diff --git a/.editorconfig b/.editorconfig index dad58944a..5e19cc2d6 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,14 +2,14 @@ # editorconfig.org root = true -[*.{cs,html,js,hbs}] +[*.{cs}] charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true indent_style = space indent_size = 4 -[*.less] +[*.{js,html,js,hbs,less,css}] charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true diff --git a/.esprintrc b/.esprintrc new file mode 100644 index 000000000..9330e00d1 --- /dev/null +++ b/.esprintrc @@ -0,0 +1,9 @@ +{ + "paths": [ + "frontend/src/**/*.js" + ], + "ignored": [ + "**/node_modules/**/*" + ], + "port": 5004 +} diff --git a/.gitattributes b/.gitattributes index 1b274cb93..e9852ad00 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,22 +1,9 @@ # Auto detect text files and perform LF normalization *text eol=lf +# Explicitly set bash scripts to have unix endings +*.sh text eol=lf + # Custom for Visual Studio *.cs diff=csharp -*.sln merge=union -*.csproj merge=union -*.vbproj merge=union -*.fsproj merge=union -*.dbproj merge=union - -# Standard to msysgit -*.doc diff=astextplain -*.DOC diff=astextplain -*.docx diff=astextplain -*.DOCX diff=astextplain -*.dot diff=astextplain -*.DOT diff=astextplain -*.pdf diff=astextplain -*.PDF diff=astextplain -*.rtf diff=astextplain -*.RTF diff=astextplain \ No newline at end of file +*.sln merge=union \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..8988a141d --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,9 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: lidarr +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +custom: # Replace with a single custom sponsorship URL diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 6bd416a38..87d054fc8 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,5 +1,41 @@ + +## Support / Questions -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) +Please use https://discord.gg/8Y7rDc9 for support. Support requests or questions will be redirected to discord and the issue will be closed. + + + +## Bug Report + +### System Information/Logs + +**Lidarr 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 + +### Description of request and 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..985b66efd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +name: Bug report +about: Create a report to help us improve + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Logs** +Link to debug logs. + +**System info (please complete the following information):** + - Lidarr Version: [e.g. 0.3.0.430] + - Operating System [e.g. iOS] + - .net Framework (Windows) or mono (macOS/Linux) Version: [e.g. 4.5 or 5.12] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md new file mode 100644 index 000000000..99bb9a009 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/custom.md @@ -0,0 +1,7 @@ +--- +name: Custom issue template +about: Describe this issue template's purpose here. + +--- + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..066b2d920 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Suggest an idea for this project + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.gitignore b/.gitignore index 8762d35b3..ed6772d69 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,10 @@ _dotCover* # DevExpress CodeRush src/.cr/ +# Emacs +*~ +\#*\# + # NCrunch *.ncrunch* .*crunch*.local.xml @@ -101,7 +105,7 @@ App_Data/*.ldf _NCrunch_* _TeamCity* -# Sonarr +# Lidarr config.xml nzbdrone.log*txt UpdateLogs/ @@ -113,15 +117,16 @@ src/UI/.idea/* *log.txt node_modules/ _output* +_artifacts _rawPackage/ _dotTrace* _tests/ *.Result.xml +coverage*.xml +coverage*.json setup/Output/ *.~is -UI.Phantom/ - #VS outout folders bin obj @@ -130,8 +135,11 @@ output/* #OS X metadata files ._* +.DS_Store _start _temp_*/**/* -src/.idea/ +## Merge any idea folder +*/**/.idea +*.iml diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index bc941f3dd..000000000 --- a/.gitmodules +++ /dev/null @@ -1,4 +0,0 @@ -[submodule "src/ExternalModules/CurlSharp"] - path = src/ExternalModules/CurlSharp - url = https://github.com/Sonarr/CurlSharp.git - branch = master diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index 02629676e..000000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -Sonarr \ No newline at end of file diff --git a/.idea/Sonarr.iml b/.idea/Sonarr.iml deleted file mode 100644 index aeec84bf6..000000000 --- a/.idea/Sonarr.iml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/codeStyleSettings.xml b/.idea/codeStyleSettings.xml deleted file mode 100644 index 7598f4c8e..000000000 --- a/.idea/codeStyleSettings.xml +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml deleted file mode 100644 index 97626ba45..000000000 --- a/.idea/encodings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/jsLibraryMappings.xml b/.idea/jsLibraryMappings.xml deleted file mode 100644 index b8387eb1b..000000000 --- a/.idea/jsLibraryMappings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/libraries/Sonarr_node_modules.xml b/.idea/libraries/Sonarr_node_modules.xml deleted file mode 100644 index 4eeebc5cc..000000000 --- a/.idea/libraries/Sonarr_node_modules.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 19f74da8e..000000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 7cc2cf51b..000000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7f4..000000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 000000000..fdd705c63 --- /dev/null +++ b/.yarnrc @@ -0,0 +1 @@ +save-prefix "" diff --git a/CLA.md b/CLA.md index 40adac7f6..463c6c14c 100644 --- a/CLA.md +++ b/CLA.md @@ -1,6 +1,6 @@ -# Sonarr Individual Contributor License Agreement # +# Lidarr Individual Contributor License Agreement # -Thank you for your interest in contributing to Sonarr ("We" or "Us"). +Thank you for your interest in contributing to Lidarr ("We" or "Us"). This contributor agreement ("Agreement") documents the rights granted by contributors to Us. To make this document effective, please complete the form below. This is a legally binding document, so please read it carefully before agreeing to it. The Agreement may cover more than one software project managed by Us. ## 1. Definitions ## diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ab945cb0c..fa439931f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # How to Contribute # -We're always looking for people to help make Sonarr even better, there are a number of ways to contribute. +We're always looking for people to help make Lidarr even better, there are a number of ways to contribute. ## Documentation ## Setup guides, FAQ, the more information we have on the wiki the better. @@ -8,31 +8,34 @@ Setup guides, FAQ, the more information we have on the wiki the better. ## Development ## ### Tools required ### -- Visual Studio 2015 -- HTML/Javascript editor of choice (Sublime Text/Webstorm/Atom/etc) -- npm (node package manager) -- git +- Visual Studio 2017 or higher (https://www.visualstudio.com/vs/). The community version is free and works (https://www.visualstudio.com/downloads/). +- HTML/Javascript editor of choice (VS Code/Sublime Text/Webstorm/Atom/etc) +- [Git](https://git-scm.com/downloads) +- [NodeJS](https://nodejs.org/en/download/) (Node 8.X.X or higher) +- [Yarn](https://yarnpkg.com/) +- .NET 4.6.2 or Mono equivalent. ### Getting started ### -1. Fork Sonarr -2. Clone (develop branch) *you may need pull in submodules separately if you client doesn't clone them automatically (CurlSharp)* -3. Run `npm install` -4. Run `npm start` - Used to compile the UI components and copy them. - Leave this window open. - If you have gulp globally installed you can use `gulp watch` instead -5. Compile in Visual Studio +1. Fork Lidarr +2. Clone the repository into your development machine. [*info*](https://help.github.com/articles/working-with-repositories) +3. Grab the submodules `git submodule init && git submodule update` +4. Install the required Node Packages `yarn install` +5. Start gulp to monitor your dev environment for any changes that need post processing using `yarn start` command. +6. Build the project in Visual Studio, Setting startup project to `NZBDrone.Console` +7. Debug the project in Visual Studio +8. Open http://localhost:8686 ### Contributing Code ### -- If you're adding a new, already requested feature, please comment on [Github Issues](https://github.com/Sonarr/Sonarr/issues "Github Issues") so work is not duplicated (If you want to add something not already on there, please talk to us first) -- Rebase from Sonarr's develop branch, don't merge +- If you're adding a new, already requested feature, please comment on [Github Issues](https://github.com/lidarr/Lidarr/issues "Github Issues") so work is not duplicated (If you want to add something not already on there, please talk to us first) +- Rebase from Lidarr's develop branch, don't merge - Make meaningful commits, or squash them - Feel free to make a pull request before work is complete, this will let us see where its at and make comments/suggest improvements -- Reach out to us on the forums or on IRC if you have any questions +- Reach out to us on the discord if you have any questions - Add tests (unit/integration) - Commit with *nix line endings for consistency (We checkout Windows and commit *nix) - One feature/bug fix per pull request to keep things clean and easy to understand -- Use 4 spaces instead of tabs, this is the default for VS 2012 and WebStorm (to my knowledge) +- Use 4 spaces instead of tabs, this is the default for VS 2017 and WebStorm (to my knowledge) ### Pull Requesting ### - Only make pull requests to develop, never master, if you make a PR to master we'll comment on it and close it diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 000000000..9cecc1d46 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + {one line to give the program's name and a brief idea of what it does.} + Copyright (C) {year} {name of author} + + 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: + + {project} Copyright (C) {year} {fullname} + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/Logo/1024.png b/Logo/1024.png index 589664f1b..f048bf77d 100644 Binary files a/Logo/1024.png and b/Logo/1024.png differ diff --git a/Logo/128.png b/Logo/128.png index b41422f1f..09682d112 100644 Binary files a/Logo/128.png and b/Logo/128.png differ diff --git a/Logo/16.png b/Logo/16.png index 970de87d5..810d2bc51 100644 Binary files a/Logo/16.png and b/Logo/16.png differ diff --git a/Logo/256.png b/Logo/256.png index 32b4f7ebc..10bff98c0 100644 Binary files a/Logo/256.png and b/Logo/256.png differ diff --git a/Logo/32.png b/Logo/32.png index 54adf351f..661067300 100644 Binary files a/Logo/32.png and b/Logo/32.png differ diff --git a/Logo/400.png b/Logo/400.png index 95cf56b7b..33bed87ef 100644 Binary files a/Logo/400.png and b/Logo/400.png differ diff --git a/Logo/48.png b/Logo/48.png index e69b68ad6..2ff9a0cb7 100644 Binary files a/Logo/48.png and b/Logo/48.png differ diff --git a/Logo/512.png b/Logo/512.png index 1ce56cd75..a93794418 100644 Binary files a/Logo/512.png and b/Logo/512.png differ diff --git a/Logo/64.png b/Logo/64.png index 06a0d4c1d..1b82050c3 100644 Binary files a/Logo/64.png and b/Logo/64.png differ diff --git a/Logo/800.png b/Logo/800.png index fe518d5f3..f802b07dd 100644 Binary files a/Logo/800.png and b/Logo/800.png differ diff --git a/Logo/96-Outline-White.png b/Logo/96-Outline-White.png new file mode 100644 index 000000000..469fe37a9 Binary files /dev/null and b/Logo/96-Outline-White.png differ diff --git a/Logo/Lidarr.svg b/Logo/Lidarr.svg new file mode 100644 index 000000000..41c5fb58a --- /dev/null +++ b/Logo/Lidarr.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/Logo/Sonarr.svg b/Logo/Sonarr.svg deleted file mode 100644 index cc8e1370e..000000000 --- a/Logo/Sonarr.svg +++ /dev/null @@ -1,240 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/README.md b/README.md index 4180ef614..48f813e0b 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,66 @@ -# Sonarr +# Lidarr -Sonarr is a PVR for Usenet and BitTorrent users. It can monitor multiple RSS feeds for new episodes of your favorite shows and will grab, sort and rename them. It can also be configured to automatically upgrade the quality of files already downloaded when a better quality format becomes available. +[![Build Status](https://dev.azure.com/Lidarr/Lidarr/_apis/build/status/lidarr.Lidarr?branchName=develop)](https://dev.azure.com/Lidarr/Lidarr/_build/latest?definitionId=1&branchName=develop) +[![Docker Pulls](https://img.shields.io/docker/pulls/linuxserver/lidarr.svg)](https://github.com/lidarr/Lidarr/wiki/Docker) +![Github Downloads](https://img.shields.io/github/downloads/lidarr/lidarr/total.svg) +[![Backers on Open Collective](https://opencollective.com/lidarr/backers/badge.svg)](#backers) [![Sponsors on Open Collective](https://opencollective.com/lidarr/sponsors/badge.svg)](#sponsors) + +Lidarr is a music collection manager for Usenet and BitTorrent users. It can monitor multiple RSS feeds for new tracks from your favorite artists and will grab, sort and rename them. It can also be configured to automatically upgrade the quality of files already downloaded when a better quality format becomes available. ## Major Features Include: * Support for major platforms: Windows, Linux, macOS, Raspberry Pi, etc. -* Automatically detects new episodes -* Can scan your existing library and download any missing episodes -* Can watch for better quality of the episodes you already have and do an automatic upgrade. *eg. from DVD to Blu-Ray* +* Automatically detects new tracks. +* Can scan your existing library and download any missing tracks. +* Can watch for better quality of the tracks you already have and do an automatic upgrade. * Automatic failed download handling will try another release if one fails * Manual search so you can pick any release or to see why a release was not downloaded automatically -* Fully configurable episode renaming +* Fully configurable track renaming * Full integration with SABnzbd and NZBGet * Full integration with Kodi, Plex (notification, library update, metadata) -* Full support for specials and multi-episode releases +* Full support for specials and multi-album releases * And a beautiful UI -## Configuring Development Environment: +## Feature Requests -### Requirements +[![Feature Requests](http://feathub.com/lidarr/Lidarr?format=svg)](http://feathub.com/lidarr/Lidarr) -* Visual Studio 2015 (https://www.visualstudio.com/vs/) -* [Git](https://git-scm.com/downloads) -* [NodeJS](https://nodejs.org/download/) +## Support -### Setup +[![Discord](https://img.shields.io/badge/discord-chat-7289DA.svg?maxAge=60)](https://discord.gg/8Y7rDc9) +[![Reddit](https://img.shields.io/badge/reddit-discussion-FF4500.svg?maxAge=60)](https://www.reddit.com/r/lidarr) +[![GitHub](https://img.shields.io/badge/github-issues-red.svg?maxAge=60)](https://github.com/Lidarr/Lidarr/issues) +[![GitHub Wiki](https://img.shields.io/badge/github-wiki-181717.svg?maxAge=60)](https://github.com/Lidarr/Lidarr/wiki) -* Make sure all the required software mentioned above are installed. -* Clone the repository into your development machine. [*info*](https://help.github.com/articles/working-with-repositories) -* Grab the submodules `git submodule init && git submodule update` -* Install the required Node Packages `npm install` -* Start gulp to monitor your dev environment for any changes that need post processing using `npm start` command. +## Contributors -*Please note gulp must be running at all times while you are working with Sonarr client source files.* +This project exists thanks to all the people who contribute. [[Contribute](CONTRIBUTING.md)]. + -### Development -* Open `NzbDrone.sln` in Visual Studio -* Make sure `NzbDrone.Console` is set as the startup project +## Backers -### License +Thank you to all our backers! 🙏 [[Become a backer](https://opencollective.com/lidarr#backer)] -* [GNU GPL v3](http://www.gnu.org/licenses/gpl.html) -* Copyright 2010-2017 + + + +## Sponsors -### Sponsors +Support this project by becoming a sponsor. Your logo will show up here with a link to your website. [[Become a sponsor](https://opencollective.com/lidarr#sponsor)] -* [JetBrains](http://www.jetbrains.com/) for providing us with free licenses to their great tools - * [ReSharper](http://www.jetbrains.com/resharper/) - * [WebStorm](http://www.jetbrains.com/webstorm/) - * [TeamCity](http://www.jetbrains.com/teamcity/) + + + + + + + + + + + +### License + +* [GNU GPL v3](http://www.gnu.org/licenses/gpl.html) +* Copyright 2010-2019 diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 000000000..a12a1e32f --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,3 @@ +skip_commits: + files: + - '**/**' \ No newline at end of file diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 000000000..10050f68d --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,494 @@ +# Starter pipeline +# Start with a minimal pipeline that you can customize to build and deploy your code. +# Add steps that build, run tests, deploy, and more: +# https://aka.ms/yaml + +variables: + outputFolder: './_output' + artifactsFolder: './_artifacts' + testsFolder: './_tests' + majorVersion: '0.7.0' + minorVersion: $[counter('minorVersion', 1076)] + lidarrVersion: '$(majorVersion).$(minorVersion)' + buildName: '$(Build.SourceBranchName).$(lidarrVersion)' + windowsInstaller: 'Lidarr.$(buildName).windows-installer.exe' + windowsZip: 'Lidarr.$(buildName).windows.zip' + macOsApp: 'Lidarr.$(buildName).osx-app.zip' + macOsTar: 'Lidarr.$(buildName).osx.tar.gz' + linuxTar: 'Lidarr.$(buildName).linux.tar.gz' + sentryOrg: 'lidarr' + +trigger: + branches: + include: + - develop + - master + +pr: +- develop + +stages: + - stage: Build_Backend + displayName: Build Backend + + jobs: + - job: Backend + strategy: + matrix: + Linux: + osName: 'Linux' + imageName: 'ubuntu-16.04' + Mac: + osName: 'Mac' + imageName: 'macos-10.13' + Windows: + osName: 'Windows' + imageName: 'vs2017-win2016' + + pool: + vmImage: $(imageName) + steps: + # Set the build name properly. The 'name' property won't recursively expand so hack here: + - powershell: Write-Host "##vso[build.updatebuildnumber]$($env:LIDARRVERSION)" + displayName: Set Build Name + - checkout: self + submodules: true + fetchDepth: 1 + - bash: ./build.sh --only-backend + displayName: Build Lidarr Backend + - publish: $(outputFolder) + artifact: '$(osName)Backend' + displayName: Publish Backend + condition: and(succeeded(), eq(variables['osName'], 'Windows')) + - publish: $(testsFolder) + artifact: '$(osName)Tests' + displayName: Publish Test Package + condition: and(succeeded(), eq(variables['osName'], 'Windows')) + + - stage: Build_Frontend + displayName: Build Frontend + dependsOn: [] + + jobs: + - job: Frontend + strategy: + matrix: + Linux: + osName: 'Linux' + imageName: 'ubuntu-16.04' + Mac: + osName: 'Mac' + imageName: 'macos-10.13' + Windows: + osName: 'Windows' + imageName: 'vs2017-win2016' + pool: + vmImage: $(imageName) + steps: + - task: NodeTool@0 + displayName: Set Node.js version + inputs: + versionSpec: '10.x' + - checkout: self + submodules: true + fetchDepth: 1 + - bash: ./build.sh --only-frontend + displayName: Build Lidarr Frontend + env: + FORCE_COLOR: 0 + - publish: $(outputFolder) + artifact: '$(osName)Frontend' + displayName: Publish Frontend + condition: and(succeeded(), eq(variables['osName'], 'Windows')) + + - stage: Package + dependsOn: + - Build_Backend + - Build_Frontend + jobs: + - job: Windows_Installer + displayName: Create Installer + pool: + vmImage: 'vs2017-win2016' + steps: + - checkout: self + fetchDepth: 1 + - task: DownloadPipelineArtifact@2 + inputs: + buildType: 'current' + artifactName: WindowsBackend + targetPath: _output + displayName: Fetch Backend + - task: DownloadPipelineArtifact@2 + inputs: + buildType: 'current' + artifactName: WindowsFrontend + targetPath: _output + displayName: Fetch Frontend + - bash: ./build.sh --only-packages + displayName: Create Packages + - bash: | + ./setup/inno/ISCC.exe "./setup/lidarr.iss" + cp ./setup/output/Lidarr.*windows.exe ${BUILD_ARTIFACTSTAGINGDIRECTORY}/${WINDOWSINSTALLER} + displayName: Create Windows installer + - publish: $(Build.ArtifactStagingDirectory) + artifact: 'WindowsInstaller' + displayName: Publish Installer + + - job: Other_Packages + displayName: Create Standard Packages + pool: + vmImage: 'ubuntu-16.04' + steps: + - bash: sudo apt install dos2unix + - checkout: self + fetchDepth: 1 + - task: DownloadPipelineArtifact@2 + inputs: + buildType: 'current' + artifactName: WindowsBackend + targetPath: _output + displayName: Fetch Backend + - task: DownloadPipelineArtifact@2 + inputs: + buildType: 'current' + artifactName: WindowsFrontend + targetPath: _output + displayName: Fetch Frontend + - bash: ./build.sh --only-packages + displayName: Create Packages + - bash: | + chmod a+x $(artifactsFolder)/macos/Lidarr/fpcalc + chmod a+x $(artifactsFolder)/macos/Lidarr/Lidarr + chmod a+x $(artifactsFolder)/macos-app/Lidarr.app/Contents/MacOS/fpcalc + chmod a+x $(artifactsFolder)/macos-app/Lidarr.app/Contents/MacOS/Lidarr + displayName: Set Mac executable bits + - task: ArchiveFiles@2 + displayName: Create Windows zip + inputs: + archiveFile: '$(Build.ArtifactStagingDirectory)/$(windowsZip)' + archiveType: 'zip' + includeRootFolder: false + rootFolderOrFile: $(artifactsFolder)/windows + - task: ArchiveFiles@2 + displayName: Create MacOS app + inputs: + archiveFile: '$(Build.ArtifactStagingDirectory)/$(macOsApp)' + archiveType: 'zip' + includeRootFolder: false + rootFolderOrFile: $(artifactsFolder)/macos-app + - task: ArchiveFiles@2 + displayName: Create MacOS tar + inputs: + archiveFile: '$(Build.ArtifactStagingDirectory)/$(macOsTar)' + archiveType: 'tar' + tarCompression: 'gz' + includeRootFolder: false + rootFolderOrFile: $(artifactsFolder)/macos + - task: ArchiveFiles@2 + displayName: Create Linux tar + inputs: + archiveFile: '$(Build.ArtifactStagingDirectory)/$(linuxTar)' + archiveType: 'tar' + tarCompression: 'gz' + includeRootFolder: false + rootFolderOrFile: $(artifactsFolder)/linux + - publish: $(Build.ArtifactStagingDirectory) + artifact: 'Packages' + displayName: Publish Packages + - bash: | + echo "Uploading source maps to sentry" + curl -sL https://sentry.io/get-cli/ | bash + RELEASENAME="${LIDARRVERSION}-${BUILD_SOURCEBRANCHNAME}" + sentry-cli releases new --finalize -p lidarr -p lidarr-ui -p lidarr-update "${RELEASENAME}" + sentry-cli releases -p lidarr-ui files "${RELEASENAME}" upload-sourcemaps _output/UI/ --rewrite + sentry-cli releases set-commits --auto "${RELEASENAME}" + if [[ ${BUILD_SOURCEBRANCHNAME} == "refs/heads/develop" ]]; then + sentry-cli releases deploys "${RELEASENAME}" new -e nightly + else + sentry-cli releases deploys "${RELEASENAME}" new -e production + fi + displayName: Publish Sentry Source Maps + condition: | + or + ( + and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/develop')), + and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/master')) + ) + env: + SENTRY_AUTH_TOKEN: $(sentryAuthToken) + SENTRY_ORG: $(sentryOrg) + + - stage: Unit_Test + displayName: Unit Tests + dependsOn: Build_Backend + condition: succeeded() + jobs: + - job: Unit + strategy: + matrix: + Linux: + osName: 'Linux' + imageName: 'ubuntu-16.04' + Mac: + osName: 'Mac' + imageName: 'macos-10.13' + Windows: + osName: 'Windows' + imageName: 'vs2017-win2016' + + pool: + vmImage: $(imageName) + + steps: + - checkout: none + - task: DownloadPipelineArtifact@2 + displayName: Download Test Artifact + inputs: + buildType: 'current' + artifactName: WindowsTests + targetPath: $(testsFolder) + - bash: | + wget https://github.com/acoustid/chromaprint/releases/download/v1.4.3/chromaprint-fpcalc-1.4.3-linux-x86_64.tar.gz + sudo tar xf chromaprint-fpcalc-1.4.3-linux-x86_64.tar.gz --strip-components=1 --directory /usr/bin + displayName: Install fpcalc + condition: and(succeeded(), eq(variables['osName'], 'Linux')) + - powershell: Set-Service SCardSvr -StartupType Manual + displayName: Enable Windows Test Service + condition: and(succeeded(), eq(variables['osName'], 'Windows')) + - bash: | + chmod a+x _tests/fpcalc + export DYLD_FALLBACK_LIBRARY_PATH=${BUILD_SOURCESDIRECTORY}/_tests + displayName: Make fpcalc Executable + condition: and(succeeded(), eq(variables['osName'], 'Mac')) + - task: Bash@3 + displayName: Run Tests + env: + DYLD_FALLBACK_LIBRARY_PATH: $(Build.SourcesDirectory)/_tests + TEST_DIR: $(Build.SourcesDirectory)/_tests + inputs: + targetType: 'filePath' + filePath: '$(testsFolder)/test.sh' + arguments: '$(osName) Unit Test' + - publish: TestResult.xml + artifact: 'TestResult' + displayName: Publish Test Result + condition: and(succeeded(), eq(variables['osName'], 'Windows')) + - task: PublishTestResults@2 + displayName: Publish Test Results + inputs: + testResultsFormat: 'NUnit' + testResultsFiles: '**/TestResult.xml' + testRunTitle: '$(osName) Unit Tests' + failTaskOnFailedTests: true + + - stage: Integration_Automation + displayName: Integration / Automation + dependsOn: Package + jobs: + + - job: Integration + strategy: + matrix: + Linux: + osName: 'Linux' + imageName: 'ubuntu-16.04' + pattern: 'Lidarr.**.linux.tar.gz' + Mac: + osName: 'Mac' + imageName: 'macos-10.13' + pattern: 'Lidarr.**.osx.tar.gz' + Windows: + osName: 'Windows' + imageName: 'vs2017-win2016' + pattern: 'Lidarr.**.windows.zip' + + pool: + vmImage: $(imageName) + + steps: + - script: | + wget https://github.com/acoustid/chromaprint/releases/download/v1.4.3/chromaprint-fpcalc-1.4.3-linux-x86_64.tar.gz + sudo tar xf chromaprint-fpcalc-1.4.3-linux-x86_64.tar.gz --strip-components=1 --directory /usr/bin + displayName: Install fpcalc + condition: and(succeeded(), eq(variables['osName'], 'Linux')) + - bash: | + SYMLINK=5_18_1 + MONOPREFIX=/Library/Frameworks/Mono.framework/Versions/$SYMLINK + echo "##vso[task.setvariable variable=DYLD_FALLBACK_LIBRARY_PATH;].:$MONOPREFIX/lib:/lib:/usr/lib:$DYLD_LIBRARY_FALLBACK_PATH" + echo "##vso[task.setvariable variable=PKG_CONFIG_PATH;]$MONOPREFIX/lib/pkgconfig:$MONOPREFIX/share/pkgconfig:$PKG_CONFIG_PATH" + echo "##vso[task.setvariable variable=PATH;]$MONOPREFIX/bin:$PATH" + displayName: Set Mono Version + condition: and(succeeded(), eq(variables['osName'], 'Mac')) + - checkout: none + - task: DownloadPipelineArtifact@2 + displayName: Download Test Artifact + inputs: + buildType: 'current' + artifactName: WindowsTests + targetPath: $(testsFolder) + - task: DownloadPipelineArtifact@2 + displayName: Download Build Artifact + inputs: + buildType: 'current' + artifactName: Packages + itemPattern: '**/$(pattern)' + targetPath: $(Build.ArtifactStagingDirectory) + - task: ExtractFiles@1 + inputs: + archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)' + destinationFolder: '$(Build.ArtifactStagingDirectory)/bin' + displayName: Extract Package + - bash: | + mkdir -p ./bin/ + cp -r -v ${BUILD_ARTIFACTSTAGINGDIRECTORY}/bin/Lidarr/. ./bin/ + displayName: Move Package Contents + - task: Bash@3 + displayName: Run Integration Tests + inputs: + targetType: 'filePath' + filePath: '$(testsFolder)/test.sh' + arguments: $(osName) Integration Test + - task: PublishTestResults@2 + inputs: + testResultsFormat: 'NUnit' + testResultsFiles: '**/TestResult.xml' + testRunTitle: '$(osName) Integration Tests' + failTaskOnFailedTests: true + displayName: Publish Test Results + + - job: Automation + strategy: + matrix: + Linux: + osName: 'Linux' + imageName: 'ubuntu-16.04' + pattern: 'Lidarr.**.linux.tar.gz' + failBuild: true + Mac: + osName: 'Mac' + imageName: 'macos-10.13' # Fails due to firefox not being installed on image + pattern: 'Lidarr.**.osx.tar.gz' + failBuild: false + Windows: + osName: 'Windows' + imageName: 'vs2017-win2016' + pattern: 'Lidarr.**.windows.zip' + failBuild: true + + pool: + vmImage: $(imageName) + + steps: + - checkout: none + - task: DownloadPipelineArtifact@2 + displayName: Download Test Artifact + inputs: + buildType: 'current' + artifactName: WindowsTests + targetPath: $(testsFolder) + - task: DownloadPipelineArtifact@2 + displayName: Download Build Artifact + inputs: + buildType: 'current' + artifactName: Packages + itemPattern: '**/$(pattern)' + targetPath: $(Build.ArtifactStagingDirectory) + - task: ExtractFiles@1 + inputs: + archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)' + destinationFolder: '$(Build.ArtifactStagingDirectory)/bin' + displayName: Extract Package + - bash: | + mkdir -p ./bin/ + cp -r -v ${BUILD_ARTIFACTSTAGINGDIRECTORY}/bin/Lidarr/. ./bin/ + displayName: Move Package Contents + - bash: | + if [[ $OSNAME == "Mac" ]]; then + url=https://github.com/mozilla/geckodriver/releases/download/v0.24.0/geckodriver-v0.24.0-macos.tar.gz + elif [[ $OSNAME == "Linux" ]]; then + url=https://github.com/mozilla/geckodriver/releases/download/v0.24.0/geckodriver-v0.24.0-linux64.tar.gz + else + echo "Unhandled OS" + exit 1 + fi + curl -s -L "$url" | tar -xz + chmod +x geckodriver + mv geckodriver _tests + displayName: Install Gecko Driver + condition: and(succeeded(), ne(variables['osName'], 'Windows')) + - bash: ls -lR + - task: Bash@3 + displayName: Run Automation Tests + inputs: + targetType: 'filePath' + filePath: '$(testsFolder)/test.sh' + arguments: $(osName) Automation Test + - task: PublishTestResults@2 + inputs: + testResultsFormat: 'NUnit' + testResultsFiles: '**/TestResult.xml' + testRunTitle: '$(osName) Automation Tests' + failTaskOnFailedTests: $(failBuild) + displayName: Publish Test Results + + - stage: Analyze + dependsOn: [] + displayName: Analyze + condition: eq(variables['system.pullrequest.isfork'], false) + + jobs: + - job: Analyze_Frontend + displayName: Frontend + pool: + vmImage: vs2017-win2016 + steps: + - checkout: self # Need history for Sonar analysis + - task: SonarCloudPrepare@1 + env: + SONAR_SCANNER_OPTS: '' + inputs: + SonarCloud: 'SonarCloud' + organization: 'lidarr' + scannerMode: 'CLI' + configMode: 'manual' + cliProjectKey: 'lidarr_Lidarr.UI' + cliProjectName: 'LidarrUI' + cliProjectVersion: '$(lidarrVersion)' + cliSources: './frontend' + - task: SonarCloudAnalyze@1 + + - job: Analyze_Backend + displayName: Backend + pool: + vmImage: vs2017-win2016 + steps: + - checkout: self # Need history for Sonar analysis + submodules: true + - task: SonarCloudPrepare@1 + inputs: + SonarCloud: 'SonarCloud' + organization: 'lidarr' + scannerMode: 'MSBuild' + projectKey: 'lidarr_Lidarr' + projectName: 'Lidarr' + projectVersion: '$(lidarrVersion)' + extraProperties: | + sonar.exclusions=**/obj/**,**/*.dll,**/NzbDrone.Core.Test/Files/**/*,./frontend/**,**/ExternalModules/**,./src/Libraries/** + sonar.coverage.exclusions=**/Lidarr.Api.V1/**/*,**/MonoTorrent/**/*,**/Marr.Data/**/* + sonar.cs.opencover.reportsPaths=$(Build.SourcesDirectory)/_tests/CoverageResults/coverage.opencover.xml + sonar.cs.nunit.reportsPaths=$(Build.SourcesDirectory)/TestResult.xml + - bash: ./build.sh --only-backend + displayName: Build Lidarr Backend + - task: Bash@3 + displayName: Coverage Unit Tests + inputs: + targetType: 'filePath' + filePath: ./test.sh + arguments: Windows Unit Coverage + - task: PublishCodeCoverageResults@1 + displayName: Publish Coverage Results + inputs: + codeCoverageTool: 'cobertura' + summaryFileLocation: './_tests/CoverageResults/coverage.cobertura.xml' + - task: SonarCloudAnalyze@1 diff --git a/build.ps1 b/build.ps1 deleted file mode 100644 index 45b8ce783..000000000 --- a/build.ps1 +++ /dev/null @@ -1 +0,0 @@ -Write-Warning "DEPRECATED -- Please use build.sh instead." \ No newline at end of file diff --git a/build.sh b/build.sh index e45c949e9..64da6b6ac 100755 --- a/build.sh +++ b/build.sh @@ -1,17 +1,25 @@ #! /bin/bash -msBuild='/c/Program Files (x86)/MSBuild/14.0/Bin' +msBuildVersion='15.0' outputFolder='./_output' -outputFolderMono='./_output_mono' -outputFolderOsx='./_output_osx' -outputFolderOsxApp='./_output_osx_app' +outputFolderLinux='./_output_linux' +outputFolderMacOS='./_output_macos' +outputFolderMacOSApp='./_output_macos_app' testPackageFolder='./_tests/' -testSearchPattern='*.Test/bin/x86/Release' sourceFolder='./src' -slnFile=$sourceFolder/NzbDrone.sln -updateFolder=$outputFolder/NzbDrone.Update -updateFolderMono=$outputFolderMono/NzbDrone.Update +slnFile=$sourceFolder/Lidarr.sln +updateFolder=$outputFolder/Lidarr.Update +updateFolderMono=$outputFolderLinux/Lidarr.Update + +#Artifact variables +artifactsFolder="./_artifacts"; +artifactsFolderWindows=$artifactsFolder/windows +artifactsFolderLinux=$artifactsFolder/linux +artifactsFolderMacOS=$artifactsFolder/macos +artifactsFolderMacOSApp=$artifactsFolder/macos-app nuget='tools/nuget/nuget.exe'; +vswhere='tools/vswhere/vswhere.exe'; + CheckExitCode() { "$@" @@ -23,12 +31,31 @@ CheckExitCode() return $status } +ProgressStart() +{ + echo "Start '$1'" +} + +ProgressEnd() +{ + echo "Finish '$1'" +} + +UpdateVersionNumber() +{ + if [ "$LIDARRVERSION" != "" ]; then + echo "Updating Version Info" + sed -i "s/[0-9.*]\+<\/AssemblyVersion>/$LIDARRVERSION<\/AssemblyVersion>/g" ./src/Directory.Build.props + sed -i "s/[\$()A-Za-z-]\+<\/AssemblyConfiguration>/${BUILD_SOURCEBRANCHNAME}<\/AssemblyConfiguration>/g" ./src/Directory.Build.props + sed -i "s/10.0.0.0<\/string>/$LIDARRVERSION<\/string>/g" ./macOS/Lidarr.app/Contents/Info.plist + fi +} + CleanFolder() { local path=$1 local keepConfigFiles=$2 - find $path -name "*.transform" -exec rm "{}" \; if [ $keepConfigFiles != true ] ; then @@ -39,9 +66,6 @@ CleanFolder() find $path -name "FluentValidation.resources.dll" -exec rm "{}" \; find $path -name "App.config" -exec rm "{}" \; - echo "Removing .less files" - find $path -name "*.less" -exec rm "{}" \; - echo "Removing vshost files" find $path -name "*.vshost.exe" -exec rm "{}" \; @@ -52,19 +76,18 @@ CleanFolder() find $path -depth -empty -type d -exec rm -r "{}" \; } - - -AddJsonNet() -{ - rm $outputFolder/Newtonsoft.Json.* - cp $sourceFolder/packages/Newtonsoft.Json.*/lib/net35/*.dll $outputFolder - cp $sourceFolder/packages/Newtonsoft.Json.*/lib/net35/*.dll $outputFolder/NzbDrone.Update -} - BuildWithMSBuild() { + installationPath=`$vswhere -latest -products \* -requires Microsoft.Component.MSBuild -property installationPath` + installationPath=${installationPath/C:\\/\/c\/} + installationPath=${installationPath//\\/\/} + msBuild="$installationPath/MSBuild/$msBuildVersion/Bin" + echo $msBuild + export PATH=$msBuild:$PATH - CheckExitCode MSBuild.exe $slnFile //t:Clean //m + CheckExitCode MSBuild.exe $slnFile //p:Configuration=Debug //p:Platform=x86 //t:Clean //m + CheckExitCode MSBuild.exe $slnFile //p:Configuration=Release //p:Platform=x86 //t:Clean //m + $nuget locals all -clear $nuget restore $slnFile CheckExitCode MSBuild.exe $slnFile //p:Configuration=Release //p:Platform=x86 //t:Build //m //p:AllowedReferenceRelatedFileExtensions=.pdb } @@ -72,16 +95,34 @@ BuildWithMSBuild() BuildWithXbuild() { export MONO_IOMAP=case - CheckExitCode xbuild /t:Clean $slnFile + CheckExitCode msbuild /p:Configuration=Debug /t:Clean $slnFile + CheckExitCode msbuild /p:Configuration=Release /t:Clean $slnFile + mono $nuget locals all -clear mono $nuget restore $slnFile - CheckExitCode xbuild /p:Configuration=Release /p:Platform=x86 /t:Build /p:AllowedReferenceRelatedFileExtensions=.pdb $slnFile + CheckExitCode msbuild /p:Configuration=Release /p:Platform=x86 /t:Build /p:AllowedReferenceRelatedFileExtensions=.pdb $slnFile +} + +LintUI() +{ + ProgressStart 'ESLint' + CheckExitCode yarn lint + ProgressEnd 'ESLint' + + ProgressStart 'Stylelint' + if [ $runtime = "dotnet" ] ; then + CheckExitCode yarn stylelint-windows + else + CheckExitCode yarn stylelint-linux + fi + ProgressEnd 'Stylelint' } Build() { - echo "##teamcity[progressStart 'Build']" + ProgressStart 'Build' rm -rf $outputFolder + rm -rf $testPackageFolder if [ $runtime = "dotnet" ] ; then BuildWithMSBuild @@ -91,157 +132,165 @@ Build() CleanFolder $outputFolder false - AddJsonNet - echo "Removing Mono.Posix.dll" rm $outputFolder/Mono.Posix.dll - echo "##teamcity[progressFinish 'Build']" + echo "Adding LICENSE.md" + cp LICENSE.md $outputFolder + + ProgressEnd 'Build' } RunGulp() { - echo "##teamcity[progressStart 'npm install']" - npm-cache install npm || CheckExitCode npm install - echo "##teamcity[progressFinish 'npm install']" + ProgressStart 'yarn install' + yarn install + #npm-cache install npm || CheckExitCode npm install --no-optional --no-bin-links + ProgressEnd 'yarn install' - echo "##teamcity[progressStart 'Running gulp']" - CheckExitCode npm run build - echo "##teamcity[progressFinish 'Running gulp']" -} + LintUI -CreateMdbs() -{ - local path=$1 - if [ $runtime = "dotnet" ] ; then - local pdbFiles=( $(find $path -name "*.pdb") ) - for filename in "${pdbFiles[@]}" - do - if [ -e ${filename%.pdb}.dll ] ; then - tools/pdb2mdb/pdb2mdb.exe ${filename%.pdb}.dll - fi - if [ -e ${filename%.pdb}.exe ] ; then - tools/pdb2mdb/pdb2mdb.exe ${filename%.pdb}.exe - fi - done - fi + ProgressStart 'Running gulp' + CheckExitCode yarn run build --production + ProgressEnd 'Running gulp' } PackageMono() { - echo "##teamcity[progressStart 'Creating Mono Package']" - rm -rf $outputFolderMono - cp -r $outputFolder $outputFolderMono + ProgressStart 'Creating Mono Package' - echo "Creating MDBs" - CreateMdbs $outputFolderMono + rm -rf $outputFolderLinux - echo "Removing PDBs" - find $outputFolderMono -name "*.pdb" -exec rm "{}" \; + echo "Copying Binaries" + cp -r $outputFolder $outputFolderLinux echo "Removing Service helpers" - rm -f $outputFolderMono/ServiceUninstall.* - rm -f $outputFolderMono/ServiceInstall.* - - echo "Removing native windows binaries Sqlite, MediaInfo" - rm -f $outputFolderMono/sqlite3.* - rm -f $outputFolderMono/MediaInfo.* + rm -f $outputFolderLinux/ServiceUninstall.* + rm -f $outputFolderLinux/ServiceInstall.* - echo "Adding NzbDrone.Core.dll.config (for dllmap)" - cp $sourceFolder/NzbDrone.Core/NzbDrone.Core.dll.config $outputFolderMono + echo "Removing native windows binaries Sqlite, fpcalc" + rm -f $outputFolderLinux/sqlite3.* + rm -f $outputFolderLinux/fpcalc* - echo "Adding CurlSharp.dll.config (for dllmap)" - cp $sourceFolder/NzbDrone.Common/CurlSharp.dll.config $outputFolderMono - - echo "Renaming NzbDrone.Console.exe to NzbDrone.exe" - rm $outputFolderMono/NzbDrone.exe* - for file in $outputFolderMono/NzbDrone.Console.exe*; do + echo "Renaming Lidarr.Console.exe to Lidarr.exe" + rm $outputFolderLinux/Lidarr.exe* + for file in $outputFolderLinux/Lidarr.Console.exe*; do mv "$file" "${file//.Console/}" done - echo "Removing NzbDrone.Windows" - rm $outputFolderMono/NzbDrone.Windows.* + echo "Removing Lidarr.Windows" + rm $outputFolderLinux/Lidarr.Windows.* - echo "Adding NzbDrone.Mono to UpdatePackage" - cp $outputFolderMono/NzbDrone.Mono.* $updateFolderMono + echo "Adding Lidarr.Mono to UpdatePackage" + cp $outputFolderLinux/Lidarr.Mono.* $updateFolderMono - echo "##teamcity[progressFinish 'Creating Mono Package']" + ProgressEnd 'Creating Mono Package' } -PackageOsx() +PackageMacOS() { - echo "##teamcity[progressStart 'Creating OS X Package']" - rm -rf $outputFolderOsx - cp -r $outputFolderMono $outputFolderOsx + ProgressStart 'Creating MacOS Package' - echo "Adding sqlite dylibs" - cp $sourceFolder/Libraries/Sqlite/*.dylib $outputFolderOsx - - echo "Adding MediaInfo dylib" - cp $sourceFolder/Libraries/MediaInfo/*.dylib $outputFolderOsx + rm -rf $outputFolderMacOS + mkdir $outputFolderMacOS echo "Adding Startup script" - cp ./osx/Sonarr $outputFolderOsx + cp ./macOS/Lidarr $outputFolderMacOS + dos2unix $outputFolderMacOS/Lidarr + + echo "Copying Binaries" + cp -r $outputFolderLinux/* $outputFolderMacOS + cp $outputFolder/fpcalc $outputFolderMacOS + + echo "Adding sqlite dylibs" + cp $sourceFolder/Libraries/Sqlite/*.dylib $outputFolderMacOS - echo "##teamcity[progressFinish 'Creating OS X Package']" + ProgressEnd 'Creating MacOS Package' } -PackageOsxApp() +PackageMacOSApp() { - echo "##teamcity[progressStart 'Creating OS X App Package']" - rm -rf $outputFolderOsxApp - mkdir $outputFolderOsxApp + ProgressStart 'Creating macOS App Package' - cp -r ./osx/Sonarr.app $outputFolderOsxApp - cp -r $outputFolderOsx $outputFolderOsxApp/Sonarr.app/Contents/MacOS + rm -rf $outputFolderMacOSApp + mkdir $outputFolderMacOSApp + cp -r ./macOS/Lidarr.app $outputFolderMacOSApp + mkdir -p $outputFolderMacOSApp/Lidarr.app/Contents/MacOS - echo "##teamcity[progressFinish 'Creating OS X App Package']" + echo "Adding Startup script" + cp ./macOS/Lidarr $outputFolderMacOSApp/Lidarr.app/Contents/MacOS + dos2unix $outputFolderMacOSApp/Lidarr.app/Contents/MacOS/Lidarr + + echo "Copying Binaries" + cp -r $outputFolderLinux/* $outputFolderMacOSApp/Lidarr.app/Contents/MacOS + cp $outputFolder/fpcalc $outputFolderMacOSApp/Lidarr.app/Contents/MacOS + + echo "Adding sqlite dylibs" + cp $sourceFolder/Libraries/Sqlite/*.dylib $outputFolderMacOSApp/Lidarr.app/Contents/MacOS + + echo "Removing Update Folder" + rm -r $outputFolderMacOSApp/Lidarr.app/Contents/MacOS/Lidarr.Update + + ProgressEnd 'Creating macOS App Package' } PackageTests() { - echo "Packaging Tests" - echo "##teamcity[progressStart 'Creating Test Package']" - rm -rf $testPackageFolder - mkdir $testPackageFolder - - find $sourceFolder -path $testSearchPattern -exec cp -r -u -T "{}" $testPackageFolder \; + ProgressStart 'Creating Test Package' if [ $runtime = "dotnet" ] ; then - $nuget install NUnit.ConsoleRunner -Version 3.2.0 -Output $testPackageFolder + $nuget install NUnit.ConsoleRunner -Version 3.10.0 -Output $testPackageFolder else - mono $nuget install NUnit.ConsoleRunner -Version 3.2.0 -Output $testPackageFolder + mono $nuget install NUnit.ConsoleRunner -Version 3.10.0 -Output $testPackageFolder fi - cp $outputFolder/*.dll $testPackageFolder - cp ./*.sh $testPackageFolder - - echo "Creating MDBs for tests" - CreateMdbs $testPackageFolder + cp ./test.sh $testPackageFolder rm -f $testPackageFolder/*.log.config CleanFolder $testPackageFolder true - echo "Adding NzbDrone.Core.dll.config (for dllmap)" - cp $sourceFolder/NzbDrone.Core/NzbDrone.Core.dll.config $testPackageFolder - - echo "Adding CurlSharp.dll.config (for dllmap)" - cp $sourceFolder/NzbDrone.Common/CurlSharp.dll.config $testPackageFolder - - echo "Copying CurlSharp libraries" - cp $sourceFolder/ExternalModules/CurlSharp/libs/i386/* $testPackageFolder + echo "Adding sqlite dylibs" + cp $sourceFolder/Libraries/Sqlite/*.dylib $testPackageFolder - echo "##teamcity[progressFinish 'Creating Test Package']" + ProgressEnd 'Creating Test Package' } CleanupWindowsPackage() { - echo "Removing NzbDrone.Mono" - rm -f $outputFolder/NzbDrone.Mono.* + ProgressStart 'Cleaning Windows Package' + + echo "Removing Lidarr.Mono" + rm -f $outputFolder/Lidarr.Mono.* + + echo "Adding Lidarr.Windows to UpdatePackage" + cp $outputFolder/Lidarr.Windows.* $updateFolder + + echo "Removing MacOS fpcalc" + rm $outputFolder/fpcalc + + ProgressEnd 'Cleaning Windows Package' +} - echo "Adding NzbDrone.Windows to UpdatePackage" - cp $outputFolder/NzbDrone.Windows.* $updateFolder +PackageArtifacts() +{ + echo "Creating Artifact Directories" + + rm -rf $artifactsFolder + mkdir $artifactsFolder + + mkdir $artifactsFolderWindows + mkdir $artifactsFolderMacOS + mkdir $artifactsFolderLinux + mkdir $artifactsFolderWindows/Lidarr + mkdir $artifactsFolderMacOS/Lidarr + mkdir $artifactsFolderLinux/Lidarr + mkdir $artifactsFolderMacOSApp + + cp -r $outputFolder/* $artifactsFolderWindows/Lidarr + cp -r $outputFolderMacOSApp/* $artifactsFolderMacOSApp + cp -r $outputFolderMacOS/* $artifactsFolderMacOS/Lidarr + cp -r $outputFolderLinux/* $artifactsFolderLinux/Lidarr } # Use mono or .net depending on OS @@ -256,10 +305,53 @@ case "$(uname -s)" in ;; esac -Build -RunGulp -PackageMono -PackageOsx -PackageOsxApp -PackageTests -CleanupWindowsPackage +POSITIONAL=() +while [[ $# -gt 0 ]] +do +key="$1" + +case $key in + --only-backend) + ONLY_BACKEND=YES + shift # past argument + ;; + --only-frontend) + ONLY_FRONTEND=YES + shift # past argument + ;; + --only-packages) + ONLY_PACKAGES=YES + shift # past argument + ;; + *) # unknown option + POSITIONAL+=("$1") # save it in an array for later + shift # past argument + ;; +esac +done +set -- "${POSITIONAL[@]}" # restore positional parameters + +# Only build backend if we haven't set only-frontend or only-packages +if [ -z "$ONLY_FRONTEND" ] && [ -z "$ONLY_PACKAGES" ]; +then + UpdateVersionNumber + Build + PackageTests +fi + +# Only build frontend if we haven't set only-backend or only-packages +if [ -z "$ONLY_BACKEND" ] && [ -z "$ONLY_PACKAGES" ]; +then + RunGulp +fi + +# Only package if we haven't set only-backend or only-frontend +if [ -z "$ONLY_BACKEND" ] && [ -z "$ONLY_FRONTEND" ]; +then + UpdateVersionNumber + PackageMono + PackageMacOS + PackageMacOSApp + CleanupWindowsPackage + PackageArtifacts +fi diff --git a/debian/control b/debian/control index ba30c02ee..34586f51d 100644 --- a/debian/control +++ b/debian/control @@ -2,11 +2,11 @@ Section: web Priority: optional Maintainer: Sonarr Source: nzbdrone -Homepage: https://sonarr.tv -Vcs-Git: git@github.com:Sonarr/Sonarr.git -Vcs-Browser: https://github.com/Sonarr/Sonarr +Homepage: https://lidarr.audio +Vcs-Git: git@github.com:lidarr/Lidarr.git +Vcs-Browser: https://github.com/lidarr/Lidarr Package: nzbdrone Architecture: all Depends: libmono-cil-dev (>= 3.2), sqlite3 (>= 3.7), mediainfo (>= 0.7.52) -Description: Sonarr is an internet PVR +Description: Lidarr is a music collection manager diff --git a/debian/copyright b/debian/copyright index 466d37fce..667d82a43 100755 --- a/debian/copyright +++ b/debian/copyright @@ -1,9 +1,9 @@ Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Upstream-Name: nzbdrone -Source: https://github.com/Sonarr/Sonarr +Source: https://github.com/lidarr/Lidarr Files: * -Copyright: 2010-2016 Sonarr +Copyright: 2010-2016 Lidarr License: GPL-3.0+ diff --git a/frontend/.csscomb.json b/frontend/.csscomb.json new file mode 100644 index 000000000..a82e49732 --- /dev/null +++ b/frontend/.csscomb.json @@ -0,0 +1,25 @@ +{ + "remove-empty-rulesets": true, + "always-semicolon": true, + "color-case": "lower", + "block-indent": " ", + "color-shorthand": false, + "element-case": "lower", + "eof-newline": true, + "leading-zero": true, + "quotes": "double", + "sort-order-fallback": "abc", + "space-before-colon": "", + "space-after-colon": " ", + "space-before-combinator": " ", + "space-after-combinator": " ", + "space-between-declarations": "\n", + "space-before-opening-brace": " ", + "space-after-opening-brace": "\n", + "space-after-selector-delimiter": " ", + "space-before-selector-delimiter": "", + "space-before-closing-brace": "\n", + "strip-spaces": true, + "tab-size": true, + "unitless-zero": false +} diff --git a/frontend/.editorconfig b/frontend/.editorconfig new file mode 100644 index 000000000..c14ef65ef --- /dev/null +++ b/frontend/.editorconfig @@ -0,0 +1,6 @@ +[*] +insert_final_newline = true + +[*.{js,css}] +indent_style = space +indent_size = 2 diff --git a/frontend/.esformatter b/frontend/.esformatter new file mode 100644 index 000000000..600bb0751 --- /dev/null +++ b/frontend/.esformatter @@ -0,0 +1,335 @@ +{ + "indent": { + "value": " ", + "FunctionExpression": 1, + "ArrayExpression": 1, + "ObjectExpression": 1 + }, + "lineBreak": { + "value": "\n", + + "before": { + "ArrayPatternClosing": 0, + "ArrayPatternComma": 0, + "ArrayPatternOpening": 0, + "ArrowFunctionExpressionArrow": 0, + "ArrowFunctionExpressionClosingBrace": ">=1", + "ArrowFunctionExpressionOpeningBrace": 0, + "AssignmentExpression": ">=1", + "AssignmentOperator": 0, + "BlockStatement": 0, + "BreakKeyword": ">=1", + "CallExpression": -1, + "CallExpressionClosingParentheses": -1, + "CallExpressionOpeningParentheses": 0, + "CatchClosingBrace": ">=1", + "CatchKeyword": 0, + "CatchOpeningBrace": 0, + "ClassDeclaration": ">=1", + "ClassDeclarationClosingBrace": ">=1", + "ClassDeclarationOpeningBrace": 0, + "ConditionalExpression": ">=1", + "DeleteOperator": ">=1", + "DoWhileStatement": ">=1", + "DoWhileStatementClosingBrace": ">=1", + "DoWhileStatementOpeningBrace": 0, + "ElseIfStatement": 0, + "ElseIfStatementClosingBrace": ">=1", + "ElseIfStatementOpeningBrace": 0, + "ElseStatement": 0, + "ElseStatementClosingBrace": ">=1", + "ElseStatementOpeningBrace": 0, + "EmptyStatement": -1, + "EndOfFile": -1, + "FinallyClosingBrace": ">=1", + "FinallyKeyword": -1, + "FinallyOpeningBrace": 0, + "ForInStatement": ">=1", + "ForInStatementClosingBrace": ">=1", + "ForInStatementExpressionClosing": 0, + "ForInStatementExpressionOpening": 0, + "ForInStatementOpeningBrace": 0, + "ForStatement": ">=1", + "ForStatementClosingBrace": ">=1", + "ForStatementExpressionClosing": "<2", + "ForStatementExpressionOpening": 0, + "ForStatementOpeningBrace": 0, + "FunctionDeclaration": ">=1", + "FunctionDeclarationClosingBrace": ">=1", + "FunctionDeclarationOpeningBrace": 0, + "FunctionExpression": 0, + "FunctionExpressionClosingBrace": 1, + "FunctionExpressionOpeningBrace":0, + "IIFEClosingParentheses": 0, + "IfStatement": ">=1", + "IfStatementClosingBrace": ">=1", + "IfStatementOpeningBrace": 0, + "LogicalExpression": -1, + "MemberExpressionClosing": 0, + "MemberExpressionOpening": 0, + "MemberExpressionPeriod": -1, + "MethodDefinition": ">=1", + "ObjectExpressionClosingBrace": "<=1", + "ObjectPatternClosingBrace": 0, + "ObjectPatternComma": 0, + "ObjectPatternOpeningBrace": 0, + "ParameterDefault": 0, + "Property": "<=2", + "PropertyValue": 0, + "ReturnStatement": -1, + "SwitchClosingBrace": ">=1", + "SwitchOpeningBrace": 0, + "ThisExpression": -1, + "ThrowStatement": ">=1", + "TryClosingBrace": ">=1", + "TryKeyword": -1, + "TryOpeningBrace": 0, + "VariableDeclaration": ">=1", + "VariableDeclarationSemiColon": 0, + "VariableDeclarationWithoutInit": ">=1", + "VariableName": ">=1", + "VariableValue": 0, + "WhileStatement": ">=1", + "WhileStatementClosingBrace": ">=1", + "WhileStatementOpeningBrace": 0 + }, + + "after": { + "ArrayPatternClosing": 0, + "ArrayPatternComma": 0, + "ArrayPatternOpening": 0, + "ArrowFunctionExpressionArrow": 0, + "ArrowFunctionExpressionClosingBrace": -1, + "ArrowFunctionExpressionOpeningBrace": ">=1", + "AssignmentExpression": ">=1", + "AssignmentOperator": 0, + "BlockStatement": 0, + "BreakKeyword": -1, + "CallExpression": -1, + "CallExpressionClosingParentheses": -1, + "CallExpressionOpeningParentheses": -1, + "CatchClosingBrace": ">=0", + "CatchKeyword": 0, + "CatchOpeningBrace": ">=1", + "ClassDeclaration": ">=1", + "ClassDeclarationClosingBrace": ">=1", + "ClassDeclarationOpeningBrace": ">=1", + "ConditionalExpression": ">=1", + "DeleteOperator": ">=1", + "DoWhileStatement": ">=1", + "DoWhileStatementClosingBrace": 0, + "DoWhileStatementOpeningBrace": ">=1", + "ElseIfStatement": ">=1", + "ElseIfStatementClosingBrace": ">=1", + "ElseIfStatementOpeningBrace": ">=1", + "ElseStatement": ">=1", + "ElseStatementClosingBrace": ">=1", + "ElseStatementOpeningBrace": ">=1", + "EmptyStatement": -1, + "FinallyClosingBrace": ">=1", + "FinallyKeyword": -1, + "FinallyOpeningBrace": ">=1", + "ForInStatement": ">=1", + "ForInStatementClosingBrace": ">=1", + "ForInStatementExpressionClosing": -1, + "ForInStatementExpressionOpening": "<2", + "ForInStatementOpeningBrace": ">=1", + "ForStatement": ">=1", + "ForStatementClosingBrace": ">=1", + "ForStatementExpressionClosing": -1, + "ForStatementExpressionOpening": "<2", + "ForStatementOpeningBrace": ">=1", + "FunctionDeclaration": ">=1", + "FunctionDeclarationClosingBrace": ">=1", + "FunctionDeclarationOpeningBrace": ">=1", + "FunctionExpression": 0, + "FunctionExpressionClosingBrace": -1, + "FunctionExpressionOpeningBrace": 1, + "IIFEOpeningParentheses": 0, + "IfStatement": ">=1", + "IfStatementClosingBrace": ">=1", + "IfStatementOpeningBrace": ">=1", + "LogicalExpression": -1, + "MemberExpressionClosing": 0, + "MemberExpressionOpening": 0, + "MemberExpressionPeriod": 0, + "MethodDefinition": ">=1", + "ObjectExpressionOpeningBrace": "<=1", + "ObjectPatternClosingBrace": 0, + "ObjectPatternComma": 0, + "ObjectPatternOpeningBrace": 0, + "ParameterDefault": 0, + "Property": -1, + "PropertyName": 0, + "ReturnStatement": -1, + "SwitchCaseColon": ">=1", + "SwitchClosingBrace": ">=1", + "SwitchOpeningBrace": ">=1", + "ThisExpression": 0, + "ThrowStatement": ">=1", + "TryClosingBrace": 0, + "TryKeyword": -1, + "TryOpeningBrace": ">=1", + "VariableDeclaration": ">=1", + "VariableDeclarationSemiColon": ">=1", + "VariableValue": -1, + "WhileStatement": ">=1", + "WhileStatementClosingBrace": ">=1", + "WhileStatementOpeningBrace": ">=1" + } + }, + "whiteSpace": { + "value": " ", + "removeTrailing": 1, + "before": { + "ArgumentComma": 0, + "ArgumentList": 0, + "ArgumentListArrayExpression": 0, + "ArgumentListFunctionExpression": 1, + "ArgumentListObjectExpression": 0, + "ArrayExpressionClosing": 0, + "ArrayExpressionComma": 0, + "ArrayExpressionOpening": 1, + "AssignmentOperator": 1, + "BinaryExpression": 0, + "BinaryExpressionOperator": 1, + "BlockComment": 1, + "CallExpression": 1, + "CatchClosingBrace": 1, + "CatchKeyword": 1, + "CatchOpeningBrace": 1, + "CatchParameterList": 0, + "CommaOperator": 0, + "ConditionalExpressionAlternate": 1, + "ConditionalExpressionConsequent": 1, + "DoWhileStatementClosingBrace": 1, + "DoWhileStatementConditional": 1, + "DoWhileStatementOpeningBrace": 1, + "ElseIfStatementClosingBrace": 1, + "ElseIfStatementOpeningBrace": 1, + "ElseStatementClosingBrace": 1, + "ElseStatementOpeningBrace": 1, + "EmptyStatement": 0, + "ExpressionClosingParentheses": 0, + "FinallyClosingBrace": 1, + "FinallyKeyword": -1, + "FinallyOpeningBrace": 1, + "ForInStatement": 1, + "ForInStatementClosingBrace": 1, + "ForInStatementExpressionClosing": 0, + "ForInStatementExpressionOpening": 1, + "ForInStatementOpeningBrace": 1, + "ForStatement": 1, + "ForStatementClosingBrace": 1, + "ForStatementExpressionClosing": 0, + "ForStatementExpressionOpening": 1, + "ForStatementOpeningBrace": 1, + "ForStatementSemicolon": 0, + "FunctionDeclarationClosingBrace": 1, + "FunctionDeclarationOpeningBrace": 1, + "FunctionExpressionClosingBrace": 1, + "FunctionExpressionOpeningBrace": 1, + "IfStatementClosingBrace": 1, + "IfStatementConditionalClosing": 0, + "IfStatementConditionalOpening": 1, + "IfStatementOpeningBrace": 1, + "LineComment": 1, + "LogicalExpressionOperator": 1, + "MemberExpressionClosing": 0, + "ObjectExpressionClosingBrace": 1, + "ParameterComma": 0, + "ParameterList": 0, + "Property": 1, + "PropertyName": 1, + "PropertyValue": 1, + "SwitchDiscriminantClosing": 0, + "SwitchDiscriminantOpening": 1, + "ThrowKeyword": 1, + "TryClosingBrace": 1, + "TryKeyword": -1, + "TryOpeningBrace": 1, + "UnaryExpressionOperator": 0, + "VariableName": 1, + "VariableValue": 1, + "WhileStatementClosingBrace": 1, + "WhileStatementConditionalClosing": 0, + "WhileStatementConditionalOpening": 1, + "WhileStatementOpeningBrace": 1 + }, + "after": { + "ArgumentComma": 1, + "ArgumentList": 0, + "ArgumentListArrayExpression": 1, + "ArgumentListFunctionExpression": 1, + "ArgumentListObjectExpression": 0, + "ArrayExpressionClosing": 0, + "ArrayExpressionComma": 1, + "ArrayExpressionOpening": 0, + "AssignmentOperator": 1, + "BinaryExpression": 0, + "BinaryExpressionOperator": 1, + "BlockComment": 1, + "CallExpression": 0, + "CatchClosingBrace": 1, + "CatchKeyword": 1, + "CatchOpeningBrace": 1, + "CatchParameterList": 0, + "CommaOperator": 1, + "ConditionalExpressionConsequent": 1, + "ConditionalExpressionTest": 1, + "DoWhileStatementBody": 1, + "DoWhileStatementClosingBrace": 1, + "DoWhileStatementOpeningBrace": 1, + "ElseIfStatementClosingBrace": 1, + "ElseIfStatementOpeningBrace": 1, + "ElseStatementClosingBrace": 1, + "ElseStatementOpeningBrace": 1, + "EmptyStatement": 0, + "ExpressionOpeningParentheses": 0, + "FinallyClosingBrace": 1, + "FinallyKeyword": -1, + "FinallyOpeningBrace": 1, + "ForInStatement": 1, + "ForInStatementClosingBrace": 1, + "ForInStatementExpressionClosing": 1, + "ForInStatementExpressionOpening": 0, + "ForInStatementOpeningBrace": 1, + "ForStatement": 1, + "ForStatementClosingBrace": 1, + "ForStatementExpressionClosing": 1, + "ForStatementExpressionOpening": 0, + "ForStatementOpeningBrace": 1, + "ForStatementSemicolon": 1, + "FunctionDeclarationClosingBrace": 0, + "FunctionDeclarationOpeningBrace": 0, + "FunctionExpressionClosingBrace": 0, + "FunctionExpressionOpeningBrace": 0, + "FunctionName": 0, + "FunctionReservedWord": 0, + "IfStatementClosingBrace": 1, + "IfStatementConditionalClosing": 0, + "IfStatementConditionalOpening": 0, + "IfStatementOpeningBrace": 1, + "LogicalExpressionOperator": 1, + "MemberExpressionOpening": 0, + "ObjectExpressionClosingBrace": 0, + "ObjectExpressionOpeningBrace": 1, + "ParameterComma": 1, + "ParameterList": 0, + "PropertyName": 0, + "PropertyValue": 0, + "SwitchDiscriminantClosing": 1, + "SwitchDiscriminantOpening": 0, + "ThrowKeyword": 1, + "TryClosingBrace": 1, + "TryKeyword": -1, + "TryOpeningBrace": 1, + "UnaryExpressionOperator": 0, + "VariableName": 1, + "WhileStatementClosingBrace": 1, + "WhileStatementConditionalClosing": 1, + "WhileStatementConditionalOpening": 0, + "WhileStatementOpeningBrace": 1 + } + } +} diff --git a/frontend/.eslintignore b/frontend/.eslintignore new file mode 100644 index 000000000..d4b43f836 --- /dev/null +++ b/frontend/.eslintignore @@ -0,0 +1 @@ +**/JsLibraries/** diff --git a/frontend/.eslintrc b/frontend/.eslintrc new file mode 100644 index 000000000..8593e9f61 --- /dev/null +++ b/frontend/.eslintrc @@ -0,0 +1,293 @@ +{ + "parser": "babel-eslint", + + "env": { + "browser": true, + "commonjs": true, + "node": true, + "es6": true + }, + + "globals": { + "expect": false, + "chai": false, + "sinon": false + }, + + "parserOptions": { + "ecmaVersion": 6, + "sourceType": "module", + "ecmaFeatures": { + "modules": true, + "impliedStrict": true + } + }, + + "plugins": [ + "filenames", + "react" + ], + + "settings": { + "react": { + "version": "detect" + } + }, + + "rules": { + "filenames/match-exported": ["error"], + + # ECMAScript 6 + + "arrow-body-style": [0], + "arrow-parens": ["error", "always"], + "arrow-spacing": ["error", { "before": true, "after": true }], + "constructor-super": "error", + "generator-star-spacing": "off", + "no-class-assign": "error", + "no-confusing-arrow": "error", + "no-const-assign": "error", + "no-dupe-class-members": "error", + "no-duplicate-imports": "error", + "no-new-symbol": "error", + "no-this-before-super": "error", + "no-useless-escape": "error", + "no-useless-computed-key": "error", + "no-useless-constructor": "error", + "no-var": "warn", + "object-shorthand": ["error", "properties"], + "prefer-arrow-callback": "error", + "prefer-const": "warn", + "prefer-reflect": "off", + "prefer-rest-params": "off", + "prefer-spread": "warn", + "prefer-template": "error", + "require-yield": "off", + "template-curly-spacing": ["error", "never"], + "yield-star-spacing": "off", + + # Possible Errors + + "comma-dangle": "error", + "no-cond-assign": "error", + "no-console": "off", + "no-constant-condition": "warn", + "no-control-regex": "error", + "no-debugger": "off", + "no-dupe-args": "error", + "no-dupe-keys": "error", + "no-duplicate-case": "error", + "no-empty": "warn", + "no-empty-character-class": "error", + "no-ex-assign": "error", + "no-extra-boolean-cast": "error", + "no-extra-parens": ["error", "functions"], + "no-extra-semi": "error", + "no-func-assign": "error", + "no-inner-declarations": "error", + "no-invalid-regexp": "error", + "no-irregular-whitespace": "error", + "no-negated-in-lhs": "error", + "no-obj-calls": "error", + "no-regex-spaces": "error", + "no-sparse-arrays": "error", + "no-unexpected-multiline": "error", + "no-unreachable": "warn", + "no-unsafe-finally": "error", + "use-isnan": "error", + "valid-jsdoc": "off", + "valid-typeof": "error", + + # Best Practices + + "accessor-pairs": "off", + "array-callback-return": "warn", + "block-scoped-var": "warn", + "consistent-return": "off", + "curly": "error", + "default-case": "error", + "dot-location": ["error", "property"], + "dot-notation": "error", + "eqeqeq": ["error", "smart"], + "guard-for-in": "error", + "no-alert": "warn", + "no-caller": "error", + "no-case-declarations": "error", + "no-div-regex": "error", + "no-else-return": "error", + "no-empty-function": ["error", {"allow": ["arrowFunctions"]}], + "no-empty-pattern": "error", + "no-eval": "error", + "no-extend-native": "error", + "no-extra-bind": "error", + "no-fallthrough": "error", + "no-floating-decimal": "error", + "no-implicit-coercion": ["error", { + "boolean": false, + "number": true, + "string": true, + "allow": [/* "!!", "~", "*", "+" */] + }], + "no-implicit-globals": "error", + "no-implied-eval": "error", + "no-invalid-this": "off", + "no-iterator": "error", + "no-labels": "error", + "no-lone-blocks": "error", + "no-loop-func": "error", + "no-magic-numbers": ["off", {"ignoreArrayIndexes": true, "ignore": [0, 1] }], + "no-multi-spaces": "error", + "no-multi-str": "error", + "no-native-reassign": ["error", {"exceptions": ["console"]}], + "no-new": "off", + "no-new-func": "error", + "no-new-wrappers": "error", + "no-octal": "error", + "no-octal-escape": "error", + "no-param-reassign": "off", + "no-process-env": "off", + "no-proto": "error", + "no-redeclare": "error", + "no-return-assign": "warn", + "no-script-url": "error", + "no-self-assign": "error", + "no-self-compare": "error", + "no-sequences": "error", + "no-throw-literal": "error", + "no-unmodified-loop-condition": "error", + "no-unused-expressions": "error", + "no-unused-labels": "error", + "no-useless-call": "error", + "no-useless-concat": "error", + "no-void": "error", + "no-warning-comments": "off", + "no-with": "error", + "radix": ["error", "as-needed"], + "vars-on-top": "off", + "wrap-iife": ["error", "inside"], + "yoda": "error", + + # Strict Mode + + "strict": ["error", "never"], + + # Variables + + "init-declarations": ["error", "always"], + "no-catch-shadow": "error", + "no-delete-var": "error", + "no-label-var": "error", + "no-restricted-globals": "off", + "no-shadow": "error", + "no-shadow-restricted-names": "error", + "no-undef": "error", + "no-undef-init": "off", + "no-undefined": "off", + "no-unused-vars": ["error", { "args": "none", "ignoreRestSiblings": true }], + "no-use-before-define": "error", + + # Node.js and CommonJS + + "callback-return": "warn", + "global-require": "error", + "handle-callback-err": "warn", + "no-mixed-requires": "error", + "no-new-require": "error", + "no-path-concat": "error", + "no-process-exit": "error", + + # Stylistic Issues + + "array-bracket-spacing": ["error", "never"], + "block-spacing": ["error", "always"], + "brace-style": ["error", "1tbs", { "allowSingleLine": false }], + "camelcase": "off", + "comma-spacing": ["error", {"before": false, "after": true}], + "comma-style": ["error", "last"], + "computed-property-spacing": ["error", "never"], + "consistent-this": ["error", "self"], + "eol-last": "error", + "func-names": "off", + "func-style": ["error", "declaration"], + "indent": ["error", 2, {"SwitchCase": 1}], + "key-spacing": ["error", {"beforeColon": false, "afterColon": true}], + "keyword-spacing": ["error", { "before": true, "after": true}], + "lines-around-comment": ["error", { "beforeBlockComment": true, "afterBlockComment": false }], + "max-depth": ["error", {"maximum": 5}], + "max-nested-callbacks": ["error", 4], + "max-statements": "off", + "max-statements-per-line": ["error", { "max": 1 }], + "new-cap": ["error", {"capIsNewExceptions": ["$.Deferred", "DragDropContext", "DragLayer", "DragSource", "DropTarget"]}], + "new-parens": "error", + "newline-after-var": "off", + "newline-before-return": "off", + "newline-per-chained-call": "off", + "no-array-constructor": "error", + "no-bitwise": "error", + "no-continue": "error", + "no-inline-comments": "off", + "no-lonely-if": "warn", + "no-mixed-spaces-and-tabs": "error", + "no-multiple-empty-lines": ["error", { "max": 1 }], + "no-negated-condition": "warn", + "no-nested-ternary": "error", + "no-new-object": "error", + "no-plusplus": "off", + "no-restricted-syntax": "off", + "no-spaced-func": "error", + "no-ternary": "off", + "no-trailing-spaces": "error", + "no-underscore-dangle": ["error", { "allowAfterThis": true }], + "no-unneeded-ternary": "error", + "no-whitespace-before-property": "error", + "object-curly-spacing": ["error", "always"], + "one-var": ["error", "never"], + "one-var-declaration-per-line": ["error", "always"], + "operator-assignment": ["off", "never"], + "operator-linebreak": ["error", "after"], + "quote-props": ["error", "as-needed"], + "quotes": ["error", "single"], + "require-jsdoc": "off", + "semi": "error", + "semi-spacing": ["error", { "before": false, "after": true }], + "sort-vars": "off", + "space-before-blocks": ["error", "always"], + "space-before-function-paren": ["error", "never"], + "space-in-parens": "off", + "space-infix-ops": "off", + "space-unary-ops": "off", + "spaced-comment": "error", + "wrap-regex": "error", + + # React + + "react/jsx-boolean-value": [2, "always"], + "react/jsx-uses-vars": 2, + "react/jsx-closing-bracket-location": 2, + "react/jsx-tag-spacing": ["error"], + "react/jsx-curly-spacing": [2, "never"], + "react/jsx-equals-spacing": [2, "never"], + "react/jsx-indent-props": [2, 2], + "react/jsx-indent": [2, 2], + "react/jsx-key": 2, + "react/jsx-no-bind": [2, { "allowArrowFunctions": true }], + "react/jsx-no-duplicate-props": [2, { "ignoreCase": true }], + "react/jsx-max-props-per-line": [2, { "maximum": 2 }], + "react/jsx-handler-names": [2, { "eventHandlerPrefix": "(on|dispatch)", "eventHandlerPropPrefix": "on" }], + "react/jsx-no-undef": 2, + "react/jsx-pascal-case": 2, + "react/jsx-uses-react": 2, + // Explicitly disabled in case we want to enable them again + "react/no-did-mount-set-state": 0, + "react/no-did-update-set-state": 0, + "react/no-direct-mutation-state": 2, + "react/no-multi-comp": [2, { "ignoreStateless": true }], + "react/no-unknown-property": 2, + "react/prefer-es6-class": 2, + "react/prop-types": 2, + "react/react-in-jsx-scope": 2, + "react/self-closing-comp": 2, + "react/sort-comp": 2, + "react/jsx-wrap-multilines": 2 + } +} diff --git a/frontend/.jsbeautifyrc b/frontend/.jsbeautifyrc new file mode 100644 index 000000000..50aa6aa29 --- /dev/null +++ b/frontend/.jsbeautifyrc @@ -0,0 +1,12 @@ +{ + "js": { + "indent_size": 2, + "indent_char": " ", + "indent_level": 2, + "indent_with_tabs": false, + "preserve_newlines": true, + "brace_style": "collapse", + "max_preserve_newlines": 2, + "jslint_happy": true + } +} \ No newline at end of file diff --git a/frontend/.stylelintrc b/frontend/.stylelintrc new file mode 100644 index 000000000..5587e5d4d --- /dev/null +++ b/frontend/.stylelintrc @@ -0,0 +1,396 @@ +{ +"plugins": [ + "stylelint-order" +], +"ignoreFiles": [ + "frontend/src/Styles/scaffolding.css", + "**/*.js" +], +"rules": { + "at-rule-empty-line-before": [ + "always", + { + "except": [ + "inside-block" + ] + } + ], + "at-rule-name-case": "lower", + "at-rule-name-newline-after": "always-multi-line", + "at-rule-name-space-after": "always", + "at-rule-no-unknown": [ + true, + { + "ignoreAtRules": [ + "/^add\\-mixin$/", + "/^define\\-mixin$/" + ] + } + ], + "at-rule-no-vendor-prefix": true, + "at-rule-semicolon-newline-after": "always", + "at-rule-semicolon-space-before": "never", + "block-closing-brace-empty-line-before": "never", + "block-closing-brace-newline-after": "always", + "block-closing-brace-newline-before": "always", + "block-closing-brace-space-after": "always-single-line", + "block-closing-brace-space-before": "always-single-line", + "block-no-empty": true, + "block-opening-brace-newline-after": "always", + "block-opening-brace-newline-before": "never-single-line", + "block-opening-brace-space-after": "always-single-line", + "block-opening-brace-space-before": "always", + "color-hex-case": "lower", + "color-hex-length": "short", + "color-named": "never", + "color-no-invalid-hex": true, + "comment-whitespace-inside": "always", + "declaration-bang-space-after": "never", + "declaration-bang-space-before": "always", + "declaration-block-no-duplicate-properties": [ + true, + { + "ignoreProperties": [ + "composes" + ] + } + ], + "declaration-block-no-redundant-longhand-properties": true, + "declaration-block-no-shorthand-property-overrides": true, + "declaration-block-semicolon-newline-after": "always", + "declaration-block-semicolon-newline-before": "never-multi-line", + "declaration-block-semicolon-space-before": "never", + "declaration-block-single-line-max-declarations": 1, + "declaration-block-trailing-semicolon": "always", + "declaration-colon-space-after": "always", + "declaration-colon-space-before": "never", + "font-family-name-quotes": "always-unless-keyword", + "function-calc-no-unspaced-operator": true, + "function-comma-newline-after": "never-multi-line", + "function-comma-newline-before": "never-multi-line", + "function-comma-space-after": "always", + "function-comma-space-before": "never", + "function-linear-gradient-no-nonstandard-direction": true, + "function-name-case": "lower", + "function-parentheses-newline-inside": "never-multi-line", + "function-parentheses-space-inside": "never", + "function-url-quotes": "always", + "function-url-scheme-blacklist": [ + "data" + ], + "function-whitespace-after": "always", + "indentation": 2, + "keyframe-declaration-no-important": true, + "length-zero-no-unit": true, + "max-empty-lines": 1, + "max-line-length": [ + 100, + { + "ignore": [ + "non-comments" + ] + } + ], + "max-nesting-depth": 2, + "media-feature-colon-space-after": "always", + "media-feature-colon-space-before": "never", + "media-feature-name-case": "lower", + "media-feature-name-no-vendor-prefix": true, + "media-feature-range-operator-space-after": "always", + "media-feature-range-operator-space-before": "always", + "no-empty-source": true, + "no-eol-whitespace": true, + "no-extra-semicolons": true, + "no-invalid-double-slash-comments": true, + "no-missing-end-of-source-newline": true, + "number-leading-zero": "always", + "number-no-trailing-zeros": true, + "order/order": [ + "custom-properties", + "dollar-variables", + { + "hasBlock": false, + "name": "add-mixin", + "type": "at-rule" + }, + "declarations", + "rules", + "at-rules" + ], + "order/properties-order": [ + { + "emptyLineBefore": "always", + "properties": [ + "composes" + ] + }, + { + "emptyLineBefore": "always", + "properties": [ + "position", + "top", + "right", + "bottom", + "left", + "z-index", + "display", + "visibility", + "align-content", + "align-items", + "align-self", + "justify-content", + "flex", + "flex-direction", + "flex-order", + "flex-pack", + "flex-align", + "flex-grow", + "flex-shrink", + "flex-basis", + "flex-wrap", + "flex-flow", + "float", + "clear", + "overflow", + "overflow-x", + "overflow-y", + "-webkit-overflow-scrolling", + "clip", + "box-sizing", + "margin", + "margin-top", + "margin-right", + "margin-bottom", + "margin-left", + "padding", + "padding-top", + "padding-right", + "padding-bottom", + "padding-left", + "min-width", + "min-height", + "max-width", + "max-height", + "width", + "height", + "outline", + "outline-width", + "outline-style", + "outline-color", + "outline-offset", + "border", + "border-spacing", + "border-collapse", + "border-width", + "border-style", + "border-color", + "border-top", + "border-top-width", + "border-top-style", + "border-top-color", + "border-right", + "border-right-width", + "border-right-style", + "border-right-color", + "border-bottom", + "border-bottom-width", + "border-bottom-style", + "border-bottom-color", + "border-left", + "border-left-width", + "border-left-style", + "border-left-color", + "border-radius", + "border-top-left-radius", + "border-top-right-radius", + "border-bottom-right-radius", + "border-bottom-left-radius", + "border-image", + "border-image-source", + "border-image-slice", + "border-image-width", + "border-image-outset", + "border-image-repeat", + "border-top-image", + "border-right-image", + "border-bottom-image", + "border-left-image", + "border-corner-image", + "border-top-left-image", + "border-top-right-image", + "border-bottom-right-image", + "border-bottom-left-image", + "background", + "background-color", + "background-image", + "background-attachment", + "background-position", + "background-position-x", + "background-position-y", + "background-clip", + "background-origin", + "background-size", + "background-repeat", + "box-decoration-break", + "box-shadow", + "color", + "table-layout", + "caption-side", + "empty-cells", + "list-style", + "list-style-position", + "list-style-type", + "list-style-image", + "quotes", + "content", + "counter-increment", + "counter-reset", + "-ms-writing-mode", + "vertical-align", + "text-align", + "text-align-last", + "text-decoration", + "text-emphasis", + "text-emphasis-position", + "text-emphasis-style", + "text-emphasis-color", + "text-indent", + "text-justify", + "text-outline", + "text-transform", + "text-wrap", + "text-overflow", + "text-overflow-ellipsis", + "text-overflow-mode", + "text-shadow", + "white-space", + "word-spacing", + "word-wrap", + "word-break", + "tab-size", + "hyphens", + "letter-spacing", + "font", + "font-weight", + "font-style", + "font-variant", + "font-size-adjust", + "font-stretch", + "font-size", + "font-family", + "font-smoothing", + "-moz-osx-font-smoothing", + "-webkit-font-smoothing", + "src", + "line-height", + "opacity", + "filter", + "resize", + "cursor", + "appearance", + "nav-index", + "nav-up", + "nav-right", + "nav-down", + "nav-left", + "transition", + "transition-delay", + "transition-timing-function", + "transition-duration", + "transition-property", + "transform", + "transform-origin", + "transform-style", + "backface-visibility", + "animation", + "animation-name", + "animation-duration", + "animation-play-state", + "animation-timing-function", + "animation-delay", + "animation-iteration-count", + "animation-direction", + "animation-fill-mode", + "pointer-events", + "user-select", + "touch-action", + "-webkit-tap-highlight-color", + "unicode-bidi", + "direction", + "columns", + "column-span", + "column-width", + "column-count", + "column-fill", + "column-gap", + "column-rule", + "column-rule-width", + "column-rule-style", + "column-rule-color", + "break-before", + "break-inside", + "break-after", + "page-break-before", + "page-break-inside", + "page-break-after", + "orphans", + "widows", + "zoom", + "max-zoom", + "min-zoom", + "user-zoom", + "orientation" + ] + } + ], + "property-case": "lower", + "property-no-vendor-prefix": true, + "rule-empty-line-before": [ + "always", + { + "except": [ + "first-nested" + ], + "ignore": [ + "after-comment" + ] + } + ], + "selector-attribute-brackets-space-inside": "never", + "selector-attribute-operator-space-after": "never", + "selector-attribute-operator-space-before": "never", + "selector-attribute-quotes": "never", + "selector-class-pattern": "^[A-Za-z0-9]+$", + "selector-combinator-space-after": "always", + "selector-combinator-space-before": "always", + "selector-descendant-combinator-no-non-space": true, + "selector-list-comma-newline-after": "always", + "selector-list-comma-newline-before": "never-multi-line", + "selector-list-comma-space-before": "never", + "selector-max-attribute": 0, + "selector-max-class": 3, + "selector-max-compound-selectors": 3, + "selector-max-empty-lines": 0, + "selector-max-id": 0, + "selector-max-universal": 0, + "selector-pseudo-class-case": "lower", + "selector-pseudo-class-parentheses-space-inside": "never", + "selector-pseudo-element-case": "lower", + "selector-pseudo-element-colon-notation": "double", + "selector-pseudo-element-no-unknown": true, + "selector-type-case": "lower", + "selector-type-no-unknown": true, + "shorthand-property-no-redundant-values": true, + "string-no-newline": true, + "string-quotes": "single", + "time-min-milliseconds": 100, + "unit-case": "lower", + "unit-no-unknown": true, + "value-list-comma-newline-after": "never-multi-line", + "value-list-comma-newline-before": "never-multi-line", + "value-list-comma-space-after": "always", + "value-list-comma-space-before": "never", + "value-list-max-empty-lines": 0, + "value-no-vendor-prefix": true + } +} diff --git a/frontend/.tern-project b/frontend/.tern-project new file mode 100644 index 000000000..aa9d76407 --- /dev/null +++ b/frontend/.tern-project @@ -0,0 +1,7 @@ +{ + "ecmaVersion": 6, + "libs": [ + "browser", + "jquery" + ] +} diff --git a/frontend/babel.config.js b/frontend/babel.config.js new file mode 100644 index 000000000..fe855af63 --- /dev/null +++ b/frontend/babel.config.js @@ -0,0 +1,35 @@ +const loose = true; + +module.exports = { + plugins: [ + // Stage 1 + '@babel/plugin-proposal-export-default-from', + ['@babel/plugin-proposal-optional-chaining', { loose }], + ['@babel/plugin-proposal-nullish-coalescing-operator', { loose }], + + // Stage 2 + '@babel/plugin-proposal-export-namespace-from', + + // Stage 3 + ['@babel/plugin-proposal-class-properties', { loose }], + '@babel/plugin-syntax-dynamic-import' + ], + env: { + development: { + presets: [ + ['@babel/preset-react', { development: true }] + ], + plugins: [ + 'babel-plugin-inline-classnames' + ] + }, + production: { + presets: [ + '@babel/preset-react' + ], + plugins: [ + 'babel-plugin-transform-react-remove-prop-types' + ] + } + } +}; diff --git a/frontend/gulp/build.js b/frontend/gulp/build.js new file mode 100644 index 000000000..de2da698f --- /dev/null +++ b/frontend/gulp/build.js @@ -0,0 +1,18 @@ +const gulp = require('gulp'); + +require('./clean'); +require('./copy'); +require('./webpack'); + +gulp.task('build', + gulp.series('clean', + gulp.parallel( + 'webpack', + 'copyHtml', + 'copyFonts', + 'copyImages', + 'copyJs' + ) + ) +); + diff --git a/frontend/gulp/clean.js b/frontend/gulp/clean.js new file mode 100644 index 000000000..ac2e4026f --- /dev/null +++ b/frontend/gulp/clean.js @@ -0,0 +1,8 @@ +const gulp = require('gulp'); +const del = require('del'); + +const paths = require('./helpers/paths'); + +gulp.task('clean', () => { + return del([paths.dest.root]); +}); diff --git a/frontend/gulp/copy.js b/frontend/gulp/copy.js new file mode 100644 index 000000000..8d58ac4a4 --- /dev/null +++ b/frontend/gulp/copy.js @@ -0,0 +1,45 @@ +const path = require('path'); +const gulp = require('gulp'); +const print = require('gulp-print').default; +const cache = require('gulp-cached'); +const livereload = require('gulp-livereload'); +const paths = require('./helpers/paths.js'); + +gulp.task('copyJs', () => { + return gulp.src( + [ + path.join(paths.src.root, 'polyfills.js') + ], { base: paths.src.root }) + .pipe(cache('copyJs')) + .pipe(print()) + .pipe(gulp.dest(paths.dest.root)) + .pipe(livereload()); +}); + +gulp.task('copyHtml', () => { + return gulp.src(paths.src.html, { base: paths.src.root }) + .pipe(cache('copyHtml')) + .pipe(print()) + .pipe(gulp.dest(paths.dest.root)) + .pipe(livereload()); +}); + +gulp.task('copyFonts', () => { + return gulp.src( + path.join(paths.src.fonts, '**', '*.*'), { base: paths.src.root } + ) + .pipe(cache('copyFonts')) + .pipe(print()) + .pipe(gulp.dest(paths.dest.root)) + .pipe(livereload()); +}); + +gulp.task('copyImages', () => { + return gulp.src( + path.join(paths.src.images, '**', '*.*'), { base: paths.src.root } + ) + .pipe(cache('copyImages')) + .pipe(print()) + .pipe(gulp.dest(paths.dest.root)) + .pipe(livereload()); +}); diff --git a/frontend/gulp/gulpFile.js b/frontend/gulp/gulpFile.js new file mode 100644 index 000000000..64f14f654 --- /dev/null +++ b/frontend/gulp/gulpFile.js @@ -0,0 +1,5 @@ +require('./build.js'); +require('./clean.js'); +require('./copy.js'); +require('./watch.js'); +require('./webpack.js'); diff --git a/frontend/gulp/helpers/errorHandler.js b/frontend/gulp/helpers/errorHandler.js new file mode 100644 index 000000000..9c542398d --- /dev/null +++ b/frontend/gulp/helpers/errorHandler.js @@ -0,0 +1,6 @@ +const colors = require('ansi-colors'); + +module.exports = function errorHandler(error) { + console.log(colors.red(`Error (${error.plugin}): ${error.message}`)); + this.emit('end'); +}; diff --git a/frontend/gulp/helpers/paths.js b/frontend/gulp/helpers/paths.js new file mode 100644 index 000000000..8707faec4 --- /dev/null +++ b/frontend/gulp/helpers/paths.js @@ -0,0 +1,23 @@ +const root = './frontend/src'; + +const paths = { + src: { + root, + html: `${root}/*.html`, + scripts: `${root}/**/*.js`, + content: `${root}/Content/`, + fonts: `${root}/Content/Fonts/`, + images: `${root}/Content/Images/`, + exclude: { + libs: `!${root}/JsLibraries/**` + } + }, + dest: { + root: './_output/UI/', + content: './_output/UI/Content/', + fonts: './_output/UI/Content/Fonts/', + images: './_output/UI/Content/Images/' + } +}; + +module.exports = paths; diff --git a/frontend/gulp/watch.js b/frontend/gulp/watch.js new file mode 100644 index 000000000..f83a4bba4 --- /dev/null +++ b/frontend/gulp/watch.js @@ -0,0 +1,18 @@ +const gulp = require('gulp'); +const livereload = require('gulp-livereload'); +const gulpWatch = require('gulp-watch'); +const paths = require('./helpers/paths.js'); + +require('./copy.js'); +require('./webpack.js'); + +function watch() { + livereload.listen({ start: true }); + + gulp.task('webpackWatch')(); + gulpWatch(paths.src.html, gulp.series('copyHtml')); + gulpWatch(`${paths.src.fonts}**/*.*`, gulp.series('copyFonts')); + gulpWatch(`${paths.src.images}**/*.*`, gulp.series('copyImages')); +} + +gulp.task('watch', gulp.series('build', watch)); diff --git a/frontend/gulp/webpack.js b/frontend/gulp/webpack.js new file mode 100644 index 000000000..bbef74e58 --- /dev/null +++ b/frontend/gulp/webpack.js @@ -0,0 +1,202 @@ +const gulp = require('gulp'); +const webpackStream = require('webpack-stream'); +const livereload = require('gulp-livereload'); +const path = require('path'); +const webpack = require('webpack'); +const errorHandler = require('./helpers/errorHandler'); +const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin'); +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); + +const uiFolder = 'UI'; +const frontendFolder = path.join(__dirname, '..'); +const srcFolder = path.join(frontendFolder, 'src'); +const isProduction = process.argv.indexOf('--production') > -1; + +console.log('Source Folder:', srcFolder); +console.log('isProduction:', isProduction); + +const cssVarsFiles = [ + '../src/Styles/Variables/colors', + '../src/Styles/Variables/dimensions', + '../src/Styles/Variables/fonts', + '../src/Styles/Variables/animations', + '../src/Styles/Variables/zIndexes' +].map(require.resolve); + +const plugins = [ + new OptimizeCssAssetsPlugin({}), + + new webpack.DefinePlugin({ + __DEV__: !isProduction, + 'process.env.NODE_ENV': isProduction ? JSON.stringify('production') : JSON.stringify('development') + }), + + new MiniCssExtractPlugin({ + filename: path.join('_output', uiFolder, 'Content', 'styles.css') + }) +]; + +const config = { + mode: isProduction ? 'production' : 'development', + devtool: '#source-map', + + stats: { + children: false + }, + + watchOptions: { + ignored: /node_modules/ + }, + + entry: { + preload: 'preload.js', + vendor: 'vendor.js', + index: 'index.js' + }, + + resolve: { + modules: [ + srcFolder, + path.join(srcFolder, 'Shims'), + 'node_modules' + ], + alias: { + jquery: 'jquery/src/jquery' + } + }, + + output: { + filename: path.join('_output', uiFolder, '[name].js'), + sourceMapFilename: '[file].map' + }, + + optimization: { + chunkIds: 'named' + }, + + plugins, + + resolveLoader: { + modules: [ + 'node_modules', + 'frontend/gulp/webpack/' + ] + }, + + module: { + rules: [ + { + test: /\.js?$/, + exclude: /(node_modules|JsLibraries)/, + use: [ + { + loader: 'babel-loader', + options: { + configFile: `${frontendFolder}/babel.config.js`, + envName: isProduction ? 'production' : 'development', + presets: [ + [ + '@babel/preset-env', + { + modules: false, + loose: true, + debug: false, + useBuiltIns: 'entry', + corejs: 3 + } + ] + ] + } + } + ] + }, + + // CSS Modules + { + test: /\.css$/, + exclude: /(node_modules|globals.css)/, + use: [ + { loader: MiniCssExtractPlugin.loader }, + { + loader: 'css-loader', + options: { + importLoaders: 1, + modules: { + localIdentName: '[name]/[local]/[hash:base64:5]' + } + } + }, + { + loader: 'postcss-loader', + options: { + ident: 'postcss', + config: { + ctx: { + cssVarsFiles + }, + path: 'frontend/postcss.config.js' + } + } + } + ] + }, + + // Global styles + { + test: /\.css$/, + include: /(node_modules|globals.css)/, + use: [ + 'style-loader', + { + loader: 'css-loader' + } + ] + }, + + // Fonts + { + test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/, + use: [ + { + loader: 'url-loader', + options: { + limit: 10240, + mimetype: 'application/font-woff', + emitFile: false, + name: 'Content/Fonts/[name].[ext]' + } + } + ] + }, + + { + test: /\.(ttf|eot|eot?#iefix|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/, + use: [ + { + loader: 'file-loader', + options: { + emitFile: false, + name: 'Content/Fonts/[name].[ext]' + } + } + ] + } + ] + } +}; + +gulp.task('webpack', () => { + return webpackStream(config, webpack) + .pipe(gulp.dest('./')); +}); + +gulp.task('webpackWatch', () => { + config.watch = true; + + return webpackStream(config, webpack) + .on('error', errorHandler) + .pipe(gulp.dest('./')) + .on('error', errorHandler) + .pipe(livereload()) + .on('error', errorHandler); +}); diff --git a/frontend/gulp/webpack/css-variables-loader.js b/frontend/gulp/webpack/css-variables-loader.js new file mode 100644 index 000000000..5683c98be --- /dev/null +++ b/frontend/gulp/webpack/css-variables-loader.js @@ -0,0 +1,11 @@ +const loaderUtils = require('loader-utils'); + +module.exports = function cssVariablesLoader(source) { + const options = loaderUtils.getOptions(this); + + options.cssVarsFiles.forEach((cssVarsFile) => { + this.addDependency(cssVarsFile); + }); + + return source; +}; diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 000000000..b0391ec6a --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,23 @@ +const reload = require('require-nocache')(module); + +module.exports = (ctx, configPath, options) => { + const config = { + plugins: { + 'postcss-mixins': { + mixinsDir: [ + 'frontend/src/Styles/Mixins' + ] + }, + 'postcss-simple-vars': { + variables: () => + ctx.options.cssVarsFiles.reduce((acc, vars) => { + return Object.assign(acc, reload(vars)); + }, {}) + }, + 'postcss-color-function': {}, + 'postcss-nested': {} + } + }; + + return config; +}; diff --git a/frontend/src/.vscode/settings.json b/frontend/src/.vscode/settings.json new file mode 100644 index 000000000..0fb2bf460 --- /dev/null +++ b/frontend/src/.vscode/settings.json @@ -0,0 +1,4 @@ +// Place your settings in this file to overwrite default and user settings. +{ + "files.insertFinalNewline": true +} \ No newline at end of file diff --git a/frontend/src/Activity/Blacklist/Blacklist.js b/frontend/src/Activity/Blacklist/Blacklist.js new file mode 100644 index 000000000..d93bec0bf --- /dev/null +++ b/frontend/src/Activity/Blacklist/Blacklist.js @@ -0,0 +1,123 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { align, icons } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import TablePager from 'Components/Table/TablePager'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import BlacklistRowConnector from './BlacklistRowConnector'; + +class Blacklist extends Component { + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + columns, + totalRecords, + isClearingBlacklistExecuting, + onClearBlacklistPress, + ...otherProps + } = this.props; + + return ( + + + + + + + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
Unable to load blacklist
+ } + + { + isPopulated && !error && !items.length && +
+ No history blacklist +
+ } + + { + isPopulated && !error && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ + +
+ } +
+
+ ); + } +} + +Blacklist.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + totalRecords: PropTypes.number, + isClearingBlacklistExecuting: PropTypes.bool.isRequired, + onClearBlacklistPress: PropTypes.func.isRequired +}; + +export default Blacklist; diff --git a/frontend/src/Activity/Blacklist/BlacklistConnector.js b/frontend/src/Activity/Blacklist/BlacklistConnector.js new file mode 100644 index 000000000..466cc40b7 --- /dev/null +++ b/frontend/src/Activity/Blacklist/BlacklistConnector.js @@ -0,0 +1,146 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import withCurrentPage from 'Components/withCurrentPage'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import * as blacklistActions from 'Store/Actions/blacklistActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import Blacklist from './Blacklist'; + +function createMapStateToProps() { + return createSelector( + (state) => state.blacklist, + createCommandExecutingSelector(commandNames.CLEAR_BLACKLIST), + (blacklist, isClearingBlacklistExecuting) => { + return { + isClearingBlacklistExecuting, + ...blacklist + }; + } + ); +} + +const mapDispatchToProps = { + ...blacklistActions, + executeCommand +}; + +class BlacklistConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + useCurrentPage, + fetchBlacklist, + gotoBlacklistFirstPage + } = this.props; + + registerPagePopulator(this.repopulate); + + if (useCurrentPage) { + fetchBlacklist(); + } else { + gotoBlacklistFirstPage(); + } + } + + componentDidUpdate(prevProps) { + if (prevProps.isClearingBlacklistExecuting && !this.props.isClearingBlacklistExecuting) { + this.props.gotoBlacklistFirstPage(); + } + } + + componentWillUnmount() { + this.props.clearBlacklist(); + unregisterPagePopulator(this.repopulate); + } + + // + // Control + + repopulate = () => { + this.props.fetchBlacklist(); + } + // + // Listeners + + onFirstPagePress = () => { + this.props.gotoBlacklistFirstPage(); + } + + onPreviousPagePress = () => { + this.props.gotoBlacklistPreviousPage(); + } + + onNextPagePress = () => { + this.props.gotoBlacklistNextPage(); + } + + onLastPagePress = () => { + this.props.gotoBlacklistLastPage(); + } + + onPageSelect = (page) => { + this.props.gotoBlacklistPage({ page }); + } + + onSortPress = (sortKey) => { + this.props.setBlacklistSort({ sortKey }); + } + + onTableOptionChange = (payload) => { + this.props.setBlacklistTableOption(payload); + + if (payload.pageSize) { + this.props.gotoBlacklistFirstPage(); + } + } + + onClearBlacklistPress = () => { + this.props.executeCommand({ name: commandNames.CLEAR_BLACKLIST }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +BlacklistConnector.propTypes = { + useCurrentPage: PropTypes.bool.isRequired, + isClearingBlacklistExecuting: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchBlacklist: PropTypes.func.isRequired, + gotoBlacklistFirstPage: PropTypes.func.isRequired, + gotoBlacklistPreviousPage: PropTypes.func.isRequired, + gotoBlacklistNextPage: PropTypes.func.isRequired, + gotoBlacklistLastPage: PropTypes.func.isRequired, + gotoBlacklistPage: PropTypes.func.isRequired, + setBlacklistSort: PropTypes.func.isRequired, + setBlacklistTableOption: PropTypes.func.isRequired, + clearBlacklist: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default withCurrentPage( + connect(createMapStateToProps, mapDispatchToProps)(BlacklistConnector) +); diff --git a/frontend/src/Activity/Blacklist/BlacklistDetailsModal.js b/frontend/src/Activity/Blacklist/BlacklistDetailsModal.js new file mode 100644 index 000000000..356512a9d --- /dev/null +++ b/frontend/src/Activity/Blacklist/BlacklistDetailsModal.js @@ -0,0 +1,89 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Button from 'Components/Link/Button'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +class BlacklistDetailsModal extends Component { + + // + // Render + + render() { + const { + isOpen, + sourceTitle, + protocol, + indexer, + message, + onModalClose + } = this.props; + + return ( + + + + Details + + + + + + + + + { + !!message && + + } + + { + !!message && + + } + + + + + + + + + ); + } +} + +BlacklistDetailsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + sourceTitle: PropTypes.string.isRequired, + protocol: PropTypes.string.isRequired, + indexer: PropTypes.string, + message: PropTypes.string, + onModalClose: PropTypes.func.isRequired +}; + +export default BlacklistDetailsModal; diff --git a/frontend/src/Activity/Blacklist/BlacklistRow.css b/frontend/src/Activity/Blacklist/BlacklistRow.css new file mode 100644 index 000000000..fe431c64a --- /dev/null +++ b/frontend/src/Activity/Blacklist/BlacklistRow.css @@ -0,0 +1,17 @@ +.quality { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} + +.indexer { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 80px; +} + +.actions { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 70px; +} diff --git a/frontend/src/Activity/Blacklist/BlacklistRow.js b/frontend/src/Activity/Blacklist/BlacklistRow.js new file mode 100644 index 000000000..31813a41d --- /dev/null +++ b/frontend/src/Activity/Blacklist/BlacklistRow.js @@ -0,0 +1,174 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TrackQuality from 'Album/TrackQuality'; +import ArtistNameLink from 'Artist/ArtistNameLink'; +import BlacklistDetailsModal from './BlacklistDetailsModal'; +import styles from './BlacklistRow.css'; + +class BlacklistRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + // + // Listeners + + onDetailsPress = () => { + this.setState({ isDetailsModalOpen: true }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + // + // Render + + render() { + const { + artist, + sourceTitle, + quality, + date, + protocol, + indexer, + message, + columns, + onRemovePress + } = this.props; + + if (!artist) { + return null; + } + + return ( + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'artist.sortName') { + return ( + + + + ); + } + + if (name === 'sourceTitle') { + return ( + + {sourceTitle} + + ); + } + + if (name === 'quality') { + return ( + + + + ); + } + + if (name === 'date') { + return ( + + ); + } + + if (name === 'indexer') { + return ( + + {indexer} + + ); + } + + if (name === 'actions') { + return ( + + + + + + ); + } + + return null; + }) + } + + + + ); + } + +} + +BlacklistRow.propTypes = { + id: PropTypes.number.isRequired, + artist: PropTypes.object.isRequired, + sourceTitle: PropTypes.string.isRequired, + quality: PropTypes.object.isRequired, + date: PropTypes.string.isRequired, + protocol: PropTypes.string.isRequired, + indexer: PropTypes.string, + message: PropTypes.string, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + onRemovePress: PropTypes.func.isRequired +}; + +export default BlacklistRow; diff --git a/frontend/src/Activity/Blacklist/BlacklistRowConnector.js b/frontend/src/Activity/Blacklist/BlacklistRowConnector.js new file mode 100644 index 000000000..a85f1f78b --- /dev/null +++ b/frontend/src/Activity/Blacklist/BlacklistRowConnector.js @@ -0,0 +1,26 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createArtistSelector from 'Store/Selectors/createArtistSelector'; +import { removeFromBlacklist } from 'Store/Actions/blacklistActions'; +import BlacklistRow from './BlacklistRow'; + +function createMapStateToProps() { + return createSelector( + createArtistSelector(), + (artist) => { + return { + artist + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onRemovePress() { + dispatch(removeFromBlacklist({ id: props.id })); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(BlacklistRow); diff --git a/frontend/src/Activity/History/Details/HistoryDetails.css b/frontend/src/Activity/History/Details/HistoryDetails.css new file mode 100644 index 000000000..383f08afd --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetails.css @@ -0,0 +1,5 @@ +.description { + composes: description from '~Components/DescriptionList/DescriptionListItemDescription.css'; + + overflow-wrap: break-word; +} diff --git a/frontend/src/Activity/History/Details/HistoryDetails.js b/frontend/src/Activity/History/Details/HistoryDetails.js new file mode 100644 index 000000000..ca6e49d22 --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetails.js @@ -0,0 +1,434 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatAge from 'Utilities/Number/formatAge'; +import Link from 'Components/Link/Link'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle'; +import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import styles from './HistoryDetails.css'; + +function getDetailedList(statusMessages) { + return ( +
+ { + statusMessages.map(({ title, messages }) => { + return ( +
+ {title} +
    + { + messages.map((message) => { + return ( +
  • + {message} +
  • + ); + }) + } +
+
+ ); + }) + } +
+ ); +} + +function formatMissing(value) { + if (value === undefined || value === 0 || value === '0') { + return (); + } + return value; +} + +function formatChange(oldValue, newValue) { + return ( +
{formatMissing(oldValue)} {formatMissing(newValue)}
+ ); +} + +function HistoryDetails(props) { + const { + eventType, + sourceTitle, + data, + shortDateFormat, + timeFormat + } = props; + + if (eventType === 'grabbed') { + const { + indexer, + releaseGroup, + nzbInfoUrl, + downloadClient, + downloadId, + age, + ageHours, + ageMinutes, + publishedDate + } = data; + + return ( + + + + { + !!indexer && + + } + + { + !!releaseGroup && + + } + + { + !!nzbInfoUrl && + + + Info URL + + + + {nzbInfoUrl} + + + } + + { + !!downloadClient && + + } + + { + !!downloadId && + + } + + { + !!indexer && + + } + + { + !!publishedDate && + + } + + ); + } + + if (eventType === 'downloadFailed') { + const { + message + } = data; + + return ( + + + + { + !!message && + + } + + ); + } + + if (eventType === 'trackFileImported') { + const { + droppedPath, + importedPath + } = data; + + return ( + + + + { + !!droppedPath && + + } + + { + !!importedPath && + + } + + ); + } + + if (eventType === 'trackFileDeleted') { + const { + reason + } = data; + + let reasonMessage = ''; + + switch (reason) { + case 'Manual': + reasonMessage = 'File was deleted by via UI'; + break; + case 'MissingFromDisk': + reasonMessage = 'Lidarr was unable to find the file on disk so it was removed'; + break; + case 'Upgrade': + reasonMessage = 'File was deleted to import an upgrade'; + break; + default: + reasonMessage = ''; + } + + return ( + + + + + + ); + } + + if (eventType === 'trackFileRenamed') { + const { + sourcePath, + sourceRelativePath, + path, + relativePath + } = data; + + return ( + + + + + + + + + + ); + } + + if (eventType === 'trackFileRetagged') { + const { + diff, + tagsScrubbed + } = data; + + return ( + + + { + JSON.parse(diff).map(({ field, oldValue, newValue }) => { + return ( + + ); + }) + } + : } + /> + + ); + } + + if (eventType === 'albumImportIncomplete') { + const { + statusMessages + } = data; + + return ( + + + + { + !!statusMessages && + + } + + ); + } + + if (eventType === 'downloadImported') { + const { + indexer, + releaseGroup, + nzbInfoUrl, + downloadClient, + downloadId, + age, + ageHours, + ageMinutes, + publishedDate + } = data; + + return ( + + + + { + !!indexer && + + } + + { + !!releaseGroup && + + } + + { + !!nzbInfoUrl && + + + Info URL + + + + {nzbInfoUrl} + + + } + + { + !!downloadClient && + + } + + { + !!downloadId && + + } + + { + !!indexer && + + } + + { + !!publishedDate && + + } + + ); + } + + return ( + + + + ); +} + +HistoryDetails.propTypes = { + eventType: PropTypes.string.isRequired, + sourceTitle: PropTypes.string.isRequired, + data: PropTypes.object.isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired +}; + +export default HistoryDetails; diff --git a/frontend/src/Activity/History/Details/HistoryDetailsConnector.js b/frontend/src/Activity/History/Details/HistoryDetailsConnector.js new file mode 100644 index 000000000..0848c7905 --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetailsConnector.js @@ -0,0 +1,19 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import HistoryDetails from './HistoryDetails'; + +function createMapStateToProps() { + return createSelector( + createUISettingsSelector(), + (uiSettings) => { + return _.pick(uiSettings, [ + 'shortDateFormat', + 'timeFormat' + ]); + } + ); +} + +export default connect(createMapStateToProps)(HistoryDetails); diff --git a/frontend/src/Activity/History/Details/HistoryDetailsModal.css b/frontend/src/Activity/History/Details/HistoryDetailsModal.css new file mode 100644 index 000000000..271d422ff --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetailsModal.css @@ -0,0 +1,5 @@ +.markAsFailedButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Activity/History/Details/HistoryDetailsModal.js b/frontend/src/Activity/History/Details/HistoryDetailsModal.js new file mode 100644 index 000000000..865024491 --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetailsModal.js @@ -0,0 +1,110 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import HistoryDetails from './HistoryDetails'; +import styles from './HistoryDetailsModal.css'; + +function getHeaderTitle(eventType) { + switch (eventType) { + case 'grabbed': + return 'Grabbed'; + case 'downloadFailed': + return 'Download Failed'; + case 'trackFileImported': + return 'Track Imported'; + case 'trackFileDeleted': + return 'Track File Deleted'; + case 'trackFileRenamed': + return 'Track File Renamed'; + case 'trackFileRetagged': + return 'Track File Tags Updated'; + case 'albumImportIncomplete': + return 'Album Import Incomplete'; + case 'downloadImported': + return 'Download Completed'; + default: + return 'Unknown'; + } +} + +function HistoryDetailsModal(props) { + const { + isOpen, + eventType, + sourceTitle, + data, + isMarkingAsFailed, + shortDateFormat, + timeFormat, + onMarkAsFailedPress, + onModalClose + } = props; + + return ( + + + + {getHeaderTitle(eventType)} + + + + + + + + { + eventType === 'grabbed' && + + Mark as Failed + + } + + + + + + ); +} + +HistoryDetailsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + eventType: PropTypes.string.isRequired, + sourceTitle: PropTypes.string.isRequired, + data: PropTypes.object.isRequired, + isMarkingAsFailed: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + onMarkAsFailedPress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +HistoryDetailsModal.defaultProps = { + isMarkingAsFailed: false +}; + +export default HistoryDetailsModal; diff --git a/frontend/src/Activity/History/History.js b/frontend/src/Activity/History/History.js new file mode 100644 index 000000000..a525d9988 --- /dev/null +++ b/frontend/src/Activity/History/History.js @@ -0,0 +1,172 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { align, icons } from 'Helpers/Props'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import TablePager from 'Components/Table/TablePager'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import HistoryRowConnector from './HistoryRowConnector'; + +class History extends Component { + + // + // Lifecycle + + shouldComponentUpdate(nextProps) { + // Don't update when fetching has completed if items have changed, + // before albums start fetching or when albums start fetching. + + if ( + ( + this.props.isFetching && + nextProps.isPopulated && + hasDifferentItems(this.props.items, nextProps.items) + ) || + (!this.props.isAlbumsFetching && nextProps.isAlbumsFetching) + ) { + return false; + } + + return true; + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + columns, + selectedFilterKey, + filters, + totalRecords, + isAlbumsFetching, + isAlbumsPopulated, + albumsError, + onFilterSelect, + onFirstPagePress, + ...otherProps + } = this.props; + + const isFetchingAny = isFetching || isAlbumsFetching; + const isAllPopulated = isPopulated && (isAlbumsPopulated || !items.length); + const hasError = error || albumsError; + + return ( + + + + + + + + + + + + + + + + + { + isFetchingAny && !isAllPopulated && + + } + + { + !isFetchingAny && hasError && +
Unable to load history
+ } + + { + // If history isPopulated and it's empty show no history found and don't + // wait for the albums to populate because they are never coming. + + isPopulated && !hasError && !items.length && +
+ No history found +
+ } + + { + isAllPopulated && !hasError && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ + +
+ } +
+
+ ); + } +} + +History.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + totalRecords: PropTypes.number, + isAlbumsFetching: PropTypes.bool.isRequired, + isAlbumsPopulated: PropTypes.bool.isRequired, + albumsError: PropTypes.object, + onFilterSelect: PropTypes.func.isRequired, + onFirstPagePress: PropTypes.func.isRequired +}; + +export default History; diff --git a/frontend/src/Activity/History/HistoryConnector.js b/frontend/src/Activity/History/HistoryConnector.js new file mode 100644 index 000000000..d8ca60839 --- /dev/null +++ b/frontend/src/Activity/History/HistoryConnector.js @@ -0,0 +1,173 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import withCurrentPage from 'Components/withCurrentPage'; +import * as historyActions from 'Store/Actions/historyActions'; +import { fetchAlbums, clearAlbums } from 'Store/Actions/albumActions'; +import { fetchTracks, clearTracks } from 'Store/Actions/trackActions'; +import History from './History'; + +function createMapStateToProps() { + return createSelector( + (state) => state.history, + (state) => state.albums, + (state) => state.tracks, + (history, albums, tracks) => { + return { + isAlbumsFetching: albums.isFetching, + isAlbumsPopulated: albums.isPopulated, + albumsError: albums.error, + isTracksFetching: tracks.isFetching, + isTracksPopulated: tracks.isPopulated, + tracksError: tracks.error, + ...history + }; + } + ); +} + +const mapDispatchToProps = { + ...historyActions, + fetchAlbums, + clearAlbums, + fetchTracks, + clearTracks +}; + +class HistoryConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + useCurrentPage, + fetchHistory, + gotoHistoryFirstPage + } = this.props; + + registerPagePopulator(this.repopulate); + + if (useCurrentPage) { + fetchHistory(); + } else { + gotoHistoryFirstPage(); + } + } + + componentDidUpdate(prevProps) { + if (hasDifferentItems(prevProps.items, this.props.items)) { + const albumIds = selectUniqueIds(this.props.items, 'albumId'); + const trackIds = selectUniqueIds(this.props.items, 'trackId'); + if (albumIds.length) { + this.props.fetchAlbums({ albumIds }); + } else { + this.props.clearAlbums(); + } + if (trackIds.length) { + this.props.fetchTracks({ trackIds }); + } else { + this.props.clearTracks(); + } + } + } + + componentWillUnmount() { + unregisterPagePopulator(this.repopulate); + this.props.clearHistory(); + this.props.clearAlbums(); + this.props.clearTracks(); + } + + // + // Control + + repopulate = () => { + this.props.fetchHistory(); + } + + // + // Listeners + + onFirstPagePress = () => { + this.props.gotoHistoryFirstPage(); + } + + onPreviousPagePress = () => { + this.props.gotoHistoryPreviousPage(); + } + + onNextPagePress = () => { + this.props.gotoHistoryNextPage(); + } + + onLastPagePress = () => { + this.props.gotoHistoryLastPage(); + } + + onPageSelect = (page) => { + this.props.gotoHistoryPage({ page }); + } + + onSortPress = (sortKey) => { + this.props.setHistorySort({ sortKey }); + } + + onFilterSelect = (selectedFilterKey) => { + this.props.setHistoryFilter({ selectedFilterKey }); + } + + onTableOptionChange = (payload) => { + this.props.setHistoryTableOption(payload); + + if (payload.pageSize) { + this.props.gotoHistoryFirstPage(); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +HistoryConnector.propTypes = { + useCurrentPage: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchHistory: PropTypes.func.isRequired, + gotoHistoryFirstPage: PropTypes.func.isRequired, + gotoHistoryPreviousPage: PropTypes.func.isRequired, + gotoHistoryNextPage: PropTypes.func.isRequired, + gotoHistoryLastPage: PropTypes.func.isRequired, + gotoHistoryPage: PropTypes.func.isRequired, + setHistorySort: PropTypes.func.isRequired, + setHistoryFilter: PropTypes.func.isRequired, + setHistoryTableOption: PropTypes.func.isRequired, + clearHistory: PropTypes.func.isRequired, + fetchAlbums: PropTypes.func.isRequired, + clearAlbums: PropTypes.func.isRequired, + fetchTracks: PropTypes.func.isRequired, + clearTracks: PropTypes.func.isRequired +}; + +export default withCurrentPage( + connect(createMapStateToProps, mapDispatchToProps)(HistoryConnector) +); diff --git a/frontend/src/Activity/History/HistoryEventTypeCell.css b/frontend/src/Activity/History/HistoryEventTypeCell.css new file mode 100644 index 000000000..63d79e18c --- /dev/null +++ b/frontend/src/Activity/History/HistoryEventTypeCell.css @@ -0,0 +1,6 @@ +.cell { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 35px; + text-align: center; +} diff --git a/frontend/src/Activity/History/HistoryEventTypeCell.js b/frontend/src/Activity/History/HistoryEventTypeCell.js new file mode 100644 index 000000000..172796cd4 --- /dev/null +++ b/frontend/src/Activity/History/HistoryEventTypeCell.js @@ -0,0 +1,96 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './HistoryEventTypeCell.css'; + +function getIconName(eventType) { + switch (eventType) { + case 'grabbed': + return icons.DOWNLOADING; + case 'artistFolderImported': + return icons.DRIVE; + case 'trackFileImported': + return icons.DOWNLOADED; + case 'downloadFailed': + return icons.DOWNLOADING; + case 'trackFileDeleted': + return icons.DELETE; + case 'trackFileRenamed': + return icons.ORGANIZE; + case 'trackFileRetagged': + return icons.RETAG; + case 'albumImportIncomplete': + return icons.DOWNLOADED; + case 'downloadImported': + return icons.DOWNLOADED; + default: + return icons.UNKNOWN; + } +} + +function getIconKind(eventType) { + switch (eventType) { + case 'downloadFailed': + return kinds.DANGER; + case 'albumImportIncomplete': + return kinds.WARNING; + default: + return kinds.DEFAULT; + } +} + +function getTooltip(eventType, data) { + switch (eventType) { + case 'grabbed': + return `Album grabbed from ${data.indexer} and sent to ${data.downloadClient}`; + case 'artistFolderImported': + return 'Track imported from artist folder'; + case 'trackFileImported': + return 'Track downloaded successfully and picked up from download client'; + case 'downloadFailed': + return 'Album download failed'; + case 'trackFileDeleted': + return 'Track file deleted'; + case 'trackFileRenamed': + return 'Track file renamed'; + case 'trackFileRetagged': + return 'Track file tags updated'; + case 'albumImportIncomplete': + return 'Files downloaded but not all could be imported'; + case 'downloadImported': + return 'Download completed and successfully imported'; + default: + return 'Unknown event'; + } +} + +function HistoryEventTypeCell({ eventType, data }) { + const iconName = getIconName(eventType); + const iconKind = getIconKind(eventType); + const tooltip = getTooltip(eventType, data); + + return ( + + + + ); +} + +HistoryEventTypeCell.propTypes = { + eventType: PropTypes.string.isRequired, + data: PropTypes.object +}; + +HistoryEventTypeCell.defaultProps = { + data: {} +}; + +export default HistoryEventTypeCell; diff --git a/frontend/src/Activity/History/HistoryRow.css b/frontend/src/Activity/History/HistoryRow.css new file mode 100644 index 000000000..669377fdb --- /dev/null +++ b/frontend/src/Activity/History/HistoryRow.css @@ -0,0 +1,23 @@ +.downloadClient { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 120px; +} + +.indexer { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 80px; +} + +.releaseGroup { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 110px; +} + +.details { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 30px; +} diff --git a/frontend/src/Activity/History/HistoryRow.js b/frontend/src/Activity/History/HistoryRow.js new file mode 100644 index 000000000..62b83ed93 --- /dev/null +++ b/frontend/src/Activity/History/HistoryRow.js @@ -0,0 +1,241 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import AlbumTitleLink from 'Album/AlbumTitleLink'; +import TrackQuality from 'Album/TrackQuality'; +import ArtistNameLink from 'Artist/ArtistNameLink'; +import HistoryEventTypeCell from './HistoryEventTypeCell'; +import HistoryDetailsModal from './Details/HistoryDetailsModal'; +import styles from './HistoryRow.css'; + +class HistoryRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + componentDidUpdate(prevProps) { + if ( + prevProps.isMarkingAsFailed && + !this.props.isMarkingAsFailed && + !this.props.markAsFailedError + ) { + this.setState({ isDetailsModalOpen: false }); + } + } + + // + // Listeners + + onDetailsPress = () => { + this.setState({ isDetailsModalOpen: true }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + // + // Render + + render() { + const { + artist, + album, + track, + quality, + qualityCutoffNotMet, + eventType, + sourceTitle, + date, + data, + isMarkingAsFailed, + columns, + shortDateFormat, + timeFormat, + onMarkAsFailedPress + } = this.props; + + if (!artist || !album) { + return null; + } + + return ( + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'eventType') { + return ( + + ); + } + + if (name === 'artist.sortName') { + return ( + + + + ); + } + + if (name === 'album.title') { + return ( + + + + ); + } + + if (name === 'trackTitle') { + return ( + + {track.title} + + ); + } + + if (name === 'quality') { + return ( + + + + ); + } + + if (name === 'date') { + return ( + + ); + } + + if (name === 'downloadClient') { + return ( + + {data.downloadClient} + + ); + } + + if (name === 'indexer') { + return ( + + {data.indexer} + + ); + } + + if (name === 'releaseGroup') { + return ( + + {data.releaseGroup} + + ); + } + + if (name === 'details') { + return ( + + + + ); + } + + return null; + }) + } + + + + ); + } + +} + +HistoryRow.propTypes = { + albumId: PropTypes.number, + artist: PropTypes.object.isRequired, + album: PropTypes.object, + track: PropTypes.object, + quality: PropTypes.object.isRequired, + qualityCutoffNotMet: PropTypes.bool.isRequired, + eventType: PropTypes.string.isRequired, + sourceTitle: PropTypes.string.isRequired, + date: PropTypes.string.isRequired, + data: PropTypes.object.isRequired, + isMarkingAsFailed: PropTypes.bool, + markAsFailedError: PropTypes.object, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + onMarkAsFailedPress: PropTypes.func.isRequired +}; + +HistoryRow.defaultProps = { + track: { + title: '' + } +}; + +export default HistoryRow; diff --git a/frontend/src/Activity/History/HistoryRowConnector.js b/frontend/src/Activity/History/HistoryRowConnector.js new file mode 100644 index 000000000..c1e70edff --- /dev/null +++ b/frontend/src/Activity/History/HistoryRowConnector.js @@ -0,0 +1,78 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions'; +import createArtistSelector from 'Store/Selectors/createArtistSelector'; +import createAlbumSelector from 'Store/Selectors/createAlbumSelector'; +import createTrackSelector from 'Store/Selectors/createTrackSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import HistoryRow from './HistoryRow'; + +function createMapStateToProps() { + return createSelector( + createArtistSelector(), + createAlbumSelector(), + createTrackSelector(), + createUISettingsSelector(), + (artist, album, track, uiSettings) => { + return { + artist, + album, + track, + shortDateFormat: uiSettings.shortDateFormat, + timeFormat: uiSettings.timeFormat + }; + } + ); +} + +const mapDispatchToProps = { + fetchHistory, + markAsFailed +}; + +class HistoryRowConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps) { + if ( + prevProps.isMarkingAsFailed && + !this.props.isMarkingAsFailed && + !this.props.markAsFailedError + ) { + this.props.fetchHistory(); + } + } + + // + // Listeners + + onMarkAsFailedPress = () => { + this.props.markAsFailed({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +HistoryRowConnector.propTypes = { + id: PropTypes.number.isRequired, + isMarkingAsFailed: PropTypes.bool, + markAsFailedError: PropTypes.object, + fetchHistory: PropTypes.func.isRequired, + markAsFailed: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(HistoryRowConnector); diff --git a/frontend/src/Activity/Queue/ProtocolLabel.css b/frontend/src/Activity/Queue/ProtocolLabel.css new file mode 100644 index 000000000..259fd5c65 --- /dev/null +++ b/frontend/src/Activity/Queue/ProtocolLabel.css @@ -0,0 +1,13 @@ +.torrent { + composes: label from '~Components/Label.css'; + + border-color: $torrentColor; + background-color: $torrentColor; +} + +.usenet { + composes: label from '~Components/Label.css'; + + border-color: $usenetColor; + background-color: $usenetColor; +} diff --git a/frontend/src/Activity/Queue/ProtocolLabel.js b/frontend/src/Activity/Queue/ProtocolLabel.js new file mode 100644 index 000000000..e8a08943c --- /dev/null +++ b/frontend/src/Activity/Queue/ProtocolLabel.js @@ -0,0 +1,20 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Label from 'Components/Label'; +import styles from './ProtocolLabel.css'; + +function ProtocolLabel({ protocol }) { + const protocolName = protocol === 'usenet' ? 'nzb' : protocol; + + return ( + + ); +} + +ProtocolLabel.propTypes = { + protocol: PropTypes.string.isRequired +}; + +export default ProtocolLabel; diff --git a/frontend/src/Activity/Queue/Queue.js b/frontend/src/Activity/Queue/Queue.js new file mode 100644 index 000000000..9169927a3 --- /dev/null +++ b/frontend/src/Activity/Queue/Queue.js @@ -0,0 +1,288 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { align, icons } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TablePager from 'Components/Table/TablePager'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import RemoveQueueItemsModal from './RemoveQueueItemsModal'; +import QueueOptionsConnector from './QueueOptionsConnector'; +import QueueRowConnector from './QueueRowConnector'; + +class Queue extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, + isPendingSelected: false, + isConfirmRemoveModalOpen: false + }; + } + + shouldComponentUpdate(nextProps) { + // Don't update when fetching has completed if items have changed, + // before albums start fetching or when albums start fetching. + + if ( + this.props.isFetching && + nextProps.isPopulated && + hasDifferentItems(this.props.items, nextProps.items) && + nextProps.items.some((e) => e.albumId) + ) { + return false; + } + + if (!this.props.isAlbumsFetching && nextProps.isAlbumsFetching) { + return false; + } + + return true; + } + + componentDidUpdate(prevProps) { + if (hasDifferentItems(prevProps.items, this.props.items)) { + this.setState((state) => { + return removeOldSelectedState(state, prevProps.items); + }); + + return; + } + + const selectedIds = this.getSelectedIds(); + const isPendingSelected = _.some(this.props.items, (item) => { + return selectedIds.indexOf(item.id) > -1 && item.status === 'Delay'; + }); + + if (isPendingSelected !== this.state.isPendingSelected) { + this.setState({ isPendingSelected }); + } + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onGrabSelectedPress = () => { + this.props.onGrabSelectedPress(this.getSelectedIds()); + } + + onRemoveSelectedPress = () => { + this.setState({ isConfirmRemoveModalOpen: true }); + } + + onRemoveSelectedConfirmed = (blacklist, skipredownload) => { + this.props.onRemoveSelectedPress(this.getSelectedIds(), blacklist, skipredownload); + this.setState({ isConfirmRemoveModalOpen: false }); + } + + onConfirmRemoveModalClose = () => { + this.setState({ isConfirmRemoveModalOpen: false }); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + isAlbumsFetching, + isAlbumsPopulated, + albumsError, + columns, + totalRecords, + isGrabbing, + isRemoving, + isCheckForFinishedDownloadExecuting, + onRefreshPress, + ...otherProps + } = this.props; + + const { + allSelected, + allUnselected, + selectedState, + isConfirmRemoveModalOpen, + isPendingSelected + } = this.state; + + const isRefreshing = isFetching || isAlbumsFetching || isCheckForFinishedDownloadExecuting; + const isAllPopulated = isPopulated && (isAlbumsPopulated || !items.length || items.every((e) => !e.albumId)); + const hasError = error || albumsError; + const selectedCount = this.getSelectedIds().length; + const disableSelectedActions = selectedCount === 0; + + return ( + + + + + + + + + + + + + + + + + + + + + { + isRefreshing && !isAllPopulated && + + } + + { + !isRefreshing && hasError && +
+ Failed to load Queue +
+ } + + { + isPopulated && !hasError && !items.length && +
+ Queue is empty +
+ } + + { + isAllPopulated && !hasError && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ + +
+ } +
+ + +
+ ); + } +} + +Queue.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + isAlbumsFetching: PropTypes.bool.isRequired, + isAlbumsPopulated: PropTypes.bool.isRequired, + albumsError: PropTypes.object, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + totalRecords: PropTypes.number, + isGrabbing: PropTypes.bool.isRequired, + isRemoving: PropTypes.bool.isRequired, + isCheckForFinishedDownloadExecuting: PropTypes.bool.isRequired, + onRefreshPress: PropTypes.func.isRequired, + onGrabSelectedPress: PropTypes.func.isRequired, + onRemoveSelectedPress: PropTypes.func.isRequired +}; + +export default Queue; diff --git a/frontend/src/Activity/Queue/QueueConnector.js b/frontend/src/Activity/Queue/QueueConnector.js new file mode 100644 index 000000000..ea571e8af --- /dev/null +++ b/frontend/src/Activity/Queue/QueueConnector.js @@ -0,0 +1,188 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import withCurrentPage from 'Components/withCurrentPage'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as queueActions from 'Store/Actions/queueActions'; +import { fetchAlbums, clearAlbums } from 'Store/Actions/albumActions'; +import * as commandNames from 'Commands/commandNames'; +import Queue from './Queue'; + +function createMapStateToProps() { + return createSelector( + (state) => state.albums, + (state) => state.queue.options, + (state) => state.queue.paged, + createCommandExecutingSelector(commandNames.CHECK_FOR_FINISHED_DOWNLOAD), + (albums, options, queue, isCheckForFinishedDownloadExecuting) => { + return { + isAlbumsFetching: albums.isFetching, + isAlbumsPopulated: albums.isPopulated, + albumsError: albums.error, + isCheckForFinishedDownloadExecuting, + ...options, + ...queue + }; + } + ); +} + +const mapDispatchToProps = { + ...queueActions, + fetchAlbums, + clearAlbums, + executeCommand +}; + +class QueueConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + useCurrentPage, + fetchQueue, + gotoQueueFirstPage + } = this.props; + + registerPagePopulator(this.repopulate); + + if (useCurrentPage) { + fetchQueue(); + } else { + gotoQueueFirstPage(); + } + } + + componentDidUpdate(prevProps) { + if (hasDifferentItems(prevProps.items, this.props.items)) { + const albumIds = selectUniqueIds(this.props.items, 'albumId'); + + if (albumIds.length) { + this.props.fetchAlbums({ albumIds }); + } else { + this.props.clearAlbums(); + } + } + + if ( + this.props.includeUnknownArtistItems !== + prevProps.includeUnknownArtistItems + ) { + this.repopulate(); + } + } + + componentWillUnmount() { + unregisterPagePopulator(this.repopulate); + this.props.clearQueue(); + this.props.clearAlbums(); + } + + // + // Control + + repopulate = () => { + this.props.fetchQueue(); + } + + // + // Listeners + + onFirstPagePress = () => { + this.props.gotoQueueFirstPage(); + } + + onPreviousPagePress = () => { + this.props.gotoQueuePreviousPage(); + } + + onNextPagePress = () => { + this.props.gotoQueueNextPage(); + } + + onLastPagePress = () => { + this.props.gotoQueueLastPage(); + } + + onPageSelect = (page) => { + this.props.gotoQueuePage({ page }); + } + + onSortPress = (sortKey) => { + this.props.setQueueSort({ sortKey }); + } + + onTableOptionChange = (payload) => { + this.props.setQueueTableOption(payload); + + if (payload.pageSize) { + this.props.gotoQueueFirstPage(); + } + } + + onRefreshPress = () => { + this.props.executeCommand({ + name: commandNames.CHECK_FOR_FINISHED_DOWNLOAD + }); + } + + onGrabSelectedPress = (ids) => { + this.props.grabQueueItems({ ids }); + } + + onRemoveSelectedPress = (ids, blacklist, skipredownload) => { + this.props.removeQueueItems({ ids, blacklist, skipredownload }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +QueueConnector.propTypes = { + useCurrentPage: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + includeUnknownArtistItems: PropTypes.bool.isRequired, + fetchQueue: PropTypes.func.isRequired, + gotoQueueFirstPage: PropTypes.func.isRequired, + gotoQueuePreviousPage: PropTypes.func.isRequired, + gotoQueueNextPage: PropTypes.func.isRequired, + gotoQueueLastPage: PropTypes.func.isRequired, + gotoQueuePage: PropTypes.func.isRequired, + setQueueSort: PropTypes.func.isRequired, + setQueueTableOption: PropTypes.func.isRequired, + clearQueue: PropTypes.func.isRequired, + grabQueueItems: PropTypes.func.isRequired, + removeQueueItems: PropTypes.func.isRequired, + fetchAlbums: PropTypes.func.isRequired, + clearAlbums: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default withCurrentPage( + connect(createMapStateToProps, mapDispatchToProps)(QueueConnector) +); diff --git a/frontend/src/Activity/Queue/QueueDetails.js b/frontend/src/Activity/Queue/QueueDetails.js new file mode 100644 index 000000000..8256b8af3 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueDetails.js @@ -0,0 +1,97 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; + +function QueueDetails(props) { + const { + title, + size, + sizeleft, + estimatedCompletionTime, + status: queueStatus, + errorMessage, + progressBar + } = props; + + const status = queueStatus.toLowerCase(); + + const progress = (100 - sizeleft / size * 100); + + if (status === 'pending') { + return ( + + ); + } + + if (status === 'completed') { + if (errorMessage) { + return ( + + ); + } + + // TODO: show an icon when download is complete, but not imported yet? + } + + if (errorMessage) { + return ( + + ); + } + + if (status === 'failed') { + return ( + + ); + } + + if (status === 'warning') { + return ( + + ); + } + + if (progress < 5) { + return ( + + ); + } + + return progressBar; +} + +QueueDetails.propTypes = { + title: PropTypes.string.isRequired, + size: PropTypes.number.isRequired, + sizeleft: PropTypes.number.isRequired, + estimatedCompletionTime: PropTypes.string, + status: PropTypes.string.isRequired, + errorMessage: PropTypes.string, + progressBar: PropTypes.node.isRequired +}; + +export default QueueDetails; diff --git a/frontend/src/Activity/Queue/QueueOptions.js b/frontend/src/Activity/Queue/QueueOptions.js new file mode 100644 index 000000000..835be52b3 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueOptions.js @@ -0,0 +1,77 @@ +import PropTypes from 'prop-types'; +import React, { Component, Fragment } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +class QueueOptions extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + includeUnknownArtistItems: props.includeUnknownArtistItems + }; + } + + componentDidUpdate(prevProps) { + const { + includeUnknownArtistItems + } = this.props; + + if (includeUnknownArtistItems !== prevProps.includeUnknownArtistItems) { + this.setState({ + includeUnknownArtistItems + }); + } + } + + // + // Listeners + + onOptionChange = ({ name, value }) => { + this.setState({ + [name]: value + }, () => { + this.props.onOptionChange({ + [name]: value + }); + }); + } + + // + // Render + + render() { + const { + includeUnknownArtistItems + } = this.state; + + return ( + + + Show Unknown Artist Items + + + + + ); + } +} + +QueueOptions.propTypes = { + includeUnknownArtistItems: PropTypes.bool.isRequired, + onOptionChange: PropTypes.func.isRequired +}; + +export default QueueOptions; diff --git a/frontend/src/Activity/Queue/QueueOptionsConnector.js b/frontend/src/Activity/Queue/QueueOptionsConnector.js new file mode 100644 index 000000000..b2c99511c --- /dev/null +++ b/frontend/src/Activity/Queue/QueueOptionsConnector.js @@ -0,0 +1,19 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setQueueOption } from 'Store/Actions/queueActions'; +import QueueOptions from './QueueOptions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.queue.options, + (options) => { + return options; + } + ); +} + +const mapDispatchToProps = { + onOptionChange: setQueueOption +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(QueueOptions); diff --git a/frontend/src/Activity/Queue/QueueRow.css b/frontend/src/Activity/Queue/QueueRow.css new file mode 100644 index 000000000..16805dbf6 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueRow.css @@ -0,0 +1,23 @@ +.quality { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 150px; +} + +.protocol { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} + +.progress { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 150px; +} + +.actions { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 90px; +} diff --git a/frontend/src/Activity/Queue/QueueRow.js b/frontend/src/Activity/Queue/QueueRow.js new file mode 100644 index 000000000..06cf16f70 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueRow.js @@ -0,0 +1,383 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import ProgressBar from 'Components/ProgressBar'; +import TableRow from 'Components/Table/TableRow'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import Icon from 'Components/Icon'; +import Popover from 'Components/Tooltip/Popover'; +import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; +import AlbumTitleLink from 'Album/AlbumTitleLink'; +import TrackQuality from 'Album/TrackQuality'; +import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; +import ArtistNameLink from 'Artist/ArtistNameLink'; +import QueueStatusCell from './QueueStatusCell'; +import TimeleftCell from './TimeleftCell'; +import RemoveQueueItemModal from './RemoveQueueItemModal'; +import styles from './QueueRow.css'; + +class QueueRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isRemoveQueueItemModalOpen: false, + isInteractiveImportModalOpen: false + }; + } + + // + // Listeners + + onRemoveQueueItemPress = () => { + this.setState({ isRemoveQueueItemModalOpen: true }); + } + + onRemoveQueueItemModalConfirmed = (blacklist, skipredownload) => { + this.props.onRemoveQueueItemPress(blacklist, skipredownload); + this.setState({ isRemoveQueueItemModalOpen: false }); + } + + onRemoveQueueItemModalClose = () => { + this.setState({ isRemoveQueueItemModalOpen: false }); + } + + onInteractiveImportPress = () => { + this.setState({ isInteractiveImportModalOpen: true }); + } + + onInteractiveImportModalClose = () => { + this.setState({ isInteractiveImportModalOpen: false }); + } + + // + // Render + + render() { + const { + id, + downloadId, + title, + status, + trackedDownloadStatus, + statusMessages, + errorMessage, + artist, + album, + quality, + protocol, + indexer, + outputPath, + downloadClient, + downloadForced, + estimatedCompletionTime, + timeleft, + size, + sizeleft, + showRelativeDates, + shortDateFormat, + timeFormat, + isGrabbing, + grabError, + isRemoving, + isSelected, + columns, + onSelectedChange, + onGrabPress + } = this.props; + + const { + isRemoveQueueItemModalOpen, + isInteractiveImportModalOpen + } = this.state; + + const progress = 100 - (sizeleft / size * 100); + const showInteractiveImport = status === 'Completed' && trackedDownloadStatus === 'Warning'; + const isPending = status === 'Delay' || status === 'DownloadClientUnavailable'; + + return ( + + + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'status') { + return ( + + ); + } + + if (name === 'artist.sortName') { + return ( + + { + artist ? + : + title + } + + ); + } + + if (name === 'album.title') { + return ( + + { + album ? + : + '-' + } + + ); + } + + if (name === 'album.releaseDate') { + if (album) { + return ( + + ); + } + + return ( + + - + + ); + } + + if (name === 'quality') { + return ( + + + + ); + } + + if (name === 'protocol') { + return ( + + + + ); + } + + if (name === 'indexer') { + return ( + + {indexer} + + ); + } + + if (name === 'downloadClient') { + return ( + + {downloadClient} + + ); + } + + if (name === 'title') { + return ( + + {title} + + ); + } + + if (name === 'outputPath') { + return ( + + {outputPath} + + ); + } + + if (name === 'estimatedCompletionTime') { + return ( + + ); + } + + if (name === 'progress') { + return ( + + { + !!progress && + + } + + ); + } + + if (name === 'actions') { + return ( + + { + downloadForced && + + } + title="Manual Download" + body="This release failed parsing checks and was manually downloaded from an interactive search. Import is likely to fail." + position={tooltipPositions.LEFT} + /> + } + + { + showInteractiveImport && + + } + + { + isPending && + + } + + + + ); + } + + return null; + }) + } + + + + + + ); + } + +} + +QueueRow.propTypes = { + id: PropTypes.number.isRequired, + downloadId: PropTypes.string, + title: PropTypes.string.isRequired, + status: PropTypes.string.isRequired, + trackedDownloadStatus: PropTypes.string, + statusMessages: PropTypes.arrayOf(PropTypes.object), + errorMessage: PropTypes.string, + artist: PropTypes.object, + album: PropTypes.object, + quality: PropTypes.object.isRequired, + protocol: PropTypes.string.isRequired, + indexer: PropTypes.string, + outputPath: PropTypes.string, + downloadClient: PropTypes.string, + downloadForced: PropTypes.bool.isRequired, + estimatedCompletionTime: PropTypes.string, + timeleft: PropTypes.string, + size: PropTypes.number, + sizeleft: PropTypes.number, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + isGrabbing: PropTypes.bool.isRequired, + grabError: PropTypes.object, + isRemoving: PropTypes.bool.isRequired, + isSelected: PropTypes.bool, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + onSelectedChange: PropTypes.func.isRequired, + onGrabPress: PropTypes.func.isRequired, + onRemoveQueueItemPress: PropTypes.func.isRequired +}; + +QueueRow.defaultProps = { + isGrabbing: false, + isRemoving: false +}; + +export default QueueRow; diff --git a/frontend/src/Activity/Queue/QueueRowConnector.js b/frontend/src/Activity/Queue/QueueRowConnector.js new file mode 100644 index 000000000..6bbbde361 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueRowConnector.js @@ -0,0 +1,71 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions'; +import createArtistSelector from 'Store/Selectors/createArtistSelector'; +import createAlbumSelector from 'Store/Selectors/createAlbumSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import QueueRow from './QueueRow'; + +function createMapStateToProps() { + return createSelector( + createArtistSelector(), + createAlbumSelector(), + createUISettingsSelector(), + (artist, album, uiSettings) => { + const result = _.pick(uiSettings, [ + 'showRelativeDates', + 'shortDateFormat', + 'timeFormat' + ]); + + result.artist = artist; + result.album = album; + + return result; + } + ); +} + +const mapDispatchToProps = { + grabQueueItem, + removeQueueItem +}; + +class QueueRowConnector extends Component { + + // + // Listeners + + onGrabPress = () => { + this.props.grabQueueItem({ id: this.props.id }); + } + + onRemoveQueueItemPress = (blacklist, skipredownload) => { + this.props.removeQueueItem({ id: this.props.id, blacklist, skipredownload }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +QueueRowConnector.propTypes = { + id: PropTypes.number.isRequired, + album: PropTypes.object, + grabQueueItem: PropTypes.func.isRequired, + removeQueueItem: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(QueueRowConnector); diff --git a/frontend/src/Activity/Queue/QueueStatusCell.css b/frontend/src/Activity/Queue/QueueStatusCell.css new file mode 100644 index 000000000..e1b9a23e9 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueStatusCell.css @@ -0,0 +1,5 @@ +.status { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 30px; +} diff --git a/frontend/src/Activity/Queue/QueueStatusCell.js b/frontend/src/Activity/Queue/QueueStatusCell.js new file mode 100644 index 000000000..552fa1444 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueStatusCell.js @@ -0,0 +1,133 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import Popover from 'Components/Tooltip/Popover'; +import styles from './QueueStatusCell.css'; + +function getDetailedPopoverBody(statusMessages) { + return ( +
+ { + statusMessages.map(({ title, messages }) => { + return ( +
+ {title} +
    + { + messages.map((message) => { + return ( +
  • + {message} +
  • + ); + }) + } +
+
+ ); + }) + } +
+ ); +} + +function QueueStatusCell(props) { + const { + sourceTitle, + status, + trackedDownloadStatus = 'Ok', + statusMessages, + errorMessage + } = props; + + const hasWarning = trackedDownloadStatus === 'Warning'; + const hasError = trackedDownloadStatus === 'Error'; + + // status === 'downloading' + let iconName = icons.DOWNLOADING; + let iconKind = kinds.DEFAULT; + let title = 'Downloading'; + + if (hasWarning) { + iconKind = kinds.WARNING; + } + + if (status === 'Paused') { + iconName = icons.PAUSED; + title = 'Paused'; + } + + if (status === 'Queued') { + iconName = icons.QUEUED; + title = 'Queued'; + } + + if (status === 'Completed') { + iconName = icons.DOWNLOADED; + title = 'Downloaded'; + } + + if (status === 'Delay') { + iconName = icons.PENDING; + title = 'Pending'; + } + + if (status === 'DownloadClientUnavailable') { + iconName = icons.PENDING; + iconKind = kinds.WARNING; + title = 'Pending - Download client is unavailable'; + } + + if (status === 'Failed') { + iconName = icons.DOWNLOADING; + iconKind = kinds.DANGER; + title = 'Download failed'; + } + + if (status === 'Warning') { + iconName = icons.DOWNLOADING; + iconKind = kinds.WARNING; + title = `Download warning: ${errorMessage || 'check download client for more details'}`; + } + + if (hasError) { + if (status === 'Completed') { + iconName = icons.DOWNLOAD; + iconKind = kinds.DANGER; + title = `Import failed: ${sourceTitle}`; + } else { + iconName = icons.DOWNLOADING; + iconKind = kinds.DANGER; + title = 'Download failed'; + } + } + + return ( + + + } + title={title} + body={hasWarning || hasError ? getDetailedPopoverBody(statusMessages) : sourceTitle} + position={tooltipPositions.RIGHT} + canFlip={false} + /> + + ); +} + +QueueStatusCell.propTypes = { + sourceTitle: PropTypes.string.isRequired, + status: PropTypes.string.isRequired, + trackedDownloadStatus: PropTypes.string, + statusMessages: PropTypes.arrayOf(PropTypes.object), + errorMessage: PropTypes.string +}; + +export default QueueStatusCell; diff --git a/frontend/src/Activity/Queue/RemoveQueueItemModal.css b/frontend/src/Activity/Queue/RemoveQueueItemModal.css new file mode 100644 index 000000000..d7a643463 --- /dev/null +++ b/frontend/src/Activity/Queue/RemoveQueueItemModal.css @@ -0,0 +1,4 @@ +.messageRemove { + margin-bottom: 30px; + color: $dangerColor; +} diff --git a/frontend/src/Activity/Queue/RemoveQueueItemModal.js b/frontend/src/Activity/Queue/RemoveQueueItemModal.js new file mode 100644 index 000000000..d2f929aee --- /dev/null +++ b/frontend/src/Activity/Queue/RemoveQueueItemModal.js @@ -0,0 +1,145 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './RemoveQueueItemModal.css'; + +class RemoveQueueItemModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + blacklist: false, + skipredownload: false + }; + } + + // + // Listeners + + onBlacklistChange = ({ value }) => { + this.setState({ blacklist: value }); + } + + onSkipReDownloadChange = ({ value }) => { + this.setState({ skipredownload: value }); + } + + onRemoveQueueItemConfirmed = () => { + const blacklist = this.state.blacklist; + const skipredownload = this.state.skipredownload; + + this.setState({ + blacklist: false, + skipredownload: false + }); + this.props.onRemovePress(blacklist, skipredownload); + } + + onModalClose = () => { + this.setState({ + blacklist: false, + skipredownload: false + }); + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + isOpen, + sourceTitle + } = this.props; + + const blacklist = this.state.blacklist; + const skipredownload = this.state.skipredownload; + + return ( + + + + Remove - {sourceTitle} + + + +
+ Are you sure you want to remove '{sourceTitle}' from the queue? +
+ +
+ Removing will remove the download and the file(s) from the download client. +
+ + + Blacklist Release + + + + { + blacklist && + + Skip Redownload + + + } + +
+ + + + + + +
+
+ ); + } +} + +RemoveQueueItemModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + sourceTitle: PropTypes.string.isRequired, + onRemovePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default RemoveQueueItemModal; diff --git a/frontend/src/Activity/Queue/RemoveQueueItemsModal.css b/frontend/src/Activity/Queue/RemoveQueueItemsModal.css new file mode 100644 index 000000000..c9ef59ec1 --- /dev/null +++ b/frontend/src/Activity/Queue/RemoveQueueItemsModal.css @@ -0,0 +1,3 @@ +.message { + margin-bottom: 30px; +} diff --git a/frontend/src/Activity/Queue/RemoveQueueItemsModal.js b/frontend/src/Activity/Queue/RemoveQueueItemsModal.js new file mode 100644 index 000000000..b573c5cbd --- /dev/null +++ b/frontend/src/Activity/Queue/RemoveQueueItemsModal.js @@ -0,0 +1,141 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './RemoveQueueItemsModal.css'; + +class RemoveQueueItemsModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + blacklist: false, + skipredownload: false + }; + } + + // + // Listeners + + onBlacklistChange = ({ value }) => { + this.setState({ blacklist: value }); + } + + onSkipReDownloadChange = ({ value }) => { + this.setState({ skipredownload: value }); + } + + onRemoveQueueItemConfirmed = () => { + const blacklist = this.state.blacklist; + const skipredownload = this.state.skipredownload; + + this.setState({ + blacklist: false, + skipredownload: false + }); + this.props.onRemovePress(blacklist, skipredownload); + } + + onModalClose = () => { + this.setState({ + blacklist: false, + skipredownload: false + }); + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + isOpen, + selectedCount + } = this.props; + + const blacklist = this.state.blacklist; + const skipredownload = this.state.skipredownload; + + return ( + + + + Remove Selected Item{selectedCount > 1 ? 's' : ''} + + + +
+ Are you sure you want to remove {selectedCount} item{selectedCount > 1 ? 's' : ''} from the queue? +
+ + + Blacklist Release + + + + { + blacklist && + + Skip Redownload + + + } + +
+ + + + + + +
+
+ ); + } +} + +RemoveQueueItemsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + selectedCount: PropTypes.number.isRequired, + onRemovePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default RemoveQueueItemsModal; diff --git a/frontend/src/Activity/Queue/Status/QueueStatusConnector.js b/frontend/src/Activity/Queue/Status/QueueStatusConnector.js new file mode 100644 index 000000000..090b8fc96 --- /dev/null +++ b/frontend/src/Activity/Queue/Status/QueueStatusConnector.js @@ -0,0 +1,76 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchQueueStatus } from 'Store/Actions/queueActions'; +import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus'; + +function createMapStateToProps() { + return createSelector( + (state) => state.app, + (state) => state.queue.status, + (state) => state.queue.options.includeUnknownArtistItems, + (app, status, includeUnknownArtistItems) => { + const { + errors, + warnings, + unknownErrors, + unknownWarnings, + count, + totalCount + } = status.item; + + return { + isConnected: app.isConnected, + isReconnecting: app.isReconnecting, + isPopulated: status.isPopulated, + ...status.item, + count: includeUnknownArtistItems ? totalCount : count, + errors: includeUnknownArtistItems ? errors || unknownErrors : errors, + warnings: includeUnknownArtistItems ? warnings || unknownWarnings : warnings + }; + } + ); +} + +const mapDispatchToProps = { + fetchQueueStatus +}; + +class QueueStatusConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + if (!this.props.isPopulated) { + this.props.fetchQueueStatus(); + } + } + + componentDidUpdate(prevProps) { + if (this.props.isConnected && prevProps.isReconnecting) { + this.props.fetchQueueStatus(); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +QueueStatusConnector.propTypes = { + isConnected: PropTypes.bool.isRequired, + isReconnecting: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + fetchQueueStatus: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(QueueStatusConnector); diff --git a/frontend/src/Activity/Queue/TimeleftCell.css b/frontend/src/Activity/Queue/TimeleftCell.css new file mode 100644 index 000000000..cc6001a22 --- /dev/null +++ b/frontend/src/Activity/Queue/TimeleftCell.css @@ -0,0 +1,5 @@ +.timeleft { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} diff --git a/frontend/src/Activity/Queue/TimeleftCell.js b/frontend/src/Activity/Queue/TimeleftCell.js new file mode 100644 index 000000000..c9515f172 --- /dev/null +++ b/frontend/src/Activity/Queue/TimeleftCell.js @@ -0,0 +1,82 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import formatTime from 'Utilities/Date/formatTime'; +import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import formatBytes from 'Utilities/Number/formatBytes'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './TimeleftCell.css'; + +function TimeleftCell(props) { + const { + estimatedCompletionTime, + timeleft, + status, + size, + sizeleft, + showRelativeDates, + shortDateFormat, + timeFormat + } = props; + + if (status === 'Delay') { + const date = getRelativeDate(estimatedCompletionTime, shortDateFormat, showRelativeDates); + const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true }); + + return ( + + - + + ); + } + + if (status === 'DownloadClientUnavailable') { + const date = getRelativeDate(estimatedCompletionTime, shortDateFormat, showRelativeDates); + const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true }); + + return ( + + - + + ); + } + + if (!timeleft) { + return ( + + - + + ); + } + + const totalSize = formatBytes(size); + const remainingSize = formatBytes(sizeleft); + + return ( + + {formatTimeSpan(timeleft)} + + ); +} + +TimeleftCell.propTypes = { + estimatedCompletionTime: PropTypes.string, + timeleft: PropTypes.string, + status: PropTypes.string.isRequired, + size: PropTypes.number.isRequired, + sizeleft: PropTypes.number.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired +}; + +export default TimeleftCell; diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtist.css b/frontend/src/AddArtist/AddNewArtist/AddNewArtist.css new file mode 100644 index 000000000..7c558d6d0 --- /dev/null +++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtist.css @@ -0,0 +1,54 @@ +.searchContainer { + display: flex; + margin-bottom: 10px; +} + +.searchIconContainer { + width: 58px; + height: 46px; + border: 1px solid $inputBorderColor; + border-right: none; + border-radius: 4px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + background-color: #edf1f2; + text-align: center; + line-height: 46px; +} + +.searchInput { + composes: input from '~Components/Form/TextInput.css'; + + height: 46px; + border-radius: 0; + font-size: 18px; +} + +.clearLookupButton { + border: 1px solid $inputBorderColor; + border-left: none; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); +} + +.message { + margin-top: 30px; + text-align: center; +} + +.helpText { + margin-bottom: 10px; + font-weight: 300; + font-size: 24px; +} + +.noResults { + margin-bottom: 10px; + font-weight: 300; + font-size: 30px; +} + +.searchResults { + margin-top: 30px; +} diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtist.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtist.js new file mode 100644 index 000000000..23affe605 --- /dev/null +++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtist.js @@ -0,0 +1,183 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Link from 'Components/Link/Link'; +import Icon from 'Components/Icon'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import TextInput from 'Components/Form/TextInput'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import AddNewArtistSearchResultConnector from './AddNewArtistSearchResultConnector'; +import styles from './AddNewArtist.css'; + +class AddNewArtist extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + term: props.term || '', + isFetching: false + }; + } + + componentDidMount() { + const term = this.state.term; + + if (term) { + this.props.onArtistLookupChange(term); + } + } + + componentDidUpdate(prevProps) { + const { + term, + isFetching + } = this.props; + + if (term && term !== prevProps.term) { + this.setState({ + term, + isFetching: true + }); + this.props.onArtistLookupChange(term); + } else if (isFetching !== prevProps.isFetching) { + this.setState({ + isFetching + }); + } + } + + // + // Listeners + + onSearchInputChange = ({ value }) => { + const hasValue = !!value.trim(); + + this.setState({ term: value, isFetching: hasValue }, () => { + if (hasValue) { + this.props.onArtistLookupChange(value); + } else { + this.props.onClearArtistLookup(); + } + }); + } + + onClearArtistLookupPress = () => { + this.setState({ term: '' }); + this.props.onClearArtistLookup(); + } + + // + // Render + + render() { + const { + error, + items + } = this.props; + + const term = this.state.term; + const isFetching = this.state.isFetching; + + return ( + + +
+
+ +
+ + + + +
+ + { + isFetching && + + } + + { + !isFetching && !!error && +
Failed to load search results, please try again.
+ } + + { + !isFetching && !error && !!items.length && +
+ { + items.map((item) => { + return ( + + ); + }) + } +
+ } + + { + !isFetching && !error && !items.length && !!term && +
+
Couldn't find any results for '{term}'
+
You can also search using MusicBrainz ID of an artist. eg. lidarr:cc197bad-dc9c-440d-a5b5-d52ba2e14234
+
+ + Why can't I find my artist? + +
+
+ } + + { + !term && +
+
It's easy to add a new artist, just start typing the name the artist you want to add.
+
You can also search using MusicBrainz ID of an artist. eg. lidarr:cc197bad-dc9c-440d-a5b5-d52ba2e14234
+
+ } + +
+ + + ); + } +} + +AddNewArtist.propTypes = { + term: PropTypes.string, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isAdding: PropTypes.bool.isRequired, + addError: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onArtistLookupChange: PropTypes.func.isRequired, + onClearArtistLookup: PropTypes.func.isRequired +}; + +export default AddNewArtist; diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistConnector.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtistConnector.js new file mode 100644 index 000000000..50cc07cd2 --- /dev/null +++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtistConnector.js @@ -0,0 +1,102 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import parseUrl from 'Utilities/String/parseUrl'; +import { lookupArtist, clearAddArtist } from 'Store/Actions/addArtistActions'; +import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import AddNewArtist from './AddNewArtist'; + +function createMapStateToProps() { + return createSelector( + (state) => state.addArtist, + (state) => state.router.location, + (addArtist, location) => { + const { params } = parseUrl(location.search); + + return { + term: params.term, + ...addArtist + }; + } + ); +} + +const mapDispatchToProps = { + lookupArtist, + clearAddArtist, + fetchRootFolders +}; + +class AddNewArtistConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._artistLookupTimeout = null; + } + + componentDidMount() { + this.props.fetchRootFolders(); + } + + componentWillUnmount() { + if (this._artistLookupTimeout) { + clearTimeout(this._artistLookupTimeout); + } + + this.props.clearAddArtist(); + } + + // + // Listeners + + onArtistLookupChange = (term) => { + if (this._artistLookupTimeout) { + clearTimeout(this._artistLookupTimeout); + } + + if (term.trim() === '') { + this.props.clearAddArtist(); + } else { + this._artistLookupTimeout = setTimeout(() => { + this.props.lookupArtist({ term }); + }, 300); + } + } + + onClearArtistLookup = () => { + this.props.clearAddArtist(); + } + + // + // Render + + render() { + const { + term, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +AddNewArtistConnector.propTypes = { + term: PropTypes.string, + lookupArtist: PropTypes.func.isRequired, + clearAddArtist: PropTypes.func.isRequired, + fetchRootFolders: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AddNewArtistConnector); diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistModal.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModal.js new file mode 100644 index 000000000..e94a8a229 --- /dev/null +++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import AddNewArtistModalContentConnector from './AddNewArtistModalContentConnector'; + +function AddNewArtistModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +AddNewArtistModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddNewArtistModal; diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.css b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.css new file mode 100644 index 000000000..4c5c747a8 --- /dev/null +++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.css @@ -0,0 +1,76 @@ +.container { + display: flex; +} + +.year { + margin-left: 5px; + color: $disabledColor; +} + +.poster { + flex: 0 0 170px; + margin-right: 20px; + height: 250px; +} + +.info { + flex-grow: 1; +} + +.overview { + margin-bottom: 30px; + max-height: 230px; + text-align: justify; +} + +.labelIcon { + margin-left: 8px; +} + +.searchForMissingAlbumsLabelContainer { + display: flex; + margin-top: 2px; +} + +.searchForMissingAlbumsLabel { + margin-right: 8px; + font-weight: normal; +} + +.searchForMissingAlbumsContainer { + composes: container from '~Components/Form/CheckInput.css'; + + flex: 0 1 0; +} + +.searchForMissingAlbumsInput { + composes: input from '~Components/Form/CheckInput.css'; + + margin-top: 0; +} + +.modalFooter { + composes: modalFooter from '~Components/Modal/ModalFooter.css'; +} + +.addButton { + @add-mixin truncate; + composes: button from '~Components/Link/SpinnerButton.css'; +} + +.hideMetadataProfile { + composes: group from '~Components/Form/FormGroup.css'; + + display: none; +} + +@media only screen and (max-width: $breakpointSmall) { + .modalFooter { + display: block; + text-align: center; + } + + .addButton { + margin-top: 10px; + } +} diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.js new file mode 100644 index 000000000..2278812b8 --- /dev/null +++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.js @@ -0,0 +1,241 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import TextTruncate from 'react-text-truncate'; +import { icons, kinds, inputTypes, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import CheckInput from 'Components/Form/CheckInput'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Popover from 'Components/Tooltip/Popover'; +import ArtistPoster from 'Artist/ArtistPoster'; +import ArtistMonitoringOptionsPopoverContent from 'AddArtist/ArtistMonitoringOptionsPopoverContent'; +import styles from './AddNewArtistModalContent.css'; + +class AddNewArtistModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + searchForMissingAlbums: false + }; + } + + // + // Listeners + + onSearchForMissingAlbumsChange = ({ value }) => { + this.setState({ searchForMissingAlbums: value }); + } + + onQualityProfileIdChange = ({ value }) => { + this.props.onInputChange({ name: 'qualityProfileId', value: parseInt(value) }); + } + + onMetadataProfileIdChange = ({ value }) => { + this.props.onInputChange({ name: 'metadataProfileId', value: parseInt(value) }); + } + + onAddArtistPress = () => { + this.props.onAddArtistPress(this.state.searchForMissingAlbums); + } + + // + // Render + + render() { + const { + artistName, + overview, + images, + isAdding, + rootFolderPath, + monitor, + qualityProfileId, + metadataProfileId, + albumFolder, + tags, + showMetadataProfile, + isSmallScreen, + onModalClose, + onInputChange, + ...otherProps + } = this.props; + + return ( + + + {artistName} + + + +
+ { + isSmallScreen ? + null: +
+ +
+ } + +
+ { + overview ? +
+ +
: + null + } + +
+ + Root Folder + + + + + + + Monitor + + + } + title="Monitoring Options" + body={} + position={tooltipPositions.RIGHT} + /> + + + + + + + Quality Profile + + + + + + Metadata Profile + + + + + + Album Folder + + + + + + Tags + + + +
+
+
+
+ + + + + + Add {artistName} + + +
+ ); + } +} + +AddNewArtistModalContent.propTypes = { + artistName: PropTypes.string.isRequired, + overview: PropTypes.string, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + isAdding: PropTypes.bool.isRequired, + addError: PropTypes.object, + rootFolderPath: PropTypes.object, + monitor: PropTypes.object.isRequired, + qualityProfileId: PropTypes.object, + metadataProfileId: PropTypes.object, + albumFolder: PropTypes.object.isRequired, + tags: PropTypes.object.isRequired, + showMetadataProfile: PropTypes.bool.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired, + onInputChange: PropTypes.func.isRequired, + onAddArtistPress: PropTypes.func.isRequired +}; + +export default AddNewArtistModalContent; diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContentConnector.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContentConnector.js new file mode 100644 index 000000000..049d05813 --- /dev/null +++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContentConnector.js @@ -0,0 +1,105 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setAddArtistDefault, addArtist } from 'Store/Actions/addArtistActions'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import selectSettings from 'Store/Selectors/selectSettings'; +import AddNewArtistModalContent from './AddNewArtistModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.addArtist, + (state) => state.settings.metadataProfiles, + createDimensionsSelector(), + (addArtistState, metadataProfiles, dimensions) => { + const { + isAdding, + addError, + defaults + } = addArtistState; + + const { + settings, + validationErrors, + validationWarnings + } = selectSettings(defaults, {}, addError); + + return { + isAdding, + addError, + showMetadataProfile: metadataProfiles.items.length > 1, + isSmallScreen: dimensions.isSmallScreen, + validationErrors, + validationWarnings, + ...settings + }; + } + ); +} + +const mapDispatchToProps = { + setAddArtistDefault, + addArtist +}; + +class AddNewArtistModalContentConnector extends Component { + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setAddArtistDefault({ [name]: value }); + } + + onAddArtistPress = (searchForMissingAlbums) => { + const { + foreignArtistId, + rootFolderPath, + monitor, + qualityProfileId, + metadataProfileId, + albumFolder, + tags + } = this.props; + + this.props.addArtist({ + foreignArtistId, + rootFolderPath: rootFolderPath.value, + monitor: monitor.value, + qualityProfileId: qualityProfileId.value, + metadataProfileId: metadataProfileId.value, + albumFolder: albumFolder.value, + tags: tags.value, + searchForMissingAlbums + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AddNewArtistModalContentConnector.propTypes = { + foreignArtistId: PropTypes.string.isRequired, + rootFolderPath: PropTypes.object, + monitor: PropTypes.object.isRequired, + qualityProfileId: PropTypes.object, + metadataProfileId: PropTypes.object, + albumFolder: PropTypes.object.isRequired, + tags: PropTypes.object.isRequired, + onModalClose: PropTypes.func.isRequired, + setAddArtistDefault: PropTypes.func.isRequired, + addArtist: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AddNewArtistModalContentConnector); diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResult.css b/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResult.css new file mode 100644 index 000000000..c56765538 --- /dev/null +++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResult.css @@ -0,0 +1,42 @@ +.searchResult { + display: flex; + margin: 20px 0; + padding: 20px; + width: 100%; + background-color: $white; + color: inherit; + transition: background 500ms; + + &:hover { + background-color: #eaf2ff; + color: inherit; + text-decoration: none; + } +} + +.poster { + flex: 0 0 170px; + margin-right: 20px; + height: 250px; +} + +.name { + font-weight: 300; + font-size: 36px; +} + +.year { + margin-left: 10px; + color: $disabledColor; +} + +.alreadyExistsIcon { + margin-left: 10px; + color: #37bc9b; +} + +.overview { + overflow: hidden; + margin-top: 20px; + text-align: justify; +} diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResult.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResult.js new file mode 100644 index 000000000..8c5e54cbc --- /dev/null +++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResult.js @@ -0,0 +1,207 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import TextTruncate from 'react-text-truncate'; +import dimensions from 'Styles/Variables/dimensions'; +import fonts from 'Styles/Variables/fonts'; +import { icons, kinds, sizes } from 'Helpers/Props'; +import HeartRating from 'Components/HeartRating'; +import Icon from 'Components/Icon'; +import Label from 'Components/Label'; +import Link from 'Components/Link/Link'; +import ArtistPoster from 'Artist/ArtistPoster'; +import AddNewArtistModal from './AddNewArtistModal'; +import styles from './AddNewArtistSearchResult.css'; + +const columnPadding = parseInt(dimensions.artistIndexColumnPadding); +const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen); +const defaultFontSize = parseInt(fonts.defaultFontSize); +const lineHeight = parseFloat(fonts.lineHeight); + +function calculateHeight(rowHeight, isSmallScreen) { + let height = rowHeight - 45; + + if (isSmallScreen) { + height -= columnPaddingSmallScreen; + } else { + height -= columnPadding; + } + + return height; +} + +class AddNewArtistSearchResult extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isNewAddArtistModalOpen: false + }; + } + + componentDidUpdate(prevProps) { + if (!prevProps.isExistingArtist && this.props.isExistingArtist) { + this.onAddArtistModalClose(); + } + } + + // + // Listeners + + onPress = () => { + this.setState({ isNewAddArtistModalOpen: true }); + } + + onAddArtistModalClose = () => { + this.setState({ isNewAddArtistModalOpen: false }); + } + + // + // Render + + render() { + const { + foreignArtistId, + artistName, + year, + disambiguation, + artistType, + status, + overview, + ratings, + images, + isExistingArtist, + isSmallScreen + } = this.props; + + const { + isNewAddArtistModalOpen + } = this.state; + + const linkProps = isExistingArtist ? { to: `/artist/${foreignArtistId}` } : { onPress: this.onPress }; + + const endedString = artistType === 'Person' ? 'Deceased' : 'Ended'; + + const height = calculateHeight(230, isSmallScreen); + + return ( +
+ + { + isSmallScreen ? + null : + + } + +
+
+ {artistName} + + { + !name.contains(year) && year ? + + ({year}) + : + null + } + + { + !!disambiguation && + ({disambiguation}) + } + + { + isExistingArtist ? + : + null + } +
+ +
+ + + { + artistType ? + : + null + } + + { + status === 'ended' ? + : + null + } +
+ +
+ +
+
+ + + +
+ ); + } +} + +AddNewArtistSearchResult.propTypes = { + foreignArtistId: PropTypes.string.isRequired, + artistName: PropTypes.string.isRequired, + year: PropTypes.number, + disambiguation: PropTypes.string, + artistType: PropTypes.string, + status: PropTypes.string.isRequired, + overview: PropTypes.string, + ratings: PropTypes.object.isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + isExistingArtist: PropTypes.bool.isRequired, + isSmallScreen: PropTypes.bool.isRequired +}; + +export default AddNewArtistSearchResult; diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResultConnector.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResultConnector.js new file mode 100644 index 000000000..45165c04d --- /dev/null +++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResultConnector.js @@ -0,0 +1,20 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createExistingArtistSelector from 'Store/Selectors/createExistingArtistSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import AddNewArtistSearchResult from './AddNewArtistSearchResult'; + +function createMapStateToProps() { + return createSelector( + createExistingArtistSelector(), + createDimensionsSelector(), + (isExistingArtist, dimensions) => { + return { + isExistingArtist, + isSmallScreen: dimensions.isSmallScreen + }; + } + ); +} + +export default connect(createMapStateToProps)(AddNewArtistSearchResult); diff --git a/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.js b/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.js new file mode 100644 index 000000000..5b53425a4 --- /dev/null +++ b/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.js @@ -0,0 +1,46 @@ +import React from 'react'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; + +function ArtistMonitoringOptionsPopoverContent() { + return ( + + + + + + + + + + + + + + + + ); +} + +export default ArtistMonitoringOptionsPopoverContent; diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtist.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtist.js new file mode 100644 index 000000000..3ed2459d1 --- /dev/null +++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtist.js @@ -0,0 +1,173 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import ImportArtistTableConnector from './ImportArtistTableConnector'; +import ImportArtistFooterConnector from './ImportArtistFooterConnector'; + +class ImportArtist extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, + contentBody: null, + scrollTop: 0 + }; + } + + // + // Control + + setContentBodyRef = (ref) => { + this.setState({ contentBody: ref }); + } + + // + // Listeners + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState, { parseIds: false }); + } + + onSelectAllChange = ({ value }) => { + // Only select non-dupes + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onRemoveSelectedStateItem = (id) => { + this.setState((state) => { + const selectedState = Object.assign({}, state.selectedState); + delete selectedState[id]; + + return { + ...state, + selectedState + }; + }); + } + + onInputChange = ({ name, value }) => { + this.props.onInputChange(this.getSelectedIds(), name, value); + } + + onImportPress = () => { + this.props.onImportPress(this.getSelectedIds()); + } + + onScroll = ({ scrollTop }) => { + this.setState({ scrollTop }); + } + + // + // Render + + render() { + const { + rootFolderId, + path, + rootFoldersFetching, + rootFoldersPopulated, + rootFoldersError, + unmappedFolders, + showMetadataProfile + } = this.props; + + const { + allSelected, + allUnselected, + selectedState, + contentBody + } = this.state; + + return ( + + + { + rootFoldersFetching && !rootFoldersPopulated && + + } + + { + !rootFoldersFetching && !!rootFoldersError && +
Unable to load root folders
+ } + + { + !rootFoldersError && rootFoldersPopulated && !unmappedFolders.length && +
+ All artist in {path} have been imported +
+ } + + { + !rootFoldersError && rootFoldersPopulated && !!unmappedFolders.length && contentBody && + + } +
+ + { + !rootFoldersError && rootFoldersPopulated && !!unmappedFolders.length && + + } +
+ ); + } +} + +ImportArtist.propTypes = { + rootFolderId: PropTypes.number.isRequired, + path: PropTypes.string, + rootFoldersFetching: PropTypes.bool.isRequired, + rootFoldersPopulated: PropTypes.bool.isRequired, + rootFoldersError: PropTypes.object, + unmappedFolders: PropTypes.arrayOf(PropTypes.object), + items: PropTypes.arrayOf(PropTypes.object), + showMetadataProfile: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onImportPress: PropTypes.func.isRequired +}; + +ImportArtist.defaultProps = { + unmappedFolders: [] +}; + +export default ImportArtist; diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistConnector.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistConnector.js new file mode 100644 index 000000000..4ce182bbd --- /dev/null +++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistConnector.js @@ -0,0 +1,170 @@ +/* eslint max-params: 0 */ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setImportArtistValue, importArtist, clearImportArtist } from 'Store/Actions/importArtistActions'; +import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import { setAddArtistDefault } from 'Store/Actions/addArtistActions'; +import createRouteMatchShape from 'Helpers/Props/Shapes/createRouteMatchShape'; +import ImportArtist from './ImportArtist'; + +function createMapStateToProps() { + return createSelector( + (state, { match }) => match, + (state) => state.rootFolders, + (state) => state.addArtist, + (state) => state.importArtist, + (state) => state.settings.qualityProfiles, + (state) => state.settings.metadataProfiles, + ( + match, + rootFolders, + addArtist, + importArtistState, + qualityProfiles, + metadataProfiles + ) => { + const { + isFetching: rootFoldersFetching, + isPopulated: rootFoldersPopulated, + error: rootFoldersError, + items + } = rootFolders; + + const rootFolderId = parseInt(match.params.rootFolderId); + + const result = { + rootFolderId, + rootFoldersFetching, + rootFoldersPopulated, + rootFoldersError, + qualityProfiles: qualityProfiles.items, + metadataProfiles: metadataProfiles.items, + showMetadataProfile: metadataProfiles.items.length > 1, + defaultQualityProfileId: addArtist.defaults.qualityProfileId, + defaultMetadataProfileId: addArtist.defaults.metadataProfileId + }; + + if (items.length) { + const rootFolder = _.find(items, { id: rootFolderId }); + + return { + ...result, + ...rootFolder, + items: importArtistState.items + }; + } + + return result; + } + ); +} + +const mapDispatchToProps = { + dispatchSetImportArtistValue: setImportArtistValue, + dispatchImportArtist: importArtist, + dispatchClearImportArtist: clearImportArtist, + dispatchFetchRootFolders: fetchRootFolders, + dispatchSetAddArtistDefault: setAddArtistDefault +}; + +class ImportArtistConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + qualityProfiles, + metadataProfiles, + defaultQualityProfileId, + defaultMetadataProfileId, + dispatchFetchRootFolders, + dispatchSetAddArtistDefault + } = this.props; + + if (!this.props.rootFoldersPopulated) { + dispatchFetchRootFolders(); + } + + let setDefaults = false; + const setDefaultPayload = {}; + + if ( + !defaultQualityProfileId || + !qualityProfiles.some((p) => p.id === defaultQualityProfileId) + ) { + setDefaults = true; + setDefaultPayload.qualityProfileId = qualityProfiles[0].id; + } + + if ( + !defaultMetadataProfileId || + !metadataProfiles.some((p) => p.id === defaultMetadataProfileId) + ) { + setDefaults = true; + setDefaultPayload.metadataProfileId = metadataProfiles[0].id; + } + + if (setDefaults) { + dispatchSetAddArtistDefault(setDefaultPayload); + } + } + + componentWillUnmount() { + this.props.dispatchClearImportArtist(); + } + + // + // Listeners + + onInputChange = (ids, name, value) => { + this.props.dispatchSetAddArtistDefault({ [name]: value }); + + ids.forEach((id) => { + this.props.dispatchSetImportArtistValue({ + id, + [name]: value + }); + }); + } + + onImportPress = (ids) => { + this.props.dispatchImportArtist({ ids }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +const routeMatchShape = createRouteMatchShape({ + rootFolderId: PropTypes.string.isRequired +}); + +ImportArtistConnector.propTypes = { + match: routeMatchShape.isRequired, + rootFoldersPopulated: PropTypes.bool.isRequired, + qualityProfiles: PropTypes.arrayOf(PropTypes.object).isRequired, + metadataProfiles: PropTypes.arrayOf(PropTypes.object).isRequired, + defaultQualityProfileId: PropTypes.number.isRequired, + defaultMetadataProfileId: PropTypes.number.isRequired, + dispatchSetImportArtistValue: PropTypes.func.isRequired, + dispatchImportArtist: PropTypes.func.isRequired, + dispatchClearImportArtist: PropTypes.func.isRequired, + dispatchFetchRootFolders: PropTypes.func.isRequired, + dispatchSetAddArtistDefault: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistConnector); diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.css b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.css new file mode 100644 index 000000000..616aeaf3c --- /dev/null +++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.css @@ -0,0 +1,33 @@ +.inputContainer { + margin-right: 20px; + min-width: 150px; +} + +.label { + margin-bottom: 3px; + font-weight: bold; +} + +.importButtonContainer { + display: flex; + align-items: center; +} + +.importButton { + composes: button from '~Components/Link/SpinnerButton.css'; + + height: 35px; +} + +.loadingButton { + composes: importButton; + + margin-left: 10px; +} + +.loading { + composes: loading from '~Components/Loading/LoadingIndicator.css'; + + margin: 0 10px 0 12px; + text-align: left; +} diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.js new file mode 100644 index 000000000..a0feaad89 --- /dev/null +++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.js @@ -0,0 +1,261 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import CheckInput from 'Components/Form/CheckInput'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import PageContentFooter from 'Components/Page/PageContentFooter'; +import styles from './ImportArtistFooter.css'; + +const MIXED = 'mixed'; + +class ImportArtistFooter extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + defaultMonitor, + defaultQualityProfileId, + defaultMetadataProfileId, + defaultAlbumFolder + } = props; + + this.state = { + monitor: defaultMonitor, + qualityProfileId: defaultQualityProfileId, + metadataProfileId: defaultMetadataProfileId, + albumFolder: defaultAlbumFolder + }; + } + + componentDidUpdate(prevProps, prevState) { + const { + defaultMonitor, + defaultQualityProfileId, + defaultMetadataProfileId, + defaultAlbumFolder, + isMonitorMixed, + isQualityProfileIdMixed, + isMetadataProfileIdMixed, + isAlbumFolderMixed + } = this.props; + + const { + monitor, + qualityProfileId, + metadataProfileId, + albumFolder + } = this.state; + + const newState = {}; + + if (isMonitorMixed && monitor !== MIXED) { + newState.monitor = MIXED; + } else if (!isMonitorMixed && monitor !== defaultMonitor) { + newState.monitor = defaultMonitor; + } + + if (isQualityProfileIdMixed && qualityProfileId !== MIXED) { + newState.qualityProfileId = MIXED; + } else if (!isQualityProfileIdMixed && qualityProfileId !== defaultQualityProfileId) { + newState.qualityProfileId = defaultQualityProfileId; + } + + if (isMetadataProfileIdMixed && metadataProfileId !== MIXED) { + newState.metadataProfileId = MIXED; + } else if (!isMetadataProfileIdMixed && metadataProfileId !== defaultMetadataProfileId) { + newState.metadataProfileId = defaultMetadataProfileId; + } + + if (isAlbumFolderMixed && albumFolder != null) { + newState.albumFolder = null; + } else if (!isAlbumFolderMixed && albumFolder !== defaultAlbumFolder) { + newState.albumFolder = defaultAlbumFolder; + } + + if (!_.isEmpty(newState)) { + this.setState(newState); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.setState({ [name]: value }); + this.props.onInputChange({ name, value }); + } + + // + // Render + + render() { + const { + selectedCount, + isImporting, + isLookingUpArtist, + isMonitorMixed, + isQualityProfileIdMixed, + isMetadataProfileIdMixed, + hasUnsearchedItems, + showMetadataProfile, + onImportPress, + onLookupPress, + onCancelLookupPress + } = this.props; + + const { + monitor, + qualityProfileId, + metadataProfileId, + albumFolder + } = this.state; + + return ( + +
+
+ Monitor +
+ + +
+ +
+
+ Quality Profile +
+ + +
+ + { + showMetadataProfile && +
+
+ Metadata Profile +
+ + +
+ } + +
+
+ Album Folder +
+ + +
+ +
+
+   +
+ +
+ + Import {selectedCount} Artist(s) + + + { + isLookingUpArtist && + + } + + { + hasUnsearchedItems && + + } + + { + isLookingUpArtist && + + } + + { + isLookingUpArtist && + 'Processing Folders' + } +
+
+
+ ); + } +} + +ImportArtistFooter.propTypes = { + selectedCount: PropTypes.number.isRequired, + isImporting: PropTypes.bool.isRequired, + isLookingUpArtist: PropTypes.bool.isRequired, + defaultMonitor: PropTypes.string.isRequired, + defaultQualityProfileId: PropTypes.number, + defaultMetadataProfileId: PropTypes.number, + defaultAlbumFolder: PropTypes.bool.isRequired, + isMonitorMixed: PropTypes.bool.isRequired, + isQualityProfileIdMixed: PropTypes.bool.isRequired, + isMetadataProfileIdMixed: PropTypes.bool.isRequired, + isAlbumFolderMixed: PropTypes.bool.isRequired, + hasUnsearchedItems: PropTypes.bool.isRequired, + showMetadataProfile: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onImportPress: PropTypes.func.isRequired, + onLookupPress: PropTypes.func.isRequired, + onCancelLookupPress: PropTypes.func.isRequired +}; + +export default ImportArtistFooter; diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooterConnector.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooterConnector.js new file mode 100644 index 000000000..873d13b28 --- /dev/null +++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooterConnector.js @@ -0,0 +1,61 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import ImportArtistFooter from './ImportArtistFooter'; +import { lookupUnsearchedArtist, cancelLookupArtist } from 'Store/Actions/importArtistActions'; + +function isMixed(items, selectedIds, defaultValue, key) { + return _.some(items, (artist) => { + return selectedIds.indexOf(artist.id) > -1 && artist[key] !== defaultValue; + }); +} + +function createMapStateToProps() { + return createSelector( + (state) => state.addArtist, + (state) => state.importArtist, + (state, { selectedIds }) => selectedIds, + (addArtist, importArtist, selectedIds) => { + const { + monitor: defaultMonitor, + qualityProfileId: defaultQualityProfileId, + metadataProfileId: defaultMetadataProfileId, + albumFolder: defaultAlbumFolder + } = addArtist.defaults; + + const { + isLookingUpArtist, + isImporting, + items + } = importArtist; + + const isMonitorMixed = isMixed(items, selectedIds, defaultMonitor, 'monitor'); + const isQualityProfileIdMixed = isMixed(items, selectedIds, defaultQualityProfileId, 'qualityProfileId'); + const isMetadataProfileIdMixed = isMixed(items, selectedIds, defaultMetadataProfileId, 'metadataProfileId'); + const isAlbumFolderMixed = isMixed(items, selectedIds, defaultAlbumFolder, 'albumFolder'); + const hasUnsearchedItems = !isLookingUpArtist && items.some((item) => !item.isPopulated); + + return { + selectedCount: selectedIds.length, + isLookingUpArtist, + isImporting, + defaultMonitor, + defaultQualityProfileId, + defaultMetadataProfileId, + defaultAlbumFolder, + isMonitorMixed, + isQualityProfileIdMixed, + isMetadataProfileIdMixed, + isAlbumFolderMixed, + hasUnsearchedItems + }; + } + ); +} + +const mapDispatchToProps = { + onLookupPress: lookupUnsearchedArtist, + onCancelLookupPress: cancelLookupArtist +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistFooter); diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.css b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.css new file mode 100644 index 000000000..52b918403 --- /dev/null +++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.css @@ -0,0 +1,38 @@ +.folder { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 1 0 200px; +} + +.monitor { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 1 200px; + min-width: 185px; +} + +.qualityProfile, +.metadataProfile { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 1 250px; + min-width: 170px; +} + +.albumFolder { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 1 150px; + min-width: 120px; +} + +.artist { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 1 400px; + min-width: 300px; +} + +.detailsIcon { + margin-left: 8px; +} diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.js new file mode 100644 index 000000000..fb0a01cb7 --- /dev/null +++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.js @@ -0,0 +1,96 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Popover from 'Components/Tooltip/Popover'; +import VirtualTableHeader from 'Components/Table/VirtualTableHeader'; +import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; +import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell'; +import ArtistMonitoringOptionsPopoverContent from 'AddArtist/ArtistMonitoringOptionsPopoverContent'; +// import SeriesTypePopoverContent from 'AddArtist/SeriesTypePopoverContent'; +import styles from './ImportArtistHeader.css'; + +function ImportArtistHeader(props) { + const { + showMetadataProfile, + allSelected, + allUnselected, + onSelectAllChange + } = props; + + return ( + + + + + Folder + + + + Monitor + + + } + title="Monitoring Options" + body={} + position={tooltipPositions.RIGHT} + /> + + + + Quality Profile + + + { + showMetadataProfile && + + Metadata Profile + + } + + + Album Folder + + + + Artist + + + ); +} + +ImportArtistHeader.propTypes = { + showMetadataProfile: PropTypes.bool.isRequired, + allSelected: PropTypes.bool.isRequired, + allUnselected: PropTypes.bool.isRequired, + onSelectAllChange: PropTypes.func.isRequired +}; + +export default ImportArtistHeader; diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.css b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.css new file mode 100644 index 000000000..f5e6ed2e5 --- /dev/null +++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.css @@ -0,0 +1,45 @@ +.selectInput { + composes: input from '~Components/Form/CheckInput.css'; +} + +.folder { + composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 1 0 200px; + line-height: 36px; +} + +.monitor { + composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 1 200px; + min-width: 185px; +} + +.qualityProfile, +.metadataProfile { + composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 1 250px; + min-width: 170px; +} + +.albumFolder { + composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 1 150px; + min-width: 120px; +} + +.artist { + composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 1 400px; + min-width: 300px; +} + +.hideMetadataProfile { + composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css'; + + display: none; +} diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.js new file mode 100644 index 000000000..ca3f32132 --- /dev/null +++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.js @@ -0,0 +1,109 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import VirtualTableRow from 'Components/Table/VirtualTableRow'; +import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; +import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell'; +import ImportArtistSelectArtistConnector from './SelectArtist/ImportArtistSelectArtistConnector'; +import styles from './ImportArtistRow.css'; + +function ImportArtistRow(props) { + const { + style, + id, + monitor, + qualityProfileId, + metadataProfileId, + albumFolder, + selectedArtist, + isExistingArtist, + showMetadataProfile, + isSelected, + onSelectedChange, + onInputChange + } = props; + + return ( + + + + + {id} + + + + + + + + + + + + + + + + + + + + + + + ); +} + +ImportArtistRow.propTypes = { + style: PropTypes.object.isRequired, + id: PropTypes.string.isRequired, + monitor: PropTypes.string.isRequired, + qualityProfileId: PropTypes.number.isRequired, + metadataProfileId: PropTypes.number.isRequired, + albumFolder: PropTypes.bool.isRequired, + selectedArtist: PropTypes.object, + isExistingArtist: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + showMetadataProfile: PropTypes.bool.isRequired, + isSelected: PropTypes.bool, + onSelectedChange: PropTypes.func.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +ImportArtistRow.defaultsProps = { + items: [] +}; + +export default ImportArtistRow; diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRowConnector.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRowConnector.js new file mode 100644 index 000000000..2480bfdb6 --- /dev/null +++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRowConnector.js @@ -0,0 +1,87 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setImportArtistValue } from 'Store/Actions/importArtistActions'; +import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; +import ImportArtistRow from './ImportArtistRow'; + +function createImportArtistItemSelector() { + return createSelector( + (state, { id }) => id, + (state) => state.importArtist.items, + (id, items) => { + return _.find(items, { id }) || {}; + } + ); +} + +function createMapStateToProps() { + return createSelector( + createImportArtistItemSelector(), + createAllArtistSelector(), + (item, artist) => { + const selectedArtist = item && item.selectedArtist; + const isExistingArtist = !!selectedArtist && _.some(artist, { foreignArtistId: selectedArtist.foreignArtistId }); + + return { + ...item, + isExistingArtist + }; + } + ); +} + +const mapDispatchToProps = { + setImportArtistValue +}; + +class ImportArtistRowConnector extends Component { + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setImportArtistValue({ + id: this.props.id, + [name]: value + }); + } + + // + // Render + + render() { + // Don't show the row until we have the information we require for it. + + const { + items, + monitor, + albumFolder + } = this.props; + + if (!items || !monitor || !albumFolder == null) { + return null; + } + + return ( + + ); + } +} + +ImportArtistRowConnector.propTypes = { + rootFolderId: PropTypes.number.isRequired, + id: PropTypes.string.isRequired, + monitor: PropTypes.string, + albumFolder: PropTypes.bool, + items: PropTypes.arrayOf(PropTypes.object), + setImportArtistValue: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistRowConnector); diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistSelected.css b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistSelected.css new file mode 100644 index 000000000..51fe4ce39 --- /dev/null +++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistSelected.css @@ -0,0 +1,3 @@ +.input { + composes: input from '~Components/Form/CheckInput.css'; +} diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTable.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTable.js new file mode 100644 index 000000000..f2c5f92eb --- /dev/null +++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTable.js @@ -0,0 +1,194 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import VirtualTable from 'Components/Table/VirtualTable'; +import ImportArtistHeader from './ImportArtistHeader'; +import ImportArtistRowConnector from './ImportArtistRowConnector'; + +class ImportArtistTable extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + unmappedFolders, + defaultMonitor, + defaultQualityProfileId, + defaultMetadataProfileId, + defaultAlbumFolder, + onArtistLookup, + onSetImportArtistValue + } = this.props; + + const values = { + monitor: defaultMonitor, + qualityProfileId: defaultQualityProfileId, + metadataProfileId: defaultMetadataProfileId, + albumFolder: defaultAlbumFolder + }; + + unmappedFolders.forEach((unmappedFolder) => { + const id = unmappedFolder.name; + + onArtistLookup(id, unmappedFolder.path); + + onSetImportArtistValue({ + id, + ...values + }); + }); + } + + // This isn't great, but it's the most reliable way to ensure the items + // are checked off even if they aren't actually visible since the cells + // are virtualized. + + componentDidUpdate(prevProps) { + const { + items, + selectedState, + onSelectedChange, + onRemoveSelectedStateItem + } = this.props; + + prevProps.items.forEach((prevItem) => { + const { + id + } = prevItem; + + const item = _.find(items, { id }); + + if (!item) { + onRemoveSelectedStateItem(id); + return; + } + + const selectedArtist = item.selectedArtist; + const isSelected = selectedState[id]; + + const isExistingArtist = !!selectedArtist && + _.some(prevProps.allArtists, { foreignArtistId: selectedArtist.foreignArtistId }); + + // Props doesn't have a selected artist or + // the selected artist is an existing artist. + if ((!selectedArtist && prevItem.selectedArtist) || (isExistingArtist && !prevItem.selectedArtist)) { + onSelectedChange({ id, value: false }); + + return; + } + + // State is selected, but a artist isn't selected or + // the selected artist is an existing artist. + if (isSelected && (!selectedArtist || isExistingArtist)) { + onSelectedChange({ id, value: false }); + + return; + } + + // A artist is being selected that wasn't previously selected. + if (selectedArtist && selectedArtist !== prevItem.selectedArtist) { + onSelectedChange({ id, value: true }); + + return; + } + }); + } + + // + // Control + + rowRenderer = ({ key, rowIndex, style }) => { + const { + rootFolderId, + items, + selectedState, + showMetadataProfile, + onSelectedChange + } = this.props; + + const item = items[rowIndex]; + + return ( + + ); + } + + // + // Render + + render() { + const { + items, + allSelected, + allUnselected, + isSmallScreen, + contentBody, + showMetadataProfile, + scrollTop, + selectedState, + onSelectAllChange, + onScroll + } = this.props; + + if (!items.length) { + return null; + } + + return ( + + } + selectedState={selectedState} + onScroll={onScroll} + /> + ); + } +} + +ImportArtistTable.propTypes = { + rootFolderId: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object), + unmappedFolders: PropTypes.arrayOf(PropTypes.object), + defaultMonitor: PropTypes.string.isRequired, + defaultQualityProfileId: PropTypes.number, + defaultMetadataProfileId: PropTypes.number, + defaultAlbumFolder: PropTypes.bool.isRequired, + allSelected: PropTypes.bool.isRequired, + allUnselected: PropTypes.bool.isRequired, + selectedState: PropTypes.object.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + allArtists: PropTypes.arrayOf(PropTypes.object), + contentBody: PropTypes.object.isRequired, + showMetadataProfile: PropTypes.bool.isRequired, + scrollTop: PropTypes.number.isRequired, + onSelectAllChange: PropTypes.func.isRequired, + onSelectedChange: PropTypes.func.isRequired, + onRemoveSelectedStateItem: PropTypes.func.isRequired, + onArtistLookup: PropTypes.func.isRequired, + onSetImportArtistValue: PropTypes.func.isRequired, + onScroll: PropTypes.func.isRequired +}; + +export default ImportArtistTable; diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTableConnector.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTableConnector.js new file mode 100644 index 000000000..fd7bf4fe2 --- /dev/null +++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTableConnector.js @@ -0,0 +1,43 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { queueLookupArtist, setImportArtistValue } from 'Store/Actions/importArtistActions'; +import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; +import ImportArtistTable from './ImportArtistTable'; + +function createMapStateToProps() { + return createSelector( + (state) => state.addArtist, + (state) => state.importArtist, + (state) => state.app.dimensions, + createAllArtistSelector(), + (addArtist, importArtist, dimensions, allArtists) => { + return { + defaultMonitor: addArtist.defaults.monitor, + defaultQualityProfileId: addArtist.defaults.qualityProfileId, + defaultMetadataProfileId: addArtist.defaults.metadataProfileId, + defaultAlbumFolder: addArtist.defaults.albumFolder, + items: importArtist.items, + isSmallScreen: dimensions.isSmallScreen, + allArtists + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onArtistLookup(name, path) { + dispatch(queueLookupArtist({ + name, + path, + term: name + })); + }, + + onSetImportArtistValue(values) { + dispatch(setImportArtistValue(values)); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(ImportArtistTable); diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.css b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.css new file mode 100644 index 000000000..fc86c41d1 --- /dev/null +++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.css @@ -0,0 +1,19 @@ +.artistNameContainer { + display: flex; + align-items: center; + flex: 0 1 auto; + overflow: hidden; +} + +.artistName { + @add-mixin truncate; +} + +.disambiguation { + margin-right: 5px; + color: $disabledColor; +} + +.existing { + margin-left: 5px; +} diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.js new file mode 100644 index 000000000..1d9fb21b7 --- /dev/null +++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.js @@ -0,0 +1,41 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Label from 'Components/Label'; +import styles from './ImportArtistName.css'; + +function ImportArtistName(props) { + const { + artistName, + disambiguation, + isExistingArtist + } = props; + + return ( +
+
+ {artistName} +
+
+ {disambiguation} +
+ + { + isExistingArtist && + + } +
+ ); +} + +ImportArtistName.propTypes = { + artistName: PropTypes.string.isRequired, + disambiguation: PropTypes.string, + isExistingArtist: PropTypes.bool.isRequired +}; + +export default ImportArtistName; diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.css b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.css new file mode 100644 index 000000000..f7bc065b5 --- /dev/null +++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.css @@ -0,0 +1,8 @@ +.artist { + padding: 10px 20px; + width: 100%; + + &:hover { + background-color: $menuItemHoverBackgroundColor; + } +} diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.js new file mode 100644 index 000000000..aa489f0fb --- /dev/null +++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.js @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import ImportArtistName from './ImportArtistName'; +import styles from './ImportArtistSearchResult.css'; + +class ImportArtistSearchResult extends Component { + + // + // Listeners + + onPress = () => { + this.props.onPress(this.props.foreignArtistId); + } + + // + // Render + + render() { + const { + artistName, + disambiguation, + // year, + isExistingArtist + } = this.props; + + return ( + + + + ); + } +} + +ImportArtistSearchResult.propTypes = { + foreignArtistId: PropTypes.string.isRequired, + artistName: PropTypes.string.isRequired, + disambiguation: PropTypes.string, + // year: PropTypes.number.isRequired, + isExistingArtist: PropTypes.bool.isRequired, + onPress: PropTypes.func.isRequired +}; + +export default ImportArtistSearchResult; diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResultConnector.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResultConnector.js new file mode 100644 index 000000000..cdbcc03b3 --- /dev/null +++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResultConnector.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createExistingArtistSelector from 'Store/Selectors/createExistingArtistSelector'; +import ImportArtistSearchResult from './ImportArtistSearchResult'; + +function createMapStateToProps() { + return createSelector( + createExistingArtistSelector(), + (isExistingArtist) => { + return { + isExistingArtist + }; + } + ); +} + +export default connect(createMapStateToProps)(ImportArtistSearchResult); diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.css b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.css new file mode 100644 index 000000000..6bdfd093e --- /dev/null +++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.css @@ -0,0 +1,77 @@ +.button { + composes: link from '~Components/Link/Link.css'; + + display: flex; + align-items: center; + padding: 6px 16px; + width: 100%; + height: 35px; + border: 1px solid $inputBorderColor; + border-radius: 4px; + background-color: $white; + box-shadow: inset 0 1px 1px $inputBoxShadowColor; +} + +.loading { + display: inline-block; +} + +.warningIcon { + margin-right: 8px; +} + +.existing { + margin-left: 5px; +} + +.dropdownArrowContainer { + flex: 1 0 auto; + margin-left: 5px; + text-align: right; +} + +.contentContainer { + z-index: $popperZIndex; + margin-top: 4px; + /* 400px container witdh with 8px padding on each side */ + width: 384px; +} + +.content { + padding: 4px; + border: 1px solid $inputBorderColor; + border-radius: 4px; + background-color: $white; +} + +.searchContainer { + display: flex; +} + +.searchIconContainer { + width: 58px; + border: 1px solid $inputBorderColor; + border-right: none; + border-radius: 4px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + background-color: #edf1f2; + text-align: center; + line-height: 33px; +} + +.searchInput { + composes: input from '~Components/Form/TextInput.css'; + + border-radius: 0; +} + +.results { + @add-mixin scrollbar; + @add-mixin scrollbarTrack; + @add-mixin scrollbarThumb; + + overflow-x: hidden; + overflow-y: scroll; + max-height: 165px; +} diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.js new file mode 100644 index 000000000..68c448d1c --- /dev/null +++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.js @@ -0,0 +1,303 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { Manager, Popper, Reference } from 'react-popper'; +import getUniqueElememtId from 'Utilities/getUniqueElementId'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Portal from 'Components/Portal'; +import FormInputButton from 'Components/Form/FormInputButton'; +import Link from 'Components/Link/Link'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import TextInput from 'Components/Form/TextInput'; +import ImportArtistSearchResultConnector from './ImportArtistSearchResultConnector'; +import ImportArtistName from './ImportArtistName'; +import styles from './ImportArtistSelectArtist.css'; + +class ImportArtistSelectArtist extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._artistLookupTimeout = null; + this._scheduleUpdate = null; + this._buttonId = getUniqueElememtId(); + this._contentId = getUniqueElememtId(); + + this.state = { + term: props.id, + isOpen: false + }; + } + + componentDidUpdate() { + if (this._scheduleUpdate) { + this._scheduleUpdate(); + } + } + + // + // Control + + _addListener() { + window.addEventListener('click', this.onWindowClick); + } + + _removeListener() { + window.removeEventListener('click', this.onWindowClick); + } + + // + // Listeners + + onWindowClick = (event) => { + const button = document.getElementById(this._buttonId); + const content = document.getElementById(this._contentId); + + if (!button || !content) { + return; + } + + if ( + !button.contains(event.target) && + !content.contains(event.target) && + this.state.isOpen + ) { + this.setState({ isOpen: false }); + this._removeListener(); + } + } + + onPress = () => { + if (this.state.isOpen) { + this._removeListener(); + } else { + this._addListener(); + } + + this.setState({ isOpen: !this.state.isOpen }); + } + + onSearchInputChange = ({ value }) => { + if (this._artistLookupTimeout) { + clearTimeout(this._artistLookupTimeout); + } + + this.setState({ term: value }, () => { + this._artistLookupTimeout = setTimeout(() => { + this.props.onSearchInputChange(value); + }, 200); + }); + } + + onRefreshPress = () => { + this.props.onSearchInputChange(this.state.term); + } + + onArtistSelect = (foreignArtistId) => { + this.setState({ isOpen: false }); + + this.props.onArtistSelect(foreignArtistId); + } + + // + // Render + + render() { + const { + selectedArtist, + isExistingArtist, + isFetching, + isPopulated, + error, + items, + isQueued, + isLookingUpArtist + } = this.props; + + const errorMessage = error && + error.responseJSON && + error.responseJSON.message; + + return ( + + + {({ ref }) => ( +
+ + { + isLookingUpArtist && isQueued && !isPopulated ? + : + null + } + + { + isPopulated && selectedArtist && isExistingArtist ? + : + null + } + + { + isPopulated && selectedArtist ? + : + null + } + + { + isPopulated && !selectedArtist ? +
+ + + No match found! +
: + null + } + + { + !isFetching && !!error ? +
+ + + Search failed, please try again later. +
: + null + } + +
+ +
+ +
+ )} +
+ + + + {({ ref, style, scheduleUpdate }) => { + this._scheduleUpdate = scheduleUpdate; + + return ( +
+ { + this.state.isOpen ? +
+
+
+ +
+ + + + + + +
+ +
+ { + items.map((item) => { + return ( + + ); + }) + } +
+
: + null + } + +
+ ); + }} +
+
+
+ ); + } +} + +ImportArtistSelectArtist.propTypes = { + id: PropTypes.string.isRequired, + selectedArtist: PropTypes.object, + isExistingArtist: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + isQueued: PropTypes.bool.isRequired, + isLookingUpArtist: PropTypes.bool.isRequired, + onSearchInputChange: PropTypes.func.isRequired, + onArtistSelect: PropTypes.func.isRequired +}; + +ImportArtistSelectArtist.defaultProps = { + isFetching: true, + isPopulated: false, + items: [], + isQueued: true +}; + +export default ImportArtistSelectArtist; diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtistConnector.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtistConnector.js new file mode 100644 index 000000000..21e2bcab2 --- /dev/null +++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtistConnector.js @@ -0,0 +1,76 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { queueLookupArtist, setImportArtistValue } from 'Store/Actions/importArtistActions'; +import createImportArtistItemSelector from 'Store/Selectors/createImportArtistItemSelector'; +import ImportArtistSelectArtist from './ImportArtistSelectArtist'; + +function createMapStateToProps() { + return createSelector( + (state) => state.importArtist.isLookingUpArtist, + createImportArtistItemSelector(), + (isLookingUpArtist, item) => { + return { + isLookingUpArtist, + ...item + }; + } + ); +} + +const mapDispatchToProps = { + queueLookupArtist, + setImportArtistValue +}; + +class ImportArtistSelectArtistConnector extends Component { + + // + // Listeners + + onSearchInputChange = (term) => { + this.props.queueLookupArtist({ + name: this.props.id, + term, + topOfQueue: true + }); + } + + onArtistSelect = (foreignArtistId) => { + const { + id, + items + } = this.props; + + this.props.setImportArtistValue({ + id, + selectedArtist: _.find(items, { foreignArtistId }) + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ImportArtistSelectArtistConnector.propTypes = { + id: PropTypes.string.isRequired, + items: PropTypes.arrayOf(PropTypes.object), + selectedArtist: PropTypes.object, + isSelected: PropTypes.bool, + queueLookupArtist: PropTypes.func.isRequired, + setImportArtistValue: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistSelectArtistConnector); diff --git a/frontend/src/AddArtist/ImportArtist/ImportArtist.js b/frontend/src/AddArtist/ImportArtist/ImportArtist.js new file mode 100644 index 000000000..ce5ec27ee --- /dev/null +++ b/frontend/src/AddArtist/ImportArtist/ImportArtist.js @@ -0,0 +1,30 @@ +import React, { Component } from 'react'; +import { Route } from 'react-router-dom'; +import Switch from 'Components/Router/Switch'; +import ImportArtistSelectFolderConnector from 'AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolderConnector'; +import ImportArtistConnector from 'AddArtist/ImportArtist/Import/ImportArtistConnector'; + +class ImportArtist extends Component { + + // + // Render + + render() { + return ( + + + + + + ); + } +} + +export default ImportArtist; diff --git a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.css b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.css new file mode 100644 index 000000000..030da96fb --- /dev/null +++ b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.css @@ -0,0 +1,32 @@ +.header { + margin-bottom: 40px; + text-align: center; + font-weight: 300; + font-size: 36px; +} + +.tips { + font-size: 20px; +} + +.tip { + font-size: $defaultFontSize; +} + +.code { + font-size: 12px; + font-family: $monoSpaceFontFamily; +} + +.recentFolders { + margin-top: 40px; +} + +.startImport { + margin-top: 40px; + text-align: center; +} + +.importButtonIcon { + margin-right: 8px; +} diff --git a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.js b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.js new file mode 100644 index 000000000..9a7253ec7 --- /dev/null +++ b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.js @@ -0,0 +1,147 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import FieldSet from 'Components/FieldSet'; +import Icon from 'Components/Icon'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import RootFolders from 'RootFolder/RootFolders'; +import styles from './ImportArtistSelectFolder.css'; + +class ImportArtistSelectFolder extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddNewRootFolderModalOpen: false + }; + } + + // + // Lifecycle + + onAddNewRootFolderPress = () => { + this.setState({ isAddNewRootFolderModalOpen: true }); + } + + onNewRootFolderSelect = ({ value }) => { + this.props.onNewRootFolderSelect(value); + } + + onAddRootFolderModalClose = () => { + this.setState({ isAddNewRootFolderModalOpen: false }); + } + + // + // Render + + render() { + const { + isWindows, + isFetching, + isPopulated, + error, + items + } = this.props; + + return ( + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
Unable to load root folders
+ } + + { + !error && isPopulated && +
+
+ Import artist(s) you already have +
+ +
+ Some tips to ensure the import goes smoothly: +
    +
  • + Point Lidarr to the folder containing all of your music not a specific one. eg. "{isWindows ? 'C:\\music' : '/music'}" and not "{isWindows ? 'C:\\music\\sublime' : '/music/sublime'}" +
  • +
+
+ + { + items.length > 0 ? +
+
+ +
+ + +
: + +
+ +
+ } + + +
+ } +
+
+ ); + } +} + +ImportArtistSelectFolder.propTypes = { + isWindows: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onNewRootFolderSelect: PropTypes.func.isRequired +}; + +export default ImportArtistSelectFolder; diff --git a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolderConnector.js b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolderConnector.js new file mode 100644 index 000000000..8354ed4da --- /dev/null +++ b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolderConnector.js @@ -0,0 +1,84 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { push } from 'connected-react-router'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; +import { fetchRootFolders, addRootFolder } from 'Store/Actions/rootFolderActions'; +import ImportArtistSelectFolder from './ImportArtistSelectFolder'; + +function createMapStateToProps() { + return createSelector( + (state) => state.rootFolders, + createSystemStatusSelector(), + (rootFolders, systemStatus) => { + return { + ...rootFolders, + isWindows: systemStatus.isWindows + }; + } + ); +} + +const mapDispatchToProps = { + fetchRootFolders, + addRootFolder, + push +}; + +class ImportArtistSelectFolderConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchRootFolders(); + } + + componentDidUpdate(prevProps) { + const { + items, + isSaving, + saveError + } = this.props; + + if (prevProps.isSaving && !isSaving && !saveError) { + const newRootFolders = _.differenceBy(items, prevProps.items, (item) => item.id); + + if (newRootFolders.length === 1) { + this.props.push(`${window.Lidarr.urlBase}/add/import/${newRootFolders[0].id}`); + } + } + } + + // + // Listeners + + onNewRootFolderSelect = (path) => { + this.props.addRootFolder({ path }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ImportArtistSelectFolderConnector.propTypes = { + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchRootFolders: PropTypes.func.isRequired, + addRootFolder: PropTypes.func.isRequired, + push: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistSelectFolderConnector); diff --git a/frontend/src/Album/AlbumCover.js b/frontend/src/Album/AlbumCover.js new file mode 100644 index 000000000..538fa5db8 --- /dev/null +++ b/frontend/src/Album/AlbumCover.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import ArtistImage from 'Artist/ArtistImage'; + +const coverPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPcAAAD3AgMAAAC84irAAAAADFBMVEUyMjI7Ozs1NTU4ODjgOsZvAAAAAWJLR0QAiAUdSAAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB+EJEBIzDdm9OfoAAAbkSURBVGje7Zq9b9s4FMBZFgUkBR27C3cw0MromL1jxwyVZASB67G4qWPgoSAyBdm9CwECKCp8nbIccGj/Ce/BTUb3Lh3aI997pCjnTnyyt0JcIif5+ZHvPZLvQ0KMYxzjGMc4xjGOcYxjHOP4JUfSfP7RVPvSH3MYX/eC5aecxne1v+w95WebFs/rwVO/8+h8PnT6t3ln/DFQuJ06/SyHiX9pxa7o5/lewkuLDxLvhM8tPki8g07dU8Gnj5zGlw7P79n4pDVYi8/YuHO4n03z0z6XXDom4G3TXDdN840+LobN/W1Ty2slHD8bNvevlUgutLmTj4NmT3pf6mMGcJGth+gefaZsDCjB2Wj65wN8ZmnAGnE6eFieI1FvcEISLjIUr9hm+w7PFeHiE9t0E7dyIatE48odXTPu0j/A3BMnXf7NXDxudTxbE2VxMWVu+sfwf3i1ZMLiaQLf+iWIP4VtjtTzFhc35vfveZrb4nPt4R95ulu1cxeVh8Psw7rzbgWp8dWHyr83WJpbgjypjS5XeZnqRxmJNUd3MS1d6ue/tOn0WuayNd2CoTlaeqwnIVeOgcWHdHdMS9cSN1vCy3bxZwzFm6VL7QA14WTudVj1sFvf4ReZNSCO0IvwngXFV3hkFcriuPokrPrYbYxjVAHiZ24zLYIeP7/E4xZUgHiZWt29D9ptGemHR7mPo9B10HLGbucRfs/Ww2f2CD4L2u0+wofKwwvrd0XoqCmr38CAZa1d58LesEpvgqtN4MCR1mVj2nZWOiweVB/CAXuyi59Y1auA2eekg6Xw8Tfm013A8LFV8mYXL61ZF4Hb8Zx8d9vBtbdG7s99XvOOZlF38QVtmlkAv0ffxTOjxU/o5p8FvKbSszw2ik87+Iz23Lwf134RiWf2tG3xN2T4oh8vDO4U33z+5qnefFnR77OA2wheh2WfbJBHeI/XgtNJEaHdtJNrvPn8E8eV/kW/2xn8FDc77LemOyq4J1XvSbds7SZ3cAV+86UXP283TGaFUk4ZwmNyugne8FaqxdHtFkH8GNewg2cc3PjsM7CbbNdMwQJ47aL3mP5H308ar5XOn2nUwpx+4hrx/z+qn5DBNqD4rMUpWACnPwnhkfa9SnZwvX1MnHLVi08cPle+0wBuAsykd8dO0KkS9L0dPCO37MVLxJc6nPHdTeNT/ZeLDQN/DEFpBzc33Bfckhx8K1q7IS5vuPgjbTf5AL97zcALxFUHN76QrF7heTHru54RN3bbxTeEn4Xx04f4NOfhSuPLncmnQk3z1yLlSE8fabtFHVyZyIQlXes8zrdSJR5ea7k3+asUooXg2mO4oDprT/XdHpROhouL/8A3edBw5DYxBhYdn08Q53jd0elDfApHbHjL6Hk/pvvNd1rEWdLl9iG+hpMgiMMdVEM64B8X5nq6ZBwX5rCSeK/4uInJROiwetLi0jtpG0yJBPOkTVQXryEPKqMQbq6JeyUTvUOkilq/EVGmo5NIpP3XRIzhXIafrjzF30JUIqecKxIjOpF6il9jbHTLxjs3rN5voPH+GxbDA1m7GrM9a4zdTigdCUUXD2MSSEAXQRxDo2QHl2iwV+h7gchqLrLrhmKxH/Z6nqLUQD5AYSHWAEwk+Z1Ck1vEAmEhBaVtufDtj8Zmv6U+PQNBqbDf/szVR5XNvQteSAzRyeQhzgnIKR2Invq43gQb4+oRaJCTTcRd6RkzGXlJQe3vDq8gsDB2S0QaSoViwKNW9Sh9zUzEMA2MWtU7nJUGYhIa4bnjcLthgkkopMAGj3dxXgoMCbg+laTFL8luSn9pFkrAMf031cmVJz0jXzsKFm6OSfVqYnEILPKZDjeicPFhQoaHbMhKX+NmZ5Q+ntr8n5obhGPVKlx48cs+FteKP3MlswWv6CSPHK4Dmntm0ckreW0snmxKbsnLFdyo4mrwjLYJo+Dmyn0k3uDTEpMRTrnPKza+IHy9wGSEU2yMvSrvHeJ/Qt2UV+p0hVacvsah0psKXqEVy7y2tPu3xhM1oMxLReY00tAlJG9JFZktzCwyU4lbuqQ7U22VN1zi9gvsIP05PjAL7H55H/C6rREzyvu41bbS4VXb1OV0FLG1YVsa1J1gtzaosVJbHO3Gb6z4bR2H89s61FRqCIcgL+E3lfyWlsaN3eR6QDP0pSdeKqOEZjOgoda285SUl5W+Jga181wz0WQFF2poM7FtZTZKXlXZ0Fam10htroY3Ug9s43pN5OJ2jyZy28Iu1nu0sNsGenGzRwO9bd8Xd/u0793LA8Vmn5cHnPhiH+Gt+HIv4Ye+tnHoSyMHvrJy6Aszh76uc+DLQuLQV5XGMY5xjGMc4xjHOMYxjnH80uNfW99BeoyzJCoAAAAASUVORK5CYII='; + +function AlbumCover(props) { + return ( + + ); +} + +AlbumCover.propTypes = { + size: PropTypes.number.isRequired +}; + +AlbumCover.defaultProps = { + size: 250 +}; + +export default AlbumCover; diff --git a/frontend/src/Album/AlbumSearchCell.css b/frontend/src/Album/AlbumSearchCell.css new file mode 100644 index 000000000..ba099e8c0 --- /dev/null +++ b/frontend/src/Album/AlbumSearchCell.css @@ -0,0 +1,6 @@ +.AlbumSearchCell { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 70px; + white-space: nowrap; +} diff --git a/frontend/src/Album/AlbumSearchCell.js b/frontend/src/Album/AlbumSearchCell.js new file mode 100644 index 000000000..9cd41f3f1 --- /dev/null +++ b/frontend/src/Album/AlbumSearchCell.js @@ -0,0 +1,80 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import AlbumInteractiveSearchModalConnector from './Search/AlbumInteractiveSearchModalConnector'; +import styles from './AlbumSearchCell.css'; + +class AlbumSearchCell extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + // + // Listeners + + onManualSearchPress = () => { + this.setState({ isDetailsModalOpen: true }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + // + // Render + + render() { + const { + albumId, + albumTitle, + isSearching, + onSearchPress, + ...otherProps + } = this.props; + + return ( + + + + + + + + + ); + } +} + +AlbumSearchCell.propTypes = { + albumId: PropTypes.number.isRequired, + artistId: PropTypes.number.isRequired, + albumTitle: PropTypes.string.isRequired, + isSearching: PropTypes.bool.isRequired, + onSearchPress: PropTypes.func.isRequired +}; + +export default AlbumSearchCell; diff --git a/frontend/src/Album/AlbumSearchCellConnector.js b/frontend/src/Album/AlbumSearchCellConnector.js new file mode 100644 index 000000000..2774db752 --- /dev/null +++ b/frontend/src/Album/AlbumSearchCellConnector.js @@ -0,0 +1,49 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { isCommandExecuting } from 'Utilities/Command'; +import createArtistSelector from 'Store/Selectors/createArtistSelector'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import AlbumSearchCell from './AlbumSearchCell'; + +function createMapStateToProps() { + return createSelector( + (state, { albumId }) => albumId, + createArtistSelector(), + createCommandsSelector(), + (albumId, artist, commands) => { + const isSearching = commands.some((command) => { + const albumSearch = command.name === commandNames.ALBUM_SEARCH; + + if (!albumSearch) { + return false; + } + + return ( + isCommandExecuting(command) && + command.body.albumIds.indexOf(albumId) > -1 + ); + }); + + return { + artistMonitored: artist.monitored, + artistType: artist.artistType, + isSearching + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onSearchPress(name, path) { + dispatch(executeCommand({ + name: commandNames.ALBUM_SEARCH, + albumIds: [props.albumId] + })); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(AlbumSearchCell); diff --git a/frontend/src/Album/AlbumTitleLink.css b/frontend/src/Album/AlbumTitleLink.css new file mode 100644 index 000000000..47d897238 --- /dev/null +++ b/frontend/src/Album/AlbumTitleLink.css @@ -0,0 +1,8 @@ +.link { + composes: link from '~Components/Link/Link.css'; + + &:hover { + color: $linkHoverColor; + text-decoration: underline; + } +} diff --git a/frontend/src/Album/AlbumTitleLink.js b/frontend/src/Album/AlbumTitleLink.js new file mode 100644 index 000000000..8b4dfe212 --- /dev/null +++ b/frontend/src/Album/AlbumTitleLink.js @@ -0,0 +1,21 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Link from 'Components/Link/Link'; + +function AlbumTitleLink({ foreignAlbumId, title, disambiguation }) { + const link = `/album/${foreignAlbumId}`; + + return ( + + {title}{disambiguation ? ` (${disambiguation})` : ''} + + ); +} + +AlbumTitleLink.propTypes = { + foreignAlbumId: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + disambiguation: PropTypes.string +}; + +export default AlbumTitleLink; diff --git a/frontend/src/Album/Details/AlbumDetails.css b/frontend/src/Album/Details/AlbumDetails.css new file mode 100644 index 000000000..1e590835a --- /dev/null +++ b/frontend/src/Album/Details/AlbumDetails.css @@ -0,0 +1,153 @@ +.innerContentBody { + padding: 0; +} + +.header { + position: relative; + width: 100%; + height: 310px; +} + +.backdrop { + position: absolute; + z-index: -1; + width: 100%; + height: 100%; + background-size: cover; +} + +.backdropOverlay { + position: absolute; + width: 100%; + height: 100%; + background: $black; + opacity: 0.7; +} + +.headerContent { + display: flex; + padding: 30px; + width: 100%; + height: 100%; + color: $white; +} + +.cover { + flex-shrink: 0; + margin-right: 35px; + width: 250px; + height: 250px; +} + +.info { + display: flex; + flex-direction: column; + flex-grow: 1; + overflow: hidden; +} + +.titleRow { + display: flex; + justify-content: space-between; + flex: 0 0 auto; +} + +.titleContainer { + display: flex; + margin-bottom: 5px; +} + +.title { + font-weight: 300; + font-size: 50px; + line-height: 50px; +} + +.toggleMonitoredContainer { + align-self: center; + margin-right: 10px; +} + +.monitorToggleButton { + composes: toggleButton from '~Components/MonitorToggleButton.css'; + + width: 40px; + + &:hover { + color: $iconButtonHoverLightColor; + } +} + +.alternateTitlesIconContainer { + align-self: flex-end; + margin-left: 20px; +} + +.albumNavigationButtons { + white-space: nowrap; +} + +.albumNavigationButton { + composes: button from '~Components/Link/IconButton.css'; + + margin-left: 5px; + width: 30px; + color: #e1e2e3; + white-space: nowrap; + + &:hover { + color: $iconButtonHoverLightColor; + } +} + +.details { + margin-bottom: 8px; + font-weight: 300; + font-size: 20px; +} + +.duration { + margin-right: 15px; +} + +.detailsLabel { + composes: label from '~Components/Label.css'; + + margin: 5px 10px 5px 0; +} + +.sizeOnDisk, +.qualityProfileName, +.links, +.tags { + margin-left: 8px; + font-weight: 300; + font-size: 17px; +} + +.overview { + flex: 1 0 auto; + margin-top: 8px; + min-height: 0; + font-size: $intermediateFontSize; +} + +.contentContainer { + padding: 20px; +} + +@media only screen and (max-width: $breakpointSmall) { + .contentContainer { + padding: 20px 0; + } + + .headerContent { + padding: 15px; + } +} + +@media only screen and (max-width: $breakpointLarge) { + .cover { + display: none; + } +} diff --git a/frontend/src/Album/Details/AlbumDetails.js b/frontend/src/Album/Details/AlbumDetails.js new file mode 100644 index 000000000..f2288b00f --- /dev/null +++ b/frontend/src/Album/Details/AlbumDetails.js @@ -0,0 +1,596 @@ +import _ from 'lodash'; +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import TextTruncate from 'react-text-truncate'; +import formatBytes from 'Utilities/Number/formatBytes'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { align, icons, kinds, sizes, tooltipPositions } from 'Helpers/Props'; +import fonts from 'Styles/Variables/fonts'; +import HeartRating from 'Components/HeartRating'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import Label from 'Components/Label'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import AlbumCover from 'Album/AlbumCover'; +import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector'; +import RetagPreviewModalConnector from 'Retag/RetagPreviewModalConnector'; +import EditAlbumModalConnector from 'Album/Edit/EditAlbumModalConnector'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import AlbumDetailsMediumConnector from './AlbumDetailsMediumConnector'; +import ArtistHistoryModal from 'Artist/History/ArtistHistoryModal'; +import AlbumInteractiveSearchModalConnector from 'Album/Search/AlbumInteractiveSearchModalConnector'; +import TrackFileEditorModal from 'TrackFile/Editor/TrackFileEditorModal'; +import AlbumDetailsLinks from './AlbumDetailsLinks'; +import styles from './AlbumDetails.css'; + +const defaultFontSize = parseInt(fonts.defaultFontSize); +const lineHeight = parseFloat(fonts.lineHeight); + +function getFanartUrl(images) { + const fanartImage = _.find(images, { coverType: 'fanart' }); + if (fanartImage) { + // Remove protocol + return fanartImage.url.replace(/^https?:/, ''); + } +} + +function formatDuration(timeSpan) { + const duration = moment.duration(timeSpan); + const hours = duration.get('hours'); + const minutes = duration.get('minutes'); + let hoursText = 'Hours'; + let minText = 'Minutes'; + + if (minutes === 1) { + minText = 'Minute'; + } + + if (hours === 0) { + return `${minutes} ${minText}`; + } + + if (hours === 1) { + hoursText = 'Hour'; + } + + return `${hours} ${hoursText} ${minutes} ${minText}`; +} + +function getExpandedState(newState) { + return { + allExpanded: newState.allSelected, + allCollapsed: newState.allUnselected, + expandedState: newState.selectedState + }; +} + +class AlbumDetails extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isOrganizeModalOpen: false, + isRetagModalOpen: false, + isArtistHistoryModalOpen: false, + isInteractiveSearchModalOpen: false, + isManageTracksOpen: false, + isEditAlbumModalOpen: false, + allExpanded: false, + allCollapsed: false, + expandedState: {} + }; + } + + // + // Listeners + + onOrganizePress = () => { + this.setState({ isOrganizeModalOpen: true }); + } + + onOrganizeModalClose = () => { + this.setState({ isOrganizeModalOpen: false }); + } + + onRetagPress = () => { + this.setState({ isRetagModalOpen: true }); + } + + onRetagModalClose = () => { + this.setState({ isRetagModalOpen: false }); + } + + onEditAlbumPress = () => { + this.setState({ isEditAlbumModalOpen: true }); + } + + onEditAlbumModalClose = () => { + this.setState({ isEditAlbumModalOpen: false }); + } + + onManageTracksPress = () => { + this.setState({ isManageTracksOpen: true }); + } + + onManageTracksModalClose = () => { + this.setState({ isManageTracksOpen: false }); + } + + onInteractiveSearchPress = () => { + this.setState({ isInteractiveSearchModalOpen: true }); + } + + onInteractiveSearchModalClose = () => { + this.setState({ isInteractiveSearchModalOpen: false }); + } + + onArtistHistoryPress = () => { + this.setState({ isArtistHistoryModalOpen: true }); + } + + onArtistHistoryModalClose = () => { + this.setState({ isArtistHistoryModalOpen: false }); + } + + onExpandAllPress = () => { + const { + allExpanded, + expandedState + } = this.state; + + this.setState(getExpandedState(selectAll(expandedState, !allExpanded))); + } + + onExpandPress = (albumId, isExpanded) => { + this.setState((state) => { + const convertedState = { + allSelected: state.allExpanded, + allUnselected: state.allCollapsed, + selectedState: state.expandedState + }; + + const newState = toggleSelected(convertedState, [], albumId, isExpanded, false); + + return getExpandedState(newState); + }); + } + + // + // Render + + render() { + const { + id, + foreignAlbumId, + title, + disambiguation, + duration, + overview, + albumType, + statistics = {}, + monitored, + releaseDate, + ratings, + images, + links, + media, + isSaving, + isFetching, + isPopulated, + albumsError, + trackFilesError, + hasTrackFiles, + shortDateFormat, + artist, + previousAlbum, + nextAlbum, + isSearching, + onMonitorTogglePress, + onSearchPress + } = this.props; + + const { + isOrganizeModalOpen, + isRetagModalOpen, + isArtistHistoryModalOpen, + isInteractiveSearchModalOpen, + isEditAlbumModalOpen, + isManageTracksOpen, + allExpanded, + allCollapsed, + expandedState + } = this.state; + + let expandIcon = icons.EXPAND_INDETERMINATE; + + if (allExpanded) { + expandIcon = icons.COLLAPSE; + } else if (allCollapsed) { + expandIcon = icons.EXPAND; + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+ + +
+
+
+ +
+ +
+ +
+ {title}{disambiguation ? ` (${disambiguation})` : ''} +
+
+ +
+ + + + + +
+
+ +
+
+ { + !!duration && + + {formatDuration(duration)} + + } + + +
+
+ +
+ + + + + + + + { + !!albumType && + + } + + + + + + Links + + + } + tooltip={ + + } + kind={kinds.INVERSE} + position={tooltipPositions.BOTTOM} + /> + +
+
+ +
+
+
+
+ +
+ { + !isPopulated && !albumsError && !trackFilesError && + + } + + { + !isFetching && albumsError && +
Loading albums failed
+ } + + { + !isFetching && trackFilesError && +
Loading track files failed
+ } + + { + isPopulated && !!media.length && +
+ + { + media.slice(0).map((medium) => { + return ( + + ); + }) + } +
+ } + +
+ + + + + + + + + + + + + + + + ); + } +} + +AlbumDetails.propTypes = { + id: PropTypes.number.isRequired, + foreignAlbumId: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + disambiguation: PropTypes.string, + duration: PropTypes.number, + overview: PropTypes.string, + albumType: PropTypes.string.isRequired, + statistics: PropTypes.object.isRequired, + releaseDate: PropTypes.string.isRequired, + ratings: PropTypes.object.isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + links: PropTypes.arrayOf(PropTypes.object).isRequired, + media: PropTypes.arrayOf(PropTypes.object).isRequired, + monitored: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + isSaving: PropTypes.bool.isRequired, + isSearching: PropTypes.bool, + isFetching: PropTypes.bool, + isPopulated: PropTypes.bool, + albumsError: PropTypes.object, + tracksError: PropTypes.object, + trackFilesError: PropTypes.object, + hasTrackFiles: PropTypes.bool.isRequired, + artist: PropTypes.object, + previousAlbum: PropTypes.object, + nextAlbum: PropTypes.object, + onMonitorTogglePress: PropTypes.func.isRequired, + onRefreshPress: PropTypes.func, + onSearchPress: PropTypes.func.isRequired +}; + +AlbumDetails.defaultProps = { + isSaving: false +}; + +export default AlbumDetails; diff --git a/frontend/src/Album/Details/AlbumDetailsConnector.js b/frontend/src/Album/Details/AlbumDetailsConnector.js new file mode 100644 index 000000000..3bcbfd06b --- /dev/null +++ b/frontend/src/Album/Details/AlbumDetailsConnector.js @@ -0,0 +1,184 @@ +/* eslint max-params: 0 */ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { findCommand } from 'Utilities/Command'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import { toggleAlbumsMonitored } from 'Store/Actions/albumActions'; +import { fetchTracks, clearTracks } from 'Store/Actions/trackActions'; +import { fetchTrackFiles, clearTrackFiles } from 'Store/Actions/trackFileActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import AlbumDetails from './AlbumDetails'; +import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; + +const selectTrackFiles = createSelector( + (state) => state.trackFiles, + (trackFiles) => { + const { + items, + isFetching, + isPopulated, + error + } = trackFiles; + + const hasTrackFiles = !!items.length; + + return { + isTrackFilesFetching: isFetching, + isTrackFilesPopulated: isPopulated, + trackFilesError: error, + hasTrackFiles + }; + } +); + +function createMapStateToProps() { + return createSelector( + (state, { foreignAlbumId }) => foreignAlbumId, + (state) => state.tracks, + selectTrackFiles, + (state) => state.albums, + createAllArtistSelector(), + createCommandsSelector(), + createUISettingsSelector(), + (foreignAlbumId, tracks, trackFiles, albums, artists, commands, uiSettings) => { + const sortedAlbums = _.orderBy(albums.items, 'releaseDate'); + const albumIndex = _.findIndex(sortedAlbums, { foreignAlbumId }); + const album = sortedAlbums[albumIndex]; + const artist = _.find(artists, { id: album.artistId }); + + if (!album) { + return {}; + } + + const { + isTrackFilesFetching, + isTrackFilesPopulated, + trackFilesError, + hasTrackFiles + } = trackFiles; + + const previousAlbum = sortedAlbums[albumIndex - 1] || _.last(sortedAlbums); + const nextAlbum = sortedAlbums[albumIndex + 1] || _.first(sortedAlbums); + const isSearching = !!findCommand(commands, { name: commandNames.ALBUM_SEARCH }); + + const isFetching = tracks.isFetching || isTrackFilesFetching; + const isPopulated = tracks.isPopulated && isTrackFilesPopulated; + const tracksError = tracks.error; + + return { + ...album, + shortDateFormat: uiSettings.shortDateFormat, + artist, + isSearching, + isFetching, + isPopulated, + tracksError, + trackFilesError, + hasTrackFiles, + previousAlbum, + nextAlbum + }; + } + ); +} + +const mapDispatchToProps = { + executeCommand, + fetchTracks, + clearTracks, + fetchTrackFiles, + clearTrackFiles, + toggleAlbumsMonitored +}; + +function getMonitoredReleases(props) { + return _.map(_.filter(props.releases, { monitored: true }), 'id').sort(); +} + +class AlbumDetailsConnector extends Component { + + componentDidMount() { + registerPagePopulator(this.populate); + this.populate(); + } + + componentDidUpdate(prevProps) { + if (!_.isEqual(getMonitoredReleases(prevProps), getMonitoredReleases(this.props)) || + (prevProps.anyReleaseOk === false && this.props.anyReleaseOk === true)) { + this.unpopulate(); + this.populate(); + } + } + + componentWillUnmount() { + unregisterPagePopulator(this.populate); + this.unpopulate(); + } + + // + // Control + + populate = () => { + const albumId = this.props.id; + + this.props.fetchTracks({ albumId }); + this.props.fetchTrackFiles({ albumId }); + } + + unpopulate = () => { + this.props.clearTracks(); + this.props.clearTrackFiles(); + } + + // + // Listeners + + onMonitorTogglePress = (monitored) => { + this.props.toggleAlbumsMonitored({ + albumIds: [this.props.id], + monitored + }); + } + + onSearchPress = () => { + this.props.executeCommand({ + name: commandNames.ALBUM_SEARCH, + albumIds: [this.props.id] + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AlbumDetailsConnector.propTypes = { + id: PropTypes.number, + anyReleaseOk: PropTypes.bool, + isAlbumFetching: PropTypes.bool, + isAlbumPopulated: PropTypes.bool, + foreignAlbumId: PropTypes.string.isRequired, + fetchTracks: PropTypes.func.isRequired, + clearTracks: PropTypes.func.isRequired, + fetchTrackFiles: PropTypes.func.isRequired, + clearTrackFiles: PropTypes.func.isRequired, + toggleAlbumsMonitored: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AlbumDetailsConnector); diff --git a/frontend/src/Album/Details/AlbumDetailsLinks.css b/frontend/src/Album/Details/AlbumDetailsLinks.css new file mode 100644 index 000000000..d37a082a1 --- /dev/null +++ b/frontend/src/Album/Details/AlbumDetailsLinks.css @@ -0,0 +1,13 @@ +.links { + margin: 0; +} + +.link { + white-space: nowrap; +} + +.linkLabel { + composes: label from '~Components/Label.css'; + + cursor: pointer; +} diff --git a/frontend/src/Album/Details/AlbumDetailsLinks.js b/frontend/src/Album/Details/AlbumDetailsLinks.js new file mode 100644 index 000000000..265a7c4ff --- /dev/null +++ b/frontend/src/Album/Details/AlbumDetailsLinks.js @@ -0,0 +1,63 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds, sizes } from 'Helpers/Props'; +import Label from 'Components/Label'; +import Link from 'Components/Link/Link'; +import styles from './AlbumDetailsLinks.css'; + +function AlbumDetailsLinks(props) { + const { + foreignAlbumId, + links + } = props; + + return ( +
+ + + + + + {links.map((link, index) => { + return ( + + + + + {(index > 0 && index % 5 === 0) && +
+ } + +
+ ); + })} + +
+ + ); +} + +AlbumDetailsLinks.propTypes = { + foreignAlbumId: PropTypes.string.isRequired, + links: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default AlbumDetailsLinks; diff --git a/frontend/src/Album/Details/AlbumDetailsMedium.css b/frontend/src/Album/Details/AlbumDetailsMedium.css new file mode 100644 index 000000000..67418316d --- /dev/null +++ b/frontend/src/Album/Details/AlbumDetailsMedium.css @@ -0,0 +1,114 @@ +.medium { + margin-bottom: 20px; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; + + &:last-of-type { + margin-bottom: 0; + } +} + +.header { + position: relative; + display: flex; + align-items: center; + width: 100%; + font-size: 24px; +} + +.mediumNumber { + margin-right: 10px; + margin-left: 5px; +} + +.mediumFormat { + color: #8895aa; + font-style: italic; + font-size: 18px; +} + +.expandButton { + composes: link from '~Components/Link/Link.css'; + + flex-grow: 1; + margin: 0 20px; + text-align: center; +} + +.left { + display: flex; + align-items: center; + flex: 0 1 300px; +} + +.left, +.actions { + padding: 15px 10px; +} + +.actionsMenu { + composes: menu from '~Components/Menu/Menu.css'; + + flex: 0 0 45px; +} + +.actionsMenuContent { + composes: menuContent from '~Components/Menu/MenuContent.css'; + + white-space: nowrap; + font-size: 14px; +} + +.actionMenuIcon { + margin-right: 8px; +} + +.actionButton { + composes: button from '~Components/Link/IconButton.css'; + + width: 30px; +} + +.tracks { + padding-top: 15px; + border-top: 1px solid $borderColor; +} + +.collapseButtonContainer { + padding: 10px 15px; + width: 100%; + border-top: 1px solid $borderColor; + border-bottom-right-radius: 4px; + border-bottom-left-radius: 4px; + background-color: #fafafa; + text-align: center; +} + +.expandButtonIcon { + composes: actionButton; + + position: absolute; + top: 50%; + left: 50%; + margin-top: -12px; + margin-left: -15px; +} + +.noTracks { + margin-bottom: 15px; + text-align: center; +} + +@media only screen and (max-width: $breakpointSmall) { + .medium { + border-right: 0; + border-left: 0; + border-radius: 0; + } + + .expandButtonIcon { + position: static; + margin: 0; + } +} diff --git a/frontend/src/Album/Details/AlbumDetailsMedium.js b/frontend/src/Album/Details/AlbumDetailsMedium.js new file mode 100644 index 000000000..33d6efb80 --- /dev/null +++ b/frontend/src/Album/Details/AlbumDetailsMedium.js @@ -0,0 +1,210 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, sizes } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import Label from 'Components/Label'; +import Link from 'Components/Link/Link'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TrackRowConnector from './TrackRowConnector'; +import styles from './AlbumDetailsMedium.css'; + +function getMediumStatistics(tracks) { + let trackCount = 0; + let trackFileCount = 0; + let totalTrackCount = 0; + + tracks.forEach((track) => { + if (track.trackFileId) { + trackCount++; + trackFileCount++; + } else { + trackCount++; + } + + totalTrackCount++; + }); + + return { + trackCount, + trackFileCount, + totalTrackCount + }; +} + +function getTrackCountKind(monitored, trackFileCount, trackCount) { + if (trackFileCount === trackCount && trackCount > 0) { + return kinds.SUCCESS; + } + + if (!monitored) { + return kinds.WARNING; + } + + return kinds.DANGER; +} + +class AlbumDetailsMedium extends Component { + + // + // Lifecycle + + componentDidMount() { + this._expandByDefault(); + } + + componentDidUpdate(prevProps) { + if (prevProps.albumId !== this.props.albumId) { + this._expandByDefault(); + } + } + + // + // Control + + _expandByDefault() { + const { + mediumNumber, + onExpandPress + } = this.props; + + onExpandPress(mediumNumber, mediumNumber === 1); + } + + // + // Listeners + + onExpandPress = () => { + const { + mediumNumber, + isExpanded + } = this.props; + + this.props.onExpandPress(mediumNumber, !isExpanded); + } + + // + // Render + + render() { + const { + mediumNumber, + mediumFormat, + albumMonitored, + items, + columns, + onTableOptionChange, + isExpanded, + isSmallScreen + } = this.props; + + const { + trackCount, + trackFileCount, + totalTrackCount + } = getMediumStatistics(items); + + return ( +
+
+
+ { +
+ + {mediumFormat} {mediumNumber} + +
+ } + + +
+ + + + { + !isSmallScreen && +   + } + + +
+ +
+ { + isExpanded && +
+ { + items.length ? + + + { + items.map((item) => { + return ( + + ); + }) + } + +
: + +
+ No tracks in this medium +
+ } +
+ +
+
+ } +
+
+ ); + } +} + +AlbumDetailsMedium.propTypes = { + albumId: PropTypes.number.isRequired, + albumMonitored: PropTypes.bool.isRequired, + mediumNumber: PropTypes.number.isRequired, + mediumFormat: PropTypes.string.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + isSaving: PropTypes.bool, + isExpanded: PropTypes.bool, + isSmallScreen: PropTypes.bool.isRequired, + onTableOptionChange: PropTypes.func.isRequired, + onExpandPress: PropTypes.func.isRequired +}; + +export default AlbumDetailsMedium; diff --git a/frontend/src/Album/Details/AlbumDetailsMediumConnector.js b/frontend/src/Album/Details/AlbumDetailsMediumConnector.js new file mode 100644 index 000000000..e05d9870d --- /dev/null +++ b/frontend/src/Album/Details/AlbumDetailsMediumConnector.js @@ -0,0 +1,65 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import { setTracksTableOption } from 'Store/Actions/trackActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import AlbumDetailsMedium from './AlbumDetailsMedium'; + +function createMapStateToProps() { + return createSelector( + (state, { mediumNumber }) => mediumNumber, + (state) => state.tracks, + createDimensionsSelector(), + (mediumNumber, tracks, dimensions) => { + + const tracksInMedium = _.filter(tracks.items, { mediumNumber }); + const sortedTracks = _.orderBy(tracksInMedium, ['absoluteTrackNumber'], ['asc']); + + return { + items: sortedTracks, + columns: tracks.columns, + isSmallScreen: dimensions.isSmallScreen + }; + } + ); +} + +const mapDispatchToProps = { + setTracksTableOption, + executeCommand +}; + +class AlbumDetailsMediumConnector extends Component { + + // + // Listeners + + onTableOptionChange = (payload) => { + this.props.setTracksTableOption(payload); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AlbumDetailsMediumConnector.propTypes = { + albumId: PropTypes.number.isRequired, + albumMonitored: PropTypes.bool.isRequired, + mediumNumber: PropTypes.number.isRequired, + setTracksTableOption: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AlbumDetailsMediumConnector); diff --git a/frontend/src/Album/Details/AlbumDetailsPageConnector.js b/frontend/src/Album/Details/AlbumDetailsPageConnector.js new file mode 100644 index 000000000..fffd014ad --- /dev/null +++ b/frontend/src/Album/Details/AlbumDetailsPageConnector.js @@ -0,0 +1,120 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { push } from 'connected-react-router'; +import NotFound from 'Components/NotFound'; +import { fetchAlbums, clearAlbums } from 'Store/Actions/albumActions'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import AlbumDetailsConnector from './AlbumDetailsConnector'; + +function createMapStateToProps() { + return createSelector( + (state, { match }) => match, + (state) => state.albums, + (state) => state.artist, + (match, albums, artist) => { + const foreignAlbumId = match.params.foreignAlbumId; + const isFetching = albums.isFetching || artist.isFetching; + const isPopulated = albums.isPopulated && artist.isPopulated; + + return { + foreignAlbumId, + isFetching, + isPopulated + }; + } + ); +} + +const mapDispatchToProps = { + push, + fetchAlbums, + clearAlbums +}; + +class AlbumDetailsPageConnector extends Component { + + constructor(props) { + super(props); + this.state = { hasMounted: false }; + } + // + // Lifecycle + + componentDidMount() { + this.populate(); + } + + componentWillUnmount() { + this.unpopulate(); + } + + // + // Control + + populate = () => { + const foreignAlbumId = this.props.foreignAlbumId; + this.setState({ hasMounted: true }); + this.props.fetchAlbums({ + foreignAlbumId, + includeAllArtistAlbums: true + }); + } + + unpopulate = () => { + this.props.clearAlbums(); + } + + // + // Render + + render() { + const { + foreignAlbumId, + isFetching, + isPopulated + } = this.props; + + if (!foreignAlbumId) { + return ( + + ); + } + + if ((isFetching || !this.state.hasMounted) || + (!isFetching && !isPopulated)) { + return ( + + + + + + ); + } + + if (!isFetching && isPopulated && this.state.hasMounted) { + return ( + + ); + } + } +} + +AlbumDetailsPageConnector.propTypes = { + foreignAlbumId: PropTypes.string, + match: PropTypes.shape({ params: PropTypes.shape({ foreignAlbumId: PropTypes.string.isRequired }).isRequired }).isRequired, + push: PropTypes.func.isRequired, + fetchAlbums: PropTypes.func.isRequired, + clearAlbums: PropTypes.func.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AlbumDetailsPageConnector); diff --git a/frontend/src/Album/Details/TrackActionsCell.css b/frontend/src/Album/Details/TrackActionsCell.css new file mode 100644 index 000000000..6b80ba0e0 --- /dev/null +++ b/frontend/src/Album/Details/TrackActionsCell.css @@ -0,0 +1,6 @@ +.TrackActionsCell { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 70px; + white-space: nowrap; +} diff --git a/frontend/src/Album/Details/TrackActionsCell.js b/frontend/src/Album/Details/TrackActionsCell.js new file mode 100644 index 000000000..db73b35b7 --- /dev/null +++ b/frontend/src/Album/Details/TrackActionsCell.js @@ -0,0 +1,109 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import FileDetailsModal from 'TrackFile/FileDetailsModal'; +import styles from './TrackActionsCell.css'; + +class TrackActionsCell extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false, + isConfirmDeleteModalOpen: false + }; + } + + // + // Listeners + + onDetailsPress = () => { + this.setState({ isDetailsModalOpen: true }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + onDeleteFilePress = () => { + this.setState({ isConfirmDeleteModalOpen: true }); + } + + onConfirmDelete = () => { + this.setState({ isConfirmDeleteModalOpen: false }); + this.props.deleteTrackFile({ id: this.props.trackFileId }); + } + + onConfirmDeleteModalClose = () => { + this.setState({ isConfirmDeleteModalOpen: false }); + } + + // + // Render + + render() { + + const { + trackFileId, + trackFilePath + } = this.props; + + const { + isDetailsModalOpen, + isConfirmDeleteModalOpen + } = this.state; + + return ( + + { + trackFilePath && + + } + { + trackFilePath && + + } + + + + + + + ); + } +} + +TrackActionsCell.propTypes = { + id: PropTypes.number.isRequired, + albumId: PropTypes.number.isRequired, + trackFilePath: PropTypes.string, + trackFileId: PropTypes.number.isRequired, + deleteTrackFile: PropTypes.func.isRequired +}; + +export default TrackActionsCell; diff --git a/frontend/src/Album/Details/TrackRow.css b/frontend/src/Album/Details/TrackRow.css new file mode 100644 index 000000000..c77d215f2 --- /dev/null +++ b/frontend/src/Album/Details/TrackRow.css @@ -0,0 +1,30 @@ +.title { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + white-space: nowrap; +} + +.monitored { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 42px; +} + +.trackNumber { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 50px; +} + +.audio { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 250px; +} + +.duration, +.status { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} diff --git a/frontend/src/Album/Details/TrackRow.js b/frontend/src/Album/Details/TrackRow.js new file mode 100644 index 000000000..217215f5c --- /dev/null +++ b/frontend/src/Album/Details/TrackRow.js @@ -0,0 +1,178 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; +import EpisodeStatusConnector from 'Album/EpisodeStatusConnector'; +import MediaInfoConnector from 'TrackFile/MediaInfoConnector'; +import TrackActionsCell from './TrackActionsCell'; +import * as mediaInfoTypes from 'TrackFile/mediaInfoTypes'; + +import styles from './TrackRow.css'; + +class TrackRow extends Component { + + // + // Render + + render() { + const { + id, + albumId, + mediumNumber, + trackFileId, + absoluteTrackNumber, + title, + duration, + trackFilePath, + trackFileRelativePath, + columns, + deleteTrackFile + } = this.props; + + return ( + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'medium') { + return ( + + {mediumNumber} + + ); + } + + if (name === 'absoluteTrackNumber') { + return ( + + {absoluteTrackNumber} + + ); + } + + if (name === 'title') { + return ( + + {title} + + ); + } + + if (name === 'path') { + return ( + + { + trackFilePath + } + + ); + } + + if (name === 'relativePath') { + return ( + + { + trackFileRelativePath + } + + ); + } + + if (name === 'duration') { + return ( + + { + formatTimeSpan(duration) + } + + ); + } + + if (name === 'audioInfo') { + return ( + + + + ); + } + + if (name === 'status') { + return ( + + + + ); + } + + if (name === 'actions') { + return ( + + ); + } + + return null; + }) + } + + ); + } +} + +TrackRow.propTypes = { + deleteTrackFile: PropTypes.func.isRequired, + id: PropTypes.number.isRequired, + albumId: PropTypes.number.isRequired, + trackFileId: PropTypes.number, + mediumNumber: PropTypes.number.isRequired, + trackNumber: PropTypes.string.isRequired, + absoluteTrackNumber: PropTypes.number, + title: PropTypes.string.isRequired, + duration: PropTypes.number.isRequired, + isSaving: PropTypes.bool, + trackFilePath: PropTypes.string, + trackFileRelativePath: PropTypes.string, + mediaInfo: PropTypes.object, + columns: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default TrackRow; diff --git a/frontend/src/Album/Details/TrackRowConnector.js b/frontend/src/Album/Details/TrackRowConnector.js new file mode 100644 index 000000000..8074c7b61 --- /dev/null +++ b/frontend/src/Album/Details/TrackRowConnector.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createTrackFileSelector from 'Store/Selectors/createTrackFileSelector'; +import { deleteTrackFile } from 'Store/Actions/trackFileActions'; +import TrackRow from './TrackRow'; + +function createMapStateToProps() { + return createSelector( + (state, { id }) => id, + createTrackFileSelector(), + (id, trackFile) => { + return { + trackFilePath: trackFile ? trackFile.path : null, + trackFileRelativePath: trackFile ? trackFile.relativePath : null + }; + } + ); +} + +const mapDispatchToProps = { + deleteTrackFile +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(TrackRow); diff --git a/frontend/src/Album/Edit/EditAlbumModal.js b/frontend/src/Album/Edit/EditAlbumModal.js new file mode 100644 index 000000000..d47bb284f --- /dev/null +++ b/frontend/src/Album/Edit/EditAlbumModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import EditAlbumModalContentConnector from './EditAlbumModalContentConnector'; + +function EditAlbumModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditAlbumModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditAlbumModal; diff --git a/frontend/src/Album/Edit/EditAlbumModalConnector.js b/frontend/src/Album/Edit/EditAlbumModalConnector.js new file mode 100644 index 000000000..7c2383f0f --- /dev/null +++ b/frontend/src/Album/Edit/EditAlbumModalConnector.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditAlbumModal from './EditAlbumModal'; + +const mapDispatchToProps = { + clearPendingChanges +}; + +class EditAlbumModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'albums' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditAlbumModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(undefined, mapDispatchToProps)(EditAlbumModalConnector); diff --git a/frontend/src/Album/Edit/EditAlbumModalContent.js b/frontend/src/Album/Edit/EditAlbumModalContent.js new file mode 100644 index 000000000..949feee08 --- /dev/null +++ b/frontend/src/Album/Edit/EditAlbumModalContent.js @@ -0,0 +1,133 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +class EditAlbumModalContent extends Component { + + // + // Listeners + + onSavePress = () => { + const { + onSavePress + } = this.props; + + onSavePress(false); + + } + + // + // Render + + render() { + const { + title, + artistName, + albumType, + statistics, + item, + isSaving, + onInputChange, + onModalClose, + ...otherProps + } = this.props; + + const { + monitored, + anyReleaseOk, + releases + } = item; + + return ( + + + Edit - {artistName} - {title} [{albumType}] + + + +
+ + Monitored + + + + + + Automatically Switch Release + + + + + + Release + + 0} + albumReleases={releases} + onChange={onInputChange} + /> + + +
+
+ + + + + Save + + + +
+ ); + } +} + +EditAlbumModalContent.propTypes = { + albumId: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + artistName: PropTypes.string.isRequired, + albumType: PropTypes.string.isRequired, + statistics: PropTypes.object.isRequired, + item: PropTypes.object.isRequired, + isSaving: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditAlbumModalContent; diff --git a/frontend/src/Album/Edit/EditAlbumModalContentConnector.js b/frontend/src/Album/Edit/EditAlbumModalContentConnector.js new file mode 100644 index 000000000..f6329f8e8 --- /dev/null +++ b/frontend/src/Album/Edit/EditAlbumModalContentConnector.js @@ -0,0 +1,98 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; +import createAlbumSelector from 'Store/Selectors/createAlbumSelector'; +import createArtistSelector from 'Store/Selectors/createArtistSelector'; +import { setAlbumValue, saveAlbum } from 'Store/Actions/albumActions'; +import EditAlbumModalContent from './EditAlbumModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.albums, + createAlbumSelector(), + createArtistSelector(), + (albumState, album, artist) => { + const { + isSaving, + saveError, + pendingChanges + } = albumState; + + const albumSettings = _.pick(album, [ + 'monitored', + 'anyReleaseOk', + 'releases' + ]); + + const settings = selectSettings(albumSettings, pendingChanges, saveError); + + return { + title: album.title, + artistName: artist.artistName, + albumType: album.albumType, + statistics: album.statistics, + isSaving, + saveError, + item: settings.settings, + ...settings + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetAlbumValue: setAlbumValue, + dispatchSaveAlbum: saveAlbum +}; + +class EditAlbumModalContentConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.dispatchSetAlbumValue({ name, value }); + } + + onSavePress = () => { + this.props.dispatchSaveAlbum({ + id: this.props.albumId + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditAlbumModalContentConnector.propTypes = { + albumId: PropTypes.number, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + dispatchSetAlbumValue: PropTypes.func.isRequired, + dispatchSaveAlbum: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditAlbumModalContentConnector); diff --git a/frontend/src/Album/EpisodeNumber.css b/frontend/src/Album/EpisodeNumber.css new file mode 100644 index 000000000..1c5072d02 --- /dev/null +++ b/frontend/src/Album/EpisodeNumber.css @@ -0,0 +1,7 @@ +.absoluteEpisodeNumber { + margin-left: 5px; +} + +.warning { + margin-left: 8px; +} diff --git a/frontend/src/Album/EpisodeNumber.js b/frontend/src/Album/EpisodeNumber.js new file mode 100644 index 000000000..73e105376 --- /dev/null +++ b/frontend/src/Album/EpisodeNumber.js @@ -0,0 +1,107 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Popover from 'Components/Tooltip/Popover'; +import SceneInfo from './SceneInfo'; +import styles from './EpisodeNumber.css'; + +function EpisodeNumber(props) { + const { + episodeNumber, + absoluteEpisodeNumber, + sceneSeasonNumber, + sceneEpisodeNumber, + sceneAbsoluteEpisodeNumber, + unverifiedSceneNumbering, + alternateTitles, + artistType + } = props; + + const hasSceneInformation = sceneSeasonNumber !== undefined || + sceneEpisodeNumber !== undefined || + (artistType === 'anime' && sceneAbsoluteEpisodeNumber !== undefined) || + !!alternateTitles.length; + + return ( + + { + hasSceneInformation ? + + {episodeNumber} + + { + artistType === 'anime' && !!absoluteEpisodeNumber && + + ({absoluteEpisodeNumber}) + + } + + } + title="Scene Information" + body={ + + } + position={tooltipPositions.RIGHT} + /> : + + {episodeNumber} + + { + artistType === 'anime' && !!absoluteEpisodeNumber && + + ({absoluteEpisodeNumber}) + + } + + } + + { + unverifiedSceneNumbering && + + } + + { + artistType === 'anime' && !absoluteEpisodeNumber && + + } + + ); +} + +EpisodeNumber.propTypes = { + seasonNumber: PropTypes.number.isRequired, + episodeNumber: PropTypes.number.isRequired, + absoluteEpisodeNumber: PropTypes.number, + sceneSeasonNumber: PropTypes.number, + sceneEpisodeNumber: PropTypes.number, + sceneAbsoluteEpisodeNumber: PropTypes.number, + unverifiedSceneNumbering: PropTypes.bool.isRequired, + alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired, + artistType: PropTypes.string +}; + +EpisodeNumber.defaultProps = { + unverifiedSceneNumbering: false, + alternateTitles: [] +}; + +export default EpisodeNumber; diff --git a/frontend/src/Album/EpisodeStatus.css b/frontend/src/Album/EpisodeStatus.css new file mode 100644 index 000000000..3833887df --- /dev/null +++ b/frontend/src/Album/EpisodeStatus.css @@ -0,0 +1,4 @@ +.center { + display: flex; + justify-content: center; +} diff --git a/frontend/src/Album/EpisodeStatus.js b/frontend/src/Album/EpisodeStatus.js new file mode 100644 index 000000000..a2c792752 --- /dev/null +++ b/frontend/src/Album/EpisodeStatus.js @@ -0,0 +1,127 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import isBefore from 'Utilities/Date/isBefore'; +import { icons, kinds, sizes } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import ProgressBar from 'Components/ProgressBar'; +import QueueDetails from 'Activity/Queue/QueueDetails'; +import TrackQuality from './TrackQuality'; +import styles from './EpisodeStatus.css'; + +function EpisodeStatus(props) { + const { + airDateUtc, + monitored, + grabbed, + queueItem, + trackFile + } = props; + + const hasTrackFile = !!trackFile; + const isQueued = !!queueItem; + const hasAired = isBefore(airDateUtc); + + if (isQueued) { + const { + sizeleft, + size + } = queueItem; + + const progress = (100 - sizeleft / size * 100); + + return ( +
+ + } + /> +
+ ); + } + + if (grabbed) { + return ( +
+ +
+ ); + } + + if (hasTrackFile) { + const quality = trackFile.quality; + const isCutoffNotMet = trackFile.qualityCutoffNotMet; + + return ( +
+ +
+ ); + } + + if (!airDateUtc) { + return ( +
+ +
+ ); + } + + if (!monitored) { + return ( +
+ +
+ ); + } + + if (hasAired) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ ); +} + +EpisodeStatus.propTypes = { + airDateUtc: PropTypes.string, + monitored: PropTypes.bool, + grabbed: PropTypes.bool, + queueItem: PropTypes.object, + trackFile: PropTypes.object +}; + +export default EpisodeStatus; diff --git a/frontend/src/Album/EpisodeStatusConnector.js b/frontend/src/Album/EpisodeStatusConnector.js new file mode 100644 index 000000000..f3a390748 --- /dev/null +++ b/frontend/src/Album/EpisodeStatusConnector.js @@ -0,0 +1,53 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createAlbumSelector from 'Store/Selectors/createAlbumSelector'; +import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector'; +import createTrackFileSelector from 'Store/Selectors/createTrackFileSelector'; +import EpisodeStatus from './EpisodeStatus'; + +function createMapStateToProps() { + return createSelector( + createAlbumSelector(), + createQueueItemSelector(), + createTrackFileSelector(), + (album, queueItem, trackFile) => { + const result = _.pick(album, [ + 'airDateUtc', + 'monitored', + 'grabbed' + ]); + + result.queueItem = queueItem; + result.trackFile = trackFile; + + return result; + } + ); +} + +const mapDispatchToProps = { +}; + +class EpisodeStatusConnector extends Component { + + // + // Render + + render() { + return ( + + ); + } +} + +EpisodeStatusConnector.propTypes = { + albumId: PropTypes.number.isRequired, + trackFileId: PropTypes.number.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeStatusConnector); diff --git a/frontend/src/Album/SceneInfo.css b/frontend/src/Album/SceneInfo.css new file mode 100644 index 000000000..8a5f4bccd --- /dev/null +++ b/frontend/src/Album/SceneInfo.css @@ -0,0 +1,17 @@ +.descriptionList { + composes: descriptionList from '~Components/DescriptionList/DescriptionList.css'; + + margin-right: 10px; +} + +.title { + composes: title from '~Components/DescriptionList/DescriptionListItemTitle.css'; + + width: 80px; +} + +.description { + composes: title from '~Components/DescriptionList/DescriptionListItemDescription.css'; + + margin-left: 100px; +} diff --git a/frontend/src/Album/SceneInfo.js b/frontend/src/Album/SceneInfo.js new file mode 100644 index 000000000..ed171248a --- /dev/null +++ b/frontend/src/Album/SceneInfo.js @@ -0,0 +1,83 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import styles from './SceneInfo.css'; + +function SceneInfo(props) { + const { + sceneSeasonNumber, + sceneEpisodeNumber, + sceneAbsoluteEpisodeNumber, + alternateTitles, + artistType + } = props; + + return ( + + { + sceneSeasonNumber !== undefined && + + } + + { + sceneEpisodeNumber !== undefined && + + } + + { + artistType === 'anime' && sceneAbsoluteEpisodeNumber !== undefined && + + } + + { + !!alternateTitles.length && + + { + alternateTitles.map((alternateTitle) => { + return ( +
+ {alternateTitle.title} +
+ ); + }) + } +
+ } + /> + } + + ); +} + +SceneInfo.propTypes = { + sceneSeasonNumber: PropTypes.number, + sceneEpisodeNumber: PropTypes.number, + sceneAbsoluteEpisodeNumber: PropTypes.number, + alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired, + artistType: PropTypes.string +}; + +export default SceneInfo; diff --git a/frontend/src/Album/Search/AlbumInteractiveSearchModal.js b/frontend/src/Album/Search/AlbumInteractiveSearchModal.js new file mode 100644 index 000000000..52e825bab --- /dev/null +++ b/frontend/src/Album/Search/AlbumInteractiveSearchModal.js @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import AlbumInteractiveSearchModalContent from './AlbumInteractiveSearchModalContent'; + +function AlbumInteractiveSearchModal(props) { + const { + isOpen, + albumId, + albumTitle, + onModalClose + } = props; + + return ( + + + + ); +} + +AlbumInteractiveSearchModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + albumId: PropTypes.number.isRequired, + albumTitle: PropTypes.string.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AlbumInteractiveSearchModal; diff --git a/frontend/src/Album/Search/AlbumInteractiveSearchModalConnector.js b/frontend/src/Album/Search/AlbumInteractiveSearchModalConnector.js new file mode 100644 index 000000000..5b23395fb --- /dev/null +++ b/frontend/src/Album/Search/AlbumInteractiveSearchModalConnector.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions'; +import AlbumInteractiveSearchModal from './AlbumInteractiveSearchModal'; + +function createMapDispatchToProps(dispatch, props) { + return { + onModalClose() { + dispatch(cancelFetchReleases()); + dispatch(clearReleases()); + props.onModalClose(); + } + }; +} + +export default connect(null, createMapDispatchToProps)(AlbumInteractiveSearchModal); diff --git a/frontend/src/Album/Search/AlbumInteractiveSearchModalContent.js b/frontend/src/Album/Search/AlbumInteractiveSearchModalContent.js new file mode 100644 index 000000000..ff8cbe384 --- /dev/null +++ b/frontend/src/Album/Search/AlbumInteractiveSearchModalContent.js @@ -0,0 +1,48 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { scrollDirections } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import InteractiveSearchConnector from 'InteractiveSearch/InteractiveSearchConnector'; + +function AlbumInteractiveSearchModalContent(props) { + const { + albumId, + albumTitle, + onModalClose + } = props; + + return ( + + + Interactive Search {albumId != null && `- ${albumTitle}`} + + + + + + + + + + + ); +} + +AlbumInteractiveSearchModalContent.propTypes = { + albumId: PropTypes.number.isRequired, + albumTitle: PropTypes.string.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AlbumInteractiveSearchModalContent; diff --git a/frontend/src/Album/SeasonEpisodeNumber.js b/frontend/src/Album/SeasonEpisodeNumber.js new file mode 100644 index 000000000..7242ebfcc --- /dev/null +++ b/frontend/src/Album/SeasonEpisodeNumber.js @@ -0,0 +1,32 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import EpisodeNumber from './EpisodeNumber'; + +function SeasonEpisodeNumber(props) { + const { + airDate, + artistType, + ...otherProps + } = props; + + if (artistType === 'daily' && airDate) { + return ( + {airDate} + ); + } + + return ( + + ); +} + +SeasonEpisodeNumber.propTypes = { + airDate: PropTypes.string, + artistType: PropTypes.string +}; + +export default SeasonEpisodeNumber; diff --git a/frontend/src/Album/TrackQuality.js b/frontend/src/Album/TrackQuality.js new file mode 100644 index 000000000..866e7d11f --- /dev/null +++ b/frontend/src/Album/TrackQuality.js @@ -0,0 +1,61 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { kinds } from 'Helpers/Props'; +import Label from 'Components/Label'; + +function getTooltip(title, quality, size) { + if (!title) { + return; + } + + const revision = quality.revision; + + if (revision.real && revision.real > 0) { + title += ' [REAL]'; + } + + if (revision.version && revision.version > 1) { + title += ' [PROPER]'; + } + + if (size) { + title += ` - ${formatBytes(size)}`; + } + + return title; +} + +function TrackQuality(props) { + const { + className, + title, + quality, + size, + isCutoffNotMet + } = props; + + return ( + + ); +} + +TrackQuality.propTypes = { + className: PropTypes.string, + title: PropTypes.string, + quality: PropTypes.object.isRequired, + size: PropTypes.number, + isCutoffNotMet: PropTypes.bool +}; + +TrackQuality.defaultProps = { + title: '' +}; + +export default TrackQuality; diff --git a/frontend/src/Album/albumEntities.js b/frontend/src/Album/albumEntities.js new file mode 100644 index 000000000..4f5a26a61 --- /dev/null +++ b/frontend/src/Album/albumEntities.js @@ -0,0 +1,13 @@ +export const CALENDAR = 'calendar'; +export const ALBUMS = 'albums'; +export const INTERACTIVE_IMPORT = 'interactiveImport.albums'; +export const WANTED_CUTOFF_UNMET = 'wanted.cutoffUnmet'; +export const WANTED_MISSING = 'wanted.missing'; + +export default { + CALENDAR, + ALBUMS, + INTERACTIVE_IMPORT, + WANTED_CUTOFF_UNMET, + WANTED_MISSING +}; diff --git a/frontend/src/AlbumStudio/AlbumStudio.js b/frontend/src/AlbumStudio/AlbumStudio.js new file mode 100644 index 000000000..39222a9e2 --- /dev/null +++ b/frontend/src/AlbumStudio/AlbumStudio.js @@ -0,0 +1,218 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { align, sortDirections } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import NoArtist from 'Artist/NoArtist'; +import AlbumStudioFilterModalConnector from './AlbumStudioFilterModalConnector'; +import AlbumStudioRowConnector from './AlbumStudioRowConnector'; +import AlbumStudioFooter from './AlbumStudioFooter'; + +const columns = [ + { + name: 'status', + isVisible: true + }, + { + name: 'sortName', + label: 'Name', + isSortable: true, + isVisible: true + }, + { + name: 'monitored', + isVisible: true + }, + { + name: 'albumCount', + label: 'Albums', + isSortable: true, + isVisible: true + } +]; + +class AlbumStudio extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {} + }; + } + + componentDidUpdate(prevProps) { + const { + isSaving, + saveError + } = this.props; + + if (prevProps.isSaving && !isSaving && !saveError) { + this.onSelectAllChange({ value: false }); + } + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onUpdateSelectedPress = (changes) => { + this.props.onUpdateSelectedPress({ + artistIds: this.getSelectedIds(), + ...changes + }); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + totalItems, + items, + selectedFilterKey, + filters, + customFilters, + sortKey, + sortDirection, + isSaving, + saveError, + onSortPress, + onFilterSelect + } = this.props; + + const { + allSelected, + allUnselected, + selectedState + } = this.state; + + return ( + + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
{getErrorMessage(error, 'Failed to load artist from API')}
+ } + + { + !error && isPopulated && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+
+ } + + { + !error && isPopulated && !items.length && + + } +
+ + +
+ ); + } +} + +AlbumStudio.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + totalItems: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + onSortPress: PropTypes.func.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onUpdateSelectedPress: PropTypes.func.isRequired +}; + +export default AlbumStudio; diff --git a/frontend/src/AlbumStudio/AlbumStudioAlbum.css b/frontend/src/AlbumStudio/AlbumStudioAlbum.css new file mode 100644 index 000000000..f3c9f6102 --- /dev/null +++ b/frontend/src/AlbumStudio/AlbumStudioAlbum.css @@ -0,0 +1,37 @@ +.album { + display: flex; + align-items: stretch; + overflow: hidden; + margin: 2px 4px; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: #eee; + cursor: default; +} + +.info { + padding: 0 4px; +} + +.albumType { + padding: 0 4px; + border-width: 0 1px; + border-style: solid; + border-color: $borderColor; + background-color: $white; + color: $defaultColor; +} + +.tracks { + padding: 0 4px; + background-color: $white; + color: $defaultColor; +} + +.allTracks { + background-color: #e0ffe0; +} + +.missingWanted { + background-color: #ffe0e0; +} diff --git a/frontend/src/AlbumStudio/AlbumStudioAlbum.js b/frontend/src/AlbumStudio/AlbumStudioAlbum.js new file mode 100644 index 000000000..8bec82840 --- /dev/null +++ b/frontend/src/AlbumStudio/AlbumStudioAlbum.js @@ -0,0 +1,101 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; +import styles from './AlbumStudioAlbum.css'; + +class AlbumStudioAlbum extends Component { + + // + // Listeners + + onAlbumMonitoredPress = () => { + const { + id, + monitored + } = this.props; + + this.props.onAlbumMonitoredPress(id, !monitored); + } + + // + // Render + + render() { + const { + title, + disambiguation, + albumType, + monitored, + statistics, + isSaving + } = this.props; + + const { + trackFileCount, + totalTrackCount, + percentOfTracks + } = statistics; + + return ( +
+
+ + + + { + disambiguation ? `${title} (${disambiguation})` : `${title}` + } + +
+ +
+ + { + `${albumType}` + } + +
+ +
+ { + totalTrackCount === 0 ? '0/0' : `${trackFileCount}/${totalTrackCount}` + } +
+
+ ); + } +} + +AlbumStudioAlbum.propTypes = { + id: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + disambiguation: PropTypes.string, + albumType: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + statistics: PropTypes.object.isRequired, + isSaving: PropTypes.bool.isRequired, + onAlbumMonitoredPress: PropTypes.func.isRequired +}; + +AlbumStudioAlbum.defaultProps = { + isSaving: false, + statistics: { + trackFileCount: 0, + totalTrackCount: 0, + percentOfTracks: 0 + } +}; + +export default AlbumStudioAlbum; diff --git a/frontend/src/AlbumStudio/AlbumStudioConnector.js b/frontend/src/AlbumStudio/AlbumStudioConnector.js new file mode 100644 index 000000000..12e863c24 --- /dev/null +++ b/frontend/src/AlbumStudio/AlbumStudioConnector.js @@ -0,0 +1,91 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import { setAlbumStudioSort, setAlbumStudioFilter, saveAlbumStudio } from 'Store/Actions/albumStudioActions'; +import { fetchAlbums, clearAlbums } from 'Store/Actions/albumActions'; +import AlbumStudio from './AlbumStudio'; + +function createMapStateToProps() { + return createSelector( + createClientSideCollectionSelector('artist', 'albumStudio'), + (artist) => { + return { + ...artist + }; + } + ); +} + +const mapDispatchToProps = { + fetchAlbums, + clearAlbums, + setAlbumStudioSort, + setAlbumStudioFilter, + saveAlbumStudio +}; + +class AlbumStudioConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.populate(); + } + + componentWillUnmount() { + this.unpopulate(); + } + + // + // Control + + populate = () => { + this.props.fetchAlbums(); + } + + unpopulate = () => { + this.props.clearAlbums(); + } + + // + // Listeners + + onSortPress = (sortKey) => { + this.props.setAlbumStudioSort({ sortKey }); + } + + onFilterSelect = (selectedFilterKey) => { + this.props.setAlbumStudioFilter({ selectedFilterKey }); + } + + onUpdateSelectedPress = (payload) => { + this.props.saveAlbumStudio(payload); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AlbumStudioConnector.propTypes = { + setAlbumStudioSort: PropTypes.func.isRequired, + setAlbumStudioFilter: PropTypes.func.isRequired, + fetchAlbums: PropTypes.func.isRequired, + clearAlbums: PropTypes.func.isRequired, + saveAlbumStudio: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AlbumStudioConnector); diff --git a/frontend/src/AlbumStudio/AlbumStudioFilterModalConnector.js b/frontend/src/AlbumStudio/AlbumStudioFilterModalConnector.js new file mode 100644 index 000000000..655601cca --- /dev/null +++ b/frontend/src/AlbumStudio/AlbumStudioFilterModalConnector.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setAlbumStudioFilter } from 'Store/Actions/albumStudioActions'; +import FilterModal from 'Components/Filter/FilterModal'; + +function createMapStateToProps() { + return createSelector( + (state) => state.artist.items, + (state) => state.albumStudio.filterBuilderProps, + (sectionItems, filterBuilderProps) => { + return { + sectionItems, + filterBuilderProps, + customFilterType: 'albumStudio' + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetFilter: setAlbumStudioFilter +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal); diff --git a/frontend/src/AlbumStudio/AlbumStudioFooter.css b/frontend/src/AlbumStudio/AlbumStudioFooter.css new file mode 100644 index 000000000..11ea5496a --- /dev/null +++ b/frontend/src/AlbumStudio/AlbumStudioFooter.css @@ -0,0 +1,14 @@ +.inputContainer { + margin-right: 20px; +} + +.label { + margin-bottom: 3px; + font-weight: bold; +} + +.updateSelectedButton { + composes: button from '~Components/Link/SpinnerButton.css'; + + height: 35px; +} diff --git a/frontend/src/AlbumStudio/AlbumStudioFooter.js b/frontend/src/AlbumStudio/AlbumStudioFooter.js new file mode 100644 index 000000000..d5eb300cd --- /dev/null +++ b/frontend/src/AlbumStudio/AlbumStudioFooter.js @@ -0,0 +1,145 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import MonitorAlbumsSelectInput from 'Components/Form/MonitorAlbumsSelectInput'; +import SelectInput from 'Components/Form/SelectInput'; +import PageContentFooter from 'Components/Page/PageContentFooter'; +import styles from './AlbumStudioFooter.css'; + +const NO_CHANGE = 'noChange'; + +class AlbumStudioFooter extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + monitored: NO_CHANGE, + monitor: NO_CHANGE + }; + } + + componentDidUpdate(prevProps) { + const { + isSaving, + saveError + } = prevProps; + + if (prevProps.isSaving && !isSaving && !saveError) { + this.setState({ + monitored: NO_CHANGE, + monitor: NO_CHANGE + }); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.setState({ [name]: value }); + } + + onUpdateSelectedPress = () => { + const { + monitor, + monitored + } = this.state; + + const changes = {}; + + if (monitored !== NO_CHANGE) { + changes.monitored = monitored === 'monitored'; + } + + if (monitor !== NO_CHANGE) { + changes.monitor = monitor; + } + + this.props.onUpdateSelectedPress(changes); + } + + // + // Render + + render() { + const { + selectedCount, + isSaving + } = this.props; + + const { + monitored, + monitor + } = this.state; + + const monitoredOptions = [ + { key: NO_CHANGE, value: 'No Change', disabled: true }, + { key: 'monitored', value: 'Monitored' }, + { key: 'unmonitored', value: 'Unmonitored' } + ]; + + const noChanges = monitored === NO_CHANGE && monitor === NO_CHANGE; + + return ( + +
+
+ Monitor Artist +
+ + +
+ +
+
+ Monitor Albums +
+ + +
+ +
+
+ {selectedCount} Artist(s) Selected +
+ + + Update Selected + +
+
+ ); + } +} + +AlbumStudioFooter.propTypes = { + selectedCount: PropTypes.number.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + onUpdateSelectedPress: PropTypes.func.isRequired +}; + +export default AlbumStudioFooter; diff --git a/frontend/src/AlbumStudio/AlbumStudioRow.css b/frontend/src/AlbumStudio/AlbumStudioRow.css new file mode 100644 index 000000000..7b9d1f52b --- /dev/null +++ b/frontend/src/AlbumStudio/AlbumStudioRow.css @@ -0,0 +1,20 @@ +.status, +.monitored { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 50px; +} + +.title { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 1px; + white-space: nowrap; +} + +.albums { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + display: flex; + flex-wrap: wrap; +} diff --git a/frontend/src/AlbumStudio/AlbumStudioRow.js b/frontend/src/AlbumStudio/AlbumStudioRow.js new file mode 100644 index 000000000..f6a146999 --- /dev/null +++ b/frontend/src/AlbumStudio/AlbumStudioRow.js @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import ArtistNameLink from 'Artist/ArtistNameLink'; +import AlbumStudioAlbum from './AlbumStudioAlbum'; +import styles from './AlbumStudioRow.css'; + +class AlbumStudioRow extends Component { + + // + // Render + + render() { + const { + artistId, + status, + foreignArtistId, + artistName, + monitored, + albums, + isSaving, + isSelected, + onSelectedChange, + onArtistMonitoredPress, + onAlbumMonitoredPress + } = this.props; + + return ( + + + + + + + + + + + + + + + + + { + albums.map((album) => { + return ( + + ); + }) + } + + + ); + } +} + +AlbumStudioRow.propTypes = { + artistId: PropTypes.number.isRequired, + status: PropTypes.string.isRequired, + foreignArtistId: PropTypes.string.isRequired, + artistName: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + albums: PropTypes.arrayOf(PropTypes.object).isRequired, + isSaving: PropTypes.bool.isRequired, + isSelected: PropTypes.bool, + onSelectedChange: PropTypes.func.isRequired, + onArtistMonitoredPress: PropTypes.func.isRequired, + onAlbumMonitoredPress: PropTypes.func.isRequired +}; + +AlbumStudioRow.defaultProps = { + isSaving: false +}; + +export default AlbumStudioRow; diff --git a/frontend/src/AlbumStudio/AlbumStudioRowConnector.js b/frontend/src/AlbumStudio/AlbumStudioRowConnector.js new file mode 100644 index 000000000..901f9407e --- /dev/null +++ b/frontend/src/AlbumStudio/AlbumStudioRowConnector.js @@ -0,0 +1,83 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createArtistSelector from 'Store/Selectors/createArtistSelector'; +import { toggleArtistMonitored } from 'Store/Actions/artistActions'; +import { toggleAlbumsMonitored } from 'Store/Actions/albumActions'; +import AlbumStudioRow from './AlbumStudioRow'; + +function createMapStateToProps() { + return createSelector( + (state) => state.albums, + createArtistSelector(), + (albums, artist) => { + const albumsInArtist = _.filter(albums.items, { artistId: artist.id }); + const sortedAlbums = _.orderBy(albumsInArtist, 'releaseDate', 'desc'); + + return { + ...artist, + artistId: artist.id, + artistName: artist.artistName, + monitored: artist.monitored, + status: artist.status, + isSaving: artist.isSaving, + albums: sortedAlbums + }; + } + ); +} + +const mapDispatchToProps = { + toggleArtistMonitored, + toggleAlbumsMonitored +}; + +class AlbumStudioRowConnector extends Component { + + // + // Listeners + + onArtistMonitoredPress = () => { + const { + artistId, + monitored + } = this.props; + + this.props.toggleArtistMonitored({ + artistId, + monitored: !monitored + }); + } + + onAlbumMonitoredPress = (albumId, monitored) => { + const albumIds = [albumId]; + this.props.toggleAlbumsMonitored({ + albumIds, + monitored + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AlbumStudioRowConnector.propTypes = { + artistId: PropTypes.number.isRequired, + monitored: PropTypes.bool.isRequired, + toggleArtistMonitored: PropTypes.func.isRequired, + toggleAlbumsMonitored: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AlbumStudioRowConnector); diff --git a/frontend/src/App/App.js b/frontend/src/App/App.js new file mode 100644 index 000000000..ecd3ea533 --- /dev/null +++ b/frontend/src/App/App.js @@ -0,0 +1,28 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import DocumentTitle from 'react-document-title'; +import { Provider } from 'react-redux'; +import { ConnectedRouter } from 'connected-react-router'; +import PageConnector from 'Components/Page/PageConnector'; +import AppRoutes from './AppRoutes'; + +function App({ store, history }) { + return ( + + + + + + + + + + ); +} + +App.propTypes = { + store: PropTypes.object.isRequired, + history: PropTypes.object.isRequired +}; + +export default App; diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js new file mode 100644 index 000000000..ed55547e0 --- /dev/null +++ b/frontend/src/App/AppRoutes.js @@ -0,0 +1,267 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { Route, Redirect } from 'react-router-dom'; +import getPathWithUrlBase from 'Utilities/getPathWithUrlBase'; +import NotFound from 'Components/NotFound'; +import Switch from 'Components/Router/Switch'; +import ArtistIndexConnector from 'Artist/Index/ArtistIndexConnector'; +import AddNewArtistConnector from 'AddArtist/AddNewArtist/AddNewArtistConnector'; +import ImportArtist from 'AddArtist/ImportArtist/ImportArtist'; +import ArtistEditorConnector from 'Artist/Editor/ArtistEditorConnector'; +import AlbumStudioConnector from 'AlbumStudio/AlbumStudioConnector'; +import UnmappedFilesTableConnector from 'UnmappedFiles/UnmappedFilesTableConnector'; +import ArtistDetailsPageConnector from 'Artist/Details/ArtistDetailsPageConnector'; +import AlbumDetailsPageConnector from 'Album/Details/AlbumDetailsPageConnector'; +import CalendarPageConnector from 'Calendar/CalendarPageConnector'; +import HistoryConnector from 'Activity/History/HistoryConnector'; +import QueueConnector from 'Activity/Queue/QueueConnector'; +import BlacklistConnector from 'Activity/Blacklist/BlacklistConnector'; +import MissingConnector from 'Wanted/Missing/MissingConnector'; +import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector'; +import Settings from 'Settings/Settings'; +import MediaManagementConnector from 'Settings/MediaManagement/MediaManagementConnector'; +import Profiles from 'Settings/Profiles/Profiles'; +import Quality from 'Settings/Quality/Quality'; +import IndexerSettingsConnector from 'Settings/Indexers/IndexerSettingsConnector'; +import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector'; +import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector'; +import NotificationSettings from 'Settings/Notifications/NotificationSettings'; +import MetadataSettings from 'Settings/Metadata/MetadataSettings'; +import TagSettings from 'Settings/Tags/TagSettings'; +import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector'; +import UISettingsConnector from 'Settings/UI/UISettingsConnector'; +import Status from 'System/Status/Status'; +import Tasks from 'System/Tasks/Tasks'; +import BackupsConnector from 'System/Backup/BackupsConnector'; +import UpdatesConnector from 'System/Updates/UpdatesConnector'; +import LogsTableConnector from 'System/Events/LogsTableConnector'; +import Logs from 'System/Logs/Logs'; + +function AppRoutes(props) { + const { + app + } = props; + + return ( + + {/* + Artist + */} + + + + { + window.Lidarr.urlBase && + { + return ( + + ); + }} + /> + } + + + + + + + + + + + + + + + + {/* + Calendar + */} + + + + {/* + Activity + */} + + + + + + + + {/* + Wanted + */} + + + + + + {/* + Settings + */} + + + + + + + + + + + + + + + + + + + + + + + + + + {/* + System + */} + + + + + + + + + + + + + + {/* + Not Found + */} + + + + + ); +} + +AppRoutes.propTypes = { + app: PropTypes.func.isRequired +}; + +export default AppRoutes; diff --git a/frontend/src/App/AppUpdatedModal.js b/frontend/src/App/AppUpdatedModal.js new file mode 100644 index 000000000..abc7f8832 --- /dev/null +++ b/frontend/src/App/AppUpdatedModal.js @@ -0,0 +1,30 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import AppUpdatedModalContentConnector from './AppUpdatedModalContentConnector'; + +function AppUpdatedModal(props) { + const { + isOpen, + onModalClose + } = props; + + return ( + + + + ); +} + +AppUpdatedModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AppUpdatedModal; diff --git a/frontend/src/App/AppUpdatedModalConnector.js b/frontend/src/App/AppUpdatedModalConnector.js new file mode 100644 index 000000000..a21afbc5a --- /dev/null +++ b/frontend/src/App/AppUpdatedModalConnector.js @@ -0,0 +1,12 @@ +import { connect } from 'react-redux'; +import AppUpdatedModal from './AppUpdatedModal'; + +function createMapDispatchToProps(dispatch, props) { + return { + onModalClose() { + location.reload(); + } + }; +} + +export default connect(null, createMapDispatchToProps)(AppUpdatedModal); diff --git a/frontend/src/App/AppUpdatedModalContent.css b/frontend/src/App/AppUpdatedModalContent.css new file mode 100644 index 000000000..37b89c9be --- /dev/null +++ b/frontend/src/App/AppUpdatedModalContent.css @@ -0,0 +1,15 @@ +.version { + margin: 0 3px; + font-weight: bold; +} + +.maintenance { + margin-top: 20px; +} + +.changes { + margin-top: 20px; + padding-bottom: 5px; + border-bottom: 1px solid #e5e5e5; + font-size: 18px; +} diff --git a/frontend/src/App/AppUpdatedModalContent.js b/frontend/src/App/AppUpdatedModalContent.js new file mode 100644 index 000000000..9597d538f --- /dev/null +++ b/frontend/src/App/AppUpdatedModalContent.js @@ -0,0 +1,98 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import UpdateChanges from 'System/Updates/UpdateChanges'; +import styles from './AppUpdatedModalContent.css'; + +function AppUpdatedModalContent(props) { + const { + version, + isPopulated, + error, + items, + onSeeChangesPress, + onModalClose + } = props; + + const update = items[0]; + + return ( + + + Lidarr Updated + + + +
+ Version {version} of Lidarr has been installed, in order to get the latest changes you'll need to reload Lidarr. +
+ + { + isPopulated && !error && !!update && +
+ { + !update.changes && +
Maintenance release
+ } + + { + !!update.changes && +
+
+ What's new? +
+ + + + +
+ } +
+ } + + { + !isPopulated && !error && + + } +
+ + + + + + +
+ ); +} + +AppUpdatedModalContent.propTypes = { + version: PropTypes.string.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onSeeChangesPress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AppUpdatedModalContent; diff --git a/frontend/src/App/AppUpdatedModalContentConnector.js b/frontend/src/App/AppUpdatedModalContentConnector.js new file mode 100644 index 000000000..7cf649b65 --- /dev/null +++ b/frontend/src/App/AppUpdatedModalContentConnector.js @@ -0,0 +1,76 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchUpdates } from 'Store/Actions/systemActions'; +import AppUpdatedModalContent from './AppUpdatedModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.app.version, + (state) => state.system.updates, + (version, updates) => { + const { + isPopulated, + error, + items + } = updates; + + return { + version, + isPopulated, + error, + items + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + dispatchFetchUpdates() { + dispatch(fetchUpdates()); + }, + + onSeeChangesPress() { + window.location = `${window.Lidarr.urlBase}/system/updates`; + } + }; +} + +class AppUpdatedModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchUpdates(); + } + + componentDidUpdate(prevProps) { + if (prevProps.version !== this.props.version) { + this.props.dispatchFetchUpdates(); + } + } + + // + // Render + + render() { + const { + dispatchFetchUpdates, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +AppUpdatedModalContentConnector.propTypes = { + version: PropTypes.string.isRequired, + dispatchFetchUpdates: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, createMapDispatchToProps)(AppUpdatedModalContentConnector); diff --git a/frontend/src/App/ColorImpairedContext.js b/frontend/src/App/ColorImpairedContext.js new file mode 100644 index 000000000..de98ac8fb --- /dev/null +++ b/frontend/src/App/ColorImpairedContext.js @@ -0,0 +1,6 @@ +import React from 'react'; + +const ColorImpairedContext = React.createContext(false); +export const ColorImpairedConsumer = ColorImpairedContext.Consumer; + +export default ColorImpairedContext; diff --git a/frontend/src/App/ConnectionLostModal.css b/frontend/src/App/ConnectionLostModal.css new file mode 100644 index 000000000..f0a9d220f --- /dev/null +++ b/frontend/src/App/ConnectionLostModal.css @@ -0,0 +1,3 @@ +.automatic { + margin-top: 20px; +} diff --git a/frontend/src/App/ConnectionLostModal.js b/frontend/src/App/ConnectionLostModal.js new file mode 100644 index 000000000..9178d2ab8 --- /dev/null +++ b/frontend/src/App/ConnectionLostModal.js @@ -0,0 +1,55 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './ConnectionLostModal.css'; + +function ConnectionLostModal(props) { + const { + isOpen, + onModalClose + } = props; + + return ( + + + + Connnection Lost + + + +
+ Lidarr has lost it's connection to the backend and will need to be reloaded to restore functionality. +
+ +
+ Lidarr will try to connect automatically, or you can click reload below. +
+
+ + + +
+
+ ); +} + +ConnectionLostModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default ConnectionLostModal; diff --git a/frontend/src/App/ConnectionLostModalConnector.js b/frontend/src/App/ConnectionLostModalConnector.js new file mode 100644 index 000000000..8ab8e3cd0 --- /dev/null +++ b/frontend/src/App/ConnectionLostModalConnector.js @@ -0,0 +1,12 @@ +import { connect } from 'react-redux'; +import ConnectionLostModal from './ConnectionLostModal'; + +function createMapDispatchToProps(dispatch, props) { + return { + onModalClose() { + location.reload(); + } + }; +} + +export default connect(undefined, createMapDispatchToProps)(ConnectionLostModal); diff --git a/frontend/src/Artist/ArtistBanner.js b/frontend/src/Artist/ArtistBanner.js new file mode 100644 index 000000000..b409667b1 --- /dev/null +++ b/frontend/src/Artist/ArtistBanner.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import ArtistImage from './ArtistImage'; + +const bannerPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA+gAAAC5AgMAAADG9/24AAAADFBMVEUyMjI7Ozs1NTU4ODjgOsZvAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4QkRBgAc5PUQ8QAAB7tJREFUeNrtnb1rHEsMwMdjArtrUrpf3uMg2cOl+5QpXWRvjQkXlyHVK8MVYXFl3F+/GAzrNfdSuXkQnH9ie/Oqw32aFM6bkTQfd85rHrwi0qjJJql+pxlJo9FISv0XqX8mKkmSJEmSJEmSJEmSJEmSJEmS5NeVYrh7HDqJ5DeY23kQB64/u7zW91amzgXqvRqjfGYvarnfxqncuaQlf72Zxv4gSOluudOfjRy1Hzh1L+nPj+KU3jh0MWr3Sp87dDFq98An/msmg3zPW/aFR6/vRaBPglML6Mci0H0g92MYfrjvRgJ5TrDvhuFyGIZv9NdTOev93dDry1Z59mM56/2hU8WZcefFjZgVT/Z90SlEV9VKio3HeKYZLLSGII6WPP+oBv3ZwkL3iE4JG/ZRjUYb16mAripUO/c4Hl3bd/juCF19FuHeJn6nK90VBh0s3SjBvcFW/wTrvfBa118EbHY8qmMOtmgdupoKOLTvAmOH1k059LKAX+Qra/TncExH4hcBXV3Zf/+Dv5Vb43cfod/wt3PLsN5VF6HDiudt56IbB23Ql5/oZ8A7CfZWbgF61sap62XdlOZTZ2rF3c7ltNW1+f7LuDez/uc6uDfO8dwkbPXcKF8vPS9sds527pC2utH0uCb0Jmz2t8wNvN3q2jj4ntDJna94m3g4sb7HH8Gue0RH4Je8z61g4GGr79Wz1qHjbi94m/jcH1IOccsj+lt/sOFr4m0EPz+3XyNueURvSmfn+Ebx1rctMvOxU8foqOwVa+9mfdvHDH+DdYQOxAesvZslvc/wo4vQZy6eY+vdrCVrut/AyTW9CWvO3E1ra4L6i5Fxoka7MDan45vTOmx2MPGc0QH52Tb6kTPxXNGtW5/Tnl+oGP2N/dstY8eeO2M+bqM3zvVxRT+gS8Vdl5/z6CaCzfx/c41o3pP2+030UzrAHDNGv8d4FvMVAf1I4c07V/QlnNsyG9TN23ID/ZjObnO+6CZmydS+y8oG9Dfk2Gd80WcW3Rv44e5bZOLtD8EUHbRq0V0F/DAMn8fQ2UUv2UayEMxplaFvM0G7QR/ufqBcmH/gG85Z9BM8rMO5bdiUDu4ceaLvUjqWfJveQm8hpvnKFv1jOLxUTtkoBWf0nIK5Cd6wO207dAznTlmjH+K630KvuKPbOHYffJsOexykx0iWJ7pNRf+ttEXvW1U49F7ZbJ26RHSe6ehn5NRGMPBVQAfP12EQf8QZPTMxXac30M1RxtaWXLBFn3j0eRsHNIBuCycLtqfWCQZrBcZ0W+gVhXts0RtEXyit4zDOoE/N/5yNzNF3oVB0A93I6ise7bijr1XwbYiurySg7xjfpp+g375mjj5D9HYD3fr6YvkKcxU80Q8duvVt2+gjob/ljX7yFH1a80dvDfpia8GXqp3Wr1XJXevljvFt2QZ6a6tJ2Gvd2Pa8Xuu2iNB7o++r+lUJlQas93oOl04be73Ut7y1Ts4tn/0EfaxZOzcKaXIsqNiI4QtE5x7N7Z08RZ9CKpb38cWs9V26b4sDWURnr3W9foq+gpM8a3S4V+rhKUCcqrBXTufMUxU2QWUTkFtZGls3pjgnqJ4FdB1lZAd8/qMEZGRtAlJvJqONb2syzuj2CuK+1YU5reg2CzGNyqq6fpOpku8VRI7lchX+7SIy8NdQSaKn3K8bsWJOlZGBX2Ed0bUIdBXftJpzG1h2vjetWFqgHXrhczTWtx2h8llXVTytI4FHnaes0bGMqNhC1+jUXmFMx7iCaq6qy4HqxR7dFfMUtc34LQCVDPppcM13Kie5RmTGJYNUKBraDL57xEKalS0UzTgXiiqn1X3fRxR0bBf6TLEvD4aKkslTdO5F4fQUIHseo5ugfrShewbWjvsriHxT65WAByAHVCT7u0fvoKDC+rZClZyf/eSAngUTj1pfCXjshU/8smDiPTr7J374sDPDfI1Hd4cX1g874TmvRc+30U8V9+e88Ig7U6V26Ofk21rzg1ScH3Hj033bTXIZab2iGG6PdWOaQzLx7cShQ0FFfdxqlfFu2DAhxw4vf5zWV3hYZ96m47l7u0+DMAgdu1Dxbs4St+Rx6MbAwzIveLfk8Y2Y9J5HP1tij3DmjZii9lujQy9GXO/M22/5pmvUVdWiV3RkYd50zbfaI7Vb9GmDD0C4t9qLGiy+JPTVKT4A4d5gMY866N4i+p9KuUM767aavpkqLnmDDl10S8W/mSq20O3I3n8x6AXufP4tdKlxcpkZ4HN43FZ0mT3GCmicTO2ysW2uVXmFWp/yb5cdN0kHrb/ADwFN0uPW+ICOt+0SWuPjkW2tPTr+CjcSphjiGIzWodPQGwljMGj4Se/Q0bfJGH4Sj7zx6DJG3tCgo4HQoYiukDHoKB5vZdCtb9MrIUM7DyK169Zu+mEUMtQsjLJrtT7vWl2IGWXnBxjaFwFnraQBhmFs5dDqi66SNLYyGlY6XMgaVip4RG00mHh2LWwwcRhHPVsJG0cdhpDProQNIQ+j52e30kbPa19B5T64H9Wfqn0mTelB7Y04pWMFfCQf5JDTjYOTuSClu5QUSa9EyU0gf5BF7iaP2zxdq6TJjUydgxTD3aPvm5wkSZIkSZIkSZIkSZIkSZIk+Z9kv/534U2+Uyf0XwP9H83PZBlAqkdjAAAAAElFTkSuQmCC'; + +function ArtistBanner(props) { + return ( + + ); +} + +ArtistBanner.propTypes = { + size: PropTypes.number.isRequired +}; + +ArtistBanner.defaultProps = { + size: 70 +}; + +export default ArtistBanner; diff --git a/frontend/src/Artist/ArtistImage.js b/frontend/src/Artist/ArtistImage.js new file mode 100644 index 000000000..6ae479a18 --- /dev/null +++ b/frontend/src/Artist/ArtistImage.js @@ -0,0 +1,200 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import LazyLoad from 'react-lazyload'; + +function findImage(images, coverType) { + return images.find((image) => image.coverType === coverType); +} + +function getUrl(image, coverType, size) { + if (image) { + // Remove protocol + let url = image.url.replace(/^https?:/, ''); + + url = url.replace(`${coverType}.jpg`, `${coverType}-${size}.jpg`); + + return url; + } +} + +class ArtistImage extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const pixelRatio = Math.ceil(window.devicePixelRatio); + + const { + images, + coverType, + size + } = props; + + const image = findImage(images, coverType); + + this.state = { + pixelRatio, + image, + url: getUrl(image, coverType, pixelRatio * size), + isLoaded: false, + hasError: false + }; + } + + componentDidMount() { + if (!this.state.url && this.props.onError) { + this.props.onError(); + } + } + + componentDidUpdate() { + const { + images, + coverType, + placeholder, + size, + onError + } = this.props; + + const { + image, + pixelRatio + } = this.state; + + const nextImage = findImage(images, coverType); + + if (nextImage && (!image || nextImage.url !== image.url)) { + this.setState({ + image: nextImage, + url: getUrl(nextImage, coverType, pixelRatio * size), + hasError: false + // Don't reset isLoaded, as we want to immediately try to + // show the new image, whether an image was shown previously + // or the placeholder was shown. + }); + } else if (!nextImage && image) { + this.setState({ + image: nextImage, + url: placeholder, + hasError: false + }); + + if (onError) { + onError(); + } + } + } + + // + // Listeners + + onError = () => { + this.setState({ + hasError: true + }); + + if (this.props.onError) { + this.props.onError(); + } + } + + onLoad = () => { + this.setState({ + isLoaded: true, + hasError: false + }); + + if (this.props.onLoad) { + this.props.onLoad(); + } + } + + // + // Render + + render() { + const { + className, + style, + placeholder, + size, + lazy, + overflow + } = this.props; + + const { + url, + hasError, + isLoaded + } = this.state; + + if (hasError || !url) { + return ( + + ); + } + + if (lazy) { + return ( + + } + > + + + ); + } + + return ( + + ); + } +} + +ArtistImage.propTypes = { + className: PropTypes.string, + style: PropTypes.object, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + coverType: PropTypes.string.isRequired, + placeholder: PropTypes.string.isRequired, + size: PropTypes.number.isRequired, + lazy: PropTypes.bool.isRequired, + overflow: PropTypes.bool.isRequired, + onError: PropTypes.func, + onLoad: PropTypes.func +}; + +ArtistImage.defaultProps = { + size: 250, + lazy: true, + overflow: false +}; + +export default ArtistImage; diff --git a/frontend/src/Artist/ArtistLogo.js b/frontend/src/Artist/ArtistLogo.js new file mode 100644 index 000000000..05e665186 --- /dev/null +++ b/frontend/src/Artist/ArtistLogo.js @@ -0,0 +1,160 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import LazyLoad from 'react-lazyload'; + +const logoPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPcAAAD3AgMAAAC84irAAAAADFBMVEUyMjI7Ozs1NTU4ODjgOsZvAAAAAWJLR0QAiAUdSAAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB+EJEBIzDdm9OfoAAAbkSURBVGje7Zq9b9s4FMBZFgUkBR27C3cw0MromL1jxwyVZASB67G4qWPgoSAyBdm9CwECKCp8nbIccGj/Ce/BTUb3Lh3aI997pCjnTnyyt0JcIif5+ZHvPZLvQ0KMYxzjGMc4xjGOcYxjHOP4JUfSfP7RVPvSH3MYX/eC5aecxne1v+w95WebFs/rwVO/8+h8PnT6t3ln/DFQuJ06/SyHiX9pxa7o5/lewkuLDxLvhM8tPki8g07dU8Gnj5zGlw7P79n4pDVYi8/YuHO4n03z0z6XXDom4G3TXDdN840+LobN/W1Ty2slHD8bNvevlUgutLmTj4NmT3pf6mMGcJGth+gefaZsDCjB2Wj65wN8ZmnAGnE6eFieI1FvcEISLjIUr9hm+w7PFeHiE9t0E7dyIatE48odXTPu0j/A3BMnXf7NXDxudTxbE2VxMWVu+sfwf3i1ZMLiaQLf+iWIP4VtjtTzFhc35vfveZrb4nPt4R95ulu1cxeVh8Psw7rzbgWp8dWHyr83WJpbgjypjS5XeZnqRxmJNUd3MS1d6ue/tOn0WuayNd2CoTlaeqwnIVeOgcWHdHdMS9cSN1vCy3bxZwzFm6VL7QA14WTudVj1sFvf4ReZNSCO0IvwngXFV3hkFcriuPokrPrYbYxjVAHiZ24zLYIeP7/E4xZUgHiZWt29D9ptGemHR7mPo9B10HLGbucRfs/Ww2f2CD4L2u0+wofKwwvrd0XoqCmr38CAZa1d58LesEpvgqtN4MCR1mVj2nZWOiweVB/CAXuyi59Y1auA2eekg6Xw8Tfm013A8LFV8mYXL61ZF4Hb8Zx8d9vBtbdG7s99XvOOZlF38QVtmlkAv0ffxTOjxU/o5p8FvKbSszw2ik87+Iz23Lwf134RiWf2tG3xN2T4oh8vDO4U33z+5qnefFnR77OA2wheh2WfbJBHeI/XgtNJEaHdtJNrvPn8E8eV/kW/2xn8FDc77LemOyq4J1XvSbds7SZ3cAV+86UXP283TGaFUk4ZwmNyugne8FaqxdHtFkH8GNewg2cc3PjsM7CbbNdMwQJ47aL3mP5H308ar5XOn2nUwpx+4hrx/z+qn5DBNqD4rMUpWACnPwnhkfa9SnZwvX1MnHLVi08cPle+0wBuAsykd8dO0KkS9L0dPCO37MVLxJc6nPHdTeNT/ZeLDQN/DEFpBzc33Bfckhx8K1q7IS5vuPgjbTf5AL97zcALxFUHN76QrF7heTHru54RN3bbxTeEn4Xx04f4NOfhSuPLncmnQk3z1yLlSE8fabtFHVyZyIQlXes8zrdSJR5ea7k3+asUooXg2mO4oDprT/XdHpROhouL/8A3edBw5DYxBhYdn08Q53jd0elDfApHbHjL6Hk/pvvNd1rEWdLl9iG+hpMgiMMdVEM64B8X5nq6ZBwX5rCSeK/4uInJROiwetLi0jtpG0yJBPOkTVQXryEPKqMQbq6JeyUTvUOkilq/EVGmo5NIpP3XRIzhXIafrjzF30JUIqecKxIjOpF6il9jbHTLxjs3rN5voPH+GxbDA1m7GrM9a4zdTigdCUUXD2MSSEAXQRxDo2QHl2iwV+h7gchqLrLrhmKxH/Z6nqLUQD5AYSHWAEwk+Z1Ck1vEAmEhBaVtufDtj8Zmv6U+PQNBqbDf/szVR5XNvQteSAzRyeQhzgnIKR2Invq43gQb4+oRaJCTTcRd6RkzGXlJQe3vDq8gsDB2S0QaSoViwKNW9Sh9zUzEMA2MWtU7nJUGYhIa4bnjcLthgkkopMAGj3dxXgoMCbg+laTFL8luSn9pFkrAMf031cmVJz0jXzsKFm6OSfVqYnEILPKZDjeicPFhQoaHbMhKX+NmZ5Q+ntr8n5obhGPVKlx48cs+FteKP3MlswWv6CSPHK4Dmntm0ckreW0snmxKbsnLFdyo4mrwjLYJo+Dmyn0k3uDTEpMRTrnPKza+IHy9wGSEU2yMvSrvHeJ/Qt2UV+p0hVacvsah0psKXqEVy7y2tPu3xhM1oMxLReY00tAlJG9JFZktzCwyU4lbuqQ7U22VN1zi9gvsIP05PjAL7H55H/C6rREzyvu41bbS4VXb1OV0FLG1YVsa1J1gtzaosVJbHO3Gb6z4bR2H89s61FRqCIcgL+E3lfyWlsaN3eR6QDP0pSdeKqOEZjOgoda285SUl5W+Jga181wz0WQFF2poM7FtZTZKXlXZ0Fam10htroY3Ug9s43pN5OJ2jyZy28Iu1nu0sNsGenGzRwO9bd8Xd/u0793LA8Vmn5cHnPhiH+Gt+HIv4Ye+tnHoSyMHvrJy6Aszh76uc+DLQuLQV5XGMY5xjGMc4xjHOMYxjnH80uNfW99BeoyzJCoAAAAASUVORK5CYII='; + +function findLogo(images) { + return _.find(images, { coverType: 'logo' }); +} + +function getLogoUrl(logo, size) { + if (logo) { + // Remove protocol + let url = logo.url.replace(/^https?:/, ''); + url = url.replace('logo.jpg', `logo-${size}.jpg`); + + return url; + } +} + +class ArtistLogo extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const pixelRatio = Math.floor(window.devicePixelRatio); + + const { + images, + size + } = props; + + const logo = findLogo(images); + + this.state = { + pixelRatio, + logo, + logoUrl: getLogoUrl(logo, pixelRatio * size), + hasError: false, + isLoaded: false + }; + } + + componentDidUpdate(prevProps) { + const { + images, + size + } = this.props; + + const { + pixelRatio + } = this.state; + + const logo = findLogo(images); + + if (logo && logo.url !== this.state.logo.url) { + this.setState({ + logo, + logoUrl: getLogoUrl(logo, pixelRatio * size), + hasError: false, + isLoaded: false + }); + } + } + + // + // Listeners + + onError = () => { + this.setState({ hasError: true }); + } + + onLoad = () => { + this.setState({ isLoaded: true }); + } + + // + // Render + + render() { + const { + className, + style, + size, + lazy, + overflow + } = this.props; + + const { + logoUrl, + hasError, + isLoaded + } = this.state; + + if (hasError || !logoUrl) { + return ( + + ); + } + + if (lazy) { + return ( + + } + > + + + ); + } + + return ( + + ); + } +} + +ArtistLogo.propTypes = { + className: PropTypes.string, + style: PropTypes.object, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + size: PropTypes.number.isRequired, + lazy: PropTypes.bool.isRequired, + overflow: PropTypes.bool.isRequired +}; + +ArtistLogo.defaultProps = { + size: 250, + lazy: true, + overflow: false +}; + +export default ArtistLogo; diff --git a/frontend/src/Artist/ArtistNameLink.js b/frontend/src/Artist/ArtistNameLink.js new file mode 100644 index 000000000..fab1cb974 --- /dev/null +++ b/frontend/src/Artist/ArtistNameLink.js @@ -0,0 +1,20 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Link from 'Components/Link/Link'; + +function ArtistNameLink({ foreignArtistId, artistName }) { + const link = `/artist/${foreignArtistId}`; + + return ( + + {artistName} + + ); +} + +ArtistNameLink.propTypes = { + foreignArtistId: PropTypes.string.isRequired, + artistName: PropTypes.string.isRequired +}; + +export default ArtistNameLink; diff --git a/frontend/src/Artist/ArtistPoster.js b/frontend/src/Artist/ArtistPoster.js new file mode 100644 index 000000000..4eebd9ca4 --- /dev/null +++ b/frontend/src/Artist/ArtistPoster.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import ArtistImage from './ArtistImage'; + +const posterPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPcAAAD3AgMAAAC84irAAAAADFBMVEUyMjI7Ozs1NTU4ODjgOsZvAAAAAWJLR0QAiAUdSAAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB+EJEBIzDdm9OfoAAAbkSURBVGje7Zq9b9s4FMBZFgUkBR27C3cw0MromL1jxwyVZASB67G4qWPgoSAyBdm9CwECKCp8nbIccGj/Ce/BTUb3Lh3aI997pCjnTnyyt0JcIif5+ZHvPZLvQ0KMYxzjGMc4xjGOcYxjHOP4JUfSfP7RVPvSH3MYX/eC5aecxne1v+w95WebFs/rwVO/8+h8PnT6t3ln/DFQuJ06/SyHiX9pxa7o5/lewkuLDxLvhM8tPki8g07dU8Gnj5zGlw7P79n4pDVYi8/YuHO4n03z0z6XXDom4G3TXDdN840+LobN/W1Ty2slHD8bNvevlUgutLmTj4NmT3pf6mMGcJGth+gefaZsDCjB2Wj65wN8ZmnAGnE6eFieI1FvcEISLjIUr9hm+w7PFeHiE9t0E7dyIatE48odXTPu0j/A3BMnXf7NXDxudTxbE2VxMWVu+sfwf3i1ZMLiaQLf+iWIP4VtjtTzFhc35vfveZrb4nPt4R95ulu1cxeVh8Psw7rzbgWp8dWHyr83WJpbgjypjS5XeZnqRxmJNUd3MS1d6ue/tOn0WuayNd2CoTlaeqwnIVeOgcWHdHdMS9cSN1vCy3bxZwzFm6VL7QA14WTudVj1sFvf4ReZNSCO0IvwngXFV3hkFcriuPokrPrYbYxjVAHiZ24zLYIeP7/E4xZUgHiZWt29D9ptGemHR7mPo9B10HLGbucRfs/Ww2f2CD4L2u0+wofKwwvrd0XoqCmr38CAZa1d58LesEpvgqtN4MCR1mVj2nZWOiweVB/CAXuyi59Y1auA2eekg6Xw8Tfm013A8LFV8mYXL61ZF4Hb8Zx8d9vBtbdG7s99XvOOZlF38QVtmlkAv0ffxTOjxU/o5p8FvKbSszw2ik87+Iz23Lwf134RiWf2tG3xN2T4oh8vDO4U33z+5qnefFnR77OA2wheh2WfbJBHeI/XgtNJEaHdtJNrvPn8E8eV/kW/2xn8FDc77LemOyq4J1XvSbds7SZ3cAV+86UXP283TGaFUk4ZwmNyugne8FaqxdHtFkH8GNewg2cc3PjsM7CbbNdMwQJ47aL3mP5H308ar5XOn2nUwpx+4hrx/z+qn5DBNqD4rMUpWACnPwnhkfa9SnZwvX1MnHLVi08cPle+0wBuAsykd8dO0KkS9L0dPCO37MVLxJc6nPHdTeNT/ZeLDQN/DEFpBzc33Bfckhx8K1q7IS5vuPgjbTf5AL97zcALxFUHN76QrF7heTHru54RN3bbxTeEn4Xx04f4NOfhSuPLncmnQk3z1yLlSE8fabtFHVyZyIQlXes8zrdSJR5ea7k3+asUooXg2mO4oDprT/XdHpROhouL/8A3edBw5DYxBhYdn08Q53jd0elDfApHbHjL6Hk/pvvNd1rEWdLl9iG+hpMgiMMdVEM64B8X5nq6ZBwX5rCSeK/4uInJROiwetLi0jtpG0yJBPOkTVQXryEPKqMQbq6JeyUTvUOkilq/EVGmo5NIpP3XRIzhXIafrjzF30JUIqecKxIjOpF6il9jbHTLxjs3rN5voPH+GxbDA1m7GrM9a4zdTigdCUUXD2MSSEAXQRxDo2QHl2iwV+h7gchqLrLrhmKxH/Z6nqLUQD5AYSHWAEwk+Z1Ck1vEAmEhBaVtufDtj8Zmv6U+PQNBqbDf/szVR5XNvQteSAzRyeQhzgnIKR2Invq43gQb4+oRaJCTTcRd6RkzGXlJQe3vDq8gsDB2S0QaSoViwKNW9Sh9zUzEMA2MWtU7nJUGYhIa4bnjcLthgkkopMAGj3dxXgoMCbg+laTFL8luSn9pFkrAMf031cmVJz0jXzsKFm6OSfVqYnEILPKZDjeicPFhQoaHbMhKX+NmZ5Q+ntr8n5obhGPVKlx48cs+FteKP3MlswWv6CSPHK4Dmntm0ckreW0snmxKbsnLFdyo4mrwjLYJo+Dmyn0k3uDTEpMRTrnPKza+IHy9wGSEU2yMvSrvHeJ/Qt2UV+p0hVacvsah0psKXqEVy7y2tPu3xhM1oMxLReY00tAlJG9JFZktzCwyU4lbuqQ7U22VN1zi9gvsIP05PjAL7H55H/C6rREzyvu41bbS4VXb1OV0FLG1YVsa1J1gtzaosVJbHO3Gb6z4bR2H89s61FRqCIcgL+E3lfyWlsaN3eR6QDP0pSdeKqOEZjOgoda285SUl5W+Jga181wz0WQFF2poM7FtZTZKXlXZ0Fam10htroY3Ug9s43pN5OJ2jyZy28Iu1nu0sNsGenGzRwO9bd8Xd/u0793LA8Vmn5cHnPhiH+Gt+HIv4Ye+tnHoSyMHvrJy6Aszh76uc+DLQuLQV5XGMY5xjGMc4xjHOMYxjnH80uNfW99BeoyzJCoAAAAASUVORK5CYII='; + +function ArtistPoster(props) { + return ( + + ); +} + +ArtistPoster.propTypes = { + size: PropTypes.number.isRequired +}; + +ArtistPoster.defaultProps = { + size: 250 +}; + +export default ArtistPoster; diff --git a/frontend/src/Artist/Delete/DeleteArtistModal.js b/frontend/src/Artist/Delete/DeleteArtistModal.js new file mode 100644 index 000000000..5b6490c66 --- /dev/null +++ b/frontend/src/Artist/Delete/DeleteArtistModal.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import DeleteArtistModalContentConnector from './DeleteArtistModalContentConnector'; + +function DeleteArtistModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +DeleteArtistModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default DeleteArtistModal; diff --git a/frontend/src/Artist/Delete/DeleteArtistModalContent.css b/frontend/src/Artist/Delete/DeleteArtistModalContent.css new file mode 100644 index 000000000..dbfef0871 --- /dev/null +++ b/frontend/src/Artist/Delete/DeleteArtistModalContent.css @@ -0,0 +1,12 @@ +.pathContainer { + margin-bottom: 20px; +} + +.pathIcon { + margin-right: 8px; +} + +.deleteFilesMessage { + margin-top: 20px; + color: $dangerColor; +} diff --git a/frontend/src/Artist/Delete/DeleteArtistModalContent.js b/frontend/src/Artist/Delete/DeleteArtistModalContent.js new file mode 100644 index 000000000..a242a1e20 --- /dev/null +++ b/frontend/src/Artist/Delete/DeleteArtistModalContent.js @@ -0,0 +1,166 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { icons, inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Icon from 'Components/Icon'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './DeleteArtistModalContent.css'; + +class DeleteArtistModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + deleteFiles: false, + addImportListExclusion: false + }; + } + + // + // Listeners + + onDeleteFilesChange = ({ value }) => { + this.setState({ deleteFiles: value }); + } + + onAddImportListExclusionChange = ({ value }) => { + this.setState({ addImportListExclusion: value }); + } + + onDeleteArtistConfirmed = () => { + const deleteFiles = this.state.deleteFiles; + const addImportListExclusion = this.state.addImportListExclusion; + + this.setState({ deleteFiles: false }); + this.setState({ addImportListExclusion: false }); + this.props.onDeletePress(deleteFiles, addImportListExclusion); + } + + // + // Render + + render() { + const { + artistName, + path, + statistics, + onModalClose + } = this.props; + + const { + trackFileCount, + sizeOnDisk + } = statistics; + + const deleteFiles = this.state.deleteFiles; + const addImportListExclusion = this.state.addImportListExclusion; + + let deleteFilesLabel = `Delete ${trackFileCount} Track Files`; + let deleteFilesHelpText = 'Delete the track files and artist folder'; + + if (trackFileCount === 0) { + deleteFilesLabel = 'Delete Artist Folder'; + deleteFilesHelpText = 'Delete the artist folder and its contents'; + } + + return ( + + + Delete - {artistName} + + + +
+ + + {path} +
+ + + {deleteFilesLabel} + + + + + + Add List Exclusion + + + + + { + deleteFiles && +
+
The artist folder {path} and all of its content will be deleted.
+ + { + !!trackFileCount && +
{trackFileCount} track files totaling {formatBytes(sizeOnDisk)}
+ } +
+ } + +
+ + + + + + +
+ ); + } +} + +DeleteArtistModalContent.propTypes = { + artistName: PropTypes.string.isRequired, + path: PropTypes.string.isRequired, + statistics: PropTypes.object.isRequired, + onDeletePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +DeleteArtistModalContent.defaultProps = { + statistics: { + trackFileCount: 0 + } +}; + +export default DeleteArtistModalContent; diff --git a/frontend/src/Artist/Delete/DeleteArtistModalContentConnector.js b/frontend/src/Artist/Delete/DeleteArtistModalContentConnector.js new file mode 100644 index 000000000..e0ea034ab --- /dev/null +++ b/frontend/src/Artist/Delete/DeleteArtistModalContentConnector.js @@ -0,0 +1,56 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createArtistSelector from 'Store/Selectors/createArtistSelector'; +import { deleteArtist } from 'Store/Actions/artistActions'; +import DeleteArtistModalContent from './DeleteArtistModalContent'; + +function createMapStateToProps() { + return createSelector( + createArtistSelector(), + (artist) => { + return artist; + } + ); +} + +const mapDispatchToProps = { + deleteArtist +}; + +class DeleteArtistModalContentConnector extends Component { + + // + // Listeners + + onDeletePress = (deleteFiles, addImportListExclusion) => { + this.props.deleteArtist({ + id: this.props.artistId, + deleteFiles, + addImportListExclusion + }); + + this.props.onModalClose(true); + } + + // + // Render + + render() { + return ( + + ); + } +} + +DeleteArtistModalContentConnector.propTypes = { + artistId: PropTypes.number.isRequired, + onModalClose: PropTypes.func.isRequired, + deleteArtist: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DeleteArtistModalContentConnector); diff --git a/frontend/src/Artist/Details/AlbumRow.css b/frontend/src/Artist/Details/AlbumRow.css new file mode 100644 index 000000000..e29f491d7 --- /dev/null +++ b/frontend/src/Artist/Details/AlbumRow.css @@ -0,0 +1,17 @@ +.title { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + white-space: nowrap; +} + +.monitored { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 42px; +} + +.status { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} diff --git a/frontend/src/Artist/Details/AlbumRow.js b/frontend/src/Artist/Details/AlbumRow.js new file mode 100644 index 000000000..e2d6cf65e --- /dev/null +++ b/frontend/src/Artist/Details/AlbumRow.js @@ -0,0 +1,263 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import { kinds, sizes } from 'Helpers/Props'; +import TableRow from 'Components/Table/TableRow'; +import Label from 'Components/Label'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; +import AlbumSearchCellConnector from 'Album/AlbumSearchCellConnector'; +import AlbumTitleLink from 'Album/AlbumTitleLink'; +import StarRating from 'Components/StarRating'; +import styles from './AlbumRow.css'; + +function getTrackCountKind(monitored, trackFileCount, trackCount) { + if (trackFileCount === trackCount && trackCount > 0) { + return kinds.SUCCESS; + } + + if (!monitored) { + return kinds.WARNING; + } + + return kinds.DANGER; +} + +class AlbumRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false, + isEditAlbumModalOpen: false + }; + } + + // + // Listeners + + onManualSearchPress = () => { + this.setState({ isDetailsModalOpen: true }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + onEditAlbumPress = () => { + this.setState({ isEditAlbumModalOpen: true }); + } + + onEditAlbumModalClose = () => { + this.setState({ isEditAlbumModalOpen: false }); + } + + onMonitorAlbumPress = (monitored, options) => { + this.props.onMonitorAlbumPress(this.props.id, monitored, options); + } + + // + // Render + + render() { + const { + id, + artistId, + monitored, + statistics, + duration, + releaseDate, + mediumCount, + secondaryTypes, + title, + ratings, + disambiguation, + isSaving, + artistMonitored, + foreignAlbumId, + columns + } = this.props; + + const { + trackCount, + trackFileCount, + totalTrackCount + } = statistics; + + return ( + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'monitored') { + return ( + + + + ); + } + + if (name === 'title') { + return ( + + + + ); + } + + if (name === 'mediumCount') { + return ( + + { + mediumCount + } + + ); + } + + if (name === 'secondaryTypes') { + return ( + + { + secondaryTypes + } + + ); + } + + if (name === 'trackCount') { + return ( + + { + statistics.totalTrackCount + } + + ); + } + + if (name === 'duration') { + return ( + + { + formatTimeSpan(duration) + } + + ); + } + + if (name === 'rating') { + return ( + + { + + } + + ); + } + + if (name === 'releaseDate') { + return ( + + ); + } + + if (name === 'status') { + return ( + + + + ); + } + + if (name === 'actions') { + return ( + + ); + } + return null; + }) + } + + ); + } +} + +AlbumRow.propTypes = { + id: PropTypes.number.isRequired, + artistId: PropTypes.number.isRequired, + monitored: PropTypes.bool.isRequired, + releaseDate: PropTypes.string.isRequired, + mediumCount: PropTypes.number.isRequired, + duration: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + ratings: PropTypes.object.isRequired, + disambiguation: PropTypes.string, + secondaryTypes: PropTypes.arrayOf(PropTypes.string).isRequired, + foreignAlbumId: PropTypes.string.isRequired, + isSaving: PropTypes.bool, + unverifiedSceneNumbering: PropTypes.bool, + artistMonitored: PropTypes.bool.isRequired, + statistics: PropTypes.object.isRequired, + mediaInfo: PropTypes.object, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + onMonitorAlbumPress: PropTypes.func.isRequired +}; + +AlbumRow.defaultProps = { + statistics: { + trackCount: 0, + trackFileCount: 0 + } +}; + +export default AlbumRow; diff --git a/frontend/src/Artist/Details/AlbumRowConnector.js b/frontend/src/Artist/Details/AlbumRowConnector.js new file mode 100644 index 000000000..6e92fb1d4 --- /dev/null +++ b/frontend/src/Artist/Details/AlbumRowConnector.js @@ -0,0 +1,22 @@ +/* eslint max-params: 0 */ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createArtistSelector from 'Store/Selectors/createArtistSelector'; +import createTrackFileSelector from 'Store/Selectors/createTrackFileSelector'; +import AlbumRow from './AlbumRow'; + +function createMapStateToProps() { + return createSelector( + createArtistSelector(), + createTrackFileSelector(), + (artist = {}, trackFile) => { + return { + foreignArtistId: artist.foreignArtistId, + artistMonitored: artist.monitored, + trackFilePath: trackFile ? trackFile.path : null, + trackFileRelativePath: trackFile ? trackFile.relativePath : null + }; + } + ); +} +export default connect(createMapStateToProps)(AlbumRow); diff --git a/frontend/src/Artist/Details/ArtistAlternateTitles.css b/frontend/src/Artist/Details/ArtistAlternateTitles.css new file mode 100644 index 000000000..1af1ae68b --- /dev/null +++ b/frontend/src/Artist/Details/ArtistAlternateTitles.css @@ -0,0 +1,3 @@ +.alternateTitle { + white-space: nowrap; +} diff --git a/frontend/src/Artist/Details/ArtistAlternateTitles.js b/frontend/src/Artist/Details/ArtistAlternateTitles.js new file mode 100644 index 000000000..e1fde52e6 --- /dev/null +++ b/frontend/src/Artist/Details/ArtistAlternateTitles.js @@ -0,0 +1,28 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './ArtistAlternateTitles.css'; + +function ArtistAlternateTitles({ alternateTitles }) { + return ( +
    + { + alternateTitles.map((alternateTitle) => { + return ( +
  • + {alternateTitle} +
  • + ); + }) + } +
+ ); +} + +ArtistAlternateTitles.propTypes = { + alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired +}; + +export default ArtistAlternateTitles; diff --git a/frontend/src/Artist/Details/ArtistDetails.css b/frontend/src/Artist/Details/ArtistDetails.css new file mode 100644 index 000000000..fb3803a85 --- /dev/null +++ b/frontend/src/Artist/Details/ArtistDetails.css @@ -0,0 +1,167 @@ +.innerContentBody { + padding: 0; +} + +.header { + position: relative; + width: 100%; + height: 310px; +} + +.errorMessage { + margin-top: 20px; + text-align: center; + font-size: 20px; +} + +.backdrop { + position: absolute; + z-index: -1; + width: 100%; + height: 100%; + background-size: cover; +} + +.backdropOverlay { + position: absolute; + width: 100%; + height: 100%; + background: $black; + opacity: 0.7; +} + +.headerContent { + display: flex; + padding: 30px; + width: 100%; + height: 100%; + color: $white; +} + +.poster { + flex-shrink: 0; + margin-right: 35px; + width: 250px; + height: 250px; +} + +.info { + display: flex; + flex-direction: column; + flex-grow: 1; + overflow: hidden; +} + +.metadataMessage { + color: $helpTextColor; + text-align: center; + font-weight: 300; + font-size: 20px; +} + +.titleRow { + display: flex; + justify-content: space-between; + flex: 0 0 auto; +} + +.titleContainer { + display: flex; + margin-bottom: 5px; +} + +.title { + font-weight: 300; + font-size: 50px; + line-height: 50px; +} + +.toggleMonitoredContainer { + align-self: center; + margin-right: 10px; +} + +.monitorToggleButton { + composes: toggleButton from '~Components/MonitorToggleButton.css'; + + width: 40px; + + &:hover { + color: $iconButtonHoverLightColor; + } +} + +.alternateTitlesIconContainer { + align-self: flex-end; + margin-left: 20px; +} + +.artistNavigationButtons { + white-space: nowrap; +} + +.artistNavigationButton { + composes: button from '~Components/Link/IconButton.css'; + + margin-left: 5px; + width: 30px; + color: #e1e2e3; + white-space: nowrap; + + &:hover { + color: $iconButtonHoverLightColor; + } +} + +.details { + margin-bottom: 8px; + font-weight: 300; + font-size: 20px; +} + +.runtime { + margin-right: 15px; +} + +.detailsLabel { + composes: label from '~Components/Label.css'; + + margin: 5px 10px 5px 0; +} + +.path, +.sizeOnDisk, +.qualityProfileName, +.links, +.tags { + margin-left: 8px; + font-weight: 300; + font-size: 17px; +} + +.overview { + flex: 1 0 auto; + margin-top: 8px; + min-height: 0; + font-size: $intermediateFontSize; +} + +.contentContainer { + padding: 20px; +} + +@media only screen and (max-width: $breakpointSmall) { + .contentContainer { + padding: 20px 0; + } + + .headerContent { + padding: 15px; + } +} + +@media only screen and (max-width: $breakpointLarge) { + .poster { + display: none; + } +} diff --git a/frontend/src/Artist/Details/ArtistDetails.js b/frontend/src/Artist/Details/ArtistDetails.js new file mode 100644 index 000000000..699eb3f21 --- /dev/null +++ b/frontend/src/Artist/Details/ArtistDetails.js @@ -0,0 +1,714 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import TextTruncate from 'react-text-truncate'; +import formatBytes from 'Utilities/Number/formatBytes'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { align, icons, kinds, sizes, tooltipPositions } from 'Helpers/Props'; +import fonts from 'Styles/Variables/fonts'; +import HeartRating from 'Components/HeartRating'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import Label from 'Components/Label'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import Popover from 'Components/Tooltip/Popover'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import TrackFileEditorModal from 'TrackFile/Editor/TrackFileEditorModal'; +import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector'; +import RetagPreviewModalConnector from 'Retag/RetagPreviewModalConnector'; +import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector'; +import ArtistPoster from 'Artist/ArtistPoster'; +import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; +import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; +import ArtistHistoryModal from 'Artist/History/ArtistHistoryModal'; +import ArtistAlternateTitles from './ArtistAlternateTitles'; +import ArtistDetailsSeasonConnector from './ArtistDetailsSeasonConnector'; +import ArtistTagsConnector from './ArtistTagsConnector'; +import ArtistDetailsLinks from './ArtistDetailsLinks'; +import styles from './ArtistDetails.css'; +import InteractiveImportModal from '../../InteractiveImport/InteractiveImportModal'; +import ArtistInteractiveSearchModalConnector from 'Artist/Search/ArtistInteractiveSearchModalConnector'; +import Link from 'Components/Link/Link'; + +const defaultFontSize = parseInt(fonts.defaultFontSize); +const lineHeight = parseFloat(fonts.lineHeight); + +function getFanartUrl(images) { + const fanartImage = _.find(images, { coverType: 'fanart' }); + if (fanartImage) { + // Remove protocol + return fanartImage.url.replace(/^https?:/, ''); + } +} + +function getExpandedState(newState) { + return { + allExpanded: newState.allSelected, + allCollapsed: newState.allUnselected, + expandedState: newState.selectedState + }; +} + +class ArtistDetails extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isOrganizeModalOpen: false, + isRetagModalOpen: false, + isManageTracksOpen: false, + isEditArtistModalOpen: false, + isDeleteArtistModalOpen: false, + isArtistHistoryModalOpen: false, + isInteractiveImportModalOpen: false, + isInteractiveSearchModalOpen: false, + allExpanded: false, + allCollapsed: false, + expandedState: {} + }; + } + + // + // Listeners + + onOrganizePress = () => { + this.setState({ isOrganizeModalOpen: true }); + } + + onOrganizeModalClose = () => { + this.setState({ isOrganizeModalOpen: false }); + } + + onRetagPress = () => { + this.setState({ isRetagModalOpen: true }); + } + + onRetagModalClose = () => { + this.setState({ isRetagModalOpen: false }); + } + + onManageTracksPress = () => { + this.setState({ isManageTracksOpen: true }); + } + + onManageTracksModalClose = () => { + this.setState({ isManageTracksOpen: false }); + } + + onInteractiveImportPress = () => { + this.setState({ isInteractiveImportModalOpen: true }); + } + + onInteractiveImportModalClose = () => { + this.setState({ isInteractiveImportModalOpen: false }); + } + + onInteractiveSearchPress = () => { + this.setState({ isInteractiveSearchModalOpen: true }); + } + + onInteractiveSearchModalClose = () => { + this.setState({ isInteractiveSearchModalOpen: false }); + } + + onEditArtistPress = () => { + this.setState({ isEditArtistModalOpen: true }); + } + + onEditArtistModalClose = () => { + this.setState({ isEditArtistModalOpen: false }); + } + + onDeleteArtistPress = () => { + this.setState({ + isEditArtistModalOpen: false, + isDeleteArtistModalOpen: true + }); + } + + onDeleteArtistModalClose = () => { + this.setState({ isDeleteArtistModalOpen: false }); + } + + onArtistHistoryPress = () => { + this.setState({ isArtistHistoryModalOpen: true }); + } + + onArtistHistoryModalClose = () => { + this.setState({ isArtistHistoryModalOpen: false }); + } + + onExpandAllPress = () => { + const { + allExpanded, + expandedState + } = this.state; + + this.setState(getExpandedState(selectAll(expandedState, !allExpanded))); + } + + onExpandPress = (albumId, isExpanded) => { + this.setState((state) => { + const convertedState = { + allSelected: state.allExpanded, + allUnselected: state.allCollapsed, + selectedState: state.expandedState + }; + + const newState = toggleSelected(convertedState, [], albumId, isExpanded, false); + + return getExpandedState(newState); + }); + } + + // + // Render + + render() { + const { + id, + foreignArtistId, + artistName, + ratings, + path, + statistics, + qualityProfileId, + monitored, + albumTypes, + status, + overview, + links, + images, + artistType, + alternateTitles, + tags, + isSaving, + isRefreshing, + isSearching, + isFetching, + isPopulated, + albumsError, + trackFilesError, + hasAlbums, + hasMonitoredAlbums, + hasTrackFiles, + previousArtist, + nextArtist, + onMonitorTogglePress, + onRefreshPress, + onSearchPress + } = this.props; + + const { + trackFileCount, + sizeOnDisk + } = statistics; + + const { + isOrganizeModalOpen, + isRetagModalOpen, + isManageTracksOpen, + isEditArtistModalOpen, + isDeleteArtistModalOpen, + isArtistHistoryModalOpen, + isInteractiveImportModalOpen, + isInteractiveSearchModalOpen, + allExpanded, + allCollapsed, + expandedState + } = this.state; + + const continuing = status === 'continuing'; + const endedString = artistType === 'Person' ? 'Deceased' : 'Ended'; + + let trackFilesCountMessage = 'No track files'; + + if (trackFileCount === 1) { + trackFilesCountMessage = '1 track file'; + } else if (trackFileCount > 1) { + trackFilesCountMessage = `${trackFileCount} track files`; + } + + let expandIcon = icons.EXPAND_INDETERMINATE; + + if (allExpanded) { + expandIcon = icons.COLLAPSE; + } else if (allCollapsed) { + expandIcon = icons.EXPAND; + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+ + +
+
+
+
+ +
+ +
+ {artistName} +
+ + { + !!alternateTitles.length && +
+ + } + title="Alternate Titles" + body={} + position={tooltipPositions.BOTTOM} + /> +
+ } +
+ +
+ + + + + +
+
+ +
+
+ +
+
+ +
+ + + + + + + + + + + + + + + Links + + + } + tooltip={ + + } + kind={kinds.INVERSE} + position={tooltipPositions.BOTTOM} + /> + + { + !!tags.length && + + + + + Tags + + + } + tooltip={} + kind={kinds.INVERSE} + position={tooltipPositions.BOTTOM} + /> + + } +
+
+ +
+
+
+
+ +
+ { + !isPopulated && !albumsError && !trackFilesError && + + } + + { + !isFetching && albumsError && +
Loading albums failed
+ } + + { + !isFetching && trackFilesError && +
Loading track files failed
+ } + + { + isPopulated && !!albumTypes.length && +
+ { + albumTypes.slice(0).map((albumType) => { + return ( + + ); + }) + } +
+ } + +
+ +
+ Missing Albums, Singles, or Other Types? Modify or Create a New Metadata Profile! +
+ + + + + + + + + + + + + + + + + + + ); + } +} + +ArtistDetails.propTypes = { + id: PropTypes.number.isRequired, + foreignArtistId: PropTypes.string.isRequired, + artistName: PropTypes.string.isRequired, + ratings: PropTypes.object.isRequired, + path: PropTypes.string.isRequired, + statistics: PropTypes.object.isRequired, + qualityProfileId: PropTypes.number.isRequired, + monitored: PropTypes.bool.isRequired, + artistType: PropTypes.string, + albumTypes: PropTypes.arrayOf(PropTypes.string), + status: PropTypes.string.isRequired, + overview: PropTypes.string.isRequired, + links: PropTypes.arrayOf(PropTypes.object).isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired, + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + isSaving: PropTypes.bool.isRequired, + isRefreshing: PropTypes.bool.isRequired, + isSearching: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + albumsError: PropTypes.object, + trackFilesError: PropTypes.object, + hasAlbums: PropTypes.bool.isRequired, + hasMonitoredAlbums: PropTypes.bool.isRequired, + hasTrackFiles: PropTypes.bool.isRequired, + previousArtist: PropTypes.object.isRequired, + nextArtist: PropTypes.object.isRequired, + onMonitorTogglePress: PropTypes.func.isRequired, + onRefreshPress: PropTypes.func.isRequired, + onSearchPress: PropTypes.func.isRequired +}; + +ArtistDetails.defaultProps = { + statistics: {}, + tags: [], + isSaving: false +}; + +export default ArtistDetails; diff --git a/frontend/src/Artist/Details/ArtistDetailsConnector.js b/frontend/src/Artist/Details/ArtistDetailsConnector.js new file mode 100644 index 000000000..2e5ba1d11 --- /dev/null +++ b/frontend/src/Artist/Details/ArtistDetailsConnector.js @@ -0,0 +1,285 @@ +/* eslint max-params: 0 */ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { findCommand, isCommandExecuting } from 'Utilities/Command'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import { fetchAlbums, clearAlbums } from 'Store/Actions/albumActions'; +import { fetchTrackFiles, clearTrackFiles } from 'Store/Actions/trackFileActions'; +import { toggleArtistMonitored } from 'Store/Actions/artistActions'; +import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import ArtistDetails from './ArtistDetails'; + +const selectAlbums = createSelector( + (state) => state.albums, + (albums) => { + const { + items, + isFetching, + isPopulated, + error + } = albums; + + const hasAlbums = !!items.length; + const hasMonitoredAlbums = items.some((e) => e.monitored); + + return { + isAlbumsFetching: isFetching, + isAlbumsPopulated: isPopulated, + albumsError: error, + hasAlbums, + hasMonitoredAlbums + }; + } +); + +const selectTrackFiles = createSelector( + (state) => state.trackFiles, + (trackFiles) => { + const { + items, + isFetching, + isPopulated, + error + } = trackFiles; + + const hasTrackFiles = !!items.length; + + return { + isTrackFilesFetching: isFetching, + isTrackFilesPopulated: isPopulated, + trackFilesError: error, + hasTrackFiles + }; + } +); + +function createMapStateToProps() { + return createSelector( + (state, { foreignArtistId }) => foreignArtistId, + selectAlbums, + selectTrackFiles, + (state) => state.settings.metadataProfiles, + createAllArtistSelector(), + createCommandsSelector(), + (foreignArtistId, albums, trackFiles, metadataProfiles, allArtists, commands) => { + const sortedArtist = _.orderBy(allArtists, 'sortName'); + const artistIndex = _.findIndex(sortedArtist, { foreignArtistId }); + const artist = sortedArtist[artistIndex]; + const metadataProfile = _.find(metadataProfiles.items, { id: artist.metadataProfileId }); + const albumTypes = _.reduce(metadataProfile.primaryAlbumTypes, (acc, primaryType) => { + if (primaryType.allowed) { + acc.push(primaryType.albumType.name); + } + return acc; + }, []); + + if (!artist) { + return {}; + } + + const { + isAlbumsFetching, + isAlbumsPopulated, + albumsError, + hasAlbums, + hasMonitoredAlbums + } = albums; + + const { + isTrackFilesFetching, + isTrackFilesPopulated, + trackFilesError, + hasTrackFiles + } = trackFiles; + + const sortedAlbumTypes = _.orderBy(albumTypes); + + const previousArtist = sortedArtist[artistIndex - 1] || _.last(sortedArtist); + const nextArtist = sortedArtist[artistIndex + 1] || _.first(sortedArtist); + const isArtistRefreshing = isCommandExecuting(findCommand(commands, { name: commandNames.REFRESH_ARTIST, artistId: artist.id })); + const artistRefreshingCommand = findCommand(commands, { name: commandNames.REFRESH_ARTIST }); + const allArtistRefreshing = ( + isCommandExecuting(artistRefreshingCommand) && + !artistRefreshingCommand.body.artistId + ); + const isRefreshing = isArtistRefreshing || allArtistRefreshing; + const isSearching = isCommandExecuting(findCommand(commands, { name: commandNames.ARTIST_SEARCH, artistId: artist.id })); + const isRenamingFiles = isCommandExecuting(findCommand(commands, { name: commandNames.RENAME_FILES, artistId: artist.id })); + + const isRenamingArtistCommand = findCommand(commands, { name: commandNames.RENAME_ARTIST }); + const isRenamingArtist = ( + isCommandExecuting(isRenamingArtistCommand) && + isRenamingArtistCommand.body.artistIds.indexOf(artist.id) > -1 + ); + + const isFetching = isAlbumsFetching || isTrackFilesFetching; + const isPopulated = isAlbumsPopulated && isTrackFilesPopulated; + + const alternateTitles = _.reduce(artist.alternateTitles, (acc, alternateTitle) => { + if ((alternateTitle.seasonNumber === -1 || alternateTitle.seasonNumber === undefined) && + (alternateTitle.sceneSeasonNumber === -1 || alternateTitle.sceneSeasonNumber === undefined)) { + acc.push(alternateTitle.title); + } + + return acc; + }, []); + + return { + ...artist, + albumTypes: sortedAlbumTypes, + alternateTitles, + isArtistRefreshing, + allArtistRefreshing, + isRefreshing, + isSearching, + isRenamingFiles, + isRenamingArtist, + isFetching, + isPopulated, + albumsError, + trackFilesError, + hasAlbums, + hasMonitoredAlbums, + hasTrackFiles, + previousArtist, + nextArtist + }; + } + ); +} + +const mapDispatchToProps = { + fetchAlbums, + clearAlbums, + fetchTrackFiles, + clearTrackFiles, + toggleArtistMonitored, + fetchQueueDetails, + clearQueueDetails, + executeCommand +}; + +class ArtistDetailsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + registerPagePopulator(this.populate); + this.populate(); + } + + componentDidUpdate(prevProps) { + const { + id, + isArtistRefreshing, + allArtistRefreshing, + isRenamingFiles, + isRenamingArtist + } = this.props; + + if ( + (prevProps.isArtistRefreshing && !isArtistRefreshing) || + (prevProps.allArtistRefreshing && !allArtistRefreshing) || + (prevProps.isRenamingFiles && !isRenamingFiles) || + (prevProps.isRenamingArtist && !isRenamingArtist) + ) { + this.populate(); + } + + // If the id has changed we need to clear the albums + // files and fetch from the server. + + if (prevProps.id !== id) { + this.unpopulate(); + this.populate(); + } + } + + componentWillUnmount() { + unregisterPagePopulator(this.populate); + this.unpopulate(); + } + + // + // Control + + populate = () => { + const artistId = this.props.id; + + this.props.fetchAlbums({ artistId }); + this.props.fetchTrackFiles({ artistId }); + this.props.fetchQueueDetails({ artistId }); + } + + unpopulate = () => { + this.props.clearAlbums(); + this.props.clearTrackFiles(); + this.props.clearQueueDetails(); + } + + // + // Listeners + + onMonitorTogglePress = (monitored) => { + this.props.toggleArtistMonitored({ + artistId: this.props.id, + monitored + }); + } + + onRefreshPress = () => { + this.props.executeCommand({ + name: commandNames.REFRESH_ARTIST, + artistId: this.props.id + }); + } + + onSearchPress = () => { + this.props.executeCommand({ + name: commandNames.ARTIST_SEARCH, + artistId: this.props.id + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ArtistDetailsConnector.propTypes = { + id: PropTypes.number.isRequired, + foreignArtistId: PropTypes.string.isRequired, + isArtistRefreshing: PropTypes.bool.isRequired, + allArtistRefreshing: PropTypes.bool.isRequired, + isRefreshing: PropTypes.bool.isRequired, + isRenamingFiles: PropTypes.bool.isRequired, + isRenamingArtist: PropTypes.bool.isRequired, + fetchAlbums: PropTypes.func.isRequired, + clearAlbums: PropTypes.func.isRequired, + fetchTrackFiles: PropTypes.func.isRequired, + clearTrackFiles: PropTypes.func.isRequired, + toggleArtistMonitored: PropTypes.func.isRequired, + fetchQueueDetails: PropTypes.func.isRequired, + clearQueueDetails: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ArtistDetailsConnector); diff --git a/frontend/src/Artist/Details/ArtistDetailsLinks.css b/frontend/src/Artist/Details/ArtistDetailsLinks.css new file mode 100644 index 000000000..d37a082a1 --- /dev/null +++ b/frontend/src/Artist/Details/ArtistDetailsLinks.css @@ -0,0 +1,13 @@ +.links { + margin: 0; +} + +.link { + white-space: nowrap; +} + +.linkLabel { + composes: label from '~Components/Label.css'; + + cursor: pointer; +} diff --git a/frontend/src/Artist/Details/ArtistDetailsLinks.js b/frontend/src/Artist/Details/ArtistDetailsLinks.js new file mode 100644 index 000000000..23941d06b --- /dev/null +++ b/frontend/src/Artist/Details/ArtistDetailsLinks.js @@ -0,0 +1,63 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds, sizes } from 'Helpers/Props'; +import Label from 'Components/Label'; +import Link from 'Components/Link/Link'; +import styles from './ArtistDetailsLinks.css'; + +function ArtistDetailsLinks(props) { + const { + foreignArtistId, + links + } = props; + + return ( +
+ + + + + + {links.map((link, index) => { + return ( + + + + + {(index > 0 && index % 5 === 0) && +
+ } + +
+ ); + })} + +
+ + ); +} + +ArtistDetailsLinks.propTypes = { + foreignArtistId: PropTypes.string.isRequired, + links: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default ArtistDetailsLinks; diff --git a/frontend/src/Artist/Details/ArtistDetailsPageConnector.js b/frontend/src/Artist/Details/ArtistDetailsPageConnector.js new file mode 100644 index 000000000..61b1a0f4c --- /dev/null +++ b/frontend/src/Artist/Details/ArtistDetailsPageConnector.js @@ -0,0 +1,117 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { push } from 'connected-react-router'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import NotFound from 'Components/NotFound'; +import ArtistDetailsConnector from './ArtistDetailsConnector'; +import styles from './ArtistDetails.css'; + +function createMapStateToProps() { + return createSelector( + (state, { match }) => match, + (state) => state.artist, + (match, artist) => { + const foreignArtistId = match.params.foreignArtistId; + const { + isFetching, + isPopulated, + error, + items + } = artist; + + const artistIndex = _.findIndex(items, { foreignArtistId }); + + if (artistIndex > -1) { + return { + isFetching, + isPopulated, + foreignArtistId + }; + } + + return { + isFetching, + isPopulated, + error + }; + } + ); +} + +const mapDispatchToProps = { + push +}; + +class ArtistDetailsPageConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps) { + if (!this.props.foreignArtistId) { + this.props.push(`${window.Lidarr.urlBase}/`); + return; + } + } + + // + // Render + + render() { + const { + foreignArtistId, + isFetching, + isPopulated, + error + } = this.props; + + if (isFetching && !isPopulated) { + return ( + + + + + + ); + } + + if (!isFetching && !!error) { + return ( +
+ {getErrorMessage(error, 'Failed to load artist from API')} +
+ ); + } + + if (!foreignArtistId) { + return ( + + ); + } + + return ( + + ); + } +} + +ArtistDetailsPageConnector.propTypes = { + foreignArtistId: PropTypes.string, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + match: PropTypes.shape({ params: PropTypes.shape({ foreignArtistId: PropTypes.string.isRequired }).isRequired }).isRequired, + push: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ArtistDetailsPageConnector); diff --git a/frontend/src/Artist/Details/ArtistDetailsSeason.css b/frontend/src/Artist/Details/ArtistDetailsSeason.css new file mode 100644 index 000000000..127f0c772 --- /dev/null +++ b/frontend/src/Artist/Details/ArtistDetailsSeason.css @@ -0,0 +1,125 @@ +.albumType { + margin-bottom: 20px; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; + + &:last-of-type { + margin-bottom: 0; + } +} + +.header { + position: relative; + display: flex; + align-items: center; + width: 100%; + font-size: 24px; + cursor: pointer; +} + +.albumTypeLabel { + margin-right: 5px; + margin-left: 5px; +} + +.albumCount { + color: #8895aa; + font-style: italic; + font-size: 18px; +} + +.episodeCountTooltip { + display: flex; +} + +.expandButton { + composes: link from '~Components/Link/Link.css'; + + flex-grow: 1; + width: 100%; + text-align: center; +} + +.left { + display: flex; + align-items: center; + flex: 0 1 300px; +} + +.left, +.actions { + padding: 15px 10px; +} + +.actionsMenu { + composes: menu from '~Components/Menu/Menu.css'; + + flex: 0 0 45px; +} + +.actionsMenuContent { + composes: menuContent from '~Components/Menu/MenuContent.css'; + + white-space: nowrap; + font-size: $defaultFontSize; +} + +.actionMenuIcon { + margin-right: 8px; +} + +.actionButton { + composes: button from '~Components/Link/IconButton.css'; + + width: 30px; +} + +.albums { + padding-top: 15px; + border-top: 1px solid $borderColor; +} + +.collapseButtonContainer { + display: flex; + align-items: center; + justify-content: center; + padding: 10px 15px; + width: 100%; + border-top: 1px solid $borderColor; + border-bottom-right-radius: 4px; + border-bottom-left-radius: 4px; + background-color: #fafafa; +} + +.collapseButtonIcon { + margin-bottom: -4px; +} + +.expandButtonIcon { + composes: actionButton; + + position: absolute; + top: 50%; + left: 50%; + margin-top: -12px; + margin-left: -15px; +} + +.noAlbums { + margin-bottom: 15px; + text-align: center; +} + +@media only screen and (max-width: $breakpointSmall) { + .albumType { + border-right: 0; + border-left: 0; + border-radius: 0; + } + + .expandButtonIcon { + position: static; + margin: 0; + } +} diff --git a/frontend/src/Artist/Details/ArtistDetailsSeason.js b/frontend/src/Artist/Details/ArtistDetailsSeason.js new file mode 100644 index 000000000..f9968a8e9 --- /dev/null +++ b/frontend/src/Artist/Details/ArtistDetailsSeason.js @@ -0,0 +1,253 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getToggledRange from 'Utilities/Table/getToggledRange'; +import { icons, sortDirections } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TrackFileEditorModal from 'TrackFile/Editor/TrackFileEditorModal'; +import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector'; +import AlbumRowConnector from './AlbumRowConnector'; +import styles from './ArtistDetailsSeason.css'; + +class ArtistDetailsSeason extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isOrganizeModalOpen: false, + isManageTracksOpen: false, + lastToggledAlbum: null + }; + } + + componentDidMount() { + this._expandByDefault(); + } + + componentDidUpdate(prevProps) { + const { + artistId + } = this.props; + + if (prevProps.artistId !== artistId) { + this._expandByDefault(); + return; + } + } + + // + // Control + + _expandByDefault() { + const { + name, + onExpandPress, + items, + uiSettings + } = this.props; + + const expand = _.some(items, (item) => + ((item.albumType === 'Album') && uiSettings.expandAlbumByDefault) || + ((item.albumType === 'Single') && uiSettings.expandSingleByDefault) || + ((item.albumType === 'EP') && uiSettings.expandEPByDefault) || + ((item.albumType === 'Broadcast') && uiSettings.expandBroadcastByDefault) || + ((item.albumType === 'Other') && uiSettings.expandOtherByDefault)); + + onExpandPress(name, expand); + } + + // + // Listeners + + onOrganizePress = () => { + this.setState({ isOrganizeModalOpen: true }); + } + + onOrganizeModalClose = () => { + this.setState({ isOrganizeModalOpen: false }); + } + + onManageTracksPress = () => { + this.setState({ isManageTracksOpen: true }); + } + + onManageTracksModalClose = () => { + this.setState({ isManageTracksOpen: false }); + } + + onExpandPress = () => { + const { + name, + isExpanded + } = this.props; + + this.props.onExpandPress(name, !isExpanded); + } + + onMonitorAlbumPress = (albumId, monitored, { shiftKey }) => { + const lastToggled = this.state.lastToggledAlbum; + const albumIds = [albumId]; + + if (shiftKey && lastToggled) { + const { lower, upper } = getToggledRange(this.props.items, albumId, lastToggled); + const items = this.props.items; + + for (let i = lower; i < upper; i++) { + albumIds.push(items[i].id); + } + } + + this.setState({ lastToggledAlbum: albumId }); + + this.props.onMonitorAlbumPress(_.uniq(albumIds), monitored); + } + + // + // Render + + render() { + const { + artistId, + label, + items, + columns, + isExpanded, + sortKey, + sortDirection, + onSortPress, + isSmallScreen, + onTableOptionChange + } = this.props; + + const { + isOrganizeModalOpen, + isManageTracksOpen + } = this.state; + + return ( +
+ +
+
+ { +
+ + {label} + + + + ({items.length} Releases) + +
+ } + +
+ + + + { + !isSmallScreen && +   + } + +
+ + +
+ { + isExpanded && +
+ { + items.length ? + + + { + items.map((item) => { + return ( + + ); + }) + } + +
: + +
+ No releases in this group +
+ } +
+ +
+
+ } +
+ + + + +
+ ); + } +} + +ArtistDetailsSeason.propTypes = { + artistId: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + isExpanded: PropTypes.bool, + isSmallScreen: PropTypes.bool.isRequired, + onTableOptionChange: PropTypes.func.isRequired, + onExpandPress: PropTypes.func.isRequired, + onSortPress: PropTypes.func.isRequired, + onMonitorAlbumPress: PropTypes.func.isRequired, + uiSettings: PropTypes.object.isRequired +}; + +export default ArtistDetailsSeason; diff --git a/frontend/src/Artist/Details/ArtistDetailsSeasonConnector.js b/frontend/src/Artist/Details/ArtistDetailsSeasonConnector.js new file mode 100644 index 000000000..ffb84ba2c --- /dev/null +++ b/frontend/src/Artist/Details/ArtistDetailsSeasonConnector.js @@ -0,0 +1,99 @@ +/* eslint max-params: 0 */ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import createArtistSelector from 'Store/Selectors/createArtistSelector'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import { toggleAlbumsMonitored, setAlbumsTableOption, setAlbumsSort } from 'Store/Actions/albumActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import ArtistDetailsSeason from './ArtistDetailsSeason'; + +function createMapStateToProps() { + return createSelector( + (state, { label }) => label, + createClientSideCollectionSelector('albums'), + createArtistSelector(), + createCommandsSelector(), + createDimensionsSelector(), + createUISettingsSelector(), + (label, albums, artist, commands, dimensions, uiSettings) => { + + const albumsInGroup = _.filter(albums.items, { albumType: label }); + + let sortDir = 'asc'; + + if (albums.sortDirection === 'descending') { + sortDir = 'desc'; + } + + const sortedAlbums = _.orderBy(albumsInGroup, albums.sortKey, sortDir); + + return { + items: sortedAlbums, + columns: albums.columns, + sortKey: albums.sortKey, + sortDirection: albums.sortDirection, + artistMonitored: artist.monitored, + isSmallScreen: dimensions.isSmallScreen, + uiSettings + }; + } + ); +} + +const mapDispatchToProps = { + toggleAlbumsMonitored, + setAlbumsTableOption, + dispatchSetAlbumSort: setAlbumsSort, + executeCommand +}; + +class ArtistDetailsSeasonConnector extends Component { + + // + // Listeners + + onTableOptionChange = (payload) => { + this.props.setAlbumsTableOption(payload); + } + + onSortPress = (sortKey) => { + this.props.dispatchSetAlbumSort({ sortKey }); + } + + onMonitorAlbumPress = (albumIds, monitored) => { + this.props.toggleAlbumsMonitored({ + albumIds, + monitored + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ArtistDetailsSeasonConnector.propTypes = { + artistId: PropTypes.number.isRequired, + toggleAlbumsMonitored: PropTypes.func.isRequired, + setAlbumsTableOption: PropTypes.func.isRequired, + dispatchSetAlbumSort: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ArtistDetailsSeasonConnector); diff --git a/frontend/src/Artist/Details/ArtistTags.css b/frontend/src/Artist/Details/ArtistTags.css new file mode 100644 index 000000000..ec340a041 --- /dev/null +++ b/frontend/src/Artist/Details/ArtistTags.css @@ -0,0 +1,8 @@ +.tags { + margin: 0; + padding-left: 20px; +} + +.tag { + white-space: nowrap; +} diff --git a/frontend/src/Artist/Details/ArtistTags.js b/frontend/src/Artist/Details/ArtistTags.js new file mode 100644 index 000000000..7ea841a36 --- /dev/null +++ b/frontend/src/Artist/Details/ArtistTags.js @@ -0,0 +1,30 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds, sizes } from 'Helpers/Props'; +import Label from 'Components/Label'; + +function ArtistTags({ tags }) { + return ( +
+ { + tags.map((tag) => { + return ( + + ); + }) + } +
+ ); +} + +ArtistTags.propTypes = { + tags: PropTypes.arrayOf(PropTypes.string).isRequired +}; + +export default ArtistTags; diff --git a/frontend/src/Artist/Details/ArtistTagsConnector.js b/frontend/src/Artist/Details/ArtistTagsConnector.js new file mode 100644 index 000000000..1ecde26cd --- /dev/null +++ b/frontend/src/Artist/Details/ArtistTagsConnector.js @@ -0,0 +1,30 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createArtistSelector from 'Store/Selectors/createArtistSelector'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import ArtistTags from './ArtistTags'; + +function createMapStateToProps() { + return createSelector( + createArtistSelector(), + createTagsSelector(), + (artist, tagList) => { + const tags = _.reduce(artist.tags, (acc, tag) => { + const matchingTag = _.find(tagList, { id: tag }); + + if (matchingTag) { + acc.push(matchingTag.label); + } + + return acc; + }, []); + + return { + tags + }; + } + ); +} + +export default connect(createMapStateToProps)(ArtistTags); diff --git a/frontend/src/Artist/Edit/EditArtistModal.js b/frontend/src/Artist/Edit/EditArtistModal.js new file mode 100644 index 000000000..6e99a2f53 --- /dev/null +++ b/frontend/src/Artist/Edit/EditArtistModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import EditArtistModalContentConnector from './EditArtistModalContentConnector'; + +function EditArtistModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditArtistModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditArtistModal; diff --git a/frontend/src/Artist/Edit/EditArtistModalConnector.js b/frontend/src/Artist/Edit/EditArtistModalConnector.js new file mode 100644 index 000000000..9e62a4780 --- /dev/null +++ b/frontend/src/Artist/Edit/EditArtistModalConnector.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditArtistModal from './EditArtistModal'; + +const mapDispatchToProps = { + clearPendingChanges +}; + +class EditArtistModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'artist' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditArtistModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(undefined, mapDispatchToProps)(EditArtistModalConnector); diff --git a/frontend/src/Artist/Edit/EditArtistModalContent.css b/frontend/src/Artist/Edit/EditArtistModalContent.css new file mode 100644 index 000000000..a2b6014df --- /dev/null +++ b/frontend/src/Artist/Edit/EditArtistModalContent.css @@ -0,0 +1,5 @@ +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Artist/Edit/EditArtistModalContent.js b/frontend/src/Artist/Edit/EditArtistModalContent.js new file mode 100644 index 000000000..73dd652e8 --- /dev/null +++ b/frontend/src/Artist/Edit/EditArtistModalContent.js @@ -0,0 +1,210 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import MoveArtistModal from 'Artist/MoveArtist/MoveArtistModal'; +import styles from './EditArtistModalContent.css'; + +class EditArtistModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isConfirmMoveModalOpen: false + }; + } + + // + // Listeners + + onSavePress = () => { + const { + isPathChanging, + onSavePress + } = this.props; + + if (isPathChanging && !this.state.isConfirmMoveModalOpen) { + this.setState({ isConfirmMoveModalOpen: true }); + } else { + this.setState({ isConfirmMoveModalOpen: false }); + + onSavePress(false); + } + } + + onMoveArtistPress = () => { + this.setState({ isConfirmMoveModalOpen: false }); + + this.props.onSavePress(true); + } + + // + // Render + + render() { + const { + artistName, + item, + isSaving, + showMetadataProfile, + originalPath, + onInputChange, + onModalClose, + onDeleteArtistPress, + ...otherProps + } = this.props; + + const { + monitored, + albumFolder, + qualityProfileId, + metadataProfileId, + path, + tags + } = item; + + return ( + + + Edit - {artistName} + + + +
+ + Monitored + + + + + + Use Album Folder + + + + + + Quality Profile + + + + + { + showMetadataProfile && + + Metadata Profile + + + + } + + + Path + + + + + + Tags + + + +
+
+ + + + + + + Save + + + + + +
+ ); + } +} + +EditArtistModalContent.propTypes = { + artistId: PropTypes.number.isRequired, + artistName: PropTypes.string.isRequired, + item: PropTypes.object.isRequired, + isSaving: PropTypes.bool.isRequired, + showMetadataProfile: PropTypes.bool.isRequired, + isPathChanging: PropTypes.bool.isRequired, + originalPath: PropTypes.string.isRequired, + onInputChange: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteArtistPress: PropTypes.func.isRequired +}; + +export default EditArtistModalContent; diff --git a/frontend/src/Artist/Edit/EditArtistModalContentConnector.js b/frontend/src/Artist/Edit/EditArtistModalContentConnector.js new file mode 100644 index 000000000..351bc7d34 --- /dev/null +++ b/frontend/src/Artist/Edit/EditArtistModalContentConnector.js @@ -0,0 +1,119 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; +import createArtistSelector from 'Store/Selectors/createArtistSelector'; +import { setArtistValue, saveArtist } from 'Store/Actions/artistActions'; +import EditArtistModalContent from './EditArtistModalContent'; + +function createIsPathChangingSelector() { + return createSelector( + (state) => state.artist.pendingChanges, + createArtistSelector(), + (pendingChanges, artist) => { + const path = pendingChanges.path; + + if (path == null) { + return false; + } + + return artist.path !== path; + } + ); +} + +function createMapStateToProps() { + return createSelector( + (state) => state.artist, + (state) => state.settings.metadataProfiles, + createArtistSelector(), + createIsPathChangingSelector(), + (artistState, metadataProfiles, artist, isPathChanging) => { + const { + isSaving, + saveError, + pendingChanges + } = artistState; + + const artistSettings = _.pick(artist, [ + 'monitored', + 'albumFolder', + 'qualityProfileId', + 'metadataProfileId', + 'path', + 'tags' + ]); + + const settings = selectSettings(artistSettings, pendingChanges, saveError); + + return { + artistName: artist.artistName, + isSaving, + saveError, + isPathChanging, + originalPath: artist.path, + item: settings.settings, + showMetadataProfile: metadataProfiles.items.length > 1, + ...settings + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetArtistValue: setArtistValue, + dispatchSaveArtist: saveArtist +}; + +class EditArtistModalContentConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.dispatchSetArtistValue({ name, value }); + } + + onSavePress = (moveFiles) => { + this.props.dispatchSaveArtist({ + id: this.props.artistId, + moveFiles + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditArtistModalContentConnector.propTypes = { + artistId: PropTypes.number, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + dispatchSetArtistValue: PropTypes.func.isRequired, + dispatchSaveArtist: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditArtistModalContentConnector); diff --git a/frontend/src/Artist/Editor/ArtistEditor.js b/frontend/src/Artist/Editor/ArtistEditor.js new file mode 100644 index 000000000..d4f6b282c --- /dev/null +++ b/frontend/src/Artist/Editor/ArtistEditor.js @@ -0,0 +1,309 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { align, sortDirections } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import NoArtist from 'Artist/NoArtist'; +import OrganizeArtistModal from './Organize/OrganizeArtistModal'; +import RetagArtistModal from './AudioTags/RetagArtistModal'; +import ArtistEditorRowConnector from './ArtistEditorRowConnector'; +import ArtistEditorFooter from './ArtistEditorFooter'; +import ArtistEditorFilterModalConnector from './ArtistEditorFilterModalConnector'; + +function getColumns(showMetadataProfile) { + return [ + { + name: 'status', + isSortable: true, + isVisible: true + }, + { + name: 'sortName', + label: 'Name', + isSortable: true, + isVisible: true + }, + { + name: 'qualityProfileId', + label: 'Quality Profile', + isSortable: true, + isVisible: true + }, + { + name: 'metadataProfileId', + label: 'Metadata Profile', + isSortable: true, + isVisible: showMetadataProfile + }, + { + name: 'albumFolder', + label: 'Album Folder', + isSortable: true, + isVisible: true + }, + { + name: 'path', + label: 'Path', + isSortable: true, + isVisible: true + }, + { + name: 'tags', + label: 'Tags', + isSortable: false, + isVisible: true + } + ]; +} + +class ArtistEditor extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, + isOrganizingArtistModalOpen: false, + isRetaggingArtistModalOpen: false, + columns: getColumns(props.showMetadataProfile) + }; + } + + componentDidUpdate(prevProps) { + const { + isDeleting, + deleteError + } = this.props; + + const hasFinishedDeleting = prevProps.isDeleting && + !isDeleting && + !deleteError; + + if (hasFinishedDeleting) { + this.onSelectAllChange({ value: false }); + } + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onSaveSelected = (changes) => { + this.props.onSaveSelected({ + artistIds: this.getSelectedIds(), + ...changes + }); + } + + onOrganizeArtistPress = () => { + this.setState({ isOrganizingArtistModalOpen: true }); + } + + onOrganizeArtistModalClose = (organized) => { + this.setState({ isOrganizingArtistModalOpen: false }); + + if (organized === true) { + this.onSelectAllChange({ value: false }); + } + } + + onRetagArtistPress = () => { + this.setState({ isRetaggingArtistModalOpen: true }); + } + + onRetagArtistModalClose = (organized) => { + this.setState({ isRetaggingArtistModalOpen: false }); + + if (organized === true) { + this.onSelectAllChange({ value: false }); + } + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + totalItems, + items, + selectedFilterKey, + filters, + customFilters, + sortKey, + sortDirection, + isSaving, + saveError, + isDeleting, + deleteError, + isOrganizingArtist, + isRetaggingArtist, + showMetadataProfile, + onSortPress, + onFilterSelect + } = this.props; + + const { + allSelected, + allUnselected, + selectedState, + columns + } = this.state; + + const selectedArtistIds = this.getSelectedIds(); + + return ( + + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
{getErrorMessage(error, 'Failed to load artist from API')}
+ } + + { + !error && isPopulated && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+
+ } + + { + !error && isPopulated && !items.length && + + } +
+ + + + + + + +
+ ); + } +} + +ArtistEditor.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + totalItems: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + isDeleting: PropTypes.bool.isRequired, + deleteError: PropTypes.object, + isOrganizingArtist: PropTypes.bool.isRequired, + isRetaggingArtist: PropTypes.bool.isRequired, + showMetadataProfile: PropTypes.bool.isRequired, + onSortPress: PropTypes.func.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onSaveSelected: PropTypes.func.isRequired +}; + +export default ArtistEditor; diff --git a/frontend/src/Artist/Editor/ArtistEditorConnector.js b/frontend/src/Artist/Editor/ArtistEditorConnector.js new file mode 100644 index 000000000..c0188ee6d --- /dev/null +++ b/frontend/src/Artist/Editor/ArtistEditorConnector.js @@ -0,0 +1,92 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { setArtistEditorSort, setArtistEditorFilter, saveArtistEditor } from 'Store/Actions/artistEditorActions'; +import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import ArtistEditor from './ArtistEditor'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.metadataProfiles, + createClientSideCollectionSelector('artist', 'artistEditor'), + createCommandExecutingSelector(commandNames.RENAME_ARTIST), + createCommandExecutingSelector(commandNames.RETAG_ARTIST), + (metadataProfiles, artist, isOrganizingArtist, isRetaggingArtist) => { + return { + isOrganizingArtist, + isRetaggingArtist, + showMetadataProfile: metadataProfiles.items.length > 1, + ...artist + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetArtistEditorSort: setArtistEditorSort, + dispatchSetArtistEditorFilter: setArtistEditorFilter, + dispatchSaveArtistEditor: saveArtistEditor, + dispatchFetchRootFolders: fetchRootFolders, + dispatchExecuteCommand: executeCommand +}; + +class ArtistEditorConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchRootFolders(); + } + + // + // Listeners + + onSortPress = (sortKey) => { + this.props.dispatchSetArtistEditorSort({ sortKey }); + } + + onFilterSelect = (selectedFilterKey) => { + this.props.dispatchSetArtistEditorFilter({ selectedFilterKey }); + } + + onSaveSelected = (payload) => { + this.props.dispatchSaveArtistEditor(payload); + } + + onMoveSelected = (payload) => { + this.props.dispatchExecuteCommand({ + name: commandNames.MOVE_ARTIST, + ...payload + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ArtistEditorConnector.propTypes = { + dispatchSetArtistEditorSort: PropTypes.func.isRequired, + dispatchSetArtistEditorFilter: PropTypes.func.isRequired, + dispatchSaveArtistEditor: PropTypes.func.isRequired, + dispatchFetchRootFolders: PropTypes.func.isRequired, + dispatchExecuteCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ArtistEditorConnector); diff --git a/frontend/src/Artist/Editor/ArtistEditorFilterModalConnector.js b/frontend/src/Artist/Editor/ArtistEditorFilterModalConnector.js new file mode 100644 index 000000000..4aff2df06 --- /dev/null +++ b/frontend/src/Artist/Editor/ArtistEditorFilterModalConnector.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setArtistEditorFilter } from 'Store/Actions/artistEditorActions'; +import FilterModal from 'Components/Filter/FilterModal'; + +function createMapStateToProps() { + return createSelector( + (state) => state.artist.items, + (state) => state.artistEditor.filterBuilderProps, + (sectionItems, filterBuilderProps) => { + return { + sectionItems, + filterBuilderProps, + customFilterType: 'artistEditor' + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetFilter: setArtistEditorFilter +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal); diff --git a/frontend/src/Artist/Editor/ArtistEditorFooter.css b/frontend/src/Artist/Editor/ArtistEditorFooter.css new file mode 100644 index 000000000..3785f88d3 --- /dev/null +++ b/frontend/src/Artist/Editor/ArtistEditorFooter.css @@ -0,0 +1,70 @@ +.inputContainer { + margin-right: 20px; + min-width: 150px; +} + +.buttonContainer { + display: flex; + justify-content: flex-end; + flex-grow: 1; +} + +.buttonContainerContent { + flex-grow: 0; +} + +.buttons { + display: flex; + justify-content: flex-end; + flex-grow: 1; +} + +.organizeSelectedButton, +.tagsButton { + composes: button from '~Components/Link/SpinnerButton.css'; + + margin-right: 10px; + height: 35px; +} + +.deleteSelectedButton { + composes: button from '~Components/Link/SpinnerButton.css'; + + margin-left: 50px; + height: 35px; +} + +@media only screen and (max-width: $breakpointExtraLarge) { + .deleteSelectedButton { + margin-left: 0; + } +} + +@media only screen and (max-width: $breakpointLarge) { + .buttonContainer { + justify-content: flex-start; + margin-top: 10px; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .inputContainer { + margin-right: 0; + } + + .buttonContainer { + justify-content: flex-start; + } + + .buttonContainerContent { + flex-grow: 1; + } + + .buttons { + justify-content: space-between; + } + + .selectedArtistLabel { + text-align: left; + } +} diff --git a/frontend/src/Artist/Editor/ArtistEditorFooter.js b/frontend/src/Artist/Editor/ArtistEditorFooter.js new file mode 100644 index 000000000..ccf044c53 --- /dev/null +++ b/frontend/src/Artist/Editor/ArtistEditorFooter.js @@ -0,0 +1,349 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import SelectInput from 'Components/Form/SelectInput'; +import MetadataProfileSelectInputConnector from 'Components/Form/MetadataProfileSelectInputConnector'; +import QualityProfileSelectInputConnector from 'Components/Form/QualityProfileSelectInputConnector'; +import RootFolderSelectInputConnector from 'Components/Form/RootFolderSelectInputConnector'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import PageContentFooter from 'Components/Page/PageContentFooter'; +import MoveArtistModal from 'Artist/MoveArtist/MoveArtistModal'; +import TagsModal from './Tags/TagsModal'; +import DeleteArtistModal from './Delete/DeleteArtistModal'; +import ArtistEditorFooterLabel from './ArtistEditorFooterLabel'; +import styles from './ArtistEditorFooter.css'; + +const NO_CHANGE = 'noChange'; + +class ArtistEditorFooter extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + monitored: NO_CHANGE, + qualityProfileId: NO_CHANGE, + metadataProfileId: NO_CHANGE, + albumFolder: NO_CHANGE, + rootFolderPath: NO_CHANGE, + savingTags: false, + isDeleteArtistModalOpen: false, + isTagsModalOpen: false, + isConfirmMoveModalOpen: false, + destinationRootFolder: null + }; + } + + componentDidUpdate(prevProps) { + const { + isSaving, + saveError + } = this.props; + + if (prevProps.isSaving && !isSaving && !saveError) { + this.setState({ + monitored: NO_CHANGE, + qualityProfileId: NO_CHANGE, + metadataProfileId: NO_CHANGE, + albumFolder: NO_CHANGE, + rootFolderPath: NO_CHANGE, + savingTags: false + }); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.setState({ [name]: value }); + + if (value === NO_CHANGE) { + return; + } + + switch (name) { + case 'rootFolderPath': + this.setState({ + isConfirmMoveModalOpen: true, + destinationRootFolder: value + }); + break; + case 'monitored': + this.props.onSaveSelected({ [name]: value === 'monitored' }); + break; + case 'albumFolder': + this.props.onSaveSelected({ [name]: value === 'yes' }); + break; + default: + this.props.onSaveSelected({ [name]: value }); + } + } + + onApplyTagsPress = (tags, applyTags) => { + this.setState({ + savingTags: true, + isTagsModalOpen: false + }); + + this.props.onSaveSelected({ + tags, + applyTags + }); + } + + onDeleteSelectedPress = () => { + this.setState({ isDeleteArtistModalOpen: true }); + } + + onDeleteArtistModalClose = () => { + this.setState({ isDeleteArtistModalOpen: false }); + } + + onTagsPress = () => { + this.setState({ isTagsModalOpen: true }); + } + + onTagsModalClose = () => { + this.setState({ isTagsModalOpen: false }); + } + + onSaveRootFolderPress = () => { + this.setState({ + isConfirmMoveModalOpen: false, + destinationRootFolder: null + }); + + this.props.onSaveSelected({ rootFolderPath: this.state.destinationRootFolder }); + } + + onMoveArtistPress = () => { + this.setState({ + isConfirmMoveModalOpen: false, + destinationRootFolder: null + }); + + this.props.onSaveSelected({ + rootFolderPath: this.state.destinationRootFolder, + moveFiles: true + }); + } + + // + // Render + + render() { + const { + artistIds, + selectedCount, + isSaving, + isDeleting, + isOrganizingArtist, + isRetaggingArtist, + showMetadataProfile, + onOrganizeArtistPress, + onRetagArtistPress + } = this.props; + + const { + monitored, + qualityProfileId, + metadataProfileId, + albumFolder, + rootFolderPath, + savingTags, + isTagsModalOpen, + isDeleteArtistModalOpen, + isConfirmMoveModalOpen, + destinationRootFolder + } = this.state; + + const monitoredOptions = [ + { key: NO_CHANGE, value: 'No Change', disabled: true }, + { key: 'monitored', value: 'Monitored' }, + { key: 'unmonitored', value: 'Unmonitored' } + ]; + + const albumFolderOptions = [ + { key: NO_CHANGE, value: 'No Change', disabled: true }, + { key: 'yes', value: 'Yes' }, + { key: 'no', value: 'No' } + ]; + + return ( + +
+ + + +
+ +
+ + + +
+ + { + showMetadataProfile && +
+ + + +
+ } + +
+ + + +
+ +
+ + + +
+ +
+
+ + +
+
+ + Rename Files + + + + Write Metadata Tags + + + + Set Lidarr Tags + +
+ + + Delete + +
+
+
+ + + + + + + +
+ ); + } +} + +ArtistEditorFooter.propTypes = { + artistIds: PropTypes.arrayOf(PropTypes.number).isRequired, + selectedCount: PropTypes.number.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + isDeleting: PropTypes.bool.isRequired, + deleteError: PropTypes.object, + isOrganizingArtist: PropTypes.bool.isRequired, + isRetaggingArtist: PropTypes.bool.isRequired, + showMetadataProfile: PropTypes.bool.isRequired, + onSaveSelected: PropTypes.func.isRequired, + onOrganizeArtistPress: PropTypes.func.isRequired, + onRetagArtistPress: PropTypes.func.isRequired +}; + +export default ArtistEditorFooter; diff --git a/frontend/src/Artist/Editor/ArtistEditorFooterLabel.css b/frontend/src/Artist/Editor/ArtistEditorFooterLabel.css new file mode 100644 index 000000000..9b4b40be6 --- /dev/null +++ b/frontend/src/Artist/Editor/ArtistEditorFooterLabel.css @@ -0,0 +1,8 @@ +.label { + margin-bottom: 3px; + font-weight: bold; +} + +.savingIcon { + margin-left: 8px; +} diff --git a/frontend/src/Artist/Editor/ArtistEditorFooterLabel.js b/frontend/src/Artist/Editor/ArtistEditorFooterLabel.js new file mode 100644 index 000000000..1c6be745d --- /dev/null +++ b/frontend/src/Artist/Editor/ArtistEditorFooterLabel.js @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons } from 'Helpers/Props'; +import SpinnerIcon from 'Components/SpinnerIcon'; +import styles from './ArtistEditorFooterLabel.css'; + +function ArtistEditorFooterLabel(props) { + const { + className, + label, + isSaving + } = props; + + return ( +
+ {label} + + { + isSaving && + + } +
+ ); +} + +ArtistEditorFooterLabel.propTypes = { + className: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + isSaving: PropTypes.bool.isRequired +}; + +ArtistEditorFooterLabel.defaultProps = { + className: styles.label +}; + +export default ArtistEditorFooterLabel; diff --git a/frontend/src/Artist/Editor/ArtistEditorRow.css b/frontend/src/Artist/Editor/ArtistEditorRow.css new file mode 100644 index 000000000..aeb9776ca --- /dev/null +++ b/frontend/src/Artist/Editor/ArtistEditorRow.css @@ -0,0 +1,5 @@ +.albumFolder { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 150px; +} diff --git a/frontend/src/Artist/Editor/ArtistEditorRow.js b/frontend/src/Artist/Editor/ArtistEditorRow.js new file mode 100644 index 000000000..cfead73be --- /dev/null +++ b/frontend/src/Artist/Editor/ArtistEditorRow.js @@ -0,0 +1,120 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import TagListConnector from 'Components/TagListConnector'; +import CheckInput from 'Components/Form/CheckInput'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import ArtistNameLink from 'Artist/ArtistNameLink'; +import ArtistStatusCell from 'Artist/Index/Table/ArtistStatusCell'; +import styles from './ArtistEditorRow.css'; + +class ArtistEditorRow extends Component { + + // + // Listeners + + onAlbumFolderChange = () => { + // Mock handler to satisfy `onChange` being required for `CheckInput`. + // + } + + // + // Render + + render() { + const { + id, + status, + foreignArtistId, + artistName, + artistType, + monitored, + metadataProfile, + qualityProfile, + albumFolder, + path, + tags, + columns, + isSelected, + onSelectedChange + } = this.props; + + return ( + + + + + + + + + + + {qualityProfile.name} + + + { + _.find(columns, { name: 'metadataProfileId' }).isVisible && + + {metadataProfile.name} + + } + + + + + + + {path} + + + + + + + ); + } +} + +ArtistEditorRow.propTypes = { + id: PropTypes.number.isRequired, + status: PropTypes.string.isRequired, + foreignArtistId: PropTypes.string.isRequired, + artistName: PropTypes.string.isRequired, + artistType: PropTypes.string, + monitored: PropTypes.bool.isRequired, + metadataProfile: PropTypes.object.isRequired, + qualityProfile: PropTypes.object.isRequired, + albumFolder: PropTypes.bool.isRequired, + path: PropTypes.string.isRequired, + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + isSelected: PropTypes.bool, + onSelectedChange: PropTypes.func.isRequired +}; + +ArtistEditorRow.defaultProps = { + tags: [] +}; + +export default ArtistEditorRow; diff --git a/frontend/src/Artist/Editor/ArtistEditorRowConnector.js b/frontend/src/Artist/Editor/ArtistEditorRowConnector.js new file mode 100644 index 000000000..32694a6b9 --- /dev/null +++ b/frontend/src/Artist/Editor/ArtistEditorRowConnector.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createMetadataProfileSelector from 'Store/Selectors/createMetadataProfileSelector'; +import createQualityProfileSelector from 'Store/Selectors/createQualityProfileSelector'; +import ArtistEditorRow from './ArtistEditorRow'; + +function createMapStateToProps() { + return createSelector( + createMetadataProfileSelector(), + createQualityProfileSelector(), + (metadataProfile, qualityProfile) => { + return { + metadataProfile, + qualityProfile + }; + } + ); +} + +function ArtistEditorRowConnector(props) { + return ( + + ); +} + +ArtistEditorRowConnector.propTypes = { + qualityProfileId: PropTypes.number.isRequired +}; + +export default connect(createMapStateToProps)(ArtistEditorRowConnector); diff --git a/frontend/src/Artist/Editor/AudioTags/RetagArtistModal.js b/frontend/src/Artist/Editor/AudioTags/RetagArtistModal.js new file mode 100644 index 000000000..636ca6618 --- /dev/null +++ b/frontend/src/Artist/Editor/AudioTags/RetagArtistModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import RetagArtistModalContentConnector from './RetagArtistModalContentConnector'; + +function RetagArtistModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +RetagArtistModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default RetagArtistModal; diff --git a/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContent.css b/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContent.css new file mode 100644 index 000000000..02c52edc8 --- /dev/null +++ b/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContent.css @@ -0,0 +1,8 @@ +.retagIcon { + margin-left: 5px; +} + +.message { + margin-top: 20px; + margin-bottom: 10px; +} diff --git a/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContent.js b/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContent.js new file mode 100644 index 000000000..015112556 --- /dev/null +++ b/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContent.js @@ -0,0 +1,73 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import Icon from 'Components/Icon'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './RetagArtistModalContent.css'; + +function RetagArtistModalContent(props) { + const { + artistNames, + onModalClose, + onRetagArtistPress + } = props; + + return ( + + + Retag Selected Artist + + + + + Tip: To preview the tags that will be written... select "Cancel" then click any artist name and use the + + + +
+ Are you sure you want to re-tag all files in the {artistNames.length} selected artist? +
+
    + { + artistNames.map((artistName) => { + return ( +
  • + {artistName} +
  • + ); + }) + } +
+
+ + + + + + +
+ ); +} + +RetagArtistModalContent.propTypes = { + artistNames: PropTypes.arrayOf(PropTypes.string).isRequired, + onModalClose: PropTypes.func.isRequired, + onRetagArtistPress: PropTypes.func.isRequired +}; + +export default RetagArtistModalContent; diff --git a/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContentConnector.js b/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContentConnector.js new file mode 100644 index 000000000..1c104db00 --- /dev/null +++ b/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContentConnector.js @@ -0,0 +1,67 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import RetagArtistModalContent from './RetagArtistModalContent'; + +function createMapStateToProps() { + return createSelector( + (state, { artistIds }) => artistIds, + createAllArtistSelector(), + (artistIds, allArtists) => { + const artist = _.intersectionWith(allArtists, artistIds, (s, id) => { + return s.id === id; + }); + + const sortedArtist = _.orderBy(artist, 'sortName'); + const artistNames = _.map(sortedArtist, 'artistName'); + + return { + artistNames + }; + } + ); +} + +const mapDispatchToProps = { + executeCommand +}; + +class RetagArtistModalContentConnector extends Component { + + // + // Listeners + + onRetagArtistPress = () => { + this.props.executeCommand({ + name: commandNames.RETAG_ARTIST, + artistIds: this.props.artistIds + }); + + this.props.onModalClose(true); + } + + // + // Render + + render(props) { + return ( + + ); + } +} + +RetagArtistModalContentConnector.propTypes = { + artistIds: PropTypes.arrayOf(PropTypes.number).isRequired, + onModalClose: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(RetagArtistModalContentConnector); diff --git a/frontend/src/Artist/Editor/Delete/DeleteArtistModal.js b/frontend/src/Artist/Editor/Delete/DeleteArtistModal.js new file mode 100644 index 000000000..11fd79d5d --- /dev/null +++ b/frontend/src/Artist/Editor/Delete/DeleteArtistModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import DeleteArtistModalContentConnector from './DeleteArtistModalContentConnector'; + +function DeleteArtistModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +DeleteArtistModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default DeleteArtistModal; diff --git a/frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.css b/frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.css new file mode 100644 index 000000000..950fdc27d --- /dev/null +++ b/frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.css @@ -0,0 +1,13 @@ +.message { + margin-top: 20px; + margin-bottom: 10px; +} + +.pathContainer { + margin-left: 5px; +} + +.path { + margin-left: 5px; + color: $dangerColor; +} diff --git a/frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.js b/frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.js new file mode 100644 index 000000000..87088b472 --- /dev/null +++ b/frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.js @@ -0,0 +1,123 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './DeleteArtistModalContent.css'; + +class DeleteArtistModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + deleteFiles: false + }; + } + + // + // Listeners + + onDeleteFilesChange = ({ value }) => { + this.setState({ deleteFiles: value }); + } + + onDeleteArtistConfirmed = () => { + const deleteFiles = this.state.deleteFiles; + + this.setState({ deleteFiles: false }); + this.props.onDeleteSelectedPress(deleteFiles); + } + + // + // Render + + render() { + const { + artist, + onModalClose + } = this.props; + const deleteFiles = this.state.deleteFiles; + + return ( + + + Delete Selected Artist + + + +
+ + {`Delete Artist Folder${artist.length > 1 ? 's' : ''}`} + + 1 ? 's' : ''} and all contents`} + kind={kinds.DANGER} + onChange={this.onDeleteFilesChange} + /> + +
+ +
+ {`Are you sure you want to delete ${artist.length} selected artist${artist.length > 1 ? 's' : ''}${deleteFiles ? ' and all contents' : ''}?`} +
+ +
    + { + artist.map((s) => { + return ( +
  • + {s.artistName} + + { + deleteFiles && + + - + + {s.path} + + + } +
  • + ); + }) + } +
+
+ + + + + + +
+ ); + } +} + +DeleteArtistModalContent.propTypes = { + artist: PropTypes.arrayOf(PropTypes.object).isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteSelectedPress: PropTypes.func.isRequired +}; + +export default DeleteArtistModalContent; diff --git a/frontend/src/Artist/Editor/Delete/DeleteArtistModalContentConnector.js b/frontend/src/Artist/Editor/Delete/DeleteArtistModalContentConnector.js new file mode 100644 index 000000000..8c61976e8 --- /dev/null +++ b/frontend/src/Artist/Editor/Delete/DeleteArtistModalContentConnector.js @@ -0,0 +1,45 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; +import { bulkDeleteArtist } from 'Store/Actions/artistEditorActions'; +import DeleteArtistModalContent from './DeleteArtistModalContent'; + +function createMapStateToProps() { + return createSelector( + (state, { artistIds }) => artistIds, + createAllArtistSelector(), + (artistIds, allArtists) => { + const selectedArtist = _.intersectionWith(allArtists, artistIds, (s, id) => { + return s.id === id; + }); + + const sortedArtist = _.orderBy(selectedArtist, 'sortName'); + const artist = _.map(sortedArtist, (s) => { + return { + artistName: s.artistName, + path: s.path + }; + }); + + return { + artist + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onDeleteSelectedPress(deleteFiles) { + dispatch(bulkDeleteArtist({ + artistIds: props.artistIds, + deleteFiles + })); + + props.onModalClose(); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(DeleteArtistModalContent); diff --git a/frontend/src/Artist/Editor/Organize/OrganizeArtistModal.js b/frontend/src/Artist/Editor/Organize/OrganizeArtistModal.js new file mode 100644 index 000000000..412396355 --- /dev/null +++ b/frontend/src/Artist/Editor/Organize/OrganizeArtistModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import OrganizeArtistModalContentConnector from './OrganizeArtistModalContentConnector'; + +function OrganizeArtistModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +OrganizeArtistModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default OrganizeArtistModal; diff --git a/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.css b/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.css new file mode 100644 index 000000000..0b896f4ef --- /dev/null +++ b/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.css @@ -0,0 +1,8 @@ +.renameIcon { + margin-left: 5px; +} + +.message { + margin-top: 20px; + margin-bottom: 10px; +} diff --git a/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.js b/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.js new file mode 100644 index 000000000..5f90eca90 --- /dev/null +++ b/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.js @@ -0,0 +1,74 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import Icon from 'Components/Icon'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './OrganizeArtistModalContent.css'; + +function OrganizeArtistModalContent(props) { + const { + artistNames, + onModalClose, + onOrganizeArtistPress + } = props; + + return ( + + + Organize Selected Artist + + + + + Tip: To preview a rename... select "Cancel" then click any artist name and use the + + + +
+ Are you sure you want to organize all files in the {artistNames.length} selected artist? +
+ +
    + { + artistNames.map((artistName) => { + return ( +
  • + {artistName} +
  • + ); + }) + } +
+
+ + + + + + +
+ ); +} + +OrganizeArtistModalContent.propTypes = { + artistNames: PropTypes.arrayOf(PropTypes.string).isRequired, + onModalClose: PropTypes.func.isRequired, + onOrganizeArtistPress: PropTypes.func.isRequired +}; + +export default OrganizeArtistModalContent; diff --git a/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContentConnector.js b/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContentConnector.js new file mode 100644 index 000000000..6be1eb961 --- /dev/null +++ b/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContentConnector.js @@ -0,0 +1,67 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import OrganizeArtistModalContent from './OrganizeArtistModalContent'; + +function createMapStateToProps() { + return createSelector( + (state, { artistIds }) => artistIds, + createAllArtistSelector(), + (artistIds, allArtists) => { + const artist = _.intersectionWith(allArtists, artistIds, (s, id) => { + return s.id === id; + }); + + const sortedArtist = _.orderBy(artist, 'sortName'); + const artistNames = _.map(sortedArtist, 'artistName'); + + return { + artistNames + }; + } + ); +} + +const mapDispatchToProps = { + executeCommand +}; + +class OrganizeArtistModalContentConnector extends Component { + + // + // Listeners + + onOrganizeArtistPress = () => { + this.props.executeCommand({ + name: commandNames.RENAME_ARTIST, + artistIds: this.props.artistIds + }); + + this.props.onModalClose(true); + } + + // + // Render + + render(props) { + return ( + + ); + } +} + +OrganizeArtistModalContentConnector.propTypes = { + artistIds: PropTypes.arrayOf(PropTypes.number).isRequired, + onModalClose: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(OrganizeArtistModalContentConnector); diff --git a/frontend/src/Artist/Editor/Tags/TagsModal.js b/frontend/src/Artist/Editor/Tags/TagsModal.js new file mode 100644 index 000000000..0f6c2d7ec --- /dev/null +++ b/frontend/src/Artist/Editor/Tags/TagsModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import TagsModalContentConnector from './TagsModalContentConnector'; + +function TagsModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +TagsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default TagsModal; diff --git a/frontend/src/Artist/Editor/Tags/TagsModalContent.css b/frontend/src/Artist/Editor/Tags/TagsModalContent.css new file mode 100644 index 000000000..63be9aadd --- /dev/null +++ b/frontend/src/Artist/Editor/Tags/TagsModalContent.css @@ -0,0 +1,12 @@ +.renameIcon { + margin-left: 5px; +} + +.message { + margin-top: 20px; + margin-bottom: 10px; +} + +.result { + padding-top: 4px; +} diff --git a/frontend/src/Artist/Editor/Tags/TagsModalContent.js b/frontend/src/Artist/Editor/Tags/TagsModalContent.js new file mode 100644 index 000000000..b982fee0e --- /dev/null +++ b/frontend/src/Artist/Editor/Tags/TagsModalContent.js @@ -0,0 +1,187 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import Label from 'Components/Label'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import styles from './TagsModalContent.css'; + +class TagsModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + tags: [], + applyTags: 'add' + }; + } + + // + // Lifecycle + + onInputChange = ({ name, value }) => { + this.setState({ [name]: value }); + } + + onApplyTagsPress = () => { + const { + tags, + applyTags + } = this.state; + + this.props.onApplyTagsPress(tags, applyTags); + } + + // + // Render + + render() { + const { + artistTags, + tagList, + onModalClose + } = this.props; + + const { + tags, + applyTags + } = this.state; + + const applyTagsOptions = [ + { key: 'add', value: 'Add' }, + { key: 'remove', value: 'Remove' }, + { key: 'replace', value: 'Replace' } + ]; + + return ( + + + Tags + + + +
+ + Tags + + + + + + Apply Tags + + + + + + Result + +
+ { + artistTags.map((t) => { + const tag = _.find(tagList, { id: t }); + + if (!tag) { + return null; + } + + const removeTag = (applyTags === 'remove' && tags.indexOf(t) > -1) || + (applyTags === 'replace' && tags.indexOf(t) === -1); + + return ( + + ); + }) + } + + { + (applyTags === 'add' || applyTags === 'replace') && + tags.map((t) => { + const tag = _.find(tagList, { id: t }); + + if (!tag) { + return null; + } + + if (artistTags.indexOf(t) > -1) { + return null; + } + + return ( + + ); + }) + } +
+
+
+
+ + + + + + +
+ ); + } +} + +TagsModalContent.propTypes = { + artistTags: PropTypes.arrayOf(PropTypes.number).isRequired, + tagList: PropTypes.arrayOf(PropTypes.object).isRequired, + onModalClose: PropTypes.func.isRequired, + onApplyTagsPress: PropTypes.func.isRequired +}; + +export default TagsModalContent; diff --git a/frontend/src/Artist/Editor/Tags/TagsModalContentConnector.js b/frontend/src/Artist/Editor/Tags/TagsModalContentConnector.js new file mode 100644 index 000000000..6741e8b5c --- /dev/null +++ b/frontend/src/Artist/Editor/Tags/TagsModalContentConnector.js @@ -0,0 +1,36 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import TagsModalContent from './TagsModalContent'; + +function createMapStateToProps() { + return createSelector( + (state, { artistIds }) => artistIds, + createAllArtistSelector(), + createTagsSelector(), + (artistIds, allArtists, tagList) => { + const artist = _.intersectionWith(allArtists, artistIds, (s, id) => { + return s.id === id; + }); + + const artistTags = _.uniq(_.concat(..._.map(artist, 'tags'))); + + return { + artistTags, + tagList + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onAction() { + // Do something + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(TagsModalContent); diff --git a/frontend/src/Artist/History/ArtistHistoryModal.js b/frontend/src/Artist/History/ArtistHistoryModal.js new file mode 100644 index 000000000..7139d7633 --- /dev/null +++ b/frontend/src/Artist/History/ArtistHistoryModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ArtistHistoryModalContentConnector from './ArtistHistoryModalContentConnector'; + +function ArtistHistoryModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +ArtistHistoryModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default ArtistHistoryModal; diff --git a/frontend/src/Artist/History/ArtistHistoryModalContent.js b/frontend/src/Artist/History/ArtistHistoryModalContent.js new file mode 100644 index 000000000..9be74ba40 --- /dev/null +++ b/frontend/src/Artist/History/ArtistHistoryModalContent.js @@ -0,0 +1,132 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import ArtistHistoryRowConnector from './ArtistHistoryRowConnector'; + +const columns = [ + { + name: 'eventType', + isVisible: true + }, + { + name: 'album', + label: 'Album', + isVisible: true + }, + { + name: 'sourceTitle', + label: 'Source Title', + isVisible: true + }, + { + name: 'quality', + label: 'Quality', + isVisible: true + }, + { + name: 'date', + label: 'Date', + isVisible: true + }, + { + name: 'details', + label: 'Details', + isVisible: true + }, + { + name: 'actions', + label: 'Actions', + isVisible: true + } +]; + +class ArtistHistoryModalContent extends Component { + + // + // Render + + render() { + const { + albumId, + isFetching, + isPopulated, + error, + items, + onMarkAsFailedPress, + onModalClose + } = this.props; + + const fullArtist = albumId == null; + const hasItems = !!items.length; + + return ( + + + History + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to load history.
+ } + + { + isPopulated && !hasItems && !error && +
No history.
+ } + + { + isPopulated && hasItems && !error && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ } +
+ + + + +
+ ); + } +} + +ArtistHistoryModalContent.propTypes = { + albumId: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onMarkAsFailedPress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default ArtistHistoryModalContent; diff --git a/frontend/src/Artist/History/ArtistHistoryModalContentConnector.js b/frontend/src/Artist/History/ArtistHistoryModalContentConnector.js new file mode 100644 index 000000000..a989361f5 --- /dev/null +++ b/frontend/src/Artist/History/ArtistHistoryModalContentConnector.js @@ -0,0 +1,81 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchArtistHistory, clearArtistHistory, artistHistoryMarkAsFailed } from 'Store/Actions/artistHistoryActions'; +import ArtistHistoryModalContent from './ArtistHistoryModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.artistHistory, + (artistHistory) => { + return artistHistory; + } + ); +} + +const mapDispatchToProps = { + fetchArtistHistory, + clearArtistHistory, + artistHistoryMarkAsFailed +}; + +class ArtistHistoryModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + artistId, + albumId + } = this.props; + + this.props.fetchArtistHistory({ + artistId, + albumId + }); + } + + componentWillUnmount() { + this.props.clearArtistHistory(); + } + + // + // Listeners + + onMarkAsFailedPress = (historyId) => { + const { + artistId, + albumId + } = this.props; + + this.props.artistHistoryMarkAsFailed({ + historyId, + artistId, + albumId + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ArtistHistoryModalContentConnector.propTypes = { + artistId: PropTypes.number.isRequired, + albumId: PropTypes.number, + fetchArtistHistory: PropTypes.func.isRequired, + clearArtistHistory: PropTypes.func.isRequired, + artistHistoryMarkAsFailed: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ArtistHistoryModalContentConnector); diff --git a/frontend/src/Artist/History/ArtistHistoryRow.css b/frontend/src/Artist/History/ArtistHistoryRow.css new file mode 100644 index 000000000..deafecb81 --- /dev/null +++ b/frontend/src/Artist/History/ArtistHistoryRow.css @@ -0,0 +1,6 @@ +.details, +.actions { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 65px; +} diff --git a/frontend/src/Artist/History/ArtistHistoryRow.js b/frontend/src/Artist/History/ArtistHistoryRow.js new file mode 100644 index 000000000..e69f8395b --- /dev/null +++ b/frontend/src/Artist/History/ArtistHistoryRow.js @@ -0,0 +1,170 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import Popover from 'Components/Tooltip/Popover'; +import TrackQuality from 'Album/TrackQuality'; +import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector'; +import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell'; +import styles from './ArtistHistoryRow.css'; + +function getTitle(eventType) { + switch (eventType) { + case 'grabbed': + return 'Grabbed'; + case 'downloadImported': + return 'Download Completed'; + case 'trackFileImported': + return 'Track Imported'; + case 'downloadFailed': + return 'Download Failed'; + case 'trackFileDeleted': + return 'Track File Deleted'; + case 'trackFileRenamed': + return 'Track File Renamed'; + case 'trackFileRetagged': + return 'Track File Tags Updated'; + case 'albumImportIncomplete': + return 'Album Import Incomplete'; + default: + return 'Unknown'; + } +} + +class ArtistHistoryRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isMarkAsFailedModalOpen: false + }; + } + + // + // Listeners + + onMarkAsFailedPress = () => { + this.setState({ isMarkAsFailedModalOpen: true }); + } + + onConfirmMarkAsFailed = () => { + this.props.onMarkAsFailedPress(this.props.id); + this.setState({ isMarkAsFailedModalOpen: false }); + } + + onMarkAsFailedModalClose = () => { + this.setState({ isMarkAsFailedModalOpen: false }); + } + + // + // Render + + render() { + const { + eventType, + sourceTitle, + quality, + qualityCutoffNotMet, + date, + data, + album + } = this.props; + + const { + isMarkAsFailedModalOpen + } = this.state; + + return ( + + + + + {album.title} + + + + {sourceTitle} + + + + + + + + + + + } + title={getTitle(eventType)} + body={ + + } + position={tooltipPositions.LEFT} + /> + + + + { + eventType === 'grabbed' && + + } + + + + + ); + } +} + +ArtistHistoryRow.propTypes = { + id: PropTypes.number.isRequired, + eventType: PropTypes.string.isRequired, + sourceTitle: PropTypes.string.isRequired, + quality: PropTypes.object.isRequired, + qualityCutoffNotMet: PropTypes.bool.isRequired, + date: PropTypes.string.isRequired, + data: PropTypes.object.isRequired, + fullArtist: PropTypes.bool.isRequired, + artist: PropTypes.object.isRequired, + album: PropTypes.object.isRequired, + onMarkAsFailedPress: PropTypes.func.isRequired +}; + +export default ArtistHistoryRow; diff --git a/frontend/src/Artist/History/ArtistHistoryRowConnector.js b/frontend/src/Artist/History/ArtistHistoryRowConnector.js new file mode 100644 index 000000000..2bcfc7cb6 --- /dev/null +++ b/frontend/src/Artist/History/ArtistHistoryRowConnector.js @@ -0,0 +1,26 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions'; +import createArtistSelector from 'Store/Selectors/createArtistSelector'; +import createAlbumSelector from 'Store/Selectors/createAlbumSelector'; +import ArtistHistoryRow from './ArtistHistoryRow'; + +function createMapStateToProps() { + return createSelector( + createArtistSelector(), + createAlbumSelector(), + (artist, album) => { + return { + artist, + album + }; + } + ); +} + +const mapDispatchToProps = { + fetchHistory, + markAsFailed +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ArtistHistoryRow); diff --git a/frontend/src/Artist/Index/ArtistIndex.css b/frontend/src/Artist/Index/ArtistIndex.css new file mode 100644 index 000000000..43b445c3c --- /dev/null +++ b/frontend/src/Artist/Index/ArtistIndex.css @@ -0,0 +1,72 @@ +.pageContentBodyWrapper { + display: flex; + flex: 1 0 1px; + overflow: hidden; +} + +.errorMessage { + margin-top: 20px; + text-align: center; + font-size: 20px; +} + +.contentBody { + composes: contentBody from '~Components/Page/PageContentBody.css'; + + display: flex; + flex-direction: column; +} + +.postersInnerContentBody { + composes: innerContentBody from '~Components/Page/PageContentBody.css'; + + display: flex; + flex-direction: column; + flex-grow: 1; + + /* 5px less padding than normal to handle poster's 5px margin */ + padding: calc($pageContentBodyPadding - 5px); +} + +.bannersInnerContentBody { + composes: innerContentBody from '~Components/Page/PageContentBody.css'; + + display: flex; + flex-direction: column; + flex-grow: 1; + + /* 5px less padding than normal to handle poster's 5px margin */ + padding: calc($pageContentBodyPadding - 5px); +} + +.tableInnerContentBody { + composes: innerContentBody from '~Components/Page/PageContentBody.css'; + + display: flex; + flex-direction: column; + flex-grow: 1; +} + +.contentBodyContainer { + display: flex; + flex-direction: column; + flex-grow: 1; +} + +@media only screen and (max-width: $breakpointSmall) { + .pageContentBodyWrapper { + flex-basis: auto; + } + + .contentBody { + flex-basis: 1px; + } + + .postersInnerContentBody { + padding: calc($pageContentBodyPaddingSmallScreen - 5px); + } + + .bannersInnerContentBody { + padding: calc($pageContentBodyPaddingSmallScreen - 5px); + } +} diff --git a/frontend/src/Artist/Index/ArtistIndex.js b/frontend/src/Artist/Index/ArtistIndex.js new file mode 100644 index 000000000..f88ffda52 --- /dev/null +++ b/frontend/src/Artist/Index/ArtistIndex.js @@ -0,0 +1,429 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import { align, icons, sortDirections } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageJumpBar from 'Components/Page/PageJumpBar'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import NoArtist from 'Artist/NoArtist'; +import ArtistIndexTableConnector from './Table/ArtistIndexTableConnector'; +import ArtistIndexTableOptionsConnector from './Table/ArtistIndexTableOptionsConnector'; +import ArtistIndexPosterOptionsModal from './Posters/Options/ArtistIndexPosterOptionsModal'; +import ArtistIndexPostersConnector from './Posters/ArtistIndexPostersConnector'; +import ArtistIndexBannerOptionsModal from './Banners/Options/ArtistIndexBannerOptionsModal'; +import ArtistIndexBannersConnector from './Banners/ArtistIndexBannersConnector'; +import ArtistIndexOverviewOptionsModal from './Overview/Options/ArtistIndexOverviewOptionsModal'; +import ArtistIndexOverviewsConnector from './Overview/ArtistIndexOverviewsConnector'; +import ArtistIndexFooterConnector from './ArtistIndexFooterConnector'; +import ArtistIndexFilterMenu from './Menus/ArtistIndexFilterMenu'; +import ArtistIndexSortMenu from './Menus/ArtistIndexSortMenu'; +import ArtistIndexViewMenu from './Menus/ArtistIndexViewMenu'; +import styles from './ArtistIndex.css'; + +function getViewComponent(view) { + if (view === 'posters') { + return ArtistIndexPostersConnector; + } + + if (view === 'banners') { + return ArtistIndexBannersConnector; + } + + if (view === 'overview') { + return ArtistIndexOverviewsConnector; + } + + return ArtistIndexTableConnector; +} + +class ArtistIndex extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + contentBody: null, + jumpBarItems: [], + jumpToCharacter: null, + isPosterOptionsModalOpen: false, + isBannerOptionsModalOpen: false, + isOverviewOptionsModalOpen: false, + isRendered: false + }; + } + + componentDidMount() { + this.setJumpBarItems(); + } + + componentDidUpdate(prevProps) { + const { + items, + sortKey, + sortDirection, + scrollTop + } = this.props; + + if ( + hasDifferentItems(prevProps.items, items) || + sortKey !== prevProps.sortKey || + sortDirection !== prevProps.sortDirection + ) { + this.setJumpBarItems(); + } + + if (this.state.jumpToCharacter != null && scrollTop !== prevProps.scrollTop) { + this.setState({ jumpToCharacter: null }); + } + } + + // + // Control + + setContentBodyRef = (ref) => { + this.setState({ contentBody: ref }); + } + + setJumpBarItems() { + const { + items, + sortKey, + sortDirection + } = this.props; + + // Reset if not sorting by sortName + if (sortKey !== 'sortName') { + this.setState({ jumpBarItems: [] }); + return; + } + + const characters = _.reduce(items, (acc, item) => { + const firstCharacter = item.sortName.charAt(0); + + if (isNaN(firstCharacter)) { + acc.push(firstCharacter); + } else { + acc.push('#'); + } + + return acc; + }, []).sort(); + + // Reverse if sorting descending + if (sortDirection === sortDirections.DESCENDING) { + characters.reverse(); + } + + this.setState({ jumpBarItems: _.sortedUniq(characters) }); + } + + // + // Listeners + + onPosterOptionsPress = () => { + this.setState({ isPosterOptionsModalOpen: true }); + } + + onPosterOptionsModalClose = () => { + this.setState({ isPosterOptionsModalOpen: false }); + } + + onBannerOptionsPress = () => { + this.setState({ isBannerOptionsModalOpen: true }); + } + + onBannerOptionsModalClose = () => { + this.setState({ isBannerOptionsModalOpen: false }); + } + + onOverviewOptionsPress = () => { + this.setState({ isOverviewOptionsModalOpen: true }); + } + + onOverviewOptionsModalClose = () => { + this.setState({ isOverviewOptionsModalOpen: false }); + } + + onJumpBarItemPress = (jumpToCharacter) => { + this.setState({ jumpToCharacter }); + } + + onRender = () => { + this.setState({ isRendered: true }, () => { + const { + scrollTop, + isSmallScreen + } = this.props; + + if (isSmallScreen) { + // Seems to result in the view being off by 125px (distance to the top of the page) + // document.documentElement.scrollTop = document.body.scrollTop = scrollTop; + + // This works, but then jumps another 1px after scrolling + document.documentElement.scrollTop = scrollTop; + } + }); + } + + onScroll = ({ scrollTop }) => { + this.props.onScroll({ scrollTop }); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + totalItems, + items, + columns, + selectedFilterKey, + filters, + customFilters, + sortKey, + sortDirection, + view, + isRefreshingArtist, + isRssSyncExecuting, + scrollTop, + onSortSelect, + onFilterSelect, + onViewSelect, + onRefreshArtistPress, + onRssSyncPress, + ...otherProps + } = this.props; + + const { + contentBody, + jumpBarItems, + jumpToCharacter, + isPosterOptionsModalOpen, + isBannerOptionsModalOpen, + isOverviewOptionsModalOpen, + isRendered + } = this.state; + + const ViewComponent = getViewComponent(view); + const isLoaded = !!(!error && isPopulated && items.length && contentBody); + const hasNoArtist = !totalItems; + + return ( + + + + + + + + + + + { + view === 'table' ? + + + : + null + } + + { + view === 'posters' ? + : + null + } + + { + view === 'banners' ? + : + null + } + + { + view === 'overview' ? + : + null + } + + { + (view === 'posters' || view === 'banners' || view === 'overview') && + + + } + + + + + + + + + +
+ + { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
+ {getErrorMessage(error, 'Failed to load artist from API')} +
+ } + + { + isLoaded && +
+ + + +
+ } + + { + !error && isPopulated && !items.length && + + } +
+ + { + isLoaded && !!jumpBarItems.length && + + } +
+ + + + + + +
+ ); + } +} + +ArtistIndex.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + totalItems: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + view: PropTypes.string.isRequired, + isRefreshingArtist: PropTypes.bool.isRequired, + isRssSyncExecuting: PropTypes.bool.isRequired, + scrollTop: PropTypes.number.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + onSortSelect: PropTypes.func.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onViewSelect: PropTypes.func.isRequired, + onRefreshArtistPress: PropTypes.func.isRequired, + onRssSyncPress: PropTypes.func.isRequired, + onScroll: PropTypes.func.isRequired +}; + +export default ArtistIndex; diff --git a/frontend/src/Artist/Index/ArtistIndexConnector.js b/frontend/src/Artist/Index/ArtistIndexConnector.js new file mode 100644 index 000000000..a9e0b7dcc --- /dev/null +++ b/frontend/src/Artist/Index/ArtistIndexConnector.js @@ -0,0 +1,162 @@ +/* eslint max-params: 0 */ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createArtistClientSideCollectionItemsSelector from 'Store/Selectors/createArtistClientSideCollectionItemsSelector'; +import dimensions from 'Styles/Variables/dimensions'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import scrollPositions from 'Store/scrollPositions'; +import { setArtistSort, setArtistFilter, setArtistView, setArtistTableOption } from 'Store/Actions/artistIndexActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import withScrollPosition from 'Components/withScrollPosition'; +import ArtistIndex from './ArtistIndex'; + +const POSTERS_PADDING = 15; +const POSTERS_PADDING_SMALL_SCREEN = 5; +const BANNERS_PADDING = 15; +const BANNERS_PADDING_SMALL_SCREEN = 5; +const TABLE_PADDING = parseInt(dimensions.pageContentBodyPadding); +const TABLE_PADDING_SMALL_SCREEN = parseInt(dimensions.pageContentBodyPaddingSmallScreen); + +// If the scrollTop is greater than zero it needs to be offset +// by the padding so when it is set initially so it is correct +// after React Virtualized takes the padding into account. + +function getScrollTop(view, scrollTop, isSmallScreen) { + if (scrollTop === 0) { + return 0; + } + + let padding = isSmallScreen ? TABLE_PADDING_SMALL_SCREEN : TABLE_PADDING; + + if (view === 'posters') { + padding = isSmallScreen ? POSTERS_PADDING_SMALL_SCREEN : POSTERS_PADDING; + } + + if (view === 'banners') { + padding = isSmallScreen ? BANNERS_PADDING_SMALL_SCREEN : BANNERS_PADDING; + } + + return scrollTop + padding; +} + +function createMapStateToProps() { + return createSelector( + createArtistClientSideCollectionItemsSelector('artistIndex'), + createCommandExecutingSelector(commandNames.REFRESH_ARTIST), + createCommandExecutingSelector(commandNames.RSS_SYNC), + createDimensionsSelector(), + ( + artist, + isRefreshingArtist, + isRssSyncExecuting, + dimensionsState + ) => { + return { + ...artist, + isRefreshingArtist, + isRssSyncExecuting, + isSmallScreen: dimensionsState.isSmallScreen + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onTableOptionChange(payload) { + dispatch(setArtistTableOption(payload)); + }, + + onSortSelect(sortKey) { + dispatch(setArtistSort({ sortKey })); + }, + + onFilterSelect(selectedFilterKey) { + dispatch(setArtistFilter({ selectedFilterKey })); + }, + + dispatchSetArtistView(view) { + dispatch(setArtistView({ view })); + }, + + onRefreshArtistPress() { + dispatch(executeCommand({ + name: commandNames.REFRESH_ARTIST + })); + }, + + onRssSyncPress() { + dispatch(executeCommand({ + name: commandNames.RSS_SYNC + })); + } + }; +} + +class ArtistIndexConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + view, + scrollTop, + isSmallScreen + } = props; + + this.state = { + scrollTop: getScrollTop(view, scrollTop, isSmallScreen) + }; + } + + // + // Listeners + + onViewSelect = (view) => { + // Reset the scroll position before changing the view + this.setState({ scrollTop: 0 }, () => { + this.props.dispatchSetArtistView(view); + }); + } + + onScroll = ({ scrollTop }) => { + this.setState({ + scrollTop + }, () => { + scrollPositions.artistIndex = scrollTop; + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ArtistIndexConnector.propTypes = { + isSmallScreen: PropTypes.bool.isRequired, + view: PropTypes.string.isRequired, + scrollTop: PropTypes.number.isRequired, + dispatchSetArtistView: PropTypes.func.isRequired +}; + +export default withScrollPosition( + connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexConnector), + 'artistIndex' +); diff --git a/frontend/src/Artist/Index/ArtistIndexFilterModalConnector.js b/frontend/src/Artist/Index/ArtistIndexFilterModalConnector.js new file mode 100644 index 000000000..412f3df34 --- /dev/null +++ b/frontend/src/Artist/Index/ArtistIndexFilterModalConnector.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setArtistFilter } from 'Store/Actions/artistIndexActions'; +import FilterModal from 'Components/Filter/FilterModal'; + +function createMapStateToProps() { + return createSelector( + (state) => state.artist.items, + (state) => state.artistIndex.filterBuilderProps, + (sectionItems, filterBuilderProps) => { + return { + sectionItems, + filterBuilderProps, + customFilterType: 'artistIndex' + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetFilter: setArtistFilter +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal); diff --git a/frontend/src/Artist/Index/ArtistIndexFooter.css b/frontend/src/Artist/Index/ArtistIndexFooter.css new file mode 100644 index 000000000..71d0439b6 --- /dev/null +++ b/frontend/src/Artist/Index/ArtistIndexFooter.css @@ -0,0 +1,74 @@ +.footer { + display: flex; + flex-wrap: wrap; + margin-top: 20px; + font-size: $smallFontSize; +} + +.legendItem { + display: flex; + margin-bottom: 4px; + line-height: 16px; +} + +.legendItemColor { + margin-right: 8px; + width: 30px; + height: 16px; + border-radius: 4px; +} + +.continuing { + composes: legendItemColor; + + background-color: $primaryColor; +} + +.ended { + composes: legendItemColor; + + background-color: $successColor; +} + +.missingMonitored { + composes: legendItemColor; + + background-color: $dangerColor; + + &:global(.colorImpaired) { + background: repeating-linear-gradient(90deg, color($dangerColor shade(5%)), color($dangerColor shade(5%)) 5px, color($dangerColor shade(15%)) 5px, color($dangerColor shade(15%)) 10px); + } +} + +.missingUnmonitored { + composes: legendItemColor; + + background-color: $warningColor; + + &:global(.colorImpaired) { + background: repeating-linear-gradient(45deg, $warningColor, $warningColor 5px, color($warningColor tint(15%)) 5px, color($warningColor tint(15%)) 10px); + } +} + +.statistics { + display: flex; + justify-content: space-between; + flex-wrap: wrap; +} + +@media (max-width: $breakpointLarge) { + .statistics { + display: block; + } +} + +@media (max-width: $breakpointSmall) { + .footer { + display: block; + } + + .statistics { + display: flex; + margin-top: 20px; + } +} diff --git a/frontend/src/Artist/Index/ArtistIndexFooter.js b/frontend/src/Artist/Index/ArtistIndexFooter.js new file mode 100644 index 000000000..245312ae6 --- /dev/null +++ b/frontend/src/Artist/Index/ArtistIndexFooter.js @@ -0,0 +1,158 @@ +import PropTypes from 'prop-types'; +import React, { PureComponent } from 'react'; +import classNames from 'classnames'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { ColorImpairedConsumer } from 'App/ColorImpairedContext'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import styles from './ArtistIndexFooter.css'; + +class ArtistIndexFooter extends PureComponent { + + // + // Render + + render() { + const { artist } = this.props; + const count = artist.length; + let tracks = 0; + let trackFiles = 0; + let ended = 0; + let continuing = 0; + let monitored = 0; + let totalFileSize = 0; + + artist.forEach((s) => { + const { statistics = {} } = s; + + const { + trackCount = 0, + trackFileCount = 0, + sizeOnDisk = 0 + } = statistics; + + tracks += trackCount; + trackFiles += trackFileCount; + + if (s.status === 'ended') { + ended++; + } else { + continuing++; + } + + if (s.monitored) { + monitored++; + } + + totalFileSize += sizeOnDisk; + }); + + return ( + + {(enableColorImpairedMode) => { + return ( +
+
+
+
+
Continuing (All tracks downloaded)
+
+ +
+
+
Ended (All tracks downloaded)
+
+ +
+
+
Missing Tracks (Artist monitored)
+
+ +
+
+
Missing Tracks (Artist not monitored)
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); + }} + + ); + } +} + +ArtistIndexFooter.propTypes = { + artist: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default ArtistIndexFooter; diff --git a/frontend/src/Artist/Index/ArtistIndexFooterConnector.js b/frontend/src/Artist/Index/ArtistIndexFooterConnector.js new file mode 100644 index 000000000..9d7afc298 --- /dev/null +++ b/frontend/src/Artist/Index/ArtistIndexFooterConnector.js @@ -0,0 +1,46 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import ArtistIndexFooter from './ArtistIndexFooter'; + +function createUnoptimizedSelector() { + return createSelector( + createClientSideCollectionSelector('artist', 'artistIndex'), + (artist) => { + return artist.items.map((s) => { + const { + monitored, + status, + statistics + } = s; + + return { + monitored, + status, + statistics + }; + }); + } + ); +} + +function createArtistSelector() { + return createDeepEqualSelector( + createUnoptimizedSelector(), + (artist) => artist + ); +} + +function createMapStateToProps() { + return createSelector( + createArtistSelector(), + (artist) => { + return { + artist + }; + } + ); +} + +export default connect(createMapStateToProps)(ArtistIndexFooter); diff --git a/frontend/src/Artist/Index/ArtistIndexItemConnector.js b/frontend/src/Artist/Index/ArtistIndexItemConnector.js new file mode 100644 index 000000000..aef6a8e5e --- /dev/null +++ b/frontend/src/Artist/Index/ArtistIndexItemConnector.js @@ -0,0 +1,141 @@ +/* eslint max-params: 0 */ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createArtistSelector from 'Store/Selectors/createArtistSelector'; +import createExecutingCommandsSelector from 'Store/Selectors/createExecutingCommandsSelector'; +import createArtistQualityProfileSelector from 'Store/Selectors/createArtistQualityProfileSelector'; +import createArtistMetadataProfileSelector from 'Store/Selectors/createArtistMetadataProfileSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; + +function selectShowSearchAction() { + return createSelector( + (state) => state.artistIndex, + (artistIndex) => { + const view = artistIndex.view; + + switch (view) { + case 'posters': + return artistIndex.posterOptions.showSearchAction; + case 'banners': + return artistIndex.bannerOptions.showSearchAction; + case 'overview': + return artistIndex.overviewOptions.showSearchAction; + default: + return artistIndex.tableOptions.showSearchAction; + } + } + ); +} + +function createMapStateToProps() { + return createSelector( + createArtistSelector(), + createArtistQualityProfileSelector(), + createArtistMetadataProfileSelector(), + selectShowSearchAction(), + createExecutingCommandsSelector(), + ( + artist, + qualityProfile, + metadataProfile, + showSearchAction, + executingCommands + ) => { + + // If an artist is deleted this selector may fire before the parent + // selectors, which will result in an undefined artist, if that happens + // we want to return early here and again in the render function to avoid + // trying to show an artist that has no information available. + + if (!artist) { + return {}; + } + + const isRefreshingArtist = executingCommands.some((command) => { + return ( + command.name === commandNames.REFRESH_ARTIST && + command.body.artistId === artist.id + ); + }); + + const isSearchingArtist = executingCommands.some((command) => { + return ( + command.name === commandNames.ARTIST_SEARCH && + command.body.artistId === artist.id + ); + }); + + const latestAlbum = _.maxBy(artist.albums, (album) => album.releaseDate); + + return { + ...artist, + qualityProfile, + metadataProfile, + latestAlbum, + showSearchAction, + isRefreshingArtist, + isSearchingArtist + }; + } + ); +} + +const mapDispatchToProps = { + dispatchExecuteCommand: executeCommand +}; + +class ArtistIndexItemConnector extends Component { + + // + // Listeners + + onRefreshArtistPress = () => { + this.props.dispatchExecuteCommand({ + name: commandNames.REFRESH_ARTIST, + artistId: this.props.id + }); + } + + onSearchPress = () => { + this.props.dispatchExecuteCommand({ + name: commandNames.ARTIST_SEARCH, + artistId: this.props.id + }); + } + + // + // Render + + render() { + const { + id, + component: ItemComponent, + ...otherProps + } = this.props; + + if (!id) { + return null; + } + + return ( + + ); + } +} + +ArtistIndexItemConnector.propTypes = { + id: PropTypes.number, + component: PropTypes.elementType.isRequired, + dispatchExecuteCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ArtistIndexItemConnector); diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css new file mode 100644 index 000000000..3f9bfdd8b --- /dev/null +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css @@ -0,0 +1,85 @@ +$hoverScale: 1.05; + +.container { + padding: 10px; +} + +.content { + transition: all 200ms ease-in; + + &:hover { + z-index: 2; + box-shadow: 0 0 12px $black; + transition: all 200ms ease-in; + + .controls { + opacity: 0.9; + transition: opacity 200ms linear 150ms; + } + } +} + +.bannerContainer { + position: relative; +} + +.link { + composes: link from '~Components/Link/Link.css'; + + display: block; + background-color: $defaultColor; +} + +.nextAiring { + background-color: #fafbfc; + text-align: center; + font-size: $smallFontSize; +} + +.title { + @add-mixin truncate; + + background-color: $defaultColor; + color: $white; + text-align: center; + font-size: $smallFontSize; +} + +.ended { + position: absolute; + top: 0; + right: 0; + width: 0; + height: 0; + border-width: 0 25px 25px 0; + border-style: solid; + border-color: transparent $dangerColor transparent transparent; + color: $white; +} + +.controls { + position: absolute; + bottom: 10px; + left: 10px; + z-index: 3; + border-radius: 4px; + background-color: #216044; + color: $white; + font-size: $smallFontSize; + opacity: 0; + transition: opacity 0; +} + +.action { + composes: button from '~Components/Link/IconButton.css'; + + &:hover { + color: #ccc; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .container { + padding: 5px; + } +} diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.js b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.js new file mode 100644 index 000000000..42883da51 --- /dev/null +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.js @@ -0,0 +1,272 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import Label from 'Components/Label'; +import Link from 'Components/Link/Link'; +import ArtistBanner from 'Artist/ArtistBanner'; +import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; +import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; +import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar'; +import ArtistIndexBannerInfo from './ArtistIndexBannerInfo'; +import styles from './ArtistIndexBanner.css'; + +class ArtistIndexBanner extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditArtistModalOpen: false, + isDeleteArtistModalOpen: false + }; + } + + // + // Listeners + + onEditArtistPress = () => { + this.setState({ isEditArtistModalOpen: true }); + } + + onEditArtistModalClose = () => { + this.setState({ isEditArtistModalOpen: false }); + } + + onDeleteArtistPress = () => { + this.setState({ + isEditArtistModalOpen: false, + isDeleteArtistModalOpen: true + }); + } + + onDeleteArtistModalClose = () => { + this.setState({ isDeleteArtistModalOpen: false }); + } + + // + // Render + + render() { + const { + style, + id, + artistName, + monitored, + status, + foreignArtistId, + nextAiring, + statistics, + images, + bannerWidth, + bannerHeight, + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile, + showSearchAction, + qualityProfile, + showRelativeDates, + shortDateFormat, + timeFormat, + isRefreshingArtist, + isSearchingArtist, + onRefreshArtistPress, + onSearchPress, + ...otherProps + } = this.props; + + const { + albumCount, + sizeOnDisk, + trackCount, + trackFileCount, + totalTrackCount + } = statistics; + + const { + isEditArtistModalOpen, + isDeleteArtistModalOpen + } = this.state; + + const link = `/artist/${foreignArtistId}`; + + const elementStyle = { + width: `${bannerWidth}px`, + height: `${bannerHeight}px` + }; + + return ( +
+
+
+ + + { + status === 'ended' && +
+ } + + + + +
+ + + + { + showTitle && +
+ {artistName} +
+ } + + { + showMonitored && +
+ {monitored ? 'Monitored' : 'Unmonitored'} +
+ } + + { + showQualityProfile && +
+ {qualityProfile.name} +
+ } + { + nextAiring && +
+ { + getRelativeDate( + nextAiring, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true + } + ) + } +
+ } + + + + + + +
+
+ ); + } +} + +ArtistIndexBanner.propTypes = { + style: PropTypes.object.isRequired, + id: PropTypes.number.isRequired, + artistName: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + status: PropTypes.string.isRequired, + foreignArtistId: PropTypes.string.isRequired, + nextAiring: PropTypes.string, + statistics: PropTypes.object.isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + bannerWidth: PropTypes.number.isRequired, + bannerHeight: PropTypes.number.isRequired, + detailedProgressBar: PropTypes.bool.isRequired, + showTitle: PropTypes.bool.isRequired, + showMonitored: PropTypes.bool.isRequired, + showQualityProfile: PropTypes.bool.isRequired, + qualityProfile: PropTypes.object.isRequired, + showSearchAction: PropTypes.bool.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + isRefreshingArtist: PropTypes.bool.isRequired, + isSearchingArtist: PropTypes.bool.isRequired, + onRefreshArtistPress: PropTypes.func.isRequired, + onSearchPress: PropTypes.func.isRequired +}; + +ArtistIndexBanner.defaultProps = { + statistics: { + albumCount: 0, + trackCount: 0, + trackFileCount: 0, + totalTrackCount: 0 + } +}; + +export default ArtistIndexBanner; diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.css b/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.css new file mode 100644 index 000000000..aab27d827 --- /dev/null +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.css @@ -0,0 +1,5 @@ +.info { + background-color: #fafbfc; + text-align: center; + font-size: $smallFontSize; +} diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.js b/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.js new file mode 100644 index 000000000..f641de0e1 --- /dev/null +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import formatBytes from 'Utilities/Number/formatBytes'; +import styles from './ArtistIndexBannerInfo.css'; + +function ArtistIndexBannerInfo(props) { + const { + qualityProfile, + showQualityProfile, + previousAiring, + added, + albumCount, + path, + sizeOnDisk, + sortKey, + showRelativeDates, + shortDateFormat, + timeFormat + } = props; + + if (sortKey === 'qualityProfileId' && !showQualityProfile) { + return ( +
+ {qualityProfile.name} +
+ ); + } + + if (sortKey === 'previousAiring' && previousAiring) { + return ( +
+ { + getRelativeDate( + previousAiring, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true + } + ) + } +
+ ); + } + + if (sortKey === 'added' && added) { + const addedDate = getRelativeDate( + added, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: false + } + ); + + return ( +
+ {`Added ${addedDate}`} +
+ ); + } + + if (sortKey === 'albumCount') { + let albums = '1 album'; + + if (albumCount === 0) { + albums = 'No albums'; + } else if (albumCount > 1) { + albums = `${albumCount} albums`; + } + + return ( +
+ {albums} +
+ ); + } + + if (sortKey === 'path') { + return ( +
+ {path} +
+ ); + } + + if (sortKey === 'sizeOnDisk') { + return ( +
+ {formatBytes(sizeOnDisk)} +
+ ); + } + + return null; +} + +ArtistIndexBannerInfo.propTypes = { + qualityProfile: PropTypes.object.isRequired, + showQualityProfile: PropTypes.bool.isRequired, + previousAiring: PropTypes.string, + added: PropTypes.string, + albumCount: PropTypes.number.isRequired, + path: PropTypes.string.isRequired, + sizeOnDisk: PropTypes.number, + sortKey: PropTypes.string.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired +}; + +export default ArtistIndexBannerInfo; diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanners.css b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.css new file mode 100644 index 000000000..9c6520fb5 --- /dev/null +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.css @@ -0,0 +1,3 @@ +.grid { + flex: 1 0 auto; +} diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js new file mode 100644 index 000000000..28cfdf14c --- /dev/null +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js @@ -0,0 +1,326 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import { Grid, WindowScroller } from 'react-virtualized'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import dimensions from 'Styles/Variables/dimensions'; +import { sortDirections } from 'Helpers/Props'; +import Measure from 'Components/Measure'; +import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector'; +import ArtistIndexBanner from './ArtistIndexBanner'; +import styles from './ArtistIndexBanners.css'; + +// container dimensions +const columnPadding = parseInt(dimensions.artistIndexColumnPadding); +const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen); +const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); +const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); + +const additionalColumnCount = { + small: 3, + medium: 2, + large: 1 +}; + +function calculateColumnWidth(width, bannerSize, isSmallScreen) { + const maxiumColumnWidth = isSmallScreen ? 344 : 364; + const columns = Math.floor(width / maxiumColumnWidth); + const remainder = width % maxiumColumnWidth; + + if (remainder === 0 && bannerSize === 'large') { + return maxiumColumnWidth; + } + + return Math.floor(width / (columns + additionalColumnCount[bannerSize])); +} + +function calculateRowHeight(bannerHeight, sortKey, isSmallScreen, bannerOptions) { + const { + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile + } = bannerOptions; + + const nextAiringHeight = 19; + + const heights = [ + bannerHeight, + detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, + nextAiringHeight, + isSmallScreen ? columnPaddingSmallScreen : columnPadding + ]; + + if (showTitle) { + heights.push(19); + } + + if (showMonitored) { + heights.push(19); + } + + if (showQualityProfile) { + heights.push(19); + } + + switch (sortKey) { + case 'seasons': + case 'previousAiring': + case 'added': + case 'path': + case 'sizeOnDisk': + heights.push(19); + break; + case 'qualityProfileId': + if (!showQualityProfile) { + heights.push(19); + } + break; + default: + // No need to add a height of 0 + } + + return heights.reduce((acc, height) => acc + height, 0); +} + +function calculateHeight(bannerWidth) { + return Math.ceil((88/476) * bannerWidth); +} + +class ArtistIndexBanners extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + width: 0, + columnWidth: 364, + columnCount: 1, + bannerWidth: 476, + bannerHeight: 88, + rowHeight: calculateRowHeight(88, null, props.isSmallScreen, {}) + }; + + this._isInitialized = false; + this._grid = null; + } + + componentDidMount() { + this._contentBodyNode = ReactDOM.findDOMNode(this.props.contentBody); + } + + componentDidUpdate(prevProps) { + const { + items, + filters, + sortKey, + sortDirection, + bannerOptions, + jumpToCharacter + } = this.props; + + const itemsChanged = hasDifferentItems(prevProps.items, items); + + if ( + prevProps.sortKey !== sortKey || + prevProps.bannerOptions !== bannerOptions || + itemsChanged + ) { + this.calculateGrid(); + } + + if ( + prevProps.filters !== filters || + prevProps.sortKey !== sortKey || + prevProps.sortDirection !== sortDirection || + itemsChanged + ) { + this._grid.recomputeGridSize(); + } + + if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (index != null) { + const { + columnCount, + rowHeight + } = this.state; + + const row = Math.floor(index / columnCount); + const scrollTop = rowHeight * row; + + this.props.onScroll({ scrollTop }); + } + } + } + + // + // Control + + setGridRef = (ref) => { + this._grid = ref; + } + + calculateGrid = (width = this.state.width, isSmallScreen) => { + const { + sortKey, + bannerOptions + } = this.props; + + const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; + const columnWidth = calculateColumnWidth(width, bannerOptions.size, isSmallScreen); + const columnCount = Math.max(Math.floor(width / columnWidth), 1); + const bannerWidth = columnWidth - padding; + const bannerHeight = calculateHeight(bannerWidth); + const rowHeight = calculateRowHeight(bannerHeight, sortKey, isSmallScreen, bannerOptions); + + this.setState({ + width, + columnWidth, + columnCount, + bannerWidth, + bannerHeight, + rowHeight + }); + } + + cellRenderer = ({ key, rowIndex, columnIndex, style }) => { + const { + items, + sortKey, + bannerOptions, + showRelativeDates, + shortDateFormat, + timeFormat + } = this.props; + + const { + bannerWidth, + bannerHeight, + columnCount + } = this.state; + + const { + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile + } = bannerOptions; + + const artist = items[rowIndex * columnCount + columnIndex]; + + if (!artist) { + return null; + } + + return ( + + ); + } + + // + // Listeners + + onMeasure = ({ width }) => { + this.calculateGrid(width, this.props.isSmallScreen); + } + + onSectionRendered = () => { + if (!this._isInitialized && this._contentBodyNode) { + this.props.onRender(); + this._isInitialized = true; + } + } + + // + // Render + + render() { + const { + items, + scrollTop, + isSmallScreen, + onScroll + } = this.props; + + const { + width, + columnWidth, + columnCount, + rowHeight + } = this.state; + + const rowCount = Math.ceil(items.length / columnCount); + + return ( + + + {({ height, isScrolling }) => { + return ( + + ); + } + } + + + ); + } +} + +ArtistIndexBanners.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + bannerOptions: PropTypes.object.isRequired, + scrollTop: PropTypes.number.isRequired, + jumpToCharacter: PropTypes.string, + contentBody: PropTypes.object.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + timeFormat: PropTypes.string.isRequired, + onRender: PropTypes.func.isRequired, + onScroll: PropTypes.func.isRequired +}; + +export default ArtistIndexBanners; diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBannersConnector.js b/frontend/src/Artist/Index/Banners/ArtistIndexBannersConnector.js new file mode 100644 index 000000000..bac56ebd2 --- /dev/null +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBannersConnector.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import ArtistIndexBanners from './ArtistIndexBanners'; + +function createMapStateToProps() { + return createSelector( + (state) => state.artistIndex.bannerOptions, + createUISettingsSelector(), + createDimensionsSelector(), + (bannerOptions, uiSettings, dimensions) => { + return { + bannerOptions, + showRelativeDates: uiSettings.showRelativeDates, + shortDateFormat: uiSettings.shortDateFormat, + timeFormat: uiSettings.timeFormat, + isSmallScreen: dimensions.isSmallScreen + }; + } + ); +} + +export default connect(createMapStateToProps)(ArtistIndexBanners); diff --git a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.js b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.js new file mode 100644 index 000000000..34c8abfcf --- /dev/null +++ b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ArtistIndexBannerOptionsModalContentConnector from './ArtistIndexBannerOptionsModalContentConnector'; + +function ArtistIndexBannerOptionsModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +ArtistIndexBannerOptionsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default ArtistIndexBannerOptionsModal; diff --git a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.js b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.js new file mode 100644 index 000000000..6bfcad0bb --- /dev/null +++ b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.js @@ -0,0 +1,213 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +const bannerSizeOptions = [ + { key: 'small', value: 'Small' }, + { key: 'medium', value: 'Medium' }, + { key: 'large', value: 'Large' } +]; + +class ArtistIndexBannerOptionsModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + detailedProgressBar: props.detailedProgressBar, + size: props.size, + showTitle: props.showTitle, + showMonitored: props.showMonitored, + showQualityProfile: props.showQualityProfile, + showSearchAction: props.showSearchAction + }; + } + + componentDidUpdate(prevProps) { + const { + detailedProgressBar, + size, + showTitle, + showMonitored, + showQualityProfile, + showSearchAction + } = this.props; + + const state = {}; + + if (detailedProgressBar !== prevProps.detailedProgressBar) { + state.detailedProgressBar = detailedProgressBar; + } + + if (size !== prevProps.size) { + state.size = size; + } + + if (showTitle !== prevProps.showTitle) { + state.showTitle = showTitle; + } + + if (showMonitored !== prevProps.showMonitored) { + state.showMonitored = showMonitored; + } + + if (showQualityProfile !== prevProps.showQualityProfile) { + state.showQualityProfile = showQualityProfile; + } + + if (showSearchAction !== prevProps.showSearchAction) { + state.showSearchAction = showSearchAction; + } + + if (!_.isEmpty(state)) { + this.setState(state); + } + } + + // + // Listeners + + onChangeBannerOption = ({ name, value }) => { + this.setState({ + [name]: value + }, () => { + this.props.onChangeBannerOption({ [name]: value }); + }); + } + + // + // Render + + render() { + const { + onModalClose + } = this.props; + + const { + detailedProgressBar, + size, + showTitle, + showMonitored, + showQualityProfile, + showSearchAction + } = this.state; + + return ( + + + Options + + + +
+ + Size + + + + + + Detailed Progress Bar + + + + + + Show Name + + + + + + Show Monitored + + + + + + Show Quality Profile + + + + + + Show Search + + + +
+
+ + + + +
+ ); + } +} + +ArtistIndexBannerOptionsModalContent.propTypes = { + size: PropTypes.string.isRequired, + showTitle: PropTypes.bool.isRequired, + showQualityProfile: PropTypes.bool.isRequired, + detailedProgressBar: PropTypes.bool.isRequired, + showSearchAction: PropTypes.bool.isRequired, + onChangeBannerOption: PropTypes.func.isRequired, + showMonitored: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default ArtistIndexBannerOptionsModalContent; diff --git a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContentConnector.js b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContentConnector.js new file mode 100644 index 000000000..884edd05d --- /dev/null +++ b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContentConnector.js @@ -0,0 +1,23 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setArtistBannerOption } from 'Store/Actions/artistIndexActions'; +import ArtistIndexBannerOptionsModalContent from './ArtistIndexBannerOptionsModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.artistIndex, + (artistIndex) => { + return artistIndex.bannerOptions; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onChangeBannerOption(payload) { + dispatch(setArtistBannerOption(payload)); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexBannerOptionsModalContent); diff --git a/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.js b/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.js new file mode 100644 index 000000000..818e83311 --- /dev/null +++ b/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.js @@ -0,0 +1,41 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { align } from 'Helpers/Props'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import ArtistIndexFilterModalConnector from 'Artist/Index/ArtistIndexFilterModalConnector'; + +function ArtistIndexFilterMenu(props) { + const { + selectedFilterKey, + filters, + customFilters, + isDisabled, + onFilterSelect + } = props; + + return ( + + ); +} + +ArtistIndexFilterMenu.propTypes = { + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + isDisabled: PropTypes.bool.isRequired, + onFilterSelect: PropTypes.func.isRequired +}; + +ArtistIndexFilterMenu.defaultProps = { + showCustomFilters: false +}; + +export default ArtistIndexFilterMenu; diff --git a/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.js b/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.js new file mode 100644 index 000000000..fc5854648 --- /dev/null +++ b/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.js @@ -0,0 +1,150 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { align, sortDirections } from 'Helpers/Props'; +import SortMenu from 'Components/Menu/SortMenu'; +import MenuContent from 'Components/Menu/MenuContent'; +import SortMenuItem from 'Components/Menu/SortMenuItem'; + +function ArtistIndexSortMenu(props) { + const { + sortKey, + sortDirection, + isDisabled, + onSortSelect + } = props; + + return ( + + + + Monitored/Status + + + + Name + + + + Type + + + + Quality Profile + + + + Metadata Profile + + + + Next Album + + + + Last Album + + + + Added + + + + Albums + + + + Tracks + + + + Track Count + + + + Path + + + + Size on Disk + + + + ); +} + +ArtistIndexSortMenu.propTypes = { + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + isDisabled: PropTypes.bool.isRequired, + onSortSelect: PropTypes.func.isRequired +}; + +export default ArtistIndexSortMenu; diff --git a/frontend/src/Artist/Index/Menus/ArtistIndexViewMenu.js b/frontend/src/Artist/Index/Menus/ArtistIndexViewMenu.js new file mode 100644 index 000000000..46ca03b9f --- /dev/null +++ b/frontend/src/Artist/Index/Menus/ArtistIndexViewMenu.js @@ -0,0 +1,63 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { align } from 'Helpers/Props'; +import ViewMenu from 'Components/Menu/ViewMenu'; +import MenuContent from 'Components/Menu/MenuContent'; +import ViewMenuItem from 'Components/Menu/ViewMenuItem'; + +function ArtistIndexViewMenu(props) { + const { + view, + isDisabled, + onViewSelect + } = props; + + return ( + + + + Table + + + + Posters + + + + Banners + + + + Overview + + + + ); +} + +ArtistIndexViewMenu.propTypes = { + view: PropTypes.string.isRequired, + isDisabled: PropTypes.bool.isRequired, + onViewSelect: PropTypes.func.isRequired +}; + +export default ArtistIndexViewMenu; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css new file mode 100644 index 000000000..054319ebc --- /dev/null +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css @@ -0,0 +1,96 @@ +$hoverScale: 1.05; + +.container { + &:hover { + .content { + background-color: $tableRowHoverBackgroundColor; + } + } +} + +.content { + display: flex; + flex-grow: 1; +} + +.poster { + position: relative; +} + +.posterContainer { + position: relative; +} + +.link { + composes: link from '~Components/Link/Link.css'; + + display: block; + color: $defaultColor; + + &:hover { + color: $defaultColor; + text-decoration: none; + } +} + +.ended { + position: absolute; + top: 0; + right: 0; + z-index: 1; + width: 0; + height: 0; + border-width: 0 25px 25px 0; + border-style: solid; + border-color: transparent $dangerColor transparent transparent; + color: $white; +} + +.info { + display: flex; + flex: 1 0 1px; + flex-direction: column; + overflow: hidden; + padding-left: 10px; +} + +.titleRow { + display: flex; + justify-content: space-between; + flex: 0 0 auto; + margin-bottom: 10px; + line-height: 32px; +} + +.title { + @add-mixin truncate; + composes: link; + + flex: 1 0 1px; + font-weight: 300; + font-size: 30px; +} + +.actions { + white-space: nowrap; +} + +.details { + display: flex; + justify-content: space-between; + flex: 1 0 auto; +} + +.overview { + composes: link; + + flex: 0 1 1000px; + overflow: hidden; + min-height: 0; +} + +@media only screen and (max-width: $breakpointSmall) { + .overview { + display: none; + } +} diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js new file mode 100644 index 000000000..6be34d622 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js @@ -0,0 +1,284 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import TextTruncate from 'react-text-truncate'; +import { icons } from 'Helpers/Props'; +import dimensions from 'Styles/Variables/dimensions'; +import fonts from 'Styles/Variables/fonts'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import ArtistPoster from 'Artist/ArtistPoster'; +import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; +import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; +import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar'; +import ArtistIndexOverviewInfo from './ArtistIndexOverviewInfo'; +import styles from './ArtistIndexOverview.css'; + +const columnPadding = parseInt(dimensions.artistIndexColumnPadding); +const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen); +const defaultFontSize = parseInt(fonts.defaultFontSize); +const lineHeight = parseFloat(fonts.lineHeight); + +// Hardcoded height beased on line-height of 32 + bottom margin of 10. +// Less side-effecty than using react-measure. +const titleRowHeight = 42; + +function getContentHeight(rowHeight, isSmallScreen) { + const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; + + return rowHeight - padding; +} + +class ArtistIndexOverview extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditArtistModalOpen: false, + isDeleteArtistModalOpen: false + }; + } + + // + // Listeners + + onEditArtistPress = () => { + this.setState({ isEditArtistModalOpen: true }); + } + + onEditArtistModalClose = () => { + this.setState({ isEditArtistModalOpen: false }); + } + + onDeleteArtistPress = () => { + this.setState({ + isEditArtistModalOpen: false, + isDeleteArtistModalOpen: true + }); + } + + onDeleteArtistModalClose = () => { + this.setState({ isDeleteArtistModalOpen: false }); + } + + // + // Render + + render() { + const { + style, + id, + artistName, + overview, + monitored, + status, + foreignArtistId, + nextAiring, + statistics, + images, + posterWidth, + posterHeight, + qualityProfile, + overviewOptions, + showSearchAction, + showRelativeDates, + shortDateFormat, + longDateFormat, + timeFormat, + rowHeight, + isSmallScreen, + isRefreshingArtist, + isSearchingArtist, + onRefreshArtistPress, + onSearchPress, + ...otherProps + } = this.props; + + const { + albumCount, + sizeOnDisk, + trackCount, + trackFileCount, + totalTrackCount + } = statistics; + + const { + isEditArtistModalOpen, + isDeleteArtistModalOpen + } = this.state; + + const link = `/artist/${foreignArtistId}`; + + const elementStyle = { + width: `${posterWidth}px`, + height: `${posterHeight}px` + }; + + const contentHeight = getContentHeight(rowHeight, isSmallScreen); + const overviewHeight = contentHeight - titleRowHeight; + + return ( +
+
+
+
+ { + status === 'ended' && +
+ } + + + + +
+ + +
+ +
+
+ + {artistName} + + +
+ + + { + showSearchAction && + + } + + +
+
+ +
+ + + + + + +
+
+
+ + + + +
+ ); + } +} + +ArtistIndexOverview.propTypes = { + style: PropTypes.object.isRequired, + id: PropTypes.number.isRequired, + artistName: PropTypes.string.isRequired, + overview: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + status: PropTypes.string.isRequired, + foreignArtistId: PropTypes.string.isRequired, + nextAiring: PropTypes.string, + statistics: PropTypes.object.isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + posterWidth: PropTypes.number.isRequired, + posterHeight: PropTypes.number.isRequired, + rowHeight: PropTypes.number.isRequired, + qualityProfile: PropTypes.object.isRequired, + overviewOptions: PropTypes.object.isRequired, + showSearchAction: PropTypes.bool.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + isRefreshingArtist: PropTypes.bool.isRequired, + isSearchingArtist: PropTypes.bool.isRequired, + onRefreshArtistPress: PropTypes.func.isRequired, + onSearchPress: PropTypes.func.isRequired +}; + +ArtistIndexOverview.defaultProps = { + statistics: { + albumCount: 0, + trackCount: 0, + trackFileCount: 0, + totalTrackCount: 0 + } +}; + +export default ArtistIndexOverview; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.css b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.css new file mode 100644 index 000000000..5dc53762f --- /dev/null +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.css @@ -0,0 +1,12 @@ +.infos { + display: flex; + flex: 0 0 250px; + flex-direction: column; + margin-left: 10px; +} + +@media only screen and (max-width: $breakpointSmall) { + .infos { + margin-left: 0; + } +} diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.js new file mode 100644 index 000000000..f7839cab5 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.js @@ -0,0 +1,250 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { icons } from 'Helpers/Props'; +import dimensions from 'Styles/Variables/dimensions'; +import ArtistIndexOverviewInfoRow from './ArtistIndexOverviewInfoRow'; +import styles from './ArtistIndexOverviewInfo.css'; + +const infoRowHeight = parseInt(dimensions.artistIndexOverviewInfoRowHeight); + +const rows = [ + { + name: 'monitored', + showProp: 'showMonitored', + valueProp: 'monitored' + + }, + { + name: 'qualityProfileId', + showProp: 'showQualityProfile', + valueProp: 'qualityProfileId' + }, + { + name: 'lastAlbum', + showProp: 'showLastAlbum', + valueProp: 'lastAlbum' + }, + { + name: 'added', + showProp: 'showAdded', + valueProp: 'added' + }, + { + name: 'albumCount', + showProp: 'showAlbumCount', + valueProp: 'albumCount' + }, + { + name: 'path', + showProp: 'showPath', + valueProp: 'path' + }, + { + name: 'sizeOnDisk', + showProp: 'showSizeOnDisk', + valueProp: 'sizeOnDisk' + } +]; + +function isVisible(row, props) { + const { + name, + showProp, + valueProp + } = row; + + if (props[valueProp] == null) { + return false; + } + + return props[showProp] || props.sortKey === name; +} + +function getInfoRowProps(row, props) { + const { name } = row; + + if (name === 'monitored') { + const monitoredText = props.monitored ? 'Monitored' : 'Unmonitored'; + + return { + title: monitoredText, + iconName: props.monitored ? icons.MONITORED : icons.UNMONITORED, + label: monitoredText + }; + } + + if (name === 'qualityProfileId') { + return { + title: 'Quality Profile', + iconName: icons.PROFILE, + label: props.qualityProfile.name + }; + } + + if (name === 'lastAlbum') { + const { + lastAlbum, + showRelativeDates, + shortDateFormat, + timeFormat + } = props; + + return { + title: `Last Album: ${lastAlbum.title}`, + iconName: icons.CALENDAR, + label: getRelativeDate( + lastAlbum.releaseDate, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true + } + ) + }; + } + + if (name === 'added') { + const { + added, + showRelativeDates, + shortDateFormat, + longDateFormat, + timeFormat + } = props; + + return { + title: `Added: ${formatDateTime(added, longDateFormat, timeFormat)}`, + iconName: icons.ADD, + label: getRelativeDate( + added, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true + } + ) + }; + } + + if (name === 'albumCount') { + const { albumCount } = props; + let albums = '1 album'; + + if (albumCount === 0) { + albums = 'No albums'; + } else if (albumCount > 1) { + albums = `${albumCount} albums`; + } + + return { + title: 'Album Count', + iconName: icons.CIRCLE, + label: albums + }; + } + + if (name === 'path') { + return { + title: 'Path', + iconName: icons.FOLDER, + label: props.path + }; + } + + if (name === 'sizeOnDisk') { + return { + title: 'Size on Disk', + iconName: icons.DRIVE, + label: formatBytes(props.sizeOnDisk) + }; + } +} + +function ArtistIndexOverviewInfo(props) { + const { + height, + nextAiring, + showRelativeDates, + shortDateFormat, + longDateFormat, + timeFormat + } = props; + + let shownRows = 1; + + const maxRows = Math.floor(height / (infoRowHeight + 4)); + + return ( +
+ { + !!nextAiring && + + } + + { + rows.map((row) => { + if (!isVisible(row, props)) { + return null; + } + + if (shownRows >= maxRows) { + return null; + } + + shownRows++; + + const infoRowProps = getInfoRowProps(row, props); + + return ( + + ); + }) + } +
+ ); +} + +ArtistIndexOverviewInfo.propTypes = { + height: PropTypes.number.isRequired, + showMonitored: PropTypes.bool.isRequired, + showQualityProfile: PropTypes.bool.isRequired, + showAdded: PropTypes.bool.isRequired, + showAlbumCount: PropTypes.bool.isRequired, + showPath: PropTypes.bool.isRequired, + showSizeOnDisk: PropTypes.bool.isRequired, + monitored: PropTypes.bool.isRequired, + nextAiring: PropTypes.string, + qualityProfile: PropTypes.object.isRequired, + lastAlbum: PropTypes.object, + added: PropTypes.string, + albumCount: PropTypes.number.isRequired, + path: PropTypes.string.isRequired, + sizeOnDisk: PropTypes.number, + sortKey: PropTypes.string.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired +}; + +export default ArtistIndexOverviewInfo; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.css b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.css new file mode 100644 index 000000000..1fcd432a3 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.css @@ -0,0 +1,10 @@ +.infoRow { + flex: 0 0 $artistIndexOverviewInfoRowHeight; + margin: 2px 0; +} + +.icon { + margin-right: 5px; + width: 25px !important; + text-align: center; +} diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.js new file mode 100644 index 000000000..b04029b88 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.js @@ -0,0 +1,35 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Icon from 'Components/Icon'; +import styles from './ArtistIndexOverviewInfoRow.css'; + +function ArtistIndexOverviewInfoRow(props) { + const { + title, + iconName, + label + } = props; + + return ( +
+ + + {label} +
+ ); +} + +ArtistIndexOverviewInfoRow.propTypes = { + title: PropTypes.string, + iconName: PropTypes.object.isRequired, + label: PropTypes.string.isRequired +}; + +export default ArtistIndexOverviewInfoRow; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.css b/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.css new file mode 100644 index 000000000..9c6520fb5 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.css @@ -0,0 +1,3 @@ +.grid { + flex: 1 0 auto; +} diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.js new file mode 100644 index 000000000..8b23cdf95 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.js @@ -0,0 +1,289 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import { Grid, WindowScroller } from 'react-virtualized'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import dimensions from 'Styles/Variables/dimensions'; +import { sortDirections } from 'Helpers/Props'; +import Measure from 'Components/Measure'; +import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector'; +import ArtistIndexOverview from './ArtistIndexOverview'; +import styles from './ArtistIndexOverviews.css'; + +// Poster container dimensions +const columnPadding = parseInt(dimensions.artistIndexColumnPadding); +const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen); +const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); +const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); + +function calculatePosterWidth(posterSize, isSmallScreen) { + const maxiumPosterWidth = isSmallScreen ? 192 : 202; + + if (posterSize === 'large') { + return maxiumPosterWidth; + } + + if (posterSize === 'medium') { + return Math.floor(maxiumPosterWidth * 0.75); + } + + return Math.floor(maxiumPosterWidth * 0.5); +} + +function calculateRowHeight(posterHeight, sortKey, isSmallScreen, overviewOptions) { + const { + detailedProgressBar + } = overviewOptions; + + const heights = [ + posterHeight, + detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, + isSmallScreen ? columnPaddingSmallScreen : columnPadding + ]; + + return heights.reduce((acc, height) => acc + height, 0); +} + +function calculatePosterHeight(posterWidth) { + return posterWidth; +} + +class ArtistIndexOverviews extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + width: 0, + columnCount: 1, + posterWidth: 238, + posterHeight: 238, + rowHeight: calculateRowHeight(238, null, props.isSmallScreen, {}) + }; + + this._isInitialized = false; + this._grid = null; + } + + componentDidMount() { + this._contentBodyNode = ReactDOM.findDOMNode(this.props.contentBody); + } + + componentDidUpdate(prevProps) { + const { + items, + filters, + sortKey, + sortDirection, + overviewOptions, + jumpToCharacter + } = this.props; + + const itemsChanged = hasDifferentItems(prevProps.items, items); + const overviewOptionsChanged = !_.isMatch(prevProps.overviewOptions, overviewOptions); + + if ( + prevProps.sortKey !== sortKey || + prevProps.overviewOptions !== overviewOptions || + itemsChanged + ) { + this.calculateGrid(); + } + + if ( + prevProps.filters !== filters || + prevProps.sortKey !== sortKey || + prevProps.sortDirection !== sortDirection || + itemsChanged || + overviewOptionsChanged + ) { + this._grid.recomputeGridSize(); + } + + if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (index != null) { + const { + rowHeight + } = this.state; + + const scrollTop = rowHeight * index; + + this.props.onScroll({ scrollTop }); + } + } + } + + // + // Control + + scrollToFirstCharacter(character) { + const items = this.props.items; + const { + rowHeight + } = this.state; + + const index = getIndexOfFirstCharacter(items, character); + + if (index != null) { + const scrollTop = rowHeight * index; + + this.props.onScroll({ scrollTop }); + } + } + + setGridRef = (ref) => { + this._grid = ref; + } + + calculateGrid = (width = this.state.width, isSmallScreen) => { + const { + sortKey, + overviewOptions + } = this.props; + + const posterWidth = calculatePosterWidth(overviewOptions.size, isSmallScreen); + const posterHeight = calculatePosterHeight(posterWidth); + const rowHeight = calculateRowHeight(posterHeight, sortKey, isSmallScreen, overviewOptions); + + this.setState({ + width, + posterWidth, + posterHeight, + rowHeight + }); + } + + cellRenderer = ({ key, rowIndex, style }) => { + const { + items, + sortKey, + overviewOptions, + showRelativeDates, + shortDateFormat, + longDateFormat, + timeFormat, + isSmallScreen + } = this.props; + + const { + posterWidth, + posterHeight, + rowHeight + } = this.state; + + const artist = items[rowIndex]; + + if (!artist) { + return null; + } + + return ( + + ); + } + + // + // Listeners + + onMeasure = ({ width }) => { + this.calculateGrid(width, this.props.isSmallScreen); + } + + onSectionRendered = () => { + if (!this._isInitialized && this._contentBodyNode) { + this.props.onRender(); + this._isInitialized = true; + } + } + + // + // Render + + render() { + const { + items, + scrollTop, + isSmallScreen, + onScroll + } = this.props; + + const { + width, + rowHeight + } = this.state; + + return ( + + + {({ height, isScrolling }) => { + return ( + + ); + } + } + + + ); + } +} + +ArtistIndexOverviews.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + overviewOptions: PropTypes.object.isRequired, + scrollTop: PropTypes.number.isRequired, + jumpToCharacter: PropTypes.string, + contentBody: PropTypes.object.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + timeFormat: PropTypes.string.isRequired, + onRender: PropTypes.func.isRequired, + onScroll: PropTypes.func.isRequired +}; + +export default ArtistIndexOverviews; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewsConnector.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewsConnector.js new file mode 100644 index 000000000..595a471b1 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewsConnector.js @@ -0,0 +1,25 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import ArtistIndexOverviews from './ArtistIndexOverviews'; + +function createMapStateToProps() { + return createSelector( + (state) => state.artistIndex.overviewOptions, + createUISettingsSelector(), + createDimensionsSelector(), + (overviewOptions, uiSettings, dimensions) => { + return { + overviewOptions, + showRelativeDates: uiSettings.showRelativeDates, + shortDateFormat: uiSettings.shortDateFormat, + longDateFormat: uiSettings.longDateFormat, + timeFormat: uiSettings.timeFormat, + isSmallScreen: dimensions.isSmallScreen + }; + } + ); +} + +export default connect(createMapStateToProps)(ArtistIndexOverviews); diff --git a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.js b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.js new file mode 100644 index 000000000..9ca575185 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ArtistIndexOverviewOptionsModalContentConnector from './ArtistIndexOverviewOptionsModalContentConnector'; + +function ArtistIndexOverviewOptionsModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +ArtistIndexOverviewOptionsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default ArtistIndexOverviewOptionsModal; diff --git a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.js b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.js new file mode 100644 index 000000000..2fe569965 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.js @@ -0,0 +1,287 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +const posterSizeOptions = [ + { key: 'small', value: 'Small' }, + { key: 'medium', value: 'Medium' }, + { key: 'large', value: 'Large' } +]; + +class ArtistIndexOverviewOptionsModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + detailedProgressBar: props.detailedProgressBar, + size: props.size, + showMonitored: props.showMonitored, + showQualityProfile: props.showQualityProfile, + showLastAlbum: props.showLastAlbum, + showAdded: props.showAdded, + showAlbumCount: props.showAlbumCount, + showPath: props.showPath, + showSizeOnDisk: props.showSizeOnDisk, + showSearchAction: props.showSearchAction + }; + } + + componentDidUpdate(prevProps) { + const { + detailedProgressBar, + size, + showMonitored, + showQualityProfile, + showLastAlbum, + showAdded, + showAlbumCount, + showPath, + showSizeOnDisk, + showSearchAction + } = this.props; + + const state = {}; + + if (detailedProgressBar !== prevProps.detailedProgressBar) { + state.detailedProgressBar = detailedProgressBar; + } + + if (size !== prevProps.size) { + state.size = size; + } + + if (showMonitored !== prevProps.showMonitored) { + state.showMonitored = showMonitored; + } + + if (showQualityProfile !== prevProps.showQualityProfile) { + state.showQualityProfile = showQualityProfile; + } + + if (showLastAlbum !== prevProps.showLastAlbum) { + state.showLastAlbum = showLastAlbum; + } + + if (showAdded !== prevProps.showAdded) { + state.showAdded = showAdded; + } + + if (showAlbumCount !== prevProps.showAlbumCount) { + state.showAlbumCount = showAlbumCount; + } + + if (showPath !== prevProps.showPath) { + state.showPath = showPath; + } + + if (showSizeOnDisk !== prevProps.showSizeOnDisk) { + state.showSizeOnDisk = showSizeOnDisk; + } + + if (showSearchAction !== prevProps.showSearchAction) { + state.showSearchAction = showSearchAction; + } + + if (!_.isEmpty(state)) { + this.setState(state); + } + } + + // + // Listeners + + onChangeOverviewOption = ({ name, value }) => { + this.setState({ + [name]: value + }, () => { + this.props.onChangeOverviewOption({ [name]: value }); + }); + } + + // + // Render + + render() { + const { + onModalClose + } = this.props; + + const { + detailedProgressBar, + size, + showMonitored, + showQualityProfile, + showLastAlbum, + showAdded, + showAlbumCount, + showPath, + showSizeOnDisk, + showSearchAction + } = this.state; + + return ( + + + Overview Options + + + +
+ + Poster Size + + + + + + Detailed Progress Bar + + + + + + Show Monitored + + + + + + + Show Quality Profile + + + + + + Show Last Album + + + + + + Show Date Added + + + + + + Show Album Count + + + + + + Show Path + + + + + + Show Size on Disk + + + + + + Show Search + + + +
+
+ + + + +
+ ); + } +} + +ArtistIndexOverviewOptionsModalContent.propTypes = { + size: PropTypes.string.isRequired, + detailedProgressBar: PropTypes.bool.isRequired, + showMonitored: PropTypes.bool.isRequired, + showQualityProfile: PropTypes.bool.isRequired, + showLastAlbum: PropTypes.bool.isRequired, + showAdded: PropTypes.bool.isRequired, + showAlbumCount: PropTypes.bool.isRequired, + showPath: PropTypes.bool.isRequired, + showSizeOnDisk: PropTypes.bool.isRequired, + showSearchAction: PropTypes.bool.isRequired, + onChangeOverviewOption: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default ArtistIndexOverviewOptionsModalContent; diff --git a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContentConnector.js b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContentConnector.js new file mode 100644 index 000000000..70c30dba6 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContentConnector.js @@ -0,0 +1,23 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setArtistOverviewOption } from 'Store/Actions/artistIndexActions'; +import ArtistIndexOverviewOptionsModalContent from './ArtistIndexOverviewOptionsModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.artistIndex, + (artistIndex) => { + return artistIndex.overviewOptions; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onChangeOverviewOption(payload) { + dispatch(setArtistOverviewOption(payload)); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexOverviewOptionsModalContent); diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPoster.css b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.css new file mode 100644 index 000000000..cd378e34c --- /dev/null +++ b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.css @@ -0,0 +1,103 @@ +$hoverScale: 1.05; + +.container { + padding: 10px; +} + +.content { + transition: all 200ms ease-in; + + &:hover { + z-index: 2; + box-shadow: 0 0 12px $black; + transition: all 200ms ease-in; + + .controls { + opacity: 0.9; + transition: opacity 200ms linear 150ms; + } + } +} + +.posterContainer { + position: relative; +} + +.link { + composes: link from '~Components/Link/Link.css'; + + position: relative; + display: block; + height: 70px; + background-color: $defaultColor; +} + +.overlayTitle { + position: absolute; + top: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 5px; + width: 100%; + height: 100%; + color: $offWhite; + text-align: center; + font-size: 20px; +} + +.nextAiring { + background-color: #fafbfc; + text-align: center; + font-size: $smallFontSize; +} + +.title { + @add-mixin truncate; + + background-color: $defaultColor; + color: $white; + text-align: center; + font-size: $smallFontSize; +} + +.ended { + position: absolute; + top: 0; + right: 0; + z-index: 1; + width: 0; + height: 0; + border-width: 0 25px 25px 0; + border-style: solid; + border-color: transparent $dangerColor transparent transparent; + color: $white; +} + +.controls { + position: absolute; + bottom: 10px; + left: 10px; + z-index: 3; + border-radius: 4px; + background-color: #216044; + color: $white; + font-size: $smallFontSize; + opacity: 0; + transition: opacity 0; +} + +.action { + composes: button from '~Components/Link/IconButton.css'; + + &:hover { + color: #ccc; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .container { + padding: 5px; + } +} diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPoster.js b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.js new file mode 100644 index 000000000..101b49f7b --- /dev/null +++ b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.js @@ -0,0 +1,295 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import Label from 'Components/Label'; +import Link from 'Components/Link/Link'; +import ArtistPoster from 'Artist/ArtistPoster'; +import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; +import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; +import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar'; +import ArtistIndexPosterInfo from './ArtistIndexPosterInfo'; +import styles from './ArtistIndexPoster.css'; + +class ArtistIndexPoster extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + hasPosterError: false, + isEditArtistModalOpen: false, + isDeleteArtistModalOpen: false + }; + } + + // + // Listeners + + onEditArtistPress = () => { + this.setState({ isEditArtistModalOpen: true }); + } + + onEditArtistModalClose = () => { + this.setState({ isEditArtistModalOpen: false }); + } + + onDeleteArtistPress = () => { + this.setState({ + isEditArtistModalOpen: false, + isDeleteArtistModalOpen: true + }); + } + + onDeleteArtistModalClose = () => { + this.setState({ isDeleteArtistModalOpen: false }); + } + + onPosterLoad = () => { + if (this.state.hasPosterError) { + this.setState({ hasPosterError: false }); + } + } + + onPosterLoadError = () => { + if (!this.state.hasPosterError) { + this.setState({ hasPosterError: true }); + } + } + + // + // Render + + render() { + const { + style, + id, + artistName, + monitored, + foreignArtistId, + status, + nextAiring, + statistics, + images, + posterWidth, + posterHeight, + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile, + qualityProfile, + showSearchAction, + showRelativeDates, + shortDateFormat, + timeFormat, + isRefreshingArtist, + isSearchingArtist, + onRefreshArtistPress, + onSearchPress, + ...otherProps + } = this.props; + + const { + albumCount, + sizeOnDisk, + trackCount, + trackFileCount, + totalTrackCount + } = statistics; + + const { + hasPosterError, + isEditArtistModalOpen, + isDeleteArtistModalOpen + } = this.state; + + const link = `/artist/${foreignArtistId}`; + + const elementStyle = { + width: `${posterWidth}px`, + height: `${posterHeight}px` + }; + + return ( +
+
+
+ + + { + status === 'ended' && +
+ } + + + + + { + hasPosterError && +
+ {artistName} +
+ } + + +
+ + + + { + showTitle && +
+ {artistName} +
+ } + + { + showMonitored && +
+ {monitored ? 'Monitored' : 'Unmonitored'} +
+ } + + { + showQualityProfile && +
+ {qualityProfile.name} +
+ } + { + nextAiring && +
+ { + getRelativeDate( + nextAiring, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true + } + ) + } +
+ } + + + + + +
+
+ ); + } +} + +ArtistIndexPoster.propTypes = { + style: PropTypes.object.isRequired, + id: PropTypes.number.isRequired, + artistName: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + status: PropTypes.string.isRequired, + foreignArtistId: PropTypes.string.isRequired, + nextAiring: PropTypes.string, + statistics: PropTypes.object.isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + posterWidth: PropTypes.number.isRequired, + posterHeight: PropTypes.number.isRequired, + detailedProgressBar: PropTypes.bool.isRequired, + showTitle: PropTypes.bool.isRequired, + showMonitored: PropTypes.bool.isRequired, + showQualityProfile: PropTypes.bool.isRequired, + qualityProfile: PropTypes.object.isRequired, + showSearchAction: PropTypes.bool.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + isRefreshingArtist: PropTypes.bool.isRequired, + isSearchingArtist: PropTypes.bool.isRequired, + onRefreshArtistPress: PropTypes.func.isRequired, + onSearchPress: PropTypes.func.isRequired +}; + +ArtistIndexPoster.defaultProps = { + statistics: { + albumCount: 0, + trackCount: 0, + trackFileCount: 0, + totalTrackCount: 0 + } +}; + +export default ArtistIndexPoster; diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.css b/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.css new file mode 100644 index 000000000..aab27d827 --- /dev/null +++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.css @@ -0,0 +1,5 @@ +.info { + background-color: #fafbfc; + text-align: center; + font-size: $smallFontSize; +} diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.js b/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.js new file mode 100644 index 000000000..591961605 --- /dev/null +++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import formatBytes from 'Utilities/Number/formatBytes'; +import styles from './ArtistIndexPosterInfo.css'; + +function ArtistIndexPosterInfo(props) { + const { + qualityProfile, + showQualityProfile, + previousAiring, + added, + albumCount, + path, + sizeOnDisk, + sortKey, + showRelativeDates, + shortDateFormat, + timeFormat + } = props; + + if (sortKey === 'qualityProfileId' && !showQualityProfile) { + return ( +
+ {qualityProfile.name} +
+ ); + } + + if (sortKey === 'previousAiring' && previousAiring) { + return ( +
+ { + getRelativeDate( + previousAiring, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true + } + ) + } +
+ ); + } + + if (sortKey === 'added' && added) { + const addedDate = getRelativeDate( + added, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: false + } + ); + + return ( +
+ {`Added ${addedDate}`} +
+ ); + } + + if (sortKey === 'albumCount') { + let albums = '1 album'; + + if (albumCount === 0) { + albums = 'No albums'; + } else if (albumCount > 1) { + albums = `${albumCount} albums`; + } + + return ( +
+ {albums} +
+ ); + } + + if (sortKey === 'path') { + return ( +
+ {path} +
+ ); + } + + if (sortKey === 'sizeOnDisk') { + return ( +
+ {formatBytes(sizeOnDisk)} +
+ ); + } + + return null; +} + +ArtistIndexPosterInfo.propTypes = { + qualityProfile: PropTypes.object.isRequired, + showQualityProfile: PropTypes.bool.isRequired, + previousAiring: PropTypes.string, + added: PropTypes.string, + albumCount: PropTypes.number.isRequired, + path: PropTypes.string.isRequired, + sizeOnDisk: PropTypes.number, + sortKey: PropTypes.string.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired +}; + +export default ArtistIndexPosterInfo; diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosters.css b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.css new file mode 100644 index 000000000..9c6520fb5 --- /dev/null +++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.css @@ -0,0 +1,3 @@ +.grid { + flex: 1 0 auto; +} diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js new file mode 100644 index 000000000..3650db93e --- /dev/null +++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js @@ -0,0 +1,326 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import { Grid, WindowScroller } from 'react-virtualized'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import dimensions from 'Styles/Variables/dimensions'; +import { sortDirections } from 'Helpers/Props'; +import Measure from 'Components/Measure'; +import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector'; +import ArtistIndexPoster from './ArtistIndexPoster'; +import styles from './ArtistIndexPosters.css'; + +// Poster container dimensions +const columnPadding = parseInt(dimensions.artistIndexColumnPadding); +const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen); +const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); +const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); + +const additionalColumnCount = { + small: 3, + medium: 2, + large: 1 +}; + +function calculateColumnWidth(width, posterSize, isSmallScreen) { + const maxiumColumnWidth = isSmallScreen ? 172 : 182; + const columns = Math.floor(width / maxiumColumnWidth); + const remainder = width % maxiumColumnWidth; + + if (remainder === 0 && posterSize === 'large') { + return maxiumColumnWidth; + } + + return Math.floor(width / (columns + additionalColumnCount[posterSize])); +} + +function calculateRowHeight(posterHeight, sortKey, isSmallScreen, posterOptions) { + const { + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile + } = posterOptions; + + const nextAiringHeight = 19; + + const heights = [ + posterHeight, + detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, + nextAiringHeight, + isSmallScreen ? columnPaddingSmallScreen : columnPadding + ]; + + if (showTitle) { + heights.push(19); + } + + if (showMonitored) { + heights.push(19); + } + + if (showQualityProfile) { + heights.push(19); + } + + switch (sortKey) { + case 'seasons': + case 'previousAiring': + case 'added': + case 'path': + case 'sizeOnDisk': + heights.push(19); + break; + case 'qualityProfileId': + if (!showQualityProfile) { + heights.push(19); + } + break; + default: + // No need to add a height of 0 + } + + return heights.reduce((acc, height) => acc + height, 0); +} + +function calculatePosterHeight(posterWidth) { + return Math.ceil(posterWidth); +} + +class ArtistIndexPosters extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + width: 0, + columnWidth: 182, + columnCount: 1, + posterWidth: 238, + posterHeight: 238, + rowHeight: calculateRowHeight(238, null, props.isSmallScreen, {}) + }; + + this._isInitialized = false; + this._grid = null; + } + + componentDidMount() { + this._contentBodyNode = ReactDOM.findDOMNode(this.props.contentBody); + } + + componentDidUpdate(prevProps) { + const { + items, + filters, + sortKey, + sortDirection, + posterOptions, + jumpToCharacter + } = this.props; + + const itemsChanged = hasDifferentItems(prevProps.items, items); + + if ( + prevProps.sortKey !== sortKey || + prevProps.posterOptions !== posterOptions || + itemsChanged + ) { + this.calculateGrid(); + } + + if ( + prevProps.filters !== filters || + prevProps.sortKey !== sortKey || + prevProps.sortDirection !== sortDirection || + itemsChanged + ) { + this._grid.recomputeGridSize(); + } + + if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (index != null) { + const { + columnCount, + rowHeight + } = this.state; + + const row = Math.floor(index / columnCount); + const scrollTop = rowHeight * row; + + this.props.onScroll({ scrollTop }); + } + } + } + + // + // Control + + setGridRef = (ref) => { + this._grid = ref; + } + + calculateGrid = (width = this.state.width, isSmallScreen) => { + const { + sortKey, + posterOptions + } = this.props; + + const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; + const columnWidth = calculateColumnWidth(width, posterOptions.size, isSmallScreen); + const columnCount = Math.max(Math.floor(width / columnWidth), 1); + const posterWidth = columnWidth - padding; + const posterHeight = calculatePosterHeight(posterWidth); + const rowHeight = calculateRowHeight(posterHeight, sortKey, isSmallScreen, posterOptions); + + this.setState({ + width, + columnWidth, + columnCount, + posterWidth, + posterHeight, + rowHeight + }); + } + + cellRenderer = ({ key, rowIndex, columnIndex, style }) => { + const { + items, + sortKey, + posterOptions, + showRelativeDates, + shortDateFormat, + timeFormat + } = this.props; + + const { + posterWidth, + posterHeight, + columnCount + } = this.state; + + const { + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile + } = posterOptions; + + const artist = items[rowIndex * columnCount + columnIndex]; + + if (!artist) { + return null; + } + + return ( + + ); + } + + // + // Listeners + + onMeasure = ({ width }) => { + this.calculateGrid(width, this.props.isSmallScreen); + } + + onSectionRendered = () => { + if (!this._isInitialized && this._contentBodyNode) { + this.props.onRender(); + this._isInitialized = true; + } + } + + // + // Render + + render() { + const { + items, + scrollTop, + isSmallScreen, + onScroll + } = this.props; + + const { + width, + columnWidth, + columnCount, + rowHeight + } = this.state; + + const rowCount = Math.ceil(items.length / columnCount); + + return ( + + + {({ height, isScrolling }) => { + return ( + + ); + } + } + + + ); + } +} + +ArtistIndexPosters.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + posterOptions: PropTypes.object.isRequired, + scrollTop: PropTypes.number.isRequired, + jumpToCharacter: PropTypes.string, + contentBody: PropTypes.object.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + timeFormat: PropTypes.string.isRequired, + onRender: PropTypes.func.isRequired, + onScroll: PropTypes.func.isRequired +}; + +export default ArtistIndexPosters; diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPostersConnector.js b/frontend/src/Artist/Index/Posters/ArtistIndexPostersConnector.js new file mode 100644 index 000000000..04c187e4e --- /dev/null +++ b/frontend/src/Artist/Index/Posters/ArtistIndexPostersConnector.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import ArtistIndexPosters from './ArtistIndexPosters'; + +function createMapStateToProps() { + return createSelector( + (state) => state.artistIndex.posterOptions, + createUISettingsSelector(), + createDimensionsSelector(), + (posterOptions, uiSettings, dimensions) => { + return { + posterOptions, + showRelativeDates: uiSettings.showRelativeDates, + shortDateFormat: uiSettings.shortDateFormat, + timeFormat: uiSettings.timeFormat, + isSmallScreen: dimensions.isSmallScreen + }; + } + ); +} + +export default connect(createMapStateToProps)(ArtistIndexPosters); diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.js b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.js new file mode 100644 index 000000000..e1b0a257a --- /dev/null +++ b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ArtistIndexPosterOptionsModalContentConnector from './ArtistIndexPosterOptionsModalContentConnector'; + +function ArtistIndexPosterOptionsModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +ArtistIndexPosterOptionsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default ArtistIndexPosterOptionsModal; diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.js b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.js new file mode 100644 index 000000000..6918436a6 --- /dev/null +++ b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.js @@ -0,0 +1,213 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +const posterSizeOptions = [ + { key: 'small', value: 'Small' }, + { key: 'medium', value: 'Medium' }, + { key: 'large', value: 'Large' } +]; + +class ArtistIndexPosterOptionsModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + detailedProgressBar: props.detailedProgressBar, + size: props.size, + showTitle: props.showTitle, + showMonitored: props.showMonitored, + showQualityProfile: props.showQualityProfile, + showSearchAction: props.showSearchAction + }; + } + + componentDidUpdate(prevProps) { + const { + detailedProgressBar, + size, + showTitle, + showMonitored, + showQualityProfile, + showSearchAction + } = this.props; + + const state = {}; + + if (detailedProgressBar !== prevProps.detailedProgressBar) { + state.detailedProgressBar = detailedProgressBar; + } + + if (size !== prevProps.size) { + state.size = size; + } + + if (showTitle !== prevProps.showTitle) { + state.showTitle = showTitle; + } + + if (showMonitored !== prevProps.showMonitored) { + state.showMonitored = showMonitored; + } + + if (showQualityProfile !== prevProps.showQualityProfile) { + state.showQualityProfile = showQualityProfile; + } + + if (showSearchAction !== prevProps.showSearchAction) { + state.showSearchAction = showSearchAction; + } + + if (!_.isEmpty(state)) { + this.setState(state); + } + } + + // + // Listeners + + onChangePosterOption = ({ name, value }) => { + this.setState({ + [name]: value + }, () => { + this.props.onChangePosterOption({ [name]: value }); + }); + } + + // + // Render + + render() { + const { + onModalClose + } = this.props; + + const { + detailedProgressBar, + size, + showTitle, + showMonitored, + showQualityProfile, + showSearchAction + } = this.state; + + return ( + + + Poster Options + + + +
+ + Poster Size + + + + + + Detailed Progress Bar + + + + + + Show Name + + + + + + Show Monitored + + + + + + Show Quality Profile + + + + + + Show Search + + + +
+
+ + + + +
+ ); + } +} + +ArtistIndexPosterOptionsModalContent.propTypes = { + size: PropTypes.string.isRequired, + showTitle: PropTypes.bool.isRequired, + showMonitored: PropTypes.bool.isRequired, + showQualityProfile: PropTypes.bool.isRequired, + detailedProgressBar: PropTypes.bool.isRequired, + showSearchAction: PropTypes.bool.isRequired, + onChangePosterOption: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default ArtistIndexPosterOptionsModalContent; diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContentConnector.js b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContentConnector.js new file mode 100644 index 000000000..72af268ad --- /dev/null +++ b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContentConnector.js @@ -0,0 +1,23 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setArtistPosterOption } from 'Store/Actions/artistIndexActions'; +import ArtistIndexPosterOptionsModalContent from './ArtistIndexPosterOptionsModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.artistIndex, + (artistIndex) => { + return artistIndex.posterOptions; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onChangePosterOption(payload) { + dispatch(setArtistPosterOption(payload)); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexPosterOptionsModalContent); diff --git a/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.css b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.css new file mode 100644 index 000000000..b98bb33d5 --- /dev/null +++ b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.css @@ -0,0 +1,14 @@ +.progress { + composes: container from '~Components/ProgressBar.css'; + + border-radius: 0; + background-color: #5b5b5b; + color: $white; + transition: width 200ms ease; +} + +.progressBar { + composes: progressBar from '~Components/ProgressBar.css'; + + transition: width 200ms ease; +} diff --git a/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.js b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.js new file mode 100644 index 000000000..6be32a46d --- /dev/null +++ b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.js @@ -0,0 +1,47 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import getProgressBarKind from 'Utilities/Artist/getProgressBarKind'; +import { sizes } from 'Helpers/Props'; +import ProgressBar from 'Components/ProgressBar'; +import styles from './ArtistIndexProgressBar.css'; + +function ArtistIndexProgressBar(props) { + const { + monitored, + status, + trackCount, + trackFileCount, + totalTrackCount, + posterWidth, + detailedProgressBar + } = props; + + const progress = trackCount ? trackFileCount / trackCount * 100 : 100; + const text = `${trackFileCount} / ${trackCount}`; + + return ( + + ); +} + +ArtistIndexProgressBar.propTypes = { + monitored: PropTypes.bool.isRequired, + status: PropTypes.string.isRequired, + trackCount: PropTypes.number.isRequired, + trackFileCount: PropTypes.number.isRequired, + totalTrackCount: PropTypes.number.isRequired, + posterWidth: PropTypes.number.isRequired, + detailedProgressBar: PropTypes.bool.isRequired +}; + +export default ArtistIndexProgressBar; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexActionsCell.js b/frontend/src/Artist/Index/Table/ArtistIndexActionsCell.js new file mode 100644 index 000000000..3f37cd56a --- /dev/null +++ b/frontend/src/Artist/Index/Table/ArtistIndexActionsCell.js @@ -0,0 +1,102 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; +import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; +import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; + +class ArtistIndexActionsCell extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditArtistModalOpen: false, + isDeleteArtistModalOpen: false + }; + } + + // + // Listeners + + onEditArtistPress = () => { + this.setState({ isEditArtistModalOpen: true }); + } + + onEditArtistModalClose = () => { + this.setState({ isEditArtistModalOpen: false }); + } + + onDeleteArtistPress = () => { + this.setState({ + isEditArtistModalOpen: false, + isDeleteArtistModalOpen: true + }); + } + + onDeleteArtistModalClose = () => { + this.setState({ isDeleteArtistModalOpen: false }); + } + + // + // Render + + render() { + const { + id, + isRefreshingArtist, + onRefreshArtistPress, + ...otherProps + } = this.props; + + const { + isEditArtistModalOpen, + isDeleteArtistModalOpen + } = this.state; + + return ( + + + + + + + + + + ); + } +} + +ArtistIndexActionsCell.propTypes = { + id: PropTypes.number.isRequired, + isRefreshingArtist: PropTypes.bool.isRequired, + onRefreshArtistPress: PropTypes.func.isRequired +}; + +export default ArtistIndexActionsCell; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexHeader.css b/frontend/src/Artist/Index/Table/ArtistIndexHeader.css new file mode 100644 index 000000000..6da0be920 --- /dev/null +++ b/frontend/src/Artist/Index/Table/ArtistIndexHeader.css @@ -0,0 +1,96 @@ +.status { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 60px; +} + +.sortName { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 4 0 110px; +} + +.banner { + flex: 0 0 379px; +} + +.bannerGrow { + flex-grow: 1; +} + +.artistType { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 100px; +} + +.qualityProfileId, +.metadataProfileId { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 1 0 125px; +} + +.nextAlbum, +.lastAlbum, +.added, +.genres { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 180px; +} + +.albumCount { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 100px; +} + +.trackProgress, +.latestAlbum { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 150px; +} + +.trackCount { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 130px; +} + +.path { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 1 0 150px; +} + +.sizeOnDisk { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 120px; +} + +.ratings { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 80px; +} + +.tags { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 1 0 60px; +} + +.useSceneNumbering { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 145px; +} + +.actions { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 1 90px; +} diff --git a/frontend/src/Artist/Index/Table/ArtistIndexHeader.js b/frontend/src/Artist/Index/Table/ArtistIndexHeader.js new file mode 100644 index 000000000..aed47bafa --- /dev/null +++ b/frontend/src/Artist/Index/Table/ArtistIndexHeader.js @@ -0,0 +1,86 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import VirtualTableHeader from 'Components/Table/VirtualTableHeader'; +import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import hasGrowableColumns from './hasGrowableColumns'; +import ArtistIndexTableOptionsConnector from './ArtistIndexTableOptionsConnector'; +import styles from './ArtistIndexHeader.css'; + +function ArtistIndexHeader(props) { + const { + showBanners, + columns, + onTableOptionChange, + ...otherProps + } = props; + + return ( + + { + columns.map((column) => { + const { + name, + label, + isSortable, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'actions') { + return ( + + + + + + + ); + } + + return ( + + {label} + + ); + }) + } + + ); +} + +ArtistIndexHeader.propTypes = { + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + onTableOptionChange: PropTypes.func.isRequired, + showBanners: PropTypes.bool.isRequired +}; + +export default ArtistIndexHeader; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexHeaderConnector.js b/frontend/src/Artist/Index/Table/ArtistIndexHeaderConnector.js new file mode 100644 index 000000000..37ddd9ef3 --- /dev/null +++ b/frontend/src/Artist/Index/Table/ArtistIndexHeaderConnector.js @@ -0,0 +1,13 @@ +import { connect } from 'react-redux'; +import { setArtistTableOption } from 'Store/Actions/artistIndexActions'; +import ArtistIndexHeader from './ArtistIndexHeader'; + +function createMapDispatchToProps(dispatch, props) { + return { + onTableOptionChange(payload) { + dispatch(setArtistTableOption(payload)); + } + }; +} + +export default connect(undefined, createMapDispatchToProps)(ArtistIndexHeader); diff --git a/frontend/src/Artist/Index/Table/ArtistIndexRow.css b/frontend/src/Artist/Index/Table/ArtistIndexRow.css new file mode 100644 index 000000000..29c89c696 --- /dev/null +++ b/frontend/src/Artist/Index/Table/ArtistIndexRow.css @@ -0,0 +1,141 @@ +.cell { + composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css'; + + display: flex; + align-items: center; +} + +.status { + composes: cell; + + flex: 0 0 60px; +} + +.sortName { + composes: cell; + + flex: 4 0 110px; +} + +.artistType { + composes: cell; + + flex: 0 0 100px; +} + +.banner { + flex: 0 0 379px; +} + +.bannerGrow { + flex-grow: 1; +} + +.link { + composes: link from '~Components/Link/Link.css'; + + position: relative; + display: block; + height: 70px; + background-color: $defaultColor; +} + +.bannerImage { + width: 379px; + height: 70px; +} + +.overlayTitle { + position: absolute; + top: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 5px; + width: 100%; + height: 100%; + color: $offWhite; + text-align: center; + font-size: 20px; +} + +.qualityProfileId, +.metadataProfileId { + composes: cell; + + flex: 1 0 125px; +} + +.nextAlbum, +.lastAlbum, +.added, +.genres { + composes: cell; + + flex: 0 0 180px; +} + +.albumCount { + composes: cell; + + flex: 0 0 100px; +} + +.trackProgress { + composes: cell; + + display: flex; + justify-content: center; + flex: 0 0 150px; + flex-direction: column; +} + +.trackCount { + composes: cell; + + flex: 0 0 130px; +} + +.path { + composes: cell; + + flex: 1 0 150px; +} + +.sizeOnDisk { + composes: cell; + + flex: 0 0 120px; +} + +.ratings { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 80px; +} + +.tags { + composes: cell; + + flex: 1 0 60px; +} + +.useSceneNumbering { + composes: cell; + + flex: 0 0 145px; +} + +.actions { + composes: cell; + + flex: 0 1 90px; + min-width: 60px; +} + +.checkInput { + composes: input from '~Components/Form/CheckInput.css'; + + margin-top: 0; +} diff --git a/frontend/src/Artist/Index/Table/ArtistIndexRow.js b/frontend/src/Artist/Index/Table/ArtistIndexRow.js new file mode 100644 index 000000000..6b597509f --- /dev/null +++ b/frontend/src/Artist/Index/Table/ArtistIndexRow.js @@ -0,0 +1,484 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import getProgressBarKind from 'Utilities/Artist/getProgressBarKind'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { icons } from 'Helpers/Props'; +import HeartRating from 'Components/HeartRating'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import ProgressBar from 'Components/ProgressBar'; +import TagListConnector from 'Components/TagListConnector'; +// import CheckInput from 'Components/Form/CheckInput'; +import VirtualTableRow from 'Components/Table/VirtualTableRow'; +import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import ArtistNameLink from 'Artist/ArtistNameLink'; +import AlbumTitleLink from 'Album/AlbumTitleLink'; +import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; +import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; +import ArtistBanner from 'Artist/ArtistBanner'; +import hasGrowableColumns from './hasGrowableColumns'; +import ArtistStatusCell from './ArtistStatusCell'; +import styles from './ArtistIndexRow.css'; + +class ArtistIndexRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + hasBannerError: false, + isEditArtistModalOpen: false, + isDeleteArtistModalOpen: false + }; + } + + onEditArtistPress = () => { + this.setState({ isEditArtistModalOpen: true }); + } + + onEditArtistModalClose = () => { + this.setState({ isEditArtistModalOpen: false }); + } + + onDeleteArtistPress = () => { + this.setState({ + isEditArtistModalOpen: false, + isDeleteArtistModalOpen: true + }); + } + + onDeleteArtistModalClose = () => { + this.setState({ isDeleteArtistModalOpen: false }); + } + + onUseSceneNumberingChange = () => { + // Mock handler to satisfy `onChange` being required for `CheckInput`. + // + } + + onBannerLoad = () => { + if (this.state.hasBannerError) { + this.setState({ hasBannerError: false }); + } + } + + onBannerLoadError = () => { + if (!this.state.hasBannerError) { + this.setState({ hasBannerError: true }); + } + } + + // + // Render + + render() { + const { + style, + id, + monitored, + status, + artistName, + foreignArtistId, + artistType, + qualityProfile, + metadataProfile, + nextAlbum, + lastAlbum, + added, + statistics, + genres, + ratings, + path, + tags, + images, + showBanners, + showSearchAction, + columns, + isRefreshingArtist, + isSearchingArtist, + onRefreshArtistPress, + onSearchPress + } = this.props; + + const { + albumCount, + trackCount, + trackFileCount, + totalTrackCount, + sizeOnDisk + } = statistics; + + const { + hasBannerError, + isEditArtistModalOpen, + isDeleteArtistModalOpen + } = this.state; + + return ( + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'status') { + return ( + + ); + } + + if (name === 'sortName') { + return ( + + { + showBanners ? + + + + { + hasBannerError && +
+ {artistName} +
+ } + : + + + } +
+ ); + } + + if (name === 'artistType') { + return ( + + {artistType} + + ); + } + + if (name === 'qualityProfileId') { + return ( + + {qualityProfile.name} + + ); + } + + if (name === 'metadataProfileId') { + return ( + + {metadataProfile.name} + + ); + } + + if (name === 'nextAlbum') { + if (nextAlbum) { + return ( + + + + ); + } + return ( + + None + + ); + } + + if (name === 'lastAlbum') { + if (lastAlbum) { + return ( + + + + ); + } + return ( + + None + + ); + } + + if (name === 'added') { + return ( + + ); + } + + if (name === 'albumCount') { + return ( + + {albumCount} + + ); + } + + if (name === 'trackProgress') { + const progress = trackCount ? trackFileCount / trackCount * 100 : 100; + + return ( + + + + ); + } + + if (name === 'trackCount') { + return ( + + {totalTrackCount} + + ); + } + + if (name === 'path') { + return ( + + {path} + + ); + } + + if (name === 'sizeOnDisk') { + return ( + + {formatBytes(sizeOnDisk)} + + ); + } + + if (name === 'genres') { + const joinedGenres = genres.join(', '); + + return ( + + + {joinedGenres} + + + ); + } + + if (name === 'ratings') { + return ( + + + + ); + } + + if (name === 'tags') { + return ( + + + + ); + } + + if (name === 'actions') { + return ( + + + + { + showSearchAction && + + } + + + + ); + } + + return null; + }) + } + + + + +
+ ); + } +} + +ArtistIndexRow.propTypes = { + style: PropTypes.object.isRequired, + id: PropTypes.number.isRequired, + monitored: PropTypes.bool.isRequired, + status: PropTypes.string.isRequired, + artistName: PropTypes.string.isRequired, + foreignArtistId: PropTypes.string.isRequired, + artistType: PropTypes.string, + qualityProfile: PropTypes.object.isRequired, + metadataProfile: PropTypes.object.isRequired, + nextAlbum: PropTypes.object, + lastAlbum: PropTypes.object, + added: PropTypes.string, + statistics: PropTypes.object.isRequired, + latestAlbum: PropTypes.object, + path: PropTypes.string.isRequired, + genres: PropTypes.arrayOf(PropTypes.string).isRequired, + ratings: PropTypes.object.isRequired, + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + showBanners: PropTypes.bool.isRequired, + showSearchAction: PropTypes.bool.isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + isRefreshingArtist: PropTypes.bool.isRequired, + isSearchingArtist: PropTypes.bool.isRequired, + onRefreshArtistPress: PropTypes.func.isRequired, + onSearchPress: PropTypes.func.isRequired +}; + +ArtistIndexRow.defaultProps = { + statistics: { + albumCount: 0, + trackCount: 0, + trackFileCount: 0, + totalTrackCount: 0 + }, + genres: [], + tags: [] +}; + +export default ArtistIndexRow; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTable.css b/frontend/src/Artist/Index/Table/ArtistIndexTable.css new file mode 100644 index 000000000..23ab127b5 --- /dev/null +++ b/frontend/src/Artist/Index/Table/ArtistIndexTable.css @@ -0,0 +1,5 @@ +.tableContainer { + composes: tableContainer from '~Components/Table/VirtualTable.css'; + + flex: 1 0 auto; +} diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTable.js b/frontend/src/Artist/Index/Table/ArtistIndexTable.js new file mode 100644 index 000000000..fcece7a2c --- /dev/null +++ b/frontend/src/Artist/Index/Table/ArtistIndexTable.js @@ -0,0 +1,132 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; +import { sortDirections } from 'Helpers/Props'; +import VirtualTable from 'Components/Table/VirtualTable'; +import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector'; +import ArtistIndexHeaderConnector from './ArtistIndexHeaderConnector'; +import ArtistIndexRow from './ArtistIndexRow'; +import styles from './ArtistIndexTable.css'; + +class ArtistIndexTable extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + scrollIndex: null + }; + } + + componentDidUpdate(prevProps) { + const jumpToCharacter = this.props.jumpToCharacter; + + if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { + const items = this.props.items; + + const scrollIndex = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (scrollIndex != null) { + this.setState({ scrollIndex }); + } + } else if (jumpToCharacter == null && prevProps.jumpToCharacter != null) { + this.setState({ scrollIndex: null }); + } + } + + // + // Control + + rowRenderer = ({ key, rowIndex, style }) => { + const { + items, + columns, + showBanners + } = this.props; + + const artist = items[rowIndex]; + + return ( + + ); + } + + // + // Render + + render() { + const { + items, + columns, + filters, + sortKey, + sortDirection, + showBanners, + isSmallScreen, + scrollTop, + contentBody, + onSortPress, + onRender, + onScroll + } = this.props; + + return ( + + } + columns={columns} + filters={filters} + sortKey={sortKey} + sortDirection={sortDirection} + onRender={onRender} + onScroll={onScroll} + /> + ); + } +} + +ArtistIndexTable.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + showBanners: PropTypes.bool.isRequired, + scrollTop: PropTypes.number.isRequired, + jumpToCharacter: PropTypes.string, + contentBody: PropTypes.object.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + onSortPress: PropTypes.func.isRequired, + onRender: PropTypes.func.isRequired, + onScroll: PropTypes.func.isRequired +}; + +export default ArtistIndexTable; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTableConnector.js b/frontend/src/Artist/Index/Table/ArtistIndexTableConnector.js new file mode 100644 index 000000000..3a97425cc --- /dev/null +++ b/frontend/src/Artist/Index/Table/ArtistIndexTableConnector.js @@ -0,0 +1,29 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setArtistSort } from 'Store/Actions/artistIndexActions'; +import ArtistIndexTable from './ArtistIndexTable'; + +function createMapStateToProps() { + return createSelector( + (state) => state.app.dimensions, + (state) => state.artistIndex.tableOptions, + (state) => state.artistIndex.columns, + (dimensions, tableOptions, columns) => { + return { + isSmallScreen: dimensions.isSmallScreen, + showBanners: tableOptions.showBanners, + columns + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onSortPress(sortKey) { + dispatch(setArtistSort({ sortKey })); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexTable); diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTableOptions.js b/frontend/src/Artist/Index/Table/ArtistIndexTableOptions.js new file mode 100644 index 000000000..110a024e4 --- /dev/null +++ b/frontend/src/Artist/Index/Table/ArtistIndexTableOptions.js @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import React, { Component, Fragment } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +class ArtistIndexTableOptions extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + showBanners: props.showBanners, + showSearchAction: props.showSearchAction + }; + } + + componentDidUpdate(prevProps) { + const { + showBanners, + showSearchAction + } = this.props; + + if ( + showBanners !== prevProps.showBanners || + showSearchAction !== prevProps.showSearchAction + ) { + this.setState({ + showBanners, + showSearchAction + }); + } + } + + // + // Listeners + + onTableOptionChange = ({ name, value }) => { + this.setState({ + [name]: value + }, () => { + this.props.onTableOptionChange({ + tableOptions: { + ...this.state, + [name]: value + } + }); + }); + } + + // + // Render + + render() { + const { + showBanners, + showSearchAction + } = this.state; + + return ( + + + Show Banners + + + + + + Show Search + + + + + ); + } +} + +ArtistIndexTableOptions.propTypes = { + showBanners: PropTypes.bool.isRequired, + showSearchAction: PropTypes.bool.isRequired, + onTableOptionChange: PropTypes.func.isRequired +}; + +export default ArtistIndexTableOptions; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTableOptionsConnector.js b/frontend/src/Artist/Index/Table/ArtistIndexTableOptionsConnector.js new file mode 100644 index 000000000..0a1607cf2 --- /dev/null +++ b/frontend/src/Artist/Index/Table/ArtistIndexTableOptionsConnector.js @@ -0,0 +1,14 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import ArtistIndexTableOptions from './ArtistIndexTableOptions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.artistIndex.tableOptions, + (tableOptions) => { + return tableOptions; + } + ); +} + +export default connect(createMapStateToProps)(ArtistIndexTableOptions); diff --git a/frontend/src/Artist/Index/Table/ArtistStatusCell.css b/frontend/src/Artist/Index/Table/ArtistStatusCell.css new file mode 100644 index 000000000..fbcd5eee9 --- /dev/null +++ b/frontend/src/Artist/Index/Table/ArtistStatusCell.css @@ -0,0 +1,9 @@ +.status { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 60px; +} + +.statusIcon { + width: 20px !important; +} diff --git a/frontend/src/Artist/Index/Table/ArtistStatusCell.js b/frontend/src/Artist/Index/Table/ArtistStatusCell.js new file mode 100644 index 000000000..26fde0e12 --- /dev/null +++ b/frontend/src/Artist/Index/Table/ArtistStatusCell.js @@ -0,0 +1,53 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './ArtistStatusCell.css'; + +function ArtistStatusCell(props) { + const { + className, + artistType, + monitored, + status, + component: Component, + ...otherProps + } = props; + + const endedString = artistType === 'Person' ? 'Deceased' : 'Ended'; + + return ( + + + + + + ); +} + +ArtistStatusCell.propTypes = { + className: PropTypes.string.isRequired, + artistType: PropTypes.string, + monitored: PropTypes.bool.isRequired, + status: PropTypes.string.isRequired, + component: PropTypes.elementType +}; + +ArtistStatusCell.defaultProps = { + className: styles.status, + component: VirtualTableRowCell +}; + +export default ArtistStatusCell; diff --git a/frontend/src/Artist/Index/Table/hasGrowableColumns.js b/frontend/src/Artist/Index/Table/hasGrowableColumns.js new file mode 100644 index 000000000..994436d9f --- /dev/null +++ b/frontend/src/Artist/Index/Table/hasGrowableColumns.js @@ -0,0 +1,16 @@ +const growableColumns = [ + 'qualityProfileId', + 'path', + 'tags' +]; + +export default function hasGrowableColumns(columns) { + return columns.some((column) => { + const { + name, + isVisible + } = column; + + return growableColumns.includes(name) && isVisible; + }); +} diff --git a/frontend/src/Artist/MoveArtist/MoveArtistModal.css b/frontend/src/Artist/MoveArtist/MoveArtistModal.css new file mode 100644 index 000000000..c1e247a50 --- /dev/null +++ b/frontend/src/Artist/MoveArtist/MoveArtistModal.css @@ -0,0 +1,5 @@ +.doNotMoveButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Artist/MoveArtist/MoveArtistModal.js b/frontend/src/Artist/MoveArtist/MoveArtistModal.js new file mode 100644 index 000000000..3f78187ff --- /dev/null +++ b/frontend/src/Artist/MoveArtist/MoveArtistModal.js @@ -0,0 +1,83 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds, sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './MoveArtistModal.css'; + +function MoveArtistModal(props) { + const { + originalPath, + destinationPath, + destinationRootFolder, + isOpen, + onSavePress, + onMoveArtistPress + } = props; + + if ( + isOpen && + !originalPath && + !destinationPath && + !destinationRootFolder + ) { + console.error('orginalPath and destinationPath OR destinationRootFolder must be provided'); + } + + return ( + + + + Move Files + + + + { + destinationRootFolder ? + `Would you like to move the artist folders to '${destinationRootFolder}'?` : + `Would you like to move the artist files from '${originalPath}' to '${destinationPath}'?` + } + + + + + + + + + + ); +} + +MoveArtistModal.propTypes = { + originalPath: PropTypes.string, + destinationPath: PropTypes.string, + destinationRootFolder: PropTypes.string, + isOpen: PropTypes.bool.isRequired, + onSavePress: PropTypes.func.isRequired, + onMoveArtistPress: PropTypes.func.isRequired +}; + +export default MoveArtistModal; diff --git a/frontend/src/Artist/NoArtist.css b/frontend/src/Artist/NoArtist.css new file mode 100644 index 000000000..38a01f391 --- /dev/null +++ b/frontend/src/Artist/NoArtist.css @@ -0,0 +1,11 @@ +.message { + margin-top: 10px; + margin-bottom: 30px; + text-align: center; + font-size: 20px; +} + +.buttonContainer { + margin-top: 20px; + text-align: center; +} diff --git a/frontend/src/Artist/NoArtist.js b/frontend/src/Artist/NoArtist.js new file mode 100644 index 000000000..b869a8d58 --- /dev/null +++ b/frontend/src/Artist/NoArtist.js @@ -0,0 +1,51 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import styles from './NoArtist.css'; + +function NoArtist(props) { + const { totalItems } = props; + + if (totalItems > 0) { + return ( +
+
+ All artists are hidden due to the applied filter. +
+
+ ); + } + + return ( +
+
+ No artist found, to get started you'll want to add a new artist or import some existing ones. +
+ +
+ +
+ +
+ +
+
+ ); +} + +NoArtist.propTypes = { + totalItems: PropTypes.number.isRequired +}; + +export default NoArtist; diff --git a/frontend/src/Artist/Search/ArtistInteractiveSearchModal.js b/frontend/src/Artist/Search/ArtistInteractiveSearchModal.js new file mode 100644 index 000000000..0da3661a8 --- /dev/null +++ b/frontend/src/Artist/Search/ArtistInteractiveSearchModal.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ArtistInteractiveSearchModalContent from './ArtistInteractiveSearchModalContent'; + +function ArtistInteractiveSearchModal(props) { + const { + isOpen, + artistId, + onModalClose + } = props; + + return ( + + + + ); +} + +ArtistInteractiveSearchModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + artistId: PropTypes.number.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default ArtistInteractiveSearchModal; diff --git a/frontend/src/Artist/Search/ArtistInteractiveSearchModalConnector.js b/frontend/src/Artist/Search/ArtistInteractiveSearchModalConnector.js new file mode 100644 index 000000000..fe3170570 --- /dev/null +++ b/frontend/src/Artist/Search/ArtistInteractiveSearchModalConnector.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions'; +import ArtistInteractiveSearchModal from './ArtistInteractiveSearchModal'; + +function createMapDispatchToProps(dispatch, props) { + return { + onModalClose() { + dispatch(cancelFetchReleases()); + dispatch(clearReleases()); + props.onModalClose(); + } + }; +} + +export default connect(null, createMapDispatchToProps)(ArtistInteractiveSearchModal); diff --git a/frontend/src/Artist/Search/ArtistInteractiveSearchModalContent.js b/frontend/src/Artist/Search/ArtistInteractiveSearchModalContent.js new file mode 100644 index 000000000..9b7f4c6ed --- /dev/null +++ b/frontend/src/Artist/Search/ArtistInteractiveSearchModalContent.js @@ -0,0 +1,45 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import InteractiveSearchConnector from 'InteractiveSearch/InteractiveSearchConnector'; + +function ArtistInteractiveSearchModalContent(props) { + const { + artistId, + onModalClose + } = props; + + return ( + + + Interactive Search + + + + + + + + + + + ); +} + +ArtistInteractiveSearchModalContent.propTypes = { + artistId: PropTypes.number.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default ArtistInteractiveSearchModalContent; diff --git a/frontend/src/Calendar/Agenda/Agenda.css b/frontend/src/Calendar/Agenda/Agenda.css new file mode 100644 index 000000000..0304d9db5 --- /dev/null +++ b/frontend/src/Calendar/Agenda/Agenda.css @@ -0,0 +1,3 @@ +.agenda { + margin-top: 10px; +} diff --git a/frontend/src/Calendar/Agenda/Agenda.js b/frontend/src/Calendar/Agenda/Agenda.js new file mode 100644 index 000000000..33d02cd79 --- /dev/null +++ b/frontend/src/Calendar/Agenda/Agenda.js @@ -0,0 +1,38 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React from 'react'; +import AgendaEventConnector from './AgendaEventConnector'; +import styles from './Agenda.css'; + +function Agenda(props) { + const { + items + } = props; + + return ( +
+ { + items.map((item, index) => { + const momentDate = moment(item.releaseDate); + const showDate = index === 0 || + !moment(items[index - 1].releaseDate).isSame(momentDate, 'day'); + + return ( + + ); + }) + } +
+ ); +} + +Agenda.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default Agenda; diff --git a/frontend/src/Calendar/Agenda/AgendaConnector.js b/frontend/src/Calendar/Agenda/AgendaConnector.js new file mode 100644 index 000000000..b6f238873 --- /dev/null +++ b/frontend/src/Calendar/Agenda/AgendaConnector.js @@ -0,0 +1,14 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import Agenda from './Agenda'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar, + (calendar) => { + return calendar; + } + ); +} + +export default connect(createMapStateToProps)(Agenda); diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.css b/frontend/src/Calendar/Agenda/AgendaEvent.css new file mode 100644 index 000000000..876c9fc75 --- /dev/null +++ b/frontend/src/Calendar/Agenda/AgendaEvent.css @@ -0,0 +1,113 @@ +.event { + display: flex; + overflow-x: hidden; + padding: 5px; + border-bottom: 1px solid $borderColor; + font-size: $defaultFontSize; + + &:hover { + background-color: $tableRowHoverBackgroundColor; + } +} + +.eventWrapper { + display: flex; + flex: 1 0 1px; + overflow-x: hidden; + padding-left: 6px; + border-left-width: 4px; + border-left-style: solid; +} + +.date { + flex: 0 0 250px; + font-weight: bold; +} + +.time { + flex: 0 0 120px; + margin-right: 10px; + border: none !important; +} + +.artistName, +.albumTitle { + @add-mixin truncate; + + flex: 0 1 300px; + margin-right: 10px; +} + +.albumTitle { + flex: 1 1 1px; +} + +.seasonEpisodeNumber { + flex: 0 0 100px; +} + +.albumSeparator { + display: none; +} + +.absoluteEpisodeNumber { + margin-left: 3px; +} + +/* + * Status + */ + +.downloaded { + composes: downloaded from '~Calendar/Events/CalendarEvent.css'; +} + +.partial { + composes: partial from '~Calendar/Events/CalendarEvent.css'; +} + +.downloading { + composes: downloading from '~Calendar/Events/CalendarEvent.css'; +} + +.unmonitored { + composes: unmonitored from '~Calendar/Events/CalendarEvent.css'; +} + +.missing { + composes: missing from '~Calendar/Events/CalendarEvent.css'; +} + +.unreleased { + composes: unreleased from '~Calendar/Events/CalendarEvent.css'; +} + +@media only screen and (max-width: $breakpointSmall) { + .event { + flex-direction: column; + } + + .eventWrapper { + display: block; + flex: 0 0 auto; + } + + .date { + margin-left: 10px; + } + + .date, + .time, + .artistName { + flex: 0 0 100%; + } + + .seasonEpisodeNumber { + flex: 0 0 auto; + } + + .albumSeparator { + display: inline-block; + margin: 0 5px; + } +} diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.js b/frontend/src/Calendar/Agenda/AgendaEvent.js new file mode 100644 index 000000000..44ad53063 --- /dev/null +++ b/frontend/src/Calendar/Agenda/AgendaEvent.js @@ -0,0 +1,145 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import formatTime from 'Utilities/Date/formatTime'; +import { icons } from 'Helpers/Props'; +import getStatusStyle from 'Calendar/getStatusStyle'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails'; +import styles from './AgendaEvent.css'; + +class AgendaEvent extends Component { + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + // + // Listeners + + onPress = () => { + this.setState({ isDetailsModalOpen: true }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + // + // Render + + render() { + const { + id, + artist, + title, + foreignAlbumId, + releaseDate, + monitored, + statistics, + grabbed, + queueItem, + showDate, + timeFormat, + longDateFormat, + colorImpairedMode + } = this.props; + + const startTime = moment(releaseDate); + // const endTime = startTime.add(artist.runtime, 'minutes'); + const downloading = !!(queueItem || grabbed); + const isMonitored = artist.monitored && monitored; + const statusStyle = getStatusStyle(id, downloading, startTime, isMonitored, statistics.percentOfTracks); + + return ( +
+ +
+ { + showDate && + startTime.format(longDateFormat) + } +
+ +
+ +
+ {formatTime(releaseDate, timeFormat)} +
+ +
+ + {artist.artistName} + +
+ +
-
+ +
+ + {title} + +
+ + { + !!queueItem && + + } + + { + !queueItem && grabbed && + + } + +
+ ); + } +} + +AgendaEvent.propTypes = { + id: PropTypes.number.isRequired, + artist: PropTypes.object.isRequired, + title: PropTypes.string.isRequired, + foreignAlbumId: PropTypes.string.isRequired, + albumType: PropTypes.string.isRequired, + releaseDate: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + statistics: PropTypes.object.isRequired, + grabbed: PropTypes.bool, + queueItem: PropTypes.object, + showDate: PropTypes.bool.isRequired, + timeFormat: PropTypes.string.isRequired, + colorImpairedMode: PropTypes.bool.isRequired, + longDateFormat: PropTypes.string.isRequired +}; + +AgendaEvent.defaultProps = { + statistics: { + percentOfTracks: 0 + } +}; + +export default AgendaEvent; diff --git a/frontend/src/Calendar/Agenda/AgendaEventConnector.js b/frontend/src/Calendar/Agenda/AgendaEventConnector.js new file mode 100644 index 000000000..b0ab00f1b --- /dev/null +++ b/frontend/src/Calendar/Agenda/AgendaEventConnector.js @@ -0,0 +1,25 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createArtistSelector from 'Store/Selectors/createArtistSelector'; +import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import AgendaEvent from './AgendaEvent'; + +function createMapStateToProps() { + return createSelector( + createArtistSelector(), + createQueueItemSelector(), + createUISettingsSelector(), + (artist, queueItem, uiSettings) => { + return { + artist, + queueItem, + timeFormat: uiSettings.timeFormat, + longDateFormat: uiSettings.longDateFormat, + colorImpairedMode: uiSettings.enableColorImpairedMode + }; + } + ); +} + +export default connect(createMapStateToProps)(AgendaEvent); diff --git a/frontend/src/Calendar/Calendar.css b/frontend/src/Calendar/Calendar.css new file mode 100644 index 000000000..37e6ff618 --- /dev/null +++ b/frontend/src/Calendar/Calendar.css @@ -0,0 +1,8 @@ +.calendar { + flex-grow: 1; + width: 100%; +} + +.calendarContent { + width: 100%; +} diff --git a/frontend/src/Calendar/Calendar.js b/frontend/src/Calendar/Calendar.js new file mode 100644 index 000000000..6ceb1f3bb --- /dev/null +++ b/frontend/src/Calendar/Calendar.js @@ -0,0 +1,64 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import * as calendarViews from './calendarViews'; +import CalendarHeaderConnector from './Header/CalendarHeaderConnector'; +import DaysOfWeekConnector from './Day/DaysOfWeekConnector'; +import CalendarDaysConnector from './Day/CalendarDaysConnector'; +import AgendaConnector from './Agenda/AgendaConnector'; +import styles from './Calendar.css'; + +class Calendar extends Component { + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + view + } = this.props; + + return ( +
+ { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
Unable to load the calendar
+ } + + { + !error && isPopulated && view === calendarViews.AGENDA && +
+ + +
+ } + + { + !error && isPopulated && view !== calendarViews.AGENDA && +
+ + + +
+ } +
+ ); + } +} + +Calendar.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + view: PropTypes.string.isRequired +}; + +export default Calendar; diff --git a/frontend/src/Calendar/CalendarConnector.js b/frontend/src/Calendar/CalendarConnector.js new file mode 100644 index 000000000..a97589c59 --- /dev/null +++ b/frontend/src/Calendar/CalendarConnector.js @@ -0,0 +1,174 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import * as calendarActions from 'Store/Actions/calendarActions'; +import { fetchTrackFiles, clearTrackFiles } from 'Store/Actions/trackFileActions'; +import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions'; +import Calendar from './Calendar'; + +const UPDATE_DELAY = 3600000; // 1 hour + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar, + (calendar) => { + return calendar; + } + ); +} + +const mapDispatchToProps = { + ...calendarActions, + fetchTrackFiles, + clearTrackFiles, + fetchQueueDetails, + clearQueueDetails +}; + +class CalendarConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.updateTimeoutId = null; + } + + componentDidMount() { + const { + useCurrentPage, + fetchCalendar, + gotoCalendarToday + } = this.props; + + registerPagePopulator(this.repopulate); + + if (useCurrentPage) { + fetchCalendar(); + } else { + gotoCalendarToday(); + } + + this.scheduleUpdate(); + } + + componentDidUpdate(prevProps) { + const { + items, + time + } = this.props; + + if (hasDifferentItems(prevProps.items, items)) { + const albumIds = selectUniqueIds(items, 'id'); + // const trackFileIds = selectUniqueIds(items, 'trackFileId'); + + if (items.length) { + this.props.fetchQueueDetails({ albumIds }); + } + + // if (trackFileIds.length) { + // this.props.fetchTrackFiles({ trackFileIds }); + // } + } + + if (prevProps.time !== time) { + this.scheduleUpdate(); + } + } + + componentWillUnmount() { + unregisterPagePopulator(this.repopulate); + this.props.clearCalendar(); + this.props.clearQueueDetails(); + this.props.clearTrackFiles(); + this.clearUpdateTimeout(); + } + + // + // Control + repopulate = () => { + const { + time, + view + } = this.props; + + this.props.fetchQueueDetails({ time, view }); + this.props.fetchCalendar({ time, view }); + } + + scheduleUpdate = () => { + this.clearUpdateTimeout(); + + this.updateTimeoutId = setTimeout(this.updateCalendar, UPDATE_DELAY); + } + + clearUpdateTimeout = () => { + if (this.updateTimeoutId) { + clearTimeout(this.updateTimeoutId); + } + } + + updateCalendar = () => { + this.props.gotoCalendarToday(); + this.scheduleUpdate(); + } + + // + // Listeners + + onCalendarViewChange = (view) => { + this.props.setCalendarView({ view }); + } + + onTodayPress = () => { + this.props.gotoCalendarToday(); + } + + onPreviousPress = () => { + this.props.gotoCalendarPreviousRange(); + } + + onNextPress = () => { + this.props.gotoCalendarNextRange(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +CalendarConnector.propTypes = { + useCurrentPage: PropTypes.bool.isRequired, + time: PropTypes.string, + view: PropTypes.string.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + setCalendarView: PropTypes.func.isRequired, + gotoCalendarToday: PropTypes.func.isRequired, + gotoCalendarPreviousRange: PropTypes.func.isRequired, + gotoCalendarNextRange: PropTypes.func.isRequired, + clearCalendar: PropTypes.func.isRequired, + fetchCalendar: PropTypes.func.isRequired, + fetchTrackFiles: PropTypes.func.isRequired, + clearTrackFiles: PropTypes.func.isRequired, + fetchQueueDetails: PropTypes.func.isRequired, + clearQueueDetails: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(CalendarConnector); diff --git a/frontend/src/Calendar/CalendarPage.css b/frontend/src/Calendar/CalendarPage.css new file mode 100644 index 000000000..b6839c467 --- /dev/null +++ b/frontend/src/Calendar/CalendarPage.css @@ -0,0 +1,20 @@ +.calendarPageBody { + composes: contentBody from '~Components/Page/PageContentBody.css'; + + display: flex; +} + +.calendarInnerPageBody { + composes: innerContentBody from '~Components/Page/PageContentBody.css'; + + display: flex; + flex-direction: column; + flex-grow: 1; + width: 100%; +} + +.errorMessage { + margin-top: 20px; + text-align: center; + font-size: 20px; +} diff --git a/frontend/src/Calendar/CalendarPage.js b/frontend/src/Calendar/CalendarPage.js new file mode 100644 index 000000000..9dfe3229e --- /dev/null +++ b/frontend/src/Calendar/CalendarPage.js @@ -0,0 +1,193 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import { align, icons } from 'Helpers/Props'; +import PageContent from 'Components/Page/PageContent'; +import Measure from 'Components/Measure'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import NoArtist from 'Artist/NoArtist'; +import CalendarLinkModal from './iCal/CalendarLinkModal'; +import CalendarOptionsModal from './Options/CalendarOptionsModal'; +import LegendConnector from './Legend/LegendConnector'; +import CalendarConnector from './CalendarConnector'; +import styles from './CalendarPage.css'; + +const MINIMUM_DAY_WIDTH = 120; + +class CalendarPage extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isCalendarLinkModalOpen: false, + isOptionsModalOpen: false, + width: 0 + }; + } + + // + // Listeners + + onMeasure = ({ width }) => { + this.setState({ width }); + const days = Math.max(3, Math.min(7, Math.floor(width / MINIMUM_DAY_WIDTH))); + + this.props.onDaysCountChange(days); + } + + onGetCalendarLinkPress = () => { + this.setState({ isCalendarLinkModalOpen: true }); + } + + onGetCalendarLinkModalClose = () => { + this.setState({ isCalendarLinkModalOpen: false }); + } + + onOptionsPress = () => { + this.setState({ isOptionsModalOpen: true }); + } + + onOptionsModalClose = () => { + this.setState({ isOptionsModalOpen: false }); + } + + onSearchMissingPress = () => { + const { + missingAlbumIds, + onSearchMissingPress + } = this.props; + + onSearchMissingPress(missingAlbumIds); + } + + // + // Render + + render() { + const { + selectedFilterKey, + filters, + hasArtist, + artistError, + missingAlbumIds, + isSearchingForMissing, + useCurrentPage, + onFilterSelect + } = this.props; + + const { + isCalendarLinkModalOpen, + isOptionsModalOpen + } = this.state; + + const isMeasured = this.state.width > 0; + + const PageComponent = hasArtist ? CalendarConnector : NoArtist; + + return ( + + + + + + + + + + + + + + + + + { + artistError && +
+ {getErrorMessage(artistError, 'Failed to load artist from API')} +
+ } + + { + !artistError && + + { + isMeasured ? + : +
+ } + + } + + { + hasArtist && !!artistError && + + } + + + + + + + + ); + } +} + +CalendarPage.propTypes = { + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + hasArtist: PropTypes.bool.isRequired, + artistError: PropTypes.object, + missingAlbumIds: PropTypes.arrayOf(PropTypes.number).isRequired, + isSearchingForMissing: PropTypes.bool.isRequired, + useCurrentPage: PropTypes.bool.isRequired, + onSearchMissingPress: PropTypes.func.isRequired, + onDaysCountChange: PropTypes.func.isRequired, + onFilterSelect: PropTypes.func.isRequired +}; + +export default CalendarPage; diff --git a/frontend/src/Calendar/CalendarPageConnector.js b/frontend/src/Calendar/CalendarPageConnector.js new file mode 100644 index 000000000..db0f827c1 --- /dev/null +++ b/frontend/src/Calendar/CalendarPageConnector.js @@ -0,0 +1,101 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import moment from 'moment'; +import { isCommandExecuting } from 'Utilities/Command'; +import isBefore from 'Utilities/Date/isBefore'; +import withCurrentPage from 'Components/withCurrentPage'; +import { searchMissing, setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions'; +import createArtistCountSelector from 'Store/Selectors/createArtistCountSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import CalendarPage from './CalendarPage'; + +function createMissingAlbumIdsSelector() { + return createSelector( + (state) => state.calendar.start, + (state) => state.calendar.end, + (state) => state.calendar.items, + (state) => state.queue.details.items, + (start, end, albums, queueDetails) => { + return albums.reduce((acc, album) => { + const releaseDate = album.releaseDate; + + if ( + album.percentOfTracks < 100 && + moment(releaseDate).isAfter(start) && + moment(releaseDate).isBefore(end) && + isBefore(album.releaseDate) && + !queueDetails.some((details) => !!details.album && details.album.id === album.id) + ) { + acc.push(album.id); + } + + return acc; + }, []); + } + ); +} + +function createIsSearchingSelector() { + return createSelector( + (state) => state.calendar.searchMissingCommandId, + createCommandsSelector(), + (searchMissingCommandId, commands) => { + if (searchMissingCommandId == null) { + return false; + } + + return isCommandExecuting(commands.find((command) => { + return command.id === searchMissingCommandId; + })); + } + ); +} + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar.selectedFilterKey, + (state) => state.calendar.filters, + createArtistCountSelector(), + createUISettingsSelector(), + createMissingAlbumIdsSelector(), + createIsSearchingSelector(), + ( + selectedFilterKey, + filters, + artistCount, + uiSettings, + missingAlbumIds, + isSearchingForMissing + ) => { + return { + selectedFilterKey, + filters, + colorImpairedMode: uiSettings.enableColorImpairedMode, + hasArtist: !!artistCount.count, + artistError: artistCount.error, + missingAlbumIds, + isSearchingForMissing + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onSearchMissingPress(albumIds) { + dispatch(searchMissing({ albumIds })); + }, + onDaysCountChange(dayCount) { + dispatch(setCalendarDaysCount({ dayCount })); + }, + + onFilterSelect(selectedFilterKey) { + dispatch(setCalendarFilter({ selectedFilterKey })); + } + }; +} + +export default withCurrentPage( + connect(createMapStateToProps, createMapDispatchToProps)(CalendarPage) +); diff --git a/frontend/src/Calendar/Day/CalendarDay.css b/frontend/src/Calendar/Day/CalendarDay.css new file mode 100644 index 000000000..79eb67ae7 --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDay.css @@ -0,0 +1,25 @@ +.day { + flex: 1 0 14.28%; + overflow: hidden; + min-height: 70px; + border-bottom: 1px solid $calendarBorderColor; + border-left: 1px solid $calendarBorderColor; +} + +.isSingleDay { + width: 100%; +} + +.dayOfMonth { + padding-right: 5px; + border-bottom: 1px solid $calendarBorderColor; + text-align: right; +} + +.isToday { + background-color: $calendarTodayBackgroundColor; +} + +.isDifferentMonth { + color: $disabledColor; +} diff --git a/frontend/src/Calendar/Day/CalendarDay.js b/frontend/src/Calendar/Day/CalendarDay.js new file mode 100644 index 000000000..bd196cc5d --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDay.js @@ -0,0 +1,63 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import * as calendarViews from 'Calendar/calendarViews'; +import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector'; +import styles from './CalendarDay.css'; + +function CalendarDay(props) { + const { + date, + time, + isTodaysDate, + events, + view, + onEventModalOpenToggle + } = props; + + return ( +
+ { + view === calendarViews.MONTH && +
+ {moment(date).date()} +
+ } +
+ { + events.map((event) => { + return ( + + ); + }) + } +
+
+ ); +} + +CalendarDay.propTypes = { + date: PropTypes.string.isRequired, + time: PropTypes.string.isRequired, + isTodaysDate: PropTypes.bool.isRequired, + events: PropTypes.arrayOf(PropTypes.object).isRequired, + view: PropTypes.string.isRequired, + onEventModalOpenToggle: PropTypes.func.isRequired +}; + +export default CalendarDay; diff --git a/frontend/src/Calendar/Day/CalendarDayConnector.js b/frontend/src/Calendar/Day/CalendarDayConnector.js new file mode 100644 index 000000000..6206ef4c6 --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDayConnector.js @@ -0,0 +1,55 @@ +import _ from 'lodash'; +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import CalendarDay from './CalendarDay'; + +function createCalendarEventsConnector() { + return createSelector( + (state, { date }) => date, + (state) => state.calendar.items, + (date, items) => { + const filtered = _.filter(items, (item) => { + return moment(date).isSame(moment(item.releaseDate), 'day'); + }); + + return _.sortBy(filtered, (item) => moment(item.releaseDate).unix()); + } + ); +} + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar, + createCalendarEventsConnector(), + (calendar, events) => { + return { + time: calendar.time, + view: calendar.view, + events + }; + } + ); +} + +class CalendarDayConnector extends Component { + + // + // Render + + render() { + return ( + + ); + } +} + +CalendarDayConnector.propTypes = { + date: PropTypes.string.isRequired +}; + +export default connect(createMapStateToProps)(CalendarDayConnector); diff --git a/frontend/src/Calendar/Day/CalendarDays.css b/frontend/src/Calendar/Day/CalendarDays.css new file mode 100644 index 000000000..b6dd2100c --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDays.css @@ -0,0 +1,14 @@ +.days { + display: flex; + border-right: 1px solid $calendarBorderColor; +} + +.day, +.week, +.forecast { + flex-wrap: nowrap; +} + +.month { + flex-wrap: wrap; +} diff --git a/frontend/src/Calendar/Day/CalendarDays.js b/frontend/src/Calendar/Day/CalendarDays.js new file mode 100644 index 000000000..0a1a36172 --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDays.js @@ -0,0 +1,164 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import isToday from 'Utilities/Date/isToday'; +import * as calendarViews from 'Calendar/calendarViews'; +import CalendarDayConnector from './CalendarDayConnector'; +import styles from './CalendarDays.css'; + +class CalendarDays extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._touchStart = null; + + this.state = { + todaysDate: moment().startOf('day').toISOString(), + isEventModalOpen: false + }; + + this.updateTimeoutId = null; + } + + // Lifecycle + + componentDidMount() { + const view = this.props.view; + + if (view === calendarViews.MONTH) { + this.scheduleUpdate(); + } + + window.addEventListener('touchstart', this.onTouchStart); + window.addEventListener('touchend', this.onTouchEnd); + window.addEventListener('touchcancel', this.onTouchCancel); + window.addEventListener('touchmove', this.onTouchMove); + } + + componentWillUnmount() { + this.clearUpdateTimeout(); + + window.removeEventListener('touchstart', this.onTouchStart); + window.removeEventListener('touchend', this.onTouchEnd); + window.removeEventListener('touchcancel', this.onTouchCancel); + window.removeEventListener('touchmove', this.onTouchMove); + } + + // + // Control + + scheduleUpdate = () => { + this.clearUpdateTimeout(); + const todaysDate = moment().startOf('day'); + const diff = moment().diff(todaysDate.clone().add(1, 'day')); + + this.setState({ todaysDate: todaysDate.toISOString() }); + + this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff); + } + + clearUpdateTimeout = () => { + if (this.updateTimeoutId) { + clearTimeout(this.updateTimeoutId); + } + } + + // + // Listeners + + onEventModalOpenToggle = (isEventModalOpen) => { + this.setState({ isEventModalOpen }); + } + + onTouchStart = (event) => { + const touches = event.touches; + const touchStart = touches[0].pageX; + + if (touches.length !== 1) { + return; + } + + if ( + touchStart < 50 || + this.props.isSidebarVisible || + this.state.isEventModalOpen + ) { + return; + } + + this._touchStart = touchStart; + } + + onTouchEnd = (event) => { + const touches = event.changedTouches; + const currentTouch = touches[0].pageX; + + if (!this._touchStart) { + return; + } + + if (currentTouch > this._touchStart && currentTouch - this._touchStart > 100) { + this.props.onNavigatePrevious(); + } else if (currentTouch < this._touchStart && this._touchStart - currentTouch > 100) { + this.props.onNavigateNext(); + } + + this._touchStart = null; + } + + onTouchCancel = (event) => { + this._touchStart = null; + } + + onTouchMove = (event) => { + if (!this._touchStart) { + return; + } + } + + // + // Render + + render() { + const { + dates, + view + } = this.props; + + return ( +
+ { + dates.map((date) => { + return ( + + ); + }) + } +
+ ); + } +} + +CalendarDays.propTypes = { + dates: PropTypes.arrayOf(PropTypes.string).isRequired, + view: PropTypes.string.isRequired, + isSidebarVisible: PropTypes.bool.isRequired, + onNavigatePrevious: PropTypes.func.isRequired, + onNavigateNext: PropTypes.func.isRequired +}; + +export default CalendarDays; diff --git a/frontend/src/Calendar/Day/CalendarDaysConnector.js b/frontend/src/Calendar/Day/CalendarDaysConnector.js new file mode 100644 index 000000000..3dea906a7 --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDaysConnector.js @@ -0,0 +1,25 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { gotoCalendarPreviousRange, gotoCalendarNextRange } from 'Store/Actions/calendarActions'; +import CalendarDays from './CalendarDays'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar, + (state) => state.app.isSidebarVisible, + (calendar, isSidebarVisible) => { + return { + dates: calendar.dates, + view: calendar.view, + isSidebarVisible + }; + } + ); +} + +const mapDispatchToProps = { + onNavigatePrevious: gotoCalendarPreviousRange, + onNavigateNext: gotoCalendarNextRange +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(CalendarDays); diff --git a/frontend/src/Calendar/Day/DayOfWeek.css b/frontend/src/Calendar/Day/DayOfWeek.css new file mode 100644 index 000000000..8c3552e55 --- /dev/null +++ b/frontend/src/Calendar/Day/DayOfWeek.css @@ -0,0 +1,13 @@ +.dayOfWeek { + flex: 1 0 14.28%; + background-color: #e4eaec; + text-align: center; +} + +.isSingleDay { + width: 100%; +} + +.isToday { + background-color: $calendarTodayBackgroundColor; +} diff --git a/frontend/src/Calendar/Day/DayOfWeek.js b/frontend/src/Calendar/Day/DayOfWeek.js new file mode 100644 index 000000000..d97671522 --- /dev/null +++ b/frontend/src/Calendar/Day/DayOfWeek.js @@ -0,0 +1,56 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import * as calendarViews from 'Calendar/calendarViews'; +import styles from './DayOfWeek.css'; + +class DayOfWeek extends Component { + + // + // Render + + render() { + const { + date, + view, + isTodaysDate, + calendarWeekColumnHeader, + shortDateFormat, + showRelativeDates + } = this.props; + + const highlightToday = view !== calendarViews.MONTH && isTodaysDate; + const momentDate = moment(date); + let formatedDate = momentDate.format('dddd'); + + if (view === calendarViews.WEEK) { + formatedDate = momentDate.format(calendarWeekColumnHeader); + } else if (view === calendarViews.FORECAST) { + formatedDate = getRelativeDate(date, shortDateFormat, showRelativeDates); + } + + return ( +
+ {formatedDate} +
+ ); + } +} + +DayOfWeek.propTypes = { + date: PropTypes.string.isRequired, + view: PropTypes.string.isRequired, + isTodaysDate: PropTypes.bool.isRequired, + calendarWeekColumnHeader: PropTypes.string.isRequired, + shortDateFormat: PropTypes.string.isRequired, + showRelativeDates: PropTypes.bool.isRequired +}; + +export default DayOfWeek; diff --git a/frontend/src/Calendar/Day/DaysOfWeek.css b/frontend/src/Calendar/Day/DaysOfWeek.css new file mode 100644 index 000000000..518664633 --- /dev/null +++ b/frontend/src/Calendar/Day/DaysOfWeek.css @@ -0,0 +1,4 @@ +.daysOfWeek { + display: flex; + margin-top: 10px; +} diff --git a/frontend/src/Calendar/Day/DaysOfWeek.js b/frontend/src/Calendar/Day/DaysOfWeek.js new file mode 100644 index 000000000..a67777f7c --- /dev/null +++ b/frontend/src/Calendar/Day/DaysOfWeek.js @@ -0,0 +1,97 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import DayOfWeek from './DayOfWeek'; +import * as calendarViews from 'Calendar/calendarViews'; +import styles from './DaysOfWeek.css'; + +class DaysOfWeek extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + todaysDate: moment().startOf('day').toISOString() + }; + + this.updateTimeoutId = null; + } + + // Lifecycle + + componentDidMount() { + const view = this.props.view; + + if (view !== calendarViews.AGENDA || view !== calendarViews.MONTH) { + this.scheduleUpdate(); + } + } + + componentWillUnmount() { + this.clearUpdateTimeout(); + } + + // + // Control + + scheduleUpdate = () => { + this.clearUpdateTimeout(); + const todaysDate = moment().startOf('day'); + const diff = todaysDate.clone().add(1, 'day').diff(moment()); + + this.setState({ + todaysDate: todaysDate.toISOString() + }); + + this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff); + } + + clearUpdateTimeout = () => { + if (this.updateTimeoutId) { + clearTimeout(this.updateTimeoutId); + } + } + + // + // Render + + render() { + const { + dates, + view, + ...otherProps + } = this.props; + + if (view === calendarViews.AGENDA) { + return null; + } + + return ( +
+ { + dates.map((date) => { + return ( + + ); + }) + } +
+ ); + } +} + +DaysOfWeek.propTypes = { + dates: PropTypes.arrayOf(PropTypes.string), + view: PropTypes.string.isRequired +}; + +export default DaysOfWeek; diff --git a/frontend/src/Calendar/Day/DaysOfWeekConnector.js b/frontend/src/Calendar/Day/DaysOfWeekConnector.js new file mode 100644 index 000000000..7f5cdef19 --- /dev/null +++ b/frontend/src/Calendar/Day/DaysOfWeekConnector.js @@ -0,0 +1,22 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import DaysOfWeek from './DaysOfWeek'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar, + createUISettingsSelector(), + (calendar, UiSettings) => { + return { + dates: calendar.dates.slice(0, 7), + view: calendar.view, + calendarWeekColumnHeader: UiSettings.calendarWeekColumnHeader, + shortDateFormat: UiSettings.shortDateFormat, + showRelativeDates: UiSettings.showRelativeDates + }; + } + ); +} + +export default connect(createMapStateToProps)(DaysOfWeek); diff --git a/frontend/src/Calendar/Events/CalendarEvent.css b/frontend/src/Calendar/Events/CalendarEvent.css new file mode 100644 index 000000000..055d51882 --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEvent.css @@ -0,0 +1,79 @@ +.event { + overflow-x: hidden; + margin: 4px 2px; + padding: 5px; + border-bottom: 1px solid $borderColor; + border-left: 4px solid $borderColor; + font-size: 12px; + + &:global(.colorImpaired) { + border-left-width: 5px; + } +} + +.info, +.albumInfo { + display: flex; +} + +.artistName, +.albumTitle { + @add-mixin truncate; + + flex: 1 0 1px; + margin-right: 10px; +} + +.artistName { + color: #3a3f51; + font-size: $defaultFontSize; +} + +.absoluteEpisodeNumber { + margin-left: 3px; +} + +.statusIcon { + margin-left: 3px; +} + +/* + * Status + */ + +.downloaded { + border-left-color: $successColor !important; + + &:global(.colorImpaired) { + border-left-color: color($successColor, saturation(+15%)) !important; + } +} + +.downloading { + border-left-color: $purple !important; +} + +.unmonitored { + border-left-color: $gray !important; + + &:global(.colorImpaired) { + background: repeating-linear-gradient(90deg, $colorImpairedGradientDark, $colorImpairedGradientDark 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px); + } +} + +.missing { + border-left-color: $dangerColor !important; + + &:global(.colorImpaired) { + border-left-color: color($dangerColor saturation(+15%)) !important; + background: repeating-linear-gradient(90deg, $colorImpairedGradientDark, $colorImpairedGradientDark 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px); + } +} + +.unreleased { + border-left-color: $primaryColor !important; + + &:global(.colorImpaired) { + background: repeating-linear-gradient(90deg, $colorImpairedGradientDark, $colorImpairedGradientDark 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px); + } +} diff --git a/frontend/src/Calendar/Events/CalendarEvent.js b/frontend/src/Calendar/Events/CalendarEvent.js new file mode 100644 index 000000000..8f04fd670 --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEvent.js @@ -0,0 +1,139 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import getStatusStyle from 'Calendar/getStatusStyle'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import CalendarEventQueueDetails from './CalendarEventQueueDetails'; +import styles from './CalendarEvent.css'; + +class CalendarEvent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + // + // Listeners + + onPress = () => { + this.setState({ isDetailsModalOpen: true }, () => { + this.props.onEventModalOpenToggle(true); + }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }, () => { + this.props.onEventModalOpenToggle(false); + }); + } + + // + // Render + + render() { + const { + id, + artist, + title, + foreignAlbumId, + releaseDate, + monitored, + statistics, + grabbed, + queueItem, + // timeFormat, + colorImpairedMode + } = this.props; + + if (!artist) { + return null; + } + + const startTime = moment(releaseDate); + // const endTime = startTime.add(artist.runtime, 'minutes'); + const downloading = !!(queueItem || grabbed); + const isMonitored = artist.monitored && monitored; + const statusStyle = getStatusStyle(id, downloading, startTime, isMonitored, statistics.percentOfTracks); + + return ( +
+ +
+
+ + {artist.artistName} + +
+ + { + !!queueItem && + + + + } + + { + !queueItem && grabbed && + + } +
+ +
+
+ + {title} + +
+
+ +
+ ); + } +} + +CalendarEvent.propTypes = { + id: PropTypes.number.isRequired, + artist: PropTypes.object.isRequired, + title: PropTypes.string.isRequired, + foreignAlbumId: PropTypes.string.isRequired, + statistics: PropTypes.object.isRequired, + releaseDate: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + grabbed: PropTypes.bool, + queueItem: PropTypes.object, + // timeFormat: PropTypes.string.isRequired, + colorImpairedMode: PropTypes.bool.isRequired, + onEventModalOpenToggle: PropTypes.func.isRequired +}; + +CalendarEvent.defaultProps = { + statistics: { + percentOfTracks: 0 + } +}; + +export default CalendarEvent; diff --git a/frontend/src/Calendar/Events/CalendarEventConnector.js b/frontend/src/Calendar/Events/CalendarEventConnector.js new file mode 100644 index 000000000..31706e2f7 --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventConnector.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createArtistSelector from 'Store/Selectors/createArtistSelector'; +import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import CalendarEvent from './CalendarEvent'; + +function createMapStateToProps() { + return createSelector( + createArtistSelector(), + createQueueItemSelector(), + createUISettingsSelector(), + (artist, queueItem, uiSettings) => { + return { + artist, + queueItem, + timeFormat: uiSettings.timeFormat, + colorImpairedMode: uiSettings.enableColorImpairedMode + }; + } + ); +} + +export default connect(createMapStateToProps)(CalendarEvent); diff --git a/frontend/src/Calendar/Events/CalendarEventQueueDetails.js b/frontend/src/Calendar/Events/CalendarEventQueueDetails.js new file mode 100644 index 000000000..1b603fd50 --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventQueueDetails.js @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import colors from 'Styles/Variables/colors'; +import CircularProgressBar from 'Components/CircularProgressBar'; +import QueueDetails from 'Activity/Queue/QueueDetails'; + +function CalendarEventQueueDetails(props) { + const { + title, + size, + sizeleft, + estimatedCompletionTime, + status, + errorMessage + } = props; + + const progress = (100 - sizeleft / size * 100); + + return ( + + +
+ } + /> + ); +} + +CalendarEventQueueDetails.propTypes = { + title: PropTypes.string.isRequired, + size: PropTypes.number.isRequired, + sizeleft: PropTypes.number.isRequired, + estimatedCompletionTime: PropTypes.string, + status: PropTypes.string.isRequired, + errorMessage: PropTypes.string +}; + +export default CalendarEventQueueDetails; diff --git a/frontend/src/Calendar/Header/CalendarHeader.css b/frontend/src/Calendar/Header/CalendarHeader.css new file mode 100644 index 000000000..4b6915406 --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeader.css @@ -0,0 +1,53 @@ +.header { + display: flex; +} + +.navigationButtons { + flex: 1 1 33%; + text-align: left; +} + +.todayButton { + composes: button from '~Components/Link/Button.css'; + + margin-left: 5px; +} + +.titleDesktop, +.titleMobile { + text-align: center; + font-size: 18px; +} + +.titleMobile { + margin-bottom: 5px; +} + +.viewButtonsContainer { + display: flex; + justify-content: flex-end; + flex: 1 1 33%; +} + +.viewMenu { + composes: menu from '~Components/Menu/Menu.css'; + + line-height: 31px; +} + +.loading { + composes: loading from '~Components/Loading/LoadingIndicator.css'; + + margin-top: 5px; + margin-right: 10px; +} + +@media only screen and (max-width: $breakpointSmall) { + .navigationButtons { + flex: 1 0 50%; + } + + .viewButtonsContainer { + flex: 0 0 100px; + } +} diff --git a/frontend/src/Calendar/Header/CalendarHeader.js b/frontend/src/Calendar/Header/CalendarHeader.js new file mode 100644 index 000000000..97052e0c8 --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeader.js @@ -0,0 +1,268 @@ +/* eslint max-params: 0 */ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { align, icons } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Icon from 'Components/Icon'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Menu from 'Components/Menu/Menu'; +import MenuButton from 'Components/Menu/MenuButton'; +import MenuContent from 'Components/Menu/MenuContent'; +import ViewMenuItem from 'Components/Menu/ViewMenuItem'; +import * as calendarViews from 'Calendar/calendarViews'; +import CalendarHeaderViewButton from './CalendarHeaderViewButton'; +import styles from './CalendarHeader.css'; + +function getTitle(time, start, end, view, longDateFormat) { + const timeMoment = moment(time); + const startMoment = moment(start); + const endMoment = moment(end); + + if (view === 'day') { + return timeMoment.format(longDateFormat); + } else if (view === 'month') { + return timeMoment.format('MMMM YYYY'); + } else if (view === 'agenda') { + return 'Agenda'; + } + + let startFormat = 'MMM D YYYY'; + let endFormat = 'MMM D YYYY'; + + if (startMoment.isSame(endMoment, 'month')) { + startFormat = 'MMM D'; + endFormat = 'D YYYY'; + } else if (startMoment.isSame(endMoment, 'year')) { + startFormat = 'MMM D'; + endFormat = 'MMM D YYYY'; + } + + return `${startMoment.format(startFormat)} \u2014 ${endMoment.format(endFormat)}`; +} + +// TODO Convert to a stateful Component so we can track view internally when changed + +class CalendarHeader extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + view: props.view + }; + } + + componentDidUpdate(prevProps) { + const view = this.props.view; + + if (prevProps.view !== view) { + this.setState({ view }); + } + } + + // + // Listeners + + onViewChange = (view) => { + this.setState({ view }, () => { + this.props.onViewChange(view); + }); + } + + // + // Render + + render() { + const { + isFetching, + time, + start, + end, + longDateFormat, + isSmallScreen, + collapseViewButtons, + onTodayPress, + onPreviousPress, + onNextPress + } = this.props; + + const view = this.state.view; + + const title = getTitle(time, start, end, view, longDateFormat); + + return ( +
+ { + isSmallScreen && +
+ {title} +
+ } + +
+
+ + + + + +
+ + { + !isSmallScreen && +
+ {title} +
+ } + +
+ { + isFetching && + + } + + { + collapseViewButtons ? + + + + + + + { + isSmallScreen ? + null : + + Month + + } + + + Week + + + + Forecast + + + + Day + + + + Agenda + + + : + +
+ + + + + + + + + +
+ } +
+
+
+ ); + } +} + +CalendarHeader.propTypes = { + isFetching: PropTypes.bool.isRequired, + time: PropTypes.string.isRequired, + start: PropTypes.string.isRequired, + end: PropTypes.string.isRequired, + view: PropTypes.oneOf(calendarViews.all).isRequired, + isSmallScreen: PropTypes.bool.isRequired, + collapseViewButtons: PropTypes.bool.isRequired, + longDateFormat: PropTypes.string.isRequired, + onViewChange: PropTypes.func.isRequired, + onTodayPress: PropTypes.func.isRequired, + onPreviousPress: PropTypes.func.isRequired, + onNextPress: PropTypes.func.isRequired +}; + +export default CalendarHeader; diff --git a/frontend/src/Calendar/Header/CalendarHeaderConnector.js b/frontend/src/Calendar/Header/CalendarHeaderConnector.js new file mode 100644 index 000000000..b73730ed9 --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeaderConnector.js @@ -0,0 +1,85 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import { setCalendarView, gotoCalendarToday, gotoCalendarPreviousRange, gotoCalendarNextRange } from 'Store/Actions/calendarActions'; +import CalendarHeader from './CalendarHeader'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar, + createDimensionsSelector(), + createUISettingsSelector(), + (calendar, dimensions, uiSettings) => { + const result = _.pick(calendar, [ + 'isFetching', + 'view', + 'time', + 'start', + 'end' + ]); + + result.isSmallScreen = dimensions.isSmallScreen; + result.collapseViewButtons = dimensions.isLargeScreen; + result.longDateFormat = uiSettings.longDateFormat; + + return result; + } + ); +} + +const mapDispatchToProps = { + setCalendarView, + gotoCalendarToday, + gotoCalendarPreviousRange, + gotoCalendarNextRange +}; + +class CalendarHeaderConnector extends Component { + + // + // Listeners + + onViewChange = (view) => { + this.props.setCalendarView({ view }); + } + + onTodayPress = () => { + this.props.gotoCalendarToday(); + } + + onPreviousPress = () => { + this.props.gotoCalendarPreviousRange(); + } + + onNextPress = () => { + this.props.gotoCalendarNextRange(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +CalendarHeaderConnector.propTypes = { + setCalendarView: PropTypes.func.isRequired, + gotoCalendarToday: PropTypes.func.isRequired, + gotoCalendarPreviousRange: PropTypes.func.isRequired, + gotoCalendarNextRange: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(CalendarHeaderConnector); diff --git a/frontend/src/Calendar/Header/CalendarHeaderViewButton.js b/frontend/src/Calendar/Header/CalendarHeaderViewButton.js new file mode 100644 index 000000000..8dd5ae9f0 --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeaderViewButton.js @@ -0,0 +1,45 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import titleCase from 'Utilities/String/titleCase'; +import Button from 'Components/Link/Button'; +import * as calendarViews from 'Calendar/calendarViews'; +// import styles from './CalendarHeaderViewButton.css'; + +class CalendarHeaderViewButton extends Component { + + // + // Listeners + + onPress = () => { + this.props.onPress(this.props.view); + } + + // + // Render + + render() { + const { + view, + selectedView, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +CalendarHeaderViewButton.propTypes = { + view: PropTypes.oneOf(calendarViews.all).isRequired, + selectedView: PropTypes.oneOf(calendarViews.all).isRequired, + onPress: PropTypes.func.isRequired +}; + +export default CalendarHeaderViewButton; diff --git a/frontend/src/Calendar/Legend/Legend.css b/frontend/src/Calendar/Legend/Legend.css new file mode 100644 index 000000000..296cbd9d5 --- /dev/null +++ b/frontend/src/Calendar/Legend/Legend.css @@ -0,0 +1,6 @@ +.legend { + display: flex; + flex-wrap: wrap; + margin-top: 10px; + padding: 3px 0; +} diff --git a/frontend/src/Calendar/Legend/Legend.js b/frontend/src/Calendar/Legend/Legend.js new file mode 100644 index 000000000..cc69198fd --- /dev/null +++ b/frontend/src/Calendar/Legend/Legend.js @@ -0,0 +1,91 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import LegendItem from './LegendItem'; +import LegendIconItem from './LegendIconItem'; +import styles from './Legend.css'; + +function Legend(props) { + const { + showCutoffUnmetIcon, + colorImpairedMode + } = props; + + const iconsToShow = []; + + if (showCutoffUnmetIcon) { + iconsToShow.push( + + ); + } + + return ( +
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ {iconsToShow[0]} +
+ + { + iconsToShow.length > 1 && +
+ {iconsToShow[1]} + {iconsToShow[2]} +
+ } +
+ ); +} + +Legend.propTypes = { + showCutoffUnmetIcon: PropTypes.bool.isRequired, + colorImpairedMode: PropTypes.bool.isRequired +}; + +export default Legend; diff --git a/frontend/src/Calendar/Legend/LegendConnector.js b/frontend/src/Calendar/Legend/LegendConnector.js new file mode 100644 index 000000000..30bbc4adb --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendConnector.js @@ -0,0 +1,19 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import Legend from './Legend'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar.options, + createUISettingsSelector(), + (calendarOptions, uiSettings) => { + return { + ...calendarOptions, + colorImpairedMode: uiSettings.enableColorImpairedMode + }; + } + ); +} + +export default connect(createMapStateToProps)(Legend); diff --git a/frontend/src/Calendar/Legend/LegendIconItem.css b/frontend/src/Calendar/Legend/LegendIconItem.css new file mode 100644 index 000000000..01db0ba5a --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendIconItem.css @@ -0,0 +1,10 @@ +.legendIconItem { + margin: 3px 0; + margin-right: 6px; + width: 150px; + cursor: default; +} + +.icon { + margin-right: 5px; +} diff --git a/frontend/src/Calendar/Legend/LegendIconItem.js b/frontend/src/Calendar/Legend/LegendIconItem.js new file mode 100644 index 000000000..13e106784 --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendIconItem.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Icon from 'Components/Icon'; +import styles from './LegendIconItem.css'; + +function LegendIconItem(props) { + const { + name, + icon, + kind, + tooltip + } = props; + + return ( +
+ + + {name} +
+ ); +} + +LegendIconItem.propTypes = { + name: PropTypes.string.isRequired, + icon: PropTypes.object.isRequired, + kind: PropTypes.string.isRequired, + tooltip: PropTypes.string.isRequired +}; + +export default LegendIconItem; diff --git a/frontend/src/Calendar/Legend/LegendItem.css b/frontend/src/Calendar/Legend/LegendItem.css new file mode 100644 index 000000000..82e16c543 --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendItem.css @@ -0,0 +1,41 @@ +.legendItem { + margin: 3px 0; + margin-right: 6px; + padding-left: 5px; + width: 150px; + border-left-width: 4px; + border-left-style: solid; + cursor: default; +} + +/* + * Status + */ + +.downloaded { + composes: downloaded from '~Calendar/Events/CalendarEvent.css'; +} + +.partial { + composes: partial from '~Calendar/Events/CalendarEvent.css'; +} + +.downloading { + composes: downloading from '~Calendar/Events/CalendarEvent.css'; +} + +.unmonitored { + composes: unmonitored from '~Calendar/Events/CalendarEvent.css'; +} + +.onAir { + composes: onAir from '~Calendar/Events/CalendarEvent.css'; +} + +.missing { + composes: missing from '~Calendar/Events/CalendarEvent.css'; +} + +.unreleased { + composes: unreleased from '~Calendar/Events/CalendarEvent.css'; +} diff --git a/frontend/src/Calendar/Legend/LegendItem.js b/frontend/src/Calendar/Legend/LegendItem.js new file mode 100644 index 000000000..961f48b86 --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendItem.js @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import titleCase from 'Utilities/String/titleCase'; +import styles from './LegendItem.css'; + +function LegendItem(props) { + const { + name, + status, + tooltip, + colorImpairedMode + } = props; + + return ( +
+ {name ? name : titleCase(status)} +
+ ); +} + +LegendItem.propTypes = { + name: PropTypes.string, + status: PropTypes.string.isRequired, + tooltip: PropTypes.string.isRequired, + colorImpairedMode: PropTypes.bool.isRequired +}; + +export default LegendItem; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModal.js b/frontend/src/Calendar/Options/CalendarOptionsModal.js new file mode 100644 index 000000000..b68c83f30 --- /dev/null +++ b/frontend/src/Calendar/Options/CalendarOptionsModal.js @@ -0,0 +1,29 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import CalendarOptionsModalContentConnector from './CalendarOptionsModalContentConnector'; + +function CalendarOptionsModal(props) { + const { + isOpen, + onModalClose + } = props; + + return ( + + + + ); +} + +CalendarOptionsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default CalendarOptionsModal; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContent.js b/frontend/src/Calendar/Options/CalendarOptionsModalContent.js new file mode 100644 index 000000000..a25d36f9c --- /dev/null +++ b/frontend/src/Calendar/Options/CalendarOptionsModalContent.js @@ -0,0 +1,216 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Button from 'Components/Link/Button'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import { firstDayOfWeekOptions, weekColumnOptions, timeFormatOptions } from 'Settings/UI/UISettings'; + +class CalendarOptionsModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + firstDayOfWeek, + calendarWeekColumnHeader, + timeFormat, + enableColorImpairedMode + } = props; + + this.state = { + firstDayOfWeek, + calendarWeekColumnHeader, + timeFormat, + enableColorImpairedMode + }; + } + + componentDidUpdate(prevProps) { + const { + firstDayOfWeek, + calendarWeekColumnHeader, + timeFormat, + enableColorImpairedMode + } = this.props; + + if ( + prevProps.firstDayOfWeek !== firstDayOfWeek || + prevProps.calendarWeekColumnHeader !== calendarWeekColumnHeader || + prevProps.timeFormat !== timeFormat || + prevProps.enableColorImpairedMode !== enableColorImpairedMode + ) { + this.setState({ + firstDayOfWeek, + calendarWeekColumnHeader, + timeFormat, + enableColorImpairedMode + }); + } + } + + // + // Listeners + + onOptionInputChange = ({ name, value }) => { + const { + dispatchSetCalendarOption + } = this.props; + + dispatchSetCalendarOption({ [name]: value }); + } + + onGlobalInputChange = ({ name, value }) => { + const { + dispatchSaveUISettings + } = this.props; + + const setting = { [name]: value }; + + this.setState(setting, () => { + dispatchSaveUISettings(setting); + }); + } + + onLinkFocus = (event) => { + event.target.select(); + } + + // + // Render + + render() { + const { + collapseMultipleAlbums, + showCutoffUnmetIcon, + onModalClose + } = this.props; + + const { + firstDayOfWeek, + calendarWeekColumnHeader, + timeFormat, + enableColorImpairedMode + } = this.state; + + return ( + + + Calendar Options + + + +
+
+ + Collapse Multiple Albums + + + + + + Icon for Cutoff Unmet + + + +
+
+ +
+
+ + First Day of Week + + + + + + Week Column Header + + + + + + Time Format + + + + Enable Color-Impaired Mode + + + + +
+
+
+ + + + +
+ ); + } +} + +CalendarOptionsModalContent.propTypes = { + collapseMultipleAlbums: PropTypes.bool.isRequired, + showCutoffUnmetIcon: PropTypes.bool.isRequired, + firstDayOfWeek: PropTypes.number.isRequired, + calendarWeekColumnHeader: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + enableColorImpairedMode: PropTypes.bool.isRequired, + dispatchSetCalendarOption: PropTypes.func.isRequired, + dispatchSaveUISettings: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default CalendarOptionsModalContent; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js b/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js new file mode 100644 index 000000000..eb979f74e --- /dev/null +++ b/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js @@ -0,0 +1,25 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setCalendarOption } from 'Store/Actions/calendarActions'; +import CalendarOptionsModalContent from './CalendarOptionsModalContent'; +import { saveUISettings } from 'Store/Actions/settingsActions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar.options, + (state) => state.settings.ui.item, + (options, uiSettings) => { + return { + ...options, + ...uiSettings + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetCalendarOption: setCalendarOption, + dispatchSaveUISettings: saveUISettings +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(CalendarOptionsModalContent); diff --git a/frontend/src/Calendar/calendarViews.js b/frontend/src/Calendar/calendarViews.js new file mode 100644 index 000000000..929958b66 --- /dev/null +++ b/frontend/src/Calendar/calendarViews.js @@ -0,0 +1,7 @@ +export const DAY = 'day'; +export const WEEK = 'week'; +export const MONTH = 'month'; +export const FORECAST = 'forecast'; +export const AGENDA = 'agenda'; + +export const all = [DAY, WEEK, MONTH, FORECAST, AGENDA]; diff --git a/frontend/src/Calendar/getStatusStyle.js b/frontend/src/Calendar/getStatusStyle.js new file mode 100644 index 000000000..c02ae0ee5 --- /dev/null +++ b/frontend/src/Calendar/getStatusStyle.js @@ -0,0 +1,30 @@ +/* eslint max-params: 0 */ +import moment from 'moment'; + +function getStatusStyle(episodeNumber, downloading, startTime, isMonitored, percentOfTracks) { + const currentTime = moment(); + + if (percentOfTracks === 100) { + return 'downloaded'; + } + + if (percentOfTracks > 0) { + return 'partial'; + } + + if (downloading) { + return 'downloading'; + } + + if (!isMonitored) { + return 'unmonitored'; + } + + if (currentTime.isAfter(startTime)) { + return 'missing'; + } + + return 'unreleased'; +} + +export default getStatusStyle; diff --git a/frontend/src/Calendar/iCal/CalendarLinkModal.js b/frontend/src/Calendar/iCal/CalendarLinkModal.js new file mode 100644 index 000000000..8cc487c16 --- /dev/null +++ b/frontend/src/Calendar/iCal/CalendarLinkModal.js @@ -0,0 +1,29 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import CalendarLinkModalContentConnector from './CalendarLinkModalContentConnector'; + +function CalendarLinkModal(props) { + const { + isOpen, + onModalClose + } = props; + + return ( + + + + ); +} + +CalendarLinkModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default CalendarLinkModal; diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContent.js b/frontend/src/Calendar/iCal/CalendarLinkModalContent.js new file mode 100644 index 000000000..074c66516 --- /dev/null +++ b/frontend/src/Calendar/iCal/CalendarLinkModalContent.js @@ -0,0 +1,213 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, inputTypes, kinds, sizes } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import ClipboardButton from 'Components/Link/ClipboardButton'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormInputButton from 'Components/Form/FormInputButton'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +function getUrls(state) { + const { + unmonitored, + pastDays, + futureDays, + tags + } = state; + + let icalUrl = `${window.location.host}${window.Lidarr.urlBase}/feed/v1/calendar/Lidarr.ics?`; + + if (unmonitored) { + icalUrl += 'unmonitored=true&'; + } + + if (tags.length) { + icalUrl += `tags=${tags.toString()}&`; + } + + icalUrl += `pastDays=${pastDays}&futureDays=${futureDays}&apikey=${window.Lidarr.apiKey}`; + + const iCalHttpUrl = `${window.location.protocol}//${icalUrl}`; + const iCalWebCalUrl = `webcal://${icalUrl}`; + + return { + iCalHttpUrl, + iCalWebCalUrl + }; +} + +class CalendarLinkModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const defaultState = { + unmonitored: false, + pastDays: 7, + futureDays: 28, + tags: [] + }; + + const urls = getUrls(defaultState); + + this.state = { + ...defaultState, + ...urls + }; + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + const state = { + ...this.state, + [name]: value + }; + + const urls = getUrls(state); + + this.setState({ + [name]: value, + ...urls + }); + } + + onLinkFocus = (event) => { + event.target.select(); + } + + // + // Render + + render() { + const { + onModalClose + } = this.props; + + const { + unmonitored, + pastDays, + futureDays, + tags, + iCalHttpUrl, + iCalWebCalUrl + } = this.state; + + return ( + + + Lidarr Calendar Feed + + + +
+ + Include Unmonitored + + + + + + Past Days + + + + + + Future Days + + + + + + Tags + + + + + + iCal Feed + + , + + + + + ]} + onChange={this.onInputChange} + onFocus={this.onLinkFocus} + /> + +
+
+ + + + +
+ ); + } +} + +CalendarLinkModalContent.propTypes = { + tagList: PropTypes.arrayOf(PropTypes.object).isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default CalendarLinkModalContent; diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js b/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js new file mode 100644 index 000000000..e10c5c3f9 --- /dev/null +++ b/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import CalendarLinkModalContent from './CalendarLinkModalContent'; + +function createMapStateToProps() { + return createSelector( + createTagsSelector(), + (tagList) => { + return { + tagList + }; + } + ); +} + +export default connect(createMapStateToProps)(CalendarLinkModalContent); diff --git a/frontend/src/Commands/commandNames.js b/frontend/src/Commands/commandNames.js new file mode 100644 index 000000000..110f94939 --- /dev/null +++ b/frontend/src/Commands/commandNames.js @@ -0,0 +1,22 @@ +export const APPLICATION_UPDATE = 'ApplicationUpdate'; +export const BACKUP = 'Backup'; +export const CHECK_FOR_FINISHED_DOWNLOAD = 'CheckForFinishedDownload'; +export const CLEAR_BLACKLIST = 'ClearBlacklist'; +export const CLEAR_LOGS = 'ClearLog'; +export const CUTOFF_UNMET_ALBUM_SEARCH = 'CutoffUnmetAlbumSearch'; +export const DELETE_LOG_FILES = 'DeleteLogFiles'; +export const DELETE_UPDATE_LOG_FILES = 'DeleteUpdateLogFiles'; +export const DOWNLOADED_ALBUMS_SCAN = 'DownloadedAlbumsScan'; +export const ALBUM_SEARCH = 'AlbumSearch'; +export const INTERACTIVE_IMPORT = 'ManualImport'; +export const MISSING_ALBUM_SEARCH = 'MissingAlbumSearch'; +export const MOVE_ARTIST = 'MoveArtist'; +export const REFRESH_ARTIST = 'RefreshArtist'; +export const RENAME_FILES = 'RenameFiles'; +export const RENAME_ARTIST = 'RenameArtist'; +export const RETAG_FILES = 'RetagFiles'; +export const RETAG_ARTIST = 'RetagArtist'; +export const RESET_API_KEY = 'ResetApiKey'; +export const RSS_SYNC = 'RssSync'; +export const SEASON_SEARCH = 'AlbumSearch'; +export const ARTIST_SEARCH = 'ArtistSearch'; diff --git a/frontend/src/Components/Alert.css b/frontend/src/Components/Alert.css new file mode 100644 index 000000000..312fbb4f2 --- /dev/null +++ b/frontend/src/Components/Alert.css @@ -0,0 +1,31 @@ +.alert { + display: block; + margin: 5px; + padding: 15px; + border: 1px solid transparent; + border-radius: 4px; +} + +.danger { + border-color: $alertDangerBorderColor; + background-color: $alertDangerBackgroundColor; + color: $alertDangerColor; +} + +.info { + border-color: $alertInfoBorderColor; + background-color: $alertInfoBackgroundColor; + color: $alertInfoColor; +} + +.success { + border-color: $alertSuccessBorderColor; + background-color: $alertSuccessBackgroundColor; + color: $alertSuccessColor; +} + +.warning { + border-color: $alertWarningBorderColor; + background-color: $alertWarningBackgroundColor; + color: $alertWarningColor; +} diff --git a/frontend/src/Components/Alert.js b/frontend/src/Components/Alert.js new file mode 100644 index 000000000..dc19a418c --- /dev/null +++ b/frontend/src/Components/Alert.js @@ -0,0 +1,32 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { kinds } from 'Helpers/Props'; +import styles from './Alert.css'; + +function Alert({ className, kind, children, ...otherProps }) { + return ( +
+ {children} +
+ ); +} + +Alert.propTypes = { + className: PropTypes.string.isRequired, + kind: PropTypes.oneOf(kinds.all).isRequired, + children: PropTypes.node.isRequired +}; + +Alert.defaultProps = { + className: styles.alert, + kind: kinds.INFO +}; + +export default Alert; diff --git a/frontend/src/Components/Card.css b/frontend/src/Components/Card.css new file mode 100644 index 000000000..b54bbcdf4 --- /dev/null +++ b/frontend/src/Components/Card.css @@ -0,0 +1,19 @@ +.card { + position: relative; + margin: 10px; + padding: 10px; + border-radius: 3px; + background-color: $white; + box-shadow: 0 0 10px 1px $cardShadowColor; + color: $defaultColor; +} + +.underlay { + @add-mixin cover; +} + +.overlay { + @add-mixin linkOverlay; + + position: relative; +} diff --git a/frontend/src/Components/Card.js b/frontend/src/Components/Card.js new file mode 100644 index 000000000..c5a4d164c --- /dev/null +++ b/frontend/src/Components/Card.js @@ -0,0 +1,60 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import styles from './Card.css'; + +class Card extends Component { + + // + // Render + + render() { + const { + className, + overlayClassName, + overlayContent, + children, + onPress + } = this.props; + + if (overlayContent) { + return ( +
+ + +
+ {children} +
+
+ ); + } + + return ( + + {children} + + ); + } +} + +Card.propTypes = { + className: PropTypes.string.isRequired, + overlayClassName: PropTypes.string.isRequired, + overlayContent: PropTypes.bool.isRequired, + children: PropTypes.node.isRequired, + onPress: PropTypes.func.isRequired +}; + +Card.defaultProps = { + className: styles.card, + overlayClassName: styles.overlay, + overlayContent: false +}; + +export default Card; diff --git a/frontend/src/Components/CircularProgressBar.css b/frontend/src/Components/CircularProgressBar.css new file mode 100644 index 000000000..32b349404 --- /dev/null +++ b/frontend/src/Components/CircularProgressBar.css @@ -0,0 +1,21 @@ +.circularProgressBarContainer { + position: relative; + display: inline-block; + vertical-align: top; + text-align: center; +} + +.circularProgressBar { + position: absolute; + top: 0; + left: 0; + transform: rotate(-90deg); + transform-origin: center center; +} + +.circularProgressBarText { + position: absolute; + width: 100%; + height: 100%; + font-weight: bold; +} diff --git a/frontend/src/Components/CircularProgressBar.js b/frontend/src/Components/CircularProgressBar.js new file mode 100644 index 000000000..f373de1d6 --- /dev/null +++ b/frontend/src/Components/CircularProgressBar.js @@ -0,0 +1,139 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import colors from 'Styles/Variables/colors'; +import styles from './CircularProgressBar.css'; + +class CircularProgressBar extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + progress: 0 + }; + } + + componentDidMount() { + this._progressStep(); + } + + componentDidUpdate(prevProps) { + const progress = this.props.progress; + + if (prevProps.progress !== progress) { + this._cancelProgressStep(); + this._progressStep(); + } + } + + componentWillUnmount() { + this._cancelProgressStep(); + } + + // + // Control + + _progressStep() { + this.requestAnimationFrame = window.requestAnimationFrame(() => { + this.setState({ + progress: this.state.progress + 1 + }, () => { + if (this.state.progress < this.props.progress) { + this._progressStep(); + } + }); + }); + } + + _cancelProgressStep() { + if (this.requestAnimationFrame) { + window.cancelAnimationFrame(this.requestAnimationFrame); + } + } + + // + // Render + + render() { + const { + className, + containerClassName, + size, + strokeWidth, + strokeColor, + showProgressText + } = this.props; + + const progress = this.state.progress; + + const center = size / 2; + const radius = center - strokeWidth; + const circumference = Math.PI * (radius * 2); + const sizeInPixels = `${size}px`; + const strokeDashoffset = ((100 - progress) / 100) * circumference; + const progressText = `${Math.round(progress)}%`; + + return ( +
+ + + + + { + showProgressText && +
+ {progressText} +
+ } +
+ ); + } +} + +CircularProgressBar.propTypes = { + className: PropTypes.string, + containerClassName: PropTypes.string, + size: PropTypes.number, + progress: PropTypes.number.isRequired, + strokeWidth: PropTypes.number, + strokeColor: PropTypes.string, + showProgressText: PropTypes.bool +}; + +CircularProgressBar.defaultProps = { + className: styles.circularProgressBar, + containerClassName: styles.circularProgressBarContainer, + size: 60, + strokeWidth: 5, + strokeColor: colors.lidarrGreen, + showProgressText: false +}; + +export default CircularProgressBar; diff --git a/frontend/src/Components/DescriptionList/DescriptionList.css b/frontend/src/Components/DescriptionList/DescriptionList.css new file mode 100644 index 000000000..230347f80 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionList.css @@ -0,0 +1,4 @@ +.descriptionList { + margin-top: 0; + margin-bottom: 0; +} diff --git a/frontend/src/Components/DescriptionList/DescriptionList.js b/frontend/src/Components/DescriptionList/DescriptionList.js new file mode 100644 index 000000000..be2c87c55 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionList.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './DescriptionList.css'; + +class DescriptionList extends Component { + + // + // Render + + render() { + const { + className, + children + } = this.props; + + return ( +
+ {children} +
+ ); + } +} + +DescriptionList.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.node +}; + +DescriptionList.defaultProps = { + className: styles.descriptionList +}; + +export default DescriptionList; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItem.js b/frontend/src/Components/DescriptionList/DescriptionListItem.js new file mode 100644 index 000000000..4ba70bf33 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItem.js @@ -0,0 +1,44 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import DescriptionListItemTitle from './DescriptionListItemTitle'; +import DescriptionListItemDescription from './DescriptionListItemDescription'; + +class DescriptionListItem extends Component { + + // + // Render + + render() { + const { + titleClassName, + descriptionClassName, + title, + data + } = this.props; + + return ( + + + {title} + + + + {data} + + + ); + } +} + +DescriptionListItem.propTypes = { + titleClassName: PropTypes.string, + descriptionClassName: PropTypes.string, + title: PropTypes.string, + data: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node]) +}; + +export default DescriptionListItem; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css new file mode 100644 index 000000000..b23415a76 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css @@ -0,0 +1,13 @@ +.description { + line-height: $lineHeight; +} + +.description { + margin-left: 0; +} + +@media (min-width: 768px) { + .description { + margin-left: 180px; + } +} diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js new file mode 100644 index 000000000..4ef3c015e --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './DescriptionListItemDescription.css'; + +function DescriptionListItemDescription(props) { + const { + className, + children + } = props; + + return ( +
+ {children} +
+ ); +} + +DescriptionListItemDescription.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node]) +}; + +DescriptionListItemDescription.defaultProps = { + className: styles.description +}; + +export default DescriptionListItemDescription; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemTitle.css b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.css new file mode 100644 index 000000000..e496e463d --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.css @@ -0,0 +1,18 @@ +.title { + line-height: $lineHeight; +} + +.title { + font-weight: bold; +} + +@media (min-width: 768px) { + .title { + @add-mixin truncate; + + float: left; + clear: left; + width: 160px; + text-align: right; + } +} diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js new file mode 100644 index 000000000..e1632c1cf --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './DescriptionListItemTitle.css'; + +function DescriptionListItemTitle(props) { + const { + className, + children + } = props; + + return ( +
+ {children} +
+ ); +} + +DescriptionListItemTitle.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.string +}; + +DescriptionListItemTitle.defaultProps = { + className: styles.title +}; + +export default DescriptionListItemTitle; diff --git a/frontend/src/Components/DragPreviewLayer.css b/frontend/src/Components/DragPreviewLayer.css new file mode 100644 index 000000000..46f721fef --- /dev/null +++ b/frontend/src/Components/DragPreviewLayer.css @@ -0,0 +1,9 @@ +.dragLayer { + position: fixed; + top: 0; + left: 0; + z-index: 9999; + width: 100%; + height: 100%; + pointer-events: none; +} diff --git a/frontend/src/Components/DragPreviewLayer.js b/frontend/src/Components/DragPreviewLayer.js new file mode 100644 index 000000000..a111df70e --- /dev/null +++ b/frontend/src/Components/DragPreviewLayer.js @@ -0,0 +1,22 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './DragPreviewLayer.css'; + +function DragPreviewLayer({ children, ...otherProps }) { + return ( +
+ {children} +
+ ); +} + +DragPreviewLayer.propTypes = { + children: PropTypes.node, + className: PropTypes.string +}; + +DragPreviewLayer.defaultProps = { + className: styles.dragLayer +}; + +export default DragPreviewLayer; diff --git a/frontend/src/Components/Error/ErrorBoundary.js b/frontend/src/Components/Error/ErrorBoundary.js new file mode 100644 index 000000000..87fb2498a --- /dev/null +++ b/frontend/src/Components/Error/ErrorBoundary.js @@ -0,0 +1,62 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import * as sentry from '@sentry/browser'; + +class ErrorBoundary extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + error: null, + info: null + }; + } + + componentDidCatch(error, info) { + this.setState({ + error, + info + }); + + sentry.captureException(error); + } + + // + // Render + + render() { + const { + children, + errorComponent: ErrorComponent, + ...otherProps + } = this.props; + + const { + error, + info + } = this.state; + + if (error) { + return ( + + ); + } + + return children; + } +} + +ErrorBoundary.propTypes = { + children: PropTypes.node.isRequired, + errorComponent: PropTypes.elementType.isRequired +}; + +export default ErrorBoundary; diff --git a/frontend/src/Components/Error/ErrorBoundaryError.css b/frontend/src/Components/Error/ErrorBoundaryError.css new file mode 100644 index 000000000..b6d1f917e --- /dev/null +++ b/frontend/src/Components/Error/ErrorBoundaryError.css @@ -0,0 +1,38 @@ +.container { + text-align: center; +} + +.message { + margin: 50px 0; + text-align: center; + font-weight: 300; + font-size: 36px; +} + +.imageContainer { + display: flex; + justify-content: center; + flex: 0 0 auto; +} + +.image { + height: 350px; +} + +.details { + margin: 20px; + text-align: left; + white-space: pre-wrap; +} + +@media only screen and (max-width: $breakpointMedium) { + .image { + height: 250px; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .image { + height: 150px; + } +} diff --git a/frontend/src/Components/Error/ErrorBoundaryError.js b/frontend/src/Components/Error/ErrorBoundaryError.js new file mode 100644 index 000000000..f99930437 --- /dev/null +++ b/frontend/src/Components/Error/ErrorBoundaryError.js @@ -0,0 +1,60 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './ErrorBoundaryError.css'; + +function ErrorBoundaryError(props) { + const { + className, + messageClassName, + detailsClassName, + message, + error, + info + } = props; + + return ( +
+
+ {message} +
+ +
+ +
+ +
+ { + error && +
+ {error.toString()} +
+ } + +
+ {info.componentStack} +
+
+
+ ); +} + +ErrorBoundaryError.propTypes = { + className: PropTypes.string.isRequired, + messageClassName: PropTypes.string.isRequired, + detailsClassName: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, + error: PropTypes.object.isRequired, + info: PropTypes.object.isRequired +}; + +ErrorBoundaryError.defaultProps = { + className: styles.container, + messageClassName: styles.message, + detailsClassName: styles.details, + message: 'There was an error loading this content' +}; + +export default ErrorBoundaryError; diff --git a/frontend/src/Components/FieldSet.css b/frontend/src/Components/FieldSet.css new file mode 100644 index 000000000..daf3bdf2e --- /dev/null +++ b/frontend/src/Components/FieldSet.css @@ -0,0 +1,19 @@ +.fieldSet { + margin: 0; + margin-bottom: 20px; + padding: 0; + min-width: 0; + border: 0; +} + +.legend { + display: block; + margin-bottom: 21px; + padding: 0; + width: 100%; + border: 0; + border-bottom: 1px solid #e5e5e5; + color: #3a3f51; + font-size: 21px; + line-height: inherit; +} diff --git a/frontend/src/Components/FieldSet.js b/frontend/src/Components/FieldSet.js new file mode 100644 index 000000000..76e68a934 --- /dev/null +++ b/frontend/src/Components/FieldSet.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './FieldSet.css'; + +class FieldSet extends Component { + + // + // Render + + render() { + const { + legend, + children + } = this.props; + + return ( +
+ + {legend} + + {children} +
+ ); + } + +} + +FieldSet.propTypes = { + legend: PropTypes.oneOfType([PropTypes.node, PropTypes.string]), + children: PropTypes.node +}; + +export default FieldSet; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModal.css b/frontend/src/Components/FileBrowser/FileBrowserModal.css new file mode 100644 index 000000000..59dba1397 --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModal.css @@ -0,0 +1,5 @@ +.modal { + composes: modal from '~Components/Modal/Modal.css'; + + height: 600px; +} diff --git a/frontend/src/Components/FileBrowser/FileBrowserModal.js b/frontend/src/Components/FileBrowser/FileBrowserModal.js new file mode 100644 index 000000000..6b58dbb8c --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModal.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import FileBrowserModalContentConnector from './FileBrowserModalContentConnector'; +import styles from './FileBrowserModal.css'; + +class FileBrowserModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +FileBrowserModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default FileBrowserModal; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.css b/frontend/src/Components/FileBrowser/FileBrowserModalContent.css new file mode 100644 index 000000000..7ddb9e806 --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.css @@ -0,0 +1,33 @@ +.modalBody { + composes: modalBody from '~Components/Modal/ModalBody.css'; + + display: flex; + flex-direction: column; +} + +.mappedDrivesWarning { + composes: alert from '~Components/Alert.css'; + + margin: 0; + margin-bottom: 20px; +} + +.faqLink { + color: $alertWarningColor; + font-weight: bold; +} + +.pathInput { + composes: inputWrapper from '~Components/Form/PathInput.css'; + + flex: 0 0 auto; +} + +.scroller { + margin-top: 20px; +} + +.loading { + display: inline-block; + margin-right: auto; +} diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.js b/frontend/src/Components/FileBrowser/FileBrowserModalContent.js new file mode 100644 index 000000000..ca84ac078 --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.js @@ -0,0 +1,257 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import { kinds, scrollDirections } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import Link from 'Components/Link/Link'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Scroller from 'Components/Scroller/Scroller'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import PathInput from 'Components/Form/PathInput'; +import FileBrowserRow from './FileBrowserRow'; +import styles from './FileBrowserModalContent.css'; + +const columns = [ + { + name: 'type', + label: 'Type', + isVisible: true + }, + { + name: 'name', + label: 'Name', + isVisible: true + } +]; + +class FileBrowserModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._scrollerNode = null; + + this.state = { + isFileBrowserModalOpen: false, + currentPath: props.value + }; + } + + componentDidUpdate(prevProps, prevState) { + const { + currentPath + } = this.props; + + if ( + currentPath !== this.state.currentPath && + currentPath !== prevState.currentPath + ) { + this.setState({ currentPath }); + this._scrollerNode.scrollTop = 0; + } + } + + // + // Control + + setScrollerRef = (ref) => { + if (ref) { + this._scrollerNode = ReactDOM.findDOMNode(ref); + } else { + this._scrollerNode = null; + } + } + + // + // Listeners + + onPathInputChange = ({ value }) => { + this.setState({ currentPath: value }); + } + + onRowPress = (path) => { + this.props.onFetchPaths(path); + } + + onOkPress = () => { + this.props.onChange({ + name: this.props.name, + value: this.state.currentPath + }); + + this.props.onClearPaths(); + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + parent, + directories, + files, + isWindowsService, + onModalClose, + ...otherProps + } = this.props; + + const emptyParent = parent === ''; + + return ( + + + File Browser + + + + { + isWindowsService && + + Mapped network drives are not available when running as a Windows Service, see the FAQ for more information. + + } + + + + + { + !!error && +
Error loading contents
+ } + + { + isPopulated && !error && + + + { + emptyParent && + + } + + { + !emptyParent && parent && + + } + + { + directories.map((directory) => { + return ( + + ); + }) + } + + { + files.map((file) => { + return ( + + ); + }) + } + +
+ } +
+
+ + + { + isFetching && + + } + + + + + +
+ ); + } +} + +FileBrowserModalContent.propTypes = { + name: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + parent: PropTypes.string, + currentPath: PropTypes.string.isRequired, + directories: PropTypes.arrayOf(PropTypes.object).isRequired, + files: PropTypes.arrayOf(PropTypes.object).isRequired, + isWindowsService: PropTypes.bool.isRequired, + onFetchPaths: PropTypes.func.isRequired, + onClearPaths: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default FileBrowserModalContent; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js b/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js new file mode 100644 index 000000000..da5ae2ab8 --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js @@ -0,0 +1,119 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchPaths, clearPaths } from 'Store/Actions/pathActions'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; +import FileBrowserModalContent from './FileBrowserModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.paths, + createSystemStatusSelector(), + (paths, systemStatus) => { + const { + isFetching, + isPopulated, + error, + parent, + currentPath, + directories, + files + } = paths; + + const filteredPaths = _.filter([...directories, ...files], ({ path }) => { + return path.toLowerCase().startsWith(currentPath.toLowerCase()); + }); + + return { + isFetching, + isPopulated, + error, + parent, + currentPath, + directories, + files, + paths: filteredPaths, + isWindowsService: systemStatus.isWindows && systemStatus.mode === 'service' + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchPaths: fetchPaths, + dispatchClearPaths: clearPaths +}; + +class FileBrowserModalContentConnector extends Component { + + // Lifecycle + + componentDidMount() { + const { + value, + includeFiles, + dispatchFetchPaths + } = this.props; + + dispatchFetchPaths({ + path: value, + allowFoldersWithoutTrailingSlashes: true, + includeFiles + }); + } + + // + // Listeners + + onFetchPaths = (path) => { + const { + includeFiles, + dispatchFetchPaths + } = this.props; + + dispatchFetchPaths({ + path, + allowFoldersWithoutTrailingSlashes: true, + includeFiles + }); + } + + onClearPaths = () => { + // this.props.dispatchClearPaths(); + } + + onModalClose = () => { + this.props.dispatchClearPaths(); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +FileBrowserModalContentConnector.propTypes = { + value: PropTypes.string, + includeFiles: PropTypes.bool.isRequired, + dispatchFetchPaths: PropTypes.func.isRequired, + dispatchClearPaths: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +FileBrowserModalContentConnector.defaultProps = { + includeFiles: false +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(FileBrowserModalContentConnector); diff --git a/frontend/src/Components/FileBrowser/FileBrowserRow.css b/frontend/src/Components/FileBrowser/FileBrowserRow.css new file mode 100644 index 000000000..9f111ed5d --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserRow.css @@ -0,0 +1,5 @@ +.type { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 32px; +} diff --git a/frontend/src/Components/FileBrowser/FileBrowserRow.js b/frontend/src/Components/FileBrowser/FileBrowserRow.js new file mode 100644 index 000000000..42ac30405 --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserRow.js @@ -0,0 +1,62 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import TableRowButton from 'Components/Table/TableRowButton'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './FileBrowserRow.css'; + +function getIconName(type) { + switch (type) { + case 'computer': + return icons.COMPUTER; + case 'drive': + return icons.DRIVE; + case 'file': + return icons.FILE; + case 'parent': + return icons.PARENT; + default: + return icons.FOLDER; + } +} + +class FileBrowserRow extends Component { + + // + // Listeners + + onPress = () => { + this.props.onPress(this.props.path); + } + + // + // Render + + render() { + const { + type, + name + } = this.props; + + return ( + + + + + + {name} + + ); + } + +} + +FileBrowserRow.propTypes = { + type: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + path: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired +}; + +export default FileBrowserRow; diff --git a/frontend/src/Components/Filter/Builder/ArtistStatusFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/ArtistStatusFilterBuilderRowValue.js new file mode 100644 index 000000000..28070d200 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/ArtistStatusFilterBuilderRowValue.js @@ -0,0 +1,18 @@ +import React from 'react'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +const protocols = [ + { id: 'continuing', name: 'Continuing' }, + { id: 'ended', name: 'Ended' } +]; + +function ArtistStatusFilterBuilderRowValue(props) { + return ( + + ); +} + +export default ArtistStatusFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/BoolFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/BoolFilterBuilderRowValue.js new file mode 100644 index 000000000..eea574dd1 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/BoolFilterBuilderRowValue.js @@ -0,0 +1,18 @@ +import React from 'react'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +const protocols = [ + { id: true, name: 'true' }, + { id: false, name: 'false' } +]; + +function BoolFilterBuilderRowValue(props) { + return ( + + ); +} + +export default BoolFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.css b/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.css new file mode 100644 index 000000000..39db60700 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.css @@ -0,0 +1,15 @@ +.container { + display: flex; +} + +.numberInput { + composes: input from '~Components/Form/TextInput.css'; + + margin-right: 3px; +} + +.selectInput { + composes: select from '~Components/Form/SelectInput.css'; + + margin-left: 3px; +} diff --git a/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.js new file mode 100644 index 000000000..f0c2d3626 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.js @@ -0,0 +1,171 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import isString from 'Utilities/String/isString'; +import { IN_LAST, IN_NEXT } from 'Helpers/Props/filterTypes'; +import NumberInput from 'Components/Form/NumberInput'; +import SelectInput from 'Components/Form/SelectInput'; +import TextInput from 'Components/Form/TextInput'; +import { NAME } from './FilterBuilderRowValue'; +import styles from './DateFilterBuilderRowValue.css'; + +const timeOptions = [ + { key: 'seconds', value: 'seconds' }, + { key: 'minutes', value: 'minutes' }, + { key: 'hours', value: 'hours' }, + { key: 'days', value: 'days' }, + { key: 'weeks', value: 'weeks' }, + { key: 'months', value: 'months' } +]; + +function isInFilter(filterType) { + return filterType === IN_LAST || filterType === IN_NEXT; +} + +class DateFilterBuilderRowValue extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + filterType, + filterValue, + onChange + } = this.props; + + if (isInFilter(filterType) && isString(filterValue)) { + onChange({ + name: NAME, + value: { + time: timeOptions[0].key, + value: null + } + }); + } + } + + componentDidUpdate(prevProps) { + const { + filterType, + filterValue, + onChange + } = this.props; + + if (prevProps.filterType === filterType) { + return; + } + + if (isInFilter(filterType) && isString(filterValue)) { + onChange({ + name: NAME, + value: { + time: timeOptions[0].key, + value: null + } + }); + + return; + } + + if (!isInFilter(filterType) && !isString(filterValue)) { + onChange({ + name: NAME, + value: '' + }); + } + } + + // + // Listeners + + onValueChange = ({ value }) => { + const { + filterValue, + onChange + } = this.props; + + let newValue = value; + + if (!isString(value)) { + newValue = { + time: filterValue.time, + value + }; + } + + onChange({ + name: NAME, + value: newValue + }); + } + + onTimeChange = ({ value }) => { + const { + filterValue, + onChange + } = this.props; + + onChange({ + name: NAME, + value: { + time: value, + value: filterValue.value + } + }); + } + + // + // Render + + render() { + const { + filterType, + filterValue + } = this.props; + + if ( + (isInFilter(filterType) && isString(filterValue)) || + (!isInFilter(filterType) && !isString(filterValue)) + ) { + return null; + } + + if (isInFilter(filterType)) { + return ( +
+ + + +
+ ); + } + + return ( + + ); + } +} + +DateFilterBuilderRowValue.propTypes = { + filterType: PropTypes.string, + filterValue: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired, + onChange: PropTypes.func.isRequired +}; + +export default DateFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.css b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.css new file mode 100644 index 000000000..6cc8fab67 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.css @@ -0,0 +1,16 @@ +.labelContainer { + margin-bottom: 20px; +} + +.label { + margin-bottom: 5px; + font-weight: bold; +} + +.labelInputContainer { + width: 300px; +} + +.rows { + margin-bottom: 100px; +} diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js new file mode 100644 index 000000000..62c3f0197 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js @@ -0,0 +1,228 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import FilterBuilderRow from './FilterBuilderRow'; +import styles from './FilterBuilderModalContent.css'; + +class FilterBuilderModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const filters = [...props.filters]; + + // Push an empty filter if there aren't any filters. FilterBuilderRow + // will handle initializing the filter. + + if (!filters.length) { + filters.push({}); + } + + this.state = { + label: props.label, + filters, + labelErrors: [] + }; + } + + componentDidUpdate(prevProps) { + const { + id, + customFilters, + isSaving, + saveError, + dispatchSetFilter, + onModalClose + } = this.props; + + if (prevProps.isSaving && !isSaving && !saveError) { + if (id) { + dispatchSetFilter({ selectedFilterKey: id }); + } else { + const last = customFilters[customFilters.length -1]; + dispatchSetFilter({ selectedFilterKey: last.id }); + } + + onModalClose(); + } + } + + // + // Listeners + + onLabelChange = ({ value }) => { + this.setState({ label: value }); + } + + onFilterChange = (index, filter) => { + const filters = [...this.state.filters]; + filters.splice(index, 1, filter); + + this.setState({ + filters + }); + } + + onAddFilterPress = () => { + const filters = [...this.state.filters]; + filters.push({}); + + this.setState({ + filters + }); + } + + onRemoveFilterPress = (index) => { + const filters = [...this.state.filters]; + filters.splice(index, 1); + + this.setState({ + filters + }); + } + + onSaveFilterPress = () => { + const { + id, + customFilterType, + onSaveCustomFilterPress + } = this.props; + + const { + label, + filters + } = this.state; + + if (!label) { + this.setState({ + labelErrors: [ + { + message: 'Label is required' + } + ] + }); + + return; + } + + onSaveCustomFilterPress({ + id, + type: customFilterType, + label, + filters + }); + } + + // + // Render + + render() { + const { + sectionItems, + filterBuilderProps, + isSaving, + saveError, + onCancelPress, + onModalClose + } = this.props; + + const { + label, + filters, + labelErrors + } = this.state; + + return ( + + + Custom Filter + + + +
+
+ Label +
+ +
+ +
+
+ +
Filters
+ +
+ { + filters.map((filter, index) => { + return ( + + ); + }) + } +
+
+ + + + + + Save + + +
+ ); + } +} + +FilterBuilderModalContent.propTypes = { + id: PropTypes.number, + label: PropTypes.string.isRequired, + customFilterType: PropTypes.string.isRequired, + sectionItems: PropTypes.arrayOf(PropTypes.object).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + filterBuilderProps: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + dispatchDeleteCustomFilter: PropTypes.func.isRequired, + onSaveCustomFilterPress: PropTypes.func.isRequired, + dispatchSetFilter: PropTypes.func.isRequired, + onCancelPress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default FilterBuilderModalContent; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderModalContentConnector.js b/frontend/src/Components/Filter/Builder/FilterBuilderModalContentConnector.js new file mode 100644 index 000000000..c94db9925 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContentConnector.js @@ -0,0 +1,42 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { saveCustomFilter, deleteCustomFilter } from 'Store/Actions/customFilterActions'; +import FilterBuilderModalContent from './FilterBuilderModalContent'; + +function createMapStateToProps() { + return createSelector( + (state, { customFilters }) => customFilters, + (state, { id }) => id, + (state) => state.customFilters.isSaving, + (state) => state.customFilters.saveError, + (customFilters, id, isSaving, saveError) => { + if (id) { + const customFilter = customFilters.find((c) => c.id === id); + + return { + id: customFilter.id, + label: customFilter.label, + filters: customFilter.filters, + customFilters, + isSaving, + saveError + }; + } + + return { + label: '', + filters: [], + customFilters, + isSaving, + saveError + }; + } + ); +} + +const mapDispatchToProps = { + onSaveCustomFilterPress: saveCustomFilter, + dispatchDeleteCustomFilter: deleteCustomFilter +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(FilterBuilderModalContent); diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.css b/frontend/src/Components/Filter/Builder/FilterBuilderRow.css new file mode 100644 index 000000000..c5471b253 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.css @@ -0,0 +1,32 @@ +.filterRow { + display: flex; + margin-bottom: 5px; + + &:hover { + background-color: $tableRowHoverBackgroundColor; + } +} + +.inputContainer { + flex: 0 1 200px; + margin-right: 10px; +} + +.valueInputContainer { + flex: 0 1 300px; + margin-right: 10px; +} + +.actionsContainer { + display: flex; +} + +@media only screen and (max-width: $breakpointSmall) { + .filterRow { + display: block; + } + + .inputContainer { + margin-bottom: 10px; + } +} diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js new file mode 100644 index 000000000..26bc50192 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js @@ -0,0 +1,286 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { filterBuilderTypes, filterBuilderValueTypes, icons } from 'Helpers/Props'; +import SelectInput from 'Components/Form/SelectInput'; +import IconButton from 'Components/Link/IconButton'; +import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue'; +import DateFilterBuilderRowValue from './DateFilterBuilderRowValue'; +import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector'; +import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValueConnector'; +import MetadataProfileFilterBuilderRowValueConnector from './MetadataProfileFilterBuilderRowValueConnector'; +import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue'; +import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector'; +import QualityProfileFilterBuilderRowValueConnector from './QualityProfileFilterBuilderRowValueConnector'; +import ArtistStatusFilterBuilderRowValue from './ArtistStatusFilterBuilderRowValue'; +import TagFilterBuilderRowValueConnector from './TagFilterBuilderRowValueConnector'; +import styles from './FilterBuilderRow.css'; + +function getselectedFilterBuilderProp(filterBuilderProps, name) { + return filterBuilderProps.find((a) => { + return a.name === name; + }); +} + +function getFilterTypeOptions(filterBuilderProps, filterKey) { + const selectedFilterBuilderProp = getselectedFilterBuilderProp(filterBuilderProps, filterKey); + + if (!selectedFilterBuilderProp) { + return []; + } + + return filterBuilderTypes.possibleFilterTypes[selectedFilterBuilderProp.type]; +} + +function getDefaultFilterType(selectedFilterBuilderProp) { + return filterBuilderTypes.possibleFilterTypes[selectedFilterBuilderProp.type][0].key; +} + +function getDefaultFilterValue(selectedFilterBuilderProp) { + if (selectedFilterBuilderProp.type === filterBuilderTypes.DATE) { + return ''; + } + + return []; +} + +function getRowValueConnector(selectedFilterBuilderProp) { + if (!selectedFilterBuilderProp) { + return FilterBuilderRowValueConnector; + } + + const valueType = selectedFilterBuilderProp.valueType; + + switch (valueType) { + case filterBuilderValueTypes.BOOL: + return BoolFilterBuilderRowValue; + + case filterBuilderValueTypes.DATE: + return DateFilterBuilderRowValue; + + case filterBuilderValueTypes.INDEXER: + return IndexerFilterBuilderRowValueConnector; + + case filterBuilderValueTypes.METADATA_PROFILE: + return MetadataProfileFilterBuilderRowValueConnector; + + case filterBuilderValueTypes.PROTOCOL: + return ProtocolFilterBuilderRowValue; + + case filterBuilderValueTypes.QUALITY: + return QualityFilterBuilderRowValueConnector; + + case filterBuilderValueTypes.QUALITY_PROFILE: + return QualityProfileFilterBuilderRowValueConnector; + + case filterBuilderValueTypes.ARTIST_STATUS: + return ArtistStatusFilterBuilderRowValue; + + case filterBuilderValueTypes.TAG: + return TagFilterBuilderRowValueConnector; + + default: + return FilterBuilderRowValueConnector; + } +} + +class FilterBuilderRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + filterKey, + filterBuilderProps + } = props; + + if (filterKey) { + const selectedFilterBuilderProp = filterBuilderProps.find((a) => a.name === filterKey); + this.selectedFilterBuilderProp = selectedFilterBuilderProp; + } + } + + componentDidMount() { + const { + index, + filterKey, + filterBuilderProps, + onFilterChange + } = this.props; + + if (filterKey) { + const selectedFilterBuilderProp = filterBuilderProps.find((a) => a.name === filterKey); + this.selectedFilterBuilderProp = selectedFilterBuilderProp; + + return; + } + + const selectedFilterBuilderProp = filterBuilderProps[0]; + + const filter = { + key: selectedFilterBuilderProp.name, + value: getDefaultFilterValue(selectedFilterBuilderProp), + type: getDefaultFilterType(selectedFilterBuilderProp) + }; + + this.selectedFilterBuilderProp = selectedFilterBuilderProp; + onFilterChange(index, filter); + } + + // + // Listeners + + onFilterKeyChange = ({ value: key }) => { + const { + index, + filterBuilderProps, + onFilterChange + } = this.props; + + const selectedFilterBuilderProp = getselectedFilterBuilderProp(filterBuilderProps, key); + const type = getDefaultFilterType(selectedFilterBuilderProp); + + const filter = { + key, + value: getDefaultFilterValue(selectedFilterBuilderProp), + type + }; + + this.selectedFilterBuilderProp = selectedFilterBuilderProp; + onFilterChange(index, filter); + } + + onFilterChange = ({ name, value }) => { + const { + index, + filterKey, + filterValue, + filterType, + onFilterChange + } = this.props; + + const filter = { + key: filterKey, + value: filterValue, + type: filterType + }; + + filter[name] = value; + + onFilterChange(index, filter); + } + + onAddPress = () => { + const { + index, + onAddPress + } = this.props; + + onAddPress(index); + } + + onRemovePress = () => { + const { + index, + onRemovePress + } = this.props; + + onRemovePress(index); + } + + // + // Render + + render() { + const { + filterKey, + filterType, + filterValue, + filterCount, + filterBuilderProps, + sectionItems + } = this.props; + + const selectedFilterBuilderProp = this.selectedFilterBuilderProp; + + const keyOptions = filterBuilderProps.map((availablePropFilter) => { + return { + key: availablePropFilter.name, + value: availablePropFilter.label + }; + }); + + const ValueComponent = getRowValueConnector(selectedFilterBuilderProp); + + return ( +
+
+ { + filterKey && + + } +
+ +
+ { + filterType && + + } +
+ +
+ { + filterValue != null && !!selectedFilterBuilderProp && + + } +
+ +
+ + + +
+
+ ); + } +} + +FilterBuilderRow.propTypes = { + index: PropTypes.number.isRequired, + filterKey: PropTypes.string, + filterValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.array, PropTypes.object]), + filterType: PropTypes.string, + filterCount: PropTypes.number.isRequired, + filterBuilderProps: PropTypes.arrayOf(PropTypes.object).isRequired, + sectionItems: PropTypes.arrayOf(PropTypes.object).isRequired, + onFilterChange: PropTypes.func.isRequired, + onAddPress: PropTypes.func.isRequired, + onRemovePress: PropTypes.func.isRequired +}; + +export default FilterBuilderRow; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js new file mode 100644 index 000000000..ef6084c02 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js @@ -0,0 +1,160 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import convertToBytes from 'Utilities/Number/convertToBytes'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { kinds, filterBuilderTypes, filterBuilderValueTypes } from 'Helpers/Props'; +import tagShape from 'Helpers/Props/Shapes/tagShape'; +import TagInput from 'Components/Form/TagInput'; +import FilterBuilderRowValueTag from './FilterBuilderRowValueTag'; + +export const NAME = 'value'; + +function getTagDisplayValue(value, selectedFilterBuilderProp) { + if (selectedFilterBuilderProp.valueType === filterBuilderValueTypes.BYTES) { + return formatBytes(value); + } + + return value; +} + +function getValue(input, selectedFilterBuilderProp) { + if (selectedFilterBuilderProp.valueType === filterBuilderValueTypes.BYTES) { + const match = input.match(/^(\d+)([kmgt](i?b)?)$/i); + + if (match && match.length > 1) { + const [, value, unit] = input.match(/^(\d+)([kmgt](i?b)?)$/i); + + switch (unit.toLowerCase()) { + case 'k': + return convertToBytes(value, 1, true); + case 'm': + return convertToBytes(value, 2, true); + case 'g': + return convertToBytes(value, 3, true); + case 't': + return convertToBytes(value, 4, true); + case 'kb': + return convertToBytes(value, 1, true); + case 'mb': + return convertToBytes(value, 2, true); + case 'gb': + return convertToBytes(value, 3, true); + case 'tb': + return convertToBytes(value, 4, true); + case 'kib': + return convertToBytes(value, 1, true); + case 'mib': + return convertToBytes(value, 2, true); + case 'gib': + return convertToBytes(value, 3, true); + case 'tib': + return convertToBytes(value, 4, true); + default: + return parseInt(value); + } + } + } + + if (selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER) { + return parseInt(input); + } + + return input; +} + +class FilterBuilderRowValue extends Component { + + // + // Listeners + + onTagAdd = (tag) => { + const { + filterValue, + selectedFilterBuilderProp, + onChange + } = this.props; + + let value = tag.id; + + if (value == null) { + value = getValue(tag.name, selectedFilterBuilderProp); + } + + onChange({ + name: NAME, + value: [...filterValue, value] + }); + } + + onTagDelete = ({ index }) => { + const { + filterValue, + onChange + } = this.props; + + const value = filterValue.filter((v, i) => i !== index); + + onChange({ + name: NAME, + value + }); + } + + // + // Render + + render() { + const { + filterValue, + selectedFilterBuilderProp, + tagList + } = this.props; + + const hasItems = !!tagList.length; + + const tags = filterValue.map((id) => { + if (hasItems) { + const tag = tagList.find((t) => t.id === id); + + return { + id, + name: tag && tag.name + }; + } + + return { + id, + name: getTagDisplayValue(id, selectedFilterBuilderProp) + }; + }); + + return ( + + ); + } +} + +FilterBuilderRowValue.propTypes = { + filterValue: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.bool, PropTypes.string, PropTypes.number])).isRequired, + selectedFilterBuilderProp: PropTypes.object.isRequired, + tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, + onChange: PropTypes.func.isRequired +}; + +FilterBuilderRowValue.defaultProps = { + filterValue: [] +}; + +export default FilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js new file mode 100644 index 000000000..c8813284e --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js @@ -0,0 +1,60 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import sortByName from 'Utilities/Array/sortByName'; +import { filterBuilderTypes } from 'Helpers/Props'; +import * as filterTypes from 'Helpers/Props/filterTypes'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createTagListSelector() { + return createSelector( + (state, { filterType }) => filterType, + (state, { sectionItems }) => sectionItems, + (state, { selectedFilterBuilderProp }) => selectedFilterBuilderProp, + (filterType, sectionItems, selectedFilterBuilderProp) => { + if ( + (selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER || + selectedFilterBuilderProp.type === filterBuilderTypes.STRING) && + filterType !== filterTypes.EQUAL && + filterType !== filterBuilderTypes.NOT_EQUAL || + !selectedFilterBuilderProp.optionsSelector + ) { + return []; + } + + let items = []; + + if (selectedFilterBuilderProp.optionsSelector) { + items = selectedFilterBuilderProp.optionsSelector(sectionItems); + } else { + items = sectionItems.reduce((acc, item) => { + const name = item[selectedFilterBuilderProp.name]; + + if (name) { + acc.push({ + id: name, + name + }); + } + + return acc; + }, []).sort(sortByName); + } + + return _.uniqBy(items, 'id'); + } + ); +} + +function createMapStateToProps() { + return createSelector( + createTagListSelector(), + (tagList) => { + return { + tagList + }; + } + ); +} + +export default connect(createMapStateToProps)(FilterBuilderRowValue); diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.css b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.css new file mode 100644 index 000000000..9bf027af9 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.css @@ -0,0 +1,19 @@ +.tag { + &.isLastTag { + .or { + display: none; + } + } +} + +.label { + composes: label from '~Components/Label.css'; + + border-style: none; + font-size: 13px; +} + +.or { + margin: 0 3px; + color: $themeDarkColor; +} diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js new file mode 100644 index 000000000..573e05759 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import TagInputTag from 'Components/Form/TagInputTag'; +import styles from './FilterBuilderRowValueTag.css'; + +function FilterBuilderRowValueTag(props) { + return ( + + + + { + !props.isLastTag && + + or + + } + + ); +} + +FilterBuilderRowValueTag.propTypes = { + isLastTag: PropTypes.bool.isRequired +}; + +export default FilterBuilderRowValueTag; diff --git a/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js new file mode 100644 index 000000000..0132ae641 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js @@ -0,0 +1,79 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import tagShape from 'Helpers/Props/Shapes/tagShape'; +import { fetchIndexers } from 'Store/Actions/settingsActions'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.indexers, + (qualityProfiles) => { + const { + isFetching, + isPopulated, + error, + items + } = qualityProfiles; + + const tagList = items.map((item) => { + return { + id: item.id, + name: item.name + }; + }); + + return { + isFetching, + isPopulated, + error, + tagList + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchIndexers: fetchIndexers +}; + +class IndexerFilterBuilderRowValueConnector extends Component { + + // + // Lifecycle + + componentDidMount = () => { + if (!this.props.isPopulated) { + this.props.dispatchFetchIndexers(); + } + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +IndexerFilterBuilderRowValueConnector.propTypes = { + tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + dispatchFetchIndexers: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(IndexerFilterBuilderRowValueConnector); diff --git a/frontend/src/Components/Filter/Builder/MetadataProfileFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/MetadataProfileFilterBuilderRowValueConnector.js new file mode 100644 index 000000000..89d6c06b3 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/MetadataProfileFilterBuilderRowValueConnector.js @@ -0,0 +1,28 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.metadataProfiles, + (metadataProfiles) => { + const tagList = metadataProfiles.items.map((metadataProfile) => { + const { + id, + name + } = metadataProfile; + + return { + id, + name + }; + }); + + return { + tagList + }; + } + ); +} + +export default connect(createMapStateToProps)(FilterBuilderRowValue); diff --git a/frontend/src/Components/Filter/Builder/ProtocolFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/ProtocolFilterBuilderRowValue.js new file mode 100644 index 000000000..ae63ae0eb --- /dev/null +++ b/frontend/src/Components/Filter/Builder/ProtocolFilterBuilderRowValue.js @@ -0,0 +1,18 @@ +import React from 'react'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +const protocols = [ + { id: 'torrent', name: 'Torrent' }, + { id: 'usenet', name: 'Usenet' } +]; + +function ProtocolFilterBuilderRowValue(props) { + return ( + + ); +} + +export default ProtocolFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js new file mode 100644 index 000000000..d0443bf19 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js @@ -0,0 +1,75 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import getQualities from 'Utilities/Quality/getQualities'; +import tagShape from 'Helpers/Props/Shapes/tagShape'; +import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.qualityProfiles, + (qualityProfiles) => { + const { + isSchemaFetching: isFetching, + isSchemaPopulated: isPopulated, + schemaError: error, + schema + } = qualityProfiles; + + const tagList = getQualities(schema.items); + + return { + isFetching, + isPopulated, + error, + tagList + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchQualityProfileSchema: fetchQualityProfileSchema +}; + +class QualityFilterBuilderRowValueConnector extends Component { + + // + // Lifecycle + + componentDidMount = () => { + if (!this.props.isPopulated) { + this.props.dispatchFetchQualityProfileSchema(); + } + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +QualityFilterBuilderRowValueConnector.propTypes = { + tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + dispatchFetchQualityProfileSchema: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(QualityFilterBuilderRowValueConnector); diff --git a/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js new file mode 100644 index 000000000..4a8b82283 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js @@ -0,0 +1,28 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.qualityProfiles, + (qualityProfiles) => { + const tagList = qualityProfiles.items.map((qualityProfile) => { + const { + id, + name + } = qualityProfile; + + return { + id, + name + }; + }); + + return { + tagList + }; + } + ); +} + +export default connect(createMapStateToProps)(FilterBuilderRowValue); diff --git a/frontend/src/Components/Filter/Builder/TagFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/TagFilterBuilderRowValueConnector.js new file mode 100644 index 000000000..60e04c446 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/TagFilterBuilderRowValueConnector.js @@ -0,0 +1,27 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createMapStateToProps() { + return createSelector( + createTagsSelector(), + (tagList) => { + return { + tagList: tagList.map((tag) => { + const { + id, + label: name + } = tag; + + return { + id, + name + }; + }) + }; + } + ); +} + +export default connect(createMapStateToProps)(FilterBuilderRowValue); diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFilter.css b/frontend/src/Components/Filter/CustomFilters/CustomFilter.css new file mode 100644 index 000000000..7acb69dc7 --- /dev/null +++ b/frontend/src/Components/Filter/CustomFilters/CustomFilter.css @@ -0,0 +1,17 @@ +.customFilter { + display: flex; + margin-bottom: 5px; + padding: 5px; + + &:hover { + background-color: $tableRowHoverBackgroundColor; + } +} + +.label { + flex: 0 1 300px; +} + +.actions { + flex: 0 0 60px; +} diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFilter.js b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js new file mode 100644 index 000000000..c9c326d78 --- /dev/null +++ b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js @@ -0,0 +1,114 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import styles from './CustomFilter.css'; + +class CustomFilter extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDeleting: false + }; + } + + componentDidUpdate(prevProps) { + const { + isDeleting, + deleteError + } = this.props; + + if (prevProps.isDeleting && !isDeleting && this.state.isDeleting && deleteError) { + this.setState({ isDeleting: false }); + } + } + + componentWillUnmount() { + const { + id, + selectedFilterKey, + dispatchSetFilter + } = this.props; + + // Assume that delete and then unmounting means the delete was successful. + // Moving this check to a ancestor would be more accurate, but would have + // more boilerplate. + if (this.state.isDeleting && id === selectedFilterKey) { + dispatchSetFilter({ selectedFilterKey: 'all' }); + } + } + + // + // Listeners + + onEditPress = () => { + const { + id, + onEditPress + } = this.props; + + onEditPress(id); + } + + onRemovePress = () => { + const { + id, + dispatchDeleteCustomFilter + } = this.props; + + this.setState({ isDeleting: true }, () => { + dispatchDeleteCustomFilter({ id }); + }); + + } + + // + // Render + + render() { + const { + label + } = this.props; + + return ( +
+
+ {label} +
+ +
+ + + +
+
+ ); + } +} + +CustomFilter.propTypes = { + id: PropTypes.number.isRequired, + label: PropTypes.string.isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + isDeleting: PropTypes.bool.isRequired, + deleteError: PropTypes.object, + dispatchSetFilter: PropTypes.func.isRequired, + onEditPress: PropTypes.func.isRequired, + dispatchDeleteCustomFilter: PropTypes.func.isRequired +}; + +export default CustomFilter; diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.css b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.css new file mode 100644 index 000000000..c391764dc --- /dev/null +++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.css @@ -0,0 +1,3 @@ +.addButtonContainer { + margin-top: 15px; +} diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js new file mode 100644 index 000000000..fb2c13a12 --- /dev/null +++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js @@ -0,0 +1,80 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import CustomFilter from './CustomFilter'; +import styles from './CustomFiltersModalContent.css'; + +function CustomFiltersModalContent(props) { + const { + selectedFilterKey, + customFilters, + isDeleting, + deleteError, + dispatchDeleteCustomFilter, + dispatchSetFilter, + onAddCustomFilter, + onEditCustomFilter, + onModalClose + } = props; + + return ( + + + Custom Filters + + + + { + customFilters.map((customFilter) => { + return ( + + ); + }) + } + +
+ +
+
+ + + + +
+ ); +} + +CustomFiltersModalContent.propTypes = { + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + isDeleting: PropTypes.bool.isRequired, + deleteError: PropTypes.object, + dispatchDeleteCustomFilter: PropTypes.func.isRequired, + dispatchSetFilter: PropTypes.func.isRequired, + onAddCustomFilter: PropTypes.func.isRequired, + onEditCustomFilter: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default CustomFiltersModalContent; diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContentConnector.js b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContentConnector.js new file mode 100644 index 000000000..32425d766 --- /dev/null +++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContentConnector.js @@ -0,0 +1,23 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { deleteCustomFilter } from 'Store/Actions/customFilterActions'; +import CustomFiltersModalContent from './CustomFiltersModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.customFilters.isDeleting, + (state) => state.customFilters.deleteError, + (isDeleting, deleteError) => { + return { + isDeleting, + deleteError + }; + } + ); +} + +const mapDispatchToProps = { + dispatchDeleteCustomFilter: deleteCustomFilter +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(CustomFiltersModalContent); diff --git a/frontend/src/Components/Filter/FilterModal.js b/frontend/src/Components/Filter/FilterModal.js new file mode 100644 index 000000000..729f380e7 --- /dev/null +++ b/frontend/src/Components/Filter/FilterModal.js @@ -0,0 +1,102 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import FilterBuilderModalContentConnector from './Builder/FilterBuilderModalContentConnector'; +import CustomFiltersModalContentConnector from './CustomFilters/CustomFiltersModalContentConnector'; + +class FilterModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + filterBuilder: !props.customFilters.length, + id: null + }; + } + + // + // Listeners + + onAddCustomFilter = () => { + this.setState({ + filterBuilder: true + }); + } + + onEditCustomFilter = (id) => { + this.setState({ + filterBuilder: true, + id + }); + } + + onCancelPress = () => { + if (this.state.filterBuilder) { + this.setState({ + filterBuilder: false, + id: null + }); + } else { + this.onModalClose(); + } + } + + onModalClose = () => { + this.setState({ + filterBuilder: false, + id: null + }, () => { + this.props.onModalClose(); + }); + } + + // + // Render + + render() { + const { + isOpen, + ...otherProps + } = this.props; + + const { + filterBuilder, + id + } = this.state; + + return ( + + { + filterBuilder ? + : + + } + + ); + } +} + +FilterModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default FilterModal; diff --git a/frontend/src/Components/Form/AlbumReleaseSelectInputConnector.js b/frontend/src/Components/Form/AlbumReleaseSelectInputConnector.js new file mode 100644 index 000000000..b79c0db1d --- /dev/null +++ b/frontend/src/Components/Form/AlbumReleaseSelectInputConnector.js @@ -0,0 +1,70 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import titleCase from 'Utilities/String/titleCase'; +import SelectInput from './SelectInput'; + +function createMapStateToProps() { + return createSelector( + (state, { albumReleases }) => albumReleases, + (albumReleases) => { + const values = _.map(albumReleases.value, (albumRelease) => { + + return { + key: albumRelease.foreignReleaseId, + value: `${albumRelease.title}` + + `${albumRelease.disambiguation ? ' (' : ''}${titleCase(albumRelease.disambiguation)}${albumRelease.disambiguation ? ')' : ''}` + + `, ${albumRelease.mediumCount} med, ${albumRelease.trackCount} tracks` + + `${albumRelease.country.length > 0 ? ', ' : ''}${albumRelease.country}` + + `${albumRelease.format ? ', [' : ''}${albumRelease.format}${albumRelease.format ? ']' : ''}` + }; + }); + + const sortedValues = _.orderBy(values, ['value']); + + const value = _.find(albumReleases.value, { monitored: true }).foreignReleaseId; + + return { + values: sortedValues, + value + }; + } + ); +} + +class AlbumReleaseSelectInputConnector extends Component { + + // + // Listeners + + onChange = ({ name, value }) => { + const { + albumReleases + } = this.props; + + const updatedReleases = _.map(albumReleases.value, (e) => ({ ...e, monitored: false })); + _.find(updatedReleases, { foreignReleaseId: value }).monitored = true; + + this.props.onChange({ name, value: updatedReleases }); + } + + render() { + + return ( + + ); + } +} + +AlbumReleaseSelectInputConnector.propTypes = { + name: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + albumReleases: PropTypes.object +}; + +export default connect(createMapStateToProps)(AlbumReleaseSelectInputConnector); diff --git a/frontend/src/Components/Form/AutoCompleteInput.js b/frontend/src/Components/Form/AutoCompleteInput.js new file mode 100644 index 000000000..e19700d08 --- /dev/null +++ b/frontend/src/Components/Form/AutoCompleteInput.js @@ -0,0 +1,98 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import jdu from 'jdu'; +import AutoSuggestInput from './AutoSuggestInput'; + +class AutoCompleteInput extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + suggestions: [] + }; + } + + // + // Control + + getSuggestionValue(item) { + return item; + } + + renderSuggestion(item) { + return item; + } + + // + // Listeners + + onInputChange = (event, { newValue }) => { + this.props.onChange({ + name: this.props.name, + value: newValue + }); + } + + onInputBlur = () => { + this.setState({ suggestions: [] }); + } + + onSuggestionsFetchRequested = ({ value }) => { + const { values } = this.props; + const lowerCaseValue = jdu.replace(value).toLowerCase(); + + const filteredValues = values.filter((v) => { + return jdu.replace(v).toLowerCase().contains(lowerCaseValue); + }); + + this.setState({ suggestions: filteredValues }); + } + + onSuggestionsClearRequested = () => { + this.setState({ suggestions: [] }); + } + + // + // Render + + render() { + const { + name, + value, + ...otherProps + } = this.props; + + const { suggestions } = this.state; + + return ( + + ); + } +} + +AutoCompleteInput.propTypes = { + name: PropTypes.string.isRequired, + value: PropTypes.string, + values: PropTypes.arrayOf(PropTypes.string).isRequired, + onChange: PropTypes.func.isRequired +}; + +AutoCompleteInput.defaultProps = { + value: '' +}; + +export default AutoCompleteInput; diff --git a/frontend/src/Components/Form/AutoSuggestInput.css b/frontend/src/Components/Form/AutoSuggestInput.css new file mode 100644 index 000000000..0f3279cb9 --- /dev/null +++ b/frontend/src/Components/Form/AutoSuggestInput.css @@ -0,0 +1,50 @@ +.input { + composes: input from '~Components/Form/Input.css'; +} + +.hasError { + composes: hasError from '~Components/Form/Input.css'; +} + +.hasWarning { + composes: hasWarning from '~Components/Form/Input.css'; +} + +.inputContainer { + flex-grow: 1; +} + +.suggestionsContainer { + @add-mixin scrollbar; + @add-mixin scrollbarTrack; + @add-mixin scrollbarThumb; +} + +.suggestionsContainerOpen { + z-index: $popperZIndex; + + .suggestionsContainer { + overflow-y: auto; + max-height: 200px; + width: 100%; + border: 1px solid $inputBorderColor; + border-radius: 4px; + background-color: $white; + box-shadow: inset 0 1px 1px $inputBoxShadowColor; + } +} + +.suggestionsList { + margin: 5px 0; + padding-left: 0; + max-height: 200px; + list-style-type: none; +} + +.suggestion { + padding: 0 16px; +} + +.suggestionHighlighted { + background-color: $menuItemHoverBackgroundColor; +} diff --git a/frontend/src/Components/Form/AutoSuggestInput.js b/frontend/src/Components/Form/AutoSuggestInput.js new file mode 100644 index 000000000..dd5833ee0 --- /dev/null +++ b/frontend/src/Components/Form/AutoSuggestInput.js @@ -0,0 +1,257 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Autosuggest from 'react-autosuggest'; +import { Manager, Popper, Reference } from 'react-popper'; +import classNames from 'classnames'; +import Portal from 'Components/Portal'; +import styles from './AutoSuggestInput.css'; + +class AutoSuggestInput extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._scheduleUpdate = null; + } + + componentDidUpdate(prevProps) { + if ( + this._scheduleUpdate && + prevProps.suggestions !== this.props.suggestions + ) { + this._scheduleUpdate(); + } + } + + // + // Control + + renderInputComponent = (inputProps) => { + const { renderInputComponent } = this.props; + + return ( + + {({ ref }) => { + if (renderInputComponent) { + return renderInputComponent(inputProps, ref); + } + + return ( +
+ +
+ ); + }} +
+ ); + } + + renderSuggestionsContainer = ({ containerProps, children }) => { + return ( + + + {({ ref: popperRef, style, scheduleUpdate }) => { + this._scheduleUpdate = scheduleUpdate; + + return ( +
+
+ {children} +
+
+ ); + }} +
+
+ ); + } + + // + // Listeners + + onComputeMaxHeight = (data) => { + const { + top, + bottom, + width + } = data.offsets.reference; + + const windowHeight = window.innerHeight; + + if ((/^botton/).test(data.placement)) { + data.styles.maxHeight = windowHeight - bottom; + } else { + data.styles.maxHeight = top; + } + + data.styles.width = width; + + return data; + } + + onInputChange = (event, { newValue }) => { + this.props.onChange({ + name: this.props.name, + value: newValue + }); + } + + onInputKeyDown = (event) => { + const { + name, + value, + suggestions, + onChange + } = this.props; + + if ( + event.key === 'Tab' && + suggestions.length && + suggestions[0] !== this.props.value + ) { + event.preventDefault(); + + if (value) { + onChange({ + name, + value: suggestions[0] + }); + } + } + } + + // + // Render + + render() { + const { + forwardedRef, + className, + inputContainerClassName, + name, + value, + placeholder, + suggestions, + hasError, + hasWarning, + getSuggestionValue, + renderSuggestion, + onInputChange, + onInputKeyDown, + onInputFocus, + onInputBlur, + onSuggestionsFetchRequested, + onSuggestionsClearRequested, + onSuggestionSelected, + ...otherProps + } = this.props; + + const inputProps = { + className: classNames( + className, + hasError && styles.hasError, + hasWarning && styles.hasWarning + ), + name, + value, + placeholder, + autoComplete: 'off', + spellCheck: false, + onChange: onInputChange || this.onInputChange, + onKeyDown: onInputKeyDown || this.onInputKeyDown, + onFocus: onInputFocus, + onBlur: onInputBlur + }; + + const theme = { + container: inputContainerClassName, + containerOpen: styles.suggestionsContainerOpen, + suggestionsContainer: styles.suggestionsContainer, + suggestionsList: styles.suggestionsList, + suggestion: styles.suggestion, + suggestionHighlighted: styles.suggestionHighlighted + }; + + return ( + + + + ); + } +} + +AutoSuggestInput.propTypes = { + forwardedRef: PropTypes.func, + className: PropTypes.string.isRequired, + inputContainerClassName: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), + placeholder: PropTypes.string, + suggestions: PropTypes.array.isRequired, + hasError: PropTypes.bool, + hasWarning: PropTypes.bool, + enforceMaxHeight: PropTypes.bool.isRequired, + minHeight: PropTypes.number.isRequired, + maxHeight: PropTypes.number.isRequired, + getSuggestionValue: PropTypes.func.isRequired, + renderInputComponent: PropTypes.elementType, + renderSuggestion: PropTypes.func.isRequired, + onInputChange: PropTypes.func, + onInputKeyDown: PropTypes.func, + onInputFocus: PropTypes.func, + onInputBlur: PropTypes.func.isRequired, + onSuggestionsFetchRequested: PropTypes.func.isRequired, + onSuggestionsClearRequested: PropTypes.func.isRequired, + onSuggestionSelected: PropTypes.func, + onChange: PropTypes.func.isRequired +}; + +AutoSuggestInput.defaultProps = { + className: styles.input, + inputContainerClassName: styles.inputContainer, + enforceMaxHeight: true, + minHeight: 50, + maxHeight: 200 +}; + +export default AutoSuggestInput; diff --git a/frontend/src/Components/Form/CaptchaInput.css b/frontend/src/Components/Form/CaptchaInput.css new file mode 100644 index 000000000..76c076834 --- /dev/null +++ b/frontend/src/Components/Form/CaptchaInput.css @@ -0,0 +1,23 @@ +.captchaInputWrapper { + display: flex; +} + +.input { + composes: input from '~Components/Form/Input.css'; +} + +.hasError { + composes: hasError from '~Components/Form/Input.css'; +} + +.hasWarning { + composes: hasWarning from '~Components/Form/Input.css'; +} + +.hasButton { + composes: hasButton from '~Components/Form/Input.css'; +} + +.recaptchaWrapper { + margin-top: 10px; +} diff --git a/frontend/src/Components/Form/CaptchaInput.js b/frontend/src/Components/Form/CaptchaInput.js new file mode 100644 index 000000000..e1a5df458 --- /dev/null +++ b/frontend/src/Components/Form/CaptchaInput.js @@ -0,0 +1,84 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import ReCAPTCHA from 'react-google-recaptcha'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import FormInputButton from './FormInputButton'; +import TextInput from './TextInput'; +import styles from './CaptchaInput.css'; + +function CaptchaInput(props) { + const { + className, + name, + value, + hasError, + hasWarning, + refreshing, + siteKey, + secretToken, + onChange, + onRefreshPress, + onCaptchaChange + } = props; + + return ( +
+
+ + + + + +
+ + { + !!siteKey && !!secretToken && +
+ +
+ } +
+ ); +} + +CaptchaInput.propTypes = { + className: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + hasError: PropTypes.bool, + hasWarning: PropTypes.bool, + refreshing: PropTypes.bool.isRequired, + siteKey: PropTypes.string, + secretToken: PropTypes.string, + onChange: PropTypes.func.isRequired, + onRefreshPress: PropTypes.func.isRequired, + onCaptchaChange: PropTypes.func.isRequired +}; + +CaptchaInput.defaultProps = { + className: styles.input, + value: '' +}; + +export default CaptchaInput; diff --git a/frontend/src/Components/Form/CaptchaInputConnector.js b/frontend/src/Components/Form/CaptchaInputConnector.js new file mode 100644 index 000000000..17b875c88 --- /dev/null +++ b/frontend/src/Components/Form/CaptchaInputConnector.js @@ -0,0 +1,98 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { refreshCaptcha, getCaptchaCookie, resetCaptcha } from 'Store/Actions/captchaActions'; +import CaptchaInput from './CaptchaInput'; + +function createMapStateToProps() { + return createSelector( + (state) => state.captcha, + (captcha) => { + return captcha; + } + ); +} + +const mapDispatchToProps = { + refreshCaptcha, + getCaptchaCookie, + resetCaptcha +}; + +class CaptchaInputConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps) { + const { + name, + token, + onChange + } = this.props; + + if (token && token !== prevProps.token) { + onChange({ name, value: token }); + } + } + + componentWillUnmount = () => { + this.props.resetCaptcha(); + } + + // + // Listeners + + onRefreshPress = () => { + const { + provider, + providerData + } = this.props; + + this.props.refreshCaptcha({ provider, providerData }); + } + + onCaptchaChange = (captchaResponse) => { + // If the captcha has expired `captchaResponse` will be null. + // In the event it's null don't try to get the captchaCookie. + // TODO: Should we clear the cookie? or reset the captcha? + + if (!captchaResponse) { + return; + } + + const { + provider, + providerData + } = this.props; + + this.props.getCaptchaCookie({ provider, providerData, captchaResponse }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +CaptchaInputConnector.propTypes = { + provider: PropTypes.string.isRequired, + providerData: PropTypes.object.isRequired, + name: PropTypes.string.isRequired, + token: PropTypes.string, + onChange: PropTypes.func.isRequired, + refreshCaptcha: PropTypes.func.isRequired, + getCaptchaCookie: PropTypes.func.isRequired, + resetCaptcha: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(CaptchaInputConnector); diff --git a/frontend/src/Components/Form/CheckInput.css b/frontend/src/Components/Form/CheckInput.css new file mode 100644 index 000000000..e0b05eca3 --- /dev/null +++ b/frontend/src/Components/Form/CheckInput.css @@ -0,0 +1,105 @@ +.container { + position: relative; + display: flex; + flex: 1 1 65%; + user-select: none; +} + +.label { + display: flex; + margin-bottom: 0; + min-height: 21px; + font-weight: normal; + cursor: pointer; +} + +.checkbox { + position: absolute; + opacity: 0; + cursor: pointer; + pointer-events: none; + + &:global(.isDisabled) { + cursor: not-allowed; + } +} + +.input { + flex: 1 0 auto; + margin-top: 7px; + margin-right: 5px; + width: 20px; + height: 20px; + border: 1px solid #ccc; + border-radius: 2px; + background-color: $white; + color: $white; + text-align: center; + line-height: 20px; +} + +.checkbox:focus + .input { + outline: 0; + border-color: $inputFocusBorderColor; + box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor; +} + +.dangerIsChecked { + border-color: $dangerColor; + background-color: $dangerColor; + + &.isDisabled { + opacity: 0.7; + } +} + +.primaryIsChecked { + border-color: $primaryColor; + background-color: $primaryColor; + + &.isDisabled { + opacity: 0.7; + } +} + +.successIsChecked { + border-color: $successColor; + background-color: $successColor; + + &.isDisabled { + opacity: 0.7; + } +} + +.warningIsChecked { + border-color: $warningColor; + background-color: $warningColor; + + &.isDisabled { + opacity: 0.7; + } +} + +.isNotChecked { + &.isDisabled { + border-color: $disabledCheckInputColor; + background-color: $disabledCheckInputColor; + opacity: 0.7; + } +} + +.isIndeterminate { + border-color: $gray; + background-color: $gray; +} + +.helpText { + composes: helpText from '~Components/Form/FormInputHelpText.css'; + + margin-top: 8px; + margin-left: 5px; +} + +.isDisabled { + cursor: not-allowed; +} diff --git a/frontend/src/Components/Form/CheckInput.js b/frontend/src/Components/Form/CheckInput.js new file mode 100644 index 000000000..134290111 --- /dev/null +++ b/frontend/src/Components/Form/CheckInput.js @@ -0,0 +1,191 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import FormInputHelpText from './FormInputHelpText'; +import styles from './CheckInput.css'; + +class CheckInput extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._checkbox = null; + } + + componentDidMount() { + this.setIndeterminate(); + } + + componentDidUpdate() { + this.setIndeterminate(); + } + + // + // Control + + setIndeterminate() { + if (!this._checkbox) { + return; + } + + const { + value, + uncheckedValue, + checkedValue + } = this.props; + + this._checkbox.indeterminate = value !== uncheckedValue && value !== checkedValue; + } + + toggleChecked = (checked, shiftKey) => { + const { + name, + value, + checkedValue, + uncheckedValue + } = this.props; + + const newValue = checked ? checkedValue : uncheckedValue; + + if (value !== newValue) { + this.props.onChange({ + name, + value: newValue, + shiftKey + }); + } + } + + // + // Listeners + + setRef = (ref) => { + this._checkbox = ref; + } + + onClick = (event) => { + if (this.props.isDisabled) { + return; + } + + const shiftKey = event.nativeEvent.shiftKey; + const checked = !this._checkbox.checked; + + event.preventDefault(); + this.toggleChecked(checked, shiftKey); + } + + onChange = (event) => { + const checked = event.target.checked; + const shiftKey = event.nativeEvent.shiftKey; + + this.toggleChecked(checked, shiftKey); + } + + // + // Render + + render() { + const { + className, + containerClassName, + name, + value, + checkedValue, + uncheckedValue, + helpText, + helpTextWarning, + isDisabled, + kind + } = this.props; + + const isChecked = value === checkedValue; + const isUnchecked = value === uncheckedValue; + const isIndeterminate = !isChecked && !isUnchecked; + const isCheckClass = `${kind}IsChecked`; + + return ( +
+ +
+ ); + } +} + +CheckInput.propTypes = { + className: PropTypes.string.isRequired, + containerClassName: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + checkedValue: PropTypes.bool, + uncheckedValue: PropTypes.bool, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + helpText: PropTypes.string, + helpTextWarning: PropTypes.string, + isDisabled: PropTypes.bool, + kind: PropTypes.oneOf(kinds.all).isRequired, + onChange: PropTypes.func.isRequired +}; + +CheckInput.defaultProps = { + className: styles.input, + containerClassName: styles.container, + checkedValue: true, + uncheckedValue: false, + kind: kinds.PRIMARY +}; + +export default CheckInput; diff --git a/frontend/src/Components/Form/DeviceInput.css b/frontend/src/Components/Form/DeviceInput.css new file mode 100644 index 000000000..7abe83db5 --- /dev/null +++ b/frontend/src/Components/Form/DeviceInput.css @@ -0,0 +1,8 @@ +.deviceInputWrapper { + display: flex; +} + +.input { + composes: input from '~./TagInput.css'; + composes: hasButton from '~Components/Form/Input.css'; +} diff --git a/frontend/src/Components/Form/DeviceInput.js b/frontend/src/Components/Form/DeviceInput.js new file mode 100644 index 000000000..f77c7cf29 --- /dev/null +++ b/frontend/src/Components/Form/DeviceInput.js @@ -0,0 +1,106 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import tagShape from 'Helpers/Props/Shapes/tagShape'; +import Icon from 'Components/Icon'; +import FormInputButton from './FormInputButton'; +import TagInput from './TagInput'; +import styles from './DeviceInput.css'; + +class DeviceInput extends Component { + + onTagAdd = (device) => { + const { + name, + value, + onChange + } = this.props; + + // New tags won't have an ID, only a name. + const deviceId = device.id || device.name; + + onChange({ + name, + value: [...value, deviceId] + }); + } + + onTagDelete = ({ index }) => { + const { + name, + value, + onChange + } = this.props; + + const newValue = value.slice(); + newValue.splice(index, 1); + + onChange({ + name, + value: newValue + }); + } + + // + // Render + + render() { + const { + className, + name, + items, + selectedDevices, + hasError, + hasWarning, + isFetching, + onRefreshPress + } = this.props; + + return ( +
+ + + + + +
+ ); + } +} + +DeviceInput.propTypes = { + className: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])).isRequired, + items: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, + selectedDevices: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, + hasError: PropTypes.bool, + hasWarning: PropTypes.bool, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired, + onRefreshPress: PropTypes.func.isRequired +}; + +DeviceInput.defaultProps = { + className: styles.deviceInputWrapper, + inputClassName: styles.input +}; + +export default DeviceInput; diff --git a/frontend/src/Components/Form/DeviceInputConnector.js b/frontend/src/Components/Form/DeviceInputConnector.js new file mode 100644 index 000000000..43e313826 --- /dev/null +++ b/frontend/src/Components/Form/DeviceInputConnector.js @@ -0,0 +1,103 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchOptions, clearOptions } from 'Store/Actions/providerOptionActions'; +import DeviceInput from './DeviceInput'; + +function createMapStateToProps() { + return createSelector( + (state, { value }) => value, + (state) => state.providerOptions, + (value, devices) => { + + return { + ...devices, + selectedDevices: value.map((valueDevice) => { + // Disable equality ESLint rule so we don't need to worry about + // a type mismatch between the value items and the device ID. + // eslint-disable-next-line eqeqeq + const device = devices.items.find((d) => d.id == valueDevice); + + if (device) { + return { + id: device.id, + name: `${device.name} (${device.id})` + }; + } + + return { + id: valueDevice, + name: `Unknown (${valueDevice})` + }; + }) + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchOptions: fetchOptions, + dispatchClearOptions: clearOptions +}; + +class DeviceInputConnector extends Component { + + // + // Lifecycle + + componentDidMount = () => { + this._populate(); + } + + componentWillUnmount = () => { + this.props.dispatchClearOptions(); + } + + // + // Control + + _populate() { + const { + provider, + providerData, + dispatchFetchOptions + } = this.props; + + dispatchFetchOptions({ + action: 'getDevices', + provider, + providerData + }); + } + + // + // Listeners + + onRefreshPress = () => { + this._populate(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +DeviceInputConnector.propTypes = { + provider: PropTypes.string.isRequired, + providerData: PropTypes.object.isRequired, + name: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + dispatchFetchOptions: PropTypes.func.isRequired, + dispatchClearOptions: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DeviceInputConnector); diff --git a/frontend/src/Components/Form/EnhancedSelectInput.css b/frontend/src/Components/Form/EnhancedSelectInput.css new file mode 100644 index 000000000..774a63517 --- /dev/null +++ b/frontend/src/Components/Form/EnhancedSelectInput.css @@ -0,0 +1,78 @@ +.enhancedSelect { + composes: input from '~Components/Form/Input.css'; + composes: link from '~Components/Link/Link.css'; + + position: relative; + display: flex; + align-items: center; + padding: 6px 16px; + width: 100%; + height: 35px; + border: 1px solid $inputBorderColor; + border-radius: 4px; + background-color: $white; + box-shadow: inset 0 1px 1px $inputBoxShadowColor; + color: $black; + cursor: default; +} + +.hasError { + composes: hasError from '~Components/Form/Input.css'; +} + +.hasWarning { + composes: hasWarning from '~Components/Form/Input.css'; +} + +.isDisabled { + opacity: 0.7; + cursor: not-allowed; +} + +.dropdownArrowContainer { + margin-left: 12px; +} + +.dropdownArrowContainerDisabled { + composes: dropdownArrowContainer; + + color: $disabledInputColor; +} + +.optionsContainer { + z-index: $popperZIndex; + width: auto; +} + +.options { + composes: scroller from '~Components/Scroller/Scroller.css'; + + border: 1px solid $inputBorderColor; + border-radius: 4px; + background-color: $white; +} + +.optionsModal { + display: flex; + justify-content: center; + max-width: 90%; + width: 350px !important; + height: auto !important; +} + +.optionsModalBody { + composes: modalBody from '~Components/Modal/ModalBody.css'; + + display: flex; + justify-content: center; + flex-direction: column; + padding: 10px 0; +} + +.optionsModalScroller { + composes: scroller from '~Components/Scroller/Scroller.css'; + + border: 1px solid $inputBorderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js new file mode 100644 index 000000000..80ee78e81 --- /dev/null +++ b/frontend/src/Components/Form/EnhancedSelectInput.js @@ -0,0 +1,449 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { Manager, Popper, Reference } from 'react-popper'; +import classNames from 'classnames'; +import getUniqueElememtId from 'Utilities/getUniqueElementId'; +import { isMobile as isMobileUtil } from 'Utilities/mobile'; +import * as keyCodes from 'Utilities/Constants/keyCodes'; +import { icons, sizes, scrollDirections } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Portal from 'Components/Portal'; +import Link from 'Components/Link/Link'; +import Measure from 'Components/Measure'; +import Modal from 'Components/Modal/Modal'; +import ModalBody from 'Components/Modal/ModalBody'; +import Scroller from 'Components/Scroller/Scroller'; +import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue'; +import HintedSelectInputOption from './HintedSelectInputOption'; +import styles from './EnhancedSelectInput.css'; + +function isArrowKey(keyCode) { + return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW; +} + +function getSelectedOption(selectedIndex, values) { + return values[selectedIndex]; +} + +function findIndex(startingIndex, direction, values) { + let indexToTest = startingIndex + direction; + + while (indexToTest !== startingIndex) { + if (indexToTest < 0) { + indexToTest = values.length - 1; + } else if (indexToTest >= values.length) { + indexToTest = 0; + } + + if (getSelectedOption(indexToTest, values).isDisabled) { + indexToTest = indexToTest + direction; + } else { + return indexToTest; + } + } +} + +function previousIndex(selectedIndex, values) { + return findIndex(selectedIndex, -1, values); +} + +function nextIndex(selectedIndex, values) { + return findIndex(selectedIndex, 1, values); +} + +function getSelectedIndex(props) { + const { + value, + values + } = props; + + return values.findIndex((v) => { + return v.key === value; + }); +} + +function getKey(selectedIndex, values) { + return values[selectedIndex].key; +} + +class EnhancedSelectInput extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._scheduleUpdate = null; + this._buttonId = getUniqueElememtId(); + this._optionsId = getUniqueElememtId(); + + this.state = { + isOpen: false, + selectedIndex: getSelectedIndex(props), + width: 0, + isMobile: isMobileUtil() + }; + } + + componentDidUpdate(prevProps) { + if (this._scheduleUpdate) { + this._scheduleUpdate(); + } + + if (prevProps.value !== this.props.value) { + this.setState({ + selectedIndex: getSelectedIndex(this.props) + }); + } + } + + // + // Control + + _addListener() { + window.addEventListener('click', this.onWindowClick); + } + + _removeListener() { + window.removeEventListener('click', this.onWindowClick); + } + + // + // Listeners + + onComputeMaxHeight = (data) => { + const { + top, + bottom + } = data.offsets.reference; + + const windowHeight = window.innerHeight; + + if ((/^botton/).test(data.placement)) { + data.styles.maxHeight = windowHeight - bottom; + } else { + data.styles.maxHeight = top; + } + + return data; + } + + onWindowClick = (event) => { + const button = document.getElementById(this._buttonId); + const options = document.getElementById(this._optionsId); + + if (!button || this.state.isMobile) { + return; + } + + if ( + !button.contains(event.target) && + options && + !options.contains(event.target) && + this.state.isOpen + ) { + this.setState({ isOpen: false }); + this._removeListener(); + } + } + + onBlur = () => { + // Calling setState without this check prevents the click event from being properly handled on Chrome (it is on firefox) + const origIndex = getSelectedIndex(this.props); + if (origIndex !== this.state.selectedIndex) { + this.setState({ selectedIndex: origIndex }); + } + } + + onKeyDown = (event) => { + const { + values + } = this.props; + + const { + isOpen, + selectedIndex + } = this.state; + + const keyCode = event.keyCode; + const newState = {}; + + if (!isOpen) { + if (isArrowKey(keyCode)) { + event.preventDefault(); + newState.isOpen = true; + } + + if ( + selectedIndex == null || + getSelectedOption(selectedIndex, values).isDisabled + ) { + if (keyCode === keyCodes.UP_ARROW) { + newState.selectedIndex = previousIndex(0, values); + } else if (keyCode === keyCodes.DOWN_ARROW) { + newState.selectedIndex = nextIndex(values.length - 1, values); + } + } + + this.setState(newState); + return; + } + + if (keyCode === keyCodes.UP_ARROW) { + event.preventDefault(); + newState.selectedIndex = previousIndex(selectedIndex, values); + } + + if (keyCode === keyCodes.DOWN_ARROW) { + event.preventDefault(); + newState.selectedIndex = nextIndex(selectedIndex, values); + } + + if (keyCode === keyCodes.ENTER) { + event.preventDefault(); + newState.isOpen = false; + this.onSelect(getKey(selectedIndex, values)); + } + + if (keyCode === keyCodes.TAB) { + newState.isOpen = false; + this.onSelect(getKey(selectedIndex, values)); + } + + if (keyCode === keyCodes.ESCAPE) { + event.preventDefault(); + event.stopPropagation(); + newState.isOpen = false; + newState.selectedIndex = getSelectedIndex(this.props); + } + + if (!_.isEmpty(newState)) { + this.setState(newState); + } + } + + onPress = () => { + if (this.state.isOpen) { + this._removeListener(); + } else { + this._addListener(); + } + + this.setState({ isOpen: !this.state.isOpen }); + } + + onSelect = (value) => { + this.setState({ isOpen: false }); + + this.props.onChange({ + name: this.props.name, + value + }); + } + + onMeasure = ({ width }) => { + this.setState({ width }); + } + + onOptionsModalClose = () => { + this.setState({ isOpen: false }); + } + + // + // Render + + render() { + const { + className, + disabledClassName, + values, + isDisabled, + hasError, + hasWarning, + selectedValueOptions, + selectedValueComponent: SelectedValueComponent, + optionComponent: OptionComponent + } = this.props; + + const { + selectedIndex, + width, + isOpen, + isMobile + } = this.state; + + const selectedOption = getSelectedOption(selectedIndex, values); + + return ( +
+ + + {({ ref }) => ( +
+ + + + {selectedOption ? selectedOption.value : null} + + +
+ +
+ +
+
+ )} +
+ + + {({ ref, style, scheduleUpdate }) => { + this._scheduleUpdate = scheduleUpdate; + + return ( +
+ { + isOpen && !isMobile ? + + { + values.map((v, index) => { + return ( + + {v.value} + + ); + }) + } + : + null + } +
+ ); + } + } +
+
+
+ + { + isMobile && + + + + { + values.map((v, index) => { + return ( + + {v.value} + + ); + }) + } + + + + } +
+ ); + } +} + +EnhancedSelectInput.propTypes = { + className: PropTypes.string, + disabledClassName: PropTypes.string, + name: PropTypes.string.isRequired, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + values: PropTypes.arrayOf(PropTypes.object).isRequired, + isDisabled: PropTypes.bool, + hasError: PropTypes.bool, + hasWarning: PropTypes.bool, + selectedValueOptions: PropTypes.object.isRequired, + selectedValueComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired, + optionComponent: PropTypes.elementType, + onChange: PropTypes.func.isRequired +}; + +EnhancedSelectInput.defaultProps = { + className: styles.enhancedSelect, + disabledClassName: styles.isDisabled, + isDisabled: false, + selectedValueOptions: {}, + selectedValueComponent: HintedSelectInputSelectedValue, + optionComponent: HintedSelectInputOption +}; + +export default EnhancedSelectInput; diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.css b/frontend/src/Components/Form/EnhancedSelectInputOption.css new file mode 100644 index 000000000..18440c50d --- /dev/null +++ b/frontend/src/Components/Form/EnhancedSelectInputOption.css @@ -0,0 +1,45 @@ +.option { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px 10px; + width: 100%; + cursor: default; + + &:hover { + background-color: #f8f8f8; + } +} + +.isSelected { + background-color: #e2e2e2; + + &:hover { + background-color: #e2e2e2; + } + + &.isMobile { + background-color: inherit; + + .iconContainer { + color: $primaryColor; + } + } +} + +.isDisabled { + background-color: #aaa; +} + +.isHidden { + display: none; +} + +.isMobile { + height: 50px; + border-bottom: 1px solid $borderColor; + + &:last-child { + border: none; + } +} diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.js b/frontend/src/Components/Form/EnhancedSelectInputOption.js new file mode 100644 index 000000000..e1b410c28 --- /dev/null +++ b/frontend/src/Components/Form/EnhancedSelectInputOption.js @@ -0,0 +1,81 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import styles from './EnhancedSelectInputOption.css'; + +class EnhancedSelectInputOption extends Component { + + // + // Listeners + + onPress = () => { + const { + id, + onSelect + } = this.props; + + onSelect(id); + } + + // + // Render + + render() { + const { + className, + isSelected, + isDisabled, + isHidden, + isMobile, + children + } = this.props; + + return ( + + {children} + + { + isMobile && +
+ +
+ } + + ); + } +} + +EnhancedSelectInputOption.propTypes = { + className: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, + isSelected: PropTypes.bool.isRequired, + isDisabled: PropTypes.bool.isRequired, + isHidden: PropTypes.bool.isRequired, + isMobile: PropTypes.bool.isRequired, + children: PropTypes.node.isRequired, + onSelect: PropTypes.func.isRequired +}; + +EnhancedSelectInputOption.defaultProps = { + className: styles.option, + isDisabled: false, + isHidden: false +}; + +export default EnhancedSelectInputOption; diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css new file mode 100644 index 000000000..6b8b73af9 --- /dev/null +++ b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css @@ -0,0 +1,7 @@ +.selectedValue { + flex: 1 1 auto; +} + +.isDisabled { + color: $disabledInputColor; +} diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js new file mode 100644 index 000000000..c40ee93c1 --- /dev/null +++ b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js @@ -0,0 +1,35 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import styles from './EnhancedSelectInputSelectedValue.css'; + +function EnhancedSelectInputSelectedValue(props) { + const { + className, + children, + isDisabled + } = props; + + return ( +
+ {children} +
+ ); +} + +EnhancedSelectInputSelectedValue.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.node, + isDisabled: PropTypes.bool.isRequired +}; + +EnhancedSelectInputSelectedValue.defaultProps = { + className: styles.selectedValue, + isDisabled: false +}; + +export default EnhancedSelectInputSelectedValue; diff --git a/frontend/src/Components/Form/Form.css b/frontend/src/Components/Form/Form.css new file mode 100644 index 000000000..52e79aec4 --- /dev/null +++ b/frontend/src/Components/Form/Form.css @@ -0,0 +1,3 @@ +.validationFailures { + margin-bottom: 20px; +} diff --git a/frontend/src/Components/Form/Form.js b/frontend/src/Components/Form/Form.js new file mode 100644 index 000000000..c2c67eddf --- /dev/null +++ b/frontend/src/Components/Form/Form.js @@ -0,0 +1,58 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import styles from './Form.css'; + +function Form({ children, validationErrors, validationWarnings, ...otherProps }) { + return ( +
+ { + validationErrors.length || validationWarnings.length ? +
+ { + validationErrors.map((error, index) => { + return ( + + {error.errorMessage} + + ); + }) + } + + { + validationWarnings.map((warning, index) => { + return ( + + {warning.errorMessage} + + ); + }) + } +
: + null + } + + {children} +
+ ); +} + +Form.propTypes = { + children: PropTypes.node.isRequired, + validationErrors: PropTypes.arrayOf(PropTypes.object).isRequired, + validationWarnings: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +Form.defaultProps = { + validationErrors: [], + validationWarnings: [] +}; + +export default Form; diff --git a/frontend/src/Components/Form/FormGroup.css b/frontend/src/Components/Form/FormGroup.css new file mode 100644 index 000000000..ddce8863b --- /dev/null +++ b/frontend/src/Components/Form/FormGroup.css @@ -0,0 +1,28 @@ +.group { + display: flex; + margin-bottom: 20px; +} + +/* Sizes */ + +.extraSmall { + max-width: $formGroupExtraSmallWidth; +} + +.small { + max-width: $formGroupSmallWidth; +} + +.medium { + max-width: $formGroupMediumWidth; +} + +.large { + max-width: $formGroupLargeWidth; +} + +@media only screen and (max-width: $breakpointLarge) { + .group { + display: block; + } +} diff --git a/frontend/src/Components/Form/FormGroup.js b/frontend/src/Components/Form/FormGroup.js new file mode 100644 index 000000000..d2e04c350 --- /dev/null +++ b/frontend/src/Components/Form/FormGroup.js @@ -0,0 +1,56 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { map } from 'Helpers/elementChildren'; +import { sizes } from 'Helpers/Props'; +import styles from './FormGroup.css'; + +function FormGroup(props) { + const { + className, + children, + size, + advancedSettings, + isAdvanced, + ...otherProps + } = props; + + if (!advancedSettings && isAdvanced) { + return null; + } + + const childProps = isAdvanced ? { isAdvanced } : {}; + + return ( +
+ { + map(children, (child) => { + return React.cloneElement(child, childProps); + }) + } +
+ ); +} + +FormGroup.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, + size: PropTypes.oneOf(sizes.all).isRequired, + advancedSettings: PropTypes.bool.isRequired, + isAdvanced: PropTypes.bool.isRequired +}; + +FormGroup.defaultProps = { + className: styles.group, + size: sizes.SMALL, + advancedSettings: false, + isAdvanced: false +}; + +export default FormGroup; diff --git a/frontend/src/Components/Form/FormInputButton.css b/frontend/src/Components/Form/FormInputButton.css new file mode 100644 index 000000000..da4888f09 --- /dev/null +++ b/frontend/src/Components/Form/FormInputButton.css @@ -0,0 +1,12 @@ +.button { + composes: button from '~Components/Link/Button.css'; + + border-left: none; + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.middleButton { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} diff --git a/frontend/src/Components/Form/FormInputButton.js b/frontend/src/Components/Form/FormInputButton.js new file mode 100644 index 000000000..4b6491663 --- /dev/null +++ b/frontend/src/Components/Form/FormInputButton.js @@ -0,0 +1,54 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import { kinds } from 'Helpers/Props'; +import styles from './FormInputButton.css'; + +function FormInputButton(props) { + const { + className, + canSpin, + isLastButton, + ...otherProps + } = props; + + if (canSpin) { + return ( + + ); + } + + return ( + + ); +} + +SpinnerButton.propTypes = { + className: PropTypes.string.isRequired, + isSpinning: PropTypes.bool.isRequired, + isDisabled: PropTypes.bool, + spinnerIcon: PropTypes.object.isRequired, + children: PropTypes.node +}; + +SpinnerButton.defaultProps = { + className: styles.button, + spinnerIcon: icons.SPINNER +}; + +export default SpinnerButton; diff --git a/frontend/src/Components/Link/SpinnerErrorButton.css b/frontend/src/Components/Link/SpinnerErrorButton.css new file mode 100644 index 000000000..1671053f1 --- /dev/null +++ b/frontend/src/Components/Link/SpinnerErrorButton.css @@ -0,0 +1,23 @@ +.iconContainer { + composes: spinnerContainer from '~Components/Link/SpinnerButton.css'; +} + +.icon { + z-index: 1; +} + +.label { + composes: label from '~Components/Link/SpinnerButton.css'; +} + +.showIcon { + .iconContainer { + left: 50%; + visibility: visible; + } + + .label { + left: 100%; + opacity: 0; + } +} diff --git a/frontend/src/Components/Link/SpinnerErrorButton.js b/frontend/src/Components/Link/SpinnerErrorButton.js new file mode 100644 index 000000000..0575db094 --- /dev/null +++ b/frontend/src/Components/Link/SpinnerErrorButton.js @@ -0,0 +1,162 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import styles from './SpinnerErrorButton.css'; + +function getTestResult(error) { + if (!error) { + return { + wasSuccessful: true, + hasWarning: false, + hasError: false + }; + } + + if (error.status !== 400) { + return { + wasSuccessful: false, + hasWarning: false, + hasError: true + }; + } + + const failures = error.responseJSON; + + const hasWarning = _.some(failures, { isWarning: true }); + const hasError = _.some(failures, (failure) => !failure.isWarning); + + return { + wasSuccessful: false, + hasWarning, + hasError + }; +} + +class SpinnerErrorButton extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._testResultTimeout = null; + + this.state = { + wasSuccessful: false, + hasWarning: false, + hasError: false + }; + } + + componentDidUpdate(prevProps) { + const { + isSpinning, + error + } = this.props; + + if (prevProps.isSpinning && !isSpinning) { + const testResult = getTestResult(error); + + this.setState(testResult, () => { + const { + wasSuccessful, + hasWarning, + hasError + } = testResult; + + if (wasSuccessful || hasWarning || hasError) { + this._testResultTimeout = setTimeout(this.resetState, 3000); + } + }); + } + } + + componentWillUnmount() { + if (this._testResultTimeout) { + clearTimeout(this._testResultTimeout); + } + } + + // + // Control + + resetState = () => { + this.setState({ + wasSuccessful: false, + hasWarning: false, + hasError: false + }); + } + + // + // Render + + render() { + const { + isSpinning, + error, + children, + ...otherProps + } = this.props; + + const { + wasSuccessful, + hasWarning, + hasError + } = this.state; + + const showIcon = wasSuccessful || hasWarning || hasError; + + let iconName = icons.CHECK; + let iconKind = kinds.SUCCESS; + + if (hasWarning) { + iconName = icons.WARNING; + iconKind = kinds.WARNING; + } + + if (hasError) { + iconName = icons.DANGER; + iconKind = kinds.DANGER; + } + + return ( + + + { + showIcon && + + + + } + + { + + { + children + } + + } + + + ); + } +} + +SpinnerErrorButton.propTypes = { + isSpinning: PropTypes.bool.isRequired, + error: PropTypes.object, + children: PropTypes.node.isRequired +}; + +export default SpinnerErrorButton; diff --git a/frontend/src/Components/Link/SpinnerIconButton.js b/frontend/src/Components/Link/SpinnerIconButton.js new file mode 100644 index 000000000..a804fafc5 --- /dev/null +++ b/frontend/src/Components/Link/SpinnerIconButton.js @@ -0,0 +1,38 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from './IconButton'; + +function SpinnerIconButton(props) { + const { + name, + spinningName, + isDisabled, + isSpinning, + ...otherProps + } = props; + + return ( + + ); +} + +SpinnerIconButton.propTypes = { + name: PropTypes.object.isRequired, + spinningName: PropTypes.object.isRequired, + isDisabled: PropTypes.bool.isRequired, + isSpinning: PropTypes.bool.isRequired +}; + +SpinnerIconButton.defaultProps = { + spinningName: icons.SPINNER, + isDisabled: false, + isSpinning: false +}; + +export default SpinnerIconButton; diff --git a/frontend/src/Components/Loading/LoadingIndicator.css b/frontend/src/Components/Loading/LoadingIndicator.css new file mode 100644 index 000000000..fd224b1d6 --- /dev/null +++ b/frontend/src/Components/Loading/LoadingIndicator.css @@ -0,0 +1,49 @@ +.loading { + margin-top: 20px; + text-align: center; +} + +.rippleContainer { + position: relative; + display: inline-block; +} + +.ripple:nth-child(0) { + animation-delay: -0.8s; +} + +.ripple:nth-child(1) { + animation-delay: -0.6s; +} + +.ripple:nth-child(2) { + animation-delay: -0.4s; +} + +.ripple:nth-child(3) { + animation-delay: -0.2s; +} + +.ripple { + position: absolute; + border: 2px solid #3a3f51; + border-radius: 100%; + animation: rippleContainer 1.25s 0s infinite cubic-bezier(0.21, 0.53, 0.56, 0.8); + animation-fill-mode: both; +} + +@keyframes rippleContainer { + 0% { + opacity: 1; + transform: scale(0.1); + } + + 70% { + opacity: 0.7; + transform: scale(1); + } + + 100% { + opacity: 0; + } +} diff --git a/frontend/src/Components/Loading/LoadingIndicator.js b/frontend/src/Components/Loading/LoadingIndicator.js new file mode 100644 index 000000000..5f9a15b1a --- /dev/null +++ b/frontend/src/Components/Loading/LoadingIndicator.js @@ -0,0 +1,48 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './LoadingIndicator.css'; + +function LoadingIndicator({ className, size }) { + const sizeInPx = `${size}px`; + const width = sizeInPx; + const height = sizeInPx; + + return ( +
+
+
+ +
+ +
+
+
+ ); +} + +LoadingIndicator.propTypes = { + className: PropTypes.string, + size: PropTypes.number +}; + +LoadingIndicator.defaultProps = { + className: styles.loading, + size: 50 +}; + +export default LoadingIndicator; diff --git a/frontend/src/Components/Loading/LoadingMessage.css b/frontend/src/Components/Loading/LoadingMessage.css new file mode 100644 index 000000000..a7b39e76f --- /dev/null +++ b/frontend/src/Components/Loading/LoadingMessage.css @@ -0,0 +1,6 @@ +.loadingMessage { + margin: 50px 10px 0; + text-align: center; + font-weight: 300; + font-size: 36px; +} diff --git a/frontend/src/Components/Loading/LoadingMessage.js b/frontend/src/Components/Loading/LoadingMessage.js new file mode 100644 index 000000000..a4ca95beb --- /dev/null +++ b/frontend/src/Components/Loading/LoadingMessage.js @@ -0,0 +1,40 @@ +import React from 'react'; +import styles from './LoadingMessage.css'; + +const messages = [ + 'Downloading more RAM', + 'Now in Technicolor', + 'Previously on Lidarr...', + 'Bleep Bloop.', + 'Locating the required gigapixels to render...', + 'Spinning up the hamster wheel...', + 'At least you\'re not on hold', + 'Hum something loud while others stare', + 'Loading humorous message... Please Wait', + 'I could\'ve been faster in Python', + 'Don\'t forget to rewind your tracks', + 'Congratulations! you are the 1000th visitor.', + 'HELP! I\'m being held hostage and forced to write these stupid lines!', + 'RE-calibrating the internet...', + 'I\'ll be here all week', + 'Don\'t forget to tip your waitress', + 'Apply directly to the forehead', + 'Loading Battlestation' +]; + +let message = null; + +function LoadingMessage() { + if (!message) { + const index = Math.floor(Math.random() * messages.length); + message = messages[index]; + } + + return ( +
+ {message} +
+ ); +} + +export default LoadingMessage; diff --git a/frontend/src/Components/Measure.js b/frontend/src/Components/Measure.js new file mode 100644 index 000000000..a2f113de7 --- /dev/null +++ b/frontend/src/Components/Measure.js @@ -0,0 +1,38 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactMeasure from 'react-measure'; + +class Measure extends Component { + + // + // Lifecycle + + componentWillUnmount() { + this.onMeasure.cancel(); + } + + // + // Listeners + + onMeasure = _.debounce((payload) => { + this.props.onMeasure(payload); + }, 250, { leading: true, trailing: false }) + + // + // Render + + render() { + return ( + + ); + } +} + +Measure.propTypes = { + onMeasure: PropTypes.func.isRequired +}; + +export default Measure; diff --git a/frontend/src/Components/Menu/FilterMenu.css b/frontend/src/Components/Menu/FilterMenu.css new file mode 100644 index 000000000..881dbe26c --- /dev/null +++ b/frontend/src/Components/Menu/FilterMenu.css @@ -0,0 +1,9 @@ +.filterMenu { + composes: menu from '~./Menu.css'; +} + +@media only screen and (max-width: $breakpointSmall) { + .filterMenu { + margin-right: 10px; + } +} diff --git a/frontend/src/Components/Menu/FilterMenu.js b/frontend/src/Components/Menu/FilterMenu.js new file mode 100644 index 000000000..d989605e5 --- /dev/null +++ b/frontend/src/Components/Menu/FilterMenu.js @@ -0,0 +1,110 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import FilterMenuContent from './FilterMenuContent'; +import Menu from './Menu'; +import ToolbarMenuButton from './ToolbarMenuButton'; +import styles from './FilterMenu.css'; + +class FilterMenu extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isFilterModalOpen: false + }; + } + + // + // Listeners + + onCustomFiltersPress = () => { + this.setState({ isFilterModalOpen: true }); + } + + onFiltersModalClose = () => { + this.setState({ isFilterModalOpen: false }); + } + + // + // Render + + render(props) { + const { + className, + isDisabled, + selectedFilterKey, + filters, + customFilters, + buttonComponent: ButtonComponent, + filterModalConnectorComponent: FilterModalConnectorComponent, + filterModalConnectorComponentProps, + onFilterSelect, + ...otherProps + } = this.props; + + const showCustomFilters = !!FilterModalConnectorComponent; + + return ( +
+ + + + + + + + { + showCustomFilters && + + } +
+ ); + } +} + +FilterMenu.propTypes = { + className: PropTypes.string, + isDisabled: PropTypes.bool.isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + buttonComponent: PropTypes.elementType.isRequired, + filterModalConnectorComponent: PropTypes.elementType, + filterModalConnectorComponentProps: PropTypes.object, + onFilterSelect: PropTypes.func.isRequired +}; + +FilterMenu.defaultProps = { + className: styles.filterMenu, + isDisabled: false, + buttonComponent: ToolbarMenuButton +}; + +export default FilterMenu; diff --git a/frontend/src/Components/Menu/FilterMenuContent.js b/frontend/src/Components/Menu/FilterMenuContent.js new file mode 100644 index 000000000..7463e2c9e --- /dev/null +++ b/frontend/src/Components/Menu/FilterMenuContent.js @@ -0,0 +1,85 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import MenuContent from './MenuContent'; +import FilterMenuItem from './FilterMenuItem'; +import MenuItem from './MenuItem'; +import MenuItemSeparator from './MenuItemSeparator'; + +class FilterMenuContent extends Component { + + // + // Render + + render() { + const { + selectedFilterKey, + filters, + customFilters, + showCustomFilters, + onFilterSelect, + onCustomFiltersPress, + ...otherProps + } = this.props; + + return ( + + { + filters.map((filter) => { + return ( + + {filter.label} + + ); + }) + } + + { + customFilters.map((filter) => { + return ( + + {filter.label} + + ); + }) + } + + { + showCustomFilters && + + } + + { + showCustomFilters && + + Custom Filters + + } + + ); + } +} + +FilterMenuContent.propTypes = { + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + showCustomFilters: PropTypes.bool.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onCustomFiltersPress: PropTypes.func.isRequired +}; + +FilterMenuContent.defaultProps = { + showCustomFilters: false +}; + +export default FilterMenuContent; diff --git a/frontend/src/Components/Menu/FilterMenuItem.js b/frontend/src/Components/Menu/FilterMenuItem.js new file mode 100644 index 000000000..d2c495187 --- /dev/null +++ b/frontend/src/Components/Menu/FilterMenuItem.js @@ -0,0 +1,45 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import SelectedMenuItem from './SelectedMenuItem'; + +class FilterMenuItem extends Component { + + // + // Listeners + + onPress = () => { + const { + filterKey, + onPress + } = this.props; + + onPress(filterKey); + } + + // + // Render + + render() { + const { + filterKey, + selectedFilterKey, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +FilterMenuItem.propTypes = { + filterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + onPress: PropTypes.func.isRequired +}; + +export default FilterMenuItem; diff --git a/frontend/src/Components/Menu/Menu.css b/frontend/src/Components/Menu/Menu.css new file mode 100644 index 000000000..963ea62cb --- /dev/null +++ b/frontend/src/Components/Menu/Menu.css @@ -0,0 +1,3 @@ +.menu { + position: relative; +} diff --git a/frontend/src/Components/Menu/Menu.js b/frontend/src/Components/Menu/Menu.js new file mode 100644 index 000000000..fadbcc69e --- /dev/null +++ b/frontend/src/Components/Menu/Menu.js @@ -0,0 +1,252 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { Manager, Popper, Reference } from 'react-popper'; +import getUniqueElememtId from 'Utilities/getUniqueElementId'; +import { align } from 'Helpers/Props'; +import Portal from 'Components/Portal'; +import styles from './Menu.css'; + +const sharedPopperOptions = { + modifiers: { + preventOverflow: { + padding: 0 + }, + flip: { + padding: 0 + } + } +}; + +const popperOptions = { + [align.RIGHT]: { + ...sharedPopperOptions, + placement: 'bottom-end' + }, + + [align.LEFT]: { + ...sharedPopperOptions, + placement: 'bottom-start' + } +}; + +class Menu extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._scheduleUpdate = null; + this._menuButtonId = getUniqueElememtId(); + this._menuContentId = getUniqueElememtId(); + + this.state = { + isMenuOpen: false, + maxHeight: 0 + }; + } + + componentDidMount() { + this.setMaxHeight(); + } + + componentDidUpdate() { + if (this._scheduleUpdate) { + this._scheduleUpdate(); + } + } + + componentWillUnmount() { + this._removeListener(); + } + + // + // Control + + getMaxHeight() { + if (!this.props.enforceMaxHeight) { + return; + } + + const menuButton = document.getElementById(this._menuButtonId); + + if (!menuButton) { + return; + } + + const { bottom } = menuButton.getBoundingClientRect(); + const maxHeight = window.innerHeight - bottom; + + return maxHeight; + } + + setMaxHeight() { + const maxHeight = this.getMaxHeight(); + + if (maxHeight !== this.state.maxHeight) { + this.setState({ + maxHeight + }); + } + } + + _addListener() { + // Listen to resize events on the window and scroll events + // on all elements to ensure the menu is the best size possible. + // Listen for click events on the window to support closing the + // menu on clicks outside. + + window.addEventListener('resize', this.onWindowResize); + window.addEventListener('scroll', this.onWindowScroll, { capture: true }); + window.addEventListener('click', this.onWindowClick); + window.addEventListener('touchstart', this.onTouchStart); + } + + _removeListener() { + window.removeEventListener('resize', this.onWindowResize); + window.removeEventListener('scroll', this.onWindowScroll, { capture: true }); + window.removeEventListener('click', this.onWindowClick); + window.removeEventListener('touchstart', this.onTouchStart); + } + + // + // Listeners + + onWindowClick = (event) => { + const menuButton = document.getElementById(this._menuButtonId); + + if (!menuButton) { + return; + } + + if (!menuButton.contains(event.target) && this.state.isMenuOpen) { + this.setState({ isMenuOpen: false }); + this._removeListener(); + } + } + + onTouchStart = (event) => { + const menuButton = document.getElementById(this._menuButtonId); + const menuContent = document.getElementById(this._menuContentId); + + if (!menuButton || !menuContent) { + return; + } + + if (event.targetTouches.length !== 1) { + return; + } + + const target = event.targetTouches[0].target; + + if ( + !menuButton.contains(target) && + !menuContent.contains(target) && + this.state.isMenuOpen + ) { + this.setState({ isMenuOpen: false }); + this._removeListener(); + } + } + + onWindowResize = () => { + this.setMaxHeight(); + } + + onWindowScroll = (event) => { + if (this.state.isMenuOpen) { + this.setMaxHeight(); + } + } + + onMenuButtonPress = () => { + const state = { + isMenuOpen: !this.state.isMenuOpen + }; + + if (this.state.isMenuOpen) { + this._removeListener(); + } else { + state.maxHeight = this.getMaxHeight(); + this._addListener(); + } + + this.setState(state); + } + + // + // Render + + render() { + const { + className, + children, + alignMenu + } = this.props; + + const { + maxHeight, + isMenuOpen + } = this.state; + + const childrenArray = React.Children.toArray(children); + const button = React.cloneElement( + childrenArray[0], + { + onPress: this.onMenuButtonPress + } + ); + + return ( + + + {({ ref }) => ( +
+ {button} +
+ )} +
+ + + + {({ ref, style, scheduleUpdate }) => { + this._scheduleUpdate = scheduleUpdate; + + return React.cloneElement( + childrenArray[1], + { + forwardedRef: ref, + style: { + ...style, + maxHeight + }, + isOpen: isMenuOpen + } + ); + }} + + +
+ ); + } +} + +Menu.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired, + alignMenu: PropTypes.oneOf([align.LEFT, align.RIGHT]), + enforceMaxHeight: PropTypes.bool.isRequired +}; + +Menu.defaultProps = { + className: styles.menu, + alignMenu: align.LEFT, + enforceMaxHeight: true +}; + +export default Menu; diff --git a/frontend/src/Components/Menu/MenuButton.css b/frontend/src/Components/Menu/MenuButton.css new file mode 100644 index 000000000..38812cfb7 --- /dev/null +++ b/frontend/src/Components/Menu/MenuButton.css @@ -0,0 +1,21 @@ +.menuButton { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + + &::after { + margin-left: 5px; + content: '\25BE'; + } + + &:hover { + color: $toobarButtonHoverColor; + } +} + +.isDisabled { + color: $disabledColor; + + pointer-events: none; +} diff --git a/frontend/src/Components/Menu/MenuButton.js b/frontend/src/Components/Menu/MenuButton.js new file mode 100644 index 000000000..477334a1d --- /dev/null +++ b/frontend/src/Components/Menu/MenuButton.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import Link from 'Components/Link/Link'; +import styles from './MenuButton.css'; + +class MenuButton extends Component { + + // + // Render + + render() { + const { + className, + children, + isDisabled, + onPress, + ...otherProps + } = this.props; + + return ( + + {children} + + ); + } +} + +MenuButton.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired, + isDisabled: PropTypes.bool.isRequired, + onPress: PropTypes.func +}; + +MenuButton.defaultProps = { + className: styles.menuButton, + isDisabled: false +}; + +export default MenuButton; diff --git a/frontend/src/Components/Menu/MenuContent.css b/frontend/src/Components/Menu/MenuContent.css new file mode 100644 index 000000000..b9327fdd7 --- /dev/null +++ b/frontend/src/Components/Menu/MenuContent.css @@ -0,0 +1,12 @@ +.menuContent { + z-index: $popperZIndex; + display: flex; + flex-direction: column; + background-color: $toolbarMenuItemBackgroundColor; + line-height: 20px; +} + +.scroller { + display: flex; + flex-direction: column; +} diff --git a/frontend/src/Components/Menu/MenuContent.js b/frontend/src/Components/Menu/MenuContent.js new file mode 100644 index 000000000..e158187d5 --- /dev/null +++ b/frontend/src/Components/Menu/MenuContent.js @@ -0,0 +1,53 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Scroller from 'Components/Scroller/Scroller'; +import styles from './MenuContent.css'; + +class MenuContent extends Component { + + // + // Render + + render() { + const { + forwardedRef, + className, + id, + children, + style, + isOpen + } = this.props; + + return ( +
+ { + isOpen ? + + {children} + : + null + } +
+ ); + } +} + +MenuContent.propTypes = { + forwardedRef: PropTypes.func, + className: PropTypes.string, + id: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, + style: PropTypes.object, + isOpen: PropTypes.bool +}; + +MenuContent.defaultProps = { + className: styles.menuContent +}; + +export default MenuContent; diff --git a/frontend/src/Components/Menu/MenuItem.css b/frontend/src/Components/Menu/MenuItem.css new file mode 100644 index 000000000..2eb2817af --- /dev/null +++ b/frontend/src/Components/Menu/MenuItem.css @@ -0,0 +1,23 @@ +.menuItem { + @add-mixin truncate; + display: block; + flex-shrink: 0; + padding: 10px 20px; + min-width: 150px; + max-width: 250px; + background-color: $toolbarMenuItemBackgroundColor; + color: $menuItemColor; + line-height: 20px; + + &:hover, + &:focus { + background-color: $toolbarMenuItemHoverBackgroundColor; + color: $menuItemHoverColor; + text-decoration: none; + } +} + +.isDisabled { + color: $disabledColor; + pointer-events: none; +} diff --git a/frontend/src/Components/Menu/MenuItem.js b/frontend/src/Components/Menu/MenuItem.js new file mode 100644 index 000000000..fb1c1d4ec --- /dev/null +++ b/frontend/src/Components/Menu/MenuItem.js @@ -0,0 +1,46 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import Link from 'Components/Link/Link'; +import styles from './MenuItem.css'; + +class MenuItem extends Component { + + // + // Render + + render() { + const { + className, + children, + isDisabled, + ...otherProps + } = this.props; + + return ( + + {children} + + ); + } +} + +MenuItem.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired, + isDisabled: PropTypes.bool.isRequired +}; + +MenuItem.defaultProps = { + className: styles.menuItem, + isDisabled: false +}; + +export default MenuItem; diff --git a/frontend/src/Components/Menu/MenuItemSeparator.css b/frontend/src/Components/Menu/MenuItemSeparator.css new file mode 100644 index 000000000..e48e7f16f --- /dev/null +++ b/frontend/src/Components/Menu/MenuItemSeparator.css @@ -0,0 +1,6 @@ +.separator { + overflow: hidden; + min-height: 1px; + height: 1px; + background-color: $themeDarkColor; +} diff --git a/frontend/src/Components/Menu/MenuItemSeparator.js b/frontend/src/Components/Menu/MenuItemSeparator.js new file mode 100644 index 000000000..e586670c9 --- /dev/null +++ b/frontend/src/Components/Menu/MenuItemSeparator.js @@ -0,0 +1,10 @@ +import React from 'react'; +import styles from './MenuItemSeparator.css'; + +function MenuItemSeparator() { + return ( +
+ ); +} + +export default MenuItemSeparator; diff --git a/frontend/src/Components/Menu/PageMenuButton.css b/frontend/src/Components/Menu/PageMenuButton.css new file mode 100644 index 000000000..d979a1708 --- /dev/null +++ b/frontend/src/Components/Menu/PageMenuButton.css @@ -0,0 +1,11 @@ +.menuButton { + composes: menuButton from '~./MenuButton.css'; + + &:hover { + color: #666; + } +} + +.label { + margin-left: 5px; +} diff --git a/frontend/src/Components/Menu/PageMenuButton.js b/frontend/src/Components/Menu/PageMenuButton.js new file mode 100644 index 000000000..abbfc98f8 --- /dev/null +++ b/frontend/src/Components/Menu/PageMenuButton.js @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Icon from 'Components/Icon'; +import MenuButton from 'Components/Menu/MenuButton'; +import styles from './PageMenuButton.css'; + +function PageMenuButton(props) { + const { + iconName, + text, + ...otherProps + } = props; + + return ( + + + +
+ {text} +
+
+ ); +} + +PageMenuButton.propTypes = { + iconName: PropTypes.object.isRequired, + text: PropTypes.string +}; + +export default PageMenuButton; diff --git a/frontend/src/Components/Menu/SelectedMenuItem.css b/frontend/src/Components/Menu/SelectedMenuItem.css new file mode 100644 index 000000000..739419d69 --- /dev/null +++ b/frontend/src/Components/Menu/SelectedMenuItem.css @@ -0,0 +1,15 @@ +.item { + display: flex; + justify-content: space-between; + white-space: nowrap; +} + +.isSelected { + visibility: visible; + margin-left: 20px; +} + +.isNotSelected { + visibility: hidden; + margin-left: 20px; +} diff --git a/frontend/src/Components/Menu/SelectedMenuItem.js b/frontend/src/Components/Menu/SelectedMenuItem.js new file mode 100644 index 000000000..8b0805c57 --- /dev/null +++ b/frontend/src/Components/Menu/SelectedMenuItem.js @@ -0,0 +1,63 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import MenuItem from './MenuItem'; +import styles from './SelectedMenuItem.css'; + +class SelectedMenuItem extends Component { + + // + // Listeners + + onPress = () => { + const { + name, + onPress + } = this.props; + + onPress(name); + } + + // + // Render + + render() { + const { + children, + selectedIconName, + isSelected, + ...otherProps + } = this.props; + + return ( + +
+ {children} + + +
+
+ ); + } +} + +SelectedMenuItem.propTypes = { + name: PropTypes.string, + children: PropTypes.node.isRequired, + selectedIconName: PropTypes.object.isRequired, + isSelected: PropTypes.bool.isRequired, + onPress: PropTypes.func.isRequired +}; + +SelectedMenuItem.defaultProps = { + selectedIconName: icons.CHECK +}; + +export default SelectedMenuItem; diff --git a/frontend/src/Components/Menu/SortMenu.js b/frontend/src/Components/Menu/SortMenu.js new file mode 100644 index 000000000..a9a6a184e --- /dev/null +++ b/frontend/src/Components/Menu/SortMenu.js @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons } from 'Helpers/Props'; +import Menu from 'Components/Menu/Menu'; +import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton'; + +function SortMenu(props) { + const { + className, + children, + isDisabled, + ...otherProps + } = props; + + return ( + + + {children} + + ); +} + +SortMenu.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired, + isDisabled: PropTypes.bool.isRequired +}; + +SortMenu.defaultProps = { + isDisabled: false +}; + +export default SortMenu; diff --git a/frontend/src/Components/Menu/SortMenuItem.js b/frontend/src/Components/Menu/SortMenuItem.js new file mode 100644 index 000000000..e35864ae6 --- /dev/null +++ b/frontend/src/Components/Menu/SortMenuItem.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, sortDirections } from 'Helpers/Props'; +import SelectedMenuItem from './SelectedMenuItem'; + +function SortMenuItem(props) { + const { + name, + sortKey, + sortDirection, + ...otherProps + } = props; + + const isSelected = name === sortKey; + + return ( + + ); +} + +SortMenuItem.propTypes = { + name: PropTypes.string, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + onPress: PropTypes.func.isRequired +}; + +SortMenuItem.defaultProps = { + name: null +}; + +export default SortMenuItem; diff --git a/frontend/src/Components/Menu/ToolbarMenuButton.css b/frontend/src/Components/Menu/ToolbarMenuButton.css new file mode 100644 index 000000000..71e966c71 --- /dev/null +++ b/frontend/src/Components/Menu/ToolbarMenuButton.css @@ -0,0 +1,16 @@ +.menuButton { + composes: menuButton from '~./MenuButton.css'; + + padding-top: 4px; + width: $toolbarButtonWidth; + height: $toolbarHeight; + text-align: center; +} + +.labelContainer { + composes: labelContainer from '~Components/Page/Toolbar/PageToolbarButton.css'; +} + +.label { + composes: label from '~Components/Page/Toolbar/PageToolbarButton.css'; +} diff --git a/frontend/src/Components/Menu/ToolbarMenuButton.js b/frontend/src/Components/Menu/ToolbarMenuButton.js new file mode 100644 index 000000000..fe06793f6 --- /dev/null +++ b/frontend/src/Components/Menu/ToolbarMenuButton.js @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Icon from 'Components/Icon'; +import MenuButton from 'Components/Menu/MenuButton'; +import styles from './ToolbarMenuButton.css'; + +function ToolbarMenuButton(props) { + const { + iconName, + text, + ...otherProps + } = props; + + return ( + +
+ + +
+
+ {text} +
+
+
+
+ ); +} + +ToolbarMenuButton.propTypes = { + iconName: PropTypes.object.isRequired, + text: PropTypes.string +}; + +export default ToolbarMenuButton; diff --git a/frontend/src/Components/Menu/ViewMenu.js b/frontend/src/Components/Menu/ViewMenu.js new file mode 100644 index 000000000..60c77e003 --- /dev/null +++ b/frontend/src/Components/Menu/ViewMenu.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons } from 'Helpers/Props'; +import Menu from 'Components/Menu/Menu'; +import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton'; + +function ViewMenu(props) { + const { + children, + isDisabled, + ...otherProps + } = props; + + return ( + + + {children} + + ); +} + +ViewMenu.propTypes = { + children: PropTypes.node.isRequired, + isDisabled: PropTypes.bool.isRequired +}; + +ViewMenu.defaultProps = { + isDisabled: false +}; + +export default ViewMenu; diff --git a/frontend/src/Components/Menu/ViewMenuItem.js b/frontend/src/Components/Menu/ViewMenuItem.js new file mode 100644 index 000000000..d355d6e94 --- /dev/null +++ b/frontend/src/Components/Menu/ViewMenuItem.js @@ -0,0 +1,28 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import SelectedMenuItem from './SelectedMenuItem'; + +function ViewMenuItem(props) { + const { + name, + selectedView, + ...otherProps + } = props; + + const isSelected = name === selectedView; + + return ( + + ); +} + +ViewMenuItem.propTypes = { + name: PropTypes.string, + selectedView: PropTypes.string.isRequired +}; + +export default ViewMenuItem; diff --git a/frontend/src/Components/Modal/ConfirmModal.js b/frontend/src/Components/Modal/ConfirmModal.js new file mode 100644 index 000000000..5bb783d43 --- /dev/null +++ b/frontend/src/Components/Modal/ConfirmModal.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds, sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +function ConfirmModal(props) { + const { + isOpen, + kind, + size, + title, + message, + confirmLabel, + cancelLabel, + hideCancelButton, + isSpinning, + onConfirm, + onCancel + } = props; + + return ( + + + {title} + + + {message} + + + + { + !hideCancelButton && + + } + + + {confirmLabel} + + + + + ); +} + +ConfirmModal.propTypes = { + className: PropTypes.string, + isOpen: PropTypes.bool.isRequired, + kind: PropTypes.oneOf(kinds.all), + size: PropTypes.oneOf(sizes.all), + title: PropTypes.string.isRequired, + message: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired, + confirmLabel: PropTypes.string, + cancelLabel: PropTypes.string, + hideCancelButton: PropTypes.bool, + isSpinning: PropTypes.bool.isRequired, + onConfirm: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired +}; + +ConfirmModal.defaultProps = { + kind: kinds.PRIMARY, + size: sizes.MEDIUM, + confirmLabel: 'OK', + cancelLabel: 'Cancel', + isSpinning: false +}; + +export default ConfirmModal; diff --git a/frontend/src/Components/Modal/Modal.css b/frontend/src/Components/Modal/Modal.css new file mode 100644 index 000000000..d7269ea46 --- /dev/null +++ b/frontend/src/Components/Modal/Modal.css @@ -0,0 +1,98 @@ +.modalContainer { + position: absolute; + top: 0; + z-index: $modalZIndex; + width: 100%; + height: 100%; +} + +.modalBackdrop { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + background-color: $modalBackdropBackgroundColor; + opacity: 1; +} + +.modal { + position: relative; + display: flex; + max-height: 90%; + border-radius: 6px; + opacity: 1; +} + +.modalOpen { + /* Prevent the body from scrolling when the modal is open */ + overflow: hidden !important; +} + +.modalOpenIOS { + position: fixed; + right: 0; + left: 0; +} + +/* + * Sizes + */ + +.small { + composes: modal; + + width: 480px; +} + +.medium { + composes: modal; + + width: 720px; +} + +.large { + composes: modal; + + width: 1080px; +} + +.extraLarge { + composes: modal; + + width: 1280px; +} + +@media only screen and (max-width: $breakpointExtraLarge) { + .modal.extraLarge { + width: 90%; + } +} + +@media only screen and (max-width: $breakpointLarge) { + .modal.large { + width: 90%; + } +} + +@media only screen and (max-width: $breakpointMedium) { + .modal.small, + .modal.medium { + width: 90%; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .modalContainer { + position: fixed; + } + + .modal.small, + .modal.medium, + .modal.large, + .modal.extraLarge { + max-height: 100%; + width: 100%; + height: 100% !important; + } +} diff --git a/frontend/src/Components/Modal/Modal.js b/frontend/src/Components/Modal/Modal.js new file mode 100644 index 000000000..3b6485ce3 --- /dev/null +++ b/frontend/src/Components/Modal/Modal.js @@ -0,0 +1,232 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import classNames from 'classnames'; +import elementClass from 'element-class'; +import getUniqueElememtId from 'Utilities/getUniqueElementId'; +import { isIOS } from 'Utilities/mobile'; +import { setScrollLock } from 'Utilities/scrollLock'; +import * as keyCodes from 'Utilities/Constants/keyCodes'; +import { sizes } from 'Helpers/Props'; +import ErrorBoundary from 'Components/Error/ErrorBoundary'; +import ModalError from './ModalError'; +import styles from './Modal.css'; + +const openModals = []; + +function removeFromOpenModals(id) { + const index = openModals.indexOf(id); + + if (index >= 0) { + openModals.splice(index, 1); + } +} + +class Modal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._node = document.getElementById('portal-root'); + this._backgroundRef = null; + this._modalId = getUniqueElememtId(); + this._bodyScrollTop = 0; + } + + componentDidMount() { + if (this.props.isOpen) { + this._openModal(); + } + } + + componentDidUpdate(prevProps) { + const { + isOpen + } = this.props; + + if (!prevProps.isOpen && isOpen) { + this._openModal(); + } else if (prevProps.isOpen && !isOpen) { + this._closeModal(); + } + } + + componentWillUnmount() { + if (this.props.isOpen) { + this._closeModal(); + } + } + + // + // Control + + _setBackgroundRef = (ref) => { + this._backgroundRef = ref; + } + + _openModal() { + openModals.push(this._modalId); + window.addEventListener('keydown', this.onKeyDown); + + if (openModals.length === 1) { + if (isIOS()) { + setScrollLock(true); + const scrollTop = document.body.scrollTop; + this._bodyScrollTop = scrollTop; + elementClass(document.body).add(styles.modalOpenIOS); + } else { + elementClass(document.body).add(styles.modalOpen); + } + } + } + + _closeModal() { + removeFromOpenModals(this._modalId); + window.removeEventListener('keydown', this.onKeyDown); + + if (openModals.length === 0) { + setScrollLock(false); + + if (isIOS()) { + elementClass(document.body).remove(styles.modalOpenIOS); + document.body.scrollTop = this._bodyScrollTop; + } else { + elementClass(document.body).remove(styles.modalOpen); + } + } + } + + _isBackdropTarget(event) { + const targetElement = this._findEventTarget(event); + + if (targetElement) { + const backgroundElement = ReactDOM.findDOMNode(this._backgroundRef); + + return backgroundElement.isEqualNode(targetElement); + } + + return false; + } + + _findEventTarget(event) { + const changedTouches = event.changedTouches; + + if (!changedTouches) { + return event.target; + } + + if (changedTouches.length === 1) { + const touch = changedTouches[0]; + + return document.elementFromPoint(touch.clientX, touch.clientY); + } + } + + // + // Listeners + + onBackdropBeginPress = (event) => { + this._isBackdropPressed = this._isBackdropTarget(event); + } + + onBackdropEndPress = (event) => { + const { + closeOnBackgroundClick, + onModalClose + } = this.props; + + if ( + this._isBackdropPressed && + this._isBackdropTarget(event) && + closeOnBackgroundClick + ) { + onModalClose(); + } + + this._isBackdropPressed = false; + } + + onKeyDown = (event) => { + const keyCode = event.keyCode; + + if (keyCode === keyCodes.ESCAPE) { + if (openModals.indexOf(this._modalId) === openModals.length - 1) { + event.preventDefault(); + event.stopPropagation(); + + this.props.onModalClose(); + } + } + } + + // + // Render + + render() { + const { + className, + style, + backdropClassName, + size, + children, + isOpen, + onModalClose + } = this.props; + + if (!isOpen) { + return null; + } + + return ReactDOM.createPortal( +
+
+
+ + {children} + +
+
+
, + this._node + ); + } +} + +Modal.propTypes = { + className: PropTypes.string, + style: PropTypes.object, + backdropClassName: PropTypes.string, + size: PropTypes.oneOf(sizes.all), + children: PropTypes.node, + isOpen: PropTypes.bool.isRequired, + closeOnBackgroundClick: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +Modal.defaultProps = { + className: styles.modal, + backdropClassName: styles.modalBackdrop, + size: sizes.LARGE, + closeOnBackgroundClick: true +}; + +export default Modal; diff --git a/frontend/src/Components/Modal/ModalBody.css b/frontend/src/Components/Modal/ModalBody.css new file mode 100644 index 000000000..ebeef29de --- /dev/null +++ b/frontend/src/Components/Modal/ModalBody.css @@ -0,0 +1,12 @@ +.modalBody { + flex: 1 0 1px; + padding: $modalBodyPadding; +} + +.modalScroller { + flex-grow: 1; +} + +.innerModalBody { + padding: $modalBodyPadding; +} diff --git a/frontend/src/Components/Modal/ModalBody.js b/frontend/src/Components/Modal/ModalBody.js new file mode 100644 index 000000000..6edde4790 --- /dev/null +++ b/frontend/src/Components/Modal/ModalBody.js @@ -0,0 +1,59 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { scrollDirections } from 'Helpers/Props'; +import Scroller from 'Components/Scroller/Scroller'; +import styles from './ModalBody.css'; + +class ModalBody extends Component { + + // + // Render + + render() { + const { + innerClassName, + scrollDirection, + children, + ...otherProps + } = this.props; + + let className = this.props.className; + const hasScroller = scrollDirection !== scrollDirections.NONE; + + if (!className) { + className = hasScroller ? styles.modalScroller : styles.modalBody; + } + + return ( + + { + hasScroller ? +
+ {children} +
: + children + } +
+ ); + } + +} + +ModalBody.propTypes = { + className: PropTypes.string, + innerClassName: PropTypes.string, + children: PropTypes.node, + scrollDirection: PropTypes.oneOf(scrollDirections.all) +}; + +ModalBody.defaultProps = { + innerClassName: styles.innerModalBody, + scrollDirection: scrollDirections.VERTICAL +}; + +export default ModalBody; diff --git a/frontend/src/Components/Modal/ModalContent.css b/frontend/src/Components/Modal/ModalContent.css new file mode 100644 index 000000000..afd798dfa --- /dev/null +++ b/frontend/src/Components/Modal/ModalContent.css @@ -0,0 +1,23 @@ +.modalContent { + position: relative; + display: flex; + flex-direction: column; + flex-grow: 1; + width: 100%; + background-color: $modalBackgroundColor; +} + +.closeButton { + position: absolute; + top: 0; + right: 0; + z-index: 1; + width: 60px; + height: 60px; + text-align: center; + line-height: 60px; + + &:hover { + color: $modalCloseButtonHoverColor; + } +} diff --git a/frontend/src/Components/Modal/ModalContent.js b/frontend/src/Components/Modal/ModalContent.js new file mode 100644 index 000000000..655046fe4 --- /dev/null +++ b/frontend/src/Components/Modal/ModalContent.js @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons } from 'Helpers/Props'; +import Link from 'Components/Link/Link'; +import Icon from 'Components/Icon'; +import styles from './ModalContent.css'; + +function ModalContent(props) { + const { + className, + children, + showCloseButton, + onModalClose, + ...otherProps + } = props; + + return ( +
+ { + showCloseButton && + + + + } + + {children} +
+ ); +} + +ModalContent.propTypes = { + className: PropTypes.string, + children: PropTypes.node, + showCloseButton: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +ModalContent.defaultProps = { + className: styles.modalContent, + showCloseButton: true +}; + +export default ModalContent; diff --git a/frontend/src/Components/Modal/ModalError.css b/frontend/src/Components/Modal/ModalError.css new file mode 100644 index 000000000..1556240c6 --- /dev/null +++ b/frontend/src/Components/Modal/ModalError.css @@ -0,0 +1,15 @@ +.message { + composes: message from '~Components/Error/ErrorBoundaryError.css'; + + margin: 0; + margin-bottom: 30px; + font-weight: normal; + font-size: 26px; +} + +.details { + composes: details from '~Components/Error/ErrorBoundaryError.css'; + + margin: 0; + margin-top: 20px; +} diff --git a/frontend/src/Components/Modal/ModalError.js b/frontend/src/Components/Modal/ModalError.js new file mode 100644 index 000000000..df99a5b32 --- /dev/null +++ b/frontend/src/Components/Modal/ModalError.js @@ -0,0 +1,46 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import ErrorBoundaryError from 'Components/Error/ErrorBoundaryError'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './ModalError.css'; + +function ModalError(props) { + const { + onModalClose, + ...otherProps + } = props; + + return ( + + + Error + + + + + + + + + + ); +} + +ModalError.propTypes = { + onModalClose: PropTypes.func.isRequired +}; + +export default ModalError; diff --git a/frontend/src/Components/Modal/ModalFooter.css b/frontend/src/Components/Modal/ModalFooter.css new file mode 100644 index 000000000..3b817d2bf --- /dev/null +++ b/frontend/src/Components/Modal/ModalFooter.css @@ -0,0 +1,23 @@ +.modalFooter { + display: flex; + align-items: center; + justify-content: flex-end; + flex-shrink: 0; + padding: 15px 30px; + border-top: 1px solid $borderColor; + + a, + button { + margin-left: 10px; + + &:first-child { + margin-left: 0; + } + } +} + +@media only screen and (max-width: $breakpointSmall) { + .modalFooter { + padding: 15px; + } +} diff --git a/frontend/src/Components/Modal/ModalFooter.js b/frontend/src/Components/Modal/ModalFooter.js new file mode 100644 index 000000000..0cf8811d3 --- /dev/null +++ b/frontend/src/Components/Modal/ModalFooter.js @@ -0,0 +1,32 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './ModalFooter.css'; + +class ModalFooter extends Component { + + // + // Render + + render() { + const { + children, + ...otherProps + } = this.props; + + return ( +
+ {children} +
+ ); + } + +} + +ModalFooter.propTypes = { + children: PropTypes.node +}; + +export default ModalFooter; diff --git a/frontend/src/Components/Modal/ModalHeader.css b/frontend/src/Components/Modal/ModalHeader.css new file mode 100644 index 000000000..eab77a9f8 --- /dev/null +++ b/frontend/src/Components/Modal/ModalHeader.css @@ -0,0 +1,8 @@ +.modalHeader { + @add-mixin truncate; + + flex-shrink: 0; + padding: 15px 50px 15px 30px; + border-bottom: 1px solid $borderColor; + font-size: 18px; +} diff --git a/frontend/src/Components/Modal/ModalHeader.js b/frontend/src/Components/Modal/ModalHeader.js new file mode 100644 index 000000000..52879b57d --- /dev/null +++ b/frontend/src/Components/Modal/ModalHeader.js @@ -0,0 +1,32 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './ModalHeader.css'; + +class ModalHeader extends Component { + + // + // Render + + render() { + const { + children, + ...otherProps + } = this.props; + + return ( +
+ {children} +
+ ); + } + +} + +ModalHeader.propTypes = { + children: PropTypes.node +}; + +export default ModalHeader; diff --git a/frontend/src/Components/MonitorToggleButton.css b/frontend/src/Components/MonitorToggleButton.css new file mode 100644 index 000000000..09b64f1ab --- /dev/null +++ b/frontend/src/Components/MonitorToggleButton.css @@ -0,0 +1,11 @@ +.toggleButton { + composes: button from '~Components/Link/IconButton.css'; + + padding: 0; + font-size: inherit; +} + +.isDisabled { + color: $disabledColor; + cursor: not-allowed; +} diff --git a/frontend/src/Components/MonitorToggleButton.js b/frontend/src/Components/MonitorToggleButton.js new file mode 100644 index 000000000..c92db9bc0 --- /dev/null +++ b/frontend/src/Components/MonitorToggleButton.js @@ -0,0 +1,79 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import styles from './MonitorToggleButton.css'; + +function getTooltip(monitored, isDisabled) { + if (isDisabled) { + return 'Cannot toogle monitored state when artist is unmonitored'; + } + + if (monitored) { + return 'Monitored, click to unmonitor'; + } + + return 'Unmonitored, click to monitor'; +} + +class MonitorToggleButton extends Component { + + // + // Listeners + + onPress = (event) => { + const shiftKey = event.nativeEvent.shiftKey; + + this.props.onPress(!this.props.monitored, { shiftKey }); + } + + // + // Render + + render() { + const { + className, + monitored, + isDisabled, + isSaving, + size, + ...otherProps + } = this.props; + + const iconName = monitored ? icons.MONITORED : icons.UNMONITORED; + + return ( + + ); + } +} + +MonitorToggleButton.propTypes = { + className: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + size: PropTypes.number, + isDisabled: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + onPress: PropTypes.func.isRequired +}; + +MonitorToggleButton.defaultProps = { + className: styles.toggleButton, + isDisabled: false, + isSaving: false +}; + +export default MonitorToggleButton; diff --git a/frontend/src/Components/NotFound.css b/frontend/src/Components/NotFound.css new file mode 100644 index 000000000..9aaf1114f --- /dev/null +++ b/frontend/src/Components/NotFound.css @@ -0,0 +1,14 @@ +.container { + text-align: center; +} + +.message { + margin: 50px 0; + text-align: center; + font-weight: 300; + font-size: 36px; +} + +.image { + height: 350px; +} diff --git a/frontend/src/Components/NotFound.js b/frontend/src/Components/NotFound.js new file mode 100644 index 000000000..ad982df8a --- /dev/null +++ b/frontend/src/Components/NotFound.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import PageContent from 'Components/Page/PageContent'; +import styles from './NotFound.css'; + +function NotFound({ message }) { + return ( + +
+
+ {message} +
+ + +
+
+ ); +} + +NotFound.propTypes = { + message: PropTypes.string.isRequired +}; + +NotFound.defaultProps = { + message: 'You must be lost, nothing to see here.' +}; + +export default NotFound; diff --git a/frontend/src/Components/Page/ErrorPage.css b/frontend/src/Components/Page/ErrorPage.css new file mode 100644 index 000000000..c72e73673 --- /dev/null +++ b/frontend/src/Components/Page/ErrorPage.css @@ -0,0 +1,12 @@ +.page { + composes: page from '~./Page.css'; + + margin-top: 20px; + text-align: center; + font-size: 20px; +} + +.version { + margin-top: 20px; + font-size: 16px; +} diff --git a/frontend/src/Components/Page/ErrorPage.js b/frontend/src/Components/Page/ErrorPage.js new file mode 100644 index 000000000..4440cf3be --- /dev/null +++ b/frontend/src/Components/Page/ErrorPage.js @@ -0,0 +1,64 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import styles from './ErrorPage.css'; + +function ErrorPage(props) { + const { + version, + isLocalStorageSupported, + artistError, + customFiltersError, + tagsError, + qualityProfilesError, + metadataProfilesError, + uiSettingsError, + systemStatusError + } = props; + + let errorMessage = 'Failed to load Lidarr'; + + if (!isLocalStorageSupported) { + errorMessage = 'Local Storage is not supported or disabled. A plugin or private browsing may have disabled it.'; + } else if (artistError) { + errorMessage = getErrorMessage(artistError, 'Failed to load artist from API'); + } else if (customFiltersError) { + errorMessage = getErrorMessage(customFiltersError, 'Failed to load custom filters from API'); + } else if (tagsError) { + errorMessage = getErrorMessage(tagsError, 'Failed to load tags from API'); + } else if (qualityProfilesError) { + errorMessage = getErrorMessage(qualityProfilesError, 'Failed to load quality profiles from API'); + } else if (metadataProfilesError) { + errorMessage = getErrorMessage(metadataProfilesError, 'Failed to load metadata profiles from API'); + } else if (uiSettingsError) { + errorMessage = getErrorMessage(uiSettingsError, 'Failed to load UI settings from API'); + } else if (systemStatusError) { + errorMessage = getErrorMessage(uiSettingsError, 'Failed to load system status from API'); + } + + return ( +
+
+ {errorMessage} +
+ +
+ Version {version} +
+
+ ); +} + +ErrorPage.propTypes = { + version: PropTypes.string.isRequired, + isLocalStorageSupported: PropTypes.bool.isRequired, + artistError: PropTypes.object, + customFiltersError: PropTypes.object, + tagsError: PropTypes.object, + qualityProfilesError: PropTypes.object, + metadataProfilesError: PropTypes.object, + uiSettingsError: PropTypes.object, + systemStatusError: PropTypes.object +}; + +export default ErrorPage; diff --git a/frontend/src/Components/Page/Header/ArtistSearchInput.css b/frontend/src/Components/Page/Header/ArtistSearchInput.css new file mode 100644 index 000000000..7043de6c5 --- /dev/null +++ b/frontend/src/Components/Page/Header/ArtistSearchInput.css @@ -0,0 +1,96 @@ +.wrapper { + display: flex; + align-items: center; +} + +.input { + margin-left: 8px; + width: 200px; + border: none; + border-bottom: solid 1px $white; + border-radius: 0; + background-color: transparent; + box-shadow: none; + color: $white; + transition: border 0.3s ease-out; + + &::placeholder { + color: $white; + transition: color 0.3s ease-out; + } + + &:focus { + outline: 0; + border-bottom-color: transparent; + + &::placeholder { + color: transparent; + } + } +} + +.container { + position: relative; + flex-grow: 1; +} + +.artistContainer { + @add-mixin scrollbar; + @add-mixin scrollbarTrack; + @add-mixin scrollbarThumb; +} + +.containerOpen { + .artistContainer { + position: absolute; + top: 42px; + z-index: 1; + overflow-y: auto; + min-width: 100%; + max-height: 230px; + border: 1px solid $themeDarkColor; + border-radius: 4px; + border-top-left-radius: 0; + border-top-right-radius: 0; + background-color: $themeDarkColor; + box-shadow: inset 0 1px 1px $inputBoxShadowColor; + color: $menuItemColor; + } +} + +.list { + margin: 5px 0; + padding-left: 0; + list-style-type: none; +} + +.listItem { + padding: 0 16px; + white-space: nowrap; +} + +.highlighted { + background-color: $primaryHoverBackgroundColor; +} + +.sectionTitle { + padding: 5px 8px; + color: $disabledColor; +} + +.addNewArtistSuggestion { + padding: 0 3px; + cursor: pointer; +} + +@media only screen and (max-width: $breakpointSmall) { + .input { + min-width: 150px; + max-width: 200px; + } + + .container { + min-width: 0; + max-width: 200px; + } +} diff --git a/frontend/src/Components/Page/Header/ArtistSearchInput.js b/frontend/src/Components/Page/Header/ArtistSearchInput.js new file mode 100644 index 000000000..eb22640ce --- /dev/null +++ b/frontend/src/Components/Page/Header/ArtistSearchInput.js @@ -0,0 +1,259 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Autosuggest from 'react-autosuggest'; +import Fuse from 'fuse.js'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts'; +import ArtistSearchResult from './ArtistSearchResult'; +import styles from './ArtistSearchInput.css'; + +const ADD_NEW_TYPE = 'addNew'; + +const fuseOptions = { + shouldSort: true, + includeMatches: true, + threshold: 0.3, + location: 0, + distance: 100, + maxPatternLength: 32, + minMatchCharLength: 1, + keys: [ + 'artistName', + 'tags.label' + ] +}; + +class ArtistSearchInput extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._autosuggest = null; + + this.state = { + value: '', + suggestions: [] + }; + } + + componentDidMount() { + this.props.bindShortcut(shortcuts.ARTIST_SEARCH_INPUT.key, this.focusInput); + } + + // + // Control + + setAutosuggestRef = (ref) => { + this._autosuggest = ref; + } + + focusInput = (event) => { + event.preventDefault(); + this._autosuggest.input.focus(); + } + + getSectionSuggestions(section) { + return section.suggestions; + } + + renderSectionTitle(section) { + return ( +
+ {section.title} +
+ ); + } + + getSuggestionValue({ title }) { + return title || ''; + } + + renderSuggestion(item, { query }) { + if (item.type === ADD_NEW_TYPE) { + return ( +
+ Search for {query} +
+ ); + } + + return ( + + ); + } + + goToArtist(item) { + this.setState({ value: '' }); + this.props.onGoToArtist(item.item.foreignArtistId); + } + + reset() { + this.setState({ + value: '', + suggestions: [] + }); + } + + // + // Listeners + + onChange = (event, { newValue, method }) => { + if (method === 'up' || method === 'down') { + return; + } + + this.setState({ value: newValue }); + } + + onKeyDown = (event) => { + if (event.key !== 'Tab' && event.key !== 'Enter' || event.key !== 'ArrowDown' || event.key !== 'ArrowUp') { + return; + } + + const { + suggestions, + value + } = this.state; + + const { + highlightedSectionIndex, + highlightedSuggestionIndex + } = this._autosuggest.state; + + if (!suggestions.length || highlightedSectionIndex && (event.key !== 'ArrowDown' || event.key !== 'ArrowUp')) { + this.props.onGoToAddNewArtist(value); + this._autosuggest.input.blur(); + this.reset(); + + return; + } + + // If an suggestion is not selected go to the first artist, + // otherwise go to the selected artist. + + if (highlightedSuggestionIndex == null && (event.key !== 'ArrowDown' || event.key !== 'ArrowUp')) { + this.goToArtist(suggestions[0]); + } else { + this.goToArtist(suggestions[highlightedSuggestionIndex]); + } + + this._autosuggest.input.blur(); + this.reset(); + } + + onBlur = () => { + this.reset(); + } + + onSuggestionsFetchRequested = ({ value }) => { + const fuse = new Fuse(this.props.artists, fuseOptions); + const suggestions = fuse.search(value).slice(0, 15); + + this.setState({ suggestions }); + } + + onSuggestionsClearRequested = () => { + this.setState({ + suggestions: [] + }); + } + + onSuggestionSelected = (event, { suggestion }) => { + if (suggestion.type === ADD_NEW_TYPE) { + this.props.onGoToAddNewArtist(this.state.value); + } else { + this.goToArtist(suggestion); + } + } + + // + // Render + + render() { + const { + value, + suggestions + } = this.state; + + const suggestionGroups = []; + + if (suggestions.length) { + suggestionGroups.push({ + title: 'Existing Artist', + suggestions + }); + } + + suggestionGroups.push({ + title: 'Add New Artist', + suggestions: [ + { + type: ADD_NEW_TYPE, + title: value + } + ] + }); + + const inputProps = { + ref: this.setInputRef, + className: styles.input, + name: 'artistSearch', + value, + placeholder: 'Search', + autoComplete: 'off', + spellCheck: false, + onChange: this.onChange, + onKeyDown: this.onKeyDown, + onBlur: this.onBlur, + onFocus: this.onFocus + }; + + const theme = { + container: styles.container, + containerOpen: styles.containerOpen, + suggestionsContainer: styles.artistContainer, + suggestionsList: styles.list, + suggestion: styles.listItem, + suggestionHighlighted: styles.highlighted + }; + + return ( +
+ + + +
+ ); + } +} + +ArtistSearchInput.propTypes = { + artists: PropTypes.arrayOf(PropTypes.object).isRequired, + onGoToArtist: PropTypes.func.isRequired, + onGoToAddNewArtist: PropTypes.func.isRequired, + bindShortcut: PropTypes.func.isRequired +}; + +export default keyboardShortcuts(ArtistSearchInput); diff --git a/frontend/src/Components/Page/Header/ArtistSearchInputConnector.js b/frontend/src/Components/Page/Header/ArtistSearchInputConnector.js new file mode 100644 index 000000000..214303358 --- /dev/null +++ b/frontend/src/Components/Page/Header/ArtistSearchInputConnector.js @@ -0,0 +1,66 @@ +import { connect } from 'react-redux'; +import { push } from 'connected-react-router'; +import { createSelector } from 'reselect'; +import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; +import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import ArtistSearchInput from './ArtistSearchInput'; + +function createCleanArtistSelector() { + return createSelector( + createAllArtistSelector(), + createTagsSelector(), + (allArtists, allTags) => { + return allArtists.map((artist) => { + const { + artistName, + sortName, + images, + foreignArtistId, + tags = [] + } = artist; + + return { + artistName, + sortName, + foreignArtistId, + images, + tags: tags.reduce((acc, id) => { + const matchingTag = allTags.find((tag) => tag.id === id); + + if (matchingTag) { + acc.push(matchingTag); + } + + return acc; + }, []) + }; + }); + } + ); +} + +function createMapStateToProps() { + return createDeepEqualSelector( + createCleanArtistSelector(), + (artists) => { + return { + artists + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onGoToArtist(foreignArtistId) { + dispatch(push(`${window.Lidarr.urlBase}/artist/${foreignArtistId}`)); + }, + + onGoToAddNewArtist(query) { + dispatch(push(`${window.Lidarr.urlBase}/add/new?term=${encodeURIComponent(query)}`)); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistSearchInput); diff --git a/frontend/src/Components/Page/Header/ArtistSearchResult.css b/frontend/src/Components/Page/Header/ArtistSearchResult.css new file mode 100644 index 000000000..4d21d4640 --- /dev/null +++ b/frontend/src/Components/Page/Header/ArtistSearchResult.css @@ -0,0 +1,38 @@ +.result { + display: flex; + padding: 3px; + cursor: pointer; +} + +.poster { + width: 35px; + height: 35px; +} + +.titles { + flex: 1 1 1px; +} + +.title { + flex: 1 1 1px; + margin-left: 5px; +} + +.alternateTitle { + composes: title; + + color: $disabledColor; + font-size: $smallFontSize; +} + +.tagContainer { + composes: title; +} + +@media only screen and (max-width: $breakpointSmall) { + .titles, + .title, + .alternateTitle { + @add-mixin truncate; + } +} diff --git a/frontend/src/Components/Page/Header/ArtistSearchResult.js b/frontend/src/Components/Page/Header/ArtistSearchResult.js new file mode 100644 index 000000000..9e8511918 --- /dev/null +++ b/frontend/src/Components/Page/Header/ArtistSearchResult.js @@ -0,0 +1,61 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Label from 'Components/Label'; +import ArtistPoster from 'Artist/ArtistPoster'; +import styles from './ArtistSearchResult.css'; + +function ArtistSearchResult(props) { + const { + match, + artistName, + images, + tags + } = props; + + let tag = null; + + if (match.key === 'tags.label') { + tag = tags[match.arrayIndex]; + } + + return ( +
+ + +
+
+ {artistName} +
+ + { + tag ? +
+ +
: + null + } +
+
+ ); +} + +ArtistSearchResult.propTypes = { + artistName: PropTypes.string.isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + tags: PropTypes.arrayOf(PropTypes.object).isRequired, + match: PropTypes.object.isRequired +}; + +export default ArtistSearchResult; diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModal.js b/frontend/src/Components/Page/Header/KeyboardShortcutsModal.js new file mode 100644 index 000000000..a1d106b58 --- /dev/null +++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import KeyboardShortcutsModalContentConnector from './KeyboardShortcutsModalContentConnector'; + +function KeyboardShortcutsModal(props) { + const { + isOpen, + onModalClose + } = props; + + return ( + + + + ); +} + +KeyboardShortcutsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default KeyboardShortcutsModal; diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.css b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.css new file mode 100644 index 000000000..4425e0e0d --- /dev/null +++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.css @@ -0,0 +1,15 @@ +.shortcut { + display: flex; + justify-content: space-between; + padding: 5px 20px; + font-size: 18px; +} + +.key { + padding: 2px 4px; + border-radius: 3px; + background-color: $defaultColor; + box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.25); + color: $white; + font-size: 16px; +} diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.js b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.js new file mode 100644 index 000000000..9c07e047c --- /dev/null +++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.js @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { shortcuts } from 'Components/keyboardShortcuts'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './KeyboardShortcutsModalContent.css'; + +function getShortcuts() { + const allShortcuts = []; + + Object.keys(shortcuts).forEach((key) => { + allShortcuts.push(shortcuts[key]); + }); + + return allShortcuts; +} + +function getShortcutKey(combo, isOsx) { + const comboMatch = combo.match(/(.+?)\+(.)/); + + if (!comboMatch) { + return combo; + } + + const modifier = comboMatch[1]; + const key = comboMatch[2]; + let osModifier = modifier; + + if (modifier === 'mod') { + osModifier = isOsx ? 'cmd' : 'ctrl'; + } + + return `${osModifier} + ${key}`; +} + +function KeyboardShortcutsModalContent(props) { + const { + isOsx, + onModalClose + } = props; + + const allShortcuts = getShortcuts(); + + return ( + + + Keyboard Shortcuts + + + + { + allShortcuts.map((shortcut) => { + return ( +
+
+ {getShortcutKey(shortcut.key, isOsx)} +
+ +
+ {shortcut.name} +
+
+ ); + }) + } +
+ + + + +
+ ); +} + +KeyboardShortcutsModalContent.propTypes = { + isOsx: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default KeyboardShortcutsModalContent; diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModalContentConnector.js b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContentConnector.js new file mode 100644 index 000000000..d80877153 --- /dev/null +++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContentConnector.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; +import KeyboardShortcutsModalContent from './KeyboardShortcutsModalContent'; + +function createMapStateToProps() { + return createSelector( + createSystemStatusSelector(), + (systemStatus) => { + return { + isOsx: systemStatus.isOsx + }; + } + ); +} + +export default connect(createMapStateToProps)(KeyboardShortcutsModalContent); diff --git a/frontend/src/Components/Page/Header/PageHeader.css b/frontend/src/Components/Page/Header/PageHeader.css new file mode 100644 index 000000000..c4dc3f844 --- /dev/null +++ b/frontend/src/Components/Page/Header/PageHeader.css @@ -0,0 +1,65 @@ +.header { + z-index: 3; + display: flex; + align-items: center; + flex: 0 0 auto; + height: $headerHeight; + background-color: $themeAlternateBlue; + color: $white; +} + +.logoContainer { + display: flex; + align-items: center; + flex: 0 0 $sidebarWidth; + padding-left: 20px; +} + +.logoLink { + line-height: 0; +} + +.logo { + width: 32px; + height: 32px; +} + +.sidebarToggleContainer { + display: none; + justify-content: center; + flex: 0 0 45px; + margin-right: 14px; +} + +.right { + display: flex; + justify-content: flex-end; + flex-grow: 1; +} + +.donate { + composes: link from '~Components/Link/Link.css'; + + width: 30px; + color: $themeRed; + text-align: center; + line-height: 60px; + + &:hover { + color: #9c1f30; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .logoContainer { + flex: 0 0 60px; + } + + .sidebarToggleContainer { + display: flex; + } + + .donate { + display: none; + } +} diff --git a/frontend/src/Components/Page/Header/PageHeader.js b/frontend/src/Components/Page/Header/PageHeader.js new file mode 100644 index 000000000..87cf317b3 --- /dev/null +++ b/frontend/src/Components/Page/Header/PageHeader.js @@ -0,0 +1,101 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import ArtistSearchInputConnector from './ArtistSearchInputConnector'; +import PageHeaderActionsMenuConnector from './PageHeaderActionsMenuConnector'; +import KeyboardShortcutsModal from './KeyboardShortcutsModal'; +import styles from './PageHeader.css'; + +class PageHeader extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props); + + this.state = { + isKeyboardShortcutsModalOpen: false + }; + } + + componentDidMount() { + this.props.bindShortcut(shortcuts.OPEN_KEYBOARD_SHORTCUTS_MODAL.key, this.onOpenKeyboardShortcutsModal); + } + + // + // Control + + onOpenKeyboardShortcutsModal = () => { + this.setState({ isKeyboardShortcutsModalOpen: true }); + } + + // + // Listeners + + onKeyboardShortcutsModalClose = () => { + this.setState({ isKeyboardShortcutsModalOpen: false }); + } + + // + // Render + + render() { + const { + onSidebarToggle + } = this.props; + + return ( +
+
+ + + +
+ +
+ +
+ + + +
+ + +
+ + +
+ ); + } +} + +PageHeader.propTypes = { + onSidebarToggle: PropTypes.func.isRequired, + bindShortcut: PropTypes.func.isRequired +}; + +export default keyboardShortcuts(PageHeader); diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenu.css b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.css new file mode 100644 index 000000000..0fee43911 --- /dev/null +++ b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.css @@ -0,0 +1,20 @@ +.menuButton { + margin-right: 15px; + width: 30px; + height: 60px; + text-align: center; + + &:hover { + color: $themeDarkColor; + } +} + +.itemIcon { + margin-right: 8px; +} + +@media only screen and (max-width: $breakpointSmall) { + .menuButton { + margin-right: 5px; + } +} diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js new file mode 100644 index 000000000..ae97e6be2 --- /dev/null +++ b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { align, icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Menu from 'Components/Menu/Menu'; +import MenuButton from 'Components/Menu/MenuButton'; +import MenuContent from 'Components/Menu/MenuContent'; +import MenuItem from 'Components/Menu/MenuItem'; +import MenuItemSeparator from 'Components/Menu/MenuItemSeparator'; +import styles from './PageHeaderActionsMenu.css'; + +function PageHeaderActionsMenu(props) { + const { + formsAuth, + onKeyboardShortcutsPress, + onRestartPress, + onShutdownPress + } = props; + + return ( +
+ + + + + + + + + Keyboard Shortcuts + + + + + + + Restart + + + + + Shutdown + + + { + formsAuth && +
+ } + + { + formsAuth && + + + Logout + + } + +
+
+ ); +} + +PageHeaderActionsMenu.propTypes = { + formsAuth: PropTypes.bool.isRequired, + onKeyboardShortcutsPress: PropTypes.func.isRequired, + onRestartPress: PropTypes.func.isRequired, + onShutdownPress: PropTypes.func.isRequired +}; + +export default PageHeaderActionsMenu; diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenuConnector.js b/frontend/src/Components/Page/Header/PageHeaderActionsMenuConnector.js new file mode 100644 index 000000000..66d131521 --- /dev/null +++ b/frontend/src/Components/Page/Header/PageHeaderActionsMenuConnector.js @@ -0,0 +1,56 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { restart, shutdown } from 'Store/Actions/systemActions'; +import PageHeaderActionsMenu from './PageHeaderActionsMenu'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.status, + (status) => { + return { + formsAuth: status.item.authentication === 'forms' + }; + } + ); +} + +const mapDispatchToProps = { + restart, + shutdown +}; + +class PageHeaderActionsMenuConnector extends Component { + + // + // Listeners + + onRestartPress = () => { + this.props.restart(); + } + + onShutdownPress = () => { + this.props.shutdown(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +PageHeaderActionsMenuConnector.propTypes = { + restart: PropTypes.func.isRequired, + shutdown: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(PageHeaderActionsMenuConnector); diff --git a/frontend/src/Components/Page/LoadingPage.css b/frontend/src/Components/Page/LoadingPage.css new file mode 100644 index 000000000..fc782dc0c --- /dev/null +++ b/frontend/src/Components/Page/LoadingPage.css @@ -0,0 +1,3 @@ +.page { + composes: page from '~./Page.css'; +} diff --git a/frontend/src/Components/Page/LoadingPage.js b/frontend/src/Components/Page/LoadingPage.js new file mode 100644 index 000000000..398b70c4b --- /dev/null +++ b/frontend/src/Components/Page/LoadingPage.js @@ -0,0 +1,15 @@ +import React from 'react'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import LoadingMessage from 'Components/Loading/LoadingMessage'; +import styles from './LoadingPage.css'; + +function LoadingPage() { + return ( +
+ + +
+ ); +} + +export default LoadingPage; diff --git a/frontend/src/Components/Page/Page.css b/frontend/src/Components/Page/Page.css new file mode 100644 index 000000000..9facbfc22 --- /dev/null +++ b/frontend/src/Components/Page/Page.css @@ -0,0 +1,18 @@ +.page { + display: flex; + flex-direction: column; + height: 100%; +} + +.main { + position: relative; /* need this to position inner content - is this really needed? */ + display: flex; + flex: 1 1 auto; +} + +@media only screen and (max-width: $breakpointSmall) { + .page { + flex-grow: 1; + height: initial; + } +} diff --git a/frontend/src/Components/Page/Page.js b/frontend/src/Components/Page/Page.js new file mode 100644 index 000000000..4f871f864 --- /dev/null +++ b/frontend/src/Components/Page/Page.js @@ -0,0 +1,135 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import locationShape from 'Helpers/Props/Shapes/locationShape'; +import SignalRConnector from 'Components/SignalRConnector'; +import ColorImpairedContext from 'App/ColorImpairedContext'; +import ConnectionLostModalConnector from 'App/ConnectionLostModalConnector'; +import AppUpdatedModalConnector from 'App/AppUpdatedModalConnector'; +import PageHeader from './Header/PageHeader'; +import PageSidebar from './Sidebar/PageSidebar'; +import styles from './Page.css'; + +class Page extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isUpdatedModalOpen: false, + isConnectionLostModalOpen: false + }; + } + + componentDidMount() { + window.addEventListener('resize', this.onResize); + } + + componentDidUpdate(prevProps) { + const { + isDisconnected, + isUpdated + } = this.props; + + if (!prevProps.isUpdated && isUpdated) { + this.setState({ isUpdatedModalOpen: true }); + } + + if (prevProps.isDisconnected !== isDisconnected) { + this.setState({ isConnectionLostModalOpen: isDisconnected }); + } + } + + componentWillUnmount() { + window.removeEventListener('resize', this.onResize); + } + + // + // Listeners + + onResize = () => { + this.props.onResize({ + width: window.innerWidth, + height: window.innerHeight + }); + } + + onUpdatedModalClose = () => { + this.setState({ isUpdatedModalOpen: false }); + } + + onConnectionLostModalClose = () => { + this.setState({ isConnectionLostModalOpen: false }); + } + + // + // Render + + render() { + const { + className, + location, + children, + isSmallScreen, + isSidebarVisible, + enableColorImpairedMode, + onSidebarToggle, + onSidebarVisibleChange + } = this.props; + + return ( + +
+ + + + +
+ + + {children} +
+ + + + +
+
+ ); + } +} + +Page.propTypes = { + className: PropTypes.string, + location: locationShape.isRequired, + children: PropTypes.node.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + isSidebarVisible: PropTypes.bool.isRequired, + isUpdated: PropTypes.bool.isRequired, + isDisconnected: PropTypes.bool.isRequired, + enableColorImpairedMode: PropTypes.bool.isRequired, + onResize: PropTypes.func.isRequired, + onSidebarToggle: PropTypes.func.isRequired, + onSidebarVisibleChange: PropTypes.func.isRequired +}; + +Page.defaultProps = { + className: styles.page +}; + +export default Page; diff --git a/frontend/src/Components/Page/PageConnector.js b/frontend/src/Components/Page/PageConnector.js new file mode 100644 index 000000000..7d0dded6b --- /dev/null +++ b/frontend/src/Components/Page/PageConnector.js @@ -0,0 +1,265 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { withRouter } from 'react-router-dom'; +import { createSelector } from 'reselect'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import { saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions'; +import { fetchCustomFilters } from 'Store/Actions/customFilterActions'; +import { fetchArtist } from 'Store/Actions/artistActions'; +import { fetchTags } from 'Store/Actions/tagActions'; +import { fetchQualityProfiles, fetchMetadataProfiles, fetchUISettings, fetchImportLists } from 'Store/Actions/settingsActions'; +import { fetchStatus } from 'Store/Actions/systemActions'; +import ErrorPage from './ErrorPage'; +import LoadingPage from './LoadingPage'; +import Page from './Page'; + +function testLocalStorage() { + const key = 'lidarrTest'; + + try { + localStorage.setItem(key, key); + localStorage.removeItem(key); + + return true; + } catch (e) { + return false; + } +} + +const selectAppProps = createSelector( + (state) => state.app.isSidebarVisible, + (state) => state.app.version, + (state) => state.app.isUpdated, + (state) => state.app.isDisconnected, + (isSidebarVisible, version, isUpdated, isDisconnected) => { + return { + isSidebarVisible, + version, + isUpdated, + isDisconnected + }; + } +); + +const selectIsPopulated = createSelector( + (state) => state.customFilters.isPopulated, + (state) => state.tags.isPopulated, + (state) => state.settings.ui.isPopulated, + (state) => state.settings.qualityProfiles.isPopulated, + (state) => state.settings.metadataProfiles.isPopulated, + (state) => state.settings.importLists.isPopulated, + (state) => state.system.status.isPopulated, + ( + customFiltersIsPopulated, + tagsIsPopulated, + uiSettingsIsPopulated, + qualityProfilesIsPopulated, + metadataProfilesIsPopulated, + importListsIsPopulated, + systemStatusIsPopulated + ) => { + return ( + customFiltersIsPopulated && + tagsIsPopulated && + uiSettingsIsPopulated && + qualityProfilesIsPopulated && + metadataProfilesIsPopulated && + importListsIsPopulated && + systemStatusIsPopulated + ); + } +); + +const selectErrors = createSelector( + (state) => state.customFilters.error, + (state) => state.tags.error, + (state) => state.settings.ui.error, + (state) => state.settings.qualityProfiles.error, + (state) => state.settings.metadataProfiles.error, + (state) => state.settings.importLists.error, + (state) => state.system.status.error, + ( + customFiltersError, + tagsError, + uiSettingsError, + qualityProfilesError, + metadataProfilesError, + importListsError, + systemStatusError + ) => { + const hasError = !!( + customFiltersError || + tagsError || + uiSettingsError || + qualityProfilesError || + metadataProfilesError || + importListsError || + systemStatusError + ); + + return { + hasError, + customFiltersError, + tagsError, + uiSettingsError, + qualityProfilesError, + metadataProfilesError, + importListsError, + systemStatusError + }; + } +); + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.ui.item.enableColorImpairedMode, + selectIsPopulated, + selectErrors, + selectAppProps, + createDimensionsSelector(), + ( + enableColorImpairedMode, + isPopulated, + errors, + app, + dimensions + ) => { + return { + ...app, + ...errors, + isPopulated, + isSmallScreen: dimensions.isSmallScreen, + enableColorImpairedMode + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + dispatchFetchArtist() { + dispatch(fetchArtist()); + }, + dispatchFetchCustomFilters() { + dispatch(fetchCustomFilters()); + }, + dispatchFetchTags() { + dispatch(fetchTags()); + }, + dispatchFetchQualityProfiles() { + dispatch(fetchQualityProfiles()); + }, + dispatchFetchMetadataProfiles() { + dispatch(fetchMetadataProfiles()); + }, + dispatchFetchImportLists() { + dispatch(fetchImportLists()); + }, + dispatchFetchUISettings() { + dispatch(fetchUISettings()); + }, + dispatchFetchStatus() { + dispatch(fetchStatus()); + }, + onResize(dimensions) { + dispatch(saveDimensions(dimensions)); + }, + onSidebarVisibleChange(isSidebarVisible) { + dispatch(setIsSidebarVisible({ isSidebarVisible })); + } + }; +} + +class PageConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isLocalStorageSupported: testLocalStorage() + }; + } + + componentDidMount() { + if (!this.props.isPopulated) { + this.props.dispatchFetchArtist(); + this.props.dispatchFetchCustomFilters(); + this.props.dispatchFetchTags(); + this.props.dispatchFetchQualityProfiles(); + this.props.dispatchFetchMetadataProfiles(); + this.props.dispatchFetchImportLists(); + this.props.dispatchFetchUISettings(); + this.props.dispatchFetchStatus(); + } + } + + // + // Listeners + + onSidebarToggle = () => { + this.props.onSidebarVisibleChange(!this.props.isSidebarVisible); + } + + // + // Render + + render() { + const { + isPopulated, + hasError, + dispatchFetchArtist, + dispatchFetchTags, + dispatchFetchQualityProfiles, + dispatchFetchMetadataProfiles, + dispatchFetchImportLists, + dispatchFetchUISettings, + dispatchFetchStatus, + ...otherProps + } = this.props; + + if (hasError || !this.state.isLocalStorageSupported) { + return ( + + ); + } + + if (isPopulated) { + return ( + + ); + } + + return ( + + ); + } +} + +PageConnector.propTypes = { + isPopulated: PropTypes.bool.isRequired, + hasError: PropTypes.bool.isRequired, + isSidebarVisible: PropTypes.bool.isRequired, + dispatchFetchArtist: PropTypes.func.isRequired, + dispatchFetchCustomFilters: PropTypes.func.isRequired, + dispatchFetchTags: PropTypes.func.isRequired, + dispatchFetchQualityProfiles: PropTypes.func.isRequired, + dispatchFetchMetadataProfiles: PropTypes.func.isRequired, + dispatchFetchImportLists: PropTypes.func.isRequired, + dispatchFetchUISettings: PropTypes.func.isRequired, + dispatchFetchStatus: PropTypes.func.isRequired, + onSidebarVisibleChange: PropTypes.func.isRequired +}; + +export default withRouter( + connect(createMapStateToProps, createMapDispatchToProps)(PageConnector) +); diff --git a/frontend/src/Components/Page/PageContent.css b/frontend/src/Components/Page/PageContent.css new file mode 100644 index 000000000..4580077c3 --- /dev/null +++ b/frontend/src/Components/Page/PageContent.css @@ -0,0 +1,8 @@ +.content { + position: relative; + display: flex; + flex-direction: column; + flex-grow: 1; + overflow-x: hidden; + width: 100%; +} diff --git a/frontend/src/Components/Page/PageContent.js b/frontend/src/Components/Page/PageContent.js new file mode 100644 index 000000000..e7a650bb4 --- /dev/null +++ b/frontend/src/Components/Page/PageContent.js @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import DocumentTitle from 'react-document-title'; +import ErrorBoundary from 'Components/Error/ErrorBoundary'; +import PageContentError from './PageContentError'; +import styles from './PageContent.css'; + +function PageContent(props) { + const { + className, + title, + children + } = props; + + return ( + + +
+ {children} +
+
+
+ ); +} + +PageContent.propTypes = { + className: PropTypes.string, + title: PropTypes.string, + children: PropTypes.node.isRequired +}; + +PageContent.defaultProps = { + className: styles.content +}; + +export default PageContent; diff --git a/frontend/src/Components/Page/PageContentBody.css b/frontend/src/Components/Page/PageContentBody.css new file mode 100644 index 000000000..8b41754dd --- /dev/null +++ b/frontend/src/Components/Page/PageContentBody.css @@ -0,0 +1,19 @@ +.contentBody { + /* 1px for flex-basis so the div grows correctly in Edge/Firefox */ + flex: 1 0 1px; +} + +.innerContentBody { + padding: $pageContentBodyPadding; +} + +@media only screen and (max-width: $breakpointSmall) { + .contentBody { + flex-basis: auto; + overflow-y: hidden !important; + } + + .innerContentBody { + padding: $pageContentBodyPaddingSmallScreen; + } +} diff --git a/frontend/src/Components/Page/PageContentBody.js b/frontend/src/Components/Page/PageContentBody.js new file mode 100644 index 000000000..5c277d8f5 --- /dev/null +++ b/frontend/src/Components/Page/PageContentBody.js @@ -0,0 +1,66 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { isLocked } from 'Utilities/scrollLock'; +import { scrollDirections } from 'Helpers/Props'; +import OverlayScroller from 'Components/Scroller/OverlayScroller'; +import Scroller from 'Components/Scroller/Scroller'; +import styles from './PageContentBody.css'; + +class PageContentBody extends Component { + + // + // Listeners + + onScroll = (props) => { + const { onScroll } = this.props; + + if (this.props.onScroll && !isLocked()) { + onScroll(props); + } + } + + // + // Render + + render() { + const { + className, + innerClassName, + isSmallScreen, + children, + dispatch, + ...otherProps + } = this.props; + + const ScrollerComponent = isSmallScreen ? Scroller : OverlayScroller; + + return ( + +
+ {children} +
+
+ ); + } +} + +PageContentBody.propTypes = { + className: PropTypes.string, + innerClassName: PropTypes.string, + isSmallScreen: PropTypes.bool.isRequired, + children: PropTypes.node.isRequired, + onScroll: PropTypes.func, + dispatch: PropTypes.func +}; + +PageContentBody.defaultProps = { + className: styles.contentBody, + innerClassName: styles.innerContentBody +}; + +export default PageContentBody; diff --git a/frontend/src/Components/Page/PageContentBodyConnector.js b/frontend/src/Components/Page/PageContentBodyConnector.js new file mode 100644 index 000000000..e864ea7eb --- /dev/null +++ b/frontend/src/Components/Page/PageContentBodyConnector.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import PageContentBody from './PageContentBody'; + +function createMapStateToProps() { + return createSelector( + createDimensionsSelector(), + (dimensions) => { + return { + isSmallScreen: dimensions.isSmallScreen + }; + } + ); +} + +export default connect(createMapStateToProps, null, null, { forwardRef: true })(PageContentBody); diff --git a/frontend/src/Components/Page/PageContentError.css b/frontend/src/Components/Page/PageContentError.css new file mode 100644 index 000000000..811e61c85 --- /dev/null +++ b/frontend/src/Components/Page/PageContentError.css @@ -0,0 +1,3 @@ +.content { + composes: content from '~./PageContent.css'; +} diff --git a/frontend/src/Components/Page/PageContentError.js b/frontend/src/Components/Page/PageContentError.js new file mode 100644 index 000000000..5ae41a936 --- /dev/null +++ b/frontend/src/Components/Page/PageContentError.js @@ -0,0 +1,19 @@ +import React from 'react'; +import ErrorBoundaryError from 'Components/Error/ErrorBoundaryError'; +import PageContentBodyConnector from './PageContentBodyConnector'; +import styles from './PageContentError.css'; + +function PageContentError(props) { + return ( +
+ + + +
+ ); +} + +export default PageContentError; diff --git a/frontend/src/Components/Page/PageContentFooter.css b/frontend/src/Components/Page/PageContentFooter.css new file mode 100644 index 000000000..74bdb3811 --- /dev/null +++ b/frontend/src/Components/Page/PageContentFooter.css @@ -0,0 +1,26 @@ +.contentFooter { + display: flex; + flex: 0 0 auto; + padding: 20px; + background-color: #f1f1f1; +} + +@media only screen and (max-width: $breakpointSmall) { + .contentFooter { + display: block; + + div { + margin-top: 10px; + + &:first-child { + margin-top: 0; + } + } + } +} + +@media only screen and (max-width: $breakpointLarge) { + .contentFooter { + flex-wrap: wrap; + } +} diff --git a/frontend/src/Components/Page/PageContentFooter.js b/frontend/src/Components/Page/PageContentFooter.js new file mode 100644 index 000000000..1f6e2d21a --- /dev/null +++ b/frontend/src/Components/Page/PageContentFooter.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './PageContentFooter.css'; + +class PageContentFooter extends Component { + + // + // Render + + render() { + const { + className, + children + } = this.props; + + return ( +
+ {children} +
+ ); + } +} + +PageContentFooter.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired +}; + +PageContentFooter.defaultProps = { + className: styles.contentFooter +}; + +export default PageContentFooter; diff --git a/frontend/src/Components/Page/PageJumpBar.css b/frontend/src/Components/Page/PageJumpBar.css new file mode 100644 index 000000000..9a116fb54 --- /dev/null +++ b/frontend/src/Components/Page/PageJumpBar.css @@ -0,0 +1,22 @@ +.jumpBar { + display: flex; + align-content: stretch; + align-items: stretch; + align-self: stretch; + justify-content: center; + flex: 0 0 30px; +} + +.jumpBarItems { + display: flex; + justify-content: space-around; + flex: 0 0 100%; + flex-direction: column; + overflow: hidden; +} + +@media only screen and (max-width: $breakpointSmall) { + .jumpBar { + display: none; + } +} diff --git a/frontend/src/Components/Page/PageJumpBar.js b/frontend/src/Components/Page/PageJumpBar.js new file mode 100644 index 000000000..41df52dfc --- /dev/null +++ b/frontend/src/Components/Page/PageJumpBar.js @@ -0,0 +1,141 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import dimensions from 'Styles/Variables/dimensions'; +import Measure from 'Components/Measure'; +import PageJumpBarItem from './PageJumpBarItem'; +import styles from './PageJumpBar.css'; + +const ITEM_HEIGHT = parseInt(dimensions.jumpBarItemHeight); + +class PageJumpBar extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + height: 0, + visibleItems: props.items + }; + } + + componentDidMount() { + this.computeVisibleItems(); + } + + shouldComponentUpdate(nextProps, nextState) { + return ( + nextProps.items !== this.props.items || + nextState.height !== this.state.height || + nextState.visibleItems !== this.state.visibleItems + ); + } + + componentDidUpdate(prevProps, prevState) { + if ( + prevProps.items !== this.props.items || + prevState.height !== this.state.height + ) { + this.computeVisibleItems(); + } + } + + // + // Control + + computeVisibleItems() { + const { + items, + minimumItems + } = this.props; + + const height = this.state.height; + const maximumItems = Math.floor(height / ITEM_HEIGHT); + const diff = items.length - maximumItems; + + if (diff < 0) { + this.setState({ visibleItems: items }); + return; + } + + if (items.length < minimumItems) { + this.setState({ visibleItems: items }); + return; + } + + const removeDiff = Math.ceil(items.length / maximumItems); + + const visibleItems = _.reduce(items, (acc, item, index) => { + if (index % removeDiff === 0) { + acc.push(item); + } + + return acc; + }, []); + + this.setState({ visibleItems }); + } + + // + // Listeners + + onMeasure = ({ height }) => { + this.setState({ height }); + } + + // + // Render + + render() { + const { + minimumItems, + onItemPress + } = this.props; + + const { + visibleItems + } = this.state; + + if (!visibleItems.length || visibleItems.length < minimumItems) { + return null; + } + + return ( +
+ +
+ { + visibleItems.map((item) => { + return ( + + ); + }) + } +
+
+
+ ); + } +} + +PageJumpBar.propTypes = { + items: PropTypes.arrayOf(PropTypes.string).isRequired, + minimumItems: PropTypes.number.isRequired, + onItemPress: PropTypes.func.isRequired +}; + +PageJumpBar.defaultProps = { + minimumItems: 5 +}; + +export default PageJumpBar; diff --git a/frontend/src/Components/Page/PageJumpBarItem.css b/frontend/src/Components/Page/PageJumpBarItem.css new file mode 100644 index 000000000..e829dd31a --- /dev/null +++ b/frontend/src/Components/Page/PageJumpBarItem.css @@ -0,0 +1,14 @@ +.jumpBarItem { + flex: 1 0 $jumpBarItemHeight; + border-bottom: 1px solid $borderColor; + text-align: center; + font-weight: bold; + + &:hover { + color: #777; + } + + &:last-child { + border: none; + } +} diff --git a/frontend/src/Components/Page/PageJumpBarItem.js b/frontend/src/Components/Page/PageJumpBarItem.js new file mode 100644 index 000000000..aeffe4ddd --- /dev/null +++ b/frontend/src/Components/Page/PageJumpBarItem.js @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import styles from './PageJumpBarItem.css'; + +class PageJumpBarItem extends Component { + + // + // Listeners + + onPress = () => { + const { + label, + onItemPress + } = this.props; + + onItemPress(label); + } + + // + // Render + + render() { + return ( + + {this.props.label.toUpperCase()} + + ); + } +} + +PageJumpBarItem.propTypes = { + label: PropTypes.string.isRequired, + onItemPress: PropTypes.func.isRequired +}; + +export default PageJumpBarItem; diff --git a/frontend/src/Components/Page/PageSectionContent.js b/frontend/src/Components/Page/PageSectionContent.js new file mode 100644 index 000000000..774b88669 --- /dev/null +++ b/frontend/src/Components/Page/PageSectionContent.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; + +function PageSectionContent(props) { + const { + isFetching, + isPopulated, + error, + errorMessage, + children + } = props; + + if (isFetching) { + return ( + + ); + } else if (!isFetching && !!error) { + return ( +
{errorMessage}
+ ); + } else if (isPopulated && !error) { + return ( +
{children}
+ ); + } + + return null; +} + +PageSectionContent.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + errorMessage: PropTypes.string.isRequired, + children: PropTypes.node.isRequired +}; + +export default PageSectionContent; diff --git a/frontend/src/Components/Page/Sidebar/Messages/Message.css b/frontend/src/Components/Page/Sidebar/Messages/Message.css new file mode 100644 index 000000000..7d53adb69 --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/Messages/Message.css @@ -0,0 +1,42 @@ +.message { + display: flex; + border-left: 3px solid $infoColor; +} + +.iconContainer, +.text { + display: flex; + justify-content: center; + flex-direction: column; + padding: 2px 0; + color: $sidebarColor; +} + +.iconContainer { + flex: 0 0 25px; + margin-left: 24px; + padding: 10px 0; +} + +.text { + margin-right: 24px; + font-size: 13px; +} + +/* Types */ + +.error { + border-left-color: $dangerColor; +} + +.info { + border-left-color: $infoColor; +} + +.success { + border-left-color: $successColor; +} + +.warning { + border-left-color: $warningColor; +} diff --git a/frontend/src/Components/Page/Sidebar/Messages/Message.js b/frontend/src/Components/Page/Sidebar/Messages/Message.js new file mode 100644 index 000000000..bb7a027fa --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/Messages/Message.js @@ -0,0 +1,70 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import styles from './Message.css'; + +function getIconName(name) { + switch (name) { + case 'ApplicationUpdate': + return icons.RESTART; + case 'Backup': + return icons.BACKUP; + case 'CheckHealth': + return icons.HEALTH; + case 'AlbumSearch': + return icons.SEARCH; + case 'Housekeeping': + return icons.HOUSEKEEPING; + case 'RefreshArtist': + return icons.REFRESH; + case 'RssSync': + return icons.RSS; + case 'SeasonSearch': + return icons.SEARCH; + case 'ArtistSearch': + return icons.SEARCH; + case 'UpdateSceneMapping': + return icons.REFRESH; + default: + return icons.SPINNER; + } +} + +function Message(props) { + const { + name, + message, + type + } = props; + + return ( +
+
+ +
+ +
+ {message} +
+
+ ); +} + +Message.propTypes = { + name: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, + type: PropTypes.string.isRequired +}; + +export default Message; diff --git a/frontend/src/Components/Page/Sidebar/Messages/MessageConnector.js b/frontend/src/Components/Page/Sidebar/Messages/MessageConnector.js new file mode 100644 index 000000000..06c545c27 --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/Messages/MessageConnector.js @@ -0,0 +1,67 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { hideMessage } from 'Store/Actions/appActions'; +import Message from './Message'; + +const mapDispatchToProps = { + hideMessage +}; + +class MessageConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._hideTimeoutId = null; + this.scheduleHideMessage(props.hideAfter); + } + + componentDidUpdate() { + this.scheduleHideMessage(this.props.hideAfter); + } + + // + // Control + + scheduleHideMessage = (hideAfter) => { + if (this._hideTimeoutId) { + clearTimeout(this._hideTimeoutId); + } + + if (hideAfter) { + this._hideTimeoutId = setTimeout(this.hideMessage, hideAfter * 1000); + } + } + + hideMessage = () => { + this.props.hideMessage({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +MessageConnector.propTypes = { + id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + hideAfter: PropTypes.number.isRequired, + hideMessage: PropTypes.func.isRequired +}; + +MessageConnector.defaultProps = { + // Hide messages after 60 seconds if there is no activity + // hideAfter: 60 +}; + +export default connect(undefined, mapDispatchToProps)(MessageConnector); diff --git a/frontend/src/Components/Page/Sidebar/Messages/Messages.css b/frontend/src/Components/Page/Sidebar/Messages/Messages.css new file mode 100644 index 000000000..ef01ad02c --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/Messages/Messages.css @@ -0,0 +1,11 @@ +.messages { + margin-top: auto; + margin-bottom: 20px; + padding-top: 20px; +} + +@media only screen and (max-width: $breakpointSmall) { + .messages { + margin-bottom: 0; + } +} diff --git a/frontend/src/Components/Page/Sidebar/Messages/Messages.js b/frontend/src/Components/Page/Sidebar/Messages/Messages.js new file mode 100644 index 000000000..ec8876f6e --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/Messages/Messages.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import MessageConnector from './MessageConnector'; +import styles from './Messages.css'; + +function Messages({ messages }) { + return ( +
+ { + messages.map((message) => { + return ( + + ); + }) + } +
+ ); +} + +Messages.propTypes = { + messages: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default Messages; diff --git a/frontend/src/Components/Page/Sidebar/Messages/MessagesConnector.js b/frontend/src/Components/Page/Sidebar/Messages/MessagesConnector.js new file mode 100644 index 000000000..5d20d9194 --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/Messages/MessagesConnector.js @@ -0,0 +1,16 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import Messages from './Messages'; + +function createMapStateToProps() { + return createSelector( + (state) => state.app.messages.items, + (messages) => { + return { + messages: messages.slice().reverse() + }; + } + ); +} + +export default connect(createMapStateToProps)(Messages); diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.css b/frontend/src/Components/Page/Sidebar/PageSidebar.css new file mode 100644 index 000000000..3f2abeee7 --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/PageSidebar.css @@ -0,0 +1,33 @@ +.sidebarContainer { + flex: 0 0 $sidebarWidth; + overflow: hidden; + width: $sidebarWidth; + background-color: $sidebarBackgroundColor; + transition: transform 300ms ease-in-out; + transform: translateX(0); +} + +.sidebar { + display: flex; + flex-direction: column; + overflow: hidden; + background-color: $sidebarBackgroundColor; + color: $white; +} + +@media only screen and (max-width: $breakpointSmall) { + .sidebarContainer { + position: fixed; + top: 0; + z-index: 2; + height: 100vh; + } + + .sidebar { + position: fixed; + z-index: 2; + overflow-y: auto; + width: 100%; + height: 100%; + } +} diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.js b/frontend/src/Components/Page/Sidebar/PageSidebar.js new file mode 100644 index 000000000..6ea0c3086 --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/PageSidebar.js @@ -0,0 +1,533 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import locationShape from 'Helpers/Props/Shapes/locationShape'; +import dimensions from 'Styles/Variables/dimensions'; +import OverlayScroller from 'Components/Scroller/OverlayScroller'; +import Scroller from 'Components/Scroller/Scroller'; +import QueueStatusConnector from 'Activity/Queue/Status/QueueStatusConnector'; +import HealthStatusConnector from 'System/Status/Health/HealthStatusConnector'; +import MessagesConnector from './Messages/MessagesConnector'; +import PageSidebarItem from './PageSidebarItem'; +import styles from './PageSidebar.css'; + +const HEADER_HEIGHT = parseInt(dimensions.headerHeight); +const SIDEBAR_WIDTH = parseInt(dimensions.sidebarWidth); + +const links = [ + { + iconName: icons.ARTIST_CONTINUING, + title: 'Library', + to: '/', + alias: '/artist', + children: [ + { + title: 'Add New', + to: '/add/new' + }, + { + title: 'Import', + to: '/add/import' + }, + { + title: 'Mass Editor', + to: '/artisteditor' + }, + { + title: 'Album Studio', + to: '/albumstudio' + }, + { + title: 'Unmapped Files', + to: '/unmapped' + } + ] + }, + + { + iconName: icons.CALENDAR, + title: 'Calendar', + to: '/calendar' + }, + + { + iconName: icons.ACTIVITY, + title: 'Activity', + to: '/activity/queue', + children: [ + { + title: 'Queue', + to: '/activity/queue', + statusComponent: QueueStatusConnector + }, + { + title: 'History', + to: '/activity/history' + }, + { + title: 'Blacklist', + to: '/activity/blacklist' + } + ] + }, + + { + iconName: icons.WARNING, + title: 'Wanted', + to: '/wanted/missing', + children: [ + { + title: 'Missing', + to: '/wanted/missing' + }, + { + title: 'Cutoff Unmet', + to: '/wanted/cutoffunmet' + } + ] + }, + + { + iconName: icons.SETTINGS, + title: 'Settings', + to: '/settings', + children: [ + { + title: 'Media Management', + to: '/settings/mediamanagement' + }, + { + title: 'Profiles', + to: '/settings/profiles' + }, + { + title: 'Quality', + to: '/settings/quality' + }, + { + title: 'Indexers', + to: '/settings/indexers' + }, + { + title: 'Download Clients', + to: '/settings/downloadclients' + }, + { + title: 'Import Lists', + to: '/settings/importlists' + }, + { + title: 'Connect', + to: '/settings/connect' + }, + { + title: 'Metadata', + to: '/settings/metadata' + }, + { + title: 'Tags', + to: '/settings/tags' + }, + { + title: 'General', + to: '/settings/general' + }, + { + title: 'UI', + to: '/settings/ui' + } + ] + }, + + { + iconName: icons.SYSTEM, + title: 'System', + to: '/system/status', + children: [ + { + title: 'Status', + to: '/system/status', + statusComponent: HealthStatusConnector + }, + { + title: 'Tasks', + to: '/system/tasks' + }, + { + title: 'Backup', + to: '/system/backup' + }, + { + title: 'Updates', + to: '/system/updates' + }, + { + title: 'Events', + to: '/system/events' + }, + { + title: 'Log Files', + to: '/system/logs/files' + } + ] + } +]; + +function getActiveParent(pathname) { + let activeParent = links[0].to; + + links.forEach((link) => { + if (link.to && link.to === pathname) { + activeParent = link.to; + + return false; + } + + const children = link.children; + + if (children) { + children.forEach((childLink) => { + if (pathname.startsWith(childLink.to)) { + activeParent = link.to; + + return false; + } + }); + } + + if ( + (link.to !== '/' && pathname.startsWith(link.to)) || + (link.alias && pathname.startsWith(link.alias)) + ) { + activeParent = link.to; + + return false; + } + }); + + return activeParent; +} + +function hasActiveChildLink(link, pathname) { + const children = link.children; + + if (!children || !children.length) { + return false; + } + + return _.some(children, (child) => { + return child.to === pathname; + }); +} + +function getPositioning() { + const windowScroll = window.scrollY == null ? document.documentElement.scrollTop : window.scrollY; + const top = Math.max(HEADER_HEIGHT - windowScroll, 0); + const height = window.innerHeight - top; + + return { + top: `${top}px`, + height: `${height}px` + }; +} + +class PageSidebar extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._touchStartX = null; + this._touchStartY = null; + this._sidebarRef = null; + + this.state = { + top: dimensions.headerHeight, + height: `${window.innerHeight - HEADER_HEIGHT}px`, + transition: null, + transform: props.isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1 + }; + } + + componentDidMount() { + if (this.props.isSmallScreen) { + window.addEventListener('click', this.onWindowClick, { capture: true }); + window.addEventListener('scroll', this.onWindowScroll); + window.addEventListener('touchstart', this.onTouchStart); + window.addEventListener('touchmove', this.onTouchMove); + window.addEventListener('touchend', this.onTouchEnd); + window.addEventListener('touchcancel', this.onTouchCancel); + } + } + + componentDidUpdate(prevProps) { + const { + isSidebarVisible + } = this.props; + + const transform = this.state.transform; + + if (prevProps.isSidebarVisible !== isSidebarVisible) { + this._setSidebarTransform(isSidebarVisible); + } else if (transform === 0 && !isSidebarVisible) { + this.props.onSidebarVisibleChange(true); + } else if (transform === -SIDEBAR_WIDTH && isSidebarVisible) { + this.props.onSidebarVisibleChange(false); + } + } + + componentWillUnmount() { + if (this.props.isSmallScreen) { + window.removeEventListener('click', this.onWindowClick, { capture: true }); + window.removeEventListener('scroll', this.onWindowScroll); + window.removeEventListener('touchstart', this.onTouchStart); + window.removeEventListener('touchmove', this.onTouchMove); + window.removeEventListener('touchend', this.onTouchEnd); + window.removeEventListener('touchcancel', this.onTouchCancel); + } + } + + // + // Control + + _setSidebarRef = (ref) => { + this._sidebarRef = ref; + } + + _setSidebarTransform(isSidebarVisible, transition, callback) { + this.setState({ + transition, + transform: isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1 + }, callback); + } + + // + // Listeners + + onWindowClick = (event) => { + const sidebar = ReactDOM.findDOMNode(this._sidebarRef); + const toggleButton = document.getElementById('sidebar-toggle-button'); + + if (!sidebar) { + return; + } + + if ( + !sidebar.contains(event.target) && + !toggleButton.contains(event.target) && + this.props.isSidebarVisible + ) { + event.preventDefault(); + event.stopPropagation(); + this.props.onSidebarVisibleChange(false); + } + } + + onWindowScroll = () => { + this.setState(getPositioning()); + } + + onTouchStart = (event) => { + const touches = event.touches; + const touchStartX = touches[0].pageX; + const touchStartY = touches[0].pageY; + const isSidebarVisible = this.props.isSidebarVisible; + + if (touches.length !== 1) { + return; + } + + if (isSidebarVisible && (touchStartX > 210 || touchStartX < 180)) { + return; + } else if (!isSidebarVisible && touchStartX > 40) { + return; + } + + this._touchStartX = touchStartX; + this._touchStartY = touchStartY; + } + + onTouchMove = (event) => { + const touches = event.touches; + const currentTouchX = touches[0].pageX; + // const currentTouchY = touches[0].pageY; + // const isSidebarVisible = this.props.isSidebarVisible; + + if (!this._touchStartX) { + return; + } + + // This is a bit funky when trying to close and you scroll + // vertical too much by mistake, commenting out for now. + // TODO: Evaluate if this should be nuked + + // if (Math.abs(this._touchStartY - currentTouchY) > 40) { + // const transform = isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1; + + // this.setState({ + // transition: 'none', + // transform + // }); + + // return; + // } + + if (Math.abs(this._touchStartX - currentTouchX) < 40) { + return; + } + + const transform = Math.min(currentTouchX - SIDEBAR_WIDTH, 0); + + this.setState({ + transition: 'none', + transform + }); + } + + onTouchEnd = (event) => { + const touches = event.changedTouches; + const currentTouch = touches[0].pageX; + + if (!this._touchStartX) { + return; + } + + if (currentTouch > this._touchStartX && currentTouch > 50) { + this._setSidebarTransform(true, 'none'); + } else if (currentTouch < this._touchStartX && currentTouch < 80) { + this._setSidebarTransform(false, 'transform 50ms ease-in-out'); + } else { + this._setSidebarTransform(this.props.isSidebarVisible); + } + + this._touchStartX = null; + this._touchStartY = null; + } + + onTouchCancel = (event) => { + this._touchStartX = null; + this._touchStartY = null; + } + + onItemPress = () => { + this.props.onSidebarVisibleChange(false); + } + + // + // Render + + render() { + const { + location, + isSmallScreen + } = this.props; + + const { + top, + height, + transition, + transform + } = this.state; + + const urlBase = window.Lidarr.urlBase; + const pathname = urlBase ? location.pathname.substr(urlBase.length) || '/' : location.pathname; + const activeParent = getActiveParent(pathname); + + let containerStyle = {}; + let sidebarStyle = {}; + + if (isSmallScreen) { + containerStyle = { + transition, + transform: `translateX(${transform}px)` + }; + + sidebarStyle = { + top, + height + }; + } + + const ScrollerComponent = isSmallScreen ? Scroller : OverlayScroller; + + return ( +
+ +
+ { + links.map((link) => { + const childWithStatusComponent = _.find(link.children, (child) => { + return !!child.statusComponent; + }); + + const childStatusComponent = childWithStatusComponent ? + childWithStatusComponent.statusComponent : + null; + + const isActiveParent = activeParent === link.to; + const hasActiveChild = hasActiveChildLink(link, pathname); + + return ( + + { + link.children && link.to === activeParent && + link.children.map((child) => { + return ( + + ); + }) + } + + ); + }) + } +
+ + +
+
+ ); + } +} + +PageSidebar.propTypes = { + location: locationShape.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + isSidebarVisible: PropTypes.bool.isRequired, + onSidebarVisibleChange: PropTypes.func.isRequired +}; + +export default PageSidebar; diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarItem.css b/frontend/src/Components/Page/Sidebar/PageSidebarItem.css new file mode 100644 index 000000000..dac40927f --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/PageSidebarItem.css @@ -0,0 +1,50 @@ +.item { + border-left: 3px solid transparent; + color: $sidebarColor; + transition: border-left 0.3s ease-in-out; +} + +.isActiveItem { + border-left: 3px solid $themeBlue; +} + +.link { + display: block; + padding: 12px 24px; + color: $sidebarColor; + + &:hover, + &:focus { + color: $themeBlue; + text-decoration: none; + } +} + +.childLink { + composes: link; + + padding: 10px 24px; +} + +.isActiveLink { + color: $themeBlue; +} + +.isActiveParentLink { + background-color: $sidebarActiveBackgroundColor; +} + +.iconContainer { + display: inline-block; + margin-right: 7px; + width: 18px; + text-align: center; +} + +.noIcon { + margin-left: 25px; +} + +.status { + float: right; +} diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarItem.js b/frontend/src/Components/Page/Sidebar/PageSidebarItem.js new file mode 100644 index 000000000..0bcc28cde --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/PageSidebarItem.js @@ -0,0 +1,106 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { map } from 'Helpers/elementChildren'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import styles from './PageSidebarItem.css'; + +class PageSidebarItem extends Component { + + // + // Listeners + + onPress = () => { + const { + isChildItem, + isParentItem, + onPress + } = this.props; + + if (isChildItem || !isParentItem) { + onPress(); + } + } + + // + // Render + + render() { + const { + iconName, + title, + to, + isActive, + isActiveParent, + isChildItem, + statusComponent: StatusComponent, + children + } = this.props; + + return ( +
+ + { + !!iconName && + + + + } + + + {title} + + + { + !!StatusComponent && + + + + } + + + { + children && + map(children, (child) => { + return React.cloneElement(child, { isChildItem: true }); + }) + } +
+ ); + } +} + +PageSidebarItem.propTypes = { + iconName: PropTypes.object, + title: PropTypes.string.isRequired, + to: PropTypes.string.isRequired, + isActive: PropTypes.bool, + isActiveParent: PropTypes.bool, + isParentItem: PropTypes.bool.isRequired, + isChildItem: PropTypes.bool.isRequired, + statusComponent: PropTypes.elementType, + children: PropTypes.node, + onPress: PropTypes.func +}; + +PageSidebarItem.defaultProps = { + isChildItem: false +}; + +export default PageSidebarItem; diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarStatus.css b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.css new file mode 100644 index 000000000..2d914be43 --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.css @@ -0,0 +1,3 @@ +.status { + composes: label from '~Components/Label.css'; +} diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarStatus.js b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.js new file mode 100644 index 000000000..c1ea615ed --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.js @@ -0,0 +1,35 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds, sizes } from 'Helpers/Props'; +import Label from 'Components/Label'; + +function PageSidebarStatus({ count, errors, warnings }) { + if (!count) { + return null; + } + + let kind = kinds.INFO; + + if (errors) { + kind = kinds.DANGER; + } else if (warnings) { + kind = kinds.WARNING; + } + + return ( + + ); +} + +PageSidebarStatus.propTypes = { + count: PropTypes.number, + errors: PropTypes.bool, + warnings: PropTypes.bool +}; + +export default PageSidebarStatus; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbar.css b/frontend/src/Components/Page/Toolbar/PageToolbar.css new file mode 100644 index 000000000..e040bc884 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbar.css @@ -0,0 +1,16 @@ +.toolbar { + display: flex; + justify-content: space-between; + flex: 0 0 auto; + padding: 0 20px; + height: $toolbarHeight; + background-color: $toolbarBackgroundColor; + color: $toolbarColor; + line-height: 60px; +} + +@media only screen and (max-width: $breakpointSmall) { + .toolbar { + padding: 0 10px; + } +} diff --git a/frontend/src/Components/Page/Toolbar/PageToolbar.js b/frontend/src/Components/Page/Toolbar/PageToolbar.js new file mode 100644 index 000000000..728f1b0d9 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbar.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './PageToolbar.css'; + +class PageToolbar extends Component { + + // + // Render + + render() { + const { + className, + children + } = this.props; + + return ( +
+ {children} +
+ ); + } +} + +PageToolbar.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired +}; + +PageToolbar.defaultProps = { + className: styles.toolbar +}; + +export default PageToolbar; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarButton.css b/frontend/src/Components/Page/Toolbar/PageToolbarButton.css new file mode 100644 index 000000000..e729ed000 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarButton.css @@ -0,0 +1,33 @@ +.toolbarButton { + composes: link from '~Components/Link/Link.css'; + + padding-top: 4px; + width: $toolbarButtonWidth; + text-align: center; + + &:hover { + color: $toobarButtonHoverColor; + } + + &.isDisabled { + color: $disabledColor; + } +} + +.isDisabled { + color: $disabledColor; +} + +.labelContainer { + display: flex; + align-items: center; + justify-content: center; + height: 24px; +} + +.label { + padding: 0 3px; + color: $toolbarLabelColor; + font-size: $extraSmallFontSize; + line-height: calc($extraSmallFontSize + 1px); +} diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarButton.js b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js new file mode 100644 index 000000000..381046bf5 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js @@ -0,0 +1,57 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import styles from './PageToolbarButton.css'; + +function PageToolbarButton(props) { + const { + label, + iconName, + spinningName, + isDisabled, + isSpinning, + ...otherProps + } = props; + + return ( + + + +
+
+ {label} +
+
+ + ); +} + +PageToolbarButton.propTypes = { + label: PropTypes.string.isRequired, + iconName: PropTypes.object.isRequired, + spinningName: PropTypes.object, + isSpinning: PropTypes.bool, + isDisabled: PropTypes.bool +}; + +PageToolbarButton.defaultProps = { + spinningName: icons.SPINNER, + isDisabled: false, + isSpinning: false +}; + +export default PageToolbarButton; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSection.css b/frontend/src/Components/Page/Toolbar/PageToolbarSection.css new file mode 100644 index 000000000..110675b99 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarSection.css @@ -0,0 +1,40 @@ +.sectionContainer { + display: flex; + flex: 1 1 10%; + overflow: hidden; +} + +.section { + display: flex; + align-items: stretch; + flex-grow: 1; +} + +.left { + justify-content: flex-start; +} + +.center { + justify-content: center; +} + +.right { + justify-content: flex-end; +} + +.overflowMenuButton { + composes: menuButton from '~Components/Menu/ToolbarMenuButton.css'; +} + +.overflowMenuItemIcon { + margin-right: 8px; +} + +@media only screen and (max-width: $breakpointSmall) { + .overflowMenuButton { + &::after { + margin-left: 0; + content: '\25BE'; + } + } +} diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSection.js b/frontend/src/Components/Page/Toolbar/PageToolbarSection.js new file mode 100644 index 000000000..35ee586ec --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarSection.js @@ -0,0 +1,221 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { forEach } from 'Helpers/elementChildren'; +import { align, icons } from 'Helpers/Props'; +import dimensions from 'Styles/Variables/dimensions'; +import SpinnerIcon from 'Components/SpinnerIcon'; +import Measure from 'Components/Measure'; +import Menu from 'Components/Menu/Menu'; +import MenuContent from 'Components/Menu/MenuContent'; +import MenuItem from 'Components/Menu/MenuItem'; +import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton'; +import styles from './PageToolbarSection.css'; + +const BUTTON_WIDTH = parseInt(dimensions.toolbarButtonWidth); +const SEPARATOR_MARGIN = parseInt(dimensions.toolbarSeparatorMargin); +const SEPARATOR_WIDTH = 2 * SEPARATOR_MARGIN + 1; +const SEPARATOR_NAME = 'PageToolbarSeparator'; + +function calculateOverflowItems(children, isMeasured, width, collapseButtons) { + let buttonCount = 0; + let separatorCount = 0; + const validChildren = []; + + forEach(children, (child) => { + const name = child.type.name; + + if (name === SEPARATOR_NAME) { + separatorCount++; + } else { + buttonCount++; + } + + validChildren.push(child); + }); + + const buttonsWidth = buttonCount * BUTTON_WIDTH; + const separatorsWidth = separatorCount + SEPARATOR_WIDTH; + const totalWidth = buttonsWidth + separatorsWidth; + + // If the width of buttons and separators is less than + // the available width return all valid children. + + if ( + !isMeasured || + !collapseButtons || + totalWidth < width + ) { + return { + buttons: validChildren, + buttonCount, + overflowItems: [] + }; + } + + const maxButtons = Math.max(Math.floor((width - separatorsWidth) / BUTTON_WIDTH), 1); + const buttons = []; + const overflowItems = []; + let actualButtons = 0; + + // Return all buttons if only one is being pushed to the overflow menu. + if (buttonCount - 1 === maxButtons) { + return { + buttons: validChildren, + buttonCount, + overflowItems: [] + }; + } + + validChildren.forEach((child, index) => { + if (actualButtons < maxButtons) { + if (child.type.name !== SEPARATOR_NAME) { + buttons.push(child); + actualButtons++; + } + } else if (child.type.name !== SEPARATOR_NAME) { + overflowItems.push(child.props); + } + }); + + return { + buttons, + buttonCount, + overflowItems + }; +} + +class PageToolbarSection extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isMeasured: false, + width: 0, + buttons: [], + overflowItems: [] + }; + } + + // + // Listeners + + onMeasure = ({ width }) => { + this.setState({ + isMeasured: true, + width + }); + } + + // + // Render + + render() { + const { + children, + alignContent, + collapseButtons + } = this.props; + + const { + isMeasured, + width + } = this.state; + + const { + buttons, + buttonCount, + overflowItems + } = calculateOverflowItems(children, isMeasured, width, collapseButtons); + + return ( + +
+ { + isMeasured ? +
+ { + buttons.map((button) => { + return button; + }) + } + + { + !!overflowItems.length && + + + + + { + overflowItems.map((item) => { + const { + iconName, + spinningName, + label, + isDisabled, + isSpinning, + ...otherProps + } = item; + + return ( + + + {label} + + ); + }) + } + + + } +
: + null + } +
+
+ ); + } + +} + +PageToolbarSection.propTypes = { + children: PropTypes.node, + alignContent: PropTypes.oneOf([align.LEFT, align.CENTER, align.RIGHT]), + collapseButtons: PropTypes.bool.isRequired +}; + +PageToolbarSection.defaultProps = { + alignContent: align.LEFT, + collapseButtons: true +}; + +export default PageToolbarSection; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.css b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.css new file mode 100644 index 000000000..968673593 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.css @@ -0,0 +1,12 @@ +.separator { + margin: 10px $toolbarSeparatorMargin; + height: 40px; + border-right: 1px solid #e5e5e5; + opacity: 0.35; +} + +@media only screen and (max-width: $breakpointSmall) { + .separator { + margin: 10px 5px; + } +} diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.js b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.js new file mode 100644 index 000000000..754248f99 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.js @@ -0,0 +1,17 @@ +import React, { Component } from 'react'; +import styles from './PageToolbarSeparator.css'; + +class PageToolbarSeparator extends Component { + + // + // Render + + render() { + return ( +
+ ); + } + +} + +export default PageToolbarSeparator; diff --git a/frontend/src/Components/Portal.js b/frontend/src/Components/Portal.js new file mode 100644 index 000000000..2e5237093 --- /dev/null +++ b/frontend/src/Components/Portal.js @@ -0,0 +1,18 @@ +import PropTypes from 'prop-types'; +import ReactDOM from 'react-dom'; + +function Portal(props) { + const { children, target } = props; + return ReactDOM.createPortal(children, target); +} + +Portal.propTypes = { + children: PropTypes.node.isRequired, + target: PropTypes.object.isRequired +}; + +Portal.defaultProps = { + target: document.getElementById('portal-root') +}; + +export default Portal; diff --git a/frontend/src/Components/ProgressBar.css b/frontend/src/Components/ProgressBar.css new file mode 100644 index 000000000..777187eec --- /dev/null +++ b/frontend/src/Components/ProgressBar.css @@ -0,0 +1,101 @@ +.container { + position: relative; + overflow: hidden; + width: 100%; + border-radius: 4px; + background-color: #f5f5f5; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.progressBar { + position: relative; + z-index: 1; + float: left; + width: 0; + height: 100%; + box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15); + color: $white; + transition: width 0.6s ease; +} + +.frontTextContainer { + z-index: 1; + color: $white; +} + +.backTextContainer, +.frontTextContainer { + position: absolute; + overflow: hidden; + width: 0; + height: 100%; +} + +.backText, +.frontText { + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-size: 12px; + cursor: default; +} + +.primary { + background-color: $primaryColor; +} + +.danger { + background-color: $dangerColor; + + &:global(.colorImpaired) { + background: repeating-linear-gradient(90deg, color($dangerColor shade(5%)), color($dangerColor shade(5%)) 5px, color($dangerColor shade(15%)) 5px, color($dangerColor shade(15%)) 10px); + } +} + +.success { + background-color: $successColor; +} + +.purple { + background-color: $purple; +} + +.warning { + background-color: $warningColor; + + &:global(.colorImpaired) { + background: repeating-linear-gradient(45deg, $warningColor, $warningColor 5px, color($warningColor tint(15%)) 5px, color($warningColor tint(15%)) 10px); + } +} + +.info { + background-color: $infoColor; +} + +.small { + height: $progressBarSmallHeight; + + .backText, + .frontText { + height: $progressBarSmallHeight; + } +} + +.medium { + height: $progressBarMediumHeight; + + .backText, + .frontText { + height: $progressBarMediumHeight; + } +} + +.large { + height: $progressBarLargeHeight; + + .backText, + .frontText { + height: $progressBarLargeHeight; + } +} diff --git a/frontend/src/Components/ProgressBar.js b/frontend/src/Components/ProgressBar.js new file mode 100644 index 000000000..3c16792fa --- /dev/null +++ b/frontend/src/Components/ProgressBar.js @@ -0,0 +1,111 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { kinds, sizes } from 'Helpers/Props'; +import { ColorImpairedConsumer } from 'App/ColorImpairedContext'; +import styles from './ProgressBar.css'; + +function ProgressBar(props) { + const { + className, + containerClassName, + title, + progress, + precision, + showText, + text, + kind, + size, + width + } = props; + + const progressPercent = `${progress.toFixed(precision)}%`; + const progressText = text || progressPercent; + const actualWidth = width ? `${width}px` : '100%'; + + return ( + + {(enableColorImpairedMode) => { + return ( +
+ { + showText && width ? +
+
+
+ {progressText} +
+
+
: + null + } + +
+ + { + showText ? +
+
+
+ {progressText} +
+
+
: + null + } +
+ ); + }} + + ); +} + +ProgressBar.propTypes = { + className: PropTypes.string, + containerClassName: PropTypes.string, + title: PropTypes.string, + progress: PropTypes.number.isRequired, + precision: PropTypes.number.isRequired, + showText: PropTypes.bool.isRequired, + text: PropTypes.string, + kind: PropTypes.oneOf(kinds.all).isRequired, + size: PropTypes.oneOf(sizes.all).isRequired, + width: PropTypes.number +}; + +ProgressBar.defaultProps = { + className: styles.progressBar, + containerClassName: styles.container, + precision: 1, + showText: false, + kind: kinds.PRIMARY, + size: sizes.MEDIUM +}; + +export default ProgressBar; diff --git a/frontend/src/Components/Router/Switch.js b/frontend/src/Components/Router/Switch.js new file mode 100644 index 000000000..0c0004a50 --- /dev/null +++ b/frontend/src/Components/Router/Switch.js @@ -0,0 +1,44 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { Switch as RouterSwitch } from 'react-router-dom'; +import getPathWithUrlBase from 'Utilities/getPathWithUrlBase'; +import { map } from 'Helpers/elementChildren'; + +class Switch extends Component { + + // + // Render + + render() { + const { + children + } = this.props; + + return ( + + { + map(children, (child) => { + const { + path: childPath, + addUrlBase = true + } = child.props; + + if (!childPath) { + return child; + } + + const path = addUrlBase ? getPathWithUrlBase(childPath) : childPath; + + return React.cloneElement(child, { path }); + }) + } + + ); + } +} + +Switch.propTypes = { + children: PropTypes.node.isRequired +}; + +export default Switch; diff --git a/frontend/src/Components/Scroller/OverlayScroller.css b/frontend/src/Components/Scroller/OverlayScroller.css new file mode 100644 index 000000000..139b1e779 --- /dev/null +++ b/frontend/src/Components/Scroller/OverlayScroller.css @@ -0,0 +1,15 @@ +.scroller { + /* Placeholder */ +} + +.thumb { + min-height: 100px; + border: 1px solid transparent; + border-radius: 5px; + background-color: $scrollbarBackgroundColor; + background-clip: padding-box; + + &:hover { + background-color: $scrollbarHoverBackgroundColor; + } +} diff --git a/frontend/src/Components/Scroller/OverlayScroller.js b/frontend/src/Components/Scroller/OverlayScroller.js new file mode 100644 index 000000000..e2a269bdc --- /dev/null +++ b/frontend/src/Components/Scroller/OverlayScroller.js @@ -0,0 +1,171 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { Scrollbars } from 'react-custom-scrollbars'; +import { scrollDirections } from 'Helpers/Props'; +import styles from './OverlayScroller.css'; + +const SCROLLBAR_SIZE = 10; + +class OverlayScroller extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._scroller = null; + this._isScrolling = false; + } + + componentDidUpdate(prevProps) { + const { + scrollTop + } = this.props; + + if ( + !this._isScrolling && + scrollTop != null && + scrollTop !== prevProps.scrollTop + ) { + this._scroller.scrollTop(scrollTop); + } + } + + // + // Control + + _setScrollRef = (ref) => { + this._scroller = ref; + } + + _renderThumb = (props) => { + return ( +
+ ); + } + + _renderTrackHorizontal = ({ style, props }) => { + const finalStyle = { + ...style, + right: 2, + bottom: 2, + left: 2, + borderRadius: 3, + height: SCROLLBAR_SIZE + }; + + return ( +
+ ); + } + + _renderTrackVertical = ({ style, props }) => { + const finalStyle = { + ...style, + right: 2, + bottom: 2, + top: 2, + borderRadius: 3, + width: SCROLLBAR_SIZE + }; + + return ( +
+ ); + } + + _renderView = (props) => { + return ( +
+ ); + } + + // + // Listers + + onScrollStart = () => { + this._isScrolling = true; + } + + onScrollStop = () => { + this._isScrolling = false; + } + + onScroll = (event) => { + const { + scrollTop, + scrollLeft + } = event.currentTarget; + + this._isScrolling = true; + const onScroll = this.props.onScroll; + + if (onScroll) { + onScroll({ scrollTop, scrollLeft }); + } + } + + // + // Render + + render() { + const { + autoHide, + autoScroll, + children + } = this.props; + + return ( + + {children} + + ); + } + +} + +OverlayScroller.propTypes = { + className: PropTypes.string, + trackClassName: PropTypes.string, + scrollTop: PropTypes.number, + scrollDirection: PropTypes.oneOf([scrollDirections.NONE, scrollDirections.HORIZONTAL, scrollDirections.VERTICAL]).isRequired, + autoHide: PropTypes.bool.isRequired, + autoScroll: PropTypes.bool.isRequired, + children: PropTypes.node, + onScroll: PropTypes.func +}; + +OverlayScroller.defaultProps = { + className: styles.scroller, + trackClassName: styles.thumb, + scrollDirection: scrollDirections.VERTICAL, + autoHide: false, + autoScroll: true +}; + +export default OverlayScroller; diff --git a/frontend/src/Components/Scroller/Scroller.css b/frontend/src/Components/Scroller/Scroller.css new file mode 100644 index 000000000..4dbd395cd --- /dev/null +++ b/frontend/src/Components/Scroller/Scroller.css @@ -0,0 +1,37 @@ +.scroller { + @add-mixin scrollbar; + @add-mixin scrollbarTrack; + @add-mixin scrollbarThumb; + -webkit-overflow-scrolling: touch; +} + +.none { + overflow-x: hidden; + overflow-y: hidden; +} + +.vertical { + overflow-x: hidden; + overflow-y: scroll; + + &.autoScroll { + overflow-y: auto; + } +} + +.horizontal { + overflow-x: scroll; + overflow-y: hidden; + + &.autoScroll { + overflow-x: auto; + } +} + +.both { + overflow: scroll; + + &.autoScroll { + overflow: auto; + } +} diff --git a/frontend/src/Components/Scroller/Scroller.js b/frontend/src/Components/Scroller/Scroller.js new file mode 100644 index 000000000..f4ce7781f --- /dev/null +++ b/frontend/src/Components/Scroller/Scroller.js @@ -0,0 +1,81 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { scrollDirections } from 'Helpers/Props'; +import styles from './Scroller.css'; + +class Scroller extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._scroller = null; + } + + componentDidMount() { + const { + scrollTop + } = this.props; + + if (this.props.scrollTop != null) { + this._scroller.scrollTop = scrollTop; + } + } + + // + // Control + + _setScrollerRef = (ref) => { + this._scroller = ref; + } + + // + // Render + + render() { + const { + className, + scrollDirection, + autoScroll, + children, + scrollTop, + onScroll, + ...otherProps + } = this.props; + + return ( +
+ {children} +
+ ); + } + +} + +Scroller.propTypes = { + className: PropTypes.string, + scrollDirection: PropTypes.oneOf(scrollDirections.all).isRequired, + autoScroll: PropTypes.bool.isRequired, + scrollTop: PropTypes.number, + children: PropTypes.node, + onScroll: PropTypes.func +}; + +Scroller.defaultProps = { + scrollDirection: scrollDirections.VERTICAL, + autoScroll: true +}; + +export default Scroller; diff --git a/frontend/src/Components/SignalRConnector.js b/frontend/src/Components/SignalRConnector.js new file mode 100644 index 000000000..86930b489 --- /dev/null +++ b/frontend/src/Components/SignalRConnector.js @@ -0,0 +1,398 @@ +import $ from 'jquery'; +import 'signalr'; +import PropTypes from 'prop-types'; +import { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { repopulatePage } from 'Utilities/pagePopulator'; +import titleCase from 'Utilities/String/titleCase'; +import { fetchCommands, updateCommand, finishCommand } from 'Store/Actions/commandActions'; +import { setAppValue, setVersion } from 'Store/Actions/appActions'; +import { update, updateItem, removeItem } from 'Store/Actions/baseActions'; +import { fetchArtist } from 'Store/Actions/artistActions'; +import { fetchHealth } from 'Store/Actions/systemActions'; +import { fetchQueue, fetchQueueDetails } from 'Store/Actions/queueActions'; +import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import { fetchTags, fetchTagDetails } from 'Store/Actions/tagActions'; + +function getState(status) { + switch (status) { + case 0: + return 'connecting'; + case 1: + return 'connected'; + case 2: + return 'reconnecting'; + case 4: + return 'disconnected'; + default: + throw new Error(`invalid status ${status}`); + } +} + +function isAppDisconnected(disconnectedTime) { + if (!disconnectedTime) { + return false; + } + + return Math.floor(new Date().getTime() / 1000) - disconnectedTime > 180; +} + +function getHandlerName(name) { + name = titleCase(name); + name = name.replace('/', ''); + + return `handle${name}`; +} + +function createMapStateToProps() { + return createSelector( + (state) => state.app.isReconnecting, + (state) => state.app.isDisconnected, + (state) => state.queue.paged.isPopulated, + (isReconnecting, isDisconnected, isQueuePopulated) => { + return { + isReconnecting, + isDisconnected, + isQueuePopulated + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchCommands: fetchCommands, + dispatchUpdateCommand: updateCommand, + dispatchFinishCommand: finishCommand, + dispatchSetAppValue: setAppValue, + dispatchSetVersion: setVersion, + dispatchUpdate: update, + dispatchUpdateItem: updateItem, + dispatchRemoveItem: removeItem, + dispatchFetchArtist: fetchArtist, + dispatchFetchHealth: fetchHealth, + dispatchFetchQueue: fetchQueue, + dispatchFetchQueueDetails: fetchQueueDetails, + dispatchFetchRootFolders: fetchRootFolders, + dispatchFetchTags: fetchTags, + dispatchFetchTagDetails: fetchTagDetails +}; + +class SignalRConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.signalRconnectionOptions = { transport: ['webSockets', 'serverSentEvents', 'longPolling'] }; + this.signalRconnection = null; + this.retryInterval = 1; + this.retryTimeoutId = null; + this.disconnectedTime = null; + } + + componentDidMount() { + console.log('Starting signalR'); + + const url = `${window.Lidarr.urlBase}/signalr`; + + this.signalRconnection = $.connection(url, { apiKey: window.Lidarr.apiKey }); + + this.signalRconnection.stateChanged(this.onStateChanged); + this.signalRconnection.received(this.onReceived); + this.signalRconnection.reconnecting(this.onReconnecting); + this.signalRconnection.disconnected(this.onDisconnected); + + this.signalRconnection.start(this.signalRconnectionOptions); + } + + componentWillUnmount() { + if (this.retryTimeoutId) { + this.retryTimeoutId = clearTimeout(this.retryTimeoutId); + } + + this.signalRconnection.stop(); + this.signalRconnection = null; + } + + // + // Control + + retryConnection = () => { + if (isAppDisconnected(this.disconnectedTime)) { + this.setState({ + isDisconnected: true + }); + } + + this.retryTimeoutId = setTimeout(() => { + if (!this.signalRconnection) { + console.error('signalR: Connection was disposed'); + return; + } + + this.signalRconnection.start(this.signalRconnectionOptions); + this.retryInterval = Math.min(this.retryInterval + 1, 10); + }, this.retryInterval * 1000); + } + + handleMessage = (message) => { + const { + name, + body + } = message; + + const handler = this[getHandlerName(name)]; + + if (handler) { + handler(body); + return; + } + + console.error(`signalR: Unable to find handler for ${name}`); + } + + handleCalendar = (body) => { + if (body.action === 'updated') { + this.props.dispatchUpdateItem({ + section: 'calendar', + updateOnly: true, + ...body.resource + }); + } + } + + handleCommand = (body) => { + if (body.action === 'sync') { + this.props.dispatchFetchCommands(); + return; + } + + const resource = body.resource; + const status = resource.status; + + // Both sucessful and failed commands need to be + // completed, otherwise they spin until they timeout. + + if (status === 'completed' || status === 'failed') { + this.props.dispatchFinishCommand(resource); + } else { + this.props.dispatchUpdateCommand(resource); + } + } + + handleAlbum = (body) => { + if (body.action === 'updated') { + this.props.dispatchUpdateItem({ + section: 'albums', + updateOnly: true, + ...body.resource + }); + } + } + + handleTrack = (body) => { + if (body.action === 'updated') { + this.props.dispatchUpdateItem({ + section: 'tracks', + updateOnly: true, + ...body.resource + }); + } + } + + handleTrackfile = (body) => { + const section = 'trackFiles'; + + if (body.action === 'updated') { + this.props.dispatchUpdateItem({ section, ...body.resource }); + } else if (body.action === 'deleted') { + this.props.dispatchRemoveItem({ section, id: body.resource.id }); + } + + // Repopulate the page to handle recently imported file + repopulatePage('trackFileUpdated'); + } + + handleHealth = () => { + this.props.dispatchFetchHealth(); + } + + handleArtist = (body) => { + const action = body.action; + const section = 'artist'; + + if (action === 'updated') { + this.props.dispatchUpdateItem({ section, ...body.resource }); + } else if (action === 'deleted') { + this.props.dispatchRemoveItem({ section, id: body.resource.id }); + } + } + + handleQueue = () => { + if (this.props.isQueuePopulated) { + this.props.dispatchFetchQueue(); + } + } + + handleQueueDetails = () => { + this.props.dispatchFetchQueueDetails(); + } + + handleQueueStatus = (body) => { + this.props.dispatchUpdate({ section: 'queue.status', data: body.resource }); + } + + handleVersion = (body) => { + const version = body.Version; + + this.props.dispatchSetVersion({ version }); + } + + handleWantedCutoff = (body) => { + if (body.action === 'updated') { + this.props.dispatchUpdateItem({ + section: 'cutoffUnmet', + updateOnly: true, + ...body.resource + }); + } + } + + handleWantedMissing = (body) => { + if (body.action === 'updated') { + this.props.dispatchUpdateItem({ + section: 'missing', + updateOnly: true, + ...body.resource + }); + } + } + + handleSystemTask = () => { + // No-op for now, we may want this later + } + + handleRootfolder = () => { + this.props.dispatchFetchRootFolders(); + } + + handleTag = (body) => { + if (body.action === 'sync') { + this.props.dispatchFetchTags(); + this.props.dispatchFetchTagDetails(); + return; + } + } + + // + // Listeners + + onStateChanged = (change) => { + const state = getState(change.newState); + console.log(`signalR: ${state}`); + + if (state === 'connected') { + // Clear disconnected time + this.disconnectedTime = null; + + const { + dispatchFetchCommands, + dispatchFetchArtist, + dispatchSetAppValue + } = this.props; + + // Repopulate the page (if a repopulator is set) to ensure things + // are in sync after reconnecting. + + if (this.props.isReconnecting || this.props.isDisconnected) { + dispatchFetchArtist(); + dispatchFetchCommands(); + repopulatePage(); + } + + dispatchSetAppValue({ + isConnected: true, + isReconnecting: false, + isDisconnected: false, + isRestarting: false + }); + + this.retryInterval = 5; + + if (this.retryTimeoutId) { + clearTimeout(this.retryTimeoutId); + } + } + } + + onReceived = (message) => { + console.debug('signalR: received', message.name, message.body); + + this.handleMessage(message); + } + + onReconnecting = () => { + if (window.Lidarr.unloading) { + return; + } + + if (!this.disconnectedTime) { + this.disconnectedTime = Math.floor(new Date().getTime() / 1000); + } + + this.props.dispatchSetAppValue({ + isReconnecting: true + }); + } + + onDisconnected = () => { + if (window.Lidarr.unloading) { + return; + } + + if (!this.disconnectedTime) { + this.disconnectedTime = Math.floor(new Date().getTime() / 1000); + } + + this.props.dispatchSetAppValue({ + isConnected: false, + isReconnecting: true, + isDisconnected: isAppDisconnected(this.disconnectedTime) + }); + + this.retryConnection(); + } + + // + // Render + + render() { + return null; + } +} + +SignalRConnector.propTypes = { + isReconnecting: PropTypes.bool.isRequired, + isDisconnected: PropTypes.bool.isRequired, + isQueuePopulated: PropTypes.bool.isRequired, + dispatchFetchCommands: PropTypes.func.isRequired, + dispatchUpdateCommand: PropTypes.func.isRequired, + dispatchFinishCommand: PropTypes.func.isRequired, + dispatchSetAppValue: PropTypes.func.isRequired, + dispatchSetVersion: PropTypes.func.isRequired, + dispatchUpdate: PropTypes.func.isRequired, + dispatchUpdateItem: PropTypes.func.isRequired, + dispatchRemoveItem: PropTypes.func.isRequired, + dispatchFetchArtist: PropTypes.func.isRequired, + dispatchFetchHealth: PropTypes.func.isRequired, + dispatchFetchQueue: PropTypes.func.isRequired, + dispatchFetchQueueDetails: PropTypes.func.isRequired, + dispatchFetchRootFolders: PropTypes.func.isRequired, + dispatchFetchTags: PropTypes.func.isRequired, + dispatchFetchTagDetails: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SignalRConnector); diff --git a/frontend/src/Components/SpinnerIcon.js b/frontend/src/Components/SpinnerIcon.js new file mode 100644 index 000000000..d21674d9e --- /dev/null +++ b/frontend/src/Components/SpinnerIcon.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from './Icon'; + +function SpinnerIcon(props) { + const { + name, + spinningName, + isSpinning, + ...otherProps + } = props; + + return ( + + ); +} + +SpinnerIcon.propTypes = { + name: PropTypes.object.isRequired, + spinningName: PropTypes.object.isRequired, + isSpinning: PropTypes.bool.isRequired +}; + +SpinnerIcon.defaultProps = { + spinningName: icons.SPINNER +}; + +export default SpinnerIcon; diff --git a/frontend/src/Components/StarRating.css b/frontend/src/Components/StarRating.css new file mode 100644 index 000000000..da7d9c79c --- /dev/null +++ b/frontend/src/Components/StarRating.css @@ -0,0 +1,19 @@ +.starRating { + display: flex; + align-items: left; + justify-content: left; +} + +.backStar { + position: relative; + display: flex; + color: #515253; +} + +.frontStar { + position: absolute; + top: 0; + display: flex; + overflow: hidden; + color: #ffbc0b; +} diff --git a/frontend/src/Components/StarRating.js b/frontend/src/Components/StarRating.js new file mode 100644 index 000000000..f895345b4 --- /dev/null +++ b/frontend/src/Components/StarRating.js @@ -0,0 +1,44 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import styles from './StarRating.css'; + +function StarRating({ rating, votes, iconSize }) { + const starWidth = { + width: `${rating * 10}%` + }; + + const helpText = `${rating/2} (${votes} Votes)`; + + return ( + +
+ + + + + +
+ + + + + +
+
+
+ ); +} + +StarRating.propTypes = { + rating: PropTypes.number.isRequired, + votes: PropTypes.number.isRequired, + iconSize: PropTypes.number.isRequired +}; + +StarRating.defaultProps = { + iconSize: 14 +}; + +export default StarRating; diff --git a/frontend/src/Components/Table/Cells/RelativeDateCell.css b/frontend/src/Components/Table/Cells/RelativeDateCell.css new file mode 100644 index 000000000..e96e5cc10 --- /dev/null +++ b/frontend/src/Components/Table/Cells/RelativeDateCell.css @@ -0,0 +1,5 @@ +.cell { + composes: cell from '~./TableRowCell.css'; + + width: 180px; +} diff --git a/frontend/src/Components/Table/Cells/RelativeDateCell.js b/frontend/src/Components/Table/Cells/RelativeDateCell.js new file mode 100644 index 000000000..207b97752 --- /dev/null +++ b/frontend/src/Components/Table/Cells/RelativeDateCell.js @@ -0,0 +1,66 @@ +import PropTypes from 'prop-types'; +import React, { PureComponent } from 'react'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import TableRowCell from './TableRowCell'; +import styles from './RelativeDateCell.css'; + +class RelativeDateCell extends PureComponent { + + // + // Render + + render() { + const { + className, + date, + includeSeconds, + showRelativeDates, + shortDateFormat, + longDateFormat, + timeFormat, + component: Component, + dispatch, + ...otherProps + } = this.props; + + if (!date) { + return ( + + ); + } + + return ( + + {getRelativeDate(date, shortDateFormat, showRelativeDates, { timeFormat, includeSeconds, timeForToday: true })} + + ); + } +} + +RelativeDateCell.propTypes = { + className: PropTypes.string.isRequired, + date: PropTypes.string, + includeSeconds: PropTypes.bool.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + component: PropTypes.elementType, + dispatch: PropTypes.func +}; + +RelativeDateCell.defaultProps = { + className: styles.cell, + includeSeconds: false, + component: TableRowCell +}; + +export default RelativeDateCell; diff --git a/frontend/src/Components/Table/Cells/RelativeDateCellConnector.js b/frontend/src/Components/Table/Cells/RelativeDateCellConnector.js new file mode 100644 index 000000000..ed996abbe --- /dev/null +++ b/frontend/src/Components/Table/Cells/RelativeDateCellConnector.js @@ -0,0 +1,21 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import RelativeDateCell from './RelativeDateCell'; + +function createMapStateToProps() { + return createSelector( + createUISettingsSelector(), + (uiSettings) => { + return _.pick(uiSettings, [ + 'showRelativeDates', + 'shortDateFormat', + 'longDateFormat', + 'timeFormat' + ]); + } + ); +} + +export default connect(createMapStateToProps, null)(RelativeDateCell); diff --git a/frontend/src/Components/Table/Cells/TableRowCell.css b/frontend/src/Components/Table/Cells/TableRowCell.css new file mode 100644 index 000000000..1c3e6fc5a --- /dev/null +++ b/frontend/src/Components/Table/Cells/TableRowCell.css @@ -0,0 +1,11 @@ +.cell { + padding: 8px; + border-top: 1px solid #eee; + line-height: 1.52857143; +} + +@media only screen and (max-width: $breakpointSmall) { + .cell { + white-space: nowrap; + } +} diff --git a/frontend/src/Components/Table/Cells/TableRowCell.js b/frontend/src/Components/Table/Cells/TableRowCell.js new file mode 100644 index 000000000..f66bbf3aa --- /dev/null +++ b/frontend/src/Components/Table/Cells/TableRowCell.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './TableRowCell.css'; + +class TableRowCell extends Component { + + // + // Render + + render() { + const { + className, + children, + ...otherProps + } = this.props; + + return ( + + {children} + + ); + } +} + +TableRowCell.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.oneOfType([PropTypes.string, PropTypes.node]) +}; + +TableRowCell.defaultProps = { + className: styles.cell +}; + +export default TableRowCell; diff --git a/frontend/src/Components/Table/Cells/TableRowCellButton.css b/frontend/src/Components/Table/Cells/TableRowCellButton.css new file mode 100644 index 000000000..c695d42fc --- /dev/null +++ b/frontend/src/Components/Table/Cells/TableRowCellButton.css @@ -0,0 +1,4 @@ +.cell { + composes: cell from '~./TableRowCell.css'; + composes: link from '~Components/Link/Link.css'; +} diff --git a/frontend/src/Components/Table/Cells/TableRowCellButton.js b/frontend/src/Components/Table/Cells/TableRowCellButton.js new file mode 100644 index 000000000..ff50d3bc9 --- /dev/null +++ b/frontend/src/Components/Table/Cells/TableRowCellButton.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Link from 'Components/Link/Link'; +import TableRowCell from './TableRowCell'; +import styles from './TableRowCellButton.css'; + +function TableRowCellButton({ className, ...otherProps }) { + return ( + + ); +} + +TableRowCellButton.propTypes = { + className: PropTypes.string.isRequired +}; + +TableRowCellButton.defaultProps = { + className: styles.cell +}; + +export default TableRowCellButton; diff --git a/frontend/src/Components/Table/Cells/TableSelectCell.css b/frontend/src/Components/Table/Cells/TableSelectCell.css new file mode 100644 index 000000000..be087c702 --- /dev/null +++ b/frontend/src/Components/Table/Cells/TableSelectCell.css @@ -0,0 +1,11 @@ +.selectCell { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 30px; +} + +.input { + composes: input from '~Components/Form/CheckInput.css'; + + margin: 0; +} diff --git a/frontend/src/Components/Table/Cells/TableSelectCell.js b/frontend/src/Components/Table/Cells/TableSelectCell.js new file mode 100644 index 000000000..9c10f4444 --- /dev/null +++ b/frontend/src/Components/Table/Cells/TableSelectCell.js @@ -0,0 +1,80 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import CheckInput from 'Components/Form/CheckInput'; +import TableRowCell from './TableRowCell'; +import styles from './TableSelectCell.css'; + +class TableSelectCell extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + id, + isSelected, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value: isSelected }); + } + + componentWillUnmount() { + const { + id, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value: null }); + } + + // + // Listeners + + onChange = ({ value, shiftKey }, a, b, c, d) => { + const { + id, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value, shiftKey }); + } + + // + // Render + + render() { + const { + className, + id, + isSelected, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +TableSelectCell.propTypes = { + className: PropTypes.string.isRequired, + id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + isSelected: PropTypes.bool.isRequired, + onSelectedChange: PropTypes.func.isRequired +}; + +TableSelectCell.defaultProps = { + className: styles.selectCell, + isSelected: false +}; + +export default TableSelectCell; diff --git a/frontend/src/Components/Table/Cells/VirtualTableRowCell.css b/frontend/src/Components/Table/Cells/VirtualTableRowCell.css new file mode 100644 index 000000000..2501b7c84 --- /dev/null +++ b/frontend/src/Components/Table/Cells/VirtualTableRowCell.css @@ -0,0 +1,14 @@ +.cell { + @add-mixin truncate; + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + flex-grow: 0; + flex-shrink: 1; + white-space: nowrap; +} + +@media only screen and (max-width: $breakpointSmall) { + .cell { + white-space: nowrap; + } +} diff --git a/frontend/src/Components/Table/Cells/VirtualTableRowCell.js b/frontend/src/Components/Table/Cells/VirtualTableRowCell.js new file mode 100644 index 000000000..42999216f --- /dev/null +++ b/frontend/src/Components/Table/Cells/VirtualTableRowCell.js @@ -0,0 +1,29 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './VirtualTableRowCell.css'; + +function VirtualTableRowCell(props) { + const { + className, + children + } = props; + + return ( +
+ {children} +
+ ); +} + +VirtualTableRowCell.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.oneOfType([PropTypes.string, PropTypes.node]) +}; + +VirtualTableRowCell.defaultProps = { + className: styles.cell +}; + +export default VirtualTableRowCell; diff --git a/frontend/src/Components/Table/Cells/VirtualTableSelectCell.css b/frontend/src/Components/Table/Cells/VirtualTableSelectCell.css new file mode 100644 index 000000000..ec7c61b92 --- /dev/null +++ b/frontend/src/Components/Table/Cells/VirtualTableSelectCell.css @@ -0,0 +1,11 @@ +.cell { + composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 0 36px; +} + +.input { + composes: input from '~Components/Form/CheckInput.css'; + + margin: 0; +} diff --git a/frontend/src/Components/Table/Cells/VirtualTableSelectCell.js b/frontend/src/Components/Table/Cells/VirtualTableSelectCell.js new file mode 100644 index 000000000..a773aab58 --- /dev/null +++ b/frontend/src/Components/Table/Cells/VirtualTableSelectCell.js @@ -0,0 +1,82 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import CheckInput from 'Components/Form/CheckInput'; +import VirtualTableRowCell from './VirtualTableRowCell'; +import styles from './VirtualTableSelectCell.css'; + +export function virtualTableSelectCellRenderer(cellProps) { + const { + cellKey, + rowData, + columnData, + ...otherProps + } = cellProps; + + return ( + + ); +} + +class VirtualTableSelectCell extends Component { + + // + // Listeners + + onChange = ({ value, shiftKey }) => { + const { + id, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value, shiftKey }); + } + + // + // Render + + render() { + const { + inputClassName, + id, + isSelected, + isDisabled, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +VirtualTableSelectCell.propTypes = { + inputClassName: PropTypes.string.isRequired, + id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + isSelected: PropTypes.bool.isRequired, + isDisabled: PropTypes.bool.isRequired, + onSelectedChange: PropTypes.func.isRequired +}; + +VirtualTableSelectCell.defaultProps = { + inputClassName: styles.input, + isSelected: false +}; + +export default VirtualTableSelectCell; diff --git a/frontend/src/Components/Table/Table.css b/frontend/src/Components/Table/Table.css new file mode 100644 index 000000000..bdfdec641 --- /dev/null +++ b/frontend/src/Components/Table/Table.css @@ -0,0 +1,23 @@ +.tableContainer { + &.horizontalScroll { + overflow-x: auto; + } +} + +.table { + max-width: 100%; + width: 100%; + border-collapse: collapse; +} + +@media only screen and (max-width: $breakpointSmall) { + .tableContainer { + min-width: 100%; + width: fit-content; + + &.horizontalScroll { + overflow-y: hidden; + width: 100%; + } + } +} diff --git a/frontend/src/Components/Table/Table.js b/frontend/src/Components/Table/Table.js new file mode 100644 index 000000000..dbd60bf5f --- /dev/null +++ b/frontend/src/Components/Table/Table.js @@ -0,0 +1,141 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { icons, scrollDirections } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import Scroller from 'Components/Scroller/Scroller'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import TableHeader from './TableHeader'; +import TableHeaderCell from './TableHeaderCell'; +import TableSelectAllHeaderCell from './TableSelectAllHeaderCell'; +import styles from './Table.css'; + +const tableHeaderCellProps = [ + 'sortKey', + 'sortDirection' +]; + +function getTableHeaderCellProps(props) { + return _.reduce(tableHeaderCellProps, (result, key) => { + if (props.hasOwnProperty(key)) { + result[key] = props[key]; + } + + return result; + }, {}); +} + +function Table(props) { + const { + className, + horizontalScroll, + selectAll, + columns, + optionsComponent, + pageSize, + canModifyColumns, + children, + onSortPress, + onTableOptionChange, + ...otherProps + } = props; + + return ( + + + + { + selectAll ? + : + null + } + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if ( + (name === 'actions' || name === 'details') && + onTableOptionChange + ) { + return ( + + + + + + ); + } + + return ( + + {column.label} + + ); + }) + } + + + {children} +
+
+ ); +} + +Table.propTypes = { + className: PropTypes.string, + horizontalScroll: PropTypes.bool.isRequired, + selectAll: PropTypes.bool.isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + optionsComponent: PropTypes.elementType, + pageSize: PropTypes.number, + canModifyColumns: PropTypes.bool, + children: PropTypes.node, + onSortPress: PropTypes.func, + onTableOptionChange: PropTypes.func +}; + +Table.defaultProps = { + className: styles.table, + horizontalScroll: true, + selectAll: false +}; + +export default Table; diff --git a/frontend/src/Components/Table/TableBody.js b/frontend/src/Components/Table/TableBody.js new file mode 100644 index 000000000..5cc60d6f4 --- /dev/null +++ b/frontend/src/Components/Table/TableBody.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; + +class TableBody extends Component { + + // + // Render + + render() { + const { + children + } = this.props; + + return ( + {children} + ); + } + +} + +TableBody.propTypes = { + children: PropTypes.node +}; + +export default TableBody; diff --git a/frontend/src/Components/Table/TableHeader.js b/frontend/src/Components/Table/TableHeader.js new file mode 100644 index 000000000..81943e919 --- /dev/null +++ b/frontend/src/Components/Table/TableHeader.js @@ -0,0 +1,28 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; + +class TableHeader extends Component { + + // + // Render + + render() { + const { + children + } = this.props; + + return ( + + + {children} + + + ); + } +} + +TableHeader.propTypes = { + children: PropTypes.node +}; + +export default TableHeader; diff --git a/frontend/src/Components/Table/TableHeaderCell.css b/frontend/src/Components/Table/TableHeaderCell.css new file mode 100644 index 000000000..c2c4f58c8 --- /dev/null +++ b/frontend/src/Components/Table/TableHeaderCell.css @@ -0,0 +1,16 @@ +.headerCell { + padding: 8px; + border: none !important; + text-align: left; + font-weight: bold; +} + +.sortIcon { + margin-left: 10px; +} + +@media only screen and (max-width: $breakpointSmall) { + .headerCell { + white-space: nowrap; + } +} diff --git a/frontend/src/Components/Table/TableHeaderCell.js b/frontend/src/Components/Table/TableHeaderCell.js new file mode 100644 index 000000000..e4739e63f --- /dev/null +++ b/frontend/src/Components/Table/TableHeaderCell.js @@ -0,0 +1,96 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, sortDirections } from 'Helpers/Props'; +import Link from 'Components/Link/Link'; +import Icon from 'Components/Icon'; +import styles from './TableHeaderCell.css'; + +class TableHeaderCell extends Component { + + // + // Listeners + + onPress = () => { + const { + name, + fixedSortDirection + } = this.props; + + if (fixedSortDirection) { + this.props.onSortPress(name, fixedSortDirection); + } else { + this.props.onSortPress(name); + } + } + + // + // Render + + render() { + const { + className, + name, + columnLabel, + isSortable, + isVisible, + isModifiable, + sortKey, + sortDirection, + fixedSortDirection, + children, + onSortPress, + ...otherProps + } = this.props; + + const isSorting = isSortable && sortKey === name; + const sortIcon = sortDirection === sortDirections.ASCENDING ? + icons.SORT_ASCENDING : + icons.SORT_DESCENDING; + + return ( + isSortable ? + + {children} + + { + isSorting && + + } + : + + + {children} + + ); + } +} + +TableHeaderCell.propTypes = { + className: PropTypes.string, + name: PropTypes.string.isRequired, + columnLabel: PropTypes.string, + isSortable: PropTypes.bool, + isVisible: PropTypes.bool, + isModifiable: PropTypes.bool, + sortKey: PropTypes.string, + fixedSortDirection: PropTypes.string, + sortDirection: PropTypes.string, + children: PropTypes.node, + onSortPress: PropTypes.func +}; + +TableHeaderCell.defaultProps = { + className: styles.headerCell, + isSortable: false +}; + +export default TableHeaderCell; diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumn.css b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.css new file mode 100644 index 000000000..204773c3d --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.css @@ -0,0 +1,48 @@ +.column { + display: flex; + align-items: stretch; + width: 100%; + border: 1px solid #aaa; + border-radius: 4px; + background: #fafafa; +} + +.checkContainer { + position: relative; + margin-right: 4px; + margin-bottom: 7px; + margin-left: 8px; +} + +.label { + display: flex; + flex-grow: 1; + margin-bottom: 0; + margin-left: 2px; + font-weight: normal; + line-height: 36px; + cursor: pointer; +} + +.dragHandle { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + width: $dragHandleWidth; + text-align: center; + cursor: grab; +} + +.dragIcon { + top: 0; +} + +.isDragging { + opacity: 0.25; +} + +.notDragable { + padding: 4px 0; +} diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js new file mode 100644 index 000000000..a986be615 --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js @@ -0,0 +1,68 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import CheckInput from 'Components/Form/CheckInput'; +import styles from './TableOptionsColumn.css'; + +function TableOptionsColumn(props) { + const { + name, + label, + isVisible, + isModifiable, + isDragging, + connectDragSource, + onVisibleChange + } = props; + + return ( +
+
+ + + { + !!connectDragSource && + connectDragSource( +
+ +
+ ) + } +
+
+ ); +} + +TableOptionsColumn.propTypes = { + name: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + isVisible: PropTypes.bool.isRequired, + isModifiable: PropTypes.bool.isRequired, + index: PropTypes.number.isRequired, + isDragging: PropTypes.bool, + connectDragSource: PropTypes.func, + onVisibleChange: PropTypes.func.isRequired +}; + +export default TableOptionsColumn; diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.css b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.css new file mode 100644 index 000000000..b927d9bce --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.css @@ -0,0 +1,4 @@ +.dragPreview { + width: 380px; + opacity: 0.75; +} diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.js b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.js new file mode 100644 index 000000000..b1d016529 --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.js @@ -0,0 +1,78 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { DragLayer } from 'react-dnd'; +import dimensions from 'Styles/Variables/dimensions.js'; +import { TABLE_COLUMN } from 'Helpers/dragTypes'; +import DragPreviewLayer from 'Components/DragPreviewLayer'; +import TableOptionsColumn from './TableOptionsColumn'; +import styles from './TableOptionsColumnDragPreview.css'; + +const formGroupSmallWidth = parseInt(dimensions.formGroupSmallWidth); +const formLabelLargeWidth = parseInt(dimensions.formLabelLargeWidth); +const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth); +const dragHandleWidth = parseInt(dimensions.dragHandleWidth); + +function collectDragLayer(monitor) { + return { + item: monitor.getItem(), + itemType: monitor.getItemType(), + currentOffset: monitor.getSourceClientOffset() + }; +} + +class TableOptionsColumnDragPreview extends Component { + + // + // Render + + render() { + const { + item, + itemType, + currentOffset + } = this.props; + + if (!currentOffset || itemType !== TABLE_COLUMN) { + return null; + } + + // The offset is shifted because the drag handle is on the right edge of the + // list item and the preview is wider than the drag handle. + + const { x, y } = currentOffset; + const handleOffset = formGroupSmallWidth - formLabelLargeWidth - formLabelRightMarginWidth - dragHandleWidth; + const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`; + + const style = { + position: 'absolute', + WebkitTransform: transform, + msTransform: transform, + transform + }; + + return ( + +
+ +
+
+ ); + } +} + +TableOptionsColumnDragPreview.propTypes = { + item: PropTypes.object, + itemType: PropTypes.string, + currentOffset: PropTypes.shape({ + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired + }) +}; + +export default DragLayer(collectDragLayer)(TableOptionsColumnDragPreview); diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.css b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.css new file mode 100644 index 000000000..9354a35c0 --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.css @@ -0,0 +1,18 @@ +.columnDragSource { + padding: 4px 0; +} + +.columnPlaceholder { + width: 100%; + height: 36px; + border: 1px dotted #aaa; + border-radius: 4px; +} + +.columnPlaceholderBefore { + margin-bottom: 8px; +} + +.columnPlaceholderAfter { + margin-top: 8px; +} diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js new file mode 100644 index 000000000..80f03e430 --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js @@ -0,0 +1,164 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { findDOMNode } from 'react-dom'; +import { DragSource, DropTarget } from 'react-dnd'; +import classNames from 'classnames'; +import { TABLE_COLUMN } from 'Helpers/dragTypes'; +import TableOptionsColumn from './TableOptionsColumn'; +import styles from './TableOptionsColumnDragSource.css'; + +const columnDragSource = { + beginDrag(column) { + return column; + }, + + endDrag(props, monitor, component) { + props.onColumnDragEnd(monitor.getItem(), monitor.didDrop()); + } +}; + +const columnDropTarget = { + hover(props, monitor, component) { + const dragIndex = monitor.getItem().index; + const hoverIndex = props.index; + + const hoverBoundingRect = findDOMNode(component).getBoundingClientRect(); + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const clientOffset = monitor.getClientOffset(); + const hoverClientY = clientOffset.y - hoverBoundingRect.top; + + if (dragIndex === hoverIndex) { + return; + } + + // When moving up, only trigger if drag position is above 50% and + // when moving down, only trigger if drag position is below 50%. + // If we're moving down the hoverIndex needs to be increased + // by one so it's ordered properly. Otherwise the hoverIndex will work. + + // Dragging downwards + if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) { + return; + } + + // Dragging upwards + if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) { + return; + } + + props.onColumnDragMove(dragIndex, hoverIndex); + } +}; + +function collectDragSource(connect, monitor) { + return { + connectDragSource: connect.dragSource(), + isDragging: monitor.isDragging() + }; +} + +function collectDropTarget(connect, monitor) { + return { + connectDropTarget: connect.dropTarget(), + isOver: monitor.isOver() + }; +} + +class TableOptionsColumnDragSource extends Component { + + // + // Render + + render() { + const { + name, + label, + isVisible, + isModifiable, + index, + isDragging, + isDraggingUp, + isDraggingDown, + isOver, + connectDragSource, + connectDropTarget, + onVisibleChange + } = this.props; + + const isBefore = !isDragging && isDraggingUp && isOver; + const isAfter = !isDragging && isDraggingDown && isOver; + + // if (isDragging && !isOver) { + // return null; + // } + + return connectDropTarget( +
+ { + isBefore && +
+ } + + + + { + isAfter && +
+ } +
+ ); + } +} + +TableOptionsColumnDragSource.propTypes = { + name: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + isVisible: PropTypes.bool.isRequired, + isModifiable: PropTypes.bool.isRequired, + index: PropTypes.number.isRequired, + isDragging: PropTypes.bool, + isDraggingUp: PropTypes.bool, + isDraggingDown: PropTypes.bool, + isOver: PropTypes.bool, + connectDragSource: PropTypes.func, + connectDropTarget: PropTypes.func, + onVisibleChange: PropTypes.func.isRequired, + onColumnDragMove: PropTypes.func.isRequired, + onColumnDragEnd: PropTypes.func.isRequired +}; + +export default DropTarget( + TABLE_COLUMN, + columnDropTarget, + collectDropTarget +)(DragSource( + TABLE_COLUMN, + columnDragSource, + collectDragSource +)(TableOptionsColumnDragSource)); diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsModal.css b/frontend/src/Components/Table/TableOptions/TableOptionsModal.css new file mode 100644 index 000000000..35544f32b --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsModal.css @@ -0,0 +1,5 @@ +.columns { + margin-top: 10px; + width: 100%; + user-select: none; +} diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsModal.js b/frontend/src/Components/Table/TableOptions/TableOptionsModal.js new file mode 100644 index 000000000..2a36668fe --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsModal.js @@ -0,0 +1,260 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { DndProvider } from 'react-dnd'; +import HTML5Backend from 'react-dnd-html5-backend'; +import { inputTypes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputHelpText from 'Components/Form/FormInputHelpText'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import TableOptionsColumn from './TableOptionsColumn'; +import TableOptionsColumnDragSource from './TableOptionsColumnDragSource'; +import TableOptionsColumnDragPreview from './TableOptionsColumnDragPreview'; +import styles from './TableOptionsModal.css'; + +class TableOptionsModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + hasPageSize: !!props.pageSize, + pageSize: props.pageSize, + pageSizeError: null, + dragIndex: null, + dropIndex: null + }; + } + + componentDidUpdate(prevProps) { + if (prevProps.pageSize !== this.state.pageSize) { + this.setState({ pageSize: this.props.pageSize }); + } + } + + // + // Listeners + + onPageSizeChange = ({ value }) => { + let pageSizeError = null; + + if (value < 5) { + pageSizeError = 'Page size must be at least 5'; + } else if (value > 250) { + pageSizeError = 'Page size must not exceed 250'; + } else { + this.props.onTableOptionChange({ pageSize: value }); + } + + this.setState({ + pageSize: value, + pageSizeError + }); + } + + onVisibleChange = ({ name, value }) => { + const columns = _.cloneDeep(this.props.columns); + + const column = _.find(columns, { name }); + column.isVisible = value; + + this.props.onTableOptionChange({ columns }); + } + + onColumnDragMove = (dragIndex, dropIndex) => { + if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) { + this.setState({ + dragIndex, + dropIndex + }); + } + } + + onColumnDragEnd = ({ id }, didDrop) => { + const { + dragIndex, + dropIndex + } = this.state; + + if (didDrop && dropIndex !== null) { + const columns = _.cloneDeep(this.props.columns); + const items = columns.splice(dragIndex, 1); + columns.splice(dropIndex, 0, items[0]); + + this.props.onTableOptionChange({ columns }); + } + + this.setState({ + dragIndex: null, + dropIndex: null + }); + } + + // + // Render + + render() { + const { + isOpen, + columns, + canModifyColumns, + optionsComponent: OptionsComponent, + onTableOptionChange, + onModalClose + } = this.props; + + const { + hasPageSize, + pageSize, + pageSizeError, + dragIndex, + dropIndex + } = this.state; + + const isDragging = dropIndex !== null; + const isDraggingUp = isDragging && dropIndex < dragIndex; + const isDraggingDown = isDragging && dropIndex > dragIndex; + + return ( + + + { + isOpen ? + + + Table Options + + + +
+ { + hasPageSize ? + + Page Size + + + : + null + } + + { + OptionsComponent ? + : null + } + + { + canModifyColumns ? + + Columns + +
+ + +
+ { + columns.map((column, index) => { + const { + name, + label, + columnLabel, + isVisible, + isModifiable + } = column; + + if (isModifiable !== false) { + return ( + + ); + } + + return ( + + ); + }) + } + + +
+
+
: + null + } + +
+ + + +
: + null + } +
+
+ ); + } +} + +TableOptionsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + pageSize: PropTypes.number, + canModifyColumns: PropTypes.bool.isRequired, + optionsComponent: PropTypes.elementType, + onTableOptionChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +TableOptionsModal.defaultProps = { + canModifyColumns: true +}; + +export default TableOptionsModal; diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsModalWrapper.js b/frontend/src/Components/Table/TableOptions/TableOptionsModalWrapper.js new file mode 100644 index 000000000..ff2b8538b --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsModalWrapper.js @@ -0,0 +1,61 @@ +import PropTypes from 'prop-types'; +import React, { Component, Fragment } from 'react'; +import TableOptionsModal from './TableOptionsModal'; + +class TableOptionsModalWrapper extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isTableOptionsModalOpen: false + }; + } + + // + // Listeners + + onTableOptionsPress = () => { + this.setState({ isTableOptionsModalOpen: true }); + } + + onTableOptionsModalClose = () => { + this.setState({ isTableOptionsModalOpen: false }); + } + + // + // Render + + render() { + const { + columns, + children, + ...otherProps + } = this.props; + + return ( + + { + React.cloneElement(children, { onPress: this.onTableOptionsPress }) + } + + + + ); + } +} + +TableOptionsModalWrapper.propTypes = { + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + children: PropTypes.node.isRequired +}; + +export default TableOptionsModalWrapper; diff --git a/frontend/src/Components/Table/TablePager.css b/frontend/src/Components/Table/TablePager.css new file mode 100644 index 000000000..19f5a8f6b --- /dev/null +++ b/frontend/src/Components/Table/TablePager.css @@ -0,0 +1,77 @@ +.pager { + display: flex; + align-items: center; + justify-content: space-between; +} + +.loadingContainer, +.controlsContainer, +.recordsContainer { + flex: 0 1 33%; +} + +.controlsContainer { + display: flex; + justify-content: center; +} + +.recordsContainer { + display: flex; + justify-content: flex-end; +} + +.loading { + composes: loading from '~Components/Loading/LoadingIndicator.css'; + + margin: 0; + margin-left: 5px; + text-align: left; +} + +.controls { + display: flex; + align-items: center; + text-align: center; +} + +.pageNumber { + line-height: 30px; +} + +.pageLink { + padding: 0; + width: 30px; + height: 30px; + line-height: 30px; +} + +.records { + color: $disabledColor; +} + +.disabledPageButton { + color: $disabledColor; +} + +.pageSelect { + composes: select from '~Components/Form/SelectInput.css'; + + padding: 0 2px; + height: 25px; +} + +@media only screen and (max-width: $breakpointSmall) { + .pager { + flex-wrap: wrap; + } + + .loadingContainer, + .recordsContainer { + flex: 0 1 50%; + } + + .controlsContainer { + flex: 0 1 100%; + order: -1; + } +} diff --git a/frontend/src/Components/Table/TablePager.js b/frontend/src/Components/Table/TablePager.js new file mode 100644 index 000000000..3c7c5a8f1 --- /dev/null +++ b/frontend/src/Components/Table/TablePager.js @@ -0,0 +1,180 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import SelectInput from 'Components/Form/SelectInput'; +import styles from './TablePager.css'; + +class TablePager extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isShowingPageSelect: false + }; + } + + // + // Listeners + + onOpenPageSelectClick = () => { + this.setState({ isShowingPageSelect: true }); + } + + onPageSelect = ({ value: page }) => { + this.setState({ isShowingPageSelect: false }); + this.props.onPageSelect(parseInt(page)); + } + + onPageSelectBlur = () => { + this.setState({ isShowingPageSelect: false }); + } + + // + // Render + + render() { + const { + page, + totalPages, + totalRecords, + isFetching, + onFirstPagePress, + onPreviousPagePress, + onNextPagePress, + onLastPagePress + } = this.props; + + const isShowingPageSelect = this.state.isShowingPageSelect; + const pages = Array.from(new Array(totalPages), (x, i) => { + const pageNumber = i + 1; + + return { + key: pageNumber, + value: pageNumber + }; + }); + + if (!page) { + return null; + } + + const isFirstPage = page === 1; + const isLastPage = page === totalPages; + + return ( +
+
+ { + isFetching && + + } +
+ +
+
+ + + + + + + + +
+ { + !isShowingPageSelect && + + {page} / {totalPages} + + } + + { + isShowingPageSelect && + + } +
+ + + + + + + + +
+
+ +
+
+ Total records: {totalRecords} +
+
+
+ ); + } + +} + +TablePager.propTypes = { + page: PropTypes.number, + totalPages: PropTypes.number, + totalRecords: PropTypes.number, + isFetching: PropTypes.bool, + onFirstPagePress: PropTypes.func.isRequired, + onPreviousPagePress: PropTypes.func.isRequired, + onNextPagePress: PropTypes.func.isRequired, + onLastPagePress: PropTypes.func.isRequired, + onPageSelect: PropTypes.func.isRequired +}; + +export default TablePager; diff --git a/frontend/src/Components/Table/TableRow.css b/frontend/src/Components/Table/TableRow.css new file mode 100644 index 000000000..dcc6ad8cf --- /dev/null +++ b/frontend/src/Components/Table/TableRow.css @@ -0,0 +1,7 @@ +.row { + transition: background-color 500ms; + + &:hover { + background-color: $tableRowHoverBackgroundColor; + } +} diff --git a/frontend/src/Components/Table/TableRow.js b/frontend/src/Components/Table/TableRow.js new file mode 100644 index 000000000..c76083183 --- /dev/null +++ b/frontend/src/Components/Table/TableRow.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './TableRow.css'; + +function TableRow(props) { + const { + className, + children, + overlayContent, + ...otherProps + } = props; + + return ( + + {children} + + ); +} + +TableRow.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.node, + overlayContent: PropTypes.bool +}; + +TableRow.defaultProps = { + className: styles.row +}; + +export default TableRow; diff --git a/frontend/src/Components/Table/TableRowButton.css b/frontend/src/Components/Table/TableRowButton.css new file mode 100644 index 000000000..e51ca44a4 --- /dev/null +++ b/frontend/src/Components/Table/TableRowButton.css @@ -0,0 +1,4 @@ +.row { + composes: link from '~Components/Link/Link.css'; + composes: row from '~./TableRow.css'; +} diff --git a/frontend/src/Components/Table/TableRowButton.js b/frontend/src/Components/Table/TableRowButton.js new file mode 100644 index 000000000..7ff679673 --- /dev/null +++ b/frontend/src/Components/Table/TableRowButton.js @@ -0,0 +1,16 @@ +import React from 'react'; +import Link from 'Components/Link/Link'; +import TableRow from './TableRow'; +import styles from './TableRowButton.css'; + +function TableRowButton(props) { + return ( + + ); +} + +export default TableRowButton; diff --git a/frontend/src/Components/Table/TableSelectAllHeaderCell.css b/frontend/src/Components/Table/TableSelectAllHeaderCell.css new file mode 100644 index 000000000..9b6f6e622 --- /dev/null +++ b/frontend/src/Components/Table/TableSelectAllHeaderCell.css @@ -0,0 +1,11 @@ +.selectAllHeaderCell { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + width: 30px; +} + +.input { + composes: input from '~Components/Form/CheckInput.css'; + + margin: 0; +} diff --git a/frontend/src/Components/Table/TableSelectAllHeaderCell.js b/frontend/src/Components/Table/TableSelectAllHeaderCell.js new file mode 100644 index 000000000..c889c32ae --- /dev/null +++ b/frontend/src/Components/Table/TableSelectAllHeaderCell.js @@ -0,0 +1,47 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import CheckInput from 'Components/Form/CheckInput'; +import VirtualTableHeaderCell from './TableHeaderCell'; +import styles from './TableSelectAllHeaderCell.css'; + +function getValue(allSelected, allUnselected) { + if (allSelected) { + return true; + } else if (allUnselected) { + return false; + } + + return null; +} + +function TableSelectAllHeaderCell(props) { + const { + allSelected, + allUnselected, + onSelectAllChange + } = props; + + const value = getValue(allSelected, allUnselected); + + return ( + + + + ); +} + +TableSelectAllHeaderCell.propTypes = { + allSelected: PropTypes.bool.isRequired, + allUnselected: PropTypes.bool.isRequired, + onSelectAllChange: PropTypes.func.isRequired +}; + +export default TableSelectAllHeaderCell; diff --git a/frontend/src/Components/Table/VirtualTable.css b/frontend/src/Components/Table/VirtualTable.css new file mode 100644 index 000000000..3287c5643 --- /dev/null +++ b/frontend/src/Components/Table/VirtualTable.css @@ -0,0 +1,3 @@ +.tableContainer { + width: 100%; +} diff --git a/frontend/src/Components/Table/VirtualTable.js b/frontend/src/Components/Table/VirtualTable.js new file mode 100644 index 000000000..258d31b00 --- /dev/null +++ b/frontend/src/Components/Table/VirtualTable.js @@ -0,0 +1,178 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import { WindowScroller } from 'react-virtualized'; +import { isLocked } from 'Utilities/scrollLock'; +import { scrollDirections } from 'Helpers/Props'; +import Measure from 'Components/Measure'; +import Scroller from 'Components/Scroller/Scroller'; +import VirtualTableBody from './VirtualTableBody'; +import styles from './VirtualTable.css'; + +const ROW_HEIGHT = 38; + +function overscanIndicesGetter(options) { + const { + cellCount, + overscanCellsCount, + startIndex, + stopIndex + } = options; + + // The default getter takes the scroll direction into account, + // but that can cause issues. Ignore the scroll direction and + // always over return more items. + + const overscanStartIndex = startIndex - overscanCellsCount; + const overscanStopIndex = stopIndex + overscanCellsCount; + + return { + overscanStartIndex: Math.max(0, overscanStartIndex), + overscanStopIndex: Math.min(cellCount - 1, overscanStopIndex) + }; +} + +class VirtualTable extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + width: 0 + }; + + this._isInitialized = false; + } + + componentDidMount() { + this._contentBodyNode = ReactDOM.findDOMNode(this.props.contentBody); + } + + componentDidUpdate(prevProps, preState) { + const scrollIndex = this.props.scrollIndex; + + if (scrollIndex != null && scrollIndex !== prevProps.scrollIndex) { + const scrollTop = (scrollIndex + 1) * ROW_HEIGHT + 20; + + this.props.onScroll({ scrollTop }); + } + } + + // + // Control + + rowGetter = ({ index }) => { + return this.props.items[index]; + } + + // + // Listeners + + onMeasure = ({ width }) => { + this.setState({ + width + }); + } + + onSectionRendered = () => { + if (!this._isInitialized && this._contentBodyNode) { + this.props.onRender(); + this._isInitialized = true; + } + } + + onScroll = (props) => { + if (isLocked()) { + return; + } + + const { onScroll } = this.props; + + onScroll(props); + } + + // + // Render + + render() { + const { + className, + items, + isSmallScreen, + header, + headerHeight, + scrollTop, + rowRenderer, + onScroll, + ...otherProps + } = this.props; + + const { + width + } = this.state; + + return ( + + + {({ height, isScrolling }) => { + return ( + + {header} + + + + ); + } + } + + + ); + } +} + +VirtualTable.propTypes = { + className: PropTypes.string.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + scrollTop: PropTypes.number.isRequired, + scrollIndex: PropTypes.number, + contentBody: PropTypes.object.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + header: PropTypes.node.isRequired, + headerHeight: PropTypes.number.isRequired, + rowRenderer: PropTypes.func.isRequired, + onRender: PropTypes.func.isRequired, + onScroll: PropTypes.func.isRequired +}; + +VirtualTable.defaultProps = { + className: styles.tableContainer, + headerHeight: 38, + onRender: () => {} +}; + +export default VirtualTable; diff --git a/frontend/src/Components/Table/VirtualTableBody.css b/frontend/src/Components/Table/VirtualTableBody.css new file mode 100644 index 000000000..12768646d --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableBody.css @@ -0,0 +1,3 @@ +.tableBodyContainer { + position: relative; +} diff --git a/frontend/src/Components/Table/VirtualTableBody.js b/frontend/src/Components/Table/VirtualTableBody.js new file mode 100644 index 000000000..de88bd03c --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableBody.js @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { Grid } from 'react-virtualized'; +import styles from './VirtualTableBody.css'; + +class VirtualTableBody extends Component { + + // + // Render + + render() { + return ( + + ); + } +} + +VirtualTableBody.propTypes = { + className: PropTypes.string.isRequired +}; + +VirtualTableBody.defaultProps = { + className: styles.tableBodyContainer +}; + +export default VirtualTableBody; diff --git a/frontend/src/Components/Table/VirtualTableHeader.css b/frontend/src/Components/Table/VirtualTableHeader.css new file mode 100644 index 000000000..4b757c1f8 --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableHeader.css @@ -0,0 +1,3 @@ +.header { + display: flex; +} diff --git a/frontend/src/Components/Table/VirtualTableHeader.js b/frontend/src/Components/Table/VirtualTableHeader.js new file mode 100644 index 000000000..cf6a0f47b --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableHeader.js @@ -0,0 +1,17 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './VirtualTableHeader.css'; + +function VirtualTableHeader({ children }) { + return ( +
+ {children} +
+ ); +} + +VirtualTableHeader.propTypes = { + children: PropTypes.node +}; + +export default VirtualTableHeader; diff --git a/frontend/src/Components/Table/VirtualTableHeaderCell.css b/frontend/src/Components/Table/VirtualTableHeaderCell.css new file mode 100644 index 000000000..c2c4f58c8 --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableHeaderCell.css @@ -0,0 +1,16 @@ +.headerCell { + padding: 8px; + border: none !important; + text-align: left; + font-weight: bold; +} + +.sortIcon { + margin-left: 10px; +} + +@media only screen and (max-width: $breakpointSmall) { + .headerCell { + white-space: nowrap; + } +} diff --git a/frontend/src/Components/Table/VirtualTableHeaderCell.js b/frontend/src/Components/Table/VirtualTableHeaderCell.js new file mode 100644 index 000000000..bf51062e9 --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableHeaderCell.js @@ -0,0 +1,107 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, sortDirections } from 'Helpers/Props'; +import Link from 'Components/Link/Link'; +import Icon from 'Components/Icon'; +import styles from './VirtualTableHeaderCell.css'; + +export function headerRenderer(headerProps) { + const { + columnData = {}, + dataKey, + label + } = headerProps; + + return ( + + {label} + + ); +} + +class VirtualTableHeaderCell extends Component { + + // + // Listeners + + onPress = () => { + const { + name, + fixedSortDirection + } = this.props; + + if (fixedSortDirection) { + this.props.onSortPress(name, fixedSortDirection); + } else { + this.props.onSortPress(name); + } + } + + // + // Render + + render() { + const { + className, + name, + isSortable, + sortKey, + sortDirection, + fixedSortDirection, + children, + onSortPress, + ...otherProps + } = this.props; + + const isSorting = isSortable && sortKey === name; + const sortIcon = sortDirection === sortDirections.ASCENDING ? + icons.SORT_ASCENDING : + icons.SORT_DESCENDING; + + return ( + isSortable ? + + {children} + + { + isSorting && + + } + : + +
+ {children} +
+ ); + } +} + +VirtualTableHeaderCell.propTypes = { + className: PropTypes.string, + name: PropTypes.string.isRequired, + label: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + isSortable: PropTypes.bool, + sortKey: PropTypes.string, + fixedSortDirection: PropTypes.string, + sortDirection: PropTypes.string, + children: PropTypes.node, + onSortPress: PropTypes.func +}; + +VirtualTableHeaderCell.defaultProps = { + className: styles.headerCell, + isSortable: false +}; + +export default VirtualTableHeaderCell; diff --git a/frontend/src/Components/Table/VirtualTableRow.css b/frontend/src/Components/Table/VirtualTableRow.css new file mode 100644 index 000000000..f4c825b64 --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableRow.css @@ -0,0 +1,14 @@ +.row { + display: flex; + transition: background-color 500ms; + + &:hover { + background-color: #fafbfc; + } +} + +@media only screen and (max-width: $breakpointMedium) { + .row { + overflow-x: visible !important; + } +} diff --git a/frontend/src/Components/Table/VirtualTableRow.js b/frontend/src/Components/Table/VirtualTableRow.js new file mode 100644 index 000000000..0a423902e --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableRow.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './VirtualTableRow.css'; + +function VirtualTableRow(props) { + const { + className, + children, + style, + ...otherProps + } = props; + + return ( +
+ {children} +
+ ); +} + +VirtualTableRow.propTypes = { + className: PropTypes.string.isRequired, + style: PropTypes.object.isRequired, + children: PropTypes.node +}; + +VirtualTableRow.defaultProps = { + className: styles.row +}; + +export default VirtualTableRow; diff --git a/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.css b/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.css new file mode 100644 index 000000000..7790ae17d --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.css @@ -0,0 +1,11 @@ +.selectAllHeaderCell { + composes: headerCell from '~Components/Table/TableHeaderCell.css'; + + flex: 0 0 36px; +} + +.input { + composes: input from '~Components/Form/CheckInput.css'; + + margin: 0; +} diff --git a/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.js b/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.js new file mode 100644 index 000000000..58b246763 --- /dev/null +++ b/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.js @@ -0,0 +1,47 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import CheckInput from 'Components/Form/CheckInput'; +import VirtualTableHeaderCell from './VirtualTableHeaderCell'; +import styles from './VirtualTableSelectAllHeaderCell.css'; + +function getValue(allSelected, allUnselected) { + if (allSelected) { + return true; + } else if (allUnselected) { + return false; + } + + return null; +} + +function VirtualTableSelectAllHeaderCell(props) { + const { + allSelected, + allUnselected, + onSelectAllChange + } = props; + + const value = getValue(allSelected, allUnselected); + + return ( + + + + ); +} + +VirtualTableSelectAllHeaderCell.propTypes = { + allSelected: PropTypes.bool.isRequired, + allUnselected: PropTypes.bool.isRequired, + onSelectAllChange: PropTypes.func.isRequired +}; + +export default VirtualTableSelectAllHeaderCell; diff --git a/frontend/src/Components/TagList.css b/frontend/src/Components/TagList.css new file mode 100644 index 000000000..c1e5567bd --- /dev/null +++ b/frontend/src/Components/TagList.css @@ -0,0 +1,3 @@ +.tags { + flex: 1 0 auto; +} diff --git a/frontend/src/Components/TagList.js b/frontend/src/Components/TagList.js new file mode 100644 index 000000000..485651bdc --- /dev/null +++ b/frontend/src/Components/TagList.js @@ -0,0 +1,38 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Label from './Label'; +import styles from './TagList.css'; + +function TagList({ tags, tagList }) { + return ( +
+ { + tags.map((t) => { + const tag = _.find(tagList, { id: t }); + + if (!tag) { + return null; + } + + return ( + + ); + }) + } +
+ ); +} + +TagList.propTypes = { + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + tagList: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default TagList; diff --git a/frontend/src/Components/TagListConnector.js b/frontend/src/Components/TagListConnector.js new file mode 100644 index 000000000..be7e618e3 --- /dev/null +++ b/frontend/src/Components/TagListConnector.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import TagList from './TagList'; + +function createMapStateToProps() { + return createSelector( + createTagsSelector(), + (tagList) => { + return { + tagList + }; + } + ); +} + +export default connect(createMapStateToProps)(TagList); diff --git a/frontend/src/Components/Tooltip/Popover.css b/frontend/src/Components/Tooltip/Popover.css new file mode 100644 index 000000000..7b0592844 --- /dev/null +++ b/frontend/src/Components/Tooltip/Popover.css @@ -0,0 +1,15 @@ +.title { + padding: 10px 20px; + border-bottom: 1px solid $popoverTitleBorderColor; + background-color: $popoverTitleBackgroundColor; + font-size: 16px; +} + +.body { + overflow: auto; + padding: 10px; +} + +.tooltipBody { + padding: 0; +} diff --git a/frontend/src/Components/Tooltip/Popover.js b/frontend/src/Components/Tooltip/Popover.js new file mode 100644 index 000000000..9ce73cf08 --- /dev/null +++ b/frontend/src/Components/Tooltip/Popover.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Tooltip from './Tooltip'; +import styles from './Popover.css'; + +function Popover(props) { + const { + title, + body, + ...otherProps + } = props; + + return ( + +
+ {title} +
+ +
+ {body} +
+
+ } + /> + ); +} + +Popover.propTypes = { + title: PropTypes.string.isRequired, + body: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired +}; + +export default Popover; diff --git a/frontend/src/Components/Tooltip/Tooltip.css b/frontend/src/Components/Tooltip/Tooltip.css new file mode 100644 index 000000000..1db58372b --- /dev/null +++ b/frontend/src/Components/Tooltip/Tooltip.css @@ -0,0 +1,158 @@ +.tooltipContainer { + z-index: $popperZIndex; + margin: 10px 15px; +} + +.tooltip { + position: relative; + + &.default { + background-color: $white; + box-shadow: 0 5px 10px $popoverShadowColor; + } + + &.inverse { + background-color: $themeDarkColor; + box-shadow: 0 5px 10px $popoverShadowInverseColor; + } +} + +.arrow, +.arrow::after { + position: absolute; + display: block; + width: 0; + height: 0; + border-width: 11px; + border-style: solid; + border-color: transparent; +} + +.arrow::after { + border-width: 10px; + content: ''; +} + +.top { + bottom: -11px; + left: 50%; + margin-left: -11px; + border-bottom-width: 0; + + &::after { + bottom: 1px; + margin-left: -10px; + border-bottom-width: 0; + content: ' '; + + &.default { + border-top-color: $popoverArrowBorderColor; + } + + &.inverse { + border-top-color: $popoverArrowBorderInverseColor; + } + } + + &.default { + border-top-color: $popoverArrowBorderColor; + } + + &.inverse { + border-top-color: $popoverArrowBorderInverseColor; + } +} + +.right { + top: 50%; + left: -11px; + margin-top: -11px; + border-left-width: 0; + + &::after { + bottom: -10px; + left: 1px; + border-left-width: 0; + content: ' '; + + &.default { + border-right-color: $popoverArrowBorderColor; + } + + &.inverse { + border-right-color: $popoverArrowBorderInverseColor; + } + } + + &.default { + border-right-color: $popoverArrowBorderColor; + } + + &.inverse { + border-right-color: $popoverArrowBorderInverseColor; + } +} + +.bottom { + top: -11px; + left: 50%; + margin-left: -11px; + border-top-width: 0; + + &::after { + top: 1px; + margin-left: -10px; + border-top-width: 0; + content: ' '; + + &.default { + border-bottom-color: $popoverArrowBorderColor; + } + + &.inverse { + border-bottom-color: $popoverArrowBorderInverseColor; + } + } + + &.default { + border-bottom-color: $popoverArrowBorderColor; + } + + &.inverse { + border-bottom-color: $popoverArrowBorderInverseColor; + } +} + +.left { + top: 50%; + right: -11px; + margin-top: -11px; + border-right-width: 0; + + &::after { + right: 1px; + bottom: -10px; + border-right-width: 0; + content: ' '; + + &.default { + border-left-color: $popoverArrowBorderColor; + } + + &.inverse { + border-left-color: $popoverArrowBorderInverseColor; + } + } + + &.default { + border-left-color: $popoverArrowBorderColor; + } + + &.inverse { + border-left-color: $popoverArrowBorderInverseColor; + } +} + +.body { + padding: 5px; +} diff --git a/frontend/src/Components/Tooltip/Tooltip.js b/frontend/src/Components/Tooltip/Tooltip.js new file mode 100644 index 000000000..3f8b5ad06 --- /dev/null +++ b/frontend/src/Components/Tooltip/Tooltip.js @@ -0,0 +1,206 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { Manager, Popper, Reference } from 'react-popper'; +import classNames from 'classnames'; +import { isMobile as isMobileUtil } from 'Utilities/mobile'; +import { kinds, tooltipPositions } from 'Helpers/Props'; +import Portal from 'Components/Portal'; +import styles from './Tooltip.css'; + +class Tooltip extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._scheduleUpdate = null; + this._closeTimeout = null; + + this.state = { + isOpen: false + }; + } + + componentDidUpdate() { + if (this._scheduleUpdate && this.state.isOpen) { + this._scheduleUpdate(); + } + } + + componentWillUnmount() { + if (this._closeTimeout) { + this._closeTimeout = clearTimeout(this._closeTimeout); + } + } + + // + // Control + + computeMaxSize = (data) => { + const { + top, + right, + bottom, + left + } = data.offsets.reference; + + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + + if ((/^top/).test(data.placement)) { + data.styles.maxHeight = top - 20; + } else if ((/^bottom/).test(data.placement)) { + data.styles.maxHeight = windowHeight - bottom - 20; + } else if ((/^right/).test(data.placement)) { + data.styles.maxWidth = windowWidth - right - 30; + } else { + data.styles.maxWidth = left - 30; + } + + return data; + } + + // + // Listeners + + onMeasure = ({ width }) => { + this.setState({ width }); + } + + onClick = () => { + if (isMobileUtil()) { + this.setState({ isOpen: !this.state.isOpen }); + } + } + + onMouseEnter = () => { + if (this._closeTimeout) { + this._closeTimeout = clearTimeout(this._closeTimeout); + } + + this.setState({ isOpen: true }); + } + + onMouseLeave = () => { + this._closeTimeout = setTimeout(() => { + this.setState({ isOpen: false }); + }, 100); + } + + // + // Render + + render() { + const { + className, + bodyClassName, + anchor, + tooltip, + kind, + position, + canFlip + } = this.props; + + return ( + + + {({ ref }) => ( + + {anchor} + + )} + + + + + {({ ref, style, placement, scheduleUpdate }) => { + this._scheduleUpdate = scheduleUpdate; + + return ( +
+ { + this.state.isOpen ? +
+
+ +
+ {tooltip} +
+
: + null + } +
+ ); + }} + + + + ); + } +} + +Tooltip.propTypes = { + className: PropTypes.string, + bodyClassName: PropTypes.string.isRequired, + anchor: PropTypes.node.isRequired, + tooltip: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired, + kind: PropTypes.oneOf([kinds.DEFAULT, kinds.INVERSE]), + position: PropTypes.oneOf(tooltipPositions.all), + canFlip: PropTypes.bool.isRequired +}; + +Tooltip.defaultProps = { + bodyClassName: styles.body, + kind: kinds.DEFAULT, + position: tooltipPositions.TOP, + canFlip: true +}; + +export default Tooltip; diff --git a/frontend/src/Components/keyboardShortcuts.js b/frontend/src/Components/keyboardShortcuts.js new file mode 100644 index 000000000..eb0d7c1d7 --- /dev/null +++ b/frontend/src/Components/keyboardShortcuts.js @@ -0,0 +1,102 @@ +import React, { Component } from 'react'; +import Mousetrap from 'mousetrap'; +import getDisplayName from 'Helpers/getDisplayName'; + +export const shortcuts = { + OPEN_KEYBOARD_SHORTCUTS_MODAL: { + key: '?', + name: 'Open This Modal' + }, + + ARTIST_SEARCH_INPUT: { + key: 's', + name: 'Focus Search Box' + }, + + SAVE_SETTINGS: { + key: 'mod+s', + name: 'Save Settings' + } +}; + +function keyboardShortcuts(WrappedComponent) { + class KeyboardShortcuts extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + this._mousetrapBindings = {}; + this._mousetrap = new Mousetrap(); + this._mousetrap.stopCallback = this.stopCallback; + } + + componentWillUnmount() { + this.unbindAllShortcuts(); + this._mousetrap = null; + } + + // + // Control + + bindShortcut = (key, callback, options = {}) => { + this._mousetrap.bind(key, callback); + this._mousetrapBindings[key] = options; + } + + unbindShortcut = (key) => { + delete this._mousetrapBindings[key]; + this._mousetrap.unbind(key); + } + + unbindAllShortcuts = () => { + const keys = Object.keys(this._mousetrapBindings); + + if (!keys.length) { + return; + } + + keys.forEach((binding) => { + this._mousetrap.unbind(binding); + }); + + this._mousetrapBindings = {}; + } + + stopCallback = (event, element, combo) => { + const binding = this._mousetrapBindings[combo]; + + if (!binding || binding.isGlobal) { + return false; + } + + return ( + element.tagName === 'INPUT' || + element.tagName === 'SELECT' || + element.tagName === 'TEXTAREA' || + (element.contentEditable && element.contentEditable === 'true') + ); + } + + // + // Render + + render() { + return ( + + ); + } + } + + KeyboardShortcuts.displayName = `KeyboardShortcut(${getDisplayName(WrappedComponent)})`; + KeyboardShortcuts.WrappedComponent = WrappedComponent; + + return KeyboardShortcuts; +} + +export default keyboardShortcuts; diff --git a/frontend/src/Components/withCurrentPage.js b/frontend/src/Components/withCurrentPage.js new file mode 100644 index 000000000..5e6d9ccf4 --- /dev/null +++ b/frontend/src/Components/withCurrentPage.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +function withCurrentPage(WrappedComponent) { + function CurrentPage(props) { + const { + history + } = props; + + return ( + + ); + } + + CurrentPage.propTypes = { + history: PropTypes.object.isRequired + }; + + return CurrentPage; +} + +export default withCurrentPage; diff --git a/frontend/src/Components/withScrollPosition.js b/frontend/src/Components/withScrollPosition.js new file mode 100644 index 000000000..110da9ab2 --- /dev/null +++ b/frontend/src/Components/withScrollPosition.js @@ -0,0 +1,30 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import scrollPositions from 'Store/scrollPositions'; + +function withScrollPosition(WrappedComponent, scrollPositionKey) { + function ScrollPosition(props) { + const { + history + } = props; + + const scrollTop = history.action === 'POP' ? + scrollPositions[scrollPositionKey] : + 0; + + return ( + + ); + } + + ScrollPosition.propTypes = { + history: PropTypes.object.isRequired + }; + + return ScrollPosition; +} + +export default withScrollPosition; diff --git a/frontend/src/Content/Fonts/Roboto-Light.ttf b/frontend/src/Content/Fonts/Roboto-Light.ttf new file mode 100644 index 000000000..94c6bcc67 Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Light.ttf differ diff --git a/frontend/src/Content/Fonts/Roboto-Light.woff b/frontend/src/Content/Fonts/Roboto-Light.woff new file mode 100644 index 000000000..ec6bf5749 Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Light.woff differ diff --git a/frontend/src/Content/Fonts/Roboto-Light.woff2 b/frontend/src/Content/Fonts/Roboto-Light.woff2 new file mode 100644 index 000000000..288201788 Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Light.woff2 differ diff --git a/frontend/src/Content/Fonts/Roboto-Regular.ttf b/frontend/src/Content/Fonts/Roboto-Regular.ttf new file mode 100644 index 000000000..8c082c8de Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Regular.ttf differ diff --git a/frontend/src/Content/Fonts/Roboto-Regular.woff b/frontend/src/Content/Fonts/Roboto-Regular.woff new file mode 100644 index 000000000..464d20623 Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Regular.woff differ diff --git a/frontend/src/Content/Fonts/Roboto-Regular.woff2 b/frontend/src/Content/Fonts/Roboto-Regular.woff2 new file mode 100644 index 000000000..f96619675 Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Regular.woff2 differ diff --git a/src/UI/Content/fonts/ubuntumono-regular.eot b/frontend/src/Content/Fonts/UbuntuMono-Regular.eot similarity index 100% rename from src/UI/Content/fonts/ubuntumono-regular.eot rename to frontend/src/Content/Fonts/UbuntuMono-Regular.eot diff --git a/src/UI/Content/fonts/UbuntuMono-Regular.ttf b/frontend/src/Content/Fonts/UbuntuMono-Regular.ttf similarity index 100% rename from src/UI/Content/fonts/UbuntuMono-Regular.ttf rename to frontend/src/Content/Fonts/UbuntuMono-Regular.ttf diff --git a/src/UI/Content/fonts/ubuntumono-regular.woff b/frontend/src/Content/Fonts/UbuntuMono-Regular.woff similarity index 100% rename from src/UI/Content/fonts/ubuntumono-regular.woff rename to frontend/src/Content/Fonts/UbuntuMono-Regular.woff diff --git a/frontend/src/Content/Fonts/fonts.css b/frontend/src/Content/Fonts/fonts.css new file mode 100644 index 000000000..bf31501dd --- /dev/null +++ b/frontend/src/Content/Fonts/fonts.css @@ -0,0 +1,38 @@ +@font-face { + font-weight: 300; + font-style: normal; + font-family: 'Roboto'; + src: url('Roboto-Light.woff2?v=1.3.0') format('woff2'), url('Roboto-Light.woff?v=1.3.0') format('woff'), url('Roboto-Light.ttf?v=1.3.0') format('truetype'); +} + +@font-face { + font-weight: 400; + font-style: normal; + font-family: 'Roboto'; + src: url('Roboto-Regular.woff2?v=1.3.0') format('woff2'), url('Roboto-Regular.woff?v=1.3.0') format('woff'), url('Roboto-Regular.ttf?v=1.3.0') format('truetype'); +} + +@font-face { + font-weight: normal; + font-style: normal; + font-family: 'Roboto'; + src: url('Roboto-Regular.woff2?v=1.3.0') format('woff2'), url('Roboto-Regular.woff?v=1.3.0') format('woff'), url('Roboto-Regular.ttf?v=1.3.0') format('truetype'); +} + +@font-face { + font-weight: 400; + font-style: normal; + font-family: 'Ubuntu Mono'; + src: url('UbuntuMono-Regular.eot?#iefix&v=1.3.0') format('embedded-opentype'), url('UbuntuMono-Regular.woff?v=1.3.0') format('woff'), url('UbuntuMono-Regular.ttf?v=1.3.0') format('truetype'); +} + +/* + * text-security-disc + */ + +@font-face { + font-weight: normal; + font-style: normal; + font-family: 'text-security-disc'; + src: url('text-security-disc.woff?v=1.3.0') format('woff'), url('text-security-disc.ttf?v=1.3.0') format('truetype'); +} diff --git a/frontend/src/Content/Fonts/text-security-disc.ttf b/frontend/src/Content/Fonts/text-security-disc.ttf new file mode 100644 index 000000000..86038dba8 Binary files /dev/null and b/frontend/src/Content/Fonts/text-security-disc.ttf differ diff --git a/frontend/src/Content/Fonts/text-security-disc.woff b/frontend/src/Content/Fonts/text-security-disc.woff new file mode 100644 index 000000000..bc4cc324b Binary files /dev/null and b/frontend/src/Content/Fonts/text-security-disc.woff differ diff --git a/frontend/src/Content/Images/404.png b/frontend/src/Content/Images/404.png new file mode 100644 index 000000000..6e5a37b78 Binary files /dev/null and b/frontend/src/Content/Images/404.png differ diff --git a/frontend/src/Content/Images/Icons/android-chrome-192x192.png b/frontend/src/Content/Images/Icons/android-chrome-192x192.png new file mode 100644 index 000000000..88a584f88 Binary files /dev/null and b/frontend/src/Content/Images/Icons/android-chrome-192x192.png differ diff --git a/frontend/src/Content/Images/Icons/android-chrome-512x512.png b/frontend/src/Content/Images/Icons/android-chrome-512x512.png new file mode 100644 index 000000000..859cdc3c8 Binary files /dev/null and b/frontend/src/Content/Images/Icons/android-chrome-512x512.png differ diff --git a/frontend/src/Content/Images/Icons/apple-touch-icon.png b/frontend/src/Content/Images/Icons/apple-touch-icon.png new file mode 100644 index 000000000..14aed1616 Binary files /dev/null and b/frontend/src/Content/Images/Icons/apple-touch-icon.png differ diff --git a/frontend/src/Content/Images/Icons/browserconfig.xml b/frontend/src/Content/Images/Icons/browserconfig.xml new file mode 100644 index 000000000..993924968 --- /dev/null +++ b/frontend/src/Content/Images/Icons/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #00ccff + + + diff --git a/frontend/src/Content/Images/Icons/favicon-16x16.png b/frontend/src/Content/Images/Icons/favicon-16x16.png new file mode 100644 index 000000000..eb2a9cf70 Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-16x16.png differ diff --git a/frontend/src/Content/Images/Icons/favicon-32x32.png b/frontend/src/Content/Images/Icons/favicon-32x32.png new file mode 100644 index 000000000..242d170fb Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-32x32.png differ diff --git a/frontend/src/Content/Images/Icons/favicon-debug-16x16.png b/frontend/src/Content/Images/Icons/favicon-debug-16x16.png new file mode 100644 index 000000000..6031bc849 Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-debug-16x16.png differ diff --git a/frontend/src/Content/Images/Icons/favicon-debug-32x32.png b/frontend/src/Content/Images/Icons/favicon-debug-32x32.png new file mode 100644 index 000000000..363966ffa Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-debug-32x32.png differ diff --git a/frontend/src/Content/Images/Icons/favicon-debug.ico b/frontend/src/Content/Images/Icons/favicon-debug.ico new file mode 100644 index 000000000..726e812c6 Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-debug.ico differ diff --git a/frontend/src/Content/Images/Icons/favicon.ico b/frontend/src/Content/Images/Icons/favicon.ico new file mode 100644 index 000000000..1b0de8423 Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon.ico differ diff --git a/frontend/src/Content/Images/Icons/manifest.json b/frontend/src/Content/Images/Icons/manifest.json new file mode 100644 index 000000000..d14732f60 --- /dev/null +++ b/frontend/src/Content/Images/Icons/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "", + "icons": [ + { + "src": "/Content/Images/Icons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/Content/Images/Icons/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#3a3f51", + "background_color": "#3a3f51", + "display": "standalone" +} \ No newline at end of file diff --git a/frontend/src/Content/Images/Icons/mstile-144x144.png b/frontend/src/Content/Images/Icons/mstile-144x144.png new file mode 100644 index 000000000..1ffe2e9c5 Binary files /dev/null and b/frontend/src/Content/Images/Icons/mstile-144x144.png differ diff --git a/frontend/src/Content/Images/Icons/mstile-150x150.png b/frontend/src/Content/Images/Icons/mstile-150x150.png new file mode 100644 index 000000000..1008bf9a0 Binary files /dev/null and b/frontend/src/Content/Images/Icons/mstile-150x150.png differ diff --git a/frontend/src/Content/Images/Icons/mstile-310x150.png b/frontend/src/Content/Images/Icons/mstile-310x150.png new file mode 100644 index 000000000..340abd98e Binary files /dev/null and b/frontend/src/Content/Images/Icons/mstile-310x150.png differ diff --git a/frontend/src/Content/Images/Icons/mstile-310x310.png b/frontend/src/Content/Images/Icons/mstile-310x310.png new file mode 100644 index 000000000..6e62ce87f Binary files /dev/null and b/frontend/src/Content/Images/Icons/mstile-310x310.png differ diff --git a/frontend/src/Content/Images/Icons/mstile-70x70.png b/frontend/src/Content/Images/Icons/mstile-70x70.png new file mode 100644 index 000000000..ecbb0dd58 Binary files /dev/null and b/frontend/src/Content/Images/Icons/mstile-70x70.png differ diff --git a/frontend/src/Content/Images/Icons/safari-pinned-tab.svg b/frontend/src/Content/Images/Icons/safari-pinned-tab.svg new file mode 100644 index 000000000..6fc7fb969 --- /dev/null +++ b/frontend/src/Content/Images/Icons/safari-pinned-tab.svg @@ -0,0 +1,38 @@ + + + + +Created by potrace 1.11, written by Peter Selinger 2001-2013 + + + + + diff --git a/frontend/src/Content/Images/error.png b/frontend/src/Content/Images/error.png new file mode 100644 index 000000000..9b1ae7746 Binary files /dev/null and b/frontend/src/Content/Images/error.png differ diff --git a/frontend/src/Content/Images/logo.svg b/frontend/src/Content/Images/logo.svg new file mode 100644 index 000000000..ebebe49a9 --- /dev/null +++ b/frontend/src/Content/Images/logo.svg @@ -0,0 +1 @@ + background Layer 1 \ No newline at end of file diff --git a/frontend/src/Content/Images/poster-dark-square.png b/frontend/src/Content/Images/poster-dark-square.png new file mode 100644 index 000000000..efadba25e Binary files /dev/null and b/frontend/src/Content/Images/poster-dark-square.png differ diff --git a/frontend/src/Content/Images/poster-dark.png b/frontend/src/Content/Images/poster-dark.png new file mode 100644 index 000000000..0b5c9786a Binary files /dev/null and b/frontend/src/Content/Images/poster-dark.png differ diff --git a/frontend/src/Helpers/Props/Shapes/createRouteMatchShape.js b/frontend/src/Helpers/Props/Shapes/createRouteMatchShape.js new file mode 100644 index 000000000..11cca7d1b --- /dev/null +++ b/frontend/src/Helpers/Props/Shapes/createRouteMatchShape.js @@ -0,0 +1,11 @@ +import PropTypes from 'prop-types'; + +function createRouteMatchShape(props) { + return PropTypes.shape({ + params: PropTypes.shape({ + ...props + }).isRequired + }); +} + +export default createRouteMatchShape; diff --git a/frontend/src/Helpers/Props/Shapes/locationShape.js b/frontend/src/Helpers/Props/Shapes/locationShape.js new file mode 100644 index 000000000..80b53eb44 --- /dev/null +++ b/frontend/src/Helpers/Props/Shapes/locationShape.js @@ -0,0 +1,11 @@ +import PropTypes from 'prop-types'; + +const locationShape = PropTypes.shape({ + pathname: PropTypes.string.isRequired, + search: PropTypes.string.isRequired, + state: PropTypes.object, + action: PropTypes.string, + key: PropTypes.string +}); + +export default locationShape; diff --git a/frontend/src/Helpers/Props/Shapes/settingShape.js b/frontend/src/Helpers/Props/Shapes/settingShape.js new file mode 100644 index 000000000..cd672de27 --- /dev/null +++ b/frontend/src/Helpers/Props/Shapes/settingShape.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; + +const settingShape = { + value: PropTypes.oneOf([PropTypes.bool, PropTypes.number, PropTypes.string]), + warnings: PropTypes.arrayOf(PropTypes.string).isRequired, + errors: PropTypes.arrayOf(PropTypes.string).isRequired +}; + +export const arraySettingShape = { + ...settingShape, + value: PropTypes.array.isRequired +}; + +export const boolSettingShape = { + ...settingShape, + value: PropTypes.bool.isRequired +}; + +export const numberSettingShape = { + ...settingShape, + value: PropTypes.number.isRequired +}; + +export const stringSettingShape = { + ...settingShape, + value: PropTypes.string +}; + +export const tagSettingShape = { + ...settingShape, + value: PropTypes.arrayOf(PropTypes.number).isRequired +}; + +export default settingShape; diff --git a/frontend/src/Helpers/Props/Shapes/tagShape.js b/frontend/src/Helpers/Props/Shapes/tagShape.js new file mode 100644 index 000000000..d701f4e8a --- /dev/null +++ b/frontend/src/Helpers/Props/Shapes/tagShape.js @@ -0,0 +1,8 @@ +import PropTypes from 'prop-types'; + +const tagShape = { + id: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]).isRequired, + name: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired +}; + +export default tagShape; diff --git a/frontend/src/Helpers/Props/align.js b/frontend/src/Helpers/Props/align.js new file mode 100644 index 000000000..f381959c6 --- /dev/null +++ b/frontend/src/Helpers/Props/align.js @@ -0,0 +1,5 @@ +export const LEFT = 'left'; +export const CENTER = 'center'; +export const RIGHT = 'right'; + +export const all = [LEFT, CENTER, RIGHT]; diff --git a/frontend/src/Helpers/Props/filterBuilderTypes.js b/frontend/src/Helpers/Props/filterBuilderTypes.js new file mode 100644 index 000000000..72722ab63 --- /dev/null +++ b/frontend/src/Helpers/Props/filterBuilderTypes.js @@ -0,0 +1,50 @@ +import * as filterTypes from './filterTypes'; + +export const ARRAY = 'array'; +export const DATE = 'date'; +export const EXACT = 'exact'; +export const NUMBER = 'number'; +export const STRING = 'string'; + +export const all = [ + ARRAY, + DATE, + EXACT, + NUMBER, + STRING +]; + +export const possibleFilterTypes = { + [ARRAY]: [ + { key: filterTypes.CONTAINS, value: 'contains' }, + { key: filterTypes.NOT_CONTAINS, value: 'does not contain' } + ], + + [DATE]: [ + { key: filterTypes.LESS_THAN, value: 'is before' }, + { key: filterTypes.GREATER_THAN, value: 'is after' }, + { key: filterTypes.IN_LAST, value: 'in the last' }, + { key: filterTypes.IN_NEXT, value: 'in the next' } + ], + + [EXACT]: [ + { key: filterTypes.EQUAL, value: 'is' }, + { key: filterTypes.NOT_EQUAL, value: 'is not' } + ], + + [NUMBER]: [ + { key: filterTypes.EQUAL, value: 'equal' }, + { key: filterTypes.GREATER_THAN, value: 'greater than' }, + { key: filterTypes.GREATER_THAN_OR_EQUAL, value: 'greater than or equal' }, + { key: filterTypes.LESS_THAN, value: 'less than' }, + { key: filterTypes.LESS_THAN_OR_EQUAL, value: 'less than or equal' }, + { key: filterTypes.NOT_EQUAL, value: 'not equal' } + ], + + [STRING]: [ + { key: filterTypes.CONTAINS, value: 'contains' }, + { key: filterTypes.NOT_CONTAINS, value: 'does not contain' }, + { key: filterTypes.EQUAL, value: 'equal' }, + { key: filterTypes.NOT_EQUAL, value: 'not equal' } + ] +}; diff --git a/frontend/src/Helpers/Props/filterBuilderValueTypes.js b/frontend/src/Helpers/Props/filterBuilderValueTypes.js new file mode 100644 index 000000000..42df49eda --- /dev/null +++ b/frontend/src/Helpers/Props/filterBuilderValueTypes.js @@ -0,0 +1,11 @@ +export const BOOL = 'bool'; +export const BYTES = 'bytes'; +export const DATE = 'date'; +export const DEFAULT = 'default'; +export const INDEXER = 'indexer'; +export const METADATA_PROFILE = 'metadataProfile'; +export const PROTOCOL = 'protocol'; +export const QUALITY = 'quality'; +export const QUALITY_PROFILE = 'qualityProfile'; +export const ARTIST_STATUS = 'artistStatus'; +export const TAG = 'tag'; diff --git a/frontend/src/Helpers/Props/filterTypePredicates.js b/frontend/src/Helpers/Props/filterTypePredicates.js new file mode 100644 index 000000000..a3ea11956 --- /dev/null +++ b/frontend/src/Helpers/Props/filterTypePredicates.js @@ -0,0 +1,45 @@ +import * as filterTypes from './filterTypes'; + +const filterTypePredicates = { + [filterTypes.CONTAINS]: function(itemValue, filterValue) { + if (Array.isArray(itemValue)) { + return itemValue.some((v) => v === filterValue); + } + + return itemValue.toLowerCase().contains(filterValue.toLowerCase()); + }, + + [filterTypes.EQUAL]: function(itemValue, filterValue) { + return itemValue === filterValue; + }, + + [filterTypes.GREATER_THAN]: function(itemValue, filterValue) { + return itemValue > filterValue; + }, + + [filterTypes.GREATER_THAN_OR_EQUAL]: function(itemValue, filterValue) { + return itemValue >= filterValue; + }, + + [filterTypes.LESS_THAN]: function(itemValue, filterValue) { + return itemValue < filterValue; + }, + + [filterTypes.LESS_THAN_OR_EQUAL]: function(itemValue, filterValue) { + return itemValue <= filterValue; + }, + + [filterTypes.NOT_CONTAINS]: function(itemValue, filterValue) { + if (Array.isArray(itemValue)) { + return !itemValue.some((v) => v === filterValue); + } + + return !itemValue.toLowerCase().contains(filterValue.toLowerCase()); + }, + + [filterTypes.NOT_EQUAL]: function(itemValue, filterValue) { + return itemValue !== filterValue; + } +}; + +export default filterTypePredicates; diff --git a/frontend/src/Helpers/Props/filterTypes.js b/frontend/src/Helpers/Props/filterTypes.js new file mode 100644 index 000000000..77809b8ce --- /dev/null +++ b/frontend/src/Helpers/Props/filterTypes.js @@ -0,0 +1,21 @@ +export const CONTAINS = 'contains'; +export const EQUAL = 'equal'; +export const GREATER_THAN = 'greaterThan'; +export const GREATER_THAN_OR_EQUAL = 'greaterThanOrEqual'; +export const IN_LAST = 'inLast'; +export const IN_NEXT = 'inNext'; +export const LESS_THAN = 'lessThan'; +export const LESS_THAN_OR_EQUAL = 'lessThanOrEqual'; +export const NOT_CONTAINS = 'notContains'; +export const NOT_EQUAL = 'notEqual'; + +export const all = [ + CONTAINS, + EQUAL, + GREATER_THAN, + GREATER_THAN_OR_EQUAL, + LESS_THAN, + LESS_THAN_OR_EQUAL, + NOT_CONTAINS, + NOT_EQUAL +]; diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js new file mode 100644 index 000000000..86ea9c58b --- /dev/null +++ b/frontend/src/Helpers/Props/icons.js @@ -0,0 +1,215 @@ +// +// Regular + +import { + faBookmark as farBookmark, + faCalendar as farCalendar, + faCircle as farCircle, + faClock as farClock, + faClone as farClone, + faDotCircle as farDotCircle, + faFile as farFile, + faFileArchive as farFileArchive, + faFileAudio as farFileAudio, + faFolder as farFolder, + faObjectGroup as farObjectGroup, + faHdd as farHdd, + faKeyboard as farKeyboard, + faObjectUngroup as farObjectUngroup +} from '@fortawesome/free-regular-svg-icons'; + +// +// Solid + +import { + faArrowCircleLeft as fasArrowCircleLeft, + faArrowCircleRight as fasArrowCircleRight, + faArrowCircleUp as fasArrowCircleUp, + faLongArrowAltRight as fasLongArrowAltRight, + faBackward as fasBackward, + faBan as fasBan, + faBars as fasBars, + faBolt as fasBolt, + faBookmark as fasBookmark, + faBookReader as fasBookReader, + faBug as fasBug, + faBroadcastTower as fasBroadcastTower, + faCalendarAlt as fasCalendarAlt, + faCaretDown as fasCaretDown, + faCheck as fasCheck, + faChevronCircleDown as fasChevronCircleDown, + faChevronCircleRight as fasChevronCircleRight, + faChevronCircleUp as fasChevronCircleUp, + faCheckCircle as fasCheckCircle, + faCircle as fasCircle, + faCloudDownloadAlt as fasCloudDownloadAlt, + faCloud as fasCloud, + faCog as fasCog, + faCogs as fasCogs, + faCopy as fasCopy, + faDesktop as fasDesktop, + faDownload as fasDownload, + faEdit as fasEdit, + faEllipsisH as fasEllipsisH, + faExclamationCircle as fasExclamationCircle, + faExclamationTriangle as fasExclamationTriangle, + faExternalLinkAlt as fasExternalLinkAlt, + faEye as fasEye, + faFastBackward as fasFastBackward, + faFastForward as fasFastForward, + faFileImport as fasFileImport, + faFileInvoice as farFileInvoice, + faFilter as fasFilter, + faFolderOpen as fasFolderOpen, + faForward as fasForward, + faHeart as fasHeart, + faHistory as fasHistory, + faHome as fasHome, + faInfoCircle as fasInfoCircle, + faLaptop as fasLaptop, + faLevelUpAlt as fasLevelUpAlt, + faMedkit as fasMedkit, + faMinus as fasMinus, + faPause as fasPause, + faPlay as fasPlay, + faPlus as fasPlus, + faPowerOff as fasPowerOff, + faQuestion as fasQuestion, + faQuestionCircle as fasQuestionCircle, + faRedoAlt as fasRedoAlt, + faRetweet as fasRetweet, + faRss as fasRss, + faRocket as fasRocket, + faSave as fasSave, + faSearch as fasSearch, + faSignOutAlt as fasSignOutAlt, + faSitemap as fasSitemap, + faSpinner as fasSpinner, + faSort as fasSort, + faSortDown as fasSortDown, + faSortUp as fasSortUp, + faStar as fasStar, + faStop as fasStop, + faSync as fasSync, + faTags as fasTags, + faTable as fasTable, + faTh as fasTh, + faThList as fasThList, + faTrashAlt as fasTrashAlt, + faTimes as fasTimes, + faTimesCircle as fasTimesCircle, + faUser as fasUser, + faUserPlus as fasUserPlus, + faVial as fasVial, + faWrench as fasWrench +} from '@fortawesome/free-solid-svg-icons'; + +// +// Icons + +export const ACTIONS = fasBolt; +export const ACTIVITY = farClock; +export const ADD = fasPlus; +export const ALTERNATE_TITLES = farClone; +export const ADVANCED_SETTINGS = fasCog; +export const ARROW_LEFT = fasArrowCircleLeft; +export const ARROW_RIGHT = fasArrowCircleRight; +export const ARROW_RIGHT_NO_CIRCLE = fasLongArrowAltRight; +export const ARROW_UP = fasArrowCircleUp; +export const BACKUP = farFileArchive; +export const BAN = fasBan; +export const BUG = fasBug; +export const CALENDAR = fasCalendarAlt; +export const CALENDAR_O = farCalendar; +export const CARET_DOWN = fasCaretDown; +export const CHECK = fasCheck; +export const CHECK_INDETERMINATE = fasMinus; +export const CHECK_CIRCLE = fasCheckCircle; +export const CIRCLE = fasCircle; +export const CIRCLE_OUTLINE = farCircle; +export const CLEAR = fasTrashAlt; +export const CLIPBOARD = fasCopy; +export const CLOSE = fasTimes; +export const CLONE = farClone; +export const COLLAPSE = fasChevronCircleUp; +export const COMPUTER = fasDesktop; +export const DANGER = fasExclamationCircle; +export const DELETE = fasTrashAlt; +export const DOWNLOAD = fasDownload; +export const DOWNLOADED = fasDownload; +export const DOWNLOADING = fasCloudDownloadAlt; +export const DRIVE = farHdd; +export const EDIT = fasWrench; +export const TRACK_FILE = farFileAudio; +export const EXPAND = fasChevronCircleDown; +export const EXPAND_INDETERMINATE = fasChevronCircleRight; +export const EXTERNAL_LINK = fasExternalLinkAlt; +export const FATAL = fasTimesCircle; +export const FILE = farFile; +export const FILEIMPORT = fasFileImport; +export const FILTER = fasFilter; +export const FOLDER = farFolder; +export const FOLDER_OPEN = fasFolderOpen; +export const GROUP = farObjectGroup; +export const HEALTH = fasMedkit; +export const HEART = fasHeart; +export const HISTORY = fasHistory; +export const HOUSEKEEPING = fasHome; +export const INFO = fasInfoCircle; +export const INTERACTIVE = fasUser; +export const KEYBOARD = farKeyboard; +export const LOGOUT = fasSignOutAlt; +export const MEDIA_INFO = farFileInvoice; +export const MISSING = fasExclamationTriangle; +export const MONITORED = fasBookmark; +export const NETWORK = fasBroadcastTower; +export const NAVBAR_COLLAPSE = fasBars; +export const NOT_AIRED = farClock; +export const ORGANIZE = fasSitemap; +export const OVERFLOW = fasEllipsisH; +export const OVERVIEW = fasThList; +export const PAGE_FIRST = fasFastBackward; +export const PAGE_PREVIOUS = fasBackward; +export const PAGE_NEXT = fasForward; +export const PAGE_LAST = fasFastForward; +export const PARENT = fasLevelUpAlt; +export const PAUSED = fasPause; +export const PENDING = farClock; +export const PROFILE = fasUser; +export const POSTER = fasTh; +export const QUEUED = fasCloud; +export const QUICK = fasRocket; +export const REFRESH = fasSync; +export const REMOVE = fasTimes; +export const REORDER = fasBars; +export const RESTART = fasRedoAlt; +export const RESTORE = fasHistory; +export const RETAG = fasEdit; +export const RSS = fasRss; +export const SAVE = fasSave; +export const SCHEDULED = farClock; +export const SCORE = fasUserPlus; +export const SEARCH = fasSearch; +export const ARTIST_CONTINUING = fasPlay; +export const ARTIST_ENDED = fasStop; +export const SETTINGS = fasCogs; +export const SHUTDOWN = fasPowerOff; +export const SORT = fasSort; +export const SORT_ASCENDING = fasSortUp; +export const SORT_DESCENDING = fasSortDown; +export const SPINNER = fasSpinner; +export const STAR_FULL = fasStar; +export const SUBTRACT = fasMinus; +export const SYSTEM = fasLaptop; +export const TABLE = fasTable; +export const TAGS = fasTags; +export const TBA = fasQuestionCircle; +export const TEST = fasVial; +export const UNGROUP = farObjectUngroup; +export const UNKNOWN = fasQuestion; +export const UNMONITORED = farBookmark; +export const UPDATE = fasRetweet; +export const UNSAVED_SETTING = farDotCircle; +export const VIEW = fasEye; +export const WARNING = fasExclamationTriangle; +export const WIKI = fasBookReader; diff --git a/frontend/src/Helpers/Props/index.js b/frontend/src/Helpers/Props/index.js new file mode 100644 index 000000000..3f4f94f6f --- /dev/null +++ b/frontend/src/Helpers/Props/index.js @@ -0,0 +1,29 @@ +import * as align from './align'; +import * as inputTypes from './inputTypes'; +import * as filterBuilderTypes from './filterBuilderTypes'; +import * as filterBuilderValueTypes from './filterBuilderValueTypes'; +import filterTypePredicates from './filterTypePredicates'; +import * as filterTypes from './filterTypes'; +import * as icons from './icons'; +import * as kinds from './kinds'; +import * as messageTypes from './messageTypes'; +import * as sizes from './sizes'; +import * as scrollDirections from './scrollDirections'; +import * as sortDirections from './sortDirections'; +import * as tooltipPositions from './tooltipPositions'; + +export { + align, + inputTypes, + filterBuilderTypes, + filterBuilderValueTypes, + filterTypePredicates, + filterTypes, + icons, + kinds, + messageTypes, + sizes, + scrollDirections, + sortDirections, + tooltipPositions +}; diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js new file mode 100644 index 000000000..172ca331c --- /dev/null +++ b/frontend/src/Helpers/Props/inputTypes.js @@ -0,0 +1,43 @@ +export const AUTO_COMPLETE = 'autoComplete'; +export const CAPTCHA = 'captcha'; +export const CHECK = 'check'; +export const DEVICE = 'device'; +export const PLAYLIST = 'playlist'; +export const KEY_VALUE_LIST = 'keyValueList'; +export const MONITOR_ALBUMS_SELECT = 'monitorAlbumsSelect'; +export const NUMBER = 'number'; +export const OAUTH = 'oauth'; +export const PASSWORD = 'password'; +export const PATH = 'path'; +export const QUALITY_PROFILE_SELECT = 'qualityProfileSelect'; +export const METADATA_PROFILE_SELECT = 'metadataProfileSelect'; +export const ALBUM_RELEASE_SELECT = 'albumReleaseSelect'; +export const ROOT_FOLDER_SELECT = 'rootFolderSelect'; +export const SELECT = 'select'; +export const SERIES_TYPE_SELECT = 'artistTypeSelect'; +export const TAG = 'tag'; +export const TEXT = 'text'; +export const TEXT_TAG = 'textTag'; + +export const all = [ + AUTO_COMPLETE, + CAPTCHA, + CHECK, + DEVICE, + PLAYLIST, + KEY_VALUE_LIST, + MONITOR_ALBUMS_SELECT, + NUMBER, + OAUTH, + PASSWORD, + PATH, + QUALITY_PROFILE_SELECT, + METADATA_PROFILE_SELECT, + ALBUM_RELEASE_SELECT, + ROOT_FOLDER_SELECT, + SELECT, + SERIES_TYPE_SELECT, + TAG, + TEXT, + TEXT_TAG +]; diff --git a/frontend/src/Helpers/Props/kinds.js b/frontend/src/Helpers/Props/kinds.js new file mode 100644 index 000000000..fd2c17f7b --- /dev/null +++ b/frontend/src/Helpers/Props/kinds.js @@ -0,0 +1,23 @@ +export const DANGER = 'danger'; +export const DEFAULT = 'default'; +export const DISABLED = 'disabled'; +export const INFO = 'info'; +export const INVERSE = 'inverse'; +export const PINK = 'pink'; +export const PRIMARY = 'primary'; +export const PURPLE = 'purple'; +export const SUCCESS = 'success'; +export const WARNING = 'warning'; + +export const all = [ + DANGER, + DEFAULT, + DISABLED, + INFO, + INVERSE, + PINK, + PRIMARY, + PURPLE, + SUCCESS, + WARNING +]; diff --git a/frontend/src/Helpers/Props/messageTypes.js b/frontend/src/Helpers/Props/messageTypes.js new file mode 100644 index 000000000..997354f9d --- /dev/null +++ b/frontend/src/Helpers/Props/messageTypes.js @@ -0,0 +1,11 @@ +export const ERROR = 'error'; +export const INFO = 'info'; +export const SUCCESS = 'success'; +export const WARNING = 'warning'; + +export const all = [ + ERROR, + INFO, + SUCCESS, + WARNING +]; diff --git a/frontend/src/Helpers/Props/scrollDirections.js b/frontend/src/Helpers/Props/scrollDirections.js new file mode 100644 index 000000000..1ae61143b --- /dev/null +++ b/frontend/src/Helpers/Props/scrollDirections.js @@ -0,0 +1,6 @@ +export const NONE = 'none'; +export const BOTH = 'both'; +export const HORIZONTAL = 'horizontal'; +export const VERTICAL = 'vertical'; + +export const all = [NONE, HORIZONTAL, VERTICAL, BOTH]; diff --git a/frontend/src/Helpers/Props/sizes.js b/frontend/src/Helpers/Props/sizes.js new file mode 100644 index 000000000..d7f85df5e --- /dev/null +++ b/frontend/src/Helpers/Props/sizes.js @@ -0,0 +1,7 @@ +export const EXTRA_SMALL = 'extraSmall'; +export const SMALL = 'small'; +export const MEDIUM = 'medium'; +export const LARGE = 'large'; +export const EXTRA_LARGE = 'extraLarge'; + +export const all = [EXTRA_SMALL, SMALL, MEDIUM, LARGE, EXTRA_LARGE]; diff --git a/frontend/src/Helpers/Props/sortDirections.js b/frontend/src/Helpers/Props/sortDirections.js new file mode 100644 index 000000000..ff3b17bb6 --- /dev/null +++ b/frontend/src/Helpers/Props/sortDirections.js @@ -0,0 +1,4 @@ +export const ASCENDING = 'ascending'; +export const DESCENDING = 'descending'; + +export const all = [ASCENDING, DESCENDING]; diff --git a/frontend/src/Helpers/Props/tooltipPositions.js b/frontend/src/Helpers/Props/tooltipPositions.js new file mode 100644 index 000000000..bca3c4ed4 --- /dev/null +++ b/frontend/src/Helpers/Props/tooltipPositions.js @@ -0,0 +1,11 @@ +export const TOP = 'top'; +export const RIGHT = 'right'; +export const BOTTOM = 'bottom'; +export const LEFT = 'left'; + +export const all = [ + TOP, + RIGHT, + BOTTOM, + LEFT +]; diff --git a/frontend/src/Helpers/dragTypes.js b/frontend/src/Helpers/dragTypes.js new file mode 100644 index 000000000..ed6ba080d --- /dev/null +++ b/frontend/src/Helpers/dragTypes.js @@ -0,0 +1,3 @@ +export const QUALITY_PROFILE_ITEM = 'qualityProfileItem'; +export const DELAY_PROFILE = 'delayProfile'; +export const TABLE_COLUMN = 'tableColumn'; diff --git a/frontend/src/Helpers/elementChildren.js b/frontend/src/Helpers/elementChildren.js new file mode 100644 index 000000000..1c10b2f0e --- /dev/null +++ b/frontend/src/Helpers/elementChildren.js @@ -0,0 +1,149 @@ +// https://github.com/react-bootstrap/react-element-children + +import React from 'react'; + +/** + * Iterates through children that are typically specified as `props.children`, + * but only maps over children that are "valid components". + * + * The mapFunction provided index will be normalised to the components mapped, + * so an invalid component would not increase the index. + * + * @param {?*} children Children tree container. + * @param {function(*, int)} func. + * @param {*} context Context for func. + * @return {object} Object containing the ordered map of results. + */ +export function map(children, func, context) { + let index = 0; + + return React.Children.map(children, (child) => { + if (!React.isValidElement(child)) { + return child; + } + + return func.call(context, child, index++); + }); +} + +/** + * Iterates through children that are "valid components". + * + * The provided forEachFunc(child, index) will be called for each + * leaf child with the index reflecting the position relative to "valid components". + * + * @param {?*} children Children tree container. + * @param {function(*, int)} func. + * @param {*} context Context for context. + */ +export function forEach(children, func, context) { + let index = 0; + + React.Children.forEach(children, (child) => { + if (!React.isValidElement(child)) { + return; + } + + func.call(context, child, index++); + }); +} + +/** + * Count the number of "valid components" in the Children container. + * + * @param {?*} children Children tree container. + * @returns {number} + */ +export function count(children) { + let result = 0; + + React.Children.forEach(children, (child) => { + if (!React.isValidElement(child)) { + return; + } + + ++result; + }); + + return result; +} + +/** + * Finds children that are typically specified as `props.children`, + * but only iterates over children that are "valid components". + * + * The provided forEachFunc(child, index) will be called for each + * leaf child with the index reflecting the position relative to "valid components". + * + * @param {?*} children Children tree container. + * @param {function(*, int)} func. + * @param {*} context Context for func. + * @returns {array} of children that meet the func return statement + */ +export function filter(children, func, context) { + const result = []; + + forEach(children, (child, index) => { + if (func.call(context, child, index)) { + result.push(child); + } + }); + + return result; +} + +export function find(children, func, context) { + let result = null; + + forEach(children, (child, index) => { + if (result) { + return; + } + if (func.call(context, child, index)) { + result = child; + } + }); + + return result; +} + +export function every(children, func, context) { + let result = true; + + forEach(children, (child, index) => { + if (!result) { + return; + } + if (!func.call(context, child, index)) { + result = false; + } + }); + + return result; +} + +export function some(children, func, context) { + let result = false; + + forEach(children, (child, index) => { + if (result) { + return; + } + + if (func.call(context, child, index)) { + result = true; + } + }); + + return result; +} + +export function toArray(children) { + const result = []; + + forEach(children, (child) => { + result.push(child); + }); + + return result; +} diff --git a/frontend/src/Helpers/getDisplayName.js b/frontend/src/Helpers/getDisplayName.js new file mode 100644 index 000000000..512702c87 --- /dev/null +++ b/frontend/src/Helpers/getDisplayName.js @@ -0,0 +1,3 @@ +export default function getDisplayName(Component) { + return Component.displayName || Component.name || 'Component'; +} diff --git a/frontend/src/InteractiveImport/Album/SelectAlbumModal.js b/frontend/src/InteractiveImport/Album/SelectAlbumModal.js new file mode 100644 index 000000000..d4f26f4ff --- /dev/null +++ b/frontend/src/InteractiveImport/Album/SelectAlbumModal.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import SelectAlbumModalContentConnector from './SelectAlbumModalContentConnector'; + +class SelectAlbumModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +SelectAlbumModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectAlbumModal; diff --git a/frontend/src/InteractiveImport/Album/SelectAlbumModalContent.css b/frontend/src/InteractiveImport/Album/SelectAlbumModalContent.css new file mode 100644 index 000000000..54f67bb07 --- /dev/null +++ b/frontend/src/InteractiveImport/Album/SelectAlbumModalContent.css @@ -0,0 +1,18 @@ +.modalBody { + composes: modalBody from '~Components/Modal/ModalBody.css'; + + display: flex; + flex: 1 1 auto; + flex-direction: column; +} + +.filterInput { + composes: input from '~Components/Form/TextInput.css'; + + flex: 0 0 auto; + margin-bottom: 20px; +} + +.scroller { + flex: 1 1 auto; +} diff --git a/frontend/src/InteractiveImport/Album/SelectAlbumModalContent.js b/frontend/src/InteractiveImport/Album/SelectAlbumModalContent.js new file mode 100644 index 000000000..20115214e --- /dev/null +++ b/frontend/src/InteractiveImport/Album/SelectAlbumModalContent.js @@ -0,0 +1,141 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Button from 'Components/Link/Button'; +import { scrollDirections } from 'Helpers/Props'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import Scroller from 'Components/Scroller/Scroller'; +import TextInput from 'Components/Form/TextInput'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import SelectAlbumRow from './SelectAlbumRow'; +import styles from './SelectAlbumModalContent.css'; + +const columns = [ + { + name: 'title', + label: 'Album Title', + isVisible: true + }, + { + name: 'albumType', + label: 'Album Type', + isVisible: true + }, + { + name: 'releaseDate', + label: 'Release Date', + isVisible: true + }, + { + name: 'status', + label: 'Album Status', + isVisible: true + } +]; + +class SelectAlbumModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + filter: '' + }; + } + + // + // Listeners + + onFilterChange = ({ value }) => { + this.setState({ filter: value.toLowerCase() }); + } + + // + // Render + + render() { + const { + items, + onAlbumSelect, + onModalClose, + isFetching, + ...otherProps + } = this.props; + + const filter = this.state.filter; + + return ( + + + Manual Import - Select Album + + + + { + isFetching && + + } + + + + { + + + { + items.map((item) => { + return item.title.toLowerCase().includes(filter) ? + ( + + ) : + null; + }) + } + +
+ } +
+
+ + + + +
+ ); + } +} + +SelectAlbumModalContent.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + isFetching: PropTypes.bool.isRequired, + onAlbumSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectAlbumModalContent; diff --git a/frontend/src/InteractiveImport/Album/SelectAlbumModalContentConnector.js b/frontend/src/InteractiveImport/Album/SelectAlbumModalContentConnector.js new file mode 100644 index 000000000..6302df334 --- /dev/null +++ b/frontend/src/InteractiveImport/Album/SelectAlbumModalContentConnector.js @@ -0,0 +1,104 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { + updateInteractiveImportItem, + saveInteractiveImportItem, + fetchInteractiveImportAlbums, + setInteractiveImportAlbumsSort, + clearInteractiveImportAlbums +} from 'Store/Actions/interactiveImportActions'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import SelectAlbumModalContent from './SelectAlbumModalContent'; + +function createMapStateToProps() { + return createSelector( + createClientSideCollectionSelector('interactiveImport.albums'), + (albums) => { + return albums; + } + ); +} + +const mapDispatchToProps = { + fetchInteractiveImportAlbums, + setInteractiveImportAlbumsSort, + clearInteractiveImportAlbums, + updateInteractiveImportItem, + saveInteractiveImportItem +}; + +class SelectAlbumModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + artistId + } = this.props; + + this.props.fetchInteractiveImportAlbums({ artistId }); + } + + componentWillUnmount() { + // This clears the albums for the queue and hides the queue + // We'll need another place to store albums for manual import + this.props.clearInteractiveImportAlbums(); + } + + // + // Listeners + + onSortPress = (sortKey, sortDirection) => { + this.props.setInteractiveImportAlbumsSort({ sortKey, sortDirection }); + } + + onAlbumSelect = (albumId) => { + const album = _.find(this.props.items, { id: albumId }); + + const ids = this.props.ids; + + ids.forEach((id) => { + this.props.updateInteractiveImportItem({ + id, + album, + albumReleaseId: undefined, + tracks: [], + rejections: [] + }); + }); + + this.props.saveInteractiveImportItem({ id: ids }); + + this.props.onModalClose(true); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SelectAlbumModalContentConnector.propTypes = { + ids: PropTypes.arrayOf(PropTypes.number).isRequired, + artistId: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchInteractiveImportAlbums: PropTypes.func.isRequired, + setInteractiveImportAlbumsSort: PropTypes.func.isRequired, + clearInteractiveImportAlbums: PropTypes.func.isRequired, + saveInteractiveImportItem: PropTypes.func.isRequired, + updateInteractiveImportItem: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SelectAlbumModalContentConnector); diff --git a/frontend/src/InteractiveImport/Album/SelectAlbumRow.css b/frontend/src/InteractiveImport/Album/SelectAlbumRow.css new file mode 100644 index 000000000..e78f0bc19 --- /dev/null +++ b/frontend/src/InteractiveImport/Album/SelectAlbumRow.css @@ -0,0 +1,3 @@ +.albumRow { + cursor: pointer; +} diff --git a/frontend/src/InteractiveImport/Album/SelectAlbumRow.js b/frontend/src/InteractiveImport/Album/SelectAlbumRow.js new file mode 100644 index 000000000..d3f69b057 --- /dev/null +++ b/frontend/src/InteractiveImport/Album/SelectAlbumRow.js @@ -0,0 +1,140 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds, sizes } from 'Helpers/Props'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import Label from 'Components/Label'; +import styles from './SelectAlbumRow.css'; + +function getTrackCountKind(monitored, trackFileCount, trackCount) { + if (trackFileCount === trackCount && trackCount > 0) { + return kinds.SUCCESS; + } + + if (!monitored) { + return kinds.WARNING; + } + + return kinds.DANGER; +} + +class SelectAlbumRow extends Component { + + // + // Listeners + + onPress = () => { + this.props.onAlbumSelect(this.props.id); + } + + // + // Render + + render() { + const { + title, + disambiguation, + albumType, + releaseDate, + statistics, + monitored, + columns + } = this.props; + + const { + trackCount, + trackFileCount, + totalTrackCount + } = statistics; + + const extendedTitle = disambiguation ? `${title} (${disambiguation})` : title; + + return ( + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'title') { + return ( + + {extendedTitle} + + ); + } + + if (name === 'albumType') { + return ( + + {albumType} + + ); + } + + if (name === 'releaseDate') { + return ( + + ); + } + + if (name === 'status') { + return ( + + + + ); + } + + return null; + }) + } + + + ); + } +} + +SelectAlbumRow.propTypes = { + id: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + disambiguation: PropTypes.string.isRequired, + albumType: PropTypes.string.isRequired, + releaseDate: PropTypes.string.isRequired, + onAlbumSelect: PropTypes.func.isRequired, + statistics: PropTypes.object.isRequired, + monitored: PropTypes.bool.isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +SelectAlbumRow.defaultProps = { + statistics: { + trackCount: 0, + trackFileCount: 0 + } +}; + +export default SelectAlbumRow; diff --git a/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModal.js b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModal.js new file mode 100644 index 000000000..f3789d9dd --- /dev/null +++ b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModal.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import SelectAlbumReleaseModalContentConnector from './SelectAlbumReleaseModalContentConnector'; + +class SelectAlbumReleaseModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +SelectAlbumReleaseModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectAlbumReleaseModal; diff --git a/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModalContent.css b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModalContent.css new file mode 100644 index 000000000..54f67bb07 --- /dev/null +++ b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModalContent.css @@ -0,0 +1,18 @@ +.modalBody { + composes: modalBody from '~Components/Modal/ModalBody.css'; + + display: flex; + flex: 1 1 auto; + flex-direction: column; +} + +.filterInput { + composes: input from '~Components/Form/TextInput.css'; + + flex: 0 0 auto; + margin-bottom: 20px; +} + +.scroller { + flex: 1 1 auto; +} diff --git a/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModalContent.js b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModalContent.js new file mode 100644 index 000000000..5c87f982e --- /dev/null +++ b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModalContent.js @@ -0,0 +1,93 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Button from 'Components/Link/Button'; +import { scrollDirections } from 'Helpers/Props'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import SelectAlbumReleaseRow from './SelectAlbumReleaseRow'; +import Alert from 'Components/Alert'; +import styles from './SelectAlbumReleaseModalContent.css'; + +const columns = [ + { + name: 'album', + label: 'Album', + isVisible: true + }, + { + name: 'release', + label: 'Album Release', + isVisible: true + } +]; + +class SelectAlbumReleaseModalContent extends Component { + + // + // Render + + render() { + const { + albums, + onAlbumReleaseSelect, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + Manual Import - Select Album Release + + + + + Overrriding a release here will disable automatic release selection for that album in future. + + + + + { + albums.map((item) => { + return ( + + ); + }) + } + +
+
+ + + + +
+ ); + } +} + +SelectAlbumReleaseModalContent.propTypes = { + albums: PropTypes.arrayOf(PropTypes.object).isRequired, + onAlbumReleaseSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectAlbumReleaseModalContent; diff --git a/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModalContentConnector.js b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModalContentConnector.js new file mode 100644 index 000000000..f308b03ce --- /dev/null +++ b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseModalContentConnector.js @@ -0,0 +1,67 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { + updateInteractiveImportItem, + saveInteractiveImportItem +} from 'Store/Actions/interactiveImportActions'; +import SelectAlbumReleaseModalContent from './SelectAlbumReleaseModalContent'; + +function createMapStateToProps() { + return {}; +} + +const mapDispatchToProps = { + updateInteractiveImportItem, + saveInteractiveImportItem +}; + +class SelectAlbumReleaseModalContentConnector extends Component { + + // + // Listeners + + // onSortPress = (sortKey, sortDirection) => { + // this.props.setInteractiveImportAlbumsSort({ sortKey, sortDirection }); + // } + + onAlbumReleaseSelect = (albumId, albumReleaseId) => { + const ids = this.props.importIdsByAlbum[albumId]; + + ids.forEach((id) => { + this.props.updateInteractiveImportItem({ + id, + albumReleaseId, + disableReleaseSwitching: true, + tracks: [], + rejections: [] + }); + }); + + this.props.saveInteractiveImportItem({ id: ids }); + + this.props.onModalClose(true); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SelectAlbumReleaseModalContentConnector.propTypes = { + importIdsByAlbum: PropTypes.object.isRequired, + albums: PropTypes.arrayOf(PropTypes.object).isRequired, + updateInteractiveImportItem: PropTypes.func.isRequired, + saveInteractiveImportItem: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SelectAlbumReleaseModalContentConnector); diff --git a/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseRow.css b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseRow.css new file mode 100644 index 000000000..e78f0bc19 --- /dev/null +++ b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseRow.css @@ -0,0 +1,3 @@ +.albumRow { + cursor: pointer; +} diff --git a/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseRow.js b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseRow.js new file mode 100644 index 000000000..786ea0f83 --- /dev/null +++ b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseRow.js @@ -0,0 +1,96 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import titleCase from 'Utilities/String/titleCase'; + +class SelectAlbumReleaseRow extends Component { + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.onAlbumReleaseSelect(parseInt(name), parseInt(value)); + } + + // + // Render + + render() { + const { + id, + matchedReleaseId, + title, + disambiguation, + releases, + columns + } = this.props; + + const extendedTitle = disambiguation ? `${title} (${disambiguation})` : title; + + return ( + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'album') { + return ( + + {extendedTitle} + + ); + } + + if (name === 'release') { + return ( + + ({ + key: r.id, + value: `${r.title}` + + `${r.disambiguation ? ' (' : ''}${titleCase(r.disambiguation)}${r.disambiguation ? ')' : ''}` + + `, ${r.mediumCount} med, ${r.trackCount} tracks` + + `${r.country.length > 0 ? ', ' : ''}${r.country}` + + `${r.format ? ', [' : ''}${r.format}${r.format ? ']' : ''}` + + `${r.monitored ? ', Monitored' : ''}` + }))} + value={matchedReleaseId} + onChange={this.onInputChange} + /> + + ); + } + + return null; + }) + } + + + ); + } +} + +SelectAlbumReleaseRow.propTypes = { + id: PropTypes.number.isRequired, + matchedReleaseId: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + disambiguation: PropTypes.string.isRequired, + releases: PropTypes.arrayOf(PropTypes.object).isRequired, + onAlbumReleaseSelect: PropTypes.func.isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default SelectAlbumReleaseRow; diff --git a/frontend/src/InteractiveImport/Artist/SelectArtistModal.js b/frontend/src/InteractiveImport/Artist/SelectArtistModal.js new file mode 100644 index 000000000..39dd67300 --- /dev/null +++ b/frontend/src/InteractiveImport/Artist/SelectArtistModal.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import SelectArtistModalContentConnector from './SelectArtistModalContentConnector'; + +class SelectArtistModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +SelectArtistModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectArtistModal; diff --git a/frontend/src/InteractiveImport/Artist/SelectArtistModalContent.css b/frontend/src/InteractiveImport/Artist/SelectArtistModalContent.css new file mode 100644 index 000000000..54f67bb07 --- /dev/null +++ b/frontend/src/InteractiveImport/Artist/SelectArtistModalContent.css @@ -0,0 +1,18 @@ +.modalBody { + composes: modalBody from '~Components/Modal/ModalBody.css'; + + display: flex; + flex: 1 1 auto; + flex-direction: column; +} + +.filterInput { + composes: input from '~Components/Form/TextInput.css'; + + flex: 0 0 auto; + margin-bottom: 20px; +} + +.scroller { + flex: 1 1 auto; +} diff --git a/frontend/src/InteractiveImport/Artist/SelectArtistModalContent.js b/frontend/src/InteractiveImport/Artist/SelectArtistModalContent.js new file mode 100644 index 000000000..b180d319b --- /dev/null +++ b/frontend/src/InteractiveImport/Artist/SelectArtistModalContent.js @@ -0,0 +1,99 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { scrollDirections } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Scroller from 'Components/Scroller/Scroller'; +import TextInput from 'Components/Form/TextInput'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import SelectArtistRow from './SelectArtistRow'; +import styles from './SelectArtistModalContent.css'; + +class SelectArtistModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + filter: '' + }; + } + + // + // Listeners + + onFilterChange = ({ value }) => { + this.setState({ filter: value.toLowerCase() }); + } + + // + // Render + + render() { + const { + items, + onArtistSelect, + onModalClose + } = this.props; + + const filter = this.state.filter; + + return ( + + + Manual Import - Select Artist + + + + + + + { + items.map((item) => { + return item.artistName.toLowerCase().includes(filter) ? + ( + + ) : + null; + }) + } + + + + + + + + ); + } +} + +SelectArtistModalContent.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onArtistSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectArtistModalContent; diff --git a/frontend/src/InteractiveImport/Artist/SelectArtistModalContentConnector.js b/frontend/src/InteractiveImport/Artist/SelectArtistModalContentConnector.js new file mode 100644 index 000000000..19a6002c9 --- /dev/null +++ b/frontend/src/InteractiveImport/Artist/SelectArtistModalContentConnector.js @@ -0,0 +1,83 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { updateInteractiveImportItem, saveInteractiveImportItem } from 'Store/Actions/interactiveImportActions'; +import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; +import SelectArtistModalContent from './SelectArtistModalContent'; + +function createMapStateToProps() { + return createSelector( + createAllArtistSelector(), + (items) => { + return { + items: items.sort((a, b) => { + if (a.sortName < b.sortName) { + return -1; + } + + if (a.sortName > b.sortName) { + return 1; + } + + return 0; + }) + }; + } + ); +} + +const mapDispatchToProps = { + updateInteractiveImportItem, + saveInteractiveImportItem +}; + +class SelectArtistModalContentConnector extends Component { + + // + // Listeners + + onArtistSelect = (artistId) => { + const artist = _.find(this.props.items, { id: artistId }); + + const ids = this.props.ids; + + ids.forEach((id) => { + this.props.updateInteractiveImportItem({ + id, + artist, + album: undefined, + albumReleaseId: undefined, + tracks: [], + rejections: [] + }); + }); + + this.props.saveInteractiveImportItem({ id: ids }); + + this.props.onModalClose(true); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SelectArtistModalContentConnector.propTypes = { + ids: PropTypes.arrayOf(PropTypes.number).isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + saveInteractiveImportItem: PropTypes.func.isRequired, + updateInteractiveImportItem: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SelectArtistModalContentConnector); diff --git a/frontend/src/InteractiveImport/Artist/SelectArtistRow.css b/frontend/src/InteractiveImport/Artist/SelectArtistRow.css new file mode 100644 index 000000000..376c3fe84 --- /dev/null +++ b/frontend/src/InteractiveImport/Artist/SelectArtistRow.css @@ -0,0 +1,4 @@ +.artist { + padding: 8px; + border-bottom: 1px solid $borderColor; +} diff --git a/frontend/src/InteractiveImport/Artist/SelectArtistRow.js b/frontend/src/InteractiveImport/Artist/SelectArtistRow.js new file mode 100644 index 000000000..dcf252bb6 --- /dev/null +++ b/frontend/src/InteractiveImport/Artist/SelectArtistRow.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import styles from './SelectArtistRow.css'; + +class SelectArtistRow extends Component { + + // + // Listeners + + onPress = () => { + this.props.onArtistSelect(this.props.id); + } + + // + // Render + + render() { + return ( + + {this.props.artistName} + + ); + } +} + +SelectArtistRow.propTypes = { + id: PropTypes.number.isRequired, + artistName: PropTypes.string.isRequired, + onArtistSelect: PropTypes.func.isRequired +}; + +export default SelectArtistRow; diff --git a/frontend/src/InteractiveImport/Confirmation/ConfirmImportModal.js b/frontend/src/InteractiveImport/Confirmation/ConfirmImportModal.js new file mode 100644 index 000000000..e002b2de9 --- /dev/null +++ b/frontend/src/InteractiveImport/Confirmation/ConfirmImportModal.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import ConfirmImportModalContentConnector from './ConfirmImportModalContentConnector'; + +class ConfirmImportModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +ConfirmImportModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default ConfirmImportModal; diff --git a/frontend/src/InteractiveImport/Confirmation/ConfirmImportModalContent.js b/frontend/src/InteractiveImport/Confirmation/ConfirmImportModalContent.js new file mode 100644 index 000000000..c91aa333b --- /dev/null +++ b/frontend/src/InteractiveImport/Confirmation/ConfirmImportModalContent.js @@ -0,0 +1,135 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import { kinds } from 'Helpers/Props'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Alert from 'Components/Alert'; + +function formatAlbumFiles(items, album) { + + return ( +
+ {album.title} +
    + { + _.sortBy(items, 'path').map((item) => { + return ( +
  • + {item.path} +
  • + ); + }) + } +
+
+ ); + +} + +class ConfirmImportModalContent extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps) { + const { + items, + isFetching, + isPopulated + } = this.props; + + if (!isFetching && isPopulated && !items.length) { + this.props.onModalClose(); + this.props.onConfirmImportPress(); + } + } + + // + // Render + + render() { + const { + albums, + items, + onConfirmImportPress, + onModalClose, + isFetching, + isPopulated + } = this.props; + + // don't render if nothing to do + if (!isFetching && isPopulated && !items.length) { + return null; + } + + return ( + + + { + !isFetching && isPopulated && + + Are you sure? + + } + + + { + isFetching && + + } + + { + !isFetching && isPopulated && +
+ + You already have files imported for the albums listed below. If you continue, the existing files will be deleted and the new files imported in their place. + + To avoid deleting existing files, press 'Cancel' and use the 'Combine with existing files' option. + + + { _.chain(items) + .groupBy('albumId') + .mapValues((value, key) => formatAlbumFiles(value, _.find(albums, (a) => a.id === parseInt(key)))) + .values() + .value() } +
+ } +
+ + { + !isFetching && isPopulated && + + + + + + + } + +
+ ); + } +} + +ConfirmImportModalContent.propTypes = { + albums: PropTypes.arrayOf(PropTypes.object).isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + onConfirmImportPress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default ConfirmImportModalContent; diff --git a/frontend/src/InteractiveImport/Confirmation/ConfirmImportModalContentConnector.js b/frontend/src/InteractiveImport/Confirmation/ConfirmImportModalContentConnector.js new file mode 100644 index 000000000..dab76fb33 --- /dev/null +++ b/frontend/src/InteractiveImport/Confirmation/ConfirmImportModalContentConnector.js @@ -0,0 +1,60 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchInteractiveImportTrackFiles, clearInteractiveImportTrackFiles } from 'Store/Actions/interactiveImportActions'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import ConfirmImportModalContent from './ConfirmImportModalContent'; + +function createMapStateToProps() { + return createSelector( + createClientSideCollectionSelector('interactiveImport.trackFiles'), + (trackFiles) => { + return trackFiles; + } + ); +} + +const mapDispatchToProps = { + fetchInteractiveImportTrackFiles, + clearInteractiveImportTrackFiles +}; + +class ConfirmImportModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + albums + } = this.props; + + this.props.fetchInteractiveImportTrackFiles({ albumId: albums.map((x) => x.id) }); + } + + componentWillUnmount() { + this.props.clearInteractiveImportTrackFiles(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ConfirmImportModalContentConnector.propTypes = { + albums: PropTypes.arrayOf(PropTypes.object).isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchInteractiveImportTrackFiles: PropTypes.func.isRequired, + clearInteractiveImportTrackFiles: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ConfirmImportModalContentConnector); diff --git a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.css b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.css new file mode 100644 index 000000000..5f9033a18 --- /dev/null +++ b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.css @@ -0,0 +1,24 @@ +.recentFoldersContainer { + margin-top: 15px; +} + +.buttonsContainer { + margin-top: 30px; +} + +.buttonContainer { + display: flex; + justify-content: center; + + margin-top: 10px; +} + +.button { + composes: button from '~Components/Link/Button.css'; + + width: 300px; +} + +.buttonIcon { + margin-right: 5px; +} diff --git a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.js b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.js new file mode 100644 index 000000000..78df1f53e --- /dev/null +++ b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.js @@ -0,0 +1,168 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Icon from 'Components/Icon'; +import PathInputConnector from 'Components/Form/PathInputConnector'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import RecentFolderRow from './RecentFolderRow'; +import styles from './InteractiveImportSelectFolderModalContent.css'; + +const recentFoldersColumns = [ + { + name: 'folder', + label: 'Folder' + }, + { + name: 'lastUsed', + label: 'Last Used' + }, + { + name: 'actions', + label: '' + } +]; + +class InteractiveImportSelectFolderModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + folder: '' + }; + } + + // + // Listeners + + onPathChange = ({ value }) => { + this.setState({ folder: value }); + } + + onRecentPathPress = (folder) => { + this.setState({ folder }); + } + + onQuickImportPress = () => { + this.props.onQuickImportPress(this.state.folder); + } + + onInteractiveImportPress = () => { + this.props.onInteractiveImportPress(this.state.folder); + } + + // + // Render + + render() { + const { + recentFolders, + onRemoveRecentFolderPress, + onModalClose + } = this.props; + + const folder = this.state.folder; + + return ( + + + Manual Import - Select Folder + + + + + + { + !!recentFolders.length && +
+ + + { + recentFolders.map((recentFolder) => { + return ( + + ); + }) + } + +
+
+ } + +
+
+ +
+ +
+ +
+
+
+ + + + +
+ ); + } +} + +InteractiveImportSelectFolderModalContent.propTypes = { + recentFolders: PropTypes.arrayOf(PropTypes.object).isRequired, + onQuickImportPress: PropTypes.func.isRequired, + onInteractiveImportPress: PropTypes.func.isRequired, + onRemoveRecentFolderPress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default InteractiveImportSelectFolderModalContent; diff --git a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContentConnector.js b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContentConnector.js new file mode 100644 index 000000000..8a6c58fb0 --- /dev/null +++ b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContentConnector.js @@ -0,0 +1,80 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { addRecentFolder, removeRecentFolder } from 'Store/Actions/interactiveImportActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import InteractiveImportSelectFolderModalContent from './InteractiveImportSelectFolderModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.interactiveImport.recentFolders, + (recentFolders) => { + return { + recentFolders + }; + } + ); +} + +const mapDispatchToProps = { + addRecentFolder, + removeRecentFolder, + executeCommand +}; + +class InteractiveImportSelectFolderModalContentConnector extends Component { + + // + // Listeners + + onQuickImportPress = (folder) => { + this.props.addRecentFolder({ folder }); + + this.props.executeCommand({ + name: commandNames.DOWNLOADED_ALBUMS_SCAN, + path: folder + }); + + this.props.onModalClose(); + } + + onInteractiveImportPress = (folder) => { + this.props.addRecentFolder({ folder }); + this.props.onFolderSelect(folder); + } + + onRemoveRecentFolderPress = (folder) => { + this.props.removeRecentFolder({ folder }); + } + + // + // Render + + render() { + if (this.path) { + return null; + } + + return ( + + ); + } +} + +InteractiveImportSelectFolderModalContentConnector.propTypes = { + path: PropTypes.string, + onFolderSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + addRecentFolder: PropTypes.func.isRequired, + removeRecentFolder: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(InteractiveImportSelectFolderModalContentConnector); diff --git a/frontend/src/InteractiveImport/Folder/RecentFolderRow.css b/frontend/src/InteractiveImport/Folder/RecentFolderRow.css new file mode 100644 index 000000000..58eb9a8e4 --- /dev/null +++ b/frontend/src/InteractiveImport/Folder/RecentFolderRow.css @@ -0,0 +1,5 @@ +.actions { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 40px; +} diff --git a/frontend/src/InteractiveImport/Folder/RecentFolderRow.js b/frontend/src/InteractiveImport/Folder/RecentFolderRow.js new file mode 100644 index 000000000..403bce33d --- /dev/null +++ b/frontend/src/InteractiveImport/Folder/RecentFolderRow.js @@ -0,0 +1,64 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import TableRowButton from 'Components/Table/TableRowButton'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import styles from './RecentFolderRow.css'; + +class RecentFolderRow extends Component { + + // + // Listeners + + onPress = () => { + this.props.onPress(this.props.folder); + } + + onRemovePress = (event) => { + event.stopPropagation(); + + const { + folder, + onRemoveRecentFolderPress + } = this.props; + + onRemoveRecentFolderPress(folder); + } + + // + // Render + + render() { + const { + folder, + lastUsed + } = this.props; + + return ( + + {folder} + + + + + + + + ); + } +} + +RecentFolderRow.propTypes = { + folder: PropTypes.string.isRequired, + lastUsed: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired, + onRemoveRecentFolderPress: PropTypes.func.isRequired +}; + +export default RecentFolderRow; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css new file mode 100644 index 000000000..d50f3a261 --- /dev/null +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css @@ -0,0 +1,65 @@ +.filterContainer { + display: flex; + justify-content: flex-end; + margin-bottom: 10px; +} + +.filterText { + margin-left: 5px; +} + +.footer { + composes: modalFooter from '~Components/Modal/ModalFooter.css'; + + justify-content: space-between; + padding: 15px; +} + +.leftButtons, +.rightButtons { + display: flex; + flex: 1 0 50%; + flex-wrap: wrap; +} + +.rightButtons { + justify-content: flex-end; +} + +.importMode, +.bulkSelect { + composes: select from '~Components/Form/SelectInput.css'; + + margin-right: 10px; + width: auto; +} + +.errorMessage { + color: $dangerColor; +} + +@media only screen and (max-width: $breakpointSmall) { + .footer { + .leftButtons, + .rightButtons { + flex-direction: column; + } + + .leftButtons { + align-items: flex-start; + } + + .rightButtons { + align-items: flex-end; + } + + a, + button { + margin-left: 0; + + &:first-child { + margin-bottom: 5px; + } + } + } +} diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js new file mode 100644 index 000000000..1edac2b7c --- /dev/null +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js @@ -0,0 +1,571 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { align, icons, kinds, scrollDirections } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Icon from 'Components/Icon'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import SelectInput from 'Components/Form/SelectInput'; +import Menu from 'Components/Menu/Menu'; +import MenuButton from 'Components/Menu/MenuButton'; +import MenuContent from 'Components/Menu/MenuContent'; +import SelectedMenuItem from 'Components/Menu/SelectedMenuItem'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal'; +import SelectArtistModal from 'InteractiveImport/Artist/SelectArtistModal'; +import SelectAlbumModal from 'InteractiveImport/Album/SelectAlbumModal'; +import SelectAlbumReleaseModal from 'InteractiveImport/AlbumRelease/SelectAlbumReleaseModal'; +import ConfirmImportModal from 'InteractiveImport/Confirmation/ConfirmImportModal'; +import InteractiveImportRow from './InteractiveImportRow'; +import styles from './InteractiveImportModalContent.css'; + +const columns = [ + { + name: 'relativePath', + label: 'Relative Path', + isSortable: true, + isVisible: true + }, + { + name: 'artist', + label: 'Artist', + isSortable: true, + isVisible: true + }, + { + name: 'album', + label: 'Album', + isVisible: true + }, + { + name: 'tracks', + label: 'Track(s)', + isVisible: true + }, + { + name: 'quality', + label: 'Quality', + isSortable: true, + isVisible: true + }, + { + name: 'size', + label: 'Size', + isVisible: true + }, + { + name: 'rejections', + label: React.createElement(Icon, { + name: icons.DANGER, + kind: kinds.DANGER + }), + isVisible: true + } +]; + +const filterExistingFilesOptions = { + ALL: 'all', + NEW: 'new' +}; + +const importModeOptions = [ + { key: 'move', value: 'Move Files' }, + { key: 'copy', value: 'Hardlink/Copy Files' } +]; + +const SELECT = 'select'; +const ARTIST = 'artist'; +const ALBUM = 'album'; +const ALBUM_RELEASE = 'albumRelease'; +const QUALITY = 'quality'; + +const replaceExistingFilesOptions = { + COMBINE: 'combine', + DELETE: 'delete' +}; + +class InteractiveImportModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, + invalidRowsSelected: [], + selectModalOpen: null, + albumsImported: [], + isConfirmImportModalOpen: false, + showClearTracks: false, + inconsistentAlbumReleases: false + }; + } + + componentDidUpdate(prevProps) { + const selectedIds = this.getSelectedIds(); + const selectedItems = _.filter(this.props.items, (x) => _.includes(selectedIds, x.id)); + const selectionHasTracks = _.some(selectedItems, (x) => x.tracks.length); + + if (this.state.showClearTracks !== selectionHasTracks) { + this.setState({ showClearTracks: selectionHasTracks }); + } + + const inconsistent = _(selectedItems) + .map((x) => ({ albumId: x.album ? x.album.id : 0, releaseId: x.albumReleaseId })) + .groupBy('albumId') + .mapValues((album) => _(album).groupBy((x) => x.releaseId).values().value().length) + .values() + .some((x) => x !== undefined && x > 1); + + if (inconsistent !== this.state.inconsistentAlbumReleases) { + this.setState({ inconsistentAlbumReleases: inconsistent }); + } + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onValidRowChange = (id, isValid) => { + this.setState((state, props) => { + // make sure to exclude any invalidRows that are no longer present in props + const diff = _.difference(state.invalidRowsSelected, _.map(props.items, 'id')); + const currentInvalid = _.difference(state.invalidRowsSelected, diff); + const newstate = isValid ? _.without(currentInvalid, id) : _.union(currentInvalid, [id]); + return { invalidRowsSelected: newstate }; + }); + } + + onImportSelectedPress = () => { + if (!this.props.replaceExistingFiles) { + this.onConfirmImportPress(); + return; + } + + // potentially deleting files + const selectedIds = this.getSelectedIds(); + const albumsImported = _(this.props.items) + .filter((x) => _.includes(selectedIds, x.id)) + .keyBy((x) => x.album.id) + .map((x) => x.album) + .value(); + + console.log(albumsImported); + + this.setState({ + albumsImported, + isConfirmImportModalOpen: true + }); + } + + onConfirmImportPress = () => { + const { + downloadId, + showImportMode, + importMode, + onImportSelectedPress + } = this.props; + + const selected = this.getSelectedIds(); + const finalImportMode = downloadId || !showImportMode ? 'auto' : importMode; + + onImportSelectedPress(selected, finalImportMode); + } + + onFilterExistingFilesChange = (value) => { + this.props.onFilterExistingFilesChange(value !== filterExistingFilesOptions.ALL); + } + + onReplaceExistingFilesChange = (value) => { + this.props.onReplaceExistingFilesChange(value === replaceExistingFilesOptions.DELETE); + } + + onImportModeChange = ({ value }) => { + this.props.onImportModeChange(value); + } + + onSelectModalSelect = ({ value }) => { + this.setState({ selectModalOpen: value }); + } + + onClearTrackMappingPress = () => { + const selectedIds = this.getSelectedIds(); + + selectedIds.forEach((id) => { + this.props.updateInteractiveImportItem({ + id, + tracks: [], + rejections: [] + }); + }); + } + + onGetTrackMappingPress = () => { + this.props.saveInteractiveImportItem({ id: this.getSelectedIds() }); + } + + onSelectModalClose = () => { + this.setState({ selectModalOpen: null }); + } + + onConfirmImportModalClose = () => { + this.setState({ isConfirmImportModalOpen: false }); + } + + // + // Render + + render() { + const { + downloadId, + allowArtistChange, + showFilterExistingFiles, + showReplaceExistingFiles, + showImportMode, + filterExistingFiles, + replaceExistingFiles, + title, + folder, + isFetching, + isPopulated, + isSaving, + error, + items, + sortKey, + sortDirection, + importMode, + interactiveImportErrorMessage, + onSortPress, + onModalClose + } = this.props; + + const { + allSelected, + allUnselected, + selectedState, + invalidRowsSelected, + selectModalOpen, + albumsImported, + isConfirmImportModalOpen, + showClearTracks, + inconsistentAlbumReleases + } = this.state; + + const selectedIds = this.getSelectedIds(); + const selectedItem = selectedIds.length ? _.find(items, { id: selectedIds[0] }) : null; + const errorMessage = getErrorMessage(error, 'Unable to load manual import items'); + + const bulkSelectOptions = [ + { key: SELECT, value: 'Select...', disabled: true }, + { key: ALBUM, value: 'Select Album' }, + { key: ALBUM_RELEASE, value: 'Select Album Release' }, + { key: QUALITY, value: 'Select Quality' } + ]; + + if (allowArtistChange) { + bulkSelectOptions.splice(1, 0, { + key: ARTIST, + value: 'Select Artist' + }); + } + + return ( + + + Manual Import - {title || folder} + + + +
+ { + showFilterExistingFiles && + + + + +
+ { + filterExistingFiles ? 'Unmapped Files Only' : 'All Files' + } +
+
+ + + + All Files + + + + Unmapped Files Only + + +
+ } + { + showReplaceExistingFiles && + + + + +
+ { + replaceExistingFiles ? 'Existing files will be deleted' : 'Combine with existing files' + } +
+
+ + + + Combine With Existing Files + + + + Replace Existing Files + + +
+ } +
+ + { + isFetching && + + } + + { + error && +
{errorMessage}
+ } + + { + isPopulated && !!items.length && !isFetching && !isFetching && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ } + + { + isPopulated && !items.length && !isFetching && + 'No audio files were found in the selected folder' + } +
+ + +
+ { + !downloadId && showImportMode ? + : + null + } + + + + { + showClearTracks ? ( + + ) : ( + + ) + } +
+ +
+ + + { + interactiveImportErrorMessage && + {interactiveImportErrorMessage} + } + + +
+
+ + + + + + x.album).groupBy((x) => x.album.id).mapValues((x) => x.map((y) => y.id)).value()} + albums={_.chain(items).filter((x) => x.album).keyBy((x) => x.album.id).mapValues((x) => ({ matchedReleaseId: x.albumReleaseId, album: x.album })).values().value()} + onModalClose={this.onSelectModalClose} + /> + + + + + +
+ ); + } +} + +InteractiveImportModalContent.propTypes = { + downloadId: PropTypes.string, + allowArtistChange: PropTypes.bool.isRequired, + showImportMode: PropTypes.bool.isRequired, + showFilterExistingFiles: PropTypes.bool.isRequired, + showReplaceExistingFiles: PropTypes.bool.isRequired, + filterExistingFiles: PropTypes.bool.isRequired, + replaceExistingFiles: PropTypes.bool.isRequired, + importMode: PropTypes.string.isRequired, + title: PropTypes.string, + folder: PropTypes.string, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.string, + interactiveImportErrorMessage: PropTypes.string, + onSortPress: PropTypes.func.isRequired, + onFilterExistingFilesChange: PropTypes.func.isRequired, + onReplaceExistingFilesChange: PropTypes.func.isRequired, + onImportModeChange: PropTypes.func.isRequired, + onImportSelectedPress: PropTypes.func.isRequired, + saveInteractiveImportItem: PropTypes.func.isRequired, + updateInteractiveImportItem: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +InteractiveImportModalContent.defaultProps = { + allowArtistChange: true, + showFilterExistingFiles: false, + showReplaceExistingFiles: false, + showImportMode: true, + importMode: 'move' +}; + +export default InteractiveImportModalContent; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js new file mode 100644 index 000000000..1bf8771ba --- /dev/null +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js @@ -0,0 +1,226 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { + fetchInteractiveImportItems, + setInteractiveImportSort, + clearInteractiveImport, + setInteractiveImportMode, + updateInteractiveImportItem, + saveInteractiveImportItem +} from 'Store/Actions/interactiveImportActions'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import InteractiveImportModalContent from './InteractiveImportModalContent'; + +function createMapStateToProps() { + return createSelector( + createClientSideCollectionSelector('interactiveImport'), + (interactiveImport) => { + return interactiveImport; + } + ); +} + +const mapDispatchToProps = { + fetchInteractiveImportItems, + setInteractiveImportSort, + setInteractiveImportMode, + clearInteractiveImport, + updateInteractiveImportItem, + saveInteractiveImportItem, + executeCommand +}; + +class InteractiveImportModalContentConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + interactiveImportErrorMessage: null, + filterExistingFiles: props.filterExistingFiles, + replaceExistingFiles: props.replaceExistingFiles + }; + } + + componentDidMount() { + const { + downloadId, + folder + } = this.props; + + const { + filterExistingFiles, + replaceExistingFiles + } = this.state; + + this.props.fetchInteractiveImportItems({ + downloadId, + folder, + filterExistingFiles, + replaceExistingFiles + }); + } + + componentDidUpdate(prevProps, prevState) { + const { + filterExistingFiles, + replaceExistingFiles + } = this.state; + + if (prevState.filterExistingFiles !== filterExistingFiles || + prevState.replaceExistingFiles !== replaceExistingFiles) { + const { + downloadId, + folder + } = this.props; + + this.props.fetchInteractiveImportItems({ + downloadId, + folder, + filterExistingFiles, + replaceExistingFiles + }); + } + } + + componentWillUnmount() { + this.props.clearInteractiveImport(); + } + + // + // Listeners + + onSortPress = (sortKey, sortDirection) => { + this.props.setInteractiveImportSort({ sortKey, sortDirection }); + } + + onFilterExistingFilesChange = (filterExistingFiles) => { + this.setState({ filterExistingFiles }); + } + + onReplaceExistingFilesChange = (replaceExistingFiles) => { + this.setState({ replaceExistingFiles }); + } + + onImportModeChange = (importMode) => { + this.props.setInteractiveImportMode({ importMode }); + } + + onImportSelectedPress = (selected, importMode) => { + const files = []; + + _.forEach(this.props.items, (item) => { + const isSelected = selected.indexOf(item.id) > -1; + + if (isSelected) { + const { + artist, + album, + albumReleaseId, + tracks, + quality, + disableReleaseSwitching + } = item; + + if (!artist) { + this.setState({ interactiveImportErrorMessage: 'Artist must be chosen for each selected file' }); + return false; + } + + if (!album) { + this.setState({ interactiveImportErrorMessage: 'Album must be chosen for each selected file' }); + return false; + } + + if (!tracks || !tracks.length) { + this.setState({ interactiveImportErrorMessage: 'One or more tracks must be chosen for each selected file' }); + return false; + } + + if (!quality) { + this.setState({ interactiveImportErrorMessage: 'Quality must be chosen for each selected file' }); + return false; + } + + files.push({ + path: item.path, + artistId: artist.id, + albumId: album.id, + albumReleaseId, + trackIds: _.map(tracks, 'id'), + quality, + downloadId: this.props.downloadId, + disableReleaseSwitching + }); + } + }); + + if (!files.length) { + return; + } + + this.props.executeCommand({ + name: commandNames.INTERACTIVE_IMPORT, + files, + importMode, + replaceExistingFiles: this.state.replaceExistingFiles + }); + + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + interactiveImportErrorMessage, + filterExistingFiles, + replaceExistingFiles + } = this.state; + + return ( + + ); + } +} + +InteractiveImportModalContentConnector.propTypes = { + downloadId: PropTypes.string, + folder: PropTypes.string, + filterExistingFiles: PropTypes.bool.isRequired, + replaceExistingFiles: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchInteractiveImportItems: PropTypes.func.isRequired, + setInteractiveImportSort: PropTypes.func.isRequired, + clearInteractiveImport: PropTypes.func.isRequired, + setInteractiveImportMode: PropTypes.func.isRequired, + updateInteractiveImportItem: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +InteractiveImportModalContentConnector.defaultProps = { + filterExistingFiles: true, + replaceExistingFiles: false +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(InteractiveImportModalContentConnector); diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css new file mode 100644 index 000000000..8510d0649 --- /dev/null +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css @@ -0,0 +1,29 @@ +.relativePath { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + word-break: break-all; +} + +.quality { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + text-align: center; +} + +.label { + composes: label from '~Components/Label.css'; + + cursor: pointer; +} + +.loading { + composes: loading from '~Components/Loading/LoadingIndicator.css'; + + margin-top: 0; +} + +.additionalFile { + composes: row from '~Components/Table/TableRow.css'; + + color: $disabledColor; +} diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js new file mode 100644 index 000000000..06c2aed2a --- /dev/null +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js @@ -0,0 +1,372 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import { icons, kinds, tooltipPositions, sortDirections } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableRowCellButton from 'Components/Table/Cells/TableRowCellButton'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import Popover from 'Components/Tooltip/Popover'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import TrackQuality from 'Album/TrackQuality'; +import SelectArtistModal from 'InteractiveImport/Artist/SelectArtistModal'; +import SelectAlbumModal from 'InteractiveImport/Album/SelectAlbumModal'; +import SelectTrackModal from 'InteractiveImport/Track/SelectTrackModal'; +import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal'; +import InteractiveImportRowCellPlaceholder from './InteractiveImportRowCellPlaceholder'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import styles from './InteractiveImportRow.css'; + +class InteractiveImportRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isSelectArtistModalOpen: false, + isSelectAlbumModalOpen: false, + isSelectTrackModalOpen: false, + isSelectQualityModalOpen: false + }; + } + + componentDidMount() { + const { + id, + artist, + album, + tracks, + quality + } = this.props; + + if ( + artist && + album != null && + tracks.length && + quality + ) { + this.props.onSelectedChange({ id, value: true }); + } + } + + componentDidUpdate(prevProps) { + const { + id, + artist, + album, + tracks, + quality, + isSelected, + onValidRowChange + } = this.props; + + if ( + prevProps.artist === artist && + prevProps.album === album && + !hasDifferentItems(prevProps.tracks, tracks) && + prevProps.quality === quality && + prevProps.isSelected === isSelected + ) { + return; + } + + const isValid = !!( + artist && + album && + tracks.length && + quality + ); + + if (isSelected && !isValid) { + onValidRowChange(id, false); + } else { + onValidRowChange(id, true); + } + } + + // + // Control + + selectRowAfterChange = (value) => { + const { + id, + isSelected + } = this.props; + + if (!isSelected && value === true) { + this.props.onSelectedChange({ id, value }); + } + } + + // + // Listeners + + onSelectArtistPress = () => { + this.setState({ isSelectArtistModalOpen: true }); + } + + onSelectAlbumPress = () => { + this.setState({ isSelectAlbumModalOpen: true }); + } + + onSelectTrackPress = () => { + this.setState({ isSelectTrackModalOpen: true }); + } + + onSelectQualityPress = () => { + this.setState({ isSelectQualityModalOpen: true }); + } + + onSelectArtistModalClose = (changed) => { + this.setState({ isSelectArtistModalOpen: false }); + this.selectRowAfterChange(changed); + } + + onSelectAlbumModalClose = (changed) => { + this.setState({ isSelectAlbumModalOpen: false }); + this.selectRowAfterChange(changed); + } + + onSelectTrackModalClose = (changed) => { + this.setState({ isSelectTrackModalOpen: false }); + this.selectRowAfterChange(changed); + } + + onSelectQualityModalClose = (changed) => { + this.setState({ isSelectQualityModalOpen: false }); + this.selectRowAfterChange(changed); + } + + // + // Render + + render() { + const { + id, + allowArtistChange, + relativePath, + artist, + album, + albumReleaseId, + tracks, + quality, + size, + rejections, + audioTags, + additionalFile, + isSelected, + isSaving, + onSelectedChange + } = this.props; + + const { + isSelectArtistModalOpen, + isSelectAlbumModalOpen, + isSelectTrackModalOpen, + isSelectQualityModalOpen + } = this.state; + + const artistName = artist ? artist.artistName : ''; + let albumTitle = ''; + if (album) { + albumTitle = album.disambiguation ? `${album.title} (${album.disambiguation})` : album.title; + } + + const sortedTracks = tracks.sort((a, b) => parseInt(a.absoluteTrackNumber) - parseInt(b.absoluteTrackNumber)); + + const trackNumbers = sortedTracks.map((track) => `${track.mediumNumber}x${track.trackNumber}`) + .join(', '); + + const showArtistPlaceholder = isSelected && !artist; + const showAlbumNumberPlaceholder = isSelected && !!artist && !album; + const showTrackNumbersPlaceholder = !isSaving && isSelected && !!album && !tracks.length; + const showTrackNumbersLoading = isSaving && !tracks.length; + const showQualityPlaceholder = isSelected && !quality; + + const pathCellContents = ( +
+ {relativePath} +
+ ); + + const pathCell = additionalFile ? ( + + ) : pathCellContents; + + return ( + + + + + {pathCell} + + + + { + showArtistPlaceholder ? : artistName + } + + + + { + showAlbumNumberPlaceholder ? : albumTitle + } + + + + { + showTrackNumbersLoading && + } + { + showTrackNumbersPlaceholder ? : trackNumbers + } + + + + { + showQualityPlaceholder && + + } + + { + !showQualityPlaceholder && !!quality && + + } + + + + {formatBytes(size)} + + + + { + rejections && rejections.length ? + + } + title="Release Rejected" + body={ +
    + { + rejections.map((rejection, index) => { + return ( +
  • + {rejection.reason} +
  • + ); + }) + } +
+ } + position={tooltipPositions.LEFT} + /> : + null + } +
+ + + + + + + + 1 : false} + real={quality ? quality.revision.real > 0 : false} + onModalClose={this.onSelectQualityModalClose} + /> +
+ ); + } + +} + +InteractiveImportRow.propTypes = { + id: PropTypes.number.isRequired, + allowArtistChange: PropTypes.bool.isRequired, + relativePath: PropTypes.string.isRequired, + artist: PropTypes.object, + album: PropTypes.object, + albumReleaseId: PropTypes.number, + tracks: PropTypes.arrayOf(PropTypes.object).isRequired, + quality: PropTypes.object, + size: PropTypes.number.isRequired, + rejections: PropTypes.arrayOf(PropTypes.object).isRequired, + audioTags: PropTypes.object.isRequired, + additionalFile: PropTypes.bool.isRequired, + isSelected: PropTypes.bool, + isSaving: PropTypes.bool.isRequired, + onSelectedChange: PropTypes.func.isRequired, + onValidRowChange: PropTypes.func.isRequired +}; + +InteractiveImportRow.defaultProps = { + tracks: [] +}; + +export default InteractiveImportRow; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.css new file mode 100644 index 000000000..941988144 --- /dev/null +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.css @@ -0,0 +1,7 @@ +.placeholder { + display: inline-block; + margin: -8px 0; + width: 100%; + height: 25px; + border: 2px dashed $dangerColor; +} diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.js new file mode 100644 index 000000000..b6744d156 --- /dev/null +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.js @@ -0,0 +1,10 @@ +import React from 'react'; +import styles from './InteractiveImportRowCellPlaceholder.css'; + +function InteractiveImportRowCellPlaceholder() { + return ( + + ); +} + +export default InteractiveImportRowCellPlaceholder; diff --git a/frontend/src/InteractiveImport/InteractiveImportModal.js b/frontend/src/InteractiveImport/InteractiveImportModal.js new file mode 100644 index 000000000..0ea6fd9cb --- /dev/null +++ b/frontend/src/InteractiveImport/InteractiveImportModal.js @@ -0,0 +1,78 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import InteractiveImportSelectFolderModalContentConnector from './Folder/InteractiveImportSelectFolderModalContentConnector'; +import InteractiveImportModalContentConnector from './Interactive/InteractiveImportModalContentConnector'; + +class InteractiveImportModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + folder: null + }; + } + + componentDidUpdate(prevProps) { + if (prevProps.isOpen && !this.props.isOpen) { + this.setState({ folder: null }); + } + } + + // + // Listeners + + onFolderSelect = (folder) => { + this.setState({ folder }); + } + + // + // Render + + render() { + const { + isOpen, + folder, + downloadId, + onModalClose, + ...otherProps + } = this.props; + + const folderPath = folder || this.state.folder; + + return ( + + { + folderPath || downloadId ? + : + + } + + ); + } +} + +InteractiveImportModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + folder: PropTypes.string, + downloadId: PropTypes.string, + onModalClose: PropTypes.func.isRequired +}; + +export default InteractiveImportModal; diff --git a/frontend/src/InteractiveImport/Quality/SelectQualityModal.js b/frontend/src/InteractiveImport/Quality/SelectQualityModal.js new file mode 100644 index 000000000..d3e31d2dd --- /dev/null +++ b/frontend/src/InteractiveImport/Quality/SelectQualityModal.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import SelectQualityModalContentConnector from './SelectQualityModalContentConnector'; + +class SelectQualityModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +SelectQualityModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectQualityModal; diff --git a/frontend/src/InteractiveImport/Quality/SelectQualityModalContent.js b/frontend/src/InteractiveImport/Quality/SelectQualityModalContent.js new file mode 100644 index 000000000..642e0433e --- /dev/null +++ b/frontend/src/InteractiveImport/Quality/SelectQualityModalContent.js @@ -0,0 +1,166 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +class SelectQualityModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + qualityId, + proper, + real + } = props; + + this.state = { + qualityId, + proper, + real + }; + } + + // + // Listeners + + onQualityChange = ({ value }) => { + this.setState({ qualityId: parseInt(value) }); + } + + onProperChange = ({ value }) => { + this.setState({ proper: value }); + } + + onRealChange = ({ value }) => { + this.setState({ real: value }); + } + + onQualitySelect = () => { + this.props.onQualitySelect(this.state); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + onModalClose + } = this.props; + + const { + qualityId, + proper, + real + } = this.state; + + const qualityOptions = items.map(({ id, name }) => { + return { + key: id, + value: name + }; + }); + + return ( + + + Manual Import - Select Quality + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to load qualities
+ } + + { + isPopulated && !error && +
+ + Quality + + + + + + Proper + + + + + + Real + + + +
+ } +
+ + + + + + +
+ ); + } +} + +SelectQualityModalContent.propTypes = { + qualityId: PropTypes.number.isRequired, + proper: PropTypes.bool.isRequired, + real: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onQualitySelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectQualityModalContent; diff --git a/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js b/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js new file mode 100644 index 000000000..1cf55cde6 --- /dev/null +++ b/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js @@ -0,0 +1,95 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import getQualities from 'Utilities/Quality/getQualities'; +import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions'; +import { updateInteractiveImportItems } from 'Store/Actions/interactiveImportActions'; +import SelectQualityModalContent from './SelectQualityModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.qualityProfiles, + (qualityProfiles) => { + const { + isSchemaFetching: isFetching, + isSchemaPopulated: isPopulated, + schemaError: error, + schema + } = qualityProfiles; + + return { + isFetching, + isPopulated, + error, + items: getQualities(schema.items) + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchQualityProfileSchema: fetchQualityProfileSchema, + dispatchUpdateInteractiveImportItems: updateInteractiveImportItems +}; + +class SelectQualityModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount = () => { + if (!this.props.isPopulated) { + this.props.dispatchFetchQualityProfileSchema(); + } + } + + // + // Listeners + + onQualitySelect = ({ qualityId, proper, real }) => { + const quality = _.find(this.props.items, + (item) => item.id === qualityId); + + const revision = { + version: proper ? 2 : 1, + real: real ? 1 : 0 + }; + + this.props.dispatchUpdateInteractiveImportItems({ + ids: this.props.ids, + quality: { + quality, + revision + } + }); + + this.props.onModalClose(true); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SelectQualityModalContentConnector.propTypes = { + ids: PropTypes.arrayOf(PropTypes.number).isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + dispatchFetchQualityProfileSchema: PropTypes.func.isRequired, + dispatchUpdateInteractiveImportItems: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SelectQualityModalContentConnector); diff --git a/frontend/src/InteractiveImport/Track/SelectTrackModal.js b/frontend/src/InteractiveImport/Track/SelectTrackModal.js new file mode 100644 index 000000000..f8c9c4160 --- /dev/null +++ b/frontend/src/InteractiveImport/Track/SelectTrackModal.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import SelectTrackModalContentConnector from './SelectTrackModalContentConnector'; + +class SelectTrackModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +SelectTrackModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectTrackModal; diff --git a/frontend/src/InteractiveImport/Track/SelectTrackModalContent.js b/frontend/src/InteractiveImport/Track/SelectTrackModalContent.js new file mode 100644 index 000000000..0934cc047 --- /dev/null +++ b/frontend/src/InteractiveImport/Track/SelectTrackModalContent.js @@ -0,0 +1,236 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import _ from 'lodash'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import SelectTrackRow from './SelectTrackRow'; +import ExpandingFileDetails from 'TrackFile/ExpandingFileDetails'; + +const columns = [ + { + name: 'mediumNumber', + label: 'Medium', + isSortable: true, + isVisible: true + }, + { + name: 'trackNumber', + label: '#', + isSortable: true, + isVisible: true + }, + { + name: 'title', + label: 'Title', + isVisible: true + }, + { + name: 'trackStatus', + label: 'Status', + isVisible: true + } +]; + +const selectAllBlankColumn = [ + { + name: 'dummy', + label: ' ', + isVisible: true + } +]; + +class SelectTrackModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const selectedTracks = _.filter(props.selectedTracksByItem, ['id', props.id])[0].tracks; + const init = _.zipObject(selectedTracks, _.times(selectedTracks.length, _.constant(true))); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: init + }; + + props.onSortPress( props.sortKey, props.sortDirection ); + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onTracksSelect = () => { + this.props.onTracksSelect(this.getSelectedIds()); + } + + // + // Render + + render() { + const { + id, + audioTags, + rejections, + isFetching, + isPopulated, + error, + items, + sortKey, + sortDirection, + onSortPress, + onModalClose, + selectedTracksByItem, + filename + } = this.props; + + const { + allSelected, + allUnselected, + selectedState + } = this.state; + + const errorMessage = getErrorMessage(error, 'Unable to load tracks'); + + // all tracks selected for other items + const otherSelected = _.map(_.filter(selectedTracksByItem, (item) => { + return item.id !== id; + }), (x) => { + return x.tracks; + }).flat(); + // tracks selected for the current file + const currentSelected = _.keys(_.pickBy(selectedState, _.identity)).map(Number); + // only enable selectAll if no other files have any tracks selected. + const selectAllEnabled = otherSelected.length === 0; + + return ( + + + Manual Import - Select Track(s): + + + + { + isFetching && + + } + + { + error && +
{errorMessage}
+ } + + + + { + isPopulated && !!items.length && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ } + + { + isPopulated && !items.length && + 'No tracks were found for the selected album' + } +
+ + + + + + +
+ ); + } +} + +SelectTrackModalContent.propTypes = { + id: PropTypes.number.isRequired, + rejections: PropTypes.arrayOf(PropTypes.object).isRequired, + audioTags: PropTypes.object.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.string, + onSortPress: PropTypes.func.isRequired, + onTracksSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + selectedTracksByItem: PropTypes.arrayOf(PropTypes.object).isRequired, + filename: PropTypes.string.isRequired +}; + +export default SelectTrackModalContent; diff --git a/frontend/src/InteractiveImport/Track/SelectTrackModalContentConnector.js b/frontend/src/InteractiveImport/Track/SelectTrackModalContentConnector.js new file mode 100644 index 000000000..35b17ade5 --- /dev/null +++ b/frontend/src/InteractiveImport/Track/SelectTrackModalContentConnector.js @@ -0,0 +1,112 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchTracks, setTracksSort, clearTracks } from 'Store/Actions/trackActions'; +import { updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import SelectTrackModalContent from './SelectTrackModalContent'; + +function createMapStateToProps() { + return createSelector( + createClientSideCollectionSelector('tracks'), + createClientSideCollectionSelector('interactiveImport'), + (tracks, interactiveImport) => { + + const selectedTracksByItem = _.map(interactiveImport.items, (item) => { + return { id: item.id, tracks: _.map(item.tracks, (track) => { + return track.id; + }) }; + }); + + return { + ...tracks, + selectedTracksByItem + }; + } + ); +} + +const mapDispatchToProps = { + fetchTracks, + setTracksSort, + clearTracks, + updateInteractiveImportItem +}; + +class SelectTrackModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + artistId, + albumId, + albumReleaseId + } = this.props; + + this.props.fetchTracks({ artistId, albumId, albumReleaseId }); + } + + componentWillUnmount() { + // This clears the tracks for the queue and hides the queue + // We'll need another place to store tracks for manual import + this.props.clearTracks(); + } + + // + // Listeners + + onSortPress = (sortKey, sortDirection) => { + this.props.setTracksSort({ sortKey, sortDirection }); + } + + onTracksSelect = (trackIds) => { + const tracks = _.reduce(this.props.items, (acc, item) => { + if (trackIds.indexOf(item.id) > -1) { + acc.push(item); + } + + return acc; + }, []); + + this.props.updateInteractiveImportItem({ + id: this.props.id, + tracks: _.sortBy(tracks, 'trackNumber') + }); + + this.props.onModalClose(true); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SelectTrackModalContentConnector.propTypes = { + id: PropTypes.number.isRequired, + artistId: PropTypes.number.isRequired, + albumId: PropTypes.number.isRequired, + albumReleaseId: PropTypes.number.isRequired, + rejections: PropTypes.arrayOf(PropTypes.object).isRequired, + audioTags: PropTypes.object.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchTracks: PropTypes.func.isRequired, + setTracksSort: PropTypes.func.isRequired, + clearTracks: PropTypes.func.isRequired, + updateInteractiveImportItem: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SelectTrackModalContentConnector); diff --git a/frontend/src/InteractiveImport/Track/SelectTrackRow.js b/frontend/src/InteractiveImport/Track/SelectTrackRow.js new file mode 100644 index 000000000..f7dea7af3 --- /dev/null +++ b/frontend/src/InteractiveImport/Track/SelectTrackRow.js @@ -0,0 +1,121 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import TableRowButton from 'Components/Table/TableRowButton'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Popover from 'Components/Tooltip/Popover'; + +class SelectTrackRow extends Component { + + // + // Listeners + + onPress = () => { + const { + id, + isSelected + } = this.props; + + this.props.onSelectedChange({ id, value: !isSelected }); + } + + // + // Render + + render() { + const { + id, + mediumNumber, + trackNumber, + title, + hasFile, + importSelected, + isSelected, + isDisabled, + onSelectedChange + } = this.props; + + let iconName = icons.UNKNOWN; + let iconKind = kinds.DEFAULT; + let iconTip = ''; + + if (hasFile && !importSelected) { + iconName = icons.DOWNLOADED; + iconKind = kinds.DEFAULT; + iconTip = 'Track already in library.'; + } else if (!hasFile && !importSelected) { + iconName = icons.UNKNOWN; + iconKind = kinds.DEFAULT; + iconTip = 'Track missing from library and no import selected.'; + } else if (importSelected && hasFile) { + iconName = icons.FILEIMPORT; + iconKind = kinds.WARNING; + iconTip = 'Warning: Existing track will be replaced by download.'; + } else if (importSelected && !hasFile) { + iconName = icons.FILEIMPORT; + iconKind = kinds.DEFAULT; + iconTip = 'Track missing from library and selected for import.'; + } + + // isDisabled can only be true if importSelected is true + if (isDisabled) { + iconTip = `${iconTip}\nAnother file is selected to import for this track.`; + } + + return ( + + + + + {mediumNumber} + + + + {trackNumber} + + + + {title} + + + + + } + title={'Track status'} + body={iconTip} + position={tooltipPositions.LEFT} + /> + + + ); + } +} + +SelectTrackRow.propTypes = { + id: PropTypes.number.isRequired, + mediumNumber: PropTypes.number.isRequired, + trackNumber: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + hasFile: PropTypes.bool.isRequired, + importSelected: PropTypes.bool.isRequired, + isSelected: PropTypes.bool, + isDisabled: PropTypes.bool, + onSelectedChange: PropTypes.func.isRequired +}; + +export default SelectTrackRow; diff --git a/frontend/src/InteractiveSearch/InteractiveSearch.css b/frontend/src/InteractiveSearch/InteractiveSearch.css new file mode 100644 index 000000000..5e647332f --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearch.css @@ -0,0 +1,9 @@ +.filterMenuContainer { + display: flex; + justify-content: flex-end; + margin-bottom: 10px; +} + +.filteredMessage { + margin-top: 10px; +} diff --git a/frontend/src/InteractiveSearch/InteractiveSearch.js b/frontend/src/InteractiveSearch/InteractiveSearch.js new file mode 100644 index 000000000..bc47e4e96 --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearch.js @@ -0,0 +1,204 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { align, icons, sortDirections } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Icon from 'Components/Icon'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import PageMenuButton from 'Components/Menu/PageMenuButton'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import InteractiveSearchFilterModalConnector from './InteractiveSearchFilterModalConnector'; +import InteractiveSearchRow from './InteractiveSearchRow'; +import styles from './InteractiveSearch.css'; + +const columns = [ + { + name: 'protocol', + label: 'Source', + isSortable: true, + isVisible: true + }, + { + name: 'age', + label: 'Age', + isSortable: true, + isVisible: true + }, + { + name: 'title', + label: 'Title', + isSortable: true, + isVisible: true + }, + { + name: 'indexer', + label: 'Indexer', + isSortable: true, + isVisible: true + }, + { + name: 'size', + label: 'Size', + isSortable: true, + isVisible: true + }, + { + name: 'peers', + label: 'Peers', + isSortable: true, + isVisible: true + }, + { + name: 'qualityWeight', + label: 'Quality', + isSortable: true, + isVisible: true + }, + { + name: 'preferredWordScore', + label: React.createElement(Icon, { + name: icons.SCORE, + title: 'Preferred word score' + }), + isSortable: true, + isVisible: true + }, + { + name: 'rejections', + label: React.createElement(Icon, { + name: icons.DANGER, + title: 'Rejections' + }), + isSortable: true, + fixedSortDirection: sortDirections.ASCENDING, + isVisible: true + }, + { + name: 'releaseWeight', + label: React.createElement(Icon, { name: icons.DOWNLOAD }), + isSortable: true, + fixedSortDirection: sortDirections.ASCENDING, + isVisible: true + } +]; + +function InteractiveSearch(props) { + const { + searchPayload, + isFetching, + isPopulated, + error, + totalReleasesCount, + items, + selectedFilterKey, + filters, + customFilters, + sortKey, + sortDirection, + type, + longDateFormat, + timeFormat, + onSortPress, + onFilterSelect, + onGrabPress + } = props; + + return ( +
+
+ +
+ + { + isFetching && + + } + + { + !isFetching && !!error && +
+ Unable to load results for this album search. Try again later +
+ } + + { + !isFetching && isPopulated && !totalReleasesCount && +
+ No results found +
+ } + + { + !!totalReleasesCount && isPopulated && !items.length && +
+ All results are hidden by the applied filter +
+ } + + { + isPopulated && !!items.length && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ } + + { + totalReleasesCount !== items.length && !!items.length && +
+ Some results are hidden by the applied filter +
+ } +
+ ); +} + +InteractiveSearch.propTypes = { + searchPayload: PropTypes.object.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + totalReleasesCount: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.string, + type: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + onSortPress: PropTypes.func.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onGrabPress: PropTypes.func.isRequired +}; + +export default InteractiveSearch; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchConnector.js b/frontend/src/InteractiveSearch/InteractiveSearchConnector.js new file mode 100644 index 000000000..b8b764aa7 --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearchConnector.js @@ -0,0 +1,94 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import * as releaseActions from 'Store/Actions/releaseActions'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import InteractiveSearch from './InteractiveSearch'; + +function createMapStateToProps(appState, { type }) { + return createSelector( + (state) => state.releases.items.length, + createClientSideCollectionSelector('releases', `releases.${type}`), + createUISettingsSelector(), + (totalReleasesCount, releases, uiSettings) => { + return { + totalReleasesCount, + longDateFormat: uiSettings.longDateFormat, + timeFormat: uiSettings.timeFormat, + ...releases + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + dispatchFetchReleases(payload) { + dispatch(releaseActions.fetchReleases(payload)); + }, + + onSortPress(sortKey, sortDirection) { + dispatch(releaseActions.setReleasesSort({ sortKey, sortDirection })); + }, + + onFilterSelect(selectedFilterKey) { + const action = props.type === 'album' ? + releaseActions.setAlbumReleasesFilter : + releaseActions.setArtistReleasesFilter; + + dispatch(action({ selectedFilterKey })); + }, + + onGrabPress(payload) { + dispatch(releaseActions.grabRelease(payload)); + } + }; +} + +class InteractiveSearchConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + searchPayload, + isPopulated, + dispatchFetchReleases + } = this.props; + + // If search results are not yet isPopulated fetch them, + // otherwise re-show the existing props. + + if (!isPopulated) { + dispatchFetchReleases(searchPayload); + } + } + + // + // Render + + render() { + const { + dispatchFetchReleases, + ...otherProps + } = this.props; + + return ( + + + ); + } +} + +InteractiveSearchConnector.propTypes = { + searchPayload: PropTypes.object.isRequired, + isPopulated: PropTypes.bool.isRequired, + dispatchFetchReleases: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, createMapDispatchToProps)(InteractiveSearchConnector); diff --git a/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js b/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js new file mode 100644 index 000000000..5f79d0ec1 --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js @@ -0,0 +1,32 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setAlbumReleasesFilter, setArtistReleasesFilter } from 'Store/Actions/releaseActions'; +import FilterModal from 'Components/Filter/FilterModal'; + +function createMapStateToProps() { + return createSelector( + (state) => state.releases.items, + (state) => state.releases.filterBuilderProps, + (sectionItems, filterBuilderProps) => { + return { + sectionItems, + filterBuilderProps, + customFilterType: 'releases' + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + dispatchSetFilter(payload) { + const action = props.type === 'album' ? + setAlbumReleasesFilter: + setArtistReleasesFilter; + + dispatch(action(payload)); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(FilterModal); diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.css b/frontend/src/InteractiveSearch/InteractiveSearchRow.css new file mode 100644 index 000000000..3ec30e184 --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.css @@ -0,0 +1,51 @@ +.protocol { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 80px; +} + +.title { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + word-break: break-all; +} + +.indexer { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 85px; +} + +.quality { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + text-align: center; +} + +.preferredWordScore { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 55px; + font-weight: bold; + cursor: default; +} + +.rejected, +.download { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 50px; +} + +.age, +.size { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + white-space: nowrap; +} + +.peers { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 75px; +} diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.js b/frontend/src/InteractiveSearch/InteractiveSearchRow.js new file mode 100644 index 000000000..055054f70 --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.js @@ -0,0 +1,260 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatAge from 'Utilities/Number/formatAge'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import Link from 'Components/Link/Link'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import Popover from 'Components/Tooltip/Popover'; +import TrackQuality from 'Album/TrackQuality'; +import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; +import Peers from './Peers'; +import styles from './InteractiveSearchRow.css'; + +function getDownloadIcon(isGrabbing, isGrabbed, grabError) { + if (isGrabbing) { + return icons.SPINNER; + } else if (isGrabbed) { + return icons.DOWNLOADING; + } else if (grabError) { + return icons.DOWNLOADING; + } + + return icons.DOWNLOAD; +} + +function getDownloadTooltip(isGrabbing, isGrabbed, grabError) { + if (isGrabbing) { + return ''; + } else if (isGrabbed) { + return 'Added to downloaded queue'; + } else if (grabError) { + return grabError; + } + + return 'Add to downloaded queue'; +} + +class InteractiveSearchRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isConfirmGrabModalOpen: false + }; + } + + // + // Listeners + + onGrabPress = () => { + const { + guid, + indexerId, + onGrabPress + } = this.props; + + onGrabPress({ + guid, + indexerId + }); + } + + onConfirmGrabPress = () => { + this.setState({ isConfirmGrabModalOpen: true }); + } + + onGrabConfirm = () => { + this.setState({ isConfirmGrabModalOpen: false }); + + const { + guid, + indexerId, + searchPayload, + onGrabPress + } = this.props; + + onGrabPress({ + guid, + indexerId, + ...searchPayload + }); + } + + onGrabCancel = () => { + this.setState({ isConfirmGrabModalOpen: false }); + } + + // + // Render + + render() { + const { + protocol, + age, + ageHours, + ageMinutes, + publishDate, + title, + infoUrl, + indexer, + size, + seeders, + leechers, + quality, + preferredWordScore, + rejections, + downloadAllowed, + isGrabbing, + isGrabbed, + longDateFormat, + timeFormat, + grabError + } = this.props; + + return ( + + + + + + + {formatAge(age, ageHours, ageMinutes)} + + + + + {title} + + + + + {indexer} + + + + {formatBytes(size)} + + + + { + protocol === 'torrent' && + + } + + + + + + + + {preferredWordScore > 0 && `+${preferredWordScore}`} + {preferredWordScore < 0 && preferredWordScore} + + + + { + !!rejections.length && + + } + title="Release Rejected" + body={ +
    + { + rejections.map((rejection, index) => { + return ( +
  • + {rejection} +
  • + ); + }) + } +
+ } + position={tooltipPositions.LEFT} + /> + } +
+ + + { + + } + + + +
+ ); + } +} + +InteractiveSearchRow.propTypes = { + guid: PropTypes.string.isRequired, + protocol: PropTypes.string.isRequired, + age: PropTypes.number.isRequired, + ageHours: PropTypes.number.isRequired, + ageMinutes: PropTypes.number.isRequired, + publishDate: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + infoUrl: PropTypes.string.isRequired, + indexer: PropTypes.string.isRequired, + indexerId: PropTypes.number.isRequired, + size: PropTypes.number.isRequired, + seeders: PropTypes.number, + leechers: PropTypes.number, + quality: PropTypes.object.isRequired, + preferredWordScore: PropTypes.number.isRequired, + rejections: PropTypes.arrayOf(PropTypes.string).isRequired, + downloadAllowed: PropTypes.bool.isRequired, + isGrabbing: PropTypes.bool.isRequired, + isGrabbed: PropTypes.bool.isRequired, + grabError: PropTypes.string, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + searchPayload: PropTypes.object.isRequired, + onGrabPress: PropTypes.func.isRequired +}; + +InteractiveSearchRow.defaultProps = { + rejections: [], + isGrabbing: false, + isGrabbed: false +}; + +export default InteractiveSearchRow; diff --git a/frontend/src/InteractiveSearch/Peers.js b/frontend/src/InteractiveSearch/Peers.js new file mode 100644 index 000000000..26654f63c --- /dev/null +++ b/frontend/src/InteractiveSearch/Peers.js @@ -0,0 +1,57 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Label from 'Components/Label'; + +function getKind(seeders) { + if (seeders > 50) { + return kinds.PRIMARY; + } + + if (seeders > 10) { + return kinds.INFO; + } + + if (seeders > 0) { + return kinds.WARNING; + } + + return kinds.DANGER; +} + +function getPeersTooltipPart(peers, peersUnit) { + if (peers == null) { + return `Unknown ${peersUnit}s`; + } + + if (peers === 1) { + return `1 ${peersUnit}`; + } + + return `${peers} ${peersUnit}s`; +} + +function Peers(props) { + const { + seeders, + leechers + } = props; + + const kind = getKind(seeders); + + return ( + + ); +} + +Peers.propTypes = { + seeders: PropTypes.number, + leechers: PropTypes.number +}; + +export default Peers; diff --git a/frontend/src/Organize/OrganizePreviewModal.js b/frontend/src/Organize/OrganizePreviewModal.js new file mode 100644 index 000000000..647f4ddf8 --- /dev/null +++ b/frontend/src/Organize/OrganizePreviewModal.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import OrganizePreviewModalContentConnector from './OrganizePreviewModalContentConnector'; + +function OrganizePreviewModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + { + isOpen && + + } + + ); +} + +OrganizePreviewModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default OrganizePreviewModal; diff --git a/frontend/src/Organize/OrganizePreviewModalConnector.js b/frontend/src/Organize/OrganizePreviewModalConnector.js new file mode 100644 index 000000000..ace733c86 --- /dev/null +++ b/frontend/src/Organize/OrganizePreviewModalConnector.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearOrganizePreview } from 'Store/Actions/organizePreviewActions'; +import OrganizePreviewModal from './OrganizePreviewModal'; + +const mapDispatchToProps = { + clearOrganizePreview +}; + +class OrganizePreviewModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearOrganizePreview(); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +OrganizePreviewModalConnector.propTypes = { + clearOrganizePreview: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(undefined, mapDispatchToProps)(OrganizePreviewModalConnector); diff --git a/frontend/src/Organize/OrganizePreviewModalContent.css b/frontend/src/Organize/OrganizePreviewModalContent.css new file mode 100644 index 000000000..cf20af7a2 --- /dev/null +++ b/frontend/src/Organize/OrganizePreviewModalContent.css @@ -0,0 +1,24 @@ +.path { + margin-left: 5px; + font-weight: bold; +} + +.trackFormat { + margin-left: 5px; + font-family: $monoSpaceFontFamily; +} + +.previews { + margin-top: 10px; +} + +.selectAllInputContainer { + margin-right: auto; + line-height: 30px; +} + +.selectAllInput { + composes: input from '~Components/Form/CheckInput.css'; + + margin: 0; +} diff --git a/frontend/src/Organize/OrganizePreviewModalContent.js b/frontend/src/Organize/OrganizePreviewModalContent.js new file mode 100644 index 000000000..6f20a9d3c --- /dev/null +++ b/frontend/src/Organize/OrganizePreviewModalContent.js @@ -0,0 +1,192 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import CheckInput from 'Components/Form/CheckInput'; +import OrganizePreviewRow from './OrganizePreviewRow'; +import styles from './OrganizePreviewModalContent.css'; + +function getValue(allSelected, allUnselected) { + if (allSelected) { + return true; + } else if (allUnselected) { + return false; + } + + return null; +} + +class OrganizePreviewModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {} + }; + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onOrganizePress = () => { + this.props.onOrganizePress(this.getSelectedIds()); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + trackFormat, + path, + onModalClose + } = this.props; + + const { + allSelected, + allUnselected, + selectedState + } = this.state; + + const selectAllValue = getValue(allSelected, allUnselected); + + return ( + + + Organize & Rename + + + + { + isFetching && + + } + + { + !isFetching && error && +
Error loading previews
+ } + + { + !isFetching && isPopulated && !items.length && +
Success! My work is done, no files to rename.
+ } + + { + !isFetching && isPopulated && !!items.length && +
+ +
+ All paths are relative to: + + {path} + +
+ +
+ Naming pattern: + + {trackFormat} + +
+
+ +
+ { + items.map((item) => { + return ( + + ); + }) + } +
+
+ } +
+ + + { + isPopulated && !!items.length && + + } + + + + + +
+ ); + } +} + +OrganizePreviewModalContent.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + path: PropTypes.string.isRequired, + trackFormat: PropTypes.string, + onOrganizePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default OrganizePreviewModalContent; diff --git a/frontend/src/Organize/OrganizePreviewModalContentConnector.js b/frontend/src/Organize/OrganizePreviewModalContentConnector.js new file mode 100644 index 000000000..deec48a13 --- /dev/null +++ b/frontend/src/Organize/OrganizePreviewModalContentConnector.js @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createArtistSelector from 'Store/Selectors/createArtistSelector'; +import { fetchOrganizePreview } from 'Store/Actions/organizePreviewActions'; +import { fetchNamingSettings } from 'Store/Actions/settingsActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import OrganizePreviewModalContent from './OrganizePreviewModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.organizePreview, + (state) => state.settings.naming, + createArtistSelector(), + (organizePreview, naming, artist) => { + const props = { ...organizePreview }; + props.isFetching = organizePreview.isFetching || naming.isFetching; + props.isPopulated = organizePreview.isPopulated && naming.isPopulated; + props.error = organizePreview.error || naming.error; + props.trackFormat = naming.item.standardTrackFormat; + props.path = artist.path; + + return props; + } + ); +} + +const mapDispatchToProps = { + fetchOrganizePreview, + fetchNamingSettings, + executeCommand +}; + +class OrganizePreviewModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + artistId, + albumId + } = this.props; + + this.props.fetchOrganizePreview({ + artistId, + albumId + }); + + this.props.fetchNamingSettings(); + } + + // + // Listeners + + onOrganizePress = (files) => { + this.props.executeCommand({ + name: commandNames.RENAME_FILES, + artistId: this.props.artistId, + files + }); + + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +OrganizePreviewModalContentConnector.propTypes = { + artistId: PropTypes.number.isRequired, + albumId: PropTypes.number, + fetchOrganizePreview: PropTypes.func.isRequired, + fetchNamingSettings: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(OrganizePreviewModalContentConnector); diff --git a/frontend/src/Organize/OrganizePreviewRow.css b/frontend/src/Organize/OrganizePreviewRow.css new file mode 100644 index 000000000..1b3c8ca47 --- /dev/null +++ b/frontend/src/Organize/OrganizePreviewRow.css @@ -0,0 +1,20 @@ +.row { + display: flex; + margin-bottom: 5px; + padding: 5px 0; + border-bottom: 1px solid $borderColor; + + &:last-of-type { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; + } +} + +.selectedContainer { + margin-right: 30px; +} + +.path { + margin-left: 10px; +} diff --git a/frontend/src/Organize/OrganizePreviewRow.js b/frontend/src/Organize/OrganizePreviewRow.js new file mode 100644 index 000000000..340232a98 --- /dev/null +++ b/frontend/src/Organize/OrganizePreviewRow.js @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import CheckInput from 'Components/Form/CheckInput'; +import styles from './OrganizePreviewRow.css'; + +class OrganizePreviewRow extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + id, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value: true }); + } + + // + // Listeners + + onSelectedChange = ({ value, shiftKey }) => { + const { + id, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value, shiftKey }); + } + + // + // Render + + render() { + const { + id, + existingPath, + newPath, + isSelected + } = this.props; + + return ( +
+ + +
+
+ + + + {existingPath} + +
+ +
+ + + + {newPath} + +
+
+
+ ); + } +} + +OrganizePreviewRow.propTypes = { + id: PropTypes.number.isRequired, + existingPath: PropTypes.string.isRequired, + newPath: PropTypes.string.isRequired, + isSelected: PropTypes.bool, + onSelectedChange: PropTypes.func.isRequired +}; + +export default OrganizePreviewRow; diff --git a/frontend/src/Retag/RetagPreviewModal.js b/frontend/src/Retag/RetagPreviewModal.js new file mode 100644 index 000000000..6abcfa09a --- /dev/null +++ b/frontend/src/Retag/RetagPreviewModal.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import RetagPreviewModalContentConnector from './RetagPreviewModalContentConnector'; + +function RetagPreviewModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + { + isOpen && + + } + + ); +} + +RetagPreviewModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default RetagPreviewModal; diff --git a/frontend/src/Retag/RetagPreviewModalConnector.js b/frontend/src/Retag/RetagPreviewModalConnector.js new file mode 100644 index 000000000..fa2e69d20 --- /dev/null +++ b/frontend/src/Retag/RetagPreviewModalConnector.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearRetagPreview } from 'Store/Actions/retagPreviewActions'; +import RetagPreviewModal from './RetagPreviewModal'; + +const mapDispatchToProps = { + clearRetagPreview +}; + +class RetagPreviewModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearRetagPreview(); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +RetagPreviewModalConnector.propTypes = { + clearRetagPreview: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(undefined, mapDispatchToProps)(RetagPreviewModalConnector); diff --git a/frontend/src/Retag/RetagPreviewModalContent.css b/frontend/src/Retag/RetagPreviewModalContent.css new file mode 100644 index 000000000..cf20af7a2 --- /dev/null +++ b/frontend/src/Retag/RetagPreviewModalContent.css @@ -0,0 +1,24 @@ +.path { + margin-left: 5px; + font-weight: bold; +} + +.trackFormat { + margin-left: 5px; + font-family: $monoSpaceFontFamily; +} + +.previews { + margin-top: 10px; +} + +.selectAllInputContainer { + margin-right: auto; + line-height: 30px; +} + +.selectAllInput { + composes: input from '~Components/Form/CheckInput.css'; + + margin: 0; +} diff --git a/frontend/src/Retag/RetagPreviewModalContent.js b/frontend/src/Retag/RetagPreviewModalContent.js new file mode 100644 index 000000000..5530d63fb --- /dev/null +++ b/frontend/src/Retag/RetagPreviewModalContent.js @@ -0,0 +1,186 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import CheckInput from 'Components/Form/CheckInput'; +import RetagPreviewRow from './RetagPreviewRow'; +import styles from './RetagPreviewModalContent.css'; + +function getValue(allSelected, allUnselected) { + if (allSelected) { + return true; + } else if (allUnselected) { + return false; + } + + return null; +} + +class RetagPreviewModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {} + }; + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onRetagPress = () => { + this.props.onRetagPress(this.getSelectedIds()); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + path, + onModalClose + } = this.props; + + const { + allSelected, + allUnselected, + selectedState + } = this.state; + + const selectAllValue = getValue(allSelected, allUnselected); + + return ( + + + Write Metadata Tags + + + + { + isFetching && + + } + + { + !isFetching && error && +
Error loading previews
+ } + + { + !isFetching && ((isPopulated && !items.length)) && +
Success! My work is done, no files to retag.
+ } + + { + !isFetching && isPopulated && !!items.length && +
+ +
+ All paths are relative to: + + {path} + +
+
+ MusicBrainz identifiers will also be added to the files; these are not shown below. +
+
+ +
+ { + items.map((item) => { + return ( + + ); + }) + } +
+
+ } +
+ + + { + isPopulated && !!items.length && + + } + + + + + +
+ ); + } +} + +RetagPreviewModalContent.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + path: PropTypes.string.isRequired, + onRetagPress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default RetagPreviewModalContent; diff --git a/frontend/src/Retag/RetagPreviewModalContentConnector.js b/frontend/src/Retag/RetagPreviewModalContentConnector.js new file mode 100644 index 000000000..ce3a64776 --- /dev/null +++ b/frontend/src/Retag/RetagPreviewModalContentConnector.js @@ -0,0 +1,85 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createArtistSelector from 'Store/Selectors/createArtistSelector'; +import { fetchRetagPreview } from 'Store/Actions/retagPreviewActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import RetagPreviewModalContent from './RetagPreviewModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.retagPreview, + createArtistSelector(), + (retagPreview, artist) => { + const props = { ...retagPreview }; + props.isFetching = retagPreview.isFetching; + props.isPopulated = retagPreview.isPopulated; + props.error = retagPreview.error; + props.path = artist.path; + + return props; + } + ); +} + +const mapDispatchToProps = { + fetchRetagPreview, + executeCommand +}; + +class RetagPreviewModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + artistId, + albumId + } = this.props; + + this.props.fetchRetagPreview({ + artistId, + albumId + }); + } + + // + // Listeners + + onRetagPress = (files) => { + this.props.executeCommand({ + name: commandNames.RETAG_FILES, + artistId: this.props.artistId, + files + }); + + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +RetagPreviewModalContentConnector.propTypes = { + artistId: PropTypes.number.isRequired, + albumId: PropTypes.number, + isPopulated: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + fetchRetagPreview: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(RetagPreviewModalContentConnector); diff --git a/frontend/src/Retag/RetagPreviewRow.css b/frontend/src/Retag/RetagPreviewRow.css new file mode 100644 index 000000000..e59b03f19 --- /dev/null +++ b/frontend/src/Retag/RetagPreviewRow.css @@ -0,0 +1,26 @@ +.row { + display: flex; + margin-bottom: 5px; + padding: 5px 0; + border-bottom: 1px solid $borderColor; + + &:last-of-type { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; + } +} + +.column { + display: flex; + flex-direction: column; +} + +.selectedContainer { + margin-right: 30px; +} + +.path { + margin-left: 10px; + font-weight: bold; +} diff --git a/frontend/src/Retag/RetagPreviewRow.js b/frontend/src/Retag/RetagPreviewRow.js new file mode 100644 index 000000000..e02246253 --- /dev/null +++ b/frontend/src/Retag/RetagPreviewRow.js @@ -0,0 +1,105 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import CheckInput from 'Components/Form/CheckInput'; +import styles from './RetagPreviewRow.css'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; + +function formatValue(field, value) { + if (value === undefined || value === 0 || value === '0' || value === '') { + return (); + } + if (field === 'Image Size') { + return formatBytes(value); + } + return value; +} + +function formatChange(field, oldValue, newValue) { + return ( +
{formatValue(field, oldValue)} {formatValue(field, newValue)}
+ ); +} + +class RetagPreviewRow extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + id, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value: true }); + } + + // + // Listeners + + onSelectedChange = ({ value, shiftKey }) => { + const { + id, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value, shiftKey }); + } + + // + // Render + + render() { + const { + id, + path, + changes, + isSelected + } = this.props; + + return ( +
+ + +
+ + {path} + + + + { + changes.map(({ field, oldValue, newValue }) => { + return ( + + ); + }) + } + +
+
+ ); + } +} + +RetagPreviewRow.propTypes = { + id: PropTypes.number.isRequired, + path: PropTypes.string.isRequired, + changes: PropTypes.arrayOf(PropTypes.object).isRequired, + isSelected: PropTypes.bool, + onSelectedChange: PropTypes.func.isRequired +}; + +export default RetagPreviewRow; diff --git a/frontend/src/RootFolder/RootFolderRow.css b/frontend/src/RootFolder/RootFolderRow.css new file mode 100644 index 000000000..c1ec2625e --- /dev/null +++ b/frontend/src/RootFolder/RootFolderRow.css @@ -0,0 +1,27 @@ +.link { + composes: link from '~Components/Link/Link.css'; +} + +.unavailablePath { + display: flex; + align-items: center; +} + +.unavailableLabel { + composes: label from '~Components/Label.css'; + + margin-left: 10px; +} + +.freeSpace, +.unmappedFolders { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 150px; +} + +.actions { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 45px; +} diff --git a/frontend/src/RootFolder/RootFolderRow.js b/frontend/src/RootFolder/RootFolderRow.js new file mode 100644 index 000000000..ffa836dc2 --- /dev/null +++ b/frontend/src/RootFolder/RootFolderRow.js @@ -0,0 +1,80 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { icons, kinds } from 'Helpers/Props'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './RootFolderRow.css'; + +function RootFolderRow(props) { + const { + id, + path, + freeSpace, + unmappedFolders, + onDeletePress + } = props; + + const unmappedFoldersCount = unmappedFolders.length || '-'; + const isUnavailable = freeSpace == null; + + return ( + + + { + isUnavailable ? +
+ {path} + + +
: + + + {path} + + } +
+ + + {freeSpace ? formatBytes(freeSpace) : '-'} + + + + {unmappedFoldersCount} + + + + + +
+ ); +} + +RootFolderRow.propTypes = { + id: PropTypes.number.isRequired, + path: PropTypes.string.isRequired, + freeSpace: PropTypes.number, + unmappedFolders: PropTypes.arrayOf(PropTypes.object).isRequired, + onDeletePress: PropTypes.func.isRequired +}; + +RootFolderRow.defaultProps = { + unmappedFolders: [] +}; + +export default RootFolderRow; diff --git a/frontend/src/RootFolder/RootFolderRowConnector.js b/frontend/src/RootFolder/RootFolderRowConnector.js new file mode 100644 index 000000000..ab0848e87 --- /dev/null +++ b/frontend/src/RootFolder/RootFolderRowConnector.js @@ -0,0 +1,13 @@ +import { connect } from 'react-redux'; +import { deleteRootFolder } from 'Store/Actions/rootFolderActions'; +import RootFolderRow from './RootFolderRow'; + +function createMapDispatchToProps(dispatch, props) { + return { + onDeletePress() { + dispatch(deleteRootFolder({ id: props.id })); + } + }; +} + +export default connect(null, createMapDispatchToProps)(RootFolderRow); diff --git a/frontend/src/RootFolder/RootFolders.js b/frontend/src/RootFolder/RootFolders.js new file mode 100644 index 000000000..57598dbb9 --- /dev/null +++ b/frontend/src/RootFolder/RootFolders.js @@ -0,0 +1,80 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import RootFolderRowConnector from './RootFolderRowConnector'; + +const rootFolderColumns = [ + { + name: 'path', + label: 'Path', + isVisible: true + }, + { + name: 'freeSpace', + label: 'Free Space', + isVisible: true + }, + { + name: 'unmappedFolders', + label: 'Unmapped Folders', + isVisible: true + }, + { + name: 'actions', + isVisible: true + } +]; + +function RootFolders(props) { + const { + isFetching, + isPopulated, + error, + items + } = props; + + if (isFetching && !isPopulated) { + return ( + + ); + } + + if (!isFetching && !!error) { + return ( +
Unable to load root folders
+ ); + } + + return ( + + + { + items.map((rootFolder) => { + return ( + + ); + }) + } + +
+ ); +} + +RootFolders.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default RootFolders; diff --git a/frontend/src/RootFolder/RootFoldersConnector.js b/frontend/src/RootFolder/RootFoldersConnector.js new file mode 100644 index 000000000..39f140bcc --- /dev/null +++ b/frontend/src/RootFolder/RootFoldersConnector.js @@ -0,0 +1,46 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import RootFolders from './RootFolders'; + +function createMapStateToProps() { + return createSelector( + (state) => state.rootFolders, + (rootFolders) => { + return rootFolders; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchRootFolders: fetchRootFolders +}; + +class RootFoldersConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchRootFolders(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +RootFoldersConnector.propTypes = { + dispatchFetchRootFolders: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(RootFoldersConnector); diff --git a/frontend/src/Settings/AdvancedSettingsButton.css b/frontend/src/Settings/AdvancedSettingsButton.css new file mode 100644 index 000000000..5f0d3b9f2 --- /dev/null +++ b/frontend/src/Settings/AdvancedSettingsButton.css @@ -0,0 +1,31 @@ +.button { + composes: toolbarButton from '~Components/Page/Toolbar/PageToolbarButton.css'; + + position: relative; +} + +.labelContainer { + composes: labelContainer from '~Components/Page/Toolbar/PageToolbarButton.css'; +} + +.label { + composes: label from '~Components/Page/Toolbar/PageToolbarButton.css'; +} + +.indicatorContainer { + position: absolute; + top: 10px; + right: 12px; +} + +.indicatorBackground { + color: $themeDarkColor; +} + +.enabled { + color: $successColor; +} + +.disabled { + color: $dangerColor; +} diff --git a/frontend/src/Settings/AdvancedSettingsButton.js b/frontend/src/Settings/AdvancedSettingsButton.js new file mode 100644 index 000000000..12d9902d5 --- /dev/null +++ b/frontend/src/Settings/AdvancedSettingsButton.js @@ -0,0 +1,59 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import styles from './AdvancedSettingsButton.css'; + +function AdvancedSettingsButton(props) { + const { + advancedSettings, + onAdvancedSettingsPress + } = props; + + return ( + + + + + + + + + +
+
+ {advancedSettings ? 'Hide Advanced' : 'Show Advanced'} +
+
+ + ); +} + +AdvancedSettingsButton.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + onAdvancedSettingsPress: PropTypes.func.isRequired +}; + +export default AdvancedSettingsButton; diff --git a/frontend/src/Settings/DownloadClients/DownloadClientSettings.js b/frontend/src/Settings/DownloadClients/DownloadClientSettings.js new file mode 100644 index 000000000..c82604a88 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClientSettings.js @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import React, { Component, Fragment } from 'react'; +import { icons } from 'Helpers/Props'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector'; +import DownloadClientOptionsConnector from './Options/DownloadClientOptionsConnector'; +import RemotePathMappingsConnector from './RemotePathMappings/RemotePathMappingsConnector'; + +class DownloadClientSettings extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._saveCallback = null; + + this.state = { + isSaving: false, + hasPendingChanges: false + }; + } + + // + // Listeners + + onChildMounted = (saveCallback) => { + this._saveCallback = saveCallback; + } + + onChildStateChange = (payload) => { + this.setState(payload); + } + + onSavePress = () => { + if (this._saveCallback) { + this._saveCallback(); + } + } + + // + // Render + + render() { + const { + isTestingAll, + dispatchTestAllDownloadClients + } = this.props; + + const { + isSaving, + hasPendingChanges + } = this.state; + + return ( + + + + + + + } + onSavePress={this.onSavePress} + /> + + + + + + + + + + ); + } +} + +DownloadClientSettings.propTypes = { + isTestingAll: PropTypes.bool.isRequired, + dispatchTestAllDownloadClients: PropTypes.func.isRequired +}; + +export default DownloadClientSettings; diff --git a/frontend/src/Settings/DownloadClients/DownloadClientSettingsConnector.js b/frontend/src/Settings/DownloadClients/DownloadClientSettingsConnector.js new file mode 100644 index 000000000..5e1a8a1ca --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClientSettingsConnector.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { testAllDownloadClients } from 'Store/Actions/settingsActions'; +import DownloadClientSettings from './DownloadClientSettings'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.downloadClients.isTestingAll, + (isTestingAll) => { + return { + isTestingAll + }; + } + ); +} + +const mapDispatchToProps = { + dispatchTestAllDownloadClients: testAllDownloadClients +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientSettings); diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.css b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.css new file mode 100644 index 000000000..a3d90cc5a --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.css @@ -0,0 +1,44 @@ +.downloadClient { + composes: card from '~Components/Card.css'; + + position: relative; + width: 300px; + height: 100px; +} + +.underlay { + @add-mixin cover; +} + +.overlay { + @add-mixin linkOverlay; + + padding: 10px; +} + +.name { + text-align: center; + font-weight: lighter; + font-size: 24px; +} + +.actions { + margin-top: 20px; + text-align: right; +} + +.presetsMenu { + composes: menu from '~Components/Menu/Menu.css'; + + display: inline-block; + margin: 0 5px; +} + +.presetsMenuButton { + composes: button from '~Components/Link/Button.css'; + + &::after { + margin-left: 5px; + content: '\25BE'; + } +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.js new file mode 100644 index 000000000..3a2265d28 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.js @@ -0,0 +1,110 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Link from 'Components/Link/Link'; +import Menu from 'Components/Menu/Menu'; +import MenuContent from 'Components/Menu/MenuContent'; +import AddDownloadClientPresetMenuItem from './AddDownloadClientPresetMenuItem'; +import styles from './AddDownloadClientItem.css'; + +class AddDownloadClientItem extends Component { + + // + // Listeners + + onDownloadClientSelect = () => { + const { + implementation + } = this.props; + + this.props.onDownloadClientSelect({ implementation }); + } + + // + // Render + + render() { + const { + implementation, + implementationName, + infoLink, + presets, + onDownloadClientSelect + } = this.props; + + const hasPresets = !!presets && !!presets.length; + + return ( +
+ + +
+
+ {implementationName} +
+ +
+ { + hasPresets && + + + + + + + + { + presets.map((preset) => { + return ( + + ); + }) + } + + + + } + + +
+
+
+ ); + } +} + +AddDownloadClientItem.propTypes = { + implementation: PropTypes.string.isRequired, + implementationName: PropTypes.string.isRequired, + infoLink: PropTypes.string.isRequired, + presets: PropTypes.arrayOf(PropTypes.object), + onDownloadClientSelect: PropTypes.func.isRequired +}; + +export default AddDownloadClientItem; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModal.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModal.js new file mode 100644 index 000000000..0c21e7dbd --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import AddDownloadClientModalContentConnector from './AddDownloadClientModalContentConnector'; + +function AddDownloadClientModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +AddDownloadClientModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddDownloadClientModal; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.css b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.css new file mode 100644 index 000000000..b4d5c6787 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.css @@ -0,0 +1,5 @@ +.downloadClients { + display: flex; + justify-content: center; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.js new file mode 100644 index 000000000..5da3e34dc --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import FieldSet from 'Components/FieldSet'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import AddDownloadClientItem from './AddDownloadClientItem'; +import styles from './AddDownloadClientModalContent.css'; + +class AddDownloadClientModalContent extends Component { + + // + // Render + + render() { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + usenetDownloadClients, + torrentDownloadClients, + onDownloadClientSelect, + onModalClose + } = this.props; + + return ( + + + Add Download Client + + + + { + isSchemaFetching && + + } + + { + !isSchemaFetching && !!schemaError && +
Unable to add a new downloadClient, please try again.
+ } + + { + isSchemaPopulated && !schemaError && +
+ + +
Lidarr supports any downloadClient that uses the Newznab standard, as well as other downloadClients listed below.
+
For more information on the individual downloadClients, click on the info buttons.
+
+ +
+
+ { + usenetDownloadClients.map((downloadClient) => { + return ( + + ); + }) + } +
+
+ +
+
+ { + torrentDownloadClients.map((downloadClient) => { + return ( + + ); + }) + } +
+
+
+ } +
+ + + +
+ ); + } +} + +AddDownloadClientModalContent.propTypes = { + isSchemaFetching: PropTypes.bool.isRequired, + isSchemaPopulated: PropTypes.bool.isRequired, + schemaError: PropTypes.object, + usenetDownloadClients: PropTypes.arrayOf(PropTypes.object).isRequired, + torrentDownloadClients: PropTypes.arrayOf(PropTypes.object).isRequired, + onDownloadClientSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddDownloadClientModalContent; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContentConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContentConnector.js new file mode 100644 index 000000000..99d5c4f19 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContentConnector.js @@ -0,0 +1,75 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchDownloadClientSchema, selectDownloadClientSchema } from 'Store/Actions/settingsActions'; +import AddDownloadClientModalContent from './AddDownloadClientModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.downloadClients, + (downloadClients) => { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + schema + } = downloadClients; + + const usenetDownloadClients = _.filter(schema, { protocol: 'usenet' }); + const torrentDownloadClients = _.filter(schema, { protocol: 'torrent' }); + + return { + isSchemaFetching, + isSchemaPopulated, + schemaError, + usenetDownloadClients, + torrentDownloadClients + }; + } + ); +} + +const mapDispatchToProps = { + fetchDownloadClientSchema, + selectDownloadClientSchema +}; + +class AddDownloadClientModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchDownloadClientSchema(); + } + + // + // Listeners + + onDownloadClientSelect = ({ implementation }) => { + this.props.selectDownloadClientSchema({ implementation }); + this.props.onModalClose({ downloadClientSelected: true }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AddDownloadClientModalContentConnector.propTypes = { + fetchDownloadClientSchema: PropTypes.func.isRequired, + selectDownloadClientSchema: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AddDownloadClientModalContentConnector); diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientPresetMenuItem.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientPresetMenuItem.js new file mode 100644 index 000000000..f356f8140 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientPresetMenuItem.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import MenuItem from 'Components/Menu/MenuItem'; + +class AddDownloadClientPresetMenuItem extends Component { + + // + // Listeners + + onPress = () => { + const { + name, + implementation + } = this.props; + + this.props.onPress({ + name, + implementation + }); + } + + // + // Render + + render() { + const { + name, + implementation, + ...otherProps + } = this.props; + + return ( + + {name} + + ); + } +} + +AddDownloadClientPresetMenuItem.propTypes = { + name: PropTypes.string.isRequired, + implementation: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired +}; + +export default AddDownloadClientPresetMenuItem; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.css b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.css new file mode 100644 index 000000000..8eea80383 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.css @@ -0,0 +1,19 @@ +.downloadClient { + composes: card from '~Components/Card.css'; + + width: 290px; +} + +.name { + @add-mixin truncate; + + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.enabled { + display: flex; + flex-wrap: wrap; + margin-top: 5px; +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js new file mode 100644 index 000000000..6a86fef16 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js @@ -0,0 +1,113 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditDownloadClientModalConnector from './EditDownloadClientModalConnector'; +import styles from './DownloadClient.css'; + +class DownloadClient extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditDownloadClientModalOpen: false, + isDeleteDownloadClientModalOpen: false + }; + } + + // + // Listeners + + onEditDownloadClientPress = () => { + this.setState({ isEditDownloadClientModalOpen: true }); + } + + onEditDownloadClientModalClose = () => { + this.setState({ isEditDownloadClientModalOpen: false }); + } + + onDeleteDownloadClientPress = () => { + this.setState({ + isEditDownloadClientModalOpen: false, + isDeleteDownloadClientModalOpen: true + }); + } + + onDeleteDownloadClientModalClose= () => { + this.setState({ isDeleteDownloadClientModalOpen: false }); + } + + onConfirmDeleteDownloadClient = () => { + this.props.onConfirmDeleteDownloadClient(this.props.id); + } + + // + // Render + + render() { + const { + id, + name, + enable + } = this.props; + + return ( + +
+ {name} +
+ +
+ { + enable ? + : + + } +
+ + + + +
+ ); + } +} + +DownloadClient.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + enable: PropTypes.bool.isRequired, + onConfirmDeleteDownloadClient: PropTypes.func.isRequired +}; + +export default DownloadClient; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.css b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.css new file mode 100644 index 000000000..81b4f1510 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.css @@ -0,0 +1,20 @@ +.downloadClients { + display: flex; + flex-wrap: wrap; +} + +.addDownloadClient { + composes: downloadClient from '~./DownloadClient.css'; + + background-color: $cardAlternateBackgroundColor; + color: $gray; + text-align: center; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js new file mode 100644 index 000000000..029845025 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import sortByName from 'Utilities/Array/sortByName'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Card from 'Components/Card'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import DownloadClient from './DownloadClient'; +import AddDownloadClientModal from './AddDownloadClientModal'; +import EditDownloadClientModalConnector from './EditDownloadClientModalConnector'; +import styles from './DownloadClients.css'; + +class DownloadClients extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddDownloadClientModalOpen: false, + isEditDownloadClientModalOpen: false + }; + } + + // + // Listeners + + onAddDownloadClientPress = () => { + this.setState({ isAddDownloadClientModalOpen: true }); + } + + onAddDownloadClientModalClose = ({ downloadClientSelected = false } = {}) => { + this.setState({ + isAddDownloadClientModalOpen: false, + isEditDownloadClientModalOpen: downloadClientSelected + }); + } + + onEditDownloadClientModalClose = () => { + this.setState({ isEditDownloadClientModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + onConfirmDeleteDownloadClient, + ...otherProps + } = this.props; + + const { + isAddDownloadClientModalOpen, + isEditDownloadClientModalOpen + } = this.state; + + return ( +
+ +
+ { + items.sort(sortByName).map((item) => { + return ( + + ); + }) + } + + +
+ +
+
+
+ + + + +
+
+ ); + } +} + +DownloadClients.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteDownloadClient: PropTypes.func.isRequired +}; + +export default DownloadClients; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js new file mode 100644 index 000000000..d318bc163 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js @@ -0,0 +1,58 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchDownloadClients, deleteDownloadClient } from 'Store/Actions/settingsActions'; +import DownloadClients from './DownloadClients'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.downloadClients, + (downloadClients) => { + return { + ...downloadClients + }; + } + ); +} + +const mapDispatchToProps = { + fetchDownloadClients, + deleteDownloadClient +}; + +class DownloadClientsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchDownloadClients(); + } + + // + // Listeners + + onConfirmDeleteDownloadClient = (id) => { + this.props.deleteDownloadClient({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +DownloadClientsConnector.propTypes = { + fetchDownloadClients: PropTypes.func.isRequired, + deleteDownloadClient: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientsConnector); diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModal.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModal.js new file mode 100644 index 000000000..f6b07599c --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditDownloadClientModalContentConnector from './EditDownloadClientModalContentConnector'; + +function EditDownloadClientModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditDownloadClientModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditDownloadClientModal; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalConnector.js new file mode 100644 index 000000000..b5e5520fb --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalConnector.js @@ -0,0 +1,65 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import { cancelTestDownloadClient, cancelSaveDownloadClient } from 'Store/Actions/settingsActions'; +import EditDownloadClientModal from './EditDownloadClientModal'; + +function createMapDispatchToProps(dispatch, props) { + const section = 'settings.downloadClients'; + + return { + dispatchClearPendingChanges() { + dispatch(clearPendingChanges({ section })); + }, + + dispatchCancelTestDownloadClient() { + dispatch(cancelTestDownloadClient({ section })); + }, + + dispatchCancelSaveDownloadClient() { + dispatch(cancelSaveDownloadClient({ section })); + } + }; +} + +class EditDownloadClientModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.dispatchClearPendingChanges(); + this.props.dispatchCancelTestDownloadClient(); + this.props.dispatchCancelSaveDownloadClient(); + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + dispatchClearPendingChanges, + dispatchCancelTestDownloadClient, + dispatchCancelSaveDownloadClient, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +EditDownloadClientModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired, + dispatchCancelTestDownloadClient: PropTypes.func.isRequired, + dispatchCancelSaveDownloadClient: PropTypes.func.isRequired +}; + +export default connect(null, createMapDispatchToProps)(EditDownloadClientModalConnector); diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.css b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.css new file mode 100644 index 000000000..8e1c16507 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.css @@ -0,0 +1,11 @@ +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: auto; +} + +.message { + composes: alert from '~Components/Alert.css'; + + margin-bottom: 30px; +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js new file mode 100644 index 000000000..6071479d8 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js @@ -0,0 +1,176 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; +import styles from './EditDownloadClientModalContent.css'; + +class EditDownloadClientModalContent extends Component { + + // + // Render + + render() { + const { + advancedSettings, + isFetching, + error, + isSaving, + isTesting, + saveError, + item, + onInputChange, + onFieldChange, + onModalClose, + onSavePress, + onTestPress, + onDeleteDownloadClientPress, + ...otherProps + } = this.props; + + const { + id, + implementationName, + name, + enable, + fields, + message + } = item; + + return ( + + + {`${id ? 'Edit' : 'Add'} Download Client - ${implementationName}`} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to add a new download client, please try again.
+ } + + { + !isFetching && !error && +
+ { + !!message && + + {message.value.message} + + } + + + Name + + + + + + Enable + + + + + { + fields.map((field) => { + return ( + + ); + }) + } + + + } +
+ + { + id && + + } + + + Test + + + + + + Save + + +
+ ); + } +} + +EditDownloadClientModalContent.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + isTesting: PropTypes.bool.isRequired, + item: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired, + onFieldChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onTestPress: PropTypes.func.isRequired, + onDeleteDownloadClientPress: PropTypes.func +}; + +export default EditDownloadClientModalContent; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContentConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContentConnector.js new file mode 100644 index 000000000..75f6f0bc3 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContentConnector.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import { setDownloadClientValue, setDownloadClientFieldValue, saveDownloadClient, testDownloadClient } from 'Store/Actions/settingsActions'; +import EditDownloadClientModalContent from './EditDownloadClientModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createProviderSettingsSelector('downloadClients'), + (advancedSettings, downloadClient) => { + return { + advancedSettings, + ...downloadClient + }; + } + ); +} + +const mapDispatchToProps = { + setDownloadClientValue, + setDownloadClientFieldValue, + saveDownloadClient, + testDownloadClient +}; + +class EditDownloadClientModalContentConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setDownloadClientValue({ name, value }); + } + + onFieldChange = ({ name, value }) => { + this.props.setDownloadClientFieldValue({ name, value }); + } + + onSavePress = () => { + this.props.saveDownloadClient({ id: this.props.id }); + } + + onTestPress = () => { + this.props.testDownloadClient({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditDownloadClientModalContentConnector.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setDownloadClientValue: PropTypes.func.isRequired, + setDownloadClientFieldValue: PropTypes.func.isRequired, + saveDownloadClient: PropTypes.func.isRequired, + testDownloadClient: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditDownloadClientModalContentConnector); diff --git a/frontend/src/Settings/DownloadClients/Options/DownloadClientOptions.js b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptions.js new file mode 100644 index 000000000..c345feb5b --- /dev/null +++ b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptions.js @@ -0,0 +1,116 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function DownloadClientOptions(props) { + const { + advancedSettings, + isFetching, + error, + settings, + hasSettings, + onInputChange + } = props; + + return ( +
+ { + isFetching && + + } + + { + !isFetching && error && +
Unable to load download client options
+ } + + { + hasSettings && !isFetching && !error && +
+
+
+ + Enable + + + + + + Remove + + + +
+
+ +
+
+ + Redownload + + + + + + Remove + + + +
+
+
+ } +
+ ); +} + +DownloadClientOptions.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + settings: PropTypes.object.isRequired, + hasSettings: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default DownloadClientOptions; diff --git a/frontend/src/Settings/DownloadClients/Options/DownloadClientOptionsConnector.js b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptionsConnector.js new file mode 100644 index 000000000..d709481b1 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptionsConnector.js @@ -0,0 +1,101 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import { fetchDownloadClientOptions, setDownloadClientOptionsValue, saveDownloadClientOptions } from 'Store/Actions/settingsActions'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import DownloadClientOptions from './DownloadClientOptions'; + +const SECTION = 'downloadClientOptions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createSettingsSectionSelector(SECTION), + (advancedSettings, sectionSettings) => { + return { + advancedSettings, + ...sectionSettings + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchDownloadClientOptions: fetchDownloadClientOptions, + dispatchSetDownloadClientOptionsValue: setDownloadClientOptionsValue, + dispatchSaveDownloadClientOptions: saveDownloadClientOptions, + dispatchClearPendingChanges: clearPendingChanges +}; + +class DownloadClientOptionsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + dispatchFetchDownloadClientOptions, + dispatchSaveDownloadClientOptions, + onChildMounted + } = this.props; + + dispatchFetchDownloadClientOptions(); + onChildMounted(dispatchSaveDownloadClientOptions); + } + + componentDidUpdate(prevProps) { + const { + hasPendingChanges, + isSaving, + onChildStateChange + } = this.props; + + if ( + prevProps.isSaving !== isSaving || + prevProps.hasPendingChanges !== hasPendingChanges + ) { + onChildStateChange({ + isSaving, + hasPendingChanges + }); + } + } + + componentWillUnmount() { + this.props.dispatchClearPendingChanges({ section: 'settings.downloadClientOptions' }); + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.dispatchSetDownloadClientOptionsValue({ name, value }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +DownloadClientOptionsConnector.propTypes = { + isSaving: PropTypes.bool.isRequired, + hasPendingChanges: PropTypes.bool.isRequired, + dispatchFetchDownloadClientOptions: PropTypes.func.isRequired, + dispatchSetDownloadClientOptionsValue: PropTypes.func.isRequired, + dispatchSaveDownloadClientOptions: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired, + onChildMounted: PropTypes.func.isRequired, + onChildStateChange: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientOptionsConnector); diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModal.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModal.js new file mode 100644 index 000000000..f66113619 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditRemotePathMappingModalContentConnector from './EditRemotePathMappingModalContentConnector'; + +function EditRemotePathMappingModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditRemotePathMappingModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditRemotePathMappingModal; diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalConnector.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalConnector.js new file mode 100644 index 000000000..94172429d --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalConnector.js @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditRemotePathMappingModal from './EditRemotePathMappingModal'; + +function mapStateToProps() { + return {}; +} + +const mapDispatchToProps = { + clearPendingChanges +}; + +class EditRemotePathMappingModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'settings.remotePathMappings' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditRemotePathMappingModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(mapStateToProps, mapDispatchToProps)(EditRemotePathMappingModalConnector); diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.css b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.css new file mode 100644 index 000000000..97e132552 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.css @@ -0,0 +1,11 @@ +.body { + composes: modalBody from '~Components/Modal/ModalBody.css'; + + flex: 1 1 430px; +} + +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.js new file mode 100644 index 000000000..afb891e0f --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.js @@ -0,0 +1,150 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import { stringSettingShape } from 'Helpers/Props/Shapes/settingShape'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import styles from './EditRemotePathMappingModalContent.css'; + +function EditRemotePathMappingModalContent(props) { + const { + id, + isFetching, + error, + isSaving, + saveError, + item, + downloadClientHosts, + onInputChange, + onSavePress, + onModalClose, + onDeleteRemotePathMappingPress, + ...otherProps + } = props; + + const { + host, + remotePath, + localPath + } = item; + + return ( + + + {id ? 'Edit Remote Path Mapping' : 'Add Remote Path Mapping'} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to add a new remote path mapping, please try again.
+ } + + { + !isFetching && !error && +
+ + Host + + + + + + Remote Path + + + + + + Local Path + + + +
+ } +
+ + + { + id && + + } + + + + + Save + + +
+ ); +} + +const remotePathMappingShape = { + host: PropTypes.shape(stringSettingShape).isRequired, + remotePath: PropTypes.shape(stringSettingShape).isRequired, + localPath: PropTypes.shape(stringSettingShape).isRequired +}; + +EditRemotePathMappingModalContent.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.shape(remotePathMappingShape).isRequired, + downloadClientHosts: PropTypes.arrayOf(PropTypes.object).isRequired, + onInputChange: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteRemotePathMappingPress: PropTypes.func +}; + +export default EditRemotePathMappingModalContent; diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js new file mode 100644 index 000000000..df7f59f52 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js @@ -0,0 +1,148 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; +import { setRemotePathMappingValue, saveRemotePathMapping } from 'Store/Actions/settingsActions'; +import EditRemotePathMappingModalContent from './EditRemotePathMappingModalContent'; + +const newRemotePathMapping = { + host: '', + remotePath: '', + localPath: '' +}; + +const selectDownloadClientHosts = createSelector( + (state) => state.settings.downloadClients.items, + (downloadClients) => { + const hosts = downloadClients.reduce((acc, downloadClient) => { + const name = downloadClient.name; + const host = downloadClient.fields.find((field) => { + return field.name === 'host'; + }); + + if (host) { + const group = acc[host.value] = acc[host.value] || []; + group.push(name); + } + + return acc; + }, {}); + + return Object.keys(hosts).map((host) => { + return { + key: host, + value: host, + hint: `${hosts[host].join(', ')}` + }; + }); + } +); + +function createRemotePathMappingSelector() { + return createSelector( + (state, { id }) => id, + (state) => state.settings.remotePathMappings, + selectDownloadClientHosts, + (id, remotePathMappings, downloadClientHosts) => { + const { + isFetching, + error, + isSaving, + saveError, + pendingChanges, + items + } = remotePathMappings; + + const mapping = id ? _.find(items, { id }) : newRemotePathMapping; + const settings = selectSettings(mapping, pendingChanges, saveError); + + return { + id, + isFetching, + error, + isSaving, + saveError, + item: settings.settings, + ...settings, + downloadClientHosts + }; + } + ); +} + +function createMapStateToProps() { + return createSelector( + createRemotePathMappingSelector(), + (remotePathMapping) => { + return { + ...remotePathMapping + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetRemotePathMappingValue: setRemotePathMappingValue, + dispatchSaveRemotePathMapping: saveRemotePathMapping +}; + +class EditRemotePathMappingModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + if (!this.props.id) { + Object.keys(newRemotePathMapping).forEach((name) => { + this.props.dispatchSetRemotePathMappingValue({ + name, + value: newRemotePathMapping[name] + }); + }); + } + } + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.dispatchSetRemotePathMappingValue({ name, value }); + } + + onSavePress = () => { + this.props.dispatchSaveRemotePathMapping({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditRemotePathMappingModalContentConnector.propTypes = { + id: PropTypes.number, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + dispatchSetRemotePathMappingValue: PropTypes.func.isRequired, + dispatchSaveRemotePathMapping: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditRemotePathMappingModalContentConnector); diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.css b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.css new file mode 100644 index 000000000..13f35bed4 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.css @@ -0,0 +1,27 @@ +.remotePathMapping { + display: flex; + align-items: stretch; + margin-bottom: 10px; + height: 30px; + border-bottom: 1px solid $borderColor; + line-height: 30px; +} + +.host { + @add-mixin truncate; + + flex: 0 1 300px; +} + +.path { + @add-mixin truncate; + + flex: 0 1 400px; +} + +.actions { + display: flex; + justify-content: flex-end; + flex: 1 0 auto; + padding-right: 10px; +} diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.js new file mode 100644 index 000000000..0633b28ff --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.js @@ -0,0 +1,114 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditRemotePathMappingModalConnector from './EditRemotePathMappingModalConnector'; +import styles from './RemotePathMapping.css'; + +class RemotePathMapping extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditRemotePathMappingModalOpen: false, + isDeleteRemotePathMappingModalOpen: false + }; + } + + // + // Listeners + + onEditRemotePathMappingPress = () => { + this.setState({ isEditRemotePathMappingModalOpen: true }); + } + + onEditRemotePathMappingModalClose = () => { + this.setState({ isEditRemotePathMappingModalOpen: false }); + } + + onDeleteRemotePathMappingPress = () => { + this.setState({ + isEditRemotePathMappingModalOpen: false, + isDeleteRemotePathMappingModalOpen: true + }); + } + + onDeleteRemotePathMappingModalClose = () => { + this.setState({ isDeleteRemotePathMappingModalOpen: false }); + } + + onConfirmDeleteRemotePathMapping = () => { + this.props.onConfirmDeleteRemotePathMapping(this.props.id); + } + + // + // Render + + render() { + const { + id, + host, + remotePath, + localPath + } = this.props; + + return ( +
+
{host}
+
{remotePath}
+
{localPath}
+ +
+ + + +
+ + + + +
+ ); + } +} + +RemotePathMapping.propTypes = { + id: PropTypes.number.isRequired, + host: PropTypes.string.isRequired, + remotePath: PropTypes.string.isRequired, + localPath: PropTypes.string.isRequired, + onConfirmDeleteRemotePathMapping: PropTypes.func.isRequired +}; + +RemotePathMapping.defaultProps = { + // The drag preview will not connect the drag handle. + connectDragSource: (node) => node +}; + +export default RemotePathMapping; diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.css b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.css new file mode 100644 index 000000000..6d0079fd9 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.css @@ -0,0 +1,27 @@ +.remotePathMappingsHeader { + display: flex; + margin-bottom: 10px; + font-weight: bold; +} + +.host { + @add-mixin truncate; + + flex: 0 1 300px; +} + +.path { + @add-mixin truncate; + + flex: 0 1 400px; +} + +.addRemotePathMapping { + display: flex; + justify-content: flex-end; + padding-right: 10px; +} + +.addButton { + text-align: center; +} diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.js new file mode 100644 index 000000000..f633a3279 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.js @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import RemotePathMapping from './RemotePathMapping'; +import EditRemotePathMappingModalConnector from './EditRemotePathMappingModalConnector'; +import styles from './RemotePathMappings.css'; + +class RemotePathMappings extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddRemotePathMappingModalOpen: false + }; + } + + // + // Listeners + + onAddRemotePathMappingPress = () => { + this.setState({ isAddRemotePathMappingModalOpen: true }); + } + + onModalClose = () => { + this.setState({ isAddRemotePathMappingModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + onConfirmDeleteRemotePathMapping, + ...otherProps + } = this.props; + + return ( +
+ +
+
Host
+
Remote Path
+
Local Path
+
+ +
+ { + items.map((item, index) => { + return ( + + ); + }) + } +
+ +
+ + + +
+ + +
+
+ ); + } +} + +RemotePathMappings.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteRemotePathMapping: PropTypes.func.isRequired +}; + +export default RemotePathMappings; diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappingsConnector.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappingsConnector.js new file mode 100644 index 000000000..7a029818a --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappingsConnector.js @@ -0,0 +1,59 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchRemotePathMappings, deleteRemotePathMapping } from 'Store/Actions/settingsActions'; +import RemotePathMappings from './RemotePathMappings'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.remotePathMappings, + (remotePathMappings) => { + return { + ...remotePathMappings + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchRemotePathMappings: fetchRemotePathMappings, + dispatchDeleteRemotePathMapping: deleteRemotePathMapping +}; + +class RemotePathMappingsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchRemotePathMappings(); + } + + // + // Listeners + + onConfirmDeleteRemotePathMapping = (id) => { + this.props.dispatchDeleteRemotePathMapping({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +RemotePathMappingsConnector.propTypes = { + dispatchFetchRemotePathMappings: PropTypes.func.isRequired, + dispatchDeleteRemotePathMapping: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(RemotePathMappingsConnector); diff --git a/frontend/src/Settings/General/AnalyticSettings.js b/frontend/src/Settings/General/AnalyticSettings.js new file mode 100644 index 000000000..6854e6d62 --- /dev/null +++ b/frontend/src/Settings/General/AnalyticSettings.js @@ -0,0 +1,42 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function AnalyticSettings(props) { + const { + settings, + onInputChange + } = props; + + const { + analyticsEnabled + } = settings; + + return ( +
+ + Send Anonymous Usage Data + + + +
+ ); +} + +AnalyticSettings.propTypes = { + settings: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default AnalyticSettings; diff --git a/frontend/src/Settings/General/BackupSettings.js b/frontend/src/Settings/General/BackupSettings.js new file mode 100644 index 000000000..9ed2c5035 --- /dev/null +++ b/frontend/src/Settings/General/BackupSettings.js @@ -0,0 +1,84 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function BackupSettings(props) { + const { + advancedSettings, + settings, + onInputChange + } = props; + + const { + backupFolder, + backupInterval, + backupRetention + } = settings; + + if (!advancedSettings) { + return null; + } + + return ( +
+ + Folder + + + + + + Interval + + + + + + Retention + + + +
+ ); +} + +BackupSettings.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + settings: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default BackupSettings; diff --git a/frontend/src/Settings/General/GeneralSettings.js b/frontend/src/Settings/General/GeneralSettings.js new file mode 100644 index 000000000..485763610 --- /dev/null +++ b/frontend/src/Settings/General/GeneralSettings.js @@ -0,0 +1,217 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import Form from 'Components/Form/Form'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import AnalyticSettings from './AnalyticSettings'; +import BackupSettings from './BackupSettings'; +import HostSettings from './HostSettings'; +import LoggingSettings from './LoggingSettings'; +import ProxySettings from './ProxySettings'; +import SecuritySettings from './SecuritySettings'; +import UpdateSettings from './UpdateSettings'; + +const requiresRestartKeys = [ + 'bindAddress', + 'port', + 'urlBase', + 'enableSsl', + 'sslPort', + 'sslCertHash', + 'authenticationMethod', + 'username', + 'password', + 'apiKey' +]; + +class GeneralSettings extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isRestartRequiredModalOpen: false + }; + } + + componentDidUpdate(prevProps) { + const { + settings, + isSaving, + saveError + } = this.props; + + if (isSaving || saveError || !prevProps.isSaving) { + return; + } + + const prevSettings = prevProps.settings; + + const pendingRestart = _.some(requiresRestartKeys, (key) => { + const setting = settings[key]; + const prevSetting = prevSettings[key]; + + if (!setting || !prevSetting) { + return false; + } + + const previousValue = prevSetting.previousValue; + const value = setting.value; + + return previousValue != null && previousValue !== value; + }); + + this.setState({ isRestartRequiredModalOpen: pendingRestart }); + } + + // + // Listeners + + onConfirmRestart = () => { + this.setState({ isRestartRequiredModalOpen: false }); + this.props.onConfirmRestart(); + } + + onCloseRestartRequiredModalOpen = () => { + this.setState({ isRestartRequiredModalOpen: false }); + } + + // + // Render + + render() { + const { + advancedSettings, + isFetching, + isPopulated, + error, + settings, + hasSettings, + isResettingApiKey, + isMono, + isWindows, + isWindowsService, + isDocker, + mode, + onInputChange, + onConfirmResetApiKey, + ...otherProps + } = this.props; + + return ( + + + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && error && +
Unable to load General settings
+ } + + { + hasSettings && isPopulated && !error && +
+ + + + + + + + + + + + + + + } +
+ + +
+ ); + } + +} + +GeneralSettings.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + settings: PropTypes.object.isRequired, + isResettingApiKey: PropTypes.bool.isRequired, + hasSettings: PropTypes.bool.isRequired, + isMono: PropTypes.bool.isRequired, + isWindows: PropTypes.bool.isRequired, + isWindowsService: PropTypes.bool.isRequired, + isDocker: PropTypes.bool.isRequired, + mode: PropTypes.string.isRequired, + onInputChange: PropTypes.func.isRequired, + onConfirmResetApiKey: PropTypes.func.isRequired, + onConfirmRestart: PropTypes.func.isRequired +}; + +export default GeneralSettings; diff --git a/frontend/src/Settings/General/GeneralSettingsConnector.js b/frontend/src/Settings/General/GeneralSettingsConnector.js new file mode 100644 index 000000000..1c64b5724 --- /dev/null +++ b/frontend/src/Settings/General/GeneralSettingsConnector.js @@ -0,0 +1,111 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; +import { setGeneralSettingsValue, saveGeneralSettings, fetchGeneralSettings } from 'Store/Actions/settingsActions'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { restart } from 'Store/Actions/systemActions'; +import * as commandNames from 'Commands/commandNames'; +import GeneralSettings from './GeneralSettings'; + +const SECTION = 'general'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createSettingsSectionSelector(SECTION), + createCommandExecutingSelector(commandNames.RESET_API_KEY), + createSystemStatusSelector(), + (advancedSettings, sectionSettings, isResettingApiKey, systemStatus) => { + return { + advancedSettings, + isResettingApiKey, + isMono: systemStatus.isMono, + isWindows: systemStatus.isWindows, + isWindowsService: systemStatus.isWindows && systemStatus.mode === 'service', + isDocker: systemStatus.isDocker, + mode: systemStatus.mode, + ...sectionSettings + }; + } + ); +} + +const mapDispatchToProps = { + setGeneralSettingsValue, + saveGeneralSettings, + fetchGeneralSettings, + executeCommand, + restart, + clearPendingChanges +}; + +class GeneralSettingsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchGeneralSettings(); + } + + componentDidUpdate(prevProps) { + if (!this.props.isResettingApiKey && prevProps.isResettingApiKey) { + this.props.fetchGeneralSettings(); + } + } + + componentWillUnmount() { + this.props.clearPendingChanges({ section: `settings.${SECTION}` }); + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setGeneralSettingsValue({ name, value }); + } + + onSavePress = () => { + this.props.saveGeneralSettings(); + } + + onConfirmResetApiKey = () => { + this.props.executeCommand({ name: commandNames.RESET_API_KEY }); + } + + onConfirmRestart = () => { + this.props.restart(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +GeneralSettingsConnector.propTypes = { + isResettingApiKey: PropTypes.bool.isRequired, + setGeneralSettingsValue: PropTypes.func.isRequired, + saveGeneralSettings: PropTypes.func.isRequired, + fetchGeneralSettings: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired, + restart: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(GeneralSettingsConnector); diff --git a/frontend/src/Settings/General/HostSettings.js b/frontend/src/Settings/General/HostSettings.js new file mode 100644 index 000000000..2f3de8562 --- /dev/null +++ b/frontend/src/Settings/General/HostSettings.js @@ -0,0 +1,157 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function HostSettings(props) { + const { + advancedSettings, + settings, + isWindows, + mode, + onInputChange + } = props; + + const { + bindAddress, + port, + urlBase, + enableSsl, + sslPort, + sslCertHash, + launchBrowser + } = settings; + + return ( +
+ + Bind Address + + + + + + Port Number + + + + + + URL Base + + + + + + Enable SSL + + + + + { + enableSsl.value ? + + SSL Port + + + : + null + } + + { + isWindows && enableSsl.value ? + + SSL Cert Hash + + + : + null + } + + { + isWindows && mode !== 'service' ? + + Open browser on start + + + : + null + } + +
+ ); +} + +HostSettings.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + settings: PropTypes.object.isRequired, + isWindows: PropTypes.bool.isRequired, + mode: PropTypes.string.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default HostSettings; diff --git a/frontend/src/Settings/General/LoggingSettings.js b/frontend/src/Settings/General/LoggingSettings.js new file mode 100644 index 000000000..39ec2fb63 --- /dev/null +++ b/frontend/src/Settings/General/LoggingSettings.js @@ -0,0 +1,48 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +const logLevelOptions = [ + { key: 'info', value: 'Info' }, + { key: 'debug', value: 'Debug' }, + { key: 'trace', value: 'Trace' } +]; + +function LoggingSettings(props) { + const { + settings, + onInputChange + } = props; + + const { + logLevel + } = settings; + + return ( +
+ + Log Level + + + +
+ ); +} + +LoggingSettings.propTypes = { + settings: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default LoggingSettings; diff --git a/frontend/src/Settings/General/ProxySettings.js b/frontend/src/Settings/General/ProxySettings.js new file mode 100644 index 000000000..5febc6b3a --- /dev/null +++ b/frontend/src/Settings/General/ProxySettings.js @@ -0,0 +1,141 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function ProxySettings(props) { + const { + settings, + onInputChange + } = props; + + const { + proxyEnabled, + proxyType, + proxyHostname, + proxyPort, + proxyUsername, + proxyPassword, + proxyBypassFilter, + proxyBypassLocalAddresses + } = settings; + + const proxyTypeOptions = [ + { key: 'http', value: 'HTTP(S)' }, + { key: 'socks4', value: 'Socks4' }, + { key: 'socks5', value: 'Socks5 (Support TOR)' } + ]; + + return ( +
+ + Use Proxy + + + + + { + proxyEnabled.value && +
+ + Proxy Type + + + + + + Hostname + + + + + + Port + + + + + + Username + + + + + + Password + + + + + + Ignored Addresses + + + + + + Bypass Proxy for Local Addresses + + + +
+ } +
+ ); +} + +ProxySettings.propTypes = { + settings: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default ProxySettings; diff --git a/frontend/src/Settings/General/SecuritySettings.js b/frontend/src/Settings/General/SecuritySettings.js new file mode 100644 index 000000000..82ed39d0c --- /dev/null +++ b/frontend/src/Settings/General/SecuritySettings.js @@ -0,0 +1,170 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, inputTypes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Icon from 'Components/Icon'; +import ClipboardButton from 'Components/Link/ClipboardButton'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormInputButton from 'Components/Form/FormInputButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; + +class SecuritySettings extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isConfirmApiKeyResetModalOpen: false + }; + } + + // + // Listeners + + onApikeyFocus = (event) => { + event.target.select(); + } + + onResetApiKeyPress = () => { + this.setState({ isConfirmApiKeyResetModalOpen: true }); + } + + onConfirmResetApiKey = () => { + this.setState({ isConfirmApiKeyResetModalOpen: false }); + this.props.onConfirmResetApiKey(); + } + + onCloseResetApiKeyModal = () => { + this.setState({ isConfirmApiKeyResetModalOpen: false }); + } + + // + // Render + + render() { + const { + settings, + isResettingApiKey, + onInputChange + } = this.props; + + const { + authenticationMethod, + username, + password, + apiKey + } = settings; + + const authenticationMethodOptions = [ + { key: 'none', value: 'None' }, + { key: 'basic', value: 'Basic (Browser Popup)' }, + { key: 'forms', value: 'Forms (Login Page)' } + ]; + + const authenticationEnabled = authenticationMethod && authenticationMethod.value !== 'none'; + + return ( +
+ + Authentication + + + + + { + authenticationEnabled && + + Username + + + + } + + { + authenticationEnabled && + + Password + + + + } + + + API Key + + , + + + + + ]} + onChange={onInputChange} + onFocus={this.onApikeyFocus} + {...apiKey} + /> + + + +
+ ); + } +} + +SecuritySettings.propTypes = { + settings: PropTypes.object.isRequired, + isResettingApiKey: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onConfirmResetApiKey: PropTypes.func.isRequired +}; + +export default SecuritySettings; diff --git a/frontend/src/Settings/General/UpdateSettings.js b/frontend/src/Settings/General/UpdateSettings.js new file mode 100644 index 000000000..d41f95f35 --- /dev/null +++ b/frontend/src/Settings/General/UpdateSettings.js @@ -0,0 +1,133 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +const branchValues = [ + 'develop', + 'nightly' +]; + +function UpdateSettings(props) { + const { + advancedSettings, + settings, + isMono, + isDocker, + onInputChange + } = props; + + const { + branch, + updateAutomatically, + updateMechanism, + updateScriptPath + } = settings; + + if (!advancedSettings) { + return null; + } + + const updateOptions = [ + { key: 'builtIn', value: 'Built-In' }, + { key: 'script', value: 'Script' } + ]; + + if (isDocker) { + return ( +
+
Updating is disabled inside a docker container. Update the container image instead.
+
+ ); + } + + return ( +
+ + Branch + + + + + { + isMono && +
+ + Automatic + + + + + + Mechanism + + + + + { + updateMechanism.value === 'script' && + + Script Path + + + + } +
+ } +
+ ); +} + +UpdateSettings.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + settings: PropTypes.object.isRequired, + isMono: PropTypes.bool.isRequired, + isDocker: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default UpdateSettings; diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModal.js b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModal.js new file mode 100644 index 000000000..72566b289 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditImportListExclusionModalContentConnector from './EditImportListExclusionModalContentConnector'; + +function EditImportListExclusionModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditImportListExclusionModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditImportListExclusionModal; diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalConnector.js b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalConnector.js new file mode 100644 index 000000000..f9a511675 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalConnector.js @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditImportListExclusionModal from './EditImportListExclusionModal'; + +function mapStateToProps() { + return {}; +} + +const mapDispatchToProps = { + clearPendingChanges +}; + +class EditImportListExclusionModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'settings.importListExclusions' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditImportListExclusionModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(mapStateToProps, mapDispatchToProps)(EditImportListExclusionModalConnector); diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.css b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.css new file mode 100644 index 000000000..97e132552 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.css @@ -0,0 +1,11 @@ +.body { + composes: modalBody from '~Components/Modal/ModalBody.css'; + + flex: 1 1 430px; +} + +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.js b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.js new file mode 100644 index 000000000..ccb2fa04a --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContent.js @@ -0,0 +1,135 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import { stringSettingShape } from 'Helpers/Props/Shapes/settingShape'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import styles from './EditImportListExclusionModalContent.css'; + +function EditImportListExclusionModalContent(props) { + const { + id, + isFetching, + error, + isSaving, + saveError, + item, + onInputChange, + onSavePress, + onModalClose, + onDeleteImportListExclusionPress, + ...otherProps + } = props; + + const { + artistName, + foreignId + } = item; + + return ( + + + {id ? 'Edit Import List Exclusion' : 'Add Import List Exclusion'} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to add a new import list exclusion, please try again.
+ } + + { + !isFetching && !error && +
+ + Artist Name + + + + + + Musicbrainz Id + + + +
+ } +
+ + + { + id && + + } + + + + + Save + + +
+ ); +} + +const ImportListExclusionShape = { + artistName: PropTypes.shape(stringSettingShape).isRequired, + foreignId: PropTypes.shape(stringSettingShape).isRequired +}; + +EditImportListExclusionModalContent.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.shape(ImportListExclusionShape).isRequired, + onInputChange: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteImportListExclusionPress: PropTypes.func +}; + +export default EditImportListExclusionModalContent; diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContentConnector.js b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContentConnector.js new file mode 100644 index 000000000..2516ca25b --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/EditImportListExclusionModalContentConnector.js @@ -0,0 +1,118 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; +import { setImportListExclusionValue, saveImportListExclusion } from 'Store/Actions/settingsActions'; +import EditImportListExclusionModalContent from './EditImportListExclusionModalContent'; + +const newImportListExclusion = { + artistName: '', + foreignId: '' +}; + +function createImportListExclusionSelector() { + return createSelector( + (state, { id }) => id, + (state) => state.settings.importListExclusions, + (id, importListExclusions) => { + const { + isFetching, + error, + isSaving, + saveError, + pendingChanges, + items + } = importListExclusions; + + const mapping = id ? _.find(items, { id }) : newImportListExclusion; + const settings = selectSettings(mapping, pendingChanges, saveError); + + return { + id, + isFetching, + error, + isSaving, + saveError, + item: settings.settings, + ...settings + }; + } + ); +} + +function createMapStateToProps() { + return createSelector( + createImportListExclusionSelector(), + (importListExclusion) => { + return { + ...importListExclusion + }; + } + ); +} + +const mapDispatchToProps = { + setImportListExclusionValue, + saveImportListExclusion +}; + +class EditImportListExclusionModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + if (!this.props.id) { + Object.keys(newImportListExclusion).forEach((name) => { + this.props.setImportListExclusionValue({ + name, + value: newImportListExclusion[name] + }); + }); + } + } + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setImportListExclusionValue({ name, value }); + } + + onSavePress = () => { + this.props.saveImportListExclusion({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditImportListExclusionModalContentConnector.propTypes = { + id: PropTypes.number, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setImportListExclusionValue: PropTypes.func.isRequired, + saveImportListExclusion: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditImportListExclusionModalContentConnector); diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusion.css b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusion.css new file mode 100644 index 000000000..4c274831c --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusion.css @@ -0,0 +1,23 @@ +.importListExclusion { + display: flex; + align-items: stretch; + margin-bottom: 10px; + height: 30px; + border-bottom: 1px solid $borderColor; + line-height: 30px; +} + +.artistName { + flex: 0 0 300px; +} + +.foreignId { + flex: 0 0 400px; +} + +.actions { + display: flex; + justify-content: flex-end; + flex: 1 0 auto; + padding-right: 10px; +} diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusion.js b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusion.js new file mode 100644 index 000000000..4a4d97c78 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusion.js @@ -0,0 +1,111 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditImportListExclusionModalConnector from './EditImportListExclusionModalConnector'; +import styles from './ImportListExclusion.css'; + +class ImportListExclusion extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditImportListExclusionModalOpen: false, + isDeleteImportListExclusionModalOpen: false + }; + } + + // + // Listeners + + onEditImportListExclusionPress = () => { + this.setState({ isEditImportListExclusionModalOpen: true }); + } + + onEditImportListExclusionModalClose = () => { + this.setState({ isEditImportListExclusionModalOpen: false }); + } + + onDeleteImportListExclusionPress = () => { + this.setState({ + isEditImportListExclusionModalOpen: false, + isDeleteImportListExclusionModalOpen: true + }); + } + + onDeleteImportListExclusionModalClose = () => { + this.setState({ isDeleteImportListExclusionModalOpen: false }); + } + + onConfirmDeleteImportListExclusion = () => { + this.props.onConfirmDeleteImportListExclusion(this.props.id); + } + + // + // Render + + render() { + const { + id, + artistName, + foreignId + } = this.props; + + return ( +
+
{artistName}
+
{foreignId}
+ +
+ + + +
+ + + + +
+ ); + } +} + +ImportListExclusion.propTypes = { + id: PropTypes.number.isRequired, + artistName: PropTypes.string.isRequired, + foreignId: PropTypes.string.isRequired, + onConfirmDeleteImportListExclusion: PropTypes.func.isRequired +}; + +ImportListExclusion.defaultProps = { + // The drag preview will not connect the drag handle. + connectDragSource: (node) => node +}; + +export default ImportListExclusion; diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.css b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.css new file mode 100644 index 000000000..99e1c1e99 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.css @@ -0,0 +1,23 @@ +.importListExclusionsHeader { + display: flex; + margin-bottom: 10px; + font-weight: bold; +} + +.host { + flex: 0 0 300px; +} + +.path { + flex: 0 0 400px; +} + +.addImportListExclusion { + display: flex; + justify-content: flex-end; + padding-right: 10px; +} + +.addButton { + text-align: center; +} diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.js b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.js new file mode 100644 index 000000000..f84015e56 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.js @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import ImportListExclusion from './ImportListExclusion'; +import EditImportListExclusionModalConnector from './EditImportListExclusionModalConnector'; +import styles from './ImportListExclusions.css'; + +class ImportListExclusions extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddImportListExclusionModalOpen: false + }; + } + + // + // Listeners + + onAddImportListExclusionPress = () => { + this.setState({ isAddImportListExclusionModalOpen: true }); + } + + onModalClose = () => { + this.setState({ isAddImportListExclusionModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + onConfirmDeleteImportListExclusion, + ...otherProps + } = this.props; + + return ( +
+ +
+
Name
+
Foreign Id
+
+ +
+ { + items.map((item, index) => { + return ( + + ); + }) + } +
+ +
+ + + +
+ + + +
+
+ ); + } +} + +ImportListExclusions.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteImportListExclusion: PropTypes.func.isRequired +}; + +export default ImportListExclusions; diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusionsConnector.js b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusionsConnector.js new file mode 100644 index 000000000..c5f15f43d --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusionsConnector.js @@ -0,0 +1,59 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchImportListExclusions, deleteImportListExclusion } from 'Store/Actions/settingsActions'; +import ImportListExclusions from './ImportListExclusions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.importListExclusions, + (importListExclusions) => { + return { + ...importListExclusions + }; + } + ); +} + +const mapDispatchToProps = { + fetchImportListExclusions, + deleteImportListExclusion +}; + +class ImportListExclusionsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchImportListExclusions(); + } + + // + // Listeners + + onConfirmDeleteImportListExclusion = (id) => { + this.props.deleteImportListExclusion({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ImportListExclusionsConnector.propTypes = { + fetchImportListExclusions: PropTypes.func.isRequired, + deleteImportListExclusion: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ImportListExclusionsConnector); diff --git a/frontend/src/Settings/ImportLists/ImportListSettings.js b/frontend/src/Settings/ImportLists/ImportListSettings.js new file mode 100644 index 000000000..63a8a6733 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportListSettings.js @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import React, { Component, Fragment } from 'react'; +import { icons } from 'Helpers/Props'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import ImportListsConnector from './ImportLists/ImportListsConnector'; +import ImportListsExclusionsConnector from './ImportListExclusions/ImportListExclusionsConnector'; + +class ImportListSettings extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + hasPendingChanges: false + }; + } + + // + // Listeners + + setListOptionsRef = (ref) => { + this._listOptions = ref; + } + + onHasPendingChange = (hasPendingChanges) => { + this.setState({ + hasPendingChanges + }); + } + + onSavePress = () => { + this._listOptions.getWrappedInstance().save(); + } + + // + // Render + + render() { + const { + isTestingAll, + dispatchTestAllImportLists + } = this.props; + + const { + isSaving, + hasPendingChanges + } = this.state; + + return ( + + + + + + + } + onSavePress={this.onSavePress} + /> + + + + + + + ); + } +} + +ImportListSettings.propTypes = { + isTestingAll: PropTypes.bool.isRequired, + dispatchTestAllImportLists: PropTypes.func.isRequired +}; + +export default ImportListSettings; diff --git a/frontend/src/Settings/ImportLists/ImportListSettingsConnector.js b/frontend/src/Settings/ImportLists/ImportListSettingsConnector.js new file mode 100644 index 000000000..7607faef7 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportListSettingsConnector.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { testAllImportLists } from 'Store/Actions/settingsActions'; +import ImportListSettings from './ImportListSettings'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.importLists.isTestingAll, + (isTestingAll) => { + return { + isTestingAll + }; + } + ); +} + +const mapDispatchToProps = { + dispatchTestAllImportLists: testAllImportLists +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ImportListSettings); diff --git a/frontend/src/Settings/ImportLists/ImportLists/AddImportListItem.css b/frontend/src/Settings/ImportLists/ImportLists/AddImportListItem.css new file mode 100644 index 000000000..5e69c30a4 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/AddImportListItem.css @@ -0,0 +1,44 @@ +.list { + composes: card from '~Components/Card.css'; + + position: relative; + width: 300px; + height: 100px; +} + +.underlay { + @add-mixin cover; +} + +.overlay { + @add-mixin linkOverlay; + + padding: 10px; +} + +.name { + text-align: center; + font-weight: lighter; + font-size: 24px; +} + +.actions { + margin-top: 20px; + text-align: right; +} + +.presetsMenu { + composes: menu from '~Components/Menu/Menu.css'; + + display: inline-block; + margin: 0 5px; +} + +.presetsMenuButton { + composes: button from '~Components/Link/Button.css'; + + &::after { + margin-left: 5px; + content: '\25BE'; + } +} diff --git a/frontend/src/Settings/ImportLists/ImportLists/AddImportListItem.js b/frontend/src/Settings/ImportLists/ImportLists/AddImportListItem.js new file mode 100644 index 000000000..21058636c --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/AddImportListItem.js @@ -0,0 +1,110 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Link from 'Components/Link/Link'; +import Menu from 'Components/Menu/Menu'; +import MenuContent from 'Components/Menu/MenuContent'; +import AddImportListPresetMenuItem from './AddImportListPresetMenuItem'; +import styles from './AddImportListItem.css'; + +class AddImportListItem extends Component { + + // + // Listeners + + onImportListSelect = () => { + const { + implementation + } = this.props; + + this.props.onImportListSelect({ implementation }); + } + + // + // Render + + render() { + const { + implementation, + implementationName, + infoLink, + presets, + onImportListSelect + } = this.props; + + const hasPresets = !!presets && !!presets.length; + + return ( +
+ + +
+
+ {implementationName} +
+ +
+ { + hasPresets && + + + + + + + + { + presets.map((preset) => { + return ( + + ); + }) + } + + + + } + + +
+
+
+ ); + } +} + +AddImportListItem.propTypes = { + implementation: PropTypes.string.isRequired, + implementationName: PropTypes.string.isRequired, + infoLink: PropTypes.string.isRequired, + presets: PropTypes.arrayOf(PropTypes.object), + onImportListSelect: PropTypes.func.isRequired +}; + +export default AddImportListItem; diff --git a/frontend/src/Settings/ImportLists/ImportLists/AddImportListModal.js b/frontend/src/Settings/ImportLists/ImportLists/AddImportListModal.js new file mode 100644 index 000000000..a188d6b4a --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/AddImportListModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import AddImportListModalContentConnector from './AddImportListModalContentConnector'; + +function AddImportListModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +AddImportListModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddImportListModal; diff --git a/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContent.css b/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContent.css new file mode 100644 index 000000000..8454ca79f --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContent.css @@ -0,0 +1,5 @@ +.lists { + display: flex; + justify-content: center; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContent.js b/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContent.js new file mode 100644 index 000000000..04700237f --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContent.js @@ -0,0 +1,102 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import FieldSet from 'Components/FieldSet'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import AddImportListItem from './AddImportListItem'; +import styles from './AddImportListModalContent.css'; +import titleCase from 'Utilities/String/titleCase'; + +class AddImportListModalContent extends Component { + + // + // Render + + render() { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + listGroups, + onImportListSelect, + onModalClose + } = this.props; + + return ( + + + Add List + + + + { + isSchemaFetching && + + } + + { + !isSchemaFetching && !!schemaError && +
Unable to add a new list, please try again.
+ } + + { + isSchemaPopulated && !schemaError && +
+ + +
Lidarr supports multiple lists for importing Albums and Artists into the database.
+
For more information on the individual lists, click on the info buttons.
+
+ { + Object.keys(listGroups).map((key) => { + return ( +
+
+ { + listGroups[key].map((list) => { + return ( + + ); + }) + } +
+
+ ); + }) + } +
+ } +
+ + + +
+ ); + } +} + +AddImportListModalContent.propTypes = { + isSchemaFetching: PropTypes.bool.isRequired, + isSchemaPopulated: PropTypes.bool.isRequired, + schemaError: PropTypes.object, + listGroups: PropTypes.object.isRequired, + onImportListSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddImportListModalContent; diff --git a/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContentConnector.js b/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContentConnector.js new file mode 100644 index 000000000..e464ccb93 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/AddImportListModalContentConnector.js @@ -0,0 +1,76 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchImportListSchema, selectImportListSchema } from 'Store/Actions/settingsActions'; +import AddImportListModalContent from './AddImportListModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.importLists, + (importLists) => { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + schema + } = importLists; + + const listGroups = _.chain(schema) + .sortBy((o) => o.listOrder) + .groupBy('listType') + .value(); + + return { + isSchemaFetching, + isSchemaPopulated, + schemaError, + listGroups + }; + } + ); +} + +const mapDispatchToProps = { + fetchImportListSchema, + selectImportListSchema +}; + +class AddImportListModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchImportListSchema(); + } + + // + // Listeners + + onImportListSelect = ({ implementation, name }) => { + this.props.selectImportListSchema({ implementation, presetName: name }); + this.props.onModalClose({ listSelected: true }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AddImportListModalContentConnector.propTypes = { + fetchImportListSchema: PropTypes.func.isRequired, + selectImportListSchema: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AddImportListModalContentConnector); diff --git a/frontend/src/Settings/ImportLists/ImportLists/AddImportListPresetMenuItem.js b/frontend/src/Settings/ImportLists/ImportLists/AddImportListPresetMenuItem.js new file mode 100644 index 000000000..477044ae0 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/AddImportListPresetMenuItem.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import MenuItem from 'Components/Menu/MenuItem'; + +class AddImportListPresetMenuItem extends Component { + + // + // Listeners + + onPress = () => { + const { + name, + implementation + } = this.props; + + this.props.onPress({ + name, + implementation + }); + } + + // + // Render + + render() { + const { + name, + implementation, + ...otherProps + } = this.props; + + return ( + + {name} + + ); + } +} + +AddImportListPresetMenuItem.propTypes = { + name: PropTypes.string.isRequired, + implementation: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired +}; + +export default AddImportListPresetMenuItem; diff --git a/frontend/src/Settings/ImportLists/ImportLists/EditImportListModal.js b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModal.js new file mode 100644 index 000000000..b673ae9a4 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import EditImportListModalContentConnector from './EditImportListModalContentConnector'; + +function EditImportListModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditImportListModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditImportListModal; diff --git a/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalConnector.js b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalConnector.js new file mode 100644 index 000000000..72d39817b --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalConnector.js @@ -0,0 +1,65 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import { cancelTestImportList, cancelSaveImportList } from 'Store/Actions/settingsActions'; +import EditImportListModal from './EditImportListModal'; + +function createMapDispatchToProps(dispatch, props) { + const section = 'settings.importLists'; + + return { + dispatchClearPendingChanges() { + dispatch(clearPendingChanges({ section })); + }, + + dispatchCancelTestImportList() { + dispatch(cancelTestImportList({ section })); + }, + + dispatchCancelSaveImportList() { + dispatch(cancelSaveImportList({ section })); + } + }; +} + +class EditImportListModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.dispatchClearPendingChanges(); + this.props.dispatchCancelTestImportList(); + this.props.dispatchCancelSaveImportList(); + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + dispatchClearPendingChanges, + dispatchCancelTestImportList, + dispatchCancelSaveImportList, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +EditImportListModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired, + dispatchCancelTestImportList: PropTypes.func.isRequired, + dispatchCancelSaveImportList: PropTypes.func.isRequired +}; + +export default connect(null, createMapDispatchToProps)(EditImportListModalConnector); diff --git a/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.css b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.css new file mode 100644 index 000000000..23e22b6dc --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.css @@ -0,0 +1,15 @@ +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: auto; +} + +.hideMetadataProfile { + composes: group from '~Components/Form/FormGroup.css'; + + display: none; +} + +.labelIcon { + margin-left: 8px; +} diff --git a/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js new file mode 100644 index 000000000..6a2826802 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js @@ -0,0 +1,278 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import Popover from 'Components/Tooltip/Popover'; +import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import styles from './EditImportListModalContent.css'; + +function ImportListMonitoringOptionsPopoverContent() { + return ( + + + + + + + + ); +} + +function EditImportListModalContent(props) { + + const monitorOptions = [ + { key: 'none', value: 'None' }, + { key: 'specificAlbum', value: 'Specific Album' }, + { key: 'entireArtist', value: 'All Artist Albums' } + ]; + + const { + advancedSettings, + isFetching, + error, + isSaving, + isTesting, + saveError, + item, + onInputChange, + onFieldChange, + onModalClose, + onSavePress, + onTestPress, + onDeleteImportListPress, + showMetadataProfile, + ...otherProps + } = props; + + const { + id, + name, + enableAutomaticAdd, + shouldMonitor, + rootFolderPath, + qualityProfileId, + metadataProfileId, + tags, + fields + } = item; + + return ( + + + {id ? 'Edit List' : 'Add List'} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to add a new list, please try again.
+ } + + { + !isFetching && !error && +
+ + Name + + + + + + Enable Automatic Add + + + + + + + Monitor + + + } + title="Monitoring Options" + body={} + position={tooltipPositions.RIGHT} + /> + + + + + + + Root Folder + + + + + + Quality Profile + + + + + + Metadata Profile + + + + + + Lidarr Tags + + + + + { + !!fields && !!fields.length && +
+ { + fields.map((field) => { + return ( + + ); + }) + } +
+ } + +
+ } +
+ + { + id && + + } + + + Test + + + + + + Save + + +
+ ); +} + +EditImportListModalContent.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + isTesting: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + showMetadataProfile: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onFieldChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onTestPress: PropTypes.func.isRequired, + onDeleteImportListPress: PropTypes.func +}; + +export default EditImportListModalContent; diff --git a/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContentConnector.js b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContentConnector.js new file mode 100644 index 000000000..527b021f2 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContentConnector.js @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import { setImportListValue, setImportListFieldValue, saveImportList, testImportList } from 'Store/Actions/settingsActions'; +import EditImportListModalContent from './EditImportListModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + (state) => state.settings.metadataProfiles, + createProviderSettingsSelector('importLists'), + (advancedSettings, metadataProfiles, importList) => { + return { + advancedSettings, + showMetadataProfile: metadataProfiles.items.length > 1, + ...importList + }; + } + ); +} + +const mapDispatchToProps = { + setImportListValue, + setImportListFieldValue, + saveImportList, + testImportList +}; + +class EditImportListModalContentConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setImportListValue({ name, value }); + } + + onFieldChange = ({ name, value }) => { + this.props.setImportListFieldValue({ name, value }); + } + + onSavePress = () => { + this.props.saveImportList({ id: this.props.id }); + } + + onTestPress = () => { + this.props.testImportList({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditImportListModalContentConnector.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setImportListValue: PropTypes.func.isRequired, + setImportListFieldValue: PropTypes.func.isRequired, + saveImportList: PropTypes.func.isRequired, + testImportList: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditImportListModalContentConnector); diff --git a/frontend/src/Settings/ImportLists/ImportLists/ImportList.css b/frontend/src/Settings/ImportLists/ImportLists/ImportList.css new file mode 100644 index 000000000..4796748f7 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/ImportList.css @@ -0,0 +1,19 @@ +.list { + composes: card from '~Components/Card.css'; + + width: 290px; +} + +.name { + @add-mixin truncate; + + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.enabled { + display: flex; + flex-wrap: wrap; + margin-top: 5px; +} diff --git a/frontend/src/Settings/ImportLists/ImportLists/ImportList.js b/frontend/src/Settings/ImportLists/ImportLists/ImportList.js new file mode 100644 index 000000000..f0b1044e7 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/ImportList.js @@ -0,0 +1,108 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditImportListModalConnector from './EditImportListModalConnector'; +import styles from './ImportList.css'; + +class ImportList extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditImportListModalOpen: false, + isDeleteImportListModalOpen: false + }; + } + + // + // Listeners + + onEditImportListPress = () => { + this.setState({ isEditImportListModalOpen: true }); + } + + onEditImportListModalClose = () => { + this.setState({ isEditImportListModalOpen: false }); + } + + onDeleteImportListPress = () => { + this.setState({ + isEditImportListModalOpen: false, + isDeleteImportListModalOpen: true + }); + } + + onDeleteImportListModalClose= () => { + this.setState({ isDeleteImportListModalOpen: false }); + } + + onConfirmDeleteImportList = () => { + this.props.onConfirmDeleteImportList(this.props.id); + } + + // + // Render + + render() { + const { + id, + name, + enableAutomaticAdd + } = this.props; + + return ( + +
+ {name} +
+ +
+ { + enableAutomaticAdd && + + } + +
+ + + + +
+ ); + } +} + +ImportList.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + enableAutomaticAdd: PropTypes.bool.isRequired, + onConfirmDeleteImportList: PropTypes.func.isRequired +}; + +export default ImportList; diff --git a/frontend/src/Settings/ImportLists/ImportLists/ImportLists.css b/frontend/src/Settings/ImportLists/ImportLists/ImportLists.css new file mode 100644 index 000000000..3db4e69d6 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/ImportLists.css @@ -0,0 +1,20 @@ +.lists { + display: flex; + flex-wrap: wrap; +} + +.addList { + composes: list from '~./ImportList.css'; + + background-color: $cardAlternateBackgroundColor; + color: $gray; + text-align: center; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Settings/ImportLists/ImportLists/ImportLists.js b/frontend/src/Settings/ImportLists/ImportLists/ImportLists.js new file mode 100644 index 000000000..7f654050e --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/ImportLists.js @@ -0,0 +1,117 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import sortByName from 'Utilities/Array/sortByName'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Card from 'Components/Card'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import ImportList from './ImportList'; +import AddImportListModal from './AddImportListModal'; +import EditImportListModalConnector from './EditImportListModalConnector'; +import styles from './ImportLists.css'; + +class ImportLists extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddImportListModalOpen: false, + isEditImportListModalOpen: false + }; + } + + // + // Listeners + + onAddImportListPress = () => { + this.setState({ isAddImportListModalOpen: true }); + } + + onAddImportListModalClose = ({ listSelected = false } = {}) => { + this.setState({ + isAddImportListModalOpen: false, + isEditImportListModalOpen: listSelected + }); + } + + onEditImportListModalClose = () => { + this.setState({ isEditImportListModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + onConfirmDeleteImportList, + ...otherProps + } = this.props; + + const { + isAddImportListModalOpen, + isEditImportListModalOpen + } = this.state; + + return ( +
+ +
+ { + items.sort(sortByName).map((item) => { + return ( + + ); + }) + } + + +
+ +
+
+
+ + + + +
+
+ ); + } +} + +ImportLists.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteImportList: PropTypes.func.isRequired +}; + +export default ImportLists; diff --git a/frontend/src/Settings/ImportLists/ImportLists/ImportListsConnector.js b/frontend/src/Settings/ImportLists/ImportLists/ImportListsConnector.js new file mode 100644 index 000000000..3938da4ae --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/ImportListsConnector.js @@ -0,0 +1,62 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchImportLists, deleteImportList } from 'Store/Actions/settingsActions'; +import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import ImportLists from './ImportLists'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.importLists, + (importLists) => { + return { + ...importLists + }; + } + ); +} + +const mapDispatchToProps = { + fetchImportLists, + deleteImportList, + fetchRootFolders +}; + +class ListsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchImportLists(); + this.props.fetchRootFolders(); + } + + // + // Listeners + + onConfirmDeleteImportList = (id) => { + this.props.deleteImportList({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ListsConnector.propTypes = { + fetchImportLists: PropTypes.func.isRequired, + deleteImportList: PropTypes.func.isRequired, + fetchRootFolders: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ListsConnector); diff --git a/frontend/src/Settings/Indexers/IndexerSettings.js b/frontend/src/Settings/Indexers/IndexerSettings.js new file mode 100644 index 000000000..35f816d9c --- /dev/null +++ b/frontend/src/Settings/Indexers/IndexerSettings.js @@ -0,0 +1,97 @@ +import PropTypes from 'prop-types'; +import React, { Component, Fragment } from 'react'; +import { icons } from 'Helpers/Props'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import IndexersConnector from './Indexers/IndexersConnector'; +import IndexerOptionsConnector from './Options/IndexerOptionsConnector'; + +class IndexerSettings extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._saveCallback = null; + + this.state = { + isSaving: false, + hasPendingChanges: false + }; + } + + // + // Listeners + + onChildMounted = (saveCallback) => { + this._saveCallback = saveCallback; + } + + onChildStateChange = (payload) => { + this.setState(payload); + } + + onSavePress = () => { + if (this._saveCallback) { + this._saveCallback(); + } + } + + // + // Render + + render() { + const { + isTestingAll, + dispatchTestAllIndexers + } = this.props; + + const { + isSaving, + hasPendingChanges + } = this.state; + + return ( + + + + + + + } + onSavePress={this.onSavePress} + /> + + + + + + + + ); + } +} + +IndexerSettings.propTypes = { + isTestingAll: PropTypes.bool.isRequired, + dispatchTestAllIndexers: PropTypes.func.isRequired +}; + +export default IndexerSettings; diff --git a/frontend/src/Settings/Indexers/IndexerSettingsConnector.js b/frontend/src/Settings/Indexers/IndexerSettingsConnector.js new file mode 100644 index 000000000..1eaf098d7 --- /dev/null +++ b/frontend/src/Settings/Indexers/IndexerSettingsConnector.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { testAllIndexers } from 'Store/Actions/settingsActions'; +import IndexerSettings from './IndexerSettings'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.indexers.isTestingAll, + (isTestingAll) => { + return { + isTestingAll + }; + } + ); +} + +const mapDispatchToProps = { + dispatchTestAllIndexers: testAllIndexers +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(IndexerSettings); diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.css b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.css new file mode 100644 index 000000000..1010221e1 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.css @@ -0,0 +1,44 @@ +.indexer { + composes: card from '~Components/Card.css'; + + position: relative; + width: 300px; + height: 100px; +} + +.underlay { + @add-mixin cover; +} + +.overlay { + @add-mixin linkOverlay; + + padding: 10px; +} + +.name { + text-align: center; + font-weight: lighter; + font-size: 24px; +} + +.actions { + margin-top: 20px; + text-align: right; +} + +.presetsMenu { + composes: menu from '~Components/Menu/Menu.css'; + + display: inline-block; + margin: 0 5px; +} + +.presetsMenuButton { + composes: button from '~Components/Link/Button.css'; + + &::after { + margin-left: 5px; + content: '\25BE'; + } +} diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.js new file mode 100644 index 000000000..21db4ecf1 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.js @@ -0,0 +1,110 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Link from 'Components/Link/Link'; +import Menu from 'Components/Menu/Menu'; +import MenuContent from 'Components/Menu/MenuContent'; +import AddIndexerPresetMenuItem from './AddIndexerPresetMenuItem'; +import styles from './AddIndexerItem.css'; + +class AddIndexerItem extends Component { + + // + // Listeners + + onIndexerSelect = () => { + const { + implementation + } = this.props; + + this.props.onIndexerSelect({ implementation }); + } + + // + // Render + + render() { + const { + implementation, + implementationName, + infoLink, + presets, + onIndexerSelect + } = this.props; + + const hasPresets = !!presets && !!presets.length; + + return ( +
+ + +
+
+ {implementationName} +
+ +
+ { + hasPresets && + + + + + + + + { + presets.map((preset) => { + return ( + + ); + }) + } + + + + } + + +
+
+
+ ); + } +} + +AddIndexerItem.propTypes = { + implementation: PropTypes.string.isRequired, + implementationName: PropTypes.string.isRequired, + infoLink: PropTypes.string.isRequired, + presets: PropTypes.arrayOf(PropTypes.object), + onIndexerSelect: PropTypes.func.isRequired +}; + +export default AddIndexerItem; diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModal.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerModal.js new file mode 100644 index 000000000..d05e8eb9a --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import AddIndexerModalContentConnector from './AddIndexerModalContentConnector'; + +function AddIndexerModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +AddIndexerModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddIndexerModal; diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.css b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.css new file mode 100644 index 000000000..946305dff --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.css @@ -0,0 +1,5 @@ +.indexers { + display: flex; + justify-content: center; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.js new file mode 100644 index 000000000..9bfd9d1fd --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import FieldSet from 'Components/FieldSet'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import AddIndexerItem from './AddIndexerItem'; +import styles from './AddIndexerModalContent.css'; + +class AddIndexerModalContent extends Component { + + // + // Render + + render() { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + usenetIndexers, + torrentIndexers, + onIndexerSelect, + onModalClose + } = this.props; + + return ( + + + Add Indexer + + + + { + isSchemaFetching && + + } + + { + !isSchemaFetching && !!schemaError && +
Unable to add a new indexer, please try again.
+ } + + { + isSchemaPopulated && !schemaError && +
+ + +
Lidarr supports any indexer that uses the Newznab standard, as well as other indexers listed below.
+
For more information on the individual indexers, click on the info buttons.
+
+ +
+
+ { + usenetIndexers.map((indexer) => { + return ( + + ); + }) + } +
+
+ +
+
+ { + torrentIndexers.map((indexer) => { + return ( + + ); + }) + } +
+
+
+ } +
+ + + +
+ ); + } +} + +AddIndexerModalContent.propTypes = { + isSchemaFetching: PropTypes.bool.isRequired, + isSchemaPopulated: PropTypes.bool.isRequired, + schemaError: PropTypes.object, + usenetIndexers: PropTypes.arrayOf(PropTypes.object).isRequired, + torrentIndexers: PropTypes.arrayOf(PropTypes.object).isRequired, + onIndexerSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddIndexerModalContent; diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js new file mode 100644 index 000000000..d79f028da --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js @@ -0,0 +1,75 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchIndexerSchema, selectIndexerSchema } from 'Store/Actions/settingsActions'; +import AddIndexerModalContent from './AddIndexerModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.indexers, + (indexers) => { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + schema + } = indexers; + + const usenetIndexers = _.filter(schema, { protocol: 'usenet' }); + const torrentIndexers = _.filter(schema, { protocol: 'torrent' }); + + return { + isSchemaFetching, + isSchemaPopulated, + schemaError, + usenetIndexers, + torrentIndexers + }; + } + ); +} + +const mapDispatchToProps = { + fetchIndexerSchema, + selectIndexerSchema +}; + +class AddIndexerModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchIndexerSchema(); + } + + // + // Listeners + + onIndexerSelect = ({ implementation, name }) => { + this.props.selectIndexerSchema({ implementation, presetName: name }); + this.props.onModalClose({ indexerSelected: true }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AddIndexerModalContentConnector.propTypes = { + fetchIndexerSchema: PropTypes.func.isRequired, + selectIndexerSchema: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AddIndexerModalContentConnector); diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerPresetMenuItem.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerPresetMenuItem.js new file mode 100644 index 000000000..ddea8b043 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerPresetMenuItem.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import MenuItem from 'Components/Menu/MenuItem'; + +class AddIndexerPresetMenuItem extends Component { + + // + // Listeners + + onPress = () => { + const { + name, + implementation + } = this.props; + + this.props.onPress({ + name, + implementation + }); + } + + // + // Render + + render() { + const { + name, + implementation, + ...otherProps + } = this.props; + + return ( + + {name} + + ); + } +} + +AddIndexerPresetMenuItem.propTypes = { + name: PropTypes.string.isRequired, + implementation: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired +}; + +export default AddIndexerPresetMenuItem; diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModal.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModal.js new file mode 100644 index 000000000..d7401b95f --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditIndexerModalContentConnector from './EditIndexerModalContentConnector'; + +function EditIndexerModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditIndexerModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditIndexerModal; diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalConnector.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalConnector.js new file mode 100644 index 000000000..ec0b7586e --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalConnector.js @@ -0,0 +1,65 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import { cancelTestIndexer, cancelSaveIndexer } from 'Store/Actions/settingsActions'; +import EditIndexerModal from './EditIndexerModal'; + +function createMapDispatchToProps(dispatch, props) { + const section = 'settings.indexers'; + + return { + dispatchClearPendingChanges() { + dispatch(clearPendingChanges({ section })); + }, + + dispatchCancelTestIndexer() { + dispatch(cancelTestIndexer({ section })); + }, + + dispatchCancelSaveIndexer() { + dispatch(cancelSaveIndexer({ section })); + } + }; +} + +class EditIndexerModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.dispatchClearPendingChanges(); + this.props.dispatchCancelTestIndexer(); + this.props.dispatchCancelSaveIndexer(); + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + dispatchClearPendingChanges, + dispatchCancelTestIndexer, + dispatchCancelSaveIndexer, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +EditIndexerModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired, + dispatchCancelTestIndexer: PropTypes.func.isRequired, + dispatchCancelSaveIndexer: PropTypes.func.isRequired +}; + +export default connect(null, createMapDispatchToProps)(EditIndexerModalConnector); diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.css b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.css new file mode 100644 index 000000000..a2b6014df --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.css @@ -0,0 +1,5 @@ +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js new file mode 100644 index 000000000..e2621e57f --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js @@ -0,0 +1,192 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; +import styles from './EditIndexerModalContent.css'; + +function EditIndexerModalContent(props) { + const { + advancedSettings, + isFetching, + error, + isSaving, + isTesting, + saveError, + item, + onInputChange, + onFieldChange, + onModalClose, + onSavePress, + onTestPress, + onDeleteIndexerPress, + ...otherProps + } = props; + + const { + id, + implementationName, + name, + enableRss, + enableAutomaticSearch, + enableInteractiveSearch, + supportsRss, + supportsSearch, + fields + } = item; + + return ( + + + {`${id ? 'Edit' : 'Add'} Indexer - ${implementationName}`} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to add a new indexer, please try again.
+ } + + { + !isFetching && !error && +
+ + Name + + + + + + Enable RSS + + + + + + Enable Automatic Search + + + + + + Enable Interactive Search + + + + + { + fields.map((field) => { + return ( + + ); + }) + } + + + } +
+ + { + id && + + } + + + Test + + + + + + Save + + +
+ ); +} + +EditIndexerModalContent.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + isTesting: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired, + onFieldChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onTestPress: PropTypes.func.isRequired, + onDeleteIndexerPress: PropTypes.func +}; + +export default EditIndexerModalContent; diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContentConnector.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContentConnector.js new file mode 100644 index 000000000..f993d2796 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContentConnector.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import { setIndexerValue, setIndexerFieldValue, saveIndexer, testIndexer } from 'Store/Actions/settingsActions'; +import EditIndexerModalContent from './EditIndexerModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createProviderSettingsSelector('indexers'), + (advancedSettings, indexer) => { + return { + advancedSettings, + ...indexer + }; + } + ); +} + +const mapDispatchToProps = { + setIndexerValue, + setIndexerFieldValue, + saveIndexer, + testIndexer +}; + +class EditIndexerModalContentConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setIndexerValue({ name, value }); + } + + onFieldChange = ({ name, value }) => { + this.props.setIndexerFieldValue({ name, value }); + } + + onSavePress = () => { + this.props.saveIndexer({ id: this.props.id }); + } + + onTestPress = () => { + this.props.testIndexer({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditIndexerModalContentConnector.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setIndexerValue: PropTypes.func.isRequired, + setIndexerFieldValue: PropTypes.func.isRequired, + saveIndexer: PropTypes.func.isRequired, + testIndexer: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditIndexerModalContentConnector); diff --git a/frontend/src/Settings/Indexers/Indexers/Indexer.css b/frontend/src/Settings/Indexers/Indexers/Indexer.css new file mode 100644 index 000000000..6715d2a0a --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Indexer.css @@ -0,0 +1,19 @@ +.indexer { + composes: card from '~Components/Card.css'; + + width: 290px; +} + +.name { + @add-mixin truncate; + + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.enabled { + display: flex; + flex-wrap: wrap; + margin-top: 5px; +} diff --git a/frontend/src/Settings/Indexers/Indexers/Indexer.js b/frontend/src/Settings/Indexers/Indexers/Indexer.js new file mode 100644 index 000000000..9269f8532 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Indexer.js @@ -0,0 +1,140 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditIndexerModalConnector from './EditIndexerModalConnector'; +import styles from './Indexer.css'; + +class Indexer extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditIndexerModalOpen: false, + isDeleteIndexerModalOpen: false + }; + } + + // + // Listeners + + onEditIndexerPress = () => { + this.setState({ isEditIndexerModalOpen: true }); + } + + onEditIndexerModalClose = () => { + this.setState({ isEditIndexerModalOpen: false }); + } + + onDeleteIndexerPress = () => { + this.setState({ + isEditIndexerModalOpen: false, + isDeleteIndexerModalOpen: true + }); + } + + onDeleteIndexerModalClose= () => { + this.setState({ isDeleteIndexerModalOpen: false }); + } + + onConfirmDeleteIndexer = () => { + this.props.onConfirmDeleteIndexer(this.props.id); + } + + // + // Render + + render() { + const { + id, + name, + enableRss, + enableAutomaticSearch, + enableInteractiveSearch, + supportsRss, + supportsSearch + } = this.props; + + return ( + +
+ {name} +
+ +
+ + { + supportsRss && enableRss && + + } + + { + supportsSearch && enableAutomaticSearch && + + } + + { + supportsSearch && enableInteractiveSearch && + + } + + { + !enableRss && !enableAutomaticSearch && !enableInteractiveSearch && + + } +
+ + + + +
+ ); + } +} + +Indexer.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + enableRss: PropTypes.bool.isRequired, + enableAutomaticSearch: PropTypes.bool.isRequired, + enableInteractiveSearch: PropTypes.bool.isRequired, + supportsRss: PropTypes.bool.isRequired, + supportsSearch: PropTypes.bool.isRequired, + onConfirmDeleteIndexer: PropTypes.func.isRequired +}; + +export default Indexer; diff --git a/frontend/src/Settings/Indexers/Indexers/Indexers.css b/frontend/src/Settings/Indexers/Indexers/Indexers.css new file mode 100644 index 000000000..bf2e72ba4 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Indexers.css @@ -0,0 +1,20 @@ +.indexers { + display: flex; + flex-wrap: wrap; +} + +.addIndexer { + composes: indexer from '~./Indexer.css'; + + background-color: $cardAlternateBackgroundColor; + color: $gray; + text-align: center; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Settings/Indexers/Indexers/Indexers.js b/frontend/src/Settings/Indexers/Indexers/Indexers.js new file mode 100644 index 000000000..f5fea9aac --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Indexers.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import sortByName from 'Utilities/Array/sortByName'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Card from 'Components/Card'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import Indexer from './Indexer'; +import AddIndexerModal from './AddIndexerModal'; +import EditIndexerModalConnector from './EditIndexerModalConnector'; +import styles from './Indexers.css'; + +class Indexers extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddIndexerModalOpen: false, + isEditIndexerModalOpen: false + }; + } + + // + // Listeners + + onAddIndexerPress = () => { + this.setState({ isAddIndexerModalOpen: true }); + } + + onAddIndexerModalClose = ({ indexerSelected = false } = {}) => { + this.setState({ + isAddIndexerModalOpen: false, + isEditIndexerModalOpen: indexerSelected + }); + } + + onEditIndexerModalClose = () => { + this.setState({ isEditIndexerModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + onConfirmDeleteIndexer, + ...otherProps + } = this.props; + + const { + isAddIndexerModalOpen, + isEditIndexerModalOpen + } = this.state; + + return ( +
+ +
+ { + items.sort(sortByName).map((item) => { + return ( + + ); + }) + } + + +
+ +
+
+
+ + + + +
+
+ ); + } +} + +Indexers.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteIndexer: PropTypes.func.isRequired +}; + +export default Indexers; diff --git a/frontend/src/Settings/Indexers/Indexers/IndexersConnector.js b/frontend/src/Settings/Indexers/Indexers/IndexersConnector.js new file mode 100644 index 000000000..415dae32b --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/IndexersConnector.js @@ -0,0 +1,58 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchIndexers, deleteIndexer } from 'Store/Actions/settingsActions'; +import Indexers from './Indexers'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.indexers, + (indexers) => { + return { + ...indexers + }; + } + ); +} + +const mapDispatchToProps = { + fetchIndexers, + deleteIndexer +}; + +class IndexersConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchIndexers(); + } + + // + // Listeners + + onConfirmDeleteIndexer = (id) => { + this.props.deleteIndexer({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +IndexersConnector.propTypes = { + fetchIndexers: PropTypes.func.isRequired, + deleteIndexer: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(IndexersConnector); diff --git a/frontend/src/Settings/Indexers/Options/IndexerOptions.js b/frontend/src/Settings/Indexers/Options/IndexerOptions.js new file mode 100644 index 000000000..9639c7737 --- /dev/null +++ b/frontend/src/Settings/Indexers/Options/IndexerOptions.js @@ -0,0 +1,111 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FieldSet from 'Components/FieldSet'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function IndexerOptions(props) { + const { + advancedSettings, + isFetching, + error, + settings, + hasSettings, + onInputChange + } = props; + + return ( +
+ { + isFetching && + + } + + { + !isFetching && error && +
Unable to load indexer options
+ } + + { + hasSettings && !isFetching && !error && +
+ + Minimum Age + + + + + + Maximum Size + + + + + + Retention + + + + + + RSS Sync Interval + + + +
+ } +
+ ); +} + +IndexerOptions.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + settings: PropTypes.object.isRequired, + hasSettings: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default IndexerOptions; diff --git a/frontend/src/Settings/Indexers/Options/IndexerOptionsConnector.js b/frontend/src/Settings/Indexers/Options/IndexerOptionsConnector.js new file mode 100644 index 000000000..54a76a1d9 --- /dev/null +++ b/frontend/src/Settings/Indexers/Options/IndexerOptionsConnector.js @@ -0,0 +1,101 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import { fetchIndexerOptions, setIndexerOptionsValue, saveIndexerOptions } from 'Store/Actions/settingsActions'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import IndexerOptions from './IndexerOptions'; + +const SECTION = 'indexerOptions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createSettingsSectionSelector(SECTION), + (advancedSettings, sectionSettings) => { + return { + advancedSettings, + ...sectionSettings + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchIndexerOptions: fetchIndexerOptions, + dispatchSetIndexerOptionsValue: setIndexerOptionsValue, + dispatchSaveIndexerOptions: saveIndexerOptions, + dispatchClearPendingChanges: clearPendingChanges +}; + +class IndexerOptionsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + dispatchFetchIndexerOptions, + dispatchSaveIndexerOptions, + onChildMounted + } = this.props; + + dispatchFetchIndexerOptions(); + onChildMounted(dispatchSaveIndexerOptions); + } + + componentDidUpdate(prevProps) { + const { + hasPendingChanges, + isSaving, + onChildStateChange + } = this.props; + + if ( + prevProps.isSaving !== isSaving || + prevProps.hasPendingChanges !== hasPendingChanges + ) { + onChildStateChange({ + isSaving, + hasPendingChanges + }); + } + } + + componentWillUnmount() { + this.props.dispatchClearPendingChanges({ section: 'settings.indexerOptions' }); + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.dispatchSetIndexerOptionsValue({ name, value }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +IndexerOptionsConnector.propTypes = { + isSaving: PropTypes.bool.isRequired, + hasPendingChanges: PropTypes.bool.isRequired, + dispatchFetchIndexerOptions: PropTypes.func.isRequired, + dispatchSetIndexerOptionsValue: PropTypes.func.isRequired, + dispatchSaveIndexerOptions: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired, + onChildMounted: PropTypes.func.isRequired, + onChildStateChange: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(IndexerOptionsConnector); diff --git a/frontend/src/Settings/MediaManagement/MediaManagement.js b/frontend/src/Settings/MediaManagement/MediaManagement.js new file mode 100644 index 000000000..c5e7e56be --- /dev/null +++ b/frontend/src/Settings/MediaManagement/MediaManagement.js @@ -0,0 +1,436 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FieldSet from 'Components/FieldSet'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import RootFoldersConnector from 'RootFolder/RootFoldersConnector'; +import NamingConnector from './Naming/NamingConnector'; +import AddRootFolderConnector from './RootFolder/AddRootFolderConnector'; + +const rescanAfterRefreshOptions = [ + { key: 'always', value: 'Always' }, + { key: 'afterManual', value: 'After Manual Refresh' }, + { key: 'never', value: 'Never' } +]; + +const allowFingerprintingOptions = [ + { key: 'allFiles', value: 'Always' }, + { key: 'newFiles', value: 'For new imports only' }, + { key: 'never', value: 'Never' } +]; + +const downloadPropersAndRepacksOptions = [ + { key: 'preferAndUpgrade', value: 'Prefer and Upgrade' }, + { key: 'doNotUpgrade', value: 'Do not Upgrade Automatically' }, + { key: 'doNotPrefer', value: 'Do not Prefer' } +]; + +const fileDateOptions = [ + { key: 'none', value: 'None' }, + { key: 'albumReleaseDate', value: 'Album Release Date' } +]; + +class MediaManagement extends Component { + + // + // Render + + render() { + const { + advancedSettings, + isFetching, + error, + settings, + hasSettings, + isMono, + onInputChange, + onSavePress, + ...otherProps + } = this.props; + + return ( + + + + + + + { + isFetching && +
+ +
+ } + + { + !isFetching && error && +
+
Unable to load Media Management settings
+
+ } + + { + hasSettings && !isFetching && !error && +
+ { + advancedSettings && +
+ + Create empty artist folders + + + + + + Delete empty folders + + + +
+ } + + { + advancedSettings && +
+ { + isMono && + + Skip Free Space Check + + + + } + + + Use Hardlinks instead of Copy + + + + + + Import Extra Files + + + + + { + settings.importExtraFiles.value && + + Import Extra Files + + + + } +
+ } + +
+ + Ignore Deleted Tracks + + + + + + Propers and Repacks + + + + + + Rescan Artist Folder after Refresh + + + + + + Allow Fingerprinting + + + + + + Change File Date + + + + + + Recycling Bin + + + + + + Recycling Bin Cleanup + + + +
+ + { + advancedSettings && isMono && +
+ + Set Permissions + + + + + + File chmod mode + + + + + + Folder chmod mode + + + + + + chown User + + + + + + chown Group + + + +
+ } +
+ } + +
+ + +
+
+
+ ); + } + +} + +MediaManagement.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + settings: PropTypes.object.isRequired, + hasSettings: PropTypes.bool.isRequired, + isMono: PropTypes.bool.isRequired, + onSavePress: PropTypes.func.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default MediaManagement; diff --git a/frontend/src/Settings/MediaManagement/MediaManagementConnector.js b/frontend/src/Settings/MediaManagement/MediaManagementConnector.js new file mode 100644 index 000000000..5a6250392 --- /dev/null +++ b/frontend/src/Settings/MediaManagement/MediaManagementConnector.js @@ -0,0 +1,86 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; +import { fetchMediaManagementSettings, setMediaManagementSettingsValue, saveMediaManagementSettings, saveNamingSettings } from 'Store/Actions/settingsActions'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import MediaManagement from './MediaManagement'; + +const SECTION = 'mediaManagement'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + (state) => state.settings.naming, + createSettingsSectionSelector(SECTION), + createSystemStatusSelector(), + (advancedSettings, namingSettings, sectionSettings, systemStatus) => { + return { + advancedSettings, + ...sectionSettings, + hasPendingChanges: !_.isEmpty(namingSettings.pendingChanges) || sectionSettings.hasPendingChanges, + isMono: systemStatus.isMono + }; + } + ); +} + +const mapDispatchToProps = { + fetchMediaManagementSettings, + setMediaManagementSettingsValue, + saveMediaManagementSettings, + saveNamingSettings, + clearPendingChanges +}; + +class MediaManagementConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchMediaManagementSettings(); + } + + componentWillUnmount() { + this.props.clearPendingChanges({ section: 'settings.mediaManagement' }); + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setMediaManagementSettingsValue({ name, value }); + } + + onSavePress = () => { + this.props.saveMediaManagementSettings(); + this.props.saveNamingSettings(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +MediaManagementConnector.propTypes = { + fetchMediaManagementSettings: PropTypes.func.isRequired, + setMediaManagementSettingsValue: PropTypes.func.isRequired, + saveMediaManagementSettings: PropTypes.func.isRequired, + saveNamingSettings: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(MediaManagementConnector); diff --git a/frontend/src/Settings/MediaManagement/Naming/Naming.css b/frontend/src/Settings/MediaManagement/Naming/Naming.css new file mode 100644 index 000000000..59d223e92 --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/Naming.css @@ -0,0 +1,5 @@ +.namingInput { + composes: input from '~Components/Form/TextInput.css'; + + font-family: $monoSpaceFontFamily; +} diff --git a/frontend/src/Settings/MediaManagement/Naming/Naming.js b/frontend/src/Settings/MediaManagement/Naming/Naming.js new file mode 100644 index 000000000..81367c831 --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/Naming.js @@ -0,0 +1,273 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FormInputButton from 'Components/Form/FormInputButton'; +import FieldSet from 'Components/FieldSet'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import NamingModal from './NamingModal'; +import styles from './Naming.css'; + +class Naming extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isNamingModalOpen: false, + namingModalOptions: null + }; + } + + // + // Listeners + + onStandardNamingModalOpenClick = () => { + this.setState({ + isNamingModalOpen: true, + namingModalOptions: { + name: 'standardTrackFormat', + album: true, + track: true, + additional: true + } + }); + } + + onMultiDiscNamingModalOpenClick = () => { + this.setState({ + isNamingModalOpen: true, + namingModalOptions: { + name: 'multiDiscTrackFormat', + album: true, + track: true, + additional: true + } + }); + } + + onArtistFolderNamingModalOpenClick = () => { + this.setState({ + isNamingModalOpen: true, + namingModalOptions: { + name: 'artistFolderFormat' + } + }); + } + + onAlbumFolderNamingModalOpenClick = () => { + this.setState({ + isNamingModalOpen: true, + namingModalOptions: { + name: 'albumFolderFormat', + album: true + } + }); + } + + onNamingModalClose = () => { + this.setState({ isNamingModalOpen: false }); + } + + // + // Render + + render() { + const { + advancedSettings, + isFetching, + error, + settings, + hasSettings, + examples, + examplesPopulated, + onInputChange + } = this.props; + + const { + isNamingModalOpen, + namingModalOptions + } = this.state; + + const renameTracks = hasSettings && settings.renameTracks.value; + + const standardTrackFormatHelpTexts = []; + const standardTrackFormatErrors = []; + const multiDiscTrackFormatHelpTexts = []; + const multiDiscTrackFormatErrors = []; + const artistFolderFormatHelpTexts = []; + const artistFolderFormatErrors = []; + const albumFolderFormatHelpTexts = []; + const albumFolderFormatErrors = []; + + if (examplesPopulated) { + if (examples.singleTrackExample) { + standardTrackFormatHelpTexts.push(`Single Track: ${examples.singleTrackExample}`); + } else { + standardTrackFormatErrors.push({ message: 'Single Track: Invalid Format' }); + } + + if (examples.multiDiscTrackExample) { + multiDiscTrackFormatHelpTexts.push(`Multi Disc Track: ${examples.multiDiscTrackExample}`); + } else { + multiDiscTrackFormatErrors.push({ message: 'Single Track: Invalid Format' }); + } + + if (examples.artistFolderExample) { + artistFolderFormatHelpTexts.push(`Example: ${examples.artistFolderExample}`); + } else { + artistFolderFormatErrors.push({ message: 'Invalid Format' }); + } + + if (examples.albumFolderExample) { + albumFolderFormatHelpTexts.push(`Example: ${examples.albumFolderExample}`); + } else { + albumFolderFormatErrors.push({ message: 'Invalid Format' }); + } + } + + return ( +
+ { + isFetching && + + } + + { + !isFetching && error && +
Unable to load Naming settings
+ } + + { + hasSettings && !isFetching && !error && +
+ + Rename Tracks + + + + + + Replace Illegal Characters + + + + + { + renameTracks && +
+ + Standard Track Format + + ?} + onChange={onInputChange} + {...settings.standardTrackFormat} + helpTexts={standardTrackFormatHelpTexts} + errors={[...standardTrackFormatErrors, ...settings.standardTrackFormat.errors]} + /> + + + + Multi Disc Track Format + + ?} + onChange={onInputChange} + {...settings.multiDiscTrackFormat} + helpTexts={multiDiscTrackFormatHelpTexts} + errors={[...multiDiscTrackFormatErrors, ...settings.multiDiscTrackFormat.errors]} + /> + + +
+ } + + + Artist Folder Format + + ?} + onChange={onInputChange} + {...settings.artistFolderFormat} + helpTexts={['Used when adding a new artist or moving an artist via the artist editor', ...artistFolderFormatHelpTexts]} + errors={[...artistFolderFormatErrors, ...settings.artistFolderFormat.errors]} + /> + + + + Album Folder Format + + ?} + onChange={onInputChange} + {...settings.albumFolderFormat} + helpTexts={albumFolderFormatHelpTexts} + errors={[...albumFolderFormatErrors, ...settings.albumFolderFormat.errors]} + /> + + + { + namingModalOptions && + + } + + } +
+ ); + } + +} + +Naming.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + settings: PropTypes.object.isRequired, + hasSettings: PropTypes.bool.isRequired, + examples: PropTypes.object.isRequired, + examplesPopulated: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default Naming; diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js b/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js new file mode 100644 index 000000000..bd11e4299 --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js @@ -0,0 +1,97 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import { fetchNamingSettings, setNamingSettingsValue, fetchNamingExamples } from 'Store/Actions/settingsActions'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import Naming from './Naming'; + +const SECTION = 'naming'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + (state) => state.settings.namingExamples, + createSettingsSectionSelector(SECTION), + (advancedSettings, examples, sectionSettings) => { + return { + advancedSettings, + examples: examples.item, + examplesPopulated: !_.isEmpty(examples.item), + ...sectionSettings + }; + } + ); +} + +const mapDispatchToProps = { + fetchNamingSettings, + setNamingSettingsValue, + fetchNamingExamples, + clearPendingChanges +}; + +class NamingConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._namingExampleTimeout = null; + } + + componentDidMount() { + this.props.fetchNamingSettings(); + this.props.fetchNamingExamples(); + } + + componentWillUnmount() { + this.props.clearPendingChanges({ section: 'settings.naming' }); + } + + // + // Control + + _fetchNamingExamples = () => { + this.props.fetchNamingExamples(); + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setNamingSettingsValue({ name, value }); + + if (this._namingExampleTimeout) { + clearTimeout(this._namingExampleTimeout); + } + + this._namingExampleTimeout = setTimeout(this._fetchNamingExamples, 1000); + } + + // + // Render + + render() { + return ( + + ); + } +} + +NamingConnector.propTypes = { + fetchNamingSettings: PropTypes.func.isRequired, + setNamingSettingsValue: PropTypes.func.isRequired, + fetchNamingExamples: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(NamingConnector); diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingModal.css b/frontend/src/Settings/MediaManagement/Naming/NamingModal.css new file mode 100644 index 000000000..c178d82cb --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/NamingModal.css @@ -0,0 +1,18 @@ +.groups { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + margin-bottom: 20px; +} + +.namingSelectContainer { + display: flex; + justify-content: flex-end; +} + +.namingSelect { + composes: select from '~Components/Form/SelectInput.css'; + + margin-left: 10px; + width: 200px; +} diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingModal.js b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js new file mode 100644 index 000000000..f96d364e6 --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js @@ -0,0 +1,541 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { sizes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Button from 'Components/Link/Button'; +import SelectInput from 'Components/Form/SelectInput'; +import TextInput from 'Components/Form/TextInput'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import NamingOption from './NamingOption'; +import styles from './NamingModal.css'; + +const separatorOptions = [ + { key: ' ', value: 'Space ( )' }, + { key: '.', value: 'Period (.)' }, + { key: '_', value: 'Underscore (_)' }, + { key: '-', value: 'Dash (-)' } +]; + +const caseOptions = [ + { key: 'title', value: 'Default Case' }, + { key: 'lower', value: 'Lower Case' }, + { key: 'upper', value: 'Upper Case' } +]; + +const fileNameTokens = [ + { + token: '{Artist Name} - {Album Title} - {track:00} - {Track Title} {Quality Full}', + example: 'Artist Name - Album Title - 01 - Track Title MP3-320 Proper' + }, + { + token: '{Artist.Name}.{Album.Title}.{track:00}.{TrackClean.Title}.{Quality.Full}', + example: 'Artist.Name.Album.Title.01.Track.Title.MP3-320' + } +]; + +const artistTokens = [ + { token: '{Artist Name}', example: 'Artist Name' }, + + { token: '{Artist NameThe}', example: 'Artist Name, The' }, + + { token: '{Artist CleanName}', example: 'Artist Name' }, + + { token: '{Artist Disambiguation}', example: 'Disambiguation' } +]; + +const albumTokens = [ + { token: '{Album Title}', example: 'Album Title' }, + + { token: '{Album TitleThe}', example: 'Album Title, The' }, + + { token: '{Album CleanTitle}', example: 'Album Title' }, + + { token: '{Album Type}', example: 'Album Type' }, + + { token: '{Album Disambiguation}', example: 'Disambiguation' } +]; + +const mediumTokens = [ + { token: '{medium:0}', example: '1' }, + { token: '{medium:00}', example: '01' } +]; + +const mediumFormatTokens = [ + { token: '{Medium Format}', example: 'CD' } +]; + +const trackTokens = [ + { token: '{track:0}', example: '1' }, + { token: '{track:00}', example: '01' } +]; + +const releaseDateTokens = [ + { token: '{Release Year}', example: '2016' } +]; + +const trackTitleTokens = [ + { token: '{Track Title}', example: 'Track Title' }, + { token: '{Track CleanTitle}', example: 'Track Title' } +]; + +const qualityTokens = [ + { token: '{Quality Full}', example: 'FLAC Proper' }, + { token: '{Quality Title}', example: 'FLAC' } +]; + +const mediaInfoTokens = [ + { token: '{MediaInfo AudioCodec}', example: 'FLAC' }, + { token: '{MediaInfo AudioChannels}', example: '2.0' }, + { token: '{MediaInfo AudioBitRate}', example: '320kbps' }, + { token: '{MediaInfo AudioBitsPerSample}', example: '24bit' }, + { token: '{MediaInfo AudioSampleRate}', example: '44.1kHz' } +]; + +const otherTokens = [ + { token: '{Release Group}', example: 'Rls Grp' }, + { token: '{Preferred Words}', example: 'iNTERNAL' } +]; + +const originalTokens = [ + { token: '{Original Title}', example: 'Artist.Name.Album.Name.2018.FLAC-EVOLVE' }, + { token: '{Original Filename}', example: '01 - track name' } +]; + +class NamingModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._selectionStart = null; + this._selectionEnd = null; + + this.state = { + separator: ' ', + case: 'title' + }; + } + + // + // Listeners + + onTokenSeparatorChange = (event) => { + this.setState({ separator: event.value }); + } + + onTokenCaseChange = (event) => { + this.setState({ case: event.value }); + } + + onInputSelectionChange = (selectionStart, selectionEnd) => { + this._selectionStart = selectionStart; + this._selectionEnd = selectionEnd; + } + + onOptionPress = ({ isFullFilename, tokenValue }) => { + const { + name, + value, + onInputChange + } = this.props; + + const selectionStart = this._selectionStart; + const selectionEnd = this._selectionEnd; + + if (isFullFilename) { + onInputChange({ name, value: tokenValue }); + } else if (selectionStart == null) { + onInputChange({ + name, + value: `${value}${tokenValue}` + }); + } else { + const start = value.substring(0, selectionStart); + const end = value.substring(selectionEnd); + const newValue = `${start}${tokenValue}${end}`; + + onInputChange({ name, value: newValue }); + this._selectionStart = newValue.length - 1; + this._selectionEnd = newValue.length - 1; + } + } + + // + // Render + + render() { + const { + name, + value, + isOpen, + advancedSettings, + album, + track, + additional, + onInputChange, + onModalClose + } = this.props; + + const { + separator: tokenSeparator, + case: tokenCase + } = this.state; + + return ( + + + + File Name Tokens + + + +
+ + + +
+ + { + !advancedSettings && +
+
+ { + fileNameTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ } + +
+
+ { + artistTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ + { + album && +
+
+
+ { + albumTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ +
+
+ { + releaseDateTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+
+ } + + { + track && +
+
+
+ { + mediumTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ +
+
+ { + mediumFormatTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ +
+
+ { + trackTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ +
+ } + + { + additional && +
+
+
+ { + trackTitleTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ +
+
+ { + qualityTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ +
+
+ { + mediaInfoTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ +
+
+ { + otherTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ +
+
+ { + originalTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+
+ } +
+ + + + + +
+
+ ); + } +} + +NamingModal.propTypes = { + name: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + isOpen: PropTypes.bool.isRequired, + advancedSettings: PropTypes.bool.isRequired, + album: PropTypes.bool.isRequired, + track: PropTypes.bool.isRequired, + additional: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +NamingModal.defaultProps = { + album: false, + track: false, + additional: false +}; + +export default NamingModal; diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingOption.css b/frontend/src/Settings/MediaManagement/Naming/NamingOption.css new file mode 100644 index 000000000..d9f865936 --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/NamingOption.css @@ -0,0 +1,69 @@ +.option { + display: flex; + align-items: center; + flex-wrap: wrap; + margin: 3px; + border: 1px solid $borderColor; + + &:hover { + .token { + background-color: #ddd; + } + + .example { + background-color: #ccc; + } + } +} + +.small { + width: 460px; +} + +.large { + width: 100%; +} + +.token { + flex: 0 0 50%; + padding: 6px 16px; + background-color: #eee; + font-family: $monoSpaceFontFamily; +} + +.example { + display: flex; + align-items: center; + align-self: stretch; + flex: 0 0 50%; + padding: 6px 16px; + background-color: #ddd; +} + +.lower { + text-transform: lowercase; +} + +.upper { + text-transform: uppercase; +} + +.isFullFilename { + .token, + .example { + flex: 1 0 auto; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .option.small { + width: 100%; + } +} + +@media only screen and (max-width: $breakpointExtraSmall) { + .token, + .example { + flex: 1 0 auto; + } +} diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingOption.js b/frontend/src/Settings/MediaManagement/Naming/NamingOption.js new file mode 100644 index 000000000..269266a5f --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/NamingOption.js @@ -0,0 +1,84 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { sizes } from 'Helpers/Props'; +import Link from 'Components/Link/Link'; +import styles from './NamingOption.css'; + +class NamingOption extends Component { + + // + // Listeners + + onPress = () => { + const { + token, + tokenSeparator, + tokenCase, + isFullFilename, + onPress + } = this.props; + + let tokenValue = token; + + tokenValue = tokenValue.replace(/ /g, tokenSeparator); + + if (tokenCase === 'lower') { + tokenValue = token.toLowerCase(); + } else if (tokenCase === 'upper') { + tokenValue = token.toUpperCase(); + } + + onPress({ isFullFilename, tokenValue }); + } + + // + // Render + render() { + const { + token, + tokenSeparator, + example, + tokenCase, + isFullFilename, + size + } = this.props; + + return ( + +
+ {token.replace(/ /g, tokenSeparator)} +
+ +
+ {example.replace(/ /g, tokenSeparator)} +
+ + ); + } +} + +NamingOption.propTypes = { + token: PropTypes.string.isRequired, + example: PropTypes.string.isRequired, + tokenSeparator: PropTypes.string.isRequired, + tokenCase: PropTypes.string.isRequired, + isFullFilename: PropTypes.bool.isRequired, + size: PropTypes.oneOf([sizes.SMALL, sizes.LARGE]), + onPress: PropTypes.func.isRequired +}; + +NamingOption.defaultProps = { + size: sizes.SMALL, + isFullFilename: false +}; + +export default NamingOption; diff --git a/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolder.css b/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolder.css new file mode 100644 index 000000000..19b1880be --- /dev/null +++ b/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolder.css @@ -0,0 +1,7 @@ +.addRootFolderButtonContainer { + margin-top: 20px; +} + +.importButtonIcon { + margin-right: 8px; +} diff --git a/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolder.js b/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolder.js new file mode 100644 index 000000000..3da2a55b9 --- /dev/null +++ b/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolder.js @@ -0,0 +1,71 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Icon from 'Components/Icon'; +import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal'; +import styles from './AddRootFolder.css'; + +class AddRootFolder extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddNewRootFolderModalOpen: false + }; + } + + // + // Lifecycle + + onAddNewRootFolderPress = () => { + this.setState({ isAddNewRootFolderModalOpen: true }); + } + + onNewRootFolderSelect = ({ value }) => { + this.props.onNewRootFolderSelect(value); + } + + onAddRootFolderModalClose = () => { + this.setState({ isAddNewRootFolderModalOpen: false }); + } + + // + // Render + + render() { + return ( +
+ + + +
+ ); + } +} + +AddRootFolder.propTypes = { + onNewRootFolderSelect: PropTypes.func.isRequired +}; + +export default AddRootFolder; diff --git a/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolderConnector.js b/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolderConnector.js new file mode 100644 index 000000000..cd5f4c50d --- /dev/null +++ b/frontend/src/Settings/MediaManagement/RootFolder/AddRootFolderConnector.js @@ -0,0 +1,13 @@ +import { connect } from 'react-redux'; +import AddRootFolder from './AddRootFolder'; +import { addRootFolder } from 'Store/Actions/rootFolderActions'; + +function createMapDispatchToProps(dispatch) { + return { + onNewRootFolderSelect(path) { + dispatch(addRootFolder({ path })); + } + }; +} + +export default connect(null, createMapDispatchToProps)(AddRootFolder); diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js new file mode 100644 index 000000000..24c0237cd --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditMetadataModalContentConnector from './EditMetadataModalContentConnector'; + +function EditMetadataModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditMetadataModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditMetadataModal; diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js new file mode 100644 index 000000000..cb461520f --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js @@ -0,0 +1,45 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditMetadataModal from './EditMetadataModal'; + +function createMapDispatchToProps(dispatch, props) { + const section = 'settings.metadata'; + + return { + dispatchClearPendingChanges() { + dispatch(clearPendingChanges({ section })); + } + }; +} + +class EditMetadataModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.dispatchClearPendingChanges(); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditMetadataModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(null, createMapDispatchToProps)(EditMetadataModalConnector); diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.js new file mode 100644 index 000000000..96bbb4b83 --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.js @@ -0,0 +1,101 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; + +function EditMetadataModalContent(props) { + const { + isSaving, + saveError, + item, + onInputChange, + onFieldChange, + onModalClose, + onSavePress, + ...otherProps + } = props; + + const { + name, + enable, + fields + } = item; + + return ( + + + Edit {name.value} Metadata + + + +
+ + Enable + + + + + { + fields.map((field) => { + return ( + + ); + }) + } + + +
+ + + + + + Save + + +
+ ); +} + +EditMetadataModalContent.propTypes = { + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired, + onFieldChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onDeleteMetadataPress: PropTypes.func +}; + +export default EditMetadataModalContent; diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContentConnector.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContentConnector.js new file mode 100644 index 000000000..2cd7636a0 --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContentConnector.js @@ -0,0 +1,93 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; +import { setMetadataValue, setMetadataFieldValue, saveMetadata } from 'Store/Actions/settingsActions'; +import EditMetadataModalContent from './EditMetadataModalContent'; + +function createMapStateToProps() { + return createSelector( + (state, { id }) => id, + (state) => state.settings.metadata, + (id, metadata) => { + const { + isSaving, + saveError, + pendingChanges, + items + } = metadata; + + const settings = selectSettings(_.find(items, { id }), pendingChanges, saveError); + + return { + id, + isSaving, + saveError, + item: settings.settings, + ...settings + }; + } + ); +} + +const mapDispatchToProps = { + setMetadataValue, + setMetadataFieldValue, + saveMetadata +}; + +class EditMetadataModalContentConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setMetadataValue({ name, value }); + } + + onFieldChange = ({ name, value }) => { + this.props.setMetadataFieldValue({ name, value }); + } + + onSavePress = () => { + this.props.saveMetadata({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditMetadataModalContentConnector.propTypes = { + id: PropTypes.number, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setMetadataValue: PropTypes.func.isRequired, + setMetadataFieldValue: PropTypes.func.isRequired, + saveMetadata: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditMetadataModalContentConnector); diff --git a/frontend/src/Settings/Metadata/Metadata/Metadata.css b/frontend/src/Settings/Metadata/Metadata/Metadata.css new file mode 100644 index 000000000..f87b92a81 --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/Metadata.css @@ -0,0 +1,15 @@ +.metadata { + composes: card from '~Components/Card.css'; + + width: 290px; +} + +.name { + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.section { + margin-top: 10px; +} diff --git a/frontend/src/Settings/Metadata/Metadata/Metadata.js b/frontend/src/Settings/Metadata/Metadata/Metadata.js new file mode 100644 index 000000000..eba01463c --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/Metadata.js @@ -0,0 +1,149 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import EditMetadataModalConnector from './EditMetadataModalConnector'; +import styles from './Metadata.css'; + +class Metadata extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditMetadataModalOpen: false + }; + } + + // + // Listeners + + onEditMetadataPress = () => { + this.setState({ isEditMetadataModalOpen: true }); + } + + onEditMetadataModalClose = () => { + this.setState({ isEditMetadataModalOpen: false }); + } + + // + // Render + + render() { + const { + id, + name, + enable, + fields + } = this.props; + + const metadataFields = []; + const imageFields = []; + + fields.forEach((field) => { + if (field.section === 'metadata') { + metadataFields.push(field); + } else { + imageFields.push(field); + } + }); + + return ( + +
+ {name} +
+ +
+ { + enable ? + : + + } +
+ + { + enable && !!metadataFields.length && +
+
+ Metadata +
+ + { + metadataFields.map((field) => { + if (!field.value) { + return null; + } + + return ( + + ); + }) + } +
+ } + + { + enable && !!imageFields.length && +
+
+ Images +
+ + { + imageFields.map((field) => { + if (!field.value) { + return null; + } + + return ( + + ); + }) + } +
+ } + + +
+ ); + } +} + +Metadata.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + enable: PropTypes.bool.isRequired, + fields: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default Metadata; diff --git a/frontend/src/Settings/Metadata/Metadata/Metadatas.css b/frontend/src/Settings/Metadata/Metadata/Metadatas.css new file mode 100644 index 000000000..fb1bd6080 --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/Metadatas.css @@ -0,0 +1,4 @@ +.metadatas { + display: flex; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/Metadata/Metadata/Metadatas.js b/frontend/src/Settings/Metadata/Metadata/Metadatas.js new file mode 100644 index 000000000..faf7e9613 --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/Metadatas.js @@ -0,0 +1,44 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import sortByName from 'Utilities/Array/sortByName'; +import FieldSet from 'Components/FieldSet'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import Metadata from './Metadata'; +import styles from './Metadatas.css'; + +function Metadatas(props) { + const { + items, + ...otherProps + } = props; + + return ( +
+ +
+ { + items.sort(sortByName).map((item) => { + return ( + + ); + }) + } +
+
+
+ ); +} + +Metadatas.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default Metadatas; diff --git a/frontend/src/Settings/Metadata/Metadata/MetadatasConnector.js b/frontend/src/Settings/Metadata/Metadata/MetadatasConnector.js new file mode 100644 index 000000000..fb7153950 --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/MetadatasConnector.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchMetadata } from 'Store/Actions/settingsActions'; +import Metadatas from './Metadatas'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.metadata, + (metadata) => { + return { + ...metadata + }; + } + ); +} + +const mapDispatchToProps = { + fetchMetadata +}; + +class MetadatasConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchMetadata(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +MetadatasConnector.propTypes = { + fetchMetadata: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(MetadatasConnector); diff --git a/frontend/src/Settings/Metadata/MetadataProvider/MetadataProvider.js b/frontend/src/Settings/Metadata/MetadataProvider/MetadataProvider.js new file mode 100644 index 000000000..f58a8ec49 --- /dev/null +++ b/frontend/src/Settings/Metadata/MetadataProvider/MetadataProvider.js @@ -0,0 +1,109 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FieldSet from 'Components/FieldSet'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +const writeAudioTagOptions = [ + { key: 'sync', value: 'All files; keep in sync with MusicBrainz' }, + { key: 'allFiles', value: 'All files; initial import only' }, + { key: 'newFiles', value: 'For new downloads only' }, + { key: 'no', value: 'Never' } +]; + +function MetadataProvider(props) { + const { + advancedSettings, + isFetching, + error, + settings, + hasSettings, + onInputChange + } = props; + + return ( + +
+ { + isFetching && + + } + + { + !isFetching && error && +
Unable to load Metadata Provider settings
+ } + + { + hasSettings && !isFetching && !error && +
+ { + advancedSettings && +
+ + Metadata Source + + + +
+ } + +
+ + Tag Audio Files with Metadata + + + + + + Scrub Existing Tags + + + + +
+
+ } +
+ + ); +} + +MetadataProvider.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + settings: PropTypes.object.isRequired, + hasSettings: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default MetadataProvider; diff --git a/frontend/src/Settings/Metadata/MetadataProvider/MetadataProviderConnector.js b/frontend/src/Settings/Metadata/MetadataProvider/MetadataProviderConnector.js new file mode 100644 index 000000000..7b459acf3 --- /dev/null +++ b/frontend/src/Settings/Metadata/MetadataProvider/MetadataProviderConnector.js @@ -0,0 +1,101 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import { setMetadataProviderValue, saveMetadataProvider, fetchMetadataProvider } from 'Store/Actions/settingsActions'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import MetadataProvider from './MetadataProvider'; + +const SECTION = 'metadataProvider'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createSettingsSectionSelector(SECTION), + (advancedSettings, sectionSettings) => { + return { + advancedSettings, + ...sectionSettings + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchMetadataProvider: fetchMetadataProvider, + dispatchSetMetadataProviderValue: setMetadataProviderValue, + dispatchSaveMetadataProvider: saveMetadataProvider, + dispatchClearPendingChanges: clearPendingChanges +}; + +class MetadataProviderConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + dispatchFetchMetadataProvider, + dispatchSaveMetadataProvider, + onChildMounted + } = this.props; + + dispatchFetchMetadataProvider(); + onChildMounted(dispatchSaveMetadataProvider); + } + + componentDidUpdate(prevProps) { + const { + hasPendingChanges, + isSaving, + onChildStateChange + } = this.props; + + if ( + prevProps.isSaving !== isSaving || + prevProps.hasPendingChanges !== hasPendingChanges + ) { + onChildStateChange({ + isSaving, + hasPendingChanges + }); + } + } + + componentWillUnmount() { + this.props.dispatchClearPendingChanges({ section: 'settings.metadataProvider' }); + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.dispatchSetMetadataProviderValue({ name, value }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +MetadataProviderConnector.propTypes = { + isSaving: PropTypes.bool.isRequired, + hasPendingChanges: PropTypes.bool.isRequired, + dispatchFetchMetadataProvider: PropTypes.func.isRequired, + dispatchSetMetadataProviderValue: PropTypes.func.isRequired, + dispatchSaveMetadataProvider: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired, + onChildMounted: PropTypes.func.isRequired, + onChildStateChange: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(MetadataProviderConnector); diff --git a/frontend/src/Settings/Metadata/MetadataSettings.js b/frontend/src/Settings/Metadata/MetadataSettings.js new file mode 100644 index 000000000..fc7fd0bb4 --- /dev/null +++ b/frontend/src/Settings/Metadata/MetadataSettings.js @@ -0,0 +1,69 @@ +import React, { Component } from 'react'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import MetadatasConnector from './Metadata/MetadatasConnector'; +import MetadataProviderConnector from './MetadataProvider/MetadataProviderConnector'; + +class MetadataSettings extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._saveCallback = null; + + this.state = { + isSaving: false, + hasPendingChanges: false + }; + } + + // + // Listeners + + onChildMounted = (saveCallback) => { + this._saveCallback = saveCallback; + } + + onChildStateChange = (payload) => { + this.setState(payload); + } + + onSavePress = () => { + if (this._saveCallback) { + this._saveCallback(); + } + } + + // + // Render + render() { + const { + isSaving, + hasPendingChanges + } = this.state; + + return ( + + + + + + + + + ); + } +} + +export default MetadataSettings; diff --git a/frontend/src/Settings/Notifications/NotificationSettings.js b/frontend/src/Settings/Notifications/NotificationSettings.js new file mode 100644 index 000000000..c9bed6501 --- /dev/null +++ b/frontend/src/Settings/Notifications/NotificationSettings.js @@ -0,0 +1,21 @@ +import React from 'react'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import NotificationsConnector from './Notifications/NotificationsConnector'; + +function NotificationSettings() { + return ( + + + + + + + + ); +} + +export default NotificationSettings; diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.css b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.css new file mode 100644 index 000000000..a9e416098 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.css @@ -0,0 +1,44 @@ +.notification { + composes: card from '~Components/Card.css'; + + position: relative; + width: 300px; + height: 100px; +} + +.underlay { + @add-mixin cover; +} + +.overlay { + @add-mixin linkOverlay; + + padding: 10px; +} + +.name { + text-align: center; + font-weight: lighter; + font-size: 24px; +} + +.actions { + margin-top: 20px; + text-align: right; +} + +.presetsMenu { + composes: menu from '~Components/Menu/Menu.css'; + + display: inline-block; + margin: 0 5px; +} + +.presetsMenuButton { + composes: button from '~Components/Link/Button.css'; + + &::after { + margin-left: 5px; + content: '\25BE'; + } +} diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.js new file mode 100644 index 000000000..6d90961b0 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationItem.js @@ -0,0 +1,110 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Link from 'Components/Link/Link'; +import Menu from 'Components/Menu/Menu'; +import MenuContent from 'Components/Menu/MenuContent'; +import AddNotificationPresetMenuItem from './AddNotificationPresetMenuItem'; +import styles from './AddNotificationItem.css'; + +class AddNotificationItem extends Component { + + // + // Listeners + + onNotificationSelect = () => { + const { + implementation + } = this.props; + + this.props.onNotificationSelect({ implementation }); + } + + // + // Render + + render() { + const { + implementation, + implementationName, + infoLink, + presets, + onNotificationSelect + } = this.props; + + const hasPresets = !!presets && !!presets.length; + + return ( +
+ + +
+
+ {implementationName} +
+ +
+ { + hasPresets && + + + + + + + + { + presets.map((preset) => { + return ( + + ); + }) + } + + + + } + + +
+
+
+ ); + } +} + +AddNotificationItem.propTypes = { + implementation: PropTypes.string.isRequired, + implementationName: PropTypes.string.isRequired, + infoLink: PropTypes.string.isRequired, + presets: PropTypes.arrayOf(PropTypes.object), + onNotificationSelect: PropTypes.func.isRequired +}; + +export default AddNotificationItem; diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModal.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationModal.js new file mode 100644 index 000000000..45f5e14b6 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import AddNotificationModalContentConnector from './AddNotificationModalContentConnector'; + +function AddNotificationModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +AddNotificationModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddNotificationModal; diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.css b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.css new file mode 100644 index 000000000..8744e516c --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.css @@ -0,0 +1,5 @@ +.notifications { + display: flex; + justify-content: center; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.js new file mode 100644 index 000000000..e09342b98 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.js @@ -0,0 +1,85 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import AddNotificationItem from './AddNotificationItem'; +import styles from './AddNotificationModalContent.css'; + +class AddNotificationModalContent extends Component { + + // + // Render + + render() { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + schema, + onNotificationSelect, + onModalClose + } = this.props; + + return ( + + + Add Notification + + + + { + isSchemaFetching && + + } + + { + !isSchemaFetching && !!schemaError && +
Unable to add a new notification, please try again.
+ } + + { + isSchemaPopulated && !schemaError && +
+
+ { + schema.map((notification) => { + return ( + + ); + }) + } +
+
+ } +
+ + + +
+ ); + } +} + +AddNotificationModalContent.propTypes = { + isSchemaFetching: PropTypes.bool.isRequired, + isSchemaPopulated: PropTypes.bool.isRequired, + schemaError: PropTypes.object, + schema: PropTypes.arrayOf(PropTypes.object).isRequired, + onNotificationSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddNotificationModalContent; diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContentConnector.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContentConnector.js new file mode 100644 index 000000000..abeb5e2ac --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContentConnector.js @@ -0,0 +1,70 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchNotificationSchema, selectNotificationSchema } from 'Store/Actions/settingsActions'; +import AddNotificationModalContent from './AddNotificationModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.notifications, + (notifications) => { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + schema + } = notifications; + + return { + isSchemaFetching, + isSchemaPopulated, + schemaError, + schema + }; + } + ); +} + +const mapDispatchToProps = { + fetchNotificationSchema, + selectNotificationSchema +}; + +class AddNotificationModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchNotificationSchema(); + } + + // + // Listeners + + onNotificationSelect = ({ implementation, name }) => { + this.props.selectNotificationSchema({ implementation, presetName: name }); + this.props.onModalClose({ notificationSelected: true }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AddNotificationModalContentConnector.propTypes = { + fetchNotificationSchema: PropTypes.func.isRequired, + selectNotificationSchema: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AddNotificationModalContentConnector); diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.js new file mode 100644 index 000000000..e4df85b8a --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import MenuItem from 'Components/Menu/MenuItem'; + +class AddNotificationPresetMenuItem extends Component { + + // + // Listeners + + onPress = () => { + const { + name, + implementation + } = this.props; + + this.props.onPress({ + name, + implementation + }); + } + + // + // Render + + render() { + const { + name, + implementation, + ...otherProps + } = this.props; + + return ( + + {name} + + ); + } +} + +AddNotificationPresetMenuItem.propTypes = { + name: PropTypes.string.isRequired, + implementation: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired +}; + +export default AddNotificationPresetMenuItem; diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.js new file mode 100644 index 000000000..27e41d062 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditNotificationModalContentConnector from './EditNotificationModalContentConnector'; + +function EditNotificationModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditNotificationModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditNotificationModal; diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalConnector.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalConnector.js new file mode 100644 index 000000000..e1452d142 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalConnector.js @@ -0,0 +1,65 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import { cancelTestNotification, cancelSaveNotification } from 'Store/Actions/settingsActions'; +import EditNotificationModal from './EditNotificationModal'; + +function createMapDispatchToProps(dispatch, props) { + const section = 'settings.notifications'; + + return { + dispatchClearPendingChanges() { + dispatch(clearPendingChanges({ section })); + }, + + dispatchCancelTestNotification() { + dispatch(cancelTestNotification({ section })); + }, + + dispatchCancelSaveNotification() { + dispatch(cancelSaveNotification({ section })); + } + }; +} + +class EditNotificationModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.dispatchClearPendingChanges(); + this.props.dispatchCancelTestNotification(); + this.props.dispatchCancelSaveNotification(); + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + dispatchClearPendingChanges, + dispatchCancelTestNotification, + dispatchCancelSaveNotification, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +EditNotificationModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired, + dispatchCancelTestNotification: PropTypes.func.isRequired, + dispatchCancelSaveNotification: PropTypes.func.isRequired +}; + +export default connect(null, createMapDispatchToProps)(EditNotificationModalConnector); diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.css b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.css new file mode 100644 index 000000000..8e1c16507 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.css @@ -0,0 +1,11 @@ +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: auto; +} + +.message { + composes: alert from '~Components/Alert.css'; + + margin-bottom: 30px; +} diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js new file mode 100644 index 000000000..c328b77d8 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js @@ -0,0 +1,178 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; +import NotificationEventItems from './NotificationEventItems'; +import styles from './EditNotificationModalContent.css'; + +function EditNotificationModalContent(props) { + const { + advancedSettings, + isFetching, + error, + isSaving, + isTesting, + saveError, + item, + onInputChange, + onFieldChange, + onModalClose, + onSavePress, + onTestPress, + onDeleteNotificationPress, + ...otherProps + } = props; + + const { + id, + implementationName, + name, + tags, + fields, + message + } = item; + + return ( + + + {`${id ? 'Edit' : 'Add'} Connection - ${implementationName}`} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to add a new notification, please try again.
+ } + + { + !isFetching && !error && +
+ { + !!message && + + {message.value.message} + + } + + + Name + + + + + + + + Tags + + + + + { + fields.map((field) => { + return ( + + ); + }) + } + + + } +
+ + { + id && + + } + + + Test + + + + + + Save + + +
+ ); +} + +EditNotificationModalContent.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + isTesting: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired, + onFieldChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onTestPress: PropTypes.func.isRequired, + onDeleteNotificationPress: PropTypes.func +}; + +export default EditNotificationModalContent; diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContentConnector.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContentConnector.js new file mode 100644 index 000000000..104f1897a --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContentConnector.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import { setNotificationValue, setNotificationFieldValue, saveNotification, testNotification } from 'Store/Actions/settingsActions'; +import EditNotificationModalContent from './EditNotificationModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createProviderSettingsSelector('notifications'), + (advancedSettings, notification) => { + return { + advancedSettings, + ...notification + }; + } + ); +} + +const mapDispatchToProps = { + setNotificationValue, + setNotificationFieldValue, + saveNotification, + testNotification +}; + +class EditNotificationModalContentConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setNotificationValue({ name, value }); + } + + onFieldChange = ({ name, value }) => { + this.props.setNotificationFieldValue({ name, value }); + } + + onSavePress = () => { + this.props.saveNotification({ id: this.props.id }); + } + + onTestPress = () => { + this.props.testNotification({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditNotificationModalContentConnector.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setNotificationValue: PropTypes.func.isRequired, + setNotificationFieldValue: PropTypes.func.isRequired, + saveNotification: PropTypes.func.isRequired, + testNotification: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditNotificationModalContentConnector); diff --git a/frontend/src/Settings/Notifications/Notifications/Notification.css b/frontend/src/Settings/Notifications/Notifications/Notification.css new file mode 100644 index 000000000..d7717d8c9 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/Notification.css @@ -0,0 +1,19 @@ +.notification { + composes: card from '~Components/Card.css'; + + width: 290px; +} + +.name { + @add-mixin truncate; + + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.enabled { + display: flex; + flex-wrap: wrap; + margin-top: 5px; +} diff --git a/frontend/src/Settings/Notifications/Notifications/Notification.js b/frontend/src/Settings/Notifications/Notifications/Notification.js new file mode 100644 index 000000000..5385ecc45 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/Notification.js @@ -0,0 +1,195 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditNotificationModalConnector from './EditNotificationModalConnector'; +import styles from './Notification.css'; + +class Notification extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditNotificationModalOpen: false, + isDeleteNotificationModalOpen: false + }; + } + + // + // Listeners + + onEditNotificationPress = () => { + this.setState({ isEditNotificationModalOpen: true }); + } + + onEditNotificationModalClose = () => { + this.setState({ isEditNotificationModalOpen: false }); + } + + onDeleteNotificationPress = () => { + this.setState({ + isEditNotificationModalOpen: false, + isDeleteNotificationModalOpen: true + }); + } + + onDeleteNotificationModalClose= () => { + this.setState({ isDeleteNotificationModalOpen: false }); + } + + onConfirmDeleteNotification = () => { + this.props.onConfirmDeleteNotification(this.props.id); + } + + // + // Render + + render() { + const { + id, + name, + onGrab, + onReleaseImport, + onUpgrade, + onRename, + onHealthIssue, + onDownloadFailure, + onImportFailure, + onTrackRetag, + supportsOnGrab, + supportsOnReleaseImport, + supportsOnUpgrade, + supportsOnRename, + supportsOnHealthIssue, + supportsOnDownloadFailure, + supportsOnImportFailure, + supportsOnTrackRetag + } = this.props; + + return ( + +
+ {name} +
+ + { + supportsOnGrab && onGrab && + + } + + { + supportsOnReleaseImport && onReleaseImport && + + } + + { + supportsOnUpgrade && onReleaseImport && onUpgrade && + + } + + { + supportsOnRename && onRename && + + } + + { + supportsOnTrackRetag && onTrackRetag && + + } + + { + supportsOnHealthIssue && onHealthIssue && + + } + + { + supportsOnDownloadFailure && onDownloadFailure && + + } + + { + supportsOnImportFailure && onImportFailure && + + } + + { + !onGrab && !onReleaseImport && !onRename && !onTrackRetag && + !onHealthIssue && !onDownloadFailure && !onImportFailure && + + } + + + + +
+ ); + } +} + +Notification.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + onGrab: PropTypes.bool.isRequired, + onReleaseImport: PropTypes.bool.isRequired, + onUpgrade: PropTypes.bool.isRequired, + onRename: PropTypes.bool.isRequired, + onHealthIssue: PropTypes.bool.isRequired, + onDownloadFailure: PropTypes.bool.isRequired, + onImportFailure: PropTypes.bool.isRequired, + onTrackRetag: PropTypes.bool.isRequired, + supportsOnGrab: PropTypes.bool.isRequired, + supportsOnReleaseImport: PropTypes.bool.isRequired, + supportsOnUpgrade: PropTypes.bool.isRequired, + supportsOnRename: PropTypes.bool.isRequired, + supportsOnHealthIssue: PropTypes.bool.isRequired, + supportsOnDownloadFailure: PropTypes.bool.isRequired, + supportsOnImportFailure: PropTypes.bool.isRequired, + supportsOnTrackRetag: PropTypes.bool.isRequired, + onConfirmDeleteNotification: PropTypes.func.isRequired +}; + +export default Notification; diff --git a/frontend/src/Settings/Notifications/Notifications/NotificationEventItems.css b/frontend/src/Settings/Notifications/Notifications/NotificationEventItems.css new file mode 100644 index 000000000..b3f6aa717 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/NotificationEventItems.css @@ -0,0 +1,4 @@ +.events { + margin-top: 10px; + user-select: none; +} diff --git a/frontend/src/Settings/Notifications/Notifications/NotificationEventItems.js b/frontend/src/Settings/Notifications/Notifications/NotificationEventItems.js new file mode 100644 index 000000000..e79369b92 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/NotificationEventItems.js @@ -0,0 +1,160 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputHelpText from 'Components/Form/FormInputHelpText'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import styles from './NotificationEventItems.css'; + +function NotificationEventItems(props) { + const { + item, + onInputChange + } = props; + + const { + onGrab, + onReleaseImport, + onUpgrade, + onRename, + onHealthIssue, + onDownloadFailure, + onImportFailure, + onTrackRetag, + supportsOnGrab, + supportsOnReleaseImport, + supportsOnUpgrade, + supportsOnRename, + supportsOnHealthIssue, + includeHealthWarnings, + supportsOnDownloadFailure, + supportsOnImportFailure, + supportsOnTrackRetag + } = item; + + return ( + + Notification Triggers +
+ +
+
+ +
+ +
+ +
+ + { + onReleaseImport.value && +
+ +
+ } + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + { + onHealthIssue.value && +
+ +
+ } +
+
+
+ ); +} + +NotificationEventItems.propTypes = { + item: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default NotificationEventItems; diff --git a/frontend/src/Settings/Notifications/Notifications/Notifications.css b/frontend/src/Settings/Notifications/Notifications/Notifications.css new file mode 100644 index 000000000..11ea6e11f --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/Notifications.css @@ -0,0 +1,20 @@ +.notifications { + display: flex; + flex-wrap: wrap; +} + +.addNotification { + composes: notification from '~./Notification.css'; + + background-color: $cardAlternateBackgroundColor; + color: $gray; + text-align: center; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Settings/Notifications/Notifications/Notifications.js b/frontend/src/Settings/Notifications/Notifications/Notifications.js new file mode 100644 index 000000000..0296c2ed4 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/Notifications.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import sortByName from 'Utilities/Array/sortByName'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Card from 'Components/Card'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import Notification from './Notification'; +import AddNotificationModal from './AddNotificationModal'; +import EditNotificationModalConnector from './EditNotificationModalConnector'; +import styles from './Notifications.css'; + +class Notifications extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddNotificationModalOpen: false, + isEditNotificationModalOpen: false + }; + } + + // + // Listeners + + onAddNotificationPress = () => { + this.setState({ isAddNotificationModalOpen: true }); + } + + onAddNotificationModalClose = ({ notificationSelected = false } = {}) => { + this.setState({ + isAddNotificationModalOpen: false, + isEditNotificationModalOpen: notificationSelected + }); + } + + onEditNotificationModalClose = () => { + this.setState({ isEditNotificationModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + onConfirmDeleteNotification, + ...otherProps + } = this.props; + + const { + isAddNotificationModalOpen, + isEditNotificationModalOpen + } = this.state; + + return ( +
+ +
+ { + items.sort(sortByName).map((item) => { + return ( + + ); + }) + } + + +
+ +
+
+
+ + + + +
+
+ ); + } +} + +Notifications.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteNotification: PropTypes.func.isRequired +}; + +export default Notifications; diff --git a/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js b/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js new file mode 100644 index 000000000..b2b5e5166 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js @@ -0,0 +1,58 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchNotifications, deleteNotification } from 'Store/Actions/settingsActions'; +import Notifications from './Notifications'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.notifications, + (notifications) => { + return { + ...notifications + }; + } + ); +} + +const mapDispatchToProps = { + fetchNotifications, + deleteNotification +}; + +class NotificationsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchNotifications(); + } + + // + // Listeners + + onConfirmDeleteNotification = (id) => { + this.props.deleteNotification({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +NotificationsConnector.propTypes = { + fetchNotifications: PropTypes.func.isRequired, + deleteNotification: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(NotificationsConnector); diff --git a/frontend/src/Settings/PendingChangesModal.js b/frontend/src/Settings/PendingChangesModal.js new file mode 100644 index 000000000..e3b14e228 --- /dev/null +++ b/frontend/src/Settings/PendingChangesModal.js @@ -0,0 +1,62 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +function PendingChangesModal(props) { + const { + isOpen, + onConfirm, + onCancel + } = props; + + return ( + + + Unsaved Changes + + + You have unsaved changes, are you sure you want to leave this page? + + + + + + + + + + ); +} + +PendingChangesModal.propTypes = { + className: PropTypes.string, + isOpen: PropTypes.bool.isRequired, + kind: PropTypes.oneOf(kinds.all), + onConfirm: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired +}; + +PendingChangesModal.defaultProps = { + kind: kinds.PRIMARY +}; + +export default PendingChangesModal; diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfile.css b/frontend/src/Settings/Profiles/Delay/DelayProfile.css new file mode 100644 index 000000000..238742efd --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfile.css @@ -0,0 +1,40 @@ +.delayProfile { + display: flex; + align-items: stretch; + margin-bottom: 10px; + height: 30px; + border-bottom: 1px solid $borderColor; + line-height: 30px; +} + +.column { + flex: 0 0 200px; +} + +.actions { + display: flex; +} + +.dragHandle { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + width: $dragHandleWidth; + text-align: center; + cursor: grab; +} + +.dragIcon { + top: 0; +} + +.isDragging { + opacity: 0.25; +} + +.editButton { + width: $dragHandleWidth; + text-align: center; +} diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfile.js b/frontend/src/Settings/Profiles/Delay/DelayProfile.js new file mode 100644 index 000000000..c9b5b8358 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfile.js @@ -0,0 +1,172 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import titleCase from 'Utilities/String/titleCase'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import TagList from 'Components/TagList'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditDelayProfileModalConnector from './EditDelayProfileModalConnector'; +import styles from './DelayProfile.css'; + +function getDelay(enabled, delay) { + if (!enabled) { + return '-'; + } + + if (!delay) { + return 'No Delay'; + } + + if (delay === 1) { + return '1 Minute'; + } + + // TODO: use better units of time than just minutes + return `${delay} Minutes`; +} + +class DelayProfile extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditDelayProfileModalOpen: false, + isDeleteDelayProfileModalOpen: false + }; + } + + // + // Listeners + + onEditDelayProfilePress = () => { + this.setState({ isEditDelayProfileModalOpen: true }); + } + + onEditDelayProfileModalClose = () => { + this.setState({ isEditDelayProfileModalOpen: false }); + } + + onDeleteDelayProfilePress = () => { + this.setState({ + isEditDelayProfileModalOpen: false, + isDeleteDelayProfileModalOpen: true + }); + } + + onDeleteDelayProfileModalClose = () => { + this.setState({ isDeleteDelayProfileModalOpen: false }); + } + + onConfirmDeleteDelayProfile = () => { + this.props.onConfirmDeleteDelayProfile(this.props.id); + } + + // + // Render + + render() { + const { + id, + enableUsenet, + enableTorrent, + preferredProtocol, + usenetDelay, + torrentDelay, + tags, + tagList, + isDragging, + connectDragSource + } = this.props; + + let preferred = titleCase(preferredProtocol); + + if (!enableUsenet) { + preferred = 'Only Torrent'; + } else if (!enableTorrent) { + preferred = 'Only Usenet'; + } + + return ( +
+
{preferred}
+
{getDelay(enableUsenet, usenetDelay)}
+
{getDelay(enableTorrent, torrentDelay)}
+ + + +
+ + + + + { + id !== 1 && + connectDragSource( +
+ +
+ ) + } +
+ + + + +
+ ); + } +} + +DelayProfile.propTypes = { + id: PropTypes.number.isRequired, + enableUsenet: PropTypes.bool.isRequired, + enableTorrent: PropTypes.bool.isRequired, + preferredProtocol: PropTypes.string.isRequired, + usenetDelay: PropTypes.number.isRequired, + torrentDelay: PropTypes.number.isRequired, + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + tagList: PropTypes.arrayOf(PropTypes.object).isRequired, + isDragging: PropTypes.bool.isRequired, + connectDragSource: PropTypes.func, + onConfirmDeleteDelayProfile: PropTypes.func.isRequired +}; + +DelayProfile.defaultProps = { + // The drag preview will not connect the drag handle. + connectDragSource: (node) => node +}; + +export default DelayProfile; diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css new file mode 100644 index 000000000..cc5a92830 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css @@ -0,0 +1,3 @@ +.dragPreview { + opacity: 0.75; +} diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.js b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.js new file mode 100644 index 000000000..0cc47c7e4 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.js @@ -0,0 +1,81 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { DragLayer } from 'react-dnd'; +import dimensions from 'Styles/Variables/dimensions.js'; +import { DELAY_PROFILE } from 'Helpers/dragTypes'; +import DragPreviewLayer from 'Components/DragPreviewLayer'; +import DelayProfile from './DelayProfile'; +import styles from './DelayProfileDragPreview.css'; + +const dragHandleWidth = parseInt(dimensions.dragHandleWidth); + +function collectDragLayer(monitor) { + return { + item: monitor.getItem(), + itemType: monitor.getItemType(), + currentOffset: monitor.getSourceClientOffset() + }; +} + +class DelayProfileDragPreview extends Component { + + // + // Render + + render() { + const { + width, + item, + itemType, + currentOffset + } = this.props; + + if (!currentOffset || itemType !== DELAY_PROFILE) { + return null; + } + + // The offset is shifted because the drag handle is on the right edge of the + // list item and the preview is wider than the drag handle. + + const { x, y } = currentOffset; + const handleOffset = width - dragHandleWidth; + const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`; + + const style = { + width, + position: 'absolute', + WebkitTransform: transform, + msTransform: transform, + transform + }; + + return ( + +
+ +
+
+ ); + } +} + +DelayProfileDragPreview.propTypes = { + width: PropTypes.number.isRequired, + item: PropTypes.object, + itemType: PropTypes.string, + currentOffset: PropTypes.shape({ + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired + }) +}; + +/* eslint-disable new-cap */ +export default DragLayer(collectDragLayer)(DelayProfileDragPreview); +/* eslint-enable new-cap */ + diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css new file mode 100644 index 000000000..835250678 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css @@ -0,0 +1,17 @@ +.delayProfileDragSource { + padding: 4px 0; +} + +.delayProfilePlaceholder { + width: 100%; + height: 30px; + border-bottom: 1px dotted #aaa; +} + +.delayProfilePlaceholderBefore { + margin-bottom: 8px; +} + +.delayProfilePlaceholderAfter { + margin-top: 8px; +} diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.js b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.js new file mode 100644 index 000000000..4661cb5a1 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.js @@ -0,0 +1,150 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { findDOMNode } from 'react-dom'; +import { DragSource, DropTarget } from 'react-dnd'; +import classNames from 'classnames'; +import { DELAY_PROFILE } from 'Helpers/dragTypes'; +import DelayProfile from './DelayProfile'; +import styles from './DelayProfileDragSource.css'; + +const delayProfileDragSource = { + beginDrag(item) { + return item; + }, + + endDrag(props, monitor, component) { + props.onDelayProfileDragEnd(monitor.getItem(), monitor.didDrop()); + } +}; + +const delayProfileDropTarget = { + hover(props, monitor, component) { + const dragIndex = monitor.getItem().order; + const hoverIndex = props.order; + + const hoverBoundingRect = findDOMNode(component).getBoundingClientRect(); + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const clientOffset = monitor.getClientOffset(); + const hoverClientY = clientOffset.y - hoverBoundingRect.top; + + if (dragIndex === hoverIndex) { + return; + } + + // When moving up, only trigger if drag position is above 50% and + // when moving down, only trigger if drag position is below 50%. + // If we're moving down the hoverIndex needs to be increased + // by one so it's ordered properly. Otherwise the hoverIndex will work. + + if (dragIndex < hoverIndex && hoverClientY > hoverMiddleY) { + props.onDelayProfileDragMove(dragIndex, hoverIndex + 1); + } else if (dragIndex > hoverIndex && hoverClientY < hoverMiddleY) { + props.onDelayProfileDragMove(dragIndex, hoverIndex); + } + } +}; + +function collectDragSource(connect, monitor) { + return { + connectDragSource: connect.dragSource(), + isDragging: monitor.isDragging() + }; +} + +function collectDropTarget(connect, monitor) { + return { + connectDropTarget: connect.dropTarget(), + isOver: monitor.isOver() + }; +} + +class DelayProfileDragSource extends Component { + + // + // Render + + render() { + const { + id, + order, + isDragging, + isDraggingUp, + isDraggingDown, + isOver, + connectDragSource, + connectDropTarget, + ...otherProps + } = this.props; + + const isBefore = !isDragging && isDraggingUp && isOver; + const isAfter = !isDragging && isDraggingDown && isOver; + + // if (isDragging && !isOver) { + // return null; + // } + + return connectDropTarget( +
+ { + isBefore && +
+ } + + + + { + isAfter && +
+ } +
+ ); + } +} + +DelayProfileDragSource.propTypes = { + id: PropTypes.number.isRequired, + order: PropTypes.number.isRequired, + isDragging: PropTypes.bool, + isDraggingUp: PropTypes.bool, + isDraggingDown: PropTypes.bool, + isOver: PropTypes.bool, + connectDragSource: PropTypes.func, + connectDropTarget: PropTypes.func, + onDelayProfileDragMove: PropTypes.func.isRequired, + onDelayProfileDragEnd: PropTypes.func.isRequired +}; + +/* eslint-disable new-cap */ +export default DropTarget( + DELAY_PROFILE, + delayProfileDropTarget, + collectDropTarget +)(DragSource( + DELAY_PROFILE, + delayProfileDragSource, + collectDragSource +)(DelayProfileDragSource)); +/* eslint-enable new-cap */ diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfiles.css b/frontend/src/Settings/Profiles/Delay/DelayProfiles.css new file mode 100644 index 000000000..3cf3e9020 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfiles.css @@ -0,0 +1,27 @@ +.delayProfiles { + user-select: none; +} + +.delayProfilesHeader { + display: flex; + margin-bottom: 10px; + font-weight: bold; +} + +.column { + flex: 0 0 200px; +} + +.tags { + flex: 1 0 auto; +} + +.addDelayProfile { + display: flex; + justify-content: flex-end; +} + +.addButton { + width: $dragHandleWidth; + text-align: center; +} diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfiles.js b/frontend/src/Settings/Profiles/Delay/DelayProfiles.js new file mode 100644 index 000000000..a745da9d4 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfiles.js @@ -0,0 +1,148 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import Measure from 'Components/Measure'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import DelayProfileDragSource from './DelayProfileDragSource'; +import DelayProfileDragPreview from './DelayProfileDragPreview'; +import DelayProfile from './DelayProfile'; +import EditDelayProfileModalConnector from './EditDelayProfileModalConnector'; +import styles from './DelayProfiles.css'; + +class DelayProfiles extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddDelayProfileModalOpen: false, + width: 0 + }; + } + + // + // Listeners + + onAddDelayProfilePress = () => { + this.setState({ isAddDelayProfileModalOpen: true }); + } + + onModalClose = () => { + this.setState({ isAddDelayProfileModalOpen: false }); + } + + onMeasure = ({ width }) => { + this.setState({ width }); + } + + // + // Render + + render() { + const { + defaultProfile, + items, + tagList, + dragIndex, + dropIndex, + onConfirmDeleteDelayProfile, + ...otherProps + } = this.props; + + const { + isAddDelayProfileModalOpen, + width + } = this.state; + + const isDragging = dropIndex !== null; + const isDraggingUp = isDragging && dropIndex < dragIndex; + const isDraggingDown = isDragging && dropIndex > dragIndex; + + return ( + +
+ +
+
Protocol
+
Usenet Delay
+
Torrent Delay
+
Tags
+
+ +
+ { + items.map((item, index) => { + return ( + + ); + }) + } + + +
+ + { + defaultProfile && +
+ +
+ } + +
+ + + +
+ + +
+
+
+ ); + } +} + +DelayProfiles.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + defaultProfile: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + tagList: PropTypes.arrayOf(PropTypes.object).isRequired, + dragIndex: PropTypes.number, + dropIndex: PropTypes.number, + onConfirmDeleteDelayProfile: PropTypes.func.isRequired +}; + +export default DelayProfiles; diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfilesConnector.js b/frontend/src/Settings/Profiles/Delay/DelayProfilesConnector.js new file mode 100644 index 000000000..16fe5718c --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/DelayProfilesConnector.js @@ -0,0 +1,105 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchDelayProfiles, deleteDelayProfile, reorderDelayProfile } from 'Store/Actions/settingsActions'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import DelayProfiles from './DelayProfiles'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.delayProfiles, + createTagsSelector(), + (delayProfiles, tagList) => { + const defaultProfile = _.find(delayProfiles.items, { id: 1 }); + const items = _.sortBy(_.reject(delayProfiles.items, { id: 1 }), ['order']); + + return { + defaultProfile, + ...delayProfiles, + items, + tagList + }; + } + ); +} + +const mapDispatchToProps = { + fetchDelayProfiles, + deleteDelayProfile, + reorderDelayProfile +}; + +class DelayProfilesConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + dragIndex: null, + dropIndex: null + }; + } + + componentDidMount() { + this.props.fetchDelayProfiles(); + } + + // + // Listeners + + onConfirmDeleteDelayProfile = (id) => { + this.props.deleteDelayProfile({ id }); + } + + onDelayProfileDragMove = (dragIndex, dropIndex) => { + if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) { + this.setState({ + dragIndex, + dropIndex + }); + } + } + + onDelayProfileDragEnd = ({ id }, didDrop) => { + const { + dropIndex + } = this.state; + + if (didDrop && dropIndex !== null) { + this.props.reorderDelayProfile({ id, moveIndex: dropIndex - 1 }); + } + + this.setState({ + dragIndex: null, + dropIndex: null + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +DelayProfilesConnector.propTypes = { + fetchDelayProfiles: PropTypes.func.isRequired, + deleteDelayProfile: PropTypes.func.isRequired, + reorderDelayProfile: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DelayProfilesConnector); diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.js new file mode 100644 index 000000000..9444fd65e --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditDelayProfileModalContentConnector from './EditDelayProfileModalContentConnector'; + +function EditDelayProfileModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditDelayProfileModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditDelayProfileModal; diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalConnector.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalConnector.js new file mode 100644 index 000000000..a1e8d2dcd --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalConnector.js @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditDelayProfileModal from './EditDelayProfileModal'; + +function mapStateToProps() { + return {}; +} + +const mapDispatchToProps = { + clearPendingChanges +}; + +class EditDelayProfileModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'settings.delayProfiles' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditDelayProfileModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(mapStateToProps, mapDispatchToProps)(EditDelayProfileModalConnector); diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.css b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.css new file mode 100644 index 000000000..a2b6014df --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.css @@ -0,0 +1,5 @@ +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.js new file mode 100644 index 000000000..432d5fdb7 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.js @@ -0,0 +1,186 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import { boolSettingShape, numberSettingShape, tagSettingShape } from 'Helpers/Props/Shapes/settingShape'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Alert from 'Components/Alert'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import styles from './EditDelayProfileModalContent.css'; + +function EditDelayProfileModalContent(props) { + const { + id, + isFetching, + error, + isSaving, + saveError, + item, + protocol, + protocolOptions, + onInputChange, + onProtocolChange, + onSavePress, + onModalClose, + onDeleteDelayProfilePress, + ...otherProps + } = props; + + const { + enableUsenet, + enableTorrent, + usenetDelay, + torrentDelay, + tags + } = item; + + return ( + + + {id ? 'Edit Delay Profile' : 'Add Delay Profile'} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to add a new quality profile, please try again.
+ } + + { + !isFetching && !error && +
+ + Protocol + + + + + { + enableUsenet.value && + + Usenet Delay + + + + } + + { + enableTorrent.value && + + Torrent Delay + + + + } + + { + id === 1 ? + + This is the default profile. It applies to all artist that don't have an explicit profile. + : + + + Tags + + + + } +
+ } +
+ + { + id && id > 1 && + + } + + + + + Save + + +
+ ); +} + +const delayProfileShape = { + enableUsenet: PropTypes.shape(boolSettingShape).isRequired, + enableTorrent: PropTypes.shape(boolSettingShape).isRequired, + usenetDelay: PropTypes.shape(numberSettingShape).isRequired, + torrentDelay: PropTypes.shape(numberSettingShape).isRequired, + order: PropTypes.shape(numberSettingShape), + tags: PropTypes.shape(tagSettingShape).isRequired +}; + +EditDelayProfileModalContent.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.shape(delayProfileShape).isRequired, + protocol: PropTypes.string.isRequired, + protocolOptions: PropTypes.arrayOf(PropTypes.object).isRequired, + onInputChange: PropTypes.func.isRequired, + onProtocolChange: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteDelayProfilePress: PropTypes.func +}; + +export default EditDelayProfileModalContent; diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContentConnector.js new file mode 100644 index 000000000..5b7e036f5 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContentConnector.js @@ -0,0 +1,178 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; +import { setDelayProfileValue, saveDelayProfile } from 'Store/Actions/settingsActions'; +import EditDelayProfileModalContent from './EditDelayProfileModalContent'; + +const newDelayProfile = { + enableUsenet: true, + enableTorrent: true, + preferredProtocol: 'usenet', + usenetDelay: 0, + torrentDelay: 0, + tags: [] +}; + +const protocolOptions = [ + { key: 'preferUsenet', value: 'Prefer Usenet' }, + { key: 'preferTorrent', value: 'Prefer Torrent' }, + { key: 'onlyUsenet', value: 'Only Usenet' }, + { key: 'onlyTorrent', value: 'Only Torrent' } +]; + +function createDelayProfileSelector() { + return createSelector( + (state, { id }) => id, + (state) => state.settings.delayProfiles, + (id, delayProfiles) => { + const { + isFetching, + error, + isSaving, + saveError, + pendingChanges, + items + } = delayProfiles; + + const profile = id ? _.find(items, { id }) : newDelayProfile; + const settings = selectSettings(profile, pendingChanges, saveError); + + return { + id, + isFetching, + error, + isSaving, + saveError, + item: settings.settings, + ...settings + }; + } + ); +} + +function createMapStateToProps() { + return createSelector( + createDelayProfileSelector(), + (delayProfile) => { + const enableUsenet = delayProfile.item.enableUsenet.value; + const enableTorrent = delayProfile.item.enableTorrent.value; + const preferredProtocol = delayProfile.item.preferredProtocol.value; + let protocol = 'preferUsenet'; + + if (preferredProtocol === 'usenet') { + protocol = 'preferUsenet'; + } else { + protocol = 'preferTorrent'; + } + + if (!enableUsenet) { + protocol = 'onlyTorrent'; + } + + if (!enableTorrent) { + protocol = 'onlyUsenet'; + } + + return { + protocol, + protocolOptions, + ...delayProfile + }; + } + ); +} + +const mapDispatchToProps = { + setDelayProfileValue, + saveDelayProfile +}; + +class EditDelayProfileModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + if (!this.props.id) { + Object.keys(newDelayProfile).forEach((name) => { + this.props.setDelayProfileValue({ + name, + value: newDelayProfile[name] + }); + }); + } + } + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setDelayProfileValue({ name, value }); + } + + onProtocolChange = ({ value }) => { + switch (value) { + case 'preferUsenet': + this.props.setDelayProfileValue({ name: 'enableUsenet', value: true }); + this.props.setDelayProfileValue({ name: 'enableTorrent', value: true }); + this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'usenet' }); + break; + case 'preferTorrent': + this.props.setDelayProfileValue({ name: 'enableUsenet', value: true }); + this.props.setDelayProfileValue({ name: 'enableTorrent', value: true }); + this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'torrent' }); + break; + case 'onlyUsenet': + this.props.setDelayProfileValue({ name: 'enableUsenet', value: true }); + this.props.setDelayProfileValue({ name: 'enableTorrent', value: false }); + this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'usenet' }); + break; + case 'onlyTorrent': + this.props.setDelayProfileValue({ name: 'enableUsenet', value: false }); + this.props.setDelayProfileValue({ name: 'enableTorrent', value: true }); + this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'torrent' }); + break; + default: + throw Error(`Unknown protocol option: ${value}`); + } + } + + onSavePress = () => { + this.props.saveDelayProfile({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditDelayProfileModalContentConnector.propTypes = { + id: PropTypes.number, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setDelayProfileValue: PropTypes.func.isRequired, + saveDelayProfile: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditDelayProfileModalContentConnector); diff --git a/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModal.js b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModal.js new file mode 100644 index 000000000..a437af38b --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditMetadataProfileModalContentConnector from './EditMetadataProfileModalContentConnector'; + +function EditMetadataProfileModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditMetadataProfileModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditMetadataProfileModal; diff --git a/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalConnector.js b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalConnector.js new file mode 100644 index 000000000..edc1f1a73 --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalConnector.js @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditMetadataProfileModal from './EditMetadataProfileModal'; + +function mapStateToProps() { + return {}; +} + +const mapDispatchToProps = { + clearPendingChanges +}; + +class EditMetadataProfileModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'settings.metadataProfiles' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditMetadataProfileModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(mapStateToProps, mapDispatchToProps)(EditMetadataProfileModalConnector); diff --git a/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.css b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.css new file mode 100644 index 000000000..74dd1c8b7 --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.css @@ -0,0 +1,3 @@ +.deleteButtonContainer { + margin-right: auto; +} diff --git a/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.js b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.js new file mode 100644 index 000000000..672e2f28c --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.js @@ -0,0 +1,154 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import PrimaryTypeItems from './PrimaryTypeItems'; +import SecondaryTypeItems from './SecondaryTypeItems'; +import ReleaseStatusItems from './ReleaseStatusItems'; +import styles from './EditMetadataProfileModalContent.css'; + +function EditMetadataProfileModalContent(props) { + const { + isFetching, + error, + isSaving, + saveError, + primaryAlbumTypes, + secondaryAlbumTypes, + item, + isInUse, + onInputChange, + onSavePress, + onModalClose, + onDeleteMetadataProfilePress, + ...otherProps + } = props; + + const { + id, + name, + primaryAlbumTypes: itemPrimaryAlbumTypes, + secondaryAlbumTypes: itemSecondaryAlbumTypes, + releaseStatuses: itemReleaseStatuses + } = item; + + return ( + + + {id ? 'Edit Metadata Profile' : 'Add Metadata Profile'} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to add a new metadata profile, please try again.
+ } + + { + !isFetching && !error && +
+ + Name + + + + + + + + + + + + } +
+ + { + id && +
+ +
+ } + + + + + Save + +
+
+ ); +} + +EditMetadataProfileModalContent.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + primaryAlbumTypes: PropTypes.arrayOf(PropTypes.object).isRequired, + secondaryAlbumTypes: PropTypes.arrayOf(PropTypes.object).isRequired, + releaseStatuses: PropTypes.arrayOf(PropTypes.object).isRequired, + item: PropTypes.object.isRequired, + isInUse: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteMetadataProfilePress: PropTypes.func +}; + +export default EditMetadataProfileModalContent; diff --git a/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContentConnector.js new file mode 100644 index 000000000..6fd45d3c9 --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContentConnector.js @@ -0,0 +1,213 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createProfileInUseSelector from 'Store/Selectors/createProfileInUseSelector'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import { fetchMetadataProfileSchema, setMetadataProfileValue, saveMetadataProfile } from 'Store/Actions/settingsActions'; +import EditMetadataProfileModalContent from './EditMetadataProfileModalContent'; + +function createPrimaryAlbumTypesSelector() { + return createSelector( + createProviderSettingsSelector('metadataProfiles'), + (metadataProfile) => { + const primaryAlbumTypes = metadataProfile.item.primaryAlbumTypes; + if (!primaryAlbumTypes || !primaryAlbumTypes.value) { + return []; + } + + return _.reduceRight(primaryAlbumTypes.value, (result, { allowed, albumType }) => { + if (allowed) { + result.push({ + key: albumType.id, + value: albumType.name + }); + } + + return result; + }, []); + } + ); +} + +function createSecondaryAlbumTypesSelector() { + return createSelector( + createProviderSettingsSelector('metadataProfiles'), + (metadataProfile) => { + const secondaryAlbumTypes = metadataProfile.item.secondaryAlbumTypes; + if (!secondaryAlbumTypes || !secondaryAlbumTypes.value) { + return []; + } + + return _.reduceRight(secondaryAlbumTypes.value, (result, { allowed, albumType }) => { + if (allowed) { + result.push({ + key: albumType.id, + value: albumType.name + }); + } + + return result; + }, []); + } + ); +} + +function createReleaseStatusesSelector() { + return createSelector( + createProviderSettingsSelector('metadataProfiles'), + (metadataProfile) => { + const releaseStatuses = metadataProfile.item.releaseStatuses; + if (!releaseStatuses || !releaseStatuses.value) { + return []; + } + + return _.reduceRight(releaseStatuses.value, (result, { allowed, releaseStatus }) => { + if (allowed) { + result.push({ + key: releaseStatus.id, + value: releaseStatus.name + }); + } + + return result; + }, []); + } + ); +} + +function createMapStateToProps() { + return createSelector( + createProviderSettingsSelector('metadataProfiles'), + createPrimaryAlbumTypesSelector(), + createSecondaryAlbumTypesSelector(), + createReleaseStatusesSelector(), + createProfileInUseSelector('metadataProfileId'), + (metadataProfile, primaryAlbumTypes, secondaryAlbumTypes, releaseStatuses, isInUse) => { + return { + primaryAlbumTypes, + secondaryAlbumTypes, + releaseStatuses, + ...metadataProfile, + isInUse + }; + } + ); +} + +const mapDispatchToProps = { + fetchMetadataProfileSchema, + setMetadataProfileValue, + saveMetadataProfile +}; + +class EditMetadataProfileModalContentConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + dragIndex: null, + dropIndex: null + }; + } + + componentDidMount() { + if (!this.props.id && !this.props.isPopulated) { + this.props.fetchMetadataProfileSchema(); + } + } + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setMetadataProfileValue({ name, value }); + } + + onSavePress = () => { + this.props.saveMetadataProfile({ id: this.props.id }); + } + + onMetadataPrimaryTypeItemAllowedChange = (id, allowed) => { + const metadataProfile = _.cloneDeep(this.props.item); + + const item = _.find(metadataProfile.primaryAlbumTypes.value, (i) => i.albumType.id === id); + item.allowed = allowed; + + this.props.setMetadataProfileValue({ + name: 'primaryAlbumTypes', + value: metadataProfile.primaryAlbumTypes.value + }); + } + + onMetadataSecondaryTypeItemAllowedChange = (id, allowed) => { + const metadataProfile = _.cloneDeep(this.props.item); + + const item = _.find(metadataProfile.secondaryAlbumTypes.value, (i) => i.albumType.id === id); + item.allowed = allowed; + + this.props.setMetadataProfileValue({ + name: 'secondaryAlbumTypes', + value: metadataProfile.secondaryAlbumTypes.value + }); + } + + onMetadataReleaseStatusItemAllowedChange = (id, allowed) => { + const metadataProfile = _.cloneDeep(this.props.item); + + const item = _.find(metadataProfile.releaseStatuses.value, (i) => i.releaseStatus.id === id); + item.allowed = allowed; + + this.props.setMetadataProfileValue({ + name: 'releaseStatuses', + value: metadataProfile.releaseStatuses.value + }); + } + + // + // Render + + render() { + if (_.isEmpty(this.props.item.primaryAlbumTypes) && !this.props.isFetching) { + return null; + } + + return ( + + ); + } +} + +EditMetadataProfileModalContentConnector.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setMetadataProfileValue: PropTypes.func.isRequired, + fetchMetadataProfileSchema: PropTypes.func.isRequired, + saveMetadataProfile: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditMetadataProfileModalContentConnector); diff --git a/frontend/src/Settings/Profiles/Metadata/MetadataProfile.css b/frontend/src/Settings/Profiles/Metadata/MetadataProfile.css new file mode 100644 index 000000000..880788343 --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/MetadataProfile.css @@ -0,0 +1,31 @@ +.metadataProfile { + composes: card from '~Components/Card.css'; + + width: 300px; +} + +.nameContainer { + display: flex; + justify-content: space-between; +} + +.name { + @add-mixin truncate; + + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.cloneButton { + composes: button from '~Components/Link/IconButton.css'; + + height: 36px; +} + +.albumTypes { + display: flex; + flex-wrap: wrap; + margin-top: 5px; + pointer-events: all; +} diff --git a/frontend/src/Settings/Profiles/Metadata/MetadataProfile.js b/frontend/src/Settings/Profiles/Metadata/MetadataProfile.js new file mode 100644 index 000000000..5943de616 --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/MetadataProfile.js @@ -0,0 +1,164 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditMetadataProfileModalConnector from './EditMetadataProfileModalConnector'; +import styles from './MetadataProfile.css'; + +class MetadataProfile extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditMetadataProfileModalOpen: false, + isDeleteMetadataProfileModalOpen: false + }; + } + + // + // Listeners + + onEditMetadataProfilePress = () => { + this.setState({ isEditMetadataProfileModalOpen: true }); + } + + onEditMetadataProfileModalClose = () => { + this.setState({ isEditMetadataProfileModalOpen: false }); + } + + onDeleteMetadataProfilePress = () => { + this.setState({ + isEditMetadataProfileModalOpen: false, + isDeleteMetadataProfileModalOpen: true + }); + } + + onDeleteMetadataProfileModalClose = () => { + this.setState({ isDeleteMetadataProfileModalOpen: false }); + } + + onConfirmDeleteMetadataProfile = () => { + this.props.onConfirmDeleteMetadataProfile(this.props.id); + } + + onCloneMetadataProfilePress = () => { + const { + id, + onCloneMetadataProfilePress + } = this.props; + + onCloneMetadataProfilePress(id); + } + + // + // Render + + render() { + const { + id, + name, + primaryAlbumTypes, + secondaryAlbumTypes, + isDeleting + } = this.props; + + return ( + +
+
+ {name} +
+ + +
+ +
+ { + primaryAlbumTypes.map((item) => { + if (!item.allowed) { + return null; + } + + return ( + + ); + }) + } +
+ +
+ { + secondaryAlbumTypes.map((item) => { + if (!item.allowed) { + return null; + } + + return ( + + ); + }) + } +
+ + + + +
+ ); + } +} + +MetadataProfile.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + primaryAlbumTypes: PropTypes.arrayOf(PropTypes.object).isRequired, + secondaryAlbumTypes: PropTypes.arrayOf(PropTypes.object).isRequired, + isDeleting: PropTypes.bool.isRequired, + onConfirmDeleteMetadataProfile: PropTypes.func.isRequired, + onCloneMetadataProfilePress: PropTypes.func.isRequired + +}; + +export default MetadataProfile; diff --git a/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.css b/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.css new file mode 100644 index 000000000..87ae2f44b --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.css @@ -0,0 +1,21 @@ +.metadataProfiles { + display: flex; + flex-wrap: wrap; +} + +.addMetadataProfile { + composes: metadataProfile from '~./MetadataProfile.css'; + + background-color: $cardAlternateBackgroundColor; + color: $gray; + text-align: center; + font-size: 45px; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.js b/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.js new file mode 100644 index 000000000..feccfb77f --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.js @@ -0,0 +1,107 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import sortByName from 'Utilities/Array/sortByName'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Card from 'Components/Card'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import MetadataProfile from './MetadataProfile'; +import EditMetadataProfileModalConnector from './EditMetadataProfileModalConnector'; +import styles from './MetadataProfiles.css'; + +class MetadataProfiles extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isMetadataProfileModalOpen: false + }; + } + + // + // Listeners + + onCloneMetadataProfilePress = (id) => { + this.props.onCloneMetadataProfilePress(id); + this.setState({ isMetadataProfileModalOpen: true }); + } + + onEditMetadataProfilePress = () => { + this.setState({ isMetadataProfileModalOpen: true }); + } + + onModalClose = () => { + this.setState({ isMetadataProfileModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + isDeleting, + onConfirmDeleteMetadataProfile, + ...otherProps + } = this.props; + + return ( +
+ +
+ { + items.sort(sortByName).map((item) => { + return ( + + ); + }) + } + + +
+ +
+
+
+ + +
+
+ ); + } +} + +MetadataProfiles.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + isDeleting: PropTypes.bool.isRequired, + onConfirmDeleteMetadataProfile: PropTypes.func.isRequired, + onCloneMetadataProfilePress: PropTypes.func.isRequired +}; + +export default MetadataProfiles; diff --git a/frontend/src/Settings/Profiles/Metadata/MetadataProfilesConnector.js b/frontend/src/Settings/Profiles/Metadata/MetadataProfilesConnector.js new file mode 100644 index 000000000..aa655cd85 --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/MetadataProfilesConnector.js @@ -0,0 +1,67 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchMetadataProfiles, deleteMetadataProfile, cloneMetadataProfile } from 'Store/Actions/settingsActions'; +import MetadataProfiles from './MetadataProfiles'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + (state) => state.settings.metadataProfiles, + (advancedSettings, metadataProfiles) => { + return { + advancedSettings, + ...metadataProfiles + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchMetadataProfiles: fetchMetadataProfiles, + dispatchDeleteMetadataProfile: deleteMetadataProfile, + dispatchCloneMetadataProfile: cloneMetadataProfile +}; + +class MetadataProfilesConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchMetadataProfiles(); + } + + // + // Listeners + + onConfirmDeleteMetadataProfile = (id) => { + this.props.dispatchDeleteMetadataProfile({ id }); + } + + onCloneMetadataProfilePress = (id) => { + this.props.dispatchCloneMetadataProfile({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +MetadataProfilesConnector.propTypes = { + dispatchFetchMetadataProfiles: PropTypes.func.isRequired, + dispatchDeleteMetadataProfile: PropTypes.func.isRequired, + dispatchCloneMetadataProfile: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(MetadataProfilesConnector); diff --git a/frontend/src/Settings/Profiles/Metadata/PrimaryTypeItem.js b/frontend/src/Settings/Profiles/Metadata/PrimaryTypeItem.js new file mode 100644 index 000000000..3d5d42a9b --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/PrimaryTypeItem.js @@ -0,0 +1,60 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import CheckInput from 'Components/Form/CheckInput'; +import styles from './TypeItem.css'; + +class PrimaryTypeItem extends Component { + + // + // Listeners + + onAllowedChange = ({ value }) => { + const { + albumTypeId, + onMetadataPrimaryTypeItemAllowedChange + } = this.props; + + onMetadataPrimaryTypeItemAllowedChange(albumTypeId, value); + } + + // + // Render + + render() { + const { + name, + allowed + } = this.props; + + return ( +
+ +
+ ); + } +} + +PrimaryTypeItem.propTypes = { + albumTypeId: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + allowed: PropTypes.bool.isRequired, + sortIndex: PropTypes.number.isRequired, + onMetadataPrimaryTypeItemAllowedChange: PropTypes.func +}; + +export default PrimaryTypeItem; diff --git a/frontend/src/Settings/Profiles/Metadata/PrimaryTypeItems.js b/frontend/src/Settings/Profiles/Metadata/PrimaryTypeItems.js new file mode 100644 index 000000000..487adbbd6 --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/PrimaryTypeItems.js @@ -0,0 +1,87 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputHelpText from 'Components/Form/FormInputHelpText'; +import PrimaryTypeItem from './PrimaryTypeItem'; +import styles from './TypeItems.css'; + +class PrimaryTypeItems extends Component { + + // + // Render + + render() { + const { + metadataProfileItems, + errors, + warnings, + ...otherProps + } = this.props; + + return ( + + Primary Types +
+ + { + errors.map((error, index) => { + return ( + + ); + }) + } + + { + warnings.map((warning, index) => { + return ( + + ); + }) + } + +
+ { + metadataProfileItems.map(({ allowed, albumType }, index) => { + return ( + + ); + }).reverse() + } +
+
+
+ ); + } +} + +PrimaryTypeItems.propTypes = { + metadataProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired, + errors: PropTypes.arrayOf(PropTypes.object), + warnings: PropTypes.arrayOf(PropTypes.object), + formLabel: PropTypes.string +}; + +PrimaryTypeItems.defaultProps = { + errors: [], + warnings: [] +}; + +export default PrimaryTypeItems; diff --git a/frontend/src/Settings/Profiles/Metadata/ReleaseStatusItem.js b/frontend/src/Settings/Profiles/Metadata/ReleaseStatusItem.js new file mode 100644 index 000000000..71fe7f76c --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/ReleaseStatusItem.js @@ -0,0 +1,60 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import CheckInput from 'Components/Form/CheckInput'; +import styles from './TypeItem.css'; + +class ReleaseStatusItem extends Component { + + // + // Listeners + + onAllowedChange = ({ value }) => { + const { + albumTypeId, + onMetadataReleaseStatusItemAllowedChange + } = this.props; + + onMetadataReleaseStatusItemAllowedChange(albumTypeId, value); + } + + // + // Render + + render() { + const { + name, + allowed + } = this.props; + + return ( +
+ +
+ ); + } +} + +ReleaseStatusItem.propTypes = { + albumTypeId: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + allowed: PropTypes.bool.isRequired, + sortIndex: PropTypes.number.isRequired, + onMetadataReleaseStatusItemAllowedChange: PropTypes.func +}; + +export default ReleaseStatusItem; diff --git a/frontend/src/Settings/Profiles/Metadata/ReleaseStatusItems.js b/frontend/src/Settings/Profiles/Metadata/ReleaseStatusItems.js new file mode 100644 index 000000000..31a24dff3 --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/ReleaseStatusItems.js @@ -0,0 +1,87 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputHelpText from 'Components/Form/FormInputHelpText'; +import ReleaseStatusItem from './ReleaseStatusItem'; +import styles from './TypeItems.css'; + +class ReleaseStatusItems extends Component { + + // + // Render + + render() { + const { + metadataProfileItems, + errors, + warnings, + ...otherProps + } = this.props; + + return ( + + Release Statuses +
+ + { + errors.map((error, index) => { + return ( + + ); + }) + } + + { + warnings.map((warning, index) => { + return ( + + ); + }) + } + +
+ { + metadataProfileItems.map(({ allowed, releaseStatus }, index) => { + return ( + + ); + }) + } +
+
+
+ ); + } +} + +ReleaseStatusItems.propTypes = { + metadataProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired, + errors: PropTypes.arrayOf(PropTypes.object), + warnings: PropTypes.arrayOf(PropTypes.object), + formLabel: PropTypes.string +}; + +ReleaseStatusItems.defaultProps = { + errors: [], + warnings: [] +}; + +export default ReleaseStatusItems; diff --git a/frontend/src/Settings/Profiles/Metadata/SecondaryTypeItem.js b/frontend/src/Settings/Profiles/Metadata/SecondaryTypeItem.js new file mode 100644 index 000000000..79995a920 --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/SecondaryTypeItem.js @@ -0,0 +1,60 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import CheckInput from 'Components/Form/CheckInput'; +import styles from './TypeItem.css'; + +class SecondaryTypeItem extends Component { + + // + // Listeners + + onAllowedChange = ({ value }) => { + const { + albumTypeId, + onMetadataSecondaryTypeItemAllowedChange + } = this.props; + + onMetadataSecondaryTypeItemAllowedChange(albumTypeId, value); + } + + // + // Render + + render() { + const { + name, + allowed + } = this.props; + + return ( +
+ +
+ ); + } +} + +SecondaryTypeItem.propTypes = { + albumTypeId: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + allowed: PropTypes.bool.isRequired, + sortIndex: PropTypes.number.isRequired, + onMetadataSecondaryTypeItemAllowedChange: PropTypes.func +}; + +export default SecondaryTypeItem; diff --git a/frontend/src/Settings/Profiles/Metadata/SecondaryTypeItems.js b/frontend/src/Settings/Profiles/Metadata/SecondaryTypeItems.js new file mode 100644 index 000000000..3f46d710a --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/SecondaryTypeItems.js @@ -0,0 +1,87 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputHelpText from 'Components/Form/FormInputHelpText'; +import SecondaryTypeItem from './SecondaryTypeItem'; +import styles from './TypeItems.css'; + +class SecondaryTypeItems extends Component { + + // + // Render + + render() { + const { + metadataProfileItems, + errors, + warnings, + ...otherProps + } = this.props; + + return ( + + Secondary Types +
+ + { + errors.map((error, index) => { + return ( + + ); + }) + } + + { + warnings.map((warning, index) => { + return ( + + ); + }) + } + +
+ { + metadataProfileItems.map(({ allowed, albumType }, index) => { + return ( + + ); + }) + } +
+
+
+ ); + } +} + +SecondaryTypeItems.propTypes = { + metadataProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired, + errors: PropTypes.arrayOf(PropTypes.object), + warnings: PropTypes.arrayOf(PropTypes.object), + formLabel: PropTypes.string +}; + +SecondaryTypeItems.defaultProps = { + errors: [], + warnings: [] +}; + +export default SecondaryTypeItems; diff --git a/frontend/src/Settings/Profiles/Metadata/TypeItem.css b/frontend/src/Settings/Profiles/Metadata/TypeItem.css new file mode 100644 index 000000000..908f3bde6 --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/TypeItem.css @@ -0,0 +1,25 @@ +.metadataProfileItem { + display: flex; + align-items: stretch; + width: 100%; +} + +.checkContainer { + position: relative; + margin-right: 4px; + margin-bottom: 7px; + margin-left: 8px; +} + +.albumTypeName { + display: flex; + flex-grow: 1; + margin-bottom: 0; + margin-left: 2px; + font-weight: normal; + line-height: 36px; +} + +.isDragging { + opacity: 0.25; +} diff --git a/frontend/src/Settings/Profiles/Metadata/TypeItems.css b/frontend/src/Settings/Profiles/Metadata/TypeItems.css new file mode 100644 index 000000000..3bce22799 --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/TypeItems.css @@ -0,0 +1,6 @@ +.albumTypes { + margin-top: 10px; + /* TODO: This should consider the number of types in the list */ + min-height: 200px; + user-select: none; +} diff --git a/frontend/src/Settings/Profiles/Profiles.js b/frontend/src/Settings/Profiles/Profiles.js new file mode 100644 index 000000000..63c90fca3 --- /dev/null +++ b/frontend/src/Settings/Profiles/Profiles.js @@ -0,0 +1,40 @@ +import React, { Component } from 'react'; +import { DndProvider } from 'react-dnd'; +import HTML5Backend from 'react-dnd-html5-backend'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import QualityProfilesConnector from './Quality/QualityProfilesConnector'; +import MetadataProfilesConnector from './Metadata/MetadataProfilesConnector'; +import DelayProfilesConnector from './Delay/DelayProfilesConnector'; +import ReleaseProfilesConnector from './Release/ReleaseProfilesConnector'; + +// Only a single DragDrop Context can exist so it's done here to allow editing +// quality profiles and reordering delay profiles to work. + +class Profiles extends Component { + + // + // Render + + render() { + return ( + + + + + + + + + + + + + ); + } +} + +export default Profiles; diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.js new file mode 100644 index 000000000..9ecbd1ca8 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.js @@ -0,0 +1,61 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditQualityProfileModalContentConnector from './EditQualityProfileModalContentConnector'; + +class EditQualityProfileModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + height: 'auto' + }; + } + + // + // Listeners + + onContentHeightChange = (height) => { + if (this.state.height === 'auto' || height > this.state.height) { + this.setState({ height }); + } + } + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +EditQualityProfileModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditQualityProfileModal; diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js new file mode 100644 index 000000000..942949cac --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditQualityProfileModal from './EditQualityProfileModal'; + +function mapStateToProps() { + return {}; +} + +const mapDispatchToProps = { + clearPendingChanges +}; + +class EditQualityProfileModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'settings.qualityProfiles' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditQualityProfileModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(mapStateToProps, mapDispatchToProps)(EditQualityProfileModalConnector); diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css new file mode 100644 index 000000000..2f6589933 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css @@ -0,0 +1,18 @@ +.formGroupsContainer { + display: flex; + flex-wrap: wrap; +} + +.formGroupWrapper { + flex: 0 0 calc($formGroupSmallWidth - 100px); +} + +.deleteButtonContainer { + margin-right: auto; +} + +@media only screen and (max-width: $breakpointLarge) { + .formGroupsContainer { + display: block; + } +} diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js new file mode 100644 index 000000000..b967b27e9 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js @@ -0,0 +1,268 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import dimensions from 'Styles/Variables/dimensions'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Measure from 'Components/Measure'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import QualityProfileItems from './QualityProfileItems'; +import styles from './EditQualityProfileModalContent.css'; + +const MODAL_BODY_PADDING = parseInt(dimensions.modalBodyPadding); + +class EditQualityProfileModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + headerHeight: 0, + bodyHeight: 0, + footerHeight: 0 + }; + } + + componentDidUpdate(prevProps, prevState) { + const { + headerHeight, + bodyHeight, + footerHeight + } = this.state; + + if ( + headerHeight > 0 && + bodyHeight > 0 && + footerHeight > 0 && + ( + headerHeight !== prevState.headerHeight || + bodyHeight !== prevState.bodyHeight || + footerHeight !== prevState.footerHeight + ) + ) { + const padding = MODAL_BODY_PADDING * 2; + + this.props.onContentHeightChange( + headerHeight + bodyHeight + footerHeight + padding + ); + } + } + + // + // Listeners + + onHeaderMeasure = ({ height }) => { + if (height > this.state.headerHeight) { + this.setState({ headerHeight: height }); + } + } + + onBodyMeasure = ({ height }) => { + + if (height > this.state.bodyHeight) { + this.setState({ bodyHeight: height }); + } + } + + onFooterMeasure = ({ height }) => { + if (height > this.state.footerHeight) { + this.setState({ footerHeight: height }); + } + } + + // + // Render + + render() { + const { + editGroups, + isFetching, + error, + isSaving, + saveError, + qualities, + item, + isInUse, + onInputChange, + onCutoffChange, + onSavePress, + onModalClose, + onDeleteQualityProfilePress, + ...otherProps + } = this.props; + + const { + id, + name, + upgradeAllowed, + cutoff, + items + } = item; + + return ( + + + + {id ? 'Edit Quality Profile' : 'Add Quality Profile'} + + + + + +
+ { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to add a new quality profile, please try again.
+ } + + { + !isFetching && !error && +
+
+
+ + + Name + + + + + + + + Upgrades Allowed + + + + + + { + upgradeAllowed.value && + + + Upgrade Until + + + + + } +
+ +
+ +
+
+
+ + } +
+
+
+ + + + { + id && +
+ +
+ } + + + + + Save + +
+
+
+ ); + } +} + +EditQualityProfileModalContent.propTypes = { + editGroups: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + qualities: PropTypes.arrayOf(PropTypes.object).isRequired, + item: PropTypes.object.isRequired, + isInUse: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onCutoffChange: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onContentHeightChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteQualityProfilePress: PropTypes.func +}; + +export default EditQualityProfileModalContent; diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js new file mode 100644 index 000000000..2decf2198 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js @@ -0,0 +1,442 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createProfileInUseSelector from 'Store/Selectors/createProfileInUseSelector'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import { fetchQualityProfileSchema, setQualityProfileValue, saveQualityProfile } from 'Store/Actions/settingsActions'; +import EditQualityProfileModalContent from './EditQualityProfileModalContent'; + +function getQualityItemGroupId(qualityProfile) { + // Get items with an `id` and filter out null/undefined values + const ids = _.filter(_.map(qualityProfile.items.value, 'id'), (i) => i != null); + + return Math.max(1000, ...ids) + 1; +} + +function parseIndex(index) { + const split = index.split('.'); + + if (split.length === 1) { + return [ + null, + parseInt(split[0]) - 1 + ]; + } + + return [ + parseInt(split[0]) - 1, + parseInt(split[1]) - 1 + ]; +} + +function createQualitiesSelector() { + return createSelector( + createProviderSettingsSelector('qualityProfiles'), + (qualityProfile) => { + const items = qualityProfile.item.items; + if (!items || !items.value) { + return []; + } + + return _.reduceRight(items.value, (result, { allowed, id, name, quality }) => { + if (allowed) { + if (id) { + result.push({ + key: id, + value: name + }); + } else { + result.push({ + key: quality.id, + value: quality.name + }); + } + } + + return result; + }, []); + } + ); +} + +function createMapStateToProps() { + return createSelector( + createProviderSettingsSelector('qualityProfiles'), + createQualitiesSelector(), + createProfileInUseSelector('qualityProfileId'), + (qualityProfile, qualities, isInUse) => { + return { + qualities, + ...qualityProfile, + isInUse + }; + } + ); +} + +const mapDispatchToProps = { + fetchQualityProfileSchema, + setQualityProfileValue, + saveQualityProfile +}; + +class EditQualityProfileModalContentConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + dragQualityIndex: null, + dropQualityIndex: null, + dropPosition: null, + editGroups: false + }; + } + + componentDidMount() { + if (!this.props.id && !this.props.isPopulated) { + this.props.fetchQualityProfileSchema(); + } + } + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Control + + ensureCutoff = (qualityProfile) => { + const cutoff = qualityProfile.cutoff.value; + + const cutoffItem = _.find(qualityProfile.items.value, (i) => { + if (!cutoff) { + return false; + } + + return i.id === cutoff || (i.quality && i.quality.id === cutoff); + }); + + // If the cutoff isn't allowed anymore or there isn't a cutoff set one + if (!cutoff || !cutoffItem || !cutoffItem.allowed) { + const firstAllowed = _.find(qualityProfile.items.value, { allowed: true }); + let cutoffId = null; + + if (firstAllowed) { + cutoffId = firstAllowed.quality ? firstAllowed.quality.id : firstAllowed.id; + } + + this.props.setQualityProfileValue({ name: 'cutoff', value: cutoffId }); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setQualityProfileValue({ name, value }); + } + + onCutoffChange = ({ name, value }) => { + const id = parseInt(value); + const item = _.find(this.props.item.items.value, (i) => { + if (i.quality) { + return i.quality.id === id; + } + + return i.id === id; + }); + + const cutoffId = item.quality ? item.quality.id : item.id; + + this.props.setQualityProfileValue({ name, value: cutoffId }); + } + + onSavePress = () => { + this.props.saveQualityProfile({ id: this.props.id }); + } + + onQualityProfileItemAllowedChange = (id, allowed) => { + const qualityProfile = _.cloneDeep(this.props.item); + const items = qualityProfile.items.value; + const item = _.find(qualityProfile.items.value, (i) => i.quality && i.quality.id === id); + + item.allowed = allowed; + + this.props.setQualityProfileValue({ + name: 'items', + value: items + }); + + this.ensureCutoff(qualityProfile); + } + + onItemGroupAllowedChange = (id, allowed) => { + const qualityProfile = _.cloneDeep(this.props.item); + const items = qualityProfile.items.value; + const item = _.find(qualityProfile.items.value, (i) => i.id === id); + + item.allowed = allowed; + + // Update each item in the group (for consistency only) + item.items.forEach((i) => { + i.allowed = allowed; + }); + + this.props.setQualityProfileValue({ + name: 'items', + value: items + }); + + this.ensureCutoff(qualityProfile); + } + + onItemGroupNameChange = (id, name) => { + const qualityProfile = _.cloneDeep(this.props.item); + const items = qualityProfile.items.value; + const group = _.find(items, (i) => i.id === id); + + group.name = name; + + this.props.setQualityProfileValue({ + name: 'items', + value: items + }); + } + + onCreateGroupPress = (id) => { + const qualityProfile = _.cloneDeep(this.props.item); + const items = qualityProfile.items.value; + const item = _.find(items, (i) => i.quality && i.quality.id === id); + const index = items.indexOf(item); + const groupId = getQualityItemGroupId(qualityProfile); + + const group = { + id: groupId, + name: item.quality.name, + allowed: item.allowed, + items: [ + item + ] + }; + + // Add the group in the same location the quality item was in. + items.splice(index, 1, group); + + this.props.setQualityProfileValue({ + name: 'items', + value: items + }); + + this.ensureCutoff(qualityProfile); + } + + onDeleteGroupPress = (id) => { + const qualityProfile = _.cloneDeep(this.props.item); + const items = qualityProfile.items.value; + const group = _.find(items, (i) => i.id === id); + const index = items.indexOf(group); + + // Add the items in the same location the group was in + items.splice(index, 1, ...group.items); + + this.props.setQualityProfileValue({ + name: 'items', + value: items + }); + + this.ensureCutoff(qualityProfile); + } + + onQualityProfileItemDragMove = (options) => { + const { + dragQualityIndex, + dropQualityIndex, + dropPosition + } = options; + + const [dragGroupIndex, dragItemIndex] = parseIndex(dragQualityIndex); + const [dropGroupIndex, dropItemIndex] = parseIndex(dropQualityIndex); + + if ( + (dropPosition === 'below' && dropItemIndex - 1 === dragItemIndex) || + (dropPosition === 'above' && dropItemIndex + 1 === dragItemIndex) + ) { + if ( + this.state.dragQualityIndex != null && + this.state.dropQualityIndex != null && + this.state.dropPosition != null + ) { + this.setState({ + dragQualityIndex: null, + dropQualityIndex: null, + dropPosition: null + }); + } + + return; + } + + let adjustedDropQualityIndex = dropQualityIndex; + + // Correct dragging out of a group to the position above + if ( + dropPosition === 'above' && + dragGroupIndex !== dropGroupIndex && + dropGroupIndex != null + ) { + // Add 1 to the group index and 2 to the item index so it's inserted above in the correct group + adjustedDropQualityIndex = `${dropGroupIndex + 1}.${dropItemIndex + 2}`; + } + + // Correct inserting above outside a group + if ( + dropPosition === 'above' && + dragGroupIndex !== dropGroupIndex && + dropGroupIndex == null + ) { + // Add 2 to the item index so it's entered in the correct place + adjustedDropQualityIndex = `${dropItemIndex + 2}`; + } + + // Correct inserting below a quality within the same group (when moving a lower item) + if ( + dropPosition === 'below' && + dragGroupIndex === dropGroupIndex && + dropGroupIndex != null && + dragItemIndex < dropItemIndex + ) { + // Add 1 to the group index leave the item index + adjustedDropQualityIndex = `${dropGroupIndex + 1}.${dropItemIndex}`; + } + + // Correct inserting below a quality outside a group (when moving a lower item) + if ( + dropPosition === 'below' && + dragGroupIndex === dropGroupIndex && + dropGroupIndex == null && + dragItemIndex < dropItemIndex + ) { + // Leave the item index so it's inserted below the item + adjustedDropQualityIndex = `${dropItemIndex}`; + } + + if ( + dragQualityIndex !== this.state.dragQualityIndex || + adjustedDropQualityIndex !== this.state.dropQualityIndex || + dropPosition !== this.state.dropPosition + ) { + this.setState({ + dragQualityIndex, + dropQualityIndex: adjustedDropQualityIndex, + dropPosition + }); + } + } + + onQualityProfileItemDragEnd = (didDrop) => { + const { + dragQualityIndex, + dropQualityIndex + } = this.state; + + if (didDrop && dropQualityIndex != null) { + const qualityProfile = _.cloneDeep(this.props.item); + const items = qualityProfile.items.value; + const [dragGroupIndex, dragItemIndex] = parseIndex(dragQualityIndex); + const [dropGroupIndex, dropItemIndex] = parseIndex(dropQualityIndex); + + let item = null; + let dropGroup = null; + + // Get the group before moving anything so we know the correct place to drop it. + if (dropGroupIndex != null) { + dropGroup = items[dropGroupIndex]; + } + + if (dragGroupIndex == null) { + item = items.splice(dragItemIndex, 1)[0]; + } else { + const group = items[dragGroupIndex]; + item = group.items.splice(dragItemIndex, 1)[0]; + + // If the group is now empty, destroy it. + if (!group.items.length) { + items.splice(dragGroupIndex, 1); + } + } + + if (dropGroupIndex == null) { + items.splice(dropItemIndex, 0, item); + } else { + dropGroup.items.splice(dropItemIndex, 0, item); + } + + this.props.setQualityProfileValue({ + name: 'items', + value: items + }); + + this.ensureCutoff(qualityProfile); + } + + this.setState({ + dragQualityIndex: null, + dropQualityIndex: null, + dropPosition: null + }); + } + + onToggleEditGroupsMode = () => { + this.setState({ editGroups: !this.state.editGroups }); + } + + // + // Render + + render() { + if (_.isEmpty(this.props.item.items) && !this.props.isFetching) { + return null; + } + + return ( + + ); + } +} + +EditQualityProfileModalContentConnector.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setQualityProfileValue: PropTypes.func.isRequired, + fetchQualityProfileSchema: PropTypes.func.isRequired, + saveQualityProfile: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditQualityProfileModalContentConnector); diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfile.css b/frontend/src/Settings/Profiles/Quality/QualityProfile.css new file mode 100644 index 000000000..2513577a1 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfile.css @@ -0,0 +1,38 @@ +.qualityProfile { + composes: card from '~Components/Card.css'; + + width: 300px; +} + +.nameContainer { + display: flex; + justify-content: space-between; +} + +.name { + @add-mixin truncate; + + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.cloneButton { + composes: button from '~Components/Link/IconButton.css'; + + height: 36px; +} + +.qualities { + display: flex; + flex-wrap: wrap; + margin-top: 5px; + pointer-events: all; +} + +.tooltipLabel { + composes: label from '~Components/Label.css'; + + margin: 0; + border: none; +} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfile.js b/frontend/src/Settings/Profiles/Quality/QualityProfile.js new file mode 100644 index 000000000..f4a4ca414 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfile.js @@ -0,0 +1,186 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import EditQualityProfileModalConnector from './EditQualityProfileModalConnector'; +import styles from './QualityProfile.css'; + +class QualityProfile extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditQualityProfileModalOpen: false, + isDeleteQualityProfileModalOpen: false + }; + } + + // + // Listeners + + onEditQualityProfilePress = () => { + this.setState({ isEditQualityProfileModalOpen: true }); + } + + onEditQualityProfileModalClose = () => { + this.setState({ isEditQualityProfileModalOpen: false }); + } + + onDeleteQualityProfilePress = () => { + this.setState({ + isEditQualityProfileModalOpen: false, + isDeleteQualityProfileModalOpen: true + }); + } + + onDeleteQualityProfileModalClose = () => { + this.setState({ isDeleteQualityProfileModalOpen: false }); + } + + onConfirmDeleteQualityProfile = () => { + this.props.onConfirmDeleteQualityProfile(this.props.id); + } + + onCloneQualityProfilePress = () => { + const { + id, + onCloneQualityProfilePress + } = this.props; + + onCloneQualityProfilePress(id); + } + + // + // Render + + render() { + const { + id, + name, + upgradeAllowed, + cutoff, + items, + isDeleting + } = this.props; + + return ( + +
+
+ {name} +
+ + +
+ +
+ { + items.map((item) => { + if (!item.allowed) { + return null; + } + + if (item.quality) { + const isCutoff = upgradeAllowed && item.quality.id === cutoff; + + return ( + + ); + } + + const isCutoff = upgradeAllowed && item.id === cutoff; + + return ( + + {item.name} + + } + tooltip={ +
+ { + item.items.map((groupItem) => { + return ( + + ); + }) + } +
+ } + kind={kinds.INVERSE} + position={tooltipPositions.TOP} + /> + ); + }) + } +
+ + + + +
+ ); + } +} + +QualityProfile.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + upgradeAllowed: PropTypes.bool.isRequired, + cutoff: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + isDeleting: PropTypes.bool.isRequired, + onConfirmDeleteQualityProfile: PropTypes.func.isRequired, + onCloneQualityProfilePress: PropTypes.func.isRequired +}; + +export default QualityProfile; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css new file mode 100644 index 000000000..4e3f4aae5 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css @@ -0,0 +1,85 @@ +.qualityProfileItem { + display: flex; + align-items: stretch; + width: 100%; + border: 1px solid #aaa; + border-radius: 4px; + background: #fafafa; + + &.isInGroup { + border-style: dashed; + } +} + +.checkInputContainer { + position: relative; + margin-right: 4px; + margin-bottom: 5px; + margin-left: 8px; +} + +.checkInput { + composes: input from '~Components/Form/CheckInput.css'; + + margin-top: 5px; +} + +.qualityNameContainer { + display: flex; + flex-grow: 1; + margin-bottom: 0; + margin-left: 2px; + font-weight: normal; + line-height: $qualityProfileItemHeight; + cursor: pointer; +} + +.qualityName { + &.isInGroup { + margin-left: 14px; + } + + &.notAllowed { + color: #c6c6c6; + } +} + +.createGroupButton { + composes: buton from '~Components/Link/IconButton.css'; + + display: flex; + justify-content: center; + flex-shrink: 0; + margin-right: 5px; + margin-left: 8px; + width: 20px; +} + +.dragHandle { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + width: $dragHandleWidth; + text-align: center; + cursor: grab; +} + +.dragIcon { + top: 0; +} + +.isDragging { + opacity: 0.25; +} + +.isPreview { + .qualityName { + margin-left: 14px; + + &.isInGroup { + margin-left: 28px; + } + } +} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItem.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.js new file mode 100644 index 000000000..8161e7061 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.js @@ -0,0 +1,131 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import CheckInput from 'Components/Form/CheckInput'; +import styles from './QualityProfileItem.css'; + +class QualityProfileItem extends Component { + + // + // Listeners + + onAllowedChange = ({ value }) => { + const { + qualityId, + onQualityProfileItemAllowedChange + } = this.props; + + onQualityProfileItemAllowedChange(qualityId, value); + } + + onCreateGroupPress = () => { + const { + qualityId, + onCreateGroupPress + } = this.props; + + onCreateGroupPress(qualityId); + } + + // + // Render + + render() { + const { + editGroups, + isPreview, + groupId, + name, + allowed, + isDragging, + isOverCurrent, + connectDragSource + } = this.props; + + return ( +
+ + + { + connectDragSource( +
+ +
+ ) + } +
+ ); + } +} + +QualityProfileItem.propTypes = { + editGroups: PropTypes.bool, + isPreview: PropTypes.bool, + groupId: PropTypes.number, + qualityId: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + allowed: PropTypes.bool.isRequired, + isDragging: PropTypes.bool.isRequired, + isOverCurrent: PropTypes.bool.isRequired, + isInGroup: PropTypes.bool, + connectDragSource: PropTypes.func, + onCreateGroupPress: PropTypes.func, + onQualityProfileItemAllowedChange: PropTypes.func +}; + +QualityProfileItem.defaultProps = { + isPreview: false, + isOverCurrent: false, + // The drag preview will not connect the drag handle. + connectDragSource: (node) => node +}; + +export default QualityProfileItem; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css new file mode 100644 index 000000000..b927d9bce --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css @@ -0,0 +1,4 @@ +.dragPreview { + width: 380px; + opacity: 0.75; +} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.js new file mode 100644 index 000000000..ac1b3ab80 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.js @@ -0,0 +1,94 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { DragLayer } from 'react-dnd'; +import dimensions from 'Styles/Variables/dimensions.js'; +import { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes'; +import DragPreviewLayer from 'Components/DragPreviewLayer'; +import QualityProfileItem from './QualityProfileItem'; +import styles from './QualityProfileItemDragPreview.css'; + +const formGroupExtraSmallWidth = parseInt(dimensions.formGroupExtraSmallWidth); +const formLabelSmallWidth = parseInt(dimensions.formLabelSmallWidth); +const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth); +const dragHandleWidth = parseInt(dimensions.dragHandleWidth); + +function collectDragLayer(monitor) { + return { + item: monitor.getItem(), + itemType: monitor.getItemType(), + currentOffset: monitor.getSourceClientOffset() + }; +} + +class QualityProfileItemDragPreview extends Component { + + // + // Render + + render() { + const { + item, + itemType, + currentOffset + } = this.props; + + if (!currentOffset || itemType !== QUALITY_PROFILE_ITEM) { + return null; + } + + // The offset is shifted because the drag handle is on the right edge of the + // list item and the preview is wider than the drag handle. + + const { x, y } = currentOffset; + const handleOffset = formGroupExtraSmallWidth - formLabelSmallWidth - formLabelRightMarginWidth - dragHandleWidth; + const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`; + + const style = { + position: 'absolute', + WebkitTransform: transform, + msTransform: transform, + transform + }; + + const { + editGroups, + groupId, + qualityId, + name, + allowed + } = item; + + // TODO: Show a different preview for groups + + return ( + +
+ +
+
+ ); + } +} + +QualityProfileItemDragPreview.propTypes = { + item: PropTypes.object, + itemType: PropTypes.string, + currentOffset: PropTypes.shape({ + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired + }) +}; + +/* eslint-disable new-cap */ +export default DragLayer(collectDragLayer)(QualityProfileItemDragPreview); +/* eslint-enable new-cap */ diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css new file mode 100644 index 000000000..d5061cc95 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css @@ -0,0 +1,18 @@ +.qualityProfileItemDragSource { + padding: $qualityProfileItemDragSourcePadding 0; +} + +.qualityProfileItemPlaceholder { + width: 100%; + height: $qualityProfileItemHeight; + border: 1px dotted #aaa; + border-radius: 4px; +} + +.qualityProfileItemPlaceholderBefore { + margin-bottom: 8px; +} + +.qualityProfileItemPlaceholderAfter { + margin-top: 8px; +} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.js new file mode 100644 index 000000000..a775ab427 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.js @@ -0,0 +1,244 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { findDOMNode } from 'react-dom'; +import { DragSource, DropTarget } from 'react-dnd'; +import classNames from 'classnames'; +import { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes'; +import QualityProfileItem from './QualityProfileItem'; +import QualityProfileItemGroup from './QualityProfileItemGroup'; +import styles from './QualityProfileItemDragSource.css'; + +const qualityProfileItemDragSource = { + beginDrag(props) { + const { + editGroups, + qualityIndex, + groupId, + qualityId, + name, + allowed + } = props; + + return { + editGroups, + qualityIndex, + groupId, + qualityId, + isGroup: !qualityId, + name, + allowed + }; + }, + + endDrag(props, monitor, component) { + props.onQualityProfileItemDragEnd(monitor.didDrop()); + } +}; + +const qualityProfileItemDropTarget = { + hover(props, monitor, component) { + const { + qualityIndex: dragQualityIndex, + isGroup: isDragGroup + } = monitor.getItem(); + + const dropQualityIndex = props.qualityIndex; + const isDropGroupItem = !!(props.qualityId && props.groupId); + + // Use childNodeIndex to select the correct node to get the middle of so + // we don't bounce between above and below causing rapid setState calls. + const childNodeIndex = component.props.isOverCurrent && component.props.isDraggingUp ? 1 :0; + const componentDOMNode = findDOMNode(component).children[childNodeIndex]; + const hoverBoundingRect = componentDOMNode.getBoundingClientRect(); + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const clientOffset = monitor.getClientOffset(); + const hoverClientY = clientOffset.y - hoverBoundingRect.top; + + // If we're hovering over a child don't trigger on the parent + if (!monitor.isOver({ shallow: true })) { + return; + } + + // Don't show targets for dropping on self + if (dragQualityIndex === dropQualityIndex) { + return; + } + + // Don't allow a group to be dropped inside a group + if (isDragGroup && isDropGroupItem) { + return; + } + + let dropPosition = null; + + // Determine drop position based on position over target + if (hoverClientY > hoverMiddleY) { + dropPosition = 'below'; + } else if (hoverClientY < hoverMiddleY) { + dropPosition = 'above'; + } else { + return; + } + + props.onQualityProfileItemDragMove({ + dragQualityIndex, + dropQualityIndex, + dropPosition + }); + } +}; + +function collectDragSource(connect, monitor) { + return { + connectDragSource: connect.dragSource(), + isDragging: monitor.isDragging() + }; +} + +function collectDropTarget(connect, monitor) { + return { + connectDropTarget: connect.dropTarget(), + isOver: monitor.isOver(), + isOverCurrent: monitor.isOver({ shallow: true }) + }; +} + +class QualityProfileItemDragSource extends Component { + + // + // Render + + render() { + const { + editGroups, + groupId, + qualityId, + name, + allowed, + items, + qualityIndex, + isDragging, + isDraggingUp, + isDraggingDown, + isOverCurrent, + connectDragSource, + connectDropTarget, + onCreateGroupPress, + onDeleteGroupPress, + onQualityProfileItemAllowedChange, + onItemGroupAllowedChange, + onItemGroupNameChange, + onQualityProfileItemDragMove, + onQualityProfileItemDragEnd + } = this.props; + + const isBefore = !isDragging && isDraggingUp && isOverCurrent; + const isAfter = !isDragging && isDraggingDown && isOverCurrent; + + return connectDropTarget( +
+ { + isBefore && +
+ } + + { + !!groupId && qualityId == null && + + } + + { + qualityId != null && + + } + + { + isAfter && +
+ } +
+ ); + } +} + +QualityProfileItemDragSource.propTypes = { + editGroups: PropTypes.bool.isRequired, + groupId: PropTypes.number, + qualityId: PropTypes.number, + name: PropTypes.string.isRequired, + allowed: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object), + qualityIndex: PropTypes.string.isRequired, + isDragging: PropTypes.bool, + isDraggingUp: PropTypes.bool, + isDraggingDown: PropTypes.bool, + isOverCurrent: PropTypes.bool, + isInGroup: PropTypes.bool, + connectDragSource: PropTypes.func, + connectDropTarget: PropTypes.func, + onCreateGroupPress: PropTypes.func, + onDeleteGroupPress: PropTypes.func, + onQualityProfileItemAllowedChange: PropTypes.func.isRequired, + onItemGroupAllowedChange: PropTypes.func, + onItemGroupNameChange: PropTypes.func, + onQualityProfileItemDragMove: PropTypes.func.isRequired, + onQualityProfileItemDragEnd: PropTypes.func.isRequired +}; + +/* eslint-disable new-cap */ +export default DropTarget( + QUALITY_PROFILE_ITEM, + qualityProfileItemDropTarget, + collectDropTarget +)(DragSource( + QUALITY_PROFILE_ITEM, + qualityProfileItemDragSource, + collectDragSource +)(QualityProfileItemDragSource)); +/* eslint-enable new-cap */ + diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.css new file mode 100644 index 000000000..613a9b2d3 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.css @@ -0,0 +1,105 @@ +.qualityProfileItemGroup { + width: 100%; + border: 1px solid #aaa; + border-radius: 4px; + background: #fafafa; + + &.editGroups { + background: #fcfcfc; + } +} + +.qualityProfileItemGroupInfo { + display: flex; + align-items: stretch; + width: 100%; +} + +.checkInputContainer { + composes: checkInputContainer from '~./QualityProfileItem.css'; + + display: flex; + align-items: center; +} + +.checkInput { + composes: checkInput from '~./QualityProfileItem.css'; +} + +.nameInput { + composes: input from '~Components/Form/TextInput.css'; + + margin-top: 4px; + margin-right: 10px; +} + +.nameContainer { + display: flex; + align-items: center; + flex-grow: 1; +} + +.name { + flex-shrink: 0; + + &.notAllowed { + color: #c6c6c6; + } +} + +.groupQualities { + display: flex; + justify-content: flex-end; + flex-grow: 1; + flex-wrap: wrap; + margin: 2px 0 2px 10px; +} + +.qualityNameContainer { + display: flex; + align-items: stretch; + flex-grow: 1; + margin-bottom: 0; + margin-left: 2px; + font-weight: normal; +} + +.qualityNameLabel { + composes: qualityNameContainer; + + cursor: pointer; +} + +.deleteGroupButton { + composes: buton from '~Components/Link/IconButton.css'; + + display: flex; + justify-content: center; + flex-shrink: 0; + margin-right: 5px; + margin-left: 8px; + width: 20px; +} + +.dragHandle { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + width: $dragHandleWidth; + text-align: center; + cursor: grab; +} + +.dragIcon { + top: 0; +} + +.isDragging { + opacity: 0.25; +} + +.items { + margin: 0 50px 0 35px; +} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.js new file mode 100644 index 000000000..c5bfba23d --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemGroup.js @@ -0,0 +1,200 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import CheckInput from 'Components/Form/CheckInput'; +import TextInput from 'Components/Form/TextInput'; +import QualityProfileItemDragSource from './QualityProfileItemDragSource'; +import styles from './QualityProfileItemGroup.css'; + +class QualityProfileItemGroup extends Component { + + // + // Listeners + + onAllowedChange = ({ value }) => { + const { + groupId, + onItemGroupAllowedChange + } = this.props; + + onItemGroupAllowedChange(groupId, value); + } + + onNameChange = ({ value }) => { + const { + groupId, + onItemGroupNameChange + } = this.props; + + onItemGroupNameChange(groupId, value); + } + + onDeleteGroupPress = ({ value }) => { + const { + groupId, + onDeleteGroupPress + } = this.props; + + onDeleteGroupPress(groupId, value); + } + + // + // Render + + render() { + const { + editGroups, + groupId, + name, + allowed, + items, + qualityIndex, + isDragging, + isDraggingUp, + isDraggingDown, + connectDragSource, + onQualityProfileItemAllowedChange, + onQualityProfileItemDragMove, + onQualityProfileItemDragEnd + } = this.props; + + return ( +
+
+ { + editGroups && +
+ + + +
+ } + + { + !editGroups && + + } + + { + connectDragSource( +
+ +
+ ) + } +
+ + { + editGroups && +
+ { + items.map(({ quality }, index) => { + return ( + + ); + }).reverse() + } +
+ } +
+ ); + } +} + +QualityProfileItemGroup.propTypes = { + editGroups: PropTypes.bool, + groupId: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + allowed: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + qualityIndex: PropTypes.string.isRequired, + isDragging: PropTypes.bool.isRequired, + isDraggingUp: PropTypes.bool.isRequired, + isDraggingDown: PropTypes.bool.isRequired, + connectDragSource: PropTypes.func, + onItemGroupAllowedChange: PropTypes.func.isRequired, + onQualityProfileItemAllowedChange: PropTypes.func.isRequired, + onItemGroupNameChange: PropTypes.func.isRequired, + onDeleteGroupPress: PropTypes.func.isRequired, + onQualityProfileItemDragMove: PropTypes.func.isRequired, + onQualityProfileItemDragEnd: PropTypes.func.isRequired +}; + +QualityProfileItemGroup.defaultProps = { + // The drag preview will not connect the drag handle. + connectDragSource: (node) => node +}; + +export default QualityProfileItemGroup; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItems.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.css new file mode 100644 index 000000000..002e555a7 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.css @@ -0,0 +1,15 @@ +.editGroupsButton { + composes: button from '~Components/Link/Button.css'; + + margin-top: 10px; +} + +.editGroupsButtonIcon { + margin-right: 8px; +} + +.qualities { + margin-top: 10px; + transition: min-height 200ms; + user-select: none; +} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItems.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.js new file mode 100644 index 000000000..c41d4b77d --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.js @@ -0,0 +1,181 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, sizes } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputHelpText from 'Components/Form/FormInputHelpText'; +import Measure from 'Components/Measure'; +import QualityProfileItemDragSource from './QualityProfileItemDragSource'; +import QualityProfileItemDragPreview from './QualityProfileItemDragPreview'; +import styles from './QualityProfileItems.css'; + +class QualityProfileItems extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + qualitiesHeight: 0, + qualitiesHeightEditGroups: 0 + }; + } + + // + // Listeners + + onMeasure = ({ height }) => { + if (this.props.editGroups) { + this.setState({ + qualitiesHeightEditGroups: height + }); + } else { + this.setState({ qualitiesHeight: height }); + } + } + + onToggleEditGroupsMode = () => { + this.props.onToggleEditGroupsMode(); + } + + // + // Render + + render() { + const { + editGroups, + dropQualityIndex, + dropPosition, + qualityProfileItems, + errors, + warnings, + ...otherProps + } = this.props; + + const { + qualitiesHeight, + qualitiesHeightEditGroups + } = this.state; + + const isDragging = dropQualityIndex !== null; + const isDraggingUp = isDragging && dropPosition === 'above'; + const isDraggingDown = isDragging && dropPosition === 'below'; + const minHeight = editGroups ? qualitiesHeightEditGroups : qualitiesHeight; + + return ( + + + Qualities + + +
+ + + { + errors.map((error, index) => { + return ( + + ); + }) + } + + { + warnings.map((warning, index) => { + return ( + + ); + }) + } + + + + +
+ { + qualityProfileItems.map(({ id, name, allowed, quality, items }, index) => { + const identifier = quality ? quality.id : id; + + return ( + + ); + }).reverse() + } + + +
+
+
+
+ ); + } +} + +QualityProfileItems.propTypes = { + editGroups: PropTypes.bool.isRequired, + dragQualityIndex: PropTypes.string, + dropQualityIndex: PropTypes.string, + dropPosition: PropTypes.string, + qualityProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired, + errors: PropTypes.arrayOf(PropTypes.object), + warnings: PropTypes.arrayOf(PropTypes.object), + onToggleEditGroupsMode: PropTypes.func.isRequired +}; + +QualityProfileItems.defaultProps = { + errors: [], + warnings: [] +}; + +export default QualityProfileItems; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileNameConnector.js b/frontend/src/Settings/Profiles/Quality/QualityProfileNameConnector.js new file mode 100644 index 000000000..bf13815ff --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileNameConnector.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createQualityProfileSelector from 'Store/Selectors/createQualityProfileSelector'; + +function createMapStateToProps() { + return createSelector( + createQualityProfileSelector(), + (qualityProfile) => { + return { + name: qualityProfile.name + }; + } + ); +} + +function QualityProfileNameConnector({ name, ...otherProps }) { + return ( + + {name} + + ); +} + +QualityProfileNameConnector.propTypes = { + qualityProfileId: PropTypes.number.isRequired, + name: PropTypes.string.isRequired +}; + +export default connect(createMapStateToProps)(QualityProfileNameConnector); diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfiles.css b/frontend/src/Settings/Profiles/Quality/QualityProfiles.css new file mode 100644 index 000000000..437d152d2 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfiles.css @@ -0,0 +1,21 @@ +.qualityProfiles { + display: flex; + flex-wrap: wrap; +} + +.addQualityProfile { + composes: qualityProfile from '~./QualityProfile.css'; + + background-color: $cardAlternateBackgroundColor; + color: $gray; + text-align: center; + font-size: 45px; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfiles.js b/frontend/src/Settings/Profiles/Quality/QualityProfiles.js new file mode 100644 index 000000000..2e9f123c4 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfiles.js @@ -0,0 +1,106 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import sortByName from 'Utilities/Array/sortByName'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Card from 'Components/Card'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import QualityProfile from './QualityProfile'; +import EditQualityProfileModalConnector from './EditQualityProfileModalConnector'; +import styles from './QualityProfiles.css'; + +class QualityProfiles extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isQualityProfileModalOpen: false + }; + } + + // + // Listeners + + onCloneQualityProfilePress = (id) => { + this.props.onCloneQualityProfilePress(id); + this.setState({ isQualityProfileModalOpen: true }); + } + + onEditQualityProfilePress = () => { + this.setState({ isQualityProfileModalOpen: true }); + } + + onModalClose = () => { + this.setState({ isQualityProfileModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + isDeleting, + onConfirmDeleteQualityProfile, + ...otherProps + } = this.props; + + return ( +
+ +
+ { + items.sort(sortByName).map((item) => { + return ( + + ); + }) + } + + +
+ +
+
+
+ + +
+
+ ); + } +} + +QualityProfiles.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isDeleting: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteQualityProfile: PropTypes.func.isRequired, + onCloneQualityProfilePress: PropTypes.func.isRequired +}; + +export default QualityProfiles; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js b/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js new file mode 100644 index 000000000..c7596ad63 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js @@ -0,0 +1,65 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchQualityProfiles, deleteQualityProfile, cloneQualityProfile } from 'Store/Actions/settingsActions'; +import QualityProfiles from './QualityProfiles'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.qualityProfiles, + (qualityProfiles) => { + return { + ...qualityProfiles + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchQualityProfiles: fetchQualityProfiles, + dispatchDeleteQualityProfile: deleteQualityProfile, + dispatchCloneQualityProfile: cloneQualityProfile +}; + +class QualityProfilesConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchQualityProfiles(); + } + + // + // Listeners + + onConfirmDeleteQualityProfile = (id) => { + this.props.dispatchDeleteQualityProfile({ id }); + } + + onCloneQualityProfilePress = (id) => { + this.props.dispatchCloneQualityProfile({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +QualityProfilesConnector.propTypes = { + dispatchFetchQualityProfiles: PropTypes.func.isRequired, + dispatchDeleteQualityProfile: PropTypes.func.isRequired, + dispatchCloneQualityProfile: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(QualityProfilesConnector); diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModal.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModal.js new file mode 100644 index 000000000..5d0e74287 --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditReleaseProfileModalContentConnector from './EditReleaseProfileModalContentConnector'; + +function EditReleaseProfileModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditReleaseProfileModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditReleaseProfileModal; diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalConnector.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalConnector.js new file mode 100644 index 000000000..89b605652 --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalConnector.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditReleaseProfileModal from './EditReleaseProfileModal'; + +const mapDispatchToProps = { + clearPendingChanges +}; + +class EditReleaseProfileModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'settings.releaseProfiles' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditReleaseProfileModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(null, mapDispatchToProps)(EditReleaseProfileModalConnector); diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css new file mode 100644 index 000000000..a2b6014df --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css @@ -0,0 +1,5 @@ +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js new file mode 100644 index 000000000..b2423e791 --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js @@ -0,0 +1,161 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import styles from './EditReleaseProfileModalContent.css'; + +// Tab, enter, and comma +const tagInputDelimiters = [9, 13, 188]; + +function EditReleaseProfileModalContent(props) { + const { + isSaving, + saveError, + item, + onInputChange, + onModalClose, + onSavePress, + onDeleteReleaseProfilePress, + ...otherProps + } = props; + + const { + id, + required, + ignored, + preferred, + includePreferredWhenRenaming, + tags + } = item; + + return ( + + + {id ? 'Edit Release Profile' : 'Add Release Profile'} + + + +
+ + Must Contain + + + + + + Must Not Contain + + + + + + Preferred + + + + + + Include Preferred when Renaming + + + + + + Tags + + + +
+
+ + { + id && + + } + + + + + Save + + +
+ ); +} + +EditReleaseProfileModalContent.propTypes = { + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onDeleteReleaseProfilePress: PropTypes.func +}; + +export default EditReleaseProfileModalContent; diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContentConnector.js new file mode 100644 index 000000000..447bea3c7 --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContentConnector.js @@ -0,0 +1,113 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; +import { setReleaseProfileValue, saveReleaseProfile } from 'Store/Actions/settingsActions'; +import EditReleaseProfileModalContent from './EditReleaseProfileModalContent'; + +const newReleaseProfile = { + required: '', + ignored: '', + preferred: [], + includePreferredWhenRenaming: false, + tags: [] +}; + +function createMapStateToProps() { + return createSelector( + (state, { id }) => id, + (state) => state.settings.releaseProfiles, + (id, releaseProfiles) => { + const { + isFetching, + error, + isSaving, + saveError, + pendingChanges, + items + } = releaseProfiles; + + const profile = id ? _.find(items, { id }) : newReleaseProfile; + const settings = selectSettings(profile, pendingChanges, saveError); + + return { + id, + isFetching, + error, + isSaving, + saveError, + item: settings.settings, + ...settings + }; + } + ); +} + +const mapDispatchToProps = { + setReleaseProfileValue, + saveReleaseProfile +}; + +class EditReleaseProfileModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + if (!this.props.id) { + Object.keys(newReleaseProfile).forEach((name) => { + this.props.setReleaseProfileValue({ + name, + value: newReleaseProfile[name] + }); + }); + } + } + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setReleaseProfileValue({ name, value }); + } + + onSavePress = () => { + this.props.saveReleaseProfile({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditReleaseProfileModalContentConnector.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setReleaseProfileValue: PropTypes.func.isRequired, + saveReleaseProfile: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditReleaseProfileModalContentConnector); diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfile.css b/frontend/src/Settings/Profiles/Release/ReleaseProfile.css new file mode 100644 index 000000000..c1e2e59a2 --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/ReleaseProfile.css @@ -0,0 +1,11 @@ +.releaseProfile { + composes: card from '~Components/Card.css'; + + width: 290px; +} + +.enabled { + display: flex; + flex-wrap: wrap; + margin-top: 5px; +} diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfile.js b/frontend/src/Settings/Profiles/Release/ReleaseProfile.js new file mode 100644 index 000000000..f1b03a68f --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/ReleaseProfile.js @@ -0,0 +1,173 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import split from 'Utilities/String/split'; +import { kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import TagList from 'Components/TagList'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditReleaseProfileModalConnector from './EditReleaseProfileModalConnector'; +import styles from './ReleaseProfile.css'; + +class ReleaseProfile extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditReleaseProfileModalOpen: false, + isDeleteReleaseProfileModalOpen: false + }; + } + + // + // Listeners + + onEditReleaseProfilePress = () => { + this.setState({ isEditReleaseProfileModalOpen: true }); + } + + onEditReleaseProfileModalClose = () => { + this.setState({ isEditReleaseProfileModalOpen: false }); + } + + onDeleteReleaseProfilePress = () => { + this.setState({ + isEditReleaseProfileModalOpen: false, + isDeleteReleaseProfileModalOpen: true + }); + } + + onDeleteReleaseProfileModalClose= () => { + this.setState({ isDeleteReleaseProfileModalOpen: false }); + } + + onConfirmDeleteReleaseProfile = () => { + this.props.onConfirmDeleteReleaseProfile(this.props.id); + } + + // + // Render + + render() { + const { + id, + required, + ignored, + preferred, + tags, + tagList + } = this.props; + + const { + isEditReleaseProfileModalOpen, + isDeleteReleaseProfileModalOpen + } = this.state; + + return ( + +
+ { + split(required).map((item) => { + if (!item) { + return null; + } + + return ( + + ); + }) + } +
+ +
+ { + split(ignored).map((item) => { + if (!item) { + return null; + } + + return ( + + ); + }) + } +
+ +
+ { + preferred.map((item) => { + const isPreferred = item.value >= 0; + + return ( + + ); + }) + } +
+ + + + + + +
+ ); + } +} + +ReleaseProfile.propTypes = { + id: PropTypes.number.isRequired, + required: PropTypes.string.isRequired, + ignored: PropTypes.string.isRequired, + preferred: PropTypes.arrayOf(PropTypes.object).isRequired, + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + tagList: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteReleaseProfile: PropTypes.func.isRequired +}; + +ReleaseProfile.defaultProps = { + required: '', + ignored: '', + preferred: [] +}; + +export default ReleaseProfile; diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfiles.css b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.css new file mode 100644 index 000000000..8f5a81252 --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.css @@ -0,0 +1,20 @@ +.releaseProfiles { + display: flex; + flex-wrap: wrap; +} + +.addReleaseProfile { + composes: releaseProfile from '~./ReleaseProfile.css'; + + background-color: $cardAlternateBackgroundColor; + color: $gray; + text-align: center; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfiles.js b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.js new file mode 100644 index 000000000..73c648a04 --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.js @@ -0,0 +1,98 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Card from 'Components/Card'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import ReleaseProfile from './ReleaseProfile'; +import EditReleaseProfileModalConnector from './EditReleaseProfileModalConnector'; +import styles from './ReleaseProfiles.css'; + +class ReleaseProfiles extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddReleaseProfileModalOpen: false + }; + } + + // + // Listeners + + onAddReleaseProfilePress = () => { + this.setState({ isAddReleaseProfileModalOpen: true }); + } + + onAddReleaseProfileModalClose = () => { + this.setState({ isAddReleaseProfileModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + tagList, + onConfirmDeleteReleaseProfile, + ...otherProps + } = this.props; + + return ( +
+ +
+ +
+ +
+
+ + { + items.map((item) => { + return ( + + ); + }) + } +
+ + +
+
+ ); + } +} + +ReleaseProfiles.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + tagList: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteReleaseProfile: PropTypes.func.isRequired +}; + +export default ReleaseProfiles; diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfilesConnector.js b/frontend/src/Settings/Profiles/Release/ReleaseProfilesConnector.js new file mode 100644 index 000000000..dd4b41171 --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/ReleaseProfilesConnector.js @@ -0,0 +1,61 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchReleaseProfiles, deleteReleaseProfile } from 'Store/Actions/settingsActions'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import ReleaseProfiles from './ReleaseProfiles'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.releaseProfiles, + createTagsSelector(), + (releaseProfiles, tagList) => { + return { + ...releaseProfiles, + tagList + }; + } + ); +} + +const mapDispatchToProps = { + fetchReleaseProfiles, + deleteReleaseProfile +}; + +class ReleaseProfilesConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchReleaseProfiles(); + } + + // + // Listeners + + onConfirmDeleteReleaseProfile = (id) => { + this.props.deleteReleaseProfile({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ReleaseProfilesConnector.propTypes = { + fetchReleaseProfiles: PropTypes.func.isRequired, + deleteReleaseProfile: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ReleaseProfilesConnector); diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.css b/frontend/src/Settings/Quality/Definition/QualityDefinition.css new file mode 100644 index 000000000..134e14d0d --- /dev/null +++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.css @@ -0,0 +1,93 @@ +.qualityDefinition { + display: flex; + align-content: stretch; + margin: 5px 0; + padding-top: 5px; + height: 45px; + border-top: 1px solid $borderColor; +} + +.quality, +.title { + flex: 0 1 250px; + padding-right: 20px; + line-height: 40px; +} + +.sizeLimit { + flex: 0 1 500px; + padding-right: 30px; +} + +.slider { + width: 100%; + height: 20px; +} + +.bar { + top: 9px; + margin: 0 5px; + height: 3px; + background-color: $sliderAccentColor; + box-shadow: 0 0 0 #000; + + &:nth-child(odd) { + background-color: #ddd; + } +} + +.handle { + top: 1px; + z-index: 0 !important; + width: 18px; + height: 18px; + border: 3px solid $sliderAccentColor; + border-radius: 50%; + background-color: $white; + text-align: center; + cursor: pointer; +} + +.sizes { + display: flex; + justify-content: space-between; +} + +.kilobitsPerSecond { + display: flex; + justify-content: space-between; + flex: 0 0 250px; +} + +.sizeInput { + composes: input from '~Components/Form/TextInput.css'; + + display: inline-block; + margin-left: 5px; + padding: 6px; + width: 75px; +} + +@media only screen and (max-width: $breakpointSmall) { + .qualityDefinition { + flex-wrap: wrap; + height: auto; + + &:first-child { + border-top: none; + } + } + + .qualityDefinition:first-child { + border-top: none; + } + + .quality { + font-weight: bold; + line-height: inherit; + } + + .sizeLimit { + margin-top: 10px; + } +} diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.js b/frontend/src/Settings/Quality/Definition/QualityDefinition.js new file mode 100644 index 000000000..9c5258019 --- /dev/null +++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.js @@ -0,0 +1,264 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactSlider from 'react-slider'; +import formatBytes from 'Utilities/Number/formatBytes'; +import roundNumber from 'Utilities/Number/roundNumber'; +import { kinds, tooltipPositions } from 'Helpers/Props'; +import Label from 'Components/Label'; +import NumberInput from 'Components/Form/NumberInput'; +import TextInput from 'Components/Form/TextInput'; +import Popover from 'Components/Tooltip/Popover'; +import QualityDefinitionLimits from './QualityDefinitionLimits'; +import styles from './QualityDefinition.css'; + +const MIN = 0; +const MAX = 1500; + +const slider = { + min: MIN, + max: roundNumber(Math.pow(MAX, 1 / 1.1)), + step: 0.1 +}; + +function getValue(inputValue) { + if (inputValue < MIN) { + return MIN; + } + + if (inputValue > MAX) { + return MAX; + } + + return roundNumber(inputValue); +} + +function getSliderValue(value, defaultValue) { + const sliderValue = value ? Math.pow(value, 1 / 1.1) : defaultValue; + + return roundNumber(sliderValue); +} + +class QualityDefinition extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._forceUpdateTimeout = null; + + this.state = { + sliderMinSize: getSliderValue(props.minSize, slider.min), + sliderMaxSize: getSliderValue(props.maxSize, slider.max) + }; + } + + componentDidMount() { + // A hack to deal with a bug in the slider component until a fix for it + // lands and an updated version is available. + // See: https://github.com/mpowaga/react-slider/issues/115 + + this._forceUpdateTimeout = setTimeout(() => this.forceUpdate(), 1); + } + + componentWillUnmount() { + if (this._forceUpdateTimeout) { + clearTimeout(this._forceUpdateTimeout); + } + } + + // + // Listeners + + onSliderChange = ([sliderMinSize, sliderMaxSize]) => { + this.setState({ + sliderMinSize, + sliderMaxSize + }); + + this.props.onSizeChange({ + minSize: roundNumber(Math.pow(sliderMinSize, 1.1)), + maxSize: sliderMaxSize === slider.max ? null : roundNumber(Math.pow(sliderMaxSize, 1.1)) + }); + } + + onAfterSliderChange = () => { + const { + minSize, + maxSize + } = this.props; + + this.setState({ + sliderMiSize: getSliderValue(minSize, slider.min), + sliderMaxSize: getSliderValue(maxSize, slider.max) + }); + } + + onMinSizeChange = ({ value }) => { + const minSize = getValue(value); + + this.setState({ + sliderMinSize: getSliderValue(minSize, slider.min) + }); + + this.props.onSizeChange({ + minSize, + maxSize: this.props.maxSize + }); + } + + onMaxSizeChange = ({ value }) => { + const maxSize = value === MAX ? null : getValue(value); + + this.setState({ + sliderMaxSize: getSliderValue(maxSize, slider.max) + }); + + this.props.onSizeChange({ + minSize: this.props.minSize, + maxSize + }); + } + + // + // Render + + render() { + const { + id, + quality, + title, + minSize, + maxSize, + advancedSettings, + onTitleChange + } = this.props; + + const { + sliderMinSize, + sliderMaxSize + } = this.state; + + const minBytes = minSize * 128; + const maxBytes = maxSize && maxSize * 128; + + const minRate = `${formatBytes(minBytes, true)}/s`; + const maxRate = maxBytes ? `${formatBytes(maxBytes, true)}/s` : 'Unlimited'; + + return ( +
+
+ {quality.name} +
+ +
+ +
+ +
+ + +
+
+ {minRate} + } + title="Minimum Limits" + body={ + + } + position={tooltipPositions.BOTTOM} + /> +
+ +
+ {maxRate} + } + title="Maximum Limits" + body={ + + } + position={tooltipPositions.BOTTOM} + /> +
+
+
+ + { + advancedSettings && +
+
+ Min + + +
+ +
+ Max + + +
+
+ } +
+ ); + } +} + +QualityDefinition.propTypes = { + id: PropTypes.number.isRequired, + quality: PropTypes.object.isRequired, + title: PropTypes.string.isRequired, + minSize: PropTypes.number, + maxSize: PropTypes.number, + advancedSettings: PropTypes.bool.isRequired, + onTitleChange: PropTypes.func.isRequired, + onSizeChange: PropTypes.func.isRequired +}; + +export default QualityDefinition; diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js b/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js new file mode 100644 index 000000000..a76c9440f --- /dev/null +++ b/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js @@ -0,0 +1,64 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { setQualityDefinitionValue } from 'Store/Actions/settingsActions'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import QualityDefinition from './QualityDefinition'; + +const mapDispatchToProps = { + setQualityDefinitionValue, + clearPendingChanges +}; + +class QualityDefinitionConnector extends Component { + + componentWillUnmount() { + this.props.clearPendingChanges({ section: 'settings.qualityDefinitions' }); + } + + // + // Listeners + + onTitleChange = ({ value }) => { + this.props.setQualityDefinitionValue({ id: this.props.id, name: 'title', value }); + } + + onSizeChange = ({ minSize, maxSize }) => { + const { + id, + minSize: currentMinSize, + maxSize: currentMaxSize + } = this.props; + + if (minSize !== currentMinSize) { + this.props.setQualityDefinitionValue({ id, name: 'minSize', value: minSize }); + } + + if (maxSize !== currentMaxSize) { + this.props.setQualityDefinitionValue({ id, name: 'maxSize', value: maxSize }); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +QualityDefinitionConnector.propTypes = { + id: PropTypes.number.isRequired, + minSize: PropTypes.number, + maxSize: PropTypes.number, + setQualityDefinitionValue: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(null, mapDispatchToProps)(QualityDefinitionConnector); diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitionLimits.js b/frontend/src/Settings/Quality/Definition/QualityDefinitionLimits.js new file mode 100644 index 000000000..618fa1bd8 --- /dev/null +++ b/frontend/src/Settings/Quality/Definition/QualityDefinitionLimits.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; + +function QualityDefinitionLimits(props) { + const { + bytes, + message + } = props; + + if (!bytes) { + return
{message}
; + } + + const twenty = formatBytes(bytes * 20 * 60); + const fourtyFive = formatBytes(bytes * 45 * 60); + const sixty = formatBytes(bytes * 60 * 60); + + return ( +
+
20 Minutes: {twenty}
+
45 Minutes: {fourtyFive}
+
60 Minutes: {sixty}
+
+ ); +} + +QualityDefinitionLimits.propTypes = { + bytes: PropTypes.number, + message: PropTypes.string.isRequired +}; + +export default QualityDefinitionLimits; diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitions.css b/frontend/src/Settings/Quality/Definition/QualityDefinitions.css new file mode 100644 index 000000000..9f4afbe0e --- /dev/null +++ b/frontend/src/Settings/Quality/Definition/QualityDefinitions.css @@ -0,0 +1,41 @@ +.header { + display: flex; + font-weight: bold; +} + +.quality, +.title { + flex: 0 1 250px; +} + +.sizeLimit { + flex: 0 1 500px; +} + +.kilobitsPerSecond { + flex: 0 0 250px; +} + +.sizeLimitHelpTextContainer { + display: flex; + justify-content: flex-end; + margin-top: 20px; + max-width: 1000px; +} + +.sizeLimitHelpText { + max-width: 500px; + color: $helpTextColor; +} + +@media only screen and (max-width: $breakpointSmall) { + .header { + display: none; + } + + .definitions { + &:first-child { + border-top: none; + } + } +} diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitions.js b/frontend/src/Settings/Quality/Definition/QualityDefinitions.js new file mode 100644 index 000000000..e7817de48 --- /dev/null +++ b/frontend/src/Settings/Quality/Definition/QualityDefinitions.js @@ -0,0 +1,72 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import FieldSet from 'Components/FieldSet'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import QualityDefinitionConnector from './QualityDefinitionConnector'; +import styles from './QualityDefinitions.css'; + +class QualityDefinitions extends Component { + + // + // Render + + render() { + const { + items, + advancedSettings, + ...otherProps + } = this.props; + + return ( +
+ +
+
Quality
+
Title
+
Size Limit
+ { + advancedSettings ? +
+ Kilobits Per Second +
: + null + } +
+ +
+ { + items.map((item) => { + return ( + + ); + }) + } +
+ +
+
+ Limits are automatically adjusted for the album duration. +
+
+
+
+ ); + } +} + +QualityDefinitions.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + defaultProfile: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + advancedSettings: PropTypes.bool.isRequired +}; + +export default QualityDefinitions; diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitionsConnector.js b/frontend/src/Settings/Quality/Definition/QualityDefinitionsConnector.js new file mode 100644 index 000000000..b7a36fe72 --- /dev/null +++ b/frontend/src/Settings/Quality/Definition/QualityDefinitionsConnector.js @@ -0,0 +1,92 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchQualityDefinitions, saveQualityDefinitions } from 'Store/Actions/settingsActions'; +import QualityDefinitions from './QualityDefinitions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.qualityDefinitions, + (state) => state.settings.advancedSettings, + (qualityDefinitions, advancedSettings) => { + const items = qualityDefinitions.items.map((item) => { + const pendingChanges = qualityDefinitions.pendingChanges[item.id] || {}; + + return Object.assign({}, item, pendingChanges); + }); + + return { + ...qualityDefinitions, + items, + hasPendingChanges: !_.isEmpty(qualityDefinitions.pendingChanges), + advancedSettings + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchQualityDefinitions: fetchQualityDefinitions, + dispatchSaveQualityDefinitions: saveQualityDefinitions +}; + +class QualityDefinitionsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchQualityDefinitions(); + + const { + dispatchFetchQualityDefinitions, + dispatchSaveQualityDefinitions, + onChildMounted + } = this.props; + + dispatchFetchQualityDefinitions(); + onChildMounted(dispatchSaveQualityDefinitions); + } + + componentDidUpdate(prevProps) { + const { + hasPendingChanges, + isSaving, + onChildStateChange + } = this.props; + + if ( + prevProps.isSaving !== isSaving || + prevProps.hasPendingChanges !== hasPendingChanges + ) { + onChildStateChange({ + isSaving, + hasPendingChanges + }); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +QualityDefinitionsConnector.propTypes = { + isSaving: PropTypes.bool.isRequired, + hasPendingChanges: PropTypes.bool.isRequired, + dispatchFetchQualityDefinitions: PropTypes.func.isRequired, + dispatchSaveQualityDefinitions: PropTypes.func.isRequired, + onChildMounted: PropTypes.func.isRequired, + onChildStateChange: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps, null)(QualityDefinitionsConnector); diff --git a/frontend/src/Settings/Quality/Quality.js b/frontend/src/Settings/Quality/Quality.js new file mode 100644 index 000000000..dfd6a24d7 --- /dev/null +++ b/frontend/src/Settings/Quality/Quality.js @@ -0,0 +1,68 @@ +import React, { Component } from 'react'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import QualityDefinitionsConnector from './Definition/QualityDefinitionsConnector'; + +class Quality extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._saveCallback = null; + + this.state = { + isSaving: false, + hasPendingChanges: false + }; + } + + // + // Listeners + + onChildMounted = (saveCallback) => { + this._saveCallback = saveCallback; + } + + onChildStateChange = (payload) => { + this.setState(payload); + } + + onSavePress = () => { + if (this._saveCallback) { + this._saveCallback(); + } + } + + // + // Render + + render() { + const { + isSaving, + hasPendingChanges + } = this.state; + + return ( + + + + + + + + ); + } +} + +export default Quality; diff --git a/frontend/src/Settings/Settings.css b/frontend/src/Settings/Settings.css new file mode 100644 index 000000000..38e88e67f --- /dev/null +++ b/frontend/src/Settings/Settings.css @@ -0,0 +1,18 @@ +.link { + composes: link from '~Components/Link/Link.css'; + + border-bottom: 1px solid #e5e5e5; + color: #3a3f51; + font-size: 21px; + + &:hover { + color: #616573; + text-decoration: none; + } +} + +.summary { + margin-top: 10px; + margin-bottom: 30px; + color: $dimColor; +} diff --git a/frontend/src/Settings/Settings.js b/frontend/src/Settings/Settings.js new file mode 100644 index 000000000..368b2c47b --- /dev/null +++ b/frontend/src/Settings/Settings.js @@ -0,0 +1,144 @@ +import React from 'react'; +import Link from 'Components/Link/Link'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from './SettingsToolbarConnector'; +import styles from './Settings.css'; + +function Settings() { + return ( + + + + + + Media Management + + +
+ Naming, file management settings and root folders +
+ + + Profiles + + +
+ Quality, Metadata, Delay, and Release profiles +
+ + + Quality + + +
+ Quality sizes and naming +
+ + + Indexers + + +
+ Indexers and indexer options +
+ + + Download Clients + + +
+ Download clients, download handling and remote path mappings +
+ + + Import Lists + + +
+ Import Lists +
+ + + Connect + + +
+ Notifications, connections to media servers/players and custom scripts +
+ + + Metadata + + +
+ Create metadata files when tracks are imported or artist are refreshed +
+ + + Tags + + +
+ Manage artist, profile, restriction, and notification tags +
+ + + General + + +
+ Port, SSL, username/password, proxy, analytics and updates +
+ + + UI + + +
+ Calendar, date and color impaired options +
+
+
+ ); +} + +Settings.propTypes = { +}; + +export default Settings; diff --git a/frontend/src/Settings/SettingsToolbar.js b/frontend/src/Settings/SettingsToolbar.js new file mode 100644 index 000000000..a47923c75 --- /dev/null +++ b/frontend/src/Settings/SettingsToolbar.js @@ -0,0 +1,105 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PendingChangesModal from './PendingChangesModal'; +import AdvancedSettingsButton from './AdvancedSettingsButton'; + +class SettingsToolbar extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.bindShortcut(shortcuts.SAVE_SETTINGS.key, this.saveSettings, { isGlobal: true }); + } + + // + // Control + + saveSettings = (event) => { + event.preventDefault(); + + const { + hasPendingChanges, + onSavePress + } = this.props; + + if (hasPendingChanges) { + onSavePress(); + } + } + + // + // Render + + render() { + const { + advancedSettings, + showSave, + isSaving, + hasPendingChanges, + additionalButtons, + hasPendingLocation, + onSavePress, + onConfirmNavigation, + onCancelNavigation, + onAdvancedSettingsPress + } = this.props; + + return ( + + + + + { + showSave && + + } + + { + additionalButtons + } + + + + + ); + } +} + +SettingsToolbar.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + showSave: PropTypes.bool.isRequired, + isSaving: PropTypes.bool, + hasPendingLocation: PropTypes.bool.isRequired, + hasPendingChanges: PropTypes.bool, + additionalButtons: PropTypes.node, + onSavePress: PropTypes.func, + onAdvancedSettingsPress: PropTypes.func.isRequired, + onConfirmNavigation: PropTypes.func.isRequired, + onCancelNavigation: PropTypes.func.isRequired, + bindShortcut: PropTypes.func.isRequired +}; + +SettingsToolbar.defaultProps = { + showSave: true +}; + +export default keyboardShortcuts(SettingsToolbar); diff --git a/frontend/src/Settings/SettingsToolbarConnector.js b/frontend/src/Settings/SettingsToolbarConnector.js new file mode 100644 index 000000000..8bfb3dad5 --- /dev/null +++ b/frontend/src/Settings/SettingsToolbarConnector.js @@ -0,0 +1,147 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { withRouter } from 'react-router-dom'; +import { toggleAdvancedSettings } from 'Store/Actions/settingsActions'; +import SettingsToolbar from './SettingsToolbar'; + +function mapStateToProps(state) { + return { + advancedSettings: state.settings.advancedSettings + }; +} + +const mapDispatchToProps = { + toggleAdvancedSettings +}; + +class SettingsToolbarConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + nextLocation: null, + nextLocationAction: null, + confirmed: false + }; + + this._unblock = null; + } + + componentDidMount() { + this._unblock = this.props.history.block(this.routerWillLeave); + } + + componentWillUnmount() { + if (this._unblock) { + this._unblock(); + } + } + + // + // Control + + routerWillLeave = (nextLocation, nextLocationAction) => { + if (this.state.confirmed) { + this.setState({ + nextLocation: null, + nextLocationAction: null, + confirmed: false + }); + + return true; + } + + if (this.props.hasPendingChanges ) { + this.setState({ + nextLocation, + nextLocationAction + }); + + return false; + } + + return true; + } + + // + // Listeners + + onAdvancedSettingsPress = () => { + this.props.toggleAdvancedSettings(); + } + + onConfirmNavigation = () => { + const { + nextLocation, + nextLocationAction + } = this.state; + + const history = this.props.history; + + const path = `${nextLocation.pathname}${nextLocation.search}`; + + this.setState({ + confirmed: true + }, () => { + if (nextLocationAction === 'PUSH') { + history.push(path); + } else { + // Unfortunately back and forward both use POP, + // which means we don't actually know which direction + // the user wanted to go, assuming back. + + history.goBack(); + } + }); + } + + onCancelNavigation = () => { + this.setState({ + nextLocation: null, + nextLocationAction: null, + confirmed: false + }); + } + + // + // Render + + render() { + const hasPendingLocation = this.state.nextLocation !== null; + + return ( + + ); + } +} + +const historyShape = { + block: PropTypes.func.isRequired, + goBack: PropTypes.func.isRequired, + push: PropTypes.func.isRequired +}; + +SettingsToolbarConnector.propTypes = { + hasPendingChanges: PropTypes.bool.isRequired, + history: PropTypes.shape(historyShape).isRequired, + onSavePress: PropTypes.func, + toggleAdvancedSettings: PropTypes.func.isRequired +}; + +SettingsToolbarConnector.defaultProps = { + hasPendingChanges: false +}; + +export default withRouter(connect(mapStateToProps, mapDispatchToProps)(SettingsToolbarConnector)); diff --git a/frontend/src/Settings/Tags/Details/TagDetailsDelayProfile.js b/frontend/src/Settings/Tags/Details/TagDetailsDelayProfile.js new file mode 100644 index 000000000..ab670359b --- /dev/null +++ b/frontend/src/Settings/Tags/Details/TagDetailsDelayProfile.js @@ -0,0 +1,47 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import titleCase from 'Utilities/String/titleCase'; + +function TagDetailsDelayProfile(props) { + const { + preferredProtocol, + enableUsenet, + enableTorrent, + usenetDelay, + torrentDelay + } = props; + + return ( +
+
+ Protocol: {titleCase(preferredProtocol)} +
+ +
+ { + enableUsenet ? + `Usenet Delay: ${usenetDelay}` : + 'Usenet disabled' + } +
+ +
+ { + enableTorrent ? + `Torrent Delay: ${torrentDelay}` : + 'Torrents disabled' + } +
+
+ ); +} + +TagDetailsDelayProfile.propTypes = { + preferredProtocol: PropTypes.string.isRequired, + enableUsenet: PropTypes.bool.isRequired, + enableTorrent: PropTypes.bool.isRequired, + usenetDelay: PropTypes.number.isRequired, + torrentDelay: PropTypes.number.isRequired +}; + +export default TagDetailsDelayProfile; diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModal.js b/frontend/src/Settings/Tags/Details/TagDetailsModal.js new file mode 100644 index 000000000..0fe1ec5d3 --- /dev/null +++ b/frontend/src/Settings/Tags/Details/TagDetailsModal.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import TagDetailsModalContentConnector from './TagDetailsModalContentConnector'; + +function TagDetailsModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +TagDetailsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default TagDetailsModal; diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.css b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.css new file mode 100644 index 000000000..d11136863 --- /dev/null +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.css @@ -0,0 +1,26 @@ +.items { + display: flex; + flex-wrap: wrap; +} + +.item { + flex: 0 0 100%; +} + +.restriction { + margin-bottom: 5px; + padding-bottom: 5px; + border-bottom: 1px solid $borderColor; + + &:last-child { + margin: 0; + padding: 0; + border-bottom: none; + } +} + +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js new file mode 100644 index 000000000..5dd031837 --- /dev/null +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js @@ -0,0 +1,196 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import split from 'Utilities/String/split'; +import { kinds } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Button from 'Components/Link/Button'; +import Label from 'Components/Label'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import TagDetailsDelayProfile from './TagDetailsDelayProfile'; +import styles from './TagDetailsModalContent.css'; + +function TagDetailsModalContent(props) { + const { + label, + isTagUsed, + artist, + delayProfiles, + importLists, + notifications, + releaseProfiles, + onModalClose, + onDeleteTagPress + } = props; + + return ( + + + Tag Details - {label} + + + + { + !isTagUsed && +
Tag is not used and can be deleted
+ } + + { + !!artist.length && +
+ { + artist.map((item) => { + return ( +
+ {item.artistName} +
+ ); + }) + } +
+ } + + { + !!delayProfiles.length && +
+ { + delayProfiles.map((item) => { + const { + id, + preferredProtocol, + enableUsenet, + enableTorrent, + usenetDelay, + torrentDelay + } = item; + + return ( + + ); + }) + } +
+ } + + { + !!notifications.length && +
+ { + notifications.map((item) => { + return ( +
+ {item.name} +
+ ); + }) + } +
+ } + + { + !!importLists.length && +
+ { + importLists.map((item) => { + return ( +
+ {item.name} +
+ ); + }) + } +
+ } + + { + !!releaseProfiles.length && +
+ { + releaseProfiles.map((item) => { + return ( +
+
+ { + split(item.required).map((r) => { + return ( + + ); + }) + } +
+ +
+ { + split(item.ignored).map((i) => { + return ( + + ); + }) + } +
+
+ ); + }) + } +
+ } +
+ + + { + + } + + + +
+ ); +} + +TagDetailsModalContent.propTypes = { + label: PropTypes.string.isRequired, + isTagUsed: PropTypes.bool.isRequired, + artist: PropTypes.arrayOf(PropTypes.object).isRequired, + delayProfiles: PropTypes.arrayOf(PropTypes.object).isRequired, + importLists: PropTypes.arrayOf(PropTypes.object).isRequired, + notifications: PropTypes.arrayOf(PropTypes.object).isRequired, + releaseProfiles: PropTypes.arrayOf(PropTypes.object).isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteTagPress: PropTypes.func.isRequired +}; + +export default TagDetailsModalContent; diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js new file mode 100644 index 000000000..18a3fb435 --- /dev/null +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js @@ -0,0 +1,71 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; +import TagDetailsModalContent from './TagDetailsModalContent'; + +function findMatchingItems(ids, items) { + return items.filter((s) => { + return ids.includes(s.id); + }); +} + +function createMatchingArtistSelector() { + return createSelector( + (state, { artistIds }) => artistIds, + createAllArtistSelector(), + findMatchingItems + ); +} + +function createMatchingDelayProfilesSelector() { + return createSelector( + (state, { delayProfileIds }) => delayProfileIds, + (state) => state.settings.delayProfiles.items, + findMatchingItems + ); +} + +function createMatchingImportListsSelector() { + return createSelector( + (state, { importListIds }) => importListIds, + (state) => state.settings.importLists.items, + findMatchingItems + ); +} + +function createMatchingNotificationsSelector() { + return createSelector( + (state, { notificationIds }) => notificationIds, + (state) => state.settings.notifications.items, + findMatchingItems + ); +} + +function createMatchingReleaseProfilesSelector() { + return createSelector( + (state, { restrictionIds }) => restrictionIds, + (state) => state.settings.releaseProfiles.items, + findMatchingItems + ); +} + +function createMapStateToProps() { + return createSelector( + createMatchingArtistSelector(), + createMatchingDelayProfilesSelector(), + createMatchingImportListsSelector(), + createMatchingNotificationsSelector(), + createMatchingReleaseProfilesSelector(), + (artist, delayProfiles, importLists, notifications, releaseProfiles) => { + return { + artist, + delayProfiles, + importLists, + notifications, + releaseProfiles + }; + } + ); +} + +export default connect(createMapStateToProps)(TagDetailsModalContent); diff --git a/frontend/src/Settings/Tags/Tag.css b/frontend/src/Settings/Tags/Tag.css new file mode 100644 index 000000000..ebf61e539 --- /dev/null +++ b/frontend/src/Settings/Tags/Tag.css @@ -0,0 +1,12 @@ +.tag { + composes: card from '~Components/Card.css'; + + flex: 150px 0 1; +} + +.label { + margin-bottom: 20px; + white-space: nowrap; + font-weight: 300; + font-size: 24px; +} diff --git a/frontend/src/Settings/Tags/Tag.js b/frontend/src/Settings/Tags/Tag.js new file mode 100644 index 000000000..b2aceb47e --- /dev/null +++ b/frontend/src/Settings/Tags/Tag.js @@ -0,0 +1,178 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import TagDetailsModal from './Details/TagDetailsModal'; +import styles from './Tag.css'; + +class Tag extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false, + isDeleteTagModalOpen: false + }; + } + + // + // Listeners + + onShowDetailsPress = () => { + this.setState({ isDetailsModalOpen: true }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + onDeleteTagPress = () => { + this.setState({ + isDetailsModalOpen: false, + isDeleteTagModalOpen: true + }); + } + + onDeleteTagModalClose= () => { + this.setState({ isDeleteTagModalOpen: false }); + } + + onConfirmDeleteTag = () => { + this.props.onConfirmDeleteTag({ id: this.props.id }); + } + + // + // Render + + render() { + const { + label, + delayProfileIds, + importListIds, + notificationIds, + restrictionIds, + artistIds + } = this.props; + + const { + isDetailsModalOpen, + isDeleteTagModalOpen + } = this.state; + + const isTagUsed = !!( + delayProfileIds.length || + importListIds.length || + notificationIds.length || + restrictionIds.length || + artistIds.length + ); + + return ( + +
+ {label} +
+ + { + isTagUsed && +
+ { + !!artistIds.length && +
+ {artistIds.length} artists +
+ } + + { + !!delayProfileIds.length && +
+ {delayProfileIds.length} delay profile{delayProfileIds.length > 1 && 's'} +
+ } + + { + !!importListIds.length && +
+ {importListIds.length} import list{importListIds.length > 1 && 's'} +
+ } + + { + !!notificationIds.length && +
+ {notificationIds.length} connection{notificationIds.length > 1 && 's'} +
+ } + + { + !!restrictionIds.length && +
+ {restrictionIds.length} restriction{restrictionIds.length > 1 && 's'} +
+ } +
+ } + + { + !isTagUsed && +
+ No links +
+ } + + + + +
+ ); + } +} + +Tag.propTypes = { + id: PropTypes.number.isRequired, + label: PropTypes.string.isRequired, + delayProfileIds: PropTypes.arrayOf(PropTypes.number).isRequired, + importListIds: PropTypes.arrayOf(PropTypes.number).isRequired, + notificationIds: PropTypes.arrayOf(PropTypes.number).isRequired, + restrictionIds: PropTypes.arrayOf(PropTypes.number).isRequired, + artistIds: PropTypes.arrayOf(PropTypes.number).isRequired, + onConfirmDeleteTag: PropTypes.func.isRequired +}; + +Tag.defaultProps = { + delayProfileIds: [], + importListIds: [], + notificationIds: [], + restrictionIds: [], + artistIds: [] +}; + +export default Tag; diff --git a/frontend/src/Settings/Tags/TagConnector.js b/frontend/src/Settings/Tags/TagConnector.js new file mode 100644 index 000000000..50f610153 --- /dev/null +++ b/frontend/src/Settings/Tags/TagConnector.js @@ -0,0 +1,22 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createTagDetailsSelector from 'Store/Selectors/createTagDetailsSelector'; +import { deleteTag } from 'Store/Actions/tagActions'; +import Tag from './Tag'; + +function createMapStateToProps() { + return createSelector( + createTagDetailsSelector(), + (tagDetails) => { + return { + ...tagDetails + }; + } + ); +} + +const mapStateToProps = { + onConfirmDeleteTag: deleteTag +}; + +export default connect(createMapStateToProps, mapStateToProps)(Tag); diff --git a/frontend/src/Settings/Tags/TagSettings.js b/frontend/src/Settings/Tags/TagSettings.js new file mode 100644 index 000000000..56ef92b49 --- /dev/null +++ b/frontend/src/Settings/Tags/TagSettings.js @@ -0,0 +1,21 @@ +import React from 'react'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import TagsConnector from './TagsConnector'; + +function TagSettings() { + return ( + + + + + + + + ); +} + +export default TagSettings; diff --git a/frontend/src/Settings/Tags/Tags.css b/frontend/src/Settings/Tags/Tags.css new file mode 100644 index 000000000..5a44f8331 --- /dev/null +++ b/frontend/src/Settings/Tags/Tags.css @@ -0,0 +1,4 @@ +.tags { + display: flex; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/Tags/Tags.js b/frontend/src/Settings/Tags/Tags.js new file mode 100644 index 000000000..8aed3c0a9 --- /dev/null +++ b/frontend/src/Settings/Tags/Tags.js @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import FieldSet from 'Components/FieldSet'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import TagConnector from './TagConnector'; +import Link from 'Components/Link/Link'; +import styles from './Tags.css'; + +function Tags(props) { + const { + items, + ...otherProps + } = props; + + if (!items.length) { + return ( +
No tags have been added yet. Add tags to link artists with delay profiles, restrictions, or notifications. Click here to find out more about tags in Lidarr.
+ ); + } + + return ( +
+ +
+ { + items.map((item) => { + return ( + + ); + }) + } +
+
+
+ ); +} + +Tags.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default Tags; diff --git a/frontend/src/Settings/Tags/TagsConnector.js b/frontend/src/Settings/Tags/TagsConnector.js new file mode 100644 index 000000000..70b727387 --- /dev/null +++ b/frontend/src/Settings/Tags/TagsConnector.js @@ -0,0 +1,76 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchTagDetails } from 'Store/Actions/tagActions'; +import { fetchDelayProfiles, fetchNotifications, fetchReleaseProfiles, fetchImportLists } from 'Store/Actions/settingsActions'; +import Tags from './Tags'; + +function createMapStateToProps() { + return createSelector( + (state) => state.tags, + (tags) => { + const isFetching = tags.isFetching || tags.details.isFetching; + const error = tags.error || tags.details.error; + const isPopulated = tags.isPopulated && tags.details.isPopulated; + + return { + ...tags, + isFetching, + error, + isPopulated + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchTagDetails: fetchTagDetails, + dispatchFetchDelayProfiles: fetchDelayProfiles, + dispatchFetchImportLists: fetchImportLists, + dispatchFetchNotifications: fetchNotifications, + dispatchFetchReleaseProfiles: fetchReleaseProfiles +}; + +class MetadatasConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + dispatchFetchTagDetails, + dispatchFetchDelayProfiles, + dispatchFetchImportLists, + dispatchFetchNotifications, + dispatchFetchReleaseProfiles + } = this.props; + + dispatchFetchTagDetails(); + dispatchFetchDelayProfiles(); + dispatchFetchImportLists(); + dispatchFetchNotifications(); + dispatchFetchReleaseProfiles(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +MetadatasConnector.propTypes = { + dispatchFetchTagDetails: PropTypes.func.isRequired, + dispatchFetchDelayProfiles: PropTypes.func.isRequired, + dispatchFetchImportLists: PropTypes.func.isRequired, + dispatchFetchNotifications: PropTypes.func.isRequired, + dispatchFetchReleaseProfiles: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(MetadatasConnector); diff --git a/frontend/src/Settings/UI/UISettings.css b/frontend/src/Settings/UI/UISettings.css new file mode 100644 index 000000000..2e6213823 --- /dev/null +++ b/frontend/src/Settings/UI/UISettings.css @@ -0,0 +1,3 @@ +.columnGroup { + flex-direction: column; +} diff --git a/frontend/src/Settings/UI/UISettings.js b/frontend/src/Settings/UI/UISettings.js new file mode 100644 index 000000000..dc249487c --- /dev/null +++ b/frontend/src/Settings/UI/UISettings.js @@ -0,0 +1,241 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FieldSet from 'Components/FieldSet'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import styles from './UISettings.css'; + +export const firstDayOfWeekOptions = [ + { key: 0, value: 'Sunday' }, + { key: 1, value: 'Monday' } +]; + +export const weekColumnOptions = [ + { key: 'ddd M/D', value: 'Tue 3/25' }, + { key: 'ddd MM/DD', value: 'Tue 03/25' }, + { key: 'ddd D/M', value: 'Tue 25/03' }, + { key: 'ddd DD/MM', value: 'Tue 25/03' } +]; + +const shortDateFormatOptions = [ + { key: 'MMM D YYYY', value: 'Mar 25 2014' }, + { key: 'DD MMM YYYY', value: '25 Mar 2014' }, + { key: 'MM/D/YYYY', value: '03/25/2014' }, + { key: 'MM/DD/YYYY', value: '03/25/2014' }, + { key: 'DD/MM/YYYY', value: '25/03/2014' }, + { key: 'YYYY-MM-DD', value: '2014-03-25' } +]; + +const longDateFormatOptions = [ + { key: 'dddd, MMMM D YYYY', value: 'Tuesday, March 25, 2014' }, + { key: 'dddd, D MMMM YYYY', value: 'Tuesday, 25 March, 2014' } +]; + +export const timeFormatOptions = [ + { key: 'h(:mm)a', value: '5pm/5:30pm' }, + { key: 'HH:mm', value: '17:00/17:30' } +]; + +class UISettings extends Component { + + // + // Render + + render() { + const { + isFetching, + error, + settings, + hasSettings, + onInputChange, + onSavePress, + ...otherProps + } = this.props; + + return ( + + + + + { + isFetching && + + } + + { + !isFetching && error && +
Unable to load UI settings
+ } + + { + hasSettings && !isFetching && !error && +
+
+ + First Day of Week + + + + + + Week Column Header + + + +
+ +
+ + Short Date Format + + + + + + Long Date Format + + + + + + Time Format + + + + + + Show Relative Dates + + +
+ +
+ + Enable Color-Impaired Mode + + + + + Expand Items by Default +
+ + + + + + + + + +
+
+
+
+ } +
+
+ ); + } + +} + +UISettings.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + settings: PropTypes.object.isRequired, + hasSettings: PropTypes.bool.isRequired, + onSavePress: PropTypes.func.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default UISettings; diff --git a/frontend/src/Settings/UI/UISettingsConnector.js b/frontend/src/Settings/UI/UISettingsConnector.js new file mode 100644 index 000000000..e2dabe9c3 --- /dev/null +++ b/frontend/src/Settings/UI/UISettingsConnector.js @@ -0,0 +1,77 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import { setUISettingsValue, saveUISettings, fetchUISettings } from 'Store/Actions/settingsActions'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import UISettings from './UISettings'; + +const SECTION = 'ui'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createSettingsSectionSelector(SECTION), + (advancedSettings, sectionSettings) => { + return { + advancedSettings, + ...sectionSettings + }; + } + ); +} + +const mapDispatchToProps = { + setUISettingsValue, + saveUISettings, + fetchUISettings, + clearPendingChanges +}; + +class UISettingsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchUISettings(); + } + + componentWillUnmount() { + this.props.clearPendingChanges({ section: 'settings.ui' }); + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setUISettingsValue({ name, value }); + } + + onSavePress = () => { + this.props.saveUISettings(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +UISettingsConnector.propTypes = { + setUISettingsValue: PropTypes.func.isRequired, + saveUISettings: PropTypes.func.isRequired, + fetchUISettings: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(UISettingsConnector); diff --git a/frontend/src/Shared/piwikCheck.js b/frontend/src/Shared/piwikCheck.js new file mode 100644 index 000000000..9fcc84361 --- /dev/null +++ b/frontend/src/Shared/piwikCheck.js @@ -0,0 +1,10 @@ +if (window.Lidarr.analytics) { + const d = document; + const g = d.createElement('script'); + const s = d.getElementsByTagName('script')[0]; + g.type = 'text/javascript'; + g.async = true; + g.defer = true; + g.src = '//piwik.sonarr.tv/piwik.js'; + s.parentNode.insertBefore(g, s); +} diff --git a/frontend/src/Store/Actions/Creators/Reducers/createClearReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createClearReducer.js new file mode 100644 index 000000000..2952973a9 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/Reducers/createClearReducer.js @@ -0,0 +1,12 @@ +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +function createClearReducer(section, defaultState) { + return (state) => { + const newState = Object.assign(getSectionState(state, section), defaultState); + + return updateSectionState(state, section, newState); + }; +} + +export default createClearReducer; diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionFilterReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionFilterReducer.js new file mode 100644 index 000000000..d58bb1cd4 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionFilterReducer.js @@ -0,0 +1,14 @@ +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +function createSetClientSideCollectionFilterReducer(section) { + return (state, { payload }) => { + const newState = getSectionState(state, section); + + newState.selectedFilterKey = payload.selectedFilterKey; + + return updateSectionState(state, section, newState); + }; +} + +export default createSetClientSideCollectionFilterReducer; diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer.js new file mode 100644 index 000000000..1bc048a80 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer.js @@ -0,0 +1,29 @@ +import { sortDirections } from 'Helpers/Props'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +function createSetClientSideCollectionSortReducer(section) { + return (state, { payload }) => { + const newState = getSectionState(state, section); + + const sortKey = payload.sortKey || newState.sortKey; + let sortDirection = payload.sortDirection; + + if (!sortDirection) { + if (payload.sortKey === newState.sortKey) { + sortDirection = newState.sortDirection === sortDirections.ASCENDING ? + sortDirections.DESCENDING : + sortDirections.ASCENDING; + } else { + sortDirection = newState.sortDirection; + } + } + + newState.sortKey = sortKey; + newState.sortDirection = sortDirection; + + return updateSectionState(state, section, newState); + }; +} + +export default createSetClientSideCollectionSortReducer; diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer.js new file mode 100644 index 000000000..3af58dd3b --- /dev/null +++ b/frontend/src/Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer.js @@ -0,0 +1,23 @@ +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +function createSetProviderFieldValueReducer(section) { + return (state, { payload }) => { + if (section === payload.section) { + const { name, value } = payload; + const newState = getSectionState(state, section); + newState.pendingChanges = Object.assign({}, newState.pendingChanges); + const fields = Object.assign({}, newState.pendingChanges.fields || {}); + + fields[name] = value; + + newState.pendingChanges.fields = fields; + + return updateSectionState(state, section, newState); + } + + return state; + }; +} + +export default createSetProviderFieldValueReducer; diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetSettingValueReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetSettingValueReducer.js new file mode 100644 index 000000000..474eb7bb2 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/Reducers/createSetSettingValueReducer.js @@ -0,0 +1,36 @@ +import _ from 'lodash'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +function createSetSettingValueReducer(section) { + return (state, { payload }) => { + if (section === payload.section) { + const { name, value } = payload; + const newState = getSectionState(state, section); + newState.pendingChanges = Object.assign({}, newState.pendingChanges); + + const currentValue = newState.item ? newState.item[name] : null; + const pendingState = newState.pendingChanges; + + let parsedValue = null; + + if (_.isNumber(currentValue) && value != null) { + parsedValue = parseInt(value); + } else { + parsedValue = value; + } + + if (currentValue === parsedValue) { + delete pendingState[name]; + } else { + pendingState[name] = parsedValue; + } + + return updateSectionState(state, section, newState); + } + + return state; + }; +} + +export default createSetSettingValueReducer; diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetTableOptionReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetTableOptionReducer.js new file mode 100644 index 000000000..70b57446d --- /dev/null +++ b/frontend/src/Store/Actions/Creators/Reducers/createSetTableOptionReducer.js @@ -0,0 +1,21 @@ +import _ from 'lodash'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +const whitelistedProperties = [ + 'pageSize', + 'columns', + 'tableOptions' +]; + +function createSetTableOptionReducer(section) { + return (state, { payload }) => { + const newState = Object.assign( + getSectionState(state, section), + _.pick(payload, whitelistedProperties)); + + return updateSectionState(state, section, newState); + }; +} + +export default createSetTableOptionReducer; diff --git a/frontend/src/Store/Actions/Creators/createBatchToggleAlbumMonitoredHandler.js b/frontend/src/Store/Actions/Creators/createBatchToggleAlbumMonitoredHandler.js new file mode 100644 index 000000000..04025b519 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createBatchToggleAlbumMonitoredHandler.js @@ -0,0 +1,42 @@ +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import updateAlbums from 'Utilities/Album/updateAlbums'; +import getSectionState from 'Utilities/State/getSectionState'; + +function createBatchToggleAlbumMonitoredHandler(section, fetchHandler) { + return function(getState, payload, dispatch) { + const { + albumIds, + monitored + } = payload; + + const state = getSectionState(getState(), section, true); + + dispatch(updateAlbums(section, state.items, albumIds, { + isSaving: true + })); + + const promise = createAjaxRequest({ + url: '/album/monitor', + method: 'PUT', + data: JSON.stringify({ albumIds, monitored }), + dataType: 'json' + }).request; + + promise.done(() => { + dispatch(updateAlbums(section, state.items, albumIds, { + isSaving: false, + monitored + })); + + dispatch(fetchHandler()); + }); + + promise.fail(() => { + dispatch(updateAlbums(section, state.items, albumIds, { + isSaving: false + })); + }); + }; +} + +export default createBatchToggleAlbumMonitoredHandler; diff --git a/frontend/src/Store/Actions/Creators/createFetchHandler.js b/frontend/src/Store/Actions/Creators/createFetchHandler.js new file mode 100644 index 000000000..c9cd058bd --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createFetchHandler.js @@ -0,0 +1,44 @@ +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { set, update, updateItem } from '../baseActions'; + +export default function createFetchHandler(section, url) { + return function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + const { + id, + ...otherPayload + } = payload; + + const { request, abortRequest } = createAjaxRequest({ + url: id == null ? url : `${url}/${id}`, + data: otherPayload, + traditional: true + }); + + request.done((data) => { + dispatch(batchActions([ + id == null ? update({ section, data }) : updateItem({ section, ...data }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + request.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr.aborted ? null : xhr + })); + }); + + return abortRequest; + }; +} diff --git a/frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js b/frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js new file mode 100644 index 000000000..a1f24bbbd --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js @@ -0,0 +1,33 @@ +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { set } from '../baseActions'; + +function createFetchSchemaHandler(section, url) { + return function(getState, payload, dispatch) { + dispatch(set({ section, isSchemaFetching: true })); + + const promise = createAjaxRequest({ + url + }).request; + + promise.done((data) => { + dispatch(set({ + section, + isSchemaFetching: false, + isSchemaPopulated: true, + schemaError: null, + schema: data + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSchemaFetching: false, + isSchemaPopulated: true, + schemaError: xhr + })); + }); + }; +} + +export default createFetchSchemaHandler; diff --git a/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js new file mode 100644 index 000000000..a80ee1e45 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js @@ -0,0 +1,67 @@ +import _ from 'lodash'; +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import findSelectedFilters from 'Utilities/Filter/findSelectedFilters'; +import getSectionState from 'Utilities/State/getSectionState'; +import { set, updateServerSideCollection } from '../baseActions'; + +function createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter) { + return function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + const sectionState = getSectionState(getState(), section, true); + const page = payload.page || sectionState.page || 1; + + const data = Object.assign({ page }, + _.pick(sectionState, [ + 'pageSize', + 'sortDirection', + 'sortKey' + ])); + + if (fetchDataAugmenter) { + fetchDataAugmenter(getState, payload, data); + } + + const { + selectedFilterKey, + filters, + customFilters + } = sectionState; + + const selectedFilters = findSelectedFilters(selectedFilterKey, filters, customFilters); + + selectedFilters.forEach((filter) => { + data[filter.key] = filter.value; + }); + + const promise = createAjaxRequest({ + url, + data + }).request; + + promise.done((response) => { + dispatch(batchActions([ + updateServerSideCollection({ section, data: response }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + }; +} + +export default createFetchServerSideCollectionHandler; diff --git a/frontend/src/Store/Actions/Creators/createHandleActions.js b/frontend/src/Store/Actions/Creators/createHandleActions.js new file mode 100644 index 000000000..c3315ce94 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createHandleActions.js @@ -0,0 +1,143 @@ +import _ from 'lodash'; +import { handleActions } from 'redux-actions'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import { + SET, + UPDATE, + UPDATE_ITEM, + UPDATE_SERVER_SIDE_COLLECTION, + CLEAR_PENDING_CHANGES, + REMOVE_ITEM +} from 'Store/Actions/baseActions'; + +const blacklistedProperties = [ + 'section', + 'id' +]; + +export default function createHandleActions(handlers, defaultState, section) { + return handleActions({ + + [SET]: function(state, { payload }) { + const payloadSection = payload.section; + const [baseSection] = payloadSection.split('.'); + + if (section === baseSection) { + const newState = Object.assign(getSectionState(state, payloadSection), + _.omit(payload, blacklistedProperties)); + + return updateSectionState(state, payloadSection, newState); + } + + return state; + }, + + [UPDATE]: function(state, { payload }) { + const payloadSection = payload.section; + const [baseSection] = payloadSection.split('.'); + + if (section === baseSection) { + const newState = getSectionState(state, payloadSection); + + if (_.isArray(payload.data)) { + newState.items = payload.data; + } else { + newState.item = payload.data; + } + + return updateSectionState(state, payloadSection, newState); + } + + return state; + }, + + [UPDATE_ITEM]: function(state, { payload }) { + const { + section: payloadSection, + updateOnly = false, + ...otherProps + } = payload; + + const [baseSection] = payloadSection.split('.'); + + if (section === baseSection) { + const newState = getSectionState(state, payloadSection); + const items = newState.items; + const index = _.findIndex(items, { id: payload.id }); + + newState.items = [...items]; + + // TODO: Move adding to it's own reducer + if (index >= 0) { + const item = items[index]; + + newState.items.splice(index, 1, { ...item, ...otherProps }); + } else if (!updateOnly) { + newState.items.push({ ...otherProps }); + } + + return updateSectionState(state, payloadSection, newState); + } + + return state; + }, + + [CLEAR_PENDING_CHANGES]: function(state, { payload }) { + const payloadSection = payload.section; + const [baseSection] = payloadSection.split('.'); + + if (section === baseSection) { + const newState = getSectionState(state, payloadSection); + newState.pendingChanges = {}; + + if (newState.hasOwnProperty('saveError')) { + newState.saveError = null; + } + + return updateSectionState(state, payloadSection, newState); + } + + return state; + }, + + [REMOVE_ITEM]: function(state, { payload }) { + const payloadSection = payload.section; + const [baseSection] = payloadSection.split('.'); + + if (section === baseSection) { + const newState = getSectionState(state, payloadSection); + + newState.items = [...newState.items]; + _.remove(newState.items, { id: payload.id }); + + return updateSectionState(state, payloadSection, newState); + } + + return state; + }, + + [UPDATE_SERVER_SIDE_COLLECTION]: function(state, { payload }) { + const payloadSection = payload.section; + const [baseSection] = payloadSection.split('.'); + + if (section === baseSection) { + const data = payload.data; + const newState = getSectionState(state, payloadSection); + + const serverState = _.omit(data, ['records']); + const calculatedState = { + totalPages: Math.max(Math.ceil(data.totalRecords / data.pageSize), 1), + items: data.records + }; + + return updateSectionState(state, payloadSection, Object.assign(newState, serverState, calculatedState)); + } + + return state; + }, + + ...handlers + + }, defaultState); +} diff --git a/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js new file mode 100644 index 000000000..5e4a2b386 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js @@ -0,0 +1,46 @@ +import $ from 'jquery'; +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { set, removeItem } from '../baseActions'; + +function createRemoveItemHandler(section, url) { + return function(getState, payload, dispatch) { + const { + id, + ...queryParams + } = payload; + + dispatch(set({ section, isDeleting: true })); + + const ajaxOptions = { + url: `${url}/${id}?${$.param(queryParams, true)}`, + method: 'DELETE' + }; + + const promise = createAjaxRequest(ajaxOptions).request; + + promise.done((data) => { + dispatch(batchActions([ + set({ + section, + isDeleting: false, + deleteError: null + }), + + removeItem({ section, id }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isDeleting: false, + deleteError: xhr + })); + }); + + return promise; + }; +} + +export default createRemoveItemHandler; diff --git a/frontend/src/Store/Actions/Creators/createSaveHandler.js b/frontend/src/Store/Actions/Creators/createSaveHandler.js new file mode 100644 index 000000000..e064b7e5a --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createSaveHandler.js @@ -0,0 +1,43 @@ +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import getSectionState from 'Utilities/State/getSectionState'; +import { set, update } from '../baseActions'; + +function createSaveHandler(section, url) { + return function(getState, payload, dispatch) { + dispatch(set({ section, isSaving: true })); + + const state = getSectionState(getState(), section, true); + const saveData = Object.assign({}, state.item, state.pendingChanges, payload); + + const promise = createAjaxRequest({ + url, + method: 'PUT', + dataType: 'json', + data: JSON.stringify(saveData) + }).request; + + promise.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + isSaving: false, + saveError: null, + pendingChanges: {} + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + }; +} + +export default createSaveHandler; diff --git a/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js b/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js new file mode 100644 index 000000000..c0697f0e2 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js @@ -0,0 +1,80 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import getProviderState from 'Utilities/State/getProviderState'; +import { set, updateItem, removeItem } from '../baseActions'; + +const abortCurrentRequests = {}; + +export function createCancelSaveProviderHandler(section) { + return function(getState, payload, dispatch) { + if (abortCurrentRequests[section]) { + abortCurrentRequests[section](); + abortCurrentRequests[section] = null; + } + }; +} + +function createSaveProviderHandler(section, url, options = {}, removeStale = false) { + return function(getState, payload, dispatch) { + dispatch(set({ section, isSaving: true })); + + const { + id, + queryParams = {}, + ...otherPayload + } = payload; + + const saveData = Array.isArray(id) ? id.map((x) => getProviderState({ id: x, ...otherPayload }, getState, section)) : getProviderState({ id, ...otherPayload }, getState, section); + + const ajaxOptions = { + url: `${url}?${$.param(queryParams, true)}`, + method: 'POST', + contentType: 'application/json', + dataType: 'json', + data: JSON.stringify(saveData) + }; + + if (id) { + ajaxOptions.method = 'PUT'; + if (!Array.isArray(id)) { + ajaxOptions.url = `${url}/${id}?${$.param(queryParams, true)}`; + } + } + + const { request, abortRequest } = createAjaxRequest(ajaxOptions); + + abortCurrentRequests[section] = abortRequest; + + request.done((data) => { + if (!Array.isArray(data)) { + data = [data]; + } + + const toRemove = removeStale && Array.isArray(id) ? _.difference(id, _.map(data, 'id')) : []; + + dispatch(batchActions( + data.map((item) => updateItem({ section, ...item })).concat( + toRemove.map((item) => removeItem({ section, id: item })) + ).concat( + set({ + section, + isSaving: false, + saveError: null, + pendingChanges: {} + }) + ))); + }); + + request.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr.aborted ? null : xhr + })); + }); + }; +} + +export default createSaveProviderHandler; diff --git a/frontend/src/Store/Actions/Creators/createServerSideCollectionHandlers.js b/frontend/src/Store/Actions/Creators/createServerSideCollectionHandlers.js new file mode 100644 index 000000000..f81723769 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createServerSideCollectionHandlers.js @@ -0,0 +1,52 @@ +import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; +import pages from 'Utilities/pages'; +import createFetchServerSideCollectionHandler from './createFetchServerSideCollectionHandler'; +import createSetServerSideCollectionPageHandler from './createSetServerSideCollectionPageHandler'; +import createSetServerSideCollectionSortHandler from './createSetServerSideCollectionSortHandler'; +import createSetServerSideCollectionFilterHandler from './createSetServerSideCollectionFilterHandler'; + +function createServerSideCollectionHandlers(section, url, fetchThunk, handlers, fetchDataAugmenter) { + const actionHandlers = {}; + const fetchHandlerType = handlers[serverSideCollectionHandlers.FETCH]; + const fetchHandler = createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter); + actionHandlers[fetchHandlerType] = fetchHandler; + + if (handlers.hasOwnProperty(serverSideCollectionHandlers.FIRST_PAGE)) { + const handlerType = handlers[serverSideCollectionHandlers.FIRST_PAGE]; + actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.FIRST, fetchThunk); + } + + if (handlers.hasOwnProperty(serverSideCollectionHandlers.PREVIOUS_PAGE)) { + const handlerType = handlers[serverSideCollectionHandlers.PREVIOUS_PAGE]; + actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.PREVIOUS, fetchThunk); + } + + if (handlers.hasOwnProperty(serverSideCollectionHandlers.NEXT_PAGE)) { + const handlerType = handlers[serverSideCollectionHandlers.NEXT_PAGE]; + actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.NEXT, fetchThunk); + } + + if (handlers.hasOwnProperty(serverSideCollectionHandlers.LAST_PAGE)) { + const handlerType = handlers[serverSideCollectionHandlers.LAST_PAGE]; + actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.LAST, fetchThunk); + } + + if (handlers.hasOwnProperty(serverSideCollectionHandlers.EXACT_PAGE)) { + const handlerType = handlers[serverSideCollectionHandlers.EXACT_PAGE]; + actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.EXACT, fetchThunk); + } + + if (handlers.hasOwnProperty(serverSideCollectionHandlers.SORT)) { + const handlerType = handlers[serverSideCollectionHandlers.SORT]; + actionHandlers[handlerType] = createSetServerSideCollectionSortHandler(section, fetchThunk); + } + + if (handlers.hasOwnProperty(serverSideCollectionHandlers.FILTER)) { + const handlerType = handlers[serverSideCollectionHandlers.FILTER]; + actionHandlers[handlerType] = createSetServerSideCollectionFilterHandler(section, fetchThunk); + } + + return actionHandlers; +} + +export default createServerSideCollectionHandlers; diff --git a/frontend/src/Store/Actions/Creators/createSetServerSideCollectionFilterHandler.js b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionFilterHandler.js new file mode 100644 index 000000000..d7e476444 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionFilterHandler.js @@ -0,0 +1,10 @@ +import { set } from '../baseActions'; + +function createSetServerSideCollectionFilterHandler(section, fetchHandler) { + return function(getState, payload, dispatch) { + dispatch(set({ section, ...payload })); + dispatch(fetchHandler({ page: 1 })); + }; +} + +export default createSetServerSideCollectionFilterHandler; diff --git a/frontend/src/Store/Actions/Creators/createSetServerSideCollectionPageHandler.js b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionPageHandler.js new file mode 100644 index 000000000..12b21bb0d --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionPageHandler.js @@ -0,0 +1,35 @@ +import pages from 'Utilities/pages'; +import getSectionState from 'Utilities/State/getSectionState'; + +function createSetServerSideCollectionPageHandler(section, page, fetchHandler) { + return function(getState, payload, dispatch) { + const sectionState = getSectionState(getState(), section, true); + const currentPage = sectionState.page || 1; + let nextPage = 0; + + switch (page) { + case pages.FIRST: + nextPage = 1; + break; + case pages.PREVIOUS: + nextPage = currentPage - 1; + break; + case pages.NEXT: + nextPage = currentPage + 1; + break; + case pages.LAST: + nextPage = sectionState.totalPages; + break; + default: + nextPage = payload.page; + } + + // If we prefer to update the page immediately we should + // set the page and not pass a page to the fetch handler. + + // dispatch(set({ section, page: nextPage })); + dispatch(fetchHandler({ page: nextPage })); + }; +} + +export default createSetServerSideCollectionPageHandler; diff --git a/frontend/src/Store/Actions/Creators/createSetServerSideCollectionSortHandler.js b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionSortHandler.js new file mode 100644 index 000000000..fbd66e83e --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionSortHandler.js @@ -0,0 +1,26 @@ +import getSectionState from 'Utilities/State/getSectionState'; +import { sortDirections } from 'Helpers/Props'; +import { set } from '../baseActions'; + +function createSetServerSideCollectionSortHandler(section, fetchHandler) { + return function(getState, payload, dispatch) { + const sectionState = getSectionState(getState(), section, true); + const sortKey = payload.sortKey || sectionState.sortKey; + let sortDirection = payload.sortDirection; + + if (!sortDirection) { + if (payload.sortKey === sectionState.sortKey) { + sortDirection = sectionState.sortDirection === sortDirections.ASCENDING ? + sortDirections.DESCENDING : + sortDirections.ASCENDING; + } else { + sortDirection = sectionState.sortDirection; + } + } + + dispatch(set({ section, sortKey, sortDirection })); + dispatch(fetchHandler()); + }; +} + +export default createSetServerSideCollectionSortHandler; diff --git a/frontend/src/Store/Actions/Creators/createTestAllProvidersHandler.js b/frontend/src/Store/Actions/Creators/createTestAllProvidersHandler.js new file mode 100644 index 000000000..77deaec64 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createTestAllProvidersHandler.js @@ -0,0 +1,34 @@ +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { set } from '../baseActions'; + +function createTestAllProvidersHandler(section, url) { + return function(getState, payload, dispatch) { + dispatch(set({ section, isTestingAll: true })); + + const ajaxOptions = { + url: `${url}/testall`, + method: 'POST', + contentType: 'application/json', + dataType: 'json' + }; + + const { request } = createAjaxRequest(ajaxOptions); + + request.done((data) => { + dispatch(set({ + section, + isTestingAll: false, + saveError: null + })); + }); + + request.fail((xhr) => { + dispatch(set({ + section, + isTestingAll: false + })); + }); + }; +} + +export default createTestAllProvidersHandler; diff --git a/frontend/src/Store/Actions/Creators/createTestProviderHandler.js b/frontend/src/Store/Actions/Creators/createTestProviderHandler.js new file mode 100644 index 000000000..ca26883fb --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createTestProviderHandler.js @@ -0,0 +1,52 @@ +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import getProviderState from 'Utilities/State/getProviderState'; +import { set } from '../baseActions'; + +const abortCurrentRequests = {}; + +export function createCancelTestProviderHandler(section) { + return function(getState, payload, dispatch) { + if (abortCurrentRequests[section]) { + abortCurrentRequests[section](); + abortCurrentRequests[section] = null; + } + }; +} + +function createTestProviderHandler(section, url) { + return function(getState, payload, dispatch) { + dispatch(set({ section, isTesting: true })); + + const testData = getProviderState(payload, getState, section); + + const ajaxOptions = { + url: `${url}/test`, + method: 'POST', + contentType: 'application/json', + dataType: 'json', + data: JSON.stringify(testData) + }; + + const { request, abortRequest } = createAjaxRequest(ajaxOptions); + + abortCurrentRequests[section] = abortRequest; + + request.done((data) => { + dispatch(set({ + section, + isTesting: false, + saveError: null + })); + }); + + request.fail((xhr) => { + dispatch(set({ + section, + isTesting: false, + saveError: xhr.aborted ? null : xhr + })); + }); + }; +} + +export default createTestProviderHandler; diff --git a/frontend/src/Store/Actions/Settings/delayProfiles.js b/frontend/src/Store/Actions/Settings/delayProfiles.js new file mode 100644 index 000000000..fcb0ad0bd --- /dev/null +++ b/frontend/src/Store/Actions/Settings/delayProfiles.js @@ -0,0 +1,103 @@ +import _ from 'lodash'; +import { createAction } from 'redux-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; +import { update } from 'Store/Actions/baseActions'; + +// +// Variables + +const section = 'settings.delayProfiles'; + +// +// Actions Types + +export const FETCH_DELAY_PROFILES = 'settings/delayProfiles/fetchDelayProfiles'; +export const FETCH_DELAY_PROFILE_SCHEMA = 'settings/delayProfiles/fetchDelayProfileSchema'; +export const SAVE_DELAY_PROFILE = 'settings/delayProfiles/saveDelayProfile'; +export const DELETE_DELAY_PROFILE = 'settings/delayProfiles/deleteDelayProfile'; +export const REORDER_DELAY_PROFILE = 'settings/delayProfiles/reorderDelayProfile'; +export const SET_DELAY_PROFILE_VALUE = 'settings/delayProfiles/setDelayProfileValue'; + +// +// Action Creators + +export const fetchDelayProfiles = createThunk(FETCH_DELAY_PROFILES); +export const fetchDelayProfileSchema = createThunk(FETCH_DELAY_PROFILE_SCHEMA); +export const saveDelayProfile = createThunk(SAVE_DELAY_PROFILE); +export const deleteDelayProfile = createThunk(DELETE_DELAY_PROFILE); +export const reorderDelayProfile = createThunk(REORDER_DELAY_PROFILE); + +export const setDelayProfileValue = createAction(SET_DELAY_PROFILE_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + items: [], + isSaving: false, + saveError: null, + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_DELAY_PROFILES]: createFetchHandler(section, '/delayprofile'), + [FETCH_DELAY_PROFILE_SCHEMA]: createFetchSchemaHandler(section, '/delayprofile/schema'), + + [SAVE_DELAY_PROFILE]: createSaveProviderHandler(section, '/delayprofile'), + [DELETE_DELAY_PROFILE]: createRemoveItemHandler(section, '/delayprofile'), + + [REORDER_DELAY_PROFILE]: (getState, payload, dispatch) => { + const { id, moveIndex } = payload; + const moveOrder = moveIndex + 1; + const delayProfiles = getState().settings.delayProfiles.items; + const moving = _.find(delayProfiles, { id }); + + // Don't move if the order hasn't changed + if (moving.order === moveOrder) { + return; + } + + const after = moveIndex > 0 ? _.find(delayProfiles, { order: moveIndex }) : null; + const afterQueryParam = after ? `after=${after.id}` : ''; + + const promise = createAjaxRequest({ + method: 'PUT', + url: `/delayprofile/reorder/${id}?${afterQueryParam}` + }).request; + + promise.done((data) => { + dispatch(update({ section, data })); + }); + } + }, + + // + // Reducers + + reducers: { + [SET_DELAY_PROFILE_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/downloadClientOptions.js b/frontend/src/Store/Actions/Settings/downloadClientOptions.js new file mode 100644 index 000000000..6d4a3954d --- /dev/null +++ b/frontend/src/Store/Actions/Settings/downloadClientOptions.js @@ -0,0 +1,64 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; + +// +// Variables + +const section = 'settings.downloadClientOptions'; + +// +// Actions Types + +export const FETCH_DOWNLOAD_CLIENT_OPTIONS = 'FETCH_DOWNLOAD_CLIENT_OPTIONS'; +export const SET_DOWNLOAD_CLIENT_OPTIONS_VALUE = 'SET_DOWNLOAD_CLIENT_OPTIONS_VALUE'; +export const SAVE_DOWNLOAD_CLIENT_OPTIONS = 'SAVE_DOWNLOAD_CLIENT_OPTIONS'; + +// +// Action Creators + +export const fetchDownloadClientOptions = createThunk(FETCH_DOWNLOAD_CLIENT_OPTIONS); +export const saveDownloadClientOptions = createThunk(SAVE_DOWNLOAD_CLIENT_OPTIONS); +export const setDownloadClientOptionsValue = createAction(SET_DOWNLOAD_CLIENT_OPTIONS_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + pendingChanges: {}, + isSaving: false, + saveError: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_DOWNLOAD_CLIENT_OPTIONS]: createFetchHandler(section, '/config/downloadclient'), + [SAVE_DOWNLOAD_CLIENT_OPTIONS]: createSaveHandler(section, '/config/downloadclient') + }, + + // + // Reducers + + reducers: { + [SET_DOWNLOAD_CLIENT_OPTIONS_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/downloadClients.js b/frontend/src/Store/Actions/Settings/downloadClients.js new file mode 100644 index 000000000..a268053f7 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/downloadClients.js @@ -0,0 +1,117 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import selectProviderSchema from 'Utilities/State/selectProviderSchema'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; +import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; +import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.downloadClients'; + +// +// Actions Types + +export const FETCH_DOWNLOAD_CLIENTS = 'settings/downloadClients/fetchDownloadClients'; +export const FETCH_DOWNLOAD_CLIENT_SCHEMA = 'settings/downloadClients/fetchDownloadClientSchema'; +export const SELECT_DOWNLOAD_CLIENT_SCHEMA = 'settings/downloadClients/selectDownloadClientSchema'; +export const SET_DOWNLOAD_CLIENT_VALUE = 'settings/downloadClients/setDownloadClientValue'; +export const SET_DOWNLOAD_CLIENT_FIELD_VALUE = 'settings/downloadClients/setDownloadClientFieldValue'; +export const SAVE_DOWNLOAD_CLIENT = 'settings/downloadClients/saveDownloadClient'; +export const CANCEL_SAVE_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelSaveDownloadClient'; +export const DELETE_DOWNLOAD_CLIENT = 'settings/downloadClients/deleteDownloadClient'; +export const TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/testDownloadClient'; +export const CANCEL_TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelTestDownloadClient'; +export const TEST_ALL_DOWNLOAD_CLIENTS = 'settings/downloadClients/testAllDownloadClients'; + +// +// Action Creators + +export const fetchDownloadClients = createThunk(FETCH_DOWNLOAD_CLIENTS); +export const fetchDownloadClientSchema = createThunk(FETCH_DOWNLOAD_CLIENT_SCHEMA); +export const selectDownloadClientSchema = createAction(SELECT_DOWNLOAD_CLIENT_SCHEMA); + +export const saveDownloadClient = createThunk(SAVE_DOWNLOAD_CLIENT); +export const cancelSaveDownloadClient = createThunk(CANCEL_SAVE_DOWNLOAD_CLIENT); +export const deleteDownloadClient = createThunk(DELETE_DOWNLOAD_CLIENT); +export const testDownloadClient = createThunk(TEST_DOWNLOAD_CLIENT); +export const cancelTestDownloadClient = createThunk(CANCEL_TEST_DOWNLOAD_CLIENT); +export const testAllDownloadClients = createThunk(TEST_ALL_DOWNLOAD_CLIENTS); + +export const setDownloadClientValue = createAction(SET_DOWNLOAD_CLIENT_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const setDownloadClientFieldValue = createAction(SET_DOWNLOAD_CLIENT_FIELD_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isSchemaFetching: false, + isSchemaPopulated: false, + schemaError: null, + schema: [], + selectedSchema: {}, + isSaving: false, + saveError: null, + isTesting: false, + isTestingAll: false, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_DOWNLOAD_CLIENTS]: createFetchHandler(section, '/downloadclient'), + [FETCH_DOWNLOAD_CLIENT_SCHEMA]: createFetchSchemaHandler(section, '/downloadclient/schema'), + + [SAVE_DOWNLOAD_CLIENT]: createSaveProviderHandler(section, '/downloadclient'), + [CANCEL_SAVE_DOWNLOAD_CLIENT]: createCancelSaveProviderHandler(section), + [DELETE_DOWNLOAD_CLIENT]: createRemoveItemHandler(section, '/downloadclient'), + [TEST_DOWNLOAD_CLIENT]: createTestProviderHandler(section, '/downloadclient'), + [CANCEL_TEST_DOWNLOAD_CLIENT]: createCancelTestProviderHandler(section), + [TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient') + }, + + // + // Reducers + + reducers: { + [SET_DOWNLOAD_CLIENT_VALUE]: createSetSettingValueReducer(section), + [SET_DOWNLOAD_CLIENT_FIELD_VALUE]: createSetProviderFieldValueReducer(section), + + [SELECT_DOWNLOAD_CLIENT_SCHEMA]: (state, { payload }) => { + return selectProviderSchema(state, section, payload, (selectedSchema) => { + selectedSchema.enable = true; + + return selectedSchema; + }); + } + } + +}; diff --git a/frontend/src/Store/Actions/Settings/general.js b/frontend/src/Store/Actions/Settings/general.js new file mode 100644 index 000000000..f5e8c277e --- /dev/null +++ b/frontend/src/Store/Actions/Settings/general.js @@ -0,0 +1,64 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; + +// +// Variables + +const section = 'settings.general'; + +// +// Actions Types + +export const FETCH_GENERAL_SETTINGS = 'settings/general/fetchGeneralSettings'; +export const SET_GENERAL_SETTINGS_VALUE = 'settings/general/setGeneralSettingsValue'; +export const SAVE_GENERAL_SETTINGS = 'settings/general/saveGeneralSettings'; + +// +// Action Creators + +export const fetchGeneralSettings = createThunk(FETCH_GENERAL_SETTINGS); +export const saveGeneralSettings = createThunk(SAVE_GENERAL_SETTINGS); +export const setGeneralSettingsValue = createAction(SET_GENERAL_SETTINGS_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + pendingChanges: {}, + isSaving: false, + saveError: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_GENERAL_SETTINGS]: createFetchHandler(section, '/config/host'), + [SAVE_GENERAL_SETTINGS]: createSaveHandler(section, '/config/host') + }, + + // + // Reducers + + reducers: { + [SET_GENERAL_SETTINGS_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/importListExclusions.js b/frontend/src/Store/Actions/Settings/importListExclusions.js new file mode 100644 index 000000000..b584e9e28 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/importListExclusions.js @@ -0,0 +1,69 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.importListExclusions'; + +// +// Actions Types + +export const FETCH_IMPORT_LIST_EXCLUSIONS = 'settings/importListExclusions/fetchImportListExclusions'; +export const SAVE_IMPORT_LIST_EXCLUSION = 'settings/importListExclusions/saveImportListExclusion'; +export const DELETE_IMPORT_LIST_EXCLUSION = 'settings/importListExclusions/deleteImportListExclusion'; +export const SET_IMPORT_LIST_EXCLUSION_VALUE = 'settings/importListExclusions/setImportListExclusionValue'; + +// +// Action Creators + +export const fetchImportListExclusions = createThunk(FETCH_IMPORT_LIST_EXCLUSIONS); +export const saveImportListExclusion = createThunk(SAVE_IMPORT_LIST_EXCLUSION); +export const deleteImportListExclusion = createThunk(DELETE_IMPORT_LIST_EXCLUSION); + +export const setImportListExclusionValue = createAction(SET_IMPORT_LIST_EXCLUSION_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + items: [], + isSaving: false, + saveError: null, + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_IMPORT_LIST_EXCLUSIONS]: createFetchHandler(section, '/importlistexclusion'), + [SAVE_IMPORT_LIST_EXCLUSION]: createSaveProviderHandler(section, '/importlistexclusion'), + [DELETE_IMPORT_LIST_EXCLUSION]: createRemoveItemHandler(section, '/importlistexclusion') + }, + + // + // Reducers + + reducers: { + [SET_IMPORT_LIST_EXCLUSION_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/importLists.js b/frontend/src/Store/Actions/Settings/importLists.js new file mode 100644 index 000000000..37c634554 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/importLists.js @@ -0,0 +1,118 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import selectProviderSchema from 'Utilities/State/selectProviderSchema'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; +import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; +import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.importLists'; + +// +// Actions Types + +export const FETCH_IMPORT_LISTS = 'settings/importlists/fetchImportLists'; +export const FETCH_IMPORT_LIST_SCHEMA = 'settings/importlists/fetchImportListSchema'; +export const SELECT_IMPORT_LIST_SCHEMA = 'settings/importlists/selectImportListSchema'; +export const SET_IMPORT_LIST_VALUE = 'settings/importlists/setImportListValue'; +export const SET_IMPORT_LIST_FIELD_VALUE = 'settings/importlists/setImportListFieldValue'; +export const SAVE_IMPORT_LIST = 'settings/importlists/saveImportList'; +export const CANCEL_SAVE_IMPORT_LIST = 'settings/importlists/cancelSaveImportList'; +export const DELETE_IMPORT_LIST = 'settings/importlists/deleteImportList'; +export const TEST_IMPORT_LIST = 'settings/importlists/testImportList'; +export const CANCEL_TEST_IMPORT_LIST = 'settings/importlists/cancelTestImportList'; +export const TEST_ALL_IMPORT_LISTS = 'settings/importlists/testAllImportLists'; + +// +// Action Creators + +export const fetchImportLists = createThunk(FETCH_IMPORT_LISTS); +export const fetchImportListSchema = createThunk(FETCH_IMPORT_LIST_SCHEMA); +export const selectImportListSchema = createAction(SELECT_IMPORT_LIST_SCHEMA); + +export const saveImportList = createThunk(SAVE_IMPORT_LIST); +export const cancelSaveImportList = createThunk(CANCEL_SAVE_IMPORT_LIST); +export const deleteImportList = createThunk(DELETE_IMPORT_LIST); +export const testImportList = createThunk(TEST_IMPORT_LIST); +export const cancelTestImportList = createThunk(CANCEL_TEST_IMPORT_LIST); +export const testAllImportLists = createThunk(TEST_ALL_IMPORT_LISTS); + +export const setImportListValue = createAction(SET_IMPORT_LIST_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const setImportListFieldValue = createAction(SET_IMPORT_LIST_FIELD_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isSchemaFetching: false, + isSchemaPopulated: false, + schemaError: null, + schema: [], + selectedSchema: {}, + isSaving: false, + saveError: null, + isTesting: false, + isTestingAll: false, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_IMPORT_LISTS]: createFetchHandler(section, '/importlist'), + [FETCH_IMPORT_LIST_SCHEMA]: createFetchSchemaHandler(section, '/importlist/schema'), + + [SAVE_IMPORT_LIST]: createSaveProviderHandler(section, '/importlist'), + [CANCEL_SAVE_IMPORT_LIST]: createCancelSaveProviderHandler(section), + [DELETE_IMPORT_LIST]: createRemoveItemHandler(section, '/importlist'), + [TEST_IMPORT_LIST]: createTestProviderHandler(section, '/importlist'), + [CANCEL_TEST_IMPORT_LIST]: createCancelTestProviderHandler(section), + [TEST_ALL_IMPORT_LISTS]: createTestAllProvidersHandler(section, '/importlist') + }, + + // + // Reducers + + reducers: { + [SET_IMPORT_LIST_VALUE]: createSetSettingValueReducer(section), + [SET_IMPORT_LIST_FIELD_VALUE]: createSetProviderFieldValueReducer(section), + + [SELECT_IMPORT_LIST_SCHEMA]: (state, { payload }) => { + return selectProviderSchema(state, section, payload, (selectedSchema) => { + selectedSchema.enableAutomaticAdd = true; + selectedSchema.shouldMonitor = 'entireArtist'; + + return selectedSchema; + }); + } + } + +}; diff --git a/frontend/src/Store/Actions/Settings/indexerOptions.js b/frontend/src/Store/Actions/Settings/indexerOptions.js new file mode 100644 index 000000000..53fb21651 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/indexerOptions.js @@ -0,0 +1,64 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; + +// +// Variables + +const section = 'settings.indexerOptions'; + +// +// Actions Types + +export const FETCH_INDEXER_OPTIONS = 'settings/indexerOptions/fetchIndexerOptions'; +export const SAVE_INDEXER_OPTIONS = 'settings/indexerOptions/saveIndexerOptions'; +export const SET_INDEXER_OPTIONS_VALUE = 'settings/indexerOptions/setIndexerOptionsValue'; + +// +// Action Creators + +export const fetchIndexerOptions = createThunk(FETCH_INDEXER_OPTIONS); +export const saveIndexerOptions = createThunk(SAVE_INDEXER_OPTIONS); +export const setIndexerOptionsValue = createAction(SET_INDEXER_OPTIONS_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + pendingChanges: {}, + isSaving: false, + saveError: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_INDEXER_OPTIONS]: createFetchHandler(section, '/config/indexer'), + [SAVE_INDEXER_OPTIONS]: createSaveHandler(section, '/config/indexer') + }, + + // + // Reducers + + reducers: { + [SET_INDEXER_OPTIONS_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/indexers.js b/frontend/src/Store/Actions/Settings/indexers.js new file mode 100644 index 000000000..ddab7c154 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/indexers.js @@ -0,0 +1,119 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import selectProviderSchema from 'Utilities/State/selectProviderSchema'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; +import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; +import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.indexers'; + +// +// Actions Types + +export const FETCH_INDEXERS = 'settings/indexers/fetchIndexers'; +export const FETCH_INDEXER_SCHEMA = 'settings/indexers/fetchIndexerSchema'; +export const SELECT_INDEXER_SCHEMA = 'settings/indexers/selectIndexerSchema'; +export const SET_INDEXER_VALUE = 'settings/indexers/setIndexerValue'; +export const SET_INDEXER_FIELD_VALUE = 'settings/indexers/setIndexerFieldValue'; +export const SAVE_INDEXER = 'settings/indexers/saveIndexer'; +export const CANCEL_SAVE_INDEXER = 'settings/indexers/cancelSaveIndexer'; +export const DELETE_INDEXER = 'settings/indexers/deleteIndexer'; +export const TEST_INDEXER = 'settings/indexers/testIndexer'; +export const CANCEL_TEST_INDEXER = 'settings/indexers/cancelTestIndexer'; +export const TEST_ALL_INDEXERS = 'settings/indexers/testAllIndexers'; + +// +// Action Creators + +export const fetchIndexers = createThunk(FETCH_INDEXERS); +export const fetchIndexerSchema = createThunk(FETCH_INDEXER_SCHEMA); +export const selectIndexerSchema = createAction(SELECT_INDEXER_SCHEMA); + +export const saveIndexer = createThunk(SAVE_INDEXER); +export const cancelSaveIndexer = createThunk(CANCEL_SAVE_INDEXER); +export const deleteIndexer = createThunk(DELETE_INDEXER); +export const testIndexer = createThunk(TEST_INDEXER); +export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER); +export const testAllIndexers = createThunk(TEST_ALL_INDEXERS); + +export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const setIndexerFieldValue = createAction(SET_INDEXER_FIELD_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isSchemaFetching: false, + isSchemaPopulated: false, + schemaError: null, + schema: [], + selectedSchema: {}, + isSaving: false, + saveError: null, + isTesting: false, + isTestingAll: false, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_INDEXERS]: createFetchHandler(section, '/indexer'), + [FETCH_INDEXER_SCHEMA]: createFetchSchemaHandler(section, '/indexer/schema'), + + [SAVE_INDEXER]: createSaveProviderHandler(section, '/indexer'), + [CANCEL_SAVE_INDEXER]: createCancelSaveProviderHandler(section), + [DELETE_INDEXER]: createRemoveItemHandler(section, '/indexer'), + [TEST_INDEXER]: createTestProviderHandler(section, '/indexer'), + [CANCEL_TEST_INDEXER]: createCancelTestProviderHandler(section), + [TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer') + }, + + // + // Reducers + + reducers: { + [SET_INDEXER_VALUE]: createSetSettingValueReducer(section), + [SET_INDEXER_FIELD_VALUE]: createSetProviderFieldValueReducer(section), + + [SELECT_INDEXER_SCHEMA]: (state, { payload }) => { + return selectProviderSchema(state, section, payload, (selectedSchema) => { + selectedSchema.enableRss = selectedSchema.supportsRss; + selectedSchema.enableAutomaticSearch = selectedSchema.supportsSearch; + selectedSchema.enableInteractiveSearch = selectedSchema.supportsSearch; + + return selectedSchema; + }); + } + } + +}; diff --git a/frontend/src/Store/Actions/Settings/mediaManagement.js b/frontend/src/Store/Actions/Settings/mediaManagement.js new file mode 100644 index 000000000..4ae9eba0c --- /dev/null +++ b/frontend/src/Store/Actions/Settings/mediaManagement.js @@ -0,0 +1,64 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; + +// +// Variables + +const section = 'settings.mediaManagement'; + +// +// Actions Types + +export const FETCH_MEDIA_MANAGEMENT_SETTINGS = 'settings/mediaManagement/fetchMediaManagementSettings'; +export const SAVE_MEDIA_MANAGEMENT_SETTINGS = 'settings/mediaManagement/saveMediaManagementSettings'; +export const SET_MEDIA_MANAGEMENT_SETTINGS_VALUE = 'settings/mediaManagement/setMediaManagementSettingsValue'; + +// +// Action Creators + +export const fetchMediaManagementSettings = createThunk(FETCH_MEDIA_MANAGEMENT_SETTINGS); +export const saveMediaManagementSettings = createThunk(SAVE_MEDIA_MANAGEMENT_SETTINGS); +export const setMediaManagementSettingsValue = createAction(SET_MEDIA_MANAGEMENT_SETTINGS_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + pendingChanges: {}, + isSaving: false, + saveError: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_MEDIA_MANAGEMENT_SETTINGS]: createFetchHandler(section, '/config/mediamanagement'), + [SAVE_MEDIA_MANAGEMENT_SETTINGS]: createSaveHandler(section, '/config/mediamanagement') + }, + + // + // Reducers + + reducers: { + [SET_MEDIA_MANAGEMENT_SETTINGS_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/metadata.js b/frontend/src/Store/Actions/Settings/metadata.js new file mode 100644 index 000000000..ed5e0aa86 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/metadata.js @@ -0,0 +1,75 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; + +// +// Variables + +const section = 'settings.metadata'; + +// +// Actions Types + +export const FETCH_METADATA = 'settings/metadata/fetchMetadata'; +export const SET_METADATA_VALUE = 'settings/metadata/setMetadataValue'; +export const SET_METADATA_FIELD_VALUE = 'settings/metadata/setMetadataFieldValue'; +export const SAVE_METADATA = 'settings/metadata/saveMetadata'; + +// +// Action Creators + +export const fetchMetadata = createThunk(FETCH_METADATA); +export const saveMetadata = createThunk(SAVE_METADATA); + +export const setMetadataValue = createAction(SET_METADATA_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const setMetadataFieldValue = createAction(SET_METADATA_FIELD_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isSaving: false, + saveError: null, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_METADATA]: createFetchHandler(section, '/metadata'), + [SAVE_METADATA]: createSaveProviderHandler(section, '/metadata') + }, + + // + // Reducers + + reducers: { + [SET_METADATA_VALUE]: createSetSettingValueReducer(section), + [SET_METADATA_FIELD_VALUE]: createSetProviderFieldValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/metadataProfiles.js b/frontend/src/Store/Actions/Settings/metadataProfiles.js new file mode 100644 index 000000000..a553068d1 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/metadataProfiles.js @@ -0,0 +1,97 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.metadataProfiles'; + +// +// Actions Types + +export const FETCH_METADATA_PROFILES = 'settings/metadataProfiles/fetchMetadataProfiles'; +export const FETCH_METADATA_PROFILE_SCHEMA = 'settings/metadataProfiles/fetchMetadataProfileSchema'; +export const SAVE_METADATA_PROFILE = 'settings/metadataProfiles/saveMetadataProfile'; +export const DELETE_METADATA_PROFILE = 'settings/metadataProfiles/deleteMetadataProfile'; +export const SET_METADATA_PROFILE_VALUE = 'settings/metadataProfiles/setMetadataProfileValue'; +export const CLONE_METADATA_PROFILE = 'settings/metadataProfiles/cloneMetadataProfile'; + +// +// Action Creators + +export const fetchMetadataProfiles = createThunk(FETCH_METADATA_PROFILES); +export const fetchMetadataProfileSchema = createThunk(FETCH_METADATA_PROFILE_SCHEMA); +export const saveMetadataProfile = createThunk(SAVE_METADATA_PROFILE); +export const deleteMetadataProfile = createThunk(DELETE_METADATA_PROFILE); + +export const setMetadataProfileValue = createAction(SET_METADATA_PROFILE_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const cloneMetadataProfile = createAction(CLONE_METADATA_PROFILE); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isDeleting: false, + deleteError: null, + isSchemaFetching: false, + isSchemaPopulated: false, + schemaError: null, + schema: {}, + isSaving: false, + saveError: null, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_METADATA_PROFILES]: createFetchHandler(section, '/metadataprofile'), + [FETCH_METADATA_PROFILE_SCHEMA]: createFetchSchemaHandler(section, '/metadataprofile/schema'), + [SAVE_METADATA_PROFILE]: createSaveProviderHandler(section, '/metadataprofile'), + [DELETE_METADATA_PROFILE]: createRemoveItemHandler(section, '/metadataprofile') + }, + + // + // Reducers + + reducers: { + [SET_METADATA_PROFILE_VALUE]: createSetSettingValueReducer(section), + + [CLONE_METADATA_PROFILE]: function(state, { payload }) { + const id = payload.id; + const newState = getSectionState(state, section); + const item = newState.items.find((i) => i.id === id); + const pendingChanges = { ...item, id: 0 }; + delete pendingChanges.id; + + pendingChanges.name = `${pendingChanges.name} - Copy`; + newState.pendingChanges = pendingChanges; + + return updateSectionState(state, section, newState); + } + } + +}; diff --git a/frontend/src/Store/Actions/Settings/metadataProvider.js b/frontend/src/Store/Actions/Settings/metadataProvider.js new file mode 100644 index 000000000..32ebd88a0 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/metadataProvider.js @@ -0,0 +1,64 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; + +// +// Variables + +const section = 'settings.metadataProvider'; + +// +// Actions Types + +export const FETCH_METADATA_PROVIDER = 'settings/metadataProvider/fetchMetadataProvider'; +export const SET_METADATA_PROVIDER_VALUE = 'settings/metadataProvider/setMetadataProviderValue'; +export const SAVE_METADATA_PROVIDER = 'settings/metadataProvider/saveMetadataProvider'; + +// +// Action Creators + +export const fetchMetadataProvider = createThunk(FETCH_METADATA_PROVIDER); +export const saveMetadataProvider = createThunk(SAVE_METADATA_PROVIDER); +export const setMetadataProviderValue = createAction(SET_METADATA_PROVIDER_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + pendingChanges: {}, + isSaving: false, + saveError: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_METADATA_PROVIDER]: createFetchHandler(section, '/config/metadataProvider'), + [SAVE_METADATA_PROVIDER]: createSaveHandler(section, '/config/metadataProvider') + }, + + // + // Reducers + + reducers: { + [SET_METADATA_PROVIDER_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/naming.js b/frontend/src/Store/Actions/Settings/naming.js new file mode 100644 index 000000000..27add8309 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/naming.js @@ -0,0 +1,64 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; + +// +// Variables + +const section = 'settings.naming'; + +// +// Actions Types + +export const FETCH_NAMING_SETTINGS = 'settings/naming/fetchNamingSettings'; +export const SAVE_NAMING_SETTINGS = 'settings/naming/saveNamingSettings'; +export const SET_NAMING_SETTINGS_VALUE = 'settings/naming/setNamingSettingsValue'; + +// +// Action Creators + +export const fetchNamingSettings = createThunk(FETCH_NAMING_SETTINGS); +export const saveNamingSettings = createThunk(SAVE_NAMING_SETTINGS); +export const setNamingSettingsValue = createAction(SET_NAMING_SETTINGS_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + pendingChanges: {}, + isSaving: false, + saveError: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_NAMING_SETTINGS]: createFetchHandler(section, '/config/naming'), + [SAVE_NAMING_SETTINGS]: createSaveHandler(section, '/config/naming') + }, + + // + // Reducers + + reducers: { + [SET_NAMING_SETTINGS_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/namingExamples.js b/frontend/src/Store/Actions/Settings/namingExamples.js new file mode 100644 index 000000000..d937b8f2e --- /dev/null +++ b/frontend/src/Store/Actions/Settings/namingExamples.js @@ -0,0 +1,79 @@ +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { createThunk } from 'Store/thunks'; +import { set, update } from 'Store/Actions/baseActions'; + +// +// Variables + +const section = 'settings.namingExamples'; + +// +// Actions Types + +export const FETCH_NAMING_EXAMPLES = 'settings/namingExamples/fetchNamingExamples'; + +// +// Action Creators + +export const fetchNamingExamples = createThunk(FETCH_NAMING_EXAMPLES); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_NAMING_EXAMPLES]: function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + const naming = getState().settings.naming; + + const promise = createAjaxRequest({ + url: '/config/naming/examples', + data: Object.assign({}, naming.item, naming.pendingChanges) + }).request; + + promise.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + } + }, + + // + // Reducers + + reducers: {} + +}; diff --git a/frontend/src/Store/Actions/Settings/notifications.js b/frontend/src/Store/Actions/Settings/notifications.js new file mode 100644 index 000000000..9a267a930 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/notifications.js @@ -0,0 +1,119 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import selectProviderSchema from 'Utilities/State/selectProviderSchema'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; +import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.notifications'; + +// +// Actions Types + +export const FETCH_NOTIFICATIONS = 'settings/notifications/fetchNotifications'; +export const FETCH_NOTIFICATION_SCHEMA = 'settings/notifications/fetchNotificationSchema'; +export const SELECT_NOTIFICATION_SCHEMA = 'settings/notifications/selectNotificationSchema'; +export const SET_NOTIFICATION_VALUE = 'settings/notifications/setNotificationValue'; +export const SET_NOTIFICATION_FIELD_VALUE = 'settings/notifications/setNotificationFieldValue'; +export const SAVE_NOTIFICATION = 'settings/notifications/saveNotification'; +export const CANCEL_SAVE_NOTIFICATION = 'settings/notifications/cancelSaveNotification'; +export const DELETE_NOTIFICATION = 'settings/notifications/deleteNotification'; +export const TEST_NOTIFICATION = 'settings/notifications/testNotification'; +export const CANCEL_TEST_NOTIFICATION = 'settings/notifications/cancelTestNotification'; + +// +// Action Creators + +export const fetchNotifications = createThunk(FETCH_NOTIFICATIONS); +export const fetchNotificationSchema = createThunk(FETCH_NOTIFICATION_SCHEMA); +export const selectNotificationSchema = createAction(SELECT_NOTIFICATION_SCHEMA); + +export const saveNotification = createThunk(SAVE_NOTIFICATION); +export const cancelSaveNotification = createThunk(CANCEL_SAVE_NOTIFICATION); +export const deleteNotification = createThunk(DELETE_NOTIFICATION); +export const testNotification = createThunk(TEST_NOTIFICATION); +export const cancelTestNotification = createThunk(CANCEL_TEST_NOTIFICATION); + +export const setNotificationValue = createAction(SET_NOTIFICATION_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const setNotificationFieldValue = createAction(SET_NOTIFICATION_FIELD_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isSchemaFetching: false, + isSchemaPopulated: false, + schemaError: null, + schema: [], + selectedSchema: {}, + isSaving: false, + saveError: null, + isTesting: false, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_NOTIFICATIONS]: createFetchHandler(section, '/notification'), + [FETCH_NOTIFICATION_SCHEMA]: createFetchSchemaHandler(section, '/notification/schema'), + + [SAVE_NOTIFICATION]: createSaveProviderHandler(section, '/notification'), + [CANCEL_SAVE_NOTIFICATION]: createCancelSaveProviderHandler(section), + [DELETE_NOTIFICATION]: createRemoveItemHandler(section, '/notification'), + [TEST_NOTIFICATION]: createTestProviderHandler(section, '/notification'), + [CANCEL_TEST_NOTIFICATION]: createCancelTestProviderHandler(section) + }, + + // + // Reducers + + reducers: { + [SET_NOTIFICATION_VALUE]: createSetSettingValueReducer(section), + [SET_NOTIFICATION_FIELD_VALUE]: createSetProviderFieldValueReducer(section), + + [SELECT_NOTIFICATION_SCHEMA]: (state, { payload }) => { + return selectProviderSchema(state, section, payload, (selectedSchema) => { + selectedSchema.onGrab = selectedSchema.supportsOnGrab; + selectedSchema.onReleaseImport = selectedSchema.supportsOnReleaseImport; + selectedSchema.onUpgrade = selectedSchema.supportsOnUpgrade; + selectedSchema.onRename = selectedSchema.supportsOnRename; + selectedSchema.onHealthIssue = selectedSchema.supportsOnHealthIssue; + selectedSchema.onDownloadFailure = selectedSchema.supportsOnDownloadFailure; + selectedSchema.onImportFailure = selectedSchema.supportsOnImportFailure; + selectedSchema.onTrackRetag = selectedSchema.supportsOnTrackRetag; + + return selectedSchema; + }); + } + } + +}; diff --git a/frontend/src/Store/Actions/Settings/qualityDefinitions.js b/frontend/src/Store/Actions/Settings/qualityDefinitions.js new file mode 100644 index 000000000..ef5d0a757 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/qualityDefinitions.js @@ -0,0 +1,135 @@ +import _ from 'lodash'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import { createThunk } from 'Store/thunks'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; +import { clearPendingChanges, set, update } from 'Store/Actions/baseActions'; + +// +// Variables + +const section = 'settings.qualityDefinitions'; + +// +// Actions Types + +export const FETCH_QUALITY_DEFINITIONS = 'settings/qualityDefinitions/fetchQualityDefinitions'; +export const SAVE_QUALITY_DEFINITIONS = 'settings/qualityDefinitions/saveQualityDefinitions'; +export const SET_QUALITY_DEFINITION_VALUE = 'settings/qualityDefinitions/setQualityDefinitionValue'; + +// +// Action Creators + +export const fetchQualityDefinitions = createThunk(FETCH_QUALITY_DEFINITIONS); +export const saveQualityDefinitions = createThunk(SAVE_QUALITY_DEFINITIONS); + +export const setQualityDefinitionValue = createAction(SET_QUALITY_DEFINITION_VALUE); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + items: [], + isSaving: false, + saveError: null, + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_QUALITY_DEFINITIONS]: createFetchHandler(section, '/qualitydefinition'), + [SAVE_QUALITY_DEFINITIONS]: createSaveHandler(section, '/qualitydefinition'), + + [SAVE_QUALITY_DEFINITIONS]: function(getState, payload, dispatch) { + const qualityDefinitions = getState().settings.qualityDefinitions; + + const upatedDefinitions = Object.keys(qualityDefinitions.pendingChanges).map((key) => { + const id = parseInt(key); + const pendingChanges = qualityDefinitions.pendingChanges[id] || {}; + const item = _.find(qualityDefinitions.items, { id }); + + return Object.assign({}, item, pendingChanges); + }); + + // If there is nothing to save don't bother isSaving + if (!upatedDefinitions || !upatedDefinitions.length) { + return; + } + + dispatch(set({ + section, + isSaving: true + })); + + const promise = createAjaxRequest({ + method: 'PUT', + url: '/qualityDefinition/update', + data: JSON.stringify(upatedDefinitions) + }).request; + + promise.done((data) => { + dispatch(batchActions([ + set({ + section, + isSaving: false, + saveError: null + }), + + update({ section, data }), + clearPendingChanges({ section }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + } + }, + + // + // Reducers + + reducers: { + [SET_QUALITY_DEFINITION_VALUE]: function(state, { payload }) { + const { id, name, value } = payload; + const newState = getSectionState(state, section); + newState.pendingChanges = _.cloneDeep(newState.pendingChanges); + + const pendingState = newState.pendingChanges[id] || {}; + const currentValue = _.find(newState.items, { id })[name]; + + if (currentValue === value) { + delete pendingState[name]; + } else { + pendingState[name] = value; + } + + if (_.isEmpty(pendingState)) { + delete newState.pendingChanges[id]; + } else { + newState.pendingChanges[id] = pendingState; + } + + return updateSectionState(state, section, newState); + } + } + +}; diff --git a/frontend/src/Store/Actions/Settings/qualityProfiles.js b/frontend/src/Store/Actions/Settings/qualityProfiles.js new file mode 100644 index 000000000..6fdc204a0 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/qualityProfiles.js @@ -0,0 +1,97 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.qualityProfiles'; + +// +// Actions Types + +export const FETCH_QUALITY_PROFILES = 'settings/qualityProfiles/fetchQualityProfiles'; +export const FETCH_QUALITY_PROFILE_SCHEMA = 'settings/qualityProfiles/fetchQualityProfileSchema'; +export const SAVE_QUALITY_PROFILE = 'settings/qualityProfiles/saveQualityProfile'; +export const DELETE_QUALITY_PROFILE = 'settings/qualityProfiles/deleteQualityProfile'; +export const SET_QUALITY_PROFILE_VALUE = 'settings/qualityProfiles/setQualityProfileValue'; +export const CLONE_QUALITY_PROFILE = 'settings/qualityProfiles/cloneQualityProfile'; + +// +// Action Creators + +export const fetchQualityProfiles = createThunk(FETCH_QUALITY_PROFILES); +export const fetchQualityProfileSchema = createThunk(FETCH_QUALITY_PROFILE_SCHEMA); +export const saveQualityProfile = createThunk(SAVE_QUALITY_PROFILE); +export const deleteQualityProfile = createThunk(DELETE_QUALITY_PROFILE); + +export const setQualityProfileValue = createAction(SET_QUALITY_PROFILE_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const cloneQualityProfile = createAction(CLONE_QUALITY_PROFILE); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isDeleting: false, + deleteError: null, + isSchemaFetching: false, + isSchemaPopulated: false, + schemaError: null, + schema: {}, + isSaving: false, + saveError: null, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_QUALITY_PROFILES]: createFetchHandler(section, '/qualityprofile'), + [FETCH_QUALITY_PROFILE_SCHEMA]: createFetchSchemaHandler(section, '/qualityprofile/schema'), + [SAVE_QUALITY_PROFILE]: createSaveProviderHandler(section, '/qualityprofile'), + [DELETE_QUALITY_PROFILE]: createRemoveItemHandler(section, '/qualityprofile') + }, + + // + // Reducers + + reducers: { + [SET_QUALITY_PROFILE_VALUE]: createSetSettingValueReducer(section), + + [CLONE_QUALITY_PROFILE]: function(state, { payload }) { + const id = payload.id; + const newState = getSectionState(state, section); + const item = newState.items.find((i) => i.id === id); + const pendingChanges = { ...item, id: 0 }; + delete pendingChanges.id; + + pendingChanges.name = `${pendingChanges.name} - Copy`; + newState.pendingChanges = pendingChanges; + + return updateSectionState(state, section, newState); + } + } + +}; diff --git a/frontend/src/Store/Actions/Settings/releaseProfiles.js b/frontend/src/Store/Actions/Settings/releaseProfiles.js new file mode 100644 index 000000000..339e732f6 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/releaseProfiles.js @@ -0,0 +1,71 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.releaseProfiles'; + +// +// Actions Types + +export const FETCH_RELEASE_PROFILES = 'settings/releaseProfiles/fetchReleaseProfiles'; +export const SAVE_RELEASE_PROFILE = 'settings/releaseProfiles/saveReleaseProfile'; +export const DELETE_RELEASE_PROFILE = 'settings/releaseProfiles/deleteReleaseProfile'; +export const SET_RELEASE_PROFILE_VALUE = 'settings/releaseProfiles/setReleaseProfileValue'; + +// +// Action Creators + +export const fetchReleaseProfiles = createThunk(FETCH_RELEASE_PROFILES); +export const saveReleaseProfile = createThunk(SAVE_RELEASE_PROFILE); +export const deleteReleaseProfile = createThunk(DELETE_RELEASE_PROFILE); + +export const setReleaseProfileValue = createAction(SET_RELEASE_PROFILE_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isSaving: false, + saveError: null, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_RELEASE_PROFILES]: createFetchHandler(section, '/releaseprofile'), + + [SAVE_RELEASE_PROFILE]: createSaveProviderHandler(section, '/releaseprofile'), + + [DELETE_RELEASE_PROFILE]: createRemoveItemHandler(section, '/releaseprofile') + }, + + // + // Reducers + + reducers: { + [SET_RELEASE_PROFILE_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/remotePathMappings.js b/frontend/src/Store/Actions/Settings/remotePathMappings.js new file mode 100644 index 000000000..3cfcc7f1f --- /dev/null +++ b/frontend/src/Store/Actions/Settings/remotePathMappings.js @@ -0,0 +1,69 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.remotePathMappings'; + +// +// Actions Types + +export const FETCH_REMOTE_PATH_MAPPINGS = 'settings/remotePathMappings/fetchRemotePathMappings'; +export const SAVE_REMOTE_PATH_MAPPING = 'settings/remotePathMappings/saveRemotePathMapping'; +export const DELETE_REMOTE_PATH_MAPPING = 'settings/remotePathMappings/deleteRemotePathMapping'; +export const SET_REMOTE_PATH_MAPPING_VALUE = 'settings/remotePathMappings/setRemotePathMappingValue'; + +// +// Action Creators + +export const fetchRemotePathMappings = createThunk(FETCH_REMOTE_PATH_MAPPINGS); +export const saveRemotePathMapping = createThunk(SAVE_REMOTE_PATH_MAPPING); +export const deleteRemotePathMapping = createThunk(DELETE_REMOTE_PATH_MAPPING); + +export const setRemotePathMappingValue = createAction(SET_REMOTE_PATH_MAPPING_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + items: [], + isSaving: false, + saveError: null, + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_REMOTE_PATH_MAPPINGS]: createFetchHandler(section, '/remotepathmapping'), + [SAVE_REMOTE_PATH_MAPPING]: createSaveProviderHandler(section, '/remotepathmapping'), + [DELETE_REMOTE_PATH_MAPPING]: createRemoveItemHandler(section, '/remotepathmapping') + }, + + // + // Reducers + + reducers: { + [SET_REMOTE_PATH_MAPPING_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/ui.js b/frontend/src/Store/Actions/Settings/ui.js new file mode 100644 index 000000000..97d7223fd --- /dev/null +++ b/frontend/src/Store/Actions/Settings/ui.js @@ -0,0 +1,64 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; + +// +// Variables + +const section = 'settings.ui'; + +// +// Actions Types + +export const FETCH_UI_SETTINGS = 'settings/ui/fetchUiSettings'; +export const SET_UI_SETTINGS_VALUE = 'SET_UI_SETTINGS_VALUE'; +export const SAVE_UI_SETTINGS = 'SAVE_UI_SETTINGS'; + +// +// Action Creators + +export const fetchUISettings = createThunk(FETCH_UI_SETTINGS); +export const saveUISettings = createThunk(SAVE_UI_SETTINGS); +export const setUISettingsValue = createAction(SET_UI_SETTINGS_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + pendingChanges: {}, + isSaving: false, + saveError: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_UI_SETTINGS]: createFetchHandler(section, '/config/ui'), + [SAVE_UI_SETTINGS]: createSaveHandler(section, '/config/ui') + }, + + // + // Reducers + + reducers: { + [SET_UI_SETTINGS_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/actionTypes.js b/frontend/src/Store/Actions/actionTypes.js new file mode 100644 index 000000000..dcb13d86d --- /dev/null +++ b/frontend/src/Store/Actions/actionTypes.js @@ -0,0 +1,16 @@ +// +// App + +export const SHOW_MESSAGE = 'SHOW_MESSAGE'; +export const HIDE_MESSAGE = 'HIDE_MESSAGE'; +export const SAVE_DIMENSIONS = 'SAVE_DIMENSIONS'; +export const SET_VERSION = 'SET_VERSION'; +export const SET_APP_VALUE = 'SET_APP_VALUE'; +export const SET_IS_SIDEBAR_VISIBLE = 'SET_IS_SIDEBAR_VISIBLE'; + +// +// Settings + +export const FETCH_GENERAL_SETTINGS = 'settings/general/fetchGeneralSettings'; +export const SET_GENERAL_SETTINGS_VALUE = 'settings/general/setGeneralSettingsValue'; +export const SAVE_GENERAL_SETTINGS = 'settings/general/saveGeneralSettings'; diff --git a/frontend/src/Store/Actions/addArtistActions.js b/frontend/src/Store/Actions/addArtistActions.js new file mode 100644 index 000000000..44496f6d5 --- /dev/null +++ b/frontend/src/Store/Actions/addArtistActions.js @@ -0,0 +1,179 @@ +import _ from 'lodash'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import monitorOptions from 'Utilities/Artist/monitorOptions'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import getNewArtist from 'Utilities/Artist/getNewArtist'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer'; +import createHandleActions from './Creators/createHandleActions'; +import { set, update, updateItem } from './baseActions'; + +// +// Variables + +export const section = 'addArtist'; +let abortCurrentRequest = null; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + isAdding: false, + isAdded: false, + addError: null, + items: [], + + defaults: { + rootFolderPath: '', + monitor: monitorOptions[0].key, + qualityProfileId: 0, + metadataProfileId: 0, + albumFolder: true, + tags: [] + } +}; + +export const persistState = [ + 'addArtist.defaults' +]; + +// +// Actions Types + +export const LOOKUP_ARTIST = 'addArtist/lookupArtist'; +export const ADD_ARTIST = 'addArtist/addArtist'; +export const SET_ADD_ARTIST_VALUE = 'addArtist/setAddArtistValue'; +export const CLEAR_ADD_ARTIST = 'addArtist/clearAddArtist'; +export const SET_ADD_ARTIST_DEFAULT = 'addArtist/setAddArtistDefault'; + +// +// Action Creators + +export const lookupArtist = createThunk(LOOKUP_ARTIST); +export const addArtist = createThunk(ADD_ARTIST); +export const clearAddArtist = createAction(CLEAR_ADD_ARTIST); +export const setAddArtistDefault = createAction(SET_ADD_ARTIST_DEFAULT); + +export const setAddArtistValue = createAction(SET_ADD_ARTIST_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [LOOKUP_ARTIST]: function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + if (abortCurrentRequest) { + abortCurrentRequest(); + } + + const { request, abortRequest } = createAjaxRequest({ + url: '/artist/lookup', + data: { + term: payload.term + } + }); + + abortCurrentRequest = abortRequest; + + request.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + request.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr.aborted ? null : xhr + })); + }); + }, + + [ADD_ARTIST]: function(getState, payload, dispatch) { + dispatch(set({ section, isAdding: true })); + + const foreignArtistId = payload.foreignArtistId; + const items = getState().addArtist.items; + const newArtist = getNewArtist(_.cloneDeep(_.find(items, { foreignArtistId })), payload); + + const promise = createAjaxRequest({ + url: '/artist', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify(newArtist) + }).request; + + promise.done((data) => { + dispatch(batchActions([ + updateItem({ section: 'artist', ...data }), + + set({ + section, + isAdding: false, + isAdded: true, + addError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isAdding: false, + isAdded: false, + addError: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_ADD_ARTIST_VALUE]: createSetSettingValueReducer(section), + + [SET_ADD_ARTIST_DEFAULT]: function(state, { payload }) { + const newState = getSectionState(state, section); + + newState.defaults = { + ...newState.defaults, + ...payload + }; + + return updateSectionState(state, section, newState); + }, + + [CLEAR_ADD_ARTIST]: function(state) { + const { + defaults, + ...otherDefaultState + } = defaultState; + + return Object.assign({}, state, otherDefaultState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/albumActions.js b/frontend/src/Store/Actions/albumActions.js new file mode 100644 index 000000000..9af72a9f1 --- /dev/null +++ b/frontend/src/Store/Actions/albumActions.js @@ -0,0 +1,256 @@ +import _ from 'lodash'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import createSaveProviderHandler from './Creators/createSaveProviderHandler'; +import albumEntities from 'Album/albumEntities'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; +import { updateItem } from './baseActions'; + +// +// Variables + +export const section = 'albums'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + isSaving: false, + saveError: null, + sortKey: 'releaseDate', + sortDirection: sortDirections.DESCENDING, + items: [], + pendingChanges: {}, + sortPredicates: { + rating: function(item) { + return item.ratings.value; + } + }, + + columns: [ + { + name: 'monitored', + columnLabel: 'Monitored', + isVisible: true, + isModifiable: false + }, + { + name: 'title', + label: 'Title', + isSortable: true, + isVisible: true + }, + { + name: 'releaseDate', + label: 'Release Date', + isSortable: true, + isVisible: true + }, + { + name: 'secondaryTypes', + label: 'Secondary Types', + isSortable: true, + isVisible: false + }, + { + name: 'mediumCount', + label: 'Media Count', + isVisible: false + }, + { + name: 'trackCount', + label: 'Track Count', + isVisible: false + }, + { + name: 'duration', + label: 'Duration', + isSortable: true, + isVisible: false + }, + { + name: 'rating', + label: 'Rating', + isSortable: true, + isVisible: true + }, + { + name: 'status', + label: 'Status', + isVisible: true + }, + { + name: 'actions', + columnLabel: 'Actions', + isVisible: true, + isModifiable: false + } + ] +}; + +export const persistState = [ + 'albums.sortKey', + 'albums.sortDirection', + 'albums.columns' +]; + +// +// Actions Types + +export const FETCH_ALBUMS = 'albums/fetchAlbums'; +export const SET_ALBUMS_SORT = 'albums/setAlbumsSort'; +export const SET_ALBUMS_TABLE_OPTION = 'albums/setAlbumsTableOption'; +export const CLEAR_ALBUMS = 'albums/clearAlbums'; +export const SET_ALBUM_VALUE = 'albums/setAlbumValue'; +export const SAVE_ALBUM = 'albums/saveAlbum'; +export const TOGGLE_ALBUM_MONITORED = 'albums/toggleAlbumMonitored'; +export const TOGGLE_ALBUMS_MONITORED = 'albums/toggleAlbumsMonitored'; + +// +// Action Creators + +export const fetchAlbums = createThunk(FETCH_ALBUMS); +export const setAlbumsSort = createAction(SET_ALBUMS_SORT); +export const setAlbumsTableOption = createAction(SET_ALBUMS_TABLE_OPTION); +export const clearAlbums = createAction(CLEAR_ALBUMS); +export const toggleAlbumMonitored = createThunk(TOGGLE_ALBUM_MONITORED); +export const toggleAlbumsMonitored = createThunk(TOGGLE_ALBUMS_MONITORED); + +export const saveAlbum = createThunk(SAVE_ALBUM); + +export const setAlbumValue = createAction(SET_ALBUM_VALUE, (payload) => { + return { + section: 'albums', + ...payload + }; +}); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_ALBUMS]: createFetchHandler(section, '/album'), + [SAVE_ALBUM]: createSaveProviderHandler(section, '/album'), + + [TOGGLE_ALBUM_MONITORED]: function(getState, payload, dispatch) { + const { + albumId, + albumEntity = albumEntities.ALBUMS, + monitored + } = payload; + + const albumSection = _.last(albumEntity.split('.')); + + dispatch(updateItem({ + id: albumId, + section: albumSection, + isSaving: true + })); + + const promise = createAjaxRequest({ + url: `/album/${albumId}`, + method: 'PUT', + data: JSON.stringify({ monitored }), + dataType: 'json' + }).request; + + promise.done((data) => { + dispatch(updateItem({ + id: albumId, + section: albumSection, + isSaving: false, + monitored + })); + }); + + promise.fail((xhr) => { + dispatch(updateItem({ + id: albumId, + section: albumSection, + isSaving: false + })); + }); + }, + + [TOGGLE_ALBUMS_MONITORED]: function(getState, payload, dispatch) { + const { + albumIds, + albumEntity = albumEntities.ALBUMS, + monitored + } = payload; + + dispatch(batchActions( + albumIds.map((albumId) => { + return updateItem({ + id: albumId, + section: albumEntity, + isSaving: true + }); + }) + )); + + const promise = createAjaxRequest({ + url: '/album/monitor', + method: 'PUT', + data: JSON.stringify({ albumIds, monitored }), + dataType: 'json' + }).request; + + promise.done((data) => { + dispatch(batchActions( + albumIds.map((albumId) => { + return updateItem({ + id: albumId, + section: albumEntity, + isSaving: false, + monitored + }); + }) + )); + }); + + promise.fail((xhr) => { + dispatch(batchActions( + albumIds.map((albumId) => { + return updateItem({ + id: albumId, + section: albumEntity, + isSaving: false + }); + }) + )); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_ALBUMS_SORT]: createSetClientSideCollectionSortReducer(section), + + [SET_ALBUMS_TABLE_OPTION]: createSetTableOptionReducer(section), + + [SET_ALBUM_VALUE]: createSetSettingValueReducer(section), + + [CLEAR_ALBUMS]: (state) => { + return Object.assign({}, state, { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/albumHistoryActions.js b/frontend/src/Store/Actions/albumHistoryActions.js new file mode 100644 index 000000000..a0c832784 --- /dev/null +++ b/frontend/src/Store/Actions/albumHistoryActions.js @@ -0,0 +1,112 @@ +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { createThunk, handleThunks } from 'Store/thunks'; +import { sortDirections } from 'Helpers/Props'; +import createHandleActions from './Creators/createHandleActions'; +import { set, update } from './baseActions'; + +// +// Variables + +export const section = 'albumHistory'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [] +}; + +// +// Actions Types + +export const FETCH_ALBUM_HISTORY = 'albumHistory/fetchAlbumHistory'; +export const CLEAR_ALBUM_HISTORY = 'albumHistory/clearAlbumHistory'; +export const ALBUM_HISTORY_MARK_AS_FAILED = 'albumHistory/albumHistoryMarkAsFailed'; + +// +// Action Creators + +export const fetchAlbumHistory = createThunk(FETCH_ALBUM_HISTORY); +export const clearAlbumHistory = createAction(CLEAR_ALBUM_HISTORY); +export const albumHistoryMarkAsFailed = createThunk(ALBUM_HISTORY_MARK_AS_FAILED); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_ALBUM_HISTORY]: function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + const queryParams = { + pageSize: 1000, + page: 1, + sortKey: 'date', + sortDirection: sortDirections.DESCENDING, + albumId: payload.albumId + }; + + const promise = createAjaxRequest({ + url: '/history', + data: queryParams + }).request; + + promise.done((data) => { + dispatch(batchActions([ + update({ section, data: data.records }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + }, + + [ALBUM_HISTORY_MARK_AS_FAILED]: function(getState, payload, dispatch) { + const { + historyId, + albumId + } = payload; + + const promise = createAjaxRequest({ + url: '/history/failed', + method: 'POST', + data: { + id: historyId + } + }).request; + + promise.done(() => { + dispatch(fetchAlbumHistory({ albumId })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_ALBUM_HISTORY]: (state) => { + return Object.assign({}, state, defaultState); + } + +}, defaultState, section); + diff --git a/frontend/src/Store/Actions/albumStudioActions.js b/frontend/src/Store/Actions/albumStudioActions.js new file mode 100644 index 000000000..5225c27cf --- /dev/null +++ b/frontend/src/Store/Actions/albumStudioActions.js @@ -0,0 +1,164 @@ +import { createAction } from 'redux-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; +import createHandleActions from './Creators/createHandleActions'; +import { set } from './baseActions'; +import { fetchAlbums } from './albumActions'; +import { filters, filterPredicates } from './artistActions'; + +// +// Variables + +export const section = 'albumStudio'; + +// +// State + +export const defaultState = { + isSaving: false, + saveError: null, + sortKey: 'sortName', + sortDirection: sortDirections.ASCENDING, + secondarySortKey: 'sortName', + secondarySortDirection: sortDirections.ASCENDING, + selectedFilterKey: 'all', + filters, + filterPredicates, + + filterBuilderProps: [ + { + name: 'monitored', + label: 'Monitored', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.BOOL + }, + { + name: 'status', + label: 'Status', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.ARTIST_STATUS + }, + { + name: 'artistType', + label: 'Artist Type', + type: filterBuilderTypes.EXACT + }, + { + name: 'qualityProfileId', + label: 'Quality Profile', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.QUALITY_PROFILE + }, + { + name: 'metadataProfileId', + label: 'Metadata Profile', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.METADATA_PROFILE + }, + { + name: 'rootFolderPath', + label: 'Root Folder Path', + type: filterBuilderTypes.EXACT + }, + { + name: 'tags', + label: 'Tags', + type: filterBuilderTypes.ARRAY, + valueType: filterBuilderValueTypes.TAG + } + ] +}; + +export const persistState = [ + 'albumStudio.sortKey', + 'albumStudio.sortDirection', + 'albumStudio.selectedFilterKey', + 'albumStudio.customFilters' +]; + +// +// Actions Types + +export const SET_ALBUM_STUDIO_SORT = 'albumStudio/setAlbumStudioSort'; +export const SET_ALBUM_STUDIO_FILTER = 'albumStudio/setAlbumStudioFilter'; +export const SAVE_ALBUM_STUDIO = 'albumStudio/saveAlbumStudio'; + +// +// Action Creators + +export const setAlbumStudioSort = createAction(SET_ALBUM_STUDIO_SORT); +export const setAlbumStudioFilter = createAction(SET_ALBUM_STUDIO_FILTER); +export const saveAlbumStudio = createThunk(SAVE_ALBUM_STUDIO); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [SAVE_ALBUM_STUDIO]: function(getState, payload, dispatch) { + const { + artistIds, + monitored, + monitor + } = payload; + + const artist = []; + + artistIds.forEach((id) => { + const artistToUpdate = { id }; + + if (payload.hasOwnProperty('monitored')) { + artistToUpdate.monitored = monitored; + } + + artist.push(artistToUpdate); + }); + + dispatch(set({ + section, + isSaving: true + })); + + const promise = createAjaxRequest({ + url: '/albumStudio', + method: 'POST', + data: JSON.stringify({ + artist, + monitoringOptions: { monitor } + }), + dataType: 'json' + }).request; + + promise.done((data) => { + dispatch(fetchAlbums()); + + dispatch(set({ + section, + isSaving: false, + saveError: null + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_ALBUM_STUDIO_SORT]: createSetClientSideCollectionSortReducer(section), + [SET_ALBUM_STUDIO_FILTER]: createSetClientSideCollectionFilterReducer(section) + +}, defaultState, section); + diff --git a/frontend/src/Store/Actions/appActions.js b/frontend/src/Store/Actions/appActions.js new file mode 100644 index 000000000..b4b84a455 --- /dev/null +++ b/frontend/src/Store/Actions/appActions.js @@ -0,0 +1,135 @@ +import _ from 'lodash'; +import { createAction } from 'redux-actions'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import createHandleActions from './Creators/createHandleActions'; + +function getDimensions(width, height) { + const dimensions = { + width, + height, + isExtraSmallScreen: width <= 480, + isSmallScreen: width <= 768, + isMediumScreen: width <= 992, + isLargeScreen: width <= 1200 + }; + + return dimensions; +} + +// +// Variables + +export const section = 'app'; +const messagesSection = 'app.messages'; + +// +// State + +export const defaultState = { + dimensions: getDimensions(window.innerWidth, window.innerHeight), + messages: { + items: [] + }, + version: window.Lidarr.version, + isUpdated: false, + isConnected: true, + isReconnecting: false, + isDisconnected: false, + isRestarting: false, + isSidebarVisible: !getDimensions(window.innerWidth, window.innerHeight).isSmallScreen +}; + +// +// Action Types + +export const SHOW_MESSAGE = 'app/showMessage'; +export const HIDE_MESSAGE = 'app/hideMessage'; +export const SAVE_DIMENSIONS = 'app/saveDimensions'; +export const SET_VERSION = 'app/setVersion'; +export const SET_APP_VALUE = 'app/setAppValue'; +export const SET_IS_SIDEBAR_VISIBLE = 'app/setIsSidebarVisible'; + +// +// Action Creators + +export const saveDimensions = createAction(SAVE_DIMENSIONS); +export const setVersion = createAction(SET_VERSION); +export const setIsSidebarVisible = createAction(SET_IS_SIDEBAR_VISIBLE); +export const setAppValue = createAction(SET_APP_VALUE); +export const showMessage = createAction(SHOW_MESSAGE); +export const hideMessage = createAction(HIDE_MESSAGE); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SAVE_DIMENSIONS]: function(state, { payload }) { + const { + width, + height + } = payload; + + const dimensions = getDimensions(width, height); + + return Object.assign({}, state, { dimensions }); + }, + + [SHOW_MESSAGE]: function(state, { payload }) { + const newState = getSectionState(state, messagesSection); + const items = newState.items; + const index = _.findIndex(items, { id: payload.id }); + + newState.items = [...items]; + + if (index >= 0) { + const item = items[index]; + + newState.items.splice(index, 1, { ...item, ...payload }); + } else { + newState.items.push({ ...payload }); + } + + return updateSectionState(state, messagesSection, newState); + }, + + [HIDE_MESSAGE]: function(state, { payload }) { + const newState = getSectionState(state, messagesSection); + + newState.items = [...newState.items]; + _.remove(newState.items, { id: payload.id }); + + return updateSectionState(state, messagesSection, newState); + }, + + [SET_APP_VALUE]: function(state, { payload }) { + const newState = Object.assign(getSectionState(state, section), payload); + + return updateSectionState(state, section, newState); + }, + + [SET_VERSION]: function(state, { payload }) { + const version = payload.version; + + const newState = { + version + }; + + if (state.version !== version) { + newState.isUpdated = true; + } + + return Object.assign({}, state, newState); + }, + + [SET_IS_SIDEBAR_VISIBLE]: function(state, { payload }) { + const newState = { + isSidebarVisible: payload.isSidebarVisible + }; + + return Object.assign({}, state, newState); + } + +}, defaultState, section); + diff --git a/frontend/src/Store/Actions/artistActions.js b/frontend/src/Store/Actions/artistActions.js new file mode 100644 index 000000000..a47dfe272 --- /dev/null +++ b/frontend/src/Store/Actions/artistActions.js @@ -0,0 +1,336 @@ +import _ from 'lodash'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import dateFilterPredicate from 'Utilities/Date/dateFilterPredicate'; +import { filterTypePredicates, filterTypes, sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from './Creators/createFetchHandler'; +import createSaveProviderHandler from './Creators/createSaveProviderHandler'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import createHandleActions from './Creators/createHandleActions'; +import { updateItem } from './baseActions'; + +// +// Variables + +export const section = 'artist'; + +export const filters = [ + { + key: 'all', + label: 'All', + filters: [] + }, + { + key: 'monitored', + label: 'Monitored Only', + filters: [ + { + key: 'monitored', + value: true, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'unmonitored', + label: 'Unmonitored Only', + filters: [ + { + key: 'monitored', + value: false, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'continuing', + label: 'Continuing Only', + filters: [ + { + key: 'status', + value: 'continuing', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'ended', + label: 'Ended Only', + filters: [ + { + key: 'status', + value: 'ended', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'missing', + label: 'Missing Tracks', + filters: [ + { + key: 'missing', + value: true, + type: filterTypes.EQUAL + } + ] + } +]; + +export const filterPredicates = { + missing: function(item) { + const { statistics = {} } = item; + + return statistics.trackCount - statistics.trackFileCount > 0; + }, + + nextAlbum: function(item, filterValue, type) { + return dateFilterPredicate(item.nextAlbum, filterValue, type); + }, + + lastAlbum: function(item, filterValue, type) { + return dateFilterPredicate(item.lastAlbum, filterValue, type); + }, + + added: function(item, filterValue, type) { + return dateFilterPredicate(item.added, filterValue, type); + }, + + ratings: function(item, filterValue, type) { + const predicate = filterTypePredicates[type]; + + return predicate(item.ratings.value * 10, filterValue); + }, + + albumCount: function(item, filterValue, type) { + const predicate = filterTypePredicates[type]; + const albumCount = item.statistics ? item.statistics.albumCount : 0; + + return predicate(albumCount, filterValue); + }, + + sizeOnDisk: function(item, filterValue, type) { + const predicate = filterTypePredicates[type]; + const sizeOnDisk = item.statistics ? item.statistics.sizeOnDisk : 0; + + return predicate(sizeOnDisk, filterValue); + } +}; + +export const sortPredicates = { + status: function(item) { + let result = 0; + + if (item.monitored) { + result += 2; + } + + if (item.status === 'continuing') { + result++; + } + + return result; + } +}; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + isSaving: false, + saveError: null, + items: [], + sortKey: 'sortName', + sortDirection: sortDirections.ASCENDING, + pendingChanges: {} +}; + +// +// Actions Types + +export const FETCH_ARTIST = 'artist/fetchArtist'; +export const SET_ARTIST_VALUE = 'artist/setArtistValue'; +export const SAVE_ARTIST = 'artist/saveArtist'; +export const DELETE_ARTIST = 'artist/deleteArtist'; + +export const TOGGLE_ARTIST_MONITORED = 'artist/toggleArtistMonitored'; +export const TOGGLE_ALBUM_MONITORED = 'artist/toggleAlbumMonitored'; + +// +// Action Creators + +export const fetchArtist = createThunk(FETCH_ARTIST); +export const saveArtist = createThunk(SAVE_ARTIST, (payload) => { + const newPayload = { + ...payload + }; + + if (payload.moveFiles) { + newPayload.queryParams = { + moveFiles: true + }; + } + + delete newPayload.moveFiles; + + return newPayload; +}); + +export const deleteArtist = createThunk(DELETE_ARTIST, (payload) => { + return { + ...payload, + queryParams: { + deleteFiles: payload.deleteFiles, + addImportListExclusion: payload.addImportListExclusion + } + }; +}); + +export const toggleArtistMonitored = createThunk(TOGGLE_ARTIST_MONITORED); +export const toggleAlbumMonitored = createThunk(TOGGLE_ALBUM_MONITORED); + +export const setArtistValue = createAction(SET_ARTIST_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Helpers + +function getSaveAjaxOptions({ ajaxOptions, payload }) { + if (payload.moveFolder) { + ajaxOptions.url = `${ajaxOptions.url}?moveFolder=true`; + } + + return ajaxOptions; +} + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_ARTIST]: createFetchHandler(section, '/artist'), + [SAVE_ARTIST]: createSaveProviderHandler(section, '/artist', { getAjaxOptions: getSaveAjaxOptions }), + [DELETE_ARTIST]: createRemoveItemHandler(section, '/artist'), + + [TOGGLE_ARTIST_MONITORED]: (getState, payload, dispatch) => { + const { + artistId: id, + monitored + } = payload; + + const artist = _.find(getState().artist.items, { id }); + + dispatch(updateItem({ + id, + section, + isSaving: true + })); + + const promise = createAjaxRequest({ + url: `/artist/${id}`, + method: 'PUT', + data: JSON.stringify({ + ...artist, + monitored + }), + dataType: 'json' + }).request; + + promise.done((data) => { + dispatch(updateItem({ + id, + section, + isSaving: false, + monitored + })); + }); + + promise.fail((xhr) => { + dispatch(updateItem({ + id, + section, + isSaving: false + })); + }); + }, + + [TOGGLE_ALBUM_MONITORED]: function(getState, payload, dispatch) { + const { + artistId: id, + seasonNumber, + monitored + } = payload; + + const artist = _.find(getState().artist.items, { id }); + const seasons = _.cloneDeep(artist.seasons); + const season = _.find(seasons, { seasonNumber }); + + season.isSaving = true; + + dispatch(updateItem({ + id, + section, + seasons + })); + + season.monitored = monitored; + + const promise = createAjaxRequest({ + url: `/artist/${id}`, + method: 'PUT', + data: JSON.stringify({ + ...artist, + seasons + }), + dataType: 'json' + }).request; + + promise.done((data) => { + const albums = _.filter(getState().albums.items, { artistId: id, seasonNumber }); + + dispatch(batchActions([ + updateItem({ + id, + section, + ...data + }), + + ...albums.map((album) => { + return updateItem({ + id: album.id, + section: 'albums', + monitored + }); + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(updateItem({ + id, + section, + seasons: artist.seasons + })); + }); + } + +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_ARTIST_VALUE]: createSetSettingValueReducer(section) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/artistEditorActions.js b/frontend/src/Store/Actions/artistEditorActions.js new file mode 100644 index 000000000..238419df0 --- /dev/null +++ b/frontend/src/Store/Actions/artistEditorActions.js @@ -0,0 +1,187 @@ +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; +import createHandleActions from './Creators/createHandleActions'; +import { set, updateItem } from './baseActions'; +import { filters, filterPredicates, sortPredicates } from './artistActions'; + +// +// Variables + +export const section = 'artistEditor'; + +// +// State + +export const defaultState = { + isSaving: false, + saveError: null, + isDeleting: false, + deleteError: null, + sortKey: 'sortName', + sortDirection: sortDirections.ASCENDING, + secondarySortKey: 'sortName', + secondarySortDirection: sortDirections.ASCENDING, + selectedFilterKey: 'all', + filters, + filterPredicates, + + filterBuilderProps: [ + { + name: 'monitored', + label: 'Monitored', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.BOOL + }, + { + name: 'status', + label: 'Status', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.ARTIST_STATUS + }, + { + name: 'qualityProfileId', + label: 'Quality Profile', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.QUALITY_PROFILE + }, + { + name: 'metadataProfileId', + label: 'Metadata Profile', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.METADATA_PROFILE + }, + { + name: 'path', + label: 'Path', + type: filterBuilderTypes.STRING + }, + { + name: 'rootFolderPath', + label: 'Root Folder Path', + type: filterBuilderTypes.EXACT + }, + { + name: 'tags', + label: 'Tags', + type: filterBuilderTypes.ARRAY, + valueType: filterBuilderValueTypes.TAG + } + ], + + sortPredicates +}; + +export const persistState = [ + 'artistEditor.sortKey', + 'artistEditor.sortDirection', + 'artistEditor.selectedFilterKey', + 'artistEditor.customFilters' +]; + +// +// Actions Types + +export const SET_ARTIST_EDITOR_SORT = 'artistEditor/setArtistEditorSort'; +export const SET_ARTIST_EDITOR_FILTER = 'artistEditor/setArtistEditorFilter'; +export const SAVE_ARTIST_EDITOR = 'artistEditor/saveArtistEditor'; +export const BULK_DELETE_ARTIST = 'artistEditor/bulkDeleteArtist'; + +// +// Action Creators + +export const setArtistEditorSort = createAction(SET_ARTIST_EDITOR_SORT); +export const setArtistEditorFilter = createAction(SET_ARTIST_EDITOR_FILTER); +export const saveArtistEditor = createThunk(SAVE_ARTIST_EDITOR); +export const bulkDeleteArtist = createThunk(BULK_DELETE_ARTIST); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [SAVE_ARTIST_EDITOR]: function(getState, payload, dispatch) { + dispatch(set({ + section, + isSaving: true + })); + + const promise = createAjaxRequest({ + url: '/artist/editor', + method: 'PUT', + data: JSON.stringify(payload), + dataType: 'json' + }).request; + + promise.done((data) => { + dispatch(batchActions([ + ...data.map((artist) => { + return updateItem({ + id: artist.id, + section: 'artist', + ...artist + }); + }), + + set({ + section, + isSaving: false, + saveError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + }, + + [BULK_DELETE_ARTIST]: function(getState, payload, dispatch) { + dispatch(set({ + section, + isDeleting: true + })); + + const promise = createAjaxRequest({ + url: '/artist/editor', + method: 'DELETE', + data: JSON.stringify(payload), + dataType: 'json' + }).request; + + promise.done(() => { + // SignalR will take care of removing the artist from the collection + + dispatch(set({ + section, + isDeleting: false, + deleteError: null + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isDeleting: false, + deleteError: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_ARTIST_EDITOR_SORT]: createSetClientSideCollectionSortReducer(section), + [SET_ARTIST_EDITOR_FILTER]: createSetClientSideCollectionFilterReducer(section) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/artistHistoryActions.js b/frontend/src/Store/Actions/artistHistoryActions.js new file mode 100644 index 000000000..237004ae3 --- /dev/null +++ b/frontend/src/Store/Actions/artistHistoryActions.js @@ -0,0 +1,104 @@ +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createHandleActions from './Creators/createHandleActions'; +import { set, update } from './baseActions'; + +// +// Variables + +export const section = 'artistHistory'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [] +}; + +// +// Actions Types + +export const FETCH_ARTIST_HISTORY = 'artistHistory/fetchArtistHistory'; +export const CLEAR_ARTIST_HISTORY = 'artistHistory/clearArtistHistory'; +export const ARTIST_HISTORY_MARK_AS_FAILED = 'artistHistory/artistHistoryMarkAsFailed'; + +// +// Action Creators + +export const fetchArtistHistory = createThunk(FETCH_ARTIST_HISTORY); +export const clearArtistHistory = createAction(CLEAR_ARTIST_HISTORY); +export const artistHistoryMarkAsFailed = createThunk(ARTIST_HISTORY_MARK_AS_FAILED); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_ARTIST_HISTORY]: function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + const promise = createAjaxRequest({ + url: '/history/artist', + data: payload + }).request; + + promise.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + }, + + [ARTIST_HISTORY_MARK_AS_FAILED]: function(getState, payload, dispatch) { + const { + historyId, + artistId, + albumId + } = payload; + + const promise = createAjaxRequest({ + url: '/history/failed', + method: 'POST', + data: { + id: historyId + } + }).request; + + promise.done(() => { + dispatch(fetchArtistHistory({ artistId, albumId })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_ARTIST_HISTORY]: (state) => { + return Object.assign({}, state, defaultState); + } + +}, defaultState, section); + diff --git a/frontend/src/Store/Actions/artistIndexActions.js b/frontend/src/Store/Actions/artistIndexActions.js new file mode 100644 index 000000000..427806b2b --- /dev/null +++ b/frontend/src/Store/Actions/artistIndexActions.js @@ -0,0 +1,430 @@ +import { createAction } from 'redux-actions'; +import sortByName from 'Utilities/Array/sortByName'; +import { filterBuilderTypes, filterBuilderValueTypes, filterTypePredicates, sortDirections } from 'Helpers/Props'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; +import createHandleActions from './Creators/createHandleActions'; +import { filters, filterPredicates, sortPredicates } from './artistActions'; + +// +// Variables + +export const section = 'artistIndex'; + +// +// State + +export const defaultState = { + sortKey: 'sortName', + sortDirection: sortDirections.ASCENDING, + secondarySortKey: 'sortName', + secondarySortDirection: sortDirections.ASCENDING, + view: 'posters', + + posterOptions: { + detailedProgressBar: false, + size: 'large', + showTitle: true, + showMonitored: true, + showQualityProfile: true, + showSearchAction: false + }, + + bannerOptions: { + detailedProgressBar: false, + size: 'large', + showTitle: false, + showMonitored: true, + showQualityProfile: true, + showSearchAction: false + }, + + overviewOptions: { + detailedProgressBar: false, + size: 'medium', + showMonitored: true, + showQualityProfile: true, + showLastAlbum: false, + showAdded: false, + showAlbumCount: true, + showPath: false, + showSizeOnDisk: false, + showSearchAction: false + }, + + tableOptions: { + showBanners: false, + showSearchAction: false + }, + + columns: [ + { + name: 'status', + columnLabel: 'Status', + isSortable: true, + isVisible: true, + isModifiable: false + }, + { + name: 'sortName', + label: 'Artist Name', + isSortable: true, + isVisible: true, + isModifiable: false + }, + { + name: 'artistType', + label: 'Type', + isSortable: true, + isVisible: true + }, + { + name: 'qualityProfileId', + label: 'Quality Profile', + isSortable: true, + isVisible: true + }, + { + name: 'metadataProfileId', + label: 'Metadata Profile', + isSortable: true, + isVisible: false + }, + { + name: 'nextAlbum', + label: 'Next Album', + isSortable: true, + isVisible: true + }, + { + name: 'lastAlbum', + label: 'Last Album', + isSortable: true, + isVisible: false + }, + { + name: 'added', + label: 'Added', + isSortable: true, + isVisible: false + }, + { + name: 'albumCount', + label: 'Albums', + isSortable: true, + isVisible: true + }, + { + name: 'trackProgress', + label: 'Tracks', + isSortable: true, + isVisible: true + }, + { + name: 'trackCount', + label: 'Track Count', + isSortable: true, + isVisible: false + }, + { + name: 'path', + label: 'Path', + isSortable: true, + isVisible: false + }, + { + name: 'sizeOnDisk', + label: 'Size on Disk', + isSortable: true, + isVisible: false + }, + { + name: 'genres', + label: 'Genres', + isSortable: false, + isVisible: false + }, + { + name: 'ratings', + label: 'Rating', + isSortable: true, + isVisible: false + }, + { + name: 'tags', + label: 'Tags', + isSortable: false, + isVisible: false + }, + { + name: 'actions', + columnLabel: 'Actions', + isVisible: true, + isModifiable: false + } + ], + + sortPredicates: { + ...sortPredicates, + + trackProgress: function(item) { + const { statistics = {} } = item; + + const { + trackCount = 0, + trackFileCount + } = statistics; + + const progress = trackCount ? trackFileCount / trackCount * 100 : 100; + + return progress + trackCount / 1000000; + }, + + nextAlbum: function(item) { + if (item.nextAlbum) { + return item.nextAlbum.releaseDate; + } + return '1/1/1000'; + }, + + lastAlbum: function(item) { + if (item.lastAlbum) { + return item.lastAlbum.releaseDate; + } + return '1/1/1000'; + }, + + albumCount: function(item) { + const { statistics = {} } = item; + + return statistics.albumCount; + }, + + trackCount: function(item) { + const { statistics = {} } = item; + + return statistics.totalTrackCount; + }, + + sizeOnDisk: function(item) { + const { statistics = {} } = item; + + return statistics.sizeOnDisk; + }, + + ratings: function(item) { + const { ratings = {} } = item; + + return ratings.value; + } + }, + + selectedFilterKey: 'all', + + filters, + + filterPredicates: { + ...filterPredicates, + + trackProgress: function(item, filterValue, type) { + const { statistics = {} } = item; + + const { + trackCount = 0, + trackFileCount + } = statistics; + + const progress = trackCount ? + trackFileCount / trackCount * 100 : + 100; + + const predicate = filterTypePredicates[type]; + + return predicate(progress, filterValue); + } + }, + + filterBuilderProps: [ + { + name: 'monitored', + label: 'Monitored', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.BOOL + }, + { + name: 'status', + label: 'Status', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.ARTIST_STATUS + }, + { + name: 'qualityProfileId', + label: 'Quality Profile', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.QUALITY_PROFILE + }, + { + name: 'metadataProfileId', + label: 'Metadata Profile', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.METADATA_PROFILE + }, + { + name: 'nextAlbum', + label: 'Next Album', + type: filterBuilderTypes.DATE, + valueType: filterBuilderValueTypes.DATE + }, + { + name: 'lastAlbum', + label: 'Last Album', + type: filterBuilderTypes.DATE, + valueType: filterBuilderValueTypes.DATE + }, + { + name: 'added', + label: 'Added', + type: filterBuilderTypes.DATE, + valueType: filterBuilderValueTypes.DATE + }, + { + name: 'albumCount', + label: 'Album Count', + type: filterBuilderTypes.NUMBER + }, + { + name: 'trackProgress', + label: 'Track Progress', + type: filterBuilderTypes.NUMBER + }, + { + name: 'path', + label: 'Path', + type: filterBuilderTypes.STRING + }, + { + name: 'sizeOnDisk', + label: 'Size on Disk', + type: filterBuilderTypes.NUMBER, + valueType: filterBuilderValueTypes.BYTES + }, + { + name: 'genres', + label: 'Genres', + type: filterBuilderTypes.ARRAY, + optionsSelector: function(items) { + const tagList = items.reduce((acc, artist) => { + artist.genres.forEach((genre) => { + acc.push({ + id: genre, + name: genre + }); + }); + + return acc; + }, []); + + return tagList.sort(sortByName); + } + }, + { + name: 'ratings', + label: 'Rating', + type: filterBuilderTypes.NUMBER + }, + { + name: 'tags', + label: 'Tags', + type: filterBuilderTypes.ARRAY, + valueType: filterBuilderValueTypes.TAG + } + ] +}; + +export const persistState = [ + 'artistIndex.sortKey', + 'artistIndex.sortDirection', + 'artistIndex.selectedFilterKey', + 'artistIndex.customFilters', + 'artistIndex.view', + 'artistIndex.columns', + 'artistIndex.posterOptions', + 'artistIndex.bannerOptions', + 'artistIndex.overviewOptions', + 'artistIndex.tableOptions' +]; + +// +// Actions Types + +export const SET_ARTIST_SORT = 'artistIndex/setArtistSort'; +export const SET_ARTIST_FILTER = 'artistIndex/setArtistFilter'; +export const SET_ARTIST_VIEW = 'artistIndex/setArtistView'; +export const SET_ARTIST_TABLE_OPTION = 'artistIndex/setArtistTableOption'; +export const SET_ARTIST_POSTER_OPTION = 'artistIndex/setArtistPosterOption'; +export const SET_ARTIST_BANNER_OPTION = 'artistIndex/setArtistBannerOption'; +export const SET_ARTIST_OVERVIEW_OPTION = 'artistIndex/setArtistOverviewOption'; + +// +// Action Creators + +export const setArtistSort = createAction(SET_ARTIST_SORT); +export const setArtistFilter = createAction(SET_ARTIST_FILTER); +export const setArtistView = createAction(SET_ARTIST_VIEW); +export const setArtistTableOption = createAction(SET_ARTIST_TABLE_OPTION); +export const setArtistPosterOption = createAction(SET_ARTIST_POSTER_OPTION); +export const setArtistBannerOption = createAction(SET_ARTIST_BANNER_OPTION); +export const setArtistOverviewOption = createAction(SET_ARTIST_OVERVIEW_OPTION); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_ARTIST_SORT]: createSetClientSideCollectionSortReducer(section), + [SET_ARTIST_FILTER]: createSetClientSideCollectionFilterReducer(section), + + [SET_ARTIST_VIEW]: function(state, { payload }) { + return Object.assign({}, state, { view: payload.view }); + }, + + [SET_ARTIST_TABLE_OPTION]: createSetTableOptionReducer(section), + + [SET_ARTIST_POSTER_OPTION]: function(state, { payload }) { + const posterOptions = state.posterOptions; + + return { + ...state, + posterOptions: { + ...posterOptions, + ...payload + } + }; + }, + + [SET_ARTIST_BANNER_OPTION]: function(state, { payload }) { + const bannerOptions = state.bannerOptions; + + return { + ...state, + bannerOptions: { + ...bannerOptions, + ...payload + } + }; + }, + + [SET_ARTIST_OVERVIEW_OPTION]: function(state, { payload }) { + const overviewOptions = state.overviewOptions; + + return { + ...state, + overviewOptions: { + ...overviewOptions, + ...payload + } + }; + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/baseActions.js b/frontend/src/Store/Actions/baseActions.js new file mode 100644 index 000000000..37be3e0d2 --- /dev/null +++ b/frontend/src/Store/Actions/baseActions.js @@ -0,0 +1,29 @@ +import { createAction } from 'redux-actions'; + +// +// Action Types + +export const SET = 'base/set'; + +export const UPDATE = 'base/update'; +export const UPDATE_ITEM = 'base/updateItem'; +export const UPDATE_SERVER_SIDE_COLLECTION = 'base/updateServerSideCollection'; + +export const SET_SETTING_VALUE = 'base/setSettingValue'; +export const CLEAR_PENDING_CHANGES = 'base/clearPendingChanges'; + +export const REMOVE_ITEM = 'base/removeItem'; + +// +// Action Creators + +export const set = createAction(SET); + +export const update = createAction(UPDATE); +export const updateItem = createAction(UPDATE_ITEM); +export const updateServerSideCollection = createAction(UPDATE_SERVER_SIDE_COLLECTION); + +export const setSettingValue = createAction(SET_SETTING_VALUE); +export const clearPendingChanges = createAction(CLEAR_PENDING_CHANGES); + +export const removeItem = createAction(REMOVE_ITEM); diff --git a/frontend/src/Store/Actions/blacklistActions.js b/frontend/src/Store/Actions/blacklistActions.js new file mode 100644 index 000000000..2dde21b1e --- /dev/null +++ b/frontend/src/Store/Actions/blacklistActions.js @@ -0,0 +1,139 @@ +import { createAction } from 'redux-actions'; +import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; +import { createThunk, handleThunks } from 'Store/thunks'; +import { sortDirections } from 'Helpers/Props'; +import createClearReducer from './Creators/Reducers/createClearReducer'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import createHandleActions from './Creators/createHandleActions'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers'; + +// +// Variables + +export const section = 'blacklist'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + pageSize: 20, + sortKey: 'date', + sortDirection: sortDirections.DESCENDING, + error: null, + items: [], + + columns: [ + { + name: 'artist.sortName', + label: 'Artist Name', + isSortable: true, + isVisible: true + }, + { + name: 'sourceTitle', + label: 'Source Title', + isSortable: true, + isVisible: true + }, + { + name: 'quality', + label: 'Quality', + isVisible: true + }, + { + name: 'date', + label: 'Date', + isSortable: true, + isVisible: true + }, + { + name: 'indexer', + label: 'Indexer', + isSortable: true, + isVisible: false + }, + { + name: 'actions', + columnLabel: 'Actions', + isVisible: true, + isModifiable: false + } + ] +}; + +export const persistState = [ + 'blacklist.pageSize', + 'blacklist.sortKey', + 'blacklist.sortDirection', + 'blacklist.columns' +]; + +// +// Action Types + +export const FETCH_BLACKLIST = 'blacklist/fetchBlacklist'; +export const GOTO_FIRST_BLACKLIST_PAGE = 'blacklist/gotoBlacklistFirstPage'; +export const GOTO_PREVIOUS_BLACKLIST_PAGE = 'blacklist/gotoBlacklistPreviousPage'; +export const GOTO_NEXT_BLACKLIST_PAGE = 'blacklist/gotoBlacklistNextPage'; +export const GOTO_LAST_BLACKLIST_PAGE = 'blacklist/gotoBlacklistLastPage'; +export const GOTO_BLACKLIST_PAGE = 'blacklist/gotoBlacklistPage'; +export const SET_BLACKLIST_SORT = 'blacklist/setBlacklistSort'; +export const SET_BLACKLIST_TABLE_OPTION = 'blacklist/setBlacklistTableOption'; +export const REMOVE_FROM_BLACKLIST = 'blacklist/removeFromBlacklist'; +export const CLEAR_BLACKLIST = 'blacklist/clearBlacklist'; + +// +// Action Creators + +export const fetchBlacklist = createThunk(FETCH_BLACKLIST); +export const gotoBlacklistFirstPage = createThunk(GOTO_FIRST_BLACKLIST_PAGE); +export const gotoBlacklistPreviousPage = createThunk(GOTO_PREVIOUS_BLACKLIST_PAGE); +export const gotoBlacklistNextPage = createThunk(GOTO_NEXT_BLACKLIST_PAGE); +export const gotoBlacklistLastPage = createThunk(GOTO_LAST_BLACKLIST_PAGE); +export const gotoBlacklistPage = createThunk(GOTO_BLACKLIST_PAGE); +export const setBlacklistSort = createThunk(SET_BLACKLIST_SORT); +export const setBlacklistTableOption = createAction(SET_BLACKLIST_TABLE_OPTION); +export const removeFromBlacklist = createThunk(REMOVE_FROM_BLACKLIST); +export const clearBlacklist = createAction(CLEAR_BLACKLIST); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + ...createServerSideCollectionHandlers( + section, + '/blacklist', + fetchBlacklist, + { + [serverSideCollectionHandlers.FETCH]: FETCH_BLACKLIST, + [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_BLACKLIST_PAGE, + [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_BLACKLIST_PAGE, + [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_BLACKLIST_PAGE, + [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_BLACKLIST_PAGE, + [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_BLACKLIST_PAGE, + [serverSideCollectionHandlers.SORT]: SET_BLACKLIST_SORT + }), + + [REMOVE_FROM_BLACKLIST]: createRemoveItemHandler(section, '/blacklist') +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_BLACKLIST_TABLE_OPTION]: createSetTableOptionReducer(section), + + [CLEAR_BLACKLIST]: createClearReducer(section, { + isFetching: false, + isPopulated: false, + error: null, + items: [], + totalPages: 0, + totalRecords: 0 + }) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/calendarActions.js b/frontend/src/Store/Actions/calendarActions.js new file mode 100644 index 000000000..aee74f14f --- /dev/null +++ b/frontend/src/Store/Actions/calendarActions.js @@ -0,0 +1,388 @@ +import _ from 'lodash'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import moment from 'moment'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { filterTypes } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import * as calendarViews from 'Calendar/calendarViews'; +import * as commandNames from 'Commands/commandNames'; +import createClearReducer from './Creators/Reducers/createClearReducer'; +import createHandleActions from './Creators/createHandleActions'; +import { set, update } from './baseActions'; +import { executeCommandHelper } from './commandActions'; + +// +// Variables + +export const section = 'calendar'; + +const viewRanges = { + [calendarViews.DAY]: 'day', + [calendarViews.WEEK]: 'week', + [calendarViews.MONTH]: 'month', + [calendarViews.FORECAST]: 'day' +}; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + start: null, + end: null, + dates: [], + dayCount: 7, + view: window.innerWidth > 768 ? 'week' : 'day', + showUpcoming: true, + error: null, + items: [], + searchMissingCommandId: null, + + options: { + collapseMultipleAlbums: false, + showCutoffUnmetIcon: false + }, + + selectedFilterKey: 'monitored', + + filters: [ + { + key: 'all', + label: 'All', + filters: [ + { + key: 'monitored', + value: false, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'monitored', + label: 'Monitored Only', + filters: [ + { + key: 'monitored', + value: true, + type: filterTypes.EQUAL + } + ] + } + ] +}; + +export const persistState = [ + 'calendar.view', + 'calendar.selectedFilterKey', + 'calendar.options' +]; + +// +// Actions Types + +export const FETCH_CALENDAR = 'calendar/fetchCalendar'; +export const SET_CALENDAR_DAYS_COUNT = 'calendar/setCalendarDaysCount'; +export const SET_CALENDAR_FILTER = 'calendar/setCalendarFilter'; +export const SET_CALENDAR_VIEW = 'calendar/setCalendarView'; +export const GOTO_CALENDAR_TODAY = 'calendar/gotoCalendarToday'; +export const GOTO_CALENDAR_NEXT_RANGE = 'calendar/gotoCalendarNextRange'; +export const CLEAR_CALENDAR = 'calendar/clearCalendar'; +export const SET_CALENDAR_OPTION = 'calendar/setCalendarOption'; +export const SEARCH_MISSING = 'calendar/searchMissing'; +export const GOTO_CALENDAR_PREVIOUS_RANGE = 'calendar/gotoCalendarPreviousRange'; + +// +// Helpers + +function getDays(start, end) { + const startTime = moment(start); + const endTime = moment(end); + const difference = endTime.diff(startTime, 'days'); + + // Difference is one less than the number of days we need to account for. + return _.times(difference + 1, (i) => { + return startTime.clone().add(i, 'days').toISOString(); + }); +} + +function getDates(time, view, firstDayOfWeek, dayCount) { + const weekName = firstDayOfWeek === 0 ? 'week' : 'isoWeek'; + + let start = time.clone().startOf('day'); + let end = time.clone().endOf('day'); + + if (view === calendarViews.WEEK) { + start = time.clone().startOf(weekName); + end = time.clone().endOf(weekName); + } + + if (view === calendarViews.FORECAST) { + start = time.clone().subtract(1, 'day').startOf('day'); + end = time.clone().add(dayCount - 2, 'days').endOf('day'); + } + + if (view === calendarViews.MONTH) { + start = time.clone().startOf('month').startOf(weekName); + end = time.clone().endOf('month').endOf(weekName); + } + + if (view === calendarViews.AGENDA) { + start = time.clone().subtract(1, 'day').startOf('day'); + end = time.clone().add(1, 'month').endOf('day'); + } + + return { + start: start.toISOString(), + end: end.toISOString(), + time: time.toISOString(), + dates: getDays(start, end) + }; +} + +function getPopulatableRange(startDate, endDate, view) { + switch (view) { + case calendarViews.DAY: + return { + start: moment(startDate).subtract(1, 'day').toISOString(), + end: moment(endDate).add(1, 'day').toISOString() + }; + case calendarViews.WEEK: + case calendarViews.FORECAST: + return { + start: moment(startDate).subtract(1, 'week').toISOString(), + end: moment(endDate).add(1, 'week').toISOString() + }; + default: + return { + start: startDate, + end: endDate + }; + } +} + +function isRangePopulated(start, end, state) { + const { + start: currentStart, + end: currentEnd, + view: currentView + } = state; + + if (!currentStart || !currentEnd) { + return false; + } + + const { + start: currentPopulatedStart, + end: currentPopulatedEnd + } = getPopulatableRange(currentStart, currentEnd, currentView); + + if ( + moment(start).isAfter(currentPopulatedStart) && + moment(start).isBefore(currentPopulatedEnd) + ) { + return true; + } + + return false; +} + +// +// Action Creators + +export const fetchCalendar = createThunk(FETCH_CALENDAR); +export const setCalendarDaysCount = createThunk(SET_CALENDAR_DAYS_COUNT); +export const setCalendarFilter = createThunk(SET_CALENDAR_FILTER); +export const setCalendarView = createThunk(SET_CALENDAR_VIEW); +export const gotoCalendarToday = createThunk(GOTO_CALENDAR_TODAY); +export const gotoCalendarPreviousRange = createThunk(GOTO_CALENDAR_PREVIOUS_RANGE); +export const gotoCalendarNextRange = createThunk(GOTO_CALENDAR_NEXT_RANGE); +export const clearCalendar = createAction(CLEAR_CALENDAR); +export const setCalendarOption = createAction(SET_CALENDAR_OPTION); +export const searchMissing = createThunk(SEARCH_MISSING); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_CALENDAR]: function(getState, payload, dispatch) { + const state = getState(); + const calendar = state.calendar; + const unmonitored = calendar.selectedFilterKey === 'all'; + + const { + time = calendar.time, + view = calendar.view + } = payload; + + const dayCount = state.calendar.dayCount; + const dates = getDates(moment(time), view, state.settings.ui.item.firstDayOfWeek, dayCount); + const { start, end } = getPopulatableRange(dates.start, dates.end, view); + const isPrePopulated = isRangePopulated(start, end, state.calendar); + + const basesAttrs = { + section, + isFetching: true + }; + + const attrs = isPrePopulated ? + { + view, + ...basesAttrs, + ...dates + } : + basesAttrs; + + dispatch(set(attrs)); + + const promise = createAjaxRequest({ + url: '/calendar', + data: { + unmonitored, + start, + end + } + }).request; + + promise.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + view, + ...dates, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + }, + + [SET_CALENDAR_DAYS_COUNT]: function(getState, payload, dispatch) { + if (payload.dayCount === getState().calendar.dayCount) { + return; + } + + dispatch(set({ + section, + dayCount: payload.dayCount + })); + + const state = getState(); + const { time, view } = state.calendar; + + dispatch(fetchCalendar({ time, view })); + }, + + [SET_CALENDAR_FILTER]: function(getState, payload, dispatch) { + dispatch(set({ + section, + selectedFilterKey: payload.selectedFilterKey + })); + + const state = getState(); + const { time, view } = state.calendar; + + dispatch(fetchCalendar({ time, view })); + }, + + [SET_CALENDAR_VIEW]: function(getState, payload, dispatch) { + const state = getState(); + const view = payload.view; + const time = view === calendarViews.FORECAST || calendarViews.AGENDA ? + moment() : + state.calendar.time; + + dispatch(fetchCalendar({ time, view })); + }, + + [GOTO_CALENDAR_TODAY]: function(getState, payload, dispatch) { + const state = getState(); + const view = state.calendar.view; + const time = moment(); + + dispatch(fetchCalendar({ time, view })); + }, + + [GOTO_CALENDAR_PREVIOUS_RANGE]: function(getState, payload, dispatch) { + const state = getState(); + + const { + view, + dayCount + } = state.calendar; + + const amount = view === calendarViews.FORECAST ? dayCount : 1; + const time = moment(state.calendar.time).subtract(amount, viewRanges[view]); + + dispatch(fetchCalendar({ time, view })); + }, + + [GOTO_CALENDAR_NEXT_RANGE]: function(getState, payload, dispatch) { + const state = getState(); + + const { + view, + dayCount + } = state.calendar; + + const amount = view === calendarViews.FORECAST ? dayCount : 1; + const time = moment(state.calendar.time).add(amount, viewRanges[view]); + + dispatch(fetchCalendar({ time, view })); + }, + + [SEARCH_MISSING]: function(getState, payload, dispatch) { + const { albumIds } = payload; + + const commandPayload = { + name: commandNames.ALBUM_SEARCH, + albumIds + }; + + executeCommandHelper(commandPayload, dispatch).then((data) => { + dispatch(set({ + section, + searchMissingCommandId: data.id + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_CALENDAR]: createClearReducer(section, { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }), + + [SET_CALENDAR_OPTION]: function(state, { payload }) { + const options = state.options; + + return { + ...state, + options: { + ...options, + ...payload + } + }; + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/captchaActions.js b/frontend/src/Store/Actions/captchaActions.js new file mode 100644 index 000000000..d506566f7 --- /dev/null +++ b/frontend/src/Store/Actions/captchaActions.js @@ -0,0 +1,119 @@ +import { createAction } from 'redux-actions'; +import requestAction from 'Utilities/requestAction'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'captcha'; + +// +// State + +export const defaultState = { + refreshing: false, + token: null, + siteKey: null, + secretToken: null, + ray: null, + stoken: null, + responseUrl: null +}; + +// +// Actions Types + +export const REFRESH_CAPTCHA = 'captcha/refreshCaptcha'; +export const GET_CAPTCHA_COOKIE = 'captcha/getCaptchaCookie'; +export const SET_CAPTCHA_VALUE = 'captcha/setCaptchaValue'; +export const RESET_CAPTCHA = 'captcha/resetCaptcha'; + +// +// Action Creators + +export const refreshCaptcha = createThunk(REFRESH_CAPTCHA); +export const getCaptchaCookie = createThunk(GET_CAPTCHA_COOKIE); +export const setCaptchaValue = createAction(SET_CAPTCHA_VALUE); +export const resetCaptcha = createAction(RESET_CAPTCHA); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [REFRESH_CAPTCHA]: function(getState, payload, dispatch) { + const actionPayload = { + action: 'checkCaptcha', + ...payload + }; + + dispatch(setCaptchaValue({ + refreshing: true + })); + + const promise = requestAction(actionPayload); + + promise.done((data) => { + if (!data.captchaRequest) { + dispatch(setCaptchaValue({ + refreshing: false + })); + } + + dispatch(setCaptchaValue({ + refreshing: false, + ...data.captchaRequest + })); + }); + + promise.fail(() => { + dispatch(setCaptchaValue({ + refreshing: false + })); + }); + }, + + [GET_CAPTCHA_COOKIE]: function(getState, payload, dispatch) { + const state = getState().captcha; + + const queryParams = { + responseUrl: state.responseUrl, + ray: state.ray, + captchaResponse: payload.captchaResponse + }; + + const actionPayload = { + action: 'getCaptchaCookie', + queryParams, + ...payload + }; + + const promise = requestAction(actionPayload); + + promise.done((data) => { + dispatch(setCaptchaValue({ + token: data.captchaToken + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_CAPTCHA_VALUE]: function(state, { payload }) { + const newState = Object.assign(getSectionState(state, section), payload); + + return updateSectionState(state, section, newState); + }, + + [RESET_CAPTCHA]: function(state) { + return updateSectionState(state, section, defaultState); + } + +}, defaultState); diff --git a/frontend/src/Store/Actions/commandActions.js b/frontend/src/Store/Actions/commandActions.js new file mode 100644 index 000000000..fc3b907f7 --- /dev/null +++ b/frontend/src/Store/Actions/commandActions.js @@ -0,0 +1,215 @@ +import _ from 'lodash'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { isSameCommand } from 'Utilities/Command'; +import { messageTypes } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import { showMessage, hideMessage } from './appActions'; +import { updateItem } from './baseActions'; + +// +// Variables + +export const section = 'commands'; + +let lastCommand = null; +let lastCommandTimeout = null; +const removeCommandTimeoutIds = {}; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [], + handlers: {} +}; + +// +// Actions Types + +export const FETCH_COMMANDS = 'commands/fetchCommands'; +export const EXECUTE_COMMAND = 'commands/executeCommand'; +export const CANCEL_COMMAND = 'commands/cancelCommand'; +export const ADD_COMMAND = 'commands/updateCommand'; +export const UPDATE_COMMAND = 'commands/finishCommand'; +export const FINISH_COMMAND = 'commands/addCommand'; +export const REMOVE_COMMAND = 'commands/removeCommand'; + +// +// Action Creators + +export const fetchCommands = createThunk(FETCH_COMMANDS); +export const executeCommand = createThunk(EXECUTE_COMMAND); +export const cancelCommand = createThunk(CANCEL_COMMAND); +export const updateCommand = createThunk(UPDATE_COMMAND); +export const finishCommand = createThunk(FINISH_COMMAND); +export const addCommand = createAction(ADD_COMMAND); +export const removeCommand = createAction(REMOVE_COMMAND); + +// +// Helpers + +function showCommandMessage(payload, dispatch) { + const { + id, + name, + trigger, + message, + body = {}, + status + } = payload; + + const { + sendUpdatesToClient, + suppressMessages + } = body; + + if (!message || !body || !sendUpdatesToClient || suppressMessages) { + return; + } + + let type = messageTypes.INFO; + let hideAfter = 0; + + if (status === 'completed') { + type = messageTypes.SUCCESS; + hideAfter = 4; + } else if (status === 'failed') { + type = messageTypes.ERROR; + hideAfter = trigger === 'manual' ? 10 : 4; + } + + dispatch(showMessage({ + id, + name, + message, + type, + hideAfter + })); +} + +function scheduleRemoveCommand(command, dispatch) { + const { + id, + status + } = command; + + if (status === 'queued') { + return; + } + + const timeoutId = removeCommandTimeoutIds[id]; + + if (timeoutId) { + clearTimeout(timeoutId); + } + + removeCommandTimeoutIds[id] = setTimeout(() => { + dispatch(batchActions([ + removeCommand({ section: 'commands', id }), + hideMessage({ id }) + ])); + + delete removeCommandTimeoutIds[id]; + }, 60000 * 5); +} + +export function executeCommandHelper( payload, dispatch) { + // TODO: show a message for the user + if (lastCommand && isSameCommand(lastCommand, payload)) { + console.warn('Please wait at least 5 seconds before running this command again'); + } + + lastCommand = payload; + + // clear last command after 5 seconds. + if (lastCommandTimeout) { + clearTimeout(lastCommandTimeout); + } + + lastCommandTimeout = setTimeout(() => { + lastCommand = null; + }, 5000); + + const promise = createAjaxRequest({ + url: '/command', + method: 'POST', + data: JSON.stringify(payload) + }).request; + + return promise.then((data) => { + dispatch(addCommand(data)); + }); +} + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_COMMANDS]: createFetchHandler('commands', '/command'), + + [EXECUTE_COMMAND]: function(getState, payload, dispatch) { + executeCommandHelper(payload, dispatch); + }, + + [CANCEL_COMMAND]: createRemoveItemHandler(section, '/command'), + + [UPDATE_COMMAND]: function(getState, payload, dispatch) { + dispatch(updateItem({ section: 'commands', ...payload })); + + showCommandMessage(payload, dispatch); + scheduleRemoveCommand(payload, dispatch); + }, + + [FINISH_COMMAND]: function(getState, payload, dispatch) { + const state = getState(); + const handlers = state.commands.handlers; + + Object.keys(handlers).forEach((key) => { + const handler = handlers[key]; + + if (handler.name === payload.name) { + dispatch(handler.handler(payload)); + } + }); + + dispatch(updateItem({ section: 'commands', ...payload })); + scheduleRemoveCommand(payload, dispatch); + showCommandMessage(payload, dispatch); + } + +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [ADD_COMMAND]: (state, { payload }) => { + const newState = Object.assign({}, state); + newState.items = [...state.items, payload]; + + return newState; + }, + + [REMOVE_COMMAND]: (state, { payload }) => { + const newState = Object.assign({}, state); + newState.items = [...state.items]; + + const index = _.findIndex(newState.items, { id: payload.id }); + + if (index > -1) { + newState.items.splice(index, 1); + } + + return newState; + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/createReducers.js b/frontend/src/Store/Actions/createReducers.js new file mode 100644 index 000000000..11928e4d2 --- /dev/null +++ b/frontend/src/Store/Actions/createReducers.js @@ -0,0 +1,23 @@ +import { combineReducers } from 'redux'; +import { enableBatching } from 'redux-batched-actions'; +import actions from 'Store/Actions'; +import { connectRouter } from 'connected-react-router'; + +const defaultState = {}; +const reducers = {}; + +actions.forEach((action) => { + const section = action.section; + + defaultState[section] = action.defaultState; + reducers[section] = action.reducers; +}); + +export { defaultState }; + +export default function(history) { + return enableBatching(combineReducers({ + ...reducers, + router: connectRouter(history) + })); +} diff --git a/frontend/src/Store/Actions/customFilterActions.js b/frontend/src/Store/Actions/customFilterActions.js new file mode 100644 index 000000000..750c3ef6f --- /dev/null +++ b/frontend/src/Store/Actions/customFilterActions.js @@ -0,0 +1,55 @@ +import { createThunk, handleThunks } from 'Store/thunks'; +import createFetchHandler from './Creators/createFetchHandler'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import createSaveProviderHandler from './Creators/createSaveProviderHandler'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'customFilters'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + isSaving: false, + saveError: null, + isDeleting: false, + deleteError: null, + items: [], + pendingChanges: {} +}; + +// +// Actions Types + +export const FETCH_CUSTOM_FILTERS = 'customFilters/fetchCustomFilters'; +export const SAVE_CUSTOM_FILTER = 'customFilters/saveCustomFilter'; +export const DELETE_CUSTOM_FILTER = 'customFilters/deleteCustomFilter'; + +// +// Action Creators + +export const fetchCustomFilters = createThunk(FETCH_CUSTOM_FILTERS); +export const saveCustomFilter = createThunk(SAVE_CUSTOM_FILTER); +export const deleteCustomFilter = createThunk(DELETE_CUSTOM_FILTER); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_CUSTOM_FILTERS]: createFetchHandler(section, '/customFilter'), + + [SAVE_CUSTOM_FILTER]: createSaveProviderHandler(section, '/customFilter'), + + [DELETE_CUSTOM_FILTER]: createRemoveItemHandler(section, '/customFilter') + +}); + +// +// Reducers +export const reducers = createHandleActions({}, defaultState, section); diff --git a/frontend/src/Store/Actions/historyActions.js b/frontend/src/Store/Actions/historyActions.js new file mode 100644 index 000000000..d7552b938 --- /dev/null +++ b/frontend/src/Store/Actions/historyActions.js @@ -0,0 +1,297 @@ +import { createAction } from 'redux-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; +import { filterTypes, sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createClearReducer from './Creators/Reducers/createClearReducer'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import createHandleActions from './Creators/createHandleActions'; +import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers'; +import { updateItem } from './baseActions'; + +// +// Variables + +export const section = 'history'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + pageSize: 20, + sortKey: 'date', + sortDirection: sortDirections.DESCENDING, + items: [], + + columns: [ + { + name: 'eventType', + columnLabel: 'Event Type', + isVisible: true, + isModifiable: false + }, + { + name: 'artist.sortName', + label: 'Artist', + isSortable: true, + isVisible: true + }, + { + name: 'album.title', + label: 'Album Title', + isSortable: true, + isVisible: true + }, + { + name: 'trackTitle', + label: 'Track Title', + isVisible: true + }, + { + name: 'quality', + label: 'Quality', + isVisible: true + }, + { + name: 'date', + label: 'Date', + isSortable: true, + isVisible: true + }, + { + name: 'downloadClient', + label: 'Download Client', + isVisible: false + }, + { + name: 'indexer', + label: 'Indexer', + isVisible: false + }, + { + name: 'releaseGroup', + label: 'Release Group', + isVisible: false + }, + { + name: 'details', + columnLabel: 'Details', + isVisible: true, + isModifiable: false + } + ], + + selectedFilterKey: 'all', + + filters: [ + { + key: 'all', + label: 'All', + filters: [] + }, + { + key: 'grabbed', + label: 'Grabbed', + filters: [ + { + key: 'eventType', + value: '1', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'trackFileImported', + label: 'Track Imported', + filters: [ + { + key: 'eventType', + value: '3', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'failed', + label: 'Download Failed', + filters: [ + { + key: 'eventType', + value: '4', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'importFailed', + label: 'Import Failed', + filters: [ + { + key: 'eventType', + value: '7', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'downloadImported', + label: 'Download Imported', + filters: [ + { + key: 'eventType', + value: '8', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'deleted', + label: 'Deleted', + filters: [ + { + key: 'eventType', + value: '5', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'renamed', + label: 'Renamed', + filters: [ + { + key: 'eventType', + value: '6', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'retagged', + label: 'Retagged', + filters: [ + { + key: 'eventType', + value: '9', + type: filterTypes.EQUAL + } + ] + } + ] + +}; + +export const persistState = [ + 'history.pageSize', + 'history.sortKey', + 'history.sortDirection', + 'history.selectedFilterKey', + 'history.columns' +]; + +// +// Actions Types + +export const FETCH_HISTORY = 'history/fetchHistory'; +export const GOTO_FIRST_HISTORY_PAGE = 'history/gotoHistoryFirstPage'; +export const GOTO_PREVIOUS_HISTORY_PAGE = 'history/gotoHistoryPreviousPage'; +export const GOTO_NEXT_HISTORY_PAGE = 'history/gotoHistoryNextPage'; +export const GOTO_LAST_HISTORY_PAGE = 'history/gotoHistoryLastPage'; +export const GOTO_HISTORY_PAGE = 'history/gotoHistoryPage'; +export const SET_HISTORY_SORT = 'history/setHistorySort'; +export const SET_HISTORY_FILTER = 'history/setHistoryFilter'; +export const SET_HISTORY_TABLE_OPTION = 'history/setHistoryTableOption'; +export const CLEAR_HISTORY = 'history/clearHistory'; +export const MARK_AS_FAILED = 'history/markAsFailed'; + +// +// Action Creators + +export const fetchHistory = createThunk(FETCH_HISTORY); +export const gotoHistoryFirstPage = createThunk(GOTO_FIRST_HISTORY_PAGE); +export const gotoHistoryPreviousPage = createThunk(GOTO_PREVIOUS_HISTORY_PAGE); +export const gotoHistoryNextPage = createThunk(GOTO_NEXT_HISTORY_PAGE); +export const gotoHistoryLastPage = createThunk(GOTO_LAST_HISTORY_PAGE); +export const gotoHistoryPage = createThunk(GOTO_HISTORY_PAGE); +export const setHistorySort = createThunk(SET_HISTORY_SORT); +export const setHistoryFilter = createThunk(SET_HISTORY_FILTER); +export const setHistoryTableOption = createAction(SET_HISTORY_TABLE_OPTION); +export const clearHistory = createAction(CLEAR_HISTORY); +export const markAsFailed = createThunk(MARK_AS_FAILED); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + ...createServerSideCollectionHandlers( + section, + '/history', + fetchHistory, + { + [serverSideCollectionHandlers.FETCH]: FETCH_HISTORY, + [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_HISTORY_PAGE, + [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_HISTORY_PAGE, + [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_HISTORY_PAGE, + [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_HISTORY_PAGE, + [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_HISTORY_PAGE, + [serverSideCollectionHandlers.SORT]: SET_HISTORY_SORT, + [serverSideCollectionHandlers.FILTER]: SET_HISTORY_FILTER + }), + + [MARK_AS_FAILED]: function(getState, payload, dispatch) { + const id = payload.id; + + dispatch(updateItem({ + section, + id, + isMarkingAsFailed: true + })); + + const promise = createAjaxRequest({ + url: '/history/failed', + method: 'POST', + data: { + id + } + }).request; + + promise.done(() => { + dispatch(updateItem({ + section, + id, + isMarkingAsFailed: false, + markAsFailedError: null + })); + }); + + promise.fail((xhr) => { + dispatch(updateItem({ + section, + id, + isMarkingAsFailed: false, + markAsFailedError: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_HISTORY_TABLE_OPTION]: createSetTableOptionReducer(section), + + [CLEAR_HISTORY]: createClearReducer(section, { + isFetching: false, + isPopulated: false, + error: null, + items: [], + totalPages: 0, + totalRecords: 0 + }) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/importArtistActions.js b/frontend/src/Store/Actions/importArtistActions.js new file mode 100644 index 000000000..b4b265a6d --- /dev/null +++ b/frontend/src/Store/Actions/importArtistActions.js @@ -0,0 +1,327 @@ +import _ from 'lodash'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import getNewArtist from 'Utilities/Artist/getNewArtist'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createHandleActions from './Creators/createHandleActions'; +import { set, removeItem, updateItem } from './baseActions'; +import { fetchRootFolders } from './rootFolderActions'; + +// +// Variables + +export const section = 'importArtist'; +let concurrentLookups = 0; +let abortCurrentLookup = null; +const queue = []; + +// +// State + +export const defaultState = { + isLookingUpArtist: false, + isImporting: false, + isImported: false, + importError: null, + items: [] +}; + +// +// Actions Types + +export const QUEUE_LOOKUP_ARTIST = 'importArtist/queueLookupArtist'; +export const START_LOOKUP_ARTIST = 'importArtist/startLookupArtist'; +export const CANCEL_LOOKUP_ARTIST = 'importArtist/cancelLookupArtist'; +export const LOOKUP_UNSEARCHED_ARTIST = 'importArtist/lookupUnsearchedArtist'; +export const CLEAR_IMPORT_ARTIST = 'importArtist/clearImportArtist'; +export const SET_IMPORT_ARTIST_VALUE = 'importArtist/setImportArtistValue'; +export const IMPORT_ARTIST = 'importArtist/importArtist'; + +// +// Action Creators + +export const queueLookupArtist = createThunk(QUEUE_LOOKUP_ARTIST); +export const startLookupArtist = createThunk(START_LOOKUP_ARTIST); +export const importArtist = createThunk(IMPORT_ARTIST); +export const lookupUnsearchedArtist = createThunk(LOOKUP_UNSEARCHED_ARTIST); +export const clearImportArtist = createAction(CLEAR_IMPORT_ARTIST); +export const cancelLookupArtist = createAction(CANCEL_LOOKUP_ARTIST); + +export const setImportArtistValue = createAction(SET_IMPORT_ARTIST_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [QUEUE_LOOKUP_ARTIST]: function(getState, payload, dispatch) { + const { + name, + path, + term, + topOfQueue = false + } = payload; + + const state = getState().importArtist; + const item = _.find(state.items, { id: name }) || { + id: name, + term, + path, + isFetching: false, + isPopulated: false, + error: null + }; + + dispatch(updateItem({ + section, + ...item, + term, + isQueued: true, + items: [] + })); + + const itemIndex = queue.indexOf(item.id); + + if (itemIndex >= 0) { + queue.splice(itemIndex, 1); + } + + if (topOfQueue) { + queue.unshift(item.id); + } else { + queue.push(item.id); + } + + if (term && term.length > 2) { + dispatch(startLookupArtist({ start: true })); + } + }, + + [START_LOOKUP_ARTIST]: function(getState, payload, dispatch) { + if (concurrentLookups >= 1) { + return; + } + + const state = getState().importArtist; + + const { + isLookingUpArtist, + items + } = state; + + const queueId = queue[0]; + + if (payload.start && !isLookingUpArtist) { + dispatch(set({ section, isLookingUpArtist: true })); + } else if (!isLookingUpArtist) { + return; + } else if (!queueId) { + dispatch(set({ section, isLookingUpArtist: false })); + return; + } + + concurrentLookups++; + queue.splice(0, 1); + + const queued = items.find((i) => i.id === queueId); + + dispatch(updateItem({ + section, + id: queued.id, + isFetching: true + })); + + const { request, abortRequest } = createAjaxRequest({ + url: '/artist/lookup', + data: { + term: queued.term + } + }); + + abortCurrentLookup = abortRequest; + + request.done((data) => { + dispatch(updateItem({ + section, + id: queued.id, + isFetching: false, + isPopulated: true, + error: null, + items: data, + isQueued: false, + selectedArtist: queued.selectedArtist || data[0], + updateOnly: true + })); + }); + + request.fail((xhr) => { + dispatch(updateItem({ + section, + id: queued.id, + isFetching: false, + isPopulated: false, + error: xhr, + isQueued: false, + updateOnly: true + })); + }); + + request.always(() => { + concurrentLookups--; + + dispatch(startLookupArtist()); + }); + }, + + [LOOKUP_UNSEARCHED_ARTIST]: function(getState, payload, dispatch) { + const state = getState().importArtist; + + if (state.isLookingUpArtist) { + return; + } + + state.items.forEach((item) => { + const id = item.id; + + if ( + !item.isPopulated && + !queue.includes(id) + ) { + queue.push(item.id); + } + }); + + if (queue.length) { + dispatch(startLookupArtist({ start: true })); + } + }, + + [IMPORT_ARTIST]: function(getState, payload, dispatch) { + dispatch(set({ section, isImporting: true })); + + const ids = payload.ids; + const items = getState().importArtist.items; + const addedIds = []; + + const allNewArtist = ids.reduce((acc, id) => { + const item = _.find(items, { id }); + const selectedArtist = item.selectedArtist; + + // Make sure we have a selected artist and + // the same artist hasn't been added yet. + if (selectedArtist && !_.some(acc, { foreignArtistId: selectedArtist.foreignArtistId })) { + const newArtist = getNewArtist(_.cloneDeep(selectedArtist), item); + newArtist.path = item.path; + + addedIds.push(id); + acc.push(newArtist); + } + + return acc; + }, []); + + const promise = createAjaxRequest({ + url: '/artist/import', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify(allNewArtist) + }).request; + + promise.done((data) => { + dispatch(batchActions([ + set({ + section, + isImporting: false, + isImported: true + }), + + ...data.map((artist) => updateItem({ section: 'artist', ...artist })), + + ...addedIds.map((id) => removeItem({ section, id })) + ])); + + dispatch(fetchRootFolders()); + }); + + promise.fail((xhr) => { + dispatch(batchActions( + set({ + section, + isImporting: false, + isImported: true + }), + + addedIds.map((id) => updateItem({ + section, + id, + importError: xhr + })) + )); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CANCEL_LOOKUP_ARTIST]: function(state) { + queue.splice(0, queue.length); + + const items = state.items.map((item) => { + if (item.isQueued) { + return { + ...item, + isQueued: false + }; + } + + return item; + }); + + return Object.assign({}, state, { + isLookingUpArtist: false, + items + }); + }, + + [CLEAR_IMPORT_ARTIST]: function(state) { + if (abortCurrentLookup) { + abortCurrentLookup(); + + abortCurrentLookup = null; + } + + queue.splice(0, queue.length); + + return Object.assign({}, state, defaultState); + }, + + [SET_IMPORT_ARTIST_VALUE]: function(state, { payload }) { + const newState = getSectionState(state, section); + const items = newState.items; + const index = _.findIndex(items, { id: payload.id }); + + newState.items = [...items]; + + if (index >= 0) { + const item = items[index]; + + newState.items.splice(index, 1, { ...item, ...payload }); + } else { + newState.items.push({ ...payload }); + } + + return updateSectionState(state, section, newState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js new file mode 100644 index 000000000..8e04a20cf --- /dev/null +++ b/frontend/src/Store/Actions/index.js @@ -0,0 +1,65 @@ +import * as addArtist from './addArtistActions'; +import * as app from './appActions'; +import * as blacklist from './blacklistActions'; +import * as calendar from './calendarActions'; +import * as captcha from './captchaActions'; +import * as customFilters from './customFilterActions'; +import * as commands from './commandActions'; +import * as albums from './albumActions'; +import * as trackFiles from './trackFileActions'; +import * as albumHistory from './albumHistoryActions'; +import * as history from './historyActions'; +import * as importArtist from './importArtistActions'; +import * as interactiveImportActions from './interactiveImportActions'; +import * as oAuth from './oAuthActions'; +import * as organizePreview from './organizePreviewActions'; +import * as retagPreview from './retagPreviewActions'; +import * as paths from './pathActions'; +import * as providerOptions from './providerOptionActions'; +import * as queue from './queueActions'; +import * as releases from './releaseActions'; +import * as rootFolders from './rootFolderActions'; +import * as albumStudio from './albumStudioActions'; +import * as artist from './artistActions'; +import * as artistEditor from './artistEditorActions'; +import * as artistHistory from './artistHistoryActions'; +import * as artistIndex from './artistIndexActions'; +import * as settings from './settingsActions'; +import * as system from './systemActions'; +import * as tags from './tagActions'; +import * as tracks from './trackActions'; +import * as wanted from './wantedActions'; + +export default [ + addArtist, + app, + blacklist, + captcha, + calendar, + commands, + customFilters, + albums, + trackFiles, + albumHistory, + history, + importArtist, + interactiveImportActions, + oAuth, + organizePreview, + retagPreview, + paths, + providerOptions, + queue, + releases, + rootFolders, + albumStudio, + artist, + artistEditor, + artistHistory, + artistIndex, + settings, + system, + tags, + tracks, + wanted +]; diff --git a/frontend/src/Store/Actions/interactiveImportActions.js b/frontend/src/Store/Actions/interactiveImportActions.js new file mode 100644 index 000000000..7b0607885 --- /dev/null +++ b/frontend/src/Store/Actions/interactiveImportActions.js @@ -0,0 +1,254 @@ +import moment from 'moment'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import { createThunk, handleThunks } from 'Store/thunks'; +import { sortDirections } from 'Helpers/Props'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; +import createSaveProviderHandler from './Creators/createSaveProviderHandler'; +import { set, update } from './baseActions'; + +// +// Variables + +export const section = 'interactiveImport'; + +const albumsSection = `${section}.albums`; +const trackFilesSection = `${section}.trackFiles`; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + isSaving: false, + error: null, + items: [], + pendingChanges: {}, + sortKey: 'quality', + sortDirection: sortDirections.DESCENDING, + recentFolders: [], + importMode: 'move', + sortPredicates: { + relativePath: function(item, direction) { + const relativePath = item.relativePath; + + return relativePath.toLowerCase(); + }, + + artist: function(item, direction) { + const artist = item.artist; + + return artist ? artist.sortName : ''; + }, + + quality: function(item, direction) { + return item.quality ? item.qualityWeight : 0; + } + }, + + albums: { + isFetching: false, + isPopulated: false, + error: null, + sortKey: 'albumTitle', + sortDirection: sortDirections.ASCENDING, + items: [] + }, + + trackFiles: { + isFetching: false, + isPopulated: false, + error: null, + sortKey: 'relataivePath', + sortDirection: sortDirections.ASCENDING, + items: [] + } +}; + +export const persistState = [ + 'interactiveImport.recentFolders', + 'interactiveImport.importMode' +]; + +// +// Actions Types + +export const FETCH_INTERACTIVE_IMPORT_ITEMS = 'interactiveImport/fetchInteractiveImportItems'; +export const SAVE_INTERACTIVE_IMPORT_ITEM = 'interactiveImport/saveInteractiveImportItem'; +export const SET_INTERACTIVE_IMPORT_SORT = 'interactiveImport/setInteractiveImportSort'; +export const UPDATE_INTERACTIVE_IMPORT_ITEM = 'interactiveImport/updateInteractiveImportItem'; +export const UPDATE_INTERACTIVE_IMPORT_ITEMS = 'interactiveImport/updateInteractiveImportItems'; +export const CLEAR_INTERACTIVE_IMPORT = 'interactiveImport/clearInteractiveImport'; +export const ADD_RECENT_FOLDER = 'interactiveImport/addRecentFolder'; +export const REMOVE_RECENT_FOLDER = 'interactiveImport/removeRecentFolder'; +export const SET_INTERACTIVE_IMPORT_MODE = 'interactiveImport/setInteractiveImportMode'; + +export const FETCH_INTERACTIVE_IMPORT_ALBUMS = 'interactiveImport/fetchInteractiveImportAlbums'; +export const SET_INTERACTIVE_IMPORT_ALBUMS_SORT = 'interactiveImport/clearInteractiveImportAlbumsSort'; +export const CLEAR_INTERACTIVE_IMPORT_ALBUMS = 'interactiveImport/clearInteractiveImportAlbums'; + +export const FETCH_INTERACTIVE_IMPORT_TRACKFILES = 'interactiveImport/fetchInteractiveImportTrackFiles'; +export const CLEAR_INTERACTIVE_IMPORT_TRACKFILES = 'interactiveImport/clearInteractiveImportTrackFiles'; + +// +// Action Creators + +export const fetchInteractiveImportItems = createThunk(FETCH_INTERACTIVE_IMPORT_ITEMS); +export const setInteractiveImportSort = createAction(SET_INTERACTIVE_IMPORT_SORT); +export const updateInteractiveImportItem = createAction(UPDATE_INTERACTIVE_IMPORT_ITEM); +export const updateInteractiveImportItems = createAction(UPDATE_INTERACTIVE_IMPORT_ITEMS); +export const saveInteractiveImportItem = createThunk(SAVE_INTERACTIVE_IMPORT_ITEM); +export const clearInteractiveImport = createAction(CLEAR_INTERACTIVE_IMPORT); +export const addRecentFolder = createAction(ADD_RECENT_FOLDER); +export const removeRecentFolder = createAction(REMOVE_RECENT_FOLDER); +export const setInteractiveImportMode = createAction(SET_INTERACTIVE_IMPORT_MODE); + +export const fetchInteractiveImportAlbums = createThunk(FETCH_INTERACTIVE_IMPORT_ALBUMS); +export const setInteractiveImportAlbumsSort = createAction(SET_INTERACTIVE_IMPORT_ALBUMS_SORT); +export const clearInteractiveImportAlbums = createAction(CLEAR_INTERACTIVE_IMPORT_ALBUMS); + +export const fetchInteractiveImportTrackFiles = createThunk(FETCH_INTERACTIVE_IMPORT_TRACKFILES); +export const clearInteractiveImportTrackFiles = createAction(CLEAR_INTERACTIVE_IMPORT_TRACKFILES); + +// +// Action Handlers +export const actionHandlers = handleThunks({ + [FETCH_INTERACTIVE_IMPORT_ITEMS]: function(getState, payload, dispatch) { + if (!payload.downloadId && !payload.folder) { + dispatch(set({ section, error: { message: '`downloadId` or `folder` is required.' } })); + return; + } + + dispatch(set({ section, isFetching: true })); + + const promise = createAjaxRequest({ + url: '/manualimport', + data: payload + }).request; + + promise.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + }, + + [SAVE_INTERACTIVE_IMPORT_ITEM]: createSaveProviderHandler(section, '/manualimport', {}, true), + + [FETCH_INTERACTIVE_IMPORT_ALBUMS]: createFetchHandler(albumsSection, '/album'), + + [FETCH_INTERACTIVE_IMPORT_TRACKFILES]: createFetchHandler(trackFilesSection, '/trackFile') +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [UPDATE_INTERACTIVE_IMPORT_ITEM]: (state, { payload }) => { + const id = payload.id; + const newState = Object.assign({}, state); + const items = newState.items; + const index = items.findIndex((item) => item.id === id); + const item = Object.assign({}, items[index], payload); + + newState.items = [...items]; + newState.items.splice(index, 1, item); + + return newState; + }, + + [UPDATE_INTERACTIVE_IMPORT_ITEMS]: (state, { payload }) => { + const ids = payload.ids; + const newState = Object.assign({}, state); + const items = [...newState.items]; + + ids.forEach((id) => { + const index = items.findIndex((item) => item.id === id); + const item = Object.assign({}, items[index], payload); + + items.splice(index, 1, item); + }); + + newState.items = items; + + return newState; + }, + + [ADD_RECENT_FOLDER]: function(state, { payload }) { + const folder = payload.folder; + const recentFolder = { folder, lastUsed: moment().toISOString() }; + const recentFolders = [...state.recentFolders]; + const index = recentFolders.findIndex((r) => r.folder === folder); + + if (index > -1) { + recentFolders.splice(index, 1, recentFolder); + } else { + recentFolders.push(recentFolder); + } + + return Object.assign({}, state, { recentFolders }); + }, + + [REMOVE_RECENT_FOLDER]: function(state, { payload }) { + const folder = payload.folder; + const recentFolders = [...state.recentFolders]; + const index = recentFolders.findIndex((r) => r.folder === folder); + + recentFolders.splice(index, 1); + + return Object.assign({}, state, { recentFolders }); + }, + + [CLEAR_INTERACTIVE_IMPORT]: function(state) { + const newState = { + ...defaultState, + recentFolders: state.recentFolders, + importMode: state.importMode + }; + + return newState; + }, + + [SET_INTERACTIVE_IMPORT_SORT]: createSetClientSideCollectionSortReducer(section), + + [SET_INTERACTIVE_IMPORT_MODE]: function(state, { payload }) { + return Object.assign({}, state, { importMode: payload.importMode }); + }, + + [SET_INTERACTIVE_IMPORT_ALBUMS_SORT]: createSetClientSideCollectionSortReducer(albumsSection), + + [CLEAR_INTERACTIVE_IMPORT_ALBUMS]: (state) => { + return updateSectionState(state, albumsSection, { + ...defaultState.albums + }); + }, + + [CLEAR_INTERACTIVE_IMPORT_TRACKFILES]: (state) => { + return updateSectionState(state, trackFilesSection, { + ...defaultState.trackFiles + }); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/oAuthActions.js b/frontend/src/Store/Actions/oAuthActions.js new file mode 100644 index 000000000..07ada4a90 --- /dev/null +++ b/frontend/src/Store/Actions/oAuthActions.js @@ -0,0 +1,206 @@ +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import requestAction from 'Utilities/requestAction'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import { createThunk, handleThunks } from 'Store/thunks'; +import { set } from 'Store/Actions/baseActions'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'oAuth'; +const callbackUrl = `${window.location.origin}${window.Lidarr.urlBase}/oauth.html`; + +// +// State + +export const defaultState = { + authorizing: false, + result: null, + error: null +}; + +// +// Actions Types + +export const START_OAUTH = 'oAuth/startOAuth'; +export const SET_OAUTH_VALUE = 'oAuth/setOAuthValue'; +export const RESET_OAUTH = 'oAuth/resetOAuth'; + +// +// Action Creators + +export const startOAuth = createThunk(START_OAUTH); +export const setOAuthValue = createAction(SET_OAUTH_VALUE); +export const resetOAuth = createAction(RESET_OAUTH); + +// +// Helpers + +function showOAuthWindow(url, payload) { + const deferred = $.Deferred(); + const selfWindow = window; + + const newWindow = window.open(url); + + if ( + !newWindow || + newWindow.closed || + typeof newWindow.closed == 'undefined' + ) { + + // A fake validation error to mimic a 400 response from the API. + const error = { + status: 400, + responseJSON: [ + { + propertyName: payload.name, + errorMessage: 'Pop-ups are being blocked by your browser' + } + ] + }; + + return deferred.reject(error).promise(); + } + + selfWindow.onCompleteOauth = function(query, onComplete) { + delete selfWindow.onCompleteOauth; + + const queryParams = {}; + const splitQuery = query.substring(1).split('&'); + + splitQuery.forEach((param) => { + if (param) { + const paramSplit = param.split('='); + + queryParams[paramSplit[0]] = paramSplit[1]; + } + }); + + onComplete(); + deferred.resolve(queryParams); + }; + + return deferred.promise(); +} + +function executeIntermediateRequest(payload, ajaxOptions) { + return createAjaxRequest(ajaxOptions).request.then((data) => { + return requestAction({ + action: 'continueOAuth', + queryParams: { + ...data, + callbackUrl + }, + ...payload + }); + }); +} + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [START_OAUTH]: function(getState, payload, dispatch) { + const { + name, + section: actionSection, + ...otherPayload + } = payload; + + const actionPayload = { + action: 'startOAuth', + queryParams: { callbackUrl }, + ...otherPayload + }; + + dispatch(setOAuthValue({ + authorizing: true + })); + + let startResponse = {}; + + const promise = requestAction(actionPayload) + .then((response) => { + startResponse = response; + + if (response.oauthUrl) { + return showOAuthWindow(response.oauthUrl, payload); + } + + return executeIntermediateRequest(otherPayload, response).then((intermediateResponse) => { + startResponse = intermediateResponse; + + return showOAuthWindow(intermediateResponse.oauthUrl, payload); + }); + }) + .then((queryParams) => { + return requestAction({ + action: 'getOAuthToken', + queryParams: { + ...startResponse, + ...queryParams + }, + ...otherPayload + }); + }) + .then((response) => { + dispatch(setOAuthValue({ + authorizing: false, + result: response, + error: null + })); + }); + + promise.done(() => { + // Clear any previously set save error. + dispatch(set({ + section: actionSection, + saveError: null + })); + }); + + promise.fail((xhr) => { + const actions = [ + setOAuthValue({ + authorizing: false, + result: null, + error: xhr + }) + ]; + + if (xhr.status === 400) { + // Set a save error so the UI can display validation errors to the user. + actions.splice(0, 0, set({ + section: actionSection, + saveError: xhr + })); + } + + dispatch(batchActions(actions)); + }); + } + +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_OAUTH_VALUE]: function(state, { payload }) { + const newState = Object.assign(getSectionState(state, section), payload); + + return updateSectionState(state, section, newState); + }, + + [RESET_OAUTH]: function(state) { + return updateSectionState(state, section, defaultState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/organizePreviewActions.js b/frontend/src/Store/Actions/organizePreviewActions.js new file mode 100644 index 000000000..78f943f32 --- /dev/null +++ b/frontend/src/Store/Actions/organizePreviewActions.js @@ -0,0 +1,51 @@ +import { createAction } from 'redux-actions'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'organizePreview'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [] +}; + +// +// Actions Types + +export const FETCH_ORGANIZE_PREVIEW = 'organizePreview/fetchOrganizePreview'; +export const CLEAR_ORGANIZE_PREVIEW = 'organizePreview/clearOrganizePreview'; + +// +// Action Creators + +export const fetchOrganizePreview = createThunk(FETCH_ORGANIZE_PREVIEW); +export const clearOrganizePreview = createAction(CLEAR_ORGANIZE_PREVIEW); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_ORGANIZE_PREVIEW]: createFetchHandler('organizePreview', '/rename') + +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_ORGANIZE_PREVIEW]: (state) => { + return Object.assign({}, state, defaultState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/pathActions.js b/frontend/src/Store/Actions/pathActions.js new file mode 100644 index 000000000..139ab9e23 --- /dev/null +++ b/frontend/src/Store/Actions/pathActions.js @@ -0,0 +1,112 @@ +import { createAction } from 'redux-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createHandleActions from './Creators/createHandleActions'; +import { set } from './baseActions'; + +// +// Variables + +export const section = 'paths'; + +// +// State + +export const defaultState = { + currentPath: '', + isPopulated: false, + isFetching: false, + error: null, + directories: [], + files: [], + parent: null +}; + +// +// Actions Types + +export const FETCH_PATHS = 'paths/fetchPaths'; +export const UPDATE_PATHS = 'paths/updatePaths'; +export const CLEAR_PATHS = 'paths/clearPaths'; + +// +// Action Creators + +export const fetchPaths = createThunk(FETCH_PATHS); +export const updatePaths = createAction(UPDATE_PATHS); +export const clearPaths = createAction(CLEAR_PATHS); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_PATHS]: function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + const { + path, + allowFoldersWithoutTrailingSlashes = false, + includeFiles = false + } = payload; + + const promise = createAjaxRequest({ + url: '/filesystem', + data: { + path, + allowFoldersWithoutTrailingSlashes, + includeFiles + } + }).request; + + promise.done((data) => { + dispatch(updatePaths({ path, ...data })); + + dispatch(set({ + section, + isFetching: false, + isPopulated: true, + error: null + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + } + +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [UPDATE_PATHS]: (state, { payload }) => { + const newState = Object.assign({}, state); + + newState.currentPath = payload.path; + newState.directories = payload.directories; + newState.files = payload.files; + newState.parent = payload.parent; + + return newState; + }, + + [CLEAR_PATHS]: (state, { payload }) => { + const newState = Object.assign({}, state); + + newState.path = ''; + newState.directories = []; + newState.files = []; + newState.parent = ''; + + return newState; + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/providerOptionActions.js b/frontend/src/Store/Actions/providerOptionActions.js new file mode 100644 index 000000000..c8d05e7e1 --- /dev/null +++ b/frontend/src/Store/Actions/providerOptionActions.js @@ -0,0 +1,78 @@ +import { createAction } from 'redux-actions'; +import requestAction from 'Utilities/requestAction'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createHandleActions from './Creators/createHandleActions'; +import { set } from './baseActions'; + +// +// Variables + +export const section = 'providerOptions'; + +// +// State + +export const defaultState = { + items: [], + isFetching: false, + isPopulated: false, + error: false +}; + +// +// Actions Types + +export const FETCH_OPTIONS = 'devices/fetchOptions'; +export const CLEAR_OPTIONS = 'devices/clearOptions'; + +// +// Action Creators + +export const fetchOptions = createThunk(FETCH_OPTIONS); +export const clearOptions = createAction(CLEAR_OPTIONS); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_OPTIONS]: function(getState, payload, dispatch) { + dispatch(set({ + section, + isFetching: true + })); + + const promise = requestAction(payload); + + promise.done((data) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: true, + error: null, + items: data.options || [] + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_OPTIONS]: function(state) { + return updateSectionState(state, section, defaultState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/queueActions.js b/frontend/src/Store/Actions/queueActions.js new file mode 100644 index 000000000..85c301c7d --- /dev/null +++ b/frontend/src/Store/Actions/queueActions.js @@ -0,0 +1,448 @@ +import _ from 'lodash'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; +import { sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createClearReducer from './Creators/Reducers/createClearReducer'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; +import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers'; +import { set, updateItem } from './baseActions'; + +// +// Variables + +export const section = 'queue'; +const status = `${section}.status`; +const details = `${section}.details`; +const paged = `${section}.paged`; + +// +// State + +export const defaultState = { + options: { + includeUnknownArtistItems: false + }, + + status: { + isFetching: false, + isPopulated: false, + error: null, + item: {} + }, + + details: { + isFetching: false, + isPopulated: false, + error: null, + items: [], + params: {} + }, + + paged: { + isFetching: false, + isPopulated: false, + pageSize: 20, + sortKey: 'timeleft', + sortDirection: sortDirections.ASCENDING, + error: null, + items: [], + isGrabbing: false, + isRemoving: false, + + columns: [ + { + name: 'status', + columnLabel: 'Status', + isSortable: true, + isVisible: true, + isModifiable: false + }, + { + name: 'artist.sortName', + label: 'Artist', + isSortable: true, + isVisible: true + }, + { + name: 'album.title', + label: 'Album Title', + isSortable: true, + isVisible: true + }, + { + name: 'album.releaseDate', + label: 'Album Release Date', + isSortable: true, + isVisible: false + }, + { + name: 'quality', + label: 'Quality', + isSortable: true, + isVisible: true + }, + { + name: 'protocol', + label: 'Protocol', + isSortable: true, + isVisible: false + }, + { + name: 'indexer', + label: 'Indexer', + isSortable: true, + isVisible: false + }, + { + name: 'downloadClient', + label: 'Download Client', + isSortable: true, + isVisible: false + }, + { + name: 'title', + label: 'Release Title', + isSortable: true, + isVisible: false + }, + { + name: 'outputPath', + label: 'Output Path', + isSortable: false, + isVisible: false + }, + { + name: 'estimatedCompletionTime', + label: 'Timeleft', + isSortable: true, + isVisible: true + }, + { + name: 'progress', + label: 'Progress', + isSortable: true, + isVisible: true + }, + { + name: 'actions', + columnLabel: 'Actions', + isVisible: true, + isModifiable: false + } + ] + } +}; + +export const persistState = [ + 'queue.options', + 'queue.paged.pageSize', + 'queue.paged.sortKey', + 'queue.paged.sortDirection', + 'queue.paged.columns' +]; + +// +// Helpers + +function fetchDataAugmenter(getState, payload, data) { + data.includeUnknownArtistItems = getState().queue.options.includeUnknownArtistItems; +} + +// +// Actions Types + +export const FETCH_QUEUE_STATUS = 'queue/fetchQueueStatus'; + +export const FETCH_QUEUE_DETAILS = 'queue/fetchQueueDetails'; +export const CLEAR_QUEUE_DETAILS = 'queue/clearQueueDetails'; + +export const FETCH_QUEUE = 'queue/fetchQueue'; +export const GOTO_FIRST_QUEUE_PAGE = 'queue/gotoQueueFirstPage'; +export const GOTO_PREVIOUS_QUEUE_PAGE = 'queue/gotoQueuePreviousPage'; +export const GOTO_NEXT_QUEUE_PAGE = 'queue/gotoQueueNextPage'; +export const GOTO_LAST_QUEUE_PAGE = 'queue/gotoQueueLastPage'; +export const GOTO_QUEUE_PAGE = 'queue/gotoQueuePage'; +export const SET_QUEUE_SORT = 'queue/setQueueSort'; +export const SET_QUEUE_TABLE_OPTION = 'queue/setQueueTableOption'; +export const SET_QUEUE_OPTION = 'queue/setQueueOption'; +export const CLEAR_QUEUE = 'queue/clearQueue'; + +export const GRAB_QUEUE_ITEM = 'queue/grabQueueItem'; +export const GRAB_QUEUE_ITEMS = 'queue/grabQueueItems'; +export const REMOVE_QUEUE_ITEM = 'queue/removeQueueItem'; +export const REMOVE_QUEUE_ITEMS = 'queue/removeQueueItems'; + +// +// Action Creators + +export const fetchQueueStatus = createThunk(FETCH_QUEUE_STATUS); + +export const fetchQueueDetails = createThunk(FETCH_QUEUE_DETAILS); +export const clearQueueDetails = createAction(CLEAR_QUEUE_DETAILS); + +export const fetchQueue = createThunk(FETCH_QUEUE); +export const gotoQueueFirstPage = createThunk(GOTO_FIRST_QUEUE_PAGE); +export const gotoQueuePreviousPage = createThunk(GOTO_PREVIOUS_QUEUE_PAGE); +export const gotoQueueNextPage = createThunk(GOTO_NEXT_QUEUE_PAGE); +export const gotoQueueLastPage = createThunk(GOTO_LAST_QUEUE_PAGE); +export const gotoQueuePage = createThunk(GOTO_QUEUE_PAGE); +export const setQueueSort = createThunk(SET_QUEUE_SORT); +export const setQueueTableOption = createAction(SET_QUEUE_TABLE_OPTION); +export const setQueueOption = createAction(SET_QUEUE_OPTION); +export const clearQueue = createAction(CLEAR_QUEUE); + +export const grabQueueItem = createThunk(GRAB_QUEUE_ITEM); +export const grabQueueItems = createThunk(GRAB_QUEUE_ITEMS); +export const removeQueueItem = createThunk(REMOVE_QUEUE_ITEM); +export const removeQueueItems = createThunk(REMOVE_QUEUE_ITEMS); + +// +// Helpers + +const fetchQueueDetailsHelper = createFetchHandler(details, '/queue/details'); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_QUEUE_STATUS]: createFetchHandler(status, '/queue/status'), + + [FETCH_QUEUE_DETAILS]: function(getState, payload, dispatch) { + let params = payload; + + // If the payload params are empty try to get params from state. + + if (params && !_.isEmpty(params)) { + dispatch(set({ section: details, params })); + } else { + params = getState().queue.details.params; + } + + // Ensure there are params before trying to fetch the queue + // so we don't make a bad request to the server. + + if (params && !_.isEmpty(params)) { + fetchQueueDetailsHelper(getState, params, dispatch); + } + }, + + ...createServerSideCollectionHandlers( + paged, + '/queue', + fetchQueue, + { + [serverSideCollectionHandlers.FETCH]: FETCH_QUEUE, + [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_QUEUE_PAGE, + [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_QUEUE_PAGE, + [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_QUEUE_PAGE, + [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_QUEUE_PAGE, + [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_QUEUE_PAGE, + [serverSideCollectionHandlers.SORT]: SET_QUEUE_SORT + }, + fetchDataAugmenter + ), + + [GRAB_QUEUE_ITEM]: function(getState, payload, dispatch) { + const id = payload.id; + + dispatch(updateItem({ section: paged, id, isGrabbing: true })); + + const promise = createAjaxRequest({ + url: `/queue/grab/${id}`, + method: 'POST' + }).request; + + promise.done((data) => { + dispatch(batchActions([ + fetchQueue(), + + set({ + section: paged, + isGrabbing: false, + grabError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(updateItem({ + section: paged, + id, + isGrabbing: false, + grabError: xhr + })); + }); + }, + + [GRAB_QUEUE_ITEMS]: function(getState, payload, dispatch) { + const ids = payload.ids; + + dispatch(batchActions([ + ...ids.map((id) => { + return updateItem({ + section: paged, + id, + isGrabbing: true + }); + }), + + set({ + section: paged, + isGrabbing: true + }) + ])); + + const promise = createAjaxRequest({ + url: '/queue/grab/bulk', + method: 'POST', + dataType: 'json', + data: JSON.stringify(payload) + }).request; + + promise.done((data) => { + dispatch(batchActions([ + fetchQueue(), + + ...ids.map((id) => { + return updateItem({ + section: paged, + id, + isGrabbing: false, + grabError: null + }); + }), + + set({ + section: paged, + isGrabbing: false, + grabError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(batchActions([ + ...ids.map((id) => { + return updateItem({ + section: paged, + id, + isGrabbing: false, + grabError: null + }); + }), + + set({ section: paged, isGrabbing: false }) + ])); + }); + }, + + [REMOVE_QUEUE_ITEM]: function(getState, payload, dispatch) { + const { + id, + blacklist, + skipredownload + } = payload; + + dispatch(updateItem({ section: paged, id, isRemoving: true })); + + const promise = createAjaxRequest({ + url: `/queue/${id}?blacklist=${blacklist}&skipredownload=${skipredownload}`, + method: 'DELETE' + }).request; + + promise.done((data) => { + dispatch(fetchQueue()); + }); + + promise.fail((xhr) => { + dispatch(updateItem({ section: paged, id, isRemoving: false })); + }); + }, + + [REMOVE_QUEUE_ITEMS]: function(getState, payload, dispatch) { + const { + ids, + blacklist, + skipredownload + } = payload; + + dispatch(batchActions([ + ...ids.map((id) => { + return updateItem({ + section: paged, + id, + isRemoving: true + }); + }), + + set({ section: paged, isRemoving: true }) + ])); + + const promise = createAjaxRequest({ + url: `/queue/bulk?blacklist=${blacklist}&skipredownload=${skipredownload}`, + method: 'DELETE', + dataType: 'json', + data: JSON.stringify({ ids }) + }).request; + + promise.done((data) => { + dispatch(batchActions([ + set({ section: paged, isRemoving: false }), + fetchQueue() + ])); + }); + + promise.fail((xhr) => { + dispatch(batchActions([ + ...ids.map((id) => { + return updateItem({ + section: paged, + id, + isRemoving: false + }); + }), + + set({ section: paged, isRemoving: false }) + ])); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_QUEUE_DETAILS]: createClearReducer(details, defaultState.details), + + [SET_QUEUE_TABLE_OPTION]: createSetTableOptionReducer(paged), + + [SET_QUEUE_OPTION]: function(state, { payload }) { + const queueOptions = state.options; + + return { + ...state, + options: { + ...queueOptions, + ...payload + } + }; + }, + + [CLEAR_QUEUE]: createClearReducer(paged, { + isFetching: false, + isPopulated: false, + error: null, + items: [], + totalPages: 0, + totalRecords: 0 + }) + +}, defaultState, section); + diff --git a/frontend/src/Store/Actions/releaseActions.js b/frontend/src/Store/Actions/releaseActions.js new file mode 100644 index 000000000..fefb64399 --- /dev/null +++ b/frontend/src/Store/Actions/releaseActions.js @@ -0,0 +1,282 @@ +import { createAction } from 'redux-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'releases'; +export const albumSection = 'releases.album'; +export const artistSection = 'releases.artist'; + +let abortCurrentRequest = null; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [], + sortKey: 'releaseWeight', + sortDirection: sortDirections.ASCENDING, + sortPredicates: { + age: function(item, direction) { + return item.ageMinutes; + }, + peers: function(item, direction) { + const seeders = item.seeders || 0; + const leechers = item.leechers || 0; + + return seeders * 1000000 + leechers; + }, + rejections: function(item, direction) { + const rejections = item.rejections; + const releaseWeight = item.releaseWeight; + + if (rejections.length !== 0) { + return releaseWeight + 1000000; + } + + return releaseWeight; + } + }, + + filters: [ + { + key: 'all', + label: 'All', + filters: [] + }, + { + key: 'discography-pack', + label: 'Discography', + filters: [ + { + key: 'discography', + value: true, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'not-discography-pack', + label: 'Not Discography', + filters: [ + { + key: 'discography', + value: false, + type: filterTypes.EQUAL + } + ] + } + ], + + filterPredicates: { + quality: function(item, value, type) { + const qualityId = item.quality.quality.id; + + if (type === filterTypes.EQUAL) { + return qualityId === value; + } + + if (type === filterTypes.NOT_EQUAL) { + return qualityId !== value; + } + + // Default to false + return false; + } + }, + + filterBuilderProps: [ + { + name: 'title', + label: 'Title', + type: filterBuilderTypes.STRING + }, + { + name: 'age', + label: 'Age', + type: filterBuilderTypes.NUMBER + }, + { + name: 'protocol', + label: 'Protocol', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.PROTOCOL + }, + { + name: 'indexerId', + label: 'Indexer', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.INDEXER + }, + { + name: 'size', + label: 'Size', + type: filterBuilderTypes.NUMBER + }, + { + name: 'seeders', + label: 'Seeders', + type: filterBuilderTypes.NUMBER + }, + { + name: 'leechers', + label: 'Peers', + type: filterBuilderTypes.NUMBER + }, + { + name: 'quality', + label: 'Quality', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.QUALITY + }, + { + name: 'rejections', + label: 'Rejections', + type: filterBuilderTypes.NUMBER + } + ], + + album: { + selectedFilterKey: 'all' + }, + + artist: { + selectedFilterKey: 'discography-pack' + } +}; + +export const persistState = [ + 'releases.selectedFilterKey', + 'releases.album.customFilters', + 'releases.artist.customFilters' +]; + +// +// Actions Types + +export const FETCH_RELEASES = 'releases/fetchReleases'; +export const CANCEL_FETCH_RELEASES = 'releases/cancelFetchReleases'; +export const SET_RELEASES_SORT = 'releases/setReleasesSort'; +export const CLEAR_RELEASES = 'releases/clearReleases'; +export const GRAB_RELEASE = 'releases/grabRelease'; +export const UPDATE_RELEASE = 'releases/updateRelease'; +export const SET_ALBUM_RELEASES_FILTER = 'releases/setAlbumReleasesFilter'; +export const SET_ARTIST_RELEASES_FILTER = 'releases/setArtistReleasesFilter'; + +// +// Action Creators + +export const fetchReleases = createThunk(FETCH_RELEASES); +export const cancelFetchReleases = createThunk(CANCEL_FETCH_RELEASES); +export const setReleasesSort = createAction(SET_RELEASES_SORT); +export const clearReleases = createAction(CLEAR_RELEASES); +export const grabRelease = createThunk(GRAB_RELEASE); +export const updateRelease = createAction(UPDATE_RELEASE); +export const setAlbumReleasesFilter = createAction(SET_ALBUM_RELEASES_FILTER); +export const setArtistReleasesFilter = createAction(SET_ARTIST_RELEASES_FILTER); + +// +// Helpers + +const fetchReleasesHelper = createFetchHandler(section, '/release'); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_RELEASES]: function(getState, payload, dispatch) { + const abortRequest = fetchReleasesHelper(getState, payload, dispatch); + + abortCurrentRequest = abortRequest; + }, + + [CANCEL_FETCH_RELEASES]: function(getState, payload, dispatch) { + if (abortCurrentRequest) { + abortCurrentRequest = abortCurrentRequest(); + } + }, + + [GRAB_RELEASE]: function(getState, payload, dispatch) { + const guid = payload.guid; + + dispatch(updateRelease({ guid, isGrabbing: true })); + + const promise = createAjaxRequest({ + url: '/release', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify(payload) + }).request; + + promise.done((data) => { + dispatch(updateRelease({ + guid, + isGrabbing: false, + isGrabbed: true, + grabError: null + })); + }); + + promise.fail((xhr) => { + const grabError = xhr.responseJSON && xhr.responseJSON.message || 'Failed to add to download queue'; + + dispatch(updateRelease({ + guid, + isGrabbing: false, + isGrabbed: false, + grabError + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_RELEASES]: (state) => { + const { + album, + artist, + ...otherDefaultState + } = defaultState; + + return Object.assign({}, state, otherDefaultState); + }, + + [UPDATE_RELEASE]: (state, { payload }) => { + const guid = payload.guid; + const newState = Object.assign({}, state); + const items = newState.items; + + // Return early if there aren't any items (the user closed the modal) + if (!items.length) { + return; + } + + const index = items.findIndex((item) => item.guid === guid); + const item = Object.assign({}, items[index], payload); + + newState.items = [...items]; + newState.items.splice(index, 1, item); + + return newState; + }, + + [SET_RELEASES_SORT]: createSetClientSideCollectionSortReducer(section), + [SET_ALBUM_RELEASES_FILTER]: createSetClientSideCollectionFilterReducer(albumSection), + [SET_ARTIST_RELEASES_FILTER]: createSetClientSideCollectionFilterReducer(artistSection) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/retagPreviewActions.js b/frontend/src/Store/Actions/retagPreviewActions.js new file mode 100644 index 000000000..73632fcf8 --- /dev/null +++ b/frontend/src/Store/Actions/retagPreviewActions.js @@ -0,0 +1,51 @@ +import { createAction } from 'redux-actions'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'retagPreview'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [] +}; + +// +// Actions Types + +export const FETCH_RETAG_PREVIEW = 'retagPreview/fetchRetagPreview'; +export const CLEAR_RETAG_PREVIEW = 'retagPreview/clearRetagPreview'; + +// +// Action Creators + +export const fetchRetagPreview = createThunk(FETCH_RETAG_PREVIEW); +export const clearRetagPreview = createAction(CLEAR_RETAG_PREVIEW); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_RETAG_PREVIEW]: createFetchHandler('retagPreview', '/retag') + +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_RETAG_PREVIEW]: (state) => { + return Object.assign({}, state, defaultState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/rootFolderActions.js b/frontend/src/Store/Actions/rootFolderActions.js new file mode 100644 index 000000000..3e3c7de8a --- /dev/null +++ b/frontend/src/Store/Actions/rootFolderActions.js @@ -0,0 +1,97 @@ +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import { set, updateItem } from './baseActions'; + +// +// Variables + +export const section = 'rootFolders'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + isSaving: false, + saveError: null, + items: [] +}; + +// +// Actions Types + +export const FETCH_ROOT_FOLDERS = 'rootFolders/fetchRootFolders'; +export const ADD_ROOT_FOLDER = 'rootFolders/addRootFolder'; +export const DELETE_ROOT_FOLDER = 'rootFolders/deleteRootFolder'; + +// +// Action Creators + +export const fetchRootFolders = createThunk(FETCH_ROOT_FOLDERS); +export const addRootFolder = createThunk(ADD_ROOT_FOLDER); +export const deleteRootFolder = createThunk(DELETE_ROOT_FOLDER); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_ROOT_FOLDERS]: createFetchHandler('rootFolders', '/rootFolder'), + + [DELETE_ROOT_FOLDER]: createRemoveItemHandler( + 'rootFolders', + '/rootFolder', + (state) => state.rootFolders + ), + + [ADD_ROOT_FOLDER]: function(getState, payload, dispatch) { + const path = payload.path; + + dispatch(set({ + section, + isSaving: true + })); + + const promise = createAjaxRequest({ + url: '/rootFolder', + method: 'POST', + data: JSON.stringify({ path }), + dataType: 'json' + }).request; + + promise.done((data) => { + dispatch(batchActions([ + updateItem({ + section, + ...data + }), + + set({ + section, + isSaving: false, + saveError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + } + +}); + +// +// Reducers + +export const reducers = createHandleActions({}, defaultState, section); diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js new file mode 100644 index 000000000..2a7cfc8b9 --- /dev/null +++ b/frontend/src/Store/Actions/settingsActions.js @@ -0,0 +1,149 @@ +import { createAction } from 'redux-actions'; +import { handleThunks } from 'Store/thunks'; +import createHandleActions from './Creators/createHandleActions'; +import delayProfiles from './Settings/delayProfiles'; +import downloadClients from './Settings/downloadClients'; +import downloadClientOptions from './Settings/downloadClientOptions'; +import general from './Settings/general'; +import indexerOptions from './Settings/indexerOptions'; +import indexers from './Settings/indexers'; +import importLists from './Settings/importLists'; +import importListExclusions from './Settings/importListExclusions'; +import metadataProfiles from './Settings/metadataProfiles'; +import mediaManagement from './Settings/mediaManagement'; +import metadata from './Settings/metadata'; +import metadataProvider from './Settings/metadataProvider'; +import naming from './Settings/naming'; +import namingExamples from './Settings/namingExamples'; +import notifications from './Settings/notifications'; +import qualityDefinitions from './Settings/qualityDefinitions'; +import qualityProfiles from './Settings/qualityProfiles'; +import releaseProfiles from './Settings/releaseProfiles'; +import remotePathMappings from './Settings/remotePathMappings'; +import ui from './Settings/ui'; + +export * from './Settings/delayProfiles'; +export * from './Settings/downloadClients'; +export * from './Settings/downloadClientOptions'; +export * from './Settings/general'; +export * from './Settings/importLists'; +export * from './Settings/importListExclusions'; +export * from './Settings/indexerOptions'; +export * from './Settings/indexers'; +export * from './Settings/metadataProfiles'; +export * from './Settings/mediaManagement'; +export * from './Settings/metadata'; +export * from './Settings/metadataProvider'; +export * from './Settings/naming'; +export * from './Settings/namingExamples'; +export * from './Settings/notifications'; +export * from './Settings/qualityDefinitions'; +export * from './Settings/qualityProfiles'; +export * from './Settings/releaseProfiles'; +export * from './Settings/remotePathMappings'; +export * from './Settings/ui'; + +// +// Variables + +export const section = 'settings'; + +// +// State + +export const defaultState = { + advancedSettings: false, + + delayProfiles: delayProfiles.defaultState, + downloadClients: downloadClients.defaultState, + downloadClientOptions: downloadClientOptions.defaultState, + general: general.defaultState, + indexerOptions: indexerOptions.defaultState, + indexers: indexers.defaultState, + importLists: importLists.defaultState, + importListExclusions: importListExclusions.defaultState, + metadataProfiles: metadataProfiles.defaultState, + mediaManagement: mediaManagement.defaultState, + metadata: metadata.defaultState, + metadataProvider: metadataProvider.defaultState, + naming: naming.defaultState, + namingExamples: namingExamples.defaultState, + notifications: notifications.defaultState, + qualityDefinitions: qualityDefinitions.defaultState, + qualityProfiles: qualityProfiles.defaultState, + releaseProfiles: releaseProfiles.defaultState, + remotePathMappings: remotePathMappings.defaultState, + ui: ui.defaultState +}; + +export const persistState = [ + 'settings.advancedSettings' +]; + +// +// Actions Types + +export const TOGGLE_ADVANCED_SETTINGS = 'settings/toggleAdvancedSettings'; + +// +// Action Creators + +export const toggleAdvancedSettings = createAction(TOGGLE_ADVANCED_SETTINGS); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + ...delayProfiles.actionHandlers, + ...downloadClients.actionHandlers, + ...downloadClientOptions.actionHandlers, + ...general.actionHandlers, + ...indexerOptions.actionHandlers, + ...indexers.actionHandlers, + ...importLists.actionHandlers, + ...importListExclusions.actionHandlers, + ...metadataProfiles.actionHandlers, + ...mediaManagement.actionHandlers, + ...metadata.actionHandlers, + ...metadataProvider.actionHandlers, + ...naming.actionHandlers, + ...namingExamples.actionHandlers, + ...notifications.actionHandlers, + ...qualityDefinitions.actionHandlers, + ...qualityProfiles.actionHandlers, + ...releaseProfiles.actionHandlers, + ...remotePathMappings.actionHandlers, + ...ui.actionHandlers +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [TOGGLE_ADVANCED_SETTINGS]: (state, { payload }) => { + return Object.assign({}, state, { advancedSettings: !state.advancedSettings }); + }, + + ...delayProfiles.reducers, + ...downloadClients.reducers, + ...downloadClientOptions.reducers, + ...general.reducers, + ...indexerOptions.reducers, + ...indexers.reducers, + ...importLists.reducers, + ...importListExclusions.reducers, + ...metadataProfiles.reducers, + ...mediaManagement.reducers, + ...metadata.reducers, + ...metadataProvider.reducers, + ...naming.reducers, + ...namingExamples.reducers, + ...notifications.reducers, + ...qualityDefinitions.reducers, + ...qualityProfiles.reducers, + ...releaseProfiles.reducers, + ...remotePathMappings.reducers, + ...ui.reducers + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/systemActions.js b/frontend/src/Store/Actions/systemActions.js new file mode 100644 index 000000000..d238a92f1 --- /dev/null +++ b/frontend/src/Store/Actions/systemActions.js @@ -0,0 +1,392 @@ +import { createAction } from 'redux-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; +import { filterTypes, sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import { setAppValue } from 'Store/Actions/appActions'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import createClearReducer from './Creators/Reducers/createClearReducer'; +import createFetchHandler from './Creators/createFetchHandler'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import createHandleActions from './Creators/createHandleActions'; +import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers'; +import { set } from './baseActions'; + +// +// Variables + +export const section = 'system'; +const backupsSection = 'system.backups'; + +// +// State + +export const defaultState = { + status: { + isFetching: false, + isPopulated: false, + error: null, + item: {} + }, + + health: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }, + + diskSpace: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }, + + tasks: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }, + + backups: { + isFetching: false, + isPopulated: false, + error: null, + isRestoring: false, + restoreError: null, + isDeleting: false, + deleteError: null, + items: [] + }, + + updates: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }, + + logs: { + isFetching: false, + isPopulated: false, + pageSize: 50, + sortKey: 'time', + sortDirection: sortDirections.DESCENDING, + error: null, + items: [], + + columns: [ + { + name: 'level', + columnLabel: 'Level', + isSortable: false, + isVisible: true, + isModifiable: false + }, + { + name: 'logger', + label: 'Component', + isSortable: false, + isVisible: true, + isModifiable: false + }, + { + name: 'message', + label: 'Message', + isVisible: true, + isModifiable: false + }, + { + name: 'time', + label: 'Time', + isSortable: true, + isVisible: true, + isModifiable: false + }, + { + name: 'actions', + columnLabel: 'Actions', + isSortable: true, + isVisible: true, + isModifiable: false + } + ], + + selectedFilterKey: 'all', + + filters: [ + { + key: 'all', + label: 'All', + filters: [] + }, + { + key: 'info', + label: 'Info', + filters: [ + { + key: 'level', + value: 'info', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'warn', + label: 'Warn', + filters: [ + { + key: 'level', + value: 'warn', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'error', + label: 'Error', + filters: [ + { + key: 'level', + value: 'error', + type: filterTypes.EQUAL + } + ] + } + ] + }, + + logFiles: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }, + + updateLogFiles: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + } +}; + +export const persistState = [ + 'system.logs.pageSize', + 'system.logs.sortKey', + 'system.logs.sortDirection', + 'system.logs.selectedFilterKey' +]; + +// +// Actions Types + +export const FETCH_STATUS = 'system/status/fetchStatus'; +export const FETCH_HEALTH = 'system/health/fetchHealth'; +export const FETCH_DISK_SPACE = 'system/diskSpace/fetchDiskSPace'; + +export const FETCH_TASK = 'system/tasks/fetchTask'; +export const FETCH_TASKS = 'system/tasks/fetchTasks'; + +export const FETCH_BACKUPS = 'system/backups/fetchBackups'; +export const RESTORE_BACKUP = 'system/backups/restoreBackup'; +export const CLEAR_RESTORE_BACKUP = 'system/backups/clearRestoreBackup'; +export const DELETE_BACKUP = 'system/backups/deleteBackup'; + +export const FETCH_UPDATES = 'system/updates/fetchUpdates'; + +export const FETCH_LOGS = 'system/logs/fetchLogs'; +export const GOTO_FIRST_LOGS_PAGE = 'system/logs/gotoLogsFirstPage'; +export const GOTO_PREVIOUS_LOGS_PAGE = 'system/logs/gotoLogsPreviousPage'; +export const GOTO_NEXT_LOGS_PAGE = 'system/logs/gotoLogsNextPage'; +export const GOTO_LAST_LOGS_PAGE = 'system/logs/gotoLogsLastPage'; +export const GOTO_LOGS_PAGE = 'system/logs/gotoLogsPage'; +export const SET_LOGS_SORT = 'system/logs/setLogsSort'; +export const SET_LOGS_FILTER = 'system/logs/setLogsFilter'; +export const SET_LOGS_TABLE_OPTION = 'system/logs/setLogsTableOption'; +export const CLEAR_LOGS_TABLE = 'system/logs/clearLogsTable'; + +export const FETCH_LOG_FILES = 'system/logFiles/fetchLogFiles'; +export const FETCH_UPDATE_LOG_FILES = 'system/updateLogFiles/fetchUpdateLogFiles'; + +export const RESTART = 'system/restart'; +export const SHUTDOWN = 'system/shutdown'; + +// +// Action Creators + +export const fetchStatus = createThunk(FETCH_STATUS); +export const fetchHealth = createThunk(FETCH_HEALTH); +export const fetchDiskSpace = createThunk(FETCH_DISK_SPACE); + +export const fetchTask = createThunk(FETCH_TASK); +export const fetchTasks = createThunk(FETCH_TASKS); + +export const fetchBackups = createThunk(FETCH_BACKUPS); +export const restoreBackup = createThunk(RESTORE_BACKUP); +export const clearRestoreBackup = createAction(CLEAR_RESTORE_BACKUP); +export const deleteBackup = createThunk(DELETE_BACKUP); + +export const fetchUpdates = createThunk(FETCH_UPDATES); + +export const fetchLogs = createThunk(FETCH_LOGS); +export const gotoLogsFirstPage = createThunk(GOTO_FIRST_LOGS_PAGE); +export const gotoLogsPreviousPage = createThunk(GOTO_PREVIOUS_LOGS_PAGE); +export const gotoLogsNextPage = createThunk(GOTO_NEXT_LOGS_PAGE); +export const gotoLogsLastPage = createThunk(GOTO_LAST_LOGS_PAGE); +export const gotoLogsPage = createThunk(GOTO_LOGS_PAGE); +export const setLogsSort = createThunk(SET_LOGS_SORT); +export const setLogsFilter = createThunk(SET_LOGS_FILTER); +export const setLogsTableOption = createAction(SET_LOGS_TABLE_OPTION); +export const clearLogsTable = createAction(CLEAR_LOGS_TABLE); + +export const fetchLogFiles = createThunk(FETCH_LOG_FILES); +export const fetchUpdateLogFiles = createThunk(FETCH_UPDATE_LOG_FILES); + +export const restart = createThunk(RESTART); +export const shutdown = createThunk(SHUTDOWN); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_STATUS]: createFetchHandler('system.status', '/system/status'), + [FETCH_HEALTH]: createFetchHandler('system.health', '/health'), + [FETCH_DISK_SPACE]: createFetchHandler('system.diskSpace', '/diskspace'), + [FETCH_TASK]: createFetchHandler('system.tasks', '/system/task'), + [FETCH_TASKS]: createFetchHandler('system.tasks', '/system/task'), + + [FETCH_BACKUPS]: createFetchHandler(backupsSection, '/system/backup'), + + [RESTORE_BACKUP]: function(getState, payload, dispatch) { + const { + id, + file + } = payload; + + dispatch(set({ + section: backupsSection, + isRestoring: true + })); + + let ajaxOptions = null; + + if (id) { + ajaxOptions = { + url: `/system/backup/restore/${id}`, + method: 'POST', + contentType: 'application/json', + dataType: 'json', + data: JSON.stringify({ + id + }) + }; + } else if (file) { + const formData = new FormData(); + formData.append('restore', file); + + ajaxOptions = { + url: '/system/backup/restore/upload', + method: 'POST', + processData: false, + contentType: false, + data: formData + }; + } else { + dispatch(set({ + section: backupsSection, + isRestoring: false, + restoreError: 'Error restoring backup' + })); + } + + const promise = createAjaxRequest(ajaxOptions).request; + + promise.done((data) => { + dispatch(set({ + section: backupsSection, + isRestoring: false, + restoreError: null + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section: backupsSection, + isRestoring: false, + restoreError: xhr + })); + }); + }, + + [DELETE_BACKUP]: createRemoveItemHandler(backupsSection, '/system/backup'), + + [FETCH_UPDATES]: createFetchHandler('system.updates', '/update'), + [FETCH_LOG_FILES]: createFetchHandler('system.logFiles', '/log/file'), + [FETCH_UPDATE_LOG_FILES]: createFetchHandler('system.updateLogFiles', '/log/file/update'), + + ...createServerSideCollectionHandlers( + 'system.logs', + '/log', + fetchLogs, + { + [serverSideCollectionHandlers.FETCH]: FETCH_LOGS, + [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_LOGS_PAGE, + [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_LOGS_PAGE, + [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_LOGS_PAGE, + [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_LOGS_PAGE, + [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_LOGS_PAGE, + [serverSideCollectionHandlers.SORT]: SET_LOGS_SORT, + [serverSideCollectionHandlers.FILTER]: SET_LOGS_FILTER + } + ), + + [RESTART]: function(getState, payload, dispatch) { + const promise = createAjaxRequest({ + url: '/system/restart', + method: 'POST' + }).request; + + promise.done(() => { + dispatch(setAppValue({ isRestarting: true })); + }); + }, + + [SHUTDOWN]: function() { + createAjaxRequest({ + url: '/system/shutdown', + method: 'POST' + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_RESTORE_BACKUP]: function(state, { payload }) { + return { + ...state, + backups: { + ...state.backups, + isRestoring: false, + restoreError: null + } + }; + }, + + [SET_LOGS_TABLE_OPTION]: createSetTableOptionReducer('logs'), + + [CLEAR_LOGS_TABLE]: createClearReducer(section, { + isFetching: false, + isPopulated: false, + error: null, + items: [], + totalPages: 0, + totalRecords: 0 + }) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/tagActions.js b/frontend/src/Store/Actions/tagActions.js new file mode 100644 index 000000000..5389b1a6b --- /dev/null +++ b/frontend/src/Store/Actions/tagActions.js @@ -0,0 +1,75 @@ +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createFetchHandler from './Creators/createFetchHandler'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import createHandleActions from './Creators/createHandleActions'; +import { update } from './baseActions'; + +// +// Variables + +export const section = 'tags'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [], + + details: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + } +}; + +// +// Actions Types + +export const FETCH_TAGS = 'tags/fetchTags'; +export const ADD_TAG = 'tags/addTag'; +export const DELETE_TAG = 'tags/deleteTag'; +export const FETCH_TAG_DETAILS = 'tags/fetchTagDetails'; + +// +// Action Creators + +export const fetchTags = createThunk(FETCH_TAGS); +export const addTag = createThunk(ADD_TAG); +export const deleteTag = createThunk(DELETE_TAG); +export const fetchTagDetails = createThunk(FETCH_TAG_DETAILS); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_TAGS]: createFetchHandler(section, '/tag'), + + [ADD_TAG]: function(getState, payload, dispatch) { + const promise = createAjaxRequest({ + url: '/tag', + method: 'POST', + data: JSON.stringify(payload.tag) + }).request; + + promise.done((data) => { + const tags = getState().tags.items.slice(); + tags.push(data); + + dispatch(update({ section, data: tags })); + payload.onTagCreated(data); + }); + }, + + [DELETE_TAG]: createRemoveItemHandler(section, '/tag'), + [FETCH_TAG_DETAILS]: createFetchHandler('tags.details', '/tag/detail') + +}); + +// +// Reducers +export const reducers = createHandleActions({}, defaultState, section); diff --git a/frontend/src/Store/Actions/trackActions.js b/frontend/src/Store/Actions/trackActions.js new file mode 100644 index 000000000..44292271a --- /dev/null +++ b/frontend/src/Store/Actions/trackActions.js @@ -0,0 +1,125 @@ +import { createAction } from 'redux-actions'; +import { sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'tracks'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + sortKey: 'mediumNumber', + sortDirection: sortDirections.ASCENDING, + secondarySortKey: 'absoluteTrackNumber', + secondarySortDirection: sortDirections.ASCENDING, + items: [], + + columns: [ + { + name: 'medium', + label: 'Medium', + isVisible: false + }, + { + name: 'absoluteTrackNumber', + label: 'Track', + isVisible: true + }, + { + name: 'title', + label: 'Title', + isVisible: true + }, + { + name: 'path', + label: 'Path', + isVisible: false + }, + { + name: 'relativePath', + label: 'Relative Path', + isVisible: false + }, + { + name: 'duration', + label: 'Duration', + isVisible: true + }, + { + name: 'audioInfo', + label: 'Audio Info', + isVisible: true + }, + { + name: 'status', + label: 'Status', + isVisible: true + }, + { + name: 'actions', + columnLabel: 'Actions', + isVisible: true, + isModifiable: false + } + ] +}; + +export const persistState = [ + 'tracks.sortKey', + 'tracks.sortDirection', + 'tracks.columns' +]; + +// +// Actions Types + +export const FETCH_TRACKS = 'tracks/fetchTracks'; +export const SET_TRACKS_SORT = 'tracks/setTracksSort'; +export const SET_TRACKS_TABLE_OPTION = 'tracks/setTracksTableOption'; +export const CLEAR_TRACKS = 'tracks/clearTracks'; + +// +// Action Creators + +export const fetchTracks = createThunk(FETCH_TRACKS); +export const setTracksSort = createAction(SET_TRACKS_SORT); +export const setTracksTableOption = createAction(SET_TRACKS_TABLE_OPTION); +export const clearTracks = createAction(CLEAR_TRACKS); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_TRACKS]: createFetchHandler(section, '/track') + +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_TRACKS_TABLE_OPTION]: createSetTableOptionReducer(section), + + [FETCH_TRACKS]: (state) => { + return Object.assign({}, state, { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }); + }, + + [SET_TRACKS_SORT]: createSetClientSideCollectionSortReducer(section) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/trackFileActions.js b/frontend/src/Store/Actions/trackFileActions.js new file mode 100644 index 000000000..331c6c05c --- /dev/null +++ b/frontend/src/Store/Actions/trackFileActions.js @@ -0,0 +1,262 @@ +import _ from 'lodash'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createClearReducer from './Creators/Reducers/createClearReducer'; +import albumEntities from 'Album/albumEntities'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import { set, removeItem, updateItem } from './baseActions'; + +// +// Variables + +export const section = 'trackFiles'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + sortKey: 'path', + sortDirection: sortDirections.ASCENDING, + + error: null, + isDeleting: false, + deleteError: null, + isSaving: false, + saveError: null, + items: [], + + sortPredicates: { + quality: function(item, direction) { + return item.quality ? item.qualityWeight : 0; + } + }, + + columns: [ + { + name: 'path', + label: 'Path', + isSortable: true, + isVisible: true, + isModifiable: false + }, + { + name: 'size', + label: 'Size', + isSortable: true, + isVisible: true + }, + { + name: 'dateAdded', + label: 'Date Added', + isSortable: true, + isVisible: true + }, + { + name: 'quality', + label: 'Quality', + isSortable: true, + isVisible: true + }, + { + name: 'actions', + columnLabel: 'Actions', + isVisible: true, + isModifiable: false + } + ] +}; + +export const persistState = [ + 'trackFiles.sortKey', + 'trackFiles.sortDirection' +]; + +// +// Actions Types + +export const FETCH_TRACK_FILES = 'trackFiles/fetchTrackFiles'; +export const DELETE_TRACK_FILE = 'trackFiles/deleteTrackFile'; +export const DELETE_TRACK_FILES = 'trackFiles/deleteTrackFiles'; +export const UPDATE_TRACK_FILES = 'trackFiles/updateTrackFiles'; +export const SET_TRACK_FILES_SORT = 'trackFiles/setTrackFilesSort'; +export const SET_TRACK_FILES_TABLE_OPTION = 'trackFiles/setTrackFilesTableOption'; +export const CLEAR_TRACK_FILES = 'trackFiles/clearTrackFiles'; + +// +// Action Creators + +export const fetchTrackFiles = createThunk(FETCH_TRACK_FILES); +export const deleteTrackFile = createThunk(DELETE_TRACK_FILE); +export const deleteTrackFiles = createThunk(DELETE_TRACK_FILES); +export const updateTrackFiles = createThunk(UPDATE_TRACK_FILES); +export const setTrackFilesSort = createAction(SET_TRACK_FILES_SORT); +export const setTrackFilesTableOption = createAction(SET_TRACK_FILES_TABLE_OPTION); +export const clearTrackFiles = createAction(CLEAR_TRACK_FILES); + +// +// Helpers + +const deleteTrackFileHelper = createRemoveItemHandler(section, '/trackFile'); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_TRACK_FILES]: createFetchHandler(section, '/trackFile'), + + [DELETE_TRACK_FILE]: function(getState, payload, dispatch) { + const { + id: trackFileId, + albumEntity = albumEntities.ALBUMS + } = payload; + + const albumSection = _.last(albumEntity.split('.')); + const deletePromise = deleteTrackFileHelper(getState, payload, dispatch); + + deletePromise.done(() => { + const albums = getState().albums.items; + const tracksWithRemovedFiles = _.filter(albums, { trackFileId }); + + dispatch(batchActions([ + ...tracksWithRemovedFiles.map((track) => { + return updateItem({ + section: albumSection, + ...track, + trackFileId: 0, + hasFile: false + }); + }) + ])); + }); + }, + + [DELETE_TRACK_FILES]: function(getState, payload, dispatch) { + const { + trackFileIds + } = payload; + + dispatch(set({ section, isDeleting: true })); + + const promise = createAjaxRequest({ + url: '/trackFile/bulk', + method: 'DELETE', + dataType: 'json', + data: JSON.stringify({ trackFileIds }) + }).request; + + promise.done(() => { + const tracks = getState().tracks.items; + const tracksWithRemovedFiles = trackFileIds.reduce((acc, trackFileId) => { + acc.push(..._.filter(tracks, { trackFileId })); + + return acc; + }, []); + + dispatch(batchActions([ + ...trackFileIds.map((id) => { + return removeItem({ section, id }); + }), + + ...tracksWithRemovedFiles.map((track) => { + return updateItem({ + section: 'tracks', + ...track, + trackFileId: 0, + hasFile: false + }); + }), + + set({ + section, + isDeleting: false, + deleteError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isDeleting: false, + deleteError: xhr + })); + }); + }, + + [UPDATE_TRACK_FILES]: function(getState, payload, dispatch) { + const { + trackFileIds, + quality + } = payload; + + dispatch(set({ section, isSaving: true })); + + const data = { + trackFileIds + }; + + if (quality) { + data.quality = quality; + } + + const promise = createAjaxRequest({ + url: '/trackFile/editor', + method: 'PUT', + dataType: 'json', + data: JSON.stringify(data) + }).request; + + promise.done(() => { + dispatch(batchActions([ + ...trackFileIds.map((id) => { + const props = {}; + + if (quality) { + props.quality = quality; + } + + return updateItem({ section, id, ...props }); + }), + + set({ + section, + isSaving: false, + saveError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + [SET_TRACK_FILES_SORT]: createSetClientSideCollectionSortReducer(section), + [SET_TRACK_FILES_TABLE_OPTION]: createSetTableOptionReducer(section), + + [CLEAR_TRACK_FILES]: createClearReducer(section, { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/wantedActions.js b/frontend/src/Store/Actions/wantedActions.js new file mode 100644 index 000000000..4df132504 --- /dev/null +++ b/frontend/src/Store/Actions/wantedActions.js @@ -0,0 +1,316 @@ +import { createAction } from 'redux-actions'; +import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; +import { filterTypes, sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createClearReducer from './Creators/Reducers/createClearReducer'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import createBatchToggleAlbumMonitoredHandler from './Creators/createBatchToggleAlbumMonitoredHandler'; +import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'wanted'; + +// +// State + +export const defaultState = { + missing: { + isFetching: false, + isPopulated: false, + pageSize: 20, + sortKey: 'releaseDate', + sortDirection: sortDirections.DESCENDING, + error: null, + items: [], + + columns: [ + { + name: 'artist.sortName', + label: 'Artist Name', + isSortable: true, + isVisible: true + }, + { + name: 'albumTitle', + label: 'Album Title', + isSortable: true, + isVisible: true + }, + { + name: 'albumType', + label: 'Album Type', + isSortable: true, + isVisible: true + }, + { + name: 'releaseDate', + label: 'Release Date', + isSortable: true, + isVisible: true + }, + // { + // name: 'status', + // label: 'Status', + // isVisible: true + // }, + { + name: 'actions', + columnLabel: 'Actions', + isVisible: true, + isModifiable: false + } + ], + + selectedFilterKey: 'monitored', + + filters: [ + { + key: 'monitored', + label: 'Monitored', + filters: [ + { + key: 'monitored', + value: true, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'unmonitored', + label: 'Unmonitored', + filters: [ + { + key: 'monitored', + value: false, + type: filterTypes.EQUAL + } + ] + } + ] + }, + + cutoffUnmet: { + isFetching: false, + isPopulated: false, + pageSize: 20, + sortKey: 'releaseDate', + sortDirection: sortDirections.DESCENDING, + items: [], + + columns: [ + { + name: 'artist.sortName', + label: 'Artist Name', + isSortable: true, + isVisible: true + }, + { + name: 'albumTitle', + label: 'Album Title', + isSortable: true, + isVisible: true + }, + { + name: 'albumType', + label: 'Album Type', + isSortable: true, + isVisible: true + }, + { + name: 'releaseDate', + label: 'Release Date', + isSortable: true, + isVisible: true + }, + // { + // name: 'status', + // label: 'Status', + // isVisible: true + // }, + { + name: 'actions', + columnLabel: 'Actions', + isVisible: true, + isModifiable: false + } + ], + + selectedFilterKey: 'monitored', + + filters: [ + { + key: 'monitored', + label: 'Monitored', + filters: [ + { + key: 'monitored', + value: true, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'unmonitored', + label: 'Unmonitored', + filters: [ + { + key: 'monitored', + value: false, + type: filterTypes.EQUAL + } + ] + } + ] + } +}; + +export const persistState = [ + 'wanted.missing.pageSize', + 'wanted.missing.sortKey', + 'wanted.missing.sortDirection', + 'wanted.missing.selectedFilterKey', + 'wanted.missing.columns', + 'wanted.cutoffUnmet.pageSize', + 'wanted.cutoffUnmet.sortKey', + 'wanted.cutoffUnmet.sortDirection', + 'wanted.cutoffUnmet.selectedFilterKey', + 'wanted.cutoffUnmet.columns' +]; + +// +// Actions Types + +export const FETCH_MISSING = 'wanted/missing/fetchMissing'; +export const GOTO_FIRST_MISSING_PAGE = 'wanted/missing/gotoMissingFirstPage'; +export const GOTO_PREVIOUS_MISSING_PAGE = 'wanted/missing/gotoMissingPreviousPage'; +export const GOTO_NEXT_MISSING_PAGE = 'wanted/missing/gotoMissingNextPage'; +export const GOTO_LAST_MISSING_PAGE = 'wanted/missing/gotoMissingLastPage'; +export const GOTO_MISSING_PAGE = 'wanted/missing/gotoMissingPage'; +export const SET_MISSING_SORT = 'wanted/missing/setMissingSort'; +export const SET_MISSING_FILTER = 'wanted/missing/setMissingFilter'; +export const SET_MISSING_TABLE_OPTION = 'wanted/missing/setMissingTableOption'; +export const CLEAR_MISSING = 'wanted/missing/clearMissing'; + +export const BATCH_TOGGLE_MISSING_ALBUMS = 'wanted/missing/batchToggleMissingAlbums'; + +export const FETCH_CUTOFF_UNMET = 'wanted/cutoffUnmet/fetchCutoffUnmet'; +export const GOTO_FIRST_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetFirstPage'; +export const GOTO_PREVIOUS_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetPreviousPage'; +export const GOTO_NEXT_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetNextPage'; +export const GOTO_LAST_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetFastPage'; +export const GOTO_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetPage'; +export const SET_CUTOFF_UNMET_SORT = 'wanted/cutoffUnmet/setCutoffUnmetSort'; +export const SET_CUTOFF_UNMET_FILTER = 'wanted/cutoffUnmet/setCutoffUnmetFilter'; +export const SET_CUTOFF_UNMET_TABLE_OPTION = 'wanted/cutoffUnmet/setCutoffUnmetTableOption'; +export const CLEAR_CUTOFF_UNMET = 'wanted/cutoffUnmet/clearCutoffUnmet'; + +export const BATCH_TOGGLE_CUTOFF_UNMET_ALBUMS = 'wanted/cutoffUnmet/batchToggleCutoffUnmetAlbums'; + +// +// Action Creators + +export const fetchMissing = createThunk(FETCH_MISSING); +export const gotoMissingFirstPage = createThunk(GOTO_FIRST_MISSING_PAGE); +export const gotoMissingPreviousPage = createThunk(GOTO_PREVIOUS_MISSING_PAGE); +export const gotoMissingNextPage = createThunk(GOTO_NEXT_MISSING_PAGE); +export const gotoMissingLastPage = createThunk(GOTO_LAST_MISSING_PAGE); +export const gotoMissingPage = createThunk(GOTO_MISSING_PAGE); +export const setMissingSort = createThunk(SET_MISSING_SORT); +export const setMissingFilter = createThunk(SET_MISSING_FILTER); +export const setMissingTableOption = createAction(SET_MISSING_TABLE_OPTION); +export const clearMissing = createAction(CLEAR_MISSING); + +export const batchToggleMissingAlbums = createThunk(BATCH_TOGGLE_MISSING_ALBUMS); + +export const fetchCutoffUnmet = createThunk(FETCH_CUTOFF_UNMET); +export const gotoCutoffUnmetFirstPage = createThunk(GOTO_FIRST_CUTOFF_UNMET_PAGE); +export const gotoCutoffUnmetPreviousPage = createThunk(GOTO_PREVIOUS_CUTOFF_UNMET_PAGE); +export const gotoCutoffUnmetNextPage = createThunk(GOTO_NEXT_CUTOFF_UNMET_PAGE); +export const gotoCutoffUnmetLastPage = createThunk(GOTO_LAST_CUTOFF_UNMET_PAGE); +export const gotoCutoffUnmetPage = createThunk(GOTO_CUTOFF_UNMET_PAGE); +export const setCutoffUnmetSort = createThunk(SET_CUTOFF_UNMET_SORT); +export const setCutoffUnmetFilter = createThunk(SET_CUTOFF_UNMET_FILTER); +export const setCutoffUnmetTableOption = createAction(SET_CUTOFF_UNMET_TABLE_OPTION); +export const clearCutoffUnmet = createAction(CLEAR_CUTOFF_UNMET); + +export const batchToggleCutoffUnmetAlbums = createThunk(BATCH_TOGGLE_CUTOFF_UNMET_ALBUMS); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + ...createServerSideCollectionHandlers( + 'wanted.missing', + '/wanted/missing', + fetchMissing, + { + [serverSideCollectionHandlers.FETCH]: FETCH_MISSING, + [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_MISSING_PAGE, + [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_MISSING_PAGE, + [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_MISSING_PAGE, + [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_MISSING_PAGE, + [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_MISSING_PAGE, + [serverSideCollectionHandlers.SORT]: SET_MISSING_SORT, + [serverSideCollectionHandlers.FILTER]: SET_MISSING_FILTER + } + ), + + [BATCH_TOGGLE_MISSING_ALBUMS]: createBatchToggleAlbumMonitoredHandler('wanted.missing', fetchMissing), + + ...createServerSideCollectionHandlers( + 'wanted.cutoffUnmet', + '/wanted/cutoff', + fetchCutoffUnmet, + { + [serverSideCollectionHandlers.FETCH]: FETCH_CUTOFF_UNMET, + [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_CUTOFF_UNMET_PAGE, + [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_CUTOFF_UNMET_PAGE, + [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_CUTOFF_UNMET_PAGE, + [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_CUTOFF_UNMET_PAGE, + [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_CUTOFF_UNMET_PAGE, + [serverSideCollectionHandlers.SORT]: SET_CUTOFF_UNMET_SORT, + [serverSideCollectionHandlers.FILTER]: SET_CUTOFF_UNMET_FILTER + } + ), + + [BATCH_TOGGLE_CUTOFF_UNMET_ALBUMS]: createBatchToggleAlbumMonitoredHandler('wanted.cutoffUnmet', fetchCutoffUnmet) + +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_MISSING_TABLE_OPTION]: createSetTableOptionReducer('wanted.missing'), + [SET_CUTOFF_UNMET_TABLE_OPTION]: createSetTableOptionReducer('wanted.cutoffUnmet'), + + [CLEAR_MISSING]: createClearReducer( + 'wanted.missing', + { + isFetching: false, + isPopulated: false, + error: null, + items: [], + totalPages: 0, + totalRecords: 0 + } + ), + + [CLEAR_CUTOFF_UNMET]: createClearReducer( + 'wanted.cutoffUnmet', + { + isFetching: false, + isPopulated: false, + error: null, + items: [], + totalPages: 0, + totalRecords: 0 + } + ) + +}, defaultState, section); diff --git a/frontend/src/Store/Middleware/createPersistState.js b/frontend/src/Store/Middleware/createPersistState.js new file mode 100644 index 000000000..407044d56 --- /dev/null +++ b/frontend/src/Store/Middleware/createPersistState.js @@ -0,0 +1,101 @@ +import _ from 'lodash'; +import persistState from 'redux-localstorage'; +import actions from 'Store/Actions'; +import migrate from 'Store/Migrators/migrate'; + +const columnPaths = []; + +const paths = _.reduce([...actions], (acc, action) => { + if (action.persistState) { + action.persistState.forEach((path) => { + if (path.match(/\.columns$/)) { + columnPaths.push(path); + } + + acc.push(path); + }); + } + + return acc; +}, []); + +function mergeColumns(path, initialState, persistedState, computedState) { + const initialColumns = _.get(initialState, path); + const persistedColumns = _.get(persistedState, path); + + if (!persistedColumns || !persistedColumns.length) { + return; + } + + const columns = []; + + initialColumns.forEach((initialColumn) => { + const persistedColumnIndex = _.findIndex(persistedColumns, { name: initialColumn.name }); + const column = Object.assign({}, initialColumn); + const persistedColumn = persistedColumnIndex > -1 ? persistedColumns[persistedColumnIndex] : undefined; + + if (persistedColumn) { + column.isVisible = persistedColumn.isVisible; + } + + // If there is a persisted column, it's index doesn't exceed the column list + // and it's modifiable, insert it in the proper position. + + if (persistedColumn && columns.length - 1 > persistedColumnIndex && persistedColumn.isModifiable !== false) { + columns.splice(persistedColumnIndex, 0, column); + } else { + columns.push(column); + } + + // Set the columns in the persisted state + _.set(computedState, path, columns); + }); +} + +function slicer(paths_) { + return (state) => { + const subset = {}; + + paths_.forEach((path) => { + _.set(subset, path, _.get(state, path)); + }); + + return subset; + }; +} + +function serialize(obj) { + return JSON.stringify(obj, null, 2); +} + +function merge(initialState, persistedState) { + if (!persistedState) { + return initialState; + } + + const computedState = {}; + + _.merge(computedState, initialState, persistedState); + + columnPaths.forEach((columnPath) => { + mergeColumns(columnPath, initialState, persistedState, computedState); + }); + + return computedState; +} + +const config = { + slicer, + serialize, + merge, + key: 'lidarr' +}; + +export default function createPersistState() { + // Migrate existing local storage before proceeding + const persistedState = JSON.parse(localStorage.getItem(config.key)); + migrate(persistedState); + localStorage.setItem(config.key, serialize(persistedState)); + + return persistState(paths, config); +} diff --git a/frontend/src/Store/Middleware/createSentryMiddleware.js b/frontend/src/Store/Middleware/createSentryMiddleware.js new file mode 100644 index 000000000..b567c83f1 --- /dev/null +++ b/frontend/src/Store/Middleware/createSentryMiddleware.js @@ -0,0 +1,93 @@ +import _ from 'lodash'; +import * as sentry from '@sentry/browser'; +import parseUrl from 'Utilities/String/parseUrl'; + +function cleanseUrl(url) { + const properties = parseUrl(url); + + return `${properties.pathname}${properties.search}`; +} + +function cleanseData(data) { + const result = _.cloneDeep(data); + + result.transaction = cleanseUrl(result.transaction); + + if (result.exception) { + result.exception.values.forEach((exception) => { + const stacktrace = exception.stacktrace; + + if (stacktrace) { + stacktrace.frames.forEach((frame) => { + frame.filename = cleanseUrl(frame.filename); + }); + } + }); + } + + result.request.url = cleanseUrl(result.request.url); + + return result; +} + +function identity(stuff) { + return stuff; +} + +function createMiddleware() { + return (store) => (next) => (action) => { + try { + // Adds a breadcrumb for reporting later (if necessary). + sentry.addBreadcrumb({ + category: 'redux', + message: action.type + }); + + return next(action); + } catch (err) { + console.error(`[sentry] Reporting error to Sentry: ${err}`); + + // Send the report including breadcrumbs. + sentry.captureException(err, { + extra: { + action: identity(action), + state: identity(store.getState()) + } + }); + } + }; +} + +export default function createSentryMiddleware() { + const { + analytics, + branch, + version, + release, + userHash, + isProduction + } = window.Lidarr; + + if (!analytics) { + return; + } + + const dsn = isProduction ? 'https://c3a5b33e08de4e18b7d0505e942dbc95@sentry.io/216290' : + 'https://baede6f14da54cf48ff431479e400adf@sentry.io/1249427'; + + sentry.init({ + dsn, + environment: branch, + release, + sendDefaultPii: true, + beforeSend: cleanseData + }); + + sentry.configureScope((scope) => { + scope.setUser({ username: userHash }); + scope.setTag('version', version); + scope.setTag('production', isProduction); + }); + + return createMiddleware(); +} diff --git a/frontend/src/Store/Middleware/middlewares.js b/frontend/src/Store/Middleware/middlewares.js new file mode 100644 index 000000000..119743b23 --- /dev/null +++ b/frontend/src/Store/Middleware/middlewares.js @@ -0,0 +1,25 @@ +import { applyMiddleware, compose } from 'redux'; +import thunk from 'redux-thunk'; +import { routerMiddleware } from 'connected-react-router'; +import createSentryMiddleware from './createSentryMiddleware'; +import createPersistState from './createPersistState'; + +export default function(history) { + const middlewares = []; + const sentryMiddleware = createSentryMiddleware(); + + if (sentryMiddleware) { + middlewares.push(sentryMiddleware); + } + + middlewares.push(routerMiddleware(history)); + middlewares.push(thunk); + + // eslint-disable-next-line no-underscore-dangle + const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; + + return composeEnhancers( + applyMiddleware(...middlewares), + createPersistState() + ); +} diff --git a/frontend/src/Store/Migrators/migrate.js b/frontend/src/Store/Migrators/migrate.js new file mode 100644 index 000000000..36dbbb8c3 --- /dev/null +++ b/frontend/src/Store/Migrators/migrate.js @@ -0,0 +1,5 @@ +import migrateAddArtistDefaults from './migrateAddArtistDefaults'; + +export default function migrate(persistedState) { + migrateAddArtistDefaults(persistedState); +} diff --git a/frontend/src/Store/Migrators/migrateAddArtistDefaults.js b/frontend/src/Store/Migrators/migrateAddArtistDefaults.js new file mode 100644 index 000000000..731bb00c6 --- /dev/null +++ b/frontend/src/Store/Migrators/migrateAddArtistDefaults.js @@ -0,0 +1,14 @@ +import { get } from 'lodash'; +import monitorOptions from 'Utilities/Artist/monitorOptions'; + +export default function migrateAddArtistDefaults(persistedState) { + const monitor = get(persistedState, 'addArtist.defaults.monitor'); + + if (!monitor) { + return; + } + + if (!monitorOptions.find((option) => option.key === monitor)) { + persistedState.addArtist.defaults.monitor = monitorOptions[0].key; + } +} diff --git a/frontend/src/Store/Selectors/createAlbumSelector.js b/frontend/src/Store/Selectors/createAlbumSelector.js new file mode 100644 index 000000000..13894a143 --- /dev/null +++ b/frontend/src/Store/Selectors/createAlbumSelector.js @@ -0,0 +1,15 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import albumEntities from 'Album/albumEntities'; + +function createAlbumSelector() { + return createSelector( + (state, { albumId }) => albumId, + (state, { albumEntity = albumEntities.ALBUMS }) => _.get(state, albumEntity, { items: [] }), + (albumId, albums) => { + return _.find(albums.items, { id: albumId }); + } + ); +} + +export default createAlbumSelector; diff --git a/frontend/src/Store/Selectors/createAllArtistSelector.js b/frontend/src/Store/Selectors/createAllArtistSelector.js new file mode 100644 index 000000000..38b1bcef1 --- /dev/null +++ b/frontend/src/Store/Selectors/createAllArtistSelector.js @@ -0,0 +1,12 @@ +import { createSelector } from 'reselect'; + +function createAllArtistSelector() { + return createSelector( + (state) => state.artist, + (artist) => { + return artist.items; + } + ); +} + +export default createAllArtistSelector; diff --git a/frontend/src/Store/Selectors/createArtistClientSideCollectionItemsSelector.js b/frontend/src/Store/Selectors/createArtistClientSideCollectionItemsSelector.js new file mode 100644 index 000000000..38300bd9d --- /dev/null +++ b/frontend/src/Store/Selectors/createArtistClientSideCollectionItemsSelector.js @@ -0,0 +1,36 @@ +import { createSelector } from 'reselect'; +import createDeepEqualSelector from './createDeepEqualSelector'; +import createClientSideCollectionSelector from './createClientSideCollectionSelector'; + +function createUnoptimizedSelector(uiSection) { + return createSelector( + createClientSideCollectionSelector('artist', uiSection), + (artist) => { + const items = artist.items.map((s) => { + const { + id, + sortName + } = s; + + return { + id, + sortName + }; + }); + + return { + ...artist, + items + }; + } + ); +} + +function createArtistClientSideCollectionItemsSelector(uiSection) { + return createDeepEqualSelector( + createUnoptimizedSelector(uiSection), + (artist) => artist + ); +} + +export default createArtistClientSideCollectionItemsSelector; diff --git a/frontend/src/Store/Selectors/createArtistCountSelector.js b/frontend/src/Store/Selectors/createArtistCountSelector.js new file mode 100644 index 000000000..203a5cb94 --- /dev/null +++ b/frontend/src/Store/Selectors/createArtistCountSelector.js @@ -0,0 +1,17 @@ +import { createSelector } from 'reselect'; +import createAllArtistSelector from './createAllArtistSelector'; + +function createArtistCountSelector() { + return createSelector( + createAllArtistSelector(), + (state) => state.artist.error, + (artists, error) => { + return { + count: artists.length, + error + }; + } + ); +} + +export default createArtistCountSelector; diff --git a/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.js b/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.js new file mode 100644 index 000000000..de5205948 --- /dev/null +++ b/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.js @@ -0,0 +1,16 @@ +import { createSelector } from 'reselect'; +import createArtistSelector from './createArtistSelector'; + +function createArtistMetadataProfileSelector() { + return createSelector( + (state) => state.settings.metadataProfiles.items, + createArtistSelector(), + (metadataProfiles, artist = {}) => { + return metadataProfiles.find((profile) => { + return profile.id === artist.metadataProfileId; + }); + } + ); +} + +export default createArtistMetadataProfileSelector; diff --git a/frontend/src/Store/Selectors/createArtistQualityProfileSelector.js b/frontend/src/Store/Selectors/createArtistQualityProfileSelector.js new file mode 100644 index 000000000..5819eb080 --- /dev/null +++ b/frontend/src/Store/Selectors/createArtistQualityProfileSelector.js @@ -0,0 +1,16 @@ +import { createSelector } from 'reselect'; +import createArtistSelector from './createArtistSelector'; + +function createArtistQualityProfileSelector() { + return createSelector( + (state) => state.settings.qualityProfiles.items, + createArtistSelector(), + (qualityProfiles, artist = {}) => { + return qualityProfiles.find((profile) => { + return profile.id === artist.qualityProfileId; + }); + } + ); +} + +export default createArtistQualityProfileSelector; diff --git a/frontend/src/Store/Selectors/createArtistSelector.js b/frontend/src/Store/Selectors/createArtistSelector.js new file mode 100644 index 000000000..4b45118b8 --- /dev/null +++ b/frontend/src/Store/Selectors/createArtistSelector.js @@ -0,0 +1,14 @@ +import { createSelector } from 'reselect'; +import createAllArtistSelector from './createAllArtistSelector'; + +function createArtistSelector() { + return createSelector( + (state, { artistId }) => artistId, + createAllArtistSelector(), + (artistId, allArtists) => { + return allArtists.find((artist) => artist.id === artistId ); + } + ); +} + +export default createArtistSelector; diff --git a/frontend/src/Store/Selectors/createClientSideCollectionSelector.js b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js new file mode 100644 index 000000000..36f9d4a56 --- /dev/null +++ b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js @@ -0,0 +1,137 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import findSelectedFilters from 'Utilities/Filter/findSelectedFilters'; +import { filterTypePredicates, filterTypes, sortDirections } from 'Helpers/Props'; + +function getSortClause(sortKey, sortDirection, sortPredicates) { + if (sortPredicates && sortPredicates.hasOwnProperty(sortKey)) { + return function(item) { + return sortPredicates[sortKey](item, sortDirection); + }; + } + + return function(item) { + return item[sortKey]; + }; +} + +function filter(items, state) { + const { + selectedFilterKey, + filters, + customFilters, + filterPredicates + } = state; + + if (!selectedFilterKey) { + return items; + } + + const selectedFilters = findSelectedFilters(selectedFilterKey, filters, customFilters); + + return _.filter(items, (item) => { + let i = 0; + let accepted = true; + + while (accepted && i < selectedFilters.length) { + const { + key, + value, + type = filterTypes.EQUAL + } = selectedFilters[i]; + + if (filterPredicates && filterPredicates.hasOwnProperty(key)) { + const predicate = filterPredicates[key]; + + if (Array.isArray(value)) { + accepted = value.some((v) => predicate(item, v, type)); + } else { + accepted = predicate(item, value, type); + } + } else if (item.hasOwnProperty(key)) { + const predicate = filterTypePredicates[type]; + + if (Array.isArray(value)) { + if ( + type === filterTypes.NOT_CONTAINS || + type === filterTypes.NOT_EQUAL + ) { + accepted = value.every((v) => predicate(item[key], v)); + } else { + accepted = value.some((v) => predicate(item[key], v)); + } + } else { + accepted = predicate(item[key], value); + } + } else { + // Default to false if the filter can't be tested + accepted = false; + } + + i++; + } + + return accepted; + }); +} + +function sort(items, state) { + const { + sortKey, + sortDirection, + sortPredicates, + secondarySortKey, + secondarySortDirection + } = state; + + const clauses = []; + const orders = []; + + clauses.push(getSortClause(sortKey, sortDirection, sortPredicates)); + orders.push(sortDirection === sortDirections.ASCENDING ? 'asc' : 'desc'); + + if (secondarySortKey && + secondarySortDirection && + (sortKey !== secondarySortKey || + sortDirection !== secondarySortDirection)) { + clauses.push(getSortClause(secondarySortKey, secondarySortDirection, sortPredicates)); + orders.push(secondarySortDirection === sortDirections.ASCENDING ? 'asc' : 'desc'); + } + + return _.orderBy(items, clauses, orders); +} + +function createCustomFiltersSelector(type, alternateType) { + return createSelector( + (state) => state.customFilters.items, + (customFilters) => { + return customFilters.filter((customFilter) => { + return customFilter.type === type || customFilter.type === alternateType; + }); + } + ); +} + +function createClientSideCollectionSelector(section, uiSection) { + return createSelector( + (state) => _.get(state, section), + (state) => _.get(state, uiSection), + createCustomFiltersSelector(section, uiSection), + (sectionState, uiSectionState = {}, customFilters) => { + const state = Object.assign({}, sectionState, uiSectionState, { customFilters }); + + const filtered = filter(state.items, state); + const sorted = sort(filtered, state); + + return { + ...sectionState, + ...uiSectionState, + customFilters, + items: sorted, + totalItems: state.items.length + }; + } + ); +} + +export default createClientSideCollectionSelector; diff --git a/frontend/src/Store/Selectors/createCommandExecutingSelector.js b/frontend/src/Store/Selectors/createCommandExecutingSelector.js new file mode 100644 index 000000000..6037d5820 --- /dev/null +++ b/frontend/src/Store/Selectors/createCommandExecutingSelector.js @@ -0,0 +1,14 @@ +import { createSelector } from 'reselect'; +import { isCommandExecuting } from 'Utilities/Command'; +import createCommandSelector from './createCommandSelector'; + +function createCommandExecutingSelector(name, contraints = {}) { + return createSelector( + createCommandSelector(name, contraints), + (command) => { + return isCommandExecuting(command); + } + ); +} + +export default createCommandExecutingSelector; diff --git a/frontend/src/Store/Selectors/createCommandSelector.js b/frontend/src/Store/Selectors/createCommandSelector.js new file mode 100644 index 000000000..709dfebaf --- /dev/null +++ b/frontend/src/Store/Selectors/createCommandSelector.js @@ -0,0 +1,14 @@ +import { createSelector } from 'reselect'; +import { findCommand } from 'Utilities/Command'; +import createCommandsSelector from './createCommandsSelector'; + +function createCommandSelector(name, contraints = {}) { + return createSelector( + createCommandsSelector(), + (commands) => { + return findCommand(commands, { name, ...contraints }); + } + ); +} + +export default createCommandSelector; diff --git a/frontend/src/Store/Selectors/createCommandsSelector.js b/frontend/src/Store/Selectors/createCommandsSelector.js new file mode 100644 index 000000000..7b9edffd9 --- /dev/null +++ b/frontend/src/Store/Selectors/createCommandsSelector.js @@ -0,0 +1,12 @@ +import { createSelector } from 'reselect'; + +function createCommandsSelector() { + return createSelector( + (state) => state.commands, + (commands) => { + return commands.items; + } + ); +} + +export default createCommandsSelector; diff --git a/frontend/src/Store/Selectors/createDeepEqualSelector.js b/frontend/src/Store/Selectors/createDeepEqualSelector.js new file mode 100644 index 000000000..c01d23875 --- /dev/null +++ b/frontend/src/Store/Selectors/createDeepEqualSelector.js @@ -0,0 +1,9 @@ +import { createSelectorCreator, defaultMemoize } from 'reselect'; +import _ from 'lodash'; + +const createDeepEqualSelector = createSelectorCreator( + defaultMemoize, + _.isEqual +); + +export default createDeepEqualSelector; diff --git a/frontend/src/Store/Selectors/createDimensionsSelector.js b/frontend/src/Store/Selectors/createDimensionsSelector.js new file mode 100644 index 000000000..ce26b2e2c --- /dev/null +++ b/frontend/src/Store/Selectors/createDimensionsSelector.js @@ -0,0 +1,12 @@ +import { createSelector } from 'reselect'; + +function createDimensionsSelector() { + return createSelector( + (state) => state.app.dimensions, + (dimensions) => { + return dimensions; + } + ); +} + +export default createDimensionsSelector; diff --git a/frontend/src/Store/Selectors/createExecutingCommandsSelector.js b/frontend/src/Store/Selectors/createExecutingCommandsSelector.js new file mode 100644 index 000000000..266865a8a --- /dev/null +++ b/frontend/src/Store/Selectors/createExecutingCommandsSelector.js @@ -0,0 +1,13 @@ +import { createSelector } from 'reselect'; +import { isCommandExecuting } from 'Utilities/Command'; + +function createExecutingCommandsSelector() { + return createSelector( + (state) => state.commands.items, + (commands) => { + return commands.filter((command) => isCommandExecuting(command)); + } + ); +} + +export default createExecutingCommandsSelector; diff --git a/frontend/src/Store/Selectors/createExistingArtistSelector.js b/frontend/src/Store/Selectors/createExistingArtistSelector.js new file mode 100644 index 000000000..4811f2034 --- /dev/null +++ b/frontend/src/Store/Selectors/createExistingArtistSelector.js @@ -0,0 +1,15 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import createAllArtistSelector from './createAllArtistSelector'; + +function createExistingArtistSelector() { + return createSelector( + (state, { foreignArtistId }) => foreignArtistId, + createAllArtistSelector(), + (foreignArtistId, artist) => { + return _.some(artist, { foreignArtistId }); + } + ); +} + +export default createExistingArtistSelector; diff --git a/frontend/src/Store/Selectors/createImportArtistItemSelector.js b/frontend/src/Store/Selectors/createImportArtistItemSelector.js new file mode 100644 index 000000000..6d72dc547 --- /dev/null +++ b/frontend/src/Store/Selectors/createImportArtistItemSelector.js @@ -0,0 +1,27 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import createAllArtistSelector from './createAllArtistSelector'; + +function createImportArtistItemSelector() { + return createSelector( + (state, { id }) => id, + (state) => state.addArtist, + (state) => state.importArtist, + createAllArtistSelector(), + (id, addArtist, importArtist, artist) => { + const item = _.find(importArtist.items, { id }) || {}; + const selectedArtist = item && item.selectedArtist; + const isExistingArtist = !!selectedArtist && _.some(artist, { foreignArtistId: selectedArtist.foreignArtistId }); + + return { + defaultMonitor: addArtist.defaults.monitor, + defaultQualityProfileId: addArtist.defaults.qualityProfileId, + defaultAlbumFolder: addArtist.defaults.albumFolder, + ...item, + isExistingArtist + }; + } + ); +} + +export default createImportArtistItemSelector; diff --git a/frontend/src/Store/Selectors/createMetadataProfileSelector.js b/frontend/src/Store/Selectors/createMetadataProfileSelector.js new file mode 100644 index 000000000..bdd0d0636 --- /dev/null +++ b/frontend/src/Store/Selectors/createMetadataProfileSelector.js @@ -0,0 +1,15 @@ +import { createSelector } from 'reselect'; + +function createMetadataProfileSelector() { + return createSelector( + (state, { metadataProfileId }) => metadataProfileId, + (state) => state.settings.metadataProfiles.items, + (metadataProfileId, metadataProfiles) => { + return metadataProfiles.find((profile) => { + return profile.id === metadataProfileId; + }); + } + ); +} + +export default createMetadataProfileSelector; diff --git a/frontend/src/Store/Selectors/createProfileInUseSelector.js b/frontend/src/Store/Selectors/createProfileInUseSelector.js new file mode 100644 index 000000000..84fefb83e --- /dev/null +++ b/frontend/src/Store/Selectors/createProfileInUseSelector.js @@ -0,0 +1,24 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import createAllArtistSelector from './createAllArtistSelector'; + +function createProfileInUseSelector(profileProp) { + return createSelector( + (state, { id }) => id, + createAllArtistSelector(), + (state) => state.settings.importLists.items, + (id, artist, lists) => { + if (!id) { + return false; + } + + if (_.some(artist, { [profileProp]: id }) || _.some(lists, { [profileProp]: id })) { + return true; + } + + return false; + } + ); +} + +export default createProfileInUseSelector; diff --git a/frontend/src/Store/Selectors/createProviderSettingsSelector.js b/frontend/src/Store/Selectors/createProviderSettingsSelector.js new file mode 100644 index 000000000..46659609f --- /dev/null +++ b/frontend/src/Store/Selectors/createProviderSettingsSelector.js @@ -0,0 +1,63 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; + +function createProviderSettingsSelector(sectionName) { + return createSelector( + (state, { id }) => id, + (state) => state.settings[sectionName], + (id, section) => { + if (!id) { + const item = _.isArray(section.schema) ? section.selectedSchema : section.schema; + const settings = selectSettings(Object.assign({ name: '' }, item), section.pendingChanges, section.saveError); + + const { + isSchemaFetching: isFetching, + isSchemaPopulated: isPopulated, + schemaError: error, + isSaving, + saveError, + isTesting, + pendingChanges + } = section; + + return { + isFetching, + isPopulated, + error, + isSaving, + saveError, + isTesting, + pendingChanges, + ...settings, + item: settings.settings + }; + } + + const { + isFetching, + isPopulated, + error, + isSaving, + saveError, + isTesting, + pendingChanges + } = section; + + const settings = selectSettings(_.find(section.items, { id }), pendingChanges, saveError); + + return { + isFetching, + isPopulated, + error, + isSaving, + saveError, + isTesting, + ...settings, + item: settings.settings + }; + } + ); +} + +export default createProviderSettingsSelector; diff --git a/frontend/src/Store/Selectors/createQualityProfileSelector.js b/frontend/src/Store/Selectors/createQualityProfileSelector.js new file mode 100644 index 000000000..451aacfd4 --- /dev/null +++ b/frontend/src/Store/Selectors/createQualityProfileSelector.js @@ -0,0 +1,15 @@ +import { createSelector } from 'reselect'; + +function createQualityProfileSelector() { + return createSelector( + (state, { qualityProfileId }) => qualityProfileId, + (state) => state.settings.qualityProfiles.items, + (qualityProfileId, qualityProfiles) => { + return qualityProfiles.find((profile) => { + return profile.id === qualityProfileId; + }); + } + ); +} + +export default createQualityProfileSelector; diff --git a/frontend/src/Store/Selectors/createQueueItemSelector.js b/frontend/src/Store/Selectors/createQueueItemSelector.js new file mode 100644 index 000000000..089795ced --- /dev/null +++ b/frontend/src/Store/Selectors/createQueueItemSelector.js @@ -0,0 +1,23 @@ +import { createSelector } from 'reselect'; + +function createQueueItemSelector() { + return createSelector( + (state, { albumId }) => albumId, + (state) => state.queue.details.items, + (albumId, details) => { + if (!albumId) { + return null; + } + + return details.find((item) => { + if (item.album) { + return item.album.id === albumId; + } + + return false; + }); + } + ); +} + +export default createQueueItemSelector; diff --git a/frontend/src/Store/Selectors/createSettingsSectionSelector.js b/frontend/src/Store/Selectors/createSettingsSectionSelector.js new file mode 100644 index 000000000..a9f6cbff6 --- /dev/null +++ b/frontend/src/Store/Selectors/createSettingsSectionSelector.js @@ -0,0 +1,32 @@ +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; + +function createSettingsSectionSelector(section) { + return createSelector( + (state) => state.settings[section], + (sectionSettings) => { + const { + isFetching, + isPopulated, + error, + item, + pendingChanges, + isSaving, + saveError + } = sectionSettings; + + const settings = selectSettings(item, pendingChanges, saveError); + + return { + isFetching, + isPopulated, + error, + isSaving, + saveError, + ...settings + }; + } + ); +} + +export default createSettingsSectionSelector; diff --git a/frontend/src/Store/Selectors/createSystemStatusSelector.js b/frontend/src/Store/Selectors/createSystemStatusSelector.js new file mode 100644 index 000000000..df586bbb9 --- /dev/null +++ b/frontend/src/Store/Selectors/createSystemStatusSelector.js @@ -0,0 +1,12 @@ +import { createSelector } from 'reselect'; + +function createSystemStatusSelector() { + return createSelector( + (state) => state.system.status, + (status) => { + return status.item; + } + ); +} + +export default createSystemStatusSelector; diff --git a/frontend/src/Store/Selectors/createTagDetailsSelector.js b/frontend/src/Store/Selectors/createTagDetailsSelector.js new file mode 100644 index 000000000..dd178944c --- /dev/null +++ b/frontend/src/Store/Selectors/createTagDetailsSelector.js @@ -0,0 +1,13 @@ +import { createSelector } from 'reselect'; + +function createTagDetailsSelector() { + return createSelector( + (state, { id }) => id, + (state) => state.tags.details.items, + (id, tagDetails) => { + return tagDetails.find((t) => t.id === id); + } + ); +} + +export default createTagDetailsSelector; diff --git a/frontend/src/Store/Selectors/createTagsSelector.js b/frontend/src/Store/Selectors/createTagsSelector.js new file mode 100644 index 000000000..fbfd91cdb --- /dev/null +++ b/frontend/src/Store/Selectors/createTagsSelector.js @@ -0,0 +1,12 @@ +import { createSelector } from 'reselect'; + +function createTagsSelector() { + return createSelector( + (state) => state.tags.items, + (tags) => { + return tags; + } + ); +} + +export default createTagsSelector; diff --git a/frontend/src/Store/Selectors/createTrackFileSelector.js b/frontend/src/Store/Selectors/createTrackFileSelector.js new file mode 100644 index 000000000..bcfc5cb0b --- /dev/null +++ b/frontend/src/Store/Selectors/createTrackFileSelector.js @@ -0,0 +1,17 @@ +import { createSelector } from 'reselect'; + +function createTrackFileSelector() { + return createSelector( + (state, { trackFileId }) => trackFileId, + (state) => state.trackFiles, + (trackFileId, trackFiles) => { + if (!trackFileId) { + return; + } + + return trackFiles.items.find((trackFile) => trackFile.id === trackFileId); + } + ); +} + +export default createTrackFileSelector; diff --git a/frontend/src/Store/Selectors/createTrackSelector.js b/frontend/src/Store/Selectors/createTrackSelector.js new file mode 100644 index 000000000..be57e6ca0 --- /dev/null +++ b/frontend/src/Store/Selectors/createTrackSelector.js @@ -0,0 +1,14 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; + +function createTrackSelector() { + return createSelector( + (state, { trackId }) => trackId, + (state) => state.tracks, + (trackId, tracks) => { + return _.find(tracks.items, { id: trackId }); + } + ); +} + +export default createTrackSelector; diff --git a/frontend/src/Store/Selectors/createUISettingsSelector.js b/frontend/src/Store/Selectors/createUISettingsSelector.js new file mode 100644 index 000000000..b256d0e98 --- /dev/null +++ b/frontend/src/Store/Selectors/createUISettingsSelector.js @@ -0,0 +1,12 @@ +import { createSelector } from 'reselect'; + +function createUISettingsSelector() { + return createSelector( + (state) => state.settings.ui, + (ui) => { + return ui.item; + } + ); +} + +export default createUISettingsSelector; diff --git a/frontend/src/Store/Selectors/selectSettings.js b/frontend/src/Store/Selectors/selectSettings.js new file mode 100644 index 000000000..3e30478b7 --- /dev/null +++ b/frontend/src/Store/Selectors/selectSettings.js @@ -0,0 +1,104 @@ +import _ from 'lodash'; + +function getValidationFailures(saveError) { + if (!saveError || saveError.status !== 400) { + return []; + } + + return _.cloneDeep(saveError.responseJSON); +} + +function mapFailure(failure) { + return { + message: failure.errorMessage, + link: failure.infoLink, + detailedMessage: failure.detailedDescription + }; +} + +function selectSettings(item, pendingChanges, saveError) { + const validationFailures = getValidationFailures(saveError); + + // Merge all settings from the item along with pending + // changes to ensure any settings that were not included + // with the item are included. + const allSettings = Object.assign({}, item, pendingChanges); + + const settings = _.reduce(allSettings, (result, value, key) => { + if (key === 'fields') { + return result; + } + + // Return a flattened value + if (key === 'implementationName') { + result.implementationName = item[key]; + + return result; + } + + const setting = { + value: item[key], + errors: _.map(_.remove(validationFailures, (failure) => { + return failure.propertyName.toLowerCase() === key.toLowerCase() && !failure.isWarning; + }), mapFailure), + + warnings: _.map(_.remove(validationFailures, (failure) => { + return failure.propertyName.toLowerCase() === key.toLowerCase() && failure.isWarning; + }), mapFailure) + }; + + if (pendingChanges.hasOwnProperty(key)) { + setting.previousValue = setting.value; + setting.value = pendingChanges[key]; + setting.pending = true; + } + + result[key] = setting; + return result; + }, {}); + + const fields = _.reduce(item.fields, (result, f) => { + const field = Object.assign({ pending: false }, f); + const hasPendingFieldChange = pendingChanges.fields && pendingChanges.fields.hasOwnProperty(field.name); + + if (hasPendingFieldChange) { + field.previousValue = field.value; + field.value = pendingChanges.fields[field.name]; + field.pending = true; + } + + field.errors = _.map(_.remove(validationFailures, (failure) => { + return failure.propertyName.toLowerCase() === field.name.toLowerCase() && !failure.isWarning; + }), mapFailure); + + field.warnings = _.map(_.remove(validationFailures, (failure) => { + return failure.propertyName.toLowerCase() === field.name.toLowerCase() && failure.isWarning; + }), mapFailure); + + result.push(field); + return result; + }, []); + + if (fields.length) { + settings.fields = fields; + } + + const validationErrors = _.filter(validationFailures, (failure) => { + return !failure.isWarning; + }); + + const validationWarnings = _.filter(validationFailures, (failure) => { + return failure.isWarning; + }); + + return { + settings, + validationErrors, + validationWarnings, + hasPendingChanges: !_.isEmpty(pendingChanges), + hasSettings: !_.isEmpty(settings), + pendingChanges + }; +} + +export default selectSettings; diff --git a/frontend/src/Store/createAppStore.js b/frontend/src/Store/createAppStore.js new file mode 100644 index 000000000..4fef265f1 --- /dev/null +++ b/frontend/src/Store/createAppStore.js @@ -0,0 +1,15 @@ +import { createStore } from 'redux'; +import createReducers, { defaultState } from 'Store/Actions/createReducers'; +import middlewares from 'Store/Middleware/middlewares'; + +function createAppStore(history) { + const appStore = createStore( + createReducers(history), + defaultState, + middlewares(history) + ); + + return appStore; +} + +export default createAppStore; diff --git a/frontend/src/Store/scrollPositions.js b/frontend/src/Store/scrollPositions.js new file mode 100644 index 000000000..287a58593 --- /dev/null +++ b/frontend/src/Store/scrollPositions.js @@ -0,0 +1,5 @@ +const scrollPositions = { + artistIndex: 0 +}; + +export default scrollPositions; diff --git a/frontend/src/Store/thunks.js b/frontend/src/Store/thunks.js new file mode 100644 index 000000000..6daa843f4 --- /dev/null +++ b/frontend/src/Store/thunks.js @@ -0,0 +1,27 @@ +const thunks = {}; + +function identity(payload) { + return payload; +} + +export function createThunk(type, identityFunction = identity) { + return function(payload = {}) { + return function(dispatch, getState) { + const thunk = thunks[type]; + + if (thunk) { + return thunk(getState, identityFunction(payload), dispatch); + } + + throw Error(`Thunk handler has not been registered for ${type}`); + }; + }; +} + +export function handleThunks(handlers) { + const types = Object.keys(handlers); + + types.forEach((type) => { + thunks[type] = handlers[type]; + }); +} diff --git a/src/UI/Shared/Styles/clickable.less b/frontend/src/Styles/Mixins/clickable.css similarity index 100% rename from src/UI/Shared/Styles/clickable.less rename to frontend/src/Styles/Mixins/clickable.css diff --git a/frontend/src/Styles/Mixins/cover.css b/frontend/src/Styles/Mixins/cover.css new file mode 100644 index 000000000..e44c99be6 --- /dev/null +++ b/frontend/src/Styles/Mixins/cover.css @@ -0,0 +1,8 @@ +@define-mixin cover { + position: absolute; + top: 0; + left: 0; + display: block; + width: 100%; + height: 100%; +} diff --git a/frontend/src/Styles/Mixins/linkOverlay.css b/frontend/src/Styles/Mixins/linkOverlay.css new file mode 100644 index 000000000..74c3fd753 --- /dev/null +++ b/frontend/src/Styles/Mixins/linkOverlay.css @@ -0,0 +1,11 @@ +@define-mixin linkOverlay { + @add-mixin cover; + + pointer-events: none; + user-select: none; + + a, + button { + pointer-events: all; + } +} diff --git a/frontend/src/Styles/Mixins/scroller.css b/frontend/src/Styles/Mixins/scroller.css new file mode 100644 index 000000000..efcca0b57 --- /dev/null +++ b/frontend/src/Styles/Mixins/scroller.css @@ -0,0 +1,26 @@ +@define-mixin scrollbar { + &::-webkit-scrollbar { + width: 10px; + height: 10px; + } +} + +@define-mixin scrollbarTrack { + &&::-webkit-scrollbar-track { + background-color: transparent; + } +} + +@define-mixin scrollbarThumb { + &::-webkit-scrollbar-thumb { + min-height: 100px; + border: 1px solid transparent; + border-radius: 5px; + background-color: $scrollbarBackgroundColor; + background-clip: padding-box; + + &:hover { + background-color: $scrollbarHoverBackgroundColor; + } + } +} diff --git a/frontend/src/Styles/Mixins/truncate.css b/frontend/src/Styles/Mixins/truncate.css new file mode 100644 index 000000000..1941afc9b --- /dev/null +++ b/frontend/src/Styles/Mixins/truncate.css @@ -0,0 +1,18 @@ +/** + * From: https://github.com/suitcss/utils-text/blob/master/lib/text.css + * + * Text truncation + * + * Prevent text from wrapping onto multiple lines, and truncate with an + * ellipsis. + * + * 1. Ensure that the node has a maximum width after which truncation can + * occur. + */ + +@define-mixin truncate { + overflow: hidden !important; + max-width: 100%; /* 1 */ + text-overflow: ellipsis !important; + white-space: nowrap !important; +} diff --git a/frontend/src/Styles/Variables/animations.js b/frontend/src/Styles/Variables/animations.js new file mode 100644 index 000000000..52d12827a --- /dev/null +++ b/frontend/src/Styles/Variables/animations.js @@ -0,0 +1,8 @@ +// Use CommonJS since this is consumed by PostCSS via webpack (node.js). + +module.exports = { + // Durations + defaultSpeed: '0.2s', + slowSpeed: '0.6s', + fastSpeed: '0.1s' +}; diff --git a/frontend/src/Styles/Variables/colors.js b/frontend/src/Styles/Variables/colors.js new file mode 100644 index 000000000..b9741a69a --- /dev/null +++ b/frontend/src/Styles/Variables/colors.js @@ -0,0 +1,185 @@ +const lidarrGreen = '#00A65B'; + +module.exports = { + defaultColor: '#333', + disabledColor: '#999', + dimColor: '#555', + black: '#000', + white: '#fff', + offWhite: '#f5f7fa', + blue: '#06f', + yellow: '#FFA500', + primaryColor: '#0b8750', + selectedColor: '#f9be03', + successColor: '#27c24c', + dangerColor: '#f05050', + warningColor: '#ffa500', + infoColor: lidarrGreen, + purple: '#7a43b6', + pink: '#ff69b4', + lidarrGreen, + helpTextColor: '#909293', + darkGray: '#888', + gray: '#adadad', + lightGray: '#ddd', + disabledInputColor: '#808080', + + // Theme Colors + + themeBlue: lidarrGreen, + themeAlternateBlue: '#00a65b', + themeRed: '#c4273c', + themeDarkColor: '#353535', + themeLightColor: '#1d563d', + + torrentColor: '#00853d', + usenetColor: '#17b1d9', + + // Links + defaultLinkHoverColor: '#fff', + linkColor: '#0b8750', + linkHoverColor: '#1b72e2', + + // Sidebar + + sidebarColor: '#e1e2e3', + sidebarBackgroundColor: '#353535', + sidebarActiveBackgroundColor: '#252525', + + // Toolbar + toolbarColor: '#e1e2e3', + toolbarBackgroundColor: '#1d563d', + toolbarMenuItemBackgroundColor: '#4D8069', + toolbarMenuItemHoverBackgroundColor: '#353535', + toolbarLabelColor: '#8895aa', + + // Accents + borderColor: '#e5e5e5', + inputBorderColor: '#dde6e9', + inputBoxShadowColor: 'rgba(0, 0, 0, 0.075)', + inputFocusBorderColor: '#66afe9', + inputFocusBoxShadowColor: 'rgba(102, 175, 233, 0.6)', + inputErrorBorderColor: '#f05050', + inputErrorBoxShadowColor: 'rgba(240, 80, 80, 0.6)', + inputWarningBorderColor: '#ffa500', + inputWarningBoxShadowColor: 'rgba(255, 165, 0, 0.6)', + colorImpairedGradient: '#ffffff', + colorImpairedGradientDark: '#f4f5f6', + + // + // Buttons + + defaultBackgroundColor: '#fff', + defaultBorderColor: '#eaeaea', + defaultHoverBackgroundColor: '#f5f5f5', + defaultHoverBorderColor: '#d6d6d6;', + + primaryBackgroundColor: '#0b8750', + primaryBorderColor: '#1d563d', + primaryHoverBackgroundColor: '#097948', + primaryHoverBorderColor: '#1D563D;', + + successBackgroundColor: '#27c24c', + successBorderColor: '#26be4a', + successHoverBackgroundColor: '#24b145', + successHoverBorderColor: '#1f9c3d;', + + warningBackgroundColor: '#ff902b', + warningBorderColor: '#ff8d26', + warningHoverBackgroundColor: '#ff8517', + warningHoverBorderColor: '#fc7800;', + + dangerBackgroundColor: '#f05050', + dangerBorderColor: '#f04b4b', + dangerHoverBackgroundColor: '#ee3d3d', + dangerHoverBorderColor: '#ec2626;', + + iconButtonDisabledColor: '#7a7a7a', + iconButtonHoverColor: '#666', + iconButtonHoverLightColor: '#ccc', + + // + // Modal + + modalBackdropBackgroundColor: 'rgba(0, 0, 0, 0.6)', + modalBackgroundColor: '#fff', + modalCloseButtonHoverColor: '#888', + + // + // Menu + menuItemColor: '#e1e2e3', + menuItemHoverColor: '#fbfcfc', + menuItemHoverBackgroundColor: '#f5f7fa', + + // + // Toolbar + + toobarButtonHoverColor: '#00A65B', + toobarButtonSelectedColor: '#00A65B', + + // + // Scroller + + scrollbarBackgroundColor: '#9ea4b9', + scrollbarHoverBackgroundColor: '#656d8c', + + // + // Card + + cardShadowColor: '#e1e1e1', + cardAlternateBackgroundColor: '#f5f5f5', + + // + // Alert + + alertDangerBorderColor: '#ebccd1', + alertDangerBackgroundColor: '#f2dede', + alertDangerColor: '#a94442', + + alertInfoBorderColor: '#bce8f1', + alertInfoBackgroundColor: '#d9edf7', + alertInfoColor: '#31708f', + + alertSuccessBorderColor: '#d6e9c6', + alertSuccessBackgroundColor: '#dff0d8', + alertSuccessColor: '#3c763d', + + alertWarningBorderColor: '#faebcc', + alertWarningBackgroundColor: '#fcf8e3', + alertWarningColor: '#8a6d3b', + + // + // Slider + + sliderAccentColor: '#0b8750', + + // + // Form + + advancedFormLabelColor: '#ff902b', + disabledCheckInputColor: '#ddd', + + // + // Popover + + popoverTitleBackgroundColor: '#f7f7f7', + popoverTitleBorderColor: '#ebebeb', + popoverShadowColor: 'rgba(0, 0, 0, 0.2)', + popoverArrowBorderColor: '#fff', + + popoverTitleBackgroundInverseColor: '#3a3f51', + popoverTitleBorderInverseColor: '#353535', + popoverShadowInverseColor: 'rgba(0, 0, 0, 0.2)', + popoverArrowBorderInverseColor: 'rgba(58, 63, 81, 0.75)', + + // + // Calendar + + calendarTodayBackgroundColor: '#ddd', + calendarBorderColor: '#cecece', + + // + // Table + + tableRowHoverBackgroundColor: '#fafbfc' +}; diff --git a/frontend/src/Styles/Variables/dimensions.js b/frontend/src/Styles/Variables/dimensions.js new file mode 100644 index 000000000..db736f589 --- /dev/null +++ b/frontend/src/Styles/Variables/dimensions.js @@ -0,0 +1,53 @@ +module.exports = { + // Page + pageContentBodyPadding: '20px', + pageContentBodyPaddingSmallScreen: '10px', + + // Header + headerHeight: '60px', + + // Sidebar + sidebarWidth: '210px', + + // Toolbar + toolbarHeight: '60px', + toolbarButtonWidth: '60px', + toolbarSeparatorMargin: '20px', + + // Break Points + breakpointExtraSmall: '480px', + breakpointSmall: '768px', + breakpointMedium: '992px', + breakpointLarge: '1310px', + breakpointExtraLarge: '1450px', + + // Form + formGroupExtraSmallWidth: '550px', + formGroupSmallWidth: '650px', + formGroupMediumWidth: '800px', + formGroupLargeWidth: '1200px', + formLabelSmallWidth: '150px', + formLabelLargeWidth: '250px', + formLabelRightMarginWidth: '20px', + + // Drag + dragHandleWidth: '40px', + qualityProfileItemHeight: '30px', + qualityProfileItemDragSourcePadding: '4px', + + // Progress Bar + progressBarSmallHeight: '5px', + progressBarMediumHeight: '15px', + progressBarLargeHeight: '20px', + + // Jump Bar + jumpBarItemHeight: '25px', + + // Modal + modalBodyPadding: '30px', + + // Artist + artistIndexColumnPadding: '20px', + artistIndexColumnPaddingSmallScreen: '10px', + artistIndexOverviewInfoRowHeight: '21px' +}; diff --git a/frontend/src/Styles/Variables/fonts.js b/frontend/src/Styles/Variables/fonts.js new file mode 100644 index 000000000..3b0077c5a --- /dev/null +++ b/frontend/src/Styles/Variables/fonts.js @@ -0,0 +1,15 @@ +module.exports = { + // Families + defaultFontFamily: 'Roboto, "open sans", "Helvetica Neue", Helvetica, Arial, sans-serif', + monoSpaceFontFamily: '"Ubuntu Mono", Menlo, Monaco, Consolas, "Courier New", monospace;', + passwordFamily: 'text-security-disc', + + // Sizes + extraSmallFontSize: '11px', + smallFontSize: '12px', + defaultFontSize: '14px', + intermediateFontSize: '15px', + largeFontSize: '16px', + + lineHeight: '1.528571429' +}; diff --git a/frontend/src/Styles/Variables/zIndexes.js b/frontend/src/Styles/Variables/zIndexes.js new file mode 100644 index 000000000..986ceb548 --- /dev/null +++ b/frontend/src/Styles/Variables/zIndexes.js @@ -0,0 +1,4 @@ +module.exports = { + modalZIndex: 1000, + popperZIndex: 2000 +}; diff --git a/frontend/src/Styles/globals.css b/frontend/src/Styles/globals.css new file mode 100644 index 000000000..e630c77b9 --- /dev/null +++ b/frontend/src/Styles/globals.css @@ -0,0 +1,6 @@ +/* stylelint-disable */ + +@import "~normalize.css/normalize.css"; +@import "scaffolding.css"; + +/* stylelint-enable */ \ No newline at end of file diff --git a/frontend/src/Styles/scaffolding.css b/frontend/src/Styles/scaffolding.css new file mode 100644 index 000000000..1810037cf --- /dev/null +++ b/frontend/src/Styles/scaffolding.css @@ -0,0 +1,54 @@ +/* stylelint-disable */ +* { + box-sizing: border-box; +} + +*::before, +*::after { + box-sizing: border-box; +} + +*:focus { + outline: none; +} +/* stylelint-enable */ + +html, +body { + color: #515253; + font-family: 'Roboto', 'open sans', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif; +} + +body { + font-size: 14px; + line-height: 1.528571429; /* 20/14 */ +} + +/* Override normalize */ + +button, +input, +optgroup, +select, +textarea { + margin: 0; + font-size: inherit; + font-family: inherit; + line-height: 1.528571429; /* 20/14 */ +} + +/* Better defaults for unordererd lists */ + +ul { + margin: 0; + padding-left: 20px; +} + +@media only screen and (min-device-width: 375px) and (max-device-width: 812px) { + input, + optgroup, + select, + textarea { + font-size: 16px; + } +} diff --git a/frontend/src/System/Backup/BackupRow.css b/frontend/src/System/Backup/BackupRow.css new file mode 100644 index 000000000..db805650e --- /dev/null +++ b/frontend/src/System/Backup/BackupRow.css @@ -0,0 +1,12 @@ +.type { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 20px; + text-align: center; +} + +.actions { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 70px; +} diff --git a/frontend/src/System/Backup/BackupRow.js b/frontend/src/System/Backup/BackupRow.js new file mode 100644 index 000000000..82113490e --- /dev/null +++ b/frontend/src/System/Backup/BackupRow.js @@ -0,0 +1,153 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import RestoreBackupModalConnector from './RestoreBackupModalConnector'; +import styles from './BackupRow.css'; + +class BackupRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isRestoreModalOpen: false, + isConfirmDeleteModalOpen: false + }; + } + + // + // Listeners + + onRestorePress = () => { + this.setState({ isRestoreModalOpen: true }); + } + + onRestoreModalClose = () => { + this.setState({ isRestoreModalOpen: false }); + } + + onDeletePress = () => { + this.setState({ isConfirmDeleteModalOpen: true }); + } + + onConfirmDeleteModalClose = () => { + this.setState({ isConfirmDeleteModalOpen: false }); + } + + onConfirmDeletePress = () => { + const { + id, + onDeleteBackupPress + } = this.props; + + this.setState({ isConfirmDeleteModalOpen: false }, () => { + onDeleteBackupPress(id); + }); + } + + // + // Render + + render() { + const { + id, + type, + name, + path, + time + } = this.props; + + const { + isRestoreModalOpen, + isConfirmDeleteModalOpen + } = this.state; + + let iconClassName = icons.SCHEDULED; + let iconTooltip = 'Scheduled'; + + if (type === 'manual') { + iconClassName = icons.INTERACTIVE; + iconTooltip = 'Manual'; + } else if (type === 'update') { + iconClassName = icons.UPDATE; + iconTooltip = 'Before update'; + } + + return ( + + + { + + } + + + + + {name} + + + + + + + + + + + + + + + + ); + } +} + +BackupRow.propTypes = { + id: PropTypes.number.isRequired, + type: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + path: PropTypes.string.isRequired, + time: PropTypes.string.isRequired, + onDeleteBackupPress: PropTypes.func.isRequired +}; + +export default BackupRow; diff --git a/frontend/src/System/Backup/Backups.js b/frontend/src/System/Backup/Backups.js new file mode 100644 index 000000000..97167e6f2 --- /dev/null +++ b/frontend/src/System/Backup/Backups.js @@ -0,0 +1,166 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import BackupRow from './BackupRow'; +import RestoreBackupModalConnector from './RestoreBackupModalConnector'; + +const columns = [ + { + name: 'type', + isVisible: true + }, + { + name: 'name', + label: 'Name', + isVisible: true + }, + { + name: 'time', + label: 'Time', + isVisible: true + }, + { + name: 'actions', + isVisible: true + } +]; + +class Backups extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isRestoreModalOpen: false + }; + } + + // + // Listeners + + onRestorePress = () => { + this.setState({ isRestoreModalOpen: true }); + } + + onRestoreModalClose = () => { + this.setState({ isRestoreModalOpen: false }); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + backupExecuting, + onBackupPress, + onDeleteBackupPress + } = this.props; + + const hasBackups = isPopulated && !!items.length; + const noBackups = isPopulated && !items.length; + + return ( + + + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
Unable to load backups
+ } + + { + noBackups && +
No backups are available
+ } + + { + hasBackups && + + + { + items.map((item) => { + const { + id, + type, + name, + path, + time + } = item; + + return ( + + ); + }) + } + +
+ } +
+ + +
+ ); + } + +} + +Backups.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.array.isRequired, + backupExecuting: PropTypes.bool.isRequired, + onBackupPress: PropTypes.func.isRequired, + onDeleteBackupPress: PropTypes.func.isRequired +}; + +export default Backups; diff --git a/frontend/src/System/Backup/BackupsConnector.js b/frontend/src/System/Backup/BackupsConnector.js new file mode 100644 index 000000000..434354f5b --- /dev/null +++ b/frontend/src/System/Backup/BackupsConnector.js @@ -0,0 +1,84 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { fetchBackups, deleteBackup } from 'Store/Actions/systemActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import Backups from './Backups'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.backups, + createCommandExecutingSelector(commandNames.BACKUP), + (backups, backupExecuting) => { + const { + isFetching, + isPopulated, + error, + items + } = backups; + + return { + isFetching, + isPopulated, + error, + items, + backupExecuting + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + dispatchFetchBackups() { + dispatch(fetchBackups()); + }, + + onDeleteBackupPress(id) { + dispatch(deleteBackup({ id })); + }, + + onBackupPress() { + dispatch(executeCommand({ + name: commandNames.BACKUP + })); + } + }; +} + +class BackupsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchBackups(); + } + + componentDidUpdate(prevProps) { + if (prevProps.backupExecuting && !this.props.backupExecuting) { + this.props.dispatchFetchBackups(); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +BackupsConnector.propTypes = { + backupExecuting: PropTypes.bool.isRequired, + dispatchFetchBackups: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, createMapDispatchToProps)(BackupsConnector); diff --git a/frontend/src/System/Backup/RestoreBackupModal.js b/frontend/src/System/Backup/RestoreBackupModal.js new file mode 100644 index 000000000..48dad4d2a --- /dev/null +++ b/frontend/src/System/Backup/RestoreBackupModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import RestoreBackupModalContentConnector from './RestoreBackupModalContentConnector'; + +function RestoreBackupModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +RestoreBackupModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default RestoreBackupModal; diff --git a/frontend/src/System/Backup/RestoreBackupModalConnector.js b/frontend/src/System/Backup/RestoreBackupModalConnector.js new file mode 100644 index 000000000..98cbcd11b --- /dev/null +++ b/frontend/src/System/Backup/RestoreBackupModalConnector.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import { clearRestoreBackup } from 'Store/Actions/systemActions'; +import RestoreBackupModal from './RestoreBackupModal'; + +function createMapDispatchToProps(dispatch, props) { + return { + onModalClose() { + dispatch(clearRestoreBackup()); + + props.onModalClose(); + } + }; +} + +export default connect(null, createMapDispatchToProps)(RestoreBackupModal); diff --git a/frontend/src/System/Backup/RestoreBackupModalContent.css b/frontend/src/System/Backup/RestoreBackupModalContent.css new file mode 100644 index 000000000..2775e8e08 --- /dev/null +++ b/frontend/src/System/Backup/RestoreBackupModalContent.css @@ -0,0 +1,24 @@ +.additionalInfo { + flex-grow: 1; + color: #777; +} + +.steps { + margin-top: 20px; +} + +.step { + display: flex; + font-size: $largeFontSize; + line-height: 20px; +} + +.stepState { + margin-right: 8px; +} + +@media only screen and (max-width: $breakpointSmall) { + composes: modalFooter from '~Components/Modal/ModalFooter.css'; + + flex-wrap: wrap; +} diff --git a/frontend/src/System/Backup/RestoreBackupModalContent.js b/frontend/src/System/Backup/RestoreBackupModalContent.js new file mode 100644 index 000000000..07dbde1c6 --- /dev/null +++ b/frontend/src/System/Backup/RestoreBackupModalContent.js @@ -0,0 +1,232 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import TextInput from 'Components/Form/TextInput'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './RestoreBackupModalContent.css'; + +function getErrorMessage(error) { + if (!error || !error.responseJSON || !error.responseJSON.message) { + return 'Error restoring backup'; + } + + return error.responseJSON.message; +} + +function getStepIconProps(isExecuting, hasExecuted, error) { + if (isExecuting) { + return { + name: icons.SPINNER, + isSpinning: true + }; + } + + if (hasExecuted) { + return { + name: icons.CHECK, + kind: kinds.SUCCESS + }; + } + + if (error) { + return { + name: icons.FATAL, + kinds: kinds.DANGER, + title: getErrorMessage(error) + }; + } + + return { + name: icons.PENDING + }; +} + +class RestoreBackupModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + file: null, + path: '', + isRestored: false, + isRestarted: false, + isReloading: false + }; + } + + componentDidUpdate(prevProps) { + const { + isRestoring, + restoreError, + isRestarting, + dispatchRestart + } = this.props; + + if (prevProps.isRestoring && !isRestoring && !restoreError) { + this.setState({ isRestored: true }, () => { + dispatchRestart(); + }); + } + + if (prevProps.isRestarting && !isRestarting) { + this.setState({ + isRestarted: true, + isReloading: true + }, () => { + location.reload(); + }); + } + } + + // + // Listeners + + onPathChange = ({ value, files }) => { + this.setState({ + file: files[0], + path: value + }); + } + + onRestorePress = () => { + const { + id, + onRestorePress + } = this.props; + + onRestorePress({ + id, + file: this.state.file + }); + } + + // + // Render + + render() { + const { + id, + name, + isRestoring, + restoreError, + isRestarting, + onModalClose + } = this.props; + + const { + path, + isRestored, + isRestarted, + isReloading + } = this.state; + + const isRestoreDisabled = ( + (!id && !path) || + isRestoring || + isRestarting || + isReloading + ); + + return ( + + + Restore Backup + + + + { + !!id && `Would you like to restore the backup '${name}'?` + } + + { + !id && + + } + +
+
+
+ +
+ +
Restore
+
+ +
+
+ +
+ +
Restart
+
+ +
+
+ +
+ +
Reload
+
+
+
+ + +
+ Note: Lidarr will automatically restart and reload the UI during the restore process. +
+ + + + + Restore + +
+
+ ); + } +} + +RestoreBackupModalContent.propTypes = { + id: PropTypes.number, + name: PropTypes.string, + path: PropTypes.string, + isRestoring: PropTypes.bool.isRequired, + restoreError: PropTypes.object, + isRestarting: PropTypes.bool.isRequired, + dispatchRestart: PropTypes.func.isRequired, + onRestorePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default RestoreBackupModalContent; diff --git a/frontend/src/System/Backup/RestoreBackupModalContentConnector.js b/frontend/src/System/Backup/RestoreBackupModalContentConnector.js new file mode 100644 index 000000000..7f2b7a6e8 --- /dev/null +++ b/frontend/src/System/Backup/RestoreBackupModalContentConnector.js @@ -0,0 +1,37 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { restoreBackup, restart } from 'Store/Actions/systemActions'; +import RestoreBackupModalContent from './RestoreBackupModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.backups, + (state) => state.app.isRestarting, + (backups, isRestarting) => { + const { + isRestoring, + restoreError + } = backups; + + return { + isRestoring, + restoreError, + isRestarting + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onRestorePress(payload) { + dispatch(restoreBackup(payload)); + }, + + dispatchRestart() { + dispatch(restart()); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(RestoreBackupModalContent); diff --git a/frontend/src/System/Events/LogsTable.js b/frontend/src/System/Events/LogsTable.js new file mode 100644 index 000000000..ce6d0c995 --- /dev/null +++ b/frontend/src/System/Events/LogsTable.js @@ -0,0 +1,139 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { align, icons } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import TablePager from 'Components/Table/TablePager'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import LogsTableRow from './LogsTableRow'; + +function LogsTable(props) { + const { + isFetching, + isPopulated, + error, + items, + columns, + selectedFilterKey, + filters, + totalRecords, + clearLogExecuting, + onRefreshPress, + onClearLogsPress, + onFilterSelect, + ...otherProps + } = props; + + return ( + + + + + + + + + + + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + isPopulated && !error && !items.length && +
+ No logs found +
+ } + + { + isPopulated && !error && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ + +
+ } +
+
+ ); +} + +LogsTable.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + totalRecords: PropTypes.number, + clearLogExecuting: PropTypes.bool.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onRefreshPress: PropTypes.func.isRequired, + onClearLogsPress: PropTypes.func.isRequired +}; + +export default LogsTable; diff --git a/frontend/src/System/Events/LogsTableConnector.js b/frontend/src/System/Events/LogsTableConnector.js new file mode 100644 index 000000000..d2cb6caf8 --- /dev/null +++ b/frontend/src/System/Events/LogsTableConnector.js @@ -0,0 +1,141 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import withCurrentPage from 'Components/withCurrentPage'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as systemActions from 'Store/Actions/systemActions'; +import * as commandNames from 'Commands/commandNames'; +import LogsTable from './LogsTable'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.logs, + createCommandExecutingSelector(commandNames.CLEAR_LOGS), + (logs, clearLogExecuting) => { + return { + clearLogExecuting, + ...logs + }; + } + ); +} + +const mapDispatchToProps = { + executeCommand, + ...systemActions +}; + +class LogsTableConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + useCurrentPage, + fetchLogs, + gotoLogsFirstPage + } = this.props; + + if (useCurrentPage) { + fetchLogs(); + } else { + gotoLogsFirstPage(); + } + } + + componentDidUpdate(prevProps) { + if (prevProps.clearLogExecuting && !this.props.clearLogExecuting) { + this.props.gotoLogsFirstPage(); + } + } + + // + // Listeners + + onFirstPagePress = () => { + this.props.gotoLogsFirstPage(); + } + + onPreviousPagePress = () => { + this.props.gotoLogsPreviousPage(); + } + + onNextPagePress = () => { + this.props.gotoLogsNextPage(); + } + + onLastPagePress = () => { + this.props.gotoLogsLastPage(); + } + + onPageSelect = (page) => { + this.props.gotoLogsPage({ page }); + } + + onSortPress = (sortKey) => { + this.props.setLogsSort({ sortKey }); + } + + onFilterSelect = (selectedFilterKey) => { + this.props.setLogsFilter({ selectedFilterKey }); + } + + onTableOptionChange = (payload) => { + this.props.setLogsTableOption(payload); + + if (payload.pageSize) { + this.props.gotoLogsFirstPage(); + } + } + + onRefreshPress = () => { + this.props.gotoLogsFirstPage(); + } + + onClearLogsPress = () => { + this.props.executeCommand({ name: commandNames.CLEAR_LOGS }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +LogsTableConnector.propTypes = { + useCurrentPage: PropTypes.bool.isRequired, + clearLogExecuting: PropTypes.bool.isRequired, + fetchLogs: PropTypes.func.isRequired, + gotoLogsFirstPage: PropTypes.func.isRequired, + gotoLogsPreviousPage: PropTypes.func.isRequired, + gotoLogsNextPage: PropTypes.func.isRequired, + gotoLogsLastPage: PropTypes.func.isRequired, + gotoLogsPage: PropTypes.func.isRequired, + setLogsSort: PropTypes.func.isRequired, + setLogsFilter: PropTypes.func.isRequired, + setLogsTableOption: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default withCurrentPage( + connect(createMapStateToProps, mapDispatchToProps)(LogsTableConnector) +); diff --git a/frontend/src/System/Events/LogsTableDetailsModal.css b/frontend/src/System/Events/LogsTableDetailsModal.css new file mode 100644 index 000000000..b6e0dd24d --- /dev/null +++ b/frontend/src/System/Events/LogsTableDetailsModal.css @@ -0,0 +1,17 @@ +.detailsText { + composes: scroller from '~Components/Scroller/Scroller.css'; + + display: block; + margin: 0 0 10.5px; + padding: 10px; + border: 1px solid #ccc; + border-radius: 4px; + background-color: #f5f5f5; + color: #3a3f51; + white-space: pre; + word-wrap: break-word; + word-break: break-all; + font-size: 13px; + font-family: $monoSpaceFontFamily; + line-height: 1.52857143; +} diff --git a/frontend/src/System/Events/LogsTableDetailsModal.js b/frontend/src/System/Events/LogsTableDetailsModal.js new file mode 100644 index 000000000..de6a881df --- /dev/null +++ b/frontend/src/System/Events/LogsTableDetailsModal.js @@ -0,0 +1,74 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { scrollDirections } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Scroller from 'Components/Scroller/Scroller'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './LogsTableDetailsModal.css'; + +function LogsTableDetailsModal(props) { + const { + isOpen, + message, + exception, + onModalClose + } = props; + + return ( + + + + Details + + + +
Message
+ + + {message} + + + { + !!exception && +
+
Exception
+ + {exception} + +
+ } +
+ + + + +
+
+ ); +} + +LogsTableDetailsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + message: PropTypes.string.isRequired, + exception: PropTypes.string, + onModalClose: PropTypes.func.isRequired +}; + +export default LogsTableDetailsModal; diff --git a/frontend/src/System/Events/LogsTableRow.css b/frontend/src/System/Events/LogsTableRow.css new file mode 100644 index 000000000..8efd99abc --- /dev/null +++ b/frontend/src/System/Events/LogsTableRow.css @@ -0,0 +1,35 @@ +.level { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 20px; +} + +.info { + color: #1e90ff; +} + +.debug { + color: #808080; +} + +.trace { + color: #d3d3d3; +} + +.warn { + color: $warningColor; +} + +.error { + color: $dangerColor; +} + +.fatal { + color: $purple; +} + +.actions { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 45px; +} diff --git a/frontend/src/System/Events/LogsTableRow.js b/frontend/src/System/Events/LogsTableRow.js new file mode 100644 index 000000000..d6751a5e5 --- /dev/null +++ b/frontend/src/System/Events/LogsTableRow.js @@ -0,0 +1,157 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRowButton from 'Components/Table/TableRowButton'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import LogsTableDetailsModal from './LogsTableDetailsModal'; +import styles from './LogsTableRow.css'; + +function getIconName(level) { + switch (level) { + case 'trace': + case 'debug': + case 'info': + return icons.INFO; + case 'warn': + return icons.DANGER; + case 'error': + return icons.BUG; + case 'fatal': + return icons.FATAL; + default: + return icons.UNKNOWN; + } +} + +class LogsTableRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + // + // Listeners + + onPress = () => { + // Don't re-open the modal if it's already open + if (!this.state.isDetailsModalOpen) { + this.setState({ isDetailsModalOpen: true }); + } + } + + onModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + // + // Render + + render() { + const { + level, + logger, + message, + time, + exception, + columns + } = this.props; + + return ( + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'level') { + return ( + + + + ); + } + + if (name === 'logger') { + return ( + + {logger} + + ); + } + + if (name === 'message') { + return ( + + {message} + + ); + } + + if (name === 'time') { + return ( + + ); + } + + if (name === 'actions') { + return ( + + ); + } + + return null; + }) + } + + + + ); + } + +} + +LogsTableRow.propTypes = { + level: PropTypes.string.isRequired, + logger: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, + time: PropTypes.string.isRequired, + exception: PropTypes.string, + columns: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default LogsTableRow; diff --git a/frontend/src/System/Logs/Files/LogFiles.js b/frontend/src/System/Logs/Files/LogFiles.js new file mode 100644 index 000000000..47482b3fe --- /dev/null +++ b/frontend/src/System/Logs/Files/LogFiles.js @@ -0,0 +1,139 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Link from 'Components/Link/Link'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import TableBody from 'Components/Table/TableBody'; +import LogsNavMenu from '../LogsNavMenu'; +import LogFilesTableRow from './LogFilesTableRow'; + +const columns = [ + { + name: 'filename', + label: 'Filename', + isVisible: true + }, + { + name: 'lastWriteTime', + label: 'Last Write Time', + isVisible: true + }, + { + name: 'download', + isVisible: true + } +]; + +class LogFiles extends Component { + + // + // Render + + render() { + const { + isFetching, + items, + deleteFilesExecuting, + currentLogView, + location, + onRefreshPress, + onDeleteFilesPress, + ...otherProps + } = this.props; + + return ( + + + + + + + + + + + + + + +
+ Log files are located in: {location} +
+ + { + currentLogView === 'Log Files' && +
+ The log level defaults to 'Info' and can be changed in General Settings +
+ } +
+ + { + isFetching && + + } + + { + !isFetching && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+
+ } + + { + !isFetching && !items.length && +
No log files
+ } +
+
+ ); + } + +} + +LogFiles.propTypes = { + isFetching: PropTypes.bool.isRequired, + items: PropTypes.array.isRequired, + deleteFilesExecuting: PropTypes.bool.isRequired, + currentLogView: PropTypes.string.isRequired, + location: PropTypes.string.isRequired, + onRefreshPress: PropTypes.func.isRequired, + onDeleteFilesPress: PropTypes.func.isRequired +}; + +export default LogFiles; diff --git a/frontend/src/System/Logs/Files/LogFilesConnector.js b/frontend/src/System/Logs/Files/LogFilesConnector.js new file mode 100644 index 000000000..628bb571c --- /dev/null +++ b/frontend/src/System/Logs/Files/LogFilesConnector.js @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import combinePath from 'Utilities/String/combinePath'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { fetchLogFiles } from 'Store/Actions/systemActions'; +import * as commandNames from 'Commands/commandNames'; +import LogFiles from './LogFiles'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.logFiles, + (state) => state.system.status.item, + createCommandExecutingSelector(commandNames.DELETE_LOG_FILES), + (logFiles, status, deleteFilesExecuting) => { + const { + isFetching, + items + } = logFiles; + + const { + appData, + isWindows + } = status; + + return { + isFetching, + items, + deleteFilesExecuting, + currentLogView: 'Log Files', + location: combinePath(isWindows, appData, ['logs']) + }; + } + ); +} + +const mapDispatchToProps = { + fetchLogFiles, + executeCommand +}; + +class LogFilesConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchLogFiles(); + } + + componentDidUpdate(prevProps) { + if (prevProps.deleteFilesExecuting && !this.props.deleteFilesExecuting) { + this.props.fetchLogFiles(); + } + } + + // + // Listeners + + onRefreshPress = () => { + this.props.fetchLogFiles(); + } + + onDeleteFilesPress = () => { + this.props.executeCommand({ name: commandNames.DELETE_LOG_FILES }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +LogFilesConnector.propTypes = { + deleteFilesExecuting: PropTypes.bool.isRequired, + fetchLogFiles: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(LogFilesConnector); diff --git a/frontend/src/System/Logs/Files/LogFilesTableRow.css b/frontend/src/System/Logs/Files/LogFilesTableRow.css new file mode 100644 index 000000000..313f50cc0 --- /dev/null +++ b/frontend/src/System/Logs/Files/LogFilesTableRow.css @@ -0,0 +1,5 @@ +.download { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} diff --git a/frontend/src/System/Logs/Files/LogFilesTableRow.js b/frontend/src/System/Logs/Files/LogFilesTableRow.js new file mode 100644 index 000000000..7ae61a531 --- /dev/null +++ b/frontend/src/System/Logs/Files/LogFilesTableRow.js @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './LogFilesTableRow.css'; + +class LogFilesTableRow extends Component { + + // + // Render + + render() { + const { + filename, + lastWriteTime, + downloadUrl + } = this.props; + + return ( + + {filename} + + + + + + Download + + + + ); + } + +} + +LogFilesTableRow.propTypes = { + filename: PropTypes.string.isRequired, + lastWriteTime: PropTypes.string.isRequired, + downloadUrl: PropTypes.string.isRequired +}; + +export default LogFilesTableRow; diff --git a/frontend/src/System/Logs/Logs.js b/frontend/src/System/Logs/Logs.js new file mode 100644 index 000000000..fa0be453e --- /dev/null +++ b/frontend/src/System/Logs/Logs.js @@ -0,0 +1,30 @@ +import React, { Component } from 'react'; +import { Route } from 'react-router-dom'; +import Switch from 'Components/Router/Switch'; +import LogFilesConnector from './Files/LogFilesConnector'; +import UpdateLogFilesConnector from './Updates/UpdateLogFilesConnector'; + +class Logs extends Component { + + // + // Render + + render() { + return ( + + + + + + ); + } +} + +export default Logs; diff --git a/frontend/src/System/Logs/LogsNavMenu.js b/frontend/src/System/Logs/LogsNavMenu.js new file mode 100644 index 000000000..b69630248 --- /dev/null +++ b/frontend/src/System/Logs/LogsNavMenu.js @@ -0,0 +1,71 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Menu from 'Components/Menu/Menu'; +import MenuButton from 'Components/Menu/MenuButton'; +import MenuContent from 'Components/Menu/MenuContent'; +import MenuItem from 'Components/Menu/MenuItem'; + +class LogsNavMenu extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isMenuOpen: false + }; + } + + // + // Listeners + + onMenuButtonPress = () => { + this.setState({ isMenuOpen: !this.state.isMenuOpen }); + } + + onMenuItemPress = () => { + this.setState({ isMenuOpen: false }); + } + + // + // Render + + render() { + const { + current + } = this.props; + + return ( + + + {current} + + + + Log Files + + + + Updater Log Files + + + + ); + } +} + +LogsNavMenu.propTypes = { + current: PropTypes.string.isRequired +}; + +export default LogsNavMenu; diff --git a/frontend/src/System/Logs/Updates/UpdateLogFilesConnector.js b/frontend/src/System/Logs/Updates/UpdateLogFilesConnector.js new file mode 100644 index 000000000..3030c12ce --- /dev/null +++ b/frontend/src/System/Logs/Updates/UpdateLogFilesConnector.js @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import combinePath from 'Utilities/String/combinePath'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { fetchUpdateLogFiles } from 'Store/Actions/systemActions'; +import * as commandNames from 'Commands/commandNames'; +import LogFiles from '../Files/LogFiles'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.updateLogFiles, + (state) => state.system.status.item, + createCommandExecutingSelector(commandNames.DELETE_UPDATE_LOG_FILES), + (updateLogFiles, status, deleteFilesExecuting) => { + const { + isFetching, + items + } = updateLogFiles; + + const { + appData, + isWindows + } = status; + + return { + isFetching, + items, + deleteFilesExecuting, + currentLogView: 'Updater Log Files', + location: combinePath(isWindows, appData, ['UpdateLogs']) + }; + } + ); +} + +const mapDispatchToProps = { + fetchUpdateLogFiles, + executeCommand +}; + +class UpdateLogFilesConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchUpdateLogFiles(); + } + + componentDidUpdate(prevProps) { + if (prevProps.deleteFilesExecuting && !this.props.deleteFilesExecuting) { + this.props.fetchUpdateLogFiles(); + } + } + + // + // Listeners + + onRefreshPress = () => { + this.props.fetchUpdateLogFiles(); + } + + onDeleteFilesPress = () => { + this.props.executeCommand({ name: commandNames.DELETE_UPDATE_LOG_FILES }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +UpdateLogFilesConnector.propTypes = { + deleteFilesExecuting: PropTypes.bool.isRequired, + fetchUpdateLogFiles: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(UpdateLogFilesConnector); diff --git a/frontend/src/System/Status/About/About.css b/frontend/src/System/Status/About/About.css new file mode 100644 index 000000000..9886c7ad0 --- /dev/null +++ b/frontend/src/System/Status/About/About.css @@ -0,0 +1,5 @@ +.descriptionList { + composes: descriptionList from '~Components/DescriptionList/DescriptionList.css'; + + margin-bottom: 10px; +} diff --git a/frontend/src/System/Status/About/About.js b/frontend/src/System/Status/About/About.js new file mode 100644 index 000000000..12ec88157 --- /dev/null +++ b/frontend/src/System/Status/About/About.js @@ -0,0 +1,105 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import titleCase from 'Utilities/String/titleCase'; +import FieldSet from 'Components/FieldSet'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import StartTime from './StartTime'; +import styles from './About.css'; + +class About extends Component { + + // + // Render + + render() { + const { + version, + isMonoRuntime, + isDocker, + runtimeVersion, + migrationVersion, + appData, + startupPath, + mode, + startTime, + timeFormat, + longDateFormat + } = this.props; + + return ( +
+ + + + { + isMonoRuntime && + + } + + { + isDocker && + + } + + + + + + + + + + + } + /> + +
+ ); + } + +} + +About.propTypes = { + version: PropTypes.string.isRequired, + isMonoRuntime: PropTypes.bool.isRequired, + runtimeVersion: PropTypes.string.isRequired, + isDocker: PropTypes.bool.isRequired, + migrationVersion: PropTypes.number.isRequired, + appData: PropTypes.string.isRequired, + startupPath: PropTypes.string.isRequired, + mode: PropTypes.string.isRequired, + startTime: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired +}; + +export default About; diff --git a/frontend/src/System/Status/About/AboutConnector.js b/frontend/src/System/Status/About/AboutConnector.js new file mode 100644 index 000000000..475d9778b --- /dev/null +++ b/frontend/src/System/Status/About/AboutConnector.js @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchStatus } from 'Store/Actions/systemActions'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import About from './About'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.status, + createUISettingsSelector(), + (status, uiSettings) => { + return { + ...status.item, + timeFormat: uiSettings.timeFormat, + longDateFormat: uiSettings.longDateFormat + }; + } + ); +} + +const mapDispatchToProps = { + fetchStatus +}; + +class AboutConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchStatus(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AboutConnector.propTypes = { + fetchStatus: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AboutConnector); diff --git a/frontend/src/System/Status/About/StartTime.js b/frontend/src/System/Status/About/StartTime.js new file mode 100644 index 000000000..94b4322d5 --- /dev/null +++ b/frontend/src/System/Status/About/StartTime.js @@ -0,0 +1,93 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; + +function getUptime(startTime) { + return formatTimeSpan(moment().diff(startTime)); +} + +class StartTime extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + startTime, + timeFormat, + longDateFormat + } = props; + + this._timeoutId = null; + + this.state = { + uptime: getUptime(startTime), + startTime: formatDateTime(startTime, longDateFormat, timeFormat, { includeSeconds: true }) + }; + } + + componentDidMount() { + this._timeoutId = setTimeout(this.onTimeout, 1000); + } + + componentDidUpdate(prevProps) { + const { + startTime, + timeFormat, + longDateFormat + } = this.props; + + if ( + startTime !== prevProps.startTime || + timeFormat !== prevProps.timeFormat || + longDateFormat !== prevProps.longDateFormat + ) { + this.setState({ + uptime: getUptime(startTime), + startTime: formatDateTime(startTime, longDateFormat, timeFormat, { includeSeconds: true }) + }); + } + } + + componentWillUnmount() { + if (this._timeoutId) { + this._timeoutId = clearTimeout(this._timeoutId); + } + } + + // + // Listeners + + onTimeout = () => { + this.setState({ uptime: getUptime(this.props.startTime) }); + this._timeoutId = setTimeout(this.onTimeout, 1000); + } + + // + // Render + + render() { + const { + uptime, + startTime + } = this.state; + + return ( + + {uptime} + + ); + } +} + +StartTime.propTypes = { + startTime: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired +}; + +export default StartTime; diff --git a/frontend/src/System/Status/DiskSpace/DiskSpace.css b/frontend/src/System/Status/DiskSpace/DiskSpace.css new file mode 100644 index 000000000..dd92926d4 --- /dev/null +++ b/frontend/src/System/Status/DiskSpace/DiskSpace.css @@ -0,0 +1,5 @@ +.space { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 150px; +} diff --git a/frontend/src/System/Status/DiskSpace/DiskSpace.js b/frontend/src/System/Status/DiskSpace/DiskSpace.js new file mode 100644 index 000000000..adb5bb853 --- /dev/null +++ b/frontend/src/System/Status/DiskSpace/DiskSpace.js @@ -0,0 +1,122 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds, sizes } from 'Helpers/Props'; +import formatBytes from 'Utilities/Number/formatBytes'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FieldSet from 'Components/FieldSet'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import ProgressBar from 'Components/ProgressBar'; +import styles from './DiskSpace.css'; + +const columns = [ + { + name: 'path', + label: 'Location', + isVisible: true + }, + { + name: 'freeSpace', + label: 'Free Space', + isVisible: true + }, + { + name: 'totalSpace', + label: 'Total Space', + isVisible: true + }, + { + name: 'progress', + isVisible: true + } +]; + +class DiskSpace extends Component { + + // + // Render + + render() { + const { + isFetching, + items + } = this.props; + + return ( +
+ { + isFetching && + + } + + { + !isFetching && + + + { + items.map((item) => { + const { + freeSpace, + totalSpace + } = item; + + const diskUsage = (100 - freeSpace / totalSpace * 100); + let diskUsageKind = kinds.PRIMARY; + + if (diskUsage > 90) { + diskUsageKind = kinds.DANGER; + } else if (diskUsage > 80) { + diskUsageKind = kinds.WARNING; + } + + return ( + + + {item.path} + + { + item.label && + ` (${item.label})` + } + + + + {formatBytes(freeSpace)} + + + + {formatBytes(totalSpace)} + + + + + + + ); + }) + } + +
+ } +
+ ); + } + +} + +DiskSpace.propTypes = { + isFetching: PropTypes.bool.isRequired, + items: PropTypes.array.isRequired +}; + +export default DiskSpace; diff --git a/frontend/src/System/Status/DiskSpace/DiskSpaceConnector.js b/frontend/src/System/Status/DiskSpace/DiskSpaceConnector.js new file mode 100644 index 000000000..3049b2ead --- /dev/null +++ b/frontend/src/System/Status/DiskSpace/DiskSpaceConnector.js @@ -0,0 +1,54 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchDiskSpace } from 'Store/Actions/systemActions'; +import DiskSpace from './DiskSpace'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.diskSpace, + (diskSpace) => { + const { + isFetching, + items + } = diskSpace; + + return { + isFetching, + items + }; + } + ); +} + +const mapDispatchToProps = { + fetchDiskSpace +}; + +class DiskSpaceConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchDiskSpace(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +DiskSpaceConnector.propTypes = { + fetchDiskSpace: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DiskSpaceConnector); diff --git a/frontend/src/System/Status/Health/Health.css b/frontend/src/System/Status/Health/Health.css new file mode 100644 index 000000000..1c4cb6a9d --- /dev/null +++ b/frontend/src/System/Status/Health/Health.css @@ -0,0 +1,20 @@ +.legend { + display: flex; + justify-content: space-between; +} + +.loading { + composes: loading from '~Components/Loading/LoadingIndicator.css'; + + margin-top: 2px; + margin-left: 10px; + text-align: left; +} + +.status { + width: 20px; +} + +.healthOk { + margin-bottom: 25px; +} diff --git a/frontend/src/System/Status/Health/Health.js b/frontend/src/System/Status/Health/Health.js new file mode 100644 index 000000000..b99075704 --- /dev/null +++ b/frontend/src/System/Status/Health/Health.js @@ -0,0 +1,223 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import titleCase from 'Utilities/String/titleCase'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FieldSet from 'Components/FieldSet'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './Health.css'; + +function getInternalLink(source) { + switch (source) { + case 'IndexerRssCheck': + case 'IndexerSearchCheck': + case 'IndexerStatusCheck': + return ( + + ); + case 'DownloadClientCheck': + case 'DownloadClientStatusCheck': + case 'ImportMechanismCheck': + case 'RemotePathMappingCheck': + return ( + + ); + case 'RootFolderCheck': + return ( + + ); + case 'UpdateCheck': + return ( + + ); + default: + return; + } +} + +function getTestLink(source, props) { + switch (source) { + case 'IndexerStatusCheck': + return ( + + ); + case 'DownloadClientCheck': + case 'DownloadClientStatusCheck': + return ( + + ); + + default: + break; + } +} + +const columns = [ + { + className: styles.status, + name: 'type', + isVisible: true + }, + { + name: 'message', + label: 'Message', + isVisible: true + }, + { + name: 'actions', + label: 'Actions', + isVisible: true + } +]; + +class Health extends Component { + + // + // Render + + render() { + const { + isFetching, + isPopulated, + items + } = this.props; + + const healthIssues = !!items.length; + + return ( +
+ Health + + { + isFetching && isPopulated && + + } +
+ } + > + { + isFetching && !isPopulated && + + } + + { + !healthIssues && +
+ No issues with your configuration +
+ } + + { + healthIssues && + + + { + items.map((item) => { + const internalLink = getInternalLink(item.source); + const testLink = getTestLink(item.source, this.props); + + let kind = kinds.WARNING; + switch (item.type.toLowerCase()) { + case 'error': + kind = kinds.DANGER; + break; + default: + case 'warning': + kind = kinds.WARNING; + break; + case 'notice': + kind = kinds.INFO; + break; + } + + return ( + + + + + + {item.message} + + + + + { + internalLink + } + + { + !!testLink && + testLink + } + + + ); + }) + } + +
+ } + + ); + } + +} + +Health.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + items: PropTypes.array.isRequired, + isTestingAllDownloadClients: PropTypes.bool.isRequired, + isTestingAllIndexers: PropTypes.bool.isRequired, + dispatchTestAllDownloadClients: PropTypes.func.isRequired, + dispatchTestAllIndexers: PropTypes.func.isRequired +}; + +export default Health; diff --git a/frontend/src/System/Status/Health/HealthConnector.js b/frontend/src/System/Status/Health/HealthConnector.js new file mode 100644 index 000000000..d2adc41cc --- /dev/null +++ b/frontend/src/System/Status/Health/HealthConnector.js @@ -0,0 +1,68 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchHealth } from 'Store/Actions/systemActions'; +import { testAllDownloadClients, testAllIndexers } from 'Store/Actions/settingsActions'; +import Health from './Health'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.health, + (state) => state.settings.downloadClients.isTestingAll, + (state) => state.settings.indexers.isTestingAll, + (health, isTestingAllDownloadClients, isTestingAllIndexers) => { + const { + isFetching, + isPopulated, + items + } = health; + + return { + isFetching, + isPopulated, + items, + isTestingAllDownloadClients, + isTestingAllIndexers + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchHealth: fetchHealth, + dispatchTestAllDownloadClients: testAllDownloadClients, + dispatchTestAllIndexers: testAllIndexers +}; + +class HealthConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchHealth(); + } + + // + // Render + + render() { + const { + dispatchFetchHealth, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +HealthConnector.propTypes = { + dispatchFetchHealth: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(HealthConnector); diff --git a/frontend/src/System/Status/Health/HealthStatusConnector.js b/frontend/src/System/Status/Health/HealthStatusConnector.js new file mode 100644 index 000000000..181eae916 --- /dev/null +++ b/frontend/src/System/Status/Health/HealthStatusConnector.js @@ -0,0 +1,79 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchHealth } from 'Store/Actions/systemActions'; +import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus'; + +function createMapStateToProps() { + return createSelector( + (state) => state.app, + (state) => state.system.health, + (app, health) => { + const count = health.items.length; + let errors = false; + let warnings = false; + + health.items.forEach((item) => { + if (item.type === 'error') { + errors = true; + } + + if (item.type === 'warning') { + warnings = true; + } + }); + + return { + isConnected: app.isConnected, + isReconnecting: app.isReconnecting, + isPopulated: health.isPopulated, + count, + errors, + warnings + }; + } + ); +} + +const mapDispatchToProps = { + fetchHealth +}; + +class HealthStatusConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + if (!this.props.isPopulated) { + this.props.fetchHealth(); + } + } + + componentDidUpdate(prevProps) { + if (this.props.isConnected && prevProps.isReconnecting) { + this.props.fetchHealth(); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +HealthStatusConnector.propTypes = { + isConnected: PropTypes.bool.isRequired, + isReconnecting: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + fetchHealth: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(HealthStatusConnector); diff --git a/frontend/src/System/Status/MoreInfo/MoreInfo.js b/frontend/src/System/Status/MoreInfo/MoreInfo.js new file mode 100644 index 000000000..40286e27b --- /dev/null +++ b/frontend/src/System/Status/MoreInfo/MoreInfo.js @@ -0,0 +1,67 @@ +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import FieldSet from 'Components/FieldSet'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle'; +import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription'; + +class MoreInfo extends Component { + + // + // Render + + render() { + return ( +
+ + Home page + + lidarr.audio + + + Wiki + + wiki.lidarr.audio + + + Reddit + + Lidarr + + + Discord + + #lidarr on Discord + + + Donations + + Donate to Lidarr + + + Sonarr Donations + + Donate to Sonarr + + + Source + + github.com/Lidarr/Lidarr + + + Feature Requests + + github.com/Lidarr/Lidarr/issues + + + +
+ ); + } +} + +MoreInfo.propTypes = { + +}; + +export default MoreInfo; diff --git a/frontend/src/System/Status/Status.js b/frontend/src/System/Status/Status.js new file mode 100644 index 000000000..f0b157515 --- /dev/null +++ b/frontend/src/System/Status/Status.js @@ -0,0 +1,29 @@ +import React, { Component } from 'react'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import HealthConnector from './Health/HealthConnector'; +import DiskSpaceConnector from './DiskSpace/DiskSpaceConnector'; +import AboutConnector from './About/AboutConnector'; +import MoreInfo from './MoreInfo/MoreInfo'; + +class Status extends Component { + + // + // Render + + render() { + return ( + + + + + + + + + ); + } + +} + +export default Status; diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRow.css b/frontend/src/System/Tasks/Queued/QueuedTaskRow.css new file mode 100644 index 000000000..6e38929c9 --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTaskRow.css @@ -0,0 +1,31 @@ +.trigger { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 50px; +} + +.triggerContent { + display: flex; + justify-content: space-between; + width: 100%; +} + +.queued, +.started, +.ended { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 180px; +} + +.duration { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} + +.actions { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 60px; +} diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRow.js b/frontend/src/System/Tasks/Queued/QueuedTaskRow.js new file mode 100644 index 000000000..4aa6d76d6 --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTaskRow.js @@ -0,0 +1,265 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import titleCase from 'Utilities/String/titleCase'; +import formatDate from 'Utilities/Date/formatDate'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './QueuedTaskRow.css'; + +function getStatusIconProps(status, message) { + const title = titleCase(status); + + switch (status) { + case 'queued': + return { + name: icons.PENDING, + title + }; + + case 'started': + return { + name: icons.REFRESH, + isSpinning: true, + title + }; + + case 'completed': + return { + name: icons.CHECK, + kind: kinds.SUCCESS, + title: message === 'Completed' ? title : `${title}: ${message}` + }; + + case 'failed': + return { + name: icons.FATAL, + kind: kinds.ERROR, + title: `${title}: ${message}` + }; + + default: + return { + name: icons.UNKNOWN, + title + }; + } +} + +function getFormattedDates(props) { + const { + queued, + started, + ended, + showRelativeDates, + shortDateFormat + } = props; + + if (showRelativeDates) { + return { + queuedAt: moment(queued).fromNow(), + startedAt: started ? moment(started).fromNow() : '-', + endedAt: ended ? moment(ended).fromNow() : '-' + }; + } + + return { + queuedAt: formatDate(queued, shortDateFormat), + startedAt: started ? formatDate(started, shortDateFormat) : '-', + endedAt: ended ? formatDate(ended, shortDateFormat) : '-' + }; +} + +class QueuedTaskRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + ...getFormattedDates(props), + isCancelConfirmModalOpen: false + }; + + this._updateTimeoutId = null; + } + + componentDidMount() { + this.setUpdateTimer(); + } + + componentDidUpdate(prevProps) { + const { + queued, + started, + ended + } = this.props; + + if ( + queued !== prevProps.queued || + started !== prevProps.started || + ended !== prevProps.ended + ) { + this.setState(getFormattedDates(this.props)); + } + } + + componentWillUnmount() { + if (this._updateTimeoutId) { + this._updateTimeoutId = clearTimeout(this._updateTimeoutId); + } + } + + // + // Control + + setUpdateTimer() { + this._updateTimeoutId = setTimeout(() => { + this.setState(getFormattedDates(this.props)); + this.setUpdateTimer(); + }, 30000); + } + + // + // Listeners + + onCancelPress = () => { + this.setState({ + isCancelConfirmModalOpen: true + }); + } + + onAbortCancel = () => { + this.setState({ + isCancelConfirmModalOpen: false + }); + } + + // + // Render + + render() { + const { + trigger, + commandName, + queued, + started, + ended, + status, + duration, + message, + longDateFormat, + timeFormat, + onCancelPress + } = this.props; + + const { + queuedAt, + startedAt, + endedAt, + isCancelConfirmModalOpen + } = this.state; + + let triggerIcon = icons.UNKNOWN; + + if (trigger === 'manual') { + triggerIcon = icons.INTERACTIVE; + } else if (trigger === 'scheduled') { + triggerIcon = icons.SCHEDULED; + } + + return ( + + + + + + + + + + {commandName} + + + {queuedAt} + + + + {startedAt} + + + + {endedAt} + + + + {formatTimeSpan(duration)} + + + + { + status === 'queued' && + + } + + + + + ); + } +} + +QueuedTaskRow.propTypes = { + trigger: PropTypes.string.isRequired, + commandName: PropTypes.string.isRequired, + queued: PropTypes.string.isRequired, + started: PropTypes.string, + ended: PropTypes.string, + status: PropTypes.string.isRequired, + duration: PropTypes.string, + message: PropTypes.string, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + onCancelPress: PropTypes.func.isRequired +}; + +export default QueuedTaskRow; diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js b/frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js new file mode 100644 index 000000000..f55ab985a --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js @@ -0,0 +1,31 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { cancelCommand } from 'Store/Actions/commandActions'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import QueuedTaskRow from './QueuedTaskRow'; + +function createMapStateToProps() { + return createSelector( + createUISettingsSelector(), + (uiSettings) => { + return { + showRelativeDates: uiSettings.showRelativeDates, + shortDateFormat: uiSettings.shortDateFormat, + longDateFormat: uiSettings.longDateFormat, + timeFormat: uiSettings.timeFormat + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onCancelPress() { + dispatch(cancelCommand({ + id: props.id + })); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(QueuedTaskRow); diff --git a/frontend/src/System/Tasks/Queued/QueuedTasks.js b/frontend/src/System/Tasks/Queued/QueuedTasks.js new file mode 100644 index 000000000..a2fd526fa --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTasks.js @@ -0,0 +1,89 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import FieldSet from 'Components/FieldSet'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import QueuedTaskRowConnector from './QueuedTaskRowConnector'; + +const columns = [ + { + name: 'trigger', + label: '', + isVisible: true + }, + { + name: 'commandName', + label: 'Name', + isVisible: true + }, + { + name: 'queued', + label: 'Queued', + isVisible: true + }, + { + name: 'started', + label: 'Started', + isVisible: true + }, + { + name: 'ended', + label: 'Ended', + isVisible: true + }, + { + name: 'duration', + label: 'Duration', + isVisible: true + }, + { + name: 'actions', + isVisible: true + } +]; + +function QueuedTasks(props) { + const { + isFetching, + isPopulated, + items + } = props; + + return ( +
+ { + isFetching && !isPopulated && + + } + + { + isPopulated && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ } +
+ ); +} + +QueuedTasks.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + items: PropTypes.array.isRequired +}; + +export default QueuedTasks; diff --git a/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js b/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js new file mode 100644 index 000000000..5fa4d9ead --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js @@ -0,0 +1,46 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchCommands } from 'Store/Actions/commandActions'; +import QueuedTasks from './QueuedTasks'; + +function createMapStateToProps() { + return createSelector( + (state) => state.commands, + (commands) => { + return commands; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchCommands: fetchCommands +}; + +class QueuedTasksConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchCommands(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +QueuedTasksConnector.propTypes = { + dispatchFetchCommands: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(QueuedTasksConnector); diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.css b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.css new file mode 100644 index 000000000..924963258 --- /dev/null +++ b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.css @@ -0,0 +1,18 @@ +.interval { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 150px; +} + +.lastExecution, +.nextExecution { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 180px; +} + +.actions { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 20px; +} diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js new file mode 100644 index 000000000..82cedc720 --- /dev/null +++ b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js @@ -0,0 +1,182 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import formatDate from 'Utilities/Date/formatDate'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import { icons } from 'Helpers/Props'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './ScheduledTaskRow.css'; + +function getFormattedDates(props) { + const { + lastExecution, + nextExecution, + interval, + showRelativeDates, + shortDateFormat + } = props; + + const isDisabled = interval === 0; + + if (showRelativeDates) { + return { + lastExecutionTime: moment(lastExecution).fromNow(), + nextExecutionTime: isDisabled ? '-' : moment(nextExecution).fromNow() + }; + } + + return { + lastExecutionTime: formatDate(lastExecution, shortDateFormat), + nextExecutionTime: isDisabled ? '-' : formatDate(nextExecution, shortDateFormat) + }; +} + +class ScheduledTaskRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = getFormattedDates(props); + + this._updateTimeoutId = null; + } + + componentDidMount() { + this.setUpdateTimer(); + } + + componentDidUpdate(prevProps) { + const { + lastExecution, + nextExecution + } = this.props; + + if ( + lastExecution !== prevProps.lastExecution || + nextExecution !== prevProps.nextExecution + ) { + this.setState(getFormattedDates(this.props)); + } + } + + componentWillUnmount() { + if (this._updateTimeoutId) { + this._updateTimeoutId = clearTimeout(this._updateTimeoutId); + } + } + + // + // Listeners + + setUpdateTimer() { + const { interval } = this.props; + const timeout = interval < 60 ? 10000 : 60000; + + this._updateTimeoutId = setTimeout(() => { + this.setState(getFormattedDates(this.props)); + this.setUpdateTimer(); + }, timeout); + } + + // + // Render + + render() { + const { + name, + interval, + lastExecution, + nextExecution, + isQueued, + isExecuting, + longDateFormat, + timeFormat, + onExecutePress + } = this.props; + + const { + lastExecutionTime, + nextExecutionTime + } = this.state; + + const isDisabled = interval === 0; + const executeNow = !isDisabled && moment().isAfter(nextExecution); + const hasNextExecutionTime = !isDisabled && !executeNow; + const duration = moment.duration(interval, 'minutes').humanize().replace(/an?(?=\s)/, '1'); + + return ( + + {name} + + {isDisabled ? 'disabled' : duration} + + + + {lastExecutionTime} + + + { + isDisabled && + - + } + + { + executeNow && isQueued && + queued + } + + { + executeNow && !isQueued && + now + } + + { + hasNextExecutionTime && + + {nextExecutionTime} + + } + + + + + + ); + } +} + +ScheduledTaskRow.propTypes = { + name: PropTypes.string.isRequired, + interval: PropTypes.number.isRequired, + lastExecution: PropTypes.string.isRequired, + nextExecution: PropTypes.string.isRequired, + isQueued: PropTypes.bool.isRequired, + isExecuting: PropTypes.bool.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + onExecutePress: PropTypes.func.isRequired +}; + +export default ScheduledTaskRow; diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRowConnector.js b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRowConnector.js new file mode 100644 index 000000000..79a0c6c87 --- /dev/null +++ b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRowConnector.js @@ -0,0 +1,92 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { findCommand, isCommandExecuting } from 'Utilities/Command'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { fetchTask } from 'Store/Actions/systemActions'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import ScheduledTaskRow from './ScheduledTaskRow'; + +function createMapStateToProps() { + return createSelector( + (state, { taskName }) => taskName, + createCommandsSelector(), + createUISettingsSelector(), + (taskName, commands, uiSettings) => { + const command = findCommand(commands, { name: taskName }); + + return { + isQueued: !!(command && command.state === 'queued'), + isExecuting: isCommandExecuting(command), + showRelativeDates: uiSettings.showRelativeDates, + shortDateFormat: uiSettings.shortDateFormat, + longDateFormat: uiSettings.longDateFormat, + timeFormat: uiSettings.timeFormat + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + const taskName = props.taskName; + + return { + dispatchFetchTask() { + dispatch(fetchTask({ + id: props.id + })); + }, + + onExecutePress() { + dispatch(executeCommand({ + name: taskName + })); + } + }; +} + +class ScheduledTaskRowConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps) { + const { + isExecuting, + dispatchFetchTask + } = this.props; + + if (!isExecuting && prevProps.isExecuting) { + // Give the host a moment to update after the command completes + setTimeout(() => { + dispatchFetchTask(); + }, 1000); + } + } + + // + // Render + + render() { + const { + dispatchFetchTask, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +ScheduledTaskRowConnector.propTypes = { + id: PropTypes.number.isRequired, + isExecuting: PropTypes.bool.isRequired, + dispatchFetchTask: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, createMapDispatchToProps)(ScheduledTaskRowConnector); diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTasks.js b/frontend/src/System/Tasks/Scheduled/ScheduledTasks.js new file mode 100644 index 000000000..7c6fe8a32 --- /dev/null +++ b/frontend/src/System/Tasks/Scheduled/ScheduledTasks.js @@ -0,0 +1,79 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import FieldSet from 'Components/FieldSet'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import ScheduledTaskRowConnector from './ScheduledTaskRowConnector'; + +const columns = [ + { + name: 'name', + label: 'Name', + isVisible: true + }, + { + name: 'interval', + label: 'Interval', + isVisible: true + }, + { + name: 'lastExecution', + label: 'Last Execution', + isVisible: true + }, + { + name: 'nextExecution', + label: 'Next Execution', + isVisible: true + }, + { + name: 'actions', + isVisible: true + } +]; + +function ScheduledTasks(props) { + const { + isFetching, + isPopulated, + items + } = props; + + return ( +
+ { + isFetching && !isPopulated && + + } + + { + isPopulated && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ } +
+ ); +} + +ScheduledTasks.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + items: PropTypes.array.isRequired +}; + +export default ScheduledTasks; diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTasksConnector.js b/frontend/src/System/Tasks/Scheduled/ScheduledTasksConnector.js new file mode 100644 index 000000000..8f418d3bb --- /dev/null +++ b/frontend/src/System/Tasks/Scheduled/ScheduledTasksConnector.js @@ -0,0 +1,46 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchTasks } from 'Store/Actions/systemActions'; +import ScheduledTasks from './ScheduledTasks'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.tasks, + (tasks) => { + return tasks; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchTasks: fetchTasks +}; + +class ScheduledTasksConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchTasks(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ScheduledTasksConnector.propTypes = { + dispatchFetchTasks: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ScheduledTasksConnector); diff --git a/frontend/src/System/Tasks/Tasks.js b/frontend/src/System/Tasks/Tasks.js new file mode 100644 index 000000000..dbbb4d1bf --- /dev/null +++ b/frontend/src/System/Tasks/Tasks.js @@ -0,0 +1,18 @@ +import React from 'react'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import ScheduledTasksConnector from './Scheduled/ScheduledTasksConnector'; +import QueuedTasksConnector from './Queued/QueuedTasksConnector'; + +function Tasks() { + return ( + + + + + + + ); +} + +export default Tasks; diff --git a/frontend/src/System/Updates/UpdateChanges.css b/frontend/src/System/Updates/UpdateChanges.css new file mode 100644 index 000000000..d21897373 --- /dev/null +++ b/frontend/src/System/Updates/UpdateChanges.css @@ -0,0 +1,4 @@ +.title { + margin-top: 10px; + font-size: 16px; +} diff --git a/frontend/src/System/Updates/UpdateChanges.js b/frontend/src/System/Updates/UpdateChanges.js new file mode 100644 index 000000000..63c7e0d85 --- /dev/null +++ b/frontend/src/System/Updates/UpdateChanges.js @@ -0,0 +1,45 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './UpdateChanges.css'; + +class UpdateChanges extends Component { + + // + // Render + + render() { + const { + title, + changes + } = this.props; + + if (changes.length === 0) { + return null; + } + + return ( +
+
{title}
+
    + { + changes.map((change, index) => { + return ( +
  • + {change} +
  • + ); + }) + } +
+
+ ); + } + +} + +UpdateChanges.propTypes = { + title: PropTypes.string.isRequired, + changes: PropTypes.arrayOf(PropTypes.string) +}; + +export default UpdateChanges; diff --git a/frontend/src/System/Updates/Updates.css b/frontend/src/System/Updates/Updates.css new file mode 100644 index 000000000..f563cb743 --- /dev/null +++ b/frontend/src/System/Updates/Updates.css @@ -0,0 +1,57 @@ +.updateAvailable { + display: flex; +} + +.upToDate { + display: flex; + margin-bottom: 20px; +} + +.upToDateIcon { + color: #37bc9b; + font-size: 30px; +} + +.upToDateMessage { + padding-left: 5px; + font-size: 18px; + line-height: 30px; +} + +.loading { + composes: loading from '~Components/Loading/LoadingIndicator.css'; + + margin-top: 5px; + margin-left: auto; +} + +.update { + margin-top: 20px; +} + +.info { + display: flex; + align-items: center; + margin-bottom: 10px; + padding-bottom: 5px; + border-bottom: 1px solid #e5e5e5; +} + +.version { + font-size: 21px; +} + +.space { + padding: 0 5px; +} + +.date { + font-size: 16px; +} + +.label { + composes: label from '~Components/Label.css'; + + margin-left: 10px; + font-size: 14px; +} diff --git a/frontend/src/System/Updates/Updates.js b/frontend/src/System/Updates/Updates.js new file mode 100644 index 000000000..d35ecd23a --- /dev/null +++ b/frontend/src/System/Updates/Updates.js @@ -0,0 +1,195 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import formatDate from 'Utilities/Date/formatDate'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import Icon from 'Components/Icon'; +import Label from 'Components/Label'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import UpdateChanges from './UpdateChanges'; +import styles from './Updates.css'; + +class Updates extends Component { + + // + // Render + + render() { + const { + currentVersion, + isFetching, + isPopulated, + error, + items, + isInstallingUpdate, + isDocker, + shortDateFormat, + onInstallLatestPress + } = this.props; + + const hasUpdates = isPopulated && !error && items.length > 0; + const noUpdates = isPopulated && !error && !items.length; + const hasUpdateToInstall = hasUpdates && _.some(items, { installable: true, latest: true }); + const noUpdateToInstall = hasUpdates && !hasUpdateToInstall; + + return ( + + + { + !isPopulated && !error && + + } + + { + noUpdates && +
No updates are available
+ } + + { + hasUpdateToInstall && +
+ { + !isDocker && + + Install Latest + + } + + { + isDocker && +
+ An update is available. Please update your Docker image and re-create the container. +
+ } + + { + isFetching && + + } +
+ } + + { + noUpdateToInstall && +
+ +
+ The latest version of Lidarr is already installed +
+ + { + isFetching && + + } +
+ } + + { + hasUpdates && +
+ { + items.map((update) => { + const hasChanges = !!update.changes; + + return ( +
+
+
{update.version}
+
+
{formatDate(update.releaseDate, shortDateFormat)}
+ + { + update.branch === 'master' ? + null : + + } + + { + update.version === currentVersion ? + : + null + } +
+ + { + !hasChanges && +
Maintenance release
+ } + + { + hasChanges && +
+ + + +
+ } +
+ ); + }) + } +
+ } + + { + !!error && +
+ Failed to fetch updates +
+ } +
+
+ ); + } + +} + +Updates.propTypes = { + currentVersion: PropTypes.string.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.array.isRequired, + isInstallingUpdate: PropTypes.bool.isRequired, + isDocker: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + onInstallLatestPress: PropTypes.func.isRequired +}; + +export default Updates; diff --git a/frontend/src/System/Updates/UpdatesConnector.js b/frontend/src/System/Updates/UpdatesConnector.js new file mode 100644 index 000000000..7c40069d4 --- /dev/null +++ b/frontend/src/System/Updates/UpdatesConnector.js @@ -0,0 +1,87 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchUpdates } from 'Store/Actions/systemActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; +import * as commandNames from 'Commands/commandNames'; +import Updates from './Updates'; + +function createMapStateToProps() { + return createSelector( + (state) => state.app.version, + (state) => state.system.updates, + createUISettingsSelector(), + createCommandExecutingSelector(commandNames.APPLICATION_UPDATE), + createSystemStatusSelector(), + ( + currentVersion, + updates, + uiSettings, + isInstallingUpdate, + systemStatus + ) => { + const { + isFetching, + isPopulated, + error, + items + } = updates; + + return { + currentVersion, + isFetching, + isPopulated, + error, + items, + isInstallingUpdate, + isDocker: systemStatus.isDocker, + shortDateFormat: uiSettings.shortDateFormat + }; + } + ); +} + +const mapDispatchToProps = { + fetchUpdates, + executeCommand +}; + +class UpdatesConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchUpdates(); + } + + // + // Listeners + + onInstallLatestPress = () => { + this.props.executeCommand({ name: commandNames.APPLICATION_UPDATE }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +UpdatesConnector.propTypes = { + fetchUpdates: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(UpdatesConnector); diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorModal.js b/frontend/src/TrackFile/Editor/TrackFileEditorModal.js new file mode 100644 index 000000000..7f52aca05 --- /dev/null +++ b/frontend/src/TrackFile/Editor/TrackFileEditorModal.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import TrackFileEditorModalContentConnector from './TrackFileEditorModalContentConnector'; + +function TrackFileEditorModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + { + isOpen && + + } + + ); +} + +TrackFileEditorModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default TrackFileEditorModal; diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.css b/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.css new file mode 100644 index 000000000..49e946826 --- /dev/null +++ b/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.css @@ -0,0 +1,8 @@ +.actions { + display: flex; + margin-right: auto; +} + +.selectInput { + margin-left: 10px; +} diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.js b/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.js new file mode 100644 index 000000000..f9d9cc282 --- /dev/null +++ b/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.js @@ -0,0 +1,262 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { kinds } from 'Helpers/Props'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import SelectInput from 'Components/Form/SelectInput'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TrackFileEditorRow from './TrackFileEditorRow'; +import styles from './TrackFileEditorModalContent.css'; + +const columns = [ + { + name: 'trackNumber', + label: 'Track', + isVisible: true + }, + { + name: 'relativePath', + label: 'Relative Path', + isVisible: true + }, + { + name: 'quality', + label: 'Quality', + isVisible: true + } +]; + +class TrackFileEditorModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, + isConfirmDeleteModalOpen: false + }; + } + + componentDidUpdate(prevProps) { + if (hasDifferentItems(prevProps.items, this.props.items)) { + this.setState((state) => { + return removeOldSelectedState(state, prevProps.items); + }); + } + } + + // + // Control + + getSelectedIds = () => { + const selectedIds = getSelectedIds(this.state.selectedState); + + return selectedIds.reduce((acc, id) => { + const matchingItem = this.props.items.find((item) => item.id === id); + + if (matchingItem && !acc.includes(matchingItem.trackFileId)) { + acc.push(matchingItem.trackFileId); + } + + return acc; + }, []); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onDeletePress = () => { + this.setState({ isConfirmDeleteModalOpen: true }); + } + + onConfirmDelete = () => { + this.setState({ isConfirmDeleteModalOpen: false }); + this.props.onDeletePress(this.getSelectedIds()); + } + + onConfirmDeleteModalClose = () => { + this.setState({ isConfirmDeleteModalOpen: false }); + } + + onQualityChange = ({ value }) => { + const selectedIds = this.getSelectedIds(); + + if (!selectedIds.length) { + return; + } + + this.props.onQualityChange(selectedIds, parseInt(value)); + } + + // + // Render + + render() { + const { + isDeleting, + isFetching, + isPopulated, + error, + items, + qualities, + onModalClose + } = this.props; + + const { + allSelected, + allUnselected, + selectedState, + isConfirmDeleteModalOpen + } = this.state; + + const qualityOptions = _.reduceRight(qualities, (acc, quality) => { + acc.push({ + key: quality.id, + value: quality.name + }); + + return acc; + }, [{ key: 'selectQuality', value: 'Select Quality', disabled: true }]); + + const hasSelectedFiles = this.getSelectedIds().length > 0; + + return ( + + + Manage Tracks + + + + { + isFetching && !isPopulated ? + : + null + } + + { + !isFetching && error ? +
{error}
: + null + } + + { + isPopulated && !items.length ? +
+ No track files to manage. +
: + null + } + + { + isPopulated && items.length ? + + + { + items.map((item) => { + return ( + + ); + }) + } + +
: + null + } +
+ + +
+ + Delete + + +
+ +
+
+ + +
+ + +
+ ); + } +} + +TrackFileEditorModalContent.propTypes = { + isDeleting: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + qualities: PropTypes.arrayOf(PropTypes.object).isRequired, + onDeletePress: PropTypes.func.isRequired, + onQualityChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default TrackFileEditorModalContent; diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorModalContentConnector.js b/frontend/src/TrackFile/Editor/TrackFileEditorModalContentConnector.js new file mode 100644 index 000000000..bfd90a44b --- /dev/null +++ b/frontend/src/TrackFile/Editor/TrackFileEditorModalContentConnector.js @@ -0,0 +1,173 @@ +/* eslint max-params: 0 */ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import getQualities from 'Utilities/Quality/getQualities'; +import createArtistSelector from 'Store/Selectors/createArtistSelector'; +import { deleteTrackFiles, updateTrackFiles } from 'Store/Actions/trackFileActions'; +import { fetchTracks, clearTracks } from 'Store/Actions/trackActions'; +import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions'; +import TrackFileEditorModalContent from './TrackFileEditorModalContent'; + +function createSchemaSelector() { + return createSelector( + (state) => state.settings.qualityProfiles, + (qualityProfiles) => { + const qualities = getQualities(qualityProfiles.schema.items); + + let error = null; + + if (qualityProfiles.schemaError) { + error = 'Unable to load qualities'; + } + + return { + isFetching: qualityProfiles.isSchemaFetching, + isPopulated: qualityProfiles.isSchemaPopulated, + error, + qualities + }; + } + ); +} + +function createMapStateToProps() { + return createSelector( + (state, { albumId }) => albumId, + (state) => state.tracks, + (state) => state.trackFiles, + createSchemaSelector(), + createArtistSelector(), + ( + albumId, + tracks, + trackFiles, + schema, + artist + ) => { + const filtered = _.filter(tracks.items, (track) => { + if (albumId >= 0 && track.albumId !== albumId) { + return false; + } + + if (!track.trackFileId) { + return false; + } + + return _.some(trackFiles.items, { id: track.trackFileId }); + }); + + const sorted = _.orderBy(filtered, ['albumId', 'absoluteTrackNumber'], ['desc', 'asc']); + + const items = _.map(sorted, (track) => { + const trackFile = _.find(trackFiles.items, { id: track.trackFileId }); + + return { + relativePath: trackFile.relativePath, + quality: trackFile.quality, + ...track + }; + }); + + return { + ...schema, + items, + artistType: artist.artistType, + isDeleting: trackFiles.isDeleting, + isSaving: trackFiles.isSaving + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + dispatchClearTracks() { + dispatch(clearTracks()); + }, + + dispatchFetchTracks(updateProps) { + dispatch(fetchTracks(updateProps)); + }, + + dispatchFetchQualityProfileSchema(name, path) { + dispatch(fetchQualityProfileSchema()); + }, + + dispatchUpdateTrackFiles(updateProps) { + dispatch(updateTrackFiles(updateProps)); + }, + + onDeletePress(trackFileIds) { + dispatch(deleteTrackFiles({ trackFileIds })); + } + }; +} + +class TrackFileEditorModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const artistId = this.props.artistId; + const albumId = this.props.albumId; + + this.props.dispatchFetchTracks({ artistId, albumId }); + + this.props.dispatchFetchQualityProfileSchema(); + } + + componentWillUnmount() { + this.props.dispatchClearTracks(); + } + + // + // Listeners + + onQualityChange = (trackFileIds, qualityId) => { + const quality = { + quality: _.find(this.props.qualities, { id: qualityId }), + revision: { + version: 1, + real: 0 + } + }; + + this.props.dispatchUpdateTrackFiles({ trackFileIds, quality }); + } + + // + // Render + + render() { + const { + dispatchFetchQualityProfileSchema, + dispatchUpdateTrackFiles, + dispatchFetchTracks, + dispatchClearTracks, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +TrackFileEditorModalContentConnector.propTypes = { + artistId: PropTypes.number.isRequired, + albumId: PropTypes.number, + qualities: PropTypes.arrayOf(PropTypes.object).isRequired, + dispatchFetchTracks: PropTypes.func.isRequired, + dispatchClearTracks: PropTypes.func.isRequired, + dispatchFetchQualityProfileSchema: PropTypes.func.isRequired, + dispatchUpdateTrackFiles: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, createMapDispatchToProps)(TrackFileEditorModalContentConnector); diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorRow.js b/frontend/src/TrackFile/Editor/TrackFileEditorRow.js new file mode 100644 index 000000000..e475c115b --- /dev/null +++ b/frontend/src/TrackFile/Editor/TrackFileEditorRow.js @@ -0,0 +1,53 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import padNumber from 'Utilities/Number/padNumber'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import TrackQuality from 'Album/TrackQuality'; + +function TrackFileEditorRow(props) { + const { + id, + trackNumber, + relativePath, + quality, + isSelected, + onSelectedChange + } = props; + + return ( + + + + + {padNumber(trackNumber, 2)} + + + + {relativePath} + + + + + + + ); +} + +TrackFileEditorRow.propTypes = { + id: PropTypes.number.isRequired, + trackNumber: PropTypes.string.isRequired, + relativePath: PropTypes.string.isRequired, + quality: PropTypes.object.isRequired, + isSelected: PropTypes.bool, + onSelectedChange: PropTypes.func.isRequired +}; + +export default TrackFileEditorRow; diff --git a/frontend/src/TrackFile/ExpandingFileDetails.css b/frontend/src/TrackFile/ExpandingFileDetails.css new file mode 100644 index 000000000..d0bd945f8 --- /dev/null +++ b/frontend/src/TrackFile/ExpandingFileDetails.css @@ -0,0 +1,61 @@ +.fileDetails { + margin-bottom: 20px; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; + + &:last-of-type { + margin-bottom: 0; + } +} + +.filename { + flex-grow: 1; + margin-right: 10px; + margin-left: 10px; + font-size: 14px; + font-family: $monoSpaceFontFamily; +} + +.header { + position: relative; + display: flex; + align-items: center; + width: 100%; + font-size: 18px; +} + +.expandButton { + position: relative; + width: 60px; + height: 60px; +} + +.actionButton { + composes: button from '~Components/Link/IconButton.css'; + + width: 30px; +} + +.expandButtonIcon { + composes: actionButton; + + position: absolute; + top: 50%; + left: 50%; + margin-top: -12px; + margin-left: -15px; +} + +@media only screen and (max-width: $breakpointSmall) { + .medium { + border-right: 0; + border-left: 0; + border-radius: 0; + } + + .expandButtonIcon { + position: static; + margin: 0; + } +} diff --git a/frontend/src/TrackFile/ExpandingFileDetails.js b/frontend/src/TrackFile/ExpandingFileDetails.js new file mode 100644 index 000000000..aa1a9ab91 --- /dev/null +++ b/frontend/src/TrackFile/ExpandingFileDetails.js @@ -0,0 +1,83 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import FileDetails from './FileDetails'; +import styles from './ExpandingFileDetails.css'; + +class ExpandingFileDetails extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isExpanded: props.isExpanded + }; + } + + // + // Listeners + + onExpandPress = () => { + const { + isExpanded + } = this.state; + this.setState({ isExpanded: !isExpanded }); + } + + // + // Render + + render() { + const { + filename, + audioTags, + rejections + } = this.props; + + const { + isExpanded + } = this.state; + + return ( +
+
+
+ {filename} +
+ +
+ +
+
+ + { + isExpanded && + + } +
+ ); + } +} + +ExpandingFileDetails.propTypes = { + audioTags: PropTypes.object.isRequired, + filename: PropTypes.string.isRequired, + rejections: PropTypes.arrayOf(PropTypes.object), + isExpanded: PropTypes.bool +}; + +export default ExpandingFileDetails; diff --git a/frontend/src/TrackFile/FileDetails.css b/frontend/src/TrackFile/FileDetails.css new file mode 100644 index 000000000..f3e51ea39 --- /dev/null +++ b/frontend/src/TrackFile/FileDetails.css @@ -0,0 +1,11 @@ +.audioTags { + padding-top: 15px; + padding-bottom: 15px; + /* border-top: 1px solid $borderColor; */ +} + +.filename { + composes: description from '~Components/DescriptionList/DescriptionListItemDescription.css'; + + font-family: $monoSpaceFontFamily; +} diff --git a/frontend/src/TrackFile/FileDetails.js b/frontend/src/TrackFile/FileDetails.js new file mode 100644 index 000000000..725a1f0a4 --- /dev/null +++ b/frontend/src/TrackFile/FileDetails.js @@ -0,0 +1,206 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Fragment } from 'react'; +import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; +import Link from 'Components/Link/Link'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle'; +import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription'; +import styles from './FileDetails.css'; + +function renderRejections(rejections) { + return ( + + + Rejections + + { + _.map(rejections, (item, key) => { + return ( + + {item.reason} + + ); + }) + } + + ); +} + +function FileDetails(props) { + + const { + filename, + audioTags, + rejections + } = props; + + return ( + +
+ + { + filename && + + } + { + audioTags.title !== undefined && + + } + { + audioTags.trackNumbers[0] > 0 && + + } + { + audioTags.discNumber > 0 && + + } + { + audioTags.discCount > 0 && + + } + { + audioTags.albumTitle !== undefined && + + } + { + audioTags.artistTitle !== undefined && + + } + { + audioTags.country !== undefined && + + } + { + audioTags.year > 0 && + + } + { + audioTags.label !== undefined && + + } + { + audioTags.catalogNumber !== undefined && + + } + { + audioTags.disambiguation !== undefined && + + } + { + audioTags.duration !== undefined && + + } + { + audioTags.artistMBId !== undefined && + + + + } + { + audioTags.albumMBId !== undefined && + + + + } + { + audioTags.releaseMBId !== undefined && + + + + } + { + audioTags.recordingMBId !== undefined && + + + + } + { + audioTags.trackMBId !== undefined && + + + + } + { + !!rejections && rejections.length > 0 && + renderRejections(rejections) + } + +
+
+ ); +} + +FileDetails.propTypes = { + filename: PropTypes.string, + audioTags: PropTypes.object.isRequired, + rejections: PropTypes.arrayOf(PropTypes.object) +}; + +export default FileDetails; diff --git a/frontend/src/TrackFile/FileDetailsConnector.js b/frontend/src/TrackFile/FileDetailsConnector.js new file mode 100644 index 000000000..f52dbcacd --- /dev/null +++ b/frontend/src/TrackFile/FileDetailsConnector.js @@ -0,0 +1,77 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import { fetchTrackFiles } from 'Store/Actions/trackFileActions'; +import FileDetails from './FileDetails'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; + +function createMapStateToProps() { + return createSelector( + (state) => state.trackFiles, + (trackFiles) => { + return { + ...trackFiles + }; + } + ); +} + +const mapDispatchToProps = { + fetchTrackFiles +}; + +class FileDetailsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchTrackFiles({ id: this.props.id }); + } + + // + // Render + + render() { + const { + items, + id, + isFetching, + error + } = this.props; + + const item = _.find(items, { id }); + const errorMessage = getErrorMessage(error, 'Unable to load manual import items'); + + if (isFetching || !item.audioTags) { + return ( + + ); + } else if (error) { + return ( +
{errorMessage}
+ ); + } + + return ( + + ); + + } +} + +FileDetailsConnector.propTypes = { + fetchTrackFiles: PropTypes.func.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + id: PropTypes.number.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(FileDetailsConnector); diff --git a/frontend/src/TrackFile/FileDetailsModal.js b/frontend/src/TrackFile/FileDetailsModal.js new file mode 100644 index 000000000..e9677b647 --- /dev/null +++ b/frontend/src/TrackFile/FileDetailsModal.js @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import FileDetailsConnector from './FileDetailsConnector'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +function FileDetailsModal(props) { + const { + isOpen, + onModalClose, + id + } = props; + + return ( + + + + Details + + + + + + + + + + + + ); +} + +FileDetailsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired, + id: PropTypes.number.isRequired +}; + +export default FileDetailsModal; diff --git a/frontend/src/TrackFile/MediaInfo.js b/frontend/src/TrackFile/MediaInfo.js new file mode 100644 index 000000000..3f50fb70e --- /dev/null +++ b/frontend/src/TrackFile/MediaInfo.js @@ -0,0 +1,78 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import * as mediaInfoTypes from './mediaInfoTypes'; + +function MediaInfo(props) { + const { + type, + audioChannels, + audioCodec, + audioBitRate, + audioBits, + audioSampleRate + } = props; + + if (type === mediaInfoTypes.AUDIO) { + return ( + + { + !!audioCodec && + audioCodec + } + + { + !!audioCodec && !!audioChannels && + ' - ' + } + + { + !!audioChannels && + audioChannels.toFixed(1) + } + + { + ((!!audioCodec && !!audioBitRate) || (!!audioChannels && !!audioBitRate)) && + ' - ' + } + + { + !!audioBitRate && + audioBitRate + } + + { + ((!!audioCodec && !!audioSampleRate) || (!!audioChannels && !!audioSampleRate) || (!!audioBitRate && !!audioSampleRate)) && + ' - ' + } + + { + !!audioSampleRate && + audioSampleRate + } + + { + ((!!audioCodec && !!audioBits) || (!!audioChannels && !!audioBits) || (!!audioBitRate && !!audioBits) || (!!audioSampleRate && !!audioBits)) && + ' - ' + } + + { + !!audioBits && + audioBits + } + + ); + } + + return null; +} + +MediaInfo.propTypes = { + type: PropTypes.string.isRequired, + audioChannels: PropTypes.number, + audioCodec: PropTypes.string, + audioBitRate: PropTypes.string, + audioBits: PropTypes.string, + audioSampleRate: PropTypes.string +}; + +export default MediaInfo; diff --git a/frontend/src/TrackFile/MediaInfoConnector.js b/frontend/src/TrackFile/MediaInfoConnector.js new file mode 100644 index 000000000..5f3a1386b --- /dev/null +++ b/frontend/src/TrackFile/MediaInfoConnector.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createTrackFileSelector from 'Store/Selectors/createTrackFileSelector'; +import MediaInfo from './MediaInfo'; + +function createMapStateToProps() { + return createSelector( + createTrackFileSelector(), + (trackFile) => { + if (trackFile) { + return { + ...trackFile.mediaInfo + }; + } + + return {}; + } + ); +} + +export default connect(createMapStateToProps)(MediaInfo); diff --git a/frontend/src/TrackFile/mediaInfoTypes.js b/frontend/src/TrackFile/mediaInfoTypes.js new file mode 100644 index 000000000..5e5a78e64 --- /dev/null +++ b/frontend/src/TrackFile/mediaInfoTypes.js @@ -0,0 +1,2 @@ +export const AUDIO = 'audio'; +export const VIDEO = 'video'; diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTable.js b/frontend/src/UnmappedFiles/UnmappedFilesTable.js new file mode 100644 index 000000000..e6a02fee6 --- /dev/null +++ b/frontend/src/UnmappedFiles/UnmappedFilesTable.js @@ -0,0 +1,161 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { align, icons, sortDirections } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import VirtualTable from 'Components/Table/VirtualTable'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import UnmappedFilesTableRow from './UnmappedFilesTableRow'; +import UnmappedFilesTableHeader from './UnmappedFilesTableHeader'; + +class UnmappedFilesTable extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + contentBody: null, + scrollTop: 0 + }; + } + + // + // Control + + setContentBodyRef = (ref) => { + this.setState({ contentBody: ref }); + } + + rowRenderer = ({ key, rowIndex, style }) => { + const { + items, + columns, + deleteUnmappedFile + } = this.props; + + const item = items[rowIndex]; + + return ( + + ); + } + + // + // Listeners + + onScroll = ({ scrollTop }) => { + this.setState({ scrollTop }); + } + + render() { + + const { + isFetching, + isPopulated, + error, + items, + columns, + sortKey, + sortDirection, + onTableOptionChange, + onSortPress, + deleteUnmappedFile, + ...otherProps + } = this.props; + + const { + scrollTop, + contentBody + } = this.state; + + return ( + + + + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + isPopulated && !error && !items.length && +
+ Success! My work is done, all files on disk are matched to known tracks. +
+ } + + { + isPopulated && !error && !!items.length && contentBody && + + } + sortKey={sortKey} + sortDirection={sortDirection} + /> + } +
+
+ ); + } +} + +UnmappedFilesTable.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + onTableOptionChange: PropTypes.func.isRequired, + onSortPress: PropTypes.func.isRequired, + deleteUnmappedFile: PropTypes.func.isRequired +}; + +export default UnmappedFilesTable; diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTableConnector.js b/frontend/src/UnmappedFiles/UnmappedFilesTableConnector.js new file mode 100644 index 000000000..2f9dffd63 --- /dev/null +++ b/frontend/src/UnmappedFiles/UnmappedFilesTableConnector.js @@ -0,0 +1,100 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import { fetchTrackFiles, deleteTrackFile, setTrackFilesSort, setTrackFilesTableOption } from 'Store/Actions/trackFileActions'; +import withCurrentPage from 'Components/withCurrentPage'; +import UnmappedFilesTable from './UnmappedFilesTable'; + +function createMapStateToProps() { + return createSelector( + createClientSideCollectionSelector('trackFiles'), + createDimensionsSelector(), + ( + trackFiles, + dimensionsState + ) => { + // trackFiles could pick up mapped entries via signalR so filter again here + const { + items, + ...otherProps + } = trackFiles; + const unmappedFiles = _.filter(items, { albumId: 0 }); + return { + items: unmappedFiles, + ...otherProps, + isSmallScreen: dimensionsState.isSmallScreen + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onTableOptionChange(payload) { + dispatch(setTrackFilesTableOption(payload)); + }, + + onSortPress(sortKey) { + dispatch(setTrackFilesSort({ sortKey })); + }, + + fetchUnmappedFiles() { + dispatch(fetchTrackFiles({ unmapped: true })); + }, + + deleteUnmappedFile(id) { + dispatch(deleteTrackFile({ id })); + } + }; +} + +class UnmappedFilesTableConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + registerPagePopulator(this.repopulate, ['trackFileUpdated']); + + this.repopulate(); + } + + componentWillUnmount() { + unregisterPagePopulator(this.repopulate); + } + + // + // Control + + repopulate = () => { + this.props.fetchUnmappedFiles(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +UnmappedFilesTableConnector.propTypes = { + isSmallScreen: PropTypes.bool.isRequired, + onSortPress: PropTypes.func.isRequired, + onTableOptionChange: PropTypes.func.isRequired, + fetchUnmappedFiles: PropTypes.func.isRequired, + deleteUnmappedFile: PropTypes.func.isRequired +}; + +export default withCurrentPage( + connect(createMapStateToProps, createMapDispatchToProps)(UnmappedFilesTableConnector) +); diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTableHeader.css b/frontend/src/UnmappedFiles/UnmappedFilesTableHeader.css new file mode 100644 index 000000000..cd8c47183 --- /dev/null +++ b/frontend/src/UnmappedFiles/UnmappedFilesTableHeader.css @@ -0,0 +1,19 @@ +.quality, +.size, +.dateAdded { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 120px; +} + +.path { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 4 0 400px; +} + +.actions { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 1 90px; +} diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTableHeader.js b/frontend/src/UnmappedFiles/UnmappedFilesTableHeader.js new file mode 100644 index 000000000..0d5701c9c --- /dev/null +++ b/frontend/src/UnmappedFiles/UnmappedFilesTableHeader.js @@ -0,0 +1,77 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import VirtualTableHeader from 'Components/Table/VirtualTableHeader'; +import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +// import hasGrowableColumns from './hasGrowableColumns'; +import styles from './UnmappedFilesTableHeader.css'; + +function UnmappedFilesTableHeader(props) { + const { + columns, + onTableOptionChange, + ...otherProps + } = props; + + return ( + + { + columns.map((column) => { + const { + name, + label, + isSortable, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'actions') { + return ( + + + + + + + ); + } + + return ( + + {label} + + ); + }) + } + + ); +} + +UnmappedFilesTableHeader.propTypes = { + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + onTableOptionChange: PropTypes.func.isRequired +}; + +export default UnmappedFilesTableHeader; diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTableRow.css b/frontend/src/UnmappedFiles/UnmappedFilesTableRow.css new file mode 100644 index 000000000..f82ac9889 --- /dev/null +++ b/frontend/src/UnmappedFiles/UnmappedFilesTableRow.css @@ -0,0 +1,22 @@ +.path { + composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 4 0 400px; + font-size: 13px; + font-family: $monoSpaceFontFamily; +} + +.quality, +.dateAdded, +.size { + composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 0 120px; + white-space: nowrap; +} + +.actions { + composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 0 90px; +} diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTableRow.js b/frontend/src/UnmappedFiles/UnmappedFilesTableRow.js new file mode 100644 index 000000000..6806f5468 --- /dev/null +++ b/frontend/src/UnmappedFiles/UnmappedFilesTableRow.js @@ -0,0 +1,218 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import formatBytes from 'Utilities/Number/formatBytes'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import VirtualTableRow from 'Components/Table/VirtualTableRow'; +import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; +import TrackQuality from 'Album/TrackQuality'; +import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; +import FileDetailsModal from 'TrackFile/FileDetailsModal'; +import styles from './UnmappedFilesTableRow.css'; + +class UnmappedFilesTableRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false, + isInteractiveImportModalOpen: false, + isConfirmDeleteModalOpen: false + }; + } + + // + // Listeners + + onDetailsPress = () => { + this.setState({ isDetailsModalOpen: true }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + onInteractiveImportPress = () => { + this.setState({ isInteractiveImportModalOpen: true }); + } + + onInteractiveImportModalClose = () => { + this.setState({ isInteractiveImportModalOpen: false }); + } + + onDeleteFilePress = () => { + this.setState({ isConfirmDeleteModalOpen: true }); + } + + onConfirmDelete = () => { + this.setState({ isConfirmDeleteModalOpen: false }); + this.props.deleteUnmappedFile(this.props.id); + } + + onConfirmDeleteModalClose = () => { + this.setState({ isConfirmDeleteModalOpen: false }); + } + + // + // Render + + render() { + const { + style, + id, + path, + size, + dateAdded, + quality, + columns + } = this.props; + + const folder = path.substring(0, Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))); + + const { + isInteractiveImportModalOpen, + isDetailsModalOpen, + isConfirmDeleteModalOpen + } = this.state; + + return ( + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'path') { + return ( + + {path} + + ); + } + + if (name === 'size') { + return ( + + {formatBytes(size)} + + ); + } + + if (name === 'dateAdded') { + return ( + + ); + } + + if (name === 'quality') { + return ( + + + + ); + } + + if (name === 'actions') { + return ( + + + + + + + + + ); + } + + return null; + }) + } + + + + + + + + + ); + } + +} + +UnmappedFilesTableRow.propTypes = { + style: PropTypes.object.isRequired, + id: PropTypes.number.isRequired, + path: PropTypes.string.isRequired, + size: PropTypes.number.isRequired, + quality: PropTypes.object.isRequired, + dateAdded: PropTypes.string.isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + deleteUnmappedFile: PropTypes.func.isRequired +}; + +export default UnmappedFilesTableRow; diff --git a/frontend/src/Utilities/Album/updateAlbums.js b/frontend/src/Utilities/Album/updateAlbums.js new file mode 100644 index 000000000..259ef510e --- /dev/null +++ b/frontend/src/Utilities/Album/updateAlbums.js @@ -0,0 +1,21 @@ +import _ from 'lodash'; +import { update } from 'Store/Actions/baseActions'; + +function updateAlbums(section, albums, albumIds, options) { + const data = _.reduce(albums, (result, item) => { + if (albumIds.indexOf(item.id) > -1) { + result.push({ + ...item, + ...options + }); + } else { + result.push(item); + } + + return result; + }, []); + + return update({ section, data }); +} + +export default updateAlbums; diff --git a/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js b/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js new file mode 100644 index 000000000..d27cfd604 --- /dev/null +++ b/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js @@ -0,0 +1,13 @@ +import _ from 'lodash'; + +export default function getIndexOfFirstCharacter(items, character) { + return _.findIndex(items, (item) => { + const firstCharacter = item.sortName.charAt(0); + + if (character === '#') { + return !isNaN(firstCharacter); + } + + return firstCharacter === character; + }); +} diff --git a/frontend/src/Utilities/Array/sortByName.js b/frontend/src/Utilities/Array/sortByName.js new file mode 100644 index 000000000..1956d3bac --- /dev/null +++ b/frontend/src/Utilities/Array/sortByName.js @@ -0,0 +1,5 @@ +function sortByName(a, b) { + return a.name.localeCompare(b.name); +} + +export default sortByName; diff --git a/frontend/src/Utilities/Artist/getNewArtist.js b/frontend/src/Utilities/Artist/getNewArtist.js new file mode 100644 index 000000000..d39855f04 --- /dev/null +++ b/frontend/src/Utilities/Artist/getNewArtist.js @@ -0,0 +1,31 @@ + +function getNewArtist(artist, payload) { + const { + rootFolderPath, + monitor, + qualityProfileId, + metadataProfileId, + artistType, + albumFolder, + tags, + searchForMissingAlbums = false + } = payload; + + const addOptions = { + monitor, + searchForMissingAlbums + }; + + artist.addOptions = addOptions; + artist.monitored = true; + artist.qualityProfileId = qualityProfileId; + artist.metadataProfileId = metadataProfileId; + artist.rootFolderPath = rootFolderPath; + artist.artistType = artistType; + artist.albumFolder = albumFolder; + artist.tags = tags; + + return artist; +} + +export default getNewArtist; diff --git a/frontend/src/Utilities/Artist/getProgressBarKind.js b/frontend/src/Utilities/Artist/getProgressBarKind.js new file mode 100644 index 000000000..eb3b2dd6e --- /dev/null +++ b/frontend/src/Utilities/Artist/getProgressBarKind.js @@ -0,0 +1,15 @@ +import { kinds } from 'Helpers/Props'; + +function getProgressBarKind(status, monitored, progress) { + if (progress === 100) { + return status === 'ended' ? kinds.SUCCESS : kinds.PRIMARY; + } + + if (monitored) { + return kinds.DANGER; + } + + return kinds.WARNING; +} + +export default getProgressBarKind; diff --git a/frontend/src/Utilities/Artist/monitorOptions.js b/frontend/src/Utilities/Artist/monitorOptions.js new file mode 100644 index 000000000..b5e942ae6 --- /dev/null +++ b/frontend/src/Utilities/Artist/monitorOptions.js @@ -0,0 +1,11 @@ +const monitorOptions = [ + { key: 'all', value: 'All Albums' }, + { key: 'future', value: 'Future Albums' }, + { key: 'missing', value: 'Missing Albums' }, + { key: 'existing', value: 'Existing Albums' }, + { key: 'first', value: 'Only First Album' }, + { key: 'latest', value: 'Only Latest Album' }, + { key: 'none', value: 'None' } +]; + +export default monitorOptions; diff --git a/frontend/src/Utilities/Command/findCommand.js b/frontend/src/Utilities/Command/findCommand.js new file mode 100644 index 000000000..cf7d5444a --- /dev/null +++ b/frontend/src/Utilities/Command/findCommand.js @@ -0,0 +1,10 @@ +import _ from 'lodash'; +import isSameCommand from './isSameCommand'; + +function findCommand(commands, options) { + return _.findLast(commands, (command) => { + return isSameCommand(command.body, options); + }); +} + +export default findCommand; diff --git a/frontend/src/Utilities/Command/index.js b/frontend/src/Utilities/Command/index.js new file mode 100644 index 000000000..66043bf03 --- /dev/null +++ b/frontend/src/Utilities/Command/index.js @@ -0,0 +1,5 @@ +export { default as findCommand } from './findCommand'; +export { default as isCommandComplete } from './isCommandComplete'; +export { default as isCommandExecuting } from './isCommandExecuting'; +export { default as isCommandFailed } from './isCommandFailed'; +export { default as isSameCommand } from './isSameCommand'; diff --git a/frontend/src/Utilities/Command/isCommandComplete.js b/frontend/src/Utilities/Command/isCommandComplete.js new file mode 100644 index 000000000..558ab801b --- /dev/null +++ b/frontend/src/Utilities/Command/isCommandComplete.js @@ -0,0 +1,9 @@ +function isCommandComplete(command) { + if (!command) { + return false; + } + + return command.status === 'complete'; +} + +export default isCommandComplete; diff --git a/frontend/src/Utilities/Command/isCommandExecuting.js b/frontend/src/Utilities/Command/isCommandExecuting.js new file mode 100644 index 000000000..8e637704e --- /dev/null +++ b/frontend/src/Utilities/Command/isCommandExecuting.js @@ -0,0 +1,9 @@ +function isCommandExecuting(command) { + if (!command) { + return false; + } + + return command.status === 'queued' || command.status === 'started'; +} + +export default isCommandExecuting; diff --git a/frontend/src/Utilities/Command/isCommandFailed.js b/frontend/src/Utilities/Command/isCommandFailed.js new file mode 100644 index 000000000..00e5ccdf2 --- /dev/null +++ b/frontend/src/Utilities/Command/isCommandFailed.js @@ -0,0 +1,12 @@ +function isCommandFailed(command) { + if (!command) { + return false; + } + + return command.status === 'failed' || + command.status === 'aborted' || + command.status === 'cancelled' || + command.status === 'orphaned'; +} + +export default isCommandFailed; diff --git a/frontend/src/Utilities/Command/isSameCommand.js b/frontend/src/Utilities/Command/isSameCommand.js new file mode 100644 index 000000000..d0acb24b5 --- /dev/null +++ b/frontend/src/Utilities/Command/isSameCommand.js @@ -0,0 +1,24 @@ +import _ from 'lodash'; + +function isSameCommand(commandA, commandB) { + if (commandA.name.toLocaleLowerCase() !== commandB.name.toLocaleLowerCase()) { + return false; + } + + for (const key in commandB) { + if (key !== 'name') { + const value = commandB[key]; + if (Array.isArray(value)) { + if (_.difference(value, commandA[key]).length > 0) { + return false; + } + } else if (value !== commandA[key]) { + return false; + } + } + } + + return true; +} + +export default isSameCommand; diff --git a/frontend/src/Utilities/Constants/keyCodes.js b/frontend/src/Utilities/Constants/keyCodes.js new file mode 100644 index 000000000..9285b10fe --- /dev/null +++ b/frontend/src/Utilities/Constants/keyCodes.js @@ -0,0 +1,7 @@ +export const TAB = 9; +export const ENTER = 13; +export const SHIFT = 16; +export const CONTROL = 17; +export const ESCAPE = 27; +export const UP_ARROW = 38; +export const DOWN_ARROW = 40; diff --git a/frontend/src/Utilities/Date/dateFilterPredicate.js b/frontend/src/Utilities/Date/dateFilterPredicate.js new file mode 100644 index 000000000..2c74f435a --- /dev/null +++ b/frontend/src/Utilities/Date/dateFilterPredicate.js @@ -0,0 +1,33 @@ +import moment from 'moment'; +import isAfter from 'Utilities/Date/isAfter'; +import isBefore from 'Utilities/Date/isBefore'; +import * as filterTypes from 'Helpers/Props/filterTypes'; + +export default function(itemValue, filterValue, type) { + if (!itemValue) { + return false; + } + + switch (type) { + case filterTypes.LESS_THAN: + return moment(itemValue).isBefore(filterValue); + + case filterTypes.GREATER_THAN: + return moment(itemValue).isAfter(filterValue); + + case filterTypes.IN_LAST: + return ( + isAfter(itemValue, { [filterValue.time]: filterValue.value * -1 }) && + isBefore(itemValue) + ); + + case filterTypes.IN_NEXT: + return ( + isAfter(itemValue) && + isBefore(itemValue, { [filterValue.time]: filterValue.value }) + ); + + default: + return false; + } +} diff --git a/frontend/src/Utilities/Date/formatDate.js b/frontend/src/Utilities/Date/formatDate.js new file mode 100644 index 000000000..92eb57840 --- /dev/null +++ b/frontend/src/Utilities/Date/formatDate.js @@ -0,0 +1,11 @@ +import moment from 'moment'; + +function formatDate(date, dateFormat) { + if (!date) { + return ''; + } + + return moment(date).format(dateFormat); +} + +export default formatDate; diff --git a/frontend/src/Utilities/Date/formatDateTime.js b/frontend/src/Utilities/Date/formatDateTime.js new file mode 100644 index 000000000..f36f4f3e0 --- /dev/null +++ b/frontend/src/Utilities/Date/formatDateTime.js @@ -0,0 +1,39 @@ +import moment from 'moment'; +import formatTime from './formatTime'; +import isToday from './isToday'; +import isTomorrow from './isTomorrow'; +import isYesterday from './isYesterday'; + +function getRelativeDay(date, includeRelativeDate) { + if (!includeRelativeDate) { + return ''; + } + + if (isYesterday(date)) { + return 'Yesterday, '; + } + + if (isToday(date)) { + return 'Today, '; + } + + if (isTomorrow(date)) { + return 'Tomorrow, '; + } + + return ''; +} + +function formatDateTime(date, dateFormat, timeFormat, { includeSeconds = false, includeRelativeDay = false } = {}) { + if (!date) { + return ''; + } + + const relativeDay = getRelativeDay(date, includeRelativeDay); + const formattedDate = moment(date).format(dateFormat); + const formattedTime = formatTime(date, timeFormat, { includeMinuteZero: true, includeSeconds }); + + return `${relativeDay}${formattedDate} ${formattedTime}`; +} + +export default formatDateTime; diff --git a/frontend/src/Utilities/Date/formatTime.js b/frontend/src/Utilities/Date/formatTime.js new file mode 100644 index 000000000..89c908d1f --- /dev/null +++ b/frontend/src/Utilities/Date/formatTime.js @@ -0,0 +1,19 @@ +import moment from 'moment'; + +function formatTime(date, timeFormat, { includeMinuteZero = false, includeSeconds = false } = {}) { + if (!date) { + return ''; + } + + if (includeSeconds) { + timeFormat = timeFormat.replace(/\(?:mm\)?/, ':mm:ss'); + } else if (includeMinuteZero) { + timeFormat = timeFormat.replace('(:mm)', ':mm'); + } else { + timeFormat = timeFormat.replace('(:mm)', ''); + } + + return moment(date).format(timeFormat); +} + +export default formatTime; diff --git a/frontend/src/Utilities/Date/formatTimeSpan.js b/frontend/src/Utilities/Date/formatTimeSpan.js new file mode 100644 index 000000000..ef1a278e5 --- /dev/null +++ b/frontend/src/Utilities/Date/formatTimeSpan.js @@ -0,0 +1,24 @@ +import moment from 'moment'; +import padNumber from 'Utilities/Number/padNumber'; + +function formatTimeSpan(timeSpan) { + if (!timeSpan) { + return ''; + } + + const duration = moment.duration(timeSpan); + const days = duration.get('days'); + const hours = padNumber(duration.get('hours'), 2); + const minutes = padNumber(duration.get('minutes'), 2); + const seconds = padNumber(duration.get('seconds'), 2); + + const time = `${hours}:${minutes}:${seconds}`; + + if (days > 0) { + return `${days}d ${time}`; + } + + return time; +} + +export default formatTimeSpan; diff --git a/frontend/src/Utilities/Date/getRelativeDate.js b/frontend/src/Utilities/Date/getRelativeDate.js new file mode 100644 index 000000000..0a60135ce --- /dev/null +++ b/frontend/src/Utilities/Date/getRelativeDate.js @@ -0,0 +1,42 @@ +import moment from 'moment'; +import formatTime from 'Utilities/Date/formatTime'; +import isInNextWeek from 'Utilities/Date/isInNextWeek'; +import isToday from 'Utilities/Date/isToday'; +import isTomorrow from 'Utilities/Date/isTomorrow'; +import isYesterday from 'Utilities/Date/isYesterday'; + +function getRelativeDate(date, shortDateFormat, showRelativeDates, { timeFormat, includeSeconds = false, timeForToday = false } = {}) { + if (!date) { + return null; + } + + const isTodayDate = isToday(date); + + if (isTodayDate && timeForToday && timeFormat) { + return formatTime(date, timeFormat, { includeMinuteZero: true, includeSeconds }); + } + + if (!showRelativeDates) { + return moment(date).format(shortDateFormat); + } + + if (isYesterday(date)) { + return 'Yesterday'; + } + + if (isTodayDate) { + return 'Today'; + } + + if (isTomorrow(date)) { + return 'Tomorrow'; + } + + if (isInNextWeek(date)) { + return moment(date).format('dddd'); + } + + return moment(date).format(shortDateFormat); +} + +export default getRelativeDate; diff --git a/frontend/src/Utilities/Date/isAfter.js b/frontend/src/Utilities/Date/isAfter.js new file mode 100644 index 000000000..4bbd8660b --- /dev/null +++ b/frontend/src/Utilities/Date/isAfter.js @@ -0,0 +1,17 @@ +import moment from 'moment'; + +function isAfter(date, offsets = {}) { + if (!date) { + return false; + } + + const offsetTime = moment(); + + Object.keys(offsets).forEach((key) => { + offsetTime.add(offsets[key], key); + }); + + return moment(date).isAfter(offsetTime); +} + +export default isAfter; diff --git a/frontend/src/Utilities/Date/isBefore.js b/frontend/src/Utilities/Date/isBefore.js new file mode 100644 index 000000000..3e1e81f67 --- /dev/null +++ b/frontend/src/Utilities/Date/isBefore.js @@ -0,0 +1,17 @@ +import moment from 'moment'; + +function isBefore(date, offsets = {}) { + if (!date) { + return false; + } + + const offsetTime = moment(); + + Object.keys(offsets).forEach((key) => { + offsetTime.add(offsets[key], key); + }); + + return moment(date).isBefore(offsetTime); +} + +export default isBefore; diff --git a/frontend/src/Utilities/Date/isInNextWeek.js b/frontend/src/Utilities/Date/isInNextWeek.js new file mode 100644 index 000000000..7b5fd7cc7 --- /dev/null +++ b/frontend/src/Utilities/Date/isInNextWeek.js @@ -0,0 +1,11 @@ +import moment from 'moment'; + +function isInNextWeek(date) { + if (!date) { + return false; + } + const now = moment(); + return moment(date).isBetween(now, now.clone().add(6, 'days').endOf('day')); +} + +export default isInNextWeek; diff --git a/frontend/src/Utilities/Date/isSameWeek.js b/frontend/src/Utilities/Date/isSameWeek.js new file mode 100644 index 000000000..14b76ffb7 --- /dev/null +++ b/frontend/src/Utilities/Date/isSameWeek.js @@ -0,0 +1,11 @@ +import moment from 'moment'; + +function isSameWeek(date) { + if (!date) { + return false; + } + + return moment(date).isSame(moment(), 'week'); +} + +export default isSameWeek; diff --git a/frontend/src/Utilities/Date/isToday.js b/frontend/src/Utilities/Date/isToday.js new file mode 100644 index 000000000..31502951f --- /dev/null +++ b/frontend/src/Utilities/Date/isToday.js @@ -0,0 +1,11 @@ +import moment from 'moment'; + +function isToday(date) { + if (!date) { + return false; + } + + return moment(date).isSame(moment(), 'day'); +} + +export default isToday; diff --git a/frontend/src/Utilities/Date/isTomorrow.js b/frontend/src/Utilities/Date/isTomorrow.js new file mode 100644 index 000000000..d22386dbd --- /dev/null +++ b/frontend/src/Utilities/Date/isTomorrow.js @@ -0,0 +1,11 @@ +import moment from 'moment'; + +function isTomorrow(date) { + if (!date) { + return false; + } + + return moment(date).isSame(moment().add(1, 'day'), 'day'); +} + +export default isTomorrow; diff --git a/frontend/src/Utilities/Date/isYesterday.js b/frontend/src/Utilities/Date/isYesterday.js new file mode 100644 index 000000000..9de21d82a --- /dev/null +++ b/frontend/src/Utilities/Date/isYesterday.js @@ -0,0 +1,11 @@ +import moment from 'moment'; + +function isYesterday(date) { + if (!date) { + return false; + } + + return moment(date).isSame(moment().subtract(1, 'day'), 'day'); +} + +export default isYesterday; diff --git a/frontend/src/Utilities/Filter/findSelectedFilters.js b/frontend/src/Utilities/Filter/findSelectedFilters.js new file mode 100644 index 000000000..1c104073c --- /dev/null +++ b/frontend/src/Utilities/Filter/findSelectedFilters.js @@ -0,0 +1,19 @@ +export default function findSelectedFilters(selectedFilterKey, filters = [], customFilters = []) { + if (!selectedFilterKey) { + return []; + } + + let selectedFilter = filters.find((f) => f.key === selectedFilterKey); + + if (!selectedFilter) { + selectedFilter = customFilters.find((f) => f.id === selectedFilterKey); + } + + if (!selectedFilter) { + // TODO: throw in dev + console.error('Matching filter not found'); + return []; + } + + return selectedFilter.filters; +} diff --git a/frontend/src/Utilities/Filter/getFilterValue.js b/frontend/src/Utilities/Filter/getFilterValue.js new file mode 100644 index 000000000..70b0b51f1 --- /dev/null +++ b/frontend/src/Utilities/Filter/getFilterValue.js @@ -0,0 +1,11 @@ +export default function getFilterValue(filters, filterKey, filterValueKey, defaultValue) { + const filter = filters.find((f) => f.key === filterKey); + + if (!filter) { + return defaultValue; + } + + const filterValue = filter.filters.find((f) => f.key === filterValueKey); + + return filterValue ? filterValue.value : defaultValue; +} diff --git a/frontend/src/Utilities/Number/convertToBytes.js b/frontend/src/Utilities/Number/convertToBytes.js new file mode 100644 index 000000000..88357944f --- /dev/null +++ b/frontend/src/Utilities/Number/convertToBytes.js @@ -0,0 +1,15 @@ +function convertToBytes(input, power, binaryPrefix) { + const size = Number(input); + + if (isNaN(size)) { + return ''; + } + + const prefix = binaryPrefix ? 1024 : 1000; + const multiplier = Math.pow(prefix, power); + const result = size * multiplier; + + return Math.round(result); +} + +export default convertToBytes; diff --git a/frontend/src/Utilities/Number/formatAge.js b/frontend/src/Utilities/Number/formatAge.js new file mode 100644 index 000000000..b8a4aacc5 --- /dev/null +++ b/frontend/src/Utilities/Number/formatAge.js @@ -0,0 +1,17 @@ +function formatAge(age, ageHours, ageMinutes) { + age = Math.round(age); + ageHours = parseFloat(ageHours); + ageMinutes = ageMinutes && parseFloat(ageMinutes); + + if (age < 2 && ageHours) { + if (ageHours < 2 && !!ageMinutes) { + return `${ageMinutes.toFixed(0)} ${ageHours === 1 ? 'minute' : 'minutes'}`; + } + + return `${ageHours.toFixed(1)} ${ageHours === 1 ? 'hour' : 'hours'}`; + } + + return `${age} ${age === 1 ? 'day' : 'days'}`; +} + +export default formatAge; diff --git a/frontend/src/Utilities/Number/formatBytes.js b/frontend/src/Utilities/Number/formatBytes.js new file mode 100644 index 000000000..7bae5367b --- /dev/null +++ b/frontend/src/Utilities/Number/formatBytes.js @@ -0,0 +1,17 @@ +import filesize from 'filesize'; + +function formatBytes(input, showBits = false) { + const size = Number(input); + + if (isNaN(size)) { + return ''; + } + + return filesize(size, { + base: 2, + round: 1, + bits: showBits + }); +} + +export default formatBytes; diff --git a/frontend/src/Utilities/Number/padNumber.js b/frontend/src/Utilities/Number/padNumber.js new file mode 100644 index 000000000..53ae69cac --- /dev/null +++ b/frontend/src/Utilities/Number/padNumber.js @@ -0,0 +1,10 @@ +function padNumber(input, width, paddingCharacter = 0) { + if (input == null) { + return ''; + } + + input = `${input}`; + return input.length >= width ? input : new Array(width - input.length + 1).join(paddingCharacter) + input; +} + +export default padNumber; diff --git a/frontend/src/Utilities/Number/roundNumber.js b/frontend/src/Utilities/Number/roundNumber.js new file mode 100644 index 000000000..e1a19018f --- /dev/null +++ b/frontend/src/Utilities/Number/roundNumber.js @@ -0,0 +1,5 @@ +export default function roundNumber(input, decimalPlaces = 1) { + const multiplier = Math.pow(10, decimalPlaces); + + return Math.round(input * multiplier) / multiplier; +} diff --git a/frontend/src/Utilities/Object/getErrorMessage.js b/frontend/src/Utilities/Object/getErrorMessage.js new file mode 100644 index 000000000..1ba874660 --- /dev/null +++ b/frontend/src/Utilities/Object/getErrorMessage.js @@ -0,0 +1,11 @@ +function getErrorMessage(xhr, fallbackErrorMessage) { + if (!xhr || !xhr.responseJSON || !xhr.responseJSON.message) { + return fallbackErrorMessage; + } + + const message = xhr.responseJSON.message; + + return message || fallbackErrorMessage; +} + +export default getErrorMessage; diff --git a/frontend/src/Utilities/Object/hasDifferentItems.js b/frontend/src/Utilities/Object/hasDifferentItems.js new file mode 100644 index 000000000..f89c99a10 --- /dev/null +++ b/frontend/src/Utilities/Object/hasDifferentItems.js @@ -0,0 +1,10 @@ +import _ from 'lodash'; + +function hasDifferentItems(prevItems, currentItems, idProp = 'id') { + const diff1 = _.differenceBy(prevItems, currentItems, (item) => item[idProp]); + const diff2 = _.differenceBy(currentItems, prevItems, (item) => item[idProp]); + + return diff1.length > 0 || diff2.length > 0; +} + +export default hasDifferentItems; diff --git a/frontend/src/Utilities/Object/selectUniqueIds.js b/frontend/src/Utilities/Object/selectUniqueIds.js new file mode 100644 index 000000000..c2c0c17e3 --- /dev/null +++ b/frontend/src/Utilities/Object/selectUniqueIds.js @@ -0,0 +1,15 @@ +import _ from 'lodash'; + +function selectUniqueIds(items, idProp) { + const ids = _.reduce(items, (result, item) => { + if (item[idProp]) { + result.push(item[idProp]); + } + + return result; + }, []); + + return _.uniq(ids); +} + +export default selectUniqueIds; diff --git a/frontend/src/Utilities/Quality/getQualities.js b/frontend/src/Utilities/Quality/getQualities.js new file mode 100644 index 000000000..da09851ea --- /dev/null +++ b/frontend/src/Utilities/Quality/getQualities.js @@ -0,0 +1,16 @@ +export default function getQualities(qualities) { + if (!qualities) { + return []; + } + + return qualities.reduce((acc, item) => { + if (item.quality) { + acc.push(item.quality); + } else { + const groupQualities = item.items.map((i) => i.quality); + acc.push(...groupQualities); + } + + return acc; + }, []); +} diff --git a/frontend/src/Utilities/ResolutionUtility.js b/frontend/src/Utilities/ResolutionUtility.js new file mode 100644 index 000000000..358448ca9 --- /dev/null +++ b/frontend/src/Utilities/ResolutionUtility.js @@ -0,0 +1,26 @@ +import $ from 'jquery'; + +module.exports = { + resolutions: { + desktopLarge: 1200, + desktop: 992, + tablet: 768, + mobile: 480 + }, + + isDesktopLarge() { + return $(window).width() < this.resolutions.desktopLarge; + }, + + isDesktop() { + return $(window).width() < this.resolutions.desktop; + }, + + isTablet() { + return $(window).width() < this.resolutions.tablet; + }, + + isMobile() { + return $(window).width() < this.resolutions.mobile; + } +}; diff --git a/frontend/src/Utilities/State/getProviderState.js b/frontend/src/Utilities/State/getProviderState.js new file mode 100644 index 000000000..60923a646 --- /dev/null +++ b/frontend/src/Utilities/State/getProviderState.js @@ -0,0 +1,42 @@ +import _ from 'lodash'; +import getSectionState from 'Utilities/State/getSectionState'; + +function getProviderState(payload, getState, section) { + const { + id, + ...otherPayload + } = payload; + + const state = getSectionState(getState(), section, true); + const pendingChanges = Object.assign({}, state.pendingChanges, otherPayload); + const pendingFields = state.pendingChanges.fields || {}; + delete pendingChanges.fields; + + const item = id ? _.find(state.items, { id }) : state.selectedSchema || state.schema || {}; + + if (item.fields) { + pendingChanges.fields = _.reduce(item.fields, (result, field) => { + const name = field.name; + + const value = pendingFields.hasOwnProperty(name) ? + pendingFields[name] : + field.value; + + // Only send the name and value to the server + result.push({ + name, + value + }); + + return result; + }, []); + } + + const result = Object.assign({}, item, pendingChanges); + + delete result.presets; + + return result; +} + +export default getProviderState; diff --git a/frontend/src/Utilities/State/getSectionState.js b/frontend/src/Utilities/State/getSectionState.js new file mode 100644 index 000000000..00871bed2 --- /dev/null +++ b/frontend/src/Utilities/State/getSectionState.js @@ -0,0 +1,22 @@ +import _ from 'lodash'; + +function getSectionState(state, section, isFullStateTree = false) { + if (isFullStateTree) { + return _.get(state, section); + } + + const [, subSection] = section.split('.'); + + if (subSection) { + return Object.assign({}, state[subSection]); + } + + // TODO: Remove in favour of using subSection + if (state.hasOwnProperty(section)) { + return Object.assign({}, state[section]); + } + + return Object.assign({}, state); +} + +export default getSectionState; diff --git a/frontend/src/Utilities/State/selectProviderSchema.js b/frontend/src/Utilities/State/selectProviderSchema.js new file mode 100644 index 000000000..c8a31760c --- /dev/null +++ b/frontend/src/Utilities/State/selectProviderSchema.js @@ -0,0 +1,34 @@ +import _ from 'lodash'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +function applySchemaDefaults(selectedSchema, schemaDefaults) { + if (!schemaDefaults) { + return selectedSchema; + } else if (_.isFunction(schemaDefaults)) { + return schemaDefaults(selectedSchema); + } + + return Object.assign(selectedSchema, schemaDefaults); +} + +function selectProviderSchema(state, section, payload, schemaDefaults) { + const newState = getSectionState(state, section); + + const { + implementation, + presetName + } = payload; + + const selectedImplementation = _.find(newState.schema, { implementation }); + + const selectedSchema = presetName ? + _.find(selectedImplementation.presets, { name: presetName }) : + selectedImplementation; + + newState.selectedSchema = applySchemaDefaults(_.cloneDeep(selectedSchema), schemaDefaults); + + return updateSectionState(state, section, newState); +} + +export default selectProviderSchema; diff --git a/frontend/src/Utilities/State/updateSectionState.js b/frontend/src/Utilities/State/updateSectionState.js new file mode 100644 index 000000000..81b33ecaf --- /dev/null +++ b/frontend/src/Utilities/State/updateSectionState.js @@ -0,0 +1,16 @@ +function updateSectionState(state, section, newState) { + const [, subSection] = section.split('.'); + + if (subSection) { + return Object.assign({}, state, { [subSection]: newState }); + } + + // TODO: Remove in favour of using subSection + if (state.hasOwnProperty(section)) { + return Object.assign({}, state, { [section]: newState }); + } + + return Object.assign({}, state, newState); +} + +export default updateSectionState; diff --git a/frontend/src/Utilities/String/combinePath.js b/frontend/src/Utilities/String/combinePath.js new file mode 100644 index 000000000..9e4e9abe8 --- /dev/null +++ b/frontend/src/Utilities/String/combinePath.js @@ -0,0 +1,5 @@ +export default function combinePath(isWindows, basePath, paths = []) { + const slash = isWindows ? '\\' : '/'; + + return `${basePath}${slash}${paths.join(slash)}`; +} diff --git a/frontend/src/Utilities/String/generateUUIDv4.js b/frontend/src/Utilities/String/generateUUIDv4.js new file mode 100644 index 000000000..51b15ec60 --- /dev/null +++ b/frontend/src/Utilities/String/generateUUIDv4.js @@ -0,0 +1,6 @@ +export default function generateUUIDv4() { + return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, (c) => + // eslint-disable-next-line no-bitwise + (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) + ); +} diff --git a/frontend/src/Utilities/String/isString.js b/frontend/src/Utilities/String/isString.js new file mode 100644 index 000000000..1e7c3dff8 --- /dev/null +++ b/frontend/src/Utilities/String/isString.js @@ -0,0 +1,3 @@ +export default function isString(possibleString) { + return typeof possibleString === 'string' || possibleString instanceof String; +} diff --git a/frontend/src/Utilities/String/parseUrl.js b/frontend/src/Utilities/String/parseUrl.js new file mode 100644 index 000000000..93341f85f --- /dev/null +++ b/frontend/src/Utilities/String/parseUrl.js @@ -0,0 +1,36 @@ +import _ from 'lodash'; +import qs from 'qs'; + +// See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils +const anchor = document.createElement('a'); + +export default function parseUrl(url) { + anchor.href = url; + + // The `origin`, `password`, and `username` properties are unavailable in + // Opera Presto. We synthesize `origin` if it's not present. While `password` + // and `username` are ignored intentionally. + const properties = _.pick( + anchor, + 'hash', + 'host', + 'hostname', + 'href', + 'origin', + 'pathname', + 'port', + 'protocol', + 'search' + ); + + properties.isAbsolute = (/^[\w:]*\/\//).test(url); + + if (properties.search) { + // Remove leading ? from querystring before parsing. + properties.params = qs.parse(properties.search.substring(1)); + } else { + properties.params = {}; + } + + return properties; +} diff --git a/frontend/src/Utilities/String/split.js b/frontend/src/Utilities/String/split.js new file mode 100644 index 000000000..0e57e7545 --- /dev/null +++ b/frontend/src/Utilities/String/split.js @@ -0,0 +1,17 @@ +import _ from 'lodash'; + +function split(input, separator = ',') { + if (!input) { + return []; + } + + return _.reduce(input.split(separator), (result, s) => { + if (s) { + result.push(s); + } + + return result; + }, []); +} + +export default split; diff --git a/frontend/src/Utilities/String/titleCase.js b/frontend/src/Utilities/String/titleCase.js new file mode 100644 index 000000000..5b76c10dd --- /dev/null +++ b/frontend/src/Utilities/String/titleCase.js @@ -0,0 +1,11 @@ +function titleCase(input) { + if (!input) { + return ''; + } + + return input.replace(/\b\w+/g, (match) => { + return match.charAt(0).toUpperCase() + match.substr(1).toLowerCase(); + }); +} + +export default titleCase; diff --git a/frontend/src/Utilities/Table/areAllSelected.js b/frontend/src/Utilities/Table/areAllSelected.js new file mode 100644 index 000000000..26102f89b --- /dev/null +++ b/frontend/src/Utilities/Table/areAllSelected.js @@ -0,0 +1,17 @@ +export default function areAllSelected(selectedState) { + let allSelected = true; + let allUnselected = true; + + Object.keys(selectedState).forEach((key) => { + if (selectedState[key]) { + allUnselected = false; + } else { + allSelected = false; + } + }); + + return { + allSelected, + allUnselected + }; +} diff --git a/frontend/src/Utilities/Table/getSelectedIds.js b/frontend/src/Utilities/Table/getSelectedIds.js new file mode 100644 index 000000000..705f13a5d --- /dev/null +++ b/frontend/src/Utilities/Table/getSelectedIds.js @@ -0,0 +1,15 @@ +import _ from 'lodash'; + +function getSelectedIds(selectedState, { parseIds = true } = {}) { + return _.reduce(selectedState, (result, value, id) => { + if (value) { + const parsedId = parseIds ? parseInt(id) : id; + + result.push(parsedId); + } + + return result; + }, []); +} + +export default getSelectedIds; diff --git a/frontend/src/Utilities/Table/getToggledRange.js b/frontend/src/Utilities/Table/getToggledRange.js new file mode 100644 index 000000000..c0cc44fe5 --- /dev/null +++ b/frontend/src/Utilities/Table/getToggledRange.js @@ -0,0 +1,23 @@ +import _ from 'lodash'; + +function getToggledRange(items, id, lastToggled) { + const lastToggledIndex = _.findIndex(items, { id: lastToggled }); + const changedIndex = _.findIndex(items, { id }); + let lower = 0; + let upper = 0; + + if (lastToggledIndex > changedIndex) { + lower = changedIndex; + upper = lastToggledIndex + 1; + } else { + lower = lastToggledIndex; + upper = changedIndex; + } + + return { + lower, + upper + }; +} + +export default getToggledRange; diff --git a/frontend/src/Utilities/Table/removeOldSelectedState.js b/frontend/src/Utilities/Table/removeOldSelectedState.js new file mode 100644 index 000000000..ff3a4fe11 --- /dev/null +++ b/frontend/src/Utilities/Table/removeOldSelectedState.js @@ -0,0 +1,16 @@ +import areAllSelected from './areAllSelected'; + +export default function removeOldSelectedState(state, prevItems) { + const selectedState = { + ...state.selectedState + }; + + prevItems.forEach((item) => { + delete selectedState[item.id]; + }); + + return { + ...areAllSelected(selectedState), + selectedState + }; +} diff --git a/frontend/src/Utilities/Table/selectAll.js b/frontend/src/Utilities/Table/selectAll.js new file mode 100644 index 000000000..ffaaeaddf --- /dev/null +++ b/frontend/src/Utilities/Table/selectAll.js @@ -0,0 +1,17 @@ +import _ from 'lodash'; + +function selectAll(selectedState, selected) { + const newSelectedState = _.reduce(Object.keys(selectedState), (result, item) => { + result[item] = selected; + return result; + }, {}); + + return { + allSelected: selected, + allUnselected: !selected, + lastToggled: null, + selectedState: newSelectedState + }; +} + +export default selectAll; diff --git a/frontend/src/Utilities/Table/toggleSelected.js b/frontend/src/Utilities/Table/toggleSelected.js new file mode 100644 index 000000000..dbc0d6223 --- /dev/null +++ b/frontend/src/Utilities/Table/toggleSelected.js @@ -0,0 +1,30 @@ +import areAllSelected from './areAllSelected'; +import getToggledRange from './getToggledRange'; + +function toggleSelected(state, items, id, selected, shiftKey) { + const lastToggled = state.lastToggled; + const selectedState = { + ...state.selectedState, + [id]: selected + }; + + if (selected == null) { + delete selectedState[id]; + } + + if (shiftKey && lastToggled) { + const { lower, upper } = getToggledRange(items, id, lastToggled); + + for (let i = lower; i < upper; i++) { + selectedState[items[i].id] = selected; + } + } + + return { + ...areAllSelected(selectedState), + lastToggled: id, + selectedState + }; +} + +export default toggleSelected; diff --git a/frontend/src/Utilities/createAjaxRequest.js b/frontend/src/Utilities/createAjaxRequest.js new file mode 100644 index 000000000..1b33f5a04 --- /dev/null +++ b/frontend/src/Utilities/createAjaxRequest.js @@ -0,0 +1,66 @@ +import $ from 'jquery'; + +const absUrlRegex = /^(https?:)?\/\//i; +const apiRoot = window.Lidarr.apiRoot; + +function isRelative(ajaxOptions) { + return !absUrlRegex.test(ajaxOptions.url); +} + +function moveBodyToQuery(ajaxOptions) { + if (ajaxOptions.data && ajaxOptions.type === 'DELETE') { + if (ajaxOptions.url.contains('?')) { + ajaxOptions.url += '&'; + } else { + ajaxOptions.url += '?'; + } + ajaxOptions.url += $.param(ajaxOptions.data); + delete ajaxOptions.data; + } +} + +function addRootUrl(ajaxOptions) { + ajaxOptions.url = apiRoot + ajaxOptions.url; +} + +function addApiKey(ajaxOptions) { + ajaxOptions.headers = ajaxOptions.headers || {}; + ajaxOptions.headers['X-Api-Key'] = window.Lidarr.apiKey; +} + +export default function createAjaxRequest(originalAjaxOptions) { + const requestXHR = new window.XMLHttpRequest(); + let aborted = false; + let complete = false; + + function abortRequest() { + if (!complete) { + aborted = true; + requestXHR.abort(); + } + } + + const ajaxOptions = { ...originalAjaxOptions }; + + if (isRelative(ajaxOptions)) { + moveBodyToQuery(ajaxOptions); + addRootUrl(ajaxOptions); + addApiKey(ajaxOptions); + } + + const request = $.ajax({ + xhr: () => requestXHR, + ...ajaxOptions + }).then(null, (xhr, textStatus, errorThrown) => { + xhr.aborted = aborted; + + return $.Deferred().reject(xhr, textStatus, errorThrown).promise(); + }).always(() => { + complete = true; + }); + + return { + request, + abortRequest + }; +} diff --git a/frontend/src/Utilities/getPathWithUrlBase.js b/frontend/src/Utilities/getPathWithUrlBase.js new file mode 100644 index 000000000..292e98dba --- /dev/null +++ b/frontend/src/Utilities/getPathWithUrlBase.js @@ -0,0 +1,3 @@ +export default function getPathWithUrlBase(path) { + return `${window.Lidarr.urlBase}${path}`; +} diff --git a/frontend/src/Utilities/getUniqueElementId.js b/frontend/src/Utilities/getUniqueElementId.js new file mode 100644 index 000000000..dae5150b7 --- /dev/null +++ b/frontend/src/Utilities/getUniqueElementId.js @@ -0,0 +1,7 @@ +let i = 0; + +// returns a HTML 4.0 compliant element IDs (http://stackoverflow.com/a/79022) + +export default function getUniqueElementId() { + return `id-${i++}`; +} diff --git a/frontend/src/Utilities/mobile.js b/frontend/src/Utilities/mobile.js new file mode 100644 index 000000000..e52975963 --- /dev/null +++ b/frontend/src/Utilities/mobile.js @@ -0,0 +1,12 @@ +import MobileDetect from 'mobile-detect'; + +const mobileDetect = new MobileDetect(window.navigator.userAgent); + +export function isMobile() { + + return mobileDetect.mobile() != null; +} + +export function isIOS() { + return mobileDetect.is('iOS'); +} diff --git a/frontend/src/Utilities/pagePopulator.js b/frontend/src/Utilities/pagePopulator.js new file mode 100644 index 000000000..f58dbe803 --- /dev/null +++ b/frontend/src/Utilities/pagePopulator.js @@ -0,0 +1,28 @@ +let currentPopulator = null; +let currentReasons = []; + +export function registerPagePopulator(populator, reasons = []) { + currentPopulator = populator; + currentReasons = reasons; +} + +export function unregisterPagePopulator(populator) { + if (currentPopulator === populator) { + currentPopulator = null; + currentReasons = []; + } +} + +export function repopulatePage(reason) { + if (!currentPopulator) { + return; + } + + if (!reason) { + currentPopulator(); + } + + if (reason && currentReasons.includes(reason)) { + currentPopulator(); + } +} diff --git a/frontend/src/Utilities/pages.js b/frontend/src/Utilities/pages.js new file mode 100644 index 000000000..1355442d9 --- /dev/null +++ b/frontend/src/Utilities/pages.js @@ -0,0 +1,9 @@ +const pages = { + FIRST: 'first', + PREVIOUS: 'previous', + NEXT: 'next', + LAST: 'last', + EXACT: 'exact' +}; + +export default pages; diff --git a/frontend/src/Utilities/requestAction.js b/frontend/src/Utilities/requestAction.js new file mode 100644 index 000000000..ed69cf5ad --- /dev/null +++ b/frontend/src/Utilities/requestAction.js @@ -0,0 +1,41 @@ +import $ from 'jquery'; +import _ from 'lodash'; +import createAjaxRequest from './createAjaxRequest'; + +function flattenProviderData(providerData) { + return _.reduce(Object.keys(providerData), (result, key) => { + const property = providerData[key]; + + if (key === 'fields') { + result[key] = property; + } else { + result[key] = property.value; + } + + return result; + }, {}); +} + +function requestAction(payload) { + const { + provider, + action, + providerData, + queryParams + } = payload; + + const ajaxOptions = { + url: `/${provider}/action/${action}`, + contentType: 'application/json', + method: 'POST', + data: JSON.stringify(flattenProviderData(providerData)) + }; + + if (queryParams) { + ajaxOptions.url += `?${$.param(queryParams, true)}`; + } + + return createAjaxRequest(ajaxOptions).request; +} + +export default requestAction; diff --git a/frontend/src/Utilities/scrollLock.js b/frontend/src/Utilities/scrollLock.js new file mode 100644 index 000000000..cff50a34b --- /dev/null +++ b/frontend/src/Utilities/scrollLock.js @@ -0,0 +1,13 @@ +// Allow iOS devices to disable scrolling of the body/virtual table +// when a modal is open. This will prevent focusing an input in a +// modal causing the modal to close due to scrolling. + +let scrollLock = false; + +export function isLocked() { + return scrollLock; +} + +export function setScrollLock(locked) { + scrollLock = locked; +} diff --git a/frontend/src/Utilities/sectionTypes.js b/frontend/src/Utilities/sectionTypes.js new file mode 100644 index 000000000..5479b32b9 --- /dev/null +++ b/frontend/src/Utilities/sectionTypes.js @@ -0,0 +1,6 @@ +const sectionTypes = { + COLLECTION: 'collection', + MODEL: 'model' +}; + +export default sectionTypes; diff --git a/frontend/src/Utilities/serverSideCollectionHandlers.js b/frontend/src/Utilities/serverSideCollectionHandlers.js new file mode 100644 index 000000000..03fa39c00 --- /dev/null +++ b/frontend/src/Utilities/serverSideCollectionHandlers.js @@ -0,0 +1,12 @@ +const serverSideCollectionHandlers = { + FETCH: 'fetch', + FIRST_PAGE: 'firstPage', + PREVIOUS_PAGE: 'previousPage', + NEXT_PAGE: 'nextPage', + LAST_PAGE: 'lastPage', + EXACT_PAGE: 'exactPage', + SORT: 'sort', + FILTER: 'filter' +}; + +export default serverSideCollectionHandlers; diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js new file mode 100644 index 000000000..eca14e349 --- /dev/null +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js @@ -0,0 +1,276 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getFilterValue from 'Utilities/Filter/getFilterValue'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { align, icons, kinds } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TablePager from 'Components/Table/TablePager'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import CutoffUnmetRowConnector from './CutoffUnmetRowConnector'; + +function getMonitoredValue(props) { + const { + filters, + selectedFilterKey + } = props; + + return getFilterValue(filters, selectedFilterKey, 'monitored', false); +} + +class CutoffUnmet extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, + isConfirmSearchAllCutoffUnmetModalOpen: false, + isInteractiveImportModalOpen: false + }; + } + + componentDidUpdate(prevProps) { + if (hasDifferentItems(prevProps.items, this.props.items)) { + this.setState((state) => { + return removeOldSelectedState(state, prevProps.items); + }); + } + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onSearchSelectedPress = () => { + const selected = this.getSelectedIds(); + + this.props.onSearchSelectedPress(selected); + } + + onToggleSelectedPress = () => { + const albumIds = this.getSelectedIds(); + + this.props.batchToggleCutoffUnmetAlbums({ + albumIds, + monitored: !getMonitoredValue(this.props) + }); + } + + onSearchAllCutoffUnmetPress = () => { + this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: true }); + } + + onSearchAllCutoffUnmetConfirmed = () => { + this.props.onSearchAllCutoffUnmetPress(); + this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: false }); + } + + onConfirmSearchAllCutoffUnmetModalClose = () => { + this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: false }); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + selectedFilterKey, + filters, + columns, + totalRecords, + isSearchingForCutoffUnmetAlbums, + isSaving, + onFilterSelect, + ...otherProps + } = this.props; + + const { + allSelected, + allUnselected, + selectedState, + isConfirmSearchAllCutoffUnmetModalOpen + } = this.state; + + const itemsSelected = !!this.getSelectedIds().length; + const isShowingMonitored = getMonitoredValue(this.props); + + return ( + + + + + + + + + + + + + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && error && +
+ Error fetching cutoff unmet +
+ } + + { + isPopulated && !error && !items.length && +
+ No cutoff unmet items +
+ } + + { + isPopulated && !error && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ + + + +
+ Are you sure you want to search for all {totalRecords} Cutoff Unmet albums? +
+
+ This cannot be cancelled once started without restarting Lidarr. +
+
+ } + confirmLabel="Search" + onConfirm={this.onSearchAllCutoffUnmetConfirmed} + onCancel={this.onConfirmSearchAllCutoffUnmetModalClose} + /> +
+ } + + + ); + } +} + +CutoffUnmet.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + totalRecords: PropTypes.number, + isSearchingForCutoffUnmetAlbums: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onSearchSelectedPress: PropTypes.func.isRequired, + batchToggleCutoffUnmetAlbums: PropTypes.func.isRequired, + onSearchAllCutoffUnmetPress: PropTypes.func.isRequired +}; + +export default CutoffUnmet; diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js new file mode 100644 index 000000000..f1259b258 --- /dev/null +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js @@ -0,0 +1,186 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import withCurrentPage from 'Components/withCurrentPage'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import * as wantedActions from 'Store/Actions/wantedActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions'; +import { fetchTrackFiles, clearTrackFiles } from 'Store/Actions/trackFileActions'; +import * as commandNames from 'Commands/commandNames'; +import CutoffUnmet from './CutoffUnmet'; + +function createMapStateToProps() { + return createSelector( + (state) => state.wanted.cutoffUnmet, + createCommandExecutingSelector(commandNames.CUTOFF_UNMET_ALBUM_SEARCH), + (cutoffUnmet, isSearchingForCutoffUnmetAlbums) => { + + return { + isSearchingForCutoffUnmetAlbums, + isSaving: cutoffUnmet.items.filter((m) => m.isSaving).length > 1, + ...cutoffUnmet + }; + } + ); +} + +const mapDispatchToProps = { + ...wantedActions, + executeCommand, + fetchQueueDetails, + clearQueueDetails, + fetchTrackFiles, + clearTrackFiles +}; + +class CutoffUnmetConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + useCurrentPage, + fetchCutoffUnmet, + gotoCutoffUnmetFirstPage + } = this.props; + + registerPagePopulator(this.repopulate, ['trackFileUpdated']); + + if (useCurrentPage) { + fetchCutoffUnmet(); + } else { + gotoCutoffUnmetFirstPage(); + } + } + + componentDidUpdate(prevProps) { + if (hasDifferentItems(prevProps.items, this.props.items)) { + const albumIds = selectUniqueIds(this.props.items, 'id'); + const trackFileIds = selectUniqueIds(this.props.items, 'trackFileId'); + + this.props.fetchQueueDetails({ albumIds }); + + if (trackFileIds.length) { + this.props.fetchTrackFiles({ trackFileIds }); + } + } + } + + componentWillUnmount() { + unregisterPagePopulator(this.repopulate); + this.props.clearCutoffUnmet(); + this.props.clearQueueDetails(); + this.props.clearTrackFiles(); + } + + // + // Control + + repopulate = () => { + this.props.fetchCutoffUnmet(); + } + + // + // Listeners + + onFirstPagePress = () => { + this.props.gotoCutoffUnmetFirstPage(); + } + + onPreviousPagePress = () => { + this.props.gotoCutoffUnmetPreviousPage(); + } + + onNextPagePress = () => { + this.props.gotoCutoffUnmetNextPage(); + } + + onLastPagePress = () => { + this.props.gotoCutoffUnmetLastPage(); + } + + onPageSelect = (page) => { + this.props.gotoCutoffUnmetPage({ page }); + } + + onSortPress = (sortKey) => { + this.props.setCutoffUnmetSort({ sortKey }); + } + + onFilterSelect = (selectedFilterKey) => { + this.props.setCutoffUnmetFilter({ selectedFilterKey }); + } + + onTableOptionChange = (payload) => { + this.props.setCutoffUnmetTableOption(payload); + + if (payload.pageSize) { + this.props.gotoCutoffUnmetFirstPage(); + } + } + + onSearchSelectedPress = (selected) => { + this.props.executeCommand({ + name: commandNames.ALBUM_SEARCH, + albumIds: selected + }); + } + + onSearchAllCutoffUnmetPress = () => { + this.props.executeCommand({ + name: commandNames.CUTOFF_UNMET_ALBUM_SEARCH + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +CutoffUnmetConnector.propTypes = { + useCurrentPage: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchCutoffUnmet: PropTypes.func.isRequired, + gotoCutoffUnmetFirstPage: PropTypes.func.isRequired, + gotoCutoffUnmetPreviousPage: PropTypes.func.isRequired, + gotoCutoffUnmetNextPage: PropTypes.func.isRequired, + gotoCutoffUnmetLastPage: PropTypes.func.isRequired, + gotoCutoffUnmetPage: PropTypes.func.isRequired, + setCutoffUnmetSort: PropTypes.func.isRequired, + setCutoffUnmetFilter: PropTypes.func.isRequired, + setCutoffUnmetTableOption: PropTypes.func.isRequired, + clearCutoffUnmet: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired, + fetchQueueDetails: PropTypes.func.isRequired, + clearQueueDetails: PropTypes.func.isRequired, + fetchTrackFiles: PropTypes.func.isRequired, + clearTrackFiles: PropTypes.func.isRequired +}; + +export default withCurrentPage( + connect(createMapStateToProps, mapDispatchToProps)(CutoffUnmetConnector) +); diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css new file mode 100644 index 000000000..106842b2b --- /dev/null +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css @@ -0,0 +1,6 @@ +.episode, +.status { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js new file mode 100644 index 000000000..6cf592fa6 --- /dev/null +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js @@ -0,0 +1,141 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import albumEntities from 'Album/albumEntities'; +import AlbumTitleLink from 'Album/AlbumTitleLink'; +import EpisodeStatusConnector from 'Album/EpisodeStatusConnector'; +import AlbumSearchCellConnector from 'Album/AlbumSearchCellConnector'; +import ArtistNameLink from 'Artist/ArtistNameLink'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import styles from './CutoffUnmetRow.css'; + +function CutoffUnmetRow(props) { + const { + id, + trackFileId, + artist, + releaseDate, + foreignAlbumId, + albumType, + title, + disambiguation, + isSelected, + columns, + onSelectedChange + } = props; + + if (!artist) { + return null; + } + + return ( + + + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'artist.sortName') { + return ( + + + + ); + } + + if (name === 'albumTitle') { + return ( + + + + ); + } + + if (name === 'albumType') { + return ( + + {albumType} + + ); + } + + if (name === 'releaseDate') { + return ( + + ); + } + + if (name === 'status') { + return ( + + + + ); + } + + if (name === 'actions') { + return ( + + ); + } + + return null; + }) + } + + ); +} + +CutoffUnmetRow.propTypes = { + id: PropTypes.number.isRequired, + trackFileId: PropTypes.number, + artist: PropTypes.object.isRequired, + releaseDate: PropTypes.string.isRequired, + foreignAlbumId: PropTypes.string.isRequired, + albumType: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + disambiguation: PropTypes.string, + isSelected: PropTypes.bool, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + onSelectedChange: PropTypes.func.isRequired +}; + +export default CutoffUnmetRow; diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRowConnector.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRowConnector.js new file mode 100644 index 000000000..625055c57 --- /dev/null +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRowConnector.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createArtistSelector from 'Store/Selectors/createArtistSelector'; +import CutoffUnmetRow from './CutoffUnmetRow'; + +function createMapStateToProps() { + return createSelector( + createArtistSelector(), + (artist) => { + return { + artist + }; + } + ); +} + +export default connect(createMapStateToProps)(CutoffUnmetRow); diff --git a/frontend/src/Wanted/Missing/Missing.js b/frontend/src/Wanted/Missing/Missing.js new file mode 100644 index 000000000..56850b67b --- /dev/null +++ b/frontend/src/Wanted/Missing/Missing.js @@ -0,0 +1,299 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getFilterValue from 'Utilities/Filter/getFilterValue'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { align, icons, kinds } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TablePager from 'Components/Table/TablePager'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; +import MissingRowConnector from './MissingRowConnector'; + +function getMonitoredValue(props) { + const { + filters, + selectedFilterKey + } = props; + + return getFilterValue(filters, selectedFilterKey, 'monitored', false); +} + +class Missing extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, + isConfirmSearchAllMissingModalOpen: false, + isInteractiveImportModalOpen: false + }; + } + + componentDidUpdate(prevProps) { + if (hasDifferentItems(prevProps.items, this.props.items)) { + this.setState((state) => { + return removeOldSelectedState(state, prevProps.items); + }); + } + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onSearchSelectedPress = () => { + const selected = this.getSelectedIds(); + + this.props.onSearchSelectedPress(selected); + } + + onToggleSelectedPress = () => { + const albumIds = this.getSelectedIds(); + + this.props.batchToggleMissingAlbums({ + albumIds, + monitored: !getMonitoredValue(this.props) + }); + } + + onSearchAllMissingPress = () => { + this.setState({ isConfirmSearchAllMissingModalOpen: true }); + } + + onSearchAllMissingConfirmed = () => { + this.props.onSearchAllMissingPress(); + this.setState({ isConfirmSearchAllMissingModalOpen: false }); + } + + onConfirmSearchAllMissingModalClose = () => { + this.setState({ isConfirmSearchAllMissingModalOpen: false }); + } + + onInteractiveImportPress = () => { + this.setState({ isInteractiveImportModalOpen: true }); + } + + onInteractiveImportModalClose = () => { + this.setState({ isInteractiveImportModalOpen: false }); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + selectedFilterKey, + filters, + columns, + totalRecords, + isSearchingForMissingAlbums, + isSaving, + onFilterSelect, + ...otherProps + } = this.props; + + const { + allSelected, + allUnselected, + selectedState, + isConfirmSearchAllMissingModalOpen, + isInteractiveImportModalOpen + } = this.state; + + const itemsSelected = !!this.getSelectedIds().length; + const isShowingMonitored = getMonitoredValue(this.props); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && error && +
+ Error fetching missing items +
+ } + + { + isPopulated && !error && !items.length && +
+ No missing items +
+ } + + { + isPopulated && !error && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ + + + +
+ Are you sure you want to search for all {totalRecords} missing albums? +
+
+ This cannot be cancelled once started without restarting Lidarr. +
+
+ } + confirmLabel="Search" + onConfirm={this.onSearchAllMissingConfirmed} + onCancel={this.onConfirmSearchAllMissingModalClose} + /> +
+ } + + + + + ); + } +} + +Missing.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + totalRecords: PropTypes.number, + isSearchingForMissingAlbums: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onSearchSelectedPress: PropTypes.func.isRequired, + batchToggleMissingAlbums: PropTypes.func.isRequired, + onSearchAllMissingPress: PropTypes.func.isRequired +}; + +export default Missing; diff --git a/frontend/src/Wanted/Missing/MissingConnector.js b/frontend/src/Wanted/Missing/MissingConnector.js new file mode 100644 index 000000000..ec90e274d --- /dev/null +++ b/frontend/src/Wanted/Missing/MissingConnector.js @@ -0,0 +1,174 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import withCurrentPage from 'Components/withCurrentPage'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import * as wantedActions from 'Store/Actions/wantedActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions'; +import * as commandNames from 'Commands/commandNames'; +import Missing from './Missing'; + +function createMapStateToProps() { + return createSelector( + (state) => state.wanted.missing, + createCommandExecutingSelector(commandNames.MISSING_ALBUM_SEARCH), + (missing, isSearchingForMissingAlbums) => { + + return { + isSearchingForMissingAlbums, + isSaving: missing.items.filter((m) => m.isSaving).length > 1, + ...missing + }; + } + ); +} + +const mapDispatchToProps = { + ...wantedActions, + executeCommand, + fetchQueueDetails, + clearQueueDetails +}; + +class MissingConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + useCurrentPage, + fetchMissing, + gotoMissingFirstPage + } = this.props; + + registerPagePopulator(this.repopulate, ['trackFileUpdated']); + + if (useCurrentPage) { + fetchMissing(); + } else { + gotoMissingFirstPage(); + } + } + + componentDidUpdate(prevProps) { + if (hasDifferentItems(prevProps.items, this.props.items)) { + const albumIds = selectUniqueIds(this.props.items, 'id'); + this.props.fetchQueueDetails({ albumIds }); + } + } + + componentWillUnmount() { + unregisterPagePopulator(this.repopulate); + this.props.clearMissing(); + this.props.clearQueueDetails(); + } + + // + // Control + + repopulate = () => { + this.props.fetchMissing(); + } + + // + // Listeners + + onFirstPagePress = () => { + this.props.gotoMissingFirstPage(); + } + + onPreviousPagePress = () => { + this.props.gotoMissingPreviousPage(); + } + + onNextPagePress = () => { + this.props.gotoMissingNextPage(); + } + + onLastPagePress = () => { + this.props.gotoMissingLastPage(); + } + + onPageSelect = (page) => { + this.props.gotoMissingPage({ page }); + } + + onSortPress = (sortKey) => { + this.props.setMissingSort({ sortKey }); + } + + onFilterSelect = (selectedFilterKey) => { + this.props.setMissingFilter({ selectedFilterKey }); + } + + onTableOptionChange = (payload) => { + this.props.setMissingTableOption(payload); + + if (payload.pageSize) { + this.props.gotoMissingFirstPage(); + } + } + + onSearchSelectedPress = (selected) => { + this.props.executeCommand({ + name: commandNames.ALBUM_SEARCH, + albumIds: selected + }); + } + + onSearchAllMissingPress = () => { + this.props.executeCommand({ + name: commandNames.MISSING_ALBUM_SEARCH + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +MissingConnector.propTypes = { + useCurrentPage: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchMissing: PropTypes.func.isRequired, + gotoMissingFirstPage: PropTypes.func.isRequired, + gotoMissingPreviousPage: PropTypes.func.isRequired, + gotoMissingNextPage: PropTypes.func.isRequired, + gotoMissingLastPage: PropTypes.func.isRequired, + gotoMissingPage: PropTypes.func.isRequired, + setMissingSort: PropTypes.func.isRequired, + setMissingFilter: PropTypes.func.isRequired, + setMissingTableOption: PropTypes.func.isRequired, + clearMissing: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired, + fetchQueueDetails: PropTypes.func.isRequired, + clearQueueDetails: PropTypes.func.isRequired +}; + +export default withCurrentPage( + connect(createMapStateToProps, mapDispatchToProps)(MissingConnector) +); diff --git a/frontend/src/Wanted/Missing/MissingRow.css b/frontend/src/Wanted/Missing/MissingRow.css new file mode 100644 index 000000000..1794c2530 --- /dev/null +++ b/frontend/src/Wanted/Missing/MissingRow.css @@ -0,0 +1,5 @@ +.status { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} diff --git a/frontend/src/Wanted/Missing/MissingRow.js b/frontend/src/Wanted/Missing/MissingRow.js new file mode 100644 index 000000000..f019c8aca --- /dev/null +++ b/frontend/src/Wanted/Missing/MissingRow.js @@ -0,0 +1,122 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import albumEntities from 'Album/albumEntities'; +import AlbumTitleLink from 'Album/AlbumTitleLink'; +import AlbumSearchCellConnector from 'Album/AlbumSearchCellConnector'; +import ArtistNameLink from 'Artist/ArtistNameLink'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; + +function MissingRow(props) { + const { + id, + artist, + releaseDate, + albumType, + foreignAlbumId, + title, + disambiguation, + isSelected, + columns, + onSelectedChange + } = props; + + if (!artist) { + return null; + } + + return ( + + + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'artist.sortName') { + return ( + + + + ); + } + + if (name === 'albumTitle') { + return ( + + + + ); + } + + if (name === 'albumType') { + return ( + + {albumType} + + ); + } + + if (name === 'releaseDate') { + return ( + + ); + } + + if (name === 'actions') { + return ( + + ); + } + + return null; + }) + } + + ); +} + +MissingRow.propTypes = { + id: PropTypes.number.isRequired, + artist: PropTypes.object.isRequired, + releaseDate: PropTypes.string.isRequired, + foreignAlbumId: PropTypes.string.isRequired, + albumType: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + disambiguation: PropTypes.string, + isSelected: PropTypes.bool, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + onSelectedChange: PropTypes.func.isRequired +}; + +export default MissingRow; diff --git a/frontend/src/Wanted/Missing/MissingRowConnector.js b/frontend/src/Wanted/Missing/MissingRowConnector.js new file mode 100644 index 000000000..f0a30d9cd --- /dev/null +++ b/frontend/src/Wanted/Missing/MissingRowConnector.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createArtistSelector from 'Store/Selectors/createArtistSelector'; +import MissingRow from './MissingRow'; + +function createMapStateToProps() { + return createSelector( + createArtistSelector(), + (artist) => { + return { + artist + }; + } + ); +} + +export default connect(createMapStateToProps)(MissingRow); diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 000000000..04e0e11ef --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,15 @@ +html, +body { + height: 100%; /* needed for proper layout */ +} + +body { + overflow: hidden; + background-color: #f5f7fa; +} + +@media only screen and (max-width: $breakpointSmall) { + body { + overflow-y: auto; + } +} diff --git a/frontend/src/index.html b/frontend/src/index.html new file mode 100644 index 000000000..a6970fb2d --- /dev/null +++ b/frontend/src/index.html @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + Lidarr + + + + + + +
+
+ + + + + + + + diff --git a/frontend/src/index.js b/frontend/src/index.js new file mode 100644 index 000000000..9f67578ad --- /dev/null +++ b/frontend/src/index.js @@ -0,0 +1,18 @@ +import React from 'react'; +import { render } from 'react-dom'; +import { createBrowserHistory } from 'history'; +import createAppStore from 'Store/createAppStore'; +import App from './App/App'; +import 'Styles/globals.css'; +import './index.css'; + +const history = createBrowserHistory(); +const store = createAppStore(history); + +render( + , + document.getElementById('root') +); diff --git a/frontend/src/login.html b/frontend/src/login.html new file mode 100644 index 000000000..2afba6d92 --- /dev/null +++ b/frontend/src/login.html @@ -0,0 +1,291 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + Login - Lidarr + + + + + +
+
+
+
+ +
+ +
+ + +
+
+ +
+ +
+ +
+ +
+ + + + + + Forgot your password? +
+ + + +
+
+
+ + +
+
+ + + + diff --git a/src/UI/oauth.html b/frontend/src/oauth.html similarity index 84% rename from src/UI/oauth.html rename to frontend/src/oauth.html index fe6ddf864..16a34dbf3 100644 --- a/src/UI/oauth.html +++ b/frontend/src/oauth.html @@ -2,7 +2,7 @@ - oauth landing page + OAuth landing page @@ -10,4 +10,4 @@ Shouldn't see this - \ No newline at end of file + diff --git a/frontend/src/polyfills.js b/frontend/src/polyfills.js new file mode 100644 index 000000000..b5d17d598 --- /dev/null +++ b/frontend/src/polyfills.js @@ -0,0 +1,41 @@ +/* eslint no-empty-function: 0 no-extend-native: 0 */ + +window.console = window.console || {}; +window.console.log = window.console.log || function() {}; +window.console.group = window.console.group || function() {}; +window.console.groupEnd = window.console.groupEnd || function() {}; +window.console.debug = window.console.debug || function() {}; +window.console.warn = window.console.warn || function() {}; +window.console.assert = window.console.assert || function() {}; + +if (!String.prototype.startsWith) { + Object.defineProperty(String.prototype, 'startsWith', { + enumerable: false, + configurable: false, + writable: false, + value(searchString, position) { + position = position || 0; + return this.indexOf(searchString, position) === position; + } + }); +} + +if (!String.prototype.endsWith) { + Object.defineProperty(String.prototype, 'endsWith', { + enumerable: false, + configurable: false, + writable: false, + value(searchString, position) { + position = position || this.length; + position = position - searchString.length; + const lastIndex = this.lastIndexOf(searchString); + return lastIndex !== -1 && lastIndex === position; + } + }); +} + +if (!('contains' in String.prototype)) { + String.prototype.contains = function(str, startIndex) { + return String.prototype.indexOf.call(this, str, startIndex) !== -1; + }; +} diff --git a/frontend/src/preload.js b/frontend/src/preload.js new file mode 100644 index 000000000..aec17072e --- /dev/null +++ b/frontend/src/preload.js @@ -0,0 +1,2 @@ +/* eslint no-undef: 0 */ +__webpack_public_path__ = `${window.Lidarr.urlBase}/`; diff --git a/frontend/src/vendor.js b/frontend/src/vendor.js new file mode 100644 index 000000000..2b08817be --- /dev/null +++ b/frontend/src/vendor.js @@ -0,0 +1,5 @@ +/* Base */ +// require('jquery'); +require('lodash'); +require('moment'); +// require('signalR'); diff --git a/gulp/build.js b/gulp/build.js deleted file mode 100644 index 23f457baf..000000000 --- a/gulp/build.js +++ /dev/null @@ -1,18 +0,0 @@ -var gulp = require('gulp'); -var runSequence = require('run-sequence'); - -require('./clean'); -require('./less'); -require('./handlebars'); -require('./copy'); - -gulp.task('build', function() { - return runSequence('clean', [ - 'webpack', - 'less', - 'handlebars', - 'copyHtml', - 'copyContent', - 'copyJs' - ]); -}); diff --git a/gulp/clean.js b/gulp/clean.js deleted file mode 100644 index 58f9c223f..000000000 --- a/gulp/clean.js +++ /dev/null @@ -1,8 +0,0 @@ -var gulp = require('gulp'); -var del = require('del'); - -var paths = require('./paths'); - -gulp.task('clean', function(cb) { - del([paths.dest.root], cb); -}); diff --git a/gulp/copy.js b/gulp/copy.js deleted file mode 100644 index ab380855d..000000000 --- a/gulp/copy.js +++ /dev/null @@ -1,31 +0,0 @@ -var gulp = require('gulp'); -var print = require('gulp-print'); -var cache = require('gulp-cached'); -var livereload = require('gulp-livereload'); - -var paths = require('./paths.js'); - -gulp.task('copyJs', function () { - return gulp.src( - [ - paths.src.root + 'polyfills.js', - paths.src.root + 'JsLibraries/handlebars.runtime.js' - ]) - .pipe(cache('copyJs')) - .pipe(print()) - .pipe(gulp.dest(paths.dest.root)) - .pipe(livereload()); -}); - -gulp.task('copyHtml', function () { - return gulp.src(paths.src.html) - .pipe(cache('copyHtml')) - .pipe(gulp.dest(paths.dest.root)) - .pipe(livereload()); -}); - -gulp.task('copyContent', function () { - return gulp.src([paths.src.content + '**/*.*', '!**/*.less']) - .pipe(gulp.dest(paths.dest.content)) - .pipe(livereload()); -}); diff --git a/gulp/errorHandler.js b/gulp/errorHandler.js deleted file mode 100644 index db24e1a66..000000000 --- a/gulp/errorHandler.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = { - onError : function(error) { - //If you want details of the error in the console - console.log(error.toString()); - this.emit('end'); - } -}; \ No newline at end of file diff --git a/gulp/gulpFile.js b/gulp/gulpFile.js deleted file mode 100644 index 428fad285..000000000 --- a/gulp/gulpFile.js +++ /dev/null @@ -1,11 +0,0 @@ -require('./watch.js'); -require('./build.js'); -require('./clean.js'); -require('./jshint.js'); -require('./handlebars.js'); -require('./copy.js'); -require('./less.js'); -require('./stripBom.js'); -require('./imageMin.js'); -require('./webpack.js'); -require('./start.js'); diff --git a/gulp/handlebars.js b/gulp/handlebars.js deleted file mode 100644 index aab62f438..000000000 --- a/gulp/handlebars.js +++ /dev/null @@ -1,55 +0,0 @@ -var gulp = require('gulp'); -var handlebars = require('gulp-handlebars'); -var declare = require('gulp-declare'); -var concat = require('gulp-concat'); -var wrap = require("gulp-wrap"); -var livereload = require('gulp-livereload'); -var path = require('path'); -var streamqueue = require('streamqueue'); -var stripbom = require('gulp-stripbom'); - -var paths = require('./paths.js'); - -gulp.task('handlebars', function() { - - var coreStream = gulp.src([ - paths.src.templates, - '!*/**/*Partial.*' - ]) - .pipe(stripbom({ showLog : false })) - .pipe(handlebars()) - .pipe(declare({ - namespace : 'T', - noRedeclare : true, - processName : function(filePath) { - - filePath = path.relative(paths.src.root, filePath); - - return filePath.replace(/\\/g, '/') - .toLocaleLowerCase() - .replace('template', '') - .replace('.js', ''); - } - })); - - var partialStream = gulp.src([paths.src.partials]) - .pipe(stripbom({ showLog : false })) - .pipe(handlebars()) - .pipe(wrap('Handlebars.template(<%= contents %>)')) - .pipe(wrap('Handlebars.registerPartial(<%= processPartialName(file.relative) %>, <%= contents %>)', {}, { - imports : { - processPartialName : function(fileName) { - return JSON.stringify( - path.basename(fileName, '.js') - ); - } - } - })); - - return streamqueue({ objectMode : true }, - partialStream, - coreStream - ).pipe(concat('templates.js')) - .pipe(gulp.dest(paths.dest.root)) - .pipe(livereload()); -}); diff --git a/gulp/imageMin.js b/gulp/imageMin.js deleted file mode 100644 index 6c8236e03..000000000 --- a/gulp/imageMin.js +++ /dev/null @@ -1,15 +0,0 @@ -var gulp = require('gulp'); -var print = require('gulp-print'); -var paths = require('./paths.js'); - -gulp.task('imageMin', function() { - var imagemin = require('gulp-imagemin'); - return gulp.src(paths.src.images) - .pipe(imagemin({ - progressive : false, - optimizationLevel : 4, - svgoPlugins : [{ removeViewBox : false }] - })) - .pipe(print()) - .pipe(gulp.dest(paths.src.content + 'Images/')); -}); \ No newline at end of file diff --git a/gulp/jshint.js b/gulp/jshint.js deleted file mode 100644 index 650ad02c2..000000000 --- a/gulp/jshint.js +++ /dev/null @@ -1,15 +0,0 @@ -var gulp = require('gulp'); -var jshint = require('gulp-jshint'); -var stylish = require('jshint-stylish'); -var cache = require('gulp-cached'); -var paths = require('./paths.js'); - -gulp.task('jshint', function() { - return gulp.src([ - paths.src.scripts, - paths.src.exclude.libs - ]) - .pipe(cache('jshint')) - .pipe(jshint()) - .pipe(jshint.reporter(stylish)); -}); diff --git a/gulp/less.js b/gulp/less.js deleted file mode 100644 index 76e04b8dc..000000000 --- a/gulp/less.js +++ /dev/null @@ -1,46 +0,0 @@ -var gulp = require('gulp'); - -var less = require('gulp-less'); -var postcss = require('gulp-postcss'); -var sourcemaps = require('gulp-sourcemaps'); -var autoprefixer = require('autoprefixer-core'); -var livereload = require('gulp-livereload'); - -var print = require('gulp-print'); -var paths = require('./paths'); -var errorHandler = require('./errorHandler'); - -gulp.task('less', function() { - - var src = [ - paths.src.content + 'bootstrap.less', - paths.src.content + 'theme.less', - paths.src.content + 'overrides.less', - paths.src.root + 'Series/series.less', - paths.src.root + 'Activity/activity.less', - paths.src.root + 'AddSeries/addSeries.less', - paths.src.root + 'Calendar/calendar.less', - paths.src.root + 'Cells/cells.less', - paths.src.root + 'ManualImport/manualimport.less', - paths.src.root + 'Settings/settings.less', - paths.src.root + 'System/Logs/logs.less', - paths.src.root + 'System/Update/update.less', - paths.src.root + 'System/Info/info.less' - ]; - - return gulp.src(src) - .pipe(print()) - .pipe(sourcemaps.init()) - .pipe(less({ - dumpLineNumbers : 'false', - compress : true, - yuicompress : true, - ieCompat : true, - strictImports : true - })) - .pipe(postcss([ autoprefixer({ browsers: ['last 2 versions'] }) ])) - .on('error', errorHandler.onError) - .pipe(sourcemaps.write(paths.dest.content)) - .pipe(gulp.dest(paths.dest.content)) - .pipe(livereload()); -}); diff --git a/gulp/paths.js b/gulp/paths.js deleted file mode 100644 index e05aa1d2b..000000000 --- a/gulp/paths.js +++ /dev/null @@ -1,21 +0,0 @@ -var paths = { - src : { - root : './src/UI/', - templates : './src/UI/**/*.hbs', - html : './src/UI/*.html', - partials : './src/UI/**/*Partial.hbs', - scripts : './src/UI/**/*.js', - less : ['./src/UI/**/*.less'], - content : './src/UI/Content/', - images : './src/UI/Content/Images/**/*', - exclude : { - libs : '!./src/UI/JsLibraries/**' - } - }, - dest : { - root : './_output/UI/', - content : './_output/UI/Content/' - } -}; - -module.exports = paths; diff --git a/gulp/start.js b/gulp/start.js deleted file mode 100644 index 5b5f88044..000000000 --- a/gulp/start.js +++ /dev/null @@ -1,112 +0,0 @@ -// will download and run sonarr (server) in a non-windows enviroment -// you can use this if you don't care about the server code and just want to work -// with the web code. - -var http = require('http'); -var gulp = require('gulp'); -var fs = require('fs'); -var targz = require('tar.gz'); -var del = require('del'); -var print = require('gulp-print'); -var spawn = require('child_process').spawn; - -function download(url, dest, cb) { - console.log('Downloading ' + url + ' to ' + dest); - var file = fs.createWriteStream(dest); - var request = http.get(url, function (response) { - response.pipe(file); - file.on('finish', function () { - console.log('Download completed'); - file.close(cb); - }); - }); -} - -function getLatest(cb) { - var branch = 'develop'; - process.argv.forEach(function (val) { - var branchMatch = /branch=([\S]*)/.exec(val); - if (branchMatch && branchMatch.length > 1) { - branch = branchMatch[1]; - } - }); - - var url = 'http://services.sonarr.tv/v1/update/' + branch + '?os=osx'; - - console.log('Checking for latest version:', url); - - http.get(url, function (res) { - var data = ''; - - res.on('data', function (chunk) { - data += chunk; - }); - - res.on('end', function () { - var updatePackage = JSON.parse(data).updatePackage; - console.log('Latest version available: ' + updatePackage.version + ' Release Date: ' + updatePackage.releaseDate); - cb(updatePackage); - }); - }).on('error', function (e) { - console.log('problem with request: ' + e.message); - }); -} - -function extract(source, dest, cb) { - console.log('extracting download page to ' + dest); - new targz().extract(source, dest, function (err) { - if (err) { - console.log(err); - } - console.log('Update package extracted.'); - cb(); - }); -} - -gulp.task('getSonarr', function () { - - //gulp.src('/Users/kayone/git/Sonarr/_start/2.0.0.3288/NzbDrone/*.*') - // .pipe(print()) - // .pipe(gulp.dest('./_output - - //return; - try { - fs.mkdirSync('./_start/'); - } catch (e) { - if (e.code != 'EEXIST') { - throw e; - } - } - - getLatest(function (package) { - var packagePath = "./_start/" + package.filename; - var dirName = "./_start/" + package.version; - download(package.url, packagePath, function () { - extract(packagePath, dirName, function () { - // clean old binaries - console.log('Cleaning old binaries'); - del.sync(['./_output/*', '!./_output/UI/']); - console.log('copying binaries to target'); - gulp.src(dirName + '/NzbDrone/*.*') - .pipe(gulp.dest('./_output/')); - }); - }); - }); -}); - -gulp.task('startSonarr', function () { - - var ls = spawn('mono', ['--debug', './_output/NzbDrone.exe']); - - ls.stdout.on('data', function (data) { - process.stdout.write('' + data); - }); - - ls.stderr.on('data', function (data) { - process.stdout.write('' + data); - }); - - ls.on('close', function (code) { - console.log('child process exited with code ' + code); - }); -}); diff --git a/gulp/stripBom.js b/gulp/stripBom.js deleted file mode 100644 index 085e6b753..000000000 --- a/gulp/stripBom.js +++ /dev/null @@ -1,21 +0,0 @@ -var gulp = require('gulp'); -var paths = require('./paths.js'); -var stripbom = require('gulp-stripbom'); - -var stripBom = function (dest) { - gulp.src([paths.src.scripts, paths.src.exclude.libs]) - .pipe(stripbom({ showLog: false })) - .pipe(gulp.dest(dest)); - - gulp.src(paths.src.less) - .pipe(stripbom({ showLog: false })) - .pipe(gulp.dest(dest)); - - gulp.src(paths.src.templates) - .pipe(stripbom({ showLog: false })) - .pipe(gulp.dest(dest)); -}; - -gulp.task('stripBom', function () { - stripBom(paths.src.root); -}); diff --git a/gulp/watch.js b/gulp/watch.js deleted file mode 100644 index f9145a464..000000000 --- a/gulp/watch.js +++ /dev/null @@ -1,20 +0,0 @@ -var gulp = require('gulp'); -var livereload = require('gulp-livereload'); - -var paths = require('./paths.js'); - -require('./jshint.js'); -require('./handlebars.js'); -require('./less.js'); -require('./copy.js'); -require('./webpack.js'); - -gulp.task('watch', ['jshint', 'handlebars', 'less', 'copyHtml', 'copyContent', 'copyJs'], function () { - livereload.listen(); - gulp.start('webpackWatch'); - gulp.watch([paths.src.scripts, paths.src.exclude.libs], ['jshint', 'copyJs']); - gulp.watch(paths.src.templates, ['handlebars']); - gulp.watch([paths.src.less, paths.src.exclude.libs], ['less']); - gulp.watch([paths.src.html], ['copyHtml']); - gulp.watch([paths.src.content + '**/*.*', '!**/*.less'], ['copyContent']); -}); \ No newline at end of file diff --git a/gulp/webpack.js b/gulp/webpack.js deleted file mode 100644 index 64570593c..000000000 --- a/gulp/webpack.js +++ /dev/null @@ -1,13 +0,0 @@ -var gulp = require('gulp'); -var webpackStream = require('webpack-stream'); -var livereload = require('gulp-livereload'); -var webpackConfig = require('../webpack.config'); - -gulp.task('webpack', function() { - return gulp.src('main.js').pipe(webpackStream(webpackConfig)).pipe(gulp.dest('')); -}); - -gulp.task('webpackWatch', function() { - webpackConfig.watch = true; - return gulp.src('main.js').pipe(webpackStream(webpackConfig)).pipe(gulp.dest('')).pipe(livereload()); -}); diff --git a/gulpFile.js b/gulpFile.js index 28dc9b0f1..73636a918 100644 --- a/gulpFile.js +++ b/gulpFile.js @@ -1 +1 @@ -require('./gulp/gulpFile.js'); +require('./frontend/gulp/gulpFile.js'); diff --git a/macOS/Lidarr b/macOS/Lidarr new file mode 100644 index 000000000..b18dedd25 --- /dev/null +++ b/macOS/Lidarr @@ -0,0 +1,62 @@ +#!/bin/sh + +#get the bundle's MacOS directory full path +DIR=$(cd "$(dirname "$0")"; pwd) + +#change these values to match your app +EXE_PATH="$DIR/Lidarr.exe" +APPNAME="Lidarr" + +#set up environment +if [[ -x '/opt/local/bin/mono' ]]; then + # Macports and mono-supplied installer path + export PATH="/opt/local/bin:$PATH" +elif [[ -x '/usr/local/bin/mono' ]]; then + # Homebrew-supplied path to mono + export PATH="/usr/local/bin:$PATH" +fi + +export DYLD_FALLBACK_LIBRARY_PATH="$DIR" + +if [ -e /Library/Frameworks/Mono.framework ]; then + MONO_FRAMEWORK_PATH=/Library/Frameworks/Mono.framework/Versions/Current + export PATH="$MONO_FRAMEWORK_PATH/bin:$PATH" + export DYLD_FALLBACK_LIBRARY_PATH="$DYLD_FALLBACK_LIBRARY_PATH:$MONO_FRAMEWORK_PATH/lib" +fi + +if [[ -f '/opt/local/lib/libsqlite3.0.dylib' ]]; then + export DYLD_FALLBACK_LIBRARY_PATH="/opt/local/lib:$DYLD_FALLBACK_LIBRARY_PATH" +fi + +export DYLD_FALLBACK_LIBRARY_PATH="$DYLD_FALLBACK_LIBRARY_PATH:$HOME/lib:/usr/local/lib:/lib:/usr/lib" + +#mono version check +REQUIRED_MAJOR=4 +REQUIRED_MINOR=6 + +VERSION_TITLE="Cannot launch $APPNAME" +VERSION_MSG="$APPNAME requires Mono Runtime Environment(MRE) $REQUIRED_MAJOR.$REQUIRED_MINOR or later." +DOWNLOAD_URL="http://www.mono-project.com/download/#download-mac" + +MONO_VERSION="$(mono --version | grep 'Mono JIT compiler version ' | cut -f5 -d\ )" +# if [[ -o DEBUG ]]; then osascript -e "display dialog \"MONO_VERSION: $MONO_VERSION\""; fi + + +MONO_VERSION_MAJOR="$(echo $MONO_VERSION | cut -f1 -d.)" +MONO_VERSION_MINOR="$(echo $MONO_VERSION | cut -f2 -d.)" +if [ -z "$MONO_VERSION" ] \ + || [ $MONO_VERSION_MAJOR -lt $REQUIRED_MAJOR ] \ + || [ $MONO_VERSION_MAJOR -eq $REQUIRED_MAJOR -a $MONO_VERSION_MINOR -lt $REQUIRED_MINOR ] +then + osascript \ + -e "set question to display dialog \"$VERSION_MSG\" with title \"$VERSION_TITLE\" buttons {\"Cancel\", \"Download...\"} default button 2" \ + -e "if button returned of question is equal to \"Download...\" then open location \"$DOWNLOAD_URL\"" + echo "$VERSION_TITLE" + echo "$VERSION_MSG" + exit 1 +fi + +MONO_EXEC="exec mono --debug" + +#run app using mono +$MONO_EXEC "$EXE_PATH" diff --git a/osx/Sonarr.app/Contents/Info.plist b/macOS/Lidarr.app/Contents/Info.plist similarity index 83% rename from osx/Sonarr.app/Contents/Info.plist rename to macOS/Lidarr.app/Contents/Info.plist index eeae50f41..6e4706fea 100644 --- a/osx/Sonarr.app/Contents/Info.plist +++ b/macOS/Lidarr.app/Contents/Info.plist @@ -11,23 +11,23 @@ CFBundleDevelopmentRegion English CFBundleExecutable - Sonarr + Lidarr CFBundleIconFile - sonarr.icns + lidarr.icns CFBundleIdentifier - com.osx.sonarr.tv + com.osx.lidarr.audio CFBundleInfoDictionaryVersion 6.0 CFBundleName - Sonarr + Lidarr CFBundlePackageType APPL CFBundleShortVersionString - 2.0 + 10.0.0.0 CFBundleSignature xmmd CFBundleVersion - 2.0 + 10.0.0.0 NSAppleScriptEnabled YES diff --git a/macOS/Lidarr.app/Contents/Resources/lidarr.icns b/macOS/Lidarr.app/Contents/Resources/lidarr.icns new file mode 100644 index 000000000..0d92b5b00 Binary files /dev/null and b/macOS/Lidarr.app/Contents/Resources/lidarr.icns differ diff --git a/osx/Sonarr b/osx/Sonarr deleted file mode 100644 index db2a35399..000000000 --- a/osx/Sonarr +++ /dev/null @@ -1,58 +0,0 @@ -#!/bin/sh - -#get the bundle's MacOS directory full path -DIR=$(cd "$(dirname "$0")"; pwd) - -#change these values to match your app -EXE_PATH="$DIR/NzbDrone.exe" -APPNAME="Sonarr" - -#set up environment -if [[ -x '/opt/local/bin/mono' ]]; then - export PATH="/opt/local/bin:$PATH" -fi - -export DYLD_FALLBACK_LIBRARY_PATH="$DIR" - -if [ -e /Library/Frameworks/Mono.framework ]; then - MONO_FRAMEWORK_PATH=/Library/Frameworks/Mono.framework/Versions/Current - export PATH="$MONO_FRAMEWORK_PATH/bin:$PATH" - export DYLD_FALLBACK_LIBRARY_PATH="$DYLD_FALLBACK_LIBRARY_PATH:$MONO_FRAMEWORK_PATH/lib" -fi - -if [[ -f '/opt/local/lib/libsqlite3.0.dylib' ]]; then - export DYLD_FALLBACK_LIBRARY_PATH="/opt/local/lib:$DYLD_FALLBACK_LIBRARY_PATH" -fi - -export DYLD_FALLBACK_LIBRARY_PATH="$DYLD_FALLBACK_LIBRARY_PATH:$HOME/lib:/usr/local/lib:/lib:/usr/lib" - -#mono version check -REQUIRED_MAJOR=3 -REQUIRED_MINOR=10 - -VERSION_TITLE="Cannot launch $APPNAME" -VERSION_MSG="$APPNAME requires Mono Runtime Environment(MRE) $REQUIRED_MAJOR.$REQUIRED_MINOR or later." -DOWNLOAD_URL="http://www.mono-project.com/download/#download-mac" - -MONO_VERSION="$(mono --version | grep 'Mono JIT compiler version ' | cut -f5 -d\ )" -# if [[ -o DEBUG ]]; then osascript -e "display dialog \"MONO_VERSION: $MONO_VERSION\""; fi - - -MONO_VERSION_MAJOR="$(echo $MONO_VERSION | cut -f1 -d.)" -MONO_VERSION_MINOR="$(echo $MONO_VERSION | cut -f2 -d.)" -if [ -z "$MONO_VERSION" ] \ - || [ $MONO_VERSION_MAJOR -lt $REQUIRED_MAJOR ] \ - || [ $MONO_VERSION_MAJOR -eq $REQUIRED_MAJOR -a $MONO_VERSION_MINOR -lt $REQUIRED_MINOR ] -then - osascript \ - -e "set question to display dialog \"$VERSION_MSG\" with title \"$VERSION_TITLE\" buttons {\"Cancel\", \"Download...\"} default button 2" \ - -e "if button returned of question is equal to \"Download...\" then open location \"$DOWNLOAD_URL\"" - echo "$VERSION_TITLE" - echo "$VERSION_MSG" - exit 1 -fi - -MONO_EXEC="exec mono --debug" - -#run app using mono -$MONO_EXEC "$EXE_PATH" \ No newline at end of file diff --git a/osx/Sonarr.app/Contents/Resources/sonarr.icns b/osx/Sonarr.app/Contents/Resources/sonarr.icns deleted file mode 100644 index 884633ff3..000000000 Binary files a/osx/Sonarr.app/Contents/Resources/sonarr.icns and /dev/null differ diff --git a/package.json b/package.json index c3556ed7f..18d25c27b 100644 --- a/package.json +++ b/package.json @@ -1,46 +1,138 @@ { - "name": "Sonarr", - "version": "2.0.0", - "description": "Sonarr", - "main": "main.js", + "name": "lidarr", + "version": "1.0.0", + "description": "Lidarr", "scripts": { "build": "gulp build", - "start": "gulp watch" + "start": "gulp watch", + "watch": "gulp watch", + "clean": "git clean -fXd", + "lint": "esprint check", + "lint-fix": "eslint --fix frontend/** ", + "stylelint-linux": "stylelint $(find frontend -name '*.css') --config frontend/.stylelintrc", + "stylelint-windows": "stylelint frontend/**/*.css --config frontend/.stylelintrc" }, "repository": { "type": "git", - "url": "git://github.com/Sonarr/Sonarr.git" + "url": "git://github.com/Lidarr/Lidarr.git" }, - "author": "", + "author": "Team Lidarr", "license": "GPL-3.0", - "gitHead": "9ff7aa1bf7fe38c4c5bdb92f56c8ad556916ed67", "readmeFilename": "readme.md", "dependencies": { - "autoprefixer-core": "5.2.1", - "del": "1.2.0", - "gulp": "3.9.0", - "gulp-cached": "1.1.0", - "gulp-concat": "2.6.0", - "gulp-declare": "0.3.0", - "gulp-handlebars": "3.0.1", - "gulp-jshint": "1.11.2", - "gulp-less": "3.0.3", - "gulp-livereload": "3.8.0", - "gulp-postcss": "6.0.0", - "gulp-print": "1.1.0", - "gulp-replace": "0.5.3", - "gulp-run": "1.6.8", - "gulp-sourcemaps": "1.5.2", - "gulp-stripbom": "1.0.4", - "gulp-webpack": "1.5.0", - "gulp-wrap": "0.11.0", - "handlebars": "3.0.3", - "jshint-loader": "0.8.3", - "jshint-stylish": "2.0.1", - "run-sequence": "1.1.1", - "streamqueue": "1.1.0", - "tar.gz": "0.1.1", - "webpack": "1.12.0", - "webpack-stream": "2.1.0" - } + "@babel/core": "7.5.5", + "@babel/plugin-proposal-class-properties": "7.5.5", + "@babel/plugin-proposal-decorators": "7.4.4", + "@babel/plugin-proposal-export-default-from": "7.5.2", + "@babel/plugin-proposal-export-namespace-from": "7.5.2", + "@babel/plugin-proposal-function-sent": "7.5.0", + "@babel/plugin-proposal-nullish-coalescing-operator": "7.4.4", + "@babel/plugin-proposal-numeric-separator": "7.2.0", + "@babel/plugin-proposal-optional-chaining": "7.2.0", + "@babel/plugin-proposal-throw-expressions": "7.2.0", + "@babel/plugin-syntax-dynamic-import": "7.2.0", + "@babel/preset-env": "7.5.5", + "@babel/preset-react": "7.0.0", + "@fortawesome/fontawesome-free": "5.10.2", + "@fortawesome/fontawesome-svg-core": "1.2.22", + "@fortawesome/free-regular-svg-icons": "5.10.2", + "@fortawesome/free-solid-svg-icons": "5.10.2", + "@fortawesome/react-fontawesome": "0.1.4", + "@sentry/browser": "5.6.3", + "@sentry/integrations": "5.6.1", + "ansi-colors": "4.1.1", + "autoprefixer": "9.6.1", + "babel-eslint": "10.0.3", + "babel-loader": "8.0.6", + "babel-plugin-inline-classnames": "2.0.1", + "babel-plugin-transform-react-remove-prop-types": "0.4.24", + "classnames": "2.2.6", + "clipboard": "2.0.4", + "connected-react-router": "6.5.2", + "core-js": "3", + "create-react-class": "15.6.3", + "css-loader": "3.2.0", + "del": "5.1.0", + "element-class": "0.2.2", + "eslint": "6.1.0", + "eslint-plugin-filenames": "1.3.2", + "eslint-plugin-react": "7.14.3", + "esprint": "0.5.0", + "file-loader": "4.2.0", + "filesize": "4.1.2", + "fuse.js": "3.4.5", + "gulp": "4.0.2", + "gulp-cached": "1.1.1", + "gulp-concat": "2.6.1", + "gulp-livereload": "4.0.1", + "gulp-postcss": "8.0.0", + "gulp-print": "5.0.2", + "gulp-sourcemaps": "2.6.5", + "gulp-watch": "5.0.1", + "gulp-wrap": "0.15.0", + "history": "4.9.0", + "jdu": "1.0.0", + "jquery": "3.4.1", + "loader-utils": "^1.1.0", + "lodash": "4.17.15", + "mini-css-extract-plugin": "0.8.0", + "mobile-detect": "1.4.3", + "moment": "2.24.0", + "mousetrap": "1.6.3", + "normalize.css": "8.0.1", + "optimize-css-assets-webpack-plugin": "5.0.3", + "postcss-color-function": "4.1.0", + "postcss-loader": "3.0.0", + "postcss-mixins": "6.2.2", + "postcss-nested": "4.1.2", + "postcss-simple-vars": "5.0.2", + "postcss-url": "8.0.0", + "prop-types": "15.7.2", + "qs": "6.7.0", + "react": "16.8.6", + "react-addons-shallow-compare": "15.6.2", + "react-async-script": "1.1.1", + "react-autosuggest": "9.4.3", + "react-custom-scrollbars": "4.2.1", + "react-dnd": "9.3.4", + "react-dnd-html5-backend": "9.3.4", + "react-document-title": "2.0.3", + "react-dom": "16.8.6", + "react-google-recaptcha": "1.1.0", + "react-lazyload": "2.6.2", + "react-measure": "1.4.7", + "react-popper": "1.3.4", + "react-redux": "7.1.1", + "react-router-dom": "5.0.1", + "react-slider": "0.11.2", + "react-text-truncate": "0.15.0", + "react-virtualized": "9.21.1", + "redux": "4.0.4", + "redux-actions": "2.6.5", + "redux-batched-actions": "0.4.1", + "redux-localstorage": "0.4.1", + "redux-thunk": "2.3.0", + "require-nocache": "1.0.0", + "reselect": "4.0.0", + "run-sequence": "2.2.1", + "signalr": "2.4.1", + "streamqueue": "1.1.2", + "style-loader": "0.23.1", + "stylelint": "10.1.0", + "stylelint-order": "3.0.1", + "uglifyjs-webpack-plugin": "2.2.0", + "url-loader": "2.1.0", + "webpack": "4.39.3", + "webpack-stream": "5.2.1" + }, + "devDependencies": { + "@sentry/cli": "1.47.1" + }, + "main": "index.js", + "browserslist": [ + ">0.25%", + "not ie 11", + "not op_mini all", + "not chrome < 60" + ] } diff --git a/setup/build.bat b/setup/build.bat index 1821e5844..faef79cb4 100644 --- a/setup/build.bat +++ b/setup/build.bat @@ -1,3 +1,3 @@ -#SET BUILD_NUMBER=1 -#SET branch=develop -inno\ISCC.exe nzbdrone.iss \ No newline at end of file +REM SET BUILD_NUMBER=1 +REM SET branch=develop +inno\ISCC.exe lidarr.iss \ No newline at end of file diff --git a/setup/inno/Default.isl b/setup/inno/Default.isl index b417cf916..a2711fecd 100644 --- a/setup/inno/Default.isl +++ b/setup/inno/Default.isl @@ -216,7 +216,7 @@ InstallingLabel=Please wait while Setup installs [name] on your computer. ; *** "Setup Completed" wizard page FinishedHeadingLabel=Completing the [name] Setup Wizard FinishedLabelNoIcons=Setup has finished installing [name] on your computer. -FinishedLabel=Setup has finished installing [name] on your computer. The application may be launched by selecting the installed icons. +FinishedLabel=Setup has finished installing [name] on your computer. The application may be launched by selecting the installed shortcuts. ClickFinish=Click Finish to exit Setup. FinishedRestartLabel=To complete the installation of [name], Setup must restart your computer. Would you like to restart now? FinishedRestartMessage=To complete the installation of [name], Setup must restart your computer.%n%nWould you like to restart now? @@ -323,9 +323,9 @@ ShutdownBlockReasonUninstallingApp=Uninstalling %1. [CustomMessages] NameAndVersion=%1 version %2 -AdditionalIcons=Additional icons: -CreateDesktopIcon=Create a &desktop icon -CreateQuickLaunchIcon=Create a &Quick Launch icon +AdditionalIcons=Additional shortcuts: +CreateDesktopIcon=Create a &desktop shortcut +CreateQuickLaunchIcon=Create a &Quick Launch shortcut ProgramOnTheWeb=%1 on the Web UninstallProgram=Uninstall %1 LaunchProgram=Launch %1 diff --git a/setup/inno/ISCC.exe b/setup/inno/ISCC.exe index 8e54535ce..cfe29f31e 100644 Binary files a/setup/inno/ISCC.exe and b/setup/inno/ISCC.exe differ diff --git a/setup/inno/ISCmplr.dll b/setup/inno/ISCmplr.dll index 39129a3d2..5b17787c6 100644 Binary files a/setup/inno/ISCmplr.dll and b/setup/inno/ISCmplr.dll differ diff --git a/setup/inno/ISPP.dll b/setup/inno/ISPP.dll index 87551fb89..93974a0da 100644 Binary files a/setup/inno/ISPP.dll and b/setup/inno/ISPP.dll differ diff --git a/setup/inno/Setup.e32 b/setup/inno/Setup.e32 index 09cc13598..9aa46fb57 100644 Binary files a/setup/inno/Setup.e32 and b/setup/inno/Setup.e32 differ diff --git a/setup/inno/SetupLdr.e32 b/setup/inno/SetupLdr.e32 index 956755065..58fdc1732 100644 Binary files a/setup/inno/SetupLdr.e32 and b/setup/inno/SetupLdr.e32 differ diff --git a/setup/inno/WizModernImage.bmp b/setup/inno/WizModernImage.bmp index cf844e093..cb05a0632 100644 Binary files a/setup/inno/WizModernImage.bmp and b/setup/inno/WizModernImage.bmp differ diff --git a/setup/inno/WizModernSmallImage.bmp b/setup/inno/WizModernSmallImage.bmp index 1e8e49792..63f421040 100644 Binary files a/setup/inno/WizModernSmallImage.bmp and b/setup/inno/WizModernSmallImage.bmp differ diff --git a/setup/inno/islzma.dll b/setup/inno/islzma.dll index 49395ada5..18365df0e 100644 Binary files a/setup/inno/islzma.dll and b/setup/inno/islzma.dll differ diff --git a/setup/lidarr.iss b/setup/lidarr.iss new file mode 100644 index 000000000..c5b3a8efd --- /dev/null +++ b/setup/lidarr.iss @@ -0,0 +1,75 @@ +; Script generated by the Inno Setup Script Wizard. +; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! + +#define AppName "Lidarr" +#define AppPublisher "Team Lidarr" +#define AppURL "https://lidarr.audio/" +#define ForumsURL "https://forums.lidarr.audio/" +#define AppExeName "Lidarr.exe" +#define BaseVersion GetEnv('MAJORVERSION') +#define BuildNumber GetEnv('MINORVERSION') +#define BuildVersion GetEnv('LIDARRVERSION') +#define BranchName GetEnv('BUILD_SOURCEBRANCHNAME') + +[Setup] +; NOTE: The value of AppId uniquely identifies this application. +; Do not use the same AppId value in installers for other applications. +; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) +AppId={{56C1065D-3523-4025-B76D-6F73F67F7F93} +AppName={#AppName} +AppVersion={#BaseVersion} +AppPublisher={#AppPublisher} +AppPublisherURL={#AppURL} +AppSupportURL={#ForumsURL} +AppUpdatesURL={#AppURL} +DefaultDirName={commonappdata}\Lidarr\bin +DisableDirPage=yes +DefaultGroupName={#AppName} +DisableProgramGroupPage=yes +OutputBaseFilename=Lidarr.{#BranchName}.{#BuildVersion}.windows +SolidCompression=yes +AppCopyright=Creative Commons 3.0 License +AllowUNCPath=False +UninstallDisplayIcon={app}\Lidarr.exe +DisableReadyPage=True +CompressionThreads=2 +Compression=lzma2/normal +AppContact={#ForumsURL} +VersionInfoVersion={#BaseVersion}.{#BuildNumber} + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +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\Lidarr.exe"; DestDir: "{app}"; Flags: ignoreversion +Source: "..\_output\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +; NOTE: Don't use "Flags: ignoreversion" on any shared system files + +[Icons] +Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Parameters: "/icon" +Name: "{commondesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Parameters: "/icon" +Name: "{userstartup}\{#AppName}"; Filename: "{app}\Lidarr.exe"; WorkingDir: "{app}"; Tasks: startupShortcut + +[Run] +Filename: "{app}\Lidarr.Console.exe"; StatusMsg: "Removing previous Windows Service"; Parameters: "/u"; Flags: runhidden waituntilterminated; +Filename: "{app}\Lidarr.Console.exe"; Description: "Enable Access from Other Devices"; StatusMsg: "Enabling Remote access"; Parameters: "/registerurl"; Flags: postinstall runascurrentuser runhidden waituntilterminated; Tasks: startupShortcut none; +Filename: "{app}\Lidarr.Console.exe"; StatusMsg: "Installing Windows Service"; Parameters: "/i"; Flags: runhidden waituntilterminated; Tasks: windowsService +Filename: "{app}\Lidarr.exe"; Description: "Open Lidarr Web UI"; Flags: postinstall skipifsilent nowait; Tasks: windowsService; +Filename: "{app}\Lidarr.exe"; Description: "Start Lidarr"; Flags: postinstall skipifsilent nowait; Tasks: startupShortcut none; + +[UninstallRun] +Filename: "{app}\lidarr.console.exe"; Parameters: "/u"; Flags: waituntilterminated skipifdoesntexist + +[Code] +function PrepareToInstall(var NeedsRestart: Boolean): String; +var + ResultCode: Integer; +begin + Exec(ExpandConstant('{commonappdata}\Lidarr\bin\Lidarr.Console.exe'), '/u', '', 0, ewWaitUntilTerminated, ResultCode) +end; diff --git a/setup/nzbdrone.iss b/setup/nzbdrone.iss deleted file mode 100644 index e667c0d03..000000000 --- a/setup/nzbdrone.iss +++ /dev/null @@ -1,60 +0,0 @@ -; Script generated by the Inno Setup Script Wizard. -; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! - -#define AppName "Sonarr" -#define AppPublisher "Team Sonarr" -#define AppURL "https://sonarr.tv/" -#define ForumsURL "https://forums.sonarr.tv/" -#define AppExeName "NzbDrone.exe" -#define BuildNumber "2.0" -#define BuildNumber GetEnv('BUILD_NUMBER') -#define BranchName GetEnv('branch') - -[Setup] -; NOTE: The value of AppId uniquely identifies this application. -; Do not use the same AppId value in installers for other applications. -; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) -AppId={{56C1065D-3523-4025-B76D-6F73F67F7F71} -AppName={#AppName} -AppVersion=2.0 -AppPublisher={#AppPublisher} -AppPublisherURL={#AppURL} -AppSupportURL={#ForumsURL} -AppUpdatesURL={#AppURL} -DefaultDirName={commonappdata}\NzbDrone\bin -DisableDirPage=yes -DefaultGroupName={#AppName} -DisableProgramGroupPage=yes -OutputBaseFilename=NzbDrone.{#BranchName}.{#BuildNumber} -SolidCompression=yes -AppCopyright=Creative Commons 3.0 License -AllowUNCPath=False -UninstallDisplayIcon={app}\NzbDrone.exe -DisableReadyPage=True -CompressionThreads=2 -Compression=lzma2/normal -AppContact={#ForumsURL} -VersionInfoVersion={#BuildNumber} - -[Languages] -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" - -[Files] -Source: "..\_output\NzbDrone.exe"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\_output\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs -; NOTE: Don't use "Flags: ignoreversion" on any shared system files - -[Icons] -Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Parameters: "/icon" -Name: "{commondesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Parameters: "/icon" - -[Run] -Filename: "{app}\nzbdrone.console.exe"; Parameters: "/u"; Flags: waituntilterminated; -Filename: "{app}\nzbdrone.console.exe"; Parameters: "/i"; Flags: waituntilterminated; Tasks: windowsService - -[UninstallRun] -Filename: "{app}\nzbdrone.console.exe"; Parameters: "/u"; Flags: waituntilterminated skipifdoesntexist diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 000000000..b3d5c08be --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,80 @@ + + + + $(MSBuildThisFileDirectory)..\ + + + Library + Test + Exe + Exe + Exe + Exe + Update + + + false + true + true + true + + + + Release + + $(LidarrRootDir)_temp\obj\$(MSBuildProjectName)\ + $(LidarrRootDir)_temp\obj\$(MSBuildProjectName)\$(Configuration)\ + $(LidarrRootDir)_temp\bin\$(Configuration)\$(MSBuildProjectName)\ + + + $(LidarrRootDir)_output\ + $(LidarrRootDir)_tests\ + $(LidarrRootDir)_output\Lidarr.Update\ + + + $([MSBuild]::MakeRelative('$(MSBuildProjectDirectory)', '$(BaseIntermediateOutputPath)')) + $([MSBuild]::MakeRelative('$(MSBuildProjectDirectory)', '$(IntermediateOutputPath)')) + $([MSBuild]::MakeRelative('$(MSBuildProjectDirectory)', '$(OutputPath)')) + + + full + true + + + + + true + true + + + + + Lidarr + lidarr.audio + Copyright 2017-$([System.DateTime]::Now.ToString('yyyy')) lidarr.audio (GNU General Public v3) + + + 10.0.0.* + $(Configuration)-dev + + false + false + false + + False + + + + + <_Parameter1>$(AssemblyConfiguration) + + + + + + false + + + $(MSBuildProjectName.Replace('Lidarr','NzbDrone')) + + diff --git a/src/ExternalModules/CurlSharp b/src/ExternalModules/CurlSharp deleted file mode 160000 index cfdbbbd9c..000000000 --- a/src/ExternalModules/CurlSharp +++ /dev/null @@ -1 +0,0 @@ -Subproject commit cfdbbbd9c6b9612c2756245049a8234ce87dc576 diff --git a/src/Libraries/Fpcalc/chromaprint-fpcalc-1.4.3-macos-x86_64/fpcalc b/src/Libraries/Fpcalc/chromaprint-fpcalc-1.4.3-macos-x86_64/fpcalc new file mode 100755 index 000000000..e8b3ae314 Binary files /dev/null and b/src/Libraries/Fpcalc/chromaprint-fpcalc-1.4.3-macos-x86_64/fpcalc differ diff --git a/src/Libraries/Fpcalc/chromaprint-fpcalc-1.4.3-windows-x86_64/fpcalc.exe b/src/Libraries/Fpcalc/chromaprint-fpcalc-1.4.3-windows-x86_64/fpcalc.exe new file mode 100755 index 000000000..94d7c81ec Binary files /dev/null and b/src/Libraries/Fpcalc/chromaprint-fpcalc-1.4.3-windows-x86_64/fpcalc.exe differ diff --git a/src/Libraries/MediaInfo/MediaInfo.dll b/src/Libraries/MediaInfo/MediaInfo.dll deleted file mode 100644 index dcd8637ea..000000000 Binary files a/src/Libraries/MediaInfo/MediaInfo.dll and /dev/null differ diff --git a/src/Libraries/MediaInfo/libmediainfo.0.dylib b/src/Libraries/MediaInfo/libmediainfo.0.dylib deleted file mode 100644 index 091dcaec1..000000000 Binary files a/src/Libraries/MediaInfo/libmediainfo.0.dylib and /dev/null differ diff --git a/src/Libraries/Sqlite/README.txt b/src/Libraries/Sqlite/README.txt new file mode 100644 index 000000000..29af7f54e --- /dev/null +++ b/src/Libraries/Sqlite/README.txt @@ -0,0 +1,10 @@ +Windows sqlite3.dll binary from here: +https://www.sqlite.org/2019/sqlite-dll-win32-x86-3280000.zip + +MacOS libsqlite3.0.dylib from azure pipeline here: +https://dev.azure.com/Lidarr/Lidarr/_build?definitionId=4&_a=summary + +System.Data.SQLite netstandard2.0 dll compiled in same pipeline with: +/p:Configuration=ReleaseManagedOnly /p:UseInteropDll=false /p:UseSqliteStandard=true + +Both MacOS and System.Data.SQLite from revision 40e714a of https://github.com/lidarr/SQLite.Build diff --git a/src/Libraries/Sqlite/System.Data.SQLite.dll b/src/Libraries/Sqlite/System.Data.SQLite.dll index 1e7145a8d..f27c9cd70 100644 Binary files a/src/Libraries/Sqlite/System.Data.SQLite.dll and b/src/Libraries/Sqlite/System.Data.SQLite.dll differ diff --git a/src/Libraries/Sqlite/System.Data.SQLite.xml b/src/Libraries/Sqlite/System.Data.SQLite.xml index 6e533f0a6..1dea1eccf 100644 --- a/src/Libraries/Sqlite/System.Data.SQLite.xml +++ b/src/Libraries/Sqlite/System.Data.SQLite.xml @@ -67,709 +67,842 @@ This class implements SQLiteBase completely, and is the guts of the code that interop's SQLite with .NET - + - This internal class provides the foundation of SQLite support. It defines all the abstract members needed to implement - a SQLite data provider, and inherits from SQLiteConvert which allows for simple translations of string to and from SQLite. + This field is used to refer to memory allocated for the + SQLITE_DBCONFIG_MAINDBNAME value used with the native + "sqlite3_db_config" API. If allocated, the associated + memeory will be freed when the underlying connection is + closed. - + - This base class provides datatype conversion services for the SQLite provider. + The opaque pointer returned to us by the sqlite provider - + - The fallback default database type when one cannot be obtained from an - existing connection instance. + The user-defined functions registered on this connection - + - The format string for DateTime values when using the InvariantCulture or CurrentCulture formats. + This is the name of the native library file that contains the + "vtshim" extension [wrapper]. - + - The fallback default database type name when one cannot be obtained from - an existing connection instance. + This is the flag indicate whether the native library file that + contains the "vtshim" extension must be dynamically loaded by + this class prior to use. - + - The value for the Unix epoch (e.g. January 1, 1970 at midnight, in UTC). + This is the name of the native entry point for the "vtshim" + extension [wrapper]. - + - The value of the OLE Automation epoch represented as a Julian day. + The modules created using this connection. - + - An array of ISO-8601 DateTime formats that we support parsing. + Constructs the object used to interact with the SQLite core library + using the UTF-8 text encoding. + + The DateTime format to be used when converting string values to a + DateTime and binding DateTime parameters. + + + The to be used when creating DateTime + values. + + + The format string to be used when parsing and formatting DateTime + values. + + + The native handle to be associated with the database connection. + + + The fully qualified file name associated with . + + + Non-zero if the newly created object instance will need to dispose + of when it is no longer needed. + - + - The internal default format for UTC DateTime values when converting - to a string. + This method attempts to dispose of all the derived + object instances currently associated with the native database connection. - + - The internal default format for local DateTime values when converting - to a string. + Returns the number of times the method has been + called. - + - An UTF-8 Encoding instance, so we can convert strings to and from UTF-8 + This method determines whether or not a + with a return code of should + be thrown after making a call into the SQLite core library. + + Non-zero if a to be thrown. This method + will only return non-zero if the method was called + one or more times during a call into the SQLite core library (e.g. when + the sqlite3_prepare*() or sqlite3_step() APIs are used). + - + - The default DateTime format for this instance. + Resets the value of the field. - + - The default DateTimeKind for this instance. + Attempts to interrupt the query currently executing on the associated + native database connection. - + - The default DateTime format string for this instance. + This function binds a user-defined function to the connection. + + The object instance containing + the metadata for the function to be bound. + + + The object instance that implements the + function to be bound. + + + The flags associated with the parent connection object. + - + - Initializes the conversion class + This function binds a user-defined function to the connection. - The default date/time format to use for this instance - The DateTimeKind to use. - The DateTime format string to use. + + The object instance containing + the metadata for the function to be unbound. + + + The flags associated with the parent connection object. + + Non-zero if the function was unbound and removed. - + - Converts a string to a UTF-8 encoded byte array sized to include a null-terminating character. + Returns non-zero if the underlying native connection handle is owned + by this instance. - The string to convert to UTF-8 - A byte array containing the converted string plus an extra 0 terminating byte at the end of the array. - + - Convert a DateTime to a UTF-8 encoded, zero-terminated byte array. + Returns the logical list of functions associated with this connection. - - This function is a convenience function, which first calls ToString() on the DateTime, and then calls ToUTF8() with the - string result. - - The DateTime to convert. - The UTF-8 encoded string, including a 0 terminating byte at the end of the array. - + - Converts a UTF-8 encoded IntPtr of the specified length into a .NET string + Attempts to free as much heap memory as possible for the database connection. - The pointer to the memory where the UTF-8 string is encoded - The number of bytes to decode - A string containing the translated character(s) + A standard SQLite return code (i.e. zero for success and non-zero for failure). - + - Converts a UTF-8 encoded IntPtr of the specified length into a .NET string + Attempts to free N bytes of heap memory by deallocating non-essential memory + allocations held by the database library. Memory used to cache database pages + to improve performance is an example of non-essential memory. This is a no-op + returning zero if the SQLite core library was not compiled with the compile-time + option SQLITE_ENABLE_MEMORY_MANAGEMENT. Optionally, attempts to reset and/or + compact the Win32 native heap, if applicable. - The pointer to the memory where the UTF-8 string is encoded - The number of bytes to decode - A string containing the translated character(s) + + The requested number of bytes to free. + + + Non-zero to attempt a heap reset. + + + Non-zero to attempt heap compaction. + + + The number of bytes actually freed. This value may be zero. + + + This value will be non-zero if the heap reset was successful. + + + The size of the largest committed free block in the heap, in bytes. + This value will be zero unless heap compaction is enabled. + + + A standard SQLite return code (i.e. zero for success and non-zero + for failure). + - + - Converts a string into a DateTime, using the DateTimeFormat, DateTimeKind, - and DateTimeFormatString specified for the connection when it was opened. + Shutdown the SQLite engine so that it can be restarted with different + configuration options. We depend on auto initialization to recover. - - Acceptable ISO8601 DateTime formats are: - - THHmmssK - THHmmK - HH:mm:ss.FFFFFFFK - HH:mm:ssK - HH:mmK - yyyy-MM-dd HH:mm:ss.FFFFFFFK - yyyy-MM-dd HH:mm:ssK - yyyy-MM-dd HH:mmK - yyyy-MM-ddTHH:mm:ss.FFFFFFFK - yyyy-MM-ddTHH:mmK - yyyy-MM-ddTHH:mm:ssK - yyyyMMddHHmmssK - yyyyMMddHHmmK - yyyyMMddTHHmmssFFFFFFFK - THHmmss - THHmm - HH:mm:ss.FFFFFFF - HH:mm:ss - HH:mm - yyyy-MM-dd HH:mm:ss.FFFFFFF - yyyy-MM-dd HH:mm:ss - yyyy-MM-dd HH:mm - yyyy-MM-ddTHH:mm:ss.FFFFFFF - yyyy-MM-ddTHH:mm - yyyy-MM-ddTHH:mm:ss - yyyyMMddHHmmss - yyyyMMddHHmm - yyyyMMddTHHmmssFFFFFFF - yyyy-MM-dd - yyyyMMdd - yy-MM-dd - - If the string cannot be matched to one of the above formats -OR- - the DateTimeFormatString if one was provided, an exception will - be thrown. - - The string containing either a long integer number of 100-nanosecond units since - System.DateTime.MinValue, a Julian day double, an integer number of seconds since the Unix epoch, a - culture-independent formatted date and time string, a formatted date and time string in the current - culture, or an ISO8601-format string. - A DateTime value + Returns a standard SQLite result code. - + - Converts a string into a DateTime, using the specified DateTimeFormat, - DateTimeKind and DateTimeFormatString. + Shutdown the SQLite engine so that it can be restarted with different + configuration options. We depend on auto initialization to recover. - - Acceptable ISO8601 DateTime formats are: - - THHmmssK - THHmmK - HH:mm:ss.FFFFFFFK - HH:mm:ssK - HH:mmK - yyyy-MM-dd HH:mm:ss.FFFFFFFK - yyyy-MM-dd HH:mm:ssK - yyyy-MM-dd HH:mmK - yyyy-MM-ddTHH:mm:ss.FFFFFFFK - yyyy-MM-ddTHH:mmK - yyyy-MM-ddTHH:mm:ssK - yyyyMMddHHmmssK - yyyyMMddHHmmK - yyyyMMddTHHmmssFFFFFFFK - THHmmss - THHmm - HH:mm:ss.FFFFFFF - HH:mm:ss - HH:mm - yyyy-MM-dd HH:mm:ss.FFFFFFF - yyyy-MM-dd HH:mm:ss - yyyy-MM-dd HH:mm - yyyy-MM-ddTHH:mm:ss.FFFFFFF - yyyy-MM-ddTHH:mm - yyyy-MM-ddTHH:mm:ss - yyyyMMddHHmmss - yyyyMMddHHmm - yyyyMMddTHHmmssFFFFFFF - yyyy-MM-dd - yyyyMMdd - yy-MM-dd - - If the string cannot be matched to one of the above formats -OR- - the DateTimeFormatString if one was provided, an exception will - be thrown. - - The string containing either a long integer number of 100-nanosecond units since - System.DateTime.MinValue, a Julian day double, an integer number of seconds since the Unix epoch, a - culture-independent formatted date and time string, a formatted date and time string in the current - culture, or an ISO8601-format string. - The SQLiteDateFormats to use. - The DateTimeKind to use. - The DateTime format string to use. - A DateTime value - - - - Converts a julianday value into a DateTime - - The value to convert - A .NET DateTime + + Non-zero to reset the database and temporary directories to their + default values, which should be null for both. This parameter has no + effect on non-Windows operating systems. + + Returns a standard SQLite result code. - + - Converts a julianday value into a DateTime + Determines if the associated native connection handle is open. - The value to convert - The DateTimeKind to use. - A .NET DateTime + + Non-zero if the associated native connection handle is open. + - + - Converts the specified number of seconds from the Unix epoch into a - value. + Returns the fully qualified path and file name for the currently open + database, if any. - - The number of whole seconds since the Unix epoch. - - - Either Utc or Local time. + + The name of the attached database to query. - The new value. + The fully qualified path and file name for the currently open database, + if any. - + - Converts the specified number of ticks since the epoch into a - value. + This method attempts to determine if a database connection opened + with the specified should be + allowed into the connection pool. - - The number of whole ticks since the epoch. - - - Either Utc or Local time. + + The that were specified when the + connection was opened. - The new value. + Non-zero if the connection should (eventually) be allowed into the + connection pool; otherwise, zero. - + - Converts a DateTime struct to a JulianDay double + Has the sqlite3_errstr() core library API been checked for yet? + If so, is it present? - The DateTime to convert - The JulianDay value the Datetime represents - - - Converts a DateTime struct to the whole number of seconds since the - Unix epoch. - - The DateTime to convert - The whole number of seconds since the Unix epoch + + + Returns the error message for the specified SQLite return code using + the sqlite3_errstr() function, falling back to the internal lookup + table if necessary. + + WARNING: Do not remove this method, it is used via reflection. + + The SQLite return code. + The error message or null if it cannot be found. - + - Returns the DateTime format string to use for the specified DateTimeKind. - If is not null, it will be returned verbatim. + Has the sqlite3_stmt_readonly() core library API been checked for yet? + If so, is it present? - The DateTimeKind to use. - The DateTime format string to use. - - The DateTime format string to use for the specified DateTimeKind. - - + - Converts a string into a DateTime, using the DateTimeFormat, DateTimeKind, - and DateTimeFormatString specified for the connection when it was opened. + Returns non-zero if the specified statement is read-only in nature. - The DateTime value to convert - Either a string containing the long integer number of 100-nanosecond units since System.DateTime.MinValue, a - Julian day double, an integer number of seconds since the Unix epoch, a culture-independent formatted date and time - string, a formatted date and time string in the current culture, or an ISO8601-format date/time string. + The statement to check. + True if the outer query is read-only. - + - Converts a string into a DateTime, using the DateTimeFormat, DateTimeKind, - and DateTimeFormatString specified for the connection when it was opened. + This field is used to keep track of whether or not the + "SQLite_ForceLogPrepare" environment variable has been queried. If so, + it will only be non-zero if the environment variable was present. - The DateTime value to convert - The SQLiteDateFormats to use. - The DateTimeKind to use. - The DateTime format string to use. - Either a string containing the long integer number of 100-nanosecond units since System.DateTime.MinValue, a - Julian day double, an integer number of seconds since the Unix epoch, a culture-independent formatted date and time - string, a formatted date and time string in the current culture, or an ISO8601-format date/time string. - + - Internal function to convert a UTF-8 encoded IntPtr of the specified length to a DateTime. + Determines if all calls to prepare a SQL query will be logged, + regardless of the flags for the associated connection. - - This is a convenience function, which first calls ToString() on the IntPtr to convert it to a string, then calls - ToDateTime() on the string to return a DateTime. - - A pointer to the UTF-8 encoded string - The length in bytes of the string - The parsed DateTime value + + Non-zero to log all calls to prepare a SQL query. + - + - Smart method of splitting a string. Skips quoted elements, removes the quotes. + Determines the file name of the native library containing the native + "vtshim" extension -AND- whether it should be dynamically loaded by + this class. - - This split function works somewhat like the String.Split() function in that it breaks apart a string into - pieces and returns the pieces as an array. The primary differences are: - - Only one character can be provided as a separator character - Quoted text inside the string is skipped over when searching for the separator, and the quotes are removed. - - Thus, if splitting the following string looking for a comma:
- One,Two, "Three, Four", Five
-
- The resulting array would contain
- [0] One
- [1] Two
- [2] Three, Four
- [3] Five
-
- Note that the leading and trailing spaces were removed from each item during the split. -
- Source string to split apart - Separator character - A string array of the split up elements + + This output parameter will be set to non-zero if the returned native + library file name should be dynamically loaded prior to attempting + the creation of native disposable extension modules. + + + The file name of the native library containing the native "vtshim" + extension -OR- null if it cannot be determined. +
- + - Splits the specified string into multiple strings based on a separator - and returns the result as an array of strings. + Calls the native SQLite core library in order to create a disposable + module containing the implementation of a virtual table. - - The string to split into pieces based on the separator character. If - this string is null, null will always be returned. If this string is - empty, an array of zero strings will always be returned. + + The module object to be used when creating the native disposable module. - - The character used to divide the original string into sub-strings. - This character cannot be a backslash or a double-quote; otherwise, no - work will be performed and null will be returned. + + The flags for the associated object instance. - - If this parameter is non-zero, all double-quote characters will be - retained in the returned list of strings; otherwise, they will be - dropped. + + + + Calls the native SQLite core library in order to cleanup the resources + associated with a module containing the implementation of a virtual table. + + + The module object previously passed to the + method. - - Upon failure, this parameter will be modified to contain an appropriate - error message. + + The flags for the associated object instance. - - The new array of strings or null if the input string is null -OR- the - separator character is a backslash or a double-quote -OR- the string - contains an unbalanced backslash or double-quote character. - - + - Queries and returns the string representation for an object, using the - specified (or current) format provider. + Calls the native SQLite core library in order to declare a virtual table + in response to a call into the + or virtual table methods. - - The object instance to return the string representation for. + + The virtual table module that is to be responsible for the virtual table + being declared. - - The format provider to use -OR- null if the current format provider for - the thread should be used instead. + + The string containing the SQL statement describing the virtual table to + be declared. + + + Upon success, the contents of this parameter are undefined. Upon failure, + it should contain an appropriate error message. - The string representation for the object instance -OR- null if the - object instance is also null. + A standard SQLite return code. - + - Attempts to convert an arbitrary object to the Boolean data type. - Null object values are converted to false. Throws an exception - upon failure. + Calls the native SQLite core library in order to declare a virtual table + function in response to a call into the + or virtual table methods. - - The object value to convert. + + The virtual table module that is to be responsible for the virtual table + function being declared. - - The format provider to use. + + The number of arguments to the function being declared. - - If non-zero, a string value will be converted using the - - method; otherwise, the - method will be used. + + The name of the function being declared. + + + Upon success, the contents of this parameter are undefined. Upon failure, + it should contain an appropriate error message. - The converted boolean value. + A standard SQLite return code. - - - Convert a value to true or false. - - A string or number representing true or false - - - - - Convert a string to true or false. - - A string representing true or false - - - "yes", "no", "y", "n", "0", "1", "on", "off" as well as Boolean.FalseString and Boolean.TrueString will all be - converted to a proper boolean value. - - - + - Converts a SQLiteType to a .NET Type object + Builds an error message string fragment containing the + defined values of the + enumeration. - The SQLiteType to convert - Returns a .NET Type object + + The built string fragment. + - + - For a given intrinsic type, return a DbType + Builds an error message string fragment containing the + defined values of the + enumeration. - The native type to convert - The corresponding (closest match) DbType + + The built string fragment. + - + - Returns the ColumnSize for the given DbType + Returns the current and/or highwater values for the specified + database status parameter. - The DbType to get the size of - + + The database status parameter to query. + + + Non-zero to reset the highwater value to the current value. + + + If applicable, receives the current value. + + + If applicable, receives the highwater value. + + + A standard SQLite return code. + - + - Determines the default database type name to be used when a - per-connection value is not available. + Change a configuration option value for the database. + connection. - - The connection context for type mappings, if any. + + The database configuration option to change. + + + The new value for the specified configuration option. - The default database type name to use. + A standard SQLite return code. - + - If applicable, issues a trace log message warning about falling back to - the default database type name. + Enables or disables extension loading by SQLite. - - The database value type. - - - The flags associated with the parent connection object. - - - The textual name of the database type. + + True to enable loading of extensions, false to disable. - + - If applicable, issues a trace log message warning about falling back to - the default database value type. + Loads a SQLite extension library from the named file. - - The textual name of the database type. - - - The flags associated with the parent connection object. + + The name of the dynamic link library file containing the extension. - - The database value type. + + The name of the exported function used to initialize the extension. + If null, the default "sqlite3_extension_init" will be used. - - - For a given database value type, return the "closest-match" textual database type name. - - The connection context for custom type mappings, if any. - The database value type. - The flags associated with the parent connection object. - The type name or an empty string if it cannot be determined. + + Enables or disables extended result codes returned by SQLite - + + Gets the last SQLite error code + + + Gets the last SQLite extended error code + + + Add a log message via the SQLite sqlite3_log interface. + + + Add a log message via the SQLite sqlite3_log interface. + + - Convert a DbType to a Type + Allows the setting of a logging callback invoked by SQLite when a + log event occurs. Only one callback may be set. If NULL is passed, + the logging callback is unregistered. - The DbType to convert from - The closest-match .NET type + The callback function to invoke. + Returns a result code - + - For a given type, return the closest-match SQLite TypeAffinity, which only understands a very limited subset of types. + Appends an error message and an appropriate line-ending to a + instance. This is useful because the .NET Compact Framework has a slightly different set + of supported methods for the class. - The type to evaluate - The SQLite type affinity for that type. + + The instance to append to. + + + The message to append. It will be followed by an appropriate line-ending. + - + - Builds and returns a map containing the database column types - recognized by this provider. + This method attempts to cause the SQLite native library to invalidate + its function pointers that refer to this instance. This is necessary + to prevent calls from native code into delegates that may have been + garbage collected. Normally, these types of issues can only arise for + connections that are added to the pool; howver, it is good practice to + unconditionally invalidate function pointers that may refer to objects + being disposed. + + Non-zero to also invalidate global function pointers (i.e. those that + are not directly associated with this connection on the native side). + + + Non-zero if this method is being executed within a context where it can + throw an exception in the event of failure; otherwise, zero. + - A map containing the database column types recognized by this - provider. + Non-zero if this method was successful; otherwise, zero. - + - Determines if a database type is considered to be a string. + This method attempts to free the cached database name used with the + method. - - The database type to check. + + Non-zero if this method is being executed within a context where it can + throw an exception in the event of failure; otherwise, zero. - Non-zero if the database type is considered to be a string, zero - otherwise. + Non-zero if this method was successful; otherwise, zero. - + - Determines and returns the runtime configuration setting string that - should be used in place of the specified object value. + Creates a new SQLite backup object based on the provided destination + database connection. The source database connection is the one + associated with this object. The source and destination database + connections cannot be the same. - - The object value to convert to a string. - - - Either the string to use in place of the object value -OR- null if it - cannot be determined. - + The destination database connection. + The destination database name. + The source database name. + The newly created backup object. - + - Determines the default value to be used when a - per-connection value is not available. + Copies up to N pages from the source database to the destination + database associated with the specified backup object. - - The connection context for type mappings, if any. + The backup object to use. + + The number of pages to copy, negative to copy all remaining pages. + + + Set to true if the operation needs to be retried due to database + locking issues; otherwise, set to false. - The default value to use. + True if there are more pages to be copied, false otherwise. - + - Determines if the specified textual value appears to be a - value. + Returns the number of pages remaining to be copied from the source + database to the destination database associated with the specified + backup object. - - The textual value to inspect. - - - Non-zero if the text looks like a value, - zero otherwise. - + The backup object to check. + The number of pages remaining to be copied. - + - Determines if the specified textual value appears to be an - value. + Returns the total number of pages in the source database associated + with the specified backup object. - - The textual value to inspect. - - - Non-zero if the text looks like an value, - zero otherwise. - + The backup object to check. + The total number of pages in the source database. - + - Determines if the specified textual value appears to be a - value. + Destroys the backup object, rolling back any backup that may be in + progess. + + The backup object to destroy. + + + + Determines if the SQLite core library has been initialized for the + current process. - - The textual value to inspect. - - Non-zero if the text looks like a value, - zero otherwise. + A boolean indicating whether or not the SQLite core library has been + initialized for the current process. - + - Determines if the specified textual value appears to be a - value. + Determines if the SQLite core library has been initialized for the + current process. - - The object instance configured with - the chosen format. - - - The textual value to inspect. - - Non-zero if the text looks like a in the - configured format, zero otherwise. + A boolean indicating whether or not the SQLite core library has been + initialized for the current process. - + - For a given textual database type name, return the "closest-match" database type. - This method is called during query result processing; therefore, its performance - is critical. + Helper function to retrieve a column of data from an active statement. - The connection context for custom type mappings, if any. - The textual name of the database type to match. - The flags associated with the parent connection object. - The .NET DBType the text evaluates to. + The statement being step()'d through + The flags associated with the connection. + The column index to retrieve + The type of data contained in the column. If Uninitialized, this function will retrieve the datatype information. + Returns the data in the column - + - The error code used for logging exceptions caught in user-provided - code. + Alternate SQLite3 object, overriding many text behaviors to support UTF-16 (Unicode) - + - Sets the status of the memory usage tracking subsystem in the SQLite core library. By default, this is enabled. - If this is disabled, memory usage tracking will not be performed. This is not really a per-connection value, it is - global to the process. + Constructs the object used to interact with the SQLite core library + using the UTF-8 text encoding. - Non-zero to enable memory usage tracking, zero otherwise. - A standard SQLite return code (i.e. zero for success and non-zero for failure). + + The DateTime format to be used when converting string values to a + DateTime and binding DateTime parameters. + + + The to be used when creating DateTime + values. + + + The format string to be used when parsing and formatting DateTime + values. + + + The native handle to be associated with the database connection. + + + The fully qualified file name associated with . + + + Non-zero if the newly created object instance will need to dispose + of when it is no longer needed. + - + - Attempts to free as much heap memory as possible for the database connection. + Overrides SQLiteConvert.ToString() to marshal UTF-16 strings instead of UTF-8 - A standard SQLite return code (i.e. zero for success and non-zero for failure). + A pointer to a UTF-16 string + The length (IN BYTES) of the string + A .NET string - + - Shutdown the SQLite engine so that it can be restarted with different config options. - We depend on auto initialization to recover. + Represents a single SQL backup in SQLite. - + - Determines if the associated native connection handle is open. + The underlying SQLite object this backup is bound to. - - Non-zero if a database connection is open. - - + - Opens a database. + The actual backup handle. - - Implementers should call SQLiteFunction.BindFunctions() and save the array after opening a connection - to bind all attributed user-defined functions and collating sequences to the new connection. - - The filename of the database to open. SQLite automatically creates it if it doesn't exist. - The flags associated with the parent connection object - The open flags to use when creating the connection - The maximum size of the pool for the given filename - If true, the connection can be pulled from the connection pool - + - Closes the currently-open database. + The destination database for the backup. - - After the database has been closed implemeters should call SQLiteFunction.UnbindFunctions() to deallocate all interop allocated - memory associated with the user-defined functions and collating sequences tied to the closed connection. - - Non-zero if the operation is allowed to throw exceptions, zero otherwise. - + - Sets the busy timeout on the connection. SQLiteCommand will call this before executing any command. + The destination database name for the backup. - The number of milliseconds to wait before returning SQLITE_BUSY + + + + The source database for the backup. + + + + + The source database name for the backup. + + + + + The last result from the StepBackup method of the SQLite3 class. + This is used to determine if the call to the FinishBackup method of + the SQLite3 class should throw an exception when it receives a non-Ok + return code from the core SQLite library. + + + + + Initializes the backup. + + The base SQLite object. + The backup handle. + The destination database for the backup. + The destination database name for the backup. + The source database for the backup. + The source database name for the backup. + + + + Disposes and finalizes the backup. + + + + + This internal class provides the foundation of SQLite support. It defines all the abstract members needed to implement + a SQLite data provider, and inherits from SQLiteConvert which allows for simple translations of string to and from SQLite. + + + + + The error code used for logging exceptions caught in user-provided + code. + + + + + Returns a string representing the active version of SQLite + + + + + Returns an integer representing the active version of SQLite + + + + + Returns non-zero if this connection to the database is read-only. + + + + + Returns the rowid of the most recent successful INSERT into the database from this connection. + + + + + Returns the number of changes the last executing insert/update caused. + + + + + Returns the amount of memory (in bytes) currently in use by the SQLite core library. This is not really a per-connection + value, it is global to the process. + + + + + Returns the maximum amount of memory (in bytes) used by the SQLite core library since the high-water mark was last reset. + This is not really a per-connection value, it is global to the process. + + + + + Returns non-zero if the underlying native connection handle is owned by this instance. + + + + + Returns the logical list of functions associated with this connection. + + + + + Sets the status of the memory usage tracking subsystem in the SQLite core library. By default, this is enabled. + If this is disabled, memory usage tracking will not be performed. This is not really a per-connection value, it is + global to the process. + + Non-zero to enable memory usage tracking, zero otherwise. + A standard SQLite return code (i.e. zero for success and non-zero for failure). + + + + Attempts to free as much heap memory as possible for the database connection. + + A standard SQLite return code (i.e. zero for success and non-zero for failure). + + + + Shutdown the SQLite engine so that it can be restarted with different config options. + We depend on auto initialization to recover. + + + + + Determines if the associated native connection handle is open. + + + Non-zero if a database connection is open. + + + + + Returns the fully qualified path and file name for the currently open + database, if any. + + + The name of the attached database to query. + + + The fully qualified path and file name for the currently open database, + if any. + + + + + Opens a database. + + + Implementers should call SQLiteFunction.BindFunctions() and save the array after opening a connection + to bind all attributed user-defined functions and collating sequences to the new connection. + + The filename of the database to open. SQLite automatically creates it if it doesn't exist. + The name of the VFS to use -OR- null to use the default VFS. + The flags associated with the parent connection object + The open flags to use when creating the connection + The maximum size of the pool for the given filename + If true, the connection can be pulled from the connection pool + + + + Closes the currently-open database. + + + After the database has been closed implemeters should call SQLiteFunction.UnbindFunctions() to deallocate all interop allocated + memory associated with the user-defined functions and collating sequences tied to the closed connection. + + Non-zero if connection is being disposed, zero otherwise. + + + + Sets the busy timeout on the connection. SQLiteCommand will call this before executing any command. + + The number of milliseconds to wait before returning SQLITE_BUSY @@ -820,6 +953,13 @@ The SQLiteStatement to step through True if a row was returned, False if not. + + + Returns non-zero if the specified statement is read-only in nature. + + The statement to check. + True if the outer query is read-only. + Resets a prepared statement so it can be executed again. If the error returned is SQLITE_SCHEMA, @@ -836,7 +976,7 @@ - This function binds a user-defined functions to the connection. + This function binds a user-defined function to the connection. The object instance containing @@ -850,6 +990,19 @@ The flags associated with the parent connection object. + + + This function unbinds a user-defined function from the connection. + + + The object instance containing + the metadata for the function to be unbound. + + + The flags associated with the parent connection object. + + Non-zero if the function was unbound. + Calls the native SQLite core library in order to create a disposable @@ -859,7 +1012,7 @@ The module object to be used when creating the native disposable module. - The flags for the associated object instance. + The flags for the associated object instance. @@ -868,18 +1021,18 @@ associated with a module containing the implementation of a virtual table. - The module object previously passed to the + The module object previously passed to the method. - The flags for the associated object instance. + The flags for the associated object instance. Calls the native SQLite core library in order to declare a virtual table - in response to a call into the - or virtual table methods. + in response to a call into the + or virtual table methods. The virtual table module that is to be responsible for the virtual table @@ -900,8 +1053,8 @@ Calls the native SQLite core library in order to declare a virtual table - function in response to a call into the - or virtual table methods. + function in response to a call into the + or virtual table methods. The virtual table module that is to be responsible for the virtual table @@ -921,9 +1074,43 @@ A standard SQLite return code. + + + Returns the current and/or highwater values for the specified database status parameter. + + + The database status parameter to query. + + + Non-zero to reset the highwater value to the current value. + + + If applicable, receives the current value. + + + If applicable, receives the highwater value. + + + A standard SQLite return code. + + + + + Change a configuration option value for the database. + + + The database configuration option to change. + + + The new value for the specified configuration option. + + + A standard SQLite return code. + + - Enables or disabled extension loading by SQLite. + Enables or disables extension loading by SQLite. True to enable loading of extensions, false to disable. @@ -943,7 +1130,7 @@ - Enables or disabled extened result codes returned by SQLite + Enables or disables extened result codes returned by SQLite true to enable extended result codes, false to disable. @@ -981,6 +1168,13 @@ zero otherwise. + + + Returns non-zero if the given database connection is in autocommit mode. + Autocommit mode is on by default. Autocommit mode is disabled by a BEGIN + statement. Autocommit mode is re-enabled by a COMMIT or ROLLBACK. + + Creates a new SQLite backup object based on the provided destination @@ -1042,768 +1236,801 @@ The SQLite return code. The error message or null if it cannot be found. - - - Returns the error message for the specified SQLite return code using - the sqlite3_errstr() function, falling back to the internal lookup - table if necessary. - - The SQLite return code. - The error message or null if it cannot be found. + + + + - + - Returns a string representing the active version of SQLite + Creates temporary tables on the connection so schema information can be queried. + + The connection upon which to build the schema tables. + - + - Returns an integer representing the active version of SQLite + The extra behavioral flags that can be applied to a connection. - + - Returns the rowid of the most recent successful INSERT into the database from this connection. + No extra flags. - + - Returns the number of changes the last executing insert/update caused. + Enable logging of all SQL statements to be prepared. - + - Returns the amount of memory (in bytes) currently in use by the SQLite core library. This is not really a per-connection - value, it is global to the process. + Enable logging of all bound parameter types and raw values. - + - Returns the maximum amount of memory (in bytes) used by the SQLite core library since the high-water mark was last reset. - This is not really a per-connection value, it is global to the process. + Enable logging of all bound parameter strongly typed values. - + - Returns non-zero if the underlying native connection handle is owned by this instance. + Enable logging of all exceptions caught from user-provided + managed code called from native code via delegates. - + - Returns non-zero if the given database connection is in autocommit mode. - Autocommit mode is on by default. Autocommit mode is disabled by a BEGIN - statement. Autocommit mode is re-enabled by a COMMIT or ROLLBACK. + Enable logging of backup API errors. - + - The opaque pointer returned to us by the sqlite provider + Skip adding the extension functions provided by the native + interop assembly. - + - The user-defined functions registered on this connection + When binding parameter values with the + type, use the interop method that accepts an + value. - + - The modules created using this connection. + When binding parameter values, always bind them as though they were + plain text (i.e. no numeric, date/time, or other conversions should + be attempted). - + - Constructs the object used to interact with the SQLite core library - using the UTF-8 text encoding. + When returning column values, always return them as though they were + plain text (i.e. no numeric, date/time, or other conversions should + be attempted). - - The DateTime format to be used when converting string values to a - DateTime and binding DateTime parameters. - - - The to be used when creating DateTime - values. - - - The format string to be used when parsing and formatting DateTime - values. - - - The native handle to be associated with the database connection. - - - The fully qualified file name associated with . - - - Non-zero if the newly created object instance will need to dispose - of when it is no longer needed. - - + - This method attempts to dispose of all the derived - object instances currently associated with the native database connection. + Prevent this object instance from + loading extensions. - + - Attempts to interrupt the query currently executing on the associated - native database connection. + Prevent this object instance from + creating virtual table modules. - + - This function binds a user-defined function to the connection. + Skip binding any functions provided by other managed assemblies when + opening the connection. - - The object instance containing - the metadata for the function to be bound. - - - The object instance that implements the - function to be bound. - - - The flags associated with the parent connection object. - - + - Attempts to free as much heap memory as possible for the database connection. + Skip setting the logging related properties of the + object instance that was passed to + the method. - A standard SQLite return code (i.e. zero for success and non-zero for failure). - + - Attempts to free N bytes of heap memory by deallocating non-essential memory - allocations held by the database library. Memory used to cache database pages - to improve performance is an example of non-essential memory. This is a no-op - returning zero if the SQLite core library was not compiled with the compile-time - option SQLITE_ENABLE_MEMORY_MANAGEMENT. Optionally, attempts to reset and/or - compact the Win32 native heap, if applicable. + Enable logging of all virtual table module errors seen by the + method. - - The requested number of bytes to free. - - - Non-zero to attempt a heap reset. - - - Non-zero to attempt heap compaction. - - - The number of bytes actually freed. This value may be zero. - - - This value will be non-zero if the heap reset was successful. - - - The size of the largest committed free block in the heap, in bytes. - This value will be zero unless heap compaction is enabled. - - - A standard SQLite return code (i.e. zero for success and non-zero - for failure). - - + - Shutdown the SQLite engine so that it can be restarted with different - configuration options. We depend on auto initialization to recover. + Enable logging of certain virtual table module exceptions that cannot + be easily discovered via other means. - Returns a standard SQLite result code. - + - Shutdown the SQLite engine so that it can be restarted with different - configuration options. We depend on auto initialization to recover. + Enable tracing of potentially important [non-fatal] error conditions + that cannot be easily reported through other means. - - Non-zero to reset the database and temporary directories to their - default values, which should be null for both. This parameter has no - effect on non-Windows operating systems. - - Returns a standard SQLite result code. - + - Determines if the associated native connection handle is open. + When binding parameter values, always use the invariant culture when + converting their values from strings. - - Non-zero if the associated native connection handle is open. - - + - Calls the native SQLite core library in order to create a disposable - module containing the implementation of a virtual table. + When binding parameter values, always use the invariant culture when + converting their values to strings. - - The module object to be used when creating the native disposable module. - - - The flags for the associated object instance. - - + - Calls the native SQLite core library in order to cleanup the resources - associated with a module containing the implementation of a virtual table. + Disable using the connection pool by default. If the "Pooling" + connection string property is specified, its value will override + this flag. The precise outcome of combining this flag with the + flag is unspecified; however, + one of the flags will be in effect. - - The module object previously passed to the - method. - - - The flags for the associated object instance. - - + - Calls the native SQLite core library in order to declare a virtual table - in response to a call into the - or virtual table methods. + Enable using the connection pool by default. If the "Pooling" + connection string property is specified, its value will override + this flag. The precise outcome of combining this flag with the + flag is unspecified; however, + one of the flags will be in effect. - - The virtual table module that is to be responsible for the virtual table - being declared. - - - The string containing the SQL statement describing the virtual table to - be declared. - - - Upon success, the contents of this parameter are undefined. Upon failure, - it should contain an appropriate error message. - - - A standard SQLite return code. - - + - Calls the native SQLite core library in order to declare a virtual table - function in response to a call into the - or virtual table methods. + Enable using per-connection mappings between type names and + values. Also see the + , + , and + methods. These + per-connection mappings, when present, override the corresponding + global mappings. - - The virtual table module that is to be responsible for the virtual table - function being declared. - - - The number of arguments to the function being declared. - - - The name of the function being declared. - - - Upon success, the contents of this parameter are undefined. Upon failure, - it should contain an appropriate error message. - - - A standard SQLite return code. - - + - Enables or disabled extension loading by SQLite. + Disable using global mappings between type names and + values. This may be useful in some very narrow + cases; however, if there are no per-connection type mappings, the + fallback defaults will be used for both type names and their + associated values. Therefore, use of this flag + is not recommended. - - True to enable loading of extensions, false to disable. - - + - Loads a SQLite extension library from the named file. + When the property is used, it + should return non-zero if there were ever any rows in the associated + result sets. - - The name of the dynamic link library file containing the extension. - - - The name of the exported function used to initialize the extension. - If null, the default "sqlite3_extension_init" will be used. - - - - Enables or disabled extended result codes returned by SQLite - - Gets the last SQLite error code - - - Gets the last SQLite extended error code - - - Add a log message via the SQLite sqlite3_log interface. - - - Add a log message via the SQLite sqlite3_log interface. + + + Enable "strict" transaction enlistment semantics. Setting this flag + will cause an exception to be thrown if an attempt is made to enlist + in a transaction with an unavailable or unsupported isolation level. + In the future, more extensive checks may be enabled by this flag as + well. + - + - Allows the setting of a logging callback invoked by SQLite when a - log event occurs. Only one callback may be set. If NULL is passed, - the logging callback is unregistered. + Enable mapping of unsupported transaction isolation levels to the + closest supported transaction isolation level. - The callback function to invoke. - Returns a result code - + - Creates a new SQLite backup object based on the provided destination - database connection. The source database connection is the one - associated with this object. The source and destination database - connections cannot be the same. + When returning column values, attempt to detect the affinity of + textual values by checking if they fully conform to those of the + , + , + , + or types. - The destination database connection. - The destination database name. - The source database name. - The newly created backup object. - + - Copies up to N pages from the source database to the destination - database associated with the specified backup object. + When returning column values, attempt to detect the type of + string values by checking if they fully conform to those of + the , + , + , + or types. - The backup object to use. - - The number of pages to copy, negative to copy all remaining pages. - - - Set to true if the operation needs to be retried due to database - locking issues; otherwise, set to false. - - - True if there are more pages to be copied, false otherwise. - - + - Returns the number of pages remaining to be copied from the source - database to the destination database associated with the specified - backup object. + Skip querying runtime configuration settings for use by the + class, including the default + value and default database type name. + NOTE: If the + and/or + properties are not set explicitly nor set via their connection + string properties and repeated calls to determine these runtime + configuration settings are seen to be a problem, this flag + should be set. - The backup object to check. - The number of pages remaining to be copied. - + - Returns the total number of pages in the source database associated - with the specified backup object. + When binding parameter values with the + type, take their into account as + well as that of the associated . - The backup object to check. - The total number of pages in the source database. - + - Destroys the backup object, rolling back any backup that may be in - progess. + If an exception is caught when raising the + event, the transaction + should be rolled back. If this is not specified, the transaction + will continue the commit process instead. - The backup object to destroy. - + - Determines if the SQLite core library has been initialized for the - current process. + If an exception is caught when raising the + event, the action should + should be denied. If this is not specified, the action will be + allowed instead. - - A boolean indicating whether or not the SQLite core library has been - initialized for the current process. - - + - Determines if the SQLite core library has been initialized for the - current process. + If an exception is caught when raising the + event, the operation + should be interrupted. If this is not specified, the operation + will simply continue. - - A boolean indicating whether or not the SQLite core library has been - initialized for the current process. - - + - Helper function to retrieve a column of data from an active statement. + Attempt to unbind all functions provided by other managed assemblies + when closing the connection. - The statement being step()'d through - The flags associated with the connection. - The column index to retrieve - The type of data contained in the column. If Uninitialized, this function will retrieve the datatype information. - Returns the data in the column - + - Returns non-zero if the underlying native connection handle is owned - by this instance. + When returning column values as a , skip + verifying their affinity. - + - Alternate SQLite3 object, overriding many text behaviors to support UTF-16 (Unicode) + Enable using per-connection mappings between type names and + values. Also see the + , + , and + methods. - + - Constructs the object used to interact with the SQLite core library - using the UTF-8 text encoding. + Enable using per-connection mappings between type names and + values. Also see the + , + , and + methods. - - The DateTime format to be used when converting string values to a - DateTime and binding DateTime parameters. - - - The to be used when creating DateTime - values. - - - The format string to be used when parsing and formatting DateTime - values. - - - The native handle to be associated with the database connection. - - - The fully qualified file name associated with . - - - Non-zero if the newly created object instance will need to dispose - of when it is no longer needed. - - + - Overrides SQLiteConvert.ToString() to marshal UTF-16 strings instead of UTF-8 + If the database type name has not been explicitly set for the + parameter specified, fallback to using the parameter name. - A pointer to a UTF-16 string - The length (IN BYTES) of the string - A .NET string - + - Represents a single SQL backup in SQLite. + If the database type name has not been explicitly set for the + parameter specified, fallback to using the database type name + associated with the value. - + - The underlying SQLite object this backup is bound to. + When returning column values, skip verifying their affinity. - + - The actual backup handle. + Allow transactions to be nested. The outermost transaction still + controls whether or not any changes are ultimately committed or + rolled back. All non-outermost transactions are implemented using + the SAVEPOINT construct. - + - The destination database for the backup. + When binding parameter values, always bind + values as though they were plain text (i.e. not , + which is the legacy behavior). - + - The destination database name for the backup. + When returning column values, always return + values as though they were plain text (i.e. not , + which is the legacy behavior). - + - The source database for the backup. + When binding parameter values, always use + the invariant culture when converting their values to strings. - + - The source database name for the backup. + When returning column values, always use + the invariant culture when converting their values from strings. - + - The last result from the StepBackup method of the SQLite3 class. - This is used to determine if the call to the FinishBackup method of - the SQLite3 class should throw an exception when it receives a non-Ok - return code from the core SQLite library. + EXPERIMENTAL -- + Enable waiting for the enlistment to be reset prior to attempting + to create a new enlistment. This may be necessary due to the + semantics used by distributed transactions, which complete + asynchronously. - + - Initializes the backup. + When returning column values, always use + the invariant culture when converting their values from strings. - The base SQLite object. - The backup handle. - The destination database for the backup. - The destination database name for the backup. - The source database for the backup. - The source database name for the backup. - + - Disposes and finalizes the backup. + When returning column values, always use + the invariant culture when converting their values from strings. - + - + EXPERIMENTAL -- + Enable strict conformance to the ADO.NET standard, e.g. use of + thrown exceptions to indicate common error conditions. - + - Creates temporary tables on the connection so schema information can be queried. + EXPERIMENTAL -- + When opening a connection, attempt to hide the password from the + connection string, etc. Given the memory architecture of the CLR, + (and P/Invoke) this is not 100% reliable and should not be relied + upon for security critical uses or applications. - - The connection upon which to build the schema tables. - - + - The extra behavioral flags that can be applied to a connection. + When binding parameter values or returning column values, always + treat them as though they were plain text (i.e. no numeric, + date/time, or other conversions should be attempted). - + - No extra flags. + When binding parameter values, always use the invariant culture when + converting their values to strings or from strings. - + - Enable logging of all SQL statements to be prepared. + When binding parameter values or returning column values, always + treat them as though they were plain text (i.e. no numeric, + date/time, or other conversions should be attempted) and always + use the invariant culture when converting their values to strings. - + - Enable logging of all bound parameter types and raw values. + When binding parameter values or returning column values, always + treat them as though they were plain text (i.e. no numeric, + date/time, or other conversions should be attempted) and always + use the invariant culture when converting their values to strings + or from strings. - + - Enable logging of all bound parameter strongly typed values. + Enables use of all per-connection value handling callbacks. - + - Enable logging of all exceptions caught from user-provided - managed code called from native code via delegates. + Enables use of all applicable + properties as fallbacks for the database type name. - + - Enable logging of backup API errors. + Enable all logging. - + - Skip adding the extension functions provided by the native - interop assembly. + The default logging related flags for new connections. - + - When binding parameter values with the - type, use the interop method that accepts an - value. + The default extra flags for new connections. - + - When binding parameter values, always bind them as though they were - plain text (i.e. no numeric, date/time, or other conversions should - be attempted). + The default extra flags for new connections with all logging enabled. - + - When returning column values, always return them as though they were - plain text (i.e. no numeric, date/time, or other conversions should - be attempted). + These are the supported status parameters for use with the native + SQLite library. - + - Prevent this object instance from - loading extensions. + This parameter returns the number of lookaside memory slots + currently checked out. - + - Prevent this object instance from - creating virtual table modules. + This parameter returns the approximate number of bytes of + heap memory used by all pager caches associated with the + database connection. The highwater mark associated with + SQLITE_DBSTATUS_CACHE_USED is always 0. - + - Skip binding any functions provided by other managed assemblies when - opening the connection. + This parameter returns the approximate number of bytes of + heap memory used to store the schema for all databases + associated with the connection - main, temp, and any ATTACH-ed + databases. The full amount of memory used by the schemas is + reported, even if the schema memory is shared with other + database connections due to shared cache mode being enabled. + The highwater mark associated with SQLITE_DBSTATUS_SCHEMA_USED + is always 0. - + - Skip setting the logging related properties of the - object instance that was passed to - the method. + This parameter returns the number malloc attempts that might + have been satisfied using lookaside memory but failed due to + all lookaside memory already being in use. Only the high-water + value is meaningful; the current value is always zero. - + - Enable logging of all virtual table module errors seen by the - method. + This parameter returns the number malloc attempts that were + satisfied using lookaside memory. Only the high-water value + is meaningful; the current value is always zero. - + - Enable logging of certain virtual table module exceptions that cannot - be easily discovered via other means. + This parameter returns the number malloc attempts that might + have been satisfied using lookaside memory but failed due to + the amount of memory requested being larger than the lookaside + slot size. Only the high-water value is meaningful; the current + value is always zero. - + - Enable tracing of potentially important [non-fatal] error conditions - that cannot be easily reported through other means. + This parameter returns the number malloc attempts that might + have been satisfied using lookaside memory but failed due to + the amount of memory requested being larger than the lookaside + slot size. Only the high-water value is meaningful; the current + value is always zero. - + - When binding parameter values, always use the invariant culture when - converting their values from strings. + This parameter returns the number of pager cache hits that + have occurred. The highwater mark associated with + SQLITE_DBSTATUS_CACHE_HIT is always 0. - + - When binding parameter values, always use the invariant culture when - converting their values to strings. + This parameter returns the number of pager cache misses that + have occurred. The highwater mark associated with + SQLITE_DBSTATUS_CACHE_MISS is always 0. - + - Disable using the connection pool by default. If the "Pooling" - connection string property is specified, its value will override - this flag. The precise outcome of combining this flag with the - flag is unspecified; however, - one of the flags will be in effect. + This parameter returns the number of dirty cache entries that + have been written to disk. Specifically, the number of pages + written to the wal file in wal mode databases, or the number + of pages written to the database file in rollback mode + databases. Any pages written as part of transaction rollback + or database recovery operations are not included. If an IO or + other error occurs while writing a page to disk, the effect + on subsequent SQLITE_DBSTATUS_CACHE_WRITE requests is + undefined. The highwater mark associated with + SQLITE_DBSTATUS_CACHE_WRITE is always 0. - + - Enable using the connection pool by default. If the "Pooling" - connection string property is specified, its value will override - this flag. The precise outcome of combining this flag with the - flag is unspecified; however, - one of the flags will be in effect. + This parameter returns zero for the current value if and only + if all foreign key constraints (deferred or immediate) have + been resolved. The highwater mark is always 0. - + - Enable using per-connection mappings between type names and - values. Also see the - , - , and - methods. These - per-connection mappings, when present, override the corresponding - global mappings. + This parameter is similar to DBSTATUS_CACHE_USED, except that + if a pager cache is shared between two or more connections the + bytes of heap memory used by that pager cache is divided evenly + between the attached connections. In other words, if none of + the pager caches associated with the database connection are + shared, this request returns the same value as DBSTATUS_CACHE_USED. + Or, if one or more or the pager caches are shared, the value + returned by this call will be smaller than that returned by + DBSTATUS_CACHE_USED. The highwater mark associated with + SQLITE_DBSTATUS_CACHE_USED_SHARED is always 0. - + - Disable using global mappings between type names and - values. This may be useful in some very narrow - cases; however, if there are no per-connection type mappings, the - fallback defaults will be used for both type names and their - associated values. Therefore, use of this flag - is not recommended. + These are the supported configuration verbs for use with the native + SQLite library. They are used with the + method. - + - When the property is used, it - should return non-zero if there were ever any rows in the associated - result sets. + This value represents an unknown (or invalid) option, do not use it. - + - Enable "strict" transaction enlistment semantics. Setting this flag - will cause an exception to be thrown if an attempt is made to enlist - in a transaction with an unavailable or unsupported isolation level. - In the future, more extensive checks may be enabled by this flag as - well. + This option is used to change the name of the "main" database + schema. The sole argument is a pointer to a constant UTF8 string + which will become the new schema name in place of "main". - + - Enable mapping of unsupported transaction isolation levels to the - closest supported transaction isolation level. + This option is used to configure the lookaside memory allocator. + The value must be an array with three elements. The second element + must be an containing the size of each buffer + slot. The third element must be an containing + the number of slots. The first element must be an + that points to a native memory buffer of bytes equal to or greater + than the product of the second and third element values. - + - When returning column values, attempt to detect the affinity of - textual values by checking if they fully conform to those of the - , - , - , - or types. + This option is used to enable or disable the enforcement of + foreign key constraints. - + - When returning column values, attempt to detect the type of - string values by checking if they fully conform to those of - the , - , - , - or types. + This option is used to enable or disable triggers. - + - Skip querying runtime configuration settings for use by the - class, including the default - value and default database type name. - NOTE: If the - and/or - properties are not set explicitly nor set via their connection - string properties and repeated calls to determine these runtime - configuration settings are seen to be a problem, this flag - should be set. + This option is used to enable or disable the two-argument version + of the fts3_tokenizer() function which is part of the FTS3 full-text + search engine extension. - + - When binding parameter values or returning column values, always - treat them as though they were plain text (i.e. no numeric, - date/time, or other conversions should be attempted). + This option is used to enable or disable the loading of extensions. - + - When binding parameter values, always use the invariant culture when - converting their values to strings or from strings. + This option is used to enable or disable the automatic checkpointing + when a WAL database is closed. - + - When binding parameter values or returning column values, always - treat them as though they were plain text (i.e. no numeric, - date/time, or other conversions should be attempted) and always - use the invariant culture when converting their values to strings. + This option is used to enable or disable the query planner stability + guarantee (QPSG). - + - When binding parameter values or returning column values, always - treat them as though they were plain text (i.e. no numeric, - date/time, or other conversions should be attempted) and always - use the invariant culture when converting their values to strings - or from strings. + This option is used to enable or disable the extra EXPLAIN QUERY PLAN + output for trigger programs. - + - Enable all logging. + This option is used as part of the process to reset a database back + to an empty state. Because resetting a database is destructive and + irreversible, the process requires the use of this obscure flag and + multiple steps to help ensure that it does not happen by accident. - + - The default extra flags for new connections. + These constants are used with the sqlite3_trace_v2() API and the + callbacks registered by it. - + - The default extra flags for new connections with all logging enabled. + Represents a single SQL blob in SQLite. + + + + + The underlying SQLite object this blob is bound to. + + + + + The actual blob handle. + + + + + Initializes the blob. + + The base SQLite object. + The blob handle. + + + + Creates a object. This will not work + for tables that were created WITHOUT ROWID -OR- if the query + does not include the "rowid" column or one of its aliases -OR- + if the was not created with the + flag. + + + The instance with a result set + containing the desired blob column. + + + The index of the blob column. + + + Non-zero to open the blob object for read-only access. + + + The newly created instance -OR- null + if an error occurs. + + + + + Creates a object. This will not work + for tables that were created WITHOUT ROWID. + + + The connection to use when opening the blob object. + + + The name of the database containing the blob object. + + + The name of the table containing the blob object. + + + The name of the column containing the blob object. + + + The integer identifier for the row associated with the desired + blob object. + + + Non-zero to open the blob object for read-only access. + + + The newly created instance -OR- null + if an error occurs. + + + + + Throws an exception if the blob object does not appear to be open. + + + + + Throws an exception if an invalid read/write parameter is detected. + + + When reading, this array will be populated with the bytes read from + the underlying database blob. When writing, this array contains new + values for the specified portion of the underlying database blob. + + + The number of bytes to read or write. + + + The byte offset, relative to the start of the underlying database + blob, where the read or write operation will begin. + + + + + Retargets this object to an underlying database blob for a + different row; the database, table, and column remain exactly + the same. If this operation fails for any reason, this blob + object is automatically disposed. + + + The integer identifier for the new row. + + + + + Queries the total number of bytes for the underlying database blob. + + + The total number of bytes for the underlying database blob. + + + + + Reads data from the underlying database blob. + + + This array will be populated with the bytes read from the + underlying database blob. + + + The number of bytes to read. + + + The byte offset, relative to the start of the underlying + database blob, where the read operation will begin. + + + + + Writes data into the underlying database blob. + + + This array contains the new values for the specified portion of + the underlying database blob. + + + The number of bytes to write. + + + The byte offset, relative to the start of the underlying + database blob, where the write operation will begin. + + + + + Closes the blob, freeing the associated resources. + + + + + Disposes and finalizes the blob. + + + + + The destructor. @@ -1815,8 +2042,8 @@ The default connection string to be used when creating a temporary connection to execute a command via the static - or - + or + methods. @@ -1945,6 +2172,21 @@ Not implemented + + + The SQL command text associated with the command + + + + + The amount of time to wait for the connection to become available before erroring out + + + + + The type of the command. SQLite only supports CommandType.Text + + Forwards to the local CreateParameter() function @@ -1957,6 +2199,44 @@ + + + The connection associated with this command + + + + + Forwards to the local Connection property + + + + + Returns the SQLiteParameterCollection for the given command + + + + + Forwards to the local Parameters property + + + + + The transaction associated with this command. SQLite only supports one transaction per connection, so this property forwards to the + command's underlying connection. + + + + + Forwards to the local Transaction property + + + + + Verifies that all SQL queries associated with the current command text + can be successfully compiled. A will be + raised if any errors occur. + + This function ensures there are no active readers, that we have a valid connection, @@ -2002,9 +2282,9 @@ This method creates a new connection, executes the query using the given - execution type and command behavior, closes the connection, and returns - the results. If the connection string is null, a temporary in-memory - database connection will be used. + execution type and command behavior, closes the connection unless a data + reader is created, and returns the results. If the connection string is + null, a temporary in-memory database connection will be used. The text of the command to be executed. @@ -2043,7 +2323,7 @@ A SQLiteDataReader - + Called by the SQLiteDataReader when the data reader is closed. @@ -2076,61 +2356,30 @@ The flags to be associated with the reader. The first column of the first row of the first resultset from the query. - - - Does nothing. Commands are prepared as they are executed the first time, and kept in prepared state afterwards. - - - - - Clones a command, including all its parameters - - A new SQLiteCommand with the same commandtext, connection and parameters - - - - The SQL command text associated with the command - - - - - The amount of time to wait for the connection to become available before erroring out - - - - - The type of the command. SQLite only supports CommandType.Text - - - - - The connection associated with this command - - - - - Forwards to the local Connection property - - - - - Returns the SQLiteParameterCollection for the given command - - - + - Forwards to the local Parameters property + This method resets all the prepared statements held by this instance + back to their initial states, ready to be re-executed. - + - The transaction associated with this command. SQLite only supports one transaction per connection, so this property forwards to the - command's underlying connection. + This method resets all the prepared statements held by this instance + back to their initial states, ready to be re-executed. + + Non-zero if the parameter bindings should be cleared as well. + + + If this is zero, a may be thrown for + any unsuccessful return codes from the native library; otherwise, a + will only be thrown if the connection + or its state is invalid. + - + - Forwards to the local Transaction property + Does nothing. Commands are prepared as they are executed the first time, and kept in prepared state afterwards. @@ -2143,6 +2392,12 @@ Determines if the command is visible at design time. Defaults to True. + + + Clones a command, including all its parameters + + A new SQLiteCommand with the same commandtext, connection and parameters + SQLite implementation of DbCommandBuilder. @@ -2159,6 +2414,14 @@ + + + Cleans up resources (native and managed) associated with the current instance. + + + Zero when being disposed via garbage collection; otherwise, non-zero. + + Minimal amount of parameter processing. Primarily sets the DbType for the parameter equal to the provider type in the schema @@ -2196,6 +2459,11 @@ A data adapter to receive events on. + + + Gets/sets the DataAdapter for this CommandBuilder + + Returns the automatically-generated SQLite command to delete rows from the database @@ -2235,6 +2503,26 @@ + + + Overridden to hide its property from the designer + + + + + Overridden to hide its property from the designer + + + + + Overridden to hide its property from the designer + + + + + Overridden to hide its property from the designer + + Places brackets around an identifier @@ -2249,6 +2537,11 @@ The quoted (bracketed) identifier The undecorated identifier + + + Overridden to hide its property from the designer + + Override helper, which can help the base command builder choose the right keys for the given query @@ -2256,7519 +2549,14454 @@ - - - Gets/sets the DataAdapter for this CommandBuilder + + + This class represents a single value to be returned + from the class via + its , + , + , + , + , + , + , + , + , + , + , + , + , + , + , or + method. If the value of the + associated public field of this class is null upon returning from the + callback, the null value will only be used if the return type for the + method called is not a value type. + If the value to be returned from the + method is unsuitable (e.g. null with a value type), an exception will + be thrown. - + - Overridden to hide its property from the designer + The value to be returned from the + method -OR- null to + indicate an error. - + - Overridden to hide its property from the designer + The value to be returned from the + method -OR- null to + indicate an error. - + - Overridden to hide its property from the designer + The value to be returned from the + method -OR- null to + indicate an error. - + - Overridden to hide its property from the designer + The value to be returned from the + method. - + - Overridden to hide its property from the designer + The value to be returned from the + method -OR- null to + indicate an error. - + - Event data for connection event handlers. + The value to be returned from the + method. - + - The type of event being raised. + The value to be returned from the + method -OR- null to + indicate an error. - + - The associated with this event, if any. + The value to be returned from the + method -OR- null to + indicate an error. - + - The transaction associated with this event, if any. + The value to be returned from the + method -OR- null to + indicate an error. - + - The command associated with this event, if any. + The value to be returned from the + method -OR- null to + indicate an error. - + - The data reader associated with this event, if any. + The value to be returned from the + method -OR- null to + indicate an error. - + - The critical handle associated with this event, if any. + The value to be returned from the + method -OR- null to + indicate an error. - + - Command or message text associated with this event, if any. + The value to be returned from the + method -OR- null to + indicate an error. - + - Extra data associated with this event, if any. + The value to be returned from the + method -OR- null to + indicate an error. - + - Constructs the object. + The value to be returned from the + method. - The type of event being raised. - The base associated - with this event, if any. - The transaction associated with this event, if any. - The command associated with this event, if any. - The data reader associated with this event, if any. - The critical handle associated with this event, if any. - The command or message text, if any. - The extra data, if any. - + - Raised when an event pertaining to a connection occurs. + The value to be returned from the + method. - The connection involved. - Extra information about the event. - + - SQLite implentation of DbConnection. + This class represents the parameters that are provided + to the methods, with + the exception of the column index (provided separately). - - The property can contain the following parameter(s), delimited with a semi-colon: - - - Parameter - Values - Required - Default - - - Data Source - - This may be a file name, the string ":memory:", or any supported URI (starting with SQLite 3.7.7). - Starting with release 1.0.86.0, in order to use more than one consecutive backslash (e.g. for a - UNC path), each of the adjoining backslash characters must be doubled (e.g. "\\Network\Share\test.db" - would become "\\\\Network\Share\test.db"). - - Y - - - - Version - 3 - N - 3 - - - UseUTF16Encoding - True
False
- N - False -
- - DateTimeFormat - - Ticks - Use the value of DateTime.Ticks.
- ISO8601 - Use the ISO-8601 format. Uses the "yyyy-MM-dd HH:mm:ss.FFFFFFFK" format for UTC - DateTime values and "yyyy-MM-dd HH:mm:ss.FFFFFFF" format for local DateTime values).
- JulianDay - The interval of time in days and fractions of a day since January 1, 4713 BC.
- UnixEpoch - The whole number of seconds since the Unix epoch (January 1, 1970).
- InvariantCulture - Any culture-independent string value that the .NET Framework can interpret as a valid DateTime.
- CurrentCulture - Any string value that the .NET Framework can interpret as a valid DateTime using the current culture.
- N - ISO8601 -
- - DateTimeKind - Unspecified - Not specified as either UTC or local time.
Utc - The time represented is UTC.
Local - The time represented is local time.
- N - Unspecified -
- - DateTimeFormatString - The exact DateTime format string to use for all formatting and parsing of all DateTime - values for this connection. - N - null - - - BaseSchemaName - Some base data classes in the framework (e.g. those that build SQL queries dynamically) - assume that an ADO.NET provider cannot support an alternate catalog (i.e. database) without supporting - alternate schemas as well; however, SQLite does not fit into this model. Therefore, this value is used - as a placeholder and removed prior to preparing any SQL statements that may contain it. - N - sqlite_default_schema - - - BinaryGUID - True - Store GUID columns in binary form
False - Store GUID columns as text
- N - True -
- - Cache Size - {size in bytes} - N - 2000 - - - Synchronous - Normal - Normal file flushing behavior
Full - Full flushing after all writes
Off - Underlying OS flushes I/O's
- N - Full -
- - Page Size - {size in bytes} - N - 1024 - - - Password - {password} - Using this parameter requires that the CryptoAPI based codec be enabled at compile-time for both the native interop assembly and the core managed assemblies; otherwise, using this parameter may result in an exception being thrown when attempting to open the connection. - N - - - - HexPassword - {hexPassword} - Must contain a sequence of zero or more hexadecimal encoded byte values without a leading "0x" prefix. Using this parameter requires that the CryptoAPI based codec be enabled at compile-time for both the native interop assembly and the core managed assemblies; otherwise, using this parameter may result in an exception being thrown when attempting to open the connection. - N - - - - Enlist - Y - Automatically enlist in distributed transactions
N - No automatic enlistment
- N - Y -
- - Pooling - - True - Use connection pooling.
- False - Do not use connection pooling.

- WARNING: When using the default connection pool implementation, - setting this property to True should be avoided by applications that make - use of COM (either directly or indirectly) due to possible deadlocks that - can occur during the finalization of some COM objects. -
- N - False -
- - FailIfMissing - True - Don't create the database if it does not exist, throw an error instead
False - Automatically create the database if it does not exist
- N - False -
- - Max Page Count - {size in pages} - Limits the maximum number of pages (limits the size) of the database - N - 0 - - - Legacy Format - True - Use the more compatible legacy 3.x database format
False - Use the newer 3.3x database format which compresses numbers more effectively
- N - False -
- - Default Timeout - {time in seconds}
The default command timeout
- N - 30 -
- - Journal Mode - Delete - Delete the journal file after a commit
Persist - Zero out and leave the journal file on disk after a commit
Off - Disable the rollback journal entirely
- N - Delete -
- - Read Only - True - Open the database for read only access
False - Open the database for normal read/write access
- N - False -
- - Max Pool Size - The maximum number of connections for the given connection string that can be in the connection pool - N - 100 - - - Default IsolationLevel - The default transaciton isolation level - N - Serializable - - - Foreign Keys - Enable foreign key constraints - N - False - - - Flags - Extra behavioral flags for the connection. See the enumeration for possible values. - N - Default - - - SetDefaults - - True - Apply the default connection settings to the opened database.
- False - Skip applying the default connection settings to the opened database. -
- N - True -
- - ToFullPath - - True - Attempt to expand the data source file name to a fully qualified path before opening.
- False - Skip attempting to expand the data source file name to a fully qualified path before opening. -
- N - True -
-
-
- + - The "invalid value" for the enumeration used - by the property. This constant is shared - by this class and the SQLiteConnectionStringBuilder class. + This class represents the parameters that are provided to + the method, with + the exception of the column index (provided separately). - + - The default "stub" (i.e. placeholder) base schema name to use when - returning column schema information. Used as the initial value of - the BaseSchemaName property. This should start with "sqlite_*" - because those names are reserved for use by SQLite (i.e. they cannot - be confused with the names of user objects). + Provides the underlying storage for the + property. - + - The managed assembly containing this type. + Constructs an instance of this class to pass into a user-defined + callback associated with the + method. + + The value that was originally specified for the "readOnly" + parameter to the method. + - + - Object used to synchronize access to the static instance data - for this class. + The value that was originally specified for the "readOnly" + parameter to the method. - + - The extra connection flags to be used for all opened connections. + This class represents the parameters that are provided + to the and + methods, with + the exception of the column index (provided separately). - + - Used to hold the active library version number of SQLite. + Provides the underlying storage for the + property. - + - State of the current connection + Provides the underlying storage for the + property. - + - The connection string + Provides the underlying storage for the + property. - + - Nesting level of the transactions open on the connection + Provides the underlying storage for the + property. - + - If set, then the connection is currently being disposed. + Provides the underlying storage for the + property. - + - The default isolation level for new transactions + Constructs an instance of this class to pass into a user-defined + callback associated with the + method. + + The value that was originally specified for the "dataOffset" + parameter to the or + methods. + + + The value that was originally specified for the "buffer" + parameter to the + method. + + + The value that was originally specified for the "bufferOffset" + parameter to the or + methods. + + + The value that was originally specified for the "length" + parameter to the or + methods. + - + - Whether or not the connection is enlisted in a distrubuted transaction + Constructs an instance of this class to pass into a user-defined + callback associated with the + method. + + The value that was originally specified for the "dataOffset" + parameter to the or + methods. + + + The value that was originally specified for the "buffer" + parameter to the + method. + + + The value that was originally specified for the "bufferOffset" + parameter to the or + methods. + + + The value that was originally specified for the "length" + parameter to the or + methods. + - + - The per-connection mappings between type names and - values. These mappings override the corresponding global mappings. + The value that was originally specified for the "dataOffset" + parameter to the or + methods. - + - The base SQLite object to interop with + The value that was originally specified for the "buffer" + parameter to the + method. - + - The database filename minus path and extension + The value that was originally specified for the "buffer" + parameter to the + method. - + - Temporary password storage, emptied after the database has been opened + The value that was originally specified for the "bufferOffset" + parameter to the or + methods. - + - The "stub" (i.e. placeholder) base schema name to use when returning - column schema information. + The value that was originally specified for the "length" + parameter to the or + methods. - + - The extra behavioral flags for this connection, if any. See the - enumeration for a list of - possible values. + This class represents the parameters and return values for the + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , and + methods. - + - The cached values for all settings that have been fetched on behalf - of this connection. This cache may be cleared by calling the - method. + Provides the underlying storage for the + property. - + - The default databse type for this connection. This value will only - be used if the - flag is set. + Provides the underlying storage for the + property. - + - The default databse type name for this connection. This value will only - be used if the - flag is set. + Provides the underlying storage for the + property. - + - Default command timeout + Constructs a new instance of this class. Depending on the method + being called, the and/or + parameters may be null. + + The name of the method that was + responsible for invoking this callback. + + + If the or + method is being called, + this object will contain the array related parameters for that + method. If the method is + being called, this object will contain the blob related parameters + for that method. + + + This may be used by the callback to set the return value for the + called method. + - + - Non-zero if the built-in (i.e. framework provided) connection string - parser should be used when opening the connection. + The name of the method that was + responsible for invoking this callback. - - - Constructs a new SQLiteConnection object - - - Default constructor - + + + If the or + method is being called, + this object will contain the array related parameters for that + method. If the method is + being called, this object will contain the blob related parameters + for that method. + - + - Initializes the connection with the specified connection string. + This may be used by the callback to set the return value for the + called method. - The connection string to use. - + - Initializes the connection with a pre-existing native connection handle. - This constructor overload is intended to be used only by the private - method. + This represents a method that will be called in response to a request to + bind a parameter to a command. If an exception is thrown, it will cause + the parameter binding operation to fail -AND- it will continue to unwind + the call stack. - - The native connection handle to use. + + The instance in use. - - The file name corresponding to the native connection handle. + + The instance in use. - - Non-zero if this instance owns the native connection handle and - should dispose of it when it is no longer needed. + + The flags associated with the instance + in use. + + + The instance being bound to the command. + + + The database type name associated with this callback. + + + The ordinal of the parameter being bound to the command. + + + The data originally used when registering this callback. + + + Non-zero if the default handling for the parameter binding call should + be skipped (i.e. the parameter should not be bound at all). Great care + should be used when setting this to non-zero. - + - Initializes the connection with the specified connection string. + This represents a method that will be called in response to a request + to read a value from a data reader. If an exception is thrown, it will + cause the data reader operation to fail -AND- it will continue to unwind + the call stack. - - The connection string to use. + + The instance in use. - - Non-zero to parse the connection string using the built-in (i.e. - framework provided) parser when opening the connection. + + The instance in use. + + + The flags associated with the instance + in use. + + + The parameter and return type data for the column being read from the + data reader. + + + The database type name associated with this callback. + + + The zero based index of the column being read from the data reader. + + + The data originally used when registering this callback. + + + Non-zero if the default handling for the data reader call should be + skipped. If this is set to non-zero and the necessary return value + is unavailable or unsuitable, an exception will be thrown. - + - Clones the settings and connection string from an existing connection. If the existing connection is already open, this - function will open its own connection, enumerate any attached databases of the original connection, and automatically - attach to them. + This class represents the custom data type handling callbacks + for a single type name. - The connection to copy the settings from. - + - Raises the event. + Provides the underlying storage for the + property. - - The connection associated with this event. If this parameter is not - null and the specified connection cannot raise events, then the - registered event handlers will not be invoked. - - - A that contains the event data. - - + - Creates and returns a new managed database connection handle. This - method is intended to be used by implementations of the - interface only. In theory, it - could be used by other classes; however, that usage is not supported. + Provides the underlying storage for the + property. - - This must be a native database connection handle returned by the - SQLite core library and it must remain valid and open during the - entire duration of the calling method. - - - The new managed database connection handle or null if it cannot be - created. - - + - Backs up the database, using the specified database connection as the - destination. + Provides the underlying storage for the + property. - The destination database connection. - The destination database name. - The source database name. - - The number of pages to copy or negative to copy all remaining pages. - - - The method to invoke between each step of the backup process. This - parameter may be null (i.e. no callbacks will be performed). - - - The number of milliseconds to sleep after encountering a locking error - during the backup process. A value less than zero means that no sleep - should be performed. - - + - Clears the per-connection cached settings. + Provides the underlying storage for the + property. - - The total number of per-connection settings cleared. - - + - Queries and returns the value of the specified setting, using the - cached setting names and values for this connection, when available. + Provides the underlying storage for the + property. - - The name of the setting. + + + + Constructs an instance of this class. + + + The custom paramater binding callback. This parameter may be null. - - The value to be returned if the setting has not been set explicitly - or cannot be determined. + + The custom data reader value callback. This parameter may be null. - - The value of the cached setting is stored here if found; otherwise, - the value of is stored here. + + The extra data to pass into the parameter binding callback. This + parameter may be null. + + + The extra data to pass into the data reader value callback. This + parameter may be null. - - Non-zero if the cached setting was found; otherwise, zero. - - + - Adds or sets the cached setting specified by - to the value specified by . + Creates an instance of the class. - - The name of the cached setting to add or replace. + + The custom paramater binding callback. This parameter may be null. - - The new value of the cached setting. + + The custom data reader value callback. This parameter may be null. + + + The extra data to pass into the parameter binding callback. This + parameter may be null. + + + The extra data to pass into the data reader value callback. This + parameter may be null. - + - Clears the per-connection type mappings. + The database type name that the callbacks contained in this class + will apply to. This value may not be null. - - The total number of per-connection type mappings cleared. - - + - Returns the per-connection type mappings. + The custom paramater binding callback. This value may be null. - - The per-connection type mappings -OR- null if they are unavailable. - - + - Adds a per-connection type mapping, possibly replacing one or more - that already exist. + The custom data reader value callback. This value may be null. - - The case-insensitive database type name (e.g. "MYDATE"). The value - of this parameter cannot be null. Using an empty string value (or - a string value consisting entirely of whitespace) for this parameter - is not recommended. - - - The value that should be associated with the - specified type name. - - - Non-zero if this mapping should be considered to be the primary one - for the specified . - - - A negative value if nothing was done. Zero if no per-connection type - mappings were replaced (i.e. it was a pure add operation). More than - zero if some per-connection type mappings were replaced. - - + - Attempts to bind the specified object - instance to this connection. + The extra data to pass into the parameter binding callback. This + value may be null. - - The object instance containing - the metadata for the function to be bound. - - - The object instance that implements the - function to be bound. - - + - Creates a clone of the connection. All attached databases and user-defined functions are cloned. If the existing connection is open, the cloned connection - will also be opened. + The extra data to pass into the data reader value callback. This + value may be null. - - + - Creates a database file. This just creates a zero-byte file which SQLite - will turn into a database when the file is opened properly. + This class represents the mappings between database type names + and their associated custom data type handling callbacks. - The file to create - + - Raises the state change event when the state of the connection changes + Constructs an (empty) instance of this class. - The new connection state. If this is different - from the previous state, the event is - raised. - The event data created for the raised event, if - it was actually raised. - + - Determines and returns the fallback default isolation level when one cannot be - obtained from an existing connection instance. + Event data for connection event handlers. - - The fallback default isolation level for this connection instance -OR- - if it cannot be determined. - - + - Determines and returns the default isolation level for this connection instance. + The type of event being raised. - - The default isolation level for this connection instance -OR- - if it cannot be determined. - - + - OBSOLETE. Creates a new SQLiteTransaction if one isn't already active on the connection. + The associated with this event, if any. - This parameter is ignored. - When TRUE, SQLite defers obtaining a write lock until a write operation is requested. - When FALSE, a writelock is obtained immediately. The default is TRUE, but in a multi-threaded multi-writer - environment, one may instead choose to lock the database immediately to avoid any possible writer deadlock. - Returns a SQLiteTransaction object. - + - OBSOLETE. Creates a new SQLiteTransaction if one isn't already active on the connection. + The transaction associated with this event, if any. - When TRUE, SQLite defers obtaining a write lock until a write operation is requested. - When FALSE, a writelock is obtained immediately. The default is false, but in a multi-threaded multi-writer - environment, one may instead choose to lock the database immediately to avoid any possible writer deadlock. - Returns a SQLiteTransaction object. - + - Creates a new if one isn't already active on the connection. + The command associated with this event, if any. - Supported isolation levels are Serializable, ReadCommitted and Unspecified. - - Unspecified will use the default isolation level specified in the connection string. If no isolation level is specified in the - connection string, Serializable is used. - Serializable transactions are the default. In this mode, the engine gets an immediate lock on the database, and no other threads - may begin a transaction. Other threads may read from the database, but not write. - With a ReadCommitted isolation level, locks are deferred and elevated as needed. It is possible for multiple threads to start - a transaction in ReadCommitted mode, but if a thread attempts to commit a transaction while another thread - has a ReadCommitted lock, it may timeout or cause a deadlock on both threads until both threads' CommandTimeout's are reached. - - Returns a SQLiteTransaction object. - + - Creates a new if one isn't already - active on the connection. + The data reader associated with this event, if any. - Returns the new transaction object. - + - Forwards to the local function + The critical handle associated with this event, if any. - Supported isolation levels are Unspecified, Serializable, and ReadCommitted - - + - This method is not implemented; however, the - event will still be raised. + Command or message text associated with this event, if any. - + + + + Extra data associated with this event, if any. + + + + + Constructs the object. + + The type of event being raised. + The base associated + with this event, if any. + The transaction associated with this event, if any. + The command associated with this event, if any. + The data reader associated with this event, if any. + The critical handle associated with this event, if any. + The command or message text, if any. + The extra data, if any. + + + + Raised when an event pertaining to a connection occurs. + + The connection involved. + Extra information about the event. + + + + SQLite implentation of DbConnection. + + + The property can contain the following parameter(s), delimited with a semi-colon: + + + Parameter + Values + Required + Default + + + Data Source + + This may be a file name, the string ":memory:", or any supported URI (starting with SQLite 3.7.7). + Starting with release 1.0.86.0, in order to use more than one consecutive backslash (e.g. for a + UNC path), each of the adjoining backslash characters must be doubled (e.g. "\\Network\Share\test.db" + would become "\\\\Network\Share\test.db"). + + Y + + + + Uri + + If specified, this must be a file name that starts with "file://", "file:", or "/". Any leading + "file://" or "file:" prefix will be stripped off and the resulting file name will be used to open + the database. + + N + null + + + FullUri + + If specified, this must be a URI in a format recognized by the SQLite core library (starting with + SQLite 3.7.7). It will be passed verbatim to the SQLite core library. + + N + null + + + Version + 3 + N + 3 + + + UseUTF16Encoding + + True - The UTF-16 encoding should be used. +
+ False - The UTF-8 encoding should be used. +
+ N + False +
+ + DefaultDbType + + This is the default to use when one cannot be determined based on the + column metadata and the configured type mappings. + + N + null + + + DefaultTypeName + + This is the default type name to use when one cannot be determined based on the column metadata + and the configured type mappings. + + N + null + + + NoDefaultFlags + + True - Do not combine the specified (or existing) connection flags with the value of the + property. +
+ False - Combine the specified (or existing) connection flags with the value of the + property. +
+ N + False +
+ + NoSharedFlags + + True - Do not combine the specified (or existing) connection flags with the value of the + property. +
+ False - Combine the specified (or existing) connection flags with the value of the + property. +
+ N + False +
+ + VfsName + + The name of the VFS to use when opening the database connection. + If this is not specified, the default VFS will be used. + + N + null + + + ZipVfsVersion + + If non-null, this is the "version" of ZipVFS to use. This requires + the System.Data.SQLite interop assembly -AND- primary managed assembly + to be compiled with the INTEROP_INCLUDE_ZIPVFS option; otherwise, this + property does nothing. The valid values are "v2" and "v3". Using + anyother value will cause an exception to be thrown. Please see the + ZipVFS documentation for more information on how to use this parameter. + + N + null + + + DateTimeFormat + + Ticks - Use the value of DateTime.Ticks.
+ ISO8601 - Use the ISO-8601 format. Uses the "yyyy-MM-dd HH:mm:ss.FFFFFFFK" format for UTC + DateTime values and "yyyy-MM-dd HH:mm:ss.FFFFFFF" format for local DateTime values).
+ JulianDay - The interval of time in days and fractions of a day since January 1, 4713 BC.
+ UnixEpoch - The whole number of seconds since the Unix epoch (January 1, 1970).
+ InvariantCulture - Any culture-independent string value that the .NET Framework can interpret as a valid DateTime.
+ CurrentCulture - Any string value that the .NET Framework can interpret as a valid DateTime using the current culture.
+ N + ISO8601 +
+ + DateTimeKind + + Unspecified - Not specified as either UTC or local time. +
+ Utc - The time represented is UTC. +
+ Local - The time represented is local time. +
+ N + Unspecified +
+ + DateTimeFormatString + + The exact DateTime format string to use for all formatting and parsing of all DateTime + values for this connection. + + N + null + + + BaseSchemaName + + Some base data classes in the framework (e.g. those that build SQL queries dynamically) + assume that an ADO.NET provider cannot support an alternate catalog (i.e. database) without supporting + alternate schemas as well; however, SQLite does not fit into this model. Therefore, this value is used + as a placeholder and removed prior to preparing any SQL statements that may contain it. + + N + sqlite_default_schema + + + BinaryGUID + + True - Store GUID columns in binary form +
+ False - Store GUID columns as text +
+ N + True +
+ + Cache Size + + If the argument N is positive then the suggested cache size is set to N. + If the argument N is negative, then the number of cache pages is adjusted + to use approximately abs(N*4096) bytes of memory. Backwards compatibility + note: The behavior of cache_size with a negative N was different in SQLite + versions prior to 3.7.10. In version 3.7.9 and earlier, the number of + pages in the cache was set to the absolute value of N. + + N + -2000 + + + Synchronous + + Normal - Normal file flushing behavior +
+ Full - Full flushing after all writes +
+ Off - Underlying OS flushes I/O's +
+ N + Full +
+ + Page Size + {size in bytes} + N + 4096 + + + Password + + {password} - Using this parameter requires that the legacy CryptoAPI based + codec (or the SQLite Encryption Extension) be enabled at compile-time for + both the native interop assembly and the core managed assemblies; otherwise, + using this parameter may result in an exception being thrown when attempting + to open the connection. + + N + + + + HexPassword + + {hexPassword} - Must contain a sequence of zero or more hexadecimal encoded + byte values without a leading "0x" prefix. Using this parameter requires + that the legacy CryptoAPI based codec (or the SQLite Encryption Extension) + be enabled at compile-time for both the native interop assembly and the + core managed assemblies; otherwise, using this parameter may result in an + exception being thrown when attempting to open the connection. + + N + + + + Enlist + + Y - Automatically enlist in distributed transactions +
+ N - No automatic enlistment +
+ N + Y +
+ + Pooling + + True - Use connection pooling.
+ False - Do not use connection pooling.

+ WARNING: When using the default connection pool implementation, + setting this property to True should be avoided by applications that make + use of COM (either directly or indirectly) due to possible deadlocks that + can occur during the finalization of some COM objects. +
+ N + False +
+ + FailIfMissing + + True - Don't create the database if it does not exist, throw an error instead +
+ False - Automatically create the database if it does not exist +
+ N + False +
+ + Max Page Count + {size in pages} - Limits the maximum number of pages (limits the size) of the database + N + 0 + + + Legacy Format + + True - Use the more compatible legacy 3.x database format +
+ False - Use the newer 3.3x database format which compresses numbers more effectively +
+ N + False +
+ + Default Timeout + {time in seconds}
The default command timeout
+ N + 30 +
+ + BusyTimeout + {time in milliseconds}
Sets the busy timeout for the core library.
+ N + 0 +
+ + WaitTimeout + {time in milliseconds}
+ EXPERIMENTAL -- The wait timeout to use with + method. This is only used when + waiting for the enlistment to be reset prior to enlisting in a transaction, + and then only when the appropriate connection flag is set.
+ N + 30000 +
+ + Journal Mode + + Delete - Delete the journal file after a commit. +
+ Persist - Zero out and leave the journal file on disk after a + commit. +
+ Off - Disable the rollback journal entirely. This saves disk I/O + but at the expense of database safety and integrity. If the application + using SQLite crashes in the middle of a transaction when this journaling + mode is set, then the database file will very likely go corrupt. +
+ Truncate - Truncate the journal file to zero-length instead of + deleting it. +
+ Memory - Store the journal in volatile RAM. This saves disk I/O + but at the expense of database safety and integrity. If the application + using SQLite crashes in the middle of a transaction when this journaling + mode is set, then the database file will very likely go corrupt. +
+ Wal - Use a write-ahead log instead of a rollback journal. +
+ N + Delete +
+ + Read Only + + True - Open the database for read only access +
+ False - Open the database for normal read/write access +
+ N + False +
+ + Max Pool Size + The maximum number of connections for the given connection string that can be in the connection pool + N + 100 + + + Default IsolationLevel + The default transaciton isolation level + N + Serializable + + + Foreign Keys + Enable foreign key constraints + N + False + + + Flags + Extra behavioral flags for the connection. See the enumeration for possible values. + N + Default + + + SetDefaults + + True - Apply the default connection settings to the opened database.
+ False - Skip applying the default connection settings to the opened database. +
+ N + True +
+ + ToFullPath + + True - Attempt to expand the data source file name to a fully qualified path before opening. +
+ False - Skip attempting to expand the data source file name to a fully qualified path before opening. +
+ N + True +
+ + PrepareRetries + + The maximum number of retries when preparing SQL to be executed. This + normally only applies to preparation errors resulting from the database + schema being changed. + + N + 3 + + + ProgressOps + + The approximate number of virtual machine instructions between progress + events. In order for progress events to actually fire, the event handler + must be added to the event as well. + + N + 0 + + + Recursive Triggers + + True - Enable the recursive trigger capability. + False - Disable the recursive trigger capability. + + N + False + +
+
+
+ + + The "invalid value" for the enumeration used + by the property. This constant is shared + by this class and the SQLiteConnectionStringBuilder class. + + + + + The default "stub" (i.e. placeholder) base schema name to use when + returning column schema information. Used as the initial value of + the BaseSchemaName property. This should start with "sqlite_*" + because those names are reserved for use by SQLite (i.e. they cannot + be confused with the names of user objects). + + + + + The managed assembly containing this type. + + + + + Object used to synchronize access to the static instance data + for this class. + + + + + Static variable to store the connection event handlers to call. + + + + + The extra connection flags to be used for all opened connections. + + + + + The instance (for this thread) that + had the most recent call to . + + + + + Used to hold the active library version number of SQLite. + + + + + State of the current connection + + + + + The connection string + + + + + Nesting level of the transactions open on the connection + + + + + Transaction counter for the connection. Currently, this is only used + to build SAVEPOINT names. + + + + + If this flag is non-zero, the method will have + no effect; however, the method will continue to + behave as normal. + + + + + If set, then the connection is currently being disposed. + + + + + The default isolation level for new transactions + + + + + This object is used with lock statements to synchronize access to the + field, below. + + + + + Whether or not the connection is enlisted in a distrubuted transaction + + + + + The per-connection mappings between type names and + values. These mappings override the corresponding global mappings. + + + + + The per-connection mappings between type names and optional callbacks + for parameter binding and value reading. + + + + + The base SQLite object to interop with + + + + + The database filename minus path and extension + + + + + The "stub" (i.e. placeholder) base schema name to use when returning + column schema information. + + + + + The extra behavioral flags for this connection, if any. See the + enumeration for a list of + possible values. + + + + + The cached values for all settings that have been fetched on behalf + of this connection. This cache may be cleared by calling the + method. + + + + + The default databse type for this connection. This value will only + be used if the + flag is set. + + + + + The default databse type name for this connection. This value will only + be used if the + flag is set. + + + + + The name of the VFS to be used when opening the database connection. + + + + + Default command timeout + + + + + The default busy timeout to use with the SQLite core library. This is + only used when opening a connection. + + + + + The default wait timeout to use with + method. This is only used when waiting for the enlistment to be reset + prior to enlisting in a transaction, and then only when the appropriate + connection flag is set. + + + + + The maximum number of retries when preparing SQL to be executed. This + normally only applies to preparation errors resulting from the database + schema being changed. + + + + + The approximate number of virtual machine instructions between progress + events. In order for progress events to actually fire, the event handler + must be added to the event as + well. This value will only be used when opening the database. + + + + + Non-zero if the built-in (i.e. framework provided) connection string + parser should be used when opening the connection. + + + + + This event is raised whenever the database is opened or closed. + + + + + Constructs a new SQLiteConnection object + + + Default constructor + + + + + Initializes the connection with the specified connection string. + + The connection string to use. + + + + Initializes the connection with a pre-existing native connection handle. + This constructor overload is intended to be used only by the private + method. + + + The native connection handle to use. + + + The file name corresponding to the native connection handle. + + + Non-zero if this instance owns the native connection handle and + should dispose of it when it is no longer needed. + + + + + Initializes the connection with the specified connection string. + + + The connection string to use. + + + Non-zero to parse the connection string using the built-in (i.e. + framework provided) parser when opening the connection. + + + + + Clones the settings and connection string from an existing connection. If the existing connection is already open, this + function will open its own connection, enumerate any attached databases of the original connection, and automatically + attach to them. + + The connection to copy the settings from. + + + + Attempts to lookup the native handle associated with the connection. An exception will + be thrown if this cannot be accomplished. + + + The connection associated with the desired native handle. + + + The native handle associated with the connection or if it + cannot be determined. + + + + + Raises the event. + + + The connection associated with this event. If this parameter is not + null and the specified connection cannot raise events, then the + registered event handlers will not be invoked. + + + A that contains the event data. + + + + + This event is raised when events related to the lifecycle of a + SQLiteConnection object occur. + + + + + This property is used to obtain or set the custom connection pool + implementation to use, if any. Setting this property to null will + cause the default connection pool implementation to be used. + + + + + Creates and returns a new managed database connection handle. This + method is intended to be used by implementations of the + interface only. In theory, it + could be used by other classes; however, that usage is not supported. + + + This must be a native database connection handle returned by the + SQLite core library and it must remain valid and open during the + entire duration of the calling method. + + + The new managed database connection handle or null if it cannot be + created. + + + + + Backs up the database, using the specified database connection as the + destination. + + The destination database connection. + The destination database name. + The source database name. + + The number of pages to copy at a time -OR- a negative value to copy all + pages. When a negative value is used, the + may never be invoked. + + + The method to invoke between each step of the backup process. This + parameter may be null (i.e. no callbacks will be performed). If the + callback returns false -OR- throws an exception, the backup is canceled. + + + The number of milliseconds to sleep after encountering a locking error + during the backup process. A value less than zero means that no sleep + should be performed. + + + + + Clears the per-connection cached settings. + + + The total number of per-connection settings cleared. + + + + + Queries and returns the value of the specified setting, using the + cached setting names and values for this connection, when available. + + + The name of the setting. + + + The value to be returned if the setting has not been set explicitly + or cannot be determined. + + + The value of the cached setting is stored here if found; otherwise, + the value of is stored here. + + + Non-zero if the cached setting was found; otherwise, zero. + + + + + Adds or sets the cached setting specified by + to the value specified by . + + + The name of the cached setting to add or replace. + + + The new value of the cached setting. + + + + + Clears the per-connection type mappings. + + + The total number of per-connection type mappings cleared. + + + + + Returns the per-connection type mappings. + + + The per-connection type mappings -OR- null if they are unavailable. + + + + + Adds a per-connection type mapping, possibly replacing one or more + that already exist. + + + The case-insensitive database type name (e.g. "MYDATE"). The value + of this parameter cannot be null. Using an empty string value (or + a string value consisting entirely of whitespace) for this parameter + is not recommended. + + + The value that should be associated with the + specified type name. + + + Non-zero if this mapping should be considered to be the primary one + for the specified . + + + A negative value if nothing was done. Zero if no per-connection type + mappings were replaced (i.e. it was a pure add operation). More than + zero if some per-connection type mappings were replaced. + + + + + Clears the per-connection type callbacks. + + + The total number of per-connection type callbacks cleared. + + + + + Attempts to get the per-connection type callbacks for the specified + database type name. + + + The database type name. + + + Upon success, this parameter will contain the object holding the + callbacks for the database type name. Upon failure, this parameter + will be null. + + + Non-zero upon success; otherwise, zero. + + + + + Sets, resets, or clears the per-connection type callbacks for the + specified database type name. + + + The database type name. + + + The object holding the callbacks for the database type name. If + this parameter is null, any callbacks for the database type name + will be removed if they are present. + + + Non-zero if callbacks were set or removed; otherwise, zero. + + + + + Attempts to bind the specified object + instance to this connection. + + + The object instance containing + the metadata for the function to be bound. + + + The object instance that implements the + function to be bound. + + + + + Attempts to bind the specified object + instance to this connection. + + + The object instance containing + the metadata for the function to be bound. + + + A object instance that helps implement the + function to be bound. For scalar functions, this corresponds to the + type. For aggregate functions, + this corresponds to the type. For + collation functions, this corresponds to the + type. + + + A object instance that helps implement the + function to be bound. For aggregate functions, this corresponds to the + type. For other callback types, it + is not used and must be null. + + + + + Attempts to unbind the specified object + instance to this connection. + + + The object instance containing + the metadata for the function to be unbound. + + Non-zero if the function was unbound. + + + + This method unbinds all registered (known) functions -OR- all previously + bound user-defined functions from this connection. + + + Non-zero to unbind all registered (known) functions -OR- zero to unbind + all functions currently bound to the connection. + + + Non-zero if all the specified user-defined functions were unbound. + + + + + Parses a connection string into component parts using the custom + connection string parser. An exception may be thrown if the syntax + of the connection string is incorrect. + + + The connection string to parse. + + + Non-zero to parse the connection string using the algorithm provided + by the framework itself. This is not applicable when running on the + .NET Compact Framework. + + + Non-zero if names are allowed without values. + + + The list of key/value pairs corresponding to the parameters specified + within the connection string. + + + + + Parses a connection string into component parts using the custom + connection string parser. An exception may be thrown if the syntax + of the connection string is incorrect. + + + The connection that will be using the parsed connection string. + + + The connection string to parse. + + + Non-zero to parse the connection string using the algorithm provided + by the framework itself. This is not applicable when running on the + .NET Compact Framework. + + + Non-zero if names are allowed without values. + + + The list of key/value pairs corresponding to the parameters specified + within the connection string. + + + + + Disposes and finalizes the connection, if applicable. + + + + + Cleans up resources (native and managed) associated with the current instance. + + + Zero when being disposed via garbage collection; otherwise, non-zero. + + + + + Creates a clone of the connection. All attached databases and user-defined functions are cloned. If the existing connection is open, the cloned connection + will also be opened. + + + + + + Creates a database file. This just creates a zero-byte file which SQLite + will turn into a database when the file is opened properly. + + The file to create + + + + Raises the state change event when the state of the connection changes + + The new connection state. If this is different + from the previous state, the event is + raised. + The event data created for the raised event, if + it was actually raised. + + + + Determines and returns the fallback default isolation level when one cannot be + obtained from an existing connection instance. + + + The fallback default isolation level for this connection instance -OR- + if it cannot be determined. + + + + + Determines and returns the default isolation level for this connection instance. + + + The default isolation level for this connection instance -OR- + if it cannot be determined. + + + + + OBSOLETE. Creates a new SQLiteTransaction if one isn't already active on the connection. + + This parameter is ignored. + When TRUE, SQLite defers obtaining a write lock until a write operation is requested. + When FALSE, a writelock is obtained immediately. The default is TRUE, but in a multi-threaded multi-writer + environment, one may instead choose to lock the database immediately to avoid any possible writer deadlock. + Returns a SQLiteTransaction object. + + + + OBSOLETE. Creates a new SQLiteTransaction if one isn't already active on the connection. + + When TRUE, SQLite defers obtaining a write lock until a write operation is requested. + When FALSE, a writelock is obtained immediately. The default is false, but in a multi-threaded multi-writer + environment, one may instead choose to lock the database immediately to avoid any possible writer deadlock. + Returns a SQLiteTransaction object. + + + + Creates a new if one isn't already active on the connection. + + Supported isolation levels are Serializable, ReadCommitted and Unspecified. + + Unspecified will use the default isolation level specified in the connection string. If no isolation level is specified in the + connection string, Serializable is used. + Serializable transactions are the default. In this mode, the engine gets an immediate lock on the database, and no other threads + may begin a transaction. Other threads may read from the database, but not write. + With a ReadCommitted isolation level, locks are deferred and elevated as needed. It is possible for multiple threads to start + a transaction in ReadCommitted mode, but if a thread attempts to commit a transaction while another thread + has a ReadCommitted lock, it may timeout or cause a deadlock on both threads until both threads' CommandTimeout's are reached. + + Returns a SQLiteTransaction object. + + + + Creates a new if one isn't already + active on the connection. + + Returns the new transaction object. + + + + Forwards to the local function + + Supported isolation levels are Unspecified, Serializable, and ReadCommitted + + + + + This method is not implemented; however, the + event will still be raised. + + - When the database connection is closed, all commands linked to this connection are automatically reset. + When the database connection is closed, all commands linked to this connection are automatically reset. + + + + + Returns the number of pool entries for the file name associated with this connection. + + + + + Clears the connection pool associated with the connection. Any other active connections using the same database file + will be discarded instead of returned to the pool when they are closed. + + + + + + Clears all connection pools. Any active connections will be discarded instead of sent to the pool when they are closed. + + + + + The connection string containing the parameters for the connection + + + For the complete list of supported connection string properties, + please see . + + + + + Create a new and associate it with this connection. + + Returns a new command object already assigned to this connection. + + + + Forwards to the local function. + + + + + + Attempts to create a new object instance + using this connection and the specified database name. + + + The name of the database for the newly created session. + + + The newly created session -OR- null if it cannot be created. + + + + + Attempts to create a new object instance + using this connection and the specified raw data. + + + The raw data that contains a change set (or patch set). + + + The newly created change set -OR- null if it cannot be created. + + + + + Attempts to create a new object instance + using this connection and the specified raw data. + + + The raw data that contains a change set (or patch set). + + + The flags used to create the change set iterator. + + + The newly created change set -OR- null if it cannot be created. + + + + + Attempts to create a new object instance + using this connection and the specified stream. + + + The stream where the raw data that contains a change set (or patch set) + may be read. + + + The stream where the raw data that contains a change set (or patch set) + may be written. + + + The newly created change set -OR- null if it cannot be created. + + + + + Attempts to create a new object instance + using this connection and the specified stream. + + + The stream where the raw data that contains a change set (or patch set) + may be read. + + + The stream where the raw data that contains a change set (or patch set) + may be written. + + + The flags used to create the change set iterator. + + + The newly created change set -OR- null if it cannot be created. + + + + + Attempts to create a new object + instance using this connection. + + + The newly created change group -OR- null if it cannot be created. + + + + + Returns the data source file name without extension or path. + + + + + Returns the fully qualified path and file name for the currently open + database, if any. + + + + + Returns the string "main". + + + + + Determines if the legacy connection string parser should be used. + + + The connection that will be using the parsed connection string. + + + Non-zero if the legacy connection string parser should be used. + + + + + Parses a connection string into component parts using the custom + connection string parser. An exception may be thrown if the syntax + of the connection string is incorrect. + + + The connection string to parse. + + + Non-zero if names are allowed without values. + + + The list of key/value pairs corresponding to the parameters specified + within the connection string. + + + + + Parses a connection string into component parts using the custom + connection string parser. An exception may be thrown if the syntax + of the connection string is incorrect. + + + The connection that will be using the parsed connection string. + + + The connection string to parse. + + + Non-zero if names are allowed without values. + + + The list of key/value pairs corresponding to the parameters specified + within the connection string. + + + + + Parses a connection string using the built-in (i.e. framework provided) + connection string parser class and returns the key/value pairs. An + exception may be thrown if the connection string is invalid or cannot be + parsed. When compiled for the .NET Compact Framework, the custom + connection string parser is always used instead because the framework + provided one is unavailable there. + + + The connection that will be using the parsed connection string. + + + The connection string to parse. + + + Non-zero to throw an exception if any connection string values are not of + the type. This is not applicable when running on + the .NET Compact Framework. + + The list of key/value pairs. + + + + Manual distributed transaction enlistment support + + The distributed transaction to enlist in + + + + EXPERIMENTAL -- + Waits for the enlistment associated with this connection to be reset. + This method always throws when + running on the .NET Compact Framework. + + + The approximate maximum number of milliseconds to wait before timing + out the wait operation. + + + The return value to use if the connection has been disposed; if this + value is null, will be raised + if the connection has been disposed. + + + Non-zero if the enlistment assciated with this connection was reset; + otherwise, zero. It should be noted that this method returning a + non-zero value does not necessarily guarantee that the connection + can enlist in a new transaction (i.e. due to potentical race with + other threads); therefore, callers should generally use try/catch + when calling the method. + + + + + Looks for a key in the array of key/values of the parameter string. If not found, return the specified default value + + The list to look in + The key to find + The default value to return if the key is not found + The value corresponding to the specified key, or the default value if not found. + + + + Attempts to convert the string value to an enumerated value of the specified type. + + The enumerated type to convert the string value to. + The string value to be converted. + Non-zero to make the conversion case-insensitive. + The enumerated value upon success or null upon error. + + + + Attempts to convert an input string into a byte value. + + + The string value to be converted. + + + The number styles to use for the conversion. + + + Upon sucess, this will contain the parsed byte value. + Upon failure, the value of this parameter is undefined. + + + Non-zero upon success; zero on failure. + + + + + Change a configuration option value for the database. + + + The database configuration option to change. + + + The new value for the specified configuration option. + + + + + Enables or disables extension loading. + + + True to enable loading of extensions, false to disable. + + + + + Loads a SQLite extension library from the named dynamic link library file. + + + The name of the dynamic link library file containing the extension. + + + + + Loads a SQLite extension library from the named dynamic link library file. + + + The name of the dynamic link library file containing the extension. + + + The name of the exported function used to initialize the extension. + If null, the default "sqlite3_extension_init" will be used. + + + + + Creates a disposable module containing the implementation of a virtual + table. + + + The module object to be used when creating the disposable module. + + + + + Parses a string containing a sequence of zero or more hexadecimal + encoded byte values and returns the resulting byte array. The + "0x" prefix is not allowed on the input string. + + + The input string containing zero or more hexadecimal encoded byte + values. + + + A byte array containing the parsed byte values or null if an error + was encountered. + + + + + Creates and returns a string containing the hexadecimal encoded byte + values from the input array. + + + The input array of bytes. + + + The resulting string or null upon failure. + + + + + Parses a string containing a sequence of zero or more hexadecimal + encoded byte values and returns the resulting byte array. The + "0x" prefix is not allowed on the input string. + + + The input string containing zero or more hexadecimal encoded byte + values. + + + Upon failure, this will contain an appropriate error message. + + + A byte array containing the parsed byte values or null if an error + was encountered. + + + + + This method figures out what the default connection pool setting should + be based on the connection flags. When present, the "Pooling" connection + string property value always overrides the value returned by this method. + + + Non-zero if the connection pool should be enabled by default; otherwise, + zero. + + + + + Determines the transaction isolation level that should be used by + the caller, primarily based upon the one specified by the caller. + If mapping of transaction isolation levels is enabled, the returned + transaction isolation level may be significantly different than the + originally specified one. + + + The originally specified transaction isolation level. + + + The transaction isolation level that should be used. + + + + + Opens the connection using the parameters found in the . + + + + + Opens the connection using the parameters found in the and then returns it. + + The current connection object. + + + + Gets/sets the default command timeout for newly-created commands. This is especially useful for + commands used internally such as inside a SQLiteTransaction, where setting the timeout is not possible. + This can also be set in the ConnectionString with "Default Timeout" + + + + + Gets/sets the default busy timeout to use with the SQLite core library. This is only used when + opening a connection. + + + + + EXPERIMENTAL -- + The wait timeout to use with method. + This is only used when waiting for the enlistment to be reset prior to + enlisting in a transaction, and then only when the appropriate connection + flag is set. + + + + + The maximum number of retries when preparing SQL to be executed. This + normally only applies to preparation errors resulting from the database + schema being changed. + + + + + The approximate number of virtual machine instructions between progress + events. In order for progress events to actually fire, the event handler + must be added to the event as + well. This value will only be used when the underlying native progress + callback needs to be changed. + + + + + Non-zero if the built-in (i.e. framework provided) connection string + parser should be used when opening the connection. + + + + + Gets/sets the extra behavioral flags for this connection. See the + enumeration for a list of + possible values. + + + + + Gets/sets the default database type for this connection. This value + will only be used when not null. + + + + + Gets/sets the default database type name for this connection. This + value will only be used when not null. + + + + + Gets/sets the VFS name for this connection. This value will only be + used when opening the database. + + + + + Returns non-zero if the underlying native connection handle is + owned by this instance. + + + + + Returns the version of the underlying SQLite database engine + + + + + Returns the rowid of the most recent successful INSERT into the database from this connection. + + + + + This method causes any pending database operation to abort and return at + its earliest opportunity. This routine is typically called in response + to a user action such as pressing "Cancel" or Ctrl-C where the user wants + a long query operation to halt immediately. It is safe to call this + routine from any thread. However, it is not safe to call this routine + with a database connection that is closed or might close before this method + returns. + + + + + Returns the number of rows changed by the last INSERT, UPDATE, or DELETE statement executed on + this connection. + + + + + Checks if this connection to the specified database should be considered + read-only. An exception will be thrown if the database name specified + via cannot be found. + + + The name of a database associated with this connection -OR- null for the + main database. + + + Non-zero if this connection to the specified database should be considered + read-only. + + + + + Returns non-zero if the given database connection is in autocommit mode. + Autocommit mode is on by default. Autocommit mode is disabled by a BEGIN + statement. Autocommit mode is re-enabled by a COMMIT or ROLLBACK. + + + + + Returns the amount of memory (in bytes) currently in use by the SQLite core library. + + + + + Returns the maximum amount of memory (in bytes) used by the SQLite core library since the high-water mark was last reset. + + + + + Returns various global memory statistics for the SQLite core library via + a dictionary of key/value pairs. Currently, only the "MemoryUsed" and + "MemoryHighwater" keys are returned and they have values that correspond + to the values that could be obtained via the + and connection properties. + + + This dictionary will be populated with the global memory statistics. It + will be created if necessary. + + + + + Attempts to free as much heap memory as possible for this database connection. + + + + + Attempts to free N bytes of heap memory by deallocating non-essential memory + allocations held by the database library. Memory used to cache database pages + to improve performance is an example of non-essential memory. This is a no-op + returning zero if the SQLite core library was not compiled with the compile-time + option SQLITE_ENABLE_MEMORY_MANAGEMENT. Optionally, attempts to reset and/or + compact the Win32 native heap, if applicable. + + + The requested number of bytes to free. + + + Non-zero to attempt a heap reset. + + + Non-zero to attempt heap compaction. + + + The number of bytes actually freed. This value may be zero. + + + This value will be non-zero if the heap reset was successful. + + + The size of the largest committed free block in the heap, in bytes. + This value will be zero unless heap compaction is enabled. + + + A standard SQLite return code (i.e. zero for success and non-zero + for failure). + + + + + Sets the status of the memory usage tracking subsystem in the SQLite core library. By default, this is enabled. + If this is disabled, memory usage tracking will not be performed. This is not really a per-connection value, it is + global to the process. + + Non-zero to enable memory usage tracking, zero otherwise. + A standard SQLite return code (i.e. zero for success and non-zero for failure). + + + + Returns a string containing the define constants (i.e. compile-time + options) used to compile the core managed assembly, delimited with + spaces. + + + + + Returns the version of the underlying SQLite core library. + + + + + This method returns the string whose value is the same as the + SQLITE_SOURCE_ID C preprocessor macro used when compiling the + SQLite core library. + + + + + Returns a string containing the compile-time options used to + compile the SQLite core native library, delimited with spaces. + + + + + This method returns the version of the interop SQLite assembly + used. If the SQLite interop assembly is not in use or the + necessary information cannot be obtained for any reason, a null + value may be returned. + + + + + This method returns the string whose value contains the unique + identifier for the source checkout used to build the interop + assembly. If the SQLite interop assembly is not in use or the + necessary information cannot be obtained for any reason, a null + value may be returned. + + + + + Returns a string containing the compile-time options used to + compile the SQLite interop assembly, delimited with spaces. + + + + + This method returns the version of the managed components used + to interact with the SQLite core library. If the necessary + information cannot be obtained for any reason, a null value may + be returned. + + + + + This method returns the string whose value contains the unique + identifier for the source checkout used to build the managed + components currently executing. If the necessary information + cannot be obtained for any reason, a null value may be returned. + + + + + Queries and returns the value of the specified setting, using the + cached setting names and values for the last connection that used + the method, when available. + + + The name of the setting. + + + The value to be returned if the setting has not been set explicitly + or cannot be determined. + + + The value of the cached setting is stored here if found; otherwise, + the value of is stored here. + + + Non-zero if the cached setting was found; otherwise, zero. + + + + + Adds or sets the cached setting specified by + to the value specified by using the cached + setting names and values for the last connection that used the + method, when available. + + + The name of the cached setting to add or replace. + + + The new value of the cached setting. + + + + + The default connection flags to be used for all opened connections + when they are not present in the connection string. + + + + + The extra connection flags to be used for all opened connections. + + + + + Returns the state of the connection. + + + + + Passes a shutdown request to the SQLite core library. Does not throw + an exception if the shutdown request fails. + + + A standard SQLite return code (i.e. zero for success and non-zero for + failure). + + + + + Passes a shutdown request to the SQLite core library. Throws an + exception if the shutdown request fails and the no-throw parameter + is non-zero. + + + Non-zero to reset the database and temporary directories to their + default values, which should be null for both. + + + When non-zero, throw an exception if the shutdown request fails. + + + + Enables or disables extended result codes returned by SQLite + + + Enables or disables extended result codes returned by SQLite + + + Enables or disables extended result codes returned by SQLite + + + Add a log message via the SQLite sqlite3_log interface. + + + Add a log message via the SQLite sqlite3_log interface. + + + + Queries or modifies the number of retries or the retry interval (in milliseconds) for + certain I/O operations that may fail due to anti-virus software. + + The number of times to retry the I/O operation. A negative value + will cause the current count to be queried and replace that negative value. + The number of milliseconds to wait before retrying the I/O + operation. This number is multiplied by the number of retry attempts so far to come + up with the final number of milliseconds to wait. A negative value will cause the + current interval to be queried and replace that negative value. + Zero for success, non-zero for error. + + + + Sets the chunk size for the primary file associated with this database + connection. + + + The new chunk size for the main database, in bytes. + + + Zero for success, non-zero for error. + + + + + Removes one set of surrounding single -OR- double quotes from the string + value and returns the resulting string value. If the string is null, empty, + or contains quotes that are not balanced, nothing is done and the original + string value will be returned. + + The string value to process. + + The string value, modified to remove one set of surrounding single -OR- + double quotes, if applicable. + + + + + Determines the directory to be used when dealing with the "|DataDirectory|" + macro in a database file name. + + + The directory to use in place of the "|DataDirectory|" macro -OR- null if it + cannot be determined. + + + + + Expand the filename of the data source, resolving the |DataDirectory| + macro as appropriate. + + The database filename to expand + + Non-zero if the returned file name should be converted to a full path + (except when using the .NET Compact Framework). + + The expanded path and filename of the filename + + + + The following commands are used to extract schema information out of the database. Valid schema types are: + + + MetaDataCollections + + + DataSourceInformation + + + Catalogs + + + Columns + + + ForeignKeys + + + Indexes + + + IndexColumns + + + Tables + + + Views + + + ViewColumns + + + + + Returns the MetaDataCollections schema + + A DataTable of the MetaDataCollections schema + + + + Returns schema information of the specified collection + + The schema collection to retrieve + A DataTable of the specified collection + + + + Retrieves schema information using the specified constraint(s) for the specified collection + + The collection to retrieve. + + The restrictions to impose. Typically, this may include: + + + restrictionValues element index + usage + + + 0 + The database (or catalog) name, if applicable. + + + 1 + The schema name. This is not used by this provider. + + + 2 + The table name, if applicable. + + + 3 + + Depends on . + When "IndexColumns", it is the index name; otherwise, it is the column name. + + + + 4 + + Depends on . + When "IndexColumns", it is the column name; otherwise, it is not used. + + + + + A DataTable of the specified collection + + + + Builds a MetaDataCollections schema datatable + + DataTable + + + + Builds a DataSourceInformation datatable + + DataTable + + + + Build a Columns schema + + The catalog (attached database) to query, can be null + The table to retrieve schema information for, can be null + The column to retrieve schema information for, can be null + DataTable + + + + Returns index information for the given database and catalog + + The catalog (attached database) to query, can be null + The name of the index to retrieve information for, can be null + The table to retrieve index information for, can be null + DataTable + + + + Retrieves table schema information for the database and catalog + + The catalog (attached database) to retrieve tables on + The table to retrieve, can be null + The table type, can be null + DataTable + + + + Retrieves view schema information for the database + + The catalog (attached database) to retrieve views on + The view name, can be null + DataTable + + + + Retrieves catalog (attached databases) schema information for the database + + The catalog to retrieve, can be null + DataTable + + + + Returns the base column information for indexes in a database + + The catalog to retrieve indexes for (can be null) + The table to restrict index information by (can be null) + The index to restrict index information by (can be null) + The source column to restrict index information by (can be null) + A DataTable containing the results + + + + Returns detailed column information for a specified view + + The catalog to retrieve columns for (can be null) + The view to restrict column information by (can be null) + The source column to restrict column information by (can be null) + A DataTable containing the results + + + + Retrieves foreign key information from the specified set of filters + + An optional catalog to restrict results on + An optional table to restrict results on + An optional foreign key name to restrict results on + A DataTable with the results of the query + + + + This event is raised periodically during long running queries. Changing + the value of the property will + determine if the operation in progress will continue or be interrupted. + For the entire duration of the event, the associated connection and + statement objects must not be modified, either directly or indirectly, by + the called code. + + + + + This event is raised whenever SQLite encounters an action covered by the + authorizer during query preparation. Changing the value of the + property will determine if + the specific action will be allowed, ignored, or denied. For the entire + duration of the event, the associated connection and statement objects + must not be modified, either directly or indirectly, by the called code. + + + + + This event is raised whenever SQLite makes an update/delete/insert into the database on + this connection. It only applies to the given connection. + + + + + This event is raised whenever SQLite is committing a transaction. + Return non-zero to trigger a rollback. + + + + + This event is raised whenever SQLite statement first begins executing on + this connection. It only applies to the given connection. + + + + + This event is raised whenever SQLite is rolling back a transaction. + + + + + Returns the instance. + + + + + The I/O file cache flushing behavior for the connection + + + + + Normal file flushing at critical sections of the code + + + + + Full file flushing after every write operation + + + + + Use the default operating system's file flushing, SQLite does not explicitly flush the file buffers after writing + + + + + Raised each time the number of virtual machine instructions is + approximately equal to the value of the + property. + + The connection performing the operation. + A that contains the + event data. + + + + Raised when authorization is required to perform an action contained + within a SQL query. + + The connection performing the action. + A that contains the + event data. + + + + Raised when a transaction is about to be committed. To roll back a transaction, set the + rollbackTrans boolean value to true. + + The connection committing the transaction + Event arguments on the transaction + + + + Raised when data is inserted, updated and deleted on a given connection + + The connection committing the transaction + The event parameters which triggered the event + + + + Raised when a statement first begins executing on a given connection + + The connection executing the statement + Event arguments of the trace + + + + Raised between each backup step. + + + The source database connection. + + + The source database name. + + + The destination database connection. + + + The destination database name. + + + The number of pages copied with each step. + + + The number of pages remaining to be copied. + + + The total number of pages in the source database. + + + Set to true if the operation needs to be retried due to database + locking issues; otherwise, set to false. + + + True to continue with the backup process or false to halt the backup + process, rolling back any changes that have been made so far. + + + + + The event data associated with progress reporting events. + + + + + The user-defined native data associated with this event. Currently, + this will always contain the value of . + + + + + The return code for the current call into the progress callback. + + + + + Constructs an instance of this class with default property values. + + + + + Constructs an instance of this class with specific property values. + + + The user-defined native data associated with this event. + + + The progress return code. + + + + + The data associated with a call into the authorizer. + + + + + The user-defined native data associated with this event. Currently, + this will always contain the value of . + + + + + The action code responsible for the current call into the authorizer. + + + + + The first string argument for the current call into the authorizer. + The exact value will vary based on the action code, see the + enumeration for possible + values. + + + + + The second string argument for the current call into the authorizer. + The exact value will vary based on the action code, see the + enumeration for possible + values. + + + + + The database name for the current call into the authorizer, if + applicable. + + + + + The name of the inner-most trigger or view that is responsible for + the access attempt or a null value if this access attempt is directly + from top-level SQL code. + + + + + The return code for the current call into the authorizer. + + + + + Constructs an instance of this class with default property values. + + + + + Constructs an instance of this class with specific property values. + + + The user-defined native data associated with this event. + + + The authorizer action code. + + + The first authorizer argument. + + + The second authorizer argument. + + + The database name, if applicable. + + + The name of the inner-most trigger or view that is responsible for + the access attempt or a null value if this access attempt is directly + from top-level SQL code. + + + The authorizer return code. + + + + + Whenever an update event is triggered on a connection, this enum will indicate + exactly what type of operation is being performed. + + + + + A row is being deleted from the given database and table + + + + + A row is being inserted into the table. + + + + + A row is being updated in the table. + + + + + Passed during an Update callback, these event arguments detail the type of update operation being performed + on the given connection. + + + + + The name of the database being updated (usually "main" but can be any attached or temporary database) + + + + + The name of the table being updated + + + + + The type of update being performed (insert/update/delete) + + + + + The RowId affected by this update. + + + + + Event arguments raised when a transaction is being committed + + + + + Set to true to abort the transaction and trigger a rollback + + + + + Passed during an Trace callback, these event arguments contain the UTF-8 rendering of the SQL statement text + + + + + SQL statement text as the statement first begins executing + + + + + This interface represents a custom connection pool implementation + usable by System.Data.SQLite. + + + + + Counts the number of pool entries matching the specified file name. + + + The file name to match or null to match all files. + + + The pool entry counts for each matching file. + + + The total number of connections successfully opened from any pool. + + + The total number of connections successfully closed from any pool. + + + The total number of pool entries for all matching files. + + + + + Disposes of all pooled connections associated with the specified + database file name. + + + The database file name. + + + + + Disposes of all pooled connections. + + + + + Adds a connection to the pool of those associated with the + specified database file name. + + + The database file name. + + + The database connection handle. + + + The connection pool version at the point the database connection + handle was received from the connection pool. This is also the + connection pool version that the database connection handle was + created under. + + + + + Removes a connection from the pool of those associated with the + specified database file name with the intent of using it to + interact with the database. + + + The database file name. + + + The new maximum size of the connection pool for the specified + database file name. + + + The connection pool version associated with the returned database + connection handle, if any. + + + The database connection handle associated with the specified + database file name or null if it cannot be obtained. + + + + + This default method implementations in this class should not be used by + applications that make use of COM (either directly or indirectly) due + to possible deadlocks that can occur during finalization of some COM + objects. + + + + + Keeps track of connections made on a specified file. The PoolVersion + dictates whether old objects get returned to the pool or discarded + when no longer in use. + + + + + The queue of weak references to the actual database connection + handles. + + + + + This pool version associated with the database connection + handles in this pool queue. + + + + + The maximum size of this pool queue. + + + + + Constructs a connection pool queue using the specified version + and maximum size. Normally, all the database connection + handles in this pool are associated with a single database file + name. + + + The initial pool version for this connection pool queue. + + + The initial maximum size for this connection pool queue. + + + + + This field is used to synchronize access to the private static data + in this class. + + + + + When this field is non-null, it will be used to provide the + implementation of all the connection pool methods; otherwise, + the default method implementations will be used. + + + + + The dictionary of connection pools, based on the normalized file + name of the SQLite database. + + + + + The default version number new pools will get. + + + + + The number of connections successfully opened from any pool. + This value is incremented by the Remove method. + + + + + The number of connections successfully closed from any pool. + This value is incremented by the Add method. + + + + + Counts the number of pool entries matching the specified file name. + + + The file name to match or null to match all files. + + + The pool entry counts for each matching file. + + + The total number of connections successfully opened from any pool. + + + The total number of connections successfully closed from any pool. + + + The total number of pool entries for all matching files. + + + + + Disposes of all pooled connections associated with the specified + database file name. + + + The database file name. + + + + + Disposes of all pooled connections. + + + + + Adds a connection to the pool of those associated with the + specified database file name. + + + The database file name. + + + The database connection handle. + + + The connection pool version at the point the database connection + handle was received from the connection pool. This is also the + connection pool version that the database connection handle was + created under. + + + + + Removes a connection from the pool of those associated with the + specified database file name with the intent of using it to + interact with the database. + + + The database file name. + + + The new maximum size of the connection pool for the specified + database file name. + + + The connection pool version associated with the returned database + connection handle, if any. + + + The database connection handle associated with the specified + database file name or null if it cannot be obtained. + + + + + This method is used to obtain a reference to the custom connection + pool implementation currently in use, if any. + + + The custom connection pool implementation or null if the default + connection pool implementation should be used. + + + + + This method is used to set the reference to the custom connection + pool implementation to use, if any. + + + The custom connection pool implementation to use or null if the + default connection pool implementation should be used. + + + + + We do not have to thread-lock anything in this function, because it + is only called by other functions above which already take the lock. + + + The pool queue to resize. + + + If a function intends to add to the pool, this is true, which + forces the resize to take one more than it needs from the pool. + + + + + SQLite implementation of DbConnectionStringBuilder. + + + + + Properties of this class + + + + + Constructs a new instance of the class + + + Default constructor + + + + + Constructs a new instance of the class using the specified connection string. + + The connection string to parse + + + + Private initializer, which assigns the connection string and resets the builder + + The connection string to assign + + + + Gets/Sets the default version of the SQLite engine to instantiate. Currently the only valid value is 3, indicating version 3 of the sqlite library. + + + + + Gets/Sets the synchronization mode (file flushing) of the connection string. Default is "Normal". + + + + + Gets/Sets the encoding for the connection string. The default is "False" which indicates UTF-8 encoding. + + + + + Gets/Sets whether or not to use connection pooling. The default is "False" + + + + + Gets/Sets whethor not to store GUID's in binary format. The default is True + which saves space in the database. + + + + + Gets/Sets the filename to open on the connection string. + + + + + An alternate to the data source property + + + + + An alternate to the data source property that uses the SQLite URI syntax. + + + + + Gets/sets the default command timeout for newly-created commands. This is especially useful for + commands used internally such as inside a SQLiteTransaction, where setting the timeout is not possible. + + + + + Gets/sets the busy timeout to use with the SQLite core library. + + + + + EXPERIMENTAL -- + The wait timeout to use with + method. + This is only used when waiting for the enlistment to be reset + prior to enlisting in a transaction, and then only when the + appropriate connection flag is set. + + + + + Gets/sets the maximum number of retries when preparing SQL to be executed. + This normally only applies to preparation errors resulting from the database + schema being changed. + + + + + Gets/sets the approximate number of virtual machine instructions between + progress events. In order for progress events to actually fire, the event + handler must be added to the event + as well. + + + + + Determines whether or not the connection will automatically participate + in the current distributed transaction (if one exists) + + + + + If set to true, will throw an exception if the database specified in the connection + string does not exist. If false, the database will be created automatically. + + + + + If enabled, uses the legacy 3.xx format for maximum compatibility, but results in larger + database sizes. + + + + + When enabled, the database will be opened for read-only access and writing will be disabled. + + + + + Gets/sets the database encryption password + + + + + Gets/sets the database encryption hexadecimal password + + + + + Gets/Sets the page size for the connection. + + + + + Gets/Sets the maximum number of pages the database may hold + + + + + Gets/Sets the cache size for the connection. + + + + + Gets/Sets the DateTime format for the connection. + + + + + Gets/Sets the DateTime kind for the connection. + + + + + Gets/sets the DateTime format string used for formatting + and parsing purposes. + + + + + Gets/Sets the placeholder base schema name used for + .NET Framework compatibility purposes. + + + + + Determines how SQLite handles the transaction journal file. + + + + + Sets the default isolation level for transactions on the connection. + + + + + Gets/sets the default database type for the connection. + + + + + Gets/sets the default type name for the connection. + + + + + Gets/sets the VFS name for the connection. + + + + + If enabled, use foreign key constraints + + + + + Enable or disable the recursive trigger capability. + + + + + If non-null, this is the version of ZipVFS to use. This requires the + System.Data.SQLite interop assembly -AND- primary managed assembly to + be compiled with the INTEROP_INCLUDE_ZIPVFS option; otherwise, this + property does nothing. + + + + + Gets/Sets the extra behavioral flags. + + + + + If enabled, apply the default connection settings to opened databases. + + + + + If enabled, attempt to resolve the provided data source file name to a + full path before opening. + + + + + If enabled, skip using the configured default connection flags. + + + + + If enabled, skip using the configured shared connection flags. + + + + + Helper function for retrieving values from the connectionstring + + The keyword to retrieve settings for + The resulting parameter value + Returns true if the value was found and returned + + + + Fallback method for MONO, which doesn't implement DbConnectionStringBuilder.GetProperties() + + The hashtable to fill with property descriptors + + + + This base class provides datatype conversion services for the SQLite provider. + + + + + This character is used to escape other characters, including itself, in + connection string property names and values. + + + + + This character can be used to wrap connection string property names and + values. Normally, it is optional; however, when used, it must be the + first -AND- last character of that connection string property name -OR- + value. + + + + + This character can be used to wrap connection string property names and + values. Normally, it is optional; however, when used, it must be the + first -AND- last character of that connection string property name -OR- + value. + + + + + The character is used to separate the name and value for a connection + string property. This character cannot be present in any connection + string property name. This character can be present in a connection + string property value; however, this should be avoided unless deemed + absolutely necessary. + + + + + This character is used to separate connection string properties. When + the "No_SQLiteConnectionNewParser" setting is enabled, this character + may not appear in connection string property names -OR- values. + + + + + These are the characters that are special to the connection string + parser. + + + + + The fallback default database type when one cannot be obtained from an + existing connection instance. + + + + + The fallback default database type name when one cannot be obtained from + an existing connection instance. + + + + + The value for the Unix epoch (e.g. January 1, 1970 at midnight, in UTC). + + + + + The value of the OLE Automation epoch represented as a Julian day. This + field cannot be removed as the test suite relies upon it. + + + + + The format string for DateTime values when using the InvariantCulture or CurrentCulture formats. + + + + + This is the minimum Julian Day value supported by this library + (148731163200000). + + + + + This is the maximum Julian Day value supported by this library + (464269060799000). + + + + + An array of ISO-8601 DateTime formats that we support parsing. + + + + + The internal default format for UTC DateTime values when converting + to a string. + + + + + The internal default format for local DateTime values when converting + to a string. + + + + + An UTF-8 Encoding instance, so we can convert strings to and from UTF-8 + + + + + The default DateTime format for this instance. + + + + + The default DateTimeKind for this instance. + + + + + The default DateTime format string for this instance. + + + + + Initializes the conversion class + + The default date/time format to use for this instance + The DateTimeKind to use. + The DateTime format string to use. + + + + Converts a string to a UTF-8 encoded byte array sized to include a null-terminating character. + + The string to convert to UTF-8 + A byte array containing the converted string plus an extra 0 terminating byte at the end of the array. + + + + Convert a DateTime to a UTF-8 encoded, zero-terminated byte array. + + + This function is a convenience function, which first calls ToString() on the DateTime, and then calls ToUTF8() with the + string result. + + The DateTime to convert. + The UTF-8 encoded string, including a 0 terminating byte at the end of the array. + + + + Converts a UTF-8 encoded IntPtr of the specified length into a .NET string + + The pointer to the memory where the UTF-8 string is encoded + The number of bytes to decode + A string containing the translated character(s) + + + + Converts a UTF-8 encoded IntPtr of the specified length into a .NET string + + The pointer to the memory where the UTF-8 string is encoded + The number of bytes to decode + A string containing the translated character(s) + + + + Checks if the specified is within the + supported range for a Julian Day value. + + + The Julian Day value to check. + + + Non-zero if the specified Julian Day value is in the supported + range; otherwise, zero. + + + + + Converts a Julian Day value from a to an + . + + + The Julian Day value to convert. + + + The resulting Julian Day value. + + + + + Converts a Julian Day value from an to a + . + + + The Julian Day value to convert. + + + The resulting Julian Day value. + + + + + Converts a Julian Day value to a . + This method was translated from the "computeYMD" function in the + "date.c" file belonging to the SQLite core library. + + + The Julian Day value to convert. + + + The value to return in the event that the + Julian Day is out of the supported range. If this value is null, + an exception will be thrown instead. + + + A value that contains the year, month, and + day values that are closest to the specified Julian Day value. + + + + + Converts a Julian Day value to a . + This method was translated from the "computeHMS" function in the + "date.c" file belonging to the SQLite core library. + + + The Julian Day value to convert. + + + The value to return in the event that the + Julian Day value is out of the supported range. If this value is + null, an exception will be thrown instead. + + + A value that contains the hour, minute, and + second, and millisecond values that are closest to the specified + Julian Day value. + + + + + Converts a to a Julian Day value. + This method was translated from the "computeJD" function in + the "date.c" file belonging to the SQLite core library. + Since the range of Julian Day values supported by this method + includes all possible (valid) values of a + value, it should be extremely difficult for this method to + raise an exception or return an undefined result. + + + The value to convert. This value + will be within the range of + (00:00:00.0000000, January 1, 0001) to + (23:59:59.9999999, December + 31, 9999). + + + The nearest Julian Day value corresponding to the specified + value. + + + + + Converts a string into a DateTime, using the DateTimeFormat, DateTimeKind, + and DateTimeFormatString specified for the connection when it was opened. + + + Acceptable ISO8601 DateTime formats are: + + THHmmssK + THHmmK + HH:mm:ss.FFFFFFFK + HH:mm:ssK + HH:mmK + yyyy-MM-dd HH:mm:ss.FFFFFFFK + yyyy-MM-dd HH:mm:ssK + yyyy-MM-dd HH:mmK + yyyy-MM-ddTHH:mm:ss.FFFFFFFK + yyyy-MM-ddTHH:mmK + yyyy-MM-ddTHH:mm:ssK + yyyyMMddHHmmssK + yyyyMMddHHmmK + yyyyMMddTHHmmssFFFFFFFK + THHmmss + THHmm + HH:mm:ss.FFFFFFF + HH:mm:ss + HH:mm + yyyy-MM-dd HH:mm:ss.FFFFFFF + yyyy-MM-dd HH:mm:ss + yyyy-MM-dd HH:mm + yyyy-MM-ddTHH:mm:ss.FFFFFFF + yyyy-MM-ddTHH:mm + yyyy-MM-ddTHH:mm:ss + yyyyMMddHHmmss + yyyyMMddHHmm + yyyyMMddTHHmmssFFFFFFF + yyyy-MM-dd + yyyyMMdd + yy-MM-dd + + If the string cannot be matched to one of the above formats -OR- + the DateTimeFormatString if one was provided, an exception will + be thrown. + + The string containing either a long integer number of 100-nanosecond units since + System.DateTime.MinValue, a Julian day double, an integer number of seconds since the Unix epoch, a + culture-independent formatted date and time string, a formatted date and time string in the current + culture, or an ISO8601-format string. + A DateTime value + + + + Converts a string into a DateTime, using the specified DateTimeFormat, + DateTimeKind and DateTimeFormatString. + + + Acceptable ISO8601 DateTime formats are: + + THHmmssK + THHmmK + HH:mm:ss.FFFFFFFK + HH:mm:ssK + HH:mmK + yyyy-MM-dd HH:mm:ss.FFFFFFFK + yyyy-MM-dd HH:mm:ssK + yyyy-MM-dd HH:mmK + yyyy-MM-ddTHH:mm:ss.FFFFFFFK + yyyy-MM-ddTHH:mmK + yyyy-MM-ddTHH:mm:ssK + yyyyMMddHHmmssK + yyyyMMddHHmmK + yyyyMMddTHHmmssFFFFFFFK + THHmmss + THHmm + HH:mm:ss.FFFFFFF + HH:mm:ss + HH:mm + yyyy-MM-dd HH:mm:ss.FFFFFFF + yyyy-MM-dd HH:mm:ss + yyyy-MM-dd HH:mm + yyyy-MM-ddTHH:mm:ss.FFFFFFF + yyyy-MM-ddTHH:mm + yyyy-MM-ddTHH:mm:ss + yyyyMMddHHmmss + yyyyMMddHHmm + yyyyMMddTHHmmssFFFFFFF + yyyy-MM-dd + yyyyMMdd + yy-MM-dd + + If the string cannot be matched to one of the above formats -OR- + the DateTimeFormatString if one was provided, an exception will + be thrown. + + The string containing either a long integer number of 100-nanosecond units since + System.DateTime.MinValue, a Julian day double, an integer number of seconds since the Unix epoch, a + culture-independent formatted date and time string, a formatted date and time string in the current + culture, or an ISO8601-format string. + The SQLiteDateFormats to use. + The DateTimeKind to use. + The DateTime format string to use. + A DateTime value + + + + Converts a julianday value into a DateTime + + The value to convert + A .NET DateTime + + + + Converts a julianday value into a DateTime + + The value to convert + The DateTimeKind to use. + A .NET DateTime + + + + Converts the specified number of seconds from the Unix epoch into a + value. + + + The number of whole seconds since the Unix epoch. + + + Either Utc or Local time. + + + The new value. + + + + + Converts the specified number of ticks since the epoch into a + value. + + + The number of whole ticks since the epoch. + + + Either Utc or Local time. + + + The new value. + + + + + Converts a DateTime struct to a JulianDay double + + The DateTime to convert + The JulianDay value the Datetime represents + + + + Converts a DateTime struct to the whole number of seconds since the + Unix epoch. + + The DateTime to convert + The whole number of seconds since the Unix epoch + + + + Returns the DateTime format string to use for the specified DateTimeKind. + If is not null, it will be returned verbatim. + + The DateTimeKind to use. + The DateTime format string to use. + + The DateTime format string to use for the specified DateTimeKind. + + + + + Converts a string into a DateTime, using the DateTimeFormat, DateTimeKind, + and DateTimeFormatString specified for the connection when it was opened. + + The DateTime value to convert + Either a string containing the long integer number of 100-nanosecond units since System.DateTime.MinValue, a + Julian day double, an integer number of seconds since the Unix epoch, a culture-independent formatted date and time + string, a formatted date and time string in the current culture, or an ISO8601-format date/time string. + + + + Converts a string into a DateTime, using the DateTimeFormat, DateTimeKind, + and DateTimeFormatString specified for the connection when it was opened. + + The DateTime value to convert + The SQLiteDateFormats to use. + The DateTimeKind to use. + The DateTime format string to use. + Either a string containing the long integer number of 100-nanosecond units since System.DateTime.MinValue, a + Julian day double, an integer number of seconds since the Unix epoch, a culture-independent formatted date and time + string, a formatted date and time string in the current culture, or an ISO8601-format date/time string. + + + + Internal function to convert a UTF-8 encoded IntPtr of the specified length to a DateTime. + + + This is a convenience function, which first calls ToString() on the IntPtr to convert it to a string, then calls + ToDateTime() on the string to return a DateTime. + + A pointer to the UTF-8 encoded string + The length in bytes of the string + The parsed DateTime value + + + + Smart method of splitting a string. Skips quoted elements, removes the quotes. + + + This split function works somewhat like the String.Split() function in that it breaks apart a string into + pieces and returns the pieces as an array. The primary differences are: + + Only one character can be provided as a separator character + Quoted text inside the string is skipped over when searching for the separator, and the quotes are removed. + + Thus, if splitting the following string looking for a comma:
+ One,Two, "Three, Four", Five
+
+ The resulting array would contain
+ [0] One
+ [1] Two
+ [2] Three, Four
+ [3] Five
+
+ Note that the leading and trailing spaces were removed from each item during the split. +
+ Source string to split apart + Separator character + A string array of the split up elements +
+ + + Splits the specified string into multiple strings based on a separator + and returns the result as an array of strings. + + + The string to split into pieces based on the separator character. If + this string is null, null will always be returned. If this string is + empty, an array of zero strings will always be returned. + + + The character used to divide the original string into sub-strings. + This character cannot be a backslash or a double-quote; otherwise, no + work will be performed and null will be returned. + + + If this parameter is non-zero, all double-quote characters will be + retained in the returned list of strings; otherwise, they will be + dropped. + + + Upon failure, this parameter will be modified to contain an appropriate + error message. + + + The new array of strings or null if the input string is null -OR- the + separator character is a backslash or a double-quote -OR- the string + contains an unbalanced backslash or double-quote character. + + + + + Queries and returns the string representation for an object, using the + specified (or current) format provider. + + + The object instance to return the string representation for. + + + The format provider to use -OR- null if the current format provider for + the thread should be used instead. + + + The string representation for the object instance -OR- null if the + object instance is also null. + + + + + Attempts to convert an arbitrary object to the Boolean data type. + Null object values are converted to false. Throws an exception + upon failure. + + + The object value to convert. + + + The format provider to use. + + + If non-zero, a string value will be converted using the + + method; otherwise, the + method will be used. + + + The converted boolean value. + + + + + Convert a value to true or false. + + A string or number representing true or false + + + + + Converts an integer to a string that can be round-tripped using the + invariant culture. + + + The integer value to return the string representation for. + + + The string representation of the specified integer value, using the + invariant culture. + + + + + Attempts to convert a into a . + + + The to convert, cannot be null. + + + The converted value. + + + The supported strings are "yes", "no", "y", "n", "on", "off", "0", "1", + as well as any prefix of the strings + and . All strings are treated in a + case-insensitive manner. + + + + + Converts a SQLiteType to a .NET Type object + + The SQLiteType to convert + Returns a .NET Type object + + + + For a given intrinsic type, return a DbType + + The native type to convert + The corresponding (closest match) DbType + + + + Returns the ColumnSize for the given DbType + + The DbType to get the size of + + + + + Determines the default database type name to be used when a + per-connection value is not available. + + + The connection context for type mappings, if any. + + + The default database type name to use. + + + + + If applicable, issues a trace log message warning about falling back to + the default database type name. + + + The database value type. + + + The flags associated with the parent connection object. + + + The textual name of the database type. + + + + + If applicable, issues a trace log message warning about falling back to + the default database value type. + + + The textual name of the database type. + + + The flags associated with the parent connection object. + + + The database value type. + + + + + For a given database value type, return the "closest-match" textual database type name. + + The connection context for custom type mappings, if any. + The database value type. + The flags associated with the parent connection object. + The type name or an empty string if it cannot be determined. + + + + Convert a DbType to a Type + + The DbType to convert from + The closest-match .NET type + + + + For a given type, return the closest-match SQLite TypeAffinity, which only understands a very limited subset of types. + + The type to evaluate + The flags associated with the connection. + The SQLite type affinity for that type. + + + + Builds and returns a map containing the database column types + recognized by this provider. + + + A map containing the database column types recognized by this + provider. + + + + + Determines if a database type is considered to be a string. + + + The database type to check. + + + Non-zero if the database type is considered to be a string, zero + otherwise. + + + + + Determines and returns the runtime configuration setting string that + should be used in place of the specified object value. + + + The object value to convert to a string. + + + Either the string to use in place of the object value -OR- null if it + cannot be determined. + + + + + Determines the default value to be used when a + per-connection value is not available. + + + The connection context for type mappings, if any. + + + The default value to use. + + + + + Converts the object value, which is assumed to have originated + from a , to a string value. + + + The value to be converted to a string. + + + A null value will be returned if the original value is null -OR- + the original value is . Otherwise, + the original value will be converted to a string, using its + (possibly overridden) method and + then returned. + + + + + Determines if the specified textual value appears to be a + value. + + + The textual value to inspect. + + + Non-zero if the text looks like a value, + zero otherwise. + + + + + Determines if the specified textual value appears to be an + value. + + + The textual value to inspect. + + + Non-zero if the text looks like an value, + zero otherwise. + + + + + Determines if the specified textual value appears to be a + value. + + + The textual value to inspect. + + + Non-zero if the text looks like a value, + zero otherwise. + + + + + Determines if the specified textual value appears to be a + value. + + + The object instance configured with + the chosen format. + + + The textual value to inspect. + + + Non-zero if the text looks like a in the + configured format, zero otherwise. + + + + + For a given textual database type name, return the "closest-match" database type. + This method is called during query result processing; therefore, its performance + is critical. + + The connection context for custom type mappings, if any. + The textual name of the database type to match. + The flags associated with the parent connection object. + The .NET DBType the text evaluates to. + + + + SQLite has very limited types, and is inherently text-based. The first 5 types below represent the sum of all types SQLite + understands. The DateTime extension to the spec is for internal use only. + + + + + Not used + + + + + All integers in SQLite default to Int64 + + + + + All floating point numbers in SQLite default to double + + + + + The default data type of SQLite is text + + + + + Typically blob types are only seen when returned from a function + + + + + Null types can be returned from functions + + + + + Used internally by this provider + + + + + Used internally by this provider + + + + + These are the event types associated with the + + delegate (and its corresponding event) and the + class. + + + + + Not used. + + + + + Not used. + + + + + The connection is being opened. + + + + + The connection string has been parsed. + + + + + The connection was opened. + + + + + The method was called on the + connection. + + + + + A transaction was created using the connection. + + + + + The connection was enlisted into a transaction. + + + + + A command was created using the connection. + + + + + A data reader was created using the connection. + + + + + An instance of a derived class has + been created to wrap a native resource. + + + + + The connection is being closed. + + + + + The connection was closed. + + + + + A command is being disposed. + + + + + A data reader is being disposed. + + + + + A data reader is being closed. + + + + + A native resource was opened (i.e. obtained) from the pool. + + + + + A native resource was closed (i.e. released) to the pool. + + + + + This implementation of SQLite for ADO.NET can process date/time fields in + databases in one of six formats. + + + ISO8601 format is more compatible, readable, fully-processable, but less + accurate as it does not provide time down to fractions of a second. + JulianDay is the numeric format the SQLite uses internally and is arguably + the most compatible with 3rd party tools. It is not readable as text + without post-processing. Ticks less compatible with 3rd party tools that + query the database, and renders the DateTime field unreadable as text + without post-processing. UnixEpoch is more compatible with Unix systems. + InvariantCulture allows the configured format for the invariant culture + format to be used and is human readable. CurrentCulture allows the + configured format for the current culture to be used and is also human + readable. + + The preferred order of choosing a DateTime format is JulianDay, ISO8601, + and then Ticks. Ticks is mainly present for legacy code support. + + + + + Use the value of DateTime.Ticks. This value is not recommended and is not well supported with LINQ. + + + + + Use the ISO-8601 format. Uses the "yyyy-MM-dd HH:mm:ss.FFFFFFFK" format for UTC DateTime values and + "yyyy-MM-dd HH:mm:ss.FFFFFFF" format for local DateTime values). + + + + + The interval of time in days and fractions of a day since January 1, 4713 BC. + + + + + The whole number of seconds since the Unix epoch (January 1, 1970). + + + + + Any culture-independent string value that the .NET Framework can interpret as a valid DateTime. + + + + + Any string value that the .NET Framework can interpret as a valid DateTime using the current culture. + + + + + The default format for this provider. + + + + + This enum determines how SQLite treats its journal file. + + + By default SQLite will create and delete the journal file when needed during a transaction. + However, for some computers running certain filesystem monitoring tools, the rapid + creation and deletion of the journal file can cause those programs to fail, or to interfere with SQLite. + + If a program or virus scanner is interfering with SQLite's journal file, you may receive errors like "unable to open database file" + when starting a transaction. If this is happening, you may want to change the default journal mode to Persist. + + + + + The default mode, this causes SQLite to use the existing journaling mode for the database. + + + + + SQLite will create and destroy the journal file as-needed. + + + + + When this is set, SQLite will keep the journal file even after a transaction has completed. It's contents will be erased, + and the journal re-used as often as needed. If it is deleted, it will be recreated the next time it is needed. + + + + + This option disables the rollback journal entirely. Interrupted transactions or a program crash can cause database + corruption in this mode! + + + + + SQLite will truncate the journal file to zero-length instead of deleting it. + + + + + SQLite will store the journal in volatile RAM. This saves disk I/O but at the expense of database safety and integrity. + If the application using SQLite crashes in the middle of a transaction when the MEMORY journaling mode is set, then the + database file will very likely go corrupt. + + + + + SQLite uses a write-ahead log instead of a rollback journal to implement transactions. The WAL journaling mode is persistent; + after being set it stays in effect across multiple database connections and after closing and reopening the database. A database + in WAL journaling mode can only be accessed by SQLite version 3.7.0 or later. + + + + + Possible values for the "synchronous" database setting. This setting determines + how often the database engine calls the xSync method of the VFS. + + + + + Use the default "synchronous" database setting. Currently, this should be + the same as using the FULL mode. + + + + + The database engine continues without syncing as soon as it has handed + data off to the operating system. If the application running SQLite + crashes, the data will be safe, but the database might become corrupted + if the operating system crashes or the computer loses power before that + data has been written to the disk surface. + + + + + The database engine will still sync at the most critical moments, but + less often than in FULL mode. There is a very small (though non-zero) + chance that a power failure at just the wrong time could corrupt the + database in NORMAL mode. + + + + + The database engine will use the xSync method of the VFS to ensure that + all content is safely written to the disk surface prior to continuing. + This ensures that an operating system crash or power failure will not + corrupt the database. FULL synchronous is very safe, but it is also + slower. + + + + + The requested command execution type. This controls which method of the + object will be called. + + + + + Do nothing. No method will be called. + + + + + The command is not expected to return a result -OR- the result is not + needed. The or + method + will be called. + + + + + The command is expected to return a scalar result -OR- the result should + be limited to a scalar result. The + or method will + be called. + + + + + The command is expected to return result. + The or + method will + be called. + + + + + Use the default command execution type. Using this value is the same + as using the value. + + + + + The action code responsible for the current call into the authorizer. + + + + + No action is being performed. This value should not be used from + external code. + + + + + No longer used. + + + + + An index will be created. The action-specific arguments are the + index name and the table name. + + + + + + A table will be created. The action-specific arguments are the + table name and a null value. + + + + + A temporary index will be created. The action-specific arguments + are the index name and the table name. + + + + + A temporary table will be created. The action-specific arguments + are the table name and a null value. + + + + + A temporary trigger will be created. The action-specific arguments + are the trigger name and the table name. + + + + + A temporary view will be created. The action-specific arguments are + the view name and a null value. + + + + + A trigger will be created. The action-specific arguments are the + trigger name and the table name. + + + + + A view will be created. The action-specific arguments are the view + name and a null value. + + + + + A DELETE statement will be executed. The action-specific arguments + are the table name and a null value. + + + + + An index will be dropped. The action-specific arguments are the + index name and the table name. + + + + + A table will be dropped. The action-specific arguments are the tables + name and a null value. + + + + + A temporary index will be dropped. The action-specific arguments are + the index name and the table name. + + + + + A temporary table will be dropped. The action-specific arguments are + the table name and a null value. + + + + + A temporary trigger will be dropped. The action-specific arguments + are the trigger name and the table name. + + + + + A temporary view will be dropped. The action-specific arguments are + the view name and a null value. + + + + + A trigger will be dropped. The action-specific arguments are the + trigger name and the table name. + + + + + A view will be dropped. The action-specific arguments are the view + name and a null value. + + + + + An INSERT statement will be executed. The action-specific arguments + are the table name and a null value. + + + + + A PRAGMA statement will be executed. The action-specific arguments + are the name of the PRAGMA and the new value or a null value. + + + + + A table column will be read. The action-specific arguments are the + table name and the column name. + + + + + A SELECT statement will be executed. The action-specific arguments + are both null values. + + + + + A transaction will be started, committed, or rolled back. The + action-specific arguments are the name of the operation (BEGIN, + COMMIT, or ROLLBACK) and a null value. + + + + + An UPDATE statement will be executed. The action-specific arguments + are the table name and the column name. + + + + + A database will be attached to the connection. The action-specific + arguments are the database file name and a null value. + + + + + A database will be detached from the connection. The action-specific + arguments are the database name and a null value. + + + + + The schema of a table will be altered. The action-specific arguments + are the database name and the table name. + + + + + An index will be deleted and then recreated. The action-specific + arguments are the index name and a null value. + + + + + A table will be analyzed to gathers statistics about it. The + action-specific arguments are the table name and a null value. + + + + + A virtual table will be created. The action-specific arguments are + the table name and the module name. + + + + + A virtual table will be dropped. The action-specific arguments are + the table name and the module name. + + + + + A SQL function will be called. The action-specific arguments are a + null value and the function name. + + + + + A savepoint will be created, released, or rolled back. The + action-specific arguments are the name of the operation (BEGIN, + RELEASE, or ROLLBACK) and the savepoint name. + + + + + A recursive query will be executed. The action-specific arguments + are two null values. + + + + + The possible return codes for the progress callback. + + + + + The operation should continue. + + + + + The operation should be interrupted. + + + + + The return code for the current call into the authorizer. + + + + + The action will be allowed. + + + + + The overall action will be disallowed and an error message will be + returned from the query preparation method. + + + + + The specific action will be disallowed; however, the overall action + will continue. The exact effects of this return code vary depending + on the specific action, please refer to the SQLite core library + documentation for futher details. + + + + + Class used internally to determine the datatype of a column in a resultset + + + + + The DbType of the column, or DbType.Object if it cannot be determined + + + + + The affinity of a column, used for expressions or when Type is DbType.Object + + + + + Constructs a default instance of this type. + + + + + Constructs an instance of this type with the specified field values. + + + The type affinity to use for the new instance. + + + The database type to use for the new instance. + + + + + SQLite implementation of DbDataAdapter. + + + + + This class is just a shell around the DbDataAdapter. Nothing from + DbDataAdapter is overridden here, just a few constructors are defined. + + + Default constructor. + + + + + Constructs a data adapter using the specified select command. + + + The select command to associate with the adapter. + + + + + Constructs a data adapter with the supplied select command text and + associated with the specified connection. + + + The select command text to associate with the data adapter. + + + The connection to associate with the select command. + + + + + Constructs a data adapter with the specified select command text, + and using the specified database connection string. + + + The select command text to use to construct a select command. + + + A connection string suitable for passing to a new SQLiteConnection, + which is associated with the select command. + + + + + Constructs a data adapter with the specified select command text, + and using the specified database connection string. + + + The select command text to use to construct a select command. + + + A connection string suitable for passing to a new SQLiteConnection, + which is associated with the select command. + + + Non-zero to parse the connection string using the built-in (i.e. + framework provided) parser when opening the connection. + + + + + Cleans up resources (native and managed) associated with the current instance. + + + Zero when being disposed via garbage collection; otherwise, non-zero. + + + + + Row updating event handler + + + + + Row updated event handler + + + + + Raised by the underlying DbDataAdapter when a row is being updated + + The event's specifics + + + + Raised by DbDataAdapter after a row is updated + + The event's specifics + + + + Gets/sets the select command for this DataAdapter + + + + + Gets/sets the insert command for this DataAdapter + + + + + Gets/sets the update command for this DataAdapter + + + + + Gets/sets the delete command for this DataAdapter + + + + + SQLite implementation of DbDataReader. + + + + + Underlying command this reader is attached to + + + + + The flags pertaining to the associated connection (via the command). + + + + + Index of the current statement in the command being processed + + + + + Current statement being Read() + + + + + State of the current statement being processed. + -1 = First Step() executed, so the first Read() will be ignored + 0 = Actively reading + 1 = Finished reading + 2 = Non-row-returning statement, no records + + + + + Number of records affected by the insert/update statements executed on the command + + + + + Count of fields (columns) in the row-returning statement currently being processed + + + + + The number of calls to Step() that have returned true (i.e. the number of rows that + have been read in the current result set). + + + + + Maps the field (column) names to their corresponding indexes within the results. + + + + + Datatypes of active fields (columns) in the current statement, used for type-restricting data + + + + + The behavior of the datareader + + + + + If set, then dispose of the command object when the reader is finished + + + + + If set, then raise an exception when the object is accessed after being disposed. + + + + + An array of rowid's for the active statement if CommandBehavior.KeyInfo is specified + + + + + Matches the version of the connection. + + + + + The "stub" (i.e. placeholder) base schema name to use when returning + column schema information. Matches the base schema name used by the + associated connection. + + + + + Internal constructor, initializes the datareader and sets up to begin executing statements + + The SQLiteCommand this data reader is for + The expected behavior of the data reader + + + + Dispose of all resources used by this datareader. + + + + + + Closes the datareader, potentially closing the connection as well if CommandBehavior.CloseConnection was specified. + + + + + Throw an error if the datareader is closed + + + + + Throw an error if a row is not loaded + + + + + Enumerator support + + Returns a DbEnumerator object. + + + + Not implemented. Returns 0 + + + + + Returns the number of columns in the current resultset + + + + + Forces the connection flags cached by this data reader to be refreshed + from the underlying connection. + + + + + Returns the number of rows seen so far in the current result set. + + + + + Returns the number of visible fields in the current resultset + + + + + This method is used to make sure the result set is open and a row is currently available. + + + + + SQLite is inherently un-typed. All datatypes in SQLite are natively strings. The definition of the columns of a table + and the affinity of returned types are all we have to go on to type-restrict data in the reader. + + This function attempts to verify that the type of data being requested of a column matches the datatype of the column. In + the case of columns that are not backed into a table definition, we attempt to match up the affinity of a column (int, double, string or blob) + to a set of known types that closely match that affinity. It's not an exact science, but its the best we can do. + + + This function throws an InvalidTypeCast() exception if the requested type doesn't match the column's definition or affinity. + + The index of the column to type-check + The type we want to get out of the column + + + + Invokes the data reader value callback configured for the database + type name associated with the specified column. If no data reader + value callback is available for the database type name, do nothing. + + + The index of the column being read. + + + The extra event data to pass into the callback. + + + Non-zero if the default handling for the data reader call should be + skipped. If this is set to non-zero and the necessary return value + is unavailable or unsuitable, an exception will be thrown. + + + + + Attempts to query the integer identifier for the current row. This + will not work for tables that were created WITHOUT ROWID -OR- if the + query does not include the "rowid" column or one of its aliases -OR- + if the was not created with the + flag. + + + The index of the BLOB column. + + + The integer identifier for the current row -OR- null if it could not + be determined. + + + + + Retrieves the column as a object. + This will not work for tables that were created WITHOUT ROWID + -OR- if the query does not include the "rowid" column or one + of its aliases -OR- if the was + not created with the + flag. + + The index of the column. + + Non-zero to open the blob object for read-only access. + + A new object. + + + + Retrieves the column as a boolean value + + The index of the column. + bool + + + + Retrieves the column as a single byte value + + The index of the column. + byte + + + + Retrieves a column as an array of bytes (blob) + + The index of the column. + The zero-based index of where to begin reading the data + The buffer to write the bytes into + The zero-based index of where to begin writing into the array + The number of bytes to retrieve + The actual number of bytes written into the array + + To determine the number of bytes in the column, pass a null value for the buffer. The total length will be returned. + + + + + Returns the column as a single character + + The index of the column. + char + + + + Retrieves a column as an array of chars (blob) + + The index of the column. + The zero-based index of where to begin reading the data + The buffer to write the characters into + The zero-based index of where to begin writing into the array + The number of bytes to retrieve + The actual number of characters written into the array + + To determine the number of characters in the column, pass a null value for the buffer. The total length will be returned. + + + + + Retrieves the name of the back-end datatype of the column + + The index of the column. + string + + + + Retrieve the column as a date/time value + + The index of the column. + DateTime + + + + Retrieve the column as a decimal value + + The index of the column. + decimal + + + + Returns the column as a double + + The index of the column. + double + + + + Determines and returns the of the + specified column. + + + The index of the column. + + + The associated with the specified + column, if any. + + + + + Returns the .NET type of a given column + + The index of the column. + Type + + + + Returns a column as a float value + + The index of the column. + float + + + + Returns the column as a Guid + + The index of the column. + Guid + + + + Returns the column as a short + + The index of the column. + Int16 + + + + Retrieves the column as an int + + The index of the column. + Int32 + + + + Retrieves the column as a long + + The index of the column. + Int64 + + + + Retrieves the name of the column + + The index of the column. + string + + + + Returns the name of the database associated with the specified column. + + The index of the column. + string + + + + Returns the name of the table associated with the specified column. + + The index of the column. + string + + + + Returns the original name of the specified column. + + The index of the column. + string + + + + Retrieves the i of a column, given its name + + The name of the column to retrieve + The int i of the column + + + + Schema information in SQLite is difficult to map into .NET conventions, so a lot of work must be done + to gather the necessary information so it can be represented in an ADO.NET manner. + Returns a DataTable containing the schema information for the active SELECT statement being processed. - + - Clears the connection pool associated with the connection. Any other active connections using the same database file - will be discarded instead of returned to the pool when they are closed. + Retrieves the column as a string - + The index of the column. + string - + - Clears all connection pools. Any active connections will be discarded instead of sent to the pool when they are closed. + Retrieves the column as an object corresponding to the underlying datatype of the column + The index of the column. + object - + - Create a new and associate it with this connection. + Retreives the values of multiple columns, up to the size of the supplied array - Returns a new command object already assigned to this connection. + The array to fill with values from the columns in the current resultset + The number of columns retrieved - + - Forwards to the local function. + Returns a collection containing all the column names and values for the + current row of data in the current resultset, if any. If there is no + current row or no current resultset, an exception may be thrown. - + + The collection containing the column name and value information for the + current row of data in the current resultset or null if this information + cannot be obtained. + - + - Parses the connection string into component parts using the custom - connection string parser. + Returns True if the resultset has rows that can be fetched - The connection string to parse - An array of key-value pairs representing each parameter of the connection string - + - Parses a connection string using the built-in (i.e. framework provided) - connection string parser class and returns the key/value pairs. An - exception may be thrown if the connection string is invalid or cannot be - parsed. When compiled for the .NET Compact Framework, the custom - connection string parser is always used instead because the framework - provided one is unavailable there. + Returns True if the data reader is closed - - The connection string to parse. - - - Non-zero to throw an exception if any connection string values are not of - the type. - - The list of key/value pairs. - + - Manual distributed transaction enlistment support + Returns True if the specified column is null - The distributed transaction to enlist in + The index of the column. + True or False - + - Looks for a key in the array of key/values of the parameter string. If not found, return the specified default value + Moves to the next resultset in multiple row-returning SQL command. - The list to look in - The key to find - The default value to return if the key is not found - The value corresponding to the specified key, or the default value if not found. + True if the command was successful and a new resultset is available, False otherwise. - + - Attempts to convert the string value to an enumerated value of the specified type. + This method attempts to query the database connection associated with + the data reader in use. If the underlying command or connection is + unavailable, a null value will be returned. - The enumerated type to convert the string value to. - The string value to be converted. - Non-zero to make the conversion case-insensitive. - The enumerated value upon success or null upon error. + + The connection object -OR- null if it is unavailable. + - + - Attempts to convert an input string into a byte value. + Retrieves the SQLiteType for a given column and row value. - - The string value to be converted. - - - The number styles to use for the conversion. + + The original SQLiteType structure, based only on the column. - - Upon sucess, this will contain the parsed byte value. - Upon failure, the value of this parameter is undefined. + + The textual value of the column for a given row. - Non-zero upon success; zero on failure. + The SQLiteType structure. - + - Enables or disabled extension loading. + Retrieves the SQLiteType for a given column, and caches it to avoid repetetive interop calls. - - True to enable loading of extensions, false to disable. - + The flags associated with the parent connection object. + The index of the column. + A SQLiteType structure - + - Loads a SQLite extension library from the named dynamic link library file. + Reads the next row from the resultset - - The name of the dynamic link library file containing the extension. - + True if a new row was successfully loaded and is ready for processing - + - Loads a SQLite extension library from the named dynamic link library file. + Returns the number of rows affected by the statement being executed. + The value returned may not be accurate for DDL statements. Also, it + will be -1 for any statement that does not modify the database (e.g. + SELECT). If an otherwise read-only statement modifies the database + indirectly (e.g. via a virtual table or user-defined function), the + value returned is undefined. - - The name of the dynamic link library file containing the extension. + + + + Indexer to retrieve data from a column given its name + + The name of the column to retrieve data for + The value contained in the column + + + + Indexer to retrieve data from a column given its i + + The index of the column. + The value contained in the column + + + + SQLite exception class. + + + + + This value was copied from the "WinError.h" file included with the + Platform SDK for Windows 10. + + + + + Private constructor for use with serialization. + + + Holds the serialized object data about the exception being thrown. - - The name of the exported function used to initialize the extension. - If null, the default "sqlite3_extension_init" will be used. + + Contains contextual information about the source or destination. - + - Creates a disposable module containing the implementation of a virtual - table. + Public constructor for generating a SQLite exception given the error + code and message. - - The module object to be used when creating the disposable module. + + The SQLite return code to report. + + + Message text to go along with the return code message text. - + - Parses a string containing a sequence of zero or more hexadecimal - encoded byte values and returns the resulting byte array. The - "0x" prefix is not allowed on the input string. + Public constructor that uses the base class constructor for the error + message. - - The input string containing zero or more hexadecimal encoded byte - values. - - - A byte array containing the parsed byte values or null if an error - was encountered. - + Error message text. - + - Creates and returns a string containing the hexadecimal encoded byte - values from the input array. + Public constructor that uses the default base class constructor. - - The input array of bytes. - - - The resulting string or null upon failure. - - + - Parses a string containing a sequence of zero or more hexadecimal - encoded byte values and returns the resulting byte array. The - "0x" prefix is not allowed on the input string. + Public constructor that uses the base class constructor for the error + message and inner exception. - - The input string containing zero or more hexadecimal encoded byte - values. + Error message text. + The original (inner) exception. + + + + Adds extra information to the serialized object data specific to this + class type. This is only used for serialization. + + + Holds the serialized object data about the exception being thrown. - - Upon failure, this will contain an appropriate error message. + + Contains contextual information about the source or destination. - - A byte array containing the parsed byte values or null if an error - was encountered. - - + - This method figures out what the default connection pool setting should - be based on the connection flags. When present, the "Pooling" connection - string property value always overrides the value returned by this method. + Gets the associated SQLite result code for this exception as a + . This property returns the same + underlying value as the property. + + + + + Gets the associated SQLite return code for this exception as an + . For desktop versions of the .NET Framework, + this property overrides the property of the same name within the + + class. This property returns the same underlying value as the + property. + + + + + This method performs extra initialization tasks. It may be called by + any of the constructors of this class. It must not throw exceptions. + + + + + Maps a Win32 error code to an HRESULT. + + The specified Win32 error code. It must be within the range of zero + (0) to 0xFFFF (65535). + + + Non-zero if the HRESULT should indicate success; otherwise, zero. + - Non-zero if the connection pool should be enabled by default; otherwise, - zero. + The integer value of the HRESULT. - + - Determines the transaction isolation level that should be used by - the caller, primarily based upon the one specified by the caller. - If mapping of transaction isolation levels is enabled, the returned - transaction isolation level may be significantly different than the - originally specified one. + Attempts to map the specified onto an + existing HRESULT -OR- a Win32 error code wrapped in an HRESULT. The + mappings may not have perfectly matching semantics; however, they do + have the benefit of being unique within the context of this exception + type. - - The originally specified transaction isolation level. + + The to map. - The transaction isolation level that should be used. + The integer HRESULT value -OR- null if there is no known mapping. - + - Opens the connection using the parameters found in the . + Returns the error message for the specified SQLite return code. + The SQLite return code. + The error message or null if it cannot be found. - + - Opens the connection using the parameters found in the and then returns it. + Returns the composite error message based on the SQLite return code + and the optional detailed error message. - The current connection object. + The SQLite return code. + Optional detailed error message. + Error message text for the return code. - + - This method causes any pending database operation to abort and return at - its earliest opportunity. This routine is typically called in response - to a user action such as pressing "Cancel" or Ctrl-C where the user wants - a long query operation to halt immediately. It is safe to call this - routine from any thread. However, it is not safe to call this routine - with a database connection that is closed or might close before this method - returns. + SQLite error codes. Actually, this enumeration represents a return code, + which may also indicate success in one of several ways (e.g. SQLITE_OK, + SQLITE_ROW, and SQLITE_DONE). Therefore, the name of this enumeration is + something of a misnomer. - + - Returns various global memory statistics for the SQLite core library via - a dictionary of key/value pairs. Currently, only the "MemoryUsed" and - "MemoryHighwater" keys are returned and they have values that correspond - to the values that could be obtained via the - and connection properties. + The error code is unknown. This error code + is only used by the managed wrapper itself. - - This dictionary will be populated with the global memory statistics. It - will be created if necessary. - - + - Attempts to free as much heap memory as possible for this database connection. + Successful result - + - Attempts to free N bytes of heap memory by deallocating non-essential memory - allocations held by the database library. Memory used to cache database pages - to improve performance is an example of non-essential memory. This is a no-op - returning zero if the SQLite core library was not compiled with the compile-time - option SQLITE_ENABLE_MEMORY_MANAGEMENT. Optionally, attempts to reset and/or - compact the Win32 native heap, if applicable. + SQL error or missing database - - The requested number of bytes to free. - - - Non-zero to attempt a heap reset. - - - Non-zero to attempt heap compaction. - - - The number of bytes actually freed. This value may be zero. - - - This value will be non-zero if the heap reset was successful. - - - The size of the largest committed free block in the heap, in bytes. - This value will be zero unless heap compaction is enabled. - - - A standard SQLite return code (i.e. zero for success and non-zero - for failure). - - + - Sets the status of the memory usage tracking subsystem in the SQLite core library. By default, this is enabled. - If this is disabled, memory usage tracking will not be performed. This is not really a per-connection value, it is - global to the process. + Internal logic error in SQLite - Non-zero to enable memory usage tracking, zero otherwise. - A standard SQLite return code (i.e. zero for success and non-zero for failure). - + - Passes a shutdown request to the SQLite core library. Does not throw - an exception if the shutdown request fails. + Access permission denied - - A standard SQLite return code (i.e. zero for success and non-zero for - failure). - - + - Passes a shutdown request to the SQLite core library. Throws an - exception if the shutdown request fails and the no-throw parameter - is non-zero. + Callback routine requested an abort - - Non-zero to reset the database and temporary directories to their - default values, which should be null for both. - - - When non-zero, throw an exception if the shutdown request fails. - - - Enables or disabled extended result codes returned by SQLite + + + The database file is locked + - - Enables or disabled extended result codes returned by SQLite + + + A table in the database is locked + - - Enables or disabled extended result codes returned by SQLite + + + A malloc() failed + - - Add a log message via the SQLite sqlite3_log interface. + + + Attempt to write a readonly database + - - Add a log message via the SQLite sqlite3_log interface. + + + Operation terminated by sqlite3_interrupt() + - + - Change the password (or assign a password) to an open database. + Some kind of disk I/O error occurred - - No readers or writers may be active for this process. The database must already be open - and if it already was password protected, the existing password must already have been supplied. - - The new password to assign to the database - + - Change the password (or assign a password) to an open database. + The database disk image is malformed - - No readers or writers may be active for this process. The database must already be open - and if it already was password protected, the existing password must already have been supplied. - - The new password to assign to the database - + - Sets the password for a password-protected database. A password-protected database is - unusable for any operation until the password has been set. + Unknown opcode in sqlite3_file_control() - The password for the database - + - Sets the password for a password-protected database. A password-protected database is - unusable for any operation until the password has been set. + Insertion failed because database is full - The password for the database - + - Queries or modifies the number of retries or the retry interval (in milliseconds) for - certain I/O operations that may fail due to anti-virus software. + Unable to open the database file - The number of times to retry the I/O operation. A negative value - will cause the current count to be queried and replace that negative value. - The number of milliseconds to wait before retrying the I/O - operation. This number is multiplied by the number of retry attempts so far to come - up with the final number of milliseconds to wait. A negative value will cause the - current interval to be queried and replace that negative value. - Zero for success, non-zero for error. - + - Sets the chunk size for the primary file associated with this database - connection. + Database lock protocol error - - The new chunk size for the main database, in bytes. - - - Zero for success, non-zero for error. - - + - Removes one set of surrounding single -OR- double quotes from the string - value and returns the resulting string value. If the string is null, empty, - or contains quotes that are not balanced, nothing is done and the original - string value will be returned. + Database is empty - The string value to process. - - The string value, modified to remove one set of surrounding single -OR- - double quotes, if applicable. - - + - Expand the filename of the data source, resolving the |DataDirectory| - macro as appropriate. + The database schema changed - The database filename to expand - - Non-zero if the returned file name should be converted to a full path - (except when using the .NET Compact Framework). - - The expanded path and filename of the filename - - - The following commands are used to extract schema information out of the database. Valid schema types are: - - - MetaDataCollections - - - DataSourceInformation - - - Catalogs - - - Columns - - - ForeignKeys - - - Indexes - - - IndexColumns - - - Tables - - - Views - - - ViewColumns - - - - - Returns the MetaDataCollections schema - - A DataTable of the MetaDataCollections schema + + + String or BLOB exceeds size limit + - + - Returns schema information of the specified collection + Abort due to constraint violation - The schema collection to retrieve - A DataTable of the specified collection - + - Retrieves schema information using the specified constraint(s) for the specified collection + Data type mismatch - The collection to retrieve - The restrictions to impose - A DataTable of the specified collection - + - Builds a MetaDataCollections schema datatable + Library used incorrectly - DataTable - + - Builds a DataSourceInformation datatable + Uses OS features not supported on host - DataTable - + - Build a Columns schema + Authorization denied - The catalog (attached database) to query, can be null - The table to retrieve schema information for, must not be null - The column to retrieve schema information for, can be null - DataTable - + - Returns index information for the given database and catalog + Auxiliary database format error - The catalog (attached database) to query, can be null - The name of the index to retrieve information for, can be null - The table to retrieve index information for, can be null - DataTable - + - Retrieves table schema information for the database and catalog + 2nd parameter to sqlite3_bind out of range - The catalog (attached database) to retrieve tables on - The table to retrieve, can be null - The table type, can be null - DataTable - + - Retrieves view schema information for the database + File opened that is not a database file - The catalog (attached database) to retrieve views on - The view name, can be null - DataTable - + - Retrieves catalog (attached databases) schema information for the database + Notifications from sqlite3_log() - The catalog to retrieve, can be null - DataTable - + - Returns the base column information for indexes in a database + Warnings from sqlite3_log() - The catalog to retrieve indexes for (can be null) - The table to restrict index information by (can be null) - The index to restrict index information by (can be null) - The source column to restrict index information by (can be null) - A DataTable containing the results - + - Returns detailed column information for a specified view + sqlite3_step() has another row ready - The catalog to retrieve columns for (can be null) - The view to restrict column information by (can be null) - The source column to restrict column information by (can be null) - A DataTable containing the results - + - Retrieves foreign key information from the specified set of filters + sqlite3_step() has finished executing - An optional catalog to restrict results on - An optional table to restrict results on - An optional foreign key name to restrict results on - A DataTable with the results of the query - + - Static variable to store the connection event handlers to call. + Used to mask off extended result codes - + - This event is raised whenever the database is opened or closed. + A collation sequence was referenced by a schema and it cannot be + found. - + - This event is raised when events related to the lifecycle of a - SQLiteConnection object occur. + An internal operation failed and it may succeed if retried. - + - This property is used to obtain or set the custom connection pool - implementation to use, if any. Setting this property to null will - cause the default connection pool implementation to be used. + A file read operation failed. - + - Returns the number of pool entries for the file name associated with this connection. + A file read operation returned less data than requested. - + - The connection string containing the parameters for the connection + A file write operation failed. + + + + + A file synchronization operation failed. - - - - Parameter - Values - Required - Default - - - Data Source - - This may be a file name, the string ":memory:", or any supported URI (starting with SQLite 3.7.7). - Starting with release 1.0.86.0, in order to use more than one consecutive backslash (e.g. for a - UNC path), each of the adjoining backslash characters must be doubled (e.g. "\\Network\Share\test.db" - would become "\\\\Network\Share\test.db"). - - Y - - - - Version - 3 - N - 3 - - - UseUTF16Encoding - True
False
- N - False -
- - DateTimeFormat - - Ticks - Use the value of DateTime.Ticks.
- ISO8601 - Use the ISO-8601 format. Uses the "yyyy-MM-dd HH:mm:ss.FFFFFFFK" format for UTC - DateTime values and "yyyy-MM-dd HH:mm:ss.FFFFFFF" format for local DateTime values).
- JulianDay - The interval of time in days and fractions of a day since January 1, 4713 BC.
- UnixEpoch - The whole number of seconds since the Unix epoch (January 1, 1970).
- InvariantCulture - Any culture-independent string value that the .NET Framework can interpret as a valid DateTime.
- CurrentCulture - Any string value that the .NET Framework can interpret as a valid DateTime using the current culture.
- N - ISO8601 -
- - DateTimeKind - Unspecified - Not specified as either UTC or local time.
Utc - The time represented is UTC.
Local - The time represented is local time.
- N - Unspecified -
- - DateTimeFormatString - The exact DateTime format string to use for all formatting and parsing of all DateTime - values for this connection. - N - null - - - BaseSchemaName - Some base data classes in the framework (e.g. those that build SQL queries dynamically) - assume that an ADO.NET provider cannot support an alternate catalog (i.e. database) without supporting - alternate schemas as well; however, SQLite does not fit into this model. Therefore, this value is used - as a placeholder and removed prior to preparing any SQL statements that may contain it. - N - sqlite_default_schema - - - BinaryGUID - True - Store GUID columns in binary form
False - Store GUID columns as text
- N - True -
- - Cache Size - {size in bytes} - N - 2000 - - - Synchronous - Normal - Normal file flushing behavior
Full - Full flushing after all writes
Off - Underlying OS flushes I/O's
- N - Full -
- - Page Size - {size in bytes} - N - 1024 - - - Password - {password} - Using this parameter requires that the CryptoAPI based codec be enabled at compile-time for both the native interop assembly and the core managed assemblies; otherwise, using this parameter may result in an exception being thrown when attempting to open the connection. - N - - - - HexPassword - {hexPassword} - Must contain a sequence of zero or more hexadecimal encoded byte values without a leading "0x" prefix. Using this parameter requires that the CryptoAPI based codec be enabled at compile-time for both the native interop assembly and the core managed assemblies; otherwise, using this parameter may result in an exception being thrown when attempting to open the connection. - N - - - - Enlist - Y - Automatically enlist in distributed transactions
N - No automatic enlistment
- N - Y -
- - Pooling - - True - Use connection pooling.
- False - Do not use connection pooling.

- WARNING: When using the default connection pool implementation, - setting this property to True should be avoided by applications that - make use of COM (either directly or indirectly) due to possible - deadlocks that can occur during the finalization of some COM objects. -
- N - False -
- - FailIfMissing - True - Don't create the database if it does not exist, throw an error instead
False - Automatically create the database if it does not exist
- N - False -
- - Max Page Count - {size in pages} - Limits the maximum number of pages (limits the size) of the database - N - 0 - - - Legacy Format - True - Use the more compatible legacy 3.x database format
False - Use the newer 3.3x database format which compresses numbers more effectively
- N - False -
- - Default Timeout - {time in seconds}
The default command timeout
- N - 30 -
- - Journal Mode - Delete - Delete the journal file after a commit
Persist - Zero out and leave the journal file on disk after a commit
Off - Disable the rollback journal entirely
- N - Delete -
- - Read Only - True - Open the database for read only access
False - Open the database for normal read/write access
- N - False -
- - Max Pool Size - The maximum number of connections for the given connection string that can be in the connection pool - N - 100 - - - Default IsolationLevel - The default transaciton isolation level - N - Serializable - - - Foreign Keys - Enable foreign key constraints - N - False - - - Flags - Extra behavioral flags for the connection. See the enumeration for possible values. - N - Default - - - SetDefaults - - True - Apply the default connection settings to the opened database.
- False - Skip applying the default connection settings to the opened database. -
- N - True -
- - ToFullPath - - True - Attempt to expand the data source file name to a fully qualified path before opening.
- False - Skip attempting to expand the data source file name to a fully qualified path before opening. -
- N - True -
-
-
- + - Returns the data source file name without extension or path. + A directory synchronization operation failed. - + - Returns the string "main". + A file truncate operation failed. - + - Gets/sets the default command timeout for newly-created commands. This is especially useful for - commands used internally such as inside a SQLiteTransaction, where setting the timeout is not possible. - This can also be set in the ConnectionString with "Default Timeout" + A file metadata operation failed. - + - Non-zero if the built-in (i.e. framework provided) connection string - parser should be used when opening the connection. + A file unlock operation failed. - + - Gets/sets the extra behavioral flags for this connection. See the - enumeration for a list of - possible values. + A file lock operation failed. - + - Gets/sets the default database type for this connection. This value - will only be used when not null. + A file delete operation failed. - + - Gets/sets the default database type name for this connection. This - value will only be used when not null. + Not currently used. - + - Returns non-zero if the underlying native connection handle is - owned by this instance. + Out-of-memory during a file operation. - + - Returns the version of the underlying SQLite database engine + A file existence/status operation failed. - + - Returns the rowid of the most recent successful INSERT into the database from this connection. + A check for a reserved lock failed. - + - Returns the number of rows changed by the last INSERT, UPDATE, or DELETE statement executed on - this connection. + A file lock operation failed. - + - Returns non-zero if the given database connection is in autocommit mode. - Autocommit mode is on by default. Autocommit mode is disabled by a BEGIN - statement. Autocommit mode is re-enabled by a COMMIT or ROLLBACK. + A file close operation failed. - + - Returns the amount of memory (in bytes) currently in use by the SQLite core library. + A directory close operation failed. - + - Returns the maximum amount of memory (in bytes) used by the SQLite core library since the high-water mark was last reset. + A shared memory open operation failed. - + - Returns a string containing the define constants (i.e. compile-time - options) used to compile the core managed assembly, delimited with - spaces. + A shared memory size operation failed. - + - Returns the version of the underlying SQLite core library. + A shared memory lock operation failed. - + - This method returns the string whose value is the same as the - SQLITE_SOURCE_ID C preprocessor macro used when compiling the - SQLite core library. + A shared memory map operation failed. - + - Returns a string containing the compile-time options used to - compile the SQLite core native library, delimited with spaces. + A file seek operation failed. - + - This method returns the version of the interop SQLite assembly - used. If the SQLite interop assembly is not in use or the - necessary information cannot be obtained for any reason, a null - value may be returned. + A file delete operation failed because it does not exist. - + - This method returns the string whose value contains the unique - identifier for the source checkout used to build the interop - assembly. If the SQLite interop assembly is not in use or the - necessary information cannot be obtained for any reason, a null - value may be returned. + A file memory mapping operation failed. - + - Returns a string containing the compile-time options used to - compile the SQLite interop assembly, delimited with spaces. + The temporary directory path could not be obtained. - + - This method returns the version of the managed components used - to interact with the SQLite core library. If the necessary - information cannot be obtained for any reason, a null value may - be returned. + A path string conversion operation failed. - + - This method returns the string whose value contains the unique - identifier for the source checkout used to build the managed - components currently executing. If the necessary information - cannot be obtained for any reason, a null value may be returned. + Reserved. - + - The extra connection flags to be used for all opened connections. + An attempt to authenticate failed. - + - Returns the state of the connection. + An attempt to begin a file system transaction failed. - + - This event is raised whenever SQLite encounters an action covered by the - authorizer during query preparation. Changing the value of the - property will determine if - the specific action will be allowed, ignored, or denied. For the entire - duration of the event, the associated connection and statement objects - must not be modified, either directly or indirectly, by the called code. + An attempt to commit a file system transaction failed. - + - This event is raised whenever SQLite makes an update/delete/insert into the database on - this connection. It only applies to the given connection. + An attempt to rollback a file system transaction failed. - + - This event is raised whenever SQLite is committing a transaction. - Return non-zero to trigger a rollback. + A database table is locked in shared-cache mode. - + - This event is raised whenever SQLite statement first begins executing on - this connection. It only applies to the given connection. + A virtual table in the database is locked. + + + + + A database file is locked due to a recovery operation. + + + + + A database file is locked due to snapshot semantics. + + + + + A database file cannot be opened because no temporary directory is available. + + + + + A database file cannot be opened because its path represents a directory. + + + + + A database file cannot be opened because its full path could not be obtained. + + + + + A database file cannot be opened because a path string conversion operation failed. + + + + + A virtual table is malformed. + + + + + A required sequence table is missing or corrupt. + + + + + A database file is read-only due to a recovery operation. + + + + + A database file is read-only because a lock could not be obtained. + + + + + A database file is read-only because it needs rollback processing. + + + + + A database file is read-only because it was moved while open. + + + + + The shared-memory file is read-only and it should be read-write. + + + + + Unable to create journal file because the directory is read-only. + + + + + An operation is being aborted due to rollback processing. + + + + + A CHECK constraint failed. + + + + + A commit hook produced a unsuccessful return code. + + + + + A FOREIGN KEY constraint failed. + + + + + Not currently used. + + + + + A NOT NULL constraint failed. + + + + + A PRIMARY KEY constraint failed. + + + + + The RAISE function was used by a trigger-program. + + + + + A UNIQUE constraint failed. + + + + + Not currently used. + + + + + A ROWID constraint failed. + + + + + Frames were recovered from the WAL log file. + + + + + Pages were recovered from the journal file. + + + + + An automatic index was created to process a query. + + + + + User authentication failed. + + + + + Success. Prevents the extension from unloading until the process + terminates. + + + + + SQLite implementation of . + + + SQLite implementation of . - + + + Constructs a new instance. + + + + + Cleans up resources (native and managed) associated with the current instance. + + + + + Cleans up resources associated with the current instance. + + + + + This event is raised whenever SQLite raises a logging event. + Note that this should be set as one of the first things in the + application. This event is provided for backward compatibility only. + New code should use the class instead. + + + + + Static instance member which returns an instanced class. + + + + + Creates and returns a new object. + + The new object. + + + + Creates and returns a new object. + + The new object. + + + + Creates and returns a new object. + + The new object. + + + + Creates and returns a new object. + + The new object. + + + + Creates and returns a new object. + + The new object. + + + + Creates and returns a new object. + + The new object. + + + + Will provide a object in .NET 3.5. + + The class or interface type to query for. + + + + + This abstract class is designed to handle user-defined functions easily. An instance of the derived class is made for each + connection to the database. + + + Although there is one instance of a class derived from SQLiteFunction per database connection, the derived class has no access + to the underlying connection. This is necessary to deter implementers from thinking it would be a good idea to make database + calls during processing. + + It is important to distinguish between a per-connection instance, and a per-SQL statement context. One instance of this class + services all SQL statements being stepped through on that connection, and there can be many. One should never store per-statement + information in member variables of user-defined function classes. + + For aggregate functions, always create and store your per-statement data in the contextData object on the 1st step. This data will + be automatically freed for you (and Dispose() called if the item supports IDisposable) when the statement completes. + + + - This event is raised whenever SQLite is rolling back a transaction. + The base connection this function is attached to - + - Returns the instance. + Internal array used to keep track of aggregate function context data - + - The I/O file cache flushing behavior for the connection + The connection flags associated with this object (this should be the + same value as the flags associated with the parent connection object). - + - Normal file flushing at critical sections of the code + Holds a reference to the callback function for user functions - + - Full file flushing after every write operation + Holds a reference to the callbakc function for stepping in an aggregate function - + - Use the default operating system's file flushing, SQLite does not explicitly flush the file buffers after writing + Holds a reference to the callback function for finalizing an aggregate function - + - Raised when authorization is required to perform an action contained - within a SQL query. + Holds a reference to the callback function for collating sequences - The connection performing the action. - A that contains the - event data. - + - Raised when a transaction is about to be committed. To roll back a transaction, set the - rollbackTrans boolean value to true. + Current context of the current callback. Only valid during a callback - The connection committing the transaction - Event arguments on the transaction - + - Raised when data is inserted, updated and deleted on a given connection + This static dictionary contains all the registered (known) user-defined + functions declared using the proper attributes. The contained dictionary + values are always null and are not currently used. - The connection committing the transaction - The event parameters which triggered the event - + - Raised when a statement first begins executing on a given connection + Internal constructor, initializes the function's internal variables. - The connection executing the statement - Event arguments of the trace - + - Raised between each backup step. + Constructs an instance of this class using the specified data-type + conversion parameters. - - The source database connection. - - - The source database name. - - - The destination database connection. - - - The destination database name. - - - The number of pages copied with each step. + + The DateTime format to be used when converting string values to a + DateTime and binding DateTime parameters. - - The number of pages remaining to be copied. + + The to be used when creating DateTime + values. - - The total number of pages in the source database. + + The format string to be used when parsing and formatting DateTime + values. - - Set to true if the operation needs to be retried due to database - locking issues; otherwise, set to false. + + Non-zero to create a UTF-16 data-type conversion context; otherwise, + a UTF-8 data-type conversion context will be created. - - True to continue with the backup process or false to halt the backup - process, rolling back any changes that have been made so far. - - - - - The data associated with a call into the authorizer. - - + - The user-defined native data associated with this event. Currently, - this will always contain the value of . + Disposes of any active contextData variables that were not automatically cleaned up. Sometimes this can happen if + someone closes the connection while a DataReader is open. - + - The action code responsible for the current call into the authorizer. + Placeholder for a user-defined disposal routine + True if the object is being disposed explicitly - + - The first string argument for the current call into the authorizer. - The exact value will vary based on the action code, see the - enumeration for possible - values. + Cleans up resources associated with the current instance. - + - The second string argument for the current call into the authorizer. - The exact value will vary based on the action code, see the - enumeration for possible - values. + Returns a reference to the underlying connection's SQLiteConvert class, which can be used to convert + strings and DateTime's into the current connection's encoding schema. - + - The database name for the current call into the authorizer, if - applicable. + Scalar functions override this method to do their magic. + + Parameters passed to functions have only an affinity for a certain data type, there is no underlying schema available + to force them into a certain type. Therefore the only types you will ever see as parameters are + DBNull.Value, Int64, Double, String or byte[] array. + + The arguments for the command to process + You may return most simple types as a return value, null or DBNull.Value to return null, DateTime, or + you may return an Exception-derived class if you wish to return an error to SQLite. Do not actually throw the error, + just return it! - + - The name of the inner-most trigger or view that is responsible for - the access attempt or a null value if this access attempt is directly - from top-level SQL code. + Aggregate functions override this method to do their magic. + + Typically you'll be updating whatever you've placed in the contextData field and returning as quickly as possible. + + The arguments for the command to process + The 1-based step number. This is incrememted each time the step method is called. + A placeholder for implementers to store contextual data pertaining to the current context. - + - The return code for the current call into the authorizer. + Aggregate functions override this method to finish their aggregate processing. + + If you implemented your aggregate function properly, + you've been recording and keeping track of your data in the contextData object provided, and now at this stage you should have + all the information you need in there to figure out what to return. + NOTE: It is possible to arrive here without receiving a previous call to Step(), in which case the contextData will + be null. This can happen when no rows were returned. You can either return null, or 0 or some other custom return value + if that is the case. + + Your own assigned contextData, provided for you so you can return your final results. + You may return most simple types as a return value, null or DBNull.Value to return null, DateTime, or + you may return an Exception-derived class if you wish to return an error to SQLite. Do not actually throw the error, + just return it! + - + - Constructs an instance of this class with default property values. + User-defined collating sequences override this method to provide a custom string sorting algorithm. + The first string to compare. + The second strnig to compare. + 1 if param1 is greater than param2, 0 if they are equal, or -1 if param1 is less than param2. - + - Constructs an instance of this class with specific property values. + Converts an IntPtr array of context arguments to an object array containing the resolved parameters the pointers point to. - - The user-defined native data associated with this event. - - - The authorizer action code. - - - The first authorizer argument. - - - The second authorizer argument. - - - The database name, if applicable. - - - The name of the inner-most trigger or view that is responsible for - the access attempt or a null value if this access attempt is directly - from top-level SQL code. - - - The authorizer return code. - + + Parameters passed to functions have only an affinity for a certain data type, there is no underlying schema available + to force them into a certain type. Therefore the only types you will ever see as parameters are + DBNull.Value, Int64, Double, String or byte[] array. + + The number of arguments + A pointer to the array of arguments + An object array of the arguments once they've been converted to .NET values - + - Whenever an update event is triggered on a connection, this enum will indicate - exactly what type of operation is being performed. + Takes the return value from Invoke() and Final() and figures out how to return it to SQLite's context. + The context the return value applies to + The parameter to return to SQLite - + - A row is being deleted from the given database and table + Internal scalar callback function, which wraps the raw context pointer and calls the virtual Invoke() method. + WARNING: Must not throw exceptions. + A raw context pointer + Number of arguments passed in + A pointer to the array of arguments - + - A row is being inserted into the table. + Internal collating sequence function, which wraps up the raw string pointers and executes the Compare() virtual function. + WARNING: Must not throw exceptions. + Not used + Length of the string pv1 + Pointer to the first string to compare + Length of the string pv2 + Pointer to the second string to compare + Returns -1 if the first string is less than the second. 0 if they are equal, or 1 if the first string is greater + than the second. Returns 0 if an exception is caught. - + - A row is being updated in the table. + Internal collating sequence function, which wraps up the raw string pointers and executes the Compare() virtual function. + WARNING: Must not throw exceptions. + Not used + Length of the string pv1 + Pointer to the first string to compare + Length of the string pv2 + Pointer to the second string to compare + Returns -1 if the first string is less than the second. 0 if they are equal, or 1 if the first string is greater + than the second. Returns 0 if an exception is caught. - + - Passed during an Update callback, these event arguments detail the type of update operation being performed - on the given connection. + The internal aggregate Step function callback, which wraps the raw context pointer and calls the virtual Step() method. + WARNING: Must not throw exceptions. + + This function takes care of doing the lookups and getting the important information put together to call the Step() function. + That includes pulling out the user's contextData and updating it after the call is made. We use a sorted list for this so + binary searches can be done to find the data. + + A raw context pointer + Number of arguments passed in + A pointer to the array of arguments - + - The name of the database being updated (usually "main" but can be any attached or temporary database) + An internal aggregate Final function callback, which wraps the context pointer and calls the virtual Final() method. + WARNING: Must not throw exceptions. + A raw context pointer - + - The name of the table being updated + Using reflection, enumerate all assemblies in the current appdomain looking for classes that + have a SQLiteFunctionAttribute attribute, and registering them accordingly. - + - The type of update being performed (insert/update/delete) + Manual method of registering a function. The type must still have the SQLiteFunctionAttributes in order to work + properly, but this is a workaround for the Compact Framework where enumerating assemblies is not currently supported. + The type of the function to register - + - The RowId affected by this update. + Alternative method of registering a function. This method + does not require the specified type to be annotated with + . + + The name of the function to register. + + + The number of arguments accepted by the function. + + + The type of SQLite function being resitered (e.g. scalar, + aggregate, or collating sequence). + + + The that actually implements the function. + This will only be used if the + and parameters are null. + + + The to be used for all calls into the + , + , + and virtual methods. + + + The to be used for all calls into the + virtual method. This + parameter is only necessary for aggregate functions. + - + - Event arguments raised when a transaction is being committed + Replaces a registered function, disposing of the associated (old) + value if necessary. + + The attribute that describes the function to replace. + + + The new value to use. + + + Non-zero if an existing registered function was replaced; otherwise, + zero. + - + - Set to true to abort the transaction and trigger a rollback + Creates a instance based on the specified + . + + The containing the metadata about + the function to create. + + + The created function -OR- null if the function could not be created. + + + Non-zero if the function was created; otherwise, zero. + - + - Passed during an Trace callback, these event arguments contain the UTF-8 rendering of the SQL statement text + Called by the SQLiteBase derived classes, this method binds all registered (known) user-defined functions to a connection. + It is done this way so that all user-defined functions will access the database using the same encoding scheme + as the connection (UTF-8 or UTF-16). + + The wrapper functions that interop with SQLite will create a unique cookie value, which internally is a pointer to + all the wrapped callback functions. The interop function uses it to map CDecl callbacks to StdCall callbacks. + + The base object on which the functions are to bind. + The flags associated with the parent connection object. + Returns a logical list of functions which the connection should retain until it is closed. - + - SQL statement text as the statement first begins executing + Called by the SQLiteBase derived classes, this method unbinds all registered (known) + functions -OR- all previously bound user-defined functions from a connection. + The base object from which the functions are to be unbound. + The flags associated with the parent connection object. + + Non-zero to unbind all registered (known) functions -OR- zero to unbind all functions + currently bound to the connection. + + Non-zero if all the specified user-defined functions were unbound. - + - This interface represents a custom connection pool implementation - usable by System.Data.SQLite. + This function binds a user-defined function to a connection. + + The object instance associated with the + that the function should be bound to. + + + The object instance containing + the metadata for the function to be bound. + + + The object instance that implements the + function to be bound. + + + The flags associated with the parent connection object. + - + - Counts the number of pool entries matching the specified file name. + This function unbinds a user-defined functions from a connection. - - The file name to match or null to match all files. - - - The pool entry counts for each matching file. + + The object instance associated with the + that the function should be bound to. - - The total number of connections successfully opened from any pool. + + The object instance containing + the metadata for the function to be bound. - - The total number of connections successfully closed from any pool. + + The object instance that implements the + function to be bound. - - The total number of pool entries for all matching files. + + The flags associated with the parent connection object. + Non-zero if the function was unbound. - + - Disposes of all pooled connections associated with the specified - database file name. + This type is used with the + method. - - The database file name. + + This is always the string literal "Invoke". + + + The arguments for the scalar function. + + The result of the scalar function. + - + - Disposes of all pooled connections. + This type is used with the + method. + + This is always the string literal "Step". + + + The arguments for the aggregate function. + + + The step number (one based). This is incrememted each time the + method is called. + + + A placeholder for implementers to store contextual data pertaining + to the current context. + - + - Adds a connection to the pool of those associated with the - specified database file name. + This type is used with the + method. - - The database file name. - - - The database connection handle. + + This is always the string literal "Final". - - The connection pool version at the point the database connection - handle was received from the connection pool. This is also the - connection pool version that the database connection handle was - created under. + + A placeholder for implementers to store contextual data pertaining + to the current context. + + The result of the aggregate function. + - + - Removes a connection from the pool of those associated with the - specified database file name with the intent of using it to - interact with the database. + This type is used with the + method. - - The database file name. + + This is always the string literal "Compare". - - The new maximum size of the connection pool for the specified - database file name. + + The first string to compare. - - The connection pool version associated with the returned database - connection handle, if any. + + The second strnig to compare. - The database connection handle associated with the specified - database file name or null if it cannot be obtained. + A positive integer if the parameter is + greater than the parameter, a negative + integer if the parameter is less than + the parameter, or zero if they are + equal. - + - This default method implementations in this class should not be used by - applications that make use of COM (either directly or indirectly) due - to possible deadlocks that can occur during finalization of some COM - objects. + This class implements a SQLite function using a . + All the virtual methods of the class are + implemented using calls to the , + , , + and strongly typed delegate types + or via the method. + The arguments are presented in the same order they appear in + the associated methods with one exception: + the first argument is the name of the virtual method being implemented. - + - This field is used to synchronize access to the private static data - in this class. + This error message is used by the overridden virtual methods when + a required property (e.g. + or ) has not been + set. - + - When this field is non-null, it will be used to provide the - implementation of all the connection pool methods; otherwise, - the default method implementations will be used. + This error message is used by the overridden + method when the result does not have a type of . - + - The dictionary of connection pools, based on the normalized file - name of the SQLite database. + Constructs an empty instance of this class. - + - The default version number new pools will get. + Constructs an instance of this class using the specified + as the + implementation. + + The to be used for all calls into the + , , and + virtual methods needed by the + base class. + + + The to be used for all calls into the + virtual methods needed by the + base class. + - + - The number of connections successfully opened from any pool. - This value is incremented by the Remove method. + Returns the list of arguments for the method, + as an of . The first + argument is always the literal string "Invoke". + + The original arguments received by the method. + + + Non-zero if the returned arguments are going to be used with the + type; otherwise, zero. + + + The arguments to pass to the configured . + - + - The number of connections successfully closed from any pool. - This value is incremented by the Add method. + Returns the list of arguments for the method, + as an of . The first + argument is always the literal string "Step". + + The original arguments received by the method. + + + The step number (one based). This is incrememted each time the + method is called. + + + A placeholder for implementers to store contextual data pertaining + to the current context. + + + Non-zero if the returned arguments are going to be used with the + type; otherwise, zero. + + + The arguments to pass to the configured . + - + - Counts the number of pool entries matching the specified file name. + Updates the output arguments for the method, + using an of . The first + argument is always the literal string "Step". Currently, only the + parameter is updated. - - The file name to match or null to match all files. + + The original arguments received by the method. - - The pool entry counts for each matching file. + + A placeholder for implementers to store contextual data pertaining + to the current context. - - The total number of connections successfully opened from any pool. + + Non-zero if the returned arguments are going to be used with the + type; otherwise, zero. - - The total number of connections successfully closed from any pool. + + The arguments to pass to the configured . + + + + + Returns the list of arguments for the method, + as an of . The first + argument is always the literal string "Final". + + + A placeholder for implementers to store contextual data pertaining + to the current context. - - The total number of pool entries for all matching files. + + Non-zero if the returned arguments are going to be used with the + type; otherwise, zero. + + The arguments to pass to the configured . + - + - Disposes of all pooled connections associated with the specified - database file name. + Returns the list of arguments for the method, + as an of . The first + argument is always the literal string "Compare". - - The database file name. + + The first string to compare. + + The second strnig to compare. + + + Non-zero if the returned arguments are going to be used with the + type; otherwise, zero. + + + The arguments to pass to the configured . + - + - Disposes of all pooled connections. + The to be used for all calls into the + , , and + virtual methods needed by the + base class. - + - Adds a connection to the pool of those associated with the - specified database file name. + The to be used for all calls into the + virtual methods needed by the + base class. - - The database file name. + + + + This virtual method is the implementation for scalar functions. + See the method for more + details. + + + The arguments for the scalar function. - - The database connection handle. + + The result of the scalar function. + + + + + This virtual method is part of the implementation for aggregate + functions. See the method + for more details. + + + The arguments for the aggregate function. - - The connection pool version at the point the database connection - handle was received from the connection pool. This is also the - connection pool version that the database connection handle was - created under. + + The step number (one based). This is incrememted each time the + method is called. + + + A placeholder for implementers to store contextual data pertaining + to the current context. - + - Removes a connection from the pool of those associated with the - specified database file name with the intent of using it to - interact with the database. + This virtual method is part of the implementation for aggregate + functions. See the method + for more details. - - The database file name. + + A placeholder for implementers to store contextual data pertaining + to the current context. - - The new maximum size of the connection pool for the specified - database file name. + + The result of the aggregate function. + + + + + This virtual method is part of the implementation for collating + sequences. See the method + for more details. + + + The first string to compare. - - The connection pool version associated with the returned database - connection handle, if any. + + The second strnig to compare. - The database connection handle associated with the specified - database file name or null if it cannot be obtained. + A positive integer if the parameter is + greater than the parameter, a negative + integer if the parameter is less than + the parameter, or zero if they are + equal. - + - This method is used to obtain a reference to the custom connection - pool implementation currently in use, if any. + Extends SQLiteFunction and allows an inherited class to obtain the collating sequence associated with a function call. - - The custom connection pool implementation or null if the default - connection pool implementation should be used. - + + User-defined functions can call the GetCollationSequence() method in this class and use it to compare strings and char arrays. + - + - This method is used to set the reference to the custom connection - pool implementation to use, if any. + Obtains the collating sequence in effect for the given function. - - The custom connection pool implementation to use or null if the - default connection pool implementation should be used. - + - + - We do not have to thread-lock anything in this function, because it - is only called by other functions above which already take the lock. + Cleans up resources (native and managed) associated with the current instance. - - The pool queue to resize. - - - If a function intends to add to the pool, this is true, which - forces the resize to take one more than it needs from the pool. + + Zero when being disposed via garbage collection; otherwise, non-zero. - + - Keeps track of connections made on a specified file. The PoolVersion - dictates whether old objects get returned to the pool or discarded - when no longer in use. + The type of user-defined function to declare - + - The queue of weak references to the actual database connection - handles. + Scalar functions are designed to be called and return a result immediately. Examples include ABS(), Upper(), Lower(), etc. - + - This pool version associated with the database connection - handles in this pool queue. + Aggregate functions are designed to accumulate data until the end of a call and then return a result gleaned from the accumulated data. + Examples include SUM(), COUNT(), AVG(), etc. - + - The maximum size of this pool queue. + Collating sequences are used to sort textual data in a custom manner, and appear in an ORDER BY clause. Typically text in an ORDER BY is + sorted using a straight case-insensitive comparison function. Custom collating sequences can be used to alter the behavior of text sorting + in a user-defined manner. - + - Constructs a connection pool queue using the specified version - and maximum size. Normally, all the database connection - handles in this pool are associated with a single database file - name. + An internal callback delegate declaration. - - The initial pool version for this connection pool queue. - - - The initial maximum size for this connection pool queue. - + Raw native context pointer for the user function. + Total number of arguments to the user function. + Raw native pointer to the array of raw native argument pointers. - + - SQLite implementation of DbConnectionStringBuilder. + An internal final callback delegate declaration. + Raw context pointer for the user function - + - Properties of this class + Internal callback delegate for implementing collating sequences + Not used + Length of the string pv1 + Pointer to the first string to compare + Length of the string pv2 + Pointer to the second string to compare + Returns -1 if the first string is less than the second. 0 if they are equal, or 1 if the first string is greater + than the second. - - - Constructs a new instance of the class - + - Default constructor + The type of collating sequence - + - Constructs a new instance of the class using the specified connection string. + The built-in BINARY collating sequence - The connection string to parse - + - Private initializer, which assigns the connection string and resets the builder + The built-in NOCASE collating sequence - The connection string to assign - + - Helper function for retrieving values from the connectionstring + The built-in REVERSE collating sequence - The keyword to retrieve settings for - The resulting parameter value - Returns true if the value was found and returned - + - Fallback method for MONO, which doesn't implement DbConnectionStringBuilder.GetProperties() + A custom user-defined collating sequence - The hashtable to fill with property descriptors - + - Gets/Sets the default version of the SQLite engine to instantiate. Currently the only valid value is 3, indicating version 3 of the sqlite library. + The encoding type the collation sequence uses - + - Gets/Sets the synchronization mode (file flushing) of the connection string. Default is "Normal". + The collation sequence is UTF8 - + - Gets/Sets the encoding for the connection string. The default is "False" which indicates UTF-8 encoding. + The collation sequence is UTF16 little-endian - + - Gets/Sets whether or not to use connection pooling. The default is "False" + The collation sequence is UTF16 big-endian - + - Gets/Sets whethor not to store GUID's in binary format. The default is True - which saves space in the database. + A struct describing the collating sequence a function is executing in - + - Gets/Sets the filename to open on the connection string. + The name of the collating sequence - + - An alternate to the data source property + The type of collating sequence - + - An alternate to the data source property that uses the SQLite URI syntax. + The text encoding of the collation sequence - + - Gets/sets the default command timeout for newly-created commands. This is especially useful for - commands used internally such as inside a SQLiteTransaction, where setting the timeout is not possible. + Context of the function that requested the collating sequence - + - Determines whether or not the connection will automatically participate - in the current distributed transaction (if one exists) + Calls the base collating sequence to compare two strings + The first string to compare + The second string to compare + -1 if s1 is less than s2, 0 if s1 is equal to s2, and 1 if s1 is greater than s2 - + - If set to true, will throw an exception if the database specified in the connection - string does not exist. If false, the database will be created automatically. + Calls the base collating sequence to compare two character arrays + The first array to compare + The second array to compare + -1 if c1 is less than c2, 0 if c1 is equal to c2, and 1 if c1 is greater than c2 - + - If enabled, uses the legacy 3.xx format for maximum compatibility, but results in larger - database sizes. + A simple custom attribute to enable us to easily find user-defined functions in + the loaded assemblies and initialize them in SQLite as connections are made. - + - When enabled, the database will be opened for read-only access and writing will be disabled. + Default constructor, initializes the internal variables for the function. - + - Gets/sets the database encryption password + Constructs an instance of this class. This sets the initial + , , and + properties to null. + + The name of the function, as seen by the SQLite core library. + + + The number of arguments that the function will accept. + + + The type of function being declared. This will either be Scalar, + Aggregate, or Collation. + - + - Gets/sets the database encryption hexadecimal password + The function's name as it will be used in SQLite command text. - + - Gets/Sets the page size for the connection. + The number of arguments this function expects. -1 if the number of arguments is variable. - + - Gets/Sets the maximum number of pages the database may hold + The type of function this implementation will be. - + - Gets/Sets the cache size for the connection. + The object instance that describes the class + containing the implementation for the associated function. The value of + this property will not be used if either the or + property values are set to non-null. - + - Gets/Sets the DateTime format for the connection. + The that refers to the implementation for the + associated function. If this property value is set to non-null, it will + be used instead of the property value. - + - Gets/Sets the DateTime kind for the connection. + The that refers to the implementation for the + associated function. If this property value is set to non-null, it will + be used instead of the property value. - + - Gets/sets the DateTime format string used for formatting - and parsing purposes. + This class provides key info for a given SQLite statement. + + Providing key information for a given statement is non-trivial :( + - + - Gets/Sets the placeholder base schema name used for - .NET Framework compatibility purposes. + Used to support CommandBehavior.KeyInfo - + - Determines how SQLite handles the transaction journal file. + Used to keep track of the per-table RowId column metadata. - + - Sets the default isolation level for transactions on the connection. + A single sub-query for a given table/database. - + - Gets/sets the default database type for the connection. + This function does all the nasty work at determining what keys need to be returned for + a given statement. + + + - + - Gets/sets the default type name for the connection. + How many additional columns of keyinfo we're holding - + - If enabled, use foreign key constraints + Make sure all the subqueries are open and ready and sync'd with the current rowid + of the table they're supporting - + - Gets/Sets the extra behavioral flags. + Release any readers on any subqueries - + - If enabled, apply the default connection settings to opened databases. + Append all the columns we've added to the original query to the schema + - + - If enabled, attempt to resolve the provided data source file name to a - full path before opening. + Event data for logging event handlers. - + - If enabled, skip using the configured shared connection flags. + The error code. The type of this object value should be + or . - + - SQLite has very limited types, and is inherently text-based. The first 5 types below represent the sum of all types SQLite - understands. The DateTime extension to the spec is for internal use only. + SQL statement text as the statement first begins executing - + - Not used + Extra data associated with this event, if any. - + - All integers in SQLite default to Int64 + Constructs the object. + Should be null. + + The error code. The type of this object value should be + or . + + The error message, if any. + The extra data, if any. - + - All floating point numbers in SQLite default to double + Raised when a log event occurs. + The current connection + Event arguments of the trace - + - The default data type of SQLite is text + Manages the SQLite custom logging functionality and the associated + callback for the whole process. - + - Typically blob types are only seen when returned from a function + Object used to synchronize access to the static instance data + for this class. - + - Null types can be returned from functions + Member variable to store the AppDomain.DomainUnload event handler. - + - Used internally by this provider + Member variable to store the application log handler to call. - + - Used internally by this provider + The default log event handler. - + - These are the event types associated with the - - delegate (and its corresponding event) and the - class. + The log callback passed to native SQLite engine. This must live + as long as the SQLite library has a pointer to it. - + - Not used. + The base SQLite object to interop with. - + - Not used. + The number of times that the + has been called when the logging subystem was actually eligible + to be initialized (i.e. without the "No_SQLiteLog" environment + variable being set). - + - The connection is being opened. + This will be non-zero if an attempt was already made to initialize + the (managed) logging subsystem. - + - The connection string has been parsed. + This will be non-zero if logging is currently enabled. - + - The connection was opened. + Initializes the SQLite logging facilities. - + - The method was called on the - connection. + Initializes the SQLite logging facilities. + + The name of the managed class that called this method. This + parameter may be null. + - + - A transaction was created using the connection. + Handles the AppDomain being unloaded. + Should be null. + The data associated with this event. - + - The connection was enlisted into a transaction. + This event is raised whenever SQLite raises a logging event. + Note that this should be set as one of the first things in the + application. - + - A command was created using the connection. + If this property is true, logging is enabled; otherwise, logging is + disabled. When logging is disabled, no logging events will fire. - + - A data reader was created using the connection. + Log a message to all the registered log event handlers without going + through the SQLite library. + The message to be logged. - + - An instance of a derived class has - been created to wrap a native resource. + Log a message to all the registered log event handlers without going + through the SQLite library. + The SQLite error code. + The message to be logged. - + - The connection is being closed. + Log a message to all the registered log event handlers without going + through the SQLite library. + The integer error code. + The message to be logged. - + - The connection was closed. + Log a message to all the registered log event handlers without going + through the SQLite library. + + The error code. The type of this object value should be + System.Int32 or SQLiteErrorCode. + + The message to be logged. - + - A command is being disposed. + Creates and initializes the default log event handler. - + - A data reader is being disposed. + Adds the default log event handler to the list of handlers. - + - A data reader is being closed. + Removes the default log event handler from the list of handlers. - + - This implementation of SQLite for ADO.NET can process date/time fields in - databases in one of six formats. - - - ISO8601 format is more compatible, readable, fully-processable, but less - accurate as it does not provide time down to fractions of a second. - JulianDay is the numeric format the SQLite uses internally and is arguably - the most compatible with 3rd party tools. It is not readable as text - without post-processing. Ticks less compatible with 3rd party tools that - query the database, and renders the DateTime field unreadable as text - without post-processing. UnixEpoch is more compatible with Unix systems. - InvariantCulture allows the configured format for the invariant culture - format to be used and is human readable. CurrentCulture allows the - configured format for the current culture to be used and is also human - readable. + Internal proxy function that calls any registered application log + event handlers. - The preferred order of choosing a DateTime format is JulianDay, ISO8601, - and then Ticks. Ticks is mainly present for legacy code support. - + WARNING: This method is used more-or-less directly by native code, + do not modify its type signature. +
+ + The extra data associated with this message, if any. + + + The error code associated with this message. + + + The message string to be logged. +
- + - Use the value of DateTime.Ticks. This value is not recommended and is not well supported with LINQ. + Default logger. Currently, uses the Trace class (i.e. sends events + to the current trace listeners, if any). + Should be null. + The data associated with this event. - + - Use the ISO-8601 format. Uses the "yyyy-MM-dd HH:mm:ss.FFFFFFFK" format for UTC DateTime values and - "yyyy-MM-dd HH:mm:ss.FFFFFFF" format for local DateTime values). + MetaDataCollections specific to SQLite - + - The interval of time in days and fractions of a day since January 1, 4713 BC. + Returns a list of databases attached to the connection - + - The whole number of seconds since the Unix epoch (January 1, 1970). + Returns column information for the specified table - + - Any culture-independent string value that the .NET Framework can interpret as a valid DateTime. + Returns index information for the optionally-specified table - + - Any string value that the .NET Framework can interpret as a valid DateTime using the current culture. + Returns base columns for the given index - + - The default format for this provider. + Returns the tables in the given catalog - - - This enum determines how SQLite treats its journal file. - - - By default SQLite will create and delete the journal file when needed during a transaction. - However, for some computers running certain filesystem monitoring tools, the rapid - creation and deletion of the journal file can cause those programs to fail, or to interfere with SQLite. - - If a program or virus scanner is interfering with SQLite's journal file, you may receive errors like "unable to open database file" - when starting a transaction. If this is happening, you may want to change the default journal mode to Persist. - + + + Returns user-defined views in the given catalog + - + - The default mode, this causes SQLite to use the existing journaling mode for the database. + Returns underlying column information on the given view - + - SQLite will create and destroy the journal file as-needed. + Returns foreign key information for the given catalog - + - When this is set, SQLite will keep the journal file even after a transaction has completed. It's contents will be erased, - and the journal re-used as often as needed. If it is deleted, it will be recreated the next time it is needed. + Returns the triggers on the database - + - This option disables the rollback journal entirely. Interrupted transactions or a program crash can cause database - corruption in this mode! + SQLite implementation of DbParameter. - + - SQLite will truncate the journal file to zero-length instead of deleting it. + This value represents an "unknown" . - + - SQLite will store the journal in volatile RAM. This saves disk I/O but at the expense of database safety and integrity. - If the application using SQLite crashes in the middle of a transaction when the MEMORY journaling mode is set, then the - database file will very likely go corrupt. + The command associated with this parameter. - + - SQLite uses a write-ahead log instead of a rollback journal to implement transactions. The WAL journaling mode is persistent; - after being set it stays in effect across multiple database connections and after closing and reopening the database. A database - in WAL journaling mode can only be accessed by SQLite version 3.7.0 or later. + The data type of the parameter - + - Possible values for the "synchronous" database setting. This setting determines - how often the database engine calls the xSync method of the VFS. + The version information for mapping the parameter - + - Use the default "synchronous" database setting. Currently, this should be - the same as using the FULL mode. + The value of the data in the parameter - + - The database engine continues without syncing as soon as it has handed - data off to the operating system. If the application running SQLite - crashes, the data will be safe, but the database might become corrupted - if the operating system crashes or the computer loses power before that - data has been written to the disk surface. + The source column for the parameter - + - The database engine will still sync at the most critical moments, but - less often than in FULL mode. There is a very small (though non-zero) - chance that a power failure at just the wrong time could corrupt the - database in NORMAL mode. + The column name - + - The database engine will use the xSync method of the VFS to ensure that - all content is safely written to the disk surface prior to continuing. - This ensures that an operating system crash or power failure will not - corrupt the database. FULL synchronous is very safe, but it is also - slower. + The data size, unused by SQLite - + - The requested command execution type. This controls which method of the - object will be called. + The database type name associated with this parameter, if any. - + - Do nothing. No method will be called. + Constructor used when creating for use with a specific command. + + The command associated with this parameter. + - + - The command is not expected to return a result -OR- the result is not - needed. The or - method - will be called. + Default constructor - + - The command is expected to return a scalar result -OR- the result should - be limited to a scalar result. The - or method will - be called. + Constructs a named parameter given the specified parameter name + The parameter name - + - The command is expected to return result. - The or - method will - be called. + Constructs a named parameter given the specified parameter name and initial value + The parameter name + The initial value of the parameter - + - Use the default command execution type. Using this value is the same - as using the value. + Constructs a named parameter of the specified type + The parameter name + The datatype of the parameter - + - The action code responsible for the current call into the authorizer. + Constructs a named parameter of the specified type and source column reference + The parameter name + The data type + The source column - + - No action is being performed. This value should not be used from - external code. + Constructs a named parameter of the specified type, source column and row version + The parameter name + The data type + The source column + The row version information - + - No longer used. + Constructs an unnamed parameter of the specified data type + The datatype of the parameter - - - An index will be created. The action-specific arguments are the - index name and the table name. - - + + + Constructs an unnamed parameter of the specified data type and sets the initial value + + The datatype of the parameter + The initial value of the parameter - + - A table will be created. The action-specific arguments are the - table name and a null value. + Constructs an unnamed parameter of the specified data type and source column + The datatype of the parameter + The source column - + - A temporary index will be created. The action-specific arguments - are the index name and the table name. + Constructs an unnamed parameter of the specified data type, source column and row version + The data type + The source column + The row version information - + - A temporary table will be created. The action-specific arguments - are the table name and a null value. + Constructs a named parameter of the specified type and size + The parameter name + The data type + The size of the parameter - + - A temporary trigger will be created. The action-specific arguments - are the trigger name and the table name. + Constructs a named parameter of the specified type, size and source column + The name of the parameter + The data type + The size of the parameter + The source column - + - A temporary view will be created. The action-specific arguments are - the view name and a null value. + Constructs a named parameter of the specified type, size, source column and row version + The name of the parameter + The data type + The size of the parameter + The source column + The row version information - + - A trigger will be created. The action-specific arguments are the - trigger name and the table name. + Constructs a named parameter of the specified type, size, source column and row version + The name of the parameter + The data type + The size of the parameter + Only input parameters are supported in SQLite + Ignored + Ignored + Ignored + The source column + The row version information + The initial value to assign the parameter - + - A view will be created. The action-specific arguments are the view - name and a null value. + Constructs a named parameter, yet another flavor + The name of the parameter + The data type + The size of the parameter + Only input parameters are supported in SQLite + Ignored + Ignored + The source column + The row version information + Whether or not this parameter is for comparing NULL's + The intial value to assign the parameter - + - A DELETE statement will be executed. The action-specific arguments - are the table name and a null value. + Constructs an unnamed parameter of the specified type and size + The data type + The size of the parameter - + - An index will be dropped. The action-specific arguments are the - index name and the table name. + Constructs an unnamed parameter of the specified type, size, and source column + The data type + The size of the parameter + The source column - + - A table will be dropped. The action-specific arguments are the tables - name and a null value. + Constructs an unnamed parameter of the specified type, size, source column and row version + The data type + The size of the parameter + The source column + The row version information - + - A temporary index will be dropped. The action-specific arguments are - the index name and the table name. + The command associated with this parameter. - + - A temporary table will be dropped. The action-specific arguments are - the table name and a null value. + Whether or not the parameter can contain a null value - + - A temporary trigger will be dropped. The action-specific arguments - are the trigger name and the table name. + Returns the datatype of the parameter - + - A temporary view will be dropped. The action-specific arguments are - the view name and a null value. + Supports only input parameters - + - A trigger will be dropped. The action-specific arguments are the - trigger name and the table name. + Returns the parameter name - + - A view will be dropped. The action-specific arguments are the view - name and a null value. + Resets the DbType of the parameter so it can be inferred from the value - + - An INSERT statement will be executed. The action-specific arguments - are the table name and a null value. + Returns the size of the parameter - + - A PRAGMA statement will be executed. The action-specific arguments - are the name of the PRAGMA and the new value or a null value. + Gets/sets the source column - + - A table column will be read. The action-specific arguments are the - table name and the column name. + Used by DbCommandBuilder to determine the mapping for nullable fields - + - A SELECT statement will be executed. The action-specific arguments - are both null values. + Gets and sets the row version - + - A transaction will be started, committed, or rolled back. The - action-specific arguments are the name of the operation (BEGIN, - COMMIT, or ROLLBACK) and a null value. + Gets and sets the parameter value. If no datatype was specified, the datatype will assume the type from the value given. - + - An UPDATE statement will be executed. The action-specific arguments - are the table name and the column name. + The database type name associated with this parameter, if any. - + - A database will be attached to the connection. The action-specific - arguments are the database file name and a null value. + Clones a parameter + A new, unassociated SQLiteParameter - + - A database will be detached from the connection. The action-specific - arguments are the database name and a null value. + SQLite implementation of DbParameterCollection. - + - The schema of a table will be altered. The action-specific arguments - are the database name and the table name. + The underlying command to which this collection belongs - + - An index will be deleted and then recreated. The action-specific - arguments are the index name and a null value. + The internal array of parameters in this collection - + - A table will be analyzed to gathers statistics about it. The - action-specific arguments are the table name and a null value. + Determines whether or not all parameters have been bound to their statement(s) - + - A virtual table will be created. The action-specific arguments are - the table name and the module name. + Initializes the collection + The command to which the collection belongs - + - A virtual table will be dropped. The action-specific arguments are - the table name and the module name. + Returns false - + - A SQL function will be called. The action-specific arguments are a - null value and the function name. + Returns false - + - A savepoint will be created, released, or rolled back. The - action-specific arguments are the name of the operation (BEGIN, - RELEASE, or ROLLBACK) and the savepoint name. + Returns false - + - A recursive query will be executed. The action-specific arguments - are two null values. + Returns null - + - The return code for the current call into the authorizer. + Retrieves an enumerator for the collection + An enumerator for the underlying array - + - The action will be allowed. + Adds a parameter to the collection + The parameter name + The data type + The size of the value + The source column + A SQLiteParameter object - + - The overall action will be disallowed and an error message will be - returned from the query preparation method. + Adds a parameter to the collection + The parameter name + The data type + The size of the value + A SQLiteParameter object - + - The specific action will be disallowed; however, the overall action - will continue. The exact effects of this return code vary depending - on the specific action, please refer to the SQLite core library - documentation for futher details. + Adds a parameter to the collection + The parameter name + The data type + A SQLiteParameter object - + - Class used internally to determine the datatype of a column in a resultset + Adds a parameter to the collection + The parameter to add + A zero-based index of where the parameter is located in the array - + - The DbType of the column, or DbType.Object if it cannot be determined + Adds a parameter to the collection + The parameter to add + A zero-based index of where the parameter is located in the array - + - The affinity of a column, used for expressions or when Type is DbType.Object + Adds a named/unnamed parameter and its value to the parameter collection. + Name of the parameter, or null to indicate an unnamed parameter + The initial value of the parameter + Returns the SQLiteParameter object created during the call. - + - Constructs a default instance of this type. + Adds an array of parameters to the collection + The array of parameters to add - + - Constructs an instance of this type with the specified field values. + Adds an array of parameters to the collection - - The type affinity to use for the new instance. - - - The database type to use for the new instance. - + The array of parameters to add - + - SQLite implementation of DbDataAdapter. + Clears the array and resets the collection - - - This class is just a shell around the DbDataAdapter. Nothing from - DbDataAdapter is overridden here, just a few constructors are defined. - + - Default constructor. + Determines if the named parameter exists in the collection + The name of the parameter to check + True if the parameter is in the collection - + - Constructs a data adapter using the specified select command. + Determines if the parameter exists in the collection - - The select command to associate with the adapter. - + The SQLiteParameter to check + True if the parameter is in the collection - + - Constructs a data adapter with the supplied select command text and - associated with the specified connection. + Not implemented - - The select command text to associate with the data adapter. - - - The connection to associate with the select command. - + + - + - Constructs a data adapter with the specified select command text, - and using the specified database connection string. + Returns a count of parameters in the collection - - The select command text to use to construct a select command. - - - A connection string suitable for passing to a new SQLiteConnection, - which is associated with the select command. - - + - Constructs a data adapter with the specified select command text, - and using the specified database connection string. + Overloaded to specialize the return value of the default indexer - - The select command text to use to construct a select command. - - - A connection string suitable for passing to a new SQLiteConnection, - which is associated with the select command. - - - Non-zero to parse the connection string using the built-in (i.e. - framework provided) parser when opening the connection. - + Name of the parameter to get/set + The specified named SQLite parameter - + - Raised by the underlying DbDataAdapter when a row is being updated + Overloaded to specialize the return value of the default indexer - The event's specifics + The index of the parameter to get/set + The specified SQLite parameter - + - Raised by DbDataAdapter after a row is updated + Retrieve a parameter by name from the collection - The event's specifics + The name of the parameter to fetch + A DbParameter object - + - Row updating event handler + Retrieves a parameter by its index in the collection + The index of the parameter to retrieve + A DbParameter object - + - Row updated event handler + Returns the index of a parameter given its name + The name of the parameter to find + -1 if not found, otherwise a zero-based index of the parameter - + - Gets/sets the select command for this DataAdapter + Returns the index of a parameter + The parameter to find + -1 if not found, otherwise a zero-based index of the parameter - + - Gets/sets the insert command for this DataAdapter + Inserts a parameter into the array at the specified location + The zero-based index to insert the parameter at + The parameter to insert - + - Gets/sets the update command for this DataAdapter + Removes a parameter from the collection + The parameter to remove - + - Gets/sets the delete command for this DataAdapter + Removes a parameter from the collection given its name + The name of the parameter to remove - + - SQLite implementation of DbDataReader. + Removes a parameter from the collection given its index + The zero-based parameter index to remove - + - Underlying command this reader is attached to + Re-assign the named parameter to a new parameter object + The name of the parameter to replace + The new parameter - + - The flags pertaining to the associated connection (via the command). + Re-assign a parameter at the specified index + The zero-based index of the parameter to replace + The new parameter - + - Index of the current statement in the command being processed + Un-binds all parameters from their statements - + - Current statement being Read() + This function attempts to map all parameters in the collection to all statements in a Command. + Since named parameters may span multiple statements, this function makes sure all statements are bound + to the same named parameter. Unnamed parameters are bound in sequence. - + - State of the current statement being processed. - -1 = First Step() executed, so the first Read() will be ignored - 0 = Actively reading - 1 = Finished reading - 2 = Non-row-returning statement, no records + Represents a single SQL statement in SQLite. - + - Number of records affected by the insert/update statements executed on the command + The underlying SQLite object this statement is bound to - + - Count of fields (columns) in the row-returning statement currently being processed + The command text of this SQL statement - + - The number of calls to Step() that have returned true (i.e. the number of rows that - have been read in the current result set). + The actual statement pointer - + - Maps the field (column) names to their corresponding indexes within the results. + An index from which unnamed parameters begin - + - Datatypes of active fields (columns) in the current statement, used for type-restricting data + Names of the parameters as SQLite understands them to be - + - The behavior of the datareader + Parameters for this statement - + - If set, then dispose of the command object when the reader is finished + Command this statement belongs to (if any) - + - If set, then raise an exception when the object is accessed after being disposed. + The flags associated with the parent connection object. - + - An array of rowid's for the active statement if CommandBehavior.KeyInfo is specified + Initializes the statement and attempts to get all information about parameters in the statement + The base SQLite object + The flags associated with the parent connection object + The statement + The command text for this statement + The previous command in a multi-statement command - + - Matches the version of the connection. + Disposes and finalizes the statement - + - The "stub" (i.e. placeholder) base schema name to use when returning - column schema information. Matches the base schema name used by the - associated connection. + If the underlying database connection is open, fetches the number of changed rows + resulting from the most recent query; otherwise, does nothing. + + The number of changes when true is returned. + Undefined if false is returned. + + + The read-only flag when true is returned. + Undefined if false is returned. + + Non-zero if the number of changed rows was fetched. - + - Internal constructor, initializes the datareader and sets up to begin executing statements + Called by SQLiteParameterCollection, this function determines if the specified parameter name belongs to + this statement, and if so, keeps a reference to the parameter so it can be bound later. - The SQLiteCommand this data reader is for - The expected behavior of the data reader + The parameter name to map + The parameter to assign it - + - Dispose of all resources used by this datareader. + Bind all parameters, making sure the caller didn't miss any - - + - Closes the datareader, potentially closing the connection as well if CommandBehavior.CloseConnection was specified. + This method attempts to query the database connection associated with + the statement in use. If the underlying command or connection is + unavailable, a null value will be returned. + + The connection object -OR- null if it is unavailable. + - + - Throw an error if the datareader is closed + Invokes the parameter binding callback configured for the database + type name associated with the specified column. If no parameter + binding callback is available for the database type name, do + nothing. + + The index of the column being read. + + + The instance being bound to the + command. + + + Non-zero if the default handling for the parameter binding call + should be skipped (i.e. the parameter should not be bound at all). + Great care should be used when setting this to non-zero. + - + - Throw an error if a row is not loaded + Perform the bind operation for an individual parameter + The index of the parameter to bind + The parameter we're binding - + - Enumerator support + SQLite implementation of DbTransaction that does not support nested transactions. - Returns a DbEnumerator object. - + - Forces the connection flags cached by this data reader to be refreshed - from the underlying connection. + Constructs the transaction object, binding it to the supplied connection + The connection to open a transaction on + TRUE to defer the writelock, or FALSE to lock immediately - - - SQLite is inherently un-typed. All datatypes in SQLite are natively strings. The definition of the columns of a table - and the affinity of returned types are all we have to go on to type-restrict data in the reader. - - This function attempts to verify that the type of data being requested of a column matches the datatype of the column. In - the case of columns that are not backed into a table definition, we attempt to match up the affinity of a column (int, double, string or blob) - to a set of known types that closely match that affinity. It's not an exact science, but its the best we can do. - - - This function throws an InvalidTypeCast() exception if the requested type doesn't match the column's definition or affinity. - - The index of the column to type-check - The type we want to get out of the column - - + - Retrieves the column as a boolean value + Disposes the transaction. If it is currently active, any changes are rolled back. - The index of the column to retrieve - bool - + - Retrieves the column as a single byte value + Commits the current transaction. - The index of the column to retrieve - byte - + - Retrieves a column as an array of bytes (blob) + Attempts to start a transaction. An exception will be thrown if the transaction cannot + be started for any reason. - The index of the column to retrieve - The zero-based index of where to begin reading the data - The buffer to write the bytes into - The zero-based index of where to begin writing into the array - The number of bytes to retrieve - The actual number of bytes written into the array - - To determine the number of bytes in the column, pass a null value for the buffer. The total length will be returned. - + TRUE to defer the writelock, or FALSE to lock immediately - + - Returns the column as a single character + Issue a ROLLBACK command against the database connection, + optionally re-throwing any caught exception. - The index of the column to retrieve - char + + Non-zero to re-throw caught exceptions. + - + - Retrieves a column as an array of chars (blob) + SQLite implementation of DbTransaction that does support nested transactions. - The index of the column to retrieve - The zero-based index of where to begin reading the data - The buffer to write the characters into - The zero-based index of where to begin writing into the array - The number of bytes to retrieve - The actual number of characters written into the array - - To determine the number of characters in the column, pass a null value for the buffer. The total length will be returned. - - + - Retrieves the name of the back-end datatype of the column + The original transaction level for the associated connection + when this transaction was created (i.e. begun). - The index of the column to retrieve - string - + - Retrieve the column as a date/time value + The SAVEPOINT name for this transaction, if any. This will + only be non-null if this transaction is a nested one. - The index of the column to retrieve - DateTime - + - Retrieve the column as a decimal value + Constructs the transaction object, binding it to the supplied connection - The index of the column to retrieve - decimal + The connection to open a transaction on + TRUE to defer the writelock, or FALSE to lock immediately - + - Returns the column as a double + Disposes the transaction. If it is currently active, any changes are rolled back. - The index of the column to retrieve - double - + - Returns the .NET type of a given column + Commits the current transaction. - The index of the column to retrieve - Type - + - Returns a column as a float value + Attempts to start a transaction. An exception will be thrown if the transaction cannot + be started for any reason. - The index of the column to retrieve - float + TRUE to defer the writelock, or FALSE to lock immediately - + - Returns the column as a Guid + Issue a ROLLBACK command against the database connection, + optionally re-throwing any caught exception. - The index of the column to retrieve - Guid + + Non-zero to re-throw caught exceptions. + - + - Returns the column as a short + Constructs the name of a new savepoint for this transaction. It + should only be called from the constructor of this class. - The index of the column to retrieve - Int16 + + The name of the new savepoint -OR- null if it cannot be constructed. + - + - Retrieves the column as an int + Base class used by to implement DbTransaction for SQLite. - The index of the column to retrieve - Int32 - + - Retrieves the column as a long + The connection to which this transaction is bound. - The index of the column to retrieve - Int64 - + - Retrieves the name of the column + Matches the version of the connection. - The index of the column to retrieve - string - + - Retrieves the i of a column, given its name + The isolation level for this transaction. - The name of the column to retrieve - The int i of the column - + - Schema information in SQLite is difficult to map into .NET conventions, so a lot of work must be done - to gather the necessary information so it can be represented in an ADO.NET manner. + Constructs the transaction object, binding it to the supplied connection - Returns a DataTable containing the schema information for the active SELECT statement being processed. + The connection to open a transaction on + TRUE to defer the writelock, or FALSE to lock immediately - + - Retrieves the column as a string + Gets the isolation level of the transaction. SQLite only supports Serializable transactions. - The index of the column to retrieve - string - + - Retrieves the column as an object corresponding to the underlying datatype of the column + Disposes the transaction. If it is currently active, any changes are rolled back. - The index of the column to retrieve - object - + - Retreives the values of multiple columns, up to the size of the supplied array + Returns the underlying connection to which this transaction applies. - The array to fill with values from the columns in the current resultset - The number of columns retrieved - + - Returns a collection containing all the column names and values for the - current row of data in the current resultset, if any. If there is no - current row or no current resultset, an exception may be thrown. + Forwards to the local Connection property - - The collection containing the column name and value information for the - current row of data in the current resultset or null if this information - cannot be obtained. - - + - Returns True if the specified column is null + Rolls back the active transaction. - The index of the column to retrieve - True or False - + - Moves to the next resultset in multiple row-returning SQL command. + Attempts to start a transaction. An exception will be thrown if the transaction cannot + be started for any reason. - True if the command was successful and a new resultset is available, False otherwise. + TRUE to defer the writelock, or FALSE to lock immediately - + - This method attempts to query the database connection associated with - the data reader in use. If the underlying command or connection is - unavailable, a null value will be returned. + Issue a ROLLBACK command against the database connection, + optionally re-throwing any caught exception. - - The connection object -OR- null if it is unavailable. - + + Non-zero to re-throw caught exceptions. + - + - Retrieves the SQLiteType for a given column and row value. + Checks the state of this transaction, optionally throwing an exception if a state + inconsistency is found. - - The original SQLiteType structure, based only on the column. - - - The textual value of the column for a given row. + + Non-zero to throw an exception if a state inconsistency is found. - The SQLiteType structure. + Non-zero if this transaction is valid; otherwise, false. - + - Retrieves the SQLiteType for a given column, and caches it to avoid repetetive interop calls. + This static class provides some methods that are shared between the + native library pre-loader and other classes. - The flags associated with the parent connection object. - The index of the column to retrieve - A SQLiteType structure - + - Reads the next row from the resultset + This lock is used to protect the static and + fields. - True if a new row was successfully loaded and is ready for processing - + - Not implemented. Returns 0 + This type is only present when running on Mono. - + - Returns the number of columns in the current resultset + This type is only present when running on .NET Core. - + - Returns the number of rows seen so far in the current result set. + Keeps track of whether we are running on Mono. Initially null, it is + set by the method on its first call. Later, it + is returned verbatim by the method. - + - Returns the number of visible fields in the current resultset + Keeps track of whether we are running on .NET Core. Initially null, + it is set by the method on its first + call. Later, it is returned verbatim by the + method. - + - Returns True if the resultset has rows that can be fetched + Keeps track of whether we successfully invoked the + method. Initially null, it is set by + the method on its first call. - + - Returns True if the data reader is closed + Determines the ID of the current process. Only used for debugging. + + The ID of the current process -OR- zero if it cannot be determined. + - + - Retrieve the count of records affected by an update/insert command. Only valid once the data reader is closed! + Determines whether or not this assembly is running on Mono. + + Non-zero if this assembly is running on Mono. + - + - Indexer to retrieve data from a column given its name + Determines whether or not this assembly is running on .NET Core. - The name of the column to retrieve data for - The value contained in the column + + Non-zero if this assembly is running on .NET Core. + - + - Indexer to retrieve data from a column given its i + Resets the cached value for the "PreLoadSQLite_BreakIntoDebugger" + configuration setting. - The index of the column to retrieve - The value contained in the column - + - SQLite exception class. + If the "PreLoadSQLite_BreakIntoDebugger" configuration setting is + present (e.g. via the environment), give the interactive user an + opportunity to attach a debugger to the current process; otherwise, + do nothing. - + - Private constructor for use with serialization. + Determines the ID of the current thread. Only used for debugging. - - Holds the serialized object data about the exception being thrown. - - - Contains contextual information about the source or destination. - + + The ID of the current thread -OR- zero if it cannot be determined. + - + - Public constructor for generating a SQLite exception given the error - code and message. + Determines if the specified flags are present within the flags + associated with the parent connection object. - - The SQLite return code to report. + + The flags associated with the parent connection object. - - Message text to go along with the return code message text. + + The flags to check for. + + Non-zero if the specified flag or flags were present; otherwise, + zero. + - + - Public constructor that uses the base class constructor for the error - message. + Determines if preparing a query should be logged. - Error message text. + + The flags associated with the parent connection object. + + + Non-zero if the query preparation should be logged; otherwise, zero. + - + - Public constructor that uses the default base class constructor. + Determines if pre-parameter binding should be logged. + + The flags associated with the parent connection object. + + + Non-zero if the pre-parameter binding should be logged; otherwise, + zero. + - + - Public constructor that uses the base class constructor for the error - message and inner exception. + Determines if parameter binding should be logged. - Error message text. - The original (inner) exception. + + The flags associated with the parent connection object. + + + Non-zero if the parameter binding should be logged; otherwise, zero. + - + - Adds extra information to the serialized object data specific to this - class type. This is only used for serialization. + Determines if an exception in a native callback should be logged. - - Holds the serialized object data about the exception being thrown. - - - Contains contextual information about the source or destination. + + The flags associated with the parent connection object. + + Non-zero if the exception should be logged; otherwise, zero. + - + - Returns the error message for the specified SQLite return code. + Determines if backup API errors should be logged. - The SQLite return code. - The error message or null if it cannot be found. + + The flags associated with the parent connection object. + + + Non-zero if the backup API error should be logged; otherwise, zero. + - + - Returns the composite error message based on the SQLite return code - and the optional detailed error message. + Determines if logging for the class is + disabled. - The SQLite return code. - Optional detailed error message. - Error message text for the return code. + + The flags associated with the parent connection object. + + + Non-zero if logging for the class is + disabled; otherwise, zero. + - + - Gets the associated SQLite result code for this exception as a - . This property returns the same - underlying value as the property. + Determines if errors should be logged. + + The flags associated with the parent connection object. + + + Non-zero if the error should be logged; + otherwise, zero. + - + - Gets the associated SQLite return code for this exception as an - . For desktop versions of the .NET Framework, - this property overrides the property of the same name within the - - class. This property returns the same underlying value as the - property. + Determines if exceptions should be + logged. + + The flags associated with the parent connection object. + + + Non-zero if the exception should be + logged; otherwise, zero. + - + - SQLite error codes. Actually, this enumeration represents a return code, - which may also indicate success in one of several ways (e.g. SQLITE_OK, - SQLITE_ROW, and SQLITE_DONE). Therefore, the name of this enumeration is - something of a misnomer. + Determines if the current process is running on one of the Windows + [sub-]platforms. + + Non-zero when running on Windows; otherwise, zero. + - + - The error code is unknown. This error code - is only used by the managed wrapper itself. + This is a wrapper around the + method. + On Mono, it has to call the method overload without the + parameter, due to a bug in Mono. + + This is used for culture-specific formatting. + + + The format string. + + + An array the objects to format. + + + The resulting string. + - + - Successful result + This static class provides a thin wrapper around the native library + loading features of the underlying platform. - + - SQL error or missing database + This delegate is used to wrap the concept of loading a native + library, based on a file name, and returning the loaded module + handle. + + The file name of the native library to load. + + + The native module handle upon success -OR- IntPtr.Zero on failure. + - + - Internal logic error in SQLite + This delegate is used to wrap the concept of querying the machine + name of the current process. + + The machine name for the current process -OR- null on failure. + - + - Access permission denied + Attempts to load the specified native library file using the Win32 + API. + + The file name of the native library to load. + + + The native module handle upon success -OR- IntPtr.Zero on failure. + - + - Callback routine requested an abort + Attempts to determine the machine name of the current process using + the Win32 API. + + The machine name for the current process -OR- null on failure. + - + - The database file is locked + Attempts to load the specified native library file using the POSIX + API. + + The file name of the native library to load. + + + The native module handle upon success -OR- IntPtr.Zero on failure. + - + - A table in the database is locked + Attempts to determine the machine name of the current process using + the POSIX API. + + The machine name for the current process -OR- null on failure. + - + - A malloc() failed + Attempts to load the specified native library file. + + The file name of the native library to load. + + + The native module handle upon success -OR- IntPtr.Zero on failure. + - + - Attempt to write a readonly database + Attempts to determine the machine name of the current process. + + The machine name for the current process -OR- null on failure. + - + - Operation terminated by sqlite3_interrupt() + This class declares P/Invoke methods to call native POSIX APIs. - + - Some kind of disk I/O error occurred + This structure is used when running on POSIX operating systems + to store information about the current machine, including the + human readable name of the operating system as well as that of + the underlying hardware. - + - The database disk image is malformed + This structure is passed directly to the P/Invoke method to + obtain the information about the current machine, including + the human readable name of the operating system as well as + that of the underlying hardware. - + - Unknown opcode in sqlite3_file_control() + This is the P/Invoke method that wraps the native Unix uname + function. See the POSIX documentation for full details on what it + does. + + Structure containing a preallocated byte buffer to fill with the + requested information. + + + Zero for success and less than zero upon failure. + - + - Insertion failed because database is full + This is the P/Invoke method that wraps the native Unix dlopen + function. See the POSIX documentation for full details on what it + does. + + The name of the executable library. + + + This must be a combination of the individual bit flags RTLD_LAZY, + RTLD_NOW, RTLD_GLOBAL, and/or RTLD_LOCAL. + + + The native module handle upon success -OR- IntPtr.Zero on failure. + - + - Unable to open the database file + This is the P/Invoke method that wraps the native Unix dlclose + function. See the POSIX documentation for full details on what it + does. + + The handle to the loaded native library. + + + Zero upon success -OR- non-zero on failure. + - + - Database lock protocol error + For use with dlopen(), bind function calls lazily. - + - Database is empty + For use with dlopen(), bind function calls immediately. - + - The database schema changed + For use with dlopen(), make symbols globally available. - + - String or BLOB exceeds size limit + For use with dlopen(), opposite of RTLD_GLOBAL, and the default. - + - Abort due to constraint violation + For use with dlopen(), the defaults used by this class. - + - Data type mismatch + These are the characters used to separate the string fields within + the raw buffer returned by the P/Invoke method. - + - Library used incorrectly + This method is a wrapper around the P/Invoke + method that extracts and returns the human readable strings from + the raw buffer. + + This structure, which contains strings, will be filled based on the + data placed in the raw buffer returned by the + P/Invoke method. + + + Non-zero upon success; otherwise, zero. + - + - Uses OS features not supported on host + This class declares P/Invoke methods to call native Win32 APIs. - + - Authorization denied + This is the P/Invoke method that wraps the native Win32 LoadLibrary + function. See the MSDN documentation for full details on what it + does. + + The name of the executable library. + + + The native module handle upon success -OR- IntPtr.Zero on failure. + - + - Auxiliary database format error + This is the P/Invoke method that wraps the native Win32 GetSystemInfo + function. See the MSDN documentation for full details on what it + does. + + The system information structure to be filled in by the function. + - + - 2nd parameter to sqlite3_bind out of range + This enumeration contains the possible values for the processor + architecture field of the system information structure. - + - File opened that is not a database file + This structure contains information about the current computer. This + includes the processor type, page size, memory addresses, etc. - + - Notifications from sqlite3_log() + This class declares P/Invoke methods to call native SQLite APIs. - + - Warnings from sqlite3_log() + The file extension used for dynamic link libraries. - + - sqlite3_step() has another row ready + The file extension used for the XML configuration file. - + - sqlite3_step() has finished executing + This is the name of the XML configuration file specific to the + System.Data.SQLite assembly. - + - Used to mask off extended result codes + This is the XML configuratrion file token that will be replaced with + the qualified path to the directory containing the XML configuration + file. - + - SQLite implementation of . + This is the environment variable token that will be replaced with + the qualified path to the directory containing this assembly. + + - SQLite implementation of . + This is the environment variable token that will be replaced with an + abbreviation of the target framework attribute value associated with + this assembly. - + - Constructs a new instance. + This lock is used to protect the static _SQLiteNativeModuleFileName, + _SQLiteNativeModuleHandle, and processorArchitecturePlatforms fields. - + - Static instance member which returns an instanced class. + This dictionary stores the mappings between processor architecture + names and platform names. These mappings are now used for two + purposes. First, they are used to determine if the assembly code + base should be used instead of the location, based upon whether one + or more of the named sub-directories exist within the assembly code + base. Second, they are used to assist in loading the appropriate + SQLite interop assembly into the current process. - + - Creates and returns a new object. + This is the cached return value from the + method -OR- null if that method + has never returned a valid value. - The new object. - + - Creates and returns a new object. + When this field is non-zero, it indicates the + method was not able to locate a + suitable assembly directory. The + method will check this + field and skips calls into the + method whenever it is non-zero. - The new object. - + - Creates and returns a new object. + This is the cached return value from the + method -OR- null if that method + has never returned a valid value. - The new object. - + - Creates and returns a new object. + When this field is non-zero, it indicates the + method was not able to locate a + suitable XML configuration file name. The + method will check this + field and skips calls into the + method whenever it is non-zero. - The new object. - + - Creates and returns a new object. + For now, this method simply calls the Initialize method. - The new object. - + - Creates and returns a new object. + Attempts to initialize this class by pre-loading the native SQLite + library for the processor architecture of the current process. - The new object. - + - Will provide a object in .NET 3.5. + Combines two path strings. - The class or interface type to query for. - + + The first path -OR- null. + + + The second path -OR- null. + + + The combined path string -OR- null if both of the original path + strings are null. + - + - This event is raised whenever SQLite raises a logging event. - Note that this should be set as one of the first things in the - application. This event is provided for backward compatibility only. - New code should use the class instead. + Resets the cached XML configuration file name value, thus forcing the + next call to method to rely + upon the method to fetch the + XML configuration file name. - - - This abstract class is designed to handle user-defined functions easily. An instance of the derived class is made for each - connection to the database. - - - Although there is one instance of a class derived from SQLiteFunction per database connection, the derived class has no access - to the underlying connection. This is necessary to deter implementers from thinking it would be a good idea to make database - calls during processing. - - It is important to distinguish between a per-connection instance, and a per-SQL statement context. One instance of this class - services all SQL statements being stepped through on that connection, and there can be many. One should never store per-statement - information in member variables of user-defined function classes. - - For aggregate functions, always create and store your per-statement data in the contextData object on the 1st step. This data will - be automatically freed for you (and Dispose() called if the item supports IDisposable) when the statement completes. - - - + - The base connection this function is attached to + Queries and returns the cached XML configuration file name for the + assembly containing the managed System.Data.SQLite components, if + available. If the cached XML configuration file name value is not + available, the method will + be used to obtain the XML configuration file name. + + The XML configuration file name -OR- null if it cannot be determined + or does not exist. + - + - Internal array used to keep track of aggregate function context data + Queries and returns the XML configuration file name for the assembly + containing the managed System.Data.SQLite components. + + The XML configuration file name -OR- null if it cannot be determined + or does not exist. + - + - The connection flags associated with this object (this should be the - same value as the flags associated with the parent connection object). + If necessary, replaces all supported XML configuration file tokens + with their associated values. + + The name of the XML configuration file being read. + + + A setting value read from the XML configuration file. + + + The value of the will all supported XML + configuration file tokens replaced. No return value is reserved + to indicate an error. This method cannot fail. + - + - Holds a reference to the callback function for user functions + Queries and returns the value of the specified setting, using the + specified XML configuration file. + + The name of the XML configuration file to read. + + + The name of the setting. + + + The value to be returned if the setting has not been set explicitly + or cannot be determined. + + + Non-zero to expand any environment variable references contained in + the setting value to be returned. This has no effect on the .NET + Compact Framework. + + + The value of the setting -OR- the default value specified by + if it has not been set explicitly or + cannot be determined. + - + - Holds a reference to the callbakc function for stepping in an aggregate function + Attempts to determine the target framework attribute value that is + associated with the specified managed assembly, if applicable. + + The managed assembly to read the target framework attribute value + from. + + + The value of the target framework attribute value for the specified + managed assembly -OR- null if it cannot be determined. If this + assembly was compiled with a version of the .NET Framework prior to + version 4.0, the value returned MAY reflect that version of the .NET + Framework instead of the one associated with the specified managed + assembly. + - + - Holds a reference to the callback function for finalizing an aggregate function + Accepts a long target framework attribute value and makes it into a + much shorter version, suitable for use with NuGet packages. + + The long target framework attribute value to convert. + + + The short target framework attribute value -OR- null if it cannot + be determined or converted. + - + - Holds a reference to the callback function for collation sequences + If necessary, replaces all supported environment variable tokens + with their associated values. + + A setting value read from an environment variable. + + + The value of the will all supported + environment variable tokens replaced. No return value is reserved + to indicate an error. This method cannot fail. + - + - Current context of the current callback. Only valid during a callback + Queries and returns the value of the specified setting, using the XML + configuration file and/or the environment variables for the current + process and/or the current system, when available. + + The name of the setting. + + + The value to be returned if the setting has not been set explicitly + or cannot be determined. + + + The value of the setting -OR- the default value specified by + if it has not been set explicitly or + cannot be determined. By default, all references to existing + environment variables will be expanded to their corresponding values + within the value to be returned unless either the "No_Expand" or + "No_Expand_" environment variable is set [to + anything]. + - + - This static list contains all the user-defined functions declared using the proper attributes. + Resets the cached assembly directory value, thus forcing the next + call to method to rely + upon the method to fetch the + assembly directory. - + - Internal constructor, initializes the function's internal variables. + Queries and returns the cached directory for the assembly currently + being executed, if available. If the cached assembly directory value + is not available, the method will + be used to obtain the assembly directory. + + The directory for the assembly currently being executed -OR- null if + it cannot be determined. + - + - Constructs an instance of this class using the specified data-type - conversion parameters. + Queries and returns the directory for the assembly currently being + executed. - - The DateTime format to be used when converting string values to a - DateTime and binding DateTime parameters. - - - The to be used when creating DateTime - values. - - - The format string to be used when parsing and formatting DateTime - values. - - - Non-zero to create a UTF-16 data-type conversion context; otherwise, - a UTF-8 data-type conversion context will be created. - + + The directory for the assembly currently being executed -OR- null if + it cannot be determined. + - + - Disposes of any active contextData variables that were not automatically cleaned up. Sometimes this can happen if - someone closes the connection while a DataReader is open. + The name of the environment variable containing the processor + architecture of the current process. - + - Placeholder for a user-defined disposal routine + The native module file name for the native SQLite library or null. - True if the object is being disposed explicitly - + - Scalar functions override this method to do their magic. + The native module handle for the native SQLite library or the value + IntPtr.Zero. - - Parameters passed to functions have only an affinity for a certain data type, there is no underlying schema available - to force them into a certain type. Therefore the only types you will ever see as parameters are - DBNull.Value, Int64, Double, String or byte[] array. - - The arguments for the command to process - You may return most simple types as a return value, null or DBNull.Value to return null, DateTime, or - you may return an Exception-derived class if you wish to return an error to SQLite. Do not actually throw the error, - just return it! - + - Aggregate functions override this method to do their magic. + Determines the base file name (without any directory information) + for the native SQLite library to be pre-loaded by this class. - - Typically you'll be updating whatever you've placed in the contextData field and returning as quickly as possible. - - The arguments for the command to process - The 1-based step number. This is incrememted each time the step method is called. - A placeholder for implementers to store contextual data pertaining to the current context. + + The base file name for the native SQLite library to be pre-loaded by + this class -OR- null if its value cannot be determined. + - + - Aggregate functions override this method to finish their aggregate processing. + Searches for the native SQLite library in the directory containing + the assembly currently being executed as well as the base directory + for the current application domain. - - If you implemented your aggregate function properly, - you've been recording and keeping track of your data in the contextData object provided, and now at this stage you should have - all the information you need in there to figure out what to return. - NOTE: It is possible to arrive here without receiving a previous call to Step(), in which case the contextData will - be null. This can happen when no rows were returned. You can either return null, or 0 or some other custom return value - if that is the case. - - Your own assigned contextData, provided for you so you can return your final results. - You may return most simple types as a return value, null or DBNull.Value to return null, DateTime, or - you may return an Exception-derived class if you wish to return an error to SQLite. Do not actually throw the error, - just return it! + + Upon success, this parameter will be modified to refer to the base + directory containing the native SQLite library. + + + Upon success, this parameter will be modified to refer to the name + of the immediate directory (i.e. the offset from the base directory) + containing the native SQLite library. + + + Upon success, this parameter will be modified to non-zero only if + the base directory itself should be allowed for loading the native + library. + + + Non-zero (success) if the native SQLite library was found; otherwise, + zero (failure). - + - User-defined collation sequences override this method to provide a custom string sorting algorithm. + Queries and returns the base directory of the current application + domain. - The first string to compare - The second strnig to compare - 1 if param1 is greater than param2, 0 if they are equal, or -1 if param1 is less than param2 + + The base directory for the current application domain -OR- null if it + cannot be determined. + - + - Converts an IntPtr array of context arguments to an object array containing the resolved parameters the pointers point to. + Determines if the dynamic link library file name requires a suffix + and adds it if necessary. - - Parameters passed to functions have only an affinity for a certain data type, there is no underlying schema available - to force them into a certain type. Therefore the only types you will ever see as parameters are - DBNull.Value, Int64, Double, String or byte[] array. - - The number of arguments - A pointer to the array of arguments - An object array of the arguments once they've been converted to .NET values + + The original dynamic link library file name to inspect. + + + The dynamic link library file name, possibly modified to include an + extension. + - + - Takes the return value from Invoke() and Final() and figures out how to return it to SQLite's context. + Queries and returns the processor architecture of the current + process. - The context the return value applies to - The parameter to return to SQLite + + The processor architecture of the current process -OR- null if it + cannot be determined. + - + - Internal scalar callback function, which wraps the raw context pointer and calls the virtual Invoke() method. - WARNING: Must not throw exceptions. + Given the processor architecture, returns the name of the platform. - A raw context pointer - Number of arguments passed in - A pointer to the array of arguments + + The processor architecture to be translated to a platform name. + + + The platform name for the specified processor architecture -OR- null + if it cannot be determined. + - + - Internal collation sequence function, which wraps up the raw string pointers and executes the Compare() virtual function. - WARNING: Must not throw exceptions. + Attempts to load the native SQLite library based on the specified + directory and processor architecture. - Not used - Length of the string pv1 - Pointer to the first string to compare - Length of the string pv2 - Pointer to the second string to compare - Returns -1 if the first string is less than the second. 0 if they are equal, or 1 if the first string is greater - than the second. Returns 0 if an exception is caught. + + The base directory to use, null for default (the base directory of + the current application domain). This directory should contain the + processor architecture specific sub-directories. + + + The requested processor architecture, null for default (the + processor architecture of the current process). This caller should + almost always specify null for this parameter. + + + Non-zero indicates that the native SQLite library can be loaded + from the base directory itself. + + + The candidate native module file name to load will be stored here, + if necessary. + + + The native module handle as returned by LoadLibrary will be stored + here, if necessary. This value will be IntPtr.Zero if the call to + LoadLibrary fails. + + + Non-zero if the native module was loaded successfully; otherwise, + zero. + - + - Internal collation sequence function, which wraps up the raw string pointers and executes the Compare() virtual function. - WARNING: Must not throw exceptions. + A strongly-typed resource class, for looking up localized strings, etc. - Not used - Length of the string pv1 - Pointer to the first string to compare - Length of the string pv2 - Pointer to the second string to compare - Returns -1 if the first string is less than the second. 0 if they are equal, or 1 if the first string is greater - than the second. Returns 0 if an exception is caught. - + - The internal aggregate Step function callback, which wraps the raw context pointer and calls the virtual Step() method. - WARNING: Must not throw exceptions. + Returns the cached ResourceManager instance used by this class. - - This function takes care of doing the lookups and getting the important information put together to call the Step() function. - That includes pulling out the user's contextData and updating it after the call is made. We use a sorted list for this so - binary searches can be done to find the data. - - A raw context pointer - Number of arguments passed in - A pointer to the array of arguments - + - An internal aggregate Final function callback, which wraps the context pointer and calls the virtual Final() method. - WARNING: Must not throw exceptions. + Overrides the current thread's CurrentUICulture property for all + resource lookups using this strongly typed resource class. - A raw context pointer - + + + Looks up a localized string similar to <?xml version="1.0" standalone="yes"?> + <DocumentElement> + <DataTypes> + <TypeName>smallint</TypeName> + <ProviderDbType>10</ProviderDbType> + <ColumnSize>5</ColumnSize> + <DataType>System.Int16</DataType> + <CreateFormat>smallint</CreateFormat> + <IsAutoIncrementable>false</IsAutoIncrementable> + <IsCaseSensitive>false</IsCaseSensitive> + <IsFixedLength>true</IsFixedLength> + <IsFixedPrecisionScale>true</IsFixedPrecisionScale> + <IsLong>false</IsLong> + <IsNullable>true</ [rest of string was truncated]";. + + + - Using reflection, enumerate all assemblies in the current appdomain looking for classes that - have a SQLiteFunctionAttribute attribute, and registering them accordingly. + Looks up a localized string similar to ALL,ALTER,AND,AS,AUTOINCREMENT,BETWEEN,BY,CASE,CHECK,COLLATE,COMMIT,CONSTRAINT,CREATE,CROSS,DEFAULT,DEFERRABLE,DELETE,DISTINCT,DROP,ELSE,ESCAPE,EXCEPT,FOREIGN,FROM,FULL,GROUP,HAVING,IN,INDEX,INNER,INSERT,INTERSECT,INTO,IS,ISNULL,JOIN,LEFT,LIMIT,NATURAL,NOT,NOTNULL,NULL,ON,OR,ORDER,OUTER,PRIMARY,REFERENCES,RIGHT,ROLLBACK,SELECT,SET,TABLE,THEN,TO,TRANSACTION,UNION,UNIQUE,UPDATE,USING,VALUES,WHEN,WHERE. - + + + Looks up a localized string similar to <?xml version="1.0" encoding="utf-8" ?> + <DocumentElement> + <MetaDataCollections> + <CollectionName>MetaDataCollections</CollectionName> + <NumberOfRestrictions>0</NumberOfRestrictions> + <NumberOfIdentifierParts>0</NumberOfIdentifierParts> + </MetaDataCollections> + <MetaDataCollections> + <CollectionName>DataSourceInformation</CollectionName> + <NumberOfRestrictions>0</NumberOfRestrictions> + <NumberOfIdentifierParts>0</NumberOfIdentifierParts> + </MetaDataCollections> + <MetaDataC [rest of string was truncated]";. + + + - Manual method of registering a function. The type must still have the SQLiteFunctionAttributes in order to work - properly, but this is a workaround for the Compact Framework where enumerating assemblies is not currently supported. + This interface represents a virtual table implementation written in + native code. - The type of the function to register - + - Called by SQLiteBase derived classes, this function binds all user-defined functions to a connection. - It is done this way so that all user-defined functions will access the database using the same encoding scheme - as the connection (UTF-8 or UTF-16). + + int (*xCreate)(sqlite3 *db, void *pAux, + int argc, char *const*argv, + sqlite3_vtab **ppVTab, + char **pzErr); + + + The xCreate method is called to create a new instance of a virtual table + in response to a CREATE VIRTUAL TABLE statement. + If the xCreate method is the same pointer as the xConnect method, then the + virtual table is an eponymous virtual table. + If the xCreate method is omitted (if it is a NULL pointer) then the virtual + table is an eponymous-only virtual table. + + + The db parameter is a pointer to the SQLite database connection that + is executing the CREATE VIRTUAL TABLE statement. + The pAux argument is the copy of the client data pointer that was the + fourth argument to the sqlite3_create_module() or + sqlite3_create_module_v2() call that registered the + virtual table module. + The argv parameter is an array of argc pointers to null terminated strings. + The first string, argv[0], is the name of the module being invoked. The + module name is the name provided as the second argument to + sqlite3_create_module() and as the argument to the USING clause of the + CREATE VIRTUAL TABLE statement that is running. + The second, argv[1], is the name of the database in which the new virtual table is being created. The database name is "main" for the primary database, or + "temp" for TEMP database, or the name given at the end of the ATTACH + statement for attached databases. The third element of the array, argv[2], + is the name of the new virtual table, as specified following the TABLE + keyword in the CREATE VIRTUAL TABLE statement. + If present, the fourth and subsequent strings in the argv[] array report + the arguments to the module name in the CREATE VIRTUAL TABLE statement. + + + The job of this method is to construct the new virtual table object + (an sqlite3_vtab object) and return a pointer to it in *ppVTab. + + + As part of the task of creating a new sqlite3_vtab structure, this + method must invoke sqlite3_declare_vtab() to tell the SQLite + core about the columns and datatypes in the virtual table. + The sqlite3_declare_vtab() API has the following prototype: + + + int sqlite3_declare_vtab(sqlite3 *db, const char *zCreateTable) + + + The first argument to sqlite3_declare_vtab() must be the same + database connection pointer as the first parameter to this method. + The second argument to sqlite3_declare_vtab() must a zero-terminated + UTF-8 string that contains a well-formed CREATE TABLE statement that + defines the columns in the virtual table and their data types. + The name of the table in this CREATE TABLE statement is ignored, + as are all constraints. Only the column names and datatypes matter. + The CREATE TABLE statement string need not to be + held in persistent memory. The string can be + deallocated and/or reused as soon as the sqlite3_declare_vtab() + routine returns. + + + The xCreate method need not initialize the pModule, nRef, and zErrMsg + fields of the sqlite3_vtab object. The SQLite core will take care of + that chore. + + + The xCreate should return SQLITE_OK if it is successful in + creating the new virtual table, or SQLITE_ERROR if it is not successful. + If not successful, the sqlite3_vtab structure must not be allocated. + An error message may optionally be returned in *pzErr if unsuccessful. + Space to hold the error message string must be allocated using + an SQLite memory allocation function like + sqlite3_malloc() or sqlite3_mprintf() as the SQLite core will + attempt to free the space using sqlite3_free() after the error has + been reported up to the application. + + + If the xCreate method is omitted (left as a NULL pointer) then the + virtual table is an eponymous-only virtual table. New instances of + the virtual table cannot be created using CREATE VIRTUAL TABLE and the + virtual table can only be used via its module name. + Note that SQLite versions prior to 3.9.0 (2015-10-14) do not understand + eponymous-only virtual tables and will segfault if an attempt is made + to CREATE VIRTUAL TABLE on an eponymous-only virtual table because + the xCreate method was not checked for null. + + + If the xCreate method is the exact same pointer as the xConnect method, + that indicates that the virtual table does not need to initialize backing + store. Such a virtual table can be used as an eponymous virtual table + or as a named virtual table using CREATE VIRTUAL TABLE or both. + + + If a column datatype contains the special keyword "HIDDEN" + (in any combination of upper and lower case letters) then that keyword + it is omitted from the column datatype name and the column is marked + as a hidden column internally. + A hidden column differs from a normal column in three respects: + + + ]]> + ]]> Hidden columns are not listed in the dataset returned by + "PRAGMA table_info", + ]]>]]> Hidden columns are not included in the expansion of a "*" + expression in the result set of a SELECT, and + ]]>]]> Hidden columns are not included in the implicit column-list + used by an INSERT statement that lacks an explicit column-list. + ]]>]]> + + + For example, if the following SQL is passed to sqlite3_declare_vtab(): + + + CREATE TABLE x(a HIDDEN VARCHAR(12), b INTEGER, c INTEGER Hidden); + + + Then the virtual table would be created with two hidden columns, + and with datatypes of "VARCHAR(12)" and "INTEGER". + + + An example use of hidden columns can be seen in the FTS3 virtual + table implementation, where every FTS virtual table + contains an FTS hidden column that is used to pass information from the + virtual table into FTS auxiliary functions and to the FTS MATCH operator. + + + A virtual table that contains hidden columns can be used like + a table-valued function in the FROM clause of a SELECT statement. + The arguments to the table-valued function become constraints on + the HIDDEN columns of the virtual table. + + + For example, the "generate_series" extension (located in the + ext/misc/series.c + file in the source tree) + implements an eponymous virtual table with the following schema: + + + CREATE TABLE generate_series( + value, + start HIDDEN, + stop HIDDEN, + step HIDDEN + ); + + + The sqlite3_module.xBestIndex method in the implementation of this + table checks for equality constraints against the HIDDEN columns, and uses + those as input parameters to determine the range of integer "value" outputs + to generate. Reasonable defaults are used for any unconstrained columns. + For example, to list all integers between 5 and 50: + + + SELECT value FROM generate_series(5,50); + + + The previous query is equivalent to the following: + + + SELECT value FROM generate_series WHERE start=5 AND stop=50; + + + Arguments on the virtual table name are matched to hidden columns + in order. The number of arguments can be less than the + number of hidden columns, in which case the latter hidden columns are + unconstrained. However, an error results if there are more arguments + than there are hidden columns in the virtual table. + + + Beginning with SQLite version 3.14.0 (2016-08-08), + the CREATE TABLE statement that + is passed into sqlite3_declare_vtab() may contain a WITHOUT ROWID clause. + This is useful for cases where the virtual table rows + cannot easily be mapped into unique integers. A CREATE TABLE + statement that includes WITHOUT ROWID must define one or more columns as + the PRIMARY KEY. Every column of the PRIMARY KEY must individually be + NOT NULL and all columns for each row must be collectively unique. + + + Note that SQLite does not enforce the PRIMARY KEY for a WITHOUT ROWID + virtual table. Enforcement is the responsibility of the underlying + virtual table implementation. But SQLite does assume that the PRIMARY KEY + constraint is valid - that the identified columns really are UNIQUE and + NOT NULL - and it uses that assumption to optimize queries against the + virtual table. + + + The rowid column is not accessible on a + WITHOUT ROWID virtual table (of course). + + + The xUpdate method was originally designed around having a + ROWID as a single value. The xUpdate method has been expanded to + accommodate an arbitrary PRIMARY KEY in place of the ROWID, but the + PRIMARY KEY must still be only one column. For this reason, SQLite + will reject any WITHOUT ROWID virtual table that has more than one + PRIMARY KEY column and a non-NULL xUpdate method. + - - The wrapper functions that interop with SQLite will create a unique cookie value, which internally is a pointer to - all the wrapped callback functions. The interop function uses it to map CDecl callbacks to StdCall callbacks. - - The base object on which the functions are to bind - The flags associated with the parent connection object - Returns a logical list of functions which the connection should retain until it is closed. + + The native database connection handle. + + + The original native pointer value that was provided to the + sqlite3_create_module(), sqlite3_create_module_v2() or + sqlite3_create_disposable_module() functions. + + + The number of arguments from the CREATE VIRTUAL TABLE statement. + + + The array of string arguments from the CREATE VIRTUAL TABLE + statement. + + + Upon success, this parameter must be modified to point to the newly + created native sqlite3_vtab derived structure. + + + Upon failure, this parameter must be modified to point to the error + message, with the underlying memory having been obtained from the + sqlite3_malloc() function. + + + A standard SQLite return code. + - + - This function binds a user-defined functions to a connection. + + int (*xConnect)(sqlite3*, void *pAux, + int argc, char *const*argv, + sqlite3_vtab **ppVTab, + char **pzErr); + + + The xConnect method is very similar to xCreate. + It has the same parameters and constructs a new sqlite3_vtab structure + just like xCreate. + And it must also call sqlite3_declare_vtab() like xCreate. + + + The difference is that xConnect is called to establish a new + connection to an existing virtual table whereas xCreate is called + to create a new virtual table from scratch. + + + The xCreate and xConnect methods are only different when the + virtual table has some kind of backing store that must be initialized + the first time the virtual table is created. The xCreate method creates + and initializes the backing store. The xConnect method just connects + to an existing backing store. When xCreate and xConnect are the same, + the table is an eponymous virtual table. + + + As an example, consider a virtual table implementation that + provides read-only access to existing comma-separated-value (CSV) + files on disk. There is no backing store that needs to be created + or initialized for such a virtual table (since the CSV files already + exist on disk) so the xCreate and xConnect methods will be identical + for that module. + + + Another example is a virtual table that implements a full-text index. + The xCreate method must create and initialize data structures to hold + the dictionary and posting lists for that index. The xConnect method, + on the other hand, only has to locate and use an existing dictionary + and posting lists that were created by a prior xCreate call. + + + The xConnect method must return SQLITE_OK if it is successful + in creating the new virtual table, or SQLITE_ERROR if it is not + successful. If not successful, the sqlite3_vtab structure must not be + allocated. An error message may optionally be returned in *pzErr if + unsuccessful. + Space to hold the error message string must be allocated using + an SQLite memory allocation function like + sqlite3_malloc() or sqlite3_mprintf() as the SQLite core will + attempt to free the space using sqlite3_free() after the error has + been reported up to the application. + + + The xConnect method is required for every virtual table implementation, + though the xCreate and xConnect pointers of the sqlite3_module object + may point to the same function if the virtual table does not need to + initialize backing store. + - - The object instance associated with the - that the function should be bound to. + + The native database connection handle. - - The object instance containing - the metadata for the function to be bound. + + The original native pointer value that was provided to the + sqlite3_create_module(), sqlite3_create_module_v2() or + sqlite3_create_disposable_module() functions. - - The object instance that implements the - function to be bound. + + The number of arguments from the CREATE VIRTUAL TABLE statement. - - The flags associated with the parent connection object. + + The array of string arguments from the CREATE VIRTUAL TABLE + statement. + + Upon success, this parameter must be modified to point to the newly + created native sqlite3_vtab derived structure. + + + Upon failure, this parameter must be modified to point to the error + message, with the underlying memory having been obtained from the + sqlite3_malloc() function. + + + A standard SQLite return code. + - - - Returns a reference to the underlying connection's SQLiteConvert class, which can be used to convert - strings and DateTime's into the current connection's encoding schema. - - - - - Extends SQLiteFunction and allows an inherited class to obtain the collating sequence associated with a function call. - - - User-defined functions can call the GetCollationSequence() method in this class and use it to compare strings and char arrays. - - - - - Obtains the collating sequence in effect for the given function. - - - - - - The type of user-defined function to declare - - - - - Scalar functions are designed to be called and return a result immediately. Examples include ABS(), Upper(), Lower(), etc. - - - - - Aggregate functions are designed to accumulate data until the end of a call and then return a result gleaned from the accumulated data. - Examples include SUM(), COUNT(), AVG(), etc. - - - - - Collation sequences are used to sort textual data in a custom manner, and appear in an ORDER BY clause. Typically text in an ORDER BY is - sorted using a straight case-insensitive comparison function. Custom collating sequences can be used to alter the behavior of text sorting - in a user-defined manner. - - - - - An internal callback delegate declaration. - - Raw native context pointer for the user function. - Total number of arguments to the user function. - Raw native pointer to the array of raw native argument pointers. - - - - An internal final callback delegate declaration. - - Raw context pointer for the user function - - - - Internal callback delegate for implementing collation sequences - - Not used - Length of the string pv1 - Pointer to the first string to compare - Length of the string pv2 - Pointer to the second string to compare - Returns -1 if the first string is less than the second. 0 if they are equal, or 1 if the first string is greater - than the second. - - + - The type of collating sequence + + SQLite uses the xBestIndex method of a virtual table module to determine + the best way to access the virtual table. + The xBestIndex method has a prototype like this: + + + int (*xBestIndex)(sqlite3_vtab *pVTab, sqlite3_index_info*); + + + The SQLite core communicates with the xBestIndex method by filling + in certain fields of the sqlite3_index_info structure and passing a + pointer to that structure into xBestIndex as the second parameter. + The xBestIndex method fills out other fields of this structure which + forms the reply. The sqlite3_index_info structure looks like this: + + + struct sqlite3_index_info { + /* Inputs */ + const int nConstraint; /* Number of entries in aConstraint */ + const struct sqlite3_index_constraint { + int iColumn; /* Column constrained. -1 for ROWID */ + unsigned char op; /* Constraint operator */ + unsigned char usable; /* True if this constraint is usable */ + int iTermOffset; /* Used internally - xBestIndex should ignore */ + } *const aConstraint; /* Table of WHERE clause constraints */ + const int nOrderBy; /* Number of terms in the ORDER BY clause */ + const struct sqlite3_index_orderby { + int iColumn; /* Column number */ + unsigned char desc; /* True for DESC. False for ASC. */ + } *const aOrderBy; /* The ORDER BY clause */ + /* Outputs */ + struct sqlite3_index_constraint_usage { + int argvIndex; /* if >0, constraint is part of argv to xFilter */ + unsigned char omit; /* Do not code a test for this constraint */ + } *const aConstraintUsage; + int idxNum; /* Number used to identify the index */ + char *idxStr; /* String, possibly obtained from sqlite3_malloc */ + int needToFreeIdxStr; /* Free idxStr using sqlite3_free() if true */ + int orderByConsumed; /* True if output is already ordered */ + double estimatedCost; /* Estimated cost of using this index */ + ]]>/* Fields below are only available in SQLite 3.8.2 and later */]]> + sqlite3_int64 estimatedRows; /* Estimated number of rows returned */ + ]]>/* Fields below are only available in SQLite 3.9.0 and later */]]> + int idxFlags; /* Mask of SQLITE_INDEX_SCAN_* flags */ + ]]>/* Fields below are only available in SQLite 3.10.0 and later */]]> + sqlite3_uint64 colUsed; /* Input: Mask of columns used by statement */ + }; + + + Note the warnings on the "estimatedRows", "idxFlags", and colUsed fields. + These fields were added with SQLite versions 3.8.2, 3.9.0, and 3.10.0, respectively. + Any extension that reads or writes these fields must first check that the + version of the SQLite library in use is greater than or equal to appropriate + version - perhaps comparing the value returned from sqlite3_libversion_number() + against constants 3008002, 3009000, and/or 3010000. The result of attempting + to access these fields in an sqlite3_index_info structure created by an + older version of SQLite are undefined. + + + In addition, there are some defined constants: + + + #define SQLITE_INDEX_CONSTRAINT_EQ 2 + #define SQLITE_INDEX_CONSTRAINT_GT 4 + #define SQLITE_INDEX_CONSTRAINT_LE 8 + #define SQLITE_INDEX_CONSTRAINT_LT 16 + #define SQLITE_INDEX_CONSTRAINT_GE 32 + #define SQLITE_INDEX_CONSTRAINT_MATCH 64 + #define SQLITE_INDEX_CONSTRAINT_LIKE 65 /* 3.10.0 and later */ + #define SQLITE_INDEX_CONSTRAINT_GLOB 66 /* 3.10.0 and later */ + #define SQLITE_INDEX_CONSTRAINT_REGEXP 67 /* 3.10.0 and later */ + #define SQLITE_INDEX_CONSTRAINT_NE 68 /* 3.21.0 and later */ + #define SQLITE_INDEX_CONSTRAINT_ISNOT 69 /* 3.21.0 and later */ + #define SQLITE_INDEX_CONSTRAINT_ISNOTNULL 70 /* 3.21.0 and later */ + #define SQLITE_INDEX_CONSTRAINT_ISNULL 71 /* 3.21.0 and later */ + #define SQLITE_INDEX_CONSTRAINT_IS 72 /* 3.21.0 and later */ + #define SQLITE_INDEX_CONSTRAINT_FUNCTION 150 /* 3.25.0 and later */ + #define SQLITE_INDEX_SCAN_UNIQUE 1 /* Scan visits at most 1 row */ + + + The SQLite core calls the xBestIndex method when it is compiling a query + that involves a virtual table. In other words, SQLite calls this method + when it is running sqlite3_prepare() or the equivalent. + By calling this method, the + SQLite core is saying to the virtual table that it needs to access + some subset of the rows in the virtual table and it wants to know the + most efficient way to do that access. The xBestIndex method replies + with information that the SQLite core can then use to conduct an + efficient search of the virtual table. + + + While compiling a single SQL query, the SQLite core might call + xBestIndex multiple times with different settings in sqlite3_index_info. + The SQLite core will then select the combination that appears to + give the best performance. + + + Before calling this method, the SQLite core initializes an instance + of the sqlite3_index_info structure with information about the + query that it is currently trying to process. This information + derives mainly from the WHERE clause and ORDER BY or GROUP BY clauses + of the query, but also from any ON or USING clauses if the query is a + join. The information that the SQLite core provides to the xBestIndex + method is held in the part of the structure that is marked as "Inputs". + The "Outputs" section is initialized to zero. + + + The information in the sqlite3_index_info structure is ephemeral + and may be overwritten or deallocated as soon as the xBestIndex method + returns. If the xBestIndex method needs to remember any part of the + sqlite3_index_info structure, it should make a copy. Care must be + take to store the copy in a place where it will be deallocated, such + as in the idxStr field with needToFreeIdxStr set to 1. + + + Note that xBestIndex will always be called before xFilter, since + the idxNum and idxStr outputs from xBestIndex are required inputs to + xFilter. However, there is no guarantee that xFilter will be called + following a successful xBestIndex. + + + The xBestIndex method is required for every virtual table implementation. + + + The main thing that the SQLite core is trying to communicate to + the virtual table is the constraints that are available to limit + the number of rows that need to be searched. The aConstraint[] array + contains one entry for each constraint. There will be exactly + nConstraint entries in that array. + + + Each constraint will usually correspond to a term in the WHERE clause + or in a USING or ON clause that is of the form + + + column OP EXPR + + + Where "column" is a column in the virtual table, OP is an operator + like "=" or "<", and EXPR is an arbitrary expression. So, for example, + if the WHERE clause contained a term like this: + + + a = 5 + + + Then one of the constraints would be on the "a" column with + operator "=" and an expression of "5". Constraints need not have a + literal representation of the WHERE clause. The query optimizer might + make transformations to the + WHERE clause in order to extract as many constraints + as it can. So, for example, if the WHERE clause contained something + like this: + + + x BETWEEN 10 AND 100 AND 999>y + + + The query optimizer might translate this into three separate constraints: + + + x >= 10 + x <= 100 + y < 999 + + + For each such constraint, the aConstraint[].iColumn field indicates which + column appears on the left-hand side of the constraint. + The first column of the virtual table is column 0. + The rowid of the virtual table is column -1. + The aConstraint[].op field indicates which operator is used. + The SQLITE_INDEX_CONSTRAINT_* constants map integer constants + into operator values. + Columns occur in the order they were defined by the call to + sqlite3_declare_vtab() in the xCreate or xConnect method. + Hidden columns are counted when determining the column index. + + + If the xFindFunction() method for the virtual table is defined, and + if xFindFunction() sometimes returns SQLITE_INDEX_CONSTRAINT_FUNCTION or + larger, then the constraints might also be of the form: + + + FUNCTION( column, EXPR) + + + In this case the aConstraint[].op value is the same as the value + returned by xFindFunction() for FUNCTION. + + + The aConstraint[] array contains information about all constraints + that apply to the virtual table. But some of the constraints might + not be usable because of the way tables are ordered in a join. + The xBestIndex method must therefore only consider constraints + that have an aConstraint[].usable flag which is true. + + + In addition to WHERE clause constraints, the SQLite core also + tells the xBestIndex method about the ORDER BY clause. + (In an aggregate query, the SQLite core might put in GROUP BY clause + information in place of the ORDER BY clause information, but this fact + should not make any difference to the xBestIndex method.) + If all terms of the ORDER BY clause are columns in the virtual table, + then nOrderBy will be the number of terms in the ORDER BY clause + and the aOrderBy[] array will identify the column for each term + in the order by clause and whether or not that column is ASC or DESC. + + + In SQLite version 3.10.0 (2016-01-06) and later, + the colUsed field is available + to indicate which fields of the virtual table are actually used by the + statement being prepared. If the lowest bit of colUsed is set, that + means that the first column is used. The second lowest bit corresponds + to the second column. And so forth. If the most significant bit of + colUsed is set, that means that one or more columns other than the + first 63 columns are used. If column usage information is needed by the + xFilter method, then the required bits must be encoded into either + the idxNum or idxStr output fields. + + + Given all of the information above, the job of the xBestIndex + method it to figure out the best way to search the virtual table. + + + The xBestIndex method fills the idxNum and idxStr fields with + information that communicates an indexing strategy to the xFilter + method. The information in idxNum and idxStr is arbitrary as far + as the SQLite core is concerned. The SQLite core just copies the + information through to the xFilter method. Any desired meaning can + be assigned to idxNum and idxStr as long as xBestIndex and xFilter + agree on what that meaning is. + + + The idxStr value may be a string obtained from an SQLite + memory allocation function such as sqlite3_mprintf(). + If this is the case, then the needToFreeIdxStr flag must be set to + true so that the SQLite core will know to call sqlite3_free() on + that string when it has finished with it, and thus avoid a memory leak. + The idxStr value may also be a static constant string, in which case + the needToFreeIdxStr boolean should remain false. + + + If the virtual table will output rows in the order specified by + the ORDER BY clause, then the orderByConsumed flag may be set to + true. If the output is not automatically in the correct order + then orderByConsumed must be left in its default false setting. + This will indicate to the SQLite core that it will need to do a + separate sorting pass over the data after it comes out of the virtual table. + + + The estimatedCost field should be set to the estimated number + of disk access operations required to execute this query against + the virtual table. The SQLite core will often call xBestIndex + multiple times with different constraints, obtain multiple cost + estimates, then choose the query plan that gives the lowest estimate. + The SQLite core initializes estimatedCost to a very large value + prior to invoking xBestIndex, so if xBestIndex determines that the + current combination of parameters is undesirable, it can leave the + estimatedCost field unchanged to discourage its use. + + + If the current version of SQLite is 3.8.2 or greater, the estimatedRows + field may be set to an estimate of the number of rows returned by the + proposed query plan. If this value is not explicitly set, the default + estimate of 25 rows is used. + + + If the current version of SQLite is 3.9.0 or greater, the idxFlags field + may be set to SQLITE_INDEX_SCAN_UNIQUE to indicate that the virtual table + will return only zero or one rows given the input constraints. Additional + bits of the idxFlags field might be understood in later versions of SQLite. + + + The aConstraintUsage[] array contains one element for each of + the nConstraint constraints in the inputs section of the + sqlite3_index_info structure. + The aConstraintUsage[] array is used by xBestIndex to tell the + core how it is using the constraints. + + + The xBestIndex method may set aConstraintUsage[].argvIndex + entries to values greater than zero. + Exactly one entry should be set to 1, another to 2, another to 3, + and so forth up to as many or as few as the xBestIndex method wants. + The EXPR of the corresponding constraints will then be passed + in as the argv[] parameters to xFilter. + + + For example, if the aConstraint[3].argvIndex is set to 1, then + when xFilter is called, the argv[0] passed to xFilter will have + the EXPR value of the aConstraint[3] constraint. + + + By default, the SQLite core double checks all constraints on + each row of the virtual table that it receives. If such a check + is redundant, the xBestFilter method can suppress that double-check by + setting aConstraintUsage[].omit. + + + The xBestIndex method should return SQLITE_OK on success. If any + kind of fatal error occurs, an appropriate error code (ex: SQLITE_NOMEM) + should be returned instead. + + + If xBestIndex returns SQLITE_CONSTRAINT, that does not indicate an + error. Rather, SQLITE_CONSTRAINT indicates that the particular combination + of input parameters specified should not be used in the query plan. + The SQLITE_CONSTRAINT return is useful for table-valued functions that + have required parameters. If the aConstraint[].usable field is false + for one of the required parameter, then the xBestIndex method should + return SQLITE_CONSTRAINT. + + + The following example will better illustrate the use of SQLITE_CONSTRAINT + as a return value from xBestIndex: + + + SELECT * FROM realtab, tablevaluedfunc(realtab.x); + + + Assuming that the first hidden column of "tablevaluedfunc" is "param1", + the query above is semantically equivalent to this: + + + SELECT * FROM realtab, tablevaluedfunc + WHERE tablevaluedfunc.param1 = realtab.x; + + + The query planner must decide between many possible implementations + of this query, but two plans in particular are of note: + + ]]> + ]]>Scan all + rows of realtab and for each row, find rows in tablevaluedfunc where + param1 is equal to realtab.x + ]]>]]>Scan all rows of tablevalued func and for each row find rows + in realtab where x is equal to tablevaluedfunc.param1. + ]]>]]> + + The xBestIndex method will be invoked once for each of the potential + plans above. For plan 1, the aConstraint[].usable flag for for the + SQLITE_CONSTRAINT_EQ constraint on the param1 column will be true because + the right-hand side value for the "param1 = ?" constraint will be known, + since it is determined by the outer realtab loop. + But for plan 2, the aConstraint[].usable flag for "param1 = ?" will be false + because the right-hand side value is determined by an inner loop and is thus + an unknown quantity. Because param1 is a required input to the table-valued + functions, the xBestIndex method should return SQLITE_CONSTRAINT when presented + with plan 2, indicating that a required input is missing. This forces the + query planner to select plan 1. + + + The native pointer to the sqlite3_vtab derived structure. + + + The native pointer to the sqlite3_index_info structure. + + + A standard SQLite return code. + - + - The built-in BINARY collating sequence + + int (*xDisconnect)(sqlite3_vtab *pVTab); + + + This method releases a connection to a virtual table. + Only the sqlite3_vtab object is destroyed. + The virtual table is not destroyed and any backing store + associated with the virtual table persists. + + This method undoes the work of xConnect. + + This method is a destructor for a connection to the virtual table. + Contrast this method with xDestroy. The xDestroy is a destructor + for the entire virtual table. + + + The xDisconnect method is required for every virtual table implementation, + though it is acceptable for the xDisconnect and xDestroy methods to be + the same function if that makes sense for the particular virtual table. + + + The native pointer to the sqlite3_vtab derived structure. + + + A standard SQLite return code. + - + - The built-in NOCASE collating sequence + + int (*xDestroy)(sqlite3_vtab *pVTab); + + + This method releases a connection to a virtual table, just like + the xDisconnect method, and it also destroys the underlying + table implementation. This method undoes the work of xCreate. + + + The xDisconnect method is called whenever a database connection + that uses a virtual table is closed. The xDestroy method is only + called when a DROP TABLE statement is executed against the virtual table. + + + The xDestroy method is required for every virtual table implementation, + though it is acceptable for the xDisconnect and xDestroy methods to be + the same function if that makes sense for the particular virtual table. + + + The native pointer to the sqlite3_vtab derived structure. + + + A standard SQLite return code. + - + - The built-in REVERSE collating sequence + + int (*xOpen)(sqlite3_vtab *pVTab, sqlite3_vtab_cursor **ppCursor); + + + The xOpen method creates a new cursor used for accessing (read and/or + writing) a virtual table. A successful invocation of this method + will allocate the memory for the sqlite3_vtab_cursor (or a subclass), + initialize the new object, and make *ppCursor point to the new object. + The successful call then returns SQLITE_OK. + + + For every successful call to this method, the SQLite core will + later invoke the xClose method to destroy + the allocated cursor. + + + The xOpen method need not initialize the pVtab field of the + sqlite3_vtab_cursor structure. The SQLite core will take care + of that chore automatically. + + + A virtual table implementation must be able to support an arbitrary + number of simultaneously open cursors. + + + When initially opened, the cursor is in an undefined state. + The SQLite core will invoke the xFilter method + on the cursor prior to any attempt to position or read from the cursor. + + + The xOpen method is required for every virtual table implementation. + + + The native pointer to the sqlite3_vtab derived structure. + + + Upon success, this parameter must be modified to point to the newly + created native sqlite3_vtab_cursor derived structure. + + + A standard SQLite return code. + - + - A custom user-defined collating sequence + + int (*xClose)(sqlite3_vtab_cursor*); + + + The xClose method closes a cursor previously opened by + xOpen. + The SQLite core will always call xClose once for each cursor opened + using xOpen. + + + This method must release all resources allocated by the + corresponding xOpen call. The routine will not be called again even if it + returns an error. The SQLite core will not use the + sqlite3_vtab_cursor again after it has been closed. + + + The xClose method is required for every virtual table implementation. + + + The native pointer to the sqlite3_vtab_cursor derived structure. + + + A standard SQLite return code. + - + - The encoding type the collation sequence uses + + int (*xFilter)(sqlite3_vtab_cursor*, int idxNum, const char *idxStr, + int argc, sqlite3_value **argv); + + + This method begins a search of a virtual table. + The first argument is a cursor opened by xOpen. + The next two arguments define a particular search index previously + chosen by xBestIndex. The specific meanings of idxNum and idxStr + are unimportant as long as xFilter and xBestIndex agree on what + that meaning is. + + + The xBestIndex function may have requested the values of + certain expressions using the aConstraintUsage[].argvIndex values + of the sqlite3_index_info structure. + Those values are passed to xFilter using the argc and argv parameters. + + + If the virtual table contains one or more rows that match the + search criteria, then the cursor must be left point at the first row. + Subsequent calls to xEof must return false (zero). + If there are no rows match, then the cursor must be left in a state + that will cause the xEof to return true (non-zero). + The SQLite engine will use + the xColumn and xRowid methods to access that row content. + The xNext method will be used to advance to the next row. + + + This method must return SQLITE_OK if successful, or an sqlite + error code if an error occurs. + + + The xFilter method is required for every virtual table implementation. + + + The native pointer to the sqlite3_vtab_cursor derived structure. + + + Number used to help identify the selected index. + + + The native pointer to the UTF-8 encoded string containing the + string used to help identify the selected index. + + + The number of native pointers to sqlite3_value structures specified + in . + + + An array of native pointers to sqlite3_value structures containing + filtering criteria for the selected index. + + + A standard SQLite return code. + - + - The collation sequence is UTF8 + + int (*xNext)(sqlite3_vtab_cursor*); + + + The xNext method advances a virtual table cursor + to the next row of a result set initiated by xFilter. + If the cursor is already pointing at the last row when this + routine is called, then the cursor no longer points to valid + data and a subsequent call to the xEof method must return true (non-zero). + If the cursor is successfully advanced to another row of content, then + subsequent calls to xEof must return false (zero). + + + This method must return SQLITE_OK if successful, or an sqlite + error code if an error occurs. + + + The xNext method is required for every virtual table implementation. + + + The native pointer to the sqlite3_vtab_cursor derived structure. + + + A standard SQLite return code. + - + - The collation sequence is UTF16 little-endian + + int (*xEof)(sqlite3_vtab_cursor*); + + + The xEof method must return false (zero) if the specified cursor + currently points to a valid row of data, or true (non-zero) otherwise. + This method is called by the SQL engine immediately after each + xFilter and xNext invocation. + + + The xEof method is required for every virtual table implementation. + + + The native pointer to the sqlite3_vtab_cursor derived structure. + + + Non-zero if no more rows are available; zero otherwise. + - + - The collation sequence is UTF16 big-endian + + int (*xColumn)(sqlite3_vtab_cursor*, sqlite3_context*, int N); + + + The SQLite core invokes this method in order to find the value for + the N-th column of the current row. N is zero-based so the first column + is numbered 0. + The xColumn method may return its result back to SQLite using one of the + following interface: + + + ]]> + ]]> sqlite3_result_blob() + ]]>]]> sqlite3_result_double() + ]]>]]> sqlite3_result_int() + ]]>]]> sqlite3_result_int64() + ]]>]]> sqlite3_result_null() + ]]>]]> sqlite3_result_text() + ]]>]]> sqlite3_result_text16() + ]]>]]> sqlite3_result_text16le() + ]]>]]> sqlite3_result_text16be() + ]]>]]> sqlite3_result_zeroblob() + ]]>]]> + + + If the xColumn method implementation calls none of the functions above, + then the value of the column defaults to an SQL NULL. + + + To raise an error, the xColumn method should use one of the result_text() + methods to set the error message text, then return an appropriate + error code. The xColumn method must return SQLITE_OK on success. + + + The xColumn method is required for every virtual table implementation. + + + The native pointer to the sqlite3_vtab_cursor derived structure. + + + The native pointer to the sqlite3_context structure to be used + for returning the specified column value to the SQLite core + library. + + + The zero-based index corresponding to the column containing the + value to be returned. + + + A standard SQLite return code. + - + - A struct describing the collating sequence a function is executing in + + int (*xRowid)(sqlite3_vtab_cursor *pCur, sqlite_int64 *pRowid); + + + A successful invocation of this method will cause *pRowid to be + filled with the rowid of row that the + virtual table cursor pCur is currently pointing at. + This method returns SQLITE_OK on success. + It returns an appropriate error code on failure. + + + The xRowid method is required for every virtual table implementation. + + + The native pointer to the sqlite3_vtab_cursor derived structure. + + + Upon success, this parameter must be modified to contain the unique + integer row identifier for the current row for the specified cursor. + + + A standard SQLite return code. + - + - The name of the collating sequence + + int (*xUpdate)( + sqlite3_vtab *pVTab, + int argc, + sqlite3_value **argv, + sqlite_int64 *pRowid + ); + + + All changes to a virtual table are made using the xUpdate method. + This one method can be used to insert, delete, or update. + + + The argc parameter specifies the number of entries in the argv array. + The value of argc will be 1 for a pure delete operation or N+2 for an insert + or replace or update where N is the number of columns in the table. + In the previous sentence, N includes any hidden columns. + + + Every argv entry will have a non-NULL value in C but may contain the + SQL value NULL. In other words, it is always true that + ]]>argv[i]!=0]]> for ]]>i]]> between 0 and ]]>argc-1]]>. + However, it might be the case that + ]]>sqlite3_value_type(argv[i])==SQLITE_NULL]]>. + + + The argv[0] parameter is the rowid of a row in the virtual table + to be deleted. If argv[0] is an SQL NULL, then no deletion occurs. + + + The argv[1] parameter is the rowid of a new row to be inserted + into the virtual table. If argv[1] is an SQL NULL, then the implementation + must choose a rowid for the newly inserted row. Subsequent argv[] + entries contain values of the columns of the virtual table, in the + order that the columns were declared. The number of columns will + match the table declaration that the xConnect or xCreate method made + using the sqlite3_declare_vtab() call. All hidden columns are included. + + + When doing an insert without a rowid (argc>1, argv[1] is an SQL NULL), + on a virtual table that uses ROWID (but not on a WITHOUT ROWID virtual table), + the implementation must set *pRowid to the rowid of the newly inserted row; + this will become the value returned by the sqlite3_last_insert_rowid() + function. Setting this value in all the other cases is a harmless no-op; + the SQLite engine ignores the *pRowid return value if argc==1 or + argv[1] is not an SQL NULL. + + + Each call to xUpdate will fall into one of cases shown below. + Not that references to ]]>argv[i]]]> mean the SQL value + held within the argv[i] object, not the argv[i] + object itself. + + + ]]> + ]]>]]>argc = 1 ]]> argv[0] ≠ NULL]]> + ]]>]]> + DELETE: The single row with rowid or PRIMARY KEY equal to argv[0] is deleted. + No insert occurs. + ]]>]]>]]>argc > 1 ]]> argv[0] = NULL]]> + ]]>]]> + INSERT: A new row is inserted with column values taken from + argv[2] and following. In a rowid virtual table, if argv[1] is an SQL NULL, + then a new unique rowid is generated automatically. The argv[1] will be NULL + for a WITHOUT ROWID virtual table, in which case the implementation should + take the PRIMARY KEY value from the appropriate column in argv[2] and following. + ]]>]]>]]>argc > 1 ]]> argv[0] ≠ NULL ]]> argv[0] = argv[1]]]> + ]]>]]> + UPDATE: + The row with rowid or PRIMARY KEY argv[0] is updated with new values + in argv[2] and following parameters. + ]]>]]>]]>argc > 1 ]]> argv[0] ≠ NULL ]]> argv[0] ≠ argv[1]]]> + ]]>]]> + UPDATE with rowid or PRIMARY KEY change: + The row with rowid or PRIMARY KEY argv[0] is updated with + the rowid or PRIMARY KEY in argv[1] + and new values in argv[2] and following parameters. This will occur + when an SQL statement updates a rowid, as in the statement: + + UPDATE table SET rowid=rowid+1 WHERE ...; + + ]]>]]> + + + The xUpdate method must return SQLITE_OK if and only if it is + successful. If a failure occurs, the xUpdate must return an appropriate + error code. On a failure, the pVTab->zErrMsg element may optionally + be replaced with error message text stored in memory allocated from SQLite + using functions such as sqlite3_mprintf() or sqlite3_malloc(). + + + If the xUpdate method violates some constraint of the virtual table + (including, but not limited to, attempting to store a value of the wrong + datatype, attempting to store a value that is too + large or too small, or attempting to change a read-only value) then the + xUpdate must fail with an appropriate error code. + + + If the xUpdate method is performing an UPDATE, then + sqlite3_value_nochange(X) can be used to discover which columns + of the virtual table were actually modified by the UPDATE + statement. The sqlite3_value_nochange(X) interface returns + true for columns that do not change. + On every UPDATE, SQLite will first invoke + xColumn separately for each unchanging column in the table to + obtain the value for that column. The xColumn method can + check to see if the column is unchanged at the SQL level + by invoking sqlite3_vtab_nochange(). If xColumn sees that + the column is not being modified, it should return without setting + a result using one of the sqlite3_result_xxxxx() + interfaces. Only in that case sqlite3_value_nochange() will be + true within the xUpdate method. If xColumn does + invoke one or more sqlite3_result_xxxxx() + interfaces, then SQLite understands that as a change in the value + of the column and the sqlite3_value_nochange() call for that + column within xUpdate will return false. + + + There might be one or more sqlite3_vtab_cursor objects open and in use + on the virtual table instance and perhaps even on the row of the virtual + table when the xUpdate method is invoked. The implementation of + xUpdate must be prepared for attempts to delete or modify rows of the table + out from other existing cursors. If the virtual table cannot accommodate + such changes, the xUpdate method must return an error code. + + + The xUpdate method is optional. + If the xUpdate pointer in the sqlite3_module for a virtual table + is a NULL pointer, then the virtual table is read-only. + + + The native pointer to the sqlite3_vtab derived structure. + + + The number of new or modified column values contained in + . + + + The array of native pointers to sqlite3_value structures containing + the new or modified column values, if any. + + + Upon success, this parameter must be modified to contain the unique + integer row identifier for the row that was inserted, if any. + + + A standard SQLite return code. + - + - The type of collating sequence + + int (*xBegin)(sqlite3_vtab *pVTab); + + + This method begins a transaction on a virtual table. + This is method is optional. The xBegin pointer of sqlite3_module + may be NULL. + + + This method is always followed by one call to either the + xCommit or xRollback method. Virtual table transactions do + not nest, so the xBegin method will not be invoked more than once + on a single virtual table + without an intervening call to either xCommit or xRollback. + Multiple calls to other methods can and likely will occur in between + the xBegin and the corresponding xCommit or xRollback. + + + The native pointer to the sqlite3_vtab derived structure. + + + A standard SQLite return code. + - + - The text encoding of the collation sequence + + int (*xSync)(sqlite3_vtab *pVTab); + + + This method signals the start of a two-phase commit on a virtual + table. + This is method is optional. The xSync pointer of sqlite3_module + may be NULL. + + + This method is only invoked after call to the xBegin method and + prior to an xCommit or xRollback. In order to implement two-phase + commit, the xSync method on all virtual tables is invoked prior to + invoking the xCommit method on any virtual table. If any of the + xSync methods fail, the entire transaction is rolled back. + + + The native pointer to the sqlite3_vtab derived structure. + + + A standard SQLite return code. + - + - Context of the function that requested the collating sequence + + int (*xCommit)(sqlite3_vtab *pVTab); + + + This method causes a virtual table transaction to commit. + This is method is optional. The xCommit pointer of sqlite3_module + may be NULL. + + + A call to this method always follows a prior call to xBegin and + xSync. + + + The native pointer to the sqlite3_vtab derived structure. + + + A standard SQLite return code. + - + - Calls the base collating sequence to compare two strings + + int (*xRollback)(sqlite3_vtab *pVTab); + + + This method causes a virtual table transaction to rollback. + This is method is optional. The xRollback pointer of sqlite3_module + may be NULL. + + + A call to this method always follows a prior call to xBegin. + - The first string to compare - The second string to compare - -1 if s1 is less than s2, 0 if s1 is equal to s2, and 1 if s1 is greater than s2 + + The native pointer to the sqlite3_vtab derived structure. + + + A standard SQLite return code. + - + - Calls the base collating sequence to compare two character arrays + + int (*xFindFunction)( + sqlite3_vtab *pVtab, + int nArg, + const char *zName, + void (**pxFunc)(sqlite3_context*,int,sqlite3_value**), + void **ppArg + ); + + + This method is called during sqlite3_prepare() to give the virtual + table implementation an opportunity to overload functions. + This method may be set to NULL in which case no overloading occurs. + + + When a function uses a column from a virtual table as its first + argument, this method is called to see if the virtual table would + like to overload the function. The first three parameters are inputs: + the virtual table, the number of arguments to the function, and the + name of the function. If no overloading is desired, this method + returns 0. To overload the function, this method writes the new + function implementation into *pxFunc and writes user data into *ppArg + and returns either 1 or a number between + SQLITE_INDEX_CONSTRAINT_FUNCTION and 255. + + + Historically, the return value from xFindFunction() was either zero + or one. Zero means that the function is not overloaded and one means that + it is overload. The ability to return values of + SQLITE_INDEX_CONSTRAINT_FUNCTION or greater was added in + version 3.25.0 (2018-09-15). If xFindFunction returns + SQLITE_INDEX_CONSTRAINT_FUNCTION or greater, than means that the function + takes two arguments and the function + can be used as a boolean in the WHERE clause of a query and that + the virtual table is able to exploit that function to speed up the query + result. When xFindFunction returns SQLITE_INDEX_CONSTRAINT_FUNCTION or + larger, the value returned becomes the sqlite3_index_info.aConstraint.op + value for one of the constraints passed into xBestIndex() and the second + argument becomes the value corresponding to that constraint that is passed + to xFilter(). This enables the + xBestIndex()/xFilter implementations to use the function to speed + its search. + + + The technique of having xFindFunction() return values of + SQLITE_INDEX_CONSTRAINT_FUNCTION was initially used in the implementation + of the Geopoly module. The xFindFunction() method of that module returns + SQLITE_INDEX_CONSTRAINT_FUNCTION for the geopoly_overlap() SQL function + and it returns + SQLITE_INDEX_CONSTRAINT_FUNCTION+1 for the geopoly_within() SQL function. + This permits search optimizations for queries such as: + + + SELECT * FROM geopolytab WHERE geopoly_overlap(_shape, $query_polygon); + + + Note that infix functions (LIKE, GLOB, REGEXP, and MATCH) reverse + the order of their arguments. So "like(A,B)" is equivalent to "B like A". + For the form "B like A" the B term is considered the first argument + to the function. But for "like(A,B)" the A term is considered the + first argument. + + + The function pointer returned by this routine must be valid for + the lifetime of the sqlite3_vtab object given in the first parameter. + - The first array to compare - The second array to compare - -1 if c1 is less than c2, 0 if c1 is equal to c2, and 1 if c1 is greater than c2 + + The native pointer to the sqlite3_vtab derived structure. + + + The number of arguments to the function being sought. + + + The name of the function being sought. + + + Upon success, this parameter must be modified to contain the + delegate responsible for implementing the specified function. + + + Upon success, this parameter must be modified to contain the + native user-data pointer associated with + . + + + Non-zero if the specified function was found; zero otherwise. + - + - A simple custom attribute to enable us to easily find user-defined functions in - the loaded assemblies and initialize them in SQLite as connections are made. + + int (*xRename)(sqlite3_vtab *pVtab, const char *zNew); + + + This method provides notification that the virtual table implementation + that the virtual table will be given a new name. + If this method returns SQLITE_OK then SQLite renames the table. + If this method returns an error code then the renaming is prevented. + + + The xRename method is optional. If omitted, then the virtual + table may not be renamed using the ALTER TABLE RENAME command. + + + The PRAGMA legacy_alter_table setting is enabled prior to invoking this + method, and the value for legacy_alter_table is restored after this + method finishes. This is necessary for the correct operation of virtual + tables that make use of shadow tables where the shadow tables must be + renamed to match the new virtual table name. If the legacy_alter_format is + off, then the xConnect method will be invoked for the virtual table every + time the xRename method tries to change the name of the shadow table. + + + The native pointer to the sqlite3_vtab derived structure. + + + The native pointer to the UTF-8 encoded string containing the new + name for the virtual table. + + + A standard SQLite return code. + - + - Default constructor, initializes the internal variables for the function. + + int (*xSavepoint)(sqlite3_vtab *pVtab, int); + int (*xRelease)(sqlite3_vtab *pVtab, int); + int (*xRollbackTo)(sqlite3_vtab *pVtab, int); + + + These methods provide the virtual table implementation an opportunity to + implement nested transactions. They are always optional and will only be + called in SQLite version 3.7.7 (2011-06-23) and later. + + + When xSavepoint(X,N) is invoked, that is a signal to the virtual table X + that it should save its current state as savepoint N. + A subsequent call + to xRollbackTo(X,R) means that the state of the virtual table should return + to what it was when xSavepoint(X,R) was last called. + The call + to xRollbackTo(X,R) will invalidate all savepoints with N>R; none of the + invalided savepoints will be rolled back or released without first + being reinitialized by a call to xSavepoint(). + A call to xRelease(X,M) invalidates all savepoints where N>=M. + + + None of the xSavepoint(), xRelease(), or xRollbackTo() methods will ever + be called except in between calls to xBegin() and + either xCommit() or xRollback(). + + + The native pointer to the sqlite3_vtab derived structure. + + + This is an integer identifier under which the the current state of + the virtual table should be saved. + + + A standard SQLite return code. + - + - Constructs an instance of this class. + + int (*xSavepoint)(sqlite3_vtab *pVtab, int); + int (*xRelease)(sqlite3_vtab *pVtab, int); + int (*xRollbackTo)(sqlite3_vtab *pVtab, int); + + + These methods provide the virtual table implementation an opportunity to + implement nested transactions. They are always optional and will only be + called in SQLite version 3.7.7 (2011-06-23) and later. + + + When xSavepoint(X,N) is invoked, that is a signal to the virtual table X + that it should save its current state as savepoint N. + A subsequent call + to xRollbackTo(X,R) means that the state of the virtual table should return + to what it was when xSavepoint(X,R) was last called. + The call + to xRollbackTo(X,R) will invalidate all savepoints with N>R; none of the + invalided savepoints will be rolled back or released without first + being reinitialized by a call to xSavepoint(). + A call to xRelease(X,M) invalidates all savepoints where N>=M. + + + None of the xSavepoint(), xRelease(), or xRollbackTo() methods will ever + be called except in between calls to xBegin() and + either xCommit() or xRollback(). + - - The name of the function, as seen by the SQLite core library. - - - The number of arguments that the function will accept. + + The native pointer to the sqlite3_vtab derived structure. - - The type of function being declared. This will either be Scalar, - Aggregate, or Collation. + + This is an integer used to indicate that any saved states with an + identifier greater than or equal to this should be deleted by the + virtual table. + + A standard SQLite return code. + - + - The function's name as it will be used in SQLite command text. + + int (*xSavepoint)(sqlite3_vtab *pVtab, int); + int (*xRelease)(sqlite3_vtab *pVtab, int); + int (*xRollbackTo)(sqlite3_vtab *pVtab, int); + + + These methods provide the virtual table implementation an opportunity to + implement nested transactions. They are always optional and will only be + called in SQLite version 3.7.7 (2011-06-23) and later. + + + When xSavepoint(X,N) is invoked, that is a signal to the virtual table X + that it should save its current state as savepoint N. + A subsequent call + to xRollbackTo(X,R) means that the state of the virtual table should return + to what it was when xSavepoint(X,R) was last called. + The call + to xRollbackTo(X,R) will invalidate all savepoints with N>R; none of the + invalided savepoints will be rolled back or released without first + being reinitialized by a call to xSavepoint(). + A call to xRelease(X,M) invalidates all savepoints where N>=M. + + + None of the xSavepoint(), xRelease(), or xRollbackTo() methods will ever + be called except in between calls to xBegin() and + either xCommit() or xRollback(). + + + The native pointer to the sqlite3_vtab derived structure. + + + This is an integer identifier used to specify a specific saved + state for the virtual table for it to restore itself back to, which + should also have the effect of deleting all saved states with an + integer identifier greater than this one. + + + A standard SQLite return code. + - + - The number of arguments this function expects. -1 if the number of arguments is variable. + This class represents a context from the SQLite core library that can + be passed to the sqlite3_result_*() and associated functions. - + - The type of function this implementation will be. + The native context handle. - + - The object instance that describes the class - containing the implementation for the associated function. + Constructs an instance of this class using the specified native + context handle. + + The native context handle to use. + - + - This class provides key info for a given SQLite statement. - - Providing key information for a given statement is non-trivial :( - + Returns the underlying SQLite native handle associated with this + object instance. - + - This function does all the nasty work at determining what keys need to be returned for - a given statement. + Sets the context result to NULL. - - - - + - Make sure all the subqueries are open and ready and sync'd with the current rowid - of the table they're supporting + Sets the context result to the specified + value. + + The value to use. + - + - Release any readers on any subqueries + Sets the context result to the specified + value. + + The value to use. + - + - Append all the columns we've added to the original query to the schema + Sets the context result to the specified + value. - + + The value to use. + - + - How many additional columns of keyinfo we're holding + Sets the context result to the specified + value. + + The value to use. This value will be + converted to the UTF-8 encoding prior to being used. + - + - Used to support CommandBehavior.KeyInfo + Sets the context result to the specified + value containing an error message. + + The value containing the error message text. + This value will be converted to the UTF-8 encoding prior to being + used. + - + - A single sub-query for a given table/database. + Sets the context result to the specified + value. + + The value to use. + - + - Event data for logging event handlers. + Sets the context result to contain the error code SQLITE_TOOBIG. - + - The error code. The type of this object value should be - or . + Sets the context result to contain the error code SQLITE_NOMEM. - + - SQL statement text as the statement first begins executing + Sets the context result to the specified array + value. + + The array value to use. + - + - Extra data associated with this event, if any. + Sets the context result to a BLOB of zeros of the specified size. + + The number of zero bytes to use for the BLOB context result. + - + - Constructs the object. + Sets the context result to the specified . - Should be null. - - The error code. The type of this object value should be - or . + + The to use. - The error message, if any. - The extra data, if any. - + + + This class represents a value from the SQLite core library that can be + passed to the sqlite3_value_*() and associated functions. + + + - Raised when a log event occurs. + The native value handle. - The current connection - Event arguments of the trace - + - Manages the SQLite custom logging functionality and the associated - callback for the whole process. + Constructs an instance of this class using the specified native + value handle. + + The native value handle to use. + - + - Object used to synchronize access to the static instance data - for this class. + Invalidates the native value handle, thereby preventing further + access to it from this object instance. - + - Member variable to store the AppDomain.DomainUnload event handler. + Converts a native pointer to a native sqlite3_value structure into + a managed object instance. + + The native pointer to a native sqlite3_value structure to convert. + + + The managed object instance or null upon + failure. + - + - The default log event handler. + Converts a logical array of native pointers to native sqlite3_value + structures into a managed array of + object instances. + + The number of elements in the logical array of native sqlite3_value + structures. + + + The native pointer to the logical array of native sqlite3_value + structures to convert. + + + The managed array of object instances or + null upon failure. + - + - The log callback passed to native SQLite engine. This must live - as long as the SQLite library has a pointer to it. + Returns the underlying SQLite native handle associated with this + object instance. - + - The base SQLite object to interop with. + Returns non-zero if the native SQLite value has been successfully + persisted as a managed value within this object instance (i.e. the + property may then be read successfully). - + - This will be non-zero if logging is currently enabled. + If the managed value for this object instance is available (i.e. it + has been previously persisted via the ) method, + that value is returned; otherwise, an exception is thrown. The + returned value may be null. - + - Initializes the SQLite logging facilities. + Gets and returns the type affinity associated with this value. + + The type affinity associated with this value. + - + - Handles the AppDomain being unloaded. + Gets and returns the number of bytes associated with this value, if + it refers to a UTF-8 encoded string. - Should be null. - The data associated with this event. + + The number of bytes associated with this value. The returned value + may be zero. + - + - Log a message to all the registered log event handlers without going - through the SQLite library. + Gets and returns the associated with this + value. - The message to be logged. + + The associated with this value. + - + - Log a message to all the registered log event handlers without going - through the SQLite library. + Gets and returns the associated with + this value. - The SQLite error code. - The message to be logged. + + The associated with this value. + - + - Log a message to all the registered log event handlers without going - through the SQLite library. + Gets and returns the associated with this + value. - The integer error code. - The message to be logged. + + The associated with this value. + - + - Log a message to all the registered log event handlers without going - through the SQLite library. + Gets and returns the associated with this + value. - - The error code. The type of this object value should be - System.Int32 or SQLiteErrorCode. - - The message to be logged. + + The associated with this value. The value is + converted from the UTF-8 encoding prior to being returned. + - + - Creates and initializes the default log event handler. + Gets and returns the array associated with this + value. + + The array associated with this value. + - + - Adds the default log event handler to the list of handlers. + Gets and returns an instance associated with + this value. + + The associated with this value. If the type + affinity of the object is unknown or cannot be determined, a null + value will be returned. + - + - Removes the default log event handler from the list of handlers. + Uses the native value handle to obtain and store the managed value + for this object instance, thus saving it for later use. The type + of the managed value is determined by the type affinity of the + native value. If the type affinity is not recognized by this + method, no work is done and false is returned. + + Non-zero if the native value was persisted successfully. + - - - Internal proxy function that calls any registered application log - event handlers. - - WARNING: This method is used more-or-less directly by native code, - do not modify its type signature. - - - The extra data associated with this message, if any. - - - The error code associated with this message. - - - The message string to be logged. - + + + These are the allowed values for the operators that are part of a + constraint term in the WHERE clause of a query that uses a virtual + table. + - + - Default logger. Currently, uses the Trace class (i.e. sends events - to the current trace listeners, if any). + This value represents the equality operator. - Should be null. - The data associated with this event. - + - Member variable to store the application log handler to call. + This value represents the greater than operator. - + - This event is raised whenever SQLite raises a logging event. - Note that this should be set as one of the first things in the - application. + This value represents the less than or equal to operator. - + - If this property is true, logging is enabled; otherwise, logging is - disabled. When logging is disabled, no logging events will fire. + This value represents the less than operator. - + - MetaDataCollections specific to SQLite + This value represents the greater than or equal to operator. - + - Returns a list of databases attached to the connection + This value represents the MATCH operator. - + - Returns column information for the specified table + This value represents the LIKE operator. - + - Returns index information for the optionally-specified table + This value represents the GLOB operator. - + - Returns base columns for the given index + This value represents the REGEXP operator. - + - Returns the tables in the given catalog + This value represents the inequality operator. - + - Returns user-defined views in the given catalog + This value represents the IS NOT operator. - + - Returns underlying column information on the given view + This value represents the IS NOT NULL operator. - + - Returns foreign key information for the given catalog + This value represents the IS NULL operator. - + - Returns the triggers on the database + This value represents the IS operator. - + - SQLite implementation of DbParameter. + These are the allowed values for the index flags from the + method. - + - The data type of the parameter + No special handling. This is the default. - + - The version information for mapping the parameter + This value indicates that the scan of the index will visit at + most one row. - + - The value of the data in the parameter + This class represents the native sqlite3_index_constraint structure + from the SQLite core library. - + - The source column for the parameter + Constructs an instance of this class using the specified native + sqlite3_index_constraint structure. + + The native sqlite3_index_constraint structure to use. + - + - The column name + Constructs an instance of this class using the specified field + values. + + Column on left-hand side of constraint. + + + Constraint operator (). + + + True if this constraint is usable. + + + Used internally - + should ignore. + - + - The data size, unused by SQLite + Column on left-hand side of constraint. - + - Default constructor + Constraint operator (). - + - Constructs a named parameter given the specified parameter name + True if this constraint is usable. - The parameter name - + - Constructs a named parameter given the specified parameter name and initial value + Used internally - + should ignore. - The parameter name - The initial value of the parameter - + - Constructs a named parameter of the specified type + This class represents the native sqlite3_index_orderby structure from + the SQLite core library. - The parameter name - The datatype of the parameter - + - Constructs a named parameter of the specified type and source column reference + Constructs an instance of this class using the specified native + sqlite3_index_orderby structure. - The parameter name - The data type - The source column + + The native sqlite3_index_orderby structure to use. + - + - Constructs a named parameter of the specified type, source column and row version + Constructs an instance of this class using the specified field + values. - The parameter name - The data type - The source column - The row version information + + Column number. + + + True for DESC. False for ASC. + - + - Constructs an unnamed parameter of the specified data type + Column number. - The datatype of the parameter - + - Constructs an unnamed parameter of the specified data type and sets the initial value + True for DESC. False for ASC. - The datatype of the parameter - The initial value of the parameter - + - Constructs an unnamed parameter of the specified data type and source column + This class represents the native sqlite3_index_constraint_usage + structure from the SQLite core library. - The datatype of the parameter - The source column - + - Constructs an unnamed parameter of the specified data type, source column and row version + Constructs a default instance of this class. - The data type - The source column - The row version information - + - Constructs a named parameter of the specified type and size + Constructs an instance of this class using the specified native + sqlite3_index_constraint_usage structure. - The parameter name - The data type - The size of the parameter + + The native sqlite3_index_constraint_usage structure to use. + - + - Constructs a named parameter of the specified type, size and source column + Constructs an instance of this class using the specified field + values. - The name of the parameter - The data type - The size of the parameter - The source column + + If greater than 0, constraint is part of argv to xFilter. + + + Do not code a test for this constraint. + - + - Constructs a named parameter of the specified type, size, source column and row version + If greater than 0, constraint is part of argv to xFilter. - The name of the parameter - The data type - The size of the parameter - The source column - The row version information - + - Constructs a named parameter of the specified type, size, source column and row version + Do not code a test for this constraint. - The name of the parameter - The data type - The size of the parameter - Only input parameters are supported in SQLite - Ignored - Ignored - Ignored - The source column - The row version information - The initial value to assign the parameter - + - Constructs a named parameter, yet another flavor + This class represents the various inputs provided by the SQLite core + library to the method. - The name of the parameter - The data type - The size of the parameter - Only input parameters are supported in SQLite - Ignored - Ignored - The source column - The row version information - Whether or not this parameter is for comparing NULL's - The intial value to assign the parameter - + - Constructs an unnamed parameter of the specified type and size + Constructs an instance of this class. - The data type - The size of the parameter + + The number of instances to + pre-allocate space for. + + + The number of instances to + pre-allocate space for. + - + - Constructs an unnamed parameter of the specified type, size, and source column + An array of object instances, + each containing information supplied by the SQLite core library. - The data type - The size of the parameter - The source column - + - Constructs an unnamed parameter of the specified type, size, source column and row version + An array of object instances, + each containing information supplied by the SQLite core library. - The data type - The size of the parameter - The source column - The row version information - + - Resets the DbType of the parameter so it can be inferred from the value + This class represents the various outputs provided to the SQLite core + library by the method. - + - Clones a parameter + Constructs an instance of this class. - A new, unassociated SQLiteParameter + + The number of instances + to pre-allocate space for. + - + - Whether or not the parameter can contain a null value + Determines if the native estimatedRows field can be used, based on + the available version of the SQLite core library. + + Non-zero if the property is supported + by the SQLite core library. + - + - Returns the datatype of the parameter + Determines if the native flags field can be used, based on the + available version of the SQLite core library. + + Non-zero if the property is supported by + the SQLite core library. + - + - Supports only input parameters + Determines if the native flags field can be used, based on the + available version of the SQLite core library. + + Non-zero if the property is supported by + the SQLite core library. + - + - Returns the parameter name + An array of object + instances, each containing information to be supplied to the SQLite + core library. - + - Returns the size of the parameter + Number used to help identify the selected index. This value will + later be provided to the + method. - + - Gets/sets the source column + String used to help identify the selected index. This value will + later be provided to the + method. - + - Used by DbCommandBuilder to determine the mapping for nullable fields + Non-zero if the index string must be freed by the SQLite core + library. - + - Gets and sets the row version + True if output is already ordered. - + - Gets and sets the parameter value. If no datatype was specified, the datatype will assume the type from the value given. + Estimated cost of using this index. Using a null value here + indicates that a default estimated cost value should be used. - + - SQLite implementation of DbParameterCollection. + Estimated number of rows returned. Using a null value here + indicates that a default estimated rows value should be used. + This property has no effect if the SQLite core library is not at + least version 3.8.2. - + - The underlying command to which this collection belongs + The flags that should be used with this index. Using a null value + here indicates that a default flags value should be used. This + property has no effect if the SQLite core library is not at least + version 3.9.0. - + - The internal array of parameters in this collection + + Indicates which columns of the virtual table may be required by the + current scan. Virtual table columns are numbered from zero in the + order in which they appear within the CREATE TABLE statement passed + to sqlite3_declare_vtab(). For the first 63 columns (columns 0-62), + the corresponding bit is set within the bit mask if the column may + be required by SQLite. If the table has at least 64 columns and + any column to the right of the first 63 is required, then bit 63 of + colUsed is also set. In other words, column iCol may be required + if the expression + + + (colUsed & ((sqlite3_uint64)1 << (iCol>=63 ? 63 : iCol))) + + + evaluates to non-zero. Using a null value here indicates that a + default flags value should be used. This property has no effect if + the SQLite core library is not at least version 3.10.0. + - + - Determines whether or not all parameters have been bound to their statement(s) + This class represents the various inputs and outputs used with the + method. - + - Initializes the collection + Constructs an instance of this class. - The command to which the collection belongs + + The number of (and + ) instances to + pre-allocate space for. + + + The number of instances to + pre-allocate space for. + - + - Retrieves an enumerator for the collection + Attempts to determine the structure sizes needed to create and + populate a native + + structure. - An enumerator for the underlying array + + The size of the native + + structure is stored here. + + + The size of the native + + structure is stored here. + + + The size of the native + + structure is stored here. + + + The size of the native + + structure is stored here. + - + - Adds a parameter to the collection + Attempts to allocate and initialize a native + + structure. - The parameter name - The data type - The size of the value - The source column - A SQLiteParameter object + + The number of instances to + pre-allocate space for. + + + The number of instances to + pre-allocate space for. + + + The newly allocated native + structure + -OR- if it could not be fully allocated. + - + - Adds a parameter to the collection + Frees all the memory associated with a native + + structure. - The parameter name - The data type - The size of the value - A SQLiteParameter object + + The native pointer to the native sqlite3_index_info structure to + free. + - + - Adds a parameter to the collection + Converts a native pointer to a native sqlite3_index_info structure + into a new object instance. - The parameter name - The data type - A SQLiteParameter object + + The native pointer to the native sqlite3_index_info structure to + convert. + + + Non-zero to include fields from the outputs portion of the native + structure; otherwise, the "output" fields will not be read. + + + Upon success, this parameter will be modified to contain the newly + created object instance. + - + - Adds a parameter to the collection + Populates the outputs of a pre-allocated native sqlite3_index_info + structure using an existing object + instance. - The parameter to add - A zero-based index of where the parameter is located in the array + + The existing object instance containing + the output data to use. + + + The native pointer to the pre-allocated native sqlite3_index_info + structure. + + + Non-zero to include fields from the inputs portion of the native + structure; otherwise, the "input" fields will not be written. + - + - Adds a parameter to the collection + The object instance containing + the inputs to the + method. - The parameter to add - A zero-based index of where the parameter is located in the array - + - Adds a named/unnamed parameter and its value to the parameter collection. + The object instance containing + the outputs from the + method. - Name of the parameter, or null to indicate an unnamed parameter - The initial value of the parameter - Returns the SQLiteParameter object created during the call. - + - Adds an array of parameters to the collection + This class represents a managed virtual table implementation. It is + not sealed and should be used as the base class for any user-defined + virtual table classes implemented in managed code. - The array of parameters to add - + - Adds an array of parameters to the collection + The index within the array of strings provided to the + and + methods containing the + name of the module implementing this virtual table. - The array of parameters to add - + - Clears the array and resets the collection + The index within the array of strings provided to the + and + methods containing the + name of the database containing this virtual table. - + - Determines if the named parameter exists in the collection + The index within the array of strings provided to the + and + methods containing the + name of the virtual table. - The name of the parameter to check - True if the parameter is in the collection - + - Determines if the parameter exists in the collection + Constructs an instance of this class. - The SQLiteParameter to check - True if the parameter is in the collection + + The original array of strings provided to the + and + methods. + - + - Not implemented + The original array of strings provided to the + and + methods. - - - + - Retrieve a parameter by name from the collection + The name of the module implementing this virtual table. - The name of the parameter to fetch - A DbParameter object - + - Retrieves a parameter by its index in the collection + The name of the database containing this virtual table. - The index of the parameter to retrieve - A DbParameter object - + - Returns the index of a parameter given its name + The name of the virtual table. - The name of the parameter to find - -1 if not found, otherwise a zero-based index of the parameter - + - Returns the index of a parameter + The object instance containing all the + data for the inputs and outputs relating to the most recent index + selection. - The parameter to find - -1 if not found, otherwise a zero-based index of the parameter - + - Inserts a parameter into the array at the specified location + This method should normally be used by the + method in order to + perform index selection based on the constraints provided by the + SQLite core library. - The zero-based index to insert the parameter at - The parameter to insert + + The object instance containing all the + data for the inputs and outputs relating to index selection. + + + Non-zero upon success. + - + - Removes a parameter from the collection + Attempts to record the renaming of the virtual table associated + with this object instance. - The parameter to remove + + The new name for the virtual table. + + + Non-zero upon success. + - + - Removes a parameter from the collection given its name + Returns the underlying SQLite native handle associated with this + object instance. - The name of the parameter to remove - + - Removes a parameter from the collection given its index + Disposes of this object instance. - The zero-based parameter index to remove - + - Re-assign the named parameter to a new parameter object + Throws an if this object + instance has been disposed. - The name of the parameter to replace - The new parameter - + - Re-assign a parameter at the specified index + Disposes of this object instance. - The zero-based index of the parameter to replace - The new parameter + + Non-zero if this method is being called from the + method. Zero if this method is being called + from the finalizer. + - + - Un-binds all parameters from their statements + Finalizes this object instance. - + - This function attempts to map all parameters in the collection to all statements in a Command. - Since named parameters may span multiple statements, this function makes sure all statements are bound - to the same named parameter. Unnamed parameters are bound in sequence. + This class represents a managed virtual table cursor implementation. + It is not sealed and should be used as the base class for any + user-defined virtual table cursor classes implemented in managed code. - + - Returns false + This value represents an invalid integer row sequence number. - + - Returns false + The field holds the integer row sequence number for the current row + pointed to by this cursor object instance. - + - Returns false + Constructs an instance of this class. + + The object instance associated + with this object instance. + - + - Returns null + Constructs an instance of this class. - + - Returns a count of parameters in the collection + The object instance associated + with this object instance. - + - Overloaded to specialize the return value of the default indexer + Number used to help identify the selected index. This value will + be set via the method. - Name of the parameter to get/set - The specified named SQLite parameter - + - Overloaded to specialize the return value of the default indexer + String used to help identify the selected index. This value will + be set via the method. - The index of the parameter to get/set - The specified SQLite parameter - + - Represents a single SQL statement in SQLite. + The values used to filter the rows returned via this cursor object + instance. This value will be set via the + method. - + - The underlying SQLite object this statement is bound to + Attempts to persist the specified object + instances in order to make them available after the + method returns. + + The array of object instances to be + persisted. + + + The number of object instances that were + successfully persisted. + - + - The command text of this SQL statement + This method should normally be used by the + method in order to + perform filtering of the result rows and/or to record the filtering + criteria provided by the SQLite core library. + + Number used to help identify the selected index. + + + String used to help identify the selected index. + + + The values corresponding to each column in the selected index. + - + - The actual statement pointer + Determines the integer row sequence number for the current row. + + The integer row sequence number for the current row -OR- zero if + it cannot be determined. + - + - An index from which unnamed parameters begin + Adjusts the integer row sequence number so that it refers to the + next row. - + - Names of the parameters as SQLite understands them to be + Returns the underlying SQLite native handle associated with this + object instance. - + - Parameters for this statement + Disposes of this object instance. - + - Command this statement belongs to (if any) + Throws an if this object + instance has been disposed. - + - The flags associated with the parent connection object. + Disposes of this object instance. + + Non-zero if this method is being called from the + method. Zero if this method is being called + from the finalizer. + - + - Initializes the statement and attempts to get all information about parameters in the statement + Finalizes this object instance. - The base SQLite object - The flags associated with the parent connection object - The statement - The command text for this statement - The previous command in a multi-statement command - + - Disposes and finalizes the statement + This interface represents a native handle provided by the SQLite core + library. - + - If the underlying database connection is open, fetches the number of changed rows - resulting from the most recent query; otherwise, does nothing. + The native handle value. - - The number of changes when true is returned. - Undefined if false is returned. - - Non-zero if the number of changed rows was fetched. - + - Called by SQLiteParameterCollection, this function determines if the specified parameter name belongs to - this statement, and if so, keeps a reference to the parameter so it can be bound later. + This interface represents a virtual table implementation written in + managed code. - The parameter name to map - The parameter to assign it - + - Bind all parameters, making sure the caller didn't miss any + Returns non-zero if the schema for the virtual table has been + declared. - + - Perform the bind operation for an individual parameter + Returns the name of the module as it was registered with the SQLite + core library. - The index of the parameter to bind - The parameter we're binding - + - SQLite implementation of DbTransaction. + This method is called in response to the + method. + + The object instance associated with + the virtual table. + + + The native user-data pointer associated with this module, as it was + provided to the SQLite core library when the native module instance + was created. + + + The module name, database name, virtual table name, and all other + arguments passed to the CREATE VIRTUAL TABLE statement. + + + Upon success, this parameter must be modified to contain the + object instance associated with + the virtual table. + + + Upon failure, this parameter must be modified to contain an error + message. + + + A standard SQLite return code. + - + - The connection to which this transaction is bound + This method is called in response to the + method. + + The object instance associated with + the virtual table. + + + The native user-data pointer associated with this module, as it was + provided to the SQLite core library when the native module instance + was created. + + + The module name, database name, virtual table name, and all other + arguments passed to the CREATE VIRTUAL TABLE statement. + + + Upon success, this parameter must be modified to contain the + object instance associated with + the virtual table. + + + Upon failure, this parameter must be modified to contain an error + message. + + + A standard SQLite return code. + - + - Constructs the transaction object, binding it to the supplied connection + This method is called in response to the + method. - The connection to open a transaction on - TRUE to defer the writelock, or FALSE to lock immediately + + The object instance associated + with this virtual table. + + + The object instance containing all the + data for the inputs and outputs relating to index selection. + + + A standard SQLite return code. + - + - Disposes the transaction. If it is currently active, any changes are rolled back. + This method is called in response to the + method. + + The object instance associated + with this virtual table. + + + A standard SQLite return code. + - + - Commits the current transaction. + This method is called in response to the + method. + + The object instance associated + with this virtual table. + + + A standard SQLite return code. + - + - Rolls back the active transaction. + This method is called in response to the + method. + + The object instance associated + with this virtual table. + + + Upon success, this parameter must be modified to contain the + object instance associated + with the newly opened virtual table cursor. + + + A standard SQLite return code. + - + - Returns the underlying connection to which this transaction applies. + This method is called in response to the + method. + + The object instance + associated with the previously opened virtual table cursor to be + used. + + + A standard SQLite return code. + - + - Forwards to the local Connection property + This method is called in response to the + method. + + The object instance + associated with the previously opened virtual table cursor to be + used. + + + Number used to help identify the selected index. + + + String used to help identify the selected index. + + + The values corresponding to each column in the selected index. + + + A standard SQLite return code. + - + - Gets the isolation level of the transaction. SQLite only supports Serializable transactions. + This method is called in response to the + method. + + The object instance + associated with the previously opened virtual table cursor to be + used. + + + A standard SQLite return code. + - + - The file extension used for dynamic link libraries. + This method is called in response to the + method. + + The object instance + associated with the previously opened virtual table cursor to be + used. + + + Non-zero if no more rows are available; zero otherwise. + - + - The file extension used for the XML configuration file. + This method is called in response to the + method. + + The object instance + associated with the previously opened virtual table cursor to be + used. + + + The object instance to be used for + returning the specified column value to the SQLite core library. + + + The zero-based index corresponding to the column containing the + value to be returned. + + + A standard SQLite return code. + - + - This is the name of the XML configuration file specific to the - System.Data.SQLite assembly. + This method is called in response to the + method. + + The object instance + associated with the previously opened virtual table cursor to be + used. + + + Upon success, this parameter must be modified to contain the unique + integer row identifier for the current row for the specified cursor. + + + A standard SQLite return code. + - + - This lock is used to protect the static _SQLiteNativeModuleFileName, - _SQLiteNativeModuleHandle, and processorArchitecturePlatforms fields. + This method is called in response to the + method. + + The object instance associated + with this virtual table. + + + The array of object instances containing + the new or modified column values, if any. + + + Upon success, this parameter must be modified to contain the unique + integer row identifier for the row that was inserted, if any. + + + A standard SQLite return code. + - + - This dictionary stores the mappings between processor architecture - names and platform names. These mappings are now used for two - purposes. First, they are used to determine if the assembly code - base should be used instead of the location, based upon whether one - or more of the named sub-directories exist within the assembly code - base. Second, they are used to assist in loading the appropriate - SQLite interop assembly into the current process. + This method is called in response to the + method. + + The object instance associated + with this virtual table. + + + A standard SQLite return code. + - + - For now, this method simply calls the Initialize method. + This method is called in response to the + method. + + The object instance associated + with this virtual table. + + + A standard SQLite return code. + - + - Attempts to initialize this class by pre-loading the native SQLite - library for the processor architecture of the current process. + This method is called in response to the + method. + + The object instance associated + with this virtual table. + + + A standard SQLite return code. + - + - Queries and returns the XML configuration file name for the assembly - containing the managed System.Data.SQLite components. + This method is called in response to the + method. + + The object instance associated + with this virtual table. + - The XML configuration file name -OR- null if it cannot be determined - or does not exist. + A standard SQLite return code. - + - Queries and returns the value of the specified setting, using the XML - configuration file and/or the environment variables for the current - process and/or the current system, when available. + This method is called in response to the + method. + + The object instance associated + with this virtual table. + + + The number of arguments to the function being sought. + - The name of the setting. + The name of the function being sought. - - The value to be returned if the setting has not been set explicitly - or cannot be determined. + + Upon success, this parameter must be modified to contain the + object instance responsible for + implementing the specified function. + + + Upon success, this parameter must be modified to contain the + native user-data pointer associated with + . - The value of the setting -OR- the default value specified by - if it has not been set explicitly or - cannot be determined. By default, all references to existing - environment variables will be expanded to their corresponding values - within the value to be returned unless either the "No_Expand" or - "No_Expand_" environment variable is set [to - anything]. + Non-zero if the specified function was found; zero otherwise. - + - Queries and returns the directory for the assembly currently being - executed. + This method is called in response to the + method. + + The object instance associated + with this virtual table. + + + The new name for the virtual table. + - The directory for the assembly currently being executed -OR- null if - it cannot be determined. + A standard SQLite return code. - + - The name of the environment variable containing the processor - architecture of the current process. + This method is called in response to the + method. + + The object instance associated + with this virtual table. + + + This is an integer identifier under which the the current state of + the virtual table should be saved. + + + A standard SQLite return code. + - + - This is the P/Invoke method that wraps the native Win32 LoadLibrary - function. See the MSDN documentation for full details on what it - does. + This method is called in response to the + method. - - The name of the executable library. + + The object instance associated + with this virtual table. + + + This is an integer used to indicate that any saved states with an + identifier greater than or equal to this should be deleted by the + virtual table. - The native module handle upon success -OR- IntPtr.Zero on failure. + A standard SQLite return code. - + - The native module file name for the native SQLite library or null. + This method is called in response to the + method. + + The object instance associated + with this virtual table. + + + This is an integer identifier used to specify a specific saved + state for the virtual table for it to restore itself back to, which + should also have the effect of deleting all saved states with an + integer identifier greater than this one. + + + A standard SQLite return code. + - + - The native module handle for the native SQLite library or the value - IntPtr.Zero. + This class contains static methods that are used to allocate, + manipulate, and free native memory provided by the SQLite core library. - + - Searches for the native SQLite library in the directory containing - the assembly currently being executed as well as the base directory - for the current application domain. + Determines if the native sqlite3_msize() API can be used, based on + the available version of the SQLite core library. - - Upon success, this parameter will be modified to refer to the base - directory containing the native SQLite library. - - - Upon success, this parameter will be modified to refer to the name - of the immediate directory (i.e. the offset from the base directory) - containing the native SQLite library. - - Non-zero (success) if the native SQLite library was found; otherwise, - zero (failure). + Non-zero if the native sqlite3_msize() API is supported by the + SQLite core library. - + - Queries and returns the base directory of the current application - domain. + Allocates at least the specified number of bytes of native memory + via the SQLite core library sqlite3_malloc() function and returns + the resulting native pointer. If the TRACK_MEMORY_BYTES option + was enabled at compile-time, adjusts the number of bytes currently + allocated by this class. + + The number of bytes to allocate. + - The base directory for the current application domain -OR- null if it - cannot be determined. + The native pointer that points to a block of memory of at least the + specified size -OR- if the memory could + not be allocated. - + - Determines if the dynamic link library file name requires a suffix - and adds it if necessary. + Allocates at least the specified number of bytes of native memory + via the SQLite core library sqlite3_malloc64() function and returns + the resulting native pointer. If the TRACK_MEMORY_BYTES option + was enabled at compile-time, adjusts the number of bytes currently + allocated by this class. - - The original dynamic link library file name to inspect. + + The number of bytes to allocate. - The dynamic link library file name, possibly modified to include an - extension. + The native pointer that points to a block of memory of at least the + specified size -OR- if the memory could + not be allocated. - + - Queries and returns the processor architecture of the current - process. + Allocates at least the specified number of bytes of native memory + via the SQLite core library sqlite3_malloc() function and returns + the resulting native pointer without adjusting the number of + allocated bytes currently tracked by this class. This is useful + when dealing with blocks of memory that will be freed directly by + the SQLite core library. + + The number of bytes to allocate. + - The processor architecture of the current process -OR- null if it - cannot be determined. + The native pointer that points to a block of memory of at least the + specified size -OR- if the memory could + not be allocated. - + - Given the processor architecture, returns the name of the platform. + Allocates at least the specified number of bytes of native memory + via the SQLite core library sqlite3_malloc64() function and returns + the resulting native pointer without adjusting the number of + allocated bytes currently tracked by this class. This is useful + when dealing with blocks of memory that will be freed directly by + the SQLite core library. - - The processor architecture to be translated to a platform name. + + The number of bytes to allocate. - The platform name for the specified processor architecture -OR- null - if it cannot be determined. + The native pointer that points to a block of memory of at least the + specified size -OR- if the memory could + not be allocated. - - - Attempts to load the native SQLite library based on the specified - directory and processor architecture. - - - The base directory to use, null for default (the base directory of - the current application domain). This directory should contain the - processor architecture specific sub-directories. - - - The requested processor architecture, null for default (the - processor architecture of the current process). This caller should - almost always specify null for this parameter. - - - The candidate native module file name to load will be stored here, - if necessary. - - - The native module handle as returned by LoadLibrary will be stored - here, if necessary. This value will be IntPtr.Zero if the call to - LoadLibrary fails. + + + Gets and returns the actual size of the specified memory block + that was previously obtained from the , + , , or + methods or directly from the + SQLite core library. + + + The native pointer to the memory block previously obtained from + the , , + , or + methods or directly from the + SQLite core library. - Non-zero if the native module was loaded successfully; otherwise, - zero. + The actual size, in bytes, of the memory block specified via the + native pointer. - + - A strongly-typed resource class, for looking up localized strings, etc. + Gets and returns the actual size of the specified memory block + that was previously obtained from the , + , , or + methods or directly from the + SQLite core library. + + The native pointer to the memory block previously obtained from + the , , + , or + methods or directly from the + SQLite core library. + + + The actual size, in bytes, of the memory block specified via the + native pointer. + - + - Returns the cached ResourceManager instance used by this class. + Frees a memory block previously obtained from the + or methods. If + the TRACK_MEMORY_BYTES option was enabled at compile-time, adjusts + the number of bytes currently allocated by this class. + + The native pointer to the memory block previously obtained from the + or methods. + - + - Overrides the current thread's CurrentUICulture property for all - resource lookups using this strongly typed resource class. + Frees a memory block previously obtained from the SQLite core + library without adjusting the number of allocated bytes currently + tracked by this class. This is useful when dealing with blocks of + memory that were not allocated using this class. + + The native pointer to the memory block previously obtained from the + SQLite core library. + - - - Looks up a localized string similar to <?xml version="1.0" standalone="yes"?> - <DocumentElement> - <DataTypes> - <TypeName>smallint</TypeName> - <ProviderDbType>10</ProviderDbType> - <ColumnSize>5</ColumnSize> - <DataType>System.Int16</DataType> - <CreateFormat>smallint</CreateFormat> - <IsAutoIncrementable>false</IsAutoIncrementable> - <IsCaseSensitive>false</IsCaseSensitive> - <IsFixedLength>true</IsFixedLength> - <IsFixedPrecisionScale>true</IsFixedPrecisionScale> - <IsLong>false</IsLong> - <IsNullable>true</ [rest of string was truncated]";. - - - + - Looks up a localized string similar to ALL,ALTER,AND,AS,AUTOINCREMENT,BETWEEN,BY,CASE,CHECK,COLLATE,COMMIT,CONSTRAINT,CREATE,CROSS,DEFAULT,DEFERRABLE,DELETE,DISTINCT,DROP,ELSE,ESCAPE,EXCEPT,FOREIGN,FROM,FULL,GROUP,HAVING,IN,INDEX,INNER,INSERT,INTERSECT,INTO,IS,ISNULL,JOIN,LEFT,LIMIT,NATURAL,NOT,NOTNULL,NULL,ON,OR,ORDER,OUTER,PRIMARY,REFERENCES,RIGHT,ROLLBACK,SELECT,SET,TABLE,THEN,TO,TRANSACTION,UNION,UNIQUE,UPDATE,USING,VALUES,WHEN,WHERE. + This class contains static methods that are used to deal with native + UTF-8 string pointers to be used with the SQLite core library. - - - Looks up a localized string similar to <?xml version="1.0" encoding="utf-8" ?> - <DocumentElement> - <MetaDataCollections> - <CollectionName>MetaDataCollections</CollectionName> - <NumberOfRestrictions>0</NumberOfRestrictions> - <NumberOfIdentifierParts>0</NumberOfIdentifierParts> - </MetaDataCollections> - <MetaDataCollections> - <CollectionName>DataSourceInformation</CollectionName> - <NumberOfRestrictions>0</NumberOfRestrictions> - <NumberOfIdentifierParts>0</NumberOfIdentifierParts> - </MetaDataCollections> - <MetaDataC [rest of string was truncated]";. - - - + - This class represents a context from the SQLite core library that can - be passed to the sqlite3_result_*() and associated functions. + This is the maximum possible length for the native UTF-8 encoded + strings used with the SQLite core library. - + - This interface represents a native handle provided by the SQLite core - library. + This is the object instance used to handle + conversions from/to UTF-8. - + - The native handle value. + Converts the specified managed string into the UTF-8 encoding and + returns the array of bytes containing its representation in that + encoding. + + The managed string to convert. + + + The array of bytes containing the representation of the managed + string in the UTF-8 encoding or null upon failure. + - + - The native context handle. + Converts the specified array of bytes representing a string in the + UTF-8 encoding and returns a managed string. + + The array of bytes to convert. + + + The managed string or null upon failure. + - + - Constructs an instance of this class using the specified native - context handle. + Probes a native pointer to a string in the UTF-8 encoding for its + terminating NUL character, within the specified length limit. - - The native context handle to use. + + The native NUL-terminated string pointer. + + + The maximum length of the native string, in bytes. + + The length of the native string, in bytes -OR- zero if the length + could not be determined. + - + - Sets the context result to NULL. + Converts the specified native NUL-terminated UTF-8 string pointer + into a managed string. + + The native NUL-terminated UTF-8 string pointer. + + + The managed string or null upon failure. + - + - Sets the context result to the specified - value. + Converts the specified native UTF-8 string pointer of the specified + length into a managed string. - - The value to use. + + The native UTF-8 string pointer. + + + The length of the native string, in bytes. + + The managed string or null upon failure. + - + - Sets the context result to the specified - value. + Converts the specified managed string into a native NUL-terminated + UTF-8 string pointer using memory obtained from the SQLite core + library. - The value to use. + The managed string to convert. + + The native NUL-terminated UTF-8 string pointer or + upon failure. + - + - Sets the context result to the specified - value. + Converts the specified managed string into a native NUL-terminated + UTF-8 string pointer using memory obtained from the SQLite core + library. - The value to use. + The managed string to convert. + + + Non-zero to obtain memory from the SQLite core library without + adjusting the number of allocated bytes currently being tracked + by the class. + + The native NUL-terminated UTF-8 string pointer or + upon failure. + - + - Sets the context result to the specified - value. + Converts the specified managed string into a native NUL-terminated + UTF-8 string pointer using memory obtained from the SQLite core + library. - The value to use. This value will be - converted to the UTF-8 encoding prior to being used. + The managed string to convert. + + + The length of the native string, in bytes. + + The native NUL-terminated UTF-8 string pointer or + upon failure. + - + - Sets the context result to the specified - value containing an error message. + Converts the specified managed string into a native NUL-terminated + UTF-8 string pointer using memory obtained from the SQLite core + library. - The value containing the error message text. - This value will be converted to the UTF-8 encoding prior to being - used. + The managed string to convert. + + Non-zero to obtain memory from the SQLite core library without + adjusting the number of allocated bytes currently being tracked + by the class. + + + The length of the native string, in bytes. + + + The native NUL-terminated UTF-8 string pointer or + upon failure. + - + - Sets the context result to the specified - value. + Converts a logical array of native NUL-terminated UTF-8 string + pointers into an array of managed strings. - - The value to use. + + The number of elements in the logical array of native + NUL-terminated UTF-8 string pointers. + + The native pointer to the logical array of native NUL-terminated + UTF-8 string pointers to convert. + + + The array of managed strings or null upon failure. + - + - Sets the context result to contain the error code SQLITE_TOOBIG. + Converts an array of managed strings into an array of native + NUL-terminated UTF-8 string pointers. + + The array of managed strings to convert. + + + Non-zero to obtain memory from the SQLite core library without + adjusting the number of allocated bytes currently being tracked + by the class. + + + The array of native NUL-terminated UTF-8 string pointers or null + upon failure. + - + - Sets the context result to contain the error code SQLITE_NOMEM. + This class contains static methods that are used to deal with native + pointers to memory blocks that logically contain arrays of bytes to be + used with the SQLite core library. - + - Sets the context result to the specified array - value. + Converts a native pointer to a logical array of bytes of the + specified length into a managed byte array. - - The array value to use. + + The native pointer to the logical array of bytes to convert. + + + The length, in bytes, of the logical array of bytes to convert. + + The managed byte array or null upon failure. + - + - Sets the context result to a BLOB of zeros of the specified size. + Converts a managed byte array into a native pointer to a logical + array of bytes. - The number of zero bytes to use for the BLOB context result. + The managed byte array to convert. + + The native pointer to a logical byte array or null upon failure. + - + - Sets the context result to the specified . + Converts a managed byte array into a native pointer to a logical + array of bytes. - The to use. + The managed byte array to convert. + + + The length, in bytes, of the converted logical array of bytes. + + The native pointer to a logical byte array or null upon failure. + - + - Returns the underlying SQLite native handle associated with this - object instance. + This class contains static methods that are used to perform several + low-level data marshalling tasks between native and managed code. - + - This class represents a value from the SQLite core library that can be - passed to the sqlite3_value_*() and associated functions. + Returns a new object instance based on the + specified object instance and an integer + offset. + + The object instance representing the base + memory location. + + + The integer offset from the base memory location that the new + object instance should point to. + + + The new object instance. + - + - The native value handle. + Rounds up an integer size to the next multiple of the alignment. + + The size, in bytes, to be rounded up. + + + The required alignment for the return value. + + + The size, in bytes, rounded up to the next multiple of the + alignment. This value may end up being the same as the original + size. + - + - Constructs an instance of this class using the specified native - value handle. + Determines the offset, in bytes, of the next structure member. - - The native value handle to use. + + The offset, in bytes, of the current structure member. + + + The size, in bytes, of the current structure member. + + The alignment, in bytes, of the next structure member. + + + The offset, in bytes, of the next structure member. + - + - Invalidates the native value handle, thereby preventing further - access to it from this object instance. + Reads a value from the specified memory + location. + + The object instance representing the base + memory location. + + + The integer offset from the base memory location where the + value to be read is located. + + + The value at the specified memory location. + - + - Converts a logical array of native pointers to native sqlite3_value - structures into a managed array of - object instances. + Reads a value from the specified memory + location. - - The number of elements in the logical array of native sqlite3_value - structures. + + The object instance representing the base + memory location. - - The native pointer to the logical array of native sqlite3_value - structures to convert. + + The integer offset from the base memory location where the + value to be read is located. - The managed array of object instances or - null upon failure. + The value at the specified memory location. - + - Gets and returns the type affinity associated with this value. + Reads a value from the specified memory + location. + + The object instance representing the base + memory location. + + + The integer offset from the base memory location where the + to be read is located. + - The type affinity associated with this value. + The value at the specified memory location. - + - Gets and returns the number of bytes associated with this value, if - it refers to a UTF-8 encoded string. + Reads an value from the specified memory + location. + + The object instance representing the base + memory location. + + + The integer offset from the base memory location where the + value to be read is located. + - The number of bytes associated with this value. The returned value - may be zero. + The value at the specified memory location. - + - Gets and returns the associated with this - value. + Writes an value to the specified memory + location. - - The associated with this value. - + + The object instance representing the base + memory location. + + + The integer offset from the base memory location where the + value to be written is located. + + + The value to write. + - + - Gets and returns the associated with - this value. + Writes an value to the specified memory + location. - - The associated with this value. - + + The object instance representing the base + memory location. + + + The integer offset from the base memory location where the + value to be written is located. + + + The value to write. + - + - Gets and returns the associated with this - value. + Writes a value to the specified memory + location. - - The associated with this value. - + + The object instance representing the base + memory location. + + + The integer offset from the base memory location where the + value to be written is located. + + + The value to write. + - + - Gets and returns the associated with this - value. + Writes a value to the specified memory + location. - - The associated with this value. The value is - converted from the UTF-8 encoding prior to being returned. - + + The object instance representing the base + memory location. + + + The integer offset from the base memory location where the + value to be written is located. + + + The value to write. + - + - Gets and returns the array associated with this - value. + Generates a hash code value for the object. + + The object instance used to calculate the hash code. + + + Non-zero if different object instances with the same value should + generate different hash codes, where applicable. This parameter + has no effect on the .NET Compact Framework. + - The array associated with this value. + The hash code value -OR- zero if the object is null. - + - Uses the native value handle to obtain and store the managed value - for this object instance, thus saving it for later use. The type - of the managed value is determined by the type affinity of the - native value. If the type affinity is not recognized by this - method, no work is done and false is returned. + This class represents a managed virtual table module implementation. + It is not sealed and must be used as the base class for any + user-defined virtual table module classes implemented in managed code. - - Non-zero if the native value was persisted successfully. - - + - Returns the underlying SQLite native handle associated with this - object instance. + This class implements the + interface by forwarding those method calls to the + object instance it contains. If the + contained object instance is null, all + the methods simply generate an + error. - + - Returns non-zero if the native SQLite value has been successfully - persisted as a managed value within this object instance (i.e. the - property may then be read successfully). + This is the value that is always used for the "logErrors" + parameter to the various static error handling methods provided + by the class. - + - If the managed value for this object instance is available (i.e. it - has been previously persisted via the ) method, - that value is returned; otherwise, an exception is thrown. The - returned value may be null. + This is the value that is always used for the "logExceptions" + parameter to the various static error handling methods provided + by the class. - + - These are the allowed values for the operators that are part of a - constraint term in the WHERE clause of a query that uses a virtual - table. + This is the error message text used when the contained + object instance is not available + for any reason. - + - This value represents the equality operator. + The object instance used to provide + an implementation of the + interface. - + - This value represents the greater than operator. + Constructs an instance of this class. + + The object instance used to provide + an implementation of the + interface. + - + - This value represents the less than or equal to operator. + Sets the table error message to one that indicates the native + module implementation is not available. + + + The native pointer to the sqlite3_vtab derived structure. + + + The value of . + + + + + Sets the table error message to one that indicates the native + module implementation is not available. + + The native pointer to the sqlite3_vtab_cursor derived + structure. + + + The value of . + - + - This value represents the less than operator. + See the method. + + See the method. + + + See the method. + + + See the method. + + + See the method. + + + See the method. + + + See the method. + + + See the method. + - + - This value represents the greater than or equal to operator. + See the method. + + See the method. + + + See the method. + + + See the method. + + + See the method. + + + See the method. + + + See the method. + + + See the method. + - + - This value represents the MATCH operator. + See the method. + + See the method. + + + See the method. + + + See the method. + - + - This class represents the native sqlite3_index_constraint structure - from the SQLite core library. + See the method. + + See the method. + + + See the method. + - + - Constructs an instance of this class using the specified native - sqlite3_index_constraint structure. + See the method. - - The native sqlite3_index_constraint structure to use. + + See the method. + + See the method. + - + - Constructs an instance of this class using the specified field - values. + See the method. - - Column on left-hand side of constraint. - - - Constraint operator (). - - - True if this constraint is usable. + + See the method. - - Used internally - - should ignore. + + See the method. + + See the method. + - + - Column on left-hand side of constraint. + See the method. + + See the method. + + + See the method. + - + - Constraint operator (). + See the method. + + See the method. + + + See the method. + + + See the method. + + + See the method. + + + See the method. + + + See the method. + - + - True if this constraint is usable. + See the method. + + See the method. + + + See the method. + - + - Used internally - - should ignore. + See the method. + + See the method. + + + See the method. + - + - This class represents the native sqlite3_index_orderby structure from - the SQLite core library. + See the method. + + See the method. + + + See the method. + + + See the method. + + + See the method. + - + - Constructs an instance of this class using the specified native - sqlite3_index_orderby structure. + See the method. - - The native sqlite3_index_orderby structure to use. + + See the method. + + + See the method. + + See the method. + - + - Constructs an instance of this class using the specified field - values. + See the method. - - Column number. + + See the method. - - True for DESC. False for ASC. + + See the method. + + + See the method. + + + See the method. + + See the method. + - + - Column number. + See the method. + + See the method. + + + See the method. + - + - True for DESC. False for ASC. + See the method. + + See the method. + + + See the method. + - + - This class represents the native sqlite3_index_constraint_usage - structure from the SQLite core library. + See the method. + + See the method. + + + See the method. + - + - Constructs an instance of this class using the specified native - sqlite3_index_constraint_usage structure. + See the method. - - The native sqlite3_index_constraint_usage structure to use. + + See the method. + + See the method. + - + - Constructs an instance of this class using the specified field - values. + See the method. - - If greater than 0, constraint is part of argv to xFilter. + + See the method. - - Do not code a test for this constraint. + + See the method. + + See the method. + + + See the method. + + + See the method. + + + See the method. + - - - If greater than 0, constraint is part of argv to xFilter. - - - - - Do not code a test for this constraint. - - - + - This class represents the various inputs provided by the SQLite core - library to the method. + See the method. + + See the method. + + + See the method. + + + See the method. + - + - Constructs an instance of this class. + See the method. - - The number of instances to - pre-allocate space for. + + See the method. - - The number of instances to - pre-allocate space for. + + See the method. + + See the method. + - + - An array of object instances, - each containing information supplied by the SQLite core library. + See the method. + + See the method. + + + See the method. + + + See the method. + - + - An array of object instances, - each containing information supplied by the SQLite core library. + See the method. + + See the method. + + + See the method. + + + See the method. + - + - This class represents the various outputs provided to the SQLite core - library by the method. + Disposes of this object instance. - + - Constructs an instance of this class. + Throws an if this object + instance has been disposed. - - The number of instances - to pre-allocate space for. - - + - Determines if the native estimatedRows field can be used, based on - the available version of the SQLite core library. + Disposes of this object instance. - - Non-zero if the property is supported - by the SQLite core library. - + + Non-zero if this method is being called from the + method. Zero if this method is being + called from the finalizer. + - + - An array of object - instances, each containing information to be supplied to the SQLite - core library. + Finalizes this object instance. - + - Number used to help identify the selected index. This value will - later be provided to the - method. + The default version of the native sqlite3_module structure in use. - + - String used to help identify the selected index. This value will - later be provided to the - method. + This field is used to store the native sqlite3_module structure + associated with this object instance. - + - Non-zero if the index string must be freed by the SQLite core - library. + This field is used to store the destructor delegate to be passed to + the SQLite core library via the sqlite3_create_disposable_module() + function. - + - True if output is already ordered. + This field is used to store a pointer to the native sqlite3_module + structure returned by the sqlite3_create_disposable_module + function. - + - Estimated cost of using this index. Using a null value here - indicates that a default estimated cost value should be used. + This field is used to store the virtual table instances associated + with this module. The native pointer to the sqlite3_vtab derived + structure is used to key into this collection. - + - Estimated number of rows returned. Using a null value here - indicates that a default estimated rows value should be used. + This field is used to store the virtual table cursor instances + associated with this module. The native pointer to the + sqlite3_vtab_cursor derived structure is used to key into this + collection. - + - This class represents the various inputs and outputs used with the - method. + This field is used to store the virtual table function instances + associated with this module. The case-insensitive function name + and the number of arguments (with -1 meaning "any") are used to + construct the string that is used to key into this collection. - + Constructs an instance of this class. - - The number of (and - ) instances to - pre-allocate space for. - - - The number of instances to - pre-allocate space for. + + The name of the module. This parameter cannot be null. - + - Converts a native pointer to a native sqlite3_index_info structure - into a new object instance. + Calls the native SQLite core library in order to create a new + disposable module containing the implementation of a virtual table. - - The native pointer to the native sqlite3_index_info structure to - convert. - - - Upon success, this parameter will be modified to contain the newly - created object instance. + + The native database connection pointer to use. + + Non-zero upon success. + - + - Populates the outputs of a pre-allocated native sqlite3_index_info - structure using an existing object - instance. + This method is called by the SQLite core library when the native + module associated with this object instance is being destroyed due + to its parent connection being closed. It may also be called by + the "vtshim" module if/when the sqlite3_dispose_module() function + is called. - - The existing object instance containing - the output data to use. - - - The native pointer to the pre-allocated native sqlite3_index_info - structure. + + The native user-data pointer associated with this module, as it was + provided to the SQLite core library when the native module instance + was created. - + - The object instance containing - the inputs to the - method. + Creates and returns the native sqlite_module structure using the + configured (or default) + interface implementation. + + The native sqlite_module structure using the configured (or + default) interface + implementation. + - + - The object instance containing - the outputs from the - method. + Creates and returns the native sqlite_module structure using the + specified interface + implementation. + + The interface implementation to + use. + + + The native sqlite_module structure using the specified + interface implementation. + - + - This class represents a managed virtual table implementation. It is - not sealed and should be used as the base class for any user-defined - virtual table classes implemented in managed code. + Creates a copy of the specified + object instance, + using default implementations for the contained delegates when + necessary. + + The object + instance to copy. + + + The new object + instance. + - + - The index within the array of strings provided to the - and - methods containing the - name of the module implementing this virtual table. + Calls one of the virtual table initialization methods. + + Non-zero to call the + method; otherwise, the + method will be called. + + + The native database connection handle. + + + The original native pointer value that was provided to the + sqlite3_create_module(), sqlite3_create_module_v2() or + sqlite3_create_disposable_module() functions. + + + The number of arguments from the CREATE VIRTUAL TABLE statement. + + + The array of string arguments from the CREATE VIRTUAL TABLE + statement. + + + Upon success, this parameter must be modified to point to the newly + created native sqlite3_vtab derived structure. + + + Upon failure, this parameter must be modified to point to the error + message, with the underlying memory having been obtained from the + sqlite3_malloc() function. + + + A standard SQLite return code. + - + - The index within the array of strings provided to the - and - methods containing the - name of the database containing this virtual table. + Calls one of the virtual table finalization methods. + + Non-zero to call the + method; otherwise, the + method will be + called. + + + The native pointer to the sqlite3_vtab derived structure. + + + A standard SQLite return code. + - + - The index within the array of strings provided to the - and - methods containing the - name of the virtual table. + Arranges for the specified error message to be placed into the + zErrMsg field of a sqlite3_vtab derived structure, freeing the + existing error message, if any. + + The object instance to be used. + + + The native pointer to the sqlite3_vtab derived structure. + + + Non-zero if this error message should also be logged using the + class. + + + Non-zero if caught exceptions should be logged using the + class. + + + The error message. + + + Non-zero upon success. + - + - Constructs an instance of this class. + Arranges for the specified error message to be placed into the + zErrMsg field of a sqlite3_vtab derived structure, freeing the + existing error message, if any. - - The original array of strings provided to the - and - methods. + + The object instance to be used. + + + The object instance used to + lookup the native pointer to the sqlite3_vtab derived structure. + + + Non-zero if this error message should also be logged using the + class. + + + Non-zero if caught exceptions should be logged using the + class. + + + The error message. + + Non-zero upon success. + - + - This method should normally be used by the - method in order to - perform index selection based on the constraints provided by the - SQLite core library. + Arranges for the specified error message to be placed into the + zErrMsg field of a sqlite3_vtab derived structure, freeing the + existing error message, if any. - - The object instance containing all the - data for the inputs and outputs relating to index selection. + + The object instance to be used. + + + The native pointer to the sqlite3_vtab_cursor derived structure + used to get the native pointer to the sqlite3_vtab derived + structure. + + + Non-zero if this error message should also be logged using the + class. + + + Non-zero if caught exceptions should be logged using the + class. + + + The error message. Non-zero upon success. - + - Attempts to record the renaming of the virtual table associated - with this object instance. + Arranges for the specified error message to be placed into the + zErrMsg field of a sqlite3_vtab derived structure, freeing the + existing error message, if any. - - The new name for the virtual table. + + The object instance to be used. + + + The object instance used to + lookup the native pointer to the sqlite3_vtab derived structure. + + + Non-zero if this error message should also be logged using the + class. + + + Non-zero if caught exceptions should be logged using the + class. + + + The error message. Non-zero upon success. - - - Disposes of this object instance. - - - - - Throws an if this object - instance has been disposed. - - - - - Disposes of this object instance. - - - Non-zero if this method is being called from the - method. Zero if this method is being called - from the finalizer. - - - + - Finalizes this object instance. + Gets and returns the interface + implementation to be used when creating the native sqlite3_module + structure. Derived classes may override this method to supply an + alternate implementation for the + interface. + + The interface implementation to + be used when populating the native sqlite3_module structure. If + the returned value is null, the private methods provided by the + class and relating to the + interface will be used to + create the necessary delegates. + - + - The original array of strings provided to the - and - methods. + Creates and returns the + interface implementation corresponding to the current + object instance. + + The interface implementation + corresponding to the current object + instance. + - + - The name of the module implementing this virtual table. + Allocates a native sqlite3_vtab derived structure and returns a + native pointer to it. + + A native pointer to a native sqlite3_vtab derived structure. + - + - The name of the database containing this virtual table. + Zeros out the fields of a native sqlite3_vtab derived structure. + + The native pointer to the native sqlite3_vtab derived structure to + zero. + - + - The name of the virtual table. + Frees a native sqlite3_vtab structure using the provided native + pointer to it. + + A native pointer to a native sqlite3_vtab derived structure. + - + - The object instance containing all the - data for the inputs and outputs relating to the most recent index - selection. + Allocates a native sqlite3_vtab_cursor derived structure and + returns a native pointer to it. + + A native pointer to a native sqlite3_vtab_cursor derived structure. + - + - Returns the underlying SQLite native handle associated with this - object instance. + Frees a native sqlite3_vtab_cursor structure using the provided + native pointer to it. + + A native pointer to a native sqlite3_vtab_cursor derived structure. + - + - This class represents a managed virtual table cursor implementation. - It is not sealed and should be used as the base class for any - user-defined virtual table cursor classes implemented in managed code. + Reads and returns the native pointer to the sqlite3_vtab derived + structure based on the native pointer to the sqlite3_vtab_cursor + derived structure. + + The object instance to be used. + + + The native pointer to the sqlite3_vtab_cursor derived structure + from which to read the native pointer to the sqlite3_vtab derived + structure. + + + The native pointer to the sqlite3_vtab derived structure -OR- + if it cannot be determined. + - + - This value represents an invalid integer row sequence number. + Reads and returns the native pointer to the sqlite3_vtab derived + structure based on the native pointer to the sqlite3_vtab_cursor + derived structure. + + The native pointer to the sqlite3_vtab_cursor derived structure + from which to read the native pointer to the sqlite3_vtab derived + structure. + + + The native pointer to the sqlite3_vtab derived structure -OR- + if it cannot be determined. + - + - The field holds the integer row sequence number for the current row - pointed to by this cursor object instance. + Looks up and returns the object + instance based on the native pointer to the sqlite3_vtab derived + structure. + + The native pointer to the sqlite3_vtab derived structure. + + + The object instance or null if + the corresponding one cannot be found. + - + - Constructs an instance of this class. + Allocates and returns a native pointer to a sqlite3_vtab derived + structure and creates an association between it and the specified + object instance. - The object instance associated - with this object instance. + The object instance to be used + when creating the association. + + The native pointer to a sqlite3_vtab derived structure or + if the method fails for any reason. + - + - Constructs an instance of this class. + Looks up and returns the + object instance based on the native pointer to the + sqlite3_vtab_cursor derived structure. + + The native pointer to the sqlite3_vtab derived structure. + + + The native pointer to the sqlite3_vtab_cursor derived structure. + + + The object instance or null + if the corresponding one cannot be found. + - + - Attempts to persist the specified object - instances in order to make them available after the - method returns. + Allocates and returns a native pointer to a sqlite3_vtab_cursor + derived structure and creates an association between it and the + specified object instance. - - The array of object instances to be - persisted. + + The object instance to be + used when creating the association. - The number of object instances that were - successfully persisted. + The native pointer to a sqlite3_vtab_cursor derived structure or + if the method fails for any reason. - + - This method should normally be used by the - method in order to - perform filtering of the result rows and/or to record the filtering - criteria provided by the SQLite core library. + Deterimines the key that should be used to identify and store the + object instance for the virtual table + (i.e. to be returned via the + method). - - Number used to help identify the selected index. + + The number of arguments to the virtual table function. - - String used to help identify the selected index. + + The name of the virtual table function. - - The values corresponding to each column in the selected index. + + The object instance associated with + this virtual table function. - - - - Determines the integer row sequence number for the current row. - - The integer row sequence number for the current row -OR- zero if - it cannot be determined. + The string that should be used to identify and store the virtual + table function instance. This method cannot return null. If null + is returned from this method, the behavior is undefined. - - - Adjusts the integer row sequence number so that it refers to the - next row. - - - - - Disposes of this object instance. - - - + - Throws an if this object - instance has been disposed. + Attempts to declare the schema for the virtual table using the + specified database connection. + + The object instance to use when + declaring the schema of the virtual table. This parameter may not + be null. + + + The string containing the CREATE TABLE statement that completely + describes the schema for the virtual table. This parameter may not + be null. + + + Upon failure, this parameter must be modified to contain an error + message. + + + A standard SQLite return code. + - + - Disposes of this object instance. + Calls the native SQLite core library in order to declare a virtual + table function in response to a call into the + + or virtual table + methods. - - Non-zero if this method is being called from the - method. Zero if this method is being called - from the finalizer. + + The object instance to use when + declaring the schema of the virtual table. + + + The number of arguments to the function being declared. + + The name of the function being declared. + + + Upon success, the contents of this parameter are undefined. Upon + failure, it should contain an appropriate error message. + + + A standard SQLite return code. + - + - Finalizes this object instance. + Returns or sets a boolean value indicating whether virtual table + errors should be logged using the class. - + - The object instance associated - with this object instance. + Returns or sets a boolean value indicating whether exceptions + caught in the + method, + the method, + the method, + the method, + and the method should be logged using the + class. - + - Number used to help identify the selected index. This value will - be set via the method. + Arranges for the specified error message to be placed into the + zErrMsg field of a sqlite3_vtab derived structure, freeing the + existing error message, if any. + + The native pointer to the sqlite3_vtab derived structure. + + + The error message. + + + Non-zero upon success. + - + - String used to help identify the selected index. This value will - be set via the method. + Arranges for the specified error message to be placed into the + zErrMsg field of a sqlite3_vtab derived structure, freeing the + existing error message, if any. + + The object instance used to + lookup the native pointer to the sqlite3_vtab derived structure. + + + The error message. + + + Non-zero upon success. + - + - The values used to filter the rows returned via this cursor object - instance. This value will be set via the - method. + Arranges for the specified error message to be placed into the + zErrMsg field of a sqlite3_vtab derived structure, freeing the + existing error message, if any. + + The object instance used to + lookup the native pointer to the sqlite3_vtab derived structure. + + + The error message. + + + Non-zero upon success. + - + - Returns the underlying SQLite native handle associated with this - object instance. + Modifies the specified object instance + to contain the specified estimated cost. + + The object instance to modify. + + + The estimated cost value to use. Using a null value means that the + default value provided by the SQLite core library should be used. + + + Non-zero upon success. + - + - This interface represents a virtual table implementation written in - native code. + Modifies the specified object instance + to contain the default estimated cost. + + The object instance to modify. + + + Non-zero upon success. + - + - - This method is called to create a new instance of a virtual table - in response to a CREATE VIRTUAL TABLE statement. The db parameter - is a pointer to the SQLite database connection that is executing - the CREATE VIRTUAL TABLE statement. The pAux argument is the copy - of the client data pointer that was the fourth argument to the - sqlite3_create_module() or sqlite3_create_module_v2() call that - registered the virtual table module. The argv parameter is an - array of argc pointers to null terminated strings. The first - string, argv[0], is the name of the module being invoked. The - module name is the name provided as the second argument to - sqlite3_create_module() and as the argument to the USING clause of - the CREATE VIRTUAL TABLE statement that is running. The second, - argv[1], is the name of the database in which the new virtual table - is being created. The database name is "main" for the primary - database, or "temp" for TEMP database, or the name given at the - end of the ATTACH statement for attached databases. The third - element of the array, argv[2], is the name of the new virtual - table, as specified following the TABLE keyword in the CREATE - VIRTUAL TABLE statement. If present, the fourth and subsequent - strings in the argv[] array report the arguments to the module name - in the CREATE VIRTUAL TABLE statement. - - - The job of this method is to construct the new virtual table object - (an sqlite3_vtab object) and return a pointer to it in *ppVTab. - - - As part of the task of creating a new sqlite3_vtab structure, this - method must invoke sqlite3_declare_vtab() to tell the SQLite core - about the columns and datatypes in the virtual table. The - sqlite3_declare_vtab() API has the following prototype: - - - - int sqlite3_declare_vtab(sqlite3 *db, const char *zCreateTable) - - - - The first argument to sqlite3_declare_vtab() must be the same - database connection pointer as the first parameter to this method. - The second argument to sqlite3_declare_vtab() must a - zero-terminated UTF-8 string that contains a well-formed CREATE - TABLE statement that defines the columns in the virtual table and - their data types. The name of the table in this CREATE TABLE - statement is ignored, as are all constraints. Only the column names - and datatypes matter. The CREATE TABLE statement string need not to - be held in persistent memory. The string can be deallocated and/or - reused as soon as the sqlite3_declare_vtab() routine returns. - + Modifies the specified object instance + to contain the specified estimated rows. - - The native database connection handle. - - - The original native pointer value that was provided to the - sqlite3_create_module(), sqlite3_create_module_v2() or - sqlite3_create_disposable_module() functions. - - - The number of arguments from the CREATE VIRTUAL TABLE statement. - - - The array of string arguments from the CREATE VIRTUAL TABLE - statement. - - - Upon success, this parameter must be modified to point to the newly - created native sqlite3_vtab derived structure. + + The object instance to modify. - - Upon failure, this parameter must be modified to point to the error - message, with the underlying memory having been obtained from the - sqlite3_malloc() function. + + The estimated rows value to use. Using a null value means that the + default value provided by the SQLite core library should be used. - A standard SQLite return code. + Non-zero upon success. - + - - The xConnect method is very similar to xCreate. It has the same - parameters and constructs a new sqlite3_vtab structure just like - xCreate. And it must also call sqlite3_declare_vtab() like xCreate. - - - The difference is that xConnect is called to establish a new - connection to an existing virtual table whereas xCreate is called - to create a new virtual table from scratch. - - - The xCreate and xConnect methods are only different when the - virtual table has some kind of backing store that must be - initialized the first time the virtual table is created. The - xCreate method creates and initializes the backing store. The - xConnect method just connects to an existing backing store. - - - As an example, consider a virtual table implementation that - provides read-only access to existing comma-separated-value (CSV) - files on disk. There is no backing store that needs to be created - or initialized for such a virtual table (since the CSV files - already exist on disk) so the xCreate and xConnect methods will be - identical for that module. - - - Another example is a virtual table that implements a full-text - index. The xCreate method must create and initialize data - structures to hold the dictionary and posting lists for that index. - The xConnect method, on the other hand, only has to locate and use - an existing dictionary and posting lists that were created by a - prior xCreate call. - - - The xConnect method must return SQLITE_OK if it is successful in - creating the new virtual table, or SQLITE_ERROR if it is not - successful. If not successful, the sqlite3_vtab structure must not - be allocated. An error message may optionally be returned in *pzErr - if unsuccessful. Space to hold the error message string must be - allocated using an SQLite memory allocation function like - sqlite3_malloc() or sqlite3_mprintf() as the SQLite core will - attempt to free the space using sqlite3_free() after the error has - been reported up to the application. - - - The xConnect method is required for every virtual table - implementation, though the xCreate and xConnect pointers of the - sqlite3_module object may point to the same function the virtual - table does not need to initialize backing store. - + Modifies the specified object instance + to contain the default estimated rows. - - The native database connection handle. - - - The original native pointer value that was provided to the - sqlite3_create_module(), sqlite3_create_module_v2() or - sqlite3_create_disposable_module() functions. - - - The number of arguments from the CREATE VIRTUAL TABLE statement. - - - The array of string arguments from the CREATE VIRTUAL TABLE - statement. + + The object instance to modify. - - Upon success, this parameter must be modified to point to the newly - created native sqlite3_vtab derived structure. + + Non-zero upon success. + + + + + Modifies the specified object instance + to contain the specified flags. + + + The object instance to modify. - - Upon failure, this parameter must be modified to point to the error - message, with the underlying memory having been obtained from the - sqlite3_malloc() function. + + The index flags value to use. Using a null value means that the + default value provided by the SQLite core library should be used. - A standard SQLite return code. + Non-zero upon success. - + - - SQLite uses the xBestIndex method of a virtual table module to - determine the best way to access the virtual table. The xBestIndex - method has a prototype like this: - - - int (*xBestIndex)(sqlite3_vtab *pVTab, sqlite3_index_info*); - - - The SQLite core communicates with the xBestIndex method by filling - in certain fields of the sqlite3_index_info structure and passing a - pointer to that structure into xBestIndex as the second parameter. - The xBestIndex method fills out other fields of this structure - which forms the reply. The sqlite3_index_info structure looks like - this: - - - struct sqlite3_index_info { - /* Inputs */ - const int nConstraint; /* Number of entries in aConstraint */ - const struct sqlite3_index_constraint { - int iColumn; /* Column on left-hand side of - * constraint */ - unsigned char op; /* Constraint operator */ - unsigned char usable; /* True if this constraint is usable */ - int iTermOffset; /* Used internally - xBestIndex should - * ignore */ - } *const aConstraint; /* Table of WHERE clause constraints */ - const int nOrderBy; /* Number of terms in the ORDER BY - * clause */ - const struct sqlite3_index_orderby { - int iColumn; /* Column number */ - unsigned char desc; /* True for DESC. False for ASC. */ - } *const aOrderBy; /* The ORDER BY clause */ - /* Outputs */ - struct sqlite3_index_constraint_usage { - int argvIndex; /* if greater than zero, constraint is - * part of argv to xFilter */ - unsigned char omit; /* Do not code a test for this - * constraint */ - } *const aConstraintUsage; - int idxNum; /* Number used to identify the index */ - char *idxStr; /* String, possibly obtained from - * sqlite3_malloc() */ - int needToFreeIdxStr; /* Free idxStr using sqlite3_free() if - * true */ - int orderByConsumed; /* True if output is already ordered */ - double estimatedCost; /* Estimated cost of using this index */ - }; - - - In addition, there are some defined constants: - - - #define SQLITE_INDEX_CONSTRAINT_EQ 2 - #define SQLITE_INDEX_CONSTRAINT_GT 4 - #define SQLITE_INDEX_CONSTRAINT_LE 8 - #define SQLITE_INDEX_CONSTRAINT_LT 16 - #define SQLITE_INDEX_CONSTRAINT_GE 32 - #define SQLITE_INDEX_CONSTRAINT_MATCH 64 - - - The SQLite core calls the xBestIndex method when it is compiling a - query that involves a virtual table. In other words, SQLite calls - this method when it is running sqlite3_prepare() or the equivalent. - By calling this method, the SQLite core is saying to the virtual - table that it needs to access some subset of the rows in the - virtual table and it wants to know the most efficient way to do - that access. The xBestIndex method replies with information that - the SQLite core can then use to conduct an efficient search of the - virtual table. - - - While compiling a single SQL query, the SQLite core might call - xBestIndex multiple times with different settings in - sqlite3_index_info. The SQLite core will then select the - combination that appears to give the best performance. - - - Before calling this method, the SQLite core initializes an instance - of the sqlite3_index_info structure with information about the - query that it is currently trying to process. This information - derives mainly from the WHERE clause and ORDER BY or GROUP BY - clauses of the query, but also from any ON or USING clauses if the - query is a join. The information that the SQLite core provides to - the xBestIndex method is held in the part of the structure that is - marked as "Inputs". The "Outputs" section is initialized to zero. - - - The information in the sqlite3_index_info structure is ephemeral - and may be overwritten or deallocated as soon as the xBestIndex - method returns. If the xBestIndex method needs to remember any part - of the sqlite3_index_info structure, it should make a copy. Care - must be take to store the copy in a place where it will be - deallocated, such as in the idxStr field with needToFreeIdxStr set - to 1. - - - Note that xBestIndex will always be called before xFilter, since - the idxNum and idxStr outputs from xBestIndex are required inputs - to xFilter. However, there is no guarantee that xFilter will be - called following a successful xBestIndex. - - - The xBestIndex method is required for every virtual table - implementation. - - - 2.3.1 Inputs - - - The main thing that the SQLite core is trying to communicate to the - virtual table is the constraints that are available to limit the - number of rows that need to be searched. The aConstraint[] array - contains one entry for each constraint. There will be exactly - nConstraint entries in that array. - - - Each constraint will correspond to a term in the WHERE clause or in - a USING or ON clause that is of the form - - - column OP EXPR - - - Where "column" is a column in the virtual table, OP is an operator - like "=" or "<", and EXPR is an arbitrary expression. So, for - example, if the WHERE clause contained a term like this: - - - a = 5 - - - Then one of the constraints would be on the "a" column with - operator "=" and an expression of "5". Constraints need not have a - literal representation of the WHERE clause. The query optimizer - might make transformations to the WHERE clause in order to extract - as many constraints as it can. So, for example, if the WHERE clause - contained something like this: - - - x BETWEEN 10 AND 100 AND 999>y - - - The query optimizer might translate this into three separate - constraints: - - - x >= 10 - x <= 100 - y < 999 - - - For each constraint, the aConstraint[].iColumn field indicates - which column appears on the left-hand side of the constraint. The - first column of the virtual table is column 0. The rowid of the - virtual table is column -1. The aConstraint[].op field indicates - which operator is used. The SQLITE_INDEX_CONSTRAINT_* constants map - integer constants into operator values. Columns occur in the order - they were defined by the call to sqlite3_declare_vtab() in the - xCreate or xConnect method. Hidden columns are counted when - determining the column index. - - - The aConstraint[] array contains information about all constraints - that apply to the virtual table. But some of the constraints might - not be usable because of the way tables are ordered in a join. The - xBestIndex method must therefore only consider constraints that - have an aConstraint[].usable flag which is true. - - - In addition to WHERE clause constraints, the SQLite core also tells - the xBestIndex method about the ORDER BY clause. (In an aggregate - query, the SQLite core might put in GROUP BY clause information in - place of the ORDER BY clause information, but this fact should not - make any difference to the xBestIndex method.) If all terms of the - ORDER BY clause are columns in the virtual table, then nOrderBy - will be the number of terms in the ORDER BY clause and the - aOrderBy[] array will identify the column for each term in the - order by clause and whether or not that column is ASC or DESC. - - - 2.3.2 Outputs - - - Given all of the information above, the job of the xBestIndex - method it to figure out the best way to search the virtual table. - - - The xBestIndex method fills the idxNum and idxStr fields with - information that communicates an indexing strategy to the xFilter - method. The information in idxNum and idxStr is arbitrary as far as - the SQLite core is concerned. The SQLite core just copies the - information through to the xFilter method. Any desired meaning can - be assigned to idxNum and idxStr as long as xBestIndex and xFilter - agree on what that meaning is. - - - The idxStr value may be a string obtained from an SQLite memory - allocation function such as sqlite3_mprintf(). If this is the case, - then the needToFreeIdxStr flag must be set to true so that the - SQLite core will know to call sqlite3_free() on that string when it - has finished with it, and thus avoid a memory leak. - - - If the virtual table will output rows in the order specified by the - ORDER BY clause, then the orderByConsumed flag may be set to true. - If the output is not automatically in the correct order then - orderByConsumed must be left in its default false setting. This - will indicate to the SQLite core that it will need to do a separate - sorting pass over the data after it comes out of the virtual table. - - - The estimatedCost field should be set to the estimated number of - disk access operations required to execute this query against the - virtual table. The SQLite core will often call xBestIndex multiple - times with different constraints, obtain multiple cost estimates, - then choose the query plan that gives the lowest estimate. - - - The aConstraintUsage[] array contains one element for each of the - nConstraint constraints in the inputs section of the - sqlite3_index_info structure. The aConstraintUsage[] array is used - by xBestIndex to tell the core how it is using the constraints. - - - The xBestIndex method may set aConstraintUsage[].argvIndex entries - to values greater than one. Exactly one entry should be set to 1, - another to 2, another to 3, and so forth up to as many or as few as - the xBestIndex method wants. The EXPR of the corresponding - constraints will then be passed in as the argv[] parameters to - xFilter. - - - For example, if the aConstraint[3].argvIndex is set to 1, then when - xFilter is called, the argv[0] passed to xFilter will have the EXPR - value of the aConstraint[3] constraint. - - - By default, the SQLite core double checks all constraints on each - row of the virtual table that it receives. If such a check is - redundant, the xBestFilter method can suppress that double-check by - setting aConstraintUsage[].omit. - + Modifies the specified object instance + to contain the default index flags. + + + The object instance to modify. + + + Non-zero upon success. + + + + + Returns or sets a boolean value indicating whether virtual table + errors should be logged using the class. + + + + + Returns or sets a boolean value indicating whether exceptions + caught in the + method, + method, and the + method should be logged using the + class. + + + + + See the method. + + See the method. + + + See the method. + + + See the method. + + + See the method. + - The native pointer to the sqlite3_vtab derived structure. + See the method. + + + See the method. + + + See the method. + + + + + See the method. + + + See the method. + + + See the method. + + + See the method. + + + See the method. + + + See the method. + + + See the method. + + + See the method. + + + + + See the method. + + + See the method. - The native pointer to the sqlite3_index_info structure. + See the method. - A standard SQLite return code. + See the method. - + - - This method releases a connection to a virtual table. Only the - sqlite3_vtab object is destroyed. The virtual table is not - destroyed and any backing store associated with the virtual table - persists. This method undoes the work of xConnect. - - - This method is a destructor for a connection to the virtual table. - Contrast this method with xDestroy. The xDestroy is a destructor - for the entire virtual table. - - - The xDisconnect method is required for every virtual table - implementation, though it is acceptable for the xDisconnect and - xDestroy methods to be the same function if that makes sense for - the particular virtual table. - + See the method. - The native pointer to the sqlite3_vtab derived structure. + See the method. - A standard SQLite return code. + See the method. - + - - This method releases a connection to a virtual table, just like the - xDisconnect method, and it also destroys the underlying table - implementation. This method undoes the work of xCreate. - - - The xDisconnect method is called whenever a database connection - that uses a virtual table is closed. The xDestroy method is only - called when a DROP TABLE statement is executed against the virtual - table. - - - The xDestroy method is required for every virtual table - implementation, though it is acceptable for the xDisconnect and - xDestroy methods to be the same function if that makes sense for - the particular virtual table. - + See the method. - The native pointer to the sqlite3_vtab derived structure. + See the method. - A standard SQLite return code. + See the method. - + - - The xOpen method creates a new cursor used for accessing (read - and/or writing) a virtual table. A successful invocation of this - method will allocate the memory for the sqlite3_vtab_cursor (or a - subclass), initialize the new object, and make *ppCursor point to - the new object. The successful call then returns SQLITE_OK. - - - For every successful call to this method, the SQLite core will - later invoke the xClose method to destroy the allocated cursor. - - - The xOpen method need not initialize the pVtab field of the - sqlite3_vtab_cursor structure. The SQLite core will take care of - that chore automatically. - - - A virtual table implementation must be able to support an arbitrary - number of simultaneously open cursors. - - - When initially opened, the cursor is in an undefined state. The - SQLite core will invoke the xFilter method on the cursor prior to - any attempt to position or read from the cursor. - - - The xOpen method is required for every virtual table - implementation. - + See the method. - The native pointer to the sqlite3_vtab derived structure. + See the method. - Upon success, this parameter must be modified to point to the newly - created native sqlite3_vtab_cursor derived structure. + See the method. - A standard SQLite return code. + See the method. - + - - The xClose method closes a cursor previously opened by xOpen. The - SQLite core will always call xClose once for each cursor opened - using xOpen. - - - This method must release all resources allocated by the - corresponding xOpen call. The routine will not be called again even - if it returns an error. The SQLite core will not use the - sqlite3_vtab_cursor again after it has been closed. - - - The xClose method is required for every virtual table - implementation. - + See the method. - The native pointer to the sqlite3_vtab_cursor derived structure. + See the method. - A standard SQLite return code. + See the method. - + - - This method begins a search of a virtual table. The first argument - is a cursor opened by xOpen. The next two argument define a - particular search index previously chosen by xBestIndex. The - specific meanings of idxNum and idxStr are unimportant as long as - xFilter and xBestIndex agree on what that meaning is. - - - The xBestIndex function may have requested the values of certain - expressions using the aConstraintUsage[].argvIndex values of the - sqlite3_index_info structure. Those values are passed to xFilter - using the argc and argv parameters. - - - If the virtual table contains one or more rows that match the - search criteria, then the cursor must be left point at the first - row. Subsequent calls to xEof must return false (zero). If there - are no rows match, then the cursor must be left in a state that - will cause the xEof to return true (non-zero). The SQLite engine - will use the xColumn and xRowid methods to access that row content. - The xNext method will be used to advance to the next row. - - - This method must return SQLITE_OK if successful, or an sqlite error - code if an error occurs. - - - The xFilter method is required for every virtual table - implementation. - + See the method. - The native pointer to the sqlite3_vtab_cursor derived structure. + See the method. - Number used to help identify the selected index. + See the method. - The native pointer to the UTF-8 encoded string containing the - string used to help identify the selected index. + See the method. - The number of native pointers to sqlite3_value structures specified - in . + See the method. - An array of native pointers to sqlite3_value structures containing - filtering criteria for the selected index. + See the method. - A standard SQLite return code. + See the method. - + - - The xNext method advances a virtual table cursor to the next row of - a result set initiated by xFilter. If the cursor is already - pointing at the last row when this routine is called, then the - cursor no longer points to valid data and a subsequent call to the - xEof method must return true (non-zero). If the cursor is - successfully advanced to another row of content, then subsequent - calls to xEof must return false (zero). - - - This method must return SQLITE_OK if successful, or an sqlite error - code if an error occurs. - - - The xNext method is required for every virtual table - implementation. - + See the method. - The native pointer to the sqlite3_vtab_cursor derived structure. + See the method. - A standard SQLite return code. + See the method. - + - - The xEof method must return false (zero) if the specified cursor - currently points to a valid row of data, or true (non-zero) - otherwise. This method is called by the SQL engine immediately - after each xFilter and xNext invocation. - - - The xEof method is required for every virtual table implementation. - + See the method. - The native pointer to the sqlite3_vtab_cursor derived structure. + See the method. - Non-zero if no more rows are available; zero otherwise. + See the method. - + - - The SQLite core invokes this method in order to find the value for - the N-th column of the current row. N is zero-based so the first - column is numbered 0. The xColumn method may return its result back - to SQLite using one of the following interface: - - - sqlite3_result_blob() - sqlite3_result_double() - sqlite3_result_int() - sqlite3_result_int64() - sqlite3_result_null() - sqlite3_result_text() - sqlite3_result_text16() - sqlite3_result_text16le() - sqlite3_result_text16be() - sqlite3_result_zeroblob() - - - If the xColumn method implementation calls none of the functions - above, then the value of the column defaults to an SQL NULL. - - - To raise an error, the xColumn method should use one of the - result_text() methods to set the error message text, then return an - appropriate error code. The xColumn method must return SQLITE_OK on - success. - - - The xColumn method is required for every virtual table - implementation. - + See the method. - The native pointer to the sqlite3_vtab_cursor derived structure. + See the method. - The native pointer to the sqlite3_context structure to be used - for returning the specified column value to the SQLite core - library. + See the method. - The zero-based index corresponding to the column containing the - value to be returned. + See the method. - A standard SQLite return code. + See the method. - + - - A successful invocation of this method will cause *pRowid to be - filled with the rowid of row that the virtual table cursor pCur is - currently pointing at. This method returns SQLITE_OK on success. It - returns an appropriate error code on failure. - - - The xRowid method is required for every virtual table - implementation. - + See the method. - The native pointer to the sqlite3_vtab_cursor derived structure. + See the method. - Upon success, this parameter must be modified to contain the unique - integer row identifier for the current row for the specified cursor. + See the method. - A standard SQLite return code. + See the method. - + - - All changes to a virtual table are made using the xUpdate method. - This one method can be used to insert, delete, or update. - - - The argc parameter specifies the number of entries in the argv - array. The value of argc will be 1 for a pure delete operation or - N+2 for an insert or replace or update where N is the number of - columns in the table. In the previous sentence, N includes any - hidden columns. - - - Every argv entry will have a non-NULL value in C but may contain - the SQL value NULL. In other words, it is always true that - argv[i]!=0 for i between 0 and argc-1. However, it might be the - case that sqlite3_value_type(argv[i])==SQLITE_NULL. - - - The argv[0] parameter is the rowid of a row in the virtual table - to be deleted. If argv[0] is an SQL NULL, then no deletion occurs. - - - The argv[1] parameter is the rowid of a new row to be inserted into - the virtual table. If argv[1] is an SQL NULL, then the - implementation must choose a rowid for the newly inserted row. - Subsequent argv[] entries contain values of the columns of the - virtual table, in the order that the columns were declared. The - number of columns will match the table declaration that the - xConnect or xCreate method made using the sqlite3_declare_vtab() - call. All hidden columns are included. - - - When doing an insert without a rowid (argc>1, argv[1] is an SQL - NULL), the implementation must set *pRowid to the rowid of the - newly inserted row; this will become the value returned by the - sqlite3_last_insert_rowid() function. Setting this value in all the - other cases is a harmless no-op; the SQLite engine ignores the - *pRowid return value if argc==1 or argv[1] is not an SQL NULL. - - - Each call to xUpdate will fall into one of cases shown below. Note - that references to argv[i] mean the SQL value held within the - argv[i] object, not the argv[i] object itself. - - - argc = 1 - - - The single row with rowid equal to argv[0] is deleted. No - insert occurs. - - - argc > 1 - argv[0] = NULL - - - A new row is inserted with a rowid argv[1] and column - values in argv[2] and following. If argv[1] is an SQL NULL, - the a new unique rowid is generated automatically. - - - argc > 1 - argv[0] ? NULL - argv[0] = argv[1] - - - The row with rowid argv[0] is updated with new values in - argv[2] and following parameters. - - - argc > 1 - argv[0] ? NULL - argv[0] ? argv[1] - - - The row with rowid argv[0] is updated with rowid argv[1] - and new values in argv[2] and following parameters. This - will occur when an SQL statement updates a rowid, as in - the statement: - - - UPDATE table SET rowid=rowid+1 WHERE ...; - - - The xUpdate method must return SQLITE_OK if and only if it is - successful. If a failure occurs, the xUpdate must return an - appropriate error code. On a failure, the pVTab->zErrMsg element - may optionally be replaced with error message text stored in memory - allocated from SQLite using functions such as sqlite3_mprintf() or - sqlite3_malloc(). - - - If the xUpdate method violates some constraint of the virtual table - (including, but not limited to, attempting to store a value of the - wrong datatype, attempting to store a value that is too large or - too small, or attempting to change a read-only value) then the - xUpdate must fail with an appropriate error code. - - - There might be one or more sqlite3_vtab_cursor objects open and in - use on the virtual table instance and perhaps even on the row of - the virtual table when the xUpdate method is invoked. The - implementation of xUpdate must be prepared for attempts to delete - or modify rows of the table out from other existing cursors. If the - virtual table cannot accommodate such changes, the xUpdate method - must return an error code. - - - The xUpdate method is optional. If the xUpdate pointer in the - sqlite3_module for a virtual table is a NULL pointer, then the - virtual table is read-only. - + See the method. - The native pointer to the sqlite3_vtab derived structure. + See the method. - The number of new or modified column values contained in - . + See the method. - The array of native pointers to sqlite3_value structures containing - the new or modified column values, if any. + See the method. - Upon success, this parameter must be modified to contain the unique - integer row identifier for the row that was inserted, if any. + See the method. - A standard SQLite return code. + See the method. - + - - This method begins a transaction on a virtual table. This is method - is optional. The xBegin pointer of sqlite3_module may be NULL. - - - This method is always followed by one call to either the xCommit or - xRollback method. Virtual table transactions do not nest, so the - xBegin method will not be invoked more than once on a single - virtual table without an intervening call to either xCommit or - xRollback. Multiple calls to other methods can and likely will - occur in between the xBegin and the corresponding xCommit or - xRollback. - + See the method. - The native pointer to the sqlite3_vtab derived structure. + See the method. - A standard SQLite return code. + See the method. - + - - This method signals the start of a two-phase commit on a virtual - table. This is method is optional. The xSync pointer of - sqlite3_module may be NULL. - - - This method is only invoked after call to the xBegin method and - prior to an xCommit or xRollback. In order to implement two-phase - commit, the xSync method on all virtual tables is invoked prior to - invoking the xCommit method on any virtual table. If any of the - xSync methods fail, the entire transaction is rolled back. - + See the method. - The native pointer to the sqlite3_vtab derived structure. + See the method. - A standard SQLite return code. + See the method. - + - - This method causes a virtual table transaction to commit. This is - method is optional. The xCommit pointer of sqlite3_module may be - NULL. - - - A call to this method always follows a prior call to xBegin and - xSync. - + See the method. - The native pointer to the sqlite3_vtab derived structure. + See the method. - A standard SQLite return code. + See the method. - + - - This method causes a virtual table transaction to rollback. This is - method is optional. The xRollback pointer of sqlite3_module may be - NULL. - - - A call to this method always follows a prior call to xBegin. - + See the method. - The native pointer to the sqlite3_vtab derived structure. + See the method. - A standard SQLite return code. + See the method. - + - - This method provides notification that the virtual table - implementation that the virtual table will be given a new name. If - this method returns SQLITE_OK then SQLite renames the table. If - this method returns an error code then the renaming is prevented. - - - The xRename method is required for every virtual table - implementation. - + See the method. - The native pointer to the sqlite3_vtab derived structure. + See the method. - The number of arguments to the function being sought. + See the method. - The name of the function being sought. + See the method. - Upon success, this parameter must be modified to contain the - delegate responsible for implementing the specified function. + See the method. - Upon success, this parameter must be modified to contain the - native user-data pointer associated with - . + See the method. - Non-zero if the specified function was found; zero otherwise. + See the method. - + - - This method provides notification that the virtual table - implementation that the virtual table will be given a new name. If - this method returns SQLITE_OK then SQLite renames the table. If - this method returns an error code then the renaming is prevented. - - - The xRename method is required for every virtual table - implementation. - + See the method. - The native pointer to the sqlite3_vtab derived structure. + See the method. - The native pointer to the UTF-8 encoded string containing the new - name for the virtual table. + See the method. - A standard SQLite return code. + See the method. - + - - These methods provide the virtual table implementation an - opportunity to implement nested transactions. They are always - optional and will only be called in SQLite version 3.7.7 and later. - - - When xSavepoint(X,N) is invoked, that is a signal to the virtual - table X that it should save its current state as savepoint N. A - subsequent call to xRollbackTo(X,R) means that the state of the - virtual table should return to what it was when xSavepoint(X,R) was - last called. The call to xRollbackTo(X,R) will invalidate all - savepoints with N>R; none of the invalided savepoints will be - rolled back or released without first being reinitialized by a call - to xSavepoint(). A call to xRelease(X,M) invalidates all savepoints - where N>=M. - - - None of the xSavepoint(), xRelease(), or xRollbackTo() methods will - ever be called except in between calls to xBegin() and either - xCommit() or xRollback(). - + See the method. - The native pointer to the sqlite3_vtab derived structure. + See the method. - This is an integer identifier under which the the current state of - the virtual table should be saved. + See the method. - A standard SQLite return code. + See the method. - + - - These methods provide the virtual table implementation an - opportunity to implement nested transactions. They are always - optional and will only be called in SQLite version 3.7.7 and later. - - - When xSavepoint(X,N) is invoked, that is a signal to the virtual - table X that it should save its current state as savepoint N. A - subsequent call to xRollbackTo(X,R) means that the state of the - virtual table should return to what it was when xSavepoint(X,R) was - last called. The call to xRollbackTo(X,R) will invalidate all - savepoints with N>R; none of the invalided savepoints will be - rolled back or released without first being reinitialized by a call - to xSavepoint(). A call to xRelease(X,M) invalidates all savepoints - where N>=M. - - - None of the xSavepoint(), xRelease(), or xRollbackTo() methods will - ever be called except in between calls to xBegin() and either - xCommit() or xRollback(). - + See the method. - The native pointer to the sqlite3_vtab derived structure. + See the method. - This is an integer used to indicate that any saved states with an - identifier greater than or equal to this should be deleted by the - virtual table. + See the method. - A standard SQLite return code. + See the method. - + - - These methods provide the virtual table implementation an - opportunity to implement nested transactions. They are always - optional and will only be called in SQLite version 3.7.7 and later. - - - When xSavepoint(X,N) is invoked, that is a signal to the virtual - table X that it should save its current state as savepoint N. A - subsequent call to xRollbackTo(X,R) means that the state of the - virtual table should return to what it was when xSavepoint(X,R) was - last called. The call to xRollbackTo(X,R) will invalidate all - savepoints with N>R; none of the invalided savepoints will be - rolled back or released without first being reinitialized by a call - to xSavepoint(). A call to xRelease(X,M) invalidates all savepoints - where N>=M. - - - None of the xSavepoint(), xRelease(), or xRollbackTo() methods will - ever be called except in between calls to xBegin() and either - xCommit() or xRollback(). - + See the method. - The native pointer to the sqlite3_vtab derived structure. + See the method. - This is an integer identifier used to specify a specific saved - state for the virtual table for it to restore itself back to, which - should also have the effect of deleting all saved states with an - integer identifier greater than this one. + See the method. - A standard SQLite return code. + See the method. - + - This interface represents a virtual table implementation written in - managed code. + Returns non-zero if the schema for the virtual table has been + declared. - + + + Returns the name of the module as it was registered with the SQLite + core library. + + + This method is called in response to the - method. + method. - The object instance associated with + The object instance associated with the virtual table. @@ -9782,7 +17010,7 @@ Upon success, this parameter must be modified to contain the - object instance associated with + object instance associated with the virtual table. @@ -9793,13 +17021,13 @@ A standard SQLite return code. - + This method is called in response to the - method. + method. - The object instance associated with + The object instance associated with the virtual table. @@ -9813,7 +17041,7 @@ Upon success, this parameter must be modified to contain the - object instance associated with + object instance associated with the virtual table. @@ -9824,74 +17052,74 @@ A standard SQLite return code. - + This method is called in response to the - method. + method. - The object instance associated + The object instance associated with this virtual table. - The object instance containing all the + The object instance containing all the data for the inputs and outputs relating to index selection. A standard SQLite return code. - + This method is called in response to the - method. + method. - The object instance associated + The object instance associated with this virtual table. A standard SQLite return code. - + This method is called in response to the - method. + method. - The object instance associated + The object instance associated with this virtual table. A standard SQLite return code. - + This method is called in response to the - method. + method. - The object instance associated + The object instance associated with this virtual table. Upon success, this parameter must be modified to contain the - object instance associated + object instance associated with the newly opened virtual table cursor. A standard SQLite return code. - + This method is called in response to the - method. + method. - The object instance + The object instance associated with the previously opened virtual table cursor to be used. @@ -9899,13 +17127,13 @@ A standard SQLite return code. - + This method is called in response to the - method. + method. - The object instance + The object instance associated with the previously opened virtual table cursor to be used. @@ -9922,13 +17150,13 @@ A standard SQLite return code. - + This method is called in response to the - method. + method. - The object instance + The object instance associated with the previously opened virtual table cursor to be used. @@ -9936,13 +17164,13 @@ A standard SQLite return code. - + This method is called in response to the - method. + method. - The object instance + The object instance associated with the previously opened virtual table cursor to be used. @@ -9950,18 +17178,18 @@ Non-zero if no more rows are available; zero otherwise. - + This method is called in response to the - method. + method. - The object instance + The object instance associated with the previously opened virtual table cursor to be used. - The object instance to be used for + The object instance to be used for returning the specified column value to the SQLite core library. @@ -9972,13 +17200,13 @@ A standard SQLite return code. - + This method is called in response to the - method. + method. - The object instance + The object instance associated with the previously opened virtual table cursor to be used. @@ -9990,17 +17218,17 @@ A standard SQLite return code. - + This method is called in response to the - method. + method. - The object instance associated + The object instance associated with this virtual table. - The array of object instances containing + The array of object instances containing the new or modified column values, if any. @@ -10011,65 +17239,65 @@ A standard SQLite return code. - + This method is called in response to the - method. + method. - The object instance associated + The object instance associated with this virtual table. A standard SQLite return code. - + This method is called in response to the - method. + method. - The object instance associated + The object instance associated with this virtual table. A standard SQLite return code. - + This method is called in response to the - method. + method. - The object instance associated + The object instance associated with this virtual table. A standard SQLite return code. - + This method is called in response to the - method. + method. - The object instance associated + The object instance associated with this virtual table. A standard SQLite return code. - + This method is called in response to the - method. + method. - The object instance associated + The object instance associated with this virtual table. @@ -10080,558 +17308,827 @@ Upon success, this parameter must be modified to contain the - object instance responsible for + object instance responsible for implementing the specified function. Upon success, this parameter must be modified to contain the native user-data pointer associated with - . + . + + + Non-zero if the specified function was found; zero otherwise. + + + + + This method is called in response to the + method. + + + The object instance associated + with this virtual table. + + + The new name for the virtual table. + + + A standard SQLite return code. + + + + + This method is called in response to the + method. + + + The object instance associated + with this virtual table. + + + This is an integer identifier under which the the current state of + the virtual table should be saved. + + + A standard SQLite return code. + + + + + This method is called in response to the + method. + + + The object instance associated + with this virtual table. + + + This is an integer used to indicate that any saved states with an + identifier greater than or equal to this should be deleted by the + virtual table. + + + A standard SQLite return code. + + + + + This method is called in response to the + method. + + + The object instance associated + with this virtual table. + + + This is an integer identifier used to specify a specific saved + state for the virtual table for it to restore itself back to, which + should also have the effect of deleting all saved states with an + integer identifier greater than this one. + + + A standard SQLite return code. + + + + + Disposes of this object instance. + + + + + Throws an if this object + instance has been disposed. + + + + + Disposes of this object instance. + + + Non-zero if this method is being called from the + method. Zero if this method is being + called from the finalizer. + + + + + Finalizes this object instance. + + + + + This class contains some virtual methods that may be useful for other + virtual table classes. It specifically does NOT implement any of the + interface methods. + + + + + The CREATE TABLE statement used to declare the schema for the + virtual table. + + + + + Non-zero if different object instances with the same value should + generate different row identifiers, where applicable. This has no + effect on the .NET Compact Framework. + + + + + Constructs an instance of this class. + + + The name of the module. This parameter cannot be null. + + + + + Constructs an instance of this class. + + + The name of the module. This parameter cannot be null. + + + Non-zero if different object instances with the same value should + generate different row identifiers, where applicable. This + parameter has no effect on the .NET Compact Framework. + + + + Determines the SQL statement used to declare the virtual table. + This method should be overridden in derived classes if they require + a custom virtual table schema. + - Non-zero if the specified function was found; zero otherwise. + The SQL statement used to declare the virtual table -OR- null if it + cannot be determined. - + - This method is called in response to the - method. + Sets the table error message to one that indicates the virtual + table cursor is of the wrong type. - - The object instance associated - with this virtual table. + + The object instance. - - The new name for the virtual table. + + The that the virtual table cursor should be. - A standard SQLite return code. + The value of . - + - This method is called in response to the - method. + Determines the string to return as the column value for the object + instance value. - - The object instance associated - with this virtual table. + + The object instance + associated with the previously opened virtual table cursor to be + used. - - This is an integer identifier under which the the current state of - the virtual table should be saved. + + The object instance to return a string representation for. - A standard SQLite return code. + The string representation of the specified object instance or null + upon failure. - + - This method is called in response to the - method. + Constructs an unique row identifier from two + values. The first value + must contain the row sequence number for the current row and the + second value must contain the hash code of the key column value + for the current row. - - The object instance associated - with this virtual table. + + The integer row sequence number for the current row. - - This is an integer used to indicate that any saved states with an - identifier greater than or equal to this should be deleted by the - virtual table. + + The hash code of the key column value for the current row. - A standard SQLite return code. + The unique row identifier or zero upon failure. - + - This method is called in response to the - method. + Determines the unique row identifier for the current row. - - The object instance associated - with this virtual table. + + The object instance + associated with the previously opened virtual table cursor to be + used. - - This is an integer identifier used to specify a specific saved - state for the virtual table for it to restore itself back to, which - should also have the effect of deleting all saved states with an - integer identifier greater than this one. + + The object instance to return a unique row identifier for. - A standard SQLite return code. + The unique row identifier or zero upon failure. - + - Returns non-zero if the schema for the virtual table has been - declared. + Throws an if this object + instance has been disposed. - + - Returns the name of the module as it was registered with the SQLite - core library. + Disposes of this object instance. + + Non-zero if this method is being called from the + method. Zero if this method is + being called from the finalizer. + - + - This class contains static methods that are used to allocate, - manipulate, and free native memory provided by the SQLite core library. + This class represents a virtual table cursor to be used with the + class. It is not sealed and may + be used as the base class for any user-defined virtual table cursor + class that wraps an object instance. - + - Allocates at least the specified number of bytes of native memory - via the SQLite core library sqlite3_malloc() function and returns - the resulting native pointer. + The instance provided when this cursor + was created. - - The number of bytes to allocate. - - - The native pointer that points to a block of memory of at least the - specified size -OR- if the memory could - not be allocated. - - + - Gets and returns the actual size of the specified memory block that - was previously obtained from the method. + This value will be non-zero if false has been returned from the + method. - - The native pointer to the memory block previously obtained from the - method. + + + + Constructs an instance of this class. + + + The object instance associated + with this object instance. + + The instance to expose as a virtual + table cursor. + + + + + Advances to the next row of the virtual table cursor using the + method of the + object instance. + - The actual size, in bytes, of the memory block specified via the - native pointer. + Non-zero if the current row is valid; zero otherwise. If zero is + returned, no further rows are available. - + - Frees a memory block previously obtained from the - method. + Returns the value for the current row of the virtual table cursor + using the property of the + object instance. - - The native pointer to the memory block previously obtained from the - method. + + + + Resets the virtual table cursor position, also invalidating the + current row, using the method of + the object instance. + + + + + Returns non-zero if the end of the virtual table cursor has been + seen (i.e. no more rows are available, including the current one). + + + + + Returns non-zero if the virtual table cursor is open. + + + + + Closes the virtual table cursor. This method must not throw any + exceptions. + + + + + Throws an if the virtual + table cursor has been closed. + + + + + Throws an if this object + instance has been disposed. + + + + + Disposes of this object instance. + + + Non-zero if this method is being called from the + method. Zero if this method is + being called from the finalizer. - + + + This class implements a virtual table module that exposes an + object instance as a read-only virtual + table. It is not sealed and may be used as the base class for any + user-defined virtual table class that wraps an + object instance. The following short + example shows it being used to treat an array of strings as a table + data source: + + public static class Sample + { + public static void Main() + { + using (SQLiteConnection connection = new SQLiteConnection( + "Data Source=:memory:;")) + { + connection.Open(); + + connection.CreateModule(new SQLiteModuleEnumerable( + "sampleModule", new string[] { "one", "two", "three" })); + + using (SQLiteCommand command = connection.CreateCommand()) + { + command.CommandText = + "CREATE VIRTUAL TABLE t1 USING sampleModule;"; + + command.ExecuteNonQuery(); + } + + using (SQLiteCommand command = connection.CreateCommand()) + { + command.CommandText = "SELECT * FROM t1;"; + + using (SQLiteDataReader dataReader = command.ExecuteReader()) + { + while (dataReader.Read()) + Console.WriteLine(dataReader[0].ToString()); + } + } + + connection.Close(); + } + } + } + + + + - This class contains static methods that are used to deal with native - UTF-8 string pointers to be used with the SQLite core library. + The instance containing the backing data + for the virtual table. - + - This is the maximum possible length for the native UTF-8 encoded - strings used with the SQLite core library. + Non-zero if different object instances with the same value should + generate different row identifiers, where applicable. This has no + effect on the .NET Compact Framework. - + - This is the object instance used to handle - conversions from/to UTF-8. + Constructs an instance of this class. + + The name of the module. This parameter cannot be null. + + + The instance to expose as a virtual + table. This parameter cannot be null. + - + - Converts the specified managed string into the UTF-8 encoding and - returns the array of bytes containing its representation in that - encoding. + Constructs an instance of this class. - - The managed string to convert. + + The name of the module. This parameter cannot be null. + + + The instance to expose as a virtual + table. This parameter cannot be null. + + + Non-zero if different object instances with the same value should + generate different row identifiers, where applicable. This + parameter has no effect on the .NET Compact Framework. - - The array of bytes containing the representation of the managed - string in the UTF-8 encoding or null upon failure. - - + - Converts the specified array of bytes representing a string in the - UTF-8 encoding and returns a managed string. + Sets the table error message to one that indicates the virtual + table cursor has no current row. - - The array of bytes to convert. + + The object instance. - The managed string or null upon failure. + The value of . - + - Probes a native pointer to a string in the UTF-8 encoding for its - terminating NUL character, within the specified length limit. + See the method. - - The native NUL-terminated string pointer. + + See the method. - - The maximum length of the native string, in bytes. + + See the method. + + + See the method. + + + See the method. + + + See the method. - The length of the native string, in bytes -OR- zero if the length - could not be determined. + See the method. - + - Converts the specified native NUL-terminated UTF-8 string pointer - into a managed string. + See the method. - - The native NUL-terminated UTF-8 string pointer. + + See the method. + + + See the method. + + + See the method. + + + See the method. + + + See the method. - The managed string or null upon failure. + See the method. - + - Converts the specified native UTF-8 string pointer of the specified - length into a managed string. + See the method. - - The native UTF-8 string pointer. + + See the method. - - The length of the native string, in bytes. + + See the method. - The managed string or null upon failure. + See the method. - + - Converts the specified managed string into a native NUL-terminated - UTF-8 string pointer using memory obtained from the SQLite core - library. + See the method. - - The managed string to convert. + + See the method. - The native NUL-terminated UTF-8 string pointer or - upon failure. + See the method. - + - Converts a logical array of native NUL-terminated UTF-8 string - pointers into an array of managed strings. + See the method. - - The number of elements in the logical array of native - NUL-terminated UTF-8 string pointers. - - - The native pointer to the logical array of native NUL-terminated - UTF-8 string pointers to convert. + + See the method. - The array of managed strings or null upon failure. + See the method. - + - Converts an array of managed strings into an array of native - NUL-terminated UTF-8 string pointers. + See the method. - - The array of managed strings to convert. + + See the method. + + + See the method. - The array of native NUL-terminated UTF-8 string pointers or null - upon failure. + See the method. - + - This class contains static methods that are used to deal with native - pointers to memory blocks that logically contain arrays of bytes to be - used with the SQLite core library. + See the method. + + See the method. + + + See the method. + - + - Converts a native pointer to a logical array of bytes of the - specified length into a managed byte array. + See the method. - - The native pointer to the logical array of bytes to convert. + + See the method. - - The length, in bytes, of the logical array of bytes to convert. + + See the method. + + + See the method. + + + See the method. - The managed byte array or null upon failure. + See the method. - + - Converts a managed byte array into a native pointer to a logical - array of bytes. + See the method. - - The managed byte array to convert. + + See the method. - The native pointer to a logical byte array or null upon failure. + See the method. - + - This class contains static methods that are used to perform several - low-level data marshalling tasks between native and managed code. + See the method. + + See the method. + + + See the method. + - + - Returns a new object instance based on the - specified object instance and an integer - offset. + See the method. - - The object instance representing the base - memory location. + + See the method. - - The integer offset from the base memory location that the new - object instance should point to. + + See the method. + + + See the method. - The new object instance. + See the method. - + - Rounds up an integer size to the next multiple of the alignment. + See the method. - - The size, in bytes, to be rounded up. + + See the method. - - The required alignment for the return value. + + See the method. - The size, in bytes, rounded up to the next multiple of the - alignment. This value may end up being the same as the original - size. + See the method. - + - Determines the offset, in bytes, of the next structure member. + See the method. - - The offset, in bytes, of the current structure member. + + See the method. - - The size, in bytes, of the current structure member. + + See the method. - - The alignment, in bytes, of the next structure member. + + See the method. - The offset, in bytes, of the next structure member. + See the method. - + - Reads a value from the specified memory - location. + See the method. - - The object instance representing the base - memory location. + + See the method. - - The integer offset from the base memory location where the - value to be read is located. + + See the method. - The value at the specified memory location. + See the method. - + - Reads a value from the specified memory - location. + Throws an if this object + instance has been disposed. - - The object instance representing the base - memory location. - - - The integer offset from the base memory location where the - to be read is located. - - - The value at the specified memory location. - - + - Reads an value from the specified memory - location. + Disposes of this object instance. - - The object instance representing the base - memory location. - - - The integer offset from the base memory location where the - value to be read is located. + + Non-zero if this method is being called from the + method. Zero if this method is + being called from the finalizer. - - The value at the specified memory location. - - + - Writes an value to the specified memory - location. + This class represents a virtual table cursor to be used with the + class. It is not sealed and may + be used as the base class for any user-defined virtual table cursor + class that wraps an object instance. - - The object instance representing the base - memory location. - - - The integer offset from the base memory location where the - value to be written is located. - - - The value to write. - - + - Writes an value to the specified memory - location. + The instance provided when this + cursor was created. - - The object instance representing the base - memory location. - - - The integer offset from the base memory location where the - value to be written is located. - - - The value to write. - - + - Writes a value to the specified memory - location. + Constructs an instance of this class. - - The object instance representing the base - memory location. - - - The integer offset from the base memory location where the - value to be written is located. + + The object instance associated + with this object instance. - - The value to write. + + The instance to expose as a virtual + table cursor. - + + + Returns the value for the current row of the virtual table cursor + using the property of the + object instance. + + + + + Closes the virtual table cursor. This method must not throw any + exceptions. + + + + + Throws an if this object + instance has been disposed. + + + - Writes a value to the specified memory - location. + Disposes of this object instance. - - The object instance representing the base - memory location. - - - The integer offset from the base memory location where the - value to be written is located. - - - The value to write. + + Non-zero if this method is being called from the + method. Zero if this method is + being called from the finalizer. - + - Generates a hash code value for the object. + This class implements a virtual table module that exposes an + object instance as a read-only virtual + table. It is not sealed and may be used as the base class for any + user-defined virtual table class that wraps an + object instance. - - The object instance used to calculate the hash code. - - - Non-zero if different object instances with the same value should - generate different hash codes, where applicable. This parameter - has no effect on the .NET Compact Framework. - - - The hash code value -OR- zero if the object is null. - - + - This class represents a managed virtual table module implementation. - It is not sealed and must be used as the base class for any - user-defined virtual table module classes implemented in managed code. + The instance containing the backing + data for the virtual table. - + - The default version of the native sqlite3_module structure in use. + Constructs an instance of this class. + + The name of the module. This parameter cannot be null. + + + The instance to expose as a virtual + table. This parameter cannot be null. + - + - This field is used to store the native sqlite3_module structure - associated with this object instance. + See the method. + + See the method. + + + See the method. + + + See the method. + - + - This field is used to store the destructor delegate to be passed to - the SQLite core library via the sqlite3_create_disposable_module() - function. + See the method. + + See the method. + + + See the method. + + + See the method. + + + See the method. + - + - This field is used to store a pointer to the native sqlite3_module - structure returned by the sqlite3_create_disposable_module - function. + Throws an if this object + instance has been disposed. - + - This field is used to store the virtual table instances associated - with this module. The native pointer to the sqlite3_vtab derived - structure is used to key into this collection. + Disposes of this object instance. + + Non-zero if this method is being called from the + method. Zero if this method is + being called from the finalizer. + - + - This field is used to store the virtual table cursor instances - associated with this module. The native pointer to the - sqlite3_vtab_cursor derived structure is used to key into this - collection. + This class implements a virtual table module that does nothing by + providing "empty" implementations for all of the + interface methods. The result + codes returned by these "empty" method implementations may be + controlled on a per-method basis by using and/or overriding the + , + , + , + , and + methods from within derived classes. - + - This field is used to store the virtual table function instances - associated with this module. The case-insensitive function name - and the number of arguments (with -1 meaning "any") are used to - construct the string that is used to key into this collection. + This field is used to store the + values to return, on a per-method basis, for all methods that are + part of the interface. - + Constructs an instance of this class. @@ -10639,2901 +18136,2918 @@ The name of the module. This parameter cannot be null. - + - Calls the native SQLite core library in order to create a new - disposable module containing the implementation of a virtual table. + Determines the default value to be + returned by methods of the + interface that lack an overridden implementation in all classes + derived from the class. - - The native database connection pointer to use. - - Non-zero upon success. + The value that should be returned + by all interface methods unless + a more specific result code has been set for that interface method. - + - This method is called by the SQLite core library when the native - module associated with this object instance is being destroyed due - to its parent connection being closed. It may also be called by - the "vtshim" module if/when the sqlite3_dispose_module() function - is called. + Converts a value into a boolean + return value for use with the + method. - - The native user-data pointer associated with this module, as it was - provided to the SQLite core library when the native module instance - was created. + + The value to convert. - - - - Creates and returns the native sqlite_module structure using the - configured (or default) - interface implementation. - - The native sqlite_module structure using the configured (or - default) interface - implementation. + The value. - + - Creates and returns the native sqlite_module structure using the - specified interface - implementation. + Converts a value into a boolean + return value for use with the + method. - - The interface implementation to - use. + + The value to convert. - The native sqlite_module structure using the specified - interface implementation. + The value. - + - Creates a copy of the specified - object instance, - using default implementations for the contained delegates when - necessary. + Determines the value that should be + returned by the specified + interface method if it lack an overridden implementation. If no + specific value is available (or set) + for the specified method, the value + returned by the method will be + returned instead. - - The object - instance to copy. + + The name of the method. Currently, this method must be part of + the interface. - The new object - instance. + The value that should be returned + by the interface method. - + - Calls one of the virtual table initialization methods. + Sets the value that should be + returned by the specified + interface method if it lack an overridden implementation. - - Non-zero to call the - method; otherwise, the - method will be called. - - - The native database connection handle. - - - The original native pointer value that was provided to the - sqlite3_create_module(), sqlite3_create_module_v2() or - sqlite3_create_disposable_module() functions. - - - The number of arguments from the CREATE VIRTUAL TABLE statement. - - - The array of string arguments from the CREATE VIRTUAL TABLE - statement. - - - Upon success, this parameter must be modified to point to the newly - created native sqlite3_vtab derived structure. + + The name of the method. Currently, this method must be part of + the interface. - - Upon failure, this parameter must be modified to point to the error - message, with the underlying memory having been obtained from the - sqlite3_malloc() function. + + The value that should be returned + by the interface method. - A standard SQLite return code. + Non-zero upon success. - + - Calls one of the virtual table finalization methods. + See the method. - - Non-zero to call the - method; otherwise, the - method will be - called. + + See the method. - - The native pointer to the sqlite3_vtab derived structure. + + See the method. + + + See the method. + + + See the method. + + + See the method. - A standard SQLite return code. + See the method. - + - Arranges for the specified error message to be placed into the - zErrMsg field of a sqlite3_vtab derived structure, freeing the - existing error message, if any. + See the method. - - The object instance to be used. + + See the method. - - The native pointer to the sqlite3_vtab derived structure. + + See the method. - - Non-zero if this error message should also be logged using the - class. + + See the method. - - Non-zero if caught exceptions should be logged using the - class. + + See the method. - The error message. + See the method. - Non-zero upon success. + See the method. - + - Arranges for the specified error message to be placed into the - zErrMsg field of a sqlite3_vtab derived structure, freeing the - existing error message, if any. + See the method. - - The object instance to be used. - - The object instance used to - lookup the native pointer to the sqlite3_vtab derived structure. + See the method. - - Non-zero if this error message should also be logged using the - class. + + See the method. - - Non-zero if caught exceptions should be logged using the - class. + + See the method. + + + + + See the method. + + + See the method. - - The error message. + + See the method. + + + + + See the method. + + + See the method. - Non-zero upon success. + See the method. - + - Arranges for the specified error message to be placed into the - zErrMsg field of a sqlite3_vtab derived structure, freeing the - existing error message, if any. + See the method. - - The object instance to be used. - - - The native pointer to the sqlite3_vtab_cursor derived structure - used to get the native pointer to the sqlite3_vtab derived - structure. - - - Non-zero if this error message should also be logged using the - class. - - - Non-zero if caught exceptions should be logged using the - class. + + See the method. - - The error message. + + See the method. - Non-zero upon success. + See the method. - + - Arranges for the specified error message to be placed into the - zErrMsg field of a sqlite3_vtab derived structure, freeing the - existing error message, if any. + See the method. - - The object instance to be used. + + See the method. + + See the method. + + + + + See the method. + - The object instance used to - lookup the native pointer to the sqlite3_vtab derived structure. + See the method. - - Non-zero if this error message should also be logged using the - class. + + See the method. - - Non-zero if caught exceptions should be logged using the - class. + + See the method. - - The error message. + + See the method. - Non-zero upon success. + See the method. - + - Gets and returns the interface - implementation to be used when creating the native sqlite3_module - structure. Derived classes may override this method to supply an - alternate implementation for the - interface. + See the method. + + See the method. + - The interface implementation to - be used when populating the native sqlite3_module structure. If - the returned value is null, the private methods provided by the - class and relating to the - interface will be used to - create the necessary delegates. + See the method. - + - Creates and returns the - interface implementation corresponding to the current - object instance. + See the method. + + See the method. + - The interface implementation - corresponding to the current object - instance. + See the method. - + - Allocates a native sqlite3_vtab derived structure and returns a - native pointer to it. + See the method. + + See the method. + + + See the method. + + + See the method. + - A native pointer to a native sqlite3_vtab derived structure. + See the method. - + - Zeros out the fields of a native sqlite3_vtab derived structure. + See the method. - - The native pointer to the native sqlite3_vtab derived structure to - zero. + + See the method. + + + See the method. + + See the method. + - + - Frees a native sqlite3_vtab structure using the provided native - pointer to it. + See the method. - - A native pointer to a native sqlite3_vtab derived structure. + + See the method. + + + See the method. + + + See the method. + + See the method. + - + - Allocates a native sqlite3_vtab_cursor derived structure and - returns a native pointer to it. + See the method. + + See the method. + - A native pointer to a native sqlite3_vtab_cursor derived structure. + See the method. - + - Frees a native sqlite3_vtab_cursor structure using the provided - native pointer to it. + See the method. - - A native pointer to a native sqlite3_vtab_cursor derived structure. + + See the method. + + See the method. + - + - Reads and returns the native pointer to the sqlite3_vtab derived - structure based on the native pointer to the sqlite3_vtab_cursor - derived structure. + See the method. - - The object instance to be used. - - - The native pointer to the sqlite3_vtab_cursor derived structure - from which to read the native pointer to the sqlite3_vtab derived - structure. + + See the method. - The native pointer to the sqlite3_vtab derived structure -OR- - if it cannot be determined. + See the method. - + - Reads and returns the native pointer to the sqlite3_vtab derived - structure based on the native pointer to the sqlite3_vtab_cursor - derived structure. + See the method. - - The native pointer to the sqlite3_vtab_cursor derived structure - from which to read the native pointer to the sqlite3_vtab derived - structure. + + See the method. - The native pointer to the sqlite3_vtab derived structure -OR- - if it cannot be determined. + See the method. - + - Looks up and returns the object - instance based on the native pointer to the sqlite3_vtab derived - structure. + See the method. - - The native pointer to the sqlite3_vtab derived structure. + + See the method. + + + See the method. + + + See the method. + + + See the method. + + + See the method. - The object instance or null if - the corresponding one cannot be found. + See the method. - + - Allocates and returns a native pointer to a sqlite3_vtab derived - structure and creates an association between it and the specified - object instance. + See the method. - The object instance to be used - when creating the association. + See the method. + + + See the method. - The native pointer to a sqlite3_vtab derived structure or - if the method fails for any reason. + See the method. - + - Looks up and returns the - object instance based on the native pointer to the - sqlite3_vtab_cursor derived structure. + See the method. - - The native pointer to the sqlite3_vtab derived structure. + + See the method. - - The native pointer to the sqlite3_vtab_cursor derived structure. + + See the method. - The object instance or null - if the corresponding one cannot be found. + See the method. - + - Allocates and returns a native pointer to a sqlite3_vtab_cursor - derived structure and creates an association between it and the - specified object instance. + See the method. - - The object instance to be - used when creating the association. + + See the method. + + + See the method. - The native pointer to a sqlite3_vtab_cursor derived structure or - if the method fails for any reason. + See the method. - + - Deterimines the key that should be used to identify and store the - object instance for the virtual table - (i.e. to be returned via the - method). + See the method. - - The number of arguments to the virtual table function. - - - The name of the virtual table function. + + See the method. - - The object instance associated with - this virtual table function. + + See the method. - The string that should be used to identify and store the virtual - table function instance. This method cannot return null. If null - is returned from this method, the behavior is undefined. + See the method. - + + + Throws an if this object + instance has been disposed. + + + + + Disposes of this object instance. + + + Non-zero if this method is being called from the + method. Zero if this method is + being called from the finalizer. + + + + + This enumerated type represents a type of conflict seen when apply + changes from a change set or patch set. + + + + + This value is seen when processing a DELETE or UPDATE change if a + row with the required PRIMARY KEY fields is present in the + database, but one or more other (non primary-key) fields modified + by the update do not contain the expected "before" values. + + + + + This value is seen when processing a DELETE or UPDATE change if a + row with the required PRIMARY KEY fields is not present in the + database. There is no conflicting row in this case. + + The results of invoking the + + method are undefined. + + + + + This value is seen when processing an INSERT change if the + operation would result in duplicate primary key values. + The conflicting row in this case is the database row with the + matching primary key. + + + + + If a non-foreign key constraint violation occurs while applying a + change (i.e. a UNIQUE, CHECK or NOT NULL constraint), the conflict + callback will see this value. + + There is no conflicting row in this case. The results of invoking + the + method are undefined. + + + + + If foreign key handling is enabled, and applying a changes leaves + the database in a state containing foreign key violations, this + value will be seen exactly once before the changes are committed. + If the conflict handler + , the changes, + including those that caused the foreign key constraint violation, + are committed. Or, if it returns + , the changes are + rolled back. + + No current or conflicting row information is provided. The only + method it is possible to call on the supplied + object is + . + + + + + This enumerated type represents the result of a user-defined conflict + resolution callback. + + + + + If a conflict callback returns this value no special action is + taken. The change that caused the conflict is not applied. The + application of changes continues with the next change. + + + + + This value may only be returned from a conflict callback if the + type of conflict was + or . If this is + not the case, any changes applied so far are rolled back and the + call to + + will raise a with an error code of + . + + If this value is returned for a + conflict, then the + conflicting row is either updated or deleted, depending on the type + of change. + + If this value is returned for a + conflict, then + the conflicting row is removed from the database and a second + attempt to apply the change is made. If this second attempt fails, + the original row is restored to the database before continuing. + + + + + If this value is returned, any changes applied so far are rolled + back and the call to + + will raise a with an error code of + . + + + + + This enumerated type represents possible flags that may be passed + to the appropriate overloads of various change set creation methods. + + + + + No special handling. + + + + + Invert the change set while iterating through it. + This is equivalent to inverting a change set using + before + applying it. It is an error to specify this flag + with a patch set. + + + - Attempts to declare the schema for the virtual table using the - specified database connection. + This callback is invoked when a determination must be made about + whether changes to a specific table should be tracked -OR- applied. + It will not be called for tables that are already attached to a + . - - The object instance to use when - declaring the schema of the virtual table. This parameter may not - be null. - - - The string containing the CREATE TABLE statement that completely - describes the schema for the virtual table. This parameter may not - be null. + + The optional application-defined context data that was originally + passed to the or + + methods. This value may be null. - - Upon failure, this parameter must be modified to contain an error - message. + + The name of the table. - A standard SQLite return code. + Non-zero if changes to the table should be considered; otherwise, + zero. Throwing an exception from this callback will result in + undefined behavior. - + - Calls the native SQLite core library in order to declare a virtual - table function in response to a call into the - - or virtual table - methods. + This callback is invoked when there is a conflict while apply changes + to a database. - - The object instance to use when - declaring the schema of the virtual table. - - - The number of arguments to the function being declared. + + The optional application-defined context data that was originally + passed to the + + method. This value may be null. - - The name of the function being declared. + + The type of this conflict. - - Upon success, the contents of this parameter are undefined. Upon - failure, it should contain an appropriate error message. + + The object associated with + this conflict. This value may not be null; however, only properties + that are applicable to the conflict type will be available. Further + information on this is available within the descriptions of the + available values. - A standard SQLite return code. + A value that indicates the + action to be taken in order to resolve the conflict. Throwing an + exception from this callback will result in undefined behavior. - + - Arranges for the specified error message to be placed into the - zErrMsg field of a sqlite3_vtab derived structure, freeing the - existing error message, if any. + This interface contains methods used to manipulate a set of changes for + a database. + + + + + This method "inverts" the set of changes within this instance. + Applying an inverted set of changes to a database reverses the + effects of applying the uninverted changes. Specifically: + ]]>]]> + Each DELETE change is changed to an INSERT, and + ]]>]]> + Each INSERT change is changed to a DELETE, and + ]]>]]> + For each UPDATE change, the old.* and new.* values are exchanged. + ]]>]]> + This method does not change the order in which changes appear + within the set of changes. It merely reverses the sense of each + individual change. - - The native pointer to the sqlite3_vtab derived structure. - - - The error message. - - Non-zero upon success. + The new instance that represents + the resulting set of changes -OR- null if it is not available. - + - Arranges for the specified error message to be placed into the - zErrMsg field of a sqlite3_vtab derived structure, freeing the - existing error message, if any. + This method combines the specified set of changes with the ones + contained in this instance. - - The object instance used to - lookup the native pointer to the sqlite3_vtab derived structure. - - - The error message. + + The changes to be combined with those in this instance. - Non-zero upon success. + The new instance that represents + the resulting set of changes -OR- null if it is not available. - + - Arranges for the specified error message to be placed into the - zErrMsg field of a sqlite3_vtab derived structure, freeing the - existing error message, if any. + Attempts to apply the set of changes in this instance to the + associated database. - - The object instance used to - lookup the native pointer to the sqlite3_vtab derived structure. + + The delegate that will need + to handle any conflicting changes that may arise. - - The error message. + + The optional application-defined context data. This value may be + null. - - Non-zero upon success. - - + - Modifies the specified object instance - to contain the specified estimated cost. + Attempts to apply the set of changes in this instance to the + associated database. - - The object instance to modify. + + The delegate that will need + to handle any conflicting changes that may arise. - - The estimated cost value to use. Using a null value means that the - default value provided by the SQLite core library should be used. + + The optional delegate + that can be used to filter the list of tables impacted by the set + of changes. + + + The optional application-defined context data. This value may be + null. - - Non-zero upon success. - - + - Modifies the specified object instance - to contain the default estimated cost. + This interface contains methods used to manipulate multiple sets of + changes for a database. - - The object instance to modify. - - - Non-zero upon success. - - + - Modifies the specified object instance - to contain the specified estimated rows. + Attempts to add a change set (or patch set) to this change group + instance. The underlying data must be contained entirely within + the byte array. - - The object instance to modify. + + The raw byte data for the specified change set (or patch set). - - The estimated rows value to use. Using a null value means that the - default value provided by the SQLite core library should be used. - - - Non-zero upon success. - - + - Modifies the specified object instance - to contain the default estimated rows. + Attempts to add a change set (or patch set) to this change group + instance. The underlying data will be read from the specified + . - - The object instance to modify. + + The instance containing the raw change set + (or patch set) data to read. - - Non-zero upon success. - - + - See the method. + Attempts to create and return, via , the + combined set of changes represented by this change group instance. - - See the method. - - - See the method. - - - See the method. - - - See the method. - - - See the method. - - - See the method. + + Upon success, this will contain the raw byte data for all the + changes in this change group instance. - - See the method. - - + - See the method. + Attempts to create and write, via , the + combined set of changes represented by this change group instance. - - See the method. - - - See the method. - - - See the method. - - - See the method. - - - See the method. + + Upon success, the raw byte data for all the changes in this change + group instance will be written to this . - - See the method. + + + + This interface contains properties and methods used to fetch metadata + about one change within a set of changes for a database. + + + + + The name of the table the change was made to. + + + + + The number of columns impacted by this change. This value can be + used to determine the highest valid column index that may be used + with the , , + and methods of this interface. It + will be this value minus one. + + + + + This will contain the value + , + , or + , corresponding to + the overall type of change this item represents. + + + + + Non-zero if this change is considered to be indirect (i.e. as + though they were made via a trigger or foreign key action). + + + + + This array contains a for each column in + the table associated with this change. The element will be zero + if the column is not part of the primary key; otherwise, it will + be non-zero. + + + + + This method may only be called from within a + delegate when the conflict + type is . It + returns the total number of known foreign key violations in the + destination database. + + + + + Queries and returns the original value of a given column for this + change. This method may only be called when the + has a value of + or + . + + + The index for the column. This value must be between zero and one + less than the total number of columns for this table. - See the method. + The original value of a given column for this change. - + - See the method. + Queries and returns the updated value of a given column for this + change. This method may only be called when the + has a value of + or + . - - See the method. - - - See the method. + + The index for the column. This value must be between zero and one + less than the total number of columns for this table. - See the method. + The updated value of a given column for this change. - + - See the method. + Queries and returns the conflicting value of a given column for + this change. This method may only be called from within a + delegate when the conflict + type is or + . - - See the method. + + The index for the column. This value must be between zero and one + less than the total number of columns for this table. - See the method. + The conflicting value of a given column for this change. - + - See the method. + This interface contains methods to query and manipulate the state of a + change tracking session for a database. + + + + + Determines if this session is currently tracking changes to its + associated database. - - See the method. - - See the method. + Non-zero if changes to the associated database are being trakced; + otherwise, zero. - + - See the method. + Enables tracking of changes to the associated database. + + + + + Disables tracking of changes to the associated database. + + + + + Determines if this session is currently set to mark changes as + indirect (i.e. as though they were made via a trigger or foreign + key action). - - See the method. - - - See the method. - - See the method. + Non-zero if changes to the associated database are being marked as + indirect; otherwise, zero. - + - See the method. + Sets the indirect flag for this session. Subsequent changes will + be marked as indirect until this flag is changed again. + + + + + Clears the indirect flag for this session. Subsequent changes will + be marked as direct until this flag is changed again. + + + + + Determines if there are any tracked changes currently within the + data for this session. - - See the method. - - See the method. + Non-zero if there are no changes within the data for this session; + otherwise, zero. - + - See the method. + Upon success, causes changes to the specified table(s) to start + being tracked. Any tables impacted by calls to this method will + not cause the callback + to be invoked. - - See the method. - - - See the method. - - - See the method. + + The name of the table to be tracked -OR- null to track all + applicable tables within this database. - - See the method. + + + + This method is used to set the table filter for this instance. + + + The table filter callback -OR- null to clear any existing table + filter callback. - - See the method. + + The optional application-defined context data. This value may be + null. - - See the method. - - + - See the method. + Attempts to create and return, via , the + combined set of changes represented by this session instance. - - See the method. + + Upon success, this will contain the raw byte data for all the + changes in this session instance. - - See the method. - - + - See the method. + Attempts to create and write, via , the + combined set of changes represented by this session instance. - - See the method. + + Upon success, the raw byte data for all the changes in this session + instance will be written to this . - - See the method. - - + - See the method. + Attempts to create and return, via , the + combined set of changes represented by this session instance as a + patch set. - - See the method. - - - See the method. + + Upon success, this will contain the raw byte data for all the + changes in this session instance. - - See the method. + + + + Attempts to create and write, via , the + combined set of changes represented by this session instance as a + patch set. + + + Upon success, the raw byte data for all the changes in this session + instance will be written to this . - - See the method. - - + - See the method. + This method loads the differences between two tables [with the same + name, set of columns, and primary key definition] into this session + instance. - - See the method. + + The name of the database containing the table with the original + data (i.e. it will need updating in order to be identical to the + one within the database associated with this session instance). - - See the method. + + The name of the table. - - See the method. - - + - See the method. + This class contains some static helper methods for use within this + subsystem. - - See the method. + + + + This method checks the byte array specified by the caller to make + sure it will be usable. + + + A byte array provided by the caller into one of the public methods + for the classes that belong to this subsystem. This value cannot + be null or represent an empty array; otherwise, an appropriate + exception will be thrown. - - See the method. + + + + This class is used to hold the native connection handle associated with + a open until this subsystem is totally + done with it. This class is for internal use by this subsystem only. + + + + + The SQL statement used when creating the native statement handle. + There are no special requirements for this other than counting as + an "open statement handle". + + + + + The format of the error message used when reporting, during object + disposal, that the statement handle is still open (i.e. because + this situation is considered a fairly serious programming error). + + + + + The wrapped native connection handle associated with this lock. + + + + + The flags associated with the connection represented by the + value. + + + + + The native statement handle for this lock. The garbage collector + cannot cause this statement to be finalized; therefore, it will + serve to hold the associated native connection open until it is + freed manually using the method. + + + + + Constructs a new instance of this class using the specified wrapped + native connection handle and associated flags. + + + The wrapped native connection handle to be associated with this + lock. - - See the method. + + The flags associated with the connection represented by the + value. - - See the method. + + Non-zero if the method should be called prior + to returning from this constructor. + + + + Queries and returns the wrapped native connection handle for this + instance. + - See the method. + The wrapped native connection handle for this instance -OR- null + if it is unavailable. - + - See the method. + Queries and returns the flags associated with the connection for + this instance. - - See the method. - - See the method. + The value. There is no return + value reserved to indicate an error. - + - See the method. + Queries and returns the native connection handle for this instance. - - See the method. - - See the method. + The native connection handle for this instance. If this value is + unavailable or invalid an exception will be thrown. - + - See the method. + This method attempts to "lock" the associated native connection + handle by preparing a SQL statement that will not be finalized + until the method is called (i.e. and which + cannot be done by the garbage collector). If the statement is + already prepared, nothing is done. If the statement cannot be + prepared for any reason, an exception will be thrown. - - See the method. - - - See the method. - - + - See the method. + This method attempts to "unlock" the associated native connection + handle by finalizing the previously prepared statement. If the + statement is already finalized, nothing is done. If the statement + cannot be finalized for any reason, an exception will be thrown. - - See the method. - - - See the method. - - + - See the method. + Disposes of this object instance. - - See the method. - - - See the method. - - - See the method. - - - See the method. - - - See the method. - - - See the method. - - + - See the method. + Non-zero if this object instance has been disposed. - - See the method. - - - See the method. - - - See the method. - - + - See the method. + Throws an exception if this object instance has been disposed. - - See the method. - - - See the method. - - - See the method. - - + - See the method. + Disposes or finalizes this object instance. - - See the method. - - - See the method. + + Non-zero if this object is being disposed; otherwise, this object + is being finalized. - - See the method. - - + - See the method. + Finalizes this object instance. - - See the method. - - - See the method. - - - See the method. - - + - This method is called in response to the - method. + This class manages the native change set iterator. It is used as the + base class for the and + classes. It knows how to + advance the native iterator handle as well as finalize it. - - The object instance associated with - the virtual table. - - - The native user-data pointer associated with this module, as it was - provided to the SQLite core library when the native module instance - was created. - - - The module name, database name, virtual table name, and all other - arguments passed to the CREATE VIRTUAL TABLE statement. - - - Upon success, this parameter must be modified to contain the - object instance associated with - the virtual table. - - - Upon failure, this parameter must be modified to contain an error - message. - - - A standard SQLite return code. - - + - This method is called in response to the - method. + The native change set (a.k.a. iterator) handle. - - The object instance associated with - the virtual table. - - - The native user-data pointer associated with this module, as it was - provided to the SQLite core library when the native module instance - was created. - - - The module name, database name, virtual table name, and all other - arguments passed to the CREATE VIRTUAL TABLE statement. - - - Upon success, this parameter must be modified to contain the - object instance associated with - the virtual table. - - - Upon failure, this parameter must be modified to contain an error - message. - - - A standard SQLite return code. - - + - This method is called in response to the - method. + Non-zero if this instance owns the native iterator handle in the + field. In that case, this instance will + finalize the native iterator handle upon being disposed or + finalized. - - The object instance associated - with this virtual table. + + + + Constructs a new instance of this class using the specified native + iterator handle. + + + The native iterator handle to use. - - The object instance containing all the - data for the inputs and outputs relating to index selection. + + Non-zero if this instance is to take ownership of the native + iterator handle specified by . - - A standard SQLite return code. - - + - This method is called in response to the - method. + Throws an exception if the native iterator handle is invalid. - - The object instance associated - with this virtual table. - - - A standard SQLite return code. - - + - This method is called in response to the - method. + Used to query the native iterator handle. This method is only used + by the class. - - The object instance associated - with this virtual table. - - A standard SQLite return code. + The native iterator handle -OR- if it + is not available. - + - This method is called in response to the - method. + Attempts to advance the native iterator handle to its next item. - - The object instance associated - with this virtual table. - - - Upon success, this parameter must be modified to contain the - object instance associated - with the newly opened virtual table cursor. - - A standard SQLite return code. + Non-zero if the native iterator handle was advanced and contains + more data; otherwise, zero. If the underlying native API returns + an unexpected value then an exception will be thrown. - + - This method is called in response to the - method. + Attempts to create an instance of this class that is associated + with the specified native iterator handle. Ownership of the + native iterator handle is NOT transferred to the new instance of + this class. - - The object instance - associated with the previously opened virtual table cursor to be - used. + + The native iterator handle to use. - A standard SQLite return code. + The new instance of this class. No return value is reserved to + indicate an error; however, if the native iterator handle is not + valid, any subsequent attempt to make use of it via the returned + instance of this class may throw exceptions. - + - This method is called in response to the - method. + Disposes of this object instance. - - The object instance - associated with the previously opened virtual table cursor to be - used. - - - Number used to help identify the selected index. - - - String used to help identify the selected index. - - - The values corresponding to each column in the selected index. - - - A standard SQLite return code. - - + - This method is called in response to the - method. + Non-zero if this object instance has been disposed. - - The object instance - associated with the previously opened virtual table cursor to be - used. - - - A standard SQLite return code. - - + - This method is called in response to the - method. + Throws an exception if this object instance has been disposed. - - The object instance - associated with the previously opened virtual table cursor to be - used. + + + + Disposes or finalizes this object instance. + + + Non-zero if this object is being disposed; otherwise, this object + is being finalized. - - Non-zero if no more rows are available; zero otherwise. - - + - This method is called in response to the - method. + Finalizes this object instance. - - The object instance - associated with the previously opened virtual table cursor to be - used. + + + + This class manages the native change set iterator for a set of changes + contained entirely in memory. + + + + + The native memory buffer allocated to contain the set of changes + associated with this instance. This will always be freed when this + instance is disposed or finalized. + + + + + Constructs an instance of this class using the specified native + memory buffer and native iterator handle. + + + The native memory buffer to use. - - The object instance to be used for - returning the specified column value to the SQLite core library. + + The native iterator handle to use. - - The zero-based index corresponding to the column containing the - value to be returned. + + Non-zero if this instance is to take ownership of the native + iterator handle specified by . - - A standard SQLite return code. - - + - This method is called in response to the - method. + Attempts to create an instance of this class using the specified + raw byte data. - - The object instance - associated with the previously opened virtual table cursor to be - used. - - - Upon success, this parameter must be modified to contain the unique - integer row identifier for the current row for the specified cursor. + + The raw byte data containing the set of changes for this native + iterator. - A standard SQLite return code. + The new instance of this class -OR- null if it cannot be created. - + - This method is called in response to the - method. + Attempts to create an instance of this class using the specified + raw byte data. - - The object instance associated - with this virtual table. - - - The array of object instances containing - the new or modified column values, if any. + + The raw byte data containing the set of changes for this native + iterator. - - Upon success, this parameter must be modified to contain the unique - integer row identifier for the row that was inserted, if any. + + The flags used to create the change set iterator. - A standard SQLite return code. + The new instance of this class -OR- null if it cannot be created. - + + + Non-zero if this object instance has been disposed. + + + + + Throws an exception if this object instance has been disposed. + + + + + Disposes or finalizes this object instance. + + + Non-zero if this object is being disposed; otherwise, this object + is being finalized. + + + + + This class manages the native change set iterator for a set of changes + backed by a instance. + + + + + The instance that is managing + the underlying used as the backing store for + the set of changes associated with this native change set iterator. + + + - This method is called in response to the - method. + Constructs an instance of this class using the specified native + iterator handle and . - - The object instance associated - with this virtual table. + + The instance to use. + + + The native iterator handle to use. + + + Non-zero if this instance is to take ownership of the native + iterator handle specified by . - - A standard SQLite return code. - - + - This method is called in response to the - method. + Attempts to create an instance of this class using the specified + . - - The object instance associated - with this virtual table. + + The where the raw byte data for the set of + changes may be read. + + + The flags associated with the parent connection. - A standard SQLite return code. + The new instance of this class -OR- null if it cannot be created. - + - This method is called in response to the - method. + Attempts to create an instance of this class using the specified + . - - The object instance associated - with this virtual table. + + The where the raw byte data for the set of + changes may be read. + + + The flags associated with the parent connection. + + + The flags used to create the change set iterator. - A standard SQLite return code. + The new instance of this class -OR- null if it cannot be created. - + - This method is called in response to the - method. + Non-zero if this object instance has been disposed. - - The object instance associated - with this virtual table. - - - A standard SQLite return code. - - + - This method is called in response to the - method. + Throws an exception if this object instance has been disposed. - - The object instance associated - with this virtual table. - - - The number of arguments to the function being sought. - - - The name of the function being sought. + + + + Disposes or finalizes this object instance. + + + Non-zero if this object is being disposed; otherwise, this object + is being finalized. - - Upon success, this parameter must be modified to contain the - object instance responsible for - implementing the specified function. + + + + This class is used to act as a bridge between a + instance and the delegates used with the native streaming API. + + + + + The managed stream instance used to in order to service the native + delegates for both input and output. + + + + + The flags associated with the connection. + + + + + The delegate used to provide input to the native streaming API. + It will be null -OR- point to the method. + + + + + The delegate used to provide output to the native streaming API. + It will be null -OR- point to the method. + + + + + Constructs a new instance of this class using the specified managed + stream and connection flags. + + + The managed stream instance to be used in order to service the + native delegates for both input and output. - - Upon success, this parameter must be modified to contain the - native user-data pointer associated with - . + + The flags associated with the parent connection. + + + + Queries and returns the flags associated with the connection for + this instance. + - Non-zero if the specified function was found; zero otherwise. + The value. There is no return + value reserved to indicate an error. - + - This method is called in response to the - method. + Returns a delegate that wraps the method, + creating it first if necessary. - - The object instance associated - with this virtual table. - - - The new name for the virtual table. - - A standard SQLite return code. + A delegate that refers to the method. - + - This method is called in response to the - method. + Returns a delegate that wraps the method, + creating it first if necessary. - - The object instance associated - with this virtual table. - - - This is an integer identifier under which the the current state of - the virtual table should be saved. - - A standard SQLite return code. + A delegate that refers to the method. - + - This method is called in response to the - method. + This method attempts to read bytes from + the managed stream, writing them to the + buffer. - - The object instance associated - with this virtual table. + + Optional extra context information. Currently, this will always + have a value of . - - This is an integer used to indicate that any saved states with an - identifier greater than or equal to this should be deleted by the - virtual table. + + A preallocated native buffer to receive the requested input bytes. + It must be at least bytes in size. + + + Upon entry, the number of bytes to read. Upon exit, the number of + bytes actually read. This value may be zero upon exit. - A standard SQLite return code. + The value upon success -OR- an + appropriate error code upon failure. - + - This method is called in response to the - method. + This method attempts to write bytes to + the managed stream, reading them from the + buffer. - - The object instance associated - with this virtual table. + + Optional extra context information. Currently, this will always + have a value of . - - This is an integer identifier used to specify a specific saved - state for the virtual table for it to restore itself back to, which - should also have the effect of deleting all saved states with an - integer identifier greater than this one. + + A preallocated native buffer containing the requested output + bytes. It must be at least bytes in + size. + + + The number of bytes to write. - A standard SQLite return code. + The value upon success -OR- an + appropriate error code upon failure. - + Disposes of this object instance. - + - Throws an if this object - instance has been disposed. + Non-zero if this object instance has been disposed. - + - Disposes of this object instance. + Throws an exception if this object instance has been disposed. + + + + + Disposes or finalizes this object instance. - Non-zero if this method is being called from the - method. Zero if this method is being - called from the finalizer. + Non-zero if this object is being disposed; otherwise, this object + is being finalized. - + Finalizes this object instance. - + - Returns or sets a boolean value indicating whether virtual table - errors should be logged using the class. + This class manages a collection of + instances. When used, it takes responsibility for creating, returning, + and disposing of its instances. - + - Returns or sets a boolean value indicating whether exceptions - caught in the - method, - the method, - the method, - the method, - and the method should be logged using the - class. + The managed collection of + instances, keyed by their associated + instance. - + - Returns or sets a boolean value indicating whether virtual table - errors should be logged using the class. + The flags associated with the connection. - + - Returns or sets a boolean value indicating whether exceptions - caught in the - method, - method, and the - method should be logged using the - class. + Constructs a new instance of this class using the specified + connection flags. + + The flags associated with the parent connection. + - + - Returns non-zero if the schema for the virtual table has been - declared. + Makes sure the collection of + is created. - + - Returns the name of the module as it was registered with the SQLite - core library. + Makes sure the collection of + is disposed. - + - This class implements the - interface by forwarding those method calls to the - object instance it contains. If the - contained object instance is null, all - the methods simply generate an - error. + Attempts to return a instance + suitable for the specified . + + The instance. If this value is null, a null + value will be returned. + + + A instance. Typically, these + are always freshly created; however, this method is designed to + return the existing instance + associated with the specified stream, should one exist. + - + - This is the value that is always used for the "logErrors" - parameter to the various static error handling methods provided - by the class. + Disposes of this object instance. - + - This is the value that is always used for the "logExceptions" - parameter to the various static error handling methods provided - by the class. + Non-zero if this object instance has been disposed. - + - This is the error message text used when the contained - object instance is not available - for any reason. + Throws an exception if this object instance has been disposed. - + - The object instance used to provide - an implementation of the - interface. + Disposes or finalizes this object instance. + + Non-zero if this object is being disposed; otherwise, this object + is being finalized. + - + - Constructs an instance of this class. + Finalizes this object instance. - - The object instance used to provide - an implementation of the - interface. - - + - Sets the table error message to one that indicates the native - module implementation is not available. + This class represents a group of change sets (or patch sets). - - The native pointer to the sqlite3_vtab derived structure. - - - The value of . - - + - Sets the table error message to one that indicates the native - module implementation is not available. + The instance associated + with this change group. - - The native pointer to the sqlite3_vtab_cursor derived - structure. - - - The value of . - - + - See the method. + The flags associated with the connection. - - See the method. - - - See the method. - - - See the method. - - - See the method. - - - See the method. + + + + The native handle for this change group. This will be deleted when + this instance is disposed or finalized. + + + + + Constructs a new instance of this class using the specified + connection flags. + + + The flags associated with the parent connection. - - See the method. + + + + Throws an exception if the native change group handle is invalid. + + + + + Makes sure the native change group handle is valid, creating it if + necessary. + + + + + Makes sure the instance + is available, creating it if necessary. + + + + + Attempts to return a instance + suitable for the specified . + + + The instance. If this value is null, a null + value will be returned. - See the method. + A instance. Typically, these + are always freshly created; however, this method is designed to + return the existing instance + associated with the specified stream, should one exist. - + - See the method. + Attempts to add a change set (or patch set) to this change group + instance. The underlying data must be contained entirely within + the byte array. - - See the method. + + The raw byte data for the specified change set (or patch set). - - See the method. - - - See the method. - - - See the method. - - - See the method. - - - See the method. - - - See the method. - - + - See the method. + Attempts to add a change set (or patch set) to this change group + instance. The underlying data will be read from the specified + . - - See the method. + + The instance containing the raw change set + (or patch set) data to read. - - See the method. + + + + Attempts to create and return, via , the + combined set of changes represented by this change group instance. + + + Upon success, this will contain the raw byte data for all the + changes in this change group instance. - - See the method. - - + - See the method. + Attempts to create and write, via , the + combined set of changes represented by this change group instance. - - See the method. + + Upon success, the raw byte data for all the changes in this change + group instance will be written to this . - - See the method. - - + + + Disposes of this object instance. + + + + + Non-zero if this object instance has been disposed. + + + - See the method. + Throws an exception if this object instance has been disposed. - - See the method. - - - See the method. - - + - See the method. + Disposes or finalizes this object instance. - - See the method. - - - See the method. + + Non-zero if this object is being disposed; otherwise, this object + is being finalized. - - See the method. - - + - See the method. + Finalizes this object instance. - - See the method. - - - See the method. - - + - See the method. + This class represents the change tracking session associated with a + database. - - See the method. - - - See the method. - - - See the method. - - - See the method. - - - See the method. - - - See the method. - - + - See the method. + The instance associated + with this session. - - See the method. - - - See the method. - - + - See the method. + The name of the database (e.g. "main") for this session. - - See the method. - - - See the method. - - + - See the method. + The native handle for this session. This will be deleted when + this instance is disposed or finalized. - - See the method. - - - See the method. - - - See the method. - - - See the method. - - + - See the method. + The delegate used to provide table filtering to the native API. + It will be null -OR- point to the method. - - See the method. - - - See the method. - - - See the method. - - + - See the method. + The managed callback used to filter tables for this session. Set + via the method. - - See the method. - - - See the method. - - - See the method. - - - See the method. - - - See the method. - - + - See the method. + The optional application-defined context data that was passed to + the method. This value may be null. - - See the method. - - - See the method. - - + - See the method. + Constructs a new instance of this class using the specified wrapped + native connection handle and associated flags. - - See the method. + + The wrapped native connection handle to be associated with this + session. + + + The flags associated with the connection represented by the + value. + + + The name of the database (e.g. "main") for this session. - - See the method. - - + - See the method. + Throws an exception if the native session handle is invalid. - - See the method. - - - See the method. - - + - See the method. + Makes sure the native session handle is valid, creating it if + necessary. - - See the method. - - - See the method. - - + - See the method. + This method sets up the internal table filtering associated state + of this instance. - - See the method. - - - See the method. - - - See the method. - - See the method. + The table filter callback -OR- null to clear any existing table + filter callback. - - See the method. + + The optional application-defined context data. This value may be + null. - See the method. + The native + delegate -OR- null to clear any existing table filter. - + - See the method. + Makes sure the instance + is available, creating it if necessary. - - See the method. - - - See the method. - - - See the method. - - + - See the method. + Attempts to return a instance + suitable for the specified . - - See the method. - - - See the method. + + The instance. If this value is null, a null + value will be returned. - See the method. + A instance. Typically, these + are always freshly created; however, this method is designed to + return the existing instance + associated with the specified stream, should one exist. - + - See the method. + This method is called when determining if a table needs to be + included in the tracked changes for the associated database. - - See the method. + + Optional extra context information. Currently, this will always + have a value of . - - See the method. + + The native pointer to the name of the table. - See the method. + Non-zero if changes to the specified table should be considered; + otherwise, zero. - + - See the method. + Determines if this session is currently tracking changes to its + associated database. - - See the method. - - - See the method. - - See the method. + Non-zero if changes to the associated database are being trakced; + otherwise, zero. - - - Disposes of this object instance. - - - - - Throws an if this object - instance has been disposed. - - - - - Disposes of this object instance. - - - Non-zero if this method is being called from the - method. Zero if this method is being - called from the finalizer. - - - + - Finalizes this object instance. + Enables tracking of changes to the associated database. - + - This class represents a virtual table cursor to be used with the - class. It is not sealed and may - be used as the base class for any user-defined virtual table cursor - class that wraps an object instance. + Disables tracking of changes to the associated database. - + - The instance provided when this cursor - was created. + Determines if this session is currently set to mark changes as + indirect (i.e. as though they were made via a trigger or foreign + key action). + + Non-zero if changes to the associated database are being marked as + indirect; otherwise, zero. + - + - This value will be non-zero if false has been returned from the - method. + Sets the indirect flag for this session. Subsequent changes will + be marked as indirect until this flag is changed again. - + - Constructs an instance of this class. + Clears the indirect flag for this session. Subsequent changes will + be marked as direct until this flag is changed again. - - The object instance associated - with this object instance. - - - The instance to expose as a virtual - table cursor. - - + - Advances to the next row of the virtual table cursor using the - method of the - object instance. + Determines if there are any tracked changes currently within the + data for this session. - Non-zero if the current row is valid; zero otherwise. If zero is - returned, no further rows are available. + Non-zero if there are no changes within the data for this session; + otherwise, zero. - - - Resets the virtual table cursor position, also invalidating the - current row, using the method of - the object instance. - - - + - Closes the virtual table cursor. This method must not throw any - exceptions. + Upon success, causes changes to the specified table(s) to start + being tracked. Any tables impacted by calls to this method will + not cause the callback + to be invoked. + + The name of the table to be tracked -OR- null to track all + applicable tables within this database. + - + - Throws an if the virtual - table cursor has been closed. + This method is used to set the table filter for this instance. + + The table filter callback -OR- null to clear any existing table + filter callback. + + + The optional application-defined context data. This value may be + null. + - + - Throws an if this object - instance has been disposed. + Attempts to create and return, via , the + set of changes represented by this session instance. + + Upon success, this will contain the raw byte data for all the + changes in this session instance. + - + - Disposes of this object instance. + Attempts to create and write, via , the + set of changes represented by this session instance. - - Non-zero if this method is being called from the - method. Zero if this method is - being called from the finalizer. + + Upon success, the raw byte data for all the changes in this session + instance will be written to this . - + - Returns the value for the current row of the virtual table cursor - using the property of the - object instance. + Attempts to create and return, via , the + set of changes represented by this session instance as a patch set. + + Upon success, this will contain the raw byte data for all the + changes in this session instance. + - + - Returns non-zero if the end of the virtual table cursor has been - seen (i.e. no more rows are available, including the current one). + Attempts to create and write, via , the + set of changes represented by this session instance as a patch set. + + Upon success, the raw byte data for all the changes in this session + instance will be written to this . + - + - Returns non-zero if the virtual table cursor is open. + This method loads the differences between two tables [with the same + name, set of columns, and primary key definition] into this session + instance. + + The name of the database containing the table with the original + data (i.e. it will need updating in order to be identical to the + one within the database associated with this session instance). + + + The name of the table. + - - - This class implements a virtual table module that exposes an - object instance as a read-only virtual - table. It is not sealed and may be used as the base class for any - user-defined virtual table class that wraps an - object instance. The following short - example shows it being used to treat an array of strings as a table - data source: - - public static class Sample - { - public static void Main() - { - using (SQLiteConnection connection = new SQLiteConnection( - "Data Source=:memory:;")) - { - connection.Open(); - - connection.CreateModule(new SQLiteModuleEnumerable( - "sampleModule", new string[] { "one", "two", "three" })); - - using (SQLiteCommand command = connection.CreateCommand()) - { - command.CommandText = - "CREATE VIRTUAL TABLE t1 USING sampleModule;"; - - command.ExecuteNonQuery(); - } - - using (SQLiteCommand command = connection.CreateCommand()) - { - command.CommandText = "SELECT * FROM t1;"; - - using (SQLiteDataReader dataReader = command.ExecuteReader()) - { - while (dataReader.Read()) - Console.WriteLine(dataReader[0].ToString()); - } - } - - connection.Close(); - } - } - } - - - - + - This class implements a virtual table module that does nothing by - providing "empty" implementations for all of the - interface methods. The result - codes returned by these "empty" method implementations may be - controlled on a per-method basis by using and/or overriding the - , - , - , - , and - methods from within derived classes. + Non-zero if this object instance has been disposed. - + - This field is used to store the - values to return, on a per-method basis, for all methods that are - part of the interface. + Throws an exception if this object instance has been disposed. - + - Constructs an instance of this class. + Disposes or finalizes this object instance. - - The name of the module. This parameter cannot be null. + + Non-zero if this object is being disposed; otherwise, this object + is being finalized. - + - Determines the default value to be - returned by methods of the - interface that lack an overridden implementation in all classes - derived from the class. + This class represents the abstract concept of a set of changes. It + acts as the base class for the + and classes. It derives from + the class, which is used to hold + the underlying native connection handle open until the instances of + this class are disposed or finalized. It also provides the ability + to construct wrapped native delegates of the + and + types. - - The value that should be returned - by all interface methods unless - a more specific result code has been set for that interface method. - - + - Converts a value into a boolean - return value for use with the - method. + Constructs an instance of this class using the specified wrapped + native connection handle. - - The value to convert. + + The wrapped native connection handle to be associated with this + change set. + + + The flags associated with the connection represented by the + value. - - The value. - - + - Converts a value into a boolean - return value for use with the - method. + Creates and returns a concrete implementation of the + interface. - - The value to convert. + + The native iterator handle to use. - The value. + An instance of the + interface, which can be used to fetch metadata associated with + the current item in this set of changes. - + - Determines the value that should be - returned by the specified - interface method if it lack an overridden implementation. If no - specific value is available (or set) - for the specified method, the value - returned by the method will be - returned instead. + Attempts to create a + native delegate + that invokes the specified + delegate. - - The name of the method. Currently, this method must be part of - the interface. + + The to invoke when the + native delegate + is called. If this value is null then null is returned. + + + The optional application-defined context data. This value may be + null. - The value that should be returned - by the interface method. + The created + native delegate -OR- null if it cannot be created. - + - Sets the value that should be - returned by the specified - interface method if it lack an overridden implementation. + Attempts to create a + native delegate + that invokes the specified + delegate. - - The name of the method. Currently, this method must be part of - the interface. + + The to invoke when the + native delegate + is called. If this value is null then null is returned. - - The value that should be returned - by the interface method. + + The optional application-defined context data. This value may be + null. - Non-zero upon success. + The created + native delegate -OR- null if it cannot be created. - + - See the method. + Non-zero if this object instance has been disposed. - - See the method. - - - See the method. + + + + Throws an exception if this object instance has been disposed. + + + + + Disposes or finalizes this object instance. + + + Non-zero if this object is being disposed; otherwise, this object + is being finalized. - - See the method. + + + + This class represents a set of changes contained entirely in memory. + + + + + The raw byte data for this set of changes. Since this data must + be marshalled to a native memory buffer before being used, there + must be enough memory available to store at least two times the + amount of data contained within it. + + + + + The flags used to create the change set iterator. + + + + + Constructs an instance of this class using the specified raw byte + data and wrapped native connection handle. + + + The raw byte data for the specified change set (or patch set). - - See the method. + + The wrapped native connection handle to be associated with this + set of changes. - - See the method. + + The flags associated with the connection represented by the + value. - - See the method. - - + - See the method. + Constructs an instance of this class using the specified raw byte + data and wrapped native connection handle. - - See the method. - - - See the method. + + The raw byte data for the specified change set (or patch set). - - See the method. + + The wrapped native connection handle to be associated with this + set of changes. - - See the method. + + The flags associated with the connection represented by the + value. - - See the method. + + The flags used to create the change set iterator. - - See the method. - - + - See the method. + This method "inverts" the set of changes within this instance. + Applying an inverted set of changes to a database reverses the + effects of applying the uninverted changes. Specifically: + ]]>]]> + Each DELETE change is changed to an INSERT, and + ]]>]]> + Each INSERT change is changed to a DELETE, and + ]]>]]> + For each UPDATE change, the old.* and new.* values are exchanged. + ]]>]]> + This method does not change the order in which changes appear + within the set of changes. It merely reverses the sense of each + individual change. - - See the method. - - - See the method. - - See the method. + The new instance that represents + the resulting set of changes. - + - See the method. + This method combines the specified set of changes with the ones + contained in this instance. - - See the method. + + The changes to be combined with those in this instance. - See the method. + The new instance that represents + the resulting set of changes. - + - See the method. + Attempts to apply the set of changes in this instance to the + associated database. - - See the method. + + The delegate that will need + to handle any conflicting changes that may arise. + + + The optional application-defined context data. This value may be + null. - - See the method. - - + - See the method. + Attempts to apply the set of changes in this instance to the + associated database. - - See the method. + + The delegate that will need + to handle any conflicting changes that may arise. - - See the method. + + The optional delegate + that can be used to filter the list of tables impacted by the set + of changes. + + + The optional application-defined context data. This value may be + null. - - See the method. - - + - See the method. + Creates an capable of iterating over the + items within this set of changes. - - See the method. - - See the method. + The new + instance. - + - See the method. + Creates an capable of iterating over the + items within this set of changes. - - See the method. - - - See the method. - - - See the method. - - - See the method. - - See the method. + The new instance. - + - See the method. + Non-zero if this object instance has been disposed. - - See the method. - - - See the method. - - + - See the method. + Throws an exception if this object instance has been disposed. - - See the method. - - - See the method. - - + - See the method. + Disposes or finalizes this object instance. - - See the method. - - - See the method. - - - See the method. + + Non-zero if this object is being disposed; otherwise, this object + is being finalized. - - See the method. - - + - See the method. + This class represents a set of changes that are backed by a + instance. - - See the method. + + + + The instance that is managing + the underlying input used as the backing + store for the set of changes associated with this instance. + + + + + The instance that is managing + the underlying output used as the backing + store for the set of changes generated by the + or methods. + + + + + The instance used as the backing store for + the set of changes associated with this instance. + + + + + The instance used as the backing store for + the set of changes generated by the or + methods. + + + + + The flags used to create the change set iterator. + + + + + Constructs an instance of this class using the specified streams + and wrapped native connection handle. + + + The where the raw byte data for the set of + changes may be read. - - See the method. + + The where the raw byte data for resulting + sets of changes may be written. + + + The wrapped native connection handle to be associated with this + set of changes. + + + The flags associated with the connection represented by the + value. - - See the method. - - + - See the method. + Constructs an instance of this class using the specified streams + and wrapped native connection handle. - - See the method. + + The where the raw byte data for the set of + changes may be read. - - See the method. + + The where the raw byte data for resulting + sets of changes may be written. - - See the method. + + The wrapped native connection handle to be associated with this + set of changes. + + + The flags associated with the connection represented by the + value. + + + The flags used to create the change set iterator. - - See the method. - - + - See the method. + Throws an exception if the input stream or its associated stream + adapter are invalid. - - See the method. - - - See the method. - - + - See the method. + Throws an exception if the output stream or its associated stream + adapter are invalid. - - See the method. - - - See the method. - - + - See the method. + This method "inverts" the set of changes within this instance. + Applying an inverted set of changes to a database reverses the + effects of applying the uninverted changes. Specifically: + ]]>]]> + Each DELETE change is changed to an INSERT, and + ]]>]]> + Each INSERT change is changed to a DELETE, and + ]]>]]> + For each UPDATE change, the old.* and new.* values are exchanged. + ]]>]]> + This method does not change the order in which changes appear + within the set of changes. It merely reverses the sense of each + individual change. - - See the method. - - See the method. + Since the resulting set of changes is written to the output stream, + this method always returns null. - + - See the method. + This method combines the specified set of changes with the ones + contained in this instance. - - See the method. + + The changes to be combined with those in this instance. - See the method. + Since the resulting set of changes is written to the output stream, + this method always returns null. - + - See the method. + Attempts to apply the set of changes in this instance to the + associated database. - - See the method. + + The delegate that will need + to handle any conflicting changes that may arise. - - See the method. + + The optional application-defined context data. This value may be + null. - - See the method. + + + + Attempts to apply the set of changes in this instance to the + associated database. + + + The delegate that will need + to handle any conflicting changes that may arise. - - See the method. + + The optional delegate + that can be used to filter the list of tables impacted by the set + of changes. - - See the method. + + The optional application-defined context data. This value may be + null. - - See the method. - - + - See the method. + Creates an capable of iterating over the + items within this set of changes. - - See the method. - - - See the method. - - See the method. + The new + instance. - + - See the method. + Creates an capable of iterating over the + items within this set of changes. - - See the method. - - - See the method. - - See the method. + The new instance. - + - See the method. + Non-zero if this object instance has been disposed. - - See the method. - - - See the method. + + + + Throws an exception if this object instance has been disposed. + + + + + Disposes or finalizes this object instance. + + + Non-zero if this object is being disposed; otherwise, this object + is being finalized. - - See the method. - - + - See the method. + This class represents an that is capable of + enumerating over a set of changes. It serves as the base class for the + and + classes. It manages and + owns an instance of the class. - - See the method. + + + + This managed change set iterator is managed and owned by this + class. It will be disposed when this class is disposed. + + + + + Constructs an instance of this class using the specified managed + change set iterator. + + + The managed iterator instance to use. - - See the method. + + + + Throws an exception if the managed iterator instance is invalid. + + + + + Sets the managed iterator instance to a new value. + + + The new managed iterator instance to use. - - See the method. - - + - Throws an if this object - instance has been disposed. + Disposes of the managed iterator instance and sets its value to + null. - + - Disposes of this object instance. + Disposes of the existing managed iterator instance and then sets it + to a new value. - - Non-zero if this method is being called from the - method. Zero if this method is - being called from the finalizer. + + The new managed iterator instance to use. - + - The CREATE TABLE statement used to declare the schema for the - virtual table. + Returns the current change within the set of changes, represented + by a instance. - + - The instance containing the backing data - for the virtual table. + Returns the current change within the set of changes, represented + by a instance. - + - Non-zero if different object instances with the same value should - generate different row identifiers, where applicable. This has no - effect on the .NET Compact Framework. + Attempts to advance to the next item in the set of changes. + + Non-zero if more items are available; otherwise, zero. + - + - Constructs an instance of this class. + Throws because not all the + derived classes are able to support reset functionality. - - The name of the module. This parameter cannot be null. - - - The instance to expose as a virtual - table. This parameter cannot be null. + + + + Disposes of this object instance. + + + + + Non-zero if this object instance has been disposed. + + + + + Throws an exception if this object instance has been disposed. + + + + + Disposes or finalizes this object instance. + + + Non-zero if this object is being disposed; otherwise, this object + is being finalized. - + + + Finalizes this object instance. + + + - Constructs an instance of this class. + This class represents an that is capable of + enumerating over a set of changes contained entirely in memory. - - The name of the module. This parameter cannot be null. - - - The instance to expose as a virtual - table. This parameter cannot be null. - - - Non-zero if different object instances with the same value should - generate different row identifiers, where applicable. This - parameter has no effect on the .NET Compact Framework. - - + - Determines the SQL statement used to declare the virtual table. - This method should be overridden in derived classes if they require - a custom virtual table schema. + The raw byte data for this set of changes. Since this data must + be marshalled to a native memory buffer before being used, there + must be enough memory available to store at least two times the + amount of data contained within it. - - The SQL statement used to declare the virtual table -OR- null if it - cannot be determined. - - + - Sets the table error message to one that indicates the virtual - table cursor is of the wrong type. + The flags used to create the change set iterator. - - The object instance. - - - The value of . - - + - Sets the table error message to one that indicates the virtual - table cursor has no current row. + Constructs an instance of this class using the specified raw byte + data. - - The object instance. + + The raw byte data containing the set of changes for this + enumerator. - - The value of . - - + - Determines the string to return as the column value for the object - instance value. + Constructs an instance of this class using the specified raw byte + data. - - The object instance - associated with the previously opened virtual table cursor to be - used. + + The raw byte data containing the set of changes for this + enumerator. - - The object instance to return a string representation for. + + The flags used to create the change set iterator. - - The string representation of the specified object instance or null - upon failure. - - + - Constructs an unique row identifier from two - values. The first value - must contain the row sequence number for the current row and the - second value must contain the hash code of the enumerator value - for the current row. + Resets the enumerator to its initial position. - - The integer row sequence number for the current row. - - - The hash code of the enumerator value for the current row. - - - The unique row identifier or zero upon failure. - - + - Determines the unique row identifier for the current row. + Non-zero if this object instance has been disposed. - - The object instance - associated with the previously opened virtual table cursor to be - used. - - - The object instance to return a unique row identifier for. - - - The unique row identifier or zero upon failure. - - + - See the method. + Throws an exception if this object instance has been disposed. - - See the method. - - - See the method. - - - See the method. - - - See the method. - - - See the method. - - - See the method. - - + - See the method. + Disposes or finalizes this object instance. - - See the method. - - - See the method. - - - See the method. + + Non-zero if this object is being disposed; otherwise, this object + is being finalized. - - See the method. + + + + This class represents an that is capable of + enumerating over a set of changes backed by a + instance. + + + + + Constructs an instance of this class using the specified stream. + + + The where the raw byte data for the set of + changes may be read. - - See the method. + + The flags associated with the parent connection. - - See the method. - - + - See the method. + Constructs an instance of this class using the specified stream. - - See the method. + + The where the raw byte data for the set of + changes may be read. - - See the method. + + The flags associated with the parent connection. + + + The flags used to create the change set iterator. - - See the method. - - + - See the method. + Non-zero if this object instance has been disposed. - - See the method. - - - See the method. - - + - See the method. + Throws an exception if this object instance has been disposed. - - See the method. - - - See the method. - - + - See the method. + Disposes or finalizes this object instance. - - See the method. - - - See the method. + + Non-zero if this object is being disposed; otherwise, this object + is being finalized. - - See the method. - - + - See the method. + This interface implements properties and methods used to fetch metadata + about one change within a set of changes for a database. - - See the method. - - - See the method. - - + - See the method. + The instance to use. This + will NOT be owned by this class and will not be disposed upon this + class being disposed or finalized. - - See the method. - - - See the method. - - - See the method. - - - See the method. - - - See the method. - - + - See the method. + Constructs an instance of this class using the specified iterator + instance. - - See the method. + + The managed iterator instance to use. - - See the method. - - + - See the method. + Throws an exception if the managed iterator instance is invalid. - - See the method. - - - See the method. - - + - See the method. + Populates the underlying data for the , + , , and + properties, using the appropriate native + API. - - See the method. - - - See the method. - - - See the method. - - - See the method. - - + - See the method. + Populates the underlying data for the + property using the appropriate + native API. - - See the method. - - - See the method. - - - See the method. - - + - See the method. + Populates the underlying data for the + property using the + appropriate native API. - - See the method. - - - See the method. - - - See the method. - - - See the method. - - + - See the method. + Backing field for the property. This value + will be null if this field has not yet been populated via the + underlying native API. - - See the method. - - - See the method. - - - See the method. - - + - Throws an if this object - instance has been disposed. + The name of the table the change was made to. - + - Disposes of this object instance. + Backing field for the property. This + value will be null if this field has not yet been populated via the + underlying native API. - - Non-zero if this method is being called from the - method. Zero if this method is - being called from the finalizer. - - + - This class represents a virtual table cursor to be used with the - class. It is not sealed and may - be used as the base class for any user-defined virtual table cursor - class that wraps an object instance. + The number of columns impacted by this change. This value can be + used to determine the highest valid column index that may be used + with the , , + and methods of this interface. It + will be this value minus one. - + - The instance provided when this - cursor was created. + Backing field for the property. This + value will be null if this field has not yet been populated via the + underlying native API. - + - Constructs an instance of this class. + This will contain the value + , + , or + , corresponding to + the overall type of change this item represents. - - The object instance associated - with this object instance. - - - The instance to expose as a virtual - table cursor. - - + - Closes the virtual table cursor. This method must not throw any - exceptions. + Backing field for the property. This value + will be null if this field has not yet been populated via the + underlying native API. - + - Throws an if this object - instance has been disposed. + Non-zero if this change is considered to be indirect (i.e. as + though they were made via a trigger or foreign key action). - + - Disposes of this object instance. + Backing field for the property. + This value will be null if this field has not yet been populated + via the underlying native API. - - Non-zero if this method is being called from the - method. Zero if this method is - being called from the finalizer. - - + - Returns the value for the current row of the virtual table cursor - using the property of the - object instance. + This array contains a for each column in + the table associated with this change. The element will be zero + if the column is not part of the primary key; otherwise, it will + be non-zero. - + - This class implements a virtual table module that exposes an - object instance as a read-only virtual - table. It is not sealed and may be used as the base class for any - user-defined virtual table class that wraps an - object instance. + Backing field for the + property. This value will be null if this field has not yet been + populated via the underlying native API. - + - The instance containing the backing - data for the virtual table. + This method may only be called from within a + delegate when the conflict + type is . It + returns the total number of known foreign key violations in the + destination database. - + - Constructs an instance of this class. + Queries and returns the original value of a given column for this + change. This method may only be called when the + has a value of + or + . - - The name of the module. This parameter cannot be null. - - - The instance to expose as a virtual - table. This parameter cannot be null. + + The index for the column. This value must be between zero and one + less than the total number of columns for this table. + + The original value of a given column for this change. + - + - See the method. + Queries and returns the updated value of a given column for this + change. This method may only be called when the + has a value of + or + . - - See the method. - - - See the method. + + The index for the column. This value must be between zero and one + less than the total number of columns for this table. - See the method. + The updated value of a given column for this change. - + - See the method. + Queries and returns the conflicting value of a given column for + this change. This method may only be called from within a + delegate when the conflict + type is or + . - - See the method. - - - See the method. - - - See the method. + + The index for the column. This value must be between zero and one + less than the total number of columns for this table. - See the method. + The conflicting value of a given column for this change. - + - Throws an if this object - instance has been disposed. + Disposes of this object instance. - + - Disposes of this object instance. + Non-zero if this object instance has been disposed. + + + + + Throws an exception if this object instance has been disposed. + + + + + Disposes or finalizes this object instance. - Non-zero if this method is being called from the - method. Zero if this method is - being called from the finalizer. + Non-zero if this object is being disposed; otherwise, this object + is being finalized. + + + Finalizes this object instance. + + diff --git a/src/Libraries/Sqlite/libsqlite3.0.dylib b/src/Libraries/Sqlite/libsqlite3.0.dylib index 364b585e7..0c477bda1 100644 Binary files a/src/Libraries/Sqlite/libsqlite3.0.dylib and b/src/Libraries/Sqlite/libsqlite3.0.dylib differ diff --git a/src/Libraries/Sqlite/sqlite3.dll b/src/Libraries/Sqlite/sqlite3.dll index a65d21493..71a7eb91a 100644 Binary files a/src/Libraries/Sqlite/sqlite3.dll and b/src/Libraries/Sqlite/sqlite3.dll differ diff --git a/src/Lidarr.Api.V1/AlbumStudio/AlbumStudioArtistResource.cs b/src/Lidarr.Api.V1/AlbumStudio/AlbumStudioArtistResource.cs new file mode 100644 index 000000000..653ed1df0 --- /dev/null +++ b/src/Lidarr.Api.V1/AlbumStudio/AlbumStudioArtistResource.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using Lidarr.Api.V1.Albums; + +namespace Lidarr.Api.V1.AlbumStudio +{ + public class AlbumStudioArtistResource + { + public int Id { get; set; } + public bool? Monitored { get; set; } + public List Albums { get; set; } + } +} diff --git a/src/Lidarr.Api.V1/AlbumStudio/AlbumStudioModule.cs b/src/Lidarr.Api.V1/AlbumStudio/AlbumStudioModule.cs new file mode 100644 index 000000000..f1df32001 --- /dev/null +++ b/src/Lidarr.Api.V1/AlbumStudio/AlbumStudioModule.cs @@ -0,0 +1,47 @@ +using System.Linq; +using Nancy; +using NzbDrone.Core.Music; +using Lidarr.Http.Extensions; + +namespace Lidarr.Api.V1.AlbumStudio +{ + public class AlbumStudioModule : LidarrV1Module + { + private readonly IArtistService _artistService; + private readonly IAlbumMonitoredService _albumMonitoredService; + + public AlbumStudioModule(IArtistService artistService, IAlbumMonitoredService albumMonitoredService) + : base("/albumstudio") + { + _artistService = artistService; + _albumMonitoredService = albumMonitoredService; + Post["/"] = artist => UpdateAll(); + } + + private Response UpdateAll() + { + //Read from request + var request = Request.Body.FromJson(); + var artistToUpdate = _artistService.GetArtists(request.Artist.Select(s => s.Id)); + + foreach (var s in request.Artist) + { + var artist = artistToUpdate.Single(c => c.Id == s.Id); + + if (s.Monitored.HasValue) + { + artist.Monitored = s.Monitored.Value; + } + + if (request.MonitoringOptions != null && request.MonitoringOptions.Monitor == MonitorTypes.None) + { + artist.Monitored = false; + } + + _albumMonitoredService.SetAlbumMonitoredStatus(artist, request.MonitoringOptions); + } + + return "ok".AsResponse(HttpStatusCode.Accepted); + } + } +} diff --git a/src/Lidarr.Api.V1/AlbumStudio/AlbumStudioResource.cs b/src/Lidarr.Api.V1/AlbumStudio/AlbumStudioResource.cs new file mode 100644 index 000000000..f89b6c962 --- /dev/null +++ b/src/Lidarr.Api.V1/AlbumStudio/AlbumStudioResource.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using NzbDrone.Core.Music; + +namespace Lidarr.Api.V1.AlbumStudio +{ + public class AlbumStudioResource + { + public List Artist { get; set; } + public MonitoringOptions MonitoringOptions { get; set; } + } +} diff --git a/src/Lidarr.Api.V1/Albums/AlbumLookupModule.cs b/src/Lidarr.Api.V1/Albums/AlbumLookupModule.cs new file mode 100644 index 000000000..2a1a0edfe --- /dev/null +++ b/src/Lidarr.Api.V1/Albums/AlbumLookupModule.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Nancy; +using NzbDrone.Core.MediaCover; +using NzbDrone.Core.MetadataSource; +using Lidarr.Http; +using Lidarr.Http.Extensions; + +namespace Lidarr.Api.V1.Albums +{ + public class AlbumLookupModule : LidarrRestModule + { + private readonly ISearchForNewAlbum _searchProxy; + + public AlbumLookupModule(ISearchForNewAlbum searchProxy) + : base("/album/lookup") + { + _searchProxy = searchProxy; + Get["/"] = x => Search(); + } + + private Response Search() + { + var searchResults = _searchProxy.SearchForNewAlbum((string)Request.Query.term, null); + return MapToResource(searchResults).ToList().AsResponse(); + } + + private static IEnumerable MapToResource(IEnumerable albums) + { + foreach (var currentAlbum in albums) + { + var resource = currentAlbum.ToResource(); + var cover = currentAlbum.Images.FirstOrDefault(c => c.CoverType == MediaCoverTypes.Cover); + if (cover != null) + { + resource.RemoteCover = cover.Url; + } + + yield return resource; + } + } + } +} diff --git a/src/Lidarr.Api.V1/Albums/AlbumModule.cs b/src/Lidarr.Api.V1/Albums/AlbumModule.cs new file mode 100644 index 000000000..e09eb8204 --- /dev/null +++ b/src/Lidarr.Api.V1/Albums/AlbumModule.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Nancy; +using NzbDrone.Core.Datastore.Events; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Music; +using NzbDrone.SignalR; +using Lidarr.Http.Extensions; +using NzbDrone.Core.ArtistStats; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Download; +using NzbDrone.Core.Music.Events; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.MediaCover; + +namespace Lidarr.Api.V1.Albums +{ + public class AlbumModule : AlbumModuleWithSignalR, + IHandle, + IHandle, + IHandle, + IHandle + + { + protected readonly IReleaseService _releaseService; + + public AlbumModule(IAlbumService albumService, + IReleaseService releaseService, + IArtistStatisticsService artistStatisticsService, + IMapCoversToLocal coverMapper, + IUpgradableSpecification upgradableSpecification, + IBroadcastSignalRMessage signalRBroadcaster) + : base(albumService, artistStatisticsService, coverMapper, upgradableSpecification, signalRBroadcaster) + { + _releaseService = releaseService; + GetResourceAll = GetAlbums; + UpdateResource = UpdateAlbum; + Put["/monitor"] = x => SetAlbumsMonitored(); + } + + private List GetAlbums() + { + var artistIdQuery = Request.Query.ArtistId; + var albumIdsQuery = Request.Query.AlbumIds; + var foreignIdQuery = Request.Query.ForeignAlbumId; + var includeAllArtistAlbumsQuery = Request.Query.IncludeAllArtistAlbums; + + if (!Request.Query.ArtistId.HasValue && !albumIdsQuery.HasValue && !foreignIdQuery.HasValue) + { + return MapToResource(_albumService.GetAllAlbums(), false); + } + + if (artistIdQuery.HasValue) + { + int artistId = Convert.ToInt32(artistIdQuery.Value); + + return MapToResource(_albumService.GetAlbumsByArtist(artistId), false); + } + + if (foreignIdQuery.HasValue) + { + string foreignAlbumId = foreignIdQuery.Value.ToString(); + + var album = _albumService.FindById(foreignAlbumId); + + if (includeAllArtistAlbumsQuery.HasValue && Convert.ToBoolean(includeAllArtistAlbumsQuery.Value)) + { + return MapToResource(_albumService.GetAlbumsByArtist(album.ArtistId), false); + } + else + { + return MapToResource(new List { album }, false); + } + } + + string albumIdsValue = albumIdsQuery.Value.ToString(); + + var albumIds = albumIdsValue.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(e => Convert.ToInt32(e)) + .ToList(); + + return MapToResource(_albumService.GetAlbums(albumIds), false); + } + + private void UpdateAlbum(AlbumResource albumResource) + { + var album = _albumService.GetAlbum(albumResource.Id); + + var model = albumResource.ToModel(album); + + _albumService.UpdateAlbum(model); + _releaseService.UpdateMany(model.AlbumReleases.Value); + + BroadcastResourceChange(ModelAction.Updated, model.Id); + } + + private Response SetAlbumsMonitored() + { + var resource = Request.Body.FromJson(); + + _albumService.SetMonitored(resource.AlbumIds, resource.Monitored); + + return MapToResource(_albumService.GetAlbums(resource.AlbumIds), false).AsResponse(HttpStatusCode.Accepted); + } + + public void Handle(AlbumGrabbedEvent message) + { + foreach (var album in message.Album.Albums) + { + var resource = album.ToResource(); + resource.Grabbed = true; + + BroadcastResourceChange(ModelAction.Updated, resource); + } + } + + public void Handle(AlbumEditedEvent message) + { + BroadcastResourceChange(ModelAction.Updated, MapToResource(message.Album, true)); + } + + public void Handle(AlbumImportedEvent message) + { + BroadcastResourceChange(ModelAction.Updated, MapToResource(message.Album, true)); + } + + public void Handle(TrackImportedEvent message) + { + BroadcastResourceChange(ModelAction.Updated, message.TrackInfo.Album.ToResource()); + } + } +} diff --git a/src/Lidarr.Api.V1/Albums/AlbumModuleWithSignalR.cs b/src/Lidarr.Api.V1/Albums/AlbumModuleWithSignalR.cs new file mode 100644 index 000000000..48f3721a7 --- /dev/null +++ b/src/Lidarr.Api.V1/Albums/AlbumModuleWithSignalR.cs @@ -0,0 +1,134 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Common.Extensions; +using Lidarr.Api.V1.Artist; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Music; +using NzbDrone.Core.ArtistStats; +using NzbDrone.SignalR; +using Lidarr.Http; +using NzbDrone.Core.MediaCover; + +namespace Lidarr.Api.V1.Albums +{ + public abstract class AlbumModuleWithSignalR : LidarrRestModuleWithSignalR + { + protected readonly IAlbumService _albumService; + protected readonly IArtistStatisticsService _artistStatisticsService; + protected readonly IUpgradableSpecification _qualityUpgradableSpecification; + protected readonly IMapCoversToLocal _coverMapper; + + protected AlbumModuleWithSignalR(IAlbumService albumService, + IArtistStatisticsService artistStatisticsService, + IMapCoversToLocal coverMapper, + IUpgradableSpecification qualityUpgradableSpecification, + IBroadcastSignalRMessage signalRBroadcaster) + : base(signalRBroadcaster) + { + _albumService = albumService; + _artistStatisticsService = artistStatisticsService; + _coverMapper = coverMapper; + _qualityUpgradableSpecification = qualityUpgradableSpecification; + + GetResourceById = GetAlbum; + } + + protected AlbumModuleWithSignalR(IAlbumService albumService, + IArtistStatisticsService artistStatisticsService, + IMapCoversToLocal coverMapper, + IUpgradableSpecification qualityUpgradableSpecification, + IBroadcastSignalRMessage signalRBroadcaster, + string resource) + : base(signalRBroadcaster, resource) + { + _albumService = albumService; + _artistStatisticsService = artistStatisticsService; + _coverMapper = coverMapper; + _qualityUpgradableSpecification = qualityUpgradableSpecification; + + GetResourceById = GetAlbum; + } + + protected AlbumResource GetAlbum(int id) + { + var album = _albumService.GetAlbum(id); + var resource = MapToResource(album, true); + return resource; + } + + protected AlbumResource MapToResource(Album album, bool includeArtist) + { + var resource = album.ToResource(); + + if (includeArtist) + { + var artist = album.Artist.Value; + + resource.Artist = artist.ToResource(); + } + + FetchAndLinkAlbumStatistics(resource); + MapCoversToLocal(resource); + + return resource; + } + + protected List MapToResource(List albums, bool includeArtist) + { + var result = albums.ToResource(); + + if (includeArtist) + { + var artistDict = new Dictionary(); + for (var i = 0; i < albums.Count; i++) + { + var album = albums[i]; + var resource = result[i]; + var artist = artistDict.GetValueOrDefault(albums[i].ArtistMetadataId) ?? album.Artist?.Value; + artistDict[artist.ArtistMetadataId] = artist; + + resource.Artist = artist.ToResource(); + } + } + + var artistStats = _artistStatisticsService.ArtistStatistics(); + LinkArtistStatistics(result, artistStats); + MapCoversToLocal(result.ToArray()); + + return result; + } + + private void FetchAndLinkAlbumStatistics(AlbumResource resource) + { + LinkArtistStatistics(resource, _artistStatisticsService.ArtistStatistics(resource.ArtistId)); + } + + private void LinkArtistStatistics(List resources, List artistStatistics) + { + foreach (var album in resources) + { + var stats = artistStatistics.SingleOrDefault(ss => ss.ArtistId == album.ArtistId); + LinkArtistStatistics(album, stats); + } + } + + private void LinkArtistStatistics(AlbumResource resource, ArtistStatistics artistStatistics) + { + if (artistStatistics?.AlbumStatistics != null) + { + var dictAlbumStats = artistStatistics.AlbumStatistics.ToDictionary(v => v.AlbumId); + + resource.Statistics = dictAlbumStats.GetValueOrDefault(resource.Id).ToResource(); + + } + } + + private void MapCoversToLocal(params AlbumResource[] albums) + { + foreach (var albumResource in albums) + { + _coverMapper.ConvertToLocalUrls(albumResource.Id, MediaCoverEntity.Album, albumResource.Images); + } + } + } +} diff --git a/src/Lidarr.Api.V1/Albums/AlbumReleaseResource.cs b/src/Lidarr.Api.V1/Albums/AlbumReleaseResource.cs new file mode 100644 index 000000000..df2b4f769 --- /dev/null +++ b/src/Lidarr.Api.V1/Albums/AlbumReleaseResource.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Music; + +namespace Lidarr.Api.V1.Albums +{ + public class AlbumReleaseResource + { + public int Id { get; set; } + public int AlbumId { get; set; } + public string ForeignReleaseId { get; set; } + public string Title { get; set; } + public string Status { get; set; } + public int Duration { get; set; } + public int TrackCount { get; set; } + public List Media { get; set; } + public int MediumCount + { + get + { + if (Media == null) + { + return 0; + } + + return Media.Where(s => s.MediumNumber > 0).Count(); + } + } + public string Disambiguation { get; set; } + public List Country { get; set; } + public List Label { get; set; } + public string Format { get; set; } + public bool Monitored { get; set; } + } + + public static class AlbumReleaseResourceMapper + { + public static AlbumReleaseResource ToResource(this AlbumRelease model) + { + if (model == null) + { + return null; + } + + return new AlbumReleaseResource + { + Id = model.Id, + AlbumId = model.AlbumId, + ForeignReleaseId = model.ForeignReleaseId, + Title = model.Title, + Status = model.Status, + Duration = model.Duration, + TrackCount = model.TrackCount, + Media = model.Media.ToResource(), + Disambiguation = model.Disambiguation, + Country = model.Country, + Label = model.Label, + Monitored = model.Monitored, + Format = string.Join(", ", + model.Media.OrderBy(x => x.Number) + .GroupBy(x => x.Format) + .Select(g => MediaFormatHelper(g.Key, g.Count())) + .ToList()) + + }; + } + + public static AlbumRelease ToModel(this AlbumReleaseResource resource) + { + if (resource == null) + { + return null; + } + + return new AlbumRelease + { + Id = resource.Id, + AlbumId = resource.AlbumId, + ForeignReleaseId = resource.ForeignReleaseId, + Title = resource.Title, + Status = resource.Status, + Duration = resource.Duration, + Label = resource.Label, + Disambiguation = resource.Disambiguation, + Country = resource.Country, + Media = resource.Media.ToModel(), + TrackCount = resource.TrackCount, + Monitored = resource.Monitored + }; + } + + private static string MediaFormatHelper(string name, int count) + { + return count == 1 ? name : string.Join("x", new List {count.ToString(), name}); + } + + public static List ToResource(this IEnumerable models) + { + return models.Select(ToResource).ToList(); + } + + public static List ToModel(this IEnumerable resources) + { + return resources.Select(ToModel).ToList(); + } + } +} diff --git a/src/Lidarr.Api.V1/Albums/AlbumResource.cs b/src/Lidarr.Api.V1/Albums/AlbumResource.cs new file mode 100644 index 000000000..446df75ef --- /dev/null +++ b/src/Lidarr.Api.V1/Albums/AlbumResource.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using NzbDrone.Core.Music; +using Lidarr.Api.V1.Artist; +using Lidarr.Http.REST; +using NzbDrone.Core.MediaCover; + +namespace Lidarr.Api.V1.Albums +{ + public class AlbumResource : RestResource + { + public string Title { get; set; } + public string Disambiguation { get; set; } + public string Overview { get; set; } + public int ArtistId { get; set; } + public string ForeignAlbumId { get; set; } + public bool Monitored { get; set; } + public bool AnyReleaseOk { get; set; } + public int ProfileId { get; set; } + public int Duration { get; set; } + public string AlbumType { get; set; } + public List SecondaryTypes { get; set; } + public int MediumCount + { + get + { + if (Media == null) + { + return 0; + } + + return Media.Where(s => s.MediumNumber > 0).Count(); + } + } + public Ratings Ratings { get; set; } + public DateTime? ReleaseDate { get; set; } + public List Releases { get; set; } + public List Genres { get; set; } + public List Media { get; set; } + public ArtistResource Artist { get; set; } + public List Images { get; set; } + public List Links { get; set; } + public AlbumStatisticsResource Statistics { get; set; } + + public string RemoteCover { get; set; } + + //Hiding this so people don't think its usable (only used to set the initial state) + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public bool Grabbed { get; set; } + } + + public static class AlbumResourceMapper + { + public static AlbumResource ToResource(this Album model) + { + if (model == null) return null; + + var selectedRelease = model.AlbumReleases?.Value.Where(x => x.Monitored).SingleOrDefault(); + + return new AlbumResource + { + Id = model.Id, + ArtistId = model.ArtistId, + ForeignAlbumId = model.ForeignAlbumId, + ProfileId = model.ProfileId, + Monitored = model.Monitored, + AnyReleaseOk = model.AnyReleaseOk, + ReleaseDate = model.ReleaseDate, + Genres = model.Genres, + Title = model.Title, + Disambiguation = model.Disambiguation, + Overview = model.Overview, + Images = model.Images, + Links = model.Links, + Ratings = model.Ratings, + Duration = selectedRelease?.Duration ?? 0, + AlbumType = model.AlbumType, + SecondaryTypes = model.SecondaryTypes.Select(s => s.Name).ToList(), + Releases = model.AlbumReleases?.Value.ToResource() ?? new List(), + Media = selectedRelease?.Media.ToResource() ?? new List(), + Artist = model.Artist?.Value.ToResource() + }; + } + + public static Album ToModel(this AlbumResource resource) + { + if (resource == null) return null; + + return new Album + { + Id = resource.Id, + ForeignAlbumId = resource.ForeignAlbumId, + Title = resource.Title, + Disambiguation = resource.Disambiguation, + Overview = resource.Overview, + Images = resource.Images, + Monitored = resource.Monitored, + AnyReleaseOk = resource.AnyReleaseOk, + AlbumReleases = resource.Releases.ToModel() + }; + } + + public static Album ToModel(this AlbumResource resource, Album album) + { + var updatedAlbum = resource.ToModel(); + + album.ApplyChanges(updatedAlbum); + album.AlbumReleases = updatedAlbum.AlbumReleases; + + return album; + } + + public static List ToResource(this IEnumerable models) + { + return models?.Select(ToResource).ToList(); + } + + public static List ToModel(this IEnumerable resources) + { + return resources.Select(ToModel).ToList(); + } + } +} diff --git a/src/Lidarr.Api.V1/Albums/AlbumStatisticsResource.cs b/src/Lidarr.Api.V1/Albums/AlbumStatisticsResource.cs new file mode 100644 index 000000000..c87993444 --- /dev/null +++ b/src/Lidarr.Api.V1/Albums/AlbumStatisticsResource.cs @@ -0,0 +1,39 @@ +using System; +using NzbDrone.Core.ArtistStats; + +namespace Lidarr.Api.V1.Albums +{ + public class AlbumStatisticsResource + { + public int TrackFileCount { get; set; } + public int TrackCount { get; set; } + public int TotalTrackCount { get; set; } + public long SizeOnDisk { get; set; } + + public decimal PercentOfTracks + { + get + { + if (TrackCount == 0) return 0; + + return (decimal)TrackFileCount / (decimal)TrackCount * 100; + } + } + } + + public static class AlbumStatisticsResourceMapper + { + public static AlbumStatisticsResource ToResource(this AlbumStatistics model) + { + if (model == null) return null; + + return new AlbumStatisticsResource + { + TrackFileCount = model.TrackFileCount, + TrackCount = model.TrackCount, + TotalTrackCount = model.TotalTrackCount, + SizeOnDisk = model.SizeOnDisk + }; + } + } +} diff --git a/src/Lidarr.Api.V1/Albums/AlbumsMonitoredResource.cs b/src/Lidarr.Api.V1/Albums/AlbumsMonitoredResource.cs new file mode 100644 index 000000000..8ccd7efea --- /dev/null +++ b/src/Lidarr.Api.V1/Albums/AlbumsMonitoredResource.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; + +namespace Lidarr.Api.V1.Albums +{ + public class AlbumsMonitoredResource + { + public List AlbumIds { get; set; } + public bool Monitored { get; set; } + } +} diff --git a/src/Lidarr.Api.V1/Albums/MediumResource.cs b/src/Lidarr.Api.V1/Albums/MediumResource.cs new file mode 100644 index 000000000..239cf02b1 --- /dev/null +++ b/src/Lidarr.Api.V1/Albums/MediumResource.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Music; + +namespace Lidarr.Api.V1.Albums +{ + public class MediumResource + { + public int MediumNumber { get; set; } + public string MediumName { get; set; } + public string MediumFormat { get; set; } + } + + public static class MediumResourceMapper + { + public static MediumResource ToResource(this Medium model) + { + if (model == null) + { + return null; + } + + return new MediumResource + { + MediumNumber = model.Number, + MediumName = model.Name, + MediumFormat = model.Format + }; + } + + public static Medium ToModel(this MediumResource resource) + { + if (resource == null) + { + return null; + } + + return new Medium + { + Number = resource.MediumNumber, + Name = resource.MediumName, + Format = resource.MediumFormat + }; + } + + public static List ToResource(this IEnumerable models) + { + return models.Select(ToResource).ToList(); + } + + public static List ToModel(this IEnumerable resources) + { + return resources.Select(ToModel).ToList(); + } + } +} diff --git a/src/NzbDrone.Api/Series/AlternateTitleResource.cs b/src/Lidarr.Api.V1/Artist/AlternateTitleResource.cs similarity index 85% rename from src/NzbDrone.Api/Series/AlternateTitleResource.cs rename to src/Lidarr.Api.V1/Artist/AlternateTitleResource.cs index b1d6cc22c..0cecb77fb 100644 --- a/src/NzbDrone.Api/Series/AlternateTitleResource.cs +++ b/src/Lidarr.Api.V1/Artist/AlternateTitleResource.cs @@ -1,4 +1,4 @@ -namespace NzbDrone.Api.Series +namespace Lidarr.Api.V1.Artist { public class AlternateTitleResource { diff --git a/src/Lidarr.Api.V1/Artist/ArtistEditorDeleteResource.cs b/src/Lidarr.Api.V1/Artist/ArtistEditorDeleteResource.cs new file mode 100644 index 000000000..cd719149e --- /dev/null +++ b/src/Lidarr.Api.V1/Artist/ArtistEditorDeleteResource.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace Lidarr.Api.V1.Artist +{ + public class ArtistEditorDeleteResource + { + public List ArtistIds { get; set; } + public bool DeleteFiles { get; set; } + } +} diff --git a/src/Lidarr.Api.V1/Artist/ArtistEditorModule.cs b/src/Lidarr.Api.V1/Artist/ArtistEditorModule.cs new file mode 100644 index 000000000..e9a8f0727 --- /dev/null +++ b/src/Lidarr.Api.V1/Artist/ArtistEditorModule.cs @@ -0,0 +1,111 @@ +using System.Collections.Generic; +using System.Linq; +using Nancy; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Music; +using NzbDrone.Core.Music.Commands; +using Lidarr.Http.Extensions; + +namespace Lidarr.Api.V1.Artist +{ + public class ArtistEditorModule : LidarrV1Module + { + private readonly IArtistService _artistService; + private readonly IManageCommandQueue _commandQueueManager; + + public ArtistEditorModule(IArtistService artistService, IManageCommandQueue commandQueueManager) + : base("/artist/editor") + { + _artistService = artistService; + _commandQueueManager = commandQueueManager; + Put["/"] = artist => SaveAll(); + Delete["/"] = artist => DeleteArtist(); + } + + private Response SaveAll() + { + var resource = Request.Body.FromJson(); + var artistToUpdate = _artistService.GetArtists(resource.ArtistIds); + var artistToMove = new List(); + + foreach (var artist in artistToUpdate) + { + if (resource.Monitored.HasValue) + { + artist.Monitored = resource.Monitored.Value; + } + + if (resource.QualityProfileId.HasValue) + { + artist.QualityProfileId = resource.QualityProfileId.Value; + } + + if (resource.MetadataProfileId.HasValue) + { + artist.MetadataProfileId = resource.MetadataProfileId.Value; + } + + if (resource.AlbumFolder.HasValue) + { + artist.AlbumFolder = resource.AlbumFolder.Value; + } + + if (resource.RootFolderPath.IsNotNullOrWhiteSpace()) + { + artist.RootFolderPath = resource.RootFolderPath; + artistToMove.Add(new BulkMoveArtist + { + ArtistId = artist.Id, + SourcePath = artist.Path + }); + + } + + if (resource.Tags != null) + { + var newTags = resource.Tags; + var applyTags = resource.ApplyTags; + + switch (applyTags) + { + case ApplyTags.Add: + newTags.ForEach(t => artist.Tags.Add(t)); + break; + case ApplyTags.Remove: + newTags.ForEach(t => artist.Tags.Remove(t)); + break; + case ApplyTags.Replace: + artist.Tags = new HashSet(newTags); + break; + } + } + } + + if (resource.MoveFiles && artistToMove.Any()) + { + _commandQueueManager.Push(new BulkMoveArtistCommand + { + DestinationRootFolder = resource.RootFolderPath, + Artist = artistToMove + }); + } + + return _artistService.UpdateArtists(artistToUpdate, !resource.MoveFiles) + .ToResource() + .AsResponse(HttpStatusCode.Accepted); + } + + private Response DeleteArtist() + { + var resource = Request.Body.FromJson(); + + foreach (var artistId in resource.ArtistIds) + { + _artistService.DeleteArtist(artistId, false); + } + + return new object().AsResponse(); + } + } +} diff --git a/src/Lidarr.Api.V1/Artist/ArtistEditorResource.cs b/src/Lidarr.Api.V1/Artist/ArtistEditorResource.cs new file mode 100644 index 000000000..cdeb7ec29 --- /dev/null +++ b/src/Lidarr.Api.V1/Artist/ArtistEditorResource.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using NzbDrone.Core.Music; + +namespace Lidarr.Api.V1.Artist +{ + public class ArtistEditorResource + { + public List ArtistIds { get; set; } + public bool? Monitored { get; set; } + public int? QualityProfileId { get; set; } + public int? MetadataProfileId { get; set; } + public bool? AlbumFolder { get; set; } + public string RootFolderPath { get; set; } + public List Tags { get; set; } + public ApplyTags ApplyTags { get; set; } + public bool MoveFiles { get; set; } + } + + public enum ApplyTags + { + Add, + Remove, + Replace + } +} diff --git a/src/Lidarr.Api.V1/Artist/ArtistImportModule.cs b/src/Lidarr.Api.V1/Artist/ArtistImportModule.cs new file mode 100644 index 000000000..d20038b38 --- /dev/null +++ b/src/Lidarr.Api.V1/Artist/ArtistImportModule.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using Nancy; +using NzbDrone.Core.Music; +using Lidarr.Http; +using Lidarr.Http.Extensions; + +namespace Lidarr.Api.V1.Artist +{ + public class ArtistImportModule : LidarrRestModule + { + private readonly IAddArtistService _addArtistService; + + public ArtistImportModule(IAddArtistService addArtistService) + : base("/artist/import") + { + _addArtistService = addArtistService; + Post["/"] = x => Import(); + } + + + private Response Import() + { + var resource = Request.Body.FromJson>(); + var newArtists = resource.ToModel(); + + return _addArtistService.AddArtists(newArtists).ToResource().AsResponse(); + } + } +} diff --git a/src/Lidarr.Api.V1/Artist/ArtistLookupModule.cs b/src/Lidarr.Api.V1/Artist/ArtistLookupModule.cs new file mode 100644 index 000000000..c48b4fbce --- /dev/null +++ b/src/Lidarr.Api.V1/Artist/ArtistLookupModule.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.Linq; +using Nancy; +using NzbDrone.Core.MediaCover; +using NzbDrone.Core.MetadataSource; +using Lidarr.Http; +using Lidarr.Http.Extensions; + +namespace Lidarr.Api.V1.Artist +{ + public class ArtistLookupModule : LidarrRestModule + { + private readonly ISearchForNewArtist _searchProxy; + + public ArtistLookupModule(ISearchForNewArtist searchProxy) + : base("/artist/lookup") + { + _searchProxy = searchProxy; + Get["/"] = x => Search(); + } + + private Response Search() + { + var searchResults = _searchProxy.SearchForNewArtist((string)Request.Query.term); + return MapToResource(searchResults).ToList().AsResponse(); + } + + private static IEnumerable MapToResource(IEnumerable artist) + { + foreach (var currentArtist in artist) + { + var resource = currentArtist.ToResource(); + var poster = currentArtist.Metadata.Value.Images.FirstOrDefault(c => c.CoverType == MediaCoverTypes.Poster); + if (poster != null) + { + resource.RemotePoster = poster.Url; + } + + yield return resource; + } + } + } +} diff --git a/src/Lidarr.Api.V1/Artist/ArtistModule.cs b/src/Lidarr.Api.V1/Artist/ArtistModule.cs new file mode 100644 index 000000000..7dbfbfeeb --- /dev/null +++ b/src/Lidarr.Api.V1/Artist/ArtistModule.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentValidation; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Datastore.Events; +using NzbDrone.Core.MediaCover; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.RootFolders; +using NzbDrone.Core.ArtistStats; +using NzbDrone.Core.Music; +using NzbDrone.Core.Music.Commands; +using NzbDrone.Core.Music.Events; +using NzbDrone.Core.Validation; +using NzbDrone.Core.Validation.Paths; +using Lidarr.Api.V1.Albums; +using NzbDrone.SignalR; +using Lidarr.Http; +using Lidarr.Http.Extensions; + +namespace Lidarr.Api.V1.Artist +{ + public class ArtistModule : LidarrRestModuleWithSignalR, + IHandle, + IHandle, + IHandle, + IHandle, + IHandle, + IHandle, + IHandle, + IHandle + + { + private readonly IArtistService _artistService; + private readonly IAlbumService _albumService; + private readonly IAddArtistService _addArtistService; + private readonly IArtistStatisticsService _artistStatisticsService; + private readonly IMapCoversToLocal _coverMapper; + private readonly IManageCommandQueue _commandQueueManager; + private readonly IRootFolderService _rootFolderService; + + public ArtistModule(IBroadcastSignalRMessage signalRBroadcaster, + IArtistService artistService, + IAlbumService albumService, + IAddArtistService addArtistService, + IArtistStatisticsService artistStatisticsService, + IMapCoversToLocal coverMapper, + IManageCommandQueue commandQueueManager, + IRootFolderService rootFolderService, + RootFolderValidator rootFolderValidator, + MappedNetworkDriveValidator mappedNetworkDriveValidator, + ArtistPathValidator artistPathValidator, + ArtistExistsValidator artistExistsValidator, + ArtistAncestorValidator artistAncestorValidator, + SystemFolderValidator systemFolderValidator, + ProfileExistsValidator profileExistsValidator, + MetadataProfileExistsValidator metadataProfileExistsValidator + ) + : base(signalRBroadcaster) + { + _artistService = artistService; + _albumService = albumService; + _addArtistService = addArtistService; + _artistStatisticsService = artistStatisticsService; + + _coverMapper = coverMapper; + _commandQueueManager = commandQueueManager; + _rootFolderService = rootFolderService; + + GetResourceAll = AllArtists; + GetResourceById = GetArtist; + CreateResource = AddArtist; + UpdateResource = UpdateArtist; + DeleteResource = DeleteArtist; + + Http.Validation.RuleBuilderExtensions.ValidId(SharedValidator.RuleFor(s => s.QualityProfileId)); + Http.Validation.RuleBuilderExtensions.ValidId(SharedValidator.RuleFor(s => s.MetadataProfileId)); + + SharedValidator.RuleFor(s => s.Path) + .Cascade(CascadeMode.StopOnFirstFailure) + .IsValidPath() + .SetValidator(rootFolderValidator) + .SetValidator(mappedNetworkDriveValidator) + .SetValidator(artistPathValidator) + .SetValidator(artistAncestorValidator) + .SetValidator(systemFolderValidator) + .When(s => !s.Path.IsNullOrWhiteSpace()); + + SharedValidator.RuleFor(s => s.QualityProfileId).SetValidator(profileExistsValidator); + SharedValidator.RuleFor(s => s.MetadataProfileId).SetValidator(metadataProfileExistsValidator); + + PostValidator.RuleFor(s => s.Path).IsValidPath().When(s => s.RootFolderPath.IsNullOrWhiteSpace()); + PostValidator.RuleFor(s => s.RootFolderPath).IsValidPath().When(s => s.Path.IsNullOrWhiteSpace()); + PostValidator.RuleFor(s => s.ArtistName).NotEmpty(); + PostValidator.RuleFor(s => s.ForeignArtistId).NotEmpty().SetValidator(artistExistsValidator); + + PutValidator.RuleFor(s => s.Path).IsValidPath(); + } + + private ArtistResource GetArtist(int id) + { + var artist = _artistService.GetArtist(id); + return GetArtistResource(artist); + } + + private ArtistResource GetArtistResource(NzbDrone.Core.Music.Artist artist) + { + if (artist == null) return null; + + var resource = artist.ToResource(); + MapCoversToLocal(resource); + FetchAndLinkArtistStatistics(resource); + LinkNextPreviousAlbums(resource); + //PopulateAlternateTitles(resource); + LinkRootFolderPath(resource); + + return resource; + } + + private List AllArtists() + { + var artistStats = _artistStatisticsService.ArtistStatistics(); + var artistsResources = _artistService.GetAllArtists().ToResource(); + + MapCoversToLocal(artistsResources.ToArray()); + LinkNextPreviousAlbums(artistsResources.ToArray()); + LinkArtistStatistics(artistsResources, artistStats); + //PopulateAlternateTitles(seriesResources); + + return artistsResources; + } + + private int AddArtist(ArtistResource artistResource) + { + var artist = _addArtistService.AddArtist(artistResource.ToModel()); + + return artist.Id; + } + + private void UpdateArtist(ArtistResource artistResource) + { + var moveFiles = Request.GetBooleanQueryParameter("moveFiles"); + var artist = _artistService.GetArtist(artistResource.Id); + + if (moveFiles) + { + var sourcePath = artist.Path; + var destinationPath = artistResource.Path; + + _commandQueueManager.Push(new MoveArtistCommand + { + ArtistId = artist.Id, + SourcePath = sourcePath, + DestinationPath = destinationPath, + Trigger = CommandTrigger.Manual + }); + } + + var model = artistResource.ToModel(artist); + + _artistService.UpdateArtist(model); + + BroadcastResourceChange(ModelAction.Updated, artistResource); + } + + private void DeleteArtist(int id) + { + var deleteFiles = Request.GetBooleanQueryParameter("deleteFiles"); + var addImportListExclusion = Request.GetBooleanQueryParameter("addImportListExclusion"); + + _artistService.DeleteArtist(id, deleteFiles, addImportListExclusion); + } + + private void MapCoversToLocal(params ArtistResource[] artists) + { + foreach (var artistResource in artists) + { + _coverMapper.ConvertToLocalUrls(artistResource.Id, MediaCoverEntity.Artist, artistResource.Images); + } + } + + private void LinkNextPreviousAlbums(params ArtistResource[] artists) + { + var nextAlbums = _albumService.GetNextAlbumsByArtistMetadataId(artists.Select(x => x.ArtistMetadataId)); + var lastAlbums = _albumService.GetLastAlbumsByArtistMetadataId(artists.Select(x => x.ArtistMetadataId)); + + foreach (var artistResource in artists) + { + artistResource.NextAlbum = nextAlbums.FirstOrDefault(x => x.ArtistMetadataId == artistResource.ArtistMetadataId); + artistResource.LastAlbum = lastAlbums.FirstOrDefault(x => x.ArtistMetadataId == artistResource.ArtistMetadataId); + } + } + + private void FetchAndLinkArtistStatistics(ArtistResource resource) + { + LinkArtistStatistics(resource, _artistStatisticsService.ArtistStatistics(resource.Id)); + } + + private void LinkArtistStatistics(List resources, List artistStatistics) + { + foreach (var artist in resources) + { + var stats = artistStatistics.SingleOrDefault(ss => ss.ArtistId == artist.Id); + if (stats == null) continue; + + LinkArtistStatistics(artist, stats); + } + } + + private void LinkArtistStatistics(ArtistResource resource, ArtistStatistics artistStatistics) + { + resource.Statistics = artistStatistics.ToResource(); + } + + //private void PopulateAlternateTitles(List resources) + //{ + // foreach (var resource in resources) + // { + // PopulateAlternateTitles(resource); + // } + //} + + //private void PopulateAlternateTitles(ArtistResource resource) + //{ + // var mappings = _sceneMappingService.FindByTvdbId(resource.TvdbId); + + // if (mappings == null) return; + + // resource.AlternateTitles = mappings.Select(v => new AlternateTitleResource { Title = v.Title, SeasonNumber = v.SeasonNumber, SceneSeasonNumber = v.SceneSeasonNumber }).ToList(); + //} + + private void LinkRootFolderPath(ArtistResource resource) + { + resource.RootFolderPath = _rootFolderService.GetBestRootFolderPath(resource.Path); + } + + public void Handle(AlbumImportedEvent message) + { + BroadcastResourceChange(ModelAction.Updated, GetArtistResource(message.Artist)); + } + + public void Handle(AlbumEditedEvent message) + { + BroadcastResourceChange(ModelAction.Updated, GetArtistResource(message.Album.Artist.Value)); + } + + public void Handle(TrackFileDeletedEvent message) + { + if (message.Reason == DeleteMediaFileReason.Upgrade) return; + + BroadcastResourceChange(ModelAction.Updated, GetArtistResource(message.TrackFile.Artist.Value)); + } + + public void Handle(ArtistUpdatedEvent message) + { + BroadcastResourceChange(ModelAction.Updated, GetArtistResource(message.Artist)); + } + + public void Handle(ArtistEditedEvent message) + { + BroadcastResourceChange(ModelAction.Updated, GetArtistResource(message.Artist)); + } + + public void Handle(ArtistDeletedEvent message) + { + BroadcastResourceChange(ModelAction.Deleted, message.Artist.ToResource()); + } + + public void Handle(ArtistRenamedEvent message) + { + BroadcastResourceChange(ModelAction.Updated, message.Artist.Id); + } + + public void Handle(MediaCoversUpdatedEvent message) + { + BroadcastResourceChange(ModelAction.Updated, GetArtistResource(message.Artist)); + } + } +} diff --git a/src/Lidarr.Api.V1/Artist/ArtistResource.cs b/src/Lidarr.Api.V1/Artist/ArtistResource.cs new file mode 100644 index 000000000..8b4981f20 --- /dev/null +++ b/src/Lidarr.Api.V1/Artist/ArtistResource.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.MediaCover; +using NzbDrone.Core.Music; +using Lidarr.Http.REST; +using Newtonsoft.Json; + +namespace Lidarr.Api.V1.Artist +{ + public class ArtistResource : RestResource + { + //Todo: Sorters should be done completely on the client + //Todo: Is there an easy way to keep IgnoreArticlesWhenSorting in sync between, Series, History, Missing? + //Todo: We should get the entire Profile instead of ID and Name separately + + [JsonIgnore] + public int ArtistMetadataId { get; set; } + public ArtistStatusType Status { get; set; } + + public bool Ended => Status == ArtistStatusType.Ended; + + public DateTime? LastInfoSync { get; set; } + + public string ArtistName { get; set; } + public string ForeignArtistId { get; set; } + public string MBId { get; set; } + public int TADBId { get; set; } + public int DiscogsId { get; set; } + public string AllMusicId { get; set; } + public string Overview { get; set; } + public string ArtistType { get; set; } + public string Disambiguation { get; set; } + public List Links { get; set; } + + public Album NextAlbum { get; set; } + public Album LastAlbum { get; set; } + + public List Images { get; set; } + public List Members { get; set; } + + public string RemotePoster { get; set; } + + + //View & Edit + public string Path { get; set; } + public int QualityProfileId { get; set; } + public int MetadataProfileId { get; set; } + + //Editing Only + public bool AlbumFolder { get; set; } + public bool Monitored { get; set; } + + public string RootFolderPath { get; set; } + public List Genres { get; set; } + public string CleanName { get; set; } + public string SortName { get; set; } + public HashSet Tags { get; set; } + public DateTime Added { get; set; } + public AddArtistOptions AddOptions { get; set; } + public Ratings Ratings { get; set; } + + public ArtistStatisticsResource Statistics { get; set; } + } + + public static class ArtistResourceMapper + { + public static ArtistResource ToResource(this NzbDrone.Core.Music.Artist model) + { + if (model == null) return null; + + return new ArtistResource + { + Id = model.Id, + ArtistMetadataId = model.ArtistMetadataId, + + ArtistName = model.Name, + //AlternateTitles + SortName = model.SortName, + + Status = model.Metadata.Value.Status, + Overview = model.Metadata.Value.Overview, + ArtistType = model.Metadata.Value.Type, + Disambiguation = model.Metadata.Value.Disambiguation, + + Images = model.Metadata.Value.Images.JsonClone(), + + Path = model.Path, + QualityProfileId = model.QualityProfileId, + MetadataProfileId = model.MetadataProfileId, + Links = model.Metadata.Value.Links, + + AlbumFolder = model.AlbumFolder, + Monitored = model.Monitored, + + LastInfoSync = model.LastInfoSync, + + CleanName = model.CleanName, + ForeignArtistId = model.Metadata.Value.ForeignArtistId, + // Root folder path is now calculated from the artist path + // RootFolderPath = model.RootFolderPath, + Genres = model.Metadata.Value.Genres, + Tags = model.Tags, + Added = model.Added, + AddOptions = model.AddOptions, + Ratings = model.Metadata.Value.Ratings, + + Statistics = new ArtistStatisticsResource() + }; + } + + public static NzbDrone.Core.Music.Artist ToModel(this ArtistResource resource) + { + if (resource == null) return null; + + return new NzbDrone.Core.Music.Artist + { + Id = resource.Id, + + Metadata = new NzbDrone.Core.Music.ArtistMetadata + { + ForeignArtistId = resource.ForeignArtistId, + Name = resource.ArtistName, + Status = resource.Status, + Overview = resource.Overview, + Links = resource.Links, + Images = resource.Images, + Genres = resource.Genres, + Ratings = resource.Ratings, + Type = resource.ArtistType + }, + + //AlternateTitles + SortName = resource.SortName, + Path = resource.Path, + QualityProfileId = resource.QualityProfileId, + MetadataProfileId = resource.MetadataProfileId, + + + AlbumFolder = resource.AlbumFolder, + Monitored = resource.Monitored, + + LastInfoSync = resource.LastInfoSync, + CleanName = resource.CleanName, + RootFolderPath = resource.RootFolderPath, + + Tags = resource.Tags, + Added = resource.Added, + AddOptions = resource.AddOptions, + + }; + } + + public static NzbDrone.Core.Music.Artist ToModel(this ArtistResource resource, NzbDrone.Core.Music.Artist artist) + { + var updatedArtist = resource.ToModel(); + + artist.ApplyChanges(updatedArtist); + + return artist; + } + + public static List ToResource(this IEnumerable artist) + { + return artist.Select(ToResource).ToList(); + } + + public static List ToModel(this IEnumerable resources) + { + return resources.Select(ToModel).ToList(); + } + } +} diff --git a/src/Lidarr.Api.V1/Artist/ArtistStatisticsResource.cs b/src/Lidarr.Api.V1/Artist/ArtistStatisticsResource.cs new file mode 100644 index 000000000..6759ff839 --- /dev/null +++ b/src/Lidarr.Api.V1/Artist/ArtistStatisticsResource.cs @@ -0,0 +1,41 @@ +using System; +using NzbDrone.Core.ArtistStats; + +namespace Lidarr.Api.V1.Artist +{ + public class ArtistStatisticsResource + { + public int AlbumCount { get; set; } + public int TrackFileCount { get; set; } + public int TrackCount { get; set; } + public int TotalTrackCount { get; set; } + public long SizeOnDisk { get; set; } + + public decimal PercentOfTracks + { + get + { + if (TrackCount == 0) return 0; + + return (decimal)TrackFileCount / (decimal)TrackCount * 100; + } + } + } + + public static class ArtistStatisticsResourceMapper + { + public static ArtistStatisticsResource ToResource(this ArtistStatistics model) + { + if (model == null) return null; + + return new ArtistStatisticsResource + { + AlbumCount = model.AlbumCount, + TrackFileCount = model.TrackFileCount, + TrackCount = model.TrackCount, + TotalTrackCount = model.TotalTrackCount, + SizeOnDisk = model.SizeOnDisk + }; + } + } +} diff --git a/src/Lidarr.Api.V1/Blacklist/BlacklistModule.cs b/src/Lidarr.Api.V1/Blacklist/BlacklistModule.cs new file mode 100644 index 000000000..2a93d68d9 --- /dev/null +++ b/src/Lidarr.Api.V1/Blacklist/BlacklistModule.cs @@ -0,0 +1,36 @@ +using NzbDrone.Core.Blacklisting; +using NzbDrone.Core.Datastore; +using Lidarr.Http; + +namespace Lidarr.Api.V1.Blacklist +{ + public class BlacklistModule : LidarrRestModule + { + private readonly IBlacklistService _blacklistService; + + public BlacklistModule(IBlacklistService blacklistService) + { + _blacklistService = blacklistService; + GetResourcePaged = GetBlacklist; + DeleteResource = DeleteBlacklist; + } + + private PagingResource GetBlacklist(PagingResource pagingResource) + { + var pagingSpec = new PagingSpec + { + Page = pagingResource.Page, + PageSize = pagingResource.PageSize, + SortKey = pagingResource.SortKey, + SortDirection = pagingResource.SortDirection + }; + + return ApplyToPage(_blacklistService.Paged, pagingSpec, BlacklistResourceMapper.MapToResource); + } + + private void DeleteBlacklist(int id) + { + _blacklistService.Delete(id); + } + } +} diff --git a/src/Lidarr.Api.V1/Blacklist/BlacklistResource.cs b/src/Lidarr.Api.V1/Blacklist/BlacklistResource.cs new file mode 100644 index 000000000..e01254c63 --- /dev/null +++ b/src/Lidarr.Api.V1/Blacklist/BlacklistResource.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Qualities; +using Lidarr.Api.V1.Artist; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.Blacklist +{ + public class BlacklistResource : RestResource + { + public int ArtistId { get; set; } + public List AlbumIds { get; set; } + public string SourceTitle { get; set; } + public QualityModel Quality { get; set; } + public DateTime Date { get; set; } + public DownloadProtocol Protocol { get; set; } + public string Indexer { get; set; } + public string Message { get; set; } + + public ArtistResource Artist { get; set; } + } + + public static class BlacklistResourceMapper + { + public static BlacklistResource MapToResource(this NzbDrone.Core.Blacklisting.Blacklist model) + { + if (model == null) return null; + + return new BlacklistResource + { + Id = model.Id, + + ArtistId = model.ArtistId, + AlbumIds = model.AlbumIds, + SourceTitle = model.SourceTitle, + Quality = model.Quality, + Date = model.Date, + Protocol = model.Protocol, + Indexer = model.Indexer, + Message = model.Message, + + Artist = model.Artist.ToResource() + }; + } + } +} diff --git a/src/Lidarr.Api.V1/Calendar/CalendarFeedModule.cs b/src/Lidarr.Api.V1/Calendar/CalendarFeedModule.cs new file mode 100644 index 000000000..75ced18e4 --- /dev/null +++ b/src/Lidarr.Api.V1/Calendar/CalendarFeedModule.cs @@ -0,0 +1,102 @@ +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; +using Nancy; +using Nancy.Responses; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Tags; +using NzbDrone.Core.Music; +using Lidarr.Http.Extensions; + +namespace Lidarr.Api.V1.Calendar +{ + public class CalendarFeedModule : LidarrV1FeedModule + { + private readonly IAlbumService _albumService; + private readonly IArtistService _artistService; + private readonly ITagService _tagService; + + public CalendarFeedModule(IAlbumService albumService, IArtistService artistService, ITagService tagService) + : base("calendar") + { + _albumService = albumService; + _artistService = artistService; + _tagService = tagService; + + Get["/Lidarr.ics"] = options => GetCalendarFeed(); + } + + private Response GetCalendarFeed() + { + var pastDays = 7; + var futureDays = 28; + var start = DateTime.Today.AddDays(-pastDays); + var end = DateTime.Today.AddDays(futureDays); + var unmonitored = Request.GetBooleanQueryParameter("unmonitored"); + var tags = new List(); + + var queryPastDays = Request.Query.PastDays; + var queryFutureDays = Request.Query.FutureDays; + var queryTags = Request.Query.Tags; + + if (queryPastDays.HasValue) + { + pastDays = int.Parse(queryPastDays.Value); + start = DateTime.Today.AddDays(-pastDays); + } + + if (queryFutureDays.HasValue) + { + futureDays = int.Parse(queryFutureDays.Value); + end = DateTime.Today.AddDays(futureDays); + } + + if (queryTags.HasValue) + { + var tagInput = (string)queryTags.Value.ToString(); + tags.AddRange(tagInput.Split(',').Select(_tagService.GetTag).Select(t => t.Id)); + } + + var albums = _albumService.AlbumsBetweenDates(start, end, unmonitored); + var calendar = new Ical.Net.Calendar + { + ProductId = "-//lidarr.audio//Lidarr//EN" + }; + + var calendarName = "Lidarr Music Schedule"; + calendar.AddProperty(new CalendarProperty("NAME", calendarName)); + calendar.AddProperty(new CalendarProperty("X-WR-CALNAME", calendarName)); + + foreach (var album in albums.OrderBy(v => v.ReleaseDate.Value)) + { + var artist = _artistService.GetArtist(album.ArtistId); // Temp fix TODO: Figure out why Album.Artist is not populated during AlbumsBetweenDates Query + + if (tags.Any() && tags.None(artist.Tags.Contains)) + { + continue; + } + + var occurrence = calendar.Create(); + occurrence.Uid = "NzbDrone_album_" + album.Id; + //occurrence.Status = album.HasFile ? EventStatus.Confirmed : EventStatus.Tentative; + //occurrence.Description = album.Overview; + //occurrence.Categories = new List() { album.Series.Network }; + + occurrence.Start = new CalDateTime(album.ReleaseDate.Value.ToLocalTime()) { HasTime = false }; + + occurrence.Summary = $"{artist.Name} - {album.Title}"; + } + + var serializer = (IStringSerializer)new SerializerFactory().Build(calendar.GetType(), new SerializationContext()); + var icalendar = serializer.SerializeToString(calendar); + + return new TextResponse(icalendar, "text/calendar"); + } + } +} diff --git a/src/Lidarr.Api.V1/Calendar/CalendarModule.cs b/src/Lidarr.Api.V1/Calendar/CalendarModule.cs new file mode 100644 index 000000000..cea1a7589 --- /dev/null +++ b/src/Lidarr.Api.V1/Calendar/CalendarModule.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Music; +using NzbDrone.Core.ArtistStats; +using NzbDrone.SignalR; +using Lidarr.Api.V1.Albums; +using Lidarr.Http.Extensions; +using NzbDrone.Core.MediaCover; + +namespace Lidarr.Api.V1.Calendar +{ + public class CalendarModule : AlbumModuleWithSignalR + { + public CalendarModule(IAlbumService albumService, + IArtistStatisticsService artistStatisticsService, + IMapCoversToLocal coverMapper, + IUpgradableSpecification upgradableSpecification, + IBroadcastSignalRMessage signalRBroadcaster) + : base(albumService, artistStatisticsService, coverMapper, upgradableSpecification, signalRBroadcaster, "calendar") + { + GetResourceAll = GetCalendar; + } + + private List GetCalendar() + { + var start = DateTime.Today; + var end = DateTime.Today.AddDays(2); + var includeUnmonitored = Request.GetBooleanQueryParameter("unmonitored"); + var includeArtist = Request.GetBooleanQueryParameter("includeArtist"); + + //TODO: Add Album Image support to AlbumModuleWithSignalR + var includeAlbumImages = Request.GetBooleanQueryParameter("includeAlbumImages"); + + var queryStart = Request.Query.Start; + var queryEnd = Request.Query.End; + + if (queryStart.HasValue) start = DateTime.Parse(queryStart.Value); + if (queryEnd.HasValue) end = DateTime.Parse(queryEnd.Value); + + var resources = MapToResource(_albumService.AlbumsBetweenDates(start, end, includeUnmonitored), includeArtist); + + return resources.OrderBy(e => e.ReleaseDate).ToList(); + } + } +} diff --git a/src/Lidarr.Api.V1/Commands/CommandModule.cs b/src/Lidarr.Api.V1/Commands/CommandModule.cs new file mode 100644 index 000000000..22c794332 --- /dev/null +++ b/src/Lidarr.Api.V1/Commands/CommandModule.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Common; +using NzbDrone.Common.TPL; +using NzbDrone.Core.Datastore.Events; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.ProgressMessaging; +using NzbDrone.SignalR; +using Lidarr.Http; +using Lidarr.Http.Extensions; +using Lidarr.Http.Validation; + +namespace Lidarr.Api.V1.Commands +{ + public class CommandModule : LidarrRestModuleWithSignalR, IHandle + { + private readonly IManageCommandQueue _commandQueueManager; + private readonly IServiceFactory _serviceFactory; + private readonly Debouncer _debouncer; + private readonly Dictionary _pendingUpdates; + + public CommandModule(IManageCommandQueue commandQueueManager, + IBroadcastSignalRMessage signalRBroadcaster, + IServiceFactory serviceFactory) + : base(signalRBroadcaster) + { + _commandQueueManager = commandQueueManager; + _serviceFactory = serviceFactory; + + GetResourceById = GetCommand; + CreateResource = StartCommand; + GetResourceAll = GetStartedCommands; + DeleteResource = CancelCommand; + + PostValidator.RuleFor(c => c.Name).NotBlank(); + + _debouncer = new Debouncer(SendUpdates, TimeSpan.FromSeconds(0.1)); + _pendingUpdates = new Dictionary(); + } + + private CommandResource GetCommand(int id) + { + return _commandQueueManager.Get(id).ToResource(); + } + + private int StartCommand(CommandResource commandResource) + { + var commandType = + _serviceFactory.GetImplementations(typeof (Command)) + .Single(c => c.Name.Replace("Command", "") + .Equals(commandResource.Name, StringComparison.InvariantCultureIgnoreCase)); + + dynamic command = Request.Body.FromJson(commandType); + command.Trigger = CommandTrigger.Manual; + command.SuppressMessages = !command.SendUpdatesToClient; + command.SendUpdatesToClient = true; + + var trackedCommand = _commandQueueManager.Push(command, CommandPriority.Normal, CommandTrigger.Manual); + return trackedCommand.Id; + } + + private List GetStartedCommands() + { + return _commandQueueManager.All().ToResource(); + } + + private void CancelCommand(int id) + { + _commandQueueManager.Cancel(id); + } + + public void Handle(CommandUpdatedEvent message) + { + if (message.Command.Body.SendUpdatesToClient) + { + 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); + + if (pendingUpdate.Name == typeof(MessagingCleanupCommand).Name.Replace("Command", "") && + pendingUpdate.Status == CommandStatus.Completed) + { + BroadcastResourceChange(ModelAction.Sync); + } + } + } + } + } +} diff --git a/src/NzbDrone.Api/Commands/CommandResource.cs b/src/Lidarr.Api.V1/Commands/CommandResource.cs similarity index 75% rename from src/NzbDrone.Api/Commands/CommandResource.cs rename to src/Lidarr.Api.V1/Commands/CommandResource.cs index cf09f12ac..dff2bf782 100644 --- a/src/NzbDrone.Api/Commands/CommandResource.cs +++ b/src/Lidarr.Api.V1/Commands/CommandResource.cs @@ -1,17 +1,19 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; -using NzbDrone.Api.REST; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Messaging.Commands; +using Lidarr.Http.REST; -namespace NzbDrone.Api.Commands +namespace Lidarr.Api.V1.Commands { public class CommandResource : RestResource { public string Name { get; set; } + public string CommandName { get; set; } public string Message { get; set; } - public object Body { get; set; } + public Command Body { get; set; } public CommandPriority Priority { get; set; } public CommandStatus Status { get; set; } public DateTime Queued { get; set; } @@ -24,37 +26,6 @@ namespace NzbDrone.Api.Commands [JsonIgnore] public string CompletionMessage { get; set; } - //Legacy - public CommandStatus State - { - get - { - return Status; - } - - set { } - } - - public bool Manual - { - get - { - return Trigger == CommandTrigger.Manual; - } - - set { } - } - - public DateTime StartedOn - { - get - { - return Queued; - } - - set { } - } - public DateTime? StateChangeTime { get @@ -72,7 +43,7 @@ namespace NzbDrone.Api.Commands { get { - if (Body != null) return (Body as Command).SendUpdatesToClient; + if (Body != null) return Body.SendUpdatesToClient; return false; } @@ -84,7 +55,7 @@ namespace NzbDrone.Api.Commands { get { - if (Body != null) return (Body as Command).UpdateScheduledTask; + if (Body != null) return Body.UpdateScheduledTask; return false; } @@ -106,6 +77,7 @@ namespace NzbDrone.Api.Commands Id = model.Id, Name = model.Name, + CommandName = model.Name.SplitCamelCase(), Message = model.Message, Body = model.Body, Priority = model.Priority, diff --git a/src/Lidarr.Api.V1/Config/DownloadClientConfigModule.cs b/src/Lidarr.Api.V1/Config/DownloadClientConfigModule.cs new file mode 100644 index 000000000..38df2d62a --- /dev/null +++ b/src/Lidarr.Api.V1/Config/DownloadClientConfigModule.cs @@ -0,0 +1,17 @@ +using NzbDrone.Core.Configuration; + +namespace Lidarr.Api.V1.Config +{ + public class DownloadClientConfigModule : LidarrConfigModule + { + public DownloadClientConfigModule(IConfigService configService) + : base(configService) + { + } + + protected override DownloadClientConfigResource ToResource(IConfigService model) + { + return DownloadClientConfigResourceMapper.ToResource(model); + } + } +} diff --git a/src/Lidarr.Api.V1/Config/DownloadClientConfigResource.cs b/src/Lidarr.Api.V1/Config/DownloadClientConfigResource.cs new file mode 100644 index 000000000..50bdaefc9 --- /dev/null +++ b/src/Lidarr.Api.V1/Config/DownloadClientConfigResource.cs @@ -0,0 +1,33 @@ +using NzbDrone.Core.Configuration; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.Config +{ + public class DownloadClientConfigResource : RestResource + { + public string DownloadClientWorkingFolders { get; set; } + + public bool EnableCompletedDownloadHandling { get; set; } + public bool RemoveCompletedDownloads { get; set; } + + public bool AutoRedownloadFailed { get; set; } + public bool RemoveFailedDownloads { get; set; } + } + + public static class DownloadClientConfigResourceMapper + { + public static DownloadClientConfigResource ToResource(IConfigService model) + { + return new DownloadClientConfigResource + { + DownloadClientWorkingFolders = model.DownloadClientWorkingFolders, + + EnableCompletedDownloadHandling = model.EnableCompletedDownloadHandling, + RemoveCompletedDownloads = model.RemoveCompletedDownloads, + + AutoRedownloadFailed = model.AutoRedownloadFailed, + RemoveFailedDownloads = model.RemoveFailedDownloads + }; + } + } +} diff --git a/src/NzbDrone.Api/Config/HostConfigModule.cs b/src/Lidarr.Api.V1/Config/HostConfigModule.cs similarity index 85% rename from src/NzbDrone.Api/Config/HostConfigModule.cs rename to src/Lidarr.Api.V1/Config/HostConfigModule.cs index 367bf770d..05cdf7b89 100644 --- a/src/NzbDrone.Api/Config/HostConfigModule.cs +++ b/src/Lidarr.Api.V1/Config/HostConfigModule.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System.IO; +using System.Linq; using System.Reflection; using FluentValidation; using NzbDrone.Common.EnvironmentInfo; @@ -8,10 +9,11 @@ using NzbDrone.Core.Configuration; using NzbDrone.Core.Update; using NzbDrone.Core.Validation; using NzbDrone.Core.Validation.Paths; +using Lidarr.Http; -namespace NzbDrone.Api.Config +namespace Lidarr.Api.V1.Config { - public class HostConfigModule : NzbDroneRestModule + public class HostConfigModule : LidarrRestModule { private readonly IConfigFileProvider _configFileProvider; private readonly IConfigService _configService; @@ -41,10 +43,16 @@ namespace NzbDrone.Api.Config SharedValidator.RuleFor(c => c.Password).NotEmpty().When(c => c.AuthenticationMethod != AuthenticationType.None); SharedValidator.RuleFor(c => c.SslPort).ValidPort().When(c => c.EnableSsl); + SharedValidator.RuleFor(c => c.SslPort).NotEqual(c => c.Port).When(c => c.EnableSsl); SharedValidator.RuleFor(c => c.SslCertHash).NotEmpty().When(c => c.EnableSsl && OsInfo.IsWindows); SharedValidator.RuleFor(c => c.Branch).NotEmpty().WithMessage("Branch name is required, 'master' is the default"); SharedValidator.RuleFor(c => c.UpdateScriptPath).IsValidPath().When(c => c.UpdateMechanism == UpdateMechanism.Script); + + SharedValidator.RuleFor(c => c.BackupFolder).IsValidPath().When(c => Path.IsPathRooted(c.BackupFolder)); + SharedValidator.RuleFor(c => c.BackupInterval).InclusiveBetween(1, 7); + SharedValidator.RuleFor(c => c.BackupRetention).InclusiveBetween(1, 90); + } private HostConfigResource GetHostConfig() diff --git a/src/NzbDrone.Api/Config/HostConfigResource.cs b/src/Lidarr.Api.V1/Config/HostConfigResource.cs similarity index 85% rename from src/NzbDrone.Api/Config/HostConfigResource.cs rename to src/Lidarr.Api.V1/Config/HostConfigResource.cs index 930e0301c..bd91ff841 100644 --- a/src/NzbDrone.Api/Config/HostConfigResource.cs +++ b/src/Lidarr.Api.V1/Config/HostConfigResource.cs @@ -1,10 +1,10 @@ -using NzbDrone.Api.REST; +using NzbDrone.Common.Http.Proxy; using NzbDrone.Core.Authentication; using NzbDrone.Core.Configuration; using NzbDrone.Core.Update; -using NzbDrone.Common.Http.Proxy; +using Lidarr.Http.REST; -namespace NzbDrone.Api.Config +namespace Lidarr.Api.V1.Config { public class HostConfigResource : RestResource { @@ -18,6 +18,7 @@ namespace NzbDrone.Api.Config public string Username { get; set; } public string Password { get; set; } public string LogLevel { get; set; } + public string ConsoleLogLevel { get; set; } public string Branch { get; set; } public string ApiKey { get; set; } public string SslCertHash { get; set; } @@ -33,6 +34,9 @@ namespace NzbDrone.Api.Config public string ProxyPassword { get; set; } public string ProxyBypassFilter { get; set; } public bool ProxyBypassLocalAddresses { get; set; } + public string BackupFolder { get; set; } + public int BackupInterval { get; set; } + public int BackupRetention { get; set; } } public static class HostConfigResourceMapper @@ -52,6 +56,7 @@ namespace NzbDrone.Api.Config //Username //Password LogLevel = model.LogLevel, + ConsoleLogLevel = model.ConsoleLogLevel, Branch = model.Branch, ApiKey = model.ApiKey, SslCertHash = model.SslCertHash, @@ -66,7 +71,10 @@ namespace NzbDrone.Api.Config ProxyUsername = configService.ProxyUsername, ProxyPassword = configService.ProxyPassword, ProxyBypassFilter = configService.ProxyBypassFilter, - ProxyBypassLocalAddresses = configService.ProxyBypassLocalAddresses + ProxyBypassLocalAddresses = configService.ProxyBypassLocalAddresses, + BackupFolder = configService.BackupFolder, + BackupInterval = configService.BackupInterval, + BackupRetention = configService.BackupRetention }; } } diff --git a/src/Lidarr.Api.V1/Config/IndexerConfigModule.cs b/src/Lidarr.Api.V1/Config/IndexerConfigModule.cs new file mode 100644 index 000000000..5b21994fb --- /dev/null +++ b/src/Lidarr.Api.V1/Config/IndexerConfigModule.cs @@ -0,0 +1,31 @@ +using FluentValidation; +using NzbDrone.Core.Configuration; +using Lidarr.Http.Validation; + +namespace Lidarr.Api.V1.Config +{ + public class IndexerConfigModule : LidarrConfigModule + { + + public IndexerConfigModule(IConfigService configService) + : base(configService) + { + SharedValidator.RuleFor(c => c.MinimumAge) + .GreaterThanOrEqualTo(0); + + SharedValidator.RuleFor(c => c.MaximumSize) + .GreaterThanOrEqualTo(0); + + SharedValidator.RuleFor(c => c.Retention) + .GreaterThanOrEqualTo(0); + + SharedValidator.RuleFor(c => c.RssSyncInterval) + .IsValidRssSyncInterval(); + } + + protected override IndexerConfigResource ToResource(IConfigService model) + { + return IndexerConfigResourceMapper.ToResource(model); + } + } +} diff --git a/src/NzbDrone.Api/Config/IndexerConfigResource.cs b/src/Lidarr.Api.V1/Config/IndexerConfigResource.cs similarity index 80% rename from src/NzbDrone.Api/Config/IndexerConfigResource.cs rename to src/Lidarr.Api.V1/Config/IndexerConfigResource.cs index 179e28c3f..f354dac64 100644 --- a/src/NzbDrone.Api/Config/IndexerConfigResource.cs +++ b/src/Lidarr.Api.V1/Config/IndexerConfigResource.cs @@ -1,11 +1,12 @@ -using NzbDrone.Api.REST; using NzbDrone.Core.Configuration; +using Lidarr.Http.REST; -namespace NzbDrone.Api.Config +namespace Lidarr.Api.V1.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/Lidarr.Api.V1/Config/LidarrConfigModule.cs b/src/Lidarr.Api.V1/Config/LidarrConfigModule.cs new file mode 100644 index 000000000..220b96198 --- /dev/null +++ b/src/Lidarr.Api.V1/Config/LidarrConfigModule.cs @@ -0,0 +1,52 @@ +using System.Linq; +using System.Reflection; +using NzbDrone.Core.Configuration; +using Lidarr.Http; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.Config +{ + public abstract class LidarrConfigModule : LidarrRestModule where TResource : RestResource, new() + { + private readonly IConfigService _configService; + + protected LidarrConfigModule(IConfigService configService) + : this(new TResource().ResourceName.Replace("config", ""), configService) + { + } + + protected LidarrConfigModule(string resource, IConfigService configService) : + base("config/" + resource.Trim('/')) + { + _configService = configService; + + GetResourceSingle = GetConfig; + GetResourceById = GetConfig; + UpdateResource = SaveConfig; + } + + private TResource GetConfig() + { + var resource = ToResource(_configService); + resource.Id = 1; + + return resource; + } + + protected abstract TResource ToResource(IConfigService model); + + private TResource GetConfig(int id) + { + return GetConfig(); + } + + private void SaveConfig(TResource resource) + { + var dictionary = resource.GetType() + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .ToDictionary(prop => prop.Name, prop => prop.GetValue(resource, null)); + + _configService.SaveConfigDictionary(dictionary); + } + } +} diff --git a/src/NzbDrone.Api/Config/MediaManagementConfigModule.cs b/src/Lidarr.Api.V1/Config/MediaManagementConfigModule.cs similarity index 77% rename from src/NzbDrone.Api/Config/MediaManagementConfigModule.cs rename to src/Lidarr.Api.V1/Config/MediaManagementConfigModule.cs index 8b35e53ed..e83767548 100644 --- a/src/NzbDrone.Api/Config/MediaManagementConfigModule.cs +++ b/src/Lidarr.Api.V1/Config/MediaManagementConfigModule.cs @@ -2,13 +2,14 @@ using NzbDrone.Core.Configuration; using NzbDrone.Core.Validation.Paths; -namespace NzbDrone.Api.Config +namespace Lidarr.Api.V1.Config { - public class MediaManagementConfigModule : NzbDroneConfigModule + public class MediaManagementConfigModule : LidarrConfigModule { public MediaManagementConfigModule(IConfigService configService, PathExistsValidator pathExistsValidator) : base(configService) { + SharedValidator.RuleFor(c => c.RecycleBinCleanupDays).GreaterThanOrEqualTo(0); SharedValidator.RuleFor(c => c.FileChmod).NotEmpty(); SharedValidator.RuleFor(c => c.FolderChmod).NotEmpty(); SharedValidator.RuleFor(c => c.RecycleBin).IsValidPath().SetValidator(pathExistsValidator).When(c => !string.IsNullOrWhiteSpace(c.RecycleBin)); diff --git a/src/Lidarr.Api.V1/Config/MediaManagementConfigResource.cs b/src/Lidarr.Api.V1/Config/MediaManagementConfigResource.cs new file mode 100644 index 000000000..b2abd60d5 --- /dev/null +++ b/src/Lidarr.Api.V1/Config/MediaManagementConfigResource.cs @@ -0,0 +1,61 @@ +using NzbDrone.Core.Configuration; +using NzbDrone.Core.MediaFiles; +using Lidarr.Http.REST; +using NzbDrone.Core.Qualities; + +namespace Lidarr.Api.V1.Config +{ + public class MediaManagementConfigResource : RestResource + { + public bool AutoUnmonitorPreviouslyDownloadedTracks { get; set; } + public string RecycleBin { get; set; } + public int RecycleBinCleanupDays { get; set; } + public ProperDownloadTypes DownloadPropersAndRepacks { get; set; } + public bool CreateEmptyArtistFolders { get; set; } + public bool DeleteEmptyFolders { get; set; } + public FileDateType FileDate { get; set; } + public RescanAfterRefreshType RescanAfterRefresh { get; set; } + public AllowFingerprinting AllowFingerprinting { get; set; } + + public bool SetPermissionsLinux { get; set; } + public string FileChmod { get; set; } + public string FolderChmod { get; set; } + public string ChownUser { get; set; } + public string ChownGroup { get; set; } + + public bool SkipFreeSpaceCheckWhenImporting { get; set; } + public bool CopyUsingHardlinks { get; set; } + public bool ImportExtraFiles { get; set; } + public string ExtraFileExtensions { get; set; } + } + + public static class MediaManagementConfigResourceMapper + { + public static MediaManagementConfigResource ToResource(IConfigService model) + { + return new MediaManagementConfigResource + { + AutoUnmonitorPreviouslyDownloadedTracks = model.AutoUnmonitorPreviouslyDownloadedTracks, + RecycleBin = model.RecycleBin, + RecycleBinCleanupDays = model.RecycleBinCleanupDays, + DownloadPropersAndRepacks = model.DownloadPropersAndRepacks, + CreateEmptyArtistFolders = model.CreateEmptyArtistFolders, + DeleteEmptyFolders = model.DeleteEmptyFolders, + FileDate = model.FileDate, + RescanAfterRefresh = model.RescanAfterRefresh, + AllowFingerprinting = model.AllowFingerprinting, + + SetPermissionsLinux = model.SetPermissionsLinux, + FileChmod = model.FileChmod, + FolderChmod = model.FolderChmod, + ChownUser = model.ChownUser, + ChownGroup = model.ChownGroup, + + SkipFreeSpaceCheckWhenImporting = model.SkipFreeSpaceCheckWhenImporting, + CopyUsingHardlinks = model.CopyUsingHardlinks, + ImportExtraFiles = model.ImportExtraFiles, + ExtraFileExtensions = model.ExtraFileExtensions, + }; + } + } +} diff --git a/src/Lidarr.Api.V1/Config/MetadataProviderConfigModule.cs b/src/Lidarr.Api.V1/Config/MetadataProviderConfigModule.cs new file mode 100644 index 000000000..5286c87c5 --- /dev/null +++ b/src/Lidarr.Api.V1/Config/MetadataProviderConfigModule.cs @@ -0,0 +1,25 @@ +using System; +using System.Linq; +using System.Reflection; +using FluentValidation; +using NzbDrone.Core.Configuration; +using Lidarr.Http; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Validation; + +namespace Lidarr.Api.V1.Config +{ + public class MetadataProviderConfigModule : LidarrConfigModule + { + public MetadataProviderConfigModule(IConfigService configService) + : base(configService) + { + SharedValidator.RuleFor(c => c.MetadataSource).IsValidUrl().When(c => !c.MetadataSource.IsNullOrWhiteSpace()); + } + + protected override MetadataProviderConfigResource ToResource(IConfigService model) + { + return MetadataProviderConfigResourceMapper.ToResource(model); + } + } +} diff --git a/src/Lidarr.Api.V1/Config/MetadataProviderConfigResource.cs b/src/Lidarr.Api.V1/Config/MetadataProviderConfigResource.cs new file mode 100644 index 000000000..3e356eedb --- /dev/null +++ b/src/Lidarr.Api.V1/Config/MetadataProviderConfigResource.cs @@ -0,0 +1,25 @@ +using NzbDrone.Core.Configuration; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.Config +{ + public class MetadataProviderConfigResource : RestResource + { + public string MetadataSource { get; set; } + public WriteAudioTagsType WriteAudioTags { get; set; } + public bool ScrubAudioTags { get; set; } + } + + public static class MetadataProviderConfigResourceMapper + { + public static MetadataProviderConfigResource ToResource(IConfigService model) + { + return new MetadataProviderConfigResource + { + MetadataSource = model.MetadataSource, + WriteAudioTags = model.WriteAudioTags, + ScrubAudioTags = model.ScrubAudioTags, + }; + } + } +} diff --git a/src/Lidarr.Api.V1/Config/NamingConfigModule.cs b/src/Lidarr.Api.V1/Config/NamingConfigModule.cs new file mode 100644 index 000000000..c2ba93c5c --- /dev/null +++ b/src/Lidarr.Api.V1/Config/NamingConfigModule.cs @@ -0,0 +1,126 @@ +using System.Collections.Generic; +using System.Linq; +using FluentValidation; +using FluentValidation.Results; +using Nancy.ModelBinding; +using Nancy.Responses; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Organizer; +using Lidarr.Http; +using Lidarr.Http.Extensions; +using Lidarr.Http.Mapping; + +namespace Lidarr.Api.V1.Config +{ + public class NamingConfigModule : LidarrRestModule + { + private readonly INamingConfigService _namingConfigService; + private readonly IFilenameSampleService _filenameSampleService; + private readonly IFilenameValidationService _filenameValidationService; + private readonly IBuildFileNames _filenameBuilder; + + public NamingConfigModule(INamingConfigService namingConfigService, + IFilenameSampleService filenameSampleService, + IFilenameValidationService filenameValidationService, + IBuildFileNames filenameBuilder) + : base("config/naming") + { + _namingConfigService = namingConfigService; + _filenameSampleService = filenameSampleService; + _filenameValidationService = filenameValidationService; + _filenameBuilder = filenameBuilder; + GetResourceSingle = GetNamingConfig; + GetResourceById = GetNamingConfig; + UpdateResource = UpdateNamingConfig; + + Get["/examples"] = x => GetExamples(this.Bind()); + + + SharedValidator.RuleFor(c => c.StandardTrackFormat).ValidTrackFormat(); + SharedValidator.RuleFor(c => c.MultiDiscTrackFormat).ValidTrackFormat(); + SharedValidator.RuleFor(c => c.ArtistFolderFormat).ValidArtistFolderFormat(); + SharedValidator.RuleFor(c => c.AlbumFolderFormat).ValidAlbumFolderFormat(); + } + + private void UpdateNamingConfig(NamingConfigResource resource) + { + var nameSpec = resource.ToModel(); + ValidateFormatResult(nameSpec); + + _namingConfigService.Save(nameSpec); + } + + private NamingConfigResource GetNamingConfig() + { + var nameSpec = _namingConfigService.GetConfig(); + var resource = nameSpec.ToResource(); + + if (resource.StandardTrackFormat.IsNotNullOrWhiteSpace()) + { + var basicConfig = _filenameBuilder.GetBasicNamingConfig(nameSpec); + basicConfig.AddToResource(resource); + } + + if (resource.MultiDiscTrackFormat.IsNotNullOrWhiteSpace()) + { + var basicConfig = _filenameBuilder.GetBasicNamingConfig(nameSpec); + basicConfig.AddToResource(resource); + } + + return resource; + } + + private NamingConfigResource GetNamingConfig(int id) + { + return GetNamingConfig(); + } + + private JsonResponse GetExamples(NamingConfigResource config) + { + if (config.Id == 0) + { + config = GetNamingConfig(); + } + + var nameSpec = config.ToModel(); + var sampleResource = new NamingExampleResource(); + + var singleTrackSampleResult = _filenameSampleService.GetStandardTrackSample(nameSpec); + var multiDiscTrackSampleResult = _filenameSampleService.GetMultiDiscTrackSample(nameSpec); + + sampleResource.SingleTrackExample = _filenameValidationService.ValidateTrackFilename(singleTrackSampleResult) != null + ? null + : singleTrackSampleResult.FileName; + + sampleResource.MultiDiscTrackExample = _filenameValidationService.ValidateTrackFilename(multiDiscTrackSampleResult) != null + ? null + : multiDiscTrackSampleResult.FileName; + + sampleResource.ArtistFolderExample = nameSpec.ArtistFolderFormat.IsNullOrWhiteSpace() + ? null + : _filenameSampleService.GetArtistFolderSample(nameSpec); + + sampleResource.AlbumFolderExample = nameSpec.AlbumFolderFormat.IsNullOrWhiteSpace() + ? null + : _filenameSampleService.GetAlbumFolderSample(nameSpec); + + return sampleResource.AsResponse(); + } + + private void ValidateFormatResult(NamingConfig nameSpec) + { + var singleTrackSampleResult = _filenameSampleService.GetStandardTrackSample(nameSpec); + + var singleTrackValidationResult = _filenameValidationService.ValidateTrackFilename(singleTrackSampleResult); + + var validationFailures = new List(); + + validationFailures.AddIfNotNull(singleTrackValidationResult); + + if (validationFailures.Any()) + { + throw new ValidationException(validationFailures.DistinctBy(v => v.PropertyName).ToArray()); + } + } + } +} diff --git a/src/Lidarr.Api.V1/Config/NamingConfigResource.cs b/src/Lidarr.Api.V1/Config/NamingConfigResource.cs new file mode 100644 index 000000000..ee39b9040 --- /dev/null +++ b/src/Lidarr.Api.V1/Config/NamingConfigResource.cs @@ -0,0 +1,20 @@ +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.Config +{ + public class NamingConfigResource : RestResource + { + public bool RenameTracks { get; set; } + public bool ReplaceIllegalCharacters { get; set; } + public string StandardTrackFormat { get; set; } + public string MultiDiscTrackFormat { get; set; } + public string ArtistFolderFormat { get; set; } + public string AlbumFolderFormat { get; set; } + public bool IncludeArtistName { get; set; } + public bool IncludeAlbumTitle { get; set; } + public bool IncludeQuality { get; set; } + public bool ReplaceSpaces { get; set; } + public string Separator { get; set; } + public string NumberStyle { get; set; } + } +} diff --git a/src/Lidarr.Api.V1/Config/NamingExampleResource.cs b/src/Lidarr.Api.V1/Config/NamingExampleResource.cs new file mode 100644 index 000000000..7a12db6ea --- /dev/null +++ b/src/Lidarr.Api.V1/Config/NamingExampleResource.cs @@ -0,0 +1,56 @@ +using NzbDrone.Core.Organizer; + +namespace Lidarr.Api.V1.Config +{ + public class NamingExampleResource + { + public string SingleTrackExample { get; set; } + public string MultiDiscTrackExample { get; set; } + public string ArtistFolderExample { get; set; } + public string AlbumFolderExample { get; set; } + } + + public static class NamingConfigResourceMapper + { + public static NamingConfigResource ToResource(this NamingConfig model) + { + return new NamingConfigResource + { + Id = model.Id, + + RenameTracks = model.RenameTracks, + ReplaceIllegalCharacters = model.ReplaceIllegalCharacters, + StandardTrackFormat = model.StandardTrackFormat, + MultiDiscTrackFormat = model.MultiDiscTrackFormat, + ArtistFolderFormat = model.ArtistFolderFormat, + AlbumFolderFormat = model.AlbumFolderFormat + }; + } + + public static void AddToResource(this BasicNamingConfig basicNamingConfig, NamingConfigResource resource) + { + resource.IncludeArtistName = basicNamingConfig.IncludeArtistName; + resource.IncludeAlbumTitle = basicNamingConfig.IncludeAlbumTitle; + resource.IncludeQuality = basicNamingConfig.IncludeQuality; + resource.ReplaceSpaces = basicNamingConfig.ReplaceSpaces; + resource.Separator = basicNamingConfig.Separator; + resource.NumberStyle = basicNamingConfig.NumberStyle; + } + + public static NamingConfig ToModel(this NamingConfigResource resource) + { + return new NamingConfig + { + Id = resource.Id, + + RenameTracks = resource.RenameTracks, + ReplaceIllegalCharacters = resource.ReplaceIllegalCharacters, + StandardTrackFormat = resource.StandardTrackFormat, + MultiDiscTrackFormat = resource.MultiDiscTrackFormat, + + ArtistFolderFormat = resource.ArtistFolderFormat, + AlbumFolderFormat = resource.AlbumFolderFormat + }; + } + } +} diff --git a/src/Lidarr.Api.V1/Config/UiConfigModule.cs b/src/Lidarr.Api.V1/Config/UiConfigModule.cs new file mode 100644 index 000000000..4e7910974 --- /dev/null +++ b/src/Lidarr.Api.V1/Config/UiConfigModule.cs @@ -0,0 +1,21 @@ +using System.Linq; +using System.Reflection; +using NzbDrone.Core.Configuration; +using Lidarr.Http; + +namespace Lidarr.Api.V1.Config +{ + public class UiConfigModule : LidarrConfigModule + { + public UiConfigModule(IConfigService configService) + : base(configService) + { + + } + + protected override UiConfigResource ToResource(IConfigService model) + { + return UiConfigResourceMapper.ToResource(model); + } + } +} \ No newline at end of file diff --git a/src/Lidarr.Api.V1/Config/UiConfigResource.cs b/src/Lidarr.Api.V1/Config/UiConfigResource.cs new file mode 100644 index 000000000..e04ab7c6a --- /dev/null +++ b/src/Lidarr.Api.V1/Config/UiConfigResource.cs @@ -0,0 +1,51 @@ +using NzbDrone.Core.Configuration; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.Config +{ + public class UiConfigResource : RestResource + { + //Calendar + public int FirstDayOfWeek { get; set; } + public string CalendarWeekColumnHeader { get; set; } + + //Dates + public string ShortDateFormat { get; set; } + public string LongDateFormat { get; set; } + public string TimeFormat { get; set; } + public bool ShowRelativeDates { get; set; } + + public bool EnableColorImpairedMode { get; set; } + + public bool ExpandAlbumByDefault { get; set; } + public bool ExpandSingleByDefault { get; set; } + public bool ExpandEPByDefault { get; set; } + public bool ExpandBroadcastByDefault { get; set; } + public bool ExpandOtherByDefault { get; set; } + } + + public static class UiConfigResourceMapper + { + public static UiConfigResource ToResource(IConfigService model) + { + return new UiConfigResource + { + FirstDayOfWeek = model.FirstDayOfWeek, + CalendarWeekColumnHeader = model.CalendarWeekColumnHeader, + + ShortDateFormat = model.ShortDateFormat, + LongDateFormat = model.LongDateFormat, + TimeFormat = model.TimeFormat, + ShowRelativeDates = model.ShowRelativeDates, + + EnableColorImpairedMode = model.EnableColorImpairedMode, + + ExpandAlbumByDefault = model.ExpandAlbumByDefault, + ExpandSingleByDefault = model.ExpandSingleByDefault, + ExpandEPByDefault = model.ExpandEPByDefault, + ExpandBroadcastByDefault = model.ExpandBroadcastByDefault, + ExpandOtherByDefault = model.ExpandOtherByDefault + }; + } + } +} diff --git a/src/Lidarr.Api.V1/CustomFilters/CustomFilterModule.cs b/src/Lidarr.Api.V1/CustomFilters/CustomFilterModule.cs new file mode 100644 index 000000000..9620ef48b --- /dev/null +++ b/src/Lidarr.Api.V1/CustomFilters/CustomFilterModule.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using NzbDrone.Core.CustomFilters; +using Lidarr.Http; + +namespace Lidarr.Api.V1.CustomFilters +{ + public class CustomFilterModule : LidarrRestModule + { + private readonly ICustomFilterService _customFilterService; + + public CustomFilterModule(ICustomFilterService customFilterService) + { + _customFilterService = customFilterService; + + GetResourceById = GetCustomFilter; + GetResourceAll = GetCustomFilters; + CreateResource = AddCustomFilter; + UpdateResource = UpdateCustomFilter; + DeleteResource = DeleteCustomResource; + } + + private CustomFilterResource GetCustomFilter(int id) + { + return _customFilterService.Get(id).ToResource(); + } + + private List GetCustomFilters() + { + return _customFilterService.All().ToResource(); + } + + private int AddCustomFilter(CustomFilterResource resource) + { + var customFilter = _customFilterService.Add(resource.ToModel()); + + return customFilter.Id; + } + + private void UpdateCustomFilter(CustomFilterResource resource) + { + _customFilterService.Update(resource.ToModel()); + } + + private void DeleteCustomResource(int id) + { + _customFilterService.Delete(id); + } + } +} diff --git a/src/Lidarr.Api.V1/CustomFilters/CustomFilterResource.cs b/src/Lidarr.Api.V1/CustomFilters/CustomFilterResource.cs new file mode 100644 index 000000000..ee3cc9ea0 --- /dev/null +++ b/src/Lidarr.Api.V1/CustomFilters/CustomFilterResource.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.CustomFilters; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.CustomFilters +{ + public class CustomFilterResource : RestResource + { + public string Type { get; set; } + public string Label { get; set; } + public List Filters { get; set; } + } + + public static class CustomFilterResourceMapper + { + public static CustomFilterResource ToResource(this CustomFilter model) + { + if (model == null) return null; + + return new CustomFilterResource + { + Id = model.Id, + Type = model.Type, + Label = model.Label, + Filters = Json.Deserialize>(model.Filters) + }; + } + + public static CustomFilter ToModel(this CustomFilterResource resource) + { + if (resource == null) return null; + + return new CustomFilter + { + Id = resource.Id, + Type = resource.Type, + Label = resource.Label, + Filters = Json.ToJson(resource.Filters) + }; + } + + public static List ToResource(this IEnumerable filters) + { + return filters.Select(ToResource).ToList(); + } + } +} diff --git a/src/NzbDrone.Api/DiskSpace/DiskSpaceModule.cs b/src/Lidarr.Api.V1/DiskSpace/DiskSpaceModule.cs similarity index 76% rename from src/NzbDrone.Api/DiskSpace/DiskSpaceModule.cs rename to src/Lidarr.Api.V1/DiskSpace/DiskSpaceModule.cs index f6d8354b4..7d57cefd1 100644 --- a/src/NzbDrone.Api/DiskSpace/DiskSpaceModule.cs +++ b/src/Lidarr.Api.V1/DiskSpace/DiskSpaceModule.cs @@ -1,20 +1,20 @@ using System.Collections.Generic; using NzbDrone.Core.DiskSpace; +using Lidarr.Http; -namespace NzbDrone.Api.DiskSpace +namespace Lidarr.Api.V1.DiskSpace { - public class DiskSpaceModule :NzbDroneRestModule + public class DiskSpaceModule :LidarrRestModule { private readonly IDiskSpaceService _diskSpaceService; public DiskSpaceModule(IDiskSpaceService diskSpaceService) - : base("diskspace") + :base("diskspace") { _diskSpaceService = diskSpaceService; GetResourceAll = GetFreeSpace; } - public List GetFreeSpace() { return _diskSpaceService.GetFreeSpace().ConvertAll(DiskSpaceResourceMapper.MapToResource); diff --git a/src/NzbDrone.Api/DiskSpace/DiskSpaceResource.cs b/src/Lidarr.Api.V1/DiskSpace/DiskSpaceResource.cs similarity index 78% rename from src/NzbDrone.Api/DiskSpace/DiskSpaceResource.cs rename to src/Lidarr.Api.V1/DiskSpace/DiskSpaceResource.cs index fc36f9d5c..9adcfaf50 100644 --- a/src/NzbDrone.Api/DiskSpace/DiskSpaceResource.cs +++ b/src/Lidarr.Api.V1/DiskSpace/DiskSpaceResource.cs @@ -1,6 +1,6 @@ -using NzbDrone.Api.REST; +using Lidarr.Http.REST; -namespace NzbDrone.Api.DiskSpace +namespace Lidarr.Api.V1.DiskSpace { public class DiskSpaceResource : RestResource { @@ -12,7 +12,7 @@ namespace NzbDrone.Api.DiskSpace public static class DiskSpaceResourceMapper { - public static DiskSpaceResource MapToResource(this Core.DiskSpace.DiskSpace model) + public static DiskSpaceResource MapToResource(this NzbDrone.Core.DiskSpace.DiskSpace model) { if (model == null) return null; diff --git a/src/Lidarr.Api.V1/DownloadClient/DownloadClientModule.cs b/src/Lidarr.Api.V1/DownloadClient/DownloadClientModule.cs new file mode 100644 index 000000000..789932dfd --- /dev/null +++ b/src/Lidarr.Api.V1/DownloadClient/DownloadClientModule.cs @@ -0,0 +1,20 @@ +using NzbDrone.Core.Download; + +namespace Lidarr.Api.V1.DownloadClient +{ + public class DownloadClientModule : ProviderModuleBase + { + public static readonly DownloadClientResourceMapper ResourceMapper = new DownloadClientResourceMapper(); + + public DownloadClientModule(IDownloadClientFactory downloadClientFactory) + : base(downloadClientFactory, "downloadclient", ResourceMapper) + { + } + + protected override void Validate(DownloadClientDefinition definition, bool includeWarnings) + { + if (!definition.Enable) return; + base.Validate(definition, includeWarnings); + } + } +} \ No newline at end of file diff --git a/src/Lidarr.Api.V1/DownloadClient/DownloadClientResource.cs b/src/Lidarr.Api.V1/DownloadClient/DownloadClientResource.cs new file mode 100644 index 000000000..bab6faa84 --- /dev/null +++ b/src/Lidarr.Api.V1/DownloadClient/DownloadClientResource.cs @@ -0,0 +1,38 @@ +using NzbDrone.Core.Download; +using NzbDrone.Core.Indexers; + +namespace Lidarr.Api.V1.DownloadClient +{ + public class DownloadClientResource : ProviderResource + { + public bool Enable { get; set; } + public DownloadProtocol Protocol { get; set; } + } + + public class DownloadClientResourceMapper : ProviderResourceMapper + { + public override DownloadClientResource ToResource(DownloadClientDefinition definition) + { + if (definition == null) return null; + + var resource = base.ToResource(definition); + + resource.Enable = definition.Enable; + resource.Protocol = definition.Protocol; + + return resource; + } + + public override DownloadClientDefinition ToModel(DownloadClientResource resource) + { + if (resource == null) return null; + + var definition = base.ToModel(resource); + + definition.Enable = resource.Enable; + definition.Protocol = resource.Protocol; + + return definition; + } + } +} \ No newline at end of file diff --git a/src/Lidarr.Api.V1/FileSystem/FileSystemModule.cs b/src/Lidarr.Api.V1/FileSystem/FileSystemModule.cs new file mode 100644 index 000000000..fe368b671 --- /dev/null +++ b/src/Lidarr.Api.V1/FileSystem/FileSystemModule.cs @@ -0,0 +1,72 @@ +using System; +using System.IO; +using System.IO.Abstractions; +using System.Linq; +using Nancy; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.MediaFiles; +using Lidarr.Http.Extensions; + +namespace Lidarr.Api.V1.FileSystem +{ + public class FileSystemModule : LidarrV1Module + { + private readonly IFileSystemLookupService _fileSystemLookupService; + private readonly IDiskProvider _diskProvider; + private readonly IDiskScanService _diskScanService; + + public FileSystemModule(IFileSystemLookupService fileSystemLookupService, + IDiskProvider diskProvider, + IDiskScanService diskScanService) + : base("/filesystem") + { + _fileSystemLookupService = fileSystemLookupService; + _diskProvider = diskProvider; + _diskScanService = diskScanService; + Get["/"] = x => GetContents(); + Get["/type"] = x => GetEntityType(); + Get["/mediafiles"] = x => GetMediaFiles(); + } + + private Response GetContents() + { + var pathQuery = Request.Query.path; + var includeFiles = Request.GetBooleanQueryParameter("includeFiles"); + var allowFoldersWithoutTrailingSlashes = Request.GetBooleanQueryParameter("allowFoldersWithoutTrailingSlashes"); + + return _fileSystemLookupService.LookupContents((string)pathQuery.Value, includeFiles, allowFoldersWithoutTrailingSlashes).AsResponse(); + } + + private Response GetEntityType() + { + var pathQuery = Request.Query.path; + var path = (string)pathQuery.Value; + + if (_diskProvider.FileExists(path)) + { + return new { type = "file" }.AsResponse(); + } + + //Return folder even if it doesn't exist on disk to avoid leaking anything from the UI about the underlying system + return new { type = "folder" }.AsResponse(); + } + + private Response GetMediaFiles() + { + var pathQuery = Request.Query.path; + var path = (string)pathQuery.Value; + + if (!_diskProvider.FolderExists(path)) + { + return new string[0].AsResponse(); + } + + return _diskScanService.GetAudioFiles(path).Select(f => new { + Path = f.FullName, + RelativePath = path.GetRelativePath(f.FullName), + Name = f.Name + }).AsResponse(); + } + } +} diff --git a/src/NzbDrone.Api/Health/HealthModule.cs b/src/Lidarr.Api.V1/Health/HealthModule.cs similarity index 85% rename from src/NzbDrone.Api/Health/HealthModule.cs rename to src/Lidarr.Api.V1/Health/HealthModule.cs index 2699fa7d6..6fa7f2efd 100644 --- a/src/NzbDrone.Api/Health/HealthModule.cs +++ b/src/Lidarr.Api.V1/Health/HealthModule.cs @@ -3,10 +3,11 @@ using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.HealthCheck; using NzbDrone.Core.Messaging.Events; using NzbDrone.SignalR; +using Lidarr.Http; -namespace NzbDrone.Api.Health +namespace Lidarr.Api.V1.Health { - public class HealthModule : NzbDroneRestModuleWithSignalR, + public class HealthModule : LidarrRestModuleWithSignalR, IHandle { private readonly IHealthCheckService _healthCheckService; diff --git a/src/NzbDrone.Api/Health/HealthResource.cs b/src/Lidarr.Api.V1/Health/HealthResource.cs similarity index 86% rename from src/NzbDrone.Api/Health/HealthResource.cs rename to src/Lidarr.Api.V1/Health/HealthResource.cs index e860cb778..b7f176630 100644 --- a/src/NzbDrone.Api/Health/HealthResource.cs +++ b/src/Lidarr.Api.V1/Health/HealthResource.cs @@ -1,13 +1,14 @@ using System.Collections.Generic; using System.Linq; -using NzbDrone.Api.REST; using NzbDrone.Common.Http; using NzbDrone.Core.HealthCheck; +using Lidarr.Http.REST; -namespace NzbDrone.Api.Health +namespace Lidarr.Api.V1.Health { public class HealthResource : RestResource { + public string Source { get; set; } public HealthCheckResult Type { get; set; } public string Message { get; set; } public HttpUri WikiUrl { get; set; } @@ -22,7 +23,7 @@ namespace NzbDrone.Api.Health return new HealthResource { Id = model.Id, - + Source = model.Source.Name, Type = model.Type, Message = model.Message, WikiUrl = model.WikiUrl diff --git a/src/Lidarr.Api.V1/History/HistoryModule.cs b/src/Lidarr.Api.V1/History/HistoryModule.cs new file mode 100644 index 000000000..27c951906 --- /dev/null +++ b/src/Lidarr.Api.V1/History/HistoryModule.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Nancy; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Download; +using NzbDrone.Core.History; +using Lidarr.Api.V1.Albums; +using Lidarr.Api.V1.Artist; +using Lidarr.Api.V1.Tracks; +using Lidarr.Http; +using Lidarr.Http.Extensions; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.History +{ + public class HistoryModule : LidarrRestModule + { + private readonly IHistoryService _historyService; + private readonly IUpgradableSpecification _upgradableSpecification; + private readonly IFailedDownloadService _failedDownloadService; + + public HistoryModule(IHistoryService historyService, + IUpgradableSpecification upgradableSpecification, + IFailedDownloadService failedDownloadService) + { + _historyService = historyService; + _upgradableSpecification = upgradableSpecification; + _failedDownloadService = failedDownloadService; + GetResourcePaged = GetHistory; + + Get["/since"] = x => GetHistorySince(); + Get["/artist"] = x => GetArtistHistory(); + Post["/failed"] = x => MarkAsFailed(); + } + + protected HistoryResource MapToResource(NzbDrone.Core.History.History model, bool includeArtist, bool includeAlbum, bool includeTrack) + { + var resource = model.ToResource(); + + if (includeArtist) + { + resource.Artist = model.Artist.ToResource(); + } + if (includeAlbum) + { + resource.Album = model.Album.ToResource(); + } + if (includeTrack) + { + resource.Track = model.Track.ToResource(); + } + + + if (model.Artist != null) + { + resource.QualityCutoffNotMet = _upgradableSpecification.QualityCutoffNotMet(model.Artist.QualityProfile.Value, model.Quality); + } + + return resource; + } + + private PagingResource GetHistory(PagingResource pagingResource) + { + var pagingSpec = pagingResource.MapToPagingSpec("date", SortDirection.Descending); + var includeArtist = Request.GetBooleanQueryParameter("includeArtist"); + var includeAlbum = Request.GetBooleanQueryParameter("includeAlbum"); + var includeTrack = Request.GetBooleanQueryParameter("includeTrack"); + + var eventTypeFilter = pagingResource.Filters.FirstOrDefault(f => f.Key == "eventType"); + var albumIdFilter = pagingResource.Filters.FirstOrDefault(f => f.Key == "albumId"); + + if (eventTypeFilter != null) + { + var filterValue = (HistoryEventType)Convert.ToInt32(eventTypeFilter.Value); + pagingSpec.FilterExpressions.Add(v => v.EventType == filterValue); + } + + if (albumIdFilter != null) + { + var albumId = Convert.ToInt32(albumIdFilter.Value); + pagingSpec.FilterExpressions.Add(h => h.AlbumId == albumId); + } + + + return ApplyToPage(_historyService.Paged, pagingSpec, h => MapToResource(h, includeArtist, includeAlbum, includeTrack)); + } + + 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; + var includeArtist = Request.GetBooleanQueryParameter("includeArtist"); + var includeAlbum = Request.GetBooleanQueryParameter("includeAlbum"); + var includeTrack = Request.GetBooleanQueryParameter("includeTrack"); + + if (queryEventType.HasValue) + { + eventType = (HistoryEventType)Convert.ToInt32(queryEventType.Value); + } + + return _historyService.Since(date, eventType).Select(h => MapToResource(h, includeArtist, includeAlbum, includeTrack)).ToList(); + } + + private List GetArtistHistory() + { + var queryArtistId = Request.Query.ArtistId; + var queryAlbumId = Request.Query.AlbumId; + var queryEventType = Request.Query.EventType; + + if (!queryArtistId.HasValue) + { + throw new BadRequestException("artistId is missing"); + } + + int artistId = Convert.ToInt32(queryArtistId.Value); + HistoryEventType? eventType = null; + var includeArtist = Request.GetBooleanQueryParameter("includeArtist"); + var includeAlbum = Request.GetBooleanQueryParameter("includeAlbum"); + var includeTrack = Request.GetBooleanQueryParameter("includeTrack"); + + if (queryEventType.HasValue) + { + eventType = (HistoryEventType)Convert.ToInt32(queryEventType.Value); + } + + if (queryAlbumId.HasValue) + { + int albumId = Convert.ToInt32(queryAlbumId.Value); + + return _historyService.GetByAlbum(albumId, eventType).Select(h => MapToResource(h, includeArtist, includeAlbum, includeTrack)).ToList(); + } + + return _historyService.GetByArtist(artistId, eventType).Select(h => MapToResource(h, includeArtist, includeAlbum, includeTrack)).ToList(); + } + + private Response MarkAsFailed() + { + var id = (int)Request.Form.Id; + _failedDownloadService.MarkAsFailed(id); + return new object().AsResponse(); + } + } +} diff --git a/src/Lidarr.Api.V1/History/HistoryResource.cs b/src/Lidarr.Api.V1/History/HistoryResource.cs new file mode 100644 index 000000000..398230864 --- /dev/null +++ b/src/Lidarr.Api.V1/History/HistoryResource.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Core.History; +using NzbDrone.Core.Qualities; +using Lidarr.Api.V1.Albums; +using Lidarr.Api.V1.Artist; +using Lidarr.Api.V1.Tracks; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.History +{ + public class HistoryResource : RestResource + { + public int AlbumId { get; set; } + public int ArtistId { get; set; } + public int TrackId { get; set; } + public string SourceTitle { get; set; } + public QualityModel Quality { get; set; } + public bool QualityCutoffNotMet { get; set; } + public DateTime Date { get; set; } + public string DownloadId { get; set; } + + public HistoryEventType EventType { get; set; } + + public Dictionary Data { get; set; } + + public AlbumResource Album { get; set; } + public ArtistResource Artist { get; set; } + public TrackResource Track { get; set; } + } + + public static class HistoryResourceMapper + { + public static HistoryResource ToResource(this NzbDrone.Core.History.History model) + { + if (model == null) return null; + + return new HistoryResource + { + Id = model.Id, + + AlbumId = model.AlbumId, + ArtistId = model.ArtistId, + TrackId = model.TrackId, + SourceTitle = model.SourceTitle, + Quality = model.Quality, + //QualityCutoffNotMet + Date = model.Date, + DownloadId = model.DownloadId, + + EventType = model.EventType, + + Data = model.Data + //Episode + //Series + }; + } + } +} diff --git a/src/Lidarr.Api.V1/ImportLists/ImportListExclusionModule.cs b/src/Lidarr.Api.V1/ImportLists/ImportListExclusionModule.cs new file mode 100644 index 000000000..c5fdd0bbe --- /dev/null +++ b/src/Lidarr.Api.V1/ImportLists/ImportListExclusionModule.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using NzbDrone.Core.ImportLists.Exclusions; +using Lidarr.Http; +using FluentValidation; +using NzbDrone.Core.Validation; + +namespace Lidarr.Api.V1.ImportLists +{ + public class ImportListExclusionModule : LidarrRestModule + { + private readonly IImportListExclusionService _importListExclusionService; + + public ImportListExclusionModule(IImportListExclusionService importListExclusionService, + ImportListExclusionExistsValidator importListExclusionExistsValidator, + GuidValidator guidValidator) + { + _importListExclusionService = importListExclusionService; + + GetResourceById = GetImportListExclusion; + GetResourceAll = GetImportListExclusions; + CreateResource = AddImportListExclusion; + UpdateResource = UpdateImportListExclusion; + DeleteResource = DeleteImportListExclusionResource; + + SharedValidator.RuleFor(c => c.ForeignId).NotEmpty().SetValidator(guidValidator).SetValidator(importListExclusionExistsValidator); + SharedValidator.RuleFor(c => c.ArtistName).NotEmpty(); + } + + private ImportListExclusionResource GetImportListExclusion(int id) + { + return _importListExclusionService.Get(id).ToResource(); + } + + private List GetImportListExclusions() + { + return _importListExclusionService.All().ToResource(); + } + + private int AddImportListExclusion(ImportListExclusionResource resource) + { + var customFilter = _importListExclusionService.Add(resource.ToModel()); + + return customFilter.Id; + } + + private void UpdateImportListExclusion(ImportListExclusionResource resource) + { + _importListExclusionService.Update(resource.ToModel()); + } + + private void DeleteImportListExclusionResource(int id) + { + _importListExclusionService.Delete(id); + } + } +} diff --git a/src/Lidarr.Api.V1/ImportLists/ImportListExclusionResource.cs b/src/Lidarr.Api.V1/ImportLists/ImportListExclusionResource.cs new file mode 100644 index 000000000..91d7f52d3 --- /dev/null +++ b/src/Lidarr.Api.V1/ImportLists/ImportListExclusionResource.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.ImportLists.Exclusions; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.ImportLists +{ + public class ImportListExclusionResource : RestResource + { + public string ForeignId { get; set; } + public string ArtistName { get; set; } + } + + public static class ImportListExclusionResourceMapper + { + public static ImportListExclusionResource ToResource(this ImportListExclusion model) + { + if (model == null) return null; + + return new ImportListExclusionResource + { + Id = model.Id, + ForeignId = model.ForeignId, + ArtistName = model.Name, + }; + } + + public static ImportListExclusion ToModel(this ImportListExclusionResource resource) + { + if (resource == null) return null; + + return new ImportListExclusion + { + Id = resource.Id, + ForeignId = resource.ForeignId, + Name = resource.ArtistName + }; + } + + public static List ToResource(this IEnumerable filters) + { + return filters.Select(ToResource).ToList(); + } + } +} diff --git a/src/Lidarr.Api.V1/ImportLists/ImportListModule.cs b/src/Lidarr.Api.V1/ImportLists/ImportListModule.cs new file mode 100644 index 000000000..b3d3965e8 --- /dev/null +++ b/src/Lidarr.Api.V1/ImportLists/ImportListModule.cs @@ -0,0 +1,34 @@ +using NzbDrone.Core.ImportLists; +using NzbDrone.Core.Validation; +using NzbDrone.Core.Validation.Paths; + +namespace Lidarr.Api.V1.ImportLists +{ + public class ImportListModule : ProviderModuleBase + { + public static readonly ImportListResourceMapper ResourceMapper = new ImportListResourceMapper(); + + public ImportListModule(ImportListFactory importListFactory, + ProfileExistsValidator profileExistsValidator, + MetadataProfileExistsValidator metadataProfileExistsValidator + ) + : base(importListFactory, "importlist", ResourceMapper) + { + Http.Validation.RuleBuilderExtensions.ValidId(SharedValidator.RuleFor(s => s.QualityProfileId)); + Http.Validation.RuleBuilderExtensions.ValidId(SharedValidator.RuleFor(s => s.MetadataProfileId)); + + SharedValidator.RuleFor(c => c.RootFolderPath).IsValidPath(); + SharedValidator.RuleFor(c => c.QualityProfileId).SetValidator(profileExistsValidator); + SharedValidator.RuleFor(c => c.MetadataProfileId).SetValidator(metadataProfileExistsValidator); + } + + protected override void Validate(ImportListDefinition definition, bool includeWarnings) + { + if (!definition.Enable) + { + return; + } + base.Validate(definition, includeWarnings); + } + } +} diff --git a/src/Lidarr.Api.V1/ImportLists/ImportListResource.cs b/src/Lidarr.Api.V1/ImportLists/ImportListResource.cs new file mode 100644 index 000000000..d23551e18 --- /dev/null +++ b/src/Lidarr.Api.V1/ImportLists/ImportListResource.cs @@ -0,0 +1,57 @@ +using NzbDrone.Core.ImportLists; + +namespace Lidarr.Api.V1.ImportLists +{ + public class ImportListResource : ProviderResource + { + public bool EnableAutomaticAdd { get; set; } + public ImportListMonitorType ShouldMonitor { get; set; } + public string RootFolderPath { get; set; } + public int QualityProfileId { get; set; } + public int MetadataProfileId { get; set; } + public ImportListType ListType { get; set; } + public int ListOrder { get; set; } + } + + public class ImportListResourceMapper : ProviderResourceMapper + { + public override ImportListResource ToResource(ImportListDefinition definition) + { + if (definition == null) + { + return null; + } + + var resource = base.ToResource(definition); + + resource.EnableAutomaticAdd = definition.EnableAutomaticAdd; + resource.ShouldMonitor = definition.ShouldMonitor; + resource.RootFolderPath = definition.RootFolderPath; + resource.QualityProfileId = definition.ProfileId; + resource.MetadataProfileId = definition.MetadataProfileId; + resource.ListType = definition.ListType; + resource.ListOrder = (int) definition.ListType; + + return resource; + } + + public override ImportListDefinition ToModel(ImportListResource resource) + { + if (resource == null) + { + return null; + } + + var definition = base.ToModel(resource); + + definition.EnableAutomaticAdd = resource.EnableAutomaticAdd; + definition.ShouldMonitor = resource.ShouldMonitor; + definition.RootFolderPath = resource.RootFolderPath; + definition.ProfileId = resource.QualityProfileId; + definition.MetadataProfileId = resource.MetadataProfileId; + definition.ListType = resource.ListType; + + return definition; + } + } +} diff --git a/src/Lidarr.Api.V1/Indexers/IndexerModule.cs b/src/Lidarr.Api.V1/Indexers/IndexerModule.cs new file mode 100644 index 000000000..9156f03c0 --- /dev/null +++ b/src/Lidarr.Api.V1/Indexers/IndexerModule.cs @@ -0,0 +1,20 @@ +using NzbDrone.Core.Indexers; + +namespace Lidarr.Api.V1.Indexers +{ + public class IndexerModule : ProviderModuleBase + { + public static readonly IndexerResourceMapper ResourceMapper = new IndexerResourceMapper(); + + public IndexerModule(IndexerFactory indexerFactory) + : base(indexerFactory, "indexer", ResourceMapper) + { + } + + protected override void Validate(IndexerDefinition definition, bool includeWarnings) + { + if (!definition.Enable) return; + base.Validate(definition, includeWarnings); + } + } +} \ No newline at end of file diff --git a/src/Lidarr.Api.V1/Indexers/IndexerResource.cs b/src/Lidarr.Api.V1/Indexers/IndexerResource.cs new file mode 100644 index 000000000..2b63db913 --- /dev/null +++ b/src/Lidarr.Api.V1/Indexers/IndexerResource.cs @@ -0,0 +1,46 @@ +using NzbDrone.Core.Indexers; + +namespace Lidarr.Api.V1.Indexers +{ + public class IndexerResource : ProviderResource + { + public bool EnableRss { get; set; } + public bool EnableAutomaticSearch { get; set; } + public bool EnableInteractiveSearch { get; set; } + public bool SupportsRss { get; set; } + public bool SupportsSearch { get; set; } + public DownloadProtocol Protocol { get; set; } + } + + public class IndexerResourceMapper : ProviderResourceMapper + { + public override IndexerResource ToResource(IndexerDefinition definition) + { + if (definition == null) return null; + + var resource = base.ToResource(definition); + + resource.EnableRss = definition.EnableRss; + resource.EnableAutomaticSearch = definition.EnableAutomaticSearch; + resource.EnableInteractiveSearch = definition.EnableInteractiveSearch; + resource.SupportsRss = definition.SupportsRss; + resource.SupportsSearch = definition.SupportsSearch; + resource.Protocol = definition.Protocol; + + return resource; + } + + public override IndexerDefinition ToModel(IndexerResource resource) + { + if (resource == null) return null; + + var definition = base.ToModel(resource); + + definition.EnableRss = resource.EnableRss; + definition.EnableAutomaticSearch = resource.EnableAutomaticSearch; + definition.EnableInteractiveSearch = resource.EnableInteractiveSearch; + + return definition; + } + } +} diff --git a/src/Lidarr.Api.V1/Indexers/ReleaseModule.cs b/src/Lidarr.Api.V1/Indexers/ReleaseModule.cs new file mode 100644 index 000000000..debc7dc28 --- /dev/null +++ b/src/Lidarr.Api.V1/Indexers/ReleaseModule.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using FluentValidation; +using Nancy; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.IndexerSearch; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Validation; +using Lidarr.Http.Extensions; +using HttpStatusCode = System.Net.HttpStatusCode; + +namespace Lidarr.Api.V1.Indexers +{ + public class ReleaseModule : ReleaseModuleBase + { + private readonly IFetchAndParseRss _rssFetcherAndParser; + private readonly ISearchForNzb _nzbSearchService; + private readonly IMakeDownloadDecision _downloadDecisionMaker; + private readonly IPrioritizeDownloadDecision _prioritizeDownloadDecision; + private readonly IDownloadService _downloadService; + private readonly Logger _logger; + + private readonly ICached _remoteAlbumCache; + + public ReleaseModule(IFetchAndParseRss rssFetcherAndParser, + ISearchForNzb nzbSearchService, + IMakeDownloadDecision downloadDecisionMaker, + IPrioritizeDownloadDecision prioritizeDownloadDecision, + IDownloadService downloadService, + ICacheManager cacheManager, + Logger logger) + { + _rssFetcherAndParser = rssFetcherAndParser; + _nzbSearchService = nzbSearchService; + _downloadDecisionMaker = downloadDecisionMaker; + _prioritizeDownloadDecision = prioritizeDownloadDecision; + _downloadService = downloadService; + _logger = logger; + + GetResourceAll = GetReleases; + Post["/"] = x => DownloadRelease(ReadResourceFromRequest()); + + PostValidator.RuleFor(s => s.IndexerId).ValidId(); + PostValidator.RuleFor(s => s.Guid).NotEmpty(); + + _remoteAlbumCache = cacheManager.GetCache(GetType(), "remoteAlbums"); + } + + private Response DownloadRelease(ReleaseResource release) + { + var remoteAlbum = _remoteAlbumCache.Find(GetCacheKey(release)); + + if (remoteAlbum == null) + { + _logger.Debug("Couldn't find requested release in cache, cache timeout probably expired."); + + throw new NzbDroneClientException(HttpStatusCode.NotFound, "Couldn't find requested release in cache, try searching again"); + } + + try + { + _downloadService.DownloadReport(remoteAlbum); + } + catch (ReleaseDownloadException ex) + { + _logger.Error(ex, "Getting release from indexer failed"); + throw new NzbDroneClientException(HttpStatusCode.Conflict, "Getting release from indexer failed"); + } + + return release.AsResponse(); + } + + private List GetReleases() + { + if (Request.Query.albumId.HasValue) + { + return GetAlbumReleases(Request.Query.albumId); + } + + if (Request.Query.artistId.HasValue) + { + return GetArtistReleases(Request.Query.artistId); + } + + return GetRss(); + } + + private List GetAlbumReleases(int albumId) + { + try + { + var decisions = _nzbSearchService.AlbumSearch(albumId, true, true, true); + var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisions(decisions); + + return MapDecisions(prioritizedDecisions); + } + catch (Exception ex) + { + _logger.Error(ex, "Album search failed"); + } + + return new List(); + } + + private List GetArtistReleases(int artistId) + { + try + { + var decisions = _nzbSearchService.ArtistSearch(artistId, false, true, true); + var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisions(decisions); + + return MapDecisions(prioritizedDecisions); + } + catch (Exception ex) + { + _logger.Error(ex, "Artist search failed"); + } + + return new List(); + } + + private List GetRss() + { + var reports = _rssFetcherAndParser.Fetch(); + var decisions = _downloadDecisionMaker.GetRssDecision(reports); + var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisions(decisions); + + return MapDecisions(prioritizedDecisions); + } + + protected override ReleaseResource MapDecision(DownloadDecision decision, int initialWeight) + { + var resource = base.MapDecision(decision, initialWeight); + _remoteAlbumCache.Set(GetCacheKey(resource), decision.RemoteAlbum, TimeSpan.FromMinutes(30)); + return resource; + } + + private string GetCacheKey(ReleaseResource resource) + { + return string.Concat(resource.IndexerId, "_", resource.Guid); + } + } +} diff --git a/src/Lidarr.Api.V1/Indexers/ReleaseModuleBase.cs b/src/Lidarr.Api.V1/Indexers/ReleaseModuleBase.cs new file mode 100644 index 000000000..b506855e6 --- /dev/null +++ b/src/Lidarr.Api.V1/Indexers/ReleaseModuleBase.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using NzbDrone.Core.DecisionEngine; +using Lidarr.Http; + +namespace Lidarr.Api.V1.Indexers +{ + public abstract class ReleaseModuleBase : LidarrRestModule + { + protected virtual List MapDecisions(IEnumerable decisions) + { + var result = new List(); + + foreach (var downloadDecision in decisions) + { + var release = MapDecision(downloadDecision, result.Count); + + result.Add(release); + } + + return result; + } + + protected virtual ReleaseResource MapDecision(DownloadDecision decision, int initialWeight) + { + var release = decision.ToResource(); + + release.ReleaseWeight = initialWeight; + + if (decision.RemoteAlbum.Artist != null) + { + release.QualityWeight = decision.RemoteAlbum + .Artist + .QualityProfile.Value.GetIndex(release.Quality.Quality).Index * 100; + } + + release.QualityWeight += release.Quality.Revision.Real * 10; + release.QualityWeight += release.Quality.Revision.Version; + + return release; + } + } +} diff --git a/src/Lidarr.Api.V1/Indexers/ReleasePushModule.cs b/src/Lidarr.Api.V1/Indexers/ReleasePushModule.cs new file mode 100644 index 000000000..3ef01d1d7 --- /dev/null +++ b/src/Lidarr.Api.V1/Indexers/ReleasePushModule.cs @@ -0,0 +1,100 @@ +using System.Collections.Generic; +using System.Linq; +using FluentValidation; +using FluentValidation.Results; +using Nancy; +using NLog; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; +using NzbDrone.Core.Parser.Model; +using Lidarr.Http.Extensions; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Indexers; + +namespace Lidarr.Api.V1.Indexers +{ + class ReleasePushModule : ReleaseModuleBase + { + 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(ReadResourceFromRequest()); + + PostValidator.RuleFor(s => s.Title).NotEmpty(); + PostValidator.RuleFor(s => s.DownloadUrl).NotEmpty(); + PostValidator.RuleFor(s => s.Protocol).NotEmpty(); + PostValidator.RuleFor(s => s.PublishDate).NotEmpty(); + } + + private Response ProcessRelease(ReleaseResource release) + { + _logger.Info("Release pushed: {0} - {1}", release.Title, release.DownloadUrl); + + var info = release.ToModel(); + + info.Guid = "PUSH-" + info.DownloadUrl; + + ResolveIndexer(info); + + var decisions = _downloadDecisionMaker.GetRssDecision(new List { info }); + _downloadDecisionProcessor.ProcessDecisions(decisions); + + var firstDecision = decisions.FirstOrDefault(); + + if (firstDecision?.RemoteAlbum.ParsedAlbumInfo == null) + { + throw new ValidationException(new List { new ValidationFailure("Title", "Unable to parse", release.Title) }); + } + + return MapDecisions(new[] { firstDecision }).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/Lidarr.Api.V1/Indexers/ReleaseResource.cs b/src/Lidarr.Api.V1/Indexers/ReleaseResource.cs new file mode 100644 index 000000000..bc10523cb --- /dev/null +++ b/src/Lidarr.Api.V1/Indexers/ReleaseResource.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Qualities; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.Indexers +{ + public class ReleaseResource : RestResource + { + public string Guid { get; set; } + public QualityModel Quality { get; set; } + public int QualityWeight { get; set; } + public int Age { get; set; } + public double AgeHours { get; set; } + public double AgeMinutes { get; set; } + public long Size { get; set; } + public int IndexerId { get; set; } + public string Indexer { get; set; } + public string ReleaseGroup { get; set; } + public string SubGroup { get; set; } + public string ReleaseHash { get; set; } + public string Title { get; set; } + public bool Discography { get; set; } + public bool SceneSource { get; set; } + public string AirDate { get; set; } + public string ArtistName { get; set; } + public string AlbumTitle { get; set; } + public bool Approved { get; set; } + public bool TemporarilyRejected { get; set; } + public bool Rejected { get; set; } + public IEnumerable Rejections { get; set; } + public DateTime PublishDate { get; set; } + public string CommentUrl { get; set; } + public string DownloadUrl { get; set; } + public string InfoUrl { get; set; } + public bool DownloadAllowed { get; set; } + public int ReleaseWeight { get; set; } + public int PreferredWordScore { get; set; } + + public string MagnetUrl { get; set; } + public string InfoHash { get; set; } + public int? Seeders { get; set; } + public int? Leechers { get; set; } + public DownloadProtocol Protocol { get; set; } + + // Sent when queuing an unknown release + + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + // [JsonIgnore] + public int? ArtistId { get; set; } + + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + // [JsonIgnore] + public int? AlbumId { get; set; } + } + + public static class ReleaseResourceMapper + { + public static ReleaseResource ToResource(this DownloadDecision model) + { + var releaseInfo = model.RemoteAlbum.Release; + var parsedAlbumInfo = model.RemoteAlbum.ParsedAlbumInfo; + var remoteAlbum = model.RemoteAlbum; + var torrentInfo = (model.RemoteAlbum.Release as TorrentInfo) ?? new TorrentInfo(); + + // TODO: Clean this mess up. don't mix data from multiple classes, use sub-resources instead? (Got a huge Deja Vu, didn't we talk about this already once?) + return new ReleaseResource + { + Guid = releaseInfo.Guid, + Quality = parsedAlbumInfo.Quality, + //QualityWeight + Age = releaseInfo.Age, + AgeHours = releaseInfo.AgeHours, + AgeMinutes = releaseInfo.AgeMinutes, + Size = releaseInfo.Size, + IndexerId = releaseInfo.IndexerId, + Indexer = releaseInfo.Indexer, + ReleaseGroup = parsedAlbumInfo.ReleaseGroup, + ReleaseHash = parsedAlbumInfo.ReleaseHash, + Title = releaseInfo.Title, + ArtistName = parsedAlbumInfo.ArtistName, + AlbumTitle = parsedAlbumInfo.AlbumTitle, + Discography = parsedAlbumInfo.Discography, + Approved = model.Approved, + TemporarilyRejected = model.TemporarilyRejected, + Rejected = model.Rejected, + Rejections = model.Rejections.Select(r => r.Reason).ToList(), + PublishDate = releaseInfo.PublishDate, + CommentUrl = releaseInfo.CommentUrl, + DownloadUrl = releaseInfo.DownloadUrl, + InfoUrl = releaseInfo.InfoUrl, + DownloadAllowed = remoteAlbum.DownloadAllowed, + //ReleaseWeight + PreferredWordScore = remoteAlbum.PreferredWordScore, + + + MagnetUrl = torrentInfo.MagnetUrl, + InfoHash = torrentInfo.InfoHash, + Seeders = torrentInfo.Seeders, + Leechers = (torrentInfo.Peers.HasValue && torrentInfo.Seeders.HasValue) ? (torrentInfo.Peers.Value - torrentInfo.Seeders.Value) : (int?)null, + Protocol = releaseInfo.DownloadProtocol, + }; + + } + + public static ReleaseInfo ToModel(this ReleaseResource resource) + { + ReleaseInfo model; + + if (resource.Protocol == DownloadProtocol.Torrent) + { + model = new TorrentInfo + { + MagnetUrl = resource.MagnetUrl, + InfoHash = resource.InfoHash, + Seeders = resource.Seeders, + Peers = (resource.Seeders.HasValue && resource.Leechers.HasValue) ? (resource.Seeders + resource.Leechers) : null + }; + } + else + { + model = new ReleaseInfo(); + } + + model.Guid = resource.Guid; + model.Title = resource.Title; + model.Size = resource.Size; + model.DownloadUrl = resource.DownloadUrl; + model.InfoUrl = resource.InfoUrl; + model.CommentUrl = resource.CommentUrl; + model.IndexerId = resource.IndexerId; + model.Indexer = resource.Indexer; + model.DownloadProtocol = resource.Protocol; + model.PublishDate = resource.PublishDate.ToUniversalTime(); + + return model; + } + } +} diff --git a/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj b/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj new file mode 100644 index 000000000..1e47e39cf --- /dev/null +++ b/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj @@ -0,0 +1,23 @@ + + + net462 + x86 + + + + + + + + + + + + + + + + + + + diff --git a/src/Lidarr.Api.V1/LidarrV1FeedModule.cs b/src/Lidarr.Api.V1/LidarrV1FeedModule.cs new file mode 100644 index 000000000..5c457a7f1 --- /dev/null +++ b/src/Lidarr.Api.V1/LidarrV1FeedModule.cs @@ -0,0 +1,12 @@ +using Nancy; + +namespace Lidarr.Api.V1 +{ + public abstract class LidarrV1FeedModule : NancyModule + { + protected LidarrV1FeedModule(string resource) + : base("/feed/v1/" + resource.Trim('/')) + { + } + } +} diff --git a/src/Lidarr.Api.V1/LidarrV1Module.cs b/src/Lidarr.Api.V1/LidarrV1Module.cs new file mode 100644 index 000000000..314581787 --- /dev/null +++ b/src/Lidarr.Api.V1/LidarrV1Module.cs @@ -0,0 +1,12 @@ +using Nancy; + +namespace Lidarr.Api.V1 +{ + public abstract class LidarrV1Module : NancyModule + { + protected LidarrV1Module(string resource) + : base("/api/v1/" + resource.Trim('/')) + { + } + } +} diff --git a/src/NzbDrone.Api/Logs/LogFileModule.cs b/src/Lidarr.Api.V1/Logs/LogFileModule.cs similarity index 85% rename from src/NzbDrone.Api/Logs/LogFileModule.cs rename to src/Lidarr.Api.V1/Logs/LogFileModule.cs index bed6f7de2..fadd69ad1 100644 --- a/src/NzbDrone.Api/Logs/LogFileModule.cs +++ b/src/Lidarr.Api.V1/Logs/LogFileModule.cs @@ -5,7 +5,7 @@ using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; -namespace NzbDrone.Api.Logs +namespace Lidarr.Api.V1.Logs { public class LogFileModule : LogFileModuleBase { @@ -31,6 +31,13 @@ namespace NzbDrone.Api.Logs return Path.Combine(_appFolderInfo.GetLogFolder(), filename); } - protected override string DownloadUrlRoot => "logfile"; + protected override string DownloadUrlRoot + { + get + { + return "logfile"; + } + } + } } \ No newline at end of file diff --git a/src/NzbDrone.Api/Logs/LogFileModuleBase.cs b/src/Lidarr.Api.V1/Logs/LogFileModuleBase.cs similarity index 86% rename from src/NzbDrone.Api/Logs/LogFileModuleBase.cs rename to src/Lidarr.Api.V1/Logs/LogFileModuleBase.cs index d8a12d1bf..9ccd1807b 100644 --- a/src/NzbDrone.Api/Logs/LogFileModuleBase.cs +++ b/src/Lidarr.Api.V1/Logs/LogFileModuleBase.cs @@ -1,14 +1,16 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using System.Linq; -using NzbDrone.Common.Disk; using Nancy; using Nancy.Responses; +using NzbDrone.Common.Disk; using NzbDrone.Core.Configuration; +using Lidarr.Http; +using NLog; -namespace NzbDrone.Api.Logs +namespace Lidarr.Api.V1.Logs { - public abstract class LogFileModuleBase : NzbDroneRestModule + public abstract class LogFileModuleBase : LidarrRestModule { protected const string LOGFILE_ROUTE = @"/(?[-.a-zA-Z0-9]+?\.txt)"; @@ -43,7 +45,7 @@ namespace NzbDrone.Api.Logs Id = i + 1, Filename = filename, LastWriteTime = _diskProvider.FileGetLastWrite(file), - ContentsUrl = string.Format("{0}/api/{1}/{2}", _configFileProvider.UrlBase, Resource, filename), + ContentsUrl = string.Format("{0}/api/v1/{1}/{2}", _configFileProvider.UrlBase, Resource, filename), DownloadUrl = string.Format("{0}/{1}/{2}", _configFileProvider.UrlBase, DownloadUrlRoot, filename) }); } @@ -53,6 +55,8 @@ namespace NzbDrone.Api.Logs private Response GetLogFileResponse(string filename) { + LogManager.Flush(); + var filePath = GetLogFilePath(filename); if (!_diskProvider.FileExists(filePath)) @@ -68,4 +72,4 @@ namespace NzbDrone.Api.Logs protected abstract string DownloadUrlRoot { get; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Api/Logs/LogFileResource.cs b/src/Lidarr.Api.V1/Logs/LogFileResource.cs similarity index 83% rename from src/NzbDrone.Api/Logs/LogFileResource.cs rename to src/Lidarr.Api.V1/Logs/LogFileResource.cs index 9f67c8af7..f99e48445 100644 --- a/src/NzbDrone.Api/Logs/LogFileResource.cs +++ b/src/Lidarr.Api.V1/Logs/LogFileResource.cs @@ -1,7 +1,7 @@ using System; -using NzbDrone.Api.REST; +using Lidarr.Http.REST; -namespace NzbDrone.Api.Logs +namespace Lidarr.Api.V1.Logs { public class LogFileResource : RestResource { diff --git a/src/Lidarr.Api.V1/Logs/LogModule.cs b/src/Lidarr.Api.V1/Logs/LogModule.cs new file mode 100644 index 000000000..1316e9024 --- /dev/null +++ b/src/Lidarr.Api.V1/Logs/LogModule.cs @@ -0,0 +1,63 @@ +using System.Linq; +using NzbDrone.Core.Instrumentation; +using Lidarr.Http; + +namespace Lidarr.Api.V1.Logs +{ + public class LogModule : LidarrRestModule + { + private readonly ILogService _logService; + + public LogModule(ILogService logService) + { + _logService = logService; + GetResourcePaged = GetLogs; + } + + private PagingResource GetLogs(PagingResource pagingResource) + { + var pageSpec = pagingResource.MapToPagingSpec(); + + if (pageSpec.SortKey == "time") + { + pageSpec.SortKey = "id"; + } + + var levelFilter = pagingResource.Filters.FirstOrDefault(f => f.Key == "level"); + + if (levelFilter != null) + { + switch (levelFilter.Value) + { + case "fatal": + pageSpec.FilterExpressions.Add(h => h.Level == "Fatal"); + break; + case "error": + pageSpec.FilterExpressions.Add(h => h.Level == "Fatal" || h.Level == "Error"); + break; + case "warn": + pageSpec.FilterExpressions.Add(h => h.Level == "Fatal" || h.Level == "Error" || h.Level == "Warn"); + break; + case "info": + pageSpec.FilterExpressions.Add(h => h.Level == "Fatal" || h.Level == "Error" || h.Level == "Warn" || h.Level == "Info"); + break; + case "debug": + pageSpec.FilterExpressions.Add(h => h.Level == "Fatal" || h.Level == "Error" || h.Level == "Warn" || h.Level == "Info" || h.Level == "Debug"); + break; + case "trace": + pageSpec.FilterExpressions.Add(h => h.Level == "Fatal" || h.Level == "Error" || h.Level == "Warn" || h.Level == "Info" || h.Level == "Debug" || h.Level == "Trace"); + break; + } + } + + var response = ApplyToPage(_logService.Paged, pageSpec, LogResourceMapper.ToResource); + + if (pageSpec.SortKey == "id") + { + response.SortKey = "time"; + } + + return response; + } + } +} diff --git a/src/Lidarr.Api.V1/Logs/LogResource.cs b/src/Lidarr.Api.V1/Logs/LogResource.cs new file mode 100644 index 000000000..bd2503bf2 --- /dev/null +++ b/src/Lidarr.Api.V1/Logs/LogResource.cs @@ -0,0 +1,36 @@ +using System; +using NzbDrone.Core.Instrumentation; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.Logs +{ + public class LogResource : RestResource + { + public DateTime Time { get; set; } + public string Exception { get; set; } + public string ExceptionType { get; set; } + public string Level { get; set; } + public string Logger { get; set; } + public string Message { get; set; } + public string Method { get; set; } + } + + public static class LogResourceMapper + { + public static LogResource ToResource(this Log model) + { + if (model == null) return null; + + return new LogResource + { + Id = model.Id, + Time = model.Time, + Exception = model.Exception, + ExceptionType = model.ExceptionType, + Level = model.Level.ToLowerInvariant(), + Logger = model.Logger, + Message = model.Message + }; + } + } +} diff --git a/src/NzbDrone.Api/Logs/UpdateLogFileModule.cs b/src/Lidarr.Api.V1/Logs/UpdateLogFileModule.cs similarity index 88% rename from src/NzbDrone.Api/Logs/UpdateLogFileModule.cs rename to src/Lidarr.Api.V1/Logs/UpdateLogFileModule.cs index 5c4f81f02..441ee9e36 100644 --- a/src/NzbDrone.Api/Logs/UpdateLogFileModule.cs +++ b/src/Lidarr.Api.V1/Logs/UpdateLogFileModule.cs @@ -7,7 +7,7 @@ using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; -namespace NzbDrone.Api.Logs +namespace Lidarr.Api.V1.Logs { public class UpdateLogFileModule : LogFileModuleBase { @@ -37,6 +37,12 @@ namespace NzbDrone.Api.Logs return Path.Combine(_appFolderInfo.GetUpdateLogFolder(), filename); } - protected override string DownloadUrlRoot => "updatelogfile"; + protected override string DownloadUrlRoot + { + get + { + return "updatelogfile"; + } + } } } \ No newline at end of file diff --git a/src/Lidarr.Api.V1/ManualImport/ManualImportModule.cs b/src/Lidarr.Api.V1/ManualImport/ManualImportModule.cs new file mode 100644 index 000000000..98abaf7c9 --- /dev/null +++ b/src/Lidarr.Api.V1/ManualImport/ManualImportModule.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.MediaFiles.TrackImport.Manual; +using NzbDrone.Core.Qualities; +using Lidarr.Http.Extensions; +using NzbDrone.Core.Music; +using NLog; +using Nancy; +using Lidarr.Http; +using NzbDrone.Core.MediaFiles; + +namespace Lidarr.Api.V1.ManualImport +{ + public class ManualImportModule : LidarrRestModule + { + private readonly IArtistService _artistService; + private readonly IAlbumService _albumService; + private readonly IReleaseService _releaseService; + private readonly IManualImportService _manualImportService; + private readonly Logger _logger; + + public ManualImportModule(IManualImportService manualImportService, + IArtistService artistService, + IAlbumService albumService, + IReleaseService releaseService, + Logger logger) + { + _artistService = artistService; + _albumService = albumService; + _releaseService = releaseService; + _manualImportService = manualImportService; + _logger = logger; + + GetResourceAll = GetMediaFiles; + + Put["/"] = options => + { + var resource = Request.Body.FromJson>(); + return UpdateImportItems(resource).AsResponse(HttpStatusCode.Accepted); + }; + } + + private List GetMediaFiles() + { + var folder = (string)Request.Query.folder; + var downloadId = (string)Request.Query.downloadId; + var filter = Request.GetBooleanQueryParameter("filterExistingFiles", true) ? FilterFilesType.Matched : FilterFilesType.None; + var replaceExistingFiles = Request.GetBooleanQueryParameter("replaceExistingFiles", true); + + return _manualImportService.GetMediaFiles(folder, downloadId, filter, replaceExistingFiles).ToResource().Select(AddQualityWeight).ToList(); + } + + private ManualImportResource AddQualityWeight(ManualImportResource item) + { + if (item.Quality != null) + { + item.QualityWeight = Quality.DefaultQualityDefinitions.Single(q => q.Quality == item.Quality.Quality).Weight; + item.QualityWeight += item.Quality.Revision.Real * 10; + item.QualityWeight += item.Quality.Revision.Version; + } + + return item; + } + + private List UpdateImportItems(List resources) + { + var items = new List(); + foreach (var resource in resources) + { + items.Add(new ManualImportItem { + Id = resource.Id, + Path = resource.Path, + RelativePath = resource.RelativePath, + Name = resource.Name, + Size = resource.Size, + Artist = resource.Artist == null ? null : _artistService.GetArtist(resource.Artist.Id), + Album = resource.Album == null ? null : _albumService.GetAlbum(resource.Album.Id), + Release = resource.AlbumReleaseId == 0 ? null : _releaseService.GetRelease(resource.AlbumReleaseId), + Quality = resource.Quality, + DownloadId = resource.DownloadId, + AdditionalFile = resource.AdditionalFile, + ReplaceExistingFiles = resource.ReplaceExistingFiles, + DisableReleaseSwitching = resource.DisableReleaseSwitching + }); + } + + return _manualImportService.UpdateItems(items).Select(x => x.ToResource()).ToList(); + } + } +} diff --git a/src/Lidarr.Api.V1/ManualImport/ManualImportResource.cs b/src/Lidarr.Api.V1/ManualImport/ManualImportResource.cs new file mode 100644 index 000000000..cce255260 --- /dev/null +++ b/src/Lidarr.Api.V1/ManualImport/ManualImportResource.cs @@ -0,0 +1,67 @@ +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.MediaFiles.TrackImport.Manual; +using NzbDrone.Core.Qualities; +using Lidarr.Api.V1.Artist; +using Lidarr.Api.V1.Albums; +using Lidarr.Api.V1.Tracks; +using Lidarr.Http.REST; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Parser.Model; + +namespace Lidarr.Api.V1.ManualImport +{ + public class ManualImportResource : RestResource + { + public string Path { get; set; } + public string RelativePath { get; set; } + public string Name { get; set; } + public long Size { get; set; } + public ArtistResource Artist { get; set; } + public AlbumResource Album { get; set; } + public int AlbumReleaseId { get; set; } + public List Tracks { get; set; } + public QualityModel Quality { get; set; } + public int QualityWeight { get; set; } + public string DownloadId { get; set; } + public IEnumerable Rejections { get; set; } + public ParsedTrackInfo AudioTags { get; set; } + public bool AdditionalFile { get; set; } + public bool ReplaceExistingFiles { get; set; } + public bool DisableReleaseSwitching { get; set; } + } + + public static class ManualImportResourceMapper + { + public static ManualImportResource ToResource(this ManualImportItem model) + { + if (model == null) return null; + + return new ManualImportResource + { + Id = model.Id, + Path = model.Path, + RelativePath = model.RelativePath, + Name = model.Name, + Size = model.Size, + Artist = model.Artist.ToResource(), + Album = model.Album.ToResource(), + AlbumReleaseId = model.Release?.Id ?? 0, + Tracks = model.Tracks.ToResource(), + Quality = model.Quality, + //QualityWeight + DownloadId = model.DownloadId, + Rejections = model.Rejections, + AudioTags = model.Tags, + AdditionalFile = model.AdditionalFile, + ReplaceExistingFiles = model.ReplaceExistingFiles, + DisableReleaseSwitching = model.DisableReleaseSwitching + }; + } + + public static List ToResource(this IEnumerable models) + { + return models.Select(ToResource).ToList(); + } + } +} diff --git a/src/Lidarr.Api.V1/MediaCovers/MediaCoverModule.cs b/src/Lidarr.Api.V1/MediaCovers/MediaCoverModule.cs new file mode 100644 index 000000000..5914988b7 --- /dev/null +++ b/src/Lidarr.Api.V1/MediaCovers/MediaCoverModule.cs @@ -0,0 +1,68 @@ +using System.IO; +using System.Text.RegularExpressions; +using Nancy; +using Nancy.Responses; +using NzbDrone.Common.Disk; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Extensions; + +namespace Lidarr.Api.V1.MediaCovers +{ + public class MediaCoverModule : LidarrV1Module + { + private static readonly Regex RegexResizedImage = new Regex(@"-\d+(?=\.(jpg|png|gif)$)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private const string MEDIA_COVER_ARTIST_ROUTE = @"/Artist/(?\d+)/(?(.+)\.(jpg|png|gif))"; + private const string MEDIA_COVER_ALBUM_ROUTE = @"/Album/(?\d+)/(?(.+)\.(jpg|png|gif))"; + + private readonly IAppFolderInfo _appFolderInfo; + private readonly IDiskProvider _diskProvider; + + public MediaCoverModule(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider) : base("MediaCover") + { + _appFolderInfo = appFolderInfo; + _diskProvider = diskProvider; + + Get[MEDIA_COVER_ARTIST_ROUTE] = options => GetArtistMediaCover(options.artistId, options.filename); + Get[MEDIA_COVER_ALBUM_ROUTE] = options => GetAlbumMediaCover(options.artistId, options.filename); + } + + private Response GetArtistMediaCover(int artistId, string filename) + { + var filePath = Path.Combine(_appFolderInfo.GetAppDataPath(), "MediaCover", artistId.ToString(), filename); + + if (!_diskProvider.FileExists(filePath) || _diskProvider.GetFileSize(filePath) == 0) + { + // Return the full sized image if someone requests a non-existing resized one. + // TODO: This code can be removed later once everyone had the update for a while. + var basefilePath = RegexResizedImage.Replace(filePath, ""); + if (basefilePath == filePath || !_diskProvider.FileExists(basefilePath)) + { + return new NotFoundResponse(); + } + filePath = basefilePath; + } + + return new StreamResponse(() => File.OpenRead(filePath), MimeTypes.GetMimeType(filePath)); + } + + private Response GetAlbumMediaCover(int albumId, string filename) + { + var filePath = Path.Combine(_appFolderInfo.GetAppDataPath(), "MediaCover", "Albums", albumId.ToString(), filename); + + if (!_diskProvider.FileExists(filePath) || _diskProvider.GetFileSize(filePath) == 0) + { + // Return the full sized image if someone requests a non-existing resized one. + // TODO: This code can be removed later once everyone had the update for a while. + var basefilePath = RegexResizedImage.Replace(filePath, ""); + if (basefilePath == filePath || !_diskProvider.FileExists(basefilePath)) + { + return new NotFoundResponse(); + } + filePath = basefilePath; + } + + return new StreamResponse(() => File.OpenRead(filePath), MimeTypes.GetMimeType(filePath)); + } + } +} diff --git a/src/Lidarr.Api.V1/Metadata/MetadataModule.cs b/src/Lidarr.Api.V1/Metadata/MetadataModule.cs new file mode 100644 index 000000000..1006c47b7 --- /dev/null +++ b/src/Lidarr.Api.V1/Metadata/MetadataModule.cs @@ -0,0 +1,20 @@ +using NzbDrone.Core.Extras.Metadata; + +namespace Lidarr.Api.V1.Metadata +{ + public class MetadataModule : ProviderModuleBase + { + public static readonly MetadataResourceMapper ResourceMapper = new MetadataResourceMapper(); + + public MetadataModule(IMetadataFactory metadataFactory) + : base(metadataFactory, "metadata", ResourceMapper) + { + } + + protected override void Validate(MetadataDefinition definition, bool includeWarnings) + { + if (!definition.Enable) return; + base.Validate(definition, includeWarnings); + } + } +} \ No newline at end of file diff --git a/src/Lidarr.Api.V1/Metadata/MetadataResource.cs b/src/Lidarr.Api.V1/Metadata/MetadataResource.cs new file mode 100644 index 000000000..5921b7fcb --- /dev/null +++ b/src/Lidarr.Api.V1/Metadata/MetadataResource.cs @@ -0,0 +1,34 @@ +using NzbDrone.Core.Extras.Metadata; + +namespace Lidarr.Api.V1.Metadata +{ + public class MetadataResource : ProviderResource + { + public bool Enable { get; set; } + } + + public class MetadataResourceMapper : ProviderResourceMapper + { + public override MetadataResource ToResource(MetadataDefinition definition) + { + if (definition == null) return null; + + var resource = base.ToResource(definition); + + resource.Enable = definition.Enable; + + return resource; + } + + public override MetadataDefinition ToModel(MetadataResource resource) + { + if (resource == null) return null; + + var definition = base.ToModel(resource); + + definition.Enable = resource.Enable; + + return definition; + } + } +} \ No newline at end of file diff --git a/src/Lidarr.Api.V1/Notifications/NotificationModule.cs b/src/Lidarr.Api.V1/Notifications/NotificationModule.cs new file mode 100644 index 000000000..10e67e3de --- /dev/null +++ b/src/Lidarr.Api.V1/Notifications/NotificationModule.cs @@ -0,0 +1,20 @@ +using NzbDrone.Core.Notifications; + +namespace Lidarr.Api.V1.Notifications +{ + public class NotificationModule : ProviderModuleBase + { + public static readonly NotificationResourceMapper ResourceMapper = new NotificationResourceMapper(); + + public NotificationModule(NotificationFactory notificationFactory) + : base(notificationFactory, "notification", ResourceMapper) + { + } + + protected override void Validate(NotificationDefinition definition, bool includeWarnings) + { + if (!definition.Enable) return; + base.Validate(definition, includeWarnings); + } + } +} diff --git a/src/Lidarr.Api.V1/Notifications/NotificationResource.cs b/src/Lidarr.Api.V1/Notifications/NotificationResource.cs new file mode 100644 index 000000000..95fc55b6c --- /dev/null +++ b/src/Lidarr.Api.V1/Notifications/NotificationResource.cs @@ -0,0 +1,84 @@ +using NzbDrone.Core.Notifications; + +namespace Lidarr.Api.V1.Notifications +{ + public class NotificationResource : ProviderResource + { + public string Link { get; set; } + public bool OnGrab { get; set; } + public bool OnReleaseImport { get; set; } + public bool OnUpgrade { get; set; } + public bool OnRename { get; set; } + public bool OnHealthIssue { get; set; } + public bool OnDownloadFailure { get; set; } + public bool OnImportFailure { get; set; } + public bool OnTrackRetag { get; set; } + public bool SupportsOnGrab { get; set; } + public bool SupportsOnReleaseImport { get; set; } + public bool SupportsOnUpgrade { get; set; } + public bool SupportsOnRename { get; set; } + public bool SupportsOnHealthIssue { get; set; } + public bool IncludeHealthWarnings { get; set; } + public bool SupportsOnDownloadFailure { get; set; } + public bool SupportsOnImportFailure { get; set; } + public bool SupportsOnTrackRetag { get; set; } + public string TestCommand { get; set; } + } + + public class NotificationResourceMapper : ProviderResourceMapper + { + public override NotificationResource ToResource(NotificationDefinition definition) + { + if (definition == null) return default(NotificationResource); + + var resource = base.ToResource(definition); + + resource.OnGrab = definition.OnGrab; + resource.OnReleaseImport = definition.OnReleaseImport; + resource.OnUpgrade = definition.OnUpgrade; + resource.OnRename = definition.OnRename; + resource.OnHealthIssue = definition.OnHealthIssue; + resource.OnDownloadFailure = definition.OnDownloadFailure; + resource.OnImportFailure = definition.OnImportFailure; + resource.OnTrackRetag = definition.OnTrackRetag; + resource.SupportsOnGrab = definition.SupportsOnGrab; + resource.SupportsOnReleaseImport = definition.SupportsOnReleaseImport; + resource.SupportsOnUpgrade = definition.SupportsOnUpgrade; + resource.SupportsOnRename = definition.SupportsOnRename; + resource.SupportsOnHealthIssue = definition.SupportsOnHealthIssue; + resource.IncludeHealthWarnings = definition.IncludeHealthWarnings; + resource.SupportsOnDownloadFailure = definition.SupportsOnDownloadFailure; + resource.SupportsOnImportFailure = definition.SupportsOnImportFailure; + resource.SupportsOnTrackRetag = definition.SupportsOnTrackRetag; + + return resource; + } + + public override NotificationDefinition ToModel(NotificationResource resource) + { + if (resource == null) return default(NotificationDefinition); + + var definition = base.ToModel(resource); + + definition.OnGrab = resource.OnGrab; + definition.OnReleaseImport = resource.OnReleaseImport; + definition.OnUpgrade = resource.OnUpgrade; + definition.OnRename = resource.OnRename; + definition.OnHealthIssue = resource.OnHealthIssue; + definition.OnDownloadFailure = resource.OnDownloadFailure; + definition.OnImportFailure = resource.OnImportFailure; + definition.OnTrackRetag = resource.OnTrackRetag; + definition.SupportsOnGrab = resource.SupportsOnGrab; + definition.SupportsOnReleaseImport = resource.SupportsOnReleaseImport; + definition.SupportsOnUpgrade = resource.SupportsOnUpgrade; + definition.SupportsOnRename = resource.SupportsOnRename; + definition.SupportsOnHealthIssue = resource.SupportsOnHealthIssue; + definition.IncludeHealthWarnings = resource.IncludeHealthWarnings; + definition.SupportsOnDownloadFailure = resource.SupportsOnDownloadFailure; + definition.SupportsOnImportFailure = resource.SupportsOnImportFailure; + definition.SupportsOnTrackRetag = resource.SupportsOnTrackRetag; + + return definition; + } + } +} diff --git a/src/Lidarr.Api.V1/Parse/ParseModule.cs b/src/Lidarr.Api.V1/Parse/ParseModule.cs new file mode 100644 index 000000000..c3afb268d --- /dev/null +++ b/src/Lidarr.Api.V1/Parse/ParseModule.cs @@ -0,0 +1,52 @@ +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Parser; +using Lidarr.Api.V1.Albums; +using Lidarr.Api.V1.Artist; +using Lidarr.Http; + +namespace Lidarr.Api.V1.Parse +{ + public class ParseModule : LidarrRestModule + { + private readonly IParsingService _parsingService; + + public ParseModule(IParsingService parsingService) + { + _parsingService = parsingService; + + GetResourceSingle = Parse; + } + + private ParseResource Parse() + { + var title = Request.Query.Title.Value as string; + var parsedAlbumInfo = Parser.ParseAlbumTitle(title); + + if (parsedAlbumInfo == null) + { + return null; + } + + var remoteAlbum = _parsingService.Map(parsedAlbumInfo); + + if (remoteAlbum != null) + { + return new ParseResource + { + Title = title, + ParsedAlbumInfo = remoteAlbum.ParsedAlbumInfo, + Artist = remoteAlbum.Artist.ToResource(), + Albums = remoteAlbum.Albums.ToResource() + }; + } + else + { + return new ParseResource + { + Title = title, + ParsedAlbumInfo = parsedAlbumInfo + }; + } + } + } +} diff --git a/src/Lidarr.Api.V1/Parse/ParseResource.cs b/src/Lidarr.Api.V1/Parse/ParseResource.cs new file mode 100644 index 000000000..785777a56 --- /dev/null +++ b/src/Lidarr.Api.V1/Parse/ParseResource.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using NzbDrone.Core.Parser.Model; +using Lidarr.Api.V1.Albums; +using Lidarr.Api.V1.Artist; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.Parse +{ + public class ParseResource : RestResource + { + public string Title { get; set; } + public ParsedAlbumInfo ParsedAlbumInfo { get; set; } + public ArtistResource Artist { get; set; } + public List Albums { get; set; } + } +} diff --git a/src/NzbDrone.Api/Profiles/Delay/DelayProfileModule.cs b/src/Lidarr.Api.V1/Profiles/Delay/DelayProfileModule.cs similarity index 76% rename from src/NzbDrone.Api/Profiles/Delay/DelayProfileModule.cs rename to src/Lidarr.Api.V1/Profiles/Delay/DelayProfileModule.cs index e7975b661..2ad0bf475 100644 --- a/src/NzbDrone.Api/Profiles/Delay/DelayProfileModule.cs +++ b/src/Lidarr.Api.V1/Profiles/Delay/DelayProfileModule.cs @@ -1,13 +1,17 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using FluentValidation; using FluentValidation.Results; -using NzbDrone.Api.REST; -using NzbDrone.Api.Validation; +using Nancy; using NzbDrone.Core.Profiles.Delay; +using Lidarr.Http; +using Lidarr.Http.Extensions; +using Lidarr.Http.REST; +using Lidarr.Http.Validation; -namespace NzbDrone.Api.Profiles.Delay +namespace Lidarr.Api.V1.Profiles.Delay { - public class DelayProfileModule : NzbDroneRestModule + public class DelayProfileModule : LidarrRestModule { private readonly IDelayProfileService _delayProfileService; @@ -20,6 +24,7 @@ namespace NzbDrone.Api.Profiles.Delay UpdateResource = Update; CreateResource = Create; DeleteResource = DeleteProfile; + Put[@"/reorder/(?[\d]{1,10})"] = options => Reorder(options.Id); SharedValidator.RuleFor(d => d.Tags).NotEmpty().When(d => d.Id != 1); SharedValidator.RuleFor(d => d.Tags).EmptyCollection().When(d => d.Id == 1); @@ -71,5 +76,15 @@ namespace NzbDrone.Api.Profiles.Delay { return _delayProfileService.All().ToResource(); } + + private Response Reorder(int id) + { + ValidateId(id); + + var afterIdQuery = Request.Query.After; + int? afterId = afterIdQuery.HasValue ? Convert.ToInt32(afterIdQuery.Value) : null; + + return _delayProfileService.Reorder(id, afterId).ToResource().AsResponse(); + } } } \ No newline at end of file diff --git a/src/NzbDrone.Api/Profiles/Delay/DelayProfileResource.cs b/src/Lidarr.Api.V1/Profiles/Delay/DelayProfileResource.cs similarity index 96% rename from src/NzbDrone.Api/Profiles/Delay/DelayProfileResource.cs rename to src/Lidarr.Api.V1/Profiles/Delay/DelayProfileResource.cs index e35df9043..bcdd83c65 100644 --- a/src/NzbDrone.Api/Profiles/Delay/DelayProfileResource.cs +++ b/src/Lidarr.Api.V1/Profiles/Delay/DelayProfileResource.cs @@ -1,10 +1,10 @@ using System.Collections.Generic; using System.Linq; -using NzbDrone.Api.REST; using NzbDrone.Core.Indexers; using NzbDrone.Core.Profiles.Delay; +using Lidarr.Http.REST; -namespace NzbDrone.Api.Profiles.Delay +namespace Lidarr.Api.V1.Profiles.Delay { public class DelayProfileResource : RestResource { diff --git a/src/Lidarr.Api.V1/Profiles/Metadata/MetadataProfileModule.cs b/src/Lidarr.Api.V1/Profiles/Metadata/MetadataProfileModule.cs new file mode 100644 index 000000000..fc7774b96 --- /dev/null +++ b/src/Lidarr.Api.V1/Profiles/Metadata/MetadataProfileModule.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using FluentValidation; +using NzbDrone.Core.Profiles.Metadata; +using Lidarr.Http; + +namespace Lidarr.Api.V1.Profiles.Metadata +{ + public class MetadataProfileModule : LidarrRestModule + { + private readonly IMetadataProfileService _profileService; + + public MetadataProfileModule(IMetadataProfileService profileService) + { + _profileService = profileService; + SharedValidator.RuleFor(c => c.Name).NotEmpty(); + SharedValidator.RuleFor(c => c.PrimaryAlbumTypes).MustHaveAllowedPrimaryType(); + SharedValidator.RuleFor(c => c.SecondaryAlbumTypes).MustHaveAllowedSecondaryType(); + SharedValidator.RuleFor(c => c.ReleaseStatuses).MustHaveAllowedReleaseStatus(); + + GetResourceAll = GetAll; + GetResourceById = GetById; + UpdateResource = Update; + CreateResource = Create; + DeleteResource = DeleteProfile; + } + + private int Create(MetadataProfileResource resource) + { + var model = resource.ToModel(); + model = _profileService.Add(model); + return model.Id; + } + + private void DeleteProfile(int id) + { + _profileService.Delete(id); + } + + private void Update(MetadataProfileResource resource) + { + var model = resource.ToModel(); + + _profileService.Update(model); + } + + private MetadataProfileResource GetById(int id) + { + return _profileService.Get(id).ToResource(); + } + + private List GetAll() + { + var profiles = _profileService.All().ToResource(); + + return profiles; + } + } +} diff --git a/src/Lidarr.Api.V1/Profiles/Metadata/MetadataProfileResource.cs b/src/Lidarr.Api.V1/Profiles/Metadata/MetadataProfileResource.cs new file mode 100644 index 000000000..0f6dd8bb4 --- /dev/null +++ b/src/Lidarr.Api.V1/Profiles/Metadata/MetadataProfileResource.cs @@ -0,0 +1,144 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Profiles.Metadata; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.Profiles.Metadata +{ + public class MetadataProfileResource : RestResource + { + public string Name { get; set; } + public List PrimaryAlbumTypes { get; set; } + public List SecondaryAlbumTypes { get; set; } + public List ReleaseStatuses { get; set; } + } + + public class ProfilePrimaryAlbumTypeItemResource : RestResource + { + public NzbDrone.Core.Music.PrimaryAlbumType AlbumType { get; set; } + public bool Allowed { get; set; } + } + + public class ProfileSecondaryAlbumTypeItemResource : RestResource + { + public NzbDrone.Core.Music.SecondaryAlbumType AlbumType { get; set; } + public bool Allowed { get; set; } + } + + public class ProfileReleaseStatusItemResource : RestResource + { + public NzbDrone.Core.Music.ReleaseStatus ReleaseStatus { get; set; } + public bool Allowed { get; set; } + } + + public static class MetadataProfileResourceMapper + { + public static MetadataProfileResource ToResource(this MetadataProfile model) + { + if (model == null) return null; + + return new MetadataProfileResource + { + Id = model.Id, + Name = model.Name, + PrimaryAlbumTypes = model.PrimaryAlbumTypes.ConvertAll(ToResource), + SecondaryAlbumTypes = model.SecondaryAlbumTypes.ConvertAll(ToResource), + ReleaseStatuses = model.ReleaseStatuses.ConvertAll(ToResource) + }; + } + + public static ProfilePrimaryAlbumTypeItemResource ToResource(this ProfilePrimaryAlbumTypeItem model) + { + if (model == null) return null; + + return new ProfilePrimaryAlbumTypeItemResource + { + AlbumType = model.PrimaryAlbumType, + Allowed = model.Allowed + }; + } + + public static ProfileSecondaryAlbumTypeItemResource ToResource(this ProfileSecondaryAlbumTypeItem model) + { + if (model == null) + { + return null; + } + + return new ProfileSecondaryAlbumTypeItemResource + { + AlbumType = model.SecondaryAlbumType, + Allowed = model.Allowed + }; + } + + public static ProfileReleaseStatusItemResource ToResource(this ProfileReleaseStatusItem model) + { + if (model == null) + { + return null; + } + + return new ProfileReleaseStatusItemResource + { + ReleaseStatus = model.ReleaseStatus, + Allowed = model.Allowed + }; + } + + public static MetadataProfile ToModel(this MetadataProfileResource resource) + { + if (resource == null) + { + return null; + } + + return new MetadataProfile + { + Id = resource.Id, + Name = resource.Name, + PrimaryAlbumTypes = resource.PrimaryAlbumTypes.ConvertAll(ToModel), + SecondaryAlbumTypes = resource.SecondaryAlbumTypes.ConvertAll(ToModel), + ReleaseStatuses = resource.ReleaseStatuses.ConvertAll(ToModel) + }; + } + + public static ProfilePrimaryAlbumTypeItem ToModel(this ProfilePrimaryAlbumTypeItemResource resource) + { + if (resource == null) return null; + + return new ProfilePrimaryAlbumTypeItem + { + PrimaryAlbumType = (NzbDrone.Core.Music.PrimaryAlbumType)resource.AlbumType.Id, + Allowed = resource.Allowed + }; + } + + public static ProfileSecondaryAlbumTypeItem ToModel(this ProfileSecondaryAlbumTypeItemResource resource) + { + if (resource == null) return null; + + return new ProfileSecondaryAlbumTypeItem + { + SecondaryAlbumType = (NzbDrone.Core.Music.SecondaryAlbumType)resource.AlbumType.Id, + Allowed = resource.Allowed + }; + } + + public static ProfileReleaseStatusItem ToModel(this ProfileReleaseStatusItemResource resource) + { + if (resource == null) return null; + + return new ProfileReleaseStatusItem + { + ReleaseStatus = (NzbDrone.Core.Music.ReleaseStatus)resource.ReleaseStatus.Id, + Allowed = resource.Allowed + }; + } + + public static List ToResource(this IEnumerable models) + { + return models.Select(ToResource).ToList(); + } + } +} diff --git a/src/Lidarr.Api.V1/Profiles/Metadata/MetadataProfileSchemaModule.cs b/src/Lidarr.Api.V1/Profiles/Metadata/MetadataProfileSchemaModule.cs new file mode 100644 index 000000000..1e7fb7650 --- /dev/null +++ b/src/Lidarr.Api.V1/Profiles/Metadata/MetadataProfileSchemaModule.cs @@ -0,0 +1,52 @@ +using System.Linq; +using NzbDrone.Core.Profiles.Metadata; +using Lidarr.Http; + +namespace Lidarr.Api.V1.Profiles.Metadata +{ + public class MetadataProfileSchemaModule : LidarrRestModule + { + + public MetadataProfileSchemaModule() + : base("/metadataprofile/schema") + { + GetResourceSingle = GetAll; + } + + private MetadataProfileResource GetAll() + { + var orderedPrimTypes = NzbDrone.Core.Music.PrimaryAlbumType.All + .OrderByDescending(l => l.Id) + .ToList(); + + var orderedSecTypes = NzbDrone.Core.Music.SecondaryAlbumType.All + .OrderByDescending(l => l.Id) + .ToList(); + + var orderedRelStatuses = NzbDrone.Core.Music.ReleaseStatus.All + .OrderByDescending(l => l.Id) + .ToList(); + + var primTypes = orderedPrimTypes + .Select(v => new ProfilePrimaryAlbumTypeItem {PrimaryAlbumType = v, Allowed = false}) + .ToList(); + + var secTypes = orderedSecTypes + .Select(v => new ProfileSecondaryAlbumTypeItem {SecondaryAlbumType = v, Allowed = false}) + .ToList(); + + var relStatuses = orderedRelStatuses + .Select(v => new ProfileReleaseStatusItem {ReleaseStatus = v, Allowed = false}) + .ToList(); + + var profile = new MetadataProfile + { + PrimaryAlbumTypes = primTypes, + SecondaryAlbumTypes = secTypes, + ReleaseStatuses = relStatuses + }; + + return profile.ToResource(); + } + } +} diff --git a/src/Lidarr.Api.V1/Profiles/Metadata/MetadataValidator.cs b/src/Lidarr.Api.V1/Profiles/Metadata/MetadataValidator.cs new file mode 100644 index 000000000..596176fbb --- /dev/null +++ b/src/Lidarr.Api.V1/Profiles/Metadata/MetadataValidator.cs @@ -0,0 +1,107 @@ +using System.Collections.Generic; +using System.Linq; +using FluentValidation; +using FluentValidation.Validators; + +namespace Lidarr.Api.V1.Profiles.Metadata +{ + public static class MetadataValidation + { + public static IRuleBuilderOptions> MustHaveAllowedPrimaryType(this IRuleBuilder> ruleBuilder) + { + ruleBuilder.SetValidator(new NotEmptyValidator(null)); + + return ruleBuilder.SetValidator(new PrimaryTypeValidator()); + } + + public static IRuleBuilderOptions> MustHaveAllowedSecondaryType(this IRuleBuilder> ruleBuilder) + { + ruleBuilder.SetValidator(new NotEmptyValidator(null)); + + return ruleBuilder.SetValidator(new SecondaryTypeValidator()); + } + + public static IRuleBuilderOptions> MustHaveAllowedReleaseStatus(this IRuleBuilder> ruleBuilder) + { + ruleBuilder.SetValidator(new NotEmptyValidator(null)); + + return ruleBuilder.SetValidator(new ReleaseStatusValidator()); + } + } + + + public class PrimaryTypeValidator : PropertyValidator + { + public PrimaryTypeValidator() + : base("Must have at least one allowed primary type") + { + } + + protected override bool IsValid(PropertyValidatorContext context) + { + var list = context.PropertyValue as IList; + + if (list == null) + { + return false; + } + + if (!list.Any(c => c.Allowed)) + { + return false; + } + + return true; + } + } + + public class SecondaryTypeValidator : PropertyValidator + { + public SecondaryTypeValidator() + : base("Must have at least one allowed secondary type") + { + } + + protected override bool IsValid(PropertyValidatorContext context) + { + var list = context.PropertyValue as IList; + + if (list == null) + { + return false; + } + + if (!list.Any(c => c.Allowed)) + { + return false; + } + + return true; + } + } + + public class ReleaseStatusValidator : PropertyValidator + { + public ReleaseStatusValidator() + : base("Must have at least one allowed release status") + { + } + + protected override bool IsValid(PropertyValidatorContext context) + { + var list = context.PropertyValue as IList; + + if (list == null) + { + return false; + } + + if (!list.Any(c => c.Allowed)) + { + return false; + } + + return true; + } + } +} diff --git a/src/Lidarr.Api.V1/Profiles/Quality/QualityCutoffValidator.cs b/src/Lidarr.Api.V1/Profiles/Quality/QualityCutoffValidator.cs new file mode 100644 index 000000000..037620f4e --- /dev/null +++ b/src/Lidarr.Api.V1/Profiles/Quality/QualityCutoffValidator.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using System.Linq; +using FluentValidation; +using FluentValidation.Validators; + +namespace Lidarr.Api.V1.Profiles.Quality +{ + public static class QualityCutoffValidator + { + public static IRuleBuilderOptions ValidCutoff(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.SetValidator(new ValidCutoffValidator()); + } + } + + public class ValidCutoffValidator : PropertyValidator + { + public ValidCutoffValidator() + : base("Cutoff must be an allowed quality or group") + { + + } + + protected override bool IsValid(PropertyValidatorContext context) + { + int cutoff = (int)context.PropertyValue; + dynamic instance = context.ParentContext.InstanceToValidate; + var items = instance.Items as IList; + + QualityProfileQualityItemResource cutoffItem = items.SingleOrDefault(i => (i.Quality == null && i.Id == cutoff) || i.Quality?.Id == cutoff); + + if (cutoffItem == null) + { + return false; + } + + if (!cutoffItem.Allowed) + { + return false; + } + + return true; + } + } +} diff --git a/src/Lidarr.Api.V1/Profiles/Quality/QualityItemsValidator.cs b/src/Lidarr.Api.V1/Profiles/Quality/QualityItemsValidator.cs new file mode 100644 index 000000000..24b765218 --- /dev/null +++ b/src/Lidarr.Api.V1/Profiles/Quality/QualityItemsValidator.cs @@ -0,0 +1,197 @@ +using System.Collections.Generic; +using System.Linq; +using FluentValidation; +using FluentValidation.Validators; +using NzbDrone.Common.Extensions; + +namespace Lidarr.Api.V1.Profiles.Quality +{ + public static class QualityItemsValidator + { + public static IRuleBuilderOptions> ValidItems(this IRuleBuilder> ruleBuilder) + { + ruleBuilder.SetValidator(new NotEmptyValidator(null)); + ruleBuilder.SetValidator(new AllowedValidator()); + ruleBuilder.SetValidator(new QualityNameValidator()); + ruleBuilder.SetValidator(new GroupItemValidator()); + ruleBuilder.SetValidator(new ItemGroupIdValidator()); + ruleBuilder.SetValidator(new UniqueIdValidator()); + ruleBuilder.SetValidator(new UniqueQualityIdValidator()); + return ruleBuilder.SetValidator(new ItemGroupNameValidator()); + } + } + + public class AllowedValidator : PropertyValidator + { + public AllowedValidator() + : base("Must contain at least one allowed quality") + { + + } + + protected override bool IsValid(PropertyValidatorContext context) + { + var list = context.PropertyValue as IList; + + if (list == null) + { + return false; + } + + if (!list.Any(c => c.Allowed)) + { + return false; + } + + return true; + } + } + + public class GroupItemValidator : PropertyValidator + { + public GroupItemValidator() + : base("Groups must contain multiple qualities") + { + + } + + protected override bool IsValid(PropertyValidatorContext context) + { + var items = context.PropertyValue as IList; + + if (items.Any(i => i.Name.IsNotNullOrWhiteSpace() && i.Items.Count <= 1)) + { + return false; + } + + return true; + } + } + + public class QualityNameValidator : PropertyValidator + { + public QualityNameValidator() + : base("Individual qualities should not be named") + { + + } + + protected override bool IsValid(PropertyValidatorContext context) + { + var items = context.PropertyValue as IList; + + if (items.Any(i => i.Name.IsNotNullOrWhiteSpace() && i.Quality != null)) + { + return false; + } + + return true; + } + } + + public class ItemGroupNameValidator : PropertyValidator + { + public ItemGroupNameValidator() + : base("Groups must have a name") + { + + } + + protected override bool IsValid(PropertyValidatorContext context) + { + var items = context.PropertyValue as IList; + + if (items.Any(i => i.Quality == null && i.Name.IsNullOrWhiteSpace())) + { + return false; + } + + return true; + } + } + + public class ItemGroupIdValidator : PropertyValidator + { + public ItemGroupIdValidator() + : base("Groups must have an ID") + { + + } + + protected override bool IsValid(PropertyValidatorContext context) + { + var items = context.PropertyValue as IList; + + if (items.Any(i => i.Quality == null && i.Id == 0)) + { + return false; + } + + return true; + } + } + + public class UniqueIdValidator : PropertyValidator + { + public UniqueIdValidator() + : base("Groups must have a unique ID") + { + + } + + protected override bool IsValid(PropertyValidatorContext context) + { + var items = context.PropertyValue as IList; + + if (items.Where(i => i.Id > 0).Select(i => i.Id).GroupBy(i => i).Any(g => g.Count() > 1)) + { + return false; + } + + return true; + } + } + + public class UniqueQualityIdValidator : PropertyValidator + { + public UniqueQualityIdValidator() + : base("Qualities can only be used once") + { + + } + + protected override bool IsValid(PropertyValidatorContext context) + { + var items = context.PropertyValue as IList; + var qualityIds = new HashSet(); + + foreach (var item in items) + { + if (item.Id > 0) + { + foreach (var quality in item.Items) + { + if (qualityIds.Contains(quality.Quality.Id)) + { + return false; + } + + qualityIds.Add(quality.Quality.Id); + } + } + + else + { + if (qualityIds.Contains(item.Quality.Id)) + { + return false; + } + + qualityIds.Add(item.Quality.Id); + } + } + + return true; + } + } +} diff --git a/src/Lidarr.Api.V1/Profiles/Quality/QualityProfileModule.cs b/src/Lidarr.Api.V1/Profiles/Quality/QualityProfileModule.cs new file mode 100644 index 000000000..dbb8b5b63 --- /dev/null +++ b/src/Lidarr.Api.V1/Profiles/Quality/QualityProfileModule.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using FluentValidation; +using NzbDrone.Core.Profiles.Qualities; +using Lidarr.Http; + +namespace Lidarr.Api.V1.Profiles.Quality +{ + public class ProfileModule : LidarrRestModule + { + private readonly IProfileService _profileService; + + public ProfileModule(IProfileService profileService) + { + _profileService = profileService; + SharedValidator.RuleFor(c => c.Name).NotEmpty(); + SharedValidator.RuleFor(c => c.Cutoff).ValidCutoff(); + SharedValidator.RuleFor(c => c.Items).ValidItems(); + + GetResourceAll = GetAll; + GetResourceById = GetById; + UpdateResource = Update; + CreateResource = Create; + DeleteResource = DeleteProfile; + } + + private int Create(QualityProfileResource resource) + { + var model = resource.ToModel(); + model = _profileService.Add(model); + return model.Id; + } + + private void DeleteProfile(int id) + { + _profileService.Delete(id); + } + + private void Update(QualityProfileResource resource) + { + var model = resource.ToModel(); + + _profileService.Update(model); + } + + private QualityProfileResource GetById(int id) + { + return _profileService.Get(id).ToResource(); + } + + private List GetAll() + { + return _profileService.All().ToResource(); + } + } +} diff --git a/src/Lidarr.Api.V1/Profiles/Quality/QualityProfileResource.cs b/src/Lidarr.Api.V1/Profiles/Quality/QualityProfileResource.cs new file mode 100644 index 000000000..a50bc0fd8 --- /dev/null +++ b/src/Lidarr.Api.V1/Profiles/Quality/QualityProfileResource.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Profiles.Qualities; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.Profiles.Quality +{ + public class QualityProfileResource : RestResource + { + public string Name { get; set; } + public bool UpgradeAllowed { get; set; } + public int Cutoff { get; set; } + public List Items { get; set; } + } + + public class QualityProfileQualityItemResource : RestResource + { + public string Name { get; set; } + public NzbDrone.Core.Qualities.Quality Quality { get; set; } + public List Items { get; set; } + public bool Allowed { get; set; } + + public QualityProfileQualityItemResource() + { + Items = new List(); + } + } + + public static class ProfileResourceMapper + { + public static QualityProfileResource ToResource(this QualityProfile model) + { + if (model == null) return null; + + return new QualityProfileResource + { + Id = model.Id, + Name = model.Name, + UpgradeAllowed = model.UpgradeAllowed, + Cutoff = model.Cutoff, + Items = model.Items.ConvertAll(ToResource) + }; + } + + public static QualityProfileQualityItemResource ToResource(this QualityProfileQualityItem model) + { + if (model == null) return null; + + return new QualityProfileQualityItemResource + { + Id = model.Id, + Name = model.Name, + Quality = model.Quality, + Items = model.Items.ConvertAll(ToResource), + Allowed = model.Allowed + }; + } + + public static QualityProfile ToModel(this QualityProfileResource resource) + { + if (resource == null) return null; + + return new QualityProfile + { + Id = resource.Id, + Name = resource.Name, + UpgradeAllowed = resource.UpgradeAllowed, + Cutoff = resource.Cutoff, + Items = resource.Items.ConvertAll(ToModel) + }; + } + + public static QualityProfileQualityItem ToModel(this QualityProfileQualityItemResource resource) + { + if (resource == null) return null; + + return new QualityProfileQualityItem + { + Id = resource.Id, + Name = resource.Name, + Quality = resource.Quality != null ? (NzbDrone.Core.Qualities.Quality)resource.Quality.Id : null, + Items = resource.Items.ConvertAll(ToModel), + Allowed = resource.Allowed + }; + } + + public static List ToResource(this IEnumerable models) + { + return models.Select(ToResource).ToList(); + } + } +} diff --git a/src/Lidarr.Api.V1/Profiles/Quality/QualityProfileSchemaModule.cs b/src/Lidarr.Api.V1/Profiles/Quality/QualityProfileSchemaModule.cs new file mode 100644 index 000000000..3436d935b --- /dev/null +++ b/src/Lidarr.Api.V1/Profiles/Quality/QualityProfileSchemaModule.cs @@ -0,0 +1,25 @@ +using NzbDrone.Core.Profiles.Qualities; +using Lidarr.Http; + +namespace Lidarr.Api.V1.Profiles.Quality +{ + public class QualityProfileSchemaModule : LidarrRestModule + { + private readonly IProfileService _profileService; + + public QualityProfileSchemaModule(IProfileService profileService) + : base("/qualityprofile/schema") + { + _profileService = profileService; + GetResourceSingle = GetSchema; + } + + private QualityProfileResource GetSchema() + { + QualityProfile qualityProfile = _profileService.GetDefaultProfile(string.Empty); + + + return qualityProfile.ToResource(); + } + } +} diff --git a/src/Lidarr.Api.V1/Profiles/Release/ReleaseProfileModule.cs b/src/Lidarr.Api.V1/Profiles/Release/ReleaseProfileModule.cs new file mode 100644 index 000000000..ae3433e41 --- /dev/null +++ b/src/Lidarr.Api.V1/Profiles/Release/ReleaseProfileModule.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using FluentValidation.Results; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Profiles.Releases; +using Lidarr.Http; + +namespace Lidarr.Api.V1.Profiles.Release +{ + public class ReleaseProfileModule : LidarrRestModule + { + private readonly IReleaseProfileService _releaseProfileService; + + + public ReleaseProfileModule(IReleaseProfileService releaseProfileService) + { + _releaseProfileService = releaseProfileService; + + GetResourceById = Get; + GetResourceAll = GetAll; + CreateResource = Create; + UpdateResource = Update; + DeleteResource = Delete; + + SharedValidator.Custom(restriction => + { + if (restriction.Ignored.IsNullOrWhiteSpace() && restriction.Required.IsNullOrWhiteSpace() && restriction.Preferred.Empty()) + { + return new ValidationFailure("", "'Must contain', 'Must not contain' or 'Preferred' is required"); + } + + return null; + }); + } + + private ReleaseProfileResource Get(int id) + { + return _releaseProfileService.Get(id).ToResource(); + } + + private List GetAll() + { + return _releaseProfileService.All().ToResource(); + } + + private int Create(ReleaseProfileResource resource) + { + return _releaseProfileService.Add(resource.ToModel()).Id; + } + + private void Update(ReleaseProfileResource resource) + { + _releaseProfileService.Update(resource.ToModel()); + } + + private void Delete(int id) + { + _releaseProfileService.Delete(id); + } + } +} diff --git a/src/Lidarr.Api.V1/Profiles/Release/ReleaseProfileResource.cs b/src/Lidarr.Api.V1/Profiles/Release/ReleaseProfileResource.cs new file mode 100644 index 000000000..e7af27941 --- /dev/null +++ b/src/Lidarr.Api.V1/Profiles/Release/ReleaseProfileResource.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Profiles.Releases; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.Profiles.Release +{ + public class ReleaseProfileResource : RestResource + { + public string Required { get; set; } + public string Ignored { get; set; } + public List> Preferred { get; set; } + public bool IncludePreferredWhenRenaming { get; set; } + public HashSet Tags { get; set; } + + public ReleaseProfileResource() + { + Tags = new HashSet(); + } + } + + public static class RestrictionResourceMapper + { + public static ReleaseProfileResource ToResource(this ReleaseProfile model) + { + if (model == null) return null; + + return new ReleaseProfileResource + { + Id = model.Id, + + Required = model.Required, + Ignored = model.Ignored, + Preferred = model.Preferred, + IncludePreferredWhenRenaming = model.IncludePreferredWhenRenaming, + Tags = new HashSet(model.Tags) + }; + } + + public static ReleaseProfile ToModel(this ReleaseProfileResource resource) + { + if (resource == null) return null; + + return new ReleaseProfile + { + Id = resource.Id, + + Required = resource.Required, + Ignored = resource.Ignored, + Preferred = resource.Preferred, + IncludePreferredWhenRenaming = resource.IncludePreferredWhenRenaming, + Tags = new HashSet(resource.Tags) + }; + } + + public static List ToResource(this IEnumerable models) + { + return models.Select(ToResource).ToList(); + } + } +} diff --git a/src/Lidarr.Api.V1/ProviderModuleBase.cs b/src/Lidarr.Api.V1/ProviderModuleBase.cs new file mode 100644 index 000000000..42775f031 --- /dev/null +++ b/src/Lidarr.Api.V1/ProviderModuleBase.cs @@ -0,0 +1,215 @@ +using System.Collections.Generic; +using System.Linq; +using FluentValidation; +using FluentValidation.Results; +using Nancy; +using Newtonsoft.Json; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; +using Lidarr.Http; +using Lidarr.Http.Extensions; + +namespace Lidarr.Api.V1 +{ + public abstract class ProviderModuleBase : LidarrRestModule + where TProviderDefinition : ProviderDefinition, new() + where TProvider : IProvider + where TProviderResource : ProviderResource, new() + { + private readonly IProviderFactory _providerFactory; + private readonly ProviderResourceMapper _resourceMapper; + + protected ProviderModuleBase(IProviderFactory providerFactory, string resource, ProviderResourceMapper resourceMapper) + : base(resource) + { + _providerFactory = providerFactory; + _resourceMapper = resourceMapper; + + Get["schema"] = x => GetTemplates(); + Post["test"] = x => Test(ReadResourceFromRequest(true)); + Post["testall"] = x => TestAll(); + Post["action/{action}"] = x => RequestAction(x.action, ReadResourceFromRequest(true)); + + GetResourceAll = GetAll; + GetResourceById = GetProviderById; + CreateResource = CreateProvider; + UpdateResource = UpdateProvider; + DeleteResource = DeleteProvider; + + SharedValidator.RuleFor(c => c.Name).NotEmpty(); + SharedValidator.RuleFor(c => c.Name).Must((v,c) => !_providerFactory.All().Any(p => p.Name == c && p.Id != v.Id)).WithMessage("Should be unique"); + SharedValidator.RuleFor(c => c.Implementation).NotEmpty(); + SharedValidator.RuleFor(c => c.ConfigContract).NotEmpty(); + + PostValidator.RuleFor(c => c.Fields).NotNull(); + } + + private TProviderResource GetProviderById(int id) + { + var definition = _providerFactory.Get(id); + _providerFactory.SetProviderCharacteristics(definition); + + return _resourceMapper.ToResource(definition); + } + + private List GetAll() + { + var providerDefinitions = _providerFactory.All().OrderBy(p => p.ImplementationName); + + var result = new List(providerDefinitions.Count()); + + foreach (var definition in providerDefinitions) + { + _providerFactory.SetProviderCharacteristics(definition); + + result.Add(_resourceMapper.ToResource(definition)); + } + + return result.OrderBy(p => p.Name).ToList(); + } + + private int CreateProvider(TProviderResource providerResource) + { + var providerDefinition = GetDefinition(providerResource, false); + + if (providerDefinition.Enable) + { + Test(providerDefinition, false); + } + + providerDefinition = _providerFactory.Create(providerDefinition); + + return providerDefinition.Id; + } + + private void UpdateProvider(TProviderResource providerResource) + { + var providerDefinition = GetDefinition(providerResource, false); + + if (providerDefinition.Enable) + { + Test(providerDefinition, false); + } + + _providerFactory.Update(providerDefinition); + } + + private TProviderDefinition GetDefinition(TProviderResource providerResource, bool includeWarnings = false, bool validate = true) + { + var definition = _resourceMapper.ToModel(providerResource); + + if (validate) + { + Validate(definition, includeWarnings); + } + + return definition; + } + + private void DeleteProvider(int id) + { + _providerFactory.Delete(id); + } + + private Response GetTemplates() + { + var defaultDefinitions = _providerFactory.GetDefaultDefinitions().OrderBy(p => p.ImplementationName).ToList(); + + var result = new List(defaultDefinitions.Count()); + + foreach (var providerDefinition in defaultDefinitions) + { + var providerResource = _resourceMapper.ToResource(providerDefinition); + var presetDefinitions = _providerFactory.GetPresetDefinitions(providerDefinition); + + providerResource.Presets = presetDefinitions.Select(v => + { + var presetResource = _resourceMapper.ToResource(v); + + return presetResource as ProviderResource; + }).ToList(); + + result.Add(providerResource); + } + + return result.AsResponse(); + } + + private Response Test(TProviderResource providerResource) + { + var providerDefinition = GetDefinition(providerResource, true); + + Test(providerDefinition, true); + + return "{}"; + } + + private Response TestAll() + { + var providerDefinitions = _providerFactory.All() + .Where(c => c.Settings.Validate().IsValid && c.Enable) + .ToList(); + var result = new List(); + + foreach (var definition in providerDefinitions) + { + var validationResult = _providerFactory.Test(definition); + + result.Add(new ProviderTestAllResult + { + Id = definition.Id, + ValidationFailures = validationResult.Errors.ToList() + }); + } + + return result.AsResponse(result.Any(c => !c.IsValid) ? HttpStatusCode.BadRequest : HttpStatusCode.OK); + } + + private Response RequestAction(string action, TProviderResource providerResource) + { + var providerDefinition = GetDefinition(providerResource, true, false); + + var query = ((IDictionary)Request.Query.ToDictionary()).ToDictionary(k => k.Key, k => k.Value.ToString()); + + var data = _providerFactory.RequestAction(providerDefinition, action, query); + Response resp = data.ToJson(); + resp.ContentType = "application/json"; + return resp; + } + + protected virtual void Validate(TProviderDefinition definition, bool includeWarnings) + { + var validationResult = definition.Settings.Validate(); + + VerifyValidationResult(validationResult, includeWarnings); + } + + protected virtual void Test(TProviderDefinition definition, bool includeWarnings) + { + var validationResult = _providerFactory.Test(definition); + + VerifyValidationResult(validationResult, includeWarnings); + } + + protected void VerifyValidationResult(ValidationResult validationResult, bool includeWarnings) + { + var result = validationResult as NzbDroneValidationResult; + + if (result == null) + { + result = new NzbDroneValidationResult(validationResult.Errors); + } + + if (includeWarnings && (!result.IsValid || result.HasWarnings)) + { + throw new ValidationException(result.Failures); + } + + if (!result.IsValid) + { + throw new ValidationException(result.Errors); + } + } + } +} diff --git a/src/Lidarr.Api.V1/ProviderResource.cs b/src/Lidarr.Api.V1/ProviderResource.cs new file mode 100644 index 000000000..428b3717e --- /dev/null +++ b/src/Lidarr.Api.V1/ProviderResource.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using NzbDrone.Common.Reflection; +using NzbDrone.Core.ThingiProvider; +using Lidarr.Http.ClientSchema; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1 +{ + public class ProviderResource : RestResource + { + public string Name { get; set; } + public List Fields { get; set; } + public string ImplementationName { get; set; } + public string Implementation { get; set; } + public string ConfigContract { get; set; } + public string InfoLink { get; set; } + public ProviderMessage Message { get; set; } + public HashSet Tags { get; set; } + + public List Presets { get; set; } + } + + public class ProviderResourceMapper + where TProviderResource : ProviderResource, new() + where TProviderDefinition : ProviderDefinition, new() + { + public virtual TProviderResource ToResource(TProviderDefinition definition) + + { + return new TProviderResource + { + Id = definition.Id, + + Name = definition.Name, + ImplementationName = definition.ImplementationName, + Implementation = definition.Implementation, + ConfigContract = definition.ConfigContract, + Message = definition.Message, + Tags = definition.Tags, + Fields = SchemaBuilder.ToSchema(definition.Settings), + + InfoLink = string.Format("https://github.com/Lidarr/Lidarr/wiki/Supported-{0}#{1}", + typeof(TProviderResource).Name.Replace("Resource", "s"), + definition.Implementation.ToLower()) + }; + } + + public virtual TProviderDefinition ToModel(TProviderResource resource) + { + if (resource == null) return default(TProviderDefinition); + + var definition = new TProviderDefinition + { + Id = resource.Id, + + Name = resource.Name, + ImplementationName = resource.ImplementationName, + Implementation = resource.Implementation, + ConfigContract = resource.ConfigContract, + Message = resource.Message, + Tags = resource.Tags + }; + + var configContract = ReflectionExtensions.CoreAssembly.FindTypeByName(definition.ConfigContract); + definition.Settings = (IProviderConfig)SchemaBuilder.ReadFromSchema(resource.Fields, configContract); + + return definition; + } + } +} diff --git a/src/Lidarr.Api.V1/ProviderTestAllResult.cs b/src/Lidarr.Api.V1/ProviderTestAllResult.cs new file mode 100644 index 000000000..ad96a3796 --- /dev/null +++ b/src/Lidarr.Api.V1/ProviderTestAllResult.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using FluentValidation.Results; +using NzbDrone.Common.Extensions; + +namespace Lidarr.Api.V1 +{ + public class ProviderTestAllResult + { + public int Id { get; set; } + public bool IsValid => ValidationFailures.Empty(); + public List ValidationFailures { get; set; } + + public ProviderTestAllResult() + { + ValidationFailures = new List(); + } + } +} diff --git a/src/Lidarr.Api.V1/Qualities/QualityDefinitionModule.cs b/src/Lidarr.Api.V1/Qualities/QualityDefinitionModule.cs new file mode 100644 index 000000000..4e735371d --- /dev/null +++ b/src/Lidarr.Api.V1/Qualities/QualityDefinitionModule.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Linq; +using Nancy; +using NzbDrone.Core.Qualities; +using Lidarr.Http; +using Lidarr.Http.Extensions; + +namespace Lidarr.Api.V1.Qualities +{ + public class QualityDefinitionModule : LidarrRestModule + { + private readonly IQualityDefinitionService _qualityDefinitionService; + + public QualityDefinitionModule(IQualityDefinitionService qualityDefinitionService) + { + _qualityDefinitionService = qualityDefinitionService; + + GetResourceAll = GetAll; + GetResourceById = GetById; + UpdateResource = Update; + Put["/update"] = d => UpdateMany(); + } + + private void Update(QualityDefinitionResource resource) + { + var model = resource.ToModel(); + _qualityDefinitionService.Update(model); + } + + private QualityDefinitionResource GetById(int id) + { + return _qualityDefinitionService.GetById(id).ToResource(); + } + + private List GetAll() + { + return _qualityDefinitionService.All().ToResource(); + } + + private Response UpdateMany() + { + //Read from request + var qualityDefinitions = Request.Body.FromJson>() + .ToModel() + .ToList(); + + _qualityDefinitionService.UpdateMany(qualityDefinitions); + + return _qualityDefinitionService.All() + .ToResource() + .AsResponse(HttpStatusCode.Accepted); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Api/Qualities/QualityDefinitionResource.cs b/src/Lidarr.Api.V1/Qualities/QualityDefinitionResource.cs similarity index 86% rename from src/NzbDrone.Api/Qualities/QualityDefinitionResource.cs rename to src/Lidarr.Api.V1/Qualities/QualityDefinitionResource.cs index ea0edc0ab..67c7b0822 100644 --- a/src/NzbDrone.Api/Qualities/QualityDefinitionResource.cs +++ b/src/Lidarr.Api.V1/Qualities/QualityDefinitionResource.cs @@ -1,9 +1,9 @@ using System.Collections.Generic; using System.Linq; -using NzbDrone.Api.REST; using NzbDrone.Core.Qualities; +using Lidarr.Http.REST; -namespace NzbDrone.Api.Qualities +namespace Lidarr.Api.V1.Qualities { public class QualityDefinitionResource : RestResource { @@ -26,13 +26,9 @@ namespace NzbDrone.Api.Qualities return new QualityDefinitionResource { Id = model.Id, - Quality = model.Quality, - Title = model.Title, - Weight = model.Weight, - MinSize = model.MinSize, MaxSize = model.MaxSize }; @@ -45,13 +41,9 @@ namespace NzbDrone.Api.Qualities return new QualityDefinition { Id = resource.Id, - Quality = resource.Quality, - Title = resource.Title, - Weight = resource.Weight, - MinSize = resource.MinSize, MaxSize = resource.MaxSize }; @@ -61,5 +53,10 @@ namespace NzbDrone.Api.Qualities { return models.Select(ToResource).ToList(); } + + public static List ToModel(this IEnumerable resources) + { + return resources.Select(ToModel).ToList(); + } } } \ No newline at end of file diff --git a/src/Lidarr.Api.V1/Queue/QueueActionModule.cs b/src/Lidarr.Api.V1/Queue/QueueActionModule.cs new file mode 100644 index 000000000..a37165613 --- /dev/null +++ b/src/Lidarr.Api.V1/Queue/QueueActionModule.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using Nancy; +using NzbDrone.Core.Download; +using NzbDrone.Core.Download.Pending; +using NzbDrone.Core.Download.TrackedDownloads; +using NzbDrone.Core.Queue; +using Lidarr.Http; +using Lidarr.Http.Extensions; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.Queue +{ + public class QueueActionModule : LidarrRestModule + { + private readonly IQueueService _queueService; + private readonly ITrackedDownloadService _trackedDownloadService; + private readonly IFailedDownloadService _failedDownloadService; + private readonly IProvideDownloadClient _downloadClientProvider; + private readonly IPendingReleaseService _pendingReleaseService; + private readonly IDownloadService _downloadService; + + public QueueActionModule(IQueueService queueService, + ITrackedDownloadService trackedDownloadService, + IFailedDownloadService failedDownloadService, + IProvideDownloadClient downloadClientProvider, + IPendingReleaseService pendingReleaseService, + IDownloadService downloadService) + { + _queueService = queueService; + _trackedDownloadService = trackedDownloadService; + _failedDownloadService = failedDownloadService; + _downloadClientProvider = downloadClientProvider; + _pendingReleaseService = pendingReleaseService; + _downloadService = downloadService; + + Post[@"/grab/(?[\d]{1,10})"] = x => Grab((int)x.Id); + Post["/grab/bulk"] = x => Grab(); + + Delete[@"/(?[\d]{1,10})"] = x => Remove((int)x.Id); + Delete["/bulk"] = x => Remove(); + } + + private Response Grab(int id) + { + var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id); + + if (pendingRelease == null) + { + throw new NotFoundException(); + } + + _downloadService.DownloadReport(pendingRelease.RemoteAlbum); + + return new object().AsResponse(); + } + + private Response Grab() + { + var resource = Request.Body.FromJson(); + + foreach (var id in resource.Ids) + { + var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id); + + if (pendingRelease == null) + { + throw new NotFoundException(); + } + + _downloadService.DownloadReport(pendingRelease.RemoteAlbum); + } + + return new object().AsResponse(); + } + + private Response Remove(int id) + { + var blacklist = Request.GetBooleanQueryParameter("blacklist"); + var skipReDownload = Request.GetBooleanQueryParameter("skipredownload"); + + var trackedDownload = Remove(id, blacklist, skipReDownload); + + if (trackedDownload != null) + { + _trackedDownloadService.StopTracking(trackedDownload.DownloadItem.DownloadId); + } + + return new object().AsResponse(); + } + + private Response Remove() + { + var blacklist = Request.GetBooleanQueryParameter("blacklist"); + var skipReDownload = Request.GetBooleanQueryParameter("skipredownload"); + + var resource = Request.Body.FromJson(); + var trackedDownloadIds = new List(); + + foreach (var id in resource.Ids) + { + var trackedDownload = Remove(id, blacklist, skipReDownload); + + if (trackedDownload != null) + { + trackedDownloadIds.Add(trackedDownload.DownloadItem.DownloadId); + } + } + + _trackedDownloadService.StopTracking(trackedDownloadIds); + + return new object().AsResponse(); + } + + private TrackedDownload Remove(int id, bool blacklist, bool skipReDownload) + { + var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id); + + if (pendingRelease != null) + { + _pendingReleaseService.RemovePendingQueueItems(pendingRelease.Id); + + return null; + } + + var trackedDownload = GetTrackedDownload(id); + + if (trackedDownload == null) + { + throw new NotFoundException(); + } + + var downloadClient = _downloadClientProvider.Get(trackedDownload.DownloadClient); + + if (downloadClient == null) + { + throw new BadRequestException(); + } + + downloadClient.RemoveItem(trackedDownload.DownloadItem.DownloadId, true); + + if (blacklist) + { + _failedDownloadService.MarkAsFailed(trackedDownload.DownloadItem.DownloadId, skipReDownload); + } + + return trackedDownload; + } + + private TrackedDownload GetTrackedDownload(int queueId) + { + var queueItem = _queueService.Find(queueId); + + if (queueItem == null) + { + throw new NotFoundException(); + } + + var trackedDownload = _trackedDownloadService.Find(queueItem.DownloadId); + + if (trackedDownload == null) + { + throw new NotFoundException(); + } + + return trackedDownload; + } + } +} diff --git a/src/Lidarr.Api.V1/Queue/QueueBulkResource.cs b/src/Lidarr.Api.V1/Queue/QueueBulkResource.cs new file mode 100644 index 000000000..12c86b964 --- /dev/null +++ b/src/Lidarr.Api.V1/Queue/QueueBulkResource.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace Lidarr.Api.V1.Queue +{ + public class QueueBulkResource + { + public List Ids { get; set; } + } +} diff --git a/src/Lidarr.Api.V1/Queue/QueueDetailsModule.cs b/src/Lidarr.Api.V1/Queue/QueueDetailsModule.cs new file mode 100644 index 000000000..72c90f1f5 --- /dev/null +++ b/src/Lidarr.Api.V1/Queue/QueueDetailsModule.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Datastore.Events; +using NzbDrone.Core.Download.Pending; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Queue; +using NzbDrone.SignalR; +using Lidarr.Http; +using Lidarr.Http.Extensions; + +namespace Lidarr.Api.V1.Queue +{ + public class QueueDetailsModule : LidarrRestModuleWithSignalR, + IHandle, IHandle + { + private readonly IQueueService _queueService; + private readonly IPendingReleaseService _pendingReleaseService; + + public QueueDetailsModule(IBroadcastSignalRMessage broadcastSignalRMessage, IQueueService queueService, IPendingReleaseService pendingReleaseService) + : base(broadcastSignalRMessage, "queue/details") + { + _queueService = queueService; + _pendingReleaseService = pendingReleaseService; + GetResourceAll = GetQueue; + } + + private List GetQueue() + { + var includeArtist = Request.GetBooleanQueryParameter("includeArtist"); + var includeAlbum = Request.GetBooleanQueryParameter("includeAlbum", true); + var queue = _queueService.GetQueue(); + var pending = _pendingReleaseService.GetPendingQueue(); + var fullQueue = queue.Concat(pending); + + var artistIdQuery = Request.Query.ArtistId; + var albumIdsQuery = Request.Query.AlbumIds; + + if (artistIdQuery.HasValue) + { + return fullQueue.Where(q => q.Artist?.Id == (int)artistIdQuery).ToResource(includeArtist, includeAlbum); + } + + if (albumIdsQuery.HasValue) + { + string albumIdsValue = albumIdsQuery.Value.ToString(); + + var albumIds = albumIdsValue.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(e => Convert.ToInt32(e)) + .ToList(); + + return fullQueue.Where(q => q.Album != null && albumIds.Contains(q.Album.Id)).ToResource(includeArtist, includeAlbum); + } + + return fullQueue.ToResource(includeArtist, includeAlbum); + } + + public void Handle(QueueUpdatedEvent message) + { + BroadcastResourceChange(ModelAction.Sync); + } + + public void Handle(PendingReleasesUpdatedEvent message) + { + BroadcastResourceChange(ModelAction.Sync); + } + } +} diff --git a/src/Lidarr.Api.V1/Queue/QueueModule.cs b/src/Lidarr.Api.V1/Queue/QueueModule.cs new file mode 100644 index 000000000..94c25ecea --- /dev/null +++ b/src/Lidarr.Api.V1/Queue/QueueModule.cs @@ -0,0 +1,163 @@ +using System; +using System.Linq; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Datastore.Events; +using NzbDrone.Core.Download.Pending; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Profiles.Qualities; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Queue; +using NzbDrone.SignalR; +using Lidarr.Http; +using Lidarr.Http.Extensions; + +namespace Lidarr.Api.V1.Queue +{ + public class QueueModule : LidarrRestModuleWithSignalR, + IHandle, IHandle + { + private readonly IQueueService _queueService; + private readonly IPendingReleaseService _pendingReleaseService; + + private readonly QualityModelComparer QUALITY_COMPARER; + + public QueueModule(IBroadcastSignalRMessage broadcastSignalRMessage, + IQueueService queueService, + IPendingReleaseService pendingReleaseService, + QualityProfileService qualityProfileService) + : base(broadcastSignalRMessage) + { + _queueService = queueService; + _pendingReleaseService = pendingReleaseService; + GetResourcePaged = GetQueue; + + QUALITY_COMPARER = new QualityModelComparer(qualityProfileService.GetDefaultProfile(string.Empty)); + } + + private PagingResource GetQueue(PagingResource pagingResource) + { + var pagingSpec = pagingResource.MapToPagingSpec("timeleft", SortDirection.Ascending); + var includeUnknownArtistItems = Request.GetBooleanQueryParameter("includeUnknownArtistItems"); + var includeArtist = Request.GetBooleanQueryParameter("includeArtist"); + var includeAlbum = Request.GetBooleanQueryParameter("includeAlbum"); + + return ApplyToPage((spec) => GetQueue(spec, includeUnknownArtistItems), pagingSpec, (q) => MapToResource(q, includeArtist, includeAlbum)); + } + + private PagingSpec GetQueue(PagingSpec pagingSpec, bool includeUnknownArtistItems) + { + var ascending = pagingSpec.SortDirection == SortDirection.Ascending; + var orderByFunc = GetOrderByFunc(pagingSpec); + + var queue = _queueService.GetQueue(); + var filteredQueue = includeUnknownArtistItems ? queue : queue.Where(q => q.Artist != null); + var pending = _pendingReleaseService.GetPendingQueue(); + var fullQueue = filteredQueue.Concat(pending).ToList(); + IOrderedEnumerable ordered; + + if (pagingSpec.SortKey == "timeleft") + { + ordered = ascending + ? fullQueue.OrderBy(q => q.Timeleft, new TimeleftComparer()) + : fullQueue.OrderByDescending(q => q.Timeleft, new TimeleftComparer()); + } + + else if (pagingSpec.SortKey == "estimatedCompletionTime") + { + ordered = ascending + ? fullQueue.OrderBy(q => q.EstimatedCompletionTime, new EstimatedCompletionTimeComparer()) + : fullQueue.OrderByDescending(q => q.EstimatedCompletionTime, + new EstimatedCompletionTimeComparer()); + } + + else if (pagingSpec.SortKey == "protocol") + { + ordered = ascending + ? fullQueue.OrderBy(q => q.Protocol) + : fullQueue.OrderByDescending(q => q.Protocol); + } + + else if (pagingSpec.SortKey == "indexer") + { + ordered = ascending + ? fullQueue.OrderBy(q => q.Indexer, StringComparer.InvariantCultureIgnoreCase) + : fullQueue.OrderByDescending(q => q.Indexer, StringComparer.InvariantCultureIgnoreCase); + } + + else if (pagingSpec.SortKey == "downloadClient") + { + ordered = ascending + ? fullQueue.OrderBy(q => q.DownloadClient, StringComparer.InvariantCultureIgnoreCase) + : fullQueue.OrderByDescending(q => q.DownloadClient, StringComparer.InvariantCultureIgnoreCase); + } + + else if (pagingSpec.SortKey == "quality") + { + ordered = ascending + ? fullQueue.OrderBy(q => q.Quality, QUALITY_COMPARER) + : fullQueue.OrderByDescending(q => q.Quality, QUALITY_COMPARER); + } + + else + { + ordered = ascending ? fullQueue.OrderBy(orderByFunc) : fullQueue.OrderByDescending(orderByFunc); + } + + ordered = ordered.ThenByDescending(q => q.Size == 0 ? 0 : 100 - q.Sizeleft / q.Size * 100); + + pagingSpec.Records = ordered.Skip((pagingSpec.Page - 1) * pagingSpec.PageSize).Take(pagingSpec.PageSize).ToList(); + pagingSpec.TotalRecords = fullQueue.Count; + + if (pagingSpec.Records.Empty() && pagingSpec.Page > 1) + { + pagingSpec.Page = (int)Math.Max(Math.Ceiling((decimal)(pagingSpec.TotalRecords / pagingSpec.PageSize)), 1); + pagingSpec.Records = ordered.Skip((pagingSpec.Page - 1) * pagingSpec.PageSize).Take(pagingSpec.PageSize).ToList(); + } + + return pagingSpec; + } + + private Func GetOrderByFunc(PagingSpec pagingSpec) + { + switch (pagingSpec.SortKey) + { + case "status": + return q => q.Status; + case "artist.sortName": + return q => q.Artist?.SortName; + case "title": + return q => q.Title; + case "album": + return q => q.Album; + case "album.title": + return q => q.Album?.Title; + case "album.releaseDate": + return q => q.Album?.ReleaseDate; + case "quality": + return q => q.Quality; + case "progress": + // Avoid exploding if a download's size is 0 + return q => 100 - q.Sizeleft / Math.Max(q.Size * 100, 1); + default: + return q => q.Timeleft; + } + } + + private QueueResource MapToResource(NzbDrone.Core.Queue.Queue queueItem, bool includeArtist, bool includeAlbum) + { + return queueItem.ToResource(includeArtist, includeAlbum); + } + + public void Handle(QueueUpdatedEvent message) + { + BroadcastResourceChange(ModelAction.Sync); + } + + public void Handle(PendingReleasesUpdatedEvent message) + { + BroadcastResourceChange(ModelAction.Sync); + } + } +} diff --git a/src/Lidarr.Api.V1/Queue/QueueResource.cs b/src/Lidarr.Api.V1/Queue/QueueResource.cs new file mode 100644 index 000000000..c9913d7f6 --- /dev/null +++ b/src/Lidarr.Api.V1/Queue/QueueResource.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Download.TrackedDownloads; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Qualities; +using Lidarr.Api.V1.Albums; +using Lidarr.Api.V1.Artist; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.Queue +{ + public class QueueResource : RestResource + { + public int? ArtistId { get; set; } + public int? AlbumId { get; set; } + public ArtistResource Artist { get; set; } + public AlbumResource Album { get; set; } + public QualityModel Quality { get; set; } + public decimal Size { get; set; } + public string Title { get; set; } + public decimal Sizeleft { get; set; } + public TimeSpan? Timeleft { get; set; } + public DateTime? EstimatedCompletionTime { get; set; } + public string Status { get; set; } + public string TrackedDownloadStatus { get; set; } + public List StatusMessages { get; set; } + public string ErrorMessage { get; set; } + public string DownloadId { get; set; } + public DownloadProtocol Protocol { get; set; } + public string DownloadClient { get; set; } + public string Indexer { get; set; } + public string OutputPath { get; set; } + public bool DownloadForced { get; set; } + } + + public static class QueueResourceMapper + { + public static QueueResource ToResource(this NzbDrone.Core.Queue.Queue model, bool includeArtist, bool includeAlbum) + { + if (model == null) return null; + + return new QueueResource + { + Id = model.Id, + ArtistId = model.Artist?.Id, + AlbumId = model.Album?.Id, + Artist = includeArtist && model.Artist != null ? model.Artist.ToResource() : null, + Album = includeAlbum && model.Album != null ? model.Album.ToResource() : null, + Quality = model.Quality, + Size = model.Size, + Title = model.Title, + Sizeleft = model.Sizeleft, + Timeleft = model.Timeleft, + EstimatedCompletionTime = model.EstimatedCompletionTime, + Status = model.Status, + TrackedDownloadStatus = model.TrackedDownloadStatus, + StatusMessages = model.StatusMessages, + ErrorMessage = model.ErrorMessage, + DownloadId = model.DownloadId, + Protocol = model.Protocol, + DownloadClient = model.DownloadClient, + Indexer = model.Indexer, + OutputPath = model.OutputPath, + DownloadForced = model.DownloadForced + }; + } + + public static List ToResource(this IEnumerable models, bool includeArtist, bool includeAlbum) + { + return models.Select((m) => ToResource(m, includeArtist, includeAlbum)).ToList(); + } + } +} diff --git a/src/Lidarr.Api.V1/Queue/QueueStatusModule.cs b/src/Lidarr.Api.V1/Queue/QueueStatusModule.cs new file mode 100644 index 000000000..0ec95403b --- /dev/null +++ b/src/Lidarr.Api.V1/Queue/QueueStatusModule.cs @@ -0,0 +1,80 @@ +using System; +using System.Linq; +using Nancy.Responses; +using NzbDrone.Common.TPL; +using NzbDrone.Core.Datastore.Events; +using NzbDrone.Core.Download.Pending; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Queue; +using NzbDrone.SignalR; +using Lidarr.Http; +using Lidarr.Http.Extensions; + +namespace Lidarr.Api.V1.Queue +{ + public class QueueStatusModule : LidarrRestModuleWithSignalR, + IHandle, IHandle + { + private readonly IQueueService _queueService; + private readonly IPendingReleaseService _pendingReleaseService; + private readonly Debouncer _broadcastDebounce; + + + public QueueStatusModule(IBroadcastSignalRMessage broadcastSignalRMessage, IQueueService queueService, IPendingReleaseService pendingReleaseService) + : base(broadcastSignalRMessage, "queue/status") + { + _queueService = queueService; + _pendingReleaseService = pendingReleaseService; + + _broadcastDebounce = new Debouncer(BroadcastChange, TimeSpan.FromSeconds(5)); + + + Get["/"] = x => GetQueueStatusResponse(); + } + + private JsonResponse GetQueueStatusResponse() + { + return GetQueueStatus().AsResponse(); + } + + private QueueStatusResource GetQueueStatus() + { + _broadcastDebounce.Pause(); + + var queue = _queueService.GetQueue(); + var pending = _pendingReleaseService.GetPendingQueue(); + + var resource = new QueueStatusResource + { + TotalCount = queue.Count + pending.Count, + Count = queue.Count(q => q.Artist != null) + pending.Count, + UnknownCount = queue.Count(q => q.Artist == null), + Errors = queue.Any(q => q.Artist != null && q.TrackedDownloadStatus.Equals("Error", StringComparison.InvariantCultureIgnoreCase)), + Warnings = queue.Any(q => q.Artist != null && q.TrackedDownloadStatus.Equals("Warning", StringComparison.InvariantCultureIgnoreCase)), + UnknownErrors = queue.Any(q => q.Artist == null && q.TrackedDownloadStatus.Equals("Error", StringComparison.InvariantCultureIgnoreCase)), + UnknownWarnings = queue.Any(q => q.Artist == null && q.TrackedDownloadStatus.Equals("Warning", StringComparison.InvariantCultureIgnoreCase)) + }; + + _broadcastDebounce.Resume(); + + return resource; + } + + private void BroadcastChange() + { + BroadcastResourceChange(ModelAction.Updated, GetQueueStatus()); + } + + public void Handle(QueueUpdatedEvent message) + { + _broadcastDebounce.Execute(); + } + + public void Handle(PendingReleasesUpdatedEvent message) + { + _broadcastDebounce.Execute(); + } + + + } +} diff --git a/src/Lidarr.Api.V1/Queue/QueueStatusResource.cs b/src/Lidarr.Api.V1/Queue/QueueStatusResource.cs new file mode 100644 index 000000000..2015bf065 --- /dev/null +++ b/src/Lidarr.Api.V1/Queue/QueueStatusResource.cs @@ -0,0 +1,15 @@ +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.Queue +{ + public class QueueStatusResource : RestResource + { + public int TotalCount { get; set; } + public int Count { get; set; } + public int UnknownCount { get; set; } + public bool Errors { get; set; } + public bool Warnings { get; set; } + public bool UnknownErrors { get; set; } + public bool UnknownWarnings { get; set; } + } +} diff --git a/src/NzbDrone.Api/RemotePathMappings/RemotePathMappingModule.cs b/src/Lidarr.Api.V1/RemotePathMappings/RemotePathMappingModule.cs similarity index 93% rename from src/NzbDrone.Api/RemotePathMappings/RemotePathMappingModule.cs rename to src/Lidarr.Api.V1/RemotePathMappings/RemotePathMappingModule.cs index a61b5f7b3..542e90d5b 100644 --- a/src/NzbDrone.Api/RemotePathMappings/RemotePathMappingModule.cs +++ b/src/Lidarr.Api.V1/RemotePathMappings/RemotePathMappingModule.cs @@ -2,10 +2,11 @@ using FluentValidation; using NzbDrone.Core.RemotePathMappings; using NzbDrone.Core.Validation.Paths; +using Lidarr.Http; -namespace NzbDrone.Api.RemotePathMappings +namespace Lidarr.Api.V1.RemotePathMappings { - public class RemotePathMappingModule : NzbDroneRestModule + public class RemotePathMappingModule : LidarrRestModule { private readonly IRemotePathMappingService _remotePathMappingService; diff --git a/src/NzbDrone.Api/RemotePathMappings/RemotePathMappingResource.cs b/src/Lidarr.Api.V1/RemotePathMappings/RemotePathMappingResource.cs similarity index 95% rename from src/NzbDrone.Api/RemotePathMappings/RemotePathMappingResource.cs rename to src/Lidarr.Api.V1/RemotePathMappings/RemotePathMappingResource.cs index 60c01b682..1f4050274 100644 --- a/src/NzbDrone.Api/RemotePathMappings/RemotePathMappingResource.cs +++ b/src/Lidarr.Api.V1/RemotePathMappings/RemotePathMappingResource.cs @@ -1,9 +1,9 @@ using System.Collections.Generic; using System.Linq; -using NzbDrone.Api.REST; using NzbDrone.Core.RemotePathMappings; +using Lidarr.Http.REST; -namespace NzbDrone.Api.RemotePathMappings +namespace Lidarr.Api.V1.RemotePathMappings { public class RemotePathMappingResource : RestResource { diff --git a/src/NzbDrone.Api/RootFolders/RootFolderModule.cs b/src/Lidarr.Api.V1/RootFolders/RootFolderModule.cs similarity index 85% rename from src/NzbDrone.Api/RootFolders/RootFolderModule.cs rename to src/Lidarr.Api.V1/RootFolders/RootFolderModule.cs index e87e581de..0ee85e2b6 100644 --- a/src/NzbDrone.Api/RootFolders/RootFolderModule.cs +++ b/src/Lidarr.Api.V1/RootFolders/RootFolderModule.cs @@ -1,12 +1,13 @@ -using System.Collections.Generic; +using System.Collections.Generic; using FluentValidation; using NzbDrone.Core.RootFolders; using NzbDrone.Core.Validation.Paths; using NzbDrone.SignalR; +using Lidarr.Http; -namespace NzbDrone.Api.RootFolders +namespace Lidarr.Api.V1.RootFolders { - public class RootFolderModule : NzbDroneRestModuleWithSignalR + public class RootFolderModule : LidarrRestModuleWithSignalR { private readonly IRootFolderService _rootFolderService; @@ -14,10 +15,11 @@ namespace NzbDrone.Api.RootFolders IBroadcastSignalRMessage signalRBroadcaster, RootFolderValidator rootFolderValidator, PathExistsValidator pathExistsValidator, - DroneFactoryValidator droneFactoryValidator, MappedNetworkDriveValidator mappedNetworkDriveValidator, StartupFolderValidator startupFolderValidator, - FolderWritableValidator folderWritableValidator) + SystemFolderValidator systemFolderValidator, + FolderWritableValidator folderWritableValidator + ) : base(signalRBroadcaster) { _rootFolderService = rootFolderService; @@ -31,10 +33,10 @@ namespace NzbDrone.Api.RootFolders .Cascade(CascadeMode.StopOnFirstFailure) .IsValidPath() .SetValidator(rootFolderValidator) - .SetValidator(droneFactoryValidator) .SetValidator(mappedNetworkDriveValidator) .SetValidator(startupFolderValidator) .SetValidator(pathExistsValidator) + .SetValidator(systemFolderValidator) .SetValidator(folderWritableValidator); } @@ -60,4 +62,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/Lidarr.Api.V1/RootFolders/RootFolderResource.cs similarity index 86% rename from src/NzbDrone.Api/RootFolders/RootFolderResource.cs rename to src/Lidarr.Api.V1/RootFolders/RootFolderResource.cs index 86efef529..f0b176691 100644 --- a/src/NzbDrone.Api/RootFolders/RootFolderResource.cs +++ b/src/Lidarr.Api.V1/RootFolders/RootFolderResource.cs @@ -1,14 +1,15 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; -using NzbDrone.Api.REST; using NzbDrone.Core.RootFolders; +using Lidarr.Http.REST; -namespace NzbDrone.Api.RootFolders +namespace Lidarr.Api.V1.RootFolders { public class RootFolderResource : RestResource { 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/Lidarr.Api.V1/System/Backup/BackupModule.cs b/src/Lidarr.Api.V1/System/Backup/BackupModule.cs new file mode 100644 index 000000000..8a33bde7a --- /dev/null +++ b/src/Lidarr.Api.V1/System/Backup/BackupModule.cs @@ -0,0 +1,134 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Nancy; +using NzbDrone.Common.Crypto; +using NzbDrone.Common.Disk; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Backup; +using Lidarr.Http; +using Lidarr.Http.Extensions; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.System.Backup +{ + public class BackupModule : LidarrRestModule + { + private readonly IBackupService _backupService; + private readonly IAppFolderInfo _appFolderInfo; + private readonly IDiskProvider _diskProvider; + + private static readonly List ValidExtensions = new List { ".zip", ".db", ".xml" }; + + public BackupModule(IBackupService backupService, + IAppFolderInfo appFolderInfo, + IDiskProvider diskProvider) + : base("system/backup") + { + _backupService = backupService; + _appFolderInfo = appFolderInfo; + _diskProvider = diskProvider; + GetResourceAll = GetBackupFiles; + DeleteResource = DeleteBackup; + + Post[@"/restore/(?[\d]{1,10})"] = x => Restore((int)x.Id); + Post["/restore/upload"] = x => UploadAndRestore(); + } + + public List GetBackupFiles() + { + var backups = _backupService.GetBackups(); + + return backups.Select(b => new BackupResource + { + Id = GetBackupId(b), + Name = b.Name, + Path = $"/backup/{b.Type.ToString().ToLower()}/{b.Name}", + Type = b.Type, + Time = b.Time + }) + .OrderByDescending(b => b.Time) + .ToList(); + } + + private void DeleteBackup(int id) + { + var backup = GetBackup(id); + var path = GetBackupPath(backup); + + if (!_diskProvider.FileExists(path)) + { + throw new NotFoundException(); + } + + _diskProvider.DeleteFile(path); + } + + public Response Restore(int id) + { + var backup = GetBackup(id); + + if (backup == null) + { + throw new NotFoundException(); + } + + var path = GetBackupPath(backup); + + _backupService.Restore(path); + + return new + { + RestartRequired = true + }.AsResponse(); + } + + public Response UploadAndRestore() + { + var files = Context.Request.Files.ToList(); + + if (files.Empty()) + { + throw new BadRequestException("file must be provided"); + } + + var file = files.First(); + var extension = Path.GetExtension(file.Name); + + if (!ValidExtensions.Contains(extension)) + { + throw new UnsupportedMediaTypeException($"Invalid extension, must be one of: {ValidExtensions.Join(", ")}"); + } + + var path = Path.Combine(_appFolderInfo.TempFolder, $"lidarr_backup_restore{extension}"); + + _diskProvider.SaveStream(file.Value, path); + _backupService.Restore(path); + + // Cleanup restored file + _diskProvider.DeleteFile(path); + + return new + { + RestartRequired = true + }.AsResponse(); + } + + private string GetBackupPath(NzbDrone.Core.Backup.Backup backup) + { + return Path.Combine(_backupService.GetBackupFolder(backup.Type), backup.Name); + + } + + private int GetBackupId(NzbDrone.Core.Backup.Backup backup) + { + return HashConverter.GetHashInt31($"backup-{backup.Type}-{backup.Name}"); + } + + private NzbDrone.Core.Backup.Backup GetBackup(int id) + { + return _backupService.GetBackups().SingleOrDefault(b => GetBackupId(b) == id); + } + } +} diff --git a/src/NzbDrone.Api/System/Backup/BackupResource.cs b/src/Lidarr.Api.V1/System/Backup/BackupResource.cs similarity index 81% rename from src/NzbDrone.Api/System/Backup/BackupResource.cs rename to src/Lidarr.Api.V1/System/Backup/BackupResource.cs index 7eac82838..cf5d895b1 100644 --- a/src/NzbDrone.Api/System/Backup/BackupResource.cs +++ b/src/Lidarr.Api.V1/System/Backup/BackupResource.cs @@ -1,8 +1,8 @@ using System; -using NzbDrone.Api.REST; using NzbDrone.Core.Backup; +using Lidarr.Http.REST; -namespace NzbDrone.Api.System.Backup +namespace Lidarr.Api.V1.System.Backup { public class BackupResource : RestResource { diff --git a/src/NzbDrone.Api/System/SystemModule.cs b/src/Lidarr.Api.V1/System/SystemModule.cs similarity index 80% rename from src/NzbDrone.Api/System/SystemModule.cs rename to src/Lidarr.Api.V1/System/SystemModule.cs index c62ed3b9e..0c6ad0268 100644 --- a/src/NzbDrone.Api/System/SystemModule.cs +++ b/src/Lidarr.Api.V1/System/SystemModule.cs @@ -1,15 +1,16 @@ -using Nancy; +using System.Threading.Tasks; +using Nancy; using Nancy.Routing; -using NzbDrone.Api.Extensions; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; using NzbDrone.Core.Datastore; using NzbDrone.Core.Lifecycle; +using Lidarr.Http.Extensions; -namespace NzbDrone.Api.System +namespace Lidarr.Api.V1.System { - public class SystemModule : NzbDroneApiModule + public class SystemModule : LidarrV1Module { private readonly IAppFolderInfo _appFolderInfo; private readonly IRuntimeInfo _runtimeInfo; @@ -27,7 +28,8 @@ namespace NzbDrone.Api.System IRouteCacheProvider routeCacheProvider, IConfigFileProvider configFileProvider, IMainDatabase database, - ILifecycleService lifecycleService) : base("system") + ILifecycleService lifecycleService) + : base("system") { _appFolderInfo = appFolderInfo; _runtimeInfo = runtimeInfo; @@ -62,12 +64,16 @@ namespace NzbDrone.Api.System IsLinux = OsInfo.IsLinux, IsOsx = OsInfo.IsOsx, IsWindows = OsInfo.IsWindows, + IsDocker = _osInfo.IsDocker, + Mode = _runtimeInfo.Mode, Branch = _configFileProvider.Branch, Authentication = _configFileProvider.AuthenticationMethod, SqliteVersion = _database.Version, + MigrationVersion = _database.Migration, UrlBase = _configFileProvider.UrlBase, RuntimeVersion = _platformInfo.Version, - RuntimeName = PlatformInfo.Platform + RuntimeName = PlatformInfo.Platform, + StartTime = _runtimeInfo.StartTime }.AsResponse(); } @@ -78,14 +84,14 @@ namespace NzbDrone.Api.System private Response Shutdown() { - _lifecycleService.Shutdown(); - return "".AsResponse(); + Task.Factory.StartNew(() => _lifecycleService.Shutdown()); + return new { ShuttingDown = true }.AsResponse(); } private Response Restart() { - _lifecycleService.Restart(); - return "".AsResponse(); + Task.Factory.StartNew(() => _lifecycleService.Restart()); + return new { Restarting = true }.AsResponse(); } } } diff --git a/src/Lidarr.Api.V1/System/Tasks/TaskModule.cs b/src/Lidarr.Api.V1/System/Tasks/TaskModule.cs new file mode 100644 index 000000000..819d341fc --- /dev/null +++ b/src/Lidarr.Api.V1/System/Tasks/TaskModule.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Datastore.Events; +using NzbDrone.Core.Jobs; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.SignalR; +using Lidarr.Http; + +namespace Lidarr.Api.V1.System.Tasks +{ + public class TaskModule : LidarrRestModuleWithSignalR, IHandle + { + private readonly ITaskManager _taskManager; + + public TaskModule(ITaskManager taskManager, IBroadcastSignalRMessage broadcastSignalRMessage) + : base(broadcastSignalRMessage, "system/task") + { + _taskManager = taskManager; + GetResourceAll = GetAll; + GetResourceById = GetTask; + } + + private List GetAll() + { + return _taskManager.GetAll() + .Select(ConvertToResource) + .OrderBy(t => t.Name) + .ToList(); + } + + private TaskResource GetTask(int id) + { + var task = _taskManager.GetAll() + .SingleOrDefault(t => t.Id == id); + + if (task == null) + { + return null; + } + + return ConvertToResource(task); + } + + private static TaskResource ConvertToResource(ScheduledTask scheduledTask) + { + var taskName = scheduledTask.TypeName.Split('.').Last().Replace("Command", ""); + + return new TaskResource + { + Id = scheduledTask.Id, + Name = taskName.SplitCamelCase(), + TaskName = taskName, + Interval = scheduledTask.Interval, + LastExecution = scheduledTask.LastExecution, + NextExecution = scheduledTask.LastExecution.AddMinutes(scheduledTask.Interval) + }; + } + + public void Handle(CommandExecutedEvent message) + { + BroadcastResourceChange(ModelAction.Sync); + } + } +} diff --git a/src/NzbDrone.Api/System/Tasks/TaskResource.cs b/src/Lidarr.Api.V1/System/Tasks/TaskResource.cs similarity index 83% rename from src/NzbDrone.Api/System/Tasks/TaskResource.cs rename to src/Lidarr.Api.V1/System/Tasks/TaskResource.cs index fda392cae..e0454b664 100644 --- a/src/NzbDrone.Api/System/Tasks/TaskResource.cs +++ b/src/Lidarr.Api.V1/System/Tasks/TaskResource.cs @@ -1,7 +1,7 @@ using System; -using NzbDrone.Api.REST; +using Lidarr.Http.REST; -namespace NzbDrone.Api.System.Tasks +namespace Lidarr.Api.V1.System.Tasks { public class TaskResource : RestResource { diff --git a/src/Lidarr.Api.V1/Tags/TagDetailsModule.cs b/src/Lidarr.Api.V1/Tags/TagDetailsModule.cs new file mode 100644 index 000000000..e89cd1815 --- /dev/null +++ b/src/Lidarr.Api.V1/Tags/TagDetailsModule.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using NzbDrone.Core.Tags; +using Lidarr.Http; + +namespace Lidarr.Api.V1.Tags +{ + public class TagDetailsModule : LidarrRestModule + { + private readonly ITagService _tagService; + + public TagDetailsModule(ITagService tagService) + : base("/tag/detail") + { + _tagService = tagService; + + GetResourceById = GetTagDetails; + GetResourceAll = GetAll; + } + + private TagDetailsResource GetTagDetails(int id) + { + return _tagService.Details(id).ToResource(); + } + + private List GetAll() + { + var tags = _tagService.Details().ToResource(); + + return tags; + } + } +} diff --git a/src/Lidarr.Api.V1/Tags/TagDetailsResource.cs b/src/Lidarr.Api.V1/Tags/TagDetailsResource.cs new file mode 100644 index 000000000..4bd6b6619 --- /dev/null +++ b/src/Lidarr.Api.V1/Tags/TagDetailsResource.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Tags; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.Tags +{ + public class TagDetailsResource : RestResource + { + public string Label { get; set; } + public List DelayProfileIds { get; set; } + public List ImportListIds { get; set; } + public List NotificationIds { get; set; } + public List RestrictionIds { get; set; } + public List ArtistIds { get; set; } + } + + public static class TagDetailsResourceMapper + { + public static TagDetailsResource ToResource(this TagDetails model) + { + if (model == null) return null; + + return new TagDetailsResource + { + Id = model.Id, + Label = model.Label, + DelayProfileIds = model.DelayProfileIds, + ImportListIds = model.ImportListIds, + NotificationIds = model.NotificationIds, + RestrictionIds = model.RestrictionIds, + ArtistIds = model.ArtistIds + }; + } + + public static List ToResource(this IEnumerable models) + { + return models.Select(ToResource).ToList(); + } + } +} diff --git a/src/Lidarr.Api.V1/Tags/TagModule.cs b/src/Lidarr.Api.V1/Tags/TagModule.cs new file mode 100644 index 000000000..43ab04b64 --- /dev/null +++ b/src/Lidarr.Api.V1/Tags/TagModule.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using NzbDrone.Core.Datastore.Events; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Tags; +using NzbDrone.SignalR; +using Lidarr.Http; + +namespace Lidarr.Api.V1.Tags +{ + public class TagModule : LidarrRestModuleWithSignalR, IHandle + { + private readonly ITagService _tagService; + + public TagModule(IBroadcastSignalRMessage signalRBroadcaster, + ITagService tagService) + : base(signalRBroadcaster) + { + _tagService = tagService; + + GetResourceById = GetTag; + GetResourceAll = GetAll; + CreateResource = Create; + UpdateResource = Update; + DeleteResource = DeleteTag; + } + + private TagResource GetTag(int id) + { + return _tagService.GetTag(id).ToResource(); + } + + private List GetAll() + { + return _tagService.All().ToResource(); + } + + private int Create(TagResource resource) + { + return _tagService.Add(resource.ToModel()).Id; + } + + private void Update(TagResource resource) + { + _tagService.Update(resource.ToModel()); + } + + private void DeleteTag(int id) + { + _tagService.Delete(id); + } + + public void Handle(TagsUpdatedEvent message) + { + BroadcastResourceChange(ModelAction.Sync); + } + } +} diff --git a/src/NzbDrone.Api/Tags/TagResource.cs b/src/Lidarr.Api.V1/Tags/TagResource.cs similarity index 94% rename from src/NzbDrone.Api/Tags/TagResource.cs rename to src/Lidarr.Api.V1/Tags/TagResource.cs index 678107bf5..681453622 100644 --- a/src/NzbDrone.Api/Tags/TagResource.cs +++ b/src/Lidarr.Api.V1/Tags/TagResource.cs @@ -1,9 +1,9 @@ using System.Collections.Generic; using System.Linq; -using NzbDrone.Api.REST; using NzbDrone.Core.Tags; +using Lidarr.Http.REST; -namespace NzbDrone.Api.Tags +namespace Lidarr.Api.V1.Tags { public class TagResource : RestResource { @@ -19,7 +19,6 @@ namespace NzbDrone.Api.Tags return new TagResource { Id = model.Id, - Label = model.Label }; } @@ -31,7 +30,6 @@ namespace NzbDrone.Api.Tags return new Tag { Id = resource.Id, - Label = resource.Label }; } diff --git a/src/Lidarr.Api.V1/TrackFiles/MediaInfoResource.cs b/src/Lidarr.Api.V1/TrackFiles/MediaInfoResource.cs new file mode 100644 index 000000000..5ce4d42e3 --- /dev/null +++ b/src/Lidarr.Api.V1/TrackFiles/MediaInfoResource.cs @@ -0,0 +1,35 @@ +using NzbDrone.Core.MediaFiles; +using Lidarr.Http.REST; +using NzbDrone.Core.Parser.Model; + +namespace Lidarr.Api.V1.TrackFiles +{ + public class MediaInfoResource : RestResource + { + public decimal AudioChannels { get; set; } + public string AudioBitRate { get; set; } + public string AudioCodec { get; set; } + public string AudioBits { get; set; } + public string AudioSampleRate { get; set; } + } + + public static class MediaInfoResourceMapper + { + public static MediaInfoResource ToResource(this MediaInfoModel model) + { + if (model == null) + { + return null; + } + + return new MediaInfoResource + { + AudioChannels = MediaInfoFormatter.FormatAudioChannels(model), + AudioCodec = MediaInfoFormatter.FormatAudioCodec(model), + AudioBitRate = MediaInfoFormatter.FormatAudioBitrate(model), + AudioBits = MediaInfoFormatter.FormatAudioBitsPerSample(model), + AudioSampleRate = MediaInfoFormatter.FormatAudioSampleRate(model) + }; + } + } +} diff --git a/src/Lidarr.Api.V1/TrackFiles/TrackFileListResource.cs b/src/Lidarr.Api.V1/TrackFiles/TrackFileListResource.cs new file mode 100644 index 000000000..166836d9c --- /dev/null +++ b/src/Lidarr.Api.V1/TrackFiles/TrackFileListResource.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using NzbDrone.Core.Qualities; + +namespace Lidarr.Api.V1.TrackFiles +{ + public class TrackFileListResource + { + public List TrackFileIds { get; set; } + public QualityModel Quality { get; set; } + } +} diff --git a/src/Lidarr.Api.V1/TrackFiles/TrackFileModule.cs b/src/Lidarr.Api.V1/TrackFiles/TrackFileModule.cs new file mode 100644 index 000000000..0a088f188 --- /dev/null +++ b/src/Lidarr.Api.V1/TrackFiles/TrackFileModule.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Nancy; +using NzbDrone.Core.Datastore.Events; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Music; +using NzbDrone.SignalR; +using Lidarr.Http; +using Lidarr.Http.Extensions; +using NzbDrone.Core.Exceptions; +using HttpStatusCode = System.Net.HttpStatusCode; + +namespace Lidarr.Api.V1.TrackFiles +{ + public class TrackFileModule : LidarrRestModuleWithSignalR, + IHandle, + IHandle + { + private readonly IMediaFileService _mediaFileService; + private readonly IDeleteMediaFiles _mediaFileDeletionService; + private readonly IAudioTagService _audioTagService; + private readonly IArtistService _artistService; + private readonly IAlbumService _albumService; + private readonly IUpgradableSpecification _upgradableSpecification; + + public TrackFileModule(IBroadcastSignalRMessage signalRBroadcaster, + IMediaFileService mediaFileService, + IDeleteMediaFiles mediaFileDeletionService, + IAudioTagService audioTagService, + IArtistService artistService, + IAlbumService albumService, + IUpgradableSpecification upgradableSpecification) + : base(signalRBroadcaster) + { + _mediaFileService = mediaFileService; + _mediaFileDeletionService = mediaFileDeletionService; + _audioTagService = audioTagService; + _artistService = artistService; + _albumService = albumService; + _upgradableSpecification = upgradableSpecification; + + GetResourceById = GetTrackFile; + GetResourceAll = GetTrackFiles; + UpdateResource = SetQuality; + DeleteResource = DeleteTrackFile; + + Put["/editor"] = trackFiles => SetQuality(); + Delete["/bulk"] = trackFiles => DeleteTrackFiles(); + } + + private TrackFileResource MapToResource(TrackFile trackFile) + { + if (trackFile.AlbumId > 0 && trackFile.Artist != null && trackFile.Artist.Value != null) + { + return trackFile.ToResource(trackFile.Artist.Value, _upgradableSpecification); + } + else + { + return trackFile.ToResource(); + } + } + + private TrackFileResource GetTrackFile(int id) + { + var resource = MapToResource(_mediaFileService.Get(id)); + resource.AudioTags = _audioTagService.ReadTags(resource.Path); + return resource; + } + + private List GetTrackFiles() + { + var artistIdQuery = Request.Query.ArtistId; + var trackFileIdsQuery = Request.Query.TrackFileIds; + var albumIdQuery = Request.Query.AlbumId; + var unmappedQuery = Request.Query.Unmapped; + + if (!artistIdQuery.HasValue && !trackFileIdsQuery.HasValue && !albumIdQuery.HasValue && !unmappedQuery.HasValue) + { + throw new Lidarr.Http.REST.BadRequestException("artistId, albumId, trackFileIds or unmapped must be provided"); + } + + if (unmappedQuery.HasValue && Convert.ToBoolean(unmappedQuery.Value)) + { + var files = _mediaFileService.GetUnmappedFiles(); + return files.ConvertAll(f => MapToResource(f)); + } + + if (artistIdQuery.HasValue && !albumIdQuery.HasValue) + { + int artistId = Convert.ToInt32(artistIdQuery.Value); + var artist = _artistService.GetArtist(artistId); + + return _mediaFileService.GetFilesByArtist(artistId).ConvertAll(f => f.ToResource(artist, _upgradableSpecification)); + } + + if (albumIdQuery.HasValue) + { + string albumIdValue = albumIdQuery.Value.ToString(); + + var albumIds = albumIdValue.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(e => Convert.ToInt32(e)) + .ToList(); + + var result = new List(); + foreach (var albumId in albumIds) + { + var album = _albumService.GetAlbum(albumId); + var albumArtist = _artistService.GetArtist(album.ArtistId); + result.AddRange(_mediaFileService.GetFilesByAlbum(album.Id).ConvertAll(f => f.ToResource(albumArtist, _upgradableSpecification))); + } + + return result; + } + + else + { + string trackFileIdsValue = trackFileIdsQuery.Value.ToString(); + + var trackFileIds = trackFileIdsValue.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(e => Convert.ToInt32(e)) + .ToList(); + + // trackfiles will come back with the artist already populated + var trackFiles = _mediaFileService.Get(trackFileIds); + return trackFiles.ConvertAll(e => MapToResource(e)); + } + } + + private void SetQuality(TrackFileResource trackFileResource) + { + var trackFile = _mediaFileService.Get(trackFileResource.Id); + trackFile.Quality = trackFileResource.Quality; + _mediaFileService.Update(trackFile); + } + + private Response SetQuality() + { + var resource = Request.Body.FromJson(); + var trackFiles = _mediaFileService.Get(resource.TrackFileIds); + + foreach (var trackFile in trackFiles) + { + if (resource.Quality != null) + { + trackFile.Quality = resource.Quality; + } + } + + _mediaFileService.Update(trackFiles); + + return trackFiles.ConvertAll(f => f.ToResource(trackFiles.First().Artist.Value, _upgradableSpecification)) + .AsResponse(Nancy.HttpStatusCode.Accepted); + } + + private void DeleteTrackFile(int id) + { + var trackFile = _mediaFileService.Get(id); + + if (trackFile == null) + { + throw new NzbDroneClientException(HttpStatusCode.NotFound, "Track file not found"); + } + + if (trackFile.AlbumId > 0 && trackFile.Artist != null && trackFile.Artist.Value != null) + { + _mediaFileDeletionService.DeleteTrackFile(trackFile.Artist.Value, trackFile); + } + else + { + _mediaFileDeletionService.DeleteTrackFile(trackFile, "Unmapped_Files"); + } + } + + private Response DeleteTrackFiles() + { + var resource = Request.Body.FromJson(); + var trackFiles = _mediaFileService.Get(resource.TrackFileIds); + var artist = trackFiles.First().Artist.Value; + + foreach (var trackFile in trackFiles) + { + _mediaFileDeletionService.DeleteTrackFile(artist, trackFile); + } + + return new object().AsResponse(); + } + + public void Handle(TrackFileAddedEvent message) + { + BroadcastResourceChange(ModelAction.Updated, MapToResource(message.TrackFile)); + } + + public void Handle(TrackFileDeletedEvent message) + { + BroadcastResourceChange(ModelAction.Deleted, MapToResource(message.TrackFile)); + } + } +} diff --git a/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs b/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs new file mode 100644 index 000000000..481980f75 --- /dev/null +++ b/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs @@ -0,0 +1,81 @@ +using System; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Qualities; +using Lidarr.Http.REST; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Parser.Model; +using System.Linq; + +namespace Lidarr.Api.V1.TrackFiles +{ + public class TrackFileResource : RestResource + { + public int ArtistId { get; set; } + public int AlbumId { get; set; } + public string RelativePath { get; set; } + public string Path { get; set; } + public long Size { get; set; } + public DateTime DateAdded { get; set; } + public QualityModel Quality { get; set; } + public int QualityWeight { get; set; } + public MediaInfoResource MediaInfo { get; set; } + + public bool QualityCutoffNotMet { get; set; } + public ParsedTrackInfo AudioTags { get; set; } + } + + public static class TrackFileResourceMapper + { + private static int QualityWeight(QualityModel quality) + { + if (quality == null) + { + return 0; + } + + int qualityWeight = Quality.DefaultQualityDefinitions.Single(q => q.Quality == quality.Quality).Weight; + qualityWeight += quality.Revision.Real * 10; + qualityWeight += quality.Revision.Version; + return qualityWeight; + } + + public static TrackFileResource ToResource(this TrackFile model) + { + if (model == null) return null; + + return new TrackFileResource + { + Id = model.Id, + AlbumId = model.AlbumId, + Path = model.Path, + Size = model.Size, + DateAdded = model.DateAdded, + Quality = model.Quality, + QualityWeight = QualityWeight(model.Quality), + MediaInfo = model.MediaInfo.ToResource() + }; + } + + public static TrackFileResource ToResource(this TrackFile model, NzbDrone.Core.Music.Artist artist, IUpgradableSpecification upgradableSpecification) + { + if (model == null) return null; + + return new TrackFileResource + { + Id = model.Id, + + ArtistId = artist.Id, + AlbumId = model.AlbumId, + Path = model.Path, + RelativePath = artist.Path.GetRelativePath(model.Path), + Size = model.Size, + DateAdded = model.DateAdded, + Quality = model.Quality, + QualityWeight = QualityWeight(model.Quality), + MediaInfo = model.MediaInfo.ToResource(), + QualityCutoffNotMet = upgradableSpecification.QualityCutoffNotMet(artist.QualityProfile.Value, model.Quality) + }; + } + } +} diff --git a/src/Lidarr.Api.V1/Tracks/RenameTrackModule.cs b/src/Lidarr.Api.V1/Tracks/RenameTrackModule.cs new file mode 100644 index 000000000..2bf4cc538 --- /dev/null +++ b/src/Lidarr.Api.V1/Tracks/RenameTrackModule.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using NzbDrone.Core.MediaFiles; +using Lidarr.Http; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.Tracks +{ + public class RenameTrackModule : LidarrRestModule + { + private readonly IRenameTrackFileService _renameTrackFileService; + + public RenameTrackModule(IRenameTrackFileService renameTrackFileService) + : base("rename") + { + _renameTrackFileService = renameTrackFileService; + + GetResourceAll = GetTracks; + } + + private List GetTracks() + { + int artistId; + + if (Request.Query.ArtistId.HasValue) + { + artistId = (int)Request.Query.ArtistId; + } + + else + { + throw new BadRequestException("artistId is missing"); + } + + if (Request.Query.albumId.HasValue) + { + var albumId = (int)Request.Query.albumId; + return _renameTrackFileService.GetRenamePreviews(artistId, albumId).ToResource(); + } + + return _renameTrackFileService.GetRenamePreviews(artistId).ToResource(); + } + } +} diff --git a/src/Lidarr.Api.V1/Tracks/RenameTrackResource.cs b/src/Lidarr.Api.V1/Tracks/RenameTrackResource.cs new file mode 100644 index 000000000..9b76b8998 --- /dev/null +++ b/src/Lidarr.Api.V1/Tracks/RenameTrackResource.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Linq; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.Tracks +{ + public class RenameTrackResource : RestResource + { + public int ArtistId { get; set; } + public int AlbumId { get; set; } + public List TrackNumbers { get; set; } + public int TrackFileId { get; set; } + public string ExistingPath { get; set; } + public string NewPath { get; set; } + } + + public static class RenameTrackResourceMapper + { + public static RenameTrackResource ToResource(this NzbDrone.Core.MediaFiles.RenameTrackFilePreview model) + { + if (model == null) return null; + + return new RenameTrackResource + { + ArtistId = model.ArtistId, + AlbumId = model.AlbumId, + TrackNumbers = model.TrackNumbers.ToList(), + TrackFileId = model.TrackFileId, + ExistingPath = model.ExistingPath, + NewPath = model.NewPath + }; + } + + public static List ToResource(this IEnumerable models) + { + return models.Select(ToResource).ToList(); + } + } +} diff --git a/src/Lidarr.Api.V1/Tracks/RetagTrackModule.cs b/src/Lidarr.Api.V1/Tracks/RetagTrackModule.cs new file mode 100644 index 000000000..1248d4215 --- /dev/null +++ b/src/Lidarr.Api.V1/Tracks/RetagTrackModule.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Linq; +using Lidarr.Http; +using Lidarr.Http.REST; +using NzbDrone.Core.MediaFiles; + +namespace Lidarr.Api.V1.Tracks +{ + public class RetagTrackModule : LidarrRestModule + { + private readonly IAudioTagService _audioTagService; + + public RetagTrackModule(IAudioTagService audioTagService) + : base("retag") + { + _audioTagService = audioTagService; + + GetResourceAll = GetTracks; + } + + private List GetTracks() + { + if (Request.Query.albumId.HasValue) + { + var albumId = (int)Request.Query.albumId; + return _audioTagService.GetRetagPreviewsByAlbum(albumId).Where(x => x.Changes.Any()).ToResource(); + } + else if (Request.Query.ArtistId.HasValue) + { + var artistId = (int)Request.Query.ArtistId; + return _audioTagService.GetRetagPreviewsByArtist(artistId).Where(x => x.Changes.Any()).ToResource(); + } + else + { + throw new BadRequestException("One of artistId or albumId must be specified"); + } + + } + } +} diff --git a/src/Lidarr.Api.V1/Tracks/RetagTrackResource.cs b/src/Lidarr.Api.V1/Tracks/RetagTrackResource.cs new file mode 100644 index 000000000..62c5c9085 --- /dev/null +++ b/src/Lidarr.Api.V1/Tracks/RetagTrackResource.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Linq; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.Tracks +{ + public class TagDifference + { + public string Field { get; set; } + public string OldValue { get; set; } + public string NewValue { get; set; } + } + + public class RetagTrackResource : RestResource + { + public int ArtistId { get; set; } + public int AlbumId { get; set; } + public List TrackNumbers { get; set; } + public int TrackFileId { get; set; } + public string RelativePath { get; set; } + public List Changes { get; set; } + } + + public static class RetagTrackResourceMapper + { + public static RetagTrackResource ToResource(this NzbDrone.Core.MediaFiles.RetagTrackFilePreview model) + { + if (model == null) + { + return null; + } + + return new RetagTrackResource + { + ArtistId = model.ArtistId, + AlbumId = model.AlbumId, + TrackNumbers = model.TrackNumbers.ToList(), + TrackFileId = model.TrackFileId, + RelativePath = model.RelativePath, + Changes = model.Changes.Select(x => new TagDifference { + Field = x.Key, + OldValue = x.Value.Item1, + NewValue = x.Value.Item2 + }).ToList() + }; + } + + public static List ToResource(this IEnumerable models) + { + return models.Select(ToResource).ToList(); + } + } +} diff --git a/src/Lidarr.Api.V1/Tracks/TrackModule.cs b/src/Lidarr.Api.V1/Tracks/TrackModule.cs new file mode 100644 index 000000000..c216c718b --- /dev/null +++ b/src/Lidarr.Api.V1/Tracks/TrackModule.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Nancy; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Music; +using NzbDrone.SignalR; +using Lidarr.Http.Extensions; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.Tracks +{ + public class TrackModule : TrackModuleWithSignalR + { + public TrackModule(IArtistService artistService, + ITrackService trackService, + IUpgradableSpecification upgradableSpecification, + IBroadcastSignalRMessage signalRBroadcaster) + : base(trackService, artistService, upgradableSpecification, signalRBroadcaster) + { + GetResourceAll = GetTracks; + } + + private List GetTracks() + { + var artistIdQuery = Request.Query.ArtistId; + var albumIdQuery = Request.Query.AlbumId; + var albumReleaseIdQuery = Request.Query.AlbumReleaseId; + var trackIdsQuery = Request.Query.TrackIds; + + if (!artistIdQuery.HasValue && !trackIdsQuery.HasValue && !albumIdQuery.HasValue && !albumReleaseIdQuery.HasValue) + { + throw new BadRequestException("One of artistId, albumId, albumReleaseId or trackIds must be provided"); + } + + if (artistIdQuery.HasValue && !albumIdQuery.HasValue) + { + int artistId = Convert.ToInt32(artistIdQuery.Value); + + return MapToResource(_trackService.GetTracksByArtist(artistId), false, false); + } + + if (albumReleaseIdQuery.HasValue) + { + int releaseId = Convert.ToInt32(albumReleaseIdQuery.Value); + + return MapToResource(_trackService.GetTracksByRelease(releaseId), false, false); + } + + if (albumIdQuery.HasValue) + { + int albumId = Convert.ToInt32(albumIdQuery.Value); + + return MapToResource(_trackService.GetTracksByAlbum(albumId), false, false); + } + + string trackIdsValue = trackIdsQuery.Value.ToString(); + + var trackIds = trackIdsValue.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(e => Convert.ToInt32(e)) + .ToList(); + + return MapToResource(_trackService.GetTracks(trackIds), false, false); + } + } +} diff --git a/src/Lidarr.Api.V1/Tracks/TrackModuleWithSignalR.cs b/src/Lidarr.Api.V1/Tracks/TrackModuleWithSignalR.cs new file mode 100644 index 000000000..014aa4027 --- /dev/null +++ b/src/Lidarr.Api.V1/Tracks/TrackModuleWithSignalR.cs @@ -0,0 +1,125 @@ +using System.Collections.Generic; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Datastore.Events; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Music; +using NzbDrone.SignalR; +using Lidarr.Api.V1.TrackFiles; +using Lidarr.Api.V1.Artist; +using Lidarr.Http; +using NzbDrone.Core.MediaFiles.Events; + +namespace Lidarr.Api.V1.Tracks +{ + public abstract class TrackModuleWithSignalR : LidarrRestModuleWithSignalR, + IHandle, + IHandle + { + protected readonly ITrackService _trackService; + protected readonly IArtistService _artistService; + protected readonly IUpgradableSpecification _upgradableSpecification; + + protected TrackModuleWithSignalR(ITrackService trackService, + IArtistService artistService, + IUpgradableSpecification upgradableSpecification, + IBroadcastSignalRMessage signalRBroadcaster) + : base(signalRBroadcaster) + { + _trackService = trackService; + _artistService = artistService; + _upgradableSpecification = upgradableSpecification; + + GetResourceById = GetTrack; + } + + protected TrackModuleWithSignalR(ITrackService trackService, + IArtistService artistService, + IUpgradableSpecification upgradableSpecification, + IBroadcastSignalRMessage signalRBroadcaster, + string resource) + : base(signalRBroadcaster, resource) + { + _trackService = trackService; + _artistService = artistService; + _upgradableSpecification = upgradableSpecification; + + GetResourceById = GetTrack; + } + + protected TrackResource GetTrack(int id) + { + var track = _trackService.GetTrack(id); + var resource = MapToResource(track, true, true); + return resource; + } + + protected TrackResource MapToResource(Track track, bool includeArtist, bool includeTrackFile) + { + var resource = track.ToResource(); + + if (includeArtist || includeTrackFile) + { + var artist = track.Artist.Value; + + if (includeArtist) + { + resource.Artist = artist.ToResource(); + } + if (includeTrackFile && track.TrackFileId != 0) + { + resource.TrackFile = track.TrackFile.Value.ToResource(artist, _upgradableSpecification); + } + } + + return resource; + } + + protected List MapToResource(List tracks, bool includeArtist, bool includeTrackFile) + { + var result = tracks.ToResource(); + + if (includeArtist || includeTrackFile) + { + var artistDict = new Dictionary(); + for (var i = 0; i < tracks.Count; i++) + { + var track = tracks[i]; + var resource = result[i]; + var artist = track.Artist.Value; + + if (includeArtist) + { + resource.Artist = artist.ToResource(); + } + if (includeTrackFile && tracks[i].TrackFileId != 0) + { + resource.TrackFile = tracks[i].TrackFile.Value.ToResource(artist, _upgradableSpecification); + } + } + } + + return result; + } + + public void Handle(TrackImportedEvent message) + { + foreach (var track in message.TrackInfo.Tracks) + { + track.TrackFile = message.ImportedTrack; + BroadcastResourceChange(ModelAction.Updated, MapToResource(track, true, true)); + } + } + + public void Handle(TrackFileDeletedEvent message) + { + foreach (var track in message.TrackFile.Tracks.Value) + { + track.TrackFile = message.TrackFile; + BroadcastResourceChange(ModelAction.Updated, MapToResource(track, true, true)); + } + } + + } +} diff --git a/src/Lidarr.Api.V1/Tracks/TrackResource.cs b/src/Lidarr.Api.V1/Tracks/TrackResource.cs new file mode 100644 index 000000000..d1ebac96e --- /dev/null +++ b/src/Lidarr.Api.V1/Tracks/TrackResource.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using NzbDrone.Core.Music; +using Lidarr.Api.V1.TrackFiles; +using Lidarr.Api.V1.Artist; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.Tracks +{ + public class TrackResource : RestResource + { + public int ArtistId { get; set; } + public int TrackFileId { get; set; } + public int AlbumId { get; set; } + public bool Explicit { get; set; } + public int AbsoluteTrackNumber { get; set; } + public string TrackNumber { get; set; } + public string Title { get; set; } + public int Duration { get; set; } + public TrackFileResource TrackFile { get; set; } + public int MediumNumber { get; set; } + public bool HasFile { get; set; } + + public ArtistResource Artist { get; set; } + public Ratings Ratings { get; set; } + + //Hiding this so people don't think its usable (only used to set the initial state) + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public bool Grabbed { get; set; } + } + + public static class TrackResourceMapper + { + public static TrackResource ToResource(this Track model) + { + if (model == null) return null; + + return new TrackResource + { + Id = model.Id, + + ArtistId = model.Artist.Value.Id, + TrackFileId = model.TrackFileId, + AlbumId = model.AlbumId, + Explicit = model.Explicit, + AbsoluteTrackNumber = model.AbsoluteTrackNumber, + TrackNumber = model.TrackNumber, + Title = model.Title, + Duration = model.Duration, + MediumNumber = model.MediumNumber, + HasFile = model.HasFile, + Ratings = model.Ratings, + }; + } + + public static List ToResource(this IEnumerable models) + { + if (models == null) return null; + + return models.Select(ToResource).ToList(); + } + } +} diff --git a/src/NzbDrone.Api/Update/UpdateModule.cs b/src/Lidarr.Api.V1/Update/UpdateModule.cs similarity index 91% rename from src/NzbDrone.Api/Update/UpdateModule.cs rename to src/Lidarr.Api.V1/Update/UpdateModule.cs index 2104f23ea..460559291 100644 --- a/src/NzbDrone.Api/Update/UpdateModule.cs +++ b/src/Lidarr.Api.V1/Update/UpdateModule.cs @@ -2,10 +2,11 @@ using System.Linq; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Update; +using Lidarr.Http; -namespace NzbDrone.Api.Update +namespace Lidarr.Api.V1.Update { - public class UpdateModule : NzbDroneRestModule + public class UpdateModule : LidarrRestModule { private readonly IRecentUpdateProvider _recentUpdateProvider; diff --git a/src/NzbDrone.Api/Update/UpdateResource.cs b/src/Lidarr.Api.V1/Update/UpdateResource.cs similarity index 96% rename from src/NzbDrone.Api/Update/UpdateResource.cs rename to src/Lidarr.Api.V1/Update/UpdateResource.cs index dca6f6725..e79a183f3 100644 --- a/src/NzbDrone.Api/Update/UpdateResource.cs +++ b/src/Lidarr.Api.V1/Update/UpdateResource.cs @@ -2,10 +2,10 @@ using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; -using NzbDrone.Api.REST; using NzbDrone.Core.Update; +using Lidarr.Http.REST; -namespace NzbDrone.Api.Update +namespace Lidarr.Api.V1.Update { public class UpdateResource : RestResource { diff --git a/src/Lidarr.Api.V1/Wanted/CutoffModule.cs b/src/Lidarr.Api.V1/Wanted/CutoffModule.cs new file mode 100644 index 000000000..a0a181969 --- /dev/null +++ b/src/Lidarr.Api.V1/Wanted/CutoffModule.cs @@ -0,0 +1,57 @@ +using System.Linq; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Music; +using NzbDrone.Core.ArtistStats; +using NzbDrone.SignalR; +using Lidarr.Api.V1.Albums; +using Lidarr.Http; +using Lidarr.Http.Extensions; +using NzbDrone.Core.MediaCover; + +namespace Lidarr.Api.V1.Wanted +{ + public class CutoffModule : AlbumModuleWithSignalR + { + private readonly IAlbumCutoffService _albumCutoffService; + + public CutoffModule(IAlbumCutoffService albumCutoffService, + IAlbumService albumService, + IArtistStatisticsService artistStatisticsService, + IMapCoversToLocal coverMapper, + IUpgradableSpecification upgradableSpecification, + IBroadcastSignalRMessage signalRBroadcaster) + : base(albumService, artistStatisticsService, coverMapper, upgradableSpecification, signalRBroadcaster, "wanted/cutoff") + { + _albumCutoffService = albumCutoffService; + GetResourcePaged = GetCutoffUnmetAlbums; + } + + private PagingResource GetCutoffUnmetAlbums(PagingResource pagingResource) + { + var pagingSpec = new PagingSpec + { + Page = pagingResource.Page, + PageSize = pagingResource.PageSize, + SortKey = pagingResource.SortKey, + SortDirection = pagingResource.SortDirection + }; + + var includeArtist = Request.GetBooleanQueryParameter("includeArtist"); + var filter = pagingResource.Filters.FirstOrDefault(f => f.Key == "monitored"); + + if (filter != null && filter.Value == "false") + { + pagingSpec.FilterExpressions.Add(v => v.Monitored == false || v.Artist.Value.Monitored == false); + } + else + { + pagingSpec.FilterExpressions.Add(v => v.Monitored == true && v.Artist.Value.Monitored == true); + } + + var resource = ApplyToPage(_albumCutoffService.AlbumsWhereCutoffUnmet, pagingSpec, v => MapToResource(v, includeArtist)); + + return resource; + } + } +} diff --git a/src/Lidarr.Api.V1/Wanted/MissingModule.cs b/src/Lidarr.Api.V1/Wanted/MissingModule.cs new file mode 100644 index 000000000..1a6eb53df --- /dev/null +++ b/src/Lidarr.Api.V1/Wanted/MissingModule.cs @@ -0,0 +1,53 @@ +using System.Linq; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Music; +using NzbDrone.Core.ArtistStats; +using NzbDrone.SignalR; +using Lidarr.Api.V1.Albums; +using Lidarr.Http; +using Lidarr.Http.Extensions; +using NzbDrone.Core.MediaCover; + +namespace Lidarr.Api.V1.Wanted +{ + public class MissingModule : AlbumModuleWithSignalR + { + public MissingModule(IAlbumService albumService, + IArtistStatisticsService artistStatisticsService, + IMapCoversToLocal coverMapper, + IUpgradableSpecification upgradableSpecification, + IBroadcastSignalRMessage signalRBroadcaster) + : base(albumService, artistStatisticsService, coverMapper, upgradableSpecification, signalRBroadcaster, "wanted/missing") + { + GetResourcePaged = GetMissingAlbums; + } + + private PagingResource GetMissingAlbums(PagingResource pagingResource) + { + var pagingSpec = new PagingSpec + { + Page = pagingResource.Page, + PageSize = pagingResource.PageSize, + SortKey = pagingResource.SortKey, + SortDirection = pagingResource.SortDirection + }; + + var includeArtist = Request.GetBooleanQueryParameter("includeArtist"); + var monitoredFilter = pagingResource.Filters.FirstOrDefault(f => f.Key == "monitored"); + + if (monitoredFilter != null && monitoredFilter.Value == "false") + { + pagingSpec.FilterExpressions.Add(v => v.Monitored == false || v.Artist.Value.Monitored == false); + } + else + { + pagingSpec.FilterExpressions.Add(v => v.Monitored == true && v.Artist.Value.Monitored == true); + } + + var resource = ApplyToPage(_albumService.AlbumsWithoutFiles, pagingSpec, v => MapToResource(v, includeArtist)); + + return resource; + } + } +} diff --git a/src/Lidarr.Api.V1/app.config b/src/Lidarr.Api.V1/app.config new file mode 100644 index 000000000..c393434e6 --- /dev/null +++ b/src/Lidarr.Api.V1/app.config @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/NzbDrone.Api/Authentication/1tews5g3.gd1~ b/src/Lidarr.Http/Authentication/1tews5g3.gd1~ similarity index 100% rename from src/NzbDrone.Api/Authentication/1tews5g3.gd1~ rename to src/Lidarr.Http/Authentication/1tews5g3.gd1~ diff --git a/src/Lidarr.Http/Authentication/AuthenticationModule.cs b/src/Lidarr.Http/Authentication/AuthenticationModule.cs new file mode 100644 index 000000000..45bba5e65 --- /dev/null +++ b/src/Lidarr.Http/Authentication/AuthenticationModule.cs @@ -0,0 +1,60 @@ +using System; +using Nancy; +using Nancy.Authentication.Forms; +using Nancy.Extensions; +using Nancy.ModelBinding; +using NzbDrone.Common.EnsureThat; +using NzbDrone.Common.Extensions; +using NLog; +using NzbDrone.Common.Instrumentation; +using NzbDrone.Core.Authentication; +using NzbDrone.Core.Configuration; + +namespace Lidarr.Http.Authentication +{ + public class AuthenticationModule : NancyModule + { + private readonly IAuthenticationService _authService; + private readonly IConfigFileProvider _configFileProvider; + + public AuthenticationModule(IAuthenticationService authService, IConfigFileProvider configFileProvider) + { + _authService = authService; + _configFileProvider = configFileProvider; + Post["/login"] = x => Login(this.Bind()); + Get["/logout"] = x => Logout(); + } + + private Response Login(LoginResource resource) + { + var user = _authService.Login(Context, resource.Username, resource.Password); + + if (user == null) + { + return LoginFailed(); + } + + DateTime? expiry = null; + + if (resource.RememberMe) + { + expiry = DateTime.UtcNow.AddDays(7); + } + + return this.LoginAndRedirect(user.Identifier, expiry, _configFileProvider.UrlBase + "/"); + } + + private Response Logout() + { + _authService.Logout(Context); + + return this.LogoutAndRedirect(_configFileProvider.UrlBase + "/"); + } + + private Response LoginFailed() + { + var returnUrl = (string)Request.Query.returnUrl; + return Context.GetRedirect($"~/login?returnUrl={returnUrl}&loginFailed=true"); + } + } +} diff --git a/src/Lidarr.Http/Authentication/AuthenticationService.cs b/src/Lidarr.Http/Authentication/AuthenticationService.cs new file mode 100644 index 000000000..a1e5bc27e --- /dev/null +++ b/src/Lidarr.Http/Authentication/AuthenticationService.cs @@ -0,0 +1,230 @@ +using System; +using System.Linq; +using Nancy; +using Nancy.Authentication.Basic; +using Nancy.Authentication.Forms; +using Nancy.Security; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Instrumentation; +using NzbDrone.Core.Authentication; +using NzbDrone.Core.Configuration; +using Lidarr.Http.Extensions; + +namespace Lidarr.Http.Authentication +{ + public interface IAuthenticationService : IUserValidator, IUserMapper + { + void SetContext(NancyContext context); + + void LogUnauthorized(NancyContext context); + User Login(NancyContext context, string username, string password); + void Logout(NancyContext context); + bool IsAuthenticated(NancyContext context); + } + + public class AuthenticationService : IAuthenticationService + { + private static readonly Logger _authLogger = LogManager.GetLogger("Auth"); + private static readonly NzbDroneUser AnonymousUser = new NzbDroneUser { UserName = "Anonymous" }; + private readonly IUserService _userService; + private readonly NancyContext _nancyContext; + + private static string API_KEY; + private static AuthenticationType AUTH_METHOD; + + [ThreadStatic] + private static NancyContext _context; + + public AuthenticationService(IConfigFileProvider configFileProvider, IUserService userService, NancyContext nancyContext) + { + _userService = userService; + _nancyContext = nancyContext; + API_KEY = configFileProvider.ApiKey; + AUTH_METHOD = configFileProvider.AuthenticationMethod; + } + + public void SetContext(NancyContext context) + { + // Validate and GetUserIdentifier don't have access to the NancyContext so get it from the pipeline earlier + _context = context; + } + + public User Login(NancyContext context, string username, string password) + { + if (AUTH_METHOD == AuthenticationType.None) + { + return null; + } + + var user = _userService.FindUser(username, password); + + if (user != null) + { + LogSuccess(context, username); + + return user; + } + + LogFailure(context, username); + + return null; + } + + public void Logout(NancyContext context) + { + if (AUTH_METHOD == AuthenticationType.None) + { + return; + } + + if (context.CurrentUser != null) + { + LogLogout(context, context.CurrentUser.UserName); + } + } + + public IUserIdentity Validate(string username, string password) + { + if (AUTH_METHOD == AuthenticationType.None) + { + return AnonymousUser; + } + + var user = _userService.FindUser(username, password); + + if (user != null) + { + if (AUTH_METHOD != AuthenticationType.Basic) + { + // Don't log success for basic auth + LogSuccess(_context, username); + } + + return new NzbDroneUser { UserName = user.Username }; + } + + LogFailure(_context, username); + + return null; + } + + public IUserIdentity GetUserFromIdentifier(Guid identifier, NancyContext context) + { + if (AUTH_METHOD == AuthenticationType.None) + { + return AnonymousUser; + } + + var user = _userService.FindUser(identifier); + + if (user != null) + { + return new NzbDroneUser { UserName = user.Username }; + } + + LogInvalidated(_context); + + return null; + } + + public bool IsAuthenticated(NancyContext context) + { + var apiKey = GetApiKey(context); + + if (context.Request.IsApiRequest()) + { + return ValidApiKey(apiKey); + } + + if (AUTH_METHOD == AuthenticationType.None) + { + return true; + } + + if (context.Request.IsFeedRequest()) + { + if (ValidUser(context) || ValidApiKey(apiKey)) + { + return true; + } + + return false; + } + + if (context.Request.IsLoginRequest()) + { + return true; + } + + if (context.Request.IsContentRequest()) + { + return true; + } + + if (ValidUser(context)) + { + return true; + } + + return false; + } + + private bool ValidUser(NancyContext context) + { + if (context.CurrentUser != null) return true; + + return false; + } + + private bool ValidApiKey(string apiKey) + { + if (API_KEY.Equals(apiKey)) return true; + + return false; + } + + private string GetApiKey(NancyContext context) + { + var apiKeyHeader = context.Request.Headers["X-Api-Key"].FirstOrDefault(); + var apiKeyQueryString = context.Request.Query["ApiKey"]; + + if (!apiKeyHeader.IsNullOrWhiteSpace()) + { + return apiKeyHeader; + } + + if (apiKeyQueryString.HasValue) + { + return apiKeyQueryString.Value; + } + + return context.Request.Headers.Authorization; + } + + public void LogUnauthorized(NancyContext context) + { + _authLogger.Info("Auth-Unauthorized ip {0} url '{1}'", context.Request.UserHostAddress, context.Request.Url.ToString()); + } + + private void LogInvalidated(NancyContext context) + { + _authLogger.Info("Auth-Invalidated ip {0}", context.Request.UserHostAddress); + } + + private void LogFailure(NancyContext context, string username) + { + _authLogger.Warn("Auth-Failure ip {0} username '{1}'", context.Request.UserHostAddress, username); + } + + private void LogSuccess(NancyContext context, string username) + { + _authLogger.Info("Auth-Success ip {0} username '{1}'", context.Request.UserHostAddress, username); + } + + private void LogLogout(NancyContext context, string username) + { + _authLogger.Info("Auth-Logout ip {0} username '{1}'", context.Request.UserHostAddress, username); + } + } +} diff --git a/src/Lidarr.Http/Authentication/EnableAuthInNancy.cs b/src/Lidarr.Http/Authentication/EnableAuthInNancy.cs new file mode 100644 index 000000000..7b916066f --- /dev/null +++ b/src/Lidarr.Http/Authentication/EnableAuthInNancy.cs @@ -0,0 +1,146 @@ +using System; +using System.Text; +using Nancy; +using Nancy.Authentication.Basic; +using Nancy.Authentication.Forms; +using Nancy.Bootstrapper; +using Nancy.Cookies; +using Nancy.Cryptography; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Authentication; +using NzbDrone.Core.Configuration; +using Lidarr.Http.Extensions; +using Lidarr.Http.Extensions.Pipelines; +using NzbDrone.Common.EnvironmentInfo; + +namespace Lidarr.Http.Authentication +{ + public class EnableAuthInNancy : IRegisterNancyPipeline + { + private readonly IAuthenticationService _authenticationService; + private readonly IConfigService _configService; + private readonly IConfigFileProvider _configFileProvider; + private FormsAuthenticationConfiguration FormsAuthConfig; + + public EnableAuthInNancy(IAuthenticationService authenticationService, + IConfigService configService, + IConfigFileProvider configFileProvider) + { + _authenticationService = authenticationService; + _configService = configService; + _configFileProvider = configFileProvider; + } + + public int Order => 10; + + public void Register(IPipelines pipelines) + { + if (_configFileProvider.AuthenticationMethod == AuthenticationType.Forms) + { + RegisterFormsAuth(pipelines); + pipelines.AfterRequest.AddItemToEndOfPipeline((Action)SlidingAuthenticationForFormsAuth); + } + + else if (_configFileProvider.AuthenticationMethod == AuthenticationType.Basic) + { + pipelines.EnableBasicAuthentication(new BasicAuthenticationConfiguration(_authenticationService, BuildInfo.AppName)); + pipelines.BeforeRequest.AddItemToStartOfPipeline(CaptureContext); + } + + pipelines.BeforeRequest.AddItemToEndOfPipeline((Func)RequiresAuthentication); + pipelines.AfterRequest.AddItemToEndOfPipeline((Action)RemoveLoginHooksForApiCalls); + } + + private Response CaptureContext(NancyContext context) + { + _authenticationService.SetContext(context); + + return null; + } + + + private Response RequiresAuthentication(NancyContext context) + { + Response response = null; + + if (!_authenticationService.IsAuthenticated(context)) + { + _authenticationService.LogUnauthorized(context); + response = new Response { StatusCode = HttpStatusCode.Unauthorized }; + } + + return response; + } + + private void RegisterFormsAuth(IPipelines pipelines) + { + FormsAuthentication.FormsAuthenticationCookieName = "LidarrAuth"; + + var cryptographyConfiguration = new CryptographyConfiguration( + new RijndaelEncryptionProvider(new PassphraseKeyGenerator(_configService.RijndaelPassphrase, Encoding.ASCII.GetBytes(_configService.RijndaelSalt))), + new DefaultHmacProvider(new PassphraseKeyGenerator(_configService.HmacPassphrase, Encoding.ASCII.GetBytes(_configService.HmacSalt))) + ); + + FormsAuthConfig = new FormsAuthenticationConfiguration + { + RedirectUrl = _configFileProvider.UrlBase + "/login", + UserMapper = _authenticationService, + Path = GetCookiePath(), + CryptographyConfiguration = cryptographyConfiguration + }; + + FormsAuthentication.Enable(pipelines, FormsAuthConfig); + } + + private void RemoveLoginHooksForApiCalls(NancyContext context) + { + if (context.Request.IsApiRequest()) + { + if ((context.Response.StatusCode == HttpStatusCode.SeeOther && + context.Response.Headers["Location"].StartsWith($"{_configFileProvider.UrlBase}/login", StringComparison.InvariantCultureIgnoreCase)) || + context.Response.StatusCode == HttpStatusCode.Unauthorized) + { + context.Response = new { Error = "Unauthorized" }.AsResponse(HttpStatusCode.Unauthorized); + } + } + } + + private void SlidingAuthenticationForFormsAuth(NancyContext context) + { + if (context.CurrentUser == null) + { + return; + } + + var formsAuthCookieName = FormsAuthentication.FormsAuthenticationCookieName; + + if (!context.Request.Path.Equals("/logout") && + context.Request.Cookies.ContainsKey(formsAuthCookieName)) + { + var formsAuthCookieValue = context.Request.Cookies[formsAuthCookieName]; + + if (FormsAuthentication.DecryptAndValidateAuthenticationCookie(formsAuthCookieValue, FormsAuthConfig).IsNotNullOrWhiteSpace()) + { + var formsAuthCookie = new NancyCookie(formsAuthCookieName, formsAuthCookieValue, true, false, DateTime.UtcNow.AddDays(7)) + { + Path = GetCookiePath() + }; + + context.Response.WithCookie(formsAuthCookie); + } + } + } + + private string GetCookiePath() + { + var urlBase = _configFileProvider.UrlBase; + + if (urlBase.IsNullOrWhiteSpace()) + { + return "/"; + } + + return urlBase; + } + } +} diff --git a/src/NzbDrone.Api/Authentication/LoginResource.cs b/src/Lidarr.Http/Authentication/LoginResource.cs similarity index 81% rename from src/NzbDrone.Api/Authentication/LoginResource.cs rename to src/Lidarr.Http/Authentication/LoginResource.cs index 5d6a5c9f5..db1b94513 100644 --- a/src/NzbDrone.Api/Authentication/LoginResource.cs +++ b/src/Lidarr.Http/Authentication/LoginResource.cs @@ -1,4 +1,4 @@ -namespace NzbDrone.Api.Authentication +namespace Lidarr.Http.Authentication { public class LoginResource { diff --git a/src/NzbDrone.Api/Authentication/NzbDroneUser.cs b/src/Lidarr.Http/Authentication/NzbDroneUser.cs similarity index 85% rename from src/NzbDrone.Api/Authentication/NzbDroneUser.cs rename to src/Lidarr.Http/Authentication/NzbDroneUser.cs index c8fce02fd..a83a0fda5 100644 --- a/src/NzbDrone.Api/Authentication/NzbDroneUser.cs +++ b/src/Lidarr.Http/Authentication/NzbDroneUser.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using Nancy.Security; -namespace NzbDrone.Api.Authentication +namespace Lidarr.Http.Authentication { public class NzbDroneUser : IUserIdentity { diff --git a/src/Lidarr.Http/ClientSchema/Field.cs b/src/Lidarr.Http/ClientSchema/Field.cs new file mode 100644 index 000000000..e9363c1f1 --- /dev/null +++ b/src/Lidarr.Http/ClientSchema/Field.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +namespace Lidarr.Http.ClientSchema +{ + public class Field + { + 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 string Section { get; set; } + public string Hidden { get; set; } + + public Field Clone() + { + return (Field)MemberwiseClone(); + } + } +} diff --git a/src/Lidarr.Http/ClientSchema/FieldMapping.cs b/src/Lidarr.Http/ClientSchema/FieldMapping.cs new file mode 100644 index 000000000..e8ce91824 --- /dev/null +++ b/src/Lidarr.Http/ClientSchema/FieldMapping.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Lidarr.Http.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/Lidarr.Http/ClientSchema/SchemaBuilder.cs b/src/Lidarr.Http/ClientSchema/SchemaBuilder.cs new file mode 100644 index 000000000..771d5635c --- /dev/null +++ b/src/Lidarr.Http/ClientSchema/SchemaBuilder.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Newtonsoft.Json.Linq; +using NzbDrone.Common.EnsureThat; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Reflection; +using NzbDrone.Core.Annotations; + +namespace Lidarr.Http.ClientSchema +{ + public static class SchemaBuilder + { + private static Dictionary _mappings = new Dictionary(); + + public static List ToSchema(object model) + { + Ensure.That(model, () => model).IsNotNull(); + + var mappings = GetFieldMappings(model.GetType()); + + var result = new List(mappings.Length); + + foreach (var mapping in mappings) + { + var field = mapping.Field.Clone(); + field.Value = mapping.GetterFunc(model); + + result.Add(field); + } + + return result.OrderBy(r => r.Order).ToList(); + } + + public static object ReadFromSchema(List fields, Type targetType) + { + Ensure.That(targetType, () => targetType).IsNotNull(); + + var mappings = GetFieldMappings(targetType); + + var target = Activator.CreateInstance(targetType); + + foreach (var mapping in mappings) + { + var field = fields.Find(f => f.Name == mapping.Field.Name); + + mapping.SetterFunc(target, field.Value); + } + + return target; + + } + + public static T ReadFromSchema(List fields) + { + 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 + GetCamelCaseName(propertyInfo.Name), + Label = fieldAttribute.Label, + Unit = fieldAttribute.Unit, + HelpText = fieldAttribute.HelpText, + HelpLink = fieldAttribute.HelpLink, + Order = fieldAttribute.Order, + Advanced = fieldAttribute.Advanced, + Type = fieldAttribute.Type.ToString().FirstCharToLower(), + Section = fieldAttribute.Section + }; + + if (fieldAttribute.Type == FieldType.Select) + { + field.SelectOptions = GetSelectOptions(fieldAttribute.SelectOptions); + } + + if (fieldAttribute.Hidden != HiddenType.Visible) + { + field.Hidden = fieldAttribute.Hidden.ToString().FirstCharToLower(); + } + + 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, GetCamelCaseName(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) + select new SelectOption { Value = Convert.ToInt32(e), Name = e.ToString() }; + + 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; + } + } + + private static string GetCamelCaseName(string name) + { + return Char.ToLowerInvariant(name[0]) + name.Substring(1); + } + } +} diff --git a/src/NzbDrone.Api/ClientSchema/SelectOption.cs b/src/Lidarr.Http/ClientSchema/SelectOption.cs similarity index 76% rename from src/NzbDrone.Api/ClientSchema/SelectOption.cs rename to src/Lidarr.Http/ClientSchema/SelectOption.cs index fe42f46dd..58029e8fa 100644 --- a/src/NzbDrone.Api/ClientSchema/SelectOption.cs +++ b/src/Lidarr.Http/ClientSchema/SelectOption.cs @@ -1,4 +1,4 @@ -namespace NzbDrone.Api.ClientSchema +namespace Lidarr.Http.ClientSchema { public class SelectOption { diff --git a/src/NzbDrone.Api/ErrorManagement/ErrorHandler.cs b/src/Lidarr.Http/ErrorManagement/ErrorHandler.cs similarity index 93% rename from src/NzbDrone.Api/ErrorManagement/ErrorHandler.cs rename to src/Lidarr.Http/ErrorManagement/ErrorHandler.cs index 6ba25d741..4307f9d53 100644 --- a/src/NzbDrone.Api/ErrorManagement/ErrorHandler.cs +++ b/src/Lidarr.Http/ErrorManagement/ErrorHandler.cs @@ -1,8 +1,8 @@ using Nancy; using Nancy.ErrorHandling; -using NzbDrone.Api.Extensions; +using Lidarr.Http.Extensions; -namespace NzbDrone.Api.ErrorManagement +namespace Lidarr.Http.ErrorManagement { public class ErrorHandler : IStatusCodeHandler { diff --git a/src/NzbDrone.Api/ErrorManagement/ErrorModel.cs b/src/Lidarr.Http/ErrorManagement/ErrorModel.cs similarity index 83% rename from src/NzbDrone.Api/ErrorManagement/ErrorModel.cs rename to src/Lidarr.Http/ErrorManagement/ErrorModel.cs index b88600717..cc2b95562 100644 --- a/src/NzbDrone.Api/ErrorManagement/ErrorModel.cs +++ b/src/Lidarr.Http/ErrorManagement/ErrorModel.cs @@ -1,4 +1,6 @@ -namespace NzbDrone.Api.ErrorManagement +using Lidarr.Http.Exceptions; + +namespace Lidarr.Http.ErrorManagement { public class ErrorModel { diff --git a/src/Lidarr.Http/ErrorManagement/LidarrErrorPipeline.cs b/src/Lidarr.Http/ErrorManagement/LidarrErrorPipeline.cs new file mode 100644 index 000000000..dc36c83e3 --- /dev/null +++ b/src/Lidarr.Http/ErrorManagement/LidarrErrorPipeline.cs @@ -0,0 +1,79 @@ +using System; +using System.Data.SQLite; +using FluentValidation; +using Nancy; +using NLog; +using NzbDrone.Core.Exceptions; +using Lidarr.Http.Exceptions; +using Lidarr.Http.Extensions; +using HttpStatusCode = Nancy.HttpStatusCode; + +namespace Lidarr.Http.ErrorManagement +{ + public class LidarrErrorPipeline + { + private readonly Logger _logger; + + public LidarrErrorPipeline(Logger logger) + { + _logger = logger; + } + + public Response HandleException(NancyContext context, Exception exception) + { + _logger.Trace("Handling Exception"); + + var apiException = exception as ApiException; + + if (apiException != null) + { + _logger.Warn(apiException, "API Error"); + return apiException.ToErrorResponse(); + } + + var validationException = exception as ValidationException; + + if (validationException != null) + { + _logger.Warn("Invalid request {0}", validationException.Message); + + return validationException.Errors.AsResponse(HttpStatusCode.BadRequest); + } + + var clientException = exception as NzbDroneClientException; + + if (clientException != null) + { + return new ErrorModel + { + Message = exception.Message, + Description = exception.ToString() + }.AsResponse((HttpStatusCode)clientException.StatusCode); + } + + var sqLiteException = exception as SQLiteException; + + if (sqLiteException != null) + { + if (context.Request.Method == "PUT" || context.Request.Method == "POST") + { + if (sqLiteException.Message.Contains("constraint failed")) + return new ErrorModel + { + Message = exception.Message, + }.AsResponse(HttpStatusCode.Conflict); + } + + _logger.Error(sqLiteException, "[{0} {1}]", context.Request.Method, context.Request.Path); + } + + _logger.Fatal(exception, "Request Failed. {0} {1}", context.Request.Method, context.Request.Path); + + return new ErrorModel + { + Message = exception.Message, + Description = exception.ToString() + }.AsResponse(HttpStatusCode.InternalServerError); + } + } +} diff --git a/src/NzbDrone.Api/ErrorManagement/ApiException.cs b/src/Lidarr.Http/Exceptions/ApiException.cs similarity index 83% rename from src/NzbDrone.Api/ErrorManagement/ApiException.cs rename to src/Lidarr.Http/Exceptions/ApiException.cs index 2a9f2678f..2e53b76fb 100644 --- a/src/NzbDrone.Api/ErrorManagement/ApiException.cs +++ b/src/Lidarr.Http/Exceptions/ApiException.cs @@ -1,9 +1,10 @@ -using System; +using System; using Nancy; using Nancy.Responses; -using NzbDrone.Api.Extensions; +using Lidarr.Http.ErrorManagement; +using Lidarr.Http.Extensions; -namespace NzbDrone.Api.ErrorManagement +namespace Lidarr.Http.Exceptions { public abstract class ApiException : Exception { @@ -29,10 +30,10 @@ namespace NzbDrone.Api.ErrorManagement if (content != null) { - result = result + " :" + content; + result = $"{result}: {content}"; } return result; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Api/Exceptions/InvalidApiKeyException.cs b/src/Lidarr.Http/Exceptions/InvalidApiKeyException.cs similarity index 87% rename from src/NzbDrone.Api/Exceptions/InvalidApiKeyException.cs rename to src/Lidarr.Http/Exceptions/InvalidApiKeyException.cs index 8c16e8133..067f0ccc4 100644 --- a/src/NzbDrone.Api/Exceptions/InvalidApiKeyException.cs +++ b/src/Lidarr.Http/Exceptions/InvalidApiKeyException.cs @@ -1,6 +1,6 @@ using System; -namespace NzbDrone.Api.Exceptions +namespace Lidarr.Http.Exceptions { public class InvalidApiKeyException : Exception { diff --git a/src/NzbDrone.Api/Extensions/AccessControlHeaders.cs b/src/Lidarr.Http/Extensions/AccessControlHeaders.cs similarity index 92% rename from src/NzbDrone.Api/Extensions/AccessControlHeaders.cs rename to src/Lidarr.Http/Extensions/AccessControlHeaders.cs index 5a32395cb..5ffc632d7 100644 --- a/src/NzbDrone.Api/Extensions/AccessControlHeaders.cs +++ b/src/Lidarr.Http/Extensions/AccessControlHeaders.cs @@ -1,4 +1,4 @@ -namespace NzbDrone.Api.Extensions +namespace Lidarr.Http.Extensions { public static class AccessControlHeaders { diff --git a/src/NzbDrone.Api/Extensions/NancyJsonSerializer.cs b/src/Lidarr.Http/Extensions/NancyJsonSerializer.cs similarity index 93% rename from src/NzbDrone.Api/Extensions/NancyJsonSerializer.cs rename to src/Lidarr.Http/Extensions/NancyJsonSerializer.cs index 00b3c3b2c..ff3459de9 100644 --- a/src/NzbDrone.Api/Extensions/NancyJsonSerializer.cs +++ b/src/Lidarr.Http/Extensions/NancyJsonSerializer.cs @@ -3,7 +3,7 @@ using System.IO; using Nancy; using NzbDrone.Common.Serializer; -namespace NzbDrone.Api.Extensions +namespace Lidarr.Http.Extensions { public class NancyJsonSerializer : ISerializer { diff --git a/src/NzbDrone.Api/Extensions/Pipelines/CacheHeaderPipeline.cs b/src/Lidarr.Http/Extensions/Pipelines/CacheHeaderPipeline.cs similarity index 85% rename from src/NzbDrone.Api/Extensions/Pipelines/CacheHeaderPipeline.cs rename to src/Lidarr.Http/Extensions/Pipelines/CacheHeaderPipeline.cs index 94c738d9b..13a1e824e 100644 --- a/src/NzbDrone.Api/Extensions/Pipelines/CacheHeaderPipeline.cs +++ b/src/Lidarr.Http/Extensions/Pipelines/CacheHeaderPipeline.cs @@ -1,9 +1,9 @@ -using System; +using System; using Nancy; using Nancy.Bootstrapper; -using NzbDrone.Api.Frontend; +using Lidarr.Http.Frontend; -namespace NzbDrone.Api.Extensions.Pipelines +namespace Lidarr.Http.Extensions.Pipelines { public class CacheHeaderPipeline : IRegisterNancyPipeline { @@ -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/Lidarr.Http/Extensions/Pipelines/CorsPipeline.cs b/src/Lidarr.Http/Extensions/Pipelines/CorsPipeline.cs new file mode 100644 index 000000000..3e21c3221 --- /dev/null +++ b/src/Lidarr.Http/Extensions/Pipelines/CorsPipeline.cs @@ -0,0 +1,81 @@ +using System; +using System.Linq; +using Nancy; +using Nancy.Bootstrapper; +using NzbDrone.Common.Extensions; + +namespace Lidarr.Http.Extensions.Pipelines +{ + public class CorsPipeline : IRegisterNancyPipeline + { + public int Order => 0; + + public void Register(IPipelines pipelines) + { + pipelines.BeforeRequest.AddItemToEndOfPipeline(HandleRequest); + pipelines.AfterRequest.AddItemToEndOfPipeline(HandleResponse); + } + + 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)) + { + return; + } + + ApplyResponseHeaders(context.Response, context.Request); + } + + private static void ApplyResponseHeaders(Response response, Request request) + { + if (request.IsApiRequest()) + { + // 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"); + } + else if (request.IsSharedContentRequest()) + { + // 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/Lidarr.Http/Extensions/Pipelines/GZipPipeline.cs b/src/Lidarr.Http/Extensions/Pipelines/GZipPipeline.cs new file mode 100644 index 000000000..26d27aacb --- /dev/null +++ b/src/Lidarr.Http/Extensions/Pipelines/GZipPipeline.cs @@ -0,0 +1,106 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using Nancy; +using Nancy.Bootstrapper; +using NLog; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Extensions; + +namespace Lidarr.Http.Extensions.Pipelines +{ + public class GzipCompressionPipeline : IRegisterNancyPipeline + { + private readonly Logger _logger; + + public int Order => 0; + + private readonly Action, Stream> _writeGZipStream; + + public GzipCompressionPipeline(Logger logger) + { + _logger = logger; + + // On Mono GZipStream/DeflateStream leaks memory if an exception is thrown, use an intermediate buffer in that case. + _writeGZipStream = PlatformInfo.IsMono ? WriteGZipStreamMono : (Action, Stream>)WriteGZipStream; + } + + public void Register(IPipelines pipelines) + { + pipelines.AfterRequest.AddItemToEndOfPipeline(CompressResponse); + } + + private void CompressResponse(NancyContext context) + { + var request = context.Request; + var response = context.Response; + + try + { + if ( + response.Contents != Response.NoBody + && !response.ContentType.Contains("image") + && !response.ContentType.Contains("font") + && request.Headers.AcceptEncoding.Any(x => x.Contains("gzip")) + && !AlreadyGzipEncoded(response) + && !ContentLengthIsTooSmall(response)) + { + var contents = response.Contents; + + response.Headers["Content-Encoding"] = "gzip"; + response.Contents = responseStream => _writeGZipStream(contents, responseStream); + } + } + + catch (Exception ex) + { + _logger.Error(ex, "Unable to gzip response"); + throw; + } + } + + private static void WriteGZipStreamMono(Action innerContent, Stream targetStream) + { + using (var membuffer = new MemoryStream()) + { + WriteGZipStream(innerContent, membuffer); + membuffer.Position = 0; + membuffer.CopyTo(targetStream); + } + } + + private static void WriteGZipStream(Action innerContent, Stream targetStream) + { + using (var gzip = new GZipStream(targetStream, CompressionMode.Compress, true)) + using (var buffered = new BufferedStream(gzip, 8192)) + { + innerContent.Invoke(buffered); + } + } + + private static bool ContentLengthIsTooSmall(Response response) + { + var contentLength = response.Headers.GetValueOrDefault("Content-Length"); + + if (contentLength != null && long.Parse(contentLength) < 1024) + { + return true; + } + + return false; + } + + private static bool AlreadyGzipEncoded(Response response) + { + var contentEncoding = response.Headers.GetValueOrDefault("Content-Encoding"); + + if (contentEncoding == "gzip") + { + return true; + } + + return false; + } + } +} diff --git a/src/NzbDrone.Api/Extensions/Pipelines/IRegisterNancyPipeline.cs b/src/Lidarr.Http/Extensions/Pipelines/IRegisterNancyPipeline.cs similarity index 78% rename from src/NzbDrone.Api/Extensions/Pipelines/IRegisterNancyPipeline.cs rename to src/Lidarr.Http/Extensions/Pipelines/IRegisterNancyPipeline.cs index 0376ccc70..84105c46b 100644 --- a/src/NzbDrone.Api/Extensions/Pipelines/IRegisterNancyPipeline.cs +++ b/src/Lidarr.Http/Extensions/Pipelines/IRegisterNancyPipeline.cs @@ -1,6 +1,6 @@ using Nancy.Bootstrapper; -namespace NzbDrone.Api.Extensions.Pipelines +namespace Lidarr.Http.Extensions.Pipelines { public interface IRegisterNancyPipeline { diff --git a/src/NzbDrone.Api/Extensions/Pipelines/IfModifiedPipeline.cs b/src/Lidarr.Http/Extensions/Pipelines/IfModifiedPipeline.cs similarity index 93% rename from src/NzbDrone.Api/Extensions/Pipelines/IfModifiedPipeline.cs rename to src/Lidarr.Http/Extensions/Pipelines/IfModifiedPipeline.cs index 68abf4ade..cf619745d 100644 --- a/src/NzbDrone.Api/Extensions/Pipelines/IfModifiedPipeline.cs +++ b/src/Lidarr.Http/Extensions/Pipelines/IfModifiedPipeline.cs @@ -1,9 +1,9 @@ using System; using Nancy; using Nancy.Bootstrapper; -using NzbDrone.Api.Frontend; +using Lidarr.Http.Frontend; -namespace NzbDrone.Api.Extensions.Pipelines +namespace Lidarr.Http.Extensions.Pipelines { public class IfModifiedPipeline : IRegisterNancyPipeline { diff --git a/src/Lidarr.Http/Extensions/Pipelines/LidarrVersionPipeline.cs b/src/Lidarr.Http/Extensions/Pipelines/LidarrVersionPipeline.cs new file mode 100644 index 000000000..1a425031d --- /dev/null +++ b/src/Lidarr.Http/Extensions/Pipelines/LidarrVersionPipeline.cs @@ -0,0 +1,25 @@ +using System; +using Nancy; +using Nancy.Bootstrapper; +using NzbDrone.Common.EnvironmentInfo; + +namespace Lidarr.Http.Extensions.Pipelines +{ + public class LidarrVersionPipeline : IRegisterNancyPipeline + { + public int Order => 0; + + public void Register(IPipelines pipelines) + { + pipelines.AfterRequest.AddItemToStartOfPipeline((Action) Handle); + } + + private void Handle(NancyContext context) + { + if (!context.Response.Headers.ContainsKey("X-ApplicationVersion")) + { + context.Response.Headers.Add("X-ApplicationVersion", BuildInfo.Version.ToString()); + } + } + } +} diff --git a/src/NzbDrone.Api/Extensions/Pipelines/RequestLoggingPipeline.cs b/src/Lidarr.Http/Extensions/Pipelines/RequestLoggingPipeline.cs similarity index 91% rename from src/NzbDrone.Api/Extensions/Pipelines/RequestLoggingPipeline.cs rename to src/Lidarr.Http/Extensions/Pipelines/RequestLoggingPipeline.cs index 73668bc81..34d40cd5a 100644 --- a/src/NzbDrone.Api/Extensions/Pipelines/RequestLoggingPipeline.cs +++ b/src/Lidarr.Http/Extensions/Pipelines/RequestLoggingPipeline.cs @@ -1,91 +1,93 @@ -using System; -using System.Threading; -using Nancy; -using Nancy.Bootstrapper; -using NLog; -using NzbDrone.Api.ErrorManagement; -using NzbDrone.Common.Extensions; - -namespace NzbDrone.Api.Extensions.Pipelines -{ - public class RequestLoggingPipeline : IRegisterNancyPipeline - { - private static readonly Logger _loggerHttp = LogManager.GetLogger("Http"); - private static readonly Logger _loggerApi = LogManager.GetLogger("Api"); - - private static int _requestSequenceID; - - private readonly NzbDroneErrorPipeline _errorPipeline; - - public RequestLoggingPipeline(NzbDroneErrorPipeline errorPipeline) - { - _errorPipeline = errorPipeline; - } - - public int Order => 100; - - public void Register(IPipelines pipelines) - { - pipelines.BeforeRequest.AddItemToStartOfPipeline(LogStart); - pipelines.AfterRequest.AddItemToEndOfPipeline(LogEnd); - pipelines.OnError.AddItemToEndOfPipeline(LogError); - } - - private Response LogStart(NancyContext context) - { - var id = Interlocked.Increment(ref _requestSequenceID); - - context.Items["ApiRequestSequenceID"] = id; - context.Items["ApiRequestStartTime"] = DateTime.UtcNow; - - var reqPath = GetRequestPathAndQuery(context.Request); - - _loggerHttp.Trace("Req: {0} [{1}] {2}", id, context.Request.Method, reqPath); - - return null; - } - - private void LogEnd(NancyContext context) - { - var id = (int)context.Items["ApiRequestSequenceID"]; - var startTime = (DateTime)context.Items["ApiRequestStartTime"]; - - var endTime = DateTime.UtcNow; - var duration = endTime - startTime; - - var reqPath = GetRequestPathAndQuery(context.Request); - - _loggerHttp.Trace("Res: {0} [{1}] {2}: {3}.{4} ({5} ms)", id, context.Request.Method, reqPath, (int)context.Response.StatusCode, context.Response.StatusCode, (int)duration.TotalMilliseconds); - - if (context.Request.IsApiRequest()) - { - _loggerApi.Debug("[{0}] {1}: {2}.{3} ({4} ms)", context.Request.Method, reqPath, (int)context.Response.StatusCode, context.Response.StatusCode, (int)duration.TotalMilliseconds); - } - } - - private Response LogError(NancyContext context, Exception exception) - { - var response = _errorPipeline.HandleException(context, exception); - - context.Response = response; - - LogEnd(context); - - context.Response = null; - - return response; - } - - private static string GetRequestPathAndQuery(Request request) - { - if (request.Url.Query.IsNotNullOrWhiteSpace()) - { - return string.Concat(request.Url.Path, request.Url.Query); - } - else - { - return request.Url.Path; - } - } - } -} +using System; +using System.Threading; +using Nancy; +using Nancy.Bootstrapper; +using NLog; +using NzbDrone.Common.Extensions; +using Lidarr.Http.ErrorManagement; +using Lidarr.Http.Extensions; +using Lidarr.Http.Extensions.Pipelines; + +namespace NzbDrone.Api.Extensions.Pipelines +{ + public class RequestLoggingPipeline : IRegisterNancyPipeline + { + private static readonly Logger _loggerHttp = LogManager.GetLogger("Http"); + private static readonly Logger _loggerApi = LogManager.GetLogger("Api"); + + private static int _requestSequenceID; + + private readonly LidarrErrorPipeline _errorPipeline; + + public RequestLoggingPipeline(LidarrErrorPipeline errorPipeline) + { + _errorPipeline = errorPipeline; + } + + public int Order => 100; + + public void Register(IPipelines pipelines) + { + pipelines.BeforeRequest.AddItemToStartOfPipeline(LogStart); + pipelines.AfterRequest.AddItemToEndOfPipeline(LogEnd); + pipelines.OnError.AddItemToEndOfPipeline(LogError); + } + + private Response LogStart(NancyContext context) + { + var id = Interlocked.Increment(ref _requestSequenceID); + + context.Items["ApiRequestSequenceID"] = id; + context.Items["ApiRequestStartTime"] = DateTime.UtcNow; + + var reqPath = GetRequestPathAndQuery(context.Request); + + _loggerHttp.Trace("Req: {0} [{1}] {2}", id, context.Request.Method, reqPath); + + return null; + } + + private void LogEnd(NancyContext context) + { + var id = (int)context.Items["ApiRequestSequenceID"]; + var startTime = (DateTime)context.Items["ApiRequestStartTime"]; + + var endTime = DateTime.UtcNow; + var duration = endTime - startTime; + + var reqPath = GetRequestPathAndQuery(context.Request); + + _loggerHttp.Trace("Res: {0} [{1}] {2}: {3}.{4} ({5} ms)", id, context.Request.Method, reqPath, (int)context.Response.StatusCode, context.Response.StatusCode, (int)duration.TotalMilliseconds); + + if (context.Request.IsApiRequest()) + { + _loggerApi.Debug("[{0}] {1}: {2}.{3} ({4} ms)", context.Request.Method, reqPath, (int)context.Response.StatusCode, context.Response.StatusCode, (int)duration.TotalMilliseconds); + } + } + + private Response LogError(NancyContext context, Exception exception) + { + var response = _errorPipeline.HandleException(context, exception); + + context.Response = response; + + LogEnd(context); + + context.Response = null; + + return response; + } + + private static string GetRequestPathAndQuery(Request request) + { + if (request.Url.Query.IsNotNullOrWhiteSpace()) + { + return string.Concat(request.Url.Path, request.Url.Query); + } + else + { + return request.Url.Path; + } + } + } +} diff --git a/src/Lidarr.Http/Extensions/Pipelines/UrlBasePipeline.cs b/src/Lidarr.Http/Extensions/Pipelines/UrlBasePipeline.cs new file mode 100644 index 000000000..85793213f --- /dev/null +++ b/src/Lidarr.Http/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 Lidarr.Http.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; + } + } +} diff --git a/src/NzbDrone.Api/Extensions/ReqResExtensions.cs b/src/Lidarr.Http/Extensions/ReqResExtensions.cs similarity index 96% rename from src/NzbDrone.Api/Extensions/ReqResExtensions.cs rename to src/Lidarr.Http/Extensions/ReqResExtensions.cs index 1f1d89180..b34a7200d 100644 --- a/src/NzbDrone.Api/Extensions/ReqResExtensions.cs +++ b/src/Lidarr.Http/Extensions/ReqResExtensions.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using Nancy; @@ -6,7 +6,7 @@ using Nancy.Responses; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Serializer; -namespace NzbDrone.Api.Extensions +namespace Lidarr.Http.Extensions { public static class ReqResExtensions { @@ -42,7 +42,7 @@ namespace NzbDrone.Api.Extensions public static IDictionary DisableCache(this IDictionary headers) { - headers["Cache-Control"] = "no-cache, no-store, must-revalidate"; + headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"; headers["Pragma"] = "no-cache"; headers["Expires"] = "0"; @@ -59,4 +59,4 @@ namespace NzbDrone.Api.Extensions return headers; } } -} \ No newline at end of file +} diff --git a/src/Lidarr.Http/Extensions/RequestExtensions.cs b/src/Lidarr.Http/Extensions/RequestExtensions.cs new file mode 100644 index 000000000..08ac6867c --- /dev/null +++ b/src/Lidarr.Http/Extensions/RequestExtensions.cs @@ -0,0 +1,58 @@ +using System; +using Nancy; + +namespace Lidarr.Http.Extensions +{ + public static class RequestExtensions + { + public static bool IsApiRequest(this Request request) + { + return request.Path.StartsWith("/api/", StringComparison.InvariantCultureIgnoreCase); + } + + public static bool IsFeedRequest(this Request request) + { + return request.Path.StartsWith("/feed/", StringComparison.InvariantCultureIgnoreCase); + } + + public static bool IsSignalRRequest(this Request request) + { + return request.Path.StartsWith("/signalr/", StringComparison.InvariantCultureIgnoreCase); + } + + public static bool IsLocalRequest(this Request request) + { + return (request.UserHostAddress.Equals("localhost") || + request.UserHostAddress.Equals("127.0.0.1") || + request.UserHostAddress.Equals("::1")); + } + + public static bool IsLoginRequest(this Request request) + { + return request.Path.Equals("/login", StringComparison.InvariantCultureIgnoreCase); + } + + public static bool IsContentRequest(this Request request) + { + return request.Path.StartsWith("/Content/", 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; + } + + public static bool IsSharedContentRequest(this Request request) + { + return request.Path.StartsWith("/MediaCover/", StringComparison.InvariantCultureIgnoreCase) || + request.Path.StartsWith("/Content/Images/", StringComparison.InvariantCultureIgnoreCase); + } + } +} diff --git a/src/NzbDrone.Api/Frontend/CacheableSpecification.cs b/src/Lidarr.Http/Frontend/CacheableSpecification.cs similarity index 88% rename from src/NzbDrone.Api/Frontend/CacheableSpecification.cs rename to src/Lidarr.Http/Frontend/CacheableSpecification.cs index 7995c7da1..0d85374d2 100644 --- a/src/NzbDrone.Api/Frontend/CacheableSpecification.cs +++ b/src/Lidarr.Http/Frontend/CacheableSpecification.cs @@ -3,7 +3,7 @@ using Nancy; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; -namespace NzbDrone.Api.Frontend +namespace Lidarr.Http.Frontend { public interface ICacheableSpecification { @@ -29,7 +29,8 @@ namespace NzbDrone.Api.Frontend } if (context.Request.Path.StartsWith("/signalr", StringComparison.CurrentCultureIgnoreCase)) return false; - if (context.Request.Path.EndsWith("main.js")) return false; + if (context.Request.Path.EndsWith("index.js")) return false; + if (context.Request.Path.EndsWith("initialize.js")) return false; if (context.Request.Path.StartsWith("/feed", StringComparison.CurrentCultureIgnoreCase)) return false; if (context.Request.Path.StartsWith("/log", StringComparison.CurrentCultureIgnoreCase) && @@ -46,4 +47,4 @@ namespace NzbDrone.Api.Frontend return true; } } -} \ No newline at end of file +} diff --git a/src/Lidarr.Http/Frontend/InitializeJsModule.cs b/src/Lidarr.Http/Frontend/InitializeJsModule.cs new file mode 100644 index 000000000..b30f7221f --- /dev/null +++ b/src/Lidarr.Http/Frontend/InitializeJsModule.cs @@ -0,0 +1,80 @@ +using System.IO; +using System.Text; +using Nancy; +using Nancy.Responses; +using NzbDrone.Common; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Core.Analytics; +using NzbDrone.Core.Configuration; + +namespace Lidarr.Http.Frontend +{ + public class InitializeJsModule : NancyModule + { + private readonly IConfigFileProvider _configFileProvider; + private readonly IAnalyticsService _analyticsService; + + private static string _apiKey; + private static string _urlBase; + private string _generatedContent; + + + public InitializeJsModule(IConfigFileProvider configFileProvider, + IAnalyticsService analyticsService) + { + _configFileProvider = configFileProvider; + _analyticsService = analyticsService; + + _apiKey = configFileProvider.ApiKey; + _urlBase = configFileProvider.UrlBase; + + Get["/initialize.js"] = x => Index(); + } + + private Response Index() + { + // TODO: Move away from window.Lidarr and prefetch the information returned here when starting the UI + return new StreamResponse(GetContentStream, "application/javascript"); + } + + private Stream GetContentStream() + { + var text = GetContent(); + + var stream = new MemoryStream(); + var writer = new StreamWriter(stream); + + + writer.Write(text); + writer.Flush(); + stream.Position = 0; + + return stream; + } + + private string GetContent() + { + if (RuntimeInfo.IsProduction && _generatedContent != null) + { + return _generatedContent; + } + + var builder = new StringBuilder(); + builder.AppendLine("window.Lidarr = {"); + builder.AppendLine($" apiRoot: '{_urlBase}/api/v1',"); + builder.AppendLine($" apiKey: '{_apiKey}',"); + builder.AppendLine($" release: '{BuildInfo.Release}',"); + builder.AppendLine($" version: '{BuildInfo.Version.ToString()}',"); + builder.AppendLine($" branch: '{_configFileProvider.Branch.ToLower()}',"); + builder.AppendLine($" analytics: {_analyticsService.IsEnabled.ToString().ToLowerInvariant()},"); + builder.AppendLine($" userHash: '{HashUtil.AnonymousToken()}',"); + builder.AppendLine($" urlBase: '{_urlBase}',"); + builder.AppendLine($" isProduction: {RuntimeInfo.IsProduction.ToString().ToLowerInvariant()}"); + builder.AppendLine("};"); + + _generatedContent = builder.ToString(); + + return _generatedContent; + } + } +} diff --git a/src/Lidarr.Http/Frontend/Mappers/BackupFileMapper.cs b/src/Lidarr.Http/Frontend/Mappers/BackupFileMapper.cs new file mode 100644 index 000000000..610fdf47e --- /dev/null +++ b/src/Lidarr.Http/Frontend/Mappers/BackupFileMapper.cs @@ -0,0 +1,30 @@ +using System.IO; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Core.Backup; + +namespace Lidarr.Http.Frontend.Mappers +{ + public class BackupFileMapper : StaticResourceMapperBase + { + private readonly IBackupService _backupService; + + public BackupFileMapper(IBackupService backupService, IDiskProvider diskProvider, Logger logger) + : base(diskProvider, logger) + { + _backupService = backupService; + } + + public override string Map(string resourceUrl) + { + var path = resourceUrl.Replace("/backup/", "").Replace('/', Path.DirectorySeparatorChar); + + return Path.Combine(_backupService.GetBackupFolder(), path); + } + + public override bool CanHandle(string resourceUrl) + { + return resourceUrl.StartsWith("/backup/") && BackupService.BackupFileRegex.IsMatch(resourceUrl); + } + } +} diff --git a/src/Lidarr.Http/Frontend/Mappers/BrowserConfig.cs b/src/Lidarr.Http/Frontend/Mappers/BrowserConfig.cs new file mode 100644 index 000000000..4d7bb0808 --- /dev/null +++ b/src/Lidarr.Http/Frontend/Mappers/BrowserConfig.cs @@ -0,0 +1,34 @@ +using System.IO; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Core.Configuration; + +namespace Lidarr.Http.Frontend.Mappers +{ + public class BrowserConfig : StaticResourceMapperBase + { + private readonly IAppFolderInfo _appFolderInfo; + private readonly IConfigFileProvider _configFileProvider; + + public BrowserConfig(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, IConfigFileProvider configFileProvider, Logger logger) + : base(diskProvider, logger) + { + _appFolderInfo = appFolderInfo; + _configFileProvider = configFileProvider; + } + + public override string Map(string resourceUrl) + { + var path = resourceUrl.Replace('/', Path.DirectorySeparatorChar); + path = path.Trim(Path.DirectorySeparatorChar); + + return Path.ChangeExtension(Path.Combine(_appFolderInfo.StartUpFolder, _configFileProvider.UiFolder, path), "xml"); + } + + public override bool CanHandle(string resourceUrl) + { + return resourceUrl.StartsWith("/Content/Images/Icons/browserconfig"); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Api/Frontend/Mappers/CacheBreakerProvider.cs b/src/Lidarr.Http/Frontend/Mappers/CacheBreakerProvider.cs similarity index 96% rename from src/NzbDrone.Api/Frontend/Mappers/CacheBreakerProvider.cs rename to src/Lidarr.Http/Frontend/Mappers/CacheBreakerProvider.cs index 53ebb2986..f165594a8 100644 --- a/src/NzbDrone.Api/Frontend/Mappers/CacheBreakerProvider.cs +++ b/src/Lidarr.Http/Frontend/Mappers/CacheBreakerProvider.cs @@ -3,7 +3,7 @@ using System.Linq; using NzbDrone.Common.Crypto; using NzbDrone.Common.Extensions; -namespace NzbDrone.Api.Frontend.Mappers +namespace Lidarr.Http.Frontend.Mappers { public interface ICacheBreakerProvider { diff --git a/src/NzbDrone.Api/Frontend/Mappers/FaviconMapper.cs b/src/Lidarr.Http/Frontend/Mappers/FaviconMapper.cs similarity index 88% rename from src/NzbDrone.Api/Frontend/Mappers/FaviconMapper.cs rename to src/Lidarr.Http/Frontend/Mappers/FaviconMapper.cs index 002ffa7ce..c269fa1b3 100644 --- a/src/NzbDrone.Api/Frontend/Mappers/FaviconMapper.cs +++ b/src/Lidarr.Http/Frontend/Mappers/FaviconMapper.cs @@ -1,10 +1,10 @@ -using System.IO; +using System.IO; using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Configuration; -namespace NzbDrone.Api.Frontend.Mappers +namespace Lidarr.Http.Frontend.Mappers { public class FaviconMapper : StaticResourceMapperBase { @@ -27,7 +27,7 @@ namespace NzbDrone.Api.Frontend.Mappers fileName = "favicon-debug.ico"; } - var path = Path.Combine("Content", "Images", fileName); + var path = Path.Combine("Content", "Images", "Icons", fileName); return Path.Combine(_appFolderInfo.StartUpFolder, _configFileProvider.UiFolder, path); } diff --git a/src/Lidarr.Http/Frontend/Mappers/HtmlMapperBase.cs b/src/Lidarr.Http/Frontend/Mappers/HtmlMapperBase.cs new file mode 100644 index 000000000..aac46ce45 --- /dev/null +++ b/src/Lidarr.Http/Frontend/Mappers/HtmlMapperBase.cs @@ -0,0 +1,82 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; +using Nancy; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.EnvironmentInfo; + +namespace Lidarr.Http.Frontend.Mappers +{ + public abstract class HtmlMapperBase : StaticResourceMapperBase + { + private readonly IDiskProvider _diskProvider; + private readonly Func _cacheBreakProviderFactory; + private static readonly Regex ReplaceRegex = new Regex(@"(?:(?href|src)=\"")(?.*?(?css|js|png|ico|ics|svg|json))(?:\"")(?:\s(?data-no-hash))?", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private string _generatedContent; + + protected HtmlMapperBase(IDiskProvider diskProvider, + Func cacheBreakProviderFactory, + Logger logger) : base(diskProvider, logger) + { + _diskProvider = diskProvider; + _cacheBreakProviderFactory = cacheBreakProviderFactory; + } + + protected string HtmlPath; + protected string UrlBase; + + protected override Stream GetContentStream(string filePath) + { + var text = GetHtmlText(); + + var stream = new MemoryStream(); + var writer = new StreamWriter(stream); + writer.Write(text); + writer.Flush(); + stream.Position = 0; + return stream; + } + + public override Response GetResponse(string resourceUrl) + { + var response = base.GetResponse(resourceUrl); + response.Headers["X-UA-Compatible"] = "IE=edge"; + + return response; + } + + protected string GetHtmlText() + { + if (RuntimeInfo.IsProduction && _generatedContent != null) + { + return _generatedContent; + } + + var text = _diskProvider.ReadAllText(HtmlPath); + var cacheBreakProvider = _cacheBreakProviderFactory(); + + text = ReplaceRegex.Replace(text, match => + { + string url; + + if (match.Groups["nohash"].Success) + { + url = match.Groups["path"].Value; + } + + else + { + url = cacheBreakProvider.AddCacheBreakerToPath(match.Groups["path"].Value); + } + + return string.Format("{0}=\"{1}{2}\"", match.Groups["attribute"].Value, UrlBase, url); + }); + + _generatedContent = text; + + return _generatedContent; + } + } +} diff --git a/src/NzbDrone.Api/Frontend/Mappers/IMapHttpRequestsToDisk.cs b/src/Lidarr.Http/Frontend/Mappers/IMapHttpRequestsToDisk.cs similarity index 84% rename from src/NzbDrone.Api/Frontend/Mappers/IMapHttpRequestsToDisk.cs rename to src/Lidarr.Http/Frontend/Mappers/IMapHttpRequestsToDisk.cs index 6390a2545..1bfd458ad 100644 --- a/src/NzbDrone.Api/Frontend/Mappers/IMapHttpRequestsToDisk.cs +++ b/src/Lidarr.Http/Frontend/Mappers/IMapHttpRequestsToDisk.cs @@ -1,7 +1,7 @@  using Nancy; -namespace NzbDrone.Api.Frontend.Mappers +namespace Lidarr.Http.Frontend.Mappers { public interface IMapHttpRequestsToDisk { diff --git a/src/Lidarr.Http/Frontend/Mappers/IndexHtmlMapper.cs b/src/Lidarr.Http/Frontend/Mappers/IndexHtmlMapper.cs new file mode 100644 index 000000000..938553a18 --- /dev/null +++ b/src/Lidarr.Http/Frontend/Mappers/IndexHtmlMapper.cs @@ -0,0 +1,43 @@ +using System; +using System.IO; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Core.Configuration; + +namespace Lidarr.Http.Frontend.Mappers +{ + public class IndexHtmlMapper : HtmlMapperBase + { + private readonly IConfigFileProvider _configFileProvider; + + public IndexHtmlMapper(IAppFolderInfo appFolderInfo, + IDiskProvider diskProvider, + IConfigFileProvider configFileProvider, + Func cacheBreakProviderFactory, + Logger logger) + : base(diskProvider, cacheBreakProviderFactory, logger) + { + _configFileProvider = configFileProvider; + + HtmlPath = Path.Combine(appFolderInfo.StartUpFolder, _configFileProvider.UiFolder, "index.html"); + UrlBase = configFileProvider.UrlBase; + } + + public override string Map(string resourceUrl) + { + return HtmlPath; + } + + public override bool CanHandle(string resourceUrl) + { + resourceUrl = resourceUrl.ToLowerInvariant(); + + return !resourceUrl.StartsWith("/content") && + !resourceUrl.StartsWith("/mediacover") && + !resourceUrl.Contains(".") && + !resourceUrl.StartsWith("/login"); + + } + } +} diff --git a/src/NzbDrone.Api/Frontend/Mappers/LogFileMapper.cs b/src/Lidarr.Http/Frontend/Mappers/LogFileMapper.cs similarity index 95% rename from src/NzbDrone.Api/Frontend/Mappers/LogFileMapper.cs rename to src/Lidarr.Http/Frontend/Mappers/LogFileMapper.cs index 5fda7d483..c6f73732f 100644 --- a/src/NzbDrone.Api/Frontend/Mappers/LogFileMapper.cs +++ b/src/Lidarr.Http/Frontend/Mappers/LogFileMapper.cs @@ -4,7 +4,7 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; -namespace NzbDrone.Api.Frontend.Mappers +namespace Lidarr.Http.Frontend.Mappers { public class UpdateLogFileMapper : StaticResourceMapperBase { diff --git a/src/Lidarr.Http/Frontend/Mappers/LoginHtmlMapper.cs b/src/Lidarr.Http/Frontend/Mappers/LoginHtmlMapper.cs new file mode 100644 index 000000000..da80817ee --- /dev/null +++ b/src/Lidarr.Http/Frontend/Mappers/LoginHtmlMapper.cs @@ -0,0 +1,34 @@ +using System; +using System.IO; +using Nancy; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Core.Configuration; + +namespace Lidarr.Http.Frontend.Mappers +{ + public class LoginHtmlMapper : HtmlMapperBase + { + public LoginHtmlMapper(IAppFolderInfo appFolderInfo, + IDiskProvider diskProvider, + Func cacheBreakProviderFactory, + IConfigFileProvider configFileProvider, + Logger logger) + : base(diskProvider, cacheBreakProviderFactory, logger) + { + HtmlPath = Path.Combine(appFolderInfo.StartUpFolder, configFileProvider.UiFolder, "login.html"); + UrlBase = configFileProvider.UrlBase; + } + + public override string Map(string resourceUrl) + { + return HtmlPath; + } + + public override bool CanHandle(string resourceUrl) + { + return resourceUrl.StartsWith("/login"); + } + } +} diff --git a/src/Lidarr.Http/Frontend/Mappers/ManifestMapper.cs b/src/Lidarr.Http/Frontend/Mappers/ManifestMapper.cs new file mode 100644 index 000000000..92afc08ca --- /dev/null +++ b/src/Lidarr.Http/Frontend/Mappers/ManifestMapper.cs @@ -0,0 +1,34 @@ +using System.IO; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Core.Configuration; + +namespace Lidarr.Http.Frontend.Mappers +{ + public class ManifestMapper : StaticResourceMapperBase + { + private readonly IAppFolderInfo _appFolderInfo; + private readonly IConfigFileProvider _configFileProvider; + + public ManifestMapper(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, IConfigFileProvider configFileProvider, Logger logger) + : base(diskProvider, logger) + { + _appFolderInfo = appFolderInfo; + _configFileProvider = configFileProvider; + } + + public override string Map(string resourceUrl) + { + var path = resourceUrl.Replace('/', Path.DirectorySeparatorChar); + path = path.Trim(Path.DirectorySeparatorChar); + + return Path.ChangeExtension(Path.Combine(_appFolderInfo.StartUpFolder, _configFileProvider.UiFolder, path), "json"); + } + + public override bool CanHandle(string resourceUrl) + { + return resourceUrl.StartsWith("/Content/Images/Icons/manifest"); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Api/Frontend/Mappers/MediaCoverMapper.cs b/src/Lidarr.Http/Frontend/Mappers/MediaCoverMapper.cs similarity index 84% rename from src/NzbDrone.Api/Frontend/Mappers/MediaCoverMapper.cs rename to src/Lidarr.Http/Frontend/Mappers/MediaCoverMapper.cs index a4e5fb8f2..b794ad686 100644 --- a/src/NzbDrone.Api/Frontend/Mappers/MediaCoverMapper.cs +++ b/src/Lidarr.Http/Frontend/Mappers/MediaCoverMapper.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using System.Text.RegularExpressions; using NLog; @@ -5,11 +6,11 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; -namespace NzbDrone.Api.Frontend.Mappers +namespace Lidarr.Http.Frontend.Mappers { public class MediaCoverMapper : StaticResourceMapperBase { - private static readonly Regex RegexResizedImage = new Regex(@"-\d+\.jpg($|\?)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex RegexResizedImage = new Regex(@"-\d+(?=\.(jpg|png|gif)($|\?))", RegexOptions.Compiled | RegexOptions.IgnoreCase); private readonly IAppFolderInfo _appFolderInfo; private readonly IDiskProvider _diskProvider; @@ -30,7 +31,7 @@ namespace NzbDrone.Api.Frontend.Mappers if (!_diskProvider.FileExists(resourcePath) || _diskProvider.GetFileSize(resourcePath) == 0) { - var baseResourcePath = RegexResizedImage.Replace(resourcePath, ".jpg$1"); + var baseResourcePath = RegexResizedImage.Replace(resourcePath, ""); if (baseResourcePath != resourcePath) { return baseResourcePath; @@ -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/RobotsTxtMapper.cs b/src/Lidarr.Http/Frontend/Mappers/RobotsTxtMapper.cs similarity index 96% rename from src/NzbDrone.Api/Frontend/Mappers/RobotsTxtMapper.cs rename to src/Lidarr.Http/Frontend/Mappers/RobotsTxtMapper.cs index 60b3131c6..d6bfedb5f 100644 --- a/src/NzbDrone.Api/Frontend/Mappers/RobotsTxtMapper.cs +++ b/src/Lidarr.Http/Frontend/Mappers/RobotsTxtMapper.cs @@ -4,7 +4,7 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Configuration; -namespace NzbDrone.Api.Frontend.Mappers +namespace Lidarr.Http.Frontend.Mappers { public class RobotsTxtMapper : StaticResourceMapperBase { diff --git a/src/Lidarr.Http/Frontend/Mappers/StaticResourceMapper.cs b/src/Lidarr.Http/Frontend/Mappers/StaticResourceMapper.cs new file mode 100644 index 000000000..98f282961 --- /dev/null +++ b/src/Lidarr.Http/Frontend/Mappers/StaticResourceMapper.cs @@ -0,0 +1,48 @@ +using System.IO; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Core.Configuration; + +namespace Lidarr.Http.Frontend.Mappers +{ + public class StaticResourceMapper : StaticResourceMapperBase + { + private readonly IAppFolderInfo _appFolderInfo; + private readonly IConfigFileProvider _configFileProvider; + + public StaticResourceMapper(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, IConfigFileProvider configFileProvider, Logger logger) + : base(diskProvider, logger) + { + _appFolderInfo = appFolderInfo; + _configFileProvider = configFileProvider; + } + + public override string Map(string resourceUrl) + { + var path = resourceUrl.Replace('/', Path.DirectorySeparatorChar); + path = path.Trim(Path.DirectorySeparatorChar); + + return Path.Combine(_appFolderInfo.StartUpFolder, _configFileProvider.UiFolder, path); + } + + public override bool CanHandle(string resourceUrl) + { + resourceUrl = resourceUrl.ToLowerInvariant(); + + if (resourceUrl.StartsWith("/content/images/icons/manifest") || + resourceUrl.StartsWith("/content/images/icons/browserconfig")) + { + return false; + } + + return resourceUrl.StartsWith("/content") || + (resourceUrl.EndsWith(".js") && !resourceUrl.EndsWith("initialize.js")) || + resourceUrl.EndsWith(".map") || + resourceUrl.EndsWith(".css") || + (resourceUrl.EndsWith(".ico") && !resourceUrl.Equals("/favicon.ico")) || + resourceUrl.EndsWith(".swf") || + resourceUrl.EndsWith("oauth.html"); + } + } +} diff --git a/src/NzbDrone.Api/Frontend/Mappers/StaticResourceMapperBase.cs b/src/Lidarr.Http/Frontend/Mappers/StaticResourceMapperBase.cs similarity index 85% rename from src/NzbDrone.Api/Frontend/Mappers/StaticResourceMapperBase.cs rename to src/Lidarr.Http/Frontend/Mappers/StaticResourceMapperBase.cs index 489d039d0..96307eae7 100644 --- a/src/NzbDrone.Api/Frontend/Mappers/StaticResourceMapperBase.cs +++ b/src/Lidarr.Http/Frontend/Mappers/StaticResourceMapperBase.cs @@ -1,12 +1,12 @@ using System; using System.IO; -using NLog; using Nancy; using Nancy.Responses; +using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; -namespace NzbDrone.Api.Frontend.Mappers +namespace Lidarr.Http.Frontend.Mappers { public abstract class StaticResourceMapperBase : IMapHttpRequestsToDisk { @@ -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); @@ -38,7 +35,7 @@ namespace NzbDrone.Api.Frontend.Mappers if (_diskProvider.FileExists(filePath, _caseSensitive)) { var response = new StreamResponse(() => GetContentStream(filePath), MimeTypes.GetMimeType(filePath)); - return response; + return new MaterialisingResponse(response); } _logger.Warn("File {0} not found", filePath); @@ -52,4 +49,4 @@ namespace NzbDrone.Api.Frontend.Mappers } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Api/Frontend/Mappers/UpdateLogFileMapper.cs b/src/Lidarr.Http/Frontend/Mappers/UpdateLogFileMapper.cs similarity index 95% rename from src/NzbDrone.Api/Frontend/Mappers/UpdateLogFileMapper.cs rename to src/Lidarr.Http/Frontend/Mappers/UpdateLogFileMapper.cs index 021bdba58..13fe24b58 100644 --- a/src/NzbDrone.Api/Frontend/Mappers/UpdateLogFileMapper.cs +++ b/src/Lidarr.Http/Frontend/Mappers/UpdateLogFileMapper.cs @@ -4,7 +4,7 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; -namespace NzbDrone.Api.Frontend.Mappers +namespace Lidarr.Http.Frontend.Mappers { public class LogFileMapper : StaticResourceMapperBase { diff --git a/src/Lidarr.Http/Frontend/StaticResourceModule.cs b/src/Lidarr.Http/Frontend/StaticResourceModule.cs new file mode 100644 index 000000000..7d4975d55 --- /dev/null +++ b/src/Lidarr.Http/Frontend/StaticResourceModule.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Nancy; +using NLog; +using Lidarr.Http.Frontend.Mappers; + +namespace Lidarr.Http.Frontend +{ + public class StaticResourceModule : NancyModule + { + private readonly IEnumerable _requestMappers; + private readonly Logger _logger; + + + public StaticResourceModule(IEnumerable requestMappers, Logger logger) + { + _requestMappers = requestMappers; + _logger = logger; + + Get["/{resource*}"] = x => Index(); + Get["/"] = x => Index(); + } + + private Response Index() + { + var path = Request.Url.Path; + + if ( + string.IsNullOrWhiteSpace(path) || + path.StartsWith("/api", StringComparison.CurrentCultureIgnoreCase) || + path.StartsWith("/signalr", StringComparison.CurrentCultureIgnoreCase)) + { + return new NotFoundResponse(); + } + + var mapper = _requestMappers.SingleOrDefault(m => m.CanHandle(path)); + + if (mapper != null) + { + return mapper.GetResponse(path); + } + + _logger.Warn("Couldn't find handler for {0}", path); + + return new NotFoundResponse(); + } + } +} diff --git a/src/Lidarr.Http/Lidarr.Http.csproj b/src/Lidarr.Http/Lidarr.Http.csproj new file mode 100644 index 000000000..8d0098386 --- /dev/null +++ b/src/Lidarr.Http/Lidarr.Http.csproj @@ -0,0 +1,23 @@ + + + net462 + x86 + + + + + + + + + + + + ..\Libraries\Sqlite\System.Data.SQLite.dll + + + + + + + diff --git a/src/Lidarr.Http/LidarrBootstrapper.cs b/src/Lidarr.Http/LidarrBootstrapper.cs new file mode 100644 index 000000000..420fda480 --- /dev/null +++ b/src/Lidarr.Http/LidarrBootstrapper.cs @@ -0,0 +1,58 @@ +using System.Linq; +using Nancy.Bootstrapper; +using Nancy.Diagnostics; +using NLog; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Instrumentation; +using NzbDrone.Core.Instrumentation; +using NzbDrone.Core.Lifecycle; +using NzbDrone.Core.Messaging.Events; +using Lidarr.Http.Extensions.Pipelines; +using TinyIoC; + +namespace Lidarr.Http +{ + public class LidarrBootstrapper : TinyIoCNancyBootstrapper + { + private readonly TinyIoCContainer _tinyIoCContainer; + private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(LidarrBootstrapper)); + + public LidarrBootstrapper(TinyIoCContainer tinyIoCContainer) + { + _tinyIoCContainer = tinyIoCContainer; + } + + protected override void ApplicationStartup(TinyIoCContainer container, IPipelines pipelines) + { + Logger.Info("Starting Web Server"); + + if (RuntimeInfo.IsProduction) + { + DiagnosticsHook.Disable(pipelines); + } + + RegisterPipelines(pipelines); + + container.Resolve().Register(); + } + + private void RegisterPipelines(IPipelines pipelines) + { + var pipelineRegistrars = _tinyIoCContainer.ResolveAll().OrderBy(v => v.Order).ToList(); + + foreach (var registerNancyPipeline in pipelineRegistrars) + { + registerNancyPipeline.Register(pipelines); + } + } + + protected override TinyIoCContainer GetApplicationContainer() + { + return _tinyIoCContainer; + } + + protected override DiagnosticsConfiguration DiagnosticsConfiguration => new DiagnosticsConfiguration { Password = @"password" }; + + protected override byte[] FavIcon => null; + } +} diff --git a/src/Lidarr.Http/LidarrRestModule.cs b/src/Lidarr.Http/LidarrRestModule.cs new file mode 100644 index 000000000..798565022 --- /dev/null +++ b/src/Lidarr.Http/LidarrRestModule.cs @@ -0,0 +1,56 @@ +using System; +using NzbDrone.Core.Datastore; +using Lidarr.Http.REST; +using Lidarr.Http.Validation; + +namespace Lidarr.Http +{ + public abstract class LidarrRestModule : RestModule where TResource : RestResource, new() + { + protected string Resource { get; private set; } + + + private static string BaseUrl() + { + var isV1 = typeof(TResource).Namespace.Contains(".V1."); + if (isV1) + { + return "/api/v1/"; + } + return "/api/"; + } + + private static string ResourceName() + { + return new TResource().ResourceName.Trim('/').ToLower(); + } + + protected LidarrRestModule() + : this(ResourceName()) + { + } + + protected LidarrRestModule(string resource) + : base(BaseUrl() + resource.Trim('/').ToLower()) + { + Resource = resource; + PostValidator.RuleFor(r => r.Id).IsZero(); + PutValidator.RuleFor(r => r.Id).ValidId(); + } + + protected PagingResource ApplyToPage(Func, PagingSpec> function, PagingSpec pagingSpec, Converter mapper) + { + pagingSpec = function(pagingSpec); + + return new PagingResource + { + Page = pagingSpec.Page, + PageSize = pagingSpec.PageSize, + SortDirection = pagingSpec.SortDirection, + SortKey = pagingSpec.SortKey, + TotalRecords = pagingSpec.TotalRecords, + Records = pagingSpec.Records.ConvertAll(mapper) + }; + } + } +} diff --git a/src/Lidarr.Http/LidarrRestModuleWithSignalR.cs b/src/Lidarr.Http/LidarrRestModuleWithSignalR.cs new file mode 100644 index 000000000..5905930d3 --- /dev/null +++ b/src/Lidarr.Http/LidarrRestModuleWithSignalR.cs @@ -0,0 +1,88 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Datastore.Events; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.SignalR; +using Lidarr.Http.REST; + +namespace Lidarr.Http +{ + public abstract class LidarrRestModuleWithSignalR : LidarrRestModule, IHandle> + where TResource : RestResource, new() + where TModel : ModelBase, new() + { + private readonly IBroadcastSignalRMessage _signalRBroadcaster; + + protected LidarrRestModuleWithSignalR(IBroadcastSignalRMessage signalRBroadcaster) + { + _signalRBroadcaster = signalRBroadcaster; + } + + protected LidarrRestModuleWithSignalR(IBroadcastSignalRMessage signalRBroadcaster, string resource) + : base(resource) + { + _signalRBroadcaster = signalRBroadcaster; + } + + public void Handle(ModelEvent message) + { + if (!_signalRBroadcaster.IsConnected) return; + + if (message.Action == ModelAction.Deleted || message.Action == ModelAction.Sync) + { + BroadcastResourceChange(message.Action); + } + + BroadcastResourceChange(message.Action, message.Model.Id); + } + + protected void BroadcastResourceChange(ModelAction action, int id) + { + if (!_signalRBroadcaster.IsConnected) return; + + if (action == ModelAction.Deleted) + { + BroadcastResourceChange(action, new TResource { Id = id }); + } + else + { + var resource = GetResourceById(id); + BroadcastResourceChange(action, resource); + } + } + + protected void BroadcastResourceChange(ModelAction action, TResource resource) + { + if (!_signalRBroadcaster.IsConnected) return; + + if (GetType().Namespace.Contains("V1")) + { + var signalRMessage = new SignalRMessage + { + Name = Resource, + Body = new ResourceChangeMessage(resource, action), + Action = action + }; + + _signalRBroadcaster.BroadcastMessage(signalRMessage); + } + } + + + protected void BroadcastResourceChange(ModelAction action) + { + if (!_signalRBroadcaster.IsConnected) return; + + if (GetType().Namespace.Contains("V1")) + { + var signalRMessage = new SignalRMessage + { + Name = Resource, + Body = new ResourceChangeMessage(action), + Action = action + }; + + _signalRBroadcaster.BroadcastMessage(signalRMessage); + } + } + } +} diff --git a/src/Lidarr.Http/Mapping/MappingValidation.cs b/src/Lidarr.Http/Mapping/MappingValidation.cs new file mode 100644 index 000000000..e4f056520 --- /dev/null +++ b/src/Lidarr.Http/Mapping/MappingValidation.cs @@ -0,0 +1,54 @@ +using System; +using System.Linq; +using System.Reflection; +using NzbDrone.Common.Reflection; +using Lidarr.Http.REST; + +namespace Lidarr.Http.Mapping +{ + public static class MappingValidation + { + public static void ValidateMapping(Type modelType, Type resourceType) + { + var errors = modelType.GetSimpleProperties().Where(c=>!c.GetGetMethod().IsStatic).Select(p => GetError(resourceType, p)).Where(c => c != null).ToList(); + + if (errors.Any()) + { + throw new ResourceMappingException(errors); + } + + PrintExtraProperties(modelType, resourceType); + } + + private static void PrintExtraProperties(Type modelType, Type resourceType) + { + var resourceBaseProperties = typeof(RestResource).GetProperties().Select(c => c.Name); + var resourceProperties = resourceType.GetProperties().Select(c => c.Name).Except(resourceBaseProperties); + var modelProperties = modelType.GetProperties().Select(c => c.Name); + + var extra = resourceProperties.Except(modelProperties); + + foreach (var extraProp in extra) + { + Console.WriteLine("Extra: [{0}]", extraProp); + } + } + + private static string GetError(Type resourceType, PropertyInfo modelProperty) + { + var resourceProperty = resourceType.GetProperties().FirstOrDefault(c => c.Name == modelProperty.Name); + + if (resourceProperty == null) + { + return string.Format("public {0} {1} {{ get; set; }}", modelProperty.PropertyType.Name, modelProperty.Name); + } + + if (resourceProperty.PropertyType != modelProperty.PropertyType && !typeof(RestResource).IsAssignableFrom(resourceProperty.PropertyType)) + { + return string.Format("Expected {0}.{1} to have type of {2} but found {3}", resourceType.Name, resourceProperty.Name, modelProperty.PropertyType, resourceProperty.PropertyType); + } + + return null; + } + } +} \ No newline at end of file diff --git a/src/Lidarr.Http/Mapping/ResourceMappingException.cs b/src/Lidarr.Http/Mapping/ResourceMappingException.cs new file mode 100644 index 000000000..3744d3d5e --- /dev/null +++ b/src/Lidarr.Http/Mapping/ResourceMappingException.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Lidarr.Http.Mapping +{ + public class ResourceMappingException : ApplicationException + { + public ResourceMappingException(IEnumerable error) + : base(Environment.NewLine + string.Join(Environment.NewLine, error.OrderBy(c => c))) + { + + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Api/PagingResource.cs b/src/Lidarr.Http/PagingResource.cs similarity index 89% rename from src/NzbDrone.Api/PagingResource.cs rename to src/Lidarr.Http/PagingResource.cs index b8025efc4..e123eba69 100644 --- a/src/NzbDrone.Api/PagingResource.cs +++ b/src/Lidarr.Http/PagingResource.cs @@ -1,7 +1,7 @@ -using System.Collections.Generic; +using System.Collections.Generic; using NzbDrone.Core.Datastore; -namespace NzbDrone.Api +namespace Lidarr.Http { public class PagingResource { @@ -9,8 +9,7 @@ namespace NzbDrone.Api public int PageSize { get; set; } public string SortKey { get; set; } public SortDirection SortDirection { get; set; } - public string FilterKey { get; set; } - public string FilterValue { get; set; } + public List Filters { get; set; } public int TotalRecords { get; set; } public List Records { get; set; } } diff --git a/src/Lidarr.Http/PagingResourceFilter.cs b/src/Lidarr.Http/PagingResourceFilter.cs new file mode 100644 index 000000000..9ce509e28 --- /dev/null +++ b/src/Lidarr.Http/PagingResourceFilter.cs @@ -0,0 +1,8 @@ +namespace Lidarr.Http +{ + public class PagingResourceFilter + { + public string Key { get; set; } + public string Value { get; set; } + } +} diff --git a/src/NzbDrone.Api/REST/BadRequestException.cs b/src/Lidarr.Http/REST/BadRequestException.cs similarity index 76% rename from src/NzbDrone.Api/REST/BadRequestException.cs rename to src/Lidarr.Http/REST/BadRequestException.cs index 450f484e5..5f61a1d49 100644 --- a/src/NzbDrone.Api/REST/BadRequestException.cs +++ b/src/Lidarr.Http/REST/BadRequestException.cs @@ -1,7 +1,7 @@ using Nancy; -using NzbDrone.Api.ErrorManagement; +using Lidarr.Http.Exceptions; -namespace NzbDrone.Api.REST +namespace Lidarr.Http.REST { public class BadRequestException : ApiException { diff --git a/src/NzbDrone.Api/REST/MethodNotAllowedException.cs b/src/Lidarr.Http/REST/MethodNotAllowedException.cs similarity index 78% rename from src/NzbDrone.Api/REST/MethodNotAllowedException.cs rename to src/Lidarr.Http/REST/MethodNotAllowedException.cs index 44d2065c6..e9dc7b74c 100644 --- a/src/NzbDrone.Api/REST/MethodNotAllowedException.cs +++ b/src/Lidarr.Http/REST/MethodNotAllowedException.cs @@ -1,7 +1,7 @@ using Nancy; -using NzbDrone.Api.ErrorManagement; +using Lidarr.Http.Exceptions; -namespace NzbDrone.Api.REST +namespace Lidarr.Http.REST { public class MethodNotAllowedException : ApiException { diff --git a/src/NzbDrone.Api/REST/NotFoundException.cs b/src/Lidarr.Http/REST/NotFoundException.cs similarity index 76% rename from src/NzbDrone.Api/REST/NotFoundException.cs rename to src/Lidarr.Http/REST/NotFoundException.cs index 92b4016a9..e8377ced4 100644 --- a/src/NzbDrone.Api/REST/NotFoundException.cs +++ b/src/Lidarr.Http/REST/NotFoundException.cs @@ -1,7 +1,7 @@ using Nancy; -using NzbDrone.Api.ErrorManagement; +using Lidarr.Http.Exceptions; -namespace NzbDrone.Api.REST +namespace Lidarr.Http.REST { public class NotFoundException : ApiException { diff --git a/src/NzbDrone.Api/REST/ResourceValidator.cs b/src/Lidarr.Http/REST/ResourceValidator.cs similarity index 95% rename from src/NzbDrone.Api/REST/ResourceValidator.cs rename to src/Lidarr.Http/REST/ResourceValidator.cs index 8062e6fd0..e052470d1 100644 --- a/src/NzbDrone.Api/REST/ResourceValidator.cs +++ b/src/Lidarr.Http/REST/ResourceValidator.cs @@ -1,13 +1,13 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Linq.Expressions; using FluentValidation; using FluentValidation.Internal; using FluentValidation.Resources; -using NzbDrone.Api.ClientSchema; -using System.Linq; +using Lidarr.Http.ClientSchema; -namespace NzbDrone.Api.REST +namespace Lidarr.Http.REST { public class ResourceValidator : AbstractValidator { diff --git a/src/NzbDrone.Api/REST/RestModule.cs b/src/Lidarr.Http/REST/RestModule.cs similarity index 79% rename from src/NzbDrone.Api/REST/RestModule.cs rename to src/Lidarr.Http/REST/RestModule.cs index 7c6ba37a4..8c7f7f714 100644 --- a/src/NzbDrone.Api/REST/RestModule.cs +++ b/src/Lidarr.Http/REST/RestModule.cs @@ -1,12 +1,13 @@ -using System; +using System; using System.Collections.Generic; +using System.Linq; using FluentValidation; using Nancy; -using NzbDrone.Api.Extensions; -using System.Linq; using NzbDrone.Core.Datastore; +using Lidarr.Http.Extensions; +using Newtonsoft.Json; -namespace NzbDrone.Api.REST +namespace Lidarr.Http.REST { public abstract class RestModule : NancyModule where TResource : RestResource, new() @@ -14,6 +15,16 @@ namespace NzbDrone.Api.REST private const string ROOT_ROUTE = "/"; private const string ID_ROUTE = @"/(?[\d]{1,10})"; + private HashSet EXCLUDED_KEYS = new HashSet(StringComparer.InvariantCultureIgnoreCase) + { + "page", + "pageSize", + "sortKey", + "sortDirection", + "filterKey", + "filterValue", + }; + private Action _deleteResource; private Func _getResourceById; private Func> _getResourceAll; @@ -184,8 +195,16 @@ namespace NzbDrone.Api.REST protected TResource ReadResourceFromRequest(bool skipValidate = false) { - //TODO: handle when request is null - var resource = Request.Body.FromJson(); + var resource = new TResource(); + + try + { + resource = Request.Body.FromJson(); + } + catch (JsonReaderException ex) + { + throw new BadRequestException(ex.Message); + } if (resource == null) { @@ -226,12 +245,14 @@ namespace NzbDrone.Api.REST { PageSize = pageSize, Page = page, + Filters = new List() }; if (Request.Query.SortKey != null) { pagingResource.SortKey = Request.Query.SortKey.ToString(); + // For backwards compatibility with v2 if (Request.Query.SortDir != null) { pagingResource.SortDirection = Request.Query.SortDir.ToString() @@ -239,19 +260,50 @@ namespace NzbDrone.Api.REST ? SortDirection.Ascending : SortDirection.Descending; } + + // v3 uses SortDirection instead of SortDir to be consistent with every other use of it + if (Request.Query.SortDirection != null) + { + pagingResource.SortDirection = Request.Query.SortDirection.ToString() + .Equals("ascending", StringComparison.InvariantCultureIgnoreCase) + ? SortDirection.Ascending + : SortDirection.Descending; + } } + // For backwards compatibility with v2 if (Request.Query.FilterKey != null) { - pagingResource.FilterKey = Request.Query.FilterKey.ToString(); + var filter = new PagingResourceFilter + { + Key = Request.Query.FilterKey.ToString() + }; if (Request.Query.FilterValue != null) { - pagingResource.FilterValue = Request.Query.FilterValue.ToString(); + filter.Value = Request.Query.FilterValue?.ToString(); + } + + pagingResource.Filters.Add(filter); + } + + // v3 uses filters in key=value format + + foreach (var key in Request.Query) + { + if (EXCLUDED_KEYS.Contains(key)) + { + continue; } + + pagingResource.Filters.Add(new PagingResourceFilter + { + Key = key, + Value = Request.Query[key] + }); } return pagingResource; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Api/REST/RestResource.cs b/src/Lidarr.Http/REST/RestResource.cs similarity index 91% rename from src/NzbDrone.Api/REST/RestResource.cs rename to src/Lidarr.Http/REST/RestResource.cs index ec9f195c6..b08aa6eef 100644 --- a/src/NzbDrone.Api/REST/RestResource.cs +++ b/src/Lidarr.Http/REST/RestResource.cs @@ -1,6 +1,6 @@ using Newtonsoft.Json; -namespace NzbDrone.Api.REST +namespace Lidarr.Http.REST { public abstract class RestResource { diff --git a/src/Lidarr.Http/REST/UnsupportedMediaTypeException.cs b/src/Lidarr.Http/REST/UnsupportedMediaTypeException.cs new file mode 100644 index 000000000..6755a51c3 --- /dev/null +++ b/src/Lidarr.Http/REST/UnsupportedMediaTypeException.cs @@ -0,0 +1,13 @@ +using Nancy; +using Lidarr.Http.Exceptions; + +namespace Lidarr.Http.REST +{ + public class UnsupportedMediaTypeException : ApiException + { + public UnsupportedMediaTypeException(object content = null) + : base(HttpStatusCode.UnsupportedMediaType, content) + { + } + } +} diff --git a/src/NzbDrone.Api/ResourceChangeMessage.cs b/src/Lidarr.Http/ResourceChangeMessage.cs similarity index 93% rename from src/NzbDrone.Api/ResourceChangeMessage.cs rename to src/Lidarr.Http/ResourceChangeMessage.cs index 6319dcc39..3d2e67c78 100644 --- a/src/NzbDrone.Api/ResourceChangeMessage.cs +++ b/src/Lidarr.Http/ResourceChangeMessage.cs @@ -1,8 +1,8 @@ using System; -using NzbDrone.Api.REST; using NzbDrone.Core.Datastore.Events; +using Lidarr.Http.REST; -namespace NzbDrone.Api +namespace Lidarr.Http { public class ResourceChangeMessage where TResource : RestResource { diff --git a/src/NzbDrone.Api/TinyIoCNancyBootstrapper.cs b/src/Lidarr.Http/TinyIoCNancyBootstrapper.cs similarity index 99% rename from src/NzbDrone.Api/TinyIoCNancyBootstrapper.cs rename to src/Lidarr.Http/TinyIoCNancyBootstrapper.cs index d938b0c6e..eb4566b06 100644 --- a/src/NzbDrone.Api/TinyIoCNancyBootstrapper.cs +++ b/src/Lidarr.Http/TinyIoCNancyBootstrapper.cs @@ -1,4 +1,4 @@ -using TinyIoC; +using TinyIoC; using System; using System.Collections.Generic; using System.Linq; @@ -7,7 +7,7 @@ using Nancy; using Nancy.Diagnostics; using Nancy.Bootstrapper; -namespace NzbDrone.Api +namespace Lidarr.Http { @@ -91,7 +91,6 @@ namespace NzbDrone.Api break; case Lifetime.PerRequest: throw new InvalidOperationException("Unable to directly register a per request lifetime."); - break; default: throw new ArgumentOutOfRangeException(); } @@ -118,7 +117,6 @@ namespace NzbDrone.Api break; case Lifetime.PerRequest: throw new InvalidOperationException("Unable to directly register a per request lifetime."); - break; default: throw new ArgumentOutOfRangeException(); } diff --git a/src/NzbDrone.Api/Validation/EmptyCollectionValidator.cs b/src/Lidarr.Http/Validation/EmptyCollectionValidator.cs similarity index 94% rename from src/NzbDrone.Api/Validation/EmptyCollectionValidator.cs rename to src/Lidarr.Http/Validation/EmptyCollectionValidator.cs index 432eb1ed9..1ad5e20e2 100644 --- a/src/NzbDrone.Api/Validation/EmptyCollectionValidator.cs +++ b/src/Lidarr.Http/Validation/EmptyCollectionValidator.cs @@ -2,7 +2,7 @@ using FluentValidation.Validators; using NzbDrone.Common.Extensions; -namespace NzbDrone.Api.Validation +namespace Lidarr.Http.Validation { public class EmptyCollectionValidator : PropertyValidator { diff --git a/src/NzbDrone.Api/Validation/RssSyncIntervalValidator.cs b/src/Lidarr.Http/Validation/RssSyncIntervalValidator.cs similarity index 95% rename from src/NzbDrone.Api/Validation/RssSyncIntervalValidator.cs rename to src/Lidarr.Http/Validation/RssSyncIntervalValidator.cs index 8a3f2d54c..797103b2b 100644 --- a/src/NzbDrone.Api/Validation/RssSyncIntervalValidator.cs +++ b/src/Lidarr.Http/Validation/RssSyncIntervalValidator.cs @@ -1,6 +1,6 @@ using FluentValidation.Validators; -namespace NzbDrone.Api.Validation +namespace Lidarr.Http.Validation { public class RssSyncIntervalValidator : PropertyValidator { diff --git a/src/NzbDrone.Api/Validation/RuleBuilderExtensions.cs b/src/Lidarr.Http/Validation/RuleBuilderExtensions.cs similarity index 97% rename from src/NzbDrone.Api/Validation/RuleBuilderExtensions.cs rename to src/Lidarr.Http/Validation/RuleBuilderExtensions.cs index 01a3a4f75..01f1608e2 100644 --- a/src/NzbDrone.Api/Validation/RuleBuilderExtensions.cs +++ b/src/Lidarr.Http/Validation/RuleBuilderExtensions.cs @@ -3,7 +3,7 @@ using System.Text.RegularExpressions; using FluentValidation; using FluentValidation.Validators; -namespace NzbDrone.Api.Validation +namespace Lidarr.Http.Validation { public static class RuleBuilderExtensions { diff --git a/src/Lidarr.Http/app.config b/src/Lidarr.Http/app.config new file mode 100644 index 000000000..edd94e9ed --- /dev/null +++ b/src/Lidarr.Http/app.config @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/Lidarr.sln b/src/Lidarr.sln new file mode 100644 index 000000000..a2322292b --- /dev/null +++ b/src/Lidarr.sln @@ -0,0 +1,280 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.27004.2002 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{57A04B72-8088-4F75-A582-1158CF8291F7}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Test.Common", "Test.Common", "{47697CDB-27B6-4B05-B4F8-0CBE6F6EDF97}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Test.Dummy", "NzbDrone.Test.Dummy\Lidarr.Test.Dummy.csproj", "{FAFB5948-A222-4CF6-AD14-026BE7564802}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Test.Common", "NzbDrone.Test.Common\Lidarr.Test.Common.csproj", "{CADDFCE0-7509-4430-8364-2074E1EEFCA2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Core.Test", "NzbDrone.Core.Test\Lidarr.Core.Test.csproj", "{193ADD3B-792B-4173-8E4C-5A3F8F0237F0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Host.Test", "NzbDrone.Host.Test\Lidarr.Host.Test.csproj", "{C0EA1A40-91AD-4EEB-BD16-2DDDEBD20AE5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Update.Test", "NzbDrone.Update.Test\Lidarr.Update.Test.csproj", "{35388E8E-0CDB-4A84-AD16-E4B6EFDA5D97}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Common.Test", "NzbDrone.Common.Test\Lidarr.Common.Test.csproj", "{BEC74619-DDBB-4FBA-B517-D3E20AFC9997}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Api.Test", "NzbDrone.Api.Test\Lidarr.Api.Test.csproj", "{D18A5DEB-5102-4775-A1AF-B75DAAA8907B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Libraries.Test", "NzbDrone.Libraries.Test\Lidarr.Libraries.Test.csproj", "{CBF6B8B0-A015-413A-8C86-01238BB45770}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Integration.Test", "NzbDrone.Integration.Test\Lidarr.Integration.Test.csproj", "{8CEFECD0-A6C2-498F-98B1-3FBE5820F9AB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Automation.Test", "NzbDrone.Automation.Test\Lidarr.Automation.Test.csproj", "{CC26800D-F67E-464B-88DE-8EB1A0C227A3}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "WindowsServiceHelpers", "WindowsServiceHelpers", "{F9E67978-5CD6-4A5F-827B-4249711C0B02}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceInstall", "ServiceHelpers\ServiceInstall\ServiceInstall.csproj", "{6BCE712F-846D-4846-9D1B-A66B858DA755}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceUninstall", "ServiceHelpers\ServiceUninstall\ServiceUninstall.csproj", "{700D0B95-95CD-43F3-B6C9-FAA0FC1358D4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Core", "NzbDrone.Core\Lidarr.Core.csproj", "{FF5EE3B6-913B-47CE-9CEB-11C51B4E1205}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Update", "NzbDrone.Update\Lidarr.Update.csproj", "{4CCC53CD-8D5E-4CC4-97D2-5C9312AC2BD7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Common", "NzbDrone.Common\Lidarr.Common.csproj", "{F2BE0FDF-6E47-4827-A420-DD4EF82407F8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".nuget", ".nuget", "{1E6B3CBE-1578-41C1-9BF9-78D818740BE9}" + ProjectSection(SolutionItems) = preProject + .nuget\NuGet.exe = .nuget\NuGet.exe + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Host", "Host", "{486ADF86-DD89-4E19-B805-9D94F19800D9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Console", "NzbDrone.Console\Lidarr.Console.csproj", "{3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Host", "NzbDrone.Host\Lidarr.Host.csproj", "{95C11A9E-56ED-456A-8447-2C89C1139266}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone", "NzbDrone\Lidarr.csproj", "{D12F7F2F-8A3C-415F-88FA-6DD061A84869}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.SignalR", "NzbDrone.SignalR\Lidarr.SignalR.csproj", "{7C2CC69F-5CA0-4E5C-85CB-983F9F6C3B36}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "External", "External", "{F6E3A728-AE77-4D02-BAC8-82FBC1402DDA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Marr.Data", "Marr.Data\Marr.Data.csproj", "{F6FC6BE7-0847-4817-A1ED-223DC647C3D7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Mono", "NzbDrone.Mono\Lidarr.Mono.csproj", "{15AD7579-A314-4626-B556-663F51D97CD1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Windows", "NzbDrone.Windows\Lidarr.Windows.csproj", "{911284D3-F130-459E-836C-2430B6FBF21D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Platform", "Platform", "{0F0D4998-8F5D-4467-A909-BB192C4B3B4B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Platform", "Platform", "{4EACDBBC-BCD7-4765-A57B-3E08331E4749}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Windows.Test", "NzbDrone.Windows.Test\Lidarr.Windows.Test.csproj", "{80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Mono.Test", "NzbDrone.Mono.Test\Lidarr.Mono.Test.csproj", "{40D72824-7D02-4A77-9106-8FE0EEA2B997}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MonoTorrent", "MonoTorrent\MonoTorrent.csproj", "{411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lidarr.Http", "Lidarr.Http\Lidarr.Http.csproj", "{5370BFF7-1BD7-46BC-AF06-7D9EA5CDA1D6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lidarr.Api.V1", "Lidarr.Api.V1\Lidarr.Api.V1.csproj", "{7140FF1F-79BE-492F-9188-B21A050BF708}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|x86 = Debug|x86 + Mono|x86 = Mono|x86 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {FAFB5948-A222-4CF6-AD14-026BE7564802}.Debug|x86.ActiveCfg = Debug|x86 + {FAFB5948-A222-4CF6-AD14-026BE7564802}.Debug|x86.Build.0 = Debug|x86 + {FAFB5948-A222-4CF6-AD14-026BE7564802}.Mono|x86.ActiveCfg = Release|x86 + {FAFB5948-A222-4CF6-AD14-026BE7564802}.Mono|x86.Build.0 = Release|x86 + {FAFB5948-A222-4CF6-AD14-026BE7564802}.Release|x86.ActiveCfg = Release|x86 + {FAFB5948-A222-4CF6-AD14-026BE7564802}.Release|x86.Build.0 = Release|x86 + {CADDFCE0-7509-4430-8364-2074E1EEFCA2}.Debug|x86.ActiveCfg = Debug|x86 + {CADDFCE0-7509-4430-8364-2074E1EEFCA2}.Debug|x86.Build.0 = Debug|x86 + {CADDFCE0-7509-4430-8364-2074E1EEFCA2}.Mono|x86.ActiveCfg = Debug|x86 + {CADDFCE0-7509-4430-8364-2074E1EEFCA2}.Mono|x86.Build.0 = Debug|x86 + {CADDFCE0-7509-4430-8364-2074E1EEFCA2}.Release|x86.ActiveCfg = Release|x86 + {CADDFCE0-7509-4430-8364-2074E1EEFCA2}.Release|x86.Build.0 = Release|x86 + {193ADD3B-792B-4173-8E4C-5A3F8F0237F0}.Debug|x86.ActiveCfg = Debug|x86 + {193ADD3B-792B-4173-8E4C-5A3F8F0237F0}.Debug|x86.Build.0 = Debug|x86 + {193ADD3B-792B-4173-8E4C-5A3F8F0237F0}.Mono|x86.ActiveCfg = Debug|x86 + {193ADD3B-792B-4173-8E4C-5A3F8F0237F0}.Mono|x86.Build.0 = Debug|x86 + {193ADD3B-792B-4173-8E4C-5A3F8F0237F0}.Release|x86.ActiveCfg = Release|x86 + {193ADD3B-792B-4173-8E4C-5A3F8F0237F0}.Release|x86.Build.0 = Release|x86 + {C0EA1A40-91AD-4EEB-BD16-2DDDEBD20AE5}.Debug|x86.ActiveCfg = Debug|x86 + {C0EA1A40-91AD-4EEB-BD16-2DDDEBD20AE5}.Debug|x86.Build.0 = Debug|x86 + {C0EA1A40-91AD-4EEB-BD16-2DDDEBD20AE5}.Mono|x86.ActiveCfg = Debug|x86 + {C0EA1A40-91AD-4EEB-BD16-2DDDEBD20AE5}.Mono|x86.Build.0 = Debug|x86 + {C0EA1A40-91AD-4EEB-BD16-2DDDEBD20AE5}.Release|x86.ActiveCfg = Release|x86 + {C0EA1A40-91AD-4EEB-BD16-2DDDEBD20AE5}.Release|x86.Build.0 = Release|x86 + {35388E8E-0CDB-4A84-AD16-E4B6EFDA5D97}.Debug|x86.ActiveCfg = Debug|x86 + {35388E8E-0CDB-4A84-AD16-E4B6EFDA5D97}.Debug|x86.Build.0 = Debug|x86 + {35388E8E-0CDB-4A84-AD16-E4B6EFDA5D97}.Mono|x86.ActiveCfg = Debug|x86 + {35388E8E-0CDB-4A84-AD16-E4B6EFDA5D97}.Mono|x86.Build.0 = Debug|x86 + {35388E8E-0CDB-4A84-AD16-E4B6EFDA5D97}.Release|x86.ActiveCfg = Release|x86 + {35388E8E-0CDB-4A84-AD16-E4B6EFDA5D97}.Release|x86.Build.0 = Release|x86 + {BEC74619-DDBB-4FBA-B517-D3E20AFC9997}.Debug|x86.ActiveCfg = Debug|x86 + {BEC74619-DDBB-4FBA-B517-D3E20AFC9997}.Debug|x86.Build.0 = Debug|x86 + {BEC74619-DDBB-4FBA-B517-D3E20AFC9997}.Mono|x86.ActiveCfg = Debug|x86 + {BEC74619-DDBB-4FBA-B517-D3E20AFC9997}.Mono|x86.Build.0 = Debug|x86 + {BEC74619-DDBB-4FBA-B517-D3E20AFC9997}.Release|x86.ActiveCfg = Release|x86 + {BEC74619-DDBB-4FBA-B517-D3E20AFC9997}.Release|x86.Build.0 = Release|x86 + {D18A5DEB-5102-4775-A1AF-B75DAAA8907B}.Debug|x86.ActiveCfg = Debug|x86 + {D18A5DEB-5102-4775-A1AF-B75DAAA8907B}.Debug|x86.Build.0 = Debug|x86 + {D18A5DEB-5102-4775-A1AF-B75DAAA8907B}.Mono|x86.ActiveCfg = Release|x86 + {D18A5DEB-5102-4775-A1AF-B75DAAA8907B}.Mono|x86.Build.0 = Release|x86 + {D18A5DEB-5102-4775-A1AF-B75DAAA8907B}.Release|x86.ActiveCfg = Release|x86 + {D18A5DEB-5102-4775-A1AF-B75DAAA8907B}.Release|x86.Build.0 = Release|x86 + {CBF6B8B0-A015-413A-8C86-01238BB45770}.Debug|x86.ActiveCfg = Debug|x86 + {CBF6B8B0-A015-413A-8C86-01238BB45770}.Debug|x86.Build.0 = Debug|x86 + {CBF6B8B0-A015-413A-8C86-01238BB45770}.Mono|x86.ActiveCfg = Debug|x86 + {CBF6B8B0-A015-413A-8C86-01238BB45770}.Mono|x86.Build.0 = Debug|x86 + {CBF6B8B0-A015-413A-8C86-01238BB45770}.Release|x86.ActiveCfg = Release|x86 + {CBF6B8B0-A015-413A-8C86-01238BB45770}.Release|x86.Build.0 = Release|x86 + {8CEFECD0-A6C2-498F-98B1-3FBE5820F9AB}.Debug|x86.ActiveCfg = Debug|x86 + {8CEFECD0-A6C2-498F-98B1-3FBE5820F9AB}.Debug|x86.Build.0 = Debug|x86 + {8CEFECD0-A6C2-498F-98B1-3FBE5820F9AB}.Mono|x86.ActiveCfg = Debug|x86 + {8CEFECD0-A6C2-498F-98B1-3FBE5820F9AB}.Mono|x86.Build.0 = Debug|x86 + {8CEFECD0-A6C2-498F-98B1-3FBE5820F9AB}.Release|x86.ActiveCfg = Release|x86 + {8CEFECD0-A6C2-498F-98B1-3FBE5820F9AB}.Release|x86.Build.0 = Release|x86 + {CC26800D-F67E-464B-88DE-8EB1A0C227A3}.Debug|x86.ActiveCfg = Debug|x86 + {CC26800D-F67E-464B-88DE-8EB1A0C227A3}.Debug|x86.Build.0 = Debug|x86 + {CC26800D-F67E-464B-88DE-8EB1A0C227A3}.Mono|x86.ActiveCfg = Debug|x86 + {CC26800D-F67E-464B-88DE-8EB1A0C227A3}.Mono|x86.Build.0 = Debug|x86 + {CC26800D-F67E-464B-88DE-8EB1A0C227A3}.Release|x86.ActiveCfg = Release|x86 + {CC26800D-F67E-464B-88DE-8EB1A0C227A3}.Release|x86.Build.0 = Release|x86 + {6BCE712F-846D-4846-9D1B-A66B858DA755}.Debug|x86.ActiveCfg = Debug|x86 + {6BCE712F-846D-4846-9D1B-A66B858DA755}.Debug|x86.Build.0 = Debug|x86 + {6BCE712F-846D-4846-9D1B-A66B858DA755}.Mono|x86.ActiveCfg = Debug|x86 + {6BCE712F-846D-4846-9D1B-A66B858DA755}.Release|x86.ActiveCfg = Release|x86 + {6BCE712F-846D-4846-9D1B-A66B858DA755}.Release|x86.Build.0 = Release|x86 + {700D0B95-95CD-43F3-B6C9-FAA0FC1358D4}.Debug|x86.ActiveCfg = Debug|x86 + {700D0B95-95CD-43F3-B6C9-FAA0FC1358D4}.Debug|x86.Build.0 = Debug|x86 + {700D0B95-95CD-43F3-B6C9-FAA0FC1358D4}.Mono|x86.ActiveCfg = Debug|x86 + {700D0B95-95CD-43F3-B6C9-FAA0FC1358D4}.Release|x86.ActiveCfg = Release|x86 + {700D0B95-95CD-43F3-B6C9-FAA0FC1358D4}.Release|x86.Build.0 = Release|x86 + {FF5EE3B6-913B-47CE-9CEB-11C51B4E1205}.Debug|x86.ActiveCfg = Debug|x86 + {FF5EE3B6-913B-47CE-9CEB-11C51B4E1205}.Debug|x86.Build.0 = Debug|x86 + {FF5EE3B6-913B-47CE-9CEB-11C51B4E1205}.Mono|x86.ActiveCfg = Release|x86 + {FF5EE3B6-913B-47CE-9CEB-11C51B4E1205}.Mono|x86.Build.0 = Release|x86 + {FF5EE3B6-913B-47CE-9CEB-11C51B4E1205}.Release|x86.ActiveCfg = Release|x86 + {FF5EE3B6-913B-47CE-9CEB-11C51B4E1205}.Release|x86.Build.0 = Release|x86 + {4CCC53CD-8D5E-4CC4-97D2-5C9312AC2BD7}.Debug|x86.ActiveCfg = Debug|x86 + {4CCC53CD-8D5E-4CC4-97D2-5C9312AC2BD7}.Debug|x86.Build.0 = Debug|x86 + {4CCC53CD-8D5E-4CC4-97D2-5C9312AC2BD7}.Mono|x86.ActiveCfg = Debug|x86 + {4CCC53CD-8D5E-4CC4-97D2-5C9312AC2BD7}.Mono|x86.Build.0 = Debug|x86 + {4CCC53CD-8D5E-4CC4-97D2-5C9312AC2BD7}.Release|x86.ActiveCfg = Release|x86 + {4CCC53CD-8D5E-4CC4-97D2-5C9312AC2BD7}.Release|x86.Build.0 = Release|x86 + {F2BE0FDF-6E47-4827-A420-DD4EF82407F8}.Debug|x86.ActiveCfg = Debug|x86 + {F2BE0FDF-6E47-4827-A420-DD4EF82407F8}.Debug|x86.Build.0 = Debug|x86 + {F2BE0FDF-6E47-4827-A420-DD4EF82407F8}.Mono|x86.ActiveCfg = Release|x86 + {F2BE0FDF-6E47-4827-A420-DD4EF82407F8}.Mono|x86.Build.0 = Release|x86 + {F2BE0FDF-6E47-4827-A420-DD4EF82407F8}.Release|x86.ActiveCfg = Release|x86 + {F2BE0FDF-6E47-4827-A420-DD4EF82407F8}.Release|x86.Build.0 = Release|x86 + {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Debug|x86.ActiveCfg = Debug|x86 + {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Debug|x86.Build.0 = Debug|x86 + {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Mono|x86.ActiveCfg = Debug|x86 + {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Mono|x86.Build.0 = Debug|x86 + {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Release|x86.ActiveCfg = Release|x86 + {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Release|x86.Build.0 = Release|x86 + {95C11A9E-56ED-456A-8447-2C89C1139266}.Debug|x86.ActiveCfg = Debug|x86 + {95C11A9E-56ED-456A-8447-2C89C1139266}.Debug|x86.Build.0 = Debug|x86 + {95C11A9E-56ED-456A-8447-2C89C1139266}.Mono|x86.ActiveCfg = Debug|x86 + {95C11A9E-56ED-456A-8447-2C89C1139266}.Mono|x86.Build.0 = Debug|x86 + {95C11A9E-56ED-456A-8447-2C89C1139266}.Release|x86.ActiveCfg = Release|x86 + {95C11A9E-56ED-456A-8447-2C89C1139266}.Release|x86.Build.0 = Release|x86 + {D12F7F2F-8A3C-415F-88FA-6DD061A84869}.Debug|x86.ActiveCfg = Debug|x86 + {D12F7F2F-8A3C-415F-88FA-6DD061A84869}.Debug|x86.Build.0 = Debug|x86 + {D12F7F2F-8A3C-415F-88FA-6DD061A84869}.Mono|x86.ActiveCfg = Release|x86 + {D12F7F2F-8A3C-415F-88FA-6DD061A84869}.Release|x86.ActiveCfg = Release|x86 + {D12F7F2F-8A3C-415F-88FA-6DD061A84869}.Release|x86.Build.0 = Release|x86 + {7C2CC69F-5CA0-4E5C-85CB-983F9F6C3B36}.Debug|x86.ActiveCfg = Debug|x86 + {7C2CC69F-5CA0-4E5C-85CB-983F9F6C3B36}.Debug|x86.Build.0 = Debug|x86 + {7C2CC69F-5CA0-4E5C-85CB-983F9F6C3B36}.Mono|x86.ActiveCfg = Debug|x86 + {7C2CC69F-5CA0-4E5C-85CB-983F9F6C3B36}.Mono|x86.Build.0 = Debug|x86 + {7C2CC69F-5CA0-4E5C-85CB-983F9F6C3B36}.Release|x86.ActiveCfg = Release|x86 + {7C2CC69F-5CA0-4E5C-85CB-983F9F6C3B36}.Release|x86.Build.0 = Release|x86 + {F6FC6BE7-0847-4817-A1ED-223DC647C3D7}.Debug|x86.ActiveCfg = Debug|x86 + {F6FC6BE7-0847-4817-A1ED-223DC647C3D7}.Debug|x86.Build.0 = Debug|x86 + {F6FC6BE7-0847-4817-A1ED-223DC647C3D7}.Mono|x86.ActiveCfg = Release|x86 + {F6FC6BE7-0847-4817-A1ED-223DC647C3D7}.Mono|x86.Build.0 = Release|x86 + {F6FC6BE7-0847-4817-A1ED-223DC647C3D7}.Release|x86.ActiveCfg = Release|x86 + {F6FC6BE7-0847-4817-A1ED-223DC647C3D7}.Release|x86.Build.0 = Release|x86 + {15AD7579-A314-4626-B556-663F51D97CD1}.Debug|x86.ActiveCfg = Debug|x86 + {15AD7579-A314-4626-B556-663F51D97CD1}.Debug|x86.Build.0 = Debug|x86 + {15AD7579-A314-4626-B556-663F51D97CD1}.Mono|x86.ActiveCfg = Release|x86 + {15AD7579-A314-4626-B556-663F51D97CD1}.Release|x86.ActiveCfg = Release|x86 + {15AD7579-A314-4626-B556-663F51D97CD1}.Release|x86.Build.0 = Release|x86 + {911284D3-F130-459E-836C-2430B6FBF21D}.Debug|x86.ActiveCfg = Debug|x86 + {911284D3-F130-459E-836C-2430B6FBF21D}.Debug|x86.Build.0 = Debug|x86 + {911284D3-F130-459E-836C-2430B6FBF21D}.Mono|x86.ActiveCfg = Release|x86 + {911284D3-F130-459E-836C-2430B6FBF21D}.Release|x86.ActiveCfg = Release|x86 + {911284D3-F130-459E-836C-2430B6FBF21D}.Release|x86.Build.0 = Release|x86 + {80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA}.Debug|x86.ActiveCfg = Debug|x86 + {80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA}.Debug|x86.Build.0 = Debug|x86 + {80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA}.Mono|x86.ActiveCfg = Release|x86 + {80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA}.Release|x86.ActiveCfg = Release|x86 + {80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA}.Release|x86.Build.0 = Release|x86 + {40D72824-7D02-4A77-9106-8FE0EEA2B997}.Debug|x86.ActiveCfg = Debug|x86 + {40D72824-7D02-4A77-9106-8FE0EEA2B997}.Debug|x86.Build.0 = Debug|x86 + {40D72824-7D02-4A77-9106-8FE0EEA2B997}.Mono|x86.ActiveCfg = Release|x86 + {40D72824-7D02-4A77-9106-8FE0EEA2B997}.Release|x86.ActiveCfg = Release|x86 + {40D72824-7D02-4A77-9106-8FE0EEA2B997}.Release|x86.Build.0 = Release|x86 + {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Debug|x86.ActiveCfg = Debug|x86 + {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Debug|x86.Build.0 = Debug|x86 + {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Mono|x86.ActiveCfg = Release|x86 + {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Mono|x86.Build.0 = Release|x86 + {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Release|x86.ActiveCfg = Release|x86 + {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Release|x86.Build.0 = Release|x86 + {5370BFF7-1BD7-46BC-AF06-7D9EA5CDA1D6}.Debug|x86.ActiveCfg = Debug|x86 + {5370BFF7-1BD7-46BC-AF06-7D9EA5CDA1D6}.Debug|x86.Build.0 = Debug|x86 + {5370BFF7-1BD7-46BC-AF06-7D9EA5CDA1D6}.Mono|x86.ActiveCfg = Release|x86 + {5370BFF7-1BD7-46BC-AF06-7D9EA5CDA1D6}.Mono|x86.Build.0 = Release|x86 + {5370BFF7-1BD7-46BC-AF06-7D9EA5CDA1D6}.Release|x86.ActiveCfg = Release|x86 + {5370BFF7-1BD7-46BC-AF06-7D9EA5CDA1D6}.Release|x86.Build.0 = Release|x86 + {7140FF1F-79BE-492F-9188-B21A050BF708}.Debug|x86.ActiveCfg = Debug|x86 + {7140FF1F-79BE-492F-9188-B21A050BF708}.Debug|x86.Build.0 = Debug|x86 + {7140FF1F-79BE-492F-9188-B21A050BF708}.Mono|x86.ActiveCfg = Release|x86 + {7140FF1F-79BE-492F-9188-B21A050BF708}.Mono|x86.Build.0 = Release|x86 + {7140FF1F-79BE-492F-9188-B21A050BF708}.Release|x86.ActiveCfg = Release|x86 + {7140FF1F-79BE-492F-9188-B21A050BF708}.Release|x86.Build.0 = Release|x86 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {47697CDB-27B6-4B05-B4F8-0CBE6F6EDF97} = {57A04B72-8088-4F75-A582-1158CF8291F7} + {FAFB5948-A222-4CF6-AD14-026BE7564802} = {47697CDB-27B6-4B05-B4F8-0CBE6F6EDF97} + {CADDFCE0-7509-4430-8364-2074E1EEFCA2} = {47697CDB-27B6-4B05-B4F8-0CBE6F6EDF97} + {193ADD3B-792B-4173-8E4C-5A3F8F0237F0} = {57A04B72-8088-4F75-A582-1158CF8291F7} + {C0EA1A40-91AD-4EEB-BD16-2DDDEBD20AE5} = {57A04B72-8088-4F75-A582-1158CF8291F7} + {35388E8E-0CDB-4A84-AD16-E4B6EFDA5D97} = {57A04B72-8088-4F75-A582-1158CF8291F7} + {BEC74619-DDBB-4FBA-B517-D3E20AFC9997} = {57A04B72-8088-4F75-A582-1158CF8291F7} + {D18A5DEB-5102-4775-A1AF-B75DAAA8907B} = {57A04B72-8088-4F75-A582-1158CF8291F7} + {CBF6B8B0-A015-413A-8C86-01238BB45770} = {57A04B72-8088-4F75-A582-1158CF8291F7} + {8CEFECD0-A6C2-498F-98B1-3FBE5820F9AB} = {57A04B72-8088-4F75-A582-1158CF8291F7} + {CC26800D-F67E-464B-88DE-8EB1A0C227A3} = {57A04B72-8088-4F75-A582-1158CF8291F7} + {6BCE712F-846D-4846-9D1B-A66B858DA755} = {F9E67978-5CD6-4A5F-827B-4249711C0B02} + {700D0B95-95CD-43F3-B6C9-FAA0FC1358D4} = {F9E67978-5CD6-4A5F-827B-4249711C0B02} + {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976} = {486ADF86-DD89-4E19-B805-9D94F19800D9} + {95C11A9E-56ED-456A-8447-2C89C1139266} = {486ADF86-DD89-4E19-B805-9D94F19800D9} + {D12F7F2F-8A3C-415F-88FA-6DD061A84869} = {486ADF86-DD89-4E19-B805-9D94F19800D9} + {F6FC6BE7-0847-4817-A1ED-223DC647C3D7} = {F6E3A728-AE77-4D02-BAC8-82FBC1402DDA} + {15AD7579-A314-4626-B556-663F51D97CD1} = {0F0D4998-8F5D-4467-A909-BB192C4B3B4B} + {911284D3-F130-459E-836C-2430B6FBF21D} = {0F0D4998-8F5D-4467-A909-BB192C4B3B4B} + {4EACDBBC-BCD7-4765-A57B-3E08331E4749} = {57A04B72-8088-4F75-A582-1158CF8291F7} + {80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA} = {4EACDBBC-BCD7-4765-A57B-3E08331E4749} + {40D72824-7D02-4A77-9106-8FE0EEA2B997} = {4EACDBBC-BCD7-4765-A57B-3E08331E4749} + {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8} = {F6E3A728-AE77-4D02-BAC8-82FBC1402DDA} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {2C047BC5-490F-4DCE-962F-141370D23765} + EnterpriseLibraryConfigurationToolBinariesPath = packages\Unity.4.0.1\lib\net45;packages\Unity.4.0.1\lib\net45 + EndGlobalSection + GlobalSection(MonoDevelopProperties) = preSolution + StartupItem = NzbDrone.Console\Lidarr.Console.csproj + EndGlobalSection + GlobalSection(JSLint) = preSolution + SolutionConfigurationLocation = JSLintOptions.xml + EndGlobalSection +EndGlobal diff --git a/src/LogentriesCore/AsyncLogger.cs b/src/LogentriesCore/AsyncLogger.cs deleted file mode 100644 index 12c588ec7..000000000 --- a/src/LogentriesCore/AsyncLogger.cs +++ /dev/null @@ -1,648 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Configuration; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Security; -using System.Text; -using System.Text.RegularExpressions; -using System.Threading; - -namespace LogentriesCore -{ - public class AsyncLogger - { - #region Constants - - // Current version number. - protected const String Version = "2.6.0"; - - // Size of the internal event queue. - protected const int QueueSize = 32768; - - // Minimal delay between attempts to reconnect in milliseconds. - protected const int MinDelay = 100; - - // Maximal delay between attempts to reconnect in milliseconds. - protected const int MaxDelay = 10000; - - // Appender signature - used for debugging messages. - protected const String LeSignature = "LE: "; - - // Legacy Logentries configuration names. - protected const String LegacyConfigTokenName = "LOGENTRIES_TOKEN"; - protected const String LegacyConfigAccountKeyName = "LOGENTRIES_ACCOUNT_KEY"; - protected const String LegacyConfigLocationName = "LOGENTRIES_LOCATION"; - - // New Logentries configuration names. - protected const String ConfigTokenName = "Logentries.Token"; - protected const String ConfigAccountKeyName = "Logentries.AccountKey"; - protected const String ConfigLocationName = "Logentries.Location"; - - // Error message displayed when invalid token is detected. - protected const String InvalidTokenMessage = "\n\nIt appears your LOGENTRIES_TOKEN value is invalid or missing.\n\n"; - - // Error message displayed when invalid account_key or location parameters are detected. - protected const String InvalidHttpPutCredentialsMessage = "\n\nIt appears your LOGENTRIES_ACCOUNT_KEY or LOGENTRIES_LOCATION values are invalid or missing.\n\n"; - - // Error message deisplayed when queue overflow occurs. - protected const String QueueOverflowMessage = "\n\nLogentries buffer queue overflow. Message dropped.\n\n"; - - // Newline char to trim from message for formatting. - protected static char[] TrimChars = { '\r', '\n' }; - - /** Non-Unix and Unix Newline */ - protected static string[] posix_newline = { "\r\n", "\n" }; - - /** Unicode line separator character */ - protected static string line_separator = "\u2028"; - - // Restricted symbols that should not appear in host name. - // See http://support.microsoft.com/kb/228275/en-us for details. - private static Regex ForbiddenHostNameChars = new Regex(@"[/\\\[\]\""\:\;\|\<\>\+\=\,\?\* _]{1,}", RegexOptions.Compiled); - - /** Regex used to validate GUID in .NET3.5 */ - private static Regex isGuid = new Regex(@"^(\{){0,1}[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}(\}){0,1}$", RegexOptions.Compiled); - - #endregion - - #region Singletons - - // UTF-8 output character set. - protected static readonly UTF8Encoding UTF8 = new UTF8Encoding(); - - // ASCII character set used by HTTP. - protected static readonly ASCIIEncoding ASCII = new ASCIIEncoding(); - - //static list of all the queues the le appender might be managing. - private static ConcurrentBag> _allQueues = new ConcurrentBag>(); - - /// - /// Determines if the queue is empty after waiting the specified waitTime. - /// Returns true or false if the underlying queues are empty. - /// - /// The length of time the method should block before giving up waiting for it to empty. - /// True if the queue is empty, false if there are still items waiting to be written. - public static bool AreAllQueuesEmpty(TimeSpan waitTime) - { - var start = DateTime.UtcNow; - var then = DateTime.UtcNow; - - while (start.Add(waitTime) > then) - { - if (_allQueues.All(x => x.Count == 0)) - return true; - - Thread.Sleep(100); - then = DateTime.UtcNow; - } - - return _allQueues.All(x => x.Count == 0); - } - #endregion - - public AsyncLogger() - { - Queue = new BlockingCollection(QueueSize); - _allQueues.Add(Queue); - - WorkerThread = new Thread(Run); - WorkerThread.Name = "Logentries Log4net Appender"; - WorkerThread.IsBackground = true; - } - - #region Configuration properties - - private String m_Token = ""; - private String m_AccountKey = ""; - private String m_Location = ""; - private bool m_ImmediateFlush = false; - private bool m_Debug = false; - private bool m_UseHttpPut = false; - private bool m_UseSsl = false; - - // Properties for defining location of DataHub instance if one is used. - private bool m_UseDataHub = false; // By default Logentries service is used instead of DataHub instance. - private String m_DataHubAddr = ""; - private int m_DataHubPort = 0; - - // Properties to define host name of user's machine and define user-specified log ID. - private bool m_UseHostName = false; // Defines whether to prefix log message with HostName or not. - private String m_HostName = ""; // User-defined or auto-defined host name (if not set in config. file) - private String m_LogID = ""; // User-defined log ID to be prefixed to the log message. - - // Sets DataHub usage flag. - public void setIsUsingDataHub(bool useDataHub) - { - m_UseDataHub = useDataHub; - } - - public bool getIsUsingDataHab() - { - return m_UseDataHub; - } - - // Sets DataHub instance address. - public void setDataHubAddr(String dataHubAddr) - { - m_DataHubAddr = dataHubAddr; - } - - public String getDataHubAddr() - { - return m_DataHubAddr; - } - - // Sets the port on which DataHub instance is waiting for log messages. - public void setDataHubPort(int port) - { - m_DataHubPort = port; - } - - public int getDataHubPort() - { - return m_DataHubPort; - } - - public void setToken(String token) - { - m_Token = token; - } - - public String getToken() - { - return m_Token; - } - - public void setAccountKey(String accountKey) - { - m_AccountKey = accountKey; - } - - public string getAccountKey() - { - return m_AccountKey; - } - - public void setLocation(String location) - { - m_Location = location; - } - - public String getLocation() - { - return m_Location; - } - - public void setImmediateFlush(bool immediateFlush) - { - m_ImmediateFlush = immediateFlush; - } - - public bool getImmediateFlush() - { - return m_ImmediateFlush; - } - - public void setDebug(bool debug) - { - m_Debug = debug; - } - - public bool getDebug() - { - return m_Debug; - } - - public void setUseHttpPut(bool useHttpPut) - { - m_UseHttpPut = useHttpPut; - } - - public bool getUseHttpPut() - { - return m_UseHttpPut; - } - - public void setUseSsl(bool useSsl) - { - m_UseSsl = useSsl; - } - - public bool getUseSsl() - { - return m_UseSsl; - } - - public void setUseHostName(bool useHostName) - { - m_UseHostName = useHostName; - } - - public bool getUseHostName() - { - return m_UseHostName; - } - - public void setHostName(String hostName) - { - m_HostName = hostName; - } - - public String getHostName() - { - return m_HostName; - } - - public void setLogID(String logID) - { - m_LogID = logID; - } - - public String getLogID() - { - return m_LogID; - } - - #endregion - - protected readonly BlockingCollection Queue; - protected readonly Thread WorkerThread; - protected readonly Random Random = new Random(); - - private LeClient LeClient = null; - protected bool IsRunning = false; - - #region Protected methods - - protected virtual void Run() - { - try - { - // Open connection. - ReopenConnection(); - - string logMessagePrefix = String.Empty; - - if (m_UseHostName) - { - // If LogHostName is set to "true", but HostName is not defined - - // try to get host name from Environment. - if (m_HostName == String.Empty) - { - try - { - WriteDebugMessages("HostName parameter is not defined - trying to get it from System.Environment.MachineName"); - m_HostName = "HostName=" + System.Environment.MachineName + " "; - } - catch (InvalidOperationException) - { - // Cannot get host name automatically, so assume that HostName is not used - // and log message is sent without it. - m_UseHostName = false; - WriteDebugMessages("Failed to get HostName parameter using System.Environment.MachineName. Log messages will not be prefixed by HostName"); - } - } - else - { - if (!CheckIfHostNameValid(m_HostName)) - { - // If user-defined host name is incorrect - we cannot use it - // and log message is sent without it. - m_UseHostName = false; - WriteDebugMessages("HostName parameter contains prohibited characters. Log messages will not be prefixed by HostName"); - } - else - { - m_HostName = "HostName=" + m_HostName + " "; - } - } - } - - if (m_LogID != String.Empty) - { - logMessagePrefix = m_LogID + " "; - } - - if (m_UseHostName) - { - logMessagePrefix += m_HostName; - } - - // Flag that is set if logMessagePrefix is empty. - bool isPrefixEmpty = (logMessagePrefix == String.Empty); - - // Send data in queue. - while (true) - { - // Take data from queue. - var line = Queue.Take(); - - // Replace newline chars with line separator to format multi-line events nicely. - foreach (String newline in posix_newline) - { - line = line.Replace(newline, line_separator); - } - - // If m_UseDataHub == true (logs are sent to DataHub instance) then m_Token is not - // appended to the message. - string finalLine = ((!m_UseHttpPut && !m_UseDataHub) ? this.m_Token + line : line) + '\n'; - - // Add prefixes: LogID and HostName if they are defined. - if (!isPrefixEmpty) - { - finalLine = logMessagePrefix + finalLine; - } - - byte[] data = UTF8.GetBytes(finalLine); - - // Send data, reconnect if needed. - while (true) - { - try - { - this.LeClient.Write(data, 0, data.Length); - - if (m_ImmediateFlush) - this.LeClient.Flush(); - } - catch (IOException) - { - // Reopen the lost connection. - ReopenConnection(); - continue; - } - - break; - } - } - } - catch (ThreadInterruptedException ex) - { - WriteDebugMessages("Logentries asynchronous socket client was interrupted.", ex); - } - } - - protected virtual void OpenConnection() - { - try - { - if (LeClient == null) - { - // Create LeClient instance providing all needed parameters. If DataHub-related properties - // have not been overridden by log4net or NLog configurators, then DataHub is not used, - // because m_UseDataHub == false by default. - LeClient = new LeClient(m_UseHttpPut, m_UseSsl, m_UseDataHub, m_DataHubAddr, m_DataHubPort); - } - - LeClient.Connect(); - - if (m_UseHttpPut) - { - var header = String.Format("PUT /{0}/hosts/{1}/?realtime=1 HTTP/1.1\r\n\r\n", m_AccountKey, m_Location); - LeClient.Write(ASCII.GetBytes(header), 0, header.Length); - } - } - catch (Exception ex) - { - throw new IOException("An error occurred while opening the connection.", ex); - } - } - - protected virtual void ReopenConnection() - { - CloseConnection(); - - var rootDelay = MinDelay; - while (true) - { - try - { - OpenConnection(); - - return; - } - catch (Exception ex) - { - if (m_Debug) - { - WriteDebugMessages("Unable to connect to Logentries API.", ex); - } - } - - rootDelay *= 2; - if (rootDelay > MaxDelay) - rootDelay = MaxDelay; - - var waitFor = rootDelay + Random.Next(rootDelay); - - try - { - Thread.Sleep(waitFor); - } - catch - { - throw new ThreadInterruptedException(); - } - } - } - - protected virtual void CloseConnection() - { - if (LeClient != null) - LeClient.Close(); - } - - public static bool IsNullOrWhiteSpace(String value) - { - if (value == null) return true; - - for (int i = 0; i < value.Length; i++) - { - if (!Char.IsWhiteSpace(value[i])) return false; - } - - return true; - } - - private string retrieveSetting(String name) - { - string value; - - - value = ConfigurationManager.AppSettings[name]; - - if (IsNullOrWhiteSpace(value)) - { - try - { - value = Environment.GetEnvironmentVariable(name); - } - catch (SecurityException) - { - } - } - - return value; - } - - /* - * Use CloudConfigurationManager with .NET4.0 and fallback to System.Configuration for previous frameworks. - * - * NOTE: This is not entirely clear with regards to the above comment, but this block of code uses a compiler directive NET4_0 - * which is not set by default anywhere, so most uses of this code will default back to the "pre-.Net4.0" code branch, even - * if you are using .Net4.0 or .Net4.5. - * - * The second issue is that there are two appsetting keys for each setting - the "legacy" key, such as "LOGENTRIES_TOKEN" - * and the "non-legacy" key, such as "Logentries.Token". Again, I'm not sure of the reasons behind this, so the code below checks - * both the legacy and non-legacy keys, defaulting to the legacy keys if they are found. - * - * It probably should be investigated whether the fallback to ConfigurationManager is needed at all, as CloudConfigurationManager - * will retrieve settings from appSettings in a non-Azure environment. - */ - protected virtual bool LoadCredentials() - { - if (!m_UseHttpPut) - { - if (GetIsValidGuid(m_Token)) - return true; - - var configToken = retrieveSetting(LegacyConfigTokenName) ?? retrieveSetting(ConfigTokenName); - - if (!String.IsNullOrEmpty(configToken) && GetIsValidGuid(configToken)) - { - m_Token = configToken; - return true; - } - - WriteDebugMessages(InvalidTokenMessage); - return false; - } - - if (m_AccountKey != "" && GetIsValidGuid(m_AccountKey) && m_Location != "") - return true; - - var configAccountKey = ConfigurationManager.AppSettings[LegacyConfigAccountKeyName] ?? ConfigurationManager.AppSettings[ConfigAccountKeyName]; - if (!String.IsNullOrEmpty(configAccountKey) && GetIsValidGuid(configAccountKey)) - { - m_AccountKey = configAccountKey; - - var configLocation = ConfigurationManager.AppSettings[LegacyConfigLocationName] ?? ConfigurationManager.AppSettings[ConfigLocationName]; - if (!String.IsNullOrEmpty(configLocation)) - { - m_Location = configLocation; - return true; - } - } - - WriteDebugMessages(InvalidHttpPutCredentialsMessage); - return false; - } - - private bool CheckIfHostNameValid(String hostName) - { - return !ForbiddenHostNameChars.IsMatch(hostName); // Returns false if reg.ex. matches any of forbidden chars. - } - - static bool IsGuid(string candidate, out Guid output) - { - bool isValid = false; - output = Guid.Empty; - - if (isGuid.IsMatch(candidate)) - { - output = new Guid(candidate); - isValid = true; - } - return isValid; - } - - protected virtual bool GetIsValidGuid(string guidString) - { - if (String.IsNullOrEmpty(guidString)) - return false; - - System.Guid newGuid = System.Guid.NewGuid(); - return IsGuid(guidString, out newGuid); - } - - protected virtual void WriteDebugMessages(string message, Exception ex) - { - if (!m_Debug) - return; - - message = LeSignature + message; - string[] messages = { message, ex.ToString() }; - foreach (var msg in messages) - { - // Use below line instead when compiling with log4net1.2.10. - //LogLog.Debug(msg); - - //LogLog.Debug(typeof(LogentriesAppender), msg); - - Debug.WriteLine(message); - } - } - - protected virtual void WriteDebugMessages(string message) - { - if (!m_Debug) - return; - - message = LeSignature + message; - - // Use below line instead when compiling with log4net1.2.10. - //LogLog.Debug(message); - - //LogLog.Debug(typeof(LogentriesAppender), message); - Debug.WriteLine(message); - } - - #endregion - - #region publicMethods - - public virtual void AddLine(string line) - { - if (!IsRunning) - { - // We need to load user credentials only - // if the configuration does not state that DataHub is used; - // credentials needed only if logs are sent to LE service directly. - bool credentialsLoaded = false; - if(!m_UseDataHub) - { - credentialsLoaded = LoadCredentials(); - } - - // If in DataHub mode credentials are ignored. - if (credentialsLoaded || m_UseDataHub) - { - WriteDebugMessages("Starting Logentries asynchronous socket client."); - WorkerThread.Start(); - IsRunning = true; - } - } - - WriteDebugMessages("Queueing: " + line); - - String trimmedEvent = line.TrimEnd(TrimChars); - - // Try to append data to queue. - if (!Queue.TryAdd(trimmedEvent)) - { - Queue.Take(); - if (!Queue.TryAdd(trimmedEvent)) - WriteDebugMessages(QueueOverflowMessage); - } - } - - public void interruptWorker() - { - WorkerThread.Interrupt(); - } - - #endregion - } -} \ No newline at end of file diff --git a/src/LogentriesCore/LeClient.cs b/src/LogentriesCore/LeClient.cs deleted file mode 100644 index fdf1b6dca..000000000 --- a/src/LogentriesCore/LeClient.cs +++ /dev/null @@ -1,103 +0,0 @@ -using System; -using System.IO; -using System.Net.Security; -using System.Net.Sockets; - -namespace LogentriesCore -{ - class LeClient - { - // Logentries API server address. - protected const String LeApiUrl = "api.logentries.com"; - - // Port number for token logging on Logentries API server. - protected const int LeApiTokenPort = 10000; - - // Port number for TLS encrypted token logging on Logentries API server - protected const int LeApiTokenTlsPort = 20000; - - // Port number for HTTP PUT logging on Logentries API server. - protected const int LeApiHttpPort = 80; - - // Port number for SSL HTTP PUT logging on Logentries API server. - protected const int LeApiHttpsPort = 443; - - // Creates LeClient instance. If do not define useServerUrl and/or useOverrideProt during call - // LeClient will be configured to work with api.logentries.com server; otherwise - with - // defined server on defined port. - public LeClient(bool useHttpPut, bool useSsl, bool useDataHub, String serverAddr, int port) - { - - // Override port number and server address to send logs to DataHub instance. - if (useDataHub) - { - m_UseSsl = false; // DataHub does not support receiving log messages over SSL for now. - m_TcpPort = port; - m_ServerAddr = serverAddr; - } - else - { - m_UseSsl = useSsl; - - if (!m_UseSsl) - m_TcpPort = useHttpPut ? LeApiHttpPort : LeApiTokenPort; - else - m_TcpPort = useHttpPut ? LeApiHttpsPort : LeApiTokenTlsPort; - } - } - - private bool m_UseSsl = false; - private int m_TcpPort; - private TcpClient m_Client = null; - private Stream m_Stream = null; - private SslStream m_SslStream = null; - private String m_ServerAddr = LeApiUrl; // By default m_ServerAddr points to api.logentries.com if useDataHub is not set to true. - - private Stream ActiveStream - { - get - { - return m_UseSsl ? m_SslStream : m_Stream; - } - } - - public void Connect() - { - m_Client = new TcpClient(m_ServerAddr, m_TcpPort); - m_Client.NoDelay = true; - - m_Stream = m_Client.GetStream(); - - if (m_UseSsl) - { - m_SslStream = new SslStream(m_Stream); - m_SslStream.AuthenticateAsClient(m_ServerAddr); - - } - } - - public void Write(byte[] buffer, int offset, int count) - { - ActiveStream.Write(buffer, offset, count); - } - - public void Flush() - { - ActiveStream.Flush(); - } - - public void Close() - { - if (m_Client != null) - { - try - { - m_Client.Close(); - } - catch - { - } - } - } - } -} diff --git a/src/LogentriesCore/LogentriesCore.csproj b/src/LogentriesCore/LogentriesCore.csproj deleted file mode 100644 index 4f6c66677..000000000 --- a/src/LogentriesCore/LogentriesCore.csproj +++ /dev/null @@ -1,83 +0,0 @@ - - - - Debug - AnyCPU - 8.0.30703 - 2.0 - {90D6E9FC-7B88-4E1B-B018-8FA742274558} - Library - Properties - LogentriesCore - LogentriesCore - v4.0 - 512 - ..\ - true - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - true - bin\x86\Debug\ - DEBUG;TRACE - full - x86 - prompt - MinimumRecommendedRules.ruleset - - - bin\x86\Release\ - TRACE - true - pdbonly - x86 - prompt - MinimumRecommendedRules.ruleset - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/LogentriesCore/Properties/AssemblyInfo.cs b/src/LogentriesCore/Properties/AssemblyInfo.cs deleted file mode 100644 index b720a8243..000000000 --- a/src/LogentriesCore/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("LogentriesCore")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("LogentriesCore")] -[assembly: AssemblyCopyright("Copyright © 2013")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("14055980-6937-4745-9449-dabf47c1d892")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("2.6.0.0")] -[assembly: AssemblyFileVersion("2.6.0.0")] diff --git a/src/LogentriesCore/packages.config b/src/LogentriesCore/packages.config deleted file mode 100644 index e0b68ff6f..000000000 --- a/src/LogentriesCore/packages.config +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/src/LogentriesNLog/LogentriesNLog.csproj b/src/LogentriesNLog/LogentriesNLog.csproj deleted file mode 100644 index 405f8df51..000000000 --- a/src/LogentriesNLog/LogentriesNLog.csproj +++ /dev/null @@ -1,97 +0,0 @@ - - - - Debug - AnyCPU - 8.0.30703 - 2.0 - {9DC31DE3-79FF-47A8-96B4-6BA18F6BB1CB} - Library - Properties - LogentriesNLog - LogentriesNLog - v4.0 - 512 - ..\ - true - - - true - full - false - bin\Debug\ - DEBUG;TRACE - prompt - 4 - - - pdbonly - true - bin\Release\ - TRACE - prompt - 4 - - - true - bin\x86\Debug\ - DEBUG;TRACE - full - x86 - prompt - MinimumRecommendedRules.ruleset - - - bin\x86\Release\ - TRACE - true - pdbonly - x86 - prompt - MinimumRecommendedRules.ruleset - - - - ..\packages\NLog.4.4.1\lib\net40\NLog.dll - True - - - - - - - - - - - - - - - - - - - - - {90D6E9FC-7B88-4E1B-B018-8FA742274558} - LogentriesCore - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/LogentriesNLog/LogentriesTarget.cs b/src/LogentriesNLog/LogentriesTarget.cs deleted file mode 100644 index f254672fd..000000000 --- a/src/LogentriesNLog/LogentriesTarget.cs +++ /dev/null @@ -1,150 +0,0 @@ -using System; -using LogentriesCore; -using LogentriesNLog.fastJSON; -using NLog; -using NLog.Targets; - -namespace LogentriesNLog -{ - [Target("Logentries")] - public sealed class LogentriesTarget : TargetWithLayout - { - private AsyncLogger logentriesAsync; - - public LogentriesTarget() - { - logentriesAsync = new AsyncLogger(); - logentriesAsync.setImmediateFlush(true); - } - - - /** Debug flag. */ - public bool Debug - { - get { return logentriesAsync.getDebug(); } - set { logentriesAsync.setDebug(value); } - } - - /** Is using DataHub parameter flag. - ste to true if it is needed to send messages to DataHub instance. */ - public bool IsUsingDataHub - { - get { return logentriesAsync.getIsUsingDataHab(); } - set { logentriesAsync.setIsUsingDataHub(value); } - } - - /** DataHub server address */ - public String DataHubAddr - { - get { return logentriesAsync.getDataHubAddr(); } - set { logentriesAsync.setDataHubAddr(value); } - } - - /** DataHub server port */ - public int DataHubPort - { - get { return logentriesAsync.getDataHubPort(); } - set { logentriesAsync.setDataHubPort(value); } - } - - /** Option to set Token programmatically or in Appender Definition */ - public string Token - { - get { return logentriesAsync.getToken(); } - set { logentriesAsync.setToken(value); } - } - - /** HTTP PUT Flag */ - public bool HttpPut - { - get { return logentriesAsync.getUseHttpPut(); } - set { logentriesAsync.setUseHttpPut(value); } - } - - /** SSL/TLS parameter flag */ - public bool Ssl - { - get { return logentriesAsync.getUseSsl(); } - set { logentriesAsync.setUseSsl(value); } - } - - /** ACCOUNT_KEY parameter for HTTP PUT logging */ - public String Key - { - get { return logentriesAsync.getAccountKey(); } - set { logentriesAsync.setAccountKey(value); } - } - - /** LOCATION parameter for HTTP PUT logging */ - public String Location - { - get { return logentriesAsync.getLocation(); } - set { logentriesAsync.setLocation(value); } - } - - /* LogHostname - switch that defines whether add host name to the log message */ - public bool LogHostname - { - get { return logentriesAsync.getUseHostName(); } - set { logentriesAsync.setUseHostName(value); } - } - - /* HostName - user-defined host name. If empty the library will try to obtain it automatically */ - public String HostName - { - get { return logentriesAsync.getHostName(); } - set { logentriesAsync.setHostName(value); } - } - - /* User-defined log message ID */ - public String LogID - { - get { return logentriesAsync.getLogID(); } - set { logentriesAsync.setLogID(value); } - } - - public bool KeepConnection { get; set; } - - protected override void Write(LogEventInfo logEvent) - { - //Render message content - var log = new Log - { - Level = logEvent.Level.ToString(), - Logger = logEvent.LoggerName, - Message = logEvent.FormattedMessage, - Time = logEvent.TimeStamp, - }; - - //NLog can pass null references of Exception - if (logEvent.Exception != null) - { - log.Exception = logEvent.Exception.ToString(); - log.ExceptionType = logEvent.Exception.GetType().ToString(); - } - - logentriesAsync.AddLine(JSON.Instance.ToJSON(log)); - } - - protected override void CloseTarget() - { - base.CloseTarget(); - - logentriesAsync.interruptWorker(); - } - } - - public class Log - { - public string Message { get; set; } - - public DateTime Time { get; set; } - - public string Logger { get; set; } - - public string Exception { get; set; } - - public string ExceptionType { get; set; } - - public String Level { get; set; } - } -} diff --git a/src/LogentriesNLog/Properties/AssemblyInfo.cs b/src/LogentriesNLog/Properties/AssemblyInfo.cs deleted file mode 100644 index f9479b9dc..000000000 --- a/src/LogentriesNLog/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("LogentriesNLog")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("LogentriesNLog")] -[assembly: AssemblyCopyright("Copyright © 2013")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("7e04ff2d-ea59-4b62-969d-72bd3e37c684")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("2.4.0.0")] -[assembly: AssemblyFileVersion("2.4.0.0")] diff --git a/src/LogentriesNLog/fastJSON/Getters.cs b/src/LogentriesNLog/fastJSON/Getters.cs deleted file mode 100644 index 26ac09b94..000000000 --- a/src/LogentriesNLog/fastJSON/Getters.cs +++ /dev/null @@ -1,21 +0,0 @@ -//http://fastjson.codeplex.com/ -//http://fastjson.codeplex.com/license - -using System; -using System.Collections.Generic; - -namespace LogentriesNLog.fastJSON -{ - internal class Getters - { - public string Name; - public JSON.GenericGetter Getter; - public Type propertyType; - } - - internal class DatasetSchema - { - public List Info { get; set; } - public string Name { get; set; } - } -} diff --git a/src/LogentriesNLog/fastJSON/JSON.cs b/src/LogentriesNLog/fastJSON/JSON.cs deleted file mode 100644 index aa730a947..000000000 --- a/src/LogentriesNLog/fastJSON/JSON.cs +++ /dev/null @@ -1,821 +0,0 @@ -//http://fastjson.codeplex.com/ -//http://fastjson.codeplex.com/license - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Data; -using System.Globalization; -using System.IO; -using System.Reflection; -using System.Reflection.Emit; -using System.Xml.Serialization; - -namespace LogentriesNLog.fastJSON -{ - - internal class JSON - { - public readonly static JSON Instance = new JSON(); - - private JSON() - { - UseSerializerExtension = false; - SerializeNullValues = false; - UseOptimizedDatasetSchema = false; - UsingGlobalTypes = false; - UseUTCDateTime = true; - } - public bool UseOptimizedDatasetSchema = true; - public bool UseFastGuid = true; - public bool UseSerializerExtension = true; - public bool IndentOutput = false; - public bool SerializeNullValues = true; - public bool UseUTCDateTime = false; - public bool ShowReadOnlyProperties = false; - public bool UsingGlobalTypes = true; - - public string ToJSON(object obj) - { - return ToJSON(obj, UseSerializerExtension, UseFastGuid, UseOptimizedDatasetSchema, SerializeNullValues); - } - - - public string ToJSON(object obj, - bool enableSerializerExtensions, - bool enableFastGuid, - bool enableOptimizedDatasetSchema, - bool serializeNullValues) - { - return new JSONSerializer(enableOptimizedDatasetSchema, enableFastGuid, enableSerializerExtensions, serializeNullValues, IndentOutput).ConvertToJSON(obj); - } - - - public T ToObject(string json) - { - return (T)ToObject(json, typeof(T)); - } - - - public object ToObject(string json, Type type) - { - var ht = new JsonParser(json).Decode() as Dictionary; - if (ht == null) return null; - return ParseDictionary(ht, null, type); - } - -#if CUSTOMTYPE - internal SafeDictionary _customSerializer = new SafeDictionary(); - internal SafeDictionary _customDeserializer = new SafeDictionary(); - - public void RegisterCustomType(Type type, Serialize serializer, Deserialize deserializer) - { - if (type != null && serializer != null && deserializer != null) - { - _customSerializer.Add(type, serializer); - _customDeserializer.Add(type, deserializer); - // reset property cache - _propertycache = new SafeDictionary>(); - } - } - - internal bool IsTypeRegistered(Type t) - { - Serialize s; - return _customSerializer.TryGetValue(t, out s); - } -#endif - - #region [ PROPERTY GET SET CACHE ] - - readonly SafeDictionary _tyname = new SafeDictionary(); - internal string GetTypeAssemblyName(Type t) - { - string val = ""; - if (_tyname.TryGetValue(t, out val)) - return val; - string s = t.AssemblyQualifiedName; - _tyname.Add(t, s); - return s; - } - - readonly SafeDictionary _typecache = new SafeDictionary(); - private Type GetTypeFromCache(string typename) - { - Type val = null; - if (_typecache.TryGetValue(typename, out val)) - return val; - Type t = Type.GetType(typename); - _typecache.Add(typename, t); - return t; - } - - readonly SafeDictionary _constrcache = new SafeDictionary(); - private delegate object CreateObject(); - private object FastCreateInstance(Type objtype) - { - try - { - CreateObject c = null; - if (_constrcache.TryGetValue(objtype, out c)) - { - return c(); - } - DynamicMethod dynMethod = new DynamicMethod("_", objtype, null, true); - ILGenerator ilGen = dynMethod.GetILGenerator(); - - ilGen.Emit(OpCodes.Newobj, objtype.GetConstructor(Type.EmptyTypes)); - ilGen.Emit(OpCodes.Ret); - c = (CreateObject)dynMethod.CreateDelegate(typeof(CreateObject)); - _constrcache.Add(objtype, c); - return c(); - } - catch (Exception exc) - { - throw new Exception(string.Format("Failed to fast create instance for type '{0}' from assemebly '{1}'", - objtype.FullName, objtype.AssemblyQualifiedName), exc); - } - } - - private struct myPropInfo - { - public bool filled; - public Type pt; - public Type bt; - public Type changeType; - public bool isDictionary; - public bool isValueType; - public bool isGenericType; - public bool isArray; - public bool isByteArray; - public bool isGuid; -#if !SILVERLIGHT - public bool isDataSet; - public bool isDataTable; - public bool isHashtable; -#endif - public GenericSetter setter; - public bool isEnum; - public bool isDateTime; - public Type[] GenericTypes; - public bool isInt; - public bool isLong; - public bool isString; - public bool isBool; - public bool isClass; - public GenericGetter getter; - public bool isStringDictionary; - public string Name; -#if CUSTOMTYPE - public bool isCustomType; -#endif - public bool CanWrite; - } - - readonly SafeDictionary> _propertycache = new SafeDictionary>(); - private SafeDictionary Getproperties(Type type, string typename) - { - SafeDictionary sd = null; - if (_propertycache.TryGetValue(typename, out sd)) - { - return sd; - } - sd = new SafeDictionary(); - var pr = type.GetProperties(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance); - foreach (var p in pr) - { - myPropInfo d = CreateMyProp(p.PropertyType, p.Name); - d.CanWrite = p.CanWrite; - d.setter = CreateSetMethod(p); - d.getter = CreateGetMethod(p); - sd.Add(p.Name, d); - } - - _propertycache.Add(typename, sd); - return sd; - } - - private myPropInfo CreateMyProp(Type t, string name) - { - myPropInfo d = new myPropInfo(); - d.filled = true; - d.CanWrite = true; - d.pt = t; - d.Name = name; - d.isDictionary = t.Name.Contains("Dictionary"); - if (d.isDictionary) - d.GenericTypes = t.GetGenericArguments(); - d.isValueType = t.IsValueType; - d.isGenericType = t.IsGenericType; - d.isArray = t.IsArray; - if (d.isArray) - d.bt = t.GetElementType(); - if (d.isGenericType) - d.bt = t.GetGenericArguments()[0]; - d.isByteArray = t == typeof(byte[]); - d.isGuid = (t == typeof(Guid) || t == typeof(Guid?)); -#if !SILVERLIGHT - d.isHashtable = t == typeof(Hashtable); - d.isDataSet = t == typeof(DataSet); - d.isDataTable = t == typeof(DataTable); -#endif - - d.changeType = GetChangeType(t); - d.isEnum = t.IsEnum; - d.isDateTime = t == typeof(DateTime) || t == typeof(DateTime?); - d.isInt = t == typeof(int) || t == typeof(int?); - d.isLong = t == typeof(long) || t == typeof(long?); - d.isString = t == typeof(string); - d.isBool = t == typeof(bool) || t == typeof(bool?); - d.isClass = t.IsClass; - - if (d.isDictionary && d.GenericTypes.Length > 0 && d.GenericTypes[0] == typeof(string)) - d.isStringDictionary = true; - -#if CUSTOMTYPE - if (IsTypeRegistered(t)) - d.isCustomType = true; -#endif - return d; - } - - private delegate void GenericSetter(object target, object value); - - private static GenericSetter CreateSetMethod(PropertyInfo propertyInfo) - { - MethodInfo setMethod = propertyInfo.GetSetMethod(nonPublic: true); - if (setMethod == null) - return null; - - var arguments = new Type[2]; - arguments[0] = arguments[1] = typeof(object); - - DynamicMethod setter = new DynamicMethod("_", typeof(void), arguments, true); - ILGenerator il = setter.GetILGenerator(); - il.Emit(OpCodes.Ldarg_0); - il.Emit(OpCodes.Castclass, propertyInfo.DeclaringType); - il.Emit(OpCodes.Ldarg_1); - - if (propertyInfo.PropertyType.IsClass) - il.Emit(OpCodes.Castclass, propertyInfo.PropertyType); - else - il.Emit(OpCodes.Unbox_Any, propertyInfo.PropertyType); - - il.EmitCall(OpCodes.Callvirt, setMethod, null); - il.Emit(OpCodes.Ret); - - return (GenericSetter)setter.CreateDelegate(typeof(GenericSetter)); - } - - internal delegate object GenericGetter(object obj); - - - private GenericGetter CreateGetMethod(PropertyInfo propertyInfo) - { - MethodInfo getMethod = propertyInfo.GetGetMethod(); - if (getMethod == null) - return null; - - var arguments = new Type[1]; - arguments[0] = typeof(object); - - DynamicMethod getter = new DynamicMethod("_", typeof(object), arguments, true); - ILGenerator il = getter.GetILGenerator(); - il.Emit(OpCodes.Ldarg_0); - il.Emit(OpCodes.Castclass, propertyInfo.DeclaringType); - il.EmitCall(OpCodes.Callvirt, getMethod, null); - - if (!propertyInfo.PropertyType.IsClass) - il.Emit(OpCodes.Box, propertyInfo.PropertyType); - - il.Emit(OpCodes.Ret); - - return (GenericGetter)getter.CreateDelegate(typeof(GenericGetter)); - } - - readonly SafeDictionary> _getterscache = new SafeDictionary>(); - internal List GetGetters(Type type) - { - List val = null; - if (_getterscache.TryGetValue(type, out val)) - return val; - - var props = type.GetProperties(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance); - var getters = new List(); - foreach (var p in props) - { - if (!p.CanWrite && ShowReadOnlyProperties == false) continue; - - var att = p.GetCustomAttributes(typeof(XmlIgnoreAttribute), false); - if (att != null && att.Length > 0) - continue; - - GenericGetter g = CreateGetMethod(p); - if (g != null) - { - Getters gg = new Getters(); - gg.Name = p.Name; - gg.Getter = g; - gg.propertyType = p.PropertyType; - getters.Add(gg); - } - } - - - _getterscache.Add(type, getters); - return getters; - } - - private object ChangeType(object value, Type conversionType) - { - if (conversionType == typeof(int)) - return (int)CreateLong((string)value); - - if (conversionType == typeof(long)) - return CreateLong((string)value); - - if (conversionType == typeof(string)) - return value; - - if (conversionType == typeof(Guid)) - return CreateGuid((string)value); - - if (conversionType.IsEnum) - return CreateEnum(conversionType, (string)value); - - return Convert.ChangeType(value, conversionType, CultureInfo.InvariantCulture); - } - #endregion - - - private object ParseDictionary(Dictionary d, Dictionary globaltypes, Type type) - { - object tn = ""; - if (d.TryGetValue("$types", out tn)) - { - UsingGlobalTypes = true; - globaltypes = new Dictionary(); - foreach (var kv in (Dictionary)tn) - { - globaltypes.Add((string)kv.Value, kv.Key); - } - } - - bool found = d.TryGetValue("$type", out tn); -#if !SILVERLIGHT - if (found == false && type == typeof(Object)) - { - return CreateDataset(d, globaltypes); - } -#endif - if (found) - { - if (UsingGlobalTypes) - { - object tname = ""; - if (globaltypes.TryGetValue((string)tn, out tname)) - tn = tname; - } - type = GetTypeFromCache((string)tn); - } - - if (type == null) - throw new Exception("Cannot determine type"); - - string typename = type.FullName; - object o = FastCreateInstance(type); - var props = Getproperties(type, typename); - foreach (var name in d.Keys) - { - if (name == "$map") - { - ProcessMap(o, props, (Dictionary)d[name]); - continue; - } - myPropInfo pi; - if (props.TryGetValue(name, out pi) == false) - continue; - if (pi.filled) - { - object v = d[name]; - - if (v != null) - { - object oset = null; - - if (pi.isInt) - oset = (int)CreateLong((string)v); -#if CUSTOMTYPE - else if (pi.isCustomType) - oset = CreateCustom((string)v, pi.pt); -#endif - else if (pi.isLong) - oset = CreateLong((string)v); - - else if (pi.isString) - oset = v; - - else if (pi.isBool) - oset = (bool)v; - - else if (pi.isGenericType && pi.isValueType == false && pi.isDictionary == false) -#if SILVERLIGHT - oset = CreateGenericList((List)v, pi.pt, pi.bt, globaltypes); -#else - oset = CreateGenericList((ArrayList)v, pi.pt, pi.bt, globaltypes); -#endif - else if (pi.isByteArray) - oset = Convert.FromBase64String((string)v); - - else if (pi.isArray && pi.isValueType == false) -#if SILVERLIGHT - oset = CreateArray((List)v, pi.pt, pi.bt, globaltypes); -#else - oset = CreateArray((ArrayList)v, pi.pt, pi.bt, globaltypes); -#endif - else if (pi.isGuid) - oset = CreateGuid((string)v); -#if !SILVERLIGHT - else if (pi.isDataSet) - oset = CreateDataset((Dictionary)v, globaltypes); - - else if (pi.isDataTable) - oset = CreateDataTable((Dictionary)v, globaltypes); -#endif - - else if (pi.isStringDictionary) - oset = CreateStringKeyDictionary((Dictionary)v, pi.pt, pi.GenericTypes, globaltypes); - -#if !SILVERLIGHT - else if (pi.isDictionary || pi.isHashtable) - oset = CreateDictionary((ArrayList)v, pi.pt, pi.GenericTypes, globaltypes); -#else - else if (pi.isDictionary) - oset = CreateDictionary((List)v, pi.pt, pi.GenericTypes, globaltypes); -#endif - - else if (pi.isEnum) - oset = CreateEnum(pi.pt, (string)v); - - else if (pi.isDateTime) - oset = CreateDateTime((string)v); - - else if (pi.isClass && v is Dictionary) - oset = ParseDictionary((Dictionary)v, globaltypes, pi.pt); - - else if (pi.isValueType) - oset = ChangeType(v, pi.changeType); - -#if SILVERLIGHT - else if (v is List) - oset = CreateArray((List)v, pi.pt, typeof(object), globaltypes); -#else - else if (v is ArrayList) - oset = CreateArray((ArrayList)v, pi.pt, typeof(object), globaltypes); -#endif - else - oset = v; - - if (pi.CanWrite) - pi.setter(o, oset); - } - } - } - return o; - } - -#if CUSTOMTYPE - private object CreateCustom(string v, Type type) - { - Deserialize d; - _customDeserializer.TryGetValue(type, out d); - return d(v); - } -#endif - - private void ProcessMap(object obj, SafeDictionary props, Dictionary dic) - { - foreach (var kv in dic) - { - myPropInfo p = props[kv.Key]; - object o = p.getter(obj); - Type t = Type.GetType((string)kv.Value); - if (t == typeof(Guid)) - p.setter(obj, CreateGuid((string)o)); - } - } - - private long CreateLong(string s) - { - long num = 0; - bool neg = false; - foreach (var cc in s) - { - if (cc == '-') - neg = true; - else if (cc == '+') - neg = false; - else - { - num *= 10; - num += (cc - '0'); - } - } - - return neg ? -num : num; - } - - private object CreateEnum(Type pt, string v) - { - // TODO : optimize create enum -#if !SILVERLIGHT - return Enum.Parse(pt, v); -#else - return Enum.Parse(pt, v, true); -#endif - } - - private Guid CreateGuid(string s) - { - if (s.Length > 30) - return new Guid(s); - return new Guid(Convert.FromBase64String(s)); - } - - private DateTime CreateDateTime(string value) - { - bool utc = false; - // 0123456789012345678 - // datetime format = yyyy-MM-dd HH:mm:ss - int year = (int)CreateLong(value.Substring(0, 4)); - int month = (int)CreateLong(value.Substring(5, 2)); - int day = (int)CreateLong(value.Substring(8, 2)); - int hour = (int)CreateLong(value.Substring(11, 2)); - int min = (int)CreateLong(value.Substring(14, 2)); - int sec = (int)CreateLong(value.Substring(17, 2)); - - if (value.EndsWith("Z")) - utc = true; - - if (UseUTCDateTime == false && utc == false) - return new DateTime(year, month, day, hour, min, sec); - return new DateTime(year, month, day, hour, min, sec, DateTimeKind.Utc).ToLocalTime(); - } - -#if SILVERLIGHT - private object CreateArray(List data, Type pt, Type bt, Dictionary globalTypes) - { - Array col = Array.CreateInstance(bt, data.Count); - // create an array of objects - for (int i = 0; i < data.Count; i++)// each (object ob in data) - { - object ob = data[i]; - if (ob is IDictionary) - col.SetValue(ParseDictionary((Dictionary)ob, globalTypes, bt), i); - else - col.SetValue(ChangeType(ob, bt), i); - } - - return col; - } -#else - private object CreateArray(ArrayList data, Type pt, Type bt, Dictionary globalTypes) - { - ArrayList col = new ArrayList(); - // create an array of objects - foreach (var ob in data) - { - if (ob is IDictionary) - col.Add(ParseDictionary((Dictionary)ob, globalTypes, bt)); - else - col.Add(ChangeType(ob, bt)); - } - return col.ToArray(bt); - } -#endif - - -#if SILVERLIGHT - private object CreateGenericList(List data, Type pt, Type bt, Dictionary globalTypes) -#else - private object CreateGenericList(ArrayList data, Type pt, Type bt, Dictionary globalTypes) -#endif - { - IList col = (IList)FastCreateInstance(pt); - // create an array of objects - foreach (var ob in data) - { - if (ob is IDictionary) - col.Add(ParseDictionary((Dictionary)ob, globalTypes, bt)); -#if SILVERLIGHT - else if (ob is List) - col.Add(((List)ob).ToArray()); -#else - else if (ob is ArrayList) - col.Add(((ArrayList)ob).ToArray()); -#endif - else - col.Add(ChangeType(ob, bt)); - } - return col; - } - - private object CreateStringKeyDictionary(Dictionary reader, Type pt, Type[] types, Dictionary globalTypes) - { - var col = (IDictionary)FastCreateInstance(pt); - Type t1 = null; - Type t2 = null; - if (types != null) - { - t1 = types[0]; - t2 = types[1]; - } - - foreach (var values in reader) - { - var key = values.Key;//ChangeType(values.Key, t1); - object val = null; - if (values.Value is Dictionary) - val = ParseDictionary((Dictionary)values.Value, globalTypes, t2); - else - val = ChangeType(values.Value, t2); - col.Add(key, val); - } - - return col; - } - -#if SILVERLIGHT - private object CreateDictionary(List reader, Type pt, Type[] types, Dictionary globalTypes) -#else - private object CreateDictionary(ArrayList reader, Type pt, Type[] types, Dictionary globalTypes) -#endif - { - IDictionary col = (IDictionary)FastCreateInstance(pt); - Type t1 = null; - Type t2 = null; - if (types != null) - { - t1 = types[0]; - t2 = types[1]; - } - - foreach (Dictionary values in reader) - { - object key = values["k"]; - object val = values["v"]; - - if (key is Dictionary) - key = ParseDictionary((Dictionary)key, globalTypes, t1); - else - key = ChangeType(key, t1); - - if (val is Dictionary) - val = ParseDictionary((Dictionary)val, globalTypes, t2); - else - val = ChangeType(val, t2); - - col.Add(key, val); - } - - return col; - } - - private Type GetChangeType(Type conversionType) - { - if (conversionType.IsGenericType && conversionType.GetGenericTypeDefinition().Equals(typeof(Nullable<>))) - return conversionType.GetGenericArguments()[0]; - - return conversionType; - } -#if !SILVERLIGHT - private DataSet CreateDataset(Dictionary reader, Dictionary globalTypes) - { - DataSet ds = new DataSet(); - ds.EnforceConstraints = false; - ds.BeginInit(); - - // read dataset schema here - ReadSchema(reader, ds, globalTypes); - - foreach (var pair in reader) - { - if (pair.Key == "$type" || pair.Key == "$schema") continue; - - ArrayList rows = (ArrayList)pair.Value; - if (rows == null) continue; - - DataTable dt = ds.Tables[pair.Key]; - ReadDataTable(rows, dt); - } - - ds.EndInit(); - - return ds; - } - - private void ReadSchema(Dictionary reader, DataSet ds, Dictionary globalTypes) - { - var schema = reader["$schema"]; - - if (schema is string) - { - TextReader tr = new StringReader((string)schema); - ds.ReadXmlSchema(tr); - } - else - { - DatasetSchema ms = (DatasetSchema)ParseDictionary((Dictionary)schema, globalTypes, typeof(DatasetSchema)); - ds.DataSetName = ms.Name; - for (int i = 0; i < ms.Info.Count; i += 3) - { - if (ds.Tables.Contains(ms.Info[i]) == false) - ds.Tables.Add(ms.Info[i]); - ds.Tables[ms.Info[i]].Columns.Add(ms.Info[i + 1], Type.GetType(ms.Info[i + 2])); - } - } - } - - private void ReadDataTable(ArrayList rows, DataTable dt) - { - dt.BeginInit(); - dt.BeginLoadData(); - var guidcols = new List(); - var datecol = new List(); - - foreach (DataColumn c in dt.Columns) - { - if (c.DataType == typeof(Guid) || c.DataType == typeof(Guid?)) - guidcols.Add(c.Ordinal); - if (UseUTCDateTime && (c.DataType == typeof(DateTime) || c.DataType == typeof(DateTime?))) - datecol.Add(c.Ordinal); - } - - foreach (ArrayList row in rows) - { - var v = new object[row.Count]; - row.CopyTo(v, 0); - foreach (var i in guidcols) - { - string s = (string)v[i]; - if (s != null && s.Length < 36) - v[i] = new Guid(Convert.FromBase64String(s)); - } - if (UseUTCDateTime) - { - foreach (var i in datecol) - { - string s = (string)v[i]; - if (s != null) - v[i] = CreateDateTime(s); - } - } - dt.Rows.Add(v); - } - - dt.EndLoadData(); - dt.EndInit(); - } - - DataTable CreateDataTable(Dictionary reader, Dictionary globalTypes) - { - var dt = new DataTable(); - - // read dataset schema here - var schema = reader["$schema"]; - - if (schema is string) - { - TextReader tr = new StringReader((string)schema); - dt.ReadXmlSchema(tr); - } - else - { - var ms = (DatasetSchema)ParseDictionary((Dictionary)schema, globalTypes, typeof(DatasetSchema)); - dt.TableName = ms.Info[0]; - for (int i = 0; i < ms.Info.Count; i += 3) - { - dt.Columns.Add(ms.Info[i + 1], Type.GetType(ms.Info[i + 2])); - } - } - - foreach (var pair in reader) - { - if (pair.Key == "$type" || pair.Key == "$schema") - continue; - - var rows = (ArrayList)pair.Value; - if (rows == null) - continue; - - if (!dt.TableName.Equals(pair.Key, StringComparison.InvariantCultureIgnoreCase)) - continue; - - ReadDataTable(rows, dt); - } - - return dt; - } -#endif - } -} diff --git a/src/LogentriesNLog/fastJSON/JsonParser.cs b/src/LogentriesNLog/fastJSON/JsonParser.cs deleted file mode 100644 index 7f451de45..000000000 --- a/src/LogentriesNLog/fastJSON/JsonParser.cs +++ /dev/null @@ -1,409 +0,0 @@ -//http://fastjson.codeplex.com/ -//http://fastjson.codeplex.com/license - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Text; - -namespace LogentriesNLog.fastJSON -{ - /// - /// This class encodes and decodes JSON strings. - /// Spec. details, see http://www.json.org/ - /// - /// JSON uses Arrays and Objects. These correspond here to the datatypes ArrayList and Hashtable. - /// All numbers are parsed to doubles. - /// - internal class JsonParser - { - enum Token - { - None = -1, // Used to denote no Lookahead available - Curly_Open, - Curly_Close, - Squared_Open, - Squared_Close, - Colon, - Comma, - String, - Number, - True, - False, - Null - } - - readonly char[] json; - readonly StringBuilder s = new StringBuilder(); - Token lookAheadToken = Token.None; - int index; - - internal JsonParser(string json) - { - this.json = json.ToCharArray(); - } - - public object Decode() - { - return ParseValue(); - } - - private Dictionary ParseObject() - { - var table = new Dictionary(); - - ConsumeToken(); // { - - while (true) - { - switch (LookAhead()) - { - - case Token.Comma: - ConsumeToken(); - break; - - case Token.Curly_Close: - ConsumeToken(); - return table; - - default: - { - - // name - string name = ParseString(); - - // : - if (NextToken() != Token.Colon) - { - throw new Exception("Expected colon at index " + index); - } - - // value - object value = ParseValue(); - - table[name] = value; - } - break; - } - } - } - -#if SILVERLIGHT - private List ParseArray() - { - List array = new List(); -#else - private ArrayList ParseArray() - { - ArrayList array = new ArrayList(); -#endif - ConsumeToken(); // [ - - while (true) - { - switch (LookAhead()) - { - - case Token.Comma: - ConsumeToken(); - break; - - case Token.Squared_Close: - ConsumeToken(); - return array; - - default: - { - array.Add(ParseValue()); - } - break; - } - } - } - - private object ParseValue() - { - switch (LookAhead()) - { - case Token.Number: - return ParseNumber(); - - case Token.String: - return ParseString(); - - case Token.Curly_Open: - return ParseObject(); - - case Token.Squared_Open: - return ParseArray(); - - case Token.True: - ConsumeToken(); - return true; - - case Token.False: - ConsumeToken(); - return false; - - case Token.Null: - ConsumeToken(); - return null; - } - - throw new Exception("Unrecognized token at index" + index); - } - - private string ParseString() - { - ConsumeToken(); // " - - s.Length = 0; - - int runIndex = -1; - - while (index < json.Length) - { - var c = json[index++]; - - if (c == '"') - { - if (runIndex != -1) - { - if (s.Length == 0) - return new string(json, runIndex, index - runIndex - 1); - - s.Append(json, runIndex, index - runIndex - 1); - } - return s.ToString(); - } - - if (c != '\\') - { - if (runIndex == -1) - runIndex = index - 1; - - continue; - } - - if (index == json.Length) break; - - if (runIndex != -1) - { - s.Append(json, runIndex, index - runIndex - 1); - runIndex = -1; - } - - switch (json[index++]) - { - case '"': - s.Append('"'); - break; - - case '\\': - s.Append('\\'); - break; - - case '/': - s.Append('/'); - break; - - case 'b': - s.Append('\b'); - break; - - case 'f': - s.Append('\f'); - break; - - case 'n': - s.Append('\n'); - break; - - case 'r': - s.Append('\r'); - break; - - case 't': - s.Append('\t'); - break; - - case 'u': - { - int remainingLength = json.Length - index; - if (remainingLength < 4) break; - - // parse the 32 bit hex into an integer codepoint - uint codePoint = ParseUnicode(json[index], json[index + 1], json[index + 2], json[index + 3]); - s.Append((char)codePoint); - - // skip 4 chars - index += 4; - } - break; - } - } - - throw new Exception("Unexpectedly reached end of string"); - } - - private uint ParseSingleChar(char c1, uint multipliyer) - { - uint p1 = 0; - if (c1 >= '0' && c1 <= '9') - p1 = (uint)(c1 - '0') * multipliyer; - else if (c1 >= 'A' && c1 <= 'F') - p1 = (uint)((c1 - 'A') + 10) * multipliyer; - else if (c1 >= 'a' && c1 <= 'f') - p1 = (uint)((c1 - 'a') + 10) * multipliyer; - return p1; - } - - private uint ParseUnicode(char c1, char c2, char c3, char c4) - { - uint p1 = ParseSingleChar(c1, 0x1000); - uint p2 = ParseSingleChar(c2, 0x100); - uint p3 = ParseSingleChar(c3, 0x10); - uint p4 = ParseSingleChar(c4, 1); - - return p1 + p2 + p3 + p4; - } - - private string ParseNumber() - { - ConsumeToken(); - - // Need to start back one place because the first digit is also a token and would have been consumed - var startIndex = index - 1; - - do - { - var c = json[index]; - - if ((c >= '0' && c <= '9') || c == '.' || c == '-' || c == '+' || c == 'e' || c == 'E') - { - if (++index == json.Length) throw new Exception("Unexpected end of string whilst parsing number"); - continue; - } - - break; - } while (true); - - return new string(json, startIndex, index - startIndex); - } - - private Token LookAhead() - { - if (lookAheadToken != Token.None) return lookAheadToken; - - return lookAheadToken = NextTokenCore(); - } - - private void ConsumeToken() - { - lookAheadToken = Token.None; - } - - private Token NextToken() - { - var result = lookAheadToken != Token.None ? lookAheadToken : NextTokenCore(); - - lookAheadToken = Token.None; - - return result; - } - - private Token NextTokenCore() - { - char c; - - // Skip past whitespace - do - { - c = json[index]; - - if (c > ' ') break; - if (c != ' ' && c != '\t' && c != '\n' && c != '\r') break; - - } while (++index < json.Length); - - if (index == json.Length) - { - throw new Exception("Reached end of string unexpectedly"); - } - - c = json[index]; - - index++; - - //if (c >= '0' && c <= '9') - // return Token.Number; - - switch (c) - { - case '{': - return Token.Curly_Open; - - case '}': - return Token.Curly_Close; - - case '[': - return Token.Squared_Open; - - case ']': - return Token.Squared_Close; - - case ',': - return Token.Comma; - - case '"': - return Token.String; - - case '0': case '1': case '2': case '3': case '4': - case '5': case '6': case '7': case '8': case '9': - case '-': case '+': case '.': - return Token.Number; - - case ':': - return Token.Colon; - - case 'f': - if (json.Length - index >= 4 && - json[index + 0] == 'a' && - json[index + 1] == 'l' && - json[index + 2] == 's' && - json[index + 3] == 'e') - { - index += 4; - return Token.False; - } - break; - - case 't': - if (json.Length - index >= 3 && - json[index + 0] == 'r' && - json[index + 1] == 'u' && - json[index + 2] == 'e') - { - index += 3; - return Token.True; - } - break; - - case 'n': - if (json.Length - index >= 3 && - json[index + 0] == 'u' && - json[index + 1] == 'l' && - json[index + 2] == 'l') - { - index += 3; - return Token.Null; - } - break; - - } - - throw new Exception("Could not find token at index " + --index); - } - } -} diff --git a/src/LogentriesNLog/fastJSON/JsonSerializer.cs b/src/LogentriesNLog/fastJSON/JsonSerializer.cs deleted file mode 100644 index 8806d5d07..000000000 --- a/src/LogentriesNLog/fastJSON/JsonSerializer.cs +++ /dev/null @@ -1,521 +0,0 @@ -//http://fastjson.codeplex.com/ -//http://fastjson.codeplex.com/license - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Data; -using System.Globalization; -using System.IO; -using System.Text; - -namespace LogentriesNLog.fastJSON -{ - internal class JSONSerializer - { - private readonly StringBuilder _output = new StringBuilder(); - readonly bool useMinimalDataSetSchema; - readonly bool fastguid = true; - readonly bool useExtension = true; - readonly bool serializeNulls = true; - readonly int _MAX_DEPTH = 10; - readonly bool _Indent; - readonly bool _useGlobalTypes = true; - int _current_depth; - private readonly Dictionary _globalTypes = new Dictionary(); - - internal JSONSerializer(bool UseMinimalDataSetSchema, bool UseFastGuid, bool UseExtensions, bool SerializeNulls, bool IndentOutput) - { - useMinimalDataSetSchema = UseMinimalDataSetSchema; - fastguid = UseFastGuid; - useExtension = UseExtensions; - _Indent = IndentOutput; - serializeNulls = SerializeNulls; - if (useExtension == false) - _useGlobalTypes = false; - } - - internal string ConvertToJSON(object obj) - { - WriteValue(obj); - - string str = ""; - if (_useGlobalTypes) - { - StringBuilder sb = new StringBuilder(); - sb.Append("{\"$types\":{"); - bool pendingSeparator = false; - foreach (var kv in _globalTypes) - { - if (pendingSeparator) sb.Append(','); - pendingSeparator = true; - sb.Append("\""); - sb.Append(kv.Key); - sb.Append("\":\""); - sb.Append(kv.Value); - sb.Append("\""); - } - sb.Append("},"); - str = sb + _output.ToString(); - } - else - str = _output.ToString(); - - return str; - } - - private void WriteValue(object obj) - { - if (obj == null || obj is DBNull) - _output.Append("null"); - - else if (obj is string || obj is char) - WriteString((string)obj); - - else if (obj is Guid) - WriteGuid((Guid)obj); - - else if (obj is bool) - _output.Append(((bool)obj) ? "true" : "false"); // conform to standard - - else if ( - obj is int || obj is long || obj is double || - obj is decimal || obj is float || - obj is byte || obj is short || - obj is sbyte || obj is ushort || - obj is uint || obj is ulong - ) - _output.Append(((IConvertible)obj).ToString(NumberFormatInfo.InvariantInfo)); - - else if (obj is DateTime) - WriteDateTime((DateTime)obj); - - else if (obj is IDictionary && obj.GetType().IsGenericType && obj.GetType().GetGenericArguments()[0] == typeof(string)) - WriteStringDictionary((IDictionary)obj); - - else if (obj is IDictionary) - WriteDictionary((IDictionary)obj); -#if !SILVERLIGHT - else if (obj is DataSet) - WriteDataset((DataSet)obj); - - else if (obj is DataTable) - WriteDataTable((DataTable)obj); -#endif - else if (obj is byte[]) - WriteBytes((byte[])obj); - - else if (obj is Array || obj is IList || obj is ICollection) - WriteArray((IEnumerable)obj); - - else if (obj is Enum) - WriteEnum((Enum)obj); - -#if CUSTOMTYPE - else if (JSON.Instance.IsTypeRegistered(obj.GetType())) - WriteCustom(obj); -#endif - else - WriteObject(obj); - } - -#if CUSTOMTYPE - private void WriteCustom(object obj) - { - Serialize s; - JSON.Instance._customSerializer.TryGetValue(obj.GetType(), out s); - WriteStringFast(s(obj)); - } -#endif - - private void WriteEnum(Enum e) - { - // TODO : optimize enum write - WriteStringFast(e.ToString()); - } - - private void WriteGuid(Guid g) - { - if (fastguid == false) - WriteStringFast(g.ToString()); - else - WriteBytes(g.ToByteArray()); - } - - private void WriteBytes(byte[] bytes) - { -#if !SILVERLIGHT - WriteStringFast(Convert.ToBase64String(bytes, 0, bytes.Length, Base64FormattingOptions.None)); -#else - WriteStringFast(Convert.ToBase64String(bytes, 0, bytes.Length)); -#endif - } - - private void WriteDateTime(DateTime dateTime) - { - // datetime format standard : yyyy-MM-dd HH:mm:ss - DateTime dt = dateTime; - if (JSON.Instance.UseUTCDateTime) - dt = dateTime.ToUniversalTime(); - - _output.Append("\""); - _output.Append(dt.Year.ToString("0000", NumberFormatInfo.InvariantInfo)); - _output.Append("-"); - _output.Append(dt.Month.ToString("00", NumberFormatInfo.InvariantInfo)); - _output.Append("-"); - _output.Append(dt.Day.ToString("00", NumberFormatInfo.InvariantInfo)); - _output.Append(" "); - _output.Append(dt.Hour.ToString("00", NumberFormatInfo.InvariantInfo)); - _output.Append(":"); - _output.Append(dt.Minute.ToString("00", NumberFormatInfo.InvariantInfo)); - _output.Append(":"); - _output.Append(dt.Second.ToString("00", NumberFormatInfo.InvariantInfo)); - _output.Append("."); - _output.Append(dt.Millisecond.ToString("000", NumberFormatInfo.InvariantInfo)); - - if (JSON.Instance.UseUTCDateTime) - _output.Append("Z"); - - _output.Append("\""); - } -#if !SILVERLIGHT - private DatasetSchema GetSchema(DataTable ds) - { - if (ds == null) return null; - - DatasetSchema m = new DatasetSchema(); - m.Info = new List(); - m.Name = ds.TableName; - - foreach (DataColumn c in ds.Columns) - { - m.Info.Add(ds.TableName); - m.Info.Add(c.ColumnName); - m.Info.Add(c.DataType.ToString()); - } - // TODO : serialize relations and constraints here - - return m; - } - - private DatasetSchema GetSchema(DataSet ds) - { - if (ds == null) return null; - - DatasetSchema m = new DatasetSchema(); - m.Info = new List(); - m.Name = ds.DataSetName; - - foreach (DataTable t in ds.Tables) - { - foreach (DataColumn c in t.Columns) - { - m.Info.Add(t.TableName); - m.Info.Add(c.ColumnName); - m.Info.Add(c.DataType.ToString()); - } - } - // TODO : serialize relations and constraints here - - return m; - } - - private string GetXmlSchema(DataTable dt) - { - using (var writer = new StringWriter()) - { - dt.WriteXmlSchema(writer); - return dt.ToString(); - } - } - - private void WriteDataset(DataSet ds) - { - _output.Append('{'); - if (useExtension) - { - WritePair("$schema", useMinimalDataSetSchema ? (object)GetSchema(ds) : ds.GetXmlSchema()); - _output.Append(','); - } - bool tablesep = false; - foreach (DataTable table in ds.Tables) - { - if (tablesep) _output.Append(","); - tablesep = true; - WriteDataTableData(table); - } - // end dataset - _output.Append('}'); - } - - private void WriteDataTableData(DataTable table) - { - _output.Append('\"'); - _output.Append(table.TableName); - _output.Append("\":["); - DataColumnCollection cols = table.Columns; - bool rowseparator = false; - foreach (DataRow row in table.Rows) - { - if (rowseparator) _output.Append(","); - rowseparator = true; - _output.Append('['); - - bool pendingSeperator = false; - foreach (DataColumn column in cols) - { - if (pendingSeperator) _output.Append(','); - WriteValue(row[column]); - pendingSeperator = true; - } - _output.Append(']'); - } - - _output.Append(']'); - } - - void WriteDataTable(DataTable dt) - { - _output.Append('{'); - if (useExtension) - { - WritePair("$schema", useMinimalDataSetSchema ? (object)GetSchema(dt) : GetXmlSchema(dt)); - _output.Append(','); - } - - WriteDataTableData(dt); - - // end datatable - _output.Append('}'); - } -#endif - bool _firstWritten; - private void WriteObject(object obj) - { - Indent(); - if (_useGlobalTypes == false) - _output.Append('{'); - else - { - if (_firstWritten) - _output.Append("{"); - } - _firstWritten = true; - _current_depth++; - if (_current_depth > _MAX_DEPTH) - throw new Exception("Serializer encountered maximum depth of " + _MAX_DEPTH); - - - var map = new Dictionary(); - Type t = obj.GetType(); - bool append = false; - if (useExtension) - { - if (_useGlobalTypes == false) - WritePairFast("$type", JSON.Instance.GetTypeAssemblyName(t)); - else - { - int dt = 0; - string ct = JSON.Instance.GetTypeAssemblyName(t); - if (_globalTypes.TryGetValue(ct, out dt) == false) - { - dt = _globalTypes.Count + 1; - _globalTypes.Add(ct, dt); - } - WritePairFast("$type", dt.ToString()); - } - append = true; - } - - var g = JSON.Instance.GetGetters(t); - foreach (var p in g) - { - if (append) - _output.Append(','); - object o = p.Getter(obj); - if ((o == null || o is DBNull) && serializeNulls == false) - append = false; - else - { - WritePair(p.Name, o); - if (o != null && useExtension) - { - Type tt = o.GetType(); - if (tt == typeof(Object)) - map.Add(p.Name, tt.ToString()); - } - append = true; - } - } - if (map.Count > 0 && useExtension) - { - _output.Append(",\"$map\":"); - WriteStringDictionary(map); - } - _current_depth--; - Indent(); - _output.Append('}'); - _current_depth--; - - } - - private void Indent() - { - Indent(false); - } - - private void Indent(bool dec) - { - if (_Indent) - { - _output.Append("\r\n"); - for (int i = 0; i < _current_depth - (dec ? 1 : 0); i++) - _output.Append("\t"); - } - } - - private void WritePairFast(string name, string value) - { - if ((value == null) && serializeNulls == false) - return; - Indent(); - WriteStringFast(name); - - _output.Append(':'); - - WriteStringFast(value); - } - - private void WritePair(string name, object value) - { - if ((value == null || value is DBNull) && serializeNulls == false) - return; - Indent(); - WriteStringFast(name); - - _output.Append(':'); - - WriteValue(value); - } - - private void WriteArray(IEnumerable array) - { - Indent(); - _output.Append('['); - - bool pendingSeperator = false; - - foreach (var obj in array) - { - Indent(); - if (pendingSeperator) _output.Append(','); - - WriteValue(obj); - - pendingSeperator = true; - } - Indent(); - _output.Append(']'); - } - - private void WriteStringDictionary(IDictionary dic) - { - Indent(); - _output.Append('{'); - - bool pendingSeparator = false; - - foreach (DictionaryEntry entry in dic) - { - if (pendingSeparator) _output.Append(','); - - WritePair((string)entry.Key, entry.Value); - - pendingSeparator = true; - } - Indent(); - _output.Append('}'); - } - - private void WriteDictionary(IDictionary dic) - { - Indent(); - _output.Append('['); - - bool pendingSeparator = false; - - foreach (DictionaryEntry entry in dic) - { - if (pendingSeparator) _output.Append(','); - Indent(); - _output.Append('{'); - WritePair("k", entry.Key); - _output.Append(","); - WritePair("v", entry.Value); - Indent(); - _output.Append('}'); - - pendingSeparator = true; - } - Indent(); - _output.Append(']'); - } - - private void WriteStringFast(string s) - { - //Indent(); - _output.Append('\"'); - _output.Append(s); - _output.Append('\"'); - } - - private void WriteString(string s) - { - //Indent(); - _output.Append('\"'); - - int runIndex = -1; - - for (var index = 0; index < s.Length; ++index) - { - var c = s[index]; - - if (c >= ' ' && c < 128 && c != '\"' && c != '\\') - { - if (runIndex == -1) - { - runIndex = index; - } - - continue; - } - - if (runIndex != -1) - { - _output.Append(s, runIndex, index - runIndex); - runIndex = -1; - } - - switch (c) - { - case '\t': _output.Append("\\t"); break; - case '\r': _output.Append("\\r"); break; - case '\n': _output.Append("\\n"); break; - case '"': - case '\\': _output.Append('\\'); _output.Append(c); break; - default: - _output.Append("\\u"); - _output.Append(((int)c).ToString("X4", NumberFormatInfo.InvariantInfo)); - break; - } - } - - if (runIndex != -1) - { - _output.Append(s, runIndex, s.Length - runIndex); - } - - _output.Append('\"'); - } - } -} diff --git a/src/LogentriesNLog/fastJSON/SafeDictionary.cs b/src/LogentriesNLog/fastJSON/SafeDictionary.cs deleted file mode 100644 index da95076c4..000000000 --- a/src/LogentriesNLog/fastJSON/SafeDictionary.cs +++ /dev/null @@ -1,36 +0,0 @@ -//http://fastjson.codeplex.com/ -//http://fastjson.codeplex.com/license - -using System.Collections.Generic; - -namespace LogentriesNLog.fastJSON -{ - internal class SafeDictionary - { - private readonly object _Padlock = new object(); - private readonly Dictionary _Dictionary = new Dictionary(); - - - internal bool TryGetValue(TKey key, out TValue value) - { - return _Dictionary.TryGetValue(key, out value); - } - - internal TValue this[TKey key] - { - get - { - return _Dictionary[key]; - } - } - - internal void Add(TKey key, TValue value) - { - lock (_Padlock) - { - if (_Dictionary.ContainsKey(key) == false) - _Dictionary.Add(key, value); - } - } - } -} diff --git a/src/LogentriesNLog/fastJSON/license.txt b/src/LogentriesNLog/fastJSON/license.txt deleted file mode 100644 index ca52ae6e5..000000000 --- a/src/LogentriesNLog/fastJSON/license.txt +++ /dev/null @@ -1,87 +0,0 @@ -Copyright (C) 1989, 1991 Free Software Foundation, Inc. -59 Temple Place, Suite 330, Boston, MA 02111-1307 USA - -Everyone is permitted to copy and distribute verbatim copies -of this license document, but changing it is not allowed. - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Library General Public License instead.) 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 this service 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 make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. - -For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. 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. - -We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. - -Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. - -Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. - -The precise terms and conditions for copying, distribution and modification follow. - -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. - -1. You may copy and distribute 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 and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - -a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. - -b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. - -c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. - -In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: - -a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, - -b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, - -c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) - -The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. - -4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. - -6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. - -7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), 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 distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -9. The Free Software Foundation may publish revised and/or new versions of the 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 a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. - -10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, 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. - -12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE 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. \ No newline at end of file diff --git a/src/LogentriesNLog/packages.config b/src/LogentriesNLog/packages.config deleted file mode 100644 index be7a78cb3..000000000 --- a/src/LogentriesNLog/packages.config +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/src/Marr.Data/EntityGraph.cs b/src/Marr.Data/EntityGraph.cs index 72d28dcdf..30d218d6f 100644 --- a/src/Marr.Data/EntityGraph.cs +++ b/src/Marr.Data/EntityGraph.cs @@ -165,7 +165,7 @@ namespace Marr.Data /// public void AddLazyRelationship(Relationship childRelationship) { - _children.Add(new EntityGraph(childRelationship.RelationshipInfo.EntityType.GetGenericArguments()[0], this, childRelationship)); + _children.Add(new EntityGraph(childRelationship.RelationshipInfo.EntityType, this, childRelationship)); } /// @@ -297,16 +297,30 @@ namespace Marr.Data private void InitOneToManyChildLists(EntityReference entityRef) { // Get a reference to the parent's the childrens' OwningLists to the parent entity - for (int i = 0; i < Relationships.Count; i++) + foreach (var child in _children) { - Relationship relationship = Relationships[i]; + Relationship relationship = child._relationship; if (relationship.RelationshipInfo.RelationType == RelationshipTypes.Many) { try { - IList list = (IList)_repos.ReflectionStrategy.CreateInstance(relationship.MemberType); - relationship.Setter(entityRef.Entity, list); - + var memberType = relationship.MemberType; + + object memberInstance; + object childList; + if (typeof(ILazyLoaded).IsAssignableFrom(memberType)) + { + childList = _repos.ReflectionStrategy.CreateInstance(memberType.GetGenericArguments()[0]); + memberInstance = Activator.CreateInstance(relationship.MemberType, childList); + } + else + { + childList = _repos.ReflectionStrategy.CreateInstance(memberType); + memberInstance = childList; + } + IList list = (IList) childList; + + relationship.Setter(entityRef.Entity, memberInstance); // Save a reference to each 1-M list entityRef.AddChildList(relationship.Member.Name, list); } diff --git a/src/Marr.Data/LazyLoaded.cs b/src/Marr.Data/LazyLoaded.cs index 10d9c13d1..ae884a6ff 100644 --- a/src/Marr.Data/LazyLoaded.cs +++ b/src/Marr.Data/LazyLoaded.cs @@ -36,6 +36,11 @@ namespace Marr.Data } } + public bool ShouldSerializeValue() + { + return IsLoaded; + } + public bool IsLoaded { get; protected set; } public virtual void Prepare(Func dataMapperFactory, object parent) diff --git a/src/Marr.Data/Mapping/MappingHelper.cs b/src/Marr.Data/Mapping/MappingHelper.cs index c1fbd42b4..80e2acb47 100644 --- a/src/Marr.Data/Mapping/MappingHelper.cs +++ b/src/Marr.Data/Mapping/MappingHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Data.Common; @@ -91,9 +91,11 @@ namespace Marr.Data.Mapping Type entType = ent.GetType(); if (_repos.Relationships.ContainsKey(entType)) { + var provider = _db.ProviderFactory; + var connectionString = _db.ConnectionString; Func dbCreate = () => { - var db = new DataMapper(_db.ProviderFactory, _db.ConnectionString); + var db = new DataMapper(provider, connectionString); db.SqlMode = SqlModes.Text; return db; }; diff --git a/src/Marr.Data/Mapping/RelationshipBuilder.cs b/src/Marr.Data/Mapping/RelationshipBuilder.cs index b4926633f..7c9607ca2 100644 --- a/src/Marr.Data/Mapping/RelationshipBuilder.cs +++ b/src/Marr.Data/Mapping/RelationshipBuilder.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Linq; using System.Linq.Expressions; using Marr.Data.Mapping.Strategies; @@ -65,8 +66,21 @@ namespace Marr.Data.Mapping public RelationshipBuilder LazyLoad(Func query, Func condition = null) { AssertCurrentPropertyIsSet(); + var relationship = Relationships[_currentPropertyName]; - Relationships[_currentPropertyName].LazyLoaded = new LazyLoaded(query, condition); + relationship.LazyLoaded = new LazyLoaded(query, condition); + + // work out if it's one to many or not + if (typeof(ICollection).IsAssignableFrom(typeof(TChild))) + { + relationship.RelationshipInfo.RelationType = RelationshipTypes.Many; + relationship.RelationshipInfo.EntityType = typeof(TChild).GetGenericArguments()[0]; + } + else + { + relationship.RelationshipInfo.EntityType = typeof(TChild); + } + return this; } diff --git a/src/Marr.Data/Marr.Data.csproj b/src/Marr.Data/Marr.Data.csproj index 449ee5e38..e57aa9941 100644 --- a/src/Marr.Data/Marr.Data.csproj +++ b/src/Marr.Data/Marr.Data.csproj @@ -1,154 +1,10 @@ - - + - Debug - x86 - 9.0.30729 - 2.0 - {F6FC6BE7-0847-4817-A1ED-223DC647C3D7} - Library - Properties - Marr.Data - Marr.Data - v4.0 - 512 - - - 3.5 - - - - ..\ - true - - - true - ..\..\_output\ - DEBUG;TRACE - full - x86 - prompt - MinimumRecommendedRules.ruleset - 4 - false - - - ..\..\_output\ - TRACE - true - pdbonly - x86 - prompt - MinimumRecommendedRules.ruleset - 4 - - - - - 4.0 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + net462 + x86 + + 3.17.0.0 + false + false + + diff --git a/src/Marr.Data/Properties/AssemblyInfo.cs b/src/Marr.Data/Properties/AssemblyInfo.cs deleted file mode 100644 index 8b5025d1a..000000000 --- a/src/Marr.Data/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("Marr.Data")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("Marr.Data")] -[assembly: AssemblyCopyright("Copyright © 2011")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Share internals -[assembly: InternalsVisibleTo("Marr.Data.Relationships")] -[assembly: InternalsVisibleTo("Marr.Data.Tests")] - - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("6864f4d2-cd0f-4720-9c15-3085f1aa8293")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Build and Revision Numbers -// by using the '*' as shown below: -// [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("3.17.*")] -[assembly: AssemblyInformationalVersion("3.17")] diff --git a/src/Marr.Data/QGen/QueryBuilder.cs b/src/Marr.Data/QGen/QueryBuilder.cs index ba135ac07..d5e798e4f 100644 --- a/src/Marr.Data/QGen/QueryBuilder.cs +++ b/src/Marr.Data/QGen/QueryBuilder.cs @@ -568,6 +568,23 @@ namespace Marr.Data.QGen return Join(joinType, rightMember, filterExpression); } + public virtual QueryBuilder Join(JoinType joinType, Expression>>> rightEntity, Expression> filterExpression) + { + _isJoin = true; + MemberInfo rightMember = (rightEntity.Body as MemberExpression).Member; + + foreach (var item in EntGraph) + { + if (item.EntityType == typeof(TLeft)) + { + var relationship = item.Relationships.Single(v => v.Member == rightMember); + item.AddLazyRelationship(relationship); + } + } + + return Join(joinType, rightMember, filterExpression); + } + public virtual QueryBuilder Join(JoinType joinType, MemberInfo rightMember, Expression> filterExpression) { _isJoin = true; diff --git a/src/Microsoft.AspNet.SignalR.Core/AuthorizeAttribute.cs b/src/Microsoft.AspNet.SignalR.Core/AuthorizeAttribute.cs deleted file mode 100644 index 05caef0a0..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/AuthorizeAttribute.cs +++ /dev/null @@ -1,160 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Security.Principal; -using Microsoft.AspNet.SignalR.Hubs; - -namespace Microsoft.AspNet.SignalR -{ - /// - /// Apply to Hubs and Hub methods to authorize client connections to Hubs and authorize client invocations of Hub methods. - /// - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true, AllowMultiple = true)] - [SuppressMessage("Microsoft.Performance", "CA1813:AvoidUnsealedAttributes", Justification = "MVC and WebAPI don't seal their AuthorizeAttributes")] - public class AuthorizeAttribute : Attribute, IAuthorizeHubConnection, IAuthorizeHubMethodInvocation - { - private string _roles; - private string[] _rolesSplit = new string[0]; - private string _users; - private string[] _usersSplit = new string[0]; - - [SuppressMessage("Microsoft.Design", "CA1051:DoNotDeclareVisibleInstanceFields", Justification = "Already somewhat represented by set-only RequiredOutgoing property.")] - protected bool? _requireOutgoing; - - /// - /// Set to false to apply authorization only to the invocations of any of the Hub's server-side methods. - /// This property only affects attributes applied to the Hub class. - /// This property cannot be read. - /// - [SuppressMessage("Microsoft.Design", "CA1065:DoNotRaiseExceptionsInUnexpectedLocations", Justification = "Must be property because this is an attribute parameter.")] - public bool RequireOutgoing - { - // It is impossible to tell here whether the attribute is being applied to a method or class. This makes - // it impossible to determine whether the value should be true or false when _requireOutgoing is null. - // It is also impossible to have a Nullable attribute parameter type. - get { throw new NotImplementedException(Resources.Error_DoNotReadRequireOutgoing); } - set { _requireOutgoing = value; } - } - - /// - /// Gets or sets the user roles. - /// - public string Roles - { - get { return _roles ?? String.Empty; } - set - { - _roles = value; - _rolesSplit = SplitString(value); - } - } - - /// - /// Gets or sets the authorized users. - /// - public string Users - { - get { return _users ?? String.Empty; } - set - { - _users = value; - _usersSplit = SplitString(value); - } - } - - /// - /// Determines whether client is authorized to connect to . - /// - /// Description of the hub client is attempting to connect to. - /// The (re)connect request from the client. - /// true if the caller is authorized to connect to the hub; otherwise, false. - public virtual bool AuthorizeHubConnection(HubDescriptor hubDescriptor, IRequest request) - { - if (request == null) - { - throw new ArgumentNullException("request"); - } - - // If RequireOutgoing is explicitly set to false, authorize all connections. - if (_requireOutgoing.HasValue && !_requireOutgoing.Value) - { - return true; - } - - return UserAuthorized(request.User); - } - - /// - /// Determines whether client is authorized to invoke the method. - /// - /// An providing details regarding the method invocation. - /// Indicates whether the interface instance is an attribute applied directly to a method. - /// true if the caller is authorized to invoke the method; otherwise, false. - public virtual bool AuthorizeHubMethodInvocation(IHubIncomingInvokerContext hubIncomingInvokerContext, bool appliesToMethod) - { - if (hubIncomingInvokerContext == null) - { - throw new ArgumentNullException("hubIncomingInvokerContext"); - } - - // It is impossible to require outgoing auth at the method level with SignalR's current design. - // Even though this isn't the stage at which outgoing auth would be applied, we want to throw a runtime error - // to indicate when the attribute is being used with obviously incorrect expectations. - - // We must explicitly check if _requireOutgoing is true since it is a Nullable type. - if (appliesToMethod && (_requireOutgoing == true)) - { - throw new ArgumentException(Resources.Error_MethodLevelOutgoingAuthorization); - } - - return UserAuthorized(hubIncomingInvokerContext.Hub.Context.User); - } - - /// - /// When overridden, provides an entry point for custom authorization checks. - /// Called by and . - /// - /// The for the client being authorize - /// true if the user is authorized, otherwise, false - protected virtual bool UserAuthorized(IPrincipal user) - { - if (user == null) - { - return false; - } - - if (!user.Identity.IsAuthenticated) - { - return false; - } - - if (_usersSplit.Length > 0 && !_usersSplit.Contains(user.Identity.Name, StringComparer.OrdinalIgnoreCase)) - { - return false; - } - - if (_rolesSplit.Length > 0 && !_rolesSplit.Any(user.IsInRole)) - { - return false; - } - - return true; - } - - private static string[] SplitString(string original) - { - if (String.IsNullOrEmpty(original)) - { - return new string[0]; - } - - var split = from piece in original.Split(',') - let trimmed = piece.Trim() - where !String.IsNullOrEmpty(trimmed) - select trimmed; - return split.ToArray(); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Configuration/ConfigurationExtensions.cs b/src/Microsoft.AspNet.SignalR.Core/Configuration/ConfigurationExtensions.cs deleted file mode 100644 index 630fad897..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Configuration/ConfigurationExtensions.cs +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; - -namespace Microsoft.AspNet.SignalR.Configuration -{ - internal static class ConfigurationExtensions - { - public const int MissedTimeoutsBeforeClientReconnect = 2; - public const int HeartBeatsPerKeepAlive = 2; - public const int HeartBeatsPerDisconnectTimeout = 6; - - /// - /// The amount of time the client should wait without seeing a keep alive before trying to reconnect. - /// - public static TimeSpan? KeepAliveTimeout(this IConfigurationManager config) - { - if (config.KeepAlive != null) - { - return TimeSpan.FromTicks(config.KeepAlive.Value.Ticks * MissedTimeoutsBeforeClientReconnect); - } - else - { - return null; - } - } - - /// - /// The interval between successively checking connection states. - /// - public static TimeSpan HeartbeatInterval(this IConfigurationManager config) - { - if (config.KeepAlive != null) - { - return TimeSpan.FromTicks(config.KeepAlive.Value.Ticks / HeartBeatsPerKeepAlive); - } - else - { - // If KeepAlives are disabled, have the heartbeat run at the same rate it would if the KeepAlive was - // kept at the default value. - return TimeSpan.FromTicks(config.DisconnectTimeout.Ticks / HeartBeatsPerDisconnectTimeout); - } - } - - /// - /// The amount of time a Topic should stay in memory after its last subscriber is removed. - /// - /// - /// - public static TimeSpan TopicTtl(this IConfigurationManager config) - { - // If the deep-alive is disabled, don't take it into account when calculating the topic TTL. - var keepAliveTimeout = config.KeepAliveTimeout() ?? TimeSpan.Zero; - - // Keep topics alive for twice as long as we let connections to reconnect. (The DisconnectTimeout) - // Also add twice the keep-alive timeout since clients might take a while to notice they are disconnected. - // This should be a very conservative estimate for how long we must wait before considering a topic dead. - return TimeSpan.FromTicks((config.DisconnectTimeout.Ticks + keepAliveTimeout.Ticks) * 2); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Configuration/DefaultConfigurationManager.cs b/src/Microsoft.AspNet.SignalR.Core/Configuration/DefaultConfigurationManager.cs deleted file mode 100644 index af49f1978..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Configuration/DefaultConfigurationManager.cs +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; - -namespace Microsoft.AspNet.SignalR.Configuration -{ - public class DefaultConfigurationManager : IConfigurationManager - { - // The below effectively sets the minimum heartbeat to once per second. - // if _minimumKeepAlive != 2 seconds, update the ArguementOutOfRanceExceptionMessage below - private static readonly TimeSpan _minimumKeepAlive = TimeSpan.FromSeconds(2); - - // if _minimumKeepAlivesPerDisconnectTimeout != 3, update the ArguementOutOfRanceExceptionMessage below - private const int _minimumKeepAlivesPerDisconnectTimeout = 3; - - // if _minimumDisconnectTimeout != 6 seconds, update the ArguementOutOfRanceExceptionMessage below - private static readonly TimeSpan _minimumDisconnectTimeout = TimeSpan.FromTicks(_minimumKeepAlive.Ticks * _minimumKeepAlivesPerDisconnectTimeout); - - private bool _keepAliveConfigured; - private TimeSpan? _keepAlive; - private TimeSpan _disconnectTimeout; - - public DefaultConfigurationManager() - { - ConnectionTimeout = TimeSpan.FromSeconds(110); - DisconnectTimeout = TimeSpan.FromSeconds(30); - DefaultMessageBufferSize = 1000; - } - - // TODO: Should we guard against negative TimeSpans here like everywhere else? - public TimeSpan ConnectionTimeout - { - get; - set; - } - - public TimeSpan DisconnectTimeout - { - get - { - return _disconnectTimeout; - } - set - { - if (value < _minimumDisconnectTimeout) - { - throw new ArgumentOutOfRangeException("value", Resources.Error_DisconnectTimeoutMustBeAtLeastSixSeconds); - } - - if (_keepAliveConfigured) - { - throw new InvalidOperationException(Resources.Error_DisconnectTimeoutCannotBeConfiguredAfterKeepAlive); - } - - _disconnectTimeout = value; - _keepAlive = TimeSpan.FromTicks(_disconnectTimeout.Ticks / _minimumKeepAlivesPerDisconnectTimeout); - } - } - - public TimeSpan? KeepAlive - { - get - { - return _keepAlive; - } - set - { - if (value < _minimumKeepAlive) - { - throw new ArgumentOutOfRangeException("value", Resources.Error_KeepAliveMustBeGreaterThanTwoSeconds); - } - - if (value > TimeSpan.FromTicks(_disconnectTimeout.Ticks / _minimumKeepAlivesPerDisconnectTimeout)) - { - throw new ArgumentOutOfRangeException("value", Resources.Error_KeepAliveMustBeNoMoreThanAThirdOfTheDisconnectTimeout); - } - - _keepAlive = value; - _keepAliveConfigured = true; - } - } - - public int DefaultMessageBufferSize - { - get; - set; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Configuration/IConfigurationManager.cs b/src/Microsoft.AspNet.SignalR.Core/Configuration/IConfigurationManager.cs deleted file mode 100644 index 9dd79a241..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Configuration/IConfigurationManager.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; - -namespace Microsoft.AspNet.SignalR.Configuration -{ - /// - /// Provides access to server configuration. - /// - public interface IConfigurationManager - { - /// - /// Gets or sets a representing the amount of time to leave a connection open before timing out. - /// - TimeSpan ConnectionTimeout { get; set; } - - /// - /// Gets or sets a representing the amount of time to wait after a connection goes away before raising the disconnect event. - /// - TimeSpan DisconnectTimeout { get; set; } - - /// - /// Gets or sets a representing the amount of time between send keep alive messages. - /// If enabled, this value must be at least two seconds. Set to null to disable. - /// - TimeSpan? KeepAlive { get; set; } - - /// - /// Gets of sets the number of messages to buffer for a specific signal. - /// - int DefaultMessageBufferSize { get; set; } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/ConnectionConfiguration.cs b/src/Microsoft.AspNet.SignalR.Core/ConnectionConfiguration.cs deleted file mode 100644 index eef56241e..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/ConnectionConfiguration.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -namespace Microsoft.AspNet.SignalR -{ - public class ConnectionConfiguration - { - // Resolver isn't set to GlobalHost.DependencyResolver in the ctor because it is lazily created. - private IDependencyResolver _resolver; - - /// - /// The dependency resolver to use for the hub connection. - /// - public IDependencyResolver Resolver - { - get { return _resolver ?? GlobalHost.DependencyResolver; } - set { _resolver = value; } - } - - /// - /// Determines if browsers can make cross domain requests to SignalR endpoints. - /// - public bool EnableCrossDomain { get; set; } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/ConnectionExtensions.cs b/src/Microsoft.AspNet.SignalR.Core/ConnectionExtensions.cs deleted file mode 100644 index 07cc587e1..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/ConnectionExtensions.cs +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Infrastructure; - -namespace Microsoft.AspNet.SignalR -{ - public static class ConnectionExtensions - { - /// - /// Sends a message to all connections subscribed to the specified signal. An example of signal may be a - /// specific connection id. - /// - /// The connection - /// The connectionId to send to. - /// The value to publish. - /// The list of connection ids to exclude - /// A task that represents when the broadcast is complete. - public static Task Send(this IConnection connection, string connectionId, object value, params string[] excludeConnectionIds) - { - if (connection == null) - { - throw new ArgumentNullException("connection"); - } - - if (string.IsNullOrEmpty(connectionId)) - { - throw new ArgumentException(Resources.Error_ArgumentNullOrEmpty, "connectionId"); - } - - var message = new ConnectionMessage(PrefixHelper.GetConnectionId(connectionId), - value, - PrefixHelper.GetPrefixedConnectionIds(excludeConnectionIds)); - - return connection.Send(message); - } - - /// - /// Broadcasts a value to all connections, excluding the connection ids specified. - /// - /// The connection - /// The value to broadcast. - /// The list of connection ids to exclude - /// A task that represents when the broadcast is complete. - public static Task Broadcast(this IConnection connection, object value, params string[] excludeConnectionIds) - { - if (connection == null) - { - throw new ArgumentNullException("connection"); - } - - var message = new ConnectionMessage(connection.DefaultSignal, - value, - PrefixHelper.GetPrefixedConnectionIds(excludeConnectionIds)); - - return connection.Send(message); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/ConnectionMessage.cs b/src/Microsoft.AspNet.SignalR.Core/ConnectionMessage.cs deleted file mode 100644 index 6c632c9f6..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/ConnectionMessage.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using Microsoft.AspNet.SignalR.Infrastructure; - -namespace Microsoft.AspNet.SignalR -{ - /// - /// A message sent to one more connections. - /// - [SuppressMessage("Microsoft.Performance", "CA1815:OverrideEqualsAndOperatorEqualsOnValueTypes", Justification = "Messags are never compared, just used as data.")] - public struct ConnectionMessage - { - /// - /// The signal to this message should be sent to. Connections subscribed to this signal - /// will receive the message payload. - /// - public string Signal { get; private set; } - - /// - /// The payload of the message. - /// - public object Value { get; private set; } - - /// - /// Represents a list of signals that should be used to filter what connections - /// receive this message. - /// - public IList ExcludedSignals { get; private set; } - - public ConnectionMessage(string signal, object value) - : this(signal, value, ListHelper.Empty) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The signal - /// The payload of the message - /// The signals to exclude. - public ConnectionMessage(string signal, object value, IList excludedSignals) - : this() - { - Signal = signal; - Value = value; - ExcludedSignals = excludedSignals; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Cookie.cs b/src/Microsoft.AspNet.SignalR.Core/Cookie.cs deleted file mode 100644 index b251da93d..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Cookie.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; - -namespace Microsoft.AspNet.SignalR -{ - public class Cookie - { - public Cookie(string name, string value) - : this(name, value, String.Empty, String.Empty) - { - - } - - public Cookie(string name, string value, string domain, string path) - { - Name = name; - Value = value; - Domain = domain; - Path = path; - } - - public string Name { get; private set; } - public string Domain { get; private set; } - public string Path { get; private set; } - public string Value { get; private set; } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/DefaultDependencyResolver.cs b/src/Microsoft.AspNet.SignalR.Core/DefaultDependencyResolver.cs deleted file mode 100644 index da8cb14fc..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/DefaultDependencyResolver.cs +++ /dev/null @@ -1,231 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Linq; -using System.Threading; -using Microsoft.AspNet.SignalR.Configuration; -using Microsoft.AspNet.SignalR.Hubs; -using Microsoft.AspNet.SignalR.Infrastructure; -using Microsoft.AspNet.SignalR.Json; -using Microsoft.AspNet.SignalR.Messaging; -using Microsoft.AspNet.SignalR.Tracing; -using Microsoft.AspNet.SignalR.Transports; - -namespace Microsoft.AspNet.SignalR -{ - public class DefaultDependencyResolver : IDependencyResolver - { - private readonly Dictionary>> _resolvers = new Dictionary>>(); - private readonly HashSet _trackedDisposables = new HashSet(); - private int _disposed; - - [SuppressMessage("Microsoft.Usage", "CA2214:DoNotCallOverridableMethodsInConstructors", Justification = "It's easiest")] - public DefaultDependencyResolver() - { - RegisterDefaultServices(); - - // Hubs - RegisterHubExtensions(); - } - - [SuppressMessage("Microsoft.Maintainability", "CA1506:AvoidExcessiveClassCoupling", Justification = "The resolver is the class that does the most coupling by design.")] - [SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Justification = "The resolver disposes dependencies on Dispose.")] - private void RegisterDefaultServices() - { - var traceManager = new Lazy(() => new TraceManager()); - Register(typeof(ITraceManager), () => traceManager.Value); - - var serverIdManager = new ServerIdManager(); - Register(typeof(IServerIdManager), () => serverIdManager); - - var serverMessageHandler = new Lazy(() => new ServerCommandHandler(this)); - Register(typeof(IServerCommandHandler), () => serverMessageHandler.Value); - - var newMessageBus = new Lazy(() => new MessageBus(this)); - Register(typeof(IMessageBus), () => newMessageBus.Value); - - var stringMinifier = new Lazy(() => new StringMinifier()); - Register(typeof(IStringMinifier), () => stringMinifier.Value); - - var serializer = new Lazy(); - Register(typeof(IJsonSerializer), () => serializer.Value); - - var transportManager = new Lazy(() => new TransportManager(this)); - Register(typeof(ITransportManager), () => transportManager.Value); - - var configurationManager = new DefaultConfigurationManager(); - Register(typeof(IConfigurationManager), () => configurationManager); - - var transportHeartbeat = new Lazy(() => new TransportHeartbeat(this)); - Register(typeof(ITransportHeartbeat), () => transportHeartbeat.Value); - - var connectionManager = new Lazy(() => new ConnectionManager(this)); - Register(typeof(IConnectionManager), () => connectionManager.Value); - - var ackHandler = new Lazy(); - Register(typeof(IAckHandler), () => ackHandler.Value); - - var perfCounterWriter = new Lazy(() => new PerformanceCounterManager(this)); - Register(typeof(IPerformanceCounterManager), () => perfCounterWriter.Value); - - var protectedData = new DefaultProtectedData(); - Register(typeof(IProtectedData), () => protectedData); - } - - private void RegisterHubExtensions() - { - var methodDescriptorProvider = new Lazy(); - Register(typeof(IMethodDescriptorProvider), () => methodDescriptorProvider.Value); - - var hubDescriptorProvider = new Lazy(() => new ReflectedHubDescriptorProvider(this)); - Register(typeof(IHubDescriptorProvider), () => hubDescriptorProvider.Value); - - var parameterBinder = new Lazy(); - Register(typeof(IParameterResolver), () => parameterBinder.Value); - - var activator = new Lazy(() => new DefaultHubActivator(this)); - Register(typeof(IHubActivator), () => activator.Value); - - var hubManager = new Lazy(() => new DefaultHubManager(this)); - Register(typeof(IHubManager), () => hubManager.Value); - - var proxyGenerator = new Lazy(() => new DefaultJavaScriptProxyGenerator(this)); - Register(typeof(IJavaScriptProxyGenerator), () => proxyGenerator.Value); - - var requestParser = new Lazy(); - Register(typeof(IHubRequestParser), () => requestParser.Value); - - var assemblyLocator = new Lazy(() => new DefaultAssemblyLocator()); - Register(typeof(IAssemblyLocator), () => assemblyLocator.Value); - - // Setup the default hub pipeline - var dispatcher = new Lazy(() => new HubPipeline().AddModule(new AuthorizeModule())); - Register(typeof(IHubPipeline), () => dispatcher.Value); - Register(typeof(IHubPipelineInvoker), () => dispatcher.Value); - } - - public virtual object GetService(Type serviceType) - { - if (serviceType == null) - { - throw new ArgumentNullException("serviceType"); - } - - IList> activators; - if (_resolvers.TryGetValue(serviceType, out activators)) - { - if (activators.Count == 0) - { - return null; - } - if (activators.Count > 1) - { - throw new InvalidOperationException(String.Format(CultureInfo.CurrentCulture, Resources.Error_MultipleActivatorsAreaRegisteredCallGetServices, serviceType.FullName)); - } - return Track(activators[0]); - } - return null; - } - - public virtual IEnumerable GetServices(Type serviceType) - { - IList> activators; - if (_resolvers.TryGetValue(serviceType, out activators)) - { - if (activators.Count == 0) - { - return null; - } - return activators.Select(Track).ToList(); - } - return null; - } - - public virtual void Register(Type serviceType, Func activator) - { - IList> activators; - if (!_resolvers.TryGetValue(serviceType, out activators)) - { - activators = new List>(); - _resolvers.Add(serviceType, activators); - } - else - { - activators.Clear(); - } - activators.Add(activator); - } - - public virtual void Register(Type serviceType, IEnumerable> activators) - { - if (activators == null) - { - throw new ArgumentNullException("activators"); - } - - IList> list; - if (!_resolvers.TryGetValue(serviceType, out list)) - { - list = new List>(); - _resolvers.Add(serviceType, list); - } - else - { - list.Clear(); - } - foreach (var a in activators) - { - list.Add(a); - } - } - - private object Track(Func creator) - { - object obj = creator(); - - if (_disposed == 0) - { - var disposable = obj as IDisposable; - if (disposable != null) - { - lock (_trackedDisposables) - { - if (_disposed == 0) - { - _trackedDisposables.Add(disposable); - } - } - } - } - - return obj; - } - - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - if (Interlocked.Exchange(ref _disposed, 1) == 0) - { - lock (_trackedDisposables) - { - foreach (var d in _trackedDisposables) - { - d.Dispose(); - } - - _trackedDisposables.Clear(); - } - } - } - } - - public void Dispose() - { - Dispose(true); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/DependencyResolverExtensions.cs b/src/Microsoft.AspNet.SignalR.Core/DependencyResolverExtensions.cs deleted file mode 100644 index d579634b1..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/DependencyResolverExtensions.cs +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Linq; - -namespace Microsoft.AspNet.SignalR -{ - public static class DependencyResolverExtensions - { - public static T Resolve(this IDependencyResolver resolver) - { - if (resolver == null) - { - throw new ArgumentNullException("resolver"); - } - - return (T)resolver.GetService(typeof(T)); - } - - public static object Resolve(this IDependencyResolver resolver, Type type) - { - if (resolver == null) - { - throw new ArgumentNullException("resolver"); - } - - if (type == null) - { - throw new ArgumentNullException("type"); - } - - return resolver.GetService(type); - } - - public static IEnumerable ResolveAll(this IDependencyResolver resolver) - { - if (resolver == null) - { - throw new ArgumentNullException("resolver"); - } - - return resolver.GetServices(typeof(T)).Cast(); - } - - public static IEnumerable ResolveAll(this IDependencyResolver resolver, Type type) - { - if (resolver == null) - { - throw new ArgumentNullException("resolver"); - } - - if (type == null) - { - throw new ArgumentNullException("type"); - } - - return resolver.GetServices(type); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/GlobalHost.cs b/src/Microsoft.AspNet.SignalR.Core/GlobalHost.cs deleted file mode 100644 index 7571495fc..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/GlobalHost.cs +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using Microsoft.AspNet.SignalR.Configuration; -using Microsoft.AspNet.SignalR.Hubs; -using Microsoft.AspNet.SignalR.Infrastructure; - -namespace Microsoft.AspNet.SignalR -{ - /// - /// Provides access to default host information. - /// - public static class GlobalHost - { - private static readonly Lazy _defaultResolver = new Lazy(() => new DefaultDependencyResolver()); - private static IDependencyResolver _resolver; - - /// - /// Gets or sets the the default - /// - public static IDependencyResolver DependencyResolver - { - get - { - return _resolver ?? _defaultResolver.Value; - } - set - { - _resolver = value; - } - } - - /// - /// Gets the default - /// - public static IConfigurationManager Configuration - { - get - { - return DependencyResolver.Resolve(); - } - } - - /// - /// Gets the default - /// - public static IConnectionManager ConnectionManager - { - get - { - return DependencyResolver.Resolve(); - } - } - - /// - /// - /// - public static IHubPipeline HubPipeline - { - get - { - return DependencyResolver.Resolve(); - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/GroupManager.cs b/src/Microsoft.AspNet.SignalR.Core/GroupManager.cs deleted file mode 100644 index c57476f70..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/GroupManager.cs +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Infrastructure; -using Microsoft.AspNet.SignalR.Messaging; - -namespace Microsoft.AspNet.SignalR -{ - /// - /// The default implementation. - /// - public class GroupManager : IConnectionGroupManager - { - private readonly IConnection _connection; - private readonly string _groupPrefix; - - /// - /// Initializes a new instance of the class. - /// - /// The this group resides on. - /// The prefix for this group. Either a name or type name. - public GroupManager(IConnection connection, string groupPrefix) - { - if (connection == null) - { - throw new ArgumentNullException("connection"); - } - - _connection = connection; - _groupPrefix = groupPrefix; - } - - /// - /// Sends a value to the specified group. - /// - /// The name of the group. - /// The value to send. - /// The list of connection ids to exclude - /// A task that represents when send is complete. - public Task Send(string groupName, object value, params string[] excludeConnectionIds) - { - if (string.IsNullOrEmpty(groupName)) - { - throw new ArgumentException((Resources.Error_ArgumentNullOrEmpty), "groupName"); - } - - var qualifiedName = CreateQualifiedName(groupName); - var message = new ConnectionMessage(qualifiedName, - value, - PrefixHelper.GetPrefixedConnectionIds(excludeConnectionIds)); - - return _connection.Send(message); - } - - /// - /// Adds a connection to the specified group. - /// - /// The connection id to add to the group. - /// The name of the group - /// A task that represents the connection id being added to the group. - public Task Add(string connectionId, string groupName) - { - if (connectionId == null) - { - throw new ArgumentNullException("connectionId"); - } - - if (groupName == null) - { - throw new ArgumentNullException("groupName"); - } - - var command = new Command - { - CommandType = CommandType.AddToGroup, - Value = CreateQualifiedName(groupName), - WaitForAck = true - }; - - return _connection.Send(connectionId, command); - } - - /// - /// Removes a connection from the specified group. - /// - /// The connection id to remove from the group. - /// The name of the group - /// A task that represents the connection id being removed from the group. - public Task Remove(string connectionId, string groupName) - { - if (connectionId == null) - { - throw new ArgumentNullException("connectionId"); - } - - if (groupName == null) - { - throw new ArgumentNullException("groupName"); - } - - var command = new Command - { - CommandType = CommandType.RemoveFromGroup, - Value = CreateQualifiedName(groupName), - WaitForAck = true - }; - - return _connection.Send(connectionId, command); - } - - private string CreateQualifiedName(string groupName) - { - return _groupPrefix + "." + groupName; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hosting/HostConstants.cs b/src/Microsoft.AspNet.SignalR.Core/Hosting/HostConstants.cs deleted file mode 100644 index 9cb8b132a..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hosting/HostConstants.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -namespace Microsoft.AspNet.SignalR.Hosting -{ - public static class HostConstants - { - /// - /// The host should set this if they need to enable debug mode - /// - public static readonly string DebugMode = "debugMode"; - - /// - /// The host should set this is web sockets can be supported - /// - public static readonly string SupportsWebSockets = "supportsWebSockets"; - - /// - /// The host should set this if the web socket url is different - /// - public static readonly string WebSocketServerUrl = "webSocketServerUrl"; - - public static readonly string ShutdownToken = "shutdownToken"; - - public static readonly string InstanceName = "instanceName"; - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hosting/HostContext.cs b/src/Microsoft.AspNet.SignalR.Core/Hosting/HostContext.cs deleted file mode 100644 index cd5324f90..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hosting/HostContext.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; - -namespace Microsoft.AspNet.SignalR.Hosting -{ - public class HostContext - { - public IRequest Request { get; private set; } - public IResponse Response { get; private set; } - public IDictionary Items { get; private set; } - - public HostContext(IRequest request, IResponse response) - { - Request = request; - Response = response; - Items = new Dictionary(StringComparer.OrdinalIgnoreCase); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hosting/HostContextExtensions.cs b/src/Microsoft.AspNet.SignalR.Core/Hosting/HostContextExtensions.cs deleted file mode 100644 index d40d9e5ac..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hosting/HostContextExtensions.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Threading; - -namespace Microsoft.AspNet.SignalR.Hosting -{ - public static class HostContextExtensions - { - public static T GetValue(this HostContext context, string key) - { - if (context == null) - { - throw new ArgumentNullException("context"); - } - - object value; - if (context.Items.TryGetValue(key, out value)) - { - return (T)value; - } - return default(T); - } - - public static bool IsDebuggingEnabled(this HostContext context) - { - return context.GetValue(HostConstants.DebugMode); - } - - public static bool SupportsWebSockets(this HostContext context) - { - // The server needs to implement IWebSocketRequest for websockets to be supported. - // It also needs to set the flag in the items collection. - return context.GetValue(HostConstants.SupportsWebSockets) && - context.Request is IWebSocketRequest; - } - - public static string WebSocketServerUrl(this HostContext context) - { - return context.GetValue(HostConstants.WebSocketServerUrl); - } - - public static CancellationToken HostShutdownToken(this HostContext context) - { - return context.GetValue(HostConstants.ShutdownToken); - } - - public static string InstanceName(this HostContext context) - { - return context.GetValue(HostConstants.InstanceName); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hosting/HostDependencyResolverExtensions.cs b/src/Microsoft.AspNet.SignalR.Core/Hosting/HostDependencyResolverExtensions.cs deleted file mode 100644 index c674b317b..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hosting/HostDependencyResolverExtensions.cs +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Threading; -using Microsoft.AspNet.SignalR.Infrastructure; - -namespace Microsoft.AspNet.SignalR.Hosting -{ - public static class HostDependencyResolverExtensions - { - public static void InitializeHost(this IDependencyResolver resolver, string instanceName, CancellationToken hostShutdownToken) - { - if (resolver == null) - { - throw new ArgumentNullException("resolver"); - } - - if (String.IsNullOrEmpty(instanceName)) - { - throw new ArgumentNullException("instanceName"); - } - - // Performance counters are broken on mono so just skip this step - if (!MonoUtility.IsRunningMono) - { - // Initialize the performance counters - resolver.InitializePerformanceCounters(instanceName, hostShutdownToken); - } - - // Dispose the dependency resolver on host shut down (cleanly) - resolver.InitializeResolverDispose(hostShutdownToken); - } - - private static void InitializePerformanceCounters(this IDependencyResolver resolver, string instanceName, CancellationToken hostShutdownToken) - { - var counters = resolver.Resolve(); - if (counters != null) - { - counters.Initialize(instanceName, hostShutdownToken); - } - } - - private static void InitializeResolverDispose(this IDependencyResolver resolver, CancellationToken hostShutdownToken) - { - // TODO: Guard against multiple calls to this - - // When the host triggers the shutdown token, dispose the resolver - hostShutdownToken.SafeRegister(state => - { - ((IDependencyResolver)state).Dispose(); - }, - resolver); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hosting/IResponse.cs b/src/Microsoft.AspNet.SignalR.Core/Hosting/IResponse.cs deleted file mode 100644 index 3d26f0e61..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hosting/IResponse.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Diagnostics.CodeAnalysis; -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.AspNet.SignalR.Hosting -{ - /// - /// Represents a connection to the client. - /// - public interface IResponse - { - /// - /// Gets a cancellation token that represents the client's lifetime. - /// - CancellationToken CancellationToken { get; } - - /// - /// Gets or sets the status code of the response. - /// - int StatusCode { get; set; } - - /// - /// Gets or sets the content type of the response. - /// - string ContentType { get; set; } - - /// - /// Writes buffered data. - /// - /// The data to write to the buffer. - void Write(ArraySegment data); - - /// - /// Flushes the buffered response to the client. - /// - /// A task that represents when the data has been flushed. - Task Flush(); - - /// - /// Closes the connection to the client. - /// - /// A task that represents when the connection is closed. - [SuppressMessage("Microsoft.Naming", "CA1716:IdentifiersShouldNotMatchKeywords", MessageId = "End", Justification = "Ends the response thus the name is appropriate.")] - Task End(); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hosting/IWebSocket.cs b/src/Microsoft.AspNet.SignalR.Core/Hosting/IWebSocket.cs deleted file mode 100644 index 60c448f2a..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hosting/IWebSocket.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Threading.Tasks; - -namespace Microsoft.AspNet.SignalR.Hosting -{ - /// - /// Represents a web socket. - /// - public interface IWebSocket - { - /// - /// Invoked when data is sent over the websocket - /// - Action OnMessage { get; set; } - - /// - /// Invoked when the websocket closes - /// - Action OnClose { get; set; } - - /// - /// Invoked when there is an error - /// - Action OnError { get; set; } - - /// - /// Sends data over the websocket. - /// - /// The value to send. - /// A that represents the send is complete. - Task Send(string value); - - /// - /// Sends a chunk of data over the websocket ("endOfMessage" flag set to false.) - /// - /// - /// A that represents the send is complete. - Task SendChunk(ArraySegment message); - - /// - /// Sends a zero byte data chunk with the "endOfMessage" flag set to true. - /// - /// A that represents the flush is complete. - Task Flush(); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hosting/IWebSocketRequest.cs b/src/Microsoft.AspNet.SignalR.Core/Hosting/IWebSocketRequest.cs deleted file mode 100644 index 95f5438f2..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hosting/IWebSocketRequest.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Threading.Tasks; - -namespace Microsoft.AspNet.SignalR.Hosting -{ - public interface IWebSocketRequest : IRequest - { - /// - /// Accepts an websocket request using the specified user function. - /// - /// The callback that fires when the websocket is ready. - /// The task that completes when the websocket transport is ready. - Task AcceptWebSocketRequest(Func callback, Task initTask); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hosting/PersistentConnectionFactory.cs b/src/Microsoft.AspNet.SignalR.Core/Hosting/PersistentConnectionFactory.cs deleted file mode 100644 index 33d9aeee1..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hosting/PersistentConnectionFactory.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Globalization; - -namespace Microsoft.AspNet.SignalR.Hosting -{ - /// - /// Responsible for creating instances. - /// - public class PersistentConnectionFactory - { - private readonly IDependencyResolver _resolver; - - /// - /// Creates a new instance of the class. - /// - /// The dependency resolver to use for when creating the . - public PersistentConnectionFactory(IDependencyResolver resolver) - { - if (resolver == null) - { - throw new ArgumentNullException("resolver"); - } - - _resolver = resolver; - } - - /// - /// Creates an instance of the specified type using the dependency resolver or the type's default constructor. - /// - /// The type of to create. - /// An instance of a . - public PersistentConnection CreateInstance(Type connectionType) - { - if (connectionType == null) - { - throw new ArgumentNullException("connectionType"); - } - - var connection = (_resolver.Resolve(connectionType) ?? - Activator.CreateInstance(connectionType)) as PersistentConnection; - - if (connection == null) - { - throw new InvalidOperationException(String.Format(CultureInfo.CurrentCulture, Resources.Error_IsNotA, connectionType.FullName, typeof(PersistentConnection).FullName)); - } - - return connection; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hosting/RequestExtensions.cs b/src/Microsoft.AspNet.SignalR.Core/Hosting/RequestExtensions.cs deleted file mode 100644 index 52cba0096..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hosting/RequestExtensions.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; - -namespace Microsoft.AspNet.SignalR.Hosting -{ - internal static class RequestExtensions - { - /// - /// Gets a value from the QueryString, and if it's null or empty, gets it from the Form instead. - /// - public static string QueryStringOrForm(this IRequest request, string key) - { - var value = request.QueryString[key]; - if (String.IsNullOrEmpty(value)) - { - value = request.Form[key]; - } - return value; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hosting/ResponseExtensions.cs b/src/Microsoft.AspNet.SignalR.Core/Hosting/ResponseExtensions.cs deleted file mode 100644 index a8f79a922..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hosting/ResponseExtensions.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Text; -using System.Threading.Tasks; - -namespace Microsoft.AspNet.SignalR.Hosting -{ - /// - /// Extension methods for . - /// - public static class ResponseExtensions - { - /// - /// Closes the connection to a client with optional data. - /// - /// The . - /// The data to write to the connection. - /// A task that represents when the connection is closed. - public static Task End(this IResponse response, string data) - { - if (response == null) - { - throw new ArgumentNullException("response"); - } - - var bytes = Encoding.UTF8.GetBytes(data); - response.Write(new ArraySegment(bytes, 0, bytes.Length)); - return response.End(); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hub.cs b/src/Microsoft.AspNet.SignalR.Core/Hub.cs deleted file mode 100644 index ca86f39e9..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hub.cs +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Hubs; - -namespace Microsoft.AspNet.SignalR -{ - /// - /// Provides methods that communicate with SignalR connections that connected to a . - /// - public abstract class Hub : IHub - { - protected Hub() - { - Clients = new HubConnectionContext(); - Clients.All = new NullClientProxy(); - Clients.Others = new NullClientProxy(); - Clients.Caller = new NullClientProxy(); - } - - /// - /// - /// - public HubConnectionContext Clients { get; set; } - - /// - /// Provides information about the calling client. - /// - public HubCallerContext Context { get; set; } - - /// - /// The group manager for this hub instance. - /// - public IGroupManager Groups { get; set; } - - /// - /// Called when a connection disconnects from this hub instance. - /// - /// A - public virtual Task OnDisconnected() - { - return TaskAsyncHelper.Empty; - } - - /// - /// Called when the connection connects to this hub instance. - /// - /// A - public virtual Task OnConnected() - { - return TaskAsyncHelper.Empty; - } - - /// - /// Called when the connection reconnects to this hub instance. - /// - /// A - public virtual Task OnReconnected() - { - return TaskAsyncHelper.Empty; - } - - protected virtual void Dispose(bool disposing) - { - } - - public void Dispose() - { - Dispose(true); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/HubConfiguration.cs b/src/Microsoft.AspNet.SignalR.Core/HubConfiguration.cs deleted file mode 100644 index 13abdc5bc..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/HubConfiguration.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -namespace Microsoft.AspNet.SignalR -{ - public class HubConfiguration : ConnectionConfiguration - { - /// - /// Determines whether JavaScript proxies for the server-side hubs should be auto generated at {Path}/hubs. - /// Defaults to true. - /// - public bool EnableJavaScriptProxies { get; set; } - - /// - /// Determines whether detailed exceptions thrown in Hub methods get reported back the invoking client. - /// Defaults to false. - /// - public bool EnableDetailedErrors { get; set; } - - public HubConfiguration() - { - EnableJavaScriptProxies = true; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/ClientHubInvocation.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/ClientHubInvocation.cs deleted file mode 100644 index 37dd52233..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/ClientHubInvocation.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using Newtonsoft.Json; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - /// - /// A description of a client-side hub method invocation. - /// - public class ClientHubInvocation - { - /// - /// The signal that clients receiving this invocation are subscribed to. - /// - [JsonIgnore] - public string Target { get; set; } - - /// - /// The name of the hub that the method being invoked belongs to. - /// - [JsonProperty("H")] - public string Hub { get; set; } - - /// - /// The name of the client-side hub method be invoked. - /// - [JsonProperty("M")] - public string Method { get; set; } - - /// - /// The argument list the client-side hub method will be called with. - /// - [SuppressMessage("Microsoft.Performance", "CA1819:PropertiesShouldNotReturnArrays", Justification = "Type is used for serialization.")] - [JsonProperty("A")] - public object[] Args { get; set; } - - /// - /// A key-value store representing the hub state on the server that has changed since the last time the hub - /// state was sent to the client. - /// - [SuppressMessage("Microsoft.Usage", "CA2227:CollectionPropertiesShouldBeReadOnly", Justification = "Type is used for serialization.")] - [JsonProperty("S", NullValueHandling = NullValueHandling.Ignore)] - public IDictionary State { get; set; } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/ClientProxy.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/ClientProxy.cs deleted file mode 100644 index df87a13f1..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/ClientProxy.cs +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Dynamic; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Infrastructure; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - public class ClientProxy : DynamicObject, IClientProxy - { - private readonly Func, Task> _send; - private readonly string _hubName; - private readonly IList _exclude; - - public ClientProxy(Func, Task> send, string hubName, IList exclude) - { - _send = send; - _hubName = hubName; - _exclude = exclude; - } - - [SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "0", Justification = "Binder is passed in by the DLR")] - public override bool TryInvokeMember(InvokeMemberBinder binder, object[] args, out object result) - { - result = Invoke(binder.Name, args); - return true; - } - - public Task Invoke(string method, params object[] args) - { - var invocation = new ClientHubInvocation - { - Hub = _hubName, - Method = method, - Args = args - }; - - return _send(PrefixHelper.GetHubName(_hubName), invocation, _exclude); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/ConnectionIdProxy.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/ConnectionIdProxy.cs deleted file mode 100644 index cf3520fb8..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/ConnectionIdProxy.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Infrastructure; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - public class ConnectionIdProxy : SignalProxy - { - public ConnectionIdProxy(Func, Task> send, string signal, string hubName, params string[] exclude) : - base(send, signal, hubName, PrefixHelper.HubConnectionIdPrefix, exclude) - { - - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/DefaultAssemblyLocator.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/DefaultAssemblyLocator.cs deleted file mode 100644 index b25b1985d..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/DefaultAssemblyLocator.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Reflection; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - public class DefaultAssemblyLocator : IAssemblyLocator - { - public virtual IList GetAssemblies() - { - return AppDomain.CurrentDomain.GetAssemblies(); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/DefaultHubActivator.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/DefaultHubActivator.cs deleted file mode 100644 index 8f160d09a..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/DefaultHubActivator.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - public class DefaultHubActivator : IHubActivator - { - private readonly IDependencyResolver _resolver; - - public DefaultHubActivator(IDependencyResolver resolver) - { - _resolver = resolver; - } - - public IHub Create(HubDescriptor descriptor) - { - if (descriptor == null) - { - throw new ArgumentNullException("descriptor"); - } - - if(descriptor.HubType == null) - { - return null; - } - - object hub = _resolver.Resolve(descriptor.HubType) ?? Activator.CreateInstance(descriptor.HubType); - return hub as IHub; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/DefaultJavaScriptProxyGenerator.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/DefaultJavaScriptProxyGenerator.cs deleted file mode 100644 index 7e84b318e..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/DefaultJavaScriptProxyGenerator.cs +++ /dev/null @@ -1,211 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Text; -using Microsoft.AspNet.SignalR.Json; -using Newtonsoft.Json; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - public class DefaultJavaScriptProxyGenerator : IJavaScriptProxyGenerator - { - private static readonly Lazy _templateFromResource = new Lazy(GetTemplateFromResource); - - private static readonly Type[] _numberTypes = new[] { typeof(byte), typeof(short), typeof(int), typeof(long), typeof(float), typeof(decimal), typeof(double) }; - private static readonly Type[] _dateTypes = new[] { typeof(DateTime), typeof(DateTimeOffset) }; - - private const string ScriptResource = "Microsoft.AspNet.SignalR.Scripts.hubs.js"; - - private readonly IHubManager _manager; - private readonly IJavaScriptMinifier _javaScriptMinifier; - private readonly Lazy _generatedTemplate; - - public DefaultJavaScriptProxyGenerator(IDependencyResolver resolver) : - this(resolver.Resolve(), - resolver.Resolve()) - { - } - - public DefaultJavaScriptProxyGenerator(IHubManager manager, IJavaScriptMinifier javaScriptMinifier) - { - _manager = manager; - _javaScriptMinifier = javaScriptMinifier ?? NullJavaScriptMinifier.Instance; - _generatedTemplate = new Lazy(() => GenerateProxy(_manager, _javaScriptMinifier, includeDocComments: false)); - } - - public string GenerateProxy(string serviceUrl) - { - serviceUrl = JavaScriptEncode(serviceUrl); - - var generateProxy = _generatedTemplate.Value; - - return generateProxy.Replace("{serviceUrl}", serviceUrl); - } - - public string GenerateProxy(string serviceUrl, bool includeDocComments) - { - serviceUrl = JavaScriptEncode(serviceUrl); - - string generateProxy = GenerateProxy(_manager, _javaScriptMinifier, includeDocComments); - - return generateProxy.Replace("{serviceUrl}", serviceUrl); - } - - private static string GenerateProxy(IHubManager hubManager, IJavaScriptMinifier javaScriptMinifier, bool includeDocComments) - { - string script = _templateFromResource.Value; - - var hubs = new StringBuilder(); - var first = true; - foreach (var descriptor in hubManager.GetHubs().OrderBy(h => h.Name)) - { - if (!first) - { - hubs.AppendLine(";"); - hubs.AppendLine(); - hubs.Append(" "); - } - GenerateType(hubManager, hubs, descriptor, includeDocComments); - first = false; - } - - if (hubs.Length > 0) - { - hubs.Append(";"); - } - - script = script.Replace("/*hubs*/", hubs.ToString()); - - return javaScriptMinifier.Minify(script); - } - - private static void GenerateType(IHubManager hubManager, StringBuilder sb, HubDescriptor descriptor, bool includeDocComments) - { - // Get only actions with minimum number of parameters. - var methods = GetMethods(hubManager, descriptor); - var hubName = GetDescriptorName(descriptor); - - sb.AppendFormat(" proxies.{0} = this.createHubProxy('{1}'); ", hubName, hubName).AppendLine(); - sb.AppendFormat(" proxies.{0}.client = {{ }};", hubName).AppendLine(); - sb.AppendFormat(" proxies.{0}.server = {{", hubName); - - bool first = true; - - foreach (var method in methods) - { - if (!first) - { - sb.Append(",").AppendLine(); - } - GenerateMethod(sb, method, includeDocComments, hubName); - first = false; - } - sb.AppendLine(); - sb.Append(" }"); - } - - private static string GetDescriptorName(Descriptor descriptor) - { - if (descriptor == null) - { - throw new ArgumentNullException("descriptor"); - } - - string name = descriptor.Name; - - // If the name was not specified then do not camel case - if (!descriptor.NameSpecified) - { - name = JsonUtility.CamelCase(name); - } - - return name; - } - - private static IEnumerable GetMethods(IHubManager manager, HubDescriptor descriptor) - { - return from method in manager.GetHubMethods(descriptor.Name) - group method by method.Name into overloads - let oload = (from overload in overloads - orderby overload.Parameters.Count - select overload).FirstOrDefault() - orderby oload.Name - select oload; - } - - private static void GenerateMethod(StringBuilder sb, MethodDescriptor method, bool includeDocComments, string hubName) - { - var parameterNames = method.Parameters.Select(p => p.Name).ToList(); - sb.AppendLine(); - sb.AppendFormat(" {0}: function ({1}) {{", GetDescriptorName(method), Commas(parameterNames)).AppendLine(); - if (includeDocComments) - { - sb.AppendFormat(Resources.DynamicComment_CallsMethodOnServerSideDeferredPromise, method.Name, method.Hub.Name).AppendLine(); - var parameterDoc = method.Parameters.Select(p => String.Format(CultureInfo.CurrentCulture, Resources.DynamicComment_ServerSideTypeIs, p.Name, MapToJavaScriptType(p.ParameterType), p.ParameterType)).ToList(); - if (parameterDoc.Any()) - { - sb.AppendLine(String.Join(Environment.NewLine, parameterDoc)); - } - } - sb.AppendFormat(" return proxies.{0}.invoke.apply(proxies.{0}, $.merge([\"{1}\"], $.makeArray(arguments)));", hubName, method.Name).AppendLine(); - sb.Append(" }"); - } - - private static string MapToJavaScriptType(Type type) - { - if (!type.IsPrimitive && !(type == typeof(string))) - { - return "Object"; - } - if (type == typeof(string)) - { - return "String"; - } - if (_numberTypes.Contains(type)) - { - return "Number"; - } - if (typeof(IEnumerable).IsAssignableFrom(type)) - { - return "Array"; - } - if (_dateTypes.Contains(type)) - { - return "Date"; - } - return String.Empty; - } - - private static string Commas(IEnumerable values) - { - return Commas(values, v => v); - } - - private static string Commas(IEnumerable values, Func selector) - { - return String.Join(", ", values.Select(selector)); - } - - private static string GetTemplateFromResource() - { - using (Stream resourceStream = typeof(DefaultJavaScriptProxyGenerator).Assembly.GetManifestResourceStream(ScriptResource)) - { - var reader = new StreamReader(resourceStream); - return reader.ReadToEnd(); - } - } - - private static string JavaScriptEncode(string value) - { - value = JsonConvert.SerializeObject(value); - - // Remove the quotes - return value.Substring(1, value.Length - 2); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/DynamicDictionary.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/DynamicDictionary.cs deleted file mode 100644 index 0a9963108..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/DynamicDictionary.cs +++ /dev/null @@ -1,144 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Collections; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Dynamic; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - public class DynamicDictionary : DynamicObject, IDictionary - { - private readonly IDictionary _obj; - - public DynamicDictionary(IDictionary obj) - { - _obj = obj; - } - - public object this[string key] - { - get - { - object result; - _obj.TryGetValue(key, out result); - return Wrap(result); - } - set - { - _obj[key] = Unwrap(value); - } - } - - [SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "0", Justification = "The compiler generates calls to invoke this")] - public override bool TryGetMember(GetMemberBinder binder, out object result) - { - result = this[binder.Name]; - return true; - } - - [SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "0", Justification = "The compiler generates calls to invoke this")] - public override bool TrySetMember(SetMemberBinder binder, object value) - { - this[binder.Name] = value; - return true; - } - - public static object Wrap(object value) - { - var obj = value as IDictionary; - if (obj != null) - { - return new DynamicDictionary(obj); - } - - return value; - } - - public static object Unwrap(object value) - { - var dictWrapper = value as DynamicDictionary; - if (dictWrapper != null) - { - return dictWrapper._obj; - } - - return value; - } - - public void Add(string key, object value) - { - _obj.Add(key, value); - } - - public bool ContainsKey(string key) - { - return _obj.ContainsKey(key); - } - - public ICollection Keys - { - get { return _obj.Keys; } - } - - public bool Remove(string key) - { - return _obj.Remove(key); - } - - public bool TryGetValue(string key, out object value) - { - return _obj.TryGetValue(key, out value); - } - - public ICollection Values - { - get { return _obj.Values; } - } - - public void Add(KeyValuePair item) - { - _obj.Add(item); - } - - public void Clear() - { - _obj.Clear(); - } - - public bool Contains(KeyValuePair item) - { - return _obj.Contains(item); - } - - public void CopyTo(KeyValuePair[] array, int arrayIndex) - { - _obj.CopyTo(array, arrayIndex); - } - - public int Count - { - get { return _obj.Count; } - } - - public bool IsReadOnly - { - get { return _obj.IsReadOnly; } - } - - public bool Remove(KeyValuePair item) - { - return _obj.Remove(item); - } - - public IEnumerator> GetEnumerator() - { - return _obj.GetEnumerator(); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return GetEnumerator(); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/EmptyJavaScriptProxyGenerator.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/EmptyJavaScriptProxyGenerator.cs deleted file mode 100644 index d0ed9055e..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/EmptyJavaScriptProxyGenerator.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Globalization; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - public class EmptyJavaScriptProxyGenerator : IJavaScriptProxyGenerator - { - public string GenerateProxy(string serviceUrl) - { - return String.Format(CultureInfo.InvariantCulture, "throw new Error('{0}');", Resources.Error_JavaScriptProxyDisabled); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Extensions/HubManagerExtensions.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Extensions/HubManagerExtensions.cs deleted file mode 100644 index bc5455b59..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Extensions/HubManagerExtensions.cs +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Globalization; -using Microsoft.AspNet.SignalR.Infrastructure; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - public static class HubManagerExtensions - { - public static HubDescriptor EnsureHub(this IHubManager hubManager, string hubName, params IPerformanceCounter[] counters) - { - if (hubManager == null) - { - throw new ArgumentNullException("hubManager"); - } - - if (String.IsNullOrEmpty(hubName)) - { - throw new ArgumentNullException("hubName"); - } - - if (counters == null) - { - throw new ArgumentNullException("counters"); - } - - var descriptor = hubManager.GetHub(hubName); - - if (descriptor == null) - { - for (var i = 0; i < counters.Length; i++) - { - counters[i].Increment(); - } - throw new InvalidOperationException(String.Format(CultureInfo.CurrentCulture, Resources.Error_HubCouldNotBeResolved, hubName)); - } - - return descriptor; - } - - public static IEnumerable GetHubs(this IHubManager hubManager) - { - if (hubManager == null) - { - throw new ArgumentNullException("hubManager"); - } - - return hubManager.GetHubs(d => true); - } - - public static IEnumerable GetHubMethods(this IHubManager hubManager, string hubName) - { - if (hubManager == null) - { - throw new ArgumentNullException("hubManager"); - } - - return hubManager.GetHubMethods(hubName, m => true); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Extensions/HubTypeExtensions.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Extensions/HubTypeExtensions.cs deleted file mode 100644 index 1bf73da42..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Extensions/HubTypeExtensions.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - internal static class HubTypeExtensions - { - internal static string GetHubName(this Type type) - { - if (!typeof(IHub).IsAssignableFrom(type)) - { - return null; - } - - return GetHubAttributeName(type) ?? type.Name; - } - - internal static string GetHubAttributeName(this Type type) - { - if (!typeof(IHub).IsAssignableFrom(type)) - { - return null; - } - - // We can still return null if there is no attribute name - return ReflectionHelper.GetAttributeValue(type, attr => attr.HubName); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Extensions/MethodExtensions.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Extensions/MethodExtensions.cs deleted file mode 100644 index 3418e8566..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Extensions/MethodExtensions.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using Microsoft.AspNet.SignalR.Json; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - public static class MethodExtensions - { - [SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "1", Justification = "The condition checks for null parameters")] - public static bool Matches(this MethodDescriptor methodDescriptor, IList parameters) - { - if (methodDescriptor == null) - { - throw new ArgumentNullException("methodDescriptor"); - } - - if ((methodDescriptor.Parameters.Count > 0 && parameters == null) - || methodDescriptor.Parameters.Count != parameters.Count) - { - return false; - } - - return true; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/GroupProxy.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/GroupProxy.cs deleted file mode 100644 index 9ca86d5ce..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/GroupProxy.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Infrastructure; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - public class GroupProxy : SignalProxy - { - public GroupProxy(Func, Task> send, string signal, string hubName, IList exclude) : - base(send, signal, hubName, PrefixHelper.HubGroupPrefix, exclude) - { - - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/HubCallerContext.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/HubCallerContext.cs deleted file mode 100644 index 2413da7da..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/HubCallerContext.cs +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Collections.Generic; -using System.Collections.Specialized; -using System.Security.Principal; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - public class HubCallerContext - { - /// - /// Gets the connection id of the calling client. - /// - public string ConnectionId { get; private set; } - - /// - /// Gets the cookies for the request. - /// - public IDictionary RequestCookies - { - get - { - return Request.Cookies; - } - } - - /// - /// Gets the headers for the request. - /// - public NameValueCollection Headers - { - get - { - return Request.Headers; - } - } - - /// - /// Gets the querystring for the request. - /// - public NameValueCollection QueryString - { - get - { - return Request.QueryString; - } - } - - /// - /// Gets the for the request. - /// - public IPrincipal User - { - get - { - return Request.User; - } - } - - /// - /// Gets the for the current HTTP request. - /// - public IRequest Request { get; private set; } - - public HubCallerContext(IRequest request, string connectionId) - { - ConnectionId = connectionId; - Request = request; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/HubConnectionContext.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/HubConnectionContext.cs deleted file mode 100644 index fded4874b..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/HubConnectionContext.cs +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Infrastructure; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - /// - /// Encapsulates all information about an individual SignalR connection for an . - /// - public class HubConnectionContext : IHubConnectionContext - { - private readonly string _hubName; - private readonly string _connectionId; - private readonly Func, Task> _send; - - /// - /// Initializes a new instance of the . - /// - public HubConnectionContext() - { - } - - /// - /// Initializes a new instance of the . - /// - /// The pipeline invoker. - /// The connection. - /// The hub name. - /// The connection id. - /// The connection hub state. - public HubConnectionContext(IHubPipelineInvoker pipelineInvoker, IConnection connection, string hubName, string connectionId, StateChangeTracker tracker) - { - _send = (signal, invocation, exclude) => pipelineInvoker.Send(new HubOutgoingInvokerContext(connection, signal, invocation, exclude)); - _connectionId = connectionId; - _hubName = hubName; - - Caller = new StatefulSignalProxy(_send, connectionId, PrefixHelper.HubConnectionIdPrefix, hubName, tracker); - All = AllExcept(); - Others = AllExcept(connectionId); - } - - /// - /// All connected clients. - /// - public dynamic All { get; set; } - - /// - /// All connected clients except the calling client. - /// - public dynamic Others { get; set; } - - /// - /// Represents the calling client. - /// - public dynamic Caller { get; set; } - - /// - /// Returns a dynamic representation of all clients except the calling client ones specified. - /// - /// The list of connection ids to exclude - /// A dynamic representation of all clients except the calling client ones specified. - public dynamic AllExcept(params string[] excludeConnectionIds) - { - return new ClientProxy(_send, _hubName, PrefixHelper.GetPrefixedConnectionIds(excludeConnectionIds)); - } - - /// - /// Returns a dynamic representation of all clients in a group except the calling client. - /// - /// The name of the group - /// A dynamic representation of all clients in a group except the calling client. - public dynamic OthersInGroup(string groupName) - { - return Group(groupName, _connectionId); - } - - /// - /// Returns a dynamic representation of the specified group. - /// - /// The name of the group - /// The list of connection ids to exclude - /// A dynamic representation of the specified group. - public dynamic Group(string groupName, params string[] excludeConnectionIds) - { - if (string.IsNullOrEmpty(groupName)) - { - throw new ArgumentException(Resources.Error_ArgumentNullOrEmpty, "groupName"); - } - - return new GroupProxy(_send, groupName, _hubName, PrefixHelper.GetPrefixedConnectionIds(excludeConnectionIds)); - } - - /// - /// Returns a dynamic representation of the connection with the specified connectionid. - /// - /// The connection id - /// A dynamic representation of the specified client. - public dynamic Client(string connectionId) - { - if (string.IsNullOrEmpty(connectionId)) - { - throw new ArgumentException(Resources.Error_ArgumentNullOrEmpty, "connectionId"); - } - - return new ConnectionIdProxy(_send, connectionId, _hubName); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/HubContext.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/HubContext.cs deleted file mode 100644 index 37a295dcf..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/HubContext.cs +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Infrastructure; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - internal class HubContext : IHubContext - { - public HubContext(Func, Task> send, string hubName, IConnection connection) - { - Clients = new ExternalHubConnectionContext(send, hubName); - Groups = new GroupManager(connection, PrefixHelper.GetHubGroupName(hubName)); - } - - public IHubConnectionContext Clients { get; private set; } - - public IGroupManager Groups { get; private set; } - - private class ExternalHubConnectionContext : IHubConnectionContext - { - private readonly Func, Task> _send; - private readonly string _hubName; - - public ExternalHubConnectionContext(Func, Task> send, string hubName) - { - _send = send; - _hubName = hubName; - All = AllExcept(); - } - - public dynamic All - { - get; - private set; - } - - public dynamic AllExcept(params string[] exclude) - { - return new ClientProxy(_send, _hubName, exclude); - } - - public dynamic Group(string groupName, params string[] exclude) - { - if (string.IsNullOrEmpty(groupName)) - { - throw new ArgumentException(Resources.Error_ArgumentNullOrEmpty, "groupName"); - } - - return new GroupProxy(_send, groupName, _hubName, exclude); - } - - public dynamic Client(string connectionId) - { - if (string.IsNullOrEmpty(connectionId)) - { - throw new ArgumentException(Resources.Error_ArgumentNullOrEmpty, "connectionId"); - } - - return new ConnectionIdProxy(_send, connectionId, _hubName); - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/HubDispatcher.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/HubDispatcher.cs deleted file mode 100644 index b97aa74c5..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/HubDispatcher.cs +++ /dev/null @@ -1,522 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Linq; -using System.Linq.Expressions; -using System.Reflection; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Hosting; -using Microsoft.AspNet.SignalR.Infrastructure; -using Microsoft.AspNet.SignalR.Json; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - /// - /// Handles all communication over the hubs persistent connection. - /// - [SuppressMessage("Microsoft.Maintainability", "CA1506:AvoidExcessiveClassCoupling", Justification = "This dispatcher makes use of many interfaces.")] - public class HubDispatcher : PersistentConnection - { - private const string HubsSuffix = "/hubs"; - - private readonly List _hubs = new List(); - private readonly bool _enableJavaScriptProxies; - private readonly bool _enableDetailedErrors; - - private IJavaScriptProxyGenerator _proxyGenerator; - private IHubManager _manager; - private IHubRequestParser _requestParser; - private IParameterResolver _binder; - private IHubPipelineInvoker _pipelineInvoker; - private IPerformanceCounterManager _counters; - private bool _isDebuggingEnabled; - - private static readonly MethodInfo _continueWithMethod = typeof(HubDispatcher).GetMethod("ContinueWith", BindingFlags.NonPublic | BindingFlags.Static); - - /// - /// Initializes an instance of the class. - /// - /// Configuration settings determining whether to enable JS proxies and provide clients with detailed hub errors. - public HubDispatcher(HubConfiguration configuration) - { - if (configuration == null) - { - throw new ArgumentNullException("configuration"); - } - - _enableJavaScriptProxies = configuration.EnableJavaScriptProxies; - _enableDetailedErrors = configuration.EnableDetailedErrors; - } - - protected override TraceSource Trace - { - get - { - return TraceManager["SignalR.HubDispatcher"]; - } - } - - internal override string GroupPrefix - { - get - { - return PrefixHelper.HubGroupPrefix; - } - } - - public override void Initialize(IDependencyResolver resolver, HostContext context) - { - if (resolver == null) - { - throw new ArgumentNullException("resolver"); - } - - if (context == null) - { - throw new ArgumentNullException("context"); - } - - _proxyGenerator = _enableJavaScriptProxies ? resolver.Resolve() - : new EmptyJavaScriptProxyGenerator(); - - _manager = resolver.Resolve(); - _binder = resolver.Resolve(); - _requestParser = resolver.Resolve(); - _pipelineInvoker = resolver.Resolve(); - _counters = resolver.Resolve(); - - base.Initialize(resolver, context); - } - - protected override bool AuthorizeRequest(IRequest request) - { - // Populate _hubs - string data = request.QueryStringOrForm("connectionData"); - - if (!String.IsNullOrEmpty(data)) - { - var clientHubInfo = JsonSerializer.Parse>(data); - - // If there's any hubs then perform the auth check - if (clientHubInfo != null && clientHubInfo.Any()) - { - var hubCache = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var hubInfo in clientHubInfo) - { - if (hubCache.ContainsKey(hubInfo.Name)) - { - throw new InvalidOperationException(Resources.Error_DuplicateHubs); - } - - // Try to find the associated hub type - HubDescriptor hubDescriptor = _manager.EnsureHub(hubInfo.Name, - _counters.ErrorsHubResolutionTotal, - _counters.ErrorsHubResolutionPerSec, - _counters.ErrorsAllTotal, - _counters.ErrorsAllPerSec); - - if (_pipelineInvoker.AuthorizeConnect(hubDescriptor, request)) - { - // Add this to the list of hub descriptors this connection is interested in - hubCache.Add(hubDescriptor.Name, hubDescriptor); - } - } - - _hubs.AddRange(hubCache.Values); - - // If we have any hubs in the list then we're authorized - return _hubs.Count > 0; - } - } - - return base.AuthorizeRequest(request); - } - - /// - /// Processes the hub's incoming method calls. - /// - protected override Task OnReceived(IRequest request, string connectionId, string data) - { - HubRequest hubRequest = _requestParser.Parse(data); - - // Create the hub - HubDescriptor descriptor = _manager.EnsureHub(hubRequest.Hub, - _counters.ErrorsHubInvocationTotal, - _counters.ErrorsHubInvocationPerSec, - _counters.ErrorsAllTotal, - _counters.ErrorsAllPerSec); - - IJsonValue[] parameterValues = hubRequest.ParameterValues; - - // Resolve the method - MethodDescriptor methodDescriptor = _manager.GetHubMethod(descriptor.Name, hubRequest.Method, parameterValues); - - if (methodDescriptor == null) - { - _counters.ErrorsHubInvocationTotal.Increment(); - _counters.ErrorsHubInvocationPerSec.Increment(); - - // Empty (noop) method descriptor - // Use: Forces the hub pipeline module to throw an error. This error is encapsulated in the HubDispatcher. - // Encapsulating it in the HubDispatcher prevents the error from bubbling up to the transport level. - // Specifically this allows us to return a faulted task (call .fail on client) and to not cause the - // transport to unintentionally fail. - methodDescriptor = new NullMethodDescriptor(hubRequest.Method); - } - - // Resolving the actual state object - var tracker = new StateChangeTracker(hubRequest.State); - var hub = CreateHub(request, descriptor, connectionId, tracker, throwIfFailedToCreate: true); - - return InvokeHubPipeline(hub, parameterValues, methodDescriptor, hubRequest, tracker) - .ContinueWith(task => hub.Dispose(), TaskContinuationOptions.ExecuteSynchronously); - } - - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Exceptions are flown to the caller.")] - private Task InvokeHubPipeline(IHub hub, - IJsonValue[] parameterValues, - MethodDescriptor methodDescriptor, - HubRequest hubRequest, - StateChangeTracker tracker) - { - Task piplineInvocation; - - try - { - var args = _binder.ResolveMethodParameters(methodDescriptor, parameterValues); - var context = new HubInvokerContext(hub, tracker, methodDescriptor, args); - - // Invoke the pipeline and save the task - piplineInvocation = _pipelineInvoker.Invoke(context); - } - catch (Exception ex) - { - piplineInvocation = TaskAsyncHelper.FromError(ex); - } - - // Determine if we have a faulted task or not and handle it appropriately. - return piplineInvocation.ContinueWith(task => - { - if (task.IsFaulted) - { - return ProcessResponse(tracker, result: null, request: hubRequest, error: task.Exception); - } - else if (task.IsCanceled) - { - return ProcessResponse(tracker, result: null, request: hubRequest, error: new OperationCanceledException()); - } - else - { - return ProcessResponse(tracker, task.Result, hubRequest, error: null); - } - }) - .FastUnwrap(); - } - - public override Task ProcessRequest(HostContext context) - { - if (context == null) - { - throw new ArgumentNullException("context"); - } - - // Trim any trailing slashes - string normalized = context.Request.Url.LocalPath.TrimEnd('/'); - - if (normalized.EndsWith(HubsSuffix, StringComparison.OrdinalIgnoreCase)) - { - // Generate the proper hub url - string hubUrl = normalized.Substring(0, normalized.Length - HubsSuffix.Length); - - // Generate the proxy - context.Response.ContentType = JsonUtility.JavaScriptMimeType; - return context.Response.End(_proxyGenerator.GenerateProxy(hubUrl)); - } - - _isDebuggingEnabled = context.IsDebuggingEnabled(); - - return base.ProcessRequest(context); - } - - internal static Task Connect(IHub hub) - { - return hub.OnConnected(); - } - - internal static Task Reconnect(IHub hub) - { - return hub.OnReconnected(); - } - - internal static Task Disconnect(IHub hub) - { - return hub.OnDisconnected(); - } - - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "A faulted task is returned.")] - internal static Task Incoming(IHubIncomingInvokerContext context) - { - var tcs = new TaskCompletionSource(); - - try - { - object result = context.MethodDescriptor.Invoker(context.Hub, context.Args.ToArray()); - Type returnType = context.MethodDescriptor.ReturnType; - - if (typeof(Task).IsAssignableFrom(returnType)) - { - var task = (Task)result; - if (!returnType.IsGenericType) - { - task.ContinueWith(tcs); - } - else - { - // Get the in Task - Type resultType = returnType.GetGenericArguments().Single(); - - Type genericTaskType = typeof(Task<>).MakeGenericType(resultType); - - // Get the correct ContinueWith overload - var parameter = Expression.Parameter(typeof(object)); - - // TODO: Cache this whole thing - // Action callback = result => ContinueWith((Task)result, tcs); - MethodInfo continueWithMethod = _continueWithMethod.MakeGenericMethod(resultType); - - Expression body = Expression.Call(continueWithMethod, - Expression.Convert(parameter, genericTaskType), - Expression.Constant(tcs)); - - var continueWithInvoker = Expression.Lambda>(body, parameter).Compile(); - continueWithInvoker.Invoke(result); - } - } - else - { - tcs.TrySetResult(result); - } - } - catch (Exception ex) - { - tcs.TrySetUnwrappedException(ex); - } - - return tcs.Task; - } - - internal static Task Outgoing(IHubOutgoingInvokerContext context) - { - var message = new ConnectionMessage(context.Signal, context.Invocation, context.ExcludedSignals); - - return context.Connection.Send(message); - } - - protected override Task OnConnected(IRequest request, string connectionId) - { - return ExecuteHubEvent(request, connectionId, hub => _pipelineInvoker.Connect(hub)); - } - - protected override Task OnReconnected(IRequest request, string connectionId) - { - return ExecuteHubEvent(request, connectionId, hub => _pipelineInvoker.Reconnect(hub)); - } - - protected override IList OnRejoiningGroups(IRequest request, IList groups, string connectionId) - { - return _hubs.Select(hubDescriptor => - { - string groupPrefix = hubDescriptor.Name + "."; - - var hubGroups = groups.Where(g => g.StartsWith(groupPrefix, StringComparison.OrdinalIgnoreCase)) - .Select(g => g.Substring(groupPrefix.Length)) - .ToList(); - - return _pipelineInvoker.RejoiningGroups(hubDescriptor, request, hubGroups) - .Select(g => groupPrefix + g); - - }).SelectMany(groupsToRejoin => groupsToRejoin).ToList(); - } - - protected override Task OnDisconnected(IRequest request, string connectionId) - { - return ExecuteHubEvent(request, connectionId, hub => _pipelineInvoker.Disconnect(hub)); - } - - protected override IList GetSignals(string connectionId) - { - return _hubs.SelectMany(info => new[] { PrefixHelper.GetHubName(info.Name), PrefixHelper.GetHubConnectionId(info.CreateQualifiedName(connectionId)) }) - .Concat(new[] { PrefixHelper.GetConnectionId(connectionId), PrefixHelper.GetAck(connectionId) }) - .ToList(); - } - - private Task ExecuteHubEvent(IRequest request, string connectionId, Func action) - { - var hubs = GetHubs(request, connectionId).ToList(); - var operations = hubs.Select(instance => action(instance).OrEmpty().Catch()).ToArray(); - - if (operations.Length == 0) - { - DisposeHubs(hubs); - return TaskAsyncHelper.Empty; - } - - var tcs = new TaskCompletionSource(); - Task.Factory.ContinueWhenAll(operations, tasks => - { - DisposeHubs(hubs); - var faulted = tasks.FirstOrDefault(t => t.IsFaulted); - if (faulted != null) - { - tcs.SetUnwrappedException(faulted.Exception); - } - else if (tasks.Any(t => t.IsCanceled)) - { - tcs.SetCanceled(); - } - else - { - tcs.SetResult(null); - } - }); - - return tcs.Task; - } - - private IHub CreateHub(IRequest request, HubDescriptor descriptor, string connectionId, StateChangeTracker tracker = null, bool throwIfFailedToCreate = false) - { - try - { - var hub = _manager.ResolveHub(descriptor.Name); - - if (hub != null) - { - tracker = tracker ?? new StateChangeTracker(); - - hub.Context = new HubCallerContext(request, connectionId); - hub.Clients = new HubConnectionContext(_pipelineInvoker, Connection, descriptor.Name, connectionId, tracker); - hub.Groups = new GroupManager(Connection, PrefixHelper.GetHubGroupName(descriptor.Name)); - } - - return hub; - } - catch (Exception ex) - { - Trace.TraceInformation(String.Format(CultureInfo.CurrentCulture, Resources.Error_ErrorCreatingHub + ex.Message, descriptor.Name)); - - if (throwIfFailedToCreate) - { - throw; - } - - return null; - } - } - - private IEnumerable GetHubs(IRequest request, string connectionId) - { - return from descriptor in _hubs - select CreateHub(request, descriptor, connectionId) into hub - where hub != null - select hub; - } - - private static void DisposeHubs(IEnumerable hubs) - { - foreach (var hub in hubs) - { - hub.Dispose(); - } - } - - private Task ProcessResponse(StateChangeTracker tracker, object result, HubRequest request, Exception error) - { - var hubResult = new HubResponse - { - State = tracker.GetChanges(), - Result = result, - Id = request.Id, - }; - - if (error != null) - { - _counters.ErrorsHubInvocationTotal.Increment(); - _counters.ErrorsHubInvocationPerSec.Increment(); - _counters.ErrorsAllTotal.Increment(); - _counters.ErrorsAllPerSec.Increment(); - - if (_enableDetailedErrors) - { - var exception = error.InnerException ?? error; - hubResult.StackTrace = _isDebuggingEnabled ? exception.StackTrace : null; - hubResult.Error = exception.Message; - } - else - { - hubResult.Error = String.Format(CultureInfo.CurrentCulture, Resources.Error_HubInvocationFailed, request.Hub, request.Method); - } - } - - return Transport.Send(hubResult); - } - - private static void ContinueWith(Task task, TaskCompletionSource tcs) - { - if (task.IsCompleted) - { - // Fast path for tasks that completed synchronously - ContinueSync(task, tcs); - } - else - { - ContinueAsync(task, tcs); - } - } - - private static void ContinueSync(Task task, TaskCompletionSource tcs) - { - if (task.IsFaulted) - { - tcs.TrySetUnwrappedException(task.Exception); - } - else if (task.IsCanceled) - { - tcs.TrySetCanceled(); - } - else - { - tcs.TrySetResult(task.Result); - } - } - - private static void ContinueAsync(Task task, TaskCompletionSource tcs) - { - task.ContinueWith(t => - { - if (t.IsFaulted) - { - tcs.TrySetUnwrappedException(t.Exception); - } - else if (t.IsCanceled) - { - tcs.TrySetCanceled(); - } - else - { - tcs.TrySetResult(t.Result); - } - }); - } - - [SuppressMessage("Microsoft.Performance", "CA1812:AvoidUninstantiatedInternalClasses", Justification = "It is instantiated through JSON deserialization.")] - private class ClientHubInfo - { - public string Name { get; set; } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/HubMethodNameAttribute.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/HubMethodNameAttribute.cs deleted file mode 100644 index 49166ce07..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/HubMethodNameAttribute.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - [AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = false)] - public sealed class HubMethodNameAttribute : Attribute - { - public HubMethodNameAttribute(string methodName) - { - if (String.IsNullOrEmpty(methodName)) - { - throw new ArgumentNullException("methodName"); - } - MethodName = methodName; - } - - public string MethodName - { - get; - private set; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/HubNameAttribute.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/HubNameAttribute.cs deleted file mode 100644 index 942c88706..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/HubNameAttribute.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] - public sealed class HubNameAttribute : Attribute - { - public HubNameAttribute(string hubName) - { - if (String.IsNullOrEmpty(hubName)) - { - throw new ArgumentNullException("hubName"); - } - HubName = hubName; - } - - public string HubName - { - get; - private set; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/HubRequest.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/HubRequest.cs deleted file mode 100644 index 083d855cb..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/HubRequest.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using Microsoft.AspNet.SignalR.Json; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - public class HubRequest - { - public string Hub { get; set; } - public string Method { get; set; } - [SuppressMessage("Microsoft.Performance", "CA1819:PropertiesShouldNotReturnArrays", Justification = "This type is used for de-serialization.")] - public IJsonValue[] ParameterValues { get; set; } - [SuppressMessage("Microsoft.Usage", "CA2227:CollectionPropertiesShouldBeReadOnly", Justification = "This type is used for de-serialization.")] - [SuppressMessage("Microsoft.Performance", "CA1819:PropertiesShouldNotReturnArrays", Justification = "This type is used for de-serialization.")] - public IDictionary State { get; set; } - public string Id { get; set; } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/HubRequestParser.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/HubRequestParser.cs deleted file mode 100644 index f5f361f41..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/HubRequestParser.cs +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using Microsoft.AspNet.SignalR.Json; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - internal class HubRequestParser : IHubRequestParser - { - private static readonly IJsonValue[] _emptyArgs = new IJsonValue[0]; - - public HubRequest Parse(string data) - { - var serializer = new JsonNetSerializer(); - var deserializedData = serializer.Parse(data); - - var request = new HubRequest(); - - request.Hub = deserializedData.Hub; - request.Method = deserializedData.Method; - request.Id = deserializedData.Id; - request.State = GetState(deserializedData); - request.ParameterValues = (deserializedData.Args != null) ? deserializedData.Args.Select(value => new JRawValue(value)).ToArray() : _emptyArgs; - - return request; - } - - [SuppressMessage("Microsoft.Performance", "CA1812:AvoidUninstantiatedInternalClasses", Justification = "This type is used for deserialzation")] - private class HubInvocation - { - [JsonProperty("H")] - public string Hub { get; set; } - [JsonProperty("M")] - public string Method { get; set; } - [JsonProperty("I")] - public string Id { get; set; } - [JsonProperty("S")] - public JRaw State { get; set; } - [JsonProperty("A")] - public JRaw[] Args { get; set; } - } - - private static IDictionary GetState(HubInvocation deserializedData) - { - if (deserializedData.State == null) - { - return new Dictionary(); - } - - // Get the raw JSON string and check if it's over 4K - string json = deserializedData.State.ToString(); - - if (json.Length > 4096) - { - throw new InvalidOperationException(Resources.Error_StateExceededMaximumLength); - } - - var settings = new JsonSerializerSettings(); - settings.Converters.Add(new SipHashBasedDictionaryConverter()); - var serializer = new JsonNetSerializer(settings); - return serializer.Parse>(json); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/HubResponse.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/HubResponse.cs deleted file mode 100644 index a1305eb9a..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/HubResponse.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using Newtonsoft.Json; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - /// - /// The response returned from an incoming hub request. - /// - public class HubResponse - { - /// - /// The changes made the the round tripped state. - /// - [SuppressMessage("Microsoft.Usage", "CA2227:CollectionPropertiesShouldBeReadOnly", Justification = "Type is used for serialization")] - [JsonProperty("S", NullValueHandling = NullValueHandling.Ignore)] - public IDictionary State { get; set; } - - /// - /// The result of the invocation. - /// - [JsonProperty("R", NullValueHandling = NullValueHandling.Ignore)] - public object Result { get; set; } - - /// - /// The id of the operation. - /// - [JsonProperty("I")] - public string Id { get; set; } - - /// - /// The exception that occurs as a result of invoking the hub method. - /// - [JsonProperty("E", NullValueHandling = NullValueHandling.Ignore)] - public string Error { get; set; } - - /// - /// The stack trace of the exception that occurs as a result of invoking the hub method. - /// - [JsonProperty("T", NullValueHandling = NullValueHandling.Ignore)] - public string StackTrace { get; set; } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/IAssemblyLocator.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/IAssemblyLocator.cs deleted file mode 100644 index 5e98096fb..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/IAssemblyLocator.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - public interface IAssemblyLocator - { - [SuppressMessage("Microsoft.Design", "CA1024:UsePropertiesWhereAppropriate", Justification = "Might be expensive.")] - IList GetAssemblies(); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/IClientProxy.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/IClientProxy.cs deleted file mode 100644 index 71c9bc69c..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/IClientProxy.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Threading.Tasks; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - /// - /// A server side proxy for the client side hub. - /// - public interface IClientProxy - { - /// - /// Invokes a method on the connection(s) represented by the instance. - /// - /// name of the method to invoke - /// argumetns to pass to the client - /// A task that represents when the data has been sent to the client. - Task Invoke(string method, params object[] args); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/IHub.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/IHub.cs deleted file mode 100644 index 1b7ef0ff8..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/IHub.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Threading.Tasks; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - public interface IHub : IDisposable - { - /// - /// Gets a . Which contains information about the calling client. - /// - HubCallerContext Context { get; set; } - - /// - /// Gets a dynamic object that represents all clients connected to this hub (not hub instance). - /// - HubConnectionContext Clients { get; set; } - - /// - /// Gets the the hub instance. - /// - IGroupManager Groups { get; set; } - - /// - /// Called when a new connection is made to the . - /// - Task OnConnected(); - - /// - /// Called when a connection reconnects to the after a timeout. - /// - Task OnReconnected(); - - /// - /// Called when a connection is disconnected from the . - /// - Task OnDisconnected(); - } -} - diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/IHubActivator.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/IHubActivator.cs deleted file mode 100644 index 9a606a8a5..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/IHubActivator.cs +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -namespace Microsoft.AspNet.SignalR.Hubs -{ - public interface IHubActivator - { - IHub Create(HubDescriptor descriptor); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/IHubConnectionContext.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/IHubConnectionContext.cs deleted file mode 100644 index fa468dd47..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/IHubConnectionContext.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -namespace Microsoft.AspNet.SignalR.Hubs -{ - /// - /// Encapsulates all information about a SignalR connection for an . - /// - public interface IHubConnectionContext - { - dynamic All { get; } - dynamic AllExcept(params string[] excludeConnectionIds); - dynamic Client(string connectionId); - dynamic Group(string groupName, params string[] excludeConnectionIds); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/IHubRequestParser.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/IHubRequestParser.cs deleted file mode 100644 index dd798dd5f..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/IHubRequestParser.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -namespace Microsoft.AspNet.SignalR.Hubs -{ - /// - /// Handles parsing incoming requests through the . - /// - public interface IHubRequestParser - { - /// - /// Parses the incoming hub payload into a . - /// - /// The raw hub payload. - /// The resulting . - HubRequest Parse(string data); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/IJavaScriptMinifier.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/IJavaScriptMinifier.cs deleted file mode 100644 index 05690b5d7..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/IJavaScriptMinifier.cs +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -namespace Microsoft.AspNet.SignalR.Hubs -{ - public interface IJavaScriptMinifier - { - string Minify(string source); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/IJavaScriptProxyGenerator.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/IJavaScriptProxyGenerator.cs deleted file mode 100644 index 49abbe3ea..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/IJavaScriptProxyGenerator.cs +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -namespace Microsoft.AspNet.SignalR.Hubs -{ - public interface IJavaScriptProxyGenerator - { - string GenerateProxy(string serviceUrl); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/DefaultHubManager.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/DefaultHubManager.cs deleted file mode 100644 index f572b91e3..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/DefaultHubManager.cs +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.AspNet.SignalR.Json; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - public class DefaultHubManager : IHubManager - { - private readonly IEnumerable _methodProviders; - private readonly IHubActivator _activator; - private readonly IEnumerable _hubProviders; - - public DefaultHubManager(IDependencyResolver resolver) - { - _hubProviders = resolver.ResolveAll(); - _methodProviders = resolver.ResolveAll(); - _activator = resolver.Resolve(); - } - - public HubDescriptor GetHub(string hubName) - { - HubDescriptor descriptor = null; - if (_hubProviders.FirstOrDefault(p => p.TryGetHub(hubName, out descriptor)) != null) - { - return descriptor; - } - - return null; - } - - public IEnumerable GetHubs(Func predicate) - { - var hubs = _hubProviders.SelectMany(p => p.GetHubs()); - - if (predicate != null) - { - return hubs.Where(predicate); - } - - return hubs; - } - - public MethodDescriptor GetHubMethod(string hubName, string method, IList parameters) - { - HubDescriptor hub = GetHub(hubName); - - if (hub == null) - { - return null; - } - - MethodDescriptor descriptor = null; - if (_methodProviders.FirstOrDefault(p => p.TryGetMethod(hub, method, out descriptor, parameters)) != null) - { - return descriptor; - } - - return null; - } - - public IEnumerable GetHubMethods(string hubName, Func predicate) - { - HubDescriptor hub = GetHub(hubName); - - if (hub == null) - { - return null; - } - - var methods = _methodProviders.SelectMany(p => p.GetMethods(hub)); - - if (predicate != null) - { - return methods.Where(predicate); - } - - return methods; - - } - - public IHub ResolveHub(string hubName) - { - HubDescriptor hub = GetHub(hubName); - return hub == null ? null : _activator.Create(hub); - } - - public IEnumerable ResolveHubs() - { - return GetHubs(predicate: null).Select(hub => _activator.Create(hub)); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/DefaultParameterResolver.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/DefaultParameterResolver.cs deleted file mode 100644 index 5b31a57f9..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/DefaultParameterResolver.cs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.AspNet.SignalR.Json; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - public class DefaultParameterResolver : IParameterResolver - { - /// - /// Resolves a parameter value based on the provided object. - /// - /// Parameter descriptor. - /// Value to resolve the parameter value from. - /// The parameter value. - public virtual object ResolveParameter(ParameterDescriptor descriptor, IJsonValue value) - { - if (descriptor == null) - { - throw new ArgumentNullException("descriptor"); - } - - if (value == null) - { - throw new ArgumentNullException("value"); - } - - if (value.GetType() == descriptor.ParameterType) - { - return value; - } - - return value.ConvertTo(descriptor.ParameterType); - } - - /// - /// Resolves method parameter values based on provided objects. - /// - /// Method descriptor. - /// List of values to resolve parameter values from. - /// Array of parameter values. - public virtual IList ResolveMethodParameters(MethodDescriptor method, IList values) - { - if (method == null) - { - throw new ArgumentNullException("method"); - } - - return method.Parameters.Zip(values, ResolveParameter).ToArray(); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/Descriptors/Descriptor.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/Descriptors/Descriptor.cs deleted file mode 100644 index 750a70965..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/Descriptors/Descriptor.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -namespace Microsoft.AspNet.SignalR.Hubs -{ - public abstract class Descriptor - { - /// - /// Name of Descriptor. - /// - public virtual string Name { get; set; } - - /// - /// Flags whether the name was specified. - /// - public virtual bool NameSpecified { get; set; } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/Descriptors/HubDescriptor.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/Descriptors/HubDescriptor.cs deleted file mode 100644 index c4271eaef..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/Descriptors/HubDescriptor.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - /// - /// Holds information about a single hub. - /// - public class HubDescriptor : Descriptor - { - /// - /// Hub type. - /// - public virtual Type HubType { get; set; } - - public string CreateQualifiedName(string unqualifiedName) - { - return Name + "." + unqualifiedName; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/Descriptors/MethodDescriptor.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/Descriptors/MethodDescriptor.cs deleted file mode 100644 index 971f54f01..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/Descriptors/MethodDescriptor.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - /// - /// Holds information about a single hub method. - /// - public class MethodDescriptor : Descriptor - { - /// - /// The return type of this method. - /// - public virtual Type ReturnType { get; set; } - - /// - /// Hub descriptor object, target to this method. - /// - public virtual HubDescriptor Hub { get; set; } - - /// - /// Available method parameters. - /// - [SuppressMessage("Microsoft.Usage", "CA2227:CollectionPropertiesShouldBeReadOnly", Justification = "This is supposed to be mutable")] - public virtual IList Parameters { get; set; } - - /// - /// Method invocation delegate. - /// Takes a target hub and an array of invocation arguments as it's arguments. - /// - public virtual Func Invoker { get; set; } - - /// - /// Attributes attached to this method. - /// - public virtual IEnumerable Attributes { get; set; } - } -} - diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/Descriptors/NullMethodDescriptor.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/Descriptors/NullMethodDescriptor.cs deleted file mode 100644 index d1617dbfb..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/Descriptors/NullMethodDescriptor.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - public class NullMethodDescriptor : MethodDescriptor - { - private static readonly IEnumerable _attributes = new List(); - private static readonly IList _parameters = new List(); - - private string _methodName; - - public NullMethodDescriptor(string methodName) - { - _methodName = methodName; - } - - public override Func Invoker - { - get - { - return (emptyHub, emptyParameters) => - { - throw new InvalidOperationException(String.Format(CultureInfo.CurrentCulture, Resources.Error_MethodCouldNotBeResolved, _methodName)); - }; - } - } - - public override IList Parameters - { - get { return _parameters; } - } - - public override IEnumerable Attributes - { - get { return _attributes; } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/Descriptors/ParameterDescriptor.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/Descriptors/ParameterDescriptor.cs deleted file mode 100644 index 4e32d3a8b..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/Descriptors/ParameterDescriptor.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - /// - /// Holds information about a single hub method parameter. - /// - public class ParameterDescriptor - { - /// - /// Parameter name. - /// - public virtual string Name { get; set; } - - /// - /// Parameter type. - /// - public virtual Type ParameterType { get; set; } - } -} - diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/HubMethodDispatcher.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/HubMethodDispatcher.cs deleted file mode 100644 index c52b5e760..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/HubMethodDispatcher.cs +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Collections.Generic; -using System.Linq.Expressions; -using System.Reflection; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - internal class HubMethodDispatcher - { - private HubMethodExecutor _executor; - - public HubMethodDispatcher(MethodInfo methodInfo) - { - _executor = GetExecutor(methodInfo); - MethodInfo = methodInfo; - } - - private delegate object HubMethodExecutor(IHub hub, object[] parameters); - - private delegate void VoidHubMethodExecutor(IHub hub, object[] parameters); - - public MethodInfo MethodInfo { get; private set; } - - public object Execute(IHub hub, object[] parameters) - { - return _executor(hub, parameters); - } - - private static HubMethodExecutor GetExecutor(MethodInfo methodInfo) - { - // Parameters to executor - ParameterExpression hubParameter = Expression.Parameter(typeof(IHub), "hub"); - ParameterExpression parametersParameter = Expression.Parameter(typeof(object[]), "parameters"); - - // Build parameter list - List parameters = new List(); - ParameterInfo[] paramInfos = methodInfo.GetParameters(); - for (int i = 0; i < paramInfos.Length; i++) - { - ParameterInfo paramInfo = paramInfos[i]; - BinaryExpression valueObj = Expression.ArrayIndex(parametersParameter, Expression.Constant(i)); - UnaryExpression valueCast = Expression.Convert(valueObj, paramInfo.ParameterType); - - // valueCast is "(Ti) parameters[i]" - parameters.Add(valueCast); - } - - // Call method - UnaryExpression instanceCast = (!methodInfo.IsStatic) ? Expression.Convert(hubParameter, methodInfo.ReflectedType) : null; - MethodCallExpression methodCall = Expression.Call(instanceCast, methodInfo, parameters); - - // methodCall is "((TController) hub) method((T0) parameters[0], (T1) parameters[1], ...)" - // Create function - if (methodCall.Type == typeof(void)) - { - Expression lambda = Expression.Lambda(methodCall, hubParameter, parametersParameter); - VoidHubMethodExecutor voidExecutor = lambda.Compile(); - return WrapVoidAction(voidExecutor); - } - else - { - // must coerce methodCall to match HubMethodExecutor signature - UnaryExpression castMethodCall = Expression.Convert(methodCall, typeof(object)); - Expression lambda = Expression.Lambda(castMethodCall, hubParameter, parametersParameter); - return lambda.Compile(); - } - } - - private static HubMethodExecutor WrapVoidAction(VoidHubMethodExecutor executor) - { - return delegate(IHub hub, object[] parameters) - { - executor(hub, parameters); - return null; - }; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/IHubDescriptorProvider.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/IHubDescriptorProvider.cs deleted file mode 100644 index e439d0b92..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/IHubDescriptorProvider.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - /// - /// Describes hub descriptor provider, which provides information about available hubs. - /// - public interface IHubDescriptorProvider - { - /// - /// Retrieve all avaiable hubs. - /// - /// Collection of hub descriptors. - [SuppressMessage("Microsoft.Design", "CA1024:UsePropertiesWhereAppropriate", Justification = "This call might be expensive")] - IList GetHubs(); - - /// - /// Tries to retrieve hub with a given name. - /// - /// Name of the hub. - /// Retrieved descriptor object. - /// True, if hub has been found - bool TryGetHub(string hubName, out HubDescriptor descriptor); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/IHubManager.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/IHubManager.cs deleted file mode 100644 index 99da9b351..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/IHubManager.cs +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using Microsoft.AspNet.SignalR.Json; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - /// - /// Describes a hub manager - main point in the whole hub and method lookup process. - /// - public interface IHubManager - { - /// - /// Retrieves a single hub descriptor. - /// - /// Name of the hub. - /// Hub descriptor, if found. Null, otherwise. - HubDescriptor GetHub(string hubName); - - /// - /// Retrieves all available hubs matching the given predicate. - /// - /// List of hub descriptors. - IEnumerable GetHubs(Func predicate); - - /// - /// Resolves a given hub name to a concrete object. - /// - /// Name of the hub. - /// Hub implementation instance, if found. Null otherwise. - IHub ResolveHub(string hubName); - - /// - /// Resolves all available hubs to their concrete objects. - /// - /// List of hub instances. - IEnumerable ResolveHubs(); - - /// - /// Retrieves a method with a given name on a given hub. - /// - /// Name of the hub. - /// Name of the method to find. - /// Method parameters to match. - /// Descriptor of the method, if found. Null otherwise. - MethodDescriptor GetHubMethod(string hubName, string method, IList parameters); - - /// - /// Gets all methods available to call on a given hub. - /// - /// Name of the hub, - /// Optional predicate for filtering results. - /// List of available methods. - IEnumerable GetHubMethods(string hubName, Func predicate); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/IMethodDescriptorProvider.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/IMethodDescriptorProvider.cs deleted file mode 100644 index ae0700f36..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/IMethodDescriptorProvider.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using Microsoft.AspNet.SignalR.Json; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - /// - /// Describes a hub method provider that builds a collection of available methods on a given hub. - /// - public interface IMethodDescriptorProvider - { - /// - /// Retrieve all methods on a given hub. - /// - /// Hub descriptor object. - /// Available methods. - IEnumerable GetMethods(HubDescriptor hub); - - /// - /// Tries to retrieve a method. - /// - /// Hub descriptor object - /// Name of the method. - /// Descriptor of the method, if found. Null otherwise. - /// Method parameters to match. - /// True, if a method has been found. - [SuppressMessage("Microsoft.Design", "CA1021:AvoidOutParameters", MessageId = "2#", Justification = "This is a well known pattern for efficient lookup")] - bool TryGetMethod(HubDescriptor hub, string method, out MethodDescriptor descriptor, IList parameters); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/IParameterResolver.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/IParameterResolver.cs deleted file mode 100644 index e1ed36d61..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/IParameterResolver.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Collections.Generic; -using Microsoft.AspNet.SignalR.Json; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - /// - /// Describes a parameter resolver for resolving parameter-matching values based on provided information. - /// - public interface IParameterResolver - { - /// - /// Resolves method parameter values based on provided objects. - /// - /// Method descriptor. - /// List of values to resolve parameter values from. - /// Array of parameter values. - IList ResolveMethodParameters(MethodDescriptor method, IList values); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/ReflectedHubDescriptorProvider.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/ReflectedHubDescriptorProvider.cs deleted file mode 100644 index 49b1e1d44..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/ReflectedHubDescriptorProvider.cs +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - public class ReflectedHubDescriptorProvider : IHubDescriptorProvider - { - private readonly Lazy> _hubs; - private readonly Lazy _locator; - - public ReflectedHubDescriptorProvider(IDependencyResolver resolver) - { - _locator = new Lazy(resolver.Resolve); - _hubs = new Lazy>(BuildHubsCache); - } - - public IList GetHubs() - { - return _hubs.Value - .Select(kv => kv.Value) - .Distinct() - .ToList(); - } - - public bool TryGetHub(string hubName, out HubDescriptor descriptor) - { - return _hubs.Value.TryGetValue(hubName, out descriptor); - } - - protected IDictionary BuildHubsCache() - { - // Getting all IHub-implementing types that apply - var types = _locator.Value.GetAssemblies() - .SelectMany(GetTypesSafe) - .Where(IsHubType); - - // Building cache entries for each descriptor - // Each descriptor is stored in dictionary under a key - // that is it's name or the name provided by an attribute - var cacheEntries = types - .Select(type => new HubDescriptor - { - NameSpecified = (type.GetHubAttributeName() != null), - Name = type.GetHubName(), - HubType = type - }) - .ToDictionary(hub => hub.Name, - hub => hub, - StringComparer.OrdinalIgnoreCase); - - return cacheEntries; - } - - [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "If we throw then it's not a hub type")] - private static bool IsHubType(Type type) - { - try - { - return typeof(IHub).IsAssignableFrom(type) && - !type.IsAbstract && - (type.Attributes.HasFlag(TypeAttributes.Public) || - type.Attributes.HasFlag(TypeAttributes.NestedPublic)); - } - catch - { - return false; - } - } - - [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "If we throw then we have an empty type")] - private static IEnumerable GetTypesSafe(Assembly a) - { - try - { - return a.GetTypes(); - } - catch - { - return Enumerable.Empty(); - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/ReflectedMethodDescriptorProvider.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/ReflectedMethodDescriptorProvider.cs deleted file mode 100644 index db6b95756..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Lookup/ReflectedMethodDescriptorProvider.cs +++ /dev/null @@ -1,150 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Reflection; -using Microsoft.AspNet.SignalR.Json; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - public class ReflectedMethodDescriptorProvider : IMethodDescriptorProvider - { - private readonly ConcurrentDictionary>> _methods; - private readonly ConcurrentDictionary _executableMethods; - - public ReflectedMethodDescriptorProvider() - { - _methods = new ConcurrentDictionary>>(StringComparer.OrdinalIgnoreCase); - _executableMethods = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - } - - public IEnumerable GetMethods(HubDescriptor hub) - { - return FetchMethodsFor(hub) - .SelectMany(kv => kv.Value) - .ToList(); - } - - /// - /// Retrieves an existing dictionary of all available methods for a given hub from cache. - /// If cache entry does not exist - it is created automatically by BuildMethodCacheFor. - /// - /// - /// - private IDictionary> FetchMethodsFor(HubDescriptor hub) - { - return _methods.GetOrAdd( - hub.Name, - key => BuildMethodCacheFor(hub)); - } - - /// - /// Builds a dictionary of all possible methods on a given hub. - /// Single entry contains a collection of available overloads for a given method name (key). - /// This dictionary is being cached afterwards. - /// - /// Hub to build cache for - /// Dictionary of available methods - private static IDictionary> BuildMethodCacheFor(HubDescriptor hub) - { - return ReflectionHelper.GetExportedHubMethods(hub.HubType) - .GroupBy(GetMethodName, StringComparer.OrdinalIgnoreCase) - .ToDictionary(group => group.Key, - group => group.Select(oload => - new MethodDescriptor - { - ReturnType = oload.ReturnType, - Name = group.Key, - NameSpecified = (GetMethodAttributeName(oload) != null), - Invoker = new HubMethodDispatcher(oload).Execute, - Hub = hub, - Attributes = oload.GetCustomAttributes(typeof(Attribute), inherit: true).Cast(), - Parameters = oload.GetParameters() - .Select(p => new ParameterDescriptor - { - Name = p.Name, - ParameterType = p.ParameterType, - }) - .ToList() - }), - StringComparer.OrdinalIgnoreCase); - } - - /// - /// Searches the specified Hub for the specified . - /// - /// - /// In the case that there are multiple overloads of the specified , the parameter set helps determine exactly which instance of the overload should be resolved. - /// If there are multiple overloads found with the same number of matching parameters, none of the methods will be returned because it is not possible to determine which overload of the method was intended to be resolved. - /// - /// Hub to search for the specified on. - /// The method name to search for. - /// If successful, the that was resolved. - /// The set of parameters that will be used to help locate a specific overload of the specified . - /// True if the method matching the name/parameter set is found on the hub, otherwise false. - public bool TryGetMethod(HubDescriptor hub, string method, out MethodDescriptor descriptor, IList parameters) - { - string hubMethodKey = BuildHubExecutableMethodCacheKey(hub, method, parameters); - - if (!_executableMethods.TryGetValue(hubMethodKey, out descriptor)) - { - IEnumerable overloads; - - if (FetchMethodsFor(hub).TryGetValue(method, out overloads)) - { - var matches = overloads.Where(o => o.Matches(parameters)).ToList(); - - // If only one match is found, that is the "executable" version, otherwise none of the methods can be returned because we don't know which one was actually being targeted - descriptor = matches.Count == 1 ? matches[0] : null; - } - else - { - descriptor = null; - } - - // If an executable method was found, cache it for future lookups (NOTE: we don't cache null instances because it could be a surface area for DoS attack by supplying random method names to flood the cache) - if (descriptor != null) - { - _executableMethods.TryAdd(hubMethodKey, descriptor); - } - } - - return descriptor != null; - } - - private static string BuildHubExecutableMethodCacheKey(HubDescriptor hub, string method, IList parameters) - { - string normalizedParameterCountKeyPart; - - if (parameters != null) - { - normalizedParameterCountKeyPart = parameters.Count.ToString(CultureInfo.InvariantCulture); - } - else - { - // NOTE: we normalize a null parameter array to be the same as an empty (i.e. Length == 0) parameter array - normalizedParameterCountKeyPart = "0"; - } - - // NOTE: we always normalize to all uppercase since method names are case insensitive and could theoretically come in diff. variations per call - string normalizedMethodName = method.ToUpperInvariant(); - - string methodKey = hub.Name + "::" + normalizedMethodName + "(" + normalizedParameterCountKeyPart + ")"; - - return methodKey; - } - - private static string GetMethodName(MethodInfo method) - { - return GetMethodAttributeName(method) ?? method.Name; - } - - private static string GetMethodAttributeName(MethodInfo method) - { - return ReflectionHelper.GetAttributeValue(method, a => a.MethodName); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/NullClientProxy.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/NullClientProxy.cs deleted file mode 100644 index 4d944017e..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/NullClientProxy.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Dynamic; -using System.Globalization; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - internal class NullClientProxy : DynamicObject - { - public override bool TryGetMember(GetMemberBinder binder, out object result) - { - throw new InvalidOperationException(String.Format(CultureInfo.CurrentCulture, Resources.Error_UsingHubInstanceNotCreatedUnsupported)); - } - - public override bool TryInvokeMember(InvokeMemberBinder binder, object[] args, out object result) - { - throw new InvalidOperationException(String.Format(CultureInfo.CurrentCulture, Resources.Error_UsingHubInstanceNotCreatedUnsupported)); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/NullJavaScriptMinifier.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/NullJavaScriptMinifier.cs deleted file mode 100644 index 932d38d0a..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/NullJavaScriptMinifier.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Diagnostics.CodeAnalysis; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - public class NullJavaScriptMinifier : IJavaScriptMinifier - { - [SuppressMessage("Microsoft.Security", "CA2104:DoNotDeclareReadOnlyMutableReferenceTypes", Justification = "This is a singleton")] - public static readonly NullJavaScriptMinifier Instance = new NullJavaScriptMinifier(); - - public string Minify(string source) - { - return source; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/Auth/AuthorizeModule.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/Auth/AuthorizeModule.cs deleted file mode 100644 index 046e45cba..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/Auth/AuthorizeModule.cs +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Threading.Tasks; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - /// - /// This module is added the the HubPipeline by default. - /// - /// Hub level attributes that implement such as are applied to determine - /// whether to allow potential clients to receive messages sent from that hub using a or a - /// All applicable hub attributes must allow hub connection for the connection to be authorized. - /// - /// Hub and method level attributes that implement such as are applied - /// to determine whether to allow callers to invoke hub methods. - /// All applicable hub level AND method level attributes must allow hub method invocation for the invocation to be authorized. - /// - /// Optionally, this module may be instantiated with and - /// authorizers that will be applied globally to all hubs and hub methods. - /// - public class AuthorizeModule : HubPipelineModule - { - // Global authorizers - private readonly IAuthorizeHubConnection _globalConnectionAuthorizer; - private readonly IAuthorizeHubMethodInvocation _globalInvocationAuthorizer; - - // Attribute authorizer caches - private readonly ConcurrentDictionary> _connectionAuthorizersCache; - private readonly ConcurrentDictionary> _classInvocationAuthorizersCache; - private readonly ConcurrentDictionary> _methodInvocationAuthorizersCache; - - // By default, this module does not include any authorizers that are applied globally. - // This module will always apply authorizers attached to hubs or hub methods - public AuthorizeModule() - : this(globalConnectionAuthorizer: null, globalInvocationAuthorizer: null) - { - } - - public AuthorizeModule(IAuthorizeHubConnection globalConnectionAuthorizer, IAuthorizeHubMethodInvocation globalInvocationAuthorizer) - { - // Set global authorizers - _globalConnectionAuthorizer = globalConnectionAuthorizer; - _globalInvocationAuthorizer = globalInvocationAuthorizer; - - // Initialize attribute authorizer caches - _connectionAuthorizersCache = new ConcurrentDictionary>(); - _classInvocationAuthorizersCache = new ConcurrentDictionary>(); - _methodInvocationAuthorizersCache = new ConcurrentDictionary>(); - } - - public override Func BuildAuthorizeConnect(Func authorizeConnect) - { - return base.BuildAuthorizeConnect((hubDescriptor, request) => - { - // Execute custom modules first and short circuit if any deny authorization. - if (!authorizeConnect(hubDescriptor, request)) - { - return false; - } - - // Execute the global hub connection authorizer if there is one next and short circuit if it denies authorization. - if (_globalConnectionAuthorizer != null && !_globalConnectionAuthorizer.AuthorizeHubConnection(hubDescriptor, request)) - { - return false; - } - - // Get hub attributes implementing IAuthorizeHubConnection from the cache - // If the attributes do not exist in the cache, retrieve them using reflection and add them to the cache - var attributeAuthorizers = _connectionAuthorizersCache.GetOrAdd(hubDescriptor.HubType, - hubType => hubType.GetCustomAttributes(typeof(IAuthorizeHubConnection), inherit: true).Cast()); - - // Every attribute (if any) implementing IAuthorizeHubConnection attached to the relevant hub MUST allow the connection - return attributeAuthorizers.All(a => a.AuthorizeHubConnection(hubDescriptor, request)); - }); - } - - public override Func> BuildIncoming(Func> invoke) - { - return base.BuildIncoming(context => - { - // Execute the global method invocation authorizer if there is one and short circuit if it denies authorization. - if (_globalInvocationAuthorizer == null || _globalInvocationAuthorizer.AuthorizeHubMethodInvocation(context, appliesToMethod: false)) - { - // Get hub attributes implementing IAuthorizeHubMethodInvocation from the cache - // If the attributes do not exist in the cache, retrieve them using reflection and add them to the cache - var classLevelAuthorizers = _classInvocationAuthorizersCache.GetOrAdd(context.Hub.GetType(), - hubType => hubType.GetCustomAttributes(typeof(IAuthorizeHubMethodInvocation), inherit: true).Cast()); - - // Execute all hub level authorizers and short circuit if ANY deny authorization. - if (classLevelAuthorizers.All(a => a.AuthorizeHubMethodInvocation(context, appliesToMethod: false))) - { - // If the MethodDescriptor is a NullMethodDescriptor, we don't want to cache it since a new one is created - // for each invocation with an invalid method name. #1801 - if (context.MethodDescriptor is NullMethodDescriptor) - { - return invoke(context); - } - - // Get method attributes implementing IAuthorizeHubMethodInvocation from the cache - // If the attributes do not exist in the cache, retrieve them from the MethodDescriptor and add them to the cache - var methodLevelAuthorizers = _methodInvocationAuthorizersCache.GetOrAdd(context.MethodDescriptor, - methodDescriptor => methodDescriptor.Attributes.OfType()); - - // Execute all method level authorizers. If ALL provide authorization, continue executing the invocation pipeline. - if (methodLevelAuthorizers.All(a => a.AuthorizeHubMethodInvocation(context, appliesToMethod: true))) - { - return invoke(context); - } - } - } - - // Send error back to the client - return TaskAsyncHelper.FromError( - new NotAuthorizedException(String.Format(CultureInfo.CurrentCulture, Resources.Error_CallerNotAuthorizedToInvokeMethodOn, - context.MethodDescriptor.Name, - context.MethodDescriptor.Hub.Name))); - }); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/Auth/IAuthorizeHubConnection.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/Auth/IAuthorizeHubConnection.cs deleted file mode 100644 index c89e60014..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/Auth/IAuthorizeHubConnection.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -namespace Microsoft.AspNet.SignalR.Hubs -{ - /// - /// Interface to be implemented by s that can authorize client to connect to a . - /// - public interface IAuthorizeHubConnection - { - /// - /// Given a , determine whether client is authorized to connect to . - /// - /// Description of the hub client is attempting to connect to. - /// The connection request from the client. - /// true if the caller is authorized to connect to the hub; otherwise, false. - bool AuthorizeHubConnection(HubDescriptor hubDescriptor, IRequest request); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/Auth/IAuthorizeHubMethodInvocation.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/Auth/IAuthorizeHubMethodInvocation.cs deleted file mode 100644 index 4d1eee752..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/Auth/IAuthorizeHubMethodInvocation.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -namespace Microsoft.AspNet.SignalR.Hubs -{ - /// - /// Interface to be implemented by s that can authorize the invocation of methods. - /// - public interface IAuthorizeHubMethodInvocation - { - /// - /// Given a , determine whether client is authorized to invoke the method. - /// - /// An providing details regarding the method invocation. - /// Indicates whether the interface instance is an attribute applied directly to a method. - /// true if the caller is authorized to invoke the method; otherwise, false. - bool AuthorizeHubMethodInvocation(IHubIncomingInvokerContext hubIncomingInvokerContext, bool appliesToMethod); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/Auth/NotAuthorizedException.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/Auth/NotAuthorizedException.cs deleted file mode 100644 index 10d32b649..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/Auth/NotAuthorizedException.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - [Serializable] - public class NotAuthorizedException : Exception - { - public NotAuthorizedException() { } - public NotAuthorizedException(string message) : base(message) { } - public NotAuthorizedException(string message, Exception inner) : base(message, inner) { } - protected NotAuthorizedException( - System.Runtime.Serialization.SerializationInfo info, - System.Runtime.Serialization.StreamingContext context) - : base(info, context) { } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/HubInvokerContext.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/HubInvokerContext.cs deleted file mode 100644 index eea40b052..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/HubInvokerContext.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Collections.Generic; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - internal class HubInvokerContext : IHubIncomingInvokerContext - { - public HubInvokerContext(IHub hub, StateChangeTracker tracker, MethodDescriptor methodDescriptor, IList args) - { - Hub = hub; - MethodDescriptor = methodDescriptor; - Args = args; - StateTracker = tracker; - } - - public IHub Hub - { - get; - private set; - } - - public MethodDescriptor MethodDescriptor - { - get; - private set; - } - - public IList Args - { - get; - private set; - } - - - public StateChangeTracker StateTracker - { - get; - private set; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/HubOutgoingInvokerContext.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/HubOutgoingInvokerContext.cs deleted file mode 100644 index 8de9851f7..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/HubOutgoingInvokerContext.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Collections.Generic; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - internal class HubOutgoingInvokerContext : IHubOutgoingInvokerContext - { - public HubOutgoingInvokerContext(IConnection connection, string signal, ClientHubInvocation invocation, IList excludedSignals) - { - Connection = connection; - Signal = signal; - Invocation = invocation; - ExcludedSignals = excludedSignals; - } - - public IConnection Connection - { - get; - private set; - } - - public ClientHubInvocation Invocation - { - get; - private set; - } - - public string Signal - { - get; - private set; - } - - public IList ExcludedSignals - { - get; - private set; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/HubPipeline.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/HubPipeline.cs deleted file mode 100644 index 9cf6eccd4..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/HubPipeline.cs +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Threading.Tasks; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - internal class HubPipeline : IHubPipeline, IHubPipelineInvoker - { - private readonly Stack _modules; - private readonly Lazy _pipeline; - - public HubPipeline() - { - _modules = new Stack(); - _pipeline = new Lazy(() => new ComposedPipeline(_modules)); - } - - public IHubPipeline AddModule(IHubPipelineModule pipelineModule) - { - if (_pipeline.IsValueCreated) - { - throw new InvalidOperationException(String.Format(CultureInfo.CurrentCulture, Resources.Error_UnableToAddModulePiplineAlreadyInvoked)); - } - _modules.Push(pipelineModule); - return this; - } - - private ComposedPipeline Pipeline - { - get { return _pipeline.Value; } - } - - public Task Invoke(IHubIncomingInvokerContext context) - { - return Pipeline.Invoke(context); - } - - public Task Connect(IHub hub) - { - return Pipeline.Connect(hub); - } - - public Task Reconnect(IHub hub) - { - return Pipeline.Reconnect(hub); - } - - public Task Disconnect(IHub hub) - { - return Pipeline.Disconnect(hub); - } - - public bool AuthorizeConnect(HubDescriptor hubDescriptor, IRequest request) - { - return Pipeline.AuthorizeConnect(hubDescriptor, request); - } - - public IList RejoiningGroups(HubDescriptor hubDescriptor, IRequest request, IList groups) - { - return Pipeline.RejoiningGroups(hubDescriptor, request, groups); - } - - public Task Send(IHubOutgoingInvokerContext context) - { - return Pipeline.Send(context); - } - - private class ComposedPipeline - { - - public Func> Invoke; - public Func Connect; - public Func Reconnect; - public Func Disconnect; - public Func AuthorizeConnect; - public Func, IList> RejoiningGroups; - public Func Send; - - public ComposedPipeline(Stack modules) - { - // This wouldn't look nearly as gnarly if C# had better type inference, but now we don't need the ComposedModule or PassThroughModule. - Invoke = Compose>>(modules, (m, f) => m.BuildIncoming(f))(HubDispatcher.Incoming); - Connect = Compose>(modules, (m, f) => m.BuildConnect(f))(HubDispatcher.Connect); - Reconnect = Compose>(modules, (m, f) => m.BuildReconnect(f))(HubDispatcher.Reconnect); - Disconnect = Compose>(modules, (m, f) => m.BuildDisconnect(f))(HubDispatcher.Disconnect); - AuthorizeConnect = Compose>(modules, (m, f) => m.BuildAuthorizeConnect(f))((h, r) => true); - RejoiningGroups = Compose, IList>>(modules, (m, f) => m.BuildRejoiningGroups(f))((h, r, g) => g); - Send = Compose>(modules, (m, f) => m.BuildOutgoing(f))(HubDispatcher.Outgoing); - } - - // IHubPipelineModule could be turned into a second generic parameter, but it would make the above invocations even longer than they currently are. - private static Func Compose(IEnumerable modules, Func method) - { - // Notice we are reversing and aggregating in one step. (Function composition is associative) - return modules.Aggregate>(x => x, (a, b) => (x => method(b, a(x)))); - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/HubPipelineExtensions.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/HubPipelineExtensions.cs deleted file mode 100644 index 0eaac77b8..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/HubPipelineExtensions.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using Microsoft.AspNet.SignalR.Hubs; - -namespace Microsoft.AspNet.SignalR -{ - public static class HubPipelineExtensions - { - /// - /// Requiring Authentication adds an to the with - /// and authorizers that will be applied globally to all hubs and hub methods. - /// These authorizers require that the 's - /// IsAuthenticated for any clients that invoke server-side hub methods or receive client-side hub method invocations. - /// - /// The to which the will be added. - public static void RequireAuthentication(this IHubPipeline pipeline) - { - if (pipeline == null) - { - throw new ArgumentNullException("pipeline"); - } - - var authorizer = new AuthorizeAttribute(); - pipeline.AddModule(new AuthorizeModule(globalConnectionAuthorizer: authorizer, globalInvocationAuthorizer: authorizer)); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/HubPipelineModule.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/HubPipelineModule.cs deleted file mode 100644 index d914c34f0..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/HubPipelineModule.cs +++ /dev/null @@ -1,311 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - /// - /// Common base class to simplify the implementation of IHubPipelineModules. - /// A module can intercept and customize various stages of hub processing such as connecting, reconnecting, disconnecting, - /// invoking server-side hub methods, invoking client-side hub methods, authorizing hub clients and rejoining hub groups. - /// A module can be activated by calling . - /// The combined modules added to the are invoked via the - /// interface. - /// - public abstract class HubPipelineModule : IHubPipelineModule - { - /// - /// Wraps a function that invokes a server-side hub method. Even if a client has not been authorized to connect - /// to a hub, it will still be authorized to invoke server-side methods on that hub unless it is prevented in - /// by not executing the invoke parameter. - /// - /// A function that invokes a server-side hub method. - /// A wrapped function that invokes a server-side hub method. - public virtual Func> BuildIncoming(Func> invoke) - { - return context => - { - if (OnBeforeIncoming(context)) - { - return invoke(context).OrEmpty() - .Then(result => OnAfterIncoming(result, context)) - .Catch(ex => OnIncomingError(ex, context)); - } - - return TaskAsyncHelper.FromResult(null); - }; - } - - /// - /// Wraps a function that is called when a client connects to the for each - /// the client connects to. By default, this results in the 's - /// OnConnected method being invoked. - /// - /// A function to be called when a client connects to a hub. - /// A wrapped function to be called when a client connects to a hub. - public virtual Func BuildConnect(Func connect) - { - return hub => - { - if (OnBeforeConnect(hub)) - { - return connect(hub).OrEmpty().Then(h => OnAfterConnect(h), hub); - } - - return TaskAsyncHelper.Empty; - }; - } - - /// - /// Wraps a function that is called when a client reconnects to the for each - /// the client connects to. By default, this results in the 's - /// OnReconnected method being invoked. - /// - /// A function to be called when a client reconnects to a hub. - /// A wrapped function to be called when a client reconnects to a hub. - public virtual Func BuildReconnect(Func reconnect) - { - return (hub) => - { - if (OnBeforeReconnect(hub)) - { - return reconnect(hub).OrEmpty().Then(h => OnAfterReconnect(h), hub); - } - return TaskAsyncHelper.Empty; - }; - } - - /// - /// Wraps a function that is called when a client disconnects from the for each - /// the client was connected to. By default, this results in the 's - /// OnDisconnected method being invoked. - /// - /// A function to be called when a client disconnects from a hub. - /// A wrapped function to be called when a client disconnects from a hub. - public virtual Func BuildDisconnect(Func disconnect) - { - return hub => - { - if (OnBeforeDisconnect(hub)) - { - return disconnect(hub).OrEmpty().Then(h => OnAfterDisconnect(h), hub); - } - - return TaskAsyncHelper.Empty; - }; - } - - /// - /// Wraps a function to be called before a client subscribes to signals belonging to the hub described by the - /// . By default, the will look for attributes on the - /// to help determine if the client is authorized to subscribe to method invocations for the - /// described hub. - /// The function returns true if the client is authorized to subscribe to client-side hub method - /// invocations; false, otherwise. - /// - /// - /// A function that dictates whether or not the client is authorized to connect to the described Hub. - /// - /// - /// A wrapped function that dictates whether or not the client is authorized to connect to the described Hub. - /// - public virtual Func BuildAuthorizeConnect(Func authorizeConnect) - { - return (hubDescriptor, request) => - { - if (OnBeforeAuthorizeConnect(hubDescriptor, request)) - { - return authorizeConnect(hubDescriptor, request); - } - return false; - }; - } - - /// - /// Wraps a function that determines which of the groups belonging to the hub described by the - /// the client should be allowed to rejoin. - /// By default, clients will rejoin all the groups they were in prior to reconnecting. - /// - /// A function that determines which groups the client should be allowed to rejoin. - /// A wrapped function that determines which groups the client should be allowed to rejoin. - public virtual Func, IList> BuildRejoiningGroups(Func, IList> rejoiningGroups) - { - return rejoiningGroups; - } - - /// - /// Wraps a function that invokes a client-side hub method. - /// - /// A function that invokes a client-side hub method. - /// A wrapped function that invokes a client-side hub method. - public virtual Func BuildOutgoing(Func send) - { - return context => - { - if (OnBeforeOutgoing(context)) - { - return send(context).OrEmpty().Then(ctx => OnAfterOutgoing(ctx), context); - } - - return TaskAsyncHelper.Empty; - }; - } - - /// - /// This method is called before the AuthorizeConnect components of any modules added later to the - /// are executed. If this returns false, then those later-added modules will not run and the client will not be allowed - /// to subscribe to client-side invocations of methods belonging to the hub defined by the . - /// - /// A description of the hub the client is trying to subscribe to. - /// The connect request of the client trying to subscribe to the hub. - /// true, if the client is authorized to connect to the hub, false otherwise. - protected virtual bool OnBeforeAuthorizeConnect(HubDescriptor hubDescriptor, IRequest request) - { - return true; - } - - /// - /// This method is called before the connect components of any modules added later to the are - /// executed. If this returns false, then those later-added modules and the method will - /// not be run. - /// - /// The hub the client has connected to. - /// - /// true, if the connect components of later added modules and the method should be executed; - /// false, otherwise. - /// - protected virtual bool OnBeforeConnect(IHub hub) - { - return true; - } - - /// - /// This method is called after the connect components of any modules added later to the are - /// executed and after is executed, if at all. - /// - /// The hub the client has connected to. - protected virtual void OnAfterConnect(IHub hub) - { - - } - - /// - /// This method is called before the reconnect components of any modules added later to the are - /// executed. If this returns false, then those later-added modules and the method will - /// not be run. - /// - /// The hub the client has reconnected to. - /// - /// true, if the reconnect components of later added modules and the method should be executed; - /// false, otherwise. - /// - protected virtual bool OnBeforeReconnect(IHub hub) - { - return true; - } - - /// - /// This method is called after the reconnect components of any modules added later to the are - /// executed and after is executed, if at all. - /// - /// The hub the client has reconnected to. - protected virtual void OnAfterReconnect(IHub hub) - { - - } - - /// - /// This method is called before the outgoing components of any modules added later to the are - /// executed. If this returns false, then those later-added modules and the client-side hub method invocation(s) will not - /// be executed. - /// - /// A description of the client-side hub method invocation. - /// - /// true, if the outgoing components of later added modules and the client-side hub method invocation(s) should be executed; - /// false, otherwise. - /// - protected virtual bool OnBeforeOutgoing(IHubOutgoingInvokerContext context) - { - return true; - } - - /// - /// This method is called after the outgoing components of any modules added later to the are - /// executed. This does not mean that all the clients have received the hub method invocation, but it does indicate indicate - /// a hub invocation message has successfully been published to a message bus. - /// - /// A description of the client-side hub method invocation. - protected virtual void OnAfterOutgoing(IHubOutgoingInvokerContext context) - { - - } - - /// - /// This method is called before the disconnect components of any modules added later to the are - /// executed. If this returns false, then those later-added modules and the method will - /// not be run. - /// - /// The hub the client has disconnected from. - /// - /// true, if the disconnect components of later added modules and the method should be executed; - /// false, otherwise. - /// - protected virtual bool OnBeforeDisconnect(IHub hub) - { - return true; - } - - /// - /// This method is called after the disconnect components of any modules added later to the are - /// executed and after is executed, if at all. - /// - /// The hub the client has disconnected from. - protected virtual void OnAfterDisconnect(IHub hub) - { - - } - - /// - /// This method is called before the incoming components of any modules added later to the are - /// executed. If this returns false, then those later-added modules and the server-side hub method invocation will not - /// be executed. Even if a client has not been authorized to connect to a hub, it will still be authorized to invoke - /// server-side methods on that hub unless it is prevented in by not - /// executing the invoke parameter or prevented in by returning false. - /// - /// A description of the server-side hub method invocation. - /// - /// true, if the incoming components of later added modules and the server-side hub method invocation should be executed; - /// false, otherwise. - /// - protected virtual bool OnBeforeIncoming(IHubIncomingInvokerContext context) - { - return true; - } - - /// - /// This method is called after the incoming components of any modules added later to the - /// and the server-side hub method have completed execution. - /// - /// The return value of the server-side hub method - /// A description of the server-side hub method invocation. - /// The possibly new or updated return value of the server-side hub method - protected virtual object OnAfterIncoming(object result, IHubIncomingInvokerContext context) - { - return result; - } - - /// - /// This is called when an uncaught exception is thrown by a server-side hub method or the incoming component of a - /// module added later to the . Observing the exception using this method will not prevent - /// it from bubbling up to other modules. - /// - /// The exception that was thrown during the server-side invocation. - /// A description of the server-side hub method invocation. - protected virtual void OnIncomingError(Exception ex, IHubIncomingInvokerContext context) - { - - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/IHubIncomingInvokerContext.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/IHubIncomingInvokerContext.cs deleted file mode 100644 index a57d92b36..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/IHubIncomingInvokerContext.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - /// - /// A description of a server-side hub method invocation originating from a client. - /// - public interface IHubIncomingInvokerContext - { - /// - /// A hub instance that contains the invoked method as a member. - /// - IHub Hub { get; } - - /// - /// A description of the method being invoked by the client. - /// - MethodDescriptor MethodDescriptor { get; } - - /// - /// The arguments to be passed to the invoked method. - /// - [SuppressMessage("Microsoft.Performance", "CA1819:PropertiesShouldNotReturnArrays", Justification = "This represents an ordered list of parameter values")] - IList Args { get; } - - /// - /// A key-value store representing the hub state on the client at the time of the invocation. - /// - StateChangeTracker StateTracker { get; } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/IHubOutgoingInvokerContext.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/IHubOutgoingInvokerContext.cs deleted file mode 100644 index 9f69af582..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/IHubOutgoingInvokerContext.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Collections.Generic; -namespace Microsoft.AspNet.SignalR.Hubs -{ - /// - /// A description of a client-side hub method invocation originating from the server. - /// - public interface IHubOutgoingInvokerContext - { - /// - /// The , if any, corresponding to the client that invoked the server-side hub method - /// that is invoking the client-side hub method. - /// - IConnection Connection { get; } - - /// - /// A description of the method call to be made on the client. - /// - ClientHubInvocation Invocation { get; } - - /// - /// The signal (ConnectionId, hub type name or hub type name + "." + group name) belonging to clients that - /// receive the method invocation. - /// - string Signal { get; } - - /// - /// The signals (ConnectionId, hub type name or hub type name + "." + group name) belonging to clients that should - /// not receive the method invocation regardless of the . - /// - IList ExcludedSignals { get; } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/IHubPipeline.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/IHubPipeline.cs deleted file mode 100644 index d0f4c58eb..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/IHubPipeline.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -namespace Microsoft.AspNet.SignalR.Hubs -{ - /// - /// A collection of modules that can intercept and customize various stages of hub processing such as connecting, - /// reconnecting, disconnecting, invoking server-side hub methods, invoking client-side hub methods, authorizing - /// hub clients and rejoining hub groups. - /// - public interface IHubPipeline - { - /// - /// Adds an to the hub pipeline. Modules added to the pipeline first will wrap - /// modules that are added to the pipeline later. All modules must be added to the pipeline before any methods - /// on the are invoked. - /// - /// - /// A module that may intercept and customize various stages of hub processing such as connecting, - /// reconnecting, disconnecting, invoking server-side hub methods, invoking client-side hub methods, authorizing - /// hub clients and rejoining hub groups. - /// - /// - /// The itself with the newly added module allowing - /// calls to be chained. - /// This method mutates the pipeline it is invoked on so it is not necessary to store its result. - /// - IHubPipeline AddModule(IHubPipelineModule pipelineModule); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/IHubPipelineInvoker.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/IHubPipelineInvoker.cs deleted file mode 100644 index 499e9c972..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/IHubPipelineInvoker.cs +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - /// - /// Implementations of this interface are responsible for executing operation required to complete various stages - /// hub processing such as connecting, reconnecting, disconnecting, invoking server-side hub methods, invoking - /// client-side hub methods, authorizing hub clients and rejoining hub groups. - /// - public interface IHubPipelineInvoker - { - /// - /// Invokes a server-side hub method. - /// - /// A description of the server-side hub method invocation. - /// An asynchronous operation giving the return value of the server-side hub method invocation. - Task Invoke(IHubIncomingInvokerContext context); - - /// - /// Invokes a client-side hub method. - /// - /// A description of the client-side hub method invocation. - Task Send(IHubOutgoingInvokerContext context); - - /// - /// To be called when a client connects to the for each the client - /// connects to. By default, this results in the 's OnConnected method being invoked. - /// - /// A the client is connected to. - Task Connect(IHub hub); - - /// - /// To be called when a client reconnects to the for each the client - /// connects to. By default, this results in the 's OnReconnected method being invoked. - /// - /// A the client is reconnected to. - Task Reconnect(IHub hub); - - /// - /// To be called when a client disconnects from the for each the client - /// was connected to. By default, this results in the 's OnDisconnected method being invoked. - /// - /// A the client was disconnected from. - Task Disconnect(IHub hub); - - /// - /// To be called before a client subscribes to signals belonging to the hub described by the . - /// By default, the will look for attributes on the to help determine if - /// the client is authorized to subscribe to method invocations for the described hub. - /// - /// A description of the hub the client is attempting to connect to. - /// - /// The connect request being made by the client which should include the client's - /// User. - /// - /// true, if the client is authorized to subscribe to client-side hub method invocations; false, otherwise. - bool AuthorizeConnect(HubDescriptor hubDescriptor, IRequest request); - - /// - /// This method determines which of the groups belonging to the hub described by the the client should be - /// allowed to rejoin. - /// By default, clients that are reconnecting to the server will be removed from all groups they may have previously been a member of, - /// because untrusted clients may claim to be a member of groups they were never authorized to join. - /// - /// A description of the hub for which the client is attempting to rejoin groups. - /// The reconnect request being made by the client that is attempting to rejoin groups. - /// - /// The list of groups belonging to the relevant hub that the client claims to have been a member of before the reconnect. - /// - /// A list of groups the client is allowed to rejoin. - IList RejoiningGroups(HubDescriptor hubDescriptor, IRequest request, IList groups); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/IHubPipelineModule.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/IHubPipelineModule.cs deleted file mode 100644 index 79a01717c..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/Pipeline/IHubPipelineModule.cs +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - /// - /// An can intercept and customize various stages of hub processing such as connecting, - /// reconnecting, disconnecting, invoking server-side hub methods, invoking client-side hub methods, authorizing hub - /// clients and rejoining hub groups. - /// Modules can be be activated by calling . - /// The combined modules added to the are invoked via the - /// interface. - /// - public interface IHubPipelineModule - { - /// - /// Wraps a function that invokes a server-side hub method. Even if a client has not been authorized to connect - /// to a hub, it will still be authorized to invoke server-side methods on that hub unless it is prevented in - /// by not executing the invoke parameter. - /// - /// A function that invokes a server-side hub method. - /// A wrapped function that invokes a server-side hub method. - Func> BuildIncoming(Func> invoke); - - /// - /// Wraps a function that invokes a client-side hub method. - /// - /// A function that invokes a client-side hub method. - /// A wrapped function that invokes a client-side hub method. - Func BuildOutgoing(Func send); - - /// - /// Wraps a function that is called when a client connects to the for each - /// the client connects to. By default, this results in the 's - /// OnConnected method being invoked. - /// - /// A function to be called when a client connects to a hub. - /// A wrapped function to be called when a client connects to a hub. - Func BuildConnect(Func connect); - - /// - /// Wraps a function that is called when a client reconnects to the for each - /// the client connects to. By default, this results in the 's - /// OnReconnected method being invoked. - /// - /// A function to be called when a client reconnects to a hub. - /// A wrapped function to be called when a client reconnects to a hub. - Func BuildReconnect(Func reconnect); - - /// - /// Wraps a function that is called when a client disconnects from the for each - /// the client was connected to. By default, this results in the 's - /// OnDisconnected method being invoked. - /// - /// A function to be called when a client disconnects from a hub. - /// A wrapped function to be called when a client disconnects from a hub. - Func BuildDisconnect(Func disconnect); - - /// - /// Wraps a function to be called before a client subscribes to signals belonging to the hub described by the - /// . By default, the will look for attributes on the - /// to help determine if the client is authorized to subscribe to method invocations for the - /// described hub. - /// The function returns true if the client is authorized to subscribe to client-side hub method - /// invocations; false, otherwise. - /// - /// - /// A function that dictates whether or not the client is authorized to connect to the described Hub. - /// - /// - /// A wrapped function that dictates whether or not the client is authorized to connect to the described Hub. - /// - Func BuildAuthorizeConnect(Func authorizeConnect); - - /// - /// Wraps a function that determines which of the groups belonging to the hub described by the - /// the client should be allowed to rejoin. - /// By default, clients will rejoin all the groups they were in prior to reconnecting. - /// - /// A function that determines which groups the client should be allowed to rejoin. - /// A wrapped function that determines which groups the client should be allowed to rejoin. - Func, IList> BuildRejoiningGroups(Func, IList> rejoiningGroups); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/ReflectionHelper.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/ReflectionHelper.cs deleted file mode 100644 index 6819ed97a..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/ReflectionHelper.cs +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - public static class ReflectionHelper - { - private static readonly Type[] _excludeTypes = new[] { typeof(Hub), typeof(object) }; - private static readonly Type[] _excludeInterfaces = new[] { typeof(IHub), typeof(IDisposable) }; - - public static IEnumerable GetExportedHubMethods(Type type) - { - if (!typeof(IHub).IsAssignableFrom(type)) - { - return Enumerable.Empty(); - } - - var methods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance); - var allInterfaceMethods = _excludeInterfaces.SelectMany(i => GetInterfaceMethods(type, i)); - - return methods.Except(allInterfaceMethods).Where(IsValidHubMethod); - - } - - private static bool IsValidHubMethod(MethodInfo methodInfo) - { - return !(_excludeTypes.Contains(methodInfo.GetBaseDefinition().DeclaringType) || - methodInfo.IsSpecialName); - } - - private static IEnumerable GetInterfaceMethods(Type type, Type iface) - { - if (!iface.IsAssignableFrom(type)) - { - return Enumerable.Empty(); - } - - return type.GetInterfaceMap(iface).TargetMethods; - } - - public static TResult GetAttributeValue(ICustomAttributeProvider source, Func valueGetter) - where TAttribute : Attribute - { - if (source == null) - { - throw new ArgumentNullException("source"); - } - - if (valueGetter == null) - { - throw new ArgumentNullException("valueGetter"); - } - - var attributes = source.GetCustomAttributes(typeof(TAttribute), false) - .Cast() - .ToList(); - if (attributes.Any()) - { - return valueGetter(attributes[0]); - } - return default(TResult); - } - - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/SignalProxy.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/SignalProxy.cs deleted file mode 100644 index 1a54997ef..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/SignalProxy.cs +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Dynamic; -using System.Threading.Tasks; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - public abstract class SignalProxy : DynamicObject, IClientProxy - { - private readonly IList _exclude; - private readonly string _prefix; - - protected SignalProxy(Func, Task> send, string signal, string hubName, string prefix, IList exclude) - { - Send = send; - Signal = signal; - HubName = hubName; - _prefix = prefix; - _exclude = exclude; - } - - protected Func, Task> Send { get; private set; } - protected string Signal { get; private set; } - protected string HubName { get; private set; } - - public override bool TryGetMember(GetMemberBinder binder, out object result) - { - result = null; - return false; - } - - [SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "0", Justification = "The compiler generates calls to invoke this")] - public override bool TryInvokeMember(InvokeMemberBinder binder, object[] args, out object result) - { - result = Invoke(binder.Name, args); - return true; - } - - public Task Invoke(string method, params object[] args) - { - var invocation = GetInvocationData(method, args); - - string signal = _prefix + HubName + "." + Signal; - - return Send(signal, invocation, _exclude); - } - - protected virtual ClientHubInvocation GetInvocationData(string method, object[] args) - { - return new ClientHubInvocation - { - Hub = HubName, - Method = method, - Args = args, - Target = Signal - }; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/StateChangeTracker.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/StateChangeTracker.cs deleted file mode 100644 index 5c7075e31..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/StateChangeTracker.cs +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - /// - /// A change tracking dictionary. - /// - public class StateChangeTracker - { - private readonly IDictionary _values; - // Keep track of everyting that changed since creation - private readonly IDictionary _oldValues = new Dictionary(StringComparer.OrdinalIgnoreCase); - - public StateChangeTracker() - { - _values = new Dictionary(StringComparer.OrdinalIgnoreCase); - } - - public StateChangeTracker(IDictionary values) - { - _values = values; - } - - public object this[string key] - { - get - { - object result; - _values.TryGetValue(key, out result); - return DynamicDictionary.Wrap(result); - } - set - { - if (!_oldValues.ContainsKey(key)) - { - object oldValue; - _values.TryGetValue(key, out oldValue); - _oldValues[key] = oldValue; - } - - _values[key] = value; - } - } - - [SuppressMessage("Microsoft.Design", "CA1024:UsePropertiesWhereAppropriate", Justification = "This might be expensive")] - public IDictionary GetChanges() - { - var changes = (from key in _oldValues.Keys - let oldValue = _oldValues[key] - let newValue = _values[key] - where !Object.Equals(oldValue, newValue) - select new - { - Key = key, - Value = newValue - }).ToDictionary(p => p.Key, p => p.Value); - - return changes.Count > 0 ? changes : null; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Hubs/StatefulSignalProxy.cs b/src/Microsoft.AspNet.SignalR.Core/Hubs/StatefulSignalProxy.cs deleted file mode 100644 index 8fbac0f32..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Hubs/StatefulSignalProxy.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Dynamic; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Infrastructure; - -namespace Microsoft.AspNet.SignalR.Hubs -{ - public class StatefulSignalProxy : SignalProxy - { - private readonly StateChangeTracker _tracker; - - public StatefulSignalProxy(Func, Task> send, string signal, string hubName, string prefix, StateChangeTracker tracker) - : base(send, signal, prefix, hubName, ListHelper.Empty) - { - _tracker = tracker; - } - - [SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "0", Justification = "The compiler generates calls to invoke this")] - public override bool TrySetMember(SetMemberBinder binder, object value) - { - _tracker[binder.Name] = value; - return true; - } - - [SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "0", Justification = "The compiler generates calls to invoke this")] - public override bool TryGetMember(GetMemberBinder binder, out object result) - { - result = _tracker[binder.Name]; - return true; - } - - protected override ClientHubInvocation GetInvocationData(string method, object[] args) - { - return new ClientHubInvocation - { - Hub = HubName, - Method = method, - Args = args, - Target = Signal, - State = _tracker.GetChanges() - }; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/IConnection.cs b/src/Microsoft.AspNet.SignalR.Core/IConnection.cs deleted file mode 100644 index a6283c259..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/IConnection.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Threading.Tasks; - -namespace Microsoft.AspNet.SignalR -{ - /// - /// A communication channel for a and its connections. - /// - public interface IConnection - { - /// - /// The main signal for this connection. This is the main signalr for a . - /// - string DefaultSignal { get; } - - /// - /// Sends a message to connections subscribed to the signal. - /// - /// The message to send. - /// A task that returns when the message has be sent. - Task Send(ConnectionMessage message); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/IConnectionGroupManager.cs b/src/Microsoft.AspNet.SignalR.Core/IConnectionGroupManager.cs deleted file mode 100644 index 93688901f..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/IConnectionGroupManager.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Threading.Tasks; - -namespace Microsoft.AspNet.SignalR -{ - /// - /// Manages groups for a connection and allows sending messages to the group. - /// - public interface IConnectionGroupManager : IGroupManager - { - /// - /// Sends a value to the specified group. - /// - /// The name of the group. - /// The value to send. - /// The list of connection ids to exclude - /// A task that represents when send is complete. - Task Send(string groupName, object value, params string[] excludeConnectionIds); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/IDependencyResolver.cs b/src/Microsoft.AspNet.SignalR.Core/IDependencyResolver.cs deleted file mode 100644 index a897d2139..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/IDependencyResolver.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; - -namespace Microsoft.AspNet.SignalR -{ - public interface IDependencyResolver : IDisposable - { - object GetService(Type serviceType); - IEnumerable GetServices(Type serviceType); - void Register(Type serviceType, Func activator); - void Register(Type serviceType, IEnumerable> activators); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/IGroupManager.cs b/src/Microsoft.AspNet.SignalR.Core/IGroupManager.cs deleted file mode 100644 index 3b23e1bb6..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/IGroupManager.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Threading.Tasks; - -namespace Microsoft.AspNet.SignalR -{ - /// - /// Manages groups for a connection. - /// - public interface IGroupManager - { - /// - /// Adds a connection to the specified group. - /// - /// The connection id to add to the group. - /// The name of the group - /// A task that represents the connection id being added to the group. - Task Add(string connectionId, string groupName); - - /// - /// Removes a connection from the specified group. - /// - /// The connection id to remove from the group. - /// The name of the group - /// A task that represents the connection id being removed from the group. - Task Remove(string connectionId, string groupName); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/IHubContext.cs b/src/Microsoft.AspNet.SignalR.Core/IHubContext.cs deleted file mode 100644 index db33cc64d..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/IHubContext.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using Microsoft.AspNet.SignalR.Hubs; - -namespace Microsoft.AspNet.SignalR -{ - /// - /// Provides access to information about a . - /// - public interface IHubContext - { - /// - /// Encapsulates all information about a SignalR connection for an . - /// - IHubConnectionContext Clients { get; } - - /// - /// Gets the the hub. - /// - IGroupManager Groups { get; } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/IPersistentConnectionContext.cs b/src/Microsoft.AspNet.SignalR.Core/IPersistentConnectionContext.cs deleted file mode 100644 index da291fa98..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/IPersistentConnectionContext.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -namespace Microsoft.AspNet.SignalR -{ - /// - /// Provides access to information about a . - /// - public interface IPersistentConnectionContext - { - /// - /// Gets the for the . - /// - IConnection Connection { get; } - - /// - /// Gets the for the . - /// - IConnectionGroupManager Groups { get; } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/IRequest.cs b/src/Microsoft.AspNet.SignalR.Core/IRequest.cs deleted file mode 100644 index da5859f51..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/IRequest.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Collections.Specialized; -using System.Security.Principal; - -namespace Microsoft.AspNet.SignalR -{ - /// - /// Represents a SignalR request - /// - public interface IRequest - { - /// - /// Gets the url for this request. - /// - Uri Url { get; } - - /// - /// Gets the querystring for this request. - /// - NameValueCollection QueryString { get; } - - /// - /// Gets the headers for this request. - /// - NameValueCollection Headers { get; } - - /// - /// Gets the form for this request. - /// - NameValueCollection Form { get; } - - /// - /// Gets the cookies for this request. - /// - IDictionary Cookies { get; } - - /// - /// Gets security information for the current HTTP request. - /// - IPrincipal User { get; } - - /// - /// Gets state for the current HTTP request. - /// - IDictionary Items { get; } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/AckHandler.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/AckHandler.cs deleted file mode 100644 index 18479ec4c..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/AckHandler.cs +++ /dev/null @@ -1,112 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - public class AckHandler : IAckHandler, IDisposable - { - private readonly ConcurrentDictionary _acks = new ConcurrentDictionary(); - - // REVIEW: Consider making this pluggable - private readonly TimeSpan _ackThreshold; - - // REVIEW: Consider moving this logic to the transport heartbeat - private Timer _timer; - - public AckHandler() - : this(completeAcksOnTimeout: true, - ackThreshold: TimeSpan.FromSeconds(30), - ackInterval: TimeSpan.FromSeconds(5)) - { - } - - [SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "Acks", Justification = "Ack is a well known term")] - public AckHandler(bool completeAcksOnTimeout, TimeSpan ackThreshold, TimeSpan ackInterval) - { - if (completeAcksOnTimeout) - { - _timer = new Timer(_ => CheckAcks(), state: null, dueTime: ackInterval, period: ackInterval); - } - - _ackThreshold = ackThreshold; - } - - public Task CreateAck(string id) - { - return _acks.GetOrAdd(id, _ => new AckInfo()).Tcs.Task; - } - - public bool TriggerAck(string id) - { - AckInfo info; - if (_acks.TryRemove(id, out info)) - { - info.Tcs.TrySetResult(null); - return true; - } - - return false; - } - - private void CheckAcks() - { - foreach (var pair in _acks) - { - TimeSpan elapsed = DateTime.UtcNow - pair.Value.Created; - if (elapsed > _ackThreshold) - { - AckInfo info; - if (_acks.TryRemove(pair.Key, out info)) - { - // If we have a pending ack for longer than the threshold - // cancel it. - info.Tcs.TrySetCanceled(); - } - } - } - } - - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - if (_timer != null) - { - _timer.Dispose(); - } - - // Trip all pending acks - foreach (var pair in _acks) - { - AckInfo info; - if (_acks.TryRemove(pair.Key, out info)) - { - info.Tcs.TrySetCanceled(); - } - } - } - } - - public void Dispose() - { - Dispose(true); - } - - private class AckInfo - { - public TaskCompletionSource Tcs { get; private set; } - public DateTime Created { get; private set; } - - public AckInfo() - { - Tcs = new TaskCompletionSource(); - Created = DateTime.UtcNow; - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/ArraySegmentTextReader.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/ArraySegmentTextReader.cs deleted file mode 100644 index 08dfb1f97..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/ArraySegmentTextReader.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.IO; -using System.Text; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - public class ArraySegmentTextReader : TextReader - { - private readonly ArraySegment _buffer; - private readonly Encoding _encoding; - private int _offset; - - public ArraySegmentTextReader(ArraySegment buffer, Encoding encoding) - { - _buffer = buffer; - _encoding = encoding; - _offset = _buffer.Offset; - } - - public override int Read(char[] buffer, int index, int count) - { - int bytesCount = _encoding.GetByteCount(buffer, index, count); - int bytesToRead = Math.Min(_buffer.Count - _offset, bytesCount); - - int read = _encoding.GetChars(_buffer.Array, _offset, bytesToRead, buffer, index); - _offset += bytesToRead; - - return read; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/BinaryTextWriter.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/BinaryTextWriter.cs deleted file mode 100644 index a5f958b47..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/BinaryTextWriter.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using Microsoft.AspNet.SignalR.Hosting; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - /// - /// A buffering text writer that supports writing binary directly as well - /// - internal unsafe class BinaryTextWriter : BufferTextWriter, IBinaryWriter - { - public BinaryTextWriter(IResponse response) : - base((data, state) => ((IResponse)state).Write(data), response, reuseBuffers: true, bufferSize: 128) - { - - } - - public BinaryTextWriter(IWebSocket socket) : - base((data, state) => ((IWebSocket)state).SendChunk(data), socket, reuseBuffers: false, bufferSize: 1024) - { - - } - - - public BinaryTextWriter(Action, object> write, object state, bool reuseBuffers, int bufferSize) : - base(write, state, reuseBuffers, bufferSize) - { - } - - public void Write(ArraySegment data) - { - Writer.Write(data); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/BufferTextWriter.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/BufferTextWriter.cs deleted file mode 100644 index 6334f3533..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/BufferTextWriter.cs +++ /dev/null @@ -1,187 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Text; -using Microsoft.AspNet.SignalR.Hosting; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - /// - /// TextWriter implementation over a write delegate optimized for writing in small chunks - /// we don't need to write to a long lived buffer. This saves massive amounts of memory - /// as the number of connections grows. - /// - internal abstract unsafe class BufferTextWriter : TextWriter - { - private readonly Encoding _encoding; - - private readonly Action, object> _write; - private readonly object _writeState; - private readonly bool _reuseBuffers; - - private ChunkedWriter _writer; - private int _bufferSize; - - public BufferTextWriter(IResponse response) : - this((data, state) => ((IResponse)state).Write(data), response, reuseBuffers: true, bufferSize: 128) - { - - } - - public BufferTextWriter(IWebSocket socket) : - this((data, state) => ((IWebSocket)state).SendChunk(data), socket, reuseBuffers: false, bufferSize: 1024 * 4) - { - - } - - [SuppressMessage("Microsoft.Globalization", "CA1305:SpecifyIFormatProvider", MessageId = "System.IO.TextWriter.#ctor", Justification = "It won't be used")] - protected BufferTextWriter(Action, object> write, object state, bool reuseBuffers, int bufferSize) - { - _write = write; - _writeState = state; - _encoding = new UTF8Encoding(); - _reuseBuffers = reuseBuffers; - _bufferSize = bufferSize; - } - - protected internal ChunkedWriter Writer - { - get - { - if (_writer == null) - { - _writer = new ChunkedWriter(_write, _writeState, _bufferSize, _encoding, _reuseBuffers); - } - - return _writer; - } - } - - public override Encoding Encoding - { - get { return _encoding; } - } - - public override void Write(string value) - { - Writer.Write(value); - } - - public override void WriteLine(string value) - { - Writer.Write(value); - } - - public override void Write(char value) - { - Writer.Write(value); - } - - public override void Flush() - { - Writer.Flush(); - } - - internal class ChunkedWriter - { - private int _charPos; - private int _charLen; - - private readonly Encoder _encoder; - private readonly char[] _charBuffer; - private readonly byte[] _byteBuffer; - private readonly Action, object> _write; - private readonly object _writeState; - - public ChunkedWriter(Action, object> write, object state, int chunkSize, Encoding encoding, bool reuseBuffers) - { - _charLen = chunkSize; - _charBuffer = new char[chunkSize]; - _write = write; - _writeState = state; - _encoder = encoding.GetEncoder(); - - if (reuseBuffers) - { - _byteBuffer = new byte[encoding.GetMaxByteCount(chunkSize)]; - } - } - - public void Write(char value) - { - if (_charPos == _charLen) - { - Flush(flushEncoder: false); - } - - _charBuffer[_charPos++] = value; - } - - public void Write(string value) - { - int length = value.Length; - int sourceIndex = 0; - - while (length > 0) - { - if (_charPos == _charLen) - { - Flush(flushEncoder: false); - } - - int count = _charLen - _charPos; - if (count > length) - { - count = length; - } - - value.CopyTo(sourceIndex, _charBuffer, _charPos, count); - _charPos += count; - sourceIndex += count; - length -= count; - } - } - - public void Write(ArraySegment data) - { - Flush(); - _write(data, _writeState); - } - - public void Flush() - { - Flush(flushEncoder: true); - } - - private void Flush(bool flushEncoder) - { - // If it's safe to reuse the buffer then do so - if (_byteBuffer != null) - { - Flush(_byteBuffer, flushEncoder); - } - else - { - // Allocate a byte array of the right size for this char buffer - int byteCount = _encoder.GetByteCount(_charBuffer, 0, _charPos, flush: false); - var byteBuffer = new byte[byteCount]; - Flush(byteBuffer, flushEncoder); - } - } - - private void Flush(byte[] byteBuffer, bool flushEncoder) - { - int count = _encoder.GetBytes(_charBuffer, 0, _charPos, byteBuffer, 0, flush: flushEncoder); - - _charPos = 0; - - if (count > 0) - { - _write(new ArraySegment(byteBuffer, 0, count), _writeState); - } - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/CancellationTokenExtensions.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/CancellationTokenExtensions.cs deleted file mode 100644 index dff2ecc46..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/CancellationTokenExtensions.cs +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Threading; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - internal static class CancellationTokenExtensions - { - public static IDisposable SafeRegister(this CancellationToken cancellationToken, Action callback, object state) - { - var callbackWrapper = new CancellationCallbackWrapper(callback, state); - - // Ensure delegate continues to use the C# Compiler static delegate caching optimization. - CancellationTokenRegistration registration = cancellationToken.Register(s => Cancel(s), - callbackWrapper, - useSynchronizationContext: false); - - var disposeCancellationState = new DiposeCancellationState(callbackWrapper, registration); - - // Ensure delegate continues to use the C# Compiler static delegate caching optimization. - return new DisposableAction(s => Dispose(s), disposeCancellationState); - } - - private static void Cancel(object state) - { - ((CancellationCallbackWrapper)state).TryInvoke(); - } - - private static void Dispose(object state) - { - ((DiposeCancellationState)state).TryDispose(); - } - - private class DiposeCancellationState - { - private readonly CancellationCallbackWrapper _callbackWrapper; - private readonly CancellationTokenRegistration _registration; - - public DiposeCancellationState(CancellationCallbackWrapper callbackWrapper, CancellationTokenRegistration registration) - { - _callbackWrapper = callbackWrapper; - _registration = registration; - } - - public void TryDispose() - { - // This normally waits until the callback is finished invoked but we don't care - if (_callbackWrapper.TrySetInvoked()) - { - try - { - _registration.Dispose(); - } - catch (ObjectDisposedException) - { - // Bug #1549, .NET 4.0 has a bug where this throws if the CTS is disposed. - } - } - } - } - - private class CancellationCallbackWrapper - { - private readonly Action _callback; - private readonly object _state; - private int _callbackInvoked; - - public CancellationCallbackWrapper(Action callback, object state) - { - _callback = callback; - _state = state; - } - - public bool TrySetInvoked() - { - return Interlocked.Exchange(ref _callbackInvoked, 1) == 0; - } - - public void TryInvoke() - { - if (TrySetInvoked()) - { - _callback(_state); - } - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/Connection.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/Connection.cs deleted file mode 100644 index a938c87f0..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/Connection.cs +++ /dev/null @@ -1,345 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Linq; -using System.Runtime.Serialization; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Json; -using Microsoft.AspNet.SignalR.Messaging; -using Microsoft.AspNet.SignalR.Tracing; -using Microsoft.AspNet.SignalR.Transports; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - public class Connection : IConnection, ITransportConnection, ISubscriber - { - private readonly IMessageBus _bus; - private readonly IJsonSerializer _serializer; - private readonly string _baseSignal; - private readonly string _connectionId; - private readonly IList _signals; - private readonly DiffSet _groups; - private readonly IPerformanceCounterManager _counters; - - private bool _disconnected; - private bool _aborted; - private readonly TraceSource _traceSource; - private readonly IAckHandler _ackHandler; - private readonly IProtectedData _protectedData; - - public Connection(IMessageBus newMessageBus, - IJsonSerializer jsonSerializer, - string baseSignal, - string connectionId, - IList signals, - IList groups, - ITraceManager traceManager, - IAckHandler ackHandler, - IPerformanceCounterManager performanceCounterManager, - IProtectedData protectedData) - { - if (traceManager == null) - { - throw new ArgumentNullException("traceManager"); - } - - _bus = newMessageBus; - _serializer = jsonSerializer; - _baseSignal = baseSignal; - _connectionId = connectionId; - _signals = new List(signals.Concat(groups)); - _groups = new DiffSet(groups); - _traceSource = traceManager["SignalR.Connection"]; - _ackHandler = ackHandler; - _counters = performanceCounterManager; - _protectedData = protectedData; - } - - public string DefaultSignal - { - get - { - return _baseSignal; - } - } - - IList ISubscriber.EventKeys - { - get - { - return _signals; - } - } - - public event Action EventKeyAdded; - - public event Action EventKeyRemoved; - - public Action WriteCursor { get; set; } - - public string Identity - { - get - { - return _connectionId; - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "Used for debugging purposes.")] - private TraceSource Trace - { - get - { - return _traceSource; - } - } - - public Subscription Subscription - { - get; - set; - } - - public Task Send(ConnectionMessage message) - { - Message busMessage = CreateMessage(message.Signal, message.Value); - - if (message.ExcludedSignals != null) - { - busMessage.Filter = String.Join("|", message.ExcludedSignals); - } - - if (busMessage.WaitForAck) - { - Task ackTask = _ackHandler.CreateAck(busMessage.CommandId); - return _bus.Publish(busMessage).Then(task => task, ackTask); - } - - return _bus.Publish(busMessage); - } - - private Message CreateMessage(string key, object value) - { - var command = value as Command; - - ArraySegment messageBuffer = GetMessageBuffer(value); - - var message = new Message(_connectionId, key, messageBuffer); - - if (command != null) - { - // Set the command id - message.CommandId = command.Id; - message.WaitForAck = command.WaitForAck; - } - - return message; - } - - private ArraySegment GetMessageBuffer(object value) - { - using (var stream = new MemoryStream(128)) - { - var bufferWriter = new BinaryTextWriter((buffer, state) => - { - ((MemoryStream)state).Write(buffer.Array, buffer.Offset, buffer.Count); - }, - stream, - reuseBuffers: true, - bufferSize: 1024); - - using (bufferWriter) - { - _serializer.Serialize(value, bufferWriter); - bufferWriter.Flush(); - - return new ArraySegment(stream.ToArray()); - } - } - } - - public IDisposable Receive(string messageId, Func> callback, int maxMessages, object state) - { - var receiveContext = new ReceiveContext(this, callback, state); - - return _bus.Subscribe(this, - messageId, - (result, s) => MessageBusCallback(result, s), - maxMessages, - receiveContext); - } - - private static Task MessageBusCallback(MessageResult result, object state) - { - var context = (ReceiveContext)state; - - return context.InvokeCallback(result); - } - - private PersistentResponse GetResponse(MessageResult result) - { - // Do a single sweep through the results to process commands and extract values - ProcessResults(result); - - Debug.Assert(WriteCursor != null, "Unable to resolve the cursor since the method is null"); - - var response = new PersistentResponse(ExcludeMessage, WriteCursor); - response.Terminal = result.Terminal; - - if (!result.Terminal) - { - // Only set these properties if the message isn't terminal - response.Messages = result.Messages; - response.Disconnect = _disconnected; - response.Aborted = _aborted; - response.TotalCount = result.TotalCount; - } - - PopulateResponseState(response); - - _counters.ConnectionMessagesReceivedTotal.IncrementBy(result.TotalCount); - _counters.ConnectionMessagesReceivedPerSec.IncrementBy(result.TotalCount); - - return response; - } - - private bool ExcludeMessage(Message message) - { - if (String.IsNullOrEmpty(message.Filter)) - { - return false; - } - - string[] exclude = message.Filter.Split('|'); - - return exclude.Any(signal => Identity.Equals(signal, StringComparison.OrdinalIgnoreCase) || - _signals.Contains(signal) || - _groups.Contains(signal)); - } - - private void ProcessResults(MessageResult result) - { - result.Messages.Enumerate(message => message.IsAck || message.IsCommand, - (state, message) => - { - if (message.IsAck) - { - _ackHandler.TriggerAck(message.CommandId); - } - else if (message.IsCommand) - { - var command = _serializer.Parse(message.Value, message.Encoding); - - if (command == null) - { - if (MonoUtility.IsRunningMono) - { - return; - } - - throw new SerializationException("Couldn't parse message " + message.Value); - } - - ProcessCommand(command); - - // Only send the ack if this command is waiting for it - if (message.WaitForAck) - { - // If we're on the same box and there's a pending ack for this command then - // just trip it - if (!_ackHandler.TriggerAck(message.CommandId)) - { - _bus.Ack(_connectionId, message.CommandId).Catch(); - } - } - } - }, null); - } - - private void ProcessCommand(Command command) - { - switch (command.CommandType) - { - case CommandType.AddToGroup: - { - var name = command.Value; - - if (EventKeyAdded != null) - { - _groups.Add(name); - EventKeyAdded(this, name); - } - } - break; - case CommandType.RemoveFromGroup: - { - var name = command.Value; - - if (EventKeyRemoved != null) - { - _groups.Remove(name); - EventKeyRemoved(this, name); - } - } - break; - case CommandType.Disconnect: - _disconnected = true; - break; - case CommandType.Abort: - _aborted = true; - break; - } - } - - private void PopulateResponseState(PersistentResponse response) - { - PopulateResponseState(response, _groups, _serializer, _protectedData, _connectionId); - } - - internal static void PopulateResponseState(PersistentResponse response, - DiffSet groupSet, - IJsonSerializer serializer, - IProtectedData protectedData, - string connectionId) - { - bool anyChanges = groupSet.DetectChanges(); - - if (anyChanges) - { - // Create a protected payload of the sorted list - IEnumerable groups = groupSet.GetSnapshot(); - - // Remove group prefixes before any thing goes over the wire - string groupsString = connectionId + ':' + serializer.Stringify(PrefixHelper.RemoveGroupPrefixes(groups)); ; - - // The groups token - response.GroupsToken = protectedData.Protect(groupsString, Purposes.Groups); - } - } - - private class ReceiveContext - { - private readonly Connection _connection; - private readonly Func> _callback; - private readonly object _callbackState; - - public ReceiveContext(Connection connection, Func> callback, object callbackState) - { - _connection = connection; - _callback = callback; - _callbackState = callbackState; - } - - public Task InvokeCallback(MessageResult result) - { - var response = _connection.GetResponse(result); - - return _callback(response, _callbackState); - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/ConnectionManager.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/ConnectionManager.cs deleted file mode 100644 index 9f74ff10e..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/ConnectionManager.cs +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Hubs; -using Microsoft.AspNet.SignalR.Json; -using Microsoft.AspNet.SignalR.Messaging; -using Microsoft.AspNet.SignalR.Tracing; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - /// - /// Default implementation. - /// - public class ConnectionManager : IConnectionManager - { - private readonly IDependencyResolver _resolver; - private readonly IPerformanceCounterManager _counters; - - /// - /// Initializes a new instance of the class. - /// - /// The . - public ConnectionManager(IDependencyResolver resolver) - { - _resolver = resolver; - _counters = _resolver.Resolve(); - } - - /// - /// Returns a for the . - /// - /// Type of the - /// A for the . - public IPersistentConnectionContext GetConnectionContext() where T : PersistentConnection - { - return GetConnection(typeof(T)); - } - - /// - /// Returns a for the . - /// - /// Type of the - /// A for the . - public IPersistentConnectionContext GetConnection(Type type) - { - if (type == null) - { - throw new ArgumentNullException("type"); - } - - string rawConnectionName = type.FullName; - string connectionName = PrefixHelper.GetPersistentConnectionName(rawConnectionName); - IConnection connection = GetConnectionCore(connectionName); - - return new PersistentConnectionContext(connection, new GroupManager(connection, PrefixHelper.GetPersistentConnectionGroupName(rawConnectionName))); - } - - /// - /// Returns a for the specified . - /// - /// Type of the - /// a for the specified - public IHubContext GetHubContext() where T : IHub - { - return GetHubContext(typeof(T).GetHubName()); - } - - /// - /// Returns a for the specified hub. - /// - /// Name of the hub - /// a for the specified hub - public IHubContext GetHubContext(string hubName) - { - var connection = GetConnectionCore(connectionName: null); - var hubManager = _resolver.Resolve(); - var pipelineInvoker = _resolver.Resolve(); - - hubManager.EnsureHub(hubName, - _counters.ErrorsHubResolutionTotal, - _counters.ErrorsHubResolutionPerSec, - _counters.ErrorsAllTotal, - _counters.ErrorsAllPerSec); - - Func, Task> send = (signal, value, exclude) => pipelineInvoker.Send(new HubOutgoingInvokerContext(connection, signal, value, exclude)); - - return new HubContext(send, hubName, connection); - } - - internal Connection GetConnectionCore(string connectionName) - { - IList signals = connectionName == null ? ListHelper.Empty : new[] { connectionName }; - - // Give this a unique id - var connectionId = Guid.NewGuid().ToString(); - return new Connection(_resolver.Resolve(), - _resolver.Resolve(), - connectionName, - connectionId, - signals, - ListHelper.Empty, - _resolver.Resolve(), - _resolver.Resolve(), - _resolver.Resolve(), - _resolver.Resolve()); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/DefaultProtectedData.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/DefaultProtectedData.cs deleted file mode 100644 index 686f33abf..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/DefaultProtectedData.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Security.Cryptography; -using System.Text; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - public class DefaultProtectedData : IProtectedData - { - private static readonly UTF8Encoding _encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); - - public string Protect(string data, string purpose) - { - byte[] purposeBytes = _encoding.GetBytes(purpose); - - byte[] unprotectedBytes = _encoding.GetBytes(data); - - byte[] protectedBytes = ProtectedData.Protect(unprotectedBytes, purposeBytes, DataProtectionScope.CurrentUser); - - return Convert.ToBase64String(protectedBytes); - } - - public string Unprotect(string protectedValue, string purpose) - { - byte[] purposeBytes = _encoding.GetBytes(purpose); - - byte[] protectedBytes = Convert.FromBase64String(protectedValue); - - byte[] unprotectedBytes = ProtectedData.Unprotect(protectedBytes, purposeBytes, DataProtectionScope.CurrentUser); - - return _encoding.GetString(unprotectedBytes); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/DiffPair.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/DiffPair.cs deleted file mode 100644 index f7a92b81e..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/DiffPair.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Collections.Generic; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - internal struct DiffPair - { - public ICollection Added; - public ICollection Removed; - - public bool AnyChanges - { - get - { - return Added.Count > 0 || Removed.Count > 0; - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/DiffSet.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/DiffSet.cs deleted file mode 100644 index 0d322bf13..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/DiffSet.cs +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Collections.Generic; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - internal class DiffSet - { - private readonly HashSet _items; - private readonly HashSet _addedItems; - private readonly HashSet _removedItems; - - public DiffSet(IEnumerable items) - { - _addedItems = new HashSet(); - _removedItems = new HashSet(); - - _items = new HashSet(items); - } - - public bool Add(T item) - { - if (_items.Add(item)) - { - if (!_removedItems.Remove(item)) - { - _addedItems.Add(item); - } - return true; - } - return false; - } - - public bool Remove(T item) - { - if (_items.Remove(item)) - { - if (!_addedItems.Remove(item)) - { - _removedItems.Add(item); - } - return true; - } - return false; - } - - public bool Contains(T item) - { - return _items.Contains(item); - } - - public ICollection GetSnapshot() - { - return _items; - } - - public bool DetectChanges() - { - bool anyChanges = _addedItems.Count > 0 || _removedItems.Count > 0; - _addedItems.Clear(); - _removedItems.Clear(); - return anyChanges; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/DisposableAction.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/DisposableAction.cs deleted file mode 100644 index ab336d604..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/DisposableAction.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Threading; -using System.Diagnostics.CodeAnalysis; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - internal class DisposableAction : IDisposable - { - [SuppressMessage("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields", Justification = "The client projects use this.")] - public static readonly DisposableAction Empty = new DisposableAction(() => { }); - - private Action _action; - private readonly object _state; - - public DisposableAction(Action action) - : this(state => ((Action)state).Invoke(), state: action) - { - - } - - public DisposableAction(Action action, object state) - { - _action = action; - _state = state; - } - - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - Interlocked.Exchange(ref _action, (state) => { }).Invoke(_state); - } - } - - public void Dispose() - { - Dispose(true); - } - } - -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/Disposer.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/Disposer.cs deleted file mode 100644 index ab0155acd..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/Disposer.cs +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Diagnostics; -using System.Threading; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - /// - /// Helper class to manage disposing a resource at an arbirtary time - /// - internal class Disposer : IDisposable - { - private static readonly object _disposedSentinel = new object(); - - private object _disposable; - - public void Set(IDisposable disposable) - { - if (disposable == null) - { - throw new ArgumentNullException("disposable"); - } - - object originalFieldValue = Interlocked.CompareExchange(ref _disposable, disposable, null); - if (originalFieldValue == null) - { - // this is the first call to Set() and Dispose() hasn't yet been called; do nothing - } - else if (originalFieldValue == _disposedSentinel) - { - // Dispose() has already been called, so we need to dispose of the object that was just added - disposable.Dispose(); - } - else - { -#if !NET35 && !SILVERLIGHT && !NETFX_CORE - // Set has been called multiple times, fail - Debug.Fail("Multiple calls to Disposer.Set(IDisposable) without calling Disposer.Dispose()"); -#endif - } - } - - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - var disposable = Interlocked.Exchange(ref _disposable, _disposedSentinel) as IDisposable; - if (disposable != null) - { - disposable.Dispose(); - } - } - } - - public void Dispose() - { - Dispose(true); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/ExceptionsExtensions.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/ExceptionsExtensions.cs deleted file mode 100644 index 5545f2f81..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/ExceptionsExtensions.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - internal static class ExceptionsExtensions - { - internal static Exception Unwrap(this Exception ex) - { - if (ex == null) - { - return null; - } - - var next = ex.GetBaseException(); - while (next.InnerException != null) - { - // On mono GetBaseException() doesn't seem to do anything - // so just walk the inner exception chain. - next = next.InnerException; - } - - return next; - } - } -} - diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/IAckHandler.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/IAckHandler.cs deleted file mode 100644 index 977b2385f..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/IAckHandler.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Threading.Tasks; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - public interface IAckHandler - { - Task CreateAck(string id); - - bool TriggerAck(string id); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/IBinaryWriter.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/IBinaryWriter.cs deleted file mode 100644 index 06be2531d..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/IBinaryWriter.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - /// - /// Implemented on anything that has the ability to write raw binary data - /// - public interface IBinaryWriter - { - void Write(ArraySegment data); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/IConnectionManager.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/IConnectionManager.cs deleted file mode 100644 index bd783d12e..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/IConnectionManager.cs +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Diagnostics.CodeAnalysis; -using Microsoft.AspNet.SignalR.Hubs; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - /// - /// Provides access to hubs and persistent connections references. - /// - public interface IConnectionManager - { - /// - /// Returns a for the specified . - /// - /// Type of the - /// a for the specified - [SuppressMessage("Microsoft.Design", "CA1004:GenericMethodsShouldProvideTypeParameter", Justification = "The hub type needs to be specified")] - IHubContext GetHubContext() where T : IHub; - - /// - /// Returns a for the specified hub. - /// - /// Name of the hub - /// a for the specified hub - IHubContext GetHubContext(string hubName); - - /// - /// Returns a for the . - /// - /// Type of the - /// A for the . - [SuppressMessage("Microsoft.Design", "CA1004:GenericMethodsShouldProvideTypeParameter", Justification = "The connection type needs to be specified")] - IPersistentConnectionContext GetConnectionContext() where T : PersistentConnection; - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/IPerformanceCounter.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/IPerformanceCounter.cs deleted file mode 100644 index 3f90659e4..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/IPerformanceCounter.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Diagnostics; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - public interface IPerformanceCounter - { - string CounterName { get; } - long Decrement(); - long Increment(); - long IncrementBy(long value); - CounterSample NextSample(); - long RawValue { get; set; } - void Close(); - void RemoveInstance(); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/IPerformanceCounterManager.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/IPerformanceCounterManager.cs deleted file mode 100644 index c2d6b73e9..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/IPerformanceCounterManager.cs +++ /dev/null @@ -1,193 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Threading; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - /// - /// Provides access to performance counters. - /// - public interface IPerformanceCounterManager - { - /// - /// Initializes the performance counters. - /// - /// The host instance name. - /// The CancellationToken representing the host shutdown. - void Initialize(string instanceName, CancellationToken hostShutdownToken); - - /// - /// Loads a performance counter. - /// - /// The category name. - /// The counter name. - /// The instance name. - /// Whether the counter is read-only. - IPerformanceCounter LoadCounter(string categoryName, string counterName, string instanceName, bool isReadOnly); - - /// - /// Gets the performance counter representing the total number of connection Connect events since the application was started. - /// - IPerformanceCounter ConnectionsConnected { get; } - - /// - /// Gets the performance counter representing the total number of connection Reconnect events since the application was started. - /// - IPerformanceCounter ConnectionsReconnected { get; } - - /// - /// Gets the performance counter representing the total number of connection Disconnect events since the application was started. - /// - IPerformanceCounter ConnectionsDisconnected { get; } - - /// - /// Gets the performance counter representing the number of connections currently connected. - /// - IPerformanceCounter ConnectionsCurrent { get; } - - /// - /// Gets the performance counter representing the total number of messages received by connections (server to client) since the application was started. - /// - IPerformanceCounter ConnectionMessagesReceivedTotal { get; } - - /// - /// Gets the performance counter representing the total number of messages received by connections (server to client) since the application was started. - /// - IPerformanceCounter ConnectionMessagesSentTotal { get; } - - /// - /// Gets the performance counter representing the number of messages received by connections (server to client) per second. - /// - IPerformanceCounter ConnectionMessagesReceivedPerSec { get; } - - /// - /// Gets the performance counter representing the number of messages sent by connections (client to server) per second. - /// - IPerformanceCounter ConnectionMessagesSentPerSec { get; } - - /// - /// Gets the performance counter representing the total number of messages received by subscribers since the application was started. - /// - IPerformanceCounter MessageBusMessagesReceivedTotal { get; } - - /// - /// Gets the performance counter representing the number of messages received by a subscribers per second. - /// - IPerformanceCounter MessageBusMessagesReceivedPerSec { get; } - - /// - /// Gets the performance counter representing the number of messages received by the scaleout message bus per second. - /// - IPerformanceCounter ScaleoutMessageBusMessagesReceivedPerSec { get; } - - /// - /// Gets the performance counter representing the total number of messages published to the message bus since the application was started. - /// - IPerformanceCounter MessageBusMessagesPublishedTotal { get; } - - /// - /// Gets the performance counter representing the number of messages published to the message bus per second. - /// - IPerformanceCounter MessageBusMessagesPublishedPerSec { get; } - - /// - /// Gets the performance counter representing the current number of subscribers to the message bus. - /// - IPerformanceCounter MessageBusSubscribersCurrent { get; } - - /// - /// Gets the performance counter representing the total number of subscribers to the message bus since the application was started. - /// - IPerformanceCounter MessageBusSubscribersTotal { get; } - - /// - /// Gets the performance counter representing the number of new subscribers to the message bus per second. - /// - IPerformanceCounter MessageBusSubscribersPerSec { get; } - - /// - /// Gets the performance counter representing the number of workers allocated to deliver messages in the message bus. - /// - IPerformanceCounter MessageBusAllocatedWorkers { get; } - - /// - /// Gets the performance counter representing the number of workers currently busy delivering messages in the message bus. - /// - IPerformanceCounter MessageBusBusyWorkers { get; } - - /// - /// Gets the performance counter representing representing the current number of topics in the message bus. - /// - IPerformanceCounter MessageBusTopicsCurrent { get; } - - /// - /// Gets the performance counter representing the total number of all errors processed since the application was started. - /// - IPerformanceCounter ErrorsAllTotal { get; } - - /// - /// Gets the performance counter representing the number of all errors processed per second. - /// - IPerformanceCounter ErrorsAllPerSec { get; } - - /// - /// Gets the performance counter representing the total number of hub resolution errors processed since the application was started. - /// - IPerformanceCounter ErrorsHubResolutionTotal { get; } - - /// - /// Gets the performance counter representing the number of hub resolution errors per second. - /// - IPerformanceCounter ErrorsHubResolutionPerSec { get; } - - /// - /// Gets the performance counter representing the total number of hub invocation errors processed since the application was started. - /// - IPerformanceCounter ErrorsHubInvocationTotal { get; } - - /// - /// Gets the performance counter representing the number of hub invocation errors per second. - /// - IPerformanceCounter ErrorsHubInvocationPerSec { get; } - - /// - /// Gets the performance counter representing the total number of transport errors processed since the application was started. - /// - IPerformanceCounter ErrorsTransportTotal { get; } - - /// - /// Gets the performance counter representing the number of transport errors per second. - /// - IPerformanceCounter ErrorsTransportPerSec { get; } - - /// - /// Gets the performance counter representing the number of logical streams in the currently configured scaleout message bus provider. - /// - IPerformanceCounter ScaleoutStreamCountTotal { get; } - - /// - /// Gets the performance counter representing the number of logical streams in the currently configured scaleout message bus provider that are in the open state. - /// - IPerformanceCounter ScaleoutStreamCountOpen { get; } - - /// - /// Gets the performance counter representing the number of logical streams in the currently configured scaleout message bus provider that are in the buffering state. - /// - IPerformanceCounter ScaleoutStreamCountBuffering { get; } - - /// - /// Gets the performance counter representing the total number of scaleout errors since the application was started. - /// - IPerformanceCounter ScaleoutErrorsTotal { get; } - - /// - /// Gets the performance counter representing the number of scaleout errors per second. - /// - IPerformanceCounter ScaleoutErrorsPerSec { get; } - - /// - /// Gets the performance counter representing the current scaleout send queue length. - /// - IPerformanceCounter ScaleoutSendQueueLength { get; } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/IProtectedData.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/IProtectedData.cs deleted file mode 100644 index 8f46d2145..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/IProtectedData.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - public interface IProtectedData - { - string Protect(string data, string purpose); - string Unprotect(string protectedValue, string purpose); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/IServerCommandHandler.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/IServerCommandHandler.cs deleted file mode 100644 index 9db7f16fc..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/IServerCommandHandler.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Threading.Tasks; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - /// - /// Handles commands from server to server. - /// - internal interface IServerCommandHandler - { - /// - /// Sends a command to all connected servers. - /// - /// - /// - Task SendCommand(ServerCommand command); - - /// - /// Gets or sets a callback that is invoked when a command is received. - /// - Action Command { get; set; } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/IServerIdManager.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/IServerIdManager.cs deleted file mode 100644 index a7637b70a..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/IServerIdManager.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - /// - /// Generates a server id - /// - public interface IServerIdManager - { - /// - /// The id of the server. - /// - string ServerId { get; } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/IStringMinifier.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/IStringMinifier.cs deleted file mode 100644 index 6ff5a7941..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/IStringMinifier.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - public interface IStringMinifier - { - /// - /// Minifies a string in a way that can be reversed by this instance of . - /// - /// The string to be minified - /// A minified representation of the without the following characters:,|\ - string Minify(string value); - - /// - /// Reverses a call that was executed at least once previously on this instance of - /// without any subsequent calls to sharing the - /// same argument as the call that returned . - /// - /// - /// A minified string that was returned by a previous call to . - /// - /// - /// The argument of all previous calls to that returned . - /// If every call to on this instance of has never - /// returned or if the most recent call to that did - /// return was followed by a call to sharing - /// the same argument, may return null but must not throw. - /// - string Unminify(string value); - - /// - /// A call to this function indicates that any future attempt to unminify strings that were previously minified - /// from may be met with a null return value. This provides an opportunity clean up - /// any internal data structures that reference . - /// - /// The string that may have previously have been minified. - void RemoveUnminified(string value); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/InterlockedHelper.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/InterlockedHelper.cs deleted file mode 100644 index 3f4044dd7..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/InterlockedHelper.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Threading; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - - public static class InterlockedHelper - { - [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1045:DoNotPassTypesByReference", MessageId = "0#", Justification="This is an interlocked helper...")] - public static bool CompareExchangeOr(ref int location, int value, int comparandA, int comparandB) - { - return Interlocked.CompareExchange(ref location, value, comparandA) == comparandA || - Interlocked.CompareExchange(ref location, value, comparandB) == comparandB; - } - } - -} \ No newline at end of file diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/ListHelper.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/ListHelper.cs deleted file mode 100644 index 12e8b8dce..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/ListHelper.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Collections.Generic; -using System.Collections.ObjectModel; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - internal class ListHelper - { - public static readonly IList Empty = new ReadOnlyCollection(new List()); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/MonoUtility.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/MonoUtility.cs deleted file mode 100644 index ea1d80127..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/MonoUtility.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - internal static class MonoUtility - { - private static readonly Lazy _isRunningMono = new Lazy(() => CheckRunningOnMono()); - - internal static bool IsRunningMono - { - get - { - return _isRunningMono.Value; - } - } - - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "This should never fail")] - private static bool CheckRunningOnMono() - { - try - { - return Type.GetType("Mono.Runtime") != null; - } - catch - { - return false; - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/PerformanceCounterAttribute.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/PerformanceCounterAttribute.cs deleted file mode 100644 index 7a2cb0682..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/PerformanceCounterAttribute.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Diagnostics; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - [AttributeUsage(AttributeTargets.Property, AllowMultiple=false)] - internal sealed class PerformanceCounterAttribute : Attribute - { - public string Name { get; set; } - public string Description { get; set; } - public PerformanceCounterType CounterType { get; set; } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/PerformanceCounterManager.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/PerformanceCounterManager.cs deleted file mode 100644 index 11b9a3577..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/PerformanceCounterManager.cs +++ /dev/null @@ -1,418 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.ComponentModel; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Reflection; -using System.Threading; -#if !UTILS -using Microsoft.AspNet.SignalR.Tracing; -#endif - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - /// - /// Manages performance counters using Windows performance counters. - /// - public class PerformanceCounterManager : IPerformanceCounterManager - { - /// - /// The performance counter category name for SignalR counters. - /// - public const string CategoryName = "SignalR"; - - private readonly static PropertyInfo[] _counterProperties = GetCounterPropertyInfo(); - private readonly static IPerformanceCounter _noOpCounter = new NoOpPerformanceCounter(); - private volatile bool _initialized; - private object _initLocker = new object(); - -#if !UTILS - private readonly TraceSource _trace; - - public PerformanceCounterManager(DefaultDependencyResolver resolver) - : this(resolver.Resolve()) - { - - } - - /// - /// Creates a new instance. - /// - public PerformanceCounterManager(ITraceManager traceManager) - : this() - { - if (traceManager == null) - { - throw new ArgumentNullException("traceManager"); - } - - _trace = traceManager["SignalR.PerformanceCounterManager"]; - } -#endif - - public PerformanceCounterManager() - { - InitNoOpCounters(); - } - - /// - /// Gets the performance counter representing the total number of connection Connect events since the application was started. - /// - [PerformanceCounter(Name = "Connections Connected", Description = "The total number of connection Connect events since the application was started.", CounterType = PerformanceCounterType.NumberOfItems32)] - public IPerformanceCounter ConnectionsConnected { get; private set; } - - /// - /// Gets the performance counter representing the total number of connection Reconnect events since the application was started. - /// - [PerformanceCounter(Name = "Connections Reconnected", Description = "The total number of connection Reconnect events since the application was started.", CounterType = PerformanceCounterType.NumberOfItems32)] - public IPerformanceCounter ConnectionsReconnected { get; private set; } - - /// - /// Gets the performance counter representing the total number of connection Disconnect events since the application was started. - /// - [PerformanceCounter(Name = "Connections Disconnected", Description = "The total number of connection Disconnect events since the application was started.", CounterType = PerformanceCounterType.NumberOfItems32)] - public IPerformanceCounter ConnectionsDisconnected { get; private set; } - - /// - /// Gets the performance counter representing the number of connections currently connected. - /// - [PerformanceCounter(Name = "Connections Current", Description = "The number of connections currently connected.", CounterType = PerformanceCounterType.NumberOfItems32)] - public IPerformanceCounter ConnectionsCurrent { get; private set; } - - /// - /// Gets the performance counter representing the toal number of messages received by connections (server to client) since the application was started. - /// - [PerformanceCounter(Name = "Connection Messages Received Total", Description = "The toal number of messages received by connections (server to client) since the application was started.", CounterType = PerformanceCounterType.NumberOfItems64)] - public IPerformanceCounter ConnectionMessagesReceivedTotal { get; private set; } - - /// - /// Gets the performance counter representing the total number of messages sent by connections (client to server) since the application was started. - /// - [PerformanceCounter(Name = "Connection Messages Sent Total", Description = "The total number of messages sent by connections (client to server) since the application was started.", CounterType = PerformanceCounterType.NumberOfItems64)] - public IPerformanceCounter ConnectionMessagesSentTotal { get; private set; } - - /// - /// Gets the performance counter representing the number of messages received by connections (server to client) per second. - /// - [PerformanceCounter(Name = "Connection Messages Received/Sec", Description = "The number of messages received by connections (server to client) per second.", CounterType = PerformanceCounterType.RateOfCountsPerSecond32)] - public IPerformanceCounter ConnectionMessagesReceivedPerSec { get; private set; } - - /// - /// Gets the performance counter representing the number of messages sent by connections (client to server) per second. - /// - [PerformanceCounter(Name = "Connection Messages Sent/Sec", Description = "The number of messages sent by connections (client to server) per second.", CounterType = PerformanceCounterType.RateOfCountsPerSecond32)] - public IPerformanceCounter ConnectionMessagesSentPerSec { get; private set; } - - /// - /// Gets the performance counter representing the total number of messages received by subscribers since the application was started. - /// - [PerformanceCounter(Name = "Message Bus Messages Received Total", Description = "The total number of messages received by subscribers since the application was started.", CounterType = PerformanceCounterType.NumberOfItems64)] - public IPerformanceCounter MessageBusMessagesReceivedTotal { get; private set; } - - /// - /// Gets the performance counter representing the number of messages received by a subscribers per second. - /// - [PerformanceCounter(Name = "Message Bus Messages Received/Sec", Description = "The number of messages received by subscribers per second.", CounterType = PerformanceCounterType.RateOfCountsPerSecond32)] - public IPerformanceCounter MessageBusMessagesReceivedPerSec { get; private set; } - - /// - /// Gets the performance counter representing the number of messages received by the scaleout message bus per second. - /// - [PerformanceCounter(Name = "Scaleout Message Bus Messages Received/Sec", Description = "The number of messages received by the scaleout message bus per second.", CounterType = PerformanceCounterType.RateOfCountsPerSecond32)] - public IPerformanceCounter ScaleoutMessageBusMessagesReceivedPerSec { get; private set; } - - - /// - /// Gets the performance counter representing the total number of messages published to the message bus since the application was started. - /// - [PerformanceCounter(Name = "Messages Bus Messages Published Total", Description = "The total number of messages published to the message bus since the application was started.", CounterType = PerformanceCounterType.NumberOfItems64)] - public IPerformanceCounter MessageBusMessagesPublishedTotal { get; private set; } - - /// - /// Gets the performance counter representing the number of messages published to the message bus per second. - /// - [PerformanceCounter(Name = "Messages Bus Messages Published/Sec", Description = "The number of messages published to the message bus per second.", CounterType = PerformanceCounterType.RateOfCountsPerSecond32)] - public IPerformanceCounter MessageBusMessagesPublishedPerSec { get; private set; } - - /// - /// Gets the performance counter representing the current number of subscribers to the message bus. - /// - [PerformanceCounter(Name = "Message Bus Subscribers Current", Description = "The current number of subscribers to the message bus.", CounterType = PerformanceCounterType.NumberOfItems32)] - public IPerformanceCounter MessageBusSubscribersCurrent { get; private set; } - - /// - /// Gets the performance counter representing the total number of subscribers to the message bus since the application was started. - /// - [PerformanceCounter(Name = "Message Bus Subscribers Total", Description = "The total number of subscribers to the message bus since the application was started.", CounterType = PerformanceCounterType.NumberOfItems32)] - public IPerformanceCounter MessageBusSubscribersTotal { get; private set; } - - /// - /// Gets the performance counter representing the number of new subscribers to the message bus per second. - /// - [PerformanceCounter(Name = "Message Bus Subscribers/Sec", Description = "The number of new subscribers to the message bus per second.", CounterType = PerformanceCounterType.RateOfCountsPerSecond32)] - public IPerformanceCounter MessageBusSubscribersPerSec { get; private set; } - - /// - /// Gets the performance counter representing the number of workers allocated to deliver messages in the message bus. - /// - [PerformanceCounter(Name = "Message Bus Allocated Workers", Description = "The number of workers allocated to deliver messages in the message bus.", CounterType = PerformanceCounterType.NumberOfItems32)] - public IPerformanceCounter MessageBusAllocatedWorkers { get; private set; } - - /// - /// Gets the performance counter representing the number of workers currently busy delivering messages in the message bus. - /// - [PerformanceCounter(Name = "Message Bus Busy Workers", Description = "The number of workers currently busy delivering messages in the message bus.", CounterType = PerformanceCounterType.NumberOfItems32)] - public IPerformanceCounter MessageBusBusyWorkers { get; private set; } - - /// - /// Gets the performance counter representing representing the current number of topics in the message bus. - /// - [PerformanceCounter(Name = "Message Bus Topics Current", Description = "The number of topics in the message bus.", CounterType = PerformanceCounterType.NumberOfItems32)] - public IPerformanceCounter MessageBusTopicsCurrent { get; private set; } - - /// - /// Gets the performance counter representing the total number of all errors processed since the application was started. - /// - [PerformanceCounter(Name = "Errors: All Total", Description = "The total number of all errors processed since the application was started.", CounterType = PerformanceCounterType.NumberOfItems32)] - public IPerformanceCounter ErrorsAllTotal { get; private set; } - - /// - /// Gets the performance counter representing the number of all errors processed per second. - /// - [PerformanceCounter(Name = "Errors: All/Sec", Description = "The number of all errors processed per second.", CounterType = PerformanceCounterType.RateOfCountsPerSecond32)] - public IPerformanceCounter ErrorsAllPerSec { get; private set; } - - /// - /// Gets the performance counter representing the total number of hub resolution errors processed since the application was started. - /// - [PerformanceCounter(Name = "Errors: Hub Resolution Total", Description = "The total number of hub resolution errors processed since the application was started.", CounterType = PerformanceCounterType.NumberOfItems32)] - public IPerformanceCounter ErrorsHubResolutionTotal { get; private set; } - - /// - /// Gets the performance counter representing the number of hub resolution errors per second. - /// - [PerformanceCounter(Name = "Errors: Hub Resolution/Sec", Description = "The number of hub resolution errors per second.", CounterType = PerformanceCounterType.RateOfCountsPerSecond32)] - public IPerformanceCounter ErrorsHubResolutionPerSec { get; private set; } - - /// - /// Gets the performance counter representing the total number of hub invocation errors processed since the application was started. - /// - [PerformanceCounter(Name = "Errors: Hub Invocation Total", Description = "The total number of hub invocation errors processed since the application was started.", CounterType = PerformanceCounterType.NumberOfItems32)] - public IPerformanceCounter ErrorsHubInvocationTotal { get; private set; } - - /// - /// Gets the performance counter representing the number of hub invocation errors per second. - /// - [PerformanceCounter(Name = "Errors: Hub Invocation/Sec", Description = "The number of hub invocation errors per second.", CounterType = PerformanceCounterType.RateOfCountsPerSecond32)] - public IPerformanceCounter ErrorsHubInvocationPerSec { get; private set; } - - /// - /// Gets the performance counter representing the total number of transport errors processed since the application was started. - /// - [PerformanceCounter(Name = "Errors: Tranport Total", Description = "The total number of transport errors processed since the application was started.", CounterType = PerformanceCounterType.NumberOfItems32)] - public IPerformanceCounter ErrorsTransportTotal { get; private set; } - - /// - /// Gets the performance counter representing the number of transport errors per second. - /// - [PerformanceCounter(Name = "Errors: Transport/Sec", Description = "The number of transport errors per second.", CounterType = PerformanceCounterType.RateOfCountsPerSecond32)] - public IPerformanceCounter ErrorsTransportPerSec { get; private set; } - - - /// - /// Gets the performance counter representing the number of logical streams in the currently configured scaleout message bus provider. - /// - [PerformanceCounter(Name = "Scaleout Streams Total", Description = "The number of logical streams in the currently configured scaleout message bus provider.", CounterType = PerformanceCounterType.NumberOfItems32)] - public IPerformanceCounter ScaleoutStreamCountTotal { get; private set; } - - /// - /// Gets the performance counter representing the number of logical streams in the currently configured scaleout message bus provider that are in the open state. - /// - [PerformanceCounter(Name = "Scaleout Streams Open", Description = "The number of logical streams in the currently configured scaleout message bus provider that are in the open state", CounterType = PerformanceCounterType.NumberOfItems32)] - public IPerformanceCounter ScaleoutStreamCountOpen { get; private set; } - - /// - /// Gets the performance counter representing the number of logical streams in the currently configured scaleout message bus provider that are in the buffering state. - /// - [PerformanceCounter(Name = "Scaleout Streams Buffering", Description = "The number of logical streams in the currently configured scaleout message bus provider that are in the buffering state", CounterType = PerformanceCounterType.NumberOfItems32)] - public IPerformanceCounter ScaleoutStreamCountBuffering { get; private set; } - - /// - /// Gets the performance counter representing the total number of scaleout errors since the application was started. - /// - [PerformanceCounter(Name = "Scaleout Errors Total", Description = "The total number of scaleout errors since the application was started.", CounterType = PerformanceCounterType.NumberOfItems32)] - public IPerformanceCounter ScaleoutErrorsTotal { get; private set; } - - /// - /// Gets the performance counter representing the number of scaleout errors per second. - /// - [PerformanceCounter(Name = "Scaleout Errors/Sec", Description = "The number of scaleout errors per second.", CounterType = PerformanceCounterType.RateOfCountsPerSecond32)] - public IPerformanceCounter ScaleoutErrorsPerSec { get; private set; } - - /// - /// Gets the performance counter representing the current scaleout send queue length. - /// - [PerformanceCounter(Name = "Scaleout Send Queue Length", Description = "The current scaleout send queue length.", CounterType = PerformanceCounterType.NumberOfItems32)] - public IPerformanceCounter ScaleoutSendQueueLength { get; private set; } - - /// - /// Initializes the performance counters. - /// - /// The host instance name. - /// The CancellationToken representing the host shutdown. - public void Initialize(string instanceName, CancellationToken hostShutdownToken) - { - if (_initialized) - { - return; - } - - var needToRegisterWithShutdownToken = false; - lock (_initLocker) - { - if (!_initialized) - { - instanceName = instanceName ?? Guid.NewGuid().ToString(); - SetCounterProperties(instanceName); - // The initializer ran, so let's register the shutdown cleanup - if (hostShutdownToken != CancellationToken.None) - { - needToRegisterWithShutdownToken = true; - } - _initialized = true; - } - } - - if (needToRegisterWithShutdownToken) - { - hostShutdownToken.Register(UnloadCounters); - } - } - - private void UnloadCounters() - { - lock (_initLocker) - { - if (!_initialized) - { - // We were never initalized - return; - } - } - - var counterProperties = this.GetType() - .GetProperties() - .Where(p => p.PropertyType == typeof(IPerformanceCounter)); - - foreach (var property in counterProperties) - { - var counter = property.GetValue(this, null) as IPerformanceCounter; - counter.Close(); - counter.RemoveInstance(); - } - } - - private void InitNoOpCounters() - { - // Set all the counter properties to no-op by default. - // These will get reset to real counters when/if the Initialize method is called. - foreach (var property in _counterProperties) - { - property.SetValue(this, new NoOpPerformanceCounter(), null); - } - } - - private void SetCounterProperties(string instanceName) - { - var loadCounters = true; - - foreach (var property in _counterProperties) - { - PerformanceCounterAttribute attribute = GetPerformanceCounterAttribute(property); - - if (attribute == null) - { - continue; - } - - IPerformanceCounter counter = null; - - if (loadCounters) - { - counter = LoadCounter(CategoryName, attribute.Name, instanceName, isReadOnly:false); - - if (counter == null) - { - // We failed to load the counter so skip the rest - loadCounters = false; - } - } - - counter = counter ?? _noOpCounter; - - property.SetValue(this, counter, null); - } - } - - internal static PropertyInfo[] GetCounterPropertyInfo() - { - return typeof(PerformanceCounterManager) - .GetProperties() - .Where(p => p.PropertyType == typeof(IPerformanceCounter)) - .ToArray(); - } - - internal static PerformanceCounterAttribute GetPerformanceCounterAttribute(PropertyInfo property) - { - return property.GetCustomAttributes(typeof(PerformanceCounterAttribute), false) - .Cast() - .SingleOrDefault(); - } - - [SuppressMessage("Microsoft.Performance", "CA1822:MarkMembersAsStatic", Justification = "This file is shared")] - [SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Justification = "Counters are disposed later")] - public IPerformanceCounter LoadCounter(string categoryName, string counterName, string instanceName, bool isReadOnly) - { - // See http://msdn.microsoft.com/en-us/library/356cx381.aspx for the list of exceptions - // and when they are thrown. - try - { - var counter = new PerformanceCounter(categoryName, counterName, instanceName, isReadOnly); - - // Initialize the counter sample - counter.NextSample(); - - return new PerformanceCounterWrapper(counter); - } -#if UTILS - catch (InvalidOperationException) { return null; } - catch (UnauthorizedAccessException) { return null; } - catch (Win32Exception) { return null; } - catch (PlatformNotSupportedException) { return null; } -#else - catch (InvalidOperationException ex) - { - _trace.TraceEvent(TraceEventType.Error, 0, "Performance counter failed to load: " + ex.GetBaseException()); - return null; - } - catch (UnauthorizedAccessException ex) - { - _trace.TraceEvent(TraceEventType.Error, 0, "Performance counter failed to load: " + ex.GetBaseException()); - return null; - } - catch (Win32Exception ex) - { - _trace.TraceEvent(TraceEventType.Error, 0, "Performance counter failed to load: " + ex.GetBaseException()); - return null; - } - catch (PlatformNotSupportedException ex) - { - _trace.TraceEvent(TraceEventType.Error, 0, "Performance counter failed to load: " + ex.GetBaseException()); - return null; - } -#endif - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/PerformanceCounterWrapper.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/PerformanceCounterWrapper.cs deleted file mode 100644 index f455158bf..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/PerformanceCounterWrapper.cs +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Diagnostics; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - internal class PerformanceCounterWrapper : IPerformanceCounter - { - private readonly PerformanceCounter _counter; - - public PerformanceCounterWrapper(PerformanceCounter counter) - { - _counter = counter; - } - - public string CounterName - { - get - { - return _counter.CounterName; - } - } - - public long RawValue - { - get { return _counter.RawValue; } - set { _counter.RawValue = value; } - } - - public long Decrement() - { - return _counter.Decrement(); - } - - public long Increment() - { - return _counter.Increment(); - } - - public long IncrementBy(long value) - { - return _counter.IncrementBy(value); - } - - public void Close() - { - _counter.Close(); - } - - public void RemoveInstance() - { - try - { - _counter.RemoveInstance(); - } - catch(NotImplementedException) - { - // This happens on mono - } - } - - public CounterSample NextSample() - { - return _counter.NextSample(); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/PersistentConnectionContext.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/PersistentConnectionContext.cs deleted file mode 100644 index b27334591..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/PersistentConnectionContext.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - internal class PersistentConnectionContext : IPersistentConnectionContext - { - public PersistentConnectionContext(IConnection connection, IConnectionGroupManager groupManager) - { - Connection = connection; - Groups = groupManager; - } - - public IConnection Connection { get; private set; } - - public IConnectionGroupManager Groups { get; private set; } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/PrefixHelper.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/PrefixHelper.cs deleted file mode 100644 index b734952e5..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/PrefixHelper.cs +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - - -using System; -using System.Collections.Generic; -using System.Linq; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - internal static class PrefixHelper - { - // Hubs - internal const string HubPrefix = "h-"; - internal const string HubGroupPrefix = "hg-"; - internal const string HubConnectionIdPrefix = "hc-"; - - // Persistent Connections - internal const string PersistentConnectionPrefix = "pc-"; - internal const string PersistentConnectionGroupPrefix = "pcg-"; - - // Both - internal const string ConnectionIdPrefix = "c-"; - internal const string AckPrefix = "ack-"; - - public static bool HasGroupPrefix(string value) - { - return value.StartsWith(HubGroupPrefix, StringComparison.Ordinal) || - value.StartsWith(PersistentConnectionGroupPrefix, StringComparison.Ordinal); - } - - public static string GetConnectionId(string connectionId) - { - return ConnectionIdPrefix + connectionId; - } - - public static string GetHubConnectionId(string connectionId) - { - return HubConnectionIdPrefix + connectionId; - } - - public static string GetHubName(string connectionId) - { - return HubPrefix + connectionId; - } - - public static string GetHubGroupName(string groupName) - { - return HubGroupPrefix + groupName; - } - - public static string GetPersistentConnectionGroupName(string groupName) - { - return PersistentConnectionGroupPrefix + groupName; - } - - public static string GetPersistentConnectionName(string connectionName) - { - return PersistentConnectionPrefix + connectionName; - } - - public static string GetAck(string connectionId) - { - return AckPrefix + connectionId; - } - - public static IList GetPrefixedConnectionIds(IList connectionIds) - { - if (connectionIds.Count == 0) - { - return ListHelper.Empty; - } - - return connectionIds.Select(PrefixHelper.GetConnectionId).ToList(); - } - - public static IEnumerable RemoveGroupPrefixes(IEnumerable groups) - { - return groups.Select(PrefixHelper.RemoveGroupPrefix); - } - - public static string RemoveGroupPrefix(string name) - { - if (name.StartsWith(HubGroupPrefix, StringComparison.Ordinal)) - { - return name.Substring(HubGroupPrefix.Length); - } - - if (name.StartsWith(PersistentConnectionGroupPrefix, StringComparison.Ordinal)) - { - return name.Substring(PersistentConnectionGroupPrefix.Length); - } - - return name; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/Purposes.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/Purposes.cs deleted file mode 100644 index c2d87415e..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/Purposes.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - // These need to change when the format changes - public static class Purposes - { - public const string ConnectionToken = "SignalR.ConnectionToken"; - public const string Groups = "SignalR.Groups.v1.1"; - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/SafeCancellationTokenSource.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/SafeCancellationTokenSource.cs deleted file mode 100644 index e2506de52..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/SafeCancellationTokenSource.cs +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Threading; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - /// - /// Thread safe cancellation token source. Allows the following: - /// - Cancel will no-op if the token is disposed. - /// - Dispose may be called after Cancel. - /// - internal class SafeCancellationTokenSource : IDisposable - { - private CancellationTokenSource _cts; - private int _state; - - public SafeCancellationTokenSource() - { - _cts = new CancellationTokenSource(); - Token = _cts.Token; - } - - public CancellationToken Token { get; private set; } - - public void Cancel() - { - var value = Interlocked.CompareExchange(ref _state, State.Cancelling, State.Initial); - - if (value == State.Initial) - { - // Because cancellation tokens are so poorly behaved, always invoke the cancellation token on - // another thread. Don't capture any of the context (execution context or sync context) - // while doing this. -#if WINDOWS_PHONE || SILVERLIGHT - ThreadPool.QueueUserWorkItem(_ => -#elif NETFX_CORE - Task.Run(() => -#else - ThreadPool.UnsafeQueueUserWorkItem(_ => -#endif - { - try - { - _cts.Cancel(); - } - finally - { - if (Interlocked.CompareExchange(ref _state, State.Cancelled, State.Cancelling) == State.Disposing) - { - _cts.Dispose(); - Interlocked.Exchange(ref _state, State.Disposed); - } - } - } -#if !NETFX_CORE - , state: null -#endif -); - } - } - - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - var value = Interlocked.Exchange(ref _state, State.Disposing); - - switch (value) - { - case State.Initial: - case State.Cancelled: - _cts.Dispose(); - Interlocked.Exchange(ref _state, State.Disposed); - break; - case State.Cancelling: - case State.Disposing: - // No-op - break; - case State.Disposed: - Interlocked.Exchange(ref _state, State.Disposed); - break; - default: - break; - } - } - } - - public void Dispose() - { - Dispose(true); - } - - private static class State - { - public const int Initial = 0; - public const int Cancelling = 1; - public const int Cancelled = 2; - public const int Disposing = 3; - public const int Disposed = 4; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/SafeSet.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/SafeSet.cs deleted file mode 100644 index ae9827a27..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/SafeSet.cs +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - internal class SafeSet - { - private readonly ConcurrentDictionary _items; - - public SafeSet() - { - _items = new ConcurrentDictionary(); - } - - public SafeSet(IEqualityComparer comparer) - { - _items = new ConcurrentDictionary(comparer); - } - - public SafeSet(IEnumerable items) - { - _items = new ConcurrentDictionary(items.Select(x => new KeyValuePair(x, null))); - } - - public ICollection GetSnapshot() - { - // The Keys property locks, so Select instead - return _items.Keys; - } - - public bool Contains(T item) - { - return _items.ContainsKey(item); - } - - public bool Add(T item) - { - return _items.TryAdd(item, null); - } - - public bool Remove(T item) - { - object _; - return _items.TryRemove(item, out _); - } - - public bool Any() - { - return _items.Any(); - } - - public long Count - { - get { return _items.Count; } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/ServerCommand.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/ServerCommand.cs deleted file mode 100644 index 604144187..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/ServerCommand.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - /// - /// A server to server command. - /// - internal class ServerCommand - { - /// - /// Gets or sets the id of the command where this message originated from. - /// - public string ServerId { get; set; } - - /// - /// Gets of sets the command type. - /// - public ServerCommandType ServerCommandType { get; set; } - - /// - /// Gets or sets the value for this command. - /// - public object Value { get; set; } - - internal bool IsFromSelf(string serverId) - { - return serverId.Equals(ServerId); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/ServerCommandHandler.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/ServerCommandHandler.cs deleted file mode 100644 index aecdfa28a..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/ServerCommandHandler.cs +++ /dev/null @@ -1,148 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Json; -using Microsoft.AspNet.SignalR.Messaging; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - /// - /// Default implementation. - /// - internal class ServerCommandHandler : IServerCommandHandler, ISubscriber, IDisposable - { - private readonly IMessageBus _messageBus; - private readonly IServerIdManager _serverIdManager; - private readonly IJsonSerializer _serializer; - private IDisposable _subscription; - - private const int MaxMessages = 10; - - // The signal for all signalr servers - private const string ServerSignal = "__SIGNALR__SERVER__"; - private static readonly string[] ServerSignals = new[] { ServerSignal }; - - public ServerCommandHandler(IDependencyResolver resolver) : - this(resolver.Resolve(), - resolver.Resolve(), - resolver.Resolve()) - { - - } - - public ServerCommandHandler(IMessageBus messageBus, IServerIdManager serverIdManager, IJsonSerializer serializer) - { - _messageBus = messageBus; - _serverIdManager = serverIdManager; - _serializer = serializer; - - ProcessMessages(); - } - - public Action Command - { - get; - set; - } - - - public IList EventKeys - { - get - { - return ServerSignals; - } - } - - event Action ISubscriber.EventKeyAdded - { - add - { - } - remove - { - } - } - - event Action ISubscriber.EventKeyRemoved - { - add - { - } - remove - { - } - } - - public Action WriteCursor { get; set; } - - public string Identity - { - get - { - return _serverIdManager.ServerId; - } - } - - public Subscription Subscription - { - get; - set; - } - - public Task SendCommand(ServerCommand command) - { - // Store where the message originated from - command.ServerId = _serverIdManager.ServerId; - - // Send the command to the all servers - return _messageBus.Publish(_serverIdManager.ServerId, ServerSignal, _serializer.Stringify(command)); - } - - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - if (_subscription != null) - { - _subscription.Dispose(); - } - } - } - - public void Dispose() - { - Dispose(true); - } - - private void ProcessMessages() - { - // Process messages that come from the bus for servers - _subscription = _messageBus.Subscribe(this, cursor: null, callback: HandleServerCommands, maxMessages: MaxMessages, state: null); - } - - private Task HandleServerCommands(MessageResult result, object state) - { - result.Messages.Enumerate(m => ServerSignal.Equals(m.Key), - (s, m) => - { - var command = _serializer.Parse(m.Value, m.Encoding); - OnCommand(command); - }, - state: null); - - return TaskAsyncHelper.True; - } - - private void OnCommand(ServerCommand command) - { - if (Command != null) - { - Command(command); - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/ServerCommandType.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/ServerCommandType.cs deleted file mode 100644 index 058164117..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/ServerCommandType.cs +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - public enum ServerCommandType - { - RemoveConnection - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/ServerIdManager.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/ServerIdManager.cs deleted file mode 100644 index e80d5cae1..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/ServerIdManager.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - /// - /// Default implementation. - /// - public class ServerIdManager : IServerIdManager - { - public ServerIdManager() - { - ServerId = Guid.NewGuid().ToString(); - } - - /// - /// The id of the server. - /// - public string ServerId - { - get; - private set; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/SipHashBasedStringEqualityComparer.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/SipHashBasedStringEqualityComparer.cs deleted file mode 100644 index bf86a93f3..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/SipHashBasedStringEqualityComparer.cs +++ /dev/null @@ -1,242 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Security.Cryptography; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - // A string equality comparer based on the SipHash-2-4 algorithm. Key differences: - // (a) we output 32-bit hashes instead of 64-bit hashes, and - // (b) we don't care about endianness since hashes are used only in hash tables - // and aren't returned to user code. - // - // Meant to serve as a replacement for StringComparer.Ordinal. - // Derivative work of https://github.com/tanglebones/ch-siphash. - internal unsafe sealed class SipHashBasedStringEqualityComparer : IEqualityComparer - { - private static readonly RNGCryptoServiceProvider _rng = new RNGCryptoServiceProvider(); - - // the 128-bit secret key - private readonly ulong _k0; - private readonly ulong _k1; - - public SipHashBasedStringEqualityComparer() - : this(GenerateRandomKeySegment(), GenerateRandomKeySegment()) - { - } - - // for unit testing - internal SipHashBasedStringEqualityComparer(ulong k0, ulong k1) - { - _k0 = k0; - _k1 = k1; - } - - public bool Equals(string x, string y) - { - return String.Equals(x, y); - } - - private static ulong GenerateRandomKeySegment() - { - byte[] bytes = new byte[sizeof(ulong)]; - _rng.GetBytes(bytes); - return (ulong)BitConverter.ToInt64(bytes, 0); - } - - public int GetHashCode(string obj) - { - if (obj == null) - { - return 0; - } - - fixed (char* pChars = obj) - { - // treat input as an opaque blob, convert char count to byte count - return GetHashCode((byte*)pChars, checked((uint)obj.Length * sizeof(char))); - } - } - - // for unit testing - internal int GetHashCode(byte* bytes, uint len) - { - // Assume SipHash-2-4 is a strong PRF, therefore truncation to 32 bits is acceptable. - return (int)SipHash_2_4_UlongCast_ForcedInline(bytes, len, _k0, _k1); - } - - private static unsafe ulong SipHash_2_4_UlongCast_ForcedInline(byte* finb, uint inlen, ulong k0, ulong k1) - { - var v0 = 0x736f6d6570736575 ^ k0; - var v1 = 0x646f72616e646f6d ^ k1; - var v2 = 0x6c7967656e657261 ^ k0; - var v3 = 0x7465646279746573 ^ k1; - - var b = ((ulong)inlen) << 56; - - if (inlen > 0) - { - var inb = finb; - var left = inlen & 7; - var end = inb + inlen - left; - var linb = (ulong*)finb; - var lend = (ulong*)end; - for (; linb < lend; ++linb) - { - v3 ^= *linb; - - v0 += v1; - v1 = (v1 << 13) | (v1 >> (64 - 13)); - v1 ^= v0; - v0 = (v0 << 32) | (v0 >> (64 - 32)); - - v2 += v3; - v3 = (v3 << 16) | (v3 >> (64 - 16)); - v3 ^= v2; - - v0 += v3; - v3 = (v3 << 21) | (v3 >> (64 - 21)); - v3 ^= v0; - - v2 += v1; - v1 = (v1 << 17) | (v1 >> (64 - 17)); - v1 ^= v2; - v2 = (v2 << 32) | (v2 >> (64 - 32)); - v0 += v1; - v1 = (v1 << 13) | (v1 >> (64 - 13)); - v1 ^= v0; - v0 = (v0 << 32) | (v0 >> (64 - 32)); - - v2 += v3; - v3 = (v3 << 16) | (v3 >> (64 - 16)); - v3 ^= v2; - - v0 += v3; - v3 = (v3 << 21) | (v3 >> (64 - 21)); - v3 ^= v0; - - v2 += v1; - v1 = (v1 << 17) | (v1 >> (64 - 17)); - v1 ^= v2; - v2 = (v2 << 32) | (v2 >> (64 - 32)); - - v0 ^= *linb; - } - for (var i = 0; i < left; ++i) - { - b |= ((ulong)end[i]) << (8 * i); - } - } - - v3 ^= b; - v0 += v1; - v1 = (v1 << 13) | (v1 >> (64 - 13)); - v1 ^= v0; - v0 = (v0 << 32) | (v0 >> (64 - 32)); - - v2 += v3; - v3 = (v3 << 16) | (v3 >> (64 - 16)); - v3 ^= v2; - - v0 += v3; - v3 = (v3 << 21) | (v3 >> (64 - 21)); - v3 ^= v0; - - v2 += v1; - v1 = (v1 << 17) | (v1 >> (64 - 17)); - v1 ^= v2; - v2 = (v2 << 32) | (v2 >> (64 - 32)); - v0 += v1; - v1 = (v1 << 13) | (v1 >> (64 - 13)); - v1 ^= v0; - v0 = (v0 << 32) | (v0 >> (64 - 32)); - - v2 += v3; - v3 = (v3 << 16) | (v3 >> (64 - 16)); - v3 ^= v2; - - v0 += v3; - v3 = (v3 << 21) | (v3 >> (64 - 21)); - v3 ^= v0; - - v2 += v1; - v1 = (v1 << 17) | (v1 >> (64 - 17)); - v1 ^= v2; - v2 = (v2 << 32) | (v2 >> (64 - 32)); - v0 ^= b; - v2 ^= 0xff; - - v0 += v1; - v1 = (v1 << 13) | (v1 >> (64 - 13)); - v1 ^= v0; - v0 = (v0 << 32) | (v0 >> (64 - 32)); - - v2 += v3; - v3 = (v3 << 16) | (v3 >> (64 - 16)); - v3 ^= v2; - - v0 += v3; - v3 = (v3 << 21) | (v3 >> (64 - 21)); - v3 ^= v0; - - v2 += v1; - v1 = (v1 << 17) | (v1 >> (64 - 17)); - v1 ^= v2; - v2 = (v2 << 32) | (v2 >> (64 - 32)); - v0 += v1; - v1 = (v1 << 13) | (v1 >> (64 - 13)); - v1 ^= v0; - v0 = (v0 << 32) | (v0 >> (64 - 32)); - - v2 += v3; - v3 = (v3 << 16) | (v3 >> (64 - 16)); - v3 ^= v2; - - v0 += v3; - v3 = (v3 << 21) | (v3 >> (64 - 21)); - v3 ^= v0; - - v2 += v1; - v1 = (v1 << 17) | (v1 >> (64 - 17)); - v1 ^= v2; - v2 = (v2 << 32) | (v2 >> (64 - 32)); - v0 += v1; - v1 = (v1 << 13) | (v1 >> (64 - 13)); - v1 ^= v0; - v0 = (v0 << 32) | (v0 >> (64 - 32)); - - v2 += v3; - v3 = (v3 << 16) | (v3 >> (64 - 16)); - v3 ^= v2; - - v0 += v3; - v3 = (v3 << 21) | (v3 >> (64 - 21)); - v3 ^= v0; - - v2 += v1; - v1 = (v1 << 17) | (v1 >> (64 - 17)); - v1 ^= v2; - v2 = (v2 << 32) | (v2 >> (64 - 32)); - v0 += v1; - v1 = (v1 << 13) | (v1 >> (64 - 13)); - v1 ^= v0; - v0 = (v0 << 32) | (v0 >> (64 - 32)); - - v2 += v3; - v3 = (v3 << 16) | (v3 >> (64 - 16)); - v3 ^= v2; - - v0 += v3; - v3 = (v3 << 21) | (v3 >> (64 - 21)); - v3 ^= v0; - - v2 += v1; - v1 = (v1 << 17) | (v1 >> (64 - 17)); - v1 ^= v2; - v2 = (v2 << 32) | (v2 >> (64 - 32)); - - return v0 ^ v1 ^ v2 ^ v3; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/StringMinifier.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/StringMinifier.cs deleted file mode 100644 index ecbb249ef..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/StringMinifier.cs +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; -using System.Threading; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - internal class StringMinifier : IStringMinifier - { - private readonly ConcurrentDictionary _stringMinifier = new ConcurrentDictionary(); - private readonly ConcurrentDictionary _stringMaximizer = new ConcurrentDictionary(); - private int _lastMinifiedKey = -1; - - private readonly Func _createMinifiedString; - - public StringMinifier() - { - _createMinifiedString = CreateMinifiedString; - } - - public string Minify(string fullString) - { - return _stringMinifier.GetOrAdd(fullString, _createMinifiedString); - } - - public string Unminify(string minifiedString) - { - string result; - _stringMaximizer.TryGetValue(minifiedString, out result); - return result; - } - - public void RemoveUnminified(string fullString) - { - string minifiedString; - if (_stringMinifier.TryRemove(fullString, out minifiedString)) - { - string value; - _stringMaximizer.TryRemove(minifiedString, out value); - } - } - - private string CreateMinifiedString(string fullString) - { - var minString = GetStringFromInt((uint)Interlocked.Increment(ref _lastMinifiedKey)); - _stringMaximizer.TryAdd(minString, fullString); - return minString; - } - - [SuppressMessage("Microsoft.Usage", "CA2201:DoNotRaiseReservedExceptionTypes", Justification = "This is a valid exception to throw.")] - private static char GetCharFromSixBitInt(uint num) - { - if (num < 26) - { - return (char)(num + 'A'); - } - if (num < 52) - { - return (char)(num - 26 + 'a'); - } - if (num < 62) - { - return (char)(num - 52 + '0'); - } - if (num == 62) - { - return '_'; - } - if (num == 63) - { - return ':'; - } - throw new IndexOutOfRangeException(); - } - - private static string GetStringFromInt(uint num) - { - const int maxSize = 6; - - // Buffer must be large enough to store any 32 bit uint at 6 bits per character - var buffer = new char[maxSize]; - var index = maxSize; - do - { - // Append next 6 bits of num - buffer[--index] = GetCharFromSixBitInt(num & 0x3f); - num >>= 6; - - // Don't pad output string, but ensure at least one character is written - } while (num != 0); - - return new string(buffer, index, maxSize - index); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/TaskQueue.cs b/src/Microsoft.AspNet.SignalR.Core/Infrastructure/TaskQueue.cs deleted file mode 100644 index 1c3d63489..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/TaskQueue.cs +++ /dev/null @@ -1,128 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Diagnostics.CodeAnalysis; -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - // Allows serial queuing of Task instances - // The tasks are not called on the current synchronization context - - internal sealed class TaskQueue - { - private readonly object _lockObj = new object(); - private Task _lastQueuedTask; - private volatile bool _drained; - private readonly int? _maxSize; - private long _size; - - public TaskQueue() - : this(TaskAsyncHelper.Empty) - { - } - - public TaskQueue(Task initialTask) - { - _lastQueuedTask = initialTask; - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is shared code")] - public TaskQueue(Task initialTask, int maxSize) - { - _lastQueuedTask = initialTask; - _maxSize = maxSize; - } - -#if !CLIENT_NET45 && !CLIENT_NET4 && !NETFX_CORE && !SILVERLIGHT - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is shared code.")] - public IPerformanceCounter QueueSizeCounter { get; set; } -#endif - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is shared code")] - public bool IsDrained - { - get - { - return _drained; - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is shared code")] - public Task Enqueue(Func taskFunc, object state) - { - // Lock the object for as short amount of time as possible - lock (_lockObj) - { - if (_drained) - { - return _lastQueuedTask; - } - - if (_maxSize != null) - { - // Increment the size if the queue - if (Interlocked.Increment(ref _size) > _maxSize) - { - Interlocked.Decrement(ref _size); - - // We failed to enqueue because the size limit was reached - return null; - } - -#if !CLIENT_NET45 && !CLIENT_NET4 && !NETFX_CORE && !SILVERLIGHT - var counter = QueueSizeCounter; - if (counter != null) - { - counter.Increment(); - } -#endif - } - - Task newTask = _lastQueuedTask.Then((next, nextState) => - { - return next(nextState).Finally(s => - { - var queue = (TaskQueue)s; - if (queue._maxSize != null) - { - // Decrement the number of items left in the queue - Interlocked.Decrement(ref queue._size); - -#if !CLIENT_NET45 && !CLIENT_NET4 && !NETFX_CORE && !SILVERLIGHT - var counter = QueueSizeCounter; - if (counter != null) - { - counter.Decrement(); - } -#endif - } - }, - this); - }, - taskFunc, state); - - _lastQueuedTask = newTask; - return newTask; - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is shared code")] - public Task Enqueue(Func taskFunc) - { - return Enqueue(state => ((Func)state).Invoke(), taskFunc); - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is shared code")] - public Task Drain() - { - lock (_lockObj) - { - _drained = true; - - return _lastQueuedTask; - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Json/IJsonSerializer.cs b/src/Microsoft.AspNet.SignalR.Core/Json/IJsonSerializer.cs deleted file mode 100644 index d2ee8705d..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Json/IJsonSerializer.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.IO; - -namespace Microsoft.AspNet.SignalR.Json -{ - /// - /// Used to serialize and deserialize outgoing/incoming data. - /// - public interface IJsonSerializer - { - /// - /// Serializes the specified object to a . - /// - /// The object to serialize - /// The to serialize the object to. - void Serialize(object value, TextWriter writer); - - /// - /// Deserializes the JSON to a .NET object. - /// - /// The to deserialize the object from. - /// The of object being deserialized. - /// The deserialized object from the JSON string. - object Parse(TextReader reader, Type targetType); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Json/IJsonValue.cs b/src/Microsoft.AspNet.SignalR.Core/Json/IJsonValue.cs deleted file mode 100644 index 4e462abb7..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Json/IJsonValue.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; - -namespace Microsoft.AspNet.SignalR.Json -{ - /// - /// Represents a JSON value. - /// - public interface IJsonValue - { - /// - /// Converts the parameter value to the specified . - /// - /// The to convert the parameter to. - /// The converted parameter value. - object ConvertTo(Type type); - - /// - /// Determines if the parameter can be converted to the specified . - /// - /// The to check. - /// True if the parameter can be converted to the specified , false otherwise. - bool CanConvertTo(Type type); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Json/IJsonWritable.cs b/src/Microsoft.AspNet.SignalR.Core/Json/IJsonWritable.cs deleted file mode 100644 index d1b73ab37..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Json/IJsonWritable.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.IO; - -namespace Microsoft.AspNet.SignalR.Json -{ - /// - /// Implementations handle their own serialization to JSON. - /// - public interface IJsonWritable - { - /// - /// Serializes itself to JSON via a . - /// - /// The that receives the JSON serialized object. - void WriteJson(TextWriter writer); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Json/JRawValue.cs b/src/Microsoft.AspNet.SignalR.Core/Json/JRawValue.cs deleted file mode 100644 index df22e7078..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Json/JRawValue.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.IO; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -namespace Microsoft.AspNet.SignalR.Json -{ - /// - /// An implementation of IJsonValue over JSON.NET - /// - internal class JRawValue : IJsonValue - { - private readonly string _value; - - public JRawValue(JRaw value) - { - _value = value.ToString(); - } - - public object ConvertTo(Type type) - { - // A non generic implementation of ToObject on JToken - using (var jsonReader = new StringReader(_value)) - { - var settings = new JsonSerializerSettings - { - MaxDepth = 20 - }; - var serializer = JsonSerializer.Create(settings); - return serializer.Deserialize(jsonReader, type); - } - } - - public bool CanConvertTo(Type type) - { - // TODO: Implement when we implement better method overload resolution - return true; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Json/JsonNetSerializer.cs b/src/Microsoft.AspNet.SignalR.Core/Json/JsonNetSerializer.cs deleted file mode 100644 index 4b0f53cf5..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Json/JsonNetSerializer.cs +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.IO; -using Newtonsoft.Json; - -namespace Microsoft.AspNet.SignalR.Json -{ - /// - /// Default implementation over Json.NET. - /// - public class JsonNetSerializer : IJsonSerializer - { - private readonly JsonSerializer _serializer; - - /// - /// Initializes a new instance of the class. - /// - public JsonNetSerializer() - : this(new JsonSerializerSettings()) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The to use when serializing and deserializing. - public JsonNetSerializer(JsonSerializerSettings settings) - { - if (settings == null) - { - throw new ArgumentNullException("settings"); - } - - // Just override it anyways (we're saving the user) - settings.MaxDepth = 20; - _serializer = JsonSerializer.Create(settings); - } - - /// - /// Deserializes the JSON to a .NET object. - /// - /// The JSON to deserialize. - /// The of object being deserialized. - /// The deserialized object from the JSON string. - public object Parse(TextReader reader, Type targetType) - { - return _serializer.Deserialize(reader, targetType); - } - - /// - /// Serializes the specified object to a . - /// - /// The object to serialize - /// The to serialize the object to. - public void Serialize(object value, TextWriter writer) - { - var selfSerializer = value as IJsonWritable; - if (selfSerializer != null) - { - selfSerializer.WriteJson(writer); - } - else - { - _serializer.Serialize(writer, value); - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Json/JsonSerializerExtensions.cs b/src/Microsoft.AspNet.SignalR.Core/Json/JsonSerializerExtensions.cs deleted file mode 100644 index 2a34ad264..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Json/JsonSerializerExtensions.cs +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Globalization; -using System.IO; -using System.Text; -using Microsoft.AspNet.SignalR.Infrastructure; - -namespace Microsoft.AspNet.SignalR.Json -{ - /// - /// Extensions for . - /// - public static class JsonSerializerExtensions - { - /// - /// Deserializes the JSON to a .NET object. - /// - /// The serializer - /// The of object being deserialized. - /// The JSON to deserialize - /// The deserialized object from the JSON string. - public static T Parse(this IJsonSerializer serializer, string json) - { - if (serializer == null) - { - throw new ArgumentNullException("serializer"); - } - - using (var reader = new StringReader(json)) - { - return (T)serializer.Parse(reader, typeof(T)); - } - } - - /// - /// Deserializes the JSON to a .NET object. - /// - /// The serializer - /// The of object being deserialized. - /// The JSON buffer to deserialize - /// The encoding to use. - /// The deserialized object from the JSON string. - public static T Parse(this IJsonSerializer serializer, ArraySegment jsonBuffer, Encoding encoding) - { - if (serializer == null) - { - throw new ArgumentNullException("serializer"); - } - - using (var reader = new ArraySegmentTextReader(jsonBuffer, encoding)) - { - return (T)serializer.Parse(reader, typeof(T)); - } - } - - /// - /// Serializes the specified object to a JSON string. - /// - /// The serializer - /// The object to serailize. - /// A JSON string representation of the object. - public static string Stringify(this IJsonSerializer serializer, object value) - { - if (serializer == null) - { - throw new ArgumentNullException("serializer"); - } - - using (var writer = new StringWriter(CultureInfo.InvariantCulture)) - { - serializer.Serialize(value, writer); - return writer.ToString(); - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Json/JsonUtility.cs b/src/Microsoft.AspNet.SignalR.Core/Json/JsonUtility.cs deleted file mode 100644 index 534b261ba..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Json/JsonUtility.cs +++ /dev/null @@ -1,124 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Globalization; -using System.Linq; -using System.Text; - -namespace Microsoft.AspNet.SignalR.Json -{ - /// - /// Helper class for common JSON operations. - /// - public static class JsonUtility - { - // JavaScript keywords taken from http://www.ecma-international.org/publications/files/ECMA-ST/Ecma-262.pdf - // Sections: 7.6.1.1, 7.6.1.2 - // Plus the implicity globals "NaN", "undefined", "Infinity" - private static readonly string[] _jsKeywords = new[] { "break", "do", "instanceof", "typeof", "case", "else", "new", "var", "catch", "finally", "return", "void", "continue", "for", "switch", "while", "debugger", "function", "this", "with", "default", "if", "throw", "delete", "in", "try", "class", "enum", "extends", "super", "const", "export", "import", "implements", "let", "private", "public", "yield", "interface", "package", "protected", "static", "NaN", "undefined", "Infinity" }; - - /// - /// Converts the specified name to camel case. - /// - /// The name to convert. - /// A camel cased version of the specified name. - public static string CamelCase(string name) - { - if (name == null) - { - throw new ArgumentNullException("name"); - } - - return String.Join(".", name.Split('.').Select(n => Char.ToLower(n[0], CultureInfo.InvariantCulture) + n.Substring(1))); - } - - /// - /// Gets a string that returns JSON mime type "application/json; charset=UTF-8". - /// - public static string JsonMimeType - { - get { return "application/json; charset=UTF-8"; } - } - - /// - /// Gets a string that returns JSONP mime type "application/javascript; charset=UTF-8". - /// - public static string JavaScriptMimeType - { - get { return "application/javascript; charset=UTF-8"; } - } - - public static string CreateJsonpCallback(string callback, string payload) - { - var sb = new StringBuilder(); - if (!IsValidJavaScriptCallback(callback)) - { - throw new InvalidOperationException(); - } - sb.AppendFormat("{0}(", callback).Append(payload).Append(");"); - return sb.ToString(); - } - - internal static bool IsValidJavaScriptCallback(string callback) - { - if (String.IsNullOrWhiteSpace(callback)) - { - return false; - } - - var identifiers = callback.Split('.'); - - // Check each identifier to ensure it's a valid JS identifier - foreach (var identifier in identifiers) - { - if (!IsValidJavaScriptFunctionName(identifier)) - { - return false; - } - } - - return true; - } - - internal static bool IsValidJavaScriptFunctionName(string name) - { - if (String.IsNullOrWhiteSpace(name) || IsJavaScriptReservedWord(name)) - { - return false; - } - - // JavaScript identifier must start with a letter or a '$' or an '_' char - var firstChar = name[0]; - if (!IsValidJavaScriptIdentifierStartChar(firstChar)) - { - return false; - } - - for (var i = 1; i < name.Length; i++) - { - // Characters can be a letter, digit, '$' or '_' - if (!IsValidJavaScriptIdenfitierNonStartChar(name[i])) - { - return false; - } - } - - return true; - } - - private static bool IsValidJavaScriptIdentifierStartChar(char startChar) - { - return Char.IsLetter(startChar) || startChar == '$' || startChar == '_'; - } - - private static bool IsValidJavaScriptIdenfitierNonStartChar(char identifierChar) - { - return Char.IsLetterOrDigit(identifierChar) || identifierChar == '$' || identifierChar == '_'; - } - - private static bool IsJavaScriptReservedWord(string word) - { - return _jsKeywords.Contains(word); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Json/SipHashBasedDictionaryConverter.cs b/src/Microsoft.AspNet.SignalR.Core/Json/SipHashBasedDictionaryConverter.cs deleted file mode 100644 index 418d53cf2..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Json/SipHashBasedDictionaryConverter.cs +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using Microsoft.AspNet.SignalR.Infrastructure; -using Newtonsoft.Json; - -namespace Microsoft.AspNet.SignalR.Json -{ - /// - /// A converter for dictionaries that uses a SipHash comparer - /// - internal class SipHashBasedDictionaryConverter : JsonConverter - { - public override bool CanWrite - { - get - { - return false; - } - } - - public override bool CanConvert(Type objectType) - { - return objectType == typeof(IDictionary); - } - - public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) - { - return ReadJsonObject(reader); - } - - private object ReadJsonObject(JsonReader reader) - { - switch (reader.TokenType) - { - case JsonToken.StartObject: - return ReadObject(reader); - case JsonToken.StartArray: - return ReadArray(reader); - case JsonToken.Integer: - case JsonToken.Float: - case JsonToken.String: - case JsonToken.Boolean: - case JsonToken.Undefined: - case JsonToken.Null: - case JsonToken.Date: - case JsonToken.Bytes: - return reader.Value; - default: - throw new NotSupportedException(); - - } - } - - private object ReadArray(JsonReader reader) - { - var array = new List(); - - while (reader.Read()) - { - switch (reader.TokenType) - { - default: - object value = ReadJsonObject(reader); - - array.Add(value); - break; - case JsonToken.EndArray: - return array; - } - } - - throw new JsonSerializationException(Resources.Error_ParseObjectFailed); - } - - private object ReadObject(JsonReader reader) - { - var obj = new Dictionary(new SipHashBasedStringEqualityComparer()); - - while (reader.Read()) - { - switch (reader.TokenType) - { - case JsonToken.PropertyName: - string propertyName = reader.Value.ToString(); - - if (!reader.Read()) - { - throw new JsonSerializationException(Resources.Error_ParseObjectFailed); - } - - object value = ReadJsonObject(reader); - - obj[propertyName] = value; - break; - case JsonToken.EndObject: - return obj; - default: - throw new JsonSerializationException(Resources.Error_ParseObjectFailed); - - } - } - - throw new JsonSerializationException(Resources.Error_ParseObjectFailed); - } - - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) - { - throw new NotImplementedException(); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/Command.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/Command.cs deleted file mode 100644 index c14c97af9..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/Command.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; - -namespace Microsoft.AspNet.SignalR.Messaging -{ - public class Command - { - public Command() - { - Id = Guid.NewGuid().ToString(); - } - - public bool WaitForAck { get; set; } - public string Id { get; private set; } - public CommandType CommandType { get; set; } - public string Value { get; set; } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/CommandType.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/CommandType.cs deleted file mode 100644 index 12bff330b..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/CommandType.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -namespace Microsoft.AspNet.SignalR.Messaging -{ - public enum CommandType - { - AddToGroup, - RemoveFromGroup, - Disconnect, - Abort - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/Cursor.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/Cursor.cs deleted file mode 100644 index ad915b562..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/Cursor.cs +++ /dev/null @@ -1,264 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Text; - -namespace Microsoft.AspNet.SignalR.Messaging -{ - internal unsafe class Cursor - { - private static char[] _escapeChars = new[] { '\\', '|', ',' }; - private string _escapedKey; - - public string Key { get; private set; } - public ulong Id { get; set; } - - public static Cursor Clone(Cursor cursor) - { - return new Cursor(cursor.Key, cursor.Id, cursor._escapedKey); - } - - public Cursor(string key, ulong id) - : this(key, id, Escape(key)) - { - } - - public Cursor(string key, ulong id, string minifiedKey) - { - Key = key; - Id = id; - _escapedKey = minifiedKey; - } - - public static void WriteCursors(TextWriter textWriter, IList cursors, string prefix) - { - textWriter.Write(prefix); - - for (int i = 0; i < cursors.Count; i++) - { - if (i > 0) - { - textWriter.Write('|'); - } - Cursor cursor = cursors[i]; - textWriter.Write(cursor._escapedKey); - textWriter.Write(','); - WriteUlongAsHexToBuffer(cursor.Id, textWriter); - } - } - - internal static void WriteUlongAsHexToBuffer(ulong value, TextWriter textWriter) - { - // This tracks the length of the output and serves as the index for the next character to be written into the pBuffer. - // The length could reach up to 16 characters, so at least that much space should remain in the pBuffer. - int length = 0; - - // Write the hex value from left to right into the buffer without zero padding. - for (int i = 0; i < 16; i++) - { - // Convert the first 4 bits of the value to a valid hex character. - char hexChar = Int32ToHex((int)(value >> 60)); - value <<= 4; - - // Don't increment length if it would just add zero padding - if (length != 0 || hexChar != '0') - { - textWriter.Write(hexChar); - length++; - } - } - - if (length == 0) - { - textWriter.Write('0'); - } - } - - private static char Int32ToHex(int value) - { - return (value < 10) ? (char)(value + '0') : (char)(value - 10 + 'A'); - } - - private static string Escape(string value) - { - // Nothing to do, so bail - if (value.IndexOfAny(_escapeChars) == -1) - { - return value; - } - - var sb = new StringBuilder(); - // \\ = \ - // \| = | - // \, = , - foreach (var ch in value) - { - switch (ch) - { - case '\\': - sb.Append('\\').Append(ch); - break; - case '|': - sb.Append('\\').Append(ch); - break; - case ',': - sb.Append('\\').Append(ch); - break; - default: - sb.Append(ch); - break; - } - } - - return sb.ToString(); - } - - public static List GetCursors(string cursor, string prefix) - { - return GetCursors(cursor, prefix, s => s); - } - - public static List GetCursors(string cursor, string prefix, Func keyMaximizer) - { - return GetCursors(cursor, prefix, (key, state) => ((Func)state).Invoke(key), keyMaximizer); - } - - public static List GetCursors(string cursor, string prefix, Func keyMaximizer, object state) - { - // Technically GetCursors should never be called with a null value, so this is extra cautious - if (String.IsNullOrEmpty(cursor)) - { - throw new FormatException(Resources.Error_InvalidCursorFormat); - } - - // If the cursor does not begin with the prefix stream, it isn't necessarily a formatting problem. - // The cursor with a different prefix might have had different, but also valid, formatting. - // Null should be returned so new cursors will be generated - if (!cursor.StartsWith(prefix, StringComparison.Ordinal)) - { - return null; - } - - var signals = new HashSet(); - var cursors = new List(); - string currentKey = null; - string currentEscapedKey = null; - ulong currentId; - bool escape = false; - bool consumingKey = true; - var sb = new StringBuilder(); - var sbEscaped = new StringBuilder(); - Cursor parsedCursor; - - for (int i = prefix.Length; i < cursor.Length; i++) - { - var ch = cursor[i]; - - // escape can only be true if we are consuming the key - if (escape) - { - if (ch != '\\' && ch != ',' && ch != '|') - { - throw new FormatException(Resources.Error_InvalidCursorFormat); - } - - sb.Append(ch); - sbEscaped.Append(ch); - escape = false; - } - else - { - if (ch == '\\') - { - if (!consumingKey) - { - throw new FormatException(Resources.Error_InvalidCursorFormat); - } - - sbEscaped.Append('\\'); - escape = true; - } - else if (ch == ',') - { - if (!consumingKey) - { - throw new FormatException(Resources.Error_InvalidCursorFormat); - } - - // For now String.Empty is an acceptable key, but this should change once we verify - // that empty keys cannot be created legitimately. - currentKey = keyMaximizer(sb.ToString(), state); - - // If the keyMap cannot find a key, we cannot create an array of cursors. - // This most likely means there was an AppDomain restart or a misbehaving client. - if (currentKey == null) - { - return null; - } - // Don't allow duplicate keys - if (!signals.Add(currentKey)) - { - throw new FormatException(Resources.Error_InvalidCursorFormat); - } - - currentEscapedKey = sbEscaped.ToString(); - - sb.Clear(); - sbEscaped.Clear(); - consumingKey = false; - } - else if (ch == '|') - { - if (consumingKey) - { - throw new FormatException(Resources.Error_InvalidCursorFormat); - } - - ParseCursorId(sb, out currentId); - - parsedCursor = new Cursor(currentKey, currentId, currentEscapedKey); - - cursors.Add(parsedCursor); - sb.Clear(); - consumingKey = true; - } - else - { - sb.Append(ch); - if (consumingKey) - { - sbEscaped.Append(ch); - } - } - } - } - - if (consumingKey) - { - throw new FormatException(Resources.Error_InvalidCursorFormat); - } - - ParseCursorId(sb, out currentId); - - parsedCursor = new Cursor(currentKey, currentId, currentEscapedKey); - - cursors.Add(parsedCursor); - - return cursors; - } - - private static void ParseCursorId(StringBuilder sb, out ulong id) - { - string value = sb.ToString(); - id = UInt64.Parse(value, NumberStyles.HexNumber, CultureInfo.InvariantCulture); - } - - public override string ToString() - { - return Key; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/DefaultSubscription.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/DefaultSubscription.cs deleted file mode 100644 index 08e4b8049..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/DefaultSubscription.cs +++ /dev/null @@ -1,239 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.IO; -using System.Security.Cryptography; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Infrastructure; - -namespace Microsoft.AspNet.SignalR.Messaging -{ - internal class DefaultSubscription : Subscription - { - internal static string _defaultCursorPrefix = GetCursorPrefix(); - - private List _cursors; - private List _cursorTopics; - - private readonly IStringMinifier _stringMinifier; - - public DefaultSubscription(string identity, - IList eventKeys, - TopicLookup topics, - string cursor, - Func> callback, - int maxMessages, - IStringMinifier stringMinifier, - IPerformanceCounterManager counters, - object state) : - base(identity, eventKeys, callback, maxMessages, counters, state) - { - _stringMinifier = stringMinifier; - - if (String.IsNullOrEmpty(cursor)) - { - _cursors = GetCursorsFromEventKeys(EventKeys, topics); - } - else - { - // Ensure delegate continues to use the C# Compiler static delegate caching optimization. - _cursors = Cursor.GetCursors(cursor, _defaultCursorPrefix, (k, s) => UnminifyCursor(k, s), stringMinifier) ?? GetCursorsFromEventKeys(EventKeys, topics); - } - - _cursorTopics = new List(); - - if (!String.IsNullOrEmpty(cursor)) - { - // Update all of the cursors so we're within the range - for (int i = _cursors.Count - 1; i >= 0; i--) - { - Cursor c = _cursors[i]; - Topic topic; - if (!EventKeys.Contains(c.Key)) - { - _cursors.Remove(c); - } - else if (!topics.TryGetValue(_cursors[i].Key, out topic) || _cursors[i].Id > topic.Store.GetMessageCount()) - { - UpdateCursor(c.Key, 0); - } - } - } - - // Add dummy entries so they can be filled in - for (int i = 0; i < _cursors.Count; i++) - { - _cursorTopics.Add(null); - } - } - - private static string UnminifyCursor(string key, object state) - { - return ((IStringMinifier)state).Unminify(key); - } - - public override bool AddEvent(string eventKey, Topic topic) - { - base.AddEvent(eventKey, topic); - - lock (_cursors) - { - // O(n), but small n and it's not common - var index = _cursors.FindIndex(c => c.Key == eventKey); - if (index == -1) - { - _cursors.Add(new Cursor(eventKey, GetMessageId(topic), _stringMinifier.Minify(eventKey))); - - _cursorTopics.Add(topic); - - return true; - } - - return false; - } - } - - public override void RemoveEvent(string eventKey) - { - base.RemoveEvent(eventKey); - - lock (_cursors) - { - var index = _cursors.FindIndex(c => c.Key == eventKey); - if (index != -1) - { - _cursors.RemoveAt(index); - _cursorTopics.RemoveAt(index); - } - } - } - - public override void SetEventTopic(string eventKey, Topic topic) - { - base.SetEventTopic(eventKey, topic); - - lock (_cursors) - { - // O(n), but small n and it's not common - var index = _cursors.FindIndex(c => c.Key == eventKey); - if (index != -1) - { - _cursorTopics[index] = topic; - } - } - } - - public override void WriteCursor(TextWriter textWriter) - { - lock (_cursors) - { - Cursor.WriteCursors(textWriter, _cursors, _defaultCursorPrefix); - } - } - - [SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "0", Justification = "It is called from the base class")] - protected override void PerformWork(IList> items, out int totalCount, out object state) - { - totalCount = 0; - - lock (_cursors) - { - var cursors = new ulong[_cursors.Count]; - for (int i = 0; i < _cursors.Count; i++) - { - MessageStoreResult storeResult = _cursorTopics[i].Store.GetMessages(_cursors[i].Id, MaxMessages); - cursors[i] = storeResult.FirstMessageId + (ulong)storeResult.Messages.Count; - - if (storeResult.Messages.Count > 0) - { - items.Add(storeResult.Messages); - totalCount += storeResult.Messages.Count; - } - } - - // Return the state as a list of cursors - state = cursors; - } - } - - protected override void BeforeInvoke(object state) - { - lock (_cursors) - { - // Update the list of cursors before invoking anything - var nextCursors = (ulong[])state; - for (int i = 0; i < _cursors.Count; i++) - { - _cursors[i].Id = nextCursors[i]; - } - } - } - - private bool UpdateCursor(string key, ulong id) - { - lock (_cursors) - { - // O(n), but small n and it's not common - var index = _cursors.FindIndex(c => c.Key == key); - if (index != -1) - { - _cursors[index].Id = id; - return true; - } - - return false; - } - } - - private List GetCursorsFromEventKeys(IList eventKeys, TopicLookup topics) - { - var list = new List(eventKeys.Count); - foreach (var eventKey in eventKeys) - { - var cursor = new Cursor(eventKey, GetMessageId(topics, eventKey), _stringMinifier.Minify(eventKey)); - list.Add(cursor); - } - - return list; - } - - private static string GetCursorPrefix() - { - using (var rng = new RNGCryptoServiceProvider()) - { - var data = new byte[4]; - rng.GetBytes(data); - - using (var writer = new StringWriter(CultureInfo.InvariantCulture)) - { - var randomValue = (ulong)BitConverter.ToUInt32(data, 0); - Cursor.WriteUlongAsHexToBuffer(randomValue, writer); - return "d-" + writer.ToString() + "-"; - } - } - } - - private static ulong GetMessageId(TopicLookup topics, string key) - { - Topic topic; - if (topics.TryGetValue(key, out topic)) - { - return GetMessageId(topic); - } - return 0; - } - - private static ulong GetMessageId(Topic topic) - { - if (topic == null) - { - return 0; - } - - return topic.Store.GetMessageCount(); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/IMessageBus.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/IMessageBus.cs deleted file mode 100644 index e229d739d..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/IMessageBus.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Threading.Tasks; - -namespace Microsoft.AspNet.SignalR.Messaging -{ - public interface IMessageBus - { - /// - /// - /// - /// - /// - Task Publish(Message message); - - /// - /// - /// - /// - /// - /// - /// - /// - /// - IDisposable Subscribe(ISubscriber subscriber, string cursor, Func> callback, int maxMessages, object state); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/ISubscriber.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/ISubscriber.cs deleted file mode 100644 index 317ab4d5f..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/ISubscriber.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.IO; - -namespace Microsoft.AspNet.SignalR.Messaging -{ - public interface ISubscriber - { - IList EventKeys { get; } - - Action WriteCursor { get; set; } - - string Identity { get; } - - event Action EventKeyAdded; - - event Action EventKeyRemoved; - - Subscription Subscription { get; set; } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/ISubscription.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/ISubscription.cs deleted file mode 100644 index fa941ff74..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/ISubscription.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Threading.Tasks; - -namespace Microsoft.AspNet.SignalR.Messaging -{ - public interface ISubscription - { - string Identity { get; } - - bool SetQueued(); - bool UnsetQueued(); - - Task Work(); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/LocalEventKeyInfo.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/LocalEventKeyInfo.cs deleted file mode 100644 index 81ef9c362..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/LocalEventKeyInfo.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; - -namespace Microsoft.AspNet.SignalR.Messaging -{ - public class LocalEventKeyInfo - { - private readonly WeakReference _storeReference; - - public LocalEventKeyInfo(string key, ulong id, MessageStore store) - { - // Don't hold onto MessageStores that would otherwise be GC'd - _storeReference = new WeakReference(store); - Key = key; - Id = id; - } - - public string Key { get; private set; } - public ulong Id { get; private set; } - public MessageStore MessageStore - { - get - { - return _storeReference.Target as MessageStore; - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/Message.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/Message.cs deleted file mode 100644 index bb1dbffd0..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/Message.cs +++ /dev/null @@ -1,155 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Text; - -namespace Microsoft.AspNet.SignalR.Messaging -{ - public class Message - { - private static readonly byte[] _zeroByteBuffer = new byte[0]; - private static readonly UTF8Encoding _encoding = new UTF8Encoding(); - - public Message() - { - Encoding = _encoding; - } - - public Message(string source, string key, string value) - { - if (source == null) - { - throw new ArgumentNullException("source"); - } - - if (key == null) - { - throw new ArgumentNullException("key"); - } - - Source = source; - Key = key; - Encoding = _encoding; - Value = value == null ? new ArraySegment(_zeroByteBuffer) : new ArraySegment(Encoding.GetBytes(value)); - } - - public Message(string source, string key, ArraySegment value) - : this() - { - if (source == null) - { - throw new ArgumentNullException("source"); - } - - if (key == null) - { - throw new ArgumentNullException("key"); - } - - Source = source; - Key = key; - Value = value; - } - - /// - /// Which connection the message originated from - /// - public string Source { get; set; } - - /// - /// The signal for the message (connection id, group, etc) - /// - public string Key { get; set; } - - /// - /// The message payload - /// - public ArraySegment Value { get; set; } - - /// - /// The command id if this message is a command - /// - public string CommandId { get; set; } - - /// - /// Determines if the caller should wait for acknowledgement for this message - /// - public bool WaitForAck { get; set; } - - /// - /// Determines if this message is itself an ACK - /// - public bool IsAck { get; set; } - - /// - /// A list of connection ids to filter out - /// - public string Filter { get; set; } - - /// - /// The encoding of the message - /// - public Encoding Encoding { get; private set; } - - /// - /// The payload id. Only used in scaleout scenarios - /// - public ulong MappingId { get; set; } - - /// - /// The stream index this message came from. Only used the scaleout scenarios. - /// - public int StreamIndex { get; set; } - - public bool IsCommand - { - get - { - return !String.IsNullOrEmpty(CommandId); - } - } - - [SuppressMessage("Microsoft.Design", "CA1024:UsePropertiesWhereAppropriate", Justification = "This may be expensive")] - public string GetString() - { - // If there's no encoding this is a raw binary payload - if (Encoding == null) - { - throw new NotSupportedException(); - } - - return Encoding.GetString(Value.Array, Value.Offset, Value.Count); - } - - public void WriteTo(Stream stream) - { - var binaryWriter = new BinaryWriter(stream); - binaryWriter.Write(Source); - binaryWriter.Write(Key); - binaryWriter.Write(Value.Count); - binaryWriter.Write(Value.Array, Value.Offset, Value.Count); - binaryWriter.Write(CommandId ?? String.Empty); - binaryWriter.Write(WaitForAck); - binaryWriter.Write(IsAck); - binaryWriter.Write(Filter ?? String.Empty); - } - - public static Message ReadFrom(Stream stream) - { - var message = new Message(); - var binaryReader = new BinaryReader(stream); - message.Source = binaryReader.ReadString(); - message.Key = binaryReader.ReadString(); - int bytes = binaryReader.ReadInt32(); - message.Value = new ArraySegment(binaryReader.ReadBytes(bytes)); - message.CommandId = binaryReader.ReadString(); - message.WaitForAck = binaryReader.ReadBoolean(); - message.IsAck = binaryReader.ReadBoolean(); - message.Filter = binaryReader.ReadString(); - - return message; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/MessageBroker.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/MessageBroker.cs deleted file mode 100644 index a87b90a33..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/MessageBroker.cs +++ /dev/null @@ -1,330 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Infrastructure; - -namespace Microsoft.AspNet.SignalR.Messaging -{ - /// - /// This class is the main coordinator. It schedules work to be done for a particular subscription - /// and has an algorithm for choosing a number of workers (thread pool threads), to handle - /// the scheduled work. - /// - public class MessageBroker : IDisposable - { - private readonly Queue _queue = new Queue(); - - private readonly IPerformanceCounterManager _counters; - - // The maximum number of workers (threads) allowed to process all incoming messages - private readonly int _maxWorkers; - - // The maximum number of workers that can be left to idle (not busy but allocated) - private readonly int _maxIdleWorkers; - - // The number of allocated workers (currently running) - private int _allocatedWorkers; - - // The number of workers that are *actually* doing work - private int _busyWorkers; - - // Determines if the broker was disposed and should stop doing all work. - private bool _disposed; - - public MessageBroker(IPerformanceCounterManager performanceCounterManager) - : this(performanceCounterManager, 3 * Environment.ProcessorCount, Environment.ProcessorCount) - { - } - - public MessageBroker(IPerformanceCounterManager performanceCounterManager, int maxWorkers, int maxIdleWorkers) - { - _counters = performanceCounterManager; - _maxWorkers = maxWorkers; - _maxIdleWorkers = maxIdleWorkers; - } - - public TraceSource Trace - { - get; - set; - } - - public int AllocatedWorkers - { - get - { - return _allocatedWorkers; - } - } - - public int BusyWorkers - { - get - { - return _busyWorkers; - } - } - - public void Schedule(ISubscription subscription) - { - if (subscription == null) - { - throw new ArgumentNullException("subscription"); - } - - if (_disposed) - { - // Don't queue up new work if we've disposed the broker - return; - } - - if (subscription.SetQueued()) - { - lock (_queue) - { - _queue.Enqueue(subscription); - Monitor.Pulse(_queue); - AddWorker(); - } - } - } - - private void AddWorker() - { - // Only create a new worker if everyone is busy (up to the max) - if (_allocatedWorkers < _maxWorkers) - { - if (_allocatedWorkers == _busyWorkers) - { - _counters.MessageBusAllocatedWorkers.RawValue = Interlocked.Increment(ref _allocatedWorkers); - - Trace.TraceEvent(TraceEventType.Verbose, 0, "Creating a worker, allocated={0}, busy={1}", _allocatedWorkers, _busyWorkers); - - ThreadPool.QueueUserWorkItem(ProcessWork); - } - else - { - Trace.TraceEvent(TraceEventType.Verbose, 0, "No need to add a worker because all allocated workers are not busy, allocated={0}, busy={1}", _allocatedWorkers, _busyWorkers); - } - } - else - { - Trace.TraceEvent(TraceEventType.Verbose, 0, "Already at max workers, allocated={0}, busy={1}", _allocatedWorkers, _busyWorkers); - } - } - - private void ProcessWork(object state) - { - Task pumpTask = PumpAsync(); - - if (pumpTask.IsCompleted) - { - ProcessWorkSync(pumpTask); - } - else - { - ProcessWorkAsync(pumpTask); - } - - } - - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "We want to avoid user code taking the process down.")] - private void ProcessWorkSync(Task pumpTask) - { - try - { - pumpTask.Wait(); - } - catch (Exception ex) - { - Trace.TraceEvent(TraceEventType.Error, 0, "Failed to process work - " + ex.GetBaseException()); - } - finally - { - // After the pump runs decrement the number of workers in flight - _counters.MessageBusAllocatedWorkers.RawValue = Interlocked.Decrement(ref _allocatedWorkers); - } - } - - private void ProcessWorkAsync(Task pumpTask) - { - pumpTask.ContinueWith(task => - { - // After the pump runs decrement the number of workers in flight - _counters.MessageBusAllocatedWorkers.RawValue = Interlocked.Decrement(ref _allocatedWorkers); - - if (task.IsFaulted) - { - Trace.TraceEvent(TraceEventType.Error, 0, "Failed to process work - " + task.Exception.GetBaseException()); - } - }); - } - - private Task PumpAsync() - { - var tcs = new TaskCompletionSource(); - PumpImpl(tcs); - return tcs.Task; - } - - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "We want to avoid user code taking the process down.")] - private void PumpImpl(TaskCompletionSource taskCompletionSource, ISubscription subscription = null) - { - - Process: - // If we were doing work before and now we've been disposed just kill this worker early - if (_disposed) - { - taskCompletionSource.TrySetResult(null); - return; - } - - Debug.Assert(_allocatedWorkers <= _maxWorkers, "How did we pass the max?"); - - // If we're withing the acceptable limit of idleness, just keep running - int idleWorkers = _allocatedWorkers - _busyWorkers; - - if (subscription != null || idleWorkers <= _maxIdleWorkers) - { - // We already have a subscription doing work so skip the queue - if (subscription == null) - { - lock (_queue) - { - while (_queue.Count == 0) - { - Monitor.Wait(_queue); - - // When disposing, all workers are pulsed so that they can quit - // if they're waiting for things to do (idle) - if (_disposed) - { - taskCompletionSource.TrySetResult(null); - return; - } - } - - subscription = _queue.Dequeue(); - } - } - - _counters.MessageBusBusyWorkers.RawValue = Interlocked.Increment(ref _busyWorkers); - - Task workTask = subscription.Work(); - - if (workTask.IsCompleted) - { - try - { - workTask.Wait(); - - goto Process; - } - catch (Exception ex) - { - Trace.TraceEvent(TraceEventType.Error, 0, "Work failed for " + subscription.Identity + ": " + ex.GetBaseException()); - - goto Process; - } - finally - { - if (!subscription.UnsetQueued() || workTask.IsFaulted || workTask.IsCanceled) - { - // If we don't have more work to do just make the subscription null - subscription = null; - } - - _counters.MessageBusBusyWorkers.RawValue = Interlocked.Decrement(ref _busyWorkers); - - Debug.Assert(_busyWorkers >= 0, "The number of busy workers has somehow gone negative"); - } - } - else - { - PumpImplAsync(workTask, subscription, taskCompletionSource); - } - } - else - { - taskCompletionSource.TrySetResult(null); - } - } - - private void PumpImplAsync(Task workTask, ISubscription subscription, TaskCompletionSource taskCompletionSource) - { - // Async path - workTask.ContinueWith(task => - { - bool moreWork = subscription.UnsetQueued(); - - _counters.MessageBusBusyWorkers.RawValue = Interlocked.Decrement(ref _busyWorkers); - - Debug.Assert(_busyWorkers >= 0, "The number of busy workers has somehow gone negative"); - - if (task.IsFaulted) - { - Trace.TraceEvent(TraceEventType.Error, 0, "Work failed for " + subscription.Identity + ": " + task.Exception.GetBaseException()); - } - - if (moreWork && !task.IsFaulted && !task.IsCanceled) - { - PumpImpl(taskCompletionSource, subscription); - } - else - { - // Don't reference the subscription anymore - subscription = null; - - PumpImpl(taskCompletionSource); - } - }); - } - - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - if (!_disposed) - { - _disposed = true; - - Trace.TraceEvent(TraceEventType.Verbose, 0, "Dispoing the broker"); - - if (MonoUtility.IsRunningMono) - { - return; - } - - // Wait for all threads to stop working - WaitForDrain(); - - Trace.TraceEvent(TraceEventType.Verbose, 0, "Disposed the broker"); - } - } - } - - public void Dispose() - { - Dispose(true); - } - - private void WaitForDrain() - { - while (_allocatedWorkers > 0) - { - lock (_queue) - { - // Tell all workers we're done - Monitor.PulseAll(_queue); - } - - Thread.Sleep(250); - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/MessageBus.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/MessageBus.cs deleted file mode 100644 index c9692b03e..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/MessageBus.cs +++ /dev/null @@ -1,588 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Configuration; -using Microsoft.AspNet.SignalR.Infrastructure; -using Microsoft.AspNet.SignalR.Tracing; - -namespace Microsoft.AspNet.SignalR.Messaging -{ - /// - /// - /// - public class MessageBus : IMessageBus, IDisposable - { - private readonly MessageBroker _broker; - - // The size of the messages store we allocate per topic. - private readonly uint _messageStoreSize; - - // By default, topics are cleaned up after having no subscribers and after - // an interval based on the disconnect timeout has passed. While this works in normal cases - // it's an issue when the rate of incoming connections is too high. - // This is the maximum number of un-expired topics with no subscribers - // we'll leave hanging around. The rest will be cleaned up on an the gc interval. - private readonly int _maxTopicsWithNoSubscriptions; - - private readonly IStringMinifier _stringMinifier; - - private readonly ITraceManager _traceManager; - private readonly TraceSource _trace; - - private Timer _gcTimer; - private int _gcRunning; - private static readonly TimeSpan _gcInterval = TimeSpan.FromSeconds(5); - - private readonly TimeSpan _topicTtl; - - // For unit testing - internal Action BeforeTopicGarbageCollected; - internal Action AfterTopicGarbageCollected; - internal Action BeforeTopicMarked; - internal Action BeforeTopicCreated; - internal Action AfterTopicMarkedSuccessfully; - internal Action AfterTopicMarked; - - private const int DefaultMaxTopicsWithNoSubscriptions = 1000; - - private readonly Func _createTopic; - private readonly Action _addEvent; - private readonly Action _removeEvent; - private readonly Action _disposeSubscription; - - /// - /// - /// - /// - public MessageBus(IDependencyResolver resolver) - : this(resolver.Resolve(), - resolver.Resolve(), - resolver.Resolve(), - resolver.Resolve(), - DefaultMaxTopicsWithNoSubscriptions) - { - } - - /// - /// - /// - /// - /// - /// - /// - /// - [SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Justification = "The message broker is disposed when the bus is disposed.")] - public MessageBus(IStringMinifier stringMinifier, - ITraceManager traceManager, - IPerformanceCounterManager performanceCounterManager, - IConfigurationManager configurationManager, - int maxTopicsWithNoSubscriptions) - { - if (stringMinifier == null) - { - throw new ArgumentNullException("stringMinifier"); - } - - if (traceManager == null) - { - throw new ArgumentNullException("traceManager"); - } - - if (performanceCounterManager == null) - { - throw new ArgumentNullException("performanceCounterManager"); - } - - if (configurationManager == null) - { - throw new ArgumentNullException("configurationManager"); - } - - if (configurationManager.DefaultMessageBufferSize < 0) - { - throw new ArgumentOutOfRangeException(Resources.Error_BufferSizeOutOfRange); - } - - _stringMinifier = stringMinifier; - _traceManager = traceManager; - Counters = performanceCounterManager; - _trace = _traceManager["SignalR." + typeof(MessageBus).Name]; - _maxTopicsWithNoSubscriptions = maxTopicsWithNoSubscriptions; - - _gcTimer = new Timer(_ => GarbageCollectTopics(), state: null, dueTime: _gcInterval, period: _gcInterval); - - _broker = new MessageBroker(Counters) - { - Trace = _trace - }; - - // The default message store size - _messageStoreSize = (uint)configurationManager.DefaultMessageBufferSize; - - _topicTtl = configurationManager.TopicTtl(); - _createTopic = CreateTopic; - _addEvent = AddEvent; - _removeEvent = RemoveEvent; - _disposeSubscription = DisposeSubscription; - - Topics = new TopicLookup(); - } - - protected virtual TraceSource Trace - { - get - { - return _trace; - } - } - - protected internal TopicLookup Topics { get; private set; } - protected IPerformanceCounterManager Counters { get; private set; } - - public int AllocatedWorkers - { - get - { - return _broker.AllocatedWorkers; - } - } - - public int BusyWorkers - { - get - { - return _broker.BusyWorkers; - } - } - - /// - /// Publishes a new message to the specified event on the bus. - /// - /// The message to publish. - public virtual Task Publish(Message message) - { - if (message == null) - { - throw new ArgumentNullException("message"); - } - - Topic topic; - if (Topics.TryGetValue(message.Key, out topic)) - { - topic.Store.Add(message); - ScheduleTopic(topic); - } - - Counters.MessageBusMessagesPublishedTotal.Increment(); - Counters.MessageBusMessagesPublishedPerSec.Increment(); - - - return TaskAsyncHelper.Empty; - } - - protected ulong Save(Message message) - { - if (message == null) - { - throw new ArgumentNullException("message"); - } - - // GetTopic will return a topic for the given key. If topic exists and is Dying, - // it will revive it and mark it as NoSubscriptions - Topic topic = GetTopic(message.Key); - // Mark the topic as used so it doesn't immediately expire (if it was in that state before). - topic.MarkUsed(); - - return topic.Store.Add(message); - } - - /// - /// - /// - /// - /// - /// - /// - /// - /// - [SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Justification = "The disposable object is returned to the caller")] - public virtual IDisposable Subscribe(ISubscriber subscriber, string cursor, Func> callback, int maxMessages, object state) - { - if (subscriber == null) - { - throw new ArgumentNullException("subscriber"); - } - - if (callback == null) - { - throw new ArgumentNullException("callback"); - } - - Subscription subscription = CreateSubscription(subscriber, cursor, callback, maxMessages, state); - - // Set the subscription for this subscriber - subscriber.Subscription = subscription; - - var topics = new HashSet(); - - foreach (var key in subscriber.EventKeys) - { - // Create or retrieve topic and set it as HasSubscriptions - Topic topic = SubscribeTopic(key); - - // Set the subscription for this topic - subscription.SetEventTopic(key, topic); - - topics.Add(topic); - } - - subscriber.EventKeyAdded += _addEvent; - subscriber.EventKeyRemoved += _removeEvent; - subscriber.WriteCursor = subscription.WriteCursor; - - var subscriptionState = new SubscriptionState(subscriber); - var disposable = new DisposableAction(_disposeSubscription, subscriptionState); - - // When the subscription itself is disposed then dispose it - subscription.Disposable = disposable; - - // Add the subscription when it's all set and can be scheduled - // for work. It's important to do this after everything is wired up for the - // subscription so that publishes can schedule work at the right time. - foreach (var topic in topics) - { - topic.AddSubscription(subscription); - } - - subscriptionState.Initialized.Set(); - - // If there's a cursor then schedule work for this subscription - if (!String.IsNullOrEmpty(cursor)) - { - _broker.Schedule(subscription); - } - - return disposable; - } - - [SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "0", Justification = "Called from derived class")] - protected virtual Subscription CreateSubscription(ISubscriber subscriber, string cursor, Func> callback, int messageBufferSize, object state) - { - return new DefaultSubscription(subscriber.Identity, subscriber.EventKeys, Topics, cursor, callback, messageBufferSize, _stringMinifier, Counters, state); - } - - protected void ScheduleEvent(string eventKey) - { - Topic topic; - if (Topics.TryGetValue(eventKey, out topic)) - { - ScheduleTopic(topic); - } - } - - private void ScheduleTopic(Topic topic) - { - try - { - topic.SubscriptionLock.EnterReadLock(); - - for (int i = 0; i < topic.Subscriptions.Count; i++) - { - ISubscription subscription = topic.Subscriptions[i]; - _broker.Schedule(subscription); - } - } - finally - { - topic.SubscriptionLock.ExitReadLock(); - } - } - - /// - /// Creates a topic for the specified key. - /// - /// The key to create the topic for. - /// A for the specified key. - protected virtual Topic CreateTopic(string key) - { - // REVIEW: This can be called multiple times, should we guard against it? - Counters.MessageBusTopicsCurrent.Increment(); - - return new Topic(_messageStoreSize, _topicTtl); - } - - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - // Stop the broker from doing any work - _broker.Dispose(); - - // Spin while we wait for the timer to finish if it's currently running - while (Interlocked.Exchange(ref _gcRunning, 1) == 1) - { - Thread.Sleep(250); - } - - // Remove all topics - Topics.Clear(); - - if (_gcTimer != null) - { - _gcTimer.Dispose(); - } - } - } - - public void Dispose() - { - Dispose(true); - } - - internal void GarbageCollectTopics() - { - if (Interlocked.Exchange(ref _gcRunning, 1) == 1) - { - return; - } - - int topicsWithNoSubs = 0; - - foreach (var pair in Topics) - { - if (pair.Value.IsExpired) - { - if (BeforeTopicGarbageCollected != null) - { - BeforeTopicGarbageCollected(pair.Key, pair.Value); - } - - // Mark the topic as dead - DestroyTopic(pair.Key, pair.Value); - } - else if (pair.Value.State == TopicState.NoSubscriptions) - { - // Keep track of the number of topics with no subscriptions - topicsWithNoSubs++; - } - } - - int overflow = topicsWithNoSubs - _maxTopicsWithNoSubscriptions; - if (overflow > 0) - { - // If we've overflowed the max the collect topics that don't have - // subscribers - var candidates = new List>(); - foreach (var pair in Topics) - { - if (pair.Value.State == TopicState.NoSubscriptions) - { - candidates.Add(pair); - } - } - - // We want to remove the overflow but oldest first - candidates.Sort((leftPair, rightPair) => leftPair.Value.LastUsed.CompareTo(rightPair.Value.LastUsed)); - - // Clear up to the overflow and stay within bounds - for (int i = 0; i < overflow && i < candidates.Count; i++) - { - var pair = candidates[i]; - // We only want to kill the topic if it's in the NoSubscriptions or Dying state. - if (InterlockedHelper.CompareExchangeOr(ref pair.Value.State, TopicState.Dead, TopicState.NoSubscriptions, TopicState.Dying)) - { - // Kill it - DestroyTopicCore(pair.Key, pair.Value); - } - } - } - - Interlocked.Exchange(ref _gcRunning, 0); - } - - private void DestroyTopic(string key, Topic topic) - { - // The goal of this function is to destroy topics after 2 garbage collect cycles - // This first if statement will transition a topic into the dying state on the first GC cycle - // but it will prevent the code path from hitting the second if statement - if (Interlocked.CompareExchange(ref topic.State, TopicState.Dying, TopicState.NoSubscriptions) == TopicState.Dying) - { - // If we've hit this if statement we're on the second GC cycle with this soon to be - // destroyed topic. At this point we move the Topic State into the Dead state as - // long as it has not been revived from the dying state. We check if the state is - // still dying again to ensure that the topic has not been transitioned into a new - // state since we've decided to destroy it. - if (Interlocked.CompareExchange(ref topic.State, TopicState.Dead, TopicState.Dying) == TopicState.Dying) - { - DestroyTopicCore(key, topic); - } - } - } - - private void DestroyTopicCore(string key, Topic topic) - { - Topics.TryRemove(key); - _stringMinifier.RemoveUnminified(key); - - Counters.MessageBusTopicsCurrent.Decrement(); - - Trace.TraceInformation("RemoveTopic(" + key + ")"); - - if (AfterTopicGarbageCollected != null) - { - AfterTopicGarbageCollected(key, topic); - } - } - - internal Topic GetTopic(string key) - { - Topic topic; - int oldState; - - do - { - if (BeforeTopicCreated != null) - { - BeforeTopicCreated(key); - } - - topic = Topics.GetOrAdd(key, _createTopic); - - if (BeforeTopicMarked != null) - { - BeforeTopicMarked(key, topic); - } - - // If the topic was dying revive it to the NoSubscriptions state. This is used to ensure - // that in the scaleout case that even if we're publishing to a topic with no subscriptions - // that we keep it around in case a user hops nodes. - oldState = Interlocked.CompareExchange(ref topic.State, TopicState.NoSubscriptions, TopicState.Dying); - - if (AfterTopicMarked != null) - { - AfterTopicMarked(key, topic, topic.State); - } - - // If the topic is currently dead then we're racing with the DestroyTopicCore function, therefore - // loop around until we're able to create a new topic - } while (oldState == TopicState.Dead); - - if (AfterTopicMarkedSuccessfully != null) - { - AfterTopicMarkedSuccessfully(key, topic); - } - - return topic; - } - - internal Topic SubscribeTopic(string key) - { - Topic topic; - - do - { - if (BeforeTopicCreated != null) - { - BeforeTopicCreated(key); - } - - topic = Topics.GetOrAdd(key, _createTopic); - - if (BeforeTopicMarked != null) - { - BeforeTopicMarked(key, topic); - } - - // Transition into the HasSubscriptions state as long as the topic is not dead - InterlockedHelper.CompareExchangeOr(ref topic.State, TopicState.HasSubscriptions, TopicState.NoSubscriptions, TopicState.Dying); - - if (AfterTopicMarked != null) - { - AfterTopicMarked(key, topic, topic.State); - } - - // If we were unable to transition into the HasSubscription state that means we're in the Dead state. - // Loop around until we're able to create the topic new - } while (topic.State != TopicState.HasSubscriptions); - - if (AfterTopicMarkedSuccessfully != null) - { - AfterTopicMarkedSuccessfully(key, topic); - } - - return topic; - } - - private void AddEvent(ISubscriber subscriber, string eventKey) - { - Topic topic = SubscribeTopic(eventKey); - - // Add or update the cursor (in case it already exists) - if (subscriber.Subscription.AddEvent(eventKey, topic)) - { - // Add it to the list of subs - topic.AddSubscription(subscriber.Subscription); - } - } - - private void RemoveEvent(ISubscriber subscriber, string eventKey) - { - Topic topic; - if (Topics.TryGetValue(eventKey, out topic)) - { - topic.RemoveSubscription(subscriber.Subscription); - subscriber.Subscription.RemoveEvent(eventKey); - } - } - - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Failure to invoke the callback should be ignored")] - private void DisposeSubscription(object state) - { - var subscriptionState = (SubscriptionState)state; - var subscriber = subscriptionState.Subscriber; - - // This will stop work from continuting to happen - subscriber.Subscription.Dispose(); - - try - { - // Invoke the terminal callback - subscriber.Subscription.Invoke(MessageResult.TerminalMessage).Wait(); - } - catch - { - // We failed to talk to the subscriber because they are already gone - // so the terminal message isn't required. - } - - subscriptionState.Initialized.Wait(); - - subscriber.EventKeyAdded -= _addEvent; - subscriber.EventKeyRemoved -= _removeEvent; - subscriber.WriteCursor = null; - - for (int i = subscriber.EventKeys.Count - 1; i >= 0; i--) - { - string eventKey = subscriber.EventKeys[i]; - RemoveEvent(subscriber, eventKey); - } - } - - private class SubscriptionState - { - public ISubscriber Subscriber { get; private set; } - public ManualResetEventSlim Initialized { get; private set; } - - public SubscriptionState(ISubscriber subscriber) - { - Initialized = new ManualResetEventSlim(); - Subscriber = subscriber; - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/MessageBusExtensions.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/MessageBusExtensions.cs deleted file mode 100644 index 6da8de78f..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/MessageBusExtensions.cs +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Infrastructure; - -namespace Microsoft.AspNet.SignalR.Messaging -{ - public static class MessageBusExtensions - { - public static Task Publish(this IMessageBus bus, string source, string key, string value) - { - if (bus == null) - { - throw new ArgumentNullException("bus"); - } - - if (source == null) - { - throw new ArgumentNullException("source"); - } - - if (String.IsNullOrEmpty(key)) - { - throw new ArgumentNullException("key"); - } - - return bus.Publish(new Message(source, key, value)); - } - - internal static Task Ack(this IMessageBus bus, string connectionId, string commandId) - { - // Prepare the ack - var message = new Message(connectionId, PrefixHelper.GetAck(connectionId), null); - message.CommandId = commandId; - message.IsAck = true; - return bus.Publish(message); - } - - public static void Enumerate(this IList> messages, Action onMessage) - { - if (messages == null) - { - throw new ArgumentNullException("messages"); - } - - if (onMessage == null) - { - throw new ArgumentNullException("onMessage"); - } - - Enumerate(messages, message => true, (state, message) => onMessage(message), state: null); - } - - public static void Enumerate(this IList> messages, Func filter, Action onMessage, T state) - { - if (messages == null) - { - throw new ArgumentNullException("messages"); - } - - if (filter == null) - { - throw new ArgumentNullException("filter"); - } - - if (onMessage == null) - { - throw new ArgumentNullException("onMessage"); - } - - for (int i = 0; i < messages.Count; i++) - { - ArraySegment segment = messages[i]; - for (int j = segment.Offset; j < segment.Offset + segment.Count; j++) - { - Message message = segment.Array[j]; - - if (filter(message)) - { - onMessage(state, message); - } - } - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/MessageResult.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/MessageResult.cs deleted file mode 100644 index f29f86097..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/MessageResult.cs +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; - -namespace Microsoft.AspNet.SignalR.Messaging -{ - /// - /// - /// - [SuppressMessage("Microsoft.Performance", "CA1815:OverrideEqualsAndOperatorEqualsOnValueTypes", Justification = "Messages are never compared")] - public struct MessageResult - { - private static readonly List> _emptyList = new List>(); - public readonly static MessageResult TerminalMessage = new MessageResult(terminal: true); - - /// - /// Gets an associated with the result. - /// - [SuppressMessage("Microsoft.Design", "CA1006:DoNotNestGenericTypesInMemberSignatures", Justification = "This is an optimization to avoid allocations.")] - public IList> Messages { get; private set; } - - public int TotalCount { get; private set; } - - public bool Terminal { get; set; } - - public MessageResult(bool terminal) - : this(_emptyList, 0) - { - Terminal = terminal; - } - - /// - /// Initializes a new instance of the struct. - /// - /// The array of messages associated with this . - /// The amount of messages populated in the messages array. - public MessageResult(IList> messages, int totalCount) - : this() - { - Messages = messages; - TotalCount = totalCount; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/MessageStore.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/MessageStore.cs deleted file mode 100644 index 565907f4a..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/MessageStore.cs +++ /dev/null @@ -1,209 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Threading; - -namespace Microsoft.AspNet.SignalR.Messaging -{ - // Represents a message store that is backed by a ring buffer. - public sealed class MessageStore where T : class - { - private static readonly uint _minFragmentCount = 4; - private static readonly uint _maxFragmentSize = (IntPtr.Size == 4) ? (uint)16384 : (uint)8192; // guarantees that fragments never end up in the LOH - private static readonly ArraySegment _emptyArraySegment = new ArraySegment(new T[0]); - private readonly uint _offset; - - private Fragment[] _fragments; - private readonly uint _fragmentSize; - - private long _nextFreeMessageId; - - // Creates a message store with the specified capacity. The actual capacity will be *at least* the - // specified value. That is, GetMessages may return more data than 'capacity'. - public MessageStore(uint capacity, uint offset) - { - // set a minimum capacity - if (capacity < 32) - { - capacity = 32; - } - - _offset = offset; - - // Dynamically choose an appropriate number of fragments and the size of each fragment. - // This is chosen to avoid allocations on the large object heap and to minimize contention - // in the store. We allocate a small amount of additional space to act as an overflow - // buffer; this increases throughput of the data structure. - checked - { - uint fragmentCount = Math.Max(_minFragmentCount, capacity / _maxFragmentSize); - _fragmentSize = Math.Min((capacity + fragmentCount - 1) / fragmentCount, _maxFragmentSize); - _fragments = new Fragment[fragmentCount + 1]; // +1 for the overflow buffer - } - } - - public MessageStore(uint capacity) - : this(capacity, offset: 0) - { - } - - // only for testing purposes - [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1024:UsePropertiesWhereAppropriate", Justification = "Only for testing")] - public ulong GetMessageCount() - { - return (ulong)Volatile.Read(ref _nextFreeMessageId); - } - - // Adds a message to the store. Returns the ID of the newly added message. - public ulong Add(T message) - { - // keep looping in TryAddImpl until it succeeds - ulong newMessageId; - while (!TryAddImpl(message, out newMessageId)) ; - - // When TryAddImpl succeeds, record the fact that a message was just added to the - // store. We increment the next free id rather than set it explicitly since - // multiple threads might be trying to write simultaneously. There is a nifty - // side effect to this: _nextFreeMessageId will *always* return the total number - // of messages that *all* threads agree have ever been added to the store. (The - // actual number may be higher, but this field will eventually catch up as threads - // flush data.) - Interlocked.Increment(ref _nextFreeMessageId); - return newMessageId; - } - - private void GetFragmentOffsets(ulong messageId, out ulong fragmentNum, out int idxIntoFragmentsArray, out int idxIntoFragment) - { - fragmentNum = messageId / _fragmentSize; - - // from the bucket number, we can figure out where in _fragments this data sits - idxIntoFragmentsArray = (int)(fragmentNum % (uint)_fragments.Length); - idxIntoFragment = (int)(messageId % _fragmentSize); - } - - private ulong GetMessageId(ulong fragmentNum, uint offset) - { - return fragmentNum * _fragmentSize + offset; - } - - // Gets the next batch of messages, beginning with the specified ID. - // This function may return an empty array or an array of length greater than the capacity - // specified in the ctor. The client may also miss messages. See MessageStoreResult. - public MessageStoreResult GetMessages(ulong firstMessageId, int maxMessages) - { - return GetMessagesImpl(firstMessageId, maxMessages); - } - - private MessageStoreResult GetMessagesImpl(ulong firstMessageIdRequestedByClient, int maxMessages) - { - ulong nextFreeMessageId = (ulong)Volatile.Read(ref _nextFreeMessageId); - - // Case 1: - // The client is already up-to-date with the message store, so we return no data. - if (nextFreeMessageId <= firstMessageIdRequestedByClient) - { - return new MessageStoreResult(firstMessageIdRequestedByClient, _emptyArraySegment, hasMoreData: false); - } - - // look for the fragment containing the start of the data requested by the client - ulong fragmentNum; - int idxIntoFragmentsArray, idxIntoFragment; - GetFragmentOffsets(firstMessageIdRequestedByClient, out fragmentNum, out idxIntoFragmentsArray, out idxIntoFragment); - Fragment thisFragment = _fragments[idxIntoFragmentsArray]; - ulong firstMessageIdInThisFragment = GetMessageId(thisFragment.FragmentNum, offset: _offset); - ulong firstMessageIdInNextFragment = firstMessageIdInThisFragment + _fragmentSize; - - // Case 2: - // This fragment contains the first part of the data the client requested. - if (firstMessageIdInThisFragment <= firstMessageIdRequestedByClient && firstMessageIdRequestedByClient < firstMessageIdInNextFragment) - { - int count = (int)(Math.Min(nextFreeMessageId, firstMessageIdInNextFragment) - firstMessageIdRequestedByClient); - - // Limit the number of messages the caller sees - count = Math.Min(count, maxMessages); - - ArraySegment retMessages = new ArraySegment(thisFragment.Data, idxIntoFragment, count); - - return new MessageStoreResult(firstMessageIdRequestedByClient, retMessages, hasMoreData: (nextFreeMessageId > firstMessageIdInNextFragment)); - } - - // Case 3: - // The client has missed messages, so we need to send him the earliest fragment we have. - while (true) - { - GetFragmentOffsets(nextFreeMessageId, out fragmentNum, out idxIntoFragmentsArray, out idxIntoFragment); - Fragment tailFragment = _fragments[(idxIntoFragmentsArray + 1) % _fragments.Length]; - if (tailFragment.FragmentNum < fragmentNum) - { - firstMessageIdInThisFragment = GetMessageId(tailFragment.FragmentNum, offset: _offset); - int count = Math.Min(maxMessages, tailFragment.Data.Length); - return new MessageStoreResult(firstMessageIdInThisFragment, new ArraySegment(tailFragment.Data, 0, count), hasMoreData: true); - } - nextFreeMessageId = (ulong)Volatile.Read(ref _nextFreeMessageId); - } - } - - private bool TryAddImpl(T message, out ulong newMessageId) - { - ulong nextFreeMessageId = (ulong)Volatile.Read(ref _nextFreeMessageId); - - // locate the fragment containing the next free id, which is where we should write - ulong fragmentNum; - int idxIntoFragmentsArray, idxIntoFragment; - GetFragmentOffsets(nextFreeMessageId, out fragmentNum, out idxIntoFragmentsArray, out idxIntoFragment); - Fragment fragment = _fragments[idxIntoFragmentsArray]; - - if (fragment == null || fragment.FragmentNum < fragmentNum) - { - // the fragment is outdated (or non-existent) and must be replaced - - if (idxIntoFragment == 0) - { - // this thread is responsible for creating the fragment - Fragment newFragment = new Fragment(fragmentNum, _fragmentSize); - newFragment.Data[0] = message; - Fragment existingFragment = Interlocked.CompareExchange(ref _fragments[idxIntoFragmentsArray], newFragment, fragment); - if (existingFragment == fragment) - { - newMessageId = GetMessageId(fragmentNum, offset: _offset); - return true; - } - } - - // another thread is responsible for updating the fragment, so fall to bottom of method - } - else if (fragment.FragmentNum == fragmentNum) - { - // the fragment is valid, and we can just try writing into it until we reach the end of the fragment - T[] fragmentData = fragment.Data; - for (int i = idxIntoFragment; i < fragmentData.Length; i++) - { - T originalMessage = Interlocked.CompareExchange(ref fragmentData[i], message, null); - if (originalMessage == null) - { - newMessageId = GetMessageId(fragmentNum, offset: (uint)i); - return true; - } - } - - // another thread used the last open space in this fragment, so fall to bottom of method - } - - // failure; caller will retry operation - newMessageId = 0; - return false; - } - - private sealed class Fragment - { - public readonly ulong FragmentNum; - public readonly T[] Data; - - public Fragment(ulong fragmentNum, uint fragmentSize) - { - FragmentNum = fragmentNum; - Data = new T[fragmentSize]; - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/MessageStoreResult.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/MessageStoreResult.cs deleted file mode 100644 index 665c887d4..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/MessageStoreResult.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Diagnostics.CodeAnalysis; - -namespace Microsoft.AspNet.SignalR.Messaging -{ - // Represents the result of a call to MessageStore.GetMessages. - [SuppressMessage("Microsoft.Performance", "CA1815:OverrideEqualsAndOperatorEqualsOnValueTypes", Justification = "This is never compared")] - public struct MessageStoreResult where T : class - { - // The first message ID in the result set. Messages in the result set have sequentually increasing IDs. - // If FirstMessageId = 20 and Messages.Length = 4, then the messages have IDs { 20, 21, 22, 23 }. - private readonly ulong _firstMessageId; - - // If this is true, the backing MessageStore contains more messages, and the client should call GetMessages again. - private readonly bool _hasMoreData; - - // The actual result set. May be empty. - private readonly ArraySegment _messages; - - public MessageStoreResult(ulong firstMessageId, ArraySegment messages, bool hasMoreData) - { - _firstMessageId = firstMessageId; - _messages = messages; - _hasMoreData = hasMoreData; - } - - public ulong FirstMessageId - { - get - { - return _firstMessageId; - } - } - - public bool HasMoreData - { - get - { - return _hasMoreData; - } - } - - public ArraySegment Messages - { - get - { - return _messages; - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/ScaleoutConfiguration.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/ScaleoutConfiguration.cs deleted file mode 100644 index 1ac31f354..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/ScaleoutConfiguration.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; - -namespace Microsoft.AspNet.SignalR.Messaging -{ - /// - /// Common settings for scale-out message bus implementations. - /// - public class ScaleoutConfiguration - { - public static readonly int DisableQueuing = 0; - - private int _maxQueueLength; - - /// - /// The maximum length of the outgoing send queue. Messages being sent to the backplane are queued - /// up to this length. After the max length is reached, further sends will throw an InvalidOperationException. - /// Set to ScaleoutConfiguration.DisableQueuing to disable queing. - /// Defaults to disabled. - /// - public virtual int MaxQueueLength - { - get - { - return _maxQueueLength; - } - set - { - if (value < 0) - { - throw new ArgumentOutOfRangeException("value"); - } - - _maxQueueLength = value; - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/ScaleoutMapping.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/ScaleoutMapping.cs deleted file mode 100644 index 15fefa51c..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/ScaleoutMapping.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using Microsoft.AspNet.SignalR.Infrastructure; - -namespace Microsoft.AspNet.SignalR.Messaging -{ - public class ScaleoutMapping - { - public ScaleoutMapping(ulong id, ScaleoutMessage message) - : this(id, message, ListHelper.Empty) - { - } - - public ScaleoutMapping(ulong id, ScaleoutMessage message, IList localKeyInfo) - { - if (message == null) - { - throw new ArgumentNullException("message"); - } - - if (localKeyInfo == null) - { - throw new ArgumentNullException("localKeyInfo"); - } - - Id = id; - LocalKeyInfo = localKeyInfo; - ServerCreationTime = message.ServerCreationTime; - } - - public ulong Id { get; private set; } - public IList LocalKeyInfo { get; private set; } - public DateTime ServerCreationTime { get; private set; } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/ScaleoutMappingStore.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/ScaleoutMappingStore.cs deleted file mode 100644 index 4c70376fb..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/ScaleoutMappingStore.cs +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections; -using System.Collections.Generic; - -namespace Microsoft.AspNet.SignalR.Messaging -{ - public class ScaleoutMappingStore - { - private const int MaxMessages = 1000000; - - private ScaleoutStore _store; - - public ScaleoutMappingStore() - { - _store = new ScaleoutStore(MaxMessages); - } - - public void Add(ulong id, ScaleoutMessage message, IList localKeyInfo) - { - if (MaxMapping != null && id < MaxMapping.Id) - { - _store = new ScaleoutStore(MaxMessages); - } - - _store.Add(new ScaleoutMapping(id, message, localKeyInfo)); - } - - public ScaleoutMapping MaxMapping - { - get - { - return _store.MaxMapping; - } - } - - public IEnumerator GetEnumerator(ulong id) - { - MessageStoreResult result = _store.GetMessagesByMappingId(id); - - return new ScaleoutStoreEnumerator(_store, result); - } - - private struct ScaleoutStoreEnumerator : IEnumerator, IEnumerator - { - private readonly WeakReference _storeReference; - private MessageStoreResult _result; - private int _offset; - private int _length; - private ulong _nextId; - - public ScaleoutStoreEnumerator(ScaleoutStore store, MessageStoreResult result) - : this() - { - _storeReference = new WeakReference(store); - Initialize(result); - } - - public ScaleoutMapping Current - { - get - { - return _result.Messages.Array[_offset]; - } - } - - public void Dispose() - { - - } - - object IEnumerator.Current - { - get { return Current; } - } - - public bool MoveNext() - { - _offset++; - - if (_offset < _length) - { - return true; - } - - if (!_result.HasMoreData) - { - return false; - } - - // If the store falls out of scope - var store = (ScaleoutStore)_storeReference.Target; - - if (store == null) - { - return false; - } - - // Get the next result - MessageStoreResult result = store.GetMessages(_nextId); - Initialize(result); - - _offset++; - - return _offset < _length; - } - - public void Reset() - { - throw new NotSupportedException(); - } - - private void Initialize(MessageStoreResult result) - { - _result = result; - _offset = _result.Messages.Offset - 1; - _length = _result.Messages.Offset + _result.Messages.Count; - _nextId = _result.FirstMessageId + (ulong)_result.Messages.Count; - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/ScaleoutMessage.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/ScaleoutMessage.cs deleted file mode 100644 index c1aa3993a..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/ScaleoutMessage.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.IO; - -namespace Microsoft.AspNet.SignalR.Messaging -{ - /// - /// Represents a message to the scaleout backplane - /// - public class ScaleoutMessage - { - public ScaleoutMessage(IList messages) - { - Messages = messages; - ServerCreationTime = DateTime.UtcNow; - } - - public ScaleoutMessage() - { - } - - /// - /// The messages from SignalR - /// - [SuppressMessage("Microsoft.Usage", "CA2227:CollectionPropertiesShouldBeReadOnly", Justification = "This type is used for serialization")] - public IList Messages { get; set; } - - /// - /// The time the message was created on the origin server - /// - public DateTime ServerCreationTime { get; set; } - - public byte[] ToBytes() - { - using (var ms = new MemoryStream()) - { - var binaryWriter = new BinaryWriter(ms); - - binaryWriter.Write(Messages.Count); - for (int i = 0; i < Messages.Count; i++) - { - Messages[i].WriteTo(ms); - } - binaryWriter.Write(ServerCreationTime.Ticks); - - return ms.ToArray(); - } - } - - public static ScaleoutMessage FromBytes(byte[] data) - { - if (data == null) - { - throw new ArgumentNullException("data"); - } - - using (var stream = new MemoryStream(data)) - { - var binaryReader = new BinaryReader(stream); - var message = new ScaleoutMessage(); - message.Messages = new List(); - int count = binaryReader.ReadInt32(); - for (int i = 0; i < count; i++) - { - message.Messages.Add(Message.ReadFrom(stream)); - } - message.ServerCreationTime = new DateTime(binaryReader.ReadInt64()); - - return message; - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/ScaleoutMessageBus.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/ScaleoutMessageBus.cs deleted file mode 100644 index 9c5c5d944..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/ScaleoutMessageBus.cs +++ /dev/null @@ -1,232 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Infrastructure; -using Microsoft.AspNet.SignalR.Tracing; - -namespace Microsoft.AspNet.SignalR.Messaging -{ - /// - /// Common base class for scaleout message bus implementations. - /// - public abstract class ScaleoutMessageBus : MessageBus - { - private readonly SipHashBasedStringEqualityComparer _sipHashBasedComparer = new SipHashBasedStringEqualityComparer(0, 0); - private readonly TraceSource _trace; - private readonly Lazy _streamManager; - private readonly IPerformanceCounterManager _perfCounters; - - protected ScaleoutMessageBus(IDependencyResolver resolver, ScaleoutConfiguration configuration) - : base(resolver) - { - if (configuration == null) - { - throw new ArgumentNullException("configuration"); - } - - var traceManager = resolver.Resolve(); - _trace = traceManager["SignalR." + typeof(ScaleoutMessageBus).Name]; - _perfCounters = resolver.Resolve(); - _streamManager = new Lazy(() => new ScaleoutStreamManager(Send, OnReceivedCore, StreamCount, _trace, _perfCounters, configuration)); - } - - /// - /// The number of streams can't change for the lifetime of this instance. - /// - protected virtual int StreamCount - { - get - { - return 1; - } - } - - private ScaleoutStreamManager StreamManager - { - get - { - return _streamManager.Value; - } - } - - /// - /// Opens the specified queue for sending messages. - /// The index of the stream to open. - /// - protected void Open(int streamIndex) - { - StreamManager.Open(streamIndex); - } - - /// - /// Closes the specified queue. - /// The index of the stream to close. - /// - protected void Close(int streamIndex) - { - StreamManager.Close(streamIndex); - } - - /// - /// Closes the specified queue for sending messages making all sends fail asynchronously. - /// - /// The index of the stream to close. - /// The error that occurred. - protected void OnError(int streamIndex, Exception exception) - { - StreamManager.OnError(streamIndex, exception); - } - - /// - /// Sends messages to the backplane - /// - /// The list of messages to send - /// - protected virtual Task Send(IList messages) - { - // If we're only using a single stream then just send - if (StreamCount == 1) - { - return StreamManager.Send(0, messages); - } - - var taskCompletionSource = new TaskCompletionSource(); - - // Group messages by source (connection id) - var messagesBySource = messages.GroupBy(m => m.Source); - - SendImpl(messagesBySource.GetEnumerator(), taskCompletionSource); - - return taskCompletionSource.Task; - } - - protected virtual Task Send(int streamIndex, IList messages) - { - throw new NotImplementedException(); - } - - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "We return a faulted tcs")] - private void SendImpl(IEnumerator> enumerator, TaskCompletionSource taskCompletionSource) - { - send: - - if (!enumerator.MoveNext()) - { - taskCompletionSource.TrySetResult(null); - } - else - { - IGrouping group = enumerator.Current; - - // Get the channel index we're going to use for this message - int index = (int)((uint)_sipHashBasedComparer.GetHashCode(group.Key) % StreamCount); - - Debug.Assert(index >= 0, "Hash function resulted in an index < 0."); - - Task sendTask = StreamManager.Send(index, group.ToArray()).Catch(); - - if (sendTask.IsCompleted) - { - try - { - sendTask.Wait(); - - goto send; - - } - catch (Exception ex) - { - taskCompletionSource.SetUnwrappedException(ex); - } - } - else - { - sendTask.Then((enumer, tcs) => SendImpl(enumer, tcs), enumerator, taskCompletionSource) - .ContinueWithNotComplete(taskCompletionSource); - } - } - } - - /// - /// Invoked when a payload is received from the backplane. There should only be one active call at any time. - /// - /// id of the stream. - /// id of the payload within that stream. - /// The scaleout message. - /// - protected virtual void OnReceived(int streamIndex, ulong id, ScaleoutMessage message) - { - StreamManager.OnReceived(streamIndex, id, message); - } - - [SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "2", Justification = "Called from derived class")] - [SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "0", Justification = "Called from derived class")] - private void OnReceivedCore(int streamIndex, ulong id, ScaleoutMessage scaleoutMessage) - { - Counters.ScaleoutMessageBusMessagesReceivedPerSec.IncrementBy(scaleoutMessage.Messages.Count); - - _trace.TraceInformation("OnReceived({0}, {1}, {2})", streamIndex, id, scaleoutMessage.Messages.Count); - - var localMapping = new LocalEventKeyInfo[scaleoutMessage.Messages.Count]; - var keys = new HashSet(); - - for (var i = 0; i < scaleoutMessage.Messages.Count; ++i) - { - Message message = scaleoutMessage.Messages[i]; - - // Remember where this message came from - message.MappingId = id; - message.StreamIndex = streamIndex; - - keys.Add(message.Key); - ulong localId = Save(message); - MessageStore messageStore = Topics[message.Key].Store; - - localMapping[i] = new LocalEventKeyInfo(message.Key, localId, messageStore); - } - - // Get the stream for this payload - ScaleoutMappingStore store = StreamManager.Streams[streamIndex]; - - // Publish only after we've setup the mapping fully - store.Add(id, scaleoutMessage, localMapping); - - // Schedule after we're done - foreach (var eventKey in keys) - { - ScheduleEvent(eventKey); - } - } - - public override Task Publish(Message message) - { - Counters.MessageBusMessagesPublishedTotal.Increment(); - Counters.MessageBusMessagesPublishedPerSec.Increment(); - - // TODO: Implement message batching here - return Send(new[] { message }); - } - - protected override void Dispose(bool disposing) - { - // Close all streams - for (int i = 0; i < StreamCount; i++) - { - Close(i); - } - - base.Dispose(disposing); - } - - [SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "0", Justification = "Called from derived class")] - protected override Subscription CreateSubscription(ISubscriber subscriber, string cursor, Func> callback, int messageBufferSize, object state) - { - return new ScaleoutSubscription(subscriber.Identity, subscriber.EventKeys, cursor, StreamManager.Streams, callback, messageBufferSize, Counters, state); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/ScaleoutStore.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/ScaleoutStore.cs deleted file mode 100644 index 605447d82..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/ScaleoutStore.cs +++ /dev/null @@ -1,440 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using System.Threading; - -namespace Microsoft.AspNet.SignalR.Messaging -{ - // Represents a message store that is backed by a ring buffer. - [SuppressMessage("Microsoft.Design", "CA1001:TypesThatOwnDisposableFieldsShouldBeDisposable", Justification = "The rate sampler doesn't need to be disposed")] - public sealed class ScaleoutStore - { - private const uint _minFragmentCount = 4; - - [SuppressMessage("Microsoft.Performance", "CA1802:UseLiteralsWhereAppropriate", Justification = "It's conditional based on architecture")] - private static readonly uint _maxFragmentSize = (IntPtr.Size == 4) ? (uint)16384 : (uint)8192; // guarantees that fragments never end up in the LOH - - private static readonly ArraySegment _emptyArraySegment = new ArraySegment(new ScaleoutMapping[0]); - - private Fragment[] _fragments; - private readonly uint _fragmentSize; - - private long _minMessageId; - private long _nextFreeMessageId; - - private ulong _minMappingId; - private ScaleoutMapping _maxMapping; - - // Creates a message store with the specified capacity. The actual capacity will be *at least* the - // specified value. That is, GetMessages may return more data than 'capacity'. - public ScaleoutStore(uint capacity) - { - // set a minimum capacity - if (capacity < 32) - { - capacity = 32; - } - - // Dynamically choose an appropriate number of fragments and the size of each fragment. - // This is chosen to avoid allocations on the large object heap and to minimize contention - // in the store. We allocate a small amount of additional space to act as an overflow - // buffer; this increases throughput of the data structure. - checked - { - uint fragmentCount = Math.Max(_minFragmentCount, capacity / _maxFragmentSize); - _fragmentSize = Math.Min((capacity + fragmentCount - 1) / fragmentCount, _maxFragmentSize); - _fragments = new Fragment[fragmentCount + 1]; // +1 for the overflow buffer - } - } - - internal ulong MinMappingId - { - get - { - return _minMappingId; - } - } - - public ScaleoutMapping MaxMapping - { - get - { - return _maxMapping; - } - } - - public uint FragmentSize - { - get - { - return _fragmentSize; - } - } - - public int FragmentCount - { - get - { - return _fragments.Length; - } - } - - // Adds a message to the store. Returns the ID of the newly added message. - public ulong Add(ScaleoutMapping mapping) - { - // keep looping in TryAddImpl until it succeeds - ulong newMessageId; - while (!TryAddImpl(mapping, out newMessageId)) ; - - // When TryAddImpl succeeds, record the fact that a message was just added to the - // store. We increment the next free id rather than set it explicitly since - // multiple threads might be trying to write simultaneously. There is a nifty - // side effect to this: _nextFreeMessageId will *always* return the total number - // of messages that *all* threads agree have ever been added to the store. (The - // actual number may be higher, but this field will eventually catch up as threads - // flush data.) - Interlocked.Increment(ref _nextFreeMessageId); - return newMessageId; - } - - private void GetFragmentOffsets(ulong messageId, out ulong fragmentNum, out int idxIntoFragmentsArray, out int idxIntoFragment) - { - fragmentNum = messageId / _fragmentSize; - - // from the bucket number, we can figure out where in _fragments this data sits - idxIntoFragmentsArray = (int)(fragmentNum % (uint)_fragments.Length); - idxIntoFragment = (int)(messageId % _fragmentSize); - } - - private int GetFragmentOffset(ulong messageId) - { - ulong fragmentNum = messageId / _fragmentSize; - - return (int)(fragmentNum % (uint)_fragments.Length); - } - - private ulong GetMessageId(ulong fragmentNum, uint offset) - { - return fragmentNum * _fragmentSize + offset; - } - - private bool TryAddImpl(ScaleoutMapping mapping, out ulong newMessageId) - { - ulong nextFreeMessageId = (ulong)Volatile.Read(ref _nextFreeMessageId); - - // locate the fragment containing the next free id, which is where we should write - ulong fragmentNum; - int idxIntoFragmentsArray, idxIntoFragment; - GetFragmentOffsets(nextFreeMessageId, out fragmentNum, out idxIntoFragmentsArray, out idxIntoFragment); - Fragment fragment = _fragments[idxIntoFragmentsArray]; - - if (fragment == null || fragment.FragmentNum < fragmentNum) - { - // the fragment is outdated (or non-existent) and must be replaced - bool overwrite = fragment != null && fragment.FragmentNum < fragmentNum; - - if (idxIntoFragment == 0) - { - // this thread is responsible for creating the fragment - Fragment newFragment = new Fragment(fragmentNum, _fragmentSize); - newFragment.Data[0] = mapping; - Fragment existingFragment = Interlocked.CompareExchange(ref _fragments[idxIntoFragmentsArray], newFragment, fragment); - if (existingFragment == fragment) - { - newMessageId = GetMessageId(fragmentNum, offset: 0); - newFragment.MinId = newMessageId; - newFragment.Length = 1; - newFragment.MaxId = GetMessageId(fragmentNum, offset: _fragmentSize - 1); - _maxMapping = mapping; - - // Move the minimum id when we overwrite - if (overwrite) - { - _minMessageId = (long)(existingFragment.MaxId + 1); - _minMappingId = existingFragment.MaxId; - } - else if (idxIntoFragmentsArray == 0) - { - _minMappingId = mapping.Id; - } - - return true; - } - } - - // another thread is responsible for updating the fragment, so fall to bottom of method - } - else if (fragment.FragmentNum == fragmentNum) - { - // the fragment is valid, and we can just try writing into it until we reach the end of the fragment - ScaleoutMapping[] fragmentData = fragment.Data; - for (int i = idxIntoFragment; i < fragmentData.Length; i++) - { - ScaleoutMapping originalMapping = Interlocked.CompareExchange(ref fragmentData[i], mapping, null); - if (originalMapping == null) - { - newMessageId = GetMessageId(fragmentNum, offset: (uint)i); - fragment.Length++; - _maxMapping = fragmentData[i]; - return true; - } - } - - // another thread used the last open space in this fragment, so fall to bottom of method - } - - // failure; caller will retry operation - newMessageId = 0; - return false; - } - - public MessageStoreResult GetMessages(ulong firstMessageIdRequestedByClient) - { - ulong nextFreeMessageId = (ulong)Volatile.Read(ref _nextFreeMessageId); - - // Case 1: - // The client is already up-to-date with the message store, so we return no data. - if (nextFreeMessageId <= firstMessageIdRequestedByClient) - { - return new MessageStoreResult(firstMessageIdRequestedByClient, _emptyArraySegment, hasMoreData: false); - } - - // look for the fragment containing the start of the data requested by the client - ulong fragmentNum; - int idxIntoFragmentsArray, idxIntoFragment; - GetFragmentOffsets(firstMessageIdRequestedByClient, out fragmentNum, out idxIntoFragmentsArray, out idxIntoFragment); - Fragment thisFragment = _fragments[idxIntoFragmentsArray]; - ulong firstMessageIdInThisFragment = GetMessageId(thisFragment.FragmentNum, offset: 0); - ulong firstMessageIdInNextFragment = firstMessageIdInThisFragment + _fragmentSize; - - // Case 2: - // This fragment contains the first part of the data the client requested. - if (firstMessageIdInThisFragment <= firstMessageIdRequestedByClient && firstMessageIdRequestedByClient < firstMessageIdInNextFragment) - { - int count = (int)(Math.Min(nextFreeMessageId, firstMessageIdInNextFragment) - firstMessageIdRequestedByClient); - - var retMessages = new ArraySegment(thisFragment.Data, idxIntoFragment, count); - - return new MessageStoreResult(firstMessageIdRequestedByClient, retMessages, hasMoreData: (nextFreeMessageId > firstMessageIdInNextFragment)); - } - - // Case 3: - // The client has missed messages, so we need to send him the earliest fragment we have. - while (true) - { - GetFragmentOffsets(nextFreeMessageId, out fragmentNum, out idxIntoFragmentsArray, out idxIntoFragment); - Fragment tailFragment = _fragments[(idxIntoFragmentsArray + 1) % _fragments.Length]; - if (tailFragment.FragmentNum < fragmentNum) - { - firstMessageIdInThisFragment = GetMessageId(tailFragment.FragmentNum, offset: 0); - - return new MessageStoreResult(firstMessageIdInThisFragment, new ArraySegment(tailFragment.Data, 0, tailFragment.Length), hasMoreData: true); - } - nextFreeMessageId = (ulong)Volatile.Read(ref _nextFreeMessageId); - } - } - - public MessageStoreResult GetMessagesByMappingId(ulong mappingId) - { - var minMessageId = (ulong)Volatile.Read(ref _minMessageId); - - int idxIntoFragment; - // look for the fragment containing the start of the data requested by the client - Fragment thisFragment; - if (TryGetFragmentFromMappingId(mappingId, out thisFragment)) - { - int lastSearchIndex; - ulong lastSearchId; - if (thisFragment.TrySearch(mappingId, - out idxIntoFragment, - out lastSearchIndex, - out lastSearchId)) - { - // Skip the first message - idxIntoFragment++; - ulong firstMessageIdRequestedByClient = GetMessageId(thisFragment.FragmentNum, (uint)idxIntoFragment); - - return GetMessages(firstMessageIdRequestedByClient); - } - else - { - if (mappingId > lastSearchId) - { - lastSearchIndex++; - } - - var segment = new ArraySegment(thisFragment.Data, - lastSearchIndex, - thisFragment.Length - lastSearchIndex); - - var firstMessageIdInThisFragment = GetMessageId(thisFragment.FragmentNum, offset: (uint)lastSearchIndex); - - return new MessageStoreResult(firstMessageIdInThisFragment, - segment, - hasMoreData: true); - } - } - - // If we're expired or we're at the first mapping or we're lower than the - // min then get everything - if (mappingId < _minMappingId || mappingId == UInt64.MaxValue) - { - return GetAllMessages(minMessageId); - } - - // We're up to date so do nothing - return new MessageStoreResult(0, _emptyArraySegment, hasMoreData: false); - } - - private MessageStoreResult GetAllMessages(ulong minMessageId) - { - ulong fragmentNum; - int idxIntoFragmentsArray, idxIntoFragment; - GetFragmentOffsets(minMessageId, out fragmentNum, out idxIntoFragmentsArray, out idxIntoFragment); - - Fragment fragment = _fragments[idxIntoFragmentsArray]; - - if (fragment == null) - { - return new MessageStoreResult(minMessageId, _emptyArraySegment, hasMoreData: false); - } - - var firstMessageIdInThisFragment = GetMessageId(fragment.FragmentNum, offset: 0); - - var messages = new ArraySegment(fragment.Data, 0, fragment.Length); - - return new MessageStoreResult(firstMessageIdInThisFragment, messages, hasMoreData: true); - } - - internal bool TryGetFragmentFromMappingId(ulong mappingId, out Fragment fragment) - { - long low = _minMessageId; - long high = _nextFreeMessageId; - - while (low <= high) - { - var mid = (ulong)((low + high) / 2); - - int midOffset = GetFragmentOffset(mid); - - fragment = _fragments[midOffset]; - - if (fragment == null) - { - return false; - } - - if (mappingId < fragment.MinValue) - { - high = (long)(fragment.MinId - 1); - } - else if (mappingId > fragment.MaxValue) - { - low = (long)(fragment.MaxId + 1); - } - else if (fragment.HasValue(mappingId)) - { - return true; - } - } - - fragment = null; - return false; - } - - internal sealed class Fragment - { - public readonly ulong FragmentNum; - public readonly ScaleoutMapping[] Data; - public int Length; - public ulong MinId; - public ulong MaxId; - - public Fragment(ulong fragmentNum, uint fragmentSize) - { - FragmentNum = fragmentNum; - Data = new ScaleoutMapping[fragmentSize]; - } - - public ulong? MinValue - { - get - { - var mapping = Data[0]; - if (mapping != null) - { - return mapping.Id; - } - - return null; - } - } - - public ulong? MaxValue - { - get - { - ScaleoutMapping mapping = null; - - if (Length == 0) - { - mapping = Data[Length]; - } - else - { - mapping = Data[Length - 1]; - } - - if (mapping != null) - { - return mapping.Id; - } - - return null; - } - } - - public bool HasValue(ulong id) - { - return id >= MinValue && id <= MaxValue; - } - - public bool TrySearch(ulong id, out int index, out int lastSearchIndex, out ulong lastSearchId) - { - lastSearchIndex = 0; - lastSearchId = id; - - var low = 0; - var high = Length; - - - while (low <= high) - { - int mid = (low + high) / 2; - - ScaleoutMapping mapping = Data[mid]; - - lastSearchIndex = mid; - lastSearchId = mapping.Id; - - if (id < mapping.Id) - { - high = mid - 1; - } - else if (id > mapping.Id) - { - low = mid + 1; - } - else if (id == mapping.Id) - { - index = mid; - return true; - } - } - - index = -1; - return false; - } - } - } - -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/ScaleoutStream.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/ScaleoutStream.cs deleted file mode 100644 index 29c359a77..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/ScaleoutStream.cs +++ /dev/null @@ -1,316 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Infrastructure; - -namespace Microsoft.AspNet.SignalR.Messaging -{ - internal class ScaleoutStream - { - private TaskCompletionSource _taskCompletionSource; - private TaskQueue _queue; - private StreamState _state; - private Exception _error; - - private readonly int _size; - private readonly TraceSource _trace; - private readonly string _tracePrefix; - private readonly IPerformanceCounterManager _perfCounters; - - private readonly object _lockObj = new object(); - - public ScaleoutStream(TraceSource trace, string tracePrefix, int size, IPerformanceCounterManager performanceCounters) - { - if (trace == null) - { - throw new ArgumentNullException("trace"); - } - - _trace = trace; - _tracePrefix = tracePrefix; - _size = size; - _perfCounters = performanceCounters; - - InitializeCore(); - } - - private bool UsingTaskQueue - { - get - { - return _size > 0; - } - } - - public void Open() - { - lock (_lockObj) - { - if (ChangeState(StreamState.Open)) - { - _perfCounters.ScaleoutStreamCountOpen.Increment(); - _perfCounters.ScaleoutStreamCountBuffering.Decrement(); - - _error = null; - - if (UsingTaskQueue) - { - _taskCompletionSource.TrySetResult(null); - } - } - } - } - - public Task Send(Func send, object state) - { - lock (_lockObj) - { - if (_error != null) - { - throw _error; - } - - // If the queue is closed then stop sending - if (_state == StreamState.Closed) - { - throw new InvalidOperationException(Resources.Error_StreamClosed); - } - - if (_state == StreamState.Initial) - { - throw new InvalidOperationException(Resources.Error_StreamNotOpen); - } - - var context = new SendContext(this, send, state); - - if (UsingTaskQueue) - { - Task task = _queue.Enqueue(Send, context); - - if (task == null) - { - // The task is null if the queue is full - throw new InvalidOperationException(Resources.Error_TaskQueueFull); - } - - // Always observe the task in case the user doesn't handle it - return task.Catch(); - } - - _perfCounters.ScaleoutSendQueueLength.Increment(); - return Send(context).Finally(counter => - { - ((IPerformanceCounter)counter).Decrement(); - }, - _perfCounters.ScaleoutSendQueueLength); - } - } - - public void SetError(Exception error) - { - Trace("Error has happened with the following exception: {0}.", error); - - lock (_lockObj) - { - _perfCounters.ScaleoutErrorsTotal.Increment(); - _perfCounters.ScaleoutErrorsPerSec.Increment(); - - Buffer(); - - _error = error; - } - } - - public void Close() - { - Task task = TaskAsyncHelper.Empty; - - lock (_lockObj) - { - if (ChangeState(StreamState.Closed)) - { - _perfCounters.ScaleoutStreamCountOpen.RawValue = 0; - _perfCounters.ScaleoutStreamCountBuffering.RawValue = 0; - - if (UsingTaskQueue) - { - // Ensure the queue is started - EnsureQueueStarted(); - - // Drain the queue to stop all sends - task = Drain(_queue); - } - } - } - - if (UsingTaskQueue) - { - // Block until the queue is drained so no new work can be done - task.Wait(); - } - } - - private static Task Send(object state) - { - var context = (SendContext)state; - - context.InvokeSend().Then(tcs => - { - // Complete the task if the send is successful - tcs.TrySetResult(null); - }, - context.TaskCompletionSource) - .Catch((ex, obj) => - { - var ctx = (SendContext)obj; - - ctx.Stream.Trace("Send failed: {0}", ex); - - lock (ctx.Stream._lockObj) - { - // Set the queue into buffering state - ctx.Stream.SetError(ex.InnerException); - - // Otherwise just set this task as failed - ctx.TaskCompletionSource.TrySetUnwrappedException(ex); - } - }, - context); - - return context.TaskCompletionSource.Task; - } - - private void Buffer() - { - lock (_lockObj) - { - if (ChangeState(StreamState.Buffering)) - { - _perfCounters.ScaleoutStreamCountOpen.Decrement(); - _perfCounters.ScaleoutStreamCountBuffering.Increment(); - - InitializeCore(); - } - } - } - - private void InitializeCore() - { - if (UsingTaskQueue) - { - Task task = DrainQueue(); - _queue = new TaskQueue(task, _size); - _queue.QueueSizeCounter = _perfCounters.ScaleoutSendQueueLength; - } - } - - private Task DrainQueue() - { - // If the tcs is null or complete then create a new one - if (_taskCompletionSource == null || - _taskCompletionSource.Task.IsCompleted) - { - _taskCompletionSource = new TaskCompletionSource(); - } - - if (_queue != null) - { - // Drain the queue when the new queue is open - return _taskCompletionSource.Task.Then(q => Drain(q), _queue); - } - - // Nothing to drain - return _taskCompletionSource.Task; - } - - private void EnsureQueueStarted() - { - if (_taskCompletionSource != null) - { - _taskCompletionSource.TrySetResult(null); - } - } - - private bool ChangeState(StreamState newState) - { - // Do nothing if the state is closed - if (_state == StreamState.Closed) - { - return false; - } - - if (_state != newState) - { - Trace("Changed state from {0} to {1}", _state, newState); - - _state = newState; - return true; - } - - return false; - } - - private static Task Drain(TaskQueue queue) - { - if (queue == null) - { - return TaskAsyncHelper.Empty; - } - - var tcs = new TaskCompletionSource(); - - queue.Drain().Catch().ContinueWith(task => - { - tcs.SetResult(null); - }); - - return tcs.Task; - } - - private void Trace(string value, params object[] args) - { - _trace.TraceInformation(_tracePrefix + " - " + value, args); - } - - private class SendContext - { - private readonly Func _send; - private readonly object _state; - - public readonly ScaleoutStream Stream; - public readonly TaskCompletionSource TaskCompletionSource; - - public SendContext(ScaleoutStream stream, Func send, object state) - { - Stream = stream; - TaskCompletionSource = new TaskCompletionSource(); - _send = send; - _state = state; - } - - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "The exception flows to the caller")] - public Task InvokeSend() - { - try - { - return _send(_state); - } - catch (Exception ex) - { - return TaskAsyncHelper.FromError(ex); - } - } - } - - private enum StreamState - { - Initial, - Open, - Buffering, - Closed - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/ScaleoutStreamManager.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/ScaleoutStreamManager.cs deleted file mode 100644 index 003822377..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/ScaleoutStreamManager.cs +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Diagnostics; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Infrastructure; - -namespace Microsoft.AspNet.SignalR.Messaging -{ - internal class ScaleoutStreamManager - { - private readonly Func, Task> _send; - private readonly Action _receive; - private readonly ScaleoutStream[] _streams; - - public ScaleoutStreamManager(Func, Task> send, - Action receive, - int streamCount, - TraceSource trace, - IPerformanceCounterManager performanceCounters, - ScaleoutConfiguration configuration) - { - _streams = new ScaleoutStream[streamCount]; - _send = send; - _receive = receive; - - var receiveMapping = new ScaleoutMappingStore[streamCount]; - - performanceCounters.ScaleoutStreamCountTotal.RawValue = streamCount; - performanceCounters.ScaleoutStreamCountBuffering.RawValue = streamCount; - performanceCounters.ScaleoutStreamCountOpen.RawValue = 0; - - for (int i = 0; i < streamCount; i++) - { - _streams[i] = new ScaleoutStream(trace, "Stream(" + i + ")", configuration.MaxQueueLength, performanceCounters); - receiveMapping[i] = new ScaleoutMappingStore(); - } - - Streams = new ReadOnlyCollection(receiveMapping); - } - - public IList Streams { get; private set; } - - public void Open(int streamIndex) - { - _streams[streamIndex].Open(); - } - - public void Close(int streamIndex) - { - _streams[streamIndex].Close(); - } - - public void OnError(int streamIndex, Exception exception) - { - _streams[streamIndex].SetError(exception); - } - - public Task Send(int streamIndex, IList messages) - { - var context = new SendContext(this, streamIndex, messages); - - return _streams[streamIndex].Send(state => Send(state), context); - } - - public void OnReceived(int streamIndex, ulong id, ScaleoutMessage message) - { - _receive(streamIndex, id, message); - - // We assume if a message has come in then the stream is open - Open(streamIndex); - } - - private static Task Send(object state) - { - var context = (SendContext)state; - - return context.StreamManager._send(context.Index, context.Messages); - } - - private class SendContext - { - public ScaleoutStreamManager StreamManager; - public int Index; - public IList Messages; - - public SendContext(ScaleoutStreamManager scaleoutStream, int index, IList messages) - { - StreamManager = scaleoutStream; - Index = index; - Messages = messages; - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/ScaleoutSubscription.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/ScaleoutSubscription.cs deleted file mode 100644 index d426f7a37..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/ScaleoutSubscription.cs +++ /dev/null @@ -1,282 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.IO; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Infrastructure; - -namespace Microsoft.AspNet.SignalR.Messaging -{ - public class ScaleoutSubscription : Subscription - { - private const string _scaleoutCursorPrefix = "s-"; - - private readonly IList _streams; - private readonly List _cursors; - - public ScaleoutSubscription(string identity, - IList eventKeys, - string cursor, - IList streams, - Func> callback, - int maxMessages, - IPerformanceCounterManager counters, - object state) - : base(identity, eventKeys, callback, maxMessages, counters, state) - { - if (streams == null) - { - throw new ArgumentNullException("streams"); - } - - _streams = streams; - - List cursors = null; - - if (String.IsNullOrEmpty(cursor)) - { - cursors = new List(); - } - else - { - cursors = Cursor.GetCursors(cursor, _scaleoutCursorPrefix); - - // If the cursor had a default prefix, "d-", cursors might be null - if (cursors == null) - { - cursors = new List(); - } - // If the streams don't match the cursors then throw it out - else if (cursors.Count != _streams.Count) - { - cursors.Clear(); - } - } - - // No cursors so we need to populate them from the list of streams - if (cursors.Count == 0) - { - for (int streamIndex = 0; streamIndex < _streams.Count; streamIndex++) - { - AddCursorForStream(streamIndex, cursors); - } - } - - _cursors = cursors; - } - - public override void WriteCursor(TextWriter textWriter) - { - Cursor.WriteCursors(textWriter, _cursors, _scaleoutCursorPrefix); - } - - [SuppressMessage("Microsoft.Design", "CA1002:DoNotExposeGenericLists", Justification = "The list needs to be populated")] - [SuppressMessage("Microsoft.Design", "CA1062:Validate arguments of public methods", MessageId = "0", Justification = "It is called from the base class")] - protected override void PerformWork(IList> items, out int totalCount, out object state) - { - // The list of cursors represent (streamid, payloadid) - var nextCursors = new ulong?[_cursors.Count]; - totalCount = 0; - - // Get the enumerator so that we can extract messages for this subscription - IEnumerator> enumerator = GetMappings().GetEnumerator(); - - while (totalCount < MaxMessages && enumerator.MoveNext()) - { - ScaleoutMapping mapping = enumerator.Current.Item1; - int streamIndex = enumerator.Current.Item2; - - ulong? nextCursor = nextCursors[streamIndex]; - - // Only keep going with this stream if the cursor we're looking at is bigger than - // anything we already processed - if (nextCursor == null || mapping.Id > nextCursor) - { - ulong mappingId = ExtractMessages(streamIndex, mapping, items, ref totalCount); - - // Update the cursor id - nextCursors[streamIndex] = mappingId; - } - } - - state = nextCursors; - } - - protected override void BeforeInvoke(object state) - { - // Update the list of cursors before invoking anything - var nextCursors = (ulong?[])state; - for (int i = 0; i < _cursors.Count; i++) - { - // Only update non-null entries - ulong? nextCursor = nextCursors[i]; - - if (nextCursor.HasValue) - { - Cursor cursor = _cursors[i]; - - cursor.Id = nextCursor.Value; - } - } - } - - private IEnumerable> GetMappings() - { - var enumerators = new List(); - - for (var streamIndex = 0; streamIndex < _streams.Count; ++streamIndex) - { - // Get the mapping for this stream - ScaleoutMappingStore store = _streams[streamIndex]; - - Cursor cursor = _cursors[streamIndex]; - - // Try to find a local mapping for this payload - var enumerator = new CachedStreamEnumerator(store.GetEnumerator(cursor.Id), - streamIndex); - - enumerators.Add(enumerator); - } - - while (enumerators.Count > 0) - { - ScaleoutMapping minMapping = null; - CachedStreamEnumerator minEnumerator = null; - - for (int i = enumerators.Count - 1; i >= 0; i--) - { - CachedStreamEnumerator enumerator = enumerators[i]; - - ScaleoutMapping mapping; - if (enumerator.TryMoveNext(out mapping)) - { - if (minMapping == null || mapping.ServerCreationTime < minMapping.ServerCreationTime) - { - minMapping = mapping; - minEnumerator = enumerator; - } - } - else - { - enumerators.RemoveAt(i); - } - } - - if (minMapping != null) - { - minEnumerator.ClearCachedValue(); - yield return Tuple.Create(minMapping, minEnumerator.StreamIndex); - } - } - } - - private ulong ExtractMessages(int streamIndex, ScaleoutMapping mapping, IList> items, ref int totalCount) - { - // For each of the event keys we care about, extract all of the messages - // from the payload - lock (EventKeys) - { - for (var i = 0; i < EventKeys.Count; ++i) - { - string eventKey = EventKeys[i]; - - for (int j = 0; j < mapping.LocalKeyInfo.Count; j++) - { - LocalEventKeyInfo info = mapping.LocalKeyInfo[j]; - - if (info.MessageStore != null && info.Key.Equals(eventKey, StringComparison.OrdinalIgnoreCase)) - { - MessageStoreResult storeResult = info.MessageStore.GetMessages(info.Id, 1); - - if (storeResult.Messages.Count > 0) - { - // TODO: Figure out what to do when we have multiple event keys per mapping - Message message = storeResult.Messages.Array[storeResult.Messages.Offset]; - - // Only add the message to the list if the stream index matches - if (message.StreamIndex == streamIndex) - { - items.Add(storeResult.Messages); - totalCount += storeResult.Messages.Count; - - // We got a mapping id bigger than what we expected which - // means we missed messages. Use the new mappingId. - if (message.MappingId > mapping.Id) - { - return message.MappingId; - } - } - else - { - // REVIEW: When the stream indexes don't match should we leave the mapping id as is? - // If we do nothing then we'll end up querying old cursor ids until - // we eventually find a message id that matches this stream index. - } - } - } - } - } - } - - return mapping.Id; - } - - private void AddCursorForStream(int streamIndex, List cursors) - { - ScaleoutMapping maxMapping = _streams[streamIndex].MaxMapping; - - ulong id = UInt64.MaxValue; - string key = streamIndex.ToString(CultureInfo.InvariantCulture); - - if (maxMapping != null) - { - id = maxMapping.Id; - } - - var newCursor = new Cursor(key, id); - cursors.Add(newCursor); - } - - private class CachedStreamEnumerator - { - private readonly IEnumerator _enumerator; - private ScaleoutMapping _cachedValue; - - public CachedStreamEnumerator(IEnumerator enumerator, int streamIndex) - { - _enumerator = enumerator; - StreamIndex = streamIndex; - } - - public int StreamIndex { get; private set; } - - public bool TryMoveNext(out ScaleoutMapping mapping) - { - mapping = null; - - if (_cachedValue != null) - { - mapping = _cachedValue; - return true; - } - - if (_enumerator.MoveNext()) - { - mapping = _enumerator.Current; - _cachedValue = mapping; - return true; - } - - return false; - } - - public void ClearCachedValue() - { - _cachedValue = null; - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/Subscription.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/Subscription.cs deleted file mode 100644 index 774ccbddc..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/Subscription.cs +++ /dev/null @@ -1,338 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Infrastructure; - -namespace Microsoft.AspNet.SignalR.Messaging -{ - public abstract class Subscription : ISubscription, IDisposable - { - private readonly Func> _callback; - private readonly object _callbackState; - private readonly IPerformanceCounterManager _counters; - - private int _state; - private int _subscriptionState; - - private bool Alive - { - get - { - return _subscriptionState != SubscriptionState.Disposed; - } - } - - public string Identity { get; private set; } - - public IList EventKeys { get; private set; } - - public int MaxMessages { get; private set; } - - public IDisposable Disposable { get; set; } - - protected Subscription(string identity, IList eventKeys, Func> callback, int maxMessages, IPerformanceCounterManager counters, object state) - { - if (String.IsNullOrEmpty(identity)) - { - throw new ArgumentNullException("identity"); - } - - if (eventKeys == null) - { - throw new ArgumentNullException("eventKeys"); - } - - if (callback == null) - { - throw new ArgumentNullException("callback"); - } - - if (maxMessages < 0) - { - throw new ArgumentOutOfRangeException("maxMessages"); - } - - if (counters == null) - { - throw new ArgumentNullException("counters"); - } - - Identity = identity; - _callback = callback; - EventKeys = eventKeys; - MaxMessages = maxMessages; - _counters = counters; - _callbackState = state; - - _counters.MessageBusSubscribersTotal.Increment(); - _counters.MessageBusSubscribersCurrent.Increment(); - _counters.MessageBusSubscribersPerSec.Increment(); - } - - public virtual Task Invoke(MessageResult result) - { - return Invoke(result, state => { }, state: null); - } - - private Task Invoke(MessageResult result, Action beforeInvoke, object state) - { - // Change the state from idle to invoking callback - var prevState = Interlocked.CompareExchange(ref _subscriptionState, - SubscriptionState.InvokingCallback, - SubscriptionState.Idle); - - if (prevState == SubscriptionState.Disposed) - { - // Only allow terminal messages after dispose - if (!result.Terminal) - { - return TaskAsyncHelper.False; - } - } - - beforeInvoke(state); - - _counters.MessageBusMessagesReceivedTotal.IncrementBy(result.TotalCount); - _counters.MessageBusMessagesReceivedPerSec.IncrementBy(result.TotalCount); - - return _callback.Invoke(result, _callbackState).ContinueWith(task => - { - // Go from invoking callback to idle - Interlocked.CompareExchange(ref _subscriptionState, - SubscriptionState.Idle, - SubscriptionState.InvokingCallback); - return task; - }, - TaskContinuationOptions.ExecuteSynchronously).FastUnwrap(); - } - - public Task Work() - { - // Set the state to working - Interlocked.Exchange(ref _state, State.Working); - - var tcs = new TaskCompletionSource(); - - WorkImpl(tcs); - - return tcs.Task; - } - - public bool SetQueued() - { - return Interlocked.Increment(ref _state) == State.Working; - } - - public bool UnsetQueued() - { - // If we try to set the state to idle and we were not already in the working state then keep going - return Interlocked.CompareExchange(ref _state, State.Idle, State.Working) != State.Working; - } - - [SuppressMessage("Microsoft.Usage", "CA2202:Do not dispose objects multiple times", Justification = "We have a sync and async code path.")] - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "We want to avoid user code taking the process down.")] - private void WorkImpl(TaskCompletionSource taskCompletionSource) - { - Process: - if (!Alive) - { - // If this subscription is dead then return immediately - taskCompletionSource.TrySetResult(null); - return; - } - - var items = new List>(); - int totalCount; - object state; - - PerformWork(items, out totalCount, out state); - - if (items.Count > 0) - { - var messageResult = new MessageResult(items, totalCount); - Task callbackTask = Invoke(messageResult, s => BeforeInvoke(s), state); - - if (callbackTask.IsCompleted) - { - try - { - // Make sure exceptions propagate - callbackTask.Wait(); - - if (callbackTask.Result) - { - // Sync path - goto Process; - } - else - { - // If we're done pumping messages through to this subscription - // then dispose - Dispose(); - - // If the callback said it's done then stop - taskCompletionSource.TrySetResult(null); - } - } - catch (Exception ex) - { - if (ex.InnerException is TaskCanceledException) - { - taskCompletionSource.TrySetCanceled(); - } - else - { - taskCompletionSource.TrySetUnwrappedException(ex); - } - } - } - else - { - WorkImplAsync(callbackTask, taskCompletionSource); - } - } - else - { - taskCompletionSource.TrySetResult(null); - } - } - - protected virtual void BeforeInvoke(object state) - { - } - - [SuppressMessage("Microsoft.Design", "CA1002:DoNotExposeGenericLists", Justification = "The list needs to be populated")] - [SuppressMessage("Microsoft.Design", "CA1007:UseGenericsWhereAppropriate", Justification = "The caller wouldn't be able to specify what the generic type argument is")] - [SuppressMessage("Microsoft.Design", "CA1021:AvoidOutParameters", MessageId = "1#", Justification = "The count needs to be returned")] - [SuppressMessage("Microsoft.Design", "CA1021:AvoidOutParameters", MessageId = "2#", Justification = "The state needs to be set by the callee")] - protected abstract void PerformWork(IList> items, out int totalCount, out object state); - - private void WorkImplAsync(Task callbackTask, TaskCompletionSource taskCompletionSource) - { - // Async path - callbackTask.ContinueWith(task => - { - if (task.IsFaulted) - { - taskCompletionSource.TrySetUnwrappedException(task.Exception); - } - else if (task.IsCanceled) - { - taskCompletionSource.TrySetCanceled(); - } - else if (task.Result) - { - WorkImpl(taskCompletionSource); - } - else - { - // If we're done pumping messages through to this subscription - // then dispose - Dispose(); - - // If the callback said it's done then stop - taskCompletionSource.TrySetResult(null); - } - }); - } - - public virtual bool AddEvent(string key, Topic topic) - { - return AddEventCore(key); - } - - public virtual void RemoveEvent(string key) - { - lock (EventKeys) - { - EventKeys.Remove(key); - } - } - - public virtual void SetEventTopic(string key, Topic topic) - { - // Don't call AddEvent since that's virtual - AddEventCore(key); - } - - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - // REIVIEW: Consider sleeping instead of using a tight loop, or maybe timing out after some interval - // if the client is very slow then this invoke call might not end quickly and this will make the CPU - // hot waiting for the task to return. - - int disposeRetryCount = 0; - - while (true) - { - // Wait until the subscription isn't working anymore - var state = Interlocked.CompareExchange(ref _subscriptionState, - SubscriptionState.Disposed, - SubscriptionState.Idle); - - // If we're not working then stop - if (state != SubscriptionState.InvokingCallback || disposeRetryCount ++ > 10) - { - if (state != SubscriptionState.Disposed) - { - // Only decrement if we're not disposed already - _counters.MessageBusSubscribersCurrent.Decrement(); - _counters.MessageBusSubscribersPerSec.Decrement(); - } - - // Raise the disposed callback - if (Disposable != null) - { - Disposable.Dispose(); - } - - break; - } - - Thread.Sleep(500); - } - } - } - - public void Dispose() - { - Dispose(true); - } - - public abstract void WriteCursor(TextWriter textWriter); - - private bool AddEventCore(string key) - { - lock (EventKeys) - { - if (EventKeys.Contains(key)) - { - return false; - } - - EventKeys.Add(key); - return true; - } - } - - private static class State - { - public const int Idle = 0; - public const int Working = 1; - } - - private static class SubscriptionState - { - public const int Idle = 0; - public const int InvokingCallback = 1; - public const int Disposed = 2; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/Topic.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/Topic.cs deleted file mode 100644 index eab7934c1..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/Topic.cs +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Threading; - -namespace Microsoft.AspNet.SignalR.Messaging -{ - public class Topic - { - private readonly TimeSpan _lifespan; - - // Keeps track of the last time this subscription was used - private DateTime _lastUsed = DateTime.UtcNow; - - public IList Subscriptions { get; private set; } - public MessageStore Store { get; private set; } - public ReaderWriterLockSlim SubscriptionLock { get; private set; } - - // State of the topic - internal int State; - - public virtual bool IsExpired - { - get - { - try - { - SubscriptionLock.EnterReadLock(); - - TimeSpan timeSpan = DateTime.UtcNow - _lastUsed; - - return Subscriptions.Count == 0 && timeSpan > _lifespan; - } - finally - { - SubscriptionLock.ExitReadLock(); - } - } - } - - public DateTime LastUsed - { - get - { - return _lastUsed; - } - } - - public Topic(uint storeSize, TimeSpan lifespan) - { - _lifespan = lifespan; - Subscriptions = new List(); - Store = new MessageStore(storeSize); - SubscriptionLock = new ReaderWriterLockSlim(); - } - - public void MarkUsed() - { - this._lastUsed = DateTime.UtcNow; - } - - public void AddSubscription(ISubscription subscription) - { - if (subscription == null) - { - throw new ArgumentNullException("subscription"); - } - - try - { - SubscriptionLock.EnterWriteLock(); - - MarkUsed(); - - Subscriptions.Add(subscription); - - // Created -> HasSubscriptions - Interlocked.CompareExchange(ref State, - TopicState.HasSubscriptions, - TopicState.NoSubscriptions); - } - finally - { - SubscriptionLock.ExitWriteLock(); - } - } - - public void RemoveSubscription(ISubscription subscription) - { - if (subscription == null) - { - throw new ArgumentNullException("subscription"); - } - - try - { - SubscriptionLock.EnterWriteLock(); - - MarkUsed(); - - Subscriptions.Remove(subscription); - - - if (Subscriptions.Count == 0) - { - // HasSubscriptions -> NoSubscriptions - Interlocked.CompareExchange(ref State, - TopicState.NoSubscriptions, - TopicState.HasSubscriptions); - } - } - finally - { - SubscriptionLock.ExitWriteLock(); - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/TopicLookup.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/TopicLookup.cs deleted file mode 100644 index d62d9084f..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/TopicLookup.cs +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using Microsoft.AspNet.SignalR.Infrastructure; - -namespace Microsoft.AspNet.SignalR.Messaging -{ - public sealed class TopicLookup : IEnumerable> - { - // General topics - private readonly ConcurrentDictionary _topics = new ConcurrentDictionary(); - - // All group topics - private readonly ConcurrentDictionary _groupTopics = new ConcurrentDictionary(new SipHashBasedStringEqualityComparer()); - - public int Count - { - get - { - return _topics.Count + _groupTopics.Count; - } - } - - public Topic this[string key] - { - get - { - Topic topic; - if (TryGetValue(key, out topic)) - { - return topic; - } - return null; - } - } - - public bool ContainsKey(string key) - { - if (PrefixHelper.HasGroupPrefix(key)) - { - return _groupTopics.ContainsKey(key); - } - - return _topics.ContainsKey(key); - } - - public bool TryGetValue(string key, out Topic topic) - { - if (PrefixHelper.HasGroupPrefix(key)) - { - return _groupTopics.TryGetValue(key, out topic); - } - - return _topics.TryGetValue(key, out topic); - } - - public IEnumerator> GetEnumerator() - { - return _topics.Concat(_groupTopics).GetEnumerator(); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return GetEnumerator(); - } - - public bool TryRemove(string key) - { - Topic topic; - if (PrefixHelper.HasGroupPrefix(key)) - { - return _groupTopics.TryRemove(key, out topic); - } - - return _topics.TryRemove(key, out topic); - } - - public Topic GetOrAdd(string key, Func factory) - { - if (PrefixHelper.HasGroupPrefix(key)) - { - return _groupTopics.GetOrAdd(key, factory); - } - - return _topics.GetOrAdd(key, factory); - } - - public void Clear() - { - _topics.Clear(); - _groupTopics.Clear(); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/TopicState.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/TopicState.cs deleted file mode 100644 index e69193c89..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/TopicState.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -namespace Microsoft.AspNet.SignalR.Messaging -{ - internal class TopicState - { - public const int NoSubscriptions = 0; - public const int HasSubscriptions = 1; - public const int Dying = 2; - public const int Dead = 3; - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Messaging/Volatile.cs b/src/Microsoft.AspNet.SignalR.Core/Messaging/Volatile.cs deleted file mode 100644 index d4ce8a10e..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Messaging/Volatile.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Threading; - -namespace Microsoft.AspNet.SignalR.Messaging -{ - // All methods here are guaranteed both volatile + atomic. - // TODO: Make this use the .NET 4.5 'Volatile' type. - internal static class Volatile - { - public static long Read(ref long location) - { - return Interlocked.Read(ref location); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Microsoft.AspNet.SignalR.Core.csproj b/src/Microsoft.AspNet.SignalR.Core/Microsoft.AspNet.SignalR.Core.csproj deleted file mode 100644 index 4fc44007d..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Microsoft.AspNet.SignalR.Core.csproj +++ /dev/null @@ -1,284 +0,0 @@ - - - - Debug - x86 - 8.0.30703 - 2.0 - {1B9A82C4-BCA1-4834-A33E-226F17BE070B} - Library - Properties - Microsoft.AspNet.SignalR - Microsoft.AspNet.SignalR.Core - 512 - true - ..\..\ - true - - - true - bin\x86\Debug\ - TRACE;DEBUG;PERFCOUNTERS - bin\Debug\Microsoft.AspNet.SignalR.Core.XML - true - 1591 - full - x86 - prompt - C:\Dropbox\Git\NzbDrone\src\Common\Microsoft.AspNet.SignalR.ruleset - 4 - false - - - bin\x86\Release\ - TRACE;PERFCOUNTERS - bin\Release\Microsoft.AspNet.SignalR.Core.XML - true - true - 1591 - pdbonly - x86 - prompt - C:\Dropbox\Git\NzbDrone\src\Common\Microsoft.AspNet.SignalR.ruleset - 4 - - - - ..\packages\Newtonsoft.Json.9.0.1\lib\net40\Newtonsoft.Json.dll - True - - - - - - - - - Properties\CommonAssemblyInfo.cs - - - Properties\CommonVersionInfo.cs - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - True - True - Resources.resx - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ResXFileCodeGenerator - Resources.Designer.cs - Designer - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/Microsoft.AspNet.SignalR.Core/Microsoft.AspNet.SignalR.Core.csproj.DotSettings b/src/Microsoft.AspNet.SignalR.Core/Microsoft.AspNet.SignalR.Core.csproj.DotSettings deleted file mode 100644 index 5b8822215..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Microsoft.AspNet.SignalR.Core.csproj.DotSettings +++ /dev/null @@ -1,2 +0,0 @@ - - DO_NOT_SHOW \ No newline at end of file diff --git a/src/Microsoft.AspNet.SignalR.Core/PersistentConnection.cs b/src/Microsoft.AspNet.SignalR.Core/PersistentConnection.cs deleted file mode 100644 index 49f2afdd6..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/PersistentConnection.cs +++ /dev/null @@ -1,522 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Configuration; -using Microsoft.AspNet.SignalR.Hosting; -using Microsoft.AspNet.SignalR.Infrastructure; -using Microsoft.AspNet.SignalR.Json; -using Microsoft.AspNet.SignalR.Messaging; -using Microsoft.AspNet.SignalR.Tracing; -using Microsoft.AspNet.SignalR.Transports; - -namespace Microsoft.AspNet.SignalR -{ - /// - /// Represents a connection between client and server. - /// - public abstract class PersistentConnection - { - private const string WebSocketsTransportName = "webSockets"; - private static readonly char[] SplitChars = new[] { ':' }; - - private IConfigurationManager _configurationManager; - private ITransportManager _transportManager; - private bool _initialized; - private IServerCommandHandler _serverMessageHandler; - - public virtual void Initialize(IDependencyResolver resolver, HostContext context) - { - if (resolver == null) - { - throw new ArgumentNullException("resolver"); - } - - if (context == null) - { - throw new ArgumentNullException("context"); - } - - if (_initialized) - { - return; - } - - MessageBus = resolver.Resolve(); - JsonSerializer = resolver.Resolve(); - TraceManager = resolver.Resolve(); - Counters = resolver.Resolve(); - AckHandler = resolver.Resolve(); - ProtectedData = resolver.Resolve(); - - _configurationManager = resolver.Resolve(); - _transportManager = resolver.Resolve(); - _serverMessageHandler = resolver.Resolve(); - - _initialized = true; - } - - public bool Authorize(IRequest request) - { - return AuthorizeRequest(request); - } - - protected virtual TraceSource Trace - { - get - { - return TraceManager["SignalR.PersistentConnection"]; - } - } - - protected IProtectedData ProtectedData { get; private set; } - - protected IMessageBus MessageBus { get; private set; } - - protected IJsonSerializer JsonSerializer { get; private set; } - - protected IAckHandler AckHandler { get; private set; } - - protected ITraceManager TraceManager { get; private set; } - - protected IPerformanceCounterManager Counters { get; private set; } - - protected ITransport Transport { get; private set; } - - /// - /// Gets the for the . - /// - public IConnection Connection - { - get; - private set; - } - - /// - /// Gets the for the . - /// - public IConnectionGroupManager Groups - { - get; - private set; - } - - private string DefaultSignal - { - get - { - return PrefixHelper.GetPersistentConnectionName(DefaultSignalRaw); - } - } - - private string DefaultSignalRaw - { - get - { - return GetType().FullName; - } - } - - internal virtual string GroupPrefix - { - get - { - return PrefixHelper.PersistentConnectionGroupPrefix; - } - } - - /// - /// Handles all requests for s. - /// - /// The for the current request. - /// A that completes when the pipeline is complete. - /// - /// Thrown if connection wasn't initialized. - /// Thrown if the transport wasn't specified. - /// Thrown if the connection id wasn't specified. - /// - public virtual Task ProcessRequest(HostContext context) - { - if (context == null) - { - throw new ArgumentNullException("context"); - } - - if (!_initialized) - { - throw new InvalidOperationException(String.Format(CultureInfo.CurrentCulture, Resources.Error_ConnectionNotInitialized)); - } - - if (IsNegotiationRequest(context.Request)) - { - return ProcessNegotiationRequest(context); - } - else if (IsPingRequest(context.Request)) - { - return ProcessPingRequest(context); - } - - Transport = GetTransport(context); - - if (Transport == null) - { - return FailResponse(context.Response, String.Format(CultureInfo.CurrentCulture, Resources.Error_ProtocolErrorUnknownTransport)); - } - - string connectionToken = context.Request.QueryString["connectionToken"]; - - // If there's no connection id then this is a bad request - if (String.IsNullOrEmpty(connectionToken)) - { - return FailResponse(context.Response, String.Format(CultureInfo.CurrentCulture, Resources.Error_ProtocolErrorMissingConnectionToken)); - } - - string connectionId; - string message; - int statusCode; - - if (!TryGetConnectionId(context, connectionToken, out connectionId, out message, out statusCode)) - { - return FailResponse(context.Response, message, statusCode); - } - - // Set the transport's connection id to the unprotected one - Transport.ConnectionId = connectionId; - - IList signals = GetSignals(connectionId); - IList groups = AppendGroupPrefixes(context, connectionId); - - Connection connection = CreateConnection(connectionId, signals, groups); - - Connection = connection; - string groupName = PrefixHelper.GetPersistentConnectionGroupName(DefaultSignalRaw); - Groups = new GroupManager(connection, groupName); - - Transport.TransportConnected = () => - { - var command = new ServerCommand - { - ServerCommandType = ServerCommandType.RemoveConnection, - Value = connectionId - }; - - return _serverMessageHandler.SendCommand(command); - }; - - Transport.Connected = () => - { - return TaskAsyncHelper.FromMethod(() => OnConnected(context.Request, connectionId).OrEmpty()); - }; - - Transport.Reconnected = () => - { - return TaskAsyncHelper.FromMethod(() => OnReconnected(context.Request, connectionId).OrEmpty()); - }; - - Transport.Received = data => - { - Counters.ConnectionMessagesSentTotal.Increment(); - Counters.ConnectionMessagesSentPerSec.Increment(); - return TaskAsyncHelper.FromMethod(() => OnReceived(context.Request, connectionId, data).OrEmpty()); - }; - - Transport.Disconnected = () => - { - return TaskAsyncHelper.FromMethod(() => OnDisconnected(context.Request, connectionId).OrEmpty()); - }; - - return Transport.ProcessRequest(connection).OrEmpty().Catch(Counters.ErrorsAllTotal, Counters.ErrorsAllPerSec); - } - - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "We want to catch any exception when unprotecting data.")] - internal bool TryGetConnectionId(HostContext context, - string connectionToken, - out string connectionId, - out string message, - out int statusCode) - { - string unprotectedConnectionToken = null; - - // connectionId is only valid when this method returns true - connectionId = null; - - // message and statusCode are only valid when this method returns false - message = null; - statusCode = 400; - - try - { - unprotectedConnectionToken = ProtectedData.Unprotect(connectionToken, Purposes.ConnectionToken); - } - catch (Exception ex) - { - Trace.TraceInformation("Failed to process connectionToken {0}: {1}", connectionToken, ex); - } - - if (String.IsNullOrEmpty(unprotectedConnectionToken)) - { - message = String.Format(CultureInfo.CurrentCulture, Resources.Error_ConnectionIdIncorrectFormat); - return false; - } - - var tokens = unprotectedConnectionToken.Split(SplitChars, 2); - - connectionId = tokens[0]; - string tokenUserName = tokens.Length > 1 ? tokens[1] : String.Empty; - string userName = GetUserIdentity(context); - - if (!String.Equals(tokenUserName, userName, StringComparison.OrdinalIgnoreCase)) - { - message = String.Format(CultureInfo.CurrentCulture, Resources.Error_UnrecognizedUserIdentity); - statusCode = 403; - return false; - } - - return true; - } - - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "We want to prevent any failures in unprotecting")] - internal IList VerifyGroups(HostContext context, string connectionId) - { - string groupsToken = context.Request.QueryString["groupsToken"]; - - if (String.IsNullOrEmpty(groupsToken)) - { - Trace.TraceInformation("The groups token is missing"); - - return ListHelper.Empty; - } - - string unprotectedGroupsToken = null; - - try - { - unprotectedGroupsToken = ProtectedData.Unprotect(groupsToken, Purposes.Groups); - } - catch (Exception ex) - { - Trace.TraceInformation("Failed to process groupsToken {0}: {1}", groupsToken, ex); - } - - if (String.IsNullOrEmpty(unprotectedGroupsToken)) - { - return ListHelper.Empty; - } - - var tokens = unprotectedGroupsToken.Split(SplitChars, 2); - - string groupConnectionId = tokens[0]; - string groupsValue = tokens.Length > 1 ? tokens[1] : String.Empty; - - if (!String.Equals(groupConnectionId, connectionId, StringComparison.OrdinalIgnoreCase)) - { - return ListHelper.Empty; - } - - return JsonSerializer.Parse(groupsValue); - } - - private IList AppendGroupPrefixes(HostContext context, string connectionId) - { - return (from g in OnRejoiningGroups(context.Request, VerifyGroups(context, connectionId), connectionId) - select GroupPrefix + g).ToList(); - } - - private Connection CreateConnection(string connectionId, IList signals, IList groups) - { - return new Connection(MessageBus, - JsonSerializer, - DefaultSignal, - connectionId, - signals, - groups, - TraceManager, - AckHandler, - Counters, - ProtectedData); - } - - /// - /// Returns the default signals for the . - /// - /// The id of the incoming connection. - /// The default signals for this . - private IList GetDefaultSignals(string connectionId) - { - // The list of default signals this connection cares about: - // 1. The default signal (the type name) - // 2. The connection id (so we can message this particular connection) - // 3. Ack signal - - return new string[] { - DefaultSignal, - PrefixHelper.GetConnectionId(connectionId), - PrefixHelper.GetAck(connectionId) - }; - } - - /// - /// Returns the signals used in the . - /// - /// The id of the incoming connection. - /// The signals used for this . - protected virtual IList GetSignals(string connectionId) - { - return GetDefaultSignals(connectionId); - } - - /// - /// Called before every request and gives the user a authorize the user. - /// - /// The for the current connection. - /// A boolean value that represents if the request is authorized. - protected virtual bool AuthorizeRequest(IRequest request) - { - return true; - } - - /// - /// Called when a connection reconnects after a timeout to determine which groups should be rejoined. - /// - /// The for the current connection. - /// The groups the calling connection claims to be part of. - /// The id of the reconnecting client. - /// A collection of group names that should be joined on reconnect - protected virtual IList OnRejoiningGroups(IRequest request, IList groups, string connectionId) - { - return groups; - } - - /// - /// Called when a new connection is made. - /// - /// The for the current connection. - /// The id of the connecting client. - /// A that completes when the connect operation is complete. - protected virtual Task OnConnected(IRequest request, string connectionId) - { - return TaskAsyncHelper.Empty; - } - - /// - /// Called when a connection reconnects after a timeout. - /// - /// The for the current connection. - /// The id of the re-connecting client. - /// A that completes when the re-connect operation is complete. - protected virtual Task OnReconnected(IRequest request, string connectionId) - { - return TaskAsyncHelper.Empty; - } - - /// - /// Called when data is received from a connection. - /// - /// The for the current connection. - /// The id of the connection sending the data. - /// The payload sent to the connection. - /// A that completes when the receive operation is complete. - protected virtual Task OnReceived(IRequest request, string connectionId, string data) - { - return TaskAsyncHelper.Empty; - } - - /// - /// Called when a connection disconnects. - /// - /// The for the current connection. - /// The id of the disconnected connection. - /// A that completes when the disconnect operation is complete. - protected virtual Task OnDisconnected(IRequest request, string connectionId) - { - return TaskAsyncHelper.Empty; - } - - private Task ProcessPingRequest(HostContext context) - { - var payload = new - { - Response = "pong" - }; - - if (!String.IsNullOrEmpty(context.Request.QueryString["callback"])) - { - return ProcessJsonpRequest(context, payload); - } - - context.Response.ContentType = JsonUtility.JsonMimeType; - return context.Response.End(JsonSerializer.Stringify(payload)); - } - - private Task ProcessNegotiationRequest(HostContext context) - { - // Total amount of time without a keep alive before the client should attempt to reconnect in seconds. - var keepAliveTimeout = _configurationManager.KeepAliveTimeout(); - string connectionId = Guid.NewGuid().ToString("d"); - string connectionToken = connectionId + ':' + GetUserIdentity(context); - - var payload = new - { - Url = context.Request.Url.LocalPath.Replace("/negotiate", ""), - ConnectionToken = ProtectedData.Protect(connectionToken, Purposes.ConnectionToken), - ConnectionId = connectionId, - KeepAliveTimeout = keepAliveTimeout != null ? keepAliveTimeout.Value.TotalSeconds : (double?)null, - DisconnectTimeout = _configurationManager.DisconnectTimeout.TotalSeconds, - TryWebSockets = _transportManager.SupportsTransport(WebSocketsTransportName) && context.SupportsWebSockets(), - WebSocketServerUrl = context.WebSocketServerUrl(), - ProtocolVersion = "1.2" - }; - - if (!String.IsNullOrEmpty(context.Request.QueryString["callback"])) - { - return ProcessJsonpRequest(context, payload); - } - - context.Response.ContentType = JsonUtility.JsonMimeType; - return context.Response.End(JsonSerializer.Stringify(payload)); - } - - private static string GetUserIdentity(HostContext context) - { - if (context.Request.User != null && context.Request.User.Identity.IsAuthenticated) - { - return context.Request.User.Identity.Name ?? String.Empty; - } - return String.Empty; - } - - private Task ProcessJsonpRequest(HostContext context, object payload) - { - context.Response.ContentType = JsonUtility.JavaScriptMimeType; - var data = JsonUtility.CreateJsonpCallback(context.Request.QueryString["callback"], JsonSerializer.Stringify(payload)); - - return context.Response.End(data); - } - - private static Task FailResponse(IResponse response, string message, int statusCode = 400) - { - response.StatusCode = statusCode; - return response.End(message); - } - - private static bool IsNegotiationRequest(IRequest request) - { - return request.Url.LocalPath.EndsWith("/negotiate", StringComparison.OrdinalIgnoreCase); - } - - private static bool IsPingRequest(IRequest request) - { - return request.Url.LocalPath.EndsWith("/ping", StringComparison.OrdinalIgnoreCase); - } - - private ITransport GetTransport(HostContext context) - { - return _transportManager.GetTransport(context); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Properties/AssemblyInfo.cs b/src/Microsoft.AspNet.SignalR.Core/Properties/AssemblyInfo.cs deleted file mode 100644 index b73b3e766..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Reflection; -using System.Runtime.CompilerServices; - -[assembly: AssemblyTitle("Microsoft.AspNet.SignalR.Core")] -[assembly: AssemblyDescription("Async signaling library for .NET to help build real-time, multi-user interactive web applications.")] -#if SIGNED -[assembly: InternalsVisibleTo("Microsoft.AspNet.SignalR.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b5fc90e7027f67871e773a8fde8938c81dd402ba65b9201d60593e96c492651e889cc13f1415ebb53fac1131ae0bd333c5ee6021672d9718ea31a8aebd0da0072f25d87dba6fc90ffd598ed4da35e44c398c454307e8e33b8426143daec9f596836f97c8f74750e5975c64e2189f45def46b2a2b1247adc3652bf5c308055da9")] -[assembly: InternalsVisibleTo("Microsoft.AspNet.SignalR.FunctionalTests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b5fc90e7027f67871e773a8fde8938c81dd402ba65b9201d60593e96c492651e889cc13f1415ebb53fac1131ae0bd333c5ee6021672d9718ea31a8aebd0da0072f25d87dba6fc90ffd598ed4da35e44c398c454307e8e33b8426143daec9f596836f97c8f74750e5975c64e2189f45def46b2a2b1247adc3652bf5c308055da9")] -[assembly: InternalsVisibleTo("Microsoft.AspNet.SignalR.Tests.Common, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b5fc90e7027f67871e773a8fde8938c81dd402ba65b9201d60593e96c492651e889cc13f1415ebb53fac1131ae0bd333c5ee6021672d9718ea31a8aebd0da0072f25d87dba6fc90ffd598ed4da35e44c398c454307e8e33b8426143daec9f596836f97c8f74750e5975c64e2189f45def46b2a2b1247adc3652bf5c308055da9")] -#else -[assembly: InternalsVisibleTo("Microsoft.AspNet.SignalR.Tests")] -[assembly: InternalsVisibleTo("Microsoft.AspNet.SignalR.FunctionalTests")] -[assembly: InternalsVisibleTo("Microsoft.AspNet.SignalR.Tests.Common")] -#endif diff --git a/src/Microsoft.AspNet.SignalR.Core/Resources.Designer.cs b/src/Microsoft.AspNet.SignalR.Core/Resources.Designer.cs deleted file mode 100644 index 92f0119ea..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Resources.Designer.cs +++ /dev/null @@ -1,375 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.18010 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace Microsoft.AspNet.SignalR { - /// - /// A strongly-typed resource class, for looking up localized strings, etc. - /// - // This class was auto-generated by the StronglyTypedResourceBuilder - // class via a tool like ResGen or Visual Studio. - // To add or remove a member, edit your .ResX file then rerun ResGen - // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - internal class Resources { - - private static global::System.Resources.ResourceManager resourceMan; - - private static global::System.Globalization.CultureInfo resourceCulture; - - [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] - internal Resources() { - } - - /// - /// Returns the cached ResourceManager instance used by this class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Resources.ResourceManager ResourceManager { - get { - if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.AspNet.SignalR.Resources", typeof(Resources).Assembly); - resourceMan = temp; - } - return resourceMan; - } - } - - /// - /// Overrides the current thread's CurrentUICulture property for all - /// resource lookups using this strongly typed resource class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Globalization.CultureInfo Culture { - get { - return resourceCulture; - } - set { - resourceCulture = value; - } - } - - /// - /// Looks up a localized string similar to /// <summary>Calls the {0} method on the server-side {1} hub.&#10;Returns a jQuery.Deferred() promise.</summary>. - /// - internal static string DynamicComment_CallsMethodOnServerSideDeferredPromise { - get { - return ResourceManager.GetString("DynamicComment_CallsMethodOnServerSideDeferredPromise", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to /// <param name=\"{0}\" type=\"{1}\">Server side type is {2}</param>. - /// - internal static string DynamicComment_ServerSideTypeIs { - get { - return ResourceManager.GetString("DynamicComment_ServerSideTypeIs", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Argument cannot be null or empty. - /// - internal static string Error_ArgumentNullOrEmpty { - get { - return ResourceManager.GetString("Error_ArgumentNullOrEmpty", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to The buffer size '{0}' is out of range.. - /// - internal static string Error_BufferSizeOutOfRange { - get { - return ResourceManager.GetString("Error_BufferSizeOutOfRange", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Caller is not authorized to invoke the {0} method on {1}.. - /// - internal static string Error_CallerNotAuthorizedToInvokeMethodOn { - get { - return ResourceManager.GetString("Error_CallerNotAuthorizedToInvokeMethodOn", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to The connection id is in the incorrect format.. - /// - internal static string Error_ConnectionIdIncorrectFormat { - get { - return ResourceManager.GetString("Error_ConnectionIdIncorrectFormat", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to The PersistentConnection is not initialized.. - /// - internal static string Error_ConnectionNotInitialized { - get { - return ResourceManager.GetString("Error_ConnectionNotInitialized", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to DisconnectTimeout cannot be configured after the KeepAlive.. - /// - internal static string Error_DisconnectTimeoutCannotBeConfiguredAfterKeepAlive { - get { - return ResourceManager.GetString("Error_DisconnectTimeoutCannotBeConfiguredAfterKeepAlive", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to DisconnectTimeout must be at least six seconds.. - /// - internal static string Error_DisconnectTimeoutMustBeAtLeastSixSeconds { - get { - return ResourceManager.GetString("Error_DisconnectTimeoutMustBeAtLeastSixSeconds", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Do not read RequireOutgoing. Use protected _requireOutgoing instead.. - /// - internal static string Error_DoNotReadRequireOutgoing { - get { - return ResourceManager.GetString("Error_DoNotReadRequireOutgoing", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Duplicate hub names found.. - /// - internal static string Error_DuplicateHubs { - get { - return ResourceManager.GetString("Error_DuplicateHubs", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Duplicate payload id detected for stream '{0}'.. - /// - internal static string Error_DuplicatePayloadsForStream { - get { - return ResourceManager.GetString("Error_DuplicatePayloadsForStream", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Error creating Hub {0}. . - /// - internal static string Error_ErrorCreatingHub { - get { - return ResourceManager.GetString("Error_ErrorCreatingHub", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to '{0}' Hub could not be resolved.. - /// - internal static string Error_HubCouldNotBeResolved { - get { - return ResourceManager.GetString("Error_HubCouldNotBeResolved", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to There was an error invoking Hub method '{0}.{1}'.. - /// - internal static string Error_HubInvocationFailed { - get { - return ResourceManager.GetString("Error_HubInvocationFailed", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Invalid cursor.. - /// - internal static string Error_InvalidCursorFormat { - get { - return ResourceManager.GetString("Error_InvalidCursorFormat", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to The supplied frameId is in the incorrect format.. - /// - internal static string Error_InvalidForeverFrameId { - get { - return ResourceManager.GetString("Error_InvalidForeverFrameId", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to '{0}' is not a {1}.. - /// - internal static string Error_IsNotA { - get { - return ResourceManager.GetString("Error_IsNotA", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to SignalR: JavaScript Hub proxy generation has been disabled.. - /// - internal static string Error_JavaScriptProxyDisabled { - get { - return ResourceManager.GetString("Error_JavaScriptProxyDisabled", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Keep Alive value must be greater than two seconds.. - /// - internal static string Error_KeepAliveMustBeGreaterThanTwoSeconds { - get { - return ResourceManager.GetString("Error_KeepAliveMustBeGreaterThanTwoSeconds", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Keep Alive value must be no more than a third of the DisconnectTimeout.. - /// - internal static string Error_KeepAliveMustBeNoMoreThanAThirdOfTheDisconnectTimeout { - get { - return ResourceManager.GetString("Error_KeepAliveMustBeNoMoreThanAThirdOfTheDisconnectTimeout", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to '{0}' method could not be resolved.. - /// - internal static string Error_MethodCouldNotBeResolved { - get { - return ResourceManager.GetString("Error_MethodCouldNotBeResolved", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Outgoing authorization can only be required for an entire Hub, not a specific method.. - /// - internal static string Error_MethodLevelOutgoingAuthorization { - get { - return ResourceManager.GetString("Error_MethodLevelOutgoingAuthorization", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Multiple activators for type {0} are registered. Please call GetServices instead.. - /// - internal static string Error_MultipleActivatorsAreaRegisteredCallGetServices { - get { - return ResourceManager.GetString("Error_MultipleActivatorsAreaRegisteredCallGetServices", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Unexpected end when reading object.. - /// - internal static string Error_ParseObjectFailed { - get { - return ResourceManager.GetString("Error_ParseObjectFailed", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Protocol error: Missing connection token.. - /// - internal static string Error_ProtocolErrorMissingConnectionToken { - get { - return ResourceManager.GetString("Error_ProtocolErrorMissingConnectionToken", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Protocol error: Unknown transport.. - /// - internal static string Error_ProtocolErrorUnknownTransport { - get { - return ResourceManager.GetString("Error_ProtocolErrorUnknownTransport", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to State has exceeded the maximum length of 4096 bytes.. - /// - internal static string Error_StateExceededMaximumLength { - get { - return ResourceManager.GetString("Error_StateExceededMaximumLength", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to The stream has been closed.. - /// - internal static string Error_StreamClosed { - get { - return ResourceManager.GetString("Error_StreamClosed", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to The stream is not open.. - /// - internal static string Error_StreamNotOpen { - get { - return ResourceManager.GetString("Error_StreamNotOpen", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to The queue is full.. - /// - internal static string Error_TaskQueueFull { - get { - return ResourceManager.GetString("Error_TaskQueueFull", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Unable to add module. The HubPipeline has already been invoked.. - /// - internal static string Error_UnableToAddModulePiplineAlreadyInvoked { - get { - return ResourceManager.GetString("Error_UnableToAddModulePiplineAlreadyInvoked", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Unrecognized user identity. The user identity cannot change during an active SignalR connection.. - /// - internal static string Error_UnrecognizedUserIdentity { - get { - return ResourceManager.GetString("Error_UnrecognizedUserIdentity", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Using a Hub instance not created by the HubPipeline is unsupported.. - /// - internal static string Error_UsingHubInstanceNotCreatedUnsupported { - get { - return ResourceManager.GetString("Error_UsingHubInstanceNotCreatedUnsupported", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to WebSockets is not supported.. - /// - internal static string Error_WebSocketsNotSupported { - get { - return ResourceManager.GetString("Error_WebSocketsNotSupported", resourceCulture); - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Resources.resx b/src/Microsoft.AspNet.SignalR.Core/Resources.resx deleted file mode 100644 index a858f4b40..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Resources.resx +++ /dev/null @@ -1,225 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - /// <summary>Calls the {0} method on the server-side {1} hub.&#10;Returns a jQuery.Deferred() promise.</summary> - - - /// <param name=\"{0}\" type=\"{1}\">Server side type is {2}</param> - - - Argument cannot be null or empty - - - The buffer size '{0}' is out of range. - - - Caller is not authorized to invoke the {0} method on {1}. - - - The connection id is in the incorrect format. - - - The PersistentConnection is not initialized. - - - DisconnectTimeout cannot be configured after the KeepAlive. - - - DisconnectTimeout must be at least six seconds. - - - Do not read RequireOutgoing. Use protected _requireOutgoing instead. - - - Duplicate hub names found. - - - Duplicate payload id detected for stream '{0}'. - - - Error creating Hub {0}. - - - '{0}' Hub could not be resolved. - - - There was an error invoking Hub method '{0}.{1}'. - - - Invalid cursor. - - - The supplied frameId is in the incorrect format. - - - '{0}' is not a {1}. - - - SignalR: JavaScript Hub proxy generation has been disabled. - - - Keep Alive value must be greater than two seconds. - - - Keep Alive value must be no more than a third of the DisconnectTimeout. - - - '{0}' method could not be resolved. - - - Outgoing authorization can only be required for an entire Hub, not a specific method. - - - Multiple activators for type {0} are registered. Please call GetServices instead. - - - Unexpected end when reading object. - - - Protocol error: Missing connection token. - - - Protocol error: Unknown transport. - - - State has exceeded the maximum length of 4096 bytes. - - - The stream has been closed. - - - The stream is not open. - - - The queue is full. - - - Unable to add module. The HubPipeline has already been invoked. - - - Unrecognized user identity. The user identity cannot change during an active SignalR connection. - - - Using a Hub instance not created by the HubPipeline is unsupported. - - - WebSockets is not supported. - - \ No newline at end of file diff --git a/src/Microsoft.AspNet.SignalR.Core/Scripts/hubs.js b/src/Microsoft.AspNet.SignalR.Core/Scripts/hubs.js deleted file mode 100644 index ca0f7977d..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Scripts/hubs.js +++ /dev/null @@ -1,90 +0,0 @@ -/*! - * ASP.NET SignalR JavaScript Library v1.2.2 - * http://signalr.net/ - * - * Copyright Microsoft Open Technologies, Inc. All rights reserved. - * Licensed under the Apache 2.0 - * https://github.com/SignalR/SignalR/blob/master/LICENSE.md - * - */ - -/// -/// -(function ($, window, undefined) { - /// - "use strict"; - - if (typeof ($.signalR) !== "function") { - throw new Error("SignalR: SignalR is not loaded. Please ensure jquery.signalR-x.js is referenced before ~/signalr/hubs."); - } - - var signalR = $.signalR; - - function makeProxyCallback(hub, callback) { - return function () { - // Call the client hub method - callback.apply(hub, $.makeArray(arguments)); - }; - } - - function registerHubProxies(instance, shouldSubscribe) { - var key, hub, memberKey, memberValue, subscriptionMethod; - - for (key in instance) { - if (instance.hasOwnProperty(key)) { - hub = instance[key]; - - if (!(hub.hubName)) { - // Not a client hub - continue; - } - - if (shouldSubscribe) { - // We want to subscribe to the hub events - subscriptionMethod = hub.on; - } - else { - // We want to unsubscribe from the hub events - subscriptionMethod = hub.off; - } - - // Loop through all members on the hub and find client hub functions to subscribe/unsubscribe - for (memberKey in hub.client) { - if (hub.client.hasOwnProperty(memberKey)) { - memberValue = hub.client[memberKey]; - - if (!$.isFunction(memberValue)) { - // Not a client hub function - continue; - } - - subscriptionMethod.call(hub, memberKey, makeProxyCallback(hub, memberValue)); - } - } - } - } - } - - $.hubConnection.prototype.createHubProxies = function () { - var proxies = {}; - this.starting(function () { - // Register the hub proxies as subscribed - // (instance, shouldSubscribe) - registerHubProxies(proxies, true); - - this._registerSubscribedHubs(); - }).disconnected(function () { - // Unsubscribe all hub proxies when we "disconnect". This is to ensure that we do not re-add functional call backs. - // (instance, shouldSubscribe) - registerHubProxies(proxies, false); - }); - - /*hubs*/ - - return proxies; - }; - - signalR.hub = $.hubConnection("{serviceUrl}", { useDefaultPath: false }); - $.extend(signalR, signalR.hub.createHubProxies()); - -}(window.jQuery, window)); \ No newline at end of file diff --git a/src/Microsoft.AspNet.SignalR.Core/TaskAsyncHelper.cs b/src/Microsoft.AspNet.SignalR.Core/TaskAsyncHelper.cs deleted file mode 100644 index 34a604bb2..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/TaskAsyncHelper.cs +++ /dev/null @@ -1,1115 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Infrastructure; - -namespace Microsoft.AspNet.SignalR -{ - internal static class TaskAsyncHelper - { - private static readonly Task _emptyTask = MakeTask(null); - private static readonly Task _trueTask = MakeTask(true); - private static readonly Task _falseTask = MakeTask(false); - - private static Task MakeTask(T value) - { - return FromResult(value); - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static Task Empty - { - get - { - return _emptyTask; - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static Task True - { - get - { - return _trueTask; - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static Task False - { - get - { - return _falseTask; - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static Task OrEmpty(this Task task) - { - return task ?? Empty; - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static Task OrEmpty(this Task task) - { - return task ?? TaskCache.Empty; - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Exceptions are set in a tcs")] - public static Task FromAsync(Func beginMethod, Action endMethod, object state) - { - try - { - return Task.Factory.FromAsync(beginMethod, endMethod, state); - } - catch (Exception ex) - { - return TaskAsyncHelper.FromError(ex); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Exceptions are set in a tcs")] - public static Task FromAsync(Func beginMethod, Func endMethod, object state) - { - try - { - return Task.Factory.FromAsync(beginMethod, endMethod, state); - } - catch (Exception ex) - { - return TaskAsyncHelper.FromError(ex); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static Task Series(Func[] tasks, object[] state) - { - Task prev = TaskAsyncHelper.Empty; - Task finalTask = TaskAsyncHelper.Empty; - - for (int i = 0; i < tasks.Length; i++) - { - prev = finalTask; - finalTask = prev.Then(tasks[i], state[i]); - } - - return finalTask; - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static TTask Catch(this TTask task) where TTask : Task - { - return Catch(task, ex => { }); - } - -#if PERFCOUNTERS - public static TTask Catch(this TTask task, params IPerformanceCounter[] counters) where TTask : Task - { - return Catch(task, _ => - { - if (counters == null) - { - return; - } - for (var i = 0; i < counters.Length; i++) - { - counters[i].Increment(); - } - }); - } -#endif - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static TTask Catch(this TTask task, Action handler, object state) where TTask : Task - { - if (task != null && task.Status != TaskStatus.RanToCompletion) - { - if (task.Status == TaskStatus.Faulted) - { - ExecuteOnFaulted(handler, state, task.Exception); - } - else - { - AttachFaultedContinuation(task, handler, state); - } - } - - return task; - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - private static void AttachFaultedContinuation(TTask task, Action handler, object state) where TTask : Task - { - task.ContinueWith(innerTask => - { - ExecuteOnFaulted(handler, state, innerTask.Exception); - }, - TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously); - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - private static void ExecuteOnFaulted(Action handler, object state, AggregateException exception) - { - // observe Exception -#if !WINDOWS_PHONE && !SILVERLIGHT && !NETFX_CORE - Trace.TraceWarning("SignalR exception thrown by Task: {0}", exception); -#endif - handler(exception, state); - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static TTask Catch(this TTask task, Action handler) where TTask : Task - { - return task.Catch((ex, state) => ((Action)state).Invoke(ex), - handler); - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Exceptions are set in a tcs")] - public static Task ContinueWithNotComplete(this Task task, Action action) - { - switch (task.Status) - { - case TaskStatus.Faulted: - case TaskStatus.Canceled: - try - { - action(); - return task; - } - catch (Exception e) - { - return FromError(e); - } - case TaskStatus.RanToCompletion: - return task; - default: - var tcs = new TaskCompletionSource(); - - task.ContinueWith(t => - { - if (t.IsFaulted || t.IsCanceled) - { - try - { - action(); - - if (t.IsFaulted) - { - tcs.TrySetUnwrappedException(t.Exception); - } - else - { - tcs.TrySetCanceled(); - } - } - catch (Exception e) - { - tcs.TrySetException(e); - } - } - else - { - tcs.TrySetResult(null); - } - }, - TaskContinuationOptions.ExecuteSynchronously); - - return tcs.Task; - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static void ContinueWithNotComplete(this Task task, TaskCompletionSource tcs) - { - task.ContinueWith(t => - { - if (t.IsFaulted) - { - tcs.SetUnwrappedException(t.Exception); - } - else if (t.IsCanceled) - { - tcs.SetCanceled(); - } - }, - TaskContinuationOptions.NotOnRanToCompletion); - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static void ContinueWith(this Task task, TaskCompletionSource tcs) - { - task.ContinueWith(t => - { - if (t.IsFaulted) - { - tcs.TrySetUnwrappedException(t.Exception); - } - else if (t.IsCanceled) - { - tcs.TrySetCanceled(); - } - else - { - tcs.TrySetResult(null); - } - }, - TaskContinuationOptions.ExecuteSynchronously); - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static void ContinueWith(this Task task, TaskCompletionSource tcs) - { - task.ContinueWith(t => - { - if (t.IsFaulted) - { - tcs.TrySetUnwrappedException(t.Exception); - } - else if (t.IsCanceled) - { - tcs.TrySetCanceled(); - } - else - { - tcs.TrySetResult(t.Result); - } - }); - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static Task Return(this Task[] tasks) - { - return Then(tasks, () => { }); - } - - // Then extesions - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static Task Then(this Task task, Action successor) - { - switch (task.Status) - { - case TaskStatus.Faulted: - case TaskStatus.Canceled: - return task; - - case TaskStatus.RanToCompletion: - return FromMethod(successor); - - default: - return RunTask(task, successor); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static Task Then(this Task task, Func successor) - { - switch (task.Status) - { - case TaskStatus.Faulted: - return FromError(task.Exception); - - case TaskStatus.Canceled: - return Canceled(); - - case TaskStatus.RanToCompletion: - return FromMethod(successor); - - default: - return TaskRunners.RunTask(task, successor); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static Task Then(this Task[] tasks, Action successor) - { - if (tasks.Length == 0) - { - return FromMethod(successor); - } - - var tcs = new TaskCompletionSource(); - Task.Factory.ContinueWhenAll(tasks, completedTasks => - { - var faulted = completedTasks.FirstOrDefault(t => t.IsFaulted); - if (faulted != null) - { - tcs.SetUnwrappedException(faulted.Exception); - return; - } - var cancelled = completedTasks.FirstOrDefault(t => t.IsCanceled); - if (cancelled != null) - { - tcs.SetCanceled(); - return; - } - - successor(); - tcs.SetResult(null); - }); - - return tcs.Task; - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static Task Then(this Task task, Action successor, T1 arg1) - { - switch (task.Status) - { - case TaskStatus.Faulted: - case TaskStatus.Canceled: - return task; - - case TaskStatus.RanToCompletion: - return FromMethod(successor, arg1); - - default: - return GenericDelegates.ThenWithArgs(task, successor, arg1); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static Task Then(this Task task, Action successor, T1 arg1, T2 arg2) - { - switch (task.Status) - { - case TaskStatus.Faulted: - case TaskStatus.Canceled: - return task; - - case TaskStatus.RanToCompletion: - return FromMethod(successor, arg1, arg2); - - default: - return GenericDelegates.ThenWithArgs(task, successor, arg1, arg2); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static Task Then(this Task task, Func successor, T1 arg1) - { - switch (task.Status) - { - case TaskStatus.Faulted: - case TaskStatus.Canceled: - return task; - - case TaskStatus.RanToCompletion: - return FromMethod(successor, arg1); - - default: - return GenericDelegates.ThenWithArgs(task, successor, arg1) - .FastUnwrap(); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static Task Then(this Task task, Func successor, T1 arg1, T2 arg2) - { - switch (task.Status) - { - case TaskStatus.Faulted: - case TaskStatus.Canceled: - return task; - - case TaskStatus.RanToCompletion: - return FromMethod(successor, arg1, arg2); - - default: - return GenericDelegates.ThenWithArgs(task, successor, arg1, arg2) - .FastUnwrap(); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static Task Then(this Task task, Func> successor) - { - switch (task.Status) - { - case TaskStatus.Faulted: - return FromError(task.Exception); - - case TaskStatus.Canceled: - return Canceled(); - - case TaskStatus.RanToCompletion: - return FromMethod(successor, task.Result); - - default: - return TaskRunners>.RunTask(task, t => successor(t.Result)) - .FastUnwrap(); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static Task Then(this Task task, Func successor) - { - switch (task.Status) - { - case TaskStatus.Faulted: - return FromError(task.Exception); - - case TaskStatus.Canceled: - return Canceled(); - - case TaskStatus.RanToCompletion: - return FromMethod(successor, task.Result); - - default: - return TaskRunners.RunTask(task, t => successor(t.Result)); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static Task Then(this Task task, Func successor, T1 arg1) - { - switch (task.Status) - { - case TaskStatus.Faulted: - return FromError(task.Exception); - - case TaskStatus.Canceled: - return Canceled(); - - case TaskStatus.RanToCompletion: - return FromMethod(successor, task.Result, arg1); - - default: - return GenericDelegates.ThenWithArgs(task, successor, arg1); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static Task Then(this Task task, Func successor) - { - switch (task.Status) - { - case TaskStatus.Faulted: - case TaskStatus.Canceled: - return task; - - case TaskStatus.RanToCompletion: - return FromMethod(successor); - - default: - return TaskRunners.RunTask(task, successor) - .FastUnwrap(); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static Task Then(this Task task, Func> successor) - { - switch (task.Status) - { - case TaskStatus.Faulted: - return FromError(task.Exception); - - case TaskStatus.Canceled: - return Canceled(); - - case TaskStatus.RanToCompletion: - return FromMethod(successor); - - default: - return TaskRunners>.RunTask(task, successor) - .FastUnwrap(); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static Task Then(this Task task, Action successor) - { - switch (task.Status) - { - case TaskStatus.Faulted: - case TaskStatus.Canceled: - return task; - - case TaskStatus.RanToCompletion: - return FromMethod(successor, task.Result); - - default: - return TaskRunners.RunTask(task, successor); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static Task Then(this Task task, Func successor) - { - switch (task.Status) - { - case TaskStatus.Faulted: - case TaskStatus.Canceled: - return task; - - case TaskStatus.RanToCompletion: - return FromMethod(successor, task.Result); - - default: - return TaskRunners.RunTask(task, t => successor(t.Result)) - .FastUnwrap(); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static Task Then(this Task task, Func, T1, Task> successor, T1 arg1) - { - switch (task.Status) - { - case TaskStatus.Faulted: - case TaskStatus.Canceled: - return task; - - case TaskStatus.RanToCompletion: - return FromMethod(successor, task, arg1); - - default: - return GenericDelegates, T1, object>.ThenWithArgs(task, successor, arg1) - .FastUnwrap(); - } - } - - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Exceptions are flowed to the caller")] - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static Task Finally(this Task task, Action next, object state) - { - try - { - switch (task.Status) - { - case TaskStatus.Faulted: - case TaskStatus.Canceled: - next(state); - return task; - case TaskStatus.RanToCompletion: - return FromMethod(next, state); - - default: - return RunTaskSynchronously(task, next, state, onlyOnSuccess: false); - } - } - catch (Exception ex) - { - return FromError(ex); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static Task RunSynchronously(this Task task, Action successor) - { - switch (task.Status) - { - case TaskStatus.Faulted: - case TaskStatus.Canceled: - return task; - - case TaskStatus.RanToCompletion: - return FromMethod(successor); - - default: - return RunTaskSynchronously(task, state => ((Action)state).Invoke(), successor); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static Task FastUnwrap(this Task task) - { - var innerTask = (task.Status == TaskStatus.RanToCompletion) ? task.Result : null; - return innerTask ?? task.Unwrap(); - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static Task FastUnwrap(this Task> task) - { - var innerTask = (task.Status == TaskStatus.RanToCompletion) ? task.Result : null; - return innerTask ?? task.Unwrap(); - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static Task Delay(TimeSpan timeOut) - { -#if NETFX_CORE - return Task.Delay(timeOut); -#else - var tcs = new TaskCompletionSource(); - - var timer = new Timer(tcs.SetResult, - null, - timeOut, - TimeSpan.FromMilliseconds(-1)); - - return tcs.Task.ContinueWith(_ => - { - timer.Dispose(); - }, - TaskContinuationOptions.ExecuteSynchronously); -#endif - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Exceptions are set in a tcs")] - public static Task FromMethod(Action func) - { - try - { - func(); - return Empty; - } - catch (Exception ex) - { - return FromError(ex); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Exceptions are set in a tcs")] - public static Task FromMethod(Action func, T1 arg) - { - try - { - func(arg); - return Empty; - } - catch (Exception ex) - { - return FromError(ex); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Exceptions are set in a tcs")] - public static Task FromMethod(Action func, T1 arg1, T2 arg2) - { - try - { - func(arg1, arg2); - return Empty; - } - catch (Exception ex) - { - return FromError(ex); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Exceptions are set in a tcs")] - public static Task FromMethod(Func func) - { - try - { - return func(); - } - catch (Exception ex) - { - return FromError(ex); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Exceptions are set in a tcs")] - public static Task FromMethod(Func> func) - { - try - { - return func(); - } - catch (Exception ex) - { - return FromError(ex); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Exceptions are set in a tcs")] - public static Task FromMethod(Func func) - { - try - { - return FromResult(func()); - } - catch (Exception ex) - { - return FromError(ex); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Exceptions are set in a tcs")] - public static Task FromMethod(Func func, T1 arg) - { - try - { - return func(arg); - } - catch (Exception ex) - { - return FromError(ex); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Exceptions are set in a tcs")] - public static Task FromMethod(Func func, T1 arg1, T2 arg2) - { - try - { - return func(arg1, arg2); - } - catch (Exception ex) - { - return FromError(ex); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Exceptions are set in a tcs")] - public static Task FromMethod(Func> func, T1 arg) - { - try - { - return func(arg); - } - catch (Exception ex) - { - return FromError(ex); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Exceptions are set in a tcs")] - public static Task FromMethod(Func func, T1 arg) - { - try - { - return FromResult(func(arg)); - } - catch (Exception ex) - { - return FromError(ex); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Exceptions are set in a tcs")] - public static Task FromMethod(Func> func, T1 arg1, T2 arg2) - { - try - { - return func(arg1, arg2); - } - catch (Exception ex) - { - return FromError(ex); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Exceptions are set in a tcs")] - public static Task FromMethod(Func func, T1 arg1, T2 arg2) - { - try - { - return FromResult(func(arg1, arg2)); - } - catch (Exception ex) - { - return FromError(ex); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - public static Task FromResult(T value) - { - var tcs = new TaskCompletionSource(); - tcs.SetResult(value); - return tcs.Task; - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - internal static Task FromError(Exception e) - { - return FromError(e); - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - internal static Task FromError(Exception e) - { - var tcs = new TaskCompletionSource(); - tcs.SetUnwrappedException(e); - return tcs.Task; - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - internal static void SetUnwrappedException(this TaskCompletionSource tcs, Exception e) - { - var aggregateException = e as AggregateException; - if (aggregateException != null) - { - tcs.SetException(aggregateException.InnerExceptions); - } - else - { - tcs.SetException(e); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - internal static bool TrySetUnwrappedException(this TaskCompletionSource tcs, Exception e) - { - var aggregateException = e as AggregateException; - if (aggregateException != null) - { - return tcs.TrySetException(aggregateException.InnerExceptions); - } - else - { - return tcs.TrySetException(e); - } - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - private static Task Canceled() - { - var tcs = new TaskCompletionSource(); - tcs.SetCanceled(); - return tcs.Task; - } - - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - private static Task Canceled() - { - var tcs = new TaskCompletionSource(); - tcs.SetCanceled(); - return tcs.Task; - } - - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Exceptions are set in a tcs")] - private static Task RunTask(Task task, Action successor) - { - var tcs = new TaskCompletionSource(); - task.ContinueWith(t => - { - if (t.IsFaulted) - { - tcs.SetUnwrappedException(t.Exception); - } - else if (t.IsCanceled) - { - tcs.SetCanceled(); - } - else - { - try - { - successor(); - tcs.SetResult(null); - } - catch (Exception ex) - { - tcs.SetUnwrappedException(ex); - } - } - }); - - return tcs.Task; - } - - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Exceptions are set in a tcs")] - [SuppressMessage("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode", Justification = "This is a shared file")] - private static Task RunTaskSynchronously(Task task, Action next, object state, bool onlyOnSuccess = true) - { - var tcs = new TaskCompletionSource(); - task.ContinueWith(t => - { - try - { - if (t.IsFaulted) - { - if (!onlyOnSuccess) - { - next(state); - } - - tcs.SetUnwrappedException(t.Exception); - } - else if (t.IsCanceled) - { - if (!onlyOnSuccess) - { - next(state); - } - - tcs.SetCanceled(); - } - else - { - next(state); - tcs.SetResult(null); - } - } - catch (Exception ex) - { - tcs.SetUnwrappedException(ex); - } - }, - TaskContinuationOptions.ExecuteSynchronously); - - return tcs.Task; - } - - private static class TaskRunners - { - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Exceptions are set in a tcs")] - internal static Task RunTask(Task task, Action successor) - { - var tcs = new TaskCompletionSource(); - task.ContinueWith(t => - { - if (t.IsFaulted) - { - tcs.SetUnwrappedException(t.Exception); - } - else if (t.IsCanceled) - { - tcs.SetCanceled(); - } - else - { - try - { - successor(t.Result); - tcs.SetResult(null); - } - catch (Exception ex) - { - tcs.SetUnwrappedException(ex); - } - } - }); - - return tcs.Task; - } - - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Exceptions are set in a tcs")] - internal static Task RunTask(Task task, Func successor) - { - var tcs = new TaskCompletionSource(); - task.ContinueWith(t => - { - if (t.IsFaulted) - { - tcs.SetUnwrappedException(t.Exception); - } - else if (t.IsCanceled) - { - tcs.SetCanceled(); - } - else - { - try - { - tcs.SetResult(successor()); - } - catch (Exception ex) - { - tcs.SetUnwrappedException(ex); - } - } - }); - - return tcs.Task; - } - - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Exceptions are set in a tcs")] - internal static Task RunTask(Task task, Func, TResult> successor) - { - var tcs = new TaskCompletionSource(); - task.ContinueWith(t => - { - if (task.IsFaulted) - { - tcs.SetUnwrappedException(t.Exception); - } - else if (task.IsCanceled) - { - tcs.SetCanceled(); - } - else - { - try - { - tcs.SetResult(successor(t)); - } - catch (Exception ex) - { - tcs.SetUnwrappedException(ex); - } - } - }); - - return tcs.Task; - } - } - - private static class GenericDelegates - { - internal static Task ThenWithArgs(Task task, Action successor, T1 arg1) - { - return RunTask(task, () => successor(arg1)); - } - - internal static Task ThenWithArgs(Task task, Action successor, T1 arg1, T2 arg2) - { - return RunTask(task, () => successor(arg1, arg2)); - } - - internal static Task ThenWithArgs(Task task, Func successor, T1 arg1) - { - return TaskRunners.RunTask(task, () => successor(arg1)); - } - - internal static Task ThenWithArgs(Task task, Func successor, T1 arg1, T2 arg2) - { - return TaskRunners.RunTask(task, () => successor(arg1, arg2)); - } - - internal static Task ThenWithArgs(Task task, Func successor, T1 arg1) - { - return TaskRunners.RunTask(task, t => successor(t.Result, arg1)); - } - - internal static Task ThenWithArgs(Task task, Func successor, T1 arg1) - { - return TaskRunners.RunTask(task, () => successor(arg1)); - } - - internal static Task ThenWithArgs(Task task, Func successor, T1 arg1, T2 arg2) - { - return TaskRunners.RunTask(task, () => successor(arg1, arg2)); - } - - internal static Task> ThenWithArgs(Task task, Func> successor, T1 arg1) - { - return TaskRunners>.RunTask(task, t => successor(t.Result, arg1)); - } - - internal static Task> ThenWithArgs(Task task, Func, T1, Task> successor, T1 arg1) - { - return TaskRunners>.RunTask(task, t => successor(t, arg1)); - } - } - - private static class TaskCache - { - public static Task Empty = MakeTask(default(T)); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Tracing/ITraceManager.cs b/src/Microsoft.AspNet.SignalR.Core/Tracing/ITraceManager.cs deleted file mode 100644 index 21b8f03dc..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Tracing/ITraceManager.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Diagnostics; - -namespace Microsoft.AspNet.SignalR.Tracing -{ - public interface ITraceManager - { - SourceSwitch Switch { get; } - TraceSource this[string name] { get; } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Tracing/TraceManager.cs b/src/Microsoft.AspNet.SignalR.Core/Tracing/TraceManager.cs deleted file mode 100644 index 8ca4264a6..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Tracing/TraceManager.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Concurrent; -using System.Diagnostics; - -namespace Microsoft.AspNet.SignalR.Tracing -{ - public class TraceManager : ITraceManager - { - private readonly ConcurrentDictionary _sources = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); - - public TraceManager() - { - Switch = new SourceSwitch("SignalRSwitch"); - } - - public SourceSwitch Switch { get; private set; } - - public TraceSource this[string name] - { - get - { - return _sources.GetOrAdd(name, key => new TraceSource(key, SourceLevels.Off) - { - Switch = Switch - }); - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Tracing/TraceSourceExtensions.cs b/src/Microsoft.AspNet.SignalR.Core/Tracing/TraceSourceExtensions.cs deleted file mode 100644 index 745304bd5..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Tracing/TraceSourceExtensions.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -namespace System.Diagnostics -{ - public static class TraceSourceExtensions - { - [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "msg")] - public static void TraceVerbose(this TraceSource traceSource, string msg) - { - Trace(traceSource, TraceEventType.Verbose, msg); - } - - public static void TraceVerbose(this TraceSource traceSource, string format, params object[] args) - { - Trace(traceSource, TraceEventType.Verbose, format, args); - } - - [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "msg")] - public static void TraceWarning(this TraceSource traceSource, string msg) - { - Trace(traceSource, TraceEventType.Warning, msg); - } - - public static void TraceWarning(this TraceSource traceSource, string format, params object[] args) - { - Trace(traceSource, TraceEventType.Warning, format, args); - } - - [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "msg")] - public static void TraceError(this TraceSource traceSource, string msg) - { - Trace(traceSource, TraceEventType.Error, msg); - } - - public static void TraceError(this TraceSource traceSource, string format, params object[] args) - { - Trace(traceSource, TraceEventType.Error, format, args); - } - - private static void Trace(TraceSource traceSource, TraceEventType eventType, string msg) - { - traceSource.TraceEvent(eventType, 0, msg); - } - - private static void Trace(TraceSource traceSource, TraceEventType eventType, string format, params object[] args) - { - traceSource.TraceEvent(eventType, 0, format, args); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Transports/ForeverFrameTransport.cs b/src/Microsoft.AspNet.SignalR.Core/Transports/ForeverFrameTransport.cs deleted file mode 100644 index 275e0a0bd..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Transports/ForeverFrameTransport.cs +++ /dev/null @@ -1,188 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.IO; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Hosting; -using Microsoft.AspNet.SignalR.Infrastructure; - -namespace Microsoft.AspNet.SignalR.Transports -{ - [SuppressMessage("Microsoft.Design", "CA1001:TypesThatOwnDisposableFieldsShouldBeDisposable", Justification = "Disposable fields are disposed from a different method")] - public class ForeverFrameTransport : ForeverTransport - { - private const string _initPrefix = "" + - "" + - "" + - "SignalR Forever Frame Transport Stream\r\n" + - "" + - "\r\n"; - - private HTMLTextWriter _htmlOutputWriter; - - public ForeverFrameTransport(HostContext context, IDependencyResolver resolver) - : base(context, resolver) - { - } - - /// - /// Pointed to the HTMLOutputWriter to wrap output stream with an HTML friendly one - /// - public override TextWriter OutputWriter - { - get - { - return HTMLOutputWriter; - } - } - - private HTMLTextWriter HTMLOutputWriter - { - get - { - if (_htmlOutputWriter == null) - { - _htmlOutputWriter = new HTMLTextWriter(Context.Response); - _htmlOutputWriter.NewLine = "\n"; - } - - return _htmlOutputWriter; - } - } - - public override Task KeepAlive() - { - if (InitializeTcs == null || !InitializeTcs.Task.IsCompleted) - { - return TaskAsyncHelper.Empty; - } - - // Ensure delegate continues to use the C# Compiler static delegate caching optimization. - return EnqueueOperation(state => PerformKeepAlive(state), this); - } - - public override Task Send(PersistentResponse response) - { - OnSendingResponse(response); - - var context = new ForeverFrameTransportContext(this, response); - - // Ensure delegate continues to use the C# Compiler static delegate caching optimization. - return EnqueueOperation(s => PerformSend(s), context); - } - - protected internal override Task InitializeResponse(ITransportConnection connection) - { - uint frameId; - string rawFrameId = Context.Request.QueryString["frameId"]; - if (String.IsNullOrWhiteSpace(rawFrameId) || !UInt32.TryParse(rawFrameId, NumberStyles.None, CultureInfo.InvariantCulture, out frameId)) - { - // Invalid frameId passed in - throw new InvalidOperationException(Resources.Error_InvalidForeverFrameId); - } - - string initScript = _initPrefix + - frameId.ToString(CultureInfo.InvariantCulture) + - _initSuffix; - - var context = new ForeverFrameTransportContext(this, initScript); - - // Ensure delegate continues to use the C# Compiler static delegate caching optimization. - return base.InitializeResponse(connection).Then(s => Initialize(s), context); - } - - private static Task Initialize(object state) - { - var context = (ForeverFrameTransportContext)state; - - var initContext = new ForeverFrameTransportContext(context.Transport, context.State); - - // Ensure delegate continues to use the C# Compiler static delegate caching optimization. - return WriteInit(initContext); - } - - private static Task WriteInit(ForeverFrameTransportContext context) - { - context.Transport.Context.Response.ContentType = "text/html; charset=UTF-8"; - - context.Transport.HTMLOutputWriter.WriteRaw((string)context.State); - context.Transport.HTMLOutputWriter.Flush(); - - return context.Transport.Context.Response.Flush(); - } - - private static Task PerformSend(object state) - { - var context = (ForeverFrameTransportContext)state; - - context.Transport.HTMLOutputWriter.WriteRaw("\r\n"); - context.Transport.HTMLOutputWriter.Flush(); - - return context.Transport.Context.Response.Flush(); - } - - private static Task PerformKeepAlive(object state) - { - var transport = (ForeverFrameTransport)state; - - transport.HTMLOutputWriter.WriteRaw(""); - transport.HTMLOutputWriter.WriteLine(); - transport.HTMLOutputWriter.WriteLine(); - transport.HTMLOutputWriter.Flush(); - - return transport.Context.Response.Flush(); - } - - private class ForeverFrameTransportContext - { - public ForeverFrameTransport Transport; - public object State; - - public ForeverFrameTransportContext(ForeverFrameTransport transport, object state) - { - Transport = transport; - State = state; - } - } - - private class HTMLTextWriter : BufferTextWriter - { - public HTMLTextWriter(IResponse response) - : base(response) - { - } - - public void WriteRaw(string value) - { - base.Write(value); - } - - public override void Write(string value) - { - base.Write(JavascriptEncode(value)); - } - - public override void WriteLine(string value) - { - base.WriteLine(JavascriptEncode(value)); - } - - private static string JavascriptEncode(string input) - { - return input.Replace("<", "\\u003c").Replace(">", "\\u003e"); - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Transports/ForeverTransport.cs b/src/Microsoft.AspNet.SignalR.Core/Transports/ForeverTransport.cs deleted file mode 100644 index 94b464cf4..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Transports/ForeverTransport.cs +++ /dev/null @@ -1,405 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Hosting; -using Microsoft.AspNet.SignalR.Infrastructure; -using Microsoft.AspNet.SignalR.Json; -using Microsoft.AspNet.SignalR.Tracing; - -namespace Microsoft.AspNet.SignalR.Transports -{ - [SuppressMessage("Microsoft.Design", "CA1001:TypesThatOwnDisposableFieldsShouldBeDisposable", Justification = "The disposer is an optimization")] - public abstract class ForeverTransport : TransportDisconnectBase, ITransport - { - private readonly IPerformanceCounterManager _counters; - private IJsonSerializer _jsonSerializer; - private string _lastMessageId; - - private const int MaxMessages = 10; - - protected ForeverTransport(HostContext context, IDependencyResolver resolver) - : this(context, - resolver.Resolve(), - resolver.Resolve(), - resolver.Resolve(), - resolver.Resolve()) - { - } - - protected ForeverTransport(HostContext context, - IJsonSerializer jsonSerializer, - ITransportHeartbeat heartbeat, - IPerformanceCounterManager performanceCounterWriter, - ITraceManager traceManager) - : base(context, heartbeat, performanceCounterWriter, traceManager) - { - _jsonSerializer = jsonSerializer; - _counters = performanceCounterWriter; - } - - protected string LastMessageId - { - get - { - if (_lastMessageId == null) - { - _lastMessageId = Context.Request.QueryString["messageId"]; - } - - return _lastMessageId; - } - } - - protected IJsonSerializer JsonSerializer - { - get { return _jsonSerializer; } - } - - internal TaskCompletionSource InitializeTcs { get; set; } - - protected virtual void OnSending(string payload) - { - Heartbeat.MarkConnection(this); - } - - protected virtual void OnSendingResponse(PersistentResponse response) - { - Heartbeat.MarkConnection(this); - } - - public Func Received { get; set; } - - public Func TransportConnected { get; set; } - - public Func Connected { get; set; } - - public Func Reconnected { get; set; } - - // Unit testing hooks - internal Action AfterReceive; - internal Action BeforeCancellationTokenCallbackRegistered; - internal Action BeforeReceive; - internal Action AfterRequestEnd; - - protected override void InitializePersistentState() - { - // PersistentConnection.OnConnected must complete before we can write to the output stream, - // so clients don't indicate the connection has started too early. - InitializeTcs = new TaskCompletionSource(); - WriteQueue = new TaskQueue(InitializeTcs.Task); - - base.InitializePersistentState(); - } - - protected Task ProcessRequestCore(ITransportConnection connection) - { - Connection = connection; - - if (Context.Request.Url.LocalPath.EndsWith("/send", StringComparison.OrdinalIgnoreCase)) - { - return ProcessSendRequest(); - } - else if (IsAbortRequest) - { - return Connection.Abort(ConnectionId); - } - else - { - InitializePersistentState(); - - return ProcessReceiveRequest(connection); - } - } - - public virtual Task ProcessRequest(ITransportConnection connection) - { - return ProcessRequestCore(connection); - } - - public abstract Task Send(PersistentResponse response); - - public virtual Task Send(object value) - { - var context = new ForeverTransportContext(this, value); - - return EnqueueOperation(state => PerformSend(state), context); - } - - protected internal virtual Task InitializeResponse(ITransportConnection connection) - { - return TaskAsyncHelper.Empty; - } - - protected internal override Task EnqueueOperation(Func writeAsync, object state) - { - Task task = base.EnqueueOperation(writeAsync, state); - - // If PersistentConnection.OnConnected has not completed (as indicated by InitializeTcs), - // the queue will be blocked to prevent clients from prematurely indicating the connection has - // started, but we must keep receive loop running to continue processing commands and to - // prevent deadlocks caused by waiting on ACKs. - if (InitializeTcs == null || InitializeTcs.Task.IsCompleted) - { - return task; - } - - return TaskAsyncHelper.Empty; - } - - private Task ProcessSendRequest() - { - string data = Context.Request.Form["data"]; - - if (Received != null) - { - return Received(data); - } - - return TaskAsyncHelper.Empty; - } - - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Exceptions are flowed to the caller.")] - protected Task ProcessReceiveRequest(ITransportConnection connection) - { - Func initialize = null; - - bool newConnection = Heartbeat.AddConnection(this); - - if (IsConnectRequest) - { - if (newConnection) - { - initialize = Connected; - - _counters.ConnectionsConnected.Increment(); - } - } - else - { - initialize = Reconnected; - } - - var series = new Func[] - { - state => ((Func)state).Invoke(), - state => ((Func)state).Invoke() - }; - - var states = new object[] { TransportConnected ?? _emptyTaskFunc, - initialize ?? _emptyTaskFunc }; - - Func fullInit = () => TaskAsyncHelper.Series(series, states); - - return ProcessMessages(connection, fullInit); - } - - [SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Justification = "The object is disposed otherwise")] - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Exceptions are flowed to the caller.")] - private Task ProcessMessages(ITransportConnection connection, Func initialize) - { - var disposer = new Disposer(); - - if (BeforeCancellationTokenCallbackRegistered != null) - { - BeforeCancellationTokenCallbackRegistered(); - } - - var cancelContext = new ForeverTransportContext(this, disposer); - - // Ensure delegate continues to use the C# Compiler static delegate caching optimization. - IDisposable registration = ConnectionEndToken.SafeRegister(state => Cancel(state), cancelContext); - - var lifetime = new RequestLifetime(this, _requestLifeTime); - var messageContext = new MessageContext(this, lifetime, registration); - - if (BeforeReceive != null) - { - BeforeReceive(); - } - - try - { - // Ensure we enqueue the response initialization before any messages are received - EnqueueOperation(state => InitializeResponse((ITransportConnection)state), connection) - .Catch((ex, state) => OnError(ex, state), messageContext); - - // Ensure delegate continues to use the C# Compiler static delegate caching optimization. - IDisposable subscription = connection.Receive(LastMessageId, - (response, state) => OnMessageReceived(response, state), - MaxMessages, - messageContext); - - - disposer.Set(subscription); - - if (AfterReceive != null) - { - AfterReceive(); - } - - // Ensure delegate continues to use the C# Compiler static delegate caching optimization. - initialize().Then(tcs => tcs.TrySetResult(null), InitializeTcs) - .Catch((ex, state) => OnError(ex, state), messageContext); - } - catch (OperationCanceledException ex) - { - InitializeTcs.TrySetCanceled(); - - lifetime.Complete(ex); - } - catch (Exception ex) - { - InitializeTcs.TrySetCanceled(); - - lifetime.Complete(ex); - } - - return _requestLifeTime.Task; - } - - private static void Cancel(object state) - { - var context = (ForeverTransportContext)state; - - context.Transport.Trace.TraceEvent(TraceEventType.Verbose, 0, "Cancel(" + context.Transport.ConnectionId + ")"); - - ((IDisposable)context.State).Dispose(); - } - - private static Task OnMessageReceived(PersistentResponse response, object state) - { - var context = (MessageContext)state; - - response.Reconnect = context.Transport.HostShutdownToken.IsCancellationRequested; - - // If we're telling the client to disconnect then clean up the instantiated connection. - if (response.Disconnect) - { - // Send the response before removing any connection data - return context.Transport.Send(response).Then(c => OnDisconnectMessage(c), context) - .Then(() => TaskAsyncHelper.False); - } - else if (context.Transport.IsTimedOut || response.Aborted) - { - context.Registration.Dispose(); - - if (response.Aborted) - { - // If this was a clean disconnect raise the event. - return context.Transport.Abort() - .Then(() => TaskAsyncHelper.False); - } - } - - if (response.Terminal) - { - // End the request on the terminal response - context.Lifetime.Complete(); - - return TaskAsyncHelper.False; - } - - // Ensure delegate continues to use the C# Compiler static delegate caching optimization. - return context.Transport.Send(response) - .Then(() => TaskAsyncHelper.True); - } - - private static void OnDisconnectMessage(MessageContext context) - { - context.Transport.ApplyState(TransportConnectionStates.DisconnectMessageReceived); - - context.Registration.Dispose(); - - // Remove connection without triggering disconnect - context.Transport.Heartbeat.RemoveConnection(context.Transport); - } - - private static Task PerformSend(object state) - { - var context = (ForeverTransportContext)state; - - if (!context.Transport.IsAlive) - { - return TaskAsyncHelper.Empty; - } - - context.Transport.Context.Response.ContentType = JsonUtility.JsonMimeType; - - context.Transport.JsonSerializer.Serialize(context.State, context.Transport.OutputWriter); - context.Transport.OutputWriter.Flush(); - - return context.Transport.Context.Response.End(); - } - - private static void OnError(AggregateException ex, object state) - { - var context = (MessageContext)state; - - context.Transport.IncrementErrors(); - - // Cancel any pending writes in the queue - context.Transport.InitializeTcs.TrySetCanceled(); - - // Complete the http request - context.Lifetime.Complete(ex); - } - - private class ForeverTransportContext - { - public object State; - public ForeverTransport Transport; - - public ForeverTransportContext(ForeverTransport foreverTransport, object state) - { - State = state; - Transport = foreverTransport; - } - } - - private class MessageContext - { - public ForeverTransport Transport; - public RequestLifetime Lifetime; - public IDisposable Registration; - - public MessageContext(ForeverTransport transport, RequestLifetime lifetime, IDisposable registration) - { - Registration = registration; - Lifetime = lifetime; - Transport = transport; - } - } - - private class RequestLifetime - { - private readonly HttpRequestLifeTime _lifetime; - private readonly ForeverTransport _transport; - - public RequestLifetime(ForeverTransport transport, HttpRequestLifeTime lifetime) - { - _lifetime = lifetime; - _transport = transport; - } - - public void Complete() - { - Complete(error: null); - } - - public void Complete(Exception error) - { - _lifetime.Complete(error); - - _transport.Dispose(); - - if (_transport.AfterRequestEnd != null) - { - _transport.AfterRequestEnd(error); - } - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Transports/HttpRequestLifeTime.cs b/src/Microsoft.AspNet.SignalR.Core/Transports/HttpRequestLifeTime.cs deleted file mode 100644 index 1354dcb2c..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Transports/HttpRequestLifeTime.cs +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Diagnostics; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Infrastructure; - -namespace Microsoft.AspNet.SignalR.Transports -{ - internal class HttpRequestLifeTime - { - private readonly TaskCompletionSource _lifetimeTcs = new TaskCompletionSource(); - private readonly TransportDisconnectBase _transport; - private readonly TaskQueue _writeQueue; - private readonly TraceSource _trace; - private readonly string _connectionId; - - public HttpRequestLifeTime(TransportDisconnectBase transport, TaskQueue writeQueue, TraceSource trace, string connectionId) - { - _transport = transport; - _trace = trace; - _connectionId = connectionId; - _writeQueue = writeQueue; - } - - public Task Task - { - get - { - return _lifetimeTcs.Task; - } - } - - public void Complete() - { - Complete(error: null); - } - - public void Complete(Exception error) - { - _trace.TraceEvent(TraceEventType.Verbose, 0, "DrainWrites(" + _connectionId + ")"); - - var context = new LifetimeContext(_transport, _lifetimeTcs, error); - - _transport.ApplyState(TransportConnectionStates.QueueDrained); - - // Drain the task queue for pending write operations so we don't end the request and then try to write - // to a corrupted request object. - _writeQueue.Drain().Catch().Finally(state => - { - // Ensure delegate continues to use the C# Compiler static delegate caching optimization. - ((LifetimeContext)state).Complete(); - }, - context); - - if (error != null) - { - _trace.TraceEvent(TraceEventType.Error, 0, "CompleteRequest (" + _connectionId + ") failed: " + error.GetBaseException()); - } - else - { - _trace.TraceInformation("CompleteRequest (" + _connectionId + ")"); - } - } - - private class LifetimeContext - { - private readonly TaskCompletionSource _lifetimeTcs; - private readonly Exception _error; - private readonly TransportDisconnectBase _transport; - - public LifetimeContext(TransportDisconnectBase transport, TaskCompletionSource lifeTimetcs, Exception error) - { - _transport = transport; - _lifetimeTcs = lifeTimetcs; - _error = error; - } - - public void Complete() - { - _transport.ApplyState(TransportConnectionStates.HttpRequestEnded); - - if (_error != null) - { - _lifetimeTcs.TrySetUnwrappedException(_error); - } - else - { - _lifetimeTcs.TrySetResult(null); - } - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Transports/ITrackingConnection.cs b/src/Microsoft.AspNet.SignalR.Core/Transports/ITrackingConnection.cs deleted file mode 100644 index 452e88def..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Transports/ITrackingConnection.cs +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Diagnostics.CodeAnalysis; -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.AspNet.SignalR.Transports -{ - /// - /// Represents a connection that can be tracked by an . - /// - public interface ITrackingConnection : IDisposable - { - /// - /// Gets the id of the connection. - /// - string ConnectionId { get; } - - /// - /// Gets a cancellation token that represents the connection's lifetime. - /// - CancellationToken CancellationToken { get; } - - /// - /// Gets a value that represents if the connection is alive. - /// - bool IsAlive { get; } - - /// - /// Gets a value that represents if the connection is timed out. - /// - bool IsTimedOut { get; } - - /// - /// Gets a value that represents if the connection supprots keep alive. - /// - bool SupportsKeepAlive { get; } - - /// - /// Gets a value indicating the amount of time to wait after the connection dies before firing the disconnecting the connection. - /// - TimeSpan DisconnectThreshold { get; } - - /// - /// Gets the uri of the connection. - /// - Uri Url { get; } - - /// - /// Applies a new state to the connection. - /// - void ApplyState(TransportConnectionStates states); - - /// - /// Causes the connection to disconnect. - /// - Task Disconnect(); - - /// - /// Causes the connection to timeout. - /// - void Timeout(); - - /// - /// Sends a keep alive ping over the connection. - /// - Task KeepAlive(); - - /// - /// Kills the connection. - /// - [SuppressMessage("Microsoft.Naming", "CA1716:IdentifiersShouldNotMatchKeywords", MessageId = "End", Justification = "Ends the connction thus the name is appropriate.")] - void End(); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Transports/ITransport.cs b/src/Microsoft.AspNet.SignalR.Core/Transports/ITransport.cs deleted file mode 100644 index f9544306c..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Transports/ITransport.cs +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Threading.Tasks; - -namespace Microsoft.AspNet.SignalR.Transports -{ - /// - /// Represents a transport that communicates - /// - public interface ITransport - { - /// - /// Gets or sets a callback that is invoked when the transport receives data. - /// - Func Received { get; set; } - - /// - /// Gets or sets a callback that is invoked when the initial connection connects to the transport. - /// - Func Connected { get; set; } - - /// - /// Gets or sets a callback that is invoked when the transport connects. - /// - Func TransportConnected { get; set; } - - /// - /// Gets or sets a callback that is invoked when the transport reconnects. - /// - Func Reconnected { get; set; } - - /// - /// Gets or sets a callback that is invoked when the transport disconnects. - /// - Func Disconnected { get; set; } - - /// - /// Gets or sets the connection id for the transport. - /// - string ConnectionId { get; set; } - - /// - /// Processes the specified for this transport. - /// - /// The to process. - /// A that completes when the transport has finished processing the connection. - Task ProcessRequest(ITransportConnection connection); - - /// - /// Sends data over the transport. - /// - /// The value to be sent. - /// A that completes when the send is complete. - Task Send(object value); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Transports/ITransportConnection.cs b/src/Microsoft.AspNet.SignalR.Core/Transports/ITransportConnection.cs deleted file mode 100644 index 50465c2f8..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Transports/ITransportConnection.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Threading.Tasks; - -namespace Microsoft.AspNet.SignalR.Transports -{ - public interface ITransportConnection - { - IDisposable Receive(string messageId, Func> callback, int maxMessages, object state); - - Task Send(ConnectionMessage message); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Transports/ITransportHeartBeat.cs b/src/Microsoft.AspNet.SignalR.Core/Transports/ITransportHeartBeat.cs deleted file mode 100644 index 17a24e825..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Transports/ITransportHeartBeat.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; - -namespace Microsoft.AspNet.SignalR.Transports -{ - /// - /// Manages tracking the state of connections. - /// - public interface ITransportHeartbeat - { - /// - /// Adds a new connection to the list of tracked connections. - /// - /// The connection to be added. - bool AddConnection(ITrackingConnection connection); - - /// - /// Marks an existing connection as active. - /// - /// The connection to mark. - void MarkConnection(ITrackingConnection connection); - - /// - /// Removes a connection from the list of tracked connections. - /// - /// The connection to remove. - void RemoveConnection(ITrackingConnection connection); - - /// - /// Gets a list of connections being tracked. - /// - /// A list of connections. - [SuppressMessage("Microsoft.Design", "CA1024:UsePropertiesWhereAppropriate", Justification = "This might be expensive.")] - IList GetConnections(); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Transports/ITransportManager.cs b/src/Microsoft.AspNet.SignalR.Core/Transports/ITransportManager.cs deleted file mode 100644 index 630cb4639..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Transports/ITransportManager.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using Microsoft.AspNet.SignalR.Hosting; -namespace Microsoft.AspNet.SignalR.Transports -{ - /// - /// Manages the transports for connections. - /// - public interface ITransportManager - { - /// - /// Gets the specified transport for the specified . - /// - /// The for the current request. - /// The for the specified . - ITransport GetTransport(HostContext hostContext); - - /// - /// Determines whether the specified transport is supported. - /// - /// The name of the transport to test. - /// True if the transport is supported, otherwise False. - bool SupportsTransport(string transportName); - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Transports/LongPollingTransport.cs b/src/Microsoft.AspNet.SignalR.Core/Transports/LongPollingTransport.cs deleted file mode 100644 index a8fd15952..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Transports/LongPollingTransport.cs +++ /dev/null @@ -1,400 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Hosting; -using Microsoft.AspNet.SignalR.Infrastructure; -using Microsoft.AspNet.SignalR.Json; -using Microsoft.AspNet.SignalR.Tracing; - -namespace Microsoft.AspNet.SignalR.Transports -{ - public class LongPollingTransport : TransportDisconnectBase, ITransport - { - private readonly IJsonSerializer _jsonSerializer; - private readonly IPerformanceCounterManager _counters; - - // This should be ok to do since long polling request never hang around too long - // so we won't bloat memory - private const int MaxMessages = 5000; - - public LongPollingTransport(HostContext context, IDependencyResolver resolver) - : this(context, - resolver.Resolve(), - resolver.Resolve(), - resolver.Resolve(), - resolver.Resolve()) - { - - } - - public LongPollingTransport(HostContext context, - IJsonSerializer jsonSerializer, - ITransportHeartbeat heartbeat, - IPerformanceCounterManager performanceCounterManager, - ITraceManager traceManager) - : base(context, heartbeat, performanceCounterManager, traceManager) - { - _jsonSerializer = jsonSerializer; - _counters = performanceCounterManager; - } - - /// - /// The number of milliseconds to tell the browser to wait before restablishing a - /// long poll connection after data is sent from the server. Defaults to 0. - /// - [SuppressMessage("Microsoft.Naming", "CA1720:IdentifiersShouldNotContainTypeNames", MessageId = "long", Justification = "Longpolling is a well known term")] - public static long LongPollDelay - { - get; - set; - } - - public override TimeSpan DisconnectThreshold - { - get { return TimeSpan.FromMilliseconds(LongPollDelay); } - } - - public override bool IsConnectRequest - { - get - { - return Context.Request.Url.LocalPath.EndsWith("/connect", StringComparison.OrdinalIgnoreCase); - } - } - - private bool IsReconnectRequest - { - get - { - return Context.Request.Url.LocalPath.EndsWith("/reconnect", StringComparison.OrdinalIgnoreCase); - } - } - - private bool IsJsonp - { - get - { - return !String.IsNullOrEmpty(JsonpCallback); - } - } - - private bool IsSendRequest - { - get - { - return Context.Request.Url.LocalPath.EndsWith("/send", StringComparison.OrdinalIgnoreCase); - } - } - - private string MessageId - { - get - { - return Context.Request.QueryString["messageId"]; - } - } - - private string JsonpCallback - { - get - { - return Context.Request.QueryString["callback"]; - } - } - - public override bool SupportsKeepAlive - { - get - { - return false; - } - } - - public Func Received { get; set; } - - public Func TransportConnected { get; set; } - - public Func Connected { get; set; } - - public Func Reconnected { get; set; } - - public Task ProcessRequest(ITransportConnection connection) - { - Connection = connection; - - if (IsSendRequest) - { - return ProcessSendRequest(); - } - else if (IsAbortRequest) - { - return Connection.Abort(ConnectionId); - } - else - { - InitializePersistentState(); - - return ProcessReceiveRequest(connection); - } - } - - public Task Send(PersistentResponse response) - { - Heartbeat.MarkConnection(this); - - AddTransportData(response); - - return Send((object)response); - } - - public Task Send(object value) - { - var context = new LongPollingTransportContext(this, value); - - return EnqueueOperation(state => PerformSend(state), context); - } - - private Task ProcessSendRequest() - { - string data = Context.Request.Form["data"] ?? Context.Request.QueryString["data"]; - - if (Received != null) - { - return Received(data); - } - - return TaskAsyncHelper.Empty; - } - - private Task ProcessReceiveRequest(ITransportConnection connection) - { - Func initialize = null; - - bool newConnection = Heartbeat.AddConnection(this); - - if (IsConnectRequest) - { - if (newConnection) - { - initialize = Connected; - - _counters.ConnectionsConnected.Increment(); - } - } - else if (IsReconnectRequest) - { - initialize = Reconnected; - } - - var series = new Func[] - { - state => ((Func)state).Invoke(), - state => ((Func)state).Invoke() - }; - - var states = new object[] { TransportConnected ?? _emptyTaskFunc, - initialize ?? _emptyTaskFunc }; - - Func fullInit = () => TaskAsyncHelper.Series(series, states); - - return ProcessMessages(connection, fullInit); - } - - [SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Justification = "The subscription is disposed in the callback")] - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "The exception is captured in a task")] - private Task ProcessMessages(ITransportConnection connection, Func initialize) - { - var disposer = new Disposer(); - - var cancelContext = new LongPollingTransportContext(this, disposer); - - // Ensure delegate continues to use the C# Compiler static delegate caching optimization. - IDisposable registration = ConnectionEndToken.SafeRegister(state => Cancel(state), cancelContext); - - var lifeTime = new RequestLifetime(this, _requestLifeTime, registration); - var messageContext = new MessageContext(this, lifeTime); - - try - { - // Ensure delegate continues to use the C# Compiler static delegate caching optimization. - IDisposable subscription = connection.Receive(MessageId, - (response, state) => OnMessageReceived(response, state), - MaxMessages, - messageContext); - - // Set the disposable - disposer.Set(subscription); - - // Ensure delegate continues to use the C# Compiler static delegate caching optimization. - initialize().Catch((ex, state) => OnError(ex, state), messageContext); - } - catch (Exception ex) - { - lifeTime.Complete(ex); - } - - return _requestLifeTime.Task; - } - - private static void Cancel(object state) - { - var context = (LongPollingTransportContext)state; - - context.Transport.Trace.TraceEvent(TraceEventType.Verbose, 0, "Cancel(" + context.Transport.ConnectionId + ")"); - - ((IDisposable)context.State).Dispose(); - } - - private static Task OnMessageReceived(PersistentResponse response, object state) - { - var context = (MessageContext)state; - - response.Reconnect = context.Transport.HostShutdownToken.IsCancellationRequested; - - Task task = TaskAsyncHelper.Empty; - - if (response.Aborted) - { - // If this was a clean disconnect then raise the event - task = context.Transport.Abort(); - } - - if (response.Terminal) - { - // If the response wasn't sent, send it before ending the request - if (!context.ResponseSent) - { - // Ensure delegate continues to use the C# Compiler static delegate caching optimization. - return task.Then((ctx, resp) => ctx.Transport.Send(resp), context, response) - .Then(() => - { - context.Lifetime.Complete(); - - return TaskAsyncHelper.False; - }); - } - - // Ensure delegate continues to use the C# Compiler static delegate caching optimization. - return task.Then(() => - { - context.Lifetime.Complete(); - - return TaskAsyncHelper.False; - }); - } - - // Mark the response as sent - context.ResponseSent = true; - - // Send the response and return false - // Ensure delegate continues to use the C# Compiler static delegate caching optimization. - return task.Then((ctx, resp) => ctx.Transport.Send(resp), context, response) - .Then(() => TaskAsyncHelper.False); - } - - private static Task PerformSend(object state) - { - var context = (LongPollingTransportContext)state; - - if (!context.Transport.IsAlive) - { - return TaskAsyncHelper.Empty; - } - - context.Transport.Context.Response.ContentType = context.Transport.IsJsonp ? JsonUtility.JavaScriptMimeType : JsonUtility.JsonMimeType; - - if (context.Transport.IsJsonp) - { - context.Transport.OutputWriter.Write(context.Transport.JsonpCallback); - context.Transport.OutputWriter.Write("("); - } - - context.Transport._jsonSerializer.Serialize(context.State, context.Transport.OutputWriter); - - if (context.Transport.IsJsonp) - { - context.Transport.OutputWriter.Write(");"); - } - - context.Transport.OutputWriter.Flush(); - - return context.Transport.Context.Response.End(); - } - - private static void OnError(AggregateException ex, object state) - { - var context = (MessageContext)state; - - context.Transport.IncrementErrors(); - - context.Lifetime.Complete(ex); - } - - private static void AddTransportData(PersistentResponse response) - { - if (LongPollDelay > 0) - { - response.LongPollDelay = LongPollDelay; - } - } - - private class LongPollingTransportContext - { - public object State; - public LongPollingTransport Transport; - - public LongPollingTransportContext(LongPollingTransport transport, object state) - { - State = state; - Transport = transport; - } - } - - private class MessageContext - { - public LongPollingTransport Transport; - public RequestLifetime Lifetime; - public bool ResponseSent; - - public MessageContext(LongPollingTransport longPollingTransport, RequestLifetime requestLifetime) - { - Transport = longPollingTransport; - Lifetime = requestLifetime; - } - } - - private class RequestLifetime - { - private readonly HttpRequestLifeTime _requestLifeTime; - private readonly LongPollingTransport _transport; - private readonly IDisposable _registration; - - public RequestLifetime(LongPollingTransport transport, HttpRequestLifeTime requestLifeTime, IDisposable registration) - { - _transport = transport; - _registration = registration; - _requestLifeTime = requestLifeTime; - } - - public void Complete() - { - Complete(exception: null); - } - - public void Complete(Exception exception) - { - // End the request - _requestLifeTime.Complete(exception); - - // Dispose of the cancellation token subscription - _registration.Dispose(); - - // Dispose any state on the transport - _transport.Dispose(); - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Transports/PersistentResponse.cs b/src/Microsoft.AspNet.SignalR.Core/Transports/PersistentResponse.cs deleted file mode 100644 index ced8d90a3..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Transports/PersistentResponse.cs +++ /dev/null @@ -1,181 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using Microsoft.AspNet.SignalR.Infrastructure; -using Microsoft.AspNet.SignalR.Json; -using Microsoft.AspNet.SignalR.Messaging; -using Newtonsoft.Json; - -namespace Microsoft.AspNet.SignalR.Transports -{ - /// - /// Represents a response to a connection. - /// - public sealed class PersistentResponse : IJsonWritable - { - private readonly Func _exclude; - private readonly Action _writeCursor; - - public PersistentResponse() - : this(message => false, writer => { }) - { - - } - - /// - /// Creates a new instance of . - /// - /// A filter that determines whether messages should be written to the client. - /// The cursor writer. - public PersistentResponse(Func exclude, Action writeCursor) - { - _exclude = exclude; - _writeCursor = writeCursor; - } - - /// - /// The list of messages to be sent to the receiving connection. - /// - [SuppressMessage("Microsoft.Design", "CA1006:DoNotNestGenericTypesInMemberSignatures", Justification = "This is an optimization and this type is only used for serialization.")] - [SuppressMessage("Microsoft.Usage", "CA2227:CollectionPropertiesShouldBeReadOnly", Justification = "This type is only used for serialization")] - public IList> Messages { get; set; } - - public bool Terminal { get; set; } - - /// - /// The total count of the messages sent the receiving connection. - /// - public int TotalCount { get; set; } - - /// - /// True if the connection receives a disconnect command. - /// - public bool Disconnect { get; set; } - - /// - /// True if the connection was forcibly closed. - /// - public bool Aborted { get; set; } - - /// - /// True if the client should try reconnecting. - /// - // This is set when the host is shutting down. - public bool Reconnect { get; set; } - - /// - /// Signed token representing the list of groups. Updates on change. - /// - public string GroupsToken { get; set; } - - /// - /// The time the long polling client should wait before reestablishing a connection if no data is received. - /// - public long? LongPollDelay { get; set; } - - /// - /// Serializes only the necessary components of the to JSON - /// using Json.NET's JsonTextWriter to improve performance. - /// - /// The that receives the JSON serialization. - void IJsonWritable.WriteJson(TextWriter writer) - { - if (writer == null) - { - throw new ArgumentNullException("writer"); - } - - var jsonWriter = new JsonTextWriter(writer); - jsonWriter.WriteStartObject(); - - // REVIEW: Is this 100% correct? - writer.Write('"'); - writer.Write("C"); - writer.Write('"'); - writer.Write(':'); - writer.Write('"'); - _writeCursor(writer); - writer.Write('"'); - writer.Write(','); - - if (Disconnect) - { - jsonWriter.WritePropertyName("D"); - jsonWriter.WriteValue(1); - } - - if (Reconnect) - { - jsonWriter.WritePropertyName("T"); - jsonWriter.WriteValue(1); - } - - if (GroupsToken != null) - { - jsonWriter.WritePropertyName("G"); - jsonWriter.WriteValue(GroupsToken); - } - - if (LongPollDelay.HasValue) - { - jsonWriter.WritePropertyName("L"); - jsonWriter.WriteValue(LongPollDelay.Value); - } - - jsonWriter.WritePropertyName("M"); - jsonWriter.WriteStartArray(); - - WriteMessages(writer, jsonWriter); - - jsonWriter.WriteEndArray(); - jsonWriter.WriteEndObject(); - } - - private void WriteMessages(TextWriter writer, JsonTextWriter jsonWriter) - { - if (Messages == null) - { - return; - } - - // If the writer is a binary writer then write to the underlying writer directly - var binaryWriter = writer as IBinaryWriter; - - bool first = true; - - for (int i = 0; i < Messages.Count; i++) - { - ArraySegment segment = Messages[i]; - for (int j = segment.Offset; j < segment.Offset + segment.Count; j++) - { - Message message = segment.Array[j]; - - if (!message.IsCommand && !_exclude(message)) - { - if (binaryWriter != null) - { - if (!first) - { - // We need to write the array separator manually - writer.Write(','); - } - - // If we can write binary then just write it - binaryWriter.Write(message.Value); - - first = false; - } - else - { - // Write the raw JSON value - jsonWriter.WriteRawValue(message.GetString()); - } - } - } - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Transports/ServerSentEventsTransport.cs b/src/Microsoft.AspNet.SignalR.Core/Transports/ServerSentEventsTransport.cs deleted file mode 100644 index ef22be398..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Transports/ServerSentEventsTransport.cs +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Hosting; - -namespace Microsoft.AspNet.SignalR.Transports -{ - public class ServerSentEventsTransport : ForeverTransport - { - public ServerSentEventsTransport(HostContext context, IDependencyResolver resolver) - : base(context, resolver) - { - } - - public override Task KeepAlive() - { - if (InitializeTcs == null || !InitializeTcs.Task.IsCompleted) - { - return TaskAsyncHelper.Empty; - } - - // Ensure delegate continues to use the C# Compiler static delegate caching optimization. - return EnqueueOperation(state => PerformKeepAlive(state), this); - } - - public override Task Send(PersistentResponse response) - { - OnSendingResponse(response); - - var context = new SendContext(this, response); - - // Ensure delegate continues to use the C# Compiler static delegate caching optimization. - return EnqueueOperation(state => PerformSend(state), context); - } - - protected internal override Task InitializeResponse(ITransportConnection connection) - { - // Ensure delegate continues to use the C# Compiler static delegate caching optimization. - return base.InitializeResponse(connection) - .Then(s => WriteInit(s), this); - } - - private static Task PerformKeepAlive(object state) - { - var transport = (ServerSentEventsTransport)state; - - transport.OutputWriter.Write("data: {}"); - transport.OutputWriter.WriteLine(); - transport.OutputWriter.WriteLine(); - transport.OutputWriter.Flush(); - - return transport.Context.Response.Flush(); - } - - private static Task PerformSend(object state) - { - var context = (SendContext)state; - - context.Transport.OutputWriter.Write("data: "); - context.Transport.JsonSerializer.Serialize(context.State, context.Transport.OutputWriter); - context.Transport.OutputWriter.WriteLine(); - context.Transport.OutputWriter.WriteLine(); - context.Transport.OutputWriter.Flush(); - - return context.Transport.Context.Response.Flush(); - } - - private static Task WriteInit(ServerSentEventsTransport transport) - { - transport.Context.Response.ContentType = "text/event-stream"; - - // "data: initialized\n\n" - transport.OutputWriter.Write("data: initialized"); - transport.OutputWriter.WriteLine(); - transport.OutputWriter.WriteLine(); - transport.OutputWriter.Flush(); - - return transport.Context.Response.Flush(); - } - - private class SendContext - { - public ServerSentEventsTransport Transport; - public object State; - - public SendContext(ServerSentEventsTransport transport, object state) - { - Transport = transport; - State = state; - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Transports/TransportConnectionExtensions.cs b/src/Microsoft.AspNet.SignalR.Core/Transports/TransportConnectionExtensions.cs deleted file mode 100644 index 1f12d533f..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Transports/TransportConnectionExtensions.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Infrastructure; -using Microsoft.AspNet.SignalR.Messaging; - -namespace Microsoft.AspNet.SignalR.Transports -{ - internal static class TransportConnectionExtensions - { - internal static Task Close(this ITransportConnection connection, string connectionId) - { - return SendCommand(connection, connectionId, CommandType.Disconnect); - } - - internal static Task Abort(this ITransportConnection connection, string connectionId) - { - return SendCommand(connection, connectionId, CommandType.Abort); - } - - private static Task SendCommand(ITransportConnection connection, string connectionId, CommandType commandType) - { - var command = new Command - { - CommandType = commandType - }; - - var message = new ConnectionMessage(PrefixHelper.GetConnectionId(connectionId), - command); - - return connection.Send(message); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Transports/TransportConnectionStates.cs b/src/Microsoft.AspNet.SignalR.Core/Transports/TransportConnectionStates.cs deleted file mode 100644 index 6ffddb4e5..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Transports/TransportConnectionStates.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; - -namespace Microsoft.AspNet.SignalR.Transports -{ - [Flags] - public enum TransportConnectionStates - { - None = 0, - Added = 1, - Removed = 2, - Replaced = 4, - QueueDrained = 8, - HttpRequestEnded = 16, - Disconnected = 32, - Aborted = 64, - DisconnectMessageReceived = 128, - Disposed = 65536, - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Transports/TransportDisconnectBase.cs b/src/Microsoft.AspNet.SignalR.Core/Transports/TransportDisconnectBase.cs deleted file mode 100644 index 45f96e0bd..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Transports/TransportDisconnectBase.cs +++ /dev/null @@ -1,344 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Hosting; -using Microsoft.AspNet.SignalR.Infrastructure; -using Microsoft.AspNet.SignalR.Tracing; - -namespace Microsoft.AspNet.SignalR.Transports -{ - [SuppressMessage("Microsoft.Design", "CA1001:TypesThatOwnDisposableFieldsShouldBeDisposable", Justification = "Disposable fields are disposed from a different method")] - public abstract class TransportDisconnectBase : ITrackingConnection - { - private readonly HostContext _context; - private readonly ITransportHeartbeat _heartbeat; - private TextWriter _outputWriter; - - private TraceSource _trace; - - private int _timedOut; - private readonly IPerformanceCounterManager _counters; - private int _ended; - private TransportConnectionStates _state; - - internal static readonly Func _emptyTaskFunc = () => TaskAsyncHelper.Empty; - - // Token that represents the end of the connection based on a combination of - // conditions (timeout, disconnect, connection forcibly ended, host shutdown) - private CancellationToken _connectionEndToken; - private SafeCancellationTokenSource _connectionEndTokenSource; - - // Token that represents the host shutting down - private CancellationToken _hostShutdownToken; - private IDisposable _hostRegistration; - private IDisposable _connectionEndRegistration; - - internal HttpRequestLifeTime _requestLifeTime; - - protected TransportDisconnectBase(HostContext context, ITransportHeartbeat heartbeat, IPerformanceCounterManager performanceCounterManager, ITraceManager traceManager) - { - if (context == null) - { - throw new ArgumentNullException("context"); - } - - if (heartbeat == null) - { - throw new ArgumentNullException("heartbeat"); - } - - if (performanceCounterManager == null) - { - throw new ArgumentNullException("performanceCounterManager"); - } - - if (traceManager == null) - { - throw new ArgumentNullException("traceManager"); - } - - _context = context; - _heartbeat = heartbeat; - _counters = performanceCounterManager; - - // Queue to protect against overlapping writes to the underlying response stream - WriteQueue = new TaskQueue(); - - _trace = traceManager["SignalR.Transports." + GetType().Name]; - } - - protected TraceSource Trace - { - get - { - return _trace; - } - } - - public string ConnectionId - { - get; - set; - } - - public virtual TextWriter OutputWriter - { - get - { - if (_outputWriter == null) - { - _outputWriter = CreateResponseWriter(); - _outputWriter.NewLine = "\n"; - } - - return _outputWriter; - } - } - - internal TaskQueue WriteQueue - { - get; - set; - } - - public Func Disconnected { get; set; } - - public virtual CancellationToken CancellationToken - { - get { return _context.Response.CancellationToken; } - } - - public virtual bool IsAlive - { - get - { - // If the CTS is tripped or the request has ended then the connection isn't alive - return !(CancellationToken.IsCancellationRequested || (_requestLifeTime != null && _requestLifeTime.Task.IsCompleted)); - } - } - - protected CancellationToken ConnectionEndToken - { - get - { - return _connectionEndToken; - } - } - - protected CancellationToken HostShutdownToken - { - get - { - return _hostShutdownToken; - } - } - - public bool IsTimedOut - { - get - { - return _timedOut == 1; - } - } - - public virtual bool SupportsKeepAlive - { - get - { - return true; - } - } - - public virtual TimeSpan DisconnectThreshold - { - get { return TimeSpan.FromSeconds(5); } - } - - public virtual bool IsConnectRequest - { - get - { - return Context.Request.Url.LocalPath.EndsWith("/connect", StringComparison.OrdinalIgnoreCase); - } - } - - protected bool IsAbortRequest - { - get - { - return Context.Request.Url.LocalPath.EndsWith("/abort", StringComparison.OrdinalIgnoreCase); - } - } - - protected ITransportConnection Connection { get; set; } - - protected HostContext Context - { - get { return _context; } - } - - protected ITransportHeartbeat Heartbeat - { - get { return _heartbeat; } - } - - public Uri Url - { - get { return _context.Request.Url; } - } - - protected virtual TextWriter CreateResponseWriter() - { - return new BinaryTextWriter(Context.Response); - } - - protected void IncrementErrors() - { - _counters.ErrorsTransportTotal.Increment(); - _counters.ErrorsTransportPerSec.Increment(); - _counters.ErrorsAllTotal.Increment(); - _counters.ErrorsAllPerSec.Increment(); - } - - public Task Disconnect() - { - // Ensure delegate continues to use the C# Compiler static delegate caching optimization. - return Abort(clean: false).Then(transport => transport.Connection.Close(transport.ConnectionId), this); - } - - public Task Abort() - { - return Abort(clean: true); - } - - public Task Abort(bool clean) - { - if (clean) - { - ApplyState(TransportConnectionStates.Aborted); - } - else - { - ApplyState(TransportConnectionStates.Disconnected); - } - - Trace.TraceInformation("Abort(" + ConnectionId + ")"); - - // When a connection is aborted (graceful disconnect) we send a command to it - // telling to to disconnect. At that moment, we raise the disconnect event and - // remove this connection from the heartbeat so we don't end up raising it for the same connection. - Heartbeat.RemoveConnection(this); - - // End the connection - End(); - - var disconnected = Disconnected ?? _emptyTaskFunc; - - // Ensure delegate continues to use the C# Compiler static delegate caching optimization. - return disconnected().Catch((ex, state) => OnDisconnectError(ex, state), Trace) - .Then(counters => counters.ConnectionsDisconnected.Increment(), _counters); - } - - public void ApplyState(TransportConnectionStates states) - { - _state |= states; - } - - public void Timeout() - { - if (Interlocked.Exchange(ref _timedOut, 1) == 0) - { - Trace.TraceInformation("Timeout(" + ConnectionId + ")"); - - End(); - } - } - - public virtual Task KeepAlive() - { - return TaskAsyncHelper.Empty; - } - - public void End() - { - if (Interlocked.Exchange(ref _ended, 1) == 0) - { - Trace.TraceInformation("End(" + ConnectionId + ")"); - - if (_connectionEndTokenSource != null) - { - _connectionEndTokenSource.Cancel(); - } - } - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - _connectionEndTokenSource.Dispose(); - _connectionEndRegistration.Dispose(); - _hostRegistration.Dispose(); - - ApplyState(TransportConnectionStates.Disposed); - } - } - - protected virtual internal Task EnqueueOperation(Func writeAsync) - { - return EnqueueOperation(state => ((Func)state).Invoke(), writeAsync); - } - - protected virtual internal Task EnqueueOperation(Func writeAsync, object state) - { - if (!IsAlive) - { - return TaskAsyncHelper.Empty; - } - - // Only enqueue new writes if the connection is alive - return WriteQueue.Enqueue(writeAsync, state); - } - - protected virtual void InitializePersistentState() - { - _hostShutdownToken = _context.HostShutdownToken(); - - _requestLifeTime = new HttpRequestLifeTime(this, WriteQueue, Trace, ConnectionId); - - // Create a token that represents the end of this connection's life - _connectionEndTokenSource = new SafeCancellationTokenSource(); - _connectionEndToken = _connectionEndTokenSource.Token; - - // Handle the shutdown token's callback so we can end our token if it trips - _hostRegistration = _hostShutdownToken.SafeRegister(state => - { - ((SafeCancellationTokenSource)state).Cancel(); - }, - _connectionEndTokenSource); - - // When the connection ends release the request - _connectionEndRegistration = CancellationToken.SafeRegister(state => - { - ((HttpRequestLifeTime)state).Complete(); - }, - _requestLifeTime); - } - - private static void OnDisconnectError(AggregateException ex, object state) - { - ((TraceSource)state).TraceEvent(TraceEventType.Error, 0, "Failed to raise disconnect: " + ex.GetBaseException()); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Transports/TransportHeartBeat.cs b/src/Microsoft.AspNet.SignalR.Core/Transports/TransportHeartBeat.cs deleted file mode 100644 index f44bb8216..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Transports/TransportHeartBeat.cs +++ /dev/null @@ -1,384 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Threading; -using Microsoft.AspNet.SignalR.Configuration; -using Microsoft.AspNet.SignalR.Infrastructure; -using Microsoft.AspNet.SignalR.Tracing; - -namespace Microsoft.AspNet.SignalR.Transports -{ - /// - /// Default implementation of . - /// - public class TransportHeartbeat : ITransportHeartbeat, IDisposable - { - private readonly ConcurrentDictionary _connections = new ConcurrentDictionary(); - private readonly Timer _timer; - private readonly IConfigurationManager _configurationManager; - private readonly IServerCommandHandler _serverCommandHandler; - private readonly TraceSource _trace; - private readonly string _serverId; - private readonly IPerformanceCounterManager _counters; - private readonly object _counterLock = new object(); - - private int _running; - private ulong _heartbeatCount; - - /// - /// Initializes and instance of the class. - /// - /// The . - public TransportHeartbeat(IDependencyResolver resolver) - { - _configurationManager = resolver.Resolve(); - _serverCommandHandler = resolver.Resolve(); - _serverId = resolver.Resolve().ServerId; - _counters = resolver.Resolve(); - - var traceManager = resolver.Resolve(); - _trace = traceManager["SignalR.Transports.TransportHeartBeat"]; - - _serverCommandHandler.Command = ProcessServerCommand; - - // REVIEW: When to dispose the timer? - _timer = new Timer(Beat, - null, - _configurationManager.HeartbeatInterval(), - _configurationManager.HeartbeatInterval()); - } - - private TraceSource Trace - { - get - { - return _trace; - } - } - - private void ProcessServerCommand(ServerCommand command) - { - switch (command.ServerCommandType) - { - case ServerCommandType.RemoveConnection: - // Only remove connections if this command didn't originate from the owner - if (!command.IsFromSelf(_serverId)) - { - var connectionId = (string)command.Value; - - // Remove the connection - ConnectionMetadata metadata; - if (_connections.TryGetValue(connectionId, out metadata)) - { - metadata.Connection.End(); - - RemoveConnection(metadata.Connection); - } - } - break; - default: - break; - } - } - - /// - /// Adds a new connection to the list of tracked connections. - /// - /// The connection to be added. - public bool AddConnection(ITrackingConnection connection) - { - if (connection == null) - { - throw new ArgumentNullException("connection"); - } - - var newMetadata = new ConnectionMetadata(connection); - bool isNewConnection = true; - - _connections.AddOrUpdate(connection.ConnectionId, newMetadata, (key, old) => - { - Trace.TraceEvent(TraceEventType.Verbose, 0, "Connection {0} exists. Closing previous connection.", old.Connection.ConnectionId); - // Kick out the older connection. This should only happen when - // a previous connection attempt fails on the client side (e.g. transport fallback). - - old.Connection.ApplyState(TransportConnectionStates.Replaced); - - // Don't bother disposing the registration here since the token source - // gets disposed after the request has ended - old.Connection.End(); - - // If we have old metadata this isn't a new connection - isNewConnection = false; - - return newMetadata; - }); - - if (isNewConnection) - { - Trace.TraceInformation("Connection {0} is New.", connection.ConnectionId); - } - - lock (_counterLock) - { - _counters.ConnectionsCurrent.RawValue = _connections.Count; - } - - // Set the initial connection time - newMetadata.Initial = DateTime.UtcNow; - - newMetadata.Connection.ApplyState(TransportConnectionStates.Added); - - return isNewConnection; - } - - /// - /// Removes a connection from the list of tracked connections. - /// - /// The connection to remove. - public void RemoveConnection(ITrackingConnection connection) - { - if (connection == null) - { - throw new ArgumentNullException("connection"); - } - - // Remove the connection and associated metadata - ConnectionMetadata metadata; - if (_connections.TryRemove(connection.ConnectionId, out metadata)) - { - lock (_counterLock) - { - _counters.ConnectionsCurrent.RawValue = _connections.Count; - } - - connection.ApplyState(TransportConnectionStates.Removed); - - Trace.TraceInformation("Removing connection {0}", connection.ConnectionId); - } - } - - /// - /// Marks an existing connection as active. - /// - /// The connection to mark. - public void MarkConnection(ITrackingConnection connection) - { - if (connection == null) - { - throw new ArgumentNullException("connection"); - } - - // Do nothing if the connection isn't alive - if (!connection.IsAlive) - { - return; - } - - ConnectionMetadata metadata; - if (_connections.TryGetValue(connection.ConnectionId, out metadata)) - { - metadata.LastMarked = DateTime.UtcNow; - } - } - - public IList GetConnections() - { - return _connections.Values.Select(metadata => metadata.Connection).ToList(); - } - - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "We're tracing exceptions and don't want to crash the process.")] - private void Beat(object state) - { - if (Interlocked.Exchange(ref _running, 1) == 1) - { - Trace.TraceEvent(TraceEventType.Verbose, 0, "Timer handler took longer than current interval"); - return; - } - - lock (_counterLock) - { - _counters.ConnectionsCurrent.RawValue = _connections.Count; - } - - try - { - _heartbeatCount++; - - foreach (var metadata in _connections.Values) - { - if (metadata.Connection.IsAlive) - { - CheckTimeoutAndKeepAlive(metadata); - } - else - { - Trace.TraceEvent(TraceEventType.Verbose, 0, metadata.Connection.ConnectionId + " is dead"); - - // Check if we need to disconnect this connection - CheckDisconnect(metadata); - } - } - } - catch (Exception ex) - { - Trace.TraceEvent(TraceEventType.Error, 0, "SignalR error during transport heart beat on background thread: {0}", ex); - } - finally - { - Interlocked.Exchange(ref _running, 0); - } - } - - private void CheckTimeoutAndKeepAlive(ConnectionMetadata metadata) - { - if (RaiseTimeout(metadata)) - { - // If we're past the expiration time then just timeout the connection - metadata.Connection.Timeout(); - } - else - { - // The connection is still alive so we need to keep it alive with a server side "ping". - // This is for scenarios where networking hardware (proxies, loadbalancers) get in the way - // of us handling timeout's or disconnects gracefully - if (RaiseKeepAlive(metadata)) - { - Trace.TraceEvent(TraceEventType.Verbose, 0, "KeepAlive(" + metadata.Connection.ConnectionId + ")"); - - // Ensure delegate continues to use the C# Compiler static delegate caching optimization. - metadata.Connection.KeepAlive().Catch((ex, state) => OnKeepAliveError(ex, state), Trace); - } - - MarkConnection(metadata.Connection); - } - } - - [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "We're tracing exceptions and don't want to crash the process.")] - private void CheckDisconnect(ConnectionMetadata metadata) - { - try - { - if (RaiseDisconnect(metadata)) - { - // Remove the connection from the list - RemoveConnection(metadata.Connection); - - // Fire disconnect on the connection - metadata.Connection.Disconnect(); - } - } - catch (Exception ex) - { - // Swallow exceptions that might happen during disconnect - Trace.TraceEvent(TraceEventType.Error, 0, "Raising Disconnect failed: {0}", ex); - } - } - - private bool RaiseDisconnect(ConnectionMetadata metadata) - { - // The transport is currently dead but it could just be reconnecting - // so we to check it's last active time to see if it's over the disconnect - // threshold - TimeSpan elapsed = DateTime.UtcNow - metadata.LastMarked; - - // The threshold for disconnect is the transport threshold + (potential network issues) - var threshold = metadata.Connection.DisconnectThreshold + _configurationManager.DisconnectTimeout; - - return elapsed >= threshold; - } - - private bool RaiseKeepAlive(ConnectionMetadata metadata) - { - var keepAlive = _configurationManager.KeepAlive; - - // Don't raise keep alive if it's set to 0 or the transport doesn't support - // keep alive - if (keepAlive == null || !metadata.Connection.SupportsKeepAlive) - { - return false; - } - - // Raise keep alive if the keep alive value has passed - return _heartbeatCount % (ulong)ConfigurationExtensions.HeartBeatsPerKeepAlive == 0; - } - - private bool RaiseTimeout(ConnectionMetadata metadata) - { - // The connection already timed out so do nothing - if (metadata.Connection.IsTimedOut) - { - return false; - } - - var keepAlive = _configurationManager.KeepAlive; - // If keep alive is configured and the connection supports keep alive - // don't ever time out - if (keepAlive != null && metadata.Connection.SupportsKeepAlive) - { - return false; - } - - TimeSpan elapsed = DateTime.UtcNow - metadata.Initial; - - // Only raise timeout if we're past the configured connection timeout. - return elapsed >= _configurationManager.ConnectionTimeout; - } - - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - if (_timer != null) - { - _timer.Dispose(); - } - - Trace.TraceInformation("Dispose(). Closing all connections"); - - // Kill all connections - foreach (var pair in _connections) - { - ConnectionMetadata metadata; - if (_connections.TryGetValue(pair.Key, out metadata)) - { - metadata.Connection.End(); - } - } - } - } - - public void Dispose() - { - Dispose(true); - } - - private static void OnKeepAliveError(AggregateException ex, object state) - { - ((TraceSource)state).TraceEvent(TraceEventType.Error, 0, "Failed to send keep alive: " + ex.GetBaseException()); - } - - private class ConnectionMetadata - { - public ConnectionMetadata(ITrackingConnection connection) - { - Connection = connection; - Initial = DateTime.UtcNow; - LastMarked = DateTime.UtcNow; - } - - // The connection instance - public ITrackingConnection Connection { get; set; } - - // The last time the connection had any activity - public DateTime LastMarked { get; set; } - - // The initial connection time of the connection - public DateTime Initial { get; set; } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Transports/TransportManager.cs b/src/Microsoft.AspNet.SignalR.Core/Transports/TransportManager.cs deleted file mode 100644 index a3619dffc..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Transports/TransportManager.cs +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; -using Microsoft.AspNet.SignalR.Hosting; - -namespace Microsoft.AspNet.SignalR.Transports -{ - /// - /// The default implementation. - /// - public class TransportManager : ITransportManager - { - private readonly ConcurrentDictionary> _transports = new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); - - /// - /// Initializes a new instance of class. - /// - /// The default . - [SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Justification = "Those are factory methods")] - public TransportManager(IDependencyResolver resolver) - { - if (resolver == null) - { - throw new ArgumentNullException("resolver"); - } - - Register("foreverFrame", context => new ForeverFrameTransport(context, resolver)); - Register("serverSentEvents", context => new ServerSentEventsTransport(context, resolver)); - Register("longPolling", context => new LongPollingTransport(context, resolver)); - Register("webSockets", context => new WebSocketTransport(context, resolver)); - } - - /// - /// Adds a new transport to the list of supported transports. - /// - /// The specified transport. - /// The factory method for the specified transport. - public void Register(string transportName, Func transportFactory) - { - if (String.IsNullOrEmpty(transportName)) - { - throw new ArgumentNullException("transportName"); - } - - if (transportFactory == null) - { - throw new ArgumentNullException("transportFactory"); - } - - _transports.TryAdd(transportName, transportFactory); - } - - /// - /// Removes a transport from the list of supported transports. - /// - /// The specified transport. - public void Remove(string transportName) - { - if (String.IsNullOrEmpty(transportName)) - { - throw new ArgumentNullException("transportName"); - } - - Func removed; - _transports.TryRemove(transportName, out removed); - } - - /// - /// Gets the specified transport for the specified . - /// - /// The for the current request. - /// The for the specified . - public ITransport GetTransport(HostContext hostContext) - { - if (hostContext == null) - { - throw new ArgumentNullException("hostContext"); - } - - string transportName = hostContext.Request.QueryString["transport"]; - - if (String.IsNullOrEmpty(transportName)) - { - return null; - } - - Func factory; - if (_transports.TryGetValue(transportName, out factory)) - { - return factory(hostContext); - } - - return null; - } - - /// - /// Determines whether the specified transport is supported. - /// - /// The name of the transport to test. - /// True if the transport is supported, otherwise False. - public bool SupportsTransport(string transportName) - { - if (String.IsNullOrEmpty(transportName)) - { - return false; - } - - return _transports.ContainsKey(transportName); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Core/Transports/WebSocketTransport.cs b/src/Microsoft.AspNet.SignalR.Core/Transports/WebSocketTransport.cs deleted file mode 100644 index 8f5db2cec..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/Transports/WebSocketTransport.cs +++ /dev/null @@ -1,175 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Diagnostics; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Hosting; -using Microsoft.AspNet.SignalR.Infrastructure; -using Microsoft.AspNet.SignalR.Json; -using Microsoft.AspNet.SignalR.Tracing; - -namespace Microsoft.AspNet.SignalR.Transports -{ - public class WebSocketTransport : ForeverTransport - { - private readonly HostContext _context; - private IWebSocket _socket; - private bool _isAlive = true; - - private readonly Action _message; - private readonly Action _closed; - private readonly Action _error; - - public WebSocketTransport(HostContext context, - IDependencyResolver resolver) - : this(context, - resolver.Resolve(), - resolver.Resolve(), - resolver.Resolve(), - resolver.Resolve()) - { - } - - public WebSocketTransport(HostContext context, - IJsonSerializer serializer, - ITransportHeartbeat heartbeat, - IPerformanceCounterManager performanceCounterWriter, - ITraceManager traceManager) - : base(context, serializer, heartbeat, performanceCounterWriter, traceManager) - { - _context = context; - _message = OnMessage; - _closed = OnClosed; - _error = OnError; - } - - public override bool IsAlive - { - get - { - return _isAlive; - } - } - - public override CancellationToken CancellationToken - { - get - { - return CancellationToken.None; - } - } - - public override Task KeepAlive() - { - // Ensure delegate continues to use the C# Compiler static delegate caching optimization. - return EnqueueOperation(state => - { - var webSocket = (IWebSocket)state; - return webSocket.Send("{}"); - }, - _socket); - } - - public override Task ProcessRequest(ITransportConnection connection) - { - if (IsAbortRequest) - { - return connection.Abort(ConnectionId); - } - else - { - var webSocketRequest = _context.Request as IWebSocketRequest; - - // Throw if the server implementation doesn't support websockets - if (webSocketRequest == null) - { - throw new InvalidOperationException(Resources.Error_WebSocketsNotSupported); - } - - Connection = connection; - InitializePersistentState(); - - return webSocketRequest.AcceptWebSocketRequest(socket => - { - _socket = socket; - socket.OnClose = _closed; - socket.OnMessage = _message; - socket.OnError = _error; - - return ProcessReceiveRequest(connection); - }, - InitializeTcs.Task); - } - } - - protected override TextWriter CreateResponseWriter() - { - return new BinaryTextWriter(_socket); - } - - public override Task Send(object value) - { - var context = new WebSocketTransportContext(this, value); - - // Ensure delegate continues to use the C# Compiler static delegate caching optimization. - return EnqueueOperation(state => PerformSend(state), context); - } - - public override Task Send(PersistentResponse response) - { - OnSendingResponse(response); - - return Send((object)response); - } - - protected internal override Task InitializeResponse(ITransportConnection connection) - { - return _socket.Send("{}"); - } - - private static Task PerformSend(object state) - { - var context = (WebSocketTransportContext)state; - - context.Transport.JsonSerializer.Serialize(context.State, context.Transport.OutputWriter); - context.Transport.OutputWriter.Flush(); - - return context.Transport._socket.Flush(); - } - - private void OnMessage(string message) - { - if (Received != null) - { - Received(message).Catch(); - } - } - - private void OnClosed() - { - Trace.TraceInformation("CloseSocket({0})", ConnectionId); - - // Require a request to /abort to stop tracking the connection. #2195 - _isAlive = false; - } - - private void OnError(Exception error) - { - Trace.TraceError("OnError({0}, {1})", ConnectionId, error); - } - - private class WebSocketTransportContext - { - public WebSocketTransport Transport; - public object State; - - public WebSocketTransportContext(WebSocketTransport transport, object state) - { - Transport = transport; - State = state; - } - } - } -} \ No newline at end of file diff --git a/src/Microsoft.AspNet.SignalR.Core/app.config b/src/Microsoft.AspNet.SignalR.Core/app.config deleted file mode 100644 index 44298137a..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/app.config +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/src/Microsoft.AspNet.SignalR.Core/packages.config b/src/Microsoft.AspNet.SignalR.Core/packages.config deleted file mode 100644 index 7c276ed86..000000000 --- a/src/Microsoft.AspNet.SignalR.Core/packages.config +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/src/Microsoft.AspNet.SignalR.Owin/Handlers/CallHandler.cs b/src/Microsoft.AspNet.SignalR.Owin/Handlers/CallHandler.cs deleted file mode 100644 index 3dd5c6975..000000000 --- a/src/Microsoft.AspNet.SignalR.Owin/Handlers/CallHandler.cs +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Security.Principal; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Hosting; -using Microsoft.AspNet.SignalR.Owin.Infrastructure; - -namespace Microsoft.AspNet.SignalR.Owin -{ - public class CallHandler - { - private readonly ConnectionConfiguration _configuration; - private readonly PersistentConnection _connection; - - public CallHandler(ConnectionConfiguration configuration, PersistentConnection connection) - { - _configuration = configuration; - _connection = connection; - } - - public Task Invoke(IDictionary environment) - { - var serverRequest = new ServerRequest(environment); - var serverResponse = new ServerResponse(environment); - var hostContext = new HostContext(serverRequest, serverResponse); - - string origin = serverRequest.RequestHeaders.GetHeader("Origin"); - - if (_configuration.EnableCrossDomain) - { - // Add CORS response headers support - if (!String.IsNullOrEmpty(origin)) - { - serverResponse.ResponseHeaders.SetHeader("Access-Control-Allow-Origin", origin); - serverResponse.ResponseHeaders.SetHeader("Access-Control-Allow-Credentials", "true"); - } - } - else - { - string callback = serverRequest.QueryString["callback"]; - - // If it's a JSONP request and we're not allowing cross domain requests then block it - // If there's an origin header and it's not a same origin request then block it. - - if (!String.IsNullOrEmpty(callback) || - (!String.IsNullOrEmpty(origin) && !IsSameOrigin(serverRequest.Url, origin))) - { - return EndResponse(environment, 403, Resources.Forbidden_CrossDomainIsDisabled); - } - } - - // Add the nosniff header for all responses to prevent IE from trying to sniff mime type from contents - serverResponse.ResponseHeaders.SetHeader("X-Content-Type-Options", "nosniff"); - - // REVIEW: Performance - hostContext.Items[HostConstants.SupportsWebSockets] = environment.SupportsWebSockets(); - hostContext.Items[HostConstants.ShutdownToken] = environment.GetShutdownToken(); - hostContext.Items[HostConstants.DebugMode] = environment.GetIsDebugEnabled(); - - serverRequest.DisableRequestCompression(); - serverResponse.DisableResponseBuffering(); - - _connection.Initialize(_configuration.Resolver, hostContext); - - if (!_connection.Authorize(serverRequest)) - { - IPrincipal user = hostContext.Request.User; - if (user != null && user.Identity.IsAuthenticated) - { - // If we failed to authorize the request then return a 403 since the request - // can't do anything - return EndResponse(environment, 403, "Forbidden"); - } - else - { - // If we failed to authorize the request and the user is not authenticated - // then return a 401 - return EndResponse(environment, 401, "Unauthorized"); - } - } - else - { - return _connection.ProcessRequest(hostContext); - } - } - - private static Task EndResponse(IDictionary environment, int statusCode, string reason) - { - environment[OwinConstants.ResponseStatusCode] = statusCode; - environment[OwinConstants.ResponseReasonPhrase] = reason; - - return TaskAsyncHelper.Empty; - } - - private static bool IsSameOrigin(Uri requestUri, string origin) - { - Uri originUri; - if (!Uri.TryCreate(origin.Trim(), UriKind.Absolute, out originUri)) - { - return false; - } - - return (requestUri.Scheme == originUri.Scheme) && - (requestUri.Host == originUri.Host) && - (requestUri.Port == originUri.Port); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Owin/Handlers/HubDispatcherHandler.cs b/src/Microsoft.AspNet.SignalR.Owin/Handlers/HubDispatcherHandler.cs deleted file mode 100644 index fcf83e5e3..000000000 --- a/src/Microsoft.AspNet.SignalR.Owin/Handlers/HubDispatcherHandler.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Hubs; -using Microsoft.AspNet.SignalR.Owin.Infrastructure; - -namespace Microsoft.AspNet.SignalR.Owin.Handlers -{ - using AppFunc = Func, Task>; - - public class HubDispatcherHandler - { - private readonly AppFunc _next; - private readonly string _path; - private readonly HubConfiguration _configuration; - - public HubDispatcherHandler(AppFunc next, string path, HubConfiguration configuration) - { - _next = next; - _path = path; - _configuration = configuration; - } - - public Task Invoke(IDictionary environment) - { - var path = environment.Get(OwinConstants.RequestPath); - if (path == null || !PrefixMatcher.IsMatch(_path, path)) - { - return _next(environment); - } - - var dispatcher = new HubDispatcher(_configuration); - - var handler = new CallHandler(_configuration, dispatcher); - return handler.Invoke(environment); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Owin/Handlers/PersistentConnectionHandler.cs b/src/Microsoft.AspNet.SignalR.Owin/Handlers/PersistentConnectionHandler.cs deleted file mode 100644 index 71fb299eb..000000000 --- a/src/Microsoft.AspNet.SignalR.Owin/Handlers/PersistentConnectionHandler.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Hosting; -using Microsoft.AspNet.SignalR.Owin.Infrastructure; - -namespace Microsoft.AspNet.SignalR.Owin.Handlers -{ - using AppFunc = Func, Task>; - - public class PersistentConnectionHandler - { - private readonly AppFunc _next; - private readonly string _path; - private readonly Type _connectionType; - private readonly ConnectionConfiguration _configuration; - - public PersistentConnectionHandler(AppFunc next, string path, Type connectionType, ConnectionConfiguration configuration) - { - _next = next; - _path = path; - _connectionType = connectionType; - _configuration = configuration; - } - - public Task Invoke(IDictionary environment) - { - var path = environment.Get(OwinConstants.RequestPath); - if (path == null || !PrefixMatcher.IsMatch(_path, path)) - { - return _next(environment); - } - - var connectionFactory = new PersistentConnectionFactory(_configuration.Resolver); - var connection = connectionFactory.CreateInstance(_connectionType); - - var handler = new CallHandler(_configuration, connection); - return handler.Invoke(environment); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Owin/Infrastructure/Headers.cs b/src/Microsoft.AspNet.SignalR.Owin/Infrastructure/Headers.cs deleted file mode 100644 index c320568c9..000000000 --- a/src/Microsoft.AspNet.SignalR.Owin/Infrastructure/Headers.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; - -namespace Microsoft.AspNet.SignalR.Owin.Infrastructure -{ - /// - /// Helper methods for creating and consuming CallParameters.Headers and ResultParameters.Headers. - /// - internal static class Headers - { - public static IDictionary SetHeader(this IDictionary headers, - string name, string value) - { - headers[name] = new[] { value }; - return headers; - } - - public static string[] GetHeaders(this IDictionary headers, - string name) - { - string[] value; - return headers != null && headers.TryGetValue(name, out value) ? value : null; - } - - public static string GetHeader(this IDictionary headers, - string name) - { - var values = GetHeaders(headers, name); - if (values == null) - { - return null; - } - - switch (values.Length) - { - case 0: - return String.Empty; - case 1: - return values[0]; - default: - return String.Join(",", values); - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Owin/Infrastructure/OwinConstants.cs b/src/Microsoft.AspNet.SignalR.Owin/Infrastructure/OwinConstants.cs deleted file mode 100644 index e2c246ba1..000000000 --- a/src/Microsoft.AspNet.SignalR.Owin/Infrastructure/OwinConstants.cs +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -namespace Microsoft.AspNet.SignalR.Owin -{ - internal static class OwinConstants - { - public const string Version = "owin.Version"; - - public const string RequestBody = "owin.RequestBody"; - public const string RequestHeaders = "owin.RequestHeaders"; - public const string RequestScheme = "owin.RequestScheme"; - public const string RequestMethod = "owin.RequestMethod"; - public const string RequestPathBase = "owin.RequestPathBase"; - public const string RequestPath = "owin.RequestPath"; - public const string RequestQueryString = "owin.RequestQueryString"; - public const string RequestProtocol = "owin.RequestProtocol"; - - public const string CallCancelled = "owin.CallCancelled"; - - public const string ResponseStatusCode = "owin.ResponseStatusCode"; - public const string ResponseReasonPhrase = "owin.ResponseReasonPhrase"; - public const string ResponseHeaders = "owin.ResponseHeaders"; - public const string ResponseBody = "owin.ResponseBody"; - - public const string TraceOutput = "host.TraceOutput"; - - public const string User = "server.User"; - public const string RemoteIpAddress = "server.RemoteIpAddress"; - public const string RemotePort = "server.RemotePort"; - public const string LocalIpAddress = "server.LocalIpAddress"; - public const string LocalPort = "server.LocalPort"; - - public const string DisableRequestCompression = "systemweb.DisableResponseCompression"; - public const string DisableRequestBuffering = "server.DisableRequestBuffering"; - public const string DisableResponseBuffering = "server.DisableResponseBuffering"; - - public const string ServerCapabilities = "server.Capabilities"; - public const string WebSocketVersion = "websocket.Version"; - public const string WebSocketAccept = "websocket.Accept"; - - public const string HostOnAppDisposing = "host.OnAppDisposing"; - public const string HostAppNameKey = "host.AppName"; - public const string HostAppModeKey = "host.AppMode"; - public const string AppModeDevelopment = "development"; - } -} diff --git a/src/Microsoft.AspNet.SignalR.Owin/Infrastructure/OwinEnvironmentExtensions.cs b/src/Microsoft.AspNet.SignalR.Owin/Infrastructure/OwinEnvironmentExtensions.cs deleted file mode 100644 index 26459c76c..000000000 --- a/src/Microsoft.AspNet.SignalR.Owin/Infrastructure/OwinEnvironmentExtensions.cs +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Threading; - -namespace Microsoft.AspNet.SignalR.Owin -{ - internal static class OwinEnvironmentExtensions - { - internal static T Get(this IDictionary environment, string key) - { - object value; - return environment.TryGetValue(key, out value) ? (T)value : default(T); - } - - internal static CancellationToken GetShutdownToken(this IDictionary env) - { - object value; - return env.TryGetValue(OwinConstants.HostOnAppDisposing, out value) - && value is CancellationToken - ? (CancellationToken)value - : default(CancellationToken); - } - - internal static string GetAppInstanceName(this IDictionary environment) - { - object value; - if (environment.TryGetValue(OwinConstants.HostAppNameKey, out value)) - { - var stringVal = value as string; - - if (!String.IsNullOrEmpty(stringVal)) - { - return stringVal; - } - } - - return null; - } - - internal static bool SupportsWebSockets(this IDictionary environment) - { - object value; - if (environment.TryGetValue(OwinConstants.ServerCapabilities, out value)) - { - var capabilities = value as IDictionary; - if (capabilities != null) - { - return capabilities.ContainsKey(OwinConstants.WebSocketVersion); - } - } - return false; - } - - internal static bool GetIsDebugEnabled(this IDictionary environment) - { - object value; - if (environment.TryGetValue(OwinConstants.HostAppModeKey, out value)) - { - var stringVal = value as string; - return !String.IsNullOrWhiteSpace(stringVal) && - OwinConstants.AppModeDevelopment.Equals(stringVal, StringComparison.OrdinalIgnoreCase); - } - - return false; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Owin/Infrastructure/ParamDictionary.cs b/src/Microsoft.AspNet.SignalR.Owin/Infrastructure/ParamDictionary.cs deleted file mode 100644 index 18b159d4d..000000000 --- a/src/Microsoft.AspNet.SignalR.Owin/Infrastructure/ParamDictionary.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using Microsoft.AspNet.SignalR.Infrastructure; - -namespace Microsoft.AspNet.SignalR.Owin.Infrastructure -{ - [SuppressMessage("Microsoft.Performance", "CA1812:AvoidUninstantiatedInternalClasses", Justification = "It is instantiated in the static Parse method")] - internal sealed class ParamDictionary - { - private static readonly char[] DefaultParamSeparators = new[] { '&', ';' }; - private static readonly char[] ParamKeyValueSeparator = new[] { '=' }; - private static readonly char[] LeadingWhitespaceChars = new[] { ' ' }; - - internal static IEnumerable> ParseToEnumerable(string value, char[] delimiters = null) - { - value = value ?? String.Empty; - delimiters = delimiters ?? DefaultParamSeparators; - - var items = value.Split(delimiters, StringSplitOptions.RemoveEmptyEntries); - - foreach (var item in items) - { - string[] pair = item.Split(ParamKeyValueSeparator, 2, StringSplitOptions.None); - - string pairKey = UrlDecoder.UrlDecode(pair[0]).TrimStart(LeadingWhitespaceChars); - string pairValue = pair.Length < 2 ? String.Empty : UrlDecoder.UrlDecode(pair[1]); - - yield return new KeyValuePair(pairKey, pairValue); - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Owin/Infrastructure/PrefixMatcher.cs b/src/Microsoft.AspNet.SignalR.Owin/Infrastructure/PrefixMatcher.cs deleted file mode 100644 index 73186522a..000000000 --- a/src/Microsoft.AspNet.SignalR.Owin/Infrastructure/PrefixMatcher.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; - -namespace Microsoft.AspNet.SignalR.Owin.Infrastructure -{ - internal static class PrefixMatcher - { - public static bool IsMatch(string pathBase, string path) - { - pathBase = EnsureStartsWithSlash(pathBase); - path = EnsureStartsWithSlash(path); - - var pathLength = path.Length; - var pathBaseLength = pathBase.Length; - - if (pathLength < pathBaseLength) - { - return false; - } - - if (pathLength > pathBaseLength && path[pathBaseLength] != '/') - { - return false; - } - - if (!path.StartsWith(pathBase, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - return true; - } - - private static string EnsureStartsWithSlash(string path) - { - if (path.Length == 0) - { - return path; - } - - if (path[0] == '/') - { - return path; - } - - return '/' + path; - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Owin/Infrastructure/UrlDecoder.cs b/src/Microsoft.AspNet.SignalR.Owin/Infrastructure/UrlDecoder.cs deleted file mode 100644 index 0d813a875..000000000 --- a/src/Microsoft.AspNet.SignalR.Owin/Infrastructure/UrlDecoder.cs +++ /dev/null @@ -1,150 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Text; - -namespace Microsoft.AspNet.SignalR.Infrastructure -{ - // Taken from System.Net.Http.Formatting.Internal.UrlDecoder.cs (http://aspnetwebstack.codeplex.com/) - - /// - /// Helpers for decoding URI query components. - /// - internal static class UrlDecoder - { - // The implementation below is ported from WebUtility for use in .Net 4 - - public static string UrlDecode(string str) - { - if (str == null) - return null; - - return UrlDecodeInternal(str, Encoding.UTF8); - } - - #region UrlDecode implementation - - private static string UrlDecodeInternal(string value, Encoding encoding) - { - if (value == null) - { - return null; - } - - int count = value.Length; - var helper = new DecoderHelper(count, encoding); - - // go through the string's chars collapsing %XX and %uXXXX and - // appending each char as char, with exception of %XX constructs - // that are appended as bytes - - for (int pos = 0; pos < count; pos++) - { - char ch = value[pos]; - - if (ch == '+') - { - ch = ' '; - } - else if (ch == '%' && pos < count - 2) - { - int h1 = HexToInt(value[pos + 1]); - int h2 = HexToInt(value[pos + 2]); - - if (h1 >= 0 && h2 >= 0) - { // valid 2 hex chars - byte b = (byte)((h1 << 4) | h2); - pos += 2; - - // don't add as char - helper.AddByte(b); - continue; - } - } - - if ((ch & 0xFF80) == 0) - helper.AddByte((byte)ch); // 7 bit have to go as bytes because of Unicode - else - helper.AddChar(ch); - } - - return helper.GetString(); - } - - private static int HexToInt(char h) - { - return (h >= '0' && h <= '9') ? h - '0' : - (h >= 'a' && h <= 'f') ? h - 'a' + 10 : - (h >= 'A' && h <= 'F') ? h - 'A' + 10 : - -1; - } - - #endregion - - #region DecoderHelper nested class - - // Internal class to facilitate URL decoding -- keeps char buffer and byte buffer, allows appending of either chars or bytes - private class DecoderHelper - { - private int _bufferSize; - - // Accumulate characters in a special array - private int _numChars; - private char[] _charBuffer; - - // Accumulate bytes for decoding into characters in a special array - private int _numBytes; - private byte[] _byteBuffer; - - // Encoding to convert chars to bytes - private Encoding _encoding; - - private void FlushBytes() - { - if (_numBytes > 0) - { - _numChars += _encoding.GetChars(_byteBuffer, 0, _numBytes, _charBuffer, _numChars); - _numBytes = 0; - } - } - - internal DecoderHelper(int bufferSize, Encoding encoding) - { - _bufferSize = bufferSize; - _encoding = encoding; - - _charBuffer = new char[bufferSize]; - // byte buffer created on demand - } - - internal void AddChar(char ch) - { - if (_numBytes > 0) - FlushBytes(); - - _charBuffer[_numChars++] = ch; - } - - internal void AddByte(byte b) - { - if (_byteBuffer == null) - _byteBuffer = new byte[_bufferSize]; - - _byteBuffer[_numBytes++] = b; - } - - internal String GetString() - { - if (_numBytes > 0) - FlushBytes(); - - if (_numChars > 0) - return new String(_charBuffer, 0, _numChars); - else - return String.Empty; - } - } - - #endregion - } -} diff --git a/src/Microsoft.AspNet.SignalR.Owin/Microsoft.AspNet.SignalR.Owin.csproj b/src/Microsoft.AspNet.SignalR.Owin/Microsoft.AspNet.SignalR.Owin.csproj deleted file mode 100644 index dbf0bb82a..000000000 --- a/src/Microsoft.AspNet.SignalR.Owin/Microsoft.AspNet.SignalR.Owin.csproj +++ /dev/null @@ -1,112 +0,0 @@ - - - - - Debug - x86 - {2B8C6DAD-4D85-41B1-83FD-248D9F347522} - Library - Properties - Microsoft.AspNet.SignalR.Owin - Microsoft.AspNet.SignalR.Owin - v4.0 - 512 - ..\..\ - true - - 12.0.0 - 2.0 - - - true - bin\x86\Debug\ - TRACE;DEBUG - bin\Debug\Microsoft.AspNet.SignalR.Owin.XML - true - 1591 - full - x86 - prompt - C:\Dropbox\Git\NzbDrone\src\Common\Microsoft.AspNet.SignalR.ruleset - 4 - false - - - bin\x86\Release\ - TRACE - bin\Release\Microsoft.AspNet.SignalR.Owin.XML - true - true - 1591 - pdbonly - x86 - prompt - C:\Dropbox\Git\NzbDrone\src\Common\Microsoft.AspNet.SignalR.ruleset - 4 - - - - - - - ..\packages\Owin.1.0\lib\net40\Owin.dll - - - - - Properties\CommonAssemblyInfo.cs - - - Properties\CommonVersionInfo.cs - - - Infrastructure\TaskAsyncHelper.cs - - - - - - - - - - - - - True - True - Resources.resx - - - - - - - - - - {1B9A82C4-BCA1-4834-A33E-226F17BE070B} - Microsoft.AspNet.SignalR.Core - - - - - - - - - ResXFileCodeGenerator - Resources.Designer.cs - Designer - - - - - - diff --git a/src/Microsoft.AspNet.SignalR.Owin/Microsoft.AspNet.SignalR.Owin.csproj.DotSettings b/src/Microsoft.AspNet.SignalR.Owin/Microsoft.AspNet.SignalR.Owin.csproj.DotSettings deleted file mode 100644 index 5b8822215..000000000 --- a/src/Microsoft.AspNet.SignalR.Owin/Microsoft.AspNet.SignalR.Owin.csproj.DotSettings +++ /dev/null @@ -1,2 +0,0 @@ - - DO_NOT_SHOW \ No newline at end of file diff --git a/src/Microsoft.AspNet.SignalR.Owin/OwinExtensions.cs b/src/Microsoft.AspNet.SignalR.Owin/OwinExtensions.cs deleted file mode 100644 index 7ff9c5d8c..000000000 --- a/src/Microsoft.AspNet.SignalR.Owin/OwinExtensions.cs +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Diagnostics.CodeAnalysis; -using System.Threading; -using Microsoft.AspNet.SignalR; -using Microsoft.AspNet.SignalR.Hosting; -using Microsoft.AspNet.SignalR.Owin; -using Microsoft.AspNet.SignalR.Owin.Handlers; - -namespace Owin -{ - [SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "Owin", Justification = "The owin namespace is for consistentcy.")] - public static class OwinExtensions - { - public static IAppBuilder MapHubs(this IAppBuilder builder) - { - return builder.MapHubs(new HubConfiguration()); - } - - public static IAppBuilder MapHubs(this IAppBuilder builder, HubConfiguration configuration) - { - return builder.MapHubs("/signalr", configuration); - } - - public static IAppBuilder MapHubs(this IAppBuilder builder, string path, HubConfiguration configuration) - { - if (configuration == null) - { - throw new ArgumentNullException("configuration"); - } - - return builder.UseType(path, configuration); - } - - [SuppressMessage("Microsoft.Design", "CA1004:GenericMethodsShouldProvideTypeParameter", Justification = "The type parameter is syntactic sugar")] - public static IAppBuilder MapConnection(this IAppBuilder builder, string url) where T : PersistentConnection - { - return builder.MapConnection(url, typeof(T), new ConnectionConfiguration()); - } - - [SuppressMessage("Microsoft.Design", "CA1004:GenericMethodsShouldProvideTypeParameter", Justification = "The type parameter is syntactic sugar")] - public static IAppBuilder MapConnection(this IAppBuilder builder, string url, ConnectionConfiguration configuration) where T : PersistentConnection - { - return builder.MapConnection(url, typeof(T), configuration); - } - - public static IAppBuilder MapConnection(this IAppBuilder builder, string url, Type connectionType, ConnectionConfiguration configuration) - { - if (configuration == null) - { - throw new ArgumentNullException("configuration"); - } - - return builder.UseType(url, connectionType, configuration); - } - - private static IAppBuilder UseType(this IAppBuilder builder, params object[] args) - { - if (args.Length > 0) - { - var configuration = args[args.Length - 1] as ConnectionConfiguration; - - if (configuration == null) - { - throw new ArgumentException(Resources.Error_NoConfiguration); - } - - var resolver = configuration.Resolver; - - if (resolver == null) - { - throw new ArgumentException(Resources.Error_NoDepenendeyResolver); - } - - var env = builder.Properties; - CancellationToken token = env.GetShutdownToken(); - string instanceName = env.GetAppInstanceName(); - - resolver.InitializeHost(instanceName, token); - } - - return builder.Use(typeof(T), args); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Owin/Properties/AssemblyInfo.cs b/src/Microsoft.AspNet.SignalR.Owin/Properties/AssemblyInfo.cs deleted file mode 100644 index c0e6f23c4..000000000 --- a/src/Microsoft.AspNet.SignalR.Owin/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System.Reflection; - -[assembly: AssemblyTitle("Microsoft.AspNet.SignalR.Owin")] -[assembly: AssemblyDescription("Assembly containing default SignalR host.")] diff --git a/src/Microsoft.AspNet.SignalR.Owin/RequestExtensions.cs b/src/Microsoft.AspNet.SignalR.Owin/RequestExtensions.cs deleted file mode 100644 index 03676f853..000000000 --- a/src/Microsoft.AspNet.SignalR.Owin/RequestExtensions.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using Microsoft.AspNet.SignalR.Owin; - -namespace Microsoft.AspNet.SignalR -{ - public static class RequestExtensions - { - public static T GetOwinVariable(this IRequest request, string key) - { - if (request == null) - { - throw new ArgumentNullException("request"); - } - - var env = request.Items.Get>(ServerRequest.OwinEnvironmentKey); - - return env == null ? default(T) : env.Get(key); - } - - private static T Get(this IDictionary values, string key) - { - object value; - return values.TryGetValue(key, out value) ? (T)value : default(T); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Owin/Resources.Designer.cs b/src/Microsoft.AspNet.SignalR.Owin/Resources.Designer.cs deleted file mode 100644 index 2925d4911..000000000 --- a/src/Microsoft.AspNet.SignalR.Owin/Resources.Designer.cs +++ /dev/null @@ -1,96 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// Runtime Version:4.0.30319.18010 -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -namespace Microsoft.AspNet.SignalR.Owin { - /// - /// A strongly-typed resource class, for looking up localized strings, etc. - /// - // This class was auto-generated by the StronglyTypedResourceBuilder - // class via a tool like ResGen or Visual Studio. - // To add or remove a member, edit your .ResX file then rerun ResGen - // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - internal class Resources { - - private static global::System.Resources.ResourceManager resourceMan; - - private static global::System.Globalization.CultureInfo resourceCulture; - - [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] - internal Resources() { - } - - /// - /// Returns the cached ResourceManager instance used by this class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Resources.ResourceManager ResourceManager { - get { - if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.AspNet.SignalR.Owin.Resources", typeof(Resources).Assembly); - resourceMan = temp; - } - return resourceMan; - } - } - - /// - /// Overrides the current thread's CurrentUICulture property for all - /// resource lookups using this strongly typed resource class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Globalization.CultureInfo Culture { - get { - return resourceCulture; - } - set { - resourceCulture = value; - } - } - - /// - /// Looks up a localized string similar to A configuration object must be specified.. - /// - internal static string Error_NoConfiguration { - get { - return ResourceManager.GetString("Error_NoConfiguration", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to A dependency resolver must be specified.. - /// - internal static string Error_NoDepenendeyResolver { - get { - return ResourceManager.GetString("Error_NoDepenendeyResolver", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Not a valid web socket request.. - /// - internal static string Error_NotWebSocketRequest { - get { - return ResourceManager.GetString("Error_NotWebSocketRequest", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Forbidden: SignalR cross domain is disabled.. - /// - internal static string Forbidden_CrossDomainIsDisabled { - get { - return ResourceManager.GetString("Forbidden_CrossDomainIsDisabled", resourceCulture); - } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Owin/Resources.resx b/src/Microsoft.AspNet.SignalR.Owin/Resources.resx deleted file mode 100644 index c77be7797..000000000 --- a/src/Microsoft.AspNet.SignalR.Owin/Resources.resx +++ /dev/null @@ -1,132 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - A configuration object must be specified. - - - A dependency resolver must be specified. - - - Not a valid web socket request. - - - Forbidden: SignalR cross domain is disabled. - - \ No newline at end of file diff --git a/src/Microsoft.AspNet.SignalR.Owin/ServerRequest.Owin.cs b/src/Microsoft.AspNet.SignalR.Owin/ServerRequest.Owin.cs deleted file mode 100644 index 407b7eb98..000000000 --- a/src/Microsoft.AspNet.SignalR.Owin/ServerRequest.Owin.cs +++ /dev/null @@ -1,237 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using Microsoft.AspNet.SignalR.Owin.Infrastructure; - -namespace Microsoft.AspNet.SignalR.Owin -{ - public partial class ServerRequest - { - private readonly IDictionary _environment; - - public static readonly string OwinEnvironmentKey = "owin.environment"; - - public ServerRequest(IDictionary environment) - { - _environment = environment; - - Items = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - { OwinEnvironmentKey , _environment } - }; - } - - private string RequestMethod - { - get { return _environment.Get(OwinConstants.RequestMethod); } - } - - public IDictionary RequestHeaders - { - get { return _environment.Get>(OwinConstants.RequestHeaders); } - } - - private Stream RequestBody - { - get { return _environment.Get(OwinConstants.RequestBody); } - } - - private string RequestScheme - { - get { return _environment.Get(OwinConstants.RequestScheme); } - } - - private string RequestPathBase - { - get { return _environment.Get(OwinConstants.RequestPathBase); } - } - - private string RequestPath - { - get { return _environment.Get(OwinConstants.RequestPath); } - } - - private string RequestQueryString - { - get { return _environment.Get(OwinConstants.RequestQueryString); } - } - - public Action DisableRequestCompression - { - get { return _environment.Get(OwinConstants.DisableRequestCompression) ?? (() => { }); } - } - - private bool TryParseHostHeader(out IPAddress address, out string host, out int port) - { - address = null; - host = null; - port = -1; - - var hostHeader = RequestHeaders.GetHeader("Host"); - if (String.IsNullOrWhiteSpace(hostHeader)) - { - return false; - } - - // IPv6 (http://www.ietf.org/rfc/rfc2732.txt) - if (hostHeader.StartsWith("[", StringComparison.Ordinal)) - { - var portIndex = hostHeader.LastIndexOf("]:", StringComparison.Ordinal); - if (portIndex != -1 && Int32.TryParse(hostHeader.Substring(portIndex + 2), out port)) - { - if (IPAddress.TryParse(hostHeader.Substring(1, portIndex - 1), out address)) - { - host = null; - return true; - } - host = hostHeader.Substring(0, portIndex + 1); - return true; - } - if (hostHeader.EndsWith("]", StringComparison.Ordinal)) - { - if (IPAddress.TryParse(hostHeader.Substring(1, hostHeader.Length - 2), out address)) - { - host = null; - port = -1; - return true; - } - } - } - else - { - // IPAddresses - if (IPAddress.TryParse(hostHeader, out address)) - { - host = null; - port = -1; - return true; - } - - var portIndex = hostHeader.LastIndexOf(':'); - if (portIndex != -1 && Int32.TryParse(hostHeader.Substring(portIndex + 1), out port)) - { - host = hostHeader.Substring(0, portIndex); - return true; - } - } - - // Plain - host = hostHeader; - return true; - } - - private string RequestHost - { - get - { - IPAddress address; - string host; - int port; - if (TryParseHostHeader(out address, out host, out port)) - { - return host ?? address.ToString(); - } - return _environment.Get(OwinConstants.LocalIpAddress) ?? IPAddress.Loopback.ToString(); - } - } - - private int RequestPort - { - get - { - IPAddress address; - string host; - int port; - if (TryParseHostHeader(out address, out host, out port)) - { - if (port == -1) - { - return DefaultPort; - } - return port; - } - - var portString = _environment.Get(OwinConstants.LocalPort); - if (Int32.TryParse(portString, out port) && port != 0) - { - return port; - } - - return DefaultPort; - } - } - - private int DefaultPort - { - get - { - return String.Equals(RequestScheme, "https", StringComparison.OrdinalIgnoreCase) ? 443 : 80; - } - } - - private string ContentType - { - get - { - return RequestHeaders.GetHeader("Content-Type"); - } - } - - private string MediaType - { - get - { - var contentType = ContentType; - if (contentType == null) - { - return null; - } - - var delimiterPos = contentType.IndexOfAny(CommaSemicolon); - return delimiterPos < 0 ? contentType : contentType.Substring(0, delimiterPos); - } - } - - private bool HasFormData - { - get - { - var mediaType = MediaType; - return (RequestMethod == "POST" && String.IsNullOrEmpty(mediaType)) - || mediaType == "application/x-www-form-urlencoded" - || mediaType == "multipart/form-data"; - } - } - - private bool HasParseableData - { - get - { - var mediaType = MediaType; - return mediaType == "application/x-www-form-urlencoded" - || mediaType == "multipart/form-data"; - } - } - - private IEnumerable> ReadForm() - { - if (!HasFormData && !HasParseableData) - { - return Enumerable.Empty>(); - } - - var body = RequestBody; - if (body.CanSeek) - { - body.Seek(0, SeekOrigin.Begin); - } - - var text = new StreamReader(body).ReadToEnd(); - return ParamDictionary.ParseToEnumerable(text); - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Owin/ServerRequest.cs b/src/Microsoft.AspNet.SignalR.Owin/ServerRequest.cs deleted file mode 100644 index 152580f8d..000000000 --- a/src/Microsoft.AspNet.SignalR.Owin/ServerRequest.cs +++ /dev/null @@ -1,155 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Collections.Specialized; -using System.Security.Principal; -using System.Threading; -using Microsoft.AspNet.SignalR.Owin.Infrastructure; - -namespace Microsoft.AspNet.SignalR.Owin -{ - public partial class ServerRequest : -#if NET45 - IWebSocketRequest -#else - IRequest -#endif - { - private static readonly char[] CommaSemicolon = new[] { ',', ';' }; - - private Uri _url; - private NameValueCollection _queryString; - private NameValueCollection _headers; - private NameValueCollection _form; - private bool _formInitialized; - private object _formLock = new object(); - private IDictionary _cookies; - - public Uri Url - { - get - { - return LazyInitializer.EnsureInitialized( - ref _url, () => - { - var uriBuilder = new UriBuilder(RequestScheme, RequestHost, RequestPort, RequestPathBase + RequestPath); - if (!String.IsNullOrEmpty(RequestQueryString)) - { - uriBuilder.Query = RequestQueryString; - } - return uriBuilder.Uri; - }); - } - } - - - public NameValueCollection QueryString - { - get - { - return LazyInitializer.EnsureInitialized( - ref _queryString, () => - { - var collection = new NameValueCollection(); - foreach (var kv in ParamDictionary.ParseToEnumerable(RequestQueryString)) - { - collection.Add(kv.Key, kv.Value); - } - return collection; - }); - } - } - - public NameValueCollection Headers - { - get - { - return LazyInitializer.EnsureInitialized( - ref _headers, () => - { - var collection = new NameValueCollection(); - foreach (var kv in RequestHeaders) - { - if (kv.Value != null) - { - for (var index = 0; index != kv.Value.Length; ++index) - { - collection.Add(kv.Key, kv.Value[index]); - } - } - } - return collection; - }); - } - } - - public NameValueCollection Form - { - get - { - return LazyInitializer.EnsureInitialized( - ref _form, ref _formInitialized, ref _formLock, () => - { - var collection = new NameValueCollection(); - foreach (var kv in ReadForm()) - { - collection.Add(kv.Key, kv.Value); - } - return collection; - }); - } - } - - - public IDictionary Cookies - { - get - { - return LazyInitializer.EnsureInitialized( - ref _cookies, () => - { - var cookies = new Dictionary(StringComparer.OrdinalIgnoreCase); - var text = RequestHeaders.GetHeader("Cookie"); - foreach (var kv in ParamDictionary.ParseToEnumerable(text, CommaSemicolon)) - { - if (!cookies.ContainsKey(kv.Key)) - { - cookies.Add(kv.Key, new Cookie(kv.Key, kv.Value)); - } - } - return cookies; - }); - } - } - - public IPrincipal User - { - get { return _environment.Get(OwinConstants.User); } - } - - - public IDictionary Items - { - get; - private set; - } - -#if NET45 - public Task AcceptWebSocketRequest(Func callback, Task initTask) - { - var accept = _environment.Get, WebSocketFunc>>(OwinConstants.WebSocketAccept); - if (accept == null) - { - var response = new ServerResponse(_environment); - response.StatusCode = 400; - return response.End(Resources.Error_NotWebSocketRequest); - } - - var handler = new OwinWebSocketHandler(callback, initTask); - accept(null, handler.ProcessRequestAsync); - return TaskAsyncHelper.Empty; - } -#endif - } -} diff --git a/src/Microsoft.AspNet.SignalR.Owin/ServerResponse.cs b/src/Microsoft.AspNet.SignalR.Owin/ServerResponse.cs deleted file mode 100644 index c268202b6..000000000 --- a/src/Microsoft.AspNet.SignalR.Owin/ServerResponse.cs +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNet.SignalR.Hosting; -using Microsoft.AspNet.SignalR.Owin.Infrastructure; - -namespace Microsoft.AspNet.SignalR.Owin -{ - public partial class ServerResponse : IResponse - { - private readonly CancellationToken _callCancelled; - private readonly IDictionary _environment; - private Stream _responseBody; - - public ServerResponse(IDictionary environment) - { - _environment = environment; - _callCancelled = _environment.Get(OwinConstants.CallCancelled); - } - - public CancellationToken CancellationToken - { - get { return _callCancelled; } - } - - public int StatusCode - { - get - { - return _environment.Get(OwinConstants.ResponseStatusCode); - } - set - { - _environment[OwinConstants.ResponseStatusCode] = value; - } - } - - public string ContentType - { - get { return ResponseHeaders.GetHeader("Content-Type"); } - set { ResponseHeaders.SetHeader("Content-Type", value); } - } - - public void Write(ArraySegment data) - { - ResponseBody.Write(data.Array, data.Offset, data.Count); - } - - public Task Flush() - { -#if NET45 - return ResponseBody.FlushAsync(); -#else - return TaskAsyncHelper.FromMethod(() => ResponseBody.Flush()); -#endif - } - - public Task End() - { - return TaskAsyncHelper.Empty; - } - - public IDictionary ResponseHeaders - { - get { return _environment.Get>(OwinConstants.ResponseHeaders); } - } - - public Stream ResponseBody - { - get - { - if (_responseBody == null) - { - _responseBody = _environment.Get(OwinConstants.ResponseBody); - } - - return _responseBody; - } - } - - public Action DisableResponseBuffering - { - get { return _environment.Get(OwinConstants.DisableResponseBuffering) ?? (() => { }); } - } - } -} diff --git a/src/Microsoft.AspNet.SignalR.Owin/app.config b/src/Microsoft.AspNet.SignalR.Owin/app.config deleted file mode 100644 index 44298137a..000000000 --- a/src/Microsoft.AspNet.SignalR.Owin/app.config +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/src/Microsoft.AspNet.SignalR.Owin/packages.config b/src/Microsoft.AspNet.SignalR.Owin/packages.config deleted file mode 100644 index ac23ae5cb..000000000 --- a/src/Microsoft.AspNet.SignalR.Owin/packages.config +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/src/MonoTorrent/MonoTorrent.csproj b/src/MonoTorrent/MonoTorrent.csproj index dd8fd6907..8fb49e77b 100644 --- a/src/MonoTorrent/MonoTorrent.csproj +++ b/src/MonoTorrent/MonoTorrent.csproj @@ -1,117 +1,8 @@ - - + - Debug - x86 - Local - 9.0.21022 - 2.0 - {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8} - Library - MonoTorrent - MonoTorrent - - - JScript - Grid - IE50 - false - - - MonoTorrent - - - 3.5 - - - v4.0 - - 512 - publish\ - true - Disk - false - Foreground - 7 - Days - false - false - true - 0 - 1.0.0.%2a - false - false - true - ..\ + net462 + x86 + + 9.0.21022 - - x86 - true - full - false - ..\..\_output\ - DEBUG;TRACE - prompt - 4 - - - x86 - pdbonly - true - ..\..\_output\ - TRACE - prompt - 4 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - False - .NET Framework 2.0 %28x86%29 - true - - - False - .NET Framework 3.0 %28x86%29 - false - - - False - .NET Framework 3.5 - false - - - - - - - - \ No newline at end of file + diff --git a/src/NzbDrone.Api.Test/ClientSchemaTests/SchemaBuilderFixture.cs b/src/NzbDrone.Api.Test/ClientSchemaTests/SchemaBuilderFixture.cs index 385a9b989..8d3f040ea 100644 --- a/src/NzbDrone.Api.Test/ClientSchemaTests/SchemaBuilderFixture.cs +++ b/src/NzbDrone.Api.Test/ClientSchemaTests/SchemaBuilderFixture.cs @@ -1,6 +1,6 @@ -using FluentAssertions; +using FluentAssertions; using NUnit.Framework; -using NzbDrone.Api.ClientSchema; +using Lidarr.Http.ClientSchema; using NzbDrone.Core.Annotations; using NzbDrone.Test.Common; @@ -21,20 +21,38 @@ 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 { [FieldDefinition(0, Label = "First Name", HelpText = "Your First Name")] @@ -45,4 +63,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/Lidarr.Api.Test.csproj b/src/NzbDrone.Api.Test/Lidarr.Api.Test.csproj new file mode 100644 index 000000000..d68c5beab --- /dev/null +++ b/src/NzbDrone.Api.Test/Lidarr.Api.Test.csproj @@ -0,0 +1,14 @@ + + + net462 + x86 + + + + + + + + + + diff --git a/src/NzbDrone.Api.Test/NzbDrone.Api.Test.csproj b/src/NzbDrone.Api.Test/NzbDrone.Api.Test.csproj deleted file mode 100644 index 8bfcbd758..000000000 --- a/src/NzbDrone.Api.Test/NzbDrone.Api.Test.csproj +++ /dev/null @@ -1,111 +0,0 @@ - - - - - Debug - x86 - {D18A5DEB-5102-4775-A1AF-B75DAAA8907B} - Library - Properties - NzbDrone.Api.Test - NzbDrone.Api.Test - v4.0 - 512 - ..\ - true - 12.0.0 - 2.0 - - - true - bin\x86\Debug\ - DEBUG;TRACE - full - x86 - prompt - MinimumRecommendedRules.ruleset - 4 - false - - - bin\x86\Release\ - TRACE - true - pdbonly - x86 - prompt - MinimumRecommendedRules.ruleset - 4 - - - - ..\packages\NBuilder.4.0.0\lib\net40\FizzWare.NBuilder.dll - True - - - ..\packages\FluentAssertions.4.18.0\lib\net40\FluentAssertions.dll - True - - - ..\packages\FluentAssertions.4.18.0\lib\net40\FluentAssertions.Core.dll - True - - - ..\packages\NUnit.3.5.0\lib\net40\nunit.framework.dll - True - - - - - - - - - - ..\packages\Moq.4.0.10827\lib\NET40\Moq.dll - - - - - - - - - {F6FC6BE7-0847-4817-A1ED-223DC647C3D7} - Marr.Data - - - {FD286DF8-2D3A-4394-8AD5-443FADE55FB2} - NzbDrone.Api - - - {F2BE0FDF-6E47-4827-A420-DD4EF82407F8} - NzbDrone.Common - - - {FF5EE3B6-913B-47CE-9CEB-11C51B4E1205} - NzbDrone.Core - - - {CADDFCE0-7509-4430-8364-2074E1EEFCA2} - NzbDrone.Test.Common - - - - - App.config - - - - - - - - - \ No newline at end of file diff --git a/src/NzbDrone.Api.Test/Properties/AssemblyInfo.cs b/src/NzbDrone.Api.Test/Properties/AssemblyInfo.cs deleted file mode 100644 index 4d2901c1a..000000000 --- a/src/NzbDrone.Api.Test/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("NzbDrone.Api.Test")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("NzbDrone.Api.Test")] -[assembly: AssemblyCopyright("Copyright © 2013")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("260b2ff9-d3b7-4d8a-b720-a12c93d045e5")] - -[assembly: AssemblyVersion("10.0.0.*")] diff --git a/src/NzbDrone.Api.Test/packages.config b/src/NzbDrone.Api.Test/packages.config deleted file mode 100644 index 941aa9773..000000000 --- a/src/NzbDrone.Api.Test/packages.config +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/src/NzbDrone.Api/Authentication/AuthenticationModule.cs b/src/NzbDrone.Api/Authentication/AuthenticationModule.cs deleted file mode 100644 index df940a947..000000000 --- a/src/NzbDrone.Api/Authentication/AuthenticationModule.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System; -using Nancy; -using Nancy.Authentication.Forms; -using Nancy.Extensions; -using Nancy.ModelBinding; -using NzbDrone.Common.EnsureThat; -using NzbDrone.Core.Authentication; -using NzbDrone.Core.Configuration; - -namespace NzbDrone.Api.Authentication -{ - public class AuthenticationModule : NancyModule - { - private readonly IUserService _userService; - private readonly IConfigFileProvider _configFileProvider; - - public AuthenticationModule(IUserService userService, IConfigFileProvider configFileProvider) - { - _userService = userService; - _configFileProvider = configFileProvider; - Post["/login"] = x => Login(this.Bind()); - Get["/logout"] = x => Logout(); - } - - private Response Login(LoginResource resource) - { - Ensure.That(resource.Username, () => resource.Username).IsNotNullOrWhiteSpace(); - - // TODO: A null or empty password should not be allowed, uncomment in v3 - //Ensure.That(resource.Password, () => resource.Password).IsNotNullOrWhiteSpace(); - - var user = _userService.FindUser(resource.Username, resource.Password); - - if (user == null) - { - return Context.GetRedirect("~/login?returnUrl=" + (string)Request.Query.returnUrl); - } - - DateTime? expiry = null; - - if (resource.RememberMe) - { - expiry = DateTime.UtcNow.AddDays(7); - } - - return this.LoginAndRedirect(user.Identifier, expiry, _configFileProvider.UrlBase + "/"); - } - - private Response Logout() - { - return this.LogoutAndRedirect(_configFileProvider.UrlBase + "/"); - } - } -} diff --git a/src/NzbDrone.Api/Authentication/AuthenticationService.cs b/src/NzbDrone.Api/Authentication/AuthenticationService.cs deleted file mode 100644 index beb908b11..000000000 --- a/src/NzbDrone.Api/Authentication/AuthenticationService.cs +++ /dev/null @@ -1,144 +0,0 @@ -using System; -using System.Linq; -using Nancy; -using Nancy.Authentication.Basic; -using Nancy.Authentication.Forms; -using Nancy.Security; -using NzbDrone.Api.Extensions; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Authentication; -using NzbDrone.Core.Configuration; - -namespace NzbDrone.Api.Authentication -{ - public interface IAuthenticationService : IUserValidator, IUserMapper - { - bool IsAuthenticated(NancyContext context); - } - - public class AuthenticationService : IAuthenticationService - { - private readonly IConfigFileProvider _configFileProvider; - private readonly IUserService _userService; - private static readonly NzbDroneUser AnonymousUser = new NzbDroneUser { UserName = "Anonymous" }; - - private static string API_KEY; - private static AuthenticationType AUTH_METHOD; - - public AuthenticationService(IConfigFileProvider configFileProvider, IUserService userService) - { - _configFileProvider = configFileProvider; - _userService = userService; - API_KEY = configFileProvider.ApiKey; - AUTH_METHOD = configFileProvider.AuthenticationMethod; - } - - public IUserIdentity Validate(string username, string password) - { - if (AUTH_METHOD == AuthenticationType.None) - { - return AnonymousUser; - } - - var user = _userService.FindUser(username, password); - - if (user != null) - { - return new NzbDroneUser { UserName = user.Username }; - } - - return null; - } - - public IUserIdentity GetUserFromIdentifier(Guid identifier, NancyContext context) - { - if (AUTH_METHOD == AuthenticationType.None) - { - return AnonymousUser; - } - - var user = _userService.FindUser(identifier); - - if (user != null) - { - return new NzbDroneUser { UserName = user.Username }; - } - - return null; - } - - public bool IsAuthenticated(NancyContext context) - { - var apiKey = GetApiKey(context); - - if (context.Request.IsApiRequest()) - { - return ValidApiKey(apiKey); - } - - if (AUTH_METHOD == AuthenticationType.None) - { - return true; - } - - if (context.Request.IsFeedRequest()) - { - if (ValidUser(context) || ValidApiKey(apiKey)) - { - return true; - } - - return false; - } - - if (context.Request.IsLoginRequest()) - { - return true; - } - - if (context.Request.IsContentRequest()) - { - return true; - } - - if (ValidUser(context)) - { - return true; - } - - return false; - } - - private bool ValidUser(NancyContext context) - { - if (context.CurrentUser != null) return true; - - return false; - } - - private bool ValidApiKey(string apiKey) - { - if (API_KEY.Equals(apiKey)) return true; - - return false; - } - - private string GetApiKey(NancyContext context) - { - var apiKeyHeader = context.Request.Headers["X-Api-Key"].FirstOrDefault(); - var apiKeyQueryString = context.Request.Query["ApiKey"]; - - if (!apiKeyHeader.IsNullOrWhiteSpace()) - { - return apiKeyHeader; - } - - if (apiKeyQueryString.HasValue) - { - return apiKeyQueryString.Value; - } - - return context.Request.Headers.Authorization; - } - } -} diff --git a/src/NzbDrone.Api/Authentication/EnableAuthInNancy.cs b/src/NzbDrone.Api/Authentication/EnableAuthInNancy.cs deleted file mode 100644 index f6efc16ce..000000000 --- a/src/NzbDrone.Api/Authentication/EnableAuthInNancy.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System; -using System.Text; -using Nancy; -using Nancy.Authentication.Basic; -using Nancy.Authentication.Forms; -using Nancy.Bootstrapper; -using Nancy.Cryptography; -using NzbDrone.Api.Extensions; -using NzbDrone.Api.Extensions.Pipelines; -using NzbDrone.Core.Authentication; -using NzbDrone.Core.Configuration; - -namespace NzbDrone.Api.Authentication -{ - public class EnableAuthInNancy : IRegisterNancyPipeline - { - private readonly IAuthenticationService _authenticationService; - private readonly IConfigService _configService; - private readonly IConfigFileProvider _configFileProvider; - - public EnableAuthInNancy(IAuthenticationService authenticationService, - IConfigService configService, - IConfigFileProvider configFileProvider) - { - _authenticationService = authenticationService; - _configService = configService; - _configFileProvider = configFileProvider; - } - - public int Order => 10; - - public void Register(IPipelines pipelines) - { - if (_configFileProvider.AuthenticationMethod == AuthenticationType.Forms) - { - RegisterFormsAuth(pipelines); - } - - else if (_configFileProvider.AuthenticationMethod == AuthenticationType.Basic) - { - pipelines.EnableBasicAuthentication(new BasicAuthenticationConfiguration(_authenticationService, "Sonarr")); - } - - pipelines.BeforeRequest.AddItemToEndOfPipeline((Func) RequiresAuthentication); - pipelines.AfterRequest.AddItemToEndOfPipeline((Action) RemoveLoginHooksForApiCalls); - } - - private Response RequiresAuthentication(NancyContext context) - { - Response response = null; - - if (!_authenticationService.IsAuthenticated(context)) - { - response = new Response { StatusCode = HttpStatusCode.Unauthorized }; - } - - return response; - } - - private void RegisterFormsAuth(IPipelines pipelines) - { - var cryptographyConfiguration = new CryptographyConfiguration( - new RijndaelEncryptionProvider(new PassphraseKeyGenerator(_configService.RijndaelPassphrase, Encoding.ASCII.GetBytes(_configService.RijndaelSalt))), - new DefaultHmacProvider(new PassphraseKeyGenerator(_configService.HmacPassphrase, Encoding.ASCII.GetBytes(_configService.HmacSalt))) - ); - - FormsAuthentication.Enable(pipelines, new FormsAuthenticationConfiguration - { - RedirectUrl = _configFileProvider.UrlBase + "/login", - UserMapper = _authenticationService, - CryptographyConfiguration = cryptographyConfiguration - }); - } - - private void RemoveLoginHooksForApiCalls(NancyContext context) - { - if (context.Request.IsApiRequest()) - { - if ((context.Response.StatusCode == HttpStatusCode.SeeOther && - context.Response.Headers["Location"].StartsWith($"{_configFileProvider.UrlBase}/login", StringComparison.InvariantCultureIgnoreCase)) || - context.Response.StatusCode == HttpStatusCode.Unauthorized) - { - context.Response = new { Error = "Unauthorized" }.AsResponse(HttpStatusCode.Unauthorized); - } - } - } - } -} diff --git a/src/NzbDrone.Api/Blacklist/BlacklistModule.cs b/src/NzbDrone.Api/Blacklist/BlacklistModule.cs deleted file mode 100644 index 1687b31e3..000000000 --- a/src/NzbDrone.Api/Blacklist/BlacklistModule.cs +++ /dev/null @@ -1,29 +0,0 @@ -using NzbDrone.Core.Blacklisting; -using NzbDrone.Core.Datastore; - -namespace NzbDrone.Api.Blacklist -{ - public class BlacklistModule : NzbDroneRestModule - { - private readonly IBlacklistService _blacklistService; - - public BlacklistModule(IBlacklistService blacklistService) - { - _blacklistService = blacklistService; - GetResourcePaged = GetBlacklist; - DeleteResource = DeleteBlacklist; - } - - private PagingResource GetBlacklist(PagingResource pagingResource) - { - var pagingSpec = pagingResource.MapToPagingSpec("id", SortDirection.Ascending); - - return ApplyToPage(_blacklistService.Paged, pagingSpec, BlacklistResourceMapper.MapToResource); - } - - private void DeleteBlacklist(int id) - { - _blacklistService.Delete(id); - } - } -} diff --git a/src/NzbDrone.Api/Blacklist/BlacklistResource.cs b/src/NzbDrone.Api/Blacklist/BlacklistResource.cs deleted file mode 100644 index c3f1c6b1b..000000000 --- a/src/NzbDrone.Api/Blacklist/BlacklistResource.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using System.Collections.Generic; -using NzbDrone.Api.REST; -using NzbDrone.Core.Qualities; -using NzbDrone.Api.Series; -using NzbDrone.Core.Indexers; - -namespace NzbDrone.Api.Blacklist -{ - public class BlacklistResource : RestResource - { - public int SeriesId { get; set; } - public List EpisodeIds { get; set; } - public string SourceTitle { get; set; } - public QualityModel Quality { get; set; } - public DateTime Date { get; set; } - public DownloadProtocol Protocol { get; set; } - public string Indexer { get; set; } - public string Message { get; set; } - - public SeriesResource Series { get; set; } - } - - public static class BlacklistResourceMapper - { - public static BlacklistResource MapToResource(this Core.Blacklisting.Blacklist model) - { - if (model == null) return null; - - return new BlacklistResource - { - Id = model.Id, - - SeriesId = model.SeriesId, - EpisodeIds = model.EpisodeIds, - SourceTitle = model.SourceTitle, - Quality = model.Quality, - Date = model.Date, - Protocol = model.Protocol, - Indexer = model.Indexer, - Message = model.Message, - - Series = model.Series.ToResource() - }; - } - } -} diff --git a/src/NzbDrone.Api/Calendar/CalendarFeedModule.cs b/src/NzbDrone.Api/Calendar/CalendarFeedModule.cs deleted file mode 100644 index 4845bc653..000000000 --- a/src/NzbDrone.Api/Calendar/CalendarFeedModule.cs +++ /dev/null @@ -1,142 +0,0 @@ -using Nancy; -using System; -using System.Collections.Generic; -using System.Linq; -using Ical.Net; -using Ical.Net.DataTypes; -using Ical.Net.Interfaces.Serialization; -using Ical.Net.Serialization; -using Ical.Net.Serialization.iCalendar.Factory; -using NzbDrone.Core.Tv; -using Nancy.Responses; -using NzbDrone.Core.Tags; -using NzbDrone.Common.Extensions; - -namespace NzbDrone.Api.Calendar -{ - public class CalendarFeedModule : NzbDroneFeedModule - { - private readonly IEpisodeService _episodeService; - private readonly ITagService _tagService; - - public CalendarFeedModule(IEpisodeService episodeService, ITagService tagService) - : base("calendar") - { - _episodeService = episodeService; - _tagService = tagService; - - Get["/NzbDrone.ics"] = options => GetCalendarFeed(); - Get["/Sonarr.ics"] = options => GetCalendarFeed(); - } - - private Response GetCalendarFeed() - { - var pastDays = 7; - var futureDays = 28; - var start = DateTime.Today.AddDays(-pastDays); - var end = DateTime.Today.AddDays(futureDays); - var unmonitored = false; - var premiersOnly = false; - var asAllDay = false; - var tags = new List(); - - // TODO: Remove start/end parameters in v3, they don't work well for iCal - var queryStart = Request.Query.Start; - var queryEnd = Request.Query.End; - var queryPastDays = Request.Query.PastDays; - var queryFutureDays = Request.Query.FutureDays; - var queryUnmonitored = Request.Query.Unmonitored; - var queryPremiersOnly = Request.Query.PremiersOnly; - var queryAsAllDay = Request.Query.AsAllDay; - var queryTags = Request.Query.Tags; - - if (queryStart.HasValue) start = DateTime.Parse(queryStart.Value); - if (queryEnd.HasValue) end = DateTime.Parse(queryEnd.Value); - - if (queryPastDays.HasValue) - { - pastDays = int.Parse(queryPastDays.Value); - start = DateTime.Today.AddDays(-pastDays); - } - - if (queryFutureDays.HasValue) - { - futureDays = int.Parse(queryFutureDays.Value); - end = DateTime.Today.AddDays(futureDays); - } - - if (queryUnmonitored.HasValue) - { - unmonitored = bool.Parse(queryUnmonitored.Value); - } - - if (queryPremiersOnly.HasValue) - { - premiersOnly = bool.Parse(queryPremiersOnly.Value); - } - - if (queryAsAllDay.HasValue) - { - asAllDay = bool.Parse(queryAsAllDay.Value); - } - - if (queryTags.HasValue) - { - var tagInput = (string)queryTags.Value.ToString(); - tags.AddRange(tagInput.Split(',').Select(_tagService.GetTag).Select(t => t.Id)); - } - - var episodes = _episodeService.EpisodesBetweenDates(start, end, unmonitored); - var calendar = new Ical.Net.Calendar - { - ProductId = "-//sonarr.tv//Sonarr//EN" - }; - - - - foreach (var episode in episodes.OrderBy(v => v.AirDateUtc.Value)) - { - if (premiersOnly && (episode.SeasonNumber == 0 || episode.EpisodeNumber != 1)) - { - continue; - } - - if (tags.Any() && tags.None(episode.Series.Tags.Contains)) - { - continue; - } - - var occurrence = calendar.Create(); - occurrence.Uid = "NzbDrone_episode_" + episode.Id; - occurrence.Status = episode.HasFile ? EventStatus.Confirmed : EventStatus.Tentative; - occurrence.Description = episode.Overview; - occurrence.Categories = new List() { episode.Series.Network }; - - if (asAllDay) - { - occurrence.Start = new CalDateTime(episode.AirDateUtc.Value) { HasTime = false }; - } - else - { - occurrence.Start = new CalDateTime(episode.AirDateUtc.Value) { HasTime = true }; - occurrence.End = new CalDateTime(episode.AirDateUtc.Value.AddMinutes(episode.Series.Runtime)) { HasTime = true }; - } - - switch (episode.Series.SeriesType) - { - case SeriesTypes.Daily: - occurrence.Summary = $"{episode.Series.Title} - {episode.Title}"; - break; - default: - occurrence.Summary =$"{episode.Series.Title} - {episode.SeasonNumber}x{episode.EpisodeNumber:00} - {episode.Title}"; - break; - } - } - - var serializer = (IStringSerializer) new SerializerFactory().Build(calendar.GetType(), new SerializationContext()); - var icalendar = serializer.SerializeToString(calendar); - - return new TextResponse(icalendar, "text/calendar"); - } - } -} diff --git a/src/NzbDrone.Api/Calendar/CalendarModule.cs b/src/NzbDrone.Api/Calendar/CalendarModule.cs deleted file mode 100644 index f403b79c7..000000000 --- a/src/NzbDrone.Api/Calendar/CalendarModule.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NzbDrone.Api.Episodes; -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Tv; -using NzbDrone.SignalR; - -namespace NzbDrone.Api.Calendar -{ - public class CalendarModule : EpisodeModuleWithSignalR - { - public CalendarModule(IEpisodeService episodeService, - ISeriesService seriesService, - IQualityUpgradableSpecification qualityUpgradableSpecification, - IBroadcastSignalRMessage signalRBroadcaster) - : base(episodeService, seriesService, qualityUpgradableSpecification, signalRBroadcaster, "calendar") - { - GetResourceAll = GetCalendar; - } - - private List GetCalendar() - { - var start = DateTime.Today; - var end = DateTime.Today.AddDays(2); - var includeUnmonitored = false; - - var queryStart = Request.Query.Start; - var queryEnd = Request.Query.End; - var queryIncludeUnmonitored = Request.Query.Unmonitored; - - if (queryStart.HasValue) start = DateTime.Parse(queryStart.Value); - if (queryEnd.HasValue) end = DateTime.Parse(queryEnd.Value); - if (queryIncludeUnmonitored.HasValue) includeUnmonitored = Convert.ToBoolean(queryIncludeUnmonitored.Value); - - var resources = MapToResource(_episodeService.EpisodesBetweenDates(start, end, includeUnmonitored), true, true); - - return resources.OrderBy(e => e.AirDateUtc).ToList(); - } - } -} diff --git a/src/NzbDrone.Api/ClientSchema/Field.cs b/src/NzbDrone.Api/ClientSchema/Field.cs deleted file mode 100644 index ec611e8d6..000000000 --- a/src/NzbDrone.Api/ClientSchema/Field.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; - -namespace NzbDrone.Api.ClientSchema -{ - public class Field - { - public int Order { get; set; } - public string Name { get; set; } - public string Label { 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; } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/ClientSchema/FieldDefinitionAttribute.cs b/src/NzbDrone.Api/ClientSchema/FieldDefinitionAttribute.cs deleted file mode 100644 index 4e796bd8c..000000000 --- a/src/NzbDrone.Api/ClientSchema/FieldDefinitionAttribute.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace NzbDrone.Api.ClientSchema -{ - -} \ No newline at end of file diff --git a/src/NzbDrone.Api/ClientSchema/SchemaBuilder.cs b/src/NzbDrone.Api/ClientSchema/SchemaBuilder.cs deleted file mode 100644 index 0a7acb9e1..000000000 --- a/src/NzbDrone.Api/ClientSchema/SchemaBuilder.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Newtonsoft.Json.Linq; -using NzbDrone.Common.EnsureThat; -using NzbDrone.Common.Extensions; -using NzbDrone.Common.Reflection; -using NzbDrone.Core.Annotations; - -namespace NzbDrone.Api.ClientSchema -{ - public static class SchemaBuilder - { - public static List ToSchema(object model) - { - Ensure.That(model, () => model).IsNotNull(); - - var properties = model.GetType().GetSimpleProperties(); - - var result = new List(properties.Count); - - foreach (var propertyInfo in properties) - { - var fieldAttribute = propertyInfo.GetAttribute(false); - - 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); - } - } - - return result.OrderBy(r => r.Order).ToList(); - } - - public static object ReadFromSchema(List fields, Type targetType) - { - Ensure.That(targetType, () => targetType).IsNotNull(); - - var properties = targetType.GetSimpleProperties(); - - var target = Activator.CreateInstance(targetType); - - foreach (var propertyInfo in properties) - { - var fieldAttribute = propertyInfo.GetAttribute(false); - - 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); - } - } - } - - return target; - - } - - public static T ReadFromSchema(List fields) - { - return (T)ReadFromSchema(fields, typeof(T)); - } - - private static List GetSelectOptions(Type selectOptions) - { - var options = from Enum e in Enum.GetValues(selectOptions) - select new SelectOption { Value = Convert.ToInt32(e), Name = e.ToString() }; - - return options.OrderBy(o => o.Value).ToList(); - } - } -} diff --git a/src/NzbDrone.Api/ClientSchema/SchemaDeserializer.cs b/src/NzbDrone.Api/ClientSchema/SchemaDeserializer.cs deleted file mode 100644 index 6af07257f..000000000 --- a/src/NzbDrone.Api/ClientSchema/SchemaDeserializer.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace NzbDrone.Api.ClientSchema -{ - public static class SchemaDeserializer - { - - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Commands/CommandModule.cs b/src/NzbDrone.Api/Commands/CommandModule.cs deleted file mode 100644 index fcaeef9c4..000000000 --- a/src/NzbDrone.Api/Commands/CommandModule.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NzbDrone.Api.Extensions; -using NzbDrone.Api.Validation; -using NzbDrone.Common; -using NzbDrone.Core.Datastore.Events; -using NzbDrone.Core.Messaging.Commands; -using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.ProgressMessaging; -using NzbDrone.SignalR; - - -namespace NzbDrone.Api.Commands -{ - public class CommandModule : NzbDroneRestModuleWithSignalR, IHandle - { - private readonly IManageCommandQueue _commandQueueManager; - private readonly IServiceFactory _serviceFactory; - - public CommandModule(IManageCommandQueue commandQueueManager, - IBroadcastSignalRMessage signalRBroadcaster, - IServiceFactory serviceFactory) - : base(signalRBroadcaster) - { - _commandQueueManager = commandQueueManager; - _serviceFactory = serviceFactory; - - GetResourceById = GetCommand; - CreateResource = StartCommand; - GetResourceAll = GetStartedCommands; - - PostValidator.RuleFor(c => c.Name).NotBlank(); - } - - private CommandResource GetCommand(int id) - { - return _commandQueueManager.Get(id).ToResource(); - } - - private int StartCommand(CommandResource commandResource) - { - var commandType = _serviceFactory.GetImplementations(typeof(Command)) - .Single(c => c.Name.Replace("Command", "").Equals(commandResource.Name, StringComparison.InvariantCultureIgnoreCase)); - - dynamic command = Request.Body.FromJson(commandType); - command.Trigger = CommandTrigger.Manual; - - var trackedCommand = _commandQueueManager.Push(command, CommandPriority.Normal, CommandTrigger.Manual); - return trackedCommand.Id; - } - - private List GetStartedCommands() - { - return _commandQueueManager.GetStarted().ToResource(); - } - - public void Handle(CommandUpdatedEvent message) - { - if (message.Command.Body.SendUpdatesToClient) - { - BroadcastResourceChange(ModelAction.Updated, message.Command.ToResource()); - } - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Config/DownloadClientConfigModule.cs b/src/NzbDrone.Api/Config/DownloadClientConfigModule.cs deleted file mode 100644 index de478235e..000000000 --- a/src/NzbDrone.Api/Config/DownloadClientConfigModule.cs +++ /dev/null @@ -1,29 +0,0 @@ -using FluentValidation; -using NzbDrone.Core.Configuration; -using NzbDrone.Core.Validation.Paths; - -namespace NzbDrone.Api.Config -{ - public class DownloadClientConfigModule : NzbDroneConfigModule - { - public DownloadClientConfigModule(IConfigService configService, - RootFolderValidator rootFolderValidator, - PathExistsValidator pathExistsValidator, - MappedNetworkDriveValidator mappedNetworkDriveValidator) - : base(configService) - { - SharedValidator.RuleFor(c => c.DownloadedEpisodesFolder) - .Cascade(CascadeMode.StopOnFirstFailure) - .IsValidPath() - .SetValidator(rootFolderValidator) - .SetValidator(mappedNetworkDriveValidator) - .SetValidator(pathExistsValidator) - .When(c => !string.IsNullOrWhiteSpace(c.DownloadedEpisodesFolder)); - } - - protected override DownloadClientConfigResource ToResource(IConfigService model) - { - return DownloadClientConfigResourceMapper.ToResource(model); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Config/DownloadClientConfigResource.cs b/src/NzbDrone.Api/Config/DownloadClientConfigResource.cs deleted file mode 100644 index 8309c9f4d..000000000 --- a/src/NzbDrone.Api/Config/DownloadClientConfigResource.cs +++ /dev/null @@ -1,37 +0,0 @@ -using NzbDrone.Api.REST; -using NzbDrone.Core.Configuration; - -namespace NzbDrone.Api.Config -{ - public class DownloadClientConfigResource : RestResource - { - public string DownloadedEpisodesFolder { get; set; } - public string DownloadClientWorkingFolders { get; set; } - public int DownloadedEpisodesScanInterval { get; set; } - - public bool EnableCompletedDownloadHandling { get; set; } - public bool RemoveCompletedDownloads { get; set; } - - public bool AutoRedownloadFailed { get; set; } - public bool RemoveFailedDownloads { get; set; } - } - - public static class DownloadClientConfigResourceMapper - { - public static DownloadClientConfigResource ToResource(IConfigService model) - { - return new DownloadClientConfigResource - { - DownloadedEpisodesFolder = model.DownloadedEpisodesFolder, - DownloadClientWorkingFolders = model.DownloadClientWorkingFolders, - DownloadedEpisodesScanInterval = model.DownloadedEpisodesScanInterval, - - EnableCompletedDownloadHandling = model.EnableCompletedDownloadHandling, - RemoveCompletedDownloads = model.RemoveCompletedDownloads, - - AutoRedownloadFailed = model.AutoRedownloadFailed, - RemoveFailedDownloads = model.RemoveFailedDownloads - }; - } - } -} diff --git a/src/NzbDrone.Api/Config/IndexerConfigModule.cs b/src/NzbDrone.Api/Config/IndexerConfigModule.cs deleted file mode 100644 index 73c2442b8..000000000 --- a/src/NzbDrone.Api/Config/IndexerConfigModule.cs +++ /dev/null @@ -1,28 +0,0 @@ -using FluentValidation; -using NzbDrone.Api.Validation; -using NzbDrone.Core.Configuration; - -namespace NzbDrone.Api.Config -{ - public class IndexerConfigModule : NzbDroneConfigModule - { - - public IndexerConfigModule(IConfigService configService) - : base(configService) - { - SharedValidator.RuleFor(c => c.MinimumAge) - .GreaterThanOrEqualTo(0); - - SharedValidator.RuleFor(c => c.Retention) - .GreaterThanOrEqualTo(0); - - SharedValidator.RuleFor(c => c.RssSyncInterval) - .IsValidRssSyncInterval(); - } - - protected override IndexerConfigResource ToResource(IConfigService model) - { - return IndexerConfigResourceMapper.ToResource(model); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs b/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs deleted file mode 100644 index 097ecc693..000000000 --- a/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs +++ /dev/null @@ -1,52 +0,0 @@ -using NzbDrone.Api.REST; -using NzbDrone.Core.Configuration; -using NzbDrone.Core.MediaFiles; - -namespace NzbDrone.Api.Config -{ - public class MediaManagementConfigResource : RestResource - { - public bool AutoUnmonitorPreviouslyDownloadedEpisodes { get; set; } - public string RecycleBin { get; set; } - public bool AutoDownloadPropers { get; set; } - public bool CreateEmptySeriesFolders { get; set; } - public FileDateType FileDate { get; set; } - - public bool SetPermissionsLinux { get; set; } - public string FileChmod { get; set; } - public string FolderChmod { get; set; } - public string ChownUser { get; set; } - public string ChownGroup { get; set; } - - public bool SkipFreeSpaceCheckWhenImporting { get; set; } - public bool CopyUsingHardlinks { get; set; } - public string ExtraFileExtensions { get; set; } - public bool EnableMediaInfo { get; set; } - } - - public static class MediaManagementConfigResourceMapper - { - public static MediaManagementConfigResource ToResource(IConfigService model) - { - return new MediaManagementConfigResource - { - AutoUnmonitorPreviouslyDownloadedEpisodes = model.AutoUnmonitorPreviouslyDownloadedEpisodes, - RecycleBin = model.RecycleBin, - AutoDownloadPropers = model.AutoDownloadPropers, - CreateEmptySeriesFolders = model.CreateEmptySeriesFolders, - FileDate = model.FileDate, - - SetPermissionsLinux = model.SetPermissionsLinux, - FileChmod = model.FileChmod, - FolderChmod = model.FolderChmod, - ChownUser = model.ChownUser, - ChownGroup = model.ChownGroup, - - SkipFreeSpaceCheckWhenImporting = model.SkipFreeSpaceCheckWhenImporting, - CopyUsingHardlinks = model.CopyUsingHardlinks, - ExtraFileExtensions = model.ExtraFileExtensions, - EnableMediaInfo = model.EnableMediaInfo - }; - } - } -} diff --git a/src/NzbDrone.Api/Config/NamingConfigModule.cs b/src/NzbDrone.Api/Config/NamingConfigModule.cs deleted file mode 100644 index 0b72e0b0c..000000000 --- a/src/NzbDrone.Api/Config/NamingConfigModule.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using FluentValidation; -using FluentValidation.Results; -using Nancy.Responses; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Organizer; -using Nancy.ModelBinding; -using NzbDrone.Api.Extensions; - -namespace NzbDrone.Api.Config -{ - public class NamingConfigModule : NzbDroneRestModule - { - private readonly INamingConfigService _namingConfigService; - private readonly IFilenameSampleService _filenameSampleService; - private readonly IFilenameValidationService _filenameValidationService; - private readonly IBuildFileNames _filenameBuilder; - - public NamingConfigModule(INamingConfigService namingConfigService, - IFilenameSampleService filenameSampleService, - IFilenameValidationService filenameValidationService, - IBuildFileNames filenameBuilder) - : base("config/naming") - { - _namingConfigService = namingConfigService; - _filenameSampleService = filenameSampleService; - _filenameValidationService = filenameValidationService; - _filenameBuilder = filenameBuilder; - GetResourceSingle = GetNamingConfig; - GetResourceById = GetNamingConfig; - UpdateResource = UpdateNamingConfig; - - Get["/samples"] = x => GetExamples(this.Bind()); - - SharedValidator.RuleFor(c => c.MultiEpisodeStyle).InclusiveBetween(0, 5); - SharedValidator.RuleFor(c => c.StandardEpisodeFormat).ValidEpisodeFormat(); - SharedValidator.RuleFor(c => c.DailyEpisodeFormat).ValidDailyEpisodeFormat(); - SharedValidator.RuleFor(c => c.AnimeEpisodeFormat).ValidAnimeEpisodeFormat(); - SharedValidator.RuleFor(c => c.SeriesFolderFormat).ValidSeriesFolderFormat(); - SharedValidator.RuleFor(c => c.SeasonFolderFormat).ValidSeasonFolderFormat(); - } - - private void UpdateNamingConfig(NamingConfigResource resource) - { - var nameSpec = resource.ToModel(); - ValidateFormatResult(nameSpec); - - _namingConfigService.Save(nameSpec); - } - - private NamingConfigResource GetNamingConfig() - { - var nameSpec = _namingConfigService.GetConfig(); - var resource = nameSpec.ToResource(); - - if (resource.StandardEpisodeFormat.IsNotNullOrWhiteSpace()) - { - var basicConfig = _filenameBuilder.GetBasicNamingConfig(nameSpec); - basicConfig.AddToResource(resource); - } - - return resource; - } - - private NamingConfigResource GetNamingConfig(int id) - { - return GetNamingConfig(); - } - - private JsonResponse GetExamples(NamingConfigResource config) - { - var nameSpec = config.ToModel(); - var sampleResource = new NamingSampleResource(); - - var singleEpisodeSampleResult = _filenameSampleService.GetStandardSample(nameSpec); - var multiEpisodeSampleResult = _filenameSampleService.GetMultiEpisodeSample(nameSpec); - var dailyEpisodeSampleResult = _filenameSampleService.GetDailySample(nameSpec); - var animeEpisodeSampleResult = _filenameSampleService.GetAnimeSample(nameSpec); - var animeMultiEpisodeSampleResult = _filenameSampleService.GetAnimeMultiEpisodeSample(nameSpec); - - sampleResource.SingleEpisodeExample = _filenameValidationService.ValidateStandardFilename(singleEpisodeSampleResult) != null - ? "Invalid format" - : singleEpisodeSampleResult.FileName; - - sampleResource.MultiEpisodeExample = _filenameValidationService.ValidateStandardFilename(multiEpisodeSampleResult) != null - ? "Invalid format" - : multiEpisodeSampleResult.FileName; - - sampleResource.DailyEpisodeExample = _filenameValidationService.ValidateDailyFilename(dailyEpisodeSampleResult) != null - ? "Invalid format" - : dailyEpisodeSampleResult.FileName; - - sampleResource.AnimeEpisodeExample = _filenameValidationService.ValidateAnimeFilename(animeEpisodeSampleResult) != null - ? "Invalid format" - : animeEpisodeSampleResult.FileName; - - sampleResource.AnimeMultiEpisodeExample = _filenameValidationService.ValidateAnimeFilename(animeMultiEpisodeSampleResult) != null - ? "Invalid format" - : animeMultiEpisodeSampleResult.FileName; - - sampleResource.SeriesFolderExample = nameSpec.SeriesFolderFormat.IsNullOrWhiteSpace() - ? "Invalid format" - : _filenameSampleService.GetSeriesFolderSample(nameSpec); - - sampleResource.SeasonFolderExample = nameSpec.SeasonFolderFormat.IsNullOrWhiteSpace() - ? "Invalid format" - : _filenameSampleService.GetSeasonFolderSample(nameSpec); - - return sampleResource.AsResponse(); - } - - private void ValidateFormatResult(NamingConfig nameSpec) - { - var singleEpisodeSampleResult = _filenameSampleService.GetStandardSample(nameSpec); - var multiEpisodeSampleResult = _filenameSampleService.GetMultiEpisodeSample(nameSpec); - var dailyEpisodeSampleResult = _filenameSampleService.GetDailySample(nameSpec); - var animeEpisodeSampleResult = _filenameSampleService.GetAnimeSample(nameSpec); - var animeMultiEpisodeSampleResult = _filenameSampleService.GetAnimeMultiEpisodeSample(nameSpec); - - var singleEpisodeValidationResult = _filenameValidationService.ValidateStandardFilename(singleEpisodeSampleResult); - var multiEpisodeValidationResult = _filenameValidationService.ValidateStandardFilename(multiEpisodeSampleResult); - var dailyEpisodeValidationResult = _filenameValidationService.ValidateDailyFilename(dailyEpisodeSampleResult); - var animeEpisodeValidationResult = _filenameValidationService.ValidateAnimeFilename(animeEpisodeSampleResult); - var animeMultiEpisodeValidationResult = _filenameValidationService.ValidateAnimeFilename(animeMultiEpisodeSampleResult); - - var validationFailures = new List(); - - validationFailures.AddIfNotNull(singleEpisodeValidationResult); - validationFailures.AddIfNotNull(multiEpisodeValidationResult); - validationFailures.AddIfNotNull(dailyEpisodeValidationResult); - validationFailures.AddIfNotNull(animeEpisodeValidationResult); - validationFailures.AddIfNotNull(animeMultiEpisodeValidationResult); - - if (validationFailures.Any()) - { - throw new ValidationException(validationFailures.DistinctBy(v => v.PropertyName).ToArray()); - } - } - } -} diff --git a/src/NzbDrone.Api/Config/NamingConfigResource.cs b/src/NzbDrone.Api/Config/NamingConfigResource.cs deleted file mode 100644 index 39147b993..000000000 --- a/src/NzbDrone.Api/Config/NamingConfigResource.cs +++ /dev/null @@ -1,76 +0,0 @@ -using NzbDrone.Api.REST; -using NzbDrone.Core.Organizer; - -namespace NzbDrone.Api.Config -{ - public class NamingConfigResource : RestResource - { - public bool RenameEpisodes { get; set; } - public bool ReplaceIllegalCharacters { get; set; } - public int MultiEpisodeStyle { get; set; } - public string StandardEpisodeFormat { get; set; } - public string DailyEpisodeFormat { get; set; } - public string AnimeEpisodeFormat { get; set; } - public string SeriesFolderFormat { get; set; } - public string SeasonFolderFormat { get; set; } - public bool IncludeSeriesTitle { get; set; } - public bool IncludeEpisodeTitle { get; set; } - public bool IncludeQuality { get; set; } - public bool ReplaceSpaces { get; set; } - public string Separator { get; set; } - public string NumberStyle { get; set; } - } - - public static class NamingConfigResourceMapper - { - public static NamingConfigResource ToResource(this NamingConfig model) - { - return new NamingConfigResource - { - Id = model.Id, - - RenameEpisodes = model.RenameEpisodes, - ReplaceIllegalCharacters = model.ReplaceIllegalCharacters, - MultiEpisodeStyle = model.MultiEpisodeStyle, - StandardEpisodeFormat = model.StandardEpisodeFormat, - DailyEpisodeFormat = model.DailyEpisodeFormat, - AnimeEpisodeFormat = model.AnimeEpisodeFormat, - SeriesFolderFormat = model.SeriesFolderFormat, - SeasonFolderFormat = model.SeasonFolderFormat - //IncludeSeriesTitle - //IncludeEpisodeTitle - //IncludeQuality - //ReplaceSpaces - //Separator - //NumberStyle - }; - } - - public static void AddToResource(this BasicNamingConfig basicNamingConfig, NamingConfigResource resource) - { - resource.IncludeSeriesTitle = basicNamingConfig.IncludeSeriesTitle; - resource.IncludeEpisodeTitle = basicNamingConfig.IncludeEpisodeTitle; - resource.IncludeQuality = basicNamingConfig.IncludeQuality; - resource.ReplaceSpaces = basicNamingConfig.ReplaceSpaces; - resource.Separator = basicNamingConfig.Separator; - resource.NumberStyle = basicNamingConfig.NumberStyle; - } - - public static NamingConfig ToModel(this NamingConfigResource resource) - { - return new NamingConfig - { - Id = resource.Id, - - RenameEpisodes = resource.RenameEpisodes, - ReplaceIllegalCharacters = resource.ReplaceIllegalCharacters, - MultiEpisodeStyle = resource.MultiEpisodeStyle, - StandardEpisodeFormat = resource.StandardEpisodeFormat, - DailyEpisodeFormat = resource.DailyEpisodeFormat, - AnimeEpisodeFormat = resource.AnimeEpisodeFormat, - SeriesFolderFormat = resource.SeriesFolderFormat, - SeasonFolderFormat = resource.SeasonFolderFormat - }; - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Config/NamingSampleResource.cs b/src/NzbDrone.Api/Config/NamingSampleResource.cs deleted file mode 100644 index 1f9c7f066..000000000 --- a/src/NzbDrone.Api/Config/NamingSampleResource.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace NzbDrone.Api.Config -{ - public class NamingSampleResource - { - public string SingleEpisodeExample { get; set; } - public string MultiEpisodeExample { get; set; } - public string DailyEpisodeExample { get; set; } - public string AnimeEpisodeExample { get; set; } - public string AnimeMultiEpisodeExample { get; set; } - public string SeriesFolderExample { get; set; } - public string SeasonFolderExample { get; set; } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Config/NzbDroneConfigModule.cs b/src/NzbDrone.Api/Config/NzbDroneConfigModule.cs deleted file mode 100644 index e5d324950..000000000 --- a/src/NzbDrone.Api/Config/NzbDroneConfigModule.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Linq; -using System.Reflection; -using NzbDrone.Api.REST; -using NzbDrone.Core.Configuration; - -namespace NzbDrone.Api.Config -{ - public abstract class NzbDroneConfigModule : NzbDroneRestModule where TResource : RestResource, new() - { - private readonly IConfigService _configService; - - protected NzbDroneConfigModule(IConfigService configService) - : this(new TResource().ResourceName.Replace("config", ""), configService) - { - } - - protected NzbDroneConfigModule(string resource, IConfigService configService) : - base("config/" + resource.Trim('/')) - { - _configService = configService; - - GetResourceSingle = GetConfig; - GetResourceById = GetConfig; - UpdateResource = SaveConfig; - } - - private TResource GetConfig() - { - var resource = ToResource(_configService); - resource.Id = 1; - - return resource; - } - - protected abstract TResource ToResource(IConfigService model); - - private TResource GetConfig(int id) - { - return GetConfig(); - } - - private void SaveConfig(TResource resource) - { - var dictionary = resource.GetType() - .GetProperties(BindingFlags.Instance | BindingFlags.Public) - .ToDictionary(prop => prop.Name, prop => prop.GetValue(resource, null)); - - _configService.SaveConfigDictionary(dictionary); - } - } -} diff --git a/src/NzbDrone.Api/Config/UiConfigModule.cs b/src/NzbDrone.Api/Config/UiConfigModule.cs deleted file mode 100644 index 1762acaca..000000000 --- a/src/NzbDrone.Api/Config/UiConfigModule.cs +++ /dev/null @@ -1,18 +0,0 @@ -using NzbDrone.Core.Configuration; - -namespace NzbDrone.Api.Config -{ - public class UiConfigModule : NzbDroneConfigModule - { - public UiConfigModule(IConfigService configService) - : base(configService) - { - - } - - protected override UiConfigResource ToResource(IConfigService model) - { - return UiConfigResourceMapper.ToResource(model); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Config/UiConfigResource.cs b/src/NzbDrone.Api/Config/UiConfigResource.cs deleted file mode 100644 index 7c7d27b67..000000000 --- a/src/NzbDrone.Api/Config/UiConfigResource.cs +++ /dev/null @@ -1,39 +0,0 @@ -using NzbDrone.Api.REST; -using NzbDrone.Core.Configuration; - -namespace NzbDrone.Api.Config -{ - public class UiConfigResource : RestResource - { - //Calendar - public int FirstDayOfWeek { get; set; } - public string CalendarWeekColumnHeader { get; set; } - - //Dates - public string ShortDateFormat { get; set; } - public string LongDateFormat { get; set; } - public string TimeFormat { get; set; } - public bool ShowRelativeDates { get; set; } - - public bool EnableColorImpairedMode { get; set; } - } - - public static class UiConfigResourceMapper - { - public static UiConfigResource ToResource(IConfigService model) - { - return new UiConfigResource - { - FirstDayOfWeek = model.FirstDayOfWeek, - CalendarWeekColumnHeader = model.CalendarWeekColumnHeader, - - ShortDateFormat = model.ShortDateFormat, - LongDateFormat = model.LongDateFormat, - TimeFormat = model.TimeFormat, - ShowRelativeDates = model.ShowRelativeDates, - - EnableColorImpairedMode = model.EnableColorImpairedMode, - }; - } - } -} diff --git a/src/NzbDrone.Api/DownloadClient/DownloadClientModule.cs b/src/NzbDrone.Api/DownloadClient/DownloadClientModule.cs deleted file mode 100644 index d7568189f..000000000 --- a/src/NzbDrone.Api/DownloadClient/DownloadClientModule.cs +++ /dev/null @@ -1,34 +0,0 @@ -using NzbDrone.Core.Download; - -namespace NzbDrone.Api.DownloadClient -{ - public class DownloadClientModule : ProviderModuleBase - { - public DownloadClientModule(IDownloadClientFactory downloadClientFactory) - : base(downloadClientFactory, "downloadclient") - { - } - - protected override void MapToResource(DownloadClientResource resource, DownloadClientDefinition definition) - { - base.MapToResource(resource, definition); - - resource.Enable = definition.Enable; - resource.Protocol = definition.Protocol; - } - - protected override void MapToModel(DownloadClientDefinition definition, DownloadClientResource resource) - { - base.MapToModel(definition, resource); - - definition.Enable = resource.Enable; - definition.Protocol = resource.Protocol; - } - - protected override void Validate(DownloadClientDefinition definition, bool includeWarnings) - { - if (!definition.Enable) return; - base.Validate(definition, includeWarnings); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/DownloadClient/DownloadClientResource.cs b/src/NzbDrone.Api/DownloadClient/DownloadClientResource.cs deleted file mode 100644 index a7156e08d..000000000 --- a/src/NzbDrone.Api/DownloadClient/DownloadClientResource.cs +++ /dev/null @@ -1,10 +0,0 @@ -using NzbDrone.Core.Indexers; - -namespace NzbDrone.Api.DownloadClient -{ - public class DownloadClientResource : ProviderResource - { - public bool Enable { get; set; } - public DownloadProtocol Protocol { get; set; } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/EpisodeFiles/EpisodeFileModule.cs b/src/NzbDrone.Api/EpisodeFiles/EpisodeFileModule.cs deleted file mode 100644 index 0271ae218..000000000 --- a/src/NzbDrone.Api/EpisodeFiles/EpisodeFileModule.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using NLog; -using NzbDrone.Api.REST; -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.SignalR; - -namespace NzbDrone.Api.EpisodeFiles -{ - public class EpisodeFileModule : NzbDroneRestModuleWithSignalR, - IHandle - { - private readonly IMediaFileService _mediaFileService; - private readonly IRecycleBinProvider _recycleBinProvider; - private readonly ISeriesService _seriesService; - private readonly IQualityUpgradableSpecification _qualityUpgradableSpecification; - private readonly Logger _logger; - - public EpisodeFileModule(IBroadcastSignalRMessage signalRBroadcaster, - IMediaFileService mediaFileService, - IRecycleBinProvider recycleBinProvider, - ISeriesService seriesService, - IQualityUpgradableSpecification qualityUpgradableSpecification, - Logger logger) - : base(signalRBroadcaster) - { - _mediaFileService = mediaFileService; - _recycleBinProvider = recycleBinProvider; - _seriesService = seriesService; - _qualityUpgradableSpecification = qualityUpgradableSpecification; - _logger = logger; - GetResourceById = GetEpisodeFile; - GetResourceAll = GetEpisodeFiles; - UpdateResource = SetQuality; - DeleteResource = DeleteEpisodeFile; - } - - private EpisodeFileResource GetEpisodeFile(int id) - { - var episodeFile = _mediaFileService.Get(id); - var series = _seriesService.GetSeries(episodeFile.SeriesId); - - return episodeFile.ToResource(series, _qualityUpgradableSpecification); - } - - private List GetEpisodeFiles() - { - if (!Request.Query.SeriesId.HasValue) - { - throw new BadRequestException("seriesId is missing"); - } - - var seriesId = (int)Request.Query.SeriesId; - - var series = _seriesService.GetSeries(seriesId); - - return _mediaFileService.GetFilesBySeries(seriesId).ConvertAll(f => f.ToResource(series, _qualityUpgradableSpecification)); - } - - private void SetQuality(EpisodeFileResource episodeFileResource) - { - var episodeFile = _mediaFileService.Get(episodeFileResource.Id); - episodeFile.Quality = episodeFileResource.Quality; - _mediaFileService.Update(episodeFile); - } - - private void DeleteEpisodeFile(int id) - { - var episodeFile = _mediaFileService.Get(id); - var series = _seriesService.GetSeries(episodeFile.SeriesId); - var fullPath = Path.Combine(series.Path, episodeFile.RelativePath); - - _logger.Info("Deleting episode file: {0}", fullPath); - _recycleBinProvider.DeleteFile(fullPath); - _mediaFileService.Delete(episodeFile, DeleteMediaFileReason.Manual); - } - - public void Handle(EpisodeFileAddedEvent message) - { - BroadcastResourceChange(ModelAction.Updated, message.EpisodeFile.Id); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/EpisodeFiles/EpisodeFileResource.cs b/src/NzbDrone.Api/EpisodeFiles/EpisodeFileResource.cs deleted file mode 100644 index bd856776d..000000000 --- a/src/NzbDrone.Api/EpisodeFiles/EpisodeFileResource.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System; -using System.IO; -using NzbDrone.Api.REST; -using NzbDrone.Core.Qualities; - -namespace NzbDrone.Api.EpisodeFiles -{ - public class EpisodeFileResource : RestResource - { - public int SeriesId { get; set; } - public int SeasonNumber { get; set; } - public string RelativePath { get; set; } - public string Path { get; set; } - public long Size { get; set; } - public DateTime DateAdded { get; set; } - public string SceneName { get; set; } - public QualityModel Quality { get; set; } - - public bool QualityCutoffNotMet { get; set; } - } - - public static class EpisodeFileResourceMapper - { - private static EpisodeFileResource ToResource(this Core.MediaFiles.EpisodeFile model) - { - if (model == null) return null; - - return new EpisodeFileResource - { - Id = model.Id, - - SeriesId = model.SeriesId, - SeasonNumber = model.SeasonNumber, - RelativePath = model.RelativePath, - //Path - Size = model.Size, - DateAdded = model.DateAdded, - SceneName = model.SceneName, - Quality = model.Quality, - //QualityCutoffNotMet - }; - } - - public static EpisodeFileResource ToResource(this Core.MediaFiles.EpisodeFile model, Core.Tv.Series series, Core.DecisionEngine.IQualityUpgradableSpecification qualityUpgradableSpecification) - { - if (model == null) return null; - - return new EpisodeFileResource - { - Id = model.Id, - - SeriesId = model.SeriesId, - SeasonNumber = model.SeasonNumber, - RelativePath = model.RelativePath, - Path = Path.Combine(series.Path, model.RelativePath), - Size = model.Size, - DateAdded = model.DateAdded, - SceneName = model.SceneName, - Quality = model.Quality, - QualityCutoffNotMet = qualityUpgradableSpecification.CutoffNotMet(series.Profile.Value, model.Quality) - }; - } - } -} diff --git a/src/NzbDrone.Api/Episodes/EpisodeModule.cs b/src/NzbDrone.Api/Episodes/EpisodeModule.cs deleted file mode 100644 index 7f6f5692c..000000000 --- a/src/NzbDrone.Api/Episodes/EpisodeModule.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Api.REST; -using NzbDrone.Core.Tv; -using NzbDrone.Core.DecisionEngine; -using NzbDrone.SignalR; - -namespace NzbDrone.Api.Episodes -{ - public class EpisodeModule : EpisodeModuleWithSignalR - { - public EpisodeModule(ISeriesService seriesService, - IEpisodeService episodeService, - IQualityUpgradableSpecification qualityUpgradableSpecification, - IBroadcastSignalRMessage signalRBroadcaster) - : base(episodeService, seriesService, qualityUpgradableSpecification, signalRBroadcaster) - { - GetResourceAll = GetEpisodes; - UpdateResource = SetMonitored; - } - - private List GetEpisodes() - { - if (!Request.Query.SeriesId.HasValue) - { - throw new BadRequestException("seriesId is missing"); - } - - var seriesId = (int)Request.Query.SeriesId; - - var resources = MapToResource(_episodeService.GetEpisodeBySeries(seriesId), false, true); - - return resources; - } - - private void SetMonitored(EpisodeResource episodeResource) - { - _episodeService.SetEpisodeMonitored(episodeResource.Id, episodeResource.Monitored); - } - } -} diff --git a/src/NzbDrone.Api/Episodes/EpisodeModuleWithSignalR.cs b/src/NzbDrone.Api/Episodes/EpisodeModuleWithSignalR.cs deleted file mode 100644 index d4c1deb27..000000000 --- a/src/NzbDrone.Api/Episodes/EpisodeModuleWithSignalR.cs +++ /dev/null @@ -1,126 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Common.Extensions; -using NzbDrone.Api.EpisodeFiles; -using NzbDrone.Api.Series; -using NzbDrone.Core.Datastore.Events; -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Download; -using NzbDrone.Core.MediaFiles.Events; -using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Tv; -using NzbDrone.SignalR; - -namespace NzbDrone.Api.Episodes -{ - public abstract class EpisodeModuleWithSignalR : NzbDroneRestModuleWithSignalR, - IHandle, - IHandle - { - protected readonly IEpisodeService _episodeService; - protected readonly ISeriesService _seriesService; - protected readonly IQualityUpgradableSpecification _qualityUpgradableSpecification; - - protected EpisodeModuleWithSignalR(IEpisodeService episodeService, - ISeriesService seriesService, - IQualityUpgradableSpecification qualityUpgradableSpecification, - IBroadcastSignalRMessage signalRBroadcaster) - : base(signalRBroadcaster) - { - _episodeService = episodeService; - _seriesService = seriesService; - _qualityUpgradableSpecification = qualityUpgradableSpecification; - - GetResourceById = GetEpisode; - } - - protected EpisodeModuleWithSignalR(IEpisodeService episodeService, - ISeriesService seriesService, - IQualityUpgradableSpecification qualityUpgradableSpecification, - IBroadcastSignalRMessage signalRBroadcaster, - string resource) - : base(signalRBroadcaster, resource) - { - _episodeService = episodeService; - _seriesService = seriesService; - _qualityUpgradableSpecification = qualityUpgradableSpecification; - - GetResourceById = GetEpisode; - } - - protected EpisodeResource GetEpisode(int id) - { - var episode = _episodeService.GetEpisode(id); - var resource = MapToResource(episode, true, true); - return resource; - } - - protected EpisodeResource MapToResource(Episode episode, bool includeSeries, bool includeEpisodeFile) - { - var resource = episode.ToResource(); - - if (includeSeries || includeEpisodeFile) - { - var series = episode.Series ?? _seriesService.GetSeries(episode.SeriesId); - - if (includeSeries) - { - resource.Series = series.ToResource(); - } - if (includeEpisodeFile && episode.EpisodeFileId != 0) - { - resource.EpisodeFile = episode.EpisodeFile.Value.ToResource(series, _qualityUpgradableSpecification); - } - } - - return resource; - } - - protected List MapToResource(List episodes, bool includeSeries, bool includeEpisodeFile) - { - var result = episodes.ToResource(); - - if (includeSeries || includeEpisodeFile) - { - var seriesDict = new Dictionary(); - for (var i = 0; i < episodes.Count; i++) - { - var episode = episodes[i]; - var resource = result[i]; - - var series = episode.Series ?? seriesDict.GetValueOrDefault(episodes[i].SeriesId) ?? _seriesService.GetSeries(episodes[i].SeriesId); - seriesDict[series.Id] = series; - - if (includeSeries) - { - resource.Series = series.ToResource(); - } - if (includeEpisodeFile && episodes[i].EpisodeFileId != 0) - { - resource.EpisodeFile = episodes[i].EpisodeFile.Value.ToResource(series, _qualityUpgradableSpecification); - } - } - } - - return result; - } - - public void Handle(EpisodeGrabbedEvent message) - { - foreach (var episode in message.Episode.Episodes) - { - var resource = episode.ToResource(); - resource.Grabbed = true; - - BroadcastResourceChange(ModelAction.Updated, resource); - } - } - - public void Handle(EpisodeDownloadedEvent message) - { - foreach (var episode in message.Episode.Episodes) - { - BroadcastResourceChange(ModelAction.Updated, episode.Id); - } - } - } -} diff --git a/src/NzbDrone.Api/Episodes/EpisodeResource.cs b/src/NzbDrone.Api/Episodes/EpisodeResource.cs deleted file mode 100644 index 3ff489f38..000000000 --- a/src/NzbDrone.Api/Episodes/EpisodeResource.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Newtonsoft.Json; -using NzbDrone.Api.EpisodeFiles; -using NzbDrone.Api.REST; -using NzbDrone.Api.Series; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Api.Episodes -{ - public class EpisodeResource : RestResource - { - public int SeriesId { get; set; } - public int EpisodeFileId { get; set; } - public int SeasonNumber { get; set; } - public int EpisodeNumber { get; set; } - public string Title { get; set; } - public string AirDate { get; set; } - public DateTime? AirDateUtc { get; set; } - public string Overview { get; set; } - public EpisodeFileResource EpisodeFile { get; set; } - - public bool HasFile { get; set; } - public bool Monitored { get; set; } - public int? AbsoluteEpisodeNumber { get; set; } - public int? SceneAbsoluteEpisodeNumber { get; set; } - public int? SceneEpisodeNumber { get; set; } - public int? SceneSeasonNumber { get; set; } - public bool UnverifiedSceneNumbering { get; set; } - public string SeriesTitle { get; set; } - public SeriesResource Series { get; set; } - - //Hiding this so people don't think its usable (only used to set the initial state) - [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] - public bool Grabbed { get; set; } - } - - public static class EpisodeResourceMapper - { - public static EpisodeResource ToResource(this Episode model) - { - if (model == null) return null; - - return new EpisodeResource - { - Id = model.Id, - - SeriesId = model.SeriesId, - EpisodeFileId = model.EpisodeFileId, - SeasonNumber = model.SeasonNumber, - EpisodeNumber = model.EpisodeNumber, - Title = model.Title, - AirDate = model.AirDate, - AirDateUtc = model.AirDateUtc, - Overview = model.Overview, - //EpisodeFile - - HasFile = model.HasFile, - Monitored = model.Monitored, - AbsoluteEpisodeNumber = model.AbsoluteEpisodeNumber, - SceneAbsoluteEpisodeNumber = model.SceneAbsoluteEpisodeNumber, - SceneEpisodeNumber = model.SceneEpisodeNumber, - SceneSeasonNumber = model.SceneSeasonNumber, - UnverifiedSceneNumbering = model.UnverifiedSceneNumbering, - SeriesTitle = model.SeriesTitle, - //Series = model.Series.MapToResource(), - }; - } - - public static List ToResource(this IEnumerable models) - { - if (models == null) return null; - - return models.Select(ToResource).ToList(); - } - } -} diff --git a/src/NzbDrone.Api/Episodes/RenameEpisodeModule.cs b/src/NzbDrone.Api/Episodes/RenameEpisodeModule.cs deleted file mode 100644 index 87f39b964..000000000 --- a/src/NzbDrone.Api/Episodes/RenameEpisodeModule.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Api.REST; -using NzbDrone.Core.MediaFiles; - -namespace NzbDrone.Api.Episodes -{ - public class RenameEpisodeModule : NzbDroneRestModule - { - private readonly IRenameEpisodeFileService _renameEpisodeFileService; - - public RenameEpisodeModule(IRenameEpisodeFileService renameEpisodeFileService) - : base("rename") - { - _renameEpisodeFileService = renameEpisodeFileService; - - GetResourceAll = GetEpisodes; - } - - private List GetEpisodes() - { - if (!Request.Query.SeriesId.HasValue) - { - throw new BadRequestException("seriesId is missing"); - } - - var seriesId = (int)Request.Query.SeriesId; - - if (Request.Query.SeasonNumber.HasValue) - { - var seasonNumber = (int)Request.Query.SeasonNumber; - return _renameEpisodeFileService.GetRenamePreviews(seriesId, seasonNumber).ToResource(); - } - - return _renameEpisodeFileService.GetRenamePreviews(seriesId).ToResource(); - } - } -} diff --git a/src/NzbDrone.Api/Episodes/RenameEpisodeResource.cs b/src/NzbDrone.Api/Episodes/RenameEpisodeResource.cs deleted file mode 100644 index c48f2cdf4..000000000 --- a/src/NzbDrone.Api/Episodes/RenameEpisodeResource.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NzbDrone.Api.REST; - -namespace NzbDrone.Api.Episodes -{ - public class RenameEpisodeResource : RestResource - { - public int SeriesId { get; set; } - public int SeasonNumber { get; set; } - public List EpisodeNumbers { get; set; } - public int EpisodeFileId { get; set; } - public string ExistingPath { get; set; } - public string NewPath { get; set; } - } - - public static class RenameEpisodeResourceMapper - { - public static RenameEpisodeResource ToResource(this Core.MediaFiles.RenameEpisodeFilePreview model) - { - if (model == null) return null; - - return new RenameEpisodeResource - { - SeriesId = model.SeriesId, - SeasonNumber = model.SeasonNumber, - EpisodeNumbers = model.EpisodeNumbers.ToList(), - EpisodeFileId = model.EpisodeFileId, - ExistingPath = model.ExistingPath, - NewPath = model.NewPath - }; - } - - public static List ToResource(this IEnumerable models) - { - return models.Select(ToResource).ToList(); - } - } -} diff --git a/src/NzbDrone.Api/ErrorManagement/NzbDroneErrorPipeline.cs b/src/NzbDrone.Api/ErrorManagement/NzbDroneErrorPipeline.cs deleted file mode 100644 index d98925f8e..000000000 --- a/src/NzbDrone.Api/ErrorManagement/NzbDroneErrorPipeline.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.Data.SQLite; -using FluentValidation; -using NLog; -using Nancy; -using NzbDrone.Api.Extensions; -using NzbDrone.Core.Exceptions; -using HttpStatusCode = Nancy.HttpStatusCode; - -namespace NzbDrone.Api.ErrorManagement -{ - public class NzbDroneErrorPipeline - { - private readonly Logger _logger; - - public NzbDroneErrorPipeline(Logger logger) - { - _logger = logger; - } - - public Response HandleException(NancyContext context, Exception exception) - { - _logger.Trace("Handling Exception"); - - var apiException = exception as ApiException; - - if (apiException != null) - { - _logger.Warn(apiException, "API Error"); - return apiException.ToErrorResponse(); - } - - var validationException = exception as ValidationException; - - if (validationException != null) - { - _logger.Warn("Invalid request {0}", validationException.Message); - - return validationException.Errors.AsResponse(HttpStatusCode.BadRequest); - } - - var clientException = exception as NzbDroneClientException; - - if (clientException != null) - { - return new ErrorModel - { - Message = exception.Message, - Description = exception.ToString() - }.AsResponse((HttpStatusCode)clientException.StatusCode); - } - - var sqLiteException = exception as SQLiteException; - - if (sqLiteException != null) - { - if (context.Request.Method == "PUT" || context.Request.Method == "POST") - { - if (sqLiteException.Message.Contains("constraint failed")) - return new ErrorModel - { - Message = exception.Message, - }.AsResponse(HttpStatusCode.Conflict); - } - - _logger.Error(sqLiteException, "[{0} {1}]", context.Request.Method, context.Request.Path); - } - - _logger.Fatal(exception, "Request Failed. {0} {1}", context.Request.Method, context.Request.Path); - - return new ErrorModel - { - Message = exception.Message, - Description = exception.ToString() - }.AsResponse(HttpStatusCode.InternalServerError); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Extensions/Pipelines/CorsPipeline.cs b/src/NzbDrone.Api/Extensions/Pipelines/CorsPipeline.cs deleted file mode 100644 index b8c83298a..000000000 --- a/src/NzbDrone.Api/Extensions/Pipelines/CorsPipeline.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using System.Linq; -using Nancy; -using Nancy.Bootstrapper; - -namespace NzbDrone.Api.Extensions.Pipelines -{ - public class CorsPipeline : IRegisterNancyPipeline - { - public int Order => 0; - - public void Register(IPipelines pipelines) - { - pipelines.AfterRequest.AddItemToEndOfPipeline((Action) Handle); - } - - private void Handle(NancyContext context) - { - if (context == null || context.Response.Headers.ContainsKey(AccessControlHeaders.AllowOrigin)) - { - return; - } - - ApplyResponseHeaders(context.Response, context.Request); - } - - private static void ApplyResponseHeaders(Response response, Request request) - { - var allowedMethods = "GET, OPTIONS, PATCH, POST, PUT, DELETE"; - - if (response.Headers.ContainsKey("Allow")) - { - allowedMethods = response.Headers["Allow"]; - } - - 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()) - { - response.Headers.Add(AccessControlHeaders.AllowHeaders, requestedHeaders); - } - } - } -} diff --git a/src/NzbDrone.Api/Extensions/Pipelines/GZipPipeline.cs b/src/NzbDrone.Api/Extensions/Pipelines/GZipPipeline.cs deleted file mode 100644 index 12293f23c..000000000 --- a/src/NzbDrone.Api/Extensions/Pipelines/GZipPipeline.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System; -using System.IO; -using System.IO.Compression; -using System.Linq; -using Nancy; -using Nancy.Bootstrapper; -using NLog; -using NzbDrone.Common.Extensions; - -namespace NzbDrone.Api.Extensions.Pipelines -{ - public class GzipCompressionPipeline : IRegisterNancyPipeline - { - private readonly Logger _logger; - - public int Order => 0; - - public GzipCompressionPipeline(Logger logger) - { - _logger = logger; - } - - public void Register(IPipelines pipelines) - { - pipelines.AfterRequest.AddItemToEndOfPipeline(CompressResponse); - } - - private void CompressResponse(NancyContext context) - { - var request = context.Request; - var response = context.Response; - - try - { - if ( - !response.ContentType.Contains("image") - && !response.ContentType.Contains("font") - && request.Headers.AcceptEncoding.Any(x => x.Contains("gzip")) - && !AlreadyGzipEncoded(response) - && !ContentLengthIsTooSmall(response)) - { - var contents = response.Contents; - - response.Headers["Content-Encoding"] = "gzip"; - response.Contents = responseStream => - { - using (var gzip = new GZipStream(responseStream, CompressionMode.Compress, true)) - using (var buffered = new BufferedStream(gzip, 8192)) - { - contents.Invoke(buffered); - } - }; - } - } - - catch (Exception ex) - { - _logger.Error(ex, "Unable to gzip response"); - throw; - } - } - - private static bool ContentLengthIsTooSmall(Response response) - { - var contentLength = response.Headers.GetValueOrDefault("Content-Length"); - if (contentLength != null && long.Parse(contentLength) < 1024) - { - return true; - } - return false; - } - - private static bool AlreadyGzipEncoded(Response response) - { - var contentEncoding = response.Headers.GetValueOrDefault("Content-Encoding"); - if (contentEncoding == "gzip") - { - return true; - } - return false; - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Extensions/Pipelines/NzbDroneVersionPipeline.cs b/src/NzbDrone.Api/Extensions/Pipelines/NzbDroneVersionPipeline.cs deleted file mode 100644 index 00488657b..000000000 --- a/src/NzbDrone.Api/Extensions/Pipelines/NzbDroneVersionPipeline.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using Nancy; -using Nancy.Bootstrapper; -using NzbDrone.Common.EnvironmentInfo; - -namespace NzbDrone.Api.Extensions.Pipelines -{ - public class NzbDroneVersionPipeline : IRegisterNancyPipeline - { - public int Order => 0; - - public void Register(IPipelines pipelines) - { - pipelines.AfterRequest.AddItemToStartOfPipeline((Action) Handle); - } - - private void Handle(NancyContext context) - { - if (!context.Response.Headers.ContainsKey("X-ApplicationVersion")) - { - context.Response.Headers.Add("X-ApplicationVersion", BuildInfo.Version.ToString()); - } - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Extensions/RequestExtensions.cs b/src/NzbDrone.Api/Extensions/RequestExtensions.cs deleted file mode 100644 index 6c112c900..000000000 --- a/src/NzbDrone.Api/Extensions/RequestExtensions.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using Nancy; - -namespace NzbDrone.Api.Extensions -{ - public static class RequestExtensions - { - public static bool IsApiRequest(this Request request) - { - return request.Path.StartsWith("/api/", StringComparison.InvariantCultureIgnoreCase); - } - - public static bool IsFeedRequest(this Request request) - { - return request.Path.StartsWith("/feed/", StringComparison.InvariantCultureIgnoreCase); - } - - public static bool IsSignalRRequest(this Request request) - { - return request.Path.StartsWith("/signalr/", StringComparison.InvariantCultureIgnoreCase); - } - - public static bool IsLocalRequest(this Request request) - { - return (request.UserHostAddress.Equals("localhost") || - request.UserHostAddress.Equals("127.0.0.1") || - request.UserHostAddress.Equals("::1")); - } - - public static bool IsLoginRequest(this Request request) - { - return request.Path.Equals("/login", StringComparison.InvariantCultureIgnoreCase); - } - - public static bool IsContentRequest(this Request request) - { - return request.Path.StartsWith("/Content/", StringComparison.InvariantCultureIgnoreCase); - } - } -} diff --git a/src/NzbDrone.Api/FileSystem/FileSystemModule.cs b/src/NzbDrone.Api/FileSystem/FileSystemModule.cs deleted file mode 100644 index 67c2be7bd..000000000 --- a/src/NzbDrone.Api/FileSystem/FileSystemModule.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using Nancy; -using NzbDrone.Api.Extensions; -using NzbDrone.Common.Disk; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.MediaFiles; - -namespace NzbDrone.Api.FileSystem -{ - public class FileSystemModule : NzbDroneApiModule - { - private readonly IFileSystemLookupService _fileSystemLookupService; - private readonly IDiskProvider _diskProvider; - private readonly IDiskScanService _diskScanService; - - public FileSystemModule(IFileSystemLookupService fileSystemLookupService, - IDiskProvider diskProvider, - IDiskScanService diskScanService) - : base("/filesystem") - { - _fileSystemLookupService = fileSystemLookupService; - _diskProvider = diskProvider; - _diskScanService = diskScanService; - Get["/"] = x => GetContents(); - Get["/type"] = x => GetEntityType(); - Get["/mediafiles"] = x => GetMediaFiles(); - } - - private Response GetContents() - { - var pathQuery = Request.Query.path; - var includeFilesQuery = Request.Query.includeFiles; - bool includeFiles = false; - - if (includeFilesQuery.HasValue) - { - includeFiles = Convert.ToBoolean(includeFilesQuery.Value); - } - - return _fileSystemLookupService.LookupContents((string)pathQuery.Value, includeFiles).AsResponse(); - } - - private Response GetEntityType() - { - var pathQuery = Request.Query.path; - var path = (string)pathQuery.Value; - - if (_diskProvider.FileExists(path)) - { - return new { type = "file" }.AsResponse(); - } - - //Return folder even if it doesn't exist on disk to avoid leaking anything from the UI about the underlying system - return new { type = "folder" }.AsResponse(); - } - - private Response GetMediaFiles() - { - var pathQuery = Request.Query.path; - var path = (string)pathQuery.Value; - - if (!_diskProvider.FolderExists(path)) - { - return new string[0].AsResponse(); - } - - return _diskScanService.GetVideoFiles(path).Select(f => new { - Path = f, - RelativePath = path.GetRelativePath(f), - Name = Path.GetFileName(f) - }).AsResponse(); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Frontend/Mappers/BackupFileMapper.cs b/src/NzbDrone.Api/Frontend/Mappers/BackupFileMapper.cs deleted file mode 100644 index 9e4912524..000000000 --- a/src/NzbDrone.Api/Frontend/Mappers/BackupFileMapper.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.IO; -using NLog; -using NzbDrone.Common.Disk; -using NzbDrone.Common.EnvironmentInfo; -using NzbDrone.Common.Extensions; - -namespace NzbDrone.Api.Frontend.Mappers -{ - public class BackupFileMapper : StaticResourceMapperBase - { - private readonly IAppFolderInfo _appFolderInfo; - - public BackupFileMapper(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, Logger logger) - : base(diskProvider, logger) - { - _appFolderInfo = appFolderInfo; - } - - public override string Map(string resourceUrl) - { - var path = resourceUrl.Replace("/backup/", "").Replace('/', Path.DirectorySeparatorChar); - - return Path.Combine(_appFolderInfo.GetBackupFolder(), path); - } - - public override bool CanHandle(string resourceUrl) - { - return resourceUrl.StartsWith("/backup/") && resourceUrl.ContainsIgnoreCase("nzbdrone_backup_") && resourceUrl.EndsWith(".zip"); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Frontend/Mappers/IndexHtmlMapper.cs b/src/NzbDrone.Api/Frontend/Mappers/IndexHtmlMapper.cs deleted file mode 100644 index ae66b2aa2..000000000 --- a/src/NzbDrone.Api/Frontend/Mappers/IndexHtmlMapper.cs +++ /dev/null @@ -1,116 +0,0 @@ -using System; -using System.IO; -using System.Text.RegularExpressions; -using Nancy; -using NLog; -using NzbDrone.Common.Disk; -using NzbDrone.Common.EnvironmentInfo; -using NzbDrone.Core.Analytics; -using NzbDrone.Core.Configuration; - -namespace NzbDrone.Api.Frontend.Mappers -{ - public class IndexHtmlMapper : StaticResourceMapperBase - { - private readonly IDiskProvider _diskProvider; - private readonly IConfigFileProvider _configFileProvider; - 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 string API_KEY; - private static string URL_BASE; - private string _generatedContent - ; - - public IndexHtmlMapper(IAppFolderInfo appFolderInfo, - IDiskProvider diskProvider, - IConfigFileProvider configFileProvider, - IAnalyticsService analyticsService, - Func cacheBreakProviderFactory, - Logger logger) - : base(diskProvider, logger) - { - _diskProvider = diskProvider; - _configFileProvider = configFileProvider; - _analyticsService = analyticsService; - _cacheBreakProviderFactory = cacheBreakProviderFactory; - _indexPath = Path.Combine(appFolderInfo.StartUpFolder, _configFileProvider.UiFolder, "index.html"); - - API_KEY = configFileProvider.ApiKey; - URL_BASE = configFileProvider.UrlBase; - } - - public override string Map(string resourceUrl) - { - return _indexPath; - } - - public override bool CanHandle(string resourceUrl) - { - return !resourceUrl.Contains(".") && !resourceUrl.StartsWith("/login"); - } - - public override Response GetResponse(string resourceUrl) - { - var response = base.GetResponse(resourceUrl); - response.Headers["X-UA-Compatible"] = "IE=edge"; - - return response; - } - - protected override Stream GetContentStream(string filePath) - { - var text = GetIndexText(); - - var stream = new MemoryStream(); - var writer = new StreamWriter(stream); - writer.Write(text); - writer.Flush(); - stream.Position = 0; - return stream; - } - - private string GetIndexText() - { - if (RuntimeInfo.IsProduction && _generatedContent != null) - { - return _generatedContent; - } - - var text = _diskProvider.ReadAllText(_indexPath); - - var cacheBreakProvider = _cacheBreakProviderFactory(); - - text = ReplaceRegex.Replace(text, match => - { - string url; - - if (match.Groups["nohash"].Success) - { - url = match.Groups["path"].Value; - } - - else - { - url = cacheBreakProvider.AddCacheBreakerToPath(match.Groups["path"].Value); - } - - return string.Format("{0}=\"{1}{2}\"", match.Groups["attribute"].Value, URL_BASE, url); - }); - - text = text.Replace("API_ROOT", URL_BASE + "/api"); - text = text.Replace("API_KEY", API_KEY); - text = text.Replace("APP_VERSION", BuildInfo.Version.ToString()); - text = text.Replace("APP_BRANCH", _configFileProvider.Branch.ToLower()); - text = text.Replace("APP_ANALYTICS", _analyticsService.IsEnabled.ToString().ToLowerInvariant()); - text = text.Replace("URL_BASE", URL_BASE); - text = text.Replace("PRODUCTION", RuntimeInfo.IsProduction.ToString().ToLowerInvariant()); - - _generatedContent = text; - - return _generatedContent; - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Frontend/Mappers/LoginHtmlMapper.cs b/src/NzbDrone.Api/Frontend/Mappers/LoginHtmlMapper.cs deleted file mode 100644 index 974e117f9..000000000 --- a/src/NzbDrone.Api/Frontend/Mappers/LoginHtmlMapper.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System; -using System.IO; -using System.Text.RegularExpressions; -using Nancy; -using NLog; -using NzbDrone.Common.Disk; -using NzbDrone.Common.EnvironmentInfo; -using NzbDrone.Core.Configuration; - -namespace NzbDrone.Api.Frontend.Mappers -{ - public class LoginHtmlMapper : StaticResourceMapperBase - { - private readonly IDiskProvider _diskProvider; - private readonly IConfigFileProvider _configFileProvider; - private readonly Func _cacheBreakProviderFactory; - private readonly string _indexPath; - private static readonly Regex ReplaceRegex = new Regex("(?<=(?:href|src|data-main)=\").*?(?=\")", RegexOptions.Compiled | RegexOptions.IgnoreCase); - - private static string URL_BASE; - private string _generatedContent; - - public LoginHtmlMapper(IAppFolderInfo appFolderInfo, - IDiskProvider diskProvider, - IConfigFileProvider configFileProvider, - Func cacheBreakProviderFactory, - Logger logger) - : base(diskProvider, logger) - { - _diskProvider = diskProvider; - _configFileProvider = configFileProvider; - _cacheBreakProviderFactory = cacheBreakProviderFactory; - _indexPath = Path.Combine(appFolderInfo.StartUpFolder, _configFileProvider.UiFolder, "login.html"); - - URL_BASE = configFileProvider.UrlBase; - } - - public override string Map(string resourceUrl) - { - return _indexPath; - } - - public override bool CanHandle(string resourceUrl) - { - return resourceUrl.StartsWith("/login"); - } - - public override Response GetResponse(string resourceUrl) - { - var response = base.GetResponse(resourceUrl); - response.Headers["X-UA-Compatible"] = "IE=edge"; - - return response; - } - - protected override Stream GetContentStream(string filePath) - { - var text = GetLoginText(); - - var stream = new MemoryStream(); - var writer = new StreamWriter(stream); - writer.Write(text); - writer.Flush(); - stream.Position = 0; - return stream; - } - - private string GetLoginText() - { - if (RuntimeInfo.IsProduction && _generatedContent != null) - { - return _generatedContent; - } - - var text = _diskProvider.ReadAllText(_indexPath); - - var cacheBreakProvider = _cacheBreakProviderFactory(); - - text = ReplaceRegex.Replace(text, match => - { - var url = cacheBreakProvider.AddCacheBreakerToPath(match.Value); - return URL_BASE + url; - }); - - _generatedContent = text; - - return _generatedContent; - } - } -} diff --git a/src/NzbDrone.Api/Frontend/Mappers/StaticResourceMapper.cs b/src/NzbDrone.Api/Frontend/Mappers/StaticResourceMapper.cs deleted file mode 100644 index 61ed14e9b..000000000 --- a/src/NzbDrone.Api/Frontend/Mappers/StaticResourceMapper.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.IO; -using NLog; -using NzbDrone.Common.Disk; -using NzbDrone.Common.EnvironmentInfo; -using NzbDrone.Core.Configuration; - -namespace NzbDrone.Api.Frontend.Mappers -{ - public class StaticResourceMapper : StaticResourceMapperBase - { - private readonly IAppFolderInfo _appFolderInfo; - private readonly IConfigFileProvider _configFileProvider; - - public StaticResourceMapper(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, IConfigFileProvider configFileProvider, Logger logger) - : base(diskProvider, logger) - { - _appFolderInfo = appFolderInfo; - _configFileProvider = configFileProvider; - } - - public override string Map(string resourceUrl) - { - var path = resourceUrl.Replace('/', Path.DirectorySeparatorChar); - path = path.Trim(Path.DirectorySeparatorChar); - - return Path.Combine(_appFolderInfo.StartUpFolder, _configFileProvider.UiFolder, path); - } - - public override bool CanHandle(string resourceUrl) - { - return resourceUrl.StartsWith("/Content") || - resourceUrl.EndsWith(".js") || - resourceUrl.EndsWith(".map") || - resourceUrl.EndsWith(".css") || - (resourceUrl.EndsWith(".ico") && !resourceUrl.Equals("/favicon.ico")) || - resourceUrl.EndsWith(".swf") || - resourceUrl.EndsWith("oauth.html"); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Frontend/StaticResourceModule.cs b/src/NzbDrone.Api/Frontend/StaticResourceModule.cs deleted file mode 100644 index 7ec5fe9d8..000000000 --- a/src/NzbDrone.Api/Frontend/StaticResourceModule.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Nancy.Responses; -using NLog; -using Nancy; -using NzbDrone.Api.Frontend.Mappers; -using NzbDrone.Core.Configuration; - -namespace NzbDrone.Api.Frontend -{ - public class StaticResourceModule : NancyModule - { - private readonly IEnumerable _requestMappers; - private readonly IConfigFileProvider _configFileProvider; - private readonly Logger _logger; - - - public StaticResourceModule(IEnumerable requestMappers, IConfigFileProvider configFileProvider, Logger logger) - { - _requestMappers = requestMappers; - _configFileProvider = configFileProvider; - _logger = logger; - - Get["/{resource*}"] = x => Index(); - Get["/"] = x => Index(); - } - - private Response Index() - { - var path = Request.Url.Path; - - if ( - string.IsNullOrWhiteSpace(path) || - path.StartsWith("/api", StringComparison.CurrentCultureIgnoreCase) || - path.StartsWith("/signalr", StringComparison.CurrentCultureIgnoreCase)) - { - 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) - { - return mapper.GetResponse(path); - } - - _logger.Warn("Couldn't find handler for {0}", path); - - return new NotFoundResponse(); - } - } -} diff --git a/src/NzbDrone.Api/History/HistoryModule.cs b/src/NzbDrone.Api/History/HistoryModule.cs deleted file mode 100644 index d85cf74d8..000000000 --- a/src/NzbDrone.Api/History/HistoryModule.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System; -using Nancy; -using NzbDrone.Api.Episodes; -using NzbDrone.Api.Extensions; -using NzbDrone.Api.Series; -using NzbDrone.Core.Datastore; -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Download; -using NzbDrone.Core.History; - -namespace NzbDrone.Api.History -{ - public class HistoryModule : NzbDroneRestModule - { - private readonly IHistoryService _historyService; - private readonly IQualityUpgradableSpecification _qualityUpgradableSpecification; - private readonly IFailedDownloadService _failedDownloadService; - - public HistoryModule(IHistoryService historyService, - IQualityUpgradableSpecification qualityUpgradableSpecification, - IFailedDownloadService failedDownloadService) - { - _historyService = historyService; - _qualityUpgradableSpecification = qualityUpgradableSpecification; - _failedDownloadService = failedDownloadService; - GetResourcePaged = GetHistory; - - Post["/failed"] = x => MarkAsFailed(); - } - - protected HistoryResource MapToResource(Core.History.History model) - { - var resource = model.ToResource(); - - resource.Series = model.Series.ToResource(); - resource.Episode = model.Episode.ToResource(); - - if (model.Series != null) - { - resource.QualityCutoffNotMet = _qualityUpgradableSpecification.CutoffNotMet(model.Series.Profile.Value, model.Quality); - } - - return resource; - } - - private PagingResource GetHistory(PagingResource pagingResource) - { - var episodeId = Request.Query.EpisodeId; - - var pagingSpec = pagingResource.MapToPagingSpec("date", SortDirection.Descending); - - if (pagingResource.FilterKey == "eventType") - { - var filterValue = (HistoryEventType)Convert.ToInt32(pagingResource.FilterValue); - pagingSpec.FilterExpression = v => v.EventType == filterValue; - } - - if (episodeId.HasValue) - { - int i = (int)episodeId; - pagingSpec.FilterExpression = h => h.EpisodeId == i; - } - - return ApplyToPage(_historyService.Paged, pagingSpec, MapToResource); - } - - private Response MarkAsFailed() - { - var id = (int)Request.Form.Id; - _failedDownloadService.MarkAsFailed(id); - return new object().AsResponse(); - } - } -} diff --git a/src/NzbDrone.Api/History/HistoryResource.cs b/src/NzbDrone.Api/History/HistoryResource.cs deleted file mode 100644 index dba4149dd..000000000 --- a/src/NzbDrone.Api/History/HistoryResource.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System; -using System.Collections.Generic; -using NzbDrone.Api.Episodes; -using NzbDrone.Api.REST; -using NzbDrone.Api.Series; -using NzbDrone.Core.History; -using NzbDrone.Core.Qualities; - - -namespace NzbDrone.Api.History -{ - public class HistoryResource : RestResource - { - public int EpisodeId { get; set; } - public int SeriesId { get; set; } - public string SourceTitle { get; set; } - public QualityModel Quality { get; set; } - public bool QualityCutoffNotMet { get; set; } - public DateTime Date { get; set; } - public string DownloadId { get; set; } - - public HistoryEventType EventType { get; set; } - - public Dictionary Data { get; set; } - - public EpisodeResource Episode { get; set; } - public SeriesResource Series { get; set; } - } - - public static class HistoryResourceMapper - { - public static HistoryResource ToResource(this Core.History.History model) - { - if (model == null) return null; - - return new HistoryResource - { - Id = model.Id, - - EpisodeId = model.EpisodeId, - SeriesId = model.SeriesId, - SourceTitle = model.SourceTitle, - Quality = model.Quality, - //QualityCutoffNotMet - Date = model.Date, - DownloadId = model.DownloadId, - - EventType = model.EventType, - - Data = model.Data - //Episode - //Series - }; - } - } -} diff --git a/src/NzbDrone.Api/Indexers/IndexerModule.cs b/src/NzbDrone.Api/Indexers/IndexerModule.cs deleted file mode 100644 index c66fa7db6..000000000 --- a/src/NzbDrone.Api/Indexers/IndexerModule.cs +++ /dev/null @@ -1,37 +0,0 @@ -using NzbDrone.Core.Indexers; - -namespace NzbDrone.Api.Indexers -{ - public class IndexerModule : ProviderModuleBase - { - public IndexerModule(IndexerFactory indexerFactory) - : base(indexerFactory, "indexer") - { - } - - protected override void MapToResource(IndexerResource resource, IndexerDefinition definition) - { - base.MapToResource(resource, definition); - - resource.EnableRss = definition.EnableRss; - resource.EnableSearch = definition.EnableSearch; - resource.SupportsRss = definition.SupportsRss; - resource.SupportsSearch = definition.SupportsSearch; - resource.Protocol = definition.Protocol; - } - - protected override void MapToModel(IndexerDefinition definition, IndexerResource resource) - { - base.MapToModel(definition, resource); - - definition.EnableRss = resource.EnableRss; - definition.EnableSearch = resource.EnableSearch; - } - - protected override void Validate(IndexerDefinition definition, bool includeWarnings) - { - if (!definition.Enable) return; - base.Validate(definition, includeWarnings); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Indexers/IndexerResource.cs b/src/NzbDrone.Api/Indexers/IndexerResource.cs deleted file mode 100644 index 26bb27cb9..000000000 --- a/src/NzbDrone.Api/Indexers/IndexerResource.cs +++ /dev/null @@ -1,13 +0,0 @@ -using NzbDrone.Core.Indexers; - -namespace NzbDrone.Api.Indexers -{ - public class IndexerResource : ProviderResource - { - public bool EnableRss { get; set; } - public bool EnableSearch { get; set; } - public bool SupportsRss { get; set; } - public bool SupportsSearch { get; set; } - public DownloadProtocol Protocol { get; set; } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Indexers/ReleaseModule.cs b/src/NzbDrone.Api/Indexers/ReleaseModule.cs deleted file mode 100644 index 6a31ec0b9..000000000 --- a/src/NzbDrone.Api/Indexers/ReleaseModule.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System; -using System.Collections.Generic; -using FluentValidation; -using Nancy; -using NLog; -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Download; -using NzbDrone.Core.Exceptions; -using NzbDrone.Core.IndexerSearch; -using NzbDrone.Core.Indexers; -using NzbDrone.Core.Parser.Model; -using Nancy.ModelBinding; -using NzbDrone.Api.Extensions; -using NzbDrone.Common.Cache; -using HttpStatusCode = System.Net.HttpStatusCode; - -namespace NzbDrone.Api.Indexers -{ - public class ReleaseModule : ReleaseModuleBase - { - private readonly IFetchAndParseRss _rssFetcherAndParser; - private readonly ISearchForNzb _nzbSearchService; - private readonly IMakeDownloadDecision _downloadDecisionMaker; - private readonly IPrioritizeDownloadDecision _prioritizeDownloadDecision; - private readonly IDownloadService _downloadService; - private readonly Logger _logger; - - private readonly ICached _remoteEpisodeCache; - - public ReleaseModule(IFetchAndParseRss rssFetcherAndParser, - ISearchForNzb nzbSearchService, - IMakeDownloadDecision downloadDecisionMaker, - IPrioritizeDownloadDecision prioritizeDownloadDecision, - IDownloadService downloadService, - ICacheManager cacheManager, - Logger logger) - { - _rssFetcherAndParser = rssFetcherAndParser; - _nzbSearchService = nzbSearchService; - _downloadDecisionMaker = downloadDecisionMaker; - _prioritizeDownloadDecision = prioritizeDownloadDecision; - _downloadService = downloadService; - _logger = logger; - - GetResourceAll = GetReleases; - Post["/"] = x => DownloadRelease(this.Bind()); - - PostValidator.RuleFor(s => s.DownloadAllowed).Equal(true); - PostValidator.RuleFor(s => s.Guid).NotEmpty(); - - _remoteEpisodeCache = cacheManager.GetCache(GetType(), "remoteEpisodes"); - } - - private Response DownloadRelease(ReleaseResource release) - { - var remoteEpisode = _remoteEpisodeCache.Find(release.Guid); - - if (remoteEpisode == null) - { - _logger.Debug("Couldn't find requested release in cache, cache timeout probably expired."); - - return new NotFoundResponse(); - } - - try - { - _downloadService.DownloadReport(remoteEpisode); - } - catch (ReleaseDownloadException ex) - { - _logger.Error(ex); - throw new NzbDroneClientException(HttpStatusCode.Conflict, "Getting release from indexer failed"); - } - - return release.AsResponse(); - } - - private List GetReleases() - { - if (Request.Query.episodeId != null) - { - return GetEpisodeReleases(Request.Query.episodeId); - } - - return GetRss(); - } - - private List GetEpisodeReleases(int episodeId) - { - try - { - var decisions = _nzbSearchService.EpisodeSearch(episodeId, true); - var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisions(decisions); - - return MapDecisions(prioritizedDecisions); - } - catch (Exception ex) - { - _logger.Error(ex, "Episode search failed"); - } - - return new List(); - } - - private List GetRss() - { - var reports = _rssFetcherAndParser.Fetch(); - var decisions = _downloadDecisionMaker.GetRssDecision(reports); - var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisions(decisions); - - return MapDecisions(prioritizedDecisions); - } - - protected override ReleaseResource MapDecision(DownloadDecision decision, int initialWeight) - { - _remoteEpisodeCache.Set(decision.RemoteEpisode.Release.Guid, decision.RemoteEpisode, TimeSpan.FromMinutes(30)); - return base.MapDecision(decision, initialWeight); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Indexers/ReleaseModuleBase.cs b/src/NzbDrone.Api/Indexers/ReleaseModuleBase.cs deleted file mode 100644 index f6a223475..000000000 --- a/src/NzbDrone.Api/Indexers/ReleaseModuleBase.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Core.DecisionEngine; - -namespace NzbDrone.Api.Indexers -{ - public abstract class ReleaseModuleBase : NzbDroneRestModule - { - protected virtual List MapDecisions(IEnumerable decisions) - { - var result = new List(); - - foreach (var downloadDecision in decisions) - { - var release = MapDecision(downloadDecision, result.Count); - - result.Add(release); - } - - return result; - } - - protected virtual ReleaseResource MapDecision(DownloadDecision decision, int initialWeight) - { - var release = decision.ToResource(); - - release.ReleaseWeight = initialWeight; - - if (decision.RemoteEpisode.Series != null) - { - release.QualityWeight = decision.RemoteEpisode.Series - .Profile.Value - .Items.FindIndex(v => v.Quality == release.Quality.Quality) * 100; - } - - release.QualityWeight += release.Quality.Revision.Real * 10; - release.QualityWeight += release.Quality.Revision.Version; - - return release; - } - } -} diff --git a/src/NzbDrone.Api/Indexers/ReleasePushModule.cs b/src/NzbDrone.Api/Indexers/ReleasePushModule.cs deleted file mode 100644 index c25e45726..000000000 --- a/src/NzbDrone.Api/Indexers/ReleasePushModule.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Nancy; -using Nancy.ModelBinding; -using FluentValidation; -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Download; -using System.Collections.Generic; -using System.Linq; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Api.Extensions; -using NLog; - -namespace NzbDrone.Api.Indexers -{ - class ReleasePushModule : ReleaseModuleBase - { - private readonly IMakeDownloadDecision _downloadDecisionMaker; - private readonly IProcessDownloadDecisions _downloadDecisionProcessor; - private readonly Logger _logger; - - public ReleasePushModule(IMakeDownloadDecision downloadDecisionMaker, - IProcessDownloadDecisions downloadDecisionProcessor, - Logger logger) - { - _downloadDecisionMaker = downloadDecisionMaker; - _downloadDecisionProcessor = downloadDecisionProcessor; - _logger = logger; - - Post["/push"] = x => ProcessRelease(this.Bind()); - - PostValidator.RuleFor(s => s.Title).NotEmpty(); - PostValidator.RuleFor(s => s.DownloadUrl).NotEmpty(); - PostValidator.RuleFor(s => s.Protocol).NotEmpty(); - PostValidator.RuleFor(s => s.PublishDate).NotEmpty(); - } - - private Response ProcessRelease(ReleaseResource release) - { - _logger.Info("Release pushed: {0} - {1}", release.Title, release.DownloadUrl); - - var info = release.ToModel(); - - info.Guid = "PUSH-" + info.DownloadUrl; - - var decisions = _downloadDecisionMaker.GetRssDecision(new List { info }); - _downloadDecisionProcessor.ProcessDecisions(decisions); - - return MapDecisions(decisions).First().AsResponse(); - } - } -} diff --git a/src/NzbDrone.Api/Indexers/ReleaseResource.cs b/src/NzbDrone.Api/Indexers/ReleaseResource.cs deleted file mode 100644 index b951b0fe0..000000000 --- a/src/NzbDrone.Api/Indexers/ReleaseResource.cs +++ /dev/null @@ -1,174 +0,0 @@ -using System; -using System.Collections.Generic; -using Newtonsoft.Json; -using NzbDrone.Api.REST; -using NzbDrone.Core.Parser; -using NzbDrone.Core.Qualities; -using NzbDrone.Core.Indexers; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.DecisionEngine; -using System.Linq; - -namespace NzbDrone.Api.Indexers -{ - public class ReleaseResource : RestResource - { - public string Guid { get; set; } - public QualityModel Quality { get; set; } - public int QualityWeight { get; set; } - public int Age { get; set; } - public double AgeHours { get; set; } - public double AgeMinutes { get; set; } - public long Size { get; set; } - public int IndexerId { get; set; } - public string Indexer { get; set; } - public string ReleaseGroup { get; set; } - public string ReleaseHash { get; set; } - public string Title { get; set; } - public bool FullSeason { get; set; } - public int SeasonNumber { get; set; } - public Language Language { get; set; } - public string AirDate { get; set; } - public string SeriesTitle { get; set; } - public int[] EpisodeNumbers { get; set; } - public int[] AbsoluteEpisodeNumbers { get; set; } - public bool Approved { get; set; } - public bool TemporarilyRejected { get; set; } - public bool Rejected { get; set; } - public int TvdbId { get; set; } - public int TvRageId { get; set; } - public IEnumerable Rejections { get; set; } - public DateTime PublishDate { get; set; } - public string CommentUrl { get; set; } - public string DownloadUrl { get; set; } - public string InfoUrl { get; set; } - public bool DownloadAllowed { get; set; } - public int ReleaseWeight { get; set; } - - - public string MagnetUrl { get; set; } - public string InfoHash { get; set; } - public int? Seeders { get; set; } - public int? Leechers { get; set; } - public DownloadProtocol Protocol { get; set; } - - - // TODO: Remove in v3 - // Used to support the original Release Push implementation - // JsonIgnore so we don't serialize it, but can still parse it - [JsonIgnore] - public DownloadProtocol DownloadProtocol - { - get - { - return Protocol; - } - set - { - if (value > 0 && Protocol == 0) - { - Protocol = value; - } - } - } - - public bool IsDaily { get; set; } - public bool IsAbsoluteNumbering { get; set; } - public bool IsPossibleSpecialEpisode { get; set; } - public bool Special { get; set; } - } - - public static class ReleaseResourceMapper - { - public static ReleaseResource ToResource(this DownloadDecision model) - { - var releaseInfo = model.RemoteEpisode.Release; - var parsedEpisodeInfo = model.RemoteEpisode.ParsedEpisodeInfo; - var remoteEpisode = model.RemoteEpisode; - var torrentInfo = (model.RemoteEpisode.Release as TorrentInfo) ?? new TorrentInfo(); - - // TODO: Clean this mess up. don't mix data from multiple classes, use sub-resources instead? (Got a huge Deja Vu, didn't we talk about this already once?) - return new ReleaseResource - { - Guid = releaseInfo.Guid, - Quality = parsedEpisodeInfo.Quality, - //QualityWeight - Age = releaseInfo.Age, - AgeHours = releaseInfo.AgeHours, - AgeMinutes = releaseInfo.AgeMinutes, - Size = releaseInfo.Size, - IndexerId = releaseInfo.IndexerId, - Indexer = releaseInfo.Indexer, - ReleaseGroup = parsedEpisodeInfo.ReleaseGroup, - ReleaseHash = parsedEpisodeInfo.ReleaseHash, - Title = releaseInfo.Title, - FullSeason = parsedEpisodeInfo.FullSeason, - SeasonNumber = parsedEpisodeInfo.SeasonNumber, - Language = parsedEpisodeInfo.Language, - AirDate = parsedEpisodeInfo.AirDate, - SeriesTitle = parsedEpisodeInfo.SeriesTitle, - EpisodeNumbers = parsedEpisodeInfo.EpisodeNumbers, - AbsoluteEpisodeNumbers = parsedEpisodeInfo.AbsoluteEpisodeNumbers, - Approved = model.Approved, - TemporarilyRejected = model.TemporarilyRejected, - Rejected = model.Rejected, - TvdbId = releaseInfo.TvdbId, - TvRageId = releaseInfo.TvRageId, - Rejections = model.Rejections.Select(r => r.Reason).ToList(), - PublishDate = releaseInfo.PublishDate, - CommentUrl = releaseInfo.CommentUrl, - DownloadUrl = releaseInfo.DownloadUrl, - InfoUrl = releaseInfo.InfoUrl, - DownloadAllowed = remoteEpisode.DownloadAllowed, - //ReleaseWeight - - MagnetUrl = torrentInfo.MagnetUrl, - InfoHash = torrentInfo.InfoHash, - Seeders = torrentInfo.Seeders, - Leechers = (torrentInfo.Peers.HasValue && torrentInfo.Seeders.HasValue) ? (torrentInfo.Peers.Value - torrentInfo.Seeders.Value) : (int?)null, - Protocol = releaseInfo.DownloadProtocol, - - IsDaily = parsedEpisodeInfo.IsDaily, - IsAbsoluteNumbering = parsedEpisodeInfo.IsAbsoluteNumbering, - IsPossibleSpecialEpisode = parsedEpisodeInfo.IsPossibleSpecialEpisode, - Special = parsedEpisodeInfo.Special, - }; - - } - - public static ReleaseInfo ToModel(this ReleaseResource resource) - { - ReleaseInfo model; - - if (resource.Protocol == DownloadProtocol.Torrent) - { - model = new TorrentInfo - { - MagnetUrl = resource.MagnetUrl, - InfoHash = resource.InfoHash, - Seeders = resource.Seeders, - Peers = (resource.Seeders.HasValue && resource.Leechers.HasValue) ? (resource.Seeders + resource.Leechers) : null - }; - } - else - { - model = new ReleaseInfo(); - } - - model.Guid = resource.Guid; - model.Title = resource.Title; - model.Size = resource.Size; - model.DownloadUrl = resource.DownloadUrl; - model.InfoUrl = resource.InfoUrl; - model.CommentUrl = resource.CommentUrl; - model.IndexerId = resource.IndexerId; - model.Indexer = resource.Indexer; - model.DownloadProtocol = resource.DownloadProtocol; - model.TvdbId = resource.TvdbId; - model.TvRageId = resource.TvRageId; - model.PublishDate = resource.PublishDate; - - return model; - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Logs/LogModule.cs b/src/NzbDrone.Api/Logs/LogModule.cs deleted file mode 100644 index 88ead3ec0..000000000 --- a/src/NzbDrone.Api/Logs/LogModule.cs +++ /dev/null @@ -1,52 +0,0 @@ -using NzbDrone.Core.Instrumentation; - -namespace NzbDrone.Api.Logs -{ - public class LogModule : NzbDroneRestModule - { - private readonly ILogService _logService; - - public LogModule(ILogService logService) - { - _logService = logService; - GetResourcePaged = GetLogs; - } - - private PagingResource GetLogs(PagingResource pagingResource) - { - var pageSpec = pagingResource.MapToPagingSpec(); - - if (pageSpec.SortKey == "time") - { - pageSpec.SortKey = "id"; - } - - if (pagingResource.FilterKey == "level") - { - switch (pagingResource.FilterValue) - { - case "Fatal": - pageSpec.FilterExpression = h => h.Level == "Fatal"; - break; - case "Error": - pageSpec.FilterExpression = h => h.Level == "Fatal" || h.Level == "Error"; - break; - case "Warn": - pageSpec.FilterExpression = h => h.Level == "Fatal" || h.Level == "Error" || h.Level == "Warn"; - break; - case "Info": - pageSpec.FilterExpression = h => h.Level == "Fatal" || h.Level == "Error" || h.Level == "Warn" || h.Level == "Info"; - break; - case "Debug": - pageSpec.FilterExpression = h => h.Level == "Fatal" || h.Level == "Error" || h.Level == "Warn" || h.Level == "Info" || h.Level == "Debug"; - break; - case "Trace": - pageSpec.FilterExpression = h => h.Level == "Fatal" || h.Level == "Error" || h.Level == "Warn" || h.Level == "Info" || h.Level == "Debug" || h.Level == "Trace"; - break; - } - } - - return ApplyToPage(_logService.Paged, pageSpec, LogResourceMapper.ToResource); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Logs/LogResource.cs b/src/NzbDrone.Api/Logs/LogResource.cs deleted file mode 100644 index 504a45839..000000000 --- a/src/NzbDrone.Api/Logs/LogResource.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System; -using NzbDrone.Api.REST; - -namespace NzbDrone.Api.Logs -{ - public class LogResource : RestResource - { - public DateTime Time { get; set; } - public string Exception { get; set; } - public string ExceptionType { get; set; } - public string Level { get; set; } - public string Logger { get; set; } - public string Message { get; set; } - } - - public static class LogResourceMapper - { - public static LogResource ToResource(this Core.Instrumentation.Log model) - { - if (model == null) return null; - - return new LogResource - { - Id = model.Id, - - Time = model.Time, - Exception = model.Exception, - ExceptionType = model.ExceptionType, - Level = model.Level, - Logger = model.Logger, - Message = model.Message - }; - } - } -} diff --git a/src/NzbDrone.Api/ManualImport/ManualImportModule.cs b/src/NzbDrone.Api/ManualImport/ManualImportModule.cs deleted file mode 100644 index 024b8e452..000000000 --- a/src/NzbDrone.Api/ManualImport/ManualImportModule.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NzbDrone.Core.MediaFiles.EpisodeImport.Manual; -using NzbDrone.Core.Qualities; - -namespace NzbDrone.Api.ManualImport -{ - public class ManualImportModule : NzbDroneRestModule - { - private readonly IManualImportService _manualImportService; - - public ManualImportModule(IManualImportService manualImportService) - : base("/manualimport") - { - _manualImportService = manualImportService; - - GetResourceAll = GetMediaFiles; - } - - private List GetMediaFiles() - { - var folderQuery = Request.Query.folder; - var folder = (string)folderQuery.Value; - - var downloadIdQuery = Request.Query.downloadId; - var downloadId = (string)downloadIdQuery.Value; - - return _manualImportService.GetMediaFiles(folder, downloadId).ToResource().Select(AddQualityWeight).ToList(); - } - - private ManualImportResource AddQualityWeight(ManualImportResource item) - { - if (item.Quality != null) - { - item.QualityWeight = Quality.DefaultQualityDefinitions.Single(q => q.Quality == item.Quality.Quality).Weight; - item.QualityWeight += item.Quality.Revision.Real * 10; - item.QualityWeight += item.Quality.Revision.Version; - } - - return item; - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/ManualImport/ManualImportResource.cs b/src/NzbDrone.Api/ManualImport/ManualImportResource.cs deleted file mode 100644 index bc7b87408..000000000 --- a/src/NzbDrone.Api/ManualImport/ManualImportResource.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NzbDrone.Api.Episodes; -using NzbDrone.Api.REST; -using NzbDrone.Api.Series; -using NzbDrone.Common.Crypto; -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Qualities; - -namespace NzbDrone.Api.ManualImport -{ - public class ManualImportResource : RestResource - { - public string Path { get; set; } - public string RelativePath { get; set; } - public string Name { get; set; } - public long Size { get; set; } - public SeriesResource Series { get; set; } - public int? SeasonNumber { get; set; } - public List Episodes { get; set; } - public QualityModel Quality { get; set; } - public int QualityWeight { get; set; } - public string DownloadId { get; set; } - public IEnumerable Rejections { get; set; } - } - - public static class ManualImportResourceMapper - { - public static ManualImportResource ToResource(this Core.MediaFiles.EpisodeImport.Manual.ManualImportItem model) - { - if (model == null) return null; - - return new ManualImportResource - { - Id = HashConverter.GetHashInt31(model.Path), - - Path = model.Path, - RelativePath = model.RelativePath, - Name = model.Name, - Size = model.Size, - Series = model.Series.ToResource(), - SeasonNumber = model.SeasonNumber, - Episodes = model.Episodes.ToResource(), - Quality = model.Quality, - //QualityWeight - DownloadId = model.DownloadId, - Rejections = model.Rejections - }; - } - - public static List ToResource(this IEnumerable models) - { - return models.Select(ToResource).ToList(); - } - } -} diff --git a/src/NzbDrone.Api/MediaCovers/MediaCoverModule.cs b/src/NzbDrone.Api/MediaCovers/MediaCoverModule.cs deleted file mode 100644 index a4ad78ef4..000000000 --- a/src/NzbDrone.Api/MediaCovers/MediaCoverModule.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.IO; -using System.Text.RegularExpressions; -using Nancy; -using Nancy.Responses; -using NzbDrone.Common.Disk; -using NzbDrone.Common.EnvironmentInfo; -using NzbDrone.Common.Extensions; - -namespace NzbDrone.Api.MediaCovers -{ - public class MediaCoverModule : NzbDroneApiModule - { - private static readonly Regex RegexResizedImage = new Regex(@"-\d+\.jpg$", RegexOptions.Compiled | RegexOptions.IgnoreCase); - - private const string MEDIA_COVER_ROUTE = @"/(?\d+)/(?(.+)\.(jpg|png|gif))"; - - private readonly IAppFolderInfo _appFolderInfo; - private readonly IDiskProvider _diskProvider; - - public MediaCoverModule(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider) : base("MediaCover") - { - _appFolderInfo = appFolderInfo; - _diskProvider = diskProvider; - - Get[MEDIA_COVER_ROUTE] = options => GetMediaCover(options.seriesId, options.filename); - } - - private Response GetMediaCover(int seriesId, string filename) - { - var filePath = Path.Combine(_appFolderInfo.GetAppDataPath(), "MediaCover", seriesId.ToString(), filename); - - if (!_diskProvider.FileExists(filePath) || _diskProvider.GetFileSize(filePath) == 0) - { - // Return the full sized image if someone requests a non-existing resized one. - // TODO: This code can be removed later once everyone had the update for a while. - var basefilePath = RegexResizedImage.Replace(filePath, ".jpg"); - if (basefilePath == filePath || !_diskProvider.FileExists(basefilePath)) - { - return new NotFoundResponse(); - } - filePath = basefilePath; - } - - return new StreamResponse(() => File.OpenRead(filePath), MimeTypes.GetMimeType(filePath)); - } - } -} diff --git a/src/NzbDrone.Api/Metadata/MetadataModule.cs b/src/NzbDrone.Api/Metadata/MetadataModule.cs deleted file mode 100644 index ab88ab044..000000000 --- a/src/NzbDrone.Api/Metadata/MetadataModule.cs +++ /dev/null @@ -1,32 +0,0 @@ -using NzbDrone.Core.Extras.Metadata; - -namespace NzbDrone.Api.Metadata -{ - public class MetadataModule : ProviderModuleBase - { - public MetadataModule(IMetadataFactory metadataFactory) - : base(metadataFactory, "metadata") - { - } - - protected override void MapToResource(MetadataResource resource, MetadataDefinition definition) - { - base.MapToResource(resource, definition); - - resource.Enable = definition.Enable; - } - - protected override void MapToModel(MetadataDefinition definition, MetadataResource resource) - { - base.MapToModel(definition, resource); - - definition.Enable = resource.Enable; - } - - protected override void Validate(MetadataDefinition definition, bool includeWarnings) - { - if (!definition.Enable) return; - base.Validate(definition, includeWarnings); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Metadata/MetadataResource.cs b/src/NzbDrone.Api/Metadata/MetadataResource.cs deleted file mode 100644 index fa9f58b64..000000000 --- a/src/NzbDrone.Api/Metadata/MetadataResource.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace NzbDrone.Api.Metadata -{ - public class MetadataResource : ProviderResource - { - public bool Enable { get; set; } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/NancyBootstrapper.cs b/src/NzbDrone.Api/NancyBootstrapper.cs deleted file mode 100644 index 1415dd4c2..000000000 --- a/src/NzbDrone.Api/NancyBootstrapper.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Linq; -using Nancy.Bootstrapper; -using Nancy.Diagnostics; -using NLog; -using NzbDrone.Api.Extensions.Pipelines; -using NzbDrone.Common.EnvironmentInfo; -using NzbDrone.Common.Instrumentation; -using NzbDrone.Core.Instrumentation; -using NzbDrone.Core.Lifecycle; -using NzbDrone.Core.Messaging.Events; -using TinyIoC; - -namespace NzbDrone.Api -{ - public class NancyBootstrapper : TinyIoCNancyBootstrapper - { - private readonly TinyIoCContainer _tinyIoCContainer; - private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(NancyBootstrapper)); - - public NancyBootstrapper(TinyIoCContainer tinyIoCContainer) - { - _tinyIoCContainer = tinyIoCContainer; - } - - protected override void ApplicationStartup(TinyIoCContainer container, IPipelines pipelines) - { - Logger.Info("Starting Web Server"); - - if (RuntimeInfo.IsProduction) - { - DiagnosticsHook.Disable(pipelines); - } - - RegisterPipelines(pipelines); - - container.Resolve().Register(); - container.Resolve().PublishEvent(new ApplicationStartedEvent()); - } - - private void RegisterPipelines(IPipelines pipelines) - { - var pipelineRegistrars = _tinyIoCContainer.ResolveAll().OrderBy(v => v.Order).ToList(); - - foreach (var registerNancyPipeline in pipelineRegistrars) - { - registerNancyPipeline.Register(pipelines); - } - } - - protected override TinyIoCContainer GetApplicationContainer() - { - return _tinyIoCContainer; - } - - protected override DiagnosticsConfiguration DiagnosticsConfiguration => new DiagnosticsConfiguration { Password = @"password" }; - - protected override byte[] FavIcon => null; - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Notifications/NotificationModule.cs b/src/NzbDrone.Api/Notifications/NotificationModule.cs deleted file mode 100644 index 88f42a043..000000000 --- a/src/NzbDrone.Api/Notifications/NotificationModule.cs +++ /dev/null @@ -1,48 +0,0 @@ -using NzbDrone.Core.Notifications; - -namespace NzbDrone.Api.Notifications -{ - public class NotificationModule : ProviderModuleBase - { - public NotificationModule(NotificationFactory notificationFactory) - : base(notificationFactory, "notification") - { - } - - protected override void MapToResource(NotificationResource resource, NotificationDefinition definition) - { - base.MapToResource(resource, definition); - - resource.OnGrab = definition.OnGrab; - resource.OnDownload = definition.OnDownload; - resource.OnUpgrade = definition.OnUpgrade; - resource.OnRename = definition.OnRename; - resource.SupportsOnGrab = definition.SupportsOnGrab; - resource.SupportsOnDownload = definition.SupportsOnDownload; - resource.SupportsOnUpgrade = definition.SupportsOnUpgrade; - resource.SupportsOnRename = definition.SupportsOnRename; - resource.Tags = definition.Tags; - } - - protected override void MapToModel(NotificationDefinition definition, NotificationResource resource) - { - base.MapToModel(definition, resource); - - definition.OnGrab = resource.OnGrab; - definition.OnDownload = resource.OnDownload; - definition.OnUpgrade = resource.OnUpgrade; - definition.OnRename = resource.OnRename; - definition.SupportsOnGrab = resource.SupportsOnGrab; - definition.SupportsOnDownload = resource.SupportsOnDownload; - definition.SupportsOnUpgrade = resource.SupportsOnUpgrade; - definition.SupportsOnRename = resource.SupportsOnRename; - definition.Tags = resource.Tags; - } - - protected override void Validate(NotificationDefinition definition, bool includeWarnings) - { - if (!definition.OnGrab && !definition.OnDownload) return; - base.Validate(definition, includeWarnings); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Notifications/NotificationResource.cs b/src/NzbDrone.Api/Notifications/NotificationResource.cs deleted file mode 100644 index f3fa11327..000000000 --- a/src/NzbDrone.Api/Notifications/NotificationResource.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; - -namespace NzbDrone.Api.Notifications -{ - public class NotificationResource : ProviderResource - { - public bool OnGrab { get; set; } - public bool OnDownload { get; set; } - public bool OnUpgrade { get; set; } - public bool OnRename { get; set; } - public bool SupportsOnGrab { get; set; } - public bool SupportsOnDownload { get; set; } - public bool SupportsOnUpgrade { get; set; } - public bool SupportsOnRename { get; set; } - public HashSet Tags { get; set; } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/NzbDrone.Api.csproj b/src/NzbDrone.Api/NzbDrone.Api.csproj deleted file mode 100644 index 7ed96c7e3..000000000 --- a/src/NzbDrone.Api/NzbDrone.Api.csproj +++ /dev/null @@ -1,288 +0,0 @@ - - - - - Debug - x86 - {FD286DF8-2D3A-4394-8AD5-443FADE55FB2} - Library - Properties - NzbDrone.Api - NzbDrone.Api - v4.0 - 512 - ..\ - true - - - 12.0.0 - 2.0 - - - true - ..\..\_output\ - DEBUG;TRACE - full - x86 - prompt - MinimumRecommendedRules.ruleset - 4 - false - - - ..\..\_output\ - TRACE - true - pdbonly - x86 - prompt - MinimumRecommendedRules.ruleset - 4 - - - - ..\packages\Ical.Net.2.2.25\lib\net40\antlr.runtime.dll - True - - - ..\packages\FluentValidation.6.2.1.0\lib\portable-net40+sl50+wp80+win8+wpa81\FluentValidation.dll - True - - - ..\packages\Ical.Net.2.2.25\lib\net40\Ical.Net.dll - True - - - ..\packages\Ical.Net.2.2.25\lib\net40\Ical.Net.Collections.dll - True - - - ..\packages\Nancy.1.4.3\lib\net40\Nancy.dll - True - - - ..\packages\Nancy.Authentication.Basic.1.4.1\lib\net40\Nancy.Authentication.Basic.dll - True - - - ..\packages\Nancy.Authentication.Forms.1.4.1\lib\net40\Nancy.Authentication.Forms.dll - True - - - ..\packages\Newtonsoft.Json.9.0.1\lib\net40\Newtonsoft.Json.dll - True - - - ..\packages\NLog.4.4.1\lib\net40\NLog.dll - True - - - ..\packages\Ical.Net.2.2.25\lib\net40\NodaTime.dll - True - - - - - - - False - ..\Libraries\Sqlite\System.Data.SQLite.dll - - - - - Properties\SharedAssemblyInfo.cs - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Designer - - - - - {F6FC6BE7-0847-4817-A1ED-223DC647C3D7} - Marr.Data - - - {F2BE0FDF-6E47-4827-A420-DD4EF82407F8} - NzbDrone.Common - - - {FF5EE3B6-913B-47CE-9CEB-11C51B4E1205} - NzbDrone.Core - - - {7C2CC69F-5CA0-4E5C-85CB-983F9F6C3B36} - NzbDrone.SignalR - - - - - - \ No newline at end of file diff --git a/src/NzbDrone.Api/NzbDroneApiModule.cs b/src/NzbDrone.Api/NzbDroneApiModule.cs deleted file mode 100644 index ad8131487..000000000 --- a/src/NzbDrone.Api/NzbDroneApiModule.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Nancy; - -namespace NzbDrone.Api -{ - public abstract class NzbDroneApiModule : NancyModule - { - protected NzbDroneApiModule(string resource) - : base("/api/" + resource.Trim('/')) - { - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/NzbDroneFeedModule.cs b/src/NzbDrone.Api/NzbDroneFeedModule.cs deleted file mode 100644 index d79307bef..000000000 --- a/src/NzbDrone.Api/NzbDroneFeedModule.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Nancy; - -namespace NzbDrone.Api -{ - public abstract class NzbDroneFeedModule : NancyModule - { - protected NzbDroneFeedModule(string resource) - : base("/feed/" + resource.Trim('/')) - { - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/NzbDroneRestModule.cs b/src/NzbDrone.Api/NzbDroneRestModule.cs deleted file mode 100644 index 4cc103d95..000000000 --- a/src/NzbDrone.Api/NzbDroneRestModule.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using NzbDrone.Api.REST; -using NzbDrone.Api.Validation; -using NzbDrone.Core.Datastore; - -namespace NzbDrone.Api -{ - public abstract class NzbDroneRestModule : RestModule where TResource : RestResource, new() - { - protected string Resource { get; private set; } - - protected NzbDroneRestModule() - : this(new TResource().ResourceName) - { - } - - protected NzbDroneRestModule(string resource) - : base("/api/" + resource.Trim('/')) - { - Resource = resource; - PostValidator.RuleFor(r => r.Id).IsZero(); - PutValidator.RuleFor(r => r.Id).ValidId(); - } - - protected PagingResource ApplyToPage(Func, PagingSpec> function, PagingSpec pagingSpec, Converter mapper) - { - pagingSpec = function(pagingSpec); - - return new PagingResource - { - Page = pagingSpec.Page, - PageSize = pagingSpec.PageSize, - SortDirection = pagingSpec.SortDirection, - SortKey = pagingSpec.SortKey, - TotalRecords = pagingSpec.TotalRecords, - Records = pagingSpec.Records.ConvertAll(mapper) - }; - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/NzbDroneRestModuleWithSignalR.cs b/src/NzbDrone.Api/NzbDroneRestModuleWithSignalR.cs deleted file mode 100644 index a2061a770..000000000 --- a/src/NzbDrone.Api/NzbDroneRestModuleWithSignalR.cs +++ /dev/null @@ -1,66 +0,0 @@ -using NzbDrone.Api.REST; -using NzbDrone.Core.Datastore; -using NzbDrone.Core.Datastore.Events; -using NzbDrone.Core.Messaging.Events; -using NzbDrone.SignalR; - -namespace NzbDrone.Api -{ - public abstract class NzbDroneRestModuleWithSignalR : NzbDroneRestModule, IHandle> - where TResource : RestResource, new() - where TModel : ModelBase, new() - { - private readonly IBroadcastSignalRMessage _signalRBroadcaster; - - protected NzbDroneRestModuleWithSignalR(IBroadcastSignalRMessage signalRBroadcaster) - { - _signalRBroadcaster = signalRBroadcaster; - } - - protected NzbDroneRestModuleWithSignalR(IBroadcastSignalRMessage signalRBroadcaster, string resource) - : base(resource) - { - _signalRBroadcaster = signalRBroadcaster; - } - - public void Handle(ModelEvent message) - { - if (message.Action == ModelAction.Deleted || message.Action == ModelAction.Sync) - { - BroadcastResourceChange(message.Action); - } - - BroadcastResourceChange(message.Action, message.Model.Id); - } - - protected void BroadcastResourceChange(ModelAction action, int id) - { - var resource = GetResourceById(id); - BroadcastResourceChange(action, resource); - } - - - protected void BroadcastResourceChange(ModelAction action, TResource resource) - { - var signalRMessage = new SignalRMessage - { - Name = Resource, - Body = new ResourceChangeMessage(resource, action) - }; - - _signalRBroadcaster.BroadcastMessage(signalRMessage); - } - - - protected void BroadcastResourceChange(ModelAction action) - { - var signalRMessage = new SignalRMessage - { - Name = Resource, - Body = new ResourceChangeMessage(action) - }; - - _signalRBroadcaster.BroadcastMessage(signalRMessage); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Parse/ParseModule.cs b/src/NzbDrone.Api/Parse/ParseModule.cs deleted file mode 100644 index df36307ff..000000000 --- a/src/NzbDrone.Api/Parse/ParseModule.cs +++ /dev/null @@ -1,50 +0,0 @@ -using NzbDrone.Api.Episodes; -using NzbDrone.Api.Series; -using NzbDrone.Core.Parser; - -namespace NzbDrone.Api.Parse -{ - public class ParseModule : NzbDroneRestModule - { - private readonly IParsingService _parsingService; - - public ParseModule(IParsingService parsingService) - { - _parsingService = parsingService; - - GetResourceSingle = Parse; - } - - private ParseResource Parse() - { - var title = Request.Query.Title.Value as string; - var parsedEpisodeInfo = Parser.ParseTitle(title); - - if (parsedEpisodeInfo == null) - { - return null; - } - - var remoteEpisode = _parsingService.Map(parsedEpisodeInfo, 0, 0); - - if (remoteEpisode != null) - { - return new ParseResource - { - Title = title, - ParsedEpisodeInfo = remoteEpisode.ParsedEpisodeInfo, - Series = remoteEpisode.Series.ToResource(), - Episodes = remoteEpisode.Episodes.ToResource() - }; - } - else - { - return new ParseResource - { - Title = title, - ParsedEpisodeInfo = parsedEpisodeInfo - }; - } - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Parse/ParseResource.cs b/src/NzbDrone.Api/Parse/ParseResource.cs deleted file mode 100644 index c795f09c3..000000000 --- a/src/NzbDrone.Api/Parse/ParseResource.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Api.Episodes; -using NzbDrone.Api.REST; -using NzbDrone.Api.Series; -using NzbDrone.Core.Parser.Model; - -namespace NzbDrone.Api.Parse -{ - public class ParseResource : RestResource - { - public string Title { get; set; } - public ParsedEpisodeInfo ParsedEpisodeInfo { get; set; } - public SeriesResource Series { get; set; } - public List Episodes { get; set; } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Profiles/Languages/LanguageModule.cs b/src/NzbDrone.Api/Profiles/Languages/LanguageModule.cs deleted file mode 100644 index 147bc69aa..000000000 --- a/src/NzbDrone.Api/Profiles/Languages/LanguageModule.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NzbDrone.Core.Parser; - -namespace NzbDrone.Api.Profiles.Languages -{ - public class LanguageModule : NzbDroneRestModule - { - public LanguageModule() - { - GetResourceAll = GetAll; - GetResourceById = GetById; - } - - private LanguageResource GetById(int id) - { - var language = (Language)id; - - return new LanguageResource - { - Id = (int)language, - Name = language.ToString() - }; - } - - private List GetAll() - { - return ((Language[])Enum.GetValues(typeof (Language))) - .Select(l => new LanguageResource - { - Id = (int) l, - Name = l.ToString() - }) - .OrderBy(l => l.Name) - .ToList(); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Profiles/Languages/LanguageResource.cs b/src/NzbDrone.Api/Profiles/Languages/LanguageResource.cs deleted file mode 100644 index 09e5ba28c..000000000 --- a/src/NzbDrone.Api/Profiles/Languages/LanguageResource.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Newtonsoft.Json; -using NzbDrone.Api.REST; - -namespace NzbDrone.Api.Profiles.Languages -{ - public class LanguageResource : RestResource - { - [JsonProperty(DefaultValueHandling = DefaultValueHandling.Include)] - public new int Id { get; set; } - public string Name { get; set; } - public string NameLower => Name.ToLowerInvariant(); - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Profiles/LegacyProfileModule.cs b/src/NzbDrone.Api/Profiles/LegacyProfileModule.cs deleted file mode 100644 index d0e6b744e..000000000 --- a/src/NzbDrone.Api/Profiles/LegacyProfileModule.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Text; -using Nancy; - -namespace NzbDrone.Api.Profiles -{ - class LegacyProfileModule : NzbDroneApiModule - { - public LegacyProfileModule() - : base("qualityprofile") - { - Get["/"] = x => - { - string queryString = ConvertQueryParams(Request.Query); - var url = string.Format("/api/profile?{0}", queryString); - - return Response.AsRedirect(url); - }; - } - - private string ConvertQueryParams(DynamicDictionary query) - { - var sb = new StringBuilder(); - - foreach (var key in query) - { - var value = query[key]; - - sb.AppendFormat("&{0}={1}", key, value); - } - - return sb.ToString().Trim('&'); - } - } -} diff --git a/src/NzbDrone.Api/Profiles/ProfileModule.cs b/src/NzbDrone.Api/Profiles/ProfileModule.cs deleted file mode 100644 index e5803db20..000000000 --- a/src/NzbDrone.Api/Profiles/ProfileModule.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.Collections.Generic; -using FluentValidation; -using NzbDrone.Core.Profiles; -using NzbDrone.Core.Validation; - -namespace NzbDrone.Api.Profiles -{ - public class ProfileModule : NzbDroneRestModule - { - private readonly IProfileService _profileService; - - public ProfileModule(IProfileService profileService) - { - _profileService = profileService; - SharedValidator.RuleFor(c => c.Name).NotEmpty(); - SharedValidator.RuleFor(c => c.Cutoff).NotNull(); - SharedValidator.RuleFor(c => c.Items).MustHaveAllowedQuality(); - SharedValidator.RuleFor(c => c.Language).ValidLanguage(); - - GetResourceAll = GetAll; - GetResourceById = GetById; - UpdateResource = Update; - CreateResource = Create; - DeleteResource = DeleteProfile; - } - - private int Create(ProfileResource resource) - { - var model = resource.ToModel(); - - return _profileService.Add(model).Id; - } - - private void DeleteProfile(int id) - { - _profileService.Delete(id); - } - - private void Update(ProfileResource resource) - { - var model = resource.ToModel(); - - _profileService.Update(model); - } - - private ProfileResource GetById(int id) - { - return _profileService.Get(id).ToResource(); - } - - private List GetAll() - { - return _profileService.All().ToResource(); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Profiles/ProfileResource.cs b/src/NzbDrone.Api/Profiles/ProfileResource.cs deleted file mode 100644 index ee02bcb32..000000000 --- a/src/NzbDrone.Api/Profiles/ProfileResource.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NzbDrone.Api.REST; -using NzbDrone.Core.Parser; -using NzbDrone.Core.Profiles; -using NzbDrone.Core.Qualities; - -namespace NzbDrone.Api.Profiles -{ - public class ProfileResource : RestResource - { - public string Name { get; set; } - public Quality Cutoff { get; set; } - public List Items { get; set; } - public Language Language { get; set; } - } - - public class ProfileQualityItemResource : RestResource - { - public Quality Quality { get; set; } - public bool Allowed { get; set; } - } - - public static class ProfileResourceMapper - { - public static ProfileResource ToResource(this Profile model) - { - if (model == null) return null; - - return new ProfileResource - { - Id = model.Id, - - Name = model.Name, - Cutoff = model.Cutoff, - Items = model.Items.ConvertAll(ToResource), - Language = model.Language - }; - } - - public static ProfileQualityItemResource ToResource(this ProfileQualityItem model) - { - if (model == null) return null; - - return new ProfileQualityItemResource - { - Quality = model.Quality, - Allowed = model.Allowed - }; - } - - public static Profile ToModel(this ProfileResource resource) - { - if (resource == null) return null; - - return new Profile - { - Id = resource.Id, - - Name = resource.Name, - Cutoff = (Quality)resource.Cutoff.Id, - Items = resource.Items.ConvertAll(ToModel), - Language = resource.Language - }; - } - - public static ProfileQualityItem ToModel(this ProfileQualityItemResource resource) - { - if (resource == null) return null; - - return new ProfileQualityItem - { - Quality = (Quality)resource.Quality.Id, - Allowed = resource.Allowed - }; - } - - public static List ToResource(this IEnumerable models) - { - return models.Select(ToResource).ToList(); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Profiles/ProfileSchemaModule.cs b/src/NzbDrone.Api/Profiles/ProfileSchemaModule.cs deleted file mode 100644 index ec5f3ae01..000000000 --- a/src/NzbDrone.Api/Profiles/ProfileSchemaModule.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NzbDrone.Core.Parser; -using NzbDrone.Core.Profiles; -using NzbDrone.Core.Qualities; - -namespace NzbDrone.Api.Profiles -{ - public class ProfileSchemaModule : NzbDroneRestModule - { - private readonly IQualityDefinitionService _qualityDefinitionService; - - public ProfileSchemaModule(IQualityDefinitionService qualityDefinitionService) - : base("/profile/schema") - { - _qualityDefinitionService = qualityDefinitionService; - - GetResourceAll = GetAll; - } - - private List GetAll() - { - var items = _qualityDefinitionService.All() - .OrderBy(v => v.Weight) - .Select(v => new ProfileQualityItem { Quality = v.Quality, Allowed = false }) - .ToList(); - - var profile = new Profile(); - profile.Cutoff = Quality.Unknown; - profile.Items = items; - profile.Language = Language.English; - - return new List { profile.ToResource() }; - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Profiles/ProfileValidation.cs b/src/NzbDrone.Api/Profiles/ProfileValidation.cs deleted file mode 100644 index 003c96f39..000000000 --- a/src/NzbDrone.Api/Profiles/ProfileValidation.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using FluentValidation; -using FluentValidation.Validators; - -namespace NzbDrone.Api.Profiles -{ - public static class ProfileValidation - { - public static IRuleBuilderOptions> MustHaveAllowedQuality(this IRuleBuilder> ruleBuilder) - { - ruleBuilder.SetValidator(new NotEmptyValidator(null)); - - return ruleBuilder.SetValidator(new AllowedValidator()); - } - } - - public class AllowedValidator : PropertyValidator - { - public AllowedValidator() - : base("Must contain at least one allowed quality") - { - - } - - protected override bool IsValid(PropertyValidatorContext context) - { - var list = context.PropertyValue as IList; - - if (list == null) - { - return false; - } - - if (!list.Any(c => c.Allowed)) - { - return false; - } - - return true; - } - } -} diff --git a/src/NzbDrone.Api/Properties/AssemblyInfo.cs b/src/NzbDrone.Api/Properties/AssemblyInfo.cs deleted file mode 100644 index 6149a06c4..000000000 --- a/src/NzbDrone.Api/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -[assembly: AssemblyTitle("NzbDrone.Api")] - -[assembly: Guid("4c0922d7-979e-4ff7-b44b-b8ac2100eeb5")] - -[assembly: AssemblyVersion("10.0.0.*")] - -[assembly: InternalsVisibleTo("NzbDrone.Core")] diff --git a/src/NzbDrone.Api/ProviderModuleBase.cs b/src/NzbDrone.Api/ProviderModuleBase.cs deleted file mode 100644 index b45727227..000000000 --- a/src/NzbDrone.Api/ProviderModuleBase.cs +++ /dev/null @@ -1,226 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using FluentValidation; -using FluentValidation.Results; -using Nancy; -using NzbDrone.Api.ClientSchema; -using NzbDrone.Api.Extensions; -using NzbDrone.Common.Reflection; -using NzbDrone.Core.ThingiProvider; -using NzbDrone.Core.Validation; -using Newtonsoft.Json; - -namespace NzbDrone.Api -{ - public abstract class ProviderModuleBase : NzbDroneRestModule - where TProviderDefinition : ProviderDefinition, new() - where TProvider : IProvider - where TProviderResource : ProviderResource, new() - { - private readonly IProviderFactory _providerFactory; - - protected ProviderModuleBase(IProviderFactory providerFactory, string resource) - : base(resource) - { - _providerFactory = providerFactory; - - Get["schema"] = x => GetTemplates(); - Post["test"] = x => Test(ReadResourceFromRequest(true)); - Post["action/{action}"] = x => RequestAction(x.action, ReadResourceFromRequest(true)); - - GetResourceAll = GetAll; - GetResourceById = GetProviderById; - CreateResource = CreateProvider; - UpdateResource = UpdateProvider; - DeleteResource = DeleteProvider; - - SharedValidator.RuleFor(c => c.Name).NotEmpty(); - SharedValidator.RuleFor(c => c.Name).Must((v,c) => !_providerFactory.All().Any(p => p.Name == c && p.Id != v.Id)).WithMessage("Should be unique"); - SharedValidator.RuleFor(c => c.Implementation).NotEmpty(); - SharedValidator.RuleFor(c => c.ConfigContract).NotEmpty(); - - PostValidator.RuleFor(c => c.Fields).NotNull(); - } - - private TProviderResource GetProviderById(int id) - { - var definition = _providerFactory.Get(id); - _providerFactory.SetProviderCharacteristics(definition); - - var resource = new TProviderResource(); - MapToResource(resource, definition); - - return resource; - } - - private List GetAll() - { - var providerDefinitions = _providerFactory.All().OrderBy(p => p.ImplementationName); - - var result = new List(providerDefinitions.Count()); - - foreach (var definition in providerDefinitions) - { - _providerFactory.SetProviderCharacteristics(definition); - - var providerResource = new TProviderResource(); - MapToResource(providerResource, definition); - - result.Add(providerResource); - } - - return result.OrderBy(p => p.Name).ToList(); - } - - private int CreateProvider(TProviderResource providerResource) - { - var providerDefinition = GetDefinition(providerResource, false); - - if (providerDefinition.Enable) - { - Test(providerDefinition, false); - } - - providerDefinition = _providerFactory.Create(providerDefinition); - - return providerDefinition.Id; - } - - private void UpdateProvider(TProviderResource providerResource) - { - var providerDefinition = GetDefinition(providerResource, false); - - _providerFactory.Update(providerDefinition); - } - - private TProviderDefinition GetDefinition(TProviderResource providerResource, bool includeWarnings = false, bool validate = true) - { - var definition = new TProviderDefinition(); - - MapToModel(definition, providerResource); - - if (validate) - { - Validate(definition, includeWarnings); - } - - return definition; - } - - protected virtual void MapToResource(TProviderResource resource, TProviderDefinition definition) - { - resource.Id = definition.Id; - - resource.Name = definition.Name; - resource.ImplementationName = definition.ImplementationName; - resource.Implementation = definition.Implementation; - resource.ConfigContract = definition.ConfigContract; - resource.Message = definition.Message; - - resource.Fields = SchemaBuilder.ToSchema(definition.Settings); - - resource.InfoLink = string.Format("https://github.com/Sonarr/Sonarr/wiki/Supported-{0}#{1}", - typeof(TProviderResource).Name.Replace("Resource", "s"), - definition.Implementation.ToLower()); - } - - protected virtual void MapToModel(TProviderDefinition definition, TProviderResource resource) - { - definition.Id = resource.Id; - - definition.Name = resource.Name; - definition.ImplementationName = resource.ImplementationName; - definition.Implementation = resource.Implementation; - definition.ConfigContract = resource.ConfigContract; - definition.Message = resource.Message; - - var configContract = ReflectionExtensions.CoreAssembly.FindTypeByName(definition.ConfigContract); - definition.Settings = (IProviderConfig)SchemaBuilder.ReadFromSchema(resource.Fields, configContract); - } - - private void DeleteProvider(int id) - { - _providerFactory.Delete(id); - } - - private Response GetTemplates() - { - var defaultDefinitions = _providerFactory.GetDefaultDefinitions().OrderBy(p => p.ImplementationName).ToList(); - - var result = new List(defaultDefinitions.Count()); - - foreach (var providerDefinition in defaultDefinitions) - { - var providerResource = new TProviderResource(); - MapToResource(providerResource, providerDefinition); - - var presetDefinitions = _providerFactory.GetPresetDefinitions(providerDefinition); - - providerResource.Presets = presetDefinitions.Select(v => - { - var presetResource = new TProviderResource(); - MapToResource(presetResource, v); - - return presetResource as ProviderResource; - }).ToList(); - - result.Add(providerResource); - } - - return result.AsResponse(); - } - - private Response Test(TProviderResource providerResource) - { - // Don't validate when getting the definition so we can validate afterwards (avoids validation being skipped because the provider is disabled) - var providerDefinition = GetDefinition(providerResource, true, false); - - Validate(providerDefinition, true); - Test(providerDefinition, true); - - return "{}"; - } - - - private Response RequestAction(string action, TProviderResource providerResource) - { - var providerDefinition = GetDefinition(providerResource, true, false); - - var query = ((IDictionary)Request.Query.ToDictionary()).ToDictionary(k => k.Key, k => k.Value.ToString()); - - var data = _providerFactory.RequestAction(providerDefinition, action, query); - Response resp = JsonConvert.SerializeObject(data); - resp.ContentType = "application/json"; - return resp; - } - - protected virtual void Validate(TProviderDefinition definition, bool includeWarnings) - { - var validationResult = definition.Settings.Validate(); - - VerifyValidationResult(validationResult, includeWarnings); - } - - protected virtual void Test(TProviderDefinition definition, bool includeWarnings) - { - var validationResult = _providerFactory.Test(definition); - - VerifyValidationResult(validationResult, includeWarnings); - } - - protected void VerifyValidationResult(ValidationResult validationResult, bool includeWarnings) - { - var result = new NzbDroneValidationResult(validationResult.Errors); - - if (includeWarnings && (!result.IsValid || result.HasWarnings)) - { - throw new ValidationException(result.Failures); - } - - if (!result.IsValid) - { - throw new ValidationException(result.Errors); - } - } - } -} diff --git a/src/NzbDrone.Api/ProviderResource.cs b/src/NzbDrone.Api/ProviderResource.cs deleted file mode 100644 index 9927a09cc..000000000 --- a/src/NzbDrone.Api/ProviderResource.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Api.ClientSchema; -using NzbDrone.Api.REST; -using NzbDrone.Core.ThingiProvider; - -namespace NzbDrone.Api -{ - public class ProviderResource : RestResource - { - public string Name { get; set; } - public List Fields { get; set; } - public string ImplementationName { get; set; } - public string Implementation { get; set; } - public string ConfigContract { get; set; } - public string InfoLink { get; set; } - public ProviderMessage Message { get; set; } - - public List Presets { get; set; } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Qualities/QualityDefinitionModule.cs b/src/NzbDrone.Api/Qualities/QualityDefinitionModule.cs deleted file mode 100644 index 1b5351300..000000000 --- a/src/NzbDrone.Api/Qualities/QualityDefinitionModule.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Core.Qualities; - -namespace NzbDrone.Api.Qualities -{ - public class QualityDefinitionModule : NzbDroneRestModule - { - private readonly IQualityDefinitionService _qualityDefinitionService; - - public QualityDefinitionModule(IQualityDefinitionService qualityDefinitionService) - { - _qualityDefinitionService = qualityDefinitionService; - - GetResourceAll = GetAll; - - GetResourceById = GetById; - - UpdateResource = Update; - } - - private void Update(QualityDefinitionResource resource) - { - var model = resource.ToModel(); - _qualityDefinitionService.Update(model); - } - - private QualityDefinitionResource GetById(int id) - { - return _qualityDefinitionService.GetById(id).ToResource(); - } - - private List GetAll() - { - return _qualityDefinitionService.All().ToResource(); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Queue/QueueActionModule.cs b/src/NzbDrone.Api/Queue/QueueActionModule.cs deleted file mode 100644 index 9882e60e6..000000000 --- a/src/NzbDrone.Api/Queue/QueueActionModule.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System; -using Nancy; -using Nancy.Responses; -using NzbDrone.Api.Extensions; -using NzbDrone.Api.REST; -using NzbDrone.Core.Download; -using NzbDrone.Core.Download.Pending; -using NzbDrone.Core.Download.TrackedDownloads; -using NzbDrone.Core.Queue; - -namespace NzbDrone.Api.Queue -{ - public class QueueActionModule : NzbDroneRestModule - { - private readonly IQueueService _queueService; - private readonly ITrackedDownloadService _trackedDownloadService; - private readonly ICompletedDownloadService _completedDownloadService; - private readonly IFailedDownloadService _failedDownloadService; - private readonly IProvideDownloadClient _downloadClientProvider; - private readonly IPendingReleaseService _pendingReleaseService; - private readonly IDownloadService _downloadService; - - public QueueActionModule(IQueueService queueService, - ITrackedDownloadService trackedDownloadService, - ICompletedDownloadService completedDownloadService, - IFailedDownloadService failedDownloadService, - IProvideDownloadClient downloadClientProvider, - IPendingReleaseService pendingReleaseService, - IDownloadService downloadService) - { - _queueService = queueService; - _trackedDownloadService = trackedDownloadService; - _completedDownloadService = completedDownloadService; - _failedDownloadService = failedDownloadService; - _downloadClientProvider = downloadClientProvider; - _pendingReleaseService = pendingReleaseService; - _downloadService = downloadService; - - Delete[@"/(?[\d]{1,10})"] = x => Remove((int)x.Id); - Post["/import"] = x => Import(); - Post["/grab"] = x => Grab(); - } - - private Response Remove(int id) - { - var blacklist = false; - var blacklistQuery = Request.Query.blacklist; - - if (blacklistQuery.HasValue) - { - blacklist = Convert.ToBoolean(blacklistQuery.Value); - } - - var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id); - - if (pendingRelease != null) - { - _pendingReleaseService.RemovePendingQueueItems(pendingRelease.Id); - - return new object().AsResponse(); - } - - var trackedDownload = GetTrackedDownload(id); - - if (trackedDownload == null) - { - throw new NotFoundException(); - } - - var downloadClient = _downloadClientProvider.Get(trackedDownload.DownloadClient); - - if (downloadClient == null) - { - throw new BadRequestException(); - } - - downloadClient.RemoveItem(trackedDownload.DownloadItem.DownloadId, true); - - if (blacklist) - { - _failedDownloadService.MarkAsFailed(trackedDownload.DownloadItem.DownloadId); - } - - return new object().AsResponse(); - } - - private JsonResponse Import() - { - var resource = Request.Body.FromJson(); - var trackedDownload = GetTrackedDownload(resource.Id); - - _completedDownloadService.Process(trackedDownload, true); - - return resource.AsResponse(); - } - - private JsonResponse Grab() - { - var resource = Request.Body.FromJson(); - - var pendingRelease = _pendingReleaseService.FindPendingQueueItem(resource.Id); - - if (pendingRelease == null) - { - throw new NotFoundException(); - } - - _downloadService.DownloadReport(pendingRelease.RemoteEpisode); - - return resource.AsResponse(); - } - - private TrackedDownload GetTrackedDownload(int queueId) - { - var queueItem = _queueService.Find(queueId); - - if (queueItem == null) - { - throw new NotFoundException(); - } - - var trackedDownload = _trackedDownloadService.Find(queueItem.DownloadId); - - if (trackedDownload == null) - { - throw new NotFoundException(); - } - - return trackedDownload; - } - } -} diff --git a/src/NzbDrone.Api/Queue/QueueModule.cs b/src/NzbDrone.Api/Queue/QueueModule.cs deleted file mode 100644 index 00e614132..000000000 --- a/src/NzbDrone.Api/Queue/QueueModule.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NzbDrone.Core.Datastore.Events; -using NzbDrone.Core.Download.Pending; -using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Queue; -using NzbDrone.SignalR; - -namespace NzbDrone.Api.Queue -{ - public class QueueModule : NzbDroneRestModuleWithSignalR, - IHandle, IHandle - { - private readonly IQueueService _queueService; - private readonly IPendingReleaseService _pendingReleaseService; - - public QueueModule(IBroadcastSignalRMessage broadcastSignalRMessage, IQueueService queueService, IPendingReleaseService pendingReleaseService) - : base(broadcastSignalRMessage) - { - _queueService = queueService; - _pendingReleaseService = pendingReleaseService; - GetResourceAll = GetQueue; - } - - private List GetQueue() - { - return GetQueueItems().ToResource(); - } - - private IEnumerable GetQueueItems() - { - var queue = _queueService.GetQueue(); - var pending = _pendingReleaseService.GetPendingQueue(); - - return queue.Concat(pending); - } - - public void Handle(QueueUpdatedEvent message) - { - BroadcastResourceChange(ModelAction.Sync); - } - - public void Handle(PendingReleasesUpdatedEvent message) - { - BroadcastResourceChange(ModelAction.Sync); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Queue/QueueResource.cs b/src/NzbDrone.Api/Queue/QueueResource.cs deleted file mode 100644 index cf1356c49..000000000 --- a/src/NzbDrone.Api/Queue/QueueResource.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System; -using System.Collections.Generic; -using NzbDrone.Api.REST; -using NzbDrone.Core.Qualities; -using NzbDrone.Api.Series; -using NzbDrone.Api.Episodes; -using NzbDrone.Core.Download.TrackedDownloads; -using NzbDrone.Core.Indexers; -using System.Linq; - -namespace NzbDrone.Api.Queue -{ - public class QueueResource : RestResource - { - public SeriesResource Series { get; set; } - public EpisodeResource Episode { get; set; } - public QualityModel Quality { get; set; } - public decimal Size { get; set; } - public string Title { get; set; } - public decimal Sizeleft { get; set; } - public TimeSpan? Timeleft { get; set; } - public DateTime? EstimatedCompletionTime { get; set; } - public string Status { get; set; } - public string TrackedDownloadStatus { get; set; } - public List StatusMessages { get; set; } - public string DownloadId { get; set; } - public DownloadProtocol Protocol { get; set; } - } - - public static class QueueResourceMapper - { - public static QueueResource ToResource(this Core.Queue.Queue model) - { - if (model == null) return null; - - return new QueueResource - { - Id = model.Id, - - Series = model.Series.ToResource(), - Episode = model.Episode.ToResource(), - Quality = model.Quality, - Size = model.Size, - Title = model.Title, - Sizeleft = model.Sizeleft, - Timeleft = model.Timeleft, - EstimatedCompletionTime = model.EstimatedCompletionTime, - Status = model.Status, - TrackedDownloadStatus = model.TrackedDownloadStatus, - StatusMessages = model.StatusMessages, - DownloadId = model.DownloadId, - Protocol = model.Protocol - }; - } - - public static List ToResource(this IEnumerable models) - { - return models.Select(ToResource).ToList(); - } - } -} diff --git a/src/NzbDrone.Api/Restrictions/RestrictionModule.cs b/src/NzbDrone.Api/Restrictions/RestrictionModule.cs deleted file mode 100644 index 918b3a50b..000000000 --- a/src/NzbDrone.Api/Restrictions/RestrictionModule.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Collections.Generic; -using FluentValidation.Results; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Restrictions; - -namespace NzbDrone.Api.Restrictions -{ - public class RestrictionModule : NzbDroneRestModule - { - private readonly IRestrictionService _restrictionService; - - - public RestrictionModule(IRestrictionService restrictionService) - { - _restrictionService = restrictionService; - - GetResourceById = GetRestriction; - GetResourceAll = GetAllRestrictions; - CreateResource = CreateRestriction; - UpdateResource = UpdateRestriction; - DeleteResource = DeleteRestriction; - - SharedValidator.Custom(restriction => - { - if (restriction.Ignored.IsNullOrWhiteSpace() && restriction.Required.IsNullOrWhiteSpace()) - { - return new ValidationFailure("", "Either 'Must contain' or 'Must not contain' is required"); - } - - return null; - }); - } - - private RestrictionResource GetRestriction(int id) - { - return _restrictionService.Get(id).ToResource(); - } - - private List GetAllRestrictions() - { - return _restrictionService.All().ToResource(); - } - - private int CreateRestriction(RestrictionResource resource) - { - return _restrictionService.Add(resource.ToModel()).Id; - } - - private void UpdateRestriction(RestrictionResource resource) - { - _restrictionService.Update(resource.ToModel()); - } - - private void DeleteRestriction(int id) - { - _restrictionService.Delete(id); - } - } -} diff --git a/src/NzbDrone.Api/Restrictions/RestrictionResource.cs b/src/NzbDrone.Api/Restrictions/RestrictionResource.cs deleted file mode 100644 index 14085e820..000000000 --- a/src/NzbDrone.Api/Restrictions/RestrictionResource.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NzbDrone.Api.REST; -using NzbDrone.Core.Restrictions; - -namespace NzbDrone.Api.Restrictions -{ - public class RestrictionResource : RestResource - { - public string Required { get; set; } - public string Preferred { get; set; } - public string Ignored { get; set; } - public HashSet Tags { get; set; } - - public RestrictionResource() - { - Tags = new HashSet(); - } - } - - public static class RestrictionResourceMapper - { - public static RestrictionResource ToResource(this Restriction model) - { - if (model == null) return null; - - return new RestrictionResource - { - Id = model.Id, - - Required = model.Required, - Preferred = model.Preferred, - Ignored = model.Ignored, - Tags = new HashSet(model.Tags) - }; - } - - public static Restriction ToModel(this RestrictionResource resource) - { - if (resource == null) return null; - - return new Restriction - { - Id = resource.Id, - - Required = resource.Required, - Preferred = resource.Preferred, - Ignored = resource.Ignored, - Tags = new HashSet(resource.Tags) - }; - } - - public static List ToResource(this IEnumerable models) - { - return models.Select(ToResource).ToList(); - } - } -} diff --git a/src/NzbDrone.Api/SeasonPass/SeasonPassModule.cs b/src/NzbDrone.Api/SeasonPass/SeasonPassModule.cs deleted file mode 100644 index 93cd25ce5..000000000 --- a/src/NzbDrone.Api/SeasonPass/SeasonPassModule.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Nancy; -using NzbDrone.Api.Extensions; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Api.SeasonPass -{ - public class SeasonPassModule : NzbDroneApiModule - { - private readonly IEpisodeMonitoredService _episodeMonitoredService; - - public SeasonPassModule(IEpisodeMonitoredService episodeMonitoredService) - : base("/seasonpass") - { - _episodeMonitoredService = episodeMonitoredService; - Post["/"] = series => UpdateAll(); - } - - private Response UpdateAll() - { - //Read from request - var request = Request.Body.FromJson(); - - foreach (var s in request.Series) - { - _episodeMonitoredService.SetEpisodeMonitoredStatus(s, request.MonitoringOptions); - } - - return "ok".AsResponse(HttpStatusCode.Accepted); - } - } -} diff --git a/src/NzbDrone.Api/SeasonPass/SeasonPassResource.cs b/src/NzbDrone.Api/SeasonPass/SeasonPassResource.cs deleted file mode 100644 index af537e7f9..000000000 --- a/src/NzbDrone.Api/SeasonPass/SeasonPassResource.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Api.SeasonPass -{ - public class SeasonPassResource - { - public List Series { get; set; } - public MonitoringOptions MonitoringOptions { get; set; } - } -} diff --git a/src/NzbDrone.Api/Series/SeasonResource.cs b/src/NzbDrone.Api/Series/SeasonResource.cs deleted file mode 100644 index 2231502d9..000000000 --- a/src/NzbDrone.Api/Series/SeasonResource.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NzbDrone.Core.Tv; -namespace NzbDrone.Api.Series -{ - public class SeasonResource - { - public int SeasonNumber { get; set; } - public bool Monitored { get; set; } - public SeasonStatisticsResource Statistics { get; set; } - } - - public static class SeasonResourceMapper - { - public static SeasonResource ToResource(this Season model) - { - if (model == null) return null; - - return new SeasonResource - { - SeasonNumber = model.SeasonNumber, - Monitored = model.Monitored - }; - } - - public static Season ToModel(this SeasonResource resource) - { - if (resource == null) return null; - - return new Season - { - SeasonNumber = resource.SeasonNumber, - Monitored = resource.Monitored - }; - } - - public static List ToResource(this IEnumerable models) - { - return models.Select(ToResource).ToList(); - } - - public static List ToModel(this IEnumerable resources) - { - return resources.Select(ToModel).ToList(); - } - } -} diff --git a/src/NzbDrone.Api/Series/SeasonStatisticsResource.cs b/src/NzbDrone.Api/Series/SeasonStatisticsResource.cs deleted file mode 100644 index 34acc721e..000000000 --- a/src/NzbDrone.Api/Series/SeasonStatisticsResource.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using NzbDrone.Core.SeriesStats; - -namespace NzbDrone.Api.Series -{ - public class SeasonStatisticsResource - { - public DateTime? NextAiring { get; set; } - public DateTime? PreviousAiring { get; set; } - public int EpisodeFileCount { get; set; } - public int EpisodeCount { get; set; } - public int TotalEpisodeCount { get; set; } - public long SizeOnDisk { get; set; } - - public decimal PercentOfEpisodes - { - get - { - if (EpisodeCount == 0) return 0; - - return (decimal)EpisodeFileCount / (decimal)EpisodeCount * 100; - } - } - } - - public static class SeasonStatisticsResourceMapper - { - public static SeasonStatisticsResource ToResource(this SeasonStatistics model) - { - if (model == null) return null; - - return new SeasonStatisticsResource - { - NextAiring = model.NextAiring, - PreviousAiring = model.PreviousAiring, - EpisodeFileCount = model.EpisodeFileCount, - EpisodeCount = model.EpisodeFileCount, - TotalEpisodeCount = model.TotalEpisodeCount, - SizeOnDisk = model.SizeOnDisk - }; - } - } -} diff --git a/src/NzbDrone.Api/Series/SeriesEditorModule.cs b/src/NzbDrone.Api/Series/SeriesEditorModule.cs deleted file mode 100644 index 87cd53113..000000000 --- a/src/NzbDrone.Api/Series/SeriesEditorModule.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using Nancy; -using NzbDrone.Api.Extensions; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Api.Series -{ - public class SeriesEditorModule : NzbDroneApiModule - { - private readonly ISeriesService _seriesService; - - public SeriesEditorModule(ISeriesService seriesService) - : base("/series/editor") - { - _seriesService = seriesService; - Put["/"] = series => SaveAll(); - } - - private Response SaveAll() - { - var resources = Request.Body.FromJson>(); - - var series = resources.Select(seriesResource => seriesResource.ToModel(_seriesService.GetSeries(seriesResource.Id))).ToList(); - - return _seriesService.UpdateSeries(series) - .ToResource() - .AsResponse(HttpStatusCode.Accepted); - } - } -} diff --git a/src/NzbDrone.Api/Series/SeriesLookupModule.cs b/src/NzbDrone.Api/Series/SeriesLookupModule.cs deleted file mode 100644 index 6506c1f82..000000000 --- a/src/NzbDrone.Api/Series/SeriesLookupModule.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Collections.Generic; -using Nancy; -using NzbDrone.Api.Extensions; -using NzbDrone.Core.MediaCover; -using NzbDrone.Core.MetadataSource; -using System.Linq; - -namespace NzbDrone.Api.Series -{ - public class SeriesLookupModule : NzbDroneRestModule - { - private readonly ISearchForNewSeries _searchProxy; - - public SeriesLookupModule(ISearchForNewSeries searchProxy) - : base("/series/lookup") - { - _searchProxy = searchProxy; - Get["/"] = x => Search(); - } - - - private Response Search() - { - var tvDbResults = _searchProxy.SearchForNewSeries((string)Request.Query.term); - return MapToResource(tvDbResults).AsResponse(); - } - - - private static IEnumerable MapToResource(IEnumerable series) - { - foreach (var currentSeries in series) - { - var resource = currentSeries.ToResource(); - var poster = currentSeries.Images.FirstOrDefault(c => c.CoverType == MediaCoverTypes.Poster); - if (poster != null) - { - resource.RemotePoster = poster.Url; - } - - yield return resource; - } - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Api/Series/SeriesModule.cs b/src/NzbDrone.Api/Series/SeriesModule.cs deleted file mode 100644 index 274a57bbd..000000000 --- a/src/NzbDrone.Api/Series/SeriesModule.cs +++ /dev/null @@ -1,242 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using FluentValidation; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Datastore.Events; -using NzbDrone.Core.MediaCover; -using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.MediaFiles.Events; -using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.SeriesStats; -using NzbDrone.Core.Tv; -using NzbDrone.Core.Tv.Events; -using NzbDrone.Core.Validation.Paths; -using NzbDrone.Core.DataAugmentation.Scene; -using NzbDrone.Core.Validation; -using NzbDrone.SignalR; - -namespace NzbDrone.Api.Series -{ - public class SeriesModule : NzbDroneRestModuleWithSignalR, - IHandle, - IHandle, - IHandle, - IHandle, - IHandle, - IHandle, - IHandle - - { - private readonly ISeriesService _seriesService; - private readonly ISeriesStatisticsService _seriesStatisticsService; - private readonly ISceneMappingService _sceneMappingService; - private readonly IMapCoversToLocal _coverMapper; - - public SeriesModule(IBroadcastSignalRMessage signalRBroadcaster, - ISeriesService seriesService, - ISeriesStatisticsService seriesStatisticsService, - ISceneMappingService sceneMappingService, - IMapCoversToLocal coverMapper, - RootFolderValidator rootFolderValidator, - SeriesPathValidator seriesPathValidator, - SeriesExistsValidator seriesExistsValidator, - DroneFactoryValidator droneFactoryValidator, - SeriesAncestorValidator seriesAncestorValidator, - ProfileExistsValidator profileExistsValidator - ) - : base(signalRBroadcaster) - { - _seriesService = seriesService; - _seriesStatisticsService = seriesStatisticsService; - _sceneMappingService = sceneMappingService; - - _coverMapper = coverMapper; - - GetResourceAll = AllSeries; - GetResourceById = GetSeries; - CreateResource = AddSeries; - UpdateResource = UpdateSeries; - DeleteResource = DeleteSeries; - - Validation.RuleBuilderExtensions.ValidId(SharedValidator.RuleFor(s => s.ProfileId)); - - SharedValidator.RuleFor(s => s.Path) - .Cascade(CascadeMode.StopOnFirstFailure) - .IsValidPath() - .SetValidator(rootFolderValidator) - .SetValidator(seriesPathValidator) - .SetValidator(droneFactoryValidator) - .SetValidator(seriesAncestorValidator) - .When(s => !s.Path.IsNullOrWhiteSpace()); - - SharedValidator.RuleFor(s => s.ProfileId).SetValidator(profileExistsValidator); - - PostValidator.RuleFor(s => s.Path).IsValidPath().When(s => s.RootFolderPath.IsNullOrWhiteSpace()); - PostValidator.RuleFor(s => s.RootFolderPath).IsValidPath().When(s => s.Path.IsNullOrWhiteSpace()); - PostValidator.RuleFor(s => s.Title).NotEmpty(); - PostValidator.RuleFor(s => s.TvdbId).GreaterThan(0).SetValidator(seriesExistsValidator); - - PutValidator.RuleFor(s => s.Path).IsValidPath(); - } - - private SeriesResource GetSeries(int id) - { - 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; - } - - private List AllSeries() - { - var seriesStats = _seriesStatisticsService.SeriesStatistics(); - var seriesResources = _seriesService.GetAllSeries().ToResource(); - - MapCoversToLocal(seriesResources.ToArray()); - LinkSeriesStatistics(seriesResources, seriesStats); - PopulateAlternateTitles(seriesResources); - - return seriesResources; - } - - private int AddSeries(SeriesResource seriesResource) - { - var model = seriesResource.ToModel(); - - return _seriesService.AddSeries(model).Id; - } - - private void UpdateSeries(SeriesResource seriesResource) - { - var model = seriesResource.ToModel(_seriesService.GetSeries(seriesResource.Id)); - - _seriesService.UpdateSeries(model); - - BroadcastResourceChange(ModelAction.Updated, seriesResource); - } - - private void DeleteSeries(int id) - { - var deleteFiles = false; - var deleteFilesQuery = Request.Query.deleteFiles; - - if (deleteFilesQuery.HasValue) - { - deleteFiles = Convert.ToBoolean(deleteFilesQuery.Value); - } - - _seriesService.DeleteSeries(id, deleteFiles); - } - - private void MapCoversToLocal(params SeriesResource[] series) - { - foreach (var seriesResource in series) - { - _coverMapper.ConvertToLocalUrls(seriesResource.Id, seriesResource.Images); - } - } - - private void FetchAndLinkSeriesStatistics(SeriesResource resource) - { - LinkSeriesStatistics(resource, _seriesStatisticsService.SeriesStatistics(resource.Id)); - } - - private void LinkSeriesStatistics(List resources, List seriesStatistics) - { - var dictSeriesStats = seriesStatistics.ToDictionary(v => v.SeriesId); - - foreach (var series in resources) - { - var stats = dictSeriesStats.GetValueOrDefault(series.Id); - if (stats == null) continue; - - LinkSeriesStatistics(series, stats); - } - } - - private void LinkSeriesStatistics(SeriesResource resource, SeriesStatistics seriesStatistics) - { - resource.TotalEpisodeCount = seriesStatistics.TotalEpisodeCount; - resource.EpisodeCount = seriesStatistics.EpisodeCount; - resource.EpisodeFileCount = seriesStatistics.EpisodeFileCount; - resource.NextAiring = seriesStatistics.NextAiring; - resource.PreviousAiring = seriesStatistics.PreviousAiring; - resource.SizeOnDisk = seriesStatistics.SizeOnDisk; - - if (seriesStatistics.SeasonStatistics != null) - { - var dictSeasonStats = seriesStatistics.SeasonStatistics.ToDictionary(v => v.SeasonNumber); - - foreach (var season in resource.Seasons) - { - season.Statistics = dictSeasonStats.GetValueOrDefault(season.SeasonNumber).ToResource(); - } - } - } - - private void PopulateAlternateTitles(List resources) - { - foreach (var resource in resources) - { - PopulateAlternateTitles(resource); - } - } - - private void PopulateAlternateTitles(SeriesResource resource) - { - var mappings = _sceneMappingService.FindByTvdbId(resource.TvdbId); - - if (mappings == null) return; - - resource.AlternateTitles = mappings.Select(v => new AlternateTitleResource { Title = v.Title, SeasonNumber = v.SeasonNumber, SceneSeasonNumber = v.SceneSeasonNumber }).ToList(); - } - - public void Handle(EpisodeImportedEvent message) - { - BroadcastResourceChange(ModelAction.Updated, message.ImportedEpisode.SeriesId); - } - - public void Handle(EpisodeFileDeletedEvent message) - { - if (message.Reason == DeleteMediaFileReason.Upgrade) return; - - BroadcastResourceChange(ModelAction.Updated, message.EpisodeFile.SeriesId); - } - - public void Handle(SeriesUpdatedEvent message) - { - BroadcastResourceChange(ModelAction.Updated, message.Series.Id); - } - - public void Handle(SeriesEditedEvent message) - { - BroadcastResourceChange(ModelAction.Updated, message.Series.Id); - } - - public void Handle(SeriesDeletedEvent message) - { - BroadcastResourceChange(ModelAction.Deleted, message.Series.ToResource()); - } - - public void Handle(SeriesRenamedEvent message) - { - BroadcastResourceChange(ModelAction.Updated, message.Series.Id); - } - - public void Handle(MediaCoversUpdatedEvent message) - { - BroadcastResourceChange(ModelAction.Updated, message.Series.Id); - } - } -} diff --git a/src/NzbDrone.Api/Series/SeriesResource.cs b/src/NzbDrone.Api/Series/SeriesResource.cs deleted file mode 100644 index 176377a86..000000000 --- a/src/NzbDrone.Api/Series/SeriesResource.cs +++ /dev/null @@ -1,232 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NzbDrone.Api.REST; -using NzbDrone.Core.MediaCover; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Api.Series -{ - public class SeriesResource : RestResource - { - public SeriesResource() - { - Monitored = true; - } - - //Todo: Sorters should be done completely on the client - //Todo: Is there an easy way to keep IgnoreArticlesWhenSorting in sync between, Series, History, Missing? - //Todo: We should get the entire Profile instead of ID and Name separately - - //View Only - public string Title { get; set; } - public List AlternateTitles { get; set; } - public string SortTitle { get; set; } - - public int SeasonCount - { - get - { - if (Seasons == null) return 0; - - return Seasons.Where(s => s.SeasonNumber > 0).Count(); - } - } - - public int? TotalEpisodeCount { get; set; } - public int? EpisodeCount { get; set; } - public int? EpisodeFileCount { get; set; } - public long? SizeOnDisk { get; set; } - public SeriesStatusType Status { get; set; } - public string Overview { get; set; } - public DateTime? NextAiring { get; set; } - public DateTime? PreviousAiring { get; set; } - public string Network { get; set; } - public string AirTime { get; set; } - public List Images { get; set; } - - public string RemotePoster { get; set; } - public List Seasons { get; set; } - public int Year { get; set; } - - //View & Edit - public string Path { get; set; } - public int ProfileId { get; set; } - - //Editing Only - public bool SeasonFolder { get; set; } - public bool Monitored { get; set; } - - public bool UseSceneNumbering { get; set; } - public int Runtime { get; set; } - public int TvdbId { get; set; } - public int TvRageId { get; set; } - public int TvMazeId { get; set; } - public DateTime? FirstAired { get; set; } - public DateTime? LastInfoSync { get; set; } - public SeriesTypes SeriesType { get; set; } - public string CleanTitle { get; set; } - public string ImdbId { get; set; } - public string TitleSlug { get; set; } - public string RootFolderPath { get; set; } - public string Certification { get; set; } - public List Genres { get; set; } - public HashSet Tags { get; set; } - public DateTime Added { get; set; } - public AddSeriesOptions AddOptions { get; set; } - public Ratings Ratings { get; set; } - - //TODO: Add series statistics as a property of the series (instead of individual properties) - - //Used to support legacy consumers - public int QualityProfileId - { - get - { - return ProfileId; - } - set - { - if (value > 0 && ProfileId == 0) - { - ProfileId = value; - } - } - } - } - - public static class SeriesResourceMapper - { - public static SeriesResource ToResource(this Core.Tv.Series model) - { - if (model == null) return null; - - return new SeriesResource - { - Id = model.Id, - - Title = model.Title, - //AlternateTitles - SortTitle = model.SortTitle, - - //TotalEpisodeCount - //EpisodeCount - //EpisodeFileCount - //SizeOnDisk - Status = model.Status, - Overview = model.Overview, - //NextAiring - //PreviousAiring - Network = model.Network, - AirTime = model.AirTime, - Images = model.Images, - - Seasons = model.Seasons.ToResource(), - Year = model.Year, - - Path = model.Path, - ProfileId = model.ProfileId, - - SeasonFolder = model.SeasonFolder, - Monitored = model.Monitored, - - UseSceneNumbering = model.UseSceneNumbering, - Runtime = model.Runtime, - TvdbId = model.TvdbId, - TvRageId = model.TvRageId, - TvMazeId = model.TvMazeId, - FirstAired = model.FirstAired, - LastInfoSync = model.LastInfoSync, - SeriesType = model.SeriesType, - CleanTitle = model.CleanTitle, - ImdbId = model.ImdbId, - TitleSlug = model.TitleSlug, - RootFolderPath = model.RootFolderPath, - Certification = model.Certification, - Genres = model.Genres, - Tags = model.Tags, - Added = model.Added, - AddOptions = model.AddOptions, - Ratings = model.Ratings - }; - } - - public static Core.Tv.Series ToModel(this SeriesResource resource) - { - if (resource == null) return null; - - return new Core.Tv.Series - { - Id = resource.Id, - - Title = resource.Title, - //AlternateTitles - SortTitle = resource.SortTitle, - - //TotalEpisodeCount - //EpisodeCount - //EpisodeFileCount - //SizeOnDisk - Status = resource.Status, - Overview = resource.Overview, - //NextAiring - //PreviousAiring - Network = resource.Network, - AirTime = resource.AirTime, - Images = resource.Images, - - Seasons = resource.Seasons.ToModel(), - Year = resource.Year, - - Path = resource.Path, - ProfileId = resource.ProfileId, - - SeasonFolder = resource.SeasonFolder, - Monitored = resource.Monitored, - - UseSceneNumbering = resource.UseSceneNumbering, - Runtime = resource.Runtime, - TvdbId = resource.TvdbId, - TvRageId = resource.TvRageId, - TvMazeId = resource.TvMazeId, - FirstAired = resource.FirstAired, - LastInfoSync = resource.LastInfoSync, - SeriesType = resource.SeriesType, - CleanTitle = resource.CleanTitle, - ImdbId = resource.ImdbId, - TitleSlug = resource.TitleSlug, - RootFolderPath = resource.RootFolderPath, - Certification = resource.Certification, - Genres = resource.Genres, - Tags = resource.Tags, - Added = resource.Added, - AddOptions = resource.AddOptions, - Ratings = resource.Ratings - }; - } - - public static Core.Tv.Series ToModel(this SeriesResource resource, Core.Tv.Series series) - { - series.TvdbId = resource.TvdbId; - - series.Seasons = resource.Seasons.ToModel(); - series.Path = resource.Path; - series.ProfileId = resource.ProfileId; - - series.SeasonFolder = resource.SeasonFolder; - series.Monitored = resource.Monitored; - - series.SeriesType = resource.SeriesType; - series.RootFolderPath = resource.RootFolderPath; - series.Tags = resource.Tags; - series.AddOptions = resource.AddOptions; - - return series; - } - - public static List ToResource(this IEnumerable series) - { - return series.Select(ToResource).ToList(); - } - } -} diff --git a/src/NzbDrone.Api/System/Backup/BackupModule.cs b/src/NzbDrone.Api/System/Backup/BackupModule.cs deleted file mode 100644 index b5074793e..000000000 --- a/src/NzbDrone.Api/System/Backup/BackupModule.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Linq; -using NzbDrone.Core.Backup; - -namespace NzbDrone.Api.System.Backup -{ - public class BackupModule : NzbDroneRestModule - { - private readonly IBackupService _backupService; - - public BackupModule(IBackupService backupService) : base("system/backup") - { - _backupService = backupService; - GetResourceAll = GetBackupFiles; - } - - public List GetBackupFiles() - { - var backups = _backupService.GetBackups(); - - return backups.Select(b => new BackupResource - { - Id = b.Path.GetHashCode(), - Name = Path.GetFileName(b.Path), - Path = b.Path, - Type = b.Type, - Time = b.Time - }).ToList(); - } - } -} diff --git a/src/NzbDrone.Api/System/Tasks/TaskModule.cs b/src/NzbDrone.Api/System/Tasks/TaskModule.cs deleted file mode 100644 index db8c4f376..000000000 --- a/src/NzbDrone.Api/System/Tasks/TaskModule.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Text.RegularExpressions; -using NzbDrone.Core.Datastore.Events; -using NzbDrone.Core.Jobs; -using NzbDrone.Core.Messaging.Events; -using NzbDrone.SignalR; - -namespace NzbDrone.Api.System.Tasks -{ - public class TaskModule : NzbDroneRestModuleWithSignalR, IHandle - { - private readonly ITaskManager _taskManager; - - private static readonly Regex NameRegex = new Regex("(? GetAll() - { - return _taskManager.GetAll().Select(ConvertToResource).ToList(); - } - - private static TaskResource ConvertToResource(ScheduledTask scheduledTask) - { - var taskName = scheduledTask.TypeName.Split('.').Last().Replace("Command", ""); - - return new TaskResource - { - Id = scheduledTask.Id, - Name = NameRegex.Replace(taskName, match => " " + match.Value), - TaskName = taskName, - Interval = scheduledTask.Interval, - LastExecution = scheduledTask.LastExecution, - NextExecution = scheduledTask.LastExecution.AddMinutes(scheduledTask.Interval) - }; - } - - public void Handle(CommandExecutedEvent message) - { - BroadcastResourceChange(ModelAction.Sync); - } - } -} diff --git a/src/NzbDrone.Api/Tags/TagModule.cs b/src/NzbDrone.Api/Tags/TagModule.cs deleted file mode 100644 index d2a01667c..000000000 --- a/src/NzbDrone.Api/Tags/TagModule.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Core.Datastore.Events; -using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Tags; -using NzbDrone.SignalR; - -namespace NzbDrone.Api.Tags -{ - public class TagModule : NzbDroneRestModuleWithSignalR, IHandle - { - private readonly ITagService _tagService; - - public TagModule(IBroadcastSignalRMessage signalRBroadcaster, - ITagService tagService) - : base(signalRBroadcaster) - { - _tagService = tagService; - - GetResourceById = GetTag; - GetResourceAll = GetAllTags; - CreateResource = CreateTag; - UpdateResource = UpdateTag; - DeleteResource = DeleteTag; - } - - private TagResource GetTag(int id) - { - return _tagService.GetTag(id).ToResource(); - } - - private List GetAllTags() - { - return _tagService.All().ToResource(); - } - - private int CreateTag(TagResource resource) - { - var model = resource.ToModel(); - - return _tagService.Add(model).Id; - } - - private void UpdateTag(TagResource resource) - { - var model = resource.ToModel(); - - _tagService.Update(model); - } - - private void DeleteTag(int id) - { - _tagService.Delete(id); - } - - public void Handle(TagsUpdatedEvent message) - { - BroadcastResourceChange(ModelAction.Sync); - } - } -} diff --git a/src/NzbDrone.Api/Wanted/CutoffModule.cs b/src/NzbDrone.Api/Wanted/CutoffModule.cs deleted file mode 100644 index d2d08edab..000000000 --- a/src/NzbDrone.Api/Wanted/CutoffModule.cs +++ /dev/null @@ -1,42 +0,0 @@ -using NzbDrone.Api.Episodes; -using NzbDrone.Core.Datastore; -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Tv; -using NzbDrone.SignalR; - -namespace NzbDrone.Api.Wanted -{ - public class CutoffModule : EpisodeModuleWithSignalR - { - private readonly IEpisodeCutoffService _episodeCutoffService; - - public CutoffModule(IEpisodeCutoffService episodeCutoffService, - IEpisodeService episodeService, - ISeriesService seriesService, - IQualityUpgradableSpecification qualityUpgradableSpecification, - IBroadcastSignalRMessage signalRBroadcaster) - : base(episodeService, seriesService, qualityUpgradableSpecification, signalRBroadcaster, "wanted/cutoff") - { - _episodeCutoffService = episodeCutoffService; - GetResourcePaged = GetCutoffUnmetEpisodes; - } - - private PagingResource GetCutoffUnmetEpisodes(PagingResource pagingResource) - { - var pagingSpec = pagingResource.MapToPagingSpec("airDateUtc", SortDirection.Descending); - - if (pagingResource.FilterKey == "monitored" && pagingResource.FilterValue == "false") - { - pagingSpec.FilterExpression = v => v.Monitored == false || v.Series.Monitored == false; - } - else - { - pagingSpec.FilterExpression = v => v.Monitored == true && v.Series.Monitored == true; - } - - var resource = ApplyToPage(_episodeCutoffService.EpisodesWhereCutoffUnmet, pagingSpec, v => MapToResource(v, true, true)); - - return resource; - } - } -} diff --git a/src/NzbDrone.Api/Wanted/LegacyMissingModule.cs b/src/NzbDrone.Api/Wanted/LegacyMissingModule.cs deleted file mode 100644 index a5a503a5d..000000000 --- a/src/NzbDrone.Api/Wanted/LegacyMissingModule.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Text; -using Nancy; - -namespace NzbDrone.Api.Wanted -{ - class LegacyMissingModule : NzbDroneApiModule - { - public LegacyMissingModule() : base("missing") - { - Get["/"] = x => - { - string queryString = ConvertQueryParams(Request.Query); - var url = string.Format("/api/wanted/missing?{0}", queryString); - - return Response.AsRedirect(url); - }; - } - - private string ConvertQueryParams(DynamicDictionary query) - { - var sb = new StringBuilder(); - - foreach (var key in query) - { - var value = query[key]; - - sb.AppendFormat("&{0}={1}", key, value); - } - - return sb.ToString().Trim('&'); - } - } -} diff --git a/src/NzbDrone.Api/Wanted/MissingModule.cs b/src/NzbDrone.Api/Wanted/MissingModule.cs deleted file mode 100644 index 9f6215a2e..000000000 --- a/src/NzbDrone.Api/Wanted/MissingModule.cs +++ /dev/null @@ -1,38 +0,0 @@ -using NzbDrone.Api.Episodes; -using NzbDrone.Core.Datastore; -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Tv; -using NzbDrone.SignalR; - -namespace NzbDrone.Api.Wanted -{ - public class MissingModule : EpisodeModuleWithSignalR - { - public MissingModule(IEpisodeService episodeService, - ISeriesService seriesService, - IQualityUpgradableSpecification qualityUpgradableSpecification, - IBroadcastSignalRMessage signalRBroadcaster) - : base(episodeService, seriesService, qualityUpgradableSpecification, signalRBroadcaster, "wanted/missing") - { - GetResourcePaged = GetMissingEpisodes; - } - - private PagingResource GetMissingEpisodes(PagingResource pagingResource) - { - var pagingSpec = pagingResource.MapToPagingSpec("airDateUtc", SortDirection.Descending); - - if (pagingResource.FilterKey == "monitored" && pagingResource.FilterValue == "false") - { - pagingSpec.FilterExpression = v => v.Monitored == false || v.Series.Monitored == false; - } - else - { - pagingSpec.FilterExpression = v => v.Monitored == true && v.Series.Monitored == true; - } - - var resource = ApplyToPage(_episodeService.EpisodesWithoutFiles, pagingSpec, v => MapToResource(v, true, false)); - - return resource; - } - } -} diff --git a/src/NzbDrone.Api/app.config b/src/NzbDrone.Api/app.config deleted file mode 100644 index c1684a7be..000000000 --- a/src/NzbDrone.Api/app.config +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/NzbDrone.Api/packages.config b/src/NzbDrone.Api/packages.config deleted file mode 100644 index 76eadbc70..000000000 --- a/src/NzbDrone.Api/packages.config +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/src/NzbDrone.App.Test/License.txt b/src/NzbDrone.App.Test/License.txt deleted file mode 100644 index 5ead6991a..000000000 --- a/src/NzbDrone.App.Test/License.txt +++ /dev/null @@ -1,22 +0,0 @@ - Copyright (c) 2010 Darren Cauthon - - Permission is hereby granted, free of charge, to any person - obtaining a copy of this software and associated documentation - files (the "Software"), to deal in the Software without - restriction, including without limitation the rights to use, - copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the - Software is furnished to do so, subject to the following - conditions: - - The above copyright notice and this permission notice shall be - included in all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES - OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR - OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/NzbDrone.App.Test/NzbDrone.Host.Test.csproj b/src/NzbDrone.App.Test/NzbDrone.Host.Test.csproj deleted file mode 100644 index 9f53dae3e..000000000 --- a/src/NzbDrone.App.Test/NzbDrone.Host.Test.csproj +++ /dev/null @@ -1,126 +0,0 @@ - - - - Debug - x86 - 8.0.30703 - 2.0 - {C0EA1A40-91AD-4EEB-BD16-2DDDEBD20AE5} - Library - Properties - NzbDrone.App.Test - NzbDrone.App.Test - v4.0 - 512 - ..\ - true - - - true - bin\x86\Debug\ - DEBUG;TRACE - full - x86 - prompt - MinimumRecommendedRules.ruleset - 4 - false - - - bin\x86\Release\ - TRACE - true - pdbonly - x86 - prompt - MinimumRecommendedRules.ruleset - 4 - - - - ..\packages\NBuilder.4.0.0\lib\net40\FizzWare.NBuilder.dll - True - - - ..\packages\FluentAssertions.4.18.0\lib\net40\FluentAssertions.dll - True - - - ..\packages\FluentAssertions.4.18.0\lib\net40\FluentAssertions.Core.dll - True - - - ..\packages\NLog.4.4.1\lib\net40\NLog.dll - True - - - ..\packages\NUnit.3.5.0\lib\net40\nunit.framework.dll - True - - - - - - - - ..\packages\Moq.4.0.10827\lib\NET40\Moq.dll - - - - - - - - - - - App.config - - - - - - {F2BE0FDF-6E47-4827-A420-DD4EF82407F8} - NzbDrone.Common - - - {FF5EE3B6-913B-47CE-9CEB-11C51B4E1205} - NzbDrone.Core - - - {95C11A9E-56ED-456A-8447-2C89C1139266} - NzbDrone.Host - - - {CADDFCE0-7509-4430-8364-2074E1EEFCA2} - NzbDrone.Test.Common - - - - - sqlite3.dll - Always - - - - - - - - - xcopy /s /y "$(SolutionDir)\..\_output\NzbDrone.Mono.*" "$(TargetDir)" - xcopy /s /y "$(SolutionDir)\..\_output\NzbDrone.Windows.*" "$(TargetDir)" - - - cp -rv $(SolutionDir)\..\_output\NzbDrone.Mono.* $(TargetDir) || true - cp -rv $(SolutionDir)\..\_output\NzbDrone.Windows.* $(TargetDir) || true - - - - \ No newline at end of file diff --git a/src/NzbDrone.App.Test/Properties/AssemblyInfo.cs b/src/NzbDrone.App.Test/Properties/AssemblyInfo.cs deleted file mode 100644 index 86a324eef..000000000 --- a/src/NzbDrone.App.Test/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("NzbDrone.App.Test")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("Microsoft")] -[assembly: AssemblyProduct("NzbDrone.App.Test")] -[assembly: AssemblyCopyright("Copyright © Microsoft 2011")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("b47d34ef-05e8-4826-8a57-9dd05106c964")] - -[assembly: AssemblyVersion("10.0.0.*")] diff --git a/src/NzbDrone.App.Test/packages.config b/src/NzbDrone.App.Test/packages.config deleted file mode 100644 index 1efb88f74..000000000 --- a/src/NzbDrone.App.Test/packages.config +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/src/NzbDrone.Automation.Test/AutomationTest.cs b/src/NzbDrone.Automation.Test/AutomationTest.cs index 9f493d824..e835cabd7 100644 --- a/src/NzbDrone.Automation.Test/AutomationTest.cs +++ b/src/NzbDrone.Automation.Test/AutomationTest.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using FluentAssertions; using NLog; @@ -31,21 +31,23 @@ namespace NzbDrone.Automation.Test LogManager.Configuration.LoggingRules.Add(new LoggingRule("*", NLog.LogLevel.Trace, consoleTarget)); } - [TestFixtureSetUp] + [OneTimeSetUp] public void SmokeTestSetup() { - driver = new FirefoxDriver(); + var options = new FirefoxOptions(); + options.AddArguments("--headless"); + driver = new FirefoxDriver(options); _runner = new NzbDroneRunner(LogManager.GetCurrentClassLogger()); _runner.KillAll(); _runner.Start(); - driver.Url = "http://localhost:8989"; + driver.Url = "http://localhost:8686"; var page = new PageBase(driver); page.WaitForNoSpinner(); - driver.ExecuteScript("window.NzbDrone.NameViews = true;"); + driver.ExecuteScript("window.Lidarr.NameViews = true;"); GetPageErrors().Should().BeEmpty(); } @@ -56,7 +58,7 @@ namespace NzbDrone.Automation.Test .Select(e => e.Text); } - [TestFixtureTearDown] + [OneTimeTearDown] public void SmokeTestTearDown() { _runner.KillAll(); diff --git a/src/NzbDrone.Automation.Test/Lidarr.Automation.Test.csproj b/src/NzbDrone.Automation.Test/Lidarr.Automation.Test.csproj new file mode 100644 index 000000000..126a44b19 --- /dev/null +++ b/src/NzbDrone.Automation.Test/Lidarr.Automation.Test.csproj @@ -0,0 +1,13 @@ + + + net462 + x86 + + + + + + + + + diff --git a/src/NzbDrone.Automation.Test/MainPagesTest.cs b/src/NzbDrone.Automation.Test/MainPagesTest.cs index bbf89690a..95eae8f8d 100644 --- a/src/NzbDrone.Automation.Test/MainPagesTest.cs +++ b/src/NzbDrone.Automation.Test/MainPagesTest.cs @@ -1,4 +1,4 @@ -using FluentAssertions; +using FluentAssertions; using NUnit.Framework; using NzbDrone.Automation.Test.PageModel; using OpenQA.Selenium; @@ -17,11 +17,11 @@ namespace NzbDrone.Automation.Test } [Test] - public void series_page() + public void artist_page() { - page.SeriesNavIcon.Click(); + page.LibraryNavIcon.Click(); page.WaitForNoSpinner(); - page.FindByClass("iv-series-index-seriesindexlayout").Should().NotBeNull(); + page.Find(By.CssSelector("div[class*='ArtistIndex']")).Should().NotBeNull(); } [Test] @@ -30,7 +30,7 @@ namespace NzbDrone.Automation.Test page.CalendarNavIcon.Click(); page.WaitForNoSpinner(); - page.FindByClass("iv-calendar-calendarlayout").Should().NotBeNull(); + page.Find(By.CssSelector("div[class*='CalendarPage']")).Should().NotBeNull(); } [Test] @@ -39,7 +39,9 @@ namespace NzbDrone.Automation.Test page.ActivityNavIcon.Click(); page.WaitForNoSpinner(); - page.FindByClass("iv-activity-activitylayout").Should().NotBeNull(); + page.Find(By.LinkText("Queue")).Should().NotBeNull(); + page.Find(By.LinkText("History")).Should().NotBeNull(); + page.Find(By.LinkText("Blacklist")).Should().NotBeNull(); } [Test] @@ -48,7 +50,8 @@ namespace NzbDrone.Automation.Test page.WantedNavIcon.Click(); page.WaitForNoSpinner(); - page.FindByClass("iv-wanted-missing-missinglayout").Should().NotBeNull(); + page.Find(By.LinkText("Missing")).Should().NotBeNull(); + page.Find(By.LinkText("Cutoff Unmet")).Should().NotBeNull(); } [Test] @@ -57,20 +60,20 @@ namespace NzbDrone.Automation.Test page.SystemNavIcon.Click(); page.WaitForNoSpinner(); - page.FindByClass("iv-system-systemlayout").Should().NotBeNull(); + page.Find(By.CssSelector("div[class*='Health']")).Should().NotBeNull(); } [Test] - public void add_series_page() + public void add_artist_page() { - page.SeriesNavIcon.Click(); + page.LibraryNavIcon.Click(); page.WaitForNoSpinner(); - page.Find(By.LinkText("Add Series")).Click(); + page.Find(By.LinkText("Add New")).Click(); page.WaitForNoSpinner(); - page.FindByClass("iv-addseries-addserieslayout").Should().NotBeNull(); + page.Find(By.CssSelector("input[class*='AddNewArtist-searchInput']")).Should().NotBeNull(); } } -} \ 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 deleted file mode 100644 index 8d8eedfee..000000000 --- a/src/NzbDrone.Automation.Test/NzbDrone.Automation.Test.csproj +++ /dev/null @@ -1,106 +0,0 @@ - - - - - Debug - x86 - {CC26800D-F67E-464B-88DE-8EB1A0C227A3} - Library - Properties - NzbDrone.Automation.Test - NzbDrone.Automation.Test - v4.0 - 512 - ..\ - true - 12.0.0 - 2.0 - - - true - bin\x86\Debug\ - DEBUG;TRACE - full - x86 - prompt - MinimumRecommendedRules.ruleset - 4 - false - - - bin\x86\Release\ - TRACE - true - pdbonly - x86 - prompt - MinimumRecommendedRules.ruleset - 4 - - - - ..\packages\FluentAssertions.4.18.0\lib\net40\FluentAssertions.dll - True - - - ..\packages\FluentAssertions.4.18.0\lib\net40\FluentAssertions.Core.dll - True - - - ..\packages\NLog.4.4.1\lib\net40\NLog.dll - True - - - ..\packages\NUnit.3.5.0\lib\net40\nunit.framework.dll - True - - - - - - - - - - - ..\packages\Selenium.WebDriver.3.0.1\lib\net40\WebDriver.dll - True - - - ..\packages\Selenium.Support.3.0.1\lib\net40\WebDriver.Support.dll - True - - - - - - - - - - - - {F2BE0FDF-6E47-4827-A420-DD4EF82407F8} - NzbDrone.Common - - - {CADDFCE0-7509-4430-8364-2074E1EEFCA2} - NzbDrone.Test.Common - - - - - - - - - - - - \ No newline at end of file diff --git a/src/NzbDrone.Automation.Test/PageModel/PageBase.cs b/src/NzbDrone.Automation.Test/PageModel/PageBase.cs index f835c39a6..acf8cfe73 100644 --- a/src/NzbDrone.Automation.Test/PageModel/PageBase.cs +++ b/src/NzbDrone.Automation.Test/PageModel/PageBase.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using OpenQA.Selenium; using OpenQA.Selenium.Remote; @@ -47,16 +47,16 @@ namespace NzbDrone.Automation.Test.PageModel }); } - public IWebElement SeriesNavIcon => FindByClass("x-series-nav"); + public IWebElement LibraryNavIcon => Find(By.LinkText("Library")); - public IWebElement CalendarNavIcon => FindByClass("x-calendar-nav"); + public IWebElement CalendarNavIcon => Find(By.LinkText("Calendar")); - public IWebElement ActivityNavIcon => FindByClass("x-activity-nav"); + public IWebElement ActivityNavIcon => Find(By.LinkText("Activity")); - public IWebElement WantedNavIcon => FindByClass("x-wanted-nav"); + public IWebElement WantedNavIcon => Find(By.LinkText("Wanted")); - public IWebElement SettingNavIcon => FindByClass("x-settings-nav"); + public IWebElement SettingNavIcon => Find(By.LinkText("Settings")); - public IWebElement SystemNavIcon => FindByClass("x-system-nav"); + public IWebElement SystemNavIcon => Find(By.PartialLinkText("System")); } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Automation.Test/Properties/AssemblyInfo.cs b/src/NzbDrone.Automation.Test/Properties/AssemblyInfo.cs deleted file mode 100644 index a5d255084..000000000 --- a/src/NzbDrone.Automation.Test/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("NzbDrone.Automation.Test")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("NzbDrone.Automation.Test")] -[assembly: AssemblyCopyright("Copyright © 2013")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("6b8945f5-f5b5-4729-865d-f958fbd673d9")] - -[assembly: AssemblyVersion("10.0.0.*")] diff --git a/src/NzbDrone.Automation.Test/app.config b/src/NzbDrone.Automation.Test/app.config index c1684a7be..4d2f70473 100644 --- a/src/NzbDrone.Automation.Test/app.config +++ b/src/NzbDrone.Automation.Test/app.config @@ -4,12 +4,16 @@ - + + + + + - \ No newline at end of file + diff --git a/src/NzbDrone.Automation.Test/packages.config b/src/NzbDrone.Automation.Test/packages.config deleted file mode 100644 index f5fe2805f..000000000 --- a/src/NzbDrone.Automation.Test/packages.config +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/src/NzbDrone.Common.Test/CacheTests/CachedFixture.cs b/src/NzbDrone.Common.Test/CacheTests/CachedFixture.cs index 54b2a0e01..1faf6bb16 100644 --- a/src/NzbDrone.Common.Test/CacheTests/CachedFixture.cs +++ b/src/NzbDrone.Common.Test/CacheTests/CachedFixture.cs @@ -100,7 +100,31 @@ namespace NzbDrone.Common.Test.CacheTests Thread.Sleep(100); } - hitCount.Should().BeInRange(3, 6); + hitCount.Should().BeInRange(3, 7); + } + + [Test] + [Retry(3)] + public void should_clear_expired_when_they_expire() + { + int hitCount = 0; + _cachedString = new Cached(); + + for (int i = 0; i < 10; i++) + { + _cachedString.Get("key", () => + { + hitCount++; + return null; + }, TimeSpan.FromMilliseconds(300)); + + Thread.Sleep(100); + } + + Thread.Sleep(1000); + + hitCount.Should().BeInRange(3, 7); + _cachedString.Values.Should().HaveCount(0); } } @@ -114,4 +138,4 @@ namespace NzbDrone.Common.Test.CacheTests return "Hit count is " + HitCount; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Common.Test/ConfigFileProviderTest.cs b/src/NzbDrone.Common.Test/ConfigFileProviderTest.cs index 92df06ded..05367247b 100644 --- a/src/NzbDrone.Common.Test/ConfigFileProviderTest.cs +++ b/src/NzbDrone.Common.Test/ConfigFileProviderTest.cs @@ -49,7 +49,7 @@ namespace NzbDrone.Common.Test public void GetValue_Success() { const string key = "Port"; - const string value = "8989"; + const string value = "8686"; var result = Subject.GetValue(key, value); @@ -60,7 +60,7 @@ namespace NzbDrone.Common.Test public void GetInt_Success() { const string key = "Port"; - const int value = 8989; + const int value = 8686; var result = Subject.GetValueInt(key, value); @@ -95,7 +95,7 @@ namespace NzbDrone.Common.Test [Test] public void GetPort_Success() { - const int value = 8989; + const int value = 8686; var result = Subject.Port; diff --git a/src/NzbDrone.Common.Test/DiskTests/DirectoryLookupServiceFixture.cs b/src/NzbDrone.Common.Test/DiskTests/DirectoryLookupServiceFixture.cs index 804666ea1..48afe91c3 100644 --- a/src/NzbDrone.Common.Test/DiskTests/DirectoryLookupServiceFixture.cs +++ b/src/NzbDrone.Common.Test/DiskTests/DirectoryLookupServiceFixture.cs @@ -1,5 +1,6 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; +using System.IO.Abstractions; using System.Linq; using FluentAssertions; using Moq; @@ -15,7 +16,7 @@ namespace NzbDrone.Common.Test.DiskTests private const string RECYCLING_BIN = "$Recycle.Bin"; private const string SYSTEM_VOLUME_INFORMATION = "System Volume Information"; private const string WINDOWS = "Windows"; - private List _folders; + private List _folders; private void SetupFolders(string root) { @@ -36,7 +37,7 @@ namespace NzbDrone.Common.Test.DiskTests WINDOWS }; - _folders = folders.Select(f => new DirectoryInfo(Path.Combine(root, f))).ToList(); + _folders = folders.Select(f => (DirectoryInfoBase)new DirectoryInfo(Path.Combine(root, f))).ToList(); } [Test] @@ -49,7 +50,7 @@ namespace NzbDrone.Common.Test.DiskTests .Setup(s => s.GetDirectoryInfos(It.IsAny())) .Returns(_folders); - Subject.LookupContents(root, false).Directories.Should().NotContain(Path.Combine(root, RECYCLING_BIN)); + Subject.LookupContents(root, false, false).Directories.Should().NotContain(Path.Combine(root, RECYCLING_BIN)); } [Test] @@ -62,7 +63,7 @@ namespace NzbDrone.Common.Test.DiskTests .Setup(s => s.GetDirectoryInfos(It.IsAny())) .Returns(_folders); - Subject.LookupContents(root, false).Directories.Should().NotContain(Path.Combine(root, SYSTEM_VOLUME_INFORMATION)); + Subject.LookupContents(root, false, false).Directories.Should().NotContain(Path.Combine(root, SYSTEM_VOLUME_INFORMATION)); } [Test] @@ -75,8 +76,8 @@ namespace NzbDrone.Common.Test.DiskTests .Setup(s => s.GetDirectoryInfos(It.IsAny())) .Returns(_folders); - var result = Subject.LookupContents(root, false); - + var result = Subject.LookupContents(root, false, false); + result.Directories.Should().HaveCount(_folders.Count - 3); result.Directories.Should().NotContain(f => f.Name == RECYCLING_BIN); diff --git a/src/NzbDrone.Common.Test/DiskTests/DiskProviderFixtureBase.cs b/src/NzbDrone.Common.Test/DiskTests/DiskProviderFixtureBase.cs index 234be692c..df228193f 100644 --- a/src/NzbDrone.Common.Test/DiskTests/DiskProviderFixtureBase.cs +++ b/src/NzbDrone.Common.Test/DiskTests/DiskProviderFixtureBase.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.IO; +using System.IO.Abstractions; using FluentAssertions; using NUnit.Framework; using NzbDrone.Common.Disk; @@ -202,7 +203,7 @@ namespace NzbDrone.Common.Test.DiskTests [Test] public void GetParentFolder_should_remove_trailing_slash_before_getting_parent_folder() { - var path = @"C:\Test\TV\".AsOsAgnostic(); + var path = @"C:\Test\Music\".AsOsAgnostic(); var parent = @"C:\Test".AsOsAgnostic(); Subject.GetParentFolder(path).Should().Be(parent); @@ -244,14 +245,20 @@ namespace NzbDrone.Common.Test.DiskTests } [Test] + [Ignore("No longer behaving this way in a Windows 10 Feature Update")] public void should_not_be_able_to_rename_open_hardlinks_with_fileshare_none() { + WindowsOnly(); + Assert.Throws(() => DoHardLinkRename(FileShare.None)); } [Test] + [Ignore("No longer behaving this way in a Windows 10 Feature Update")] public void should_not_be_able_to_rename_open_hardlinks_with_fileshare_write() { + WindowsOnly(); + Assert.Throws(() => DoHardLinkRename(FileShare.Read)); } } diff --git a/src/NzbDrone.Common.Test/DiskTests/DiskTransferServiceFixture.cs b/src/NzbDrone.Common.Test/DiskTests/DiskTransferServiceFixture.cs index d6c4faece..5a216c719 100644 --- a/src/NzbDrone.Common.Test/DiskTests/DiskTransferServiceFixture.cs +++ b/src/NzbDrone.Common.Test/DiskTests/DiskTransferServiceFixture.cs @@ -1,11 +1,14 @@ -using System; +using System; +using System.Collections.Generic; using System.IO; +using System.IO.Abstractions; using System.Linq; +using FluentAssertions; using Moq; using NUnit.Framework; using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; using NzbDrone.Test.Common; -using FluentAssertions; namespace NzbDrone.Common.Test.DiskTests { @@ -238,7 +241,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()); @@ -485,10 +488,10 @@ namespace NzbDrone.Common.Test.DiskTests Mocker.GetMock() .Setup(v => v.CopyFile(_sourcePath, _tempTargetPath, false)) .Callback(() => - { - WithExistingFile(_tempTargetPath, true, 900); - if (retry++ == 1) WithExistingFile(_tempTargetPath, true, 1000); - }); + { + WithExistingFile(_tempTargetPath, true, 900); + if (retry++ == 1) WithExistingFile(_tempTargetPath, true, 1000); + }); var result = Subject.TransferFile(_sourcePath, _targetPath, TransferMode.Copy); @@ -504,10 +507,10 @@ namespace NzbDrone.Common.Test.DiskTests Mocker.GetMock() .Setup(v => v.CopyFile(_sourcePath, _tempTargetPath, false)) .Callback(() => - { - WithExistingFile(_tempTargetPath, true, 900); - if (retry++ == 3) throw new Exception("Test Failed, retried too many times."); - }); + { + WithExistingFile(_tempTargetPath, true, 900); + if (retry++ == 3) throw new Exception("Test Failed, retried too many times."); + }); Assert.Throws(() => Subject.TransferFile(_sourcePath, _targetPath, TransferMode.Copy)); @@ -794,6 +797,75 @@ namespace NzbDrone.Common.Test.DiskTests VerifyCopyFolder(original.FullName, destination.FullName); } + [Test] + public void TransferFolder_should_use_movefolder_if_on_same_mount() + { + WithEmulatedDiskProvider(); + + var src = @"C:\Base1\TestDir1".AsOsAgnostic(); + var dst = @"C:\Base1\TestDir2".AsOsAgnostic(); + + WithMockMount(@"C:\Base1".AsOsAgnostic()); + WithExistingFile(@"C:\Base1\TestDir1\test.file.txt".AsOsAgnostic()); + + Subject.TransferFolder(src, dst, TransferMode.Move); + + Mocker.GetMock() + .Verify(v => v.MoveFolder(src, dst), Times.Once()); + } + + [Test] + public void TransferFolder_should_not_use_movefolder_if_on_same_mount_but_target_already_exists() + { + WithEmulatedDiskProvider(); + + var src = @"C:\Base1\TestDir1".AsOsAgnostic(); + var dst = @"C:\Base1\TestDir2".AsOsAgnostic(); + + WithMockMount(@"C:\Base1".AsOsAgnostic()); + WithExistingFile(@"C:\Base1\TestDir1\test.file.txt".AsOsAgnostic()); + WithExistingFolder(dst); + + Subject.TransferFolder(src, dst, TransferMode.Move); + + Mocker.GetMock() + .Verify(v => v.MoveFolder(src, dst), Times.Never()); + } + + [Test] + public void TransferFolder_should_not_use_movefolder_if_on_same_mount_but_transactional() + { + WithEmulatedDiskProvider(); + + var src = @"C:\Base1\TestDir1".AsOsAgnostic(); + var dst = @"C:\Base1\TestDir2".AsOsAgnostic(); + + WithMockMount(@"C:\Base1".AsOsAgnostic()); + WithExistingFile(@"C:\Base1\TestDir1\test.file.txt".AsOsAgnostic()); + + Subject.TransferFolder(src, dst, TransferMode.Move, DiskTransferVerificationMode.Transactional); + + Mocker.GetMock() + .Verify(v => v.MoveFolder(src, dst), Times.Never()); + } + + [Test] + public void TransferFolder_should_not_use_movefolder_if_on_different_mount() + { + WithEmulatedDiskProvider(); + + var src = @"C:\Base1\TestDir1".AsOsAgnostic(); + var dst = @"C:\Base2\TestDir2".AsOsAgnostic(); + + WithMockMount(@"C:\Base1".AsOsAgnostic()); + WithMockMount(@"C:\Base2".AsOsAgnostic()); + + Subject.TransferFolder(src, dst, TransferMode.Move); + + Mocker.GetMock() + .Verify(v => v.MoveFolder(src, dst), Times.Never()); + } + public DirectoryInfo GetFilledTempFolder() { var tempFolder = GetTempFilePath(); @@ -810,8 +882,23 @@ namespace NzbDrone.Common.Test.DiskTests return new DirectoryInfo(tempFolder); } + private void WithExistingFolder(string path, bool exists = true) + { + var dir = Path.GetDirectoryName(path); + if (exists && dir.IsNotNullOrWhiteSpace()) + WithExistingFolder(dir); + + Mocker.GetMock() + .Setup(v => v.FolderExists(path)) + .Returns(exists); + } + private void WithExistingFile(string path, bool exists = true, int size = 1000) { + var dir = Path.GetDirectoryName(path); + if (exists && dir.IsNotNullOrWhiteSpace()) + WithExistingFolder(dir); + Mocker.GetMock() .Setup(v => v.FileExists(path)) .Returns(exists); @@ -863,41 +950,82 @@ namespace NzbDrone.Common.Test.DiskTests { WithExistingFile(v, false); }); + + + Mocker.GetMock() + .Setup(v => v.FolderExists(It.IsAny())) + .Returns(false); + + Mocker.GetMock() + .Setup(v => v.CreateFolder(It.IsAny())) + .Callback((f) => + { + WithExistingFolder(f); + }); + + Mocker.GetMock() + .Setup(v => v.MoveFolder(It.IsAny(), It.IsAny())) + .Callback((s, d) => + { + WithExistingFolder(s, false); + WithExistingFolder(d); + // Note: Should also deal with the files. + }); + + Mocker.GetMock() + .Setup(v => v.DeleteFolder(It.IsAny(), It.IsAny())) + .Callback((f, r) => + { + WithExistingFolder(f, false); + // Note: Should also deal with the files. + }); + + // Note: never returns anything. + Mocker.GetMock() + .Setup(v => v.GetDirectoryInfos(It.IsAny())) + .Returns(new List()); + + // Note: never returns anything. + Mocker.GetMock() + .Setup(v => v.GetFileInfos(It.IsAny(), It.IsAny())) + .Returns(new List()); } private void WithRealDiskProvider() { + IFileSystem _fileSystem = new FileSystem(); + Mocker.GetMock() .Setup(v => v.FolderExists(It.IsAny())) - .Returns(v => Directory.Exists(v)); + .Returns(v => _fileSystem.Directory.Exists(v)); Mocker.GetMock() .Setup(v => v.FileExists(It.IsAny())) - .Returns(v => File.Exists(v)); + .Returns(v => _fileSystem.File.Exists(v)); Mocker.GetMock() .Setup(v => v.CreateFolder(It.IsAny())) - .Callback(v => Directory.CreateDirectory(v)); + .Callback(v => _fileSystem.Directory.CreateDirectory(v)); Mocker.GetMock() .Setup(v => v.DeleteFolder(It.IsAny(), It.IsAny())) - .Callback((v,r) => Directory.Delete(v, r)); + .Callback((v, r) => _fileSystem.Directory.Delete(v, r)); Mocker.GetMock() .Setup(v => v.DeleteFile(It.IsAny())) - .Callback(v => File.Delete(v)); + .Callback(v => _fileSystem.File.Delete(v)); Mocker.GetMock() .Setup(v => v.GetDirectoryInfos(It.IsAny())) - .Returns(v => new DirectoryInfo(v).GetDirectories().ToList()); + .Returns(v => _fileSystem.DirectoryInfo.FromDirectoryName(v).GetDirectories().ToList()); Mocker.GetMock() - .Setup(v => v.GetFileInfos(It.IsAny())) - .Returns(v => new DirectoryInfo(v).GetFiles().ToList()); + .Setup(v => v.GetFileInfos(It.IsAny(), It.IsAny())) + .Returns((string v, SearchOption option) => _fileSystem.DirectoryInfo.FromDirectoryName(v).GetFiles("*", option).ToList()); Mocker.GetMock() .Setup(v => v.GetFileSize(It.IsAny())) - .Returns(v => new FileInfo(v).Length); + .Returns(v => _fileSystem.FileInfo.FromFileName(v).Length); Mocker.GetMock() .Setup(v => v.TryCreateHardLink(It.IsAny(), It.IsAny())) @@ -905,13 +1033,13 @@ namespace NzbDrone.Common.Test.DiskTests Mocker.GetMock() .Setup(v => v.CopyFile(It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((s, d, o) => File.Copy(s, d, o)); + .Callback((s, d, o) => _fileSystem.File.Copy(s, d, o)); Mocker.GetMock() .Setup(v => v.MoveFile(It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((s,d,o) => { - if (File.Exists(d) && o) File.Delete(d); - File.Move(s, d); + .Callback((s, d, o) => { + if (_fileSystem.File.Exists(d) && o) _fileSystem.File.Delete(d); + _fileSystem.File.Move(s, d); }); Mocker.GetMock() @@ -919,6 +1047,18 @@ namespace NzbDrone.Common.Test.DiskTests .Returns(s => new FileStream(s, FileMode.Open, FileAccess.Read)); } + private void WithMockMount(string root) + { + var rootDir = root; + var mock = new Mock(); + mock.SetupGet(v => v.RootDirectory) + .Returns(rootDir); + + Mocker.GetMock() + .Setup(v => v.GetMount(It.Is(s => s.StartsWith(rootDir)))) + .Returns(mock.Object); + } + private void VerifyCopyFolder(string source, string destination) { var sourceFiles = Directory.GetFileSystemEntries(source, "*", SearchOption.AllDirectories).Select(v => v.Substring(source.Length + 1)).ToArray(); diff --git a/src/NzbDrone.Common.Test/EnsureTest/PathExtensionFixture.cs b/src/NzbDrone.Common.Test/EnsureTest/PathExtensionFixture.cs index 3388df9ad..f3b127b31 100644 --- a/src/NzbDrone.Common.Test/EnsureTest/PathExtensionFixture.cs +++ b/src/NzbDrone.Common.Test/EnsureTest/PathExtensionFixture.cs @@ -7,8 +7,8 @@ namespace NzbDrone.Common.Test.EnsureTest [TestFixture] public class PathExtensionFixture : TestBase { - [TestCase(@"p:\TV Shows\file with, comma.mkv")] - [TestCase(@"\\serer\share\file with, comma.mkv")] + [TestCase(@"p:\Music\file with, comma.mp3")] + [TestCase(@"\\serer\share\file with, comma.mp3")] public void EnsureWindowsPath(string path) { WindowsOnly(); @@ -16,7 +16,7 @@ namespace NzbDrone.Common.Test.EnsureTest } - [TestCase(@"/var/user/file with, comma.mkv")] + [TestCase(@"/var/user/file with, comma.mp3")] public void EnsureLinuxPath(string path) { MonoOnly(); diff --git a/src/NzbDrone.Common.Test/EnvironmentInfo/BuildInfoFixture.cs b/src/NzbDrone.Common.Test/EnvironmentInfo/BuildInfoFixture.cs index dca0b292e..f87c79bfe 100644 --- a/src/NzbDrone.Common.Test/EnvironmentInfo/BuildInfoFixture.cs +++ b/src/NzbDrone.Common.Test/EnvironmentInfo/BuildInfoFixture.cs @@ -1,4 +1,4 @@ -using FluentAssertions; +using FluentAssertions; using NUnit.Framework; using NzbDrone.Common.EnvironmentInfo; @@ -10,14 +10,14 @@ namespace NzbDrone.Common.Test.EnvironmentInfo [Test] public void should_return_version() { - BuildInfo.Version.Major.Should().BeOneOf(2, 10); + BuildInfo.Version.Major.Should().BeOneOf(0, 10); } [Test] public void should_get_branch() { - BuildInfo.Branch.Should().NotBe("unknow"); + BuildInfo.Branch.Should().NotBe("unknown"); BuildInfo.Branch.Should().NotBeNullOrWhiteSpace(); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Common.Test/ExtensionTests/FuzzyContainsFixture.cs b/src/NzbDrone.Common.Test/ExtensionTests/FuzzyContainsFixture.cs new file mode 100644 index 000000000..76e87888f --- /dev/null +++ b/src/NzbDrone.Common.Test/ExtensionTests/FuzzyContainsFixture.cs @@ -0,0 +1,61 @@ +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Common.Extensions; +using NzbDrone.Test.Common; + +namespace NzbDrone.Common.Test +{ + [TestFixture] + public class FuzzyContainsFixture : TestBase + { + [TestCase("abcdef", "abcdef", 0.5, 0)] + [TestCase("", "abcdef", 0.5, -1)] + [TestCase("abcdef", "", 0.5, -1)] + [TestCase("", "", 0.5, -1)] + [TestCase("abcdef", "de", 0.5, 3)] + [TestCase("abcdef", "defy", 0.5, 3)] + [TestCase("abcdef", "abcdefy", 0.5, 0)] + [TestCase("I am the very model of a modern major general.", " that berry ", 0.3, 4)] + [TestCase("abcdefghijk", "fgh", 0.5, 5)] + [TestCase("abcdefghijk", "fgh", 0.5, 5)] + [TestCase("abcdefghijk", "efxhi", 0.5, 4)] + [TestCase("abcdefghijk", "cdefxyhijk", 0.5, 2)] + [TestCase("abcdefghijk", "bxy", 0.5, -1)] + [TestCase("123456789xx0", "3456789x0", 0.5, 2)] + [TestCase("abcdef", "xxabc", 0.5, 0)] + [TestCase("abcdef", "defyy", 0.5, 3)] + [TestCase("abcdef", "xabcdefy", 0.5, 0)] + [TestCase("abcdefghijk", "efxyhi", 0.6, 4)] + [TestCase("abcdefghijk", "efxyhi", 0.7, -1)] + [TestCase("abcdefghijk", "bcdef", 0.0, 1)] + [TestCase("abcdexyzabcde", "abccde", 0.5, 0)] + [TestCase("abcdefghijklmnopqrstuvwxyz", "abcdxxefg", 0.5, 0)] + [TestCase("abcdefghijklmnopqrstuvwxyz", "abcdefg", 0.5, 0)] + [TestCase("The quick brown fox jumps over the lazy dog", "The quick brown fox jumps over the lazy d", 0.5, 0)] + [TestCase("The quick brown fox jumps over the lazy dog", "The quick brown fox jumps over the lazy g", 0.5, 0)] + [TestCase("The quick brown fox jumps over the lazy dog", "quikc brown fox jumps over the lazy dog", 0.5, 4)] + [TestCase("The quick brown fox jumps over the lazy dog", "qui jumps over the lazy dog", 0.5, 16)] + [TestCase("The quick brown fox jumps over the lazy dog", "quikc brown fox jumps over the lazy dog", 0.5, 4)] + [TestCase("u6IEytQiYpzAccsbjQ5ISuE4smDQ1ZiU42cFBrTeKB2XrVLEqAvgIiKlDP75iApy07jzmK", "xEytQiYpzAccsbjQ5ISuE4smDQ1ZiU42cFBrTeKB2XrVLEqAvgIiKlDP75iApy07jzmK", 0.5, 2)] + [TestCase("plusifeelneedforredundantinformationintitlefield", "anthology", 0.5, -1)] + public void FuzzyFind(string text, string pattern, double threshold, int expected) + { + text.FuzzyFind(pattern, threshold).Should().Be(expected); + } + + [TestCase("abcdef", "abcdef", 1)] + [TestCase("", "abcdef", 0)] + [TestCase("abcdef", "", 0)] + [TestCase("", "", 0)] + [TestCase("abcdef", "de", 1)] + [TestCase("abcdef", "defy", 0.75)] + [TestCase("abcdef", "abcdefghk", 6.0/9)] + [TestCase("abcdef", "zabcdefz", 6.0/8)] + [TestCase("plusifeelneedforredundantinformationintitlefield", "anthology", 4.0/9)] + [TestCase("+ (Plus) - I feel the need for redundant information in the title field", "+", 1)] + public void FuzzyContains(string text, string pattern, double expectedScore) + { + text.FuzzyContains(pattern).Should().BeApproximately(expectedScore, 1e-9); + } + } +} diff --git a/src/NzbDrone.Common.Test/ExtensionTests/StringExtensionTests/FirstCharacterToLowerFixture.cs b/src/NzbDrone.Common.Test/ExtensionTests/StringExtensionTests/FirstCharacterToLowerFixture.cs new file mode 100644 index 000000000..d2df657d4 --- /dev/null +++ b/src/NzbDrone.Common.Test/ExtensionTests/StringExtensionTests/FirstCharacterToLowerFixture.cs @@ -0,0 +1,36 @@ +using System.Globalization; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Common.Test.ExtensionTests.StringExtensionTests +{ + [TestFixture] + public class FirstCharacterToLowerFixture + { + [TestCase("Hello", "hello")] + [TestCase("CamelCase", "camelCase")] + [TestCase("A Full Sentence", "a Full Sentence")] + [TestCase("", "")] + [TestCase(null, "")] + public void should_lower_case_first_character(string input, string expected) + { + input.FirstCharToLower().Should().Be(expected); + } + + [Test] + public void should_lower_case_first_character_regardless_of_culture() + { + var current = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("tr-TR"); + try + { + "InfInite".FirstCharToLower().Should().Be("infInite"); + } + finally + { + CultureInfo.CurrentCulture = current; + } + } + } +} diff --git a/src/NzbDrone.Common.Test/ExtensionTests/StringExtensionTests/FirstCharcacterToUpperFixture.cs b/src/NzbDrone.Common.Test/ExtensionTests/StringExtensionTests/FirstCharcacterToUpperFixture.cs new file mode 100644 index 000000000..5013ddd73 --- /dev/null +++ b/src/NzbDrone.Common.Test/ExtensionTests/StringExtensionTests/FirstCharcacterToUpperFixture.cs @@ -0,0 +1,36 @@ +using System.Globalization; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Common.Test.ExtensionTests.StringExtensionTests +{ + [TestFixture] + public class FirstCharcacterToUpperFixture + { + [TestCase("hello", "Hello")] + [TestCase("camelCase", "CamelCase")] + [TestCase("a full sentence", "A full sentence")] + [TestCase("", "")] + [TestCase(null, "")] + public void should_capitalize_first_character(string input, string expected) + { + input.FirstCharToUpper().Should().Be(expected); + } + + [Test] + public void should_capitalize_first_character_regardless_of_culture() + { + var current = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("tr-TR"); + try + { + "infInite".FirstCharToUpper().Should().Be("InfInite"); + } + finally + { + CultureInfo.CurrentCulture = current; + } + } + } +} 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..1eab71277 100644 --- a/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs +++ b/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -21,7 +21,6 @@ namespace NzbDrone.Common.Test.Http { [IntegrationTest] [TestFixture(typeof(ManagedHttpDispatcher))] - [TestFixture(typeof(CurlHttpDispatcher))] public class HttpClientFixture : TestBase where TDispatcher : IHttpDispatcher { private static string[] _httpBinHosts = new[] { "eu.httpbin.org", "httpbin.org" }; @@ -55,13 +54,13 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_execute_simple_get() { - var request = new HttpRequest($"http://{_httpBinHost}/get"); + var request = new HttpRequest($"https://{_httpBinHost}/get"); var response = Subject.Execute(request); response.Content.Should().NotBeNullOrWhiteSpace(); } - + [Test] public void should_execute_https_get() { @@ -75,11 +74,12 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_execute_typed_get() { - var request = new HttpRequest($"http://{_httpBinHost}/get"); + var request = new HttpRequest($"https://{_httpBinHost}/get?test=1"); var response = Subject.Get(request); - response.Resource.Url.Should().Be(request.Url.FullUri); + response.Resource.Url.EndsWith("/get?test=1"); + response.Resource.Args.Should().Contain("test", "1"); } [Test] @@ -87,7 +87,7 @@ namespace NzbDrone.Common.Test.Http { var message = "{ my: 1 }"; - var request = new HttpRequest($"http://{_httpBinHost}/post"); + var request = new HttpRequest($"https://{_httpBinHost}/post"); request.SetContent(message); var response = Subject.Post(request); @@ -98,7 +98,7 @@ namespace NzbDrone.Common.Test.Http [TestCase("gzip")] public void should_execute_get_using_gzip(string compression) { - var request = new HttpRequest($"http://{_httpBinHost}/{compression}"); + var request = new HttpRequest($"https://{_httpBinHost}/{compression}"); var response = Subject.Get(request); @@ -114,7 +114,7 @@ namespace NzbDrone.Common.Test.Http [TestCase(HttpStatusCode.BadGateway)] public void should_throw_on_unsuccessful_status_codes(int statusCode) { - var request = new HttpRequest($"http://{_httpBinHost}/status/{statusCode}"); + var request = new HttpRequest($"https://{_httpBinHost}/status/{statusCode}"); var exception = Assert.Throws(() => Subject.Get(request)); @@ -126,7 +126,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_not_follow_redirects_when_not_in_production() { - var request = new HttpRequest($"http://{_httpBinHost}/redirect/1"); + var request = new HttpRequest($"https://{_httpBinHost}/redirect/1"); Subject.Get(request); @@ -136,10 +136,57 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_follow_redirects() { - var request = new HttpRequest($"http://{_httpBinHost}/redirect/1"); + var request = new HttpRequest($"https://{_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($"https://{_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($"https://{_httpBinHost}/redirect-to") + .AddQueryParam("url", $"https://lidarr.audio/") + .Build(); + request.AllowAutoRedirect = true; + + var response = Subject.Get(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Should().Contain("Lidarr"); + + ExceptionVerification.ExpectedErrors(0); + } + + [Test] + public void should_throw_on_too_many_redirects() + { + var request = new HttpRequest($"https://{_httpBinHost}/redirect/4"); + request.AllowAutoRedirect = true; + + Assert.Throws(() => Subject.Get(request)); ExceptionVerification.ExpectedErrors(0); } @@ -147,7 +194,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_send_user_agent() { - var request = new HttpRequest($"http://{_httpBinHost}/get"); + var request = new HttpRequest($"https://{_httpBinHost}/get"); var response = Subject.Get(request); @@ -155,13 +202,13 @@ namespace NzbDrone.Common.Test.Http var userAgent = response.Resource.Headers["User-Agent"].ToString(); - userAgent.Should().Contain("Sonarr"); + userAgent.Should().Contain("Lidarr"); } [TestCase("Accept", "text/xml, text/rss+xml, application/rss+xml")] public void should_send_headers(string header, string value) { - var request = new HttpRequest($"http://{_httpBinHost}/get"); + var request = new HttpRequest($"https://{_httpBinHost}/get"); request.Headers.Add(header, value); var response = Subject.Get(request); @@ -174,7 +221,7 @@ namespace NzbDrone.Common.Test.Http { var file = GetTempFilePath(); - Assert.Throws(() => Subject.DownloadFile("http://download.sonarr.tv/wrongpath", file)); + Assert.Throws(() => Subject.DownloadFile("https://download.lidarr.audio/wrongpath", file)); File.Exists(file).Should().BeFalse(); @@ -184,7 +231,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_send_cookie() { - var request = new HttpRequest($"http://{_httpBinHost}/get"); + var request = new HttpRequest($"https://{_httpBinHost}/get"); request.Cookies["my"] = "cookie"; var response = Subject.Get(request); @@ -198,7 +245,7 @@ namespace NzbDrone.Common.Test.Http public void GivenOldCookie() { - var oldRequest = new HttpRequest("http://eu.httpbin.org/get"); + var oldRequest = new HttpRequest("https://eu.httpbin.org/get"); oldRequest.Cookies["my"] = "cookie"; var oldClient = new HttpClient(new IHttpRequestInterceptor[0], Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), Mocker.GetMock().Object, Mocker.Resolve()); @@ -215,7 +262,7 @@ namespace NzbDrone.Common.Test.Http { GivenOldCookie(); - var request = new HttpRequest("http://eu.httpbin.org/get"); + var request = new HttpRequest("https://eu.httpbin.org/get"); var response = Subject.Get(request); @@ -231,26 +278,103 @@ namespace NzbDrone.Common.Test.Http { GivenOldCookie(); - var request = new HttpRequest("http://httpbin.org/get"); + var request = new HttpRequest("https://httpbin.org/get"); var response = Subject.Get(request); response.Resource.Headers.Should().NotContainKey("Cookie"); } + [Test] + public void should_not_store_request_cookie() + { + var requestGet = new HttpRequest($"https://{_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($"https://{_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($"https://{_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($"https://{_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($"https://{_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($"https://{_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($"https://{_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"); + var requestSet = new HttpRequest($"https://{_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($"https://{_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(); } @@ -258,21 +382,33 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_store_response_cookie() { - var requestSet = new HttpRequest($"http://{_httpBinHost}/cookies/set?my=cookie"); + var requestSet = new HttpRequest($"https://{_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($"https://{_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($"https://{_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(); } @@ -280,22 +416,130 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_overwrite_response_cookie() { - var requestSet = new HttpRequest($"http://{_httpBinHost}/cookies/set?my=cookie"); + var requestSet = new HttpRequest($"https://{_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($"https://{_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($"https://{_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($"https://{_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($"https://{_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($"https://{_httpBinHost}/cookies/delete?my"); + requestDelete.AllowAutoRedirect = false; + requestDelete.StoreRequestCookie = false; + requestDelete.StoreResponseCookie = false; + + var responseDelete = Subject.Get(requestDelete); + + requestCookies = new HttpRequest($"https://{_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($"https://{_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($"https://{_httpBinHost}/cookies/delete?my"); + requestDelete.AllowAutoRedirect = false; + requestDelete.StoreRequestCookie = false; + requestDelete.StoreResponseCookie = true; + + var responseDelete = Subject.Get(requestDelete); + + requestCookies = new HttpRequest($"https://{_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($"https://{_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($"https://{_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($"https://{_httpBinHost}/cookies"); + requestCookies.StoreRequestCookie = false; + requestCookies.StoreResponseCookie = false; + + responseCookies.Resource.Cookies.Should().HaveCount(1).And.Contain("my", "cookie"); ExceptionVerification.IgnoreErrors(); } @@ -303,7 +547,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_throw_on_http429_too_many_requests() { - var request = new HttpRequest($"http://{_httpBinHost}/status/429"); + var request = new HttpRequest($"https://{_httpBinHost}/status/429"); Assert.Throws(() => Subject.Get(request)); @@ -323,7 +567,7 @@ namespace NzbDrone.Common.Test.Http .Setup(v => v.PostResponse(It.IsAny())) .Returns(r => r); - var request = new HttpRequest($"http://{_httpBinHost}/get"); + var request = new HttpRequest($"https://{_httpBinHost}/get"); Subject.Get(request); @@ -345,7 +589,7 @@ namespace NzbDrone.Common.Test.Http { // the date is bad in the below - should be 13-Jul-2026 string malformedCookie = @"__cfduid=d29e686a9d65800021c66faca0a29b4261436890790; expires=Mon, 13-Jul-26 16:19:50 GMT; path=/; HttpOnly"; - var requestSet = new HttpRequestBuilder($"http://{_httpBinHost}/response-headers") + var requestSet = new HttpRequestBuilder($"https://{_httpBinHost}/response-headers") .AddQueryParam("Set-Cookie", malformedCookie) .Build(); @@ -354,7 +598,7 @@ namespace NzbDrone.Common.Test.Http var responseSet = Subject.Get(requestSet); - var request = new HttpRequest($"http://{_httpBinHost}/get"); + var request = new HttpRequest($"https://{_httpBinHost}/get"); var response = Subject.Get(request); @@ -378,7 +622,7 @@ namespace NzbDrone.Common.Test.Http { try { - string url = $"http://{_httpBinHost}/response-headers?Set-Cookie={Uri.EscapeUriString(malformedCookie)}"; + string url = $"https://{_httpBinHost}/response-headers?Set-Cookie={Uri.EscapeUriString(malformedCookie)}"; var requestSet = new HttpRequest(url); requestSet.AllowAutoRedirect = false; @@ -386,7 +630,7 @@ namespace NzbDrone.Common.Test.Http var responseSet = Subject.Get(requestSet); - var request = new HttpRequest($"http://{_httpBinHost}/get"); + var request = new HttpRequest($"https://{_httpBinHost}/get"); var response = Subject.Get(request); @@ -402,9 +646,15 @@ namespace NzbDrone.Common.Test.Http public class HttpBinResource { + public Dictionary Args { get; set; } public Dictionary Headers { get; set; } public string Origin { get; set; } 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/HttpHeaderFixture.cs b/src/NzbDrone.Common.Test/Http/HttpHeaderFixture.cs index 421f9d947..8abdcf6e8 100644 --- a/src/NzbDrone.Common.Test/Http/HttpHeaderFixture.cs +++ b/src/NzbDrone.Common.Test/Http/HttpHeaderFixture.cs @@ -5,6 +5,7 @@ using System; using System.Text; using NzbDrone.Common.Http; using System.Collections.Specialized; +using System.Linq; namespace NzbDrone.Common.Test.Http { @@ -36,5 +37,17 @@ namespace NzbDrone.Common.Test.Http Action action = () => httpheader.GetEncodingFromContentType(); action.ShouldThrow(); } + + [Test] + public void should_parse_cookie_with_trailing_semi_colon() + { + var cookies = HttpHeader.ParseCookies("uid=123456; pass=123456b2f3abcde42ac3a123f3f1fc9f;"); + + cookies.Count.Should().Be(2); + cookies.First().Key.Should().Be("uid"); + cookies.First().Value.Should().Be("123456"); + cookies.Last().Key.Should().Be("pass"); + cookies.Last().Value.Should().Be("123456b2f3abcde42ac3a123f3f1fc9f"); + } } } diff --git a/src/NzbDrone.Common.Test/Http/HttpUriFixture.cs b/src/NzbDrone.Common.Test/Http/HttpUriFixture.cs index 099ab990f..b76f6ca1f 100644 --- a/src/NzbDrone.Common.Test/Http/HttpUriFixture.cs +++ b/src/NzbDrone.Common.Test/Http/HttpUriFixture.cs @@ -1,4 +1,4 @@ -using FluentAssertions; +using FluentAssertions; using NUnit.Framework; using NzbDrone.Common.Http; using NzbDrone.Test.Common; @@ -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")] diff --git a/src/NzbDrone.Common.Test/InstrumentationTests/CleanseLogMessageFixture.cs b/src/NzbDrone.Common.Test/InstrumentationTests/CleanseLogMessageFixture.cs index 00c22cffb..fbf8fdee0 100644 --- a/src/NzbDrone.Common.Test/InstrumentationTests/CleanseLogMessageFixture.cs +++ b/src/NzbDrone.Common.Test/InstrumentationTests/CleanseLogMessageFixture.cs @@ -12,9 +12,10 @@ namespace NzbDrone.Common.Test.InstrumentationTests [TestCase(@"http://rss.torrentleech.org/mySecret")] [TestCase(@"http://rss.torrentleech.org/rss/download/12345/01233210/filename.torrent")] [TestCase(@"http://www.bitmetv.org/rss.php?uid=mySecret&passkey=mySecret")] - [TestCase(@"https://rss.omgwtfnzbs.org/rss-search.php?catid=19,20&user=sonarr&api=mySecret&eng=1")] + [TestCase(@"https://rss.omgwtfnzbs.org/rss-search.php?catid=19,20&user=Lidarr&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/InstrumentationTests/SentryTargetFixture.cs b/src/NzbDrone.Common.Test/InstrumentationTests/SentryTargetFixture.cs new file mode 100644 index 000000000..3a3098a23 --- /dev/null +++ b/src/NzbDrone.Common.Test/InstrumentationTests/SentryTargetFixture.cs @@ -0,0 +1,87 @@ +using NUnit.Framework; +using NzbDrone.Common.Instrumentation; +using FluentAssertions; +using NzbDrone.Common.Instrumentation.Sentry; +using System; +using NLog; +using NzbDrone.Test.Common; +using System.Globalization; +using System.Collections.Generic; +using System.Linq; + +namespace NzbDrone.Common.Test.InstrumentationTests +{ + [TestFixture] + public class SentryTargetFixture : TestBase + { + private SentryTarget Subject; + + private static LogLevel[] AllLevels = LogLevel.AllLevels.ToArray(); + private static LogLevel[] SentryLevels = LogLevel.AllLevels.Where(x => x >= LogLevel.Error).ToArray(); + private static LogLevel[] OtherLevels = AllLevels.Except(SentryLevels).ToArray(); + + private static Exception[] FilteredExceptions = new Exception[] { + new UnauthorizedAccessException(), + new TinyIoC.TinyIoCResolutionException(typeof(string)) + }; + + [SetUp] + public void Setup() + { + Subject = new SentryTarget("https://aaaaaaaaaaaaaaaaaaaaaaaaaa@sentry.io/111111"); + } + + private LogEventInfo GivenLogEvent(LogLevel level, Exception ex, string message) + { + return LogEventInfo.Create(level, "SentryTest", ex, CultureInfo.InvariantCulture, message); + } + + [Test, TestCaseSource("AllLevels")] + public void log_without_error_is_not_sentry_event(LogLevel level) + { + Subject.IsSentryMessage(GivenLogEvent(level, null, "test")).Should().BeFalse(); + } + + [Test, TestCaseSource("SentryLevels")] + public void error_or_worse_with_exception_is_sentry_event(LogLevel level) + { + Subject.IsSentryMessage(GivenLogEvent(level, new Exception(), "test")).Should().BeTrue(); + } + + [Test, TestCaseSource("OtherLevels")] + public void less_than_error_with_exception_is_not_sentry_event(LogLevel level) + { + Subject.IsSentryMessage(GivenLogEvent(level, new Exception(), "test")).Should().BeFalse(); + } + + [Test, TestCaseSource("FilteredExceptions")] + public void should_filter_event_for_filtered_exception_types(Exception ex) + { + var log = GivenLogEvent(LogLevel.Error, ex, "test"); + Subject.IsSentryMessage(log).Should().BeFalse(); + } + + [Test, TestCaseSource("FilteredExceptions")] + public void should_not_filter_event_for_filtered_exception_types_if_filtering_disabled(Exception ex) + { + Subject.FilterEvents = false; + var log = GivenLogEvent(LogLevel.Error, ex, "test"); + Subject.IsSentryMessage(log).Should().BeTrue(); + } + + [Test, TestCaseSource(typeof(SentryTarget), "FilteredExceptionMessages")] + public void should_filter_event_for_filtered_exception_messages(string message) + { + var log = GivenLogEvent(LogLevel.Error, new Exception("aaaaaaa" + message + "bbbbbbb"), "test"); + Subject.IsSentryMessage(log).Should().BeFalse(); + } + + [TestCase("A message that isn't filtered")] + [TestCase("Error")] + public void should_not_filter_event_for_exception_messages_that_are_not_filtered(string message) + { + var log = GivenLogEvent(LogLevel.Error, new Exception(message), "test"); + Subject.IsSentryMessage(log).Should().BeTrue(); + } + } +} diff --git a/src/NzbDrone.Common.Test/LevenshteinDistanceFixture.cs b/src/NzbDrone.Common.Test/LevenshteinDistanceFixture.cs index d8711191b..aff7e9738 100644 --- a/src/NzbDrone.Common.Test/LevenshteinDistanceFixture.cs +++ b/src/NzbDrone.Common.Test/LevenshteinDistanceFixture.cs @@ -42,5 +42,21 @@ namespace NzbDrone.Common.Test { text.ToLower().LevenshteinDistanceClean(other.ToLower()).Should().Be(expected); } + + [TestCase("hello", "hello")] + [TestCase("hello", "bye")] + [TestCase("a longer string", "a different long string")] + public void FuzzyMatchSymmetric(string a, string b) + { + a.FuzzyMatch(b).Should().Be(b.FuzzyMatch(a)); + } + + [TestCase("", "", 0)] + [TestCase("a", "", 0)] + [TestCase("", "a", 0)] + public void FuzzyMatchEmptyValuesReturnZero(string a, string b, double expected) + { + a.FuzzyMatch(b).Should().Be(expected); + } } } diff --git a/src/NzbDrone.Common.Test/License.txt b/src/NzbDrone.Common.Test/License.txt deleted file mode 100644 index 5ead6991a..000000000 --- a/src/NzbDrone.Common.Test/License.txt +++ /dev/null @@ -1,22 +0,0 @@ - Copyright (c) 2010 Darren Cauthon - - Permission is hereby granted, free of charge, to any person - obtaining a copy of this software and associated documentation - files (the "Software"), to deal in the Software without - restriction, including without limitation the rights to use, - copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the - Software is furnished to do so, subject to the following - conditions: - - The above copyright notice and this permission notice shall be - included in all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES - OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR - OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/NzbDrone.Common.Test/Lidarr.Common.Test.csproj b/src/NzbDrone.Common.Test/Lidarr.Common.Test.csproj new file mode 100644 index 000000000..b5db0b483 --- /dev/null +++ b/src/NzbDrone.Common.Test/Lidarr.Common.Test.csproj @@ -0,0 +1,14 @@ + + + net462 + x86 + + + + + + + + + + diff --git a/src/NzbDrone.Common.Test/NzbDrone.Common.Test.csproj b/src/NzbDrone.Common.Test/NzbDrone.Common.Test.csproj deleted file mode 100644 index bd34c1e63..000000000 --- a/src/NzbDrone.Common.Test/NzbDrone.Common.Test.csproj +++ /dev/null @@ -1,164 +0,0 @@ - - - - Debug - x86 - 8.0.30703 - 2.0 - {BEC74619-DDBB-4FBA-B517-D3E20AFC9997} - Library - Properties - NzbDrone.Common.Test - NzbDrone.Common.Test - v4.0 - 512 - ..\ - true - - - true - bin\x86\Debug\ - DEBUG;TRACE - full - x86 - prompt - MinimumRecommendedRules.ruleset - 4 - false - - - bin\x86\Release\ - TRACE - true - pdbonly - x86 - prompt - MinimumRecommendedRules.ruleset - 4 - - - - ..\packages\FluentAssertions.4.18.0\lib\net40\FluentAssertions.dll - True - - - ..\packages\FluentAssertions.4.18.0\lib\net40\FluentAssertions.Core.dll - True - - - ..\packages\NLog.4.4.1\lib\net40\NLog.dll - True - - - ..\packages\NUnit.3.5.0\lib\net40\nunit.framework.dll - True - - - - - - - - - - - ..\packages\Moq.4.0.10827\lib\NET40\Moq.dll - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - App.config - - - - - - {F2BE0FDF-6E47-4827-A420-DD4EF82407F8} - NzbDrone.Common - - - {FF5EE3B6-913B-47CE-9CEB-11C51B4E1205} - NzbDrone.Core - - - {95C11A9E-56ED-456A-8447-2C89C1139266} - NzbDrone.Host - - - {15ad7579-a314-4626-b556-663f51d97cd1} - NzbDrone.Mono - - - {911284d3-f130-459e-836c-2430b6fbf21d} - NzbDrone.Windows - - - {D12F7F2F-8A3C-415F-88FA-6DD061A84869} - NzbDrone - - - {CADDFCE0-7509-4430-8364-2074E1EEFCA2} - NzbDrone.Test.Common - - - {FAFB5948-A222-4CF6-AD14-026BE7564802} - NzbDrone.Test.Dummy - - - - - - - - - - - xcopy /s /y "$(SolutionDir)\ExternalModules\CurlSharp\libs\i386\*" "$(TargetDir)" - - - \ No newline at end of file diff --git a/src/NzbDrone.Common.Test/PathExtensionFixture.cs b/src/NzbDrone.Common.Test/PathExtensionFixture.cs index e3e7fb34a..269e4145c 100644 --- a/src/NzbDrone.Common.Test/PathExtensionFixture.cs +++ b/src/NzbDrone.Common.Test/PathExtensionFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using FluentAssertions; using Moq; @@ -19,7 +19,7 @@ namespace NzbDrone.Common.Test { var fakeEnvironment = new Mock(); - fakeEnvironment.SetupGet(c => c.AppDataFolder).Returns(@"C:\NzbDrone\".AsOsAgnostic()); + fakeEnvironment.SetupGet(c => c.AppDataFolder).Returns(@"C:\Lidarr\".AsOsAgnostic()); fakeEnvironment.SetupGet(c => c.TempFolder).Returns(@"C:\Temp\".AsOsAgnostic()); @@ -43,6 +43,7 @@ namespace NzbDrone.Common.Test result.Should().Be(clean); } + [TestCase(@"/", @"/")] [TestCase(@"/test/", @"/test")] [TestCase(@"//test/", @"/test")] [TestCase(@"//test//", @"/test")] @@ -98,7 +99,7 @@ namespace NzbDrone.Common.Test [Test] public void should_return_true_when_folder_is_parent_of_another_folder() { - var path = @"C:\Test\TV".AsOsAgnostic(); + var path = @"C:\Test\Music".AsOsAgnostic(); _parent.IsParentPath(path).Should().BeTrue(); } @@ -113,6 +114,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(); @@ -137,18 +139,34 @@ namespace NzbDrone.Common.Test } [TestCase(@"C:\Test\mydir", @"C:\Test")] - [TestCase(@"C:\Test\", @"C:")] + [TestCase(@"C:\Test\", @"C:\")] [TestCase(@"C:\", null)] - public void path_should_return_parent(string path, string parentPath) + [TestCase(@"\\server\share", null)] + [TestCase(@"\\server\share\test", @"\\server\share")] + public void path_should_return_parent_windows(string path, string parentPath) { + WindowsOnly(); + path.GetParentPath().Should().Be(parentPath); + } + + [TestCase(@"/", null)] + [TestCase(@"/test", "/")] + public void path_should_return_parent_mono(string path, string parentPath) + { + MonoOnly(); path.GetParentPath().Should().Be(parentPath); } [Test] public void path_should_return_parent_for_oversized_path() { - var path = @"/media/2e168617-f2ae-43fb-b88c-3663af1c8eea/downloads/sabnzbd/nzbdrone/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories"; - var parentPath = @"/media/2e168617-f2ae-43fb-b88c-3663af1c8eea/downloads/sabnzbd/nzbdrone/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing"; + MonoOnly(); + + // This test will fail on Windows if long path support is not enabled: https://www.howtogeek.com/266621/how-to-make-windows-10-accept-file-paths-over-260-characters/ + // It will also fail if the app isn't configured to use long path (such as resharper): https://blogs.msdn.microsoft.com/jeremykuhne/2016/07/30/net-4-6-2-and-long-paths-on-windows-10/ + + var path = @"C:\media\2e168617-f2ae-43fb-b88c-3663af1c8eea\downloads\sabnzbd\lidarr\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories".AsOsAgnostic(); + var parentPath = @"C:\media\2e168617-f2ae-43fb-b88c-3663af1c8eea\downloads\sabnzbd\lidarr\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing".AsOsAgnostic(); path.GetParentPath().Should().Be(parentPath); } @@ -233,71 +251,71 @@ namespace NzbDrone.Common.Test [Test] public void AppDataDirectory_path_test() { - GetIAppDirectoryInfo().GetAppDataPath().Should().BeEquivalentTo(@"C:\NzbDrone\".AsOsAgnostic()); + GetIAppDirectoryInfo().GetAppDataPath().Should().BeEquivalentTo(@"C:\Lidarr\".AsOsAgnostic()); } [Test] public void Config_path_test() { - GetIAppDirectoryInfo().GetConfigPath().Should().BeEquivalentTo(@"C:\NzbDrone\Config.xml".AsOsAgnostic()); + GetIAppDirectoryInfo().GetConfigPath().Should().BeEquivalentTo(@"C:\Lidarr\Config.xml".AsOsAgnostic()); } [Test] public void Sandbox() { - GetIAppDirectoryInfo().GetUpdateSandboxFolder().Should().BeEquivalentTo(@"C:\Temp\nzbdrone_update\".AsOsAgnostic()); + GetIAppDirectoryInfo().GetUpdateSandboxFolder().Should().BeEquivalentTo(@"C:\Temp\lidarr_update\".AsOsAgnostic()); } [Test] public void GetUpdatePackageFolder() { - GetIAppDirectoryInfo().GetUpdatePackageFolder().Should().BeEquivalentTo(@"C:\Temp\nzbdrone_update\NzbDrone\".AsOsAgnostic()); + GetIAppDirectoryInfo().GetUpdatePackageFolder().Should().BeEquivalentTo(@"C:\Temp\lidarr_update\Lidarr\".AsOsAgnostic()); } [Test] public void GetUpdateClientFolder() { - GetIAppDirectoryInfo().GetUpdateClientFolder().Should().BeEquivalentTo(@"C:\Temp\nzbdrone_update\NzbDrone\NzbDrone.Update\".AsOsAgnostic()); + GetIAppDirectoryInfo().GetUpdateClientFolder().Should().BeEquivalentTo(@"C:\Temp\lidarr_update\Lidarr\Lidarr.Update\".AsOsAgnostic()); } [Test] public void GetUpdateClientExePath() { - GetIAppDirectoryInfo().GetUpdateClientExePath().Should().BeEquivalentTo(@"C:\Temp\nzbdrone_update\NzbDrone.Update.exe".AsOsAgnostic()); + GetIAppDirectoryInfo().GetUpdateClientExePath().Should().BeEquivalentTo(@"C:\Temp\lidarr_update\Lidarr.Update.exe".AsOsAgnostic()); } [Test] public void GetUpdateLogFolder() { - GetIAppDirectoryInfo().GetUpdateLogFolder().Should().BeEquivalentTo(@"C:\NzbDrone\UpdateLogs\".AsOsAgnostic()); + GetIAppDirectoryInfo().GetUpdateLogFolder().Should().BeEquivalentTo(@"C:\Lidarr\UpdateLogs\".AsOsAgnostic()); } [Test] public void GetAncestorFolders_should_return_all_ancestors_in_path_Windows() { WindowsOnly(); - var path = @"C:\Test\TV\Series Title"; + var path = @"C:\Test\Music\Artist Title"; var result = path.GetAncestorFolders(); result.Count.Should().Be(4); result[0].Should().Be(@"C:\"); result[1].Should().Be(@"Test"); - result[2].Should().Be(@"TV"); - result[3].Should().Be(@"Series Title"); + result[2].Should().Be(@"Music"); + result[3].Should().Be(@"Artist Title"); } [Test] public void GetAncestorFolders_should_return_all_ancestors_in_path_Linux() { MonoOnly(); - var path = @"/Test/TV/Series Title"; + var path = @"/Test/Music/Artist Title"; var result = path.GetAncestorFolders(); result.Count.Should().Be(4); result[0].Should().Be(@"/"); result[1].Should().Be(@"Test"); - result[2].Should().Be(@"TV"); - result[3].Should().Be(@"Series Title"); + result[2].Should().Be(@"Music"); + result[3].Should().Be(@"Artist Title"); } } } diff --git a/src/NzbDrone.Common.Test/ProcessProviderFixture.cs b/src/NzbDrone.Common.Test/ProcessProviderFixture.cs new file mode 100644 index 000000000..7f4e5b7a9 --- /dev/null +++ b/src/NzbDrone.Common.Test/ProcessProviderFixture.cs @@ -0,0 +1,143 @@ +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Common.Model; +using NzbDrone.Common.Processes; +using NzbDrone.Test.Common; +using NzbDrone.Test.Dummy; + +namespace NzbDrone.Common.Test +{ + // We don't want one tests setup killing processes used in another + [NonParallelizable] + [TestFixture] + public class ProcessProviderFixture : TestBase + { + + [SetUp] + public void Setup() + { + Process.GetProcessesByName(DummyApp.DUMMY_PROCCESS_NAME).ToList().ForEach(c => + { + c.Kill(); + c.WaitForExit(); + }); + + Process.GetProcessesByName(DummyApp.DUMMY_PROCCESS_NAME).Should().BeEmpty(); + } + + [TearDown] + public void TearDown() + { + Process.GetProcessesByName(DummyApp.DUMMY_PROCCESS_NAME).ToList().ForEach(c => + { + try + { + c.Kill(); + } + catch (Win32Exception ex) + { + TestLogger.Warn(ex, "{0} when killing process", ex.Message); + } + + }); + } + + [Test] + public void GetById_should_return_null_if_process_doesnt_exist() + { + Subject.GetProcessById(1234567).Should().BeNull(); + + ExceptionVerification.ExpectedWarns(1); + } + + [TestCase(0)] + [TestCase(-1)] + [TestCase(9999)] + public void GetProcessById_should_return_null_for_invalid_process(int processId) + { + Subject.GetProcessById(processId).Should().BeNull(); + + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + public void should_be_able_to_start_process() + { + var process = StartDummyProcess(); + + var check = Subject.GetProcessById(process.Id); + check.Should().NotBeNull(); + + process.Refresh(); + process.HasExited.Should().BeFalse(); + + process.Kill(); + process.WaitForExit(); + process.HasExited.Should().BeTrue(); + } + + [Test] + [Platform(Exclude="MacOsX")] + [Retry(3)] + public void exists_should_find_running_process() + { + var process = StartDummyProcess(); + + Subject.Exists(DummyApp.DUMMY_PROCCESS_NAME).Should() + .BeTrue("expected one dummy process to be already running"); + + process.Kill(); + process.WaitForExit(); + + Subject.Exists(DummyApp.DUMMY_PROCCESS_NAME).Should().BeFalse(); + } + + + [Test] + [Platform(Exclude="MacOsX")] + public void kill_all_should_kill_all_process_with_name() + { + var dummy1 = StartDummyProcess(); + var dummy2 = StartDummyProcess(); + + Subject.KillAll(DummyApp.DUMMY_PROCCESS_NAME); + + dummy1.HasExited.Should().BeTrue(); + dummy2.HasExited.Should().BeTrue(); + } + + private Process StartDummyProcess() + { + var processStarted = new ManualResetEventSlim(); + + var path = Path.Combine(TestContext.CurrentContext.TestDirectory, DummyApp.DUMMY_PROCCESS_NAME + ".exe"); + var process = Subject.Start(path, onOutputDataReceived: (string data) => { + if (data.StartsWith("Dummy process. ID:")) + { + processStarted.Set(); + } + }); + + if (!processStarted.Wait(2000)) + { + Assert.Fail("Failed to start process within 2 sec"); + } + + return process; + } + + [Test] + [Retry(3)] + public void ToString_on_new_processInfo() + { + Console.WriteLine(new ProcessInfo().ToString()); + ExceptionVerification.MarkInconclusive(typeof(Win32Exception)); + } + } +} diff --git a/src/NzbDrone.Common.Test/ProcessProviderTests.cs b/src/NzbDrone.Common.Test/ProcessProviderTests.cs deleted file mode 100644 index 205037562..000000000 --- a/src/NzbDrone.Common.Test/ProcessProviderTests.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System; -using System.ComponentModel; -using System.Diagnostics; -using System.IO; -using System.Linq; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Common.Model; -using NzbDrone.Common.Processes; -using NzbDrone.Test.Common; -using NzbDrone.Test.Dummy; - -namespace NzbDrone.Common.Test -{ - [TestFixture] - public class ProcessProviderTests : TestBase - { - - [SetUp] - public void Setup() - { - Process.GetProcessesByName(DummyApp.DUMMY_PROCCESS_NAME).ToList().ForEach(c => - { - c.Kill(); - c.WaitForExit(); - }); - - Process.GetProcessesByName(DummyApp.DUMMY_PROCCESS_NAME).Should().BeEmpty(); - } - - [TearDown] - public void TearDown() - { - Process.GetProcessesByName(DummyApp.DUMMY_PROCCESS_NAME).ToList().ForEach(c => - { - try - { - c.Kill(); - } - catch (Win32Exception ex) - { - TestLogger.Warn(ex, "{0} when killing process", ex.Message); - } - - }); - } - - [Test] - public void GetById_should_return_null_if_process_doesnt_exist() - { - Subject.GetProcessById(1234567).Should().BeNull(); - - ExceptionVerification.ExpectedWarns(1); - } - - [TestCase(0)] - [TestCase(-1)] - [TestCase(9999)] - public void GetProcessById_should_return_null_for_invalid_process(int processId) - { - Subject.GetProcessById(processId).Should().BeNull(); - - ExceptionVerification.ExpectedWarns(1); - } - - [Test] - public void Should_be_able_to_start_process() - { - var process = Subject.Start(Path.Combine(Directory.GetCurrentDirectory(), DummyApp.DUMMY_PROCCESS_NAME + ".exe")); - - Subject.Exists(DummyApp.DUMMY_PROCCESS_NAME).Should() - .BeTrue("excepted one dummy process to be already running"); - - process.Kill(); - process.WaitForExit(); - - Subject.Exists(DummyApp.DUMMY_PROCCESS_NAME).Should().BeFalse(); - } - - - [Test] - public void kill_all_should_kill_all_process_with_name() - { - var dummy1 = StartDummyProcess(); - var dummy2 = StartDummyProcess(); - - Subject.KillAll(DummyApp.DUMMY_PROCCESS_NAME); - - dummy1.HasExited.Should().BeTrue(); - dummy2.HasExited.Should().BeTrue(); - } - - private Process StartDummyProcess() - { - return Subject.Start(DummyApp.DUMMY_PROCCESS_NAME + ".exe"); - } - - [Test] - public void ToString_on_new_processInfo() - { - Console.WriteLine(new ProcessInfo().ToString()); - ExceptionVerification.MarkInconclusive(typeof(Win32Exception)); - } - } -} diff --git a/src/NzbDrone.Common.Test/ReflectionTests/ReflectionExtensionFixture.cs b/src/NzbDrone.Common.Test/ReflectionTests/ReflectionExtensionFixture.cs index 3e6c3fab9..4480b6449 100644 --- a/src/NzbDrone.Common.Test/ReflectionTests/ReflectionExtensionFixture.cs +++ b/src/NzbDrone.Common.Test/ReflectionTests/ReflectionExtensionFixture.cs @@ -1,4 +1,4 @@ -using System.Reflection; +using System.Reflection; using FluentAssertions; using NUnit.Framework; using NzbDrone.Common.Reflection; @@ -12,7 +12,7 @@ namespace NzbDrone.Common.Test.ReflectionTests [Test] public void should_get_properties_from_models() { - var models = Assembly.Load("NzbDrone.Core").ImplementationsOf(); + var models = Assembly.Load("Lidarr.Core").ImplementationsOf(); foreach (var model in models) { @@ -23,9 +23,9 @@ namespace NzbDrone.Common.Test.ReflectionTests [Test] public void should_be_able_to_get_implementations() { - var models = Assembly.Load("NzbDrone.Core").ImplementationsOf(); + var models = Assembly.Load("Lidarr.Core").ImplementationsOf(); models.Should().NotBeEmpty(); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Common.Test/ServiceProviderFixture.cs b/src/NzbDrone.Common.Test/ServiceProviderFixture.cs new file mode 100644 index 000000000..ecbc420d6 --- /dev/null +++ b/src/NzbDrone.Common.Test/ServiceProviderFixture.cs @@ -0,0 +1,146 @@ +using System; +using System.Security.Principal; +using System.ServiceProcess; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Test.Common; +using NzbDrone.Test.Common.Categories; + +namespace NzbDrone.Common.Test +{ + [TestFixture] + [Timeout(15000)] + public class ServiceProviderFixture : TestBase + { + private const string ALWAYS_INSTALLED_SERVICE = "SCardSvr"; //Smart Card + private const string TEMP_SERVICE_NAME = "NzbDrone_Nunit"; + + [SetUp] + public void Setup() + { + WindowsOnly(); + CleanupService(); + } + + [TearDown] + public void TearDown() + { + if (OsInfo.IsWindows) + { + CleanupService(); + } + } + + + private void CleanupService() + { + if (Subject.ServiceExist(TEMP_SERVICE_NAME)) + { + Subject.Uninstall(TEMP_SERVICE_NAME); + } + + if (Subject.IsServiceRunning(ALWAYS_INSTALLED_SERVICE)) + { + Subject.Stop(ALWAYS_INSTALLED_SERVICE); + } + } + + [Test] + public void Exists_should_find_existing_service() + { + Subject.ServiceExist(ALWAYS_INSTALLED_SERVICE).Should().BeTrue(); + } + + [Test] + public void Exists_should_not_find_random_service() + { + Subject.ServiceExist("random_service_name").Should().BeFalse(); + } + + + [Test] + public void Service_should_be_installed_and_then_uninstalled() + { + if (!IsAnAdministrator()) + { + Assert.Inconclusive("Can't run test without Administrator rights"); + } + + Subject.ServiceExist(TEMP_SERVICE_NAME).Should().BeFalse("Service already installed"); + Subject.Install(TEMP_SERVICE_NAME); + Subject.ServiceExist(TEMP_SERVICE_NAME).Should().BeTrue(); + Subject.Uninstall(TEMP_SERVICE_NAME); + Subject.ServiceExist(TEMP_SERVICE_NAME).Should().BeFalse(); + + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + [Explicit] + [ManualTest] + public void UnInstallService() + { + Subject.Uninstall(ServiceProvider.SERVICE_NAME); + Subject.ServiceExist(ServiceProvider.SERVICE_NAME).Should().BeFalse(); + } + + [Test] + [Explicit] + [ManualTest] + public void Should_be_able_to_start_and_stop_service() + { + Subject.GetService(ALWAYS_INSTALLED_SERVICE).Status + .Should().NotBe(ServiceControllerStatus.Running); + + Subject.Start(ALWAYS_INSTALLED_SERVICE); + + Subject.GetService(ALWAYS_INSTALLED_SERVICE).Status + .Should().Be(ServiceControllerStatus.Running); + + Subject.Stop(ALWAYS_INSTALLED_SERVICE); + + Subject.GetService(ALWAYS_INSTALLED_SERVICE).Status + .Should().Be(ServiceControllerStatus.Stopped); + } + + [Test] + public void should_throw_if_starting_a_running_service() + { + if (!IsAnAdministrator()) + { + Assert.Inconclusive("Can't run test without Administrator rights"); + } + + Subject.GetService(ALWAYS_INSTALLED_SERVICE).Status + .Should().NotBe(ServiceControllerStatus.Running); + + Subject.Start(ALWAYS_INSTALLED_SERVICE); + Assert.Throws(() => Subject.Start(ALWAYS_INSTALLED_SERVICE)); + + + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + public void Should_log_warn_if_on_stop_if_service_is_already_stopped() + { + Subject.GetService(ALWAYS_INSTALLED_SERVICE).Status + .Should().NotBe(ServiceControllerStatus.Running); + + + Subject.Stop(ALWAYS_INSTALLED_SERVICE); + + + Subject.GetService(ALWAYS_INSTALLED_SERVICE).Status + .Should().Be(ServiceControllerStatus.Stopped); + + ExceptionVerification.ExpectedWarns(1); + } + private static bool IsAnAdministrator() + { + var principal = new WindowsPrincipal(WindowsIdentity.GetCurrent()); + return principal.IsInRole(WindowsBuiltInRole.Administrator); + } + } +} diff --git a/src/NzbDrone.Common.Test/ServiceProviderTests.cs b/src/NzbDrone.Common.Test/ServiceProviderTests.cs deleted file mode 100644 index 68d7b1789..000000000 --- a/src/NzbDrone.Common.Test/ServiceProviderTests.cs +++ /dev/null @@ -1,131 +0,0 @@ -using System; -using System.ServiceProcess; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Common.EnvironmentInfo; -using NzbDrone.Test.Common; -using NzbDrone.Test.Common.Categories; - -namespace NzbDrone.Common.Test -{ - [TestFixture] - [Timeout(15000)] - public class ServiceProviderTests : TestBase - { - private const string ALWAYS_INSTALLED_SERVICE = "SCardSvr"; //Smart Card - private const string TEMP_SERVICE_NAME = "NzbDrone_Nunit"; - - [SetUp] - public void Setup() - { - WindowsOnly(); - CleanupService(); - } - - [TearDown] - public void TearDown() - { - if (OsInfo.IsWindows) - { - CleanupService(); - } - } - - - private void CleanupService() - { - if (Subject.ServiceExist(TEMP_SERVICE_NAME)) - { - Subject.UnInstall(TEMP_SERVICE_NAME); - } - - if (Subject.IsServiceRunning(ALWAYS_INSTALLED_SERVICE)) - { - Subject.Stop(ALWAYS_INSTALLED_SERVICE); - } - } - - [Test] - public void Exists_should_find_existing_service() - { - Subject.ServiceExist(ALWAYS_INSTALLED_SERVICE).Should().BeTrue(); - } - - [Test] - public void Exists_should_not_find_random_service() - { - Subject.ServiceExist("random_service_name").Should().BeFalse(); - } - - - [Test] - public void Service_should_be_installed_and_then_uninstalled() - { - - Subject.ServiceExist(TEMP_SERVICE_NAME).Should().BeFalse("Service already installed"); - Subject.Install(TEMP_SERVICE_NAME); - Subject.ServiceExist(TEMP_SERVICE_NAME).Should().BeTrue(); - Subject.UnInstall(TEMP_SERVICE_NAME); - Subject.ServiceExist(TEMP_SERVICE_NAME).Should().BeFalse(); - - ExceptionVerification.ExpectedWarns(1); - } - - [Test] - [Explicit] - [ManualTest] - public void UnInstallService() - { - Subject.UnInstall(ServiceProvider.NZBDRONE_SERVICE_NAME); - Subject.ServiceExist(ServiceProvider.NZBDRONE_SERVICE_NAME).Should().BeFalse(); - } - - [Test] - [Explicit] - [ManualTest] - public void Should_be_able_to_start_and_stop_service() - { - Subject.GetService(ALWAYS_INSTALLED_SERVICE).Status - .Should().NotBe(ServiceControllerStatus.Running); - - Subject.Start(ALWAYS_INSTALLED_SERVICE); - - Subject.GetService(ALWAYS_INSTALLED_SERVICE).Status - .Should().Be(ServiceControllerStatus.Running); - - Subject.Stop(ALWAYS_INSTALLED_SERVICE); - - Subject.GetService(ALWAYS_INSTALLED_SERVICE).Status - .Should().Be(ServiceControllerStatus.Stopped); - } - - [Test] - public void should_throw_if_starting_a_running_serivce() - { - Subject.GetService(ALWAYS_INSTALLED_SERVICE).Status - .Should().NotBe(ServiceControllerStatus.Running); - - Subject.Start(ALWAYS_INSTALLED_SERVICE); - Assert.Throws(() => Subject.Start(ALWAYS_INSTALLED_SERVICE)); - - - ExceptionVerification.ExpectedWarns(1); - } - - [Test] - public void Should_log_warn_if_on_stop_if_service_is_already_stopped() - { - Subject.GetService(ALWAYS_INSTALLED_SERVICE).Status - .Should().NotBe(ServiceControllerStatus.Running); - - - Subject.Stop(ALWAYS_INSTALLED_SERVICE); - - - Subject.GetService(ALWAYS_INSTALLED_SERVICE).Status - .Should().Be(ServiceControllerStatus.Stopped); - - ExceptionVerification.ExpectedWarns(1); - } - } -} diff --git a/src/NzbDrone.Common.Test/WebClientTests.cs b/src/NzbDrone.Common.Test/WebClientTests.cs index 899fbadbd..589ee1bff 100644 --- a/src/NzbDrone.Common.Test/WebClientTests.cs +++ b/src/NzbDrone.Common.Test/WebClientTests.cs @@ -1,4 +1,4 @@ - + using System; using FluentAssertions; using NUnit.Framework; @@ -20,11 +20,27 @@ namespace NzbDrone.Common.Test } [TestCase("")] - [TestCase("http://")] - public void DownloadString_should_throw_on_error(string url) + public void DownloadString_should_throw_on_empty_string(string url) { Assert.Throws(() => Subject.DownloadString(url)); ExceptionVerification.ExpectedWarns(1); } + + // .net 4.6.2 throws NotSupportedException instead of ArgumentException here + [TestCase("http://")] + public void DownloadString_should_throw_on_not_supported_string_windows(string url) + { + WindowsOnly(); + Assert.Throws(() => Subject.DownloadString(url)); + ExceptionVerification.ExpectedWarns(1); + } + + [TestCase("http://")] + public void DownloadString_should_throw_on_not_supported_string_mono(string url) + { + MonoOnly(); + Assert.Throws(() => Subject.DownloadString(url)); + ExceptionVerification.ExpectedWarns(1); + } } } diff --git a/src/NzbDrone.Common.Test/packages.config b/src/NzbDrone.Common.Test/packages.config deleted file mode 100644 index cc617cf4d..000000000 --- a/src/NzbDrone.Common.Test/packages.config +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/src/NzbDrone.Common/ArchiveService.cs b/src/NzbDrone.Common/ArchiveService.cs index 02aa777c9..25cbe2f0a 100644 --- a/src/NzbDrone.Common/ArchiveService.cs +++ b/src/NzbDrone.Common/ArchiveService.cs @@ -1,10 +1,10 @@ -using System.IO; +using System; +using System.IO; using ICSharpCode.SharpZipLib.Core; using ICSharpCode.SharpZipLib.GZip; using ICSharpCode.SharpZipLib.Tar; using ICSharpCode.SharpZipLib.Zip; using NLog; -using NzbDrone.Common.EnvironmentInfo; namespace NzbDrone.Common { @@ -27,7 +27,7 @@ namespace NzbDrone.Common { _logger.Debug("Extracting archive [{0}] to [{1}]", compressedFile, destination); - if (OsInfo.IsWindows) + if (compressedFile.EndsWith(".zip", StringComparison.InvariantCultureIgnoreCase)) { ExtractZip(compressedFile, destination); } @@ -120,4 +120,4 @@ namespace NzbDrone.Common } } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Common/Cache/Cached.cs b/src/NzbDrone.Common/Cache/Cached.cs index 928809d30..572e1a560 100644 --- a/src/NzbDrone.Common/Cache/Cached.cs +++ b/src/NzbDrone.Common/Cache/Cached.cs @@ -40,6 +40,11 @@ namespace NzbDrone.Common.Cache { Ensure.That(key, () => key).IsNotNullOrWhiteSpace(); _store[key] = new CacheItem(value, lifetime); + + if (lifetime != null) + { + System.Threading.Tasks.Task.Delay(lifetime.Value).ContinueWith(t => _store.TryRemove(key, out var temp)); + } } public T Find(string key) diff --git a/src/NzbDrone.Common/Cloud/LidarrCloudRequestBuilder.cs b/src/NzbDrone.Common/Cloud/LidarrCloudRequestBuilder.cs new file mode 100644 index 000000000..bbb007fcb --- /dev/null +++ b/src/NzbDrone.Common/Cloud/LidarrCloudRequestBuilder.cs @@ -0,0 +1,30 @@ +using NzbDrone.Common.Http; + +namespace NzbDrone.Common.Cloud +{ + public interface ILidarrCloudRequestBuilder + { + IHttpRequestBuilderFactory Services { get; } + IHttpRequestBuilderFactory Search { get; } + IHttpRequestBuilderFactory InternalSearch { get; } + } + + public class LidarrCloudRequestBuilder : ILidarrCloudRequestBuilder + { + public LidarrCloudRequestBuilder() + { + Services = new HttpRequestBuilder("https://services.lidarr.audio/v1/") + .CreateFactory(); + + Search = new HttpRequestBuilder("https://api.lidarr.audio/api/v0.4/{route}") + .KeepAlive() + .CreateFactory(); + } + + public IHttpRequestBuilderFactory Services { get; } + + public IHttpRequestBuilderFactory Search { get; } + + public IHttpRequestBuilderFactory InternalSearch { get; } + } +} diff --git a/src/NzbDrone.Common/Cloud/SonarrCloudRequestBuilder.cs b/src/NzbDrone.Common/Cloud/SonarrCloudRequestBuilder.cs deleted file mode 100644 index ed00104a8..000000000 --- a/src/NzbDrone.Common/Cloud/SonarrCloudRequestBuilder.cs +++ /dev/null @@ -1,27 +0,0 @@ -using NzbDrone.Common.Http; - -namespace NzbDrone.Common.Cloud -{ - public interface ISonarrCloudRequestBuilder - { - IHttpRequestBuilderFactory Services { get; } - IHttpRequestBuilderFactory SkyHookTvdb { get; } - } - - public class SonarrCloudRequestBuilder : ISonarrCloudRequestBuilder - { - public SonarrCloudRequestBuilder() - { - Services = new HttpRequestBuilder("http://services.sonarr.tv/v1/") - .CreateFactory(); - - SkyHookTvdb = new HttpRequestBuilder("http://skyhook.sonarr.tv/v1/tvdb/{route}/{language}/") - .SetSegment("language", "en") - .CreateFactory(); - } - - public IHttpRequestBuilderFactory Services { get; } - - public IHttpRequestBuilderFactory SkyHookTvdb { get; } - } -} diff --git a/src/NzbDrone.Common/Composition/ContainerBuilderBase.cs b/src/NzbDrone.Common/Composition/ContainerBuilderBase.cs index a4174a26f..7323c06f9 100644 --- a/src/NzbDrone.Common/Composition/ContainerBuilderBase.cs +++ b/src/NzbDrone.Common/Composition/ContainerBuilderBase.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Reflection; @@ -18,8 +18,8 @@ namespace NzbDrone.Common.Composition { _loadedTypes = new List(); - assemblies.Add(OsInfo.IsWindows ? "NzbDrone.Windows" : "NzbDrone.Mono"); - assemblies.Add("NzbDrone.Common"); + assemblies.Add(OsInfo.IsWindows ? "Lidarr.Windows" : "Lidarr.Mono"); + assemblies.Add("Lidarr.Common"); foreach (var assembly in assemblies) { diff --git a/src/NzbDrone.Common/ConsoleService.cs b/src/NzbDrone.Common/ConsoleService.cs index 321831277..e44dfcf52 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; @@ -21,20 +21,26 @@ namespace NzbDrone.Common Console.WriteLine(); Console.WriteLine(" Usage: {0} ", Process.GetCurrentProcess().MainModule.ModuleName); Console.WriteLine(" Commands:"); - 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); + if (OsInfo.IsWindows) + { + Console.WriteLine(" /{0} Install the application as a Windows Service ({1}).", StartupContext.INSTALL_SERVICE, ServiceProvider.SERVICE_NAME); + Console.WriteLine(" /{0} Uninstall already installed Windows Service ({1}).", StartupContext.UNINSTALL_SERVICE, ServiceProvider.SERVICE_NAME); + Console.WriteLine(" /{0} Register URL and open firewall port (allows access from other devices on your network).", StartupContext.REGISTER_URL); + } + Console.WriteLine(" /{0} Don't open Lidarr in a browser", StartupContext.NO_BROWSER); + Console.WriteLine(" /{0} Start Lidarr 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."); } public void PrintServiceAlreadyExist() { - Console.WriteLine("A service with the same name ({0}) already exists. Aborting installation", ServiceProvider.NZBDRONE_SERVICE_NAME); + Console.WriteLine("A service with the same name ({0}) already exists. Aborting installation", ServiceProvider.SERVICE_NAME); } public void PrintServiceDoesNotExist() { - Console.WriteLine("Can't find service ({0})", ServiceProvider.NZBDRONE_SERVICE_NAME); + Console.WriteLine("Can't find service ({0})", ServiceProvider.SERVICE_NAME); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Common/CurlSharp.dll.config b/src/NzbDrone.Common/CurlSharp.dll.config deleted file mode 100644 index eadeb9ad7..000000000 --- a/src/NzbDrone.Common/CurlSharp.dll.config +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - 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..de37a7ac3 100644 --- a/src/NzbDrone.Common/Disk/DiskProviderBase.cs +++ b/src/NzbDrone.Common/Disk/DiskProviderBase.cs @@ -1,6 +1,7 @@ -using System; +using System; using System.Collections.Generic; using System.IO; +using System.IO.Abstractions; using System.Linq; using System.Security.AccessControl; using System.Security.Principal; @@ -15,6 +16,12 @@ namespace NzbDrone.Common.Disk public abstract class DiskProviderBase : IDiskProvider { private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(DiskProviderBase)); + protected readonly IFileSystem _fileSystem; + + public DiskProviderBase(IFileSystem fileSystem) + { + _fileSystem = fileSystem; + } public static StringComparison PathStringComparison { @@ -38,7 +45,7 @@ namespace NzbDrone.Common.Disk { CheckFolderExists(path); - return new DirectoryInfo(path).CreationTimeUtc; + return _fileSystem.DirectoryInfo.FromDirectoryName(path).CreationTimeUtc; } public DateTime FolderGetLastWrite(string path) @@ -49,17 +56,17 @@ namespace NzbDrone.Common.Disk if (!dirFiles.Any()) { - return new DirectoryInfo(path).LastWriteTimeUtc; + return _fileSystem.DirectoryInfo.FromDirectoryName(path).LastWriteTimeUtc; } - return dirFiles.Select(f => new FileInfo(f)).Max(c => c.LastWriteTimeUtc); + return dirFiles.Select(f => _fileSystem.FileInfo.FromFileName(f)).Max(c => c.LastWriteTimeUtc); } public DateTime FileGetLastWrite(string path) { CheckFileExists(path); - return new FileInfo(path).LastWriteTimeUtc; + return _fileSystem.FileInfo.FromFileName(path).LastWriteTimeUtc; } private void CheckFolderExists(string path) @@ -93,7 +100,7 @@ namespace NzbDrone.Common.Disk public bool FolderExists(string path) { Ensure.That(path, () => path).IsValidPath(); - return Directory.Exists(path); + return _fileSystem.Directory.Exists(path); } public bool FileExists(string path) @@ -112,11 +119,11 @@ namespace NzbDrone.Common.Disk case StringComparison.InvariantCulture: case StringComparison.Ordinal: { - return File.Exists(path) && path == path.GetActualCasing(); + return _fileSystem.File.Exists(path) && path == path.GetActualCasing(); } default: { - return File.Exists(path); + return _fileSystem.File.Exists(path); } } } @@ -127,10 +134,10 @@ namespace NzbDrone.Common.Disk try { - var testPath = Path.Combine(path, "sonarr_write_test.txt"); + var testPath = Path.Combine(path, "lidarr_write_test.txt"); var testContent = $"This file was created to verify if '{path}' is writable. It should've been automatically deleted. Feel free to delete it."; - File.WriteAllText(testPath, testContent); - File.Delete(testPath); + _fileSystem.File.WriteAllText(testPath, testContent); + _fileSystem.File.Delete(testPath); return true; } catch (Exception e) @@ -144,21 +151,21 @@ namespace NzbDrone.Common.Disk { Ensure.That(path, () => path).IsValidPath(); - return Directory.GetDirectories(path); + return _fileSystem.Directory.GetDirectories(path); } public string[] GetFiles(string path, SearchOption searchOption) { Ensure.That(path, () => path).IsValidPath(); - return Directory.GetFiles(path, "*.*", searchOption); + return _fileSystem.Directory.GetFiles(path, "*.*", searchOption); } public long GetFolderSize(string path) { Ensure.That(path, () => path).IsValidPath(); - return GetFiles(path, SearchOption.AllDirectories).Sum(e => new FileInfo(e).Length); + return GetFiles(path, SearchOption.AllDirectories).Sum(e => _fileSystem.FileInfo.FromFileName(e).Length); } public long GetFileSize(string path) @@ -170,14 +177,14 @@ namespace NzbDrone.Common.Disk throw new FileNotFoundException("File doesn't exist: " + path); } - var fi = new FileInfo(path); + var fi = _fileSystem.FileInfo.FromFileName(path); return fi.Length; } public void CreateFolder(string path) { Ensure.That(path, () => path).IsValidPath(); - Directory.CreateDirectory(path); + _fileSystem.Directory.CreateDirectory(path); } public void DeleteFile(string path) @@ -187,7 +194,7 @@ namespace NzbDrone.Common.Disk RemoveReadOnly(path); - File.Delete(path); + _fileSystem.File.Delete(path); } public void CopyFile(string source, string destination, bool overwrite = false) @@ -200,7 +207,12 @@ namespace NzbDrone.Common.Disk throw new IOException(string.Format("Source and destination can't be the same {0}", source)); } - File.Copy(source, destination, overwrite); + CopyFileInternal(source, destination, overwrite); + } + + protected virtual void CopyFileInternal(string source, string destination, bool overwrite = false) + { + _fileSystem.File.Copy(source, destination, overwrite); } public void MoveFile(string source, string destination, bool overwrite = false) @@ -219,7 +231,20 @@ namespace NzbDrone.Common.Disk } RemoveReadOnly(source); - File.Move(source, destination); + MoveFileInternal(source, destination); + } + + public void MoveFolder(string source, string destination) + { + Ensure.That(source, () => source).IsValidPath(); + Ensure.That(destination, () => destination).IsValidPath(); + + Directory.Move(source, destination); + } + + protected virtual void MoveFileInternal(string source, string destination) + { + _fileSystem.File.Move(source, destination); } public abstract bool TryCreateHardLink(string source, string destination); @@ -228,45 +253,45 @@ namespace NzbDrone.Common.Disk { Ensure.That(path, () => path).IsValidPath(); - var files = Directory.GetFiles(path, "*.*", recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly); + var files = _fileSystem.Directory.GetFiles(path, "*.*", recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly); Array.ForEach(files, RemoveReadOnly); - Directory.Delete(path, recursive); + _fileSystem.Directory.Delete(path, recursive); } public string ReadAllText(string filePath) { Ensure.That(filePath, () => filePath).IsValidPath(); - return File.ReadAllText(filePath); + return _fileSystem.File.ReadAllText(filePath); } public void WriteAllText(string filename, string contents) { Ensure.That(filename, () => filename).IsValidPath(); RemoveReadOnly(filename); - File.WriteAllText(filename, contents); + _fileSystem.File.WriteAllText(filename, contents); } public void FolderSetLastWriteTime(string path, DateTime dateTime) { Ensure.That(path, () => path).IsValidPath(); - Directory.SetLastWriteTimeUtc(path, dateTime); + _fileSystem.Directory.SetLastWriteTimeUtc(path, dateTime); } public void FileSetLastWriteTime(string path, DateTime dateTime) { Ensure.That(path, () => path).IsValidPath(); - File.SetLastWriteTime(path, dateTime); + _fileSystem.File.SetLastWriteTime(path, dateTime); } public bool IsFileLocked(string file) { try { - using (File.Open(file, FileMode.Open, FileAccess.Read, FileShare.None)) + using (_fileSystem.File.Open(file, FileMode.Open, FileAccess.Read, FileShare.None)) { return false; } @@ -288,7 +313,7 @@ namespace NzbDrone.Common.Disk { Ensure.That(path, () => path).IsValidPath(); - var parent = Directory.GetParent(path.TrimEnd(Path.DirectorySeparatorChar)); + var parent = _fileSystem.Directory.GetParent(path.TrimEnd(Path.DirectorySeparatorChar)); if (parent == null) { @@ -304,7 +329,7 @@ namespace NzbDrone.Common.Disk { var sid = new SecurityIdentifier(accountSid, null); - var directoryInfo = new DirectoryInfo(filename); + var directoryInfo = _fileSystem.DirectoryInfo.FromDirectoryName(filename); var directorySecurity = directoryInfo.GetAccessControl(AccessControlSections.Access); var rules = directorySecurity.GetAccessRules(true, false, typeof(SecurityIdentifier)); @@ -350,7 +375,7 @@ namespace NzbDrone.Common.Disk public FileAttributes GetFileAttributes(string path) { - return File.GetAttributes(path); + return _fileSystem.File.GetAttributes(path); } public void EmptyFolder(string path) @@ -392,15 +417,20 @@ namespace NzbDrone.Common.Disk throw new FileNotFoundException("Unable to find file: " + path, path); } - return new FileStream(path, FileMode.Open, FileAccess.Read); + return (FileStream) _fileSystem.FileStream.Create(path, FileMode.Open, FileAccess.Read); } public FileStream OpenWriteStream(string path) { - return new FileStream(path, FileMode.Create); + return (FileStream) _fileSystem.FileStream.Create(path, FileMode.Create); + } + + public List GetMounts() + { + return GetAllMounts().Where(d => !IsSpecialMount(d)).ToList(); } - public virtual List GetMounts() + protected virtual List GetAllMounts() { return GetDriveInfoMounts().Where(d => d.DriveType == DriveType.Fixed || d.DriveType == DriveType.Network || d.DriveType == DriveType.Removable) .Select(d => new DriveInfoMount(d)) @@ -408,11 +438,16 @@ namespace NzbDrone.Common.Disk .ToList(); } + protected virtual bool IsSpecialMount(IMount mount) + { + return false; + } + public virtual IMount GetMount(string path) { try { - var mounts = GetMounts(); + var mounts = GetAllMounts(); return mounts.Where(drive => drive.RootDirectory.PathEquals(path) || drive.RootDirectory.IsParentPath(path)) @@ -426,29 +461,41 @@ namespace NzbDrone.Common.Disk } } - protected List GetDriveInfoMounts() + protected List GetDriveInfoMounts() { - return DriveInfo.GetDrives() - .Where(d => d.IsReady) - .ToList(); + return _fileSystem.DriveInfo.GetDrives() + .Where(d => d.IsReady) + .ToList(); } - public List GetDirectoryInfos(string path) + public List GetDirectoryInfos(string path) { Ensure.That(path, () => path).IsValidPath(); - var di = new DirectoryInfo(path); + var di = _fileSystem.DirectoryInfo.FromDirectoryName(path); return di.GetDirectories().ToList(); } - public List GetFileInfos(string path) + public IDirectoryInfo GetDirectoryInfo(string path) { Ensure.That(path, () => path).IsValidPath(); + return _fileSystem.DirectoryInfo.FromDirectoryName(path); + } - var di = new DirectoryInfo(path); + public List GetFileInfos(string path, SearchOption searchOption = SearchOption.TopDirectoryOnly) + { + Ensure.That(path, () => path).IsValidPath(); - return di.GetFiles().ToList(); + var di = _fileSystem.DirectoryInfo.FromDirectoryName(path); + + return di.GetFiles("*", searchOption).ToList(); + } + + public IFileInfo GetFileInfo(string path) + { + Ensure.That(path, () => path).IsValidPath(); + return _fileSystem.FileInfo.FromFileName(path); } public void RemoveEmptySubfolders(string path) @@ -464,5 +511,13 @@ namespace NzbDrone.Common.Disk } } } + + public void SaveStream(Stream stream, string path) + { + using (var fileStream = OpenWriteStream(path)) + { + stream.CopyTo(fileStream); + } + } } } diff --git a/src/NzbDrone.Common/Disk/DiskTransferService.cs b/src/NzbDrone.Common/Disk/DiskTransferService.cs index 3f93c11e4..947ad16d3 100644 --- a/src/NzbDrone.Common/Disk/DiskTransferService.cs +++ b/src/NzbDrone.Common/Disk/DiskTransferService.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.IO; +using System.IO.Abstractions; using System.Linq; using System.Threading; using NLog; @@ -55,6 +56,23 @@ namespace NzbDrone.Common.Disk Ensure.That(sourcePath, () => sourcePath).IsValidPath(); Ensure.That(targetPath, () => targetPath).IsValidPath(); + if (mode == TransferMode.Move && !_diskProvider.FolderExists(targetPath)) + { + if (verificationMode == DiskTransferVerificationMode.TryTransactional || verificationMode == DiskTransferVerificationMode.VerifyOnly) + { + var sourceMount = _diskProvider.GetMount(sourcePath); + var targetMount = _diskProvider.GetMount(targetPath); + + // If we're on the same mount, do a simple folder move. + if (sourceMount != null && targetMount != null && sourceMount.RootDirectory == targetMount.RootDirectory) + { + _logger.Debug("Move Directory [{0}] > [{1}]", sourcePath, targetPath); + _diskProvider.MoveFolder(sourcePath, targetPath); + return mode; + } + } + } + if (!_diskProvider.FolderExists(targetPath)) { _diskProvider.CreateFolder(targetPath); @@ -223,7 +241,7 @@ namespace NzbDrone.Common.Disk _diskProvider.MoveFile(sourcePath, tempPath, true); try { - ClearTargetPath(targetPath, overwrite); + ClearTargetPath(sourcePath, targetPath, overwrite); _diskProvider.MoveFile(tempPath, targetPath); @@ -253,7 +271,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 +348,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 +358,7 @@ namespace NzbDrone.Common.Disk } else { - throw new IOException(string.Format("Destination already exists [{0}]", targetPath)); + throw new DestinationAlreadyExistsException($"Destination {targetPath} already exists."); } } } @@ -577,7 +595,7 @@ namespace NzbDrone.Common.Disk } } - private bool ShouldIgnore(DirectoryInfo folder) + private bool ShouldIgnore(IDirectoryInfo folder) { if (folder.Name.StartsWith(".nfs")) { @@ -588,9 +606,9 @@ namespace NzbDrone.Common.Disk return false; } - private bool ShouldIgnore(FileInfo file) + private bool ShouldIgnore(IFileInfo 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..d80513666 100644 --- a/src/NzbDrone.Common/Disk/DriveInfoMount.cs +++ b/src/NzbDrone.Common/Disk/DriveInfoMount.cs @@ -1,17 +1,20 @@ -using System.IO; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; using NzbDrone.Common.Extensions; namespace NzbDrone.Common.Disk { public class DriveInfoMount : IMount { - private readonly DriveInfo _driveInfo; + private readonly IDriveInfo _driveInfo; private readonly DriveType _driveType; - public DriveInfoMount(DriveInfo driveInfo, DriveType driveType = DriveType.Unknown) + public DriveInfoMount(IDriveInfo driveInfo, DriveType driveType = DriveType.Unknown, MountOptions mountOptions = null) { _driveInfo = driveInfo; _driveType = driveType; + MountOptions = mountOptions; } public long AvailableFreeSpace => _driveInfo.AvailableFreeSpace; @@ -33,6 +36,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 +52,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/FileSystemLookupService.cs b/src/NzbDrone.Common/Disk/FileSystemLookupService.cs index b262c9918..819407cd2 100644 --- a/src/NzbDrone.Common/Disk/FileSystemLookupService.cs +++ b/src/NzbDrone.Common/Disk/FileSystemLookupService.cs @@ -1,8 +1,7 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; -using NLog; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; @@ -10,13 +9,13 @@ namespace NzbDrone.Common.Disk { public interface IFileSystemLookupService { - FileSystemResult LookupContents(string query, bool includeFiles); + FileSystemResult LookupContents(string query, bool includeFiles, bool allowFoldersWithoutTrailingSlashes); } public class FileSystemLookupService : IFileSystemLookupService { private readonly IDiskProvider _diskProvider; - private readonly Logger _logger; + private readonly IRuntimeInfo _runtimeInfo; private readonly HashSet _setToRemove = new HashSet { @@ -48,20 +47,19 @@ namespace NzbDrone.Common.Disk "@eadir" }; - public FileSystemLookupService(IDiskProvider diskProvider, Logger logger) + public FileSystemLookupService(IDiskProvider diskProvider, IRuntimeInfo runtimeInfo) { _diskProvider = diskProvider; - _logger = logger; + _runtimeInfo = runtimeInfo; } - public FileSystemResult LookupContents(string query, bool includeFiles) + public FileSystemResult LookupContents(string query, bool includeFiles, bool allowFoldersWithoutTrailingSlashes) { - var result = new FileSystemResult(); - if (query.IsNullOrWhiteSpace()) { if (OsInfo.IsWindows) { + var result = new FileSystemResult(); result.Directories = GetDrives(); return result; @@ -70,46 +68,38 @@ namespace NzbDrone.Common.Disk query = "/"; } + if ( + allowFoldersWithoutTrailingSlashes && + query.IsPathValid() && + _diskProvider.FolderExists(query)) + { + return GetResult(query, includeFiles); + } + var lastSeparatorIndex = query.LastIndexOf(Path.DirectorySeparatorChar); var path = query.Substring(0, lastSeparatorIndex + 1); if (lastSeparatorIndex != -1) { - try - { - result.Parent = GetParent(path); - result.Directories = GetDirectories(path); - - if (includeFiles) - { - result.Files = GetFiles(path); - } - } - - catch (DirectoryNotFoundException) - { - return new FileSystemResult { Parent = GetParent(path) }; - } - catch (ArgumentException) - { - return new FileSystemResult(); - } - catch (IOException) - { - return new FileSystemResult { Parent = GetParent(path) }; - } - catch (UnauthorizedAccessException) - { - return new FileSystemResult { Parent = GetParent(path) }; - } + return GetResult(path, includeFiles); } - return result; + return new FileSystemResult(); } private List GetDrives() { return _diskProvider.GetMounts() + .Where(d => + { + // Fow Windows Services, exclude mapped network drives. + if (_runtimeInfo.IsWindowsService) + { + return d.DriveType != DriveType.Network; + } + + return true; + }) .Select(d => new FileSystemModel { Type = FileSystemEntityType.Drive, @@ -120,6 +110,41 @@ namespace NzbDrone.Common.Disk .ToList(); } + private FileSystemResult GetResult(string path, bool includeFiles) + { + var result = new FileSystemResult(); + + try + { + result.Parent = GetParent(path); + result.Directories = GetDirectories(path); + + if (includeFiles) + { + result.Files = GetFiles(path); + } + } + + catch (DirectoryNotFoundException) + { + return new FileSystemResult { Parent = GetParent(path) }; + } + catch (ArgumentException) + { + return new FileSystemResult(); + } + catch (IOException) + { + return new FileSystemResult { Parent = GetParent(path) }; + } + catch (UnauthorizedAccessException) + { + return new FileSystemResult { Parent = GetParent(path) }; + } + + return result; + } + private List GetDirectories(string path) { var directories = _diskProvider.GetDirectoryInfos(path) @@ -154,6 +179,16 @@ namespace NzbDrone.Common.Disk .ToList(); } + private static string GetVolumeName(IMount mountInfo) + { + if (mountInfo.VolumeLabel.IsNullOrWhiteSpace()) + { + return mountInfo.Name; + } + + return $"{mountInfo.Name} ({mountInfo.VolumeLabel})"; + } + private string GetDirectoryPath(string path) { if (path.Last() != Path.DirectorySeparatorChar) @@ -164,7 +199,7 @@ namespace NzbDrone.Common.Disk return path; } - private string GetParent(string path) + private static string GetParent(string path) { var di = new DirectoryInfo(path); diff --git a/src/NzbDrone.Common/Disk/IDiskProvider.cs b/src/NzbDrone.Common/Disk/IDiskProvider.cs index 5ed461fbb..c4cec3fc0 100644 --- a/src/NzbDrone.Common/Disk/IDiskProvider.cs +++ b/src/NzbDrone.Common/Disk/IDiskProvider.cs @@ -1,6 +1,7 @@ -using System; +using System; using System.Collections.Generic; using System.IO; +using System.IO.Abstractions; using System.Security.AccessControl; using System.Security.Principal; @@ -28,6 +29,7 @@ namespace NzbDrone.Common.Disk void DeleteFile(string path); void CopyFile(string source, string destination, bool overwrite = false); void MoveFile(string source, string destination, bool overwrite = false); + void MoveFolder(string source, string destination); bool TryCreateHardLink(string source, string destination); void DeleteFolder(string path, bool recursive); string ReadAllText(string filePath); @@ -45,8 +47,11 @@ namespace NzbDrone.Common.Disk FileStream OpenWriteStream(string path); List GetMounts(); IMount GetMount(string path); - List GetDirectoryInfos(string path); - List GetFileInfos(string path); + IDirectoryInfo GetDirectoryInfo(string path); + List GetDirectoryInfos(string path); + IFileInfo GetFileInfo(string path); + List GetFileInfos(string path, SearchOption searchOption = SearchOption.TopDirectoryOnly); void RemoveEmptySubfolders(string path); + void SaveStream(Stream stream, string path); } } 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/LongPathSupport.cs b/src/NzbDrone.Common/Disk/LongPathSupport.cs new file mode 100644 index 000000000..916123f8d --- /dev/null +++ b/src/NzbDrone.Common/Disk/LongPathSupport.cs @@ -0,0 +1,15 @@ +using System; + +namespace NzbDrone.Common.Disk +{ + public static class LongPathSupport + { + public static void Enable() + { + // Mono has an issue with enabling long path support via app.config. + // This works for both mono and .net on Windows. + AppContext.SetSwitch("Switch.System.IO.UseLegacyPathHandling", false); + AppContext.SetSwitch("Switch.System.IO.BlockLongPaths", false); + } + } +} 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 80% rename from src/NzbDrone.Common/Exceptions/NotParentException.cs rename to src/NzbDrone.Common/Disk/NotParentException.cs index d9b78247e..66dae7789 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/Disk/SystemFolders.cs b/src/NzbDrone.Common/Disk/SystemFolders.cs new file mode 100644 index 000000000..c108e3d02 --- /dev/null +++ b/src/NzbDrone.Common/Disk/SystemFolders.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Common.EnvironmentInfo; + +namespace NzbDrone.Common.Disk +{ + public static class SystemFolders + { + public static List GetSystemFolders() + { + if (OsInfo.IsWindows) + { + return new List { Environment.GetFolderPath(Environment.SpecialFolder.Windows) }; + } + + if (OsInfo.IsOsx) + { + return new List { "/System" }; + } + + return new List + { + "/bin", + "/boot", + "/lib", + "/sbin", + "/proc" + }; + } + } +} diff --git a/src/NzbDrone.Common/EnsureThat/Resources/ExceptionMessages.Designer.cs b/src/NzbDrone.Common/EnsureThat/Resources/ExceptionMessages.Designer.cs index 5b800bb9c..0dda76095 100644 --- a/src/NzbDrone.Common/EnsureThat/Resources/ExceptionMessages.Designer.cs +++ b/src/NzbDrone.Common/EnsureThat/Resources/ExceptionMessages.Designer.cs @@ -1,7 +1,7 @@ //------------------------------------------------------------------------------ // // This code was generated by a tool. -// Runtime Version:4.0.30319.17626 +// Runtime Version:4.0.30319.42000 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. @@ -9,6 +9,9 @@ //------------------------------------------------------------------------------ namespace NzbDrone.Common.EnsureThat.Resources { + using System; + + /// /// A strongly-typed resource class, for looking up localized strings, etc. /// @@ -16,7 +19,7 @@ namespace NzbDrone.Common.EnsureThat.Resources { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] internal class ExceptionMessages { diff --git a/src/NzbDrone.Common/EnvironmentInfo/AppFolderFactory.cs b/src/NzbDrone.Common/EnvironmentInfo/AppFolderFactory.cs index 7132d539f..1b8e38b8c 100644 --- a/src/NzbDrone.Common/EnvironmentInfo/AppFolderFactory.cs +++ b/src/NzbDrone.Common/EnvironmentInfo/AppFolderFactory.cs @@ -1,8 +1,11 @@ -using System; +using System; +using System.IO; using System.Security.AccessControl; using System.Security.Principal; using NLog; using NzbDrone.Common.Disk; +using NzbDrone.Common.Exceptions; +using NzbDrone.Common.Extensions; using NzbDrone.Common.Instrumentation; namespace NzbDrone.Common.EnvironmentInfo @@ -18,7 +21,10 @@ namespace NzbDrone.Common.EnvironmentInfo private readonly IDiskProvider _diskProvider; private readonly Logger _logger; - public AppFolderFactory(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider) + public AppFolderFactory(IAppFolderInfo appFolderInfo, + IStartupContext startupContext, + IDiskProvider diskProvider, + IDiskTransferService diskTransferService) { _appFolderInfo = appFolderInfo; _diskProvider = diskProvider; @@ -27,12 +33,27 @@ namespace NzbDrone.Common.EnvironmentInfo public void Register() { - _diskProvider.EnsureFolder(_appFolderInfo.AppDataFolder); + try + { + _diskProvider.EnsureFolder(_appFolderInfo.AppDataFolder); + } + catch (UnauthorizedAccessException) + { + throw new LidarrStartupException("Cannot create AppFolder, Access to the path {0} is denied", _appFolderInfo.AppDataFolder); + } + if (OsInfo.IsWindows) { SetPermissions(); } + + if (!_diskProvider.FolderWritable(_appFolderInfo.AppDataFolder)) + { + throw new LidarrStartupException("AppFolder {0} is not writable", _appFolderInfo.AppDataFolder); + } + + InitializeMonoApplicationData(); } private void SetPermissions() @@ -46,5 +67,35 @@ namespace NzbDrone.Common.EnvironmentInfo _logger.Warn(ex, "Coudn't set app folder permission"); } } + + private void InitializeMonoApplicationData() + { + if (OsInfo.IsWindows) return; + + try + { + var configHome = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + if (configHome == "/.config" || + configHome.EndsWith("/.config") && !_diskProvider.FolderExists(configHome.GetParentPath()) || + !_diskProvider.FolderExists(configHome)) + { + // Tell mono to use appData/.config as ApplicationData folder. + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", Path.Combine(_appFolderInfo.AppDataFolder, ".config")); + } + + var dataHome = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (dataHome == "/.local/share" || + dataHome.EndsWith("/.local/share") && !_diskProvider.FolderExists(dataHome.GetParentPath().GetParentPath()) || + !_diskProvider.FolderExists(dataHome)) + { + // Tell mono to use appData/.config/share as LocalApplicationData folder. + Environment.SetEnvironmentVariable("XDG_DATA_HOME", Path.Combine(_appFolderInfo.AppDataFolder, ".config/share")); + } + } + catch (Exception ex) + { + _logger.Warn(ex, "Failed to initialize the mono config directory."); + } + } } } diff --git a/src/NzbDrone.Common/EnvironmentInfo/AppFolderInfo.cs b/src/NzbDrone.Common/EnvironmentInfo/AppFolderInfo.cs index b93d9f870..b43199902 100644 --- a/src/NzbDrone.Common/EnvironmentInfo/AppFolderInfo.cs +++ b/src/NzbDrone.Common/EnvironmentInfo/AppFolderInfo.cs @@ -34,7 +34,7 @@ namespace NzbDrone.Common.EnvironmentInfo } else { - AppDataFolder = Path.Combine(Environment.GetFolderPath(DATA_SPECIAL_FOLDER, Environment.SpecialFolderOption.None), "NzbDrone"); + AppDataFolder = Path.Combine(Environment.GetFolderPath(DATA_SPECIAL_FOLDER, Environment.SpecialFolderOption.None), "Lidarr"); } StartUpFolder = new FileInfo(Assembly.GetExecutingAssembly().Location).Directory.FullName; diff --git a/src/NzbDrone.Common/EnvironmentInfo/BuildInfo.cs b/src/NzbDrone.Common/EnvironmentInfo/BuildInfo.cs index 84a1d3435..15dc9ae98 100644 --- a/src/NzbDrone.Common/EnvironmentInfo/BuildInfo.cs +++ b/src/NzbDrone.Common/EnvironmentInfo/BuildInfo.cs @@ -1,5 +1,4 @@ using System; -using System.Diagnostics; using System.IO; using System.Linq; using System.Reflection; @@ -16,7 +15,7 @@ namespace NzbDrone.Common.EnvironmentInfo var attributes = assembly.GetCustomAttributes(true); - Branch = "unknow"; + Branch = "unknown"; var config = attributes.OfType().FirstOrDefault(); if (config != null) @@ -27,6 +26,8 @@ namespace NzbDrone.Common.EnvironmentInfo Release = $"{Version}-{Branch}"; } + public static string AppName { get; } = "Lidarr"; + public static Version Version { get; } public static String Branch { get; } public static string Release { get; } @@ -52,4 +53,4 @@ namespace NzbDrone.Common.EnvironmentInfo } } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Common/EnvironmentInfo/IOperatingSystemVersionInfo.cs b/src/NzbDrone.Common/EnvironmentInfo/IOperatingSystemVersionInfo.cs deleted file mode 100644 index e953ed884..000000000 --- a/src/NzbDrone.Common/EnvironmentInfo/IOperatingSystemVersionInfo.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace NzbDrone.Common.EnvironmentInfo -{ - public interface IOperatingSystemVersionInfo - { - string Version { get; } - string Name { get; } - string FullName { get; } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Common/EnvironmentInfo/IRuntimeInfo.cs b/src/NzbDrone.Common/EnvironmentInfo/IRuntimeInfo.cs index cb432addc..a8e4bd9ad 100644 --- a/src/NzbDrone.Common/EnvironmentInfo/IRuntimeInfo.cs +++ b/src/NzbDrone.Common/EnvironmentInfo/IRuntimeInfo.cs @@ -1,14 +1,18 @@ -using System; +using System; namespace NzbDrone.Common.EnvironmentInfo { public interface IRuntimeInfo { + DateTime StartTime { get; } bool IsUserInteractive { get; } bool IsAdmin { get; } bool IsWindowsService { get; } + bool IsWindowsTray { get; } bool IsExiting { get; set; } + bool IsTray { get; } + RuntimeMode Mode { get; } bool RestartPending { get; set; } string ExecutingApplication { get; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Common/EnvironmentInfo/OsInfo.cs b/src/NzbDrone.Common/EnvironmentInfo/OsInfo.cs index abe4070c6..4579ca48f 100644 --- a/src/NzbDrone.Common/EnvironmentInfo/OsInfo.cs +++ b/src/NzbDrone.Common/EnvironmentInfo/OsInfo.cs @@ -15,6 +15,9 @@ namespace NzbDrone.Common.EnvironmentInfo public static bool IsOsx => Os == Os.Osx; public static bool IsWindows => Os == Os.Windows; + // this needs to not be static so we can mock it + public bool IsDocker { get; } + public string Version { get; } public string Name { get; } public string FullName { get; } @@ -83,8 +86,10 @@ namespace NzbDrone.Common.EnvironmentInfo FullName = Name; } - Environment.SetEnvironmentVariable("OS_NAME", Name); - Environment.SetEnvironmentVariable("OS_VERSION", Version); + if (IsLinux && File.Exists("/proc/1/cgroup") && File.ReadAllText("/proc/1/cgroup").Contains("/docker/")) + { + IsDocker = true; + } } } @@ -93,6 +98,8 @@ namespace NzbDrone.Common.EnvironmentInfo string Version { get; } string Name { get; } string FullName { get; } + + bool IsDocker { get; } } public enum Os @@ -101,4 +108,4 @@ namespace NzbDrone.Common.EnvironmentInfo Linux, Osx } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Common/EnvironmentInfo/RuntimeInfo.cs b/src/NzbDrone.Common/EnvironmentInfo/RuntimeInfo.cs index a53862311..8651f6861 100644 --- a/src/NzbDrone.Common/EnvironmentInfo/RuntimeInfo.cs +++ b/src/NzbDrone.Common/EnvironmentInfo/RuntimeInfo.cs @@ -1,25 +1,28 @@ -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 { public class RuntimeInfo : IRuntimeInfo { private readonly Logger _logger; + private readonly DateTime _startTime = DateTime.UtcNow; public RuntimeInfo(IServiceProvider serviceProvider, Logger logger) { _logger = logger; + IsWindowsService = !IsUserInteractive && OsInfo.IsWindows && - serviceProvider.ServiceExist(ServiceProvider.NZBDRONE_SERVICE_NAME) && - serviceProvider.GetStatus(ServiceProvider.NZBDRONE_SERVICE_NAME) == ServiceControllerStatus.StartPending; + serviceProvider.ServiceExist(ServiceProvider.SERVICE_NAME) && + serviceProvider.GetStatus(ServiceProvider.SERVICE_NAME) == ServiceControllerStatus.StartPending; //Guarded to avoid issues when running in a non-managed process var entry = Assembly.GetEntryAssembly(); @@ -27,12 +30,31 @@ namespace NzbDrone.Common.EnvironmentInfo if (entry != null) { ExecutingApplication = entry.Location; + IsWindowsTray = OsInfo.IsWindows && entry.ManifestModule.Name == $"{ProcessProvider.LIDARR_PROCESS_NAME}.exe"; + } } static RuntimeInfo() { - IsProduction = InternalIsProduction(); + var officialBuild = InternalIsOfficialBuild(); + + // An build running inside of the testing environment. (Analytics disabled) + IsTesting = InternalIsTesting(); + + // An official build running outside of the testing environment. (Analytics configurable) + IsProduction = !IsTesting && officialBuild; + + // An unofficial build running outside of the testing environment. (Analytics enabled) + IsDevelopment = !IsTesting && !officialBuild && !InternalIsDebug(); + } + + public DateTime StartTime + { + get + { + return _startTime; + } } public static bool IsUserInteractive => Environment.UserInteractive; @@ -59,26 +81,57 @@ namespace NzbDrone.Common.EnvironmentInfo public bool IsWindowsService { get; private set; } public bool IsExiting { get; set; } + + public bool IsTray + { + get + { + if (OsInfo.IsWindows) + { + return IsUserInteractive && Process.GetCurrentProcess().ProcessName.Equals(ProcessProvider.LIDARR_PROCESS_NAME, StringComparison.InvariantCultureIgnoreCase); + } + + return false; + } + } + + public RuntimeMode Mode + { + get + { + if (IsWindowsService) + { + return RuntimeMode.Service; + } + + if (IsTray) + { + return RuntimeMode.Tray; + } + + return RuntimeMode.Console; + } + } + + public bool RestartPending { get; set; } public string ExecutingApplication { get; } + public static bool IsTesting { get; } public static bool IsProduction { get; } + public static bool IsDevelopment { get; } - private static bool InternalIsProduction() - { - if (BuildInfo.IsDebug || Debugger.IsAttached) return false; - - //Official builds will never have such a high revision - if (BuildInfo.Version.Revision > 10000) return false; + private static bool InternalIsTesting() + { try { var lowerProcessName = Process.GetCurrentProcess().ProcessName.ToLower(); - if (lowerProcessName.Contains("vshost")) return false; - if (lowerProcessName.Contains("nunit")) return false; - if (lowerProcessName.Contains("jetbrain")) return false; - if (lowerProcessName.Contains("resharper")) return false; + if (lowerProcessName.Contains("vshost")) return true; + if (lowerProcessName.Contains("nunit")) return true; + if (lowerProcessName.Contains("jetbrain")) return true; + if (lowerProcessName.Contains("resharper")) return true; } catch { @@ -88,7 +141,7 @@ namespace NzbDrone.Common.EnvironmentInfo try { var currentAssemblyLocation = typeof(RuntimeInfo).Assembly.Location; - if (currentAssemblyLocation.ToLower().Contains("_output")) return false; + if (currentAssemblyLocation.ToLower().Contains("_output")) return true; } catch { @@ -96,11 +149,28 @@ namespace NzbDrone.Common.EnvironmentInfo } var lowerCurrentDir = Directory.GetCurrentDirectory().ToLower(); - if (lowerCurrentDir.Contains("teamcity")) return false; - if (lowerCurrentDir.Contains("buildagent")) return false; - if (lowerCurrentDir.Contains("_output")) return false; + if (lowerCurrentDir.Contains("vsts")) return true; + if (lowerCurrentDir.Contains("buildagent")) return true; + if (lowerCurrentDir.Contains("_output")) return true; + + return false; + } + + private static bool InternalIsDebug() + { + if (BuildInfo.IsDebug || Debugger.IsAttached) return true; + + return false; + } + + private static bool InternalIsOfficialBuild() + { + //Official builds will never have such a high revision + if (BuildInfo.Version.Major >= 10 || BuildInfo.Version.Revision > 10000) return false; return true; } + + public bool IsWindowsTray { get; private set; } } } diff --git a/src/NzbDrone.Common/EnvironmentInfo/RuntimeMode.cs b/src/NzbDrone.Common/EnvironmentInfo/RuntimeMode.cs new file mode 100644 index 000000000..fc5a1867d --- /dev/null +++ b/src/NzbDrone.Common/EnvironmentInfo/RuntimeMode.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Common.EnvironmentInfo +{ + public enum RuntimeMode + { + Console, + Service, + Tray + } +} diff --git a/src/NzbDrone.Common/EnvironmentInfo/StartupContext.cs b/src/NzbDrone.Common/EnvironmentInfo/StartupContext.cs index 49925b415..62c3f7787 100644 --- a/src/NzbDrone.Common/EnvironmentInfo/StartupContext.cs +++ b/src/NzbDrone.Common/EnvironmentInfo/StartupContext.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; namespace NzbDrone.Common.EnvironmentInfo { @@ -6,8 +6,10 @@ namespace NzbDrone.Common.EnvironmentInfo { HashSet Flags { get; } Dictionary Args { get; } + bool Help { get; } bool InstallService { get; } bool UninstallService { get; } + bool RegisterUrl { get; } string PreservedArguments { get; } } @@ -21,6 +23,7 @@ namespace NzbDrone.Common.EnvironmentInfo public const string HELP = "?"; public const string TERMINATE = "terminateexisting"; public const string RESTART = "restart"; + public const string REGISTER_URL = "registerurl"; public StartupContext(params string[] args) { @@ -47,9 +50,10 @@ namespace NzbDrone.Common.EnvironmentInfo public HashSet Flags { get; private set; } public Dictionary Args { get; private set; } + public bool Help => Flags.Contains(HELP); public bool InstallService => Flags.Contains(INSTALL_SERVICE); - public bool UninstallService => Flags.Contains(UNINSTALL_SERVICE); + public bool RegisterUrl => Flags.Contains(REGISTER_URL); public string PreservedArguments { @@ -71,4 +75,4 @@ namespace NzbDrone.Common.EnvironmentInfo } } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Common/Exceptions/LidarrStartupException.cs b/src/NzbDrone.Common/Exceptions/LidarrStartupException.cs new file mode 100644 index 000000000..b43e12cf1 --- /dev/null +++ b/src/NzbDrone.Common/Exceptions/LidarrStartupException.cs @@ -0,0 +1,41 @@ +using System; + +namespace NzbDrone.Common.Exceptions +{ + public class LidarrStartupException : NzbDroneException + { + public LidarrStartupException(string message, params object[] args) + : base("Lidarr failed to start: " + string.Format(message, args)) + { + + } + + public LidarrStartupException(string message) + : base("Lidarr failed to start: " + message) + { + + } + + public LidarrStartupException() + : base("Lidarr failed to start") + { + + } + + public LidarrStartupException(Exception innerException, string message, params object[] args) + : base("Lidarr failed to start: " + string.Format(message, args), innerException) + { + } + + public LidarrStartupException(Exception innerException, string message) + : base("Lidarr failed to start: " + message, innerException) + { + } + + public LidarrStartupException(Exception innerException) + : base("Lidarr failed to start: " + innerException.Message) + { + + } + } +} diff --git a/src/NzbDrone.Common/Extensions/Base64Extensions.cs b/src/NzbDrone.Common/Extensions/Base64Extensions.cs new file mode 100644 index 000000000..1d65ac298 --- /dev/null +++ b/src/NzbDrone.Common/Extensions/Base64Extensions.cs @@ -0,0 +1,17 @@ +using System; + +namespace NzbDrone.Common.Extensions +{ + public static class Base64Extensions + { + public static string ToBase64(this byte[] bytes) + { + return Convert.ToBase64String(bytes); + } + + public static string ToBase64(this long input) + { + return BitConverter.GetBytes(input).ToBase64(); + } + } +} diff --git a/src/NzbDrone.Common/Extensions/Base64Extentions.cs b/src/NzbDrone.Common/Extensions/Base64Extentions.cs deleted file mode 100644 index 3a2dbcf3f..000000000 --- a/src/NzbDrone.Common/Extensions/Base64Extentions.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; - -namespace NzbDrone.Common.Extensions -{ - public static class Base64Extentions - { - public static string ToBase64(this byte[] bytes) - { - return Convert.ToBase64String(bytes); - } - - public static string ToBase64(this long input) - { - return BitConverter.GetBytes(input).ToBase64(); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Common/Extensions/ExceptionExtensions.cs b/src/NzbDrone.Common/Extensions/ExceptionExtensions.cs new file mode 100644 index 000000000..bb74db80a --- /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/FuzzyContains.cs b/src/NzbDrone.Common/Extensions/FuzzyContains.cs new file mode 100644 index 000000000..6a372c44f --- /dev/null +++ b/src/NzbDrone.Common/Extensions/FuzzyContains.cs @@ -0,0 +1,167 @@ +/* + * This file incorporates work covered by the following copyright and + * permission notice: + * + * Diff Match and Patch + * Copyright 2018 The diff-match-patch Authors. + * https://github.com/google/diff-match-patch + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace NzbDrone.Common.Extensions +{ + + public static class FuzzyContainsExtension { + + public static int FuzzyFind(this string text, string pattern, double matchProb) + { + return match(text, pattern, matchProb).Item1; + } + + // return the accuracy of the best match of pattern within text + public static double FuzzyContains(this string text, string pattern) + { + return match(text, pattern, 0.25).Item2; + } + + /** + * Locate the best instance of 'pattern' in 'text'. + * Returns (-1, 1) if no match found. + * @param text The text to search. + * @param pattern The pattern to search for. + * @return Best match index or -1. + */ + private static Tuple match(string text, string pattern, double matchThreshold = 0.5) { + // Check for null inputs not needed since null can't be passed in C#. + if (text.Length == 0 || pattern.Length == 0) { + // Nothing to match. + return new Tuple (-1, 0); + } + + if (pattern.Length <= text.Length) + { + var loc = text.IndexOf(pattern, StringComparison.Ordinal); + if (loc != -1) + { + // Perfect match! + return new Tuple (loc, 1); + } + } + + // Do a fuzzy compare. + return match_bitap(text, pattern, matchThreshold); + } + + /** + * Locate the best instance of 'pattern' in 'text' near 'loc' using the + * Bitap algorithm. Returns -1 if no match found. + * @param text The text to search. + * @param pattern The pattern to search for. + * @return Best match index or -1. + */ + private static Tuple match_bitap(string text, string pattern, double matchThreshold) { + + // Initialise the alphabet. + Dictionary s = alphabet(pattern); + // don't keep creating new BigInteger(1) + var big1 = new BigInteger(1); + + // Lowest score belowe which we give up. + var score_threshold = matchThreshold; + + // Initialise the bit arrays. + var matchmask = big1 << (pattern.Length - 1); + int best_loc = -1; + + // Empty initialization added to appease C# compiler. + var last_rd = new BigInteger[0]; + for (int d = 0; d < pattern.Length; d++) { + // Scan for the best match; each iteration allows for one more error. + int start = 1; + int finish = text.Length + pattern.Length; + + var rd = new BigInteger[finish + 2]; + rd[finish + 1] = (big1 << d) - big1; + for (int j = finish; j >= start; j--) { + BigInteger charMatch; + if (text.Length <= j - 1 || !s.ContainsKey(text[j - 1])) { + // Out of range. + charMatch = 0; + } else { + charMatch = s[text[j - 1]]; + } + if (d == 0) { + // First pass: exact match. + rd[j] = ((rd[j + 1] << 1) | big1) & charMatch; + } else { + // Subsequent passes: fuzzy match. + rd[j] = ((rd[j + 1] << 1) | big1) & charMatch + | (((last_rd[j + 1] | last_rd[j]) << 1) | big1) | last_rd[j + 1]; + } + if ((rd[j] & matchmask) != 0) { + var score = bitapScore(d, pattern); + // This match will almost certainly be better than any existing + // match. But check anyway. + if (score >= score_threshold) { + // Told you so. + score_threshold = score; + best_loc = j - 1; + } + } + } + if (bitapScore(d + 1, pattern) < score_threshold) { + // No hope for a (better) match at greater error levels. + break; + } + last_rd = rd; + } + return new Tuple (best_loc, score_threshold); + } + + /** + * Compute and return the score for a match with e errors and x location. + * @param e Number of errors in match. + * @param pattern Pattern being sought. + * @return Overall score for match (1.0 = good, 0.0 = bad). + */ + private static double bitapScore(int e, string pattern) { + return 1.0 - (double)e / pattern.Length; + } + + /** + * Initialise the alphabet for the Bitap algorithm. + * @param pattern The text to encode. + * @return Hash of character locations. + */ + private static Dictionary alphabet(string pattern) { + var s = new Dictionary(); + char[] char_pattern = pattern.ToCharArray(); + foreach (char c in char_pattern) { + if (!s.ContainsKey(c)) { + s.Add(c, 0); + } + } + int i = 0; + foreach (char c in char_pattern) { + s[c] = s[c] | (new BigInteger(1) << (pattern.Length - i - 1)); + i++; + } + return s; + } + } +} diff --git a/src/NzbDrone.Common/Extensions/IEnumerableExtensions.cs b/src/NzbDrone.Common/Extensions/IEnumerableExtensions.cs index a1beecaa9..85a288aee 100644 --- a/src/NzbDrone.Common/Extensions/IEnumerableExtensions.cs +++ b/src/NzbDrone.Common/Extensions/IEnumerableExtensions.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; namespace NzbDrone.Common.Extensions { @@ -51,6 +52,62 @@ namespace NzbDrone.Common.Extensions } } + public static TSource ExclusiveOrDefault(this IEnumerable source) + { + if (source == null) + { + throw new ArgumentNullException("source"); + } + + var results = source.Take(2).ToArray(); + + return results.Length == 1 ? results[0] : default(TSource); + } + + public static TSource ExclusiveOrDefault(this IEnumerable source, Func predicate) + { + if (source == null) + { + throw new ArgumentNullException("source"); + } + if (predicate == null) + { + throw new ArgumentNullException("predicate"); + } + + var results = source.Where(predicate).Take(2).ToArray(); + + return results.Length == 1 ? results[0] : default(TSource); + } + + 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) @@ -80,5 +137,15 @@ namespace NzbDrone.Common.Extensions { return source.Select(predicate).ToList(); } + + public static string ConcatToString(this IEnumerable source, string separator = ", ") + { + return string.Join(separator, source.Select(x => x.ToString())); + } + + public static string ConcatToString(this IEnumerable source, Func predicate, string separator = ", ") + { + return string.Join(separator, source.Select(predicate)); + } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Common/Extensions/PathExtensions.cs b/src/NzbDrone.Common/Extensions/PathExtensions.cs index e03f0a594..a6a62042d 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; @@ -11,19 +11,21 @@ namespace NzbDrone.Common.Extensions public static class PathExtensions { private const string APP_CONFIG_FILE = "config.xml"; - private const string NZBDRONE_DB = "nzbdrone.db"; - private const string NZBDRONE_LOG_DB = "logs.db"; + private const string DB = "lidarr.db"; + private const string DB_RESTORE = "lidarr.restore"; + private const string LOG_DB = "logs.db"; private const string NLOG_CONFIG_FILE = "nlog.config"; - private const string UPDATE_CLIENT_EXE = "NzbDrone.Update.exe"; - private const string BACKUP_FOLDER = "Backups"; - - private static readonly string UPDATE_SANDBOX_FOLDER_NAME = "nzbdrone_update" + Path.DirectorySeparatorChar; - private static readonly string UPDATE_PACKAGE_FOLDER_NAME = "NzbDrone" + Path.DirectorySeparatorChar; - private static readonly string UPDATE_BACKUP_FOLDER_NAME = "nzbdrone_backup" + Path.DirectorySeparatorChar; - private static readonly string UPDATE_BACKUP_APPDATA_FOLDER_NAME = "nzbdrone_appdata_backup" + Path.DirectorySeparatorChar; - private static readonly string UPDATE_CLIENT_FOLDER_NAME = "NzbDrone.Update" + Path.DirectorySeparatorChar; + private const string UPDATE_CLIENT_EXE = "Lidarr.Update.exe"; + + private static readonly string UPDATE_SANDBOX_FOLDER_NAME = "lidarr_update" + Path.DirectorySeparatorChar; + private static readonly string UPDATE_PACKAGE_FOLDER_NAME = "Lidarr" + Path.DirectorySeparatorChar; + private static readonly string UPDATE_BACKUP_FOLDER_NAME = "lidarr_backup" + Path.DirectorySeparatorChar; + private static readonly string UPDATE_BACKUP_APPDATA_FOLDER_NAME = "lidarr_appdata_backup" + Path.DirectorySeparatorChar; + private static readonly string UPDATE_CLIENT_FOLDER_NAME = "Lidarr.Update" + Path.DirectorySeparatorChar; private static readonly string UPDATE_LOG_FOLDER_NAME = "UpdateLogs" + Path.DirectorySeparatorChar; + private static readonly Regex PARENT_PATH_END_SLASH_REGEX = new Regex(@"(? path).IsNotNullOrWhiteSpace(); @@ -36,6 +38,11 @@ namespace NzbDrone.Common.Extensions return info.FullName.TrimEnd('/', '\\', ' '); } + if (OsInfo.IsNotWindows && info.FullName.TrimEnd('/').Length == 0) + { + return "/"; + } + return info.FullName.TrimEnd('/').Trim('\\', ' '); } @@ -59,7 +66,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); @@ -67,24 +74,25 @@ namespace NzbDrone.Common.Extensions public static string GetParentPath(this string childPath) { - var parentPath = childPath.TrimEnd('\\', '/'); + var cleanPath = OsInfo.IsWindows + ? PARENT_PATH_END_SLASH_REGEX.Replace(childPath, "") + : childPath.TrimEnd(Path.DirectorySeparatorChar); - var index = parentPath.LastIndexOfAny(new[] { '\\', '/' }); - - if (index != -1) + if (cleanPath.IsNullOrWhiteSpace()) { - return parentPath.Substring(0, index); + return null; } - return null; + + return Directory.GetParent(cleanPath)?.FullName; } 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); } @@ -191,6 +199,24 @@ namespace NzbDrone.Common.Extensions return directories; } + public static string GetAncestorPath(this string path, string ancestorName) + { + var parent = Path.GetDirectoryName(path); + + while (parent != null) + { + var currentPath = parent; + parent = Path.GetDirectoryName(parent); + + if (Path.GetFileName(currentPath) == ancestorName) + { + return currentPath; + } + } + + return null; + } + public static string GetAppDataPath(this IAppFolderInfo appFolderInfo) { return appFolderInfo.AppDataFolder; @@ -238,7 +264,7 @@ namespace NzbDrone.Common.Extensions public static string GetUpdateBackupDatabase(this IAppFolderInfo appFolderInfo) { - return Path.Combine(GetUpdateBackUpAppDataFolder(appFolderInfo), NZBDRONE_DB); + return Path.Combine(GetUpdateBackUpAppDataFolder(appFolderInfo), DB); } public static string GetUpdatePackageFolder(this IAppFolderInfo appFolderInfo) @@ -256,19 +282,19 @@ namespace NzbDrone.Common.Extensions return Path.Combine(GetUpdateSandboxFolder(appFolderInfo), UPDATE_CLIENT_EXE); } - public static string GetBackupFolder(this IAppFolderInfo appFolderInfo) + public static string GetDatabase(this IAppFolderInfo appFolderInfo) { - return Path.Combine(GetAppDataPath(appFolderInfo), BACKUP_FOLDER); + return Path.Combine(GetAppDataPath(appFolderInfo), DB); } - public static string GetNzbDroneDatabase(this IAppFolderInfo appFolderInfo) + public static string GetDatabaseRestore(this IAppFolderInfo appFolderInfo) { - return Path.Combine(GetAppDataPath(appFolderInfo), NZBDRONE_DB); + return Path.Combine(GetAppDataPath(appFolderInfo), DB_RESTORE); } public static string GetLogDatabase(this IAppFolderInfo appFolderInfo) { - return Path.Combine(GetAppDataPath(appFolderInfo), NZBDRONE_LOG_DB); + return Path.Combine(GetAppDataPath(appFolderInfo), LOG_DB); } public static string GetNlogConfigPath(this IAppFolderInfo appFolderInfo) @@ -276,4 +302,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..71ecc73a8 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; @@ -8,6 +9,8 @@ namespace NzbDrone.Common.Extensions { public static class StringExtensions { + private static readonly Regex CamelCaseRegex = new Regex("(? values, string separator) + { + return string.Join(separator, values); + } + public static string CleanSpaces(this string text) { return CollapseSpace.Replace(text, " ").Trim(); @@ -78,6 +101,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; @@ -117,5 +150,58 @@ namespace NzbDrone.Common.Extensions return Encoding.ASCII.GetString(new [] { byteResult }); } + + public static string SplitCamelCase(this string input) + { + return CamelCaseRegex.Replace(input, match => " " + match.Value); + } + + public static double FuzzyMatch(this string a, string b) + { + if (a.IsNullOrWhiteSpace() || b.IsNullOrWhiteSpace()) + { + return 0; + } + else if (a.Contains(" ") && b.Contains(" ")) + { + var partsA = a.Split(' '); + var partsB = b.Split(' '); + + var coef = (FuzzyMatchComponents(partsA, partsB) + FuzzyMatchComponents(partsB, partsA)) / (partsA.Length + partsB.Length); + return Math.Max(coef, LevenshteinCoefficient(a, b)); + } + else + { + return LevenshteinCoefficient(a, b); + } + } + + private static double FuzzyMatchComponents(string[] a, string[] b) + { + double weightDenom = Math.Max(a.Length, b.Length); + double sum = 0; + for (int i = 0; i < a.Length; i++) + { + double high = 0.0; + int indexDistance = 0; + for (int x = 0; x < b.Length; x++) + { + var coef = LevenshteinCoefficient(a[i], b[x]); + if (coef > high) + { + high = coef; + indexDistance = Math.Abs(i - x); + } + } + sum += (1.0 - (double)indexDistance / weightDenom) * high; + } + return sum; + } + + public static double LevenshteinCoefficient(this string a, string b) + { + return 1.0 - (double)a.LevenshteinDistance(b) / Math.Max(a.Length, b.Length); + } + } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Common/Extensions/TryParseExtensions.cs b/src/NzbDrone.Common/Extensions/TryParseExtensions.cs index c485fbd54..21255f514 100644 --- a/src/NzbDrone.Common/Extensions/TryParseExtensions.cs +++ b/src/NzbDrone.Common/Extensions/TryParseExtensions.cs @@ -1,4 +1,5 @@ -using System; +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/Extensions/XmlExtensions.cs b/src/NzbDrone.Common/Extensions/XmlExtensions.cs new file mode 100644 index 000000000..84b163165 --- /dev/null +++ b/src/NzbDrone.Common/Extensions/XmlExtensions.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Xml.Linq; + +namespace NzbDrone.Common.Extensions +{ + public static class XmlExtensions + { + public static IEnumerable FindDecendants(this XContainer container, string localName) + { + return container.Descendants().Where(c => c.Name.LocalName.Equals(localName, StringComparison.InvariantCultureIgnoreCase)); + } + } +} diff --git a/src/NzbDrone.Common/Extensions/XmlExtentions.cs b/src/NzbDrone.Common/Extensions/XmlExtentions.cs deleted file mode 100644 index 5e9ffd6db..000000000 --- a/src/NzbDrone.Common/Extensions/XmlExtentions.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Xml.Linq; - -namespace NzbDrone.Common.Extensions -{ - public static class XmlExtentions - { - public static IEnumerable FindDecendants(this XContainer container, string localName) - { - return container.Descendants().Where(c => c.Name.LocalName.Equals(localName, StringComparison.InvariantCultureIgnoreCase)); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Common/Http/Dispatchers/CurlHttpDispatcher.cs b/src/NzbDrone.Common/Http/Dispatchers/CurlHttpDispatcher.cs deleted file mode 100644 index 83d6fb1d1..000000000 --- a/src/NzbDrone.Common/Http/Dispatchers/CurlHttpDispatcher.cs +++ /dev/null @@ -1,335 +0,0 @@ -using System; -using System.Globalization; -using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Net; -using System.Reflection; -using System.Runtime.InteropServices; -using System.Text; -using System.Text.RegularExpressions; -using CurlSharp; -using NLog; -using NzbDrone.Common.EnvironmentInfo; -using NzbDrone.Common.Extensions; -using NzbDrone.Common.Http.Proxy; - -namespace NzbDrone.Common.Http.Dispatchers -{ - public class CurlHttpDispatcher : IHttpDispatcher - { - private static readonly Regex ExpiryDate = new Regex(@"(expires=)([^;]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); - - private readonly IHttpProxySettingsProvider _proxySettingsProvider; - private readonly IUserAgentBuilder _userAgentBuilder; - private readonly Logger _logger; - - private const string _caBundleFileName = "curl-ca-bundle.crt"; - private static readonly string _caBundleFilePath; - - static CurlHttpDispatcher() - { - if (Assembly.GetExecutingAssembly().Location.IsNotNullOrWhiteSpace()) - { - _caBundleFilePath = Path.Combine(Assembly.GetExecutingAssembly().Location, "..", _caBundleFileName); - } - else - { - _caBundleFilePath = _caBundleFileName; - } - } - - public CurlHttpDispatcher(IHttpProxySettingsProvider proxySettingsProvider, IUserAgentBuilder userAgentBuilder, Logger logger) - { - _proxySettingsProvider = proxySettingsProvider; - _userAgentBuilder = userAgentBuilder; - _logger = logger; - } - - public bool CheckAvailability() - { - try - { - return CurlGlobalHandle.Instance.Initialize(); - } - catch (Exception ex) - { - _logger.Trace(ex, "Initializing curl failed"); - return false; - } - } - - public HttpResponse GetResponse(HttpRequest request, CookieContainer cookies) - { - if (!CheckAvailability()) - { - throw new ApplicationException("Curl failed to initialize."); - } - - lock (CurlGlobalHandle.Instance) - { - Stream responseStream = new MemoryStream(); - Stream headerStream = new MemoryStream(); - - using (var curlEasy = new CurlEasy()) - { - curlEasy.AutoReferer = false; - curlEasy.WriteFunction = (b, s, n, o) => - { - responseStream.Write(b, 0, s * n); - return s * n; - }; - curlEasy.HeaderFunction = (b, s, n, o) => - { - headerStream.Write(b, 0, s * n); - return s * n; - }; - - AddProxy(curlEasy, request); - - curlEasy.Url = request.Url.FullUri; - - switch (request.Method) - { - case HttpMethod.GET: - curlEasy.HttpGet = true; - break; - - case HttpMethod.POST: - curlEasy.Post = true; - break; - - case HttpMethod.PUT: - curlEasy.Put = true; - break; - - default: - throw new NotSupportedException($"HttpCurl method {request.Method} not supported"); - } - curlEasy.UserAgent = _userAgentBuilder.GetUserAgent(request.UseSimplifiedUserAgent); - curlEasy.FollowLocation = request.AllowAutoRedirect; - - if (request.RequestTimeout != TimeSpan.Zero) - { - curlEasy.Timeout = (int)Math.Ceiling(request.RequestTimeout.TotalSeconds); - } - - if (OsInfo.IsWindows) - { - curlEasy.CaInfo = _caBundleFilePath; - } - - if (cookies != null) - { - curlEasy.Cookie = cookies.GetCookieHeader((Uri)request.Url); - } - - if (request.ContentData != null) - { - curlEasy.PostFieldSize = request.ContentData.Length; - curlEasy.SetOpt(CurlOption.CopyPostFields, new string(Array.ConvertAll(request.ContentData, v => (char)v))); - } - - // Yes, we have to keep a ref to the object to prevent corrupting the unmanaged state - using (var httpRequestHeaders = SerializeHeaders(request)) - { - curlEasy.HttpHeader = httpRequestHeaders; - - var result = curlEasy.Perform(); - - if (result != CurlCode.Ok) - { - switch (result) - { - case CurlCode.SslCaCert: - case (CurlCode)77: - throw new WebException(string.Format("Curl Error {0} for Url {1}, issues with your operating system SSL Root Certificate Bundle (ca-bundle).", result, curlEasy.Url)); - default: - throw new WebException(string.Format("Curl Error {0} for Url {1}", result, curlEasy.Url)); - - } - } - } - - var webHeaderCollection = ProcessHeaderStream(request, cookies, headerStream); - var responseData = ProcessResponseStream(request, responseStream, webHeaderCollection); - - var httpHeader = new HttpHeader(webHeaderCollection); - - return new HttpResponse(request, httpHeader, responseData, (HttpStatusCode)curlEasy.ResponseCode); - } - } - } - - private void AddProxy(CurlEasy curlEasy, HttpRequest request) - { - var proxySettings = _proxySettingsProvider.GetProxySettings(request); - if (proxySettings != null) - - { - switch (proxySettings.Type) - { - case ProxyType.Http: - curlEasy.SetOpt(CurlOption.ProxyType, CurlProxyType.Http); - curlEasy.SetOpt(CurlOption.ProxyAuth, CurlHttpAuth.Basic); - curlEasy.SetOpt(CurlOption.ProxyUserPwd, proxySettings.Username + ":" + proxySettings.Password.ToString()); - break; - case ProxyType.Socks4: - curlEasy.SetOpt(CurlOption.ProxyType, CurlProxyType.Socks4); - curlEasy.SetOpt(CurlOption.ProxyUsername, proxySettings.Username); - curlEasy.SetOpt(CurlOption.ProxyPassword, proxySettings.Password); - break; - case ProxyType.Socks5: - curlEasy.SetOpt(CurlOption.ProxyType, CurlProxyType.Socks5); - curlEasy.SetOpt(CurlOption.ProxyUsername, proxySettings.Username); - curlEasy.SetOpt(CurlOption.ProxyPassword, proxySettings.Password); - break; - } - curlEasy.SetOpt(CurlOption.Proxy, proxySettings.Host + ":" + proxySettings.Port.ToString()); - } - } - - private CurlSlist SerializeHeaders(HttpRequest request) - { - if (!request.Headers.ContainsKey("Accept-Encoding")) - { - request.Headers.Add("Accept-Encoding", "gzip"); - } - - if (request.Headers.ContentType == null) - { - request.Headers.ContentType = string.Empty; - } - - var curlHeaders = new CurlSlist(); - foreach (var header in request.Headers) - { - curlHeaders.Append(header.Key + ": " + header.Value.ToString()); - } - - return curlHeaders; - } - - private WebHeaderCollection ProcessHeaderStream(HttpRequest request, CookieContainer cookies, Stream headerStream) - { - headerStream.Position = 0; - var headerData = headerStream.ToBytes(); - var headerString = Encoding.ASCII.GetString(headerData); - - var webHeaderCollection = new WebHeaderCollection(); - - // following a redirect we could have two sets of headers, so only process the last one - foreach (var header in headerString.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).Reverse()) - { - if (!header.Contains(":")) break; - webHeaderCollection.Add(header); - } - - var setCookie = webHeaderCollection.Get("Set-Cookie"); - if (setCookie != null && setCookie.Length > 0 && cookies != null) - { - try - { - cookies.SetCookies((Uri)request.Url, FixSetCookieHeader(setCookie)); - } - catch (CookieException ex) - { - _logger.Debug("Rejected cookie {0}: {1}", ex.InnerException.Message, setCookie); - } - } - - return webHeaderCollection; - } - - private string FixSetCookieHeader(string setCookie) - { - // fix up the date if it was malformed - var setCookieClean = ExpiryDate.Replace(setCookie, delegate(Match match) - { - string shortFormat = "ddd, dd-MMM-yy HH:mm:ss"; - string longFormat = "ddd, dd-MMM-yyyy HH:mm:ss"; - DateTime dt; - if (DateTime.TryParseExact(match.Groups[2].Value, longFormat, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out dt) || - DateTime.TryParseExact(match.Groups[2].Value, shortFormat, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out dt) || - DateTime.TryParse(match.Groups[2].Value, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out dt)) - return match.Groups[1].Value + dt.ToUniversalTime().ToString(longFormat, CultureInfo.InvariantCulture) + " GMT"; - else - return match.Value; - }); - return setCookieClean; - } - - private byte[] ProcessResponseStream(HttpRequest request, Stream responseStream, WebHeaderCollection webHeaderCollection) - { - responseStream.Position = 0; - - if (responseStream.Length != 0) - { - var encoding = webHeaderCollection["Content-Encoding"]; - if (encoding != null) - { - if (encoding.IndexOf("gzip") != -1) - { - responseStream = new GZipStream(responseStream, CompressionMode.Decompress); - - webHeaderCollection.Remove("Content-Encoding"); - } - else if (encoding.IndexOf("deflate") != -1) - { - responseStream = new DeflateStream(responseStream, CompressionMode.Decompress); - - webHeaderCollection.Remove("Content-Encoding"); - } - } - } - - return responseStream.ToBytes(); - - } - } - - internal sealed class CurlGlobalHandle : SafeHandle - { - public static readonly CurlGlobalHandle Instance = new CurlGlobalHandle(); - - private bool _initialized; - private bool _available; - - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); - } - - private CurlGlobalHandle() - : base(IntPtr.Zero, true) - { - - } - - public bool Initialize() - { - lock (CurlGlobalHandle.Instance) - { - if (_initialized) - return _available; - - _initialized = true; - _available = Curl.GlobalInit(CurlInitFlag.All) == CurlCode.Ok; - - return _available; - } - } - - protected override bool ReleaseHandle() - { - if (_initialized && _available) - { - Curl.GlobalCleanup(); - _available = false; - } - return true; - } - - public override bool IsInvalid => !_initialized || !_available; - } -} diff --git a/src/NzbDrone.Common/Http/Dispatchers/FallbackHttpDispatcher.cs b/src/NzbDrone.Common/Http/Dispatchers/FallbackHttpDispatcher.cs deleted file mode 100644 index 707004c9d..000000000 --- a/src/NzbDrone.Common/Http/Dispatchers/FallbackHttpDispatcher.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System; -using System.Net; -using NLog; -using NzbDrone.Common.Cache; -using NzbDrone.Common.EnvironmentInfo; - -namespace NzbDrone.Common.Http.Dispatchers -{ - public class FallbackHttpDispatcher : IHttpDispatcher - { - private readonly ManagedHttpDispatcher _managedDispatcher; - private readonly CurlHttpDispatcher _curlDispatcher; - private readonly IPlatformInfo _platformInfo; - private readonly Logger _logger; - - private readonly ICached _curlTLSFallbackCache; - - public FallbackHttpDispatcher(ManagedHttpDispatcher managedDispatcher, CurlHttpDispatcher curlDispatcher, ICacheManager cacheManager, IPlatformInfo platformInfo, Logger logger) - { - _managedDispatcher = managedDispatcher; - _curlDispatcher = curlDispatcher; - _platformInfo = platformInfo; - _curlTLSFallbackCache = cacheManager.GetCache(GetType(), "curlTLSFallback"); - _logger = logger; - } - - public HttpResponse GetResponse(HttpRequest request, CookieContainer cookies) - { - if (PlatformInfo.IsMono && request.Url.Scheme == "https") - { - if (!_curlTLSFallbackCache.Find(request.Url.Host)) - { - try - { - return _managedDispatcher.GetResponse(request, cookies); - } - catch (Exception ex) - { - 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); - - _curlTLSFallbackCache.Set(request.Url.Host, true); - } - else - { - throw; - } - } - } - - if (_curlDispatcher.CheckAvailability()) - { - return _curlDispatcher.GetResponse(request, cookies); - } - - _logger.Trace("Curl not available, using default WebClient."); - } - - return _managedDispatcher.GetResponse(request, cookies); - } - } -} diff --git a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs index 60231f75e..f32ac8845 100644 --- a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs +++ b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs @@ -1,8 +1,14 @@ using System; +using System.IO; +using System.IO.Compression; using System.Net; +using System.Reflection; +using NLog; +using NLog.Fluent; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; using NzbDrone.Common.Http.Proxy; +using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Common.Security; namespace NzbDrone.Common.Http.Dispatchers @@ -12,27 +18,40 @@ namespace NzbDrone.Common.Http.Dispatchers private readonly IHttpProxySettingsProvider _proxySettingsProvider; private readonly ICreateManagedWebProxy _createManagedWebProxy; private readonly IUserAgentBuilder _userAgentBuilder; + private readonly IPlatformInfo _platformInfo; + private readonly Logger _logger; - public ManagedHttpDispatcher(IHttpProxySettingsProvider proxySettingsProvider, ICreateManagedWebProxy createManagedWebProxy, IUserAgentBuilder userAgentBuilder) + public ManagedHttpDispatcher(IHttpProxySettingsProvider proxySettingsProvider, ICreateManagedWebProxy createManagedWebProxy, IUserAgentBuilder userAgentBuilder, IPlatformInfo platformInfo, Logger logger) { _proxySettingsProvider = proxySettingsProvider; _createManagedWebProxy = createManagedWebProxy; _userAgentBuilder = userAgentBuilder; + _platformInfo = platformInfo; + _logger = logger; } public HttpResponse GetResponse(HttpRequest request, CookieContainer cookies) { var webRequest = (HttpWebRequest)WebRequest.Create((Uri)request.Url); - // Deflate is not a standard and could break depending on implementation. - // we should just stick with the more compatible Gzip - //http://stackoverflow.com/questions/8490718/how-to-decompress-stream-deflated-with-java-util-zip-deflater-in-net - webRequest.AutomaticDecompression = DecompressionMethods.GZip; + if (PlatformInfo.IsMono) + { + // On Mono GZipStream/DeflateStream leaks memory if an exception is thrown, use an intermediate buffer in that case. + webRequest.AutomaticDecompression = DecompressionMethods.None; + webRequest.Headers.Add("Accept-Encoding", "gzip"); + } + else + { + // Deflate is not a standard and could break depending on implementation. + // we should just stick with the more compatible Gzip + //http://stackoverflow.com/questions/8490718/how-to-decompress-stream-deflated-with-java-util-zip-deflater-in-net + webRequest.AutomaticDecompression = DecompressionMethods.GZip; + } 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 +66,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 +92,30 @@ namespace NzbDrone.Common.Http.Dispatchers if (httpWebResponse == null) { - throw; + // Workaround for mono not closing connections properly in certain situations. + AbortWebRequest(webRequest); + + // 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; + }; } } @@ -81,9 +123,29 @@ namespace NzbDrone.Common.Http.Dispatchers using (var responseStream = httpWebResponse.GetResponseStream()) { - if (responseStream != null) + if (responseStream != null && responseStream != Stream.Null) { - data = responseStream.ToBytes(); + try + { + data = responseStream.ToBytes(); + + if (PlatformInfo.IsMono && httpWebResponse.ContentEncoding == "gzip") + { + using (var compressedStream = new MemoryStream(data)) + using (var gzip = new GZipStream(compressedStream, CompressionMode.Decompress)) + using (var decompressedStream = new MemoryStream()) + { + gzip.CopyTo(decompressedStream); + data = decompressedStream.ToArray(); + } + + httpWebResponse.Headers.Remove("Content-Encoding"); + } + } + catch (Exception ex) + { + throw new WebException("Failed to read complete http response", ex, WebExceptionStatus.ReceiveFailure, httpWebResponse); + } } } @@ -138,7 +200,7 @@ namespace NzbDrone.Common.Http.Dispatchers webRequest.TransferEncoding = header.Value; break; case "User-Agent": - throw new NotSupportedException("User-Agent other than Sonarr not allowed."); + throw new NotSupportedException("User-Agent other than Lidarr not allowed."); case "Proxy-Connection": throw new NotImplementedException(); default: @@ -147,5 +209,36 @@ namespace NzbDrone.Common.Http.Dispatchers } } } + + // Workaround for mono not closing connections properly on timeouts + private void AbortWebRequest(HttpWebRequest webRequest) + { + // First affected version was mono 5.16 + if (OsInfo.IsNotWindows && _platformInfo.Version >= new Version(5, 16)) + { + try + { + var currentOperationInfo = webRequest.GetType().GetField("currentOperation", BindingFlags.NonPublic | BindingFlags.Instance); + var currentOperation = currentOperationInfo.GetValue(webRequest); + + if (currentOperation != null) + { + var responseStreamInfo = currentOperation.GetType().GetField("responseStream", BindingFlags.NonPublic | BindingFlags.Instance); + var responseStream = responseStreamInfo.GetValue(currentOperation) as Stream; + // Note that responseStream will likely be null once mono fixes it. + responseStream?.Dispose(); + } + } + catch (Exception ex) + { + // This can fail randomly on future mono versions that have been changed/fixed. Log to sentry and ignore. + _logger.Trace() + .Exception(ex) + .Message("Unable to dispose responseStream on mono {0}", _platformInfo.Version) + .WriteSentryWarn("MonoCloseWaitPatchFailed", ex.Message) + .Write(); + } + } + } } } diff --git a/src/NzbDrone.Common/Http/HttpClient.cs b/src/NzbDrone.Common/Http/HttpClient.cs index 849647f64..58896af48 100644 --- a/src/NzbDrone.Common/Http/HttpClient.cs +++ b/src/NzbDrone.Common/Http/HttpClient.cs @@ -7,6 +7,7 @@ using System.Net; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Extensions; using NzbDrone.Common.Http.Dispatchers; using NzbDrone.Common.TPL; @@ -50,6 +51,56 @@ namespace NzbDrone.Common.Http } public HttpResponse Execute(HttpRequest request) + { + var cookieContainer = InitializeRequestCookies(request); + + var response = ExecuteRequest(request, cookieContainer); + + if (request.AllowAutoRedirect && response.HasHttpRedirect) + { + 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 (response.HasHttpRedirect && !RuntimeInfo.IsProduction) + { + _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) + { + _logger.Warn("HTTP Error - {0}", response); + + if ((int)response.StatusCode == 429) + { + throw new TooManyRequestsException(request, response); + } + else + { + throw new HttpException(request, response); + } + } + + return response; + } + + private HttpResponse ExecuteRequest(HttpRequest request, CookieContainer cookieContainer) { foreach (var interceptor in _requestInterceptors) { @@ -65,11 +116,11 @@ namespace NzbDrone.Common.Http var stopWatch = Stopwatch.StartNew(); - var cookies = PrepareRequestCookies(request); + PrepareRequestCookies(request, cookieContainer); - var response = _httpDispatcher.GetResponse(request, cookies); + var response = _httpDispatcher.GetResponse(request, cookieContainer); - HandleResponseCookies(request, cookies); + HandleResponseCookies(response, cookieContainer); stopWatch.Stop(); @@ -85,74 +136,94 @@ namespace NzbDrone.Common.Http _logger.Trace("Response content ({0} bytes): {1}", response.ResponseData.Length, response.Content); } - if (!RuntimeInfo.IsProduction && - (response.StatusCode == HttpStatusCode.Moved || - response.StatusCode == HttpStatusCode.MovedPermanently || - response.StatusCode == HttpStatusCode.Found)) - { - _logger.Error("Server requested a redirect to [{0}]. Update the request URL to avoid this redirect.", response.Headers["Location"]); - } - - if (!request.SuppressHttpError && response.HasHttpError) - { - _logger.Warn("HTTP Error - {0}", response); - - if ((int)response.StatusCode == 429) - { - throw new TooManyRequestsException(request, response); - } - else - { - throw new HttpException(request, response); - } - } - return response; } - private CookieContainer PrepareRequestCookies(HttpRequest request) + 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) + { + cookie = new Cookie(pair.Key, "", "/") + { + Expires = DateTime.Now.AddDays(-1) + }; + } + else { - // 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, 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(); + return sourceContainer; + } + } - cookieContainer.Add(requestCookies); + private void PrepareRequestCookies(HttpRequest request, CookieContainer cookieContainer) + { + // 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); - return cookieContainer; - } + cookieContainer.Add(persistentCookies); + cookieContainer.Add(existingCookies); + }*/ } - private void HandleResponseCookies(HttpRequest request, CookieContainer cookieContainer) + private void HandleResponseCookies(HttpResponse response, CookieContainer cookieContainer) { - if (!request.StoreResponseCookie) + var cookieHeaders = response.GetCookieHeaders(); + if (cookieHeaders.Empty()) { return; } - lock (_cookieContainerCache) + if (response.Request.StoreResponseCookie) { - var persistentCookieContainer = _cookieContainerCache.Get("container", () => new CookieContainer()); - - var cookies = cookieContainer.GetCookies((Uri)request.Url); + lock (_cookieContainerCache) + { + var persistentCookieContainer = _cookieContainerCache.Get("container", () => new CookieContainer()); - 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); + } + } + } } } @@ -196,6 +267,7 @@ namespace NzbDrone.Common.Http public HttpResponse Get(HttpRequest request) where T : new() { var response = Get(request); + CheckResponseContentType(response); return new HttpResponse(response); } @@ -214,7 +286,16 @@ namespace NzbDrone.Common.Http public HttpResponse Post(HttpRequest request) where T : new() { var response = Post(request); + CheckResponseContentType(response); return new HttpResponse(response); } + + private void CheckResponseContentType(HttpResponse response) + { + if (response.Headers.ContentType != null && response.Headers.ContentType.Contains("text/html")) + { + throw new UnexpectedHtmlContentException(response); + } + } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Common/Http/HttpException.cs b/src/NzbDrone.Common/Http/HttpException.cs index 759a104c1..005fab57a 100644 --- a/src/NzbDrone.Common/Http/HttpException.cs +++ b/src/NzbDrone.Common/Http/HttpException.cs @@ -7,13 +7,19 @@ namespace NzbDrone.Common.Http public HttpRequest Request { get; private set; } public HttpResponse Response { get; private set; } - public HttpException(HttpRequest request, HttpResponse response) - : base(string.Format("HTTP request failed: [{0}:{1}] [{2}] at [{3}]", (int)response.StatusCode, response.StatusCode, request.Method, request.Url)) + public HttpException(HttpRequest request, HttpResponse response, string message) + : base(message) { Request = request; Response = response; } + public HttpException(HttpRequest request, HttpResponse response) + : this(request, response, string.Format("HTTP request failed: [{0}:{1}] [{2}] at [{3}]", (int)response.StatusCode, response.StatusCode, request.Method, request.Url)) + { + + } + public HttpException(HttpResponse response) : this(response.Request, response) { @@ -30,4 +36,4 @@ namespace NzbDrone.Common.Http return base.ToString(); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Common/Http/HttpHeader.cs b/src/NzbDrone.Common/Http/HttpHeader.cs index fcfc825d7..46180515f 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]; @@ -117,6 +117,18 @@ namespace NzbDrone.Common.Http } } + public DateTime? LastModified + { + get + { + return GetSingleValue("Last-Modified", Convert.ToDateTime); + } + set + { + SetSingleValue("Last-Modified", value); + } + } + public new IEnumerator> GetEnumerator() { return AllKeys.SelectMany(GetValues, (k, c) => new KeyValuePair(k, c)).ToList().GetEnumerator(); @@ -169,10 +181,10 @@ namespace NzbDrone.Common.Http public static List> ParseCookies(string cookies) { - return cookies.Split(';') + return cookies.Split(new[] { ";" }, StringSplitOptions.RemoveEmptyEntries) .Select(v => v.Trim().Split('=')) .Select(v => new KeyValuePair(v[0], v[1])) .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..8964bbef6 100644 --- a/src/NzbDrone.Common/Http/HttpMethod.cs +++ b/src/NzbDrone.Common/Http/HttpMethod.cs @@ -3,11 +3,12 @@ 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/HttpProvider.cs b/src/NzbDrone.Common/Http/HttpProvider.cs index e09fbf1c6..a61ac8a14 100644 --- a/src/NzbDrone.Common/Http/HttpProvider.cs +++ b/src/NzbDrone.Common/Http/HttpProvider.cs @@ -24,7 +24,7 @@ namespace NzbDrone.Common.Http public HttpProvider(Logger logger) { _logger = logger; - _userAgent = string.Format("Sonarr {0}", BuildInfo.Version); + _userAgent = $"{BuildInfo.AppName}/{BuildInfo.Version.ToString(2)}"; ServicePointManager.Expect100Continue = false; } 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/HttpRequestBuilder.cs b/src/NzbDrone.Common/Http/HttpRequestBuilder.cs index b75be10f1..d4ccc26d3 100644 --- a/src/NzbDrone.Common/Http/HttpRequestBuilder.cs +++ b/src/NzbDrone.Common/Http/HttpRequestBuilder.cs @@ -355,7 +355,7 @@ namespace NzbDrone.Common.Http FormData.Add(new HttpFormData { Name = key, - ContentData = Encoding.UTF8.GetBytes(value.ToString()) + ContentData = Encoding.UTF8.GetBytes(Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture)) }); return this; diff --git a/src/NzbDrone.Common/Http/HttpResponse.cs b/src/NzbDrone.Common/Http/HttpResponse.cs index dd9df22c7..10b55f199 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,27 @@ namespace NzbDrone.Common.Http public bool HasHttpError => (int)StatusCode >= 400; + public bool HasHttpRedirect => StatusCode == HttpStatusCode.Moved || + StatusCode == HttpStatusCode.MovedPermanently || + StatusCode == HttpStatusCode.TemporaryRedirect || + 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 +102,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..a62933e82 100644 --- a/src/NzbDrone.Common/Http/HttpUri.cs +++ b/src/NzbDrone.Common/Http/HttpUri.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Text; using System.Text.RegularExpressions; @@ -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; @@ -135,7 +135,7 @@ namespace NzbDrone.Common.Http return new HttpUri(Scheme, Host, Port, CombinePath(Path, path), Query, Fragment); } - private static string CombinePath(string basePath, string relativePath) + public static string CombinePath(string basePath, string relativePath) { if (relativePath.IsNullOrWhiteSpace()) { diff --git a/src/NzbDrone.Common/Http/TlsFailureException.cs b/src/NzbDrone.Common/Http/TlsFailureException.cs new file mode 100644 index 000000000..cb1b5f93a --- /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 + "'.", innerException, WebExceptionStatus.SecureChannelFailure, innerException.Response) + { + + } + + } +} diff --git a/src/NzbDrone.Common/Http/UnexpectedHtmlContentException.cs b/src/NzbDrone.Common/Http/UnexpectedHtmlContentException.cs new file mode 100644 index 000000000..fedf94761 --- /dev/null +++ b/src/NzbDrone.Common/Http/UnexpectedHtmlContentException.cs @@ -0,0 +1,13 @@ +using System; + +namespace NzbDrone.Common.Http +{ + public class UnexpectedHtmlContentException : HttpException + { + public UnexpectedHtmlContentException(HttpResponse response) + : base(response.Request, response, $"Site responded with browser content instead of api data. This disruption may be temporary, please try again later. [{response.Request.Url}]") + { + + } + } +} diff --git a/src/NzbDrone.Common/Http/UserAgentBuilder.cs b/src/NzbDrone.Common/Http/UserAgentBuilder.cs index 5d3dc9644..525b8ee0b 100644 --- a/src/NzbDrone.Common/Http/UserAgentBuilder.cs +++ b/src/NzbDrone.Common/Http/UserAgentBuilder.cs @@ -33,8 +33,8 @@ namespace NzbDrone.Common.Http var osVersion = osInfo.Version?.ToLower(); - _userAgent = $"Sonarr/{BuildInfo.Version} ({osName} {osVersion})"; - _userAgentSimplified = $"Sonarr/{BuildInfo.Version.ToString(2)}"; + _userAgent = $"{BuildInfo.AppName}/{BuildInfo.Version} ({osName} {osVersion})"; + _userAgentSimplified = $"{BuildInfo.AppName}/{BuildInfo.Version.ToString(2)}"; } } } \ No newline at end of file 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/LoggerExtensions.cs b/src/NzbDrone.Common/Instrumentation/Extensions/LoggerExtensions.cs new file mode 100644 index 000000000..0f4773bd3 --- /dev/null +++ b/src/NzbDrone.Common/Instrumentation/Extensions/LoggerExtensions.cs @@ -0,0 +1,33 @@ +using NLog; + +namespace NzbDrone.Common.Instrumentation.Extensions +{ + public static class LoggerExtensions + { + public static void ProgressInfo(this Logger logger, string message, params object[] args) + { + var formattedMessage = string.Format(message, args); + LogProgressMessage(logger, LogLevel.Info, formattedMessage); + } + + public static void ProgressDebug(this Logger logger, string message, params object[] args) + { + var formattedMessage = string.Format(message, args); + LogProgressMessage(logger, LogLevel.Debug, formattedMessage); + } + + public static void ProgressTrace(this Logger logger, string message, params object[] args) + { + var formattedMessage = string.Format(message, args); + LogProgressMessage(logger, LogLevel.Trace, formattedMessage); + } + + private static void LogProgressMessage(Logger logger, LogLevel level, string message) + { + var logEvent = new LogEventInfo(level, logger.Name, message); + logEvent.Properties.Add("Status", ""); + + logger.Log(logEvent); + } + } +} diff --git a/src/NzbDrone.Common/Instrumentation/Extensions/LoggerProgressExtensions.cs b/src/NzbDrone.Common/Instrumentation/Extensions/LoggerProgressExtensions.cs deleted file mode 100644 index 5abeeb6ba..000000000 --- a/src/NzbDrone.Common/Instrumentation/Extensions/LoggerProgressExtensions.cs +++ /dev/null @@ -1,33 +0,0 @@ -using NLog; - -namespace NzbDrone.Common.Instrumentation.Extensions -{ - public static class LoggerExtensions - { - public static void ProgressInfo(this Logger logger, string message, params object[] args) - { - var formattedMessage = string.Format(message, args); - LogProgressMessage(logger, LogLevel.Info, formattedMessage); - } - - public static void ProgressDebug(this Logger logger, string message, params object[] args) - { - var formattedMessage = string.Format(message, args); - LogProgressMessage(logger, LogLevel.Debug, formattedMessage); - } - - public static void ProgressTrace(this Logger logger, string message, params object[] args) - { - var formattedMessage = string.Format(message, args); - LogProgressMessage(logger, LogLevel.Trace, formattedMessage); - } - - private static void LogProgressMessage(Logger logger, LogLevel level, string message) - { - var logEvent = new LogEventInfo(level, logger.Name, message); - logEvent.Properties.Add("Status", ""); - - logger.Log(logEvent); - } - } -} diff --git a/src/NzbDrone.Common/Instrumentation/Extensions/SentryLoggerExtensions.cs b/src/NzbDrone.Common/Instrumentation/Extensions/SentryLoggerExtensions.cs new file mode 100644 index 000000000..a2af08de7 --- /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); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Common/Instrumentation/GlobalExceptionHandlers.cs b/src/NzbDrone.Common/Instrumentation/GlobalExceptionHandlers.cs index fbcbf4dcb..3b99b7ad3 100644 --- a/src/NzbDrone.Common/Instrumentation/GlobalExceptionHandlers.cs +++ b/src/NzbDrone.Common/Instrumentation/GlobalExceptionHandlers.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading.Tasks; using NLog; using NzbDrone.Common.EnvironmentInfo; @@ -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/InitializeLogger.cs b/src/NzbDrone.Common/Instrumentation/InitializeLogger.cs new file mode 100644 index 000000000..aa7696204 --- /dev/null +++ b/src/NzbDrone.Common/Instrumentation/InitializeLogger.cs @@ -0,0 +1,28 @@ +using System.Linq; +using NLog; +using NLog.Fluent; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Common.Instrumentation.Sentry; + +namespace NzbDrone.Common.Instrumentation +{ + public class InitializeLogger + { + private readonly IOsInfo _osInfo; + + public InitializeLogger(IOsInfo osInfo) + { + _osInfo = osInfo; + } + + public void Initialize() + { + var sentryTarget = LogManager.Configuration.AllTargets.OfType().FirstOrDefault(); + if (sentryTarget != null) + { + sentryTarget.UpdateScope(_osInfo); + } + } + } +} diff --git a/src/NzbDrone.Common/Instrumentation/NzbDroneLogger.cs b/src/NzbDrone.Common/Instrumentation/NzbDroneLogger.cs index 60373b991..489d16a3b 100644 --- a/src/NzbDrone.Common/Instrumentation/NzbDroneLogger.cs +++ b/src/NzbDrone.Common/Instrumentation/NzbDroneLogger.cs @@ -1,8 +1,7 @@ -using System; +using System; using System.Diagnostics; using System.IO; using System.Linq; -using LogentriesNLog; using NLog; using NLog.Config; using NLog.Targets; @@ -45,7 +44,6 @@ namespace NzbDrone.Common.Instrumentation if (updateApp) { RegisterUpdateFile(appFolderInfo); - RegisterLogEntries(); } else { @@ -57,50 +55,26 @@ namespace NzbDrone.Common.Instrumentation RegisterAppFile(appFolderInfo); } - LogManager.ReconfigExistingLoggers(); - } - - public static void UnRegisterRemoteLoggers() - { - var sentryRules = LogManager.Configuration.LoggingRules.Where(r => r.Targets.Any(t => t.Name == "sentryTarget")); - - foreach (var rules in sentryRules) - { - rules.Targets.Clear(); - } + RegisterAuthLogger(); LogManager.ReconfigExistingLoggers(); } - private static void RegisterLogEntries() - { - var target = new LogentriesTarget(); - target.Name = "logentriesTarget"; - target.Token = "d3a83ee9-74fb-4045-ad25-a84c1d4d7c81"; - target.LogHostname = true; - target.Debug = false; - - var loggingRule = new LoggingRule("*", LogLevel.Info, target); - LogManager.Configuration.AddTarget("logentries", target); - LogManager.Configuration.LoggingRules.Add(loggingRule); - } - private static void RegisterSentry(bool updateClient) { + string dsn; if (updateClient) { - dsn = RuntimeInfo.IsProduction - ? "https://b85aa82c65b84b0e99e3b7c281438357:392b5bc007974147a922c5d841c47cf9@sentry.sonarr.tv/11" - : "https://6168f0946aba4e60ac23e469ac08eac5:bd59e8454ccc454ea27a90cff1f814ca@sentry.sonarr.tv/9"; + dsn = "https://2f3cc03453e4453bb3c1dd3ff77b15ab@sentry.io/1339335"; } else { dsn = RuntimeInfo.IsProduction - ? "https://3e8a38b1a4df4de8b0453a724f5a1139:5a708dd75c724b32ae5128b6a895650f@sentry.sonarr.tv/8" - : "https://4ee3580e01d8407c96a7430fbc953512:5f2d07227a0b4fde99dea07041a3ff93@sentry.sonarr.tv/10"; + ? "https://f607fb34f89745f9bfe5ded0a97ab00a@sentry.io/209545" + : "https://28faaa7023384031b29e38d3be74fa11@sentry.io/227247"; } var target = new SentryTarget(dsn) @@ -109,9 +83,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.Debug, 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() @@ -144,9 +122,9 @@ namespace NzbDrone.Common.Instrumentation private static void RegisterAppFile(IAppFolderInfo appFolderInfo) { - RegisterAppFile(appFolderInfo, "appFileInfo", "sonarr.txt", 5, LogLevel.Info); - RegisterAppFile(appFolderInfo, "appFileDebug", "sonarr.debug.txt", 50, LogLevel.Off); - RegisterAppFile(appFolderInfo, "appFileTrace", "sonarr.trace.txt", 50, LogLevel.Off); + RegisterAppFile(appFolderInfo, "appFileInfo", "Lidarr.txt", 5, LogLevel.Info); + RegisterAppFile(appFolderInfo, "appFileDebug", "Lidarr.debug.txt", 50, LogLevel.Off); + RegisterAppFile(appFolderInfo, "appFileTrace", "Lidarr.trace.txt", 50, LogLevel.Off); } private static void RegisterAppFile(IAppFolderInfo appFolderInfo, string name, string fileName, int maxArchiveFiles, LogLevel minLogLevel) @@ -191,6 +169,23 @@ namespace NzbDrone.Common.Instrumentation LogManager.Configuration.LoggingRules.Add(loggingRule); } + private static void RegisterAuthLogger() + { + var consoleTarget = LogManager.Configuration.FindTargetByName("console"); + var fileTarget = LogManager.Configuration.FindTargetByName("appFileInfo"); + + var target = consoleTarget ?? fileTarget ?? new NullTarget(); + + // Send Auth to Console and info app file, but not the log database + var rule = new LoggingRule("Auth", LogLevel.Info, target) { Final = true }; + if (consoleTarget != null && fileTarget != null) + { + rule.Targets.Add(fileTarget); + } + + LogManager.Configuration.LoggingRules.Insert(0, rule); + } + public static Logger GetLogger(Type obj) { return LogManager.GetLogger(obj.Name.Replace("NzbDrone.", "")); diff --git a/src/NzbDrone.Common/Instrumentation/Sentry/MachineNameUserFactory.cs b/src/NzbDrone.Common/Instrumentation/Sentry/MachineNameUserFactory.cs deleted file mode 100644 index 59e892542..000000000 --- a/src/NzbDrone.Common/Instrumentation/Sentry/MachineNameUserFactory.cs +++ /dev/null @@ -1,12 +0,0 @@ -using SharpRaven.Data; - -namespace NzbDrone.Common.Instrumentation.Sentry -{ - public class MachineNameUserFactory : ISentryUserFactory - { - public SentryUser Create() - { - return new SentryUser(HashUtil.AnonymousToken()); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Common/Instrumentation/Sentry/SentryCleanser.cs b/src/NzbDrone.Common/Instrumentation/Sentry/SentryCleanser.cs new file mode 100644 index 000000000..7d351a55a --- /dev/null +++ b/src/NzbDrone.Common/Instrumentation/Sentry/SentryCleanser.cs @@ -0,0 +1,85 @@ +using System; +using System.Linq; +using Sentry; +using Sentry.Protocol; + +namespace NzbDrone.Common.Instrumentation.Sentry +{ + public static class SentryCleanser + { + public static SentryEvent CleanseEvent(SentryEvent sentryEvent) + { + try + { + sentryEvent.Message = CleanseLogMessage.Cleanse(sentryEvent.Message); + + if (sentryEvent.Fingerprint != null) + { + var fingerprint = sentryEvent.Fingerprint.Select(x => CleanseLogMessage.Cleanse(x)).ToList(); + sentryEvent.SetFingerprint(fingerprint); + } + + if (sentryEvent.Extra != null) + { + var extras = sentryEvent.Extra.ToDictionary(x => x.Key, y => (object)CleanseLogMessage.Cleanse((string)y.Value)); + sentryEvent.SetExtras(extras); + } + + foreach (var exception in sentryEvent.SentryExceptions) + { + exception.Value = CleanseLogMessage.Cleanse(exception.Value); + foreach (var frame in exception.Stacktrace.Frames) + { + frame.FileName = ShortenPath(frame.FileName); + } + } + } + catch (Exception) + { + + } + + return sentryEvent; + } + + public static Breadcrumb CleanseBreadcrumb(Breadcrumb b) + { + try + { + var message = CleanseLogMessage.Cleanse(b.Message); + var data = b.Data?.ToDictionary(x => x.Key, y => CleanseLogMessage.Cleanse(y.Value)); + return new Breadcrumb(message, b.Type, data, b.Category, b.Level); + } + catch(Exception) + { + + } + + return b; + } + + private static string ShortenPath(string path) + { + + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + // the paths in the stacktrace depend on where it was compiled, + // not the current OS + var rootDirs = new [] { "\\src\\", "/src/" }; + foreach (var rootDir in rootDirs) + { + var index = path.IndexOf(rootDir, StringComparison.Ordinal); + + if (index > 0) + { + return path.Substring(index + rootDir.Length - 1); + } + } + + return path; + } + } +} diff --git a/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs b/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs index b0b20eeee..15b2956c9 100644 --- a/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs +++ b/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs @@ -1,54 +1,175 @@ -using System; +using System; +using System.Collections; using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading; +using System.Data.SQLite; using NLog; using NLog.Common; using NLog.Targets; using NzbDrone.Common.EnvironmentInfo; -using SharpRaven; -using SharpRaven.Data; +using NzbDrone.Common.Extensions; +using Sentry; +using Sentry.Protocol; namespace NzbDrone.Common.Instrumentation.Sentry { [Target("Sentry")] public class SentryTarget : TargetWithLayout { - private readonly RavenClient _client; + // don't report uninformative SQLite exceptions + // busy/locked are benign https://forums.sonarr.tv/t/owin-sqlite-error-5-database-is-locked/5423/11 + // The others will be user configuration problems and silt up Sentry + private static readonly HashSet FilteredSQLiteErrors = new HashSet { + SQLiteErrorCode.Busy, + SQLiteErrorCode.Locked, + SQLiteErrorCode.Perm, + SQLiteErrorCode.ReadOnly, + SQLiteErrorCode.IoErr, + SQLiteErrorCode.Corrupt, + SQLiteErrorCode.Full, + SQLiteErrorCode.CantOpen, + SQLiteErrorCode.Auth + }; + + // use string and not Type so we don't need a reference to the project + // where these are defined + private static readonly HashSet FilteredExceptionTypeNames = new HashSet { + // UnauthorizedAccessExceptions will just be user configuration issues + "UnauthorizedAccessException", + // Filter out people stuck in boot loops + "CorruptDatabaseException", + // This also filters some people in boot loops + "TinyIoCResolutionException" + }; - private static readonly IDictionary LoggingLevelMap = new Dictionary + public static readonly List FilteredExceptionMessages = new List { + // Swallow the many, many exceptions flowing through from Jackett + "Jackett.Common.IndexerException", + // Fix openflixr being stupid with permissions + "openflixr" + }; + + // exception types in this list will additionally have the exception message added to the + // sentry fingerprint. Make sure that this message doesn't vary by exception + // (e.g. containing a path or a url) so that the sentry grouping is sensible + private static readonly HashSet IncludeExceptionMessageTypes = new HashSet { + "SQLiteException" + }; + + private static readonly IDictionary LoggingLevelMap = new Dictionary { - {LogLevel.Debug, ErrorLevel.Debug}, - {LogLevel.Error, ErrorLevel.Error}, - {LogLevel.Fatal, ErrorLevel.Fatal}, - {LogLevel.Info, ErrorLevel.Info}, - {LogLevel.Trace, ErrorLevel.Debug}, - {LogLevel.Warn, ErrorLevel.Warning}, + {LogLevel.Debug, SentryLevel.Debug}, + {LogLevel.Error, SentryLevel.Error}, + {LogLevel.Fatal, SentryLevel.Fatal}, + {LogLevel.Info, SentryLevel.Info}, + {LogLevel.Trace, SentryLevel.Debug}, + {LogLevel.Warn, SentryLevel.Warning}, }; + private static readonly IDictionary BreadcrumbLevelMap = new Dictionary + { + {LogLevel.Debug, BreadcrumbLevel.Debug}, + {LogLevel.Error, BreadcrumbLevel.Error}, + {LogLevel.Fatal, BreadcrumbLevel.Critical}, + {LogLevel.Info, BreadcrumbLevel.Info}, + {LogLevel.Trace, BreadcrumbLevel.Debug}, + {LogLevel.Warn, BreadcrumbLevel.Warning}, + }; + + private readonly DateTime _startTime = DateTime.UtcNow; + private readonly IDisposable _sdk; + private bool _disposed; + private readonly SentryDebounce _debounce; private bool _unauthorized; + public bool FilterEvents { get; set; } + public bool SentryEnabled { get; set; } public SentryTarget(string dsn) { - _client = new RavenClient(new Dsn(dsn), new SonarrJsonPacketFactory(), new SentryRequestFactory(), new MachineNameUserFactory()) + _sdk = SentrySdk.Init(o => + { + o.Dsn = new Dsn(dsn); + o.AttachStacktrace = true; + o.MaxBreadcrumbs = 200; + o.SendDefaultPii = false; + o.Debug = false; + o.DiagnosticsLevel = SentryLevel.Debug; + o.Release = BuildInfo.Release; + if (PlatformInfo.IsMono) + { + // Mono 6.0 broke GzipStream.WriteAsync + // TODO: Check specific version + o.RequestBodyCompressionLevel = System.IO.Compression.CompressionLevel.NoCompression; + } + o.BeforeSend = x => SentryCleanser.CleanseEvent(x); + o.BeforeBreadcrumb = x => SentryCleanser.CleanseBreadcrumb(x); + o.Environment = BuildInfo.Branch; + }); + + InitializeScope(); + + _debounce = new SentryDebounce(); + + // initialize to true and reconfigure later + // Otherwise it will default to false and any errors occuring + // before config file gets read will not be filtered + FilterEvents = true; + SentryEnabled = true; + } + + public void InitializeScope() + { + SentrySdk.ConfigureScope(scope => { - Compression = true, - Environment = RuntimeInfo.IsProduction ? "production" : "development", - Release = BuildInfo.Release, - ErrorOnCapture = OnError - }; + scope.User = new User + { + Id = HashUtil.AnonymousToken() + }; + scope.Contexts.App.Name = BuildInfo.AppName; + scope.Contexts.App.Version = BuildInfo.Version.ToString(); + scope.Contexts.App.StartTime = _startTime; + scope.Contexts.App.Hash = HashUtil.AnonymousToken(); + scope.Contexts.App.Build = BuildInfo.Release; // Git commit cache? - _client.Tags.Add("osfamily", OsInfo.Os.ToString()); - _client.Tags.Add("runtime", PlatformInfo.PlatformName); - _client.Tags.Add("culture", Thread.CurrentThread.CurrentCulture.Name); - _client.Tags.Add("branch", BuildInfo.Branch); - _client.Tags.Add("version", BuildInfo.Version.ToString()); + scope.SetTag("culture", Thread.CurrentThread.CurrentCulture.Name); + scope.SetTag("branch", BuildInfo.Branch); + }); + } - _debounce = new SentryDebounce(); + public void UpdateScope(IOsInfo osInfo) + { + SentrySdk.ConfigureScope(scope => + { + scope.SetTag("is_docker", $"{osInfo.IsDocker}"); + + if (osInfo.Name != null && PlatformInfo.IsMono) + { + // Sentry auto-detection of non-Windows platforms isn't that accurate on certain devices. + scope.Contexts.OperatingSystem.Name = osInfo.Name.FirstCharToUpper(); + scope.Contexts.OperatingSystem.RawDescription = osInfo.FullName; + scope.Contexts.OperatingSystem.Version = osInfo.Version.ToString(); + } + }); + } + + public void UpdateScope(Version databaseVersion, int migration, string updateBranch, IPlatformInfo platformInfo) + { + SentrySdk.ConfigureScope(scope => + { + scope.Environment = updateBranch; + scope.SetTag("runtime_version", $"{PlatformInfo.PlatformName} {platformInfo.Version}"); + + if (databaseVersion != default(Version)) + { + scope.SetTag("sqlite_version", $"{databaseVersion}"); + scope.SetTag("database_migration", $"{migration}"); + } + }); } private void OnError(Exception ex) @@ -71,36 +192,85 @@ 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(), - logEvent.LoggerName + logEvent.Level.ToString(), + logEvent.LoggerName, + logEvent.Message }; var ex = logEvent.Exception; if (ex != null) { - var exception = ex.GetType().Name; - + fingerPrint.Add(ex.GetType().FullName); + fingerPrint.Add(ex.TargetSite.ToString()); if (ex.InnerException != null) { - exception += ex.InnerException.GetType().Name; + fingerPrint.Add(ex.InnerException.GetType().FullName); + } + else if (IncludeExceptionMessageTypes.Contains(ex.GetType().Name)) + { + fingerPrint.Add(ex?.Message); } - - fingerPrint.Add(exception); } return fingerPrint; } + public bool IsSentryMessage(LogEventInfo logEvent) + { + if (logEvent.Properties.ContainsKey("Sentry")) + { + return logEvent.Properties["Sentry"] != null; + } + + if (logEvent.Level >= LogLevel.Error && logEvent.Exception != null) + { + if (FilterEvents) + { + var sqlEx = logEvent.Exception as SQLiteException; + if (sqlEx != null && FilteredSQLiteErrors.Contains(sqlEx.ResultCode)) + { + return false; + } + + if (FilteredExceptionTypeNames.Contains(logEvent.Exception.GetType().Name)) + { + return false; + } + + if (FilteredExceptionMessages.Any(x => logEvent.Exception.Message.Contains(x))) + { + return false; + } + } + + return true; + } + + return false; + } + protected override void Write(LogEventInfo logEvent) { + if (_unauthorized || !SentryEnabled) + { + return; + } + try { + SentrySdk.AddBreadcrumb(logEvent.FormattedMessage, logEvent.LoggerName, level: BreadcrumbLevelMap[logEvent.Level]); + // don't report non-critical events without exceptions - if (logEvent.Exception == null || _unauthorized) + if (!IsSentryMessage(logEvent)) { return; } @@ -111,45 +281,54 @@ namespace NzbDrone.Common.Instrumentation.Sentry return; } - var extras = logEvent.Properties.ToDictionary(x => x.Key.ToString(), x => x.Value.ToString()); - _client.Logger = logEvent.LoggerName; - + var extras = logEvent.Properties.ToDictionary(x => x.Key.ToString(), x => (object)x.Value.ToString()); + extras.Remove("Sentry"); - var sentryMessage = new SentryMessage(logEvent.Message, logEvent.Parameters); - - var sentryEvent = new SentryEvent(logEvent.Exception) + if (logEvent.Exception != null) { - Level = LoggingLevelMap[logEvent.Level], - Message = sentryMessage, - Extra = extras, - Fingerprint = + foreach (DictionaryEntry data in logEvent.Exception.Data) { - logEvent.Level.ToString(), - logEvent.LoggerName, - logEvent.Message + extras.Add(data.Key.ToString(), data.Value.ToString()); } - }; - - if (logEvent.Exception != null) - { - sentryEvent.Fingerprint.Add(logEvent.Exception.GetType().FullName); } - var osName = Environment.GetEnvironmentVariable("OS_NAME"); - var osVersion = Environment.GetEnvironmentVariable("OS_VERSION"); - var runTimeVersion = Environment.GetEnvironmentVariable("RUNTIME_VERSION"); - + var sentryEvent = new SentryEvent(logEvent.Exception) + { + Level = LoggingLevelMap[logEvent.Level], + Logger = logEvent.LoggerName, + Message = logEvent.FormattedMessage + }; - sentryEvent.Tags.Add("os_name", osName); - sentryEvent.Tags.Add("os_version", $"{osName} {osVersion}"); - sentryEvent.Tags.Add("runtime_version", $"{PlatformInfo.PlatformName} {runTimeVersion}"); + sentryEvent.SetExtras(extras); + sentryEvent.SetFingerprint(fingerPrint); - _client.Capture(sentryEvent); + SentrySdk.CaptureEvent(sentryEvent); } catch (Exception e) { OnError(e); } } + + // https://stackoverflow.com/questions/2496311/implementing-idisposable-on-a-subclass-when-the-parent-also-implements-idisposab + protected override void Dispose(bool disposing) + { + // Only do something if we're not already disposed + if (_disposed) + { + // If disposing == true, we're being called from a call to base.Dispose(). In this case, we Dispose() our logger + // If we're being called from a finalizer, our logger will get finalized as well, so no need to do anything. + if (disposing) + { + _sdk?.Dispose(); + } + // Flag us as disposed. This allows us to handle multiple calls to Dispose() as well as ObjectDisposedException + _disposed = true; + } + + // This should always be safe to call multiple times! + // We could include it in the check for disposed above, but I left it out to demonstrate that it's safe + base.Dispose(disposing); + } } } diff --git a/src/NzbDrone.Common/Instrumentation/Sentry/SonarrJsonPacketFactory.cs b/src/NzbDrone.Common/Instrumentation/Sentry/SonarrJsonPacketFactory.cs deleted file mode 100644 index fb639fb2f..000000000 --- a/src/NzbDrone.Common/Instrumentation/Sentry/SonarrJsonPacketFactory.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System; -using System.Collections.Generic; -using SharpRaven.Data; - -namespace NzbDrone.Common.Instrumentation.Sentry -{ - public class SonarrJsonPacketFactory : IJsonPacketFactory - { - private static string ShortenPath(string path) - { - - if (string.IsNullOrWhiteSpace(path)) - { - return null; - } - - var index = path.IndexOf("\\src\\", StringComparison.Ordinal); - - if (index <= 0) - { - return path; - } - - return path.Substring(index + "\\src".Length); - } - - public JsonPacket Create(string project, SentryEvent @event) - { - var packet = new SonarrSentryPacket(project, @event); - - try - { - foreach (var exception in packet.Exceptions) - { - foreach (var frame in exception.Stacktrace.Frames) - { - frame.Filename = ShortenPath(frame.Filename); - } - } - } - catch (Exception) - { - - } - - return packet; - } - - - [Obsolete] - public JsonPacket Create(string project, SentryMessage message, ErrorLevel level = ErrorLevel.Info, IDictionary tags = null, - string[] fingerprint = null, object extra = null) - { - throw new NotImplementedException(); - } - - [Obsolete] - public JsonPacket Create(string project, Exception exception, SentryMessage message = null, ErrorLevel level = ErrorLevel.Error, - IDictionary tags = null, string[] fingerprint = null, object extra = null) - { - throw new NotImplementedException(); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Common/Instrumentation/Sentry/SonarrSentryPacket.cs b/src/NzbDrone.Common/Instrumentation/Sentry/SonarrSentryPacket.cs deleted file mode 100644 index 8dbfba818..000000000 --- a/src/NzbDrone.Common/Instrumentation/Sentry/SonarrSentryPacket.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Newtonsoft.Json; -using SharpRaven.Data; - -namespace NzbDrone.Common.Instrumentation.Sentry -{ - public class SonarrSentryPacket : JsonPacket - { - private readonly JsonSerializerSettings _setting; - - public SonarrSentryPacket(string project, SentryEvent @event) : - base(project, @event) - { - _setting = new JsonSerializerSettings - { - DefaultValueHandling = DefaultValueHandling.Ignore - }; - } - - public override string ToString(Formatting formatting) - { - return JsonConvert.SerializeObject(this, formatting, _setting); - } - - public override string ToString() - { - return ToString(Formatting.None); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Common/Lidarr.Common.csproj b/src/NzbDrone.Common/Lidarr.Common.csproj new file mode 100644 index 000000000..412138c97 --- /dev/null +++ b/src/NzbDrone.Common/Lidarr.Common.csproj @@ -0,0 +1,33 @@ + + + net462 + x86 + + + + + + + + + + + + + + ..\Libraries\Sqlite\System.Data.SQLite.dll + + + + + + True + True + ExceptionMessages.resx + + + ResXFileCodeGenerator + ExceptionMessages.Designer.cs + + + diff --git a/src/NzbDrone.Common/NzbDrone.Common.csproj b/src/NzbDrone.Common/NzbDrone.Common.csproj deleted file mode 100644 index 4346a2610..000000000 --- a/src/NzbDrone.Common/NzbDrone.Common.csproj +++ /dev/null @@ -1,253 +0,0 @@ - - - - Debug - x86 - 8.0.30703 - 2.0 - {F2BE0FDF-6E47-4827-A420-DD4EF82407F8} - Library - Properties - NzbDrone.Common - NzbDrone.Common - v4.0 - 512 - ..\ - true - - - - - true - ..\..\_output\ - DEBUG;TRACE - full - x86 - prompt - MinimumRecommendedRules.ruleset - 4 - false - - - ..\..\_output\ - TRACE - true - pdbonly - x86 - prompt - MinimumRecommendedRules.ruleset - 4 - - - - ..\packages\Newtonsoft.Json.9.0.1\lib\net40\Newtonsoft.Json.dll - True - - - ..\packages\NLog.4.4.1\lib\net40\NLog.dll - True - - - ..\packages\DotNet4.SocksProxy.1.3.2.0\lib\net40\Org.Mentalis.dll - True - - - ..\packages\SharpRaven.2.1.0\lib\net40\SharpRaven.dll - True - - - ..\packages\DotNet4.SocksProxy.1.3.2.0\lib\net40\SocksWebProxy.dll - True - - - - - - - - - - ..\packages\ICSharpCode.SharpZipLib.Patched.0.86.5\lib\net20\ICSharpCode.SharpZipLib.dll - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Component - - - - - - - - - - - - - - - - - - Component - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Always - - - Designer - - - - - - - - - - - {74420a79-cc16-442c-8b1e-7c1b913844f0} - CurlSharp - - - {9DC31DE3-79FF-47A8-96B4-6BA18F6BB1CB} - LogentriesNLog - - - - - \ No newline at end of file diff --git a/src/NzbDrone.Common/Processes/PidFileProvider.cs b/src/NzbDrone.Common/Processes/PidFileProvider.cs index c04ff445f..a272b1c7b 100644 --- a/src/NzbDrone.Common/Processes/PidFileProvider.cs +++ b/src/NzbDrone.Common/Processes/PidFileProvider.cs @@ -1,7 +1,8 @@ -using System; +using System; using System.IO; using NLog; using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Exceptions; namespace NzbDrone.Common.Processes { @@ -30,7 +31,7 @@ namespace NzbDrone.Common.Processes return; } - var filename = Path.Combine(_appFolderInfo.AppDataFolder, "nzbdrone.pid"); + var filename = Path.Combine(_appFolderInfo.AppDataFolder, "lidarr.pid"); try { File.WriteAllText(filename, _processProvider.GetCurrentProcessId().ToString()); @@ -38,7 +39,7 @@ namespace NzbDrone.Common.Processes catch (Exception ex) { _logger.Error(ex, "Unable to write PID file {0}", filename); - throw; + throw new LidarrStartupException(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..d69d90bf9 100644 --- a/src/NzbDrone.Common/Processes/ProcessProvider.cs +++ b/src/NzbDrone.Common/Processes/ProcessProvider.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; @@ -27,7 +27,7 @@ namespace NzbDrone.Common.Processes bool Exists(string processName); ProcessPriorityClass GetCurrentProcessPriority(); Process Start(string path, string args = null, StringDictionary environmentVariables = null, Action onOutputDataReceived = null, Action onErrorDataReceived = null); - Process SpawnNewProcess(string path, string args = null, StringDictionary environmentVariables = null); + Process SpawnNewProcess(string path, string args = null, StringDictionary environmentVariables = null, bool noWindow = false); ProcessOutput StartAndCapture(string path, string args = null, StringDictionary environmentVariables = null); } @@ -35,8 +35,8 @@ namespace NzbDrone.Common.Processes { private readonly Logger _logger; - public const string NZB_DRONE_PROCESS_NAME = "NzbDrone"; - public const string NZB_DRONE_CONSOLE_PROCESS_NAME = "NzbDrone.Console"; + public const string LIDARR_PROCESS_NAME = "Lidarr"; + public const string LIDARR_CONSOLE_PROCESS_NAME = "Lidarr.Console"; public ProcessProvider(Logger logger) { @@ -108,11 +108,7 @@ namespace NzbDrone.Common.Processes public Process Start(string path, string args = null, StringDictionary environmentVariables = null, Action onOutputDataReceived = null, Action onErrorDataReceived = null) { - if (PlatformInfo.IsMono && path.EndsWith(".exe", StringComparison.InvariantCultureIgnoreCase)) - { - args = GetMonoArgs(path, args); - path = "mono"; - } + (path, args) = GetPathAndArgs(path, args); var logger = LogManager.GetLogger(new FileInfo(path).Name); @@ -129,7 +125,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; + } } } @@ -172,17 +186,16 @@ namespace NzbDrone.Common.Processes return process; } - public Process SpawnNewProcess(string path, string args = null, StringDictionary environmentVariables = null) + public Process SpawnNewProcess(string path, string args = null, StringDictionary environmentVariables = null, bool noWindow = false) { - if (PlatformInfo.IsMono && path.EndsWith(".exe", StringComparison.InvariantCultureIgnoreCase)) - { - args = GetMonoArgs(path, args); - path = "mono"; - } + (path, args) = GetPathAndArgs(path, args); _logger.Debug("Starting {0} {1}", path, args); var startInfo = new ProcessStartInfo(path, args); + startInfo.CreateNoWindow = noWindow; + startInfo.UseShellExecute = !noWindow; + var process = new Process { StartInfo = startInfo @@ -315,6 +328,8 @@ namespace NzbDrone.Common.Processes var monoProcesses = Process.GetProcessesByName("mono") .Union(Process.GetProcessesByName("mono-sgen")) + .Union(Process.GetProcessesByName("mono-sgen32")) + .Union(Process.GetProcessesByName("mono-sgen64")) .Where(process => process.Modules.Cast() .Any(module => @@ -340,9 +355,19 @@ namespace NzbDrone.Common.Processes return processes; } - private string GetMonoArgs(string path, string args) + private (string Path, string Args) GetPathAndArgs(string path, string args) { - return string.Format("--debug {0} {1}", path, args); + if (PlatformInfo.IsMono && path.EndsWith(".exe", StringComparison.InvariantCultureIgnoreCase)) + { + return ("mono", $"--debug {path} {args}"); + } + + if (OsInfo.IsWindows && path.EndsWith(".bat", StringComparison.InvariantCultureIgnoreCase)) + { + return ("cmd.exe", $"/c {path} {args}"); + } + + return (path, args); } } } diff --git a/src/NzbDrone.Common/Properties/AssemblyInfo.cs b/src/NzbDrone.Common/Properties/AssemblyInfo.cs deleted file mode 100644 index e8cdf90c1..000000000 --- a/src/NzbDrone.Common/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("NzbDrone.Common")] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("b6eaa144-e13b-42e5-a738-c60d89c0f728")] - -[assembly: AssemblyVersion("10.0.0.*")] diff --git a/src/NzbDrone.Common/Properties/SharedAssemblyInfo.cs b/src/NzbDrone.Common/Properties/SharedAssemblyInfo.cs deleted file mode 100644 index 1e622af2c..000000000 --- a/src/NzbDrone.Common/Properties/SharedAssemblyInfo.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -// Gets updated at build time by TeamCity to branch name -[assembly: AssemblyConfiguration("debug")] - -[assembly: AssemblyCompany("sonarr.tv")] -[assembly: AssemblyProduct("NzbDrone")] -[assembly: AssemblyCopyright("GNU General Public v3")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. - -[assembly: ComVisible(false)] diff --git a/src/NzbDrone.Common/Reflection/ReflectionExtensions.cs b/src/NzbDrone.Common/Reflection/ReflectionExtensions.cs index db7edc31b..1e778b843 100644 --- a/src/NzbDrone.Common/Reflection/ReflectionExtensions.cs +++ b/src/NzbDrone.Common/Reflection/ReflectionExtensions.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Reflection; @@ -7,7 +7,7 @@ namespace NzbDrone.Common.Reflection { public static class ReflectionExtensions { - public static readonly Assembly CoreAssembly = Assembly.Load("NzbDrone.Core"); + public static readonly Assembly CoreAssembly = Assembly.Load("Lidarr.Core"); public static List GetSimpleProperties(this Type type) { @@ -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..c0ebc56eb 100644 --- a/src/NzbDrone.Common/Security/SecurityProtocolPolicy.cs +++ b/src/NzbDrone.Common/Security/SecurityProtocolPolicy.cs @@ -1,6 +1,7 @@ -using System; +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/Json.cs b/src/NzbDrone.Common/Serializer/Json.cs index 31e0d3f0b..5b16c8278 100644 --- a/src/NzbDrone.Common/Serializer/Json.cs +++ b/src/NzbDrone.Common/Serializer/Json.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using Newtonsoft.Json; using Newtonsoft.Json.Converters; @@ -9,37 +9,40 @@ namespace NzbDrone.Common.Serializer public static class Json { private static readonly JsonSerializer Serializer; - private static readonly JsonSerializerSettings SerializerSetting; + private static readonly JsonSerializerSettings SerializerSettings; static Json() { - SerializerSetting = new JsonSerializerSettings - { - DateTimeZoneHandling = DateTimeZoneHandling.Utc, - NullValueHandling = NullValueHandling.Ignore, - Formatting = Formatting.Indented, - DefaultValueHandling = DefaultValueHandling.Include, - ContractResolver = new CamelCasePropertyNamesContractResolver() - }; - + SerializerSettings = GetSerializerSettings(); + Serializer = JsonSerializer.Create(SerializerSettings); + } - SerializerSetting.Converters.Add(new StringEnumConverter { CamelCaseText = true }); - //SerializerSetting.Converters.Add(new IntConverter()); - SerializerSetting.Converters.Add(new VersionConverter()); - SerializerSetting.Converters.Add(new HttpUriConverter()); + public static JsonSerializerSettings GetSerializerSettings() + { + var serializerSettings = new JsonSerializerSettings + { + DateTimeZoneHandling = DateTimeZoneHandling.Utc, + NullValueHandling = NullValueHandling.Ignore, + Formatting = Formatting.Indented, + DefaultValueHandling = DefaultValueHandling.Include, + ContractResolver = new CamelCasePropertyNamesContractResolver() + }; - Serializer = JsonSerializer.Create(SerializerSetting); + serializerSettings.Converters.Add(new StringEnumConverter { NamingStrategy = new CamelCaseNamingStrategy() }); + serializerSettings.Converters.Add(new VersionConverter()); + serializerSettings.Converters.Add(new HttpUriConverter()); + return serializerSettings; } public static T Deserialize(string json) where T : new() { - return JsonConvert.DeserializeObject(json, SerializerSetting); + return JsonConvert.DeserializeObject(json, SerializerSettings); } public static object Deserialize(string json, Type type) { - return JsonConvert.DeserializeObject(json, type, SerializerSetting); + return JsonConvert.DeserializeObject(json, type, SerializerSettings); } public static bool TryDeserialize(string json, out T result) where T : new() @@ -63,7 +66,7 @@ namespace NzbDrone.Common.Serializer public static string ToJson(this object obj) { - return JsonConvert.SerializeObject(obj, SerializerSetting); + return JsonConvert.SerializeObject(obj, SerializerSettings); } public static void Serialize(TModel model, TextWriter outputStream) 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..49ca2e207 --- /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/ServiceProvider.cs b/src/NzbDrone.Common/ServiceProvider.cs index b494381c3..2819c2736 100644 --- a/src/NzbDrone.Common/ServiceProvider.cs +++ b/src/NzbDrone.Common/ServiceProvider.cs @@ -1,10 +1,11 @@ -using System; +using System; using System.Collections.Specialized; using System.Configuration.Install; using System.Diagnostics; using System.Linq; using System.ServiceProcess; using NLog; +using NzbDrone.Common.Extensions; using NzbDrone.Common.Processes; namespace NzbDrone.Common @@ -14,23 +15,23 @@ namespace NzbDrone.Common bool ServiceExist(string name); bool IsServiceRunning(string name); void Install(string serviceName); - void UnInstall(string serviceName); + void Uninstall(string serviceName); void Run(ServiceBase service); ServiceController GetService(string serviceName); void Stop(string serviceName); void Start(string serviceName); ServiceControllerStatus GetStatus(string serviceName); void Restart(string serviceName); + void SetPermissions(string serviceName); } public class ServiceProvider : IServiceProvider { - public const string NZBDRONE_SERVICE_NAME = "NzbDrone"; + public const string SERVICE_NAME = "Lidarr"; private readonly IProcessProvider _processProvider; private readonly Logger _logger; - public ServiceProvider(IProcessProvider processProvider, Logger logger) { _processProvider = processProvider; @@ -66,7 +67,7 @@ namespace NzbDrone.Common var installer = new ServiceProcessInstaller { - Account = ServiceAccount.LocalSystem + Account = ServiceAccount.LocalService }; var serviceInstaller = new ServiceInstaller(); @@ -78,7 +79,7 @@ namespace NzbDrone.Common serviceInstaller.Context = context; serviceInstaller.DisplayName = serviceName; serviceInstaller.ServiceName = serviceName; - serviceInstaller.Description = "NzbDrone Application Server"; + serviceInstaller.Description = "Lidarr Application Server"; serviceInstaller.StartType = ServiceStartMode.Automatic; serviceInstaller.ServicesDependedOn = new[] { "EventLog", "Tcpip", "http" }; @@ -89,7 +90,7 @@ namespace NzbDrone.Common _logger.Info("Service Has installed successfully."); } - public virtual void UnInstall(string serviceName) + public virtual void Uninstall(string serviceName) { _logger.Info("Uninstalling {0} service", serviceName); @@ -189,5 +190,42 @@ namespace NzbDrone.Common _processProvider.Start("cmd.exe", args); } + + public void SetPermissions(string serviceName) + { + var dacls = GetServiceDacls(serviceName); + SetServiceDacls(serviceName, dacls); + } + + private string GetServiceDacls(string serviceName) + { + var output = _processProvider.StartAndCapture("sc.exe", $"sdshow {serviceName}"); + + var dacls = output.Standard.Select(s => s.Content).Where(s => s.IsNotNullOrWhiteSpace()).ToList(); + + if (dacls.Count == 1) + { + return dacls[0]; + } + + throw new ArgumentException("Invalid DACL output"); + } + + private void SetServiceDacls(string serviceName, string dacls) + { + const string authenticatedUsersDacl = "(A;;CCLCSWRPWPLOCRRC;;;AU)"; + + if (dacls.Contains(authenticatedUsersDacl)) + { + // Permssions already set + return; + } + + var indexOfS = dacls.IndexOf("S:", StringComparison.InvariantCultureIgnoreCase); + + dacls = indexOfS == -1 ? $"{dacls}{authenticatedUsersDacl}" : dacls.Insert(indexOfS, authenticatedUsersDacl); + + _processProvider.Start("sc.exe", $"sdset {serviceName} {dacls}").WaitForExit(); + } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Common/app.config b/src/NzbDrone.Common/app.config index 8460dd432..835ae48cc 100644 --- a/src/NzbDrone.Common/app.config +++ b/src/NzbDrone.Common/app.config @@ -1,11 +1,11 @@ - + - - + + - \ No newline at end of file + diff --git a/src/NzbDrone.Common/packages.config b/src/NzbDrone.Common/packages.config deleted file mode 100644 index 91e117688..000000000 --- a/src/NzbDrone.Common/packages.config +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/src/NzbDrone.Console/ConsoleApp.cs b/src/NzbDrone.Console/ConsoleApp.cs index 6f935887f..54e12655a 100644 --- a/src/NzbDrone.Console/ConsoleApp.cs +++ b/src/NzbDrone.Console/ConsoleApp.cs @@ -1,9 +1,11 @@ -using System; +using System; using System.Net.Sockets; using NLog; using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Exceptions; using NzbDrone.Common.Instrumentation; using NzbDrone.Host; +using NzbDrone.Host.AccessControl; namespace NzbDrone.Console { @@ -11,38 +13,90 @@ 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 (LidarrStartupException 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 Lidarr is already running another application is using the same port (default: 8686) or the user has insufficient permissions"); + Exit(ExitCodes.RecoverableFailure); + } + catch (RemoteAccessException ex) + { + System.Console.WriteLine(""); + System.Console.WriteLine(""); + Logger.Fatal(ex, "EPIC FAIL!"); + Exit(ExitCodes.Normal); + } + 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/Lidarr.Console.csproj b/src/NzbDrone.Console/Lidarr.Console.csproj new file mode 100644 index 000000000..9f416b8a2 --- /dev/null +++ b/src/NzbDrone.Console/Lidarr.Console.csproj @@ -0,0 +1,13 @@ + + + Exe + net462 + x86 + + ..\NzbDrone.Host\NzbDrone.ico + app.manifest + + + + + diff --git a/src/NzbDrone.Console/NzbDrone.Console.csproj b/src/NzbDrone.Console/NzbDrone.Console.csproj deleted file mode 100644 index 7264ad35b..000000000 --- a/src/NzbDrone.Console/NzbDrone.Console.csproj +++ /dev/null @@ -1,158 +0,0 @@ - - - - Debug - x86 - 8.0.30703 - 2.0 - {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976} - Exe - Properties - NzbDrone.Console - NzbDrone.Console - v4.0 - 512 - - - false - publish\ - true - Disk - false - Foreground - 7 - Days - false - false - true - 0 - 1.0.0.%2a - false - true - ..\ - true - - - x86 - true - full - false - ..\..\_output\ - DEBUG;TRACE - prompt - 4 - true - BasicCorrectnessRules.ruleset - - - x86 - pdbonly - true - ..\..\_output\ - TRACE - prompt - 4 - - - ..\NzbDrone.Host\NzbDrone.ico - - - NzbDrone.Console.ConsoleApp - - - OnBuildSuccess - - - app.manifest - - - - False - ..\packages\Microsoft.Owin.2.1.0\lib\net40\Microsoft.Owin.dll - - - False - ..\packages\Microsoft.Owin.Hosting.2.1.0\lib\net40\Microsoft.Owin.Hosting.dll - - - ..\packages\Newtonsoft.Json.9.0.1\lib\net40\Newtonsoft.Json.dll - True - - - ..\packages\NLog.4.4.1\lib\net40\NLog.dll - True - - - - - ..\packages\Owin.1.0\lib\net40\Owin.dll - - - - - Properties\SharedAssemblyInfo.cs - - - - - - - - False - Microsoft .NET Framework 4 %28x86 and x64%29 - true - - - False - .NET Framework 3.5 SP1 Client Profile - false - - - False - .NET Framework 3.5 SP1 - false - - - False - Windows Installer 3.1 - true - - - - - {1B9A82C4-BCA1-4834-A33E-226F17BE070B} - Microsoft.AspNet.SignalR.Core - - - {2B8C6DAD-4D85-41B1-83FD-248D9F347522} - Microsoft.AspNet.SignalR.Owin - - - {F2BE0FDF-6E47-4827-A420-DD4EF82407F8} - NzbDrone.Common - - - {95C11A9E-56ED-456A-8447-2C89C1139266} - NzbDrone.Host - - - - - app.config - - - - - - - - - - - \ No newline at end of file diff --git a/src/NzbDrone.Console/Properties/AssemblyInfo.cs b/src/NzbDrone.Console/Properties/AssemblyInfo.cs deleted file mode 100644 index ed519f028..000000000 --- a/src/NzbDrone.Console/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. - -[assembly: AssemblyTitle("NzbDrone.Host")] -[assembly: Guid("67AADCD9-89AA-4D95-8281-3193740E70E5")] - -[assembly: AssemblyVersion("10.0.0.*")] \ No newline at end of file diff --git a/src/NzbDrone.Console/app.manifest b/src/NzbDrone.Console/app.manifest index 523cc7301..8e6eb2fea 100644 --- a/src/NzbDrone.Console/app.manifest +++ b/src/NzbDrone.Console/app.manifest @@ -43,4 +43,10 @@ + + + + true + + diff --git a/src/NzbDrone.Console/packages.config b/src/NzbDrone.Console/packages.config deleted file mode 100644 index 0c7772d2c..000000000 --- a/src/NzbDrone.Console/packages.config +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/ArtistStatsTests/ArtistStatisticsFixture.cs b/src/NzbDrone.Core.Test/ArtistStatsTests/ArtistStatisticsFixture.cs new file mode 100644 index 000000000..34a00f50f --- /dev/null +++ b/src/NzbDrone.Core.Test/ArtistStatsTests/ArtistStatisticsFixture.cs @@ -0,0 +1,130 @@ +using System; +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.ArtistStats; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Test.ArtistStatsTests +{ + [TestFixture] + public class ArtistStatisticsFixture : DbTest + { + private Artist _artist; + private Album _album; + private AlbumRelease _release; + private Track _track; + private TrackFile _trackFile; + + [SetUp] + public void Setup() + { + _artist = Builder.CreateNew() + .With(a => a.ArtistMetadataId = 10) + .BuildNew(); + Db.Insert(_artist); + + _album = Builder.CreateNew() + .With(e => e.ReleaseDate = DateTime.Today.AddDays(-5)) + .With(e => e.ArtistMetadataId = 10) + .BuildNew(); + Db.Insert(_album); + + _release = Builder.CreateNew() + .With(e => e.AlbumId = _album.Id) + .With(e => e.Monitored = true) + .BuildNew(); + Db.Insert(_release); + + _track = Builder.CreateNew() + .With(e => e.TrackFileId = 0) + .With(e => e.Artist = _artist) + .With(e => e.AlbumReleaseId = _release.Id) + .BuildNew(); + + _trackFile = Builder.CreateNew() + .With(e => e.Artist = _artist) + .With(e => e.Album = _album) + .With(e => e.Quality = new QualityModel(Quality.MP3_256)) + .BuildNew(); + + } + + private void GivenTrackWithFile() + { + _track.TrackFileId = 1; + } + + private void GivenTrack() + { + Db.Insert(_track); + } + + private void GivenTrackFile() + { + Db.Insert(_trackFile); + } + + [Test] + public void should_get_stats_for_artist() + { + GivenTrack(); + + var stats = Subject.ArtistStatistics(); + + stats.Should().HaveCount(1); + } + + [Test] + public void should_not_include_unmonitored_track_in_track_count() + { + GivenTrack(); + + var stats = Subject.ArtistStatistics(); + + stats.Should().HaveCount(1); + stats.First().TrackCount.Should().Be(0); + } + + [Test] + public void should_include_unmonitored_track_with_file_in_track_count() + { + GivenTrackWithFile(); + GivenTrack(); + + var stats = Subject.ArtistStatistics(); + + stats.Should().HaveCount(1); + stats.First().TrackCount.Should().Be(1); + } + + [Test] + public void should_have_size_on_disk_of_zero_when_no_track_file() + { + GivenTrack(); + + var stats = Subject.ArtistStatistics(); + + stats.Should().HaveCount(1); + stats.First().SizeOnDisk.Should().Be(0); + } + + [Test] + public void should_have_size_on_disk_when_track_file_exists() + { + GivenTrackWithFile(); + GivenTrack(); + GivenTrackFile(); + + var stats = Subject.ArtistStatistics(); + + stats.Should().HaveCount(1); + stats.First().SizeOnDisk.Should().Be(_trackFile.Size); + } + + } +} diff --git a/src/NzbDrone.Core.Test/Blacklisting/BlacklistRepositoryFixture.cs b/src/NzbDrone.Core.Test/Blacklisting/BlacklistRepositoryFixture.cs index 4cc75b955..946a5ad66 100644 --- a/src/NzbDrone.Core.Test/Blacklisting/BlacklistRepositoryFixture.cs +++ b/src/NzbDrone.Core.Test/Blacklisting/BlacklistRepositoryFixture.cs @@ -19,10 +19,10 @@ namespace NzbDrone.Core.Test.Blacklisting { _blacklist = new Blacklist { - SeriesId = 12345, - EpisodeIds = new List { 1 }, - Quality = new QualityModel(Quality.Bluray720p), - SourceTitle = "series.title.s01e01", + ArtistId = 12345, + AlbumIds = new List { 1 }, + Quality = new QualityModel(Quality.FLAC), + SourceTitle = "artist.name.album.title", Date = DateTime.UtcNow }; } @@ -35,11 +35,11 @@ namespace NzbDrone.Core.Test.Blacklisting } [Test] - public void should_should_have_episode_ids() + public void should_should_have_album_ids() { Subject.Insert(_blacklist); - Subject.All().First().EpisodeIds.Should().Contain(_blacklist.EpisodeIds); + Subject.All().First().AlbumIds.Should().Contain(_blacklist.AlbumIds); } [Test] @@ -47,7 +47,7 @@ namespace NzbDrone.Core.Test.Blacklisting { Subject.Insert(_blacklist); - Subject.BlacklistedByTitle(_blacklist.SeriesId, _blacklist.SourceTitle.ToUpperInvariant()).Should().HaveCount(1); + Subject.BlacklistedByTitle(_blacklist.ArtistId, _blacklist.SourceTitle.ToUpperInvariant()).Should().HaveCount(1); } } } diff --git a/src/NzbDrone.Core.Test/Blacklisting/BlacklistServiceFixture.cs b/src/NzbDrone.Core.Test/Blacklisting/BlacklistServiceFixture.cs index 8766de661..eb4e1fa56 100644 --- a/src/NzbDrone.Core.Test/Blacklisting/BlacklistServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Blacklisting/BlacklistServiceFixture.cs @@ -19,10 +19,10 @@ namespace NzbDrone.Core.Test.Blacklisting { _event = new DownloadFailedEvent { - SeriesId = 12345, - EpisodeIds = new List {1}, - Quality = new QualityModel(Quality.Bluray720p), - SourceTitle = "series.title.s01e01", + ArtistId = 12345, + AlbumIds = new List {1}, + Quality = new QualityModel(Quality.MP3_320), + SourceTitle = "artist.name.album.title", DownloadClient = "SabnzbdClient", DownloadId = "Sabnzbd_nzo_2dfh73k" }; @@ -40,7 +40,7 @@ namespace NzbDrone.Core.Test.Blacklisting Subject.Handle(_event); Mocker.GetMock() - .Verify(v => v.Insert(It.Is(b => b.EpisodeIds == _event.EpisodeIds)), Times.Once()); + .Verify(v => v.Insert(It.Is(b => b.AlbumIds == _event.AlbumIds)), Times.Once()); } [Test] @@ -52,7 +52,7 @@ namespace NzbDrone.Core.Test.Blacklisting _event.Data.Remove("protocol"); Mocker.GetMock() - .Verify(v => v.Insert(It.Is(b => b.EpisodeIds == _event.EpisodeIds)), Times.Once()); + .Verify(v => v.Insert(It.Is(b => b.AlbumIds == _event.AlbumIds)), Times.Once()); } } } diff --git a/src/NzbDrone.Core.Test/DataAugmentation/DailySeries/DailySeriesDataProxyFixture.cs b/src/NzbDrone.Core.Test/DataAugmentation/DailySeries/DailySeriesDataProxyFixture.cs deleted file mode 100644 index b429e24b2..000000000 --- a/src/NzbDrone.Core.Test/DataAugmentation/DailySeries/DailySeriesDataProxyFixture.cs +++ /dev/null @@ -1,27 +0,0 @@ -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.DataAugmentation.DailySeries; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Test.Common.Categories; - -namespace NzbDrone.Core.Test.DataAugmentation.DailySeries -{ - [TestFixture] - [IntegrationTest] - public class DailySeriesDataProxyFixture : CoreTest - { - [SetUp] - public void Setup() - { - UseRealHttp(); - } - - [Test] - public void should_get_list_of_daily_series() - { - var list = Subject.GetDailySeriesIds(); - list.Should().NotBeEmpty(); - list.Should().OnlyHaveUniqueItems(); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/DataAugmentation/Scene/SceneMappingProxyFixture.cs b/src/NzbDrone.Core.Test/DataAugmentation/Scene/SceneMappingProxyFixture.cs deleted file mode 100644 index ce59cf37c..000000000 --- a/src/NzbDrone.Core.Test/DataAugmentation/Scene/SceneMappingProxyFixture.cs +++ /dev/null @@ -1,33 +0,0 @@ -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.DataAugmentation.Scene; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Test.Common.Categories; - -namespace NzbDrone.Core.Test.DataAugmentation.Scene -{ - [TestFixture] - [IntegrationTest] - public class SceneMappingProxyFixture : CoreTest - { - [SetUp] - public void Setup() - { - UseRealHttp(); - } - - [Test] - public void fetch_should_return_list_of_mappings() - { - var mappings = Subject.Fetch(); - - mappings.Should().NotBeEmpty(); - - mappings.Should().NotContain(c => c.SearchTerm.IsNullOrWhiteSpace()); - mappings.Should().NotContain(c => c.Title.IsNullOrWhiteSpace()); - mappings.Should().Contain(c => c.SeasonNumber > 0); - } - - } -} diff --git a/src/NzbDrone.Core.Test/DataAugmentation/Scene/SceneMappingServiceFixture.cs b/src/NzbDrone.Core.Test/DataAugmentation/Scene/SceneMappingServiceFixture.cs deleted file mode 100644 index b94578c32..000000000 --- a/src/NzbDrone.Core.Test/DataAugmentation/Scene/SceneMappingServiceFixture.cs +++ /dev/null @@ -1,337 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Net; -using FizzWare.NBuilder; -using Moq; -using NUnit.Framework; -using NzbDrone.Core.DataAugmentation.Scene; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Test.Common; -using FluentAssertions; -using NzbDrone.Common.Extensions; - -namespace NzbDrone.Core.Test.DataAugmentation.Scene -{ - [TestFixture] - - public class SceneMappingServiceFixture : CoreTest - { - private List _fakeMappings; - - private Mock _provider1; - private Mock _provider2; - - [SetUp] - public void Setup() - { - _fakeMappings = Builder.CreateListOfSize(5).BuildListOfNew(); - - _fakeMappings[0].SearchTerm = "Words"; - _fakeMappings[1].SearchTerm = "That"; - _fakeMappings[2].SearchTerm = "Can"; - _fakeMappings[3].SearchTerm = "Be"; - _fakeMappings[4].SearchTerm = "Cleaned"; - - _fakeMappings[0].ParseTerm = "Words"; - _fakeMappings[1].ParseTerm = "That"; - _fakeMappings[2].ParseTerm = "Can"; - _fakeMappings[3].ParseTerm = "Be"; - _fakeMappings[4].ParseTerm = "Cleaned"; - - _provider1 = new Mock(); - _provider1.Setup(s => s.GetSceneMappings()).Returns(_fakeMappings); - - _provider2 = new Mock(); - _provider2.Setup(s => s.GetSceneMappings()).Returns(_fakeMappings); - } - - private void GivenProviders(IEnumerable> providers) - { - Mocker.SetConstant>(providers.Select(s => s.Object)); - } - - [Test] - public void should_purge_existing_mapping_and_add_new_ones() - { - GivenProviders(new [] { _provider1 }); - - Mocker.GetMock().Setup(c => c.All()).Returns(_fakeMappings); - - Subject.Execute(new UpdateSceneMappingCommand()); - - AssertMappingUpdated(); - } - - [Test] - public void should_not_delete_if_fetch_fails() - { - GivenProviders(new[] { _provider1 }); - - _provider1.Setup(c => c.GetSceneMappings()).Throws(new WebException()); - - Subject.Execute(new UpdateSceneMappingCommand()); - - AssertNoUpdate(); - - ExceptionVerification.ExpectedErrors(1); - } - - [Test] - public void should_not_delete_if_fetch_returns_empty_list() - { - GivenProviders(new[] { _provider1 }); - - _provider1.Setup(c => c.GetSceneMappings()).Returns(new List()); - - Subject.Execute(new UpdateSceneMappingCommand()); - - AssertNoUpdate(); - - ExceptionVerification.ExpectedWarns(1); - } - - [Test] - public void should_get_mappings_for_all_providers() - { - GivenProviders(new[] { _provider1, _provider2 }); - - Mocker.GetMock().Setup(c => c.All()).Returns(_fakeMappings); - - Subject.Execute(new UpdateSceneMappingCommand()); - - _provider1.Verify(c => c.GetSceneMappings(), Times.Once()); - _provider2.Verify(c => c.GetSceneMappings(), Times.Once()); - } - - [Test] - public void should_refresh_cache_if_cache_is_empty_when_looking_for_tvdb_id() - { - Subject.FindTvdbId("title"); - - Mocker.GetMock() - .Verify(v => v.All(), Times.Once()); - } - - [Test] - public void should_not_refresh_cache_if_cache_is_not_empty_when_looking_for_tvdb_id() - { - GivenProviders(new[] { _provider1 }); - - Mocker.GetMock() - .Setup(s => s.All()) - .Returns(Builder.CreateListOfSize(1).Build()); - - - Subject.Execute(new UpdateSceneMappingCommand()); - - Mocker.GetMock() - .Verify(v => v.All(), Times.Once()); - - Subject.FindTvdbId("title"); - - Mocker.GetMock() - .Verify(v => v.All(), Times.Once()); - } - - [Test] - public void should_not_add_mapping_with_blank_title() - { - GivenProviders(new[] { _provider1 }); - - var fakeMappings = Builder.CreateListOfSize(2) - .TheLast(1) - .With(m => m.Title = null) - .Build() - .ToList(); - - _provider1.Setup(s => s.GetSceneMappings()).Returns(fakeMappings); - - Mocker.GetMock().Setup(c => c.All()).Returns(_fakeMappings); - - Subject.Execute(new UpdateSceneMappingCommand()); - - Mocker.GetMock().Verify(c => c.InsertMany(It.Is>(m => !m.Any(s => s.Title.IsNullOrWhiteSpace()))), Times.Once()); - ExceptionVerification.ExpectedWarns(1); - } - - [Test] - public void should_not_add_mapping_with_blank_search_title() - { - GivenProviders(new[] { _provider1 }); - - var fakeMappings = Builder.CreateListOfSize(2) - .TheLast(1) - .With(m => m.SearchTerm = null) - .Build() - .ToList(); - - _provider1.Setup(s => s.GetSceneMappings()).Returns(fakeMappings); - - Mocker.GetMock().Setup(c => c.All()).Returns(_fakeMappings); - - Subject.Execute(new UpdateSceneMappingCommand()); - - Mocker.GetMock().Verify(c => c.InsertMany(It.Is>(m => !m.Any(s => s. SearchTerm.IsNullOrWhiteSpace()))), Times.Once()); - ExceptionVerification.ExpectedWarns(1); - } - - - [TestCase("Working!!", "Working!!", 1)] - [TestCase("Working`!!", "Working`!!", 2)] - [TestCase("Working!!!", "Working!!!", 3)] - [TestCase("Working!!!!", "Working!!!", 3)] - [TestCase("Working !!", "Working!!", 1)] - public void should_return_single_match(string parseTitle, string title, int expectedSeasonNumber) - { - var mappings = new List - { - new SceneMapping { Title = "Working!!", ParseTerm = "working", SearchTerm = "Working!!", TvdbId = 100, SceneSeasonNumber = 1 }, - new SceneMapping { Title = "Working`!!", ParseTerm = "working", SearchTerm = "Working`!!", TvdbId = 100, SceneSeasonNumber = 2 }, - new SceneMapping { Title = "Working!!!", ParseTerm = "working", SearchTerm = "Working!!!", TvdbId = 100, SceneSeasonNumber = 3 }, - }; - - Mocker.GetMock().Setup(c => c.All()).Returns(mappings); - - var tvdbId = Subject.FindTvdbId(parseTitle); - var seasonNumber = Subject.GetSceneSeasonNumber(parseTitle); - - tvdbId.Should().Be(100); - seasonNumber.Should().Be(expectedSeasonNumber); - } - - [Test] - public void should_return_alternate_title_for_global_season() - { - var mappings = new List - { - new SceneMapping { Title = "Fudanshi Koukou Seikatsu 1", ParseTerm = "fudanshikoukouseikatsu1", SearchTerm = "Fudanshi Koukou Seikatsu 1", TvdbId = 100, SeasonNumber = null, SceneSeasonNumber = null }, - new SceneMapping { Title = "Fudanshi Koukou Seikatsu 2", ParseTerm = "fudanshikoukouseikatsu2", SearchTerm = "Fudanshi Koukou Seikatsu 2", TvdbId = 100, SeasonNumber = -1, SceneSeasonNumber = null }, - new SceneMapping { Title = "Fudanshi Koukou Seikatsu 3", ParseTerm = "fudanshikoukouseikatsu3", SearchTerm = "Fudanshi Koukou Seikatsu 3", TvdbId = 100, SeasonNumber = null, SceneSeasonNumber = -1 }, - new SceneMapping { Title = "Fudanshi Koukou Seikatsu 4", ParseTerm = "fudanshikoukouseikatsu4", SearchTerm = "Fudanshi Koukou Seikatsu 4", TvdbId = 100, SeasonNumber = -1, SceneSeasonNumber = -1 }, - }; - - Mocker.GetMock().Setup(c => c.All()).Returns(mappings); - - var names = Subject.GetSceneNames(100, new List { 10 }, new List { 10 }); - names.Should().HaveCount(4); - } - - [Test] - public void should_return_alternate_title_for_season() - { - var mappings = new List - { - new SceneMapping { Title = "Fudanshi Koukou Seikatsu", ParseTerm = "fudanshikoukouseikatsu", SearchTerm = "Fudanshi Koukou Seikatsu", TvdbId = 100, SeasonNumber = 1, SceneSeasonNumber = null } - }; - - Mocker.GetMock().Setup(c => c.All()).Returns(mappings); - - var names = Subject.GetSceneNames(100, new List { 1 }, new List { 10 }); - names.Should().HaveCount(1); - } - - [Test] - public void should_not_return_alternate_title_for_season() - { - var mappings = new List - { - new SceneMapping { Title = "Fudanshi Koukou Seikatsu", ParseTerm = "fudanshikoukouseikatsu", SearchTerm = "Fudanshi Koukou Seikatsu", TvdbId = 100, SeasonNumber = 1, SceneSeasonNumber = null } - }; - - Mocker.GetMock().Setup(c => c.All()).Returns(mappings); - - var names = Subject.GetSceneNames(100, new List { 2 }, new List { 10 }); - names.Should().BeEmpty(); - } - - [Test] - public void should_return_alternate_title_for_sceneseason() - { - var mappings = new List - { - new SceneMapping { Title = "Fudanshi Koukou Seikatsu", ParseTerm = "fudanshikoukouseikatsu", SearchTerm = "Fudanshi Koukou Seikatsu", TvdbId = 100, SeasonNumber = null, SceneSeasonNumber = 1 } - }; - - Mocker.GetMock().Setup(c => c.All()).Returns(mappings); - - var names = Subject.GetSceneNames(100, new List { 10 }, new List { 1 }); - names.Should().HaveCount(1); - } - - [Test] - public void should_not_return_alternate_title_for_sceneseason() - { - var mappings = new List - { - new SceneMapping { Title = "Fudanshi Koukou Seikatsu", ParseTerm = "fudanshikoukouseikatsu", SearchTerm = "Fudanshi Koukou Seikatsu", TvdbId = 100, SeasonNumber = null, SceneSeasonNumber = 1 } - }; - - Mocker.GetMock().Setup(c => c.All()).Returns(mappings); - - var names = Subject.GetSceneNames(100, new List { 10 }, new List { 2 }); - names.Should().BeEmpty(); - } - - [Test] - public void should_return_alternate_title_for_fairy_tail() - { - var mappings = new List - { - new SceneMapping { Title = "Fairy Tail S2", ParseTerm = "fairytails2", SearchTerm = "Fairy Tail S2", TvdbId = 100, SeasonNumber = null, SceneSeasonNumber = 2 } - }; - - Mocker.GetMock().Setup(c => c.All()).Returns(mappings); - - Subject.GetSceneNames(100, new List { 4 }, new List { 20 }).Should().BeEmpty(); - Subject.GetSceneNames(100, new List { 5 }, new List { 20 }).Should().BeEmpty(); - Subject.GetSceneNames(100, new List { 6 }, new List { 20 }).Should().BeEmpty(); - Subject.GetSceneNames(100, new List { 7 }, new List { 20 }).Should().BeEmpty(); - - Subject.GetSceneNames(100, new List { 20 }, new List { 1 }).Should().BeEmpty(); - Subject.GetSceneNames(100, new List { 20 }, new List { 2 }).Should().NotBeEmpty(); - Subject.GetSceneNames(100, new List { 20 }, new List { 3 }).Should().BeEmpty(); - Subject.GetSceneNames(100, new List { 20 }, new List { 4 }).Should().BeEmpty(); - } - - [Test] - public void should_return_alternate_title_for_fudanshi() - { - var mappings = new List - { - new SceneMapping { Title = "Fudanshi Koukou Seikatsu", ParseTerm = "fudanshikoukouseikatsu", SearchTerm = "Fudanshi Koukou Seikatsu", TvdbId = 100, SeasonNumber = null, SceneSeasonNumber = 1 } - }; - - Mocker.GetMock().Setup(c => c.All()).Returns(mappings); - - Subject.GetSceneNames(100, new List { 1 }, new List { 20 }).Should().BeEmpty(); - Subject.GetSceneNames(100, new List { 2 }, new List { 20 }).Should().BeEmpty(); - Subject.GetSceneNames(100, new List { 3 }, new List { 20 }).Should().BeEmpty(); - Subject.GetSceneNames(100, new List { 4 }, new List { 20 }).Should().BeEmpty(); - - Subject.GetSceneNames(100, new List { 1 }, new List { 1 }).Should().NotBeEmpty(); - Subject.GetSceneNames(100, new List { 2 }, new List { 2 }).Should().BeEmpty(); - Subject.GetSceneNames(100, new List { 3 }, new List { 3 }).Should().BeEmpty(); - Subject.GetSceneNames(100, new List { 4 }, new List { 4 }).Should().BeEmpty(); - } - - private void AssertNoUpdate() - { - _provider1.Verify(c => c.GetSceneMappings(), Times.Once()); - Mocker.GetMock().Verify(c => c.Clear(It.IsAny()), Times.Never()); - Mocker.GetMock().Verify(c => c.InsertMany(_fakeMappings), Times.Never()); - } - - private void AssertMappingUpdated() - { - _provider1.Verify(c => c.GetSceneMappings(), Times.Once()); - Mocker.GetMock().Verify(c => c.Clear(It.IsAny()), Times.Once()); - Mocker.GetMock().Verify(c => c.InsertMany(_fakeMappings), Times.Once()); - - foreach (var sceneMapping in _fakeMappings) - { - Subject.GetSceneNames(sceneMapping.TvdbId, _fakeMappings.Select(m => m.SeasonNumber.Value).Distinct().ToList(), new List()).Should().Contain(sceneMapping.SearchTerm); - Subject.FindTvdbId(sceneMapping.ParseTerm).Should().Be(sceneMapping.TvdbId); - } - } - } -} diff --git a/src/NzbDrone.Core.Test/DataAugmentation/SceneNumbering/XemServiceFixture.cs b/src/NzbDrone.Core.Test/DataAugmentation/SceneNumbering/XemServiceFixture.cs deleted file mode 100644 index 3f263c6dd..000000000 --- a/src/NzbDrone.Core.Test/DataAugmentation/SceneNumbering/XemServiceFixture.cs +++ /dev/null @@ -1,312 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using FizzWare.NBuilder; -using FluentAssertions; -using Moq; -using NUnit.Framework; -using NzbDrone.Core.DataAugmentation.Xem; -using NzbDrone.Core.DataAugmentation.Xem.Model; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; -using NzbDrone.Core.Tv.Events; -using NzbDrone.Test.Common; - -namespace NzbDrone.Core.Test.DataAugmentation.SceneNumbering -{ - [TestFixture] - public class XemServiceFixture : CoreTest - { - private Series _series; - private List _theXemSeriesIds; - private List _theXemTvdbMappings; - private List _episodes; - - [SetUp] - public void SetUp() - { - _series = Builder.CreateNew() - .With(v => v.TvdbId = 10) - .With(v => v.UseSceneNumbering = false) - .BuildNew(); - - _theXemSeriesIds = new List { 120 }; - Mocker.GetMock() - .Setup(v => v.GetXemSeriesIds()) - .Returns(_theXemSeriesIds); - - _theXemTvdbMappings = new List(); - Mocker.GetMock() - .Setup(v => v.GetSceneTvdbMappings(10)) - .Returns(_theXemTvdbMappings); - - _episodes = new List(); - _episodes.Add(new Episode { SeasonNumber = 1, EpisodeNumber = 1 }); - _episodes.Add(new Episode { SeasonNumber = 1, EpisodeNumber = 2 }); - _episodes.Add(new Episode { SeasonNumber = 2, EpisodeNumber = 1 }); - _episodes.Add(new Episode { SeasonNumber = 2, EpisodeNumber = 2 }); - _episodes.Add(new Episode { SeasonNumber = 2, EpisodeNumber = 3 }); - _episodes.Add(new Episode { SeasonNumber = 2, EpisodeNumber = 4 }); - _episodes.Add(new Episode { SeasonNumber = 2, EpisodeNumber = 5 }); - _episodes.Add(new Episode { SeasonNumber = 3, EpisodeNumber = 1 }); - _episodes.Add(new Episode { SeasonNumber = 3, EpisodeNumber = 2 }); - - Mocker.GetMock() - .Setup(v => v.GetEpisodeBySeries(It.IsAny())) - .Returns(_episodes); - } - - private void GivenTvdbMappings() - { - _theXemSeriesIds.Add(10); - - AddTvdbMapping(1, 1, 1, 1, 1, 1); // 1x01 -> 1x01 - AddTvdbMapping(2, 1, 2, 2, 1, 2); // 1x02 -> 1x02 - AddTvdbMapping(3, 2, 1, 3, 2, 1); // 2x01 -> 2x01 - AddTvdbMapping(4, 2, 2, 4, 2, 2); // 2x02 -> 2x02 - AddTvdbMapping(5, 2, 3, 5, 2, 3); // 2x03 -> 2x03 - AddTvdbMapping(6, 3, 1, 6, 2, 4); // 3x01 -> 2x04 - AddTvdbMapping(7, 3, 2, 7, 2, 5); // 3x02 -> 2x05 - } - - private void GivenExistingMapping() - { - _series.UseSceneNumbering = true; - - _episodes[0].SceneSeasonNumber = 1; - _episodes[0].SceneEpisodeNumber = 1; - _episodes[1].SceneSeasonNumber = 1; - _episodes[1].SceneEpisodeNumber = 2; - _episodes[2].SceneSeasonNumber = 2; - _episodes[2].SceneEpisodeNumber = 1; - _episodes[3].SceneSeasonNumber = 2; - _episodes[3].SceneEpisodeNumber = 2; - _episodes[4].SceneSeasonNumber = 2; - _episodes[4].SceneEpisodeNumber = 3; - _episodes[5].SceneSeasonNumber = 3; - _episodes[5].SceneEpisodeNumber = 1; - _episodes[6].SceneSeasonNumber = 3; - _episodes[6].SceneEpisodeNumber = 1; - } - - private void AddTvdbMapping(int sceneAbsolute, int sceneSeason, int sceneEpisode, int tvdbAbsolute, int tvdbSeason, int tvdbEpisode) - { - _theXemTvdbMappings.Add(new XemSceneTvdbMapping - { - Scene = new XemValues { Absolute = sceneAbsolute, Season = sceneSeason, Episode = sceneEpisode }, - Tvdb = new XemValues { Absolute = tvdbAbsolute, Season = tvdbSeason, Episode = tvdbEpisode }, - }); - } - - - [Test] - public void should_not_fetch_scenenumbering_if_not_listed() - { - Subject.Handle(new SeriesUpdatedEvent(_series)); - - Mocker.GetMock() - .Verify(v => v.GetSceneTvdbMappings(10), Times.Never()); - - Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.IsAny()), Times.Never()); - } - - [Test] - public void should_fetch_scenenumbering() - { - GivenTvdbMappings(); - - Subject.Handle(new SeriesUpdatedEvent(_series)); - - Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.Is(s => s.UseSceneNumbering == true)), Times.Once()); - } - - [Test] - public void should_clear_scenenumbering_if_removed_from_thexem() - { - GivenExistingMapping(); - - Subject.Handle(new SeriesUpdatedEvent(_series)); - - Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.IsAny()), Times.Once()); - } - - [Test] - public void should_not_clear_scenenumbering_if_no_results_at_all_from_thexem() - { - GivenExistingMapping(); - - _theXemSeriesIds.Clear(); - - Subject.Handle(new SeriesUpdatedEvent(_series)); - - Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.IsAny()), Times.Never()); - - ExceptionVerification.ExpectedWarns(1); - } - - [Test] - public void should_not_clear_scenenumbering_if_thexem_throws() - { - GivenExistingMapping(); - - Mocker.GetMock() - .Setup(v => v.GetXemSeriesIds()) - .Throws(new InvalidOperationException()); - - Subject.Handle(new SeriesUpdatedEvent(_series)); - - Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.IsAny()), Times.Never()); - - ExceptionVerification.ExpectedWarns(1); - } - - [Test] - public void should_flag_unknown_future_episodes_if_existing_season_is_mapped() - { - GivenTvdbMappings(); - _theXemTvdbMappings.RemoveAll(v => v.Tvdb.Season == 2 && v.Tvdb.Episode == 5); - - Subject.Handle(new SeriesUpdatedEvent(_series)); - - var episode = _episodes.First(v => v.SeasonNumber == 2 && v.EpisodeNumber == 5); - - episode.UnverifiedSceneNumbering.Should().BeTrue(); - } - - [Test] - public void should_flag_unknown_future_season_if_future_season_is_shifted() - { - GivenTvdbMappings(); - - Subject.Handle(new SeriesUpdatedEvent(_series)); - - var episode = _episodes.First(v => v.SeasonNumber == 3 && v.EpisodeNumber == 1); - - episode.UnverifiedSceneNumbering.Should().BeTrue(); - } - - [Test] - public void should_not_flag_unknown_future_season_if_future_season_is_not_shifted() - { - GivenTvdbMappings(); - _theXemTvdbMappings.RemoveAll(v => v.Scene.Season == 3); - - Subject.Handle(new SeriesUpdatedEvent(_series)); - - var episode = _episodes.First(v => v.SeasonNumber == 3 && v.EpisodeNumber == 1); - - episode.UnverifiedSceneNumbering.Should().BeFalse(); - } - - [Test] - public void should_not_flag_past_episodes_if_not_causing_overlaps() - { - GivenTvdbMappings(); - _theXemTvdbMappings.RemoveAll(v => v.Scene.Season == 2); - - Subject.Handle(new SeriesUpdatedEvent(_series)); - - var episode = _episodes.First(v => v.SeasonNumber == 2 && v.EpisodeNumber == 1); - - episode.UnverifiedSceneNumbering.Should().BeFalse(); - } - - [Test] - public void should_flag_past_episodes_if_causing_overlap() - { - GivenTvdbMappings(); - _theXemTvdbMappings.RemoveAll(v => v.Scene.Season == 2 && v.Tvdb.Episode <= 1); - _theXemTvdbMappings.First(v => v.Scene.Season == 2 && v.Scene.Episode == 2).Scene.Episode = 1; - - Subject.Handle(new SeriesUpdatedEvent(_series)); - - var episode = _episodes.First(v => v.SeasonNumber == 2 && v.EpisodeNumber == 1); - - episode.UnverifiedSceneNumbering.Should().BeTrue(); - } - - [Test] - public void should_not_extrapolate_season_with_specials() - { - GivenTvdbMappings(); - var specialMapping = _theXemTvdbMappings.First(v => v.Tvdb.Season == 2 && v.Tvdb.Episode == 5); - specialMapping.Tvdb.Season = 0; - specialMapping.Tvdb.Episode = 1; - - Subject.Handle(new SeriesUpdatedEvent(_series)); - - var episode = _episodes.First(v => v.SeasonNumber == 2 && v.EpisodeNumber == 5); - - episode.UnverifiedSceneNumbering.Should().BeTrue(); - episode.SceneSeasonNumber.Should().NotHaveValue(); - episode.SceneEpisodeNumber.Should().NotHaveValue(); - } - - [Test] - public void should_extrapolate_season_with_future_episodes() - { - GivenTvdbMappings(); - _theXemTvdbMappings.RemoveAll(v => v.Tvdb.Season == 2 && v.Tvdb.Episode == 5); - - Subject.Handle(new SeriesUpdatedEvent(_series)); - - var episode = _episodes.First(v => v.SeasonNumber == 2 && v.EpisodeNumber == 5); - - episode.UnverifiedSceneNumbering.Should().BeTrue(); - episode.SceneSeasonNumber.Should().Be(3); - episode.SceneEpisodeNumber.Should().Be(2); - } - - [Test] - public void should_extrapolate_season_with_shifted_episodes() - { - GivenTvdbMappings(); - _theXemTvdbMappings.RemoveAll(v => v.Tvdb.Season == 2 && v.Tvdb.Episode == 5); - var dualMapping = _theXemTvdbMappings.First(v => v.Tvdb.Season == 2 && v.Tvdb.Episode == 4); - dualMapping.Scene.Season = 2; - dualMapping.Scene.Episode = 3; - - Subject.Handle(new SeriesUpdatedEvent(_series)); - - var episode = _episodes.First(v => v.SeasonNumber == 2 && v.EpisodeNumber == 5); - - episode.UnverifiedSceneNumbering.Should().BeTrue(); - episode.SceneSeasonNumber.Should().Be(2); - episode.SceneEpisodeNumber.Should().Be(4); - } - - [Test] - public void should_extrapolate_shifted_future_seasons() - { - GivenTvdbMappings(); - - Subject.Handle(new SeriesUpdatedEvent(_series)); - - var episode = _episodes.First(v => v.SeasonNumber == 3 && v.EpisodeNumber == 2); - - episode.UnverifiedSceneNumbering.Should().BeTrue(); - episode.SceneSeasonNumber.Should().Be(4); - episode.SceneEpisodeNumber.Should().Be(2); - } - - [Test] - public void should_not_extrapolate_matching_future_seasons() - { - GivenTvdbMappings(); - _theXemTvdbMappings.RemoveAll(v => v.Scene.Season != 1); - - Subject.Handle(new SeriesUpdatedEvent(_series)); - - var episode = _episodes.First(v => v.SeasonNumber == 3 && v.EpisodeNumber == 2); - - episode.UnverifiedSceneNumbering.Should().BeFalse(); - episode.SceneSeasonNumber.Should().NotHaveValue(); - episode.SceneEpisodeNumber.Should().NotHaveValue(); - } - } -} 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..56d42e54e --- /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.Music.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 RefreshArtistCommand(); + + 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("RefreshArtist"); + + var context = new ConverterContext + { + DataRecord = dataRecordMock.Object, + DbValue = new RefreshArtistCommand().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..49bd70074 --- /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.Music; + +namespace NzbDrone.Core.Test.Datastore.Converters +{ + [TestFixture] + public class EnumIntConverterFixture : CoreTest + { + [Test] + public void should_return_int_when_saving_enum_to_db() + { + Subject.ToDB(ArtistStatusType.Continuing).Should().Be((int)ArtistStatusType.Continuing); + } + + [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(ArtistMetadata)); + mockMemberInfo.SetupGet(s => s.Name).Returns("Status"); + + var expected = ArtistStatusType.Continuing; + + var context = new ConverterContext + { + ColumnMap = new ColumnMap(mockMemberInfo.Object) { FieldType = typeof(ArtistStatusType) }, + 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..058436b38 --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Converters/Int32ConverterFixture.cs @@ -0,0 +1,56 @@ +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() + { + 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..8dae96853 --- /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\Music".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\Music".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..7809dd10a --- /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.FLAC; + + 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.FLAC; + + 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/DatabaseFixture.cs b/src/NzbDrone.Core.Test/Datastore/DatabaseFixture.cs index e1942d6c3..be8b6c7ec 100644 --- a/src/NzbDrone.Core.Test/Datastore/DatabaseFixture.cs +++ b/src/NzbDrone.Core.Test/Datastore/DatabaseFixture.cs @@ -4,7 +4,7 @@ using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.Datastore; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Test.Datastore { @@ -14,8 +14,8 @@ namespace NzbDrone.Core.Test.Datastore public void SingleOrDefault_should_return_null_on_empty_db() { Mocker.Resolve() - .GetDataMapper().Query() - .SingleOrDefault(c => c.CleanTitle == "SomeTitle") + .GetDataMapper().Query() + .SingleOrDefault(c => c.CleanName == "SomeTitle") .Should() .BeNull(); } @@ -33,4 +33,4 @@ namespace NzbDrone.Core.Test.Datastore Mocker.Resolve().Version.Should().BeGreaterThan(new Version("3.0.0")); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core.Test/Datastore/DatabaseRelationshipFixture.cs b/src/NzbDrone.Core.Test/Datastore/DatabaseRelationshipFixture.cs index 761ad59cb..58bc8cc89 100644 --- a/src/NzbDrone.Core.Test/Datastore/DatabaseRelationshipFixture.cs +++ b/src/NzbDrone.Core.Test/Datastore/DatabaseRelationshipFixture.cs @@ -1,11 +1,12 @@ -using System.Linq; +using System.Linq; using FizzWare.NBuilder; using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Qualities; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; +using System; namespace NzbDrone.Core.Test.Datastore { @@ -15,47 +16,49 @@ namespace NzbDrone.Core.Test.Datastore [Test] public void one_to_one() { - var episodeFile = Builder.CreateNew() - .With(c => c.Quality = new QualityModel()) - .BuildNew(); - - Db.Insert(episodeFile); - - var episode = Builder.CreateNew() - .With(c => c.EpisodeFileId = episodeFile.Id) + var album = Builder.CreateNew() + .With(c => c.Id = 0) + .With(x => x.ReleaseDate = DateTime.UtcNow) + .With(x => x.LastInfoSync = DateTime.UtcNow) + .With(x => x.Added = DateTime.UtcNow) .BuildNew(); + Db.Insert(album); - Db.Insert(episode); - - var loadedEpisodeFile = Db.Single().EpisodeFile.Value; - - loadedEpisodeFile.Should().NotBeNull(); - loadedEpisodeFile.ShouldBeEquivalentTo(episodeFile, - options => options - .IncludingAllRuntimeProperties() - .Excluding(c => c.DateAdded) - .Excluding(c => c.Path) - .Excluding(c => c.Series) - .Excluding(c => c.Episodes)); + var albumRelease = Builder.CreateNew() + .With(c => c.Id = 0) + .With(c => c.AlbumId = album.Id) + .BuildNew(); + Db.Insert(albumRelease); + + var loadedAlbum = Db.Single().Album.Value; + + loadedAlbum.Should().NotBeNull(); + loadedAlbum.ShouldBeEquivalentTo(album, + options => options + .IncludingAllRuntimeProperties() + .Excluding(c => c.Artist) + .Excluding(c => c.ArtistId) + .Excluding(c => c.ArtistMetadata) + .Excluding(c => c.AlbumReleases)); } [Test] public void one_to_one_should_not_query_db_if_foreign_key_is_zero() { - var episode = Builder.CreateNew() - .With(c => c.EpisodeFileId = 0) + var track = Builder.CreateNew() + .With(c => c.TrackFileId = 0) .BuildNew(); - Db.Insert(episode); + Db.Insert(track); - Db.Single().EpisodeFile.Value.Should().BeNull(); + Db.Single().TrackFile.Value.Should().BeNull(); } [Test] public void embedded_document_as_json() { - var quality = new QualityModel { Quality = Quality.Bluray720p, Revision = new Revision(version: 2 )}; + var quality = new QualityModel { Quality = Quality.MP3_320, Revision = new Revision(version: 2 )}; var history = Builder.CreateNew() .With(c => c.Id = 0) @@ -75,15 +78,15 @@ namespace NzbDrone.Core.Test.Datastore .All().With(c => c.Id = 0) .Build().ToList(); - history[0].Quality = new QualityModel(Quality.HDTV1080p, new Revision(version: 2)); - history[1].Quality = new QualityModel(Quality.Bluray720p, new Revision(version: 2)); + history[0].Quality = new QualityModel(Quality.MP3_320, new Revision(version: 2)); + history[1].Quality = new QualityModel(Quality.MP3_256, new Revision(version: 2)); Db.InsertMany(history); var returnedHistory = Db.All(); - returnedHistory[0].Quality.Quality.Should().Be(Quality.HDTV1080p); + returnedHistory[0].Quality.Quality.Should().Be(Quality.MP3_320); } } } diff --git a/src/NzbDrone.Core.Test/Datastore/MappingExtentionFixture.cs b/src/NzbDrone.Core.Test/Datastore/MappingExtentionFixture.cs index 76558e6f1..7db539fc3 100644 --- a/src/NzbDrone.Core.Test/Datastore/MappingExtentionFixture.cs +++ b/src/NzbDrone.Core.Test/Datastore/MappingExtentionFixture.cs @@ -1,11 +1,11 @@ -using System.Collections.Generic; +using System.Collections.Generic; using FluentAssertions; using Marr.Data; using NUnit.Framework; using NzbDrone.Core.Datastore; using NzbDrone.Core.Datastore.Converters; using NzbDrone.Core.Datastore.Extensions; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Test.Datastore { @@ -30,7 +30,7 @@ namespace NzbDrone.Core.Test.Datastore public class TypeWithNoMappableProperties { - public Series Series { get; set; } + public Artist Artist { get; set; } public int ReadOnly { get; private set; } public int WriteOnly { private get; set; } @@ -62,4 +62,4 @@ namespace NzbDrone.Core.Test.Datastore properties.Should().NotContain(c => MappingExtensions.IsMappableProperty(c)); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core.Test/Datastore/MarrDataLazyLoadingFixture.cs b/src/NzbDrone.Core.Test/Datastore/MarrDataLazyLoadingFixture.cs index 49d67f063..9a5fdffe6 100644 --- a/src/NzbDrone.Core.Test/Datastore/MarrDataLazyLoadingFixture.cs +++ b/src/NzbDrone.Core.Test/Datastore/MarrDataLazyLoadingFixture.cs @@ -1,11 +1,14 @@ -using FizzWare.NBuilder; +using FizzWare.NBuilder; using NUnit.Framework; using NzbDrone.Core.Datastore; -using NzbDrone.Core.Profiles; +using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; using NzbDrone.Core.Qualities; using NzbDrone.Core.MediaFiles; +using Marr.Data.QGen; +using System.Collections.Generic; +using System.Linq; namespace NzbDrone.Core.Test.Datastore { @@ -16,39 +19,82 @@ namespace NzbDrone.Core.Test.Datastore [SetUp] public void Setup() { - var profile = new Profile - { - Name = "Test", - Cutoff = Quality.WEBDL720p, - Items = Qualities.QualityFixture.GetDefaultQualities() - }; - - + var profile = new QualityProfile + { + Name = "Test", + Cutoff = Quality.MP3_320.Id, + Items = Qualities.QualityFixture.GetDefaultQualities() + }; + profile = Db.Insert(profile); - var series = Builder.CreateListOfSize(1) + var metadata = Builder.CreateNew() + .With(v => v.Id = 0) + .Build(); + Db.Insert(metadata); + + var artist = Builder.CreateListOfSize(1) + .All() + .With(v => v.Id = 0) + .With(v => v.QualityProfileId = profile.Id) + .With(v => v.ArtistMetadataId = metadata.Id) + .BuildListOfNew(); + + Db.InsertMany(artist); + + var albums = Builder.CreateListOfSize(3) .All() - .With(v => v.ProfileId = profile.Id) + .With(v => v.Id = 0) + .With(v => v.ArtistMetadataId = metadata.Id) .BuildListOfNew(); - Db.InsertMany(series); + Db.InsertMany(albums); + + var releases = new List(); + foreach (var album in albums) + { + releases.Add( + Builder.CreateNew() + .With(v => v.Id = 0) + .With(v => v.AlbumId = album.Id) + .With(v => v.ForeignReleaseId = "test" + album.Id) + .Build()); + } + Db.InsertMany(releases); - var episodeFiles = Builder.CreateListOfSize(1) + var trackFiles = Builder.CreateListOfSize(1) .All() - .With(v => v.SeriesId = series[0].Id) + .With(v => v.Id = 0) + .With(v => v.AlbumId = albums[0].Id) .With(v => v.Quality = new QualityModel()) .BuildListOfNew(); - Db.InsertMany(episodeFiles); + Db.InsertMany(trackFiles); - var episodes = Builder.CreateListOfSize(10) + var tracks = Builder.CreateListOfSize(10) .All() - .With(v => v.Monitored = true) - .With(v => v.EpisodeFileId = episodeFiles[0].Id) - .With(v => v.SeriesId = series[0].Id) + .With(v => v.Id = 0) + .With(v => v.TrackFileId = trackFiles[0].Id) + .With(v => v.AlbumReleaseId = releases[0].Id) .BuildListOfNew(); - Db.InsertMany(episodes); + Db.InsertMany(tracks); + } + + [Test] + public void should_join_artist_when_query_for_albums() + { + var db = Mocker.Resolve(); + var DataMapper = db.GetDataMapper(); + + var albums = DataMapper.Query() + .Join(JoinType.Inner, v => v.Artist, (l, r) => l.ArtistMetadataId == r.ArtistMetadataId) + .ToList(); + + foreach (var album in albums) + { + Assert.IsNotNull(album.Artist); + } } [Test] @@ -57,51 +103,195 @@ namespace NzbDrone.Core.Test.Datastore var db = Mocker.Resolve(); var DataMapper = db.GetDataMapper(); - var episodes = DataMapper.Query() - .Join(Marr.Data.QGen.JoinType.Inner, v => v.Series, (l, r) => l.SeriesId == r.Id) + var tracks = DataMapper.Query() + .Join(JoinType.Inner, v => v.AlbumRelease, (l, r) => l.AlbumReleaseId == r.Id) + .Join(JoinType.Inner, v => v.Album, (l, r) => l.AlbumId == r.Id) + .Join(JoinType.Inner, v => v.Artist, (l, r) => l.ArtistMetadataId == r.ArtistMetadataId) .ToList(); - foreach (var episode in episodes) + foreach (var track in tracks) { - Assert.IsNotNull(episode.Series); - Assert.IsFalse(episode.Series.Profile.IsLoaded); + Assert.IsTrue(track.AlbumRelease.IsLoaded); + Assert.IsTrue(track.AlbumRelease.Value.Album.IsLoaded); + Assert.IsTrue(track.AlbumRelease.Value.Album.Value.Artist.IsLoaded); + Assert.IsNotNull(track.AlbumRelease.Value.Album.Value.Artist.Value); + Assert.IsFalse(track.AlbumRelease.Value.Album.Value.Artist.Value.QualityProfile.IsLoaded); } } [Test] - public void should_explicit_load_episodefile_if_joined() + public void should_explicit_load_trackfile_if_joined() { var db = Mocker.Resolve(); var DataMapper = db.GetDataMapper(); - var episodes = DataMapper.Query() - .Join(Marr.Data.QGen.JoinType.Inner, v => v.EpisodeFile, (l, r) => l.EpisodeFileId == r.Id) + var tracks = DataMapper.Query() + .Join(JoinType.Inner, v => v.TrackFile, (l, r) => l.TrackFileId == r.Id) .ToList(); - foreach (var episode in episodes) + foreach (var track in tracks) { - Assert.IsNull(episode.Series); - Assert.IsTrue(episode.EpisodeFile.IsLoaded); + Assert.IsFalse(track.Artist.IsLoaded); + Assert.IsTrue(track.TrackFile.IsLoaded); } } [Test] - public void should_explicit_load_profile_if_joined() + public void should_lazy_load_artist_for_track() + { + var db = Mocker.Resolve(); + var DataMapper = db.GetDataMapper(); + + var tracks = DataMapper.Query() + .ToList(); + + Assert.IsNotEmpty(tracks); + foreach (var track in tracks) + { + Assert.IsFalse(track.Artist.IsLoaded); + Assert.IsNotNull(track.Artist.Value); + Assert.IsTrue(track.Artist.IsLoaded); + Assert.IsTrue(track.Artist.Value.Metadata.IsLoaded); + } + } + + [Test] + public void should_lazy_load_artist_for_trackfile() + { + var db = Mocker.Resolve(); + var DataMapper = db.GetDataMapper(); + + var tracks = DataMapper.Query() + .ToList(); + + Assert.IsNotEmpty(tracks); + foreach (var track in tracks) + { + Assert.IsFalse(track.Artist.IsLoaded); + Assert.IsNotNull(track.Artist.Value); + Assert.IsTrue(track.Artist.IsLoaded); + Assert.IsTrue(track.Artist.Value.Metadata.IsLoaded); + } + } + + [Test] + public void should_lazy_load_trackfile_if_not_joined() + { + var db = Mocker.Resolve(); + var DataMapper = db.GetDataMapper(); + + var tracks = DataMapper.Query() + .ToList(); + + foreach (var track in tracks) + { + Assert.IsFalse(track.TrackFile.IsLoaded); + Assert.IsNotNull(track.TrackFile.Value); + Assert.IsTrue(track.TrackFile.IsLoaded); + } + } + + [Test] + public void should_explicit_load_everything_if_joined() + { + var db = Mocker.Resolve(); + var DataMapper = db.GetDataMapper(); + + var files = DataMapper.Query() + .Join(JoinType.Inner, f => f.Tracks, (f, t) => f.Id == t.TrackFileId) + .Join(JoinType.Inner, t => t.Album, (t, a) => t.AlbumId == a.Id) + .Join(JoinType.Inner, t => t.Artist, (t, a) => t.Album.Value.ArtistMetadataId == a.ArtistMetadataId) + .Join(JoinType.Inner, a => a.Metadata, (a, m) => a.ArtistMetadataId == m.Id) + .ToList(); + + Assert.IsNotEmpty(files); + foreach (var file in files) + { + Assert.IsTrue(file.Tracks.IsLoaded); + Assert.IsNotEmpty(file.Tracks.Value); + Assert.IsTrue(file.Album.IsLoaded); + Assert.IsTrue(file.Artist.IsLoaded); + Assert.IsTrue(file.Artist.Value.Metadata.IsLoaded); + } + } + + [Test] + public void should_lazy_load_tracks_if_not_joined_to_trackfile() + { + var db = Mocker.Resolve(); + var DataMapper = db.GetDataMapper(); + + var files = DataMapper.Query() + .Join(JoinType.Inner, t => t.Album, (t, a) => t.AlbumId == a.Id) + .Join(JoinType.Inner, t => t.Artist, (t, a) => t.Album.Value.ArtistMetadataId == a.ArtistMetadataId) + .Join(JoinType.Inner, a => a.Metadata, (a, m) => a.ArtistMetadataId == m.Id) + .ToList(); + + Assert.IsNotEmpty(files); + foreach (var file in files) + { + Assert.IsFalse(file.Tracks.IsLoaded); + Assert.IsNotNull(file.Tracks.Value); + Assert.IsNotEmpty(file.Tracks.Value); + Assert.IsTrue(file.Tracks.IsLoaded); + Assert.IsTrue(file.Album.IsLoaded); + Assert.IsTrue(file.Artist.IsLoaded); + Assert.IsTrue(file.Artist.Value.Metadata.IsLoaded); + } + } + + [Test] + public void should_lazy_load_tracks_if_not_joined() + { + var db = Mocker.Resolve(); + var DataMapper = db.GetDataMapper(); + + var release = DataMapper.Query().Where(x => x.Id == 1).SingleOrDefault(); + + Assert.IsFalse(release.Tracks.IsLoaded); + Assert.IsNotNull(release.Tracks.Value); + Assert.IsNotEmpty(release.Tracks.Value); + Assert.IsTrue(release.Tracks.IsLoaded); + } + + [Test] + public void should_lazy_load_track_if_not_joined() { var db = Mocker.Resolve(); var DataMapper = db.GetDataMapper(); - var episodes = DataMapper.Query() - .Join(Marr.Data.QGen.JoinType.Inner, v => v.Series, (l, r) => l.SeriesId == r.Id) - .Join(Marr.Data.QGen.JoinType.Inner, v => v.Profile, (l, r) => l.ProfileId == r.Id) + var tracks = DataMapper.Query() .ToList(); - foreach (var episode in episodes) + foreach (var track in tracks) { - Assert.IsNotNull(episode.Series); - Assert.IsTrue(episode.Series.Profile.IsLoaded); + Assert.IsFalse(track.Tracks.IsLoaded); + Assert.IsNotNull(track.Tracks.Value); + Assert.IsTrue(track.Tracks.IsLoaded); } } + [Test] + public void should_explicit_load_profile_if_joined() + { + var db = Mocker.Resolve(); + var DataMapper = db.GetDataMapper(); + + var tracks = DataMapper.Query() + .Join(JoinType.Inner, v => v.AlbumRelease, (l, r) => l.AlbumReleaseId == r.Id) + .Join(JoinType.Inner, v => v.Album, (l, r) => l.AlbumId == r.Id) + .Join(JoinType.Inner, v => v.Artist, (l, r) => l.ArtistMetadataId == r.ArtistMetadataId) + .Join(JoinType.Inner, v => v.QualityProfile, (l, r) => l.QualityProfileId == r.Id) + .ToList(); + + foreach (var track in tracks) + { + Assert.IsTrue(track.AlbumRelease.IsLoaded); + Assert.IsTrue(track.AlbumRelease.Value.Album.IsLoaded); + Assert.IsTrue(track.AlbumRelease.Value.Album.Value.Artist.IsLoaded); + Assert.IsNotNull(track.AlbumRelease.Value.Album.Value.Artist.Value); + Assert.IsTrue(track.AlbumRelease.Value.Album.Value.Artist.Value.QualityProfile.IsLoaded); + } + } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/004_add_various_qualities_in_profileFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/004_add_various_qualities_in_profileFixture.cs new file mode 100644 index 000000000..cba53eb71 --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Migration/004_add_various_qualities_in_profileFixture.cs @@ -0,0 +1,64 @@ +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Datastore.Migration; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Datastore.Migration +{ + [TestFixture] + public class add_various_qualites_in_profileFixture : MigrationTest + { + private string GenerateQualityJson(int quality, bool allowed) + { + return $"{{ \"quality\": {quality}, \"allowed\": {allowed.ToString().ToLowerInvariant()} }}"; + } + + [Test] + public void should_add_wav_quality() + { + var db = WithMigrationTestDb(c => + { + c.Insert.IntoTable("Profiles").Row(new + { + Id = 0, + Name = "Lossless", + Cutoff = 1, + Items = $"[{GenerateQualityJson(1, true)}, {GenerateQualityJson((int)Quality.MP3_320, false)}, {GenerateQualityJson((int)Quality.FLAC, true)}]" + }); + }); + + var profiles = db.Query("SELECT Items FROM Profiles LIMIT 1"); + + var items = profiles.First().Items; + items.Should().HaveCount(7); + items.Select(v => v.Quality).Should().Contain(13); + items.Select(v => v.Items.Count).Should().BeEquivalentTo(9, 5, 6, 3, 0, 5, 5); + items.Select(v => v.Allowed).Should().BeEquivalentTo(false, true, false, true, false, false, false); + } + + [Test] + public void should_add_trash_lossy_quality_group_and_qualities() + { + var db = WithMigrationTestDb(c => + { + c.Insert.IntoTable("Profiles").Row(new + { + Id = 0, + Name = "Lossless", + Cutoff = 1, + Items = $"[{GenerateQualityJson(1, true)}, {GenerateQualityJson((int)Quality.MP3_320, false)}, {GenerateQualityJson((int)Quality.FLAC, true)}]" + }); + }); + + var profiles = db.Query("SELECT Items FROM Profiles LIMIT 1"); + + var items = profiles.First().Items; + items.Should().HaveCount(7); + items.Select(v => v.Name).Should().Contain("Trash Quality Lossy"); + items.Select(v => v.Items.Count).Should().BeEquivalentTo(9, 5, 6, 3, 0, 5, 5); + items.Select(v => v.Allowed).Should().BeEquivalentTo(false, true, false, true, false, false, false); + } + } +} diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/023_add_release_groups_etcFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/023_add_release_groups_etcFixture.cs new file mode 100644 index 000000000..4eb43de03 --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Migration/023_add_release_groups_etcFixture.cs @@ -0,0 +1,240 @@ +using System.Linq; +using FluentAssertions; +using FizzWare.NBuilder; +using NUnit.Framework; +using NzbDrone.Core.Datastore.Migration; +using NzbDrone.Core.Music; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Common.Serializer; +using System.Collections.Generic; + +namespace NzbDrone.Core.Test.Datastore.Migration +{ + [TestFixture] + public class add_release_groups_etcFixture : MigrationTest + { + private void GivenArtist(add_release_groups_etc c, int id, string name) + { + c.Insert.IntoTable("Artists").Row(new + { + Id = id, + ForeignArtistId = id.ToString(), + Name = name, + CleanName = name, + Status = 1, + Images = "", + Path = $"/mnt/data/path/{name}", + Monitored = 1, + AlbumFolder = 1, + LanguageProfileId = 1, + MetadataProfileId = 1 + }); + } + + private void GivenAlbum(add_release_groups_etc c, int id, int artistId, string title, string currentRelease) + { + c.Insert.IntoTable("Albums").Row(new + { + Id = id, + ForeignAlbumId = id.ToString(), + ArtistId = artistId, + Title = title, + CleanTitle = title, + Images = "", + Monitored = 1, + AlbumType = "Studio", + Duration = 100, + Media = "", + Releases = "", + CurrentRelease = currentRelease + }); + } + + private void GivenTracks(add_release_groups_etc c, int artistid, int albumid, int firstId, int count) + { + for (int i = 0; i < count; i++) + { + var id = firstId + i; + c.Insert.IntoTable("Tracks").Row(new + { + Id = id, + ForeignTrackId = id.ToString(), + ArtistId = artistid, + AlbumId = albumid, + Explicit = 0, + Compilation = 0, + Monitored = 0, + Duration = 100, + MediumNumber = 1, + AbsoluteTrackNumber = i, + TrackNumber = i.ToString() + }); + } + } + + private IEnumerable VerifyAlbumReleases(IDirectDataMapper db) + { + var releases = db.Query("SELECT * FROM AlbumReleases"); + var albums = db.Query("SELECT * FROM Albums"); + + // we only put in one release per album + releases.Count().Should().Be(albums.Count()); + + // each album should be linked to exactly one release + releases.Select(x => x.AlbumId).SequenceEqual(albums.Select(x => x.Id)).Should().Be(true); + + // each release should have at least one medium + releases.Select(x => x.Media.Count).Min().Should().BeGreaterOrEqualTo(1); + + return releases; + } + + private void VerifyTracks(IDirectDataMapper db, int albumId, int albumReleaseId, int expectedCount) + { + var tracks = db.Query("SELECT Tracks.* FROM Tracks " + + "JOIN AlbumReleases ON Tracks.AlbumReleaseId = AlbumReleases.Id " + + "JOIN Albums ON AlbumReleases.AlbumId = Albums.Id " + + "WHERE Albums.Id = " + albumId).ToList(); + + var album = db.Query("SELECT * FROM Albums WHERE Albums.Id = " + albumId).ToList().Single(); + + tracks.Count.Should().Be(expectedCount); + tracks.First().AlbumReleaseId.Should().Be(albumReleaseId); + tracks.All(t => t.ArtistMetadataId == album.ArtistMetadataId).Should().BeTrue(); + } + + [Test] + public void migration_023_simple_case() + { + var release = Builder + .CreateNew() + .Build(); + + var db = WithMigrationTestDb(c => + { + GivenArtist(c, 1, "TestArtist"); + GivenAlbum(c, 1, 1, "TestAlbum", release.ToJson()); + GivenTracks(c, 1, 1, 1, 10); + }); + + VerifyAlbumReleases(db); + VerifyTracks(db, 1, 1, 10); + } + + [Test] + public void migration_023_multiple_media() + { + var release = Builder + .CreateNew() + .With(e => e.MediaCount = 2) + .Build(); + + var db = WithMigrationTestDb(c => + { + GivenArtist(c, 1, "TestArtist"); + GivenAlbum(c, 1, 1, "TestAlbum", release.ToJson()); + GivenTracks(c, 1, 1, 1, 10); + }); + + var migrated = VerifyAlbumReleases(db); + migrated.First().Media.Count.Should().Be(2); + + VerifyTracks(db, 1, 1, 10); + } + + [Test] + public void migration_023_null_title() + { + var release = Builder + .CreateNew() + .With(e => e.Title = null) + .Build(); + + var db = WithMigrationTestDb(c => + { + GivenArtist(c, 1, "TestArtist"); + GivenAlbum(c, 1, 1, "TestAlbum", release.ToJson()); + GivenTracks(c, 1, 1, 1, 10); + }); + + VerifyAlbumReleases(db); + VerifyTracks(db, 1, 1, 10); + } + + [Test] + public void migration_023_all_default_entries() + { + var release = new add_release_groups_etc.LegacyAlbumRelease(); + + var db = WithMigrationTestDb(c => + { + GivenArtist(c, 1, "TestArtist"); + GivenAlbum(c, 1, 1, "TestAlbum", release.ToJson()); + GivenTracks(c, 1, 1, 1, 10); + }); + + VerifyAlbumReleases(db); + VerifyTracks(db, 1, 1, 10); + } + + [Test] + public void migration_023_empty_albumrelease() + { + var db = WithMigrationTestDb(c => + { + GivenArtist(c, 1, "TestArtist"); + GivenAlbum(c, 1, 1, "TestAlbum", ""); + GivenTracks(c, 1, 1, 1, 10); + }); + + VerifyAlbumReleases(db); + VerifyTracks(db, 1, 1, 10); + } + + [Test] + public void migration_023_duplicate_albumrelease() + { + var release = Builder + .CreateNew() + .Build(); + + var db = WithMigrationTestDb(c => + { + GivenArtist(c, 1, "TestArtist"); + GivenAlbum(c, 1, 1, "TestAlbum1", release.ToJson()); + GivenTracks(c, 1, 1, 1, 10); + GivenAlbum(c, 2, 1, "TestAlbum2", release.ToJson()); + GivenTracks(c, 1, 2, 100, 10); + + }); + + VerifyAlbumReleases(db); + VerifyTracks(db, 1, 1, 10); + VerifyTracks(db, 2, 2, 10); + } + + [Test] + public void migration_023_duplicate_foreignreleaseid() + { + var releases = Builder + .CreateListOfSize(2) + .All() + .With(e => e.Id = "TestForeignId") + .Build(); + + var db = WithMigrationTestDb(c => + { + GivenArtist(c, 1, "TestArtist"); + GivenAlbum(c, 1, 1, "TestAlbum1", releases[0].ToJson()); + GivenTracks(c, 1, 1, 1, 10); + GivenAlbum(c, 2, 1, "TestAlbum2", releases[1].ToJson()); + GivenTracks(c, 1, 2, 100, 10); + + }); + + VerifyAlbumReleases(db); + VerifyTracks(db, 1, 1, 10); + VerifyTracks(db, 2, 2, 10); + } + } +} diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/030_add_mediafilerepository_mtimeFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/030_add_mediafilerepository_mtimeFixture.cs new file mode 100644 index 000000000..b79248a61 --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Migration/030_add_mediafilerepository_mtimeFixture.cs @@ -0,0 +1,321 @@ +using NUnit.Framework; +using NzbDrone.Core.Datastore.Migration; +using NzbDrone.Core.Test.Framework; +using System.Collections.Generic; +using System; +using NzbDrone.Core.Qualities; +using NzbDrone.Common.Serializer; +using NzbDrone.Test.Common; +using System.Linq; +using FluentAssertions; +using System.IO; + +namespace NzbDrone.Core.Test.Datastore.Migration +{ + [TestFixture] + public class add_mediafilerepository_mtimeFixture : MigrationTest + { + private string _artistPath = null; + + private void GivenArtist(add_mediafilerepository_mtime c, int id, string name) + { + _artistPath = $"/mnt/data/path/{name}".AsOsAgnostic(); + c.Insert.IntoTable("Artists").Row(new + { + Id = id, + CleanName = name, + Path = _artistPath, + Monitored = 1, + AlbumFolder = 1, + LanguageProfileId = 1, + MetadataProfileId = 1, + ArtistMetadataId = id + }); + } + + private void GivenAlbum(add_mediafilerepository_mtime c, int id, int artistMetadataId, string title) + { + c.Insert.IntoTable("Albums").Row(new + { + Id = id, + ForeignAlbumId = id.ToString(), + ArtistMetadataId = artistMetadataId, + Title = title, + CleanTitle = title, + Images = "", + Monitored = 1, + AlbumType = "Studio", + AnyReleaseOk = 1 + }); + } + + private void GivenAlbumRelease(add_mediafilerepository_mtime c, int id, int albumId, bool monitored) + { + c.Insert.IntoTable("AlbumReleases").Row(new + { + Id = id, + ForeignReleaseId = id.ToString(), + AlbumId = albumId, + Title = "Title", + Status = "Status", + Duration = 0, + Monitored = monitored + }); + } + + private void GivenTrackFiles(add_mediafilerepository_mtime c, List tracks, int albumReleaseId, int albumId, int firstId = 1, bool addTracks = true) + { + int id = firstId; + foreach (var track in tracks) + { + c.Insert.IntoTable("TrackFiles").Row(new + { + Id = id, + RelativePath = track?.AsOsAgnostic(), + Size = 100, + DateAdded = DateTime.UtcNow, + Quality = new QualityModel(Quality.FLAC).ToJson(), + Language = 1, + AlbumId = albumId + }); + + if (addTracks) + { + c.Insert.IntoTable("Tracks").Row(new + { + Id = id, + ForeignTrackId = id.ToString(), + Explicit = 0, + TrackFileId = id, + Duration = 100, + MediumNumber = 1, + AbsoluteTrackNumber = 1, + ForeignRecordingId = id.ToString(), + AlbumReleaseId = albumReleaseId, + ArtistMetadataId = 0 + }); + } + + id++; + } + } + + private void VerifyTracksFiles(IDirectDataMapper db, int albumId, List expectedPaths) + { + var tracks = db.Query("SELECT TrackFiles.* FROM TrackFiles " + + "WHERE TrackFiles.AlbumId = " + albumId); + + TestLogger.Debug($"Got {tracks.Count} tracks"); + + tracks.Select(x => x["Path"]).Should().BeEquivalentTo(expectedPaths); + } + + [Test] + public void migration_030_simple_case() + { + var tracks = new List { + "folder/track1.mp3", + "folder/track2.mp3", + }; + + var db = WithMigrationTestDb(c => { + GivenArtist(c, 1, "TestArtist"); + GivenAlbum(c, 1, 1, "TestAlbum"); + GivenAlbumRelease(c, 1, 1, true); + GivenTrackFiles(c, tracks, 1, 1); + }); + + var expected = tracks.Select(x => Path.Combine(_artistPath, x)).ToList(); + + VerifyTracksFiles(db, 1, expected); + } + + [Test] + public void migration_030_missing_path() + { + var tracks = new List { + "folder/track1.mp3", + null, + }; + + var db = WithMigrationTestDb(c => { + GivenArtist(c, 1, "TestArtist"); + GivenAlbum(c, 1, 1, "TestAlbum"); + GivenAlbumRelease(c, 1, 1, true); + GivenTrackFiles(c, tracks, 1, 1); + }); + + var expected = tracks.GetRange(0, 1).Select(x => Path.Combine(_artistPath, x)).ToList(); + + VerifyTracksFiles(db, 1, expected); + } + + [Test] + public void migration_030_bad_albumrelease_id() + { + var tracks = new List { + "folder/track1.mp3", + "folder/track2.mp3" + }; + + var db = WithMigrationTestDb(c => { + GivenArtist(c, 1, "TestArtist"); + GivenAlbum(c, 1, 1, "TestAlbum"); + GivenAlbumRelease(c, 1, 1, true); + GivenTrackFiles(c, tracks, 2, 1); + }); + + VerifyTracksFiles(db, 1, new List()); + } + + [Test] + public void migration_030_bad_album_id() + { + var tracks = new List { + "folder/track1.mp3", + "folder/track2.mp3" + }; + + var db = WithMigrationTestDb(c => { + GivenArtist(c, 1, "TestArtist"); + GivenAlbum(c, 1, 1, "TestAlbum"); + GivenAlbumRelease(c, 1, 1, true); + GivenTrackFiles(c, tracks, 1, 2); + }); + + VerifyTracksFiles(db, 1, new List()); + } + + [Test] + public void migration_030_bad_artist_metadata_id() + { + var tracks = new List { + "folder/track1.mp3", + "folder/track2.mp3" + }; + + var db = WithMigrationTestDb(c => { + GivenArtist(c, 1, "TestArtist"); + GivenAlbum(c, 1, 2, "TestAlbum"); + GivenAlbumRelease(c, 1, 1, true); + GivenTrackFiles(c, tracks, 1, 1); + }); + + VerifyTracksFiles(db, 1, new List()); + } + + [Test] + public void migration_030_missing_artist() + { + var tracks = new List { + "folder/track1.mp3", + "folder/track2.mp3" + }; + + var db = WithMigrationTestDb(c => { + GivenAlbum(c, 1, 1, "TestAlbum"); + GivenAlbumRelease(c, 1, 1, true); + GivenTrackFiles(c, tracks, 1, 1); + }); + + VerifyTracksFiles(db, 1, new List()); + } + + [Test] + public void migration_030_missing_tracks() + { + var tracks = new List { + "folder/track1.mp3", + "folder/track2.mp3" + }; + + var db = WithMigrationTestDb(c => { + GivenArtist(c, 1, "TestArtist"); + GivenAlbum(c, 1, 1, "TestAlbum"); + GivenAlbumRelease(c, 1, 1, true); + GivenTrackFiles(c, tracks, 1, 1, addTracks: false); + }); + + VerifyTracksFiles(db, 1, new List()); + } + + [Test] + public void migration_030_duplicate_files() + { + var tracks = new List { + "folder/track1.mp3", + "folder/track2.mp3", + "folder/track1.mp3", + }; + + var db = WithMigrationTestDb(c => { + GivenArtist(c, 1, "TestArtist"); + GivenAlbum(c, 1, 1, "TestAlbum"); + GivenAlbumRelease(c, 1, 1, true); + GivenTrackFiles(c, tracks, 1, 1); + }); + + var expected = tracks.GetRange(0, 2).Select(x => Path.Combine(_artistPath, x)).ToList(); + + VerifyTracksFiles(db, 1, expected); + } + + [Test] + public void migration_030_unmonitored_release_duplicate() + { + var monitored_tracks = new List { + "folder/track1.mp3", + "folder/track2.mp3", + }; + + var unmonitored_tracks = new List { + "folder/track1.mp3", + "folder/track2.mp3", + }; + + var db = WithMigrationTestDb(c => { + GivenArtist(c, 1, "TestArtist"); + GivenAlbum(c, 1, 1, "TestAlbum"); + + GivenAlbumRelease(c, 1, 1, true); + GivenTrackFiles(c, monitored_tracks, 1, 1); + + GivenAlbumRelease(c, 2, 1, false); + GivenTrackFiles(c, unmonitored_tracks, 2, 1, firstId: 100); + }); + + var expected = monitored_tracks.Select(x => Path.Combine(_artistPath, x)).ToList(); + + VerifyTracksFiles(db, 1, expected); + } + + [Test] + public void migration_030_unmonitored_release_distinct() + { + var monitored_tracks = new List { + "folder/track1.mp3", + "folder/track2.mp3", + }; + + var unmonitored_tracks = new List { + "folder/track3.mp3", + "folder/track4.mp3", + }; + + var db = WithMigrationTestDb(c => { + GivenArtist(c, 1, "TestArtist"); + GivenAlbum(c, 1, 1, "TestAlbum"); + + GivenAlbumRelease(c, 1, 1, true); + GivenTrackFiles(c, monitored_tracks, 1, 1); + + GivenAlbumRelease(c, 2, 1, false); + GivenTrackFiles(c, unmonitored_tracks, 2, 1, firstId: 100); + }); + + var expected = monitored_tracks.Select(x => Path.Combine(_artistPath, x)).ToList(); + + VerifyTracksFiles(db, 1, expected); + } + } +} diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/031_add_artistmetadataid_constraintFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/031_add_artistmetadataid_constraintFixture.cs new file mode 100644 index 000000000..cb4943710 --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Migration/031_add_artistmetadataid_constraintFixture.cs @@ -0,0 +1,113 @@ +using NUnit.Framework; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Datastore.Migration; +using NzbDrone.Test.Common; +using System.Linq; +using FluentAssertions; +using System.Collections.Generic; + +namespace NzbDrone.Core.Test.Datastore.Migration +{ + [TestFixture] + public class add_artistmetadataid_constraintFixture : MigrationTest + { + private string _artistPath = null; + + private void GivenArtistMetadata(add_artistmetadataid_constraint c, int id, string name) + { + c.Insert.IntoTable("ArtistMetadata").Row(new + { + Id = id, + ForeignArtistId = id, + Name = name, + Status = 1, + Images = "images" + }); + } + + private void GivenArtist(add_artistmetadataid_constraint c, int id, int artistMetadataId, string name) + { + _artistPath = $"/mnt/data/path/{name}".AsOsAgnostic(); + c.Insert.IntoTable("Artists").Row(new + { + Id = id, + ArtistMetadataId = artistMetadataId, + CleanName = name, + Path = _artistPath, + Monitored = 1, + AlbumFolder = 1, + LanguageProfileId = 1, + MetadataProfileId = 1, + }); + } + + private void VerifyArtists(IDirectDataMapper db, List ids) + { + var artists = db.Query("SELECT Artists.* from Artists"); + + artists.Select(x => x["Id"]).ShouldBeEquivalentTo(ids); + + var duplicates = artists.GroupBy(x => x["ArtistMetadataId"]) + .Where(x => x.Count() > 1); + + duplicates.Should().BeEmpty(); + } + + [Test] + public void migration_031_should_not_remove_unique_artist() + { + var db = WithMigrationTestDb(c => { + GivenArtistMetadata(c, 1, "test"); + GivenArtist(c, 1, 1, "test"); + }); + + VerifyArtists(db, new List { 1 }); + } + + [Test] + public void migration_031_should_not_remove_either_unique_artist() + { + var db = WithMigrationTestDb(c => { + GivenArtistMetadata(c, 1, "test"); + GivenArtist(c, 1, 1, "test"); + + GivenArtistMetadata(c, 2, "test2"); + GivenArtist(c, 2, 2, "test2"); + }); + + VerifyArtists(db, new List { 1, 2 }); + } + + [Test] + public void migration_031_should_remove_duplicate_artist() + { + var db = WithMigrationTestDb(c => { + GivenArtistMetadata(c, 1, "test"); + GivenArtist(c, 1, 1, "test"); + + GivenArtist(c, 2, 1, "test2"); + }); + + VerifyArtists(db, new List { 1 }); + } + + [Test] + public void migration_031_should_remove_all_duplicate_artists() + { + var db = WithMigrationTestDb(c => { + GivenArtistMetadata(c, 1, "test"); + GivenArtist(c, 1, 1, "test"); + GivenArtist(c, 2, 1, "test"); + GivenArtist(c, 3, 1, "test"); + GivenArtist(c, 4, 1, "test"); + + GivenArtistMetadata(c, 2, "test2"); + GivenArtist(c, 5, 2, "test2"); + GivenArtist(c, 6, 2, "test2"); + + }); + + VerifyArtists(db, new List { 1, 5 }); + } + } +} diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/070_delay_profileFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/070_delay_profileFixture.cs deleted file mode 100644 index be2b07b66..000000000 --- a/src/NzbDrone.Core.Test/Datastore/Migration/070_delay_profileFixture.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System.Linq; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Datastore.Migration; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.Datastore.Migration -{ - [TestFixture] - public class delay_profileFixture : MigrationTest - { - [Test] - public void should_migrate_old_delays() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("Profiles").Row(new - { - GrabDelay = 1, - Name = "OneHour", - Cutoff = 0, - Items = "[]" - }); - - c.Insert.IntoTable("Profiles").Row(new - { - GrabDelay = 2, - Name = "TwoHours", - Cutoff = "{}", - Items = "[]" - }); - }); - - var allProfiles = db.Query("SELECT * FROM DelayProfiles"); - - allProfiles.Should().HaveCount(3); - allProfiles.Should().OnlyContain(c => c.PreferredProtocol == 1); - allProfiles.Should().OnlyContain(c => c.TorrentDelay == 0); - allProfiles.Should().Contain(c => c.UsenetDelay == 60); - allProfiles.Should().Contain(c => c.UsenetDelay == 120); - } - - [Test] - public void should_create_tag_for_delay_profile() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("Profiles").Row(new - { - GrabDelay = 1, - Name = "OneHour", - Cutoff = 0, - Items = "[]" - }); - }); - - var tags = db.Query("SELECT * FROM Tags"); - - tags.Should().HaveCount(1); - tags.First().Label.Should().Be("delay-60"); - } - - [Test] - public void should_add_tag_to_series_that_had_a_profile_with_delay_attached() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("Profiles").Row(new - { - GrabDelay = 1, - Name = "OneHour", - Cutoff = 0, - Items = "[]" - }); - - c.Insert.IntoTable("Series").Row(new - { - TvdbId = 0, - TvRageId = 0, - Title = "Series", - TitleSlug = "series", - CleanTitle = "series", - Status = 0, - Images = "[]", - Path = @"C:\Test\Series", - Monitored = 1, - SeasonFolder = 1, - RunTime = 0, - SeriesType = 0, - UseSceneNumbering = 0, - Tags = "[1]" - }); - }); - - var tag = db.Query("SELECT Id, Label FROM Tags").Single(); - var series = db.Query("SELECT Tags FROM Series"); - - series.Should().HaveCount(1); - series.First().Tags.Should().BeEquivalentTo(tag.Id); - } - } -} diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/071_unknown_quality_in_profileFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/071_unknown_quality_in_profileFixture.cs deleted file mode 100644 index ad31df44c..000000000 --- a/src/NzbDrone.Core.Test/Datastore/Migration/071_unknown_quality_in_profileFixture.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Linq; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Datastore.Migration; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.Datastore.Migration -{ - [TestFixture] - public class unknown_quality_in_profileFixture : MigrationTest - { - [Test] - public void should_add_unknown_to_old_profile() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("Profiles").Row(new - { - Id = 0, - Name = "SDTV", - Cutoff = 1, - Items = "[ { \"quality\": 1, \"allowed\": true } ]", - Language = 1 - }); - }); - - var profiles = db.Query("SELECT Items FROM Profiles LIMIT 1"); - - var items = profiles.First().Items; - items.Should().HaveCount(2); - items.First().Quality.Should().Be(0); - items.First().Allowed.Should().Be(false); - } - } -} diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/072_history_downloadIdFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/072_history_downloadIdFixture.cs deleted file mode 100644 index c976f9b10..000000000 --- a/src/NzbDrone.Core.Test/Datastore/Migration/072_history_downloadIdFixture.cs +++ /dev/null @@ -1,106 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using FluentAssertions; -using FluentMigrator; -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 history_downloadIdFixture : MigrationTest - { - [Test] - public void should_move_grab_id_from_date_to_columns() - { - var db = WithMigrationTestDb(c => - { - InsertHistory(c, new Dictionary - { - {"indexer","test"}, - {"downloadClientId","123"} - }); - - InsertHistory(c, new Dictionary - { - {"indexer","test"}, - {"downloadClientId","abc"} - }); - - }); - - var history = db.Query("SELECT DownloadId, Data FROM History"); - - history.Should().HaveCount(2); - history.Should().NotContain(c => c.Data.ContainsKey("downloadClientId")); - history.Should().Contain(c => c.DownloadId == "123"); - history.Should().Contain(c => c.DownloadId == "abc"); - } - - - [Test] - public void should_leave_items_with_no_grabid() - { - var db = WithMigrationTestDb(c => - { - InsertHistory(c, new Dictionary - { - {"indexer","test"}, - {"downloadClientId","123"} - }); - - InsertHistory(c, new Dictionary - { - {"indexer","test"} - }); - - }); - - var history = db.Query("SELECT DownloadId, Data FROM History"); - - history.Should().HaveCount(2); - history.Should().NotContain(c => c.Data.ContainsKey("downloadClientId")); - history.Should().Contain(c => c.DownloadId == "123"); - history.Should().Contain(c => c.DownloadId == null); - } - - [Test] - public void should_leave_other_data() - { - var db = WithMigrationTestDb(c => - { - InsertHistory(c, new Dictionary - { - {"indexer","test"}, - {"group","test2"}, - {"downloadClientId","123"} - }); - }); - - var history = db.Query("SELECT DownloadId, Data FROM History").Single(); - - history.Data.Should().NotContainKey("downloadClientId"); - history.Data.Should().Contain(new KeyValuePair("indexer", "test")); - history.Data.Should().Contain(new KeyValuePair("group", "test2")); - - history.DownloadId.Should().Be("123"); - } - - private void InsertHistory(MigrationBase migrationBase, Dictionary data) - { - migrationBase.Insert.IntoTable("History").Row(new - { - EpisodeId = 1, - SeriesId = 1, - SourceTitle = "Test", - Date = DateTime.Now, - Quality = "{}", - Data = data.ToJson(), - EventType = 1 - }); - } - } -} diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/075_force_lib_updateFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/075_force_lib_updateFixture.cs deleted file mode 100644 index 1d6c113b8..000000000 --- a/src/NzbDrone.Core.Test/Datastore/Migration/075_force_lib_updateFixture.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System.Linq; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Datastore.Migration; - -namespace NzbDrone.Core.Test.Datastore.Migration -{ - [TestFixture] - public class force_lib_updateFixture : MigrationTest - { - [Test] - public void should_not_fail_on_empty_db() - { - var db = WithMigrationTestDb(); - - db.Query("SELECT * FROM ScheduledTasks").Should().BeEmpty(); - db.Query("SELECT * FROM Series").Should().BeEmpty(); - } - - - [Test] - public void should_reset_job_last_execution_time() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("ScheduledTasks").Row(new - { - TypeName = "NzbDrone.Core.Tv.Commands.RefreshSeriesCommand", - Interval = 10, - LastExecution = "2000-01-01 00:00:00" - }); - - c.Insert.IntoTable("ScheduledTasks").Row(new - { - TypeName = "NzbDrone.Core.Backup.BackupCommand", - Interval = 10, - LastExecution = "2000-01-01 00:00:00" - }); - }); - - var jobs = db.Query("SELECT TypeName, LastExecution FROM ScheduledTasks"); - - jobs.Single(c => c.TypeName == "NzbDrone.Core.Tv.Commands.RefreshSeriesCommand") - .LastExecution.Year.Should() - .Be(2014); - - jobs.Single(c => c.TypeName == "NzbDrone.Core.Backup.BackupCommand") - .LastExecution.Year.Should() - .Be(2000); - } - - [Test] - public void should_reset_series_last_sync_time() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("Series").Row(new - { - Tvdbid = 1, - TvRageId =1, - Title ="Title1", - CleanTitle ="CleanTitle1", - Status =1, - Images ="", - Path ="c:\\test", - Monitored =1, - SeasonFolder =1, - Runtime= 0, - SeriesType=0, - UseSceneNumbering =0, - LastInfoSync = "2000-01-01 00:00:00" - }); - - c.Insert.IntoTable("Series").Row(new - { - Tvdbid = 2, - TvRageId = 2, - Title = "Title2", - CleanTitle = "CleanTitle2", - Status = 1, - Images = "", - Path = "c:\\test2", - Monitored = 1, - SeasonFolder = 1, - Runtime = 0, - SeriesType = 0, - UseSceneNumbering = 0, - LastInfoSync = "2000-01-01 00:00:00" - }); - }); - - var series = db.Query("SELECT LastInfoSync FROM Series"); - - series.Should().OnlyContain(c => c.LastInfoSync.Value.Year == 2014); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/079_dedupe_tagsFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/079_dedupe_tagsFixture.cs deleted file mode 100644 index e333fb9a1..000000000 --- a/src/NzbDrone.Core.Test/Datastore/Migration/079_dedupe_tagsFixture.cs +++ /dev/null @@ -1,205 +0,0 @@ -using System.Linq; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Datastore.Migration; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.Datastore.Migration -{ - [TestFixture] - public class dedupe_tagsFixture : MigrationTest - { - [Test] - public void should_not_fail_if_series_tags_are_null() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("Series").Row(new - { - Tvdbid = 1, - TvRageId = 1, - Title = "Title1", - CleanTitle = "CleanTitle1", - Status = 1, - Images = "", - Path = "c:\\test", - Monitored = 1, - SeasonFolder = 1, - Runtime = 0, - SeriesType = 0, - UseSceneNumbering = 0, - LastInfoSync = "2000-01-01 00:00:00" - }); - - c.Insert.IntoTable("Tags").Row(new - { - Label = "test" - }); - }); - - var tags = db.Query("SELECT * FROM Tags"); - tags.Should().HaveCount(1); - } - - [Test] - public void should_not_fail_if_series_tags_are_empty() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("Series").Row(new - { - Tvdbid = 1, - TvRageId = 1, - Title = "Title1", - CleanTitle = "CleanTitle1", - Status = 1, - Images = "", - Path = "c:\\test", - Monitored = 1, - SeasonFolder = 1, - Runtime = 0, - SeriesType = 0, - UseSceneNumbering = 0, - LastInfoSync = "2000-01-01 00:00:00", - Tags = "[]" - }); - - c.Insert.IntoTable("Tags").Row(new - { - Label = "test" - }); - }); - - var tags = db.Query("SELECT * FROM Tags"); - tags.Should().HaveCount(1); - } - - [Test] - public void should_remove_duplicate_labels_from_tags() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("Tags").Row(new - { - Label = "test" - }); - - c.Insert.IntoTable("Tags").Row(new - { - Label = "test" - }); - }); - - var tags = db.Query("SELECT * FROM Tags"); - tags.Should().HaveCount(1); - } - - [Test] - public void should_not_allow_duplicate_tag_to_be_inserted() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("Tags").Row(new - { - Label = "test" - }); - }); - - Assert.That(() => db.Query("INSERT INTO Tags (Label) VALUES ('test')"), Throws.Exception); - } - - [Test] - public void should_replace_duplicated_tag_with_proper_tag() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("Series").Row(new - { - Tvdbid = 1, - TvRageId = 1, - Title = "Title1", - CleanTitle = "CleanTitle1", - Status = 1, - Images = "", - Path = "c:\\test", - Monitored = 1, - SeasonFolder = 1, - Runtime = 0, - SeriesType = 0, - UseSceneNumbering = 0, - LastInfoSync = "2000-01-01 00:00:00", - Tags = "[2]" - }); - - c.Insert.IntoTable("Tags").Row(new - { - Label = "test" - }); - - c.Insert.IntoTable("Tags").Row(new - { - Label = "test" - }); - }); - - var series = db.Query("SELECT Tags FROM Series WHERE Id = 1").Single(); - series.Tags.First().Should().Be(1); - } - - [Test] - public void should_only_update_affected_series() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("Series").Row(new - { - Tvdbid = 1, - TvRageId = 1, - Title = "Title1", - CleanTitle = "CleanTitle1", - Status = 1, - Images = "", - Path = "c:\\test", - Monitored = 1, - SeasonFolder = 1, - Runtime = 0, - SeriesType = 0, - UseSceneNumbering = 0, - LastInfoSync = "2000-01-01 00:00:00", - Tags = "[2]" - }); - - c.Insert.IntoTable("Series").Row(new - { - Tvdbid = 2, - TvRageId = 2, - Title = "Title2", - CleanTitle = "CleanTitle2", - Status = 1, - Images = "", - Path = "c:\\test", - Monitored = 1, - SeasonFolder = 1, - Runtime = 0, - SeriesType = 0, - UseSceneNumbering = 0, - LastInfoSync = "2000-01-01 00:00:00", - Tags = "[]" - }); - - c.Insert.IntoTable("Tags").Row(new - { - Label = "test" - }); - - c.Insert.IntoTable("Tags").Row(new - { - Label = "test" - }); - }); - - var series = db.Query("SELECT Tags FROM Series WHERE Id = 2").Single(); - series.Tags.Should().BeEmpty(); - } - } -} diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/081_move_dot_prefix_to_transmission_categoryFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/081_move_dot_prefix_to_transmission_categoryFixture.cs deleted file mode 100644 index 7aae7010e..000000000 --- a/src/NzbDrone.Core.Test/Datastore/Migration/081_move_dot_prefix_to_transmission_categoryFixture.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System.Linq; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Common.Serializer; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Datastore.Migration; - -namespace NzbDrone.Core.Test.Datastore.Migration -{ - [TestFixture] - public class move_dot_prefix_to_transmission_categoryFixture : MigrationTest - { - [Test] - public void should_not_fail_if_no_transmission() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("DownloadClients").Row(new - { - Enable = 1, - Name = "Sab", - Implementation = "Sabnzbd", - Settings = new - { - Host = "127.0.0.1", - TvCategory = "abc" - }.ToJson(), - ConfigContract = "SabnzbdSettings" - }); - }); - - var downloadClients = db.Query("SELECT Settings FROM DownloadClients"); - - downloadClients.Should().HaveCount(1); - downloadClients.First().Settings.ToObject().TvCategory.Should().Be("abc"); - } - - [Test] - public void should_be_updated_for_transmission() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("DownloadClients").Row(new - { - Enable = 1, - Name = "Trans", - Implementation = "Transmission", - Settings = new - { - Host = "127.0.0.1", - TvCategory = "abc" - }.ToJson(), - ConfigContract = "TransmissionSettings" - }); - }); - - var downloadClients = db.Query("SELECT Settings FROM DownloadClients"); - - downloadClients.Should().HaveCount(1); - downloadClients.First().Settings.ToObject().TvCategory.Should().Be(".abc"); - } - - [Test] - public void should_leave_empty_category_untouched() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("DownloadClients").Row(new - { - Enable = 1, - Name = "Trans", - Implementation = "Transmission", - Settings = new - { - Host = "127.0.0.1", - TvCategory = "" - }.ToJson(), - ConfigContract = "TransmissionSettings" - }); - }); - - var downloadClients = db.Query("SELECT Settings FROM DownloadClients"); - - downloadClients.Should().HaveCount(1); - downloadClients.First().Settings.ToObject().TvCategory.Should().Be(""); - } - } -} diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/084_update_quality_minmax_sizeFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/084_update_quality_minmax_sizeFixture.cs deleted file mode 100644 index 8b4b237e6..000000000 --- a/src/NzbDrone.Core.Test/Datastore/Migration/084_update_quality_minmax_sizeFixture.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System.Linq; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Datastore.Migration; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.Datastore.Migration -{ - [TestFixture] - public class update_quality_minmax_sizeFixture : MigrationTest - { - [Test] - public void should_not_fail_if_empty() - { - var db = WithMigrationTestDb(); - - var qualityDefinitions = db.Query("SELECT * FROM QualityDefinitions"); - - qualityDefinitions.Should().BeEmpty(); - } - - [Test] - public void should_set_rawhd_to_null() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("QualityDefinitions").Row(new - { - Quality = 1, - Title = "SDTV", - MinSize = 0, - MaxSize = 100 - }) - .Row(new - { - Quality = 10, - Title = "RawHD", - MinSize = 0, - MaxSize = 100 - }); - }); - - var qualityDefinitions = db.Query("SELECT * FROM QualityDefinitions"); - - qualityDefinitions.Should().HaveCount(2); - qualityDefinitions.First(v => v.Quality == 10).MaxSize.Should().NotHaveValue(); - } - - [Test] - public void should_set_zero_maxsize_to_null() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("QualityDefinitions").Row(new - { - Quality = 1, - Title = "SDTV", - MinSize = 0, - MaxSize = 0 - }); - }); - - var qualityDefinitions = db.Query("SELECT * FROM QualityDefinitions"); - - qualityDefinitions.Should().HaveCount(1); - qualityDefinitions.First(v => v.Quality == 1).MaxSize.Should().NotHaveValue(); - } - - [Test] - public void should_preserve_values() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("QualityDefinitions").Row(new - { - Quality = 1, - Title = "SDTV", - MinSize = 0, - MaxSize = 100 - }) - .Row(new - { - Quality = 10, - Title = "RawHD", - MinSize = 0, - MaxSize = 100 - }); - }); - - var qualityDefinitions = db.Query("SELECT * FROM QualityDefinitions"); - - qualityDefinitions.Should().HaveCount(2); - qualityDefinitions.First(v => v.Quality == 1).MaxSize.Should().Be(100); - } - } -} diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/085_expand_transmission_urlbaseFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/085_expand_transmission_urlbaseFixture.cs deleted file mode 100644 index 0b1f7460d..000000000 --- a/src/NzbDrone.Core.Test/Datastore/Migration/085_expand_transmission_urlbaseFixture.cs +++ /dev/null @@ -1,90 +0,0 @@ -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 expand_transmission_urlbaseFixture : MigrationTest - { - [Test] - public void should_not_fail_if_no_transmission() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("DownloadClients").Row(new - { - Enable = 1, - Name = "Deluge", - Implementation = "Deluge", - Settings = new DelugeSettings85 - { - Host = "127.0.0.1", - TvCategory = "abc", - UrlBase = "/my/" - }.ToJson(), - ConfigContract = "DelugeSettings" - }); - }); - - var items = db.Query("SELECT * FROM DownloadClients"); - - items.Should().HaveCount(1); - items.First().Settings.ToObject().UrlBase.Should().Be("/my/"); - } - - [Test] - public void should_be_updated_for_transmission() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("DownloadClients").Row(new - { - Enable = 1, - Name = "Trans", - Implementation = "Transmission", - Settings = new TransmissionSettings81 - { - Host = "127.0.0.1", - TvCategory = "abc" - }.ToJson(), - ConfigContract = "TransmissionSettings" - }); - }); - - var items = db.Query("SELECT * FROM DownloadClients"); - - items.Should().HaveCount(1); - items.First().Settings.ToObject().UrlBase.Should().Be("/transmission/"); - } - - [Test] - public void should_be_append_to_existing_urlbase() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("DownloadClients").Row(new - { - Enable = 1, - Name = "Trans", - Implementation = "Transmission", - Settings = new TransmissionSettings81 - { - Host = "127.0.0.1", - TvCategory = "abc", - UrlBase = "/my/url/" - }.ToJson(), - ConfigContract = "TransmissionSettings" - }); - }); - - var items = db.Query("SELECT * FROM DownloadClients"); - - items.Should().HaveCount(1); - items.First().Settings.ToObject().UrlBase.Should().Be("/my/url/transmission/"); - } - } -} diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/086_pushbullet_device_idsFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/086_pushbullet_device_idsFixture.cs deleted file mode 100644 index 20a8e063a..000000000 --- a/src/NzbDrone.Core.Test/Datastore/Migration/086_pushbullet_device_idsFixture.cs +++ /dev/null @@ -1,89 +0,0 @@ -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 pushbullet_device_idsFixture : MigrationTest - { - [Test] - public void should_not_fail_if_no_pushbullet() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("Notifications").Row(new - { - OnGrab = false, - OnDownload = false, - OnUpgrade = false, - Name = "Pushover", - Implementation = "Pushover", - Settings = "{}", - ConfigContract = "PushoverSettings" - }); - }); - - var items = db.Query("SELECT * FROM Notifications"); - - items.Should().HaveCount(1); - } - - [Test] - public void should_not_fail_if_deviceId_is_not_set() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("Notifications").Row(new - { - OnGrab = false, - OnDownload = false, - OnUpgrade = false, - Name = "PushBullet", - Implementation = "PushBullet", - Settings = new - { - ApiKey = "my_api_key" - }.ToJson(), - ConfigContract = "PushBulletSettings" - }); - }); - - var items = db.Query("SELECT * FROM Notifications"); - - items.Should().HaveCount(1); - } - - [Test] - public void should_add_deviceIds_setting_matching_deviceId() - { - var deviceId = "device_id"; - - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("Notifications").Row(new - { - OnGrab = false, - OnDownload = false, - OnUpgrade = false, - Name = "PushBullet", - Implementation = "PushBullet", - Settings = new - { - ApiKey = "my_api_key", - DeviceId = deviceId - }.ToJson(), - ConfigContract = "PushBulletSettings" - }); - }); - - var items = db.Query("SELECT * FROM Notifications"); - - items.Should().HaveCount(1); - items.First().Settings.ToObject().DeviceIds.First().Should().Be(deviceId); - } - } -} diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/088_pushbullet_devices_channels_listFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/088_pushbullet_devices_channels_listFixture.cs deleted file mode 100644 index 37679998c..000000000 --- a/src/NzbDrone.Core.Test/Datastore/Migration/088_pushbullet_devices_channels_listFixture.cs +++ /dev/null @@ -1,40 +0,0 @@ -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 pushbullet_devices_channels_listFixture : MigrationTest - { - [Test] - public void should_convert_comma_separted_string_to_list() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("Notifications").Row(new - { - OnGrab = false, - OnDownload = false, - OnUpgrade = false, - Name = "PushBullet", - Implementation = "PushBullet", - Settings = new - { - ApiKey = "my_api_key", - ChannelTags = "channel1,channel2" - }.ToJson(), - ConfigContract = "PushBulletSettings" - }); - }); - - var items = db.Query("SELECT * FROM Notifications"); - - items.Should().HaveCount(1); - items.First().Settings.ToObject().ChannelTags.Should().HaveCount(2); - } - } -} diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/090_update_kickass_urlFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/090_update_kickass_urlFixture.cs deleted file mode 100644 index 292344127..000000000 --- a/src/NzbDrone.Core.Test/Datastore/Migration/090_update_kickass_urlFixture.cs +++ /dev/null @@ -1,64 +0,0 @@ -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_kickass_url_migration_fixture : MigrationTest - { - [TestCase("http://kickass.so")] - [TestCase("https://kickass.so")] - [TestCase("http://kickass.to")] - [TestCase("https://kickass.to")] - [TestCase("http://kat.cr")] - // [TestCase("HTTP://KICKASS.SO")] Not sure if there is an easy way to do this, not sure if worth it. - public void should_replace_old_url(string oldUrl) - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("Indexers").Row(new - { - Name = "Kickass_wrong_url", - Implementation = "KickassTorrents", - Settings = new KickassTorrentsSettings90 - { - BaseUrl = oldUrl - }.ToJson(), - ConfigContract = "KickassTorrentsSettings" - }); - }); - - var items = db.Query("SELECT * FROM Indexers"); - - items.Should().HaveCount(1); - items.First().Settings.ToObject().BaseUrl.Should().Be("https://kat.cr"); - } - - [Test] - public void should_not_replace_other_indexers() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("Indexers").Row(new - { - Name = "not_kickass", - Implementation = "NotKickassTorrents", - Settings = new KickassTorrentsSettings90 - { - BaseUrl = "kickass.so", - }.ToJson(), - ConfigContract = "KickassTorrentsSettings" - }); - }); - - var items = db.Query("SELECT * FROM Indexers"); - - items.Should().HaveCount(1); - items.First().Settings.ToObject().BaseUrl.Should().Be("kickass.so"); - } - } -} diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/099_extra_and_subtitle_filesFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/099_extra_and_subtitle_filesFixture.cs deleted file mode 100644 index f72d950f0..000000000 --- a/src/NzbDrone.Core.Test/Datastore/Migration/099_extra_and_subtitle_filesFixture.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Linq; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Datastore.Migration; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.Datastore.Migration -{ - [TestFixture] - public class metadata_files_extensionFixture : MigrationTest - { - [Test] - public void should_set_extension_using_relative_path() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("MetadataFiles").Row(new - { - SeriesId = 1, - RelativePath = "banner.jpg", - LastUpdated = "2016-05-30 20:23:02.3725923", - Type = 3, - Consumer = "XbmcMetadata" - }); - - c.Insert.IntoTable("MetadataFiles").Row(new - { - SeriesId = 1, - SeasonNumber = 1, - EpisodeFileId = 1, - RelativePath = "Series.Title.S01E01.jpg", - LastUpdated = "2016-05-30 20:23:02.3725923", - Type = 5, - Consumer = "XbmcMetadata" - }); - - c.Insert.IntoTable("MetadataFiles").Row(new - { - SeriesId = 1, - RelativePath = "Series Title", - LastUpdated = "2016-05-30 20:23:02.3725923", - Type = 3, - Consumer = "RoksboxMetadata" - }); - }); - - var items = db.Query("SELECT * FROM MetadataFiles"); - - items.Should().HaveCount(2); - items.First().Extension.Should().Be(".jpg"); - items.Last().Extension.Should().Be(".jpg"); - } - } -} diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/101_add_ultrahd_quality_in_profilesFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/101_add_ultrahd_quality_in_profilesFixture.cs deleted file mode 100644 index 8e5562824..000000000 --- a/src/NzbDrone.Core.Test/Datastore/Migration/101_add_ultrahd_quality_in_profilesFixture.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Linq; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Datastore.Migration; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.Datastore.Migration -{ - [TestFixture] - public class add_ultrahd_quality_in_profilesFixture : MigrationTest - { - [Test] - public void should_add_ultrahd_to_old_profile() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("Profiles").Row(new - { - Id = 0, - Name = "SDTV", - Cutoff = 1, - Items = "[ { \"quality\": 1, \"allowed\": true } ]", - Language = 1 - }); - }); - - var profiles = db.Query("SELECT Items FROM Profiles LIMIT 1"); - - var items = profiles.First().Items; - items.Should().HaveCount(4); - items.Select(v => v.Quality).Should().BeEquivalentTo(1, 16, 18, 19); - items.Select(v => v.Allowed).Should().BeEquivalentTo(true, false, false, false); - } - } -} diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/103_fix_metadata_file_extensionsFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/103_fix_metadata_file_extensionsFixture.cs deleted file mode 100644 index 86905df18..000000000 --- a/src/NzbDrone.Core.Test/Datastore/Migration/103_fix_metadata_file_extensionsFixture.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Linq; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Datastore.Migration; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.Datastore.Migration -{ - [TestFixture] - public class fix_metadata_file_extensionsFixture : MigrationTest - { - [Test] - public void should_fix_extension_when_relative_path_contained_multiple_periods() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("MetadataFiles").Row(new - { - SeriesId = 1, - SeasonNumber = 1, - EpisodeFileId = 1, - RelativePath = "Series.Title.S01E01.jpg", - LastUpdated = "2016-05-30 20:23:02.3725923", - Type = 5, - Consumer = "XbmcMetadata", - Extension = ".S01E01.jpg" - }); - }); - - var items = db.Query("SELECT * FROM MetadataFiles"); - - items.Should().HaveCount(1); - items.First().Extension.Should().Be(".jpg"); - } - } -} diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/106_update_btn_urlFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/106_update_btn_urlFixture.cs deleted file mode 100644 index 3b719d42e..000000000 --- a/src/NzbDrone.Core.Test/Datastore/Migration/106_update_btn_urlFixture.cs +++ /dev/null @@ -1,62 +0,0 @@ -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_btn_url_migration_fixture : MigrationTest - { - [TestCase("http://api.btnapps.net")] - [TestCase("https://api.btnapps.net")] - [TestCase("http://api.btnapps.net/")] - [TestCase("https://api.btnapps.net/")] - public void should_replace_old_url(string oldUrl) - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("Indexers").Row(new - { - Name = "btn_old_url", - Implementation = "BroadcastheNet", - Settings = new BroadcastheNetSettings106 - { - BaseUrl = oldUrl - }.ToJson(), - ConfigContract = "BroadcastheNetSettings" - }); - }); - - var items = db.Query("SELECT * FROM Indexers"); - - items.Should().HaveCount(1); - items.First().Settings.ToObject().BaseUrl.Should().Contain("api.broadcasthe.net"); - } - - [Test] - public void should_not_replace_other_indexers() - { - var db = WithMigrationTestDb(c => - { - c.Insert.IntoTable("Indexers").Row(new - { - Name = "not_btn", - Implementation = "NotBroadcastheNet", - Settings = new BroadcastheNetSettings106 - { - BaseUrl = "http://api.btnapps.net", - }.ToJson(), - ConfigContract = "BroadcastheNetSettings" - }); - }); - - var items = db.Query("SELECT * FROM Indexers"); - - items.Should().HaveCount(1); - items.First().Settings.ToObject().BaseUrl.Should().Be("http://api.btnapps.net"); - } - } -} diff --git a/src/NzbDrone.Core.Test/Datastore/PagingSpecExtensionsTests/PagingOffsetFixture.cs b/src/NzbDrone.Core.Test/Datastore/PagingSpecExtensionsTests/PagingOffsetFixture.cs index 5ece0f8a4..7a39cc708 100644 --- a/src/NzbDrone.Core.Test/Datastore/PagingSpecExtensionsTests/PagingOffsetFixture.cs +++ b/src/NzbDrone.Core.Test/Datastore/PagingSpecExtensionsTests/PagingOffsetFixture.cs @@ -1,8 +1,8 @@ -using FluentAssertions; +using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.Datastore; using NzbDrone.Core.Datastore.Extensions; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Test.Datastore.PagingSpecExtensionsTests { @@ -14,12 +14,12 @@ namespace NzbDrone.Core.Test.Datastore.PagingSpecExtensionsTests [TestCase(1, 100, 0)] public void should_calcuate_expected_offset(int page, int pageSize, int expected) { - var pagingSpec = new PagingSpec + var pagingSpec = new PagingSpec { Page = page, PageSize = pageSize, SortDirection = SortDirection.Ascending, - SortKey = "AirDate" + SortKey = "ReleaseDate" }; pagingSpec.PagingOffset().Should().Be(expected); diff --git a/src/NzbDrone.Core.Test/Datastore/PagingSpecExtensionsTests/ToSortDirectionFixture.cs b/src/NzbDrone.Core.Test/Datastore/PagingSpecExtensionsTests/ToSortDirectionFixture.cs index d2cecde84..cd70cb4e3 100644 --- a/src/NzbDrone.Core.Test/Datastore/PagingSpecExtensionsTests/ToSortDirectionFixture.cs +++ b/src/NzbDrone.Core.Test/Datastore/PagingSpecExtensionsTests/ToSortDirectionFixture.cs @@ -1,8 +1,8 @@ -using FluentAssertions; +using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.Datastore; using NzbDrone.Core.Datastore.Extensions; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Test.Datastore.PagingSpecExtensionsTests { @@ -11,12 +11,12 @@ namespace NzbDrone.Core.Test.Datastore.PagingSpecExtensionsTests [Test] public void should_convert_default_to_asc() { - var pagingSpec = new PagingSpec + var pagingSpec = new PagingSpec { Page = 1, PageSize = 10, SortDirection = SortDirection.Default, - SortKey = "AirDate" + SortKey = "ReleaseDate" }; pagingSpec.ToSortDirection().Should().Be(Marr.Data.QGen.SortDirection.Asc); @@ -25,13 +25,13 @@ namespace NzbDrone.Core.Test.Datastore.PagingSpecExtensionsTests [Test] public void should_convert_ascending_to_asc() { - var pagingSpec = new PagingSpec + var pagingSpec = new PagingSpec { Page = 1, PageSize = 10, SortDirection = SortDirection.Ascending, - SortKey = "AirDate" - }; + SortKey = "ReleaseDate" + }; pagingSpec.ToSortDirection().Should().Be(Marr.Data.QGen.SortDirection.Asc); } @@ -39,12 +39,12 @@ namespace NzbDrone.Core.Test.Datastore.PagingSpecExtensionsTests [Test] public void should_convert_descending_to_desc() { - var pagingSpec = new PagingSpec + var pagingSpec = new PagingSpec { Page = 1, PageSize = 10, SortDirection = SortDirection.Descending, - SortKey = "AirDate" + SortKey = "ReleaseDate" }; pagingSpec.ToSortDirection().Should().Be(Marr.Data.QGen.SortDirection.Desc); diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/AcceptableSizeSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/AcceptableSizeSpecificationFixture.cs index 14cdef982..c07b8d6df 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/AcceptableSizeSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/AcceptableSizeSpecificationFixture.cs @@ -8,7 +8,7 @@ using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Qualities; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Test.DecisionEngineTests { @@ -16,40 +16,57 @@ namespace NzbDrone.Core.Test.DecisionEngineTests public class AcceptableSizeSpecificationFixture : CoreTest { - private RemoteEpisode parseResultMultiSet; - private RemoteEpisode parseResultMulti; - private RemoteEpisode parseResultSingle; - private Series series; + private const int HIGH_KBPS_BITRATE = 1600; + private const int TWENTY_MINUTE_EP_MILLIS = 20 * 60 * 1000; + private const int FORTY_FIVE_MINUTE_LP_MILLIS = 45 * 60 * 1000; + private RemoteAlbum parseResultMultiSet; + private RemoteAlbum parseResultMulti; + private RemoteAlbum parseResultSingle; + private Artist artist; private QualityDefinition qualityType; + private Album AlbumBuilder(int id = 0) + { + return new Album + { + Id = id, + AlbumReleases = new List { new AlbumRelease + { + Duration = 0, + Monitored = true + } + } + }; + } + [SetUp] public void Setup() { - series = Builder.CreateNew() + artist = Builder.CreateNew() .Build(); - parseResultMultiSet = new RemoteEpisode - { - Series = series, + parseResultMultiSet = new RemoteAlbum + { + Artist = artist, Release = new ReleaseInfo(), - ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.SDTV, new Revision(version: 2)) }, - Episodes = new List { new Episode(), new Episode(), new Episode(), new Episode(), new Episode(), new Episode() } + ParsedAlbumInfo = new ParsedAlbumInfo { Quality = new QualityModel(Quality.MP3_192, new Revision(version: 2)) }, + Albums = new List { AlbumBuilder(), AlbumBuilder(), AlbumBuilder(), AlbumBuilder(), AlbumBuilder(), AlbumBuilder() } }; - parseResultMulti = new RemoteEpisode - { - Series = series, + parseResultMulti = new RemoteAlbum + { + Artist = artist, Release = new ReleaseInfo(), - ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.SDTV, new Revision(version: 2)) }, - Episodes = new List { new Episode(), new Episode() } + ParsedAlbumInfo = new ParsedAlbumInfo { Quality = new QualityModel(Quality.MP3_192, new Revision(version: 2)) }, + Albums = new List { AlbumBuilder(), AlbumBuilder() } }; - parseResultSingle = new RemoteEpisode - { - Series = series, + parseResultSingle = new RemoteAlbum + { + Artist = artist, Release = new ReleaseInfo(), - ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.SDTV, new Revision(version: 2)) }, - Episodes = new List { new Episode() { Id = 2 } } + ParsedAlbumInfo = new ParsedAlbumInfo { Quality = new QualityModel(Quality.MP3_192, new Revision(version: 2)) }, + Albums = new List { AlbumBuilder(2) } }; @@ -58,84 +75,69 @@ namespace NzbDrone.Core.Test.DecisionEngineTests .Returns(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v)); qualityType = Builder.CreateNew() - .With(q => q.MinSize = 2) - .With(q => q.MaxSize = 10) - .With(q => q.Quality = Quality.SDTV) + .With(q => q.MinSize = 150) + .With(q => q.MaxSize = 210) + .With(q => q.Quality = Quality.MP3_192) .Build(); - Mocker.GetMock().Setup(s => s.Get(Quality.SDTV)).Returns(qualityType); + Mocker.GetMock().Setup(s => s.Get(Quality.MP3_192)).Returns(qualityType); - Mocker.GetMock().Setup( - s => s.GetEpisodesBySeason(It.IsAny(), It.IsAny())) - .Returns(new List() { - new Episode(), new Episode(), new Episode(), new Episode(), new Episode(), - new Episode(), new Episode(), new Episode(), new Episode() { Id = 2 }, new Episode() }); + Mocker.GetMock().Setup( + s => s.GetAlbumsByArtist(It.IsAny())) + .Returns(new List() { + AlbumBuilder(), AlbumBuilder(), AlbumBuilder(), AlbumBuilder(), AlbumBuilder(), + AlbumBuilder(), AlbumBuilder(), AlbumBuilder(), AlbumBuilder(2), AlbumBuilder() }); } - private void GivenLastEpisode() + private void GivenLastAlbum() { - Mocker.GetMock().Setup( - s => s.GetEpisodesBySeason(It.IsAny(), It.IsAny())) - .Returns(new List() { - new Episode(), new Episode(), new Episode(), new Episode(), new Episode(), - new Episode(), new Episode(), new Episode(), new Episode(), new Episode() { Id = 2 } }); - } - - [TestCase(30, 50, false)] - [TestCase(30, 250, true)] - [TestCase(30, 500, false)] - [TestCase(60, 100, false)] - [TestCase(60, 500, true)] - [TestCase(60, 1000, false)] - public void single_episode(int runtime, int sizeInMegaBytes, bool expectedResult) - { - series.Runtime = runtime; - parseResultSingle.Series = series; - parseResultSingle.Release.Size = sizeInMegaBytes.Megabytes(); - - Subject.IsSatisfiedBy(parseResultSingle, null).Accepted.Should().Be(expectedResult); + Mocker.GetMock().Setup( + s => s.GetAlbumsByArtist(It.IsAny())) + .Returns(new List { + AlbumBuilder(), AlbumBuilder(), AlbumBuilder(), AlbumBuilder(), AlbumBuilder(), + AlbumBuilder(), AlbumBuilder(), AlbumBuilder(), AlbumBuilder(), AlbumBuilder(2) }); } - [TestCase(30, 500, true)] - [TestCase(30, 1000, false)] - [TestCase(60, 1000, true)] - [TestCase(60, 2000, false)] - public void single_episode_first_or_last(int runtime, int sizeInMegaBytes, bool expectedResult) + [TestCase(TWENTY_MINUTE_EP_MILLIS, 20, false)] + [TestCase(TWENTY_MINUTE_EP_MILLIS, 25, true)] + [TestCase(TWENTY_MINUTE_EP_MILLIS, 35, false)] + [TestCase(FORTY_FIVE_MINUTE_LP_MILLIS, 45, false)] + [TestCase(FORTY_FIVE_MINUTE_LP_MILLIS, 55, true)] + [TestCase(FORTY_FIVE_MINUTE_LP_MILLIS, 75, false)] + public void single_album(int runtime, int sizeInMegaBytes, bool expectedResult) { - GivenLastEpisode(); - - series.Runtime = runtime; - parseResultSingle.Series = series; + parseResultSingle.Albums.Select(c => { c.AlbumReleases.Value[0].Duration = runtime; return c; }).ToList(); + parseResultSingle.Artist = artist; parseResultSingle.Release.Size = sizeInMegaBytes.Megabytes(); Subject.IsSatisfiedBy(parseResultSingle, null).Accepted.Should().Be(expectedResult); } - [TestCase(30, 50 * 2, false)] - [TestCase(30, 250 * 2, true)] - [TestCase(30, 500 * 2, false)] - [TestCase(60, 100 * 2, false)] - [TestCase(60, 500 * 2, true)] - [TestCase(60, 1000 * 2, false)] - public void multi_episode(int runtime, int sizeInMegaBytes, bool expectedResult) + [TestCase(TWENTY_MINUTE_EP_MILLIS, 20 * 2, false)] + [TestCase(TWENTY_MINUTE_EP_MILLIS, 25 * 2, true)] + [TestCase(TWENTY_MINUTE_EP_MILLIS, 35 * 2, false)] + [TestCase(FORTY_FIVE_MINUTE_LP_MILLIS, 45 * 2, false)] + [TestCase(FORTY_FIVE_MINUTE_LP_MILLIS, 55 * 2, true)] + [TestCase(FORTY_FIVE_MINUTE_LP_MILLIS, 75 * 2, false)] + public void multi_album(int runtime, int sizeInMegaBytes, bool expectedResult) { - series.Runtime = runtime; - parseResultMulti.Series = series; + parseResultMulti.Albums.Select(c => { c.AlbumReleases.Value[0].Duration = runtime; return c; }).ToList(); + parseResultMulti.Artist = artist; parseResultMulti.Release.Size = sizeInMegaBytes.Megabytes(); Subject.IsSatisfiedBy(parseResultMulti, null).Accepted.Should().Be(expectedResult); } - [TestCase(30, 50 * 6, false)] - [TestCase(30, 250 * 6, true)] - [TestCase(30, 500 * 6, false)] - [TestCase(60, 100 * 6, false)] - [TestCase(60, 500 * 6, true)] - [TestCase(60, 1000 * 6, false)] - public void multiset_episode(int runtime, int sizeInMegaBytes, bool expectedResult) + [TestCase(TWENTY_MINUTE_EP_MILLIS, 20 * 6, false)] + [TestCase(TWENTY_MINUTE_EP_MILLIS, 25 * 6, true)] + [TestCase(TWENTY_MINUTE_EP_MILLIS, 35 * 6, false)] + [TestCase(FORTY_FIVE_MINUTE_LP_MILLIS, 45 * 6, false)] + [TestCase(FORTY_FIVE_MINUTE_LP_MILLIS, 55 * 6, true)] + [TestCase(FORTY_FIVE_MINUTE_LP_MILLIS, 75 * 6, false)] + public void multiset_album(int runtime, int sizeInMegaBytes, bool expectedResult) { - series.Runtime = runtime; - parseResultMultiSet.Series = series; + parseResultMultiSet.Albums.Select(c => { c.AlbumReleases.Value[0].Duration = runtime; return c; }).ToList(); + parseResultMultiSet.Artist = artist; parseResultMultiSet.Release.Size = sizeInMegaBytes.Megabytes(); Subject.IsSatisfiedBy(parseResultMultiSet, null).Accepted.Should().Be(expectedResult); @@ -144,77 +146,39 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [Test] public void should_return_true_if_size_is_zero() { - GivenLastEpisode(); - - series.Runtime = 30; - parseResultSingle.Series = series; + GivenLastAlbum(); + parseResultSingle.Albums.Select(c => { c.AlbumReleases.Value[0].Duration = TWENTY_MINUTE_EP_MILLIS; return c; }).ToList(); + parseResultSingle.Artist = artist; parseResultSingle.Release.Size = 0; - qualityType.MinSize = 10; - qualityType.MaxSize = 20; + qualityType.MinSize = 150; + qualityType.MaxSize = 210; Subject.IsSatisfiedBy(parseResultSingle, null).Accepted.Should().BeTrue(); } [Test] - public void should_return_true_if_unlimited_30_minute() + public void should_return_true_if_unlimited_20_minute() { - GivenLastEpisode(); - - series.Runtime = 30; - parseResultSingle.Series = series; - parseResultSingle.Release.Size = 18457280000; + GivenLastAlbum(); + parseResultSingle.Albums.Select(c => { c.AlbumReleases.Value[0].Duration = TWENTY_MINUTE_EP_MILLIS; return c; }).ToList(); + parseResultSingle.Artist = artist; + parseResultSingle.Release.Size = (HIGH_KBPS_BITRATE * 128) * (TWENTY_MINUTE_EP_MILLIS / 1000); qualityType.MaxSize = null; Subject.IsSatisfiedBy(parseResultSingle, null).Accepted.Should().BeTrue(); } [Test] - public void should_return_true_if_unlimited_60_minute() + public void should_return_true_if_unlimited_45_minute() { - GivenLastEpisode(); - - series.Runtime = 60; - parseResultSingle.Series = series; - parseResultSingle.Release.Size = 36857280000; + GivenLastAlbum(); + parseResultSingle.Albums.Select(c => { c.AlbumReleases.Value[0].Duration = FORTY_FIVE_MINUTE_LP_MILLIS; return c; }).ToList(); + parseResultSingle.Artist = artist; + parseResultSingle.Release.Size = (HIGH_KBPS_BITRATE * 128) * (FORTY_FIVE_MINUTE_LP_MILLIS / 1000); qualityType.MaxSize = null; Subject.IsSatisfiedBy(parseResultSingle, null).Accepted.Should().BeTrue(); } - [Test] - public void should_treat_daily_series_as_single_episode() - { - GivenLastEpisode(); - - series.Runtime = 60; - parseResultSingle.Series = series; - parseResultSingle.Series.SeriesType = SeriesTypes.Daily; - parseResultSingle.Release.Size = 300.Megabytes(); - - qualityType.MaxSize = 10; - - Subject.IsSatisfiedBy(parseResultSingle, null).Accepted.Should().BeTrue(); - } - - [Test] - public void should_return_true_if_RAWHD() - { - parseResultSingle.ParsedEpisodeInfo.Quality = new QualityModel(Quality.RAWHD); - - series.Runtime = 45; - parseResultSingle.Series = series; - parseResultSingle.Series.SeriesType = SeriesTypes.Daily; - parseResultSingle.Release.Size = 8000.Megabytes(); - - Subject.IsSatisfiedBy(parseResultSingle, null).Accepted.Should().BeTrue(); - } - - [Test] - public void should_return_true_for_special() - { - parseResultSingle.ParsedEpisodeInfo.Special = true; - - Subject.IsSatisfiedBy(parseResultSingle, null).Accepted.Should().BeTrue(); - } } } diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/AlreadyImportedSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/AlreadyImportedSpecificationFixture.cs new file mode 100644 index 000000000..8342e79e7 --- /dev/null +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/AlreadyImportedSpecificationFixture.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.History; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Music; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.MediaFiles; + +namespace NzbDrone.Core.Test.DecisionEngineTests +{ + [TestFixture] + public class AlreadyImportedSpecificationFixture : CoreTest + { + private const int FIRST_ALBUM_ID = 1; + private const string TITLE = "Some.Artist-Some.Album-2018-320kbps-CD-Lidarr"; + + private Artist _artist; + private QualityModel _mp3; + private QualityModel _flac; + private RemoteAlbum _remoteAlbum; + private List _history; + private TrackFile _firstFile; + + [SetUp] + public void Setup() + { + var singleAlbumList = new List + { + new Album + { + Id = FIRST_ALBUM_ID, + Title = "Some Album" + } + }; + + _artist = Builder.CreateNew() + .Build(); + + _firstFile = new TrackFile { Quality = new QualityModel(Quality.FLAC, new Revision(version: 2)), DateAdded = DateTime.Now }; + + _mp3 = new QualityModel(Quality.MP3_320, new Revision(version: 1)); + _flac = new QualityModel(Quality.FLAC, new Revision(version: 1)); + + _remoteAlbum = new RemoteAlbum + { + Artist = _artist, + ParsedAlbumInfo = new ParsedAlbumInfo { Quality = _mp3 }, + Albums = singleAlbumList, + Release = Builder.CreateNew() + .Build() + }; + + _history = new List(); + + Mocker.GetMock() + .SetupGet(s => s.EnableCompletedDownloadHandling) + .Returns(true); + + Mocker.GetMock() + .Setup(s => s.GetByAlbum(It.IsAny(), null)) + .Returns(_history); + + Mocker.GetMock() + .Setup(c => c.GetFilesByAlbum(It.IsAny())) + .Returns(new List { _firstFile }); + } + + private void GivenCdhDisabled() + { + Mocker.GetMock() + .SetupGet(s => s.EnableCompletedDownloadHandling) + .Returns(false); + } + + private void GivenHistoryItem(string downloadId, string sourceTitle, QualityModel quality, HistoryEventType eventType) + { + _history.Add(new History.History + { + DownloadId = downloadId, + SourceTitle = sourceTitle, + Quality = quality, + Date = DateTime.UtcNow, + EventType = eventType + }); + } + + [Test] + public void should_be_accepted_if_CDH_is_disabled() + { + GivenCdhDisabled(); + + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_accepted_if_album_does_not_have_a_file() + { + Mocker.GetMock() + .Setup(c => c.GetFilesByAlbum(It.IsAny())) + .Returns(new List { }); + + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_accepted_if_album_does_not_have_grabbed_event() + { + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_accepted_if_album_does_not_have_imported_event() + { + GivenHistoryItem(Guid.NewGuid().ToString().ToUpper(), TITLE, _mp3, HistoryEventType.Grabbed); + + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_accepted_if_grabbed_and_imported_quality_is_the_same() + { + var downloadId = Guid.NewGuid().ToString().ToUpper(); + + GivenHistoryItem(downloadId, TITLE, _mp3, HistoryEventType.Grabbed); + GivenHistoryItem(downloadId, TITLE, _mp3, HistoryEventType.DownloadImported); + + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_rejected_if_grabbed_download_id_matches_release_torrent_hash() + { + var downloadId = Guid.NewGuid().ToString().ToUpper(); + + GivenHistoryItem(downloadId, TITLE, _mp3, HistoryEventType.Grabbed); + GivenHistoryItem(downloadId, TITLE, _flac, HistoryEventType.DownloadImported); + + _remoteAlbum.Release = Builder.CreateNew() + .With(t => t.DownloadProtocol = DownloadProtocol.Torrent) + .With(t => t.InfoHash = downloadId) + .Build(); + + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); + } + + [Test] + public void should_be_accepted_if_release_torrent_hash_is_null() + { + var downloadId = Guid.NewGuid().ToString().ToUpper(); + + GivenHistoryItem(downloadId, TITLE, _mp3, HistoryEventType.Grabbed); + GivenHistoryItem(downloadId, TITLE, _flac, HistoryEventType.DownloadImported); + + _remoteAlbum.Release = Builder.CreateNew() + .With(t => t.DownloadProtocol = DownloadProtocol.Torrent) + .With(t => t.InfoHash = null) + .Build(); + + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_accepted_if_release_torrent_hash_is_null_and_downloadId_is_null() + { + GivenHistoryItem(null, TITLE, _mp3, HistoryEventType.Grabbed); + GivenHistoryItem(null, TITLE, _flac, HistoryEventType.DownloadImported); + + _remoteAlbum.Release = Builder.CreateNew() + .With(t => t.DownloadProtocol = DownloadProtocol.Torrent) + .With(t => t.InfoHash = null) + .Build(); + + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_rejected_if_release_title_matches_grabbed_event_source_title() + { + var downloadId = Guid.NewGuid().ToString().ToUpper(); + + GivenHistoryItem(downloadId, TITLE, _mp3, HistoryEventType.Grabbed); + GivenHistoryItem(downloadId, TITLE, _flac, HistoryEventType.DownloadImported); + + _remoteAlbum.Release = Builder.CreateNew() + .With(t => t.DownloadProtocol = DownloadProtocol.Torrent) + .With(t => t.InfoHash = downloadId) + .Build(); + + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); + } + } +} diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/AnimeVersionUpgradeSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/AnimeVersionUpgradeSpecificationFixture.cs deleted file mode 100644 index 2a555a186..000000000 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/AnimeVersionUpgradeSpecificationFixture.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System.Linq; -using FizzWare.NBuilder; -using FluentAssertions; -using Marr.Data; -using NUnit.Framework; -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.DecisionEngine.Specifications; -using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Qualities; -using NzbDrone.Core.Tv; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.DecisionEngineTests -{ - [TestFixture] - public class AnimeVersionUpgradeSpecificationFixture : CoreTest - { - private AnimeVersionUpgradeSpecification _subject; - private RemoteEpisode _remoteEpisode; - private EpisodeFile _episodeFile; - - [SetUp] - public void Setup() - { - Mocker.Resolve(); - _subject = Mocker.Resolve(); - - _episodeFile = new EpisodeFile - { - Quality = new QualityModel(Quality.HDTV720p, new Revision()), - ReleaseGroup = "DRONE2" - }; - - _remoteEpisode = new RemoteEpisode(); - _remoteEpisode.Series = new Series { SeriesType = SeriesTypes.Anime }; - _remoteEpisode.ParsedEpisodeInfo = new ParsedEpisodeInfo - { - Quality = new QualityModel(Quality.HDTV720p, new Revision(2)), - ReleaseGroup = "DRONE" - }; - - _remoteEpisode.Episodes = Builder.CreateListOfSize(1) - .All() - .With(e => e.EpisodeFile = new LazyLoaded(_episodeFile)) - .Build() - .ToList(); - } - - private void GivenStandardSeries() - { - _remoteEpisode.Series.SeriesType = SeriesTypes.Standard; - } - - private void GivenNoVersionUpgrade() - { - _remoteEpisode.ParsedEpisodeInfo.Quality.Revision = new Revision(); - } - - [Test] - public void should_be_true_when_no_existing_file() - { - _remoteEpisode.Episodes.First().EpisodeFileId = 0; - - _subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); - } - - [Test] - public void should_be_true_if_series_is_not_anime() - { - GivenStandardSeries(); - _subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); - } - - [Test] - public void should_be_true_if_is_not_a_version_upgrade_for_existing_file() - { - GivenNoVersionUpgrade(); - _subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); - } - - [Test] - public void should_be_true_when_release_group_matches() - { - _episodeFile.ReleaseGroup = _remoteEpisode.ParsedEpisodeInfo.ReleaseGroup; - - _subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); - } - - [Test] - public void should_be_false_when_existing_file_doesnt_have_a_release_group() - { - _episodeFile.ReleaseGroup = string.Empty; - _subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); - } - - [Test] - public void should_should_be_false_when_release_doesnt_have_a_release_group() - { - _remoteEpisode.ParsedEpisodeInfo.ReleaseGroup = string.Empty; - _subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); - } - - [Test] - public void should_be_false_when_release_group_does_not_match() - { - _subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/BlockedIndexerSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/BlockedIndexerSpecificationFixture.cs new file mode 100644 index 000000000..e5f872c0d --- /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 RemoteAlbum _remoteAlbum; + + [SetUp] + public void Setup() + { + _remoteAlbum = new RemoteAlbum + { + 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(_remoteAlbum, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_false_if_blocked_indexer() + { + WithBlockedIndexer(); + + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); + Subject.Type.Should().Be(RejectionType.Temporary); + } + } +} diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/CutoffSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/CutoffSpecificationFixture.cs index e038ba82c..66b8df8aa 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/CutoffSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/CutoffSpecificationFixture.cs @@ -1,50 +1,123 @@ -using FluentAssertions; +using FluentAssertions; using NUnit.Framework; -using NzbDrone.Core.Profiles; +using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Qualities; -using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Test.Framework; +using System.Collections.Generic; namespace NzbDrone.Core.Test.DecisionEngineTests { [TestFixture] - public class CutoffSpecificationFixture : CoreTest + public class CutoffSpecificationFixture : CoreTest { + private static readonly int NoPreferredWordScore = 0; + [Test] - public void should_return_true_if_current_episode_is_less_than_cutoff() + public void should_return_true_if_current_album_is_less_than_cutoff() { - Subject.CutoffNotMet(new Profile { Cutoff = Quality.Bluray1080p, Items = Qualities.QualityFixture.GetDefaultQualities() }, - new QualityModel(Quality.DVD, new Revision(version: 2))).Should().BeTrue(); + Subject.CutoffNotMet( + new QualityProfile + + { + Cutoff = Quality.MP3_256.Id, + Items = Qualities.QualityFixture.GetDefaultQualities() + }, + new List { new QualityModel(Quality.MP3_192, new Revision(version: 2)) }, + NoPreferredWordScore).Should().BeTrue(); } [Test] - public void should_return_false_if_current_episode_is_equal_to_cutoff() + public void should_return_false_if_current_album_is_equal_to_cutoff() { - Subject.CutoffNotMet(new Profile { Cutoff = Quality.HDTV720p, Items = Qualities.QualityFixture.GetDefaultQualities() }, - new QualityModel(Quality.HDTV720p, new Revision(version: 2))).Should().BeFalse(); + Subject.CutoffNotMet( + new QualityProfile + { + Cutoff = Quality.MP3_256.Id, + Items = Qualities.QualityFixture.GetDefaultQualities() + }, + new List { new QualityModel(Quality.MP3_256, new Revision(version: 2)) }, + NoPreferredWordScore).Should().BeFalse(); } [Test] - public void should_return_false_if_current_episode_is_greater_than_cutoff() + public void should_return_false_if_current_album_is_greater_than_cutoff() { - Subject.CutoffNotMet(new Profile { Cutoff = Quality.HDTV720p, Items = Qualities.QualityFixture.GetDefaultQualities() }, - new QualityModel(Quality.Bluray1080p, new Revision(version: 2))).Should().BeFalse(); + Subject.CutoffNotMet( + new QualityProfile + + { + Cutoff = Quality.MP3_256.Id, + Items = Qualities.QualityFixture.GetDefaultQualities() + }, + new List { new QualityModel(Quality.MP3_320, new Revision(version: 2)) }, + NoPreferredWordScore).Should().BeFalse(); } [Test] - public void should_return_true_when_new_episode_is_proper_but_existing_is_not() + public void should_return_true_when_new_album_is_proper_but_existing_is_not() { - Subject.CutoffNotMet(new Profile { Cutoff = Quality.HDTV720p, Items = Qualities.QualityFixture.GetDefaultQualities() }, - new QualityModel(Quality.HDTV720p, new Revision(version: 1)), - new QualityModel(Quality.HDTV720p, new Revision(version: 2))).Should().BeTrue(); + Subject.CutoffNotMet( + new QualityProfile + + { + Cutoff = Quality.MP3_320.Id, + Items = Qualities.QualityFixture.GetDefaultQualities() + }, + new List { new QualityModel(Quality.MP3_320, new Revision(version: 1)) }, + NoPreferredWordScore, + new QualityModel(Quality.MP3_320, new Revision(version: 2))).Should().BeTrue(); + } [Test] public void should_return_false_if_cutoff_is_met_and_quality_is_higher() { - Subject.CutoffNotMet(new Profile { Cutoff = Quality.HDTV720p, Items = Qualities.QualityFixture.GetDefaultQualities() }, - new QualityModel(Quality.HDTV720p, new Revision(version: 2)), - new QualityModel(Quality.Bluray1080p, new Revision(version: 2))).Should().BeFalse(); + Subject.CutoffNotMet( + new QualityProfile + + { + Cutoff = Quality.MP3_320.Id, + Items = Qualities.QualityFixture.GetDefaultQualities() + }, + new List { new QualityModel(Quality.MP3_320, new Revision(version: 2)) }, + NoPreferredWordScore, + new QualityModel(Quality.FLAC, new Revision(version: 2))).Should().BeFalse(); + } + + [Test] + public void should_return_true_if_cutoffs_are_met_and_score_is_higher() + { + QualityProfile _profile = new QualityProfile + { + Cutoff = Quality.MP3_320.Id, + Items = Qualities.QualityFixture.GetDefaultQualities(), + }; + + Subject.CutoffNotMet( + _profile, + new List { new QualityModel(Quality.MP3_320, new Revision(version: 2)) }, + NoPreferredWordScore, + new QualityModel(Quality.FLAC, new Revision(version: 2)), + 10).Should().BeTrue(); + } + + + [Test] + public void should_return_true_if_cutoffs_are_met_but_is_a_revision_upgrade() + { + QualityProfile _profile = new QualityProfile + { + Cutoff = Quality.MP3_320.Id, + Items = Qualities.QualityFixture.GetDefaultQualities(), + }; + + Subject.CutoffNotMet( + _profile, + new List { new QualityModel(Quality.FLAC, new Revision(version: 1)) }, + NoPreferredWordScore, + new QualityModel(Quality.FLAC, new Revision(version: 2)), + NoPreferredWordScore).Should().BeTrue(); } } } diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/DiscographySpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/DiscographySpecificationFixture.cs new file mode 100644 index 000000000..f12bbaf00 --- /dev/null +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/DiscographySpecificationFixture.cs @@ -0,0 +1,74 @@ +using System; +using NUnit.Framework; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using FizzWare.NBuilder; +using System.Linq; +using FluentAssertions; +using NzbDrone.Core.Music; +using Moq; +using System.Collections.Generic; + +namespace NzbDrone.Core.Test.DecisionEngineTests +{ + [TestFixture] + public class DiscographySpecificationFixture : CoreTest + { + private RemoteAlbum _remoteAlbum; + + [SetUp] + public void Setup() + { + var artist = Builder.CreateNew().With(s => s.Id = 1234).Build(); + _remoteAlbum = new RemoteAlbum + { + ParsedAlbumInfo = new ParsedAlbumInfo + { + Discography = true + }, + Albums = Builder.CreateListOfSize(3) + .All() + .With(e => e.ReleaseDate = DateTime.UtcNow.AddDays(-8)) + .With(s => s.ArtistId = artist.Id) + .BuildList(), + Artist = artist, + Release = new ReleaseInfo + { + Title = "Artist.Discography.1978.2005.FLAC-RlsGrp" + } + }; + + Mocker.GetMock().Setup(s => s.AlbumsBetweenDates(It.IsAny(), It.IsAny(), false)) + .Returns(new List()); + } + + [Test] + public void should_return_true_if_is_not_a_discography() + { + _remoteAlbum.ParsedAlbumInfo.Discography = false; + _remoteAlbum.Albums.Last().ReleaseDate = DateTime.UtcNow.AddDays(+2); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_true_if_all_albums_have_released() + { + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_false_if_one_album_has_not_released() + { + _remoteAlbum.Albums.Last().ReleaseDate = DateTime.UtcNow.AddDays(+2); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); + } + + [Test] + public void should_return_false_if_an_album_does_not_have_an_release_date() + { + _remoteAlbum.Albums.Last().ReleaseDate = null; + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); + } + } +} diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/DownloadDecisionMakerFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/DownloadDecisionMakerFixture.cs index 0206abbd2..8d8c0243f 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/DownloadDecisionMakerFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/DownloadDecisionMakerFixture.cs @@ -1,16 +1,18 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using FluentAssertions; using Moq; using NUnit.Framework; using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; using NzbDrone.Test.Common; using FizzWare.NBuilder; +using Marr.Data; namespace NzbDrone.Core.Test.DecisionEngineTests { @@ -18,7 +20,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests public class DownloadDecisionMakerFixture : CoreTest { private List _reports; - private RemoteEpisode _remoteEpisode; + private RemoteAlbum _remoteAlbum; private Mock _pass1; private Mock _pass2; @@ -28,6 +30,8 @@ namespace NzbDrone.Core.Test.DecisionEngineTests private Mock _fail2; private Mock _fail3; + private Mock _failDelayed1; + [SetUp] public void Setup() { @@ -39,23 +43,28 @@ namespace NzbDrone.Core.Test.DecisionEngineTests _fail2 = new Mock(); _fail3 = 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); + _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")); - - _reports = new List { new ReleaseInfo { Title = "The.Office.S03E115.DVDRip.XviD-OSiTV" } }; - _remoteEpisode = new RemoteEpisode { - Series = new Series(), - Episodes = new List { new Episode() } + _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 = "Coldplay-A Head Full Of Dreams-CD-FLAC-2015-PERFECT" } }; + _remoteAlbum = new RemoteAlbum { + Artist = new Artist(), + Albums = new List { new Album() } }; Mocker.GetMock() - .Setup(c => c.Map(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(_remoteEpisode); + .Setup(c => c.Map(It.IsAny(), It.IsAny())) + .Returns(_remoteAlbum); } private void GivenSpecifications(params Mock[] mocks) @@ -70,12 +79,31 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Subject.GetRssDecision(_reports).ToList(); - _fail1.Verify(c => c.IsSatisfiedBy(_remoteEpisode, null), Times.Once()); - _fail2.Verify(c => c.IsSatisfiedBy(_remoteEpisode, null), Times.Once()); - _fail3.Verify(c => c.IsSatisfiedBy(_remoteEpisode, null), Times.Once()); - _pass1.Verify(c => c.IsSatisfiedBy(_remoteEpisode, null), Times.Once()); - _pass2.Verify(c => c.IsSatisfiedBy(_remoteEpisode, null), Times.Once()); - _pass3.Verify(c => c.IsSatisfiedBy(_remoteEpisode, null), Times.Once()); + _fail1.Verify(c => c.IsSatisfiedBy(_remoteAlbum, null), Times.Once()); + _fail2.Verify(c => c.IsSatisfiedBy(_remoteAlbum, null), Times.Once()); + _fail3.Verify(c => c.IsSatisfiedBy(_remoteAlbum, null), Times.Once()); + _pass1.Verify(c => c.IsSatisfiedBy(_remoteAlbum, null), Times.Once()); + _pass2.Verify(c => c.IsSatisfiedBy(_remoteAlbum, null), Times.Once()); + _pass3.Verify(c => c.IsSatisfiedBy(_remoteAlbum, 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(_remoteAlbum, 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(_remoteAlbum, null), Times.Never()); } [Test] @@ -118,51 +146,51 @@ namespace NzbDrone.Core.Test.DecisionEngineTests } [Test] - public void should_not_attempt_to_map_episode_if_not_parsable() + public void should_not_attempt_to_map_album_if_not_parsable() { GivenSpecifications(_pass1, _pass2, _pass3); _reports[0].Title = "Not parsable"; var results = Subject.GetRssDecision(_reports).ToList(); - Mocker.GetMock().Verify(c => c.Map(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); + Mocker.GetMock().Verify(c => c.Map(It.IsAny(), It.IsAny()), Times.Never()); - _pass1.Verify(c => c.IsSatisfiedBy(It.IsAny(), null), Times.Never()); - _pass2.Verify(c => c.IsSatisfiedBy(It.IsAny(), null), Times.Never()); - _pass3.Verify(c => c.IsSatisfiedBy(It.IsAny(), null), Times.Never()); + _pass1.Verify(c => c.IsSatisfiedBy(It.IsAny(), null), Times.Never()); + _pass2.Verify(c => c.IsSatisfiedBy(It.IsAny(), null), Times.Never()); + _pass3.Verify(c => c.IsSatisfiedBy(It.IsAny(), null), Times.Never()); results.Should().BeEmpty(); } [Test] - public void should_not_attempt_to_map_episode_series_title_is_blank() + public void should_not_attempt_to_map_album_artist_title_is_blank() { GivenSpecifications(_pass1, _pass2, _pass3); - _reports[0].Title = "1937 - Snow White and the Seven Dwarves"; + _reports[0].Title = "2013 - Night Visions"; var results = Subject.GetRssDecision(_reports).ToList(); - Mocker.GetMock().Verify(c => c.Map(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); + Mocker.GetMock().Verify(c => c.Map(It.IsAny(), It.IsAny()), Times.Never()); - _pass1.Verify(c => c.IsSatisfiedBy(It.IsAny(), null), Times.Never()); - _pass2.Verify(c => c.IsSatisfiedBy(It.IsAny(), null), Times.Never()); - _pass3.Verify(c => c.IsSatisfiedBy(It.IsAny(), null), Times.Never()); + _pass1.Verify(c => c.IsSatisfiedBy(It.IsAny(), null), Times.Never()); + _pass2.Verify(c => c.IsSatisfiedBy(It.IsAny(), null), Times.Never()); + _pass3.Verify(c => c.IsSatisfiedBy(It.IsAny(), null), Times.Never()); results.Should().BeEmpty(); } [Test] - public void should_not_attempt_to_make_decision_if_series_is_unknown() + public void should_not_attempt_to_make_decision_if_artist_is_unknown() { GivenSpecifications(_pass1, _pass2, _pass3); - _remoteEpisode.Series = null; + _remoteAlbum.Artist = null; Subject.GetRssDecision(_reports); - _pass1.Verify(c => c.IsSatisfiedBy(It.IsAny(), null), Times.Never()); - _pass2.Verify(c => c.IsSatisfiedBy(It.IsAny(), null), Times.Never()); - _pass3.Verify(c => c.IsSatisfiedBy(It.IsAny(), null), Times.Never()); + _pass1.Verify(c => c.IsSatisfiedBy(It.IsAny(), null), Times.Never()); + _pass2.Verify(c => c.IsSatisfiedBy(It.IsAny(), null), Times.Never()); + _pass3.Verify(c => c.IsSatisfiedBy(It.IsAny(), null), Times.Never()); } [Test] @@ -170,29 +198,29 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { GivenSpecifications(_pass1); - Mocker.GetMock().Setup(c => c.Map(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + Mocker.GetMock().Setup(c => c.Map(It.IsAny(), It.IsAny())) .Throws(); _reports = new List { - new ReleaseInfo{Title = "The.Office.S03E115.DVDRip.XviD-OSiTV"}, - new ReleaseInfo{Title = "The.Office.S03E115.DVDRip.XviD-OSiTV"}, - new ReleaseInfo{Title = "The.Office.S03E115.DVDRip.XviD-OSiTV"} + new ReleaseInfo{Title = "Coldplay-A Head Full Of Dreams-CD-FLAC-2015-PERFECT"}, + new ReleaseInfo{Title = "Coldplay-A Head Full Of Dreams-CD-FLAC-2015-PERFECT"}, + new ReleaseInfo{Title = "Coldplay-A Head Full Of Dreams-CD-FLAC-2015-PERFECT"} }; Subject.GetRssDecision(_reports); - Mocker.GetMock().Verify(c => c.Map(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(_reports.Count)); + Mocker.GetMock().Verify(c => c.Map(It.IsAny(), It.IsAny()), Times.Exactly(_reports.Count)); ExceptionVerification.ExpectedErrors(3); } [Test] - public void should_return_unknown_series_rejection_if_series_is_unknown() + public void should_return_unknown_artist_rejection_if_artist_is_unknown() { GivenSpecifications(_pass1, _pass2, _pass3); - _remoteEpisode.Series = null; + _remoteAlbum.Artist = null; var result = Subject.GetRssDecision(_reports); @@ -200,40 +228,38 @@ namespace NzbDrone.Core.Test.DecisionEngineTests } [Test] - public void should_only_include_reports_for_requested_episodes() + public void should_only_include_reports_for_requested_albums() { - var series = Builder.CreateNew().Build(); + var artist = Builder.CreateNew().Build(); - var episodes = Builder.CreateListOfSize(2) + var albums = Builder.CreateListOfSize(2) .All() - .With(v => v.SeriesId, series.Id) - .With(v => v.Series, series) - .With(v => v.SeasonNumber, 1) - .With(v => v.SceneSeasonNumber, 2) + .With(v => v.ArtistId, artist.Id) + .With(v => v.Artist, new LazyLoaded(artist)) .BuildList(); - var criteria = new SeasonSearchCriteria { Episodes = episodes.Take(1).ToList(), SeasonNumber = 1 }; + var criteria = new ArtistSearchCriteria { Albums = albums.Take(1).ToList()}; - var reports = episodes.Select(v => + var reports = albums.Select(v => new ReleaseInfo() { - Title = string.Format("{0}.S{1:00}E{2:00}.720p.WEB-DL-DRONE", series.Title, v.SceneSeasonNumber, v.SceneEpisodeNumber) + Title = string.Format("{0}-{1}[FLAC][2017][DRONE]", artist.Name, v.Title) }).ToList(); Mocker.GetMock() - .Setup(v => v.Map(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns((p,tvdbid,tvrageid,c) => - new RemoteEpisode + .Setup(v => v.Map(It.IsAny(), It.IsAny())) + .Returns((p,c) => + new RemoteAlbum { DownloadAllowed = true, - ParsedEpisodeInfo = p, - Series = series, - Episodes = episodes.Where(v => v.SceneEpisodeNumber == p.EpisodeNumbers.First()).ToList() + ParsedAlbumInfo = p, + Artist = artist, + Albums = albums.Where(v => v.Title == p.AlbumTitle).ToList() }); Mocker.SetConstant>(new List { - Mocker.Resolve() + Mocker.Resolve() }); var decisions = Subject.GetSearchDecision(reports, criteria); @@ -244,31 +270,31 @@ namespace NzbDrone.Core.Test.DecisionEngineTests } [Test] - public void should_not_allow_download_if_series_is_unknown() + public void should_not_allow_download_if_artist_is_unknown() { GivenSpecifications(_pass1, _pass2, _pass3); - _remoteEpisode.Series = null; + _remoteAlbum.Artist = null; var result = Subject.GetRssDecision(_reports); result.Should().HaveCount(1); - result.First().RemoteEpisode.DownloadAllowed.Should().BeFalse(); + result.First().RemoteAlbum.DownloadAllowed.Should().BeFalse(); } [Test] - public void should_not_allow_download_if_no_episodes_found() + public void should_not_allow_download_if_no_albums_found() { GivenSpecifications(_pass1, _pass2, _pass3); - _remoteEpisode.Episodes = new List(); + _remoteAlbum.Albums = new List(); var result = Subject.GetRssDecision(_reports); result.Should().HaveCount(1); - result.First().RemoteEpisode.DownloadAllowed.Should().BeFalse(); + result.First().RemoteAlbum.DownloadAllowed.Should().BeFalse(); } [Test] @@ -276,12 +302,12 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { GivenSpecifications(_pass1); - Mocker.GetMock().Setup(c => c.Map(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + Mocker.GetMock().Setup(c => c.Map(It.IsAny(), It.IsAny())) .Throws(); _reports = new List { - new ReleaseInfo{Title = "The.Office.S03E115.DVDRip.XviD-OSiTV"}, + new ReleaseInfo{Title = "Alien Ant Farm - TruAnt (FLAC) DRONE"}, }; Subject.GetRssDecision(_reports).Should().HaveCount(1); @@ -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/EarlyReleaseSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/EarlyReleaseSpecificationFixture.cs new file mode 100644 index 000000000..7a49f3cc2 --- /dev/null +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/EarlyReleaseSpecificationFixture.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +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.Music; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.DecisionEngineTests +{ + [TestFixture] + public class EarlyReleaseSpecificationFixture : TestBase + { + private Artist _artist; + private Album _album1; + private Album _album2; + private RemoteAlbum _remoteAlbum; + private IndexerDefinition _indexerDefinition; + + [SetUp] + public void Setup() + { + _artist = Builder.CreateNew().With(s => s.Id = 1).Build(); + _album1 = Builder.CreateNew().With(s => s.ReleaseDate = DateTime.Today).Build(); + _album2 = Builder.CreateNew().With(s => s.ReleaseDate = DateTime.Today).Build(); + + _remoteAlbum = new RemoteAlbum + { + Artist = _artist, + Albums = new List{_album1}, + Release = new TorrentInfo + { + IndexerId = 1, + Title = "Artist - Album [FLAC-RlsGrp]", + PublishDate = DateTime.Today + } + }; + + _indexerDefinition = new IndexerDefinition + { + Settings = new TorrentRssIndexerSettings { EarlyReleaseLimit = 5 } + }; + + Mocker.GetMock() + .Setup(v => v.Get(1)) + .Returns(_indexerDefinition); + + } + + private void GivenPublishDateFromToday(int days) + { + (_remoteAlbum.Release).PublishDate = DateTime.Today.AddDays(days); + } + + [Test] + public void should_return_true_if_indexer_not_specified() + { + _remoteAlbum.Release.IndexerId = 0; + + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_true_if_release_contains_multiple_albums() + { + _remoteAlbum.Albums.Add(_album2); + + Subject.IsSatisfiedBy(_remoteAlbum, 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(_remoteAlbum, null).Accepted.Should().BeTrue(); + } + + [TestCase(-2)] + [TestCase(-5)] + public void should_return_true_if_publish_date_above_or_equal_to_limit(int days) + { + GivenPublishDateFromToday(days); + + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); + } + + [TestCase(-10)] + [TestCase(-20)] + public void should_return_false_if_publish_date_belove_limit(int days) + { + GivenPublishDateFromToday(days); + + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); + } + + [TestCase(-10)] + [TestCase(-100)] + public void should_return_true_if_limit_null(int days) + { + GivenPublishDateFromToday(days); + + _indexerDefinition.Settings = new TorrentRssIndexerSettings{EarlyReleaseLimit = null}; + + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); + } + } +} diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/FullSeasonSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/FullSeasonSpecificationFixture.cs deleted file mode 100644 index 6a66d957d..000000000 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/FullSeasonSpecificationFixture.cs +++ /dev/null @@ -1,75 +0,0 @@ - -using System; -using NUnit.Framework; -using NzbDrone.Core.DecisionEngine.Specifications; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Test.Framework; -using FizzWare.NBuilder; -using System.Linq; -using FluentAssertions; -using NzbDrone.Core.Tv; -using Moq; -using System.Collections.Generic; - -namespace NzbDrone.Core.Test.DecisionEngineTests -{ - [TestFixture] - public class FullSeasonSpecificationFixture : CoreTest - { - private RemoteEpisode _remoteEpisode; - - [SetUp] - public void Setup() - { - var show = Builder.CreateNew().With(s => s.Id = 1234).Build(); - _remoteEpisode = new RemoteEpisode - { - ParsedEpisodeInfo = new ParsedEpisodeInfo - { - FullSeason = true - }, - Episodes = Builder.CreateListOfSize(3) - .All() - .With(e => e.AirDateUtc = DateTime.UtcNow.AddDays(-8)) - .With(s => s.SeriesId = show.Id) - .BuildList(), - Series = show, - Release = new ReleaseInfo - { - Title = "Series.Title.S01.720p.BluRay.X264-RlsGrp" - } - }; - - Mocker.GetMock().Setup(s => s.EpisodesBetweenDates(It.IsAny(), It.IsAny(), false)) - .Returns(new List()); - } - - [Test] - public void should_return_true_if_is_not_a_full_season() - { - _remoteEpisode.ParsedEpisodeInfo.FullSeason = false; - _remoteEpisode.Episodes.Last().AirDateUtc = DateTime.UtcNow.AddDays(+2); - Mocker.Resolve().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(); - } - - [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(); - } - - [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(); - } - } -} diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/HistorySpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/HistorySpecificationFixture.cs index 25a10b498..2f28829ee 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/HistorySpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/HistorySpecificationFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using FizzWare.NBuilder; using FluentAssertions; @@ -9,12 +9,11 @@ using NzbDrone.Core.DecisionEngine.Specifications.RssSync; using NzbDrone.Core.History; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Profiles; using NzbDrone.Core.Qualities; -using NzbDrone.Core.Tv; -using NzbDrone.Core.DecisionEngine; - +using NzbDrone.Core.Music; +using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Profiles.Qualities; namespace NzbDrone.Core.Test.DecisionEngineTests { @@ -23,56 +22,61 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { private HistorySpecification _upgradeHistory; - private RemoteEpisode _parseResultMulti; - private RemoteEpisode _parseResultSingle; + private RemoteAlbum _parseResultMulti; + private RemoteAlbum _parseResultSingle; private QualityModel _upgradableQuality; private QualityModel _notupgradableQuality; - private Series _fakeSeries; - private const int FIRST_EPISODE_ID = 1; - private const int SECOND_EPISODE_ID = 2; + private Artist _fakeArtist; + private const int FIRST_ALBUM_ID = 1; + private const int SECOND_ALBUM_ID = 2; [SetUp] public void Setup() { - Mocker.Resolve(); + Mocker.Resolve(); _upgradeHistory = Mocker.Resolve(); - var singleEpisodeList = new List { new Episode { Id = FIRST_EPISODE_ID, SeasonNumber = 12, EpisodeNumber = 3 } }; - var doubleEpisodeList = new List { - new Episode {Id = FIRST_EPISODE_ID, SeasonNumber = 12, EpisodeNumber = 3 }, - new Episode {Id = SECOND_EPISODE_ID, SeasonNumber = 12, EpisodeNumber = 4 }, - new Episode {Id = 3, SeasonNumber = 12, EpisodeNumber = 5 } + var singleAlbumList = new List { new Album { Id = FIRST_ALBUM_ID } }; + var doubleAlbumList = new List { + new Album {Id = FIRST_ALBUM_ID }, + new Album {Id = SECOND_ALBUM_ID }, + new Album {Id = 3 } }; - _fakeSeries = Builder.CreateNew() - .With(c => c.Profile = new Profile { Cutoff = Quality.Bluray1080p, Items = Qualities.QualityFixture.GetDefaultQualities() }) - .Build(); + _fakeArtist = Builder.CreateNew() + .With(c => c.QualityProfile = new QualityProfile + { + UpgradeAllowed = true, + Cutoff = Quality.MP3_320.Id, + Items = Qualities.QualityFixture.GetDefaultQualities() + }) + .Build(); - _parseResultMulti = new RemoteEpisode + _parseResultMulti = new RemoteAlbum { - Series = _fakeSeries, - ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.DVD, new Revision(version: 2)) }, - Episodes = doubleEpisodeList + Artist = _fakeArtist, + ParsedAlbumInfo = new ParsedAlbumInfo { Quality = new QualityModel(Quality.MP3_192, new Revision(version: 2)) }, + Albums = doubleAlbumList }; - _parseResultSingle = new RemoteEpisode + _parseResultSingle = new RemoteAlbum { - Series = _fakeSeries, - ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.DVD, new Revision(version: 2)) }, - Episodes = singleEpisodeList + Artist = _fakeArtist, + ParsedAlbumInfo = new ParsedAlbumInfo { Quality = new QualityModel(Quality.MP3_192, new Revision(version: 2)) }, + Albums = singleAlbumList }; - _upgradableQuality = new QualityModel(Quality.SDTV, new Revision(version: 1)); - _notupgradableQuality = new QualityModel(Quality.HDTV1080p, new Revision(version: 2)); + _upgradableQuality = new QualityModel(Quality.MP3_192, new Revision(version: 1)); + _notupgradableQuality = new QualityModel(Quality.MP3_320, new Revision(version: 2)); Mocker.GetMock() .SetupGet(s => s.EnableCompletedDownloadHandling) .Returns(true); } - private void GivenMostRecentForEpisode(int episodeId, string downloadId, QualityModel quality, DateTime date, HistoryEventType eventType) + private void GivenMostRecentForAlbum(int albumId, string downloadId, QualityModel quality, DateTime date, HistoryEventType eventType) { - Mocker.GetMock().Setup(s => s.MostRecentForEpisode(episodeId)) + Mocker.GetMock().Setup(s => s.MostRecentForAlbum(albumId)) .Returns(new History.History { DownloadId = downloadId, Quality = quality, Date = date, EventType = eventType }); } @@ -86,20 +90,20 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [Test] public void should_return_true_if_it_is_a_search() { - _upgradeHistory.IsSatisfiedBy(_parseResultMulti, new SeasonSearchCriteria()).Accepted.Should().BeTrue(); + _upgradeHistory.IsSatisfiedBy(_parseResultMulti, new AlbumSearchCriteria()).Accepted.Should().BeTrue(); } [Test] public void should_return_true_if_latest_history_item_is_null() { - Mocker.GetMock().Setup(s => s.MostRecentForEpisode(It.IsAny())).Returns((History.History)null); + Mocker.GetMock().Setup(s => s.MostRecentForAlbum(It.IsAny())).Returns((History.History)null); _upgradeHistory.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeTrue(); } [Test] public void should_return_true_if_latest_history_item_is_not_grabbed() { - GivenMostRecentForEpisode(FIRST_EPISODE_ID, string.Empty, _notupgradableQuality, DateTime.UtcNow, HistoryEventType.DownloadFailed); + GivenMostRecentForAlbum(FIRST_ALBUM_ID, string.Empty, _notupgradableQuality, DateTime.UtcNow, HistoryEventType.DownloadFailed); _upgradeHistory.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeTrue(); } @@ -113,57 +117,57 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [Test] public void should_return_true_if_latest_history_item_is_older_than_twelve_hours() { - GivenMostRecentForEpisode(FIRST_EPISODE_ID, string.Empty, _notupgradableQuality, DateTime.UtcNow.AddHours(-13), HistoryEventType.Grabbed); + GivenMostRecentForAlbum(FIRST_ALBUM_ID, string.Empty, _notupgradableQuality, DateTime.UtcNow.AddHours(-12).AddMilliseconds(-1), HistoryEventType.Grabbed); _upgradeHistory.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeTrue(); } [Test] - public void should_be_upgradable_if_only_episode_is_upgradable() + public void should_be_upgradable_if_only_album_is_upgradable() { - GivenMostRecentForEpisode(FIRST_EPISODE_ID, string.Empty, _upgradableQuality, DateTime.UtcNow, HistoryEventType.Grabbed); + GivenMostRecentForAlbum(FIRST_ALBUM_ID, string.Empty, _upgradableQuality, DateTime.UtcNow, HistoryEventType.Grabbed); _upgradeHistory.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue(); } [Test] - public void should_be_upgradable_if_both_episodes_are_upgradable() + public void should_be_upgradable_if_both_albums_are_upgradable() { - GivenMostRecentForEpisode(FIRST_EPISODE_ID, string.Empty, _upgradableQuality, DateTime.UtcNow, HistoryEventType.Grabbed); - GivenMostRecentForEpisode(SECOND_EPISODE_ID, string.Empty, _upgradableQuality, DateTime.UtcNow, HistoryEventType.Grabbed); + GivenMostRecentForAlbum(FIRST_ALBUM_ID, string.Empty, _upgradableQuality, DateTime.UtcNow, HistoryEventType.Grabbed); + GivenMostRecentForAlbum(SECOND_ALBUM_ID, string.Empty, _upgradableQuality, DateTime.UtcNow, HistoryEventType.Grabbed); _upgradeHistory.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeTrue(); } [Test] - public void should_not_be_upgradable_if_both_episodes_are_not_upgradable() + public void should_not_be_upgradable_if_both_albums_are_not_upgradable() { - GivenMostRecentForEpisode(FIRST_EPISODE_ID, string.Empty, _notupgradableQuality, DateTime.UtcNow, HistoryEventType.Grabbed); - GivenMostRecentForEpisode(SECOND_EPISODE_ID, string.Empty, _notupgradableQuality, DateTime.UtcNow, HistoryEventType.Grabbed); + GivenMostRecentForAlbum(FIRST_ALBUM_ID, string.Empty, _notupgradableQuality, DateTime.UtcNow, HistoryEventType.Grabbed); + GivenMostRecentForAlbum(SECOND_ALBUM_ID, string.Empty, _notupgradableQuality, DateTime.UtcNow, HistoryEventType.Grabbed); _upgradeHistory.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeFalse(); } [Test] - public void should_be_not_upgradable_if_only_first_episodes_is_upgradable() + public void should_be_not_upgradable_if_only_first_albums_is_upgradable() { - GivenMostRecentForEpisode(FIRST_EPISODE_ID, string.Empty, _upgradableQuality, DateTime.UtcNow, HistoryEventType.Grabbed); - GivenMostRecentForEpisode(FIRST_EPISODE_ID, string.Empty, _notupgradableQuality, DateTime.UtcNow, HistoryEventType.Grabbed); + GivenMostRecentForAlbum(FIRST_ALBUM_ID, string.Empty, _upgradableQuality, DateTime.UtcNow, HistoryEventType.Grabbed); + GivenMostRecentForAlbum(FIRST_ALBUM_ID, string.Empty, _notupgradableQuality, DateTime.UtcNow, HistoryEventType.Grabbed); _upgradeHistory.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeFalse(); } [Test] - public void should_be_not_upgradable_if_only_second_episodes_is_upgradable() + public void should_be_not_upgradable_if_only_second_albums_is_upgradable() { - GivenMostRecentForEpisode(FIRST_EPISODE_ID, string.Empty, _notupgradableQuality, DateTime.UtcNow, HistoryEventType.Grabbed); - GivenMostRecentForEpisode(SECOND_EPISODE_ID, string.Empty, _upgradableQuality, DateTime.UtcNow, HistoryEventType.Grabbed); + GivenMostRecentForAlbum(FIRST_ALBUM_ID, string.Empty, _notupgradableQuality, DateTime.UtcNow, HistoryEventType.Grabbed); + GivenMostRecentForAlbum(SECOND_ALBUM_ID, string.Empty, _upgradableQuality, DateTime.UtcNow, HistoryEventType.Grabbed); _upgradeHistory.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeFalse(); } [Test] - public void should_not_be_upgradable_if_episode_is_of_same_quality_as_existing() + public void should_not_be_upgradable_if_album_is_of_same_quality_as_existing() { - _fakeSeries.Profile = new Profile { Cutoff = Quality.Bluray1080p, Items = Qualities.QualityFixture.GetDefaultQualities() }; - _parseResultSingle.ParsedEpisodeInfo.Quality = new QualityModel(Quality.WEBDL1080p, new Revision(version: 1)); - _upgradableQuality = new QualityModel(Quality.WEBDL1080p, new Revision(version: 1)); + _fakeArtist.QualityProfile = new QualityProfile { Cutoff = Quality.MP3_320.Id, Items = Qualities.QualityFixture.GetDefaultQualities() }; + _parseResultSingle.ParsedAlbumInfo.Quality = new QualityModel(Quality.MP3_320, new Revision(version: 1)); + _upgradableQuality = new QualityModel(Quality.MP3_320, new Revision(version: 1)); - GivenMostRecentForEpisode(FIRST_EPISODE_ID, string.Empty, _upgradableQuality, DateTime.UtcNow, HistoryEventType.Grabbed); + GivenMostRecentForAlbum(FIRST_ALBUM_ID, string.Empty, _upgradableQuality, DateTime.UtcNow, HistoryEventType.Grabbed); _upgradeHistory.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeFalse(); } @@ -171,11 +175,11 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [Test] public void should_not_be_upgradable_if_cutoff_already_met() { - _fakeSeries.Profile = new Profile { Cutoff = Quality.WEBDL1080p, Items = Qualities.QualityFixture.GetDefaultQualities() }; - _parseResultSingle.ParsedEpisodeInfo.Quality = new QualityModel(Quality.WEBDL1080p, new Revision(version: 1)); - _upgradableQuality = new QualityModel(Quality.Bluray1080p, new Revision(version: 1)); + _fakeArtist.QualityProfile = new QualityProfile { Cutoff = Quality.MP3_320.Id, Items = Qualities.QualityFixture.GetDefaultQualities() }; + _parseResultSingle.ParsedAlbumInfo.Quality = new QualityModel(Quality.MP3_320, new Revision(version: 1)); + _upgradableQuality = new QualityModel(Quality.MP3_320, new Revision(version: 1)); - GivenMostRecentForEpisode(FIRST_EPISODE_ID, string.Empty, _upgradableQuality, DateTime.UtcNow, HistoryEventType.Grabbed); + GivenMostRecentForAlbum(FIRST_ALBUM_ID, string.Empty, _upgradableQuality, DateTime.UtcNow, HistoryEventType.Grabbed); _upgradeHistory.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeFalse(); } @@ -183,7 +187,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [Test] public void should_return_false_if_latest_history_item_is_only_one_hour_old() { - GivenMostRecentForEpisode(FIRST_EPISODE_ID, string.Empty, _notupgradableQuality, DateTime.UtcNow.AddHours(-1), HistoryEventType.Grabbed); + GivenMostRecentForAlbum(FIRST_ALBUM_ID, string.Empty, _notupgradableQuality, DateTime.UtcNow.AddHours(-1), HistoryEventType.Grabbed); _upgradeHistory.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeFalse(); } @@ -191,7 +195,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests public void should_return_false_if_latest_history_has_a_download_id_and_cdh_is_disabled() { GivenCdhDisabled(); - GivenMostRecentForEpisode(FIRST_EPISODE_ID, "test", _upgradableQuality, DateTime.UtcNow.AddDays(-100), HistoryEventType.Grabbed); + GivenMostRecentForAlbum(FIRST_ALBUM_ID, "test", _upgradableQuality, DateTime.UtcNow.AddDays(-100), HistoryEventType.Grabbed); _upgradeHistory.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeTrue(); } @@ -199,20 +203,20 @@ namespace NzbDrone.Core.Test.DecisionEngineTests public void should_return_false_if_cutoff_already_met_and_cdh_is_disabled() { GivenCdhDisabled(); - _fakeSeries.Profile = new Profile { Cutoff = Quality.WEBDL1080p, Items = Qualities.QualityFixture.GetDefaultQualities() }; - _parseResultSingle.ParsedEpisodeInfo.Quality = new QualityModel(Quality.Bluray1080p, new Revision(version: 1)); - _upgradableQuality = new QualityModel(Quality.WEBDL1080p, new Revision(version: 1)); + _fakeArtist.QualityProfile = new QualityProfile { Cutoff = Quality.MP3_320.Id, Items = Qualities.QualityFixture.GetDefaultQualities() }; + _parseResultSingle.ParsedAlbumInfo.Quality = new QualityModel(Quality.MP3_320, new Revision(version: 1)); + _upgradableQuality = new QualityModel(Quality.MP3_320, new Revision(version: 1)); - GivenMostRecentForEpisode(FIRST_EPISODE_ID, "test", _upgradableQuality, DateTime.UtcNow.AddDays(-100), HistoryEventType.Grabbed); + GivenMostRecentForAlbum(FIRST_ALBUM_ID, "test", _upgradableQuality, DateTime.UtcNow.AddDays(-100), HistoryEventType.Grabbed); _upgradeHistory.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeFalse(); } [Test] - public void should_return_false_if_only_episode_is_not_upgradable_and_cdh_is_disabled() + public void should_return_false_if_only_album_is_not_upgradable_and_cdh_is_disabled() { GivenCdhDisabled(); - GivenMostRecentForEpisode(FIRST_EPISODE_ID, "test", _notupgradableQuality, DateTime.UtcNow.AddDays(-100), HistoryEventType.Grabbed); + GivenMostRecentForAlbum(FIRST_ALBUM_ID, "test", _notupgradableQuality, DateTime.UtcNow.AddDays(-100), HistoryEventType.Grabbed); _upgradeHistory.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeFalse(); } } diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/LanguageSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/LanguageSpecificationFixture.cs deleted file mode 100644 index f190677c3..000000000 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/LanguageSpecificationFixture.cs +++ /dev/null @@ -1,64 +0,0 @@ -using FluentAssertions; -using Marr.Data; -using NUnit.Framework; -using NzbDrone.Core.DecisionEngine.Specifications; -using NzbDrone.Core.Parser; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Profiles; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Test.DecisionEngineTests -{ - [TestFixture] - - public class LanguageSpecificationFixture : CoreTest - { - private RemoteEpisode _remoteEpisode; - - [SetUp] - public void Setup() - { - _remoteEpisode = new RemoteEpisode - { - ParsedEpisodeInfo = new ParsedEpisodeInfo - { - Language = Language.English - }, - Series = new Series - { - Profile = new LazyLoaded(new Profile - { - Language = Language.English - }) - } - }; - } - - private void WithEnglishRelease() - { - _remoteEpisode.ParsedEpisodeInfo.Language = Language.English; - } - - private void WithGermanRelease() - { - _remoteEpisode.ParsedEpisodeInfo.Language = Language.German; - } - - [Test] - public void should_return_true_if_language_is_english() - { - WithEnglishRelease(); - - Mocker.Resolve().IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); - } - - [Test] - public void should_return_false_if_language_is_german() - { - WithGermanRelease(); - - Mocker.Resolve().IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/MaximumSizeSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/MaximumSizeSpecificationFixture.cs new file mode 100644 index 000000000..d3a1ef88e --- /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 RemoteAlbum _remoteAlbum; + + [SetUp] + public void Setup() + { + _remoteAlbum = new RemoteAlbum() { Release = new ReleaseInfo() }; + } + + private void WithMaximumSize(int size) + { + Mocker.GetMock().SetupGet(c => c.MaximumSize).Returns(size); + } + + private void WithSize(int size) + { + _remoteAlbum.Release.Size = size * 1024 * 1024; + } + + [Test] + public void should_return_true_when_maximum_size_is_set_to_zero() + { + WithMaximumSize(0); + WithSize(1000); + + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_true_when_size_is_smaller_than_maximum_size() + { + WithMaximumSize(2000); + WithSize(1999); + + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_true_when_size_is_equals_to_maximum_size() + { + WithMaximumSize(2000); + WithSize(2000); + + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_false_when_size_is_bigger_than_maximum_size() + { + WithMaximumSize(2000); + WithSize(2001); + + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); + } + + [Test] + public void should_return_true_when_size_is_zero() + { + WithMaximumSize(2000); + WithSize(0); + + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); + } + } +} diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/MinimumAgeSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/MinimumAgeSpecificationFixture.cs index 745eb68d5..40cc8c941 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/MinimumAgeSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/MinimumAgeSpecificationFixture.cs @@ -13,12 +13,12 @@ namespace NzbDrone.Core.Test.DecisionEngineTests public class MinimumAgeSpecificationFixture : CoreTest { - private RemoteEpisode _remoteEpisode; + private RemoteAlbum _remoteAlbum; [SetUp] public void Setup() { - _remoteEpisode = new RemoteEpisode + _remoteAlbum = new RemoteAlbum { Release = new ReleaseInfo() { DownloadProtocol = DownloadProtocol.Usenet } }; @@ -31,7 +31,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests private void WithAge(int minutes) { - _remoteEpisode.Release.PublishDate = DateTime.UtcNow.AddMinutes(-minutes); + _remoteAlbum.Release.PublishDate = DateTime.UtcNow.AddMinutes(-minutes); } [Test] @@ -40,7 +40,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests WithMinimumAge(0); WithAge(100); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); } [Test] @@ -49,7 +49,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests WithMinimumAge(30); WithAge(100); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); } [Test] @@ -58,7 +58,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests WithMinimumAge(30); WithAge(10); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); } } } \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/MonitoredAlbumSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/MonitoredAlbumSpecificationFixture.cs new file mode 100644 index 000000000..5c069fbfa --- /dev/null +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/MonitoredAlbumSpecificationFixture.cs @@ -0,0 +1,140 @@ +using System.Collections.Generic; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.DecisionEngine.Specifications.RssSync; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Music; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.DecisionEngineTests +{ + [TestFixture] + + public class MonitoredAlbumSpecificationFixture : CoreTest + { + private MonitoredAlbumSpecification _monitoredAlbumSpecification; + + private RemoteAlbum _parseResultMulti; + private RemoteAlbum _parseResultSingle; + private Artist _fakeArtist; + private Album _firstAlbum; + private Album _secondAlbum; + + [SetUp] + public void Setup() + { + _monitoredAlbumSpecification = Mocker.Resolve(); + + _fakeArtist = Builder.CreateNew() + .With(c => c.Monitored = true) + .Build(); + + _firstAlbum = new Album { Monitored = true }; + _secondAlbum = new Album { Monitored = true }; + + + var singleAlbumList = new List { _firstAlbum }; + var doubleAlbumList = new List { _firstAlbum, _secondAlbum }; + + _parseResultMulti = new RemoteAlbum + { + Artist = _fakeArtist, + Albums = doubleAlbumList + }; + + _parseResultSingle = new RemoteAlbum + { + Artist = _fakeArtist, + Albums = singleAlbumList + }; + } + + private void WithFirstAlbumUnmonitored() + { + _firstAlbum.Monitored = false; + } + + private void WithSecondAlbumUnmonitored() + { + _secondAlbum.Monitored = false; + } + + [Test] + public void setup_should_return_monitored_album_should_return_true() + { + _monitoredAlbumSpecification.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue(); + _monitoredAlbumSpecification.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeTrue(); + } + + [Test] + public void not_monitored_artist_should_be_skipped() + { + _fakeArtist.Monitored = false; + _monitoredAlbumSpecification.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeFalse(); + } + + [Test] + public void only_album_not_monitored_should_return_false() + { + WithFirstAlbumUnmonitored(); + _monitoredAlbumSpecification.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeFalse(); + } + + [Test] + public void both_albums_not_monitored_should_return_false() + { + WithFirstAlbumUnmonitored(); + WithSecondAlbumUnmonitored(); + _monitoredAlbumSpecification.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeFalse(); + } + + [Test] + public void only_first_album_not_monitored_should_return_false() + { + WithFirstAlbumUnmonitored(); + _monitoredAlbumSpecification.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeFalse(); + } + + [Test] + public void only_second_album_not_monitored_should_return_false() + { + WithSecondAlbumUnmonitored(); + _monitoredAlbumSpecification.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeFalse(); + } + + [Test] + public void should_return_true_for_single_album_search() + { + _fakeArtist.Monitored = false; + _monitoredAlbumSpecification.IsSatisfiedBy(_parseResultSingle, new AlbumSearchCriteria()).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_true_if_album_is_not_monitored_and_monitoredEpisodesOnly_flag_is_false() + { + WithFirstAlbumUnmonitored(); + _monitoredAlbumSpecification.IsSatisfiedBy(_parseResultSingle, new AlbumSearchCriteria { MonitoredEpisodesOnly = false }).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_false_if_album_is_not_monitored_and_monitoredEpisodesOnly_flag_is_true() + { + WithFirstAlbumUnmonitored(); + _monitoredAlbumSpecification.IsSatisfiedBy(_parseResultSingle, new AlbumSearchCriteria{ MonitoredEpisodesOnly = true}).Accepted.Should().BeFalse(); + } + + [Test] + public void should_return_false_if_all_albums_are_not_monitored_for_discography_pack_release() + { + WithSecondAlbumUnmonitored(); + _parseResultMulti.ParsedAlbumInfo = new ParsedAlbumInfo() + { + Discography = true + }; + + _monitoredAlbumSpecification.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeFalse(); + } + } +} diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/MonitoredEpisodeSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/MonitoredEpisodeSpecificationFixture.cs deleted file mode 100644 index 36a46337d..000000000 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/MonitoredEpisodeSpecificationFixture.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System.Collections.Generic; -using FizzWare.NBuilder; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.DecisionEngine.Specifications.RssSync; -using NzbDrone.Core.IndexerSearch.Definitions; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Tv; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.DecisionEngineTests -{ - [TestFixture] - - public class MonitoredEpisodeSpecificationFixture : CoreTest - { - private MonitoredEpisodeSpecification _monitoredEpisodeSpecification; - - private RemoteEpisode _parseResultMulti; - private RemoteEpisode _parseResultSingle; - private Series _fakeSeries; - private Episode _firstEpisode; - private Episode _secondEpisode; - - [SetUp] - public void Setup() - { - _monitoredEpisodeSpecification = Mocker.Resolve(); - - _fakeSeries = Builder.CreateNew() - .With(c => c.Monitored = true) - .Build(); - - _firstEpisode = new Episode { Monitored = true }; - _secondEpisode = new Episode { Monitored = true }; - - - var singleEpisodeList = new List { _firstEpisode }; - var doubleEpisodeList = new List { _firstEpisode, _secondEpisode }; - - _parseResultMulti = new RemoteEpisode - { - Series = _fakeSeries, - Episodes = doubleEpisodeList - }; - - _parseResultSingle = new RemoteEpisode - { - Series = _fakeSeries, - Episodes = singleEpisodeList - }; - } - - private void WithFirstEpisodeUnmonitored() - { - _firstEpisode.Monitored = false; - } - - private void WithSecondEpisodeUnmonitored() - { - _secondEpisode.Monitored = false; - } - - [Test] - public void setup_should_return_monitored_episode_should_return_true() - { - _monitoredEpisodeSpecification.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue(); - _monitoredEpisodeSpecification.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeTrue(); - } - - [Test] - public void not_monitored_series_should_be_skipped() - { - _fakeSeries.Monitored = false; - _monitoredEpisodeSpecification.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeFalse(); - } - - [Test] - public void only_episode_not_monitored_should_return_false() - { - WithFirstEpisodeUnmonitored(); - _monitoredEpisodeSpecification.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeFalse(); - } - - [Test] - public void both_episodes_not_monitored_should_return_false() - { - WithFirstEpisodeUnmonitored(); - WithSecondEpisodeUnmonitored(); - _monitoredEpisodeSpecification.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeFalse(); - } - - [Test] - public void only_first_episode_not_monitored_should_return_false() - { - WithFirstEpisodeUnmonitored(); - _monitoredEpisodeSpecification.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeFalse(); - } - - [Test] - public void only_second_episode_not_monitored_should_return_false() - { - WithSecondEpisodeUnmonitored(); - _monitoredEpisodeSpecification.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeFalse(); - } - - [Test] - public void should_return_true_for_single_episode_search() - { - _fakeSeries.Monitored = false; - _monitoredEpisodeSpecification.IsSatisfiedBy(_parseResultSingle, new SingleEpisodeSearchCriteria()).Accepted.Should().BeTrue(); - } - - [Test] - public void should_return_true_if_episode_is_monitored_for_season_search() - { - _monitoredEpisodeSpecification.IsSatisfiedBy(_parseResultSingle, new SeasonSearchCriteria()).Accepted.Should().BeTrue(); - } - - [Test] - public void should_return_false_if_episode_is_not_monitored_for_season_search() - { - WithFirstEpisodeUnmonitored(); - _monitoredEpisodeSpecification.IsSatisfiedBy(_parseResultSingle, new SeasonSearchCriteria()).Accepted.Should().BeFalse(); - } - - [Test] - public void should_return_true_if_episode_is_not_monitored_and_monitoredEpisodesOnly_flag_is_false() - { - WithFirstEpisodeUnmonitored(); - _monitoredEpisodeSpecification.IsSatisfiedBy(_parseResultSingle, new SingleEpisodeSearchCriteria { MonitoredEpisodesOnly = false }).Accepted.Should().BeTrue(); - } - - [Test] - public void should_return_false_if_episode_is_not_monitored_and_monitoredEpisodesOnly_flag_is_true() - { - WithFirstEpisodeUnmonitored(); - _monitoredEpisodeSpecification.IsSatisfiedBy(_parseResultSingle, new SingleEpisodeSearchCriteria{ MonitoredEpisodesOnly = true}).Accepted.Should().BeFalse(); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs index 76e139fe1..dbce66d24 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/PrioritizeDownloadDecisionFixture.cs @@ -1,11 +1,11 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using Moq; using NzbDrone.Core.Indexers; using NzbDrone.Core.Profiles.Delay; -using NzbDrone.Core.Tv; -using NzbDrone.Core.Profiles; +using NzbDrone.Core.Music; +using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Qualities; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.DecisionEngine; @@ -14,6 +14,7 @@ using FluentAssertions; using FizzWare.NBuilder; using NzbDrone.Common.Extensions; using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Configuration; namespace NzbDrone.Core.Test.DecisionEngineTests { @@ -26,33 +27,36 @@ namespace NzbDrone.Core.Test.DecisionEngineTests GivenPreferredDownloadProtocol(DownloadProtocol.Usenet); } - private Episode GivenEpisode(int id) + private Album GivenAlbum(int id) { - return Builder.CreateNew() + return Builder.CreateNew() .With(e => e.Id = id) - .With(e => e.EpisodeNumber = id) .Build(); } - private RemoteEpisode GivenRemoteEpisode(List episodes, QualityModel quality, int age = 0, long size = 0, DownloadProtocol downloadProtocol = DownloadProtocol.Usenet) + private RemoteAlbum GivenRemoteAlbum(List albums, QualityModel quality, int age = 0, long size = 0, DownloadProtocol downloadProtocol = DownloadProtocol.Usenet) { - var remoteEpisode = new RemoteEpisode(); - remoteEpisode.ParsedEpisodeInfo = new ParsedEpisodeInfo(); - remoteEpisode.ParsedEpisodeInfo.Quality = quality; + var remoteAlbum = new RemoteAlbum(); + remoteAlbum.ParsedAlbumInfo = new ParsedAlbumInfo(); + remoteAlbum.ParsedAlbumInfo.Quality = quality; - remoteEpisode.Episodes = new List(); - remoteEpisode.Episodes.AddRange(episodes); + remoteAlbum.Albums = new List(); + remoteAlbum.Albums.AddRange(albums); - remoteEpisode.Release = new ReleaseInfo(); - remoteEpisode.Release.PublishDate = DateTime.Now.AddDays(-age); - remoteEpisode.Release.Size = size; - remoteEpisode.Release.DownloadProtocol = downloadProtocol; + remoteAlbum.Release = new ReleaseInfo(); + remoteAlbum.Release.PublishDate = DateTime.Now.AddDays(-age); + remoteAlbum.Release.Size = size; + remoteAlbum.Release.DownloadProtocol = downloadProtocol; - remoteEpisode.Series = Builder.CreateNew() - .With(e => e.Profile = new Profile { Items = Qualities.QualityFixture.GetDefaultQualities() }) - .Build(); + remoteAlbum.Artist = Builder.CreateNew() + .With(e => e.QualityProfile = new QualityProfile + { + Items = Qualities.QualityFixture.GetDefaultQualities() + }).Build(); - return remoteEpisode; + remoteAlbum.DownloadAllowed = true; + + return remoteAlbum; } private void GivenPreferredDownloadProtocol(DownloadProtocol downloadProtocol) @@ -68,103 +72,75 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [Test] public void should_put_propers_before_non_propers() { - var remoteEpisode1 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p, new Revision(version: 1))); - var remoteEpisode2 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p, new Revision(version: 2))); + var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256, new Revision(version: 1))); + var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256, new Revision(version: 2))); var decisions = new List(); - decisions.Add(new DownloadDecision(remoteEpisode1)); - decisions.Add(new DownloadDecision(remoteEpisode2)); + decisions.Add(new DownloadDecision(remoteAlbum1)); + decisions.Add(new DownloadDecision(remoteAlbum2)); var qualifiedReports = Subject.PrioritizeDecisions(decisions); - qualifiedReports.First().RemoteEpisode.ParsedEpisodeInfo.Quality.Revision.Version.Should().Be(2); + qualifiedReports.First().RemoteAlbum.ParsedAlbumInfo.Quality.Revision.Version.Should().Be(2); } [Test] public void should_put_higher_quality_before_lower() { - var remoteEpisode1 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.SDTV)); - var remoteEpisode2 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p)); - - var decisions = new List(); - decisions.Add(new DownloadDecision(remoteEpisode1)); - decisions.Add(new DownloadDecision(remoteEpisode2)); - - var qualifiedReports = Subject.PrioritizeDecisions(decisions); - qualifiedReports.First().RemoteEpisode.ParsedEpisodeInfo.Quality.Quality.Should().Be(Quality.HDTV720p); - } - - [Test] - public void should_order_by_lowest_number_of_episodes() - { - var remoteEpisode1 = GivenRemoteEpisode(new List { GivenEpisode(2) }, new QualityModel(Quality.HDTV720p)); - var remoteEpisode2 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p)); - - var decisions = new List(); - decisions.Add(new DownloadDecision(remoteEpisode1)); - decisions.Add(new DownloadDecision(remoteEpisode2)); - - var qualifiedReports = Subject.PrioritizeDecisions(decisions); - qualifiedReports.First().RemoteEpisode.Episodes.First().EpisodeNumber.Should().Be(1); - } - - [Test] - public void should_order_by_lowest_number_of_episodes_with_multiple_episodes() - { - var remoteEpisode1 = GivenRemoteEpisode(new List { GivenEpisode(2), GivenEpisode(3) }, new QualityModel(Quality.HDTV720p)); - var remoteEpisode2 = GivenRemoteEpisode(new List { GivenEpisode(1), GivenEpisode(2) }, new QualityModel(Quality.HDTV720p)); + var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_192)); + var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256)); var decisions = new List(); - decisions.Add(new DownloadDecision(remoteEpisode1)); - decisions.Add(new DownloadDecision(remoteEpisode2)); + decisions.Add(new DownloadDecision(remoteAlbum1)); + decisions.Add(new DownloadDecision(remoteAlbum2)); var qualifiedReports = Subject.PrioritizeDecisions(decisions); - qualifiedReports.First().RemoteEpisode.Episodes.First().EpisodeNumber.Should().Be(1); + qualifiedReports.First().RemoteAlbum.ParsedAlbumInfo.Quality.Quality.Should().Be(Quality.MP3_256); } [Test] public void should_order_by_age_then_largest_rounded_to_200mb() { - var remoteEpisodeSd = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.SDTV), size: 100.Megabytes(), age: 1); - var remoteEpisodeHdSmallOld = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), size: 1200.Megabytes(), age: 1000); - var remoteEpisodeSmallYoung = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), size: 1250.Megabytes(), age: 10); - var remoteEpisodeHdLargeYoung = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), size: 3000.Megabytes(), age: 1); + var remoteAlbumSd = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_192), size: 100.Megabytes(), age: 1); + var remoteAlbumHdSmallOld = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), size: 1200.Megabytes(), age: 1000); + var remoteAlbumSmallYoung = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), size: 1250.Megabytes(), age: 10); + var remoteAlbumHdLargeYoung = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), size: 3000.Megabytes(), age: 1); var decisions = new List(); - decisions.Add(new DownloadDecision(remoteEpisodeSd)); - decisions.Add(new DownloadDecision(remoteEpisodeHdSmallOld)); - decisions.Add(new DownloadDecision(remoteEpisodeSmallYoung)); - decisions.Add(new DownloadDecision(remoteEpisodeHdLargeYoung)); + decisions.Add(new DownloadDecision(remoteAlbumSd)); + decisions.Add(new DownloadDecision(remoteAlbumHdSmallOld)); + decisions.Add(new DownloadDecision(remoteAlbumSmallYoung)); + decisions.Add(new DownloadDecision(remoteAlbumHdLargeYoung)); var qualifiedReports = Subject.PrioritizeDecisions(decisions); - qualifiedReports.First().RemoteEpisode.Should().Be(remoteEpisodeHdLargeYoung); + qualifiedReports.First().RemoteAlbum.Should().Be(remoteAlbumHdLargeYoung); } [Test] public void should_order_by_youngest() { - var remoteEpisode1 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), age: 10); - var remoteEpisode2 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), age: 5); + var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), age: 10); + var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), age: 5); var decisions = new List(); - decisions.Add(new DownloadDecision(remoteEpisode1)); - decisions.Add(new DownloadDecision(remoteEpisode2)); + decisions.Add(new DownloadDecision(remoteAlbum1)); + decisions.Add(new DownloadDecision(remoteAlbum2)); var qualifiedReports = Subject.PrioritizeDecisions(decisions); - qualifiedReports.First().RemoteEpisode.Should().Be(remoteEpisode2); + qualifiedReports.First().RemoteAlbum.Should().Be(remoteAlbum2); } [Test] - public void should_not_throw_if_no_episodes_are_found() + public void should_not_throw_if_no_albums_are_found() { - var remoteEpisode1 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), size: 500.Megabytes()); - var remoteEpisode2 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), size: 500.Megabytes()); + var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), size: 500.Megabytes()); + var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), size: 500.Megabytes()); - remoteEpisode1.Episodes = new List(); + remoteAlbum1.Albums = new List(); var decisions = new List(); - decisions.Add(new DownloadDecision(remoteEpisode1)); - decisions.Add(new DownloadDecision(remoteEpisode2)); + decisions.Add(new DownloadDecision(remoteAlbum1)); + decisions.Add(new DownloadDecision(remoteAlbum2)); Subject.PrioritizeDecisions(decisions); } @@ -174,15 +150,15 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { GivenPreferredDownloadProtocol(DownloadProtocol.Usenet); - var remoteEpisode1 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), downloadProtocol: DownloadProtocol.Torrent); - var remoteEpisode2 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), downloadProtocol: DownloadProtocol.Usenet); + var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), downloadProtocol: DownloadProtocol.Torrent); + var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), downloadProtocol: DownloadProtocol.Usenet); var decisions = new List(); - decisions.Add(new DownloadDecision(remoteEpisode1)); - decisions.Add(new DownloadDecision(remoteEpisode2)); + decisions.Add(new DownloadDecision(remoteAlbum1)); + decisions.Add(new DownloadDecision(remoteAlbum2)); var qualifiedReports = Subject.PrioritizeDecisions(decisions); - qualifiedReports.First().RemoteEpisode.Release.DownloadProtocol.Should().Be(DownloadProtocol.Usenet); + qualifiedReports.First().RemoteAlbum.Release.DownloadProtocol.Should().Be(DownloadProtocol.Usenet); } [Test] @@ -190,69 +166,68 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { GivenPreferredDownloadProtocol(DownloadProtocol.Torrent); - var remoteEpisode1 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), downloadProtocol: DownloadProtocol.Torrent); - var remoteEpisode2 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p), downloadProtocol: DownloadProtocol.Usenet); + var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), downloadProtocol: DownloadProtocol.Torrent); + var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256), downloadProtocol: DownloadProtocol.Usenet); var decisions = new List(); - decisions.Add(new DownloadDecision(remoteEpisode1)); - decisions.Add(new DownloadDecision(remoteEpisode2)); + decisions.Add(new DownloadDecision(remoteAlbum1)); + decisions.Add(new DownloadDecision(remoteAlbum2)); var qualifiedReports = Subject.PrioritizeDecisions(decisions); - qualifiedReports.First().RemoteEpisode.Release.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); + qualifiedReports.First().RemoteAlbum.Release.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); } [Test] - public void should_prefer_season_pack_above_single_episode() + public void should_prefer_discography_pack_above_single_album() { - var remoteEpisode1 = GivenRemoteEpisode(new List { GivenEpisode(1), GivenEpisode(2) }, new QualityModel(Quality.HDTV720p)); - var remoteEpisode2 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p)); + var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1), GivenAlbum(2) }, new QualityModel(Quality.FLAC)); + var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.FLAC)); - remoteEpisode1.ParsedEpisodeInfo.FullSeason = true; + remoteAlbum1.ParsedAlbumInfo.Discography = true; var decisions = new List(); - decisions.Add(new DownloadDecision(remoteEpisode1)); - decisions.Add(new DownloadDecision(remoteEpisode2)); + decisions.Add(new DownloadDecision(remoteAlbum1)); + decisions.Add(new DownloadDecision(remoteAlbum2)); var qualifiedReports = Subject.PrioritizeDecisions(decisions); - qualifiedReports.First().RemoteEpisode.ParsedEpisodeInfo.FullSeason.Should().BeTrue(); + qualifiedReports.First().RemoteAlbum.ParsedAlbumInfo.Discography.Should().BeTrue(); } [Test] - public void should_prefer_multiepisode_over_single_episode_for_anime() + public void should_prefer_quality_over_discography_pack() { - var remoteEpisode1 = GivenRemoteEpisode(new List { GivenEpisode(1), GivenEpisode(2) }, new QualityModel(Quality.HDTV720p)); - var remoteEpisode2 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p)); + var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1), GivenAlbum(2) }, new QualityModel(Quality.MP3_320)); + var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.FLAC)); - remoteEpisode1.Series.SeriesType = SeriesTypes.Anime; - remoteEpisode2.Series.SeriesType = SeriesTypes.Anime; + remoteAlbum1.ParsedAlbumInfo.Discography = true; var decisions = new List(); - decisions.Add(new DownloadDecision(remoteEpisode1)); - decisions.Add(new DownloadDecision(remoteEpisode2)); + decisions.Add(new DownloadDecision(remoteAlbum1)); + decisions.Add(new DownloadDecision(remoteAlbum2)); var qualifiedReports = Subject.PrioritizeDecisions(decisions); - qualifiedReports.First().RemoteEpisode.Episodes.Count.Should().Be(remoteEpisode1.Episodes.Count); + qualifiedReports.First().RemoteAlbum.ParsedAlbumInfo.Discography.Should().BeFalse(); } [Test] - public void should_prefer_single_episode_over_multi_episode_for_non_anime() + public void should_prefer_single_album_over_multi_album() { - var remoteEpisode1 = GivenRemoteEpisode(new List { GivenEpisode(1), GivenEpisode(2) }, new QualityModel(Quality.HDTV720p)); - var remoteEpisode2 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p)); + var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1), GivenAlbum(2) }, new QualityModel(Quality.MP3_256)); + var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256)); var decisions = new List(); - decisions.Add(new DownloadDecision(remoteEpisode1)); - decisions.Add(new DownloadDecision(remoteEpisode2)); + decisions.Add(new DownloadDecision(remoteAlbum1)); + decisions.Add(new DownloadDecision(remoteAlbum2)); var qualifiedReports = Subject.PrioritizeDecisions(decisions); - qualifiedReports.First().RemoteEpisode.Episodes.Count.Should().Be(remoteEpisode2.Episodes.Count); + qualifiedReports.First().RemoteAlbum.Albums.Count.Should().Be(remoteAlbum2.Albums.Count); } [Test] public void should_prefer_releases_with_more_seeders() { - var remoteEpisode1 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p)); - var remoteEpisode2 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p)); + var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256)); + var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256)); var torrentInfo1 = new TorrentInfo(); torrentInfo1.PublishDate = DateTime.Now; @@ -263,22 +238,22 @@ namespace NzbDrone.Core.Test.DecisionEngineTests var torrentInfo2 = torrentInfo1.JsonClone(); torrentInfo2.Seeders = 100; - remoteEpisode1.Release = torrentInfo1; - remoteEpisode2.Release = torrentInfo2; + remoteAlbum1.Release = torrentInfo1; + remoteAlbum2.Release = torrentInfo2; var decisions = new List(); - decisions.Add(new DownloadDecision(remoteEpisode1)); - decisions.Add(new DownloadDecision(remoteEpisode2)); + decisions.Add(new DownloadDecision(remoteAlbum1)); + decisions.Add(new DownloadDecision(remoteAlbum2)); var qualifiedReports = Subject.PrioritizeDecisions(decisions); - ((TorrentInfo) qualifiedReports.First().RemoteEpisode.Release).Seeders.Should().Be(torrentInfo2.Seeders); + ((TorrentInfo)qualifiedReports.First().RemoteAlbum.Release).Seeders.Should().Be(torrentInfo2.Seeders); } [Test] public void should_prefer_releases_with_more_peers_given_equal_number_of_seeds() { - var remoteEpisode1 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p)); - var remoteEpisode2 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p)); + var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256)); + var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256)); var torrentInfo1 = new TorrentInfo(); torrentInfo1.PublishDate = DateTime.Now; @@ -291,22 +266,22 @@ namespace NzbDrone.Core.Test.DecisionEngineTests var torrentInfo2 = torrentInfo1.JsonClone(); torrentInfo2.Peers = 100; - remoteEpisode1.Release = torrentInfo1; - remoteEpisode2.Release = torrentInfo2; + remoteAlbum1.Release = torrentInfo1; + remoteAlbum2.Release = torrentInfo2; var decisions = new List(); - decisions.Add(new DownloadDecision(remoteEpisode1)); - decisions.Add(new DownloadDecision(remoteEpisode2)); + decisions.Add(new DownloadDecision(remoteAlbum1)); + decisions.Add(new DownloadDecision(remoteAlbum2)); var qualifiedReports = Subject.PrioritizeDecisions(decisions); - ((TorrentInfo)qualifiedReports.First().RemoteEpisode.Release).Peers.Should().Be(torrentInfo2.Peers); + ((TorrentInfo)qualifiedReports.First().RemoteAlbum.Release).Peers.Should().Be(torrentInfo2.Peers); } [Test] public void should_prefer_releases_with_more_peers_no_seeds() { - var remoteEpisode1 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p)); - var remoteEpisode2 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p)); + var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256)); + var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256)); var torrentInfo1 = new TorrentInfo(); torrentInfo1.PublishDate = DateTime.Now; @@ -320,22 +295,22 @@ namespace NzbDrone.Core.Test.DecisionEngineTests torrentInfo2.Seeders = 0; torrentInfo2.Peers = 100; - remoteEpisode1.Release = torrentInfo1; - remoteEpisode2.Release = torrentInfo2; + remoteAlbum1.Release = torrentInfo1; + remoteAlbum2.Release = torrentInfo2; var decisions = new List(); - decisions.Add(new DownloadDecision(remoteEpisode1)); - decisions.Add(new DownloadDecision(remoteEpisode2)); + decisions.Add(new DownloadDecision(remoteAlbum1)); + decisions.Add(new DownloadDecision(remoteAlbum2)); var qualifiedReports = Subject.PrioritizeDecisions(decisions); - ((TorrentInfo)qualifiedReports.First().RemoteEpisode.Release).Peers.Should().Be(torrentInfo2.Peers); + ((TorrentInfo)qualifiedReports.First().RemoteAlbum.Release).Peers.Should().Be(torrentInfo2.Peers); } [Test] public void should_prefer_first_release_if_peers_and_size_are_too_similar() { - var remoteEpisode1 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p)); - var remoteEpisode2 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p)); + var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256)); + var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256)); var torrentInfo1 = new TorrentInfo(); torrentInfo1.PublishDate = DateTime.Now; @@ -349,35 +324,161 @@ namespace NzbDrone.Core.Test.DecisionEngineTests torrentInfo2.Peers = 10; torrentInfo1.Size = 250.Megabytes(); - remoteEpisode1.Release = torrentInfo1; - remoteEpisode2.Release = torrentInfo2; + remoteAlbum1.Release = torrentInfo1; + remoteAlbum2.Release = torrentInfo2; var decisions = new List(); - decisions.Add(new DownloadDecision(remoteEpisode1)); - decisions.Add(new DownloadDecision(remoteEpisode2)); + decisions.Add(new DownloadDecision(remoteAlbum1)); + decisions.Add(new DownloadDecision(remoteAlbum2)); var qualifiedReports = Subject.PrioritizeDecisions(decisions); - ((TorrentInfo) qualifiedReports.First().RemoteEpisode.Release).Should().Be(torrentInfo1); + ((TorrentInfo)qualifiedReports.First().RemoteAlbum.Release).Should().Be(torrentInfo1); } [Test] public void should_prefer_first_release_if_age_and_size_are_too_similar() { - var remoteEpisode1 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p)); - var remoteEpisode2 = GivenRemoteEpisode(new List { GivenEpisode(1) }, new QualityModel(Quality.HDTV720p)); + var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256)); + var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256)); + + remoteAlbum1.Release.PublishDate = DateTime.UtcNow.AddDays(-100); + remoteAlbum1.Release.Size = 200.Megabytes(); + + remoteAlbum2.Release.PublishDate = DateTime.UtcNow.AddDays(-150); + remoteAlbum2.Release.Size = 250.Megabytes(); + + var decisions = new List(); + decisions.Add(new DownloadDecision(remoteAlbum1)); + decisions.Add(new DownloadDecision(remoteAlbum2)); + + var qualifiedReports = Subject.PrioritizeDecisions(decisions); + qualifiedReports.First().RemoteAlbum.Release.Should().Be(remoteAlbum1.Release); + } + + [Test] + public void should_prefer_quality_over_the_number_of_peers() + { + var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_320)); + var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_192)); + + var torrentInfo1 = new TorrentInfo(); + torrentInfo1.PublishDate = DateTime.Now; + torrentInfo1.DownloadProtocol = DownloadProtocol.Torrent; + torrentInfo1.Seeders = 100; + torrentInfo1.Peers = 10; + torrentInfo1.Size = 200.Megabytes(); + + var torrentInfo2 = torrentInfo1.JsonClone(); + torrentInfo2.Seeders = 1100; + torrentInfo2.Peers = 10; + torrentInfo1.Size = 250.Megabytes(); + + remoteAlbum1.Release = torrentInfo1; + remoteAlbum2.Release = torrentInfo2; + + var decisions = new List(); + decisions.Add(new DownloadDecision(remoteAlbum1)); + decisions.Add(new DownloadDecision(remoteAlbum2)); + + var qualifiedReports = Subject.PrioritizeDecisions(decisions); + ((TorrentInfo)qualifiedReports.First().RemoteAlbum.Release).Should().Be(torrentInfo1); + } + + [Test] + public void should_put_higher_quality_before_lower_always() + { + var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_256)); + var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.MP3_320)); + + var decisions = new List(); + decisions.Add(new DownloadDecision(remoteAlbum1)); + decisions.Add(new DownloadDecision(remoteAlbum2)); + + var qualifiedReports = Subject.PrioritizeDecisions(decisions); + qualifiedReports.First().RemoteAlbum.ParsedAlbumInfo.Quality.Quality.Should().Be(Quality.MP3_320); + } + + + [Test] + public void should_prefer_higher_score_over_lower_score() + { + var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.FLAC)); + var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.FLAC)); + + remoteAlbum1.PreferredWordScore = 10; + remoteAlbum2.PreferredWordScore = 0; + + var decisions = new List(); + decisions.Add(new DownloadDecision(remoteAlbum1)); + decisions.Add(new DownloadDecision(remoteAlbum2)); + + var qualifiedReports = Subject.PrioritizeDecisions(decisions); + qualifiedReports.First().RemoteAlbum.PreferredWordScore.Should().Be(10); + } + + [Test] + public void should_prefer_proper_over_score_when_download_propers_is_prefer_and_upgrade() + { + Mocker.GetMock() + .Setup(s => s.DownloadPropersAndRepacks) + .Returns(ProperDownloadTypes.PreferAndUpgrade); + + var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.FLAC, new Revision(1))); + var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.FLAC, new Revision(2))); + + remoteAlbum1.PreferredWordScore = 10; + remoteAlbum2.PreferredWordScore = 0; + + var decisions = new List(); + decisions.Add(new DownloadDecision(remoteAlbum1)); + decisions.Add(new DownloadDecision(remoteAlbum2)); + + var qualifiedReports = Subject.PrioritizeDecisions(decisions); + qualifiedReports.First().RemoteAlbum.ParsedAlbumInfo.Quality.Revision.Version.Should().Be(2); + } + + [Test] + public void should_prefer_proper_over_score_when_download_propers_is_do_not_upgrade() + { + Mocker.GetMock() + .Setup(s => s.DownloadPropersAndRepacks) + .Returns(ProperDownloadTypes.DoNotUpgrade); + + var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.FLAC, new Revision(1))); + var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.FLAC, new Revision(2))); + + remoteAlbum1.PreferredWordScore = 10; + remoteAlbum2.PreferredWordScore = 0; + + var decisions = new List(); + decisions.Add(new DownloadDecision(remoteAlbum1)); + decisions.Add(new DownloadDecision(remoteAlbum2)); + + var qualifiedReports = Subject.PrioritizeDecisions(decisions); + qualifiedReports.First().RemoteAlbum.ParsedAlbumInfo.Quality.Revision.Version.Should().Be(2); + } + + [Test] + public void should_prefer_score_over_proper_when_download_propers_is_do_not_prefer() + { + Mocker.GetMock() + .Setup(s => s.DownloadPropersAndRepacks) + .Returns(ProperDownloadTypes.DoNotPrefer); - remoteEpisode1.Release.PublishDate = DateTime.UtcNow.AddDays(-100); - remoteEpisode1.Release.Size = 200.Megabytes(); + var remoteAlbum1 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.FLAC, new Revision(1))); + var remoteAlbum2 = GivenRemoteAlbum(new List { GivenAlbum(1) }, new QualityModel(Quality.FLAC, new Revision(2))); - remoteEpisode2.Release.PublishDate = DateTime.UtcNow.AddDays(-150); - remoteEpisode2.Release.Size = 250.Megabytes(); + remoteAlbum1.PreferredWordScore = 10; + remoteAlbum2.PreferredWordScore = 0; var decisions = new List(); - decisions.Add(new DownloadDecision(remoteEpisode1)); - decisions.Add(new DownloadDecision(remoteEpisode2)); + decisions.Add(new DownloadDecision(remoteAlbum1)); + decisions.Add(new DownloadDecision(remoteAlbum2)); var qualifiedReports = Subject.PrioritizeDecisions(decisions); - qualifiedReports.First().RemoteEpisode.Release.Should().Be(remoteEpisode1.Release); + qualifiedReports.First().RemoteAlbum.ParsedAlbumInfo.Quality.Quality.Should().Be(Quality.FLAC); + qualifiedReports.First().RemoteAlbum.ParsedAlbumInfo.Quality.Revision.Version.Should().Be(1); + qualifiedReports.First().RemoteAlbum.PreferredWordScore.Should().Be(10); } } } diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/ProtocolSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/ProtocolSpecificationFixture.cs index 4bfaf34dc..5b9ce36ef 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/ProtocolSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/ProtocolSpecificationFixture.cs @@ -7,22 +7,22 @@ using NzbDrone.Core.Indexers; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Profiles.Delay; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Test.DecisionEngineTests { [TestFixture] public class ProtocolSpecificationFixture : CoreTest { - private RemoteEpisode _remoteEpisode; + private RemoteAlbum _remoteAlbum; private DelayProfile _delayProfile; [SetUp] public void Setup() { - _remoteEpisode = new RemoteEpisode(); - _remoteEpisode.Release = new ReleaseInfo(); - _remoteEpisode.Series = new Series(); + _remoteAlbum = new RemoteAlbum(); + _remoteAlbum.Release = new ReleaseInfo(); + _remoteAlbum.Artist = new Artist(); _delayProfile = new DelayProfile(); @@ -33,7 +33,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests private void GivenProtocol(DownloadProtocol downloadProtocol) { - _remoteEpisode.Release.DownloadProtocol = downloadProtocol; + _remoteAlbum.Release.DownloadProtocol = downloadProtocol; } [Test] @@ -42,7 +42,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests GivenProtocol(DownloadProtocol.Usenet); _delayProfile.EnableUsenet = true; - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().Be(true); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().Be(true); } [Test] @@ -51,7 +51,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests GivenProtocol(DownloadProtocol.Torrent); _delayProfile.EnableTorrent = true; - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().Be(true); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().Be(true); } [Test] @@ -60,7 +60,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests GivenProtocol(DownloadProtocol.Usenet); _delayProfile.EnableUsenet = false; - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().Be(false); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().Be(false); } [Test] @@ -69,7 +69,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests GivenProtocol(DownloadProtocol.Torrent); _delayProfile.EnableTorrent = false; - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().Be(false); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().Be(false); } } } \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/QualityAllowedByProfileSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/QualityAllowedByProfileSpecificationFixture.cs index 207f38225..34f29df7b 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/QualityAllowedByProfileSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/QualityAllowedByProfileSpecificationFixture.cs @@ -1,12 +1,12 @@ -using FizzWare.NBuilder; +using FizzWare.NBuilder; using FluentAssertions; using Marr.Data; using NUnit.Framework; using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Profiles; +using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Qualities; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; using NzbDrone.Core.Test.Framework; namespace NzbDrone.Core.Test.DecisionEngineTests @@ -15,52 +15,52 @@ namespace NzbDrone.Core.Test.DecisionEngineTests public class QualityAllowedByProfileSpecificationFixture : CoreTest { - private RemoteEpisode remoteEpisode; + private RemoteAlbum remoteAlbum; public static object[] AllowedTestCases = { - new object[] { Quality.DVD }, - new object[] { Quality.HDTV720p }, - new object[] { Quality.Bluray1080p } + new object[] { Quality.MP3_192 }, + new object[] { Quality.MP3_256 }, + new object[] { Quality.MP3_320 } }; public static object[] DeniedTestCases = { - new object[] { Quality.SDTV }, - new object[] { Quality.WEBDL720p }, - new object[] { Quality.Bluray720p } + new object[] { Quality.MP3_VBR }, + new object[] { Quality.FLAC }, + new object[] { Quality.Unknown } }; [SetUp] public void Setup() { - var fakeSeries = Builder.CreateNew() - .With(c => c.Profile = (LazyLoaded)new Profile { Cutoff = Quality.Bluray1080p }) + var fakeArtist = Builder.CreateNew() + .With(c => c.QualityProfile = (LazyLoaded)new QualityProfile { Cutoff = Quality.MP3_320.Id }) .Build(); - remoteEpisode = new RemoteEpisode + remoteAlbum = new RemoteAlbum { - Series = fakeSeries, - ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.DVD, new Revision(version: 2)) }, + Artist = fakeArtist, + ParsedAlbumInfo = new ParsedAlbumInfo { Quality = new QualityModel(Quality.MP3_192, new Revision(version: 2)) }, }; } [Test, TestCaseSource(nameof(AllowedTestCases))] public void should_allow_if_quality_is_defined_in_profile(Quality qualityType) { - remoteEpisode.ParsedEpisodeInfo.Quality.Quality = qualityType; - remoteEpisode.Series.Profile.Value.Items = Qualities.QualityFixture.GetDefaultQualities(Quality.DVD, Quality.HDTV720p, Quality.Bluray1080p); + remoteAlbum.ParsedAlbumInfo.Quality.Quality = qualityType; + remoteAlbum.Artist.QualityProfile.Value.Items = Qualities.QualityFixture.GetDefaultQualities(Quality.MP3_192, Quality.MP3_256, Quality.MP3_320); - Subject.IsSatisfiedBy(remoteEpisode, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(remoteAlbum, null).Accepted.Should().BeTrue(); } [Test, TestCaseSource(nameof(DeniedTestCases))] public void should_not_allow_if_quality_is_not_defined_in_profile(Quality qualityType) { - remoteEpisode.ParsedEpisodeInfo.Quality.Quality = qualityType; - remoteEpisode.Series.Profile.Value.Items = Qualities.QualityFixture.GetDefaultQualities(Quality.DVD, Quality.HDTV720p, Quality.Bluray1080p); + remoteAlbum.ParsedAlbumInfo.Quality.Quality = qualityType; + remoteAlbum.Artist.QualityProfile.Value.Items = Qualities.QualityFixture.GetDefaultQualities(Quality.MP3_192, Quality.MP3_256, Quality.MP3_320); - Subject.IsSatisfiedBy(remoteEpisode, null).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(remoteAlbum, null).Accepted.Should().BeFalse(); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/QualityUpgradeSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/QualityUpgradeSpecificationFixture.cs deleted file mode 100644 index 1a4307bd1..000000000 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/QualityUpgradeSpecificationFixture.cs +++ /dev/null @@ -1,61 +0,0 @@ -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Configuration; -using NzbDrone.Core.Profiles; -using NzbDrone.Core.Qualities; -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.DecisionEngineTests -{ - [TestFixture] - - public class QualityUpgradeSpecificationFixture : CoreTest - { - public static object[] IsUpgradeTestCases = - { - new object[] { Quality.SDTV, 1, Quality.SDTV, 2, Quality.SDTV, true }, - new object[] { Quality.WEBDL720p, 1, Quality.WEBDL720p, 2, Quality.WEBDL720p, true }, - new object[] { Quality.SDTV, 1, Quality.SDTV, 1, Quality.SDTV, false }, - new object[] { Quality.WEBDL720p, 1, Quality.HDTV720p, 2, Quality.Bluray720p, false }, - new object[] { Quality.WEBDL720p, 1, Quality.HDTV720p, 2, Quality.WEBDL720p, false }, - new object[] { Quality.WEBDL720p, 1, Quality.WEBDL720p, 1, Quality.WEBDL720p, false }, - new object[] { Quality.WEBDL1080p, 1, Quality.WEBDL1080p, 1, Quality.WEBDL1080p, false } - }; - - [SetUp] - public void Setup() - { - - } - - private void GivenAutoDownloadPropers(bool autoDownloadPropers) - { - Mocker.GetMock() - .SetupGet(s => s.AutoDownloadPropers) - .Returns(autoDownloadPropers); - } - - [Test, TestCaseSource(nameof(IsUpgradeTestCases))] - public void IsUpgradeTest(Quality current, int currentVersion, Quality newQuality, int newVersion, Quality cutoff, bool expected) - { - GivenAutoDownloadPropers(true); - - var profile = new Profile { Items = Qualities.QualityFixture.GetDefaultQualities() }; - - Subject.IsUpgradable(profile, new QualityModel(current, new Revision(version: currentVersion)), new QualityModel(newQuality, new Revision(version: newVersion))) - .Should().Be(expected); - } - - [Test] - public void should_return_false_if_proper_and_autoDownloadPropers_is_false() - { - GivenAutoDownloadPropers(false); - - var profile = new Profile { Items = Qualities.QualityFixture.GetDefaultQualities() }; - - Subject.IsUpgradable(profile, new QualityModel(Quality.DVD, new Revision(version: 2)), new QualityModel(Quality.DVD, new Revision(version: 1))) - .Should().BeFalse(); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/QueueSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/QueueSpecificationFixture.cs index 6ed0cbde4..b03aac44e 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/QueueSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/QueueSpecificationFixture.cs @@ -1,15 +1,14 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using FizzWare.NBuilder; using FluentAssertions; using NUnit.Framework; -using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Profiles; +using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Qualities; using NzbDrone.Core.Queue; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; using NzbDrone.Core.Test.Framework; namespace NzbDrone.Core.Test.DecisionEngineTests @@ -17,41 +16,49 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [TestFixture] public class QueueSpecificationFixture : CoreTest { - private Series _series; - private Episode _episode; - private RemoteEpisode _remoteEpisode; + private Artist _artist; + private Album _album; + private RemoteAlbum _remoteAlbum; - private Series _otherSeries; - private Episode _otherEpisode; + private Artist _otherArtist; + private Album _otherAlbum; + + private ReleaseInfo _releaseInfo; [SetUp] public void Setup() { - Mocker.Resolve(); - - _series = Builder.CreateNew() - .With(e => e.Profile = new Profile { Items = Qualities.QualityFixture.GetDefaultQualities() }) + Mocker.Resolve(); + + _artist = Builder.CreateNew() + .With(e => e.QualityProfile = new QualityProfile + { + UpgradeAllowed = true, + Items = Qualities.QualityFixture.GetDefaultQualities(), + }) .Build(); - _episode = Builder.CreateNew() - .With(e => e.SeriesId = _series.Id) + _album = Builder.CreateNew() + .With(e => e.ArtistId = _artist.Id) .Build(); - _otherSeries = Builder.CreateNew() + _otherArtist = Builder.CreateNew() .With(s => s.Id = 2) .Build(); - _otherEpisode = Builder.CreateNew() - .With(e => e.SeriesId = _otherSeries.Id) + _otherAlbum = Builder.CreateNew() + .With(e => e.ArtistId = _otherArtist.Id) .With(e => e.Id = 2) - .With(e => e.SeasonNumber = 2) - .With(e => e.EpisodeNumber = 2) .Build(); - _remoteEpisode = Builder.CreateNew() - .With(r => r.Series = _series) - .With(r => r.Episodes = new List { _episode }) - .With(r => r.ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.DVD) }) + _releaseInfo = Builder.CreateNew() + .Build(); + + _remoteAlbum = Builder.CreateNew() + .With(r => r.Artist = _artist) + .With(r => r.Albums = new List { _album }) + .With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo { Quality = new QualityModel(Quality.MP3_256) }) + .With(r => r.PreferredWordScore = 0) .Build(); } @@ -62,11 +69,11 @@ namespace NzbDrone.Core.Test.DecisionEngineTests .Returns(new List()); } - private void GivenQueue(IEnumerable remoteEpisodes) + private void GivenQueue(IEnumerable remoteAlbums) { - var queue = remoteEpisodes.Select(remoteEpisode => new Queue.Queue + var queue = remoteAlbums.Select(remoteAlbum => new Queue.Queue { - RemoteEpisode = remoteEpisode + RemoteAlbum = remoteAlbum }); Mocker.GetMock() @@ -78,181 +85,228 @@ namespace NzbDrone.Core.Test.DecisionEngineTests public void should_return_true_when_queue_is_empty() { GivenEmptyQueue(); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); } [Test] - public void should_return_true_when_series_doesnt_match() + public void should_return_true_when_artist_doesnt_match() { - var remoteEpisode = Builder.CreateNew() - .With(r => r.Series = _otherSeries) - .With(r => r.Episodes = new List { _episode }) + var remoteAlbum = Builder.CreateNew() + .With(r => r.Artist = _otherArtist) + .With(r => r.Albums = new List { _album }) + .With(r => r.Release = _releaseInfo) .Build(); - GivenQueue(new List { remoteEpisode }); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + GivenQueue(new List { remoteAlbum }); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_false_if_everything_is_the_same() + { + _artist.QualityProfile.Value.Cutoff = Quality.FLAC.Id; + + var remoteAlbum = Builder.CreateNew() + .With(r => r.Artist = _artist) + .With(r => r.Albums = new List { _album }) + .With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo + { + Quality = new QualityModel(Quality.MP3_256) + }) + .With(r => r.Release = _releaseInfo) + .Build(); + + GivenQueue(new List { remoteAlbum }); + + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); } [Test] public void should_return_true_when_quality_in_queue_is_lower() { - _series.Profile.Value.Cutoff = Quality.Bluray1080p; - - var remoteEpisode = Builder.CreateNew() - .With(r => r.Series = _series) - .With(r => r.Episodes = new List { _episode }) - .With(r => r.ParsedEpisodeInfo = new ParsedEpisodeInfo - { - Quality = new QualityModel(Quality.SDTV) - }) + _artist.QualityProfile.Value.Cutoff = Quality.MP3_320.Id; + + var remoteAlbum = Builder.CreateNew() + .With(r => r.Artist = _artist) + .With(r => r.Albums = new List { _album }) + .With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo + { + Quality = new QualityModel(Quality.MP3_192) + }) + .With(r => r.Release = _releaseInfo) .Build(); - GivenQueue(new List { remoteEpisode }); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + GivenQueue(new List { remoteAlbum }); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); } [Test] - public void should_return_true_when_episode_doesnt_match() + public void should_return_true_when_album_doesnt_match() { - var remoteEpisode = Builder.CreateNew() - .With(r => r.Series = _series) - .With(r => r.Episodes = new List { _otherEpisode }) - .With(r => r.ParsedEpisodeInfo = new ParsedEpisodeInfo - { - Quality = new QualityModel(Quality.DVD) - }) + var remoteAlbum = Builder.CreateNew() + .With(r => r.Artist = _artist) + .With(r => r.Albums = new List { _otherAlbum }) + .With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo + { + Quality = new QualityModel(Quality.MP3_192) + }) + .With(r => r.Release = _releaseInfo) .Build(); - GivenQueue(new List { remoteEpisode }); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + GivenQueue(new List { remoteAlbum }); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_true_when_qualities_are_the_same_with_higher_preferred_word_score() + { + _remoteAlbum.PreferredWordScore = 1; + + var remoteAlbum = Builder.CreateNew() + .With(r => r.Artist = _artist) + .With(r => r.Albums = new List { _album }) + .With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo + { + Quality = new QualityModel(Quality.MP3_256) + }) + .With(r => r.Release = _releaseInfo) + .Build(); + + GivenQueue(new List { remoteAlbum }); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); } [Test] public void should_return_false_when_qualities_are_the_same() { - var remoteEpisode = Builder.CreateNew() - .With(r => r.Series = _series) - .With(r => r.Episodes = new List { _episode }) - .With(r => r.ParsedEpisodeInfo = new ParsedEpisodeInfo - { - Quality = new QualityModel(Quality.DVD) - }) + var remoteAlbum = Builder.CreateNew() + .With(r => r.Artist = _artist) + .With(r => r.Albums = new List { _album }) + .With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo + { + Quality = new QualityModel(Quality.MP3_192) + }) + .With(r => r.Release = _releaseInfo) .Build(); - GivenQueue(new List { remoteEpisode }); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); + GivenQueue(new List { remoteAlbum }); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); } [Test] public void should_return_false_when_quality_in_queue_is_better() { - _series.Profile.Value.Cutoff = Quality.Bluray1080p; - - var remoteEpisode = Builder.CreateNew() - .With(r => r.Series = _series) - .With(r => r.Episodes = new List { _episode }) - .With(r => r.ParsedEpisodeInfo = new ParsedEpisodeInfo - { - Quality = new QualityModel(Quality.HDTV720p) - }) + _artist.QualityProfile.Value.Cutoff = Quality.FLAC.Id; + + var remoteAlbum = Builder.CreateNew() + .With(r => r.Artist = _artist) + .With(r => r.Albums = new List { _album }) + .With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo + { + Quality = new QualityModel(Quality.MP3_320) + }) + .With(r => r.Release = _releaseInfo) .Build(); - GivenQueue(new List { remoteEpisode }); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); + GivenQueue(new List { remoteAlbum }); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); } [Test] - public void should_return_false_if_matching_multi_episode_is_in_queue() + public void should_return_false_if_matching_multi_album_is_in_queue() { - var remoteEpisode = Builder.CreateNew() - .With(r => r.Series = _series) - .With(r => r.Episodes = new List { _episode, _otherEpisode }) - .With(r => r.ParsedEpisodeInfo = new ParsedEpisodeInfo + var remoteAlbum = Builder.CreateNew() + .With(r => r.Artist = _artist) + .With(r => r.Albums = new List { _album, _otherAlbum }) + .With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo { - Quality = new QualityModel(Quality.HDTV720p) + Quality = new QualityModel(Quality.MP3_320) }) + .With(r => r.Release = _releaseInfo) .Build(); - GivenQueue(new List { remoteEpisode }); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); + GivenQueue(new List { remoteAlbum }); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); } [Test] - public void should_return_false_if_multi_episode_has_one_episode_in_queue() + public void should_return_false_if_multi_album_has_one_album_in_queue() { - var remoteEpisode = Builder.CreateNew() - .With(r => r.Series = _series) - .With(r => r.Episodes = new List { _episode }) - .With(r => r.ParsedEpisodeInfo = new ParsedEpisodeInfo + var remoteAlbum = Builder.CreateNew() + .With(r => r.Artist = _artist) + .With(r => r.Albums = new List { _album }) + .With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo { - Quality = new QualityModel(Quality.HDTV720p) + Quality = new QualityModel(Quality.MP3_320) }) + .With(r => r.Release = _releaseInfo) .Build(); - _remoteEpisode.Episodes.Add(_otherEpisode); + _remoteAlbum.Albums.Add(_otherAlbum); - GivenQueue(new List { remoteEpisode }); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); + GivenQueue(new List { remoteAlbum }); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); } [Test] - public void should_return_false_if_multi_part_episode_is_already_in_queue() + public void should_return_false_if_multi_part_album_is_already_in_queue() { - var remoteEpisode = Builder.CreateNew() - .With(r => r.Series = _series) - .With(r => r.Episodes = new List { _episode, _otherEpisode }) - .With(r => r.ParsedEpisodeInfo = new ParsedEpisodeInfo + var remoteAlbum = Builder.CreateNew() + .With(r => r.Artist = _artist) + .With(r => r.Albums = new List { _album, _otherAlbum }) + .With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo { - Quality = new QualityModel(Quality.HDTV720p) + Quality = new QualityModel(Quality.MP3_320) }) + .With(r => r.Release = _releaseInfo) .Build(); - _remoteEpisode.Episodes.Add(_otherEpisode); + _remoteAlbum.Albums.Add(_otherAlbum); - GivenQueue(new List { remoteEpisode }); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); + GivenQueue(new List { remoteAlbum }); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); } [Test] - public void should_return_false_if_multi_part_episode_has_two_episodes_in_queue() + public void should_return_false_if_multi_part_album_has_two_albums_in_queue() { - var remoteEpisodes = Builder.CreateListOfSize(2) + var remoteAlbums = Builder.CreateListOfSize(2) .All() - .With(r => r.Series = _series) - .With(r => r.ParsedEpisodeInfo = new ParsedEpisodeInfo - { - Quality = - new QualityModel( - Quality.HDTV720p) - }) + .With(r => r.Artist = _artist) + .With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo + { + Quality = new QualityModel(Quality.MP3_320) + }) + .With(r => r.Release = _releaseInfo) .TheFirst(1) - .With(r => r.Episodes = new List { _episode }) + .With(r => r.Albums = new List { _album }) .TheNext(1) - .With(r => r.Episodes = new List { _otherEpisode }) + .With(r => r.Albums = new List { _otherAlbum }) .Build(); - _remoteEpisode.Episodes.Add(_otherEpisode); - GivenQueue(remoteEpisodes); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); + _remoteAlbum.Albums.Add(_otherAlbum); + GivenQueue(remoteAlbums); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); } [Test] - public void should_return_false_if_quality_in_queue_meets_cutoff() + public void should_return_false_when_quality_is_better_and_upgrade_allowed_is_false_for_quality_profile() { - _series.Profile.Value.Cutoff = _remoteEpisode.ParsedEpisodeInfo.Quality.Quality; - - var remoteEpisode = Builder.CreateNew() - .With(r => r.Series = _series) - .With(r => r.Episodes = new List { _episode }) - .With(r => r.ParsedEpisodeInfo = new ParsedEpisodeInfo - { - Quality = new QualityModel(Quality.HDTV720p) - }) - .Build(); - - GivenQueue(new List { remoteEpisode }); - - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); + _artist.QualityProfile.Value.Cutoff = Quality.FLAC.Id; + _artist.QualityProfile.Value.UpgradeAllowed = false; + + var remoteAlbum = Builder.CreateNew() + .With(r => r.Artist = _artist) + .With(r => r.Albums = new List { _album }) + .With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo + { + Quality = new QualityModel(Quality.FLAC) + }) + .With(r => r.Release = _releaseInfo) + .Build(); + + GivenQueue(new List { remoteAlbum }); + Subject.IsSatisfiedBy(_remoteAlbum, 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..301566f25 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/RawDiskSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/RawDiskSpecificationFixture.cs @@ -2,7 +2,6 @@ using NUnit.Framework; using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Parser.Model; - using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Indexers; @@ -12,12 +11,12 @@ namespace NzbDrone.Core.Test.DecisionEngineTests public class RawDiskSpecificationFixture : CoreTest { - private RemoteEpisode _remoteEpisode; + private RemoteAlbum _remoteAlbum; [SetUp] public void Setup() { - _remoteEpisode = new RemoteEpisode + _remoteAlbum = new RemoteAlbum { Release = new ReleaseInfo() { DownloadProtocol = DownloadProtocol.Torrent } }; @@ -25,48 +24,41 @@ namespace NzbDrone.Core.Test.DecisionEngineTests private void WithContainer(string container) { - _remoteEpisode.Release.Container = container; + _remoteAlbum.Release.Container = container; } [Test] public void should_return_true_if_no_container_specified() { - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); } [Test] - public void should_return_true_if_mkv() + public void should_return_true_if_flac() { - WithContainer("MKV"); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + WithContainer("FLAC"); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); } [Test] public void should_return_false_if_vob() { WithContainer("VOB"); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); } [Test] public void should_return_false_if_iso() { WithContainer("ISO"); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); - } - - [Test] - public void should_return_false_if_m2ts() - { - WithContainer("M2TS"); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); } [Test] public void should_compare_case_insensitive() { WithContainer("vob"); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); } } diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/ReleaseRestrictionsSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/ReleaseRestrictionsSpecificationFixture.cs index 5ccaaaedb..7c7e9e156 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/ReleaseRestrictionsSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/ReleaseRestrictionsSpecificationFixture.cs @@ -1,26 +1,26 @@ -using System.Collections.Generic; +using System.Collections.Generic; using FluentAssertions; using Moq; using NUnit.Framework; using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Restrictions; +using NzbDrone.Core.Profiles.Releases; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Test.DecisionEngineTests { [TestFixture] public class ReleaseRestrictionsSpecificationFixture : CoreTest { - private RemoteEpisode _remoteEpisode; + private RemoteAlbum _remoteAlbum; [SetUp] public void Setup() { - _remoteEpisode = new RemoteEpisode - { - Series = new Series + _remoteAlbum = new RemoteAlbum + { + Artist = new Artist { Tags = new HashSet() }, @@ -29,15 +29,17 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Title = "Dexter.S08E01.EDITED.WEBRip.x264-KYR" } }; + + Mocker.SetConstant(Mocker.Resolve()); } private void GivenRestictions(string required, string ignored) { - Mocker.GetMock() + Mocker.GetMock() .Setup(s => s.AllForTags(It.IsAny>())) - .Returns(new List + .Returns(new List { - new Restriction + new ReleaseProfile() { Required = required, Ignored = ignored @@ -48,11 +50,11 @@ namespace NzbDrone.Core.Test.DecisionEngineTests [Test] public void should_be_true_when_restrictions_are_empty() { - Mocker.GetMock() + Mocker.GetMock() .Setup(s => s.AllForTags(It.IsAny>())) - .Returns(new List()); + .Returns(new List()); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); } [Test] @@ -60,7 +62,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { GivenRestictions("WEBRip", null); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); } [Test] @@ -68,7 +70,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { GivenRestictions("doesnt,exist", null); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); } [Test] @@ -76,7 +78,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { GivenRestictions(null, "ignored"); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); } [Test] @@ -84,7 +86,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { GivenRestictions(null, "edited"); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); } [TestCase("EdiTED")] @@ -95,7 +97,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { GivenRestictions(required, null); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); } [TestCase("EdiTED")] @@ -106,22 +108,33 @@ namespace NzbDrone.Core.Test.DecisionEngineTests { GivenRestictions(null, ignored); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); } [Test] public void should_be_false_when_release_contains_one_restricted_word_and_one_required_word() { - _remoteEpisode.Release.Title = "[ www.Speed.cd ] -Whose.Line.is.it.Anyway.US.S10E24.720p.HDTV.x264-BAJSKORV"; + _remoteAlbum.Release.Title = "[ www.Speed.cd ] - Katy Perry - Witness (2017) MP3 [320 kbps] "; - Mocker.GetMock() + Mocker.GetMock() .Setup(s => s.AllForTags(It.IsAny>())) - .Returns(new List + .Returns(new List { - new Restriction { Required = "x264", Ignored = "www.Speed.cd" } + new ReleaseProfile { Required = "320", Ignored = "www.Speed.cd" } }); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_remoteAlbum, 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(_remoteAlbum, null).Accepted.Should().Be(expected); } } } diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/RepackSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/RepackSpecificationFixture.cs new file mode 100644 index 000000000..c8b5e8335 --- /dev/null +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/RepackSpecificationFixture.cs @@ -0,0 +1,203 @@ +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Music; +using Moq; + +namespace NzbDrone.Core.Test.DecisionEngineTests +{ + [TestFixture] + public class RepackSpecificationFixture : CoreTest + { + private ParsedAlbumInfo _parsedAlbumInfo; + private List _albums; + private List _trackFiles; + + [SetUp] + public void Setup() + { + Mocker.Resolve(); + + _parsedAlbumInfo = Builder.CreateNew() + .With(p => p.Quality = new QualityModel(Quality.FLAC, + new Revision(2, 0, false))) + .With(p => p.ReleaseGroup = "Lidarr") + .Build(); + + _albums = Builder.CreateListOfSize(1) + .All() + .BuildList(); + + _trackFiles = Builder.CreateListOfSize(3) + .All() + .With(t => t.AlbumId = _albums.First().Id) + .BuildList(); + + Mocker.GetMock() + .Setup(c => c.GetFilesByAlbum(It.IsAny())) + .Returns(_trackFiles); + } + + [Test] + public void should_return_true_if_it_is_not_a_repack() + { + var remoteAlbum = Builder.CreateNew() + .With(e => e.ParsedAlbumInfo = _parsedAlbumInfo) + .With(e => e.Albums = _albums) + .Build(); + + Subject.IsSatisfiedBy(remoteAlbum, null) + .Accepted + .Should() + .BeTrue(); + } + + [Test] + public void should_return_true_if_there_are_is_no_track_files() + { + Mocker.GetMock() + .Setup(c => c.GetFilesByAlbum(It.IsAny())) + .Returns(new List()); + + _parsedAlbumInfo.Quality.Revision.IsRepack = true; + + var remoteAlbum = Builder.CreateNew() + .With(e => e.ParsedAlbumInfo = _parsedAlbumInfo) + .With(e => e.Albums = _albums) + .Build(); + + Subject.IsSatisfiedBy(remoteAlbum, null) + .Accepted + .Should() + .BeTrue(); + } + + [Test] + public void should_return_true_if_is_a_repack_for_a_different_quality() + { + _parsedAlbumInfo.Quality.Revision.IsRepack = true; + + _trackFiles.Select(c => { c.ReleaseGroup = "Lidarr"; return c; }).ToList(); + _trackFiles.Select(c => { c.Quality = new QualityModel(Quality.MP3_256); return c; }).ToList(); + + var remoteAlbum = Builder.CreateNew() + .With(e => e.ParsedAlbumInfo = _parsedAlbumInfo) + .With(e => e.Albums = _albums) + .Build(); + + Subject.IsSatisfiedBy(remoteAlbum, null) + .Accepted + .Should() + .BeTrue(); + } + + [Test] + public void should_return_true_if_is_a_repack_for_all_existing_files() + { + _parsedAlbumInfo.Quality.Revision.IsRepack = true; + + _trackFiles.Select(c => { c.ReleaseGroup = "Lidarr"; return c; }).ToList(); + _trackFiles.Select(c => { c.Quality = new QualityModel(Quality.FLAC); return c; }).ToList(); + + var remoteAlbum = Builder.CreateNew() + .With(e => e.ParsedAlbumInfo = _parsedAlbumInfo) + .With(e => e.Albums = _albums) + .Build(); + + Subject.IsSatisfiedBy(remoteAlbum, null) + .Accepted + .Should() + .BeTrue(); + } + + [Test] + public void should_return_false_if_is_a_repack_for_some_but_not_all_trackfiles() + { + _parsedAlbumInfo.Quality.Revision.IsRepack = true; + + _trackFiles.Select(c => { c.ReleaseGroup = "Lidarr"; return c; }).ToList(); + _trackFiles.Select(c => { c.Quality = new QualityModel(Quality.FLAC); return c; }).ToList(); + + _trackFiles.First().ReleaseGroup = "NotLidarr"; + + var remoteAlbum = Builder.CreateNew() + .With(e => e.ParsedAlbumInfo = _parsedAlbumInfo) + .With(e => e.Albums = _albums) + .Build(); + + Subject.IsSatisfiedBy(remoteAlbum, null) + .Accepted + .Should() + .BeFalse(); + } + + + [Test] + public void should_return_false_if_is_a_repack_for_different_group() + { + _parsedAlbumInfo.Quality.Revision.IsRepack = true; + + _trackFiles.Select(c => { c.ReleaseGroup = "NotLidarr"; return c; }).ToList(); + _trackFiles.Select(c => { c.Quality = new QualityModel(Quality.FLAC); return c; }).ToList(); + + var remoteAlbum = Builder.CreateNew() + .With(e => e.ParsedAlbumInfo = _parsedAlbumInfo) + .With(e => e.Albums = _albums) + .Build(); + + Subject.IsSatisfiedBy(remoteAlbum, null) + .Accepted + .Should() + .BeFalse(); + } + + + [Test] + public void should_return_false_if_release_group_for_existing_file_is_unknown() + { + _parsedAlbumInfo.Quality.Revision.IsRepack = true; + + _trackFiles.Select(c => { c.ReleaseGroup = ""; return c; }).ToList(); + _trackFiles.Select(c => { c.Quality = new QualityModel(Quality.FLAC); return c; }).ToList(); + + + var remoteAlbum = Builder.CreateNew() + .With(e => e.ParsedAlbumInfo = _parsedAlbumInfo) + .With(e => e.Albums = _albums) + .Build(); + + Subject.IsSatisfiedBy(remoteAlbum, null) + .Accepted + .Should() + .BeFalse(); + } + + [Test] + public void should_return_false_if_release_group_for_release_is_unknown() + { + _parsedAlbumInfo.Quality.Revision.IsRepack = true; + _parsedAlbumInfo.ReleaseGroup = null; + + _trackFiles.Select(c => { c.ReleaseGroup = "Lidarr"; return c; }).ToList(); + _trackFiles.Select(c => { c.Quality = new QualityModel(Quality.FLAC); return c; }).ToList(); + + + var remoteAlbum = Builder.CreateNew() + .With(e => e.ParsedAlbumInfo = _parsedAlbumInfo) + .With(e => e.Albums = _albums) + .Build(); + + Subject.IsSatisfiedBy(remoteAlbum, null) + .Accepted + .Should() + .BeFalse(); + } + } +} diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/RetentionSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/RetentionSpecificationFixture.cs index a9c8ace61..243f7c740 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/RetentionSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/RetentionSpecificationFixture.cs @@ -13,12 +13,12 @@ namespace NzbDrone.Core.Test.DecisionEngineTests public class RetentionSpecificationFixture : CoreTest { - private RemoteEpisode _remoteEpisode; + private RemoteAlbum _remoteAlbum; [SetUp] public void Setup() { - _remoteEpisode = new RemoteEpisode + _remoteAlbum = new RemoteAlbum { Release = new ReleaseInfo() { DownloadProtocol = DownloadProtocol.Usenet } }; @@ -31,7 +31,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests private void WithAge(int days) { - _remoteEpisode.Release.PublishDate = DateTime.UtcNow.AddDays(-days); + _remoteAlbum.Release.PublishDate = DateTime.UtcNow.AddDays(-days); } [Test] @@ -40,7 +40,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests WithRetention(0); WithAge(100); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); } [Test] @@ -49,7 +49,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests WithRetention(1000); WithAge(100); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); } [Test] @@ -58,7 +58,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests WithRetention(100); WithAge(100); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); } [Test] @@ -67,7 +67,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests WithRetention(10); WithAge(100); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); } [Test] @@ -76,18 +76,18 @@ namespace NzbDrone.Core.Test.DecisionEngineTests WithRetention(0); WithAge(100); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); } [Test] public void should_return_true_when_release_is_not_usenet() { - _remoteEpisode.Release.DownloadProtocol = DownloadProtocol.Torrent; + _remoteAlbum.Release.DownloadProtocol = DownloadProtocol.Torrent; WithRetention(10); WithAge(100); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); } } } \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DelaySpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DelaySpecificationFixture.cs index 2bbe1ae24..4aaf82e1a 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DelaySpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DelaySpecificationFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using FizzWare.NBuilder; @@ -6,101 +6,104 @@ using FluentAssertions; using Marr.Data; using Moq; using NUnit.Framework; -using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.DecisionEngine.Specifications.RssSync; using NzbDrone.Core.Download.Pending; using NzbDrone.Core.Indexers; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Profiles; +using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Profiles.Delay; using NzbDrone.Core.Qualities; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync { [TestFixture] public class DelaySpecificationFixture : CoreTest { - private Profile _profile; + private QualityProfile _profile; private DelayProfile _delayProfile; - private RemoteEpisode _remoteEpisode; + private RemoteAlbum _remoteAlbum; [SetUp] public void Setup() { - _profile = Builder.CreateNew() + _profile = Builder.CreateNew() .Build(); + _delayProfile = Builder.CreateNew() - .With(d => d.PreferredProtocol = DownloadProtocol.Usenet) - .Build(); + .With(d => d.PreferredProtocol = DownloadProtocol.Usenet) + .Build(); - var series = Builder.CreateNew() - .With(s => s.Profile = _profile) + var artist = Builder.CreateNew() + .With(s => s.QualityProfile = _profile) .Build(); - _remoteEpisode = Builder.CreateNew() - .With(r => r.Series = series) + _remoteAlbum = Builder.CreateNew() + .With(r => r.Artist = artist) .Build(); - _profile.Items = new List(); - _profile.Items.Add(new ProfileQualityItem { Allowed = true, Quality = Quality.HDTV720p }); - _profile.Items.Add(new ProfileQualityItem { Allowed = true, Quality = Quality.WEBDL720p }); - _profile.Items.Add(new ProfileQualityItem { Allowed = true, Quality = Quality.Bluray720p }); + _profile.Items = new List(); + _profile.Items.Add(new QualityProfileQualityItem { Allowed = true, Quality = Quality.MP3_256 }); + _profile.Items.Add(new QualityProfileQualityItem { Allowed = true, Quality = Quality.MP3_320 }); + _profile.Items.Add(new QualityProfileQualityItem { Allowed = true, Quality = Quality.MP3_320 }); + + _profile.Cutoff = Quality.MP3_320.Id; - _profile.Cutoff = Quality.WEBDL720p; + _remoteAlbum.ParsedAlbumInfo = new ParsedAlbumInfo(); + _remoteAlbum.Release = new ReleaseInfo(); + _remoteAlbum.Release.DownloadProtocol = DownloadProtocol.Usenet; - _remoteEpisode.ParsedEpisodeInfo = new ParsedEpisodeInfo(); - _remoteEpisode.Release = new ReleaseInfo(); - _remoteEpisode.Release.DownloadProtocol = DownloadProtocol.Usenet; + _remoteAlbum.Albums = Builder.CreateListOfSize(1).Build().ToList(); - _remoteEpisode.Episodes = Builder.CreateListOfSize(1).Build().ToList(); - _remoteEpisode.Episodes.First().EpisodeFileId = 0; + Mocker.GetMock() + .Setup(s => s.GetFilesByAlbum(It.IsAny())) + .Returns(new List { }); Mocker.GetMock() .Setup(s => s.BestForTags(It.IsAny>())) .Returns(_delayProfile); Mocker.GetMock() - .Setup(s => s.GetPendingRemoteEpisodes(It.IsAny())) - .Returns(new List()); + .Setup(s => s.GetPendingRemoteAlbums(It.IsAny())) + .Returns(new List()); } private void GivenExistingFile(QualityModel quality) { - _remoteEpisode.Episodes.First().EpisodeFileId = 1; - - _remoteEpisode.Episodes.First().EpisodeFile = new LazyLoaded(new EpisodeFile - { - Quality = quality - }); + Mocker.GetMock() + .Setup(s => s.GetFilesByAlbum(It.IsAny())) + .Returns(new List { new TrackFile { + Quality = quality + } }); } private void GivenUpgradeForExistingFile() { - Mocker.GetMock() - .Setup(s => s.IsUpgradable(It.IsAny(), It.IsAny(), It.IsAny())) + Mocker.GetMock() + .Setup(s => s.IsUpgradable(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(true); } [Test] public void should_be_true_when_user_invoked_search() { - Subject.IsSatisfiedBy(new RemoteEpisode(), new SingleEpisodeSearchCriteria { UserInvokedSearch = true }).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(new RemoteAlbum(), new AlbumSearchCriteria { UserInvokedSearch = true }).Accepted.Should().BeTrue(); } [Test] public void should_be_false_when_system_invoked_search_and_release_is_younger_than_delay() { - _remoteEpisode.ParsedEpisodeInfo.Quality = new QualityModel(Quality.SDTV); - _remoteEpisode.Release.PublishDate = DateTime.UtcNow; + _remoteAlbum.ParsedAlbumInfo.Quality = new QualityModel(Quality.MP3_192); + _remoteAlbum.Release.PublishDate = DateTime.UtcNow; _delayProfile.UsenetDelay = 720; - Subject.IsSatisfiedBy(_remoteEpisode, new SingleEpisodeSearchCriteria()).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_remoteAlbum, new AlbumSearchCriteria()).Accepted.Should().BeFalse(); } [Test] @@ -108,86 +111,86 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync { _delayProfile.UsenetDelay = 0; - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); } [Test] public void should_be_true_when_quality_is_last_allowed_in_profile() { - _remoteEpisode.ParsedEpisodeInfo.Quality = new QualityModel(Quality.Bluray720p); + _remoteAlbum.ParsedAlbumInfo.Quality = new QualityModel(Quality.MP3_320); - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); } [Test] public void should_be_true_when_release_is_older_than_delay() { - _remoteEpisode.ParsedEpisodeInfo.Quality = new QualityModel(Quality.HDTV720p); - _remoteEpisode.Release.PublishDate = DateTime.UtcNow.AddHours(-10); + _remoteAlbum.ParsedAlbumInfo.Quality = new QualityModel(Quality.MP3_256); + _remoteAlbum.Release.PublishDate = DateTime.UtcNow.AddHours(-10); _delayProfile.UsenetDelay = 60; - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); } [Test] public void should_be_false_when_release_is_younger_than_delay() { - _remoteEpisode.ParsedEpisodeInfo.Quality = new QualityModel(Quality.SDTV); - _remoteEpisode.Release.PublishDate = DateTime.UtcNow; + _remoteAlbum.ParsedAlbumInfo.Quality = new QualityModel(Quality.MP3_192); + _remoteAlbum.Release.PublishDate = DateTime.UtcNow; _delayProfile.UsenetDelay = 720; - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); } [Test] - public void should_be_true_when_release_is_a_proper_for_existing_episode() + public void should_be_true_when_release_is_a_proper_for_existing_album() { - _remoteEpisode.ParsedEpisodeInfo.Quality = new QualityModel(Quality.HDTV720p, new Revision(version: 2)); - _remoteEpisode.Release.PublishDate = DateTime.UtcNow; + _remoteAlbum.ParsedAlbumInfo.Quality = new QualityModel(Quality.MP3_256, new Revision(version: 2)); + _remoteAlbum.Release.PublishDate = DateTime.UtcNow; - GivenExistingFile(new QualityModel(Quality.HDTV720p)); + GivenExistingFile(new QualityModel(Quality.MP3_256)); GivenUpgradeForExistingFile(); - Mocker.GetMock() + Mocker.GetMock() .Setup(s => s.IsRevisionUpgrade(It.IsAny(), It.IsAny())) .Returns(true); _delayProfile.UsenetDelay = 720; - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); } [Test] - public void should_be_true_when_release_is_a_real_for_existing_episode() + public void should_be_true_when_release_is_a_real_for_existing_album() { - _remoteEpisode.ParsedEpisodeInfo.Quality = new QualityModel(Quality.HDTV720p, new Revision(real: 1)); - _remoteEpisode.Release.PublishDate = DateTime.UtcNow; + _remoteAlbum.ParsedAlbumInfo.Quality = new QualityModel(Quality.MP3_256, new Revision(real: 1)); + _remoteAlbum.Release.PublishDate = DateTime.UtcNow; - GivenExistingFile(new QualityModel(Quality.HDTV720p)); + GivenExistingFile(new QualityModel(Quality.MP3_256)); GivenUpgradeForExistingFile(); - Mocker.GetMock() + Mocker.GetMock() .Setup(s => s.IsRevisionUpgrade(It.IsAny(), It.IsAny())) .Returns(true); _delayProfile.UsenetDelay = 720; - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); } [Test] - public void should_be_false_when_release_is_proper_for_existing_episode_of_different_quality() + public void should_be_false_when_release_is_proper_for_existing_album_of_different_quality() { - _remoteEpisode.ParsedEpisodeInfo.Quality = new QualityModel(Quality.HDTV720p, new Revision(version: 2)); - _remoteEpisode.Release.PublishDate = DateTime.UtcNow; + _remoteAlbum.ParsedAlbumInfo.Quality = new QualityModel(Quality.MP3_256, new Revision(version: 2)); + _remoteAlbum.Release.PublishDate = DateTime.UtcNow; - GivenExistingFile(new QualityModel(Quality.SDTV)); + GivenExistingFile(new QualityModel(Quality.MP3_192)); _delayProfile.UsenetDelay = 720; - Subject.IsSatisfiedBy(_remoteEpisode, null).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); } } } diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DeletedTrackFileSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DeletedTrackFileSpecificationFixture.cs new file mode 100644 index 000000000..d581c5194 --- /dev/null +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DeletedTrackFileSpecificationFixture.cs @@ -0,0 +1,156 @@ +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.Qualities; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Music; +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 DeletedTrackFileSpecificationFixture : CoreTest + { + private RemoteAlbum _parseResultMulti; + private RemoteAlbum _parseResultSingle; + private TrackFile _firstFile; + private TrackFile _secondFile; + + [SetUp] + public void Setup() + { + _firstFile = + new TrackFile{ + Id = 1, + Path = "/My.Artist.S01E01.mp3", + Quality = new QualityModel(Quality.FLAC, new Revision(version: 1)), + DateAdded = DateTime.Now, + AlbumId = 1 + + }; + _secondFile = + new TrackFile{ + Id = 2, + Path = "/My.Artist.S01E02.mp3", + Quality = new QualityModel(Quality.FLAC, new Revision(version: 1)), + DateAdded = DateTime.Now, + AlbumId = 2 + + }; + + var singleAlbumList = new List { new Album { Id = 1 } }; + var doubleAlbumList = new List { + new Album { Id = 1 }, + new Album { Id = 2 } + }; + + var firstTrack = new Track { TrackFile = _firstFile, TrackFileId = 1, AlbumId = 1 }; + var secondTrack = new Track { TrackFile = _secondFile, TrackFileId = 2, AlbumId = 2 }; + + var fakeArtist = Builder.CreateNew() + .With(c => c.QualityProfile = new QualityProfile { Cutoff = Quality.FLAC.Id }) + .With(c => c.Path = @"C:\Music\My.Artist".AsOsAgnostic()) + .Build(); + + _parseResultMulti = new RemoteAlbum + { + Artist = fakeArtist, + ParsedAlbumInfo = new ParsedAlbumInfo { Quality = new QualityModel(Quality.MP3_256, new Revision(version: 2)) }, + Albums = doubleAlbumList + }; + + _parseResultSingle = new RemoteAlbum + { + Artist = fakeArtist, + ParsedAlbumInfo = new ParsedAlbumInfo { Quality = new QualityModel(Quality.MP3_256, new Revision(version: 2)) }, + Albums = singleAlbumList + }; + + GivenUnmonitorDeletedTracks(true); + } + + private void GivenUnmonitorDeletedTracks(bool enabled) + { + Mocker.GetMock() + .SetupGet(v => v.AutoUnmonitorPreviouslyDownloadedTracks) + .Returns(enabled); + } + + private void SetupMediaFile(List files) + { + Mocker.GetMock() + .Setup(v => v.GetFilesByAlbum(It.IsAny())) + .Returns(files); + } + + private void WithExistingFile(TrackFile trackFile) + { + var path = trackFile.Path; + + Mocker.GetMock() + .Setup(v => v.FileExists(path)) + .Returns(true); + } + + [Test] + public void should_return_true_when_unmonitor_deleted_tracks_is_off() + { + GivenUnmonitorDeletedTracks(false); + + Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_true_when_searching() + { + Subject.IsSatisfiedBy(_parseResultSingle, new ArtistSearchCriteria()).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_true_if_file_exists() + { + WithExistingFile(_firstFile); + SetupMediaFile(new List { _firstFile }); + + Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_false_if_file_is_missing() + { + SetupMediaFile(new List { _firstFile }); + Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeFalse(); + } + + [Test] + public void should_return_true_if_both_of_multiple_episode_exist() + { + WithExistingFile(_firstFile); + WithExistingFile(_secondFile); + SetupMediaFile(new List { _firstFile, _secondFile }); + + Subject.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_false_if_one_of_multiple_episode_is_missing() + { + WithExistingFile(_firstFile); + SetupMediaFile(new List { _firstFile, _secondFile }); + + Subject.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeFalse(); + } + } +} diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/ProperSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/ProperSpecificationFixture.cs index 0d711c1a0..38768fbd5 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/ProperSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/ProperSpecificationFixture.cs @@ -1,17 +1,18 @@ -using System; +using System; using System.Collections.Generic; using FizzWare.NBuilder; using FluentAssertions; using NUnit.Framework; +using Moq; 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.Profiles.Qualities; using NzbDrone.Core.Qualities; -using NzbDrone.Core.Tv; -using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Music; +using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Test.Framework; @@ -21,84 +22,82 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync public class ProperSpecificationFixture : CoreTest { - private RemoteEpisode _parseResultMulti; - private RemoteEpisode _parseResultSingle; - private EpisodeFile _firstFile; - private EpisodeFile _secondFile; + private RemoteAlbum _parseResultMulti; + private RemoteAlbum _parseResultSingle; + private TrackFile _firstFile; + private TrackFile _secondFile; [SetUp] public void Setup() { - Mocker.Resolve(); + Mocker.Resolve(); - _firstFile = new EpisodeFile { Quality = new QualityModel(Quality.Bluray1080p, new Revision(version: 1)), DateAdded = DateTime.Now }; - _secondFile = new EpisodeFile { Quality = new QualityModel(Quality.Bluray1080p, new Revision(version: 1)), DateAdded = DateTime.Now }; + _firstFile = new TrackFile { Quality = new QualityModel(Quality.FLAC, new Revision(version: 1)), DateAdded = DateTime.Now }; + _secondFile = new TrackFile { Quality = new QualityModel(Quality.FLAC, new Revision(version: 1)), DateAdded = DateTime.Now }; - var singleEpisodeList = new List { new Episode { EpisodeFile = _firstFile, EpisodeFileId = 1 }, new Episode { EpisodeFile = null } }; - var doubleEpisodeList = new List { new Episode { EpisodeFile = _firstFile, EpisodeFileId = 1 }, new Episode { EpisodeFile = _secondFile, EpisodeFileId = 1 }, new Episode { EpisodeFile = null } }; + var singleAlbumList = new List { new Album {}, new Album {} }; + var doubleAlbumList = new List { new Album {}, new Album {}, new Album {} }; - var fakeSeries = Builder.CreateNew() - .With(c => c.Profile = new Profile { Cutoff = Quality.Bluray1080p }) + + var fakeArtist = Builder.CreateNew() + .With(c => c.QualityProfile = new QualityProfile { Cutoff = Quality.FLAC.Id }) .Build(); - _parseResultMulti = new RemoteEpisode + Mocker.GetMock() + .Setup(c => c.GetFilesByAlbum(It.IsAny())) + .Returns(new List { _firstFile, _secondFile }); + + _parseResultMulti = new RemoteAlbum { - Series = fakeSeries, - ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.DVD, new Revision(version: 2)) }, - Episodes = doubleEpisodeList + Artist = fakeArtist, + ParsedAlbumInfo = new ParsedAlbumInfo { Quality = new QualityModel(Quality.MP3_256, new Revision(version: 2)) }, + Albums = doubleAlbumList }; - _parseResultSingle = new RemoteEpisode + _parseResultSingle = new RemoteAlbum { - Series = fakeSeries, - ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.DVD, new Revision(version: 2)) }, - Episodes = singleEpisodeList + Artist = fakeArtist, + ParsedAlbumInfo = new ParsedAlbumInfo { Quality = new QualityModel(Quality.MP3_256, new Revision(version: 2)) }, + Albums = singleAlbumList }; } private void WithFirstFileUpgradable() { - _firstFile.Quality = new QualityModel(Quality.SDTV); - } - - private void GivenAutoDownloadPropers() - { - Mocker.GetMock() - .Setup(s => s.AutoDownloadPropers) - .Returns(true); + _firstFile.Quality = new QualityModel(Quality.MP3_192); } [Test] - public void should_return_false_when_episodeFile_was_added_more_than_7_days_ago() + public void should_return_false_when_trackFile_was_added_more_than_7_days_ago() { - _firstFile.Quality.Quality = Quality.DVD; + _firstFile.Quality.Quality = Quality.MP3_256; _firstFile.DateAdded = DateTime.Today.AddDays(-30); Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeFalse(); } [Test] - public void should_return_false_when_first_episodeFile_was_added_more_than_7_days_ago() + public void should_return_false_when_first_trackFile_was_added_more_than_7_days_ago() { - _firstFile.Quality.Quality = Quality.DVD; - _secondFile.Quality.Quality = Quality.DVD; + _firstFile.Quality.Quality = Quality.MP3_256; + _secondFile.Quality.Quality = Quality.MP3_256; _firstFile.DateAdded = DateTime.Today.AddDays(-30); Subject.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeFalse(); } [Test] - public void should_return_false_when_second_episodeFile_was_added_more_than_7_days_ago() + public void should_return_false_when_second_trackFile_was_added_more_than_7_days_ago() { - _firstFile.Quality.Quality = Quality.DVD; - _secondFile.Quality.Quality = Quality.DVD; + _firstFile.Quality.Quality = Quality.MP3_256; + _secondFile.Quality.Quality = Quality.MP3_256; _secondFile.DateAdded = DateTime.Today.AddDays(-30); Subject.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeFalse(); } [Test] - public void should_return_true_when_episodeFile_was_added_more_than_7_days_ago_but_proper_is_for_better_quality() + public void should_return_true_when_trackFile_was_added_more_than_7_days_ago_but_proper_is_for_better_quality() { WithFirstFileUpgradable(); @@ -107,32 +106,51 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync } [Test] - public void should_return_true_when_episodeFile_was_added_more_than_7_days_ago_but_is_for_search() + public void should_return_true_when_trackFile_was_added_more_than_7_days_ago_but_is_for_search() { WithFirstFileUpgradable(); _firstFile.DateAdded = DateTime.Today.AddDays(-30); - Subject.IsSatisfiedBy(_parseResultSingle, new SingleEpisodeSearchCriteria()).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_parseResultSingle, new AlbumSearchCriteria()).Accepted.Should().BeTrue(); } [Test] public void should_return_false_when_proper_but_auto_download_propers_is_false() { - _firstFile.Quality.Quality = Quality.DVD; + Mocker.GetMock() + .Setup(s => s.DownloadPropersAndRepacks) + .Returns(ProperDownloadTypes.DoNotUpgrade); + + _firstFile.Quality.Quality = Quality.MP3_256; _firstFile.DateAdded = DateTime.Today; Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeFalse(); } [Test] - public void should_return_true_when_episodeFile_was_added_today() + public void should_return_true_when_trackFile_was_added_today() + { + Mocker.GetMock() + .Setup(s => s.DownloadPropersAndRepacks) + .Returns(ProperDownloadTypes.PreferAndUpgrade); + + _firstFile.Quality.Quality = Quality.MP3_256; + + _firstFile.DateAdded = DateTime.Today; + Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_true_when_propers_are_not_preferred() { - GivenAutoDownloadPropers(); + Mocker.GetMock() + .Setup(s => s.DownloadPropersAndRepacks) + .Returns(ProperDownloadTypes.DoNotPrefer); - _firstFile.Quality.Quality = Quality.DVD; + _firstFile.Quality.Quality = Quality.MP3_256; _firstFile.DateAdded = DateTime.Today; Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue(); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/SameEpisodesSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/SameEpisodesSpecificationFixture.cs deleted file mode 100644 index 183b6cc77..000000000 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/SameEpisodesSpecificationFixture.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using FizzWare.NBuilder; -using FluentAssertions; -using Moq; -using NUnit.Framework; -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Tv; - -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.DecisionEngineTests -{ - [TestFixture] - public class SameEpisodesSpecificationFixture : CoreTest - { - private List _episodes; - - [SetUp] - public void Setup() - { - _episodes = Builder.CreateListOfSize(2) - .All() - .With(e => e.EpisodeFileId = 1) - .BuildList(); - } - - private void GivenEpisodesInFile(List episodes) - { - Mocker.GetMock() - .Setup(s => s.GetEpisodesByFileId(It.IsAny())) - .Returns(episodes); - } - - [Test] - public void should_not_upgrade_when_new_release_contains_less_episodes() - { - GivenEpisodesInFile(_episodes); - - Subject.IsSatisfiedBy(new List { _episodes.First() }).Should().BeFalse(); - } - - [Test] - public void should_upgrade_when_new_release_contains_more_episodes() - { - GivenEpisodesInFile(new List { _episodes.First() }); - - Subject.IsSatisfiedBy(_episodes).Should().BeTrue(); - } - - [Test] - public void should_upgrade_when_new_release_contains_the_same_episodes() - { - GivenEpisodesInFile(_episodes); - - Subject.IsSatisfiedBy(_episodes).Should().BeTrue(); - } - - [Test] - public void should_upgrade_when_release_contains_the_same_episodes_as_multiple_files() - { - var episodes = Builder.CreateListOfSize(2) - .BuildList(); - - Mocker.GetMock() - .Setup(s => s.GetEpisodesByFileId(episodes.First().EpisodeFileId)) - .Returns(new List { episodes.First() }); - - Mocker.GetMock() - .Setup(s => s.GetEpisodesByFileId(episodes.Last().EpisodeFileId)) - .Returns(new List { episodes.Last() }); - - Subject.IsSatisfiedBy(episodes).Should().BeTrue(); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/Search/ArtistSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/Search/ArtistSpecificationFixture.cs new file mode 100644 index 000000000..423d2614f --- /dev/null +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/Search/ArtistSpecificationFixture.cs @@ -0,0 +1,45 @@ +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.DecisionEngine.Specifications.Search; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Music; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.DecisionEngineTests.Search +{ + [TestFixture] + public class ArtistSpecificationFixture : TestBase + { + private Artist _artist1; + private Artist _artist2; + private RemoteAlbum _remoteAlbum = new RemoteAlbum(); + private SearchCriteriaBase _searchCriteria = new AlbumSearchCriteria(); + + [SetUp] + public void Setup() + { + _artist1 = Builder.CreateNew().With(s => s.Id = 1).Build(); + _artist2 = Builder.CreateNew().With(s => s.Id = 2).Build(); + + _remoteAlbum.Artist = _artist1; + } + + [Test] + public void should_return_false_if_artist_doesnt_match() + { + _searchCriteria.Artist = _artist2; + + Subject.IsSatisfiedBy(_remoteAlbum, _searchCriteria).Accepted.Should().BeFalse(); + } + + [Test] + public void should_return_true_when_artist_ids_match() + { + _searchCriteria.Artist = _artist1; + + Subject.IsSatisfiedBy(_remoteAlbum, _searchCriteria).Accepted.Should().BeTrue(); + } + } +} diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/Search/SeriesSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/Search/SeriesSpecificationFixture.cs deleted file mode 100644 index 279890763..000000000 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/Search/SeriesSpecificationFixture.cs +++ /dev/null @@ -1,45 +0,0 @@ -using FizzWare.NBuilder; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.DecisionEngine.Specifications.Search; -using NzbDrone.Core.IndexerSearch.Definitions; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Tv; -using NzbDrone.Test.Common; - -namespace NzbDrone.Core.Test.DecisionEngineTests.Search -{ - [TestFixture] - public class SeriesSpecificationFixture : TestBase - { - private Series _series1; - private Series _series2; - private RemoteEpisode _remoteEpisode = new RemoteEpisode(); - private SearchCriteriaBase _searchCriteria = new SingleEpisodeSearchCriteria(); - - [SetUp] - public void Setup() - { - _series1 = Builder.CreateNew().With(s => s.Id = 1).Build(); - _series2 = Builder.CreateNew().With(s => s.Id = 2).Build(); - - _remoteEpisode.Series = _series1; - } - - [Test] - public void should_return_false_if_series_doesnt_match() - { - _searchCriteria.Series = _series2; - - Subject.IsSatisfiedBy(_remoteEpisode, _searchCriteria).Accepted.Should().BeFalse(); - } - - [Test] - public void should_return_true_when_series_ids_match() - { - _searchCriteria.Series = _series1; - - Subject.IsSatisfiedBy(_remoteEpisode, _searchCriteria).Accepted.Should().BeTrue(); - } - } -} 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..d284f6642 --- /dev/null +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/Search/TorrentSeedingSpecificationFixture.cs @@ -0,0 +1,111 @@ +using FizzWare.NBuilder; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Indexers.TorrentRss; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Music; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.DecisionEngineTests.Search +{ + [TestFixture] + public class TorrentSeedingSpecificationFixture : TestBase + { + private Artist _artist; + private RemoteAlbum _remoteAlbum; + private IndexerDefinition _indexerDefinition; + + [SetUp] + public void Setup() + { + _artist = Builder.CreateNew().With(s => s.Id = 1).Build(); + + _remoteAlbum = new RemoteAlbum + { + Artist = _artist, + Release = new TorrentInfo + { + IndexerId = 1, + Title = "Artist - Album [FLAC-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) + { + (_remoteAlbum.Release as TorrentInfo).Seeders = seeders; + } + + [Test] + public void should_return_true_if_not_torrent() + { + _remoteAlbum.Release = new ReleaseInfo + { + IndexerId = 1, + Title = "Artist - Album [FLAC-RlsGrp]" + }; + + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_true_if_indexer_not_specified() + { + _remoteAlbum.Release.IndexerId = 0; + + Subject.IsSatisfiedBy(_remoteAlbum, 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(_remoteAlbum, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_true_if_seeds_unknown() + { + GivenReleaseSeeders(null); + + Subject.IsSatisfiedBy(_remoteAlbum, 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(_remoteAlbum, null).Accepted.Should().BeTrue(); + } + + [TestCase(0)] + [TestCase(4)] + public void should_return_false_if_seeds_belove_limit(int seeders) + { + GivenReleaseSeeders(seeders); + + Subject.IsSatisfiedBy(_remoteAlbum, null).Accepted.Should().BeFalse(); + } + } +} diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeAllowedSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeAllowedSpecificationFixture.cs new file mode 100644 index 000000000..716dc0947 --- /dev/null +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeAllowedSpecificationFixture.cs @@ -0,0 +1,104 @@ +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Profiles.Qualities; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Test.Framework; +using System.Collections.Generic; + +namespace NzbDrone.Core.Test.DecisionEngineTests +{ + [TestFixture] + public class UpgradeAllowedSpecificationFixture : CoreTest + { + [Test] + public void should_return_false_when_quality_is_better_and_upgrade_allowed_is_false_for_quality_profile() + { + Subject.IsUpgradeAllowed( + new QualityProfile + { + Cutoff = Quality.FLAC.Id, + Items = Qualities.QualityFixture.GetDefaultQualities(), + UpgradeAllowed = false + }, + new List { new QualityModel(Quality.MP3_320) }, + new QualityModel(Quality.FLAC) + ).Should().BeFalse(); + } + + [Test] + public void should_return_true_for_quality_upgrade_when_upgrading_is_allowed() + { + Subject.IsUpgradeAllowed( + new QualityProfile + { + Cutoff = Quality.FLAC.Id, + Items = Qualities.QualityFixture.GetDefaultQualities(), + UpgradeAllowed = true + }, + new List { new QualityModel(Quality.MP3_320) }, + new QualityModel(Quality.FLAC) + ).Should().BeTrue(); + } + + [Test] + public void should_return_true_for_same_quality_when_upgrading_is_allowed() + { + Subject.IsUpgradeAllowed( + new QualityProfile + { + Cutoff = Quality.FLAC.Id, + Items = Qualities.QualityFixture.GetDefaultQualities(), + UpgradeAllowed = true + }, + new List { new QualityModel(Quality.MP3_320) }, + new QualityModel(Quality.MP3_320) + ).Should().BeTrue(); + } + + [Test] + public void should_return_true_for_same_quality_when_upgrading_is_not_allowed() + { + Subject.IsUpgradeAllowed( + new QualityProfile + { + Cutoff = Quality.FLAC.Id, + Items = Qualities.QualityFixture.GetDefaultQualities(), + UpgradeAllowed = false + }, + new List { new QualityModel(Quality.MP3_320) }, + new QualityModel(Quality.MP3_320) + ).Should().BeTrue(); + } + + [Test] + public void should_return_true_for_lower_quality_when_upgrading_is_allowed() + { + Subject.IsUpgradeAllowed( + new QualityProfile + { + Cutoff = Quality.FLAC.Id, + Items = Qualities.QualityFixture.GetDefaultQualities(), + UpgradeAllowed = true + }, + new List { new QualityModel(Quality.MP3_320) }, + new QualityModel(Quality.MP3_256) + ).Should().BeTrue(); + } + + [Test] + public void should_return_true_for_lower_quality_when_upgrading_is_not_allowed() + { + Subject.IsUpgradeAllowed( + new QualityProfile + { + Cutoff = Quality.FLAC.Id, + Items = Qualities.QualityFixture.GetDefaultQualities(), + UpgradeAllowed = false + }, + new List{ new QualityModel(Quality.MP3_320) }, + new QualityModel(Quality.MP3_256) + ).Should().BeTrue(); + } + } +} diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeDiskSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeDiskSpecificationFixture.cs index ab5795267..34aa10365 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeDiskSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeDiskSpecificationFixture.cs @@ -1,16 +1,16 @@ -using System; +using System; using System.Collections.Generic; using FizzWare.NBuilder; using FluentAssertions; using NUnit.Framework; +using Moq; using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Profiles; +using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Qualities; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; using NzbDrone.Core.DecisionEngine; - using NzbDrone.Core.Test.Framework; namespace NzbDrone.Core.Test.DecisionEngineTests @@ -19,110 +19,140 @@ namespace NzbDrone.Core.Test.DecisionEngineTests public class UpgradeDiskSpecificationFixture : CoreTest { - private UpgradeDiskSpecification _upgradeDisk; - - private RemoteEpisode _parseResultMulti; - private RemoteEpisode _parseResultSingle; - private EpisodeFile _firstFile; - private EpisodeFile _secondFile; + private RemoteAlbum _parseResultMulti; + private RemoteAlbum _parseResultSingle; + private TrackFile _firstFile; + private TrackFile _secondFile; [SetUp] public void Setup() { - Mocker.Resolve(); - _upgradeDisk = Mocker.Resolve(); + Mocker.Resolve(); - _firstFile = new EpisodeFile { Quality = new QualityModel(Quality.Bluray1080p, new Revision(version: 2)), DateAdded = DateTime.Now }; - _secondFile = new EpisodeFile { Quality = new QualityModel(Quality.Bluray1080p, new Revision(version: 2)), DateAdded = DateTime.Now }; + _firstFile = new TrackFile { Quality = new QualityModel(Quality.FLAC, new Revision(version: 2)), DateAdded = DateTime.Now }; + _secondFile = new TrackFile { Quality = new QualityModel(Quality.FLAC, new Revision(version: 2)), DateAdded = DateTime.Now }; - var singleEpisodeList = new List { new Episode { EpisodeFile = _firstFile, EpisodeFileId = 1 }, new Episode { EpisodeFile = null } }; - var doubleEpisodeList = new List { new Episode { EpisodeFile = _firstFile, EpisodeFileId = 1 }, new Episode { EpisodeFile = _secondFile, EpisodeFileId = 1 }, new Episode { EpisodeFile = null } }; + var singleAlbumList = new List { new Album {}}; + var doubleAlbumList = new List { new Album {}, new Album {}, new Album {} }; - var fakeSeries = Builder.CreateNew() - .With(c => c.Profile = new Profile { Cutoff = Quality.Bluray1080p, Items = Qualities.QualityFixture.GetDefaultQualities() }) + var fakeArtist = Builder.CreateNew() + .With(c => c.QualityProfile = new QualityProfile + { + UpgradeAllowed = true, + Cutoff = Quality.MP3_320.Id, + Items = Qualities.QualityFixture.GetDefaultQualities() + }) .Build(); - _parseResultMulti = new RemoteEpisode + Mocker.GetMock() + .Setup(c => c.TracksWithoutFiles(It.IsAny())) + .Returns(new List()); + + Mocker.GetMock() + .Setup(c => c.GetFilesByAlbum(It.IsAny())) + .Returns(new List { _firstFile, _secondFile }); + + _parseResultMulti = new RemoteAlbum { - Series = fakeSeries, - ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.DVD, new Revision(version: 2)) }, - Episodes = doubleEpisodeList + Artist = fakeArtist, + ParsedAlbumInfo = new ParsedAlbumInfo { Quality = new QualityModel(Quality.MP3_256, new Revision(version: 2)) }, + Albums = doubleAlbumList }; - _parseResultSingle = new RemoteEpisode + _parseResultSingle = new RemoteAlbum { - Series = fakeSeries, - ParsedEpisodeInfo = new ParsedEpisodeInfo { Quality = new QualityModel(Quality.DVD, new Revision(version: 2)) }, - Episodes = singleEpisodeList + Artist = fakeArtist, + ParsedAlbumInfo = new ParsedAlbumInfo { Quality = new QualityModel(Quality.MP3_256, new Revision(version: 2)) }, + Albums = singleAlbumList }; + } private void WithFirstFileUpgradable() { - _firstFile.Quality = new QualityModel(Quality.SDTV); + _firstFile.Quality = new QualityModel(Quality.MP3_192); } private void WithSecondFileUpgradable() { - _secondFile.Quality = new QualityModel(Quality.SDTV); + _secondFile.Quality = new QualityModel(Quality.MP3_192); } [Test] - public void should_return_true_if_episode_has_no_existing_file() + public void should_return_true_if_album_has_no_existing_file() { - _parseResultSingle.Episodes.ForEach(c => c.EpisodeFileId = 0); - _upgradeDisk.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue(); + Mocker.GetMock() + .Setup(c => c.GetFilesByAlbum(It.IsAny())) + .Returns(new List { }); + + Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue(); } [Test] - public void should_return_true_if_single_episode_doesnt_exist_on_disk() + public void should_return_true_if_track_is_missing() { - _parseResultSingle.Episodes = new List(); + Mocker.GetMock() + .Setup(c => c.TracksWithoutFiles(It.IsAny())) + .Returns(new List { new Track() }); - _upgradeDisk.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue(); } [Test] - public void should_be_upgradable_if_only_episode_is_upgradable() + public void should_only_query_db_for_missing_tracks_once() { - WithFirstFileUpgradable(); - _upgradeDisk.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeFalse(); + + Mocker.GetMock() + .Verify(c => c.TracksWithoutFiles(It.IsAny()), Times.Once()); } [Test] - public void should_be_upgradable_if_both_episodes_are_upgradable() + public void should_return_true_if_single_album_doesnt_exist_on_disk() + { + _parseResultSingle.Albums = new List(); + + Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_upgradable_if_all_files_are_upgradable() { WithFirstFileUpgradable(); WithSecondFileUpgradable(); - _upgradeDisk.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeTrue(); + Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue(); } [Test] - public void should_be_not_upgradable_if_both_episodes_are_not_upgradable() + public void should_not_be_upgradable_if_qualities_are_the_same() { - _upgradeDisk.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeFalse(); + _firstFile.Quality = new QualityModel(Quality.MP3_320); + _secondFile.Quality = new QualityModel(Quality.MP3_320); + _parseResultSingle.ParsedAlbumInfo.Quality = new QualityModel(Quality.MP3_320); + Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeFalse(); } [Test] - public void should_be_not_upgradable_if_only_first_episodes_is_upgradable() + public void should_not_be_upgradable_if_all_tracks_are_not_upgradable() { - WithFirstFileUpgradable(); - _upgradeDisk.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeFalse(); } [Test] - public void should_be_not_upgradable_if_only_second_episodes_is_upgradable() + public void should_be_true_if_some_tracks_are_upgradable_and_none_are_downgrades() { - WithSecondFileUpgradable(); - _upgradeDisk.IsSatisfiedBy(_parseResultMulti, null).Accepted.Should().BeFalse(); + WithFirstFileUpgradable(); + _parseResultSingle.ParsedAlbumInfo.Quality = _secondFile.Quality; + Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue(); } [Test] - public void should_not_be_upgradable_if_qualities_are_the_same() + public void should_be_false_if_some_tracks_are_upgradable_and_some_are_downgrades() { - _firstFile.Quality = new QualityModel(Quality.WEBDL1080p); - _parseResultSingle.ParsedEpisodeInfo.Quality = new QualityModel(Quality.WEBDL1080p); - _upgradeDisk.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeFalse(); + WithFirstFileUpgradable(); + _parseResultSingle.ParsedAlbumInfo.Quality = new QualityModel(Quality.MP3_320); + Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeFalse(); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeSpecificationFixture.cs new file mode 100644 index 000000000..ba3c4f80a --- /dev/null +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeSpecificationFixture.cs @@ -0,0 +1,93 @@ +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Profiles.Qualities; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Test.Framework; +using System.Collections.Generic; + +namespace NzbDrone.Core.Test.DecisionEngineTests +{ + [TestFixture] + + public class UpgradeSpecificationFixture : CoreTest + { + public static object[] IsUpgradeTestCases = + { + new object[] { Quality.MP3_192, 1, Quality.MP3_192, 2, Quality.MP3_192, true }, + new object[] { Quality.MP3_320, 1, Quality.MP3_320, 2, Quality.MP3_320, true }, + new object[] { Quality.MP3_192, 1, Quality.MP3_192, 1, Quality.MP3_192, false }, + new object[] { Quality.MP3_320, 1, Quality.MP3_256, 2, Quality.MP3_320, false }, + new object[] { Quality.MP3_320, 1, Quality.MP3_256, 2, Quality.MP3_320, false }, + new object[] { Quality.MP3_320, 1, Quality.MP3_320, 1, Quality.MP3_320, false } + }; + + private static readonly int NoPreferredWordScore = 0; + + private void GivenAutoDownloadPropers(ProperDownloadTypes type) + { + Mocker.GetMock() + .SetupGet(s => s.DownloadPropersAndRepacks) + .Returns(type); + } + + [Test, TestCaseSource(nameof(IsUpgradeTestCases))] + public void IsUpgradeTest(Quality current, int currentVersion, Quality newQuality, int newVersion, Quality cutoff, bool expected) + { + GivenAutoDownloadPropers(ProperDownloadTypes.PreferAndUpgrade); + + var profile = new QualityProfile + { + UpgradeAllowed = true, + Items = Qualities.QualityFixture.GetDefaultQualities() + }; + + Subject.IsUpgradable( + profile, + new List { new QualityModel(current, new Revision(version: currentVersion)) }, + NoPreferredWordScore, + new QualityModel(newQuality, new Revision(version: newVersion)), + NoPreferredWordScore) + .Should().Be(expected); + } + + [Test] + public void should_return_true_if_proper_and_download_propers_is_do_not_download() + { + GivenAutoDownloadPropers(ProperDownloadTypes.DoNotUpgrade); + + var profile = new QualityProfile + { + Items = Qualities.QualityFixture.GetDefaultQualities(), + }; + + Subject.IsUpgradable( + profile, + new List { new QualityModel(Quality.MP3_256, new Revision(version: 1)) }, + NoPreferredWordScore, + new QualityModel(Quality.MP3_256, new Revision(version: 2)), + NoPreferredWordScore) + .Should().BeTrue(); + } + + [Test] + public void should_return_false_if_proper_and_autoDownloadPropers_is_do_not_prefer() + { + GivenAutoDownloadPropers(ProperDownloadTypes.DoNotPrefer); + + var profile = new QualityProfile + { + Items = Qualities.QualityFixture.GetDefaultQualities(), + }; + + Subject.IsUpgradable( + profile, + new List { new QualityModel(Quality.MP3_256, new Revision(version: 1)) }, + NoPreferredWordScore, + new QualityModel(Quality.MP3_256, new Revision(version: 2)), + NoPreferredWordScore) + .Should().BeFalse(); + } + } +} diff --git a/src/NzbDrone.Core.Test/DiskSpace/DiskSpaceServiceFixture.cs b/src/NzbDrone.Core.Test/DiskSpace/DiskSpaceServiceFixture.cs index d7650c204..2c15d650e 100644 --- a/src/NzbDrone.Core.Test/DiskSpace/DiskSpaceServiceFixture.cs +++ b/src/NzbDrone.Core.Test/DiskSpace/DiskSpaceServiceFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -9,7 +9,7 @@ using NzbDrone.Common.Disk; using NzbDrone.Core.Configuration; using NzbDrone.Core.DiskSpace; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.DiskSpace @@ -17,16 +17,14 @@ namespace NzbDrone.Core.Test.DiskSpace [TestFixture] public class DiskSpaceServiceFixture : CoreTest { - private string _seriesFolder; - private string _seriesFolder2; - private string _droneFactoryFolder; + private string _artistFolder; + private string _artostFolder2; [SetUp] public void SetUp() { - _seriesFolder = @"G:\fasdlfsdf\series".AsOsAgnostic(); - _seriesFolder2 = @"G:\fasdlfsdf\series2".AsOsAgnostic(); - _droneFactoryFolder = @"G:\dronefactory".AsOsAgnostic(); + _artistFolder = @"G:\fasdlfsdf\artist".AsOsAgnostic(); + _artostFolder2 = @"G:\fasdlfsdf\artist2".AsOsAgnostic(); Mocker.GetMock() .Setup(v => v.GetMounts()) @@ -44,14 +42,14 @@ namespace NzbDrone.Core.Test.DiskSpace .Setup(v => v.GetTotalSize(It.IsAny())) .Returns(0); - GivenSeries(); + GivenArtist(); } - private void GivenSeries(params Series[] series) + private void GivenArtist(params Artist[] artist) { - Mocker.GetMock() - .Setup(v => v.GetAllSeries()) - .Returns(series.ToList()); + Mocker.GetMock() + .Setup(v => v.GetAllArtists()) + .Returns(artist.ToList()); } private void GivenExistingFolder(string folder) @@ -62,11 +60,11 @@ namespace NzbDrone.Core.Test.DiskSpace } [Test] - public void should_check_diskspace_for_series_folders() + public void should_check_diskspace_for_artist_folders() { - GivenSeries(new Series { Path = _seriesFolder }); + GivenArtist(new Artist { Path = _artistFolder }); - GivenExistingFolder(_seriesFolder); + GivenExistingFolder(_artistFolder); var freeSpace = Subject.GetFreeSpace(); @@ -76,10 +74,10 @@ namespace NzbDrone.Core.Test.DiskSpace [Test] public void should_check_diskspace_for_same_root_folder_only_once() { - GivenSeries(new Series { Path = _seriesFolder }, new Series { Path = _seriesFolder2 }); + GivenArtist(new Artist { Path = _artistFolder }, new Artist { Path = _artostFolder2 }); - GivenExistingFolder(_seriesFolder); - GivenExistingFolder(_seriesFolder2); + GivenExistingFolder(_artistFolder); + GivenExistingFolder(_artostFolder2); var freeSpace = Subject.GetFreeSpace(); @@ -90,9 +88,9 @@ namespace NzbDrone.Core.Test.DiskSpace } [Test] - public void should_not_check_diskspace_for_missing_series_folders() + public void should_not_check_diskspace_for_missing_artist_folders() { - GivenSeries(new Series { Path = _seriesFolder }); + GivenArtist(new Artist { Path = _artistFolder }); var freeSpace = Subject.GetFreeSpace(); @@ -102,33 +100,26 @@ namespace NzbDrone.Core.Test.DiskSpace .Verify(v => v.GetAvailableSpace(It.IsAny()), Times.Never()); } - [Test] - public void should_check_diskspace_for_dronefactory_folder() + [TestCase("/boot")] + [TestCase("/var/lib/rancher")] + [TestCase("/var/lib/rancher/volumes")] + [TestCase("/var/lib/kubelet")] + [TestCase("/var/lib/docker")] + [TestCase("/some/place/docker/aufs")] + [TestCase("/etc/network")] + public void should_not_check_diskspace_for_irrelevant_mounts(string path) { - Mocker.GetMock() - .SetupGet(v => v.DownloadedEpisodesFolder) - .Returns(_droneFactoryFolder); - - GivenExistingFolder(_droneFactoryFolder); - - var freeSpace = Subject.GetFreeSpace(); - - freeSpace.Should().NotBeEmpty(); - } + var mount = new Mock(); + mount.SetupGet(v => v.RootDirectory).Returns(path); + mount.SetupGet(v => v.DriveType).Returns(System.IO.DriveType.Fixed); - [Test] - public void should_not_check_diskspace_for_missing_dronefactory_folder() - { - Mocker.GetMock() - .SetupGet(v => v.DownloadedEpisodesFolder) - .Returns(_droneFactoryFolder); + Mocker.GetMock() + .Setup(v => v.GetMounts()) + .Returns(new List { mount.Object }); var freeSpace = Subject.GetFreeSpace(); freeSpace.Should().BeEmpty(); - - Mocker.GetMock() - .Verify(v => v.GetAvailableSpace(It.IsAny()), Times.Never()); } } } diff --git a/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceFixture.cs b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceFixture.cs index 3a1d29ba3..acb65a85c 100644 --- a/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceFixture.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using FizzWare.NBuilder; using FluentAssertions; using Moq; @@ -10,13 +10,14 @@ using NzbDrone.Core.Download; using NzbDrone.Core.Download.TrackedDownloads; using NzbDrone.Core.History; using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.MediaFiles.EpisodeImport; +using NzbDrone.Core.MediaFiles.TrackImport; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; using NzbDrone.Test.Common; +using NzbDrone.Core.MediaFiles.Events; namespace NzbDrone.Core.Test.Download { @@ -34,12 +35,12 @@ namespace NzbDrone.Core.Test.Download .With(h => h.Title = "Drone.S01E01.HDTV") .Build(); - var remoteEpisode = BuildRemoteEpisode(); + var remoteAlbum = BuildRemoteAlbum(); _trackedDownload = Builder.CreateNew() .With(c => c.State = TrackedDownloadStage.Downloading) .With(c => c.DownloadItem = completed) - .With(c => c.RemoteEpisode = remoteEpisode) + .With(c => c.RemoteAlbum = remoteAlbum) .Build(); @@ -56,17 +57,30 @@ namespace NzbDrone.Core.Test.Download .Returns(new History.History()); Mocker.GetMock() - .Setup(s => s.GetSeries("Drone.S01E01.HDTV")) - .Returns(remoteEpisode.Series); + .Setup(s => s.GetArtist("Drone.S01E01.HDTV")) + .Returns(remoteAlbum.Artist); } - private RemoteEpisode BuildRemoteEpisode() + private Album CreateAlbum(int id, int trackCount) { - return new RemoteEpisode + return new Album { + Id = id, + AlbumReleases = new List { + new AlbumRelease { + Monitored = true, + TrackCount = trackCount + } + } + }; + } + + private RemoteAlbum BuildRemoteAlbum() + { + return new RemoteAlbum { - Series = new Series(), - Episodes = new List { new Episode { Id = 1 } } + Artist = new Artist(), + Albums = new List { CreateAlbum(1, 1) } }; } @@ -80,17 +94,18 @@ namespace NzbDrone.Core.Test.Download private void GivenSuccessfulImport() { - Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + Mocker.GetMock() + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { - new ImportResult(new ImportDecision(new LocalEpisode() { Path = @"C:\TestPath\Droned.S01E01.mkv" })) + new ImportResult(new ImportDecision(new LocalTrack { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic() })) }); } private void GivenABadlyNamedDownload() { + _trackedDownload.RemoteAlbum.Artist = null; _trackedDownload.DownloadItem.DownloadId = "1234"; _trackedDownload.DownloadItem.Title = "Droned Pilot"; // Set a badly named download Mocker.GetMock() @@ -98,19 +113,19 @@ namespace NzbDrone.Core.Test.Download .Returns(new History.History() { SourceTitle = "Droned S01E01" }); Mocker.GetMock() - .Setup(s => s.GetSeries(It.IsAny())) - .Returns((Series)null); + .Setup(s => s.GetArtist(It.IsAny())) + .Returns((Artist)null); Mocker.GetMock() - .Setup(s => s.GetSeries("Droned S01E01")) - .Returns(BuildRemoteEpisode().Series); + .Setup(s => s.GetArtist("Droned S01E01")) + .Returns(BuildRemoteAlbum().Artist); } - private void GivenSeriesMatch() + private void GivenArtistMatch() { Mocker.GetMock() - .Setup(s => s.GetSeries(It.IsAny())) - .Returns(_trackedDownload.RemoteEpisode.Series); + .Setup(s => s.GetArtist(It.IsAny())) + .Returns(_trackedDownload.RemoteAlbum.Artist); } [TestCase(DownloadItemStatus.Downloading)] @@ -143,7 +158,7 @@ namespace NzbDrone.Core.Test.Download { _trackedDownload.DownloadItem.Category = "tv"; GivenNoGrabbedHistory(); - GivenSeriesMatch(); + GivenArtistMatch(); GivenSuccessfulImport(); Subject.Process(_trackedDownload); @@ -152,13 +167,9 @@ namespace NzbDrone.Core.Test.Download } [Test] - public void should_not_process_if_storage_directory_in_drone_factory() + public void should_not_process_if_output_path_is_empty() { - Mocker.GetMock() - .SetupGet(v => v.DownloadedEpisodesFolder) - .Returns(@"C:\DropFolder".AsOsAgnostic()); - - _trackedDownload.DownloadItem.OutputPath = new OsPath(@"C:\DropFolder\SomeOtherFolder".AsOsAgnostic()); + _trackedDownload.DownloadItem.OutputPath = new OsPath(); Subject.Process(_trackedDownload); @@ -166,9 +177,9 @@ namespace NzbDrone.Core.Test.Download } [Test] - public void should_not_process_if_output_path_is_empty() + public void should_not_throw_if_remotealbum_is_null() { - _trackedDownload.DownloadItem.OutputPath = new OsPath(); + _trackedDownload.RemoteAlbum = null; Subject.Process(_trackedDownload); @@ -176,19 +187,50 @@ namespace NzbDrone.Core.Test.Download } [Test] - public void should_mark_as_imported_if_all_episodes_were_imported() + public void should_mark_as_imported_if_all_tracks_were_imported() { - Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + _trackedDownload.RemoteAlbum.Albums = new List + { + CreateAlbum(1, 2) + }; + + Mocker.GetMock() + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { new ImportResult( - new ImportDecision( - new LocalEpisode {Path = @"C:\TestPath\Droned.S01E01.mkv"})), + new ImportDecision( + new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()})), new ImportResult( - new ImportDecision( - new LocalEpisode {Path = @"C:\TestPath\Droned.S01E02.mkv"})) + new ImportDecision( + new LocalTrack {Path = @"C:\TestPath\Droned.S01E02.mkv".AsOsAgnostic()})) + }); + + Subject.Process(_trackedDownload); + + AssertCompletedDownload(); + } + + [Test] + public void should_mark_as_imported_if_all_tracks_were_imported_but_album_incomplete() + { + _trackedDownload.RemoteAlbum.Albums = new List + { + CreateAlbum(1, 3) + }; + + Mocker.GetMock() + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new List + { + new ImportResult( + new ImportDecision( + new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()})), + + new ImportResult( + new ImportDecision( + new LocalTrack {Path = @"C:\TestPath\Droned.S01E02.mkv".AsOsAgnostic()})) }); Subject.Process(_trackedDownload); @@ -199,17 +241,17 @@ namespace NzbDrone.Core.Test.Download [Test] public void should_not_mark_as_imported_if_all_files_were_rejected() { - Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + Mocker.GetMock() + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { new ImportResult( - new ImportDecision( - new LocalEpisode {Path = @"C:\TestPath\Droned.S01E01.mkv"}, new Rejection("Rejected!")), "Test Failure"), + new ImportDecision( + new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()}, new Rejection("Rejected!")), "Test Failure"), new ImportResult( - new ImportDecision( - new LocalEpisode {Path = @"C:\TestPath\Droned.S01E02.mkv"},new Rejection("Rejected!")), "Test Failure") + new ImportDecision( + new LocalTrack {Path = @"C:\TestPath\Droned.S01E02.mkv".AsOsAgnostic()},new Rejection("Rejected!")), "Test Failure") }); Subject.Process(_trackedDownload); @@ -217,65 +259,67 @@ namespace NzbDrone.Core.Test.Download Mocker.GetMock() .Verify(v => v.PublishEvent(It.IsAny()), Times.Never()); - AssertNoCompletedDownload(); + AssertImportIncomplete(); } [Test] - public void should_not_mark_as_imported_if_no_episodes_were_parsed() + public void should_not_mark_as_imported_if_no_tracks_were_parsed() { - Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + Mocker.GetMock() + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { new ImportResult( - new ImportDecision( - new LocalEpisode {Path = @"C:\TestPath\Droned.S01E01.mkv"}, new Rejection("Rejected!")), "Test Failure"), + new ImportDecision( + new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()}, new Rejection("Rejected!")), "Test Failure"), new ImportResult( - new ImportDecision( - new LocalEpisode {Path = @"C:\TestPath\Droned.S01E02.mkv"},new Rejection("Rejected!")), "Test Failure") + new ImportDecision( + new LocalTrack {Path = @"C:\TestPath\Droned.S01E02.mkv".AsOsAgnostic()},new Rejection("Rejected!")), "Test Failure") }); - _trackedDownload.RemoteEpisode.Episodes.Clear(); + _trackedDownload.RemoteAlbum.Albums.Clear(); Subject.Process(_trackedDownload); - AssertNoCompletedDownload(); + AssertImportIncomplete(); } [Test] public void should_not_mark_as_imported_if_all_files_were_skipped() { - Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + Mocker.GetMock() + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { - new ImportResult(new ImportDecision(new LocalEpisode {Path = @"C:\TestPath\Droned.S01E01.mkv"}),"Test Failure"), - new ImportResult(new ImportDecision(new LocalEpisode {Path = @"C:\TestPath\Droned.S01E01.mkv"}),"Test Failure") + new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()}),"Test Failure"), + new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()}),"Test Failure") }); Subject.Process(_trackedDownload); - AssertNoCompletedDownload(); + AssertImportIncomplete(); } [Test] - public void should_mark_as_imported_if_all_episodes_were_imported_but_extra_files_were_not() + public void should_mark_as_imported_if_all_tracks_were_imported_but_extra_files_were_not() { - GivenSeriesMatch(); + GivenArtistMatch(); - _trackedDownload.RemoteEpisode.Episodes = new List + _trackedDownload.RemoteAlbum.Albums = new List { - new Episode() + CreateAlbum(1, 3) }; - Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + Mocker.GetMock() + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { - new ImportResult(new ImportDecision(new LocalEpisode {Path = @"C:\TestPath\Droned.S01E01.mkv"})), - new ImportResult(new ImportDecision(new LocalEpisode{Path = @"C:\TestPath\Droned.S01E01.mkv"}),"Test Failure") + new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()})), + new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()})), + new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()})), + new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()}), "Test Failure") }); Subject.Process(_trackedDownload); @@ -284,28 +328,30 @@ namespace NzbDrone.Core.Test.Download } [Test] - public void should_mark_as_failed_if_some_of_episodes_were_not_imported() + public void should_mark_as_failed_if_some_tracks_were_not_imported() { - _trackedDownload.RemoteEpisode.Episodes = new List + _trackedDownload.RemoteAlbum.Albums = new List { - new Episode(), - new Episode(), - new Episode() + CreateAlbum(1, 1), + CreateAlbum(1, 2), + CreateAlbum(1, 1) }; - Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + Mocker.GetMock() + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { - new ImportResult(new ImportDecision(new LocalEpisode {Path = @"C:\TestPath\Droned.S01E01.mkv"})), - new ImportResult(new ImportDecision(new LocalEpisode{Path = @"C:\TestPath\Droned.S01E01.mkv"}),"Test Failure"), - new ImportResult(new ImportDecision(new LocalEpisode{Path = @"C:\TestPath\Droned.S01E01.mkv"}),"Test Failure") + new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()})), + new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()})), + new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()})), + new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()}), "Test Failure"), + new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()}), "Test Failure") }); Subject.Process(_trackedDownload); - AssertNoCompletedDownload(); + AssertImportIncomplete(); } [Test] @@ -313,16 +359,16 @@ namespace NzbDrone.Core.Test.Download { GivenABadlyNamedDownload(); - Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + Mocker.GetMock() + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { - new ImportResult(new ImportDecision(new LocalEpisode {Path = @"C:\TestPath\Droned.S01E01.mkv"})) + new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()})) }); - Mocker.GetMock() - .Setup(v => v.GetSeries(It.IsAny())) - .Returns(BuildRemoteEpisode().Series); + Mocker.GetMock() + .Setup(v => v.GetArtist(It.IsAny())) + .Returns(BuildRemoteAlbum().Artist); Subject.Process(_trackedDownload); @@ -334,11 +380,11 @@ namespace NzbDrone.Core.Test.Download { GivenABadlyNamedDownload(); - Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + Mocker.GetMock() + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { - new ImportResult(new ImportDecision(new LocalEpisode {Path = @"C:\TestPath\Droned.S01E01.mkv"})) + new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()})) }); Mocker.GetMock() @@ -352,9 +398,10 @@ namespace NzbDrone.Core.Test.Download [Test] public void should_not_import_when_there_is_a_title_mismatch() { + _trackedDownload.RemoteAlbum.Artist = null; Mocker.GetMock() - .Setup(s => s.GetSeries("Drone.S01E01.HDTV")) - .Returns((Series)null); + .Setup(s => s.GetArtist("Drone.S01E01.HDTV")) + .Returns((Artist)null); Subject.Process(_trackedDownload); @@ -364,16 +411,16 @@ namespace NzbDrone.Core.Test.Download [Test] public void should_mark_as_import_title_mismatch_if_ignore_warnings_is_true() { - _trackedDownload.RemoteEpisode.Episodes = new List + _trackedDownload.RemoteAlbum.Albums = new List { - new Episode() + CreateAlbum(0, 1) }; - Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + Mocker.GetMock() + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { - new ImportResult(new ImportDecision(new LocalEpisode {Path = @"C:\TestPath\Droned.S01E01.mkv"})) + new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()})) }); Subject.Process(_trackedDownload, true); @@ -407,8 +454,16 @@ namespace NzbDrone.Core.Test.Download private void AssertNoAttemptedImport() { - Mocker.GetMock() - .Verify(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); + Mocker.GetMock() + .Verify(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); + + AssertNoCompletedDownload(); + } + + private void AssertImportIncomplete() + { + Mocker.GetMock() + .Verify(v => v.PublishEvent(It.IsAny()), Times.Once()); AssertNoCompletedDownload(); } @@ -423,8 +478,8 @@ namespace NzbDrone.Core.Test.Download private void AssertCompletedDownload() { - Mocker.GetMock() - .Verify(v => v.ProcessPath(_trackedDownload.DownloadItem.OutputPath.FullPath, ImportMode.Auto, _trackedDownload.RemoteEpisode.Series, _trackedDownload.DownloadItem), Times.Once()); + Mocker.GetMock() + .Verify(v => v.ProcessPath(_trackedDownload.DownloadItem.OutputPath.FullPath, ImportMode.Auto, _trackedDownload.RemoteAlbum.Artist, _trackedDownload.DownloadItem), Times.Once()); Mocker.GetMock() .Verify(v => v.PublishEvent(It.IsAny()), Times.Once()); diff --git a/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/DownloadApprovedFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/DownloadApprovedFixture.cs index 76d22d669..088b00ddf 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/DownloadApprovedFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadApprovedReportsTests/DownloadApprovedFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using FizzWare.NBuilder; using FluentAssertions; @@ -6,12 +6,15 @@ 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.Profiles.Qualities; using NzbDrone.Core.Qualities; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests @@ -27,89 +30,89 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests .Returns>(v => v); } - private Episode GetEpisode(int id) + private Album GetAlbum(int id) { - return Builder.CreateNew() + return Builder.CreateNew() .With(e => e.Id = id) - .With(e => e.EpisodeNumber = id) .Build(); } - private RemoteEpisode GetRemoteEpisode(List episodes, QualityModel quality) + private RemoteAlbum GetRemoteAlbum(List albums, QualityModel quality, DownloadProtocol downloadProtocol = DownloadProtocol.Usenet) { - var remoteEpisode = new RemoteEpisode(); - remoteEpisode.ParsedEpisodeInfo = new ParsedEpisodeInfo(); - remoteEpisode.ParsedEpisodeInfo.Quality = quality; + var remoteAlbum = new RemoteAlbum(); + remoteAlbum.ParsedAlbumInfo = new ParsedAlbumInfo(); + remoteAlbum.ParsedAlbumInfo.Quality = quality; - remoteEpisode.Episodes = new List(); - remoteEpisode.Episodes.AddRange(episodes); + remoteAlbum.Albums = new List(); + remoteAlbum.Albums.AddRange(albums); - remoteEpisode.Release = new ReleaseInfo(); - remoteEpisode.Release.PublishDate = DateTime.UtcNow; + remoteAlbum.Release = new ReleaseInfo(); + remoteAlbum.Release.DownloadProtocol = downloadProtocol; + remoteAlbum.Release.PublishDate = DateTime.UtcNow; - remoteEpisode.Series = Builder.CreateNew() - .With(e => e.Profile = new Profile { Items = Qualities.QualityFixture.GetDefaultQualities() }) + remoteAlbum.Artist = Builder.CreateNew() + .With(e => e.QualityProfile = new QualityProfile { Items = Qualities.QualityFixture.GetDefaultQualities() }) .Build(); - return remoteEpisode; + return remoteAlbum; } [Test] - public void should_download_report_if_epsiode_was_not_already_downloaded() + public void should_download_report_if_album_was_not_already_downloaded() { - var episodes = new List { GetEpisode(1) }; - var remoteEpisode = GetRemoteEpisode(episodes, new QualityModel(Quality.HDTV720p)); + var albums = new List { GetAlbum(1) }; + var remoteAlbum = GetRemoteAlbum(albums, new QualityModel(Quality.MP3_192)); var decisions = new List(); - decisions.Add(new DownloadDecision(remoteEpisode)); + decisions.Add(new DownloadDecision(remoteAlbum)); Subject.ProcessDecisions(decisions); - Mocker.GetMock().Verify(v => v.DownloadReport(It.IsAny()), Times.Once()); + Mocker.GetMock().Verify(v => v.DownloadReport(It.IsAny()), Times.Once()); } [Test] - public void should_only_download_episode_once() + public void should_only_download_album_once() { - var episodes = new List { GetEpisode(1) }; - var remoteEpisode = GetRemoteEpisode(episodes, new QualityModel(Quality.HDTV720p)); + var albums = new List { GetAlbum(1) }; + var remoteAlbum = GetRemoteAlbum(albums, new QualityModel(Quality.MP3_192)); var decisions = new List(); - decisions.Add(new DownloadDecision(remoteEpisode)); - decisions.Add(new DownloadDecision(remoteEpisode)); + decisions.Add(new DownloadDecision(remoteAlbum)); + decisions.Add(new DownloadDecision(remoteAlbum)); Subject.ProcessDecisions(decisions); - Mocker.GetMock().Verify(v => v.DownloadReport(It.IsAny()), Times.Once()); + Mocker.GetMock().Verify(v => v.DownloadReport(It.IsAny()), Times.Once()); } [Test] - public void should_not_download_if_any_episode_was_already_downloaded() + public void should_not_download_if_any_album_was_already_downloaded() { - var remoteEpisode1 = GetRemoteEpisode( - new List { GetEpisode(1) }, - new QualityModel(Quality.HDTV720p) + var remoteAlbum1 = GetRemoteAlbum( + new List { GetAlbum(1) }, + new QualityModel(Quality.MP3_192) ); - var remoteEpisode2 = GetRemoteEpisode( - new List { GetEpisode(1), GetEpisode(2) }, - new QualityModel(Quality.HDTV720p) + var remoteAlbum2 = GetRemoteAlbum( + new List { GetAlbum(1), GetAlbum(2) }, + new QualityModel(Quality.MP3_192) ); var decisions = new List(); - decisions.Add(new DownloadDecision(remoteEpisode1)); - decisions.Add(new DownloadDecision(remoteEpisode2)); + decisions.Add(new DownloadDecision(remoteAlbum1)); + decisions.Add(new DownloadDecision(remoteAlbum2)); Subject.ProcessDecisions(decisions); - Mocker.GetMock().Verify(v => v.DownloadReport(It.IsAny()), Times.Once()); + Mocker.GetMock().Verify(v => v.DownloadReport(It.IsAny()), Times.Once()); } [Test] public void should_return_downloaded_reports() { - var episodes = new List { GetEpisode(1) }; - var remoteEpisode = GetRemoteEpisode(episodes, new QualityModel(Quality.HDTV720p)); + var albums = new List { GetAlbum(1) }; + var remoteAlbum = GetRemoteAlbum(albums, new QualityModel(Quality.MP3_192)); var decisions = new List(); - decisions.Add(new DownloadDecision(remoteEpisode)); + decisions.Add(new DownloadDecision(remoteAlbum)); Subject.ProcessDecisions(decisions).Grabbed.Should().HaveCount(1); } @@ -117,19 +120,19 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests [Test] public void should_return_all_downloaded_reports() { - var remoteEpisode1 = GetRemoteEpisode( - new List { GetEpisode(1) }, - new QualityModel(Quality.HDTV720p) + var remoteAlbum1 = GetRemoteAlbum( + new List { GetAlbum(1) }, + new QualityModel(Quality.MP3_192) ); - var remoteEpisode2 = GetRemoteEpisode( - new List { GetEpisode(2) }, - new QualityModel(Quality.HDTV720p) + var remoteAlbum2 = GetRemoteAlbum( + new List { GetAlbum(2) }, + new QualityModel(Quality.MP3_192) ); var decisions = new List(); - decisions.Add(new DownloadDecision(remoteEpisode1)); - decisions.Add(new DownloadDecision(remoteEpisode2)); + decisions.Add(new DownloadDecision(remoteAlbum1)); + decisions.Add(new DownloadDecision(remoteAlbum2)); Subject.ProcessDecisions(decisions).Grabbed.Should().HaveCount(2); } @@ -137,25 +140,25 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests [Test] public void should_only_return_downloaded_reports() { - var remoteEpisode1 = GetRemoteEpisode( - new List { GetEpisode(1) }, - new QualityModel(Quality.HDTV720p) + var remoteAlbum1 = GetRemoteAlbum( + new List { GetAlbum(1) }, + new QualityModel(Quality.MP3_192) ); - var remoteEpisode2 = GetRemoteEpisode( - new List { GetEpisode(2) }, - new QualityModel(Quality.HDTV720p) + var remoteAlbum2 = GetRemoteAlbum( + new List { GetAlbum(2) }, + new QualityModel(Quality.MP3_192) ); - var remoteEpisode3 = GetRemoteEpisode( - new List { GetEpisode(2) }, - new QualityModel(Quality.HDTV720p) + var remoteAlbum3 = GetRemoteAlbum( + new List { GetAlbum(2) }, + new QualityModel(Quality.MP3_192) ); var decisions = new List(); - decisions.Add(new DownloadDecision(remoteEpisode1)); - decisions.Add(new DownloadDecision(remoteEpisode2)); - decisions.Add(new DownloadDecision(remoteEpisode3)); + decisions.Add(new DownloadDecision(remoteAlbum1)); + decisions.Add(new DownloadDecision(remoteAlbum2)); + decisions.Add(new DownloadDecision(remoteAlbum3)); Subject.ProcessDecisions(decisions).Grabbed.Should().HaveCount(2); } @@ -163,13 +166,13 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests [Test] public void should_not_add_to_downloaded_list_when_download_fails() { - var episodes = new List { GetEpisode(1) }; - var remoteEpisode = GetRemoteEpisode(episodes, new QualityModel(Quality.HDTV720p)); + var albums = new List { GetAlbum(1) }; + var remoteAlbum = GetRemoteAlbum(albums, new QualityModel(Quality.MP3_192)); var decisions = new List(); - decisions.Add(new DownloadDecision(remoteEpisode)); + decisions.Add(new DownloadDecision(remoteAlbum)); - Mocker.GetMock().Setup(s => s.DownloadReport(It.IsAny())).Throws(new Exception()); + Mocker.GetMock().Setup(s => s.DownloadReport(It.IsAny())).Throws(new Exception()); Subject.ProcessDecisions(decisions).Grabbed.Should().BeEmpty(); ExceptionVerification.ExpectedWarns(1); } @@ -178,8 +181,8 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests public void should_return_an_empty_list_when_none_are_appproved() { var decisions = new List(); - decisions.Add(new DownloadDecision(null, new Rejection("Failure!"))); - decisions.Add(new DownloadDecision(null, new Rejection("Failure!"))); + decisions.Add(new DownloadDecision(new RemoteAlbum(), new Rejection("Failure!"))); + decisions.Add(new DownloadDecision(new RemoteAlbum(), new Rejection("Failure!"))); Subject.GetQualifiedReports(decisions).Should().BeEmpty(); } @@ -187,43 +190,99 @@ namespace NzbDrone.Core.Test.Download.DownloadApprovedReportsTests [Test] public void should_not_grab_if_pending() { - var episodes = new List { GetEpisode(1) }; - var remoteEpisode = GetRemoteEpisode(episodes, new QualityModel(Quality.HDTV720p)); + var albums = new List { GetAlbum(1) }; + var remoteAlbum = GetRemoteAlbum(albums, new QualityModel(Quality.MP3_192)); var decisions = new List(); - decisions.Add(new DownloadDecision(remoteEpisode, new Rejection("Failure!", RejectionType.Temporary))); - decisions.Add(new DownloadDecision(remoteEpisode)); + decisions.Add(new DownloadDecision(remoteAlbum, new Rejection("Failure!", RejectionType.Temporary))); Subject.ProcessDecisions(decisions); - Mocker.GetMock().Verify(v => v.DownloadReport(It.IsAny()), Times.Never()); + Mocker.GetMock().Verify(v => v.DownloadReport(It.IsAny()), Times.Never()); } [Test] - public void should_not_add_to_pending_if_episode_was_grabbed() + public void should_not_add_to_pending_if_album_was_grabbed() { - var episodes = new List { GetEpisode(1) }; - var remoteEpisode = GetRemoteEpisode(episodes, new QualityModel(Quality.HDTV720p)); + var albums = new List { GetAlbum(1) }; + var remoteAlbum = GetRemoteAlbum(albums, new QualityModel(Quality.MP3_192)); var decisions = new List(); - decisions.Add(new DownloadDecision(remoteEpisode)); - decisions.Add(new DownloadDecision(remoteEpisode, new Rejection("Failure!", RejectionType.Temporary))); + decisions.Add(new DownloadDecision(remoteAlbum)); + decisions.Add(new DownloadDecision(remoteAlbum, 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] public void should_add_to_pending_even_if_already_added_to_pending() { - var episodes = new List { GetEpisode(1) }; - var remoteEpisode = GetRemoteEpisode(episodes, new QualityModel(Quality.HDTV720p)); + var albums = new List { GetAlbum(1) }; + var remoteAlbum = GetRemoteAlbum(albums, new QualityModel(Quality.MP3_192)); var decisions = new List(); - decisions.Add(new DownloadDecision(remoteEpisode, new Rejection("Failure!", RejectionType.Temporary))); - decisions.Add(new DownloadDecision(remoteEpisode, new Rejection("Failure!", RejectionType.Temporary))); + decisions.Add(new DownloadDecision(remoteAlbum, new Rejection("Failure!", RejectionType.Temporary))); + decisions.Add(new DownloadDecision(remoteAlbum, 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 albums = new List { GetAlbum(1) }; + var remoteAlbum = GetRemoteAlbum(albums, new QualityModel(Quality.MP3_320)); + + var decisions = new List(); + decisions.Add(new DownloadDecision(remoteAlbum)); + decisions.Add(new DownloadDecision(remoteAlbum)); + + 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 albums = new List { GetAlbum(1) }; + var remoteAlbum = GetRemoteAlbum(albums, new QualityModel(Quality.MP3_320), DownloadProtocol.Usenet); + var remoteAlbum2 = GetRemoteAlbum(albums, new QualityModel(Quality.MP3_320), DownloadProtocol.Torrent); + + var decisions = new List(); + decisions.Add(new DownloadDecision(remoteAlbum)); + decisions.Add(new DownloadDecision(remoteAlbum2)); + + 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 albums = new List { GetAlbum(1) }; + var remoteAlbum = GetRemoteAlbum(albums, new QualityModel(Quality.MP3_320)); + + var decisions = new List(); + decisions.Add(new DownloadDecision(remoteAlbum)); + + Mocker.GetMock() + .Setup(s => s.DownloadReport(It.IsAny())) + .Throws(new ReleaseUnavailableException(remoteAlbum.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..a608321d9 --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/DownloadClientStatusServiceFixture.cs @@ -0,0 +1,161 @@ +using System; +using System.Linq; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.EnvironmentInfo; +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; + + Mocker.GetMock() + .SetupGet(v => v.StartTime) + .Returns(_epoch - TimeSpan.FromHours(1)); + } + + 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..227102792 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/ScanWatchFolderFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/ScanWatchFolderFixture.cs @@ -1,22 +1,25 @@ using System; +using System.Collections.Generic; using System.IO; +using System.IO.Abstractions; 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 { [TestFixture] public class ScanWatchFolderFixture : CoreTest { - protected readonly string _title = "Droned.S01E01.Pilot.1080p.WEB-DL-DRONE"; + protected readonly string _title = "Radiohead - Scotch Mist [2008-FLAC-Lossless]"; protected string _completedDownloadFolder = @"c:\blackhole\completed".AsOsAgnostic(); protected void GivenCompletedItem() @@ -28,11 +31,17 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.Blackhole Mocker.GetMock() .Setup(c => c.GetFiles(targetDir, SearchOption.AllDirectories)) - .Returns(new[] { Path.Combine(targetDir, "somefile.mkv") }); + .Returns(new[] { Path.Combine(targetDir, "somefile.flac") }); 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()); + + Mocker.GetMock().Setup(c => c.FilterFiles(It.IsAny(), It.IsAny>())) + .Returns>((b, s) => s.ToList()); } protected void GivenChangedItem() diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/TorrentBlackholeFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/TorrentBlackholeFixture.cs index 5a61271cf..6fd6c6042 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/TorrentBlackholeFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/TorrentBlackholeFixture.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.IO; +using System.IO.Abstractions; using System.Linq; using System.Net; using FluentAssertions; @@ -10,6 +12,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; @@ -30,7 +33,6 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.Blackhole _completedDownloadFolder = @"c:\blackhole\completed".AsOsAgnostic(); _blackholeFolder = @"c:\blackhole\torrent".AsOsAgnostic(); _filePath = (@"c:\blackhole\torrent\" + _title + ".torrent").AsOsAgnostic(); - _magnetFilePath = Path.ChangeExtension(_filePath, ".magnet"); Mocker.SetConstant(Mocker.Resolve()); @@ -48,6 +50,12 @@ 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()); + + Mocker.GetMock().Setup(c => c.FilterFiles(It.IsAny(), It.IsAny>())) + .Returns>((b, s) => s.ToList()); } protected void GivenFailedDownload() @@ -67,26 +75,31 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.Blackhole Mocker.GetMock() .Setup(c => c.GetFiles(targetDir, SearchOption.AllDirectories)) - .Returns(new[] { Path.Combine(targetDir, "somefile.mkv") }); + .Returns(new[] { Path.Combine(targetDir, "somefile.flac") }); Mocker.GetMock() .Setup(c => c.GetFileSize(It.IsAny())) .Returns(1000000); } - protected override RemoteEpisode CreateRemoteEpisode() + protected void GivenMagnetFilePath(string extension = ".magnet") + { + _magnetFilePath = Path.ChangeExtension(_filePath, extension); + } + + protected override RemoteAlbum CreateRemoteAlbum() { - var remoteEpisode = base.CreateRemoteEpisode(); + var remoteAlbum = base.CreateRemoteAlbum(); var torrentInfo = new TorrentInfo(); - torrentInfo.Title = remoteEpisode.Release.Title; - torrentInfo.DownloadUrl = remoteEpisode.Release.DownloadUrl; - torrentInfo.DownloadProtocol = remoteEpisode.Release.DownloadProtocol; - torrentInfo.MagnetUrl = "magnet:?xt=urn:btih:755248817d32b00cc853e633ecdc48e4c21bff15&dn=Series.S05E10.PROPER.HDTV.x264-DEFiNE%5Brartv%5D&tr=http%3A%2F%2Ftracker.trackerfix.com%3A80%2Fannounce&tr=udp%3A%2F%2F9.rarbg.me%3A2710&tr=udp%3A%2F%2F9.rarbg.to%3A2710"; + torrentInfo.Title = remoteAlbum.Release.Title; + torrentInfo.DownloadUrl = remoteAlbum.Release.DownloadUrl; + torrentInfo.DownloadProtocol = remoteAlbum.Release.DownloadProtocol; + torrentInfo.MagnetUrl = "magnet:?xt=urn:btih:755248817d32b00cc853e633ecdc48e4c21bff15&dn=Artist.Album.FLAC.loseless-DEFiNE%5Brartv%5D&tr=http%3A%2F%2Ftracker.trackerfix.com%3A80%2Fannounce&tr=udp%3A%2F%2F9.rarbg.me%3A2710&tr=udp%3A%2F%2F9.rarbg.to%3A2710"; - remoteEpisode.Release = torrentInfo; + remoteAlbum.Release = torrentInfo; - return remoteEpisode; + return remoteAlbum; } [Test] @@ -99,6 +112,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.Blackhole var result = Subject.GetItems().Single(); VerifyCompleted(result); + + result.CanBeRemoved.Should().BeFalse(); + result.CanMoveFiles.Should().BeFalse(); } [Test] @@ -125,9 +141,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.Blackhole [Test] public void Download_should_download_file_if_it_doesnt_exist() { - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - Subject.Download(remoteEpisode); + Subject.Download(remoteAlbum); Mocker.GetMock().Verify(c => c.Get(It.Is(v => v.Url.FullUri == _downloadUrl)), Times.Once()); Mocker.GetMock().Verify(c => c.OpenWriteStream(_filePath), Times.Once()); @@ -137,12 +153,30 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.Blackhole [Test] public void Download_should_save_magnet_if_enabled() { + GivenMagnetFilePath(); + Subject.Definition.Settings.As().SaveMagnetFiles = true; + var remoteAlbum = CreateRemoteAlbum(); + remoteAlbum.Release.DownloadUrl = null; + Subject.Download(remoteAlbum); + Mocker.GetMock().Verify(c => c.Get(It.Is(v => v.Url.FullUri == _downloadUrl)), Times.Never()); + Mocker.GetMock().Verify(c => c.OpenWriteStream(_filePath), Times.Never()); + Mocker.GetMock().Verify(c => c.OpenWriteStream(_magnetFilePath), Times.Once()); + Mocker.GetMock().Verify(c => c.DownloadFile(It.IsAny(), It.IsAny()), Times.Never()); + } + + [Test] + public void Download_should_save_magnet_using_specified_extension() + { + var magnetFileExtension = ".url"; + GivenMagnetFilePath(magnetFileExtension); + Subject.Definition.Settings.As().SaveMagnetFiles = true; + Subject.Definition.Settings.As().MagnetFileExtension = magnetFileExtension; - var remoteEpisode = CreateRemoteEpisode(); - remoteEpisode.Release.DownloadUrl = null; + var remoteAlbum = CreateRemoteAlbum(); + remoteAlbum.Release.DownloadUrl = null; - Subject.Download(remoteEpisode); + Subject.Download(remoteAlbum); Mocker.GetMock().Verify(c => c.Get(It.Is(v => v.Url.FullUri == _downloadUrl)), Times.Never()); Mocker.GetMock().Verify(c => c.OpenWriteStream(_filePath), Times.Never()); @@ -153,10 +187,11 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.Blackhole [Test] public void Download_should_not_save_magnet_if_disabled() { - var remoteEpisode = CreateRemoteEpisode(); - remoteEpisode.Release.DownloadUrl = null; + GivenMagnetFilePath(); + var remoteAlbum = CreateRemoteAlbum(); + remoteAlbum.Release.DownloadUrl = null; - Assert.Throws(() => Subject.Download(remoteEpisode)); + Assert.Throws(() => Subject.Download(remoteAlbum)); Mocker.GetMock().Verify(c => c.Get(It.Is(v => v.Url.FullUri == _downloadUrl)), Times.Never()); Mocker.GetMock().Verify(c => c.OpenWriteStream(_filePath), Times.Never()); @@ -169,9 +204,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.Blackhole { Subject.Definition.Settings.As().SaveMagnetFiles = true; - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - Subject.Download(remoteEpisode); + Subject.Download(remoteAlbum); Mocker.GetMock().Verify(c => c.Get(It.Is(v => v.Url.FullUri == _downloadUrl)), Times.Once()); Mocker.GetMock().Verify(c => c.OpenWriteStream(_filePath), Times.Once()); @@ -182,13 +217,13 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.Blackhole [Test] public void Download_should_replace_illegal_characters_in_title() { - var illegalTitle = "Saturday Night Live - S38E08 - Jeremy Renner/Maroon 5 [SDTV]"; - var expectedFilename = Path.Combine(_blackholeFolder, "Saturday Night Live - S38E08 - Jeremy Renner+Maroon 5 [SDTV]" + Path.GetExtension(_filePath)); + var illegalTitle = "Radiohead - Scotch Mist [2008/FLAC/Lossless]"; + var expectedFilename = Path.Combine(_blackholeFolder, "Radiohead - Scotch Mist [2008+FLAC+Lossless]" + Path.GetExtension(_filePath)); - var remoteEpisode = CreateRemoteEpisode(); - remoteEpisode.Release.Title = illegalTitle; + var remoteAlbum = CreateRemoteAlbum(); + remoteAlbum.Release.Title = illegalTitle; - Subject.Download(remoteEpisode); + Subject.Download(remoteAlbum); Mocker.GetMock().Verify(c => c.Get(It.Is(v => v.Url.FullUri == _downloadUrl)), Times.Once()); Mocker.GetMock().Verify(c => c.OpenWriteStream(expectedFilename), Times.Once()); @@ -198,10 +233,10 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.Blackhole [Test] public void Download_should_throw_if_magnet_and_torrent_url_does_not_exist() { - var remoteEpisode = CreateRemoteEpisode(); - remoteEpisode.Release.DownloadUrl = null; + var remoteAlbum = CreateRemoteAlbum(); + remoteAlbum.Release.DownloadUrl = null; - Assert.Throws(() => Subject.Download(remoteEpisode)); + Assert.Throws(() => Subject.Download(remoteAlbum)); } [Test] @@ -273,9 +308,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.Blackhole [Test] public void should_return_null_hash() { - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - Subject.Download(remoteEpisode).Should().BeNull(); + Subject.Download(remoteAlbum).Should().BeNull(); } } } diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/UsenetBlackholeFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/UsenetBlackholeFixture.cs index d48d9e0b8..04786acbc 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/UsenetBlackholeFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/Blackhole/UsenetBlackholeFixture.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using System.IO; +using System.IO.Abstractions; using System.Linq; using System.Net; using FluentAssertions; @@ -10,6 +12,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 +44,12 @@ 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()); + + Mocker.GetMock().Setup(c => c.FilterFiles(It.IsAny(), It.IsAny>())) + .Returns>((b, s) => s.ToList()); } protected void GivenFailedDownload() @@ -60,7 +69,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.Blackhole Mocker.GetMock() .Setup(c => c.GetFiles(targetDir, SearchOption.AllDirectories)) - .Returns(new[] { Path.Combine(targetDir, "somefile.mkv") }); + .Returns(new[] { Path.Combine(targetDir, "somefile.flac") }); Mocker.GetMock() .Setup(c => c.GetFileSize(It.IsAny())) @@ -77,6 +86,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.Blackhole var result = Subject.GetItems().Single(); VerifyCompleted(result); + + result.CanBeRemoved.Should().BeTrue(); + result.CanMoveFiles.Should().BeTrue(); } [Test] @@ -104,9 +116,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.Blackhole [Test] public void Download_should_download_file_if_it_doesnt_exist() { - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - Subject.Download(remoteEpisode); + Subject.Download(remoteAlbum); Mocker.GetMock().Verify(c => c.Get(It.Is(v => v.Url.FullUri == _downloadUrl)), Times.Once()); Mocker.GetMock().Verify(c => c.OpenWriteStream(_filePath), Times.Once()); @@ -116,13 +128,13 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.Blackhole [Test] public void Download_should_replace_illegal_characters_in_title() { - var illegalTitle = "Saturday Night Live - S38E08 - Jeremy Renner/Maroon 5 [SDTV]"; - var expectedFilename = Path.Combine(_blackholeFolder, "Saturday Night Live - S38E08 - Jeremy Renner+Maroon 5 [SDTV]" + Path.GetExtension(_filePath)); + var illegalTitle = "Radiohead - Scotch Mist [2008/FLAC/Lossless]"; + var expectedFilename = Path.Combine(_blackholeFolder, "Radiohead - Scotch Mist [2008+FLAC+Lossless]" + Path.GetExtension(_filePath)); - var remoteEpisode = CreateRemoteEpisode(); - remoteEpisode.Release.Title = illegalTitle; + var remoteAlbum = CreateRemoteAlbum(); + remoteAlbum.Release.Title = illegalTitle; - Subject.Download(remoteEpisode); + Subject.Download(remoteAlbum); Mocker.GetMock().Verify(c => c.Get(It.Is(v => v.Url.FullUri == _downloadUrl)), Times.Once()); Mocker.GetMock().Verify(c => c.OpenWriteStream(expectedFilename), Times.Once()); diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/DelugeTests/DelugeFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/DelugeTests/DelugeFixture.cs index af24f2797..622e65708 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() @@ -26,7 +27,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DelugeTests Subject.Definition = new DownloadClientDefinition(); Subject.Definition.Settings = new DelugeSettings() { - TvCategory = null + MusicCategory = null }; _queued = new DelugeTorrent @@ -75,8 +76,12 @@ 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() .Setup(s => s.GetHashFromTorrentFile(It.IsAny())) @@ -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] @@ -196,9 +204,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DelugeTests { GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().NotBeNullOrEmpty(); } @@ -208,10 +216,10 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DelugeTests { GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); - remoteEpisode.Release.DownloadUrl = magnetUrl; + var remoteAlbum = CreateRemoteAlbum(); + remoteAlbum.Release.DownloadUrl = magnetUrl; - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().Be(expectedHash); } @@ -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,43 @@ 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] + public void GetItems_should_ignore_items_without_hash() + { + _downloading.Hash = null; + + GivenTorrents(new List + { + _downloading, + _queued + }); + + var items = Subject.GetItems().ToList(); + + items.Should().HaveCount(1); + + items.First().Status.Should().Be(DownloadItemStatus.Queued); } [Test] diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadClientFixtureBase.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadClientFixtureBase.cs index 762137861..1f0dfd4c0 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadClientFixtureBase.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadClientFixtureBase.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Moq; using NUnit.Framework; @@ -8,7 +8,7 @@ using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Parser; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; using NzbDrone.Core.Download; using NzbDrone.Core.Configuration; using NzbDrone.Core.RemotePathMappings; @@ -30,8 +30,8 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests .Returns(30); Mocker.GetMock() - .Setup(s => s.Map(It.IsAny(), It.IsAny(), It.IsAny(), (SearchCriteriaBase)null)) - .Returns(() => CreateRemoteEpisode()); + .Setup(s => s.Map(It.IsAny(), (SearchCriteriaBase)null)) + .Returns(() => CreateRemoteAlbum()); Mocker.GetMock() .Setup(s => s.Get(It.IsAny())) @@ -42,22 +42,21 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests .Returns((h, r) => r); } - protected virtual RemoteEpisode CreateRemoteEpisode() + protected virtual RemoteAlbum CreateRemoteAlbum() { - var remoteEpisode = new RemoteEpisode(); - remoteEpisode.Release = new ReleaseInfo(); - remoteEpisode.Release.Title = _title; - remoteEpisode.Release.DownloadUrl = _downloadUrl; - remoteEpisode.Release.DownloadProtocol = Subject.Protocol; + var remoteAlbum = new RemoteAlbum(); + remoteAlbum.Release = new ReleaseInfo(); + remoteAlbum.Release.Title = _title; + remoteAlbum.Release.DownloadUrl = _downloadUrl; + remoteAlbum.Release.DownloadProtocol = Subject.Protocol; - remoteEpisode.ParsedEpisodeInfo = new ParsedEpisodeInfo(); - remoteEpisode.ParsedEpisodeInfo.FullSeason = false; + remoteAlbum.ParsedAlbumInfo = new ParsedAlbumInfo(); - remoteEpisode.Episodes = new List(); + remoteAlbum.Albums = new List(); - remoteEpisode.Series = new Series(); + remoteAlbum.Artist = new Artist(); - return remoteEpisode; + return remoteAlbum; } protected void VerifyIdentifiable(DownloadClientItem downloadClientItem) 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..0437126df --- /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 d48b29e11..2c3897e34 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/TorrentDownloadStationFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/TorrentDownloadStationFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using FluentAssertions; @@ -7,6 +7,7 @@ using NUnit.Framework; using NzbDrone.Common.Disk; using NzbDrone.Common.Http; using NzbDrone.Core.Download; +using NzbDrone.Core.Download.Clients; using NzbDrone.Core.Download.Clients.DownloadStation; using NzbDrone.Core.Download.Clients.DownloadStation.Proxies; using NzbDrone.Core.MediaFiles.TorrentInfo; @@ -32,8 +33,8 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests protected DownloadStationTask _multipleFilesCompleted; protected string _serialNumber = "SERIALNUMBER"; - protected string _category = "sonarr"; - protected string _tvDirectory = @"video/Series"; + protected string _category ="lidarr"; + protected string _musicDirectory = @"music/Artist"; protected string _defaultDestination = "somepath"; protected OsPath _physicalPath = new OsPath("/mnt/sdb1/mydata"); @@ -73,6 +74,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests Transfer = new Dictionary { { "size_downloaded", "0"}, + { "size_uploaded", "0"}, { "speed_download", "0" } } } @@ -96,6 +98,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests Transfer = new Dictionary { { "size_downloaded", "1000"}, + { "size_uploaded", "100"}, { "speed_download", "0" } }, } @@ -119,6 +122,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests Transfer = new Dictionary { { "size_downloaded", "1000"}, + { "size_uploaded", "100"}, { "speed_download", "0" } } } @@ -142,6 +146,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests Transfer = new Dictionary { { "size_downloaded", "100"}, + { "size_uploaded", "10"}, { "speed_download", "50" } } } @@ -165,6 +170,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests Transfer = new Dictionary { { "size_downloaded", "10"}, + { "size_uploaded", "1"}, { "speed_download", "0" } } } @@ -188,6 +194,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests Transfer = new Dictionary { { "size_downloaded", "1000"}, + { "size_uploaded", "100"}, { "speed_download", "0" } } } @@ -211,6 +218,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests Transfer = new Dictionary { { "size_downloaded", "1000"}, + { "size_uploaded", "100"}, { "speed_download", "0" } } } @@ -234,6 +242,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests Transfer = new Dictionary { { "size_downloaded", "1000"}, + { "size_uploaded", "100"}, { "speed_download", "0" } } } @@ -257,6 +266,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests Transfer = new Dictionary { { "size_downloaded", "1000"}, + { "size_uploaded", "100"}, { "speed_download", "0" } } } @@ -275,7 +285,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests { "default_destination", _defaultDestination }, }; - Mocker.GetMock() + Mocker.GetMock() .Setup(v => v.GetConfig(It.IsAny())) .Returns(_downloadStationConfigItems); } @@ -287,6 +297,17 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests .Returns((path, setttings, serial) => _physicalPath); } + protected void GivenSharedFolder(string share) + { + Mocker.GetMock() + .Setup(s => s.RemapToFullPath(It.IsAny(), It.IsAny(), It.IsAny())) + .Throws(new DownloadClientException("There is no matching shared folder")); + + Mocker.GetMock() + .Setup(s => s.RemapToFullPath(It.Is(x => x.FullPath == share), It.IsAny(), It.IsAny())) + .Returns((path, setttings, serial) => _physicalPath); + } + protected void GivenSerialNumber() { Mocker.GetMock() @@ -294,14 +315,14 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests .Returns(_serialNumber); } - protected void GivenTvCategory() + protected void GivenMusicCategory() { - _settings.TvCategory = _category; + _settings.MusicCategory = _category; } protected void GivenTvDirectory() { - _settings.TvDirectory = _tvDirectory; + _settings.TvDirectory = _musicDirectory; } protected virtual void GivenTasks(List torrents) @@ -311,7 +332,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests torrents = new List(); } - Mocker.GetMock() + Mocker.GetMock() .Setup(s => s.GetTasks(It.IsAny())) .Returns(torrents); } @@ -330,29 +351,29 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests .Setup(s => s.Get(It.IsAny())) .Returns(r => new HttpResponse(r, new HttpHeader(), new byte[1000])); - Mocker.GetMock() + Mocker.GetMock() .Setup(s => s.AddTaskFromUrl(It.IsAny(), It.IsAny(), It.IsAny())) .Callback(PrepareClientToReturnQueuedItem); - Mocker.GetMock() + Mocker.GetMock() .Setup(s => s.AddTaskFromData(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback(PrepareClientToReturnQueuedItem); } - protected override RemoteEpisode CreateRemoteEpisode() + protected override RemoteAlbum CreateRemoteAlbum() { - var episode = base.CreateRemoteEpisode(); + var album = base.CreateRemoteAlbum(); - episode.Release.DownloadUrl = DownloadURL; + album.Release.DownloadUrl = DownloadURL; - return episode; + return album; } protected int GivenAllKindOfTasks() { var tasks = new List() { _queued, _completed, _failed, _downloading, _seeding }; - Mocker.GetMock() + Mocker.GetMock() .Setup(d => d.GetTasks(_settings)) .Returns(tasks); @@ -366,30 +387,30 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests GivenTvDirectory(); GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().NotBeNullOrEmpty(); - Mocker.GetMock() - .Verify(v => v.AddTaskFromUrl(It.IsAny(), _tvDirectory, It.IsAny()), Times.Once()); + Mocker.GetMock() + .Verify(v => v.AddTaskFromUrl(It.IsAny(), _musicDirectory, It.IsAny()), Times.Once()); } [Test] public void Download_with_category_should_force_directory() { GivenSerialNumber(); - GivenTvCategory(); + GivenMusicCategory(); GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().NotBeNullOrEmpty(); - Mocker.GetMock() + Mocker.GetMock() .Verify(v => v.AddTaskFromUrl(It.IsAny(), $"{_defaultDestination}/{_category}", It.IsAny()), Times.Once()); } @@ -399,13 +420,13 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests GivenSerialNumber(); GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().NotBeNullOrEmpty(); - Mocker.GetMock() + Mocker.GetMock() .Verify(v => v.AddTaskFromUrl(It.IsAny(), null, It.IsAny()), Times.Once()); } @@ -474,18 +495,53 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests [Test] public void Download_should_throw_and_not_add_task_if_cannot_get_serial_number() { - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); Mocker.GetMock() .Setup(s => s.GetSerialNumber(_settings)) .Throws(new ApplicationException("Some unknown exception, HttpException or DownloadClientException")); - Assert.Throws(Is.InstanceOf(), () => Subject.Download(remoteEpisode)); + Assert.Throws(Is.InstanceOf(), () => Subject.Download(remoteAlbum)); - Mocker.GetMock() + Mocker.GetMock() .Verify(v => v.AddTaskFromUrl(It.IsAny(), null, _settings), Times.Never()); } + [Test] + public void GetStatus_should_map_outputpath_when_using_default() + { + GivenSerialNumber(); + GivenSharedFolder("/somepath"); + + var status = Subject.GetStatus(); + + status.OutputRootFolders.First().Should().Be(_physicalPath); + } + + [Test] + public void GetStatus_should_map_outputpath_when_using_destination() + { + GivenSerialNumber(); + GivenTvDirectory(); + GivenSharedFolder($"/{_musicDirectory}"); + + var status = Subject.GetStatus(); + + status.OutputRootFolders.First().Should().Be(_physicalPath); + } + + [Test] + public void GetStatus_should_map_outputpath_when_using_category() + { + GivenSerialNumber(); + GivenMusicCategory(); + GivenSharedFolder($"/somepath/{_category}"); + + var status = Subject.GetStatus(); + + status.OutputRootFolders.First().Should().Be(_physicalPath); + } + [Test] public void GetItems_should_set_outputPath_to_base_folder_when_single_file_non_finished_tasks() { @@ -576,11 +632,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 +648,10 @@ 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 +660,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 2e44c60ba..385aa6fdd 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/UsenetDownloadStationFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/DownloadStationTests/UsenetDownloadStationFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using FluentAssertions; @@ -9,10 +9,10 @@ using NzbDrone.Common.Http; using NzbDrone.Core.Download; using NzbDrone.Core.Download.Clients.DownloadStation; using NzbDrone.Core.Download.Clients.DownloadStation.Proxies; -using NzbDrone.Core.MediaFiles.TorrentInfo; using NzbDrone.Core.Parser.Model; using NzbDrone.Test.Common; using NzbDrone.Core.Organizer; +using NzbDrone.Core.Download.Clients; namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests { @@ -28,19 +28,19 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests protected DownloadStationTask _seeding; protected string _serialNumber = "SERIALNUMBER"; - protected string _category = "sonarr"; - protected string _tvDirectory = @"video/Series"; + protected string _category = "lidarr"; + protected string _musicDirectory = @"music/Artist"; protected string _defaultDestination = "somepath"; protected OsPath _physicalPath = new OsPath("/mnt/sdb1/mydata"); - protected RemoteEpisode _remoteEpisode; + protected RemoteAlbum _remoteAlbum; protected Dictionary _downloadStationConfigItems; [SetUp] public void Setup() { - _remoteEpisode = CreateRemoteEpisode(); + _remoteAlbum = CreateRemoteAlbum(); _settings = new DownloadStationSettings() { @@ -66,7 +66,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests Detail = new Dictionary { { "destination","shared/folder" }, - { "uri", FileNameBuilder.CleanFileName(_remoteEpisode.Release.Title) + ".nzb" } + { "uri", FileNameBuilder.CleanFileName(_remoteAlbum.Release.Title) + ".nzb" } }, Transfer = new Dictionary { @@ -89,7 +89,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests Detail = new Dictionary { { "destination","shared/folder" }, - { "uri", FileNameBuilder.CleanFileName(_remoteEpisode.Release.Title) + ".nzb" } + { "uri", FileNameBuilder.CleanFileName(_remoteAlbum.Release.Title) + ".nzb" } }, Transfer = new Dictionary { @@ -112,7 +112,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests Detail = new Dictionary { { "destination","shared/folder" }, - { "uri", FileNameBuilder.CleanFileName(_remoteEpisode.Release.Title) + ".nzb" } + { "uri", FileNameBuilder.CleanFileName(_remoteAlbum.Release.Title) + ".nzb" } }, Transfer = new Dictionary { @@ -135,7 +135,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests Detail = new Dictionary { { "destination","shared/folder" }, - { "uri", FileNameBuilder.CleanFileName(_remoteEpisode.Release.Title) + ".nzb" } + { "uri", FileNameBuilder.CleanFileName(_remoteAlbum.Release.Title) + ".nzb" } }, Transfer = new Dictionary { @@ -158,7 +158,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests Detail = new Dictionary { { "destination","shared/folder" }, - { "uri", FileNameBuilder.CleanFileName(_remoteEpisode.Release.Title) + ".nzb" } + { "uri", FileNameBuilder.CleanFileName(_remoteAlbum.Release.Title) + ".nzb" } }, Transfer = new Dictionary { @@ -177,7 +177,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests { "default_destination", _defaultDestination }, }; - Mocker.GetMock() + Mocker.GetMock() .Setup(v => v.GetConfig(It.IsAny())) .Returns(_downloadStationConfigItems); } @@ -189,6 +189,18 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests .Returns((path, setttings, serial) => _physicalPath); } + protected void GivenSharedFolder(string share) + { + Mocker.GetMock() + .Setup(s => s.RemapToFullPath(It.IsAny(), It.IsAny(), It.IsAny())) + .Throws(new DownloadClientException("There is no matching shared folder")); + + Mocker.GetMock() + .Setup(s => s.RemapToFullPath(It.Is(x => x.FullPath == share), It.IsAny(), It.IsAny())) + .Returns((path, setttings, serial) => _physicalPath); + } + + protected void GivenSerialNumber() { Mocker.GetMock() @@ -196,14 +208,14 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests .Returns(_serialNumber); } - protected void GivenTvCategory() + protected void GivenMusicCategory() { - _settings.TvCategory = _category; + _settings.MusicCategory = _category; } protected void GivenTvDirectory() { - _settings.TvDirectory = _tvDirectory; + _settings.TvDirectory = _musicDirectory; } protected virtual void GivenTasks(List nzbs) @@ -213,7 +225,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests nzbs = new List(); } - Mocker.GetMock() + Mocker.GetMock() .Setup(s => s.GetTasks(It.IsAny())) .Returns(nzbs); } @@ -233,7 +245,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests .Returns(r => new HttpResponse(r, new HttpHeader(), new byte[1000])); */ - Mocker.GetMock() + Mocker.GetMock() .Setup(s => s.AddTaskFromData(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback(PrepareClientToReturnQueuedItem); } @@ -242,7 +254,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests { var tasks = new List() { _queued, _completed, _failed, _downloading, _seeding }; - Mocker.GetMock() + Mocker.GetMock() .Setup(d => d.GetTasks(_settings)) .Returns(tasks); } @@ -254,30 +266,30 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests GivenTvDirectory(); GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().NotBeNullOrEmpty(); - Mocker.GetMock() - .Verify(v => v.AddTaskFromData(It.IsAny(), It.IsAny(), _tvDirectory, It.IsAny()), Times.Once()); + Mocker.GetMock() + .Verify(v => v.AddTaskFromData(It.IsAny(), It.IsAny(), _musicDirectory, It.IsAny()), Times.Once()); } [Test] public void Download_with_category_should_force_directory() { GivenSerialNumber(); - GivenTvCategory(); + GivenMusicCategory(); GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().NotBeNullOrEmpty(); - Mocker.GetMock() + Mocker.GetMock() .Verify(v => v.AddTaskFromData(It.IsAny(), It.IsAny(), $"{_defaultDestination}/{_category}", It.IsAny()), Times.Once()); } @@ -287,13 +299,13 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests GivenSerialNumber(); GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().NotBeNullOrEmpty(); - Mocker.GetMock() + Mocker.GetMock() .Verify(v => v.AddTaskFromData(It.IsAny(), It.IsAny(), null, It.IsAny()), Times.Once()); } @@ -362,17 +374,52 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.DownloadStationTests [Test] public void Download_should_throw_and_not_add_task_if_cannot_get_serial_number() { - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); Mocker.GetMock() .Setup(s => s.GetSerialNumber(_settings)) .Throws(new ApplicationException("Some unknown exception, HttpException or DownloadClientException")); - Assert.Throws(Is.InstanceOf(), () => Subject.Download(remoteEpisode)); + Assert.Throws(Is.InstanceOf(), () => Subject.Download(remoteAlbum)); - Mocker.GetMock() + Mocker.GetMock() .Verify(v => v.AddTaskFromUrl(It.IsAny(), null, _settings), Times.Never()); } + + [Test] + public void GetStatus_should_map_outputpath_when_using_default() + { + GivenSerialNumber(); + GivenSharedFolder("/somepath"); + + var status = Subject.GetStatus(); + + status.OutputRootFolders.First().Should().Be(_physicalPath); + } + + [Test] + public void GetStatus_should_map_outputpath_when_using_destination() + { + GivenSerialNumber(); + GivenTvDirectory(); + GivenSharedFolder($"/{_musicDirectory}"); + + var status = Subject.GetStatus(); + + status.OutputRootFolders.First().Should().Be(_physicalPath); + } + + [Test] + public void GetStatus_should_map_outputpath_when_using_category() + { + GivenSerialNumber(); + GivenMusicCategory(); + GivenSharedFolder($"/somepath/{_category}"); + + var status = Subject.GetStatus(); + + status.OutputRootFolders.First().Should().Be(_physicalPath); + } [Test] public void GetItems_should_not_map_outputpath_for_queued_or_downloading_tasks() @@ -408,32 +455,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..92bb41460 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/HadoukenTests/HadoukenFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/HadoukenTests/HadoukenFixture.cs @@ -1,4 +1,4 @@ -using Moq; +using Moq; using NUnit.Framework; using NzbDrone.Common.Http; using NzbDrone.Core.Download; @@ -37,7 +37,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.HadoukenTests DownloadedBytes = 0, Progress = 0.0, SavePath = "somepath", - Label = "sonarr-tv" + Label = "lidarr-music" }; _downloading = new HadoukenTorrent @@ -50,7 +50,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.HadoukenTests DownloadedBytes = 100, Progress = 10.0, SavePath = "somepath", - Label = "sonarr-tv" + Label = "lidarr-music" }; _failed = new HadoukenTorrent @@ -64,7 +64,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.HadoukenTests DownloadedBytes = 100, Progress = 10.0, SavePath = "somepath", - Label = "sonarr-tv" + Label = "lidarr-music" }; _completed = new HadoukenTorrent @@ -77,7 +77,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.HadoukenTests DownloadedBytes = 1000, Progress = 100.0, SavePath = "somepath", - Label = "sonarr-tv" + Label = "lidarr-music" }; Mocker.GetMock() @@ -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] @@ -197,9 +200,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.HadoukenTests { GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().NotBeNullOrEmpty(); } @@ -235,7 +238,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.HadoukenTests DownloadedBytes = 1000, Progress = 100.0, SavePath = "somepath", - Label = "sonarr-tv" + Label = "lidarr-music" }; var torrents = new HadoukenTorrent[] { torrent }; @@ -262,7 +265,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.HadoukenTests DownloadedBytes = 1000, Progress = 100.0, SavePath = "somepath", - Label = "sonarr-tv-other" + Label = "lidarr-music-other" }; var torrents = new HadoukenTorrent[] { torrent }; @@ -276,14 +279,14 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.HadoukenTests [Test] public void Download_from_magnet_link_should_return_hash_uppercase() { - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - remoteEpisode.Release.DownloadUrl = "magnet:?xt=urn:btih:a45129e59d8750f9da982f53552b1e4f0457ee9f"; + remoteAlbum.Release.DownloadUrl = "magnet:?xt=urn:btih:a45129e59d8750f9da982f53552b1e4f0457ee9f"; Mocker.GetMock() .Setup(v => v.AddTorrentUri(It.IsAny(), It.IsAny())); - var result = Subject.Download(remoteEpisode); + var result = Subject.Download(remoteAlbum); Assert.IsFalse(result.Any(c => char.IsLower(c))); } @@ -291,13 +294,13 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.HadoukenTests [Test] public void Download_from_torrent_file_should_return_hash_uppercase() { - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); Mocker.GetMock() .Setup(v => v.AddTorrentFile(It.IsAny(), It.IsAny())) .Returns("hash"); - var result = Subject.Download(remoteEpisode); + var result = Subject.Download(remoteAlbum); 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..bffb73e31 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbVortexTests/NzbVortexFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbVortexTests/NzbVortexFixture.cs @@ -32,7 +32,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbVortexTests Host = "127.0.0.1", Port = 2222, ApiKey = "1234-ABCD", - TvCategory = "tv", + MusicCategory = "Music", RecentTvPriority = (int)NzbgetPriority.High }; @@ -41,16 +41,16 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbVortexTests Id = RandomNumber, DownloadedSize = 1000, TotalDownloadSize = 10, - GroupName = "tv", - UiTitle = "Droned.S01E01.Pilot.1080p.WEB-DL-DRONE" - }; + GroupName = "Music", + UiTitle = "Fall Out Boy-Make America Psycho Again-CD-FLAC-2015-FORSAKEN" + }; _failed = new NzbVortexQueueItem { DownloadedSize = 1000, TotalDownloadSize = 1000, - GroupName = "tv", - UiTitle = "Droned.S01E01.Pilot.1080p.WEB-DL-DRONE", + GroupName = "Music", + UiTitle = "Fall Out Boy-Make America Psycho Again-CD-FLAC-2015-FORSAKEN", DestinationPath = "somedirectory", State = NzbVortexStateType.UncompressFailed, }; @@ -59,9 +59,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbVortexTests { DownloadedSize = 1000, TotalDownloadSize = 1000, - GroupName = "tv", - UiTitle = "Droned.S01E01.Pilot.1080p.WEB-DL-DRONE", - DestinationPath = "/remote/mount/tv/Droned.S01E01.Pilot.1080p.WEB-DL-DRONE", + GroupName = "Music", + UiTitle = "Fall Out Boy-Make America Psycho Again-CD-FLAC-2015-FORSAKEN", + DestinationPath = "/remote/mount/music/Fall Out Boy-Make America Psycho Again-CD-FLAC-2015-FORSAKEN", State = NzbVortexStateType.Done }; } @@ -107,6 +107,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbVortexTests 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] @@ -189,9 +204,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbVortexTests { GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().NotBeNullOrEmpty(); } @@ -201,9 +216,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbVortexTests { GivenFailedDownload(); - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - Assert.Throws(() => Subject.Download(remoteEpisode)); + Assert.Throws(() => Subject.Download(remoteAlbum)); } [Test] @@ -223,13 +238,13 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbVortexTests { Mocker.GetMock() .Setup(v => v.RemapRemoteToLocal("127.0.0.1", It.IsAny())) - .Returns(new OsPath(@"O:\mymount\Droned.S01E01.Pilot.1080p.WEB-DL-DRONE".AsOsAgnostic())); + .Returns(new OsPath(@"O:\mymount\Fall Out Boy-Make America Psycho Again-CD-FLAC-2015-FORSAKEN".AsOsAgnostic())); GivenQueue(_completed); var result = Subject.GetItems().Single(); - result.OutputPath.Should().Be(@"O:\mymount\Droned.S01E01.Pilot.1080p.WEB-DL-DRONE".AsOsAgnostic()); + result.OutputPath.Should().Be(@"O:\mymount\Fall Out Boy-Make America Psycho Again-CD-FLAC-2015-FORSAKEN".AsOsAgnostic()); } [Test] @@ -241,14 +256,14 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbVortexTests Mocker.GetMock() .Setup(s => s.GetFiles(It.IsAny(), It.IsAny())) - .Returns(new List { new NzbVortexFile { FileName = "Droned.S01E01.Pilot.1080p.WEB-DL-DRONE.mkv" } }); + .Returns(new List { new NzbVortexFile { FileName = "Fall Out Boy - Make America Psyco Again - Track 1.flac" } }); _completed.State = NzbVortexStateType.Done; GivenQueue(_completed); var result = Subject.GetItems().Single(); - result.OutputPath.Should().Be(@"O:\mymount\Droned.S01E01.Pilot.1080p.WEB-DL-DRONE.mkv".AsOsAgnostic()); + result.OutputPath.Should().Be(@"O:\mymount\Fall Out Boy - Make America Psyco Again - Track 1.flac".AsOsAgnostic()); } [Test] @@ -262,8 +277,8 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbVortexTests .Setup(s => s.GetFiles(It.IsAny(), It.IsAny())) .Returns(new List { - new NzbVortexFile { FileName = "Droned.S01E01.Pilot.1080p.WEB-DL-DRONE.mkv" }, - new NzbVortexFile { FileName = "Droned.S01E01.Pilot.1080p.WEB-DL-DRONE.nfo" } + new NzbVortexFile { FileName = "Fall Out Boy - Make America Psyco Again - Track 1.flac" }, + new NzbVortexFile { FileName = "Fall Out Boy-Make America Psycho Again-CD-FLAC-2015-FORSAKEN.nfo" } }); _completed.State = NzbVortexStateType.Done; @@ -283,7 +298,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbVortexTests { Mocker.GetMock() .Setup(v => v.GetGroups(It.IsAny())) - .Returns(new List { new NzbVortexGroup { GroupName = ((NzbVortexSettings)Subject.Definition.Settings).TvCategory } }); + .Returns(new List { new NzbVortexGroup { GroupName = ((NzbVortexSettings)Subject.Definition.Settings).MusicCategory } }); Mocker.GetMock() .Setup(v => v.GetApiVersion(It.IsAny())) diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetTests/NzbgetFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetTests/NzbgetFixture.cs index 226288464..66b7db64d 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetTests/NzbgetFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/NzbgetTests/NzbgetFixture.cs @@ -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() @@ -30,7 +31,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests Port = 2222, Username = "admin", Password = "pass", - TvCategory = "tv", + MusicCategory = "music", RecentTvPriority = (int)NzbgetPriority.High }; @@ -38,16 +39,16 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests { FileSizeLo = 1000, RemainingSizeLo = 10, - Category = "tv", - NzbName = "Droned.S01E01.Pilot.1080p.WEB-DL-DRONE", + Category = "music", + NzbName = "Fall Out Boy-Make America Psycho Again-CD-FLAC-2015-FORSAKEN", Parameters = new List { new NzbgetParameter { Name = "drone", Value = "id" } } }; _failed = new NzbgetHistoryItem { FileSizeLo = 1000, - Category = "tv", - Name = "Droned.S01E01.Pilot.1080p.WEB-DL-DRONE", + Category = "music", + Name = "Fall Out Boy-Make America Psycho Again-CD-FLAC-2015-FORSAKEN", DestDir = "somedirectory", Parameters = new List { new NzbgetParameter { Name = "drone", Value = "id" } }, ParStatus = "Some Error", @@ -61,9 +62,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests _completed = new NzbgetHistoryItem { FileSizeLo = 1000, - Category = "tv", - Name = "Droned.S01E01.Pilot.1080p.WEB-DL-DRONE", - DestDir = "/remote/mount/tv/Droned.S01E01.Pilot.1080p.WEB-DL-DRONE", + Category = "music", + Name = "Fall Out Boy-Make America Psycho Again-CD-FLAC-2015-FORSAKEN", + DestDir = "/remote/mount/music/Fall Out Boy-Make America Psycho Again-CD-FLAC-2015-FORSAKEN", Parameters = new List { new NzbgetParameter { Name = "drone", Value = "id" } }, ParStatus = "SUCCESS", UnpackStatus = "NONE", @@ -80,13 +81,17 @@ 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", "music"); + _configItems.Add("Category1.DestDir", @"/remote/mount/music"); Mocker.GetMock() .Setup(v => v.GetConfig(It.IsAny())) - .Returns(configItems); + .Returns(_configItems); } protected void GivenFailedDownload() @@ -167,6 +172,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests var result = Subject.GetItems().Single(); VerifyQueued(result); + + result.CanBeRemoved.Should().BeTrue(); + result.CanMoveFiles.Should().BeTrue(); } [Test] @@ -180,6 +188,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 +204,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 +234,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests var result = Subject.GetItems().Single(); VerifyCompleted(result); + + result.CanBeRemoved.Should().BeTrue(); + result.CanMoveFiles.Should().BeTrue(); } [Test] @@ -303,9 +336,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests { GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().NotBeNullOrEmpty(); } @@ -315,9 +348,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests { GivenFailedDownload(); - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - Assert.Throws(() => Subject.Download(remoteEpisode)); + Assert.Throws(() => Subject.Download(remoteAlbum)); } [Test] @@ -340,7 +373,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests result.IsLocalhost.Should().BeTrue(); result.OutputRootFolders.Should().NotBeNull(); - result.OutputRootFolders.First().Should().Be(@"/remote/mount/tv"); + result.OutputRootFolders.First().Should().Be(@"/remote/mount/music"); } [Test] @@ -362,14 +395,45 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.NzbgetTests { Mocker.GetMock() .Setup(v => v.RemapRemoteToLocal("127.0.0.1", It.IsAny())) - .Returns(new OsPath(@"O:\mymount\Droned.S01E01.Pilot.1080p.WEB-DL-DRONE".AsOsAgnostic())); + .Returns(new OsPath(@"O:\mymount\Fall Out Boy-Make America Psycho Again-CD-FLAC-2015-FORSAKEN".AsOsAgnostic())); GivenQueue(null); GivenHistory(_completed); var result = Subject.GetItems().Single(); - result.OutputPath.Should().Be(@"O:\mymount\Droned.S01E01.Pilot.1080p.WEB-DL-DRONE".AsOsAgnostic()); + result.OutputPath.Should().Be(@"O:\mymount\Fall Out Boy-Make America Psycho Again-CD-FLAC-2015-FORSAKEN".AsOsAgnostic()); + } + + [Test] + public void should_use_dest_dir_if_final_dir_is_null() + { + GivenQueue(null); + GivenHistory(_completed); + + Subject.GetItems().First().OutputPath.Should().Be(_completed.DestDir); + } + + [Test] + public void should_use_dest_dir_if_final_dir_is_not_set() + { + _completed.FinalDir = string.Empty; + + GivenQueue(null); + GivenHistory(_completed); + + Subject.GetItems().First().OutputPath.Should().Be(_completed.DestDir); + } + + [Test] + public void should_use_final_dir_when_set_instead_of_dest_dir() + { + _completed.FinalDir = "/remote/mount/music2/Some.Artist-Some.Album.FLAC.2018-DRONE"; + + GivenQueue(null); + GivenHistory(_completed); + + Subject.GetItems().First().OutputPath.Should().Be(_completed.FinalDir); } [TestCase("11.0", false)] @@ -386,5 +450,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/PneumaticProviderFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/PneumaticProviderFixture.cs index d3de3c1d9..3bd28c9bb 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/PneumaticProviderFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/PneumaticProviderFixture.cs @@ -4,7 +4,6 @@ using System.Net; using Moq; using NUnit.Framework; using NzbDrone.Common.Http; -using NzbDrone.Core.Configuration; using NzbDrone.Core.Download; using NzbDrone.Core.Download.Clients.Pneumatic; using NzbDrone.Core.Parser.Model; @@ -19,9 +18,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests private const string _nzbUrl = "http://www.nzbs.com/url"; private const string _title = "30.Rock.S01E05.hdtv.xvid-LoL"; private string _pneumaticFolder; - private string _sabDrop; + private string _strmFolder; private string _nzbPath; - private RemoteEpisode _remoteEpisode; + private RemoteAlbum _remoteAlbum; [SetUp] public void Setup() @@ -29,22 +28,20 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests _pneumaticFolder = @"d:\nzb\pneumatic\".AsOsAgnostic(); _nzbPath = Path.Combine(_pneumaticFolder, _title + ".nzb").AsOsAgnostic(); - _sabDrop = @"d:\unsorted tv\".AsOsAgnostic(); + _strmFolder = @"d:\unsorted tv\".AsOsAgnostic(); - Mocker.GetMock().SetupGet(c => c.DownloadedEpisodesFolder).Returns(_sabDrop); + _remoteAlbum = new RemoteAlbum(); + _remoteAlbum.Release = new ReleaseInfo(); + _remoteAlbum.Release.Title = _title; + _remoteAlbum.Release.DownloadUrl = _nzbUrl; - _remoteEpisode = new RemoteEpisode(); - _remoteEpisode.Release = new ReleaseInfo(); - _remoteEpisode.Release.Title = _title; - _remoteEpisode.Release.DownloadUrl = _nzbUrl; - - _remoteEpisode.ParsedEpisodeInfo = new ParsedEpisodeInfo(); - _remoteEpisode.ParsedEpisodeInfo.FullSeason = false; + _remoteAlbum.ParsedAlbumInfo = new ParsedAlbumInfo(); Subject.Definition = new DownloadClientDefinition(); Subject.Definition.Settings = new PneumaticSettings { - NzbFolder = _pneumaticFolder + NzbFolder = _pneumaticFolder, + StrmFolder = _strmFolder }; } @@ -56,27 +53,26 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests [Test] public void should_download_file_if_it_doesnt_exist() { - Subject.Download(_remoteEpisode); + Subject.Download(_remoteAlbum); Mocker.GetMock().Verify(c => c.DownloadFile(_nzbUrl, _nzbPath), Times.Once()); } - [Test] public void should_throw_on_failed_download() { WithFailedDownload(); - Assert.Throws(() => Subject.Download(_remoteEpisode)); + Assert.Throws(() => Subject.Download(_remoteAlbum)); } [Test] - public void should_throw_if_full_season_download() + public void should_throw_if_discography_download() { - _remoteEpisode.Release.Title = "30 Rock - Season 1"; - _remoteEpisode.ParsedEpisodeInfo.FullSeason = true; + _remoteAlbum.Release.Title = "Alien Ant Farm - Discography"; + _remoteAlbum.ParsedAlbumInfo.Discography = true; - Assert.Throws(() => Subject.Download(_remoteEpisode)); + Assert.Throws(() => Subject.Download(_remoteAlbum)); } [Test] @@ -90,9 +86,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests { var illegalTitle = "Saturday Night Live - S38E08 - Jeremy Renner/Maroon 5 [SDTV]"; var expectedFilename = Path.Combine(_pneumaticFolder, "Saturday Night Live - S38E08 - Jeremy Renner+Maroon 5 [SDTV].nzb"); - _remoteEpisode.Release.Title = illegalTitle; + _remoteAlbum.Release.Title = illegalTitle; - Subject.Download(_remoteEpisode); + Subject.Download(_remoteAlbum); Mocker.GetMock().Verify(c => c.DownloadFile(It.IsAny(), expectedFilename), Times.Once()); } diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs index 3ceece6f6..e74c73fe5 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs @@ -1,14 +1,15 @@ using System; -using System.Linq; using System.Collections.Generic; +using System.Linq; using FluentAssertions; using Moq; using NUnit.Framework; using NzbDrone.Common.Http; -using NzbDrone.Core.MediaFiles.TorrentInfo; using NzbDrone.Core.Download; using NzbDrone.Core.Download.Clients.QBittorrent; +using NzbDrone.Core.MediaFiles.TorrentInfo; using NzbDrone.Test.Common; +using NzbDrone.Core.Exceptions; namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests { @@ -20,25 +21,29 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests { Subject.Definition = new DownloadClientDefinition(); Subject.Definition.Settings = new QBittorrentSettings - { - Host = "127.0.0.1", - Port = 2222, - Username = "admin", - Password = "pass", - TvCategory = "tv" - }; + { + Host = "127.0.0.1", + Port = 2222, + Username = "admin", + Password = "pass", + MusicCategory = "music" + }; Mocker.GetMock() - .Setup(s => s.GetHashFromTorrentFile(It.IsAny())) + .Setup(s => s.GetHashFromTorrentFile(It.IsAny())) .Returns("CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951"); Mocker.GetMock() .Setup(s => s.Get(It.IsAny())) - .Returns(r => new HttpResponse(r, new HttpHeader(), new Byte[0])); + .Returns(r => new HttpResponse(r, new HttpHeader(), new byte[0])); Mocker.GetMock() - .Setup(s => s.GetConfig(It.IsAny())) - .Returns(new QBittorrentPreferences()); + .Setup(s => s.GetConfig(It.IsAny())) + .Returns(new QBittorrentPreferences { DhtEnabled = true }); + + Mocker.GetMock() + .Setup(s => s.GetProxy(It.IsAny(), It.IsAny())) + .Returns(Mocker.GetMock().Object); } protected void GivenRedirectToMagnet() @@ -48,13 +53,13 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests Mocker.GetMock() .Setup(s => s.Get(It.IsAny())) - .Returns(r => new HttpResponse(r, httpHeader, new Byte[0], System.Net.HttpStatusCode.SeeOther)); + .Returns(r => new HttpResponse(r, httpHeader, new byte[0], System.Net.HttpStatusCode.SeeOther)); } protected void GivenRedirectToTorrent() { var httpHeader = new HttpHeader(); - httpHeader["Location"] = "http://test.sonarr.tv/not-a-real-torrent.torrent"; + httpHeader["Location"] = "http://test.lidarr.audio/not-a-real-torrent.torrent"; Mocker.GetMock() .Setup(s => s.Get(It.Is(h => h.Url.FullUri == _downloadUrl))) @@ -89,21 +94,32 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests }); } - protected void GivenMaxRatio(float maxRatio, bool removeOnMaxRatio = true) + protected void GivenHighPriority() + { + Subject.Definition.Settings.As().OlderTvPriority = (int)QBittorrentPriority.First; + Subject.Definition.Settings.As().RecentTvPriority = (int)QBittorrentPriority.First; + } + + protected void GivenGlobalSeedLimits(float maxRatio, int maxSeedingTime = -1, bool removeOnMaxRatio = false) { Mocker.GetMock() - .Setup(s => s.GetConfig(It.IsAny())) - .Returns(new QBittorrentPreferences - { - RemoveOnMaxRatio = removeOnMaxRatio, - MaxRatio = maxRatio - }); + .Setup(s => s.GetConfig(It.IsAny())) + .Returns(new QBittorrentPreferences + { + RemoveOnMaxRatio = removeOnMaxRatio, + MaxRatio = maxRatio, + MaxRatioEnabled = maxRatio >= 0, + MaxSeedingTime = maxSeedingTime, + MaxSeedingTimeEnabled = maxSeedingTime >= 0 + }); } protected virtual void GivenTorrents(List torrents) { if (torrents == null) + { torrents = new List(); + } Mocker.GetMock() .Setup(s => s.GetTorrents(It.IsAny())) @@ -148,7 +164,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests var item = Subject.GetItems().Single(); VerifyPaused(item); - item.RemainingTime.Should().NotBe(TimeSpan.Zero); + item.RemainingTime.Should().NotHaveValue(); } [TestCase("pausedUP")] @@ -156,6 +172,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests [TestCase("uploading")] [TestCase("stalledUP")] [TestCase("checkingUP")] + [TestCase("forcedUP")] public void completed_item_should_have_required_properties(string state) { var torrent = new QBittorrentTorrent @@ -178,6 +195,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests [TestCase("queuedDL")] [TestCase("checkingDL")] + [TestCase("metaDL")] public void queued_item_should_have_required_properties(string state) { var torrent = new QBittorrentTorrent @@ -195,7 +213,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests var item = Subject.GetItems().Single(); VerifyQueued(item); - item.RemainingTime.Should().NotBe(TimeSpan.Zero); + item.RemainingTime.Should().NotHaveValue(); } [Test] @@ -237,7 +255,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests var item = Subject.GetItems().Single(); VerifyWarning(item); - item.RemainingTime.Should().NotBe(TimeSpan.Zero); + item.RemainingTime.Should().NotHaveValue(); } [Test] @@ -245,9 +263,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests { GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().NotBeNullOrEmpty(); } @@ -257,14 +275,77 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests { GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); - remoteEpisode.Release.DownloadUrl = magnetUrl; + var remoteAlbum = CreateRemoteAlbum(); + remoteAlbum.Release.DownloadUrl = magnetUrl; - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().Be(expectedHash); } + [Test] + public void Download_should_refuse_magnet_if_no_trackers_provided_and_dht_is_disabled() + { + Mocker.GetMock() + .Setup(s => s.GetConfig(It.IsAny())) + .Returns(new QBittorrentPreferences() { DhtEnabled = false }); + + var remoteAlbum = CreateRemoteAlbum(); + remoteAlbum.Release.DownloadUrl = "magnet:?xt=urn:btih:ZPBPA2P6ROZPKRHK44D5OW6NHXU5Z6KR"; + + Assert.Throws(() => Subject.Download(remoteAlbum)); + } + + [Test] + public void Download_should_accept_magnet_if_trackers_provided_and_dht_is_disabled() + { + + Mocker.GetMock() + .Setup(s => s.GetConfig(It.IsAny())) + .Returns(new QBittorrentPreferences { DhtEnabled = false }); + + var remoteAlbum = CreateRemoteAlbum(); + remoteAlbum.Release.DownloadUrl = "magnet:?xt=urn:btih:ZPBPA2P6ROZPKRHK44D5OW6NHXU5Z6KR&tr=udp://abc"; + + Assert.DoesNotThrow(() => Subject.Download(remoteAlbum)); + + Mocker.GetMock() + .Verify(s => s.AddTorrentFromUrl(It.IsAny(), It.IsAny()), Times.Once()); + } + + [Test] + public void Download_should_set_top_priority() + { + GivenHighPriority(); + GivenSuccessfulDownload(); + + var remoteAlbum = CreateRemoteAlbum(); + + var id = Subject.Download(remoteAlbum); + + 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 remoteAlbum = CreateRemoteAlbum(); + + var id = Subject.Download(remoteAlbum); + + id.Should().NotBeNullOrEmpty(); + + ExceptionVerification.ExpectedWarns(1); + } + [Test] public void should_return_status_with_outputdirs() { @@ -290,9 +371,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests GivenRedirectToMagnet(); GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().NotBeNullOrEmpty(); } @@ -303,17 +384,17 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests GivenRedirectToTorrent(); GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().NotBeNullOrEmpty(); } [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); + GivenGlobalSeedLimits(1.0f); var torrent = new QBittorrentTorrent { @@ -330,14 +411,15 @@ 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() + protected virtual QBittorrentTorrent GivenCompletedTorrent( + string state = "pausedUP", + float ratio = 0.1f, float ratioLimit = -2, + int seedingTime = 1, int seedingTimeLimit = -2) { - GivenMaxRatio(1.0f); - var torrent = new QBittorrentTorrent { Hash = "HASH", @@ -345,68 +427,142 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests Size = 1000, Progress = 1.0, Eta = 8640000, - State = "uploading", + State = state, Label = "", SavePath = "", - Ratio = 1.0f + Ratio = ratio, + RatioLimit = ratioLimit, + SeedingTimeLimit = seedingTimeLimit }; - GivenTorrents(new List { torrent }); + GivenTorrents(new List() { torrent }); + + Mocker.GetMock() + .Setup(s => s.GetTorrentProperties("HASH", It.IsAny())) + .Returns(new QBittorrentTorrentProperties + { + Hash = "HASH", + SeedingTime = seedingTime + }); + + return torrent; + } + + [Test] + public void should_not_be_removable_and_should_not_allow_move_files_if_max_ratio_reached_and_not_paused() + { + GivenGlobalSeedLimits(1.0f); + GivenCompletedTorrent("uploading", ratio: 1.0f); 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); + GivenGlobalSeedLimits(-1); + GivenCompletedTorrent("pausedUP", ratio: 1.0f); - var torrent = new QBittorrentTorrent - { - Hash = "HASH", - Name = _title, - Size = 1000, - Progress = 1.0, - Eta = 8640000, - State = "uploading", - Label = "", - SavePath = "", - Ratio = 1.0f - }; - GivenTorrents(new List { torrent }); + var item = Subject.GetItems().Single(); + item.CanBeRemoved.Should().BeFalse(); + item.CanMoveFiles.Should().BeFalse(); + } + + [Test] + public void should_be_removable_and_should_allow_move_files_if_max_ratio_reached_and_paused() + { + GivenGlobalSeedLimits(1.0f); + GivenCompletedTorrent("pausedUP", ratio: 1.0f); var item = Subject.GetItems().Single(); - item.IsReadOnly.Should().BeTrue(); + item.CanBeRemoved.Should().BeTrue(); + item.CanMoveFiles.Should().BeTrue(); } [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_overridden_max_ratio_reached_and_paused() { - GivenMaxRatio(1.0f); + GivenGlobalSeedLimits(2.0f); + GivenCompletedTorrent("pausedUP", ratio: 1.0f, ratioLimit: 0.8f); - var torrent = new QBittorrentTorrent - { - Hash = "HASH", - Name = _title, - Size = 1000, - Progress = 1.0, - Eta = 8640000, - State = "pausedUP", - Label = "", - SavePath = "", - Ratio = 1.0f - }; - GivenTorrents(new List { torrent }); + var item = Subject.GetItems().Single(); + item.CanBeRemoved.Should().BeTrue(); + item.CanMoveFiles.Should().BeTrue(); + } + + [Test] + public void should_not_be_removable_if_overridden_max_ratio_not_reached_and_paused() + { + GivenGlobalSeedLimits(0.2f); + GivenCompletedTorrent("pausedUP", ratio: 0.5f, ratioLimit: 0.8f); + + var item = Subject.GetItems().Single(); + item.CanBeRemoved.Should().BeFalse(); + item.CanMoveFiles.Should().BeFalse(); + } + + + [Test] + public void should_not_be_removable_and_should_not_allow_move_files_if_max_seedingtime_reached_and_not_paused() + { + GivenGlobalSeedLimits(-1, 20); + GivenCompletedTorrent("uploading", ratio: 2.0f, seedingTime: 30); + + var item = Subject.GetItems().Single(); + item.CanBeRemoved.Should().BeFalse(); + item.CanMoveFiles.Should().BeFalse(); + } + + [Test] + public void should_be_removable_and_should_allow_move_files_if_max_seedingtime_reached_and_paused() + { + GivenGlobalSeedLimits(-1, 20); + GivenCompletedTorrent("pausedUP", ratio: 2.0f, seedingTime: 20); + + var item = Subject.GetItems().Single(); + item.CanBeRemoved.Should().BeTrue(); + item.CanMoveFiles.Should().BeTrue(); + } + + [Test] + public void should_be_removable_and_should_allow_move_files_if_overridden_max_seedingtime_reached_and_paused() + { + GivenGlobalSeedLimits(-1, 40); + GivenCompletedTorrent("pausedUP", ratio: 2.0f, seedingTime: 20, seedingTimeLimit: 10); + + var item = Subject.GetItems().Single(); + item.CanBeRemoved.Should().BeTrue(); + item.CanMoveFiles.Should().BeTrue(); + } + + [Test] + public void should_not_be_removable_if_overridden_max_seedingtime_not_reached_and_paused() + { + GivenGlobalSeedLimits(-1, 20); + GivenCompletedTorrent("pausedUP", ratio: 2.0f, seedingTime: 30, seedingTimeLimit: 40); var item = Subject.GetItems().Single(); - item.IsReadOnly.Should().BeFalse(); + item.CanBeRemoved.Should().BeFalse(); + item.CanMoveFiles.Should().BeFalse(); + } + + [Test] + public void should_be_removable_and_should_allow_move_files_if_max_seedingtime_reached_but_ratio_not_and_paused() + { + GivenGlobalSeedLimits(2.0f, 20); + GivenCompletedTorrent("pausedUP", ratio: 1.0f, seedingTime: 30); + + var item = Subject.GetItems().Single(); + item.CanBeRemoved.Should().BeTrue(); + item.CanMoveFiles.Should().BeTrue(); } [Test] public void should_get_category_from_the_category_if_set() { - const string category = "tv-sonarr"; - GivenMaxRatio(1.0f); + const string category = "music-lidarr"; + GivenGlobalSeedLimits(1.0f); var torrent = new QBittorrentTorrent { @@ -430,8 +586,8 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests [Test] public void should_get_category_from_the_label_if_the_category_is_not_available() { - const string category = "tv-sonarr"; - GivenMaxRatio(1.0f); + const string category = "music-lidarr"; + GivenGlobalSeedLimits(1.0f); var torrent = new QBittorrentTorrent { @@ -451,5 +607,27 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests var item = Subject.GetItems().Single(); item.Category.Should().Be(category); } + + [Test] + public void should_handle_eta_biginteger() + { + var json = "{ \"eta\": 18446744073709335000 }"; + var torrent = Newtonsoft.Json.JsonConvert.DeserializeObject(json); + torrent.Eta.ToString().Should().Be("18446744073709335000"); + } + + [Test] + public void Test_should_force_api_version_check() + { + // Set TestConnection up to fail quick + Mocker.GetMock() + .Setup(v => v.GetApiVersion(It.IsAny())) + .Returns(new Version(1, 0)); + + Subject.Test(); + + Mocker.GetMock() + .Verify(v => v.GetProxy(It.IsAny(), true), Times.Once()); + } } } diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/RTorrentTests/RTorrentFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/RTorrentTests/RTorrentFixture.cs index f657a7884..1ef20907b 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/RTorrentTests/RTorrentFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/RTorrentTests/RTorrentFixture.cs @@ -21,7 +21,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.RTorrentTests Subject.Definition = new DownloadClientDefinition(); Subject.Definition.Settings = new RTorrentSettings() { - TvCategory = null + MusicCategory = null }; _downloading = new RTorrentTorrent @@ -54,11 +54,11 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.RTorrentTests protected void GivenSuccessfulDownload() { Mocker.GetMock() - .Setup(s => s.AddTorrentFromUrl(It.IsAny(), It.IsAny())) + .Setup(s => s.AddTorrentFromUrl(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback(PrepareClientToReturnCompletedItem); Mocker.GetMock() - .Setup(s => s.AddTorrentFromFile(It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(s => s.AddTorrentFromFile(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback(PrepareClientToReturnCompletedItem); @@ -116,9 +116,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.RTorrentTests { GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().NotBeNullOrEmpty(); } diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabnzbdTests/SabnzbdFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabnzbdTests/SabnzbdFixture.cs index a308e68aa..dfe7225e7 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabnzbdTests/SabnzbdFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/SabnzbdTests/SabnzbdFixture.cs @@ -8,7 +8,7 @@ using NUnit.Framework; using NzbDrone.Core.Download; using NzbDrone.Core.Download.Clients.Sabnzbd; using NzbDrone.Core.Download.Clients.Sabnzbd.Responses; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; using NzbDrone.Test.Common; using NzbDrone.Core.RemotePathMappings; using NzbDrone.Common.Disk; @@ -23,6 +23,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests private SabnzbdHistory _failed; private SabnzbdHistory _completed; private SabnzbdConfig _config; + private SabnzbdFullStatus _fullStatus; [SetUp] public void Setup() @@ -35,7 +36,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests ApiKey = "5c770e3197e4fe763423ee7c392c25d1", Username = "admin", Password = "pass", - TvCategory = "tv", + MusicCategory = "tv", RecentTvPriority = (int)SabnzbdPriority.High }; _queued = new SabnzbdQueue @@ -65,7 +66,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests { Status = SabnzbdDownloadStatus.Failed, Size = 1000, - Category = "tv", + Category = "tv", Id = "sabnzbd_nzb12345", Title = "Droned.S01E01.Pilot.1080p.WEB-DL-DRONE" } @@ -80,7 +81,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests { Status = SabnzbdDownloadStatus.Completed, Size = 1000, - Category = "tv", + Category = "tv", Id = "sabnzbd_nzb12345", Title = "Droned.S01E01.Pilot.1080p.WEB-DL-DRONE", Storage = "/remote/mount/vv/Droned.S01E01.Pilot.1080p.WEB-DL-DRONE" @@ -100,9 +101,29 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests } }; + Mocker.GetMock() + .Setup(v => v.GetVersion(It.IsAny())) + .Returns("1.2.3"); + Mocker.GetMock() .Setup(s => s.GetConfig(It.IsAny())) .Returns(_config); + + _fullStatus = new SabnzbdFullStatus + { + CompleteDir = @"Y:\nzbget\root\complete".AsOsAgnostic() + }; + + Mocker.GetMock() + .Setup(s => s.GetFullStatus(It.IsAny())) + .Returns(_fullStatus); + } + + protected void GivenVersion(string version) + { + Mocker.GetMock() + .Setup(s => s.GetVersion(It.IsAny())) + .Returns(version); } protected void GivenFailedDownload() @@ -166,11 +187,14 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests GivenQueue(_queued); GivenHistory(null); - + var result = Subject.GetItems().Single(); VerifyQueued(result); + result.RemainingTime.Should().NotBe(TimeSpan.Zero); + result.CanBeRemoved.Should().BeTrue(); + result.CanMoveFiles.Should().BeTrue(); } [TestCase(SabnzbdDownloadStatus.Paused)] @@ -184,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)] @@ -206,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] @@ -218,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] @@ -231,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] @@ -260,10 +296,10 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests { GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); - remoteEpisode.Release.Title = title; + var remoteAlbum = CreateRemoteAlbum(); + remoteAlbum.Release.Title = title; - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); Mocker.GetMock() .Verify(v => v.DownloadNzb(It.IsAny(), filename, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); @@ -274,9 +310,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests { GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().NotBeNullOrEmpty(); } @@ -313,16 +349,16 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests { Mocker.GetMock() .Setup(s => s.DownloadNzb(It.IsAny(), It.IsAny(), It.IsAny(), (int)SabnzbdPriority.High, It.IsAny())) - .Returns(new SabnzbdAddResponse()); + .Returns(new SabnzbdAddResponse { Ids = new List { "lidarrtest" } }); - var remoteEpisode = CreateRemoteEpisode(); - remoteEpisode.Episodes = Builder.CreateListOfSize(1) + var remoteAlbum = CreateRemoteAlbum(); + remoteAlbum.Albums = Builder.CreateListOfSize(1) .All() - .With(e => e.AirDate = DateTime.Today.ToString(Episode.AIR_DATE_FORMAT)) + .With(e => e.ReleaseDate = DateTime.Today) .Build() .ToList(); - Subject.Download(remoteEpisode); + Subject.Download(remoteAlbum); Mocker.GetMock() .Verify(v => v.DownloadNzb(It.IsAny(), It.IsAny(), It.IsAny(), (int)SabnzbdPriority.High, It.IsAny()), Times.Once()); @@ -386,23 +422,46 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests result.OutputPath.Should().Be(@"C:\sorted\somewhere\asdfasdf\asdfasdf.mkv".AsOsAgnostic()); } - [TestCase(@"Y:\nzbget\root", @"completed\downloads", @"vv", @"Y:\nzbget\root\completed\downloads\vv")] - [TestCase(@"Y:\nzbget\root", @"completed", @"vv", @"Y:\nzbget\root\completed\vv")] - [TestCase(@"/nzbget/root", @"completed/downloads", @"vv", @"/nzbget/root/completed/downloads/vv")] - [TestCase(@"/nzbget/root", @"completed", @"vv", @"/nzbget/root/completed/vv")] - public void should_return_status_with_outputdir(string rootFolder, string completeDir, string categoryDir, string expectedDir) + [TestCase(@"Y:\nzbget\root", @"completed\downloads", @"vv", @"Y:\nzbget\root\completed\downloads", @"Y:\nzbget\root\completed\downloads\vv")] + [TestCase(@"Y:\nzbget\root", @"completed", @"vv", @"Y:\nzbget\root\completed", @"Y:\nzbget\root\completed\vv")] + [TestCase(@"/nzbget/root", @"completed/downloads", @"vv", @"/nzbget/root/completed/downloads", @"/nzbget/root/completed/downloads/vv")] + [TestCase(@"/nzbget/root", @"completed", @"vv", @"/nzbget/root/completed", @"/nzbget/root/completed/vv")] + public void should_return_status_with_outputdir_for_version_lt_2(string rootFolder, string completeDir, string categoryDir, string fullCompleteDir, string fullCategoryDir) { + _fullStatus.CompleteDir = null; _queued.DefaultRootFolder = rootFolder; _config.Misc.complete_dir = completeDir; _config.Categories.First().Dir = categoryDir; - + + GivenVersion("1.2.1"); GivenQueue(null); var result = Subject.GetStatus(); result.IsLocalhost.Should().BeTrue(); result.OutputRootFolders.Should().NotBeNull(); - result.OutputRootFolders.First().Should().Be(expectedDir); + result.OutputRootFolders.First().Should().Be(fullCategoryDir); + } + + [TestCase(@"Y:\nzbget\root", @"completed\downloads", @"vv", @"Y:\nzbget\root\completed\downloads", @"Y:\nzbget\root\completed\downloads\vv")] + [TestCase(@"Y:\nzbget\root", @"completed", @"vv", @"Y:\nzbget\root\completed", @"Y:\nzbget\root\completed\vv")] + [TestCase(@"/nzbget/root", @"completed/downloads", @"vv", @"/nzbget/root/completed/downloads", @"/nzbget/root/completed/downloads/vv")] + [TestCase(@"/nzbget/root", @"completed", @"vv", @"/nzbget/root/completed", @"/nzbget/root/completed/vv")] + public void should_return_status_with_outputdir_for_version_gte_2(string rootFolder, string completeDir, string categoryDir, string fullCompleteDir, string fullCategoryDir) + { + _fullStatus.CompleteDir = fullCompleteDir; + _queued.DefaultRootFolder = null; + _config.Misc.complete_dir = completeDir; + _config.Categories.First().Dir = categoryDir; + + GivenVersion("2.0.0beta1"); + GivenQueue(null); + + var result = Subject.GetStatus(); + + result.IsLocalhost.Should().BeTrue(); + result.OutputRootFolders.Should().NotBeNull(); + result.OutputRootFolders.First().Should().Be(fullCategoryDir); } [Test] @@ -450,5 +509,73 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.SabnzbdTests result.IsValid.Should().BeTrue(); result.HasWarnings.Should().BeTrue(); } + + [Test] + public void should_test_success_if_tv_sorting_disabled() + { + _config.Misc.enable_tv_sorting = false; + _config.Misc.tv_categories = null; + + var result = new NzbDroneValidationResult(Subject.Test()); + + result.IsValid.Should().BeTrue(); + } + + [Test] + public void should_test_failed_if_tv_sorting_null() + { + _config.Misc.enable_tv_sorting = true; + _config.Misc.tv_categories = null; + + var result = new NzbDroneValidationResult(Subject.Test()); + + result.IsValid.Should().BeFalse(); + } + + [Test] + public void should_test_failed_if_tv_sorting_empty() + { + _config.Misc.enable_tv_sorting = true; + _config.Misc.tv_categories = new string[0]; + + var result = new NzbDroneValidationResult(Subject.Test()); + + result.IsValid.Should().BeFalse(); + } + + [Test] + public void should_test_success_if_tv_sorting_contains_different_category() + { + _config.Misc.enable_tv_sorting = true; + _config.Misc.tv_categories = new[] { "tv-custom" }; + + var result = new NzbDroneValidationResult(Subject.Test()); + + result.IsValid.Should().BeTrue(); + } + + [Test] + public void should_test_failed_if_tv_sorting_contains_category() + { + _config.Misc.enable_tv_sorting = true; + _config.Misc.tv_categories = new[] { "tv" }; + + var result = new NzbDroneValidationResult(Subject.Test()); + + result.IsValid.Should().BeFalse(); + } + + [Test] + public void should_test_failed_if_tv_sorting_default_category() + { + Subject.Definition.Settings.As().MusicCategory = null; + + _config.Misc.enable_tv_sorting = true; + _config.Misc.tv_categories = new[] { "Default" }; + + var result = new NzbDroneValidationResult(Subject.Test()); + + result.IsValid.Should().BeFalse(); + } } } diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/TransmissionTests/TransmissionFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/TransmissionTests/TransmissionFixture.cs index 39ec56789..638664693 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] @@ -55,9 +58,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.TransmissionTests { GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().NotBeNullOrEmpty(); } @@ -68,48 +71,48 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.TransmissionTests GivenTvDirectory(); GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().NotBeNullOrEmpty(); Mocker.GetMock() - .Verify(v => v.AddTorrentFromData(It.IsAny(), @"C:/Downloads/Finished/sonarr", It.IsAny()), Times.Once()); + .Verify(v => v.AddTorrentFromData(It.IsAny(), @"C:/Downloads/Finished/Lidarr", It.IsAny()), Times.Once()); } [Test] public void Download_with_category_should_force_directory() { - GivenTvCategory(); + GivenMusicCategory(); GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().NotBeNullOrEmpty(); Mocker.GetMock() - .Verify(v => v.AddTorrentFromData(It.IsAny(), @"C:/Downloads/Finished/transmission/sonarr", It.IsAny()), Times.Once()); + .Verify(v => v.AddTorrentFromData(It.IsAny(), @"C:/Downloads/Finished/transmission/Lidarr", It.IsAny()), Times.Once()); } [Test] public void Download_with_category_should_not_have_double_slashes() { - GivenTvCategory(); + GivenMusicCategory(); GivenSuccessfulDownload(); _transmissionConfigItems["download-dir"] += "/"; - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().NotBeNullOrEmpty(); Mocker.GetMock() - .Verify(v => v.AddTorrentFromData(It.IsAny(), @"C:/Downloads/Finished/transmission/sonarr", It.IsAny()), Times.Once()); + .Verify(v => v.AddTorrentFromData(It.IsAny(), @"C:/Downloads/Finished/transmission/Lidarr", It.IsAny()), Times.Once()); } [Test] @@ -117,9 +120,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.TransmissionTests { GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().NotBeNullOrEmpty(); @@ -132,10 +135,10 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.TransmissionTests { GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); - remoteEpisode.Release.DownloadUrl = magnetUrl; + var remoteAlbum = CreateRemoteAlbum(); + remoteAlbum.Release.DownloadUrl = magnetUrl; - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().Be(expectedHash); } @@ -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] @@ -203,9 +207,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.TransmissionTests [Test] public void should_exclude_items_not_in_category() { - GivenTvCategory(); + GivenMusicCategory(); - _downloading.DownloadDir = @"C:/Downloads/Finished/transmission/sonarr"; + _downloading.DownloadDir = @"C:/Downloads/Finished/transmission/Lidarr"; GivenTorrents(new List { @@ -224,7 +228,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.TransmissionTests { GivenTvDirectory(); - _downloading.DownloadDir = @"C:/Downloads/Finished/sonarr/subdir"; + _downloading.DownloadDir = @"C:/Downloads/Finished/Lidarr/subdir"; GivenTorrents(new List { diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/TransmissionTests/TransmissionFixtureBase.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/TransmissionTests/TransmissionFixtureBase.cs index d46f9a30e..78274e57d 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/TransmissionTests/TransmissionFixtureBase.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/TransmissionTests/TransmissionFixtureBase.cs @@ -110,14 +110,14 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.TransmissionTests } - protected void GivenTvCategory() + protected void GivenMusicCategory() { - _settings.TvCategory = "sonarr"; + _settings.MusicCategory = "Lidarr"; } protected void GivenTvDirectory() { - _settings.TvDirectory = @"C:/Downloads/Finished/sonarr"; + _settings.TvDirectory = @"C:/Downloads/Finished/Lidarr"; } protected void GivenFailedDownload() @@ -194,4 +194,4 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.TransmissionTests }); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/UTorrentTests/UTorrentFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/UTorrentTests/UTorrentFixture.cs index 1d9f037d2..060ca7be4 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/UTorrentTests/UTorrentFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/UTorrentTests/UTorrentFixture.cs @@ -30,8 +30,8 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.UTorrentTests Port = 2222, Username = "admin", Password = "pass", - TvCategory = "tv" - }; + MusicCategory = "lidarr" + }; _queued = new UTorrentTorrent { @@ -41,7 +41,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.UTorrentTests Size = 1000, Remaining = 1000, Progress = 0, - Label = "tv", + Label = "lidarr", DownloadUrl = _downloadUrl, RootDownloadPath = "somepath" }; @@ -54,7 +54,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.UTorrentTests Size = 1000, Remaining = 100, Progress = 0.9, - Label = "tv", + Label = "lidarr", DownloadUrl = _downloadUrl, RootDownloadPath = "somepath" }; @@ -67,7 +67,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.UTorrentTests Size = 1000, Remaining = 100, Progress = 0.9, - Label = "tv", + Label = "lidarr", DownloadUrl = _downloadUrl, RootDownloadPath = "somepath" }; @@ -80,7 +80,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.UTorrentTests Size = 1000, Remaining = 0, Progress = 1.0, - Label = "tv", + Label = "lidarr", DownloadUrl = _downloadUrl, RootDownloadPath = "somepath" }; @@ -107,7 +107,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.UTorrentTests protected void GivenRedirectToTorrent() { var httpHeader = new HttpHeader(); - httpHeader["Location"] = "http://test.sonarr.tv/not-a-real-torrent.torrent"; + httpHeader["Location"] = "http://test.lidarr.audio/not-a-real-torrent.torrent"; Mocker.GetMock() .Setup(s => s.Get(It.Is(h => h.Url.ToString() == _downloadUrl))) @@ -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] @@ -229,9 +232,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.UTorrentTests { GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().NotBeNullOrEmpty(); } @@ -253,10 +256,10 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.UTorrentTests { GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); - remoteEpisode.Release.DownloadUrl = magnetUrl; + var remoteAlbum = CreateRemoteAlbum(); + remoteAlbum.Release.DownloadUrl = magnetUrl; - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().Be(expectedHash); } @@ -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] @@ -328,7 +332,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.UTorrentTests result.IsLocalhost.Should().BeTrue(); result.OutputRootFolders.Should().NotBeNull(); - result.OutputRootFolders.First().Should().Be(@"C:\Downloads\Finished\utorrent\tv".AsOsAgnostic()); + result.OutputRootFolders.First().Should().Be(@"C:\Downloads\Finished\utorrent\lidarr".AsOsAgnostic()); } [Test] @@ -351,9 +355,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.UTorrentTests GivenRedirectToMagnet(); GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().NotBeNullOrEmpty(); } @@ -364,9 +368,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.UTorrentTests GivenRedirectToTorrent(); GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().NotBeNullOrEmpty(); } diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/VuzeTests/VuzeFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/VuzeTests/VuzeFixture.cs index 00278c811..4eb31d280 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/VuzeTests/VuzeFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/VuzeTests/VuzeFixture.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using FluentAssertions; using Moq; @@ -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] @@ -57,9 +67,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.VuzeTests { GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().NotBeNullOrEmpty(); } @@ -70,48 +80,48 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.VuzeTests GivenTvDirectory(); GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().NotBeNullOrEmpty(); Mocker.GetMock() - .Verify(v => v.AddTorrentFromData(It.IsAny(), @"C:/Downloads/Finished/sonarr", It.IsAny()), Times.Once()); + .Verify(v => v.AddTorrentFromData(It.IsAny(), @"C:/Downloads/Finished/Lidarr", It.IsAny()), Times.Once()); } [Test] public void Download_with_category_should_force_directory() { - GivenTvCategory(); + GivenMusicCategory(); GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().NotBeNullOrEmpty(); Mocker.GetMock() - .Verify(v => v.AddTorrentFromData(It.IsAny(), @"C:/Downloads/Finished/transmission/sonarr", It.IsAny()), Times.Once()); + .Verify(v => v.AddTorrentFromData(It.IsAny(), @"C:/Downloads/Finished/transmission/Lidarr", It.IsAny()), Times.Once()); } [Test] public void Download_with_category_should_not_have_double_slashes() { - GivenTvCategory(); + GivenMusicCategory(); GivenSuccessfulDownload(); _transmissionConfigItems["download-dir"] += "/"; - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().NotBeNullOrEmpty(); Mocker.GetMock() - .Verify(v => v.AddTorrentFromData(It.IsAny(), @"C:/Downloads/Finished/transmission/sonarr", It.IsAny()), Times.Once()); + .Verify(v => v.AddTorrentFromData(It.IsAny(), @"C:/Downloads/Finished/transmission/Lidarr", It.IsAny()), Times.Once()); } [Test] @@ -119,9 +129,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.VuzeTests { GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); + var remoteAlbum = CreateRemoteAlbum(); - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().NotBeNullOrEmpty(); @@ -134,10 +144,10 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.VuzeTests { GivenSuccessfulDownload(); - var remoteEpisode = CreateRemoteEpisode(); - remoteEpisode.Release.DownloadUrl = magnetUrl; + var remoteAlbum = CreateRemoteAlbum(); + remoteAlbum.Release.DownloadUrl = magnetUrl; - var id = Subject.Download(remoteEpisode); + var id = Subject.Download(remoteAlbum); id.Should().Be(expectedHash); } @@ -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] @@ -205,9 +216,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.VuzeTests [Test] public void should_exclude_items_not_in_category() { - GivenTvCategory(); + GivenMusicCategory(); - _downloading.DownloadDir = @"C:/Downloads/Finished/transmission/sonarr"; + _downloading.DownloadDir = @"C:/Downloads/Finished/transmission/Lidarr"; GivenTorrents(new List { @@ -226,7 +237,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.VuzeTests { GivenTvDirectory(); - _downloading.DownloadDir = @"C:/Downloads/Finished/sonarr/subdir"; + _downloading.DownloadDir = @"C:/Downloads/Finished/Lidarr/subdir"; GivenTorrents(new List { @@ -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..38df5626d 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadServiceFixture.cs @@ -12,7 +12,7 @@ using NzbDrone.Core.Exceptions; using NzbDrone.Core.Indexers; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.Download @@ -20,7 +20,7 @@ namespace NzbDrone.Core.Test.Download [TestFixture] public class DownloadServiceFixture : CoreTest { - private RemoteEpisode _parseResult; + private RemoteAlbum _parseResult; private List _downloadClients; [SetUp] public void Setup() @@ -35,10 +35,10 @@ namespace NzbDrone.Core.Test.Download .Setup(v => v.GetDownloadClient(It.IsAny())) .Returns(v => _downloadClients.FirstOrDefault(d => d.Protocol == v)); - var episodes = Builder.CreateListOfSize(2) + var episodes = Builder.CreateListOfSize(2) .TheFirst(1).With(s => s.Id = 12) .TheNext(1).With(s => s.Id = 99) - .All().With(s => s.SeriesId = 5) + .All().With(s => s.ArtistId = 5) .Build().ToList(); var releaseInfo = Builder.CreateNew() @@ -46,10 +46,10 @@ namespace NzbDrone.Core.Test.Download .With(v => v.DownloadUrl = "http://test.site/download1.ext") .Build(); - _parseResult = Builder.CreateNew() - .With(c => c.Series = Builder.CreateNew().Build()) + _parseResult = Builder.CreateNew() + .With(c => c.Artist = Builder.CreateNew().Build()) .With(c => c.Release = releaseInfo) - .With(c => c.Episodes = episodes) + .With(c => c.Albums = episodes) .Build(); } @@ -81,42 +81,42 @@ namespace NzbDrone.Core.Test.Download public void Download_report_should_publish_on_grab_event() { var mock = WithUsenetClient(); - mock.Setup(s => s.Download(It.IsAny())); + mock.Setup(s => s.Download(It.IsAny())); Subject.DownloadReport(_parseResult); - VerifyEventPublished(); + VerifyEventPublished(); } [Test] public void Download_report_should_grab_using_client() { var mock = WithUsenetClient(); - mock.Setup(s => s.Download(It.IsAny())); + mock.Setup(s => s.Download(It.IsAny())); Subject.DownloadReport(_parseResult); - mock.Verify(s => s.Download(It.IsAny()), Times.Once()); + mock.Verify(s => s.Download(It.IsAny()), Times.Once()); } [Test] public void Download_report_should_not_publish_on_failed_grab_event() { var mock = WithUsenetClient(); - mock.Setup(s => s.Download(It.IsAny())) + mock.Setup(s => s.Download(It.IsAny())) .Throws(new WebException()); Assert.Throws(() => Subject.DownloadReport(_parseResult)); - VerifyEventNotPublished(); + VerifyEventNotPublished(); } [Test] public void Download_report_should_trigger_indexer_backoff_on_indexer_error() { var mock = WithUsenetClient(); - mock.Setup(s => s.Download(It.IsAny())) - .Callback(v => { + mock.Setup(s => s.Download(It.IsAny())) + .Callback(v => { throw new ReleaseDownloadException(v.Release, "Error", new WebException()); }); @@ -134,8 +134,8 @@ namespace NzbDrone.Core.Test.Download response.Headers["Retry-After"] = "300"; var mock = WithUsenetClient(); - mock.Setup(s => s.Download(It.IsAny())) - .Callback(v => { + mock.Setup(s => s.Download(It.IsAny())) + .Callback(v => { throw new ReleaseDownloadException(v.Release, "Error", new TooManyRequestsException(request, response)); }); @@ -153,8 +153,8 @@ namespace NzbDrone.Core.Test.Download response.Headers["Retry-After"] = DateTime.UtcNow.AddSeconds(300).ToString("r"); var mock = WithUsenetClient(); - mock.Setup(s => s.Download(It.IsAny())) - .Callback(v => + mock.Setup(s => s.Download(It.IsAny())) + .Callback(v => { throw new ReleaseDownloadException(v.Release, "Error", new TooManyRequestsException(request, response)); }); @@ -170,7 +170,7 @@ namespace NzbDrone.Core.Test.Download public void Download_report_should_not_trigger_indexer_backoff_on_downloadclient_error() { var mock = WithUsenetClient(); - mock.Setup(s => s.Download(It.IsAny())) + mock.Setup(s => s.Download(It.IsAny())) .Throws(new DownloadClientException("Some Error")); Assert.Throws(() => Subject.DownloadReport(_parseResult)); @@ -180,14 +180,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()); + } - Mocker.GetMock().Verify(c => c.Download(It.IsAny()), Times.Never()); - VerifyEventNotPublished(); + [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(); + } + + [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); - ExceptionVerification.ExpectedWarns(1); + Mocker.GetMock().Verify(c => c.GetBlockedProviders(), Times.Never()); + mockUsenet.Verify(c => c.Download(It.IsAny()), Times.Once()); + VerifyEventPublished(); } [Test] @@ -198,8 +234,8 @@ namespace NzbDrone.Core.Test.Download Subject.DownloadReport(_parseResult); - mockTorrent.Verify(c => c.Download(It.IsAny()), Times.Never()); - mockUsenet.Verify(c => c.Download(It.IsAny()), Times.Once()); + mockTorrent.Verify(c => c.Download(It.IsAny()), Times.Never()); + mockUsenet.Verify(c => c.Download(It.IsAny()), Times.Once()); } [Test] @@ -212,8 +248,8 @@ namespace NzbDrone.Core.Test.Download Subject.DownloadReport(_parseResult); - mockTorrent.Verify(c => c.Download(It.IsAny()), Times.Once()); - mockUsenet.Verify(c => c.Download(It.IsAny()), Times.Never()); + mockTorrent.Verify(c => c.Download(It.IsAny()), Times.Once()); + mockUsenet.Verify(c => c.Download(It.IsAny()), Times.Never()); } } } diff --git a/src/NzbDrone.Core.Test/Download/FailedDownloadServiceFixture.cs b/src/NzbDrone.Core.Test/Download/FailedDownloadServiceFixture.cs index 42b589e6b..420647de4 100644 --- a/src/NzbDrone.Core.Test/Download/FailedDownloadServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Download/FailedDownloadServiceFixture.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using FizzWare.NBuilder; using FluentAssertions; using Moq; @@ -10,7 +10,7 @@ using NzbDrone.Core.History; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.Download @@ -27,21 +27,21 @@ namespace NzbDrone.Core.Test.Download var completed = Builder.CreateNew() .With(h => h.Status = DownloadItemStatus.Completed) .With(h => h.OutputPath = new OsPath(@"C:\DropFolder\MyDownload".AsOsAgnostic())) - .With(h => h.Title = "Drone.S01E01.HDTV") + .With(h => h.Title = "Drone.DroneTheAlbum.FLAC") .Build(); _grabHistory = Builder.CreateListOfSize(2).BuildList(); - var remoteEpisode = new RemoteEpisode + var remoteAlbum = new RemoteAlbum { - Series = new Series(), - Episodes = new List { new Episode { Id = 1 } } + Artist = new Artist(), + Albums = new List { new Album { Id = 1 } } }; _trackedDownload = Builder.CreateNew() .With(c => c.State = TrackedDownloadStage.Downloading) .With(c => c.DownloadItem = completed) - .With(c => c.RemoteEpisode = remoteEpisode) + .With(c => c.RemoteAlbum = remoteAlbum) .Build(); 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..84264ce70 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; using System.Collections.Generic; +using System.Linq; using FizzWare.NBuilder; using Marr.Data; using Moq; @@ -9,10 +10,10 @@ using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Download.Pending; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Profiles; +using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Qualities; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests { @@ -20,67 +21,78 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests public class AddFixture : CoreTest { private DownloadDecision _temporarilyRejected; - private Series _series; - private Episode _episode; - private Profile _profile; + private Artist _artist; + private Album _album; + private QualityProfile _profile; private ReleaseInfo _release; - private ParsedEpisodeInfo _parsedEpisodeInfo; - private RemoteEpisode _remoteEpisode; + private ParsedAlbumInfo _parsedAlbumInfo; + private RemoteAlbum _remoteAlbum; + private List _heldReleases; [SetUp] public void Setup() { - _series = Builder.CreateNew() + _artist = Builder.CreateNew() .Build(); - _episode = Builder.CreateNew() + _album = Builder.CreateNew() .Build(); - _profile = new Profile + _profile = new QualityProfile { Name = "Test", - Cutoff = Quality.HDTV720p, - Items = new List + Cutoff = Quality.MP3_256.Id, + Items = new List { - new ProfileQualityItem { Allowed = true, Quality = Quality.HDTV720p }, - new ProfileQualityItem { Allowed = true, Quality = Quality.WEBDL720p }, - new ProfileQualityItem { Allowed = true, Quality = Quality.Bluray720p } + new QualityProfileQualityItem { Allowed = true, Quality = Quality.MP3_256 }, + new QualityProfileQualityItem { Allowed = true, Quality = Quality.MP3_320 }, + new QualityProfileQualityItem { Allowed = true, Quality = Quality.MP3_320 } }, }; - _series.Profile = new LazyLoaded(_profile); + _artist.QualityProfile = new LazyLoaded(_profile); _release = Builder.CreateNew().Build(); - _parsedEpisodeInfo = Builder.CreateNew().Build(); - _parsedEpisodeInfo.Quality = new QualityModel(Quality.HDTV720p); + _parsedAlbumInfo = Builder.CreateNew().Build(); + _parsedAlbumInfo.Quality = new QualityModel(Quality.MP3_256); - _remoteEpisode = new RemoteEpisode(); - _remoteEpisode.Episodes = new List{ _episode }; - _remoteEpisode.Series = _series; - _remoteEpisode.ParsedEpisodeInfo = _parsedEpisodeInfo; - _remoteEpisode.Release = _release; + _remoteAlbum = new RemoteAlbum(); + _remoteAlbum.Albums = new List{ _album }; + _remoteAlbum.Artist = _artist; + _remoteAlbum.ParsedAlbumInfo = _parsedAlbumInfo; + _remoteAlbum.Release = _release; - _temporarilyRejected = new DownloadDecision(_remoteEpisode, new Rejection("Temp Rejected", RejectionType.Temporary)); + _temporarilyRejected = new DownloadDecision(_remoteAlbum, 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.AllByArtistId(It.IsAny())) + .Returns(i => _heldReleases.Where(v => v.ArtistId == i).ToList()); - Mocker.GetMock() - .Setup(s => s.GetSeries(It.IsAny())) - .Returns(_series); + Mocker.GetMock() + .Setup(s => s.GetArtist(It.IsAny())) + .Returns(_artist); + + Mocker.GetMock() + .Setup(s => s.GetArtists(It.IsAny>())) + .Returns(new List { _artist }); Mocker.GetMock() - .Setup(s => s.GetEpisodes(It.IsAny(), _series, true, null)) - .Returns(new List {_episode}); + .Setup(s => s.GetAlbums(It.IsAny(), _artist, null)) + .Returns(new List {_album}); Mocker.GetMock() .Setup(s => s.PrioritizeDecisions(It.IsAny>())) .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; @@ -89,20 +101,19 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests var heldReleases = Builder.CreateListOfSize(1) .All() - .With(h => h.SeriesId = _series.Id) + .With(h => h.ArtistId = _artist.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..350bf47d2 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; @@ -9,10 +10,10 @@ using NzbDrone.Core.Download; using NzbDrone.Core.Download.Pending; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Profiles; +using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Qualities; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests { @@ -20,60 +21,71 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests public class RemoveGrabbedFixture : CoreTest { private DownloadDecision _temporarilyRejected; - private Series _series; - private Episode _episode; - private Profile _profile; + private Artist _artist; + private Album _album; + private QualityProfile _profile; private ReleaseInfo _release; - private ParsedEpisodeInfo _parsedEpisodeInfo; - private RemoteEpisode _remoteEpisode; + private ParsedAlbumInfo _parsedAlbumInfo; + private RemoteAlbum _remoteAlbum; + private List _heldReleases; [SetUp] public void Setup() { - _series = Builder.CreateNew() + _artist = Builder.CreateNew() .Build(); - _episode = Builder.CreateNew() + _album = Builder.CreateNew() .Build(); - _profile = new Profile + _profile = new QualityProfile { Name = "Test", - Cutoff = Quality.HDTV720p, - Items = new List + Cutoff = Quality.MP3_256.Id, + Items = new List { - new ProfileQualityItem { Allowed = true, Quality = Quality.HDTV720p }, - new ProfileQualityItem { Allowed = true, Quality = Quality.WEBDL720p }, - new ProfileQualityItem { Allowed = true, Quality = Quality.Bluray720p } + new QualityProfileQualityItem { Allowed = true, Quality = Quality.MP3_256 }, + new QualityProfileQualityItem { Allowed = true, Quality = Quality.MP3_320 }, + new QualityProfileQualityItem { Allowed = true, Quality = Quality.FLAC } }, }; - _series.Profile = new LazyLoaded(_profile); + _artist.QualityProfile = new LazyLoaded(_profile); _release = Builder.CreateNew().Build(); - _parsedEpisodeInfo = Builder.CreateNew().Build(); - _parsedEpisodeInfo.Quality = new QualityModel(Quality.HDTV720p); + _parsedAlbumInfo = Builder.CreateNew().Build(); + _parsedAlbumInfo.Quality = new QualityModel(Quality.MP3_256); - _remoteEpisode = new RemoteEpisode(); - _remoteEpisode.Episodes = new List{ _episode }; - _remoteEpisode.Series = _series; - _remoteEpisode.ParsedEpisodeInfo = _parsedEpisodeInfo; - _remoteEpisode.Release = _release; + _remoteAlbum = new RemoteAlbum(); + _remoteAlbum.Albums = new List{ _album }; + _remoteAlbum.Artist = _artist; + _remoteAlbum.ParsedAlbumInfo = _parsedAlbumInfo; + _remoteAlbum.Release = _release; - _temporarilyRejected = new DownloadDecision(_remoteEpisode, new Rejection("Temp Rejected", RejectionType.Temporary)); + _temporarilyRejected = new DownloadDecision(_remoteAlbum, 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.AllByArtistId(It.IsAny())) + .Returns(i => _heldReleases.Where(v => v.ArtistId == i).ToList()); - Mocker.GetMock() - .Setup(s => s.GetSeries(It.IsAny())) - .Returns(_series); + Mocker.GetMock() + .Setup(s => s.GetArtist(It.IsAny())) + .Returns(_artist); + + Mocker.GetMock() + .Setup(s => s.GetArtists(It.IsAny>())) + .Returns(new List { _artist }); Mocker.GetMock() - .Setup(s => s.GetEpisodes(It.IsAny(), _series, true, null)) - .Returns(new List {_episode}); + .Setup(s => s.GetAlbums(It.IsAny(), _artist, null)) + .Returns(new List {_album}); Mocker.GetMock() .Setup(s => s.PrioritizeDecisions(It.IsAny>())) @@ -82,27 +94,25 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests private void GivenHeldRelease(QualityModel quality) { - var parsedEpisodeInfo = _parsedEpisodeInfo.JsonClone(); + var parsedEpisodeInfo = _parsedAlbumInfo.JsonClone(); parsedEpisodeInfo.Quality = quality; var heldReleases = Builder.CreateListOfSize(1) .All() - .With(h => h.SeriesId = _series.Id) + .With(h => h.ArtistId = _artist.Id) .With(h => h.Release = _release.JsonClone()) - .With(h => h.ParsedEpisodeInfo = parsedEpisodeInfo) + .With(h => h.ParsedAlbumInfo = parsedEpisodeInfo) .Build(); - Mocker.GetMock() - .Setup(s => s.All()) - .Returns(heldReleases); + _heldReleases.AddRange(heldReleases); } [Test] public void should_delete_if_the_grabbed_quality_is_the_same() { - GivenHeldRelease(_parsedEpisodeInfo.Quality); + GivenHeldRelease(_parsedAlbumInfo.Quality); - Subject.Handle(new EpisodeGrabbedEvent(_remoteEpisode)); + Subject.Handle(new AlbumGrabbedEvent(_remoteAlbum)); VerifyDelete(); } @@ -110,9 +120,9 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests [Test] public void should_delete_if_the_grabbed_quality_is_the_higher() { - GivenHeldRelease(new QualityModel(Quality.SDTV)); + GivenHeldRelease(new QualityModel(Quality.MP3_192)); - Subject.Handle(new EpisodeGrabbedEvent(_remoteEpisode)); + Subject.Handle(new AlbumGrabbedEvent(_remoteAlbum)); VerifyDelete(); } @@ -120,9 +130,9 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests [Test] public void should_not_delete_if_the_grabbed_quality_is_the_lower() { - GivenHeldRelease(new QualityModel(Quality.Bluray720p)); + GivenHeldRelease(new QualityModel(Quality.FLAC)); - Subject.Handle(new EpisodeGrabbedEvent(_remoteEpisode)); + Subject.Handle(new AlbumGrabbedEvent(_remoteAlbum)); VerifyNoDelete(); } diff --git a/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemovePendingFixture.cs b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemovePendingFixture.cs index 44c2a1029..642547e36 100644 --- a/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemovePendingFixture.cs +++ b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemovePendingFixture.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using FizzWare.NBuilder; using Moq; @@ -8,7 +8,7 @@ using NzbDrone.Core.Download.Pending; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests { @@ -16,48 +16,52 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests public class RemovePendingFixture : CoreTest { private List _pending; - private Episode _episode; + private Album _album; [SetUp] public void Setup() { _pending = new List(); - _episode = Builder.CreateNew() + _album = Builder.CreateNew() .Build(); Mocker.GetMock() - .Setup(s => s.AllBySeriesId(It.IsAny())) + .Setup(s => s.AllByArtistId(It.IsAny())) .Returns(_pending); Mocker.GetMock() .Setup(s => s.All()) .Returns( _pending); - Mocker.GetMock() - .Setup(s => s.GetSeries(It.IsAny())) - .Returns(new Series()); + Mocker.GetMock() + .Setup(s => s.GetArtist(It.IsAny())) + .Returns(new Artist()); + + Mocker.GetMock() + .Setup(s => s.GetArtists(It.IsAny>())) + .Returns(new List { new Artist() }); Mocker.GetMock() - .Setup(s => s.GetEpisodes(It.IsAny(), It.IsAny(), It.IsAny(), null)) - .Returns(new List{ _episode }); + .Setup(s => s.GetAlbums(It.IsAny(), It.IsAny(), null)) + .Returns(new List{ _album }); } - private void AddPending(int id, int seasonNumber, int[] episodes) + private void AddPending(int id, string album) { _pending.Add(new PendingRelease { Id = id, - ParsedEpisodeInfo = new ParsedEpisodeInfo { SeasonNumber = seasonNumber, EpisodeNumbers = episodes } + ParsedAlbumInfo = new ParsedAlbumInfo { AlbumTitle = album} }); } [Test] public void should_remove_same_release() { - AddPending(id: 1, seasonNumber: 2, episodes: new[] { 3 }); + AddPending(id: 1, album: "Album" ); - var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id)); + var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-album{1}", 1, _album.Id)); Subject.RemovePendingQueueItems(queueId); @@ -67,12 +71,12 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests [Test] public void should_remove_multiple_releases_release() { - AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 }); - AddPending(id: 2, seasonNumber: 2, episodes: new[] { 2 }); - AddPending(id: 3, seasonNumber: 2, episodes: new[] { 3 }); - AddPending(id: 4, seasonNumber: 2, episodes: new[] { 3 }); + AddPending(id: 1, album: "Album 1"); + AddPending(id: 2, album: "Album 2"); + AddPending(id: 3, album: "Album 3"); + AddPending(id: 4, album: "Album 3"); - var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 3, _episode.Id)); + var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-album{1}", 3, _album.Id)); Subject.RemovePendingQueueItems(queueId); @@ -80,60 +84,19 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests } [Test] - public void should_not_remove_diffrent_season() - { - AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 }); - AddPending(id: 2, seasonNumber: 2, episodes: new[] { 1 }); - AddPending(id: 3, seasonNumber: 3, episodes: new[] { 1 }); - AddPending(id: 4, seasonNumber: 3, episodes: new[] { 1 }); - - var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id)); - - Subject.RemovePendingQueueItems(queueId); - - AssertRemoved(1, 2); - } - - [Test] - public void should_not_remove_diffrent_episodes() + public void should_not_remove_diffrent_albums() { - AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 }); - AddPending(id: 2, seasonNumber: 2, episodes: new[] { 1 }); - AddPending(id: 3, seasonNumber: 2, episodes: new[] { 2 }); - AddPending(id: 4, seasonNumber: 2, episodes: new[] { 3 }); + AddPending(id: 1, album: "Album 1"); + AddPending(id: 2, album: "Album 1"); + AddPending(id: 3, album: "Album 2"); + AddPending(id: 4, album: "Album 3"); - var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id)); + var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-album{1}", 1, _album.Id)); Subject.RemovePendingQueueItems(queueId); AssertRemoved(1, 2); } - - [Test] - public void should_not_remove_multiepisodes() - { - AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 }); - AddPending(id: 2, seasonNumber: 2, episodes: new[] { 1, 2 }); - - var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id)); - - Subject.RemovePendingQueueItems(queueId); - - AssertRemoved(1); - } - - [Test] - public void should_not_remove_singleepisodes() - { - AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 }); - AddPending(id: 2, seasonNumber: 2, episodes: new[] { 1, 2 }); - - var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 2, _episode.Id)); - - Subject.RemovePendingQueueItems(queueId); - - AssertRemoved(2); - } private void AssertRemoved(params int[] 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..6889c6800 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; @@ -11,10 +11,10 @@ using NzbDrone.Core.Download.Pending; using NzbDrone.Core.Indexers; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Profiles; +using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Qualities; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests { @@ -22,60 +22,64 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests public class RemoveRejectedFixture : CoreTest { private DownloadDecision _temporarilyRejected; - private Series _series; - private Episode _episode; - private Profile _profile; + private Artist _artist; + private Album _album; + private QualityProfile _profile; private ReleaseInfo _release; - private ParsedEpisodeInfo _parsedEpisodeInfo; - private RemoteEpisode _remoteEpisode; + private ParsedAlbumInfo _parsedAlbumInfo; + private RemoteAlbum _remoteAlbum; [SetUp] public void Setup() { - _series = Builder.CreateNew() + _artist = Builder.CreateNew() .Build(); - _episode = Builder.CreateNew() + _album = Builder.CreateNew() .Build(); - _profile = new Profile + _profile = new QualityProfile { Name = "Test", - Cutoff = Quality.HDTV720p, - Items = new List + Cutoff = Quality.MP3_192.Id, + Items = new List { - new ProfileQualityItem { Allowed = true, Quality = Quality.HDTV720p }, - new ProfileQualityItem { Allowed = true, Quality = Quality.WEBDL720p }, - new ProfileQualityItem { Allowed = true, Quality = Quality.Bluray720p } + new QualityProfileQualityItem { Allowed = true, Quality = Quality.MP3_192 }, + new QualityProfileQualityItem { Allowed = true, Quality = Quality.MP3_256 }, + new QualityProfileQualityItem { Allowed = true, Quality = Quality.MP3_320 } }, }; - _series.Profile = new LazyLoaded(_profile); + _artist.QualityProfile = new LazyLoaded(_profile); _release = Builder.CreateNew().Build(); - _parsedEpisodeInfo = Builder.CreateNew().Build(); - _parsedEpisodeInfo.Quality = new QualityModel(Quality.HDTV720p); + _parsedAlbumInfo = Builder.CreateNew().Build(); + _parsedAlbumInfo.Quality = new QualityModel(Quality.MP3_192); - _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)); + _remoteAlbum = new RemoteAlbum(); + _remoteAlbum.Albums = new List{ _album }; + _remoteAlbum.Artist = _artist; + _remoteAlbum.ParsedAlbumInfo = _parsedAlbumInfo; + _remoteAlbum.Release = _release; + + _temporarilyRejected = new DownloadDecision(_remoteAlbum, new Rejection("Temp Rejected", RejectionType.Temporary)); Mocker.GetMock() .Setup(s => s.All()) .Returns(new List()); - Mocker.GetMock() - .Setup(s => s.GetSeries(It.IsAny())) - .Returns(_series); + Mocker.GetMock() + .Setup(s => s.GetArtist(It.IsAny())) + .Returns(_artist); + + Mocker.GetMock() + .Setup(s => s.GetArtists(It.IsAny>())) + .Returns(new List { _artist }); Mocker.GetMock() - .Setup(s => s.GetEpisodes(It.IsAny(), _series, true, null)) - .Returns(new List {_episode}); + .Setup(s => s.GetAlbums(It.IsAny(), _artist, null)) + .Returns(new List {_album}); Mocker.GetMock() .Setup(s => s.PrioritizeDecisions(It.IsAny>())) @@ -91,7 +95,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests var heldReleases = Builder.CreateListOfSize(1) .All() - .With(h => h.SeriesId = _series.Id) + .With(h => h.ArtistId = _artist.Id) .With(h => h.Title = title) .With(h => h.Release = release) .Build(); diff --git a/src/NzbDrone.Core.Test/Download/RedownloadFailedDownloadServiceFixture.cs b/src/NzbDrone.Core.Test/Download/RedownloadFailedDownloadServiceFixture.cs new file mode 100644 index 000000000..a907d7ed5 --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/RedownloadFailedDownloadServiceFixture.cs @@ -0,0 +1,128 @@ +using NUnit.Framework; +using NzbDrone.Core.Download; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Messaging.Commands; +using Moq; +using System.Collections.Generic; +using NzbDrone.Core.Music; +using FizzWare.NBuilder; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.IndexerSearch; + +namespace NzbDrone.Core.Test.Download +{ + [TestFixture] + public class RedownloadFailedDownloadServiceFixture : CoreTest + { + [SetUp] + public void Setup() + { + Mocker.GetMock() + .Setup(x => x.AutoRedownloadFailed) + .Returns(true); + + Mocker.GetMock() + .Setup(x => x.GetAlbumsByArtist(It.IsAny())) + .Returns(Builder.CreateListOfSize(3).Build() as List); + } + + [Test] + public void should_skip_redownload_if_event_has_skipredownload_set() + { + var failedEvent = new DownloadFailedEvent { + ArtistId = 1, + AlbumIds = new List { 1 }, + SkipReDownload = true + }; + + Subject.HandleAsync(failedEvent); + + Mocker.GetMock() + .Verify(x => x.Push(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never()); + } + + [Test] + public void should_skip_redownload_if_redownload_failed_disabled() + { + var failedEvent = new DownloadFailedEvent { + ArtistId = 1, + AlbumIds = new List { 1 } + }; + + Mocker.GetMock() + .Setup(x => x.AutoRedownloadFailed) + .Returns(false); + + Subject.HandleAsync(failedEvent); + + Mocker.GetMock() + .Verify(x => x.Push(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never()); + } + + [Test] + public void should_redownload_album_on_failure() + { + var failedEvent = new DownloadFailedEvent { + ArtistId = 1, + AlbumIds = new List { 2 } + }; + + Subject.HandleAsync(failedEvent); + + Mocker.GetMock() + .Verify(x => x.Push(It.Is(c => c.AlbumIds.Count == 1 && + c.AlbumIds[0] == 2), + It.IsAny(), It.IsAny()), + Times.Once()); + + Mocker.GetMock() + .Verify(x => x.Push(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never()); + } + + [Test] + public void should_redownload_multiple_albums_on_failure() + { + var failedEvent = new DownloadFailedEvent { + ArtistId = 1, + AlbumIds = new List { 2, 3 } + }; + + Subject.HandleAsync(failedEvent); + + Mocker.GetMock() + .Verify(x => x.Push(It.Is(c => c.AlbumIds.Count == 2 && + c.AlbumIds[0] == 2 && + c.AlbumIds[1] == 3), + It.IsAny(), It.IsAny()), + Times.Once()); + + Mocker.GetMock() + .Verify(x => x.Push(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never()); + } + + [Test] + public void should_redownload_artist_on_failure() + { + // note that artist is set to have 3 albums in setup + var failedEvent = new DownloadFailedEvent { + ArtistId = 2, + AlbumIds = new List { 1, 2, 3 } + }; + + Subject.HandleAsync(failedEvent); + + Mocker.GetMock() + .Verify(x => x.Push(It.Is(c => c.ArtistId == failedEvent.ArtistId), + It.IsAny(), It.IsAny()), + Times.Once()); + + Mocker.GetMock() + .Verify(x => x.Push(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never()); + } + } +} diff --git a/src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadServiceFixture.cs b/src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadServiceFixture.cs index 912b60335..1335e6186 100644 --- a/src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadServiceFixture.cs @@ -8,9 +8,10 @@ using NzbDrone.Core.History; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; using NzbDrone.Core.Indexers; using System.Linq; +using NzbDrone.Core.Music.Events; namespace NzbDrone.Core.Test.Download.TrackedDownloads { @@ -24,9 +25,9 @@ namespace NzbDrone.Core.Test.Download.TrackedDownloads .Returns(new List(){ new History.History(){ DownloadId = "35238", - SourceTitle = "TV Series S01", - SeriesId = 5, - EpisodeId = 4 + SourceTitle = "Audio Artist - Audio Album [2018 - FLAC]", + ArtistId = 5, + AlbumId = 4, } }); } @@ -36,20 +37,20 @@ namespace NzbDrone.Core.Test.Download.TrackedDownloads { GivenDownloadHistory(); - var remoteEpisode = new RemoteEpisode + var remoteAlbum = new RemoteAlbum { - Series = new Series() { Id = 5 }, - Episodes = new List { new Episode { Id = 4 } }, - ParsedEpisodeInfo = new ParsedEpisodeInfo() + Artist = new Artist() { Id = 5 }, + Albums = new List { new Album { Id = 4 } }, + ParsedAlbumInfo = new ParsedAlbumInfo() { - SeriesTitle = "TV Series", - SeasonNumber = 1 + AlbumTitle = "Audio Album", + ArtistName = "Audio Artist" } }; Mocker.GetMock() - .Setup(s => s.Map(It.Is(i => i.SeasonNumber == 1 && i.SeriesTitle == "TV Series"), It.IsAny(), It.IsAny>())) - .Returns(remoteEpisode); + .Setup(s => s.Map(It.Is(i => i.AlbumTitle == "Audio Album" && i.ArtistName == "Audio Artist"), It.IsAny(), It.IsAny>())) + .Returns(remoteAlbum); var client = new DownloadClientDefinition() { @@ -66,46 +67,31 @@ namespace NzbDrone.Core.Test.Download.TrackedDownloads var trackedDownload = Subject.TrackDownload(client, item); trackedDownload.Should().NotBeNull(); - trackedDownload.RemoteEpisode.Should().NotBeNull(); - trackedDownload.RemoteEpisode.Series.Should().NotBeNull(); - trackedDownload.RemoteEpisode.Series.Id.Should().Be(5); - trackedDownload.RemoteEpisode.Episodes.First().Id.Should().Be(4); - trackedDownload.RemoteEpisode.ParsedEpisodeInfo.SeasonNumber.Should().Be(1); + trackedDownload.RemoteAlbum.Should().NotBeNull(); + trackedDownload.RemoteAlbum.Artist.Should().NotBeNull(); + trackedDownload.RemoteAlbum.Artist.Id.Should().Be(5); + trackedDownload.RemoteAlbum.Albums.First().Id.Should().Be(4); } [Test] - public void should_parse_as_special_when_source_title_parsing_fails() + public void should_unmap_tracked_download_if_album_deleted() { - var remoteEpisode = new RemoteEpisode + GivenDownloadHistory(); + + var remoteAlbum = new RemoteAlbum { - Series = new Series() { Id = 5 }, - Episodes = new List { new Episode { Id = 4 } }, - ParsedEpisodeInfo = new ParsedEpisodeInfo() + Artist = new Artist() { Id = 5 }, + Albums = new List { new Album { Id = 4 } }, + ParsedAlbumInfo = new ParsedAlbumInfo() { - SeriesTitle = "TV Series", - SeasonNumber = 0, - EpisodeNumbers = new []{ 1 } + AlbumTitle = "Audio Album", + ArtistName = "Audio Artist" } }; - Mocker.GetMock() - .Setup(s => s.FindByDownloadId(It.Is(sr => sr == "35238"))) - .Returns(new List(){ - new History.History(){ - DownloadId = "35238", - SourceTitle = "TV Series Special", - SeriesId = 5, - EpisodeId = 4 - } - }); - - Mocker.GetMock() - .Setup(s => s.Map(It.Is(i => i.SeasonNumber == 0 && i.SeriesTitle == "TV Series"), It.IsAny(), It.IsAny>())) - .Returns(remoteEpisode); - Mocker.GetMock() - .Setup(s => s.ParseSpecialEpisodeTitle(It.IsAny(), It.IsAny(), It.IsAny(), null)) - .Returns(remoteEpisode.ParsedEpisodeInfo); + .Setup(s => s.Map(It.Is(i => i.AlbumTitle == "Audio Album" && i.ArtistName == "Audio Artist"), It.IsAny(), It.IsAny>())) + .Returns(remoteAlbum); var client = new DownloadClientDefinition() { @@ -115,18 +101,26 @@ namespace NzbDrone.Core.Test.Download.TrackedDownloads var item = new DownloadClientItem() { - Title = "The torrent release folder", + Title = "Audio Artist - Audio Album [2018 - FLAC]", DownloadId = "35238", }; + // get a tracked download in place var trackedDownload = Subject.TrackDownload(client, item); + Subject.GetTrackedDownloads().Should().HaveCount(1); - trackedDownload.Should().NotBeNull(); - trackedDownload.RemoteEpisode.Should().NotBeNull(); - trackedDownload.RemoteEpisode.Series.Should().NotBeNull(); - trackedDownload.RemoteEpisode.Series.Id.Should().Be(5); - trackedDownload.RemoteEpisode.Episodes.First().Id.Should().Be(4); - trackedDownload.RemoteEpisode.ParsedEpisodeInfo.SeasonNumber.Should().Be(0); + // simulate deletion - album no longer maps + Mocker.GetMock() + .Setup(s => s.Map(It.Is(i => i.AlbumTitle == "Audio Album" && i.ArtistName == "Audio Artist"), It.IsAny(), It.IsAny>())) + .Returns(default(RemoteAlbum)); + + // handle deletion event + Subject.Handle(new AlbumDeletedEvent(remoteAlbum.Albums.First(), false)); + + // verify download has null remote album + var trackedDownloads = Subject.GetTrackedDownloads(); + trackedDownloads.Should().HaveCount(1); + trackedDownloads.First().RemoteAlbum.Should().BeNull(); } } } diff --git a/src/NzbDrone.Core.Test/Extras/Metadata/Consumers/Roksbox/FindMetadataFileFixture.cs b/src/NzbDrone.Core.Test/Extras/Metadata/Consumers/Roksbox/FindMetadataFileFixture.cs new file mode 100644 index 000000000..7df4a0804 --- /dev/null +++ b/src/NzbDrone.Core.Test/Extras/Metadata/Consumers/Roksbox/FindMetadataFileFixture.cs @@ -0,0 +1,78 @@ +using System.IO; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Extras.Metadata; +using NzbDrone.Core.Extras.Metadata.Consumers.Roksbox; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.Extras.Metadata.Consumers.Roksbox +{ + [TestFixture] + public class FindMetadataFileFixture : CoreTest + { + private Artist _artist; + + [SetUp] + public void Setup() + { + _artist = Builder.CreateNew() + .With(s => s.Path = @"C:\Test\Music\The.Artist".AsOsAgnostic()) + .Build(); + } + + [Test] + public void should_return_null_if_filename_is_not_handled() + { + var path = Path.Combine(_artist.Path, "file.jpg"); + + Subject.FindMetadataFile(_artist, path).Should().BeNull(); + } + + [TestCase("Specials")] + [TestCase("specials")] + [TestCase("Season 1")] + public void should_return_album_image(string folder) + { + var path = Path.Combine(_artist.Path, folder, folder + ".jpg"); + + Subject.FindMetadataFile(_artist, path).Type.Should().Be(MetadataType.AlbumImage); + } + + [TestCase(".xml", MetadataType.TrackMetadata)] + public void should_return_metadata_for_track_if_valid_file_for_track(string extension, MetadataType type) + { + var path = Path.Combine(_artist.Path, "the.artist.s01e01.track" + extension); + + Subject.FindMetadataFile(_artist, path).Type.Should().Be(type); + } + + [Ignore("Need Updated")] + [TestCase(".xml")] + [TestCase(".jpg")] + public void should_return_null_if_not_valid_file_for_track(string extension) + { + var path = Path.Combine(_artist.Path, "the.artist.track" + extension); + + Subject.FindMetadataFile(_artist, path).Should().BeNull(); + } + + [Test] + public void should_not_return_metadata_if_image_file_is_a_thumb() + { + var path = Path.Combine(_artist.Path, "the.artist.s01e01.track-thumb.jpg"); + + Subject.FindMetadataFile(_artist, path).Should().BeNull(); + } + + [Test] + public void should_return_artist_image_for_folder_jpg_in_artist_folder() + { + var path = Path.Combine(_artist.Path, new DirectoryInfo(_artist.Path).Name + ".jpg"); + + Subject.FindMetadataFile(_artist, path).Type.Should().Be(MetadataType.ArtistImage); + } + } +} diff --git a/src/NzbDrone.Core.Test/Extras/Metadata/Consumers/Wdtv/FindMetadataFileFixture.cs b/src/NzbDrone.Core.Test/Extras/Metadata/Consumers/Wdtv/FindMetadataFileFixture.cs new file mode 100644 index 000000000..64c56396c --- /dev/null +++ b/src/NzbDrone.Core.Test/Extras/Metadata/Consumers/Wdtv/FindMetadataFileFixture.cs @@ -0,0 +1,52 @@ +using System.IO; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Extras.Metadata; +using NzbDrone.Core.Extras.Metadata.Consumers.Wdtv; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.Extras.Metadata.Consumers.Wdtv +{ + [TestFixture] + public class FindMetadataFileFixture : CoreTest + { + private Artist _artist; + + [SetUp] + public void Setup() + { + _artist = Builder.CreateNew() + .With(s => s.Path = @"C:\Test\Music\The.Artist".AsOsAgnostic()) + .Build(); + } + + [Test] + public void should_return_null_if_filename_is_not_handled() + { + var path = Path.Combine(_artist.Path, "file.jpg"); + + Subject.FindMetadataFile(_artist, path).Should().BeNull(); + } + + [TestCase(".xml", MetadataType.TrackMetadata)] + public void should_return_metadata_for_track_if_valid_file_for_track(string extension, MetadataType type) + { + var path = Path.Combine(_artist.Path, "the.artist.s01e01.track" + extension); + + Subject.FindMetadataFile(_artist, path).Type.Should().Be(type); + } + + [Ignore("Need Updated")] + [TestCase(".xml")] + [TestCase(".metathumb")] + public void should_return_null_if_not_valid_file_for_track(string extension) + { + var path = Path.Combine(_artist.Path, "the.artist.track" + extension); + + Subject.FindMetadataFile(_artist, path).Should().BeNull(); + } + } +} 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..dd335843e --- /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.Music; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.Extras.Metadata.Consumers.Xbmc +{ + [TestFixture] + public class FindMetadataFileFixture : CoreTest + { + private Artist _artist; + + [SetUp] + public void Setup() + { + _artist = Builder.CreateNew() + .With(s => s.Path = @"C:\Test\Music\The.Artist".AsOsAgnostic()) + .Build(); + } + + [Test] + public void should_return_null_if_filename_is_not_handled() + { + var path = Path.Combine(_artist.Path, "file.jpg"); + + Subject.FindMetadataFile(_artist, path).Should().BeNull(); + } + + [Test] + public void should_return_metadata_for_xbmc_nfo() + { + var path = Path.Combine(_artist.Path, "album.nfo"); + + Mocker.GetMock() + .Setup(v => v.IsXbmcNfoFile(path)) + .Returns(true); + + Subject.FindMetadataFile(_artist, path).Type.Should().Be(MetadataType.AlbumMetadata); + + Mocker.GetMock() + .Verify(v => v.IsXbmcNfoFile(It.IsAny()), Times.Once()); + } + + [Test] + public void should_return_null_for_scene_nfo() + { + var path = Path.Combine(_artist.Path, "album.nfo"); + + Mocker.GetMock() + .Setup(v => v.IsXbmcNfoFile(path)) + .Returns(false); + + Subject.FindMetadataFile(_artist, path).Should().BeNull(); + + Mocker.GetMock() + .Verify(v => v.IsXbmcNfoFile(It.IsAny()), Times.Once()); + } + } +} diff --git a/src/NzbDrone.Core.Test/Files/Identification/CorruptFile.json b/src/NzbDrone.Core.Test/Files/Identification/CorruptFile.json new file mode 100644 index 000000000..540e661c1 --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/Identification/CorruptFile.json @@ -0,0 +1,1284 @@ +{ + "expectedMusicBrainzReleaseIds": [ + "e34999c7-36bd-4d77-a10b-627b1b4f3904" + ], + "libraryArtists": [ + { + "artist": "401c3991-b76b-499d-8082-9f2df958ef78", + "metadataProfile": { + "name": "Standard", + "primaryAlbumTypes": [ + { + "primaryAlbumType": { + "id": 2, + "name": "Single" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 4, + "name": "Other" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 1, + "name": "EP" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 3, + "name": "Broadcast" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 0, + "name": "Album" + }, + "allowed": true + } + ], + "secondaryAlbumTypes": [ + { + "secondaryAlbumType": { + "id": 0, + "name": "Studio" + }, + "allowed": true + }, + { + "secondaryAlbumType": { + "id": 3, + "name": "Spokenword" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 2, + "name": "Soundtrack" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 7, + "name": "Remix" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 9, + "name": "Mixtape/Street" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 6, + "name": "Live" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 4, + "name": "Interview" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 8, + "name": "DJ-mix" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 10, + "name": "Demo" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 1, + "name": "Compilation" + }, + "allowed": false + } + ], + "releaseStatuses": [ + { + "releaseStatus": { + "id": 3, + "name": "Pseudo" + }, + "allowed": false + }, + { + "releaseStatus": { + "id": 1, + "name": "Promotion" + }, + "allowed": false + }, + { + "releaseStatus": { + "id": 0, + "name": "Official" + }, + "allowed": true + }, + { + "releaseStatus": { + "id": 2, + "name": "Bootleg" + }, + "allowed": false + } + ], + "id": 1 + } + } + ], + "newDownload": true, + "singleRelease": false, + "tracks": [ + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/101-phil_collins-going_back.flac", + "fileTrackInfo": { + "artistTitle": "Phil Collins The Essential Going Back (081227946470) REMASTERED DELUXE EDITION 2CD FLAC 2016 WRE 101", + "artistTitleInfo": { + "title": "Phil Collins The Essential Going Back (081227946470) REMASTERED DELUXE EDITION 2CD FLAC 2016 WRE 101", + "year": 0 + }, + "discNumber": 0, + "discCount": 0, + "year": 0, + "duration": "00:00:00", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "trackNumbers": [] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/102-phil_collins-girl_(why_you_wanna_make_me_blue).flac", + "fileTrackInfo": { + "title": "Girl (Why You Wanna Make Me Blue)", + "cleanTitle": "Girl (Why You Wanna Make Me Blue)", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:02:32.6530000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 1016, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 2 + ] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/103-phil_collins-(love_is_like_a)_heatwave.flac", + "fileTrackInfo": { + "title": "(Love Is Like A) Heatwave", + "cleanTitle": "(Love Is Like A) Heatwave", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:02:53.4000000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 969, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 3 + ] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/104-phil_collins-some_of_your_lovin.flac", + "fileTrackInfo": { + "title": "Some Of Your Lovin'", + "cleanTitle": "Some Of Your Lovin'", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:03:18.7730000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 942, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 4 + ] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/105-phil_collins-going_to_a_go-go.flac", + "fileTrackInfo": { + "title": "Going To A Go-Go", + "cleanTitle": "Going To A Go-Go", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:02:48.8530000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 931, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 5 + ] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/106-phil_collins-papa_was_a_rolling_stone.flac", + "fileTrackInfo": { + "title": "Papa Was A Rolling Stone", + "cleanTitle": "Papa Was A Rolling Stone", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:06:44.6000000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 803, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 6 + ] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/107-phil_collins-loving_you_is_sweeter_than_ever.flac", + "fileTrackInfo": { + "title": "Loving You Is Sweeter Than Ever", + "cleanTitle": "Loving You Is Sweeter Than Ever", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:02:47.8000000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 978, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 7 + ] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/108-phil_collins-something_about_you.flac", + "fileTrackInfo": { + "title": "Something About You", + "cleanTitle": "Something About You", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:02:46.3870000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 961, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 8 + ] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/109-phil_collins-talkin_about_my_baby.flac", + "fileTrackInfo": { + "title": "Talkin' About My Baby", + "cleanTitle": "Talkin' About My Baby", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:02:48.0400000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 925, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 9 + ] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/110-phil_collins-do_i_love_you.flac", + "fileTrackInfo": { + "title": "Do I Love You", + "cleanTitle": "Do I Love You", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:02:50.4530000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 958, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 10 + ] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/111-phil_collins-never_dreamed_youd_leave_in_summer.flac", + "fileTrackInfo": { + "title": "Never Dreamed You'd Leave In Summer", + "cleanTitle": "Never Dreamed You'd Leave In Summer", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:03:00.0530000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 827, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 11 + ] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/112-phil_collins-take_me_in_your_arms_(rock_me_for_a_little_while).flac", + "fileTrackInfo": { + "title": "Take Me In Your Arms (Rock Me For A Little While)", + "cleanTitle": "Take Me In Your Arms (Rock Me For A Little While)", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:02:57.7070000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 937, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 12 + ] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/113-phil_collins-too_many_fish_in_the_sea.flac", + "fileTrackInfo": { + "title": "Too Many Fish In The Sea", + "cleanTitle": "Too Many Fish In The Sea", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:02:30.6670000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 960, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 13 + ] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/114-phil_collins-uptight_(everythings_alright).flac", + "fileTrackInfo": { + "title": "Uptight (Everything's Alright)", + "cleanTitle": "Uptight (Everything's Alright)", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:03:03.9330000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 1009, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 14 + ] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/201-phil_collins-signed_sealed_delivered_(im_yours)_intro_(live).flac", + "fileTrackInfo": { + "title": "Signed, Sealed, Delivered (I'm Yours) Intro (Live)", + "cleanTitle": "Signed, Sealed, Delivered (I'm Yours) Intro (Live)", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:01:16.1070000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 976, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 1 + ] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/202-phil_collins-aint_too_proud_to_beg_(live).flac", + "fileTrackInfo": { + "title": "Ain't Too Proud To Beg (Live)", + "cleanTitle": "Ain't Too Proud To Beg (Live)", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:02:40.9600000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 1041, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 2 + ] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/203-phil_collins-girl_(why_you_wanna_make_me_blue)_(live).flac", + "fileTrackInfo": { + "title": "Girl (Why You Wanna Make Me Blue) (Live)", + "cleanTitle": "Girl (Why You Wanna Make Me Blue) (Live)", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:02:41.1730000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 1023, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 3 + ] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/204-phil_collins-dancing_in_the_street_(live).flac", + "fileTrackInfo": { + "title": "Dancing In The Street (Live)", + "cleanTitle": "Dancing In The Street (Live)", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:02:43.5870000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 1048, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 4 + ] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/205-phil_collins-(love_is_like_a)_heatwave_(live).flac", + "fileTrackInfo": { + "title": "(Love Is Like A) Heatwave (Live)", + "cleanTitle": "(Love Is Like A) Heatwave (Live)", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:03:20", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 1050, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 5 + ] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/206-phil_collins-papa_was_a_rolling_stone_(live).flac", + "fileTrackInfo": { + "title": "Papa Was A Rolling Stone (Live)", + "cleanTitle": "Papa Was A Rolling Stone (Live)", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:07:27.0530000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 938, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 6 + ] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/207-phil_collins-never_dreamed_youd_leave_in_summer_(live).flac", + "fileTrackInfo": { + "title": "Never Dreamed You'd Leave In Summer (Live)", + "cleanTitle": "Never Dreamed You'd Leave In Summer (Live)", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:02:57.7070000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 817, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 7 + ] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/208-phil_collins-talkin_about_my_baby_(live).flac", + "fileTrackInfo": { + "title": "Talkin' About My Baby (Live)", + "cleanTitle": "Talkin' About My Baby (Live)", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:03:11.4670000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 998, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 8 + ] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/209-phil_collins-do_i_love_you_(live).flac", + "fileTrackInfo": { + "title": "Do I Love You (Live)", + "cleanTitle": "Do I Love You (Live)", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:03:12.5330000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 1033, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 9 + ] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/210-phil_collins-aint_that_peculiar_(live).flac", + "fileTrackInfo": { + "title": "Ain't That Peculiar (Live)", + "cleanTitle": "Ain't That Peculiar (Live)", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:03:30.4530000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 1065, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 10 + ] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/211-phil_collins-too_many_fish_in_the_sea_(live).flac", + "fileTrackInfo": { + "title": "Too Many Fish In The Sea (Live)", + "cleanTitle": "Too Many Fish In The Sea (Live)", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:02:50.4400000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 1061, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 11 + ] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/212-phil_collins-you_really_got_a_hold_on_me_(live).flac", + "fileTrackInfo": { + "title": "You Really Got A Hold On Me (Live)", + "cleanTitle": "You Really Got A Hold On Me (Live)", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:03:45.2800000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 988, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 12 + ] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/213-phil_collins-something_about_you_(live).flac", + "fileTrackInfo": { + "title": "Something About You (Live)", + "cleanTitle": "Something About You (Live)", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:03:20.6400000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 1057, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 13 + ] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/214-phil_collins-uptight_(everythings_alright)_(live).flac", + "fileTrackInfo": { + "title": "Uptight (Everything's Alright) (Live)", + "cleanTitle": "Uptight (Everything's Alright) (Live)", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:04:17.6000000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 1066, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 14 + ] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/215-phil_collins-my_girl_(live).flac", + "fileTrackInfo": { + "title": "My Girl (Live)", + "cleanTitle": "My Girl (Live)", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:03:44.6530000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 1020, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 15 + ] + } + }, + { + "path": "/media/nas/video/unpacked/music/Phil_Collins_The_Essential_Going_Back_(081227946470)_REMASTERED_DELUXE_EDITION_2CD_FLAC_2016_WRE/216-phil_collins-going_back_(live).flac", + "fileTrackInfo": { + "title": "Going Back (Live)", + "cleanTitle": "Going Back (Live)", + "artistTitle": "Phil Collins", + "albumTitle": "The Essential Going Back", + "artistTitleInfo": { + "title": "Phil Collins", + "year": 2016 + }, + "discNumber": 0, + "discCount": 0, + "year": 2016, + "label": "Atlantic", + "duration": "00:05:08.0530000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 954, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 16 + ] + } + } + ] +} diff --git a/src/NzbDrone.Core.Test/Files/Identification/FilesWithMBIds.json b/src/NzbDrone.Core.Test/Files/Identification/FilesWithMBIds.json new file mode 100644 index 000000000..c2d76c358 --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/Identification/FilesWithMBIds.json @@ -0,0 +1,1430 @@ +{ + "expectedMusicBrainzReleaseIds": [ + "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "97189482-89ee-4d31-90c7-ba07b412d7f9", + "9105a5b3-eb68-3a03-9aa8-f3495e602a4f" + ], + "libraryArtists": [ + { + "artist": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "metadataProfile": { + "name": "Standard", + "primaryAlbumTypes": [ + { + "primaryAlbumType": { + "id": 2, + "name": "Single" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 4, + "name": "Other" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 1, + "name": "EP" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 3, + "name": "Broadcast" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 0, + "name": "Album" + }, + "allowed": true + } + ], + "secondaryAlbumTypes": [ + { + "secondaryAlbumType": { + "id": 0, + "name": "Studio" + }, + "allowed": true + }, + { + "secondaryAlbumType": { + "id": 3, + "name": "Spokenword" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 2, + "name": "Soundtrack" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 7, + "name": "Remix" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 9, + "name": "Mixtape/Street" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 6, + "name": "Live" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 4, + "name": "Interview" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 8, + "name": "DJ-mix" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 10, + "name": "Demo" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 1, + "name": "Compilation" + }, + "allowed": false + } + ], + "releaseStatuses": [ + { + "releaseStatus": { + "id": 3, + "name": "Pseudo" + }, + "allowed": false + }, + { + "releaseStatus": { + "id": 1, + "name": "Promotion" + }, + "allowed": false + }, + { + "releaseStatus": { + "id": 0, + "name": "Official" + }, + "allowed": true + }, + { + "releaseStatus": { + "id": 2, + "name": "Bootleg" + }, + "allowed": false + } + ], + "id": 1 + } + } + ], + "newDownload": false, + "singleRelease": false, + "tracks": [ + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 01 - 21 - Rolling in the Deep.flac", + "fileTrackInfo": { + "title": "Rolling in the Deep", + "cleanTitle": "Rolling in the Deep", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "1a13c710-4b7e-4701-8968-cd61f2e58110", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:03:49.2930000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 943, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 1 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 02 - 21 - Rumour Has It.flac", + "fileTrackInfo": { + "title": "Rumour Has It", + "cleanTitle": "Rumour Has It", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "797ae656-81d4-4d89-bddb-eca56f77ba72", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:03:43.2670000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 924, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 2 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 03 - 21 - Turning Tables.flac", + "fileTrackInfo": { + "title": "Turning Tables", + "cleanTitle": "Turning Tables", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "5ac6c47f-bce8-4718-8bc6-f1de40693d14", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:04:10.1330000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 786, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 3 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 04 - 21 - Don’t You Remember.flac", + "fileTrackInfo": { + "title": "Don’t You Remember", + "cleanTitle": "Don’t You Remember", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "f5057d26-1aac-47fe-b766-18c5a28927b1", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:04:03.2000000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 854, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 4 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 05 - 21 - Set Fire to the Rain.flac", + "fileTrackInfo": { + "title": "Set Fire to the Rain", + "cleanTitle": "Set Fire to the Rain", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "d1e0a99e-1894-457b-ba6a-985eeef4d0c4", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:04:01.6930000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 970, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 5 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 06 - 21 - He Won’t Go.flac", + "fileTrackInfo": { + "title": "He Won’t Go", + "cleanTitle": "He Won’t Go", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "4dd209b9-80fd-4e11-8093-3bab2db810fc", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:04:37.9470000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 892, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 6 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 07 - 21 - Take It All.flac", + "fileTrackInfo": { + "title": "Take It All", + "cleanTitle": "Take It All", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "4f515654-052b-4631-8a78-57ea362cd18a", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:03:48.2130000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 701, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 7 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 08 - 21 - I’ll Be Waiting.flac", + "fileTrackInfo": { + "title": "I’ll Be Waiting", + "cleanTitle": "I’ll Be Waiting", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "dd2d2073-50c4-438a-91cc-a1fea1c81b12", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:04:01.6530000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 1002, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 8 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 09 - 21 - One and Only.flac", + "fileTrackInfo": { + "title": "One and Only", + "cleanTitle": "One and Only", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "04f96056-91ac-4b64-af89-24c596013f05", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:05:48.1600000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 873, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 9 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 10 - 21 - Lovesong.flac", + "fileTrackInfo": { + "title": "Lovesong", + "cleanTitle": "Lovesong", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "7932ba40-a0d3-4a7e-8d85-b351fd33317e", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:05:16.2800000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 788, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 10 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 11 - 21 - Someone Like You.flac", + "fileTrackInfo": { + "title": "Someone Like You", + "cleanTitle": "Someone Like You", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "028efe7f-cdfb-4135-846f-848f2fff15b1", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:04:55.3870000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 722, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 11 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 12 - 21 - I Found a Boy.flac", + "fileTrackInfo": { + "title": "I Found a Boy", + "cleanTitle": "I Found a Boy", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "a027bfd5-c002-4a85-906e-f2c613c45022", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:03:36.6000000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 636, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 12 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 13 - 21 - Turning Tables (live acoustic).flac", + "fileTrackInfo": { + "title": "Turning Tables (live acoustic)", + "cleanTitle": "Turning Tables (live acoustic)", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "b2e47e6d-b69a-420a-aa52-31d3f38978ed", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:04:22.0800000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 664, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 13 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 14 - 21 - Don’t You Remember (live acoustic).flac", + "fileTrackInfo": { + "title": "Don’t You Remember (live acoustic)", + "cleanTitle": "Don’t You Remember (live acoustic)", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "b19a4995-7fbf-406a-8ff1-db5b8896bd28", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:04:19.6670000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 689, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 14 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 15 - 21 - Someone Like You (live acoustic).flac", + "fileTrackInfo": { + "title": "Someone Like You (live acoustic)", + "cleanTitle": "Someone Like You (live acoustic)", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "c365d988-0f2d-4313-9c5f-a557c30f027b", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:05:16.4270000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 681, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 15 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/25 (2015)/Adele - 01 - 25 - Hello.flac", + "fileTrackInfo": { + "title": "Hello", + "cleanTitle": "Hello", + "artistTitle": "Adele", + "albumTitle": "25", + "artistTitleInfo": { + "title": "Adele", + "year": 2015 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "5537624c-3d2f-4f5c-8099-df916082c85c", + "releaseMBId": "97189482-89ee-4d31-90c7-ba07b412d7f9", + "recordingMBId": "0a8e8d55-4b83-4f8a-9732-fbb5ded9f344", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "HK", + "name": "Hong Kong" + }, + "year": 2015, + "label": "Columbia", + "catalogNumber": "88875189832", + "duration": "00:04:55.4930000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 789, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 1 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/25 (2015)/Adele - 02 - 25 - Send My Love (to Your New Lover).flac", + "fileTrackInfo": { + "title": "Send My Love (to Your New Lover)", + "cleanTitle": "Send My Love (to Your New Lover)", + "artistTitle": "Adele", + "albumTitle": "25", + "artistTitleInfo": { + "title": "Adele", + "year": 2015 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "5537624c-3d2f-4f5c-8099-df916082c85c", + "releaseMBId": "97189482-89ee-4d31-90c7-ba07b412d7f9", + "recordingMBId": "1e74cd4c-cfa7-4bdb-99da-41869f5f1171", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "HK", + "name": "Hong Kong" + }, + "year": 2015, + "label": "Columbia", + "catalogNumber": "88875189832", + "duration": "00:03:43.0800000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 879, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 2 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/25 (2015)/Adele - 03 - 25 - I Miss You.flac", + "fileTrackInfo": { + "title": "I Miss You", + "cleanTitle": "I Miss You", + "artistTitle": "Adele", + "albumTitle": "25", + "artistTitleInfo": { + "title": "Adele", + "year": 2015 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "5537624c-3d2f-4f5c-8099-df916082c85c", + "releaseMBId": "97189482-89ee-4d31-90c7-ba07b412d7f9", + "recordingMBId": "20594682-fa10-43e8-80fa-b116c68f1b7f", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "HK", + "name": "Hong Kong" + }, + "year": 2015, + "label": "Columbia", + "catalogNumber": "88875189832", + "duration": "00:05:48.6270000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 900, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 3 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/25 (2015)/Adele - 04 - 25 - When We Were Young.flac", + "fileTrackInfo": { + "title": "When We Were Young", + "cleanTitle": "When We Were Young", + "artistTitle": "Adele", + "albumTitle": "25", + "artistTitleInfo": { + "title": "Adele", + "year": 2015 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "5537624c-3d2f-4f5c-8099-df916082c85c", + "releaseMBId": "97189482-89ee-4d31-90c7-ba07b412d7f9", + "recordingMBId": "c5ad2611-071b-4003-bb22-eee8b4f48fe9", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "HK", + "name": "Hong Kong" + }, + "year": 2015, + "label": "Columbia", + "catalogNumber": "88875189832", + "duration": "00:04:50.9070000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 776, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 4 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/25 (2015)/Adele - 05 - 25 - Remedy.flac", + "fileTrackInfo": { + "title": "Remedy", + "cleanTitle": "Remedy", + "artistTitle": "Adele", + "albumTitle": "25", + "artistTitleInfo": { + "title": "Adele", + "year": 2015 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "5537624c-3d2f-4f5c-8099-df916082c85c", + "releaseMBId": "97189482-89ee-4d31-90c7-ba07b412d7f9", + "recordingMBId": "cdc9f701-60b4-4e37-a94f-87d0e396f2bc", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "HK", + "name": "Hong Kong" + }, + "year": 2015, + "label": "Columbia", + "catalogNumber": "88875189832", + "duration": "00:04:05.4270000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 748, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 5 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/25 (2015)/Adele - 06 - 25 - Water Under the Bridge.flac", + "fileTrackInfo": { + "title": "Water Under the Bridge", + "cleanTitle": "Water Under the Bridge", + "artistTitle": "Adele", + "albumTitle": "25", + "artistTitleInfo": { + "title": "Adele", + "year": 2015 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "5537624c-3d2f-4f5c-8099-df916082c85c", + "releaseMBId": "97189482-89ee-4d31-90c7-ba07b412d7f9", + "recordingMBId": "ade2f0f3-39bf-46ad-a44d-7fc4a8069db7", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "HK", + "name": "Hong Kong" + }, + "year": 2015, + "label": "Columbia", + "catalogNumber": "88875189832", + "duration": "00:04:00.4270000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 938, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 6 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/25 (2015)/Adele - 07 - 25 - River Lea.flac", + "fileTrackInfo": { + "title": "River Lea", + "cleanTitle": "River Lea", + "artistTitle": "Adele", + "albumTitle": "25", + "artistTitleInfo": { + "title": "Adele", + "year": 2015 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "5537624c-3d2f-4f5c-8099-df916082c85c", + "releaseMBId": "97189482-89ee-4d31-90c7-ba07b412d7f9", + "recordingMBId": "b7c37d3d-feea-4a73-8346-9e2392a292e6", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "HK", + "name": "Hong Kong" + }, + "year": 2015, + "label": "Columbia", + "catalogNumber": "88875189832", + "duration": "00:03:45.4270000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 884, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 7 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/25 (2015)/Adele - 08 - 25 - Love in the Dark.flac", + "fileTrackInfo": { + "title": "Love in the Dark", + "cleanTitle": "Love in the Dark", + "artistTitle": "Adele", + "albumTitle": "25", + "artistTitleInfo": { + "title": "Adele", + "year": 2015 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "5537624c-3d2f-4f5c-8099-df916082c85c", + "releaseMBId": "97189482-89ee-4d31-90c7-ba07b412d7f9", + "recordingMBId": "dedf519d-8eca-4756-9f93-c390308e0c1b", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "HK", + "name": "Hong Kong" + }, + "year": 2015, + "label": "Columbia", + "catalogNumber": "88875189832", + "duration": "00:04:45.9470000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 825, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 8 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/25 (2015)/Adele - 09 - 25 - Million Years Ago.flac", + "fileTrackInfo": { + "title": "Million Years Ago", + "cleanTitle": "Million Years Ago", + "artistTitle": "Adele", + "albumTitle": "25", + "artistTitleInfo": { + "title": "Adele", + "year": 2015 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "5537624c-3d2f-4f5c-8099-df916082c85c", + "releaseMBId": "97189482-89ee-4d31-90c7-ba07b412d7f9", + "recordingMBId": "870c62b6-ba2c-4873-b962-6289128e4a90", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "HK", + "name": "Hong Kong" + }, + "year": 2015, + "label": "Columbia", + "catalogNumber": "88875189832", + "duration": "00:03:47.0670000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 736, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 9 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/25 (2015)/Adele - 10 - 25 - All I Ask.flac", + "fileTrackInfo": { + "title": "All I Ask", + "cleanTitle": "All I Ask", + "artistTitle": "Adele", + "albumTitle": "25", + "artistTitleInfo": { + "title": "Adele", + "year": 2015 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "5537624c-3d2f-4f5c-8099-df916082c85c", + "releaseMBId": "97189482-89ee-4d31-90c7-ba07b412d7f9", + "recordingMBId": "bfe7a94e-4161-4802-8916-efe57e611842", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "HK", + "name": "Hong Kong" + }, + "year": 2015, + "label": "Columbia", + "catalogNumber": "88875189832", + "duration": "00:04:31.8000000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 752, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 10 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/25 (2015)/Adele - 11 - 25 - Sweetest Devotion.flac", + "fileTrackInfo": { + "title": "Sweetest Devotion", + "cleanTitle": "Sweetest Devotion", + "artistTitle": "Adele", + "albumTitle": "25", + "artistTitleInfo": { + "title": "Adele", + "year": 2015 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "5537624c-3d2f-4f5c-8099-df916082c85c", + "releaseMBId": "97189482-89ee-4d31-90c7-ba07b412d7f9", + "recordingMBId": "b2c8aed1-777d-409b-941e-7d4c594697a2", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "HK", + "name": "Hong Kong" + }, + "year": 2015, + "label": "Columbia", + "catalogNumber": "88875189832", + "duration": "00:04:11.6930000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 887, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 11 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/Incomplete/Adele - 19 - 108 - Right as Rain.mp3", + "fileTrackInfo": { + "title": "Right as Rain", + "cleanTitle": "Right as Rain", + "artistTitle": "Adele", + "albumTitle": "19", + "artistTitleInfo": { + "title": "Adele", + "year": 2008 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "9796da06-2d59-3176-8598-2105f31ee54a", + "releaseMBId": "9105a5b3-eb68-3a03-9aa8-f3495e602a4f", + "recordingMBId": "e5aa0386-15cc-43a8-a059-b14fc39b8301", + "trackMBId": "d98b4797-f47e-3acf-b334-54c71c9cb608", + "discNumber": 1, + "discCount": 2, + "country": { + "twoLetterCode": "FR", + "name": "France" + }, + "year": 2008, + "label": "XL Recordings", + "catalogNumber": "XLCD313X", + "disambiguation": "expanded edition", + "duration": "00:03:17.3810000", + "quality": { + "quality": { + "id": 2, + "name": "MP3-VBR-V0" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3 VBR", + "audioBitrate": 189, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 8 + ] + } + } + ] +} diff --git a/src/NzbDrone.Core.Test/Files/Identification/FilesWithoutTags.json b/src/NzbDrone.Core.Test/Files/Identification/FilesWithoutTags.json new file mode 100644 index 000000000..a28e3a162 --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/Identification/FilesWithoutTags.json @@ -0,0 +1,1138 @@ +{ + "expectedMusicBrainzReleaseIds": [ + "9105a5b3-eb68-3a03-9aa8-f3495e602a4f" + ], + "libraryArtists": [ + { + "artist": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "metadataProfile": { + "name": "Standard", + "primaryAlbumTypes": [ + { + "primaryAlbumType": { + "id": 2, + "name": "Single" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 4, + "name": "Other" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 1, + "name": "EP" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 3, + "name": "Broadcast" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 0, + "name": "Album" + }, + "allowed": true + } + ], + "secondaryAlbumTypes": [ + { + "secondaryAlbumType": { + "id": 0, + "name": "Studio" + }, + "allowed": true + }, + { + "secondaryAlbumType": { + "id": 3, + "name": "Spokenword" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 2, + "name": "Soundtrack" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 7, + "name": "Remix" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 9, + "name": "Mixtape/Street" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 6, + "name": "Live" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 4, + "name": "Interview" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 8, + "name": "DJ-mix" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 10, + "name": "Demo" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 1, + "name": "Compilation" + }, + "allowed": false + } + ], + "releaseStatuses": [ + { + "releaseStatus": { + "id": 3, + "name": "Pseudo" + }, + "allowed": false + }, + { + "releaseStatus": { + "id": 1, + "name": "Promotion" + }, + "allowed": false + }, + { + "releaseStatus": { + "id": 0, + "name": "Official" + }, + "allowed": true + }, + { + "releaseStatus": { + "id": 2, + "name": "Bootleg" + }, + "allowed": false + } + ], + "id": 1 + } + } + ], + "newDownload": true, + "singleRelease": false, + "tracks": [ + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD1/Adele - 19 - 101 - Daydreamer.mp3", + "fileTrackInfo": { + "artistTitleInfo": { + "year": 0 + }, + "discNumber": 0, + "discCount": 0, + "year": 0, + "duration": "00:03:40.5520000", + "quality": { + "quality": { + "id": 2, + "name": "MP3-VBR-V0" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3 VBR", + "audioBitrate": 173, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 0 + ] + } + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD1/Adele - 19 - 102 - Best for Last.mp3", + "fileTrackInfo": { + "artistTitleInfo": { + "year": 0 + }, + "discNumber": 0, + "discCount": 0, + "year": 0, + "duration": "00:04:18.5340000", + "quality": { + "quality": { + "id": 2, + "name": "MP3-VBR-V0" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3 VBR", + "audioBitrate": 170, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 0 + ] + } + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD1/Adele - 19 - 103 - Chasing Pavements.mp3", + "fileTrackInfo": { + "artistTitleInfo": { + "year": 0 + }, + "discNumber": 0, + "discCount": 0, + "year": 0, + "duration": "00:03:29.7890000", + "quality": { + "quality": { + "id": 2, + "name": "MP3-VBR-V0" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3 VBR", + "audioBitrate": 176, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 0 + ] + } + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD1/Adele - 19 - 104 - Cold Shoulder.mp3", + "fileTrackInfo": { + "artistTitleInfo": { + "year": 0 + }, + "discNumber": 0, + "discCount": 0, + "year": 0, + "duration": "00:03:11.8960000", + "quality": { + "quality": { + "id": 2, + "name": "MP3-VBR-V0" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3 VBR", + "audioBitrate": 189, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 0 + ] + } + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD1/Adele - 19 - 105 - Crazy for You.mp3", + "fileTrackInfo": { + "artistTitleInfo": { + "year": 0 + }, + "discNumber": 0, + "discCount": 0, + "year": 0, + "duration": "00:03:27.7780000", + "quality": { + "quality": { + "id": 2, + "name": "MP3-VBR-V0" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3 VBR", + "audioBitrate": 156, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 0 + ] + } + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD1/Adele - 19 - 106 - Melt My Heart to Stone.mp3", + "fileTrackInfo": { + "artistTitleInfo": { + "year": 0 + }, + "discNumber": 0, + "discCount": 0, + "year": 0, + "duration": "00:03:23.9380000", + "quality": { + "quality": { + "id": 2, + "name": "MP3-VBR-V0" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3 VBR", + "audioBitrate": 176, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 0 + ] + } + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD1/Adele - 19 - 107 - First Love.mp3", + "fileTrackInfo": { + "artistTitleInfo": { + "year": 0 + }, + "discNumber": 0, + "discCount": 0, + "year": 0, + "duration": "00:03:10.3280000", + "quality": { + "quality": { + "id": 2, + "name": "MP3-VBR-V0" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3 VBR", + "audioBitrate": 187, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 0 + ] + } + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD1/Adele - 19 - 108 - Right as Rain.mp3", + "fileTrackInfo": { + "artistTitleInfo": { + "year": 0 + }, + "discNumber": 0, + "discCount": 0, + "year": 0, + "duration": "00:03:17.3810000", + "quality": { + "quality": { + "id": 2, + "name": "MP3-VBR-V0" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3 VBR", + "audioBitrate": 189, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 0 + ] + } + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD1/Adele - 19 - 109 - Make You Feel My Love.mp3", + "fileTrackInfo": { + "artistTitleInfo": { + "year": 0 + }, + "discNumber": 0, + "discCount": 0, + "year": 0, + "duration": "00:03:31.6960000", + "quality": { + "quality": { + "id": 2, + "name": "MP3-VBR-V0" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3 VBR", + "audioBitrate": 173, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 0 + ] + } + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD1/Adele - 19 - 110 - My Same.mp3", + "fileTrackInfo": { + "artistTitleInfo": { + "year": 0 + }, + "discNumber": 0, + "discCount": 0, + "year": 0, + "duration": "00:03:15.6830000", + "quality": { + "quality": { + "id": 2, + "name": "MP3-VBR-V0" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3 VBR", + "audioBitrate": 190, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 0 + ] + } + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD1/Adele - 19 - 111 - Tired.mp3", + "fileTrackInfo": { + "artistTitleInfo": { + "year": 0 + }, + "discNumber": 0, + "discCount": 0, + "year": 0, + "duration": "00:04:18.0110000", + "quality": { + "quality": { + "id": 2, + "name": "MP3-VBR-V0" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3 VBR", + "audioBitrate": 177, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 0 + ] + } + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD1/Adele - 19 - 112 - Hometown Glory.mp3", + "fileTrackInfo": { + "artistTitleInfo": { + "year": 0 + }, + "discNumber": 0, + "discCount": 0, + "year": 0, + "duration": "00:04:29.2700000", + "quality": { + "quality": { + "id": 2, + "name": "MP3-VBR-V0" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3 VBR", + "audioBitrate": 186, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 0 + ] + } + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD2/Adele - 19 - 201 - Chasing Pavements.mp3", + "fileTrackInfo": { + "artistTitleInfo": { + "year": 0 + }, + "discNumber": 0, + "discCount": 0, + "year": 0, + "duration": "00:03:52.2290000", + "quality": { + "quality": { + "id": 2, + "name": "MP3-VBR-V0" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3 VBR", + "audioBitrate": 164, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 0 + ] + } + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD2/Adele - 19 - 202 - Melt My Heart to Stone.mp3", + "fileTrackInfo": { + "artistTitleInfo": { + "year": 0 + }, + "discNumber": 0, + "discCount": 0, + "year": 0, + "duration": "00:03:21.9000000", + "quality": { + "quality": { + "id": 2, + "name": "MP3-VBR-V0" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3 VBR", + "audioBitrate": 158, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 0 + ] + } + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD2/Adele - 19 - 203 - That's It, I Quit, I'm Moving On.mp3", + "fileTrackInfo": { + "artistTitleInfo": { + "year": 0 + }, + "discNumber": 0, + "discCount": 0, + "year": 0, + "duration": "00:02:07.5560000", + "quality": { + "quality": { + "id": 2, + "name": "MP3-VBR-V0" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3 VBR", + "audioBitrate": 173, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 0 + ] + } + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD2/Adele - 19 - 204 - Crazy for You.mp3", + "fileTrackInfo": { + "artistTitleInfo": { + "year": 0 + }, + "discNumber": 0, + "discCount": 0, + "year": 0, + "duration": "00:03:43.4510000", + "quality": { + "quality": { + "id": 2, + "name": "MP3-VBR-V0" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3 VBR", + "audioBitrate": 153, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 0 + ] + } + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD2/Adele - 19 - 205 - Right as Rain.mp3", + "fileTrackInfo": { + "artistTitleInfo": { + "year": 0 + }, + "discNumber": 0, + "discCount": 0, + "year": 0, + "duration": "00:03:32.0620000", + "quality": { + "quality": { + "id": 2, + "name": "MP3-VBR-V0" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3 VBR", + "audioBitrate": 175, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 0 + ] + } + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD2/Adele - 19 - 206 - My Same.mp3", + "fileTrackInfo": { + "artistTitleInfo": { + "year": 0 + }, + "discNumber": 0, + "discCount": 0, + "year": 0, + "duration": "00:03:05.4690000", + "quality": { + "quality": { + "id": 2, + "name": "MP3-VBR-V0" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3 VBR", + "audioBitrate": 169, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 0 + ] + } + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD2/Adele - 19 - 207 - Make You Feel My Love.mp3", + "fileTrackInfo": { + "artistTitleInfo": { + "year": 0 + }, + "discNumber": 0, + "discCount": 0, + "year": 0, + "duration": "00:03:52.2290000", + "quality": { + "quality": { + "id": 2, + "name": "MP3-VBR-V0" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3 VBR", + "audioBitrate": 164, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 0 + ] + } + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD2/Adele - 19 - 208 - Daydreamer.mp3", + "fileTrackInfo": { + "artistTitleInfo": { + "year": 0 + }, + "discNumber": 0, + "discCount": 0, + "year": 0, + "duration": "00:03:41.5440000", + "quality": { + "quality": { + "id": 2, + "name": "MP3-VBR-V0" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3 VBR", + "audioBitrate": 151, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 0 + ] + } + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD2/Adele - 19 - 209 - Hometown Glory.mp3", + "fileTrackInfo": { + "artistTitleInfo": { + "year": 0 + }, + "discNumber": 0, + "discCount": 0, + "year": 0, + "duration": "00:03:48.6240000", + "quality": { + "quality": { + "id": 2, + "name": "MP3-VBR-V0" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3 VBR", + "audioBitrate": 170, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 0 + ] + } + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD2/Adele - 19 - 210 - Many Shades of Black.mp3", + "fileTrackInfo": { + "artistTitleInfo": { + "year": 0 + }, + "discNumber": 0, + "discCount": 0, + "year": 0, + "duration": "00:04:28.1210000", + "quality": { + "quality": { + "id": 2, + "name": "MP3-VBR-V0" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3 VBR", + "audioBitrate": 188, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 0 + ] + } + } + ], + "fingerprints": + [ + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD1/Adele - 19 - 101 - Daydreamer.mp3", + "acoustIdResults": [ + "09a43ae1-57a0-49e8-96f7-5be7c8ae1f4c", + "0dcedcea-945d-48c4-b42f-df65a78a5e0d", + "27e08fb0-6c66-4b1f-bbd8-ed847455022b", + "5958ef70-fcfb-4e12-9776-3330cf5d56cc", + "b94b9760-b6e8-4aa4-815c-1261231c4970", + "cb79fa12-8c02-4f87-8209-2041c3c07ede", + "ec7d4cc4-c6b6-4bd1-b301-681da015a0f3", + "efd854c2-30e5-40ab-828c-1c740ffeb438", + "17d6dca8-a59e-4b67-a15b-d8da83bfceb2", + "7fae315e-4738-4bb1-b2c1-de95047c69f6" + ] + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD1/Adele - 19 - 102 - Best for Last.mp3", + "acoustIdResults": [ + "6f05a7e7-0ce9-41f2-a27a-813d00165146", + "7502dd98-a7e6-41d0-90d1-16aa885f8fdb", + "89397822-80d8-498a-aded-5303144e867a", + "8ab211cb-b12f-4366-8803-8c65f666f1dd", + "8d5b16b6-6f0f-476d-8b18-802843e1a390", + "fbd7532b-66a2-4009-96c1-17c6178e4afc" + ] + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD1/Adele - 19 - 103 - Chasing Pavements.mp3", + "acoustIdResults": [ + "05957c8d-0dbf-41bc-bb66-65586e5f5fd7", + "1e748d2c-0194-425c-89a3-7a6e7669a12d", + "44171f6f-6e12-47f7-9c4a-bb783fe4a86d", + "453f8ecf-e853-45ec-8335-d240a15cd75f", + "59049052-8f7b-4be6-9cd9-897b0c8c5db3", + "6c1e4c08-d297-44ba-a828-2df24c5009d1", + "9eee5c07-326a-45c8-8666-eebde107b144", + "b317fac7-687f-4eac-a3a4-80b94355c2ab", + "1862f21e-c706-4505-923e-fa63ca2d6255", + "bb126c92-043c-4016-835e-3e23bb02e662", + "c4a1e56c-1f26-4a3a-8c76-327fb5d84d5e", + "ea889e42-b373-40b7-9497-f1d7f06146ec", + "95f62f74-c3c0-4232-98db-2556a3d9d553" + ] + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD1/Adele - 19 - 104 - Cold Shoulder.mp3", + "acoustIdResults": [ + "1683503d-785d-481a-81a4-e599d64eba66", + "4aa737c8-b899-4289-841f-41657c206aa1", + "834c740c-438e-41c7-ae92-61709021bcb4", + "baf61c23-fea1-4096-955c-ce61235cb0dd", + "bd355a93-9336-4ac6-b06e-b24117cb63d1", + "bd8f9ee4-6a83-46cc-a59e-190fbfc5012c", + "c009203d-a0f4-4292-9d10-8d198060c198", + "dc9e2f5c-225f-4685-aeda-6067e079d5dc", + "e6e15fe1-4fcf-40bf-abed-93dd0274bbdd" + ] + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD1/Adele - 19 - 105 - Crazy for You.mp3", + "acoustIdResults": [ + "20bc934e-899c-4230-946b-dc57349ae9b8", + "2c61207d-9063-4a43-81c6-1cf8cefbce92", + "54520823-a807-443f-821a-280944f7c0f1", + "68b8da42-bf81-40d8-807b-20b90d444aac", + "7427679e-9e1a-40c6-94e2-8881b48d2e18", + "95f62f74-c3c0-4232-98db-2556a3d9d553", + "d6848807-3548-45d6-8e51-c78bc49b44d2", + "e42df375-e203-4f9c-8e06-a385634e76c7" + ] + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD1/Adele - 19 - 106 - Melt My Heart to Stone.mp3", + "acoustIdResults": [ + "18828f6f-1085-4249-9566-a3e24210f52e", + "233b4c92-c38e-4baa-8200-2009bb69ecc2", + "44dc19cb-fe11-4e79-9d52-b5866812f2e0", + "4c24975a-998c-416c-9574-341dc487d86f", + "536cd321-d700-4763-ae9b-68994eaac087", + "67c8fb9c-d630-45b1-bb45-3a1d5462f622", + "72c0b78f-0491-490d-8d9d-5972bb80d903", + "8291ea15-fb2e-402b-9736-7be4c51c1f9a", + "8ac2bc99-9f9c-4096-af46-f6334d42f2bd", + "97b238f2-1e29-4a4c-9c7f-7dc7764a305a", + "983813b2-5119-4218-94fc-672229f7ca23", + "ab543f79-3103-479d-9bd4-ad982d1b7939", + "c2a01ae6-e5b3-4568-a4fc-3261e0fd9370" + ] + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD1/Adele - 19 - 107 - First Love.mp3", + "acoustIdResults": [ + "1683503d-785d-481a-81a4-e599d64eba66", + "1c981845-ab93-454e-bca1-e35e5670df4e", + "3fd5c4cc-a5a7-4bbf-be17-065769561468", + "4aa737c8-b899-4289-841f-41657c206aa1", + "81c0685c-f10c-4ac0-aad9-84d2c476011b", + "c009203d-a0f4-4292-9d10-8d198060c198" + ] + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD1/Adele - 19 - 108 - Right as Rain.mp3", + "acoustIdResults": [ + "3d3b00fb-1e80-47e5-932e-2f4f15fda2b1", + "6cb59533-f11f-42d0-9b91-b414b671b1be", + "6f05a7e7-0ce9-41f2-a27a-813d00165146", + "bb5cdd79-ba7f-40aa-8f51-ea9e803e9443", + "e5aa0386-15cc-43a8-a059-b14fc39b8301", + "f7b1bf72-78ba-4d5b-af3e-9e217be37a62" + ] + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD1/Adele - 19 - 109 - Make You Feel My Love.mp3", + "acoustIdResults": [ + "027c5431-70ff-400d-832d-1e5f607e8bd0", + "2412f7f5-7306-4419-bf42-37ea3025eb47", + "3dcdd899-eb81-482d-b495-45ec72c2d94e", + "453f8ecf-e853-45ec-8335-d240a15cd75f", + "5106d57f-9c3c-4971-8dd7-19a3370d53ef", + "589afaf4-f92e-4a64-8f38-ddb9f50e72e3", + "601594e1-7ba4-4d51-abf6-158586fea4ef", + "6f8d622f-1c68-4e00-9f68-6e7bd63147e9", + "88978d1c-625c-4e3e-85bb-80ed2329bb3e", + "983813b2-5119-4218-94fc-672229f7ca23", + "9efc6d4b-0ec8-4d68-b915-b0e00b3e5ae9", + "c329e4f9-743b-4681-900f-2e207b15a1bc", + "e2067c74-0284-47cd-8f7c-13689fb17337", + "e42df375-e203-4f9c-8e06-a385634e76c7", + "e82fa2b0-ed50-4d6f-93cc-7b7114431d6b", + "fb337643-87f6-44ee-8d3c-8e5562fb6cdd", + "ec79ad12-7d3d-4b4b-a6b5-61f83ffff841" + ] + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD1/Adele - 19 - 110 - My Same.mp3", + "acoustIdResults": [ + "217047d7-3482-4cac-a935-b941ecf92233", + "2a8df405-5bce-413d-9ad2-a3356fdab62b", + "2daf9540-e409-440c-8c25-94c580352ee9", + "5c0e4414-f352-440a-bc08-349331e6b105", + "7d759554-a1f6-480c-b963-88810b9d54fb", + "8658b791-e604-46ed-a8d1-113cb4058d64", + "8cfe1246-6aaa-4e33-86ea-3667330b0f0d", + "aab9b3f0-ef5c-44f0-ab83-be3bd6ab5553", + "b1a2fc67-6fe3-42e5-a461-3c75a7b43e9e", + "f3c651ae-2f43-46e4-a565-a7a66f6f3e07" + ] + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD1/Adele - 19 - 111 - Tired.mp3", + "acoustIdResults": [ + "03fc1567-d13b-48b6-8e2f-37d01f46eda0", + "1e288ef1-dfb8-4225-841a-a2684764985e", + "206cad73-0854-4f88-a5e2-5c7a0126093e", + "4c24975a-998c-416c-9574-341dc487d86f", + "6abe348e-9aa4-46ab-823f-8d32b0051321", + "8b27337e-af52-4868-a903-819678c3aeb7", + "b94b9760-b6e8-4aa4-815c-1261231c4970" + ] + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD1/Adele - 19 - 112 - Hometown Glory.mp3", + "acoustIdResults": [ + "268bcc34-42ab-4f1a-a684-31cbcee81441", + "3fd5c4cc-a5a7-4bbf-be17-065769561468", + "4415072c-2b81-4d90-a478-27586db19b47", + "669628d7-fe04-4a81-a8b3-422c86021e14", + "69bcf464-4714-4674-9076-1606e62a1c4e", + "70b9c246-0935-437c-9873-d24f4d78c388", + "894be7cc-6e73-4eaa-b259-d90d62a89acb", + "af9d4cef-2132-4a2a-bdae-56bc213378de", + "bce20d9f-0da5-46a0-98f6-335228849e99", + "fbd7532b-66a2-4009-96c1-17c6178e4afc" + ] + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD2/Adele - 19 - 201 - Chasing Pavements.mp3", + "acoustIdResults": [ + "05957c8d-0dbf-41bc-bb66-65586e5f5fd7", + "282a2ebf-31be-4e19-a262-664079f9a92a", + "56354a1b-9dae-448b-8a92-2240bdd68b9a", + "6e82c662-30b0-409c-9fa1-af2a3d8a62f3", + "ddfc9d63-ed67-42db-a16d-4cf805aed067", + "15ffe35b-72f5-475c-b27a-28350518f4a3" + ] + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD2/Adele - 19 - 202 - Melt My Heart to Stone.mp3", + "acoustIdResults": [ + "148efbf1-63cf-41b8-bc71-8c2bd00beb53", + "18828f6f-1085-4249-9566-a3e24210f52e", + "44dc19cb-fe11-4e79-9d52-b5866812f2e0", + "5846d785-ab9c-4e4e-a6a2-8bad09bbbbee", + "8ac2bc99-9f9c-4096-af46-f6334d42f2bd", + "983813b2-5119-4218-94fc-672229f7ca23", + "ab543f79-3103-479d-9bd4-ad982d1b7939", + "c2a01ae6-e5b3-4568-a4fc-3261e0fd9370", + "e065bd8e-537f-4112-b3e6-c234a16e2e93" + ] + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD2/Adele - 19 - 203 - That's It, I Quit, I'm Moving On.mp3", + "acoustIdResults": [ + "206cad73-0854-4f88-a5e2-5c7a0126093e", + "329d3b62-edf5-422d-8fb4-bc1db42c8b26", + "453f8ecf-e853-45ec-8335-d240a15cd75f", + "8cfe1246-6aaa-4e33-86ea-3667330b0f0d", + "ae73d295-b825-4740-b78f-d0e8187ac1a1", + "cefb4fe0-718e-4fea-869b-00a513a9a3e7", + "d1a4186b-3004-4cbc-ab08-593bc1b28803" + ] + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD2/Adele - 19 - 204 - Crazy for You.mp3", + "acoustIdResults": [ + "2c61207d-9063-4a43-81c6-1cf8cefbce92", + "55ad6ce0-3666-4d64-8e04-5ab71121ec7b", + "95f62f74-c3c0-4232-98db-2556a3d9d553" + ] + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD2/Adele - 19 - 205 - Right as Rain.mp3", + "acoustIdResults": [ + "3d3b00fb-1e80-47e5-932e-2f4f15fda2b1", + "e5aa0386-15cc-43a8-a059-b14fc39b8301", + "7502dd98-a7e6-41d0-90d1-16aa885f8fdb" + ] + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD2/Adele - 19 - 206 - My Same.mp3", + "acoustIdResults": [ + "217047d7-3482-4cac-a935-b941ecf92233", + "7d759554-a1f6-480c-b963-88810b9d54fb", + "8658b791-e604-46ed-a8d1-113cb4058d64", + "b12cb4dc-6192-43c6-8fad-6712de704cf0", + "e3acf02f-a90e-4ae0-b1e2-ea314c1beb54", + "eafc036b-5bd0-4cc2-a51f-fb1186038ad3", + "f3c651ae-2f43-46e4-a565-a7a66f6f3e07" + ] + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD2/Adele - 19 - 207 - Make You Feel My Love.mp3", + "acoustIdResults": [ + "3dcdd899-eb81-482d-b495-45ec72c2d94e", + "bc6a0d82-a3df-45b1-a4fe-f25e34ca27c0", + "e109f0b7-5c0c-494a-9f87-af2da08cc480", + "e42df375-e203-4f9c-8e06-a385634e76c7" + ] + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD2/Adele - 19 - 208 - Daydreamer.mp3", + "acoustIdResults": [ + "17d6dca8-a59e-4b67-a15b-d8da83bfceb2", + "b94b9760-b6e8-4aa4-815c-1261231c4970" + ] + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD2/Adele - 19 - 209 - Hometown Glory.mp3", + "acoustIdResults": [ + "e1b7e7a3-4f19-44cc-9573-e5fa884de765" + ] + }, + { + "path": "/mnt/data1/ImportTest/19_no_tags/CD2/Adele - 19 - 210 - Many Shades of Black.mp3", + "acoustIdResults": [ + "26d08d36-934c-4d0a-9e26-04ed85819cd5", + "40fe2d4d-cadc-4860-926b-1dacd650df72", + "48bf3ebd-798d-46cc-b35c-75d8bca8c045", + "8a0c70de-178a-47c4-95ac-ca42d6483ec3", + "8b27337e-af52-4868-a903-819678c3aeb7", + "fa7826ed-a77d-45b3-b8c2-36835879a120", + "fbd7532b-66a2-4009-96c1-17c6178e4afc" + ] + } + ] +} diff --git a/src/NzbDrone.Core.Test/Files/Identification/InconsistentTyposInAlbum.json b/src/NzbDrone.Core.Test/Files/Identification/InconsistentTyposInAlbum.json new file mode 100644 index 000000000..218b5918f --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/Identification/InconsistentTyposInAlbum.json @@ -0,0 +1,1304 @@ +{ + "expectedMusicBrainzReleaseIds": [ + "134f5f3e-8b5f-46ab-809d-8c0dbc794f3e" + ], + "libraryArtists": [ + { + "artist": "c296e10c-110a-4103-9e77-47bfebb7fb2e", + "metadataProfile": { + "name": "Standard", + "primaryAlbumTypes": [ + { + "primaryAlbumType": { + "id": 2, + "name": "Single" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 4, + "name": "Other" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 1, + "name": "EP" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 3, + "name": "Broadcast" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 0, + "name": "Album" + }, + "allowed": true + } + ], + "secondaryAlbumTypes": [ + { + "secondaryAlbumType": { + "id": 0, + "name": "Studio" + }, + "allowed": true + }, + { + "secondaryAlbumType": { + "id": 3, + "name": "Spokenword" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 2, + "name": "Soundtrack" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 7, + "name": "Remix" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 9, + "name": "Mixtape/Street" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 6, + "name": "Live" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 4, + "name": "Interview" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 8, + "name": "DJ-mix" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 10, + "name": "Demo" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 1, + "name": "Compilation" + }, + "allowed": false + } + ], + "releaseStatuses": [ + { + "releaseStatus": { + "id": 3, + "name": "Pseudo" + }, + "allowed": false + }, + { + "releaseStatus": { + "id": 1, + "name": "Promotion" + }, + "allowed": false + }, + { + "releaseStatus": { + "id": 0, + "name": "Official" + }, + "allowed": true + }, + { + "releaseStatus": { + "id": 2, + "name": "Bootleg" + }, + "allowed": false + } + ], + "id": 1 + } + } + ], + "newDownload": false, + "singleRelease": false, + "tracks": [ + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-101-positive_vibration-rns.mp3", + "fileTrackInfo": { + "title": "Positive Vibration", + "cleanTitle": "Positive Vibration", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered)", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:03:33.9620000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 1 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-102-roots_rock_reggae-rns.mp3", + "fileTrackInfo": { + "title": "Roots Rock Reggae", + "cleanTitle": "Roots Rock Reggae", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:03:38.4480000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 2 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-103-johnny_was-rns.mp3", + "fileTrackInfo": { + "title": "Johnny Was", + "cleanTitle": "Johnny Was", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:03:47.8900000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 3 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-104-cry_to_me-rns.mp3", + "fileTrackInfo": { + "title": "Cry To Me", + "cleanTitle": "Cry To Me", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:02:36.1090000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 4 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-105-want_more-rns.mp3", + "fileTrackInfo": { + "title": "Want More", + "cleanTitle": "Want More", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:04:16.6600000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 5 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-106-crazy_baldhead-rns.mp3", + "fileTrackInfo": { + "title": "Crazy Baldhead", + "cleanTitle": "Crazy Baldhead", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:03:11.9210000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 6 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-107-who_the_cap_fit-rns.mp3", + "fileTrackInfo": { + "title": "Who The Cap Fit", + "cleanTitle": "Who The Cap Fit", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:04:43.1350000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 7 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-108-night_shift-rns.mp3", + "fileTrackInfo": { + "title": "Night Shift", + "cleanTitle": "Night Shift", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:03:11.0080000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 8 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-109-war-rns.mp3", + "fileTrackInfo": { + "title": "War", + "cleanTitle": "War", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:03:36.5180000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 9 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-110-rat_race-rns.mp3", + "fileTrackInfo": { + "title": "Rat Race", + "cleanTitle": "Rat Race", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:02:54.5240000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 10 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-111-jah_live_(original_mix)-rns.mp3", + "fileTrackInfo": { + "title": "Jah Live (Original Mix)", + "cleanTitle": "Jah Live (Original Mix)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:04:17.1030000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 11 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-112-concrete-rns.mp3", + "fileTrackInfo": { + "title": "Concrete", + "cleanTitle": "Concrete", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:04:23.6760000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 12 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-113-roots_rock_reggae-rns.mp3", + "fileTrackInfo": { + "title": "Roots Rock Reggae", + "cleanTitle": "Roots Rock Reggae", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:03:37.7700000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 13 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-114-roots_rock_dub-rns.mp3", + "fileTrackInfo": { + "title": "Roots Rock Dub", + "cleanTitle": "Roots Rock Dub", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:03:37.6390000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 14 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-115-want_more_(alternate_mix)-rns.mp3", + "fileTrackInfo": { + "title": "Want More (Alternate Mix)", + "cleanTitle": "Want More (Alternate Mix)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:05:10.2870000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 15 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-116-crazy_baldhead_(alternate_mix)-rns.mp3", + "fileTrackInfo": { + "title": "Crazy Baldhead (Alternate Mix)", + "cleanTitle": "Crazy Baldhead (Alternate Mix)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:03:08.1650000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 16 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-117-war_(alternate_mix)-rns.mp3", + "fileTrackInfo": { + "title": "War (Alternate Mix)", + "cleanTitle": "War (Alternate Mix)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:04:03.0180000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 17 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-118-johnny_was_(alternate_mix)-rns.mp3", + "fileTrackInfo": { + "title": "Johnny Was (Alternate Mix)", + "cleanTitle": "Johnny Was (Alternate Mix)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:03:41.0560000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 18 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-201-introduction-rns.mp3", + "fileTrackInfo": { + "title": "Introduction", + "cleanTitle": "Introduction", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered)", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:00:38.4210000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 1 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-202-trenchtown_rock_(live)-rns.mp3", + "fileTrackInfo": { + "title": "Trenchtown Rock (Live)", + "cleanTitle": "Trenchtown Rock (Live)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered)", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:04:55.7590000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 2 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-203-burnin_and_looting_(live)-rns.mp3", + "fileTrackInfo": { + "title": "Burnin And Looting (Live)", + "cleanTitle": "Burnin And Looting (Live)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered)", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:04:53.8030000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 3 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-204-them_belly_full_(live)-rns.mp3", + "fileTrackInfo": { + "title": "Them Belly Full (Live)", + "cleanTitle": "Them Belly Full (Live)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered)", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:04:12.8520000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 4 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-205-rebel_music_(live)-rns.mp3", + "fileTrackInfo": { + "title": "Rebel Music (Live)", + "cleanTitle": "Rebel Music (Live)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered)", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:06:07.9840000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 5 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-206-i_shot_the_sheriff_(live)-rns.mp3", + "fileTrackInfo": { + "title": "I Shot The Sheriff (Live)", + "cleanTitle": "I Shot The Sheriff (Live)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered)", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:06:33.6500000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 6 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-207-want_more_(live)-rns.mp3", + "fileTrackInfo": { + "title": "Want More (Live)", + "cleanTitle": "Want More (Live)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered)", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:07:02.2890000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 7 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-208-no_woman_no_cry_(live)-rns.mp3", + "fileTrackInfo": { + "title": "No Woman No Cry (Live)", + "cleanTitle": "No Woman No Cry (Live)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered)", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:05:18.9210000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 8 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-209-lively_up_yourself_(live)-rns.mp3", + "fileTrackInfo": { + "title": "Lively Up Yourself (Live)", + "cleanTitle": "Lively Up Yourself (Live)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered)", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:05:44.1700000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 9 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-210-roots_rock_reggae_(live)-rns.mp3", + "fileTrackInfo": { + "title": "Roots Rock Reggae (Live)", + "cleanTitle": "Roots Rock Reggae (Live)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered)", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:05:32.2230000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 10 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-211-rat_race_(live)-rns.mp3", + "fileTrackInfo": { + "title": "Rat Race (Live)", + "cleanTitle": "Rat Race (Live)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered)", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:07:53.4390000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 11 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-212-smile_jamica_(part_one_live)-rns.mp3", + "fileTrackInfo": { + "title": "Smile Jamica (Part One Live)", + "cleanTitle": "Smile Jamica (Part One Live)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered)", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:03:18.9900000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 12 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-213-smile_jamica_(part_2_live)-rns.mp3", + "fileTrackInfo": { + "title": "Smile Jamica (Part 2 Live)", + "cleanTitle": "Smile Jamica (Part 2 Live)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered)", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:03:09.8870000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 13 + ] + } + } + ] +} diff --git a/src/NzbDrone.Core.Test/Files/Identification/PenalizeUnknownMedia.json b/src/NzbDrone.Core.Test/Files/Identification/PenalizeUnknownMedia.json new file mode 100644 index 000000000..f9b486449 --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/Identification/PenalizeUnknownMedia.json @@ -0,0 +1,194 @@ +{ + "expectedMusicBrainzReleaseIds": [ + "0ce2d66f-e871-415a-9a85-e564f99d4021" + ], + "libraryArtists": [ + { + "artist": "7ac055fa-e357-4890-9098-010b8094a900", + "metadataProfile": { + "name": "Standard", + "primaryAlbumTypes": [ + { + "primaryAlbumType": { + "id": 2, + "name": "Single" + }, + "allowed": true + }, + { + "primaryAlbumType": { + "id": 4, + "name": "Other" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 1, + "name": "EP" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 3, + "name": "Broadcast" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 0, + "name": "Album" + }, + "allowed": true + } + ], + "secondaryAlbumTypes": [ + { + "secondaryAlbumType": { + "id": 0, + "name": "Studio" + }, + "allowed": true + }, + { + "secondaryAlbumType": { + "id": 3, + "name": "Spokenword" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 2, + "name": "Soundtrack" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 7, + "name": "Remix" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 9, + "name": "Mixtape/Street" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 6, + "name": "Live" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 4, + "name": "Interview" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 8, + "name": "DJ-mix" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 10, + "name": "Demo" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 1, + "name": "Compilation" + }, + "allowed": false + } + ], + "releaseStatuses": [ + { + "releaseStatus": { + "id": 3, + "name": "Pseudo" + }, + "allowed": false + }, + { + "releaseStatus": { + "id": 1, + "name": "Promotion" + }, + "allowed": false + }, + { + "releaseStatus": { + "id": 0, + "name": "Official" + }, + "allowed": true + }, + { + "releaseStatus": { + "id": 2, + "name": "Bootleg" + }, + "allowed": false + } + ], + "id": 1 + } + } + ], + "newDownload": false, + "singleRelease": false, + "tracks": [ + { + "path": "D:\\Test2\\Alabama\\The Touch\\06 Touch Me When We're Dancing.mp3", + "fileTrackInfo": { + "title": "Touch Me When We're Dancing", + "cleanTitle": "Touch Me When We're Dancing", + "artistTitle": "Alabama", + "albumTitle": "The Touch", + "artistTitleInfo": { + "title": "Alabama", + "year": 1986 + }, + "discNumber": 0, + "discCount": 0, + "year": 1986, + "duration": "00:03:43.2950000", + "quality": { + "quality": { + "id": 2, + "name": "MP3-VBR-V0" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3 VBR", + "audioBitrate": 161, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 6 + ] + } + } + ] +} diff --git a/src/NzbDrone.Core.Test/Files/Identification/PreferMissingToBadMatch.json b/src/NzbDrone.Core.Test/Files/Identification/PreferMissingToBadMatch.json new file mode 100644 index 000000000..6f3302637 --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/Identification/PreferMissingToBadMatch.json @@ -0,0 +1,231 @@ +{ + "expectedMusicBrainzReleaseIds": [ + "25f0fa1b-ae04-479a-a182-18a655ff6040" + ], + "libraryArtists": [ + { + "artist": "70248960-cb53-4ea4-943a-edb18f7d336f", + "metadataProfile": { + "name": "Album+Single", + "primaryAlbumTypes": [ + { + "primaryAlbumType": { + "id": 4, + "name": "Other" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 3, + "name": "Broadcast" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 2, + "name": "Single" + }, + "allowed": true + }, + { + "primaryAlbumType": { + "id": 1, + "name": "EP" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 0, + "name": "Album" + }, + "allowed": true + } + ], + "secondaryAlbumTypes": [ + { + "secondaryAlbumType": { + "id": 10, + "name": "Demo" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 9, + "name": "Mixtape/Street" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 8, + "name": "DJ-mix" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 7, + "name": "Remix" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 6, + "name": "Live" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 4, + "name": "Interview" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 3, + "name": "Spokenword" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 2, + "name": "Soundtrack" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 1, + "name": "Compilation" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 0, + "name": "Studio" + }, + "allowed": true + } + ], + "releaseStatuses": [ + { + "releaseStatus": { + "id": 3, + "name": "Pseudo" + }, + "allowed": false + }, + { + "releaseStatus": { + "id": 2, + "name": "Bootleg" + }, + "allowed": false + }, + { + "releaseStatus": { + "id": 1, + "name": "Promotion" + }, + "allowed": false + }, + { + "releaseStatus": { + "id": 0, + "name": "Official" + }, + "allowed": true + } + ], + "id": 2 + } + } + ], + "newDownload": true, + "singleRelease": false, + "tracks": [ + { + "path": "/mnt/data1/LidarrTest/Bruce Springsteen/Album/10_Glory_Days.mp3", + "fileTrackInfo": { + "title": "Glory Days", + "cleanTitle": "Glory Days", + "artistTitle": "Bruce Springsteen", + "albumTitle": "Born in the U.S.A.", + "artistTitleInfo": { + "title": "Bruce Springsteen", + "year": 1984 + }, + "discNumber": 0, + "discCount": 0, + "year": 1984, + "duration": "00:04:18.0680000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 10 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Bruce Springsteen/Album/11_Dancing_In_The_Dark.mp3", + "fileTrackInfo": { + "title": "Dancing In The Dark", + "cleanTitle": "Dancing In The Dark", + "artistTitle": "Bruce Springsteen", + "albumTitle": "Born in the U.S.A.", + "artistTitleInfo": { + "title": "Bruce Springsteen", + "year": 1984 + }, + "discNumber": 0, + "discCount": 0, + "year": 1984, + "duration": "00:04:03.0450000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 11 + ] + } + } + ] +} diff --git a/src/NzbDrone.Core.Test/Files/Identification/SucceedWhenManyAlbumsHaveSameTitle.json b/src/NzbDrone.Core.Test/Files/Identification/SucceedWhenManyAlbumsHaveSameTitle.json new file mode 100644 index 000000000..d8fcdd9f5 --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/Identification/SucceedWhenManyAlbumsHaveSameTitle.json @@ -0,0 +1,537 @@ +{ + "expectedMusicBrainzReleaseIds": [ + "4e2dd34f-53fe-4d54-b564-b14a2871505e" + ], + "libraryArtists": [ + { + "artist": "6fe07aa5-fec0-4eca-a456-f29bff451b04", + "metadataProfile": { + "name": "Standard", + "primaryAlbumTypes": [ + { + "primaryAlbumType": { + "id": 2, + "name": "Single" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 4, + "name": "Other" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 1, + "name": "EP" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 3, + "name": "Broadcast" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 0, + "name": "Album" + }, + "allowed": true + } + ], + "secondaryAlbumTypes": [ + { + "secondaryAlbumType": { + "id": 0, + "name": "Studio" + }, + "allowed": true + }, + { + "secondaryAlbumType": { + "id": 3, + "name": "Spokenword" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 2, + "name": "Soundtrack" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 7, + "name": "Remix" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 9, + "name": "Mixtape/Street" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 6, + "name": "Live" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 4, + "name": "Interview" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 8, + "name": "DJ-mix" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 10, + "name": "Demo" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 1, + "name": "Compilation" + }, + "allowed": false + } + ], + "releaseStatuses": [ + { + "releaseStatus": { + "id": 3, + "name": "Pseudo" + }, + "allowed": false + }, + { + "releaseStatus": { + "id": 1, + "name": "Promotion" + }, + "allowed": false + }, + { + "releaseStatus": { + "id": 0, + "name": "Official" + }, + "allowed": true + }, + { + "releaseStatus": { + "id": 2, + "name": "Bootleg" + }, + "allowed": false + } + ], + "id": 1 + } + } + ], + "newDownload": false, + "singleRelease": false, + "tracks": [ + { + "path": "/mnt/data1/LidarrTest/Weezer/Weezer (2019)/01-weezer-africa-0f640cbf.mp3", + "fileTrackInfo": { + "title": "Africa", + "cleanTitle": "Africa", + "artistTitle": "Weezer", + "albumTitle": "Weezer (Teal Album)", + "artistTitleInfo": { + "title": "Weezer", + "year": 2019 + }, + "discNumber": 0, + "discCount": 0, + "year": 2019, + "label": "Crush Music / Atlantic ", + "duration": "00:03:58.6850000", + "quality": { + "quality": { + "id": 4, + "name": "MP3-320" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 320, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 1 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Weezer/Weezer (2019)/02-weezer-everybody_wants_to_rule_the_world-efc2a5b4.mp3", + "fileTrackInfo": { + "title": "Everybody Wants To Rule The World", + "cleanTitle": "Everybody Wants To Rule The World", + "artistTitle": "Weezer", + "albumTitle": "Weezer (Teal Album)", + "artistTitleInfo": { + "title": "Weezer", + "year": 2019 + }, + "discNumber": 0, + "discCount": 0, + "year": 2019, + "label": "Crush Music / Atlantic ", + "duration": "00:04:04.8960000", + "quality": { + "quality": { + "id": 4, + "name": "MP3-320" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 320, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 2 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Weezer/Weezer (2019)/03-weezer-sweet_dreams_(are_made_of_this)-a8a934a6.mp3", + "fileTrackInfo": { + "title": "Sweet Dreams (Are Made Of This)", + "cleanTitle": "Sweet Dreams (Are Made Of This)", + "artistTitle": "Weezer", + "albumTitle": "Weezer (Teal Album)", + "artistTitleInfo": { + "title": "Weezer", + "year": 2019 + }, + "discNumber": 0, + "discCount": 0, + "year": 2019, + "label": "Crush Music / Atlantic ", + "duration": "00:03:34.8550000", + "quality": { + "quality": { + "id": 4, + "name": "MP3-320" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 320, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 3 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Weezer/Weezer (2019)/04-weezer-take_on_me-5698a04c.mp3", + "fileTrackInfo": { + "title": "Take On Me", + "cleanTitle": "Take On Me", + "artistTitle": "Weezer", + "albumTitle": "Weezer (Teal Album)", + "artistTitleInfo": { + "title": "Weezer", + "year": 2019 + }, + "discNumber": 0, + "discCount": 0, + "year": 2019, + "label": "Crush Music / Atlantic ", + "duration": "00:03:43.6510000", + "quality": { + "quality": { + "id": 4, + "name": "MP3-320" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 320, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 4 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Weezer/Weezer (2019)/05-weezer-happy_together-dd30d8d4.mp3", + "fileTrackInfo": { + "title": "Happy Together", + "cleanTitle": "Happy Together", + "artistTitle": "Weezer", + "albumTitle": "Weezer (Teal Album)", + "artistTitleInfo": { + "title": "Weezer", + "year": 2019 + }, + "discNumber": 0, + "discCount": 0, + "year": 2019, + "label": "Crush Music / Atlantic ", + "duration": "00:02:25.7160000", + "quality": { + "quality": { + "id": 4, + "name": "MP3-320" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 320, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 5 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Weezer/Weezer (2019)/06-weezer-paranoid-d0617671.mp3", + "fileTrackInfo": { + "title": "Paranoid", + "cleanTitle": "Paranoid", + "artistTitle": "Weezer", + "albumTitle": "Weezer (Teal Album)", + "artistTitleInfo": { + "title": "Weezer", + "year": 2019 + }, + "discNumber": 0, + "discCount": 0, + "year": 2019, + "label": "Crush Music / Atlantic ", + "duration": "00:02:44.9260000", + "quality": { + "quality": { + "id": 4, + "name": "MP3-320" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 320, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 6 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Weezer/Weezer (2019)/07-weezer-mr_blue_sky-e3e44f02.mp3", + "fileTrackInfo": { + "title": "Mr. Blue Sky", + "cleanTitle": "Mr. Blue Sky", + "artistTitle": "Weezer", + "albumTitle": "Weezer (Teal Album)", + "artistTitleInfo": { + "title": "Weezer", + "year": 2019 + }, + "discNumber": 0, + "discCount": 0, + "year": 2019, + "label": "Crush Music / Atlantic ", + "duration": "00:04:46.4210000", + "quality": { + "quality": { + "id": 4, + "name": "MP3-320" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 320, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 7 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Weezer/Weezer (2019)/08-weezer-no_scrubs-577fa9fa.mp3", + "fileTrackInfo": { + "title": "No Scrubs", + "cleanTitle": "No Scrubs", + "artistTitle": "Weezer", + "albumTitle": "Weezer (Teal Album)", + "artistTitleInfo": { + "title": "Weezer", + "year": 2019 + }, + "discNumber": 0, + "discCount": 0, + "year": 2019, + "label": "Crush Music / Atlantic ", + "duration": "00:03:10.3730000", + "quality": { + "quality": { + "id": 4, + "name": "MP3-320" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 320, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 8 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Weezer/Weezer (2019)/09-weezer-billie_jean-9c35bbda.mp3", + "fileTrackInfo": { + "title": "Billie Jean", + "cleanTitle": "Billie Jean", + "artistTitle": "Weezer", + "albumTitle": "Weezer (Teal Album)", + "artistTitleInfo": { + "title": "Weezer", + "year": 2019 + }, + "discNumber": 0, + "discCount": 0, + "year": 2019, + "label": "Crush Music / Atlantic ", + "duration": "00:04:54.1990000", + "quality": { + "quality": { + "id": 4, + "name": "MP3-320" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 320, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 9 + ] + } + }, + { + "path": "/mnt/data1/LidarrTest/Weezer/Weezer (2019)/10-weezer-stand_by_me-396f336f.mp3", + "fileTrackInfo": { + "title": "Stand By Me", + "cleanTitle": "Stand By Me", + "artistTitle": "Weezer", + "albumTitle": "Weezer (Teal Album)", + "artistTitleInfo": { + "title": "Weezer", + "year": 2019 + }, + "discNumber": 0, + "discCount": 0, + "year": 2019, + "label": "Crush Music / Atlantic ", + "duration": "00:03:00.9770000", + "quality": { + "quality": { + "id": 4, + "name": "MP3-320" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 320, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 10 + ] + } + } + ] +} diff --git a/src/NzbDrone.Core.Test/Files/Indexers/BitMeTv/BitMeTv.xml b/src/NzbDrone.Core.Test/Files/Indexers/BitMeTv/BitMeTv.xml deleted file mode 100644 index 345c51c87..000000000 --- a/src/NzbDrone.Core.Test/Files/Indexers/BitMeTv/BitMeTv.xml +++ /dev/null @@ -1,65 +0,0 @@ - - - - 10 - BitMeTV.ORG - http://www.bitmetv.org - This is a private - by registration only - website. You can help keep it alive by donating: http://www.bitmetv.org/donate.php - en-usde - Copyright © 2004 - 2007 BitMeTV.ORG - noreply@bitmetv.org - - BitMeTV.ORG - http://www.bitmetv.org/favicon.ico - http://www.bitmetv.org - 16 - 16 - This is a private - by registration only - website. You can help keep it alive by donating: http://www.bitmetv.org/donate.php - - - Total.Divas.S02E08.HDTV.x264-CRiMSON - http://www.bitmetv.org/download.php/12/Total.Divas.S02E08.HDTV.x264-CRiMSON.torrent - Tue, 13 May 2014 17:04:29 -0000 - - Category: (Reality TV - Un-scripted) - Size: 376.71 MB - - - - Aqua.Teen.Hunger.Force.S10.INTERNAL.HDTV.x264-BitMeTV - http://www.bitmetv.org/download.php/34/Aqua.Teen.Hunger.Force.S10.INTERNAL.HDTV.x264-BitMeTV.torrent - Tue, 13 May 2014 17:03:12 -0000 - - Category: (Adult Swim) - Size: 725.46 MB - - - - Antiques.Roadshow.US.S18E16.720p.HDTV.x264-BAJSKORV - http://www.bitmetv.org/download.php/56/Antiques.Roadshow.US.S18E16.720p.HDTV.x264-BAJSKORV.torrent - Tue, 13 May 2014 16:47:05 -0000 - - Category: (Reality TV - Un-scripted) - Size: 960.15 MB - - - - Seth.Meyers.2014.05.12.Chris.O.Dowd-Emma.Roberts.HDTV.x264-CROOKS - http://www.bitmetv.org/download.php/78/Seth.Meyers.2014.05.12.Chris.O.Dowd-Emma.Roberts.HDTV.x264-CROOKS.torrent - Tue, 13 May 2014 16:01:21 -0000 - - Category: Seth Meyers - Size: 301.31 MB - - - - The.Mole.Australia.Season.4 - http://www.bitmetv.org/download.php/910/The%20Mole%20Australia%20-%20Season%204.torrent - Tue, 13 May 2014 15:52:54 -0000 - - Category: (Reality TV - Competitive) - Size: 2.13 GB - - - - diff --git a/src/NzbDrone.Core.Test/Files/Indexers/BroadcastheNet/RecentFeed.json b/src/NzbDrone.Core.Test/Files/Indexers/BroadcastheNet/RecentFeed.json deleted file mode 100644 index 9ac55ee7c..000000000 --- a/src/NzbDrone.Core.Test/Files/Indexers/BroadcastheNet/RecentFeed.json +++ /dev/null @@ -1,61 +0,0 @@ -{ -"id":"9787693d", -"result":{ -"torrents":{ -"123":{ -"GroupName":"2014.09.15", -"GroupID":"237457", -"TorrentID":"123", -"SeriesID":"1034", -"Series":"Jimmy Kimmel Live", -"SeriesBanner":"https:\/\/cdn2.broadcasthe.net\/tvdb\/banners\/graphical\/71998-g.jpg", -"SeriesPoster":"https:\/\/cdn2.broadcasthe.net\/tvdb\/banners\/posters\/71998-3.jpg", -"YoutubeTrailer":"http:\/\/www.youtube.com\/v\/w3NwB9PLxss", -"Category":"Episode", -"Snatched":"40", -"Seeders":"40", -"Leechers":"9", -"Source":"HDTV", -"Container":"MP4", -"Codec":"x264", -"Resolution":"SD", -"Origin":"Scene", -"ReleaseName":"Jimmy.Kimmel.2014.09.15.Jane.Fonda.HDTV.x264-aAF", -"Size":"505099926", -"Time":"1410902133", -"TvdbID":"71998", -"TvrageID":"4055", -"ImdbID":"0320037", -"InfoHash":"123", -"DownloadURL":"https:\/\/broadcasthe.net\/torrents.php?action=download&id=123&authkey=123&torrent_pass=123" -}, -"1234":{ -"GroupName":"S01E02", -"GroupID":"237456", -"TorrentID":"1234", -"SeriesID":"45853", -"Series":"Mammon", -"SeriesBanner":"https:\/\/cdn2.broadcasthe.net\/tvdb\/banners\/text\/274366.jpg", -"SeriesPoster":"\/\/cdn2.broadcasthe.net\/tvdb\/banners\/posters\/274366-2.jpg", -"YoutubeTrailer":"http:\/\/www.youtube.com\/v\/1VVbJecvHr8", -"Category":"Episode", -"Snatched":"0", -"Seeders":"1", -"Leechers":"23", -"Source":"HDTV", -"Container":"TS", -"Codec":"h.264", -"Resolution":"1080i", -"Origin":"Internal", -"ReleaseName":"Mammon.S01E02.1080i.HDTV.H.264-Irishman", -"Size":"4021238596", -"Time":"1410901918", -"TvdbID":"274366", -"TvrageID":"38472", -"ImdbID":"2377081", -"InfoHash":"1234", -"DownloadURL":"https:\/\/broadcasthe.net\/torrents.php?action=download&id=1234&authkey=1234&torrent_pass=1234" -}}, -"results":"117927" -} -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/Files/Indexers/Fanzub/fanzub.xml b/src/NzbDrone.Core.Test/Files/Indexers/Fanzub/fanzub.xml deleted file mode 100644 index bd4175086..000000000 --- a/src/NzbDrone.Core.Test/Files/Indexers/Fanzub/fanzub.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - Anime :: Fanzub - http://www.fanzub.com/ - A Usenet Search Engine for Japanese Media - en-us - - - [Vivid] Hanayamata - 10 [A33D6606] - http://fanzub.com/nzb/296464 - <i>Age</i>: 0 days<br /><i>Size</i>: 530.48 MiB<br /><i>Parts</i>: 100%<br /><i>Files</i>: 1 other, 8 par2<br /><i>Subject</i>: [9/9] [Vivid] Hanayamata - 10 [A33D6606].vol63+27.par2 (1/28) - Anime - Sat, 13 Sep 2014 12:56:53 +0000 - - http://fanzub.com/nzb/296464 - - - (Sniper2000) - Pokemon HD - XY 37 - http://fanzub.com/nzb/296456 - <i>Age</i>: 0 days<br /><i>Size</i>: 2.79 GiB<br /><i>Parts</i>: 100%<br /><i>Files</i>: 1 nzb, 1 other, 77 par2, 30 rar<br /><i>Subject</i>: (Sniper2000) [108/108] - "XY 37.vol183+176.PAR2"Pokemon HD (1/272) - Anime - Sat, 13 Sep 2014 12:38:03 +0000 - - http://fanzub.com/nzb/296456 - - - [HorribleSubs] Kindaichi Case Files R - 23 [480p].mkv - http://fanzub.com/nzb/296472 - <i>Age</i>: 0 days<br /><i>Size</i>: 153.87 MiB<br /><i>Parts</i>: 100%<br /><i>Files</i>: 7 par2, 6 split<br /><i>Subject</i>: [HorribleSubs] Kindaichi Case Files R - 23 [480p] [13/13] - "[HorribleSubs] Kindaichi Case Files R - 23 [480p].mkv.vol31+06.par2" yEnc (1/7) - Anime - Sat, 13 Sep 2014 11:51:59 +0000 - - http://fanzub.com/nzb/296472 - - - diff --git a/src/NzbDrone.Core.Test/Files/Indexers/Gazelle/Gazelle.json b/src/NzbDrone.Core.Test/Files/Indexers/Gazelle/Gazelle.json new file mode 100644 index 000000000..f357c1cbd --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/Indexers/Gazelle/Gazelle.json @@ -0,0 +1,166 @@ +{ + "status": "success", + "response": { + "currentPage": 1, + "pages": 1, + "results": [ + { + "groupId": 106951, + "groupName": "Shania Twain", + "artist": "Shania Twain", + "cover": "https:\\/\\/ptpimg.me\\/460az6.jpg", + "tags": [ + "rock", + "country" + ], + "bookmarked": false, + "vanityHouse": false, + "groupYear": 1993, + "releaseType": "Album", + "groupTime": "1512951473", + "maxSize": 653734702, + "totalSnatched": 33, + "totalSeeders": 27, + "totalLeechers": 0, + "torrents": [ + { + "torrentId": 194008, + "editionId": 1, + "artists": [ + { + "id": 9504, + "name": "Shania Twain", + "aliasid": 9506 + } + ], + "remastered": false, + "remasterYear": 0, + "remasterCatalogueNumber": "", + "remasterTitle": "", + "media": "CD", + "encoding": "Lossless", + "format": "FLAC", + "hasLog": true, + "logScore": 100, + "hasCue": true, + "scene": false, + "vanityHouse": false, + "fileCount": 14, + "time": "2016-12-02 16:02:39", + "size": 198741350, + "snatches": 19, + "seeders": 17, + "leechers": 0, + "isFreeleech": false, + "isNeutralLeech": false, + "isPersonalFreeleech": false, + "canUseToken": false, + "hasSnatched": false + }, + { + "torrentId": 230096, + "editionId": 1, + "artists": [ + { + "id": 9504, + "name": "Shania Twain", + "aliasid": 9506 + } + ], + "remastered": false, + "remasterYear": 0, + "remasterCatalogueNumber": "", + "remasterTitle": "", + "media": "CD", + "encoding": "320", + "format": "MP3", + "hasLog": false, + "logScore": 0, + "hasCue": false, + "scene": false, + "vanityHouse": false, + "fileCount": 13, + "time": "2016-12-03 21:46:04", + "size": 74275018, + "snatches": 3, + "seeders": 3, + "leechers": 0, + "isFreeleech": false, + "isNeutralLeech": false, + "isPersonalFreeleech": false, + "canUseToken": false, + "hasSnatched": false + }, + { + "torrentId": 230086, + "editionId": 1, + "artists": [ + { + "id": 9504, + "name": "Shania Twain", + "aliasid": 9506 + } + ], + "remastered": false, + "remasterYear": 0, + "remasterCatalogueNumber": "", + "remasterTitle": "", + "media": "CD", + "encoding": "V0 (VBR)", + "format": "MP3", + "hasLog": false, + "logScore": 0, + "hasCue": false, + "scene": false, + "vanityHouse": false, + "fileCount": 13, + "time": "2016-12-03 21:45:41", + "size": 61191629, + "snatches": 7, + "seeders": 5, + "leechers": 0, + "isFreeleech": false, + "isNeutralLeech": false, + "isPersonalFreeleech": false, + "canUseToken": false, + "hasSnatched": false + }, + { + "torrentId": 1541452, + "editionId": 2, + "artists": [ + { + "id": 9504, + "name": "Shania Twain", + "aliasid": 9506 + } + ], + "remastered": true, + "remasterYear": 2017, + "remasterCatalogueNumber": "", + "remasterTitle": "", + "media": "WEB", + "encoding": "24bit Lossless", + "format": "FLAC", + "hasLog": false, + "logScore": 0, + "hasCue": false, + "scene": false, + "vanityHouse": false, + "fileCount": 13, + "time": "2017-12-11 00:17:53", + "size": 653734702, + "snatches": 4, + "seeders": 2, + "leechers": 0, + "isFreeleech": false, + "isNeutralLeech": false, + "isPersonalFreeleech": false, + "canUseToken": false, + "hasSnatched": false + } + ] + } + ] + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/Files/Indexers/Gazelle/GazelleIndex.json b/src/NzbDrone.Core.Test/Files/Indexers/Gazelle/GazelleIndex.json new file mode 100644 index 000000000..ea3406bde --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/Indexers/Gazelle/GazelleIndex.json @@ -0,0 +1,22 @@ +{ + "status": "success", + "response": { + "username": "dr4g0n", + "id": 469, + "authkey": "redacted", + "passkey": "redacted", + "notifications": { + "messages": 0, + "notifications": 9000, + "newAnnouncement": false, + "newBlog": false + }, + "userstats": { + "uploaded": 585564424629, + "downloaded": 177461229738, + "ratio": 3.29, + "requiredratio": 0.6, + "class": "VIP" + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/Files/Indexers/HdBits/RecentFeedLongIDs.json b/src/NzbDrone.Core.Test/Files/Indexers/HdBits/RecentFeedLongIDs.json deleted file mode 100644 index 9e4b114f7..000000000 --- a/src/NzbDrone.Core.Test/Files/Indexers/HdBits/RecentFeedLongIDs.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "status": 0, - "data": [ - { - "id": 257142, - "hash": "EABC50AEF9F53CEDED84ADF14144D3368E586F3A", - "leechers": 1, - "seeders": 46, - "name": "Supernatural S10E17 1080p WEB-DL DD5.1 H.264-ECI", - "times_completed": 49, - "size": 1718009717, - "utadded": 1428179446, - "added": "2015-04-04T20:30:46+0000", - "comments": 0, - "numfiles": 1, - "filename": "Supernatural.S10E17.1080p.WEB-DL.DD5.1.H.264-ECI.torrent", - "freeleech": "no", - "type_category": 2, - "type_codec": 1, - "type_medium": 6, - "type_origin": 0, - "username": "abc", - "owner": 1107944, - "tvdb": { - "id": 78901, - "season": 10, - "episode": 17 - } - }, - { - "id": 257140, - "hash": "BE3BA5396B9A30544353B55FDD89EDE46C8FB72A", - "leechers": 0, - "seeders": 18, - "name": "Scandal S04E18 1080p WEB-DL DD5.1 H.264-ECI", - "times_completed": 19, - "size": 1789106197, - "utadded": 1428179128, - "added": "2015-04-04T20:25:28+0000", - "comments": 0, - "numfiles": 1, - "filename": "Scandal.2012.S04E18.1080p.WEB-DL.DD5.1.H.264-ECI.torrent", - "freeleech": "no", - "type_category": 2, - "type_codec": 1, - "type_medium": 6, - "type_origin": 0, - "username": "abc", - "owner": 1107944, - "tvdb": { - "id": 248841, - "season": 4, - "episode": 18 - } - } - ] -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/Files/Indexers/HdBits/RecentFeedStringIDs.json b/src/NzbDrone.Core.Test/Files/Indexers/HdBits/RecentFeedStringIDs.json deleted file mode 100644 index 2c533f5c4..000000000 --- a/src/NzbDrone.Core.Test/Files/Indexers/HdBits/RecentFeedStringIDs.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "status": 0, - "data": [ - { - "id": "257142", - "hash": "EABC50AEF9F53CEDED84ADF14144D3368E586F3A", - "leechers": 1, - "seeders": 46, - "name": "Supernatural S10E17 1080p WEB-DL DD5.1 H.264-ECI", - "times_completed": 49, - "size": 1718009717, - "utadded": 1428179446, - "added": "2015-04-04T20:30:46+0000", - "comments": 0, - "numfiles": 1, - "filename": "Supernatural.S10E17.1080p.WEB-DL.DD5.1.H.264-ECI.torrent", - "freeleech": "no", - "type_category": 2, - "type_codec": 1, - "type_medium": 6, - "type_origin": 0, - "username": "abc", - "owner": 1107944, - "tvdb": { - "id": 78901, - "season": 10, - "episode": 17 - } - }, - { - "id": "257140", - "hash": "BE3BA5396B9A30544353B55FDD89EDE46C8FB72A", - "leechers": 0, - "seeders": 18, - "name": "Scandal S04E18 1080p WEB-DL DD5.1 H.264-ECI", - "times_completed": 19, - "size": 1789106197, - "utadded": 1428179128, - "added": "2015-04-04T20:25:28+0000", - "comments": 0, - "numfiles": 1, - "filename": "Scandal.2012.S04E18.1080p.WEB-DL.DD5.1.H.264-ECI.torrent", - "freeleech": "no", - "type_category": 2, - "type_codec": 1, - "type_medium": 6, - "type_origin": 0, - "username": "abc", - "owner": 1107944, - "tvdb": { - "id": 248841, - "season": 4, - "episode": 18 - } - } - ] -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/Files/Indexers/Headphones/Headphones.xml b/src/NzbDrone.Core.Test/Files/Indexers/Headphones/Headphones.xml new file mode 100644 index 000000000..a1129b1a7 --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/Indexers/Headphones/Headphones.xml @@ -0,0 +1,377 @@ + + + + Headphones Indexer + powered by pynab + https://indexer.codeshy.com + + + + Lady Gaga Born This Way 2CD FLAC 2011 WRE + https://indexer.codeshy.com/details/123456 + https://indexer.codeshy.com/api?t=g&guid=123456&apikey=123456789 + Tue, 13 Sep 2016 14:39:52 -0000 + Audio > Lossless + Lady Gaga Born This Way 2CD FLAC 2011 WRE + Sun, 02 Jun 2013 08:58:54 -0000 + alt.binaries.sounds.flac + + 146 + + + + + + + + + + 917347414 + + + + Lady Gaga Born This Way PROMO CDR2 FLAC 2011 WRE + https://indexer.codeshy.com/details/178728 + https://indexer.codeshy.com/api?t=g&guid=178728&apikey=123456789 + Tue, 13 Sep 2016 15:37:26 -0000 + Audio > Lossless + Lady Gaga Born This Way PROMO CDR2 FLAC 2011 WRE + Wed, 19 Sep 2012 18:17:13 -0000 + alt.binaries.sounds.flac + + 4 + + + + + + + + + + 523005229 + + + + Lady Gaga Born This Way PROMO CDR FLAC 2011 WRE + https://indexer.codeshy.com/details/178732 + https://indexer.codeshy.com/api?t=g&guid=178732&apikey=123456789 + Tue, 13 Sep 2016 15:37:27 -0000 + Audio > Lossless + Lady Gaga Born This Way PROMO CDR FLAC 2011 WRE + Wed, 19 Sep 2012 18:12:24 -0000 + alt.binaries.sounds.flac + + 2 + + + + + + + + + + 297599650 + + + + Lady Gaga Born This Way (The Remix) (2011) FLAC + https://indexer.codeshy.com/details/97557 + https://indexer.codeshy.com/api?t=g&guid=97557&apikey=123456789 + Tue, 13 Sep 2016 09:28:03 -0000 + Audio > Lossless + Lady Gaga Born This Way (The Remix) (2011) FLAC + Thu, 17 Nov 2011 15:38:07 -0000 + alt.binaries.sounds.lossless + + 0 + + + + + + + + + + 542418884 + + + + Lady Gaga Born This Way The Remix CD FLAC 2011 EMG + https://indexer.codeshy.com/details/97580 + https://indexer.codeshy.com/api?t=g&guid=97580&apikey=123456789 + Tue, 13 Sep 2016 09:28:05 -0000 + Audio > Lossless + Lady Gaga Born This Way The Remix CD FLAC 2011 EMG + Thu, 17 Nov 2011 09:21:00 -0000 + alt.binaries.sounds.lossless + + 0 + + + + + + + + + + 44608274 + + + + Lady Gaga Born This Way The Remix CD FLAC 2011 EMG + https://indexer.codeshy.com/details/204233 + https://indexer.codeshy.com/api?t=g&guid=204233&apikey=123456789 + Tue, 13 Sep 2016 17:05:21 -0000 + Audio > Lossless + Lady Gaga Born This Way The Remix CD FLAC 2011 EMG + Thu, 17 Nov 2011 07:39:41 -0000 + alt.binaries.sounds.flac + + 0 + + + + + + + + + + 42548396 + + + + Lady Gaga Born This Way The Remix CD FLAC 2011 EMG + https://indexer.codeshy.com/details/204234 + https://indexer.codeshy.com/api?t=g&guid=204234&apikey=123456789 + Tue, 13 Sep 2016 17:05:22 -0000 + Audio > Lossless + Lady Gaga Born This Way The Remix CD FLAC 2011 EMG + Thu, 17 Nov 2011 07:38:23 -0000 + alt.binaries.sounds.flac + + 0 + + + + + + + + + + 514617494 + + + + Lady Gaga Born This Way The Remix CD FLAC 2011 EMG + https://indexer.codeshy.com/details/204235 + https://indexer.codeshy.com/api?t=g&guid=204235&apikey=123456789 + Tue, 13 Sep 2016 17:05:22 -0000 + Audio > Lossless + Lady Gaga Born This Way The Remix CD FLAC 2011 EMG + Thu, 17 Nov 2011 07:37:20 -0000 + alt.binaries.sounds.flac + + 0 + + + + + + + + + + 541983521 + + + + Lady Gaga Born This Way (The Remix) 2011 pLAN9 + https://indexer.codeshy.com/details/101273 + https://indexer.codeshy.com/api?t=g&guid=101273&apikey=123456789 + Tue, 13 Sep 2016 10:45:43 -0000 + Audio > MP3 + Lady Gaga Born This Way (The Remix) 2011 pLAN9 + Wed, 16 Nov 2011 23:19:32 -0000 + alt.binaries.sounds.mp3 + + 5 + + + + + + + + + + 12390648 + + + + Lady GaGa Born This Way (Special Edition) 2CD FLAC 2011 PERFECT + https://indexer.codeshy.com/details/214301 + https://indexer.codeshy.com/api?t=g&guid=214301&apikey=123456789 + Tue, 13 Sep 2016 17:38:58 -0000 + Audio > Lossless + Lady GaGa Born This Way (Special Edition) 2CD FLAC 2011 PERFECT + Mon, 17 Oct 2011 16:43:14 -0000 + alt.binaries.sounds.flac + + 5 + + + + + + + + + + 823716079 + + + + Lady GaGa Born This Way Bonus Track CD FLAC 2011 PERFECT + https://indexer.codeshy.com/details/214424 + https://indexer.codeshy.com/api?t=g&guid=214424&apikey=123456789 + Tue, 13 Sep 2016 17:39:21 -0000 + Audio > Lossless + Lady GaGa Born This Way Bonus Track CD FLAC 2011 PERFECT + Mon, 17 Oct 2011 03:37:35 -0000 + alt.binaries.sounds.flac + + 0 + + + + + + + + + + 38894529 + + + + Lady Gaga Born This Way CDM FLAC 2011 WRE + https://indexer.codeshy.com/details/214428 + https://indexer.codeshy.com/api?t=g&guid=214428&apikey=123456789 + Tue, 13 Sep 2016 17:39:22 -0000 + Audio > Lossless + Lady Gaga Born This Way CDM FLAC 2011 WRE + Mon, 17 Oct 2011 03:36:31 -0000 + alt.binaries.sounds.flac + + 1 + + + + + + + + + + 174562763 + + + + Lady GaGa Born This Way Special Edition FLAC + https://indexer.codeshy.com/details/205419 + https://indexer.codeshy.com/api?t=g&guid=205419&apikey=123456789 + Tue, 13 Sep 2016 17:07:40 -0000 + Audio > Lossless + Lady GaGa Born This Way Special Edition FLAC + Tue, 14 Jun 2011 22:06:05 -0000 + alt.binaries.music + + 0 + + + + + + + + + + 8045237 + + + + Lutheria Lady Gaga Born This Way CD1 + https://indexer.codeshy.com/details/205457 + https://indexer.codeshy.com/api?t=g&guid=205457&apikey=123456789 + Tue, 13 Sep 2016 17:07:49 -0000 + Audio > MP3 + Lutheria Lady Gaga Born This Way CD1 + Tue, 31 May 2011 02:04:02 -0000 + alt.binaries.music + + 4 + + + + + + + + + + 4198420 + + + + Lady Gaga Born This Way (New Single) Feb 2011 Mp3ViLLe + https://indexer.codeshy.com/details/24756 + https://indexer.codeshy.com/api?t=g&guid=24756&apikey=123456789 + Tue, 13 Sep 2016 01:29:53 -0000 + Audio > MP3 + Lady Gaga Born This Way (New Single) Feb 2011 Mp3ViLLe + Fri, 11 Mar 2011 11:08:08 -0000 + alt.binaries.sounds.mp3.complete_cd + + 43 + + + + + + + + + + 10301727 + + + + Lady Gaga Born This Way (New Single) Feb 2011 Mp3ViLLe + https://indexer.codeshy.com/details/109954 + https://indexer.codeshy.com/api?t=g&guid=109954&apikey=123456789 + Tue, 13 Sep 2016 11:30:12 -0000 + Audio > MP3 + Lady Gaga Born This Way (New Single) Feb 2011 Mp3ViLLe + Fri, 11 Mar 2011 11:04:06 -0000 + alt.binaries.sounds.mp3 + + 1 + + + + + + + + + + 10301727 + + + diff --git a/src/NzbDrone.Core.Test/Files/Indexers/Newznab/newznab_caps.xml b/src/NzbDrone.Core.Test/Files/Indexers/Newznab/newznab_caps.xml index f340341ca..fbf2f87af 100644 --- a/src/NzbDrone.Core.Test/Files/Indexers/Newznab/newznab_caps.xml +++ b/src/NzbDrone.Core.Test/Files/Indexers/Newznab/newznab_caps.xml @@ -9,15 +9,11 @@ - - - - - - - - - + + + + + diff --git a/src/NzbDrone.Core.Test/Files/Indexers/Newznab/newznab_nzb_su.xml b/src/NzbDrone.Core.Test/Files/Indexers/Newznab/newznab_nzb_su.xml index ea68ee154..6f9d1e4bd 100644 --- a/src/NzbDrone.Core.Test/Files/Indexers/Newznab/newznab_nzb_su.xml +++ b/src/NzbDrone.Core.Test/Files/Indexers/Newznab/newznab_nzb_su.xml @@ -1,1923 +1,2146 @@ - + - - - Nzb.su - Nzb.su Feed - http://nzb.su/ - en-gb - root@nzb.su (Nzb.su) - - - http://nzb.su/views/images/banner.jpg - - Nzb.su - http://nzb.su/ - Visit Nzb.su - indexing usenet one part at a time - - - - - - White.Collar.S03E05.720p.HDTV.X264-DIMENSION - - http://nzb.su/details/24967ef4c2e26296c65d3bbfa97aa8fe - http://nzb.su/getnzb/24967ef4c2e26296c65d3bbfa97aa8fe.nzb&i=37292&r=xxx - http://nzb.su/details/24967ef4c2e26296c65d3bbfa97aa8fe#comments - Mon, 27 Feb 2012 11:09:39 -0500 - TV > HD - White.Collar.S03E05.720p.HDTV.X264-DIMENSION - - - - - - - - - - - - White.Collar.S03E04.720p.HDTV.X264-DIMENSION - - http://nzb.su/details/fab3bed2f4169522c3cb2ef24a6e8a5f - http://nzb.su/getnzb/fab3bed2f4169522c3cb2ef24a6e8a5f.nzb&i=37292&r=xxx - http://nzb.su/details/fab3bed2f4169522c3cb2ef24a6e8a5f#comments - Mon, 27 Feb 2012 11:14:16 -0500 - TV > HD - White.Collar.S03E04.720p.HDTV.X264-DIMENSION - - - - - - - - - - - - White.Collar.S03E03.720p.HDTV.x264-CTU - - http://nzb.su/details/ba12896db486b455706ef5f353a78e81 - http://nzb.su/getnzb/ba12896db486b455706ef5f353a78e81.nzb&i=37292&r=xxx - http://nzb.su/details/ba12896db486b455706ef5f353a78e81#comments - Mon, 27 Feb 2012 11:14:16 -0500 - TV > HD - White.Collar.S03E03.720p.HDTV.x264-CTU - - - - - - - - - - - - White.Collar.S03E02.720p.HDTV.X264-DIMENSION - - http://nzb.su/details/79eacdb15c967465bf6667c46bcff3e4 - http://nzb.su/getnzb/79eacdb15c967465bf6667c46bcff3e4.nzb&i=37292&r=xxx - http://nzb.su/details/79eacdb15c967465bf6667c46bcff3e4#comments - Mon, 27 Feb 2012 11:12:43 -0500 - TV > HD - White.Collar.S03E02.720p.HDTV.X264-DIMENSION - - - - - - - - - - - - White.Collar.S03E07.720p.HDTV.x264-IMMERSE - - http://nzb.su/details/923a97da875283a74127762c061830e1 - http://nzb.su/getnzb/923a97da875283a74127762c061830e1.nzb&i=37292&r=xxx - http://nzb.su/details/923a97da875283a74127762c061830e1#comments - Mon, 27 Feb 2012 11:11:13 -0500 - TV > HD - White.Collar.S03E07.720p.HDTV.x264-IMMERSE - - - - - - - - - - - - White.Collar.S02E14.720p.HDTV.X264-DIMENSION - - http://nzb.su/details/320fe82676c117e1e9c595a4d4cce8ff - http://nzb.su/getnzb/320fe82676c117e1e9c595a4d4cce8ff.nzb&i=37292&r=xxx - http://nzb.su/details/320fe82676c117e1e9c595a4d4cce8ff#comments - Mon, 27 Feb 2012 11:09:39 -0500 - TV > HD - White.Collar.S02E14.720p.HDTV.X264-DIMENSION - - - - - - - - - - - - Head Rush 2010-09-17 Human Conductions 1080i HDTV DD5.1 MPEG2-TrollHD - - http://nzb.su/details/07af6cf4563e3e8c76feb52401d954e2 - http://nzb.su/getnzb/07af6cf4563e3e8c76feb52401d954e2.nzb&i=37292&r=xxx - http://nzb.su/details/07af6cf4563e3e8c76feb52401d954e2#comments - Mon, 27 Feb 2012 10:59:04 -0500 - TV > HD - Head Rush 2010-09-17 Human Conductions 1080i HDTV DD5.1 MPEG2-TrollHD - - - - - - - - - - - - Fringe S04E13 720p WMVHD NeoDweezil - - http://nzb.su/details/3c1e005678df784ae0062ac47e7b4245 - http://nzb.su/getnzb/3c1e005678df784ae0062ac47e7b4245.nzb&i=37292&r=xxx - http://nzb.su/details/3c1e005678df784ae0062ac47e7b4245#comments - Mon, 27 Feb 2012 10:52:11 -0500 - TV > HD - Fringe S04E13 720p WMVHD NeoDweezil - - - - - - - - - - - - The.Indian.Doctor.S02E01.HDTV.x264-TLA - - http://nzb.su/details/3fc0305f87d841eb20a89fac1f8fc17a - http://nzb.su/getnzb/3fc0305f87d841eb20a89fac1f8fc17a.nzb&i=37292&r=xxx - http://nzb.su/details/3fc0305f87d841eb20a89fac1f8fc17a#comments - Mon, 27 Feb 2012 10:39:19 -0500 - TV > SD - The.Indian.Doctor.S02E01.HDTV.x264-TLA - - - - - - - - - - - - Giada at Home GH0412H Pure Comfort 1080i HDTV DD5.1 MPEG2-TrollHD - - http://nzb.su/details/9cfb651bc08b687be3c4e7bb865a78ec - http://nzb.su/getnzb/9cfb651bc08b687be3c4e7bb865a78ec.nzb&i=37292&r=xxx - http://nzb.su/details/9cfb651bc08b687be3c4e7bb865a78ec#comments - Mon, 27 Feb 2012 10:39:18 -0500 - TV > HD - Giada at Home GH0412H Pure Comfort 1080i HDTV DD5.1 MPEG2-TrollHD - - - - - - - - - - - - Black Forest (2012) 1080i HDTV DD5.1 MPEG2-TrollHD - - http://nzb.su/details/f357688542be93d7c258557f9e1d2d52 - http://nzb.su/getnzb/f357688542be93d7c258557f9e1d2d52.nzb&i=37292&r=xxx - http://nzb.su/details/f357688542be93d7c258557f9e1d2d52#comments - Mon, 27 Feb 2012 10:33:46 -0500 - TV > HD - Black Forest (2012) 1080i HDTV DD5.1 MPEG2-TrollHD - - - - - - - - - - - - The.Indian.Doctor.S02E01.720p.HDTV.x264-TLA - - http://nzb.su/details/8df60f1ac194cab27859d21b958704a9 - http://nzb.su/getnzb/8df60f1ac194cab27859d21b958704a9.nzb&i=37292&r=xxx - http://nzb.su/details/8df60f1ac194cab27859d21b958704a9#comments - Mon, 27 Feb 2012 10:21:23 -0500 - TV > HD - The.Indian.Doctor.S02E01.720p.HDTV.x264-TLA - - - - - - - - - - - - American Weed S01E01 Marijuana Drama 720p HDTV DD5.1 MPEG2-TrollHD - - http://nzb.su/details/28283f97bc847e21e19ebefecb7c20ca - http://nzb.su/getnzb/28283f97bc847e21e19ebefecb7c20ca.nzb&i=37292&r=xxx - http://nzb.su/details/28283f97bc847e21e19ebefecb7c20ca#comments - Mon, 27 Feb 2012 10:11:12 -0500 - TV > HD - American Weed S01E01 Marijuana Drama 720p HDTV DD5.1 MPEG2-TrollHD - - - - - - - - - - - - Space.1999.S01E10.1080p.BluRay.x264-aAF - - http://nzb.su/details/e34e9d1d77795786d93b8b3b01cd53b7 - http://nzb.su/getnzb/e34e9d1d77795786d93b8b3b01cd53b7.nzb&i=37292&r=xxx - http://nzb.su/details/e34e9d1d77795786d93b8b3b01cd53b7#comments - Mon, 27 Feb 2012 09:31:29 -0500 - TV > HD - Space.1999.S01E10.1080p.BluRay.x264-aAF - - - - - - - - - - - - Space.1999.S01E09.1080p.BluRay.x264-aAF - - http://nzb.su/details/0c3fea48354250895a2c2f218331d9c8 - http://nzb.su/getnzb/0c3fea48354250895a2c2f218331d9c8.nzb&i=37292&r=xxx - http://nzb.su/details/0c3fea48354250895a2c2f218331d9c8#comments - Mon, 27 Feb 2012 09:28:46 -0500 - TV > HD - Space.1999.S01E09.1080p.BluRay.x264-aAF - - - - - - - - - - - - Space.1999.S01E08.1080p.BluRay.x264-aAF - - http://nzb.su/details/a17fad5ce97f75d13af98a956511c84e - http://nzb.su/getnzb/a17fad5ce97f75d13af98a956511c84e.nzb&i=37292&r=xxx - http://nzb.su/details/a17fad5ce97f75d13af98a956511c84e#comments - Mon, 27 Feb 2012 09:25:55 -0500 - TV > HD - Space.1999.S01E08.1080p.BluRay.x264-aAF - - - - - - - - - - - - Space.1999.S01E07.1080p.BluRay.x264-aAF - - http://nzb.su/details/f64e6740e11f03d3d793f6ec52b64ff9 - http://nzb.su/getnzb/f64e6740e11f03d3d793f6ec52b64ff9.nzb&i=37292&r=xxx - http://nzb.su/details/f64e6740e11f03d3d793f6ec52b64ff9#comments - Mon, 27 Feb 2012 09:21:36 -0500 - TV > HD - Space.1999.S01E07.1080p.BluRay.x264-aAF - - - - - - - - - - - - Space.1999.S01E06.1080p.BluRay.x264-aAF - - http://nzb.su/details/b6aa66e8139b083073f0ca172f1998d0 - http://nzb.su/getnzb/b6aa66e8139b083073f0ca172f1998d0.nzb&i=37292&r=xxx - http://nzb.su/details/b6aa66e8139b083073f0ca172f1998d0#comments - Mon, 27 Feb 2012 09:16:57 -0500 - TV > HD - Space.1999.S01E06.1080p.BluRay.x264-aAF - - - - - - - - - - - - Space.1999.S01E05.1080p.BluRay.x264-aAF - - http://nzb.su/details/12998f81119de5de3c3b27f345bfae39 - http://nzb.su/getnzb/12998f81119de5de3c3b27f345bfae39.nzb&i=37292&r=xxx - http://nzb.su/details/12998f81119de5de3c3b27f345bfae39#comments - Mon, 27 Feb 2012 09:11:46 -0500 - TV > HD - Space.1999.S01E05.1080p.BluRay.x264-aAF - - - - - - - - - - - - My.Kitchen.Rules.AU.S03E17.PDTV.XviD.BF1 - - http://nzb.su/details/453f52cd16c2b2007a9a0e8fabc61d84 - http://nzb.su/getnzb/453f52cd16c2b2007a9a0e8fabc61d84.nzb&i=37292&r=xxx - http://nzb.su/details/453f52cd16c2b2007a9a0e8fabc61d84#comments - Mon, 27 Feb 2012 09:24:25 -0500 - TV > SD - My.Kitchen.Rules.AU.S03E17.PDTV.XviD.BF1 - - - - - - - - - - - - Space.1999.S01E04.1080p.BluRay.x264-aAF - - http://nzb.su/details/35925829150dd7f213a3dae2b185a6d1 - http://nzb.su/getnzb/35925829150dd7f213a3dae2b185a6d1.nzb&i=37292&r=xxx - http://nzb.su/details/35925829150dd7f213a3dae2b185a6d1#comments - Mon, 27 Feb 2012 09:08:45 -0500 - TV > HD - Space.1999.S01E04.1080p.BluRay.x264-aAF - - - - - - - - - - - - Space.1999.S01E03.1080p.BluRay.x264-aAF - - http://nzb.su/details/ac1687c426a101f236efc30af613aff1 - http://nzb.su/getnzb/ac1687c426a101f236efc30af613aff1.nzb&i=37292&r=xxx - http://nzb.su/details/ac1687c426a101f236efc30af613aff1#comments - Mon, 27 Feb 2012 09:02:49 -0500 - TV > HD - Space.1999.S01E03.1080p.BluRay.x264-aAF - - - - - - - - - - - - Space.1999.S01E02.1080p.BluRay.x264-aAF - - http://nzb.su/details/717b6d4423970502927f1cc8fe2a6a3b - http://nzb.su/getnzb/717b6d4423970502927f1cc8fe2a6a3b.nzb&i=37292&r=xxx - http://nzb.su/details/717b6d4423970502927f1cc8fe2a6a3b#comments - Mon, 27 Feb 2012 08:58:21 -0500 - TV > HD - Space.1999.S01E02.1080p.BluRay.x264-aAF - - - - - - - - - - - - Space.1999.S01E01.1080p.BluRay.x264-aAF - - http://nzb.su/details/14e7dc51a27d16f523d4b43c3d976eea - http://nzb.su/getnzb/14e7dc51a27d16f523d4b43c3d976eea.nzb&i=37292&r=xxx - http://nzb.su/details/14e7dc51a27d16f523d4b43c3d976eea#comments - Mon, 27 Feb 2012 08:56:54 -0500 - TV > HD - Space.1999.S01E01.1080p.BluRay.x264-aAF - - - - - - - - - - - - National.Geographic.Forbidden.Tomb.of.Genghis.Khan.720p.HDTV.x264-GeT - - http://nzb.su/details/bd7e1fc46db570ac2e21733f34e44573 - http://nzb.su/getnzb/bd7e1fc46db570ac2e21733f34e44573.nzb&i=37292&r=xxx - http://nzb.su/details/bd7e1fc46db570ac2e21733f34e44573#comments - Mon, 27 Feb 2012 08:18:31 -0500 - TV > HD - National.Geographic.Forbidden.Tomb.of.Genghis.Khan.720p.HDTV.x264-GeT - - - - - - - - - - - - Chicago's Best - Western Suburbs 2 1080i HDTV DD5.1 MPEG2-TrollHD - - http://nzb.su/details/6e948865e3dd4af740a68f944e8afdd3 - http://nzb.su/getnzb/6e948865e3dd4af740a68f944e8afdd3.nzb&i=37292&r=xxx - http://nzb.su/details/6e948865e3dd4af740a68f944e8afdd3#comments - Mon, 27 Feb 2012 08:02:17 -0500 - TV > HD - Chicago's Best - Western Suburbs 2 1080i HDTV DD5.1 MPEG2-TrollHD - - - - - - - - - - - - Star.Wars.Episode.VI.Return.Of.The.Jedi.1983.DTS-HD.DTS.MULTISUBS.1080p.BluRay.x264.HQ-TUSAHD - - http://nzb.su/details/d4c0b3b28421fbe9e14ea6683889b125 - http://nzb.su/getnzb/d4c0b3b28421fbe9e14ea6683889b125.nzb&i=37292&r=xxx - http://nzb.su/details/d4c0b3b28421fbe9e14ea6683889b125#comments - Mon, 27 Feb 2012 07:36:39 -0500 - TV > HD - Star.Wars.Episode.VI.Return.Of.The.Jedi.1983.DTS-HD.DTS.MULTISUBS.1080p.BluRay.x264.HQ-TUSAHD - - - - - - - - - - - - Bondi.Rescue.S07E04.WS.PDTV.XviD-RTA - - http://nzb.su/details/20b6cba13475fa349166a999ad924440 - http://nzb.su/getnzb/20b6cba13475fa349166a999ad924440.nzb&i=37292&r=xxx - http://nzb.su/details/20b6cba13475fa349166a999ad924440#comments - Mon, 27 Feb 2012 07:26:02 -0500 - TV > SD - Bondi.Rescue.S07E04.WS.PDTV.XviD-RTA - - - - - - - - - - - - Star.Wars.Episode.I.The.Phantom.Menace.1999.DTS-HD.DTS.MULTISUBS.1080p.BluRay.x264.HQ-TUSAHD - - http://nzb.su/details/22e31ed44ff26b110f8a910beb7d6444 - http://nzb.su/getnzb/22e31ed44ff26b110f8a910beb7d6444.nzb&i=37292&r=xxx - http://nzb.su/details/22e31ed44ff26b110f8a910beb7d6444#comments - Mon, 27 Feb 2012 07:13:34 -0500 - TV > HD - Star.Wars.Episode.I.The.Phantom.Menace.1999.DTS-HD.DTS.MULTISUBS.1080p.BluRay.x264.HQ-TUSAHD - - - - - - - - - - - - The.Biggest.Loser.Australia.s07e23.PDTV.XviD.BF1 - - http://nzb.su/details/c937520cb1d94bd17bc5378a0450b1a2 - http://nzb.su/getnzb/c937520cb1d94bd17bc5378a0450b1a2.nzb&i=37292&r=xxx - http://nzb.su/details/c937520cb1d94bd17bc5378a0450b1a2#comments - Mon, 27 Feb 2012 07:26:02 -0500 - TV > SD - The.Biggest.Loser.Australia.s07e23.PDTV.XviD.BF1 - - - - - - - - - - - - Star.Wars.Episode.II.Attack.Of.The.Clones.2002.DTS-HD.DTS.MULTISUBS.1080p.BluRay.x264.HQ-TUSAHD - - http://nzb.su/details/a8401920efbd36e1ed518f629f06958a - http://nzb.su/getnzb/a8401920efbd36e1ed518f629f06958a.nzb&i=37292&r=xxx - http://nzb.su/details/a8401920efbd36e1ed518f629f06958a#comments - Mon, 27 Feb 2012 06:46:20 -0500 - TV > HD - Star.Wars.Episode.II.Attack.Of.The.Clones.2002.DTS-HD.DTS.MULTISUBS.1080p.BluRay.x264.HQ-TUSAHD - - - - - - - - - - - - The River - S01E03 - Los Ciegos - 264x720p - - http://nzb.su/details/513d74cea591a23e5d9e56336117052f - http://nzb.su/getnzb/513d74cea591a23e5d9e56336117052f.nzb&i=37292&r=xxx - http://nzb.su/details/513d74cea591a23e5d9e56336117052f#comments - Mon, 27 Feb 2012 10:48:45 -0500 - TV > HD - The River - S01E03 - Los Ciegos - 264x720p - - - - - - - - - - - - My.Kitchen.Rules.S03E17.WS.PDTV.x264-TASTETV - - http://nzb.su/details/00ef9f029b8bda6be9e095f09ce26e60 - http://nzb.su/getnzb/00ef9f029b8bda6be9e095f09ce26e60.nzb&i=37292&r=xxx - http://nzb.su/details/00ef9f029b8bda6be9e095f09ce26e60#comments - Mon, 27 Feb 2012 06:27:16 -0500 - TV > SD - My.Kitchen.Rules.S03E17.WS.PDTV.x264-TASTETV - - - - - - - - - - - - Catch 21 2011-05-23 1080i HDTV DD2.0 MPEG2-TrollHD - - http://nzb.su/details/b8f1a51e098b81010d5f493a3e02dc95 - http://nzb.su/getnzb/b8f1a51e098b81010d5f493a3e02dc95.nzb&i=37292&r=xxx - http://nzb.su/details/b8f1a51e098b81010d5f493a3e02dc95#comments - Mon, 27 Feb 2012 06:37:02 -0500 - TV > HD - Catch 21 2011-05-23 1080i HDTV DD2.0 MPEG2-TrollHD - - - - - - - - - - - - Star.Wars.Episode.III.Revenge.Of.The.Sith.2005.DTS-HD.DTS.MULTISUBS.1080p.BluRay.x264.HQ-TUSAHD - - http://nzb.su/details/04784d8f333181048060c2ad61d3e580 - http://nzb.su/getnzb/04784d8f333181048060c2ad61d3e580.nzb&i=37292&r=xxx - http://nzb.su/details/04784d8f333181048060c2ad61d3e580#comments - Mon, 27 Feb 2012 05:59:02 -0500 - TV > HD - Star.Wars.Episode.III.Revenge.Of.The.Sith.2005.DTS-HD.DTS.MULTISUBS.1080p.BluRay.x264.HQ-TUSAHD - - - - - - - - - - - - Unwrapped CW1612H Easy as Pie 1080i HDTV DD5.1 MPEG2-TrollHD - - http://nzb.su/details/423632bf02ae6c0b1c3da3eccf79bb44 - http://nzb.su/getnzb/423632bf02ae6c0b1c3da3eccf79bb44.nzb&i=37292&r=xxx - http://nzb.su/details/423632bf02ae6c0b1c3da3eccf79bb44#comments - Mon, 27 Feb 2012 05:57:28 -0500 - TV > HD - Unwrapped CW1612H Easy as Pie 1080i HDTV DD5.1 MPEG2-TrollHD - - - - - - - - - - - - Unwrapped CW1312H Sack Lunch 1080i HDTV DD5.1 MPEG2-TrollHD - - http://nzb.su/details/77e1ffbc0c390300628e96b02f84d379 - http://nzb.su/getnzb/77e1ffbc0c390300628e96b02f84d379.nzb&i=37292&r=xxx - http://nzb.su/details/77e1ffbc0c390300628e96b02f84d379#comments - Mon, 27 Feb 2012 05:52:15 -0500 - TV > HD - Unwrapped CW1312H Sack Lunch 1080i HDTV DD5.1 MPEG2-TrollHD - - - - - - - - - - - - Unforgettable S01E16 Heartbreak 1080i HDTV DD5.1 MPEG2-TrollHD - - http://nzb.su/details/f065d1f75d19e8e7a9073bca8c90d544 - http://nzb.su/getnzb/f065d1f75d19e8e7a9073bca8c90d544.nzb&i=37292&r=xxx - http://nzb.su/details/f065d1f75d19e8e7a9073bca8c90d544#comments - Mon, 27 Feb 2012 05:42:45 -0500 - TV > HD - Unforgettable S01E16 Heartbreak 1080i HDTV DD5.1 MPEG2-TrollHD - - - - - - - - - - - - This Old House S33E20 1080i HDTV DD5.1 MPEG2-TrollHD - - http://nzb.su/details/957b62f7489aa4bf6908c69cbd1c9898 - http://nzb.su/getnzb/957b62f7489aa4bf6908c69cbd1c9898.nzb&i=37292&r=xxx - http://nzb.su/details/957b62f7489aa4bf6908c69cbd1c9898#comments - Mon, 27 Feb 2012 05:31:06 -0500 - TV > HD - This Old House S33E20 1080i HDTV DD5.1 MPEG2-TrollHD - - - - - - - - - - - - Grimm S01E12 Last Grimm Standing 1080i HDTV DD5.1 MPEG2-TrollHD - - http://nzb.su/details/71f97967a20a74683bc15c2cb77ce699 - http://nzb.su/getnzb/71f97967a20a74683bc15c2cb77ce699.nzb&i=37292&r=xxx - http://nzb.su/details/71f97967a20a74683bc15c2cb77ce699#comments - Mon, 27 Feb 2012 05:24:06 -0500 - TV > HD - Grimm S01E12 Last Grimm Standing 1080i HDTV DD5.1 MPEG2-TrollHD - - - - - - - - - - - - Catch 21 2011-05-20 1080i HDTV DD2.0 MPEG2-TrollHD - - http://nzb.su/details/bf0d691fd49c54b5f93c37bb4f4cd867 - http://nzb.su/getnzb/bf0d691fd49c54b5f93c37bb4f4cd867.nzb&i=37292&r=xxx - http://nzb.su/details/bf0d691fd49c54b5f93c37bb4f4cd867#comments - Mon, 27 Feb 2012 05:32:39 -0500 - TV > HD - Catch 21 2011-05-20 1080i HDTV DD2.0 MPEG2-TrollHD - - - - - - - - - - - - Luck.S01E05.PROPER.720p.HDTV.x264-2HD - - http://nzb.su/details/f47617ed790b37ffa8cfc8be4d10f1df - http://nzb.su/getnzb/f47617ed790b37ffa8cfc8be4d10f1df.nzb&i=37292&r=xxx - http://nzb.su/details/f47617ed790b37ffa8cfc8be4d10f1df#comments - Mon, 27 Feb 2012 05:10:07 -0500 - TV > HD - Luck.S01E05.PROPER.720p.HDTV.x264-2HD - - - - - - - - - - - - Ask This Old House S10E20 1080i HDTV DD5.1 MPEG2-TrollHD - - http://nzb.su/details/82167e476238b16cc1f55e942a2c0434 - http://nzb.su/getnzb/82167e476238b16cc1f55e942a2c0434.nzb&i=37292&r=xxx - http://nzb.su/details/82167e476238b16cc1f55e942a2c0434#comments - Mon, 27 Feb 2012 05:13:13 -0500 - TV > HD - Ask This Old House S10E20 1080i HDTV DD5.1 MPEG2-TrollHD - - - - - - - - - - - - According to Jim S07E04 The Perfect Fight 1080i HDTV DD5.1 MPEG2-TrollHD - - http://nzb.su/details/d8e942fde13b65b97b9e9ad34f6c4e56 - http://nzb.su/getnzb/d8e942fde13b65b97b9e9ad34f6c4e56.nzb&i=37292&r=xxx - http://nzb.su/details/d8e942fde13b65b97b9e9ad34f6c4e56#comments - Mon, 27 Feb 2012 05:07:24 -0500 - TV > HD - According to Jim S07E04 The Perfect Fight 1080i HDTV DD5.1 MPEG2-TrollHD - - - - - - - - - - - - According to Jim S07E03 Safety Last 1080i HDTV DD5.1 MPEG2-TrollHD - - http://nzb.su/details/976cc03bb2519e27113bedafd6037619 - http://nzb.su/getnzb/976cc03bb2519e27113bedafd6037619.nzb&i=37292&r=xxx - http://nzb.su/details/976cc03bb2519e27113bedafd6037619#comments - Mon, 27 Feb 2012 05:00:28 -0500 - TV > HD - According to Jim S07E03 Safety Last 1080i HDTV DD5.1 MPEG2-TrollHD - - - - - - - - - - - - The.84th.Annual.Academy.Awards.2012.HDTV.XviD-2HD - - http://nzb.su/details/4e6acc683c21aa3c8f4ea8e3aa9ab3d4 - http://nzb.su/getnzb/4e6acc683c21aa3c8f4ea8e3aa9ab3d4.nzb&i=37292&r=xxx - http://nzb.su/details/4e6acc683c21aa3c8f4ea8e3aa9ab3d4#comments - Mon, 27 Feb 2012 04:48:41 -0500 - TV > SD - The.84th.Annual.Academy.Awards.2012.HDTV.XviD-2HD - - - - - - - - - - - - A Gifted Man S01E15 In Case of Letting Go 1080i HDTV DD5.1 MPEG2-TrollHD - - http://nzb.su/details/148268f41d539093ca2d78ffd8682a3e - http://nzb.su/getnzb/148268f41d539093ca2d78ffd8682a3e.nzb&i=37292&r=xxx - http://nzb.su/details/148268f41d539093ca2d78ffd8682a3e#comments - Mon, 27 Feb 2012 04:55:13 -0500 - TV > HD - A Gifted Man S01E15 In Case of Letting Go 1080i HDTV DD5.1 MPEG2-TrollHD - - - - - - - - - - - - Star.Wars.Episode.IV.A.New.Hope.1977.DTS-HD.DTS.MULTISUBS.1080p.BluRay.x264.HQ-TUSAHD - - http://nzb.su/details/7f52cc22f0b8e8db4d7fde708f229887 - http://nzb.su/getnzb/7f52cc22f0b8e8db4d7fde708f229887.nzb&i=37292&r=xxx - http://nzb.su/details/7f52cc22f0b8e8db4d7fde708f229887#comments - Mon, 27 Feb 2012 04:34:54 -0500 - TV > HD - Star.Wars.Episode.IV.A.New.Hope.1977.DTS-HD.DTS.MULTISUBS.1080p.BluRay.x264.HQ-TUSAHD - - - - - - - - - - - - The.84th.Annual.Academy.Awards.2012.720p.HDTV.x264-2HD - - http://nzb.su/details/8b4859648c0084a19f58d34b1070d705 - http://nzb.su/getnzb/8b4859648c0084a19f58d34b1070d705.nzb&i=37292&r=xxx - http://nzb.su/details/8b4859648c0084a19f58d34b1070d705#comments - Mon, 27 Feb 2012 04:28:30 -0500 - TV > HD - The.84th.Annual.Academy.Awards.2012.720p.HDTV.x264-2HD - - - - - - - - - - - - Full.Metal.Jousting.S01E03.Death.Sticks.and.a.Coffin.720p.HDTV.x264-MOMENTUM - - http://nzb.su/details/2b90fc4d321c63df54f74219e1cf32c4 - http://nzb.su/getnzb/2b90fc4d321c63df54f74219e1cf32c4.nzb&i=37292&r=xxx - http://nzb.su/details/2b90fc4d321c63df54f74219e1cf32c4#comments - Mon, 27 Feb 2012 04:12:00 -0500 - TV > HD - Full.Metal.Jousting.S01E03.Death.Sticks.and.a.Coffin.720p.HDTV.x264-MOMENTUM - - - - - - - - - - - - Full.Metal.Jousting.S01E03.Death.Sticks.and.a.Coffin.HDTV.x264-MOMENTUM - - http://nzb.su/details/814b5ca4feb747d6dc975fb4c13495dc - http://nzb.su/getnzb/814b5ca4feb747d6dc975fb4c13495dc.nzb&i=37292&r=xxx - http://nzb.su/details/814b5ca4feb747d6dc975fb4c13495dc#comments - Mon, 27 Feb 2012 03:39:56 -0500 - TV > SD - Full.Metal.Jousting.S01E03.Death.Sticks.and.a.Coffin.HDTV.x264-MOMENTUM - - - - - - - - - - - - Star.Wars.Episode.V.The.Empire.Strikes.Back.1980.DTS-HD.DTS.MULTISUBS.1080p.BluRay.x264.HQ-TUSAHD - - http://nzb.su/details/94d016064f91a77f3afce41d3cb7fec4 - http://nzb.su/getnzb/94d016064f91a77f3afce41d3cb7fec4.nzb&i=37292&r=xxx - http://nzb.su/details/94d016064f91a77f3afce41d3cb7fec4#comments - Mon, 27 Feb 2012 03:34:20 -0500 - TV > HD - Star.Wars.Episode.V.The.Empire.Strikes.Back.1980.DTS-HD.DTS.MULTISUBS.1080p.BluRay.x264.HQ-TUSAHD - - - - - - - - - - - - Iron.Chef.America.S10E08.Flay.vs.Hastings.HDTV.x264-MOMENTUM - - http://nzb.su/details/fecfc46859bf2c451671c1865e685190 - http://nzb.su/getnzb/fecfc46859bf2c451671c1865e685190.nzb&i=37292&r=xxx - http://nzb.su/details/fecfc46859bf2c451671c1865e685190#comments - Mon, 27 Feb 2012 03:34:22 -0500 - TV > SD - Iron.Chef.America.S10E08.Flay.vs.Hastings.HDTV.x264-MOMENTUM - - - - - - - - - - - - Iron.Chef.America.S10E08.Flay.vs.Hastings.720p.HDTV.x264-MOMENTUM - - http://nzb.su/details/09ece094563001dcc03765acb1b6616f - http://nzb.su/getnzb/09ece094563001dcc03765acb1b6616f.nzb&i=37292&r=xxx - http://nzb.su/details/09ece094563001dcc03765acb1b6616f#comments - Mon, 27 Feb 2012 03:34:21 -0500 - TV > HD - Iron.Chef.America.S10E08.Flay.vs.Hastings.720p.HDTV.x264-MOMENTUM - - - - - - - - - - - - Less.Than.Kind.S03E09.720p.HDTV.x264-2HD - - http://nzb.su/details/8e2a809cb7c8ea130e99995786a96219 - http://nzb.su/getnzb/8e2a809cb7c8ea130e99995786a96219.nzb&i=37292&r=xxx - http://nzb.su/details/8e2a809cb7c8ea130e99995786a96219#comments - Mon, 27 Feb 2012 03:34:22 -0500 - TV > HD - Less.Than.Kind.S03E09.720p.HDTV.x264-2HD - - - - - - - - - - - - Catch 21 2011-05-19 1080i HDTV DD2.0 MPEG2-TrollHD - - http://nzb.su/details/7d9662a466f327f2dd2a2683f479d657 - http://nzb.su/getnzb/7d9662a466f327f2dd2a2683f479d657.nzb&i=37292&r=xxx - http://nzb.su/details/7d9662a466f327f2dd2a2683f479d657#comments - Mon, 27 Feb 2012 03:34:22 -0500 - TV > HD - Catch 21 2011-05-19 1080i HDTV DD2.0 MPEG2-TrollHD - - - - - - - - - - - - Less.Than.Kind.S03E09.HDTV.XviD-2HD - - http://nzb.su/details/b8f62815092ed06c7f38dc90e6c5bc43 - http://nzb.su/getnzb/b8f62815092ed06c7f38dc90e6c5bc43.nzb&i=37292&r=xxx - http://nzb.su/details/b8f62815092ed06c7f38dc90e6c5bc43#comments - Mon, 27 Feb 2012 02:28:50 -0500 - TV > SD - Less.Than.Kind.S03E09.HDTV.XviD-2HD - - - - - - - - - - - - Luck.S01E05.HDTV.XviD-2HD - - http://nzb.su/details/b134b6fb4dcd2c7a73084b43c4febb43 - http://nzb.su/getnzb/b134b6fb4dcd2c7a73084b43c4febb43.nzb&i=37292&r=xxx - http://nzb.su/details/b134b6fb4dcd2c7a73084b43c4febb43#comments - Mon, 27 Feb 2012 02:20:39 -0500 - TV > SD - Luck.S01E05.HDTV.XviD-2HD - - - - - - - - - - - - Jimmy.Kimmel.2012.02.26.After.the.Oscars.Special.HDTV.XviD-2HD - - http://nzb.su/details/8008065b1ba474100fa3cc7e98acd2a3 - http://nzb.su/getnzb/8008065b1ba474100fa3cc7e98acd2a3.nzb&i=37292&r=xxx - http://nzb.su/details/8008065b1ba474100fa3cc7e98acd2a3#comments - Mon, 27 Feb 2012 02:17:58 -0500 - TV > SD - Jimmy.Kimmel.2012.02.26.After.the.Oscars.Special.HDTV.XviD-2HD - - - - - - - - - - - - Spartacus.S02E05.HDTV.XviD-2HD - - http://nzb.su/details/f3ee7238c75f80524635c3e4197d9035 - http://nzb.su/getnzb/f3ee7238c75f80524635c3e4197d9035.nzb&i=37292&r=xxx - http://nzb.su/details/f3ee7238c75f80524635c3e4197d9035#comments - Mon, 27 Feb 2012 02:10:29 -0500 - TV > SD - Spartacus.S02E05.HDTV.XviD-2HD - - - - - - - - - - - - Jay.Leno.2012.02.22.Tim.Allen.720p.HDTV.x264-BAJSKORV - - http://nzb.su/details/08a9f199400d3d83c1f6a38379fde982 - http://nzb.su/getnzb/08a9f199400d3d83c1f6a38379fde982.nzb&i=37292&r=xxx - http://nzb.su/details/08a9f199400d3d83c1f6a38379fde982#comments - Mon, 27 Feb 2012 06:00:35 -0500 - TV > HD - Jay.Leno.2012.02.22.Tim.Allen.720p.HDTV.x264-BAJSKORV - - - - - - - - - - - - Jay.Leno.2012.02.09.Denzel.Washington.720p.HDTV.x264-BAJSKORV - - http://nzb.su/details/52ad1175fe6d34efce14379a84aed88f - http://nzb.su/getnzb/52ad1175fe6d34efce14379a84aed88f.nzb&i=37292&r=xxx - http://nzb.su/details/52ad1175fe6d34efce14379a84aed88f#comments - Mon, 27 Feb 2012 01:59:03 -0500 - TV > HD - Jay.Leno.2012.02.09.Denzel.Washington.720p.HDTV.x264-BAJSKORV - - - - - - - - - - - - Jay.Leno.2012.02.21.Bill.O.Reilly.720p.HDTV.x264-BAJSKORV - - http://nzb.su/details/5d905e84d39a754f9fb659aa8187c3ab - http://nzb.su/getnzb/5d905e84d39a754f9fb659aa8187c3ab.nzb&i=37292&r=xxx - http://nzb.su/details/5d905e84d39a754f9fb659aa8187c3ab#comments - Mon, 27 Feb 2012 01:56:10 -0500 - TV > HD - Jay.Leno.2012.02.21.Bill.O.Reilly.720p.HDTV.x264-BAJSKORV - - - - - - - - - - - - Jay.Leno.2012.02.02.Drew.Barrymore.720p.HDTV.x264-BAJSKORV - - http://nzb.su/details/527eed252711c8602db523da1f6ed4db - http://nzb.su/getnzb/527eed252711c8602db523da1f6ed4db.nzb&i=37292&r=xxx - http://nzb.su/details/527eed252711c8602db523da1f6ed4db#comments - Mon, 27 Feb 2012 01:54:07 -0500 - TV > HD - Jay.Leno.2012.02.02.Drew.Barrymore.720p.HDTV.x264-BAJSKORV - - - - - - - - - - - - Jay.Leno.2012.02.17.Dave.Salmoni.720p.HDTV.x264-BAJSKORV - - http://nzb.su/details/3049a9e867abda77a1423fbedbabec7c - http://nzb.su/getnzb/3049a9e867abda77a1423fbedbabec7c.nzb&i=37292&r=xxx - http://nzb.su/details/3049a9e867abda77a1423fbedbabec7c#comments - Mon, 27 Feb 2012 01:54:07 -0500 - TV > HD - Jay.Leno.2012.02.17.Dave.Salmoni.720p.HDTV.x264-BAJSKORV - - - - - - - - - - - - Jay.Leno.2012.02.14.Tyler.Perry.720p.HDTV.x264-BAJSKORV - - http://nzb.su/details/886721cfee04dc96c56352344103c233 - http://nzb.su/getnzb/886721cfee04dc96c56352344103c233.nzb&i=37292&r=xxx - http://nzb.su/details/886721cfee04dc96c56352344103c233#comments - Mon, 27 Feb 2012 01:48:31 -0500 - TV > HD - Jay.Leno.2012.02.14.Tyler.Perry.720p.HDTV.x264-BAJSKORV - - - - - - - - - - - - Jay.Leno.2012.02.06.Dwayne.Johnson.720p.HDTV.x264-BAJSKORV - - http://nzb.su/details/679282512d2678beccab18dba03a62a8 - http://nzb.su/getnzb/679282512d2678beccab18dba03a62a8.nzb&i=37292&r=xxx - http://nzb.su/details/679282512d2678beccab18dba03a62a8#comments - Mon, 27 Feb 2012 01:48:31 -0500 - TV > HD - Jay.Leno.2012.02.06.Dwayne.Johnson.720p.HDTV.x264-BAJSKORV - - - - - - - - - - - - Jimmy.Fallon.2012.02.22.Alan.Alda.720p.HDTV.x264-BAJSKORV - - http://nzb.su/details/192efe2d4ee53faf05196c0212510b59 - http://nzb.su/getnzb/192efe2d4ee53faf05196c0212510b59.nzb&i=37292&r=xxx - http://nzb.su/details/192efe2d4ee53faf05196c0212510b59#comments - Mon, 27 Feb 2012 01:46:52 -0500 - TV > HD - Jimmy.Fallon.2012.02.22.Alan.Alda.720p.HDTV.x264-BAJSKORV - - - - - - - - - - - - Jimmy.Fallon.2012.02.21.Tyler.Perry.720p.HDTV.x264-BAJSKORV - - http://nzb.su/details/088616fa4b9138149a21af9354900d98 - http://nzb.su/getnzb/088616fa4b9138149a21af9354900d98.nzb&i=37292&r=xxx - http://nzb.su/details/088616fa4b9138149a21af9354900d98#comments - Mon, 27 Feb 2012 01:46:52 -0500 - TV > HD - Jimmy.Fallon.2012.02.21.Tyler.Perry.720p.HDTV.x264-BAJSKORV - - - - - - - - - - - - Eastbound.and.Down.S03E02.HDTV.XviD-2HD - - http://nzb.su/details/eb106eb8de8f7d49259b61bab732e798 - http://nzb.su/getnzb/eb106eb8de8f7d49259b61bab732e798.nzb&i=37292&r=xxx - http://nzb.su/details/eb106eb8de8f7d49259b61bab732e798#comments - Mon, 27 Feb 2012 01:44:42 -0500 - TV > SD - Eastbound.and.Down.S03E02.HDTV.XviD-2HD - - - - - - - - - - - - Jimmy.Fallon.2012.02.07.Harry.Connick.Jr.720p.HDTV.x264-BAJSKORV - - http://nzb.su/details/e0c0f698784621ca39df1d5f69e3205b - http://nzb.su/getnzb/e0c0f698784621ca39df1d5f69e3205b.nzb&i=37292&r=xxx - http://nzb.su/details/e0c0f698784621ca39df1d5f69e3205b#comments - Mon, 27 Feb 2012 01:44:42 -0500 - TV > HD - Jimmy.Fallon.2012.02.07.Harry.Connick.Jr.720p.HDTV.x264-BAJSKORV - - - - - - - - - - - - Jimmy.Fallon.2012.02.20.Anjelica.Houston.720p.HDTV.x264-BAJSKORV - - http://nzb.su/details/e9f9cac00055872b57b49493ed21bc02 - http://nzb.su/getnzb/e9f9cac00055872b57b49493ed21bc02.nzb&i=37292&r=xxx - http://nzb.su/details/e9f9cac00055872b57b49493ed21bc02#comments - Mon, 27 Feb 2012 01:44:42 -0500 - TV > HD - Jimmy.Fallon.2012.02.20.Anjelica.Houston.720p.HDTV.x264-BAJSKORV - - - - - - - - - - - - Jimmy.Fallon.2012.02.17.Ricky.Gervais.720p.HDTV.x264-BAJSKORV - - http://nzb.su/details/0ac4fb081cfbbf5e1230109ce538ed25 - http://nzb.su/getnzb/0ac4fb081cfbbf5e1230109ce538ed25.nzb&i=37292&r=xxx - http://nzb.su/details/0ac4fb081cfbbf5e1230109ce538ed25#comments - Mon, 27 Feb 2012 01:42:59 -0500 - TV > HD - Jimmy.Fallon.2012.02.17.Ricky.Gervais.720p.HDTV.x264-BAJSKORV - - - - - - - - - - - - Cartoon Network Hall of Game Awards 2012 1080i HDTV DD5.1 MPEG2-TrollHD - - http://nzb.su/details/06b110b77e4a0e34ac3dc98d2102b8f7 - http://nzb.su/getnzb/06b110b77e4a0e34ac3dc98d2102b8f7.nzb&i=37292&r=xxx - http://nzb.su/details/06b110b77e4a0e34ac3dc98d2102b8f7#comments - Mon, 27 Feb 2012 02:10:29 -0500 - TV > HD - Cartoon Network Hall of Game Awards 2012 1080i HDTV DD5.1 MPEG2-TrollHD - - - - - - - - - - - - Jimmy.Fallon.2012.02.15.Greg.Kinnear.720p.HDTV.x264-BAJSKORV - - http://nzb.su/details/c2d4dba128f03c050ee96e6c5afd48c7 - http://nzb.su/getnzb/c2d4dba128f03c050ee96e6c5afd48c7.nzb&i=37292&r=xxx - http://nzb.su/details/c2d4dba128f03c050ee96e6c5afd48c7#comments - Mon, 27 Feb 2012 01:42:59 -0500 - TV > HD - Jimmy.Fallon.2012.02.15.Greg.Kinnear.720p.HDTV.x264-BAJSKORV - - - - - - - - - - - - Jimmy.Fallon.2012.02.14.Donald.Trump.720p.HDTV.x264-BAJSKORV - - http://nzb.su/details/8f5a0af48e56d4c7ce8740675336377d - http://nzb.su/getnzb/8f5a0af48e56d4c7ce8740675336377d.nzb&i=37292&r=xxx - http://nzb.su/details/8f5a0af48e56d4c7ce8740675336377d#comments - Mon, 27 Feb 2012 01:41:19 -0500 - TV > HD - Jimmy.Fallon.2012.02.14.Donald.Trump.720p.HDTV.x264-BAJSKORV - - - - - - - - - - - - Jimmy.Fallon.2012.02.13.Nicolas.Cage.720p.HDTV.x264-BAJSKORV - - http://nzb.su/details/20d941c07d7c45080bbd97771461a9ab - http://nzb.su/getnzb/20d941c07d7c45080bbd97771461a9ab.nzb&i=37292&r=xxx - http://nzb.su/details/20d941c07d7c45080bbd97771461a9ab#comments - Mon, 27 Feb 2012 01:39:49 -0500 - TV > HD - Jimmy.Fallon.2012.02.13.Nicolas.Cage.720p.HDTV.x264-BAJSKORV - - - - - - - - - - - - Jimmy.Fallon.2012.02.06.The.Best.Of.720p.HDTV.x264-BAJSKORV - - http://nzb.su/details/eb88e1253cfa159f75a17391f62bcf33 - http://nzb.su/getnzb/eb88e1253cfa159f75a17391f62bcf33.nzb&i=37292&r=xxx - http://nzb.su/details/eb88e1253cfa159f75a17391f62bcf33#comments - Mon, 27 Feb 2012 01:34:11 -0500 - TV > HD - Jimmy.Fallon.2012.02.06.The.Best.Of.720p.HDTV.x264-BAJSKORV - - - - - - - - - - - - Jimmy.Fallon.2012.02.02.Taylor.Lautner.720p.HDTV.x264-BAJSKORV - - http://nzb.su/details/c681e94346b715f485eec20ffc9844c2 - http://nzb.su/getnzb/c681e94346b715f485eec20ffc9844c2.nzb&i=37292&r=xxx - http://nzb.su/details/c681e94346b715f485eec20ffc9844c2#comments - Mon, 27 Feb 2012 01:32:29 -0500 - TV > HD - Jimmy.Fallon.2012.02.02.Taylor.Lautner.720p.HDTV.x264-BAJSKORV - - - - - - - - - - - - The.Apprentice.US.S12E02.HDTV.XviD-2HD - - http://nzb.su/details/f3e00a427211bbbf667d184fcf05b94d - http://nzb.su/getnzb/f3e00a427211bbbf667d184fcf05b94d.nzb&i=37292&r=xxx - http://nzb.su/details/f3e00a427211bbbf667d184fcf05b94d#comments - Mon, 27 Feb 2012 01:06:20 -0500 - TV > SD - The.Apprentice.US.S12E02.HDTV.XviD-2HD - - - - - - - - - - - - Heartland.CA.S05E14.720p.HDTV.x264-2HD - - http://nzb.su/details/cb901e996ce1286c938d9400350a2983 - http://nzb.su/getnzb/cb901e996ce1286c938d9400350a2983.nzb&i=37292&r=xxx - http://nzb.su/details/cb901e996ce1286c938d9400350a2983#comments - Mon, 27 Feb 2012 01:04:41 -0500 - TV > HD - Heartland.CA.S05E14.720p.HDTV.x264-2HD - - - - - - - - - - - - time.out.s13e03.pdtv.x264-d2v - - http://nzb.su/details/1cb4b15820ea8ee09682da493216d7f0 - http://nzb.su/getnzb/1cb4b15820ea8ee09682da493216d7f0.nzb&i=37292&r=xxx - http://nzb.su/details/1cb4b15820ea8ee09682da493216d7f0#comments - Mon, 27 Feb 2012 00:57:29 -0500 - TV > SD - time.out.s13e03.pdtv.x264-d2v - - - - - - - - - - - - lyxfallan.s12e04.proper.pdtv.x264-d2v - - http://nzb.su/details/caf3dc9a99fc36167f49183da991e652 - http://nzb.su/getnzb/caf3dc9a99fc36167f49183da991e652.nzb&i=37292&r=xxx - http://nzb.su/details/caf3dc9a99fc36167f49183da991e652#comments - Mon, 27 Feb 2012 00:52:49 -0500 - TV > SD - lyxfallan.s12e04.proper.pdtv.x264-d2v - - - - - - - - - - - - karatefylla.s02e01.pdtv.x264-d2v - - http://nzb.su/details/e8a70fb2b81e715d8650bcbdef7ea55e - http://nzb.su/getnzb/e8a70fb2b81e715d8650bcbdef7ea55e.nzb&i=37292&r=xxx - http://nzb.su/details/e8a70fb2b81e715d8650bcbdef7ea55e#comments - Mon, 27 Feb 2012 00:34:28 -0500 - TV > SD - karatefylla.s02e01.pdtv.x264-d2v - - - - - - - - - - - - The.Walking.Dead.S02E10.iNTERNAL.720p.HDTV.x264-2HD - - http://nzb.su/details/0dd3bc43ec5856a34aff3cbed4e5def3 - http://nzb.su/getnzb/0dd3bc43ec5856a34aff3cbed4e5def3.nzb&i=37292&r=xxx - http://nzb.su/details/0dd3bc43ec5856a34aff3cbed4e5def3#comments - Mon, 27 Feb 2012 00:30:53 -0500 - TV > HD - The.Walking.Dead.S02E10.iNTERNAL.720p.HDTV.x264-2HD - - - - - - - - - - - - How.The.Celts.Saved.Britain.S01E02.BDRip.XviD-SPRiNTER - - http://nzb.su/details/7e66256948969eedc50acfa16dc25336 - http://nzb.su/getnzb/7e66256948969eedc50acfa16dc25336.nzb&i=37292&r=xxx - http://nzb.su/details/7e66256948969eedc50acfa16dc25336#comments - Mon, 27 Feb 2012 00:02:54 -0500 - TV > SD - How.The.Celts.Saved.Britain.S01E02.BDRip.XviD-SPRiNTER - - - - - - - - - - - - How.The.Celts.Saved.Britain.S01E01.BDRip.XviD-SPRiNTER - - http://nzb.su/details/c3421676f28ebe6584b4ba4237d2531b - http://nzb.su/getnzb/c3421676f28ebe6584b4ba4237d2531b.nzb&i=37292&r=xxx - http://nzb.su/details/c3421676f28ebe6584b4ba4237d2531b#comments - Mon, 27 Feb 2012 00:02:54 -0500 - TV > SD - How.The.Celts.Saved.Britain.S01E01.BDRip.XviD-SPRiNTER - - - - - - - - - - - - Wanna.BEn.S02E02.PDTV.XviD-FiHTV - - http://nzb.su/details/a297836314a5aa5947957babc9277148 - http://nzb.su/getnzb/a297836314a5aa5947957babc9277148.nzb&i=37292&r=xxx - http://nzb.su/details/a297836314a5aa5947957babc9277148#comments - Sun, 26 Feb 2012 23:59:18 -0500 - TV > SD - Wanna.BEn.S02E02.PDTV.XviD-FiHTV - - - - - - - - - - - - Ax.Men.S05E07.Wake-Up.Call.720p.HDTV.x264-MOMENTUM - - http://nzb.su/details/1e28429ef8dff9ef00df2927cd381177 - http://nzb.su/getnzb/1e28429ef8dff9ef00df2927cd381177.nzb&i=37292&r=xxx - http://nzb.su/details/1e28429ef8dff9ef00df2927cd381177#comments - Sun, 26 Feb 2012 23:44:08 -0500 - TV > HD - Ax.Men.S05E07.Wake-Up.Call.720p.HDTV.x264-MOMENTUM - - - - - - - - - - - - Heartland.CA.S05E14.HDTV.XviD-2HD - - http://nzb.su/details/7726d5073f24f2ad0593cfee6619621d - http://nzb.su/getnzb/7726d5073f24f2ad0593cfee6619621d.nzb&i=37292&r=xxx - http://nzb.su/details/7726d5073f24f2ad0593cfee6619621d#comments - Sun, 26 Feb 2012 23:38:49 -0500 - TV > SD - Heartland.CA.S05E14.HDTV.XviD-2HD - - - - - - - - - - - - Parallel Series 2 (MOTE028D)-WEB-2012-dL - - http://nzb.su/details/1f182287ce20e045411648b8d60e300c - http://nzb.su/getnzb/1f182287ce20e045411648b8d60e300c.nzb&i=37292&r=xxx - http://nzb.su/details/1f182287ce20e045411648b8d60e300c#comments - Sun, 26 Feb 2012 23:36:04 -0500 - TV > SD - Parallel Series 2 (MOTE028D)-WEB-2012-dL - - - - - - - - - - - - 7.Days.NZ.S04E02.PDTV.XviD-FiHTV - - http://nzb.su/details/14542c22bbc1e1a584332ebf5f3487d4 - http://nzb.su/getnzb/14542c22bbc1e1a584332ebf5f3487d4.nzb&i=37292&r=xxx - http://nzb.su/details/14542c22bbc1e1a584332ebf5f3487d4#comments - Sun, 26 Feb 2012 23:32:11 -0500 - TV > SD - 7.Days.NZ.S04E02.PDTV.XviD-FiHTV - - - - - - - - - - - - Finding.Bigfoot.S02E08.Finding.Bigfoot.Special.HDTV.XviD-FQM - - http://nzb.su/details/058d8b676adc765a2fc7c3260066958f - http://nzb.su/getnzb/058d8b676adc765a2fc7c3260066958f.nzb&i=37292&r=xxx - http://nzb.su/details/058d8b676adc765a2fc7c3260066958f#comments - Sun, 26 Feb 2012 23:20:28 -0500 - TV > SD - Finding.Bigfoot.S02E08.Finding.Bigfoot.Special.HDTV.XviD-FQM - - - - - - - - - - - - Oscars.Red.Carpet.Live.2012.720p.HDTV.x264-2HD - - http://nzb.su/details/5f8772bab2282f1f6938614114a71fb4 - http://nzb.su/getnzb/5f8772bab2282f1f6938614114a71fb4.nzb&i=37292&r=xxx - http://nzb.su/details/5f8772bab2282f1f6938614114a71fb4#comments - Sun, 26 Feb 2012 23:15:19 -0500 - TV > HD - Oscars.Red.Carpet.Live.2012.720p.HDTV.x264-2HD - - - - - - - - - - - - The.Apprentice.S12E02.720p.HDTV.x264-BAJSKORV - - http://nzb.su/details/884783386e9802e6ed4ca85edf1601b5 - http://nzb.su/getnzb/884783386e9802e6ed4ca85edf1601b5.nzb&i=37292&r=xxx - http://nzb.su/details/884783386e9802e6ed4ca85edf1601b5#comments - Sun, 26 Feb 2012 23:13:20 -0500 - TV > HD - The.Apprentice.S12E02.720p.HDTV.x264-BAJSKORV - - - - - - - - - - - - The.Walking.Dead.S02E10.HDTV.x264-ASAP - - http://nzb.su/details/ea5a6405af227988216a5157db8229db - http://nzb.su/getnzb/ea5a6405af227988216a5157db8229db.nzb&i=37292&r=xxx - http://nzb.su/details/ea5a6405af227988216a5157db8229db#comments - Sun, 26 Feb 2012 23:10:28 -0500 - TV > SD - The.Walking.Dead.S02E10.HDTV.x264-ASAP - - - - - - - - - - - - The.Walking.Dead.S02E10.720p.HDTV.x264-IMMERSE - - http://nzb.su/details/6860ebbe38724e58747edf7a804cbadb - http://nzb.su/getnzb/6860ebbe38724e58747edf7a804cbadb.nzb&i=37292&r=xxx - http://nzb.su/details/6860ebbe38724e58747edf7a804cbadb#comments - Sun, 26 Feb 2012 23:10:28 -0500 - TV > HD - The.Walking.Dead.S02E10.720p.HDTV.x264-IMMERSE - - - - - - - - - - - - The.Apprentice.S12E02.HDTV.x264-BAJSKORV - - http://nzb.su/details/b16397d84630fa2f6d5a140f3013a998 - http://nzb.su/getnzb/b16397d84630fa2f6d5a140f3013a998.nzb&i=37292&r=xxx - http://nzb.su/details/b16397d84630fa2f6d5a140f3013a998#comments - Sun, 26 Feb 2012 23:03:46 -0500 - TV > SD - The.Apprentice.S12E02.HDTV.x264-BAJSKORV - - - - - - - - - - - - Ax.Men.S05E07.Wake-Up.Call.HDTV.x264-MOMENTUM - - http://nzb.su/details/db7c5f361814f4a910ac0b73d30f7468 - http://nzb.su/getnzb/db7c5f361814f4a910ac0b73d30f7468.nzb&i=37292&r=xxx - http://nzb.su/details/db7c5f361814f4a910ac0b73d30f7468#comments - Sun, 26 Feb 2012 22:53:44 -0500 - TV > SD - Ax.Men.S05E07.Wake-Up.Call.HDTV.x264-MOMENTUM - - - - - - - - - - - - The.Amazing.Race.S20E02.HDTV.XviD-2HD - - http://nzb.su/details/a3416403c5ff159657199fde24c1fb7a - http://nzb.su/getnzb/a3416403c5ff159657199fde24c1fb7a.nzb&i=37292&r=xxx - http://nzb.su/details/a3416403c5ff159657199fde24c1fb7a#comments - Sun, 26 Feb 2012 22:40:59 -0500 - TV > SD - The.Amazing.Race.S20E02.HDTV.XviD-2HD - - - - - - - - - - - - + + + api.nzbgeek.info + NZBgeek API + http://api.nzbgeek.info/ + en-gb + info@nzbgeek.info (NZBgeek) + + + https://api.nzbgeek.info/covers/nzbgeek.png + api.nzbgeek.info + http://api.nzbgeek.info/ + NZBgeek + + + + Brainstorm-Scary Creatures-CD-FLAC-2016-NBFLAC + https://api.nzbgeek.info/details/38884827e1e56b9336278a449e0a38ec + https://api.nzbgeek.info/api?t=get&id=38884827e1e56b9336278a449e0a38ec&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=38884827e1e56b9336278a449e0a38ec + Fri, 26 May 2017 05:54:51 +0000 + Audio > Lossless + Brainstorm-Scary Creatures-CD-FLAC-2016-NBFLAC + + + + + + + + + + + + + + + Dylan LeBlanc-Cautionary Tale-(SL012)-CD-FLAC-2016-CUSTODES + https://api.nzbgeek.info/details/d6395e3218b0b2ed15cbc3743df77112 + https://api.nzbgeek.info/api?t=get&id=d6395e3218b0b2ed15cbc3743df77112&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=d6395e3218b0b2ed15cbc3743df77112 + Wed, 14 Dec 2016 20:03:39 +0000 + Audio > Lossless + Dylan LeBlanc-Cautionary Tale-(SL012)-CD-FLAC-2016-CUSTODES + + + + + + + + + + + + + + + + Sia-This Is Acting-Limited Deluxe Edition-CD-FLAC-2016-PERFECT + https://api.nzbgeek.info/details/dc3230ac8143dd9a2fab65c07ad0f295 + https://api.nzbgeek.info/api?t=get&id=dc3230ac8143dd9a2fab65c07ad0f295&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=dc3230ac8143dd9a2fab65c07ad0f295 + Fri, 21 Oct 2016 21:37:43 +0000 + Audio > Lossless + Sia-This Is Acting-Limited Deluxe Edition-CD-FLAC-2016-PERFECT + + + + + + + + + + + + + + + + Azad-Leben II-DE-Limited Edition-3CD-FLAC-2016-Mrflac + https://api.nzbgeek.info/details/a5560bb5ecf2ad19642527a567d427d0 + https://api.nzbgeek.info/api?t=get&id=a5560bb5ecf2ad19642527a567d427d0&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=a5560bb5ecf2ad19642527a567d427d0 + Tue, 04 Oct 2016 16:04:06 +0000 + Audio > Lossless + Azad-Leben II-DE-Limited Edition-3CD-FLAC-2016-Mrflac + + + + + + + + + + + + + + + + VA-Hits 2016-(TETA092-2)-CD-FLAC-2016-flachedelic + https://api.nzbgeek.info/details/eb42bbef1b5d8e0697962a291371655b + https://api.nzbgeek.info/api?t=get&id=eb42bbef1b5d8e0697962a291371655b&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=eb42bbef1b5d8e0697962a291371655b + Thu, 30 Jun 2016 05:00:02 +0000 + Audio > Lossless + VA-Hits 2016-(TETA092-2)-CD-FLAC-2016-flachedelic + + + + + + + + + + + + + + + + Rhapsody Of Fire-Into The Legend-CD-FLAC-2016-CATARACT + https://api.nzbgeek.info/details/812d0f7b9c38b6989d07c125eb35eabd + https://api.nzbgeek.info/api?t=get&id=812d0f7b9c38b6989d07c125eb35eabd&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=812d0f7b9c38b6989d07c125eb35eabd + Fri, 06 May 2016 22:18:32 +0000 + Audio > Lossless + Rhapsody Of Fire-Into The Legend-CD-FLAC-2016-CATARACT + + + + + + + + + + + + + + + + Megadeth-Dystopia-JP Retail-CD-FLAC-2016-GRAVEWISH + https://api.nzbgeek.info/details/6ac8a40d20fb27a3d8fc11cb41afae4b + https://api.nzbgeek.info/api?t=get&id=6ac8a40d20fb27a3d8fc11cb41afae4b&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=6ac8a40d20fb27a3d8fc11cb41afae4b + Wed, 13 Apr 2016 16:58:09 +0000 + Audio > Lossless + Megadeth-Dystopia-JP Retail-CD-FLAC-2016-GRAVEWISH + + + + + + + + + + + + + + + + Rachel Platten-Wildfire-Deluxe Edition-CD-FLAC-2016-PERFECT + https://api.nzbgeek.info/details/36b4176e900767359b5ad2cb5c3b9906 + https://api.nzbgeek.info/api?t=get&id=36b4176e900767359b5ad2cb5c3b9906&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=36b4176e900767359b5ad2cb5c3b9906 + Thu, 24 Mar 2016 05:02:03 +0000 + Audio > Lossless + Rachel Platten-Wildfire-Deluxe Edition-CD-FLAC-2016-PERFECT + + + + + + + + + + + + + + + + Anthrax-For All Kings-DELUXE EDITION-2CD-FLAC-2016-mwnd + https://api.nzbgeek.info/details/fc1aaca4356bef96677483c7da2b5693 + https://api.nzbgeek.info/api?t=get&id=fc1aaca4356bef96677483c7da2b5693&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=fc1aaca4356bef96677483c7da2b5693 + Thu, 17 Mar 2016 13:56:38 +0000 + Audio > Lossless + Anthrax-For All Kings-DELUXE EDITION-2CD-FLAC-2016-mwnd + + + + + + + + + + + + + + + + The Shrine-Rare Breed-CD-FLAC-2015-NBFLAC + https://api.nzbgeek.info/details/c14b90c40483c0fe9ab9e93541453449 + https://api.nzbgeek.info/api?t=get&id=c14b90c40483c0fe9ab9e93541453449&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=c14b90c40483c0fe9ab9e93541453449 + Fri, 11 Mar 2016 18:08:57 +0000 + Audio > Lossless + The Shrine-Rare Breed-CD-FLAC-2015-NBFLAC + + + + + + + + + + + + + + + + Anthrax-For All Kings-CD-FLAC-2016-FORSAKEN + https://api.nzbgeek.info/details/cf9908f3c878107d84c015aad48b8845 + https://api.nzbgeek.info/api?t=get&id=cf9908f3c878107d84c015aad48b8845&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=cf9908f3c878107d84c015aad48b8845 + Sat, 05 Mar 2016 19:02:57 +0000 + Audio > Lossless + Anthrax-For All Kings-CD-FLAC-2016-FORSAKEN + + + + + + + + + + + + + + + + Anthrax-For All Kings-2CD-Ltd + https://api.nzbgeek.info/details/06dcfac7a4b7f46dc1e9ec483f8ec73f + https://api.nzbgeek.info/api?t=get&id=06dcfac7a4b7f46dc1e9ec483f8ec73f&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=06dcfac7a4b7f46dc1e9ec483f8ec73f + Tue, 01 Mar 2016 12:56:31 +0000 + Audio > MP3 + Anthrax-For All Kings-2CD-Ltd + + + + + + + + + + + + + + + + The Contortionist-Exoplanet (Redux)-2016-MTD + https://api.nzbgeek.info/details/952effe2dc87e5d1c271d5ea8fa3801f + https://api.nzbgeek.info/api?t=get&id=952effe2dc87e5d1c271d5ea8fa3801f&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=952effe2dc87e5d1c271d5ea8fa3801f + Thu, 25 Feb 2016 04:22:46 +0000 + Audio > MP3 + The Contortionist-Exoplanet (Redux)-2016-MTD + + + + + + + + + + + + + + + + VA - Hard Bass 2016 + https://api.nzbgeek.info/details/474de3b6fe44b3190bf35fba59328327 + https://api.nzbgeek.info/api?t=get&id=474de3b6fe44b3190bf35fba59328327&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=474de3b6fe44b3190bf35fba59328327 + Wed, 10 Feb 2016 14:18:11 +0000 + Audio > MP3 + VA - Hard Bass 2016 + + + + + + + + + + + + + + + + Sia-This Is Acting-CD-FLAC-2016-PERFECT + https://api.nzbgeek.info/details/31c118f3e86f87b071768a7e090176d1 + https://api.nzbgeek.info/api?t=get&id=31c118f3e86f87b071768a7e090176d1&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=31c118f3e86f87b071768a7e090176d1 + Fri, 29 Jan 2016 11:25:35 +0000 + Audio > Lossless + Sia-This Is Acting-CD-FLAC-2016-PERFECT + + + + + + + + + + + + + + + + Lefa-Monsieur Fall-FR-CD-FLAC-2016-Mrflac + https://api.nzbgeek.info/details/664f15bd457f6669745fa494091f8108 + https://api.nzbgeek.info/api?t=get&id=664f15bd457f6669745fa494091f8108&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=664f15bd457f6669745fa494091f8108 + Thu, 28 Jan 2016 16:52:52 +0000 + Audio > Lossless + Lefa-Monsieur Fall-FR-CD-FLAC-2016-Mrflac + + + + + + + + + + + + + + + + VA-Now Thats What I Call Rock-CD-FLAC-2016-FATHEAD + https://api.nzbgeek.info/details/a57f2a5d61f0b403b05904dc0e8a0d9e + https://api.nzbgeek.info/api?t=get&id=a57f2a5d61f0b403b05904dc0e8a0d9e&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=a57f2a5d61f0b403b05904dc0e8a0d9e + Wed, 27 Jan 2016 21:10:58 +0000 + Audio > Lossless + VA-Now Thats What I Call Rock-CD-FLAC-2016-FATHEAD + + + + + + + + + + + + + + + + AniMe-Exterminate-(TRAXCD084)-2CD-FLAC-2016-SPL + https://api.nzbgeek.info/details/6b0315222a1df2a4b07f2681a9dbbc41 + https://api.nzbgeek.info/api?t=get&id=6b0315222a1df2a4b07f2681a9dbbc41&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=6b0315222a1df2a4b07f2681a9dbbc41 + Tue, 26 Jan 2016 16:48:40 +0000 + Audio > Lossless + AniMe-Exterminate-(TRAXCD084)-2CD-FLAC-2016-SPL + + + + + + + + + + + + + + VA - Absolute Uplifter Vol 2 Euphoric Trance + https://api.nzbgeek.info/details/51ac24dbb3122346e6fa2860efc3cff7 + https://api.nzbgeek.info/api?t=get&id=51ac24dbb3122346e6fa2860efc3cff7&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=51ac24dbb3122346e6fa2860efc3cff7 + Tue, 26 Jan 2016 12:09:58 +0000 + Audio > MP3 + VA - Absolute Uplifter Vol 2 Euphoric Trance + + + + + + + + + + + + + + + + Dream Theater - The Astonishing (2016) + https://api.nzbgeek.info/details/d9e64c5efa86444f4c227fc99c155861 + https://api.nzbgeek.info/api?t=get&id=d9e64c5efa86444f4c227fc99c155861&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=d9e64c5efa86444f4c227fc99c155861 + Tue, 26 Jan 2016 11:38:48 +0000 + Audio > MP3 + Dream Theater - The Astonishing (2016) + + + + + + + + + + + + + + + + Black Sabbath ? The End (2016) + https://api.nzbgeek.info/details/0d720fffa565fd1ab16f2f1eb8ef2129 + https://api.nzbgeek.info/api?t=get&id=0d720fffa565fd1ab16f2f1eb8ef2129&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=0d720fffa565fd1ab16f2f1eb8ef2129 + Mon, 25 Jan 2016 19:04:27 +0000 + Audio > MP3 + Black Sabbath ? The End (2016) + + + + + + + + + + + + + Status Quo Quo-Remastered (2016). - + https://api.nzbgeek.info/details/0040f26df09ad3b0742f3b32afe7f3ab + https://api.nzbgeek.info/api?t=get&id=0040f26df09ad3b0742f3b32afe7f3ab&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=0040f26df09ad3b0742f3b32afe7f3ab + Mon, 25 Jan 2016 17:56:17 +0000 + Audio > MP3 + Status Quo Quo-Remastered (2016). - + + + + + + + + + + + + + + + + 100-va_-_polonaise_deel_12-cd1-2016-sob + https://api.nzbgeek.info/details/b0109c03014ff38a7b080e4791a31cb1 + https://api.nzbgeek.info/api?t=get&id=b0109c03014ff38a7b080e4791a31cb1&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=b0109c03014ff38a7b080e4791a31cb1 + Mon, 25 Jan 2016 17:52:23 +0000 + Audio > MP3 + 100-va_-_polonaise_deel_12-cd1-2016-sob + + + + + + + + + + + + + + Lutece-From Glory Towards Void-2016 + https://api.nzbgeek.info/details/3606e4c22779a8aa9da0fda29c851913 + https://api.nzbgeek.info/api?t=get&id=3606e4c22779a8aa9da0fda29c851913&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=3606e4c22779a8aa9da0fda29c851913 + Mon, 25 Jan 2016 17:44:36 +0000 + Audio > MP3 + Lutece-From Glory Towards Void-2016 + + + + + + + + + + + + + + + + The Black Market Trust - II - 2016 + https://api.nzbgeek.info/details/94b8ed888b3b42279961c3472e0b080d + https://api.nzbgeek.info/api?t=get&id=94b8ed888b3b42279961c3472e0b080d&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=94b8ed888b3b42279961c3472e0b080d + Mon, 25 Jan 2016 04:13:18 +0000 + Audio > MP3 + The Black Market Trust - II - 2016 + + + + + + + + + + + + + Borknagar-Winter Thrice-(88875175232)-CD-FLAC-2016-WRE + https://api.nzbgeek.info/details/ab70097392213165d77bc41b5375aa4a + https://api.nzbgeek.info/api?t=get&id=ab70097392213165d77bc41b5375aa4a&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=ab70097392213165d77bc41b5375aa4a + Sun, 24 Jan 2016 21:35:56 +0000 + Audio > Lossless + Borknagar-Winter Thrice-(88875175232)-CD-FLAC-2016-WRE + + + + + + + + + + + + + + + + Joseph Trapanese-Straight Outta Compton Original Motion Picture Score-OST-CD-FLAC-2016-FORSAKEN + https://api.nzbgeek.info/details/e562e530012f8e7b8e0340baed72060e + https://api.nzbgeek.info/api?t=get&id=e562e530012f8e7b8e0340baed72060e&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=e562e530012f8e7b8e0340baed72060e + Sun, 24 Jan 2016 20:31:52 +0000 + Audio > Lossless + Joseph Trapanese-Straight Outta Compton Original Motion Picture Score-OST-CD-FLAC-2016-FORSAKEN + + + + + + + + + + + + + + + + VA-Deephouse Top 100 Vol.3 + https://api.nzbgeek.info/details/1b1d1cf2690e9cf344968d6e4cd5432f + https://api.nzbgeek.info/api?t=get&id=1b1d1cf2690e9cf344968d6e4cd5432f&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=1b1d1cf2690e9cf344968d6e4cd5432f + Sun, 24 Jan 2016 16:35:45 +0000 + Audio > MP3 + VA-Deephouse Top 100 Vol.3 + + + + + + + + + + + + + + VA-Deephouse Top 100 Vol.3 - + https://api.nzbgeek.info/details/bde795d6aa76e337ca996075d092f79a + https://api.nzbgeek.info/api?t=get&id=bde795d6aa76e337ca996075d092f79a&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=bde795d6aa76e337ca996075d092f79a + Sun, 24 Jan 2016 16:35:45 +0000 + Audio > MP3 + VA-Deephouse Top 100 Vol.3 - + + + + + + + + + + + + + + VA-2016 Grammy Nominees-CD-FLAC-2016-FORSAKEN + https://api.nzbgeek.info/details/4deac3811667801dce66637d05f4e6ca + https://api.nzbgeek.info/api?t=get&id=4deac3811667801dce66637d05f4e6ca&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=4deac3811667801dce66637d05f4e6ca + Sat, 23 Jan 2016 20:08:52 +0000 + Audio > Lossless + VA-2016 Grammy Nominees-CD-FLAC-2016-FORSAKEN + + + + + + + + + + + + + + + + Dvalin-Aus Dem Schatten-WEB-2016-ENTiTLED + https://api.nzbgeek.info/details/ba6e1b072dc863b65c1ea73c8c0994f3 + https://api.nzbgeek.info/api?t=get&id=ba6e1b072dc863b65c1ea73c8c0994f3&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=ba6e1b072dc863b65c1ea73c8c0994f3 + Sat, 23 Jan 2016 18:11:42 +0000 + Audio > MP3 + Dvalin-Aus Dem Schatten-WEB-2016-ENTiTLED + + + + + + + + + + + + + + + + Bonnie Prince Billy - Pond Scum (2016) + https://api.nzbgeek.info/details/c6cca22fbd4e5c21584a88d7f7324965 + https://api.nzbgeek.info/api?t=get&id=c6cca22fbd4e5c21584a88d7f7324965&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=c6cca22fbd4e5c21584a88d7f7324965 + Sat, 23 Jan 2016 20:40:18 +0000 + Audio > Lossless + Bonnie Prince Billy - Pond Scum (2016) + + + + + + + + + + + + + + + + VA-Urban_Dance_Vol.15-2016 + https://api.nzbgeek.info/details/c3a554620060484770cd0d2034b5191d + https://api.nzbgeek.info/api?t=get&id=c3a554620060484770cd0d2034b5191d&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=c3a554620060484770cd0d2034b5191d + Sat, 23 Jan 2016 16:36:37 +0000 + Audio > MP3 + VA-Urban_Dance_Vol.15-2016 + + + + + + + + + + + + + + + + VA-Urban Dance Vol.15-2016 + https://api.nzbgeek.info/details/19ad406b3e21480794e5905f335fe19c + https://api.nzbgeek.info/api?t=get&id=19ad406b3e21480794e5905f335fe19c&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=19ad406b3e21480794e5905f335fe19c + Sat, 23 Jan 2016 16:36:37 +0000 + Audio > MP3 + VA-Urban Dance Vol.15-2016 + + + + + + + + + + + + + + + + VA-Handsup Hits 2 Explicit + https://api.nzbgeek.info/details/44905e4b6dd3be7488dce4959d23103a + https://api.nzbgeek.info/api?t=get&id=44905e4b6dd3be7488dce4959d23103a&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=44905e4b6dd3be7488dce4959d23103a + Sat, 23 Jan 2016 12:41:49 +0000 + Audio > MP3 + VA-Handsup Hits 2 Explicit + + + + + + + + + + + + + + + + VA-Handsup Hits 2 Explicit - + https://api.nzbgeek.info/details/32968fa3ecc7f15d9145477dc3f823aa + https://api.nzbgeek.info/api?t=get&id=32968fa3ecc7f15d9145477dc3f823aa&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=32968fa3ecc7f15d9145477dc3f823aa + Sat, 23 Jan 2016 12:45:14 +0000 + Audio > MP3 + VA-Handsup Hits 2 Explicit - + + + + + + + + + + + + + + + + VA - Ultimate Trance Reflections + https://api.nzbgeek.info/details/878d45b6cac8ccdb8d80ae60ae581af8 + https://api.nzbgeek.info/api?t=get&id=878d45b6cac8ccdb8d80ae60ae581af8&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=878d45b6cac8ccdb8d80ae60ae581af8 + Sat, 23 Jan 2016 12:40:14 +0000 + Audio > MP3 + VA - Ultimate Trance Reflections + + + + + + + + + + + + + + Highborne-Descent-WEB-2016-ENTiTLED + https://api.nzbgeek.info/details/81f9e2d70f1164aef1a9543ccf817331 + https://api.nzbgeek.info/api?t=get&id=81f9e2d70f1164aef1a9543ccf817331&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=81f9e2d70f1164aef1a9543ccf817331 + Sat, 23 Jan 2016 10:24:19 +0000 + Audio > MP3 + Highborne-Descent-WEB-2016-ENTiTLED + + + + + + + + + + + + + + + + Lifelss 2 Life-L2L-WEB-2016-ENTiTLED + https://api.nzbgeek.info/details/da8c9de4ee5e9a4362dcac650ae42ef7 + https://api.nzbgeek.info/api?t=get&id=da8c9de4ee5e9a4362dcac650ae42ef7&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=da8c9de4ee5e9a4362dcac650ae42ef7 + Sat, 23 Jan 2016 09:51:48 +0000 + Audio > MP3 + Lifelss 2 Life-L2L-WEB-2016-ENTiTLED + + + + + + + + + + + + + + + + Lumberjack Feedback-Blackened Visions-WEB-2016-ENTiTLED + https://api.nzbgeek.info/details/682506affbe0a2aaccc0f5f830e5349e + https://api.nzbgeek.info/api?t=get&id=682506affbe0a2aaccc0f5f830e5349e&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=682506affbe0a2aaccc0f5f830e5349e + Sat, 23 Jan 2016 09:45:26 +0000 + Audio > MP3 + Lumberjack Feedback-Blackened Visions-WEB-2016-ENTiTLED + + + + + + + + + + + + + + + + Semidimes-The Same Old Stories-WEB-2016-ENTiTLED + https://api.nzbgeek.info/details/6a1b8e3bdae227fc1f6d1a1819123ea0 + https://api.nzbgeek.info/api?t=get&id=6a1b8e3bdae227fc1f6d1a1819123ea0&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=6a1b8e3bdae227fc1f6d1a1819123ea0 + Sat, 23 Jan 2016 09:35:35 +0000 + Audio > MP3 + Semidimes-The Same Old Stories-WEB-2016-ENTiTLED + + + + + + + + + + + + + + + + The Shrine-Rare Breed-WEB-2016-ENTiTLED + https://api.nzbgeek.info/details/6afe8e823a75b1e122701e58ef31f4ed + https://api.nzbgeek.info/api?t=get&id=6afe8e823a75b1e122701e58ef31f4ed&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=6afe8e823a75b1e122701e58ef31f4ed + Sat, 23 Jan 2016 09:28:49 +0000 + Audio > MP3 + The Shrine-Rare Breed-WEB-2016-ENTiTLED + + + + + + + + + + + + + + + + The Contortionist-Exoplanet (Redux)-WEB-2016-ENTiTLED + https://api.nzbgeek.info/details/c8d7048ddbb7044eecb3722dd532f2b9 + https://api.nzbgeek.info/api?t=get&id=c8d7048ddbb7044eecb3722dd532f2b9&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=c8d7048ddbb7044eecb3722dd532f2b9 + Sat, 23 Jan 2016 07:54:26 +0000 + Audio > MP3 + The Contortionist-Exoplanet (Redux)-WEB-2016-ENTiTLED + + + + + + + + + + + + + + + + Megadeth-Dystopia-Limited Edition-2016-FATHEAD + https://api.nzbgeek.info/details/ec0d7b18c13a076f6417ca7da1018c04 + https://api.nzbgeek.info/api?t=get&id=ec0d7b18c13a076f6417ca7da1018c04&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=ec0d7b18c13a076f6417ca7da1018c04 + Sat, 23 Jan 2016 07:40:43 +0000 + Audio > MP3 + Megadeth-Dystopia-Limited Edition-2016-FATHEAD + + + + + + + + + + + + + + + + Will_Tura_-_Klein_Geluk-WEB-2016-320 + https://api.nzbgeek.info/details/fef4f9ead88a88d33253efdc25909263 + https://api.nzbgeek.info/api?t=get&id=fef4f9ead88a88d33253efdc25909263&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=fef4f9ead88a88d33253efdc25909263 + Sat, 23 Jan 2016 12:38:44 +0000 + Audio > MP3 + Will_Tura_-_Klein_Geluk-WEB-2016-320 + + + + + + + + + + + + + + + Borknagar-Winter Thrice-WEB-2016-ENTiTLED + https://api.nzbgeek.info/details/a932af5da0bc8e35967f4b00f72b2790 + https://api.nzbgeek.info/api?t=get&id=a932af5da0bc8e35967f4b00f72b2790&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=a932af5da0bc8e35967f4b00f72b2790 + Sat, 23 Jan 2016 07:28:49 +0000 + Audio > MP3 + Borknagar-Winter Thrice-WEB-2016-ENTiTLED + + + + + + + + + + + + + + + + 6563dce3-68d0-4ef4-a2cd-284119446e3d + https://api.nzbgeek.info/details/8a5309e266d1fb7676dff6c256de0b6a + https://api.nzbgeek.info/api?t=get&id=8a5309e266d1fb7676dff6c256de0b6a&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=8a5309e266d1fb7676dff6c256de0b6a + Fri, 22 Jan 2016 21:22:47 +0000 + Audio > MP3 + 6563dce3-68d0-4ef4-a2cd-284119446e3d + + + + + + + + + + + + + + AniMe_-_Exterminate_(Extended_DJ_Versions)-WEB-2016-HB + https://api.nzbgeek.info/details/3a1cd051d0d477f35e9255fcbc77b0c9 + https://api.nzbgeek.info/api?t=get&id=3a1cd051d0d477f35e9255fcbc77b0c9&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=3a1cd051d0d477f35e9255fcbc77b0c9 + Fri, 22 Jan 2016 19:41:46 +0000 + Audio > MP3 + AniMe_-_Exterminate_(Extended_DJ_Versions)-WEB-2016-HB + + + + + + + + + + + + + + Turkish Techno-Number Two-VINYL-FLAC-2016-FATHEAD + https://api.nzbgeek.info/details/7cf3e8abcb2a7ac2e660b97d2d7df788 + https://api.nzbgeek.info/api?t=get&id=7cf3e8abcb2a7ac2e660b97d2d7df788&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=7cf3e8abcb2a7ac2e660b97d2d7df788 + Fri, 22 Jan 2016 18:22:34 +0000 + Audio > Lossless + Turkish Techno-Number Two-VINYL-FLAC-2016-FATHEAD + + + + + + + + + + + + + + + + Megadeth-Dystopia-LIMITED EDITION-CD-FLAC-2016-FATHEAD + https://api.nzbgeek.info/details/31482d6031794cfb9cb52eedd4e8b960 + https://api.nzbgeek.info/api?t=get&id=31482d6031794cfb9cb52eedd4e8b960&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=31482d6031794cfb9cb52eedd4e8b960 + Fri, 22 Jan 2016 17:29:50 +0000 + Audio > Lossless + Megadeth-Dystopia-LIMITED EDITION-CD-FLAC-2016-FATHEAD + + + + + + + + + + + + + + + + Lionheart-Love Dont Live Here-CD-FLAC-2016-CATARACT + https://api.nzbgeek.info/details/7fdf940f3d459c1e33c31d6a89ef13f2 + https://api.nzbgeek.info/api?t=get&id=7fdf940f3d459c1e33c31d6a89ef13f2&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=7fdf940f3d459c1e33c31d6a89ef13f2 + Fri, 22 Jan 2016 16:52:20 +0000 + Audio > Lossless + Lionheart-Love Dont Live Here-CD-FLAC-2016-CATARACT + + + + + + + + + + + + + + + + VA - House Clubhits Megamix Vol.6 (2016) + https://api.nzbgeek.info/details/6bbf1e1423d477a6f942224e54eac60b + https://api.nzbgeek.info/api?t=get&id=6bbf1e1423d477a6f942224e54eac60b&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=6bbf1e1423d477a6f942224e54eac60b + Fri, 22 Jan 2016 14:25:24 +0000 + Audio > MP3 + VA - House Clubhits Megamix Vol.6 (2016) + + + + + + + + + + + + + + Rachel Platten-Wildfire-CD-FLAC-2016-PERFECT + https://api.nzbgeek.info/details/a88a5cc38ea18ea66e4e2b6ab918a0b8 + https://api.nzbgeek.info/api?t=get&id=a88a5cc38ea18ea66e4e2b6ab918a0b8&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=a88a5cc38ea18ea66e4e2b6ab918a0b8 + Fri, 22 Jan 2016 10:11:50 +0000 + Audio > Lossless + Rachel Platten-Wildfire-CD-FLAC-2016-PERFECT + + + + + + + + + + + + + + + + Chairlift-Moth-CD-FLAC-2016-PERFECT + https://api.nzbgeek.info/details/427874c8793cdbf0998d1271b5523b49 + https://api.nzbgeek.info/api?t=get&id=427874c8793cdbf0998d1271b5523b49&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=427874c8793cdbf0998d1271b5523b49 + Fri, 22 Jan 2016 10:02:01 +0000 + Audio > Lossless + Chairlift-Moth-CD-FLAC-2016-PERFECT + + + + + + + + + + + + + + + + AniMe_-_Exterminate_(Extended_DJ_Versions)-WEB-2016-HB + https://api.nzbgeek.info/details/573e6c6b7320094b89a9135538559f21 + https://api.nzbgeek.info/api?t=get&id=573e6c6b7320094b89a9135538559f21&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=573e6c6b7320094b89a9135538559f21 + Thu, 21 Jan 2016 23:41:03 +0000 + Audio > MP3 + AniMe_-_Exterminate_(Extended_DJ_Versions)-WEB-2016-HB + + + + + + + + + + + + + + Danforth-Crack House-PROMO-CDR-FLAC-2016-CATARACT + https://api.nzbgeek.info/details/e3e39d2cac1e2f7b509d02f8391ec0ac + https://api.nzbgeek.info/api?t=get&id=e3e39d2cac1e2f7b509d02f8391ec0ac&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=e3e39d2cac1e2f7b509d02f8391ec0ac + Thu, 21 Jan 2016 22:44:44 +0000 + Audio > Lossless + Danforth-Crack House-PROMO-CDR-FLAC-2016-CATARACT + + + + + + + + + + + + + + + + Rimk-Monster Tape-FR-CD-FLAC-2016-Mrflac + https://api.nzbgeek.info/details/5011e7db085ac89d11c6a6378ca7d7d2 + https://api.nzbgeek.info/api?t=get&id=5011e7db085ac89d11c6a6378ca7d7d2&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=5011e7db085ac89d11c6a6378ca7d7d2 + Thu, 21 Jan 2016 16:02:45 +0000 + Audio > Lossless + Rimk-Monster Tape-FR-CD-FLAC-2016-Mrflac + + + + + + + + + + + + + + + + H-Magnum-Gotham City-FR-CD-FLAC-2016-Mrflac + https://api.nzbgeek.info/details/b00e5ec0b5466aaf1b5ae33d0e7c7036 + https://api.nzbgeek.info/api?t=get&id=b00e5ec0b5466aaf1b5ae33d0e7c7036&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=b00e5ec0b5466aaf1b5ae33d0e7c7036 + Thu, 21 Jan 2016 15:06:28 +0000 + Audio > Lossless + H-Magnum-Gotham City-FR-CD-FLAC-2016-Mrflac + + + + + + + + + + + + + + + + VA-Hard Bass 2016-(B2SCD009)-4CD-FLAC-2016-SPL + https://api.nzbgeek.info/details/7cf1334353e9196b8473f0ea361c099b + https://api.nzbgeek.info/api?t=get&id=7cf1334353e9196b8473f0ea361c099b&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=7cf1334353e9196b8473f0ea361c099b + Thu, 21 Jan 2016 14:53:11 +0000 + Audio > Lossless + VA-Hard Bass 2016-(B2SCD009)-4CD-FLAC-2016-SPL + + + + + + + + + + + + + + + + Dylan LeBlanc - Cautionary Tale + https://api.nzbgeek.info/details/34f4a16d0bb579dde38de29f716f1bdf + https://api.nzbgeek.info/api?t=get&id=34f4a16d0bb579dde38de29f716f1bdf&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=34f4a16d0bb579dde38de29f716f1bdf + Wed, 20 Jan 2016 22:03:32 +0000 + Audio > Lossless + Dylan LeBlanc - Cautionary Tale + + + + + + + + + + + + + + + + Stern-2016 01 20-96k-Part 05 + https://api.nzbgeek.info/details/d47f7d44a4aef15259e21323f3780ec1 + https://api.nzbgeek.info/api?t=get&id=d47f7d44a4aef15259e21323f3780ec1&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=d47f7d44a4aef15259e21323f3780ec1 + Wed, 20 Jan 2016 19:46:46 +0000 + Audio > MP3 + Stern-2016 01 20-96k-Part 05 + + + + + + + + + + + + + + Dylan LeBlanc - Cautionary Tale + https://api.nzbgeek.info/details/2dc103b2b15a63f2779e36dd7cf40232 + https://api.nzbgeek.info/api?t=get&id=2dc103b2b15a63f2779e36dd7cf40232&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=2dc103b2b15a63f2779e36dd7cf40232 + Wed, 20 Jan 2016 23:17:02 +0000 + Audio > Lossless + Dylan LeBlanc - Cautionary Tale + + + + + + + + + + + + + + + + Stern-2016 01 20-96k-Part 04 + https://api.nzbgeek.info/details/be3cf337f012387b0e26c5e3a9c51281 + https://api.nzbgeek.info/api?t=get&id=be3cf337f012387b0e26c5e3a9c51281&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=be3cf337f012387b0e26c5e3a9c51281 + Wed, 20 Jan 2016 17:14:13 +0000 + Audio > MP3 + Stern-2016 01 20-96k-Part 04 + + + + + + + + + + + + + + Stern-2016 01 20-96k-Part 03 + https://api.nzbgeek.info/details/218d85679fdc286720b35763380d715b + https://api.nzbgeek.info/api?t=get&id=218d85679fdc286720b35763380d715b&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=218d85679fdc286720b35763380d715b + Wed, 20 Jan 2016 16:37:44 +0000 + Audio > MP3 + Stern-2016 01 20-96k-Part 03 + + + + + + + + + + + + + + VA - NRJ Winter Hits 2016 + https://api.nzbgeek.info/details/158c3782cbb82996b0a67dc43b5ad87b + https://api.nzbgeek.info/api?t=get&id=158c3782cbb82996b0a67dc43b5ad87b&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=158c3782cbb82996b0a67dc43b5ad87b + Wed, 20 Jan 2016 16:13:01 +0000 + Audio > MP3 + VA - NRJ Winter Hits 2016 + + + + + + + + + + + + + + Stern-2016 01 20-96k-Part 02 + https://api.nzbgeek.info/details/fbc231e82faef5759a9742f678946e78 + https://api.nzbgeek.info/api?t=get&id=fbc231e82faef5759a9742f678946e78&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=fbc231e82faef5759a9742f678946e78 + Wed, 20 Jan 2016 14:54:00 +0000 + Audio > MP3 + Stern-2016 01 20-96k-Part 02 + + + + + + + + + + + + + + Stern-2016 01 20-96k-Part 01 + https://api.nzbgeek.info/details/2a6101c2e09ba53517186c3f211525c0 + https://api.nzbgeek.info/api?t=get&id=2a6101c2e09ba53517186c3f211525c0&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=2a6101c2e09ba53517186c3f211525c0 + Wed, 20 Jan 2016 13:52:39 +0000 + Audio > MP3 + Stern-2016 01 20-96k-Part 01 + + + + + + + + + + + + + + German Top 100 Single Charts (16-01-2016)(320) + https://api.nzbgeek.info/details/b051509428cf7d265677e57e55e21a78 + https://api.nzbgeek.info/api?t=get&id=b051509428cf7d265677e57e55e21a78&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=b051509428cf7d265677e57e55e21a78 + Mon, 18 Jan 2016 17:01:02 +0000 + Audio > MP3 + German Top 100 Single Charts (16-01-2016)(320) + + + + + + + + + + + + + + va-dj-sounds-2016.1 - + https://api.nzbgeek.info/details/f6263050e63c48b3c169deea08019ae2 + https://api.nzbgeek.info/api?t=get&id=f6263050e63c48b3c169deea08019ae2&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=f6263050e63c48b3c169deea08019ae2 + Mon, 18 Jan 2016 12:20:07 +0000 + Audio > MP3 + va-dj-sounds-2016.1 - + + + + + + + + + + + + + + + va-dj-sounds-2016.1 + https://api.nzbgeek.info/details/6ce01dc1a826621c1892276af045b938 + https://api.nzbgeek.info/api?t=get&id=6ce01dc1a826621c1892276af045b938&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=6ce01dc1a826621c1892276af045b938 + Mon, 18 Jan 2016 12:20:07 +0000 + Audio > MP3 + va-dj-sounds-2016.1 + + + + + + + + + + + + + + + Anima Tempo-Caged In Memories-WEB-2016-ENTiTLED + https://api.nzbgeek.info/details/be1bd46c6ecdf495bfcc780137853514 + https://api.nzbgeek.info/api?t=get&id=be1bd46c6ecdf495bfcc780137853514&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=be1bd46c6ecdf495bfcc780137853514 + Mon, 18 Jan 2016 09:05:17 +0000 + Audio > MP3 + Anima Tempo-Caged In Memories-WEB-2016-ENTiTLED + + + + + + + + + + + + + + Rhapsody_Of_Fire-Into_The_Legend-Ltd.Ed.-2016-MCA_int + https://api.nzbgeek.info/details/2408b6aff117464c5a577dcc35b8bac1 + https://api.nzbgeek.info/api?t=get&id=2408b6aff117464c5a577dcc35b8bac1&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=2408b6aff117464c5a577dcc35b8bac1 + Mon, 18 Jan 2016 08:31:12 +0000 + Audio > MP3 + Rhapsody_Of_Fire-Into_The_Legend-Ltd.Ed.-2016-MCA_int + + + + + + + + + + + + + + + + Brainstorm-Scary_Creatures-Ltd.Ed.-2016-MCA_int + https://api.nzbgeek.info/details/0eee04bc2599d4b9627944cc3fef6b0a + https://api.nzbgeek.info/api?t=get&id=0eee04bc2599d4b9627944cc3fef6b0a&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=0eee04bc2599d4b9627944cc3fef6b0a + Mon, 18 Jan 2016 08:19:06 +0000 + Audio > MP3 + Brainstorm-Scary_Creatures-Ltd.Ed.-2016-MCA_int + + + + + + + + + + + + + + + + VA-Hardstyle_The_Annual_2016 + https://api.nzbgeek.info/details/be4ac48e3a40631b3611cd50bb471841 + https://api.nzbgeek.info/api?t=get&id=be4ac48e3a40631b3611cd50bb471841&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=be4ac48e3a40631b3611cd50bb471841 + Sun, 17 Jan 2016 18:41:21 +0000 + Audio > MP3 + VA-Hardstyle_The_Annual_2016 + + + + + + + + + + + + + + + + Benjamin Bluemchen-131 Auf Grosser Flossfahrt-DE-AUDIOBOOK-CD-FLAC-2016-VOLDiES + https://api.nzbgeek.info/details/e2afc175525a5ea0f5269ef5a03b507d + https://api.nzbgeek.info/api?t=get&id=e2afc175525a5ea0f5269ef5a03b507d&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=e2afc175525a5ea0f5269ef5a03b507d + Sun, 17 Jan 2016 18:04:21 +0000 + Audio > Lossless + Benjamin Bluemchen-131 Auf Grosser Flossfahrt-DE-AUDIOBOOK-CD-FLAC-2016-VOLDiES + + + + + + + + + + + + + + Tribulation-Melancholia-CDEP-2016 + https://api.nzbgeek.info/details/59b0acad0a23ae5c0ccf2ef7a9d994b6 + https://api.nzbgeek.info/api?t=get&id=59b0acad0a23ae5c0ccf2ef7a9d994b6&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=59b0acad0a23ae5c0ccf2ef7a9d994b6 + Sun, 17 Jan 2016 17:19:05 +0000 + Audio > MP3 + Tribulation-Melancholia-CDEP-2016 + + + + + + + + + + + + + + + + VA - A State Of Trance Radio Top 20 January (2016) + https://api.nzbgeek.info/details/f55fd82e73969feee4a18a561bf61df1 + https://api.nzbgeek.info/api?t=get&id=f55fd82e73969feee4a18a561bf61df1&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=f55fd82e73969feee4a18a561bf61df1 + Sun, 17 Jan 2016 09:28:14 +0000 + Audio > MP3 + VA - A State Of Trance Radio Top 20 January (2016) + + + + + + + + + + + + + + Die drei Fragezeichen Kids - Tanz Der Skelette - Folge 48 - mp3 - by Videomann + https://api.nzbgeek.info/details/39992159d6488a00adcfb39ee26408e8 + https://api.nzbgeek.info/api?t=get&id=39992159d6488a00adcfb39ee26408e8&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=39992159d6488a00adcfb39ee26408e8 + Sun, 17 Jan 2016 00:57:05 +0000 + Audio > MP3 + Die drei Fragezeichen Kids - Tanz Der Skelette - Folge 48 - mp3 - by Videomann + + + + + + + + + + + + + Die Drei Fragezeichen Kids-Tanz Der Skelette-Folge 48-Mp3-By Videomann + https://api.nzbgeek.info/details/9568ef54e722af0d827fadd7c4f3719e + https://api.nzbgeek.info/api?t=get&id=9568ef54e722af0d827fadd7c4f3719e&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=9568ef54e722af0d827fadd7c4f3719e + Sun, 17 Jan 2016 01:12:55 +0000 + Audio > MP3 + Die Drei Fragezeichen Kids-Tanz Der Skelette-Folge 48-Mp3-By Videomann + + + + + + + + + + + + + Deep_Nirvana_Vol__4_25_Deep-House_Tunes + https://api.nzbgeek.info/details/7a545e70c7fb59de811a164089ca3cd4 + https://api.nzbgeek.info/api?t=get&id=7a545e70c7fb59de811a164089ca3cd4&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=7a545e70c7fb59de811a164089ca3cd4 + Sun, 17 Jan 2016 01:28:31 +0000 + Audio > MP3 + Deep_Nirvana_Vol__4_25_Deep-House_Tunes + + + + + + + + + + + + + + + + VA-Straight Outta Compton-OST-CD-FLAC-2016-FORSAKEN + https://api.nzbgeek.info/details/03a7c325f9c7f5c90d3a0b9200f58b86 + https://api.nzbgeek.info/api?t=get&id=03a7c325f9c7f5c90d3a0b9200f58b86&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=03a7c325f9c7f5c90d3a0b9200f58b86 + Sat, 16 Jan 2016 22:48:26 +0000 + Audio > Lossless + VA-Straight Outta Compton-OST-CD-FLAC-2016-FORSAKEN + + + + + + + + + + + + + + + + + Sj0005m4a-Steve_Vai_-_The_Infinite_Steve_Vai_An_Anthology._(2cd)_(2003)-cd-01 + https://api.nzbgeek.info/details/201513128394d6471174f4c42d73e26c + https://api.nzbgeek.info/api?t=get&id=201513128394d6471174f4c42d73e26c&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=201513128394d6471174f4c42d73e26c + Sat, 16 Jan 2016 11:53:58 +0000 + Audio > MP3 + Sj0005m4a-Steve_Vai_-_The_Infinite_Steve_Vai_An_Anthology._(2cd)_(2003)-cd-01 + + + + + + + + + + + + + Varg-Das Ende Aller Lugen-2CD-DELUXE EDITION-DE-2016 + https://api.nzbgeek.info/details/577a981b680b896fce8bb8a5c6829c4d + https://api.nzbgeek.info/api?t=get&id=577a981b680b896fce8bb8a5c6829c4d&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=577a981b680b896fce8bb8a5c6829c4d + Sat, 16 Jan 2016 07:20:17 +0000 + Audio > MP3 + Varg-Das Ende Aller Lugen-2CD-DELUXE EDITION-DE-2016 + + + + + + + + + + + + + + + Nifrost-Motvind-WEB-2016-ENTiTLED + https://api.nzbgeek.info/details/8297d9b82646f8aff603b1f8b277d673 + https://api.nzbgeek.info/api?t=get&id=8297d9b82646f8aff603b1f8b277d673&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=8297d9b82646f8aff603b1f8b277d673 + Sat, 16 Jan 2016 06:35:51 +0000 + Audio > MP3 + Nifrost-Motvind-WEB-2016-ENTiTLED + + + + + + + + + + + + + + + + Die Drei Fragezeichen - Die Rache des Untoten - Folge 179 - MP3 - by Videomann + https://api.nzbgeek.info/details/5c3ef6652729ed046a22f5b749b6c8f1 + https://api.nzbgeek.info/api?t=get&id=5c3ef6652729ed046a22f5b749b6c8f1&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=5c3ef6652729ed046a22f5b749b6c8f1 + Sat, 16 Jan 2016 01:26:59 +0000 + Audio > MP3 + Die Drei Fragezeichen - Die Rache des Untoten - Folge 179 - MP3 - by Videomann + + + + + + + + + + + + + + VA-The_Best_Deep_House__Vol_1 + https://api.nzbgeek.info/details/f8b01b2bf982572922980b45ed587d47 + https://api.nzbgeek.info/api?t=get&id=f8b01b2bf982572922980b45ed587d47&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=f8b01b2bf982572922980b45ed587d47 + Sat, 16 Jan 2016 01:34:44 +0000 + Audio > MP3 + VA-The_Best_Deep_House__Vol_1 + + + + + + + + + + + + + + + + Unantastbar-Hand Aufs Herz-DE-CD-FLAC-2016-NBFLAC + https://api.nzbgeek.info/details/dc53f812c384fadc25af8d800e3870a3 + https://api.nzbgeek.info/api?t=get&id=dc53f812c384fadc25af8d800e3870a3&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=dc53f812c384fadc25af8d800e3870a3 + Fri, 15 Jan 2016 23:01:54 +0000 + Audio > Lossless + Unantastbar-Hand Aufs Herz-DE-CD-FLAC-2016-NBFLAC + + + + + + + + + + + + + + + + Terrorgruppe-Tiergarten-DE-CD-FLAC-2016-NBFLAC + https://api.nzbgeek.info/details/288cd748eb0de993baad85d0ed13b0d2 + https://api.nzbgeek.info/api?t=get&id=288cd748eb0de993baad85d0ed13b0d2&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=288cd748eb0de993baad85d0ed13b0d2 + Fri, 15 Jan 2016 22:51:30 +0000 + Audio > Lossless + Terrorgruppe-Tiergarten-DE-CD-FLAC-2016-NBFLAC + + + + + + + + + + + + + + + + VA - Hardstyle Sounds Vol.05 + https://api.nzbgeek.info/details/031f0dc6f560180a935521a35bc11170 + https://api.nzbgeek.info/api?t=get&id=031f0dc6f560180a935521a35bc11170&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=031f0dc6f560180a935521a35bc11170 + Fri, 15 Jan 2016 19:23:02 +0000 + Audio > MP3 + VA - Hardstyle Sounds Vol.05 + + + + + + + + + + + + + + + 50589e41-899f-4771-87fd-6619e82e7cdb + https://api.nzbgeek.info/details/8ce80d7775a0ef8799d91b432f842194 + https://api.nzbgeek.info/api?t=get&id=8ce80d7775a0ef8799d91b432f842194&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=8ce80d7775a0ef8799d91b432f842194 + Fri, 15 Jan 2016 18:11:57 +0000 + Audio > MP3 + 50589e41-899f-4771-87fd-6619e82e7cdb + + + + + + + + + + + + + + 50589e41-899f-4771-87fd-6619e82e7cdb - + https://api.nzbgeek.info/details/13763b97f75ee8245f9fb14f5ea0b5c7 + https://api.nzbgeek.info/api?t=get&id=13763b97f75ee8245f9fb14f5ea0b5c7&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=13763b97f75ee8245f9fb14f5ea0b5c7 + Fri, 15 Jan 2016 18:11:58 +0000 + Audio > MP3 + 50589e41-899f-4771-87fd-6619e82e7cdb - + + + + + + + + + + + + + + VA-House_do_Brasil,_Vol_2-2016 + https://api.nzbgeek.info/details/8bb7a0045f07af8a39ae4568d366b2ca + https://api.nzbgeek.info/api?t=get&id=8bb7a0045f07af8a39ae4568d366b2ca&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=8bb7a0045f07af8a39ae4568d366b2ca + Fri, 15 Jan 2016 18:09:28 +0000 + Audio > MP3 + VA-House_do_Brasil,_Vol_2-2016 + + + + + + + + + + + + + + + + Todd Edwards-Rinse-FM-01-01-2016-G3L + https://api.nzbgeek.info/details/802c4caf3ac508b49abcd9a5f06ec97c + https://api.nzbgeek.info/api?t=get&id=802c4caf3ac508b49abcd9a5f06ec97c&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=802c4caf3ac508b49abcd9a5f06ec97c + Fri, 15 Jan 2016 15:23:38 +0000 + Audio > MP3 + Todd Edwards-Rinse-FM-01-01-2016-G3L + + + + + + + + + + + + + + Azad-Leben II-DE-CD-FLAC-2016-VOLDiES + https://api.nzbgeek.info/details/59c990e1a889c7e6d436ea069ab90336 + https://api.nzbgeek.info/api?t=get&id=59c990e1a889c7e6d436ea069ab90336&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=59c990e1a889c7e6d436ea069ab90336 + Fri, 15 Jan 2016 10:13:50 +0000 + Audio > Lossless + Azad-Leben II-DE-CD-FLAC-2016-VOLDiES + + + + + + + + + + + + + + + + Rhapsody Of Fire-Into The Legend-WEB-2016-ENTiTLED + https://api.nzbgeek.info/details/9ea015decb1907fb579f8fcd2d08daa2 + https://api.nzbgeek.info/api?t=get&id=9ea015decb1907fb579f8fcd2d08daa2&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=9ea015decb1907fb579f8fcd2d08daa2 + Fri, 15 Jan 2016 08:35:50 +0000 + Audio > MP3 + Rhapsody Of Fire-Into The Legend-WEB-2016-ENTiTLED + + + + + + + + + + + + + + + + VA - Berlin Deep House 2016.1 + https://api.nzbgeek.info/details/10e3b9bc3c73702d3e1be80bdaceba5c + https://api.nzbgeek.info/api?t=get&id=10e3b9bc3c73702d3e1be80bdaceba5c&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=10e3b9bc3c73702d3e1be80bdaceba5c + Thu, 14 Jan 2016 21:02:37 +0000 + Audio > MP3 + VA - Berlin Deep House 2016.1 + + + + + + + + + + + + + + Tech House Masters Fresh + https://api.nzbgeek.info/details/d62c60cd848b93d55d44266338f2918b + https://api.nzbgeek.info/api?t=get&id=d62c60cd848b93d55d44266338f2918b&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=d62c60cd848b93d55d44266338f2918b + Thu, 14 Jan 2016 20:24:55 +0000 + Audio > MP3 + Tech House Masters Fresh + + + + + + + + + + + + + + EDM Essentials - Future Banging Concert + https://api.nzbgeek.info/details/792c03529273a5f8fe44cd29c177ce22 + https://api.nzbgeek.info/api?t=get&id=792c03529273a5f8fe44cd29c177ce22&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=792c03529273a5f8fe44cd29c177ce22 + Thu, 14 Jan 2016 19:25:22 +0000 + Audio > MP3 + EDM Essentials - Future Banging Concert + + + + + + + + + + + + + + + Dance 2016 mystery of sound + https://api.nzbgeek.info/details/a98724fb81f939a0c7b8b5a9852ea731 + https://api.nzbgeek.info/api?t=get&id=a98724fb81f939a0c7b8b5a9852ea731&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=a98724fb81f939a0c7b8b5a9852ea731 + Thu, 14 Jan 2016 18:55:55 +0000 + Audio > MP3 + Dance 2016 mystery of sound + + + + + + + + + + + + + + Hinds-Leave Me Alone-CD-FLAC-2016-FORSAKEN + https://api.nzbgeek.info/details/184ba8c920c587da880b55bacb3d43c6 + https://api.nzbgeek.info/api?t=get&id=184ba8c920c587da880b55bacb3d43c6&apikey=xxx + https://nzbgeek.info/geekseek.php?guid=184ba8c920c587da880b55bacb3d43c6 + Thu, 14 Jan 2016 13:25:03 +0000 + Audio > Lossless + Hinds-Leave Me Alone-CD-FLAC-2016-FORSAKEN + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/Files/Indexers/TorrentRss/AlphaRatio.xml b/src/NzbDrone.Core.Test/Files/Indexers/TorrentRss/AlphaRatio.xml new file mode 100644 index 000000000..c76c27ad5 --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/Indexers/TorrentRss/AlphaRatio.xml @@ -0,0 +1,281 @@ + + + + + + TV :: AlphaRatio + https://alpharatio.cc/ + Personal RSS feed: TV + en-us + Tue, 29 Nov 2016 11:01:28 +0000 + http://blogs.law.harvard.edu/tech/rss + Gazelle Feed Class + + + <![CDATA[TvHD 465989 465960 Good.Behavior.S01E03.PROPER.720p.HDTV.x264-KILLERS]]> + + + @@@@: :
+ :7 :::.7:@.:u7:.X5LF
+ .LFq2 .B@B@B@B@B@B@B@
+ .. i@r rB@B@B@B@B@B@B@@@:
+ : :B@B@B@B@: X@@@@@B@B@B@B@B@B@B@J .u@B.
+ :.YkuB@B@B@BM. @B@B@B@B@B@@@B@B@B@r 2B@B@B@B@i
+ @@@B@r@@@B@B: B@B@B@B@B@@@B@B@B@B@ i@B@B@B@BrO@@@@@
+ @@@@B@B@BB, r:@B@B@@@B@@@B@q@@@BM @L:B.B, @@B@B@B@BO@@@@B@B@B
+ jB@B@B@B@N. 7 B@@@@@B@B@O 8B@. @F B@B@B@@@O@B@B@B@@@@@B.
+ i@B@B@B@: 7 @B@B@B@B@ B: B: i@B@B@B@BNB8B7 .B@@
+ @B@B@. 1G @B@B@B@ @ , @B@:i @u @: 0EB@
+ ;ir , U@B@B .@ B@B L B@B
+ 7 B@B@ q@Bv:@BP @B@
+ i@Bu @ ,S@ @@ B@@@B@B@. BkU@B@ 5Ui @Y@B
+ @@B@v B :@iB B@@@B@B@M@ @B@@@B@BB @@7i iU 5i2vB@k B@
+ @B@B@B7 i @ @B@B@B@B@B@B5 i@B@@@B@ r @
+ @B@B@B@. @ B@B@B@B@B@B@B@. MB @Bu . U @Bi
+ k@P @@@OBi .@ @B@B@B@B@B@B@B. @MBB@@ @F @ 7B@B
+ @B @B@@@B@ 0B@B@B@B@B@B@B@B@ B@B@B@B@F B@B@B. B@B
+ B @B@B@B B: B@B@B@B@@@B@@@BM B@B@B@B@B@: @B@;:B@@@: F@B
+ @. B@@@B@ @Bu i. MX J B@B@B@ @B. @B@B@B@@ B@B@F Si k@@ B@BN
+ @@ @B@B@B@B@B B @B@BOr: .i0F7@B: B@B@ E@ @B@B@r@ B@B@B. @B@B@B5
+ B@B@B@@@B@B@B: @B@B@B@Z: B@B@B@B@B@B@@@B, L@ @B@ B@B@B@B@B@B@B,
+ :@B@B@B@B@B@B@: Y@B@@@@@B@B@B@: 7B@@@0 :@ L@ ,@B@B@B@B@B@B@B@.
+ JB@B@B@B@B@B@B@ U@B@@J, @U.@@B@B@B B@F i@ PB @B@B@B@B@BG.@B@B@B,
+ r@B@B@B@B@B@B@B ; @B@B@ :. @ @J r@ G@ @@: .Z@@7 B@@@@@B@B@B@F
+ ,B@B@B@B@B@B@B@B5 @B@@@ j@B5E@BXB@BvO rB OB B@ B@@@r B@B@B@B@B@B@B@B.
+ @B@B@B@B@B@@@B@i @@ .uO0 :v. @ @B@B @@@ L: ,@ .@ Z@ iB B@B @B@B@@@BNB@ :2@B@B@B@@@B@B
+ :@@B@B@B@B@@@B@B..@@@B@B@@. :YY B@@@: B, B .: u@ .@B@B r@ OB i@B@B@@@B@B
+UB@B@B@B@B@@@B@B@B@B@B LJ, @B@B. @. @ Y @BP .rUB@B@B@B@Z7, B@B@B@B@B@
+,@B@@@B@B@B@B@B@@@ i17. @B@B@v O, B 1B@B@B@B@@@B@@@B@B@B@B@B@B@B@BiB@Bv
+:B@B@B@B@B@B@B@2@B B@B@B@ k. .@ M@@BOB@B@B@B@B@B@B@B@B@B@B@B@B@P @@B
+i@B@B@@@B@B@B@B M@B .7 @B@B@r B. 7B @ YB@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@@@
+u@@B@B@B@@@B@B@ B@B@ 2U8. 5B@@E @, r@ M B@B@B@B@B@B@B@B@B@@@B@B@B@B@@@B@@@@@B
+q@B@B@B@@@B@B@B. @@@i2 @JX :@B@ BY rB @ G@@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@
+BB@B@B@B@B@B@Bi . B@@: @B @ @B@B@B@B@B@B@B@B@B@B@B@@@@@B@B@B@j
+O@B@B@B@B@B@B@@@ u@B@@ Bu B @@B@B@B@BB8 0B@B@B
+SB@B@B@B@B@B@B@B@B @@B@@@B @ .BS r @@@B@
+.@B@B@B@B@B@B@B@B@ B@B@@@B@Br XB@Br 7B u .vB B: B@B@B@B
+ @@@@B@B@B@B@B@B@B@B@B .@B@B@B@B@@M i..@. i i i P @,jB @@B
+ @B@B@B@B@B@B@B@B@B@B@B@i @N@@@B@@@B@B@B @ B@E
+ @B@B@B@@@B@B@B@B@B@B@B@B @ : @@B@B@@@Mi B . @@
+ F@B@@@B@B@B@B@B@@@B@B@B@,@ M @.:v X i B :BM
+ i@B@B@B@B@@@B@B@B@@@@@B@B L ,r , ; B@. @B@,
+ ,M@B@B @B@B@B@B@@@B@B@B@ rL B j :jr@B@@r @B@B@B
+ @B@B@B@B@B@B@B@B .: . .@ : , @@@@B@@
+ ,@B@B@B@B@B@B@B .@X r5BMB: r ,, 7 B B@B @B@B@B@@
+ L@@B@B@B@ 8B@B@ BM:@B@B@B@B@B@@8X80Mu: FB@B@B@B@B@@@B@B@@@B@B q uO@GMFLv@B@B@B@B@ B@Bu
+ @B@B@B@B k@ MYM@@@B@B@F ,. :5@B@B7 Y@B@@@@@B@@@B@@@@@B@@@@@B@ B : B@B@B@ @@B@BL
+ B@@@@@@@ @@7 @EvB@BF B@B@B@@@@@B@BMB@B@B@B@@@B@B@B@ 7 . .@u, i@ B@B@77B@
+ B@B@B@@@O@ : 7., :@B@@@B@B@B@, .LB@@@B@B@B@B@B@B@ @ r@: @::M @@B@B@M@B@
+ :i @B@B@@@Bi5 :: v@B@@@B@B@B B@@@B@@@B@@@B@@ B@@@B@B@B@B@B@B@i
+ : B@B@B@B@B@ @B@B@B@@@B LB@B@@@B@B@B@B@B@@5 @B@B@B@B@B@@@B@
+ .k@B@B@B@B@B@u. B@B@B@B@B2 @B@B@B@B@B@@@B@B@@@B@@@B@B@B@B@B@B@
+ :Lur:F@@@B@B@B@B@@@B B@B@B@B@B@ @@@B@B@B@B@B@@@B@B@B@B@B@B@B@B@B@J
+ .vBMi :,,rB@B@B@B@BO @@@B@B@@@5 :i@@@B@B@B@@@B@B@B@B@@@B@@@B@B@B@@:
+ 1r @B@B@B@B@B@ LB@B@B@B@Bq N@Bi rB@B@B@B@B@B@B@@@B@B@B@B@B@B@@@B@
+ ,L. @@B@B@B@G Li .@B@B@B@B@B@@@B@. B@B@viv@B@B@B@B@B@B@B@B@B@B@B@@@B@B@B@
+ r, @@@@@B@@@B@: : B@B@B@B@B@B@@@B@ :B@ @S@B@B@B@B@B@B@@@B@B@B@@@B@B@B@
+ .j iB@B@B@B@B@B@Bi:; G @@BrLk . i M@B@B@@@B@@@ GB@B@B@B@B@B@B@B@B@@@B@B@B@:
+ ,: : BUB@B@B@B@@@@@N0r@B@B B@B@ : :@B@B@B@@@B @B@B@B@B@B@B@B@B@B@B@B@. B
+ @i ui M .. @B@: B.@@B@BB:O @. B @ i B@@@@@B@ @@@B@B@B@@@B @@B@B@B@@@B @ @
+ E@ @7 ; .U i N@B@ B .@ @ @B@B@B@B@ v .GB @B@B@B@B@@@M: @B@B@B@B@BZ @ Y
+ Bu .@ M 7v7 @ :B@B@@@B@BU @B@B@B@B@ BB@B@B@B@B@Bi B@@@B@B@B@v
+ 5@ @7 L S .q .N@B@@@B@BBB@B@B@B@B@B@kqqSB@B@B@B@B@ @B@B@B@B@@r
+ q : B Z .: 2@B@B@B@..L. . M@B@B@:@BiP @B B@@@B@B@B@
+ ; B@@@B@B. @@@B@: 2@B@B@ i @B@@@B@B@B
+ 7@B@k@B@B@B@B@B@B@B@ B@B@@@@@Bi
+ jB@B@B@@@B@@O @B@B@B@B@
+ B@B@B@B@k B@@@B@@@B
+ rPS: @B@ @B@B@
+ . :: ,ui:,: vL:,:: B@B B@B@B
+ .B@B@B@B@i @B@@@B@5 @B@B :@@@@@ OB@@@ @B@ @B@@@
+ @B@B@ FB@B@ 7@B@B@ .@@@B @B@B iMB@B@S .GB@B@.,B@@L vG0Sqv;:@ L@@ ..E@B
+ B@@@B@@@B@B@B .@B@@ :B@B@ B@B@ @B@B@..B@B@ @B@@@2.B@B@ @B@BBM S@1 S@.
+ @B@B@B. ,@B@B8 B@B@ .@B@@ @B@@ B@B@8:iii;Mv i@B@M .rr B@B@B@B k@r EJ
+ S@B@B@B@ EB@B@B@B. B@B@B@, r@B@B@1 @B@B@B: YB@@@r. 7@B@B@2 @@@@@@MB@B@ ,Bi
+ :r, .i ,: .i: :i ,:.i. i,:: :: :J@B@X7 i. i: r :,:. ,@ a
+ .B n
+ [ P R E S E N T S ] @ t
+ @ i
+ 0 /
+ B 4
+ @ 0
+ . 4
+
+ Good.Behavior.S01E03.PROPER.720p.HDTV.x264-KILLERS
+
+
+ Day: 2016-11-29
+ Resolution: 1280x720
+ Size: 1.02 GiB
+ FrameRate: 23.976
+ Length: 00:49:02.144
+ Bitrate: 2 535 Kbps
+ Note: FLEET is missing the last seg
+
+
+ n***** We all miss you. Come back soon.]]> +
+ Tue, 29 Nov 2016 10:55:58 +0000 + https://alpharatio.cc/torrents.php?action=download&authkey=private_auth_key&torrent_pass=private_torrent_pass&id=465960 + https://alpharatio.cc/torrents.php?action=download&authkey=private_auth_key&torrent_pass=private_torrent_pass&id=465960 + https://alpharatio.cc/torrents.php?id=465989 + Anonymous +
+ + <![CDATA[TvHD 465860 465831 WWE.RAW.2016.11.28.720p.HDTV.x264-KYR]]> + + +ÛÛÛÛÛÛÛÛÛÛÛß°° ÜÜÜÜÜÜ Ü° ßÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛ
+ÛÛÛÛÛÛÛßß°°Ü°ÛÛ²ßÜÛÛÜܲ Üß² ÞÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛ
+ÛÛÛÛÛß Üß°²°²ÛÛÝÛÛÛ±²² ÜÝ Þ ÞÞÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛ
+ÛÛÛÛ° ÛÛݲ޲ÛÛÛÞÛÛ±²ÝÝÞÛ ß ÜÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛ
+²ÛÛ°ÛÝÛÛÛݱÛÛÛ²ÛÛ°ÛÛÞ²Þ Ý ÞÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛßß ßßßÛÛÛÛÛÛÛÛÛÛ
+ÛÛ°ÛÛÞÛÛÛÛ²ÛÛÛÛÛ°Û²ÛÞ² ݲ ÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛß ßÛÛÛÛÛÛÛ
+Û°°ÛÛÛÛÛÛÛÛÛÛÛÛÝÝÛÛßݲ°ÞÞ ÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛ² ²ÛÛÛÛÛ
+°ÛÝÛÛÞÛÛÛ²ÛÛÛÞÛÞÞÞÜÛÛ°²ÝÞ ÞÛÛÛÛÛÛÛÛÛÛÛÛÛÛ² ²ÛÛÛÛ
+Û²ÝÛÛÝÛÛÛÛÛÛÛÞ°Û²ÞÛÛÛ Þ ² ÛÛÛÛÛÛÛÛÛÛÛÛÛÛ ²ÛÛÛ
+ÛÛÛÛÛÛÛÛÛÛÛÛÛÝÝÛÛÝÛÝÛ ² Ý ÛÛÛÛÛÛÛÛÛÛÛÛÛÝ ÛÛÛ
+Û²ÛÛÛÝÛÛÛÛÛÝÛÝÛÞÛÛÞ²Þ ÝÝ ÜÜÜÜ ÛÛÛÛÛÛÛÛÛÛÛÛ ÜÜ Ü ÞÛÛ
+ÛÛÛÛÛÝÛÛÛÛÛÛÞÛÞÛÛ²Þ°Þ ²Ý ßßßÛÛÛ² ÞÛÛÛÛÛÛÛÛÛÛ² ÛÛ²²ÛÛ² ÞÛÛ
+ÛÛÛÛÛÛÞÛÛÛÝÛÞÛÝÛÛ±Þ ² ÝÞ ßÛÛÛÛÛÛ²Þ ÛÛÛÛÛÛÛÛÛÛ ÛÛÛÛÛÛÛ²± ÜÜ ß²ÛÛ
+²ÛÛÛÛÛÛÛÛÛ²ÞÝÛÝÛÛ°Û ² Þ ² ÜßÛÛÛß ÞÞÛÛÛÛÛÛÛÛÛÝÝ ÛÛß ÜÜÜß² ÛÛ
+ÛÛÛÛÝÛÛÛÛÛÛÛÝÛÞÞÛܲ Þ Ý Ý ßßß ßÛÛÛÛÛÛÛÛÛÝß ²ß ²ÞÝ ÞÛ
+²ÛÛÛÝÛÛÛÛÛÞÝÛ²ÞÛÝÛÝ Ý Ý Þ ßÛÛÛÛÛÛÛß Þ ÞÝ ÞÛ
+±ÛÛÛÛÞÛÛÛÛÞÞÛÝÛÛÝÞÛ ÝÞ ßÛÛÛÛÛ Ý ²ÛÜ ÛÛ
+²ÛÛÛÛ°ÛÛÛÛÝÛÛÛÞÛÛ°Û ß Þ Ý Ý ÛÛÛÛÛ ÜÛÛÜ Ý Üß ßÛÛ
+Û²ÛÛÝÝÝÛÛÛÞÛÛÛÛÛÞ²Þ Ý ²Ü²ÛÛ ÜÛÛÛÛÛÛßÛßßß ÜÜÜ²ß ÛÛ
+ÛÝÛÛÞÝ°ÛÛÛÝÛÛÝÛÛÝÛ²Ý Ý ßßÜÛÛÛÛÛÛÛÛÛ ° ß²ß ² ÞÛ
+ÛÛ°ÛÞÛÞÞÛÛÛÞÛÛ°ÛÛ²ÞÛ Ý Ý ÜÜ ÞÞÛÛÛÛÛÛÛÛÜÛÜÜ ±°° ß Û
+ÛÛÛÞÝÛÞÝÛÛÛÝÛÛÛÞÛÛ ²Ý Þ Þ°²ÛÛÛÛ ÞÛÛÛÛÛÛÛÛß ±²±± Þ
+ÛÛÛÞÛÛÝÛÞÛÛݲÞÛÝÛÛÝÞÝ Ý ²ß ÜßݲÛÛÛÛÛÛÛÝÜ ÜܲÛÛÛ²² Þ
+ÛÛÝÞÛÛÛÛÛÛÛÝÛ°Û²ÞÝÛ°ÛÞ ÝÝ ÜܲÞÛÛÛÛÛÛÛÛÝÛ²Ü ÜÛÛÛÛÛÛÛÛ±
+ÛßÜÛÛÛÛÛÝÛÛÞÛÛÞÛ²ÞÝÝÛ ÝÝÝ ° ÝßÛ²ÝÞÛÛÛÛÛÛÛÛÛß ²ÛÛÛÛÛÛÛÛÛ
+Ûßßß ßܲÞÛÛÛÛÛÞÛÞÛÛ°ÛÛÛ ²²±±Þ ßÜÛÛÛÛÛÛÛÛÛÛ ²ÛÛÛÛÛÛÛÛÛÛ
+ ÛÛÞÛÛÛÝÛÛÝÛÛÜßÛÞÛÛÛ²Ý²Ü ÛÛÛÛÛÛÛÛÛÝÞÜ ÜÜÛÛÛÛÛÛÛÛÛÛÛÛÝ Ü Þ
+ ß²ÛÞ²ÛÛÛ ÛÛÞÛÛÛÞÞÛÛ²Û²Þ²Ý ÞÛÛÛÛÛÛÛÛÝÞÛÛ²ÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛß ÞÛ
+ ܲßÜÛÛÛÛÛÛÛÛÛÝÛÛÛÛÛÛÞ²ÛÜÛÛÛÛÛÛÛÛÛÛÜÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛ² ÝÛ
+ ß ÜÜßßÜÛÛÛÛÛÛÞÛÛÛÛÛÛÞÛÛÛÛÛÛÛÛÛÛÛÛÛÛÜÛßßÛÛÛÛÛÛÛÛÛÛÛÛÛ² Þ Û
+ ß ßÛÛÜÜÝÛÛÛ²ÛÛÛÛÛÛÝÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÜÜßÛÛÛÛÛÛÛ²ÜÜß ÝÞ²
+ ßÝÛÛÞÛÛÛÞÛÝÛÛÛÞÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛ²Ü ² Û²
+ ÛÛÞÛÛÛÝÛÛÞÛÝÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÞÛÛÛÛÛß ² Þ²°
+ ß ²ÞÛÛÛÞÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÝÛÛÛ² ² ÜÛ²
+ ÞÛÛÝÛÛÛÛÛÛÞÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÝÛÛ ² ÜÛ²°
+ Üß ÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛßÝß Üß ÜÛ²°
+ ²ÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛßܲ Üß ÜÛÛ²°
+ ÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛ Û² Ü²ß ÜÛÛ²±°
+ ²ÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛ Û² ÜÜßß ÜÛÛ²±°
+ ßÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛ Û² ÜÜßß ÜÜÛÛÛ²±°
+ ²ÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛ Û² ÜÜßß ÜÜÛÛÛÛ²²±°
+ ßÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛÝÛ²ÜÜßß ÜÜÛÛÛÛ²²²±°
+ ²ÛÛÛÛÛÛÛÛÛÛÛÛÛÛÛ²ßßßÜÜÜÜÛÛÛÛ²²²±±°
+ ²ÛÛÛÛÛÛÛÛÛÛÛÛß²ÛÛÛÛÛÛÛÛ²²²±±°
+ ßÛÛÛÛÛÛÛÛÛÛ °²ß²²²²±±°
+ ßÛÛÛÛÛÛÛÛ °²
+ ²ÛÛÛÛÛÛÝ °±Ý
+ ßÛÛÛÛÛÜ°°±²
+ ²ÛÛ۲߲²ß
+ Û²Û
+ Þ²Ý
+ ±
+ °
+
+ ÜÜÜÜÜÜ ÜÜÜÜÜÜ ÜÜÜÜÜÜ
+ ÜßßÛÛß ßÛ² ÜßßÛÛß ßÛ² ÜÜÛÛÛÛÛß ßÛ²
+ ÛÛÛ°±ÛÝ ²ÛÜ ÛÛÛ°±ÛÝ ÜÛÛÛÛßßÛÛÛÛÛ°±ÛÝ
+ ÞÛÛÛ²ÛÛ ÛÛÝ ÞÛÛÛ²ÛÝÛÛÛß ÞÛÛÛÛ²ÛÛ
+ ÜÜ ÛÛÛÛÛÛ² ÞÛÛÝ ÞÛÛÛÛÛ ²ÛÝ ÛÛÛÛÛÛ²
+ ÞÛÛ ²ÛÛÛÛÛÛ ÛÛÛ ÞÛÛÛÛ² ÞÛÛ ²ÛÛÛÛÛÛ
+ ÛÛÝ ²ÛÛÛÛÛ² ÛÛÛ ÛÛÛÛÛÝ ÛÛÝ ²ÛÛÛÛÛ²
+ ÞÛ² ÜÜÛÛÛÛÛÛÜÜÜ ÞÛÛÝ ²ÛÛÛÛÛ ÞÛ² ÜÜÛÛÛÛÛÛÜÜÜ ß
+ ÛÛÛÛÛÛÛÛÛÛÛÛÛßßÛÜ ²ÛÛÜÜÜÛÛÛÛÛÛÝ ÛÛÛÛÛÛÛÛÛÛÛÛÛßßÛÜ
+ ÞÛÛÛßß ßÛÛÛ°ÞÛ ßÛÛÛÛÛÛÛÛÛ² ÞÛÛÛßß ßÛÛÛ°ÞÛ
+ ²Û² ÛÛÛÜÛÝ ÛÛÛÛÛÝ ²ÛÛ ÛÛÛÜÛÝ
+ ÞÛÛÝ ÛÛÛÛÛ² ÞÛÛÛÛÛ ÞÛÛÝ ÛÛÛÛÛ²
+ ÛÛÛ ÛßÛÛÛÛ ÛßÛÛÛÝ ÛÛÛ ÛßÛÛÛÛ
+ ß Û°ÞÛÛÛÝ Û°ÞÛÛÛ ß Û°ÞÛÛÛÝ
+ Û±°ÛÛÛ² Û±°ÛÛÛ² Û±°ÛÛÛ²
+ Þ±±±°ÛÛ Þ±±±°ÛÛ Þ±±±°ÛÛ presents..
+ Þ²±±±±Ý Þ²±±±±Ý Þ²±±±±Ý
+ Û²²²ÛÜ Ü² Û²²²ÛÜ Ü² Û²²²ÛÜ Ü²
+ ßßßßßßßßßßß ßßßßßßßßßßßßßßßßßß ßßßßßßßßßß
+ k n o w y o u r r o l e
+
+ ú úú--Ä-Ä-ÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄ-Ä-Ä--úú ú
+ WWE.RAW.2016.11.28.720p.HDTV.h264-KYR
+ ú úú--Ä-Ä-ÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄ-Ä-Ä--úú ú
+ ÜÜÜÜÜÜÜÜÜÜ ÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜ ÜÜÜÜÜÜÜÜÜÜÜÜÜÜ
+ ÛßÜ°ÛßÜ°Û Û²ÛßÜ°Û°ÜßÛ ÜÜÛßÜ°Û ÛÜÛßÜ°ÛßÜÜÛ°ÜßÛ
+ ÚÄÄÛ ßÜÛ ÜÛÛ Û²Û ÜÛÛ Ü ÛÜÜ°Û ÜÛßÄÄÛ Û Û Û ÜÛÛ Û ÛÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄúú ú
+ ³ Û Û ÛÜßßÛÜßßÛÜßßÛÜÛ ÛÜß ÛÜßßÛ Û ÛÜÛ Û°Û²Û ß Û
+ ³ ßßßßßßßßßßßßßßßßß ßßßßßßßßßßß ßßß ßßßßß ßßßßß
+ ³
+ ³ titleú[ WWE RAW ]ú
+ ³ genreú[ Wrestling ]ú crfú[ 23 ]ú
+ ³ rel. dateú[ 11.28.16 ]ú formatú[ x264 ]ú
+ ³ air dateú[ 11.28.16 ]ú sourceú[ HDTV ]ú
+ ³ runtimeú[ 2h 13m 48s ]ú bitrateú[ 4111kbps ]ú
+ ³ filesizeú[ 4.28 GB ]ú resolu.ú[ 1280x720 ]ú
+ ³ rar countú[ 93x50mb ]ú framesú[ 59.940 ]ú
+ ³ ú[ audioú[ 384 kbps AC3 5.1 ]ú
+ ³ ú[ locationú[ USA ]ú
+ ³ ú[ ]ú
+ ³ url ú[ http://www.wwe.com ]ú
+ ³
+ ³
+ ³ ÜÜÜÜÜÜÜÜÜÜ ÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜ ÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜ
+ ³ ÛßÜ°ÛßÜ°Û Û²ÛßÜ°Û°ÜßÛ ÜÜÛßÜ°Û ÛßÜ°Û°ÜßÛÜ ÜÛßÜ°Û ÜÜÛ
+ ³ ú úúÄÄÄÄÄ-Û ßÜÛ ÜÛÛ Û²Û ÜÛÛ Ü ÛÜÜ°Û ÜÛßÄÄÛ Û Û Û ÛÛ ÛÛ ÜÛÛÜÜ°ÛÄÄ´
+ ³ Û Û ÛÜßßÛÜßßÛÜßßÛÜÛ ÛÜß ÛÜßßÛ Û Û Û ß ÛÛ°ÛÛÜßßÛÜß Û ³
+ ³ ßßßßßßßßßßßßßßßßß ßßßßßßßßßßß ßßßßßßßßßßßß ßßßßßßßß ³
+ ³ ³
+ ³ ³
+ ³ Enjoy! ³
+ ³ ³
+ ³ ³
+ ³ ÜÜÜÜÜÜÜÜÜÜÜ ÜÜÜÜÜÜÜ ÜÜÜÜÜÜÜÜÜÜÜÜÜÜ ³
+ ³ ÛßÜ°ÛßÜ°Û°ÜßÛßÛ°ÛßÜ°Û ÛÜÛßÜ°ÛßÜÜÛ°ÜßÛ ³
+ ÃÄÄÛ ÝßÛ ßÜÛ Û Û Û Û ß ÛÄÄÛ°Û Û Û ÜÛÛ Û ÛÄÄÄÄÄÄÄÄÄÄÄÄÄÄ-ÄÄÄÄÄúú ú ³
+ Û ß Û Û Û ß Û ß Û Ûßß Û Û Û Û°Û²Û ß Û ³
+ ßßßßßßßßßßßßßßßßßßß ßßßßßßßßß ßßßßß ³
+ ³
+  group info ³
+ ³
+ Know Your Role and Shut Your Mouth! ³
+ ³
+  we are now looking for... ³
+ ³
+ (a) capper(s) of cable, PPV, good upspeed advantageous ³
+ .. contact in the usual way. ³
+ ³
+  KYR respects... ³
+ ³
+ everyone keeping it real and oldschool. we love ya! ³
+ ³
+ Ü ÜÜÜÜÜÜÜÜ ÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜ ³
+ ÜÜÜܲ ÜÜÜ Ûܲ ÜÜÜ ÜÜÜÜÜÜÜ ²Ý ³
+ ú úúÄÄÄÄÄ--ÄÄÄÄÄÄÄÄÄÄÄÄÛ ÜÜ ÝÞÛÛÝÜÜ ÝÞÛÛÝßß ÞÛÛÝÞÛ ÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄ´
+ Û ÛÛ ÜÛÛß ÛÛ ÜÛÛ² ÛÛ ÜÛÛß ÛÝ K N O W ³
+ ascii crafted by Û ÛÛÛÛ²Ü ÞÛÛ²ß Ü ÛÛÛÛ²Ü ßÛ ³
+ Û ÛÛ ßÛÛ² ÛÛ Ü²Û ÛÛ ßÛÛ² ²Ý Y O U R ³
+ h8`!HiGHONASCii Û ÛÛ ÝÞÛÛÝ ÛÛ Û Û ÛÛ ÝÞÛÛÝÞÛ ³
+ Û Û² Û ÛÛ² Û² Û Û Û² Û ÛÛ² Û R O L E ³
+ ú úúÄ-Ä----ÄÄÄÄÄÄÄÄÄÄÄÄÛÜÜÜܲÜÜÜÜÜÜÜܲ ÛÜÜÜܲÜÜÜÜܲ ÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÄÙ
+ ÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜ ÜÜÜÜÜÜÜÜÜÜ ÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜ ÜÜÜ ÜÜÜÜÜÜÜ
+ °±²Û ÜßÛ ÜßÛ ÜßÛ ÜßÛ²²ÛßÜ°ÛßÜ°Û Û²ÛßÜ°Û°ÜßÛ ÜÜÛßÜ°Û ÜÜÛ²²Û°ÜßÛßÜ°Û°ÜßÛ²±°
+ ° °±Û Û Û Û Û Û Û Û Û+±Û ßÜÛ ÜÛÛ Û±Û ÜÛÛ Ü ÛÜÜ°Û ÜÛÛÜÜ°Û±±Û Ü Û Û Û Û Û±° °
+ °±²ÛÜß°ÛÜß°ÛÜß°ÛÜß°Û²²Û Û ÛÜßßÛÜßßÛÜßßÛÜÛ ÛÜß ÛÜßßÛÜß Û²²ÛÜÛ ÛÜÛ Û ßÜÛ²±°
+ ßßßßßßßßßßßßßßßßß ßßßßßßßßßßßßßßßßß ßßßßßßßßßßßßßßß ßßß ßßßßßß
+ ÜÜÜÜÜÜÜ ÜÜ ÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜÜ
+ °±²²ÛßÜ Û°ÜßÛßÛ°ÛßÜ°ÛÜ ÜÛÜÛßÜ°ÛßÜ°Û°Û°Û°Û²²±°
+ ° °±±Û ÛÛÛ Û Û Û Û Û ÛÛ ÛÛ°Û Û Û ÝßÛ Û Û Û±±° °
+ °±²²ÛÜß°Û ß Û ß ÛÜÛ ÛÛ°ÛÛ Û Û Û°ß ÛßÛßÛßÛ²²±°
+ ßßßßßßßßßßßß ßßßßßßßßßßßßßßßßßßßßßß]]> +
+ Tue, 29 Nov 2016 05:08:18 +0000 + https://alpharatio.cc/torrents.php?action=download&authkey=private_auth_key&torrent_pass=private_torrent_pass&id=465831 + https://alpharatio.cc/torrents.php?action=download&authkey=private_auth_key&torrent_pass=private_torrent_pass&id=465831 + https://alpharatio.cc/torrents.php?id=465860 + Anonymous +
+
+
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/TorrentRss/ImmortalSeed.xml b/src/NzbDrone.Core.Test/Files/Indexers/TorrentRss/ImmortalSeed.xml index 801acdaea..506fdacf3 100644 --- a/src/NzbDrone.Core.Test/Files/Indexers/TorrentRss/ImmortalSeed.xml +++ b/src/NzbDrone.Core.Test/Files/Indexers/TorrentRss/ImmortalSeed.xml @@ -289,7 +289,7 @@ <br /> Finally, theres Penny. Penny is the gorgeous girl next-door to Leonard and Sheldons apartment, and though she does not have any knowledge in physics or science, she makes success by being a funny character frequently having hilarious comments and on- and off-going relationships.<br /> <br /> - All together, this unit of comedians make the shows half-hour episodes pure enjoyment and whether you like physics, women or neither, this show is surely going to get you laughing!<br /> + All together, this unit of comedians make the shows half-hour tracks pure enjoyment and whether you like physics, women or neither, this show is surely going to get you laughing!<br /> <br /> <div style="text-align: center;"><span id="lazyload"><span id="1776335379_620778c1a5d72709be3fc47a2262cdb9">&nbsp;</span> <a href="https://immortalseed.me/images/modpics/41872.jpg" id="ts_show_preview" alt=""><img src="https://immortalseed.me/images/modpics/41872.jpg" border="0" alt="" onload="TSResizeImage(this, '1776335379_620778c1a5d72709be3fc47a2262cdb9');" /></a></span></div> @@ -375,7 +375,7 @@ <br /> Finally, theres Penny. Penny is the gorgeous girl next-door to Leonard and Sheldons apartment, and though she does not have any knowledge in physics or science, she makes success by being a funny character frequently having hilarious comments and on- and off-going relationships.<br /> <br /> - All together, this unit of comedians make the shows half-hour episodes pure enjoyment and whether you like physics, women or neither, this show is surely going to get you laughing!<br /> + All together, this unit of comedians make the shows half-hour tracks pure enjoyment and whether you like physics, women or neither, this show is surely going to get you laughing!<br /> <br /> <div style="text-align: center;"><span id="lazyload"><span id="1099410497_49fffcedd2eef0506d6b92e66fc4f3d4">&nbsp;</span> <a href="https://immortalseed.me/images/modpics/57412.jpg" id="ts_show_preview" alt=""><img src="https://immortalseed.me/images/modpics/57412.jpg" border="0" alt="" onload="TSResizeImage(this, '1099410497_49fffcedd2eef0506d6b92e66fc4f3d4');" /></a></span></div> @@ -473,7 +473,7 @@ <br /> Finally, theres Penny. Penny is the gorgeous girl next-door to Leonard and Sheldons apartment, and though she does not have any knowledge in physics or science, she makes success by being a funny character frequently having hilarious comments and on- and off-going relationships.<br /> <br /> - All together, this unit of comedians make the shows half-hour episodes pure enjoyment and whether you like physics, women or neither, this show is surely going to get you laughing!<br /> + All together, this unit of comedians make the shows half-hour tracks pure enjoyment and whether you like physics, women or neither, this show is surely going to get you laughing!<br /> <br /> <div style="text-align: center;"><span id="lazyload"><span id="1183173375_229d6c19d62f235698f60e876f0f5ab4">&nbsp;</span> <a href="https://immortalseed.me/images/modpics/55069.jpg" id="ts_show_preview" alt=""><img src="https://immortalseed.me/images/modpics/55069.jpg" border="0" alt="" onload="TSResizeImage(this, '1183173375_229d6c19d62f235698f60e876f0f5ab4');" /></a></span></div> diff --git a/src/NzbDrone.Core.Test/Files/Indexers/Waffles/waffles.xml b/src/NzbDrone.Core.Test/Files/Indexers/Waffles/waffles.xml new file mode 100644 index 000000000..7c5025f7b --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/Indexers/Waffles/waffles.xml @@ -0,0 +1,586 @@ + + + + Waffles + https://waffles.ch + To make the links go to the details page, add &i to the end of the URL. + en-usde + Copyright 2009 Waffles + waffles@waffles.ch + + artist:coldplay + https://waffles.ch/favicon.ico + https://waffles.ch/browse.php?c0=1&q=artist%3Acoldplay&limit=50 + 16 + 16 + artist:coldplay + + + Coldplay - Kaleidoscope EP (FLAC HD) [2017-Web-FLAC-Lossless] + + <table id="waffles-rss-t1166992" class="rsstable" border=0 cellspacing=0 cellpadding=10><tr><td>Link: <a href="https://waffles.ch/details.php?id=1166992&#x26;hit=1">Description/Comments Page</a><br/> + Genre: Alternative<br/> + Year: 2017<br/> + Size: 552668227<br/> + </td><td>Link: <a href="https://waffles.ch/download.php/xxx/1166992/Coldplay%20-%20Kaleidoscope%20EP%20%28FLAC%20HD%29%20%5B2017-Web-FLAC-Lossless%5D.torrent?passkey=123456789&#x26;uid=xxx&#x26;rss=1">Download Torrent</a><br/> + Seeders: 11<br/> + Leechers: 0<br/> + Format: FLAC<br/> + </td><td>Link: <a href="https://waffles.ch/userdetails.php?id=0">*Anonymous*</a> (Uploader)<br/> + Comments: 0<br/> + Files: 6<br/> + Bitrate: Lossless<br/> + </tr></td></table><br/> + Coldplay – Kaleidoscope EP (FLAC HD)<br /> + <br /> + Year: 2017/07/13<br /> + Genre: Alternative<br /> + (24bit/96kHz)<br /> + <br /> + Tracklist<br /> + 1. Coldplay – All I Can Think About Is You (04:34)<br /> + 2. Coldplay &amp; Big Sean – Miracles (Someone Special) (04:36)<br /> + 3. Coldplay – A L I E N S (04:42)<br /> + 4. Coldplay &amp; The Chainsmokers – Something Just Like This (Tokyo Remix) (04:33)<br /> + 5. Coldplay – Hypnotised (EP Mix) (06:31) + + https://waffles.ch/download.php/xxx/1166992/Coldplay%20-%20Kaleidoscope%20EP%20%28FLAC%20HD%29%20%5B2017-Web-FLAC-Lossless%5D.torrent?passkey=123456789&uid=xxx&rss=1 + https://waffles.ch/details.php?id=1166992&hit=1 + Alternative + 2017-07-16T09:51:54Z + 1166992-9d438d94dc7fc934b801439f4c05458b + + + Coldplay - Kaleidoscope EP [2017-Web-MP3-320] + + <table id="waffles-rss-t1166785" class="rsstable" border=0 cellspacing=0 cellpadding=10><tr><td>Link: <a href="https://waffles.ch/details.php?id=1166785&#x26;hit=1">Description/Comments Page</a><br/> + Genre: Alternative<br/> + Year: 2017<br/> + Size: 69580307<br/> + </td><td>Link: <a href="https://waffles.ch/download.php/xxx/1166785/Coldplay%20-%20Kaleidoscope%20EP%20%5B2017-Web-MP3-320%5D.torrent?passkey=123456789&#x26;uid=xxx&#x26;rss=1">Download Torrent</a><br/> + Seeders: 11<br/> + Leechers: 0<br/> + Format: MP3<br/> + </td><td>Link: <a href="https://waffles.ch/userdetails.php?id=230211">idenline</a> (Uploader)<br/> + Comments: 0<br/> + Files: 6<br/> + Bitrate: 320<br/> + </tr></td></table><br/> + <img src="https://i.imgur.com/FMJLwf4.jpg" style="max-width:640px;max-height:480px;" /><br /> + <br /> + <span style="font-size:large"><b>Coldplay – Kaleidoscope EP</b></span><br /> + <b>Year:</b> 2017-07-13<br /> + <b>Genre:</b> <a href="https://anon.click/https://www.waffles.fm/tags.php?tag=alternative" target="_blank">Alternative</a><br /> + <br /> + <span style="font-size:medium"><b>Tracklist</b></span><br /> + 1. Coldplay – All I Can Think About Is You (04:34)<br /> + 2. Coldplay &amp; Big Sean – Miracles (Someone Special) (04:36)<br /> + 3. Coldplay – A L I E N S (04:42)<br /> + 4. Coldplay &amp; The Chainsmokers – Something Just Like This (Tokyo Remix) (04:33)<br /> + 5. Coldplay – Hypnotised (EP Mix) (06:31)<br /> + <br /> + <b>Total length:</b> 24:56<br /> + <br /> + More information: <a href="https://anon.click/https://itunes.apple.com/us/album/kaleidoscope-ep/id1248904974?uo=4" target="_blank">https://itunes.apple.com/us/album/kaleidoscope-ep/id1248904974?uo=4</a> + + https://waffles.ch/download.php/xxx/1166785/Coldplay%20-%20Kaleidoscope%20EP%20%5B2017-Web-MP3-320%5D.torrent?passkey=123456789&uid=xxx&rss=1 + https://waffles.ch/details.php?id=1166785&hit=1 + Alternative + 2017-07-14T20:30:17Z + 1166785-bb582163c070d5e2bd297b8d72308f7d + + + Coldplay - Kaleidoscope EP [2017-Web-FLAC-Lossless] + + <table id="waffles-rss-t1166784" class="rsstable" border=0 cellspacing=0 cellpadding=10><tr><td>Link: <a href="https://waffles.ch/details.php?id=1166784&#x26;hit=1">Description/Comments Page</a><br/> + Genre: Alternative<br/> + Year: 2017<br/> + Size: 176342030<br/> + </td><td>Link: <a href="https://waffles.ch/download.php/xxx/1166784/Coldplay%20-%20Kaleidoscope%20EP%20%5B2017-Web-FLAC-Lossless%5D.torrent?passkey=123456789&#x26;uid=xxx&#x26;rss=1">Download Torrent</a><br/> + Seeders: 43<br/> + Leechers: 0<br/> + Format: FLAC<br/> + </td><td>Link: <a href="https://waffles.ch/userdetails.php?id=230211">idenline</a> (Uploader)<br/> + Comments: 1<br/> + Files: 6<br/> + Bitrate: Lossless<br/> + </tr></td></table><br/> + <img src="https://i.imgur.com/FMJLwf4.jpg" style="max-width:640px;max-height:480px;" /><br /> + <br /> + <span style="font-size:large"><b>Coldplay – Kaleidoscope EP</b></span><br /> + <b>Year:</b> 2017-07-13<br /> + <b>Genre:</b> <a href="https://anon.click/https://www.waffles.fm/tags.php?tag=alternative" target="_blank">Alternative</a><br /> + <br /> + <span style="font-size:medium"><b>Tracklist</b></span><br /> + 1. Coldplay – All I Can Think About Is You (04:34)<br /> + 2. Coldplay &amp; Big Sean – Miracles (Someone Special) (04:36)<br /> + 3. Coldplay – A L I E N S (04:42)<br /> + 4. Coldplay &amp; The Chainsmokers – Something Just Like This (Tokyo Remix) (04:33)<br /> + 5. Coldplay – Hypnotised (EP Mix) (06:31)<br /> + <br /> + <b>Total length:</b> 24:56 + + https://waffles.ch/download.php/xxx/1166784/Coldplay%20-%20Kaleidoscope%20EP%20%5B2017-Web-FLAC-Lossless%5D.torrent?passkey=123456789&uid=xxx&rss=1 + https://waffles.ch/details.php?id=1166784&hit=1 + Alternative + 2017-07-14T20:28:38Z + 1166784-344a753385ac46905e90b03bc674a3f6 + + + Coldplay - Kaleidoscope EP [2017-Web-MP3-V0(VBR)] + + <table id="waffles-rss-t1166765" class="rsstable" border=0 cellspacing=0 cellpadding=10><tr><td>Link: <a href="https://waffles.ch/details.php?id=1166765&#x26;hit=1">Description/Comments Page</a><br/> + Genre: Rock<br/> + Year: 2017<br/> + Size: 51228307<br/> + </td><td>Link: <a href="https://waffles.ch/download.php/xxx/1166765/Coldplay%20-%20Kaleidoscope%20EP%20%5B2017-Web-MP3-V0%28VBR%29%5D.torrent?passkey=123456789&#x26;uid=xxx&#x26;rss=1">Download Torrent</a><br/> + Seeders: 17<br/> + Leechers: 0<br/> + Format: MP3<br/> + </td><td>Link: <a href="https://waffles.ch/userdetails.php?id=0">*Anonymous*</a> (Uploader)<br/> + Comments: 0<br/> + Files: 6<br/> + Bitrate: V0<br/> + </tr></td></table><br/> + From Redacted<br /> + 2017 - Parlophone / WEB<br /> + <br /> + <img src="https://i.imgur.com/FMJLwf4.jpg" style="max-width:640px;max-height:480px;" /><br /> + <br /> + <span style="font-size:large"><b>Tracklist</b></span><br /> + <b>1.</b> Coldplay – All I Can Think About Is You <i>(04:34)</i><br /> + <b>2.</b> Coldplay &amp; Big Sean – Miracles (Someone Special) <i>(04:36)</i><br /> + <b>3.</b> Coldplay – A L I E N S <i>(04:42)</i><br /> + <b>4.</b> Coldplay &amp; The Chainsmokers – Something Just Like This (Tokyo Remix) <i>(04:33)</i><br /> + <b>5.</b> Coldplay – Hypnotised (EP Mix) <i>(06:31)</i><br /> + <br /> + <b>Total length:</b> 24:56<br /> + <br /> + More information: <a href="https://anon.click/https://itunes.apple.com/us/album/kaleidoscope-ep/id1248904974?uo=4" target="_blank">https://itunes.apple.com/us/album/kaleidoscope-ep/id1248904974?uo=4</a> + + https://waffles.ch/download.php/xxx/1166765/Coldplay%20-%20Kaleidoscope%20EP%20%5B2017-Web-MP3-V0%28VBR%29%5D.torrent?passkey=123456789&uid=xxx&rss=1 + https://waffles.ch/details.php?id=1166765&hit=1 + Rock + 2017-07-14T16:24:28Z + 1166765-6e93009d6a6d1689133a292bfc6c79ce + + + Coldplay - All I Can Think About Is You [Single] [2017-Web-MP3-320] (Scene) + + <table id="waffles-rss-t1163589" class="rsstable" border=0 cellspacing=0 cellpadding=10><tr><td>Link: <a href="https://waffles.ch/details.php?id=1163589&#x26;hit=1">Description/Comments Page</a><br/> + Genre: Alternative<br/> + Year: 2017<br/> + Size: 11407841<br/> + </td><td>Link: <a href="https://waffles.ch/download.php/xxx/1163589/Coldplay%20-%20All%20I%20Can%20Think%20About%20Is%20You%20%5BSingle%5D%20%5B2017-Web-MP3-320%5D%20%28Scene%29.torrent?passkey=123456789&#x26;uid=xxx&#x26;rss=1">Download Torrent</a><br/> + Seeders: 2<br/> + Leechers: 0<br/> + Format: MP3<br/> + </td><td>Link: <a href="https://waffles.ch/userdetails.php?id=230211">idenline</a> (Uploader)<br/> + Comments: 0<br/> + Files: 5<br/> + Bitrate: 320<br/> + </tr></td></table><br/> + <img src="https://lut.im/dOcMCtGdnH/RF3xaArHwxC67lZq.jpg" style="max-width:640px;max-height:480px;" /><br /> + <br /> + <span style="font-size:small"><b>Coldplay – All I Can Think About Is You</b></span><br /> + <br /> + <span style="font-size:large"><b>Tracklist</b></span><br /> + <br /> + <b>1.</b>Coldplay – All I Can Think About Is You <i>(04:34)</i><br /> + <br /> + <b>Total length:</b> 04:34<br /> + <br /> + From the forthcoming EP Kaleidoscope + + https://waffles.ch/download.php/xxx/1163589/Coldplay%20-%20All%20I%20Can%20Think%20About%20Is%20You%20%5BSingle%5D%20%5B2017-Web-MP3-320%5D%20%28Scene%29.torrent?passkey=123456789&uid=xxx&rss=1 + https://waffles.ch/details.php?id=1163589&hit=1 + Alternative + 2017-06-18T20:13:26Z + 1163589-590295b689e5e9bfd193bedd5a2108cc + + + Coldplay - Parachutes [24bit-192kHz] [2000-Web-FLAC-Lossless] + + <table id="waffles-rss-t1159282" class="rsstable" border=0 cellspacing=0 cellpadding=10><tr><td>Link: <a href="https://waffles.ch/details.php?id=1159282&#x26;hit=1">Description/Comments Page</a><br/> + Genre: Alternative<br/> + Year: 2000<br/> + Size: 1789688738<br/> + </td><td>Link: <a href="https://waffles.ch/download.php/xxx/1159282/Coldplay%20-%20Parachutes%20%5B24bit-192kHz%5D%20%5B2000-Web-FLAC-Lossless%5D.torrent?passkey=123456789&#x26;uid=xxx&#x26;rss=1">Download Torrent</a><br/> + Seeders: 3<br/> + Leechers: 1<br/> + Format: FLAC<br/> + </td><td>Link: <a href="https://waffles.ch/userdetails.php?id=236337">calabasas</a> (Uploader)<br/> + Comments: 0<br/> + Files: 11<br/> + Bitrate: Lossless<br/> + </tr></td></table><br/> + Purchased from HDTracks - <a href="https://anon.click/http://www.hdtracks.com/parachutes-315927?format=FLAC" target="_blank">http://www.hdtracks.com/parachutes-315927?format=FLAC</a><br /> + <br /> + <img src="http://imgur.com/lPFxYfS.jpg" style="max-width:640px;max-height:480px;" /><br /> + <br /> + <b><span style="font-size:large">Coldplay - Parachutes</span></b><br /> + <br /> + <b>Label/Cat#:</b> Parlophone<br /> + <b>Country:</b> UK<br /> + <b>Year:</b> 2000<br /> + <b>Genre:</b> Alternative Rock, Britpop<br /> + <b>Format:</b> WEB, Album<br /> + <br /> + <b>Tracklist</b><br /> + <br /> + <b>1.</b> Don&#039;t Panic <i>(02:17)</i><br /> + <b>2.</b> Shiver <i>(04:59)</i><br /> + <b>3.</b> Spies <i>(05:18)</i><br /> + <b>4.</b> Sparks <i>(03:47)</i><br /> + <b>5.</b> Yellow <i>(04:29)</i><br /> + <b>6.</b> Trouble <i>(04:31)</i><br /> + <b>7.</b> Parachutes <i>(00:46)</i><br /> + <b>8.</b> High Speed <i>(04:14)</i><br /> + <b>9.</b> We Never Change <i>(04:09)</i><br /> + <b>10.</b> Everything&#039;s Not Lost / Life is for Living (Hidden Track) <i>(07:15)</i><br /> + <br /> + <b>Total length</b>: 41:47 + + https://waffles.ch/download.php/xxx/1159282/Coldplay%20-%20Parachutes%20%5B24bit-192kHz%5D%20%5B2000-Web-FLAC-Lossless%5D.torrent?passkey=123456789&uid=xxx&rss=1 + https://waffles.ch/details.php?id=1159282&hit=1 + Alternative + 2017-05-18T22:18:16Z + 1159282-015073d67c0b4e7ac2a00e95d98603bb + + + Coldplay - A Head Full of Dreams (Japanese Tour Edition) [2017-FLAC-Lossless-Log] + + <table id="waffles-rss-t1154242" class="rsstable" border=0 cellspacing=0 cellpadding=10><tr><td>Link: <a href="https://waffles.ch/details.php?id=1154242&#x26;hit=1">Description/Comments Page</a><br/> + Genre: Rock<br/> + Year: 2017<br/> + Size: 595533199<br/> + </td><td>Link: <a href="https://waffles.ch/download.php/xxx/1154242/Coldplay%20-%20A%20Head%20Full%20of%20Dreams%20%28Japanese%20Tour%20Edition%29%20%5B2017-FLAC-Lossless-Log%5D.torrent?passkey=123456789&#x26;uid=xxx&#x26;rss=1">Download Torrent</a><br/> + Seeders: 12<br/> + Leechers: 0<br/> + Format: FLAC<br/> + </td><td>Link: <a href="https://waffles.ch/userdetails.php?id=6301">heirloom</a> (Uploader)<br/> + Comments: 0<br/> + Files: 25<br/> + Bitrate: Lossless<br/> + </tr></td></table><br/> + <b>Japan Tour Edition:</b><br /> + CD1<br /> + 01 A Head Full of Dreams<br /> + 02 Birds<br /> + 03 Hymn for the Weekend<br /> + 04 Everglow<br /> + 05 Adventure of a Lifetime<br /> + 06 Fun<br /> + 07 Kaleidoscope<br /> + 08 Army of One / X Marks the Spot<br /> + 09 Amazing Day<br /> + 10 Colour Spectrum<br /> + 11 Up&amp;Up<br /> + 12 Miracles<br /> + CD2<br /> + 01 Adventure of a Lifetime (Matoma Remix)<br /> + 02 Hymn for the Weekend (SeeB Remix)<br /> + 03 Up&amp;Up (Freedo Remix)<br /> + 04 Magic (Live at Tokyo Dome City Hall, Tokyo)<br /> + 05 Clocks (Live at Tokyo Dome City Hall, Tokyo)<br /> + 06 Viva la Vida (Live at Tokyo Dome City Hall, Tokyo)<br /> + 07 Oceans (Live at Tokyo Dome City Hall, Tokyo)<br /> + 08 A Sky Full of Stars (Live at Tokyo Dome City Hall, Tokyo) + + https://waffles.ch/download.php/xxx/1154242/Coldplay%20-%20A%20Head%20Full%20of%20Dreams%20%28Japanese%20Tour%20Edition%29%20%5B2017-FLAC-Lossless-Log%5D.torrent?passkey=123456789&uid=xxx&rss=1 + https://waffles.ch/details.php?id=1154242&hit=1 + Rock + 2017-04-15T12:52:32Z + 1154242-56d56f2d649a9bd6a61b25d10f92d8b2 + + + Coldplay - A Head Full of Dreams (Japanese Tour Edition) [2017-CD-MP3-320-Log] + + <table id="waffles-rss-t1154063" class="rsstable" border=0 cellspacing=0 cellpadding=10><tr><td>Link: <a href="https://waffles.ch/details.php?id=1154063&#x26;hit=1">Description/Comments Page</a><br/> + Genre: Rock<br/> + Year: 2017<br/> + Size: 213357581<br/> + </td><td>Link: <a href="https://waffles.ch/download.php/xxx/1154063/Coldplay%20-%20A%20Head%20Full%20of%20Dreams%20%28Japanese%20Tour%20Edition%29%20%5B2017-CD-MP3-320-Log%5D.torrent?passkey=123456789&#x26;uid=xxx&#x26;rss=1">Download Torrent</a><br/> + Seeders: 4<br/> + Leechers: 0<br/> + Format: MP3<br/> + </td><td>Link: <a href="https://waffles.ch/userdetails.php?id=6301">heirloom</a> (Uploader)<br/> + Comments: 0<br/> + Files: 25<br/> + Bitrate: 320<br/> + </tr></td></table><br/> + <b>Japan Tour Edition:</b><br /> + CD1<br /> + 01 A Head Full of Dreams<br /> + 02 Birds<br /> + 03 Hymn for the Weekend<br /> + 04 Everglow<br /> + 05 Adventure of a Lifetime<br /> + 06 Fun<br /> + 07 Kaleidoscope<br /> + 08 Army of One / X Marks the Spot<br /> + 09 Amazing Day<br /> + 10 Colour Spectrum<br /> + 11 Up&amp;Up<br /> + 12 Miracles<br /> + CD2<br /> + 01 Adventure of a Lifetime (Matoma Remix)<br /> + 02 Hymn for the Weekend (SeeB Remix)<br /> + 03 Up&amp;Up (Freedo Remix)<br /> + 04 Magic (Live at Tokyo Dome City Hall, Tokyo)<br /> + 05 Clocks (Live at Tokyo Dome City Hall, Tokyo)<br /> + 06 Viva la Vida (Live at Tokyo Dome City Hall, Tokyo)<br /> + 07 Oceans (Live at Tokyo Dome City Hall, Tokyo)<br /> + 08 A Sky Full of Stars (Live at Tokyo Dome City Hall, Tokyo) + + https://waffles.ch/download.php/xxx/1154063/Coldplay%20-%20A%20Head%20Full%20of%20Dreams%20%28Japanese%20Tour%20Edition%29%20%5B2017-CD-MP3-320-Log%5D.torrent?passkey=123456789&uid=xxx&rss=1 + https://waffles.ch/details.php?id=1154063&hit=1 + Rock + 2017-04-14T15:27:13Z + 1154063-cad18a6758c940860e448795a4a50375 + + + Coldplay - A Head Full of Dreams (Japanese Tour Edition) [2017-CD-MP3-V0(VBR)-Log] + + <table id="waffles-rss-t1154060" class="rsstable" border=0 cellspacing=0 cellpadding=10><tr><td>Link: <a href="https://waffles.ch/details.php?id=1154060&#x26;hit=1">Description/Comments Page</a><br/> + Genre: Rock<br/> + Year: 2017<br/> + Size: 178934031<br/> + </td><td>Link: <a href="https://waffles.ch/download.php/xxx/1154060/Coldplay%20-%20A%20Head%20Full%20of%20Dreams%20%28Japanese%20Tour%20Edition%29%20%5B2017-CD-MP3-V0%28VBR%29-Log%5D.torrent?passkey=123456789&#x26;uid=xxx&#x26;rss=1">Download Torrent</a><br/> + Seeders: 7<br/> + Leechers: 0<br/> + Format: MP3<br/> + </td><td>Link: <a href="https://waffles.ch/userdetails.php?id=6301">heirloom</a> (Uploader)<br/> + Comments: 0<br/> + Files: 25<br/> + Bitrate: V0<br/> + </tr></td></table><br/> + <b>Japan Tour Edition:</b><br /> + CD1<br /> + 01 A Head Full of Dreams<br /> + 02 Birds<br /> + 03 Hymn for the Weekend<br /> + 04 Everglow<br /> + 05 Adventure of a Lifetime<br /> + 06 Fun<br /> + 07 Kaleidoscope<br /> + 08 Army of One / X Marks the Spot<br /> + 09 Amazing Day<br /> + 10 Colour Spectrum<br /> + 11 Up&amp;Up<br /> + 12 Miracles<br /> + CD2<br /> + 01 Adventure of a Lifetime (Matoma Remix)<br /> + 02 Hymn for the Weekend (SeeB Remix)<br /> + 03 Up&amp;Up (Freedo Remix)<br /> + 04 Magic (Live at Tokyo Dome City Hall, Tokyo)<br /> + 05 Clocks (Live at Tokyo Dome City Hall, Tokyo)<br /> + 06 Viva la Vida (Live at Tokyo Dome City Hall, Tokyo)<br /> + 07 Oceans (Live at Tokyo Dome City Hall, Tokyo)<br /> + 08 A Sky Full of Stars (Live at Tokyo Dome City Hall, Tokyo) + + https://waffles.ch/download.php/xxx/1154060/Coldplay%20-%20A%20Head%20Full%20of%20Dreams%20%28Japanese%20Tour%20Edition%29%20%5B2017-CD-MP3-V0%28VBR%29-Log%5D.torrent?passkey=123456789&uid=xxx&rss=1 + https://waffles.ch/details.php?id=1154060&hit=1 + Rock + 2017-04-14T15:23:22Z + 1154060-c30c33036dd692110303cb39de4fc051 + + + Coldplay - X&Y [2005-CD-MP3-V2(VBR)-Log] + + <table id="waffles-rss-t1152893" class="rsstable" border=0 cellspacing=0 cellpadding=10><tr><td>Link: <a href="https://waffles.ch/details.php?id=1152893&#x26;hit=1">Description/Comments Page</a><br/> + Genre: Alternative<br/> + Year: 2005<br/> + Size: 89254820<br/> + </td><td>Link: <a href="https://waffles.ch/download.php/xxx/1152893/Coldplay%20-%20X%26%23x26%3BY%20%5B2005-CD-MP3-V2%28VBR%29-Log%5D.torrent?passkey=123456789&#x26;uid=xxx&#x26;rss=1">Download Torrent</a><br/> + Seeders: 2<br/> + Leechers: 0<br/> + Format: MP3<br/> + </td><td>Link: <a href="https://waffles.ch/userdetails.php?id=235469">hpet0</a> (Uploader)<br/> + Comments: 0<br/> + Files: 16<br/> + Bitrate: V2<br/> + </tr></td></table><br/> + <b>Tracklist:</b><br /> + <br /> + 1. Square One (4:46)<br /> + 2. What If (4:56)<br /> + 3. White Shadows (5:28)<br /> + 4. Fix You (4:55)<br /> + 5. Talk (5:11)<br /> + 6. X&amp;Y (4:34)<br /> + 7. Speed of Sound (4:48)<br /> + 8. A Message (4:45)<br /> + 9. Low (5:32)<br /> + 10. The Hardest Part (4:25)<br /> + 11. Swallowed in the Sea (3:59)<br /> + 12. Twisted Logic (4:31)<br /> + 13. &#039;til Kingdom Come / How You See the World (8:44) + + https://waffles.ch/download.php/xxx/1152893/Coldplay%20-%20X%26%23x26%3BY%20%5B2005-CD-MP3-V2%28VBR%29-Log%5D.torrent?passkey=123456789&uid=xxx&rss=1 + https://waffles.ch/details.php?id=1152893&hit=1 + Alternative + 2017-04-08T00:37:41Z + 1152893-267fcd8cd8f0e5cb8925a4289be4ad94 + + + Coldplay - Viva La Vida or Death And All His Friends (Japan) [2008-CD-MP3-V2(VBR)-Log] + + <table id="waffles-rss-t1152891" class="rsstable" border=0 cellspacing=0 cellpadding=10><tr><td>Link: <a href="https://waffles.ch/details.php?id=1152891&#x26;hit=1">Description/Comments Page</a><br/> + Genre: Rock<br/> + Year: 2008<br/> + Size: 71570327<br/> + </td><td>Link: <a href="https://waffles.ch/download.php/xxx/1152891/Coldplay%20-%20Viva%20La%20Vida%20or%20Death%20And%20All%20His%20Friends%20%28Japan%29%20%5B2008-CD-MP3-V2%28VBR%29-Log%5D.torrent?passkey=123456789&#x26;uid=xxx&#x26;rss=1">Download Torrent</a><br/> + Seeders: 1<br/> + Leechers: 0<br/> + Format: MP3<br/> + </td><td>Link: <a href="https://waffles.ch/userdetails.php?id=235469">hpet0</a> (Uploader)<br/> + Comments: 0<br/> + Files: 17<br/> + Bitrate: V2<br/> + </tr></td></table><br/> + <b>Tracklist:</b><br /> + <br /> + 01. Life In Technicolor [02:30]<br /> + 02. Cemeteries Of London [03:21]<br /> + 03. Lost! [03:55]<br /> + 04. 42 [03:57]<br /> + 05. Lovers In Japan [06:51]<br /> + 06. Yes [07:07]<br /> + 07. Viva La Vida [04:01]<br /> + 08. Violet Hill [03:43]<br /> + 09. Strawberry Swing [04:10]<br /> + 10. Death And All His Friends [06:24]<br /> + 11. Lost! (Alternate Version) (Bonus Track) [03:44] + + https://waffles.ch/download.php/xxx/1152891/Coldplay%20-%20Viva%20La%20Vida%20or%20Death%20And%20All%20His%20Friends%20%28Japan%29%20%5B2008-CD-MP3-V2%28VBR%29-Log%5D.torrent?passkey=123456789&uid=xxx&rss=1 + https://waffles.ch/details.php?id=1152891&hit=1 + Rock + 2017-04-08T00:34:22Z + 1152891-e11d2bb5c9375017471d8768c3844b51 + + + Coldplay - Parachutes [2000-CD-MP3-V2(VBR)-Log] + + <table id="waffles-rss-t1152889" class="rsstable" border=0 cellspacing=0 cellpadding=10><tr><td>Link: <a href="https://waffles.ch/details.php?id=1152889&#x26;hit=1">Description/Comments Page</a><br/> + Genre: Alternative<br/> + Year: 2000<br/> + Size: 58474976<br/> + </td><td>Link: <a href="https://waffles.ch/download.php/xxx/1152889/Coldplay%20-%20Parachutes%20%5B2000-CD-MP3-V2%28VBR%29-Log%5D.torrent?passkey=123456789&#x26;uid=xxx&#x26;rss=1">Download Torrent</a><br/> + Seeders: 2<br/> + Leechers: 0<br/> + Format: MP3<br/> + </td><td>Link: <a href="https://waffles.ch/userdetails.php?id=235469">hpet0</a> (Uploader)<br/> + Comments: 0<br/> + Files: 12<br/> + Bitrate: V2<br/> + </tr></td></table><br/> + <b>Tracklist:</b><br /> + 1. Don&#039;t Panic (2:19)<br /> + 2. Shiver (5:01)<br /> + 3. Spies (5:20)<br /> + 4. Sparks (3:48)<br /> + 5. Yellow (4:30)<br /> + 6. Trouble (4:32)<br /> + 7. Parachutes (0:47)<br /> + 8. High Speed (4:15)<br /> + 9. We Never Change (4:11)<br /> + 10. Everything&#039;s Not Lost (7:16) + + https://waffles.ch/download.php/xxx/1152889/Coldplay%20-%20Parachutes%20%5B2000-CD-MP3-V2%28VBR%29-Log%5D.torrent?passkey=123456789&uid=xxx&rss=1 + https://waffles.ch/details.php?id=1152889&hit=1 + Alternative + 2017-04-08T00:29:02Z + 1152889-eacb784b83fea23f353f437299795b68 + + + Coldplay - A Head Full Of Dreams [2015-CD-MP3-V2(VBR)] (Scene) + + <table id="waffles-rss-t1152888" class="rsstable" border=0 cellspacing=0 cellpadding=10><tr><td>Link: <a href="https://waffles.ch/details.php?id=1152888&#x26;hit=1">Description/Comments Page</a><br/> + Genre: Pop<br/> + Year: 2015<br/> + Size: 67264805<br/> + </td><td>Link: <a href="https://waffles.ch/download.php/xxx/1152888/Coldplay%20-%20A%20Head%20Full%20Of%20Dreams%20%5B2015-CD-MP3-V2%28VBR%29%5D%20%28Scene%29.torrent?passkey=123456789&#x26;uid=xxx&#x26;rss=1">Download Torrent</a><br/> + Seeders: 6<br/> + Leechers: 0<br/> + Format: MP3<br/> + </td><td>Link: <a href="https://waffles.ch/userdetails.php?id=235469">hpet0</a> (Uploader)<br/> + Comments: 1<br/> + Files: 14<br/> + Bitrate: V2<br/> + </tr></td></table><br/> + <b>Tracklist:</b><br /> + <br /> + 01. A Head Full Of Dreams <br /> + 02. Birds <br /> + 03. Hymn For The Weekend (Feat. Beyonce) <br /> + 04. Everglow <br /> + 05. Adventure Of A Lifetime <br /> + 06. Fun (Feat. Tove Lo) <br /> + 07. Kaleidoscope <br /> + 08. Army Of One / X Marks The Spot <br /> + 09. Amazing Day <br /> + 10. Colour Spectrum <br /> + 11. Up&amp;Up + + https://waffles.ch/download.php/xxx/1152888/Coldplay%20-%20A%20Head%20Full%20Of%20Dreams%20%5B2015-CD-MP3-V2%28VBR%29%5D%20%28Scene%29.torrent?passkey=123456789&uid=xxx&rss=1 + https://waffles.ch/details.php?id=1152888&hit=1 + Pop + 2017-04-08T00:25:28Z + 1152888-85625243ba9b4cded8c96f4f8b2ab411 + + + The Chainsmokers & Coldplay - Something Just Like This (Single) [2017-Web-FLAC-Lossless] + + <table id="waffles-rss-t1147894" class="rsstable" border=0 cellspacing=0 cellpadding=10><tr><td>Link: <a href="https://waffles.ch/details.php?id=1147894&#x26;hit=1">Description/Comments Page</a><br/> + Genre: Electronic<br/> + Year: 2017<br/> + Size: 30491794<br/> + </td><td>Link: <a href="https://waffles.ch/download.php/xxx/1147894/The%20Chainsmokers%20%26%23x26%3B%20Coldplay%20-%20Something%20Just%20Like%20This%20%28Single%29%20%5B2017-Web-FLAC-Lossless%5D.torrent?passkey=123456789&#x26;uid=xxx&#x26;rss=1">Download Torrent</a><br/> + Seeders: 23<br/> + Leechers: 0<br/> + Format: FLAC<br/> + </td><td>Link: <a href="https://waffles.ch/userdetails.php?id=3218">cdzo</a> (Uploader)<br/> + Comments: 2<br/> + Files: 2<br/> + Bitrate: Lossless<br/> + </tr></td></table><br/> + Year: 2017<br /> + Artist: The Chainsmokers &amp; Coldplay<br /> + Source: WEB <br /> + Quality: FLAC<br /> + <br /> + 01. The Chainsmokers &amp; Coldplay - Something Just Like This [4:08] + + https://waffles.ch/download.php/xxx/1147894/The%20Chainsmokers%20%26%23x26%3B%20Coldplay%20-%20Something%20Just%20Like%20This%20%28Single%29%20%5B2017-Web-FLAC-Lossless%5D.torrent?passkey=123456789&uid=xxx&rss=1 + https://waffles.ch/details.php?id=1147894&hit=1 + Electronic + 2017-03-24T03:42:33Z + 1147894-f5c1965de8509793c0c1bd86e624e99b + + + Coldplay - Adventure of a Lifetime (Audien Remix) [2016-Web-MP3-320] + + <table id="waffles-rss-t1126256" class="rsstable" border=0 cellspacing=0 cellpadding=10><tr><td>Link: <a href="https://waffles.ch/details.php?id=1126256&#x26;hit=1">Description/Comments Page</a><br/> + Genre: Pop<br/> + Year: 2016<br/> + Size: 9694440<br/> + </td><td>Link: <a href="https://waffles.ch/download.php/xxx/1126256/Coldplay%20-%20Adventure%20of%20a%20Lifetime%20%28Audien%20Remix%29%20%5B2016-Web-MP3-320%5D.torrent?passkey=123456789&#x26;uid=xxx&#x26;rss=1">Download Torrent</a><br/> + Seeders: 0<br/> + Leechers: 0<br/> + Format: MP3<br/> + </td><td>Link: <a href="https://waffles.ch/userdetails.php?id=0">*Anonymous*</a> (Uploader)<br/> + Comments: 0<br/> + Files: 1<br/> + Bitrate: 320<br/> + </tr></td></table><br/> + Audien remix of Coldplay&#039;s Adventure of a Lifetime + + https://waffles.ch/download.php/xxx/1126256/Coldplay%20-%20Adventure%20of%20a%20Lifetime%20%28Audien%20Remix%29%20%5B2016-Web-MP3-320%5D.torrent?passkey=123456789&uid=xxx&rss=1 + https://waffles.ch/details.php?id=1126256&hit=1 + Pop + 2016-03-06T14:14:39Z + 1126256-0c99292f358b2d130bf5b3a1180f3606 + + + diff --git a/src/NzbDrone.Core.Test/Files/Media/H264_sample.mp4 b/src/NzbDrone.Core.Test/Files/Media/H264_sample.mp4 deleted file mode 100644 index 35bc6b353..000000000 Binary files a/src/NzbDrone.Core.Test/Files/Media/H264_sample.mp4 and /dev/null differ diff --git a/src/NzbDrone.Core.Test/Files/Media/LICENSE b/src/NzbDrone.Core.Test/Files/Media/LICENSE new file mode 100644 index 000000000..3f10abe21 --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/Media/LICENSE @@ -0,0 +1,11 @@ +nin.* in this directory are re-encodes of nin.mp3 + +title : 999,999 +artist : Nine Inch Nails +track : 1 +album : The Slip +copyright : Attribution-Noncommercial-Share Alike 3.0 United States: http://creativecommons.org/licenses/by-nc-sa/3.0/us/ +comment : URL: http://freemusicarchive.org/music/Nine_Inch_Nails/The_Slip/999999 + : Comments: http://freemusicarchive.org/ + : Curator: + : Copyright: Attribution-Noncommercial-Share Alike 3.0 United States: http://creativecommons.org/licenses/by-nc-sa/3.0/us/ diff --git a/src/NzbDrone.Core.Test/Files/Media/nin.ape b/src/NzbDrone.Core.Test/Files/Media/nin.ape new file mode 100644 index 000000000..42a45db51 Binary files /dev/null and b/src/NzbDrone.Core.Test/Files/Media/nin.ape differ diff --git a/src/NzbDrone.Core.Test/Files/Media/nin.flac b/src/NzbDrone.Core.Test/Files/Media/nin.flac new file mode 100644 index 000000000..2cf1d5abd Binary files /dev/null and b/src/NzbDrone.Core.Test/Files/Media/nin.flac differ diff --git a/src/NzbDrone.Core.Test/Files/Media/nin.m4a b/src/NzbDrone.Core.Test/Files/Media/nin.m4a new file mode 100644 index 000000000..e447782a1 Binary files /dev/null and b/src/NzbDrone.Core.Test/Files/Media/nin.m4a differ diff --git a/src/NzbDrone.Core.Test/Files/Media/nin.mp2 b/src/NzbDrone.Core.Test/Files/Media/nin.mp2 new file mode 100644 index 000000000..5faec56b3 Binary files /dev/null and b/src/NzbDrone.Core.Test/Files/Media/nin.mp2 differ diff --git a/src/NzbDrone.Core.Test/Files/Media/nin.mp3 b/src/NzbDrone.Core.Test/Files/Media/nin.mp3 new file mode 100644 index 000000000..20c5ec4ba Binary files /dev/null and b/src/NzbDrone.Core.Test/Files/Media/nin.mp3 differ diff --git a/src/NzbDrone.Core.Test/Files/Media/nin.opus b/src/NzbDrone.Core.Test/Files/Media/nin.opus new file mode 100644 index 000000000..280670f28 Binary files /dev/null and b/src/NzbDrone.Core.Test/Files/Media/nin.opus differ diff --git a/src/NzbDrone.Core.Test/Files/Media/nin.png b/src/NzbDrone.Core.Test/Files/Media/nin.png new file mode 100644 index 000000000..9b0164982 Binary files /dev/null and b/src/NzbDrone.Core.Test/Files/Media/nin.png differ diff --git a/src/NzbDrone.Core.Test/Files/Media/nin.wma b/src/NzbDrone.Core.Test/Files/Media/nin.wma new file mode 100644 index 000000000..cbd9b1ee7 Binary files /dev/null and b/src/NzbDrone.Core.Test/Files/Media/nin.wma differ 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/Framework/CoreTest.cs b/src/NzbDrone.Core.Test/Framework/CoreTest.cs index 429388a3e..9a27d0c4e 100644 --- a/src/NzbDrone.Core.Test/Framework/CoreTest.cs +++ b/src/NzbDrone.Core.Test/Framework/CoreTest.cs @@ -10,6 +10,7 @@ using NzbDrone.Test.Common; using NzbDrone.Common.Http.Proxy; using NzbDrone.Core.Http; using NzbDrone.Core.Configuration; +using NzbDrone.Core.MetadataSource; namespace NzbDrone.Core.Test.Framework { @@ -23,11 +24,11 @@ namespace NzbDrone.Core.Test.Framework Mocker.SetConstant(new HttpProxySettingsProvider(Mocker.Resolve())); Mocker.SetConstant(new ManagedWebProxyFactory(Mocker.Resolve())); - Mocker.SetConstant(new ManagedHttpDispatcher(Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve())); - Mocker.SetConstant(new CurlHttpDispatcher(Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve())); + Mocker.SetConstant(new ManagedHttpDispatcher(Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), TestLogger)); Mocker.SetConstant(new HttpProvider(TestLogger)); - Mocker.SetConstant(new HttpClient(new IHttpRequestInterceptor[0], Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), TestLogger)); - Mocker.SetConstant(new SonarrCloudRequestBuilder()); + Mocker.SetConstant(new HttpClient(new IHttpRequestInterceptor[0], Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), TestLogger)); + Mocker.SetConstant(new LidarrCloudRequestBuilder()); + Mocker.SetConstant(Mocker.Resolve()); } } diff --git a/src/NzbDrone.Core.Test/Framework/DbTest.cs b/src/NzbDrone.Core.Test/Framework/DbTest.cs index 7ae33e059..1eb68a55f 100644 --- a/src/NzbDrone.Core.Test/Framework/DbTest.cs +++ b/src/NzbDrone.Core.Test/Framework/DbTest.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Data.SQLite; using System.IO; using System.Linq; using FluentMigrator.Runner; @@ -115,21 +116,14 @@ namespace NzbDrone.Core.Test.Framework [TearDown] public void TearDown() { - if (TestFolderInfo != null && Directory.Exists(TestFolderInfo.AppDataFolder)) + // Make sure there are no lingering connections. (When this happens it means we haven't disposed something properly) + GC.Collect(); + GC.WaitForPendingFinalizers(); + SQLiteConnection.ClearAllPools(); + + if (TestFolderInfo != null) { - var files = Directory.GetFiles(TestFolderInfo.AppDataFolder); - - foreach (var file in files) - { - try - { - File.Delete(file); - } - catch (Exception) - { - - } - } + DeleteTempFolder(TestFolderInfo.AppDataFolder); } } } diff --git a/src/NzbDrone.Core.Test/Framework/DirectDataMapper.cs b/src/NzbDrone.Core.Test/Framework/DirectDataMapper.cs index 27b4354c1..ccc469e21 100644 --- a/src/NzbDrone.Core.Test/Framework/DirectDataMapper.cs +++ b/src/NzbDrone.Core.Test/Framework/DirectDataMapper.cs @@ -12,6 +12,7 @@ namespace NzbDrone.Core.Test.Framework { List> Query(string sql); List Query(string sql) where T : new(); + T QueryScalar(string sql); } public class DirectDataMapper : IDirectDataMapper @@ -25,7 +26,7 @@ namespace NzbDrone.Core.Test.Framework _providerFactory = dataMapper.ProviderFactory; _connectionString = dataMapper.ConnectionString; } - + private DbConnection OpenConnection() { var connection = _providerFactory.CreateConnection(); @@ -62,6 +63,13 @@ namespace NzbDrone.Core.Test.Framework return dataTable.Rows.Cast().Select(MapToObject).ToList(); } + public T QueryScalar(string sql) + { + var dataTable = GetDataTable(sql); + + return dataTable.Rows.Cast().Select(d => MapValue(d, 0, typeof(T))).Cast().FirstOrDefault(); + } + protected Dictionary MapToDictionary(DataRow dataRow) { var item = new Dictionary(); @@ -80,7 +88,7 @@ namespace NzbDrone.Core.Test.Framework value = dataRow.ItemArray[i]; } - item[columnName] = dataRow.ItemArray[i]; + item[columnName] = value; } return item; @@ -107,24 +115,29 @@ namespace NzbDrone.Core.Test.Framework propertyType = propertyType.GetGenericArguments()[0]; } - object value; - if (dataRow.ItemArray[i] == DBNull.Value) - { - value = null; - } - else if (dataRow.Table.Columns[i].DataType == typeof(string) && propertyType != typeof(string)) - { - value = Json.Deserialize((string)dataRow.ItemArray[i], propertyType); - } - else - { - value = Convert.ChangeType(dataRow.ItemArray[i], propertyType); - } + object value = MapValue(dataRow, i, propertyType); + propertyInfo.SetValue(item, value, null); } return item; } + + private object MapValue(DataRow dataRow, int i, Type targetType) + { + if (dataRow.ItemArray[i] == DBNull.Value) + { + return null; + } + else if (dataRow.Table.Columns[i].DataType == typeof(string) && targetType != typeof(string)) + { + return Json.Deserialize((string)dataRow.ItemArray[i], targetType); + } + else + { + return Convert.ChangeType(dataRow.ItemArray[i], targetType); + } + } } } diff --git a/src/NzbDrone.Core.Test/Framework/FileSystemTest.cs b/src/NzbDrone.Core.Test/Framework/FileSystemTest.cs new file mode 100644 index 000000000..8e43591a2 --- /dev/null +++ b/src/NzbDrone.Core.Test/Framework/FileSystemTest.cs @@ -0,0 +1,22 @@ +using NUnit.Framework; +using System.IO.Abstractions.TestingHelpers; +using Microsoft.Practices.Unity; +using NzbDrone.Common.Disk; +namespace NzbDrone.Core.Test.Framework +{ + public abstract class FileSystemTest : CoreTest where TSubject : class + { + protected MockFileSystem FileSystem { get; private set; } + protected IDiskProvider DiskProvider { get; private set; } + + [SetUp] + public void FileSystemTestSetup() + { + FileSystem = new MockFileSystem(); + + DiskProvider = Mocker.Resolve("ActualDiskProvider", new ResolverOverride[] { + new ParameterOverride("fileSystem", FileSystem) + }); + } + } +} diff --git a/src/NzbDrone.Core.Test/Framework/TestBaseTests.cs b/src/NzbDrone.Core.Test/Framework/TestBaseTests.cs deleted file mode 100644 index 94d67cda0..000000000 --- a/src/NzbDrone.Core.Test/Framework/TestBaseTests.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Text; -using NLog; -using NUnit.Framework; - -namespace NzbDrone.Core.Test.Framework.AutoMoq -{ - [TestFixture] - class TestBaseTests : TestBase - { - private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); - - [Test] - public void Test_should_pass_when_no_exceptions_are_logged() - { - Logger.Info("Everything is fine and dandy!"); - } - - [Test] - public void Test_should_pass_when_errors_are_excpected() - { - Logger.Error("I knew this would happer"); - ExceptionVerification.ExcpectedErrors(1); - } - - [Test] - public void Test_should_pass_when_warns_are_excpected() - { - Logger.Warn("I knew this would happer"); - ExceptionVerification.ExcpectedWarns(1); - } - - [Test] - public void Test_should_pass_when_warns_are_ignored() - { - Logger.Warn("I knew this would happer"); - Logger.Warn("I knew this would happer"); - Logger.Warn("I knew this would happer"); - ExceptionVerification.IgnoreWarns(); - } - - [Test] - public void Test_should_pass_when_errors_are_ignored() - { - Logger.Error("I knew this would happer"); - Logger.Error("I knew this would happer"); - Logger.Error("I knew this would happer"); - ExceptionVerification.IgnoreErrors(); - } - - [Test] - public void Test_should_pass_when_exception_type_is_ignored() - { - Logger.ErrorException("bad exception", new WebException("Test")); - ExceptionVerification.MarkInconclusive(typeof(WebException)); - } - } -} diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/DeleteBadMediaCovers.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/DeleteBadMediaCovers.cs index 5b454ae3c..16639271d 100644 --- a/src/NzbDrone.Core.Test/HealthCheck/Checks/DeleteBadMediaCovers.cs +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/DeleteBadMediaCovers.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -11,7 +11,7 @@ using NzbDrone.Core.Extras.Metadata; using NzbDrone.Core.Extras.Metadata.Files; using NzbDrone.Core.Housekeeping.Housekeepers; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.HealthCheck.Checks @@ -20,27 +20,27 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks public class DeleteBadMediaCoversFixture : CoreTest { private List _metadata; - private List _series; + private List _artist; [SetUp] public void Setup() { - _series = Builder.CreateListOfSize(1) + _artist = Builder.CreateListOfSize(1) .All() - .With(c => c.Path = "C:\\TV\\".AsOsAgnostic()) + .With(c => c.Path = "C:\\Music\\".AsOsAgnostic()) .Build().ToList(); _metadata = Builder.CreateListOfSize(1) .Build().ToList(); - Mocker.GetMock() - .Setup(c => c.GetAllSeries()) - .Returns(_series); + Mocker.GetMock() + .Setup(c => c.GetAllArtists()) + .Returns(_artist); Mocker.GetMock() - .Setup(c => c.GetFilesBySeries(_series.First().Id)) + .Setup(c => c.GetFilesByArtist(_artist.First().Id)) .Returns(_metadata); @@ -51,8 +51,8 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks [Test] public void should_not_process_non_image_files() { - _metadata.First().RelativePath = "season\\file.xml".AsOsAgnostic(); - _metadata.First().Type = MetadataType.EpisodeMetadata; + _metadata.First().RelativePath = "album\\file.xml".AsOsAgnostic(); + _metadata.First().Type = MetadataType.TrackMetadata; Subject.Clean(); @@ -80,7 +80,7 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks Subject.Clean(); Mocker.GetMock().VerifySet(c => c.CleanupMetadataImages = true, Times.Never()); - Mocker.GetMock().Verify(c => c.GetAllSeries(), Times.Never()); + Mocker.GetMock().Verify(c => c.GetAllArtists(), Times.Never()); AssertImageWasNotRemoved(); } @@ -101,10 +101,10 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks public void should_delete_html_images() { - var imagePath = "C:\\TV\\Season\\image.jpg".AsOsAgnostic(); + var imagePath = "C:\\Music\\Album\\image.jpg".AsOsAgnostic(); _metadata.First().LastUpdated = new DateTime(2014, 12, 29); - _metadata.First().RelativePath = "Season\\image.jpg".AsOsAgnostic(); - _metadata.First().Type = MetadataType.SeriesImage; + _metadata.First().RelativePath = "Album\\image.jpg".AsOsAgnostic(); + _metadata.First().Type = MetadataType.ArtistImage; Mocker.GetMock() .Setup(c => c.OpenReadStream(imagePath)) @@ -123,10 +123,10 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks public void should_delete_empty_images() { - var imagePath = "C:\\TV\\Season\\image.jpg".AsOsAgnostic(); + var imagePath = "C:\\Music\\Album\\image.jpg".AsOsAgnostic(); _metadata.First().LastUpdated = new DateTime(2014, 12, 29); - _metadata.First().Type = MetadataType.SeasonImage; - _metadata.First().RelativePath = "Season\\image.jpg".AsOsAgnostic(); + _metadata.First().Type = MetadataType.AlbumImage; + _metadata.First().RelativePath = "Album\\image.jpg".AsOsAgnostic(); Mocker.GetMock() .Setup(c => c.OpenReadStream(imagePath)) @@ -144,9 +144,9 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks public void should_not_delete_non_html_files() { - var imagePath = "C:\\TV\\Season\\image.jpg".AsOsAgnostic(); + var imagePath = "C:\\Music\\Album\\image.jpg".AsOsAgnostic(); _metadata.First().LastUpdated = new DateTime(2014, 12, 29); - _metadata.First().RelativePath = "Season\\image.jpg".AsOsAgnostic(); + _metadata.First().RelativePath = "Album\\image.jpg".AsOsAgnostic(); Mocker.GetMock() .Setup(c => c.OpenReadStream(imagePath)) diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/DotnetVersionCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/DotnetVersionCheckFixture.cs new file mode 100644 index 000000000..2f7386b48 --- /dev/null +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/DotnetVersionCheckFixture.cs @@ -0,0 +1,69 @@ +using System; +using NUnit.Framework; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Core.HealthCheck.Checks; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.HealthCheck.Checks +{ + [TestFixture] + public class DotnetVersionCheckFixture : CoreTest + { + private void GivenOutput(string version) + { + WindowsOnly(); + + Mocker.GetMock() + .SetupGet(s => s.Version) + .Returns(new Version(version)); + } + + [TestCase("4.7.2")] + [TestCase("4.8")] + public void should_return_ok(string version) + { + GivenOutput(version); + + Subject.Check().ShouldBeOk(); + } + + [TestCase("4.6.2")] + [TestCase("4.7")] + [TestCase("4.7.1")] + public void should_return_notice(string version) + { + GivenOutput(version); + + Subject.Check().ShouldBeNotice(); + } + + public void should_return_warning(string version) + { + GivenOutput(version); + + Subject.Check().ShouldBeWarning(); + } + + [TestCase("4.5")] + [TestCase("4.5.2")] + [TestCase("4.6.1")] + public void should_return_error(string version) + { + GivenOutput(version); + + Subject.Check().ShouldBeError(); + } + + [Test] + public void should_return_ok_for_net462_on_Win1511() + { + Mocker.GetMock() + .SetupGet(v => v.Version) + .Returns("10.0.14392"); + + GivenOutput("4.6.2"); + + Subject.Check().ShouldBeOk(); + } + } +} diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/DownloadClientCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/DownloadClientCheckFixture.cs index dc6986d79..56f83e0a9 100644 --- a/src/NzbDrone.Core.Test/HealthCheck/Checks/DownloadClientCheckFixture.cs +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/DownloadClientCheckFixture.cs @@ -1,9 +1,8 @@ -using System; +using System; 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/DroneFactoryCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/DroneFactoryCheckFixture.cs deleted file mode 100644 index fbde84eb4..000000000 --- a/src/NzbDrone.Core.Test/HealthCheck/Checks/DroneFactoryCheckFixture.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Moq; -using NUnit.Framework; -using NzbDrone.Common.Disk; -using NzbDrone.Core.Configuration; -using NzbDrone.Core.HealthCheck.Checks; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.HealthCheck.Checks -{ - [TestFixture] - public class DroneFactoryCheckFixture : CoreTest - { - private const string DRONE_FACTORY_FOLDER = @"C:\Test\Unsorted"; - - private void GivenDroneFactoryFolder(bool exists = false, bool writable = true) - { - Mocker.GetMock() - .SetupGet(s => s.DownloadedEpisodesFolder) - .Returns(DRONE_FACTORY_FOLDER); - - Mocker.GetMock() - .Setup(s => s.FolderExists(DRONE_FACTORY_FOLDER)) - .Returns(exists); - - Mocker.GetMock() - .Setup(s => s.FolderWritable(It.IsAny())) - .Returns(exists && writable); - } - - [Test] - public void should_return_error_when_drone_factory_folder_does_not_exist() - { - GivenDroneFactoryFolder(); - - Subject.Check().ShouldBeError(); - } - - [Test] - public void should_return_error_when_unable_to_write_to_drone_factory_folder() - { - GivenDroneFactoryFolder(true, false); - - Subject.Check().ShouldBeError(); - } - - [Test] - public void should_return_ok_when_no_issues_found() - { - GivenDroneFactoryFolder(true); - - Subject.Check().ShouldBeOk(); - } - } -} diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/HealthCheckFixtureExtensions.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/HealthCheckFixtureExtensions.cs new file mode 100644 index 000000000..0eb927426 --- /dev/null +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/HealthCheckFixtureExtensions.cs @@ -0,0 +1,49 @@ +using FluentAssertions; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.HealthCheck; + +namespace NzbDrone.Core.Test.HealthCheck.Checks +{ + public static class HealthCheckFixtureExtensions + { + public static void ShouldBeOk(this Core.HealthCheck.HealthCheck result) + { + result.Type.Should().Be(HealthCheckResult.Ok); + } + + public static void ShouldBeNotice(this Core.HealthCheck.HealthCheck result, string message = null) + { + result.Type.Should().Be(HealthCheckResult.Notice); + + if (message.IsNotNullOrWhiteSpace()) + { + result.Message.Should().Contain(message); + } + } + + public static void ShouldBeWarning(this Core.HealthCheck.HealthCheck result, string message = null) + { + result.Type.Should().Be(HealthCheckResult.Warning); + + if (message.IsNotNullOrWhiteSpace()) + { + result.Message.Should().Contain(message); + } + } + + public static void ShouldBeError(this Core.HealthCheck.HealthCheck result, string message = null, string wikiFragment = null) + { + result.Type.Should().Be(HealthCheckResult.Error); + + if (message.IsNotNullOrWhiteSpace()) + { + result.Message.Should().Contain(message); + } + + if (wikiFragment.IsNotNullOrWhiteSpace()) + { + result.WikiUrl.Fragment.Should().Be(wikiFragment); + } + } + } +} diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/HealthCheckFixtureExtentions.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/HealthCheckFixtureExtentions.cs deleted file mode 100644 index fa1577974..000000000 --- a/src/NzbDrone.Core.Test/HealthCheck/Checks/HealthCheckFixtureExtentions.cs +++ /dev/null @@ -1,23 +0,0 @@ -using FluentAssertions; -using NzbDrone.Core.HealthCheck; - -namespace NzbDrone.Core.Test.HealthCheck.Checks -{ - public static class HealthCheckFixtureExtensions - { - public static void ShouldBeOk(this Core.HealthCheck.HealthCheck result) - { - result.Type.Should().Be(HealthCheckResult.Ok); - } - - public static void ShouldBeWarning(this Core.HealthCheck.HealthCheck result) - { - result.Type.Should().Be(HealthCheckResult.Warning); - } - - public static void ShouldBeError(this Core.HealthCheck.HealthCheck result) - { - result.Type.Should().Be(HealthCheckResult.Error); - } - } -} diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/ImportListStatusCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/ImportListStatusCheckFixture.cs new file mode 100644 index 000000000..a69efdfb1 --- /dev/null +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/ImportListStatusCheckFixture.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.HealthCheck.Checks; +using NzbDrone.Core.ImportLists; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.HealthCheck.Checks +{ + [TestFixture] + public class ImportListStatusCheckFixture : CoreTest + { + private List _importLists = new List(); + private List _blockedImportLists = new List(); + + [SetUp] + public void SetUp() + { + Mocker.GetMock() + .Setup(v => v.GetAvailableProviders()) + .Returns(_importLists); + + Mocker.GetMock() + .Setup(v => v.GetBlockedProviders()) + .Returns(_blockedImportLists); + } + + private Mock GivenImportList(int i, double backoffHours, double failureHours) + { + var id = i; + + var mockImportList = new Mock(); + mockImportList.SetupGet(s => s.Definition).Returns(new ImportListDefinition { Id = id }); + + _importLists.Add(mockImportList.Object); + + if (backoffHours != 0.0) + { + _blockedImportLists.Add(new ImportListStatus + { + ProviderId = id, + InitialFailure = DateTime.UtcNow.AddHours(-failureHours), + MostRecentFailure = DateTime.UtcNow.AddHours(-0.1), + EscalationLevel = 5, + DisabledTill = DateTime.UtcNow.AddHours(backoffHours) + }); + } + + return mockImportList; + } + + + [Test] + public void should_not_return_error_when_no_import_lists() + { + Subject.Check().ShouldBeOk(); + } + + [Test] + public void should_return_warning_if_import_list_unavailable() + { + GivenImportList(1, 10.0, 24.0); + GivenImportList(2, 0.0, 0.0); + + Subject.Check().ShouldBeWarning(); + } + + [Test] + public void should_return_error_if_all_import_lists_unavailable() + { + GivenImportList(1, 10.0, 24.0); + + Subject.Check().ShouldBeError(); + } + + [Test] + public void should_return_warning_if_few_import_lists_unavailable() + { + GivenImportList(1, 10.0, 24.0); + GivenImportList(2, 10.0, 24.0); + GivenImportList(3, 0.0, 0.0); + + Subject.Check().ShouldBeWarning(); + } + } +} diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/ImportMechanismCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/ImportMechanismCheckFixture.cs index 5f0f3d9a0..050eeadbb 100644 --- a/src/NzbDrone.Core.Test/HealthCheck/Checks/ImportMechanismCheckFixture.cs +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/ImportMechanismCheckFixture.cs @@ -1,51 +1,26 @@ -using NUnit.Framework; -using NzbDrone.Common.Disk; +using NUnit.Framework; using NzbDrone.Core.Configuration; using NzbDrone.Core.HealthCheck.Checks; using NzbDrone.Core.Test.Framework; -using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.HealthCheck.Checks { [TestFixture] public class ImportMechanismCheckFixture : CoreTest { - private const string DRONE_FACTORY_FOLDER = @"C:\Test\Unsorted"; - private void GivenCompletedDownloadHandling(bool? enabled = null) { if (enabled.HasValue) { - Mocker.GetMock() - .Setup(s => s.IsDefined("EnableCompletedDownloadHandling")) - .Returns(true); - Mocker.GetMock() .SetupGet(s => s.EnableCompletedDownloadHandling) .Returns(enabled.Value); } } - private void GivenDroneFactoryFolder(bool exists = false) - { - Mocker.GetMock() - .SetupGet(s => s.DownloadedEpisodesFolder) - .Returns(DRONE_FACTORY_FOLDER.AsOsAgnostic()); - - Mocker.GetMock() - .Setup(s => s.FolderExists(DRONE_FACTORY_FOLDER.AsOsAgnostic())) - .Returns(exists); - } - - [Test] - public void should_return_warning_when_completed_download_handling_not_configured() - { - Subject.Check().ShouldBeWarning(); - } - [Test] - public void should_return_warning_when_both_completeddownloadhandling_and_dronefactory_are_not_configured() + public void should_return_warning_when_completeddownloadhandling_false() { GivenCompletedDownloadHandling(false); @@ -56,7 +31,6 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks public void should_return_ok_when_no_issues_found() { GivenCompletedDownloadHandling(true); - GivenDroneFactoryFolder(true); Subject.Check().ShouldBeOk(); } diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/IndexerCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/IndexerCheckFixture.cs deleted file mode 100644 index 513784d27..000000000 --- a/src/NzbDrone.Core.Test/HealthCheck/Checks/IndexerCheckFixture.cs +++ /dev/null @@ -1,123 +0,0 @@ -using System.Collections.Generic; -using Moq; -using NUnit.Framework; -using NzbDrone.Core.HealthCheck.Checks; -using NzbDrone.Core.Indexers; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.HealthCheck.Checks -{ - [TestFixture] - public class IndexerCheckFixture : CoreTest - { - private Mock _indexerMock; - - private void GivenIndexer(bool supportsRss, bool supportsSearch) - { - _indexerMock = Mocker.GetMock(); - _indexerMock.SetupGet(s => s.SupportsRss).Returns(supportsRss); - _indexerMock.SetupGet(s => s.SupportsSearch).Returns(supportsSearch); - - Mocker.GetMock() - .Setup(s => s.GetAvailableProviders()) - .Returns(new List { _indexerMock.Object }); - - Mocker.GetMock() - .Setup(s => s.RssEnabled()) - .Returns(new List()); - - Mocker.GetMock() - .Setup(s => s.SearchEnabled()) - .Returns(new List()); - } - - private void GivenRssEnabled() - { - Mocker.GetMock() - .Setup(s => s.RssEnabled()) - .Returns(new List { _indexerMock.Object }); - } - - private void GivenSearchEnabled() - { - Mocker.GetMock() - .Setup(s => s.SearchEnabled()) - .Returns(new List { _indexerMock.Object }); - } - - [Test] - public void should_return_error_when_not_indexers_are_enabled() - { - Mocker.GetMock() - .Setup(s => s.GetAvailableProviders()) - .Returns(new List()); - - Subject.Check().ShouldBeError(); - } - - [Test] - public void should_return_warning_when_only_enabled_indexer_doesnt_support_search() - { - GivenIndexer(true, false); - - Subject.Check().ShouldBeWarning(); - } - - [Test] - public void should_return_warning_when_only_enabled_indexer_doesnt_support_rss() - { - GivenIndexer(false, true); - - Subject.Check().ShouldBeWarning(); - } - - [Test] - public void should_return_ok_when_multiple_indexers_are_enabled() - { - GivenRssEnabled(); - GivenSearchEnabled(); - - var indexer1 = Mocker.GetMock(); - indexer1.SetupGet(s => s.SupportsRss).Returns(true); - indexer1.SetupGet(s => s.SupportsSearch).Returns(true); - - var indexer2 = new Moq.Mock(); - indexer2.SetupGet(s => s.SupportsRss).Returns(true); - indexer2.SetupGet(s => s.SupportsSearch).Returns(false); - - Mocker.GetMock() - .Setup(s => s.GetAvailableProviders()) - .Returns(new List { indexer1.Object, indexer2.Object }); - - Subject.Check().ShouldBeOk(); - } - - [Test] - public void should_return_ok_when_indexer_supports_rss_and_search() - { - GivenIndexer(true, true); - GivenRssEnabled(); - GivenSearchEnabled(); - - Subject.Check().ShouldBeOk(); - } - - [Test] - public void should_return_warning_if_rss_is_supported_but_disabled() - { - GivenIndexer(true, true); - GivenSearchEnabled(); - - Subject.Check().ShouldBeWarning(); - } - - [Test] - public void should_return_warning_if_search_is_supported_but_disabled() - { - GivenIndexer(true, true); - GivenRssEnabled(); - - Subject.Check().ShouldBeWarning(); - } - } -} diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/IndexerRssCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/IndexerRssCheckFixture.cs new file mode 100644 index 000000000..28d314005 --- /dev/null +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/IndexerRssCheckFixture.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.HealthCheck.Checks; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.HealthCheck.Checks +{ + [TestFixture] + public class IndexerRssCheckFixture : CoreTest + { + private Mock _indexerMock; + + [SetUp] + public void SetUp() + { + Mocker.GetMock() + .Setup(s => s.GetAvailableProviders()) + .Returns(new List()); + + Mocker.GetMock() + .Setup(s => s.RssEnabled(It.IsAny())) + .Returns(new List()); + } + + private void GivenIndexer(bool supportsRss, bool supportsSearch) + { + _indexerMock = Mocker.GetMock(); + _indexerMock.SetupGet(s => s.SupportsRss).Returns(supportsRss); + _indexerMock.SetupGet(s => s.SupportsSearch).Returns(supportsSearch); + + Mocker.GetMock() + .Setup(s => s.GetAvailableProviders()) + .Returns(new List { _indexerMock.Object }); + } + + private void GivenRssEnabled() + { + Mocker.GetMock() + .Setup(s => s.RssEnabled(It.IsAny())) + .Returns(new List { _indexerMock.Object }); + } + + private void GivenRssFiltered() + { + Mocker.GetMock() + .Setup(s => s.RssEnabled(false)) + .Returns(new List { _indexerMock.Object }); + } + + [Test] + public void should_return_error_when_no_indexer_present() + { + Subject.Check().ShouldBeError(); + } + + [Test] + public void should_return_error_when_no_rss_supported_indexer_present() + { + GivenIndexer(false, true); + + Subject.Check().ShouldBeError(); + } + + [Test] + public void should_return_ok_when_rss_is_enabled() + { + GivenIndexer(true, false); + GivenRssEnabled(); + + Subject.Check().ShouldBeOk(); + } + + [Test] + public void should_return_error_if_rss_is_supported_but_disabled() + { + GivenIndexer(true, false); + + Subject.Check().ShouldBeError(); + } + + [Test] + public void should_return_filter_warning_if_rss_is_enabled_but_filtered() + { + GivenIndexer(true, false); + GivenRssFiltered(); + + Subject.Check().ShouldBeWarning("recent indexer errors"); + } + } +} diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/IndexerSearchCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/IndexerSearchCheckFixture.cs new file mode 100644 index 000000000..030d8a8db --- /dev/null +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/IndexerSearchCheckFixture.cs @@ -0,0 +1,126 @@ +using System.Collections.Generic; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.HealthCheck.Checks; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.HealthCheck.Checks +{ + [TestFixture] + public class IndexerSearchCheckFixture : CoreTest + { + private Mock _indexerMock; + + [SetUp] + public void SetUp() + { + Mocker.GetMock() + .Setup(s => s.GetAvailableProviders()) + .Returns(new List()); + + Mocker.GetMock() + .Setup(s => s.AutomaticSearchEnabled(It.IsAny())) + .Returns(new List()); + + Mocker.GetMock() + .Setup(s => s.InteractiveSearchEnabled(It.IsAny())) + .Returns(new List()); + } + + private void GivenIndexer(bool supportsRss, bool supportsSearch) + { + _indexerMock = Mocker.GetMock(); + _indexerMock.SetupGet(s => s.SupportsRss).Returns(supportsRss); + _indexerMock.SetupGet(s => s.SupportsSearch).Returns(supportsSearch); + + Mocker.GetMock() + .Setup(s => s.GetAvailableProviders()) + .Returns(new List { _indexerMock.Object }); + } + + private void GivenAutomaticSearchEnabled() + { + Mocker.GetMock() + .Setup(s => s.AutomaticSearchEnabled(It.IsAny())) + .Returns(new List { _indexerMock.Object }); + } + + private void GivenInteractiveSearchEnabled() + { + Mocker.GetMock() + .Setup(s => s.InteractiveSearchEnabled(It.IsAny())) + .Returns(new List { _indexerMock.Object }); + } + + private void GivenSearchFiltered() + { + Mocker.GetMock() + .Setup(s => s.AutomaticSearchEnabled(false)) + .Returns(new List { _indexerMock.Object }); + + Mocker.GetMock() + .Setup(s => s.InteractiveSearchEnabled(false)) + .Returns(new List { _indexerMock.Object }); + } + + [Test] + public void should_return_warning_when_no_indexer_present() + { + Subject.Check().ShouldBeWarning(); + } + + [Test] + public void should_return_warning_when_no_search_supported_indexer_present() + { + GivenIndexer(true, false); + + Subject.Check().ShouldBeWarning(); + } + + [Test] + public void should_return_ok_when_automatic_and__search_is_enabled() + { + GivenIndexer(false, true); + GivenAutomaticSearchEnabled(); + GivenInteractiveSearchEnabled(); + + Subject.Check().ShouldBeOk(); + } + + [Test] + public void should_return_warning_when_only_automatic_search_is_enabled() + { + GivenIndexer(false, true); + GivenAutomaticSearchEnabled(); + + Subject.Check().ShouldBeWarning(); + } + + [Test] + public void should_return_warning_when_only_interactive_search_is_enabled() + { + GivenIndexer(false, true); + GivenInteractiveSearchEnabled(); + + Subject.Check().ShouldBeWarning(); + } + + [Test] + public void should_return_warning_if_search_is_supported_but_disabled() + { + GivenIndexer(false, true); + + Subject.Check().ShouldBeWarning(); + } + + [Test] + public void should_return_filter_warning_if_search_is_enabled_but_filtered() + { + GivenIndexer(false, true); + GivenSearchFiltered(); + + Subject.Check().ShouldBeWarning("recent indexer errors"); + } + } +} diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/IndexerStatusCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/IndexerStatusCheckFixture.cs index 6592e2a76..1d71d3a80 100644 --- a/src/NzbDrone.Core.Test/HealthCheck/Checks/IndexerStatusCheckFixture.cs +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/IndexerStatusCheckFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using Moq; using NUnit.Framework; @@ -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/MonoDebugCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/MonoDebugCheckFixture.cs new file mode 100644 index 000000000..d56ff463d --- /dev/null +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/MonoDebugCheckFixture.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 MonoDebugCheckFixture : 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/HealthCheck/Checks/MonoVersionCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/MonoVersionCheckFixture.cs index baca51b08..e33402e41 100644 --- a/src/NzbDrone.Core.Test/HealthCheck/Checks/MonoVersionCheckFixture.cs +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/MonoVersionCheckFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using NUnit.Framework; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.HealthCheck.Checks; @@ -18,11 +18,8 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks .Returns(new Version(version)); } - [TestCase("3.10")] - [TestCase("4.0.0.0")] - [TestCase("4.2")] - [TestCase("4.6")] - [TestCase("4.4.2")] + [TestCase("5.18")] + [TestCase("5.20")] public void should_return_ok(string version) { GivenOutput(version); @@ -30,6 +27,23 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks Subject.Check().ShouldBeOk(); } + [TestCase("5.16")] + public void should_return_notice(string version) + { + GivenOutput(version); + + Subject.Check().ShouldBeNotice(); + } + + [TestCase("5.4")] + [TestCase("5.8")] + public void should_return_warning(string version) + { + GivenOutput(version); + + Subject.Check().ShouldBeWarning(); + } + [TestCase("2.10.2")] [TestCase("2.10.8.1")] [TestCase("3.0.0.1")] @@ -38,14 +52,9 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks [TestCase("3.2.7")] [TestCase("3.6.1")] [TestCase("3.8")] - public void should_return_warning(string version) - { - GivenOutput(version); - - Subject.Check().ShouldBeWarning(); - } - - + [TestCase("3.10")] + [TestCase("4.0.0.0")] + [TestCase("4.2")] [TestCase("4.4.0")] [TestCase("4.4.1")] public void should_return_error(string version) diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/RemotePathMappingCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/RemotePathMappingCheckFixture.cs new file mode 100644 index 000000000..d16924ceb --- /dev/null +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/RemotePathMappingCheckFixture.cs @@ -0,0 +1,253 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Disk; +using NzbDrone.Common.EnsureThat; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Core.Download; +using NzbDrone.Core.Download.Clients; +using NzbDrone.Core.HealthCheck.Checks; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.HealthCheck.Checks +{ + [TestFixture] + public class RemotePathMappingCheckFixture : CoreTest + { + private string downloadRootPath = @"c:\Test".AsOsAgnostic(); + private string downloadItemPath = @"c:\Test\item".AsOsAgnostic(); + + private DownloadClientInfo clientStatus; + private DownloadClientItem downloadItem; + private Mock downloadClient; + + static Exception[] DownloadClientExceptions = { + new DownloadClientUnavailableException("error"), + new DownloadClientAuthenticationException("error"), + new DownloadClientException("error") + }; + + [SetUp] + public void Setup() + { + downloadItem = new DownloadClientItem { + DownloadClient = "Test", + DownloadId = "TestId", + OutputPath = new OsPath(downloadItemPath) + }; + + clientStatus = new DownloadClientInfo { + IsLocalhost = true, + OutputRootFolders = new List { new OsPath(downloadRootPath) } + }; + + downloadClient = Mocker.GetMock(); + downloadClient.Setup(s => s.Definition) + .Returns(new DownloadClientDefinition { Name = "Test" }); + + downloadClient.Setup(s => s.GetItems()) + .Returns(new List { downloadItem }); + + downloadClient.Setup(s => s.GetStatus()) + .Returns(clientStatus); + + Mocker.GetMock() + .Setup(s => s.GetDownloadClients()) + .Returns(new IDownloadClient[] { downloadClient.Object }); + + Mocker.GetMock() + .Setup(x => x.FolderExists(It.IsAny())) + .Returns((string path) => { + Ensure.That(path, () => path).IsValidPath(); + return false; + }); + + Mocker.GetMock() + .Setup(x => x.FileExists(It.IsAny())) + .Returns((string path) => { + Ensure.That(path, () => path).IsValidPath(); + return false; + }); + } + + private void GivenFolderExists(string folder) + { + Mocker.GetMock() + .Setup(x => x.FolderExists(folder)) + .Returns(true); + } + + private void GivenFileExists(string file) + { + Mocker.GetMock() + .Setup(x => x.FileExists(file)) + .Returns(true); + } + + + private void GivenDocker() + { + Mocker.GetMock() + .Setup(x => x.IsDocker) + .Returns(true); + } + + [Test] + public void should_return_ok_if_setup_correctly() + { + GivenFolderExists(downloadRootPath); + + Subject.Check().ShouldBeOk(); + } + + [Test] + public void should_return_permissions_error_if_local_client_download_root_missing() + { + Subject.Check().ShouldBeError(wikiFragment: "permissions-error"); + } + + [Test] + public void should_return_mapping_error_if_remote_client_root_path_invalid() + { + clientStatus.IsLocalhost = false; + clientStatus.OutputRootFolders = new List { new OsPath("An invalid path") }; + + Subject.Check().ShouldBeError(wikiFragment: "bad-remote-path-mapping"); + } + + [Test] + public void should_return_download_client_error_if_local_client_root_path_invalid() + { + clientStatus.IsLocalhost = true; + clientStatus.OutputRootFolders = new List { new OsPath("An invalid path") }; + + Subject.Check().ShouldBeError(wikiFragment: "bad-download-client-settings"); + } + + [Test] + public void should_return_path_mapping_error_if_remote_client_download_root_missing() + { + clientStatus.IsLocalhost = false; + + Subject.Check().ShouldBeError(wikiFragment: "bad-remote-path-mapping"); + } + + [Test, TestCaseSource("DownloadClientExceptions")] + public void should_return_ok_if_client_throws_downloadclientexception(Exception ex) + { + downloadClient.Setup(s => s.GetStatus()) + .Throws(ex); + + Subject.Check().ShouldBeOk(); + + ExceptionVerification.ExpectedErrors(0); + } + + [Test] + public void should_return_docker_path_mapping_error_if_on_docker_and_root_missing() + { + GivenDocker(); + + Subject.Check().ShouldBeError(wikiFragment: "docker-bad-remote-path-mapping"); + } + + [Test] + public void should_return_ok_on_track_imported_event() + { + GivenFolderExists(downloadRootPath); + var importEvent = new TrackImportedEvent(new LocalTrack(), new TrackFile(), new List(), true, new DownloadClientItem()); + + Subject.Check(importEvent).ShouldBeOk(); + } + + [Test] + public void should_return_permissions_error_on_track_import_failed_event_if_file_exists() + { + var localTrack = new LocalTrack { + Path = Path.Combine(downloadItemPath, "file.mp3") + }; + GivenFileExists(localTrack.Path); + + var importEvent = new TrackImportFailedEvent(new Exception(), localTrack, true, new DownloadClientItem()); + + Subject.Check(importEvent).ShouldBeError(wikiFragment: "permissions-error"); + } + + [Test] + public void should_return_permissions_error_on_track_import_failed_event_if_folder_exists() + { + GivenFolderExists(downloadItemPath); + + var importEvent = new TrackImportFailedEvent(null, null, true, downloadItem); + + Subject.Check(importEvent).ShouldBeError(wikiFragment: "permissions-error"); + } + + [Test] + public void should_return_permissions_error_on_track_import_failed_event_for_local_client_if_folder_does_not_exist() + { + var importEvent = new TrackImportFailedEvent(null, null, true, downloadItem); + + Subject.Check(importEvent).ShouldBeError(wikiFragment: "permissions-error"); + } + + [Test] + public void should_return_mapping_error_on_track_import_failed_event_for_remote_client_if_folder_does_not_exist() + { + clientStatus.IsLocalhost = false; + var importEvent = new TrackImportFailedEvent(null, null, true, downloadItem); + + Subject.Check(importEvent).ShouldBeError(wikiFragment: "bad-remote-path-mapping"); + } + + [Test] + public void should_return_mapping_error_on_track_import_failed_event_for_remote_client_if_path_invalid() + { + clientStatus.IsLocalhost = false; + downloadItem.OutputPath = new OsPath("an invalid path"); + var importEvent = new TrackImportFailedEvent(null, null, true, downloadItem); + + Subject.Check(importEvent).ShouldBeError(wikiFragment: "bad-remote-path-mapping"); + } + + [Test] + public void should_return_download_client_error_on_track_import_failed_event_for_remote_client_if_path_invalid() + { + clientStatus.IsLocalhost = true; + downloadItem.OutputPath = new OsPath("an invalid path"); + var importEvent = new TrackImportFailedEvent(null, null, true, downloadItem); + + Subject.Check(importEvent).ShouldBeError(wikiFragment: "bad-download-client-settings"); + } + + [Test] + public void should_return_docker_mapping_error_on_track_import_failed_event_inside_docker_if_folder_does_not_exist() + { + GivenDocker(); + + clientStatus.IsLocalhost = false; + var importEvent = new TrackImportFailedEvent(null, null, true, downloadItem); + + Subject.Check(importEvent).ShouldBeError(wikiFragment: "docker-bad-remote-path-mapping"); + } + + [Test, TestCaseSource("DownloadClientExceptions")] + public void should_return_ok_on_import_failed_event_if_client_throws_downloadclientexception(Exception ex) + { + downloadClient.Setup(s => s.GetStatus()) + .Throws(ex); + + var importEvent = new TrackImportFailedEvent(null, null, true, downloadItem); + + Subject.Check(importEvent).ShouldBeOk(); + + ExceptionVerification.ExpectedErrors(0); + } + } +} diff --git a/src/NzbDrone.Core.Test/HealthCheck/Checks/RootFolderCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/Checks/RootFolderCheckFixture.cs index 45ad31207..bad299f2e 100644 --- a/src/NzbDrone.Core.Test/HealthCheck/Checks/RootFolderCheckFixture.cs +++ b/src/NzbDrone.Core.Test/HealthCheck/Checks/RootFolderCheckFixture.cs @@ -1,12 +1,13 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using FizzWare.NBuilder; using Moq; using NUnit.Framework; using NzbDrone.Common.Disk; using NzbDrone.Core.HealthCheck.Checks; +using NzbDrone.Core.ImportLists; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Test.HealthCheck.Checks { @@ -15,17 +16,25 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks { private void GivenMissingRootFolder() { - var series = Builder.CreateListOfSize(1) + var artist = Builder.CreateListOfSize(1) .Build() .ToList(); - Mocker.GetMock() - .Setup(s => s.GetAllSeries()) - .Returns(series); + var importList = Builder.CreateListOfSize(1) + .Build() + .ToList(); + + Mocker.GetMock() + .Setup(s => s.GetAllArtists()) + .Returns(artist); + + Mocker.GetMock() + .Setup(s => s.All()) + .Returns(importList); Mocker.GetMock() - .Setup(s => s.GetParentFolder(series.First().Path)) - .Returns(@"C:\TV"); + .Setup(s => s.GetParentFolder(artist.First().Path)) + .Returns(@"C:\Music"); Mocker.GetMock() .Setup(s => s.FolderExists(It.IsAny())) @@ -33,17 +42,21 @@ namespace NzbDrone.Core.Test.HealthCheck.Checks } [Test] - public void should_not_return_error_when_no_series() + public void should_not_return_error_when_no_artist() { - Mocker.GetMock() - .Setup(s => s.GetAllSeries()) - .Returns(new List()); + Mocker.GetMock() + .Setup(s => s.GetAllArtists()) + .Returns(new List()); + + Mocker.GetMock() + .Setup(s => s.All()) + .Returns(new List()); Subject.Check().ShouldBeOk(); } [Test] - public void should_return_error_if_series_parent_is_missing() + public void should_return_error_if_artist_parent_is_missing() { GivenMissingRootFolder(); diff --git a/src/NzbDrone.Core.Test/HealthCheck/HealthCheckFixture.cs b/src/NzbDrone.Core.Test/HealthCheck/HealthCheckFixture.cs index 7eea94951..85e586f5d 100644 --- a/src/NzbDrone.Core.Test/HealthCheck/HealthCheckFixture.cs +++ b/src/NzbDrone.Core.Test/HealthCheck/HealthCheckFixture.cs @@ -8,7 +8,7 @@ namespace NzbDrone.Core.Test.HealthCheck [TestFixture] public class HealthCheckFixture : CoreTest { - private const string WikiRoot = "https://github.com/Sonarr/Sonarr/wiki/"; + private const string WikiRoot = "https://github.com/Lidarr/Lidarr/wiki/"; [TestCase("I blew up because of some weird user mistake", null, WikiRoot + "Health-checks#i-blew-up-because-of-some-weird-user-mistake")] [TestCase("I blew up because of some weird user mistake", "#my-health-check", WikiRoot + "Health-checks#my-health-check")] diff --git a/src/NzbDrone.Core.Test/HistoryTests/HistoryRepositoryFixture.cs b/src/NzbDrone.Core.Test/HistoryTests/HistoryRepositoryFixture.cs index 649c3d499..92355343e 100644 --- a/src/NzbDrone.Core.Test/HistoryTests/HistoryRepositoryFixture.cs +++ b/src/NzbDrone.Core.Test/HistoryTests/HistoryRepositoryFixture.cs @@ -31,21 +31,21 @@ namespace NzbDrone.Core.Test.HistoryTests public void should_get_download_history() { var historyBluray = Builder.CreateNew() - .With(c => c.Quality = new QualityModel(Quality.Bluray1080p)) - .With(c => c.SeriesId = 12) + .With(c => c.Quality = new QualityModel(Quality.MP3_320)) + .With(c => c.ArtistId = 12) .With(c => c.EventType = HistoryEventType.Grabbed) .BuildNew(); var historyDvd = Builder.CreateNew() - .With(c => c.Quality = new QualityModel(Quality.DVD)) - .With(c => c.SeriesId = 12) + .With(c => c.Quality = new QualityModel(Quality.MP3_192)) + .With(c => c.ArtistId = 12) .With(c => c.EventType = HistoryEventType.Grabbed) .BuildNew(); Subject.Insert(historyBluray); Subject.Insert(historyDvd); - var downloadHistory = Subject.FindDownloadHistory(12, new QualityModel(Quality.Bluray1080p)); + var downloadHistory = Subject.FindDownloadHistory(12, new QualityModel(Quality.MP3_320)); downloadHistory.Should().HaveCount(1); } diff --git a/src/NzbDrone.Core.Test/HistoryTests/HistoryServiceFixture.cs b/src/NzbDrone.Core.Test/HistoryTests/HistoryServiceFixture.cs index c2d436ec8..ee95f14fa 100644 --- a/src/NzbDrone.Core.Test/HistoryTests/HistoryServiceFixture.cs +++ b/src/NzbDrone.Core.Test/HistoryTests/HistoryServiceFixture.cs @@ -1,4 +1,4 @@ -using System.IO; +using System.IO; using System.Linq; using FizzWare.NBuilder; using Moq; @@ -6,85 +6,68 @@ using NUnit.Framework; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Profiles; +using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Test.Framework; using NzbDrone.Core.History; using NzbDrone.Core.Qualities; using System.Collections.Generic; using NzbDrone.Core.Test.Qualities; -using FluentAssertions; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Download; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Test.HistoryTests { public class HistoryServiceFixture : CoreTest { - private Profile _profile; - private Profile _profileCustom; + private QualityProfile _profile; + private QualityProfile _profileCustom; [SetUp] public void Setup() { - _profile = new Profile { Cutoff = Quality.WEBDL720p, Items = QualityFixture.GetDefaultQualities() }; - _profileCustom = new Profile { Cutoff = Quality.WEBDL720p, Items = QualityFixture.GetDefaultQualities(Quality.DVD) }; - } - - [Test] - public void should_return_null_if_no_history() - { - Mocker.GetMock() - .Setup(v => v.GetBestQualityInHistory(2)) - .Returns(new List()); - - var quality = Subject.GetBestQualityInHistory(_profile, 2); - - quality.Should().BeNull(); - } - - [Test] - public void should_return_best_quality() - { - Mocker.GetMock() - .Setup(v => v.GetBestQualityInHistory(2)) - .Returns(new List { new QualityModel(Quality.DVD), new QualityModel(Quality.Bluray1080p) }); + _profile = new QualityProfile + { + Cutoff = Quality.MP3_320.Id, + Items = QualityFixture.GetDefaultQualities(), + }; - var quality = Subject.GetBestQualityInHistory(_profile, 2); - - quality.Should().Be(new QualityModel(Quality.Bluray1080p)); - } - - [Test] - public void should_return_best_quality_with_custom_order() - { - Mocker.GetMock() - .Setup(v => v.GetBestQualityInHistory(2)) - .Returns(new List { new QualityModel(Quality.DVD), new QualityModel(Quality.Bluray1080p) }); + _profileCustom = new QualityProfile - var quality = Subject.GetBestQualityInHistory(_profileCustom, 2); + { + Cutoff = Quality.MP3_320.Id, + Items = QualityFixture.GetDefaultQualities(Quality.MP3_256), - quality.Should().Be(new QualityModel(Quality.DVD)); + }; } [Test] public void should_use_file_name_for_source_title_if_scene_name_is_null() { - var series = Builder.CreateNew().Build(); - var episodes = Builder.CreateListOfSize(1).Build().ToList(); - var episodeFile = Builder.CreateNew() - .With(f => f.SceneName = null) - .Build(); + var artist = Builder.CreateNew().Build(); + var tracks = Builder.CreateListOfSize(1).Build().ToList(); + var trackFile = Builder.CreateNew() + .With(f => f.SceneName = null) + .With(f => f.Artist = artist) + .Build(); - var localEpisode = new LocalEpisode - { - Series = series, - Episodes = episodes, - Path = @"C:\Test\Unsorted\Series.s01e01.mkv" - }; + var localTrack = new LocalTrack + { + Artist = artist, + Album = new Album(), + Tracks = tracks, + Path = @"C:\Test\Unsorted\Artist.01.Hymn.mp3" + }; - Subject.Handle(new EpisodeImportedEvent(localEpisode, episodeFile, true, "sab", "abcd", true)); + var downloadClientItem = new DownloadClientItem + { + DownloadClient = "sab", + DownloadId = "abcd" + }; + + Subject.Handle(new TrackImportedEvent(localTrack, trackFile, new List(), true, downloadClientItem)); Mocker.GetMock() - .Verify(v => v.Insert(It.Is(h => h.SourceTitle == Path.GetFileNameWithoutExtension(localEpisode.Path)))); + .Verify(v => v.Insert(It.Is(h => h.SourceTitle == Path.GetFileNameWithoutExtension(localTrack.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..cda91729d --- /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.ParsedAlbumInfo = new ParsedAlbumInfo()) + .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.ParsedAlbumInfo = new ParsedAlbumInfo()) + .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.ParsedAlbumInfo = new ParsedAlbumInfo()) + .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/CleanupDuplicateMetadataFilesFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupDuplicateMetadataFilesFixture.cs index 5bfeaefc0..8fc699cfa 100644 --- a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupDuplicateMetadataFilesFixture.cs +++ b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupDuplicateMetadataFilesFixture.cs @@ -1,4 +1,4 @@ -using FizzWare.NBuilder; +using FizzWare.NBuilder; using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.Extras.Metadata; @@ -12,12 +12,12 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers public class CleanupDuplicateMetadataFilesFixture : DbTest { [Test] - public void should_not_delete_metadata_files_when_they_are_for_the_same_series_but_different_consumers() + public void should_not_delete_metadata_files_when_they_are_for_the_same_artist_but_different_consumers() { var files = Builder.CreateListOfSize(2) .All() - .With(m => m.Type = MetadataType.SeriesMetadata) - .With(m => m.SeriesId = 1) + .With(m => m.Type = MetadataType.ArtistMetadata) + .With(m => m.ArtistId = 1) .BuildListOfNew(); Db.InsertMany(files); @@ -26,11 +26,11 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers } [Test] - public void should_not_delete_metadata_files_for_different_series() + public void should_not_delete_metadata_files_for_different_artist() { var files = Builder.CreateListOfSize(2) .All() - .With(m => m.Type = MetadataType.SeriesMetadata) + .With(m => m.Type = MetadataType.ArtistMetadata) .With(m => m.Consumer = "XbmcMetadata") .BuildListOfNew(); @@ -40,12 +40,12 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers } [Test] - public void should_delete_metadata_files_when_they_are_for_the_same_series_and_consumer() + public void should_delete_metadata_files_when_they_are_for_the_same_artist_and_consumer() { var files = Builder.CreateListOfSize(2) .All() - .With(m => m.Type = MetadataType.SeriesMetadata) - .With(m => m.SeriesId = 1) + .With(m => m.Type = MetadataType.ArtistMetadata) + .With(m => m.ArtistId = 1) .With(m => m.Consumer = "XbmcMetadata") .BuildListOfNew(); @@ -55,7 +55,7 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers } [Test] - public void should_not_delete_metadata_files_when_there_is_only_one_for_that_series_and_consumer() + public void should_not_delete_metadata_files_when_there_is_only_one_for_that_artist_and_consumer() { var file = Builder.CreateNew() .BuildNew(); @@ -66,12 +66,13 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers } [Test] - public void should_not_delete_metadata_files_when_they_are_for_the_same_episode_but_different_consumers() + public void should_not_delete_metadata_files_when_they_are_for_the_same_album_but_different_consumers() { var files = Builder.CreateListOfSize(2) .All() - .With(m => m.Type = MetadataType.EpisodeMetadata) - .With(m => m.EpisodeFileId = 1) + .With(m => m.Type = MetadataType.AlbumMetadata) + .With(m => m.ArtistId = 1) + .With(m => m.AlbumId = 1) .BuildListOfNew(); Db.InsertMany(files); @@ -80,12 +81,13 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers } [Test] - public void should_not_delete_metadata_files_for_different_episode() + public void should_not_delete_metadata_files_for_different_album() { var files = Builder.CreateListOfSize(2) .All() - .With(m => m.Type = MetadataType.EpisodeMetadata) + .With(m => m.Type = MetadataType.AlbumMetadata) .With(m => m.Consumer = "XbmcMetadata") + .With(m => m.ArtistId = 1) .BuildListOfNew(); Db.InsertMany(files); @@ -94,12 +96,13 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers } [Test] - public void should_delete_metadata_files_when_they_are_for_the_same_episode_and_consumer() + public void should_delete_metadata_files_when_they_are_for_the_same_album_and_consumer() { var files = Builder.CreateListOfSize(2) .All() - .With(m => m.Type = MetadataType.EpisodeMetadata) - .With(m => m.EpisodeFileId = 1) + .With(m => m.Type = MetadataType.AlbumMetadata) + .With(m => m.ArtistId = 1) + .With(m => m.AlbumId = 1) .With(m => m.Consumer = "XbmcMetadata") .BuildListOfNew(); @@ -109,10 +112,10 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers } [Test] - public void should_not_delete_metadata_files_when_there_is_only_one_for_that_episode_and_consumer() + public void should_not_delete_metadata_files_when_there_is_only_one_for_that_album_and_consumer() { var file = Builder.CreateNew() - .BuildNew(); + .BuildNew(); Db.Insert(file); Subject.Clean(); @@ -120,12 +123,12 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers } [Test] - public void should_not_delete_image_when_they_are_for_the_same_episode_but_different_consumers() + public void should_not_delete_metadata_files_when_they_are_for_the_same_track_but_different_consumers() { var files = Builder.CreateListOfSize(2) .All() - .With(m => m.Type = MetadataType.EpisodeImage) - .With(m => m.EpisodeFileId = 1) + .With(m => m.Type = MetadataType.TrackMetadata) + .With(m => m.TrackFileId = 1) .BuildListOfNew(); Db.InsertMany(files); @@ -134,11 +137,11 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers } [Test] - public void should_not_delete_image_for_different_episode() + public void should_not_delete_metadata_files_for_different_track() { var files = Builder.CreateListOfSize(2) .All() - .With(m => m.Type = MetadataType.EpisodeImage) + .With(m => m.Type = MetadataType.TrackMetadata) .With(m => m.Consumer = "XbmcMetadata") .BuildListOfNew(); @@ -148,12 +151,12 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers } [Test] - public void should_delete_image_when_they_are_for_the_same_episode_and_consumer() + public void should_delete_metadata_files_when_they_are_for_the_same_track_and_consumer() { var files = Builder.CreateListOfSize(2) .All() - .With(m => m.Type = MetadataType.EpisodeImage) - .With(m => m.EpisodeFileId = 1) + .With(m => m.Type = MetadataType.TrackMetadata) + .With(m => m.TrackFileId = 1) .With(m => m.Consumer = "XbmcMetadata") .BuildListOfNew(); @@ -163,7 +166,7 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers } [Test] - public void should_not_delete_image_when_there_is_only_one_for_that_episode_and_consumer() + public void should_not_delete_metadata_files_when_there_is_only_one_for_that_track_and_consumer() { var file = Builder.CreateNew() .BuildNew(); diff --git a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedAlbumsFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedAlbumsFixture.cs new file mode 100644 index 000000000..aeac11da6 --- /dev/null +++ b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedAlbumsFixture.cs @@ -0,0 +1,44 @@ +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Housekeeping.Housekeepers; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Test.Housekeeping.Housekeepers +{ + [TestFixture] + public class CleanupOrphanedAlbumsFixture : DbTest + { + [Test] + public void should_delete_orphaned_albums() + { + var album = Builder.CreateNew() + .BuildNew(); + + Db.Insert(album); + Subject.Clean(); + AllStoredModels.Should().BeEmpty(); + } + + [Test] + public void should_not_delete_unorphaned_albums() + { + var artist = Builder.CreateNew() + .With(e => e.Metadata = new ArtistMetadata {Id = 1}) + .BuildNew(); + + Db.Insert(artist); + + var albums = Builder.CreateListOfSize(2) + .TheFirst(1) + .With(e => e.ArtistMetadataId = artist.Metadata.Value.Id) + .BuildListOfNew(); + + Db.InsertMany(albums); + Subject.Clean(); + AllStoredModels.Should().HaveCount(1); + AllStoredModels.Should().Contain(e => e.ArtistMetadataId == artist.Metadata.Value.Id); + } + } +} diff --git a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedBlacklistFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedBlacklistFixture.cs index e6eaa1af9..37fe1cff6 100644 --- a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedBlacklistFixture.cs +++ b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedBlacklistFixture.cs @@ -5,7 +5,7 @@ using NzbDrone.Core.Blacklisting; using NzbDrone.Core.Housekeeping.Housekeepers; using NzbDrone.Core.Qualities; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; using System.Collections.Generic; namespace NzbDrone.Core.Test.Housekeeping.Housekeepers @@ -17,7 +17,7 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers public void should_delete_orphaned_blacklist_items() { var blacklist = Builder.CreateNew() - .With(h => h.EpisodeIds = new List()) + .With(h => h.AlbumIds = new List()) .With(h => h.Quality = new QualityModel()) .BuildNew(); @@ -29,14 +29,14 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers [Test] public void should_not_delete_unorphaned_blacklist_items() { - var series = Builder.CreateNew().BuildNew(); + var artist = Builder.CreateNew().BuildNew(); - Db.Insert(series); + Db.Insert(artist); var blacklist = Builder.CreateNew() - .With(h => h.EpisodeIds = new List()) + .With(h => h.AlbumIds = new List()) .With(h => h.Quality = new QualityModel()) - .With(b => b.SeriesId = series.Id) + .With(b => b.ArtistId = artist.Id) .BuildNew(); Db.Insert(blacklist); diff --git a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedEpisodeFilesFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedEpisodeFilesFixture.cs deleted file mode 100644 index b09def40c..000000000 --- a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedEpisodeFilesFixture.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Linq; -using FizzWare.NBuilder; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Housekeeping.Housekeepers; -using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; -using NzbDrone.Core.Qualities; - -namespace NzbDrone.Core.Test.Housekeeping.Housekeepers -{ - [TestFixture] - public class CleanupOrphanedEpisodeFilesFixture : DbTest - { - [Test] - public void should_delete_orphaned_episode_files() - { - var episodeFile = Builder.CreateNew() - .With(h => h.Quality = new QualityModel()) - .BuildNew(); - - Db.Insert(episodeFile); - Subject.Clean(); - AllStoredModels.Should().BeEmpty(); - } - - [Test] - public void should_not_delete_unorphaned_episode_files() - { - var episodeFiles = Builder.CreateListOfSize(2) - .All() - .With(h => h.Quality = new QualityModel()) - .BuildListOfNew(); - - Db.InsertMany(episodeFiles); - - var episode = Builder.CreateNew() - .With(e => e.EpisodeFileId = episodeFiles.First().Id) - .BuildNew(); - - Db.Insert(episode); - - Subject.Clean(); - AllStoredModels.Should().HaveCount(1); - Db.All().Should().Contain(e => e.EpisodeFileId == AllStoredModels.First().Id); - } - } -} diff --git a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedEpisodesFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedEpisodesFixture.cs deleted file mode 100644 index 03f8b395e..000000000 --- a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedEpisodesFixture.cs +++ /dev/null @@ -1,43 +0,0 @@ -using FizzWare.NBuilder; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Housekeeping.Housekeepers; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Test.Housekeeping.Housekeepers -{ - [TestFixture] - public class CleanupOrphanedEpisodesFixture : DbTest - { - [Test] - public void should_delete_orphaned_episodes() - { - var episode = Builder.CreateNew() - .BuildNew(); - - Db.Insert(episode); - Subject.Clean(); - AllStoredModels.Should().BeEmpty(); - } - - [Test] - public void should_not_delete_unorphaned_episodes() - { - var series = Builder.CreateNew() - .BuildNew(); - - Db.Insert(series); - - var episodes = Builder.CreateListOfSize(2) - .TheFirst(1) - .With(e => e.SeriesId = series.Id) - .BuildListOfNew(); - - Db.InsertMany(episodes); - Subject.Clean(); - AllStoredModels.Should().HaveCount(1); - AllStoredModels.Should().Contain(e => e.SeriesId == series.Id); - } - } -} diff --git a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedHistoryItemsFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedHistoryItemsFixture.cs index 022248abd..a118e4696 100644 --- a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedHistoryItemsFixture.cs +++ b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedHistoryItemsFixture.cs @@ -4,44 +4,44 @@ using NUnit.Framework; using NzbDrone.Core.Housekeeping.Housekeepers; using NzbDrone.Core.Qualities; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Test.Housekeeping.Housekeepers { [TestFixture] public class CleanupOrphanedHistoryItemsFixture : DbTest { - private Series _series; - private Episode _episode; + private Artist _artist; + private Album _album; [SetUp] public void Setup() { - _series = Builder.CreateNew() + _artist = Builder.CreateNew() .BuildNew(); - _episode = Builder.CreateNew() - .BuildNew(); + _album = Builder.CreateNew() + .BuildNew(); } - private void GivenSeries() + private void GivenArtist() { - Db.Insert(_series); + Db.Insert(_artist); } - private void GivenEpisode() + private void GivenAlbum() { - Db.Insert(_episode); + Db.Insert(_album); } [Test] - public void should_delete_orphaned_items_by_series() + public void should_delete_orphaned_items_by_artist() { - GivenEpisode(); + GivenAlbum(); var history = Builder.CreateNew() .With(h => h.Quality = new QualityModel()) - .With(h => h.EpisodeId = _episode.Id) + .With(h => h.AlbumId = _album.Id) .BuildNew(); Db.Insert(history); @@ -50,13 +50,13 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers } [Test] - public void should_delete_orphaned_items_by_episode() + public void should_delete_orphaned_items_by_album() { - GivenSeries(); + GivenArtist(); var history = Builder.CreateNew() .With(h => h.Quality = new QualityModel()) - .With(h => h.SeriesId = _series.Id) + .With(h => h.ArtistId = _artist.Id) .BuildNew(); Db.Insert(history); @@ -65,45 +65,45 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers } [Test] - public void should_not_delete_unorphaned_data_by_series() + public void should_not_delete_unorphaned_data_by_artist() { - GivenSeries(); - GivenEpisode(); + GivenArtist(); + GivenAlbum(); var history = Builder.CreateListOfSize(2) .All() .With(h => h.Quality = new QualityModel()) - .With(h => h.EpisodeId = _episode.Id) + .With(h => h.AlbumId = _album.Id) .TheFirst(1) - .With(h => h.SeriesId = _series.Id) + .With(h => h.ArtistId = _artist.Id) .BuildListOfNew(); Db.InsertMany(history); Subject.Clean(); AllStoredModels.Should().HaveCount(1); - AllStoredModels.Should().Contain(h => h.SeriesId == _series.Id); + AllStoredModels.Should().Contain(h => h.ArtistId == _artist.Id); } [Test] - public void should_not_delete_unorphaned_data_by_episode() + public void should_not_delete_unorphaned_data_by_album() { - GivenSeries(); - GivenEpisode(); + GivenArtist(); + GivenAlbum(); var history = Builder.CreateListOfSize(2) .All() .With(h => h.Quality = new QualityModel()) - .With(h => h.SeriesId = _series.Id) + .With(h => h.ArtistId = _artist.Id) .TheFirst(1) - .With(h => h.EpisodeId = _episode.Id) + .With(h => h.AlbumId = _album.Id) .BuildListOfNew(); Db.InsertMany(history); Subject.Clean(); AllStoredModels.Should().HaveCount(1); - AllStoredModels.Should().Contain(h => h.EpisodeId == _episode.Id); + AllStoredModels.Should().Contain(h => h.AlbumId == _album.Id); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedImportListStatusFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedImportListStatusFixture.cs new file mode 100644 index 000000000..860834cdb --- /dev/null +++ b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedImportListStatusFixture.cs @@ -0,0 +1,54 @@ +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Housekeeping.Housekeepers; +using NzbDrone.Core.ImportLists; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Housekeeping.Housekeepers +{ + [TestFixture] + public class CleanupOrphanedImportListStatusFixture : DbTest + { + private ImportListDefinition _importList; + + [SetUp] + public void Setup() + { + _importList = Builder.CreateNew() + .BuildNew(); + } + + private void GivenImportList() + { + Db.Insert(_importList); + } + + [Test] + public void should_delete_orphaned_importliststatus() + { + var status = Builder.CreateNew() + .With(h => h.ProviderId = _importList.Id) + .BuildNew(); + Db.Insert(status); + + Subject.Clean(); + AllStoredModels.Should().BeEmpty(); + } + + [Test] + public void should_not_delete_unorphaned_importliststatus() + { + GivenImportList(); + + var status = Builder.CreateNew() + .With(h => h.ProviderId = _importList.Id) + .BuildNew(); + Db.Insert(status); + + Subject.Clean(); + AllStoredModels.Should().HaveCount(1); + AllStoredModels.Should().Contain(h => h.ProviderId == _importList.Id); + } + } +} 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/CleanupOrphanedMetadataFilesFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedMetadataFilesFixture.cs index 27679d8d3..78a0515f8 100644 --- a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedMetadataFilesFixture.cs +++ b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedMetadataFilesFixture.cs @@ -1,4 +1,4 @@ -using FizzWare.NBuilder; +using FizzWare.NBuilder; using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.Extras.Metadata; @@ -7,7 +7,7 @@ using NzbDrone.Core.Housekeeping.Housekeepers; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Qualities; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Test.Housekeeping.Housekeepers { @@ -15,10 +15,10 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers public class CleanupOrphanedMetadataFilesFixture : DbTest { [Test] - public void should_delete_metadata_files_that_dont_have_a_coresponding_series() + public void should_delete_metadata_files_that_dont_have_a_coresponding_artist() { var metadataFile = Builder.CreateNew() - .With(m => m.EpisodeFileId = null) + .With(m => m.TrackFileId = null) .BuildNew(); Db.Insert(metadataFile); @@ -27,34 +27,82 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers } [Test] - public void should_not_delete_metadata_files_that_have_a_coresponding_series() + public void should_delete_metadata_files_that_dont_have_a_coresponding_album() { - var series = Builder.CreateNew() + var artist = Builder.CreateNew() .BuildNew(); - Db.Insert(series); + Db.Insert(artist); var metadataFile = Builder.CreateNew() - .With(m => m.SeriesId = series.Id) - .With(m => m.EpisodeFileId = null) + .With(m => m.ArtistId = artist.Id) + .With(m => m.TrackFileId = null) .BuildNew(); Db.Insert(metadataFile); Subject.Clean(); + AllStoredModels.Should().BeEmpty(); + } + + [Test] + public void should_not_delete_metadata_files_that_have_a_coresponding_artist() + { + var artist = Builder.CreateNew() + .BuildNew(); + + Db.Insert(artist); + + var metadataFile = Builder.CreateNew() + .With(m => m.ArtistId = artist.Id) + .With(m => m.AlbumId = null) + .With(m => m.TrackFileId = null) + .BuildNew(); + + Db.Insert(metadataFile); + var countMods = AllStoredModels.Count; + Subject.Clean(); AllStoredModels.Should().HaveCount(1); } [Test] - public void should_delete_metadata_files_that_dont_have_a_coresponding_episode_file() + public void should_not_delete_metadata_files_that_have_a_coresponding_album() { - var series = Builder.CreateNew() + var artist = Builder.CreateNew() .BuildNew(); - Db.Insert(series); + var album = Builder.CreateNew() + .BuildNew(); + + Db.Insert(artist); + Db.Insert(album); var metadataFile = Builder.CreateNew() - .With(m => m.SeriesId = series.Id) - .With(m => m.EpisodeFileId = 10) + .With(m => m.ArtistId = artist.Id) + .With(m => m.AlbumId = album.Id) + .With(m => m.TrackFileId = null) + .BuildNew(); + + Db.Insert(metadataFile); + Subject.Clean(); + AllStoredModels.Should().HaveCount(1); + } + + [Test] + public void should_delete_metadata_files_that_dont_have_a_coresponding_track_file() + { + var artist = Builder.CreateNew() + .BuildNew(); + + var album = Builder.CreateNew() + .BuildNew(); + + Db.Insert(artist); + Db.Insert(album); + + var metadataFile = Builder.CreateNew() + .With(m => m.ArtistId = artist.Id) + .With(m => m.AlbumId = album.Id) + .With(m => m.TrackFileId = 10) .BuildNew(); Db.Insert(metadataFile); @@ -63,21 +111,26 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers } [Test] - public void should_not_delete_metadata_files_that_have_a_coresponding_episode_file() + public void should_not_delete_metadata_files_that_have_a_coresponding_track_file() { - var series = Builder.CreateNew() + var artist = Builder.CreateNew() .BuildNew(); - var episodeFile = Builder.CreateNew() + var album = Builder.CreateNew() + .BuildNew(); + + var trackFile = Builder.CreateNew() .With(h => h.Quality = new QualityModel()) .BuildNew(); - Db.Insert(series); - Db.Insert(episodeFile); + Db.Insert(artist); + Db.Insert(album); + Db.Insert(trackFile); var metadataFile = Builder.CreateNew() - .With(m => m.SeriesId = series.Id) - .With(m => m.EpisodeFileId = episodeFile.Id) + .With(m => m.ArtistId = artist.Id) + .With(m => m.AlbumId = album.Id) + .With(m => m.TrackFileId = trackFile.Id) .BuildNew(); Db.Insert(metadataFile); @@ -86,18 +139,39 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers } [Test] - public void should_delete_episode_metadata_files_that_have_episodefileid_of_zero() + public void should_delete_album_metadata_files_that_have_albumid_of_zero() { - var series = Builder.CreateNew() - .BuildNew(); + var artist = Builder.CreateNew() + .BuildNew(); - Db.Insert(series); + Db.Insert(artist); var metadataFile = Builder.CreateNew() - .With(m => m.SeriesId = series.Id) - .With(m => m.Type = MetadataType.EpisodeMetadata) - .With(m => m.EpisodeFileId = 0) - .BuildNew(); + .With(m => m.ArtistId = artist.Id) + .With(m => m.Type = MetadataType.AlbumMetadata) + .With(m => m.AlbumId = 0) + .With(m => m.TrackFileId = null) + .BuildNew(); + + Db.Insert(metadataFile); + Subject.Clean(); + AllStoredModels.Should().HaveCount(0); + } + + [Test] + public void should_delete_album_image_files_that_have_albumid_of_zero() + { + var artist = Builder.CreateNew() + .BuildNew(); + + Db.Insert(artist); + + var metadataFile = Builder.CreateNew() + .With(m => m.ArtistId = artist.Id) + .With(m => m.Type = MetadataType.AlbumImage) + .With(m => m.AlbumId = 0) + .With(m => m.TrackFileId = null) + .BuildNew(); Db.Insert(metadataFile); Subject.Clean(); @@ -105,18 +179,18 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers } [Test] - public void should_delete_episode_image_files_that_have_episodefileid_of_zero() + public void should_delete_track_metadata_files_that_have_trackfileid_of_zero() { - var series = Builder.CreateNew() + var artist = Builder.CreateNew() .BuildNew(); - Db.Insert(series); + Db.Insert(artist); var metadataFile = Builder.CreateNew() - .With(m => m.SeriesId = series.Id) - .With(m => m.Type = MetadataType.EpisodeImage) - .With(m => m.EpisodeFileId = 0) - .BuildNew(); + .With(m => m.ArtistId = artist.Id) + .With(m => m.Type = MetadataType.TrackMetadata) + .With(m => m.TrackFileId = 0) + .BuildNew(); Db.Insert(metadataFile); Subject.Clean(); diff --git a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedPendingReleasesFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedPendingReleasesFixture.cs index 104ba9bfc..c0d7aea4d 100644 --- a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedPendingReleasesFixture.cs +++ b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedPendingReleasesFixture.cs @@ -5,7 +5,7 @@ using NzbDrone.Core.Download.Pending; using NzbDrone.Core.Housekeeping.Housekeepers; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Test.Housekeeping.Housekeepers { @@ -16,7 +16,7 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers public void should_delete_orphaned_pending_items() { var pendingRelease = Builder.CreateNew() - .With(h => h.ParsedEpisodeInfo = new ParsedEpisodeInfo()) + .With(h => h.ParsedAlbumInfo = new ParsedAlbumInfo()) .With(h => h.Release = new ReleaseInfo()) .BuildNew(); @@ -28,13 +28,13 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers [Test] public void should_not_delete_unorphaned_pending_items() { - var series = Builder.CreateNew().BuildNew(); + var artist = Builder.CreateNew().BuildNew(); - Db.Insert(series); + Db.Insert(artist); var pendingRelease = Builder.CreateNew() - .With(h => h.SeriesId = series.Id) - .With(h => h.ParsedEpisodeInfo = new ParsedEpisodeInfo()) + .With(h => h.ArtistId = artist.Id) + .With(h => h.ParsedAlbumInfo = new ParsedAlbumInfo()) .With(h => h.Release = new ReleaseInfo()) .BuildNew(); diff --git a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedTrackFilesFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedTrackFilesFixture.cs new file mode 100644 index 000000000..fa3fbcae9 --- /dev/null +++ b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedTrackFilesFixture.cs @@ -0,0 +1,52 @@ +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Housekeeping.Housekeepers; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; +using NzbDrone.Core.Qualities; + +namespace NzbDrone.Core.Test.Housekeeping.Housekeepers +{ + [TestFixture] + public class CleanupOrphanedTrackFilesFixture : DbTest + { + [Test] + public void should_unlink_orphaned_track_files() + { + var trackFile = Builder.CreateNew() + .With(h => h.Quality = new QualityModel()) + .With(h => h.AlbumId = 1) + .BuildNew(); + + Db.Insert(trackFile); + Subject.Clean(); + AllStoredModels[0].AlbumId.Should().Be(0); + } + + [Test] + public void should_not_unlink_unorphaned_track_files() + { + var trackFiles = Builder.CreateListOfSize(2) + .All() + .With(h => h.Quality = new QualityModel()) + .With(h => h.AlbumId = 1) + .BuildListOfNew(); + + Db.InsertMany(trackFiles); + + var track = Builder.CreateNew() + .With(e => e.TrackFileId = trackFiles.First().Id) + .BuildNew(); + + Db.Insert(track); + + Subject.Clean(); + AllStoredModels.Where(x => x.AlbumId == 1).Should().HaveCount(1); + + Db.All().Should().Contain(e => e.TrackFileId == AllStoredModels.First().Id); + } + } +} diff --git a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedTracksFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedTracksFixture.cs new file mode 100644 index 000000000..7c46f932b --- /dev/null +++ b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedTracksFixture.cs @@ -0,0 +1,43 @@ +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Housekeeping.Housekeepers; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Test.Housekeeping.Housekeepers +{ + [TestFixture] + public class CleanupOrphanedTracksFixture : DbTest + { + [Test] + public void should_delete_orphaned_tracks() + { + var track = Builder.CreateNew() + .BuildNew(); + + Db.Insert(track); + Subject.Clean(); + AllStoredModels.Should().BeEmpty(); + } + + [Test] + public void should_not_delete_unorphaned_tracks() + { + var release = Builder.CreateNew() + .BuildNew(); + + Db.Insert(release); + + var tracks = Builder.CreateListOfSize(2) + .TheFirst(1) + .With(e => e.AlbumReleaseId = release.Id) + .BuildListOfNew(); + + Db.InsertMany(tracks); + Subject.Clean(); + AllStoredModels.Should().HaveCount(1); + AllStoredModels.Should().Contain(e => e.AlbumReleaseId == release.Id); + } + } +} diff --git a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupUnusedTagsFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupUnusedTagsFixture.cs index fa7401ef5..70f3582e6 100644 --- a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupUnusedTagsFixture.cs +++ b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupUnusedTagsFixture.cs @@ -1,10 +1,10 @@ -using FizzWare.NBuilder; +using FizzWare.NBuilder; using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.Housekeeping.Housekeepers; using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Tags; -using NzbDrone.Core.Restrictions; +using NzbDrone.Core.Profiles.Releases; namespace NzbDrone.Core.Test.Housekeeping.Housekeepers { @@ -27,7 +27,7 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers var tags = Builder.CreateListOfSize(2).BuildList(); Db.InsertMany(tags); - var restrictions = Builder.CreateListOfSize(2) + var restrictions = Builder.CreateListOfSize(2) .All() .With(v => v.Tags.Add(tags[0].Id)) .BuildList(); 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..cc563e065 --- /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/FixFutureImportListStatusTimesFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/FixFutureImportListStatusTimesFixture.cs new file mode 100644 index 000000000..77197ad34 --- /dev/null +++ b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/FixFutureImportListStatusTimesFixture.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.ImportLists; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.ThingiProvider.Status; + +namespace NzbDrone.Core.Test.Housekeeping.Housekeepers +{ + [TestFixture] + public class FixFutureImportListStatusTimesFixture : CoreTest + { + [Test] + public void should_set_disabled_till_when_its_too_far_in_the_future() + { + var disabledTillTime = EscalationBackOff.Periods[1]; + var importListStatuses = 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(importListStatuses); + + 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 importListStatuses = 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(importListStatuses); + + 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 importListStatuses = 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(importListStatuses); + + 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 importListStatuses = 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(importListStatuses); + + 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..fe7b61cc6 --- /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/Housekeeping/Housekeepers/UpdateCleanTitleForArtistFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/UpdateCleanTitleForArtistFixture.cs new file mode 100644 index 000000000..a7cf09a07 --- /dev/null +++ b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/UpdateCleanTitleForArtistFixture.cs @@ -0,0 +1,50 @@ +using FizzWare.NBuilder; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Housekeeping.Housekeepers; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Test.Housekeeping.Housekeepers +{ + [TestFixture] + public class UpdateCleanTitleForArtistFixture : CoreTest + { + [Test] + public void should_update_clean_title() + { + var artist = Builder.CreateNew() + .With(s => s.Name = "Full Name") + .With(s => s.CleanName = "unclean") + .Build(); + + Mocker.GetMock() + .Setup(s => s.All()) + .Returns(new[] { artist }); + + Subject.Clean(); + + Mocker.GetMock() + .Verify(v => v.Update(It.Is(s => s.CleanName == "fullname")), Times.Once()); + } + + [Test] + public void should_not_update_unchanged_title() + { + var artist = Builder.CreateNew() + .With(s => s.Name = "Full Name") + .With(s => s.CleanName = "fullname") + .Build(); + + Mocker.GetMock() + .Setup(s => s.All()) + .Returns(new[] { artist }); + + Subject.Clean(); + + Mocker.GetMock() + .Verify(v => v.Update(It.Is(s => s.CleanName == "fullname")), Times.Never()); + } + } +} diff --git a/src/NzbDrone.Core.Test/ImportListTests/ImportListServiceFixture.cs b/src/NzbDrone.Core.Test/ImportListTests/ImportListServiceFixture.cs new file mode 100644 index 000000000..086821da0 --- /dev/null +++ b/src/NzbDrone.Core.Test/ImportListTests/ImportListServiceFixture.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.ImportLists; +using NzbDrone.Core.ImportLists.LidarrLists; +using NzbDrone.Core.Lifecycle; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.ImportListTests +{ + public class ImportListServiceFixture : DbTest + { + private List _importLists; + + [SetUp] + public void Setup() + { + _importLists = new List(); + + _importLists.Add(Mocker.Resolve()); + + Mocker.SetConstant>(_importLists); + } + + [Test] + public void should_remove_missing_import_lists_on_startup() + { + var repo = Mocker.Resolve(); + + Mocker.SetConstant(repo); + + var existingImportLists = Builder.CreateNew().BuildNew(); + existingImportLists.ConfigContract = typeof (LidarrListsSettings).Name; + + repo.Insert(existingImportLists); + + Subject.Handle(new ApplicationStartedEvent()); + + AllStoredModels.Should().NotContain(c => c.Id == existingImportLists.Id); + } + } +} diff --git a/src/NzbDrone.Core.Test/ImportListTests/ImportListStatusServiceFixture.cs b/src/NzbDrone.Core.Test/ImportListTests/ImportListStatusServiceFixture.cs new file mode 100644 index 000000000..0cbafb36d --- /dev/null +++ b/src/NzbDrone.Core.Test/ImportListTests/ImportListStatusServiceFixture.cs @@ -0,0 +1,72 @@ +using System; +using System.Linq; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Core.ImportLists; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.ImportListTests +{ + public class ImportListStatusServiceFixture : CoreTest + { + private DateTime _epoch; + + [SetUp] + public void SetUp() + { + _epoch = DateTime.UtcNow; + + Mocker.GetMock() + .SetupGet(v => v.StartTime) + .Returns(_epoch - TimeSpan.FromHours(1)); + } + + private void WithStatus(ImportListStatus 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_cancel_backoff_on_success() + { + WithStatus(new ImportListStatus { 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 ImportListStatus { EscalationLevel = 0 }); + + Subject.RecordSuccess(1); + + VerifyNoUpdate(); + } + } +} diff --git a/src/NzbDrone.Core.Test/ImportListTests/ImportListSyncServiceFixture.cs b/src/NzbDrone.Core.Test/ImportListTests/ImportListSyncServiceFixture.cs new file mode 100644 index 000000000..17995eeed --- /dev/null +++ b/src/NzbDrone.Core.Test/ImportListTests/ImportListSyncServiceFixture.cs @@ -0,0 +1,231 @@ +using System.Linq; +using System.Collections.Generic; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.ImportLists; +using NzbDrone.Core.MetadataSource; +using NzbDrone.Core.Music; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.ImportLists.Exclusions; + +namespace NzbDrone.Core.Test.ImportListTests +{ + public class ImportListSyncServiceFixture : CoreTest + { + private List _importListReports; + + [SetUp] + public void SetUp() + { + var importListItem1 = new ImportListItemInfo + { + Artist = "Linkin Park" + }; + + _importListReports = new List{importListItem1}; + + Mocker.GetMock() + .Setup(v => v.Fetch()) + .Returns(_importListReports); + + Mocker.GetMock() + .Setup(v => v.SearchForNewArtist(It.IsAny())) + .Returns(new List()); + + Mocker.GetMock() + .Setup(v => v.SearchForNewAlbum(It.IsAny(), It.IsAny())) + .Returns(new List()); + + Mocker.GetMock() + .Setup(v => v.Get(It.IsAny())) + .Returns(new ImportListDefinition{ ShouldMonitor = ImportListMonitorType.SpecificAlbum }); + + Mocker.GetMock() + .Setup(v => v.Fetch()) + .Returns(_importListReports); + + Mocker.GetMock() + .Setup(v => v.All()) + .Returns(new List()); + } + + private void WithAlbum() + { + _importListReports.First().Album = "Meteora"; + } + + private void WithArtistId() + { + _importListReports.First().ArtistMusicBrainzId = "f59c5520-5f46-4d2c-b2c4-822eabf53419"; + } + + private void WithAlbumId() + { + _importListReports.First().AlbumMusicBrainzId = "09474d62-17dd-3a4f-98fb-04c65f38a479"; + } + + private void WithExistingArtist() + { + Mocker.GetMock() + .Setup(v => v.FindById(_importListReports.First().ArtistMusicBrainzId)) + .Returns(new Artist{ForeignArtistId = _importListReports.First().ArtistMusicBrainzId }); + } + + private void WithExcludedArtist() + { + Mocker.GetMock() + .Setup(v => v.All()) + .Returns(new List { + new ImportListExclusion { + ForeignId = "f59c5520-5f46-4d2c-b2c4-822eabf53419" + } + }); + } + + private void WithMonitorType(ImportListMonitorType monitor) + { + Mocker.GetMock() + .Setup(v => v.Get(It.IsAny())) + .Returns(new ImportListDefinition{ ShouldMonitor = monitor }); + } + + [Test] + public void should_search_if_artist_title_and_no_artist_id() + { + Subject.Execute(new ImportListSyncCommand()); + + Mocker.GetMock() + .Verify(v => v.SearchForNewArtist(It.IsAny()), Times.Once()); + } + + [Test] + public void should_not_search_if_artist_title_and_artist_id() + { + WithArtistId(); + Subject.Execute(new ImportListSyncCommand()); + + Mocker.GetMock() + .Verify(v => v.SearchForNewArtist(It.IsAny()), Times.Never()); + } + + [Test] + public void should_search_if_album_title_and_no_album_id() + { + WithAlbum(); + Subject.Execute(new ImportListSyncCommand()); + + Mocker.GetMock() + .Verify(v => v.SearchForNewAlbum(It.IsAny(), It.IsAny()), Times.Once()); + } + + [Test] + public void should_not_search_if_album_title_and_album_id() + { + WithAlbum(); + WithAlbumId(); + Subject.Execute(new ImportListSyncCommand()); + + Mocker.GetMock() + .Verify(v => v.SearchForNewAlbum(It.IsAny(), It.IsAny()), Times.Never()); + } + + [Test] + public void should_not_search_if_all_info() + { + WithArtistId(); + WithAlbum(); + WithAlbumId(); + Subject.Execute(new ImportListSyncCommand()); + + Mocker.GetMock() + .Verify(v => v.SearchForNewArtist(It.IsAny()), Times.Never()); + + Mocker.GetMock() + .Verify(v => v.SearchForNewAlbum(It.IsAny(), It.IsAny()), Times.Never()); + } + + [Test] + public void should_not_add_if_existing_artist() + { + WithArtistId(); + WithAlbum(); + WithAlbumId(); + WithExistingArtist(); + + Subject.Execute(new ImportListSyncCommand()); + + Mocker.GetMock() + .Verify(v => v.AddArtists(It.Is>(t=>t.Count == 0))); + } + + [TestCase(ImportListMonitorType.None, false)] + [TestCase(ImportListMonitorType.SpecificAlbum, true)] + [TestCase(ImportListMonitorType.EntireArtist, true)] + public void should_add_if_not_existing_artist(ImportListMonitorType monitor, bool expectedArtistMonitored) + { + WithArtistId(); + WithAlbum(); + WithAlbumId(); + WithMonitorType(monitor); + + Subject.Execute(new ImportListSyncCommand()); + + Mocker.GetMock() + .Verify(v => v.AddArtists(It.Is>(t => t.Count == 1 && t.First().Monitored == expectedArtistMonitored))); + } + + [Test] + public void should_not_add_if_excluded_artist() + { + WithArtistId(); + WithAlbum(); + WithAlbumId(); + WithExcludedArtist(); + + Subject.Execute(new ImportListSyncCommand()); + + Mocker.GetMock() + .Verify(v => v.AddArtists(It.Is>(t => t.Count == 0))); + } + + [Test] + public void should_mark_album_for_monitor_if_album_id_and_specific_monitor_selected() + { + WithArtistId(); + WithAlbum(); + WithAlbumId(); + WithMonitorType(ImportListMonitorType.SpecificAlbum); + + Subject.Execute(new ImportListSyncCommand()); + + Mocker.GetMock() + .Verify(v => v.AddArtists(It.Is>(t => t.Count == 1 && t.First().AddOptions.AlbumsToMonitor.Contains("09474d62-17dd-3a4f-98fb-04c65f38a479")))); + } + + [Test] + public void should_not_mark_album_for_monitor_if_album_id_and_monitor_all_selected() + { + WithArtistId(); + WithAlbum(); + WithAlbumId(); + WithMonitorType(ImportListMonitorType.EntireArtist); + + Subject.Execute(new ImportListSyncCommand()); + + Mocker.GetMock() + .Verify(v => v.AddArtists(It.Is>(t => t.Count == 1 && !t.First().AddOptions.AlbumsToMonitor.Any()))); + } + + [Test] + public void should_not_mark_album_for_monitor_if_no_album_id() + { + WithArtistId(); + + Subject.Execute(new ImportListSyncCommand()); + + Mocker.GetMock() + .Verify(v => v.AddArtists(It.Is>(t => t.Count == 1 && t.First().AddOptions.AlbumsToMonitor.Count == 0))); + } + } +} diff --git a/src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifyFollowedArtistsFixture.cs b/src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifyFollowedArtistsFixture.cs new file mode 100644 index 000000000..982df6e61 --- /dev/null +++ b/src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifyFollowedArtistsFixture.cs @@ -0,0 +1,173 @@ +using System.Collections.Generic; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.ImportLists.Spotify; +using NzbDrone.Core.Test.Framework; +using SpotifyAPI.Web; +using SpotifyAPI.Web.Models; + +namespace NzbDrone.Core.Test.ImportListTests +{ + [TestFixture] + public class SpotifyFollowedArtistsFixture : CoreTest + { + // placeholder, we don't use real API + private readonly SpotifyWebAPI api = null; + + [Test] + public void should_not_throw_if_followed_is_null() + { + Mocker.GetMock(). + Setup(x => x.GetFollowedArtists(It.IsAny(), + It.IsAny())) + .Returns(default(FollowedArtists)); + + var result = Subject.Fetch(api); + + result.Should().BeEmpty(); + } + + [Test] + public void should_not_throw_if_followed_artists_is_null() + { + var followed = new FollowedArtists { + Artists = null + }; + + Mocker.GetMock(). + Setup(x => x.GetFollowedArtists(It.IsAny(), + It.IsAny())) + .Returns(followed); + + var result = Subject.Fetch(api); + + result.Should().BeEmpty(); + } + + [Test] + public void should_not_throw_if_followed_artist_items_is_null() + { + var followed = new FollowedArtists { + Artists = new CursorPaging { + Items = null + } + }; + + Mocker.GetMock(). + Setup(x => x.GetFollowedArtists(It.IsAny(), + It.IsAny())) + .Returns(followed); + + var result = Subject.Fetch(api); + + result.Should().BeEmpty(); + Subject.Fetch(api); + } + + [Test] + public void should_not_throw_if_artist_is_null() + { + var followed = new FollowedArtists { + Artists = new CursorPaging { + Items = new List { + null + } + } + }; + + Mocker.GetMock(). + Setup(x => x.GetFollowedArtists(It.IsAny(), + It.IsAny())) + .Returns(followed); + + var result = Subject.Fetch(api); + + result.Should().BeEmpty(); + Subject.Fetch(api); + } + + [Test] + public void should_parse_followed_artist() + { + var followed = new FollowedArtists { + Artists = new CursorPaging { + Items = new List { + new FullArtist { + Name = "artist" + } + } + } + }; + + Mocker.GetMock(). + Setup(x => x.GetFollowedArtists(It.IsAny(), + It.IsAny())) + .Returns(followed); + + var result = Subject.Fetch(api); + + result.Should().HaveCount(1); + } + + [Test] + public void should_not_throw_if_get_next_page_returns_null() + { + var followed = new FollowedArtists { + Artists = new CursorPaging { + Items = new List { + new FullArtist { + Name = "artist" + } + }, + Next = "DummyToMakeHasNextTrue" + } + }; + + Mocker.GetMock(). + Setup(x => x.GetFollowedArtists(It.IsAny(), + It.IsAny())) + .Returns(followed); + + Mocker.GetMock() + .Setup(x => x.GetNextPage(It.IsAny(), + It.IsAny(), + It.IsAny>())) + .Returns(default(CursorPaging)); + + var result = Subject.Fetch(api); + + result.Should().HaveCount(1); + + Mocker.GetMock() + .Verify(v => v.GetNextPage(It.IsAny(), + It.IsAny(), + It.IsAny>()), + Times.Once()); + } + + [TestCase(null)] + [TestCase("")] + public void should_skip_bad_artist_names(string name) + { + var followed = new FollowedArtists { + Artists = new CursorPaging { + Items = new List { + new FullArtist { + Name = name + } + } + } + }; + + Mocker.GetMock(). + Setup(x => x.GetFollowedArtists(It.IsAny(), + It.IsAny())) + .Returns(followed); + + var result = Subject.Fetch(api); + + result.Should().BeEmpty(); + } + } +} diff --git a/src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifyPlaylistFixture.cs b/src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifyPlaylistFixture.cs new file mode 100644 index 000000000..6b82d43cb --- /dev/null +++ b/src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifyPlaylistFixture.cs @@ -0,0 +1,239 @@ +using System.Collections.Generic; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.ImportLists.Spotify; +using NzbDrone.Core.Test.Framework; +using SpotifyAPI.Web; +using SpotifyAPI.Web.Models; + +namespace NzbDrone.Core.Test.ImportListTests +{ + [TestFixture] + public class SpotifyPlaylistFixture : CoreTest + { + // placeholder, we don't use real API + private readonly SpotifyWebAPI api = null; + + [Test] + public void should_not_throw_if_playlist_tracks_is_null() + { + Mocker.GetMock(). + Setup(x => x.GetPlaylistTracks(It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(default(Paging)); + + var result = Subject.Fetch(api, "playlistid"); + + result.Should().BeEmpty(); + } + + [Test] + public void should_not_throw_if_playlist_tracks_items_is_null() + { + var playlistTracks = new Paging { + Items = null + }; + + Mocker.GetMock(). + Setup(x => x.GetPlaylistTracks(It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(playlistTracks); + + var result = Subject.Fetch(api, "playlistid"); + + result.Should().BeEmpty(); + } + + [Test] + public void should_not_throw_if_playlist_track_is_null() + { + var playlistTracks = new Paging { + Items = new List { + null + } + }; + + Mocker.GetMock(). + Setup(x => x.GetPlaylistTracks(It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(playlistTracks); + + var result = Subject.Fetch(api, "playlistid"); + + result.Should().BeEmpty(); + } + + [Test] + public void should_use_album_artist_when_it_exists() + { + var playlistTracks = new Paging { + Items = new List { + new PlaylistTrack { + Track = new FullTrack { + Album = new SimpleAlbum { + Name = "Album", + Artists = new List { + new SimpleArtist { + Name = "AlbumArtist" + } + } + }, + Artists = new List { + new SimpleArtist { + Name = "TrackArtist" + } + } + } + } + } + }; + + Mocker.GetMock(). + Setup(x => x.GetPlaylistTracks(It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(playlistTracks); + + var result = Subject.Fetch(api, "playlistid"); + + result.Should().HaveCount(1); + result[0].Artist.Should().Be("AlbumArtist"); + } + + [Test] + public void should_fall_back_to_track_artist_if_album_artist_missing() + { + var playlistTracks = new Paging { + Items = new List { + new PlaylistTrack { + Track = new FullTrack { + Album = new SimpleAlbum { + Name = "Album", + Artists = new List { + new SimpleArtist { + Name = null + } + } + }, + Artists = new List { + new SimpleArtist { + Name = "TrackArtist" + } + } + } + } + } + }; + + Mocker.GetMock(). + Setup(x => x.GetPlaylistTracks(It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(playlistTracks); + + var result = Subject.Fetch(api, "playlistid"); + + result.Should().HaveCount(1); + result[0].Artist.Should().Be("TrackArtist"); + } + + + [TestCase(null, null, "Album")] + [TestCase("AlbumArtist", null, null)] + [TestCase(null, "TrackArtist", null)] + public void should_skip_bad_artist_or_album_names(string albumArtistName, string trackArtistName, string albumName) + { + var playlistTracks = new Paging { + Items = new List { + new PlaylistTrack { + Track = new FullTrack { + Album = new SimpleAlbum { + Name = albumName, + Artists = new List { + new SimpleArtist { + Name = albumArtistName + } + } + }, + Artists = new List { + new SimpleArtist { + Name = trackArtistName + } + } + } + } + } + }; + + Mocker.GetMock(). + Setup(x => x.GetPlaylistTracks(It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(playlistTracks); + + var result = Subject.Fetch(api, "playlistid"); + + result.Should().BeEmpty(); + } + + [Test] + public void should_not_throw_if_get_next_page_returns_null() + { + var playlistTracks = new Paging { + Items = new List { + new PlaylistTrack { + Track = new FullTrack { + Album = new SimpleAlbum { + Name = "Album", + Artists = new List { + new SimpleArtist { + Name = null + } + } + }, + Artists = new List { + new SimpleArtist { + Name = "TrackArtist" + } + } + } + } + }, + Next = "DummyToMakeHasNextTrue" + }; + + Mocker.GetMock(). + Setup(x => x.GetPlaylistTracks(It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(playlistTracks); + + Mocker.GetMock() + .Setup(x => x.GetNextPage(It.IsAny(), + It.IsAny(), + It.IsAny>())) + .Returns(default(Paging)); + + var result = Subject.Fetch(api, "playlistid"); + + result.Should().HaveCount(1); + + Mocker.GetMock() + .Verify(x => x.GetNextPage(It.IsAny(), + It.IsAny(), + It.IsAny>()), + Times.Once()); + } + } +} diff --git a/src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifySavedAlbumsFixture.cs b/src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifySavedAlbumsFixture.cs new file mode 100644 index 000000000..1444b08bf --- /dev/null +++ b/src/NzbDrone.Core.Test/ImportListTests/Spotify/SpotifySavedAlbumsFixture.cs @@ -0,0 +1,166 @@ +using System.Collections.Generic; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.ImportLists.Spotify; +using NzbDrone.Core.Test.Framework; +using SpotifyAPI.Web; +using SpotifyAPI.Web.Models; + +namespace NzbDrone.Core.Test.ImportListTests +{ + [TestFixture] + public class SpotifySavedAlbumsFixture : CoreTest + { + // placeholder, we don't use real API + private readonly SpotifyWebAPI api = null; + + [Test] + public void should_not_throw_if_saved_albums_is_null() + { + Mocker.GetMock(). + Setup(x => x.GetSavedAlbums(It.IsAny(), + It.IsAny())) + .Returns(default(Paging)); + + var result = Subject.Fetch(api); + + result.Should().BeEmpty(); + } + + [Test] + public void should_not_throw_if_saved_album_items_is_null() + { + var savedAlbums = new Paging { + Items = null + }; + + Mocker.GetMock(). + Setup(x => x.GetSavedAlbums(It.IsAny(), + It.IsAny())) + .Returns(savedAlbums); + + var result = Subject.Fetch(api); + + result.Should().BeEmpty(); + } + + [Test] + public void should_not_throw_if_saved_album_is_null() + { + var savedAlbums = new Paging { + Items = new List { + null + } + }; + + Mocker.GetMock(). + Setup(x => x.GetSavedAlbums(It.IsAny(), + It.IsAny())) + .Returns(savedAlbums); + + var result = Subject.Fetch(api); + + result.Should().BeEmpty(); + } + + [TestCase("Artist", "Album")] + public void should_parse_saved_album(string artistName, string albumName) + { + var savedAlbums = new Paging { + Items = new List { + new SavedAlbum { + Album = new FullAlbum { + Name = albumName, + Artists = new List { + new SimpleArtist { + Name = artistName + } + } + } + } + } + }; + + Mocker.GetMock(). + Setup(x => x.GetSavedAlbums(It.IsAny(), + It.IsAny())) + .Returns(savedAlbums); + + var result = Subject.Fetch(api); + + result.Should().HaveCount(1); + } + + [Test] + public void should_not_throw_if_get_next_page_returns_null() + { + var savedAlbums = new Paging { + Items = new List { + new SavedAlbum { + Album = new FullAlbum { + Name = "Album", + Artists = new List { + new SimpleArtist { + Name = "Artist" + } + } + } + } + }, + Next = "DummyToMakeHasNextTrue" + }; + + Mocker.GetMock(). + Setup(x => x.GetSavedAlbums(It.IsAny(), + It.IsAny())) + .Returns(savedAlbums); + + Mocker.GetMock() + .Setup(x => x.GetNextPage(It.IsAny(), + It.IsAny(), + It.IsAny>())) + .Returns(default(Paging)); + + var result = Subject.Fetch(api); + + result.Should().HaveCount(1); + + Mocker.GetMock() + .Verify(x => x.GetNextPage(It.IsAny(), + It.IsAny(), + It.IsAny>()), + Times.Once()); + } + + [TestCase(null, "Album")] + [TestCase("Artist", null)] + [TestCase(null, null)] + public void should_skip_bad_artist_or_album_names(string artistName, string albumName) + { + var savedAlbums = new Paging { + Items = new List { + new SavedAlbum { + Album = new FullAlbum { + Name = albumName, + Artists = new List { + new SimpleArtist { + Name = artistName + } + } + } + } + } + }; + + Mocker.GetMock(). + Setup(x => x.GetSavedAlbums(It.IsAny(), + It.IsAny())) + .Returns(savedAlbums); + + var result = Subject.Fetch(api); + + result.Should().BeEmpty(); + } + } +} diff --git a/src/NzbDrone.Core.Test/IndexerSearchTests/ArtistSearchServiceFixture.cs b/src/NzbDrone.Core.Test/IndexerSearchTests/ArtistSearchServiceFixture.cs new file mode 100644 index 000000000..422ad17e3 --- /dev/null +++ b/src/NzbDrone.Core.Test/IndexerSearchTests/ArtistSearchServiceFixture.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; +using NzbDrone.Core.IndexerSearch; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Test.IndexerSearchTests +{ + [TestFixture] + public class ArtistSearchServiceFixture : CoreTest + { + private Artist _artist; + + [SetUp] + public void Setup() + { + _artist = new Artist(); + + Mocker.GetMock() + .Setup(s => s.GetArtist(It.IsAny())) + .Returns(_artist); + + Mocker.GetMock() + .Setup(s => s.ArtistSearch(_artist.Id, false, true, false)) + .Returns(new List()); + + Mocker.GetMock() + .Setup(s => s.ProcessDecisions(It.IsAny>())) + .Returns(new ProcessedDecisions(new List(), new List(), + new List())); + } + + [Test] + public void should_only_include_monitored_albums() + { + _artist.Albums = new List + { + new Album {Monitored = false}, + new Album {Monitored = true} + }; + + Subject.Execute(new ArtistSearchCommand {ArtistId = _artist.Id, Trigger = CommandTrigger.Manual}); + + Mocker.GetMock() + .Verify(v => v.ArtistSearch(_artist.Id, false, true, false), + Times.Exactly(_artist.Albums.Value.Count(s => s.Monitored))); + } + } +} diff --git a/src/NzbDrone.Core.Test/IndexerSearchTests/FetchAndParseRssServiceFixture.cs b/src/NzbDrone.Core.Test/IndexerSearchTests/FetchAndParseRssServiceFixture.cs deleted file mode 100644 index 7b96b7690..000000000 --- a/src/NzbDrone.Core.Test/IndexerSearchTests/FetchAndParseRssServiceFixture.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System.Collections.Generic; -using FizzWare.NBuilder; -using FluentAssertions; -using Moq; -using NUnit.Framework; -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.IndexerSearch; -using NzbDrone.Core.IndexerSearch.Definitions; -using NzbDrone.Core.Indexers; -using NzbDrone.Core.Indexers.Newznab; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; -using NzbDrone.Test.Common; - -namespace NzbDrone.Core.Test.IndexerSearchTests -{ - public class NzbSearchServiceFixture : CoreTest - { - private List _indexers; - - private Series _searchTargetSeries; - - [SetUp] - public void Setup() - { - - _searchTargetSeries = Builder.CreateNew().BuildNew(); - - _indexers = new List(); - - _indexers.Add(new Newznab()); - _indexers.Add(new Newznab()); - _indexers.Add(new Newznab()); - _indexers.Add(new Newznab()); - _indexers.Add(new Newznab()); - _indexers.Add(new Newznab()); - _indexers.Add(new Newznab()); - _indexers.Add(new Newznab()); - _indexers.Add(new Newznab()); - _indexers.Add(new Newznab()); - _indexers.Add(new Newznab()); - _indexers.Add(new Newznab()); - _indexers.Add(new Newznab()); - _indexers.Add(new Newznab()); - _indexers.Add(new Newznab()); - - Mocker.SetConstant>(_indexers); - - Mocker.GetMock().Setup(c => c.GetSeries(It.IsAny())) - .Returns(_searchTargetSeries); - - } - - [Test] - public void should_call_fetch_on_all_indexers_at_the_same_time() - { - - var counter = new ConcurrencyCounter(_indexers.Count); - - Mocker.GetMock().Setup(c => c.Fetch(It.IsAny(), It.IsAny())) - .Returns(new List()) - .Callback((() => counter.SimulateWork(500))); - - Mocker.GetMock().Setup(c => c.GetAvailableIndexers()).Returns(_indexers); - - Mocker.GetMock() - .Setup(c => c.GetSearchDecision(It.IsAny>(), It.IsAny())) - .Returns(new List()); - - Subject.SearchSingle(0, 0, 0); - - counter.WaitForAllItems(); - - counter.MaxThreads.Should().Be(_indexers.Count); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/IndexerSearchTests/NzbSearchServiceFixture.cs b/src/NzbDrone.Core.Test/IndexerSearchTests/NzbSearchServiceFixture.cs deleted file mode 100644 index 9b01ad829..000000000 --- a/src/NzbDrone.Core.Test/IndexerSearchTests/NzbSearchServiceFixture.cs +++ /dev/null @@ -1,265 +0,0 @@ -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.DataAugmentation.Scene; -using NzbDrone.Core.IndexerSearch; -using NzbDrone.Core.Test.Framework; -using FizzWare.NBuilder; -using System.Collections.Generic; -using System.Linq; -using FluentAssertions; -using Moq; -using NUnit.Framework; -using NzbDrone.Core.Tv; -using NzbDrone.Core.Indexers; -using NzbDrone.Core.IndexerSearch.Definitions; - -namespace NzbDrone.Core.Test.IndexerSearchTests -{ - public class NzbSearchServiceFixture : CoreTest - { - private Mock _mockIndexer; - private Series _xemSeries; - private List _xemEpisodes; - - [SetUp] - public void SetUp() - { - _mockIndexer = Mocker.GetMock(); - _mockIndexer.SetupGet(s => s.Definition).Returns(new IndexerDefinition { Id = 1 }); - _mockIndexer.SetupGet(s => s.SupportsSearch).Returns(true); - - Mocker.GetMock() - .Setup(s => s.SearchEnabled()) - .Returns(new List { _mockIndexer.Object }); - - Mocker.GetMock() - .Setup(s => s.GetSearchDecision(It.IsAny>(), It.IsAny())) - .Returns(new List()); - - _xemSeries = Builder.CreateNew() - .With(v => v.UseSceneNumbering = true) - .With(v => v.Monitored = true) - .Build(); - - _xemEpisodes = new List(); - - Mocker.GetMock() - .Setup(v => v.GetSeries(_xemSeries.Id)) - .Returns(_xemSeries); - - Mocker.GetMock() - .Setup(v => v.GetEpisodesBySeason(_xemSeries.Id, It.IsAny())) - .Returns((i, j) => _xemEpisodes.Where(d => d.SeasonNumber == j).ToList()); - - Mocker.GetMock() - .Setup(s => s.GetSceneNames(It.IsAny(), It.IsAny>(), It.IsAny>())) - .Returns(new List()); - } - - private void WithEpisode(int seasonNumber, int episodeNumber, int? sceneSeasonNumber, int? sceneEpisodeNumber) - { - var episode = Builder.CreateNew() - .With(v => v.SeriesId == _xemSeries.Id) - .With(v => v.Series == _xemSeries) - .With(v => v.SeasonNumber, seasonNumber) - .With(v => v.EpisodeNumber, episodeNumber) - .With(v => v.SceneSeasonNumber, sceneSeasonNumber) - .With(v => v.SceneEpisodeNumber, sceneEpisodeNumber) - .With(v => v.Monitored = true) - .Build(); - - _xemEpisodes.Add(episode); - } - - private void WithEpisodes() - { - // Season 1 maps to Scene Season 2 (one-to-one) - WithEpisode(1, 12, 2, 3); - WithEpisode(1, 13, 2, 4); - - // Season 2 maps to Scene Season 3 & 4 (one-to-one) - WithEpisode(2, 1, 3, 11); - WithEpisode(2, 2, 3, 12); - WithEpisode(2, 3, 4, 11); - WithEpisode(2, 4, 4, 12); - - // Season 3 maps to Scene Season 5 (partial) - // Season 4 maps to Scene Season 5 & 6 (partial) - WithEpisode(3, 1, 5, 11); - WithEpisode(3, 2, 5, 12); - WithEpisode(4, 1, 5, 13); - WithEpisode(4, 2, 5, 14); - WithEpisode(4, 3, 6, 11); - WithEpisode(5, 1, 6, 12); - - // Season 7+ maps normally, so no mapping specified. - WithEpisode(7, 1, null, null); - WithEpisode(7, 2, null, null); - } - - private List WatchForSearchCriteria() - { - var result = 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; - } - - [Test] - public void scene_episodesearch() - { - WithEpisodes(); - - var allCriteria = WatchForSearchCriteria(); - - Subject.EpisodeSearch(_xemEpisodes.First(), true); - - var criteria = allCriteria.OfType().ToList(); - - criteria.Count.Should().Be(1); - criteria[0].SeasonNumber.Should().Be(2); - criteria[0].EpisodeNumber.Should().Be(3); - } - - [Test] - public void scene_seasonsearch() - { - WithEpisodes(); - - var allCriteria = WatchForSearchCriteria(); - - Subject.SeasonSearch(_xemSeries.Id, 1, false, true); - - var criteria = allCriteria.OfType().ToList(); - - criteria.Count.Should().Be(1); - criteria[0].SeasonNumber.Should().Be(2); - } - - [Test] - public void scene_seasonsearch_should_search_multiple_seasons() - { - WithEpisodes(); - - var allCriteria = WatchForSearchCriteria(); - - Subject.SeasonSearch(_xemSeries.Id, 2, false, true); - - var criteria = allCriteria.OfType().ToList(); - - criteria.Count.Should().Be(2); - criteria[0].SeasonNumber.Should().Be(3); - criteria[1].SeasonNumber.Should().Be(4); - } - - [Test] - public void scene_seasonsearch_should_search_single_episode_if_possible() - { - WithEpisodes(); - - var allCriteria = WatchForSearchCriteria(); - - Subject.SeasonSearch(_xemSeries.Id, 4, false, true); - - var criteria1 = allCriteria.OfType().ToList(); - var criteria2 = allCriteria.OfType().ToList(); - - criteria1.Count.Should().Be(1); - criteria1[0].SeasonNumber.Should().Be(5); - - criteria2.Count.Should().Be(1); - criteria2[0].SeasonNumber.Should().Be(6); - criteria2[0].EpisodeNumber.Should().Be(11); - } - - [Test] - public void scene_seasonsearch_should_use_seasonnumber_if_no_scene_number_is_available() - { - WithEpisodes(); - - var allCriteria = WatchForSearchCriteria(); - - Subject.SeasonSearch(_xemSeries.Id, 7, false, true); - - var criteria = allCriteria.OfType().ToList(); - - criteria.Count.Should().Be(1); - criteria[0].SeasonNumber.Should().Be(7); - } - - [Test] - public void season_search_for_anime_should_search_for_each_monitored_episode() - { - WithEpisodes(); - _xemSeries.SeriesType = SeriesTypes.Anime; - _xemEpisodes.ForEach(e => e.EpisodeFileId = 0); - - var seasonNumber = 1; - var allCriteria = WatchForSearchCriteria(); - - Subject.SeasonSearch(_xemSeries.Id, seasonNumber, true, true); - - var criteria = allCriteria.OfType().ToList(); - - criteria.Count.Should().Be(_xemEpisodes.Count(e => e.SeasonNumber == seasonNumber)); - } - - [Test] - public void season_search_for_anime_should_not_search_for_unmonitored_episodes() - { - WithEpisodes(); - _xemSeries.SeriesType = SeriesTypes.Anime; - _xemEpisodes.ForEach(e => e.Monitored = false); - _xemEpisodes.ForEach(e => e.EpisodeFileId = 0); - - var seasonNumber = 1; - var allCriteria = WatchForSearchCriteria(); - - Subject.SeasonSearch(_xemSeries.Id, seasonNumber, false, true); - - var criteria = allCriteria.OfType().ToList(); - - criteria.Count.Should().Be(0); - } - - [Test] - public void season_search_for_anime_should_not_search_for_episodes_with_files() - { - WithEpisodes(); - _xemSeries.SeriesType = SeriesTypes.Anime; - _xemEpisodes.ForEach(e => e.EpisodeFileId = 1); - - var seasonNumber = 1; - var allCriteria = WatchForSearchCriteria(); - - Subject.SeasonSearch(_xemSeries.Id, seasonNumber, true, true); - - var criteria = allCriteria.OfType().ToList(); - - criteria.Count.Should().Be(0); - } - - [Test] - public void getscenenames_should_use_seasonnumber_if_no_scene_seasonnumber_is_available() - { - WithEpisodes(); - - var allCriteria = WatchForSearchCriteria(); - - Subject.SeasonSearch(_xemSeries.Id, 7, false, true); - - Mocker.GetMock() - .Verify(v => v.GetSceneNames(_xemSeries.Id, It.Is>(l => l.Contains(7)), It.Is>(l => l.Contains(7))), Times.Once()); - } - } -} diff --git a/src/NzbDrone.Core.Test/IndexerSearchTests/SearchDefinitionFixture.cs b/src/NzbDrone.Core.Test/IndexerSearchTests/SearchDefinitionFixture.cs index 02c4db4bb..d2b04848f 100644 --- a/src/NzbDrone.Core.Test/IndexerSearchTests/SearchDefinitionFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerSearchTests/SearchDefinitionFixture.cs @@ -3,22 +3,38 @@ using System.Linq; using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Music; using NzbDrone.Core.Test.Framework; namespace NzbDrone.Core.Test.IndexerSearchTests -{ - public class SearchDefinitionFixture : CoreTest +{ + public class AlbumSearchDefinitionFixture : CoreTest { - [TestCase("Betty White's Off Their Rockers", "Betty+Whites+Off+Their+Rockers")] - [TestCase("Star Wars: The Clone Wars", "Star+Wars+The+Clone+Wars")] - [TestCase("Hawaii Five-0", "Hawaii+Five+0")] - [TestCase("Franklin & Bash", "Franklin+and+Bash")] - [TestCase("Chicago P.D.", "Chicago+PD")] - [TestCase("Kourtney And Khlo\u00E9 Take The Hamptons", "Kourtney+And+Khloe+Take+The+Hamptons")] - public void should_replace_some_special_characters(string input, string expected) + [TestCase("Mötley Crüe", "Motley+Crue")] + [TestCase("방탄소년단", "방탄소년단")] + public void should_replace_some_special_characters_artist(string artist, string expected) { - Subject.SceneTitles = new List { input }; - Subject.QueryTitles.First().Should().Be(expected); + Subject.Artist = new Artist { Name = artist }; + Subject.ArtistQuery.Should().Be(expected); + } + + [TestCase("…and Justice for All", "and+Justice+for+All")] + [TestCase("American III: Solitary Man", "American+III+Solitary+Man")] + [TestCase("Sad Clowns & Hillbillies", "Sad+Clowns+Hillbillies")] + [TestCase("¿Quién sabe?", "Quien+sabe")] + [TestCase("Seal the Deal & Let’s Boogie", "Seal+the+Deal+Lets+Boogie")] + [TestCase("Section.80", "Section80")] + public void should_replace_some_special_characters(string album, string expected) + { + Subject.AlbumTitle = album; + Subject.AlbumQuery.Should().Be(expected); + } + + [TestCase("+", "+")] + public void should_not_replace_some_special_characters_if_result_empty_string(string album, string expected) + { + Subject.AlbumTitle = album; + Subject.AlbumQuery.Should().Be(expected); } } } diff --git a/src/NzbDrone.Core.Test/IndexerSearchTests/SeriesSearchServiceFixture.cs b/src/NzbDrone.Core.Test/IndexerSearchTests/SeriesSearchServiceFixture.cs deleted file mode 100644 index 906a9f071..000000000 --- a/src/NzbDrone.Core.Test/IndexerSearchTests/SeriesSearchServiceFixture.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using FluentAssertions; -using Moq; -using NUnit.Framework; -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Download; -using NzbDrone.Core.IndexerSearch; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; -using NzbDrone.Core.Messaging.Commands; - -namespace NzbDrone.Core.Test.IndexerSearchTests -{ - [TestFixture] - public class SeriesSearchServiceFixture : CoreTest - { - private Series _series; - - [SetUp] - public void Setup() - { - _series = new Series - { - Id = 1, - Title = "Title", - Seasons = new List() - }; - - Mocker.GetMock() - .Setup(s => s.GetSeries(It.IsAny())) - .Returns(_series); - - Mocker.GetMock() - .Setup(s => s.SeasonSearch(_series.Id, It.IsAny(), false, true)) - .Returns(new List()); - - Mocker.GetMock() - .Setup(s => s.ProcessDecisions(It.IsAny>())) - .Returns(new ProcessedDecisions(new List(), new List(), new List())); - } - - [Test] - public void should_only_include_monitored_seasons() - { - _series.Seasons = new List - { - new Season { SeasonNumber = 0, Monitored = false }, - new Season { SeasonNumber = 1, Monitored = true } - }; - - Subject.Execute(new SeriesSearchCommand { SeriesId = _series.Id, Trigger = CommandTrigger.Manual }); - - Mocker.GetMock() - .Verify(v => v.SeasonSearch(_series.Id, It.IsAny(), false, true), Times.Exactly(_series.Seasons.Count(s => s.Monitored))); - } - - [Test] - public void should_start_with_lower_seasons_first() - { - var seasonOrder = new List(); - - _series.Seasons = new List - { - new Season { SeasonNumber = 3, Monitored = true }, - new Season { SeasonNumber = 1, Monitored = true }, - new Season { SeasonNumber = 2, Monitored = true } - }; - - Mocker.GetMock() - .Setup(s => s.SeasonSearch(_series.Id, It.IsAny(), false, true)) - .Returns(new List()) - .Callback((seriesId, seasonNumber, missingOnly, userInvokedSearch) => seasonOrder.Add(seasonNumber)); - - Subject.Execute(new SeriesSearchCommand { SeriesId = _series.Id, Trigger = CommandTrigger.Manual }); - - seasonOrder.First().Should().Be(_series.Seasons.OrderBy(s => s.SeasonNumber).First().SeasonNumber); - } - } -} diff --git a/src/NzbDrone.Core.Test/IndexerTests/BasicRssParserFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/BasicRssParserFixture.cs index b2819434d..d75079fbd 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/BasicRssParserFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/BasicRssParserFixture.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System.Linq; using System.Text; using FluentAssertions; using NUnit.Framework; @@ -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); @@ -58,4 +59,4 @@ namespace NzbDrone.Core.Test.IndexerTests result.First().DownloadUrl.Should().Be("http://my.indexer.com/getnzb/123.nzb&i=782&r=123"); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core.Test/IndexerTests/BitMeTvTests/BitMeTvFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/BitMeTvTests/BitMeTvFixture.cs deleted file mode 100644 index d49d940a4..000000000 --- a/src/NzbDrone.Core.Test/IndexerTests/BitMeTvTests/BitMeTvFixture.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Moq; -using NUnit.Framework; -using NzbDrone.Common.Http; -using NzbDrone.Core.Indexers; -using NzbDrone.Core.Indexers.BitMeTv; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Test.Framework; -using System; -using System.Linq; -using FluentAssertions; - -namespace NzbDrone.Core.Test.IndexerTests.BitMeTvTests -{ - [TestFixture] - public class BitMeTvFixture : CoreTest - { - [SetUp] - public void Setup() - { - Subject.Definition = new IndexerDefinition() - { - Name = "BitMeTV", - Settings = new BitMeTvSettings() { Cookie = "uid=123" } - }; - } - - [Test] - public void should_parse_recent_feed_from_BitMeTv() - { - var recentFeed = ReadAllText(@"Files/Indexers/BitMeTv/BitMeTv.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(5); - releases.First().Should().BeOfType(); - - var torrentInfo = releases.First() as TorrentInfo; - - torrentInfo.Title.Should().Be("Total.Divas.S02E08.HDTV.x264-CRiMSON"); - torrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); - torrentInfo.DownloadUrl.Should().Be("http://www.bitmetv.org/download.php/12/Total.Divas.S02E08.HDTV.x264-CRiMSON.torrent"); - torrentInfo.InfoUrl.Should().BeNullOrEmpty(); - torrentInfo.CommentUrl.Should().BeNullOrEmpty(); - torrentInfo.Indexer.Should().Be(Subject.Definition.Name); - torrentInfo.PublishDate.Should().Be(DateTime.Parse("2014/05/13 17:04:29")); - torrentInfo.Size.Should().Be(395009065); - torrentInfo.InfoHash.Should().Be(null); - torrentInfo.MagnetUrl.Should().Be(null); - torrentInfo.Peers.Should().Be(null); - torrentInfo.Seeders.Should().Be(null); - } - } -} diff --git a/src/NzbDrone.Core.Test/IndexerTests/BroadcastheNetTests/BroadcastheNetFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/BroadcastheNetTests/BroadcastheNetFixture.cs deleted file mode 100644 index e1b43d988..000000000 --- a/src/NzbDrone.Core.Test/IndexerTests/BroadcastheNetTests/BroadcastheNetFixture.cs +++ /dev/null @@ -1,161 +0,0 @@ -using Moq; -using NUnit.Framework; -using NzbDrone.Common.Http; -using NzbDrone.Core.Indexers; -using NzbDrone.Core.Indexers.BroadcastheNet; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Test.Common; -using System; -using System.Linq; -using FluentAssertions; - -namespace NzbDrone.Core.Test.IndexerTests.BroadcastheNetTests -{ - [TestFixture] - public class BroadcastheNetFixture : CoreTest - { - [SetUp] - public void Setup() - { - Subject.Definition = new IndexerDefinition() - { - Name = "BroadcastheNet", - Settings = new BroadcastheNetSettings() { ApiKey = "abc", BaseUrl = "https://api.broadcasthe.net/" } - }; - } - - [Test] - public void should_parse_recent_feed_from_BroadcastheNet() - { - var recentFeed = ReadAllText(@"Files/Indexers/BroadcastheNet/RecentFeed.json"); - - Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.POST))) - .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); - - var releases = Subject.FetchRecent(); - - releases.Should().HaveCount(2); - releases.First().Should().BeOfType(); - - var torrentInfo = releases.First() as TorrentInfo; - - torrentInfo.Guid.Should().Be("BTN-123"); - torrentInfo.Title.Should().Be("Jimmy.Kimmel.2014.09.15.Jane.Fonda.HDTV.x264-aAF"); - torrentInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); - torrentInfo.DownloadUrl.Should().Be("https://broadcasthe.net/torrents.php?action=download&id=123&authkey=123&torrent_pass=123"); - torrentInfo.InfoUrl.Should().Be("https://broadcasthe.net/torrents.php?id=237457&torrentid=123"); - torrentInfo.CommentUrl.Should().BeNullOrEmpty(); - torrentInfo.Indexer.Should().Be(Subject.Definition.Name); - torrentInfo.PublishDate.Should().Be(DateTime.Parse("2014/09/16 21:15:33")); - torrentInfo.Size.Should().Be(505099926); - torrentInfo.InfoHash.Should().Be("123"); - torrentInfo.TvdbId.Should().Be(71998); - torrentInfo.TvRageId.Should().Be(4055); - torrentInfo.MagnetUrl.Should().BeNullOrEmpty(); - torrentInfo.Peers.Should().Be(40+9); - torrentInfo.Seeders.Should().Be(40); - - torrentInfo.Origin.Should().Be("Scene"); - torrentInfo.Source.Should().Be("HDTV"); - torrentInfo.Container.Should().Be("MP4"); - torrentInfo.Codec.Should().Be("x264"); - torrentInfo.Resolution.Should().Be("SD"); - } - - private void VerifyBackOff() - { - Mocker.GetMock() - .Verify(v => v.RecordFailure(It.IsAny(), It.IsAny()), Times.Once()); - } - - [Test] - public void should_back_off_on_bad_request() - { - Mocker.GetMock() - .Setup(v => v.Execute(It.IsAny())) - .Returns(r => new HttpResponse(r, new HttpHeader(), new byte[0], System.Net.HttpStatusCode.BadRequest)); - - var results = Subject.FetchRecent(); - - results.Should().BeEmpty(); - - VerifyBackOff(); - - ExceptionVerification.ExpectedWarns(1); - } - - [Test] - public void should_back_off_and_report_api_key_invalid() - { - Mocker.GetMock() - .Setup(v => v.Execute(It.IsAny())) - .Returns(r => new HttpResponse(r, new HttpHeader(), new byte[0], System.Net.HttpStatusCode.Unauthorized)); - - var results = Subject.FetchRecent(); - - results.Should().BeEmpty(); - - VerifyBackOff(); - - ExceptionVerification.ExpectedWarns(1); - } - - [Test] - public void should_back_off_on_unknown_method() - { - Mocker.GetMock() - .Setup(v => v.Execute(It.IsAny())) - .Returns(r => new HttpResponse(r, new HttpHeader(), new byte[0], System.Net.HttpStatusCode.NotFound)); - - var results = Subject.FetchRecent(); - - results.Should().BeEmpty(); - - VerifyBackOff(); - - ExceptionVerification.ExpectedWarns(1); - } - - [Test] - public void should_back_off_api_limit_reached() - { - Mocker.GetMock() - .Setup(v => v.Execute(It.IsAny())) - .Returns(r => new HttpResponse(r, new HttpHeader(), new byte[0], System.Net.HttpStatusCode.ServiceUnavailable)); - - var results = Subject.FetchRecent(); - - results.Should().BeEmpty(); - - VerifyBackOff(); - - ExceptionVerification.ExpectedWarns(1); - } - - [Test] - public void should_replace_https_http_as_needed() - { - var recentFeed = ReadAllText(@"Files/Indexers/BroadcastheNet/RecentFeed.json"); - - (Subject.Definition.Settings as BroadcastheNetSettings).BaseUrl = "http://api.broadcasthe.net/"; - - recentFeed = recentFeed.Replace("http:", "https:"); - - Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.POST))) - .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); - - var releases = Subject.FetchRecent(); - - releases.Should().HaveCount(2); - releases.First().Should().BeOfType(); - - var torrentInfo = releases.First() as TorrentInfo; - - torrentInfo.DownloadUrl.Should().Be("http://broadcasthe.net/torrents.php?action=download&id=123&authkey=123&torrent_pass=123"); - torrentInfo.InfoUrl.Should().Be("http://broadcasthe.net/torrents.php?id=237457&torrentid=123"); - } - } -} diff --git a/src/NzbDrone.Core.Test/IndexerTests/FanzubTests/FanzubFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/FanzubTests/FanzubFixture.cs deleted file mode 100644 index ed8587e38..000000000 --- a/src/NzbDrone.Core.Test/IndexerTests/FanzubTests/FanzubFixture.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using System.Linq; -using FluentAssertions; -using Moq; -using NUnit.Framework; -using NzbDrone.Common.Http; -using NzbDrone.Core.Indexers; -using NzbDrone.Core.Indexers.Fanzub; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.IndexerTests.FanzubTests -{ - [TestFixture] - public class FanzubFixture : CoreTest - { - [SetUp] - public void Setup() - { - Subject.Definition = new IndexerDefinition() - { - Name = "Fanzub", - Settings = new FanzubSettings() - }; - } - - [Test] - public void should_parse_recent_feed_from_fanzub() - { - var recentFeed = ReadAllText(@"Files/Indexers/Fanzub/fanzub.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(3); - - var releaseInfo = releases.First(); - - releaseInfo.Title.Should().Be("[Vivid] Hanayamata - 10 [A33D6606]"); - releaseInfo.DownloadProtocol.Should().Be(DownloadProtocol.Usenet); - releaseInfo.DownloadUrl.Should().Be("http://fanzub.com/nzb/296464/Vivid%20Hanayamata%20-%2010.nzb"); - releaseInfo.InfoUrl.Should().BeNullOrEmpty(); - releaseInfo.CommentUrl.Should().BeNullOrEmpty(); - releaseInfo.Indexer.Should().Be(Subject.Definition.Name); - releaseInfo.PublishDate.Should().Be(DateTime.Parse("2014/09/13 12:56:53")); - releaseInfo.Size.Should().Be(556246858); - } - } -} diff --git a/src/NzbDrone.Core.Test/IndexerTests/FetchAndParseRssServiceFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/FetchAndParseRssServiceFixture.cs deleted file mode 100644 index 6dd685d9e..000000000 --- a/src/NzbDrone.Core.Test/IndexerTests/FetchAndParseRssServiceFixture.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using FluentAssertions; -using Moq; -using NUnit.Framework; -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 -{ - public class FetchAndParseRssServiceFixture : CoreTest - { - private List _indexers; - - [SetUp] - public void Setup() - { - _indexers = new List(); - - _indexers.Add(new Newznab()); - _indexers.Add(new Newznab()); - _indexers.Add(new Newznab()); - _indexers.Add(new Newznab()); - _indexers.Add(new Newznab()); - _indexers.Add(new Newznab()); - _indexers.Add(new Newznab()); - _indexers.Add(new Newznab()); - _indexers.Add(new Newznab()); - _indexers.Add(new Newznab()); - _indexers.Add(new Newznab()); - _indexers.Add(new Newznab()); - _indexers.Add(new Newznab()); - _indexers.Add(new Newznab()); - _indexers.Add(new Newznab()); - - Mocker.SetConstant>(_indexers); - - } - - [Test] - [Explicit] - public void should_call_fetch_on_all_indexers_at_the_same_time() - { - - var counter = new ConcurrencyCounter(_indexers.Count); - - Mocker.GetMock().Setup(c => c.FetchRss(It.IsAny())) - .Returns(new List()) - .Callback((() => counter.SimulateWork(500))); - - Mocker.GetMock().Setup(c => c.GetAvailableIndexers()).Returns(_indexers); - - Subject.Fetch(); - - counter.WaitForAllItems(); - - counter.MaxThreads.Should().Be(_indexers.Count); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/IndexerTests/GazelleTests/GazelleFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/GazelleTests/GazelleFixture.cs new file mode 100644 index 000000000..7e8c11e90 --- /dev/null +++ b/src/NzbDrone.Core.Test/IndexerTests/GazelleTests/GazelleFixture.cs @@ -0,0 +1,66 @@ +using System; +using System.Linq; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Http; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Indexers.Gazelle; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.IndexerTests.GazelleTests +{ + [TestFixture] + public class GazelleFixture : CoreTest + { + [SetUp] + public void Setup() + { + Subject.Definition = new IndexerDefinition() + { + Name = "Gazelle", + Settings = new GazelleSettings + { + Username = "user", + Password = "pass", + BaseUrl = "http://someurl.ch" + } + }; + } + + [Test] + public void should_parse_recent_feed_from_gazelle() + { + var recentFeed = ReadAllText(@"Files/Indexers/Gazelle/Gazelle.json"); + var indexFeed = ReadAllText(@"Files/Indexers/Gazelle/GazelleIndex.json"); + + Mocker.GetMock() + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET && v.Url.FullUri.Contains("ajax.php?action=browse")))) + .Returns(r => new HttpResponse(r, new HttpHeader{ContentType = "application/json" }, recentFeed)); + + Mocker.GetMock() + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.POST && v.Url.FullUri.Contains("ajax.php?action=index")))) + .Returns(r => new HttpResponse(r, new HttpHeader(), indexFeed)); + + Mocker.GetMock() + .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.POST && v.Url.FullUri.Contains("login.php")))) + .Returns(r => new HttpResponse(r, new HttpHeader(), indexFeed)); + + var releases = Subject.FetchRecent(); + + releases.Should().HaveCount(4); + + var releaseInfo = releases.First(); + + releaseInfo.Title.Should().Be("Shania Twain - Shania Twain (1993) [FLAC 24bit Lossless]"); + releaseInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); + releaseInfo.DownloadUrl.Should() + .Be("http://someurl.ch/torrents.php?action=download&id=1541452&authkey=redacted&torrent_pass=redacted"); + releaseInfo.InfoUrl.Should().Be("http://someurl.ch/torrents.php?id=106951&torrentid=1541452"); + releaseInfo.CommentUrl.Should().Be(null); + releaseInfo.Indexer.Should().Be(Subject.Definition.Name); + releaseInfo.PublishDate.Should().Be(DateTime.Parse("2017-12-11 00:17:53")); + releaseInfo.Size.Should().Be(653734702); + } + } +} diff --git a/src/NzbDrone.Core.Test/IndexerTests/HDBitsTests/HDBitsFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/HDBitsTests/HDBitsFixture.cs deleted file mode 100644 index 1edc5631d..000000000 --- a/src/NzbDrone.Core.Test/IndexerTests/HDBitsTests/HDBitsFixture.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System; -using System.Linq; -using System.Text; -using FluentAssertions; -using Moq; -using NUnit.Framework; -using NzbDrone.Common.Http; -using NzbDrone.Common.Serializer; -using NzbDrone.Core.Indexers; -using NzbDrone.Core.Indexers.HDBits; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Test.Common; - -namespace NzbDrone.Core.Test.IndexerTests.HDBitsTests -{ - [TestFixture] - public class HDBitsFixture : CoreTest - { - [SetUp] - public void Setup() - { - Subject.Definition = new IndexerDefinition() - { - Name = "HdBits", - Settings = new HDBitsSettings() { ApiKey = "fakekey" } - }; - } - - [TestCase("Files/Indexers/HdBits/RecentFeedLongIDs.json")] - [TestCase("Files/Indexers/HdBits/RecentFeedStringIDs.json")] - public void should_parse_recent_feed_from_HDBits(string fileName) - { - var responseJson = ReadAllText(fileName); - - Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.POST))) - .Returns(r => new HttpResponse(r, new HttpHeader(), responseJson)); - - var torrents = Subject.FetchRecent(); - - torrents.Should().HaveCount(2); - torrents.First().Should().BeOfType(); - - var first = torrents.First() as TorrentInfo; - - first.Guid.Should().Be("HDBits-257142"); - first.Title.Should().Be("Supernatural S10E17 1080p WEB-DL DD5.1 H.264-ECI"); - first.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); - first.DownloadUrl.Should().Be("https://hdbits.org/download.php?id=257142&passkey=fakekey"); - first.InfoUrl.Should().Be("https://hdbits.org/details.php?id=257142"); - first.PublishDate.Should().Be(DateTime.Parse("2015-04-04T20:30:46+0000").ToUniversalTime()); - first.Size.Should().Be(1718009717); - first.InfoHash.Should().Be("EABC50AEF9F53CEDED84ADF14144D3368E586F3A"); - first.MagnetUrl.Should().BeNullOrEmpty(); - first.Peers.Should().Be(47); - first.Seeders.Should().Be(46); - } - - [Test] - public void should_warn_on_wrong_passkey() - { - var responseJson = new { status = 5, message = "Invalid authentication credentials" }.ToJson(); - - Mocker.GetMock() - .Setup(v => v.Execute(It.IsAny())) - .Returns(r => new HttpResponse(r, new HttpHeader(), - Encoding.UTF8.GetBytes(responseJson))); - - var torrents = Subject.FetchRecent(); - - torrents.Should().BeEmpty(); - - ExceptionVerification.ExpectedWarns(1); - } - } -} diff --git a/src/NzbDrone.Core.Test/IndexerTests/HeadphonesTests/HeadphonesCapabilitiesProviderFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/HeadphonesTests/HeadphonesCapabilitiesProviderFixture.cs new file mode 100644 index 000000000..ec9567d34 --- /dev/null +++ b/src/NzbDrone.Core.Test/IndexerTests/HeadphonesTests/HeadphonesCapabilitiesProviderFixture.cs @@ -0,0 +1,98 @@ +using System; +using System.Xml; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Http; +using NzbDrone.Core.Indexers.Headphones; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.IndexerTests.HeadphonesTests +{ + [TestFixture] + public class HeadphonesCapabilitiesProviderFixture : CoreTest + { + private HeadphonesSettings _settings; + private string _caps; + + [SetUp] + public void SetUp() + { + _settings = new HeadphonesSettings(); + + _caps = ReadAllText("Files/Indexers/Newznab/newznab_caps.xml"); + } + + private void GivenCapsResponse(string caps) + { + Mocker.GetMock() + .Setup(o => o.Get(It.IsAny())) + .Returns(r => new HttpResponse(r, new HttpHeader(), caps)); + } + + [Test] + public void should_not_request_same_caps_twice() + { + GivenCapsResponse(_caps); + + Subject.GetCapabilities(_settings); + Subject.GetCapabilities(_settings); + + Mocker.GetMock() + .Verify(o => o.Get(It.IsAny()), Times.Once()); + } + + [Test] + public void should_report_pagesize() + { + GivenCapsResponse(_caps); + + var caps = Subject.GetCapabilities(_settings); + + caps.DefaultPageSize.Should().Be(25); + caps.MaxPageSize.Should().Be(60); + } + + [Test] + public void should_use_default_pagesize_if_missing() + { + GivenCapsResponse(_caps.Replace("() + .Setup(o => o.Get(It.IsAny())) + .Throws(); + + Assert.Throws(() => Subject.GetCapabilities(_settings)); + } + + [Test] + public void should_throw_if_xml_invalid() + { + GivenCapsResponse(_caps.Replace("")); + + Assert.Throws(() => Subject.GetCapabilities(_settings)); + } + + [Test] + public void should_not_throw_on_xml_data_unexpected() + { + GivenCapsResponse(_caps.Replace("3040", "asdf")); + + var result = Subject.GetCapabilities(_settings); + + result.Should().NotBeNull(); + + ExceptionVerification.ExpectedErrors(1); + } + } +} diff --git a/src/NzbDrone.Core.Test/IndexerTests/HeadphonesTests/HeadphonesFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/HeadphonesTests/HeadphonesFixture.cs new file mode 100644 index 000000000..f1b30ad42 --- /dev/null +++ b/src/NzbDrone.Core.Test/IndexerTests/HeadphonesTests/HeadphonesFixture.cs @@ -0,0 +1,73 @@ +using System; +using System.Linq; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Http; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Indexers.Headphones; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.IndexerTests.HeadphonesTests +{ + [TestFixture] + public class HeadphonesFixture : CoreTest + { + private HeadphonesCapabilities _caps; + + [SetUp] + public void Setup() + { + Subject.Definition = new IndexerDefinition() + { + Name = "Headphones VIP", + Settings = new HeadphonesSettings() + { + Categories = new int[] { 3000 }, + Username = "user", + Password = "pass" + } + }; + + _caps = new HeadphonesCapabilities(); + Mocker.GetMock() + .Setup(v => v.GetCapabilities(It.IsAny())) + .Returns(_caps); + } + + [Test] + public void should_parse_recent_feed_from_headphones() + { + var recentFeed = ReadAllText(@"Files/Indexers/Headphones/Headphones.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(16); + + releases.First().Should().BeOfType(); + var releaseInfo = releases.First() as ReleaseInfo; + + releaseInfo.Title.Should().Be("Lady Gaga Born This Way 2CD FLAC 2011 WRE"); + releaseInfo.DownloadProtocol.Should().Be(DownloadProtocol.Usenet); + releaseInfo.DownloadUrl.Should().Be("https://indexer.codeshy.com/api?t=g&guid=123456&apikey=123456789"); + releaseInfo.BasicAuthString.Should().Be("dXNlcjpwYXNz"); + releaseInfo.Indexer.Should().Be(Subject.Definition.Name); + releaseInfo.PublishDate.Should().Be(DateTime.Parse("2013/06/02 08:58:54")); + releaseInfo.Size.Should().Be(917347414); + } + + [Test] + public void should_use_pagesize_reported_by_caps() + { + _caps.MaxPageSize = 30; + _caps.DefaultPageSize = 25; + + Subject.PageSize.Should().Be(25); + } + } +} diff --git a/src/NzbDrone.Core.Test/IndexerTests/IPTorrentsTests/IPTorrentsFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/IPTorrentsTests/IPTorrentsFixture.cs index d48c06f6c..08204e6a7 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/IPTorrentsTests/IPTorrentsFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/IPTorrentsTests/IPTorrentsFixture.cs @@ -1,4 +1,4 @@ -using Moq; +using Moq; using NUnit.Framework; using NzbDrone.Common.Http; using NzbDrone.Core.Indexers; @@ -20,10 +20,68 @@ 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/" } }; } + private void GivenOldFeedFormat() + { + Subject.Definition = new IndexerDefinition() + { + Name = "IPTorrents", + Settings = new IPTorrentsSettings() { BaseUrl = "https://iptorrents.com/torrents/rss?u=snip;tp=snip;3;80;93;37;download" } + }; + } + + private void GivenNewFeedFormat() + { + Subject.Definition = new IndexerDefinition() + { + Name = "IPTorrents", + Settings = new IPTorrentsSettings() { BaseUrl = "https://iptorrents.com/t.rss?u=USERID;tp=APIKEY;3;80;93;37;download" } + }; + } + + private void GivenFeedNoDownloadFormat() + { + Subject.Definition = new IndexerDefinition() + { + Name = "IPTorrents", + Settings = new IPTorrentsSettings() { BaseUrl = "https://iptorrents.com/t.rss?u=USERID;tp=APIKEY;3;80;93;37" } + }; + } + + [Test] + public void should_validate_old_feed_format() + { + GivenOldFeedFormat(); + var validationResult = Subject.Definition.Settings.Validate(); + validationResult.IsValid.Should().BeTrue(); + } + + [Test] + public void should_validate_new_feed_format() + { + GivenNewFeedFormat(); + var validationResult = Subject.Definition.Settings.Validate(); + validationResult.IsValid.Should().BeTrue(); + } + + [Test] + public void should_not_validate_bad_format() + { + var validationResult = Subject.Definition.Settings.Validate(); + validationResult.IsValid.Should().BeFalse(); + } + + [Test] + public void should_not_validate_no_download_format() + { + GivenFeedNoDownloadFormat(); + var validationResult = Subject.Definition.Settings.Validate(); + validationResult.IsValid.Should().BeFalse(); + } + [Test] public void should_parse_recent_feed_from_IPTorrents() { diff --git a/src/NzbDrone.Core.Test/IndexerTests/IndexerStatusServiceFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/IndexerStatusServiceFixture.cs index d7bee11f2..de561469a 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/IndexerStatusServiceFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/IndexerStatusServiceFixture.cs @@ -1,8 +1,9 @@ -using System; +using System; using System.Linq; using FluentAssertions; using Moq; using NUnit.Framework; +using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Indexers; using NzbDrone.Core.Test.Framework; @@ -11,17 +12,21 @@ namespace NzbDrone.Core.Test.IndexerTests public class IndexerStatusServiceFixture : CoreTest { private DateTime _epoch; - + [SetUp] public void SetUp() { _epoch = DateTime.UtcNow; + + Mocker.GetMock() + .SetupGet(v => v.StartTime) + .Returns(_epoch - TimeSpan.FromHours(1)); } private void WithStatus(IndexerStatus status) { Mocker.GetMock() - .Setup(v => v.FindByIndexerId(1)) + .Setup(v => v.FindByProviderId(1)) .Returns(status); Mocker.GetMock() @@ -29,25 +34,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 +55,7 @@ namespace NzbDrone.Core.Test.IndexerTests VerifyUpdate(); - var status = Subject.GetBlockedIndexers().FirstOrDefault(); + var status = Subject.GetBlockedProviders().FirstOrDefault(); status.Should().BeNull(); } @@ -70,22 +66,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/IntegrationTests/IndexerIntegrationTests.cs b/src/NzbDrone.Core.Test/IndexerTests/IntegrationTests/IndexerIntegrationTests.cs index a72bb8f57..9504a4d99 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/IntegrationTests/IndexerIntegrationTests.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/IntegrationTests/IndexerIntegrationTests.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using FluentAssertions; using NUnit.Framework; @@ -16,62 +16,18 @@ namespace NzbDrone.Core.Test.IndexerTests.IntegrationTests [IntegrationTest] public class IndexerIntegrationTests : CoreTest { - private SingleEpisodeSearchCriteria _singleSearchCriteria; - private AnimeEpisodeSearchCriteria _animeSearchCriteria; + private AlbumSearchCriteria _albumSearchCriteria; [SetUp] public void SetUp() { UseRealHttp(); - _singleSearchCriteria = new SingleEpisodeSearchCriteria() + _albumSearchCriteria = new AlbumSearchCriteria() { - SceneTitles = new List { "Person of Interest" }, - SeasonNumber = 1, - EpisodeNumber = 1 }; - - _animeSearchCriteria = new AnimeEpisodeSearchCriteria() - { - SceneTitles = new List { "Steins;Gate" }, - AbsoluteEpisodeNumber = 1 - }; - } - - [Test] - public void nyaa_fetch_recent() - { - var indexer = Mocker.Resolve(); - - indexer.Definition = new IndexerDefinition - { - Name = "MyIndexer", - Settings = new NyaaSettings() - }; - - var result = indexer.FetchRecent(); - - ValidateTorrentResult(result, hasSize: true); } - [Test] - public void nyaa_search_single() - { - var indexer = Mocker.Resolve(); - - indexer.Definition = new IndexerDefinition - { - Name = "MyIndexer", - Settings = new NyaaSettings() - }; - - var result = indexer.Fetch(_animeSearchCriteria); - - ValidateTorrentResult(result, hasSize: true); - } - - - private void ValidateTorrentResult(IList reports, bool hasSize = false, bool hasInfoUrl = false, bool hasMagnet = false) { reports.Should().OnlyContain(c => c.GetType() == typeof(TorrentInfo)); diff --git a/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabCapabilitiesProviderFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabCapabilitiesProviderFixture.cs index b7956a212..f7afd220d 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabCapabilitiesProviderFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabCapabilitiesProviderFixture.cs @@ -1,9 +1,12 @@ -using FluentAssertions; +using System; +using System.Xml; +using FluentAssertions; using Moq; using NUnit.Framework; using NzbDrone.Common.Http; using NzbDrone.Core.Indexers.Newznab; using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.IndexerTests.NewznabTests { @@ -18,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"); @@ -64,5 +67,35 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests caps.DefaultPageSize.Should().Be(100); caps.MaxPageSize.Should().Be(100); } + + [Test] + public void should_throw_if_failed_to_get() + { + Mocker.GetMock() + .Setup(o => o.Get(It.IsAny())) + .Throws(); + + Assert.Throws(() => Subject.GetCapabilities(_settings)); + } + + [Test] + public void should_throw_if_xml_invalid() + { + GivenCapsResponse(_caps.Replace("")); + + Assert.Throws(() => Subject.GetCapabilities(_settings)); + } + + [Test] + public void should_not_throw_on_xml_data_unexpected() + { + GivenCapsResponse(_caps.Replace("3040", "asdf")); + + var result = Subject.GetCapabilities(_settings); + + result.Should().NotBeNull(); + + ExceptionVerification.ExpectedErrors(1); + } } } diff --git a/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabFixture.cs index d8dd4bae3..0c79b67d2 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabFixture.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.Linq; +using System.Net; using FluentAssertions; using Moq; using NUnit.Framework; @@ -7,6 +8,7 @@ using NzbDrone.Common.Http; using NzbDrone.Core.Indexers; using NzbDrone.Core.Indexers.Newznab; using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.IndexerTests.NewznabTests { @@ -24,7 +26,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests Name = "Newznab", Settings = new NewznabSettings() { - Url = "http://indexer.local/", + BaseUrl = "http://indexer.local/", Categories = new int[] { 1 } } }; @@ -50,15 +52,15 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests var releaseInfo = releases.First(); - releaseInfo.Title.Should().Be("White.Collar.S03E05.720p.HDTV.X264-DIMENSION"); + releaseInfo.Title.Should().Be("Brainstorm-Scary Creatures-CD-FLAC-2016-NBFLAC"); releaseInfo.DownloadProtocol.Should().Be(DownloadProtocol.Usenet); - releaseInfo.DownloadUrl.Should().Be("http://nzb.su/getnzb/24967ef4c2e26296c65d3bbfa97aa8fe.nzb&i=37292&r=xxx"); - releaseInfo.InfoUrl.Should().Be("http://nzb.su/details/24967ef4c2e26296c65d3bbfa97aa8fe"); - releaseInfo.CommentUrl.Should().Be("http://nzb.su/details/24967ef4c2e26296c65d3bbfa97aa8fe#comments"); + releaseInfo.DownloadUrl.Should().Be("http://api.nzbgeek.info/api?t=get&id=38884827e1e56b9336278a449e0a38ec&apikey=xxx"); + releaseInfo.InfoUrl.Should().Be("https://nzbgeek.info/geekseek.php?guid=38884827e1e56b9336278a449e0a38ec"); + releaseInfo.CommentUrl.Should().Be("https://nzbgeek.info/geekseek.php?guid=38884827e1e56b9336278a449e0a38ec"); releaseInfo.IndexerId.Should().Be(Subject.Definition.Id); releaseInfo.Indexer.Should().Be(Subject.Definition.Name); - releaseInfo.PublishDate.Should().Be(DateTime.Parse("2012/02/27 16:09:39")); - releaseInfo.Size.Should().Be(1183105773); + releaseInfo.PublishDate.Should().Be(DateTime.Parse("2017/05/26 05:54:31")); + releaseInfo.Size.Should().Be(492735000); } [Test] @@ -69,5 +71,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..ddbd94bd2 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabRequestGeneratorFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabRequestGeneratorFixture.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using FluentAssertions; using Moq; @@ -11,8 +11,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests { public class NewznabRequestGeneratorFixture : CoreTest { - private SingleEpisodeSearchCriteria _singleEpisodeSearchCriteria; - private AnimeEpisodeSearchCriteria _animeSearchCriteria; + private AlbumSearchCriteria _singleAlbumSearchCriteria; private NewznabCapabilities _capabilities; [SetUp] @@ -20,24 +19,16 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests { Subject.Settings = new NewznabSettings() { - Url = "http://127.0.0.1:1234/", + BaseUrl = "http://127.0.0.1:1234/", Categories = new [] { 1, 2 }, - AnimeCategories = new [] { 3, 4 }, ApiKey = "abcd", }; - _singleEpisodeSearchCriteria = new SingleEpisodeSearchCriteria + _singleAlbumSearchCriteria = new AlbumSearchCriteria { - Series = new Tv.Series { TvRageId = 10, TvdbId = 20, TvMazeId = 30 }, - SceneTitles = new List { "Monkey Island" }, - SeasonNumber = 1, - EpisodeNumber = 2 - }; + Artist = new Music.Artist { Name = "Alien Ant Farm" }, + AlbumTitle = "TruANT" - _animeSearchCriteria = new AnimeEpisodeSearchCriteria() - { - SceneTitles = new List() { "Monkey+Island" }, - AbsoluteEpisodeNumber = 100 }; _capabilities = new NewznabCapabilities(); @@ -56,212 +47,21 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests var page = results.GetAllTiers().First().First(); - page.Url.Query.Should().Contain("&cat=1,2,3,4&"); - } - - [Test] - public void should_not_have_duplicate_categories() - { - Subject.Settings.Categories = new[] { 1, 2, 3 }; - - var results = Subject.GetRecentRequests(); - - results.GetAllTiers().Should().HaveCount(1); - - var page = results.GetAllTiers().First().First(); - - page.Url.FullUri.Should().Contain("&cat=1,2,3,4&"); - } - - [Test] - public void should_use_only_anime_categories_for_anime_search() - { - var results = Subject.GetSearchRequests(_animeSearchCriteria); - - results.GetAllTiers().Should().HaveCount(1); - - var page = results.GetAllTiers().First().First(); - - page.Url.FullUri.Should().Contain("&cat=3,4&"); - } - - [Test] - public void should_use_mode_search_for_anime() - { - var results = Subject.GetSearchRequests(_animeSearchCriteria); - - results.GetAllTiers().Should().HaveCount(1); - - var page = results.GetAllTiers().First().First(); - - page.Url.FullUri.Should().Contain("?t=search&"); - } - - [Test] - public void should_return_subsequent_pages() - { - var results = Subject.GetSearchRequests(_animeSearchCriteria); - - results.GetAllTiers().Should().HaveCount(1); - - var pages = results.GetAllTiers().First().Take(3).ToList(); - - pages[0].Url.FullUri.Should().Contain("&offset=0&"); - pages[1].Url.FullUri.Should().Contain("&offset=100&"); - pages[2].Url.FullUri.Should().Contain("&offset=200&"); - } - - [Test] - public void should_not_get_unlimited_pages() - { - var results = Subject.GetSearchRequests(_animeSearchCriteria); - - results.GetAllTiers().Should().HaveCount(1); - - var pages = results.GetAllTiers().First().Take(500).ToList(); - - pages.Count.Should().BeLessThan(500); - } - - [Test] - public void should_not_search_by_rid_if_not_supported() - { - _capabilities.SupportedTvSearchParameters = new[] { "q", "season", "ep" }; - - var results = Subject.GetSearchRequests(_singleEpisodeSearchCriteria); - - results.GetAllTiers().Should().HaveCount(1); - - var page = results.GetAllTiers().First().First(); - - page.Url.Query.Should().NotContain("rid=10"); - page.Url.Query.Should().Contain("q=Monkey"); - } - - [Test] - public void should_search_by_rid_if_supported() - { - var results = Subject.GetSearchRequests(_singleEpisodeSearchCriteria); - results.GetTier(0).Should().HaveCount(1); - - var page = results.GetAllTiers().First().First(); - - page.Url.Query.Should().Contain("rid=10"); - } - - [Test] - public void should_not_search_by_tvdbid_if_not_supported() - { - _capabilities.SupportedTvSearchParameters = new[] { "q", "season", "ep" }; - - var results = Subject.GetSearchRequests(_singleEpisodeSearchCriteria); - results.GetTier(0).Should().HaveCount(1); - - var page = results.GetAllTiers().First().First(); - - page.Url.Query.Should().NotContain("rid=10"); - page.Url.Query.Should().Contain("q=Monkey"); + page.Url.Query.Should().Contain("&cat=1,2&"); } [Test] - public void should_search_by_tvdbid_if_supported() + public void should_search_by_artist_and_album_if_supported() { - _capabilities.SupportedTvSearchParameters = new[] { "q", "tvdbid", "season", "ep" }; + _capabilities.SupportedAudioSearchParameters = new[] { "q", "artist", "album"}; - var results = Subject.GetSearchRequests(_singleEpisodeSearchCriteria); + var results = Subject.GetSearchRequests(_singleAlbumSearchCriteria); results.GetTier(0).Should().HaveCount(1); var page = results.GetAllTiers().First().First(); - page.Url.Query.Should().Contain("tvdbid=20"); - } - - [Test] - public void should_search_by_tvmaze_if_supported() - { - _capabilities.SupportedTvSearchParameters = new[] { "q", "tvmazeid", "season", "ep" }; - - var results = Subject.GetSearchRequests(_singleEpisodeSearchCriteria); - results.GetTier(0).Should().HaveCount(1); - - var page = results.GetAllTiers().First().First(); - - page.Url.Query.Should().Contain("tvmazeid=30"); - } - - [Test] - public void should_prefer_search_by_tvdbid_if_rid_supported() - { - _capabilities.SupportedTvSearchParameters = new[] { "q", "tvdbid", "rid", "season", "ep" }; - - var results = Subject.GetSearchRequests(_singleEpisodeSearchCriteria); - results.GetTier(0).Should().HaveCount(1); - - var page = results.GetAllTiers().First().First(); - - page.Url.Query.Should().Contain("tvdbid=20"); - page.Url.Query.Should().NotContain("rid=10"); - } - - [Test] - public void should_use_aggregrated_id_search_if_supported() - { - _capabilities.SupportedTvSearchParameters = new[] { "q", "tvdbid", "rid", "season", "ep" }; - _capabilities.SupportsAggregateIdSearch = true; - - var results = Subject.GetSearchRequests(_singleEpisodeSearchCriteria); - results.GetTier(0).Should().HaveCount(1); - - var page = results.GetTier(0).First().First(); - - page.Url.Query.Should().Contain("tvdbid=20"); - page.Url.Query.Should().Contain("rid=10"); - } - - [Test] - public void should_not_use_aggregrated_id_search_if_no_ids_supported() - { - _capabilities.SupportedTvSearchParameters = new[] { "q", "season", "ep" }; - _capabilities.SupportsAggregateIdSearch = true; // Turns true if indexer supplies supportedParams. - - var results = Subject.GetSearchRequests(_singleEpisodeSearchCriteria); - results.Tiers.Should().Be(1); - results.GetTier(0).Should().HaveCount(1); - - var page = results.GetTier(0).First().First(); - - page.Url.Query.Should().Contain("q="); - } - - [Test] - public void should_not_use_aggregrated_id_search_if_no_ids_are_known() - { - _capabilities.SupportedTvSearchParameters = new[] { "q", "rid", "season", "ep" }; - _capabilities.SupportsAggregateIdSearch = true; // Turns true if indexer supplies supportedParams. - - _singleEpisodeSearchCriteria.Series.TvRageId = 0; - - var results = Subject.GetSearchRequests(_singleEpisodeSearchCriteria); - - var page = results.GetTier(0).First().First(); - - page.Url.Query.Should().Contain("q="); - } - - [Test] - public void should_fallback_to_q() - { - _capabilities.SupportedTvSearchParameters = new[] { "q", "tvdbid", "rid", "season", "ep" }; - _capabilities.SupportsAggregateIdSearch = true; - - var results = Subject.GetSearchRequests(_singleEpisodeSearchCriteria); - results.Tiers.Should().Be(2); - - var pageTier2 = results.GetTier(1).First().First(); - - pageTier2.Url.Query.Should().NotContain("tvdbid=20"); - pageTier2.Url.Query.Should().NotContain("rid=10"); - pageTier2.Url.Query.Should().Contain("q="); + page.Url.Query.Should().Contain("artist=Alien%20Ant%20Farm"); + page.Url.Query.Should().Contain("album=TruANT"); } } } diff --git a/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabSettingFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabSettingFixture.cs index 4bd26817d..bb5bc782c 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabSettingFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/NewznabTests/NewznabSettingFixture.cs @@ -1,4 +1,4 @@ -using FluentAssertions; +using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.Indexers.Newznab; using NzbDrone.Core.Test.Framework; @@ -15,7 +15,7 @@ namespace NzbDrone.Core.Test.IndexerTests.NewznabTests var setting = new NewznabSettings() { ApiKey = "", - Url = url + BaseUrl = url }; @@ -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().Contain(c => c.PropertyName == "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/RarbgTests/RarbgFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/RarbgTests/RarbgFixture.cs index 7f442fcb2..387f4047b 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/RarbgTests/RarbgFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/RarbgTests/RarbgFixture.cs @@ -56,8 +56,6 @@ namespace NzbDrone.Core.Test.IndexerTests.RarbgTests torrentInfo.MagnetUrl.Should().BeNull(); torrentInfo.Peers.Should().Be(304 + 200); torrentInfo.Seeders.Should().Be(304); - torrentInfo.TvdbId.Should().Be(268156); - torrentInfo.TvRageId.Should().Be(35197); } [Test] diff --git a/src/NzbDrone.Core.Test/IndexerTests/SeasonSearchFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/SeasonSearchFixture.cs deleted file mode 100644 index 075bb73e2..000000000 --- a/src/NzbDrone.Core.Test/IndexerTests/SeasonSearchFixture.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Collections.Generic; -using FizzWare.NBuilder; -using Moq; -using NUnit.Framework; -using NzbDrone.Common.Http; -using NzbDrone.Core.Indexers; -using NzbDrone.Core.IndexerSearch.Definitions; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Tv; -using NzbDrone.Test.Common; - -namespace NzbDrone.Core.Test.IndexerTests -{ - [TestFixture] - public class SeasonSearchFixture : TestBase - { - private Series _series; - - [SetUp] - public void Setup() - { - _series = Builder.CreateNew().Build(); - - Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) - .Returns(r => new HttpResponse(r, new HttpHeader(), "")); - } - - private void WithIndexer(bool paging, int resultCount) - { - var definition = new IndexerDefinition(); - definition.Name = "Test"; - Subject.Definition = definition; - - Subject._supportedPageSize = paging ? 100 : 0; - - var requestGenerator = Mocker.GetMock(); - Subject._requestGenerator = requestGenerator.Object; - - var requests = Builder.CreateListOfSize(paging ? 100 : 1) - .All() - .WithConstructor(() => new IndexerRequest("http://my.feed.local/", HttpAccept.Rss)) - .With(v => v.HttpRequest.Method = HttpMethod.GET) - .Build(); - - var pageable = new IndexerPageableRequestChain(); - pageable.Add(requests); - - requestGenerator.Setup(s => s.GetSearchRequests(It.IsAny())) - .Returns(pageable); - - var parser = Mocker.GetMock(); - Subject._parser = parser.Object; - - var results = Builder.CreateListOfSize(resultCount) - .Build(); - - parser.Setup(s => s.ParseResponse(It.IsAny())) - .Returns(results); - } - - [Test] - public void should_not_use_offset_if_result_count_is_less_than_90() - { - WithIndexer(true, 25); - - Subject.Fetch(new SeasonSearchCriteria { Series = _series, SceneTitles = new List{_series.Title} }); - - Mocker.GetMock().Verify(v => v.Execute(It.IsAny()), Times.Once()); - } - - [Test] - public void should_not_use_offset_for_sites_that_do_not_support_it() - { - WithIndexer(false, 125); - - Subject.Fetch(new SeasonSearchCriteria { Series = _series, SceneTitles = new List { _series.Title } }); - - Mocker.GetMock().Verify(v => v.Execute(It.IsAny()), Times.Once()); - } - - [Test] - public void should_not_use_offset_if_its_already_tried_10_times() - { - WithIndexer(true, 100); - - Subject.Fetch(new SeasonSearchCriteria { Series = _series, SceneTitles = new List { _series.Title } }); - - Mocker.GetMock().Verify(v => v.Execute(It.IsAny()), Times.Exactly(10)); - } - } -} diff --git a/src/NzbDrone.Core.Test/IndexerTests/SeedConfigProviderFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/SeedConfigProviderFixture.cs new file mode 100644 index 000000000..53a054868 --- /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 RemoteAlbum + { + Release = new ReleaseInfo + { + DownloadProtocol = DownloadProtocol.Torrent, + IndexerId = 0 + } + }); + + result.Should().BeNull(); + } + + [Test] + public void should_return_discography_time_for_discography_packs() + { + var settings = new TorznabSettings(); + settings.SeedCriteria.DiscographySeedTime = 10; + + Mocker.GetMock() + .Setup(v => v.Get(It.IsAny())) + .Returns(new IndexerDefinition + { + Settings = settings + }); + + var result = Subject.GetSeedConfiguration(new RemoteAlbum + { + Release = new ReleaseInfo() + { + DownloadProtocol = DownloadProtocol.Torrent, + IndexerId = 1 + }, + ParsedAlbumInfo = new ParsedAlbumInfo + { + Discography = 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..7fcefb7de 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/TestIndexerSettings.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/TestIndexerSettings.cs @@ -1,14 +1,18 @@ -using System; +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; } + public int? EarlyReleaseLimit { get; set; } } } diff --git a/src/NzbDrone.Core.Test/IndexerTests/TorrentRssIndexerTests/TorrentRssIndexerFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/TorrentRssIndexerTests/TorrentRssIndexerFixture.cs index 175425599..169d40888 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/TorrentRssIndexerTests/TorrentRssIndexerFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/TorrentRssIndexerTests/TorrentRssIndexerFixture.cs @@ -1,13 +1,15 @@ -using System; +using System; using System.Linq; using FluentAssertions; 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,64 @@ 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] + public void should_parse_recent_feed_from_AlphaRatio() + { + GivenRecentFeedResponse("TorrentRss/AlphaRatio.xml"); + + var releases = Subject.FetchRecent(); + + releases.Should().HaveCount(2); + releases.Last().Should().BeOfType(); + + var torrentInfo = releases.Last() as TorrentInfo; + + torrentInfo.Title.Should().Be("TvHD 465860 465831 WWE.RAW.2016.11.28.720p.HDTV.x264-KYR"); + 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 1c6afaa25..409da6bd0 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/TorrentRssIndexerTests/TorrentRssSettingsDetectorFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/TorrentRssIndexerTests/TorrentRssSettingsDetectorFixture.cs @@ -1,4 +1,4 @@ -using FluentAssertions; +using FluentAssertions; using Moq; using NUnit.Framework; using NzbDrone.Common.Http; @@ -200,6 +200,26 @@ namespace NzbDrone.Core.Test.IndexerTests.TorrentRssIndexerTests }); } + [Test] + public void should_detect_rss_settings_for_AlphaRatio() + { + _indexerSettings.AllowZeroSize = true; + + GivenRecentFeedResponse("TorrentRss/AlphaRatio.xml"); + + var settings = Subject.Detect(_indexerSettings); + + settings.ShouldBeEquivalentTo(new TorrentRssIndexerParserSettings + { + UseEZTVFormat = false, + UseEnclosureUrl = false, + UseEnclosureLength = false, + ParseSizeInDescription = true, + ParseSeedersInDescription = false, + SizeElementName = null + }); + } + [Test] [Ignore("Cannot reliably reject unparseable titles")] public void should_reject_rss_settings_for_AwesomeHD() @@ -233,12 +253,8 @@ 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")] public void should_detect_recent_feed(string rssXmlFile) @@ -267,9 +283,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..4c22d22bb 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/TorznabTests/TorznabFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/TorznabTests/TorznabFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using FluentAssertions; using Moq; @@ -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); @@ -60,8 +60,6 @@ namespace NzbDrone.Core.Test.IndexerTests.TorznabTests releaseInfo.Indexer.Should().Be(Subject.Definition.Name); releaseInfo.PublishDate.Should().Be(DateTime.Parse("2015/03/14 21:10:42")); releaseInfo.Size.Should().Be(2538463390); - releaseInfo.TvdbId.Should().Be(273181); - releaseInfo.TvRageId.Should().Be(37780); releaseInfo.InfoHash.Should().Be("63e07ff523710ca268567dad344ce1e0e6b7e8a3"); releaseInfo.Seeders.Should().Be(7); releaseInfo.Peers.Should().Be(7); diff --git a/src/NzbDrone.Core.Test/IndexerTests/WafflesTests/WafflesFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/WafflesTests/WafflesFixture.cs new file mode 100644 index 000000000..c606a632c --- /dev/null +++ b/src/NzbDrone.Core.Test/IndexerTests/WafflesTests/WafflesFixture.cs @@ -0,0 +1,56 @@ +using System; +using System.Linq; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Http; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Indexers.Waffles; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.IndexerTests.WafflesTests +{ + [TestFixture] + public class WafflesFixture : CoreTest + { + [SetUp] + public void Setup() + { + Subject.Definition = new IndexerDefinition() + { + Name = "Waffles", + Settings = new WafflesSettings() + { + UserId = "xxx", + RssPasskey = "123456789" + } + }; + } + + [Test] + public void should_parse_recent_feed_from_waffles() + { + var recentFeed = ReadAllText(@"Files/Indexers/Waffles/waffles.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(15); + + var releaseInfo = releases.First(); + + releaseInfo.Title.Should().Be("Coldplay - Kaleidoscope EP (FLAC HD) [2017-Web-FLAC-Lossless]"); + releaseInfo.DownloadProtocol.Should().Be(DownloadProtocol.Torrent); + releaseInfo.DownloadUrl.Should().Be("https://waffles.ch/download.php/xxx/1166992/" + + "Coldplay%20-%20Kaleidoscope%20EP%20%28FLAC%20HD%29%20%5B2017-Web-FLAC-Lossless%5D.torrent?passkey=123456789&uid=xxx&rss=1"); + releaseInfo.InfoUrl.Should().Be("https://waffles.ch/details.php?id=1166992&hit=1"); + releaseInfo.CommentUrl.Should().Be("https://waffles.ch/details.php?id=1166992&hit=1"); + releaseInfo.Indexer.Should().Be(Subject.Definition.Name); + releaseInfo.PublishDate.Should().Be(DateTime.Parse("2017-07-16 09:51:54")); + releaseInfo.Size.Should().Be(552668227); + } + } +} diff --git a/src/NzbDrone.Core.Test/Instrumentation/DatabaseTargetFixture.cs b/src/NzbDrone.Core.Test/Instrumentation/DatabaseTargetFixture.cs index 716b5c042..c575e3fdd 100644 --- a/src/NzbDrone.Core.Test/Instrumentation/DatabaseTargetFixture.cs +++ b/src/NzbDrone.Core.Test/Instrumentation/DatabaseTargetFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using FluentAssertions; using Marr.Data; @@ -40,7 +40,7 @@ namespace NzbDrone.Core.Test.Instrumentation { _logger.Info(_uniqueMessage); - Thread.Sleep(600); + Thread.Sleep(1000); StoredModel.Message.Should().Be(_uniqueMessage); VerifyLog(StoredModel, LogLevel.Info); @@ -57,7 +57,7 @@ namespace NzbDrone.Core.Test.Instrumentation _logger.Info(message); - Thread.Sleep(600); + Thread.Sleep(1000); StoredModel.Message.Should().HaveLength(message.Length); StoredModel.Message.Should().Be(message); @@ -76,7 +76,7 @@ namespace NzbDrone.Core.Test.Instrumentation _logger.Info(Guid.NewGuid()); } - Thread.Sleep(600); + Thread.Sleep(1000); MapRepository.Instance.EnableTraceLogging = true; } @@ -88,7 +88,7 @@ namespace NzbDrone.Core.Test.Instrumentation _logger.Error(ex, _uniqueMessage); - Thread.Sleep(600); + Thread.Sleep(1000); VerifyLog(StoredModel, LogLevel.Error); StoredModel.Message.Should().Be(_uniqueMessage + ": " + ex.Message); @@ -106,7 +106,7 @@ namespace NzbDrone.Core.Test.Instrumentation _logger.Error(ex, _uniqueMessage); - Thread.Sleep(600); + Thread.Sleep(1000); StoredModel.Message.Should().Be(ex.Message); @@ -118,12 +118,12 @@ namespace NzbDrone.Core.Test.Instrumentation [Test] public void null_string_as_arg_should_not_fail() { - var epFile = new EpisodeFile(); - _logger.Debug("File {0} no longer exists on disk. removing from database.", epFile.RelativePath); + var epFile = new TrackFile(); + _logger.Debug("File {0} no longer exists on disk. removing from database.", epFile.Path); - Thread.Sleep(600); + Thread.Sleep(1000); - epFile.RelativePath.Should().BeNull(); + epFile.Path.Should().BeNull(); } diff --git a/src/NzbDrone.Core.Test/Lidarr.Core.Test.csproj b/src/NzbDrone.Core.Test/Lidarr.Core.Test.csproj new file mode 100644 index 000000000..e314a2c58 --- /dev/null +++ b/src/NzbDrone.Core.Test/Lidarr.Core.Test.csproj @@ -0,0 +1,27 @@ + + + net462 + x86 + + + + + + + + + + + + + Files\1024.png + Always + + + ..\Libraries\Sqlite\System.Data.SQLite.dll + + + PreserveNewest + + + diff --git a/src/NzbDrone.Core.Test/MediaCoverTests/CoverExistsSpecificationFixture.cs b/src/NzbDrone.Core.Test/MediaCoverTests/CoverExistsSpecificationFixture.cs index bb3b0a99c..f66210c91 100644 --- a/src/NzbDrone.Core.Test/MediaCoverTests/CoverExistsSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/MediaCoverTests/CoverExistsSpecificationFixture.cs @@ -1,72 +1,75 @@ -using System.Net; using FluentAssertions; using Moq; using NUnit.Framework; using NzbDrone.Common.Disk; -using NzbDrone.Common.Http; using NzbDrone.Core.MediaCover; using NzbDrone.Core.Test.Framework; +using System; namespace NzbDrone.Core.Test.MediaCoverTests { [TestFixture] public class CoverAlreadyExistsSpecificationFixture : CoreTest { - private HttpResponse _httpResponse; - - [SetUp] - public void Setup() + private void GivenFileExistsOnDisk(DateTime? givenDate) { - _httpResponse = new HttpResponse(null, new HttpHeader(), "", HttpStatusCode.OK); - Mocker.GetMock().Setup(c => c.GetFileSize(It.IsAny())).Returns(100); - Mocker.GetMock().Setup(c => c.Head(It.IsAny())).Returns(_httpResponse); - + Mocker.GetMock().Setup(c => c.FileExists(It.IsAny())).Returns(true); + Mocker.GetMock().Setup(c => c.FileGetLastWrite(It.IsAny())).Returns(givenDate ?? DateTime.Now); + Mocker.GetMock().Setup(c => c.GetFileSize(It.IsAny())).Returns(1000); } - - private void GivenFileExistsOnDisk() + [Test] + public void should_return_false_if_file_not_exists() { - Mocker.GetMock().Setup(c => c.FileExists(It.IsAny())).Returns(true); - } + Mocker.GetMock().Setup(c => c.FileExists(It.IsAny())).Returns(false); + Subject.AlreadyExists(DateTime.Now, 0, "c:\\file.exe").Should().BeFalse(); + } - private void GivenExistingFileSize(long bytes) + [Test] + public void should_return_false_if_file_exists_but_different_date() { - GivenFileExistsOnDisk(); - Mocker.GetMock().Setup(c => c.GetFileSize(It.IsAny())).Returns(bytes); + GivenFileExistsOnDisk(DateTime.Now); + Subject.AlreadyExists(DateTime.Now.AddHours(-5), 0, "c:\\file.exe").Should().BeFalse(); } - [Test] - public void should_return_false_if_file_not_exists() + public void should_return_true_if_file_exists_and_same_date_but_no_length_header() { - Subject.AlreadyExists("http://url", "c:\\file.exe").Should().BeFalse(); + var givenDate = DateTime.Now; + + GivenFileExistsOnDisk(givenDate); + + Subject.AlreadyExists(givenDate, null, "c:\\file.exe").Should().BeTrue(); } [Test] - public void should_return_false_if_file_exists_but_diffrent_size() + public void should_return_false_if_file_exists_and_same_date_but_length_header_different() { - GivenExistingFileSize(100); - _httpResponse.Headers.ContentLength = 200; + var givenDate = DateTime.Now; - Subject.AlreadyExists("http://url", "c:\\file.exe").Should().BeFalse(); + GivenFileExistsOnDisk(givenDate); + + Subject.AlreadyExists(givenDate, 999, "c:\\file.exe").Should().BeFalse(); } [Test] - public void should_return_ture_if_file_exists_and_same_size() + public void should_return_true_if_file_exists_and_date_header_is_null_but_has_length_header() { - GivenExistingFileSize(100); - _httpResponse.Headers.ContentLength = 100; - Subject.AlreadyExists("http://url", "c:\\file.exe").Should().BeTrue(); + GivenFileExistsOnDisk(DateTime.Now); + + Subject.AlreadyExists(null, 1000, "c:\\file.exe").Should().BeTrue(); } [Test] - public void should_return_true_if_there_is_no_size_header_and_file_exist() + public void should_return_true_if_file_exists_and_date_header_is_different_but_length_header_the_same() { - GivenExistingFileSize(100); - Subject.AlreadyExists("http://url", "c:\\file.exe").Should().BeFalse(); + GivenFileExistsOnDisk(DateTime.Now.AddDays(-1)); + + Subject.AlreadyExists(DateTime.Now, 1000, "c:\\file.exe").Should().BeTrue(); } + } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core.Test/MediaCoverTests/ImageResizerFixture.cs b/src/NzbDrone.Core.Test/MediaCoverTests/ImageResizerFixture.cs index dc37776fa..d1674d8b6 100644 --- a/src/NzbDrone.Core.Test/MediaCoverTests/ImageResizerFixture.cs +++ b/src/NzbDrone.Core.Test/MediaCoverTests/ImageResizerFixture.cs @@ -1,16 +1,15 @@ -using System; +using System; using System.IO; using FluentAssertions; using Moq; using NUnit.Framework; using NzbDrone.Common.Disk; -using NzbDrone.Core.MediaCover; using NzbDrone.Core.Test.Framework; namespace NzbDrone.Core.Test.MediaCoverTests { [TestFixture] - public class ImageResizerFixture : CoreTest + public class ImageResizerFixture : CoreTest { [SetUp] public void SetUp() @@ -64,4 +63,4 @@ namespace NzbDrone.Core.Test.MediaCoverTests File.Exists(resizedFile).Should().BeFalse(); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core.Test/MediaCoverTests/MediaCoverServiceFixture.cs b/src/NzbDrone.Core.Test/MediaCoverTests/MediaCoverServiceFixture.cs index fdf2efb07..f05bb6406 100644 --- a/src/NzbDrone.Core.Test/MediaCoverTests/MediaCoverServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MediaCoverTests/MediaCoverServiceFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using FizzWare.NBuilder; @@ -7,35 +7,51 @@ using Moq; using NUnit.Framework; using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Http; using NzbDrone.Core.MediaCover; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; -using NzbDrone.Core.Tv.Events; +using NzbDrone.Core.Music; +using NzbDrone.Core.Music.Events; namespace NzbDrone.Core.Test.MediaCoverTests { [TestFixture] public class MediaCoverServiceFixture : CoreTest { - Series _series; + Artist _artist; + Album _album; + private HttpResponse _httpResponse; [SetUp] public void Setup() { Mocker.SetConstant(new AppFolderInfo(Mocker.Resolve())); - _series = Builder.CreateNew() + _artist = Builder.CreateNew() .With(v => v.Id = 2) - .With(v => v.Images = new List { new MediaCover.MediaCover(MediaCoverTypes.Poster, "") }) + .With(v => v.Metadata.Value.Images = new List { new MediaCover.MediaCover(MediaCoverTypes.Poster, "") }) .Build(); + + _album = Builder.CreateNew() + .With(v => v.Id = 4) + .With(v => v.Images = new List { new MediaCover.MediaCover(MediaCoverTypes.Cover, "") }) + .Build(); + + _httpResponse = new HttpResponse(null, new HttpHeader(), ""); + Mocker.GetMock().Setup(c => c.Head(It.IsAny())).Returns(_httpResponse); } - [Test] - public void should_convert_cover_urls_to_local() + [TestCase(".png")] + [TestCase(".jpg")] + public void should_convert_cover_urls_to_local(string extension) { var covers = new List { - new MediaCover.MediaCover {CoverType = MediaCoverTypes.Banner} + new MediaCover.MediaCover + { + Url = "http://dummy.com/test" + extension, + CoverType = MediaCoverTypes.Banner + } }; Mocker.GetMock().Setup(c => c.FileGetLastWrite(It.IsAny())) @@ -44,39 +60,97 @@ namespace NzbDrone.Core.Test.MediaCoverTests Mocker.GetMock().Setup(c => c.FileExists(It.IsAny())) .Returns(true); - Subject.ConvertToLocalUrls(12, covers); + Subject.ConvertToLocalUrls(12, MediaCoverEntity.Artist, covers); - covers.Single().Url.Should().Be("/MediaCover/12/banner.jpg?lastWrite=1234"); + covers.Single().Url.Should().Be("/MediaCover/12/banner" + extension + "?lastWrite=1234"); } - [Test] - public void should_convert_media_urls_to_local_without_time_if_file_doesnt_exist() + [TestCase(".png")] + [TestCase(".jpg")] + public void convert_to_local_url_should_not_change_extension(string extension) { var covers = new List { - new MediaCover.MediaCover {CoverType = MediaCoverTypes.Banner} + new MediaCover.MediaCover + { + Url = "http://dummy.com/test" + extension, + CoverType = MediaCoverTypes.Banner + } }; + Mocker.GetMock().Setup(c => c.FileGetLastWrite(It.IsAny())) + .Returns(new DateTime(1234)); - Subject.ConvertToLocalUrls(12, covers); + Mocker.GetMock().Setup(c => c.FileExists(It.IsAny())) + .Returns(true); + Subject.ConvertToLocalUrls(12, MediaCoverEntity.Artist, covers); - covers.Single().Url.Should().Be("/MediaCover/12/banner.jpg"); + covers.Single().Extension.Should().Be(extension); + } + + [TestCase(".png")] + [TestCase(".jpg")] + public void should_convert_album_cover_urls_to_local(string extension) + { + var covers = new List + { + new MediaCover.MediaCover + { + Url = "http://dummy.com/test" + extension, + CoverType = MediaCoverTypes.Disc + } + }; + + Mocker.GetMock().Setup(c => c.FileGetLastWrite(It.IsAny())) + .Returns(new DateTime(1234)); + + Mocker.GetMock().Setup(c => c.FileExists(It.IsAny())) + .Returns(true); + + Subject.ConvertToLocalUrls(6, MediaCoverEntity.Album, covers); + + + covers.Single().Url.Should().Be("/MediaCover/Albums/6/disc" + extension + "?lastWrite=1234"); + } + + [TestCase(".png")] + [TestCase(".jpg")] + public void should_convert_media_urls_to_local_without_time_if_file_doesnt_exist(string extension) + { + var covers = new List + { + new MediaCover.MediaCover + { + Url = "http://dummy.com/test" + extension, + CoverType = MediaCoverTypes.Banner + } + }; + + + Subject.ConvertToLocalUrls(12, MediaCoverEntity.Artist, covers); + + + covers.Single().Url.Should().Be("/MediaCover/12/banner" + extension); } [Test] public void should_resize_covers_if_main_downloaded() { Mocker.GetMock() - .Setup(v => v.AlreadyExists(It.IsAny(), It.IsAny())) + .Setup(v => v.AlreadyExists(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(false); + Mocker.GetMock() + .Setup(v => v.GetAlbumsByArtist(It.IsAny())) + .Returns(new List { _album }); + Mocker.GetMock() .Setup(v => v.FileExists(It.IsAny())) .Returns(true); - Subject.HandleAsync(new SeriesUpdatedEvent(_series)); + Subject.HandleAsync(new ArtistRefreshCompleteEvent(_artist)); Mocker.GetMock() .Verify(v => v.Resize(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); @@ -86,14 +160,18 @@ namespace NzbDrone.Core.Test.MediaCoverTests public void should_resize_covers_if_missing() { Mocker.GetMock() - .Setup(v => v.AlreadyExists(It.IsAny(), It.IsAny())) + .Setup(v => v.AlreadyExists(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(true); + Mocker.GetMock() + .Setup(v => v.GetAlbumsByArtist(It.IsAny())) + .Returns(new List { _album }); + Mocker.GetMock() .Setup(v => v.FileExists(It.IsAny())) .Returns(false); - Subject.HandleAsync(new SeriesUpdatedEvent(_series)); + Subject.HandleAsync(new ArtistRefreshCompleteEvent(_artist)); Mocker.GetMock() .Verify(v => v.Resize(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); @@ -103,18 +181,22 @@ namespace NzbDrone.Core.Test.MediaCoverTests public void should_not_resize_covers_if_exists() { Mocker.GetMock() - .Setup(v => v.AlreadyExists(It.IsAny(), It.IsAny())) + .Setup(v => v.AlreadyExists(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(true); Mocker.GetMock() .Setup(v => v.FileExists(It.IsAny())) .Returns(true); + Mocker.GetMock() + .Setup(v => v.GetAlbumsByArtist(It.IsAny())) + .Returns(new List { _album }); + Mocker.GetMock() .Setup(v => v.GetFileSize(It.IsAny())) .Returns(1000); - Subject.HandleAsync(new SeriesUpdatedEvent(_series)); + Subject.HandleAsync(new ArtistRefreshCompleteEvent(_artist)); Mocker.GetMock() .Verify(v => v.Resize(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); @@ -124,18 +206,22 @@ namespace NzbDrone.Core.Test.MediaCoverTests public void should_resize_covers_if_existing_is_empty() { Mocker.GetMock() - .Setup(v => v.AlreadyExists(It.IsAny(), It.IsAny())) + .Setup(v => v.AlreadyExists(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(true); Mocker.GetMock() .Setup(v => v.FileExists(It.IsAny())) .Returns(true); + Mocker.GetMock() + .Setup(v => v.GetAlbumsByArtist(It.IsAny())) + .Returns(new List { _album }); + Mocker.GetMock() .Setup(v => v.GetFileSize(It.IsAny())) .Returns(0); - Subject.HandleAsync(new SeriesUpdatedEvent(_series)); + Subject.HandleAsync(new ArtistRefreshCompleteEvent(_artist)); Mocker.GetMock() .Verify(v => v.Resize(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); @@ -145,21 +231,25 @@ namespace NzbDrone.Core.Test.MediaCoverTests public void should_log_error_if_resize_failed() { Mocker.GetMock() - .Setup(v => v.AlreadyExists(It.IsAny(), It.IsAny())) + .Setup(v => v.AlreadyExists(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(true); Mocker.GetMock() .Setup(v => v.FileExists(It.IsAny())) .Returns(false); + Mocker.GetMock() + .Setup(v => v.GetAlbumsByArtist(It.IsAny())) + .Returns(new List { _album }); + Mocker.GetMock() .Setup(v => v.Resize(It.IsAny(), It.IsAny(), It.IsAny())) .Throws(); - Subject.HandleAsync(new SeriesUpdatedEvent(_series)); + Subject.HandleAsync(new ArtistRefreshCompleteEvent(_artist)); Mocker.GetMock() .Verify(v => v.Resize(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/AudioTagServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/AudioTagServiceFixture.cs new file mode 100644 index 000000000..0e5c85e90 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/AudioTagServiceFixture.cs @@ -0,0 +1,408 @@ +using System.IO; +using NUnit.Framework; +using FluentAssertions; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Music; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Configuration; +using FizzWare.NBuilder; +using System; +using System.Collections; +using System.Linq; +using NzbDrone.Common.Extensions; +using System.Collections.Generic; +using NzbDrone.Test.Common; +using NzbDrone.Common.Disk; + +namespace NzbDrone.Core.Test.MediaFiles.AudioTagServiceFixture +{ + [TestFixture] + public class AudioTagServiceFixture : CoreTest + { + public static class TestCaseFactory + { + private static readonly string[] MediaFiles = new [] { "nin.mp2", "nin.mp3", "nin.flac", "nin.m4a", "nin.wma", "nin.ape", "nin.opus" }; + + private static readonly string[] SkipProperties = new [] { "IsValid", "Duration", "Quality", "MediaInfo", "ImageFile" }; + private static readonly Dictionary SkipPropertiesByFile = new Dictionary { + { "nin.mp2", new [] {"OriginalReleaseDate"} } + }; + + public static IEnumerable TestCases + { + get + { + foreach (var file in MediaFiles) + { + var toSkip = SkipProperties; + if (SkipPropertiesByFile.ContainsKey(file)) + { + toSkip = toSkip.Union(SkipPropertiesByFile[file]).ToArray(); + } + yield return new TestCaseData(file, toSkip).SetName($"{{m}}_{file.Replace("nin.", "")}"); + } + } + } + } + + private readonly string testdir = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Media"); + private string copiedFile; + private AudioTag testTags; + private IDiskProvider _diskProvider; + + [SetUp] + public void Setup() + { + _diskProvider = Mocker.Resolve("ActualDiskProvider"); + + Mocker.SetConstant(_diskProvider); + + Mocker.GetMock() + .Setup(x => x.WriteAudioTags) + .Returns(WriteAudioTagsType.Sync); + + var imageFile = Path.Combine(testdir, "nin.png"); + var imageSize = _diskProvider.GetFileSize(imageFile); + + // have to manually set the arrays of string parameters and integers to values > 1 + testTags = Builder.CreateNew() + .With(x => x.Track = 2) + .With(x => x.TrackCount = 33) + .With(x => x.Disc = 44) + .With(x => x.DiscCount = 55) + .With(x => x.Date = new DateTime(2019, 3, 1)) + .With(x => x.Year = 2019) + .With(x => x.OriginalReleaseDate = new DateTime(2009, 4, 1)) + .With(x => x.OriginalYear = 2009) + .With(x => x.Performers = new [] { "Performer1" }) + .With(x => x.AlbumArtists = new [] { "방탄소년단" }) + .With(x => x.Genres = new [] { "Genre1", "Genre2" }) + .With(x => x.ImageFile = imageFile) + .With(x => x.ImageSize = imageSize) + .Build(); + } + + [TearDown] + public void Cleanup() + { + if (File.Exists(copiedFile)) + { + File.Delete(copiedFile); + } + } + + private void GivenFileCopy(string filename) + { + var original = Path.Combine(testdir, filename); + var tempname = $"temp_{Path.GetRandomFileName()}{Path.GetExtension(filename)}"; + copiedFile = Path.Combine(testdir, tempname); + + File.Copy(original, copiedFile); + } + + private void VerifyDifferent(AudioTag a, AudioTag b, string[] skipProperties) + { + foreach (var property in typeof(AudioTag).GetProperties()) + { + if (skipProperties.Contains(property.Name)) + { + continue; + } + + if (property.CanRead) + { + if (property.PropertyType.GetInterfaces().Any(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IEquatable<>)) || + Nullable.GetUnderlyingType(property.PropertyType) != null) + { + var val1 = property.GetValue(a, null); + var val2 = property.GetValue(b, null); + val1.Should().NotBe(val2, $"{property.Name} should not be equal. Found {val1.NullSafe()} for both tags"); + } + else if (typeof(IEnumerable).IsAssignableFrom(property.PropertyType)) + { + var val1 = (IEnumerable) property.GetValue(a, null); + var val2 = (IEnumerable) property.GetValue(b, null); + + if (val1 != null && val2 != null) + { + val1.Should().NotBeEquivalentTo(val2, $"{property.Name} should not be equal"); + } + } + } + } + } + + private void VerifySame(AudioTag a, AudioTag b, string[] skipProperties) + { + foreach (var property in typeof(AudioTag).GetProperties()) + { + if (skipProperties.Contains(property.Name)) + { + continue; + } + + if (property.CanRead) + { + if (property.PropertyType.GetInterfaces().Any(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IEquatable<>)) || + Nullable.GetUnderlyingType(property.PropertyType) != null) + { + var val1 = property.GetValue(a, null); + var val2 = property.GetValue(b, null); + val1.Should().Be(val2, $"{property.Name} should be equal"); + } + else if (typeof(IEnumerable).IsAssignableFrom(property.PropertyType)) + { + var val1 = (IEnumerable) property.GetValue(a, null); + var val2 = (IEnumerable) property.GetValue(b, null); + + if (val1 != null || val2 != null) + { + val1.Should().BeEquivalentTo(val2, $"{property.Name} should be equal"); + } + } + } + } + } + + [Test, TestCaseSource(typeof(TestCaseFactory), "TestCases")] + public void should_read_duration(string filename, string[] ignored) + { + var path = Path.Combine(testdir, filename); + + var tags = Subject.ReadTags(path); + + tags.Duration.Should().BeCloseTo(new TimeSpan(0, 0, 1, 25, 130), 100); + } + + [Test, TestCaseSource(typeof(TestCaseFactory), "TestCases")] + public void should_read_write_tags(string filename, string[] skipProperties) + { + GivenFileCopy(filename); + var path = copiedFile; + + var initialtags = Subject.ReadAudioTag(path); + + VerifyDifferent(initialtags, testTags, skipProperties); + + testTags.Write(path); + + var writtentags = Subject.ReadAudioTag(path); + + VerifySame(writtentags, testTags, skipProperties); + } + + [Test, TestCaseSource(typeof(TestCaseFactory), "TestCases")] + public void should_remove_mb_tags(string filename, string[] skipProperties) + { + GivenFileCopy(filename); + var path = copiedFile; + + var track = new TrackFile { + Path = path + }; + + testTags.Write(path); + + var withmb = Subject.ReadAudioTag(path); + + VerifySame(withmb, testTags, skipProperties); + + Subject.RemoveMusicBrainzTags(track); + + var tag = Subject.ReadAudioTag(path); + + tag.MusicBrainzReleaseCountry.Should().BeNull(); + tag.MusicBrainzReleaseStatus.Should().BeNull(); + tag.MusicBrainzReleaseType.Should().BeNull(); + tag.MusicBrainzReleaseId.Should().BeNull(); + tag.MusicBrainzArtistId.Should().BeNull(); + tag.MusicBrainzReleaseArtistId.Should().BeNull(); + tag.MusicBrainzReleaseGroupId.Should().BeNull(); + tag.MusicBrainzTrackId.Should().BeNull(); + tag.MusicBrainzAlbumComment.Should().BeNull(); + tag.MusicBrainzReleaseTrackId.Should().BeNull(); + } + + [Test, TestCaseSource(typeof(TestCaseFactory), "TestCases")] + public void should_read_audiotag_from_file_with_no_tags(string filename, string[] skipProperties) + { + GivenFileCopy(filename); + var path = copiedFile; + + Subject.RemoveAllTags(path); + + var tag = Subject.ReadAudioTag(path); + var expected = new AudioTag() { + Performers = new string[0], + AlbumArtists = new string[0], + Genres = new string[0] + }; + + VerifySame(tag, expected, skipProperties); + tag.Quality.Should().NotBeNull(); + tag.MediaInfo.Should().NotBeNull(); + } + + [Test, TestCaseSource(typeof(TestCaseFactory), "TestCases")] + public void should_read_parsedtrackinfo_from_file_with_no_tags(string filename, string[] skipProperties) + { + GivenFileCopy(filename); + var path = copiedFile; + + Subject.RemoveAllTags(path); + + var tag = Subject.ReadTags(path); + + tag.Quality.Should().NotBeNull(); + tag.MediaInfo.Should().NotBeNull(); + } + + [Test, TestCaseSource(typeof(TestCaseFactory), "TestCases")] + public void should_set_quality_and_mediainfo_for_corrupt_file(string filename, string[] skipProperties) + { + // use missing to simulate corrupt + var tag = Subject.ReadAudioTag(filename.Replace("nin", "missing")); + var expected = new AudioTag(); + + VerifySame(tag, expected, skipProperties); + tag.Quality.Should().NotBeNull(); + tag.MediaInfo.Should().NotBeNull(); + + ExceptionVerification.ExpectedErrors(1); + } + + [Test, TestCaseSource(typeof(TestCaseFactory), "TestCases")] + public void should_read_file_with_only_title_tag(string filename, string[] ignored) + { + GivenFileCopy(filename); + var path = copiedFile; + + Subject.RemoveAllTags(path); + + var nametag = new AudioTag(); + nametag.Title = "test"; + nametag.Write(path); + + var tag = Subject.ReadTags(path); + tag.Title.Should().Be("test"); + + tag.Quality.Should().NotBeNull(); + tag.MediaInfo.Should().NotBeNull(); + } + + [Test, TestCaseSource(typeof(TestCaseFactory), "TestCases")] + public void should_remove_date_from_tags_when_not_in_metadata(string filename, string[] ignored) + { + GivenFileCopy(filename); + var path = copiedFile; + + testTags.Write(path); + + testTags.Date = null; + testTags.OriginalReleaseDate = null; + + testTags.Write(path); + + var onDisk = Subject.ReadAudioTag(path); + + onDisk.Date.HasValue.Should().BeFalse(); + onDisk.OriginalReleaseDate.HasValue.Should().BeFalse(); + } + + [Test] + public void should_ignore_non_parsable_id3v23_date() + { + GivenFileCopy("nin.mp2"); + + using(var file = TagLib.File.Create(copiedFile)) + { + var id3tag = (TagLib.Id3v2.Tag) file.GetTag(TagLib.TagTypes.Id3v2); + id3tag.SetTextFrame("TORY", "0"); + file.Save(); + } + + var tag = Subject.ReadAudioTag(copiedFile); + tag.OriginalReleaseDate.HasValue.Should().BeFalse(); + } + + private TrackFile GivenPopulatedTrackfile(int mediumOffset) + { + var meta = Builder.CreateNew().Build(); + var artist = Builder.CreateNew() + .With(x => x.Metadata = meta) + .Build(); + + var album = Builder.CreateNew() + .With(x => x.Artist = artist) + .Build(); + + var media = Builder.CreateListOfSize(2).Build() as List; + media.ForEach(x => x.Number += mediumOffset); + + var release = Builder.CreateNew() + .With(x => x.Album = album) + .With(x => x.Media = media) + .With(x => x.Country = new List()) + .With(x => x.Label = new List()) + .Build(); + + var tracks = Builder.CreateListOfSize(10) + .All() + .With(x => x.AlbumRelease = release) + .With(x => x.ArtistMetadata = meta) + .TheFirst(5) + .With(x => x.MediumNumber = 1 + mediumOffset) + .TheNext(5) + .With(x => x.MediumNumber = 2 + mediumOffset) + .Build() as List; + release.Tracks = tracks; + + var file = Builder.CreateNew() + .With(x => x.Tracks = new List { tracks[0] }) + .With(x => x.Artist = artist) + .Build(); + + return file; + } + + [Test] + public void get_metadata_should_not_fail_with_missing_country() + { + var file = GivenPopulatedTrackfile(0); + var tag = Subject.GetTrackMetadata(file); + + tag.MusicBrainzReleaseCountry.Should().BeNull(); + } + + [Test] + public void should_not_fail_if_media_has_been_omitted() + { + // make sure that we aren't relying on index of items in + // Media being the same as the medium number + + var file = GivenPopulatedTrackfile(100); + var tag = Subject.GetTrackMetadata(file); + + tag.Media.Should().NotBeNull(); + } + + [TestCase("nin.mp3")] + public void write_tags_should_update_trackfile_size_and_modified(string filename) + { + Mocker.GetMock() + .Setup(x => x.ScrubAudioTags) + .Returns(true); + + GivenFileCopy(filename); + + var file = GivenPopulatedTrackfile(0); + + file.Path = copiedFile; + Subject.WriteTags(file, false, true); + + var fileInfo = _diskProvider.GetFileInfo(file.Path); + file.Modified.Should().Be(fileInfo.LastWriteTimeUtc); + file.Size.Should().Be(fileInfo.Length); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/DiskScanServiceTests/ScanFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/DiskScanServiceTests/ScanFixture.cs index 5bb18e455..1ebba5da5 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/DiskScanServiceTests/ScanFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/DiskScanServiceTests/ScanFixture.cs @@ -1,266 +1,568 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; +using System.IO.Abstractions; using System.Linq; using FizzWare.NBuilder; using Moq; using NUnit.Framework; using NzbDrone.Common.Disk; +using NzbDrone.Core.Configuration; using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.MediaFiles.EpisodeImport; +using NzbDrone.Core.MediaFiles.TrackImport; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; +using NzbDrone.Core.RootFolders; using NzbDrone.Test.Common; +using NzbDrone.Core.Parser.Model; +using FluentAssertions; +using System.IO.Abstractions.TestingHelpers; +using NzbDrone.Core.DecisionEngine; +using System; +using NzbDrone.Core.Qualities; namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests { [TestFixture] - public class ScanFixture : CoreTest + public class ScanFixture : FileSystemTest { - private Series _series; + private Artist _artist; + private string _rootFolder; + private string _otherArtistFolder; [SetUp] public void Setup() { - _series = Builder.CreateNew() - .With(s => s.Path = @"C:\Test\TV\Series".AsOsAgnostic()) + _rootFolder = @"C:\Test\Music".AsOsAgnostic(); + _otherArtistFolder = @"C:\Test\Music\OtherArtist".AsOsAgnostic(); + var artistFolder = @"C:\Test\Music\Artist".AsOsAgnostic(); + + _artist = Builder.CreateNew() + .With(s => s.Path = artistFolder) .Build(); - Mocker.GetMock() - .Setup(s => s.GetParentFolder(It.IsAny())) - .Returns((string path) => Directory.GetParent(path).FullName); + Mocker.GetMock() + .Setup(s => s.GetBestRootFolderPath(It.IsAny())) + .Returns(_rootFolder); + + Mocker.GetMock() + .Setup(v => v.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new List>()); + + Mocker.GetMock() + .Setup(v => v.GetFilesByArtist(It.IsAny())) + .Returns(new List()); + + Mocker.GetMock() + .Setup(v => v.GetFilesWithBasePath(It.IsAny())) + .Returns(new List()); + + Mocker.GetMock() + .Setup(v => v.FilterUnchangedFiles(It.IsAny>(), It.IsAny(), It.IsAny())) + .Returns((List files, Artist artist, FilterFilesType filter) => files); } - private void GivenParentFolderExists() + private void GivenRootFolder(params string[] subfolders) { - Mocker.GetMock() - .Setup(s => s.FolderExists(It.IsAny())) - .Returns(true); + FileSystem.AddDirectory(_rootFolder); - Mocker.GetMock() - .Setup(s => s.GetDirectories(It.IsAny())) - .Returns(new string[] { @"C:\Test\TV\Series2".AsOsAgnostic() }); + foreach (var folder in subfolders) + { + FileSystem.AddDirectory(folder); + } } - private void GivenFiles(IEnumerable files) + private void GivenArtistFolder() { - Mocker.GetMock() - .Setup(s => s.GetFiles(It.IsAny(), SearchOption.AllDirectories)) - .Returns(files.ToArray()); + GivenRootFolder(_artist.Path); + } + + private List GivenFiles(IEnumerable files, DateTimeOffset? lastWrite = null) + { + if (lastWrite == null) + { + TestLogger.Debug("Using default lastWrite"); + lastWrite = new DateTime(2019, 1, 1, 0, 0, 0, DateTimeKind.Utc); + } + + foreach (var file in files) + { + FileSystem.AddFile(file, new MockFileData(string.Empty) { LastWriteTime = lastWrite.Value }); + } + + return files.Select(x => DiskProvider.GetFileInfo(x)).ToList(); + } + + private void GivenKnownFiles(IEnumerable files, DateTimeOffset? lastWrite = null) + { + if (lastWrite == null) + { + TestLogger.Debug("Using default lastWrite"); + lastWrite = new DateTime(2019, 1, 1, 0, 0, 0, DateTimeKind.Utc); + } + + Mocker.GetMock() + .Setup(x => x.GetFilesWithBasePath(_artist.Path)) + .Returns(files.Select(x => new TrackFile { + Path = x, + Modified = lastWrite.Value.UtcDateTime + }).ToList()); } [Test] - public void should_not_scan_if_series_root_folder_does_not_exist() - { - Subject.Scan(_series); + public void should_not_scan_if_root_folder_does_not_exist() + { + Subject.Scan(_artist); ExceptionVerification.ExpectedWarns(1); + Mocker.GetMock() + .Verify(v => v.FolderExists(_artist.Path), Times.Never()); + Mocker.GetMock() - .Verify(v => v.Clean(It.IsAny(), It.IsAny>()), Times.Never()); + .Verify(v => v.Clean(It.IsAny(), It.IsAny>()), Times.Never()); } [Test] - public void should_not_scan_if_series_root_folder_is_empty() + public void should_not_scan_if_artist_root_folder_is_empty() { + GivenRootFolder(); + + Subject.Scan(_artist); + + ExceptionVerification.ExpectedWarns(1); + Mocker.GetMock() - .Setup(s => s.FolderExists(It.IsAny())) + .Verify(v => v.FolderExists(_artist.Path), Times.Never()); + + Mocker.GetMock() + .Verify(v => v.Clean(It.IsAny(), It.IsAny>()), Times.Never()); + + Mocker.GetMock() + .Verify(v => v.GetImportDecisions(It.IsAny>(), _artist, FilterFilesType.Known, true), Times.Never()); + } + + [Test] + public void should_create_if_artist_folder_does_not_exist_but_create_folder_enabled() + { + GivenRootFolder(_otherArtistFolder); + + Mocker.GetMock() + .Setup(s => s.CreateEmptyArtistFolders) .Returns(true); - Mocker.GetMock() - .Setup(s => s.GetDirectories(It.IsAny())) - .Returns(new string[0]); + Subject.Scan(_artist); - Subject.Scan(_series); + DiskProvider.FolderExists(_artist.Path).Should().BeTrue(); + } - ExceptionVerification.ExpectedWarns(1); + [Test] + public void should_not_create_if_artist_folder_does_not_exist_and_create_folder_disabled() + { + GivenRootFolder(_otherArtistFolder); + + Mocker.GetMock() + .Setup(s => s.CreateEmptyArtistFolders) + .Returns(false); + + Subject.Scan(_artist); + + DiskProvider.FolderExists(_artist.Path).Should().BeFalse(); + } + + [Test] + public void should_clean_but_not_import_if_artist_folder_does_not_exist() + { + GivenRootFolder(_otherArtistFolder); + + Subject.Scan(_artist); + + DiskProvider.FolderExists(_artist.Path).Should().BeFalse(); Mocker.GetMock() - .Verify(v => v.Clean(It.IsAny(), new List()), Times.Never()); + .Verify(v => v.Clean(It.IsAny(), It.IsAny>()), Times.Once()); + + Mocker.GetMock() + .Verify(v => v.GetImportDecisions(It.IsAny>(), _artist, FilterFilesType.Known, true), Times.Never()); + } + + [Test] + public void should_clean_but_not_import_if_artist_folder_does_not_exist_and_create_folder_enabled() + { + GivenRootFolder(_otherArtistFolder); + + Mocker.GetMock() + .Setup(s => s.CreateEmptyArtistFolders) + .Returns(true); + + Subject.Scan(_artist); + + Mocker.GetMock() + .Verify(v => v.Clean(It.IsAny(), It.IsAny>()), Times.Once()); + + Mocker.GetMock() + .Verify(v => v.GetImportDecisions(It.IsAny>(), _artist, FilterFilesType.Known, true), Times.Never()); + } + + [Test] + public void should_find_files_at_root_of_artist_folder() + { + GivenArtistFolder(); + + GivenFiles(new List + { + Path.Combine(_artist.Path, "file1.flac"), + Path.Combine(_artist.Path, "s01e01.flac") + }); + + Subject.Scan(_artist); + + Mocker.GetMock() + .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 2), _artist, FilterFilesType.Known, true), Times.Once()); } [Test] public void should_not_scan_extras_subfolder() { - GivenParentFolderExists(); + GivenArtistFolder(); GivenFiles(new List { - Path.Combine(_series.Path, "EXTRAS", "file1.mkv").AsOsAgnostic(), - Path.Combine(_series.Path, "Extras", "file2.mkv").AsOsAgnostic(), - Path.Combine(_series.Path, "EXTRAs", "file3.mkv").AsOsAgnostic(), - Path.Combine(_series.Path, "ExTrAs", "file4.mkv").AsOsAgnostic(), - Path.Combine(_series.Path, "Season 1", "s01e01.mkv").AsOsAgnostic() + Path.Combine(_artist.Path, "EXTRAS", "file1.flac"), + Path.Combine(_artist.Path, "Extras", "file2.flac"), + Path.Combine(_artist.Path, "EXTRAs", "file3.flac"), + Path.Combine(_artist.Path, "ExTrAs", "file4.flac"), + Path.Combine(_artist.Path, "Season 1", "s01e01.flac") }); - Subject.Scan(_series); + Subject.Scan(_artist); Mocker.GetMock() - .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), _series), Times.Once()); + .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), _artist, FilterFilesType.Known, true), Times.Once()); } [Test] public void should_not_scan_AppleDouble_subfolder() { - GivenParentFolderExists(); + GivenArtistFolder(); + + GivenFiles(new List + { + Path.Combine(_artist.Path, ".AppleDouble", "file1.flac"), + Path.Combine(_artist.Path, ".appledouble", "file2.flac"), + Path.Combine(_artist.Path, "Season 1", "s01e01.flac") + }); + + Subject.Scan(_artist); + + Mocker.GetMock() + .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), _artist, FilterFilesType.Known, true), Times.Once()); + } + + [Test] + public void should_scan_extras_artist_and_subfolders() + { + _artist.Path = @"C:\Test\Music\Extras".AsOsAgnostic(); + + GivenArtistFolder(); GivenFiles(new List { - Path.Combine(_series.Path, ".AppleDouble", "file1.mkv").AsOsAgnostic(), - Path.Combine(_series.Path, ".appledouble", "file2.mkv").AsOsAgnostic(), - Path.Combine(_series.Path, "Season 1", "s01e01.mkv").AsOsAgnostic() + Path.Combine(_artist.Path, "Extras", "file1.flac"), + Path.Combine(_artist.Path, ".AppleDouble", "file2.flac"), + Path.Combine(_artist.Path, "Season 1", "s01e01.flac"), + Path.Combine(_artist.Path, "Season 1", "s01e02.flac"), + Path.Combine(_artist.Path, "Season 2", "s02e01.flac"), + Path.Combine(_artist.Path, "Season 2", "s02e02.flac"), }); - Subject.Scan(_series); + Subject.Scan(_artist); Mocker.GetMock() - .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), _series), Times.Once()); + .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 4), _artist, FilterFilesType.Known, true), Times.Once()); } [Test] - public void should_scan_extras_series_and_subfolders() + public void should_scan_files_that_start_with_period() { - GivenParentFolderExists(); - _series.Path = @"C:\Test\TV\Extras".AsOsAgnostic(); + GivenArtistFolder(); GivenFiles(new List { - Path.Combine(_series.Path, "Extras", "file1.mkv").AsOsAgnostic(), - Path.Combine(_series.Path, ".AppleDouble", "file2.mkv").AsOsAgnostic(), - Path.Combine(_series.Path, "Season 1", "s01e01.mkv").AsOsAgnostic(), - Path.Combine(_series.Path, "Season 1", "s01e02.mkv").AsOsAgnostic(), - Path.Combine(_series.Path, "Season 2", "s02e01.mkv").AsOsAgnostic(), - Path.Combine(_series.Path, "Season 2", "s02e02.mkv").AsOsAgnostic(), + Path.Combine(_artist.Path, "Album 1", ".t01.mp3") }); - Subject.Scan(_series); + Subject.Scan(_artist); Mocker.GetMock() - .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 4), _series), Times.Once()); + .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), _artist, FilterFilesType.Known, true), Times.Once()); } [Test] public void should_not_scan_subfolders_that_start_with_period() { - GivenParentFolderExists(); + GivenArtistFolder(); GivenFiles(new List { - Path.Combine(_series.Path, ".@__thumb", "file1.mkv").AsOsAgnostic(), - Path.Combine(_series.Path, ".@__THUMB", "file2.mkv").AsOsAgnostic(), - Path.Combine(_series.Path, ".hidden", "file2.mkv").AsOsAgnostic(), - Path.Combine(_series.Path, "Season 1", "s01e01.mkv").AsOsAgnostic() + Path.Combine(_artist.Path, ".@__thumb", "file1.flac"), + Path.Combine(_artist.Path, ".@__THUMB", "file2.flac"), + Path.Combine(_artist.Path, ".hidden", "file2.flac"), + Path.Combine(_artist.Path, "Season 1", "s01e01.flac") }); - Subject.Scan(_series); + Subject.Scan(_artist); Mocker.GetMock() - .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), _series), Times.Once()); + .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), _artist, FilterFilesType.Known, true), Times.Once()); } [Test] public void should_not_scan_subfolder_of_season_folder_that_starts_with_a_period() { - GivenParentFolderExists(); + GivenArtistFolder(); GivenFiles(new List { - Path.Combine(_series.Path, "Season 1", ".@__thumb", "file1.mkv").AsOsAgnostic(), - Path.Combine(_series.Path, "Season 1", ".@__THUMB", "file2.mkv").AsOsAgnostic(), - Path.Combine(_series.Path, "Season 1", ".hidden", "file2.mkv").AsOsAgnostic(), - Path.Combine(_series.Path, "Season 1", ".AppleDouble", "s01e01.mkv").AsOsAgnostic(), - Path.Combine(_series.Path, "Season 1", "s01e01.mkv").AsOsAgnostic() + Path.Combine(_artist.Path, "Season 1", ".@__thumb", "file1.flac"), + Path.Combine(_artist.Path, "Season 1", ".@__THUMB", "file2.flac"), + Path.Combine(_artist.Path, "Season 1", ".hidden", "file2.flac"), + Path.Combine(_artist.Path, "Season 1", ".AppleDouble", "s01e01.flac"), + Path.Combine(_artist.Path, "Season 1", "s01e01.flac") }); - Subject.Scan(_series); + Subject.Scan(_artist); Mocker.GetMock() - .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), _series), Times.Once()); + .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), _artist, FilterFilesType.Known, true), Times.Once()); } [Test] public void should_not_scan_Synology_eaDir() { - GivenParentFolderExists(); + GivenArtistFolder(); GivenFiles(new List { - Path.Combine(_series.Path, "@eaDir", "file1.mkv").AsOsAgnostic(), - Path.Combine(_series.Path, "Season 1", "s01e01.mkv").AsOsAgnostic() + Path.Combine(_artist.Path, "@eaDir", "file1.flac"), + Path.Combine(_artist.Path, "Season 1", "s01e01.flac") }); - Subject.Scan(_series); + Subject.Scan(_artist); Mocker.GetMock() - .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), _series), Times.Once()); + .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), _artist, FilterFilesType.Known, true), Times.Once()); } [Test] public void should_not_scan_thumb_folder() { - GivenParentFolderExists(); + GivenArtistFolder(); GivenFiles(new List { - Path.Combine(_series.Path, ".@__thumb", "file1.mkv").AsOsAgnostic(), - Path.Combine(_series.Path, "Season 1", "s01e01.mkv").AsOsAgnostic() + Path.Combine(_artist.Path, ".@__thumb", "file1.flac"), + Path.Combine(_artist.Path, "Season 1", "s01e01.flac") }); - Subject.Scan(_series); + Subject.Scan(_artist); Mocker.GetMock() - .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), _series), Times.Once()); + .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), _artist, FilterFilesType.Known, true), Times.Once()); } [Test] public void should_scan_dotHack_folder() { - GivenParentFolderExists(); - _series.Path = @"C:\Test\TV\.hack".AsOsAgnostic(); + _artist.Path = @"C:\Test\Music\.hack".AsOsAgnostic(); + + GivenArtistFolder(); GivenFiles(new List { - Path.Combine(_series.Path, "Season 1", "file1.mkv").AsOsAgnostic(), - Path.Combine(_series.Path, "Season 1", "s01e01.mkv").AsOsAgnostic() + Path.Combine(_artist.Path, "Season 1", "file1.flac"), + Path.Combine(_artist.Path, "Season 1", "s01e01.flac") }); - Subject.Scan(_series); + Subject.Scan(_artist); Mocker.GetMock() - .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 2), _series), Times.Once()); + .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 2), _artist, FilterFilesType.Known, true), Times.Once()); } [Test] - public void should_find_files_at_root_of_series_folder() + public void should_exclude_osx_metadata_files() { - GivenParentFolderExists(); + GivenArtistFolder(); GivenFiles(new List { - Path.Combine(_series.Path, "file1.mkv").AsOsAgnostic(), - Path.Combine(_series.Path, "s01e01.mkv").AsOsAgnostic() + Path.Combine(_artist.Path, ".DS_STORE"), + Path.Combine(_artist.Path, "._24 The Status Quo Combustion.flac"), + Path.Combine(_artist.Path, "24 The Status Quo Combustion.flac") }); - Subject.Scan(_series); + Subject.Scan(_artist); + + Mocker.GetMock() + .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), _artist, FilterFilesType.Known, true), Times.Once()); + } + private void GivenRejections() + { Mocker.GetMock() - .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 2), _series), Times.Once()); + .Setup(x => x.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((List fileList, Artist artist, FilterFilesType filter, bool includeExisting) => + fileList.Select(x => new LocalTrack { + Artist = artist, + Path = x.FullName, + Modified = x.LastWriteTimeUtc, + FileTrackInfo = new ParsedTrackInfo() + }) + .Select(x => new ImportDecision(x, new Rejection("Reject"))) + .ToList()); } [Test] - public void should_exclude_osx_metadata_files() + public void should_insert_new_unmatched_files_when_all_new() { - GivenParentFolderExists(); + var files = new List { + Path.Combine(_artist.Path, "Season 1", "file1.flac"), + Path.Combine(_artist.Path, "Season 1", "s01e01.flac") + }; - GivenFiles(new List - { - Path.Combine(_series.Path, "._24 The Status Quo Combustion.mp4").AsOsAgnostic(), - Path.Combine(_series.Path, "24 The Status Quo Combustion.mkv").AsOsAgnostic() - }); + GivenFiles(files); + GivenKnownFiles(new List()); + GivenRejections(); + + Subject.Scan(_artist); + + Mocker.GetMock() + .Verify(x => x.AddMany(It.Is>(l => l.Select(t => t.Path).SequenceEqual(files))), + Times.Once()); + } + + [Test] + public void should_insert_new_unmatched_files_when_some_known() + { + var files = new List { + Path.Combine(_artist.Path, "Season 1", "file1.flac"), + Path.Combine(_artist.Path, "Season 1", "s01e01.flac") + }; + + GivenFiles(files); + GivenKnownFiles(files.GetRange(1, 1)); + GivenRejections(); + + Subject.Scan(_artist); + + Mocker.GetMock() + .Verify(x => x.AddMany(It.Is>(l => l.Select(t => t.Path).SequenceEqual(files.GetRange(0, 1)))), + Times.Once()); + } + + [Test] + public void should_not_insert_files_when_all_known() + { + var files = new List { + Path.Combine(_artist.Path, "Season 1", "file1.flac"), + Path.Combine(_artist.Path, "Season 1", "s01e01.flac") + }; + + GivenFiles(files); + GivenKnownFiles(files); + GivenRejections(); + + Subject.Scan(_artist); + + Mocker.GetMock() + .Verify(x => x.AddMany(It.Is>(l => l.Count == 0)), + Times.Once()); + + Mocker.GetMock() + .Verify(x => x.AddMany(It.Is>(l => l.Count > 0)), + Times.Never()); + } + + [Test] + public void should_not_update_info_for_unchanged_known_files() + { + var files = new List { + Path.Combine(_artist.Path, "Season 1", "file1.flac"), + Path.Combine(_artist.Path, "Season 1", "s01e01.flac") + }; + + GivenFiles(files); + GivenKnownFiles(files); + GivenRejections(); + + Subject.Scan(_artist); + + Mocker.GetMock() + .Verify(x => x.Update(It.Is>(l => l.Count == 0)), + Times.Once()); + + Mocker.GetMock() + .Verify(x => x.Update(It.Is>(l => l.Count > 0)), + Times.Never()); + + } + + [Test] + public void should_update_info_for_changed_known_files() + { + var files = new List { + Path.Combine(_artist.Path, "Season 1", "file1.flac"), + Path.Combine(_artist.Path, "Season 1", "s01e01.flac") + }; + + GivenFiles(files, new DateTime(2019, 2, 1)); + GivenKnownFiles(files); + GivenRejections(); + + Subject.Scan(_artist); + + Mocker.GetMock() + .Verify(x => x.Update(It.Is>(l => l.Count == 2)), + Times.Once()); + } + + [Test] + public void should_update_fields_for_updated_files() + { + var files = new List { + Path.Combine(_artist.Path, "Season 1", "file1.flac"), + }; + + GivenKnownFiles(files); + + FileSystem.AddFile(files[0], new MockFileData("".PadRight(100)) { LastWriteTime = new DateTime(2019, 2, 1) }); - Subject.Scan(_series); + var localTrack = Builder.CreateNew() + .With(x => x.Path = files[0]) + .With(x => x.Modified = new DateTime(2019, 2, 1)) + .With(x => x.Size = 100) + .With(x => x.Quality = new QualityModel(Quality.FLAC)) + .With(x => x.FileTrackInfo = new ParsedTrackInfo { + MediaInfo = Builder.CreateNew().Build() + }) + .Build(); Mocker.GetMock() - .Verify(v => v.GetImportDecisions(It.Is>(l => l.Count == 1), _series), Times.Once()); + .Setup(x => x.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new List> { new ImportDecision(localTrack, new Rejection("Reject")) }); + + Subject.Scan(_artist); + + Mocker.GetMock() + .Verify(x => x.Update(It.Is>( + l => l.Count == 1 && + l[0].Path == localTrack.Path && + l[0].Modified == localTrack.Modified && + l[0].Size == localTrack.Size && + l[0].Quality.Equals(localTrack.Quality) && + l[0].MediaInfo.AudioFormat == localTrack.FileTrackInfo.MediaInfo.AudioFormat + )), + Times.Once()); } } } diff --git a/src/NzbDrone.Core.Test/MediaFiles/DownloadedAlbumsCommandServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/DownloadedAlbumsCommandServiceFixture.cs new file mode 100644 index 000000000..dacf30aaf --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/DownloadedAlbumsCommandServiceFixture.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.IO.Abstractions; +using FizzWare.NBuilder; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Download; +using NzbDrone.Core.Download.TrackedDownloads; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.Commands; +using NzbDrone.Core.MediaFiles.TrackImport; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; +using NzbDrone.Test.Common; +using System.IO.Abstractions.TestingHelpers; + +namespace NzbDrone.Core.Test.MediaFiles +{ + [TestFixture] + public class DownloadedAlbumsCommandServiceFixture : FileSystemTest + { + private string _downloadFolder = "c:\\drop_other\\Show.S01E01\\".AsOsAgnostic(); + private string _downloadFile = "c:\\drop_other\\Show.S01E01.mkv".AsOsAgnostic(); + + private TrackedDownload _trackedDownload; + + [SetUp] + public void Setup() + { + + Mocker.GetMock() + .Setup(v => v.ProcessRootFolder(It.IsAny())) + .Returns(new List()); + + Mocker.GetMock() + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new List()); + + var downloadItem = Builder.CreateNew() + .With(v => v.DownloadId = "sab1") + .With(v => v.Status = DownloadItemStatus.Downloading) + .Build(); + + var remoteAlbum = Builder.CreateNew() + .With(v => v.Artist = new Artist()) + .Build(); + + _trackedDownload = new TrackedDownload + { + DownloadItem = downloadItem, + RemoteAlbum = remoteAlbum, + State = TrackedDownloadStage.Downloading + }; + } + + private void GivenExistingFolder(string path) + { + FileSystem.AddDirectory(path); + } + + private void GivenExistingFile(string path) + { + FileSystem.AddFile(path, new MockFileData(string.Empty)); + } + + private void GivenValidQueueItem() + { + Mocker.GetMock() + .Setup(s => s.Find("sab1")) + .Returns(_trackedDownload); + } + + [Test] + public void should_skip_import_if_dronefactory_doesnt_exist() + { + Assert.Throws(() => Subject.Execute(new DownloadedAlbumsScanCommand())); + + Mocker.GetMock().Verify(c => c.ProcessRootFolder(It.IsAny()), Times.Never()); + + } + + + [Test] + public void should_process_folder_if_downloadclientid_is_not_specified() + { + GivenExistingFolder(_downloadFolder); + + Subject.Execute(new DownloadedAlbumsScanCommand() { Path = _downloadFolder }); + + Mocker.GetMock().Verify(c => c.ProcessPath(It.IsAny(), ImportMode.Auto, null, null), Times.Once()); + } + + [Test] + public void should_process_file_if_downloadclientid_is_not_specified() + { + GivenExistingFile(_downloadFile); + + Subject.Execute(new DownloadedAlbumsScanCommand() { Path = _downloadFile }); + + Mocker.GetMock().Verify(c => c.ProcessPath(It.IsAny(), ImportMode.Auto, null, null), Times.Once()); + } + + [Test] + public void should_process_folder_with_downloadclientitem_if_available() + { + GivenExistingFolder(_downloadFolder); + GivenValidQueueItem(); + + Subject.Execute(new DownloadedAlbumsScanCommand() { Path = _downloadFolder, DownloadClientId = "sab1" }); + + Mocker.GetMock().Verify(c => c.ProcessPath(_downloadFolder, ImportMode.Auto, _trackedDownload.RemoteAlbum.Artist, _trackedDownload.DownloadItem), Times.Once()); + } + + [Test] + public void should_process_folder_without_downloadclientitem_if_not_available() + { + GivenExistingFolder(_downloadFolder); + + Subject.Execute(new DownloadedAlbumsScanCommand() { Path = _downloadFolder, DownloadClientId = "sab1" }); + + Mocker.GetMock().Verify(c => c.ProcessPath(_downloadFolder, ImportMode.Auto, null, null), Times.Once()); + + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + public void should_warn_if_neither_folder_or_file_exists() + { + Subject.Execute(new DownloadedAlbumsScanCommand() { Path = _downloadFolder }); + + Mocker.GetMock().Verify(c => c.ProcessPath(It.IsAny(), ImportMode.Auto, null, null), Times.Never()); + + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + public void should_override_import_mode() + { + GivenExistingFile(_downloadFile); + + Subject.Execute(new DownloadedAlbumsScanCommand() { Path = _downloadFile, ImportMode = ImportMode.Copy }); + + Mocker.GetMock().Verify(c => c.ProcessPath(It.IsAny(), ImportMode.Copy, null, null), Times.Once()); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesCommandServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesCommandServiceFixture.cs deleted file mode 100644 index 2ea63e183..000000000 --- a/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesCommandServiceFixture.cs +++ /dev/null @@ -1,172 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using FizzWare.NBuilder; -using Moq; -using NUnit.Framework; -using NzbDrone.Common.Disk; -using NzbDrone.Core.Configuration; -using NzbDrone.Core.Download; -using NzbDrone.Core.Download.TrackedDownloads; -using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.MediaFiles.Commands; -using NzbDrone.Core.MediaFiles.EpisodeImport; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; -using NzbDrone.Test.Common; - -namespace NzbDrone.Core.Test.MediaFiles -{ - [TestFixture] - public class DownloadedEpisodesCommandServiceFixture : CoreTest - { - private string _droneFactory = "c:\\drop\\".AsOsAgnostic(); - private string _downloadFolder = "c:\\drop_other\\Show.S01E01\\".AsOsAgnostic(); - private string _downloadFile = "c:\\drop_other\\Show.S01E01.mkv".AsOsAgnostic(); - - private TrackedDownload _trackedDownload; - - [SetUp] - public void Setup() - { - Mocker.GetMock().SetupGet(c => c.DownloadedEpisodesFolder) - .Returns(_droneFactory); - - Mocker.GetMock() - .Setup(v => v.ProcessRootFolder(It.IsAny())) - .Returns(new List()); - - Mocker.GetMock() - .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .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 GivenExistingFolder(string path) - { - Mocker.GetMock().Setup(c => c.FolderExists(It.IsAny())) - .Returns(true); - } - - private void GivenExistingFile(string path) - { - Mocker.GetMock().Setup(c => c.FileExists(It.IsAny())) - .Returns(true); - } - - private void GivenValidQueueItem() - { - Mocker.GetMock() - .Setup(s => s.Find("sab1")) - .Returns(_trackedDownload); - } - - [Test] - public void should_process_dronefactory_if_path_is_not_specified() - { - GivenExistingFolder(_droneFactory); - - Subject.Execute(new DownloadedEpisodesScanCommand()); - - Mocker.GetMock().Verify(c => c.ProcessRootFolder(It.IsAny()), Times.Once()); - } - - [Test] - public void should_skip_import_if_dronefactory_doesnt_exist() - { - Subject.Execute(new DownloadedEpisodesScanCommand()); - - Mocker.GetMock().Verify(c => c.ProcessRootFolder(It.IsAny()), Times.Never()); - - ExceptionVerification.ExpectedWarns(1); - } - - [Test] - public void should_ignore_downloadclientid_if_path_is_not_specified() - { - GivenExistingFolder(_droneFactory); - - Subject.Execute(new DownloadedEpisodesScanCommand() { DownloadClientId = "sab1" }); - - Mocker.GetMock().Verify(c => c.ProcessRootFolder(It.IsAny()), Times.Once()); - } - - [Test] - public void should_process_folder_if_downloadclientid_is_not_specified() - { - GivenExistingFolder(_downloadFolder); - - Subject.Execute(new DownloadedEpisodesScanCommand() { Path = _downloadFolder }); - - Mocker.GetMock().Verify(c => c.ProcessPath(It.IsAny(), ImportMode.Auto, null, null), Times.Once()); - } - - [Test] - public void should_process_file_if_downloadclientid_is_not_specified() - { - GivenExistingFile(_downloadFile); - - Subject.Execute(new DownloadedEpisodesScanCommand() { Path = _downloadFile }); - - Mocker.GetMock().Verify(c => c.ProcessPath(It.IsAny(), ImportMode.Auto, null, null), Times.Once()); - } - - [Test] - public void should_process_folder_with_downloadclientitem_if_available() - { - GivenExistingFolder(_downloadFolder); - GivenValidQueueItem(); - - Subject.Execute(new DownloadedEpisodesScanCommand() { Path = _downloadFolder, DownloadClientId = "sab1" }); - - Mocker.GetMock().Verify(c => c.ProcessPath(_downloadFolder, ImportMode.Auto, _trackedDownload.RemoteEpisode.Series, _trackedDownload.DownloadItem), Times.Once()); - } - - [Test] - public void should_process_folder_without_downloadclientitem_if_not_available() - { - GivenExistingFolder(_downloadFolder); - - Subject.Execute(new DownloadedEpisodesScanCommand() { Path = _downloadFolder, DownloadClientId = "sab1" }); - - Mocker.GetMock().Verify(c => c.ProcessPath(_downloadFolder, ImportMode.Auto, null, null), Times.Once()); - - ExceptionVerification.ExpectedWarns(1); - } - - [Test] - public void should_warn_if_neither_folder_or_file_exists() - { - Subject.Execute(new DownloadedEpisodesScanCommand() { Path = _downloadFolder }); - - Mocker.GetMock().Verify(c => c.ProcessPath(It.IsAny(), ImportMode.Auto, null, null), Times.Never()); - - ExceptionVerification.ExpectedWarns(1); - } - - [Test] - public void should_override_import_mode() - { - GivenExistingFile(_downloadFile); - - Subject.Execute(new DownloadedEpisodesScanCommand() { Path = _downloadFile, ImportMode = ImportMode.Copy }); - - Mocker.GetMock().Verify(c => c.ProcessPath(It.IsAny(), ImportMode.Copy, null, null), Times.Once()); - } - } -} diff --git a/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesImportServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesImportServiceFixture.cs deleted file mode 100644 index 0fd99b058..000000000 --- a/src/NzbDrone.Core.Test/MediaFiles/DownloadedEpisodesImportServiceFixture.cs +++ /dev/null @@ -1,377 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Linq; -using FizzWare.NBuilder; -using Moq; -using NUnit.Framework; -using NzbDrone.Common.Disk; -using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.MediaFiles.EpisodeImport; -using NzbDrone.Core.Parser; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Qualities; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; -using NzbDrone.Test.Common; -using FluentAssertions; - -namespace NzbDrone.Core.Test.MediaFiles -{ - [TestFixture] - public class DownloadedEpisodesImportServiceFixture : CoreTest - { - private string _droneFactory = "c:\\drop\\".AsOsAgnostic(); - private string[] _subFolders = new[] { "c:\\root\\foldername".AsOsAgnostic() }; - private string[] _videoFiles = new[] { "c:\\root\\foldername\\30.rock.s01e01.ext".AsOsAgnostic() }; - - [SetUp] - public void Setup() - { - Mocker.GetMock().Setup(c => c.GetVideoFiles(It.IsAny(), It.IsAny())) - .Returns(_videoFiles); - - Mocker.GetMock().Setup(c => c.GetDirectories(It.IsAny())) - .Returns(_subFolders); - - Mocker.GetMock().Setup(c => c.FolderExists(It.IsAny())) - .Returns(true); - - Mocker.GetMock() - .Setup(s => s.Import(It.IsAny>(), true, null, ImportMode.Auto)) - .Returns(new List()); - } - - private void GivenValidSeries() - { - Mocker.GetMock() - .Setup(s => s.GetSeries(It.IsAny())) - .Returns(Builder.CreateNew().Build()); - } - - [Test] - public void should_search_for_series_using_folder_name() - { - Subject.ProcessRootFolder(new DirectoryInfo(_droneFactory)); - - Mocker.GetMock().Verify(c => c.GetSeries("foldername"), Times.Once()); - } - - [Test] - public void should_skip_if_file_is_in_use_by_another_process() - { - GivenValidSeries(); - - Mocker.GetMock().Setup(c => c.IsFileLocked(It.IsAny())) - .Returns(true); - - Subject.ProcessRootFolder(new DirectoryInfo(_droneFactory)); - - VerifyNoImport(); - } - - [Test] - public void should_skip_if_no_series_found() - { - Mocker.GetMock().Setup(c => c.GetSeries("foldername")).Returns((Series)null); - - Subject.ProcessRootFolder(new DirectoryInfo(_droneFactory)); - - Mocker.GetMock() - .Verify(c => c.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny()), - Times.Never()); - - VerifyNoImport(); - } - - [Test] - public void should_not_import_if_folder_is_a_series_path() - { - GivenValidSeries(); - - Mocker.GetMock() - .Setup(s => s.SeriesPathExists(It.IsAny())) - .Returns(true); - - Mocker.GetMock() - .Setup(c => c.GetVideoFiles(It.IsAny(), It.IsAny())) - .Returns(new string[0]); - - Subject.ProcessRootFolder(new DirectoryInfo(_droneFactory)); - - Mocker.GetMock() - .Verify(v => v.GetVideoFiles(It.IsAny(), true), Times.Never()); - - ExceptionVerification.ExpectedWarns(1); - } - - [Test] - public void should_not_delete_folder_if_no_files_were_imported() - { - Mocker.GetMock() - .Setup(s => s.Import(It.IsAny>(), false, null, ImportMode.Auto)) - .Returns(new List()); - - Subject.ProcessRootFolder(new DirectoryInfo(_droneFactory)); - - Mocker.GetMock() - .Verify(v => v.GetFolderSize(It.IsAny()), Times.Never()); - } - - [Test] - public void should_not_delete_folder_if_files_were_imported_and_video_files_remain() - { - GivenValidSeries(); - - var localEpisode = new LocalEpisode(); - - var imported = new List(); - imported.Add(new ImportDecision(localEpisode)); - - Mocker.GetMock() - .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), null, true)) - .Returns(imported); - - Mocker.GetMock() - .Setup(s => s.Import(It.IsAny>(), true, null, ImportMode.Auto)) - .Returns(imported.Select(i => new ImportResult(i)).ToList()); - - Subject.ProcessRootFolder(new DirectoryInfo(_droneFactory)); - - Mocker.GetMock() - .Verify(v => v.DeleteFolder(It.IsAny(), true), Times.Never()); - - ExceptionVerification.ExpectedWarns(1); - } - - [Test] - public void should_delete_folder_if_files_were_imported_and_only_sample_files_remain() - { - GivenValidSeries(); - - var localEpisode = new LocalEpisode(); - - var imported = new List(); - imported.Add(new ImportDecision(localEpisode)); - - Mocker.GetMock() - .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), null, true)) - .Returns(imported); - - Mocker.GetMock() - .Setup(s => s.Import(It.IsAny>(), true, null, ImportMode.Auto)) - .Returns(imported.Select(i => new ImportResult(i)).ToList()); - - Mocker.GetMock() - .Setup(s => s.IsSample(It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .Returns(true); - - Subject.ProcessRootFolder(new DirectoryInfo(_droneFactory)); - - Mocker.GetMock() - .Verify(v => v.DeleteFolder(It.IsAny(), true), Times.Once()); - } - - [TestCase("_UNPACK_")] - [TestCase("_FAILED_")] - public void should_remove_unpack_from_folder_name(string prefix) - { - var folderName = "30.rock.s01e01.pilot.hdtv-lol"; - var folders = new[] { string.Format(@"C:\Test\Unsorted\{0}{1}", prefix, folderName).AsOsAgnostic() }; - - Mocker.GetMock() - .Setup(c => c.GetDirectories(It.IsAny())) - .Returns(folders); - - Subject.ProcessRootFolder(new DirectoryInfo(_droneFactory)); - - Mocker.GetMock() - .Verify(v => v.GetSeries(folderName), Times.Once()); - - Mocker.GetMock() - .Verify(v => v.GetSeries(It.Is(s => s.StartsWith(prefix))), Times.Never()); - } - - [Test] - public void should_return_importresult_on_unknown_series() - { - Mocker.GetMock().Setup(c => c.FolderExists(It.IsAny())) - .Returns(false); - - Mocker.GetMock().Setup(c => c.FileExists(It.IsAny())) - .Returns(true); - - var fileName = @"C:\folder\file.mkv".AsOsAgnostic(); - - var result = Subject.ProcessPath(fileName); - - result.Should().HaveCount(1); - result.First().ImportDecision.Should().NotBeNull(); - result.First().ImportDecision.LocalEpisode.Should().NotBeNull(); - result.First().ImportDecision.LocalEpisode.Path.Should().Be(fileName); - result.First().Result.Should().Be(ImportResultType.Rejected); - } - - [Test] - public void should_not_delete_if_there_is_large_rar_file() - { - GivenValidSeries(); - - var localEpisode = new LocalEpisode(); - - var imported = new List(); - imported.Add(new ImportDecision(localEpisode)); - - Mocker.GetMock() - .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), null, true)) - .Returns(imported); - - Mocker.GetMock() - .Setup(s => s.Import(It.IsAny>(), true, null, ImportMode.Auto)) - .Returns(imported.Select(i => new ImportResult(i)).ToList()); - - Mocker.GetMock() - .Setup(s => s.IsSample(It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .Returns(true); - - Mocker.GetMock() - .Setup(s => s.GetFiles(It.IsAny(), SearchOption.AllDirectories)) - .Returns(new []{ _videoFiles.First().Replace(".ext", ".rar") }); - - Mocker.GetMock() - .Setup(s => s.GetFileSize(It.IsAny())) - .Returns(15.Megabytes()); - - Subject.ProcessRootFolder(new DirectoryInfo(_droneFactory)); - - Mocker.GetMock() - .Verify(v => v.DeleteFolder(It.IsAny(), true), Times.Never()); - - ExceptionVerification.ExpectedWarns(1); - } - - [Test] - public void should_use_folder_if_folder_import() - { - GivenValidSeries(); - - var folderName = @"C:\media\ba09030e-1234-1234-1234-123456789abc\[HorribleSubs] Maria the Virgin Witch - 09 [720p]".AsOsAgnostic(); - var fileName = @"C:\media\ba09030e-1234-1234-1234-123456789abc\[HorribleSubs] Maria the Virgin Witch - 09 [720p]\[HorribleSubs] Maria the Virgin Witch - 09 [720p].mkv".AsOsAgnostic(); - - Mocker.GetMock().Setup(c => c.FolderExists(folderName)) - .Returns(true); - - Mocker.GetMock().Setup(c => c.GetFiles(folderName, SearchOption.TopDirectoryOnly)) - .Returns(new[] { fileName }); - - var localEpisode = new LocalEpisode(); - - var imported = new List(); - imported.Add(new ImportDecision(localEpisode)); - - - Subject.ProcessPath(fileName); - - Mocker.GetMock() - .Verify(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.Is(v => v.AbsoluteEpisodeNumbers.First() == 9), true), Times.Once()); - } - - [Test] - public void should_not_use_folder_if_file_import() - { - GivenValidSeries(); - - var fileName = @"C:\media\ba09030e-1234-1234-1234-123456789abc\Torrents\[HorribleSubs] Maria the Virgin Witch - 09 [720p].mkv".AsOsAgnostic(); - - Mocker.GetMock().Setup(c => c.FolderExists(fileName)) - .Returns(false); - - Mocker.GetMock().Setup(c => c.FileExists(fileName)) - .Returns(true); - - var localEpisode = new LocalEpisode(); - - var imported = new List(); - imported.Add(new ImportDecision(localEpisode)); - - var result = Subject.ProcessPath(fileName); - - Mocker.GetMock() - .Verify(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), null, true), Times.Once()); - } - - [Test] - public void should_not_process_if_file_and_folder_do_not_exist() - { - var folderName = @"C:\media\ba09030e-1234-1234-1234-123456789abc\[HorribleSubs] Maria the Virgin Witch - 09 [720p]".AsOsAgnostic(); - - Mocker.GetMock().Setup(c => c.FolderExists(folderName)) - .Returns(false); - - Mocker.GetMock().Setup(c => c.FileExists(folderName)) - .Returns(false); - - Subject.ProcessPath(folderName).Should().BeEmpty(); - - Mocker.GetMock() - .Verify(v => v.GetSeries(It.IsAny()), Times.Never()); - - ExceptionVerification.ExpectedErrors(1); - } - - [Test] - public void should_not_delete_if_no_files_were_imported() - { - GivenValidSeries(); - - var localEpisode = new LocalEpisode(); - - var imported = new List(); - imported.Add(new ImportDecision(localEpisode)); - - Mocker.GetMock() - .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), null, true)) - .Returns(imported); - - Mocker.GetMock() - .Setup(s => s.Import(It.IsAny>(), true, null, ImportMode.Auto)) - .Returns(new List()); - - Mocker.GetMock() - .Setup(s => s.IsSample(It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .Returns(true); - - Mocker.GetMock() - .Setup(s => s.GetFileSize(It.IsAny())) - .Returns(15.Megabytes()); - - Subject.ProcessRootFolder(new DirectoryInfo(_droneFactory)); - - 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), - Times.Never()); - } - - private void VerifyImport() - { - Mocker.GetMock().Verify(c => c.Import(It.IsAny>(), true, null, ImportMode.Auto), - Times.Once()); - } - } -} diff --git a/src/NzbDrone.Core.Test/MediaFiles/DownloadedTracksImportServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/DownloadedTracksImportServiceFixture.cs new file mode 100644 index 000000000..1aada9a68 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/DownloadedTracksImportServiceFixture.cs @@ -0,0 +1,351 @@ +using System.Collections.Generic; +using System.IO.Abstractions; +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.TrackImport; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; +using NzbDrone.Test.Common; +using System.IO.Abstractions.TestingHelpers; +using System.IO; + +namespace NzbDrone.Core.Test.MediaFiles +{ + [TestFixture] + public class DownloadedTracksImportServiceFixture : FileSystemTest + { + private string _droneFactory = "c:\\drop\\".AsOsAgnostic(); + private string[] _subFolders = new[] { "c:\\drop\\foldername".AsOsAgnostic() }; + private string[] _audioFiles = new[] { "c:\\drop\\foldername\\01 the first track.ext".AsOsAgnostic() }; + + private TrackedDownload _trackedDownload; + + [SetUp] + public void Setup() + { + GivenAudioFiles(_audioFiles, 10); + + Mocker.GetMock().Setup(c => c.GetAudioFiles(It.IsAny(), It.IsAny())) + .Returns(_audioFiles.Select(x => DiskProvider.GetFileInfo(x)).ToArray()); + + Mocker.GetMock().Setup(c => c.FilterFiles(It.IsAny(), It.IsAny>())) + .Returns>((b, s) => s.ToList()); + + 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 remoteAlbum = Builder.CreateNew() + .With(v => v.Artist = new Artist()) + .Build(); + + _trackedDownload = new TrackedDownload + + { + DownloadItem = downloadItem, + RemoteAlbum = remoteAlbum, + State = TrackedDownloadStage.Downloading + }; + } + + private void GivenAudioFiles(string[] files, long filesize) + { + foreach (var file in files) + { + FileSystem.AddFile(file, new MockFileData("".PadRight((int)filesize))); + } + } + + private void GivenValidArtist() + { + Mocker.GetMock() + .Setup(s => s.GetArtist(It.IsAny())) + .Returns(Builder.CreateNew().Build()); + } + + private void GivenSuccessfulImport() + { + var localTrack = new LocalTrack(); + + var imported = new List>(); + imported.Add(new ImportDecision(localTrack)); + + Mocker.GetMock() + .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), null)) + .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.GetAudioFiles(It.IsAny(), It.IsAny())) + .Returns(new IFileInfo[0]); + } + + [Test] + public void should_search_for_artist_using_folder_name() + { + Subject.ProcessRootFolder(DiskProvider.GetDirectoryInfo(_droneFactory)); + + Mocker.GetMock().Verify(c => c.GetArtist("foldername"), Times.Once()); + } + + [Test] + public void should_skip_if_file_is_in_use_by_another_process() + { + GivenValidArtist(); + + foreach (var file in _audioFiles) + { + FileSystem.AddFile(file, new MockFileData("".PadRight(10)) { AllowedFileShare = FileShare.None }); + } + + Subject.ProcessRootFolder(DiskProvider.GetDirectoryInfo(_droneFactory)); + + VerifyNoImport(); + } + + [Test] + public void should_skip_if_no_artist_found() + { + Mocker.GetMock().Setup(c => c.GetArtist("foldername")).Returns((Artist)null); + + Subject.ProcessRootFolder(DiskProvider.GetDirectoryInfo(_droneFactory)); + + Mocker.GetMock() + .Verify(c => c.GetImportDecisions(It.IsAny>(), It.IsAny(), It.IsAny()), + Times.Never()); + + VerifyNoImport(); + } + + [Test] + public void should_not_import_if_folder_is_a_artist_path() + { + GivenValidArtist(); + + Mocker.GetMock() + .Setup(s => s.ArtistPathExists(It.IsAny())) + .Returns(true); + + Mocker.GetMock() + .Setup(c => c.GetAudioFiles(It.IsAny(), It.IsAny())) + .Returns(new IFileInfo[0]); + + Subject.ProcessRootFolder(DiskProvider.GetDirectoryInfo(_droneFactory)); + + Mocker.GetMock() + .Verify(v => v.GetAudioFiles(It.IsAny(), true), Times.Never()); + + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + public void should_not_delete_folder_if_no_files_were_imported() + { + Mocker.GetMock() + .Setup(s => s.Import(It.IsAny>>(), false, null, ImportMode.Auto)) + .Returns(new List()); + + Subject.ProcessRootFolder(DiskProvider.GetDirectoryInfo(_droneFactory)); + + Mocker.GetMock() + .Verify(v => v.GetFolderSize(It.IsAny()), Times.Never()); + } + + [Test] + public void should_not_delete_folder_if_files_were_imported_and_audio_files_remain() + { + GivenValidArtist(); + + var localTrack = new LocalTrack(); + + var imported = new List>(); + imported.Add(new ImportDecision(localTrack)); + + Mocker.GetMock() + .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), null)) + .Returns(imported); + + Mocker.GetMock() + .Setup(s => s.Import(It.IsAny>>(), true, null, ImportMode.Auto)) + .Returns(imported.Select(i => new ImportResult(i)).ToList()); + + Subject.ProcessRootFolder(DiskProvider.GetDirectoryInfo(_droneFactory)); + + Mocker.GetMock() + .Verify(v => v.DeleteFolder(It.IsAny(), true), Times.Never()); + + ExceptionVerification.ExpectedWarns(1); + } + + [TestCase("_UNPACK_")] + [TestCase("_FAILED_")] + public void should_remove_unpack_from_folder_name(string prefix) + { + var folderName = "Alien Ant Farm - Truant (2003)"; + FileSystem.AddDirectory(string.Format(@"C:\drop\{0}{1}", prefix, folderName).AsOsAgnostic()); + + Subject.ProcessRootFolder(DiskProvider.GetDirectoryInfo(_droneFactory)); + + Mocker.GetMock() + .Verify(v => v.GetArtist(folderName), Times.Once()); + + Mocker.GetMock() + .Verify(v => v.GetArtist(It.Is(s => s.StartsWith(prefix))), Times.Never()); + } + + [Test] + public void should_return_importresult_on_unknown_artist() + { + var fileName = @"C:\folder\file.mkv".AsOsAgnostic(); + FileSystem.AddFile(fileName, new MockFileData(string.Empty)); + + var result = Subject.ProcessPath(fileName); + + result.Should().HaveCount(1); + result.First().ImportDecision.Should().NotBeNull(); + result.First().ImportDecision.Item.Should().NotBeNull(); + result.First().ImportDecision.Item.Path.Should().Be(fileName); + result.First().Result.Should().Be(ImportResultType.Rejected); + } + + [Test] + public void should_not_delete_if_there_is_large_rar_file() + { + GivenValidArtist(); + + var localTrack = new LocalTrack(); + + var imported = new List>(); + imported.Add(new ImportDecision(localTrack)); + + Mocker.GetMock() + .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), null)) + .Returns(imported); + + Mocker.GetMock() + .Setup(s => s.Import(It.IsAny>>(), true, null, ImportMode.Auto)) + .Returns(imported.Select(i => new ImportResult(i)).ToList()); + + GivenAudioFiles(new []{ _audioFiles.First().Replace(".ext", ".rar") }, 15.Megabytes()); + + Subject.ProcessRootFolder(DiskProvider.GetDirectoryInfo(_droneFactory)); + + DiskProvider.FolderExists(_subFolders[0]).Should().BeTrue(); + + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + public void should_not_process_if_file_and_folder_do_not_exist() + { + var folderName = @"C:\media\ba09030e-1234-1234-1234-123456789abc\[HorribleSubs] Maria the Virgin Witch - 09 [720p]".AsOsAgnostic(); + + Subject.ProcessPath(folderName).Should().BeEmpty(); + + Mocker.GetMock() + .Verify(v => v.GetArtist(It.IsAny()), Times.Never()); + + ExceptionVerification.ExpectedErrors(1); + } + + [Test] + public void should_not_delete_if_no_files_were_imported() + { + GivenValidArtist(); + + var localTrack = new LocalTrack(); + + var imported = new List>(); + imported.Add(new ImportDecision(localTrack)); + + Mocker.GetMock() + .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), null)) + .Returns(imported); + + Mocker.GetMock() + .Setup(s => s.Import(It.IsAny>>(), true, null, ImportMode.Auto)) + .Returns(new List()); + + Subject.ProcessRootFolder(DiskProvider.GetDirectoryInfo(_droneFactory)); + + DiskProvider.FolderExists(_subFolders[0]).Should().BeTrue(); + + Mocker.GetMock() + .Verify(v => v.DeleteFolder(It.IsAny(), true), Times.Never()); + } + + [Test] + public void should_not_delete_folder_after_import() + { + GivenValidArtist(); + + GivenSuccessfulImport(); + + _trackedDownload.DownloadItem.CanMoveFiles = false; + + Subject.ProcessPath(_droneFactory, ImportMode.Auto, _trackedDownload.RemoteAlbum.Artist, _trackedDownload.DownloadItem); + + DiskProvider.FolderExists(_subFolders[0]).Should().BeTrue(); + } + + [Test] + public void should_delete_folder_if_importmode_move() + { + GivenValidArtist(); + + GivenSuccessfulImport(); + + _trackedDownload.DownloadItem.CanMoveFiles = false; + + Subject.ProcessPath(_droneFactory, ImportMode.Move, _trackedDownload.RemoteAlbum.Artist, _trackedDownload.DownloadItem); + + DiskProvider.FolderExists(_subFolders[0]).Should().BeFalse(); + } + + [Test] + public void should_not_delete_folder_if_importmode_copy() + { + GivenValidArtist(); + + GivenSuccessfulImport(); + + _trackedDownload.DownloadItem.CanMoveFiles = true; + + Subject.ProcessPath(_droneFactory, ImportMode.Copy, _trackedDownload.RemoteAlbum.Artist, _trackedDownload.DownloadItem); + + DiskProvider.FolderExists(_subFolders[0]).Should().BeTrue(); + } + + private void VerifyNoImport() + { + Mocker.GetMock().Verify(c => c.Import(It.IsAny>>(), true, null, ImportMode.Auto), + Times.Never()); + } + + private void VerifyImport() + { + Mocker.GetMock().Verify(c => c.Import(It.IsAny>>(), true, null, ImportMode.Auto), + Times.Once()); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/EpisodeFileMovingServiceTests/MoveEpisodeFileFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeFileMovingServiceTests/MoveEpisodeFileFixture.cs deleted file mode 100644 index 595a19dd4..000000000 --- a/src/NzbDrone.Core.Test/MediaFiles/EpisodeFileMovingServiceTests/MoveEpisodeFileFixture.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using FizzWare.NBuilder; -using Moq; -using NUnit.Framework; -using NzbDrone.Common.Disk; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.MediaFiles.Events; -using NzbDrone.Core.Messaging.Events; -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.EpisodeFileMovingServiceTests -{ - [TestFixture] - public class MoveEpisodeFileFixture : CoreTest - { - private Series _series; - private EpisodeFile _episodeFile; - private LocalEpisode _localEpisode; - - [SetUp] - public void Setup() - { - _series = Builder.CreateNew() - .With(s => s.Path = @"C:\Test\TV\Series".AsOsAgnostic()) - .Build(); - - _episodeFile = Builder.CreateNew() - .With(f => f.Path = null) - .With(f => f.RelativePath = @"Season 1\File.avi") - .Build(); - - _localEpisode = Builder.CreateNew() - .With(l => l.Series = _series) - .With(l => l.Episodes = Builder.CreateListOfSize(1).Build().ToList()) - .Build(); - - Mocker.GetMock() - .Setup(s => s.BuildFileName(It.IsAny>(), It.IsAny(), It.IsAny(), null)) - .Returns("File Name"); - - Mocker.GetMock() - .Setup(s => s.BuildFilePath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(@"C:\Test\TV\Series\Season 01\File Name.avi".AsOsAgnostic()); - - Mocker.GetMock() - .Setup(s => s.BuildSeasonPath(It.IsAny(), It.IsAny())) - .Returns(@"C:\Test\TV\Series\Season 01".AsOsAgnostic()); - - var rootFolder = @"C:\Test\TV\".AsOsAgnostic(); - Mocker.GetMock() - .Setup(s => s.FolderExists(rootFolder)) - .Returns(true); - - Mocker.GetMock() - .Setup(s => s.FileExists(It.IsAny())) - .Returns(true); - } - - [Test] - public void should_catch_UnauthorizedAccessException_during_folder_inheritance() - { - WindowsOnly(); - - Mocker.GetMock() - .Setup(s => s.InheritFolderPermissions(It.IsAny())) - .Throws(); - - Subject.MoveEpisodeFile(_episodeFile, _localEpisode); - } - - [Test] - public void should_catch_InvalidOperationException_during_folder_inheritance() - { - WindowsOnly(); - - Mocker.GetMock() - .Setup(s => s.InheritFolderPermissions(It.IsAny())) - .Throws(); - - Subject.MoveEpisodeFile(_episodeFile, _localEpisode); - } - - [Test] - public void should_notify_on_series_folder_creation() - { - Subject.MoveEpisodeFile(_episodeFile, _localEpisode); - - Mocker.GetMock() - .Verify(s => s.PublishEvent(It.Is(p => - p.SeriesFolder.IsNotNullOrWhiteSpace())), Times.Once()); - } - - [Test] - public void should_notify_on_season_folder_creation() - { - Subject.MoveEpisodeFile(_episodeFile, _localEpisode); - - Mocker.GetMock() - .Verify(s => s.PublishEvent(It.Is(p => - p.SeasonFolder.IsNotNullOrWhiteSpace())), Times.Once()); - } - - [Test] - public void should_not_notify_if_series_folder_already_exists() - { - Mocker.GetMock() - .Setup(s => s.FolderExists(_series.Path)) - .Returns(true); - - Subject.MoveEpisodeFile(_episodeFile, _localEpisode); - - Mocker.GetMock() - .Verify(s => s.PublishEvent(It.Is(p => - p.SeriesFolder.IsNotNullOrWhiteSpace())), Times.Never()); - } - } -} diff --git a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/ImportDecisionMakerFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/ImportDecisionMakerFixture.cs deleted file mode 100644 index 37268834b..000000000 --- a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/ImportDecisionMakerFixture.cs +++ /dev/null @@ -1,407 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using FluentAssertions; -using Moq; -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; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; -using NzbDrone.Test.Common; -using FizzWare.NBuilder; - -namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport -{ - [TestFixture] - public class ImportDecisionMakerFixture : CoreTest - { - private List _videoFiles; - private LocalEpisode _localEpisode; - private Series _series; - private QualityModel _quality; - - private Mock _pass1; - private Mock _pass2; - private Mock _pass3; - - private Mock _fail1; - private Mock _fail2; - private Mock _fail3; - - [SetUp] - public void Setup() - { - _pass1 = new Mock(); - _pass2 = new Mock(); - _pass3 = new Mock(); - - _fail1 = new Mock(); - _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()); - - _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")); - - _series = Builder.CreateNew() - .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() }); - } - - private void GivenSpecifications(params Mock[] mocks) - { - Mocker.SetConstant(mocks.Select(c => c.Object)); - } - - private void GivenVideoFiles(IEnumerable videoFiles) - { - _videoFiles = videoFiles.ToList(); - - Mocker.GetMock() - .Setup(c => c.FilterExistingFiles(_videoFiles, It.IsAny())) - .Returns(_videoFiles); - } - - [Test] - public void should_call_all_specifications() - { - GivenSpecifications(_pass1, _pass2, _pass3, _fail1, _fail2, _fail3); - - Subject.GetImportDecisions(_videoFiles, new Series(), 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()); - } - - [Test] - public void should_return_rejected_if_single_specs_fail() - { - GivenSpecifications(_fail1); - - var result = Subject.GetImportDecisions(_videoFiles, new Series()); - - result.Single().Approved.Should().BeFalse(); - } - - [Test] - public void should_return_rejected_if_one_of_specs_fail() - { - GivenSpecifications(_pass1, _fail1, _pass2, _pass3); - - var result = Subject.GetImportDecisions(_videoFiles, new Series()); - - result.Single().Approved.Should().BeFalse(); - } - - [Test] - public void should_return_pass_if_all_specs_pass() - { - GivenSpecifications(_pass1, _pass2, _pass3); - - var result = Subject.GetImportDecisions(_videoFiles, new Series()); - - result.Single().Approved.Should().BeTrue(); - } - - [Test] - public void should_have_same_number_of_rejections_as_specs_that_failed() - { - GivenSpecifications(_pass1, _pass2, _pass3, _fail1, _fail2, _fail3); - - var result = Subject.GetImportDecisions(_videoFiles, new Series()); - result.Single().Rejections.Should().HaveCount(3); - } - - [Test] - public void should_not_blowup_the_process_due_to_failed_parse() - { - GivenSpecifications(_pass1); - - Mocker.GetMock() - .Setup(c => c.GetLocalEpisode(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Throws(); - - _videoFiles = new List - { - "The.Office.S03E115.DVDRip.XviD-OSiTV", - "The.Office.S03E115.DVDRip.XviD-OSiTV", - "The.Office.S03E115.DVDRip.XviD-OSiTV" - }; - - GivenVideoFiles(_videoFiles); - - Subject.GetImportDecisions(_videoFiles, _series); - - Mocker.GetMock() - .Verify(c => c.GetLocalEpisode(It.IsAny(), It.IsAny(), 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", - "The.Office.S03E115.DVDRip.XviD-OSiTV", - "The.Office.S03E115.DVDRip.XviD-OSiTV" - }; - - GivenVideoFiles(_videoFiles); - - var decisions = Subject.GetImportDecisions(_videoFiles, _series); - - Mocker.GetMock() - .Verify(c => c.GetLocalEpisode(It.IsAny(), It.IsAny(), 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())) - .Throws(); - - _videoFiles = new List - { - "The.Office.S03E115.DVDRip.XviD-OSiTV" - }; - - GivenVideoFiles(_videoFiles); - - Subject.GetImportDecisions(_videoFiles, _series).Should().HaveCount(1); - - ExceptionVerification.ExpectedErrors(1); - } - } -} diff --git a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/SampleServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/SampleServiceFixture.cs deleted file mode 100644 index febb5c42f..000000000 --- a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/SampleServiceFixture.cs +++ /dev/null @@ -1,182 +0,0 @@ -using System; -using System.Linq; -using FizzWare.NBuilder; -using FluentAssertions; -using Moq; -using NUnit.Framework; -using NzbDrone.Core.MediaFiles.EpisodeImport; -using NzbDrone.Core.MediaFiles.MediaInfo; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Qualities; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport -{ - [TestFixture] - public class SampleServiceFixture : CoreTest - { - private Series _series; - private LocalEpisode _localEpisode; - - [SetUp] - public void Setup() - { - _series = Builder.CreateNew() - .With(s => s.SeriesType = SeriesTypes.Standard) - .With(s => s.Runtime = 30) - .Build(); - - var episodes = Builder.CreateListOfSize(1) - .All() - .With(e => e.SeasonNumber = 1) - .Build() - .ToList(); - - _localEpisode = new LocalEpisode - { - Path = @"C:\Test\30 Rock\30.rock.s01e01.avi", - Episodes = episodes, - Series = _series, - Quality = new QualityModel(Quality.HDTV720p) - }; - } - - private void GivenFileSize(long size) - { - _localEpisode.Size = size; - } - - private void GivenRuntime(int seconds) - { - Mocker.GetMock() - .Setup(s => s.GetRunTime(It.IsAny())) - .Returns(new TimeSpan(0, 0, seconds)); - } - - [Test] - public void should_return_false_if_season_zero() - { - _localEpisode.Episodes[0].SeasonNumber = 0; - ShouldBeFalse(); - } - - [Test] - public void should_return_false_for_flv() - { - _localEpisode.Path = @"C:\Test\some.show.s01e01.flv"; - - ShouldBeFalse(); - - Mocker.GetMock().Verify(c => c.GetRunTime(It.IsAny()), Times.Never()); - } - - [Test] - public void should_return_false_for_strm() - { - _localEpisode.Path = @"C:\Test\some.show.s01e01.strm"; - - ShouldBeFalse(); - - Mocker.GetMock().Verify(c => c.GetRunTime(It.IsAny()), Times.Never()); - } - - [Test] - 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()); - } - - [Test] - public void should_return_true_if_runtime_is_less_than_minimum() - { - GivenRuntime(60); - - ShouldBeTrue(); - } - - [Test] - public void should_return_false_if_runtime_greater_than_minimum() - { - GivenRuntime(600); - - ShouldBeFalse(); - } - - [Test] - public void should_return_false_if_runtime_greater_than_webisode_minimum() - { - _series.Runtime = 6; - GivenRuntime(299); - - ShouldBeFalse(); - } - - [Test] - public void should_fall_back_to_file_size_if_mediainfo_dll_not_found_acceptable_size() - { - Mocker.GetMock() - .Setup(s => s.GetRunTime(It.IsAny())) - .Throws(); - - GivenFileSize(1000.Megabytes()); - ShouldBeFalse(); - } - - [Test] - public void should_fall_back_to_file_size_if_mediainfo_dll_not_found_undersize() - { - Mocker.GetMock() - .Setup(s => s.GetRunTime(It.IsAny())) - .Throws(); - - GivenFileSize(1.Megabytes()); - ShouldBeTrue(); - } - - [Test] - public void should_not_treat_daily_episode_a_special() - { - GivenRuntime(600); - _series.SeriesType = SeriesTypes.Daily; - _localEpisode.Episodes[0].SeasonNumber = 0; - ShouldBeFalse(); - } - - [Test] - public void should_return_false_for_anime_special() - { - _series.SeriesType = SeriesTypes.Anime; - _localEpisode.Episodes[0].SeasonNumber = 0; - - ShouldBeFalse(); - } - - private void ShouldBeTrue() - { - 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(); - } - } -} diff --git a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/FreeSpaceSpecificationFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/FreeSpaceSpecificationFixture.cs deleted file mode 100644 index a6f1afca1..000000000 --- a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/FreeSpaceSpecificationFixture.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System.IO; -using System.Linq; -using FizzWare.NBuilder; -using FluentAssertions; -using Moq; -using NUnit.Framework; -using NzbDrone.Common.Disk; -using NzbDrone.Core.Configuration; -using NzbDrone.Core.MediaFiles.EpisodeImport.Specifications; -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 FreeSpaceSpecificationFixture : CoreTest - { - private Series _series; - private LocalEpisode _localEpisode; - private string _rootFolder; - - [SetUp] - public void Setup() - { - _rootFolder = @"C:\Test\TV".AsOsAgnostic(); - - _series = Builder.CreateNew() - .With(s => s.SeriesType = SeriesTypes.Standard) - .With(s => s.Path = Path.Combine(_rootFolder, "30 Rock")) - .Build(); - - var episodes = Builder.CreateListOfSize(1) - .All() - .With(e => e.SeasonNumber = 1) - .Build() - .ToList(); - - _localEpisode = new LocalEpisode - { - Path = @"C:\Test\Unsorted\30 Rock\30.rock.s01e01.avi".AsOsAgnostic(), - Episodes = episodes, - Series = _series - }; - } - - private void GivenFileSize(long size) - { - _localEpisode.Size = size; - } - - private void GivenFreeSpace(long? size) - { - Mocker.GetMock() - .Setup(s => s.GetAvailableSpace(It.IsAny())) - .Returns(size); - } - - [Test] - public void should_reject_when_there_isnt_enough_disk_space() - { - GivenFileSize(100.Megabytes()); - GivenFreeSpace(80.Megabytes()); - - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeFalse(); - ExceptionVerification.ExpectedWarns(1); - } - - [Test] - public void should_reject_when_there_isnt_enough_space_for_file_plus_100mb_padding() - { - GivenFileSize(100.Megabytes()); - GivenFreeSpace(150.Megabytes()); - - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeFalse(); - ExceptionVerification.ExpectedWarns(1); - } - - [Test] - public void should_accept_when_there_is_enough_disk_space() - { - GivenFileSize(100.Megabytes()); - GivenFreeSpace(1.Gigabytes()); - - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); - } - - [Test] - public void should_use_series_paths_parent_for_free_space_check() - { - GivenFileSize(100.Megabytes()); - GivenFreeSpace(1.Gigabytes()); - - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); - - Mocker.GetMock() - .Verify(v => v.GetAvailableSpace(_rootFolder), Times.Once()); - } - - [Test] - public void should_pass_if_free_space_is_null() - { - GivenFileSize(100.Megabytes()); - GivenFreeSpace(null); - - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); - } - - [Test] - public void should_pass_if_exception_is_thrown() - { - GivenFileSize(100.Megabytes()); - - Mocker.GetMock() - .Setup(s => s.GetAvailableSpace(It.IsAny())) - .Throws(new TestException()); - - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); - ExceptionVerification.ExpectedErrors(1); - } - - [Test] - public void should_skip_check_for_files_under_series_folder() - { - _localEpisode.ExistingFile = true; - - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); - - Mocker.GetMock() - .Verify(s => s.GetAvailableSpace(It.IsAny()), Times.Never()); - } - - [Test] - public void should_return_true_if_free_space_is_null() - { - long? freeSpace = null; - - Mocker.GetMock() - .Setup(s => s.GetAvailableSpace(It.IsAny())) - .Returns(freeSpace); - - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); - } - - [Test] - public void should_return_true_when_skip_check_is_enabled() - { - Mocker.GetMock() - .Setup(s => s.SkipFreeSpaceCheckWhenImporting) - .Returns(true); - - Subject.IsSatisfiedBy(_localEpisode).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 deleted file mode 100644 index d8dced788..000000000 --- a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/FullSeasonSpecificationFixture.cs +++ /dev/null @@ -1,46 +0,0 @@ -using FizzWare.NBuilder; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.MediaFiles.EpisodeImport.Specifications; -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 FullSeasonSpecificationFixture : CoreTest - { - private LocalEpisode _localEpisode; - - [SetUp] - public void Setup() - { - _localEpisode = new LocalEpisode - { - Path = @"C:\Test\30 Rock\30.rock.s01e01.avi".AsOsAgnostic(), - Size = 100, - Series = Builder.CreateNew().Build(), - ParsedEpisodeInfo = new ParsedEpisodeInfo - { - FullSeason = false - } - }; - } - - [Test] - public void should_return_false_when_file_contains_the_full_season() - { - _localEpisode.ParsedEpisodeInfo.FullSeason = true; - - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeFalse(); - } - - [Test] - public void should_return_true_when_file_does_not_contain_the_full_season() - { - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); - } - } -} diff --git a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/MatchesFolderSpecificationFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/MatchesFolderSpecificationFixture.cs deleted file mode 100644 index 71ff631a1..000000000 --- a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/MatchesFolderSpecificationFixture.cs +++ /dev/null @@ -1,84 +0,0 @@ -using FizzWare.NBuilder; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.MediaFiles.EpisodeImport.Specifications; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Test.Common; - -namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications -{ - [TestFixture] - public class MatchesFolderSpecificationFixture : CoreTest - { - private LocalEpisode _localEpisode; - - [SetUp] - public void Setup() - { - _localEpisode = Builder.CreateNew() - .With(l => l.Path = @"C:\Test\Unsorted\Series.Title.S01E01.720p.HDTV-Sonarr\S01E05.mkv".AsOsAgnostic()) - .With(l => l.ParsedEpisodeInfo = - Builder.CreateNew() - .With(p => p.EpisodeNumbers = new[] {5}) - .With(p => p.FullSeason = false) - .Build()) - .Build(); - } - - [Test] - public void should_be_accepted_for_existing_file() - { - _localEpisode.ExistingFile = true; - - Subject.IsSatisfiedBy(_localEpisode).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(); - - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); - } - - [Test] - public void should_should_be_accepted_for_full_season() - { - _localEpisode.Path = @"C:\Test\Unsorted\Series.Title.S01\S01E01.mkv".AsOsAgnostic(); - - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); - } - - [Test] - public void should_be_accepted_if_file_and_folder_have_the_same_episode() - { - _localEpisode.ParsedEpisodeInfo.EpisodeNumbers = new[] { 1 }; - _localEpisode.Path = @"C:\Test\Unsorted\Series.Title.S01E01.720p.HDTV-Sonarr\S01E01.mkv".AsOsAgnostic(); - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); - } - - [Test] - public void should_be_accepted_if_file_is_one_episode_in_folder() - { - _localEpisode.ParsedEpisodeInfo.EpisodeNumbers = new[] { 1 }; - _localEpisode.Path = @"C:\Test\Unsorted\Series.Title.S01E01E02.720p.HDTV-Sonarr\S01E01.mkv".AsOsAgnostic(); - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); - } - - [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(); - } - - [Test] - public void should_be_rejected_if_file_and_folder_do_not_have_same_episodes() - { - _localEpisode.ParsedEpisodeInfo.EpisodeNumbers = new[] { 5, 6 }; - _localEpisode.Path = @"C:\Test\Unsorted\Series.Title.S01E01E02.720p.HDTV-Sonarr\S01E05E06.mkv".AsOsAgnostic(); - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeFalse(); - } - } -} \ 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 deleted file mode 100644 index 1f3492205..000000000 --- a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/NotSampleSpecificationFixture.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System.Linq; -using FizzWare.NBuilder; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.MediaFiles.EpisodeImport.Specifications; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Qualities; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications -{ - [TestFixture] - public class NotSampleSpecificationFixture : CoreTest - { - private Series _series; - private LocalEpisode _localEpisode; - - [SetUp] - public void Setup() - { - _series = Builder.CreateNew() - .With(s => s.SeriesType = SeriesTypes.Standard) - .Build(); - - var episodes = Builder.CreateListOfSize(1) - .All() - .With(e => e.SeasonNumber = 1) - .Build() - .ToList(); - - _localEpisode = new LocalEpisode - { - Path = @"C:\Test\30 Rock\30.rock.s01e01.avi", - Episodes = episodes, - Series = _series, - Quality = new QualityModel(Quality.HDTV720p) - }; - } - - [Test] - public void should_return_true_for_existing_file() - { - _localEpisode.ExistingFile = true; - Subject.IsSatisfiedBy(_localEpisode).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 deleted file mode 100644 index ad27e402f..000000000 --- a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/NotUnpackingSpecificationFixture.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using FizzWare.NBuilder; -using FluentAssertions; -using Moq; -using NUnit.Framework; -using NzbDrone.Common.Disk; -using NzbDrone.Core.Configuration; -using NzbDrone.Core.MediaFiles.EpisodeImport.Specifications; -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 NotUnpackingSpecificationFixture : CoreTest - { - private LocalEpisode _localEpisode; - - [SetUp] - public void Setup() - { - Mocker.GetMock() - .SetupGet(s => s.DownloadClientWorkingFolders) - .Returns("_UNPACK_|_FAILED_"); - - _localEpisode = new LocalEpisode - { - Path = @"C:\Test\Unsorted TV\30.rock\30.rock.s01e01.avi".AsOsAgnostic(), - Size = 100, - Series = Builder.CreateNew().Build() - }; - } - - private void GivenInWorkingFolder() - { - _localEpisode.Path = @"C:\Test\Unsorted TV\_UNPACK_30.rock\someSubFolder\30.rock.s01e01.avi".AsOsAgnostic(); - } - - private void GivenLastWriteTimeUtc(DateTime time) - { - Mocker.GetMock() - .Setup(s => s.FileGetLastWrite(It.IsAny())) - .Returns(time); - } - - [Test] - public void should_return_true_if_not_in_working_folder() - { - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); - } - - [Test] - public void should_return_true_when_in_old_working_folder() - { - WindowsOnly(); - - GivenInWorkingFolder(); - GivenLastWriteTimeUtc(DateTime.UtcNow.AddHours(-1)); - - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); - } - - [Test] - public void should_return_false_if_in_working_folder_and_last_write_time_was_recent() - { - GivenInWorkingFolder(); - GivenLastWriteTimeUtc(DateTime.UtcNow); - - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeFalse(); - } - - [Test] - public void should_return_false_if_unopacking_on_linux() - { - MonoOnly(); - - GivenInWorkingFolder(); - GivenLastWriteTimeUtc(DateTime.UtcNow.AddDays(-5)); - - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeFalse(); - } - } -} diff --git a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/UpgradeSpecificationFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/UpgradeSpecificationFixture.cs deleted file mode 100644 index f55cdcce2..000000000 --- a/src/NzbDrone.Core.Test/MediaFiles/EpisodeImport/Specifications/UpgradeSpecificationFixture.cs +++ /dev/null @@ -1,156 +0,0 @@ -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.Profiles; -using NzbDrone.Core.Qualities; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport.Specifications -{ - [TestFixture] - public class UpgradeSpecificationFixture : CoreTest - { - private Series _series; - private LocalEpisode _localEpisode; - - [SetUp] - public void Setup() - { - _series = Builder.CreateNew() - .With(s => s.SeriesType = SeriesTypes.Standard) - .With(e => e.Profile = new Profile { Items = Qualities.QualityFixture.GetDefaultQualities() }) - .Build(); - - _localEpisode = new LocalEpisode - { - Path = @"C:\Test\30 Rock\30.rock.s01e01.avi", - Quality = new QualityModel(Quality.HDTV720p, new Revision(version: 1)), - Series = _series - }; - } - - [Test] - public void should_return_true_if_no_existing_episodeFile() - { - _localEpisode.Episodes = Builder.CreateListOfSize(1) - .All() - .With(e => e.EpisodeFileId = 0) - .With(e => e.EpisodeFile = null) - .Build() - .ToList(); - - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); - } - - [Test] - public void should_return_true_if_no_existing_episodeFile_for_multi_episodes() - { - _localEpisode.Episodes = Builder.CreateListOfSize(2) - .All() - .With(e => e.EpisodeFileId = 0) - .With(e => e.EpisodeFile = null) - .Build() - .ToList(); - - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); - } - - [Test] - public void should_return_true_if_upgrade_for_existing_episodeFile() - { - _localEpisode.Episodes = Builder.CreateListOfSize(1) - .All() - .With(e => e.EpisodeFileId = 1) - .With(e => e.EpisodeFile = new LazyLoaded( - new EpisodeFile - { - Quality = new QualityModel(Quality.SDTV, new Revision(version: 1)) - })) - .Build() - .ToList(); - - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); - } - - [Test] - public void should_return_true_if_upgrade_for_existing_episodeFile_for_multi_episodes() - { - _localEpisode.Episodes = Builder.CreateListOfSize(2) - .All() - .With(e => e.EpisodeFileId = 1) - .With(e => e.EpisodeFile = new LazyLoaded( - new EpisodeFile - { - Quality = new QualityModel(Quality.SDTV, new Revision(version: 1)) - })) - .Build() - .ToList(); - - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeTrue(); - } - - [Test] - public void should_return_false_if_not_an_upgrade_for_existing_episodeFile() - { - _localEpisode.Episodes = Builder.CreateListOfSize(1) - .All() - .With(e => e.EpisodeFileId = 1) - .With(e => e.EpisodeFile = new LazyLoaded( - new EpisodeFile - { - Quality = new QualityModel(Quality.Bluray720p, new Revision(version: 1)) - })) - .Build() - .ToList(); - - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeFalse(); - } - - [Test] - public void should_return_false_if_not_an_upgrade_for_existing_episodeFile_for_multi_episodes() - { - _localEpisode.Episodes = Builder.CreateListOfSize(2) - .All() - .With(e => e.EpisodeFileId = 1) - .With(e => e.EpisodeFile = new LazyLoaded( - new EpisodeFile - { - Quality = new QualityModel(Quality.Bluray720p, new Revision(version: 1)) - })) - .Build() - .ToList(); - - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeFalse(); - } - - [Test] - public void should_return_false_if_not_an_upgrade_for_one_existing_episodeFile_for_multi_episode() - { - _localEpisode.Episodes = Builder.CreateListOfSize(2) - .TheFirst(1) - .With(e => e.EpisodeFileId = 1) - .With(e => e.EpisodeFile = new LazyLoaded( - new EpisodeFile - { - Quality = new QualityModel(Quality.SDTV, new Revision(version: 1)) - })) - .TheNext(1) - .With(e => e.EpisodeFileId = 2) - .With(e => e.EpisodeFile = new LazyLoaded( - new EpisodeFile - { - Quality = new QualityModel(Quality.Bluray720p, new Revision(version: 1)) - })) - .Build() - .ToList(); - - Subject.IsSatisfiedBy(_localEpisode).Accepted.Should().BeFalse(); - } - } -} diff --git a/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs deleted file mode 100644 index 6ae1ccc10..000000000 --- a/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs +++ /dev/null @@ -1,244 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Linq; -using FizzWare.NBuilder; -using FluentAssertions; -using Moq; -using NUnit.Framework; -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Download; -using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.MediaFiles.EpisodeImport; -using NzbDrone.Core.MediaFiles.Events; -using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Profiles; -using NzbDrone.Core.Qualities; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; -using NzbDrone.Test.Common; - -namespace NzbDrone.Core.Test.MediaFiles -{ - [TestFixture] - public class ImportApprovedEpisodesFixture : CoreTest - { - private List _rejectedDecisions; - private List _approvedDecisions; - - private DownloadClientItem _downloadClientItem; - - [SetUp] - public void Setup() - { - _rejectedDecisions = new List(); - _approvedDecisions = new List(); - - var series = Builder.CreateNew() - .With(e => e.Profile = new Profile { Items = Qualities.QualityFixture.GetDefaultQualities() }) - .With(s => s.Path = @"C:\Test\TV\30 Rock".AsOsAgnostic()) - .Build(); - - var episodes = Builder.CreateListOfSize(5) - .Build(); - - - - _rejectedDecisions.Add(new ImportDecision(new LocalEpisode(), new Rejection("Rejected!"))); - _rejectedDecisions.Add(new ImportDecision(new LocalEpisode(), new Rejection("Rejected!"))); - _rejectedDecisions.Add(new ImportDecision(new LocalEpisode(), new Rejection("Rejected!"))); - - foreach (var episode in episodes) - { - _approvedDecisions.Add(new ImportDecision - ( - new LocalEpisode - { - Series = series, - Episodes = new List { episode }, - Path = Path.Combine(series.Path, "30 Rock - S01E01 - Pilot.avi"), - Quality = new QualityModel(Quality.Bluray720p), - ParsedEpisodeInfo = new ParsedEpisodeInfo - { - ReleaseGroup = "DRONE" - } - })); - } - - Mocker.GetMock() - .Setup(s => s.UpgradeEpisodeFile(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(new EpisodeFileMoveResult()); - - _downloadClientItem = Builder.CreateNew().Build(); - } - - [Test] - public void should_not_import_any_if_there_are_no_approved_decisions() - { - Subject.Import(_rejectedDecisions, false).Where(i => i.Result == ImportResultType.Imported).Should().BeEmpty(); - - Mocker.GetMock().Verify(v => v.Add(It.IsAny()), Times.Never()); - } - - [Test] - public void should_import_each_approved() - { - Subject.Import(_approvedDecisions, false).Should().HaveCount(5); - } - - [Test] - public void should_only_import_approved() - { - var all = new List(); - all.AddRange(_rejectedDecisions); - all.AddRange(_approvedDecisions); - - var result = Subject.Import(all, false); - - result.Should().HaveCount(all.Count); - result.Where(i => i.Result == ImportResultType.Imported).Should().HaveCount(_approvedDecisions.Count); - } - - [Test] - public void should_only_import_each_episode_once() - { - var all = new List(); - all.AddRange(_approvedDecisions); - all.Add(new ImportDecision(_approvedDecisions.First().LocalEpisode)); - - var result = Subject.Import(all, false); - - result.Where(i => i.Result == ImportResultType.Imported).Should().HaveCount(_approvedDecisions.Count); - } - - [Test] - public void should_move_new_downloads() - { - Subject.Import(new List { _approvedDecisions.First() }, true); - - Mocker.GetMock() - .Verify(v => v.UpgradeEpisodeFile(It.IsAny(), _approvedDecisions.First().LocalEpisode, false), - Times.Once()); - } - - [Test] - public void should_publish_EpisodeImportedEvent_for_new_downloads() - { - Subject.Import(new List { _approvedDecisions.First() }, true); - - Mocker.GetMock() - .Verify(v => v.PublishEvent(It.IsAny()), Times.Once()); - } - - [Test] - public void should_not_move_existing_files() - { - Subject.Import(new List { _approvedDecisions.First() }, false); - - Mocker.GetMock() - .Verify(v => v.UpgradeEpisodeFile(It.IsAny(), _approvedDecisions.First().LocalEpisode, false), - Times.Never()); - } - - [Test] - public void should_use_nzb_title_as_scene_name() - { - _downloadClientItem.Title = "malcolm.in.the.middle.s02e05.dvdrip.xvid-ingot"; - - Subject.Import(new List { _approvedDecisions.First() }, true, _downloadClientItem); - - Mocker.GetMock().Verify(v => v.Add(It.Is(c => c.SceneName == _downloadClientItem.Title))); - } - - [TestCase(".mkv")] - [TestCase(".par2")] - [TestCase(".nzb")] - public void should_remove_extension_from_nzb_title_for_scene_name(string extension) - { - var title = "malcolm.in.the.middle.s02e05.dvdrip.xvid-ingot"; - - _downloadClientItem.Title = title + extension; - - Subject.Import(new List { _approvedDecisions.First() }, true, _downloadClientItem); - - Mocker.GetMock().Verify(v => v.Add(It.Is(c => c.SceneName == title))); - } - - [Test] - public void should_not_use_nzb_title_as_scene_name_if_full_season() - { - _approvedDecisions.First().LocalEpisode.Path = "c:\\tv\\season1\\malcolm.in.the.middle.s02e23.dvdrip.xvid-ingot.mkv".AsOsAgnostic(); - _downloadClientItem.Title = "malcolm.in.the.middle.s02.dvdrip.xvid-ingot"; - - Subject.Import(new List { _approvedDecisions.First() }, true, _downloadClientItem); - - Mocker.GetMock().Verify(v => v.Add(It.Is(c => c.SceneName == "malcolm.in.the.middle.s02e23.dvdrip.xvid-ingot"))); - } - - [Test] - public void should_use_file_name_as_scenename_only_if_it_looks_like_scenename() - { - _approvedDecisions.First().LocalEpisode.Path = "c:\\tv\\malcolm.in.the.middle.s02e23.dvdrip.xvid-ingot.mkv".AsOsAgnostic(); - - Subject.Import(new List { _approvedDecisions.First() }, true); - - Mocker.GetMock().Verify(v => v.Add(It.Is(c => c.SceneName == "malcolm.in.the.middle.s02e23.dvdrip.xvid-ingot"))); - } - - [Test] - public void should_not_use_file_name_as_scenename_if_it_doesnt_looks_like_scenename() - { - _approvedDecisions.First().LocalEpisode.Path = "c:\\tv\\aaaaa.mkv".AsOsAgnostic(); - - Subject.Import(new List { _approvedDecisions.First() }, true); - - Mocker.GetMock().Verify(v => v.Add(It.Is(c => c.SceneName == null))); - } - - [Test] - public void should_import_larger_files_first() - { - var fileDecision = _approvedDecisions.First(); - fileDecision.LocalEpisode.Size = 1.Gigabytes(); - - var sampleDecision = new ImportDecision - (new LocalEpisode - { - Series = fileDecision.LocalEpisode.Series, - Episodes = new List { fileDecision.LocalEpisode.Episodes.First() }, - Path = @"C:\Test\TV\30 Rock\30 Rock - S01E01 - Pilot.avi".AsOsAgnostic(), - Quality = new QualityModel(Quality.Bluray720p), - Size = 80.Megabytes() - }); - - - var all = new List(); - all.Add(fileDecision); - all.Add(sampleDecision); - - var results = Subject.Import(all, false); - - results.Should().HaveCount(all.Count); - results.Should().ContainSingle(d => d.Result == ImportResultType.Imported); - results.Should().ContainSingle(d => d.Result == ImportResultType.Imported && d.ImportDecision.LocalEpisode.Size == fileDecision.LocalEpisode.Size); - } - - [Test] - public void should_copy_readonly_downloads() - { - Subject.Import(new List { _approvedDecisions.First() }, true, new DownloadClientItem { Title = "30.Rock.S01E01", IsReadOnly = true }); - - Mocker.GetMock() - .Verify(v => v.UpgradeEpisodeFile(It.IsAny(), _approvedDecisions.First().LocalEpisode, true), Times.Once()); - } - - [Test] - public void should_use_override_importmode() - { - Subject.Import(new List { _approvedDecisions.First() }, true, new DownloadClientItem { Title = "30.Rock.S01E01", IsReadOnly = true }, ImportMode.Move); - - Mocker.GetMock() - .Verify(v => v.UpgradeEpisodeFile(It.IsAny(), _approvedDecisions.First().LocalEpisode, false), Times.Once()); - } - } -} diff --git a/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedTracksFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedTracksFixture.cs new file mode 100644 index 000000000..425e0d803 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedTracksFixture.cs @@ -0,0 +1,224 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.TrackImport; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Profiles.Qualities; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.MediaFiles +{ + [TestFixture] + public class ImportApprovedTracksFixture : CoreTest + { + private List> _rejectedDecisions; + private List> _approvedDecisions; + + private DownloadClientItem _downloadClientItem; + + [SetUp] + public void Setup() + { + _rejectedDecisions = new List>(); + _approvedDecisions = new List>(); + + var artist = Builder.CreateNew() + .With(e => e.QualityProfile = new QualityProfile { Items = Qualities.QualityFixture.GetDefaultQualities() }) + .With(s => s.Path = @"C:\Test\Music\Alien Ant Farm".AsOsAgnostic()) + .Build(); + + var album = Builder.CreateNew() + .With(e => e.Artist = artist) + .Build(); + + var release = Builder.CreateNew() + .With(e => e.AlbumId = album.Id) + .With(e => e.Monitored = true) + .Build(); + + album.AlbumReleases = new List { release }; + + var tracks = Builder.CreateListOfSize(5) + .Build(); + + _rejectedDecisions.Add(new ImportDecision(new LocalTrack(), new Rejection("Rejected!"))); + _rejectedDecisions.Add(new ImportDecision(new LocalTrack(), new Rejection("Rejected!"))); + _rejectedDecisions.Add(new ImportDecision(new LocalTrack(), new Rejection("Rejected!"))); + + foreach (var track in tracks) + { + _approvedDecisions.Add(new ImportDecision + ( + new LocalTrack + { + Artist = artist, + Album = album, + Release = release, + Tracks = new List { track }, + Path = Path.Combine(artist.Path, "Alien Ant Farm - 01 - Pilot.mp3"), + Quality = new QualityModel(Quality.MP3_256), + FileTrackInfo = new ParsedTrackInfo + { + ReleaseGroup = "DRONE" + } + })); + } + + Mocker.GetMock() + .Setup(s => s.UpgradeTrackFile(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new TrackFileMoveResult()); + + _downloadClientItem = Builder.CreateNew().Build(); + + Mocker.GetMock() + .Setup(s => s.GetFilesByAlbum(It.IsAny())) + .Returns(new List()); + + } + + [Test] + public void should_not_import_any_if_there_are_no_approved_decisions() + { + Subject.Import(_rejectedDecisions, false).Where(i => i.Result == ImportResultType.Imported).Should().BeEmpty(); + + Mocker.GetMock().Verify(v => v.Add(It.IsAny()), Times.Never()); + } + + [Test] + public void should_import_each_approved() + { + Subject.Import(_approvedDecisions, false).Should().HaveCount(5); + } + + [Test] + public void should_only_import_approved() + { + var all = new List>(); + all.AddRange(_rejectedDecisions); + all.AddRange(_approvedDecisions); + + var result = Subject.Import(all, false); + + result.Should().HaveCount(all.Count); + result.Where(i => i.Result == ImportResultType.Imported).Should().HaveCount(_approvedDecisions.Count); + } + + [Test] + public void should_only_import_each_track_once() + { + var all = new List>(); + all.AddRange(_approvedDecisions); + all.Add(new ImportDecision(_approvedDecisions.First().Item)); + + var result = Subject.Import(all, false); + + result.Where(i => i.Result == ImportResultType.Imported).Should().HaveCount(_approvedDecisions.Count); + } + + [Test] + public void should_move_new_downloads() + { + Subject.Import(new List> { _approvedDecisions.First() }, true); + + Mocker.GetMock() + .Verify(v => v.UpgradeTrackFile(It.IsAny(), _approvedDecisions.First().Item, false), + Times.Once()); + } + + [Test] + public void should_publish_TrackImportedEvent_for_new_downloads() + { + Subject.Import(new List> { _approvedDecisions.First() }, true); + + Mocker.GetMock() + .Verify(v => v.PublishEvent(It.IsAny()), Times.Once()); + } + + [Test] + public void should_not_move_existing_files() + { + var track = _approvedDecisions.First(); + track.Item.ExistingFile = true; + Subject.Import(new List> { track }, false); + + Mocker.GetMock() + .Verify(v => v.UpgradeTrackFile(It.IsAny(), _approvedDecisions.First().Item, false), + Times.Never()); + } + + [Test] + public void should_import_larger_files_first() + { + var fileDecision = _approvedDecisions.First(); + fileDecision.Item.Size = 1.Gigabytes(); + + var sampleDecision = new ImportDecision + (new LocalTrack + { + Artist = fileDecision.Item.Artist, + Album = fileDecision.Item.Album, + Tracks = new List { fileDecision.Item.Tracks.First() }, + Path = @"C:\Test\Music\Alien Ant Farm\Alien Ant Farm - 01 - Pilot.mp3".AsOsAgnostic(), + Quality = new QualityModel(Quality.MP3_256), + Size = 80.Megabytes() + }); + + + var all = new List>(); + all.Add(fileDecision); + all.Add(sampleDecision); + + var results = Subject.Import(all, false); + + results.Should().HaveCount(all.Count); + results.Should().ContainSingle(d => d.Result == ImportResultType.Imported); + results.Should().ContainSingle(d => d.Result == ImportResultType.Imported && d.ImportDecision.Item.Size == fileDecision.Item.Size); + } + + [Test] + public void should_copy_when_cannot_move_files_downloads() + { + Subject.Import(new List> { _approvedDecisions.First() }, true, new DownloadClientItem { Title = "Alien.Ant.Farm-Truant", CanMoveFiles = false }); + + Mocker.GetMock() + .Verify(v => v.UpgradeTrackFile(It.IsAny(), _approvedDecisions.First().Item, true), Times.Once()); + } + + [Test] + public void should_use_override_importmode() + { + Subject.Import(new List> { _approvedDecisions.First() }, true, new DownloadClientItem { Title = "Alien.Ant.Farm-Truant", CanMoveFiles = false }, ImportMode.Move); + + Mocker.GetMock() + .Verify(v => v.UpgradeTrackFile(It.IsAny(), _approvedDecisions.First().Item, false), Times.Once()); + } + + [Test] + public void should_delete_existing_trackfiles_with_the_same_path() + { + Mocker.GetMock() + .Setup(s => s.GetFileWithPath(It.IsAny())) + .Returns(Builder.CreateNew().Build()); + + var track = _approvedDecisions.First(); + track.Item.ExistingFile = true; + Subject.Import(new List> { track }, false); + + Mocker.GetMock() + .Verify(v => v.Delete(It.IsAny(), DeleteMediaFileReason.ManualOverride), Times.Once()); + } + + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaFileDeletionService/DeleteTrackFileFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaFileDeletionService/DeleteTrackFileFixture.cs new file mode 100644 index 000000000..edaf152ce --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaFileDeletionService/DeleteTrackFileFixture.cs @@ -0,0 +1,141 @@ +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.Music; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.MediaFiles.MediaFileDeletionService +{ + [TestFixture] + public class DeleteTrackFileFixture : CoreTest + { + private static readonly string RootFolder = @"C:\Test\Music"; + private Artist _artist; + private TrackFile _trackFile; + + [SetUp] + public void Setup() + { + _artist = Builder.CreateNew() + .With(s => s.Path = Path.Combine(RootFolder, "Artist Name")) + .Build(); + + _trackFile = Builder.CreateNew() + .With(f => f.Path = "/Artist Name - Track01") + .Build(); + + Mocker.GetMock() + .Setup(s => s.GetParentFolder(_artist.Path)) + .Returns(RootFolder); + + Mocker.GetMock() + .Setup(s => s.GetParentFolder(_trackFile.Path)) + .Returns(_artist.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[] { _artist.Path }); + } + + private void GivenArtistFolderExists() + { + Mocker.GetMock() + .Setup(s => s.FolderExists(_artist.Path)) + .Returns(true); + } + + [Test] + public void should_throw_if_root_folder_does_not_exist() + { + Assert.Throws(() => Subject.DeleteTrackFile(_artist, _trackFile)); + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + public void should_throw_if_root_folder_is_empty() + { + GivenRootFolderExists(); + Assert.Throws(() => Subject.DeleteTrackFile(_artist, _trackFile)); + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + public void should_delete_from_db_if_artist_folder_does_not_exist() + { + GivenRootFolderExists(); + GivenRootFolderHasFolders(); + + Subject.DeleteTrackFile(_artist, _trackFile); + + Mocker.GetMock().Verify(v => v.Delete(_trackFile, DeleteMediaFileReason.Manual), Times.Once()); + Mocker.GetMock().Verify(v => v.DeleteFile(_trackFile.Path, It.IsAny()), Times.Never()); + } + + [Test] + public void should_delete_from_db_if_track_file_does_not_exist() + { + GivenRootFolderExists(); + GivenRootFolderHasFolders(); + GivenArtistFolderExists(); + + Subject.DeleteTrackFile(_artist, _trackFile); + + Mocker.GetMock().Verify(v => v.Delete(_trackFile, DeleteMediaFileReason.Manual), Times.Once()); + Mocker.GetMock().Verify(v => v.DeleteFile(_trackFile.Path, It.IsAny()), Times.Never()); + } + + [Test] + public void should_delete_from_disk_and_db_if_track_file_exists() + { + GivenRootFolderExists(); + GivenRootFolderHasFolders(); + GivenArtistFolderExists(); + + Mocker.GetMock() + .Setup(s => s.FileExists(_trackFile.Path)) + .Returns(true); + + Subject.DeleteTrackFile(_artist, _trackFile); + + Mocker.GetMock().Verify(v => v.DeleteFile(_trackFile.Path, "Artist Name"), Times.Once()); + Mocker.GetMock().Verify(v => v.Delete(_trackFile, DeleteMediaFileReason.Manual), Times.Once()); + } + + [Test] + public void should_handle_error_deleting_track_file() + { + GivenRootFolderExists(); + GivenRootFolderHasFolders(); + GivenArtistFolderExists(); + + Mocker.GetMock() + .Setup(s => s.FileExists(_trackFile.Path)) + .Returns(true); + + Mocker.GetMock() + .Setup(s => s.DeleteFile(_trackFile.Path, "Artist Name")) + .Throws(new IOException()); + + Assert.Throws(() => Subject.DeleteTrackFile(_artist, _trackFile)); + + ExceptionVerification.ExpectedErrors(1); + Mocker.GetMock().Verify(v => v.DeleteFile(_trackFile.Path, "Artist Name"), Times.Once()); + Mocker.GetMock().Verify(v => v.Delete(_trackFile, DeleteMediaFileReason.Manual), Times.Never()); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaFileRepositoryFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaFileRepositoryFixture.cs index ace441e7b..fbf128409 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/MediaFileRepositoryFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaFileRepositoryFixture.cs @@ -3,32 +3,220 @@ using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Qualities; +using NzbDrone.Core.Music; using NzbDrone.Core.Test.Framework; +using System.Collections.Generic; +using System.Linq; namespace NzbDrone.Core.Test.MediaFiles { [TestFixture] - public class MediaFileRepositoryFixture : DbTest + public class MediaFileRepositoryFixture : DbTest { - [Test] - public void get_files_by_series() + private Artist artist; + private Album album; + private List releases; + + [SetUp] + public void Setup() { - var files = Builder.CreateListOfSize(10) + var meta = Builder.CreateNew() + .With(a => a.Id = 0) + .Build(); + Db.Insert(meta); + + artist = Builder.CreateNew() + .With(a => a.ArtistMetadataId = meta.Id) + .With(a => a.Id = 0) + .Build(); + Db.Insert(artist); + + album = Builder.CreateNew() + .With(a => a.Id = 0) + .With(a => a.ArtistMetadataId = artist.ArtistMetadataId) + .Build(); + Db.Insert(album); + + releases = Builder.CreateListOfSize(2) + .All() + .With(a => a.Id = 0) + .With(a => a.AlbumId = album.Id) + .TheFirst(1) + .With(a => a.Monitored = true) + .TheNext(1) + .With(a => a.Monitored = false) + .Build().ToList(); + Db.InsertMany(releases); + + var files = Builder.CreateListOfSize(10) .All() .With(c => c.Id = 0) - .With(c => c.Quality =new QualityModel(Quality.Bluray720p)) - .Random(4) - .With(s => s.SeriesId = 12) + .With(c => c.Quality =new QualityModel(Quality.MP3_192)) + .TheFirst(5) + .With(c => c.AlbumId = album.Id) + .TheFirst(1) + .With(c => c.Path = "/Test/Path/Artist/somefile1.flac") + .TheNext(1) + .With(c => c.Path = "/Test/Path/Artist/somefile2.flac") .BuildListOfNew(); + Db.InsertMany(files); + + var track = Builder.CreateListOfSize(10) + .All() + .With(a => a.Id = 0) + .TheFirst(4) + .With(a => a.AlbumReleaseId = releases[0].Id) + .TheFirst(1) + .With(a => a.TrackFileId = files[0].Id) + .TheNext(1) + .With(a => a.TrackFileId = files[1].Id) + .TheNext(1) + .With(a => a.TrackFileId = files[2].Id) + .TheNext(1) + .With(a => a.TrackFileId = files[3].Id) + .TheNext(1) + .With(a => a.TrackFileId = files[4].Id) + .With(a => a.AlbumReleaseId = releases[1].Id) + .TheNext(5) + .With(a => a.TrackFileId = 0) + .Build(); + Db.InsertMany(track); + } + + [Test] + public void get_files_by_artist() + { + VerifyData(); + var artistFiles = Subject.GetFilesByArtist(artist.Id); + VerifyEagerLoaded(artistFiles); + artistFiles.Should().OnlyContain(c => c.Artist.Value.Id == artist.Id); + } - Db.InsertMany(files); + [Test] + public void get_unmapped_files() + { + VerifyData(); + var unmappedfiles = Subject.GetUnmappedFiles(); + VerifyUnmapped(unmappedfiles); + + unmappedfiles.Should().HaveCount(5); + } + + [Test] + public void get_files_by_release() + { + VerifyData(); + var firstReleaseFiles = Subject.GetFilesByRelease(releases[0].Id); + var secondReleaseFiles = Subject.GetFilesByRelease(releases[1].Id); + VerifyEagerLoaded(firstReleaseFiles); + VerifyEagerLoaded(secondReleaseFiles); + + firstReleaseFiles.Should().HaveCount(4); + secondReleaseFiles.Should().HaveCount(1); + } + + [Test] + public void get_files_by_base_path() + { + VerifyData(); + var firstReleaseFiles = Subject.GetFilesWithBasePath("/Test/Path"); + VerifyEagerLoaded(firstReleaseFiles); + + firstReleaseFiles.Should().HaveCount(2); + } + + [Test] + public void get_file_by_path() + { + VerifyData(); + var file = Subject.GetFileWithPath("/Test/Path/Artist/somefile2.flac"); + + file.Should().NotBeNull(); + file.Tracks.IsLoaded.Should().BeTrue(); + file.Tracks.Value.Should().NotBeNull(); + file.Tracks.Value.Should().NotBeEmpty(); + file.Album.IsLoaded.Should().BeTrue(); + file.Album.Value.Should().NotBeNull(); + file.Artist.IsLoaded.Should().BeTrue(); + file.Artist.Value.Should().NotBeNull(); + } + + [Test] + public void get_files_by_artist_should_only_return_tracks_for_monitored_releases() + { + VerifyData(); + var artistFiles = Subject.GetFilesByArtist(artist.Id); + VerifyEagerLoaded(artistFiles); + + artistFiles.Should().HaveCount(4); + } + + [Test] + public void get_files_by_album() + { + VerifyData(); + var files = Subject.GetFilesByAlbum(album.Id); + VerifyEagerLoaded(files); + + files.Should().OnlyContain(c => c.AlbumId == album.Id); + } - var seriesFiles = Subject.GetFilesBySeries(12); + [Test] + public void get_files_by_album_should_only_return_tracks_for_monitored_releases() + { + VerifyData(); + var files = Subject.GetFilesByAlbum(album.Id); + VerifyEagerLoaded(files); + + files.Should().HaveCount(4); + } - seriesFiles.Should().HaveCount(4); - seriesFiles.Should().OnlyContain(c => c.SeriesId == 12); + private void VerifyData() + { + Db.All().Should().HaveCount(1); + Db.All().Should().HaveCount(1); + Db.All().Should().HaveCount(10); + Db.All().Should().HaveCount(10); + } + private void VerifyEagerLoaded(List files) + { + foreach (var file in files) + { + file.Tracks.IsLoaded.Should().BeTrue(); + file.Tracks.Value.Should().NotBeNull(); + file.Tracks.Value.Should().NotBeEmpty(); + file.Album.IsLoaded.Should().BeTrue(); + file.Album.Value.Should().NotBeNull(); + file.Artist.IsLoaded.Should().BeTrue(); + file.Artist.Value.Should().NotBeNull(); + file.Artist.Value.Metadata.IsLoaded.Should().BeTrue(); + file.Artist.Value.Metadata.Value.Should().NotBeNull(); + } + } + + private void VerifyUnmapped(List files) + { + foreach (var file in files) + { + file.Tracks.IsLoaded.Should().BeFalse(); + file.Tracks.Value.Should().NotBeNull(); + file.Tracks.Value.Should().BeEmpty(); + file.Album.IsLoaded.Should().BeFalse(); + file.Album.Value.Should().BeNull(); + file.Artist.IsLoaded.Should().BeFalse(); + file.Artist.Value.Should().BeNull(); + } + } + + [Test] + public void delete_files_by_album_should_work_if_join_fails() + { + Db.Delete(album); + Subject.DeleteFilesByAlbum(album.Id); + + Db.All().Where(x => x.AlbumId == album.Id).Should().HaveCount(0); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaFileServiceTests/FilterFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaFileServiceTests/FilterFixture.cs index 172d0c571..c65aea5c2 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/MediaFileServiceTests/FilterFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaFileServiceTests/FilterFixture.cs @@ -1,150 +1,281 @@ using System.Collections.Generic; -using System.IO; using System.Linq; using FluentAssertions; using Moq; using NUnit.Framework; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; using NzbDrone.Test.Common; +using System.IO.Abstractions.TestingHelpers; +using System.IO.Abstractions; +using System; +using FizzWare.NBuilder; namespace NzbDrone.Core.Test.MediaFiles.MediaFileServiceTests { [TestFixture] - public class FilterFixture : CoreTest + public class FilterFixture : FileSystemTest { - private Series _series; + private Artist _artist; + private DateTime _lastWrite = new DateTime(2019, 1, 1); [SetUp] public void Setup() { - _series = new Series + _artist = new Artist { Id = 10, Path = @"C:\".AsOsAgnostic() }; } - [Test] - public void filter_should_return_all_files_if_no_existing_files() + private List GivenFiles(string[] files) { - var files = new List() + foreach (var file in files) { - "C:\\file1.avi".AsOsAgnostic(), - "C:\\file2.avi".AsOsAgnostic(), - "C:\\file3.avi".AsOsAgnostic() - }; + FileSystem.AddFile(file, new MockFileData(string.Empty) { LastWriteTime = _lastWrite }); + } + + return files.Select(x => DiskProvider.GetFileInfo(x)).ToList(); + } - Mocker.GetMock() - .Setup(c => c.GetFilesBySeries(It.IsAny())) - .Returns(new List()); + [TestCase(FilterFilesType.Known)] + [TestCase(FilterFilesType.Matched)] + public void filter_should_return_all_files_if_no_existing_files(FilterFilesType filter) + { + var files = GivenFiles(new [] + { + "C:\\file1.avi".AsOsAgnostic(), + "C:\\file2.avi".AsOsAgnostic(), + "C:\\file3.avi".AsOsAgnostic() + }); + Mocker.GetMock() + .Setup(c => c.GetFilesWithBasePath(It.IsAny())) + .Returns(new List()); - Subject.FilterExistingFiles(files, _series).Should().BeEquivalentTo(files); + Subject.FilterUnchangedFiles(files, _artist, filter).Should().BeEquivalentTo(files); } - [Test] - public void filter_should_return_none_if_all_files_exist() + [TestCase(FilterFilesType.Known)] + [TestCase(FilterFilesType.Matched)] + public void filter_should_return_nothing_if_all_files_exist(FilterFilesType filter) { - var files = new List() - { - "C:\\file1.avi".AsOsAgnostic(), - "C:\\file2.avi".AsOsAgnostic(), - "C:\\file3.avi".AsOsAgnostic() - }; + var files = GivenFiles(new [] + { + "C:\\file1.avi".AsOsAgnostic(), + "C:\\file2.avi".AsOsAgnostic(), + "C:\\file3.avi".AsOsAgnostic() + }); Mocker.GetMock() - .Setup(c => c.GetFilesBySeries(It.IsAny())) - .Returns(files.Select(f => new EpisodeFile { RelativePath = Path.GetFileName(f) }).ToList()); - + .Setup(c => c.GetFilesWithBasePath(It.IsAny())) + .Returns(files.Select(f => new TrackFile { + Path = f.FullName, + Modified = _lastWrite + }).ToList()); - Subject.FilterExistingFiles(files, _series).Should().BeEmpty(); + Subject.FilterUnchangedFiles(files, _artist, filter).Should().BeEmpty(); } - [Test] - public void filter_should_return_none_existing_files() + [TestCase(FilterFilesType.Known)] + [TestCase(FilterFilesType.Matched)] + public void filter_should_not_return_existing_files(FilterFilesType filter) { - var files = new List() - { - "C:\\file1.avi".AsOsAgnostic(), - "C:\\file2.avi".AsOsAgnostic(), - "C:\\file3.avi".AsOsAgnostic() - }; + var files = GivenFiles(new [] + { + "C:\\file1.avi".AsOsAgnostic(), + "C:\\file2.avi".AsOsAgnostic(), + "C:\\file3.avi".AsOsAgnostic() + }); Mocker.GetMock() - .Setup(c => c.GetFilesBySeries(It.IsAny())) - .Returns(new List + .Setup(c => c.GetFilesWithBasePath(It.IsAny())) + .Returns(new List { - new EpisodeFile{ RelativePath = "file2.avi".AsOsAgnostic()} + new TrackFile{ + Path = "C:\\file2.avi".AsOsAgnostic(), + Modified = _lastWrite + } }); - - Subject.FilterExistingFiles(files, _series).Should().HaveCount(2); - Subject.FilterExistingFiles(files, _series).Should().NotContain("C:\\file2.avi".AsOsAgnostic()); + Subject.FilterUnchangedFiles(files, _artist, filter).Should().HaveCount(2); + Subject.FilterUnchangedFiles(files, _artist, filter).Select(x => x.FullName).Should().NotContain("C:\\file2.avi".AsOsAgnostic()); } - [Test] - public void filter_should_return_none_existing_files_ignoring_case() + [TestCase(FilterFilesType.Known)] + [TestCase(FilterFilesType.Matched)] + public void filter_should_return_none_existing_files_ignoring_case(FilterFilesType filter) { WindowsOnly(); - var files = new List() - { - "C:\\file1.avi".AsOsAgnostic(), - "C:\\FILE2.avi".AsOsAgnostic(), - "C:\\file3.avi".AsOsAgnostic() - }; + var files = GivenFiles(new [] + { + "C:\\file1.avi".AsOsAgnostic(), + "C:\\FILE2.avi".AsOsAgnostic(), + "C:\\file3.avi".AsOsAgnostic() + }); Mocker.GetMock() - .Setup(c => c.GetFilesBySeries(It.IsAny())) - .Returns(new List + .Setup(c => c.GetFilesWithBasePath(It.IsAny())) + .Returns(new List { - new EpisodeFile{ RelativePath = "file2.avi".AsOsAgnostic()} + new TrackFile{ + Path = "C:\\file2.avi".AsOsAgnostic(), + Modified = _lastWrite + } }); - Subject.FilterExistingFiles(files, _series).Should().HaveCount(2); - Subject.FilterExistingFiles(files, _series).Should().NotContain("C:\\file2.avi".AsOsAgnostic()); + Subject.FilterUnchangedFiles(files, _artist, filter).Should().HaveCount(2); + Subject.FilterUnchangedFiles(files, _artist, filter).Select(x => x.FullName).Should().NotContain("C:\\file2.avi".AsOsAgnostic()); } - [Test] - public void filter_should_return_none_existing_files_not_ignoring_case() + + [TestCase(FilterFilesType.Known)] + [TestCase(FilterFilesType.Matched)] + public void filter_should_return_none_existing_files_not_ignoring_case(FilterFilesType filter) { MonoOnly(); - var files = new List() - { - "C:\\file1.avi".AsOsAgnostic(), - "C:\\FILE2.avi".AsOsAgnostic(), - "C:\\file3.avi".AsOsAgnostic() - }; + var files = GivenFiles(new [] + { + "C:\\file1.avi".AsOsAgnostic(), + "C:\\FILE2.avi".AsOsAgnostic(), + "C:\\file3.avi".AsOsAgnostic() + }); Mocker.GetMock() - .Setup(c => c.GetFilesBySeries(It.IsAny())) - .Returns(new List + .Setup(c => c.GetFilesWithBasePath(It.IsAny())) + .Returns(new List { - new EpisodeFile{ RelativePath = "file2.avi".AsOsAgnostic()} + new TrackFile{ + Path = "C:\\file2.avi".AsOsAgnostic(), + Modified = _lastWrite + } }); - Subject.FilterExistingFiles(files, _series).Should().HaveCount(3); + Subject.FilterUnchangedFiles(files, _artist, filter).Should().HaveCount(3); } - [Test] - public void filter_should_not_change_casing() + [TestCase(FilterFilesType.Known)] + [TestCase(FilterFilesType.Matched)] + public void filter_should_not_change_casing(FilterFilesType filter) { - var files = new List() - { - "C:\\FILE1.avi".AsOsAgnostic() - }; + var files = GivenFiles(new [] + { + "C:\\FILE1.avi".AsOsAgnostic() + }); + + Mocker.GetMock() + .Setup(c => c.GetFilesWithBasePath(It.IsAny())) + .Returns(new List()); + + Subject.FilterUnchangedFiles(files, _artist, filter).Should().HaveCount(1); + Subject.FilterUnchangedFiles(files, _artist, filter).Select(x => x.FullName).Should().NotContain(files.First().FullName.ToLower()); + Subject.FilterUnchangedFiles(files, _artist, filter).Should().Contain(files.First()); + } + + + [TestCase(FilterFilesType.Known)] + [TestCase(FilterFilesType.Matched)] + public void filter_should_not_return_existing_file_if_size_unchanged(FilterFilesType filter) + { + FileSystem.AddFile("C:\\file1.avi".AsOsAgnostic(), new MockFileData("".PadRight(10)) { LastWriteTime = _lastWrite }); + FileSystem.AddFile("C:\\file2.avi".AsOsAgnostic(), new MockFileData("".PadRight(10)) { LastWriteTime = _lastWrite }); + FileSystem.AddFile("C:\\file3.avi".AsOsAgnostic(), new MockFileData("".PadRight(10)) { LastWriteTime = _lastWrite }); + + var files = FileSystem.AllFiles.Select(x => DiskProvider.GetFileInfo(x)).ToList(); + + Mocker.GetMock() + .Setup(c => c.GetFilesWithBasePath(It.IsAny())) + .Returns(new List + { + new TrackFile{ + Path = "C:\\file2.avi".AsOsAgnostic(), + Size = 10, + Modified = _lastWrite + } + }); + + Subject.FilterUnchangedFiles(files, _artist, filter).Should().HaveCount(2); + Subject.FilterUnchangedFiles(files, _artist, filter).Select(x => x.FullName).Should().NotContain("C:\\file2.avi".AsOsAgnostic()); + } + + [TestCase(FilterFilesType.Matched)] + public void filter_unmatched_should_return_existing_file_if_unmatched(FilterFilesType filter) + { + FileSystem.AddFile("C:\\file1.avi".AsOsAgnostic(), new MockFileData("".PadRight(10)) { LastWriteTime = _lastWrite }); + FileSystem.AddFile("C:\\file2.avi".AsOsAgnostic(), new MockFileData("".PadRight(10)) { LastWriteTime = _lastWrite }); + FileSystem.AddFile("C:\\file3.avi".AsOsAgnostic(), new MockFileData("".PadRight(10)) { LastWriteTime = _lastWrite }); + + var files = FileSystem.AllFiles.Select(x => DiskProvider.GetFileInfo(x)).ToList(); + + Mocker.GetMock() + .Setup(c => c.GetFilesWithBasePath(It.IsAny())) + .Returns(new List + { + new TrackFile{ + Path = "C:\\file2.avi".AsOsAgnostic(), + Size = 10, + Modified = _lastWrite, + Tracks = new List() + } + }); + + Subject.FilterUnchangedFiles(files, _artist, filter).Should().HaveCount(3); + Subject.FilterUnchangedFiles(files, _artist, filter).Select(x => x.FullName).Should().Contain("C:\\file2.avi".AsOsAgnostic()); + } + + [TestCase(FilterFilesType.Matched)] + public void filter_unmatched_should_not_return_existing_file_if_matched(FilterFilesType filter) + { + FileSystem.AddFile("C:\\file1.avi".AsOsAgnostic(), new MockFileData("".PadRight(10)) { LastWriteTime = _lastWrite }); + FileSystem.AddFile("C:\\file2.avi".AsOsAgnostic(), new MockFileData("".PadRight(10)) { LastWriteTime = _lastWrite }); + FileSystem.AddFile("C:\\file3.avi".AsOsAgnostic(), new MockFileData("".PadRight(10)) { LastWriteTime = _lastWrite }); + + var files = FileSystem.AllFiles.Select(x => DiskProvider.GetFileInfo(x)).ToList(); Mocker.GetMock() - .Setup(c => c.GetFilesBySeries(It.IsAny())) - .Returns(new List()); + .Setup(c => c.GetFilesWithBasePath(It.IsAny())) + .Returns(new List + { + new TrackFile{ + Path = "C:\\file2.avi".AsOsAgnostic(), + Size = 10, + Modified = _lastWrite, + Tracks = Builder.CreateListOfSize(1).Build() as List + } + }); + + Subject.FilterUnchangedFiles(files, _artist, filter).Should().HaveCount(2); + Subject.FilterUnchangedFiles(files, _artist, filter).Select(x => x.FullName).Should().NotContain("C:\\file2.avi".AsOsAgnostic()); + } + + [TestCase(FilterFilesType.Known)] + [TestCase(FilterFilesType.Matched)] + public void filter_should_return_existing_file_if_size_changed(FilterFilesType filter) + { + FileSystem.AddFile("C:\\file1.avi".AsOsAgnostic(), new MockFileData("".PadRight(10)) { LastWriteTime = _lastWrite }); + FileSystem.AddFile("C:\\file2.avi".AsOsAgnostic(), new MockFileData("".PadRight(11)) { LastWriteTime = _lastWrite }); + FileSystem.AddFile("C:\\file3.avi".AsOsAgnostic(), new MockFileData("".PadRight(10)) { LastWriteTime = _lastWrite }); + + var files = FileSystem.AllFiles.Select(x => DiskProvider.GetFileInfo(x)).ToList(); + + Mocker.GetMock() + .Setup(c => c.GetFilesWithBasePath(It.IsAny())) + .Returns(new List + { + new TrackFile{ + Path = "C:\\file2.avi".AsOsAgnostic(), + Size = 10, + Modified = _lastWrite + } + }); - Subject.FilterExistingFiles(files, _series).Should().HaveCount(1); - Subject.FilterExistingFiles(files, _series).Should().NotContain(files.First().ToLower()); - Subject.FilterExistingFiles(files, _series).Should().Contain(files.First()); + Subject.FilterUnchangedFiles(files, _artist, filter).Should().HaveCount(3); + Subject.FilterUnchangedFiles(files, _artist, filter).Select(x => x.FullName).Should().Contain("C:\\file2.avi".AsOsAgnostic()); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaFileServiceTests/MediaFileServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaFileServiceTests/MediaFileServiceFixture.cs new file mode 100644 index 000000000..9a9dfc9b6 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaFileServiceTests/MediaFileServiceFixture.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Test.MediaFiles.TrackFileMovingServiceTests +{ + [TestFixture] + public class MediaFileServiceFixture : CoreTest + { + private Album _album; + private List _trackFiles; + + [SetUp] + public void Setup() + { + _album = Builder.CreateNew() + .Build(); + + _trackFiles = Builder.CreateListOfSize(3) + .TheFirst(2) + .With(f => f.AlbumId = _album.Id) + .TheNext(1) + .With(f => f.AlbumId = 0) + .Build().ToList(); + } + + [Test] + public void should_throw_trackFileDeletedEvent_for_each_mapped_track_on_deletemany() + { + Subject.DeleteMany(_trackFiles, DeleteMediaFileReason.Manual); + + VerifyEventPublished(Times.Exactly(2)); + } + + [Test] + public void should_throw_trackFileDeletedEvent_for_mapped_track_on_delete() + { + Subject.Delete(_trackFiles[0], DeleteMediaFileReason.Manual); + + VerifyEventPublished(Times.Once()); + } + + [Test] + public void should_throw_trackFileAddedEvent_for_each_track_added_on_addmany() + { + Subject.AddMany(_trackFiles); + + VerifyEventPublished(Times.Exactly(3)); + } + + [Test] + public void should_throw_trackFileAddedEvent_for_track_added() + { + Subject.Add(_trackFiles[0]); + + VerifyEventPublished(Times.Once()); + } + + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaFileTableCleanupServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaFileTableCleanupServiceFixture.cs index bb249561b..4ac1c3ad8 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/MediaFileTableCleanupServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaFileTableCleanupServiceFixture.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.IO; using FizzWare.NBuilder; @@ -7,125 +7,115 @@ using NUnit.Framework; using NzbDrone.Common.Disk; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.MediaFiles { public class MediaFileTableCleanupServiceFixture : CoreTest { - private const string DELETED_PATH = "ANY FILE WITH THIS PATH IS CONSIDERED DELETED!"; - private List _episodes; - private Series _series; + private readonly string DELETED_PATH = @"c:\ANY FILE STARTING WITH THIS PATH IS CONSIDERED DELETED!".AsOsAgnostic(); + private List _tracks; + private Artist _artist; [SetUp] public void SetUp() { - _episodes = Builder.CreateListOfSize(10) + _tracks = Builder.CreateListOfSize(10) .Build() .ToList(); - _series = Builder.CreateNew() - .With(s => s.Path = @"C:\Test\TV\Series".AsOsAgnostic()) + _artist = Builder.CreateNew() + .With(s => s.Path = @"C:\Test\Music\Artist".AsOsAgnostic()) .Build(); - Mocker.GetMock() - .Setup(e => e.FileExists(It.Is(c => !c.Contains(DELETED_PATH)))) - .Returns(true); - - Mocker.GetMock() - .Setup(c => c.GetEpisodeBySeries(It.IsAny())) - .Returns(_episodes); + Mocker.GetMock() + .Setup(c => c.GetTracksByFileId(It.IsAny>())) + .Returns((IEnumerable ids) => _tracks.Where(y => ids.Contains(y.TrackFileId)).ToList()); } - private void GivenEpisodeFiles(IEnumerable episodeFiles) + private void GivenTrackFiles(IEnumerable trackFiles) { Mocker.GetMock() - .Setup(c => c.GetFilesBySeries(It.IsAny())) - .Returns(episodeFiles.ToList()); + .Setup(c => c.GetFilesWithBasePath(It.IsAny())) + .Returns(trackFiles.ToList()); } - private void GivenFilesAreNotAttachedToEpisode() + private void GivenFilesAreNotAttachedToTrack() { - _episodes.ForEach(e => e.EpisodeFileId = 0); - - Mocker.GetMock() - .Setup(c => c.GetEpisodeBySeries(It.IsAny())) - .Returns(_episodes); + Mocker.GetMock() + .Setup(c => c.GetTracksByFileId(It.IsAny())) + .Returns(new List()); } - private List FilesOnDisk(IEnumerable episodeFiles) + private List FilesOnDisk(IEnumerable trackFiles) { - return episodeFiles.Select(e => Path.Combine(_series.Path, e.RelativePath)).ToList(); + return trackFiles.Select(e => e.Path).ToList(); } [Test] - public void should_skip_files_that_exist_in_disk() + public void should_skip_files_that_exist_on_disk() { - var episodeFiles = Builder.CreateListOfSize(10) + var trackFiles = Builder.CreateListOfSize(10) + .All() + .With(x => x.Path = Path.Combine(@"c:\test".AsOsAgnostic(), Path.GetRandomFileName())) .Build(); - GivenEpisodeFiles(episodeFiles); + GivenTrackFiles(trackFiles); - Subject.Clean(_series, FilesOnDisk(episodeFiles)); + Subject.Clean(_artist, FilesOnDisk(trackFiles)); - Mocker.GetMock().Verify(c => c.UpdateEpisode(It.IsAny()), Times.Never()); + Mocker.GetMock() + .Verify(c => c.DeleteMany(It.Is>(x => x.Count == 0), DeleteMediaFileReason.MissingFromDisk), Times.Once()); } [Test] public void should_delete_non_existent_files() { - var episodeFiles = Builder.CreateListOfSize(10) + var trackFiles = Builder.CreateListOfSize(10) + .All() + .With(x => x.Path = Path.Combine(@"c:\test".AsOsAgnostic(), Path.GetRandomFileName())) .Random(2) - .With(c => c.RelativePath = DELETED_PATH) + .With(c => c.Path = Path.Combine(DELETED_PATH, Path.GetRandomFileName())) .Build(); - GivenEpisodeFiles(episodeFiles); + GivenTrackFiles(trackFiles); - Subject.Clean(_series, FilesOnDisk(episodeFiles.Where(e => e.RelativePath != DELETED_PATH))); + Subject.Clean(_artist, FilesOnDisk(trackFiles.Where(e => !e.Path.StartsWith(DELETED_PATH)))); - Mocker.GetMock().Verify(c => c.Delete(It.Is(e => e.RelativePath == DELETED_PATH), DeleteMediaFileReason.MissingFromDisk), Times.Exactly(2)); + Mocker.GetMock() + .Verify(c => c.DeleteMany(It.Is>(e => e.Count == 2 && e.All(y => y.Path.StartsWith(DELETED_PATH))), DeleteMediaFileReason.MissingFromDisk), Times.Once()); } [Test] - public void should_delete_files_that_dont_belong_to_any_episodes() + public void should_unlink_track_when_trackFile_does_not_exist() { - var episodeFiles = Builder.CreateListOfSize(10) - .Random(10) - .With(c => c.RelativePath = "ExistingPath") - .Build(); - - GivenEpisodeFiles(episodeFiles); - GivenFilesAreNotAttachedToEpisode(); - - Subject.Clean(_series, FilesOnDisk(episodeFiles)); - - Mocker.GetMock().Verify(c => c.Delete(It.IsAny(), DeleteMediaFileReason.NoLinkedEpisodes), Times.Exactly(10)); - } + var trackFiles = Builder.CreateListOfSize(10) + .Random(10) + .With(c => c.Path = Path.Combine(@"c:\test".AsOsAgnostic(), Path.GetRandomFileName())) + .Build(); - [Test] - public void should_unlink_episode_when_episodeFile_does_not_exist() - { - GivenEpisodeFiles(new List()); + GivenTrackFiles(trackFiles); - Subject.Clean(_series, new List()); + Subject.Clean(_artist, new List()); - Mocker.GetMock().Verify(c => c.UpdateEpisode(It.Is(e => e.EpisodeFileId == 0)), Times.Exactly(10)); + Mocker.GetMock() + .Verify(c => c.SetFileIds(It.Is>(e => e.Count == 10 && e.All(y => y.TrackFileId == 0))), Times.Once()); } [Test] - public void should_not_update_episode_when_episodeFile_exists() + public void should_not_update_track_when_trackFile_exists() { - var episodeFiles = Builder.CreateListOfSize(10) + var trackFiles = Builder.CreateListOfSize(10) .Random(10) - .With(c => c.RelativePath = "ExistingPath") + .With(c => c.Path = Path.Combine(@"c:\test".AsOsAgnostic(), Path.GetRandomFileName())) .Build(); - GivenEpisodeFiles(episodeFiles); + GivenTrackFiles(trackFiles); - Subject.Clean(_series, FilesOnDisk(episodeFiles)); + Subject.Clean(_artist, FilesOnDisk(trackFiles)); - Mocker.GetMock().Verify(c => c.UpdateEpisode(It.IsAny()), Times.Never()); + Mocker.GetMock().Verify(c => c.SetFileIds(It.Is>(x => x.Count == 0)), Times.Once()); } } } diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/FormattedAudioChannelsFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/FormattedAudioChannelsFixture.cs deleted file mode 100644 index c344c0906..000000000 --- a/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/FormattedAudioChannelsFixture.cs +++ /dev/null @@ -1,120 +0,0 @@ -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.MediaFiles.MediaInfo; - -namespace NzbDrone.Core.Test.MediaFiles.MediaInfo -{ - [TestFixture] - public class FormattedAudioChannelsFixture - { - [Test] - public void should_subtract_one_from_AudioChannels_as_total_channels_if_LFE_in_AudioChannelPositionsText() - { - var mediaInfoModel = new MediaInfoModel - { - AudioChannels = 6, - AudioChannelPositions = null, - AudioChannelPositionsText = "Front: L C R, Side: L R, LFE" - }; - - mediaInfoModel.FormattedAudioChannels.Should().Be(5.1m); - } - - [Test] - public void should_use_AudioChannels_as_total_channels_if_LFE_not_in_AudioChannelPositionsText() - { - var mediaInfoModel = new MediaInfoModel - { - AudioChannels = 2, - AudioChannelPositions = null, - AudioChannelPositionsText = "Front: L R" - }; - - mediaInfoModel.FormattedAudioChannels.Should().Be(2); - } - - [Test] - public void should_return_0_if_schema_revision_is_less_than_3_and_other_properties_are_null() - { - var mediaInfoModel = new MediaInfoModel - { - AudioChannels = 2, - AudioChannelPositions = null, - AudioChannelPositionsText = null, - SchemaRevision = 2 - }; - - mediaInfoModel.FormattedAudioChannels.Should().Be(0); - } - - [Test] - public void should_use_AudioChannels_if_schema_revision_is_3_and_other_properties_are_null() - { - var mediaInfoModel = new MediaInfoModel - { - AudioChannels = 2, - AudioChannelPositions = null, - AudioChannelPositionsText = null, - SchemaRevision = 3 - }; - - mediaInfoModel.FormattedAudioChannels.Should().Be(2); - } - - [Test] - public void should_sum_AudioChannelPositions() - { - var mediaInfoModel = new MediaInfoModel - { - AudioChannels = 2, - AudioChannelPositions = "2/0/0", - AudioChannelPositionsText = null, - SchemaRevision = 3 - }; - - mediaInfoModel.FormattedAudioChannels.Should().Be(2); - } - - [Test] - public void should_sum_AudioChannelPositions_including_decimal() - { - var mediaInfoModel = new MediaInfoModel - { - AudioChannels = 2, - AudioChannelPositions = "3/2/0.1", - AudioChannelPositionsText = null, - SchemaRevision = 3 - }; - - mediaInfoModel.FormattedAudioChannels.Should().Be(5.1m); - } - - [Test] - public void should_cleanup_extraneous_text_from_AudioChannelPositions() - { - var mediaInfoModel = new MediaInfoModel - { - AudioChannels = 2, - AudioChannelPositions = "Object Based / 3/2/2.1", - AudioChannelPositionsText = null, - SchemaRevision = 3 - }; - - mediaInfoModel.FormattedAudioChannels.Should().Be(7.1m); - } - - [Test] - public void should_sum_first_series_of_numbers_from_AudioChannelPositions() - { - var mediaInfoModel = new MediaInfoModel - { - AudioChannels = 2, - AudioChannelPositions = "3/2/2.1 / 3/2/2.1", - AudioChannelPositionsText = null, - SchemaRevision = 3 - }; - - mediaInfoModel.FormattedAudioChannels.Should().Be(7.1m); - } - } -} diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/UpdateMediaInfoServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/UpdateMediaInfoServiceFixture.cs deleted file mode 100644 index 4ea9af0f2..000000000 --- a/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/UpdateMediaInfoServiceFixture.cs +++ /dev/null @@ -1,158 +0,0 @@ -using System.IO; -using FizzWare.NBuilder; -using Moq; -using NUnit.Framework; -using NzbDrone.Common.Disk; -using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.MediaFiles.Events; -using NzbDrone.Core.MediaFiles.MediaInfo; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; -using NzbDrone.Test.Common; -using NzbDrone.Core.Configuration; - -namespace NzbDrone.Core.Test.MediaFiles.MediaInfo -{ - [TestFixture] - public class UpdateMediaInfoServiceFixture : CoreTest - { - private Series _series; - - [SetUp] - public void Setup() - { - _series = new Series - { - Id = 1, - Path = @"C:\series".AsOsAgnostic() - }; - - Mocker.GetMock() - .SetupGet(s => s.EnableMediaInfo) - .Returns(true); - } - - private void GivenFileExists() - { - Mocker.GetMock() - .Setup(v => v.FileExists(It.IsAny())) - .Returns(true); - } - - private void GivenSuccessfulScan() - { - Mocker.GetMock() - .Setup(v => v.GetMediaInfo(It.IsAny())) - .Returns(new MediaInfoModel()); - } - - private void GivenFailedScan(string path) - { - Mocker.GetMock() - .Setup(v => v.GetMediaInfo(path)) - .Returns((MediaInfoModel)null); - } - - [Test] - public void should_skip_up_to_date_media_info() - { - var episodeFiles = Builder.CreateListOfSize(3) - .All() - .With(v => v.RelativePath = "media.mkv") - .TheFirst(1) - .With(v => v.MediaInfo = new MediaInfoModel { SchemaRevision = 3 }) - .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_update_outdated_media_info() - { - var episodeFiles = Builder.CreateListOfSize(3) - .All() - .With(v => v.RelativePath = "media.mkv") - .TheFirst(1) - .With(v => v.MediaInfo = new MediaInfoModel()) - .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(3)); - - Mocker.GetMock() - .Verify(v => v.Update(It.IsAny()), Times.Exactly(3)); - } - - [Test] - public void should_ignore_missing_files() - { - var episodeFiles = Builder.CreateListOfSize(2) - .All() - .With(v => v.RelativePath = "media.mkv") - .BuildList(); - - Mocker.GetMock() - .Setup(v => v.GetFilesBySeries(1)) - .Returns(episodeFiles); - - GivenSuccessfulScan(); - - Subject.Handle(new SeriesScannedEvent(_series)); - - Mocker.GetMock() - .Verify(v => v.GetMediaInfo("media.mkv"), Times.Never()); - - Mocker.GetMock() - .Verify(v => v.Update(It.IsAny()), Times.Never()); - } - - [Test] - public void should_continue_after_failure() - { - var episodeFiles = Builder.CreateListOfSize(2) - .All() - .With(v => v.RelativePath = "media.mkv") - .TheFirst(1) - .With(v => v.RelativePath = "media2.mkv") - .BuildList(); - - Mocker.GetMock() - .Setup(v => v.GetFilesBySeries(1)) - .Returns(episodeFiles); - - GivenFileExists(); - GivenSuccessfulScan(); - GivenFailedScan(Path.Combine(_series.Path, "media2.mkv")); - - Subject.Handle(new SeriesScannedEvent(_series)); - - Mocker.GetMock() - .Verify(v => v.GetMediaInfo(Path.Combine(_series.Path, "media.mkv")), Times.Exactly(1)); - - Mocker.GetMock() - .Verify(v => v.Update(It.IsAny()), Times.Exactly(1)); - } - } -} diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/VideoFileInfoReaderFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/VideoFileInfoReaderFixture.cs deleted file mode 100644 index 5ccd1e4eb..000000000 --- a/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/VideoFileInfoReaderFixture.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System.IO; -using FluentAssertions; -using Moq; -using NUnit.Framework; -using NzbDrone.Common.Disk; -using NzbDrone.Core.MediaFiles.MediaInfo; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Test.Common.Categories; - -namespace NzbDrone.Core.Test.MediaFiles.MediaInfo -{ - [TestFixture] - [DiskAccessTest] - public class VideoFileInfoReaderFixture : CoreTest - { - [SetUp] - public void Setup() - { - Mocker.GetMock() - .Setup(s => s.FileExists(It.IsAny())) - .Returns(true); - - Mocker.GetMock() - .Setup(s => s.OpenReadStream(It.IsAny())) - .Returns(s => new FileStream(s, FileMode.Open, FileAccess.Read)); - } - - [Test] - public void get_runtime() - { - var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Media", "H264_sample.mp4"); - - Subject.GetRunTime(path).Seconds.Should().Be(10); - - } - - - [Test] - public void get_info() - { - var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Media", "H264_sample.mp4"); - - var info = Subject.GetMediaInfo(path); - - - 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] - public void get_info_unicode() - { - var srcPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Media", "H264_sample.mp4"); - - var tempPath = GetTempFilePath(); - Directory.CreateDirectory(tempPath); - - var path = Path.Combine(tempPath, "H264_Pok\u00E9mon.mkv"); - - File.Copy(srcPath, path); - - var info = Subject.GetMediaInfo(path); - - 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] - public void should_dispose_file_after_scanning_mediainfo() - { - var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Media", "H264_sample.mp4"); - - var info = Subject.GetMediaInfo(path); - - var stream = new FileStream(path, FileMode.Open, FileAccess.Write); - - stream.Close(); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/MediaFiles/RenameEpisodeFileServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/RenameEpisodeFileServiceFixture.cs deleted file mode 100644 index 5757641dc..000000000 --- a/src/NzbDrone.Core.Test/MediaFiles/RenameEpisodeFileServiceFixture.cs +++ /dev/null @@ -1,122 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using FizzWare.NBuilder; -using Moq; -using NUnit.Framework; -using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.MediaFiles.Commands; -using NzbDrone.Core.MediaFiles.Events; -using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Test.MediaFiles -{ - public class RenameEpisodeFileServiceFixture : CoreTest - { - private Series _series; - private List _episodeFiles; - - [SetUp] - public void Setup() - { - _series = Builder.CreateNew() - .Build(); - - _episodeFiles = Builder.CreateListOfSize(2) - .All() - .With(e => e.SeriesId = _series.Id) - .With(e => e.SeasonNumber = 1) - .Build() - .ToList(); - - Mocker.GetMock() - .Setup(s => s.GetSeries(_series.Id)) - .Returns(_series); - } - - private void GivenNoEpisodeFiles() - { - Mocker.GetMock() - .Setup(s => s.Get(It.IsAny>())) - .Returns(new List()); - } - - private void GivenEpisodeFiles() - { - Mocker.GetMock() - .Setup(s => s.Get(It.IsAny>())) - .Returns(_episodeFiles); - } - - private void GivenMovedFiles() - { - Mocker.GetMock() - .Setup(s => s.MoveEpisodeFile(It.IsAny(), _series)); - } - - [Test] - public void should_not_publish_event_if_no_files_to_rename() - { - GivenNoEpisodeFiles(); - - Subject.Execute(new RenameFilesCommand(_series.Id, new List{1})); - - Mocker.GetMock() - .Verify(v => v.PublishEvent(It.IsAny()), Times.Never()); - } - - [Test] - public void should_not_publish_event_if_no_files_are_renamed() - { - GivenEpisodeFiles(); - - Mocker.GetMock() - .Setup(s => s.MoveEpisodeFile(It.IsAny(), It.IsAny())) - .Throws(new SameFilenameException("Same file name", "Filename")); - - Subject.Execute(new RenameFilesCommand(_series.Id, new List { 1 })); - - Mocker.GetMock() - .Verify(v => v.PublishEvent(It.IsAny()), Times.Never()); - } - - [Test] - public void should_publish_event_if_files_are_renamed() - { - GivenEpisodeFiles(); - GivenMovedFiles(); - - Subject.Execute(new RenameFilesCommand(_series.Id, new List { 1 })); - - Mocker.GetMock() - .Verify(v => v.PublishEvent(It.IsAny()), Times.Once()); - } - - [Test] - public void should_update_moved_files() - { - GivenEpisodeFiles(); - GivenMovedFiles(); - - Subject.Execute(new RenameFilesCommand(_series.Id, new List { 1 })); - - Mocker.GetMock() - .Verify(v => v.Update(It.IsAny()), Times.Exactly(2)); - } - - [Test] - public void should_get_episodefiles_by_ids_only() - { - GivenEpisodeFiles(); - GivenMovedFiles(); - - var files = new List { 1 }; - - Subject.Execute(new RenameFilesCommand(_series.Id, files)); - - Mocker.GetMock() - .Verify(v => v.Get(files), Times.Once()); - } - } -} diff --git a/src/NzbDrone.Core.Test/MediaFiles/RenameTrackFileServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/RenameTrackFileServiceFixture.cs new file mode 100644 index 000000000..2e1f60544 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/RenameTrackFileServiceFixture.cs @@ -0,0 +1,121 @@ +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.Commands; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Test.MediaFiles +{ + public class RenameTrackFileServiceFixture : CoreTest + { + private Artist _artist; + private List _trackFiles; + + [SetUp] + public void Setup() + { + _artist = Builder.CreateNew() + .Build(); + + _trackFiles = Builder.CreateListOfSize(2) + .All() + .With(e => e.Artist = _artist) + .Build() + .ToList(); + + Mocker.GetMock() + .Setup(s => s.GetArtist(_artist.Id)) + .Returns(_artist); + } + + private void GivenNoTrackFiles() + { + Mocker.GetMock() + .Setup(s => s.Get(It.IsAny>())) + .Returns(new List()); + } + + private void GivenTrackFiles() + { + Mocker.GetMock() + .Setup(s => s.Get(It.IsAny>())) + .Returns(_trackFiles); + } + + private void GivenMovedFiles() + { + Mocker.GetMock() + .Setup(s => s.MoveTrackFile(It.IsAny(), _artist)); + } + + [Test] + public void should_not_publish_event_if_no_files_to_rename() + { + GivenNoTrackFiles(); + + Subject.Execute(new RenameFilesCommand(_artist.Id, new List{1})); + + Mocker.GetMock() + .Verify(v => v.PublishEvent(It.IsAny()), Times.Never()); + } + + [Test] + public void should_not_publish_event_if_no_files_are_renamed() + { + GivenTrackFiles(); + + Mocker.GetMock() + .Setup(s => s.MoveTrackFile(It.IsAny(), It.IsAny())) + .Throws(new SameFilenameException("Same file name", "Filename")); + + Subject.Execute(new RenameFilesCommand(_artist.Id, new List { 1 })); + + Mocker.GetMock() + .Verify(v => v.PublishEvent(It.IsAny()), Times.Never()); + } + + [Test] + public void should_publish_event_if_files_are_renamed() + { + GivenTrackFiles(); + GivenMovedFiles(); + + Subject.Execute(new RenameFilesCommand(_artist.Id, new List { 1 })); + + Mocker.GetMock() + .Verify(v => v.PublishEvent(It.IsAny()), Times.Once()); + } + + [Test] + public void should_update_moved_files() + { + GivenTrackFiles(); + GivenMovedFiles(); + + Subject.Execute(new RenameFilesCommand(_artist.Id, new List { 1 })); + + Mocker.GetMock() + .Verify(v => v.Update(It.IsAny()), Times.Exactly(2)); + } + + [Test] + public void should_get_trackfiles_by_ids_only() + { + GivenTrackFiles(); + GivenMovedFiles(); + + var files = new List { 1 }; + + Subject.Execute(new RenameFilesCommand(_artist.Id, files)); + + Mocker.GetMock() + .Verify(v => v.Get(files), Times.Once()); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackFileMovingServiceTests/MoveTrackFileFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackFileMovingServiceTests/MoveTrackFileFixture.cs new file mode 100644 index 000000000..c5c0fa630 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackFileMovingServiceTests/MoveTrackFileFixture.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; +using NzbDrone.Test.Common; +using System.IO; + +namespace NzbDrone.Core.Test.MediaFiles.TrackFileMovingServiceTests +{ + [TestFixture] + public class MoveTrackFileFixture : CoreTest + { + private Artist _artist; + private TrackFile _trackFile; + private LocalTrack _localtrack; + + [SetUp] + public void Setup() + { + _artist = Builder.CreateNew() + .With(s => s.Path = @"C:\Test\Music\Artist".AsOsAgnostic()) + .Build(); + + _trackFile = Builder.CreateNew() + .With(f => f.Path = null) + .With(f => f.Path = Path.Combine(_artist.Path, @"Album\File.mp3")) + .Build(); + + _localtrack = Builder.CreateNew() + .With(l => l.Artist = _artist) + .With(l => l.Tracks = Builder.CreateListOfSize(1).Build().ToList()) + .Build(); + + Mocker.GetMock() + .Setup(s => s.BuildTrackFileName(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), null, null)) + .Returns("File Name"); + + Mocker.GetMock() + .Setup(s => s.BuildTrackFilePath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(@"C:\Test\Music\Artist\Album\File Name.mp3".AsOsAgnostic()); + + Mocker.GetMock() + .Setup(s => s.BuildAlbumPath(It.IsAny(), It.IsAny())) + .Returns(@"C:\Test\Music\Artist\Album".AsOsAgnostic()); + + var rootFolder = @"C:\Test\Music\".AsOsAgnostic(); + Mocker.GetMock() + .Setup(s => s.FolderExists(rootFolder)) + .Returns(true); + + Mocker.GetMock() + .Setup(s => s.FileExists(It.IsAny())) + .Returns(true); + } + + [Test] + public void should_catch_UnauthorizedAccessException_during_folder_inheritance() + { + WindowsOnly(); + + Mocker.GetMock() + .Setup(s => s.InheritFolderPermissions(It.IsAny())) + .Throws(); + + Subject.MoveTrackFile(_trackFile, _localtrack); + } + + [Test] + public void should_catch_InvalidOperationException_during_folder_inheritance() + { + WindowsOnly(); + + Mocker.GetMock() + .Setup(s => s.InheritFolderPermissions(It.IsAny())) + .Throws(); + + Subject.MoveTrackFile(_trackFile, _localtrack); + } + + [Test] + public void should_notify_on_artist_folder_creation() + { + Subject.MoveTrackFile(_trackFile, _localtrack); + + Mocker.GetMock() + .Verify(s => s.PublishEvent(It.Is(p => + p.ArtistFolder.IsNotNullOrWhiteSpace())), Times.Once()); + } + + [Test] + public void should_notify_on_album_folder_creation() + { + Subject.MoveTrackFile(_trackFile, _localtrack); + + Mocker.GetMock() + .Verify(s => s.PublishEvent(It.Is(p => + p.AlbumFolder.IsNotNullOrWhiteSpace())), Times.Once()); + } + + [Test] + public void should_not_notify_if_artist_folder_already_exists() + { + Mocker.GetMock() + .Setup(s => s.FolderExists(_artist.Path)) + .Returns(true); + + Subject.MoveTrackFile(_trackFile, _localtrack); + + Mocker.GetMock() + .Verify(s => s.PublishEvent(It.Is(p => + p.ArtistFolder.IsNotNullOrWhiteSpace())), Times.Never()); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Aggregation/AggregateFilenameInfoFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Aggregation/AggregateFilenameInfoFixture.cs new file mode 100644 index 000000000..4819ba958 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Aggregation/AggregateFilenameInfoFixture.cs @@ -0,0 +1,202 @@ +using NUnit.Framework; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Parser.Model; +using System.Collections.Generic; +using System.Linq; +using System.IO; +using NzbDrone.Test.Common; +using NzbDrone.Core.MediaFiles.TrackImport.Aggregation.Aggregators; +using FluentAssertions; +using System.Text; +using System; +using System.Collections; + +namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Aggregation.Aggregators +{ + [TestFixture] + public class AggregateFilenameInfoFixture : CoreTest + { + + private LocalAlbumRelease GivenTracks(List files, string root) + { + var tracks = files.Select(x => new LocalTrack { + Path = Path.Combine(root, x), + FileTrackInfo = new ParsedTrackInfo { + TrackNumbers = new [] { 0 }, + } + }).ToList(); + return new LocalAlbumRelease(tracks); + } + + private void VerifyData(LocalTrack track, string artist, string title, int trackNum, int disc) + { + track.FileTrackInfo.ArtistTitle.Should().Be(artist); + track.FileTrackInfo.Title.Should().Be(title); + track.FileTrackInfo.TrackNumbers[0].Should().Be(trackNum); + track.FileTrackInfo.DiscNumber.Should().Be(disc); + } + + [Test] + public void should_aggregate_filenames_example() + { + var release = GivenTracks(new List { + "Adele - 19 - 101 - Daydreamer.mp3", + "Adele - 19 - 102 - Best for Last.mp3", + "Adele - 19 - 103 - Chasing Pavements.mp3", + "Adele - 19 - 203 - That's It, I Quit, I'm Moving On.mp3" + }, @"C:\incoming".AsOsAgnostic()); + + Subject.Aggregate(release, true); + + VerifyData(release.LocalTracks[0], "Adele", "Daydreamer", 1, 1); + VerifyData(release.LocalTracks[1], "Adele", "Best for Last", 2, 1); + VerifyData(release.LocalTracks[2], "Adele", "Chasing Pavements", 3, 1); + VerifyData(release.LocalTracks[3], "Adele", "That's It, I Quit, I'm Moving On", 3, 2); + } + + public static class TestCaseFactory + { + private static List tokenList = new List { + + new [] {"trackNum2", "artist", "title", "tag"}, + new [] {"trackNum3", "artist", "title", "tag"}, + new [] {"trackNum2", "artist", "tag", "title"}, + new [] {"trackNum3", "artist", "tag", "title"}, + new [] {"trackNum2", "artist", "title"}, + new [] {"trackNum3", "artist", "title"}, + + new [] {"artist", "tag", "trackNum2", "title"}, + new [] {"artist", "tag", "trackNum3", "title"}, + new [] {"artist", "trackNum2", "title", "tag"}, + new [] {"artist", "trackNum3", "title", "tag"}, + new [] {"artist", "trackNum2", "title"}, + new [] {"artist", "trackNum3", "title"}, + + new [] {"artist", "title", "tag"}, + new [] {"artist", "tag", "title"}, + new [] {"artist", "title"}, + + new [] {"trackNum2", "title"}, + new [] {"trackNum3", "title"}, + + new [] {"title"}, + }; + + private static List> separators = new List> { + Tuple.Create(" - ", " "), + Tuple.Create("_", " "), + Tuple.Create("-", "_") + }; + + private static List> otherCases = new List> { + Tuple.Create(new [] {"track2", "title"}, " ", " "), + Tuple.Create(new [] {"track3", "title"}, " ", " ") + }; + + public static IEnumerable TestCases + { + get + { + int i = 0; + + foreach (var tokens in tokenList) + { + foreach (var separator in separators) + { + i++; + yield return new TestCaseData(Tuple.Create(tokens, separator.Item1, separator.Item2)) + .SetName($"should_aggregate_filenames_auto_{i}") + .SetDescription($"tokens: {string.Join(", ", tokens)}, separator: '{separator.Item1}', whitespace: '{separator.Item2}'"); + } + } + + // and a few other cases where all the permutations don't make sense + foreach (var item in otherCases) + { + i++; + yield return new TestCaseData(item) + .SetName($"should_aggregate_filenames_auto_{i}") + .SetDescription($"tokens: {string.Join(", ", item.Item1)}, separator: '{item.Item2}', whitespace: '{item.Item3}'"); + } + } + } + } + + private List GivenFilenames(string[] fields, string fieldSeparator, string whitespace) + { + var outp = new List(); + for (int i = 1; i <= 3; i++) + { + var components = new List(); + foreach (var field in fields) + { + switch(field) + { + case "artist": + components.Add("artist name".Replace(" ", whitespace)); + break; + case "tag": + components.Add("tag string ignore".Replace(" ", whitespace)); + break; + case "title": + components.Add($"{(char)(96+i)} track title {i}".Replace(" ", whitespace)); + break; + case "trackNum2": + components.Add(i.ToString("00")); + break; + case "trackNum3": + components.Add((100 + i).ToString("000")); + break; + } + } + outp.Add(string.Join(fieldSeparator, components) + ".mp3"); + } + + return outp; + } + + private void VerifyDataAuto(List tracks, string[] tokens, string whitespace) + { + for (int i = 1; i <= tracks.Count; i++) + { + var info = tracks[i-1].FileTrackInfo; + + if (tokens.Contains("artist")) + { + info.ArtistTitle.Should().Be("artist name".Replace(" ", whitespace)); + } + + if (tokens.Contains("title")) + { + info.Title.Should().Be($"{(char)(96+i)} track title {i}".Replace(" ", whitespace)); + } + + if (tokens.Contains("trackNum2") || tokens.Contains("trackNum3")) + { + info.TrackNumbers[0].Should().Be(i); + } + + if (tokens.Contains("trackNum3")) + { + info.DiscNumber.Should().Be(1); + } + else + { + info.DiscNumber.Should().Be(0); + } + } + } + + [Test, TestCaseSource(typeof(TestCaseFactory), "TestCases")] + public void should_aggregate_filenames_auto(Tuple testcase) + { + var files = GivenFilenames(testcase.Item1, testcase.Item2, testcase.Item3); + var release = GivenTracks(files, @"C:\incoming".AsOsAgnostic()); + + Subject.Aggregate(release, true); + + VerifyDataAuto(release.LocalTracks, testcase.Item1, testcase.Item3); + } + + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/AlbumDistanceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/AlbumDistanceFixture.cs new file mode 100644 index 000000000..d311b450c --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/AlbumDistanceFixture.cs @@ -0,0 +1,264 @@ +using NUnit.Framework; +using NzbDrone.Core.MediaFiles.TrackImport.Identification; +using FluentAssertions; +using NzbDrone.Core.Test.Framework; +using FizzWare.NBuilder; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Music; +using System.Collections.Generic; +using System.Linq; +using System; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification +{ + [TestFixture] + public class AlbumDistanceFixture : CoreTest + { + + private ArtistMetadata artist; + + [SetUp] + public void Setup() + { + artist = Builder + .CreateNew() + .With(x => x.Name = "artist") + .Build(); + } + + private List GivenTracks(int count) + { + return Builder + .CreateListOfSize(count) + .All() + .With(x => x.ArtistMetadata = artist) + .With(x => x.MediumNumber = 1) + .Build() + .ToList(); + } + + private LocalTrack GivenLocalTrack(Track track, AlbumRelease release) + { + var fileInfo = Builder + .CreateNew() + .With(x => x.Title = track.Title) + .With(x => x.CleanTitle = track.Title.CleanTrackTitle()) + .With(x => x.AlbumTitle = release.Title) + .With(x => x.Disambiguation = release.Disambiguation) + .With(x => x.ReleaseMBId = release.ForeignReleaseId) + .With(x => x.ArtistTitle = track.ArtistMetadata.Value.Name) + .With(x => x.TrackNumbers = new[] { track.AbsoluteTrackNumber }) + .With(x => x.DiscCount = release.Media.Count) + .With(x => x.DiscNumber = track.MediumNumber) + .With(x => x.RecordingMBId = track.ForeignRecordingId) + .With(x => x.Country = IsoCountries.Find("US")) + .With(x => x.Label = release.Label.First()) + .With(x => x.Year = (uint)(release.Album.Value.ReleaseDate?.Year ?? 0)) + .Build(); + + var localTrack = Builder + .CreateNew() + .With(x => x.FileTrackInfo = fileInfo) + .Build(); + + return localTrack; + } + + private List GivenLocalTracks(List tracks, AlbumRelease release) + { + var output = new List(); + foreach (var track in tracks) + { + output.Add(GivenLocalTrack(track, release)); + } + return output; + } + + private AlbumRelease GivenAlbumRelease(string title, List tracks) + { + var album = Builder + .CreateNew() + .With(x => x.Title = title) + .With(x => x.ArtistMetadata = artist) + .Build(); + + var media = Builder + .CreateListOfSize(tracks.Max(x => x.MediumNumber)) + .Build() + .ToList(); + + return Builder + .CreateNew() + .With(x => x.Tracks = tracks) + .With(x => x.Title = title) + .With(x => x.Album = album) + .With(x => x.Media = media) + .With(x => x.Country = new List { "United States" }) + .With(x => x.Label = new List { "label" }) + .Build(); + } + + private TrackMapping GivenMapping(List local, List remote) + { + var mapping = new TrackMapping(); + var distances = local.Zip(remote, (l, r) => Tuple.Create(r, Subject.TrackDistance(l, r, Subject.GetTotalTrackNumber(r, remote)))); + mapping.Mapping = local.Zip(distances, (l, r) => new { l, r }).ToDictionary(x => x.l, x => x.r); + mapping.LocalExtra = local.Except(mapping.Mapping.Keys).ToList(); + mapping.MBExtra = remote.Except(mapping.Mapping.Values.Select(x => x.Item1)).ToList(); + + return mapping; + } + + [Test] + public void test_identical_albums() + { + var tracks = GivenTracks(3); + var release = GivenAlbumRelease("album", tracks); + var localTracks = GivenLocalTracks(tracks, release); + var mapping = GivenMapping(localTracks, tracks); + + Subject.AlbumReleaseDistance(localTracks, release, mapping).NormalizedDistance().Should().Be(0.0); + } + + [Test] + public void test_incomplete_album() + { + var tracks = GivenTracks(3); + var release = GivenAlbumRelease("album", tracks); + var localTracks = GivenLocalTracks(tracks, release); + localTracks.RemoveAt(1); + var mapping = GivenMapping(localTracks, tracks); + + var dist = Subject.AlbumReleaseDistance(localTracks, release, mapping); + dist.NormalizedDistance().Should().NotBe(0.0); + dist.NormalizedDistance().Should().BeLessThan(0.2); + } + + [Test] + public void test_global_artists_differ() + { + var tracks = GivenTracks(3); + var release = GivenAlbumRelease("album", tracks); + var localTracks = GivenLocalTracks(tracks, release); + var mapping = GivenMapping(localTracks, tracks); + + release.Album.Value.ArtistMetadata = Builder + .CreateNew() + .With(x => x.Name = "different artist") + .Build(); + + Subject.AlbumReleaseDistance(localTracks, release, mapping).NormalizedDistance().Should().NotBe(0.0); + } + + [Test] + public void test_comp_track_artists_match() + { + var tracks = GivenTracks(3); + var release = GivenAlbumRelease("album", tracks); + var localTracks = GivenLocalTracks(tracks, release); + var mapping = GivenMapping(localTracks, tracks); + + release.Album.Value.ArtistMetadata = Builder + .CreateNew() + .With(x => x.Name = "Various Artists") + .With(x => x.ForeignArtistId = "89ad4ac3-39f7-470e-963a-56509c546377") + .Build(); + + Subject.AlbumReleaseDistance(localTracks, release, mapping).NormalizedDistance().Should().Be(0.0); + } + + // TODO: there are a couple more VA tests in beets but we don't support VA yet anyway + + [Test] + public void test_tracks_out_of_order() + { + var tracks = GivenTracks(3); + var release = GivenAlbumRelease("album", tracks); + var localTracks = GivenLocalTracks(tracks, release); + localTracks = new [] {1, 3, 2}.Select(x => localTracks[x-1]).ToList(); + var mapping = GivenMapping(localTracks, tracks); + + var dist = Subject.AlbumReleaseDistance(localTracks, release, mapping); + dist.NormalizedDistance().Should().NotBe(0.0); + dist.NormalizedDistance().Should().BeLessThan(0.2); + } + + [Test] + public void test_two_medium_release() + { + var tracks = GivenTracks(3); + tracks[2].AbsoluteTrackNumber = 1; + tracks[2].MediumNumber = 2; + var release = GivenAlbumRelease("album", tracks); + var localTracks = GivenLocalTracks(tracks, release); + var mapping = GivenMapping(localTracks, tracks); + + Subject.AlbumReleaseDistance(localTracks, release, mapping).NormalizedDistance().Should().Be(0.0); + } + + [Test] + public void test_absolute_track_numbering() + { + var tracks = GivenTracks(3); + tracks[2].AbsoluteTrackNumber = 1; + tracks[2].MediumNumber = 2; + var release = GivenAlbumRelease("album", tracks); + var localTracks = GivenLocalTracks(tracks, release); + localTracks[2].FileTrackInfo.DiscNumber = 2; + localTracks[2].FileTrackInfo.TrackNumbers = new[] { 3 }; + + var mapping = GivenMapping(localTracks, tracks); + + Subject.AlbumReleaseDistance(localTracks, release, mapping).NormalizedDistance().Should().Be(0.0); + } + + private static DateTime?[] dates = new DateTime?[] { null, new DateTime(2007, 1, 1), DateTime.Now }; + + [TestCaseSource("dates")] + public void test_null_album_year(DateTime? releaseDate) + { + var tracks = GivenTracks(3); + var release = GivenAlbumRelease("album", tracks); + var localTracks = GivenLocalTracks(tracks, release); + var mapping = GivenMapping(localTracks, tracks); + + release.Album.Value.ReleaseDate = null; + release.ReleaseDate = releaseDate; + + var result = Subject.AlbumReleaseDistance(localTracks, release, mapping).NormalizedDistance(); + + if (!releaseDate.HasValue || (localTracks[0].FileTrackInfo.Year == (releaseDate?.Year ?? 0))) + { + result.Should().Be(0.0); + } + else + { + result.Should().NotBe(0.0); + } + } + + [TestCaseSource("dates")] + public void test_null_release_year(DateTime? albumDate) + { + var tracks = GivenTracks(3); + var release = GivenAlbumRelease("album", tracks); + var localTracks = GivenLocalTracks(tracks, release); + var mapping = GivenMapping(localTracks, tracks); + + release.Album.Value.ReleaseDate = albumDate; + release.ReleaseDate = null; + + var result = Subject.AlbumReleaseDistance(localTracks, release, mapping).NormalizedDistance(); + + if (!albumDate.HasValue || (localTracks[0].FileTrackInfo.Year == (albumDate?.Year ?? 0))) + { + result.Should().Be(0.0); + } + else + { + result.Should().NotBe(0.0); + } + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/DistanceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/DistanceFixture.cs new file mode 100644 index 000000000..07a50bdca --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/DistanceFixture.cs @@ -0,0 +1,167 @@ +using NUnit.Framework; +using NzbDrone.Core.MediaFiles.TrackImport.Identification; +using NzbDrone.Test.Common; +using FluentAssertions; +using System.Collections.Generic; + +namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification +{ + [TestFixture] + public class DistanceFixture : TestBase + { + [Test] + public void test_add() + { + var dist = new Distance(); + dist.Add("add", 1.0); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"add", new List { 1.0 }}} ); + } + + [Test] + public void test_equality() + { + var dist = new Distance(); + dist.AddEquality("equality", "ghi", new List { "abc", "def", "ghi" }); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"equality", new List { 0.0 }}} ); + + dist.AddEquality("equality", "xyz", new List { "abc", "def", "ghi" }); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"equality", new List { 0.0, 1.0 }}} ); + + dist.AddEquality("equality", "abc", new List { "abc", "def", "ghi" }); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"equality", new List { 0.0, 1.0, 0.0 }}} ); + } + + [Test] + public void test_add_bool() + { + var dist = new Distance(); + dist.AddBool("expr", true); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"expr", new List { 1.0 }}} ); + + dist.AddBool("expr", false); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"expr", new List { 1.0, 0.0 }}} ); + } + + [Test] + public void test_add_number() + { + var dist = new Distance(); + dist.AddNumber("number", 1, 1); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"number", new List { 0.0 }}} ); + + dist.AddNumber("number", 1, 2); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"number", new List { 0.0, 1.0 }}} ); + + dist.AddNumber("number", 2, 1); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"number", new List { 0.0, 1.0, 1.0 }}} ); + + dist.AddNumber("number", -1, 2); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"number", new List { 0.0, 1.0, 1.0, 1.0, 1.0, 1.0 }}} ); + } + + [Test] + public void test_add_priority_value() + { + var dist = new Distance(); + dist.AddPriority("priority", "abc", new List { "abc" }); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"priority", new List { 0.0 }}} ); + + dist.AddPriority("priority", "def", new List { "abc", "def" }); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"priority", new List { 0.0, 0.5 }}} ); + + dist.AddPriority("priority", "xyz", new List { "abc", "def" }); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"priority", new List { 0.0, 0.5, 1.0 }}} ); + } + + [Test] + public void test_add_priority_list() + { + var dist = new Distance(); + dist.AddPriority("priority", new List { "abc" }, new List { "abc" }); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"priority", new List { 0.0 }}} ); + + dist.AddPriority("priority", new List { "def" }, new List { "abc" }); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"priority", new List { 0.0, 1.0 }}} ); + + dist.AddPriority("priority", new List { "abc", "xyz" }, new List { "abc" }); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"priority", new List { 0.0, 1.0, 0.0 }}} ); + + dist.AddPriority("priority", new List { "def", "xyz" }, new List { "abc", "def" }); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"priority", new List { 0.0, 1.0, 0.0, 0.5 }}} ); + } + + [Test] + public void test_add_ratio() + { + var dist = new Distance(); + dist.AddRatio("ratio", 25, 100); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"ratio", new List { 0.25 }}} ); + + dist.AddRatio("ratio", 10, 5); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"ratio", new List { 0.25, 1.0 }}} ); + + dist.AddRatio("ratio", -5, 5); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"ratio", new List { 0.25, 1.0, 0.0 }}} ); + + dist.AddRatio("ratio", 5, 0); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"ratio", new List { 0.25, 1.0, 0.0, 0.0 }}} ); + } + + [Test] + public void test_add_string() + { + var dist = new Distance(); + dist.AddString("string", "abcd", "bcde"); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"string", new List { 0.5 }}} ); + } + + [Test] + public void test_add_string_none() + { + var dist = new Distance(); + dist.AddString("string", string.Empty, "bcd"); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"string", new List { 1.0 }}} ); + } + + [Test] + public void test_add_string_both_none() + { + var dist = new Distance(); + dist.AddString("string", string.Empty, string.Empty); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"string", new List { 0.0 }}} ); + } + + [Test] + public void test_distance() + { + var dist = new Distance(); + dist.Add("album", 0.5); + dist.Add("media_count", 0.25); + dist.Add("media_count", 0.75); + + dist.NormalizedDistance().Should().Be(0.5); + } + + [Test] + public void test_max_distance() + { + var dist = new Distance(); + dist.Add("album", 0.5); + dist.Add("media_count", 0.0); + dist.Add("media_count", 0.0); + + dist.MaxDistance().Should().Be(5.0); + } + + [Test] + public void test_raw_distance() + { + var dist = new Distance(); + dist.Add("album", 0.5); + dist.Add("media_count", 0.25); + dist.Add("media_count", 0.5); + + dist.RawDistance().Should().Be(2.25); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/GetCandidatesFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/GetCandidatesFixture.cs new file mode 100644 index 000000000..c73c7ad50 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/GetCandidatesFixture.cs @@ -0,0 +1,159 @@ +using NUnit.Framework; +using NzbDrone.Core.MediaFiles.TrackImport.Identification; +using FluentAssertions; +using NzbDrone.Core.Test.Framework; +using FizzWare.NBuilder; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Music; +using System.Collections.Generic; +using System.Linq; +using System; +using NzbDrone.Core.Parser; +using NzbDrone.Common.Serializer; +using Moq; + +namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification +{ + [TestFixture] + public class GetCandidatesFixture : CoreTest + { + + private ArtistMetadata artist; + + [SetUp] + public void Setup() + { + artist = Builder + .CreateNew() + .With(x => x.Name = "artist") + .Build(); + } + + private List GivenTracks(int count) + { + return Builder + .CreateListOfSize(count) + .All() + .With(x => x.ArtistMetadata = artist) + .Build() + .ToList(); + } + + private ParsedTrackInfo GivenParsedTrackInfo(Track track, AlbumRelease release) + { + return Builder + .CreateNew() + .With(x => x.Title = track.Title) + .With(x => x.AlbumTitle = release.Title) + .With(x => x.Disambiguation = release.Disambiguation) + .With(x => x.ReleaseMBId = release.ForeignReleaseId) + .With(x => x.ArtistTitle = track.ArtistMetadata.Value.Name) + .With(x => x.TrackNumbers = new[] { track.AbsoluteTrackNumber }) + .With(x => x.RecordingMBId = track.ForeignRecordingId) + .With(x => x.Country = IsoCountries.Find("US")) + .With(x => x.Label = release.Label.First()) + .With(x => x.Year = (uint)release.Album.Value.ReleaseDate.Value.Year) + .Build(); + } + + private List GivenLocalTracks(List tracks, AlbumRelease release) + { + var output = Builder + .CreateListOfSize(tracks.Count) + .Build() + .ToList(); + + for (int i = 0; i < tracks.Count; i++) + { + output[i].FileTrackInfo = GivenParsedTrackInfo(tracks[i], release); + } + + return output; + } + + private AlbumRelease GivenAlbumRelease(string title, List tracks) + { + var album = Builder + .CreateNew() + .With(x => x.Title = title) + .With(x => x.ArtistMetadata = artist) + .Build(); + + var media = Builder + .CreateListOfSize(1) + .Build() + .ToList(); + + return Builder + .CreateNew() + .With(x => x.Tracks = tracks) + .With(x => x.Title = title) + .With(x => x.Album = album) + .With(x => x.Media = media) + .With(x => x.Country = new List()) + .With(x => x.Label = new List { "label" }) + .With(x => x.ForeignReleaseId = null) + .Build(); + } + + private LocalAlbumRelease GivenLocalAlbumRelease() + { + var tracks = GivenTracks(3); + var release = GivenAlbumRelease("album", tracks); + var localTracks = GivenLocalTracks(tracks, release); + + return new LocalAlbumRelease(localTracks); + } + + [Test] + public void get_candidates_by_fingerprint_should_not_fail_if_fingerprint_lookup_returned_null() + { + Mocker.GetMock() + .Setup(x => x.Lookup(It.IsAny>(), It.IsAny())) + .Callback((List x, double thres) => { + foreach(var track in x) { + track.AcoustIdResults = null; + } + }); + + Mocker.GetMock() + .Setup(x => x.GetReleasesByRecordingIds(It.IsAny>())) + .Returns(new List()); + + var local = GivenLocalAlbumRelease(); + + Subject.GetCandidatesFromFingerprint(local, null, null, null, false).ShouldBeEquivalentTo(new List()); + } + + [Test] + public void get_candidates_should_only_return_specified_release_if_set() + { + var tracks = GivenTracks(3); + var release = GivenAlbumRelease("album", tracks); + var localTracks = GivenLocalTracks(tracks, release); + var localAlbumRelease = new LocalAlbumRelease(localTracks); + + Subject.GetCandidatesFromTags(localAlbumRelease, null, null, release, false).ShouldBeEquivalentTo( + new List { new CandidateAlbumRelease(release) } + ); + } + + [Test] + public void get_candidates_should_use_consensus_release_id() + { + var tracks = GivenTracks(3); + var release = GivenAlbumRelease("album", tracks); + release.ForeignReleaseId = "xxx"; + var localTracks = GivenLocalTracks(tracks, release); + var localAlbumRelease = new LocalAlbumRelease(localTracks); + + Mocker.GetMock() + .Setup(x => x.GetReleaseByForeignReleaseId("xxx", true)) + .Returns(release); + + Subject.GetCandidatesFromTags(localAlbumRelease, null, null, null, false).ShouldBeEquivalentTo( + new List { new CandidateAlbumRelease(release) } + ); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/IdentificationServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/IdentificationServiceFixture.cs new file mode 100644 index 000000000..32ea75906 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/IdentificationServiceFixture.cs @@ -0,0 +1,189 @@ +using System.IO; +using System.Linq; +using System.Collections; +using FluentAssertions; +using FluentValidation.Results; +using Moq; +using Newtonsoft.Json; +using NUnit.Framework; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.MediaFiles.TrackImport.Identification; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.MetadataSource; +using NzbDrone.Core.MetadataSource.SkyHook; +using NzbDrone.Core.Music; +using NzbDrone.Core.Music.Commands; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Profiles.Metadata; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common; +using System.Collections.Generic; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Parser; +using NzbDrone.Core.MediaFiles.TrackImport.Aggregation.Aggregators; +using NzbDrone.Core.MediaFiles.TrackImport.Aggregation; + +namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification +{ + [TestFixture] + public class IdentificationServiceFixture : DbTest + { + private ArtistService _artistService; + private AddArtistService _addArtistService; + private RefreshArtistService _refreshArtistService; + + private IdentificationService Subject; + + [SetUp] + public void SetUp() + { + UseRealHttp(); + + // Resolve all the parts we need + Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); + + Mocker.GetMock().Setup(x => x.Exists(It.IsAny())).Returns(true); + + _artistService = Mocker.Resolve(); + Mocker.SetConstant(_artistService); + Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); + + Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); + + _addArtistService = Mocker.Resolve(); + + Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); + _refreshArtistService = Mocker.Resolve(); + + Mocker.GetMock().Setup(x => x.Validate(It.IsAny())).Returns(new ValidationResult()); + + Mocker.SetConstant(Mocker.Resolve()); + + // set up the augmenters + List> aggregators = new List> { + Mocker.Resolve() + }; + Mocker.SetConstant>>(aggregators); + Mocker.SetConstant(Mocker.Resolve()); + + Subject = Mocker.Resolve(); + + } + + private void GivenMetadataProfile(MetadataProfile profile) + { + Mocker.GetMock().Setup(x => x.Get(profile.Id)).Returns(profile); + } + + private List GivenArtists(List artists) + { + var outp = new List(); + for (int i = 0; i < artists.Count; i++) + { + var meta = artists[i].MetadataProfile; + meta.Id = i + 1; + GivenMetadataProfile(meta); + outp.Add(GivenArtist(artists[i].Artist, meta.Id)); + } + + return outp; + } + + private Artist GivenArtist(string foreignArtistId, int metadataProfileId) + { + var artist = _addArtistService.AddArtist(new Artist { + Metadata = new ArtistMetadata { + ForeignArtistId = foreignArtistId + }, + Path = @"c:\test".AsOsAgnostic(), + MetadataProfileId = metadataProfileId + }); + + var command = new RefreshArtistCommand{ + ArtistId = artist.Id, + Trigger = CommandTrigger.Unspecified + }; + + _refreshArtistService.Execute(command); + + return _artistService.FindById(foreignArtistId); + } + + private void GivenFingerprints(List fingerprints) + { + Mocker.GetMock().Setup(x => x.AllowFingerprinting).Returns(AllowFingerprinting.AllFiles); + Mocker.GetMock().Setup(x => x.IsSetup()).Returns(true); + + Mocker.GetMock() + .Setup(x => x.Lookup(It.IsAny>(), It.IsAny())) + .Callback((List track, double thres) => { + track.ForEach(x => x.AcoustIdResults = fingerprints.SingleOrDefault(f => f.Path == x.Path).AcoustIdResults); + }); + } + + public static class IdTestCaseFactory + { + // for some reason using Directory.GetFiles causes nUnit to error + private static string[] files = { + "FilesWithMBIds.json", + "PreferMissingToBadMatch.json", + "InconsistentTyposInAlbum.json", + "SucceedWhenManyAlbumsHaveSameTitle.json", + "PenalizeUnknownMedia.json", + "CorruptFile.json", + "FilesWithoutTags.json" + }; + + public static IEnumerable TestCases + { + get + { + foreach (var file in files) + { + yield return new TestCaseData(file).SetName($"should_match_tracks_{file.Replace(".json", "")}"); + } + } + } + } + + // these are slow to run so only do so manually + [Explicit] + [Test, TestCaseSource(typeof(IdTestCaseFactory), "TestCases")] + public void should_match_tracks(string file) + { + var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Identification", file); + var testcase = JsonConvert.DeserializeObject(File.ReadAllText(path)); + + var artists = GivenArtists(testcase.LibraryArtists); + var specifiedArtist = artists.SingleOrDefault(x => x.Metadata.Value.ForeignArtistId == testcase.Artist); + + var tracks = testcase.Tracks.Select(x => new LocalTrack { + Path = x.Path.AsOsAgnostic(), + FileTrackInfo = x.FileTrackInfo + }).ToList(); + + if (testcase.Fingerprints != null) + { + GivenFingerprints(testcase.Fingerprints); + } + + var result = Subject.Identify(tracks, specifiedArtist, null, null, testcase.NewDownload, testcase.SingleRelease, false); + + TestLogger.Debug($"Found releases:\n{result.Where(x => x.AlbumRelease != null).Select(x => x.AlbumRelease?.ForeignReleaseId).ToJson()}"); + + result.Should().HaveCount(testcase.ExpectedMusicBrainzReleaseIds.Count); + result.Where(x => x.AlbumRelease != null).Select(x => x.AlbumRelease.ForeignReleaseId).ShouldBeEquivalentTo(testcase.ExpectedMusicBrainzReleaseIds); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/MunkresFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/MunkresFixture.cs new file mode 100644 index 000000000..b9e7ab06c --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/MunkresFixture.cs @@ -0,0 +1,184 @@ +using NUnit.Framework; +using NzbDrone.Core.MediaFiles.TrackImport.Identification; +using NzbDrone.Test.Common; +using FluentAssertions; + +namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification +{ + [TestFixture] + public class MunkresFixture : TestBase + { + // 2d arrays don't play nicely with attributes + public void RunTest(double[,] costMatrix, double expectedCost) + { + var m = new Munkres(costMatrix); + m.Run(); + m.Cost.Should().Be(expectedCost); + } + + [Test] + public void MunkresSquareTest1() + { + var C = new double[,] { + { 1, 2, 3 }, + { 2, 4, 6 }, + { 3, 6, 9 } + }; + + RunTest(C, 10); + + } + + [Test] + public void MunkresSquareTest2() + { + var C = new double[,] { + { 400, 150, 400 }, + { 400, 450, 600 }, + { 300, 225, 300 } + }; + + RunTest(C, 850); + } + + [Test] + public void MunkresSquareTest3() + { + var C = new double[,] { + { 10, 10, 8 }, + { 9, 8, 1 }, + { 9, 7, 4 } + }; + + RunTest(C, 18); + } + + [Test] + public void MunkresSquareTest4() + { + var C = new double[,] { + { 5, 9, 1 }, + { 10, 3, 2 }, + { 8, 7, 4 } + }; + + RunTest(C, 12); + } + + [Test] + public void MunkresSquareTest5() + { + var C = new double[,] { + {12, 26, 17, 0, 0}, + {49, 43, 36, 10, 5}, + {97, 9, 66, 34, 0}, + {52, 42, 19, 36, 0}, + {15, 93, 55, 80, 0} + }; + + RunTest(C, 48); + } + + [Test] + public void Munkres5x5Test() + { + var C = new double[,] { + {12, 9, 27, 10, 23}, + {7, 13, 13, 30, 19}, + {25, 18, 26, 11, 26}, + {9, 28, 26, 23, 13}, + {16, 16, 24, 6, 9} + }; + + RunTest(C, 51); + } + + [Test] + public void Munkres10x10Test() + { + var C = new double[,] { + {37, 34, 29, 26, 19, 8, 9, 23, 19, 29}, + {9, 28, 20, 8, 18, 20, 14, 33, 23, 14}, + {15, 26, 12, 28, 6, 17, 9, 13, 21, 7}, + {2, 8, 38, 36, 39, 5, 36, 2, 38, 27}, + {30, 3, 33, 16, 21, 39, 7, 23, 28, 36}, + {7, 5, 19, 22, 36, 36, 24, 19, 30, 2}, + {34, 20, 13, 36, 12, 33, 9, 10, 23, 5}, + {7, 37, 22, 39, 33, 39, 10, 3, 13, 26}, + {21, 25, 23, 39, 31, 37, 32, 33, 38, 1}, + {17, 34, 40, 10, 29, 37, 40, 3, 25, 3} + }; + + RunTest(C, 66); + } + + [Test] + public void Munkres20x20Test() + { + var C = new double[,] { + {5, 4, 3, 9, 8, 9, 3, 5, 6, 9, 4, 10, 3, 5, 6, 6, 1, 8, 10, 2}, + {10, 9, 9, 2, 8, 3, 9, 9, 10, 1, 7, 10, 8, 4, 2, 1, 4, 8, 4, 8}, + {10, 4, 4, 3, 1, 3, 5, 10, 6, 8, 6, 8, 4, 10, 7, 2, 4, 5, 1, 8}, + {2, 1, 4, 2, 3, 9, 3, 4, 7, 3, 4, 1, 3, 2, 9, 8, 6, 5, 7, 8}, + {3, 4, 4, 1, 4, 10, 1, 2, 6, 4, 5, 10, 2, 2, 3, 9, 10, 9, 9, 10}, + {1, 10, 1, 8, 1, 3, 1, 7, 1, 1, 2, 1, 2, 6, 3, 3, 4, 4, 8, 6}, + {1, 8, 7, 10, 10, 3, 4, 6, 1, 6, 6, 4, 9, 6, 9, 6, 4, 5, 4, 7}, + {8, 10, 3, 9, 4, 9, 3, 3, 4, 6, 4, 2, 6, 7, 7, 4, 4, 3, 4, 7}, + {1, 3, 8, 2, 6, 9, 2, 7, 4, 8, 10, 8, 10, 5, 1, 3, 10, 10, 2, 9}, + {2, 4, 1, 9, 2, 9, 7, 8, 2, 1, 4, 10, 5, 2, 7, 6, 5, 7, 2, 6}, + {4, 5, 1, 4, 2, 3, 3, 4, 1, 8, 8, 2, 6, 9, 5, 9, 6, 3, 9, 3}, + {3, 1, 1, 8, 6, 8, 8, 7, 9, 3, 2, 1, 8, 2, 4, 7, 3, 1, 2, 4}, + {5, 9, 8, 6, 10, 4, 10, 3, 4, 10, 10, 10, 1, 7, 8, 8, 7, 7, 8, 8}, + {1, 4, 6, 1, 6, 1, 2, 10, 5, 10, 2, 6, 2, 4, 5, 5, 3, 5, 1, 5}, + {5, 6, 9, 10, 6, 6, 10, 6, 4, 1, 5, 3, 9, 5, 2, 10, 9, 9, 5, 1}, + {10, 9, 4, 6, 9, 5, 3, 7, 10, 1, 6, 8, 1, 1, 10, 9, 5, 7, 7, 5}, + {2, 6, 6, 6, 6, 2, 9, 4, 7, 5, 3, 2, 10, 3, 4, 5, 10, 9, 1, 7}, + {5, 2, 4, 9, 8, 4, 8, 2, 4, 1, 3, 7, 6, 8, 1, 6, 8, 8, 10, 10}, + {9, 6, 3, 1, 8, 5, 7, 8, 7, 2, 1, 8, 2, 8, 3, 7, 4, 8, 7, 7}, + {8, 4, 4, 9, 7, 10, 6, 2, 1, 5, 8, 5, 1, 1, 1, 9, 1, 3, 5, 3} + }; + + RunTest(C, 22); + } + + [Test] + public void MunkresRectangularTest1() + { + var C = new double[,] { + { 400, 150, 400, 1 }, + { 400, 450, 600, 2 }, + { 300, 225, 300, 3 } + }; + + RunTest(C, 452); + } + + [Test] + public void MunkresRectangularTest2() + { + var C = new double[,] { + { 10, 10, 8, 11 }, + { 9, 8, 1, 1 }, + { 9, 7, 4, 10 } + }; + + RunTest(C, 15); + } + + [Test] + public void MunkresRectangularTest3() + { + var C = new double[,] { + {34, 26, 17, 12}, + {43, 43, 36, 10}, + {97, 47, 66, 34}, + {52, 42, 19, 36}, + {15, 93, 55, 80} + }; + + RunTest(C, 70); + } + + } +} + diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/TrackDistanceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/TrackDistanceFixture.cs new file mode 100644 index 000000000..cda57ecaa --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/TrackDistanceFixture.cs @@ -0,0 +1,99 @@ +using NUnit.Framework; +using NzbDrone.Core.MediaFiles.TrackImport.Identification; +using FluentAssertions; +using NzbDrone.Core.Test.Framework; +using FizzWare.NBuilder; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Music; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification +{ + [TestFixture] + public class TrackDistanceFixture : CoreTest + { + private Track GivenTrack(string title) + { + var artist = Builder + .CreateNew() + .With(x => x.Name = "artist") + .Build(); + + var mbTrack = Builder + .CreateNew() + .With(x => x.Title = title) + .With(x => x.ArtistMetadata = artist) + .Build(); + + return mbTrack; + } + + private LocalTrack GivenLocalTrack(Track track) + { + var fileInfo = Builder + .CreateNew() + .With(x => x.Title = track.Title) + .With(x => x.CleanTitle = track.Title.CleanTrackTitle()) + .With(x => x.ArtistTitle = track.ArtistMetadata.Value.Name) + .With(x => x.TrackNumbers = new[] { 1 }) + .With(x => x.RecordingMBId = track.ForeignRecordingId) + .Build(); + + var localTrack = Builder + .CreateNew() + .With(x => x.FileTrackInfo = fileInfo) + .Build(); + + return localTrack; + } + + [Test] + public void test_identical_tracks() + { + var track = GivenTrack("one"); + var localTrack = GivenLocalTrack(track); + + Subject.TrackDistance(localTrack, track, 1, true).NormalizedDistance().Should().Be(0.0); + } + + [Test] + public void test_feat_removed_from_localtrack() + { + var track = GivenTrack("one"); + var localTrack = GivenLocalTrack(track); + localTrack.FileTrackInfo.Title = "one (feat. two)"; + + Subject.TrackDistance(localTrack, track, 1, true).NormalizedDistance().Should().Be(0.0); + } + + [Test] + public void test_different_title() + { + var track = GivenTrack("one"); + var localTrack = GivenLocalTrack(track); + localTrack.FileTrackInfo.CleanTitle = "foo"; + + Subject.TrackDistance(localTrack, track, 1, true).NormalizedDistance().Should().NotBe(0.0); + } + + [Test] + public void test_different_artist() + { + var track = GivenTrack("one"); + var localTrack = GivenLocalTrack(track); + localTrack.FileTrackInfo.ArtistTitle = "foo"; + + Subject.TrackDistance(localTrack, track, 1, true).NormalizedDistance().Should().NotBe(0.0); + } + + [Test] + public void test_various_artists_tolerated() + { + var track = GivenTrack("one"); + var localTrack = GivenLocalTrack(track); + localTrack.FileTrackInfo.ArtistTitle = "Various Artists"; + + Subject.TrackDistance(localTrack, track, 1, true).NormalizedDistance().Should().Be(0.0); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/TrackGroupingServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/TrackGroupingServiceFixture.cs new file mode 100644 index 000000000..9e2bc3bf8 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/TrackGroupingServiceFixture.cs @@ -0,0 +1,408 @@ +using NUnit.Framework; +using NzbDrone.Core.MediaFiles.TrackImport.Identification; +using NzbDrone.Test.Common; +using FluentAssertions; +using NzbDrone.Core.Test.Framework; +using FizzWare.NBuilder; +using NzbDrone.Core.Parser.Model; +using System.Collections.Generic; +using System.Linq; +using System.IO; +using FizzWare.NBuilder.PropertyNaming; +using System.Reflection; +using System.Text; + +namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification +{ + // we need to use random strings to test the va (so we don't just get artist1, artist2 etc which are too similar) + // but the standard random value namer would give paths that are too long on windows + public class RandomValueNamerShortStrings : RandomValuePropertyNamer + { + private readonly IRandomGenerator generator; + private static readonly List allowedChars; + + public RandomValueNamerShortStrings(BuilderSettings settings) : base(settings) + { + generator = new RandomGenerator(); + } + + static RandomValueNamerShortStrings() + { + allowedChars = new List(); + for (char c = 'a'; c < 'z'; c++) + { + allowedChars.Add(c); + } + + for (char c = 'A'; c < 'Z'; c++) + { + allowedChars.Add(c); + } + + for (char c = '0'; c < '9'; c++) + { + allowedChars.Add(c); + } + } + + protected override string GetString(MemberInfo memberInfo) + { + int length = generator.Next(1, 100); + + char[] chars = new char[length]; + + for (int i = 0; i < length; i++) + { + int index = generator.Next(0, allowedChars.Count - 1); + chars[i] = allowedChars[index]; + } + + byte[] bytes = Encoding.UTF8.GetBytes(chars); + return Encoding.UTF8.GetString(bytes, 0, bytes.Length); + } + } + + [TestFixture] + public class TrackGroupingServiceFixture : CoreTest + { + private List GivenTracks(string root, string artist, string album, int count) + { + var fileInfos = Builder + .CreateListOfSize(count) + .All() + .With(f => f.ArtistTitle = artist) + .With(f => f.AlbumTitle = album) + .With(f => f.AlbumMBId = null) + .With(f => f.ReleaseMBId = null) + .Build(); + + var tracks = fileInfos.Select(x => Builder + .CreateNew() + .With(y => y.FileTrackInfo = x) + .With(y => y.Path = Path.Combine(root, x.Title)) + .Build()).ToList(); + + return tracks; + } + + private List GivenTracksWithNoTags(string root, int count) + { + var outp = new List(); + + for (int i = 0; i < count; i++) + { + var track = Builder + .CreateNew() + .With(y => y.FileTrackInfo = new ParsedTrackInfo()) + .With(y => y.Path = Path.Combine(root, $"{i}.mp3")) + .Build(); + outp.Add(track); + } + + return outp; + } + + [Repeat(100)] + private List GivenVaTracks(string root, string album, int count) + { + var settings = new BuilderSettings(); + settings.SetPropertyNamerFor(new RandomValueNamerShortStrings(settings)); + + var builder = new Builder(settings); + + var fileInfos = builder + .CreateListOfSize(count) + .All() + .With(f => f.AlbumTitle = "album") + .With(f => f.AlbumMBId = null) + .With(f => f.ReleaseMBId = null) + .Build(); + + var tracks = fileInfos.Select(x => Builder + .CreateNew() + .With(y => y.FileTrackInfo = x) + .With(y => y.Path = Path.Combine(@"C:\music\incoming".AsOsAgnostic(), x.Title)) + .Build()).ToList(); + + return tracks; + } + + [TestCase(1)] + [TestCase(2)] + [TestCase(10)] + public void single_artist_is_not_various_artists(int count) + { + var tracks = GivenTracks(@"C:\music\incoming".AsOsAgnostic(), "artist", "album", count); + TrackGroupingService.IsVariousArtists(tracks).Should().Be(false); + } + + // GivenVaTracks uses random names so repeat multiple times to try to prompt any intermittent failures + [Test] + [Repeat(100)] + public void all_different_artists_is_various_artists() + { + var tracks = GivenVaTracks(@"C:\music\incoming".AsOsAgnostic(), "album", 10); + TrackGroupingService.IsVariousArtists(tracks).Should().Be(true); + } + + [Test] + public void two_artists_is_not_various_artists() + { + var dir = @"C:\music\incoming".AsOsAgnostic(); + var tracks = GivenTracks(dir, "artist1", "album", 10); + tracks.AddRange(GivenTracks(dir, "artist2", "album", 10)); + + TrackGroupingService.IsVariousArtists(tracks).Should().Be(false); + } + + [Test] + [Repeat(100)] + public void mostly_different_artists_is_various_artists() + { + var dir = @"C:\music\incoming".AsOsAgnostic(); + var tracks = GivenVaTracks(dir, "album", 10); + tracks.AddRange(GivenTracks(dir, "single_artist", "album", 2)); + TrackGroupingService.IsVariousArtists(tracks).Should().Be(true); + } + + [TestCase("")] + [TestCase("Various Artists")] + [TestCase("Various")] + [TestCase("VA")] + [TestCase("Unknown")] + public void va_artist_title_is_various_artists(string artist) + { + var tracks = GivenTracks(@"C:\music\incoming".AsOsAgnostic(), artist, "album", 10); + TrackGroupingService.IsVariousArtists(tracks).Should().Be(true); + } + + [TestCase("Va?!")] + [TestCase("Va Va Voom")] + [TestCase("V.A. Jr.")] + [TestCase("Ca Va")] + public void va_in_artist_name_is_not_various_artists(string artist) + { + var tracks = GivenTracks(@"C:\music\incoming".AsOsAgnostic(), artist, "album", 10); + TrackGroupingService.IsVariousArtists(tracks).Should().Be(false); + } + + [TestCase(1)] + [TestCase(2)] + [TestCase(10)] + public void should_group_single_artist_album(int count) + { + var tracks = GivenTracks(@"C:\music\incoming".AsOsAgnostic(), "artist", "album", count); + var output = Subject.GroupTracks(tracks); + + TrackGroupingService.IsVariousArtists(tracks).Should().Be(false); + TrackGroupingService.LooksLikeSingleRelease(tracks).Should().Be(true); + + output.Count.Should().Be(1); + output[0].LocalTracks.Count.Should().Be(count); + } + + [TestCase("cd")] + [TestCase("disc")] + [TestCase("disk")] + public void should_group_multi_disc_release(string mediaName) + { + var tracks = GivenTracks($"C:\\music\\incoming\\artist - album\\{mediaName} 1".AsOsAgnostic(), + "artist", "album", 10); + tracks.AddRange(GivenTracks($"C:\\music\\incoming\\artist - album\\{mediaName} 2".AsOsAgnostic(), + "artist", "album", 5)); + + TrackGroupingService.IsVariousArtists(tracks).Should().Be(false); + TrackGroupingService.LooksLikeSingleRelease(tracks).Should().Be(true); + + var output = Subject.GroupTracks(tracks); + output.Count.Should().Be(1); + output[0].LocalTracks.Count.Should().Be(15); + } + + [Test] + public void should_not_group_two_different_albums_by_same_artist() + { + var tracks = GivenTracks($"C:\\music\\incoming\\artist - album1".AsOsAgnostic(), + "artist", "album1", 10); + tracks.AddRange(GivenTracks($"C:\\music\\incoming\\artist - album2".AsOsAgnostic(), + "artist", "album2", 5)); + + TrackGroupingService.IsVariousArtists(tracks).Should().Be(false); + TrackGroupingService.LooksLikeSingleRelease(tracks).Should().Be(false); + + var output = Subject.GroupTracks(tracks); + output.Count.Should().Be(2); + output[0].LocalTracks.Count.Should().Be(10); + output[1].LocalTracks.Count.Should().Be(5); + } + + [Test] + public void should_group_albums_with_typos() + { + var tracks = GivenTracks($"C:\\music\\incoming\\artist - album".AsOsAgnostic(), + "artist", "Rastaman Vibration (Remastered)", 10); + tracks.AddRange(GivenTracks($"C:\\music\\incoming\\artist - album".AsOsAgnostic(), + "artist", "Rastaman Vibration (Remastered", 5)); + + TrackGroupingService.IsVariousArtists(tracks).Should().Be(false); + TrackGroupingService.LooksLikeSingleRelease(tracks).Should().Be(true); + + var output = Subject.GroupTracks(tracks); + output.Count.Should().Be(1); + output[0].LocalTracks.Count.Should().Be(15); + } + + [Test] + public void should_not_group_two_different_tracks_in_same_directory() + { + var tracks = GivenTracks($"C:\\music\\incoming".AsOsAgnostic(), + "artist", "album1", 1); + tracks.AddRange(GivenTracks($"C:\\music\\incoming".AsOsAgnostic(), + "artist", "album2", 1)); + + TrackGroupingService.IsVariousArtists(tracks).Should().Be(false); + TrackGroupingService.LooksLikeSingleRelease(tracks).Should().Be(false); + + var output = Subject.GroupTracks(tracks); + output.Count.Should().Be(2); + output[0].LocalTracks.Count.Should().Be(1); + output[1].LocalTracks.Count.Should().Be(1); + } + + [Test] + public void should_separate_two_albums_in_same_directory() + { + var tracks = GivenTracks($"C:\\music\\incoming\\artist discog".AsOsAgnostic(), + "artist", "album1", 10); + tracks.AddRange(GivenTracks($"C:\\music\\incoming\\artist disog".AsOsAgnostic(), + "artist", "album2", 5)); + + TrackGroupingService.IsVariousArtists(tracks).Should().Be(false); + TrackGroupingService.LooksLikeSingleRelease(tracks).Should().Be(false); + + var output = Subject.GroupTracks(tracks); + output.Count.Should().Be(2); + output[0].LocalTracks.Count.Should().Be(10); + output[1].LocalTracks.Count.Should().Be(5); + } + + [Test] + public void should_separate_many_albums_in_same_directory() + { + var tracks = new List(); + for (int i = 0; i < 100; i++) + { + tracks.AddRange(GivenTracks($"C:\\music".AsOsAgnostic(), + "artist" + i, "album" + i, 10)); + } + + // don't test various artists here because it's designed to only work if there's a common album + TrackGroupingService.LooksLikeSingleRelease(tracks).Should().Be(false); + + var output = Subject.GroupTracks(tracks); + output.Count.Should().Be(100); + output.Select(x => x.LocalTracks.Count).Distinct().ShouldBeEquivalentTo(new List { 10 }); + } + + [Test] + public void should_separate_two_albums_by_different_artists_in_same_directory() + { + var tracks = GivenTracks($"C:\\music\\incoming".AsOsAgnostic(), + "artist1", "album1", 10); + tracks.AddRange(GivenTracks($"C:\\music\\incoming".AsOsAgnostic(), + "artist2", "album2", 5)); + + TrackGroupingService.IsVariousArtists(tracks).Should().Be(false); + TrackGroupingService.LooksLikeSingleRelease(tracks).Should().Be(false); + + var output = Subject.GroupTracks(tracks); + output.Count.Should().Be(2); + output[0].LocalTracks.Count.Should().Be(10); + output[1].LocalTracks.Count.Should().Be(5); + } + + [Test] + [Repeat(100)] + public void should_group_va_release() + { + var tracks = GivenVaTracks(@"C:\music\incoming".AsOsAgnostic(), "album", 10); + + TrackGroupingService.IsVariousArtists(tracks).Should().Be(true); + TrackGroupingService.LooksLikeSingleRelease(tracks).Should().Be(true); + + var output = Subject.GroupTracks(tracks); + output.Count.Should().Be(1); + output[0].LocalTracks.Count.Should().Be(10); + } + + [Test] + public void should_not_group_two_albums_by_different_artists_with_same_title() + { + var tracks = GivenTracks($"C:\\music\\incoming\\album".AsOsAgnostic(), + "artist1", "album", 10); + tracks.AddRange(GivenTracks($"C:\\music\\incoming\\album".AsOsAgnostic(), + "artist2", "album", 5)); + + TrackGroupingService.IsVariousArtists(tracks).Should().Be(false); + TrackGroupingService.LooksLikeSingleRelease(tracks).Should().Be(false); + + var output = Subject.GroupTracks(tracks); + + output.Count.Should().Be(2); + output[0].LocalTracks.Count.Should().Be(10); + output[1].LocalTracks.Count.Should().Be(5); + } + + [Test] + public void should_not_fail_if_all_tags_null() + { + var tracks = GivenTracksWithNoTags($"C:\\music\\incoming\\album".AsOsAgnostic(), 10); + + TrackGroupingService.IsVariousArtists(tracks).Should().Be(false); + TrackGroupingService.LooksLikeSingleRelease(tracks).Should().Be(true); + + var output = Subject.GroupTracks(tracks); + output.Count.Should().Be(1); + output[0].LocalTracks.Count.Should().Be(10); + } + + [Test] + public void should_not_fail_if_some_tags_null() + { + var tracks = GivenTracks($"C:\\music\\incoming\\album".AsOsAgnostic(), + "artist1", "album", 10); + tracks.AddRange(GivenTracksWithNoTags($"C:\\music\\incoming\\album".AsOsAgnostic(), 2)); + + TrackGroupingService.IsVariousArtists(tracks).Should().Be(false); + TrackGroupingService.LooksLikeSingleRelease(tracks).Should().Be(true); + + var output = Subject.GroupTracks(tracks); + output.Count.Should().Be(1); + output[0].LocalTracks.Count.Should().Be(12); + } + + [Test] + public void should_cope_with_one_album_in_subfolder_of_another() + { + var tracks = GivenTracks($"C:\\music\\incoming\\album".AsOsAgnostic(), + "artist1", "album", 10); + tracks.AddRange(GivenTracks($"C:\\music\\incoming\\album\\anotheralbum".AsOsAgnostic(), + "artist2", "album2", 10)); + + TrackGroupingService.IsVariousArtists(tracks).Should().Be(false); + TrackGroupingService.LooksLikeSingleRelease(tracks).Should().Be(false); + + var output = Subject.GroupTracks(tracks); + + foreach(var group in output) + { + TestLogger.Debug($"*** group {group} ***"); + TestLogger.Debug(string.Join("\n", group.LocalTracks.Select(x => x.Path))); + } + + output.Count.Should().Be(2); + output[0].LocalTracks.Count.Should().Be(10); + output[1].LocalTracks.Count.Should().Be(10); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/TrackMappingFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/TrackMappingFixture.cs new file mode 100644 index 000000000..d7743de99 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/TrackMappingFixture.cs @@ -0,0 +1,187 @@ +using NUnit.Framework; +using NzbDrone.Core.MediaFiles.TrackImport.Identification; +using FluentAssertions; +using NzbDrone.Core.Test.Framework; +using FizzWare.NBuilder; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Music; +using System.Collections.Generic; +using System.Linq; +using System; +using NzbDrone.Core.Parser; +using NzbDrone.Common.Serializer; + +namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification +{ + [TestFixture] + public class TrackMappingFixture : CoreTest + { + + private ArtistMetadata artist; + + [SetUp] + public void Setup() + { + artist = Builder + .CreateNew() + .With(x => x.Name = "artist") + .Build(); + } + + private List GivenTracks(int count) + { + return Builder + .CreateListOfSize(count) + .All() + .With(x => x.ArtistMetadata = artist) + .Build() + .ToList(); + } + + private ParsedTrackInfo GivenParsedTrackInfo(Track track, AlbumRelease release) + { + return Builder + .CreateNew() + .With(x => x.Title = track.Title) + .With(x => x.CleanTitle = track.Title.CleanTrackTitle()) + .With(x => x.AlbumTitle = release.Title) + .With(x => x.Disambiguation = release.Disambiguation) + .With(x => x.ReleaseMBId = release.ForeignReleaseId) + .With(x => x.ArtistTitle = track.ArtistMetadata.Value.Name) + .With(x => x.TrackNumbers = new[] { track.AbsoluteTrackNumber }) + .With(x => x.RecordingMBId = track.ForeignRecordingId) + .With(x => x.Country = IsoCountries.Find("US")) + .With(x => x.Label = release.Label.First()) + .With(x => x.Year = (uint)release.Album.Value.ReleaseDate.Value.Year) + .Build(); + } + + private List GivenLocalTracks(List tracks, AlbumRelease release) + { + var output = Builder + .CreateListOfSize(tracks.Count) + .Build() + .ToList(); + + for (int i = 0; i < tracks.Count; i++) + { + output[i].FileTrackInfo = GivenParsedTrackInfo(tracks[i], release); + } + + return output; + } + + private AlbumRelease GivenAlbumRelease(string title, List tracks) + { + var album = Builder + .CreateNew() + .With(x => x.Title = title) + .With(x => x.ArtistMetadata = artist) + .Build(); + + var media = Builder + .CreateListOfSize(1) + .Build() + .ToList(); + + return Builder + .CreateNew() + .With(x => x.Tracks = tracks) + .With(x => x.Title = title) + .With(x => x.Album = album) + .With(x => x.Media = media) + .With(x => x.Country = new List()) + .With(x => x.Label = new List { "label" }) + .Build(); + } + + [Test] + public void test_reorder_when_track_numbers_incorrect() + { + var tracks = GivenTracks(3); + var release = GivenAlbumRelease("album", tracks); + var localTracks = GivenLocalTracks(tracks, release); + + localTracks[2].FileTrackInfo.TrackNumbers = new [] { 2 }; + localTracks[1].FileTrackInfo.TrackNumbers = new [] { 3 }; + localTracks = new [] {0, 2, 1}.Select(x => localTracks[x]).ToList(); + + var result = Subject.MapReleaseTracks(localTracks, tracks); + + result.Mapping + .ToDictionary(x => x.Key, y => y.Value.Item1) + .ShouldBeEquivalentTo(new Dictionary { + {localTracks[0], tracks[0]}, + {localTracks[1], tracks[2]}, + {localTracks[2], tracks[1]}, + }); + result.LocalExtra.Should().BeEmpty(); + result.MBExtra.Should().BeEmpty(); + } + + [Test] + public void test_order_works_with_invalid_track_numbers() + { + var tracks = GivenTracks(3); + var release = GivenAlbumRelease("album", tracks); + var localTracks = GivenLocalTracks(tracks, release); + + foreach (var track in localTracks) + { + track.FileTrackInfo.TrackNumbers = new[] { 1 }; + } + + var result = Subject.MapReleaseTracks(localTracks, tracks); + + result.Mapping + .ToDictionary(x => x.Key, y => y.Value.Item1) + .ShouldBeEquivalentTo(new Dictionary { + {localTracks[0], tracks[0]}, + {localTracks[1], tracks[1]}, + {localTracks[2], tracks[2]}, + }); + result.LocalExtra.Should().BeEmpty(); + result.MBExtra.Should().BeEmpty(); + } + + [Test] + public void test_order_works_with_missing_tracks() + { + var tracks = GivenTracks(3); + var release = GivenAlbumRelease("album", tracks); + var localTracks = GivenLocalTracks(tracks, release); + localTracks.RemoveAt(1); + + var result = Subject.MapReleaseTracks(localTracks, tracks); + + result.Mapping + .ToDictionary(x => x.Key, y => y.Value.Item1) + .ShouldBeEquivalentTo(new Dictionary { + {localTracks[0], tracks[0]}, + {localTracks[1], tracks[2]} + }); + result.LocalExtra.Should().BeEmpty(); + result.MBExtra.ShouldBeEquivalentTo(new List { tracks[1] }); + } + + [Test] + public void test_order_works_with_extra_tracks() + { + var tracks = GivenTracks(3); + var release = GivenAlbumRelease("album", tracks); + var localTracks = GivenLocalTracks(tracks, release); + tracks.RemoveAt(1); + + var result = Subject.MapReleaseTracks(localTracks, tracks); + + result.Mapping + .ToDictionary(x => x.Key, y => y.Value.Item1) + .ShouldBeEquivalentTo(new Dictionary { + {localTracks[0], tracks[0]}, + {localTracks[2], tracks[1]} + }); + result.LocalExtra.ShouldBeEquivalentTo(new List { localTracks[1] }); + result.MBExtra.Should().BeEmpty(); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/ImportDecisionMakerFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/ImportDecisionMakerFixture.cs new file mode 100644 index 000000000..90a5a45fd --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/ImportDecisionMakerFixture.cs @@ -0,0 +1,352 @@ +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using System.IO.Abstractions; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.TrackImport; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; +using NzbDrone.Test.Common; +using FizzWare.NBuilder; +using NzbDrone.Core.Download; +using NzbDrone.Core.MediaFiles.TrackImport.Aggregation; +using NzbDrone.Core.Profiles.Qualities; +using NzbDrone.Core.MediaFiles.TrackImport.Identification; +using System.IO.Abstractions.TestingHelpers; + +namespace NzbDrone.Core.Test.MediaFiles.TrackImport +{ + [TestFixture] + public class ImportDecisionMakerFixture : FileSystemTest + { + private List _fileInfos; + private LocalTrack _localTrack; + private Artist _artist; + private AlbumRelease _albumRelease; + private QualityModel _quality; + + private Mock> _albumpass1; + private Mock> _albumpass2; + private Mock> _albumpass3; + + private Mock> _albumfail1; + private Mock> _albumfail2; + private Mock> _albumfail3; + + + private Mock> _pass1; + private Mock> _pass2; + private Mock> _pass3; + + private Mock> _fail1; + private Mock> _fail2; + private Mock> _fail3; + + [SetUp] + public void Setup() + { + _albumpass1 = new Mock>(); + _albumpass2 = new Mock>(); + _albumpass3 = new Mock>(); + + _albumfail1 = new Mock>(); + _albumfail2 = new Mock>(); + _albumfail3 = new Mock>(); + + + _pass1 = new Mock>(); + _pass2 = new Mock>(); + _pass3 = new Mock>(); + + _fail1 = new Mock>(); + _fail2 = new Mock>(); + _fail3 = new Mock>(); + + _albumpass1.Setup(c => c.IsSatisfiedBy(It.IsAny())).Returns(Decision.Accept()); + _albumpass2.Setup(c => c.IsSatisfiedBy(It.IsAny())).Returns(Decision.Accept()); + _albumpass3.Setup(c => c.IsSatisfiedBy(It.IsAny())).Returns(Decision.Accept()); + + _albumfail1.Setup(c => c.IsSatisfiedBy(It.IsAny())).Returns(Decision.Reject("_albumfail1")); + _albumfail2.Setup(c => c.IsSatisfiedBy(It.IsAny())).Returns(Decision.Reject("_albumfail2")); + _albumfail3.Setup(c => c.IsSatisfiedBy(It.IsAny())).Returns(Decision.Reject("_albumfail3")); + + _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()); + + _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")); + + _artist = Builder.CreateNew() + .With(e => e.QualityProfile = new QualityProfile { Items = Qualities.QualityFixture.GetDefaultQualities() }) + .Build(); + + _albumRelease = Builder.CreateNew() + .Build(); + + _quality = new QualityModel(Quality.MP3_256); + + _localTrack = new LocalTrack + { + Artist = _artist, + Quality = _quality, + Tracks = new List { new Track() }, + Path = @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV.avi".AsOsAgnostic() + }; + + GivenAudioFiles(new List { @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV.avi".AsOsAgnostic() }); + + Mocker.GetMock() + .Setup(s => s.Identify(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((List tracks, Artist artist, Album album, AlbumRelease release, bool newDownload, bool singleRelease, bool includeExisting) => { + var ret = new LocalAlbumRelease(tracks); + ret.AlbumRelease = _albumRelease; + return new List { ret }; + }); + + Mocker.GetMock() + .Setup(c => c.FilterUnchangedFiles(It.IsAny>(), It.IsAny(), It.IsAny())) + .Returns((List files, Artist artist, FilterFilesType filter) => files); + + GivenSpecifications(_albumpass1); + } + + private void GivenSpecifications(params Mock>[] mocks) + { + Mocker.SetConstant(mocks.Select(c => c.Object)); + } + + private void GivenAudioFiles(IEnumerable videoFiles) + { + foreach (var file in videoFiles) + { + FileSystem.AddFile(file, new MockFileData(string.Empty)); + } + + _fileInfos = videoFiles.Select(x => DiskProvider.GetFileInfo(x)).ToList(); + } + + private void GivenAugmentationSuccess() + { + Mocker.GetMock() + .Setup(s => s.Augment(It.IsAny(), It.IsAny())) + .Callback((localTrack, otherFiles) => + { + localTrack.Tracks = _localTrack.Tracks; + }); + } + + [Test] + public void should_call_all_album_specifications() + { + var downloadClientItem = Builder.CreateNew().Build(); + GivenAugmentationSuccess(); + GivenSpecifications(_albumpass1, _albumpass2, _albumpass3, _albumfail1, _albumfail2, _albumfail3); + + Subject.GetImportDecisions(_fileInfos, new Artist(), null, null, downloadClientItem, null, FilterFilesType.None, false, false, false); + + _albumfail1.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Once()); + _albumfail2.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Once()); + _albumfail3.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Once()); + _albumpass1.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Once()); + _albumpass2.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Once()); + _albumpass3.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Once()); + } + + [Test] + public void should_call_all_track_specifications_if_album_accepted() + { + var downloadClientItem = Builder.CreateNew().Build(); + GivenAugmentationSuccess(); + GivenSpecifications(_pass1, _pass2, _pass3, _fail1, _fail2, _fail3); + + Subject.GetImportDecisions(_fileInfos, new Artist(), null, null, downloadClientItem, null, FilterFilesType.None, false, false, false); + + _fail1.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Once()); + _fail2.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Once()); + _fail3.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Once()); + _pass1.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Once()); + _pass2.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Once()); + _pass3.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Once()); + } + + [Test] + public void should_call_no_track_specifications_if_album_rejected() + { + var downloadClientItem = Builder.CreateNew().Build(); + GivenAugmentationSuccess(); + GivenSpecifications(_albumpass1, _albumpass2, _albumpass3, _albumfail1, _albumfail2, _albumfail3); + GivenSpecifications(_pass1, _pass2, _pass3, _fail1, _fail2, _fail3); + + Subject.GetImportDecisions(_fileInfos, new Artist(), null, null, downloadClientItem, null, FilterFilesType.None, false, false, false); + + _fail1.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Never()); + _fail2.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Never()); + _fail3.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Never()); + _pass1.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Never()); + _pass2.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Never()); + _pass3.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Never()); + } + + [Test] + public void should_return_rejected_if_only_album_spec_fails() + { + GivenSpecifications(_albumfail1); + GivenSpecifications(_pass1); + + var result = Subject.GetImportDecisions(_fileInfos, new Artist(), FilterFilesType.None, false); + + result.Single().Approved.Should().BeFalse(); + } + + [Test] + public void should_return_rejected_if_only_track_spec_fails() + { + GivenSpecifications(_albumpass1); + GivenSpecifications(_fail1); + + var result = Subject.GetImportDecisions(_fileInfos, new Artist(), FilterFilesType.None, false); + + result.Single().Approved.Should().BeFalse(); + } + + [Test] + public void should_return_rejected_if_one_album_spec_fails() + { + GivenSpecifications(_albumpass1, _albumfail1, _albumpass2, _albumpass3); + GivenSpecifications(_pass1, _pass2, _pass3); + + var result = Subject.GetImportDecisions(_fileInfos, new Artist(), FilterFilesType.None, false); + + result.Single().Approved.Should().BeFalse(); + } + + [Test] + public void should_return_rejected_if_one_track_spec_fails() + { + GivenSpecifications(_albumpass1, _albumpass2, _albumpass3); + GivenSpecifications(_pass1, _fail1, _pass2, _pass3); + + var result = Subject.GetImportDecisions(_fileInfos, new Artist(), FilterFilesType.None, false); + + result.Single().Approved.Should().BeFalse(); + } + + [Test] + public void should_return_approved_if_all_specs_pass() + { + GivenAugmentationSuccess(); + GivenSpecifications(_albumpass1, _albumpass2, _albumpass3); + GivenSpecifications(_pass1, _pass2, _pass3); + + var result = Subject.GetImportDecisions(_fileInfos, new Artist(), FilterFilesType.None, false); + + result.Single().Approved.Should().BeTrue(); + } + + [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(_fileInfos, new Artist(), FilterFilesType.None, false); + result.Single().Rejections.Should().HaveCount(3); + } + + [Test] + public void should_not_blowup_the_process_due_to_failed_augment() + { + GivenSpecifications(_pass1); + + Mocker.GetMock() + .Setup(c => c.Augment(It.IsAny(), It.IsAny())) + .Throws(); + + GivenAudioFiles(new [] + { + @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV".AsOsAgnostic(), + @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV".AsOsAgnostic(), + @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV".AsOsAgnostic() + }); + + Subject.GetImportDecisions(_fileInfos, _artist, FilterFilesType.None, false); + + Mocker.GetMock() + .Verify(c => c.Augment(It.IsAny(), It.IsAny()), Times.Exactly(_fileInfos.Count)); + + ExceptionVerification.ExpectedErrors(3); + } + + [Test] + public void should_not_throw_if_release_not_identified() + { + GivenSpecifications(_pass1); + + GivenAudioFiles(new [] + { + @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV".AsOsAgnostic(), + @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV".AsOsAgnostic(), + @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV".AsOsAgnostic() + }); + + Mocker.GetMock() + .Setup(s => s.Identify(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((List tracks, Artist artist, Album album, AlbumRelease release, bool newDownload, bool singleRelease, bool includeExisting) => { + return new List { new LocalAlbumRelease(tracks) }; + }); + + var decisions = Subject.GetImportDecisions(_fileInfos, _artist, FilterFilesType.None, false); + + Mocker.GetMock() + .Verify(c => c.Augment(It.IsAny(), It.IsAny()), Times.Exactly(_fileInfos.Count)); + + decisions.Should().HaveCount(3); + decisions.First().Rejections.Should().NotBeEmpty(); + } + + [Test] + public void should_not_throw_if_tracks_are_not_found() + { + GivenSpecifications(_pass1); + + GivenAudioFiles(new [] + { + @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV".AsOsAgnostic(), + @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV".AsOsAgnostic(), + @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV".AsOsAgnostic() + }); + + var decisions = Subject.GetImportDecisions(_fileInfos, _artist, FilterFilesType.None, false); + + Mocker.GetMock() + .Verify(c => c.Augment(It.IsAny(), It.IsAny()), Times.Exactly(_fileInfos.Count)); + + decisions.Should().HaveCount(3); + decisions.First().Rejections.Should().NotBeEmpty(); + } + + [Test] + public void should_return_a_decision_when_exception_is_caught() + { + Mocker.GetMock() + .Setup(c => c.Augment(It.IsAny(), It.IsAny())) + .Throws(); + + GivenAudioFiles(new [] + { + @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV".AsOsAgnostic() + }); + + Subject.GetImportDecisions(_fileInfos, _artist, FilterFilesType.None, false).Should().HaveCount(1); + + ExceptionVerification.ExpectedErrors(1); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Specifications/FreeSpaceSpecificationFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Specifications/FreeSpaceSpecificationFixture.cs new file mode 100644 index 000000000..dab5fe358 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Specifications/FreeSpaceSpecificationFixture.cs @@ -0,0 +1,154 @@ +using System.IO; +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Disk; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.MediaFiles.TrackImport.Specifications; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Specifications +{ + [TestFixture] + public class FreeSpaceSpecificationFixture : CoreTest + { + private Artist _artist; + private LocalTrack _localTrack; + private string _rootFolder; + + [SetUp] + public void Setup() + { + _rootFolder = @"C:\Test\Music".AsOsAgnostic(); + + _artist = Builder.CreateNew() + .With(s => s.Path = Path.Combine(_rootFolder, "Alice in Chains")) + .Build(); + + var tracks = Builder.CreateListOfSize(1) + .All() + .Build() + .ToList(); + + _localTrack = new LocalTrack + { + Path = @"C:\Test\Unsorted\Alice in Chains\Alice in Chains - track1.mp3".AsOsAgnostic(), + Tracks = tracks, + Artist = _artist + }; + } + + private void GivenFileSize(long size) + { + _localTrack.Size = size; + } + + private void GivenFreeSpace(long? size) + { + Mocker.GetMock() + .Setup(s => s.GetAvailableSpace(It.IsAny())) + .Returns(size); + } + + [Test] + public void should_reject_when_there_isnt_enough_disk_space() + { + GivenFileSize(100.Megabytes()); + GivenFreeSpace(80.Megabytes()); + + Subject.IsSatisfiedBy(_localTrack).Accepted.Should().BeFalse(); + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + public void should_reject_when_there_isnt_enough_space_for_file_plus_100mb_padding() + { + GivenFileSize(100.Megabytes()); + GivenFreeSpace(150.Megabytes()); + + Subject.IsSatisfiedBy(_localTrack).Accepted.Should().BeFalse(); + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + public void should_accept_when_there_is_enough_disk_space() + { + GivenFileSize(100.Megabytes()); + GivenFreeSpace(1.Gigabytes()); + + Subject.IsSatisfiedBy(_localTrack).Accepted.Should().BeTrue(); + } + + [Test] + public void should_use_artist_paths_parent_for_free_space_check() + { + GivenFileSize(100.Megabytes()); + GivenFreeSpace(1.Gigabytes()); + + Subject.IsSatisfiedBy(_localTrack).Accepted.Should().BeTrue(); + + Mocker.GetMock() + .Verify(v => v.GetAvailableSpace(_rootFolder), Times.Once()); + } + + [Test] + public void should_pass_if_free_space_is_null() + { + GivenFileSize(100.Megabytes()); + GivenFreeSpace(null); + + Subject.IsSatisfiedBy(_localTrack).Accepted.Should().BeTrue(); + } + + [Test] + public void should_pass_if_exception_is_thrown() + { + GivenFileSize(100.Megabytes()); + + Mocker.GetMock() + .Setup(s => s.GetAvailableSpace(It.IsAny())) + .Throws(new TestException()); + + Subject.IsSatisfiedBy(_localTrack).Accepted.Should().BeTrue(); + ExceptionVerification.ExpectedErrors(1); + } + + [Test] + public void should_skip_check_for_files_under_artist_folder() + { + _localTrack.ExistingFile = true; + + Subject.IsSatisfiedBy(_localTrack).Accepted.Should().BeTrue(); + + Mocker.GetMock() + .Verify(s => s.GetAvailableSpace(It.IsAny()), Times.Never()); + } + + [Test] + public void should_return_true_if_free_space_is_null() + { + long? freeSpace = null; + + Mocker.GetMock() + .Setup(s => s.GetAvailableSpace(It.IsAny())) + .Returns(freeSpace); + + Subject.IsSatisfiedBy(_localTrack).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_true_when_skip_check_is_enabled() + { + Mocker.GetMock() + .Setup(s => s.SkipFreeSpaceCheckWhenImporting) + .Returns(true); + + Subject.IsSatisfiedBy(_localTrack).Accepted.Should().BeTrue(); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Specifications/NotUnpackingSpecificationFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Specifications/NotUnpackingSpecificationFixture.cs new file mode 100644 index 000000000..cfdec1aa4 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Specifications/NotUnpackingSpecificationFixture.cs @@ -0,0 +1,85 @@ +using System; +using FizzWare.NBuilder; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Disk; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.MediaFiles.TrackImport.Specifications; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Specifications +{ + [TestFixture] + public class NotUnpackingSpecificationFixture : CoreTest + { + private LocalTrack _localTrack; + + [SetUp] + public void Setup() + { + Mocker.GetMock() + .SetupGet(s => s.DownloadClientWorkingFolders) + .Returns("_UNPACK_|_FAILED_"); + + _localTrack = new LocalTrack + { + Path = @"C:\Test\Unsorted Music\Kid.Rock\Kid.Rock.Cowboy.mp3".AsOsAgnostic(), + Size = 100, + Artist = Builder.CreateNew().Build() + }; + } + + private void GivenInWorkingFolder() + { + _localTrack.Path = @"C:\Test\Unsorted Music\_UNPACK_Kid.Rock\someSubFolder\Kid.Rock.Cowboy.mp3".AsOsAgnostic(); + } + + private void GivenLastWriteTimeUtc(DateTime time) + { + Mocker.GetMock() + .Setup(s => s.FileGetLastWrite(It.IsAny())) + .Returns(time); + } + + [Test] + public void should_return_true_if_not_in_working_folder() + { + Subject.IsSatisfiedBy(_localTrack).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_true_when_in_old_working_folder() + { + WindowsOnly(); + + GivenInWorkingFolder(); + GivenLastWriteTimeUtc(DateTime.UtcNow.AddHours(-1)); + + Subject.IsSatisfiedBy(_localTrack).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_false_if_in_working_folder_and_last_write_time_was_recent() + { + GivenInWorkingFolder(); + GivenLastWriteTimeUtc(DateTime.UtcNow); + + Subject.IsSatisfiedBy(_localTrack).Accepted.Should().BeFalse(); + } + + [Test] + public void should_return_false_if_unopacking_on_linux() + { + MonoOnly(); + + GivenInWorkingFolder(); + GivenLastWriteTimeUtc(DateTime.UtcNow.AddDays(-5)); + + Subject.IsSatisfiedBy(_localTrack).Accepted.Should().BeFalse(); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Specifications/SameFileSpecificationFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Specifications/SameFileSpecificationFixture.cs new file mode 100644 index 000000000..073ce9cd5 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/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.TrackImport.Specifications; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Specifications +{ + [TestFixture] + public class SameFileSpecificationFixture : CoreTest + { + private LocalTrack _localTrack; + + [SetUp] + public void Setup() + { + _localTrack = Builder.CreateNew() + .With(l => l.Size = 150.Megabytes()) + .Build(); + } + + [Test] + public void should_be_accepted_if_no_existing_file() + { + _localTrack.Tracks = Builder.CreateListOfSize(1) + .TheFirst(1) + .With(e => e.TrackFileId = 0) + .BuildList(); + + Subject.IsSatisfiedBy(_localTrack).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_accepted_if_multiple_existing_files() + { + _localTrack.Tracks = Builder.CreateListOfSize(2) + .TheFirst(1) + .With(e => e.TrackFileId = 1) + .With(e => e.TrackFile = new LazyLoaded( + new TrackFile + { + Size = _localTrack.Size + })) + .TheNext(1) + .With(e => e.TrackFileId = 2) + .With(e => e.TrackFile = new LazyLoaded( + new TrackFile + { + Size = _localTrack.Size + })) + .Build() + .ToList(); + + Subject.IsSatisfiedBy(_localTrack).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_accepted_if_file_size_is_different() + { + _localTrack.Tracks = Builder.CreateListOfSize(1) + .TheFirst(1) + .With(e => e.TrackFileId = 1) + .With(e => e.TrackFile = new LazyLoaded( + new TrackFile + { + Size = _localTrack.Size + 100.Megabytes() + })) + .Build() + .ToList(); + + Subject.IsSatisfiedBy(_localTrack).Accepted.Should().BeTrue(); + } + + [Test] + public void should_be_reject_if_file_size_is_the_same() + { + _localTrack.Tracks = Builder.CreateListOfSize(1) + .TheFirst(1) + .With(e => e.TrackFileId = 1) + .With(e => e.TrackFile = new LazyLoaded( + new TrackFile + { + Size = _localTrack.Size + })) + .Build() + .ToList(); + + Subject.IsSatisfiedBy(_localTrack).Accepted.Should().BeFalse(); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Specifications/UpgradeSpecificationFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Specifications/UpgradeSpecificationFixture.cs new file mode 100644 index 000000000..0be4ba3a7 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Specifications/UpgradeSpecificationFixture.cs @@ -0,0 +1,228 @@ +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using Marr.Data; +using NUnit.Framework; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.TrackImport.Specifications; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Profiles.Qualities; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; +using NzbDrone.Core.Configuration; + +namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Specifications +{ + [TestFixture] + public class UpgradeSpecificationFixture : CoreTest + { + private Artist _artist; + private Album _album; + private LocalTrack _localTrack; + + [SetUp] + public void Setup() + { + _artist = Builder.CreateNew() + .With(e => e.QualityProfile = new QualityProfile + { + Items = Qualities.QualityFixture.GetDefaultQualities(), + }).Build(); + + _album = Builder.CreateNew().Build(); + + _localTrack = new LocalTrack + { + Path = @"C:\Test\Imagine Dragons\Imagine.Dragons.Song.1.mp3", + Quality = new QualityModel(Quality.MP3_256, new Revision(version: 1)), + Artist = _artist, + Album = _album + }; + } + + [Test] + public void should_return_true_if_no_existing_trackFile() + { + _localTrack.Tracks = Builder.CreateListOfSize(1) + .All() + .With(e => e.TrackFileId = 0) + .With(e => e.TrackFile = null) + .Build() + .ToList(); + + Subject.IsSatisfiedBy(_localTrack).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_true_if_no_existing_trackFile_for_multi_tracks() + { + _localTrack.Tracks = Builder.CreateListOfSize(2) + .All() + .With(e => e.TrackFileId = 0) + .With(e => e.TrackFile = null) + .Build() + .ToList(); + + Subject.IsSatisfiedBy(_localTrack).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_true_if_upgrade_for_existing_trackFile() + { + _localTrack.Tracks = Builder.CreateListOfSize(1) + .All() + .With(e => e.TrackFileId = 1) + .With(e => e.TrackFile = new LazyLoaded( + new TrackFile + { + Quality = new QualityModel(Quality.MP3_192, new Revision(version: 1)) + })) + .Build() + .ToList(); + + Subject.IsSatisfiedBy(_localTrack).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_true_if_upgrade_for_existing_trackFile_for_multi_tracks() + { + _localTrack.Tracks = Builder.CreateListOfSize(2) + .All() + .With(e => e.TrackFileId = 1) + .With(e => e.TrackFile = new LazyLoaded( + new TrackFile + { + Quality = new QualityModel(Quality.MP3_192, new Revision(version: 1)) + })) + .Build() + .ToList(); + + Subject.IsSatisfiedBy(_localTrack).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_false_if_not_an_upgrade_for_existing_trackFile() + { + _localTrack.Tracks = Builder.CreateListOfSize(1) + .All() + .With(e => e.TrackFileId = 1) + .With(e => e.TrackFile = new LazyLoaded( + new TrackFile + { + Quality = new QualityModel(Quality.FLAC, new Revision(version: 1)) + })) + .Build() + .ToList(); + + Subject.IsSatisfiedBy(_localTrack).Accepted.Should().BeFalse(); + } + + [Test] + public void should_return_false_if_not_an_upgrade_for_existing_trackFile_for_multi_tracks() + { + _localTrack.Tracks = Builder.CreateListOfSize(2) + .All() + .With(e => e.TrackFileId = 1) + .With(e => e.TrackFile = new LazyLoaded( + new TrackFile + { + Quality = new QualityModel(Quality.FLAC, new Revision(version: 1)) + })) + .Build() + .ToList(); + + Subject.IsSatisfiedBy(_localTrack).Accepted.Should().BeFalse(); + } + + [Test] + public void should_return_false_if_not_an_upgrade_for_one_existing_trackFile_for_multi_track() + { + _localTrack.Tracks = Builder.CreateListOfSize(2) + .TheFirst(1) + .With(e => e.TrackFileId = 1) + .With(e => e.TrackFile = new LazyLoaded( + new TrackFile + { + Quality = new QualityModel(Quality.MP3_192, new Revision(version: 1)) + })) + .TheNext(1) + .With(e => e.TrackFileId = 2) + .With(e => e.TrackFile = new LazyLoaded( + new TrackFile + { + Quality = new QualityModel(Quality.FLAC, new Revision(version: 1)) + })) + .Build() + .ToList(); + + Subject.IsSatisfiedBy(_localTrack).Accepted.Should().BeFalse(); + } + + + [Test] + public void should_return_false_if_not_a_revision_upgrade_and_prefers_propers() + { + Mocker.GetMock() + .Setup(s => s.DownloadPropersAndRepacks) + .Returns(ProperDownloadTypes.PreferAndUpgrade); + + _localTrack.Tracks = Builder.CreateListOfSize(1) + .All() + .With(e => e.TrackFileId = 1) + .With(e => e.TrackFile = new LazyLoaded( + new TrackFile + { + Quality = new QualityModel(Quality.MP3_256, new Revision(version: 2)) + })) + .Build() + .ToList(); + + Subject.IsSatisfiedBy(_localTrack).Accepted.Should().BeFalse(); + } + + [Test] + public void should_return_true_if_not_a_revision_upgrade_and_does_not_prefer_propers() + { + Mocker.GetMock() + .Setup(s => s.DownloadPropersAndRepacks) + .Returns(ProperDownloadTypes.DoNotPrefer); + + _localTrack.Tracks = Builder.CreateListOfSize(1) + .All() + .With(e => e.TrackFileId = 1) + .With(e => e.TrackFile = new LazyLoaded( + new TrackFile + { + Quality = new QualityModel(Quality.MP3_256, new Revision(version: 2)) + })) + .Build() + .ToList(); + + Subject.IsSatisfiedBy(_localTrack).Accepted.Should().BeTrue(); + } + + [Test] + public void should_return_true_when_comparing_to_a_lower_quality_proper() + { + Mocker.GetMock() + .Setup(s => s.DownloadPropersAndRepacks) + .Returns(ProperDownloadTypes.DoNotPrefer); + + _localTrack.Quality = new QualityModel(Quality.FLAC); + + _localTrack.Tracks = Builder.CreateListOfSize(1) + .All() + .With(e => e.TrackFileId = 1) + .With(e => e.TrackFile = new LazyLoaded( + new TrackFile + { + Quality = new QualityModel(Quality.FLAC, new Revision(version: 2)) + })) + .Build() + .ToList(); + + Subject.IsSatisfiedBy(_localTrack).Accepted.Should().BeTrue(); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/UpgradeMediaFileServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/UpgradeMediaFileServiceFixture.cs index 2dfb17e8b..0d5bc318a 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; @@ -8,26 +9,27 @@ using NzbDrone.Common.Disk; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.MediaFiles { public class UpgradeMediaFileServiceFixture : CoreTest { - private EpisodeFile _episodeFile; - private LocalEpisode _localEpisode; + private TrackFile _trackFile; + private LocalTrack _localTrack; + private string rootPath = @"C:\Test\Music\Artist".AsOsAgnostic(); [SetUp] public void Setup() { - _localEpisode = new LocalEpisode(); - _localEpisode.Series = new Series + _localTrack = new LocalTrack(); + _localTrack.Artist = new Artist { - Path = @"C:\Test\TV\Series".AsOsAgnostic() + Path = rootPath }; - _episodeFile = Builder + _trackFile = Builder .CreateNew() .Build(); @@ -35,141 +37,149 @@ namespace NzbDrone.Core.Test.MediaFiles Mocker.GetMock() .Setup(c => c.FileExists(It.IsAny())) .Returns(true); + + Mocker.GetMock() + .Setup(c => c.FolderExists(It.IsAny())) + .Returns(true); + + Mocker.GetMock() + .Setup(c => c.GetParentFolder(It.IsAny())) + .Returns(c => Path.GetDirectoryName(c)); } - private void GivenSingleEpisodeWithSingleEpisodeFile() + private void GivenSingleTrackWithSingleTrackFile() { - _localEpisode.Episodes = Builder.CreateListOfSize(1) + _localTrack.Tracks = Builder.CreateListOfSize(1) .All() - .With(e => e.EpisodeFileId = 1) - .With(e => e.EpisodeFile = new LazyLoaded( - new EpisodeFile + .With(e => e.TrackFileId = 1) + .With(e => e.TrackFile = new LazyLoaded( + new TrackFile { Id = 1, - RelativePath = @"Season 01\30.rock.s01e01.avi", + Path = Path.Combine(rootPath, @"Season 01\30.rock.s01e01.avi"), })) .Build() .ToList(); } - private void GivenMultipleEpisodesWithSingleEpisodeFile() + private void GivenMultipleTracksWithSingleTrackFile() { - _localEpisode.Episodes = Builder.CreateListOfSize(2) + _localTrack.Tracks = Builder.CreateListOfSize(2) .All() - .With(e => e.EpisodeFileId = 1) - .With(e => e.EpisodeFile = new LazyLoaded( - new EpisodeFile + .With(e => e.TrackFileId = 1) + .With(e => e.TrackFile = new LazyLoaded( + new TrackFile { Id = 1, - RelativePath = @"Season 01\30.rock.s01e01.avi", + Path = Path.Combine(rootPath, @"Season 01\30.rock.s01e01.avi"), })) .Build() .ToList(); } - private void GivenMultipleEpisodesWithMultipleEpisodeFiles() + private void GivenMultipleTracksWithMultipleTrackFiles() { - _localEpisode.Episodes = Builder.CreateListOfSize(2) + _localTrack.Tracks = Builder.CreateListOfSize(2) .TheFirst(1) - .With(e => e.EpisodeFile = new LazyLoaded( - new EpisodeFile + .With(e => e.TrackFile = new LazyLoaded( + new TrackFile { Id = 1, - RelativePath = @"Season 01\30.rock.s01e01.avi", + Path = Path.Combine(rootPath, @"Season 01\30.rock.s01e01.avi"), })) .TheNext(1) - .With(e => e.EpisodeFile = new LazyLoaded( - new EpisodeFile + .With(e => e.TrackFile = new LazyLoaded( + new TrackFile { Id = 2, - RelativePath = @"Season 01\30.rock.s01e02.avi", + Path = Path.Combine(rootPath, @"Season 01\30.rock.s01e02.avi"), })) .Build() .ToList(); } [Test] - public void should_delete_single_episode_file_once() + public void should_delete_single_track_file_once() { - GivenSingleEpisodeWithSingleEpisodeFile(); + GivenSingleTrackWithSingleTrackFile(); - Subject.UpgradeEpisodeFile(_episodeFile, _localEpisode); + Subject.UpgradeTrackFile(_trackFile, _localTrack); - Mocker.GetMock().Verify(v => v.DeleteFile(It.IsAny()), Times.Once()); + Mocker.GetMock().Verify(v => v.DeleteFile(It.IsAny(), It.IsAny()), Times.Once()); } [Test] - public void should_delete_the_same_episode_file_only_once() + public void should_delete_the_same_track_file_only_once() { - GivenMultipleEpisodesWithSingleEpisodeFile(); + GivenMultipleTracksWithSingleTrackFile(); - Subject.UpgradeEpisodeFile(_episodeFile, _localEpisode); + Subject.UpgradeTrackFile(_trackFile, _localTrack); - Mocker.GetMock().Verify(v => v.DeleteFile(It.IsAny()), Times.Once()); + Mocker.GetMock().Verify(v => v.DeleteFile(It.IsAny(), It.IsAny()), Times.Once()); } [Test] - public void should_delete_multiple_different_episode_files() + public void should_delete_multiple_different_track_files() { - GivenMultipleEpisodesWithMultipleEpisodeFiles(); + GivenMultipleTracksWithMultipleTrackFiles(); - Subject.UpgradeEpisodeFile(_episodeFile, _localEpisode); + Subject.UpgradeTrackFile(_trackFile, _localTrack); - Mocker.GetMock().Verify(v => v.DeleteFile(It.IsAny()), Times.Exactly(2)); + Mocker.GetMock().Verify(v => v.DeleteFile(It.IsAny(), It.IsAny()), Times.Exactly(2)); } [Test] - public void should_delete_episode_file_from_database() + public void should_delete_track_file_from_database() { - GivenSingleEpisodeWithSingleEpisodeFile(); + GivenSingleTrackWithSingleTrackFile(); - Subject.UpgradeEpisodeFile(_episodeFile, _localEpisode); + Subject.UpgradeTrackFile(_trackFile, _localTrack); - Mocker.GetMock().Verify(v => v.Delete(It.IsAny(), DeleteMediaFileReason.Upgrade), Times.Once()); + Mocker.GetMock().Verify(v => v.Delete(It.IsAny(), DeleteMediaFileReason.Upgrade), Times.Once()); } [Test] public void should_delete_existing_file_fromdb_if_file_doesnt_exist() { - GivenSingleEpisodeWithSingleEpisodeFile(); + GivenSingleTrackWithSingleTrackFile(); Mocker.GetMock() .Setup(c => c.FileExists(It.IsAny())) .Returns(false); - Subject.UpgradeEpisodeFile(_episodeFile, _localEpisode); + Subject.UpgradeTrackFile(_trackFile, _localTrack); - Mocker.GetMock().Verify(v => v.Delete(_localEpisode.Episodes.Single().EpisodeFile.Value, DeleteMediaFileReason.Upgrade), Times.Once()); + Mocker.GetMock().Verify(v => v.Delete(_localTrack.Tracks.Single().TrackFile.Value, DeleteMediaFileReason.Upgrade), Times.Once()); } [Test] public void should_not_try_to_recyclebin_existing_file_if_file_doesnt_exist() { - GivenSingleEpisodeWithSingleEpisodeFile(); + GivenSingleTrackWithSingleTrackFile(); Mocker.GetMock() .Setup(c => c.FileExists(It.IsAny())) .Returns(false); - Subject.UpgradeEpisodeFile(_episodeFile, _localEpisode); + Subject.UpgradeTrackFile(_trackFile, _localTrack); - Mocker.GetMock().Verify(v => v.DeleteFile(It.IsAny()), Times.Never()); + Mocker.GetMock().Verify(v => v.DeleteFile(It.IsAny(), It.IsAny()), Times.Never()); } [Test] - public void should_return_old_episode_file_in_oldFiles() + public void should_return_old_track_file_in_oldFiles() { - GivenSingleEpisodeWithSingleEpisodeFile(); + GivenSingleTrackWithSingleTrackFile(); - Subject.UpgradeEpisodeFile(_episodeFile, _localEpisode).OldFiles.Count.Should().Be(1); + Subject.UpgradeTrackFile(_trackFile, _localTrack).OldFiles.Count.Should().Be(1); } [Test] - public void should_return_old_episode_files_in_oldFiles() + public void should_return_old_track_files_in_oldFiles() { - GivenMultipleEpisodesWithMultipleEpisodeFiles(); + GivenMultipleTracksWithMultipleTrackFiles(); - Subject.UpgradeEpisodeFile(_episodeFile, _localEpisode).OldFiles.Count.Should().Be(2); + Subject.UpgradeTrackFile(_trackFile, _localTrack).OldFiles.Count.Should().Be(2); } } } diff --git a/src/NzbDrone.Core.Test/Messaging/Commands/CommandEqualityComparerFixture.cs b/src/NzbDrone.Core.Test/Messaging/Commands/CommandEqualityComparerFixture.cs index 8154c7a24..63a2fd581 100644 --- a/src/NzbDrone.Core.Test/Messaging/Commands/CommandEqualityComparerFixture.cs +++ b/src/NzbDrone.Core.Test/Messaging/Commands/CommandEqualityComparerFixture.cs @@ -1,40 +1,43 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using FizzWare.NBuilder; using FluentAssertions; using NUnit.Framework; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Indexers; using NzbDrone.Core.IndexerSearch; using NzbDrone.Core.MediaFiles.Commands; +using NzbDrone.Core.MediaFiles.TrackImport.Manual; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Update.Commands; +using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.Messaging.Commands { [TestFixture] public class CommandEqualityComparerFixture { - [Test] - public void should_return_true_when_there_are_no_properties() + private string GivenRandomPath() { - var command1 = new DownloadedEpisodesScanCommand(); - var command2 = new DownloadedEpisodesScanCommand(); - - CommandEqualityComparer.Instance.Equals(command1, command2).Should().BeTrue(); + return Path.Combine(@"C:\Tesst\", Guid.NewGuid().ToString()).AsOsAgnostic(); } [Test] - public void should_return_true_when_single_property_matches() + public void should_return_true_when_there_are_no_properties() { - var command1 = new EpisodeSearchCommand { EpisodeIds = new List{ 1 } }; - var command2 = new EpisodeSearchCommand { EpisodeIds = new List { 1 } }; + var command1 = new DownloadedAlbumsScanCommand(); + var command2 = new DownloadedAlbumsScanCommand(); CommandEqualityComparer.Instance.Equals(command1, command2).Should().BeTrue(); } [Test] - public void should_return_true_when_multiple_properties_match() + public void should_return_true_when_single_property_matches() { - var command1 = new SeasonSearchCommand { SeriesId = 1, SeasonNumber = 1 }; - var command2 = new SeasonSearchCommand { SeriesId = 1, SeasonNumber = 1 }; + var command1 = new AlbumSearchCommand { AlbumIds = new List{ 1 } }; + var command2 = new AlbumSearchCommand { AlbumIds = new List { 1 } }; CommandEqualityComparer.Instance.Equals(command1, command2).Should().BeTrue(); } @@ -42,35 +45,18 @@ namespace NzbDrone.Core.Test.Messaging.Commands [Test] public void should_return_false_when_single_property_doesnt_match() { - var command1 = new EpisodeSearchCommand { EpisodeIds = new List { 1 } }; - var command2 = new EpisodeSearchCommand { EpisodeIds = new List { 2 } }; + var command1 = new AlbumSearchCommand { AlbumIds = new List { 1 } }; + var command2 = new AlbumSearchCommand { AlbumIds = new List { 2 } }; CommandEqualityComparer.Instance.Equals(command1, command2).Should().BeFalse(); } - [Test] - public void should_return_false_when_only_one_property_matches() - { - var command1 = new SeasonSearchCommand { SeriesId = 1, SeasonNumber = 1 }; - var command2 = new SeasonSearchCommand { SeriesId = 1, SeasonNumber = 2 }; - - CommandEqualityComparer.Instance.Equals(command1, command2).Should().BeFalse(); - } - - [Test] - public void should_return_false_when_no_properties_match() - { - var command1 = new SeasonSearchCommand { SeriesId = 1, SeasonNumber = 1 }; - var command2 = new SeasonSearchCommand { SeriesId = 2, SeasonNumber = 2 }; - - CommandEqualityComparer.Instance.Equals(command1, command2).Should().BeFalse(); - } [Test] public void should_return_false_when_only_one_has_properties() { - var command1 = new SeasonSearchCommand(); - var command2 = new SeasonSearchCommand { SeriesId = 2, SeasonNumber = 2 }; + var command1 = new ArtistSearchCommand(); + var command2 = new ArtistSearchCommand { ArtistId = 2}; CommandEqualityComparer.Instance.Equals(command1, command2).Should().BeFalse(); } @@ -78,8 +64,8 @@ namespace NzbDrone.Core.Test.Messaging.Commands [Test] public void should_return_false_when_only_one_has_null_property() { - var command1 = new EpisodeSearchCommand(null); - var command2 = new EpisodeSearchCommand(new List()); + var command1 = new AlbumSearchCommand(null); + var command2 = new AlbumSearchCommand(new List()); CommandEqualityComparer.Instance.Equals(command1, command2).Should().BeFalse(); } @@ -93,8 +79,8 @@ namespace NzbDrone.Core.Test.Messaging.Commands [Test] public void should_return_false_when_commands_list_are_different_lengths() { - var command1 = new EpisodeSearchCommand { EpisodeIds = new List { 1 } }; - var command2 = new EpisodeSearchCommand { EpisodeIds = new List { 1, 2 } }; + var command1 = new AlbumSearchCommand { AlbumIds = new List { 1 } }; + var command2 = new AlbumSearchCommand { AlbumIds = new List { 1, 2 } }; CommandEqualityComparer.Instance.Equals(command1, command2).Should().BeFalse(); } @@ -102,9 +88,42 @@ namespace NzbDrone.Core.Test.Messaging.Commands [Test] public void should_return_false_when_commands_list_dont_match() { - var command1 = new EpisodeSearchCommand { EpisodeIds = new List { 1 } }; - var command2 = new EpisodeSearchCommand { EpisodeIds = new List { 2 } }; + var command1 = new AlbumSearchCommand { AlbumIds = new List { 1 } }; + var command2 = new AlbumSearchCommand { AlbumIds = new List { 2 } }; + + CommandEqualityComparer.Instance.Equals(command1, command2).Should().BeFalse(); + } + + [Test] + public void should_return_true_when_commands_list_for_non_primitive_type_match() + { + var files1 = Builder.CreateListOfSize(2) + .All() + .With(m => m.Path = GivenRandomPath()) + .Build() + .ToList(); + var files2 = files1.JsonClone(); + var command1 = new ManualImportCommand { Files = files1 }; + var command2 = new ManualImportCommand { Files = files2 }; + CommandEqualityComparer.Instance.Equals(command1, command2).Should().BeTrue(); + } + + [Test] + public void should_return_false_when_commands_list_for_non_primitive_type_dont_match() + { + var files1 = Builder.CreateListOfSize(2) + .All() + .With(m => m.Path = GivenRandomPath()) + .Build() + .ToList(); + var files2 = Builder.CreateListOfSize(2) + .All() + .With(m => m.Path = GivenRandomPath()) + .Build() + .ToList(); + var command1 = new ManualImportCommand { Files = files1 }; + var command2 = new ManualImportCommand { Files = files2 }; CommandEqualityComparer.Instance.Equals(command1, command2).Should().BeFalse(); } } diff --git a/src/NzbDrone.Core.Test/Messaging/Commands/CommandExecutorFixture.cs b/src/NzbDrone.Core.Test/Messaging/Commands/CommandExecutorFixture.cs index 4a039e699..733e00240 100644 --- a/src/NzbDrone.Core.Test/Messaging/Commands/CommandExecutorFixture.cs +++ b/src/NzbDrone.Core.Test/Messaging/Commands/CommandExecutorFixture.cs @@ -1,121 +1,218 @@ -//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 CommandQueue _commandQueue; + 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); + } + + [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 CommandQueue(); + + Mocker.GetMock() + .Setup(s => s.Queue(It.IsAny())) + .Returns(_commandQueue.GetConsumingEnumerable); + } + + private void QueueAndWaitForExecution(CommandModel commandModel, bool waitPublish = false) + { + var waitEventComplete = new ManualResetEventSlim(); + var waitEventPublish = new ManualResetEventSlim(); + + Mocker.GetMock() + .Setup(s => s.Complete(It.Is(c => c == commandModel), It.IsAny())) + .Callback(() => waitEventComplete.Set()); + + Mocker.GetMock() + .Setup(s => s.Fail(It.Is(c => c == commandModel), It.IsAny(), It.IsAny())) + .Callback(() => waitEventComplete.Set()); + + Mocker.GetMock() + .Setup(s => s.PublishEvent(It.IsAny())) + .Callback(() => waitEventPublish.Set()); + + _commandQueue.Add(commandModel); + + if (!waitEventComplete.Wait(2000)) + { + Assert.Fail("Command did not Complete/Fail within 2 sec"); + } + + if (waitPublish && !waitEventPublish.Wait(500)) + { + Assert.Fail("Command did not Publish within 500 msec"); + } + } + + [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(); + + ExceptionVerification.WaitForErrors(1, 500); + } + + [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() + .Verify(s => s.Complete(It.Is(c => c == commandModel), commandA.CompletionMessage), Times.Once()); + } + + [Test] + public void should_use_last_progress_message_if_completion_message_is_null() + { + GivenCommandQueue(); + var commandB = new CommandB(); + var commandModel = new CommandModel + { + Body = commandB, + Message = "Do work" + }; + + Subject.Handle(new ApplicationStartedEvent()); + + QueueAndWaitForExecution(commandModel); + + Mocker.GetMock() + .Verify(s => s.Complete(It.Is(c => c == commandModel), commandModel.Message), Times.Once()); + } + } + + public class CommandA : Command + { + public CommandA(int id = 0) + { + } + } + + public class CommandB : Command + { + public override string CompletionMessage => null; + } + +} diff --git a/src/NzbDrone.Core.Test/Messaging/Commands/CommandQueueFixture.cs b/src/NzbDrone.Core.Test/Messaging/Commands/CommandQueueFixture.cs new file mode 100644 index 000000000..f27453095 --- /dev/null +++ b/src/NzbDrone.Core.Test/Messaging/Commands/CommandQueueFixture.cs @@ -0,0 +1,194 @@ +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Download; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Test.Framework; +using Moq; +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using NzbDrone.Core.ImportLists; +using NzbDrone.Core.Update.Commands; +using NzbDrone.Core.Music.Commands; + +namespace NzbDrone.Core.Test.Messaging.Commands +{ + [TestFixture] + public class CommandQueueFixture : CoreTest + { + private void GivenStartedDiskCommand() + { + var commandModel = Builder + .CreateNew() + .With(c => c.Name = "CheckForFinishedDownload") + .With(c => c.Body = new CheckForFinishedDownloadCommand()) + .With(c => c.Status = CommandStatus.Started) + .Build(); + + Subject.Add(commandModel); + } + + private void GivenStartedTypeExclusiveCommand() + { + var commandModel = Builder + .CreateNew() + .With(c => c.Name = "ImportListSync") + .With(c => c.Body = new ImportListSyncCommand()) + .With(c => c.Status = CommandStatus.Started) + .Build(); + + Subject.Add(commandModel); + } + + private void GivenStartedExclusiveCommand() + { + var commandModel = Builder + .CreateNew() + .With(c => c.Name = "ApplicationUpdate") + .With(c => c.Body = new ApplicationUpdateCommand()) + .With(c => c.Status = CommandStatus.Started) + .Build(); + + Subject.Add(commandModel); + } + + [Test] + public void should_not_return_disk_access_command_if_another_running() + { + GivenStartedDiskCommand(); + + var newCommandModel = Builder + .CreateNew() + .With(c => c.Name = "CheckForFinishedDownload") + .With(c => c.Body = new CheckForFinishedDownloadCommand()) + .Build(); + + Subject.Add(newCommandModel); + + Subject.TryGet(out var command); + + command.Should().BeNull(); + } + + [Test] + public void should_not_return_type_exclusive_command_if_another_running() + { + GivenStartedTypeExclusiveCommand(); + + var newCommandModel = Builder + .CreateNew() + .With(c => c.Name = "ImportListSync") + .With(c => c.Body = new ImportListSyncCommand()) + .Build(); + + Subject.Add(newCommandModel); + + Subject.TryGet(out var command); + + command.Should().BeNull(); + } + + [Test] + public void should_not_return_type_exclusive_command_if_another_and_disk_access_command_running() + { + GivenStartedTypeExclusiveCommand(); + GivenStartedDiskCommand(); + + var newCommandModel = Builder + .CreateNew() + .With(c => c.Name = "ImportListSync") + .With(c => c.Body = new ImportListSyncCommand()) + .Build(); + + Subject.Add(newCommandModel); + + Subject.TryGet(out var command); + + command.Should().BeNull(); + } + + [Test] + public void should_return_type_exclusive_command_if_another_not_running() + { + GivenStartedDiskCommand(); + + var newCommandModel = Builder + .CreateNew() + .With(c => c.Name = "ImportListSync") + .With(c => c.Body = new ImportListSyncCommand()) + .Build(); + + Subject.Add(newCommandModel); + + Subject.TryGet(out var command); + + command.Should().NotBeNull(); + command.Status.Should().Be(CommandStatus.Started); + } + + [Test] + public void should_return_regular_command_if_type_exclusive_command_running() + { + GivenStartedTypeExclusiveCommand(); + + var newCommandModel = Builder + .CreateNew() + .With(c => c.Name = "RefreshArtist") + .With(c => c.Body = new RefreshArtistCommand()) + .Build(); + + Subject.Add(newCommandModel); + + Subject.TryGet(out var command); + + command.Should().NotBeNull(); + command.Status.Should().Be(CommandStatus.Started); + } + + [Test] + public void should_not_return_exclusive_command_if_any_running() + { + GivenStartedDiskCommand(); + + var newCommandModel = Builder + .CreateNew() + .With(c => c.Name = "ApplicationUpdate") + .With(c => c.Body = new ApplicationUpdateCommand()) + .Build(); + + Subject.Add(newCommandModel); + + Subject.TryGet(out var command); + + command.Should().BeNull(); + } + + [Test] + public void should_not_return_any_command_if_exclusive_running() + { + GivenStartedExclusiveCommand(); + + var newCommandModel = Builder + .CreateNew() + .With(c => c.Name = "RefreshArtist") + .With(c => c.Body = new RefreshArtistCommand()) + .Build(); + + Subject.Add(newCommandModel); + + Subject.TryGet(out var command); + + command.Should().BeNull(); + } + + [Test] + public void should_return_null_if_nothing_queued() + { + GivenStartedDiskCommand(); + + Subject.TryGet(out var command); + + command.Should().BeNull(); + } + } +} diff --git a/src/NzbDrone.Core.Test/Messaging/Commands/CommandQueueManagerFixture.cs b/src/NzbDrone.Core.Test/Messaging/Commands/CommandQueueManagerFixture.cs index 16178a9cc..68ec47951 100644 --- a/src/NzbDrone.Core.Test/Messaging/Commands/CommandQueueManagerFixture.cs +++ b/src/NzbDrone.Core.Test/Messaging/Commands/CommandQueueManagerFixture.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using FluentAssertions; using Moq; @@ -42,6 +43,10 @@ namespace NzbDrone.Core.Test.Messaging.Commands { var command = Subject.Push(new CheckForFinishedDownloadCommand()); + // Start the command to mimic CommandQueue's behaviour + command.StartedAt = DateTime.Now; + command.Status = CommandStatus.Started; + Subject.Start(command); Subject.Complete(command, "All done"); Subject.CleanCommands(); diff --git a/src/NzbDrone.Core.Test/Metadata/Consumers/Roksbox/FindMetadataFileFixture.cs b/src/NzbDrone.Core.Test/Metadata/Consumers/Roksbox/FindMetadataFileFixture.cs deleted file mode 100644 index 6d4328b32..000000000 --- a/src/NzbDrone.Core.Test/Metadata/Consumers/Roksbox/FindMetadataFileFixture.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System.IO; -using FizzWare.NBuilder; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Extras.Metadata; -using NzbDrone.Core.Extras.Metadata.Consumers.Roksbox; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; -using NzbDrone.Test.Common; - -namespace NzbDrone.Core.Test.Metadata.Consumers.Roksbox -{ - [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(); - } - - [TestCase("Specials")] - [TestCase("specials")] - [TestCase("Season 1")] - public void should_return_season_image(string folder) - { - var path = Path.Combine(_series.Path, folder, folder + ".jpg"); - - Subject.FindMetadataFile(_series, path).Type.Should().Be(MetadataType.SeasonImage); - } - - [TestCase(".xml", MetadataType.EpisodeMetadata)] - [TestCase(".jpg", MetadataType.EpisodeImage)] - public void should_return_metadata_for_episode_if_valid_file_for_episode(string extension, MetadataType type) - { - var path = Path.Combine(_series.Path, "the.series.s01e01.episode" + extension); - - Subject.FindMetadataFile(_series, path).Type.Should().Be(type); - } - - [TestCase(".xml")] - [TestCase(".jpg")] - public void should_return_null_if_not_valid_file_for_episode(string extension) - { - var path = Path.Combine(_series.Path, "the.series.episode" + extension); - - Subject.FindMetadataFile(_series, path).Should().BeNull(); - } - - [Test] - public void should_not_return_metadata_if_image_file_is_a_thumb() - { - var path = Path.Combine(_series.Path, "the.series.s01e01.episode-thumb.jpg"); - - Subject.FindMetadataFile(_series, path).Should().BeNull(); - } - - [Test] - public void should_return_series_image_for_folder_jpg_in_series_folder() - { - var path = Path.Combine(_series.Path, new DirectoryInfo(_series.Path).Name + ".jpg"); - - Subject.FindMetadataFile(_series, path).Type.Should().Be(MetadataType.SeriesImage); - } - } -} diff --git a/src/NzbDrone.Core.Test/Metadata/Consumers/Wdtv/FindMetadataFileFixture.cs b/src/NzbDrone.Core.Test/Metadata/Consumers/Wdtv/FindMetadataFileFixture.cs deleted file mode 100644 index 078744ec8..000000000 --- a/src/NzbDrone.Core.Test/Metadata/Consumers/Wdtv/FindMetadataFileFixture.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.IO; -using FizzWare.NBuilder; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Extras.Metadata; -using NzbDrone.Core.Extras.Metadata.Consumers.Wdtv; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; -using NzbDrone.Test.Common; - -namespace NzbDrone.Core.Test.Metadata.Consumers.Wdtv -{ - [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(); - } - - [TestCase("Specials")] - [TestCase("specials")] - [TestCase("Season 1")] - public void should_return_season_image(string folder) - { - var path = Path.Combine(_series.Path, folder, "folder.jpg"); - - Subject.FindMetadataFile(_series, path).Type.Should().Be(MetadataType.SeasonImage); - } - - [TestCase(".xml", MetadataType.EpisodeMetadata)] - [TestCase(".metathumb", MetadataType.EpisodeImage)] - public void should_return_metadata_for_episode_if_valid_file_for_episode(string extension, MetadataType type) - { - var path = Path.Combine(_series.Path, "the.series.s01e01.episode" + extension); - - Subject.FindMetadataFile(_series, path).Type.Should().Be(type); - } - - [TestCase(".xml")] - [TestCase(".metathumb")] - public void should_return_null_if_not_valid_file_for_episode(string extension) - { - var path = Path.Combine(_series.Path, "the.series.episode" + extension); - - Subject.FindMetadataFile(_series, path).Should().BeNull(); - } - - [Test] - public void should_return_series_image_for_folder_jpg_in_series_folder() - { - var path = Path.Combine(_series.Path, "folder.jpg"); - - Subject.FindMetadataFile(_series, path).Type.Should().Be(MetadataType.SeriesImage); - } - } -} diff --git a/src/NzbDrone.Core.Test/MetadataSource/MetadataRequestBuilderFixture.cs b/src/NzbDrone.Core.Test/MetadataSource/MetadataRequestBuilderFixture.cs new file mode 100644 index 000000000..ea2cda29e --- /dev/null +++ b/src/NzbDrone.Core.Test/MetadataSource/MetadataRequestBuilderFixture.cs @@ -0,0 +1,51 @@ +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.MetadataSource; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Configuration; +using NzbDrone.Common.Cloud; +using NzbDrone.Common.Http; + +namespace NzbDrone.Core.Test.MetadataSource +{ + [TestFixture] + public class MetadataRequestBuilderFixture : CoreTest + { + [SetUp] + public void Setup() + { + Mocker.GetMock() + .Setup(s => s.MetadataSource) + .Returns(""); + + Mocker.GetMock() + .Setup(s => s.Search) + .Returns(new HttpRequestBuilder("https://api.lidarr.audio/api/v0.4/{route}").CreateFactory()); + } + + private void WithCustomProvider() + { + Mocker.GetMock() + .Setup(s => s.MetadataSource) + .Returns("http://api.lidarr.audio/api/testing/"); + } + + [TestCase] + public void should_use_user_definied_if_not_blank() + { + WithCustomProvider(); + + var details = Subject.GetRequestBuilder().Create(); + + details.BaseUrl.ToString().Should().Contain("testing"); + } + + [TestCase] + public void should_use_default_if_config_blank() + { + var details = Subject.GetRequestBuilder().Create(); + + details.BaseUrl.ToString().Should().Contain("v0.4"); + } + } +} diff --git a/src/NzbDrone.Core.Test/MetadataSource/SearchArtistComparerFixture.cs b/src/NzbDrone.Core.Test/MetadataSource/SearchArtistComparerFixture.cs new file mode 100644 index 000000000..9a70a3e38 --- /dev/null +++ b/src/NzbDrone.Core.Test/MetadataSource/SearchArtistComparerFixture.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.MetadataSource; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Test.MetadataSource +{ + [TestFixture] + public class SearchArtistComparerFixture : CoreTest + { + private List _artist; + + [SetUp] + public void Setup() + { + _artist = new List(); + } + + private void WithSeries(string name) + { + _artist.Add(new Artist { Name = name }); + } + + [Test] + public void should_prefer_the_walking_dead_over_talking_dead_when_searching_for_the_walking_dead() + { + WithSeries("Talking Dead"); + WithSeries("The Walking Dead"); + + _artist.Sort(new SearchArtistComparer("the walking dead")); + + _artist.First().Name.Should().Be("The Walking Dead"); + } + + [Test] + public void should_prefer_the_walking_dead_over_talking_dead_when_searching_for_walking_dead() + { + WithSeries("Talking Dead"); + WithSeries("The Walking Dead"); + + _artist.Sort(new SearchArtistComparer("walking dead")); + + _artist.First().Name.Should().Be("The Walking Dead"); + } + + [Test] + public void should_prefer_blacklist_over_the_blacklist_when_searching_for_blacklist() + { + WithSeries("The Blacklist"); + WithSeries("Blacklist"); + + _artist.Sort(new SearchArtistComparer("blacklist")); + + _artist.First().Name.Should().Be("Blacklist"); + } + + [Test] + public void should_prefer_the_blacklist_over_blacklist_when_searching_for_the_blacklist() + { + WithSeries("Blacklist"); + WithSeries("The Blacklist"); + + _artist.Sort(new SearchArtistComparer("the blacklist")); + + _artist.First().Name.Should().Be("The Blacklist"); + } + } +} diff --git a/src/NzbDrone.Core.Test/MetadataSource/SearchSeriesComparerFixture.cs b/src/NzbDrone.Core.Test/MetadataSource/SearchSeriesComparerFixture.cs deleted file mode 100644 index f7f9053dd..000000000 --- a/src/NzbDrone.Core.Test/MetadataSource/SearchSeriesComparerFixture.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.MetadataSource; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Test.MetadataSource -{ - [TestFixture] - public class SearchSeriesComparerFixture : CoreTest - { - private List _series; - - [SetUp] - public void Setup() - { - _series = new List(); - } - - private void WithSeries(string title) - { - _series.Add(new Series { Title = title }); - } - - [Test] - public void should_prefer_the_walking_dead_over_talking_dead_when_searching_for_the_walking_dead() - { - WithSeries("Talking Dead"); - WithSeries("The Walking Dead"); - - _series.Sort(new SearchSeriesComparer("the walking dead")); - - _series.First().Title.Should().Be("The Walking Dead"); - } - - [Test] - public void should_prefer_the_walking_dead_over_talking_dead_when_searching_for_walking_dead() - { - WithSeries("Talking Dead"); - WithSeries("The Walking Dead"); - - _series.Sort(new SearchSeriesComparer("walking dead")); - - _series.First().Title.Should().Be("The Walking Dead"); - } - - [Test] - public void should_prefer_blacklist_over_the_blacklist_when_searching_for_blacklist() - { - WithSeries("The Blacklist"); - WithSeries("Blacklist"); - - _series.Sort(new SearchSeriesComparer("blacklist")); - - _series.First().Title.Should().Be("Blacklist"); - } - - [Test] - public void should_prefer_the_blacklist_over_blacklist_when_searching_for_the_blacklist() - { - WithSeries("Blacklist"); - WithSeries("The Blacklist"); - - _series.Sort(new SearchSeriesComparer("the blacklist")); - - _series.First().Title.Should().Be("The Blacklist"); - } - } -} diff --git a/src/NzbDrone.Core.Test/MetadataSource/SkyHook/SkyHookProxyFixture.cs b/src/NzbDrone.Core.Test/MetadataSource/SkyHook/SkyHookProxyFixture.cs index e6178c0d2..50607b816 100644 --- a/src/NzbDrone.Core.Test/MetadataSource/SkyHook/SkyHookProxyFixture.cs +++ b/src/NzbDrone.Core.Test/MetadataSource/SkyHook/SkyHookProxyFixture.cs @@ -1,14 +1,16 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.Exceptions; -using NzbDrone.Core.MediaCover; using NzbDrone.Core.MetadataSource.SkyHook; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; using NzbDrone.Test.Common.Categories; +using Moq; +using NzbDrone.Core.Profiles.Metadata; +using NzbDrone.Core.MetadataSource.SkyHook.Resource; namespace NzbDrone.Core.Test.MetadataSource.SkyHook { @@ -16,94 +18,232 @@ namespace NzbDrone.Core.Test.MetadataSource.SkyHook [IntegrationTest] public class SkyHookProxyFixture : CoreTest { + private MetadataProfile _metadataProfile; + [SetUp] public void Setup() { UseRealHttp(); + + _metadataProfile = new MetadataProfile + { + PrimaryAlbumTypes = new List + { + new ProfilePrimaryAlbumTypeItem + { + PrimaryAlbumType = PrimaryAlbumType.Album, + Allowed = true + } + }, + SecondaryAlbumTypes = new List + { + new ProfileSecondaryAlbumTypeItem() + { + SecondaryAlbumType = SecondaryAlbumType.Studio, + Allowed = true + } + }, + ReleaseStatuses = new List + { + new ProfileReleaseStatusItem + { + ReleaseStatus = ReleaseStatus.Official, + Allowed = true + } + } + }; + + Mocker.GetMock() + .Setup(s => s.Get(It.IsAny())) + .Returns(_metadataProfile); + + Mocker.GetMock() + .Setup(s => s.Exists(It.IsAny())) + .Returns(true); + } + + public List GivenExampleAlbums() + { + var result = new List(); + + foreach (var primaryType in PrimaryAlbumType.All) + { + foreach (var secondaryType in SecondaryAlbumType.All) + { + var secondaryTypes = secondaryType.Name == "Studio" ? new List() : new List { secondaryType.Name }; + foreach (var releaseStatus in ReleaseStatus.All) + { + var releaseStatuses = new List { releaseStatus.Name }; + result.Add(new AlbumResource { + Type = primaryType.Name, + SecondaryTypes = secondaryTypes, + ReleaseStatuses = releaseStatuses + }); + } + } + } + + return result; } - [TestCase(75978, "Family Guy")] - [TestCase(83462, "Castle (2009)")] - [TestCase(266189, "The Blacklist")] - public void should_be_able_to_get_series_detail(int tvdbId, string title) + [TestCase("f59c5520-5f46-4d2c-b2c4-822eabf53419", "Linkin Park")] + [TestCase("66c662b6-6e2f-4930-8610-912e24c63ed1", "AC/DC")] + public void should_be_able_to_get_artist_detail(string mbId, string name) { - var details = Subject.GetSeriesInfo(tvdbId); + var details = Subject.GetArtistInfo(mbId, 1); - ValidateSeries(details.Item1); - ValidateEpisodes(details.Item2); + ValidateArtist(details); + ValidateAlbums(details.Albums.Value, true); - details.Item1.Title.Should().Be(title); + details.Name.Should().Be(name); } - [Test] - public void getting_details_of_invalid_series() + [TestCaseSource(typeof(PrimaryAlbumType), "All")] + public void should_filter_albums_by_primary_release_type(PrimaryAlbumType type) + { + _metadataProfile.PrimaryAlbumTypes = new List { + new ProfilePrimaryAlbumTypeItem + { + PrimaryAlbumType = type, + Allowed = true + } + }; + + + var albums = GivenExampleAlbums(); + Subject.FilterAlbums(albums, 1).Select(x => x.Type).Distinct() + .Should().BeEquivalentTo(new List { type.Name }); + } + + [TestCaseSource(typeof(SecondaryAlbumType), "All")] + public void should_filter_albums_by_secondary_release_type(SecondaryAlbumType type) + { + _metadataProfile.SecondaryAlbumTypes = new List { + new ProfileSecondaryAlbumTypeItem + { + SecondaryAlbumType = type, + Allowed = true + } + }; + + var albums = GivenExampleAlbums(); + var filtered = Subject.FilterAlbums(albums, 1); + TestLogger.Debug(filtered.Count()); + + filtered.SelectMany(x => x.SecondaryTypes.Select(SkyHookProxy.MapSecondaryTypes)) + .Select(x => x.Name) + .Distinct() + .Should().BeEquivalentTo(type.Name == "Studio" ? new List() : new List { type.Name }); + } + + [TestCaseSource(typeof(ReleaseStatus), "All")] + public void should_filter_albums_by_release_status(ReleaseStatus type) + { + _metadataProfile.ReleaseStatuses = new List { + new ProfileReleaseStatusItem + { + ReleaseStatus = type, + Allowed = true + } + }; + + var albums = GivenExampleAlbums(); + Subject.FilterAlbums(albums, 1).SelectMany(x => x.ReleaseStatuses).Distinct() + .Should().BeEquivalentTo(new List { type.Name }); + } + + [TestCase("12fa3845-7c62-36e5-a8da-8be137155a72", "Hysteria")] + public void should_be_able_to_get_album_detail(string mbId, string name) + { + var details = Subject.GetAlbumInfo(mbId); + + ValidateAlbums(new List {details.Item2}); + + details.Item2.Title.Should().Be(name); + } + + [TestCase("12fa3845-7c62-36e5-a8da-8be137155a72", "3c186b52-ca73-46a3-a8e6-04559bfbb581",1, 13, "Hysteria")] + [TestCase("12fa3845-7c62-36e5-a8da-8be137155a72", "dee9ca6f-4f84-4359-82a9-b75a37ffc316",2, 27,"Hysteria")] + public void should_be_able_to_get_album_detail_with_release(string mbId, string release, int mediaCount, int trackCount, string name) { - Assert.Throws(() => Subject.GetSeriesInfo(int.MaxValue)); + var details = Subject.GetAlbumInfo(mbId); + + ValidateAlbums(new List { details.Item2 }); + + details.Item2.AlbumReleases.Value.Single(r => r.ForeignReleaseId == release).Media.Count.Should().Be(mediaCount); + details.Item2.AlbumReleases.Value.Single(r => r.ForeignReleaseId == release).Tracks.Value.Count.Should().Be(trackCount); + details.Item2.Title.Should().Be(name); } [Test] - public void should_not_have_period_at_start_of_title_slug() + public void getting_details_of_invalid_artist() { - var details = Subject.GetSeriesInfo(79099); + Assert.Throws(() => Subject.GetArtistInfo("66c66aaa-6e2f-4930-8610-912e24c63ed1", 1)); + } - details.Item1.TitleSlug.Should().Be("dothack"); + [Test] + public void getting_details_of_invalid_guid_for_artist() + { + Assert.Throws(() => Subject.GetArtistInfo("66c66aaa-6e2f-4930-aaaaaa", 1)); } - private void ValidateSeries(Series series) + [Test] + public void getting_details_of_invalid_album() { - series.Should().NotBeNull(); - series.Title.Should().NotBeNullOrWhiteSpace(); - series.CleanTitle.Should().Be(Parser.Parser.CleanSeriesTitle(series.Title)); - series.SortTitle.Should().Be(SeriesTitleNormalizer.Normalize(series.Title, series.TvdbId)); - series.Overview.Should().NotBeNullOrWhiteSpace(); - series.AirTime.Should().NotBeNullOrWhiteSpace(); - series.FirstAired.Should().HaveValue(); - series.FirstAired.Value.Kind.Should().Be(DateTimeKind.Utc); - series.Images.Should().NotBeEmpty(); - series.ImdbId.Should().NotBeNullOrWhiteSpace(); - series.Network.Should().NotBeNullOrWhiteSpace(); - series.Runtime.Should().BeGreaterThan(0); - series.TitleSlug.Should().NotBeNullOrWhiteSpace(); - //series.TvRageId.Should().BeGreaterThan(0); - series.TvdbId.Should().BeGreaterThan(0); + Assert.Throws(() => Subject.GetAlbumInfo("66c66aaa-6e2f-4930-8610-912e24c63ed1")); } - private void ValidateEpisodes(List episodes) + [Test] + public void getting_details_of_invalid_guid_for_album() { - episodes.Should().NotBeEmpty(); + Assert.Throws(() => Subject.GetAlbumInfo("66c66aaa-6e2f-4930-aaaaaa")); + } - var episodeGroup = episodes.GroupBy(e => e.SeasonNumber.ToString("000") + e.EpisodeNumber.ToString("000")); - episodeGroup.Should().OnlyContain(c => c.Count() == 1); + private void ValidateArtist(Artist artist) + { + artist.Should().NotBeNull(); + artist.Name.Should().NotBeNullOrWhiteSpace(); + artist.CleanName.Should().Be(Parser.Parser.CleanArtistName(artist.Name)); + artist.SortName.Should().Be(Parser.Parser.NormalizeTitle(artist.Name)); + artist.Metadata.Value.Overview.Should().NotBeNullOrWhiteSpace(); + artist.Metadata.Value.Images.Should().NotBeEmpty(); + artist.ForeignArtistId.Should().NotBeNullOrWhiteSpace(); + } - episodes.Should().Contain(c => c.SeasonNumber > 0); - episodes.Should().Contain(c => !string.IsNullOrWhiteSpace(c.Overview)); + private void ValidateAlbums(List albums, bool idOnly = false) + { + albums.Should().NotBeEmpty(); - foreach (var episode in episodes) + foreach (var album in albums) { - ValidateEpisode(episode); + album.ForeignAlbumId.Should().NotBeNullOrWhiteSpace(); + if (!idOnly) + { + ValidateAlbum(album); + } - //if atleast one episdoe has title it means parse it working. - episodes.Should().Contain(c => !string.IsNullOrWhiteSpace(c.Title)); + } + + //if atleast one album has title it means parse it working. + if (!idOnly) + { + albums.Should().Contain(c => !string.IsNullOrWhiteSpace(c.Title)); } } - private void ValidateEpisode(Episode episode) + private void ValidateAlbum(Album album) { - episode.Should().NotBeNull(); + album.Should().NotBeNull(); + + album.Title.Should().NotBeNullOrWhiteSpace(); + album.AlbumType.Should().NotBeNullOrWhiteSpace(); - //TODO: Is there a better way to validate that episode number or season number is greater than zero? - (episode.EpisodeNumber + episode.SeasonNumber).Should().NotBe(0); + album.Should().NotBeNull(); - episode.Should().NotBeNull(); - - if (episode.AirDateUtc.HasValue) + if (album.ReleaseDate.HasValue) { - episode.AirDateUtc.Value.Kind.Should().Be(DateTimeKind.Utc); + album.ReleaseDate.Value.Kind.Should().Be(DateTimeKind.Utc); } - - episode.Images.Any(i => i.CoverType == MediaCoverTypes.Screenshot && i.Url.Contains("-940.")) - .Should() - .BeFalse(); } } } diff --git a/src/NzbDrone.Core.Test/MetadataSource/SkyHook/SkyHookProxySearchFixture.cs b/src/NzbDrone.Core.Test/MetadataSource/SkyHook/SkyHookProxySearchFixture.cs index 2ec2d8bc0..d1666981d 100644 --- a/src/NzbDrone.Core.Test/MetadataSource/SkyHook/SkyHookProxySearchFixture.cs +++ b/src/NzbDrone.Core.Test/MetadataSource/SkyHook/SkyHookProxySearchFixture.cs @@ -1,9 +1,13 @@ -using FluentAssertions; +using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.MetadataSource.SkyHook; using NzbDrone.Core.Test.Framework; using NzbDrone.Test.Common; using NzbDrone.Test.Common.Categories; +using Moq; +using NzbDrone.Core.Profiles.Metadata; +using NzbDrone.Core.Music; +using System.Collections.Generic; namespace NzbDrone.Core.Test.MetadataSource.SkyHook { @@ -15,22 +19,73 @@ namespace NzbDrone.Core.Test.MetadataSource.SkyHook public void Setup() { UseRealHttp(); + + var _metadataProfile = new MetadataProfile + { + Id = 1, + PrimaryAlbumTypes = new List + { + new ProfilePrimaryAlbumTypeItem + { + PrimaryAlbumType = PrimaryAlbumType.Album, + Allowed = true + + } + }, + SecondaryAlbumTypes = new List + { + new ProfileSecondaryAlbumTypeItem() + { + SecondaryAlbumType = SecondaryAlbumType.Studio, + Allowed = true + } + }, + ReleaseStatuses = new List + { + new ProfileReleaseStatusItem + { + ReleaseStatus = ReleaseStatus.Official, + Allowed = true + } + } + }; + + Mocker.GetMock() + .Setup(s => s.All()) + .Returns(new List{_metadataProfile}); + + Mocker.GetMock() + .Setup(s => s.Get(It.IsAny())) + .Returns(_metadataProfile); } - [TestCase("The Simpsons", "The Simpsons")] - [TestCase("South Park", "South Park")] - [TestCase("Franklin & Bash", "Franklin & Bash")] - [TestCase("House", "House")] - [TestCase("Mr. D", "Mr. D")] - [TestCase("Rob & Big", "Rob & Big")] - [TestCase("M*A*S*H", "M*A*S*H")] - //[TestCase("imdb:tt0436992", "Doctor Who (2005)")] - [TestCase("tvdb:78804", "Doctor Who (2005)")] - [TestCase("tvdbid:78804", "Doctor Who (2005)")] - [TestCase("tvdbid: 78804 ", "Doctor Who (2005)")] - public void successful_search(string title, string expected) + [TestCase("Coldplay", "Coldplay")] + [TestCase("Avenged Sevenfold", "Avenged Sevenfold")] + [TestCase("3OH!3", "3OH!3")] + [TestCase("The Academy Is...", "The Academy Is…")] + [TestCase("lidarr:f59c5520-5f46-4d2c-b2c4-822eabf53419", "Linkin Park")] + [TestCase("lidarrid:f59c5520-5f46-4d2c-b2c4-822eabf53419", "Linkin Park")] + [TestCase("lidarrid: f59c5520-5f46-4d2c-b2c4-822eabf53419 ", "Linkin Park")] + public void successful_artist_search(string title, string expected) + { + var result = Subject.SearchForNewArtist(title); + + result.Should().NotBeEmpty(); + + result[0].Name.Should().Be(expected); + + ExceptionVerification.IgnoreWarns(); + } + + + [TestCase("Evolve", "Imagine Dragons", "Evolve")] + [TestCase("Hysteria", null, "Hysteria")] + [TestCase("lidarr:d77df681-b779-3d6d-b66a-3bfd15985e3e", null, "Pyromania")] + [TestCase("lidarr: d77df681-b779-3d6d-b66a-3bfd15985e3e", null, "Pyromania")] + [TestCase("lidarrid:d77df681-b779-3d6d-b66a-3bfd15985e3e", null, "Pyromania")] + public void successful_album_search(string title, string artist, string expected) { - var result = Subject.SearchForNewSeries(title); + var result = Subject.SearchForNewAlbum(title, artist); result.Should().NotBeEmpty(); @@ -39,15 +94,15 @@ namespace NzbDrone.Core.Test.MetadataSource.SkyHook ExceptionVerification.IgnoreWarns(); } - [TestCase("tvdbid:")] - [TestCase("tvdbid: 99999999999999999999")] - [TestCase("tvdbid: 0")] - [TestCase("tvdbid: -12")] - [TestCase("tvdbid:289578")] - [TestCase("adjalkwdjkalwdjklawjdlKAJD;EF")] - public void no_search_result(string term) + [TestCase("lidarrid:")] + [TestCase("lidarrid: 99999999999999999999")] + [TestCase("lidarrid: 0")] + [TestCase("lidarrid: -12")] + [TestCase("lidarrid:289578")] + [TestCase("adjalkwdjkalwdjklawjdlKAJD")] + public void no_artist_search_result(string term) { - var result = Subject.SearchForNewSeries(term); + var result = Subject.SearchForNewArtist(term); result.Should().BeEmpty(); ExceptionVerification.IgnoreWarns(); diff --git a/src/NzbDrone.Core.Test/MusicTests/AddArtistFixture.cs b/src/NzbDrone.Core.Test/MusicTests/AddArtistFixture.cs new file mode 100644 index 000000000..1f04fa6a3 --- /dev/null +++ b/src/NzbDrone.Core.Test/MusicTests/AddArtistFixture.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.IO; +using FizzWare.NBuilder; +using FluentAssertions; +using FluentValidation; +using FluentValidation.Results; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.MetadataSource; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Music; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.MusicTests +{ + [TestFixture] + public class AddArtistFixture : CoreTest + { + private Artist _fakeArtist; + + [SetUp] + public void Setup() + { + _fakeArtist = Builder + .CreateNew() + .With(s => s.Path = null) + .Build(); + _fakeArtist.Albums = new List(); + } + + private void GivenValidArtist(string lidarrId) + { + Mocker.GetMock() + .Setup(s => s.GetArtistInfo(lidarrId, It.IsAny())) + .Returns(_fakeArtist); + } + + private void GivenValidPath() + { + Mocker.GetMock() + .Setup(s => s.GetArtistFolder(It.IsAny(), null)) + .Returns((c, n) => c.Name); + + Mocker.GetMock() + .Setup(s => s.Validate(It.IsAny())) + .Returns(new ValidationResult()); + } + + [Test] + public void should_be_able_to_add_a_artist_without_passing_in_name() + { + var newArtist = new Artist + { + ForeignArtistId = "ce09ea31-3d4a-4487-a797-e315175457a0", + RootFolderPath = @"C:\Test\Music" + }; + + GivenValidArtist(newArtist.ForeignArtistId); + GivenValidPath(); + + var artist = Subject.AddArtist(newArtist); + + artist.Name.Should().Be(_fakeArtist.Name); + } + + [Test] + public void should_have_proper_path() + { + var newArtist = new Artist + { + ForeignArtistId = "ce09ea31-3d4a-4487-a797-e315175457a0", + RootFolderPath = @"C:\Test\Music" + }; + + GivenValidArtist(newArtist.ForeignArtistId); + GivenValidPath(); + + var artist = Subject.AddArtist(newArtist); + + artist.Path.Should().Be(Path.Combine(newArtist.RootFolderPath, _fakeArtist.Name)); + } + + [Test] + public void should_throw_if_artist_validation_fails() + { + var newArtist = new Artist + { + ForeignArtistId = "ce09ea31-3d4a-4487-a797-e315175457a0", + Path = @"C:\Test\Music\Name1" + }; + + GivenValidArtist(newArtist.ForeignArtistId); + + Mocker.GetMock() + .Setup(s => s.Validate(It.IsAny())) + .Returns(new ValidationResult(new List + { + new ValidationFailure("Path", "Test validation failure") + })); + + Assert.Throws(() => Subject.AddArtist(newArtist)); + } + + [Test] + public void should_throw_if_artist_cannot_be_found() + { + var newArtist = new Artist + { + ForeignArtistId = "ce09ea31-3d4a-4487-a797-e315175457a0", + Path = @"C:\Test\Music\Name1" + }; + + Mocker.GetMock() + .Setup(s => s.GetArtistInfo(newArtist.ForeignArtistId, newArtist.MetadataProfileId)) + .Throws(new ArtistNotFoundException(newArtist.ForeignArtistId)); + + Mocker.GetMock() + .Setup(s => s.Validate(It.IsAny())) + .Returns(new ValidationResult(new List + { + new ValidationFailure("Path", "Test validation failure") + })); + + Assert.Throws(() => Subject.AddArtist(newArtist)); + + ExceptionVerification.ExpectedErrors(1); + } + } +} diff --git a/src/NzbDrone.Core.Test/MusicTests/AlbumMonitoredServiceTests/AlbumMonitoredServiceFixture.cs b/src/NzbDrone.Core.Test/MusicTests/AlbumMonitoredServiceTests/AlbumMonitoredServiceFixture.cs new file mode 100644 index 000000000..9d8fd0052 --- /dev/null +++ b/src/NzbDrone.Core.Test/MusicTests/AlbumMonitoredServiceTests/AlbumMonitoredServiceFixture.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Music; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.MusicTests.AlbumMonitoredServiceTests +{ + [TestFixture] + public class SetAlbumMontitoredFixture : CoreTest + { + private Artist _artist; + private List _albums; + + [SetUp] + public void Setup() + { + const int albums = 4; + + _artist = Builder.CreateNew() + .Build(); + + _albums = Builder.CreateListOfSize(albums) + .All() + .With(e => e.Monitored = true) + .With(e => e.ReleaseDate = DateTime.UtcNow.AddDays(-7)) + //Future + .TheFirst(1) + .With(e => e.ReleaseDate = DateTime.UtcNow.AddDays(7)) + //Future/TBA + .TheNext(1) + .With(e => e.ReleaseDate = null) + .Build() + .ToList(); + + Mocker.GetMock() + .Setup(s => s.GetAlbumsByArtist(It.IsAny())) + .Returns(_albums); + + Mocker.GetMock() + .Setup(s => s.GetArtistAlbumsWithFiles(It.IsAny())) + .Returns(new List()); + + Mocker.GetMock() + .Setup(s => s.GetTracksByAlbum(It.IsAny())) + .Returns(new List()); + } + + [Test] + public void should_be_able_to_monitor_artist_without_changing_albums() + { + Subject.SetAlbumMonitoredStatus(_artist, null); + + Mocker.GetMock() + .Verify(v => v.UpdateArtist(It.IsAny()), Times.Once()); + + Mocker.GetMock() + .Verify(v => v.UpdateMany(It.IsAny>()), Times.Never()); + } + + [Test] + public void should_be_able_to_monitor_albums_when_passed_in_artist() + { + var albumsToMonitor = new List{_albums.First().ForeignAlbumId}; + + Subject.SetAlbumMonitoredStatus(_artist, new MonitoringOptions { Monitored = true, AlbumsToMonitor = albumsToMonitor }); + + Mocker.GetMock() + .Verify(v => v.UpdateArtist(It.IsAny()), Times.Once()); + + VerifyMonitored(e => e.ForeignAlbumId == _albums.First().ForeignAlbumId); + VerifyNotMonitored(e => e.ForeignAlbumId != _albums.First().ForeignAlbumId); + } + + [Test] + public void should_be_able_to_monitor_all_albums() + { + Subject.SetAlbumMonitoredStatus(_artist, new MonitoringOptions{Monitor = MonitorTypes.All}); + + Mocker.GetMock() + .Verify(v => v.UpdateMany(It.Is>(l => l.All(e => e.Monitored)))); + } + + [Test] + public void should_be_able_to_monitor_new_albums_only() + { + var monitoringOptions = new MonitoringOptions + { + Monitor = MonitorTypes.Future + }; + + Subject.SetAlbumMonitoredStatus(_artist, monitoringOptions); + + VerifyMonitored(e => e.ReleaseDate.HasValue && e.ReleaseDate.Value.After(DateTime.UtcNow)); + VerifyMonitored(e => !e.ReleaseDate.HasValue); + VerifyNotMonitored(e => e.ReleaseDate.HasValue && e.ReleaseDate.Value.Before(DateTime.UtcNow)); + } + + private void VerifyMonitored(Func predicate) + { + Mocker.GetMock() + .Verify(v => v.UpdateMany(It.Is>(l => l.Where(predicate).All(e => e.Monitored)))); + } + + private void VerifyNotMonitored(Func predicate) + { + Mocker.GetMock() + .Verify(v => v.UpdateMany(It.Is>(l => l.Where(predicate).All(e => !e.Monitored)))); + } + } +} diff --git a/src/NzbDrone.Core.Test/MusicTests/AlbumRepositoryTests/AlbumRepositoryFixture.cs b/src/NzbDrone.Core.Test/MusicTests/AlbumRepositoryTests/AlbumRepositoryFixture.cs new file mode 100644 index 000000000..66e82f0bf --- /dev/null +++ b/src/NzbDrone.Core.Test/MusicTests/AlbumRepositoryTests/AlbumRepositoryFixture.cs @@ -0,0 +1,199 @@ +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Music; +using NzbDrone.Core.Test.Framework; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace NzbDrone.Core.Test.MusicTests.AlbumRepositoryTests +{ + [TestFixture] + public class AlbumRepositoryFixture : DbTest + { + private Artist _artist; + private Album _album; + private Album _albumSpecial; + private List _albums; + private AlbumRelease _release; + private AlbumRepository _albumRepo; + private ReleaseRepository _releaseRepo; + + [SetUp] + public void Setup() + { + _artist = new Artist + { + Name = "Alien Ant Farm", + Monitored = true, + ForeignArtistId = "this is a fake id", + Id = 1, + Metadata = new ArtistMetadata { + Id = 1 + } + }; + + _albumRepo = Mocker.Resolve(); + _releaseRepo = Mocker.Resolve(); + + _release = Builder + .CreateNew() + .With(e => e.Id = 0) + .With(e => e.ForeignReleaseId = "e00e40a3-5ed5-4ed3-9c22-0a8ff4119bdf" ) + .With(e => e.Monitored = true) + .Build(); + + _album = new Album + { + Title = "ANThology", + ForeignAlbumId = "1", + CleanTitle = "anthology", + Artist = _artist, + ArtistMetadataId = _artist.ArtistMetadataId, + AlbumType = "", + AlbumReleases = new List {_release }, + }; + + _albumRepo.Insert(_album); + _release.AlbumId = _album.Id; + _releaseRepo.Insert(_release); + _albumRepo.Update(_album); + + _albumSpecial = new Album + { + Title = "+", + ForeignAlbumId = "2", + CleanTitle = "", + Artist = _artist, + ArtistMetadataId = _artist.ArtistMetadataId, + AlbumType = "", + AlbumReleases = new List + { + new AlbumRelease + { + ForeignReleaseId = "fake id" + } + } + + }; + + _albumRepo.Insert(_albumSpecial); + + } + + [Test] + public void should_find_album_in_db_by_releaseid() + { + var id = "e00e40a3-5ed5-4ed3-9c22-0a8ff4119bdf"; + + var album = _albumRepo.FindAlbumByRelease(id); + + album.Should().NotBeNull(); + album.Title.Should().Be(_album.Title); + } + + [TestCase("ANThology")] + [TestCase("anthology")] + [TestCase("anthology!")] + public void should_find_album_in_db_by_title(string title) + { + var album = _albumRepo.FindByTitle(_artist.ArtistMetadataId, title); + + album.Should().NotBeNull(); + album.Title.Should().Be(_album.Title); + } + + [Test] + public void should_find_album_in_db_by_title_all_special_characters() + { + var album = _albumRepo.FindByTitle(_artist.ArtistMetadataId, "+"); + + album.Should().NotBeNull(); + album.Title.Should().Be(_albumSpecial.Title); + } + + [TestCase("ANTholog")] + [TestCase("nthology")] + [TestCase("antholoyg")] + [TestCase("÷")] + public void should_not_find_album_in_db_by_incorrect_title(string title) + { + var album = _albumRepo.FindByTitle(_artist.ArtistMetadataId, title); + + album.Should().BeNull(); + } + + [Test] + public void should_not_find_album_when_two_albums_have_same_name() + { + var albums = Builder.CreateListOfSize(2) + .All() + .With(x => x.Id = 0) + .With(x => x.Artist = _artist) + .With(x => x.ArtistMetadataId = _artist.ArtistMetadataId) + .With(x => x.Title = "Weezer") + .With(x => x.CleanTitle = "weezer") + .Build(); + + _albumRepo.InsertMany(albums); + + var album = _albumRepo.FindByTitle(_artist.ArtistMetadataId, "Weezer"); + + _albumRepo.All().Should().HaveCount(4); + album.Should().BeNull(); + } + + [Test] + public void should_not_find_album_in_db_by_partial_releaseid() + { + var id = "e00e40a3-5ed5-4ed3-9c22"; + + var album = _albumRepo.FindAlbumByRelease(id); + + album.Should().BeNull(); + } + + private void GivenMultipleAlbums() + { + _albums = Builder.CreateListOfSize(4) + .All() + .With(x => x.Id = 0) + .With(x => x.Artist = _artist) + .With(x => x.ArtistMetadataId = _artist.ArtistMetadataId) + .TheFirst(1) + // next + .With(x => x.ReleaseDate = DateTime.UtcNow.AddDays(1)) + .TheNext(1) + // another future one + .With(x => x.ReleaseDate = DateTime.UtcNow.AddDays(2)) + .TheNext(1) + // most recent + .With(x => x.ReleaseDate = DateTime.UtcNow.AddDays(-1)) + .TheNext(1) + // an older one + .With(x => x.ReleaseDate = DateTime.UtcNow.AddDays(-2)) + .BuildList(); + + _albumRepo.InsertMany(_albums); + } + + [Test] + public void get_next_albums_should_return_next_album() + { + GivenMultipleAlbums(); + + var result = _albumRepo.GetNextAlbums(new [] { _artist.ArtistMetadataId }); + result.Should().BeEquivalentTo(_albums.Take(1)); + } + + [Test] + public void get_last_albums_should_return_next_album() + { + GivenMultipleAlbums(); + + var result = _albumRepo.GetLastAlbums(new [] { _artist.ArtistMetadataId }); + result.Should().BeEquivalentTo(_albums.Skip(2).Take(1)); + } + } +} diff --git a/src/NzbDrone.Core.Test/MusicTests/AlbumServiceFixture.cs b/src/NzbDrone.Core.Test/MusicTests/AlbumServiceFixture.cs new file mode 100644 index 000000000..ec4641eb6 --- /dev/null +++ b/src/NzbDrone.Core.Test/MusicTests/AlbumServiceFixture.cs @@ -0,0 +1,77 @@ +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Music; +using NzbDrone.Core.Test.Framework; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using NLog; +using Moq; + +namespace NzbDrone.Core.Test.MusicTests.AlbumRepositoryTests +{ + [TestFixture] + public class AlbumServiceFixture : CoreTest + { + private List _albums; + + [SetUp] + public void Setup() + { + _albums = new List(); + _albums.Add(new Album + { + Title = "ANThology", + CleanTitle = "anthology", + }); + + _albums.Add(new Album + { + Title = "+", + CleanTitle = "", + }); + + Mocker.GetMock() + .Setup(s => s.GetAlbumsByArtistMetadataId(It.IsAny())) + .Returns(_albums); + } + + private void GivenSimilarAlbum() + { + _albums.Add(new Album + { + Title = "ANThology2", + CleanTitle = "anthology2", + }); + } + + [TestCase("ANTholog", "ANThology")] + [TestCase("antholoyg", "ANThology")] + [TestCase("ANThology CD", "ANThology")] + [TestCase("ANThology CD xxxx (Remastered) - [Oh please why do they do this?]", "ANThology")] + [TestCase("+ (Plus) - I feel the need for redundant information in the title field", "+")] + public void should_find_album_in_db_by_inexact_title(string title, string expected) + { + var album = Subject.FindByTitleInexact(0, title); + + album.Should().NotBeNull(); + album.Title.Should().Be(expected); + } + + [TestCase("ANTholog")] + [TestCase("antholoyg")] + [TestCase("ANThology CD")] + [TestCase("÷")] + [TestCase("÷ (Divide)")] + public void should_not_find_album_in_db_by_inexact_title_when_two_similar_matches(string title) + { + GivenSimilarAlbum(); + var album = Subject.FindByTitleInexact(0, title); + + album.Should().BeNull(); + } + } +} diff --git a/src/NzbDrone.Core.Test/MusicTests/ArtistMetadataRepositoryTests/ArtistMetadataRepositoryFixture.cs b/src/NzbDrone.Core.Test/MusicTests/ArtistMetadataRepositoryTests/ArtistMetadataRepositoryFixture.cs new file mode 100644 index 000000000..111e456a6 --- /dev/null +++ b/src/NzbDrone.Core.Test/MusicTests/ArtistMetadataRepositoryTests/ArtistMetadataRepositoryFixture.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; +using NzbDrone.Common.Extensions; +using System.Linq; + +namespace NzbDrone.Core.Test.MusicTests.ArtistRepositoryTests +{ + [TestFixture] + + public class ArtistMetadataRepositoryFixture : DbTest + { + private ArtistMetadataRepository _artistMetadataRepo; + private List _metadataList; + + [SetUp] + public void Setup() + { + _artistMetadataRepo = Mocker.Resolve(); + _metadataList = Builder.CreateListOfSize(10).BuildList(); + } + + [Test] + public void upsert_many_should_insert_list_of_new() + { + var updated = _artistMetadataRepo.UpsertMany(_metadataList); + AllStoredModels.Should().HaveCount(_metadataList.Count); + updated.Should().BeTrue(); + } + + [Test] + public void upsert_many_should_upsert_existing_with_id_0() + { + var _clone = _metadataList.JsonClone(); + var updated = _artistMetadataRepo.UpsertMany(_clone); + + updated.Should().BeTrue(); + AllStoredModels.Should().HaveCount(_metadataList.Count); + + updated = _artistMetadataRepo.UpsertMany(_metadataList); + updated.Should().BeFalse(); + AllStoredModels.Should().HaveCount(_metadataList.Count); + } + + [Test] + public void upsert_many_should_upsert_mixed_list_of_old_and_new() + { + var _clone = _metadataList.Take(5).ToList().JsonClone(); + var updated = _artistMetadataRepo.UpsertMany(_clone); + + updated.Should().BeTrue(); + AllStoredModels.Should().HaveCount(_clone.Count); + + updated = _artistMetadataRepo.UpsertMany(_metadataList); + updated.Should().BeTrue(); + AllStoredModels.Should().HaveCount(_metadataList.Count); + } + } +} diff --git a/src/NzbDrone.Core.Test/MusicTests/ArtistRepositoryTests/ArtistRepositoryFixture.cs b/src/NzbDrone.Core.Test/MusicTests/ArtistRepositoryTests/ArtistRepositoryFixture.cs new file mode 100644 index 000000000..b2e7c6b86 --- /dev/null +++ b/src/NzbDrone.Core.Test/MusicTests/ArtistRepositoryTests/ArtistRepositoryFixture.cs @@ -0,0 +1,143 @@ +using System.Collections.Generic; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Profiles.Qualities; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; +using NzbDrone.Core.Profiles.Metadata; +using NzbDrone.Common.Extensions; +using System; +using System.Data.SQLite; + +namespace NzbDrone.Core.Test.MusicTests.ArtistRepositoryTests +{ + [TestFixture] + + public class ArtistRepositoryFixture : DbTest + { + private ArtistRepository _artistRepo; + private ArtistMetadataRepository _artistMetadataRepo; + private int _id = 1; + + [SetUp] + public void Setup() + { + _artistRepo = Mocker.Resolve(); + _artistMetadataRepo = Mocker.Resolve(); + } + + private void AddArtist(string name) + { + var metadata = Builder.CreateNew() + .With(a => a.Id = 0) + .With(a => a.Name = name) + .BuildNew(); + + var artist = Builder.CreateNew() + .With(a => a.Id = 0) + .With(a => a.Metadata = metadata) + .With(a => a.CleanName = Parser.Parser.CleanArtistName(name)) + .With(a => a.ForeignArtistId = _id.ToString()) + .BuildNew(); + _id++; + + _artistMetadataRepo.Insert(metadata); + artist.ArtistMetadataId = metadata.Id; + _artistRepo.Insert(artist); + } + + private void GivenArtists() + { + AddArtist("The Black Eyed Peas"); + AddArtist("The Black Keys"); + } + + [Test] + public void should_lazyload_profiles() + { + var profile = new QualityProfile + { + Items = Qualities.QualityFixture.GetDefaultQualities(Quality.FLAC, Quality.MP3_192, Quality.MP3_320), + + Cutoff = Quality.FLAC.Id, + Name = "TestProfile" + }; + + var metaProfile = new MetadataProfile + { + Name = "TestProfile", + PrimaryAlbumTypes = new List(), + SecondaryAlbumTypes = new List(), + ReleaseStatuses = new List() + }; + + + Mocker.Resolve().Insert(profile); + Mocker.Resolve().Insert(metaProfile); + + var artist = Builder.CreateNew().BuildNew(); + artist.QualityProfileId = profile.Id; + artist.MetadataProfileId = metaProfile.Id; + + Subject.Insert(artist); + + + StoredModel.QualityProfile.Should().NotBeNull(); + StoredModel.MetadataProfile.Should().NotBeNull(); + + } + + [TestCase("The Black Eyed Peas")] + [TestCase("The Black Keys")] + public void should_find_artist_in_db_by_name(string name) + { + GivenArtists(); + var artist = _artistRepo.FindByName(Parser.Parser.CleanArtistName(name)); + + artist.Should().NotBeNull(); + artist.Name.Should().Be(name); + } + + [Test] + public void should_not_find_artist_if_multiple_artists_have_same_name() + { + GivenArtists(); + + string name = "Alice Cooper"; + AddArtist(name); + AddArtist(name); + + _artistRepo.All().Should().HaveCount(4); + + var artist = _artistRepo.FindByName(Parser.Parser.CleanArtistName(name)); + artist.Should().BeNull(); + } + + [Test] + public void should_throw_sql_exception_adding_duplicate_artist() + { + var name = "test"; + var metadata = Builder.CreateNew() + .With(a => a.Id = 0) + .With(a => a.Name = name) + .BuildNew(); + + var artist1 = Builder.CreateNew() + .With(a => a.Id = 0) + .With(a => a.Metadata = metadata) + .With(a => a.CleanName = Parser.Parser.CleanArtistName(name)) + .BuildNew(); + + var artist2 = artist1.JsonClone(); + artist2.Metadata = metadata; + + _artistMetadataRepo.Insert(metadata); + _artistRepo.Insert(artist1); + + Action insertDupe = () => _artistRepo.Insert(artist2); + insertDupe.ShouldThrow(); + } + } +} diff --git a/src/NzbDrone.Core.Test/MusicTests/ArtistServiceTests/FindByNameInexactFixture.cs b/src/NzbDrone.Core.Test/MusicTests/ArtistServiceTests/FindByNameInexactFixture.cs new file mode 100644 index 000000000..98409d8ab --- /dev/null +++ b/src/NzbDrone.Core.Test/MusicTests/ArtistServiceTests/FindByNameInexactFixture.cs @@ -0,0 +1,73 @@ +using System.Collections.Generic; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Test.MusicTests.ArtistServiceTests +{ + [TestFixture] + + public class FindByNameInexactFixture : CoreTest + { + private List _artists; + + private Artist CreateArtist(string name) + { + return Builder.CreateNew() + .With(a => a.Name = name) + .With(a => a.CleanName = Parser.Parser.CleanArtistName(name)) + .With(a => a.ForeignArtistId = name) + .BuildNew(); + } + + [SetUp] + public void Setup() + { + _artists = new List(); + _artists.Add(CreateArtist("The Black Eyed Peas")); + _artists.Add(CreateArtist("The Black Keys")); + + Mocker.GetMock() + .Setup(s => s.All()) + .Returns(_artists); + } + + [TestCase("The Black Eyde Peas", "The Black Eyed Peas")] + [TestCase("Black Eyed Peas", "The Black Eyed Peas")] + [TestCase("The Black eys", "The Black Keys")] + [TestCase("Black Keys", "The Black Keys")] + public void should_find_artist_in_db_by_name_inexact(string name, string expected) + { + var artist = Subject.FindByNameInexact(name); + + artist.Should().NotBeNull(); + artist.Name.Should().Be(expected); + } + + [Test] + public void should_find_artist_when_the_is_omitted_from_start() + { + _artists = new List(); + _artists.Add(CreateArtist("Black Keys")); + _artists.Add(CreateArtist("The Black Eyed Peas")); + + Mocker.GetMock() + .Setup(s => s.All()) + .Returns(_artists); + + Subject.FindByNameInexact("The Black Keys").Should().NotBeNull(); + } + + [TestCase("The Black Peas")] + public void should_not_find_artist_in_db_by_ambiguous_name(string name) + { + + var artist = Subject.FindByNameInexact(name); + + artist.Should().BeNull(); + } + + } +} diff --git a/src/NzbDrone.Core.Test/MusicTests/ArtistServiceTests/UpdateMultipleArtistFixture.cs b/src/NzbDrone.Core.Test/MusicTests/ArtistServiceTests/UpdateMultipleArtistFixture.cs new file mode 100644 index 000000000..5a8b92124 --- /dev/null +++ b/src/NzbDrone.Core.Test/MusicTests/ArtistServiceTests/UpdateMultipleArtistFixture.cs @@ -0,0 +1,88 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.RootFolders; +using NzbDrone.Core.Music; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.MusicTests.ArtistServiceTests +{ + [TestFixture] + public class UpdateMultipleArtistFixture : CoreTest + { + private List _artists; + + [SetUp] + public void Setup() + { + _artists = Builder.CreateListOfSize(5) + .All() + .With(s => s.QualityProfileId = 1) + .With(s => s.Monitored) + .With(s => s.Path = @"C:\Test\name".AsOsAgnostic()) + .With(s => s.RootFolderPath = "") + .Build().ToList(); + } + + [Test] + public void should_call_repo_updateMany() + { + Subject.UpdateArtists(_artists, false); + + Mocker.GetMock().Verify(v => v.UpdateMany(_artists), Times.Once()); + } + + [Test] + public void should_update_path_when_rootFolderPath_is_supplied() + { + Mocker.GetMock() + .Setup(s => s.GetArtistFolder(It.IsAny(), null)) + .Returns((c, n) => c.Name); + + var newRoot = @"C:\Test\Music2".AsOsAgnostic(); + _artists.ForEach(s => s.RootFolderPath = newRoot); + + Mocker.GetMock() + .Setup(s => s.BuildPath(It.IsAny(), false)) + .Returns((s, u) => Path.Combine(s.RootFolderPath, s.Name)); + + + Subject.UpdateArtists(_artists, false).ForEach(s => s.Path.Should().StartWith(newRoot)); + } + + [Test] + public void should_not_update_path_when_rootFolderPath_is_empty() + { + Subject.UpdateArtists(_artists, false).ForEach(s => + { + var expectedPath = _artists.Single(ser => ser.Id == s.Id).Path; + s.Path.Should().Be(expectedPath); + }); + } + + [Test] + public void should_be_able_to_update_many_artist() + { + var artist = Builder.CreateListOfSize(50) + .All() + .With(s => s.Path = (@"C:\Test\Music\" + s.Path).AsOsAgnostic()) + .Build() + .ToList(); + + Mocker.GetMock() + .Setup(s => s.GetArtistFolder(It.IsAny(), null)) + .Returns((c, n) => c.Name); + + var newRoot = @"C:\Test\Music2".AsOsAgnostic(); + artist.ForEach(s => s.RootFolderPath = newRoot); + + Subject.UpdateArtists(artist, false); + } + } +} diff --git a/src/NzbDrone.Core.Test/MusicTests/EntityFixture.cs b/src/NzbDrone.Core.Test/MusicTests/EntityFixture.cs new file mode 100644 index 000000000..ba5fb83b5 --- /dev/null +++ b/src/NzbDrone.Core.Test/MusicTests/EntityFixture.cs @@ -0,0 +1,298 @@ +using NUnit.Framework; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Music; +using NzbDrone.Test.Common; +using FluentAssertions; +using System.Collections; +using System.Reflection; +using AutoFixture; +using System.Linq; +using Equ; +using Marr.Data; + +namespace NzbDrone.Core.Test.MusicTests +{ + [TestFixture] + public class EntityFixture : LoggingTest + { + + Fixture fixture = new Fixture(); + + private static bool IsNotMarkedAsIgnore(PropertyInfo propertyInfo) + { + return !propertyInfo.GetCustomAttributes(typeof(MemberwiseEqualityIgnoreAttribute), true).Any(); + } + + public class EqualityPropertySource + { + public static IEnumerable TestCases + { + get + { + foreach (var property in typeof(T).GetProperties().Where(x => x.CanRead && x.CanWrite && IsNotMarkedAsIgnore(x))) + { + yield return new TestCaseData(property).SetName($"{{m}}_{property.Name}"); + } + } + } + } + + public class IgnoredPropertySource + { + public static IEnumerable TestCases + { + get + { + foreach (var property in typeof(T).GetProperties().Where(x => x.CanRead && x.CanWrite && !IsNotMarkedAsIgnore(x))) + { + yield return new TestCaseData(property).SetName($"{{m}}_{property.Name}"); + } + } + } + } + + [Test] + public void two_equivalent_artist_metadata_should_be_equal() + { + var item1 = fixture.Create(); + var item2 = item1.JsonClone(); + + item1.Should().NotBeSameAs(item2); + item1.Should().Be(item2); + } + + [Test, TestCaseSource(typeof(EqualityPropertySource), "TestCases")] + public void two_different_artist_metadata_should_not_be_equal(PropertyInfo prop) + { + var item1 = fixture.Create(); + var item2 = item1.JsonClone(); + var different = fixture.Create(); + + // make item2 different in the property under consideration + var differentEntry = prop.GetValue(different); + prop.SetValue(item2, differentEntry); + + item1.Should().NotBeSameAs(item2); + item1.Should().NotBe(item2); + } + + [Test] + public void metadata_and_db_fields_should_replicate_artist_metadata() + { + var item1 = fixture.Create(); + var item2 = fixture.Create(); + + item1.Should().NotBe(item2); + + item1.UseMetadataFrom(item2); + item1.UseDbFieldsFrom(item2); + item1.Should().Be(item2); + } + + private Track GivenTrack() + { + return fixture.Build() + .Without(x => x.AlbumRelease) + .Without(x => x.ArtistMetadata) + .Without(x => x.TrackFile) + .Without(x => x.Artist) + .Without(x => x.AlbumId) + .Without(x => x.Album) + .Create(); + } + + [Test] + public void two_equivalent_track_should_be_equal() + { + var item1 = GivenTrack(); + var item2 = item1.JsonClone(); + + item1.Should().NotBeSameAs(item2); + item1.Should().Be(item2); + } + + [Test, TestCaseSource(typeof(EqualityPropertySource), "TestCases")] + public void two_different_tracks_should_not_be_equal(PropertyInfo prop) + { + var item1 = GivenTrack(); + var item2 = item1.JsonClone(); + var different = GivenTrack(); + + // make item2 different in the property under consideration + var differentEntry = prop.GetValue(different); + prop.SetValue(item2, differentEntry); + + item1.Should().NotBeSameAs(item2); + item1.Should().NotBe(item2); + } + + [Test] + public void metadata_and_db_fields_should_replicate_track() + { + var item1 = GivenTrack(); + var item2 = GivenTrack(); + + item1.Should().NotBe(item2); + + item1.UseMetadataFrom(item2); + item1.UseDbFieldsFrom(item2); + item1.Should().Be(item2); + } + + private AlbumRelease GivenAlbumRelease() + { + return fixture.Build() + .Without(x => x.Album) + .Without(x => x.Tracks) + .Create(); + } + + [Test] + public void two_equivalent_album_releases_should_be_equal() + { + var item1 = GivenAlbumRelease(); + var item2 = item1.JsonClone(); + + item1.Should().NotBeSameAs(item2); + item1.Should().Be(item2); + } + + [Test, TestCaseSource(typeof(EqualityPropertySource), "TestCases")] + public void two_different_album_releases_should_not_be_equal(PropertyInfo prop) + { + var item1 = GivenAlbumRelease(); + var item2 = item1.JsonClone(); + var different = GivenAlbumRelease(); + + // make item2 different in the property under consideration + var differentEntry = prop.GetValue(different); + prop.SetValue(item2, differentEntry); + + item1.Should().NotBeSameAs(item2); + item1.Should().NotBe(item2); + } + + [Test] + public void metadata_and_db_fields_should_replicate_release() + { + var item1 = GivenAlbumRelease(); + var item2 = GivenAlbumRelease(); + + item1.Should().NotBe(item2); + + item1.UseMetadataFrom(item2); + item1.UseDbFieldsFrom(item2); + item1.Should().Be(item2); + } + + private Album GivenAlbum() + { + return fixture.Build() + .Without(x => x.ArtistMetadata) + .Without(x => x.AlbumReleases) + .Without(x => x.Artist) + .Without(x => x.ArtistId) + .Create(); + } + + [Test] + public void two_equivalent_albums_should_be_equal() + { + var item1 = GivenAlbum(); + var item2 = item1.JsonClone(); + + item1.Should().NotBeSameAs(item2); + item1.Should().Be(item2); + } + + [Test, TestCaseSource(typeof(EqualityPropertySource), "TestCases")] + public void two_different_albums_should_not_be_equal(PropertyInfo prop) + { + var item1 = GivenAlbum(); + var item2 = item1.JsonClone(); + var different = GivenAlbum(); + + // make item2 different in the property under consideration + if (prop.PropertyType == typeof(bool)) + { + prop.SetValue(item2, !(bool)prop.GetValue(item1)); + } + else + { + prop.SetValue(item2, prop.GetValue(different)); + } + + item1.Should().NotBeSameAs(item2); + item1.Should().NotBe(item2); + } + + [Test] + public void metadata_and_db_fields_should_replicate_album() + { + var item1 = GivenAlbum(); + var item2 = GivenAlbum(); + + item1.Should().NotBe(item2); + + item1.UseMetadataFrom(item2); + item1.UseDbFieldsFrom(item2); + item1.Should().Be(item2); + } + + private Artist GivenArtist() + { + return fixture.Build() + .With(x => x.Metadata, new LazyLoaded(fixture.Create())) + .Without(x => x.QualityProfile) + .Without(x => x.MetadataProfile) + .Without(x => x.Albums) + .Without(x => x.Name) + .Without(x => x.ForeignArtistId) + .Create(); + } + + [Test] + public void two_equivalent_artists_should_be_equal() + { + var item1 = GivenArtist(); + var item2 = item1.JsonClone(); + + item1.Should().NotBeSameAs(item2); + item1.Should().Be(item2); + } + + [Test, TestCaseSource(typeof(EqualityPropertySource), "TestCases")] + public void two_different_artists_should_not_be_equal(PropertyInfo prop) + { + var item1 = GivenArtist(); + var item2 = item1.JsonClone(); + var different = GivenArtist(); + + // make item2 different in the property under consideration + if (prop.PropertyType == typeof(bool)) + { + prop.SetValue(item2, !(bool)prop.GetValue(item1)); + } + else + { + prop.SetValue(item2, prop.GetValue(different)); + } + + item1.Should().NotBeSameAs(item2); + item1.Should().NotBe(item2); + } + + [Test] + public void metadata_and_db_fields_should_replicate_artist() + { + var item1 = GivenArtist(); + var item2 = GivenArtist(); + + item1.Should().NotBe(item2); + + item1.UseMetadataFrom(item2); + item1.UseDbFieldsFrom(item2); + item1.Should().Be(item2); + } + } +} diff --git a/src/NzbDrone.Core.Test/MusicTests/MoveArtistServiceFixture.cs b/src/NzbDrone.Core.Test/MusicTests/MoveArtistServiceFixture.cs new file mode 100644 index 000000000..e13b2f11b --- /dev/null +++ b/src/NzbDrone.Core.Test/MusicTests/MoveArtistServiceFixture.cs @@ -0,0 +1,143 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using FizzWare.NBuilder; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Disk; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; +using NzbDrone.Core.Music.Commands; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.MusicTests +{ + [TestFixture] + public class MoveArtistServiceFixture : CoreTest + { + private Artist _artist; + private MoveArtistCommand _command; + private BulkMoveArtistCommand _bulkCommand; + + [SetUp] + public void Setup() + { + _artist = Builder + .CreateNew() + .Build(); + + _command = new MoveArtistCommand + { + ArtistId = 1, + SourcePath = @"C:\Test\Music\Artist".AsOsAgnostic(), + DestinationPath = @"C:\Test\Music2\Artist".AsOsAgnostic() + }; + + _bulkCommand = new BulkMoveArtistCommand + { + Artist = new List + { + new BulkMoveArtist + { + ArtistId = 1, + SourcePath = @"C:\Test\Music\Artist".AsOsAgnostic() + } + }, + DestinationRootFolder = @"C:\Test\Music2".AsOsAgnostic() + }; + + Mocker.GetMock() + .Setup(s => s.GetArtist(It.IsAny())) + .Returns(_artist); + + Mocker.GetMock() + .Setup(s => s.FolderExists(It.IsAny())) + .Returns(true); + } + + private void GivenFailedMove() + { + Mocker.GetMock() + .Setup(s => s.TransferFolder(It.IsAny(), It.IsAny(), TransferMode.Move, true)) + .Throws(); + } + + [Test] + public void should_log_error_when_move_throws_an_exception() + { + GivenFailedMove(); + + Subject.Execute(_command); + + ExceptionVerification.ExpectedErrors(1); + } + + [Test] + public void should_revert_artist_path_on_error() + { + GivenFailedMove(); + + Subject.Execute(_command); + + ExceptionVerification.ExpectedErrors(1); + + Mocker.GetMock() + .Verify(v => v.UpdateArtist(It.IsAny()), Times.Once()); + } + + [Test] + public void should_use_destination_path() + { + + Subject.Execute(_command); + + Mocker.GetMock() + .Verify( + v => v.TransferFolder(_command.SourcePath, _command.DestinationPath, TransferMode.Move, + It.IsAny()), Times.Once()); + + Mocker.GetMock() + .Verify(v => v.GetArtistFolder(It.IsAny(), null), Times.Never()); + } + + [Test] + public void should_build_new_path_when_root_folder_is_provided() + { + var artistFolder = "Artist"; + var expectedPath = Path.Combine(_bulkCommand.DestinationRootFolder, artistFolder); + + + Mocker.GetMock() + .Setup(s => s.GetArtistFolder(It.IsAny(), null)) + .Returns(artistFolder); + + Subject.Execute(_bulkCommand); + + Mocker.GetMock() + .Verify( + v => v.TransferFolder(_bulkCommand.Artist.First().SourcePath, expectedPath, TransferMode.Move, + It.IsAny()), Times.Once()); + } + + [Test] + public void should_skip_artist_folder_if_it_does_not_exist() + { + Mocker.GetMock() + .Setup(s => s.FolderExists(It.IsAny())) + .Returns(false); + + + Subject.Execute(_command); + + Mocker.GetMock() + .Verify( + v => v.TransferFolder(_command.SourcePath, _command.DestinationPath, TransferMode.Move, + It.IsAny()), Times.Never()); + + Mocker.GetMock() + .Verify(v => v.GetArtistFolder(It.IsAny(), null), Times.Never()); + + } + } +} diff --git a/src/NzbDrone.Core.Test/MusicTests/RefreshAlbumReleaseServiceFixture.cs b/src/NzbDrone.Core.Test/MusicTests/RefreshAlbumReleaseServiceFixture.cs new file mode 100644 index 000000000..9dfaa7af0 --- /dev/null +++ b/src/NzbDrone.Core.Test/MusicTests/RefreshAlbumReleaseServiceFixture.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.MetadataSource; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; +using NzbDrone.Test.Common; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.History; + +namespace NzbDrone.Core.Test.MusicTests +{ + [TestFixture] + public class RefreshAlbumReleaseServiceFixture : CoreTest + { + private AlbumRelease _release; + private List _tracks; + private ArtistMetadata _metadata; + + [SetUp] + public void Setup() + { + + _release = Builder + .CreateNew() + .With(s => s.Media = new List { new Medium { Number = 1 } }) + .With(s => s.ForeignReleaseId = "xxx-xxx-xxx-xxx") + .With(s => s.Monitored = true) + .With(s => s.TrackCount = 10) + .Build(); + + _metadata = Builder.CreateNew().Build(); + + _tracks = Builder + .CreateListOfSize(10) + .All() + .With(x => x.AlbumReleaseId = _release.Id) + .With(x => x.ArtistMetadata = _metadata) + .With(x => x.ArtistMetadataId = _metadata.Id) + .BuildList(); + + Mocker.GetMock() + .Setup(s => s.GetTracksForRefresh(_release.Id, It.IsAny>())) + .Returns(_tracks); + + } + + [Test] + public void should_update_if_musicbrainz_id_changed_and_no_clash() + { + var newInfo = _release.JsonClone(); + newInfo.ForeignReleaseId = _release.ForeignReleaseId + 1; + newInfo.OldForeignReleaseIds = new List { _release.ForeignReleaseId }; + newInfo.Tracks = _tracks; + + Subject.RefreshEntityInfo(_release, new List { newInfo }, false, false); + + Mocker.GetMock() + .Verify(v => v.UpdateMany(It.Is>(s => s.First().ForeignReleaseId == newInfo.ForeignReleaseId))); + } + + [Test] + public void should_merge_if_musicbrainz_id_changed_and_new_already_exists() + { + var existing = _release; + + var clash = existing.JsonClone(); + clash.Id = 100; + clash.ForeignReleaseId = clash.ForeignReleaseId + 1; + + clash.Tracks = Builder.CreateListOfSize(10) + .All() + .With(x => x.AlbumReleaseId = clash.Id) + .With(x => x.ArtistMetadata = _metadata) + .With(x => x.ArtistMetadataId = _metadata.Id) + .BuildList(); + + Mocker.GetMock() + .Setup(x => x.GetReleaseByForeignReleaseId(clash.ForeignReleaseId, false)) + .Returns(clash); + + Mocker.GetMock() + .Setup(x => x.GetTracksForRefresh(It.IsAny(), It.IsAny>())) + .Returns(_tracks); + + var newInfo = existing.JsonClone(); + newInfo.ForeignReleaseId = _release.ForeignReleaseId + 1; + newInfo.OldForeignReleaseIds = new List { _release.ForeignReleaseId }; + newInfo.Tracks = _tracks; + + Subject.RefreshEntityInfo(new List { clash, existing }, new List { newInfo }, false, false); + + // check old album is deleted + Mocker.GetMock() + .Verify(v => v.DeleteMany(It.Is>(x => x.First().ForeignReleaseId == existing.ForeignReleaseId))); + + // check that clash gets updated + Mocker.GetMock() + .Verify(v => v.UpdateMany(It.Is>(s => s.First().ForeignReleaseId == newInfo.ForeignReleaseId))); + + } + + [Test] + public void child_merge_targets_should_not_be_null_if_target_is_new() + { + var oldTrack = Builder + .CreateNew() + .With(x => x.AlbumReleaseId = _release.Id) + .With(x => x.ArtistMetadata = _metadata) + .With(x => x.ArtistMetadataId = _metadata.Id) + .Build(); + _release.Tracks = new List { oldTrack }; + + var newInfo = _release.JsonClone(); + var newTrack = oldTrack.JsonClone(); + newTrack.ArtistMetadata = _metadata; + newTrack.ArtistMetadataId = _metadata.Id; + newTrack.ForeignTrackId = "new id"; + newTrack.OldForeignTrackIds = new List { oldTrack.ForeignTrackId }; + newInfo.Tracks = new List { newTrack }; + + Mocker.GetMock() + .Setup(s => s.GetTracksForRefresh(_release.Id, It.IsAny>())) + .Returns(new List { oldTrack }); + + Subject.RefreshEntityInfo(_release, new List { newInfo }, false, false); + + Mocker.GetMock() + .Verify(v => v.RefreshTrackInfo(It.IsAny>(), + It.IsAny>(), + It.Is>>(x => x.All(y => y.Item2 != null)), + It.IsAny>(), + It.IsAny>(), + It.IsAny>(), + It.IsAny())); + + Mocker.GetMock() + .Verify(v => v.UpdateMany(It.Is>(s => s.First().ForeignReleaseId == newInfo.ForeignReleaseId))); + + } + } +} diff --git a/src/NzbDrone.Core.Test/MusicTests/RefreshAlbumServiceFixture.cs b/src/NzbDrone.Core.Test/MusicTests/RefreshAlbumServiceFixture.cs new file mode 100644 index 000000000..d1a44e647 --- /dev/null +++ b/src/NzbDrone.Core.Test/MusicTests/RefreshAlbumServiceFixture.cs @@ -0,0 +1,396 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.MetadataSource; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; +using NzbDrone.Test.Common; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.History; + +namespace NzbDrone.Core.Test.MusicTests +{ + [TestFixture] + public class RefreshAlbumServiceFixture : CoreTest + { + private Artist _artist; + private List _albums; + private List _releases; + private readonly string _fakeArtistForeignId = "xxx-xxx-xxx"; + private readonly List _fakeArtists = new List { new ArtistMetadata() }; + + [SetUp] + public void Setup() + { + + var release = Builder + .CreateNew() + .With(s => s.Media = new List { new Medium { Number = 1 } }) + .With(s => s.ForeignReleaseId = "xxx-xxx-xxx-xxx") + .With(s => s.Monitored = true) + .With(s => s.TrackCount = 10) + .Build(); + + _releases = new List { release }; + + var album1 = Builder.CreateNew() + .With(x => x.ArtistMetadata = Builder.CreateNew().Build()) + .With(s => s.Id = 1234) + .With(s => s.ForeignAlbumId = "1") + .With(s => s.AlbumReleases = _releases) + .Build(); + + _albums = new List{ album1 }; + + _artist = Builder.CreateNew() + .With(s => s.Albums = _albums) + .Build(); + + Mocker.GetMock() + .Setup(s => s.GetArtist(_artist.Id)) + .Returns(_artist); + + Mocker.GetMock() + .Setup(s => s.GetReleasesForRefresh(album1.Id, It.IsAny>())) + .Returns(new List { release }); + + Mocker.GetMock() + .Setup(s => s.UpsertMany(It.IsAny >())) + .Returns(true); + + Mocker.GetMock() + .Setup(s => s.GetAlbumInfo(It.IsAny())) + .Callback(() => { throw new AlbumNotFoundException(album1.ForeignAlbumId); }); + + Mocker.GetMock() + .Setup(s => s.ShouldRefresh(It.IsAny())) + .Returns(true); + + Mocker.GetMock() + .Setup(x => x.GetFilesByAlbum(It.IsAny())) + .Returns(new List()); + + Mocker.GetMock() + .Setup(x => x.GetFilesByRelease(It.IsAny())) + .Returns(new List()); + + Mocker.GetMock() + .Setup(x => x.GetByAlbum(It.IsAny(), It.IsAny())) + .Returns(new List()); + } + + private void GivenNewAlbumInfo(Album album) + { + Mocker.GetMock() + .Setup(s => s.GetAlbumInfo(_albums.First().ForeignAlbumId)) + .Returns(new Tuple>(_fakeArtistForeignId, album, _fakeArtists)); + } + + [Test] + public void should_update_if_musicbrainz_id_changed_and_no_clash() + { + var newAlbumInfo = _albums.First().JsonClone(); + newAlbumInfo.ArtistMetadata = _albums.First().ArtistMetadata.Value.JsonClone(); + newAlbumInfo.ForeignAlbumId = _albums.First().ForeignAlbumId + 1; + newAlbumInfo.AlbumReleases = _releases; + + GivenNewAlbumInfo(newAlbumInfo); + + Subject.RefreshAlbumInfo(_albums, null, false, false); + + Mocker.GetMock() + .Verify(v => v.UpdateMany(It.Is>(s => s.First().ForeignAlbumId == newAlbumInfo.ForeignAlbumId))); + } + + [Test] + public void should_merge_if_musicbrainz_id_changed_and_new_already_exists() + { + var existing = _albums.First(); + + var clash = existing.JsonClone(); + clash.Id = 100; + clash.ArtistMetadata = existing.ArtistMetadata.Value.JsonClone(); + clash.ForeignAlbumId = clash.ForeignAlbumId + 1; + + clash.AlbumReleases = Builder.CreateListOfSize(10) + .All().With(x => x.AlbumId = clash.Id) + .BuildList(); + + Mocker.GetMock() + .Setup(x => x.FindById(clash.ForeignAlbumId)) + .Returns(clash); + + Mocker.GetMock() + .Setup(x => x.GetReleasesByAlbum(_albums.First().Id)) + .Returns(_releases); + + Mocker.GetMock() + .Setup(x => x.GetReleasesByAlbum(clash.Id)) + .Returns(new List()); + + Mocker.GetMock() + .Setup(x => x.GetReleasesForRefresh(It.IsAny(), It.IsAny>())) + .Returns(_releases); + + var newAlbumInfo = existing.JsonClone(); + newAlbumInfo.ArtistMetadata = existing.ArtistMetadata.Value.JsonClone(); + newAlbumInfo.ForeignAlbumId = _albums.First().ForeignAlbumId + 1; + newAlbumInfo.AlbumReleases = _releases; + + GivenNewAlbumInfo(newAlbumInfo); + + Subject.RefreshAlbumInfo(_albums, null, false, false); + + // check releases moved to clashing album + Mocker.GetMock() + .Verify(v => v.UpdateMany(It.Is>(x => x.All(y => y.AlbumId == clash.Id) && x.Count == _releases.Count))); + + // check old album is deleted + Mocker.GetMock() + .Verify(v => v.DeleteMany(It.Is>(x => x.First().ForeignAlbumId == existing.ForeignAlbumId))); + + // check that clash gets updated + Mocker.GetMock() + .Verify(v => v.UpdateMany(It.Is>(s => s.First().ForeignAlbumId == newAlbumInfo.ForeignAlbumId))); + + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + public void should_remove_album_with_no_valid_releases() + { + var album = _albums.First(); + album.AlbumReleases = new List(); + + GivenNewAlbumInfo(album); + + Subject.RefreshAlbumInfo(album, null, false); + + Mocker.GetMock() + .Verify(x => x.DeleteAlbum(album.Id, true), + Times.Once()); + + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + public void should_not_add_duplicate_releases() + { + var newAlbum = Builder.CreateNew() + .With(x => x.ArtistMetadata = Builder.CreateNew().Build()) + .Build(); + // this is required because RefreshAlbumInfo will edit the album passed in + var albumCopy = Builder.CreateNew() + .With(x => x.ArtistMetadata = Builder.CreateNew().Build()) + .Build(); + + var releases = Builder.CreateListOfSize(10) + .All() + .With(x => x.AlbumId = newAlbum.Id) + .With(x => x.Monitored = true) + .TheFirst(4) + .With(x => x.ForeignReleaseId = "DuplicateId1") + .TheLast(1) + .With(x => x.ForeignReleaseId = "DuplicateId2") + .Build() as List; + + newAlbum.AlbumReleases = releases; + albumCopy.AlbumReleases = releases; + + var existingReleases = Builder.CreateListOfSize(1) + .TheFirst(1) + .With(x => x.ForeignReleaseId = "DuplicateId2") + .With(x => x.Monitored = true) + .Build() as List; + + Mocker.GetMock() + .Setup(x => x.GetReleasesForRefresh(It.IsAny(), It.IsAny>())) + .Returns(existingReleases); + + Mocker.GetMock() + .Setup(x => x.GetAlbumInfo(It.IsAny())) + .Returns(Tuple.Create("dummy string", albumCopy, new List())); + + Subject.RefreshAlbumInfo(newAlbum, null, false); + + Mocker.GetMock() + .Verify(x => x.RefreshEntityInfo(It.Is>(l => l.Count == 7 && l.Count(y => y.Monitored) == 1), + It.IsAny>(), + It.IsAny(), + It.IsAny())); + } + + [TestCase(true, true, 1)] + [TestCase(true, false, 0)] + [TestCase(false, true, 1)] + [TestCase(false, false, 0)] + public void should_only_leave_one_release_monitored(bool skyhookMonitored, bool existingMonitored, int expectedUpdates) + { + var newAlbum = Builder.CreateNew() + .With(x => x.ArtistMetadata = Builder.CreateNew().Build()) + .Build(); + // this is required because RefreshAlbumInfo will edit the album passed in + var albumCopy = Builder.CreateNew() + .With(x => x.ArtistMetadata = Builder.CreateNew().Build()) + .Build(); + + var releases = Builder.CreateListOfSize(10) + .All() + .With(x => x.AlbumId = newAlbum.Id) + .With(x => x.Monitored = skyhookMonitored) + .TheFirst(1) + .With(x => x.ForeignReleaseId = "ExistingId1") + .TheNext(1) + .With(x => x.ForeignReleaseId = "ExistingId2") + .Build() as List; + + newAlbum.AlbumReleases = releases; + albumCopy.AlbumReleases = releases; + + var existingReleases = Builder.CreateListOfSize(2) + .All() + .With(x => x.Monitored = existingMonitored) + .TheFirst(1) + .With(x => x.ForeignReleaseId = "ExistingId1") + .TheNext(1) + .With(x => x.ForeignReleaseId = "ExistingId2") + .Build() as List; + + Mocker.GetMock() + .Setup(x => x.GetReleasesForRefresh(It.IsAny(), It.IsAny>())) + .Returns(existingReleases); + + Mocker.GetMock() + .Setup(x => x.GetAlbumInfo(It.IsAny())) + .Returns(Tuple.Create("dummy string", albumCopy, new List())); + + Subject.RefreshAlbumInfo(newAlbum, null, false); + + Mocker.GetMock() + .Verify(x => x.RefreshEntityInfo(It.Is>(l => l.Count == 10 && l.Count(y => y.Monitored) == 1), + It.IsAny>(), + It.IsAny(), + It.IsAny())); + + } + + [Test] + public void refreshing_album_should_not_change_monitored_release_if_monitored_release_not_deleted() + { + var newAlbum = Builder.CreateNew() + .With(x => x.ArtistMetadata = Builder.CreateNew().Build()) + .Build(); + // this is required because RefreshAlbumInfo will edit the album passed in + var albumCopy = Builder.CreateNew() + .With(x => x.ArtistMetadata = Builder.CreateNew().Build()) + .Build(); + + // only ExistingId1 is monitored from dummy skyhook + var releases = Builder.CreateListOfSize(10) + .All() + .With(x => x.AlbumId = newAlbum.Id) + .With(x => x.Monitored = false) + .TheFirst(1) + .With(x => x.ForeignReleaseId = "ExistingId1") + .With(x => x.Monitored = true) + .TheNext(1) + .With(x => x.ForeignReleaseId = "ExistingId2") + .Build() as List; + + newAlbum.AlbumReleases = releases; + albumCopy.AlbumReleases = releases; + + // ExistingId2 is monitored in DB + var existingReleases = Builder.CreateListOfSize(2) + .All() + .With(x => x.Monitored = false) + .TheFirst(1) + .With(x => x.ForeignReleaseId = "ExistingId1") + .TheNext(1) + .With(x => x.ForeignReleaseId = "ExistingId2") + .With(x => x.Monitored = true) + .Build() as List; + + Mocker.GetMock() + .Setup(x => x.GetReleasesForRefresh(It.IsAny(), It.IsAny>())) + .Returns(existingReleases); + + Mocker.GetMock() + .Setup(x => x.GetAlbumInfo(It.IsAny())) + .Returns(Tuple.Create("dummy string", albumCopy, new List())); + + Subject.RefreshAlbumInfo(newAlbum, null, false); + + Mocker.GetMock() + .Verify(x => x.RefreshEntityInfo(It.Is>( + l => l.Count == 10 && + l.Count(y => y.Monitored) == 1 && + l.Single(y => y.Monitored).ForeignReleaseId == "ExistingId2"), + It.IsAny>(), + It.IsAny(), + It.IsAny())); + } + + [Test] + public void refreshing_album_should_change_monitored_release_if_monitored_release_deleted() + { + var newAlbum = Builder.CreateNew() + .With(x => x.ArtistMetadata = Builder.CreateNew().Build()) + .Build(); + // this is required because RefreshAlbumInfo will edit the album passed in + var albumCopy = Builder.CreateNew() + .With(x => x.ArtistMetadata = Builder.CreateNew().Build()) + .Build(); + + // Only existingId1 monitored in skyhook. ExistingId2 is missing + var releases = Builder.CreateListOfSize(10) + .All() + .With(x => x.AlbumId = newAlbum.Id) + .With(x => x.Monitored = false) + .TheFirst(1) + .With(x => x.ForeignReleaseId = "ExistingId1") + .With(x => x.Monitored = true) + .TheNext(1) + .With(x => x.ForeignReleaseId = "NotExistingId2") + .Build() as List; + + newAlbum.AlbumReleases = releases; + albumCopy.AlbumReleases = releases; + + // ExistingId2 is monitored but will be deleted + var existingReleases = Builder.CreateListOfSize(2) + .All() + .With(x => x.Monitored = false) + .TheFirst(1) + .With(x => x.ForeignReleaseId = "ExistingId1") + .TheNext(1) + .With(x => x.ForeignReleaseId = "ExistingId2") + .With(x => x.Monitored = true) + .Build() as List; + + Mocker.GetMock() + .Setup(x => x.GetReleasesForRefresh(It.IsAny(), It.IsAny>())) + .Returns(existingReleases); + + Mocker.GetMock() + .Setup(x => x.GetAlbumInfo(It.IsAny())) + .Returns(Tuple.Create("dummy string", albumCopy, new List())); + + Subject.RefreshAlbumInfo(newAlbum, null, false); + + Mocker.GetMock() + .Verify(x => x.RefreshEntityInfo(It.Is>( + l => l.Count == 11 && + l.Count(y => y.Monitored) == 1 && + l.Single(y => y.Monitored).ForeignReleaseId != "ExistingId2"), + It.IsAny>(), + It.IsAny(), + It.IsAny())); + } + } +} diff --git a/src/NzbDrone.Core.Test/MusicTests/RefreshArtistServiceFixture.cs b/src/NzbDrone.Core.Test/MusicTests/RefreshArtistServiceFixture.cs new file mode 100644 index 000000000..be648afb6 --- /dev/null +++ b/src/NzbDrone.Core.Test/MusicTests/RefreshArtistServiceFixture.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.MetadataSource; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; +using NzbDrone.Core.Music.Commands; +using NzbDrone.Test.Common; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.History; +using NzbDrone.Core.Music.Events; + +namespace NzbDrone.Core.Test.MusicTests +{ + [TestFixture] + public class RefreshArtistServiceFixture : CoreTest + { + private Artist _artist; + private Album _album1; + private Album _album2; + private List _albums; + + [SetUp] + public void Setup() + { + _album1 = Builder.CreateNew() + .With(s => s.ForeignAlbumId = "1") + .Build(); + + _album2 = Builder.CreateNew() + .With(s => s.ForeignAlbumId = "2") + .Build(); + + _albums = new List {_album1, _album2}; + + var metadata = Builder.CreateNew().Build(); + + _artist = Builder.CreateNew() + .With(a => a.Metadata = metadata) + .Build(); + + Mocker.GetMock(MockBehavior.Strict) + .Setup(s => s.GetArtist(_artist.Id)) + .Returns(_artist); + + Mocker.GetMock(MockBehavior.Strict) + .Setup(s => s.InsertMany(It.IsAny>())); + + Mocker.GetMock() + .Setup(s => s.GetArtistInfo(It.IsAny(), It.IsAny())) + .Callback(() => { throw new ArtistNotFoundException(_artist.ForeignArtistId); }); + + Mocker.GetMock() + .Setup(x => x.GetFilesByArtist(It.IsAny())) + .Returns(new List()); + + Mocker.GetMock() + .Setup(x => x.GetByArtist(It.IsAny(), It.IsAny())) + .Returns(new List()); + } + + private void GivenNewArtistInfo(Artist artist) + { + Mocker.GetMock() + .Setup(s => s.GetArtistInfo(_artist.ForeignArtistId, _artist.MetadataProfileId)) + .Returns(artist); + } + + private void GivenArtistFiles() + { + Mocker.GetMock() + .Setup(x => x.GetFilesByArtist(It.IsAny())) + .Returns(Builder.CreateListOfSize(1).BuildList()); + } + + private void GivenAlbumsForRefresh() + { + Mocker.GetMock(MockBehavior.Strict) + .Setup(s => s.GetAlbumsForRefresh(It.IsAny(), It.IsAny>())) + .Returns(new List()); + } + + private void AllowArtistUpdate() + { + Mocker.GetMock(MockBehavior.Strict) + .Setup(x => x.UpdateArtist(It.IsAny())) + .Returns((Artist a) => a); + } + + [Test] + public void should_not_publish_artist_updated_event_if_metadata_not_updated() + { + var newArtistInfo = _artist.JsonClone(); + newArtistInfo.Metadata = _artist.Metadata.Value.JsonClone(); + newArtistInfo.Albums = _albums; + + GivenNewArtistInfo(newArtistInfo); + GivenAlbumsForRefresh(); + AllowArtistUpdate(); + + Subject.Execute(new RefreshArtistCommand(_artist.Id)); + + VerifyEventNotPublished(); + VerifyEventPublished(); + } + + [Test] + public void should_publish_artist_updated_event_if_metadata_updated() + { + var newArtistInfo = _artist.JsonClone(); + newArtistInfo.Metadata = _artist.Metadata.Value.JsonClone(); + newArtistInfo.Metadata.Value.Images = new List { + new MediaCover.MediaCover(MediaCover.MediaCoverTypes.Logo, "dummy") + }; + newArtistInfo.Albums = _albums; + + GivenNewArtistInfo(newArtistInfo); + GivenAlbumsForRefresh(); + AllowArtistUpdate(); + + Subject.Execute(new RefreshArtistCommand(_artist.Id)); + + VerifyEventPublished(); + VerifyEventPublished(); + } + + [Test] + public void should_log_error_and_delete_if_musicbrainz_id_not_found_and_artist_has_no_files() + { + Mocker.GetMock() + .Setup(x => x.DeleteArtist(It.IsAny(), It.IsAny(), It.IsAny())); + + Subject.Execute(new RefreshArtistCommand(_artist.Id)); + + Mocker.GetMock() + .Verify(v => v.UpdateArtist(It.IsAny()), Times.Never()); + + Mocker.GetMock() + .Verify(v => v.DeleteArtist(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); + + ExceptionVerification.ExpectedErrors(1); + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + public void should_log_error_but_not_delete_if_musicbrainz_id_not_found_and_artist_has_files() + { + GivenArtistFiles(); + GivenAlbumsForRefresh(); + + Subject.Execute(new RefreshArtistCommand(_artist.Id)); + + Mocker.GetMock() + .Verify(v => v.UpdateArtist(It.IsAny()), Times.Never()); + + Mocker.GetMock() + .Verify(v => v.DeleteArtist(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); + + ExceptionVerification.ExpectedErrors(2); + } + + [Test] + public void should_update_if_musicbrainz_id_changed_and_no_clash() + { + var newArtistInfo = _artist.JsonClone(); + newArtistInfo.Metadata = _artist.Metadata.Value.JsonClone(); + newArtistInfo.Albums = _albums; + newArtistInfo.ForeignArtistId = _artist.ForeignArtistId + 1; + newArtistInfo.Metadata.Value.Id = 100; + + GivenNewArtistInfo(newArtistInfo); + + var seq = new MockSequence(); + + Mocker.GetMock(MockBehavior.Strict) + .Setup(x => x.FindById(newArtistInfo.ForeignArtistId)) + .Returns(default(Artist)); + + // Make sure that the artist is updated before we refresh the albums + Mocker.GetMock(MockBehavior.Strict) + .InSequence(seq) + .Setup(x => x.UpdateArtist(It.IsAny())) + .Returns((Artist a) => a); + + Mocker.GetMock(MockBehavior.Strict) + .InSequence(seq) + .Setup(x => x.GetAlbumsForRefresh(It.IsAny(), It.IsAny>())) + .Returns(new List()); + + // Update called twice for a move/merge + Mocker.GetMock(MockBehavior.Strict) + .InSequence(seq) + .Setup(x => x.UpdateArtist(It.IsAny())) + .Returns((Artist a) => a); + + Subject.Execute(new RefreshArtistCommand(_artist.Id)); + + Mocker.GetMock() + .Verify(v => v.UpdateArtist(It.Is(s => s.ArtistMetadataId == 100 && s.ForeignArtistId == newArtistInfo.ForeignArtistId)), + Times.Exactly(2)); + } + + [Test] + public void should_merge_if_musicbrainz_id_changed_and_new_id_already_exists() + { + var existing = _artist; + + var clash = _artist.JsonClone(); + clash.Id = 100; + clash.Metadata = existing.Metadata.Value.JsonClone(); + clash.Metadata.Value.Id = 101; + clash.Metadata.Value.ForeignArtistId = clash.Metadata.Value.ForeignArtistId + 1; + + Mocker.GetMock(MockBehavior.Strict) + .Setup(x => x.FindById(clash.Metadata.Value.ForeignArtistId)) + .Returns(clash); + + var newArtistInfo = clash.JsonClone(); + newArtistInfo.Metadata = clash.Metadata.Value.JsonClone(); + newArtistInfo.Albums = _albums.JsonClone(); + newArtistInfo.Albums.Value.ForEach(x => x.Id = 0); + + GivenNewArtistInfo(newArtistInfo); + + var seq = new MockSequence(); + + // Make sure that the artist is updated before we refresh the albums + Mocker.GetMock(MockBehavior.Strict) + .InSequence(seq) + .Setup(x => x.GetAlbumsByArtist(existing.Id)) + .Returns(_albums); + + Mocker.GetMock(MockBehavior.Strict) + .InSequence(seq) + .Setup(x => x.UpdateMany(It.IsAny>())); + + Mocker.GetMock(MockBehavior.Strict) + .InSequence(seq) + .Setup(x => x.DeleteArtist(existing.Id, It.IsAny(), false)); + + Mocker.GetMock(MockBehavior.Strict) + .InSequence(seq) + .Setup(x => x.UpdateArtist(It.Is(a => a.Id == clash.Id))) + .Returns((Artist a) => a); + + Mocker.GetMock(MockBehavior.Strict) + .InSequence(seq) + .Setup(x => x.GetAlbumsForRefresh(clash.ArtistMetadataId, It.IsAny>())) + .Returns(_albums); + + // Update called twice for a move/merge + Mocker.GetMock(MockBehavior.Strict) + .InSequence(seq) + .Setup(x => x.UpdateArtist(It.IsAny())) + .Returns((Artist a) => a); + + Subject.Execute(new RefreshArtistCommand(_artist.Id)); + + // the retained artist gets updated + Mocker.GetMock() + .Verify(v => v.UpdateArtist(It.Is(s => s.Id == clash.Id)), Times.Exactly(2)); + + // the old one gets removed + Mocker.GetMock() + .Verify(v => v.DeleteArtist(existing.Id, false, false)); + + Mocker.GetMock() + .Verify(v => v.UpdateMany(It.Is>(x => x.Count == _albums.Count))); + + ExceptionVerification.ExpectedWarns(1); + } + } +} diff --git a/src/NzbDrone.Core.Test/MusicTests/RefreshTrackServiceFixture.cs b/src/NzbDrone.Core.Test/MusicTests/RefreshTrackServiceFixture.cs new file mode 100644 index 000000000..7ff5a21f0 --- /dev/null +++ b/src/NzbDrone.Core.Test/MusicTests/RefreshTrackServiceFixture.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; +using NzbDrone.Core.MediaFiles; + +namespace NzbDrone.Core.Test.MusicTests +{ + [TestFixture] + public class RefreshTrackServiceFixture : CoreTest + { + private AlbumRelease _release; + private List _allTracks; + + [SetUp] + public void Setup() + { + _release = Builder.CreateNew().Build(); + _allTracks = Builder.CreateListOfSize(20) + .All() + .BuildList(); + } + + [Test] + public void updated_track_should_not_have_null_album_release() + { + var add = new List(); + var update = new List(); + var merge = new List>(); + var delete = new List(); + var upToDate = new List(); + + upToDate.AddRange(_allTracks.Take(10)); + + var toUpdate = _allTracks[10].JsonClone(); + toUpdate.Title = "title to update"; + toUpdate.AlbumRelease = _release; + + update.Add(toUpdate); + + Subject.RefreshTrackInfo(add, update, merge, delete, upToDate, _allTracks, false); + + Mocker.GetMock() + .Verify(v => v.SyncTags(It.Is>(x => x.Count == 1 && + x[0].AlbumRelease != null && + x[0].AlbumRelease.IsLoaded == true))); + + } + } +} diff --git a/src/NzbDrone.Core.Test/MusicTests/ShouldRefreshAlbumFixture.cs b/src/NzbDrone.Core.Test/MusicTests/ShouldRefreshAlbumFixture.cs new file mode 100644 index 000000000..8299f0539 --- /dev/null +++ b/src/NzbDrone.Core.Test/MusicTests/ShouldRefreshAlbumFixture.cs @@ -0,0 +1,103 @@ +using System; +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Music; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.MusicTests +{ + [TestFixture] + public class ShouldRefreshAlbumFixture : TestBase + { + private Album _album; + + [SetUp] + public void Setup() + { + _album = Builder.CreateNew() + .With(e=>e.ReleaseDate = DateTime.Today.AddDays(-100)) + .Build(); + } + + private void GivenAlbumLastRefreshedMonthsAgo() + { + _album.LastInfoSync = DateTime.UtcNow.AddDays(-90); + } + + private void GivenAlbumLastRefreshedYesterday() + { + _album.LastInfoSync = DateTime.UtcNow.AddDays(-1); + } + + private void GivenAlbumLastRefreshedRecently() + { + _album.LastInfoSync = DateTime.UtcNow.AddHours(-7); + } + + private void GivenRecentlyReleased() + { + _album.ReleaseDate = DateTime.Today.AddDays(-7); + } + + private void GivenFutureRelease() + { + _album.ReleaseDate = DateTime.Today.AddDays(7); + } + + [Test] + public void should_return_false_if_album_last_refreshed_less_than_12_hours_ago() + { + GivenAlbumLastRefreshedRecently(); + + Subject.ShouldRefresh(_album).Should().BeFalse(); + } + + [Test] + public void should_return_true_if_album_last_refreshed_more_than_30_days_ago() + { + GivenAlbumLastRefreshedMonthsAgo(); + + Subject.ShouldRefresh(_album).Should().BeTrue(); + } + + [Test] + public void should_return_true_if_album_released_in_last_30_days() + { + GivenAlbumLastRefreshedYesterday(); + + GivenRecentlyReleased(); + + Subject.ShouldRefresh(_album).Should().BeTrue(); + } + + [Test] + public void should_return_true_if_album_releases_in_future() + { + GivenAlbumLastRefreshedYesterday(); + + GivenFutureRelease(); + + Subject.ShouldRefresh(_album).Should().BeTrue(); + } + + [Test] + public void should_return_false_when_recently_refreshed_album_released_over_30_days_ago() + { + GivenAlbumLastRefreshedYesterday(); + + Subject.ShouldRefresh(_album).Should().BeFalse(); + } + + [Test] + public void should_return_false_when_recently_refreshed_album_released_in_last_30_days() + { + GivenAlbumLastRefreshedRecently(); + + GivenRecentlyReleased(); + + Subject.ShouldRefresh(_album).Should().BeFalse(); + } + } +} diff --git a/src/NzbDrone.Core.Test/MusicTests/ShouldRefreshArtistFixture.cs b/src/NzbDrone.Core.Test/MusicTests/ShouldRefreshArtistFixture.cs new file mode 100644 index 000000000..fee0024f6 --- /dev/null +++ b/src/NzbDrone.Core.Test/MusicTests/ShouldRefreshArtistFixture.cs @@ -0,0 +1,135 @@ +using System; +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Music; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.MusicTests +{ + [TestFixture] + public class ShouldRefreshArtistFixture : TestBase + { + private Artist _artist; + + [SetUp] + public void Setup() + { + _artist = Builder.CreateNew() + .With(v => v.Metadata.Value.Status == ArtistStatusType.Continuing) + .Build(); + + Mocker.GetMock() + .Setup(s => s.GetAlbumsByArtist(_artist.Id)) + .Returns(Builder.CreateListOfSize(2) + .All() + .With(e => e.ReleaseDate = DateTime.Today.AddDays(-100)) + .Build() + .ToList()); + } + + private void GivenArtistIsEnded() + { + _artist.Metadata.Value.Status = ArtistStatusType.Ended; + } + + private void GivenArtistLastRefreshedMonthsAgo() + { + _artist.LastInfoSync = DateTime.UtcNow.AddDays(-90); + } + + private void GivenArtistLastRefreshedYesterday() + { + _artist.LastInfoSync = DateTime.UtcNow.AddDays(-1); + } + + private void GivenArtistLastRefreshedThreeDaysAgo() + { + _artist.LastInfoSync = DateTime.UtcNow.AddDays(-3); + } + + private void GivenArtistLastRefreshedRecently() + { + _artist.LastInfoSync = DateTime.UtcNow.AddHours(-7); + } + + private void GivenRecentlyAired() + { + Mocker.GetMock() + .Setup(s => s.GetAlbumsByArtist(_artist.Id)) + .Returns(Builder.CreateListOfSize(2) + .TheFirst(1) + .With(e => e.ReleaseDate = DateTime.Today.AddDays(-7)) + .TheLast(1) + .With(e => e.ReleaseDate = DateTime.Today.AddDays(-100)) + .Build() + .ToList()); + } + + [Test] + public void should_return_true_if_running_artist_last_refreshed_more_than_24_hours_ago() + { + GivenArtistLastRefreshedThreeDaysAgo(); + + Subject.ShouldRefresh(_artist).Should().BeTrue(); + } + + [Test] + public void should_return_false_if_running_artist_last_refreshed_less_than_12_hours_ago() + { + GivenArtistLastRefreshedRecently(); + + Subject.ShouldRefresh(_artist).Should().BeFalse(); + } + + [Test] + public void should_return_false_if_ended_artist_last_refreshed_yesterday() + { + GivenArtistIsEnded(); + GivenArtistLastRefreshedYesterday(); + + Subject.ShouldRefresh(_artist).Should().BeFalse(); + } + + [Test] + public void should_return_true_if_artist_last_refreshed_more_than_30_days_ago() + { + GivenArtistIsEnded(); + GivenArtistLastRefreshedMonthsAgo(); + + Subject.ShouldRefresh(_artist).Should().BeTrue(); + } + + [Test] + public void should_return_true_if_album_released_in_last_30_days() + { + GivenArtistIsEnded(); + GivenArtistLastRefreshedYesterday(); + + GivenRecentlyAired(); + + Subject.ShouldRefresh(_artist).Should().BeTrue(); + } + + [Test] + public void should_return_false_when_recently_refreshed_ended_show_has_not_aired_for_30_days() + { + GivenArtistIsEnded(); + GivenArtistLastRefreshedYesterday(); + + Subject.ShouldRefresh(_artist).Should().BeFalse(); + } + + [Test] + public void should_return_false_when_recently_refreshed_ended_show_aired_in_last_30_days() + { + GivenArtistIsEnded(); + GivenArtistLastRefreshedRecently(); + + GivenRecentlyAired(); + + Subject.ShouldRefresh(_artist).Should().BeFalse(); + } + } +} diff --git a/src/NzbDrone.Core.Test/NotificationTests/GrowlProviderTest.cs b/src/NzbDrone.Core.Test/NotificationTests/GrowlProviderTest.cs deleted file mode 100644 index 7e602d357..000000000 --- a/src/NzbDrone.Core.Test/NotificationTests/GrowlProviderTest.cs +++ /dev/null @@ -1,63 +0,0 @@ -using NUnit.Framework; -using NzbDrone.Core.Notifications.Growl; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.NotificationTests -{ - [Explicit] - [TestFixture] - public class GrowlProviderTest : CoreTest - { - [Test] - public void Register_should_add_new_application_to_local_growl_instance() - { - - - - - Mocker.Resolve().Register("localhost", 23053, ""); - - - Mocker.VerifyAllMocks(); - } - - [Test] - public void TestNotification_should_send_a_message_to_local_growl_instance() - { - - - - - Mocker.Resolve().TestNotification("localhost", 23053, ""); - - - Mocker.VerifyAllMocks(); - } - - [Test] - public void OnGrab_should_send_a_message_to_local_growl_instance() - { - - - - - Mocker.Resolve().SendNotification("Episode Grabbed", "Series Title - 1x05 - Episode Title", "GRAB", "localhost", 23053, ""); - - - Mocker.VerifyAllMocks(); - } - - [Test] - public void OnDownload_should_send_a_message_to_local_growl_instance() - { - - - - - Mocker.Resolve().SendNotification("Episode Downloaded", "Series Title - 1x05 - Episode Title", "DOWNLOAD", "localhost", 23053, ""); - - - Mocker.VerifyAllMocks(); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/NotificationTests/NotificationBaseFixture.cs b/src/NzbDrone.Core.Test/NotificationTests/NotificationBaseFixture.cs index 0230fa5e8..a7898e103 100644 --- a/src/NzbDrone.Core.Test/NotificationTests/NotificationBaseFixture.cs +++ b/src/NzbDrone.Core.Test/NotificationTests/NotificationBaseFixture.cs @@ -1,10 +1,10 @@ -using System; +using System; using FluentAssertions; using FluentValidation.Results; using NUnit.Framework; using NzbDrone.Core.Notifications; using NzbDrone.Core.ThingiProvider; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; using NzbDrone.Core.Validation; using NzbDrone.Test.Common; @@ -21,7 +21,7 @@ namespace NzbDrone.Core.Test.NotificationTests } } - class TestNotificationWithOnDownload : NotificationBase + class TestNotificationWithOnReleaseImport : NotificationBase { public override string Name => "TestNotification"; public override string Link => ""; @@ -32,7 +32,7 @@ namespace NzbDrone.Core.Test.NotificationTests throw new NotImplementedException(); } - public override void OnDownload(DownloadMessage downloadMessage) + public override void OnReleaseImport(AlbumDownloadMessage message) { TestLogger.Info("OnDownload was called"); } @@ -55,16 +55,35 @@ namespace NzbDrone.Core.Test.NotificationTests TestLogger.Info("OnGrab was called"); } - public override void OnDownload(DownloadMessage message) + public override void OnReleaseImport(AlbumDownloadMessage message) { - TestLogger.Info("OnDownload was called"); + TestLogger.Info("OnAlbumDownload was called"); } - public override void OnRename(Series series) + public override void OnRename(Artist artist) { TestLogger.Info("OnRename was called"); } + public override void OnHealthIssue(NzbDrone.Core.HealthCheck.HealthCheck artist) + { + TestLogger.Info("OnHealthIssue was called"); + } + + public override void OnDownloadFailure(DownloadFailedMessage message) + { + TestLogger.Info("OnDownloadFailure was called"); + } + + public override void OnImportFailure(AlbumDownloadMessage message) + { + TestLogger.Info("OnImportFailure was called"); + } + + public override void OnTrackRetag(TrackRetagMessage message) + { + TestLogger.Info("OnTrackRetag was called"); + } } class TestNotificationWithNoEvents : NotificationBase @@ -82,11 +101,11 @@ namespace NzbDrone.Core.Test.NotificationTests } [Test] - public void should_support_OnUpgrade_should_link_to_OnDownload() + public void should_support_OnUpgrade_should_link_to_OnReleaseImport() { - var notification = new TestNotificationWithOnDownload(); + var notification = new TestNotificationWithOnReleaseImport(); - notification.SupportsOnDownload.Should().BeTrue(); + notification.SupportsOnReleaseImport.Should().BeTrue(); notification.SupportsOnUpgrade.Should().BeTrue(); notification.SupportsOnGrab.Should().BeFalse(); @@ -99,9 +118,13 @@ namespace NzbDrone.Core.Test.NotificationTests var notification = new TestNotificationWithAllEvents(); notification.SupportsOnGrab.Should().BeTrue(); - notification.SupportsOnDownload.Should().BeTrue(); + notification.SupportsOnReleaseImport.Should().BeTrue(); notification.SupportsOnUpgrade.Should().BeTrue(); notification.SupportsOnRename.Should().BeTrue(); + notification.SupportsOnHealthIssue.Should().BeTrue(); + notification.SupportsOnDownloadFailure.Should().BeTrue(); + notification.SupportsOnImportFailure.Should().BeTrue(); + notification.SupportsOnTrackRetag.Should().BeTrue(); } @@ -111,9 +134,13 @@ namespace NzbDrone.Core.Test.NotificationTests var notification = new TestNotificationWithNoEvents(); notification.SupportsOnGrab.Should().BeFalse(); - notification.SupportsOnDownload.Should().BeFalse(); + notification.SupportsOnReleaseImport.Should().BeFalse(); notification.SupportsOnUpgrade.Should().BeFalse(); notification.SupportsOnRename.Should().BeFalse(); + notification.SupportsOnHealthIssue.Should().BeFalse(); + notification.SupportsOnDownloadFailure.Should().BeFalse(); + notification.SupportsOnImportFailure.Should().BeFalse(); + notification.SupportsOnTrackRetag.Should().BeFalse(); } } diff --git a/src/NzbDrone.Core.Test/NotificationTests/PlexClientServiceTest.cs b/src/NzbDrone.Core.Test/NotificationTests/PlexClientServiceTest.cs index f9b826703..35f7206a0 100644 --- a/src/NzbDrone.Core.Test/NotificationTests/PlexClientServiceTest.cs +++ b/src/NzbDrone.Core.Test/NotificationTests/PlexClientServiceTest.cs @@ -1,7 +1,7 @@ -using Moq; +using Moq; using NUnit.Framework; using NzbDrone.Common.Http; -using NzbDrone.Core.Notifications.Plex; +using NzbDrone.Core.Notifications.Plex.HomeTheater; using NzbDrone.Core.Test.Framework; namespace NzbDrone.Core.Test.NotificationTests @@ -69,4 +69,4 @@ namespace NzbDrone.Core.Test.NotificationTests fakeHttp.Verify(v => v.DownloadString(expectedUrl, "plex", "plex"), Times.Once()); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core.Test/NotificationTests/SynologyIndexerFixture.cs b/src/NzbDrone.Core.Test/NotificationTests/SynologyIndexerFixture.cs index 724bfb0d7..08df42c62 100644 --- a/src/NzbDrone.Core.Test/NotificationTests/SynologyIndexerFixture.cs +++ b/src/NzbDrone.Core.Test/NotificationTests/SynologyIndexerFixture.cs @@ -1,47 +1,53 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Moq; using NUnit.Framework; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Notifications; using NzbDrone.Core.Notifications.Synology; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; using NzbDrone.Test.Common; +using System.IO; namespace NzbDrone.Core.Test.NotificationTests { [TestFixture] public class SynologyIndexerFixture : CoreTest { - private Series _series; - private DownloadMessage _upgrade; + private Artist _artist; + private AlbumDownloadMessage _upgrade; + private string rootPath = @"C:\Test\".AsOsAgnostic(); [SetUp] public void SetUp() { - _series = new Series() + _artist = new Artist() { - Path = @"C:\Test\".AsOsAgnostic() + Path = rootPath, }; - _upgrade = new DownloadMessage() + _upgrade = new AlbumDownloadMessage() { - Series = _series, + Artist = _artist, - EpisodeFile = new EpisodeFile + TrackFiles = new List { - RelativePath = "file1.S01E01E02.mkv" + new TrackFile + { + Path = Path.Combine(rootPath, "file1.S01E01E02.mkv") + } + }, - OldFiles = new List + OldFiles = new List { - new EpisodeFile + new TrackFile { - RelativePath = "file1.S01E01.mkv" + Path = Path.Combine(rootPath, "file1.S01E01.mkv") }, - new EpisodeFile + new TrackFile { - RelativePath = "file1.S01E02.mkv" + Path = Path.Combine(rootPath, "file1.S01E02.mkv") } } }; @@ -60,16 +66,16 @@ namespace NzbDrone.Core.Test.NotificationTests { (Subject.Definition.Settings as SynologyIndexerSettings).UpdateLibrary = false; - Subject.OnRename(_series); + Subject.OnRename(_artist); Mocker.GetMock() - .Verify(v => v.UpdateFolder(_series.Path), Times.Never()); + .Verify(v => v.UpdateFolder(_artist.Path), Times.Never()); } [Test] public void should_remove_old_episodes_on_upgrade() { - Subject.OnDownload(_upgrade); + Subject.OnReleaseImport(_upgrade); Mocker.GetMock() .Verify(v => v.DeleteFile(@"C:\Test\file1.S01E01.mkv".AsOsAgnostic()), Times.Once()); @@ -81,7 +87,7 @@ namespace NzbDrone.Core.Test.NotificationTests [Test] public void should_add_new_episode_on_upgrade() { - Subject.OnDownload(_upgrade); + Subject.OnReleaseImport(_upgrade); Mocker.GetMock() .Verify(v => v.AddFile(@"C:\Test\file1.S01E01E02.mkv".AsOsAgnostic()), Times.Once()); @@ -90,7 +96,7 @@ namespace NzbDrone.Core.Test.NotificationTests [Test] public void should_update_entire_series_folder_on_rename() { - Subject.OnRename(_series); + Subject.OnRename(_artist); Mocker.GetMock() .Verify(v => v.UpdateFolder(@"C:\Test\".AsOsAgnostic()), Times.Once()); diff --git a/src/NzbDrone.Core.Test/NotificationTests/Xbmc/Http/ActivePlayersFixture.cs b/src/NzbDrone.Core.Test/NotificationTests/Xbmc/Http/ActivePlayersFixture.cs index bf5f4de2b..1cb41de43 100644 --- a/src/NzbDrone.Core.Test/NotificationTests/Xbmc/Http/ActivePlayersFixture.cs +++ b/src/NzbDrone.Core.Test/NotificationTests/Xbmc/Http/ActivePlayersFixture.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System.Linq; using FluentAssertions; using NUnit.Framework; using NzbDrone.Common.Http; @@ -23,7 +23,7 @@ namespace NzbDrone.Core.Test.NotificationTests.Xbmc.Http private void WithVideoPlayerActive() { var activePlayers = @"
  • Filename:C:\Test\TV\2 Broke Girls\Season 01\2 Broke Girls - S01E01 - Pilot [SDTV].avi" + - "
  • PlayStatus:Playing
  • VideoNo:0
  • Type:Video
  • Thumb:special://masterprofile/Thumbnails/Video/a/auto-a664d5a2.tbn" + + "
  • PlayStatus:Playing
  • VideoNo:0
  • Type:Audio
  • Thumb:special://masterprofile/Thumbnails/Video/a/auto-a664d5a2.tbn" + "
  • Time:00:06
  • Duration:21:35
  • Percentage:0
  • File size:183182590
  • Changed:True"; Mocker.GetMock() @@ -57,14 +57,14 @@ namespace NzbDrone.Core.Test.NotificationTests.Xbmc.Http } [Test] - public void should_have_active_video_player() + public void should_have_active_audio_player() { WithVideoPlayerActive(); var result = Subject.GetActivePlayers(_settings); result.Should().HaveCount(1); - result.First().Type.Should().Be("video"); + result.First().Type.Should().Be("audio"); } } } diff --git a/src/NzbDrone.Core.Test/NotificationTests/Xbmc/Http/GetArtistPathFixture.cs b/src/NzbDrone.Core.Test/NotificationTests/Xbmc/Http/GetArtistPathFixture.cs new file mode 100644 index 000000000..37c292379 --- /dev/null +++ b/src/NzbDrone.Core.Test/NotificationTests/Xbmc/Http/GetArtistPathFixture.cs @@ -0,0 +1,94 @@ +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Common.Http; +using NzbDrone.Core.Notifications.Xbmc; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Test.NotificationTests.Xbmc.Http +{ + [TestFixture] + public class GetArtistPathFixture : CoreTest + { + private XbmcSettings _settings; + private Artist _artist; + + [SetUp] + public void Setup() + { + _settings = new XbmcSettings + { + Host = "localhost", + Port = 8080, + Username = "xbmc", + Password = "xbmc", + AlwaysUpdate = false, + CleanLibrary = false, + UpdateLibrary = true + }; + + _artist = new Artist + { + ForeignArtistId = "9f4e41c3-2648-428e-b8c7-dc10465b49ac", + Name = "Shawn Desman" + }; + + const string setResponseUrl = "http://localhost:8080/xbmcCmds/xbmcHttp?command=SetResponseFormat(webheader;false;webfooter;false;header;;footer;;opentag;;closetag;;closefinaltag;false)"; + const string resetResponseUrl = "http://localhost:8080/xbmcCmds/xbmcHttp?command=SetResponseFormat()"; + + Mocker.GetMock() + .Setup(s => s.DownloadString(setResponseUrl, _settings.Username, _settings.Password)) + .Returns("OK"); + + Mocker.GetMock() + .Setup(s => s.DownloadString(resetResponseUrl, _settings.Username, _settings.Password)) + .Returns(@" +
  • OK + "); + } + + [Test] + public void should_get_artist_path() + { + const string queryResult = @"smb://xbmc:xbmc@HOMESERVER/Music/Shawn Desman/"; + var query = string.Format("http://localhost:8080/xbmcCmds/xbmcHttp?command=QueryMusicDatabase(select path.strPath from path, artist, artistlinkpath where artist.c12 = 9f4e41c3-2648-428e-b8c7-dc10465b49ac and artistlinkpath.idArtist = artist.idArtist and artistlinkpath.idPath = path.idPath)"); + + Mocker.GetMock() + .Setup(s => s.DownloadString(query, _settings.Username, _settings.Password)) + .Returns(queryResult); + + Subject.GetArtistPath(_settings, _artist) + .Should().Be("smb://xbmc:xbmc@HOMESERVER/Music/Shawn Desman/"); + } + + [Test] + public void should_get_null_for_artist_path() + { + const string queryResult = @""; + var query = string.Format("http://localhost:8080/xbmcCmds/xbmcHttp?command=QueryMusicDatabase(select path.strPath from path, artist, artistlinkpath where artist.c12 = 9f4e41c3-2648-428e-b8c7-dc10465b49ac and artistlinkpath.idArtist = artist.idArtist and artistlinkpath.idPath = path.idPath)"); + + Mocker.GetMock() + .Setup(s => s.DownloadString(query, _settings.Username, _settings.Password)) + .Returns(queryResult); + + + Subject.GetArtistPath(_settings, _artist) + .Should().BeNull(); + } + + [Test] + public void should_get_artist_path_with_special_characters_in_it() + { + const string queryResult = @"smb://xbmc:xbmc@HOMESERVER/Music/-wumpscut-/"; + var query = string.Format("http://localhost:8080/xbmcCmds/xbmcHttp?command=QueryMusicDatabase(select path.strPath from path, artist, artistlinkpath where artist.c12 = 9f4e41c3-2648-428e-b8c7-dc10465b49ac and artistlinkpath.idArtist = artist.idArtist and artistlinkpath.idPath = path.idPath)"); + + Mocker.GetMock() + .Setup(s => s.DownloadString(query, _settings.Username, _settings.Password)) + .Returns(queryResult); + + + Subject.GetArtistPath(_settings, _artist) + .Should().Be("smb://xbmc:xbmc@HOMESERVER/Music/-wumpscut-/"); + } + } +} diff --git a/src/NzbDrone.Core.Test/NotificationTests/Xbmc/Http/GetSeriesPathFixture.cs b/src/NzbDrone.Core.Test/NotificationTests/Xbmc/Http/GetSeriesPathFixture.cs deleted file mode 100644 index 15ec93960..000000000 --- a/src/NzbDrone.Core.Test/NotificationTests/Xbmc/Http/GetSeriesPathFixture.cs +++ /dev/null @@ -1,94 +0,0 @@ -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Common.Http; -using NzbDrone.Core.Notifications.Xbmc; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Test.NotificationTests.Xbmc.Http -{ - [TestFixture] - public class GetSeriesPathFixture : CoreTest - { - private XbmcSettings _settings; - private Series _series; - - [SetUp] - public void Setup() - { - _settings = new XbmcSettings - { - Host = "localhost", - Port = 8080, - Username = "xbmc", - Password = "xbmc", - AlwaysUpdate = false, - CleanLibrary = false, - UpdateLibrary = true - }; - - _series = new Series - { - TvdbId = 79488, - Title = "30 Rock" - }; - - const string setResponseUrl = "http://localhost:8080/xbmcCmds/xbmcHttp?command=SetResponseFormat(webheader;false;webfooter;false;header;;footer;;opentag;;closetag;;closefinaltag;false)"; - const string resetResponseUrl = "http://localhost:8080/xbmcCmds/xbmcHttp?command=SetResponseFormat()"; - - Mocker.GetMock() - .Setup(s => s.DownloadString(setResponseUrl, _settings.Username, _settings.Password)) - .Returns("OK"); - - Mocker.GetMock() - .Setup(s => s.DownloadString(resetResponseUrl, _settings.Username, _settings.Password)) - .Returns(@" -
  • OK - "); - } - - [Test] - public void should_get_series_path() - { - const string queryResult = @"smb://xbmc:xbmc@HOMESERVER/TV/30 Rock/"; - var query = string.Format("http://localhost:8080/xbmcCmds/xbmcHttp?command=QueryVideoDatabase(select path.strPath from path, tvshow, tvshowlinkpath where tvshow.c12 = 79488 and tvshowlinkpath.idShow = tvshow.idShow and tvshowlinkpath.idPath = path.idPath)"); - - Mocker.GetMock() - .Setup(s => s.DownloadString(query, _settings.Username, _settings.Password)) - .Returns(queryResult); - - Subject.GetSeriesPath(_settings, _series) - .Should().Be("smb://xbmc:xbmc@HOMESERVER/TV/30 Rock/"); - } - - [Test] - public void should_get_null_for_series_path() - { - const string queryResult = @""; - var query = string.Format("http://localhost:8080/xbmcCmds/xbmcHttp?command=QueryVideoDatabase(select path.strPath from path, tvshow, tvshowlinkpath where tvshow.c12 = 79488 and tvshowlinkpath.idShow = tvshow.idShow and tvshowlinkpath.idPath = path.idPath)"); - - Mocker.GetMock() - .Setup(s => s.DownloadString(query, _settings.Username, _settings.Password)) - .Returns(queryResult); - - - Subject.GetSeriesPath(_settings, _series) - .Should().BeNull(); - } - - [Test] - public void should_get_series_path_with_special_characters_in_it() - { - const string queryResult = @"smb://xbmc:xbmc@HOMESERVER/TV/Law & Order- Special Victims Unit/"; - var query = string.Format("http://localhost:8080/xbmcCmds/xbmcHttp?command=QueryVideoDatabase(select path.strPath from path, tvshow, tvshowlinkpath where tvshow.c12 = 79488 and tvshowlinkpath.idShow = tvshow.idShow and tvshowlinkpath.idPath = path.idPath)"); - - Mocker.GetMock() - .Setup(s => s.DownloadString(query, _settings.Username, _settings.Password)) - .Returns(queryResult); - - - Subject.GetSeriesPath(_settings, _series) - .Should().Be("smb://xbmc:xbmc@HOMESERVER/TV/Law & Order- Special Victims Unit/"); - } - } -} diff --git a/src/NzbDrone.Core.Test/NotificationTests/Xbmc/Http/UpdateFixture.cs b/src/NzbDrone.Core.Test/NotificationTests/Xbmc/Http/UpdateFixture.cs index aad928f95..7b780f725 100644 --- a/src/NzbDrone.Core.Test/NotificationTests/Xbmc/Http/UpdateFixture.cs +++ b/src/NzbDrone.Core.Test/NotificationTests/Xbmc/Http/UpdateFixture.cs @@ -1,9 +1,9 @@ -using FizzWare.NBuilder; +using FizzWare.NBuilder; using NUnit.Framework; using NzbDrone.Common.Http; using NzbDrone.Core.Notifications.Xbmc; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Test.NotificationTests.Xbmc.Http { @@ -11,8 +11,8 @@ namespace NzbDrone.Core.Test.NotificationTests.Xbmc.Http public class UpdateFixture : CoreTest { private XbmcSettings _settings; - private string _seriesQueryUrl = "http://localhost:8080/xbmcCmds/xbmcHttp?command=QueryVideoDatabase(select path.strPath from path, tvshow, tvshowlinkpath where tvshow.c12 = 79488 and tvshowlinkpath.idShow = tvshow.idShow and tvshowlinkpath.idPath = path.idPath)"; - private Series _fakeSeries; + private string _artistQueryUrl = "http://localhost:8080/xbmcCmds/xbmcHttp?command=QueryMusicDatabase(select path.strPath from path, artist, artistlinkpath where artist.c12 = 9f4e41c3-2648-428e-b8c7-dc10465b49ac and artistlinkpath.idArtist = artist.idArtist and artistlinkpath.idPath = path.idPath)"; + private Artist _fakeArtist; [SetUp] public void Setup() @@ -28,47 +28,47 @@ namespace NzbDrone.Core.Test.NotificationTests.Xbmc.Http UpdateLibrary = true }; - _fakeSeries = Builder.CreateNew() - .With(s => s.TvdbId = 79488) - .With(s => s.Title = "30 Rock") + _fakeArtist = Builder.CreateNew() + .With(s => s.ForeignArtistId = "9f4e41c3-2648-428e-b8c7-dc10465b49ac") + .With(s => s.Name = "Shawn Desman") .Build(); } - private void WithSeriesPath() + private void WithArtistPath() { Mocker.GetMock() - .Setup(s => s.DownloadString(_seriesQueryUrl, _settings.Username, _settings.Password)) - .Returns("smb://xbmc:xbmc@HOMESERVER/TV/30 Rock/"); + .Setup(s => s.DownloadString(_artistQueryUrl, _settings.Username, _settings.Password)) + .Returns("smb://xbmc:xbmc@HOMESERVER/Music/Shawn Desman/"); } - private void WithoutSeriesPath() + private void WithoutArtistPath() { Mocker.GetMock() - .Setup(s => s.DownloadString(_seriesQueryUrl, _settings.Username, _settings.Password)) + .Setup(s => s.DownloadString(_artistQueryUrl, _settings.Username, _settings.Password)) .Returns(""); } [Test] - public void should_update_using_series_path() + public void should_update_using_artist_path() { - WithSeriesPath(); - const string url = "http://localhost:8080/xbmcCmds/xbmcHttp?command=ExecBuiltIn(UpdateLibrary(video,smb://xbmc:xbmc@HOMESERVER/TV/30 Rock/))"; + WithArtistPath(); + const string url = "http://localhost:8080/xbmcCmds/xbmcHttp?command=ExecBuiltIn(UpdateLibrary(music,smb://xbmc:xbmc@HOMESERVER/Music/Shawn Desman/))"; Mocker.GetMock().Setup(s => s.DownloadString(url, _settings.Username, _settings.Password)); - Subject.Update(_settings, _fakeSeries); + Subject.Update(_settings, _fakeArtist); Mocker.VerifyAllMocks(); } [Test] - public void should_update_all_paths_when_series_path_not_found() + public void should_update_all_paths_when_artist_path_not_found() { - WithoutSeriesPath(); - const string url = "http://localhost:8080/xbmcCmds/xbmcHttp?command=ExecBuiltIn(UpdateLibrary(video))"; + WithoutArtistPath(); + const string url = "http://localhost:8080/xbmcCmds/xbmcHttp?command=ExecBuiltIn(UpdateLibrary(music))"; Mocker.GetMock().Setup(s => s.DownloadString(url, _settings.Username, _settings.Password)); - Subject.Update(_settings, _fakeSeries); + Subject.Update(_settings, _fakeArtist); Mocker.VerifyAllMocks(); } } diff --git a/src/NzbDrone.Core.Test/NotificationTests/Xbmc/Json/GetArtistPathFixture.cs b/src/NzbDrone.Core.Test/NotificationTests/Xbmc/Json/GetArtistPathFixture.cs new file mode 100644 index 000000000..19e62a0cb --- /dev/null +++ b/src/NzbDrone.Core.Test/NotificationTests/Xbmc/Json/GetArtistPathFixture.cs @@ -0,0 +1,91 @@ +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Notifications.Xbmc; +using NzbDrone.Core.Notifications.Xbmc.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Test.NotificationTests.Xbmc.Json +{ + [TestFixture] + public class GetArtistPathFixture : CoreTest + { + private const string MB_ID = "9f4e41c3-2648-428e-b8c7-dc10465b49ac"; + private XbmcSettings _settings; + private Music.Artist _artist; + private List _xbmcArtist; + + [SetUp] + public void Setup() + { + _settings = Builder.CreateNew() + .Build(); + + _xbmcArtist = Builder.CreateListOfSize(3) + .All() + .With(s => s.MusicbrainzArtistId = new List{"0"}) + .TheFirst(1) + .With(s => s.MusicbrainzArtistId = new List {MB_ID.ToString()}) + .Build() + .ToList(); + + Mocker.GetMock() + .Setup(s => s.GetArtist(_settings)) + .Returns(_xbmcArtist); + } + + private void GivenMatchingMusicbrainzId() + { + _artist = new Artist + { + ForeignArtistId = MB_ID, + Name = "Artist" + }; + } + + private void GivenMatchingTitle() + { + _artist = new Artist + { + ForeignArtistId = "1000", + Name = _xbmcArtist.First().Label + }; + } + + private void GivenMatchingArtist() + { + _artist = new Artist + { + ForeignArtistId = "1000", + Name = "Does not exist" + }; + } + + [Test] + public void should_return_null_when_artist_is_not_found() + { + GivenMatchingArtist(); + + Subject.GetArtistPath(_settings, _artist).Should().BeNull(); + } + + [Test] + public void should_return_path_when_musicbrainzId_matches() + { + GivenMatchingMusicbrainzId(); + + Subject.GetArtistPath(_settings, _artist).Should().Be(_xbmcArtist.First().File); + } + + [Test] + public void should_return_path_when_title_matches() + { + GivenMatchingTitle(); + + Subject.GetArtistPath(_settings, _artist).Should().Be(_xbmcArtist.First().File); + } + } +} diff --git a/src/NzbDrone.Core.Test/NotificationTests/Xbmc/Json/GetSeriesPathFixture.cs b/src/NzbDrone.Core.Test/NotificationTests/Xbmc/Json/GetSeriesPathFixture.cs deleted file mode 100644 index b4b29dff2..000000000 --- a/src/NzbDrone.Core.Test/NotificationTests/Xbmc/Json/GetSeriesPathFixture.cs +++ /dev/null @@ -1,106 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using FizzWare.NBuilder; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Notifications.Xbmc; -using NzbDrone.Core.Notifications.Xbmc.Model; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Test.NotificationTests.Xbmc.Json -{ - [TestFixture] - public class GetSeriesPathFixture : CoreTest - { - private const int TVDB_ID = 5; - private XbmcSettings _settings; - private Series _series; - private List _xbmcSeries; - - [SetUp] - public void Setup() - { - _settings = Builder.CreateNew() - .Build(); - - _xbmcSeries = Builder.CreateListOfSize(3) - .All() - .With(s => s.ImdbNumber = "0") - .TheFirst(1) - .With(s => s.ImdbNumber = TVDB_ID.ToString()) - .Build() - .ToList(); - - Mocker.GetMock() - .Setup(s => s.GetSeries(_settings)) - .Returns(_xbmcSeries); - } - - private void GivenMatchingTvdbId() - { - _series = new Series - { - TvdbId = TVDB_ID, - Title = "TV Show" - }; - } - - private void GivenMatchingTitle() - { - _series = new Series - { - TvdbId = 1000, - Title = _xbmcSeries.First().Label - }; - } - - private void GivenMatchingSeries() - { - _series = new Series - { - TvdbId = 1000, - Title = "Does not exist" - }; - } - - [Test] - public void should_return_null_when_series_is_not_found() - { - GivenMatchingSeries(); - - Subject.GetSeriesPath(_settings, _series).Should().BeNull(); - } - - [Test] - public void should_return_path_when_tvdbId_matches() - { - GivenMatchingTvdbId(); - - Subject.GetSeriesPath(_settings, _series).Should().Be(_xbmcSeries.First().File); - } - - [Test] - public void should_return_path_when_title_matches() - { - GivenMatchingTitle(); - - Subject.GetSeriesPath(_settings, _series).Should().Be(_xbmcSeries.First().File); - } - - [Test] - public void should_not_throw_when_imdb_number_is_not_a_number() - { - GivenMatchingTvdbId(); - - _xbmcSeries.ForEach(s => s.ImdbNumber = "tt12345"); - _xbmcSeries.Last().ImdbNumber = TVDB_ID.ToString(); - - Mocker.GetMock() - .Setup(s => s.GetSeries(_settings)) - .Returns(_xbmcSeries); - - Subject.GetSeriesPath(_settings, _series).Should().NotBeNull(); - } - } -} diff --git a/src/NzbDrone.Core.Test/NotificationTests/Xbmc/Json/UpdateFixture.cs b/src/NzbDrone.Core.Test/NotificationTests/Xbmc/Json/UpdateFixture.cs index 408f2eeba..7dfc9ee1b 100644 --- a/src/NzbDrone.Core.Test/NotificationTests/Xbmc/Json/UpdateFixture.cs +++ b/src/NzbDrone.Core.Test/NotificationTests/Xbmc/Json/UpdateFixture.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using FizzWare.NBuilder; using Moq; @@ -6,16 +6,16 @@ using NUnit.Framework; using NzbDrone.Core.Notifications.Xbmc; using NzbDrone.Core.Notifications.Xbmc.Model; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Test.NotificationTests.Xbmc.Json { [TestFixture] public class UpdateFixture : CoreTest { - private const int TVDB_ID = 5; + private const string MB_ID = "9f4e41c3-2648-428e-b8c7-dc10465b49ac"; private XbmcSettings _settings; - private List _xbmcSeries; + private List _xbmcArtist; [SetUp] public void Setup() @@ -23,15 +23,17 @@ namespace NzbDrone.Core.Test.NotificationTests.Xbmc.Json _settings = Builder.CreateNew() .Build(); - _xbmcSeries = Builder.CreateListOfSize(3) - .TheFirst(1) - .With(s => s.ImdbNumber = TVDB_ID.ToString()) - .Build() - .ToList(); + _xbmcArtist = Builder.CreateListOfSize(3) + .TheFirst(1) + .With(s => s.MusicbrainzArtistId = new List { MB_ID.ToString()}) + .TheNext(2) + .With(s => s.MusicbrainzArtistId = new List()) + .Build() + .ToList(); Mocker.GetMock() - .Setup(s => s.GetSeries(_settings)) - .Returns(_xbmcSeries); + .Setup(s => s.GetArtist(_settings)) + .Returns(_xbmcArtist); Mocker.GetMock() .Setup(s => s.GetActivePlayers(_settings)) @@ -39,27 +41,27 @@ namespace NzbDrone.Core.Test.NotificationTests.Xbmc.Json } [Test] - public void should_update_using_series_path() + public void should_update_using_artist_path() { - var series = Builder.CreateNew() - .With(s => s.TvdbId = TVDB_ID) + var artist = Builder.CreateNew() + .With(s => s.ForeignArtistId = MB_ID) .Build(); - Subject.Update(_settings, series); + Subject.Update(_settings, artist); Mocker.GetMock() .Verify(v => v.UpdateLibrary(_settings, It.IsAny()), Times.Once()); } [Test] - public void should_update_all_paths_when_series_path_not_found() + public void should_update_all_paths_when_artist_path_not_found() { - var fakeSeries = Builder.CreateNew() - .With(s => s.TvdbId = 1000) - .With(s => s.Title = "Not 30 Rock") + var fakeArtist = Builder.CreateNew() + .With(s => s.ForeignArtistId = "9f4e41c3-2648-428e-b8c7-dc10465b49ad") + .With(s => s.Name = "Not Shawn Desman") .Build(); - Subject.Update(_settings, fakeSeries); + Subject.Update(_settings, fakeArtist); Mocker.GetMock() .Verify(v => v.UpdateLibrary(_settings, null), Times.Once()); diff --git a/src/NzbDrone.Core.Test/NotificationTests/Xbmc/OnDownloadFixture.cs b/src/NzbDrone.Core.Test/NotificationTests/Xbmc/OnDownloadFixture.cs deleted file mode 100644 index c43786614..000000000 --- a/src/NzbDrone.Core.Test/NotificationTests/Xbmc/OnDownloadFixture.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using FizzWare.NBuilder; -using Moq; -using NUnit.Framework; -using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.Notifications; -using NzbDrone.Core.Notifications.Xbmc; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Test.NotificationTests.Xbmc -{ - [TestFixture] - public class OnDownloadFixture : CoreTest - { - private DownloadMessage _downloadMessage; - - [SetUp] - public void Setup() - { - var series = Builder.CreateNew() - .Build(); - - var episodeFile = Builder.CreateNew() - .Build(); - - _downloadMessage = Builder.CreateNew() - .With(d => d.Series = series) - .With(d => d.EpisodeFile = episodeFile) - .With(d => d.OldFiles = new List()) - .Build(); - - Subject.Definition = new NotificationDefinition(); - Subject.Definition.Settings = new XbmcSettings - { - UpdateLibrary = true - }; - } - - private void GivenOldFiles() - { - _downloadMessage.OldFiles = Builder.CreateListOfSize(1) - .Build() - .ToList(); - - Subject.Definition.Settings = new XbmcSettings - { - UpdateLibrary = true, - CleanLibrary = true - }; - } - - [Test] - public void should_not_clean_if_no_episode_was_replaced() - { - Subject.OnDownload(_downloadMessage); - - Mocker.GetMock().Verify(v => v.Clean(It.IsAny()), Times.Never()); - } - - [Test] - public void should_clean_if_episode_was_replaced() - { - GivenOldFiles(); - Subject.OnDownload(_downloadMessage); - - Mocker.GetMock().Verify(v => v.Clean(It.IsAny()), Times.Once()); - } - } -} diff --git a/src/NzbDrone.Core.Test/NotificationTests/Xbmc/OnReleaseImportFixture.cs b/src/NzbDrone.Core.Test/NotificationTests/Xbmc/OnReleaseImportFixture.cs new file mode 100644 index 000000000..56b80b878 --- /dev/null +++ b/src/NzbDrone.Core.Test/NotificationTests/Xbmc/OnReleaseImportFixture.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Notifications; +using NzbDrone.Core.Notifications.Xbmc; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Test.NotificationTests.Xbmc +{ + [TestFixture] + public class OnReleaseImportFixture : CoreTest + { + private AlbumDownloadMessage _albumDownloadMessage; + + [SetUp] + public void Setup() + { + var artist = Builder.CreateNew() + .Build(); + + var trackFile = Builder.CreateNew() + .Build(); + + _albumDownloadMessage = Builder.CreateNew() + .With(d => d.Artist = artist) + .With(d => d.TrackFiles = new List { trackFile }) + .With(d => d.OldFiles = new List()) + .Build(); + + Subject.Definition = new NotificationDefinition(); + Subject.Definition.Settings = new XbmcSettings + { + UpdateLibrary = true + }; + } + + private void GivenOldFiles() + { + _albumDownloadMessage.OldFiles = Builder.CreateListOfSize(1) + .Build() + .ToList(); + + Subject.Definition.Settings = new XbmcSettings + { + UpdateLibrary = true, + CleanLibrary = true + }; + } + + [Test] + public void should_not_clean_if_no_episode_was_replaced() + { + Subject.OnReleaseImport(_albumDownloadMessage); + + Mocker.GetMock().Verify(v => v.Clean(It.IsAny()), Times.Never()); + } + + [Test] + public void should_clean_if_episode_was_replaced() + { + GivenOldFiles(); + Subject.OnReleaseImport(_albumDownloadMessage); + + Mocker.GetMock().Verify(v => v.Clean(It.IsAny()), Times.Once()); + } + } +} diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj deleted file mode 100644 index fa795dd2c..000000000 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ /dev/null @@ -1,592 +0,0 @@ - - - - Debug - x86 - 8.0.30703 - 2.0 - {193ADD3B-792B-4173-8E4C-5A3F8F0237F0} - Library - Properties - NzbDrone.Core.Test - NzbDrone.Core.Test - v4.0 - 512 - ..\ - true - - - true - bin\x86\Debug\ - DEBUG;TRACE - full - x86 - prompt - MinimumRecommendedRules.ruleset - 4 - false - - - bin\x86\Release\ - TRACE - true - pdbonly - x86 - prompt - MinimumRecommendedRules.ruleset - 4 - - - OnBuildSuccess - - - - ..\packages\AutoMoq.1.8.1.0\lib\net40\AutoMoq.dll - True - - - ..\packages\NBuilder.4.0.0\lib\net40\FizzWare.NBuilder.dll - True - - - ..\packages\FluentAssertions.4.18.0\lib\net40\FluentAssertions.dll - True - - - ..\packages\FluentAssertions.4.18.0\lib\net40\FluentAssertions.Core.dll - True - - - ..\packages\FluentMigrator.1.6.2\lib\40\FluentMigrator.dll - True - - - ..\packages\FluentMigrator.Runner.1.6.2\lib\40\FluentMigrator.Runner.dll - True - - - ..\packages\FluentValidation.6.2.1.0\lib\portable-net40+sl50+wp80+win8+wpa81\FluentValidation.dll - True - - - ..\packages\CommonServiceLocator.1.0\lib\NET35\Microsoft.Practices.ServiceLocation.dll - True - - - ..\packages\Unity.2.1.505.2\lib\NET35\Microsoft.Practices.Unity.dll - True - - - ..\packages\Unity.2.1.505.2\lib\NET35\Microsoft.Practices.Unity.Configuration.dll - True - - - ..\packages\NCrunch.Framework.3.2.0.3\lib\NCrunch.Framework.dll - True - - - ..\packages\Newtonsoft.Json.9.0.1\lib\net40\Newtonsoft.Json.dll - True - - - ..\packages\NLog.4.4.1\lib\net40\NLog.dll - True - - - ..\packages\NUnit.3.5.0\lib\net40\nunit.framework.dll - True - - - - - - - - - ..\packages\Moq.4.0.10827\lib\NET40\Moq.dll - - - ..\packages\Prowlin.0.9.4456.26422\lib\net40\Prowlin.dll - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Always - - - Always - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {F6FC6BE7-0847-4817-A1ED-223DC647C3D7} - Marr.Data - - - {F2BE0FDF-6E47-4827-A420-DD4EF82407F8} - NzbDrone.Common - - - {FF5EE3B6-913B-47CE-9CEB-11C51B4E1205} - NzbDrone.Core - - - {7C2CC69F-5CA0-4E5C-85CB-983F9F6C3B36} - NzbDrone.SignalR - - - {CADDFCE0-7509-4430-8364-2074E1EEFCA2} - NzbDrone.Test.Common - - - - - Files\1024.png - Always - - - sqlite3.dll - Always - - - Always - - - - Always - - - Always - - - Always - - - PreserveNewest - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Designer - Always - - - Always - - - Always - Designer - - - App.config - - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - - - Always - - - Always - - - Always - - - Always - - - Always - - - Always - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/OrganizerTests/BuildFilePathFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/BuildFilePathFixture.cs index 3848659c9..0af5aad66 100644 --- a/src/NzbDrone.Core.Test/OrganizerTests/BuildFilePathFixture.cs +++ b/src/NzbDrone.Core.Test/OrganizerTests/BuildFilePathFixture.cs @@ -2,7 +2,7 @@ using FizzWare.NBuilder; using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.Organizer; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; using NzbDrone.Core.Test.Framework; using NzbDrone.Test.Common; @@ -24,41 +24,24 @@ namespace NzbDrone.Core.Test.OrganizerTests } [Test] - [TestCase("30 Rock - S01E05 - Episode Title", 1, true, "Season {season:00}", @"C:\Test\30 Rock\Season 01\30 Rock - S01E05 - Episode Title.mkv")] - [TestCase("30 Rock - S01E05 - Episode Title", 1, true, "Season {season}", @"C:\Test\30 Rock\Season 1\30 Rock - S01E05 - Episode Title.mkv")] - [TestCase("30 Rock - S01E05 - Episode Title", 1, false, "Season {season:00}", @"C:\Test\30 Rock\30 Rock - S01E05 - Episode Title.mkv")] - [TestCase("30 Rock - S01E05 - Episode Title", 1, false, "Season {season}", @"C:\Test\30 Rock\30 Rock - S01E05 - Episode Title.mkv")] - [TestCase("30 Rock - S01E05 - Episode Title", 1, true, "ReallyUglySeasonFolder {season}", @"C:\Test\30 Rock\ReallyUglySeasonFolder 1\30 Rock - S01E05 - Episode Title.mkv")] - [TestCase("30 Rock - S00E05 - Episode Title", 0, true, "Season {season}", @"C:\Test\30 Rock\Specials\30 Rock - S00E05 - Episode Title.mkv")] - public void CalculateFilePath_SeasonFolder_SingleNumber(string filename, int seasonNumber, bool useSeasonFolder, string seasonFolderFormat, string expectedPath) + public void should_clean_album_folder_when_it_contains_illegal_characters_in_album_or_artist_title() { - var fakeSeries = Builder.CreateNew() - .With(s => s.Title = "30 Rock") - .With(s => s.Path = @"C:\Test\30 Rock".AsOsAgnostic()) - .With(s => s.SeasonFolder = useSeasonFolder) - .Build(); - - namingConfig.SeasonFolderFormat = seasonFolderFormat; - - Subject.BuildFilePath(fakeSeries, seasonNumber, filename, ".mkv").Should().Be(expectedPath.AsOsAgnostic()); - } + var filename = @"02 - Track Title"; + var expectedPath = @"C:\Test\Fake- The Artist\Fake- The Artist Fake- Album\02 - Track Title.flac"; - [Test] - public void should_clean_season_folder_when_it_contains_illegal_characters_in_series_title() - { - var filename = @"S01E05 - Episode Title"; - var seasonNumber = 1; - var expectedPath = @"C:\Test\NCIS- Los Angeles\NCIS- Los Angeles Season 1\S01E05 - Episode Title.mkv"; + var fakeArtist = Builder.CreateNew() + .With(s => s.Name = "Fake: The Artist") + .With(s => s.Path = @"C:\Test\Fake- The Artist".AsOsAgnostic()) + .With(s => s.AlbumFolder = true) + .Build(); - var fakeSeries = Builder.CreateNew() - .With(s => s.Title = "NCIS: Los Angeles") - .With(s => s.Path = @"C:\Test\NCIS- Los Angeles".AsOsAgnostic()) - .With(s => s.SeasonFolder = true) + var fakeAlbum = Builder.CreateNew() + .With(s => s.Title = "Fake: Album") .Build(); - namingConfig.SeasonFolderFormat = "{Series Title} Season {season:0}"; + namingConfig.AlbumFolderFormat = "{Artist Name} {Album Title}"; - Subject.BuildFilePath(fakeSeries, seasonNumber, filename, ".mkv").Should().Be(expectedPath.AsOsAgnostic()); + Subject.BuildTrackFilePath(fakeArtist, fakeAlbum, filename, ".flac").Should().Be(expectedPath.AsOsAgnostic()); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/CleanTitleFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/CleanTitleFixture.cs index f6aabeb9d..8d1389c18 100644 --- a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/CleanTitleFixture.cs +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/CleanTitleFixture.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using FizzWare.NBuilder; using FluentAssertions; @@ -7,37 +7,48 @@ using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Organizer; using NzbDrone.Core.Qualities; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests { [TestFixture] public class CleanTitleFixture : CoreTest { - private Series _series; - private Episode _episode; - private EpisodeFile _episodeFile; + private Artist _artist; + private Album _album; + private AlbumRelease _release; + private Track _track; + private TrackFile _trackFile; private NamingConfig _namingConfig; [SetUp] public void Setup() { - _series = Builder + _artist = Builder .CreateNew() - .With(s => s.Title = "South Park") + .With(s => s.Name = "Avenged Sevenfold") .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) + _album = Builder + .CreateNew() + .With(s => s.Title = "Hail to the King") + .Build(); + + _release = Builder + .CreateNew() + .With(s => s.Media = new List { new Medium { Number = 1 } }) + .Build(); + + _track = Builder.CreateNew() + .With(e => e.Title = "Doing Time") + .With(e => e.AbsoluteTrackNumber = 3) + .With(e => e.AlbumRelease = _release) .Build(); - _episodeFile = new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p), ReleaseGroup = "SonarrTest" }; + _trackFile = new TrackFile { Quality = new QualityModel(Quality.MP3_256), ReleaseGroup = "LidarrTest" }; _namingConfig = NamingConfig.Default; - _namingConfig.RenameEpisodes = true; + _namingConfig.RenameTracks = true; Mocker.GetMock() .Setup(c => c.GetConfig()).Returns(_namingConfig); @@ -65,31 +76,32 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests [TestCase("[a] title", "a title")] [TestCase("backslash \\ backlash", "backslash backlash")] [TestCase("I'm the Boss", "Im the Boss")] - //[TestCase("", "")] - public void should_get_expected_title_back(string title, string expected) + public void should_get_expected_title_back(string name, string expected) { - _series.Title = title; - _namingConfig.StandardEpisodeFormat = "{Series CleanTitle}"; + _artist.Name = name; + _namingConfig.StandardTrackFormat = "{Artist CleanName}"; - Subject.BuildFileName(new List { _episode }, _series, _episodeFile) + Subject.BuildTrackFileName(new List { _track }, _artist, _album, _trackFile) .Should().Be(expected); } [Test] public void should_use_and_as_separator_for_multiple_episodes() { - var episodes = Builder.CreateListOfSize(2) + var tracks = Builder.CreateListOfSize(2) .TheFirst(1) .With(e => e.Title = "Surrender Benson") .TheNext(1) .With(e => e.Title = "Imprisoned Lives") + .All() + .With(e => e.AlbumRelease = _release) .Build() .ToList(); - _namingConfig.StandardEpisodeFormat = "{Episode CleanTitle}"; + _namingConfig.StandardTrackFormat = "{Track CleanTitle}"; - Subject.BuildFileName(episodes, _series, _episodeFile) - .Should().Be(episodes.First().Title + " and " + episodes.Last().Title); + Subject.BuildTrackFileName(tracks, _artist, _album, _trackFile) + .Should().Be(tracks.First().Title + " and " + tracks.Last().Title); } } } diff --git a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/EpisodeTitleCollapseFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/EpisodeTitleCollapseFixture.cs deleted file mode 100644 index f4da13b5b..000000000 --- a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/EpisodeTitleCollapseFixture.cs +++ /dev/null @@ -1,113 +0,0 @@ -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 EpisodeTitleCollapseFixture : CoreTest - { - private Series _series; - private Episode _episode1; - private Episode _episode2; - private Episode _episode3; - private EpisodeFile _episodeFile; - private NamingConfig _namingConfig; - - [SetUp] - public void Setup() - { - _series = Builder - .CreateNew() - .With(s => s.Title = "South Park") - .Build(); - - - _namingConfig = NamingConfig.Default; - _namingConfig.RenameEpisodes = true; - - - Mocker.GetMock() - .Setup(c => c.GetConfig()).Returns(_namingConfig); - - _episode1 = Builder.CreateNew() - .With(e => e.Title = "City Sushi") - .With(e => e.SeasonNumber = 15) - .With(e => e.EpisodeNumber = 6) - .With(e => e.AbsoluteEpisodeNumber = 100) - .Build(); - - _episode2 = Builder.CreateNew() - .With(e => e.Title = "City Sushi") - .With(e => e.SeasonNumber = 15) - .With(e => e.EpisodeNumber = 7) - .With(e => e.AbsoluteEpisodeNumber = 101) - .Build(); - - _episode3 = Builder.CreateNew() - .With(e => e.Title = "City Sushi") - .With(e => e.SeasonNumber = 15) - .With(e => e.EpisodeNumber = 8) - .With(e => e.AbsoluteEpisodeNumber = 102) - .Build(); - - _episodeFile = new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p), ReleaseGroup = "SonarrTest" }; - - Mocker.GetMock() - .Setup(v => v.Get(Moq.It.IsAny())) - .Returns(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v)); - } - - - [TestCase("Hey, Baby, What's Wrong (1)", "Hey, Baby, What's Wrong (2)", "Hey, Baby, What's Wrong")] - [TestCase("Meet the Guys and Girls of Cycle 20 Part 1", "Meet the Guys and Girls of Cycle 20 Part 2", "Meet the Guys and Girls of Cycle 20")] - [TestCase("Meet the Guys and Girls of Cycle 20 Part1", "Meet the Guys and Girls of Cycle 20 Part2", "Meet the Guys and Girls of Cycle 20")] - [TestCase("Meet the Guys and Girls of Cycle 20 Part01", "Meet the Guys and Girls of Cycle 20 Part02", "Meet the Guys and Girls of Cycle 20")] - [TestCase("Meet the Guys and Girls of Cycle 20 Part 01", "Meet the Guys and Girls of Cycle 20 Part 02", "Meet the Guys and Girls of Cycle 20")] - [TestCase("Meet the Guys and Girls of Cycle 20 part 1", "Meet the Guys and Girls of Cycle 20 part 2", "Meet the Guys and Girls of Cycle 20")] - [TestCase("Meet the Guys and Girls of Cycle 20 pt 1", "Meet the Guys and Girls of Cycle 20 pt 2", "Meet the Guys and Girls of Cycle 20")] - [TestCase("Meet the Guys and Girls of Cycle 20 pt. 1", "Meet the Guys and Girls of Cycle 20 pt. 2", "Meet the Guys and Girls of Cycle 20")] - public void should_collapse_episode_titles_when_episode_titles_are_the_same(string title1, string title2, string expected) - { - _namingConfig.StandardEpisodeFormat = "{Episode Title}"; - - _episode1.Title = title1; - _episode2.Title = title2; - - Subject.BuildFileName(new List { _episode1, _episode2 }, _series, _episodeFile) - .Should().Be(expected); - } - - [Test] - public void should_not_collapse_episode_titles_when_episode_titles_are_not_the_same() - { - _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title}"; - _namingConfig.MultiEpisodeStyle = 3; - - _episode1.Title = "Hello"; - _episode2.Title = "World"; - - Subject.BuildFileName(new List { _episode1, _episode2 }, _series, _episodeFile) - .Should().Be("South Park - S15E06-E07 - Hello + World"); - } - - [Test] - public void should_not_collaspe_when_result_is_empty() - { - _namingConfig.StandardEpisodeFormat = "{Episode Title}"; - - _episode1.Title = "Part 1"; - _episode2.Title = "Part 2"; - - Subject.BuildFileName(new List { _episode1, _episode2 }, _series, _episodeFile) - .Should().Be("Part 1 + Part 2"); - } - } -} diff --git a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs index e28bc58f5..f08dec808 100644 --- a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs @@ -8,7 +8,7 @@ using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Organizer; using NzbDrone.Core.Qualities; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests { @@ -16,35 +16,69 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests public class FileNameBuilderFixture : CoreTest { - private Series _series; - private Episode _episode1; - private EpisodeFile _episodeFile; + private Artist _artist; + private Album _album; + private Medium _medium; + private AlbumRelease _release; + private Track _track1; + private TrackFile _trackFile; private NamingConfig _namingConfig; [SetUp] public void Setup() { - _series = Builder + _artist = Builder .CreateNew() - .With(s => s.Title = "South Park") + .With(s => s.Name = "Linkin Park") + .With(s => s.Metadata = new ArtistMetadata { + Disambiguation = "US Rock Band", + Name = "Linkin Park" + }) .Build(); + _medium = Builder + .CreateNew() + .With(m => m.Number = 3) + .Build(); + + _release = Builder + .CreateNew() + .With(s => s.Media = new List { _medium }) + .With(s => s.Monitored = true) + .Build(); + + _album = Builder + .CreateNew() + .With(s => s.Title = "Hybrid Theory") + .With(s => s.AlbumType = "Album") + .With(s => s.Disambiguation = "The Best Album") + .Build(); + _namingConfig = NamingConfig.Default; - _namingConfig.RenameEpisodes = true; + _namingConfig.RenameTracks = true; Mocker.GetMock() .Setup(c => c.GetConfig()).Returns(_namingConfig); - _episode1 = Builder.CreateNew() + _track1 = Builder.CreateNew() .With(e => e.Title = "City Sushi") - .With(e => e.SeasonNumber = 15) - .With(e => e.EpisodeNumber = 6) - .With(e => e.AbsoluteEpisodeNumber = 100) + .With(e => e.AbsoluteTrackNumber = 6) + .With(e => e.AlbumRelease = _release) + .With(e => e.MediumNumber = _medium.Number) .Build(); - _episodeFile = new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p), ReleaseGroup = "SonarrTest" }; + _trackFile = Builder.CreateNew() + .With(e => e.Quality = new QualityModel(Quality.MP3_256)) + .With(e => e.ReleaseGroup = "LidarrTest") + .With(e => e.MediaInfo = new Parser.Model.MediaInfoModel { + AudioBitrate = 320, + AudioBits = 16, + AudioChannels = 2, + AudioFormat = "Flac Audio", + AudioSampleRate = 44100 + }).Build(); Mocker.GetMock() .Setup(v => v.Get(Moq.It.IsAny())) @@ -53,592 +87,482 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests private void GivenProper() { - _episodeFile.Quality.Revision.Version = 2; + _trackFile.Quality.Revision.Version = 2; } private void GivenReal() { - _episodeFile.Quality.Revision.Real = 1; + _trackFile.Quality.Revision.Real = 1; } [Test] - public void should_replace_Series_space_Title() + public void should_replace_Artist_space_Name() { - _namingConfig.StandardEpisodeFormat = "{Series Title}"; + _namingConfig.StandardTrackFormat = "{Artist Name}"; - Subject.BuildFileName(new List {_episode1}, _series, _episodeFile) - .Should().Be("South Park"); + Subject.BuildTrackFileName(new List {_track1}, _artist, _album, _trackFile) + .Should().Be("Linkin Park"); } [Test] - public void should_replace_Series_underscore_Title() + public void should_replace_Artist_underscore_Name() { - _namingConfig.StandardEpisodeFormat = "{Series_Title}"; + _namingConfig.StandardTrackFormat = "{Artist_Name}"; - Subject.BuildFileName(new List {_episode1}, _series, _episodeFile) - .Should().Be("South_Park"); + Subject.BuildTrackFileName(new List {_track1}, _artist, _album, _trackFile) + .Should().Be("Linkin_Park"); } [Test] - public void should_replace_Series_dot_Title() + public void should_replace_Artist_dot_Name() { - _namingConfig.StandardEpisodeFormat = "{Series.Title}"; + _namingConfig.StandardTrackFormat = "{Artist.Name}"; - Subject.BuildFileName(new List {_episode1}, _series, _episodeFile) - .Should().Be("South.Park"); + Subject.BuildTrackFileName(new List {_track1}, _artist, _album, _trackFile) + .Should().Be("Linkin.Park"); } [Test] - public void should_replace_Series_dash_Title() + public void should_replace_Artist_dash_Name() { - _namingConfig.StandardEpisodeFormat = "{Series-Title}"; + _namingConfig.StandardTrackFormat = "{Artist-Name}"; - Subject.BuildFileName(new List {_episode1}, _series, _episodeFile) - .Should().Be("South-Park"); + Subject.BuildTrackFileName(new List {_track1}, _artist, _album, _trackFile) + .Should().Be("Linkin-Park"); } [Test] - public void should_replace_SERIES_TITLE_with_all_caps() + public void should_replace_ARTIST_NAME_with_all_caps() { - _namingConfig.StandardEpisodeFormat = "{SERIES TITLE}"; + _namingConfig.StandardTrackFormat = "{ARTIST NAME}"; - Subject.BuildFileName(new List {_episode1}, _series, _episodeFile) - .Should().Be("SOUTH PARK"); + Subject.BuildTrackFileName(new List {_track1}, _artist, _album, _trackFile) + .Should().Be("LINKIN PARK"); } [Test] - public void should_replace_SERIES_TITLE_with_random_casing_should_keep_original_casing() + public void should_replace_ARTIST_NAME_with_random_casing_should_keep_original_casing() { - _namingConfig.StandardEpisodeFormat = "{sErIES-tItLE}"; + _namingConfig.StandardTrackFormat = "{aRtIST-nAmE}"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be(_series.Title.Replace(' ', '-')); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be(_artist.Name.Replace(' ', '-')); } [Test] - public void should_replace_series_title_with_all_lower_case() + public void should_replace_artist_name_with_all_lower_case() { - _namingConfig.StandardEpisodeFormat = "{series title}"; + _namingConfig.StandardTrackFormat = "{artist name}"; - Subject.BuildFileName(new List {_episode1}, _series, _episodeFile) - .Should().Be("south park"); + Subject.BuildTrackFileName(new List {_track1}, _artist, _album, _trackFile) + .Should().Be("linkin park"); } [Test] - public void should_cleanup_Series_Title() + public void should_cleanup_Artist_Name() { - _namingConfig.StandardEpisodeFormat = "{Series.CleanTitle}"; - _series.Title = "South Park (1997)"; + _namingConfig.StandardTrackFormat = "{Artist.CleanName}"; + _artist.Name = "Linkin Park (1997)"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("South.Park.1997"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("Linkin.Park.1997"); } [Test] - public void should_replace_episode_title() + public void should_replace_Artist_Disambiguation() { - _namingConfig.StandardEpisodeFormat = "{Episode Title}"; + _namingConfig.StandardTrackFormat = "{Artist Disambiguation}"; - Subject.BuildFileName(new List {_episode1}, _series, _episodeFile) - .Should().Be("City Sushi"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("US Rock Band"); } [Test] - public void should_replace_episode_title_if_pattern_has_random_casing() + public void should_replace_Album_space_Title() { - _namingConfig.StandardEpisodeFormat = "{ePisOde-TitLe}"; + _namingConfig.StandardTrackFormat = "{Album Title}"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("City-Sushi"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("Hybrid Theory"); } [Test] - public void should_replace_season_number_with_single_digit() + public void should_replace_Album_Type() { - _episode1.SeasonNumber = 1; - _namingConfig.StandardEpisodeFormat = "{season}x{episode}"; + _namingConfig.StandardTrackFormat = "{Album Type}"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("1x6"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("Album"); } [Test] - public void should_replace_season00_number_with_two_digits() + public void should_replace_Album_Disambiguation() { - _episode1.SeasonNumber = 1; - _namingConfig.StandardEpisodeFormat = "{season:00}x{episode}"; + _namingConfig.StandardTrackFormat = "{Album Disambiguation}"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("01x6"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("The Best Album"); } [Test] - public void should_replace_episode_number_with_single_digit() + public void should_replace_Album_underscore_Title() { - _episode1.SeasonNumber = 1; - _namingConfig.StandardEpisodeFormat = "{season}x{episode}"; + _namingConfig.StandardTrackFormat = "{Album_Title}"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("1x6"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("Hybrid_Theory"); } [Test] - public void should_replace_episode00_number_with_two_digits() + public void should_replace_Album_dot_Title() { - _episode1.SeasonNumber = 1; - _namingConfig.StandardEpisodeFormat = "{season}x{episode:00}"; + _namingConfig.StandardTrackFormat = "{Album.Title}"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("1x06"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("Hybrid.Theory"); } [Test] - public void should_replace_quality_title() + public void should_replace_Album_dash_Title() { - _namingConfig.StandardEpisodeFormat = "{Quality Title}"; + _namingConfig.StandardTrackFormat = "{Album-Title}"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("HDTV-720p"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("Hybrid-Theory"); } [Test] - public void should_replace_quality_proper_with_proper() + public void should_replace_ALBUM_TITLE_with_all_caps() { - _namingConfig.StandardEpisodeFormat = "{Quality Proper}"; - GivenProper(); + _namingConfig.StandardTrackFormat = "{ALBUM TITLE}"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("Proper"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("HYBRID THEORY"); } [Test] - public void should_replace_quality_real_with_real() + public void should_replace_ALBUM_TITLE_with_random_casing_should_keep_original_casing() { - _namingConfig.StandardEpisodeFormat = "{Quality Real}"; - GivenReal(); + _namingConfig.StandardTrackFormat = "{aLbUM-tItLE}"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("REAL"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be(_album.Title.Replace(' ', '-')); } [Test] - public void should_replace_all_contents_in_pattern() + public void should_replace_album_title_with_all_lower_case() { - _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} [{Quality Title}]"; + _namingConfig.StandardTrackFormat = "{album title}"; - Subject.BuildFileName(new List {_episode1}, _series, _episodeFile) - .Should().Be("South Park - S15E06 - City Sushi [HDTV-720p]"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("hybrid theory"); } [Test] - public void use_file_name_when_sceneName_is_null() + public void should_cleanup_Album_Title() { - _namingConfig.RenameEpisodes = false; - _episodeFile.RelativePath = "30 Rock - S01E01 - Test"; + _namingConfig.StandardTrackFormat = "{Artist.CleanName}"; + _artist.Name = "Hybrid Theory (2000)"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be(Path.GetFileNameWithoutExtension(_episodeFile.RelativePath)); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("Hybrid.Theory.2000"); } [Test] - public void use_path_when_sceneName_and_relative_path_are_null() + public void should_replace_track_title() { - _namingConfig.RenameEpisodes = false; - _episodeFile.RelativePath = null; - _episodeFile.Path = @"C:\Test\Unsorted\Series - S01E01 - Test"; - - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be(Path.GetFileNameWithoutExtension(_episodeFile.Path)); - } + _namingConfig.StandardTrackFormat = "{Track Title}"; - [Test] - public void use_file_name_when_sceneName_is_not_null() - { - _namingConfig.RenameEpisodes = false; - _episodeFile.SceneName = "30.Rock.S01E01.xvid-LOL"; - _episodeFile.RelativePath = "30 Rock - S01E01 - Test"; - - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("30.Rock.S01E01.xvid-LOL"); + Subject.BuildTrackFileName(new List {_track1}, _artist, _album, _trackFile) + .Should().Be("City Sushi"); } [Test] - public void should_use_airDate_if_series_isDaily() + public void should_replace_track_title_if_pattern_has_random_casing() { - _namingConfig.DailyEpisodeFormat = "{Series Title} - {air-date} - {Episode Title}"; - - _series.Title = "The Daily Show with Jon Stewart"; - _series.SeriesType = SeriesTypes.Daily; - - _episode1.AirDate = "2012-12-13"; - _episode1.Title = "Kristen Stewart"; + _namingConfig.StandardTrackFormat = "{tRaCK-TitLe}"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("The Daily Show with Jon Stewart - 2012-12-13 - Kristen Stewart"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("City-Sushi"); } [Test] - public void should_set_airdate_to_unknown_if_not_available() + public void should_replace_track_number_with_single_digit() { - _namingConfig.DailyEpisodeFormat = "{Series Title} - {Air-Date} - {Episode Title}"; - - _series.Title = "The Daily Show with Jon Stewart"; - _series.SeriesType = SeriesTypes.Daily; - - _episode1.AirDate = null; - _episode1.Title = "Kristen Stewart"; + _track1.AbsoluteTrackNumber = 1; + _namingConfig.StandardTrackFormat = "{track}"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("The Daily Show with Jon Stewart - Unknown - Kristen Stewart"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("1"); } [Test] - public void should_not_clean_episode_title_if_there_is_only_one() + public void should_replace_track00_number_with_two_digits() { - var title = "City Sushi (1)"; - _episode1.Title = title; - - _namingConfig.StandardEpisodeFormat = "{Episode Title}"; + _track1.AbsoluteTrackNumber = 1; + _namingConfig.StandardTrackFormat = "{track:00}"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be(title); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("01"); } [Test] - public void should_should_replace_release_group() + public void should_replace_medium_number_with_single_digit() { - _namingConfig.StandardEpisodeFormat = "{Release Group}"; + _namingConfig.StandardTrackFormat = "{medium}"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be(_episodeFile.ReleaseGroup); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("3"); } [Test] - public void should_be_able_to_use_original_title() + public void should_replace_medium00_number_with_two_digits() { - _series.Title = "30 Rock"; - _namingConfig.StandardEpisodeFormat = "{Series Title} - {Original Title}"; + _namingConfig.StandardTrackFormat = "{medium:00}"; - _episodeFile.SceneName = "30.Rock.S01E01.xvid-LOL"; - _episodeFile.RelativePath = "30 Rock - S01E01 - Test"; - - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("30 Rock - 30.Rock.S01E01.xvid-LOL"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("03"); } [Test] - public void should_trim_periods_from_end_of_episode_title() + public void should_replace_quality_title() { - _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title}"; - _namingConfig.MultiEpisodeStyle = 3; - - var episode = Builder.CreateNew() - .With(e => e.Title = "Part 1.") - .With(e => e.SeasonNumber = 6) - .With(e => e.EpisodeNumber = 6) - .Build(); - + _namingConfig.StandardTrackFormat = "{Quality Title}"; - Subject.BuildFileName(new List { episode }, new Series { Title = "30 Rock" }, _episodeFile) - .Should().Be("30 Rock - S06E06 - Part 1"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("MP3-256"); } [Test] - public void should_trim_question_marks_from_end_of_episode_title() + public void should_replace_media_info_audio_codec() { - _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title}"; - _namingConfig.MultiEpisodeStyle = 3; + _namingConfig.StandardTrackFormat = "{MediaInfo AudioCodec}"; - var episode = Builder.CreateNew() - .With(e => e.Title = "Part 1?") - .With(e => e.SeasonNumber = 6) - .With(e => e.EpisodeNumber = 6) - .Build(); - - - Subject.BuildFileName(new List { episode }, new Series { Title = "30 Rock" }, _episodeFile) - .Should().Be("30 Rock - S06E06 - Part 1"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("FLAC"); } [Test] - public void should_replace_double_period_with_single_period() + public void should_replace_media_info_audio_bitrate() { - _namingConfig.StandardEpisodeFormat = "{Series.Title}.S{season:00}E{episode:00}.{Episode.Title}"; + _namingConfig.StandardTrackFormat = "{MediaInfo AudioBitRate}"; - var episode = Builder.CreateNew() - .With(e => e.Title = "Part 1") - .With(e => e.SeasonNumber = 6) - .With(e => e.EpisodeNumber = 6) - .Build(); - - Subject.BuildFileName(new List { episode }, new Series { Title = "Chicago P.D." }, _episodeFile) - .Should().Be("Chicago.P.D.S06E06.Part.1"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("320 kbps"); } [Test] - public void should_replace_triple_period_with_single_period() + public void should_replace_media_info_audio_channels() { - _namingConfig.StandardEpisodeFormat = "{Series.Title}.S{season:00}E{episode:00}.{Episode.Title}"; - - var episode = Builder.CreateNew() - .With(e => e.Title = "Part 1") - .With(e => e.SeasonNumber = 6) - .With(e => e.EpisodeNumber = 6) - .Build(); + _namingConfig.StandardTrackFormat = "{MediaInfo AudioChannels}"; - Subject.BuildFileName(new List { episode }, new Series { Title = "Chicago P.D.." }, _episodeFile) - .Should().Be("Chicago.P.D.S06E06.Part.1"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("2.0"); } [Test] - public void should_not_replace_absolute_numbering_when_series_is_not_anime() + public void should_replace_media_info_bits_per_sample() { - _namingConfig.StandardEpisodeFormat = "{Series.Title}.S{season:00}E{episode:00}.{absolute:00}.{Episode.Title}"; + _namingConfig.StandardTrackFormat = "{MediaInfo AudioBitsPerSample}"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("South.Park.S15E06.City.Sushi"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("16bit"); } [Test] - public void should_replace_standard_and_absolute_numbering_when_series_is_anime() + public void should_replace_media_info_sample_rate() { - _series.SeriesType = SeriesTypes.Anime; - _namingConfig.AnimeEpisodeFormat = "{Series.Title}.S{season:00}E{episode:00}.{absolute:00}.{Episode.Title}"; + _namingConfig.StandardTrackFormat = "{MediaInfo AudioSampleRate}"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("South.Park.S15E06.100.City.Sushi"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("44.1kHz"); } [Test] - public void should_replace_standard_numbering_when_series_is_anime() + public void should_replace_all_contents_in_pattern() { - _series.SeriesType = SeriesTypes.Anime; - _namingConfig.AnimeEpisodeFormat = "{Series.Title}.S{season:00}E{episode:00}.{Episode.Title}"; + _namingConfig.StandardTrackFormat = "{Artist Name} - {Album Title} - {track:00} - {Track Title} [{Quality Title}]"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("South.Park.S15E06.City.Sushi"); + Subject.BuildTrackFileName(new List {_track1}, _artist, _album, _trackFile) + .Should().Be("Linkin Park - Hybrid Theory - 06 - City Sushi [MP3-256]"); } [Test] - public void should_replace_absolute_numbering_when_series_is_anime() + public void use_file_name_when_sceneName_is_null() { - _series.SeriesType = SeriesTypes.Anime; - _namingConfig.AnimeEpisodeFormat = "{Series.Title}.{absolute:00}.{Episode.Title}"; + _namingConfig.RenameTracks = false; + _trackFile.Path = "Linkin Park - 06 - Test"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("South.Park.100.City.Sushi"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be(Path.GetFileNameWithoutExtension(_trackFile.Path)); } - + [Test] - public void should_replace_duplicate_numbering_individually() + public void use_file_name_when_sceneName_is_not_null() { - _series.SeriesType = SeriesTypes.Anime; - _namingConfig.AnimeEpisodeFormat = "{Series.Title}.{season}x{episode:00}.{absolute:000}\\{Series.Title}.S{season:00}E{episode:00}.{absolute:00}.{Episode.Title}"; + _namingConfig.RenameTracks = false; + _trackFile.Path = "Linkin Park - 06 - Test"; + _trackFile.SceneName = "SceneName"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("South.Park.15x06.100\\South.Park.S15E06.100.City.Sushi"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be(Path.GetFileNameWithoutExtension(_trackFile.Path)); } [Test] - public void should_replace_individual_season_episode_tokens() + public void use_path_when_sceneName_and_relative_path_are_null() { - _series.SeriesType = SeriesTypes.Anime; - _namingConfig.AnimeEpisodeFormat = "{Series Title} Season {season:0000} Episode {episode:0000}\\{Series.Title}.S{season:00}E{episode:00}.{absolute:00}.{Episode.Title}"; + _namingConfig.RenameTracks = false; + _trackFile.Path = @"C:\Test\Unsorted\Artist - 01 - Test"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("South Park Season 0015 Episode 0006\\South.Park.S15E06.100.City.Sushi"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be(Path.GetFileNameWithoutExtension(_trackFile.Path)); } [Test] - public void should_use_standard_naming_when_anime_episode_has_no_absolute_number() + public void should_not_clean_track_title_if_there_is_only_one() { - _series.SeriesType = SeriesTypes.Anime; - _episode1.AbsoluteEpisodeNumber = null; + var title = "City Sushi (1)"; + _track1.Title = title; - _namingConfig.StandardEpisodeFormat = "{Series Title} - {season:0}x{episode:00} - {Episode Title}"; - _namingConfig.AnimeEpisodeFormat = "{Series Title} - {absolute:000} - {Episode Title}"; + _namingConfig.StandardTrackFormat = "{Track Title}"; - Subject.BuildFileName(new List { _episode1, }, _series, _episodeFile) - .Should().Be("South Park - 15x06 - City Sushi"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be(title); } [Test] - public void should_include_affixes_if_value_not_empty() + public void should_should_replace_release_group() { - _namingConfig.StandardEpisodeFormat = "{Series.Title}.S{season:00}E{episode:00}{_Episode.Title_}{Quality.Title}"; - - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("South.Park.S15E06_City.Sushi_HDTV-720p"); + _namingConfig.StandardTrackFormat = "{Release Group}"; + + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be(_trackFile.ReleaseGroup); } [Test] - public void should_not_include_affixes_if_value_empty() + public void should_be_able_to_use_original_title() { - _namingConfig.StandardEpisodeFormat = "{Series.Title}.S{season:00}E{episode:00}{_Episode.Title_}"; + _artist.Name = "Linkin Park"; + _namingConfig.StandardTrackFormat = "{Artist Name} - {Original Title} - {Track Title}"; - _episode1.Title = ""; + _trackFile.SceneName = "Linkin.Park.Meteora.320-LOL"; + _trackFile.Path = "30 Rock - 01 - Test"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("South.Park.S15E06"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("Linkin Park - Linkin.Park.Meteora.320-LOL - City Sushi"); } [Test] - public void should_format_mediainfo_properly() + public void should_replace_double_period_with_single_period() { - _namingConfig.StandardEpisodeFormat = "{Series.Title}.S{season:00}E{episode:00}.{Episode.Title}.{MEDIAINFO.FULL}"; + _namingConfig.StandardTrackFormat = "{Artist.Name}.{track:00}.{Track.Title}"; - _episodeFile.MediaInfo = new Core.MediaFiles.MediaInfo.MediaInfoModel() - { - VideoCodec = "AVC", - AudioFormat = "DTS", - AudioLanguages = "English/Spanish", - Subtitles = "English/Spanish/Italian" - }; + var track = Builder.CreateNew() + .With(e => e.Title = "Part 1") + .With(e => e.AbsoluteTrackNumber = 6) + .With(e => e.AlbumRelease = _release) + .Build(); - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("South.Park.S15E06.City.Sushi.X264.DTS[EN+ES].[EN+ES+IT]"); + Subject.BuildTrackFileName(new List { track }, new Artist { Name = "In The Woods." }, new Album { Title = "30 Rock" }, _trackFile) + .Should().Be("In.The.Woods.06.Part.1"); } [Test] - public void should_exclude_english_in_mediainfo_audio_language() + public void should_replace_triple_period_with_single_period() { - _namingConfig.StandardEpisodeFormat = "{Series.Title}.S{season:00}E{episode:00}.{Episode.Title}.{MEDIAINFO.FULL}"; + _namingConfig.StandardTrackFormat = "{Artist.Name}.{track:00}.{Track.Title}"; - _episodeFile.MediaInfo = new Core.MediaFiles.MediaInfo.MediaInfoModel() - { - VideoCodec = "AVC", - AudioFormat = "DTS", - AudioLanguages = "English", - Subtitles = "English/Spanish/Italian" - }; + var track = Builder.CreateNew() + .With(e => e.Title = "Part 1") + .With(e => e.AbsoluteTrackNumber = 6) + .With(e => e.AlbumRelease = _release) + .Build(); - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("South.Park.S15E06.City.Sushi.X264.DTS.[EN+ES+IT]"); + Subject.BuildTrackFileName(new List { track }, new Artist { Name = "In The Woods..." }, new Album { Title = "30 Rock" }, _trackFile) + .Should().Be("In.The.Woods.06.Part.1"); } [Test] - public void should_remove_duplicate_non_word_characters() + public void should_include_affixes_if_value_not_empty() { - _series.Title = "Venture Bros."; - _namingConfig.StandardEpisodeFormat = "{Series.Title}.{season}x{episode:00}"; - - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("Venture.Bros.15x06"); + _namingConfig.StandardTrackFormat = "{Artist.Name}.{track:00}{_Track.Title_}{Quality.Title}"; + + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("Linkin.Park.06_City.Sushi_MP3-256"); } [Test] - public void should_use_existing_filename_when_scene_name_is_not_available() + public void should_not_include_affixes_if_value_empty() { - _namingConfig.RenameEpisodes = true; - _namingConfig.StandardEpisodeFormat = "{Original Title}"; + _namingConfig.StandardTrackFormat = "{Artist.Name}.{track:00}{_Track.Title_}"; - _episodeFile.SceneName = null; - _episodeFile.RelativePath = "existing.file.mkv"; + _track1.Title = ""; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be(Path.GetFileNameWithoutExtension(_episodeFile.RelativePath)); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("Linkin.Park.06"); } [Test] - public void should_be_able_to_use_only_original_title() + public void should_remove_duplicate_non_word_characters() { - _series.Title = "30 Rock"; - _namingConfig.StandardEpisodeFormat = "{Original Title}"; - - _episodeFile.SceneName = "30.Rock.S01E01.xvid-LOL"; - _episodeFile.RelativePath = "30 Rock - S01E01 - Test"; + _artist.Name = "Venture Bros."; + _namingConfig.StandardTrackFormat = "{Artist.Name}.{Album.Title}-{track:00}"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("30.Rock.S01E01.xvid-LOL"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("Venture.Bros.Hybrid.Theory-06"); } [Test] - public void should_allow_period_between_season_and_episode() + public void should_use_existing_filename_when_scene_name_is_not_available() { - _namingConfig.StandardEpisodeFormat = "{Series.Title}.S{season:00}.E{episode:00}.{Episode.Title}"; + _namingConfig.RenameTracks = true; + _namingConfig.StandardTrackFormat = "{Original Title}"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("South.Park.S15.E06.City.Sushi"); - } + _trackFile.SceneName = null; + _trackFile.Path = "existing.file.mkv"; - [Test] - public void should_allow_space_between_season_and_episode() - { - _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00} E{episode:00} - {Episode Title}"; - - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("South Park - S15 E06 - City Sushi"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be(Path.GetFileNameWithoutExtension(_trackFile.Path)); } [Test] - public void should_replace_quality_proper_with_v2_for_anime_v2() + public void should_be_able_to_use_only_original_title() { - _series.SeriesType = SeriesTypes.Anime; - _namingConfig.AnimeEpisodeFormat = "{Quality Proper}"; + _artist.Name = "30 Rock"; + _namingConfig.StandardTrackFormat = "{Original Title}"; - GivenProper(); + _trackFile.SceneName = "30.Rock.S01E01.xvid-LOL"; + _trackFile.Path = "30 Rock - S01E01 - Test"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("v2"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("30.Rock.S01E01.xvid-LOL"); } [Test] public void should_not_include_quality_proper_when_release_is_not_a_proper() { - _namingConfig.StandardEpisodeFormat = "{Quality Title} {Quality Proper}"; - - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("HDTV-720p"); - } - - [Test] - public void should_wrap_proper_in_square_brackets() - { - _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} [{Quality Title}] {[Quality Proper]}"; + _namingConfig.StandardTrackFormat = "{Quality Title} {Quality Proper}"; - GivenProper(); - - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("South Park - S15E06 [HDTV-720p] [Proper]"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("MP3-256"); } [Test] public void should_not_wrap_proper_in_square_brackets_when_not_a_proper() { - _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} [{Quality Title}] {[Quality Proper]}"; + _namingConfig.StandardTrackFormat = "{Artist Name} - {track:00} [{Quality Title}] {[Quality Proper]}"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("South Park - S15E06 [HDTV-720p]"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("Linkin Park - 06 [MP3-256]"); } [Test] public void should_replace_quality_full_with_quality_title_only_when_not_a_proper() { - _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} [{Quality Full}]"; - - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("South Park - S15E06 [HDTV-720p]"); - } - - [Test] - public void should_replace_quality_full_with_quality_title_and_proper_only_when_a_proper() - { - _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} [{Quality Full}]"; - - GivenProper(); - - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("South Park - S15E06 [HDTV-720p Proper]"); - } - - [Test] - public void should_replace_quality_full_with_quality_title_and_real_when_a_real() - { - _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} [{Quality Full}]"; - GivenReal(); + _namingConfig.StandardTrackFormat = "{Artist Name} - {track:00} [{Quality Full}]"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("South Park - S15E06 [HDTV-720p REAL]"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("Linkin Park - 06 [MP3-256]"); } [TestCase(' ')] @@ -647,10 +571,10 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests [TestCase('_')] public void should_trim_extra_separators_from_end_when_quality_proper_is_not_included(char separator) { - _namingConfig.StandardEpisodeFormat = string.Format("{{Quality{0}Title}}{0}{{Quality{0}Proper}}", separator); + _namingConfig.StandardTrackFormat = string.Format("{{Quality{0}Title}}{0}{{Quality{0}Proper}}", separator); - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("HDTV-720p"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("MP3-256"); } [TestCase(' ')] @@ -659,67 +583,59 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests [TestCase('_')] public void should_trim_extra_separators_from_middle_when_quality_proper_is_not_included(char separator) { - _namingConfig.StandardEpisodeFormat = string.Format("{{Quality{0}Title}}{0}{{Quality{0}Proper}}{0}{{Episode{0}Title}}", separator); + _namingConfig.StandardTrackFormat = string.Format("{{Quality{0}Title}}{0}{{Quality{0}Proper}}{0}{{Track{0}Title}}", separator); - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be(string.Format("HDTV-720p{0}City{0}Sushi", separator)); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be(string.Format("MP3-256{0}City{0}Sushi", separator)); } - [Test] - public void should_not_require_a_separator_between_tokens() - { - _series.SeriesType = SeriesTypes.Anime; - _namingConfig.AnimeEpisodeFormat = "[{Release Group}]{Series.CleanTitle}.{absolute:000}"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("[SonarrTest]South.Park.100"); - } [Test] public void should_be_able_to_use_original_filename() { - _series.Title = "30 Rock"; - _namingConfig.StandardEpisodeFormat = "{Series Title} - {Original Filename}"; + _artist.Name = "30 Rock"; + _namingConfig.StandardTrackFormat = "{Artist Name} - {Original Filename}"; - _episodeFile.SceneName = "30.Rock.S01E01.xvid-LOL"; - _episodeFile.RelativePath = "30 Rock - S01E01 - Test"; + _trackFile.SceneName = "30.Rock.S01E01.xvid-LOL"; + _trackFile.Path = "30 Rock - S01E01 - Test"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) .Should().Be("30 Rock - 30 Rock - S01E01 - Test"); } [Test] public void should_be_able_to_use_original_filename_only() { - _series.Title = "30 Rock"; - _namingConfig.StandardEpisodeFormat = "{Original Filename}"; + _artist.Name = "30 Rock"; + _namingConfig.StandardTrackFormat = "{Original Filename}"; - _episodeFile.SceneName = "30.Rock.S01E01.xvid-LOL"; - _episodeFile.RelativePath = "30 Rock - S01E01 - Test"; + _trackFile.SceneName = "30.Rock.S01E01.xvid-LOL"; + _trackFile.Path = "30 Rock - S01E01 - Test"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) .Should().Be("30 Rock - S01E01 - Test"); } [Test] - public void should_use_Sonarr_as_release_group_when_not_available() + public void should_use_Lidarr_as_release_group_when_not_available() { - _episodeFile.ReleaseGroup = null; - _namingConfig.StandardEpisodeFormat = "{Release Group}"; + _trackFile.ReleaseGroup = null; + _namingConfig.StandardTrackFormat = "{Release Group}"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("Sonarr"); + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) + .Should().Be("Lidarr"); } - [TestCase("{Episode Title}{-Release Group}", "City Sushi")] - [TestCase("{Episode Title}{ Release Group}", "City Sushi")] - [TestCase("{Episode Title}{ [Release Group]}", "City Sushi")] - public void should_not_use_Sonarr_as_release_group_if_pattern_has_separator(string pattern, string expectedFileName) + [TestCase("{Track Title}{-Release Group}", "City Sushi")] + [TestCase("{Track Title}{ Release Group}", "City Sushi")] + [TestCase("{Track Title}{ [Release Group]}", "City Sushi")] + public void should_not_use_Lidarr_as_release_group_if_pattern_has_separator(string pattern, string expectedFileName) { - _episodeFile.ReleaseGroup = null; - _namingConfig.StandardEpisodeFormat = pattern; + _trackFile.ReleaseGroup = null; + _namingConfig.StandardTrackFormat = pattern; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) .Should().Be(expectedFileName); } @@ -728,10 +644,10 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests [TestCase("IMMERSE")] public void should_use_existing_casing_for_release_group(string releaseGroup) { - _episodeFile.ReleaseGroup = releaseGroup; - _namingConfig.StandardEpisodeFormat = "{Release Group}"; + _trackFile.ReleaseGroup = releaseGroup; + _namingConfig.StandardTrackFormat = "{Release Group}"; - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) + Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) .Should().Be(releaseGroup); } } diff --git a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/MultiEpisodeFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/MultiEpisodeFixture.cs deleted file mode 100644 index fd02cf413..000000000 --- a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/MultiEpisodeFixture.cs +++ /dev/null @@ -1,271 +0,0 @@ -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 MultiEpisodeFixture : CoreTest - { - private Series _series; - private Episode _episode1; - private Episode _episode2; - private Episode _episode3; - private EpisodeFile _episodeFile; - private NamingConfig _namingConfig; - - [SetUp] - public void Setup() - { - _series = Builder - .CreateNew() - .With(s => s.Title = "South Park") - .Build(); - - - _namingConfig = NamingConfig.Default; - _namingConfig.RenameEpisodes = true; - - - Mocker.GetMock() - .Setup(c => c.GetConfig()).Returns(_namingConfig); - - _episode1 = Builder.CreateNew() - .With(e => e.Title = "City Sushi") - .With(e => e.SeasonNumber = 15) - .With(e => e.EpisodeNumber = 6) - .With(e => e.AbsoluteEpisodeNumber = 100) - .Build(); - - _episode2 = Builder.CreateNew() - .With(e => e.Title = "City Sushi") - .With(e => e.SeasonNumber = 15) - .With(e => e.EpisodeNumber = 7) - .With(e => e.AbsoluteEpisodeNumber = 101) - .Build(); - - _episode3 = Builder.CreateNew() - .With(e => e.Title = "City Sushi") - .With(e => e.SeasonNumber = 15) - .With(e => e.EpisodeNumber = 8) - .With(e => e.AbsoluteEpisodeNumber = 102) - .Build(); - - _episodeFile = new EpisodeFile { Quality = new QualityModel(Quality.HDTV720p), ReleaseGroup = "SonarrTest" }; - - Mocker.GetMock() - .Setup(v => v.Get(Moq.It.IsAny())) - .Returns(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v)); - } - - private void GivenProper() - { - _episodeFile.Quality.Revision.Version = 2; - } - - [Test] - public void should_replace_Series_space_Title() - { - _namingConfig.StandardEpisodeFormat = "{Series Title}"; - - Subject.BuildFileName(new List {_episode1}, _series, _episodeFile) - .Should().Be("South Park"); - } - - [Test] - public void should_format_extend_multi_episode_properly() - { - _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title}"; - _namingConfig.MultiEpisodeStyle = 0; - - Subject.BuildFileName(new List {_episode1, _episode2}, _series, _episodeFile) - .Should().Be("South Park - S15E06-07 - City Sushi"); - } - - [Test] - public void should_format_duplicate_multi_episode_properly() - { - _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title}"; - _namingConfig.MultiEpisodeStyle = 1; - - Subject.BuildFileName(new List { _episode1, _episode2 }, _series, _episodeFile) - .Should().Be("South Park - S15E06 - S15E07 - City Sushi"); - } - - [Test] - public void should_format_repeat_multi_episode_properly() - { - _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title}"; - _namingConfig.MultiEpisodeStyle = 2; - - Subject.BuildFileName(new List { _episode1, _episode2 }, _series, _episodeFile) - .Should().Be("South Park - S15E06E07 - City Sushi"); - } - - [Test] - public void should_format_scene_multi_episode_properly() - { - _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title}"; - _namingConfig.MultiEpisodeStyle = 3; - - Subject.BuildFileName(new List { _episode1, _episode2 }, _series, _episodeFile) - .Should().Be("South Park - S15E06-E07 - City Sushi"); - } - - [Test] - public void should_use_dash_as_separator_when_multi_episode_style_is_extend_for_anime() - { - _series.SeriesType = SeriesTypes.Anime; - _namingConfig.AnimeEpisodeFormat = "{Series Title} - {absolute:000} - {Episode Title}"; - - Subject.BuildFileName(new List { _episode1, _episode2 }, _series, _episodeFile) - .Should().Be("South Park - 100-101 - City Sushi"); - } - - [Test] - public void should_duplicate_absolute_pattern_when_multi_episode_style_is_duplicate() - { - _series.SeriesType = SeriesTypes.Anime; - _namingConfig.MultiEpisodeStyle = (int)MultiEpisodeStyle.Duplicate; - _namingConfig.AnimeEpisodeFormat = "{Series Title} - {absolute:000} - {Episode Title}"; - - Subject.BuildFileName(new List { _episode1, _episode2, _episode3 }, _series, _episodeFile) - .Should().Be("South Park - 100 - 101 - 102 - City Sushi"); - } - - [Test] - public void should_get_proper_filename_when_multi_episode_is_duplicated_and_bracket_follows_pattern() - { - _namingConfig.StandardEpisodeFormat = - "{Series Title} - S{season:00}E{episode:00} - ({Quality Title}, {MediaInfo Full}, {Release Group}) - {Episode Title}"; - _namingConfig.MultiEpisodeStyle = (int) MultiEpisodeStyle.Duplicate; - - Subject.BuildFileName(new List { _episode1, _episode2 }, _series, _episodeFile) - .Should().Be("South Park - S15E06 - S15E07 - (HDTV-720p, , SonarrTest) - City Sushi"); - } - - [Test] - public void should_format_range_multi_episode_properly() - { - _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title}"; - _namingConfig.MultiEpisodeStyle = 4; - - Subject.BuildFileName(new List { _episode1, _episode2, _episode3 }, _series, _episodeFile) - .Should().Be("South Park - S15E06-08 - City Sushi"); - } - - [Test] - public void should_format_range_multi_episode_anime_properly() - { - _series.SeriesType = SeriesTypes.Anime; - _namingConfig.MultiEpisodeStyle = 4; - _namingConfig.AnimeEpisodeFormat = "{Series Title} - {absolute:000} - {Episode Title}"; - - Subject.BuildFileName(new List { _episode1, _episode2, _episode3 }, _series, _episodeFile) - .Should().Be("South Park - 100-102 - City Sushi"); - } - - [Test] - public void should_format_repeat_multi_episode_anime_properly() - { - _series.SeriesType = SeriesTypes.Anime; - _namingConfig.MultiEpisodeStyle = 2; - _namingConfig.AnimeEpisodeFormat = "{Series Title} - {absolute:000} - {Episode Title}"; - - Subject.BuildFileName(new List { _episode1, _episode2, _episode3 }, _series, _episodeFile) - .Should().Be("South Park - 100-101-102 - City Sushi"); - } - - [Test] - public void should_format_single_episode_with_range_multi_episode_properly() - { - _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title}"; - _namingConfig.MultiEpisodeStyle = 4; - - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("South Park - S15E06 - City Sushi"); - } - - [Test] - public void should_format_single_anime_episode_with_range_multi_episode_properly() - { - _series.SeriesType = SeriesTypes.Anime; - _namingConfig.MultiEpisodeStyle = 4; - _namingConfig.AnimeEpisodeFormat = "{Series Title} - {absolute:000} - {Episode Title}"; - - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("South Park - 100 - City Sushi"); - } - - [Test] - public void should_default_to_dash_when_serparator_is_not_set_for_absolute_number() - { - _series.SeriesType = SeriesTypes.Anime; - _namingConfig.MultiEpisodeStyle = (int)MultiEpisodeStyle.Duplicate; - _namingConfig.AnimeEpisodeFormat = "{Series Title} - {season}x{episode:00} - [{absolute:000}] - {Episode Title} - {Quality Title}"; - - Subject.BuildFileName(new List { _episode1, _episode2 }, _series, _episodeFile) - .Should().Be("South Park - 15x06 - 15x07 - [100-101] - City Sushi - HDTV-720p"); - } - - [Test] - public void should_format_prefixed_range_multi_episode_properly() - { - _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title}"; - _namingConfig.MultiEpisodeStyle = 5; - - Subject.BuildFileName(new List { _episode1, _episode2, _episode3 }, _series, _episodeFile) - .Should().Be("South Park - S15E06-E08 - City Sushi"); - } - - [Test] - public void should_format_prefixed_range_multi_episode_anime_properly() - { - _series.SeriesType = SeriesTypes.Anime; - _namingConfig.MultiEpisodeStyle = 5; - _namingConfig.AnimeEpisodeFormat = "{Series Title} - {absolute:000} - {Episode Title}"; - - Subject.BuildFileName(new List { _episode1, _episode2, _episode3 }, _series, _episodeFile) - .Should().Be("South Park - 100-102 - City Sushi"); - } - - [Test] - public void should_format_single_episode_with_prefixed_range_multi_episode_properly() - { - _namingConfig.StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title}"; - _namingConfig.MultiEpisodeStyle = 5; - - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("South Park - S15E06 - City Sushi"); - } - - [Test] - public void should_format_single_anime_episode_with_prefixed_range_multi_episode_properly() - { - _series.SeriesType = SeriesTypes.Anime; - _namingConfig.MultiEpisodeStyle = 5; - _namingConfig.AnimeEpisodeFormat = "{Series Title} - {absolute:000} - {Episode Title}"; - - Subject.BuildFileName(new List { _episode1 }, _series, _episodeFile) - .Should().Be("South Park - 100 - City Sushi"); - } - - [Test] - public void should_format_prefixed_range_multi_episode_using_episode_separator() - { - _namingConfig.StandardEpisodeFormat = "{Series Title} - {season:0}x{episode:00} - {Episode Title}"; - _namingConfig.MultiEpisodeStyle = 5; - - Subject.BuildFileName(new List { _episode1, _episode2, _episode3 }, _series, _episodeFile) - .Should().Be("South Park - 15x06-x08 - City Sushi"); - } - } -} 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..8b54bd11a --- /dev/null +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/TitleTheFixture.cs @@ -0,0 +1,94 @@ +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.Music; + +namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests +{ + [TestFixture] + public class TitleTheFixture : CoreTest + { + private Artist _artist; + private Album _album; + private AlbumRelease _release; + private Track _track; + private TrackFile _trackFile; + private NamingConfig _namingConfig; + + [SetUp] + public void Setup() + { + _artist = Builder + .CreateNew() + .With(s => s.Name = "Alien Ant Farm") + .Build(); + + _album = Builder + .CreateNew() + .With(s => s.Title = "Anthology") + .Build(); + + _release = Builder + .CreateNew() + .With(s => s.Media = new List { new Medium { Number = 1 } }) + .Build(); + + _track = Builder.CreateNew() + .With(e => e.Title = "City Sushi") + .With(e => e.AbsoluteTrackNumber = 6) + .With(e => e.AlbumRelease = _release) + .Build(); + + _trackFile = new TrackFile { Quality = new QualityModel(Quality.MP3_320), ReleaseGroup = "LidarrTest" }; + + _namingConfig = NamingConfig.Default; + _namingConfig.RenameTracks = 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 name, string expected) + { + _artist.Name = name; + _namingConfig.StandardTrackFormat = "{Artist NameThe}"; + + Subject.BuildTrackFileName(new List { _track }, _artist, _album, _trackFile) + .Should().Be(expected); + } + + [TestCase("A")] + [TestCase("Anne")] + [TestCase("Theodore")] + [TestCase("3%")] + public void should_not_change_title(string name) + { + _artist.Name = name; + _namingConfig.StandardTrackFormat = "{Artist NameThe}"; + + Subject.BuildTrackFileName(new List { _track }, _artist, _album, _trackFile) + .Should().Be(name); + } + } +} diff --git a/src/NzbDrone.Core.Test/OrganizerTests/GetAlbumFolderFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/GetAlbumFolderFixture.cs new file mode 100644 index 000000000..dec3d8318 --- /dev/null +++ b/src/NzbDrone.Core.Test/OrganizerTests/GetAlbumFolderFixture.cs @@ -0,0 +1,35 @@ +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Test.OrganizerTests +{ + [TestFixture] + public class GetAlbumFolderFixture : CoreTest + { + private NamingConfig namingConfig; + + [SetUp] + public void Setup() + { + namingConfig = NamingConfig.Default; + + Mocker.GetMock() + .Setup(c => c.GetConfig()).Returns(namingConfig); + } + + [TestCase("Venture Bros.", "Today", "{Artist.Name}.{Album.Title}", "Venture.Bros.Today")] + [TestCase("Venture Bros.", "Today", "{Artist Name} {Album Title}", "Venture Bros. Today")] + public void should_use_albumFolderFormat_to_build_folder_name(string artistName, string albumTitle, string format, string expected) + { + namingConfig.AlbumFolderFormat = format; + + var artist = new Artist { Name = artistName }; + var album = new Album { Title = albumTitle }; + + Subject.GetAlbumFolder(artist, album, namingConfig).Should().Be(expected); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/OrganizerTests/GetArtistFolderFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/GetArtistFolderFixture.cs new file mode 100644 index 000000000..1a9a2b400 --- /dev/null +++ b/src/NzbDrone.Core.Test/OrganizerTests/GetArtistFolderFixture.cs @@ -0,0 +1,39 @@ +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Test.OrganizerTests +{ + [TestFixture] + + public class GetArtistFolderFixture : CoreTest + { + private NamingConfig namingConfig; + + [SetUp] + public void Setup() + { + namingConfig = NamingConfig.Default; + + Mocker.GetMock() + .Setup(c => c.GetConfig()).Returns(namingConfig); + } + + [TestCase("Avenged Sevenfold", "{Artist Name}", "Avenged Sevenfold")] + [TestCase("Avenged Sevenfold", "{Artist.Name}", "Avenged.Sevenfold")] + [TestCase("AC/DC", "{Artist Name}", "AC+DC")] + [TestCase("In the Woods...", "{Artist.Name}", "In.the.Woods")] + [TestCase("3OH!3", "{Artist.Name}", "3OH!3")] + [TestCase("Avenged Sevenfold", ".{Artist.Name}.", "Avenged.Sevenfold")] + public void should_use_artistFolderFormat_to_build_folder_name(string artistName, string format, string expected) + { + namingConfig.ArtistFolderFormat = format; + + var artist = new Artist { Name = artistName }; + + Subject.GetArtistFolder(artist).Should().Be(expected); + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/OrganizerTests/GetSeasonFolderFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/GetSeasonFolderFixture.cs deleted file mode 100644 index 796a0881f..000000000 --- a/src/NzbDrone.Core.Test/OrganizerTests/GetSeasonFolderFixture.cs +++ /dev/null @@ -1,34 +0,0 @@ -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Organizer; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Test.OrganizerTests -{ - [TestFixture] - public class GetSeasonFolderFixture : CoreTest - { - private NamingConfig namingConfig; - - [SetUp] - public void Setup() - { - namingConfig = NamingConfig.Default; - - Mocker.GetMock() - .Setup(c => c.GetConfig()).Returns(namingConfig); - } - - [TestCase("Venture Bros.", 1, "{Series.Title}.{season:00}", "Venture.Bros.01")] - [TestCase("Venture Bros.", 1, "{Series Title} Season {season:00}", "Venture Bros. Season 01")] - public void should_use_seriesFolderFormat_to_build_folder_name(string seriesTitle, int seasonNumber, string format, string expected) - { - namingConfig.SeasonFolderFormat = format; - - var series = new Series { Title = seriesTitle }; - - Subject.GetSeasonFolder(series, seasonNumber, namingConfig).Should().Be(expected); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/OrganizerTests/GetSeriesFolderFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/GetSeriesFolderFixture.cs deleted file mode 100644 index 9cf0b5e01..000000000 --- a/src/NzbDrone.Core.Test/OrganizerTests/GetSeriesFolderFixture.cs +++ /dev/null @@ -1,39 +0,0 @@ -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Organizer; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Test.OrganizerTests -{ - [TestFixture] - - public class GetSeriesFolderFixture : CoreTest - { - private NamingConfig namingConfig; - - [SetUp] - public void Setup() - { - namingConfig = NamingConfig.Default; - - Mocker.GetMock() - .Setup(c => c.GetConfig()).Returns(namingConfig); - } - - [TestCase("30 Rock", "{Series Title}", "30 Rock")] - [TestCase("30 Rock", "{Series.Title}", "30.Rock")] - [TestCase("24/7 Road to the NHL Winter Classic", "{Series Title}", "24+7 Road to the NHL Winter Classic")] - [TestCase("Venture Bros.", "{Series.Title}", "Venture.Bros")] - [TestCase(".hack", "{Series.Title}", "hack")] - [TestCase("30 Rock", ".{Series.Title}.", "30.Rock")] - public void should_use_seriesFolderFormat_to_build_folder_name(string seriesTitle, string format, string expected) - { - namingConfig.SeriesFolderFormat = format; - - var series = new Series { Title = seriesTitle }; - - Subject.GetSeriesFolder(series).Should().Be(expected); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/ParserTests/AbsoluteEpisodeNumberParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/AbsoluteEpisodeNumberParserFixture.cs deleted file mode 100644 index 9cdbf08e4..000000000 --- a/src/NzbDrone.Core.Test/ParserTests/AbsoluteEpisodeNumberParserFixture.cs +++ /dev/null @@ -1,131 +0,0 @@ -using System.Linq; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.ParserTests -{ - - [TestFixture] - public class AbsoluteEpisodeNumberParserFixture : CoreTest - { - [TestCase("[SubDESU]_High_School_DxD_07_(1280x720_x264-AAC)_[6B7FD717]", "High School DxD", 7, 0, 0)] - [TestCase("[Chihiro]_Working!!_-_06_[848x480_H.264_AAC][859EEAFA]", "Working!!", 6, 0, 0)] - [TestCase("[Commie]_Senki_Zesshou_Symphogear_-_11_[65F220B4]", "Senki Zesshou Symphogear", 11, 0, 0)] - [TestCase("[Underwater]_Rinne_no_Lagrange_-_12_(720p)_[5C7BC4F9]", "Rinne no Lagrange", 12, 0, 0)] - [TestCase("[Commie]_Rinne_no_Lagrange_-_15_[E76552EA]", "Rinne no Lagrange", 15, 0, 0)] - [TestCase("[HorribleSubs]_Hunter_X_Hunter_-_33_[720p]", "Hunter X Hunter", 33, 0, 0)] - [TestCase("[HorribleSubs]_Fairy_Tail_-_145_[720p]", "Fairy Tail", 145, 0, 0)] - [TestCase("[HorribleSubs] Tonari no Kaibutsu-kun - 13 [1080p].mkv", "Tonari no Kaibutsu-kun", 13, 0, 0)] - [TestCase("[Doremi].Yes.Pretty.Cure.5.Go.Go!.31.[1280x720].[C65D4B1F].mkv", "Yes Pretty Cure 5 Go Go!", 31, 0, 0)] - [TestCase("[K-F] One Piece 214", "One Piece", 214, 0, 0)] - [TestCase("[K-F] One Piece S10E14 214", "One Piece", 214, 10, 14)] - [TestCase("[K-F] One Piece 10x14 214", "One Piece", 214, 10, 14)] - [TestCase("[K-F] One Piece 214 10x14", "One Piece", 214, 10, 14)] -// [TestCase("One Piece S10E14 214", "One Piece", 214, 10, 14)] -// [TestCase("One Piece 10x14 214", "One Piece", 214, 10, 14)] -// [TestCase("One Piece 214 10x14", "One Piece", 214, 10, 14)] -// [TestCase("214 One Piece 10x14", "One Piece", 214, 10, 14)] - [TestCase("Bleach - 031 - The Resolution to Kill [Lunar].avi", "Bleach", 31, 0, 0)] - [TestCase("Bleach - 031 - The Resolution to Kill [Lunar]", "Bleach", 31, 0, 0)] - [TestCase("[ACX]Hack Sign 01 Role Play [Kosaka] [9C57891E].mkv", "Hack Sign", 1, 0, 0)] - [TestCase("[SFW-sage] Bakuman S3 - 12 [720p][D07C91FC]", "Bakuman S3", 12, 0, 0)] - [TestCase("ducktales_e66_time_is_money_part_one_marking_time", "ducktales", 66, 0, 0)] - [TestCase("[Underwater-FFF] No Game No Life - 01 (720p) [27AAA0A0].mkv", "No Game No Life", 1, 0, 0)] - [TestCase("[FroZen] Miyuki - 23 [DVD][7F6170E6]", "Miyuki", 23, 0, 0)] - [TestCase("[Commie] Yowamushi Pedal - 32 [0BA19D5B]", "Yowamushi Pedal", 32, 0, 0)] - [TestCase("[Doki] Mahouka Koukou no Rettousei - 07 (1280x720 Hi10P AAC) [80AF7DDE]", "Mahouka Koukou no Rettousei", 7, 0, 0)] - [TestCase("[HorribleSubs] Yowamushi Pedal - 32 [480p]", "Yowamushi Pedal", 32, 0, 0)] - [TestCase("[CR] Sailor Moon - 004 [480p][48CE2D0F]", "Sailor Moon", 4, 0, 0)] - [TestCase("[Chibiki] Puchimas!! - 42 [360p][7A4FC77B]", "Puchimas!!", 42, 0, 0)] - [TestCase("[HorribleSubs] Yowamushi Pedal - 32 [1080p]", "Yowamushi Pedal", 32, 0, 0)] - [TestCase("[HorribleSubs] Love Live! S2 - 07 [720p]", "Love Live! S2", 7, 0, 0)] - [TestCase("[DeadFish] Onee-chan ga Kita - 09v2 [720p][AAC]", "Onee-chan ga Kita", 9, 0, 0)] - [TestCase("[Underwater-FFF] No Game No Life - 01 (720p) [27AAA0A0]", "No Game No Life", 1, 0, 0)] - [TestCase("[S-T-D] Soul Eater Not! - 06 (1280x720 10bit AAC) [59B3F2EA].mkv", "Soul Eater Not!", 6, 0, 0)] - [TestCase("No Game No Life - 010 (720p) [27AAA0A0].mkv", "No Game No Life", 10, 0, 0)] - [TestCase("Initial D Fifth Stage - 01 DVD - Central Anime", "Initial D Fifth Stage", 1, 0, 0)] - [TestCase("Initial_D_Fifth_Stage_-_01(DVD)_-_(Central_Anime)[5AF6F1E4].mkv", "Initial D Fifth Stage", 1, 0, 0)] - [TestCase("Initial_D_Fifth_Stage_-_02(DVD)_-_(Central_Anime)[0CA65F00].mkv", "Initial D Fifth Stage", 2, 0, 0)] - [TestCase("Initial D Fifth Stage - 03 DVD - Central Anime", "Initial D Fifth Stage", 3, 0, 0)] - [TestCase("Initial_D_Fifth_Stage_-_03(DVD)_-_(Central_Anime)[629BD592].mkv", "Initial D Fifth Stage", 3, 0, 0)] - [TestCase("Initial D Fifth Stage - 14 DVD - Central Anime", "Initial D Fifth Stage", 14, 0, 0)] - [TestCase("Initial_D_Fifth_Stage_-_14(DVD)_-_(Central_Anime)[0183D922].mkv", "Initial D Fifth Stage", 14, 0, 0)] -// [TestCase("Initial D - 4th Stage Ep 01.mkv", "Initial D - 4th Stage", 1, 0, 0)] - [TestCase("[ChihiroDesuYo].No.Game.No.Life.-.09.1280x720.10bit.AAC.[24CCE81D]", "No Game No Life", 9, 0, 0)] - [TestCase("Fairy Tail - 001 - Fairy Tail", "Fairy Tail", 001, 0, 0)] - [TestCase("Fairy Tail - 049 - The Day of Fated Meeting", "Fairy Tail", 049, 0, 0)] - [TestCase("Fairy Tail - 050 - Special Request Watch Out for the Guy You Like!", "Fairy Tail", 050, 0, 0)] - [TestCase("Fairy Tail - 099 - Natsu vs. Gildarts", "Fairy Tail", 099, 0, 0)] - [TestCase("Fairy Tail - 100 - Mest", "Fairy Tail", 100, 0, 0)] -// [TestCase("Fairy Tail - 101 - Mest", "Fairy Tail", 101, 0, 0)] //This gets caught up in the 'see' numbering - [TestCase("[Exiled-Destiny] Angel Beats Ep01 (D2201EC5).mkv", "Angel Beats", 1, 0, 0)] - [TestCase("[Commie] Nobunaga the Fool - 23 [5396CA24].mkv", "Nobunaga the Fool", 23, 0, 0)] - [TestCase("[FFF] Seikoku no Dragonar - 01 [1FB538B5].mkv", "Seikoku no Dragonar", 1, 0, 0)] - [TestCase("[Hatsuyuki]Fate_Zero-01[1280x720][122E6EF8]", "Fate Zero", 1, 0, 0)] - [TestCase("[CBM]_Monster_-_11_-_511_Kinderheim_[6C70C4E4].mkv", "Monster", 11, 0, 0)] - [TestCase("[HorribleSubs] Log Horizon 2 - 05 [720p].mkv", "Log Horizon 2", 5, 0, 0)] - [TestCase("[Commie] Log Horizon 2 - 05 [FCE4D070].mkv", "Log Horizon 2", 5, 0, 0)] - [TestCase("[DRONE]Series.Title.100", "Series Title", 100, 0, 0)] - [TestCase("[RlsGrp]Series.Title.2010.S01E01.001.HDTV-720p.x264-DTS", "Series Title 2010", 1, 1, 1)] - [TestCase("Dragon Ball Kai - 130 - Found You, Gohan! Harsh Training in the Kaioshin Realm! [Baaro][720p][5A1AD35B].mkv", "Dragon Ball Kai", 130, 0, 0)] - [TestCase("Dragon Ball Kai - 131 - A Merged Super-Warrior Is Born, His Name Is Gotenks!! [Baaro][720p][32E03F96].mkv", "Dragon Ball Kai", 131, 0, 0)] - [TestCase("[HorribleSubs] Magic Kaito 1412 - 01 [1080p]", "Magic Kaito 1412", 1, 0, 0)] - [TestCase("[Jumonji-Giri]_[F-B]_Kagihime_Monogatari_Eikyuu_Alice_Rondo_Ep04_(0b0e2c10).mkv", "Kagihime Monogatari Eikyuu Alice Rondo", 4, 0, 0)] - [TestCase("[Jumonji-Giri]_[F-B]_Kagihime_Monogatari_Eikyuu_Alice_Rondo_Ep08_(8246e542).mkv", "Kagihime Monogatari Eikyuu Alice Rondo", 8, 0, 0)] - [TestCase("Knights of Sidonia - 01 [1080p 10b DTSHD-MA eng sub].mkv", "Knights of Sidonia", 1, 0, 0)] - [TestCase("Series Title (2010) {01} Episode Title (1).hdtv-720p", "Series Title (2010)", 1, 0, 0)] - [TestCase("[HorribleSubs] Shirobako - 20 [720p].mkv", "Shirobako", 20, 0, 0)] - [TestCase("[Hatsuyuki] Dragon Ball Kai (2014) - 017 (115) [1280x720][B2CFBC0F]", "Dragon Ball Kai (2014)", 17, 0, 0)] - [TestCase("[Hatsuyuki] Dragon Ball Kai (2014) - 018 (116) [1280x720][C4A3B16E]", "Dragon Ball Kai (2014)", 18, 0, 0)] - [TestCase("Dragon Ball Kai (2014) - 39 (137) [v2][720p.HDTV][Unison Fansub]", "Dragon Ball Kai (2014)", 39, 0, 0)] - [TestCase("[HorribleSubs] Eyeshield 21 - 101 [480p].mkv", "Eyeshield 21", 101, 0, 0)] - [TestCase("[Cthuyuu].Taimadou.Gakuen.35.Shiken.Shoutai.-.03.[720p.H264.AAC][8AD82C3A]", "Taimadou Gakuen 35 Shiken Shoutai", 3, 0, 0)] - //[TestCase("Taimadou.Gakuen.35.Shiken.Shoutai.-.03.(1280x720.HEVC.AAC)", "Taimadou Gakuen 35 Shiken Shoutai", 3, 0, 0)] - [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("", "", 0, 0, 0)] - public void should_parse_absolute_numbers(string postTitle, string title, int absoluteEpisodeNumber, int seasonNumber, int episodeNumber) - { - var result = Parser.Parser.ParseTitle(postTitle); - result.Should().NotBeNull(); - result.AbsoluteEpisodeNumbers.Single().Should().Be(absoluteEpisodeNumber); - result.SeasonNumber.Should().Be(seasonNumber); - result.EpisodeNumbers.SingleOrDefault().Should().Be(episodeNumber); - result.SeriesTitle.Should().Be(title); - result.FullSeason.Should().BeFalse(); - } - - [TestCase("[DeadFish] Kenzen Robo Daimidaler - 01 - Special [BD][720p][AAC]", "Kenzen Robo Daimidaler", 1)] - [TestCase("[DeadFish] Kenzen Robo Daimidaler - 01 - OVA [BD][720p][AAC]", "Kenzen Robo Daimidaler", 1)] - [TestCase("[DeadFish] Kenzen Robo Daimidaler - 01 - OVD [BD][720p][AAC]", "Kenzen Robo Daimidaler", 1)] - public void should_parse_absolute_specials(string postTitle, string title, int absoluteEpisodeNumber) - { - var result = Parser.Parser.ParseTitle(postTitle); - result.Should().NotBeNull(); - result.AbsoluteEpisodeNumbers.Single().Should().Be(absoluteEpisodeNumber); - result.SeasonNumber.Should().Be(0); - result.EpisodeNumbers.SingleOrDefault().Should().Be(0); - result.SeriesTitle.Should().Be(title); - result.FullSeason.Should().BeFalse(); - result.Special.Should().BeTrue(); - } - - [TestCase("[ANBU-AonE]_Naruto_26-27_[F224EF26].avi", "Naruto", new[] { 26, 27 })] - [TestCase("[Doutei] Recently, My Sister is Unusual - 01-12 [BD][720p-AAC]", "Recently, My Sister is Unusual", new [] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 })] - [TestCase("Series Title (2010) - 01-02-03 - Episode Title (1) HDTV-720p", "Series Title (2010)", new [] { 1, 2, 3 })] - [TestCase("[RlsGrp] Series Title (2010) - S01E01-02-03 - 001-002-003 - Episode Title HDTV-720p v2", "Series Title (2010)", new[] { 1, 2, 3 })] - [TestCase("[RlsGrp] Series Title (2010) - S01E01-02 - 001-002 - Episode Title HDTV-720p v2", "Series Title (2010)", new[] { 1, 2 })] - [TestCase("Series Title (2010) - S01E01-02 (001-002) - Episode Title (1) HDTV-720p v2 [RlsGrp]", "Series Title (2010)", new[] { 1, 2 })] - [TestCase("[HorribleSubs] Haikyuu!! (01-25) [1080p] (Batch)", "Haikyuu!!", new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25 })] - public void should_parse_multi_episode_absolute_numbers(string postTitle, string title, int[] absoluteEpisodeNumbers) - { - var result = Parser.Parser.ParseTitle(postTitle); - result.Should().NotBeNull(); - result.AbsoluteEpisodeNumbers.Should().BeEquivalentTo(absoluteEpisodeNumbers); - result.SeriesTitle.Should().Be(title); - result.FullSeason.Should().BeFalse(); - } - } -} diff --git a/src/NzbDrone.Core.Test/ParserTests/AnimeMetadataParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/AnimeMetadataParserFixture.cs deleted file mode 100644 index 599da12aa..000000000 --- a/src/NzbDrone.Core.Test/ParserTests/AnimeMetadataParserFixture.cs +++ /dev/null @@ -1,34 +0,0 @@ -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.ParserTests -{ - - [TestFixture] - public class AnimeMetadataParserFixture : CoreTest - { - [TestCase("[SubDESU]_High_School_DxD_07_(1280x720_x264-AAC)_[6B7FD717]", "SubDESU", "6B7FD717")] - [TestCase("[Chihiro]_Working!!_-_06_[848x480_H.264_AAC][859EEAFA]", "Chihiro", "859EEAFA")] - [TestCase("[Underwater]_Rinne_no_Lagrange_-_12_(720p)_[5C7BC4F9]", "Underwater", "5C7BC4F9")] - [TestCase("[HorribleSubs]_Hunter_X_Hunter_-_33_[720p]", "HorribleSubs", "")] - [TestCase("[HorribleSubs] Tonari no Kaibutsu-kun - 13 [1080p].mkv", "HorribleSubs", "")] - [TestCase("[Doremi].Yes.Pretty.Cure.5.Go.Go!.31.[1280x720].[C65D4B1F].mkv", "Doremi", "C65D4B1F")] - [TestCase("[Doremi].Yes.Pretty.Cure.5.Go.Go!.31.[1280x720].[C65D4B1F]", "Doremi", "C65D4B1F")] - [TestCase("[Doremi].Yes.Pretty.Cure.5.Go.Go!.31.[1280x720].mkv", "Doremi", "")] - [TestCase("[K-F] One Piece 214", "K-F", "")] - [TestCase("[K-F] One Piece S10E14 214", "K-F", "")] - [TestCase("[K-F] One Piece 10x14 214", "K-F", "")] - [TestCase("[K-F] One Piece 214 10x14", "K-F", "")] - [TestCase("Bleach - 031 - The Resolution to Kill [Lunar].avi", "Lunar", "")] - [TestCase("[ACX]Hack Sign 01 Role Play [Kosaka] [9C57891E].mkv", "ACX", "9C57891E")] - [TestCase("[S-T-D] Soul Eater Not! - 06 (1280x720 10bit AAC) [59B3F2EA].mkv", "S-T-D", "59B3F2EA")] - public void should_parse_absolute_numbers(string postTitle, string subGroup, string hash) - { - var result = Parser.Parser.ParseTitle(postTitle); - result.Should().NotBeNull(); - result.ReleaseGroup.Should().Be(subGroup); - result.ReleaseHash.Should().Be(hash); - } - } -} diff --git a/src/NzbDrone.Core.Test/ParserTests/ArtistTitleInfoFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ArtistTitleInfoFixture.cs new file mode 100644 index 000000000..1fab6f605 --- /dev/null +++ b/src/NzbDrone.Core.Test/ParserTests/ArtistTitleInfoFixture.cs @@ -0,0 +1,67 @@ +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.ParserTests +{ + [TestFixture] + public class ArtistTitleInfoFixture : CoreTest + { + + // TODO: Redo this test and parsed info for Albums which do have a year association + [Test] + [Ignore("Artist Don't have year association thus we dont use this currently")] + public void should_have_year_zero_when_title_doesnt_have_a_year() + { + const string title = "Alien Ant Farm - TruAnt [Flac]"; + + var result = Parser.Parser.ParseAlbumTitle(title).ArtistTitleInfo; + + result.Year.Should().Be(0); + } + + [Test] + [Ignore("Artist Don't have year association thus we dont use this currently")] + public void should_have_same_title_for_title_and_title_without_year_when_title_doesnt_have_a_year() + { + const string title = "Alien Ant Farm - TruAnt [Flac]"; + + var result = Parser.Parser.ParseAlbumTitle(title).ArtistTitleInfo; + + result.Title.Should().Be(result.TitleWithoutYear); + } + + [Test] + [Ignore("Artist Don't have year association thus we dont use this currently")] + public void should_have_year_when_title_has_a_year() + { + const string title = "Alien Ant Farm - TruAnt [Flac]"; + + var result = Parser.Parser.ParseAlbumTitle(title).ArtistTitleInfo; + + result.Year.Should().Be(2004); + } + + [Test] + [Ignore("Artist Don't have year association thus we dont use this currently")] + public void should_have_year_in_title_when_title_has_a_year() + { + const string title = "Alien Ant Farm - TruAnt [Flac]"; + + var result = Parser.Parser.ParseAlbumTitle(title).ArtistTitleInfo; + + result.Title.Should().Be("House 2004"); + } + + [Test] + [Ignore("Artist Don't have year association thus we dont use this currently")] + public void should_title_without_year_should_not_contain_year() + { + const string title = "Alien Ant Farm - TruAnt [Flac]"; + + var result = Parser.Parser.ParseAlbumTitle(title).ArtistTitleInfo; + + result.TitleWithoutYear.Should().Be("House"); + } + } +} diff --git a/src/NzbDrone.Core.Test/ParserTests/CrapParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/CrapParserFixture.cs index e678bf6a1..1a0493273 100644 --- a/src/NzbDrone.Core.Test/ParserTests/CrapParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/CrapParserFixture.cs @@ -33,7 +33,7 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("QZC4HDl7ncmzyUj9amucWe1ddKU1oFMZDd8r0dEDUsTd")] public void should_not_parse_crap(string title) { - Parser.Parser.ParseTitle(title).Should().BeNull(); + Parser.Parser.ParseAlbumTitle(title).Should().BeNull(); ExceptionVerification.IgnoreWarns(); } @@ -52,7 +52,7 @@ namespace NzbDrone.Core.Test.ParserTests hash = BitConverter.ToString(hashData).Replace("-", ""); - if (Parser.Parser.ParseTitle(hash) == null) + if (Parser.Parser.ParseAlbumTitle(hash) == null) success++; } @@ -78,7 +78,7 @@ namespace NzbDrone.Core.Test.ParserTests hash.Append(charset[hashAlgo.Next() % charset.Length]); } - if (Parser.Parser.ParseTitle(hash.ToString()) == null) + if (Parser.Parser.ParseAlbumTitle(hash.ToString()) == null) success++; } @@ -88,7 +88,7 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("thebiggestloser1618finale")] public void should_not_parse_file_name_without_proper_spacing(string fileName) { - Parser.Parser.ParseTitle(fileName).Should().BeNull(); + Parser.Parser.ParseAlbumTitle(fileName).Should().BeNull(); } } } diff --git a/src/NzbDrone.Core.Test/ParserTests/DailyEpisodeParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/DailyEpisodeParserFixture.cs deleted file mode 100644 index 7b5cbaaf6..000000000 --- a/src/NzbDrone.Core.Test/ParserTests/DailyEpisodeParserFixture.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Common.Expansive; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Test.ParserTests -{ - - [TestFixture] - public class DailyEpisodeParserFixture : CoreTest - { - [TestCase("Conan 2011 04 18 Emma Roberts HDTV XviD BFF", "Conan", 2011, 04, 18)] - [TestCase("The Tonight Show With Jay Leno 2011 04 15 1080i HDTV DD5 1 MPEG2 TrollHD", "The Tonight Show With Jay Leno", 2011, 04, 15)] - [TestCase("The.Daily.Show.2010.10.11.Johnny.Knoxville.iTouch-MW", "The Daily Show", 2010, 10, 11)] - [TestCase("The Daily Show - 2011-04-12 - Gov. Deval Patrick", "The Daily Show", 2011, 04, 12)] - [TestCase("2011.01.10 - Denis Leary - HD TV.mkv", "", 2011, 1, 10)] - [TestCase("2011.03.13 - Denis Leary - HD TV.mkv", "", 2011, 3, 13)] - [TestCase("The Tonight Show with Jay Leno - 2011-06-16 - Larry David, \"Bachelorette\" Ashley Hebert, Pitbull with Ne-Yo", "The Tonight Show with Jay Leno", 2011, 6, 16)] - [TestCase("2020.NZ.2012.16.02.PDTV.XviD-C4TV", "2020 NZ", 2012, 2, 16)] - [TestCase("2020.NZ.2012.13.02.PDTV.XviD-C4TV", "2020 NZ", 2012, 2, 13)] - [TestCase("2020.NZ.2011.12.02.PDTV.XviD-C4TV", "2020 NZ", 2011, 12, 2)] - [TestCase("Series Title - 2013-10-30 - Episode Title (1) [HDTV-720p]", "Series Title", 2013, 10, 30)] - [TestCase("The_Voice_US_04.28.2014_hdtv.x264.Poke.mp4", "The Voice US", 2014, 4, 28)] - [TestCase("At.Midnight.140722.720p.HDTV.x264-YesTV", "At Midnight", 2014, 07, 22)] - [TestCase("At_Midnight_140722_720p_HDTV_x264-YesTV", "At Midnight", 2014, 07, 22)] - //[TestCase("Corrie.07.01.15", "Corrie", 2015, 1, 7)] - [TestCase("The Nightly Show with Larry Wilmore 2015 02 09 WEBRIP s01e13", "The Nightly Show with Larry Wilmore", 2015, 2, 9)] - //[TestCase("", "", 0, 0, 0)] - public void should_parse_daily_episode(string postTitle, string title, int year, int month, int day) - { - var result = Parser.Parser.ParseTitle(postTitle); - var airDate = new DateTime(year, month, day); - result.Should().NotBeNull(); - result.SeriesTitle.Should().Be(title); - result.AirDate.Should().Be(airDate.ToString(Episode.AIR_DATE_FORMAT)); - result.EpisodeNumbers.Should().BeEmpty(); - result.AbsoluteEpisodeNumbers.Should().BeEmpty(); - result.FullSeason.Should().BeFalse(); - } - - [TestCase("Conan {year} {month} {day} Emma Roberts HDTV XviD BFF")] - [TestCase("The Tonight Show With Jay Leno {year} {month} {day} 1080i HDTV DD5 1 MPEG2 TrollHD")] - [TestCase("The.Daily.Show.{year}.{month}.{day}.Johnny.Knoxville.iTouch-MW")] - [TestCase("The Daily Show - {year}-{month}-{day} - Gov. Deval Patrick")] - [TestCase("{year}.{month}.{day} - Denis Leary - HD TV.mkv")] - [TestCase("The Tonight Show with Jay Leno - {year}-{month}-{day} - Larry David, \"Bachelorette\" Ashley Hebert, Pitbull with Ne-Yo")] - [TestCase("2020.NZ.{year}.{month}.{day}.PDTV.XviD-C4TV")] - public void should_not_accept_ancient_daily_series(string title) - { - var yearTooLow = title.Expand(new { year = 1950, month = 10, day = 14 }); - Parser.Parser.ParseTitle(yearTooLow).Should().BeNull(); - } - - [TestCase("Conan {year} {month} {day} Emma Roberts HDTV XviD BFF")] - [TestCase("The Tonight Show With Jay Leno {year} {month} {day} 1080i HDTV DD5 1 MPEG2 TrollHD")] - [TestCase("The.Daily.Show.{year}.{month}.{day}.Johnny.Knoxville.iTouch-MW")] - [TestCase("The Daily Show - {year}-{month}-{day} - Gov. Deval Patrick")] - [TestCase("{year}.{month}.{day} - Denis Leary - HD TV.mkv")] - [TestCase("The Tonight Show with Jay Leno - {year}-{month}-{day} - Larry David, \"Bachelorette\" Ashley Hebert, Pitbull with Ne-Yo")] - [TestCase("2020.NZ.{year}.{month}.{day}.PDTV.XviD-C4TV")] - public void should_not_accept_future_dates(string title) - { - var twoDaysFromNow = DateTime.Now.AddDays(2); - - var validDate = title.Expand(new { year = twoDaysFromNow.Year, month = twoDaysFromNow.Month.ToString("00"), day = twoDaysFromNow.Day.ToString("00") }); - - Parser.Parser.ParseTitle(validDate).Should().BeNull(); - } - - [Test] - public void should_fail_if_episode_is_far_in_future() - { - var title = string.Format("{0:yyyy.MM.dd} - Denis Leary - HD TV.mkv", DateTime.Now.AddDays(2)); - - Parser.Parser.ParseTitle(title).Should().BeNull(); - } - } -} diff --git a/src/NzbDrone.Core.Test/ParserTests/ExtendedQualityParserRegex.cs b/src/NzbDrone.Core.Test/ParserTests/ExtendedQualityParserRegex.cs index cd979fa2a..e7007a4cb 100644 --- a/src/NzbDrone.Core.Test/ParserTests/ExtendedQualityParserRegex.cs +++ b/src/NzbDrone.Core.Test/ParserTests/ExtendedQualityParserRegex.cs @@ -1,4 +1,4 @@ -using FluentAssertions; +using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.Parser; using NzbDrone.Core.Test.Framework; @@ -24,7 +24,7 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("The Real Housewives of Some Place - S01E01 - Why are we doing this?", 0)] public void should_parse_reality_from_title(string title, int reality) { - QualityParser.ParseQuality(title).Revision.Real.Should().Be(reality); + QualityParser.ParseQuality(title, null, 0).Revision.Real.Should().Be(reality); } [TestCase("Chuck.S04E05.HDTV.XviD-LOL", 1)] @@ -45,7 +45,7 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("[Vivid-Asenshi] Akame ga Kill - 02v2 [1F67AB55]", 2)] public void should_parse_version_from_title(string title, int version) { - QualityParser.ParseQuality(title).Revision.Version.Should().Be(version); + QualityParser.ParseQuality(title, null, 0).Revision.Version.Should().Be(version); } } } diff --git a/src/NzbDrone.Core.Test/ParserTests/FingerprintingServiceFixture.cs b/src/NzbDrone.Core.Test/ParserTests/FingerprintingServiceFixture.cs new file mode 100644 index 000000000..002f1016d --- /dev/null +++ b/src/NzbDrone.Core.Test/ParserTests/FingerprintingServiceFixture.cs @@ -0,0 +1,218 @@ +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Parser; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NzbDrone.Core.Parser.Model; +using System; +using NzbDrone.Common.Http; +using Moq; +using static NzbDrone.Core.Parser.FingerprintingService; +using NzbDrone.Test.Common; +using System.Net; + +namespace NzbDrone.Core.Test.ParserTests +{ + [TestFixture] + public class FingerprintingServiceFixture : CoreTest + { + + [Test] + public void should_parse_fpcalc_json() + { + var json = "{\"duration\": 229.29, \"fingerprint\": \"AQADtFMURlEURcgP6cwRD43Y4Ptw9FowncWPWkf6GB9-JYdP9OgJHw8u4Apw4SsOHMdxHAfwHIfYET_0HiZ55MfxNCm8Hj-mRM8KLlXwIzqS-Que_Cg7NEseE_2j4DUjJBOvI08UNNUufEd5NM-OHzmSUZsO58er7nhUeN6EK3HYwhe0H-fxXEFzlOSHJwpj3MevID7kHyqVqBH6zEI6GzmSs4HTDz4j9DjxKHDvpLiOH7l4iFUWRXiiqtCJmMqNPmiAZEyY5AgTLclxmSWuwJ-O33jz4KdwHt-PsDHRjBNyCQ_-KcGPhoePsJNyiJOiI-5VPKMj_McvfNQRUkyiImEzAv2Ckxb-RUJ-CVpGhMYnB5em4scvHFeuoU7DwHugT8fRecPVpMF5_Cb8jNvw4n_wJ6iV4xcSHuFxKcfTBhVF5uiPA6OSoWQP5x_6BX-5YDoaJT_OLA3e0Ifr4D2SH2EYB-WShLiE6plg5U2G6jlOSgy-G8-Rk0XD7yiP4DyPfcejiQ2aH9WVHKeOJ5eQP9CeKEfD44zw45kj_NAQJmIWfMTPCJF048IPPTtcB_GP_2ieFydyxYXGvUgffIO1D96SHW10vEeYI9mNR-qLhnpwfYHz49KR8ijnol58pDGLKWuF4wvKTUGjrE-Q79COdFyI8Aze4C70LMP24-YKJLtSeCHqajJ6e2g0qsdzBd94RNdxHY80oomYocdB5hNiUrGIVtrhJ0f4CY-Y48yOF5rFI93sgNF2hNRhTSk65odGaUhJClc5ArOGdi8-oz_SzaqQ3MKV63DkGX8NT07wVcdf5MmO5Av8C9dOONGIS_mRS7gO8fjhLNMFbQqq4zF0jRPir2j2Y9_xH5Z-_EFiHWE-oaePh8JN3CsaiYgkGvrR_OgVaI2iHv_gTLsQaTeuZ8K5HUdMCWUlHpLIIFyO8TiNO2nQOHzwLkfYQg2RHxXlUfDxHX-MO9Pxwc8J90JSEzmDJk-Ji0cpZanw44mOWpKEkkHDsUFkZsfNaJCOqDvK_fg-9NHx5UHtFGEOPUeu47hw4dzwH22Nc9pgK8dZhJ-h8wipwyS8Dw8cZ0evC8kZJcgrHT22jsfhH_QCv4jMHNp25NLx6Hh4tEtaePzgcNHxDOkYQ3tCxBsvPFKOfyzCB-L5IHyCpzhFHo9zvMeP5vrQbdzwK8N_hFG6F0mdo2GOJ4yxM0bJRFaGH3WyHUdzHWWe4uoH8hWS8EGOGuUSBZ-E6w7sZsKpIId-IzyDptLxhD76GpfxNDHSkdCTS0JMFS-8Rh6e48c6Hv2O8CG05MinRcUX9A0cKoSv4Ts8KbSQbCXyDDeDZ_mKUrwHP8tRi8d94UcX3ImMLzq8o3-DnFXwHG2UJ_Au6FlwNLgOXU_xakKv4zp8VMeN_vBz9OCbQzsFP7h8PB96Df8llNJSWI8KfVkqZMcPaw5ePEgf6HKQjj2YTRmOxslyTJ-K_kj3Qj9M6TjCM33QixGcX8PzHNfxDOfhMx2SHflz_Dr-K6hSKVLQ7MSjII-UQ9fQ53Bi6ahMoz5jNA304xDz7RjTI08eXKeChzmeDeUJfw7OEPVGnB_yoz_M6sZj8MOfHE5wXKLhT0V-MMhjlDv0C-mP_jgt4cxRMgoLSz5GkUZ-3MFTPHCpB-W2EheX43iWeUgfDhdJPAsR_oKWmDGeJRMRoyea3fhyVA6RjsuCCxdDiD-2fsizC5eSp_CPh4UnP5gU6sOpL7g3ocyCSDn0I7-L4znKBg3uKDmeW8Nzsriqg5eMHk33Cn9Q_0hPQtMX4oqOfMedHI-I2mPwd0PeQ8yHsByu4zqHPkfzo4-OtE6R7Dq-5fg3o-kuoVU84VGLIDkNqzEFNt1x7RvOHO7wf8hznDz0KA6F6-g1uE2OPseFvKh-9MGp4we7LLmIhxE1JHeDcLyOUD8uDTozND_apQvyiQ9yQcoRxisuBxeVDd-LRrGO-0xRKwuPh4ivHP8GUQ_ig87RN0czSkf0xEMNcRzxw8elHKIikcKlB5bGY-J9PImJ64K4PCDD7rh8_Ogvo5kOmU0RnjmcC2na9LiGbpKhC48RJvo2hI-HD80sVONx58nQ49CfLtCj59CTBz8eDZ4O7fHwBw-hPhJ0HhqlU0h9XB9qKsh_fNmRiTz-qPido6l3lB0X_DiS90QOOxdxbwJ94XqjwmKyHBeLHzfyH1MuaM9xibgeCe2O5gv-QzUz5NtRay8s7Tj6E1cSHH2eIa0tJB9OBVXa4g9-1M6DOsPTo3wwJQo3PA_4TMh_JJEPb4OzHSUTbcIvPCHcI4-gE7mOp8RxRyX6ySvM2EbJbhEeHVeKZOkR_hJKKTvM_SiPu9FhOUes6kJySTneozfO0HiObSy-7AXDp8gbJMd-4TmebB9KiYKfHNWPG8c9JGvUI5Tz4Xle3FEkhD-01DFy4gnKoOTxw23wOfjRjNKPZNp2Io8OpxmuJsW-7DgvoXYenEEtCT2aKDfIMDv-TEJ0JMtxoZk04Uqi4Ic_HLGP5JLRC80_XFJC9JnwjpNRjTueRSuS_4h4NIfzBl00E5-mo3ckNB-FPIE-5BlO9UL5Dudx5vikFB5DF-FKI7myxPjj4Yzw7OjRxOHxB8f3o9wO34V__IR5UkSf7LiOPhLSL-DR4MKlo5zwr8LFAf8h_oBtVTj8GIcAAYgB4YwSDACBiFBMPPGctY6a6pAAxighhEjKAAaBNsgIIAAQRgFFHCBGIACQIBJAoAAADCKiCDLSCCABQYQQwIwEigkArKGII0OUQEwAogBRoCCHCCKQAEcEkQYQYoQSigDjCBMCIUCJIUQJQQRRiDAAHSEKEOCCEAAgDRQBxDEgkBEGEAQQFkIIIAQhAgnBAFTAQIEAQYgxYwBClAkEACMEAEAAMQgISAxCgiiCACESIWQAUWYQBoBDBhBGgBBAMSMkYogAIwjwBCMkACLCCASEIMARg4QCBCAijCYAKweEAkAJYRgSAkDjjBCMQgioMIYBQIgDQhkBGiKEQAeBFAQAZoACBgFKFAEAOQMAEIQYgICShChGCCDKESSYoEIQAIRiFkAAgQJMCWMYRUAIgAQAigFEgFDEEAEwEAYQaQw0BgEHADDWGSCAWEgIBQQxBghmjBQCCEqMA0YpQIwAwgDjHFBECiWUEQYxohABRAhAjAUCESUARgRQYgAARggKMAVOAEIEc4gyJ4ABABCCjGMIKAIAEQIAIAwChAEgAGEAGAeIUMZY4QghSBhADSCCGiKNlAQoogAChjCDEAEEWEIRA0QohoQAQCkECEGQEQIEMUwYgIgBABgECECCKEQAAkIgYZARBgmADDNKIEAEoAgAAAQDBAgCHJCAEGG0QkQAaZgAAkpAkBAGImAgIYI5AoQAAiBBiCDMASGIYQAoB4QgCAlBIHFIICaAIM4aJawAxFIBDAFGGEA5AUQAgZhCTgJBgEKAACEMI4gwRRwQDAEEIQFEIaKIQYQBgYgBCjiGgAHEUQGYAIwwQASSylCjgJKMI8AIhIAAgAQwBBChhCIKAyOAVIIZAYBBihIIiBAACKMUUhI6JYAAigBADFAiAGBAhoZApRRQwABAiCCAGGcAQUIaBQBhgjBDACEEWSEsYAQYBIAVAACCmDAhEUAEEEQJhQA\"}"; + + var result = Subject.ParseFpcalcJsonOutput(json); + + result.Duration.Should().Be(229.29); + result.Fingerprint.Should().Be("AQADtFMURlEURcgP6cwRD43Y4Ptw9FowncWPWkf6GB9-JYdP9OgJHw8u4Apw4SsOHMdxHAfwHIfYET_0HiZ55MfxNCm8Hj-mRM8KLlXwIzqS-Que_Cg7NEseE_2j4DUjJBOvI08UNNUufEd5NM-OHzmSUZsO58er7nhUeN6EK3HYwhe0H-fxXEFzlOSHJwpj3MevID7kHyqVqBH6zEI6GzmSs4HTDz4j9DjxKHDvpLiOH7l4iFUWRXiiqtCJmMqNPmiAZEyY5AgTLclxmSWuwJ-O33jz4KdwHt-PsDHRjBNyCQ_-KcGPhoePsJNyiJOiI-5VPKMj_McvfNQRUkyiImEzAv2Ckxb-RUJ-CVpGhMYnB5em4scvHFeuoU7DwHugT8fRecPVpMF5_Cb8jNvw4n_wJ6iV4xcSHuFxKcfTBhVF5uiPA6OSoWQP5x_6BX-5YDoaJT_OLA3e0Ifr4D2SH2EYB-WShLiE6plg5U2G6jlOSgy-G8-Rk0XD7yiP4DyPfcejiQ2aH9WVHKeOJ5eQP9CeKEfD44zw45kj_NAQJmIWfMTPCJF048IPPTtcB_GP_2ieFydyxYXGvUgffIO1D96SHW10vEeYI9mNR-qLhnpwfYHz49KR8ijnol58pDGLKWuF4wvKTUGjrE-Q79COdFyI8Aze4C70LMP24-YKJLtSeCHqajJ6e2g0qsdzBd94RNdxHY80oomYocdB5hNiUrGIVtrhJ0f4CY-Y48yOF5rFI93sgNF2hNRhTSk65odGaUhJClc5ArOGdi8-oz_SzaqQ3MKV63DkGX8NT07wVcdf5MmO5Av8C9dOONGIS_mRS7gO8fjhLNMFbQqq4zF0jRPir2j2Y9_xH5Z-_EFiHWE-oaePh8JN3CsaiYgkGvrR_OgVaI2iHv_gTLsQaTeuZ8K5HUdMCWUlHpLIIFyO8TiNO2nQOHzwLkfYQg2RHxXlUfDxHX-MO9Pxwc8J90JSEzmDJk-Ji0cpZanw44mOWpKEkkHDsUFkZsfNaJCOqDvK_fg-9NHx5UHtFGEOPUeu47hw4dzwH22Nc9pgK8dZhJ-h8wipwyS8Dw8cZ0evC8kZJcgrHT22jsfhH_QCv4jMHNp25NLx6Hh4tEtaePzgcNHxDOkYQ3tCxBsvPFKOfyzCB-L5IHyCpzhFHo9zvMeP5vrQbdzwK8N_hFG6F0mdo2GOJ4yxM0bJRFaGH3WyHUdzHWWe4uoH8hWS8EGOGuUSBZ-E6w7sZsKpIId-IzyDptLxhD76GpfxNDHSkdCTS0JMFS-8Rh6e48c6Hv2O8CG05MinRcUX9A0cKoSv4Ts8KbSQbCXyDDeDZ_mKUrwHP8tRi8d94UcX3ImMLzq8o3-DnFXwHG2UJ_Au6FlwNLgOXU_xakKv4zp8VMeN_vBz9OCbQzsFP7h8PB96Df8llNJSWI8KfVkqZMcPaw5ePEgf6HKQjj2YTRmOxslyTJ-K_kj3Qj9M6TjCM33QixGcX8PzHNfxDOfhMx2SHflz_Dr-K6hSKVLQ7MSjII-UQ9fQ53Bi6ahMoz5jNA304xDz7RjTI08eXKeChzmeDeUJfw7OEPVGnB_yoz_M6sZj8MOfHE5wXKLhT0V-MMhjlDv0C-mP_jgt4cxRMgoLSz5GkUZ-3MFTPHCpB-W2EheX43iWeUgfDhdJPAsR_oKWmDGeJRMRoyea3fhyVA6RjsuCCxdDiD-2fsizC5eSp_CPh4UnP5gU6sOpL7g3ocyCSDn0I7-L4znKBg3uKDmeW8Nzsriqg5eMHk33Cn9Q_0hPQtMX4oqOfMedHI-I2mPwd0PeQ8yHsByu4zqHPkfzo4-OtE6R7Dq-5fg3o-kuoVU84VGLIDkNqzEFNt1x7RvOHO7wf8hznDz0KA6F6-g1uE2OPseFvKh-9MGp4we7LLmIhxE1JHeDcLyOUD8uDTozND_apQvyiQ9yQcoRxisuBxeVDd-LRrGO-0xRKwuPh4ivHP8GUQ_ig87RN0czSkf0xEMNcRzxw8elHKIikcKlB5bGY-J9PImJ64K4PCDD7rh8_Ogvo5kOmU0RnjmcC2na9LiGbpKhC48RJvo2hI-HD80sVONx58nQ49CfLtCj59CTBz8eDZ4O7fHwBw-hPhJ0HhqlU0h9XB9qKsh_fNmRiTz-qPido6l3lB0X_DiS90QOOxdxbwJ94XqjwmKyHBeLHzfyH1MuaM9xibgeCe2O5gv-QzUz5NtRay8s7Tj6E1cSHH2eIa0tJB9OBVXa4g9-1M6DOsPTo3wwJQo3PA_4TMh_JJEPb4OzHSUTbcIvPCHcI4-gE7mOp8RxRyX6ySvM2EbJbhEeHVeKZOkR_hJKKTvM_SiPu9FhOUes6kJySTneozfO0HiObSy-7AXDp8gbJMd-4TmebB9KiYKfHNWPG8c9JGvUI5Tz4Xle3FEkhD-01DFy4gnKoOTxw23wOfjRjNKPZNp2Io8OpxmuJsW-7DgvoXYenEEtCT2aKDfIMDv-TEJ0JMtxoZk04Uqi4Ic_HLGP5JLRC80_XFJC9JnwjpNRjTueRSuS_4h4NIfzBl00E5-mo3ckNB-FPIE-5BlO9UL5Dudx5vikFB5DF-FKI7myxPjj4Yzw7OjRxOHxB8f3o9wO34V__IR5UkSf7LiOPhLSL-DR4MKlo5zwr8LFAf8h_oBtVTj8GIcAAYgB4YwSDACBiFBMPPGctY6a6pAAxighhEjKAAaBNsgIIAAQRgFFHCBGIACQIBJAoAAADCKiCDLSCCABQYQQwIwEigkArKGII0OUQEwAogBRoCCHCCKQAEcEkQYQYoQSigDjCBMCIUCJIUQJQQRRiDAAHSEKEOCCEAAgDRQBxDEgkBEGEAQQFkIIIAQhAgnBAFTAQIEAQYgxYwBClAkEACMEAEAAMQgISAxCgiiCACESIWQAUWYQBoBDBhBGgBBAMSMkYogAIwjwBCMkACLCCASEIMARg4QCBCAijCYAKweEAkAJYRgSAkDjjBCMQgioMIYBQIgDQhkBGiKEQAeBFAQAZoACBgFKFAEAOQMAEIQYgICShChGCCDKESSYoEIQAIRiFkAAgQJMCWMYRUAIgAQAigFEgFDEEAEwEAYQaQw0BgEHADDWGSCAWEgIBQQxBghmjBQCCEqMA0YpQIwAwgDjHFBECiWUEQYxohABRAhAjAUCESUARgRQYgAARggKMAVOAEIEc4gyJ4ABABCCjGMIKAIAEQIAIAwChAEgAGEAGAeIUMZY4QghSBhADSCCGiKNlAQoogAChjCDEAEEWEIRA0QohoQAQCkECEGQEQIEMUwYgIgBABgECECCKEQAAkIgYZARBgmADDNKIEAEoAgAAAQDBAgCHJCAEGG0QkQAaZgAAkpAkBAGImAgIYI5AoQAAiBBiCDMASGIYQAoB4QgCAlBIHFIICaAIM4aJawAxFIBDAFGGEA5AUQAgZhCTgJBgEKAACEMI4gwRRwQDAEEIQFEIaKIQYQBgYgBCjiGgAHEUQGYAIwwQASSylCjgJKMI8AIhIAAgAQwBBChhCIKAyOAVIIZAYBBihIIiBAACKMUUhI6JYAAigBADFAiAGBAhoZApRRQwABAiCCAGGcAQUIaBQBhgjBDACEEWSEsYAQYBIAVAACCmDAhEUAEEEQJhQA"); + } + + [Test] + public void should_parse_fpcalc_text() + { + var text = @"FILE=Adele - 01 - 21 - Rolling in the Deep.flac +DURATION=229 +FINGERPRINT=AQAHJlMURlEURcgP6cwRD43Y4Ptw9FowncWPWkf6GB9-JYdP9OgJHw8u4Apw4SsOHMdxHAfwHIfYET_0HiZ55MfxNCm8Hj-mRM8KLlXwIzqS-Que_Cg7NEseE_2j4DUjJBOvI08UNNUufEd5NM-OHzmSUZsO58er7nhUeN6EK3HYwhe0H-fxXEFzlOSHJwpj3MevID7kHyqVqBH6zEI6GzmSs4HTDz4j9DjxKHDvpLiOH7l4iFUWRXiiqtCJmMqNPmiAZEyY5AgTLclxmSWuwJ-O33jz4KdwHt-PsDHRjBNyCQ_-KcGPhoePsJNyiJOiI-5VPKMj_McvfNQRUkyiImEzAv2Ckxb-RUJ-CVpGhMYnB5em4scvHFeuoU7DwHugT8fRecPVpMF5_Cb8jNvw4n_wJ6iV4xcSHuFxKcfTBhVF5uiPA6OSoWQP5x_6BX-5YDoaJT_OLA3e0Ifr4D2SH2EYB-WShLiE6plg5U2G6jlOSgy-G8-Rk0XD7yiP4DyPfcejiQ2aH9WVHKeOJ5eQP9CeKEfD44zw45kj_NAQJmIWfMTPCJF048IPPTtcB_GP_2ieFydyxYXGvUgffIO1D96SHW10vEeYI9mNR-qLhnpwfYHz49KR8ijnol58pDGLKWuF4wvKTUGjrE-Q79COdFyI8Aze4C70LMP24-YKJLtSeCHqajJ6e2g0qsdzBd94RNdxHY80oomYocdB5hNiUrGIVtrhJ0f4CY-Y48yOF5rFI93sgNF2hNRhTSk65odGaUhJClc5ArOGdi8-oz_SzaqQ3MKV63DkGX8NT07wVcdf5MmO5Av8C9dOONGIS_mRS7gO8fjhLNMFbQqq4zF0jRPir2j2Y9_xH5Z-_EFiHWE-oaePh8JN3CsaiYgkGvrR_OgVaI2iHv_gTLsQaTeuZ8K5HUdMCWUlHpLIIFyO8TiNO2nQOHzwLkfYQg2RHxXlUfDxHX-MO9Pxwc8J90JSEzmDJk-Ji0cpZanw44mOWpKEkkHDsUFkZsfNaJCOqDvK_fg-9NHx5UHtFGEOPUeu47hw4dzwH22Nc9pgK8dZhJ-h8wipwyS8Dw8cZ0evC8kZJcgrHT22jsfhH_QCv4jMHNp25NLx6Hh4tEtaePzgcNHxDOkYQ3tCxBsvPFKOfyzCB-L5IHyCpzhFHo9zvMeP5vrQbdzwK8N_hFG6F0mdo2GOJ4yxM0bJRFaGH3WyHUdzHWWe4uoH8hWS8EGOGuUSBZ-E6w7sZsKpIId-IzyDptLxhD76GpfxNDHSkdCTS0JMFS-8Rh6e48c6Hv2O8CG05MinRcUX9A0cKoSv4Ts8KbSQbCXyDDeDZ_mKUrwHP8tRi8d94UcX3ImMLzq8o3-DnFXwHG2UJ_Au6FlwNLgOXU_xakKv4zp8VMeN_vBz9OCbQzsFP7h8PB96Df8llNJSWI8KfVkqZMcPaw5ePEgf6HKQjj2YTRmOxslyTJ-K_kj3Qj9M6TjCM33QixGcX8PzHNfxDOfhMx2SHflz_Dr-K6hSKVLQ7MSjII-UQ9fQ53Bi6ahMoz5jNA304xDz7RjTI08eXKeChzmeDeUJfw7OEPVGnB_yoz_M6sZj8MOfHE5wXKLhT0V-MMhjlDv0C-mP_jgt4cxRMgoLSz5GkUZ-3MFTPHCpB-W2EheX43iWeUgfDhdJPAsR_oKWmDGeJRMRoyea3fhyVA6RjsuCCxdDiD-2fsizC5eSp_CPh4UnP5gU6sOpL7g3ocyCSDn0I7-L4znKBg3uKDmeW8Nzsriqg5eMHk33Cn9Q_0hPQtMX4oqOfMedHI-IehuDm92Q9xDzISyH67jOoc_R_OijI61TJLuObzn-zWi6S2gVT3jUIkhOw2pMgU13XPuGM4c7_B_yHCcPPYpD4Tp6DW6To89xIS-qH31w6vjBLksu4mFEDcndIByvI9SPS4PODM2PdumCfOKDXJByhPGKy8FFZcP3olGs4z5T1MrC4yHiK8e_QdSD-KBz9M3RjNIRPfFQQxxH_PBxKYeoSKRw6YGl8Zh4H09i4rogLg_IsDsuHz_6y2imQ2ZThGcO50KaNj2uoZtk6MJjhIm-DeHj4UMzC9V43Hky9Dj0pwv06Dn05MGPR4OnQ3s8_MFDqI8EnYdG6RRSH9eHmgryH192ZCKPPyp-52jqHWXHBT-O5D2Rw85F3JtAX7jeqLCYLMfF4seN_MeUC9pzXCKuR0K7o_mC_1DNDPl21NoLSzuO_sSVBEefZ0hrC8mHU0GVtviDH7XzoM7w9CgfTInCDc8DPhPyH0nkw9vgbEfJRJvwC08I98gj6ESu4ylx3FGJfvIKM7ZRsluER8eVIll6hL-EUsoOcz_K4250WM4Rq7qQXFKO9-iNMzSeYxuLL3vB8CnyBsmxX3iOJ9uHUqLgJ0f148ZxD8ka9QjlfHieF3cUCeEPLXWMnHiCMih5_HAbfA5-NKP0I5m2ncijw2mGq0mxLzvOS6idB2dQS0KPJsoNMsyOP5MQHclyXGgmTbiSKPjhD0fsI7lk9ELzD5eUEH0mvGONatyFZ9GK5D8iHs3hvEEXzcSn6egdCc1HIU-gD3mGU71QvsN5nDk-KYXH0EW40kiuLDH-eDgjPDt6NHF4PHjw_Si3w3fhHz9hnhTRJzuuI3wk-At4NLhw6Sgn_KtwccB_iD9gWxUOP8bhHb8HT0evRcJ_ouuC5oM0t0K_I_7gTUpwXI6RZ4fIE-kkynhzvMwFnzmI1MXeQ1xW9MqDHqkj3Fl0fDyeHa2iB9-CF2EuaD9yBWdi4cHjKEoCZzGPfOGhK8tx4kyHSh3SROODkIpRW4eW54J-NEckOZJGPH8S_MXX5HgiHj18MUMWj8UJ_SL-HL0HOxF0_MZ3xDDzoL_QKzr8gOGH8IH-Ii9-eE8s1Bc-7cS1EY3KHPON2D3axMa344eWx4JmH1dKnDxRUtvQ3DJO4spyaH8ORx9yLVlRNcbRH-4T3NERTXrwD4pJKoGvDlt9IeQS9Drcx7jEdHCqHdO2436Ea0cZIlkmBrmOx8WHK0eZC36Kylks47mNI8zEndAcFzleM2hWxkR_PMH14UOYH4V6ovvh4j7eGflx68H24P_gUsHx7fCNx7g2IX5QLpwW5FqWGDyH_vAVrCXgZ0F-iIqOSswe_ESPZqxRH9su6ISvoz2OR0Xt4Olx96CO43gl-CHqB7rxpzjR7EPlctDNQl6SoxJpIT3-wD_q4cmRB1qOPkbeKI3wa9ijBU9E-HSRL9uh4_7wKA1RK1GI5tFxGe0PMQmNf0iPPyIeHf_xJg9O5IeYv3geFuHJID96SRs-HGePG47xRsTFBfpxScthJ1Eq1D9Cwj-eLcZVH-5wwfvRN0b8BzvCPAE5HemWLOgqGfehHc2XNMj7oOxxpOijBDrBMES6ZclxesQzvBX8oD9e9IJ_tJOMagpzNNfBC4-OqTua96i3w6_RK-HR8DmSWULcxMOV43fwP2iuTMi14UwERUrI6MgX5J-S4BbmQ-yPPDn85DhxamIC_7hSPMf9IJmSuAhzYg9OUkrw6UHzoCdyfUjAHf05NGoWB2-InCeS2XFwH4we5ThODp2i7-Dn4BaHMJKSC7qObPKD5ktm1BS-oEyuBc1xXoiThTmSJ5Qx5VGHXmFwcnjjCJ6SIHmi3Mgf4EqHR7uC7xFOZQiZdUOy7MaNn8eHv4EZ6cFH4T_uI_mHvA9OxUEj1phEDTlzHP-D_UmIo3FIhCxRPeiX4wmuJEeeT2hCCslOPNjvBI80EqwUFE3xI9bICXpUBM0zfej0tPglnDeYXIuRH2KP_Ivw5LiUB1nictCdEvfRTTma73hyfNEjrEw-iLMS4-GFPWlCZPmOZuyPOcR_9JUGuyb64_iFL_hkxH6g7YiXF009PLlw7DkO_kHPFE2VrcSToVeQPkR16M3x-DjCE8ePH_mFhD8eocdxXiu6w2nC6ehxIkHe4cwMVlqOvfBTnJi6LNPR7B_4HOm-QE-OPW4i5DiPXM6E39AEH6Vz9MeRb8ePA7kh3ugRUnoERxH6CrVjfHvgx7BPo1aON8UlPEiupIgfBeuNShmoTTz-FCGpHMnNDp9wncF1XDqSPUeYxMf4ZMecHKd-8DrEHz--m3h6_Cr6ozluyRISh9ERH4d7nMcXBhPJB83xDZP-obGET1oRH8nzB2dYNCeO6Ua-7NDx7InwRyuaH6F148qSJ_jT4SyJ8BLRjBnk5EmG3KLQHdtJosyiJsFvoheyZKeQPMqOL0yk4jz847mEX3g61NfxBrXwWChPNGd0fAuq7ALV9EJ6aDx-hPlRH9eO0PrRHNehJzmKXxkhH-oKHz_S47iEzyJyHDr8RyjxFtV4iCoz4kj_glbh4xPyBR8P_UGYUcfDo9_hLwdv-Ee0EZdwCbqOnHjg_IeT4_ng4zDJD8ls5Ds-JniEx1sC58jxaNAp9LqhH2r8oIKe6LiOntByCbEjVnguR9gvXAmPc0HZo7k5_A7yGr9GaNnR49GFZwo0xDkcHj2uHL3RHEfeQbuI-3CP-yGq6cZPdB6xxT3-Ii-OK4Mr1I6D58MnPGJihDyaP9AV4kXIT2i4ZCeeHtWPX3jm4jh7pNJ6_PgiiM-DXMnxwEctxkap7YeDjsmJij6uBOYiJPuR48aPhyeOKb9RUQyeF8n7IecPJsyI8iVQ80RzjshTaHmD8J3w6EDVCk9zXOlxHM1lBX-RI_nRLwf_DP7hB5WOPwgbEeJb4algxgcvvIE14zhO4j0cKYRmLS_6HfxxHf0nzCz0H82DWsiDLxEndO3hP-jxo9-FMId-5A8-EndUBVX06Wj64yqOvN3wFb9DqNKGPjouAulROccfaUPEP8KTQ9cWPD_CHPXmDG9wXUb4Q8uFXA6eG4zawzochcd__INP9C2yHOIR_sLR5wqavHjU8Dji74Tma0jJ4MdF8B2-GH5mGXvhG-GhqPzQjDpygrFz2GGDn4iT69izEY7Q_7ieLZAVEU1Elsi_HB4DL8dk9fiWoelQnkF-KErErMSTNHikqCq0PrgmBeEp-HRU9CHCjIfmIdJTNFUmTQF7VE2EJqm0Is9SFeVyatj4IyQZXDl6NFWWDpeI3DtOY_SLJ8sM9k2QZ2huVMcTA4cAAYgB4YwSDACBiFBMPPGctY6a6pAAxighhEjKAAaBNsgIIAAQRgFFHCBGIACQIBJAoAAADCKiCDLSCCABQYQQwIwEigkArKGII0OUQEwAogBRoCCHCCKQAEcEkQYQYoQSigDjCBMCIUCJIUQJQQRRiDAAHSEKEOCCEAAgDRQBxDEgkBEGEAQQFkIIIAQhAgnBAFTAQIEAQYgxYwBClAkEACMEAEAAMQgISAxCgiiCACESIWQAUWYQBoBDBhBGgBBAMSMkYogAIwjwBCMkACLCCASEIMARg4QCBCAijCYAKweEAkAJYRgSAkDjjBCMQgioMIYBQIgDQhkBGiKEQAeBFAQAZoACBgFKFAEAOQMAEIQYgICShChGCCDKESSYoEIQAIRiFkAAgQJMCWMYRUAIgAQAigFEgFDEEAEwEAYQaQw0BgEHADDWGSCAWEgIBQQxBghmjBQCCEqMA0YpQIwAwgDjHFBECiWUEQYxohABRAhAjAUCESUARgRQYgAARggKMAVOAEIEc4gyJ4ABABCCjGMIKAIAEQIAIAwChAEgAGEAGAeIUMZY4QghSBhADSCCGiKNlAQoogAChjCDEAEEWEIRA0QohoQAQCkECEGQEQIEMUwYgIgBABgECECCKEQAAkIgYZARSABkmFECASIARQAAIBggQBDggASECKMVIgJIwwQQUAKChDAQAQMJEcwRIAQQAAlCBGEOCEEMA0A5IARBSAgCiUMCMQEEcdYoYQUglgpgCDDCAMoJIAIIxBRyEggCFAIECGEYQYQp4oBgCCAICSAKEUUMIgwIRAxQwDEEDCCOCsAEYIQBIpBUhhoFlGQcAUYgBAQAJIAhgAglFFEYGAGkEswIAAxSlEBAhABAGKWQktApAQRQBABigBIBAAMyNAQqpYACBgACBAHEOAMIEtIoAAgThBkCCCHIChMYAQYBYAUAgCAmTEgEEAEEUUIhNRAABgChCACCKAAEAAAQYwghCAAAAEJgMMEYCEhAAiADCAAFRAACOUKII4gBowwwECABgAJAIKgMoogJAwABQiFBODLECMSQAQYoYCRAAABgjBBGEUiAYQAR6ghiAhBKCFMGEeAFEIIAYBBDABEhFDKKIgOYoAQo4QgToElEAPIMIIAQtVZQgxQoBJIKAENGESSIFAQwgBAgADAAAMBEOGKIQMpJAAgQRCljKCGICKQAIQAYYwQXQCCgDBJECWIIGAgwxgRQRAnDhMMOGICMEAgYIYEiAhADCBGEMiMIAsQQoBAiBAgPiHBEGiMMIIoSoJAABCFJDAFEaKAIAIYyQJQBgigCBAMAgGIAQQYRIZBASAhDABEIIgLEEQA4JgCBSgAAnAEKIIYJoIAYAjAkQCAiCAEMIACYAYAIFDxxSgElGGAcEaGMkIQBBDlSEDFBFRKLAOEEJMIQBYgQgEAGAGEEEKIIcYAZIRChSDOLiAXMMGohUk4IYJFQCAkECCGWGCCIMMwAABATlCgghCjMGWCSEMIAAAigwjhjCAMICQCIEAIZA4gwTgACkGKMMEIVUEwABBzAmBnBlBKCSGIMAcAqpagFBUWCgBRIGCuYUYQhwAggggEIjjKKAyAAMwoE5owIziIiBCSGCAIMAEAoggxFCCAmmFTEWMKUYQgNQBhBgCAEjFNIKeOMQ4AwRYBBwAjGgDDAMQSMUUwAIqAR1jpIEEEEKGMEAkYIhBADQgBEqUFAFEqQIYI4JhSwwihRjUCAGAeQAYgAYrRhgBGkjEBACCEBAEYopIAwQCCGBDAAGUAAoooJJKByRkBDiJDCMa2UAQMYYRgQAGBFADBIGKEAYg4AgAgQSgAoHCFMCgCUAEY5BQBCxjMIEBBEEgAA +"; + + var result = Subject.ParseFpcalcTextOutput(text); + result.Duration.Should().Be(229); + result.Fingerprint.Should().Be("AQAHJlMURlEURcgP6cwRD43Y4Ptw9FowncWPWkf6GB9-JYdP9OgJHw8u4Apw4SsOHMdxHAfwHIfYET_0HiZ55MfxNCm8Hj-mRM8KLlXwIzqS-Que_Cg7NEseE_2j4DUjJBOvI08UNNUufEd5NM-OHzmSUZsO58er7nhUeN6EK3HYwhe0H-fxXEFzlOSHJwpj3MevID7kHyqVqBH6zEI6GzmSs4HTDz4j9DjxKHDvpLiOH7l4iFUWRXiiqtCJmMqNPmiAZEyY5AgTLclxmSWuwJ-O33jz4KdwHt-PsDHRjBNyCQ_-KcGPhoePsJNyiJOiI-5VPKMj_McvfNQRUkyiImEzAv2Ckxb-RUJ-CVpGhMYnB5em4scvHFeuoU7DwHugT8fRecPVpMF5_Cb8jNvw4n_wJ6iV4xcSHuFxKcfTBhVF5uiPA6OSoWQP5x_6BX-5YDoaJT_OLA3e0Ifr4D2SH2EYB-WShLiE6plg5U2G6jlOSgy-G8-Rk0XD7yiP4DyPfcejiQ2aH9WVHKeOJ5eQP9CeKEfD44zw45kj_NAQJmIWfMTPCJF048IPPTtcB_GP_2ieFydyxYXGvUgffIO1D96SHW10vEeYI9mNR-qLhnpwfYHz49KR8ijnol58pDGLKWuF4wvKTUGjrE-Q79COdFyI8Aze4C70LMP24-YKJLtSeCHqajJ6e2g0qsdzBd94RNdxHY80oomYocdB5hNiUrGIVtrhJ0f4CY-Y48yOF5rFI93sgNF2hNRhTSk65odGaUhJClc5ArOGdi8-oz_SzaqQ3MKV63DkGX8NT07wVcdf5MmO5Av8C9dOONGIS_mRS7gO8fjhLNMFbQqq4zF0jRPir2j2Y9_xH5Z-_EFiHWE-oaePh8JN3CsaiYgkGvrR_OgVaI2iHv_gTLsQaTeuZ8K5HUdMCWUlHpLIIFyO8TiNO2nQOHzwLkfYQg2RHxXlUfDxHX-MO9Pxwc8J90JSEzmDJk-Ji0cpZanw44mOWpKEkkHDsUFkZsfNaJCOqDvK_fg-9NHx5UHtFGEOPUeu47hw4dzwH22Nc9pgK8dZhJ-h8wipwyS8Dw8cZ0evC8kZJcgrHT22jsfhH_QCv4jMHNp25NLx6Hh4tEtaePzgcNHxDOkYQ3tCxBsvPFKOfyzCB-L5IHyCpzhFHo9zvMeP5vrQbdzwK8N_hFG6F0mdo2GOJ4yxM0bJRFaGH3WyHUdzHWWe4uoH8hWS8EGOGuUSBZ-E6w7sZsKpIId-IzyDptLxhD76GpfxNDHSkdCTS0JMFS-8Rh6e48c6Hv2O8CG05MinRcUX9A0cKoSv4Ts8KbSQbCXyDDeDZ_mKUrwHP8tRi8d94UcX3ImMLzq8o3-DnFXwHG2UJ_Au6FlwNLgOXU_xakKv4zp8VMeN_vBz9OCbQzsFP7h8PB96Df8llNJSWI8KfVkqZMcPaw5ePEgf6HKQjj2YTRmOxslyTJ-K_kj3Qj9M6TjCM33QixGcX8PzHNfxDOfhMx2SHflz_Dr-K6hSKVLQ7MSjII-UQ9fQ53Bi6ahMoz5jNA304xDz7RjTI08eXKeChzmeDeUJfw7OEPVGnB_yoz_M6sZj8MOfHE5wXKLhT0V-MMhjlDv0C-mP_jgt4cxRMgoLSz5GkUZ-3MFTPHCpB-W2EheX43iWeUgfDhdJPAsR_oKWmDGeJRMRoyea3fhyVA6RjsuCCxdDiD-2fsizC5eSp_CPh4UnP5gU6sOpL7g3ocyCSDn0I7-L4znKBg3uKDmeW8Nzsriqg5eMHk33Cn9Q_0hPQtMX4oqOfMedHI-IehuDm92Q9xDzISyH67jOoc_R_OijI61TJLuObzn-zWi6S2gVT3jUIkhOw2pMgU13XPuGM4c7_B_yHCcPPYpD4Tp6DW6To89xIS-qH31w6vjBLksu4mFEDcndIByvI9SPS4PODM2PdumCfOKDXJByhPGKy8FFZcP3olGs4z5T1MrC4yHiK8e_QdSD-KBz9M3RjNIRPfFQQxxH_PBxKYeoSKRw6YGl8Zh4H09i4rogLg_IsDsuHz_6y2imQ2ZThGcO50KaNj2uoZtk6MJjhIm-DeHj4UMzC9V43Hky9Dj0pwv06Dn05MGPR4OnQ3s8_MFDqI8EnYdG6RRSH9eHmgryH192ZCKPPyp-52jqHWXHBT-O5D2Rw85F3JtAX7jeqLCYLMfF4seN_MeUC9pzXCKuR0K7o_mC_1DNDPl21NoLSzuO_sSVBEefZ0hrC8mHU0GVtviDH7XzoM7w9CgfTInCDc8DPhPyH0nkw9vgbEfJRJvwC08I98gj6ESu4ylx3FGJfvIKM7ZRsluER8eVIll6hL-EUsoOcz_K4250WM4Rq7qQXFKO9-iNMzSeYxuLL3vB8CnyBsmxX3iOJ9uHUqLgJ0f148ZxD8ka9QjlfHieF3cUCeEPLXWMnHiCMih5_HAbfA5-NKP0I5m2ncijw2mGq0mxLzvOS6idB2dQS0KPJsoNMsyOP5MQHclyXGgmTbiSKPjhD0fsI7lk9ELzD5eUEH0mvGONatyFZ9GK5D8iHs3hvEEXzcSn6egdCc1HIU-gD3mGU71QvsN5nDk-KYXH0EW40kiuLDH-eDgjPDt6NHF4PHjw_Si3w3fhHz9hnhTRJzuuI3wk-At4NLhw6Sgn_KtwccB_iD9gWxUOP8bhHb8HT0evRcJ_ouuC5oM0t0K_I_7gTUpwXI6RZ4fIE-kkynhzvMwFnzmI1MXeQ1xW9MqDHqkj3Fl0fDyeHa2iB9-CF2EuaD9yBWdi4cHjKEoCZzGPfOGhK8tx4kyHSh3SROODkIpRW4eW54J-NEckOZJGPH8S_MXX5HgiHj18MUMWj8UJ_SL-HL0HOxF0_MZ3xDDzoL_QKzr8gOGH8IH-Ii9-eE8s1Bc-7cS1EY3KHPON2D3axMa344eWx4JmH1dKnDxRUtvQ3DJO4spyaH8ORx9yLVlRNcbRH-4T3NERTXrwD4pJKoGvDlt9IeQS9Drcx7jEdHCqHdO2436Ea0cZIlkmBrmOx8WHK0eZC36Kylks47mNI8zEndAcFzleM2hWxkR_PMH14UOYH4V6ovvh4j7eGflx68H24P_gUsHx7fCNx7g2IX5QLpwW5FqWGDyH_vAVrCXgZ0F-iIqOSswe_ESPZqxRH9su6ISvoz2OR0Xt4Olx96CO43gl-CHqB7rxpzjR7EPlctDNQl6SoxJpIT3-wD_q4cmRB1qOPkbeKI3wa9ijBU9E-HSRL9uh4_7wKA1RK1GI5tFxGe0PMQmNf0iPPyIeHf_xJg9O5IeYv3geFuHJID96SRs-HGePG47xRsTFBfpxScthJ1Eq1D9Cwj-eLcZVH-5wwfvRN0b8BzvCPAE5HemWLOgqGfehHc2XNMj7oOxxpOijBDrBMES6ZclxesQzvBX8oD9e9IJ_tJOMagpzNNfBC4-OqTua96i3w6_RK-HR8DmSWULcxMOV43fwP2iuTMi14UwERUrI6MgX5J-S4BbmQ-yPPDn85DhxamIC_7hSPMf9IJmSuAhzYg9OUkrw6UHzoCdyfUjAHf05NGoWB2-InCeS2XFwH4we5ThODp2i7-Dn4BaHMJKSC7qObPKD5ktm1BS-oEyuBc1xXoiThTmSJ5Qx5VGHXmFwcnjjCJ6SIHmi3Mgf4EqHR7uC7xFOZQiZdUOy7MaNn8eHv4EZ6cFH4T_uI_mHvA9OxUEj1phEDTlzHP-D_UmIo3FIhCxRPeiX4wmuJEeeT2hCCslOPNjvBI80EqwUFE3xI9bICXpUBM0zfej0tPglnDeYXIuRH2KP_Ivw5LiUB1nictCdEvfRTTma73hyfNEjrEw-iLMS4-GFPWlCZPmOZuyPOcR_9JUGuyb64_iFL_hkxH6g7YiXF009PLlw7DkO_kHPFE2VrcSToVeQPkR16M3x-DjCE8ePH_mFhD8eocdxXiu6w2nC6ehxIkHe4cwMVlqOvfBTnJi6LNPR7B_4HOm-QE-OPW4i5DiPXM6E39AEH6Vz9MeRb8ePA7kh3ugRUnoERxH6CrVjfHvgx7BPo1aON8UlPEiupIgfBeuNShmoTTz-FCGpHMnNDp9wncF1XDqSPUeYxMf4ZMecHKd-8DrEHz--m3h6_Cr6ozluyRISh9ERH4d7nMcXBhPJB83xDZP-obGET1oRH8nzB2dYNCeO6Ua-7NDx7InwRyuaH6F148qSJ_jT4SyJ8BLRjBnk5EmG3KLQHdtJosyiJsFvoheyZKeQPMqOL0yk4jz847mEX3g61NfxBrXwWChPNGd0fAuq7ALV9EJ6aDx-hPlRH9eO0PrRHNehJzmKXxkhH-oKHz_S47iEzyJyHDr8RyjxFtV4iCoz4kj_glbh4xPyBR8P_UGYUcfDo9_hLwdv-Ee0EZdwCbqOnHjg_IeT4_ng4zDJD8ls5Ds-JniEx1sC58jxaNAp9LqhH2r8oIKe6LiOntByCbEjVnguR9gvXAmPc0HZo7k5_A7yGr9GaNnR49GFZwo0xDkcHj2uHL3RHEfeQbuI-3CP-yGq6cZPdB6xxT3-Ii-OK4Mr1I6D58MnPGJihDyaP9AV4kXIT2i4ZCeeHtWPX3jm4jh7pNJ6_PgiiM-DXMnxwEctxkap7YeDjsmJij6uBOYiJPuR48aPhyeOKb9RUQyeF8n7IecPJsyI8iVQ80RzjshTaHmD8J3w6EDVCk9zXOlxHM1lBX-RI_nRLwf_DP7hB5WOPwgbEeJb4algxgcvvIE14zhO4j0cKYRmLS_6HfxxHf0nzCz0H82DWsiDLxEndO3hP-jxo9-FMId-5A8-EndUBVX06Wj64yqOvN3wFb9DqNKGPjouAulROccfaUPEP8KTQ9cWPD_CHPXmDG9wXUb4Q8uFXA6eG4zawzochcd__INP9C2yHOIR_sLR5wqavHjU8Dji74Tma0jJ4MdF8B2-GH5mGXvhG-GhqPzQjDpygrFz2GGDn4iT69izEY7Q_7ieLZAVEU1Elsi_HB4DL8dk9fiWoelQnkF-KErErMSTNHikqCq0PrgmBeEp-HRU9CHCjIfmIdJTNFUmTQF7VE2EJqm0Is9SFeVyatj4IyQZXDl6NFWWDpeI3DtOY_SLJ8sM9k2QZ2huVMcTA4cAAYgB4YwSDACBiFBMPPGctY6a6pAAxighhEjKAAaBNsgIIAAQRgFFHCBGIACQIBJAoAAADCKiCDLSCCABQYQQwIwEigkArKGII0OUQEwAogBRoCCHCCKQAEcEkQYQYoQSigDjCBMCIUCJIUQJQQRRiDAAHSEKEOCCEAAgDRQBxDEgkBEGEAQQFkIIIAQhAgnBAFTAQIEAQYgxYwBClAkEACMEAEAAMQgISAxCgiiCACESIWQAUWYQBoBDBhBGgBBAMSMkYogAIwjwBCMkACLCCASEIMARg4QCBCAijCYAKweEAkAJYRgSAkDjjBCMQgioMIYBQIgDQhkBGiKEQAeBFAQAZoACBgFKFAEAOQMAEIQYgICShChGCCDKESSYoEIQAIRiFkAAgQJMCWMYRUAIgAQAigFEgFDEEAEwEAYQaQw0BgEHADDWGSCAWEgIBQQxBghmjBQCCEqMA0YpQIwAwgDjHFBECiWUEQYxohABRAhAjAUCESUARgRQYgAARggKMAVOAEIEc4gyJ4ABABCCjGMIKAIAEQIAIAwChAEgAGEAGAeIUMZY4QghSBhADSCCGiKNlAQoogAChjCDEAEEWEIRA0QohoQAQCkECEGQEQIEMUwYgIgBABgECECCKEQAAkIgYZARSABkmFECASIARQAAIBggQBDggASECKMVIgJIwwQQUAKChDAQAQMJEcwRIAQQAAlCBGEOCEEMA0A5IARBSAgCiUMCMQEEcdYoYQUglgpgCDDCAMoJIAIIxBRyEggCFAIECGEYQYQp4oBgCCAICSAKEUUMIgwIRAxQwDEEDCCOCsAEYIQBIpBUhhoFlGQcAUYgBAQAJIAhgAglFFEYGAGkEswIAAxSlEBAhABAGKWQktApAQRQBABigBIBAAMyNAQqpYACBgACBAHEOAMIEtIoAAgThBkCCCHIChMYAQYBYAUAgCAmTEgEEAEEUUIhNRAABgChCACCKAAEAAAQYwghCAAAAEJgMMEYCEhAAiADCAAFRAACOUKII4gBowwwECABgAJAIKgMoogJAwABQiFBODLECMSQAQYoYCRAAABgjBBGEUiAYQAR6ghiAhBKCFMGEeAFEIIAYBBDABEhFDKKIgOYoAQo4QgToElEAPIMIIAQtVZQgxQoBJIKAENGESSIFAQwgBAgADAAAMBEOGKIQMpJAAgQRCljKCGICKQAIQAYYwQXQCCgDBJECWIIGAgwxgRQRAnDhMMOGICMEAgYIYEiAhADCBGEMiMIAsQQoBAiBAgPiHBEGiMMIIoSoJAABCFJDAFEaKAIAIYyQJQBgigCBAMAgGIAQQYRIZBASAhDABEIIgLEEQA4JgCBSgAAnAEKIIYJoIAYAjAkQCAiCAEMIACYAYAIFDxxSgElGGAcEaGMkIQBBDlSEDFBFRKLAOEEJMIQBYgQgEAGAGEEEKIIcYAZIRChSDOLiAXMMGohUk4IYJFQCAkECCGWGCCIMMwAABATlCgghCjMGWCSEMIAAAigwjhjCAMICQCIEAIZA4gwTgACkGKMMEIVUEwABBzAmBnBlBKCSGIMAcAqpagFBUWCgBRIGCuYUYQhwAggggEIjjKKAyAAMwoE5owIziIiBCSGCAIMAEAoggxFCCAmmFTEWMKUYQgNQBhBgCAEjFNIKeOMQ4AwRYBBwAjGgDDAMQSMUUwAIqAR1jpIEEEEKGMEAkYIhBADQgBEqUFAFEqQIYI4JhSwwihRjUCAGAeQAYgAYrRhgBGkjEBACCEBAEYopIAwQCCGBDAAGUAAoooJJKByRkBDiJDCMa2UAQMYYRgQAGBFADBIGKEAYg4AgAgQSgAoHCFMCgCUAEY5BQBCxjMIEBBEEgAA"); + } + + [Test] + public void should_parse_fpcalc_text_with_noninteger_duration() + { + var text = @"FILE=Adele - 01 - 21 - Rolling in the Deep.flac +DURATION=229.29 +FINGERPRINT=AQAHJlMURlEURcgP6cwRD43Y4Ptw9FowncWPWkf6GB9-JYdP9OgJHw8u4Apw4SsOHMdxHAfwHIfYET_0HiZ55MfxNCm8Hj-mRM8KLlXwIzqS-Que_Cg7NEseE_2j4DUjJBOvI08UNNUufEd5NM-OHzmSUZsO58er7nhUeN6EK3HYwhe0H-fxXEFzlOSHJwpj3MevID7kHyqVqBH6zEI6GzmSs4HTDz4j9DjxKHDvpLiOH7l4iFUWRXiiqtCJmMqNPmiAZEyY5AgTLclxmSWuwJ-O33jz4KdwHt-PsDHRjBNyCQ_-KcGPhoePsJNyiJOiI-5VPKMj_McvfNQRUkyiImEzAv2Ckxb-RUJ-CVpGhMYnB5em4scvHFeuoU7DwHugT8fRecPVpMF5_Cb8jNvw4n_wJ6iV4xcSHuFxKcfTBhVF5uiPA6OSoWQP5x_6BX-5YDoaJT_OLA3e0Ifr4D2SH2EYB-WShLiE6plg5U2G6jlOSgy-G8-Rk0XD7yiP4DyPfcejiQ2aH9WVHKeOJ5eQP9CeKEfD44zw45kj_NAQJmIWfMTPCJF048IPPTtcB_GP_2ieFydyxYXGvUgffIO1D96SHW10vEeYI9mNR-qLhnpwfYHz49KR8ijnol58pDGLKWuF4wvKTUGjrE-Q79COdFyI8Aze4C70LMP24-YKJLtSeCHqajJ6e2g0qsdzBd94RNdxHY80oomYocdB5hNiUrGIVtrhJ0f4CY-Y48yOF5rFI93sgNF2hNRhTSk65odGaUhJClc5ArOGdi8-oz_SzaqQ3MKV63DkGX8NT07wVcdf5MmO5Av8C9dOONGIS_mRS7gO8fjhLNMFbQqq4zF0jRPir2j2Y9_xH5Z-_EFiHWE-oaePh8JN3CsaiYgkGvrR_OgVaI2iHv_gTLsQaTeuZ8K5HUdMCWUlHpLIIFyO8TiNO2nQOHzwLkfYQg2RHxXlUfDxHX-MO9Pxwc8J90JSEzmDJk-Ji0cpZanw44mOWpKEkkHDsUFkZsfNaJCOqDvK_fg-9NHx5UHtFGEOPUeu47hw4dzwH22Nc9pgK8dZhJ-h8wipwyS8Dw8cZ0evC8kZJcgrHT22jsfhH_QCv4jMHNp25NLx6Hh4tEtaePzgcNHxDOkYQ3tCxBsvPFKOfyzCB-L5IHyCpzhFHo9zvMeP5vrQbdzwK8N_hFG6F0mdo2GOJ4yxM0bJRFaGH3WyHUdzHWWe4uoH8hWS8EGOGuUSBZ-E6w7sZsKpIId-IzyDptLxhD76GpfxNDHSkdCTS0JMFS-8Rh6e48c6Hv2O8CG05MinRcUX9A0cKoSv4Ts8KbSQbCXyDDeDZ_mKUrwHP8tRi8d94UcX3ImMLzq8o3-DnFXwHG2UJ_Au6FlwNLgOXU_xakKv4zp8VMeN_vBz9OCbQzsFP7h8PB96Df8llNJSWI8KfVkqZMcPaw5ePEgf6HKQjj2YTRmOxslyTJ-K_kj3Qj9M6TjCM33QixGcX8PzHNfxDOfhMx2SHflz_Dr-K6hSKVLQ7MSjII-UQ9fQ53Bi6ahMoz5jNA304xDz7RjTI08eXKeChzmeDeUJfw7OEPVGnB_yoz_M6sZj8MOfHE5wXKLhT0V-MMhjlDv0C-mP_jgt4cxRMgoLSz5GkUZ-3MFTPHCpB-W2EheX43iWeUgfDhdJPAsR_oKWmDGeJRMRoyea3fhyVA6RjsuCCxdDiD-2fsizC5eSp_CPh4UnP5gU6sOpL7g3ocyCSDn0I7-L4znKBg3uKDmeW8Nzsriqg5eMHk33Cn9Q_0hPQtMX4oqOfMedHI-IehuDm92Q9xDzISyH67jOoc_R_OijI61TJLuObzn-zWi6S2gVT3jUIkhOw2pMgU13XPuGM4c7_B_yHCcPPYpD4Tp6DW6To89xIS-qH31w6vjBLksu4mFEDcndIByvI9SPS4PODM2PdumCfOKDXJByhPGKy8FFZcP3olGs4z5T1MrC4yHiK8e_QdSD-KBz9M3RjNIRPfFQQxxH_PBxKYeoSKRw6YGl8Zh4H09i4rogLg_IsDsuHz_6y2imQ2ZThGcO50KaNj2uoZtk6MJjhIm-DeHj4UMzC9V43Hky9Dj0pwv06Dn05MGPR4OnQ3s8_MFDqI8EnYdG6RRSH9eHmgryH192ZCKPPyp-52jqHWXHBT-O5D2Rw85F3JtAX7jeqLCYLMfF4seN_MeUC9pzXCKuR0K7o_mC_1DNDPl21NoLSzuO_sSVBEefZ0hrC8mHU0GVtviDH7XzoM7w9CgfTInCDc8DPhPyH0nkw9vgbEfJRJvwC08I98gj6ESu4ylx3FGJfvIKM7ZRsluER8eVIll6hL-EUsoOcz_K4250WM4Rq7qQXFKO9-iNMzSeYxuLL3vB8CnyBsmxX3iOJ9uHUqLgJ0f148ZxD8ka9QjlfHieF3cUCeEPLXWMnHiCMih5_HAbfA5-NKP0I5m2ncijw2mGq0mxLzvOS6idB2dQS0KPJsoNMsyOP5MQHclyXGgmTbiSKPjhD0fsI7lk9ELzD5eUEH0mvGONatyFZ9GK5D8iHs3hvEEXzcSn6egdCc1HIU-gD3mGU71QvsN5nDk-KYXH0EW40kiuLDH-eDgjPDt6NHF4PHjw_Si3w3fhHz9hnhTRJzuuI3wk-At4NLhw6Sgn_KtwccB_iD9gWxUOP8bhHb8HT0evRcJ_ouuC5oM0t0K_I_7gTUpwXI6RZ4fIE-kkynhzvMwFnzmI1MXeQ1xW9MqDHqkj3Fl0fDyeHa2iB9-CF2EuaD9yBWdi4cHjKEoCZzGPfOGhK8tx4kyHSh3SROODkIpRW4eW54J-NEckOZJGPH8S_MXX5HgiHj18MUMWj8UJ_SL-HL0HOxF0_MZ3xDDzoL_QKzr8gOGH8IH-Ii9-eE8s1Bc-7cS1EY3KHPON2D3axMa344eWx4JmH1dKnDxRUtvQ3DJO4spyaH8ORx9yLVlRNcbRH-4T3NERTXrwD4pJKoGvDlt9IeQS9Drcx7jEdHCqHdO2436Ea0cZIlkmBrmOx8WHK0eZC36Kylks47mNI8zEndAcFzleM2hWxkR_PMH14UOYH4V6ovvh4j7eGflx68H24P_gUsHx7fCNx7g2IX5QLpwW5FqWGDyH_vAVrCXgZ0F-iIqOSswe_ESPZqxRH9su6ISvoz2OR0Xt4Olx96CO43gl-CHqB7rxpzjR7EPlctDNQl6SoxJpIT3-wD_q4cmRB1qOPkbeKI3wa9ijBU9E-HSRL9uh4_7wKA1RK1GI5tFxGe0PMQmNf0iPPyIeHf_xJg9O5IeYv3geFuHJID96SRs-HGePG47xRsTFBfpxScthJ1Eq1D9Cwj-eLcZVH-5wwfvRN0b8BzvCPAE5HemWLOgqGfehHc2XNMj7oOxxpOijBDrBMES6ZclxesQzvBX8oD9e9IJ_tJOMagpzNNfBC4-OqTua96i3w6_RK-HR8DmSWULcxMOV43fwP2iuTMi14UwERUrI6MgX5J-S4BbmQ-yPPDn85DhxamIC_7hSPMf9IJmSuAhzYg9OUkrw6UHzoCdyfUjAHf05NGoWB2-InCeS2XFwH4we5ThODp2i7-Dn4BaHMJKSC7qObPKD5ktm1BS-oEyuBc1xXoiThTmSJ5Qx5VGHXmFwcnjjCJ6SIHmi3Mgf4EqHR7uC7xFOZQiZdUOy7MaNn8eHv4EZ6cFH4T_uI_mHvA9OxUEj1phEDTlzHP-D_UmIo3FIhCxRPeiX4wmuJEeeT2hCCslOPNjvBI80EqwUFE3xI9bICXpUBM0zfej0tPglnDeYXIuRH2KP_Ivw5LiUB1nictCdEvfRTTma73hyfNEjrEw-iLMS4-GFPWlCZPmOZuyPOcR_9JUGuyb64_iFL_hkxH6g7YiXF009PLlw7DkO_kHPFE2VrcSToVeQPkR16M3x-DjCE8ePH_mFhD8eocdxXiu6w2nC6ehxIkHe4cwMVlqOvfBTnJi6LNPR7B_4HOm-QE-OPW4i5DiPXM6E39AEH6Vz9MeRb8ePA7kh3ugRUnoERxH6CrVjfHvgx7BPo1aON8UlPEiupIgfBeuNShmoTTz-FCGpHMnNDp9wncF1XDqSPUeYxMf4ZMecHKd-8DrEHz--m3h6_Cr6ozluyRISh9ERH4d7nMcXBhPJB83xDZP-obGET1oRH8nzB2dYNCeO6Ua-7NDx7InwRyuaH6F148qSJ_jT4SyJ8BLRjBnk5EmG3KLQHdtJosyiJsFvoheyZKeQPMqOL0yk4jz847mEX3g61NfxBrXwWChPNGd0fAuq7ALV9EJ6aDx-hPlRH9eO0PrRHNehJzmKXxkhH-oKHz_S47iEzyJyHDr8RyjxFtV4iCoz4kj_glbh4xPyBR8P_UGYUcfDo9_hLwdv-Ee0EZdwCbqOnHjg_IeT4_ng4zDJD8ls5Ds-JniEx1sC58jxaNAp9LqhH2r8oIKe6LiOntByCbEjVnguR9gvXAmPc0HZo7k5_A7yGr9GaNnR49GFZwo0xDkcHj2uHL3RHEfeQbuI-3CP-yGq6cZPdB6xxT3-Ii-OK4Mr1I6D58MnPGJihDyaP9AV4kXIT2i4ZCeeHtWPX3jm4jh7pNJ6_PgiiM-DXMnxwEctxkap7YeDjsmJij6uBOYiJPuR48aPhyeOKb9RUQyeF8n7IecPJsyI8iVQ80RzjshTaHmD8J3w6EDVCk9zXOlxHM1lBX-RI_nRLwf_DP7hB5WOPwgbEeJb4algxgcvvIE14zhO4j0cKYRmLS_6HfxxHf0nzCz0H82DWsiDLxEndO3hP-jxo9-FMId-5A8-EndUBVX06Wj64yqOvN3wFb9DqNKGPjouAulROccfaUPEP8KTQ9cWPD_CHPXmDG9wXUb4Q8uFXA6eG4zawzochcd__INP9C2yHOIR_sLR5wqavHjU8Dji74Tma0jJ4MdF8B2-GH5mGXvhG-GhqPzQjDpygrFz2GGDn4iT69izEY7Q_7ieLZAVEU1Elsi_HB4DL8dk9fiWoelQnkF-KErErMSTNHikqCq0PrgmBeEp-HRU9CHCjIfmIdJTNFUmTQF7VE2EJqm0Is9SFeVyatj4IyQZXDl6NFWWDpeI3DtOY_SLJ8sM9k2QZ2huVMcTA4cAAYgB4YwSDACBiFBMPPGctY6a6pAAxighhEjKAAaBNsgIIAAQRgFFHCBGIACQIBJAoAAADCKiCDLSCCABQYQQwIwEigkArKGII0OUQEwAogBRoCCHCCKQAEcEkQYQYoQSigDjCBMCIUCJIUQJQQRRiDAAHSEKEOCCEAAgDRQBxDEgkBEGEAQQFkIIIAQhAgnBAFTAQIEAQYgxYwBClAkEACMEAEAAMQgISAxCgiiCACESIWQAUWYQBoBDBhBGgBBAMSMkYogAIwjwBCMkACLCCASEIMARg4QCBCAijCYAKweEAkAJYRgSAkDjjBCMQgioMIYBQIgDQhkBGiKEQAeBFAQAZoACBgFKFAEAOQMAEIQYgICShChGCCDKESSYoEIQAIRiFkAAgQJMCWMYRUAIgAQAigFEgFDEEAEwEAYQaQw0BgEHADDWGSCAWEgIBQQxBghmjBQCCEqMA0YpQIwAwgDjHFBECiWUEQYxohABRAhAjAUCESUARgRQYgAARggKMAVOAEIEc4gyJ4ABABCCjGMIKAIAEQIAIAwChAEgAGEAGAeIUMZY4QghSBhADSCCGiKNlAQoogAChjCDEAEEWEIRA0QohoQAQCkECEGQEQIEMUwYgIgBABgECECCKEQAAkIgYZARSABkmFECASIARQAAIBggQBDggASECKMVIgJIwwQQUAKChDAQAQMJEcwRIAQQAAlCBGEOCEEMA0A5IARBSAgCiUMCMQEEcdYoYQUglgpgCDDCAMoJIAIIxBRyEggCFAIECGEYQYQp4oBgCCAICSAKEUUMIgwIRAxQwDEEDCCOCsAEYIQBIpBUhhoFlGQcAUYgBAQAJIAhgAglFFEYGAGkEswIAAxSlEBAhABAGKWQktApAQRQBABigBIBAAMyNAQqpYACBgACBAHEOAMIEtIoAAgThBkCCCHIChMYAQYBYAUAgCAmTEgEEAEEUUIhNRAABgChCACCKAAEAAAQYwghCAAAAEJgMMEYCEhAAiADCAAFRAACOUKII4gBowwwECABgAJAIKgMoogJAwABQiFBODLECMSQAQYoYCRAAABgjBBGEUiAYQAR6ghiAhBKCFMGEeAFEIIAYBBDABEhFDKKIgOYoAQo4QgToElEAPIMIIAQtVZQgxQoBJIKAENGESSIFAQwgBAgADAAAMBEOGKIQMpJAAgQRCljKCGICKQAIQAYYwQXQCCgDBJECWIIGAgwxgRQRAnDhMMOGICMEAgYIYEiAhADCBGEMiMIAsQQoBAiBAgPiHBEGiMMIIoSoJAABCFJDAFEaKAIAIYyQJQBgigCBAMAgGIAQQYRIZBASAhDABEIIgLEEQA4JgCBSgAAnAEKIIYJoIAYAjAkQCAiCAEMIACYAYAIFDxxSgElGGAcEaGMkIQBBDlSEDFBFRKLAOEEJMIQBYgQgEAGAGEEEKIIcYAZIRChSDOLiAXMMGohUk4IYJFQCAkECCGWGCCIMMwAABATlCgghCjMGWCSEMIAAAigwjhjCAMICQCIEAIZA4gwTgACkGKMMEIVUEwABBzAmBnBlBKCSGIMAcAqpagFBUWCgBRIGCuYUYQhwAggggEIjjKKAyAAMwoE5owIziIiBCSGCAIMAEAoggxFCCAmmFTEWMKUYQgNQBhBgCAEjFNIKeOMQ4AwRYBBwAjGgDDAMQSMUUwAIqAR1jpIEEEEKGMEAkYIhBADQgBEqUFAFEqQIYI4JhSwwihRjUCAGAeQAYgAYrRhgBGkjEBACCEBAEYopIAwQCCGBDAAGUAAoooJJKByRkBDiJDCMa2UAQMYYRgQAGBFADBIGKEAYg4AgAgQSgAoHCFMCgCUAEY5BQBCxjMIEBBEEgAA +"; + + var result = Subject.ParseFpcalcTextOutput(text); + result.Duration.Should().Be(229.29); + result.Fingerprint.Should().Be("AQAHJlMURlEURcgP6cwRD43Y4Ptw9FowncWPWkf6GB9-JYdP9OgJHw8u4Apw4SsOHMdxHAfwHIfYET_0HiZ55MfxNCm8Hj-mRM8KLlXwIzqS-Que_Cg7NEseE_2j4DUjJBOvI08UNNUufEd5NM-OHzmSUZsO58er7nhUeN6EK3HYwhe0H-fxXEFzlOSHJwpj3MevID7kHyqVqBH6zEI6GzmSs4HTDz4j9DjxKHDvpLiOH7l4iFUWRXiiqtCJmMqNPmiAZEyY5AgTLclxmSWuwJ-O33jz4KdwHt-PsDHRjBNyCQ_-KcGPhoePsJNyiJOiI-5VPKMj_McvfNQRUkyiImEzAv2Ckxb-RUJ-CVpGhMYnB5em4scvHFeuoU7DwHugT8fRecPVpMF5_Cb8jNvw4n_wJ6iV4xcSHuFxKcfTBhVF5uiPA6OSoWQP5x_6BX-5YDoaJT_OLA3e0Ifr4D2SH2EYB-WShLiE6plg5U2G6jlOSgy-G8-Rk0XD7yiP4DyPfcejiQ2aH9WVHKeOJ5eQP9CeKEfD44zw45kj_NAQJmIWfMTPCJF048IPPTtcB_GP_2ieFydyxYXGvUgffIO1D96SHW10vEeYI9mNR-qLhnpwfYHz49KR8ijnol58pDGLKWuF4wvKTUGjrE-Q79COdFyI8Aze4C70LMP24-YKJLtSeCHqajJ6e2g0qsdzBd94RNdxHY80oomYocdB5hNiUrGIVtrhJ0f4CY-Y48yOF5rFI93sgNF2hNRhTSk65odGaUhJClc5ArOGdi8-oz_SzaqQ3MKV63DkGX8NT07wVcdf5MmO5Av8C9dOONGIS_mRS7gO8fjhLNMFbQqq4zF0jRPir2j2Y9_xH5Z-_EFiHWE-oaePh8JN3CsaiYgkGvrR_OgVaI2iHv_gTLsQaTeuZ8K5HUdMCWUlHpLIIFyO8TiNO2nQOHzwLkfYQg2RHxXlUfDxHX-MO9Pxwc8J90JSEzmDJk-Ji0cpZanw44mOWpKEkkHDsUFkZsfNaJCOqDvK_fg-9NHx5UHtFGEOPUeu47hw4dzwH22Nc9pgK8dZhJ-h8wipwyS8Dw8cZ0evC8kZJcgrHT22jsfhH_QCv4jMHNp25NLx6Hh4tEtaePzgcNHxDOkYQ3tCxBsvPFKOfyzCB-L5IHyCpzhFHo9zvMeP5vrQbdzwK8N_hFG6F0mdo2GOJ4yxM0bJRFaGH3WyHUdzHWWe4uoH8hWS8EGOGuUSBZ-E6w7sZsKpIId-IzyDptLxhD76GpfxNDHSkdCTS0JMFS-8Rh6e48c6Hv2O8CG05MinRcUX9A0cKoSv4Ts8KbSQbCXyDDeDZ_mKUrwHP8tRi8d94UcX3ImMLzq8o3-DnFXwHG2UJ_Au6FlwNLgOXU_xakKv4zp8VMeN_vBz9OCbQzsFP7h8PB96Df8llNJSWI8KfVkqZMcPaw5ePEgf6HKQjj2YTRmOxslyTJ-K_kj3Qj9M6TjCM33QixGcX8PzHNfxDOfhMx2SHflz_Dr-K6hSKVLQ7MSjII-UQ9fQ53Bi6ahMoz5jNA304xDz7RjTI08eXKeChzmeDeUJfw7OEPVGnB_yoz_M6sZj8MOfHE5wXKLhT0V-MMhjlDv0C-mP_jgt4cxRMgoLSz5GkUZ-3MFTPHCpB-W2EheX43iWeUgfDhdJPAsR_oKWmDGeJRMRoyea3fhyVA6RjsuCCxdDiD-2fsizC5eSp_CPh4UnP5gU6sOpL7g3ocyCSDn0I7-L4znKBg3uKDmeW8Nzsriqg5eMHk33Cn9Q_0hPQtMX4oqOfMedHI-IehuDm92Q9xDzISyH67jOoc_R_OijI61TJLuObzn-zWi6S2gVT3jUIkhOw2pMgU13XPuGM4c7_B_yHCcPPYpD4Tp6DW6To89xIS-qH31w6vjBLksu4mFEDcndIByvI9SPS4PODM2PdumCfOKDXJByhPGKy8FFZcP3olGs4z5T1MrC4yHiK8e_QdSD-KBz9M3RjNIRPfFQQxxH_PBxKYeoSKRw6YGl8Zh4H09i4rogLg_IsDsuHz_6y2imQ2ZThGcO50KaNj2uoZtk6MJjhIm-DeHj4UMzC9V43Hky9Dj0pwv06Dn05MGPR4OnQ3s8_MFDqI8EnYdG6RRSH9eHmgryH192ZCKPPyp-52jqHWXHBT-O5D2Rw85F3JtAX7jeqLCYLMfF4seN_MeUC9pzXCKuR0K7o_mC_1DNDPl21NoLSzuO_sSVBEefZ0hrC8mHU0GVtviDH7XzoM7w9CgfTInCDc8DPhPyH0nkw9vgbEfJRJvwC08I98gj6ESu4ylx3FGJfvIKM7ZRsluER8eVIll6hL-EUsoOcz_K4250WM4Rq7qQXFKO9-iNMzSeYxuLL3vB8CnyBsmxX3iOJ9uHUqLgJ0f148ZxD8ka9QjlfHieF3cUCeEPLXWMnHiCMih5_HAbfA5-NKP0I5m2ncijw2mGq0mxLzvOS6idB2dQS0KPJsoNMsyOP5MQHclyXGgmTbiSKPjhD0fsI7lk9ELzD5eUEH0mvGONatyFZ9GK5D8iHs3hvEEXzcSn6egdCc1HIU-gD3mGU71QvsN5nDk-KYXH0EW40kiuLDH-eDgjPDt6NHF4PHjw_Si3w3fhHz9hnhTRJzuuI3wk-At4NLhw6Sgn_KtwccB_iD9gWxUOP8bhHb8HT0evRcJ_ouuC5oM0t0K_I_7gTUpwXI6RZ4fIE-kkynhzvMwFnzmI1MXeQ1xW9MqDHqkj3Fl0fDyeHa2iB9-CF2EuaD9yBWdi4cHjKEoCZzGPfOGhK8tx4kyHSh3SROODkIpRW4eW54J-NEckOZJGPH8S_MXX5HgiHj18MUMWj8UJ_SL-HL0HOxF0_MZ3xDDzoL_QKzr8gOGH8IH-Ii9-eE8s1Bc-7cS1EY3KHPON2D3axMa344eWx4JmH1dKnDxRUtvQ3DJO4spyaH8ORx9yLVlRNcbRH-4T3NERTXrwD4pJKoGvDlt9IeQS9Drcx7jEdHCqHdO2436Ea0cZIlkmBrmOx8WHK0eZC36Kylks47mNI8zEndAcFzleM2hWxkR_PMH14UOYH4V6ovvh4j7eGflx68H24P_gUsHx7fCNx7g2IX5QLpwW5FqWGDyH_vAVrCXgZ0F-iIqOSswe_ESPZqxRH9su6ISvoz2OR0Xt4Olx96CO43gl-CHqB7rxpzjR7EPlctDNQl6SoxJpIT3-wD_q4cmRB1qOPkbeKI3wa9ijBU9E-HSRL9uh4_7wKA1RK1GI5tFxGe0PMQmNf0iPPyIeHf_xJg9O5IeYv3geFuHJID96SRs-HGePG47xRsTFBfpxScthJ1Eq1D9Cwj-eLcZVH-5wwfvRN0b8BzvCPAE5HemWLOgqGfehHc2XNMj7oOxxpOijBDrBMES6ZclxesQzvBX8oD9e9IJ_tJOMagpzNNfBC4-OqTua96i3w6_RK-HR8DmSWULcxMOV43fwP2iuTMi14UwERUrI6MgX5J-S4BbmQ-yPPDn85DhxamIC_7hSPMf9IJmSuAhzYg9OUkrw6UHzoCdyfUjAHf05NGoWB2-InCeS2XFwH4we5ThODp2i7-Dn4BaHMJKSC7qObPKD5ktm1BS-oEyuBc1xXoiThTmSJ5Qx5VGHXmFwcnjjCJ6SIHmi3Mgf4EqHR7uC7xFOZQiZdUOy7MaNn8eHv4EZ6cFH4T_uI_mHvA9OxUEj1phEDTlzHP-D_UmIo3FIhCxRPeiX4wmuJEeeT2hCCslOPNjvBI80EqwUFE3xI9bICXpUBM0zfej0tPglnDeYXIuRH2KP_Ivw5LiUB1nictCdEvfRTTma73hyfNEjrEw-iLMS4-GFPWlCZPmOZuyPOcR_9JUGuyb64_iFL_hkxH6g7YiXF009PLlw7DkO_kHPFE2VrcSToVeQPkR16M3x-DjCE8ePH_mFhD8eocdxXiu6w2nC6ehxIkHe4cwMVlqOvfBTnJi6LNPR7B_4HOm-QE-OPW4i5DiPXM6E39AEH6Vz9MeRb8ePA7kh3ugRUnoERxH6CrVjfHvgx7BPo1aON8UlPEiupIgfBeuNShmoTTz-FCGpHMnNDp9wncF1XDqSPUeYxMf4ZMecHKd-8DrEHz--m3h6_Cr6ozluyRISh9ERH4d7nMcXBhPJB83xDZP-obGET1oRH8nzB2dYNCeO6Ua-7NDx7InwRyuaH6F148qSJ_jT4SyJ8BLRjBnk5EmG3KLQHdtJosyiJsFvoheyZKeQPMqOL0yk4jz847mEX3g61NfxBrXwWChPNGd0fAuq7ALV9EJ6aDx-hPlRH9eO0PrRHNehJzmKXxkhH-oKHz_S47iEzyJyHDr8RyjxFtV4iCoz4kj_glbh4xPyBR8P_UGYUcfDo9_hLwdv-Ee0EZdwCbqOnHjg_IeT4_ng4zDJD8ls5Ds-JniEx1sC58jxaNAp9LqhH2r8oIKe6LiOntByCbEjVnguR9gvXAmPc0HZo7k5_A7yGr9GaNnR49GFZwo0xDkcHj2uHL3RHEfeQbuI-3CP-yGq6cZPdB6xxT3-Ii-OK4Mr1I6D58MnPGJihDyaP9AV4kXIT2i4ZCeeHtWPX3jm4jh7pNJ6_PgiiM-DXMnxwEctxkap7YeDjsmJij6uBOYiJPuR48aPhyeOKb9RUQyeF8n7IecPJsyI8iVQ80RzjshTaHmD8J3w6EDVCk9zXOlxHM1lBX-RI_nRLwf_DP7hB5WOPwgbEeJb4algxgcvvIE14zhO4j0cKYRmLS_6HfxxHf0nzCz0H82DWsiDLxEndO3hP-jxo9-FMId-5A8-EndUBVX06Wj64yqOvN3wFb9DqNKGPjouAulROccfaUPEP8KTQ9cWPD_CHPXmDG9wXUb4Q8uFXA6eG4zawzochcd__INP9C2yHOIR_sLR5wqavHjU8Dji74Tma0jJ4MdF8B2-GH5mGXvhG-GhqPzQjDpygrFz2GGDn4iT69izEY7Q_7ieLZAVEU1Elsi_HB4DL8dk9fiWoelQnkF-KErErMSTNHikqCq0PrgmBeEp-HRU9CHCjIfmIdJTNFUmTQF7VE2EJqm0Is9SFeVyatj4IyQZXDl6NFWWDpeI3DtOY_SLJ8sM9k2QZ2huVMcTA4cAAYgB4YwSDACBiFBMPPGctY6a6pAAxighhEjKAAaBNsgIIAAQRgFFHCBGIACQIBJAoAAADCKiCDLSCCABQYQQwIwEigkArKGII0OUQEwAogBRoCCHCCKQAEcEkQYQYoQSigDjCBMCIUCJIUQJQQRRiDAAHSEKEOCCEAAgDRQBxDEgkBEGEAQQFkIIIAQhAgnBAFTAQIEAQYgxYwBClAkEACMEAEAAMQgISAxCgiiCACESIWQAUWYQBoBDBhBGgBBAMSMkYogAIwjwBCMkACLCCASEIMARg4QCBCAijCYAKweEAkAJYRgSAkDjjBCMQgioMIYBQIgDQhkBGiKEQAeBFAQAZoACBgFKFAEAOQMAEIQYgICShChGCCDKESSYoEIQAIRiFkAAgQJMCWMYRUAIgAQAigFEgFDEEAEwEAYQaQw0BgEHADDWGSCAWEgIBQQxBghmjBQCCEqMA0YpQIwAwgDjHFBECiWUEQYxohABRAhAjAUCESUARgRQYgAARggKMAVOAEIEc4gyJ4ABABCCjGMIKAIAEQIAIAwChAEgAGEAGAeIUMZY4QghSBhADSCCGiKNlAQoogAChjCDEAEEWEIRA0QohoQAQCkECEGQEQIEMUwYgIgBABgECECCKEQAAkIgYZARSABkmFECASIARQAAIBggQBDggASECKMVIgJIwwQQUAKChDAQAQMJEcwRIAQQAAlCBGEOCEEMA0A5IARBSAgCiUMCMQEEcdYoYQUglgpgCDDCAMoJIAIIxBRyEggCFAIECGEYQYQp4oBgCCAICSAKEUUMIgwIRAxQwDEEDCCOCsAEYIQBIpBUhhoFlGQcAUYgBAQAJIAhgAglFFEYGAGkEswIAAxSlEBAhABAGKWQktApAQRQBABigBIBAAMyNAQqpYACBgACBAHEOAMIEtIoAAgThBkCCCHIChMYAQYBYAUAgCAmTEgEEAEEUUIhNRAABgChCACCKAAEAAAQYwghCAAAAEJgMMEYCEhAAiADCAAFRAACOUKII4gBowwwECABgAJAIKgMoogJAwABQiFBODLECMSQAQYoYCRAAABgjBBGEUiAYQAR6ghiAhBKCFMGEeAFEIIAYBBDABEhFDKKIgOYoAQo4QgToElEAPIMIIAQtVZQgxQoBJIKAENGESSIFAQwgBAgADAAAMBEOGKIQMpJAAgQRCljKCGICKQAIQAYYwQXQCCgDBJECWIIGAgwxgRQRAnDhMMOGICMEAgYIYEiAhADCBGEMiMIAsQQoBAiBAgPiHBEGiMMIIoSoJAABCFJDAFEaKAIAIYyQJQBgigCBAMAgGIAQQYRIZBASAhDABEIIgLEEQA4JgCBSgAAnAEKIIYJoIAYAjAkQCAiCAEMIACYAYAIFDxxSgElGGAcEaGMkIQBBDlSEDFBFRKLAOEEJMIQBYgQgEAGAGEEEKIIcYAZIRChSDOLiAXMMGohUk4IYJFQCAkECCGWGCCIMMwAABATlCgghCjMGWCSEMIAAAigwjhjCAMICQCIEAIZA4gwTgACkGKMMEIVUEwABBzAmBnBlBKCSGIMAcAqpagFBUWCgBRIGCuYUYQhwAggggEIjjKKAyAAMwoE5owIziIiBCSGCAIMAEAoggxFCCAmmFTEWMKUYQgNQBhBgCAEjFNIKeOMQ4AwRYBBwAjGgDDAMQSMUUwAIqAR1jpIEEEEKGMEAkYIhBADQgBEqUFAFEqQIYI4JhSwwihRjUCAGAeQAYgAYrRhgBGkjEBACCEBAEYopIAwQCCGBDAAGUAAoooJJKByRkBDiJDCMa2UAQMYYRgQAGBFADBIGKEAYg4AgAgQSgAoHCFMCgCUAEY5BQBCxjMIEBBEEgAA"); + } + + [TestCase("nin.mp3")] + [TestCase("nin.flac")] + public void should_fingerprint_file(string file) + { + var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Media", file); + + var fingerprint = Subject.GetFingerprint(path); + fingerprint.Should().NotBeNull(); + fingerprint.Fingerprint.Should().Be("AQACmomSJEySRNISHCa6zDh6OO_hoUfr48cvHD83-JApYL6F8oCMHvlg46qOHYf740L6ascN_fj0wh90aD2OC41FC-Exf0IvPBKyH6qNOGLEgrBxXC3w4MiFB2J--Cce4cYXpLwGXER64RHxQxT142eJFMeL40g_HSbUh7h6xONhuDZ-5NpxFrpwfCrSo4pj_EJYHoehvzjeHcbnDGoOH30WPEYeEz3xI72P0sLRNRHx49IB_IXx4_iH5yF-HOdx2FoE8R5-HM2hJmOF6hFSXziPHdtxmLmQ5sKYHzouFjlMokfOBncsaE91mPoQ_MctTCqRX9CEnziO_zhcCPk_POmOoz8u1KOQ6-ihazgaHw9OaPlxAvmKfDhUpcWH_MNbBr-Do0eYH7p-5PqFPUbD5TK2o6p0_MeP8IcOw0c-H8eRZxCjRniE8HgPhlKKxqmk4jnai8JZBO9SPMauFEeOHxeOFj_-NsgFdaHxIzx8PMzQRsQfRL4GHvoiNLnwXD6uA-Jx_DiM4-Wh5nBUIXx8vEMtBt60wWwQ3mh7ovE1PCmaHMdx9AiOM8SFXMfVC_oToDl64sqSsfiNB-Wh5_iT4xf-D8dh6PjxAP6CP8fjQf9ghM069IYfwxf8C5cQdwcXC5-CPNiP48WBKh_i49DN4Dz6C82Rx8mQH7-wgzls5VDznPhz4PBx_AAr-PjxVBHUB-aHQld2oWfQODnOoKcLX7iW4fHBFcfxHPOgH_Mt_HjEJDoq9cKH44nyQyfCjBeeVBP4VEUfvIvwIIueUYWTJviFH_lx_EeOQ7wG1sibwNGL7oh_6GzQTGKIkFm64CE-1TiYH_6OWHXw_Dh-2Dp0_TgR43mOXjOOkOGhZdKHPHiIvgy2H_0xJlsQX9Cu48gN__iR5_jBHj7U6wiVtyj9oemOz8LR_Ba-IM8VXDPCJ1PBRdMRyhe8aYZ-HP3R48fBvdCeHM9R7Qve48htuMoS0eiR-_jxoDkYLjoeJ9DUU_iA4zr44zec4_EOPcdTeAib4URPPLCPnseniUL0D6HSHf9xoz3wByF73Ic-VA_-fYh_ePwQ_cGlQD-ap-jDHOEpXENWwrEyPMe1I8ePHz_yQz-8D_-NrIbzo7uF-IcuohkZXBpyH89jnAJFHSH5DP9R5fh0FDrB42mGazj-bbhz6DwahGSKq_jxGwx3-EIsbnh-wdYO4YSPHz8OQszxPMSPhsf3HDl6zTCRhwr6g1fxPAej44txfLhw6JiPisfb4LhzlDy0ZDhj5NqIo3-w_UA6Cn2GKk6hHtcAH7-JH4cDNSkeHS9yH9_x4cyQ6yIemM5xBefx9HCqGT-OHzxOaDwefLfwpBfyC-EP_9CZo88snEPOHl-EHn4QMSd-XAN2PAe8HdBlH9SLrzm6HH-hK0d5mCrOoj9-MMe3hrhycPERjw9-YciJHx-EP8cTsRvOw5OOHjpjY14yEj0ycbBxZUXEVHB25BrM4McOhCwwCCDgjAQAFIOoAIICIQSAQiBDFELAEaYUMMtAKgUSACBnRJEQEGCAeAoRRIQQliFAsVYGGOKIcEYAAIVRgjgAJDNGKYCUBgIIKLwxCBHjCVDGECYuAsJBDhUSBhGhCACGKKOEEUUI4AAxHAkFHQEAQWeQJAYIYGQUAgBBgNGAMAEQMABRJwQSD2gCGAIMAOUIYhQBKwBjBCKmlCIBAACUYmYJZA0gRDgBCDDwWQcRAAAARJxACoFuBABOAACIwlY5Q4BwQAEAJDDYKZGQwogIyQwSABFmFDHDCAKAUMwooYAQABEgACPEAEORcQQ4aAAgHBRBpFFMECUoUJQZs4ARSBEFgUHKOUGAAYQ5QxAQyAFADDlAKgKMUAIQBxpQjBDLIBNCAGKsIBQRRQSSwjkFkCPDEOEQIUwKJJiywADhtHGAEaKAUBASxRRBxBgCDFBAKYWAAkIAYQQQBAkLBFEAKEIexEYxQYhFBghiAEDECCABMQAACh1QiBAgCDBUIEUZQAAAwpxYADFzAIAEIQEwkAQIAJhwylElhINSOMEEAwwggJVSAjHjgBQIEcMQcUARBJRT8AKBgAMAGKMIABIxQYVwhACAFbKAAUEAccAR4wmRlAjCiEEGMGIcEAQYZCSCTCInBQ"); + fingerprint.Duration.Should().BeApproximately(85.11, 0.1); + } + + [TestCase("nin.mp3")] + [TestCase("nin.flac")] + public void should_lookup_file(string file) + { + UseRealHttp(); + + var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Media", file); + var localTrack = new LocalTrack { Path = path }; + Subject.Lookup(new List { localTrack }, 0.5); + localTrack.AcoustIdResults.Should().NotBeNull(); + localTrack.AcoustIdResults.Should().Contain("30f3f33e-8d0c-4e69-8539-cbd701d18f28"); + } + + [Test] + public void should_lookup_list() + { + UseRealHttp(); + + var files = new [] { + "nin.mp3", + "nin.flac" + }.Select(x => new LocalTrack { Path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Media", x) }).ToList(); + Subject.Lookup(files, 0.5); + + files[0].AcoustIdResults.Should().Contain("30f3f33e-8d0c-4e69-8539-cbd701d18f28"); + files[1].AcoustIdResults.Should().Contain("30f3f33e-8d0c-4e69-8539-cbd701d18f28"); + } + + [Test] + public void should_lookup_list_when_fpcalc_fails_for_some_files() + { + UseRealHttp(); + + var files = new [] { + "nin.mp3", + "missing.mp3", + "nin.flac" + }.Select(x => new LocalTrack { Path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Media", x) }).ToList(); + + var idpairs = files.Select(x => Tuple.Create(x, Subject.GetFingerprint(x.Path))).ToList(); + + Subject.Lookup(idpairs, 0.5); + + files[0].AcoustIdResults.Should().Contain("30f3f33e-8d0c-4e69-8539-cbd701d18f28"); + files[1].AcoustIdResults.Should().BeNull(); + files[2].AcoustIdResults.Should().Contain("30f3f33e-8d0c-4e69-8539-cbd701d18f28"); + } + + [Test] + public void should_lookup_list_when_fpcalc_fails_for_all_files() + { + UseRealHttp(); + + var files = new [] { + "missing1.mp3", + "missing2.mp3" + }.Select(x => new LocalTrack { Path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Media", x) }).ToList(); + + var idpairs = files.Select(x => Tuple.Create(x, null)).ToList(); + + Subject.Lookup(idpairs, 0.5); + + files[0].AcoustIdResults.Should().BeNull(); + files[1].AcoustIdResults.Should().BeNull(); + } + + [Test] + public void should_not_fail_if_duration_reported_as_zero() + { + UseRealHttp(); + + var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Media", "missing.mp3"); + var localTrack = new LocalTrack { Path = path }; + var acoustId = new AcoustId { + Duration = 0, + Fingerprint = "fingerprint" + }; + + Subject.Lookup(new List> { Tuple.Create(localTrack, acoustId)}, 0.5); + } + + [Test] + public void should_not_throw_if_fingerprint_invalid() + { + UseRealHttp(); + + var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Media", "missing.mp3"); + var localTrack = new LocalTrack { Path = path }; + var acoustId = new AcoustId { + Duration = 1, + Fingerprint = "fingerprint" + }; + + var files = new List> { Tuple.Create(localTrack, acoustId)}; + Subject.Lookup(files, 0.5); + files[0].Item1.AcoustIdResults.Should().BeNull(); + } + + [Test] + public void should_not_fail_for_some_invalid_fingerprints() + { + UseRealHttp(); + + var files = new [] { + "nin.mp3", + "nin.flac" + }.Select(x => new LocalTrack { Path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Media", x) }).ToList(); + + var idpairs = files.Select(x => Tuple.Create(x, Subject.GetFingerprint(x.Path))).ToList(); + + idpairs.Add(Tuple.Create(new LocalTrack(), new AcoustId { Duration = 1, Fingerprint = "fingerprint" })); + + Subject.Lookup(idpairs, 0.5); + + idpairs[0].Item1.AcoustIdResults.Should().Contain("30f3f33e-8d0c-4e69-8539-cbd701d18f28"); + idpairs[1].Item1.AcoustIdResults.Should().Contain("30f3f33e-8d0c-4e69-8539-cbd701d18f28"); + idpairs[2].Item1.AcoustIdResults.Should().BeNull(); + } + + [Test] + public void should_not_throw_if_api_returns_html() + { + Mocker.GetMock().Setup(x => x.Post(It.IsAny())) + .Callback(req => throw new UnexpectedHtmlContentException(new HttpResponse(req, req.Headers, "html content", HttpStatusCode.Accepted))); + + var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Media", "nin.mp3"); + var localTrack = new LocalTrack { Path = path }; + Subject.Lookup(new List { localTrack }, 0.5); + localTrack.AcoustIdResults.Should().BeNull(); + + ExceptionVerification.ExpectedWarns(1); + } + + [Test] + public void should_not_throw_if_api_times_out() + { + Mocker.GetMock().Setup(x => x.Post(It.IsAny())) + .Throws(new System.Net.WebException("The operation has timed out.")); + + var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Media", "nin.mp3"); + var localTrack = new LocalTrack { Path = path }; + Subject.Lookup(new List { localTrack }, 0.5); + localTrack.AcoustIdResults.Should().BeNull(); + + ExceptionVerification.ExpectedWarns(1); + } + } +} diff --git a/src/NzbDrone.Core.Test/ParserTests/HashedReleaseFixture.cs b/src/NzbDrone.Core.Test/ParserTests/HashedReleaseFixture.cs index 7f17e1563..e78fb26c6 100644 --- a/src/NzbDrone.Core.Test/ParserTests/HashedReleaseFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/HashedReleaseFixture.cs @@ -1,4 +1,4 @@ -using FluentAssertions; +using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.Qualities; using NzbDrone.Core.Test.Framework; @@ -13,83 +13,83 @@ namespace NzbDrone.Core.Test.ParserTests { new object[] { - @"C:\Test\Some.Hashed.Release.S01E01.720p.WEB-DL.AAC2.0.H.264-Mercury\0e895c37245186812cb08aab1529cf8ee389dd05.mkv".AsOsAgnostic(), + @"C:\Test\Some.Hashed.Release.(256kbps)-Mercury\0e895c37245186812cb08aab1529cf8ee389dd05.mp3".AsOsAgnostic(), "Some Hashed Release", - Quality.WEBDL720p, + Quality.MP3_256, "Mercury" }, new object[] { - @"C:\Test\0e895c37245186812cb08aab1529cf8ee389dd05\Some.Hashed.Release.S01E01.720p.WEB-DL.AAC2.0.H.264-Mercury.mkv".AsOsAgnostic(), + @"C:\Test-[256]\0e895c37245186812cb08aab1529cf8ee389dd05\Some.Hashed.Release.S01E01.720p.WEB-DL.AAC2.0.H.264-Mercury.mp3".AsOsAgnostic(), "Some Hashed Release", - Quality.WEBDL720p, + Quality.MP3_256, "Mercury" }, new object[] { - @"C:\Test\Fake.Dir.S01E01-Test\yrucreM-462.H.0.2CAA.LD-BEW.p027.10E10S.esaeleR.dehsaH.emoS.mkv".AsOsAgnostic(), + @"C:\Test\Fake.Dir.S01E01-Test\yrucreM-462.H.0.2CAA.LD-BEW.p027.10E10S.esaeleR.dehsaH.emoS.mp3".AsOsAgnostic(), "Some Hashed Release", - Quality.WEBDL720p, + Quality.MP3_256, "Mercury" }, new object[] { - @"C:\Test\Fake.Dir.S01E01-Test\yrucreM-LN 1.5DD LD-BEW P0801 10E10S esaeleR dehsaH emoS.mkv".AsOsAgnostic(), + @"C:\Test\Fake.Dir.S01E01-Test\yrucreM-LN 1.5DD LD-BEW P0801 10E10S esaeleR dehsaH emoS.mp3".AsOsAgnostic(), "Some Hashed Release", - Quality.WEBDL1080p, + Quality.MP3_256, "Mercury" }, new object[] { - @"C:\Test\Weeds.S01E10.DVDRip.XviD-SONARR\AHFMZXGHEWD660.mkv".AsOsAgnostic(), + @"C:\Test\Weeds.S01E10.DVDRip.XviD-Lidarr\AHFMZXGHEWD660.mp3".AsOsAgnostic(), "Weeds", - Quality.DVD, - "SONARR" + Quality.MP3_256, + "Lidarr" }, new object[] { - @"C:\Test\Deadwood.S02E12.1080p.BluRay.x264-SONARR\Backup_72023S02-12.mkv".AsOsAgnostic(), + @"C:\Test\Deadwood.S02E12.1080p.BluRay.x264-Lidarr\Backup_72023S02-12.mp3".AsOsAgnostic(), "Deadwood", - Quality.Bluray1080p, + Quality.MP3_256, null }, new object[] { - @"C:\Test\Grimm S04E08 Chupacabra 720p WEB-DL DD5 1 H 264-ECI\123.mkv".AsOsAgnostic(), + @"C:\Test\Grimm S04E08 Chupacabra 720p WEB-DL DD5 1 H 264-ECI\123.mp3".AsOsAgnostic(), "Grimm", - Quality.WEBDL720p, + Quality.MP3_256, "ECI" }, new object[] { - @"C:\Test\Grimm S04E08 Chupacabra 720p WEB-DL DD5 1 H 264-ECI\abc.mkv".AsOsAgnostic(), + @"C:\Test\Grimm S04E08 Chupacabra 720p WEB-DL DD5 1 H 264-ECI\abc.mp3".AsOsAgnostic(), "Grimm", - Quality.WEBDL720p, + Quality.MP3_256, "ECI" }, new object[] { - @"C:\Test\Grimm S04E08 Chupacabra 720p WEB-DL DD5 1 H 264-ECI\b00bs.mkv".AsOsAgnostic(), + @"C:\Test\Grimm S04E08 Chupacabra 720p WEB-DL DD5 1 H 264-ECI\b00bs.mp3".AsOsAgnostic(), "Grimm", - Quality.WEBDL720p, + Quality.MP3_256, "ECI" }, new object[] { - @"C:\Test\The.Good.Wife.S02E23.720p.HDTV.x264-NZBgeek/cgajsofuejsa501.mkv".AsOsAgnostic(), + @"C:\Test\The.Good.Wife.S02E23.720p.HDTV.x264-NZBgeek/cgajsofuejsa501.mp3".AsOsAgnostic(), "The Good Wife", - Quality.HDTV720p, + Quality.MP3_256, "NZBgeek" } }; [Test, TestCaseSource(nameof(HashedReleaseParserCases))] + [Ignore("Hashed code is not currently called with track parsing")] public void should_properly_parse_hashed_releases(string path, string title, Quality quality, string releaseGroup) { - var result = Parser.Parser.ParsePath(path); - result.SeriesTitle.Should().Be(title); + var result = Parser.Parser.ParseMusicPath(path); + //result.SeriesTitle.Should().Be(title); result.Quality.Quality.Should().Be(quality); - result.ReleaseGroup.Should().Be(releaseGroup); } } } diff --git a/src/NzbDrone.Core.Test/ParserTests/IsPossibleSpecialEpisodeFixture.cs b/src/NzbDrone.Core.Test/ParserTests/IsPossibleSpecialEpisodeFixture.cs deleted file mode 100644 index 11f68da85..000000000 --- a/src/NzbDrone.Core.Test/ParserTests/IsPossibleSpecialEpisodeFixture.cs +++ /dev/null @@ -1,43 +0,0 @@ -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Parser.Model; - -namespace NzbDrone.Core.Test.ParserTests -{ - [TestFixture] - public class IsPossibleSpecialEpisodeFixture - { - [Test] - public void should_not_treat_files_without_a_series_title_as_a_special() - { - var parsedEpisodeInfo = new ParsedEpisodeInfo - { - EpisodeNumbers = new[]{ 7 }, - SeasonNumber = 1, - SeriesTitle = "" - }; - - parsedEpisodeInfo.IsPossibleSpecialEpisode.Should().BeFalse(); - } - - [Test] - public void should_return_true_when_episode_numbers_is_empty() - { - var parsedEpisodeInfo = new ParsedEpisodeInfo - { - SeasonNumber = 1, - SeriesTitle = "" - }; - - parsedEpisodeInfo.IsPossibleSpecialEpisode.Should().BeTrue(); - } - - [TestCase("Under.the.Dome.S02.Special-Inside.Chesters.Mill.HDTV.x264-BAJSKORV")] - [TestCase("Under.the.Dome.S02.Special-Inside.Chesters.Mill.720p.HDTV.x264-BAJSKORV")] - [TestCase("Rookie.Blue.Behind.the.Badge.S05.Special.HDTV.x264-2HD")] - public void IsPossibleSpecialEpisode_should_be_true(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 deleted file mode 100644 index 4b430e171..000000000 --- a/src/NzbDrone.Core.Test/ParserTests/LanguageParserFixture.cs +++ /dev/null @@ -1,64 +0,0 @@ -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Parser; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.ParserTests -{ - - [TestFixture] - public class LanguageParserFixture : CoreTest - { - [TestCase("Castle.2009.S01E14.English.HDTV.XviD-LOL", Language.English)] - [TestCase("Castle.2009.S01E14.French.HDTV.XviD-LOL", Language.French)] - [TestCase("Castle.2009.S01E14.Spanish.HDTV.XviD-LOL", Language.Spanish)] - [TestCase("Castle.2009.S01E14.German.HDTV.XviD-LOL", Language.German)] - [TestCase("Castle.2009.S01E14.Germany.HDTV.XviD-LOL", Language.English)] - [TestCase("Castle.2009.S01E14.Italian.HDTV.XviD-LOL", Language.Italian)] - [TestCase("Castle.2009.S01E14.Danish.HDTV.XviD-LOL", Language.Danish)] - [TestCase("Castle.2009.S01E14.Dutch.HDTV.XviD-LOL", Language.Dutch)] - [TestCase("Castle.2009.S01E14.Japanese.HDTV.XviD-LOL", Language.Japanese)] - [TestCase("Castle.2009.S01E14.Cantonese.HDTV.XviD-LOL", Language.Cantonese)] - [TestCase("Castle.2009.S01E14.Mandarin.HDTV.XviD-LOL", Language.Mandarin)] - [TestCase("Castle.2009.S01E14.Korean.HDTV.XviD-LOL", Language.Korean)] - [TestCase("Castle.2009.S01E14.Russian.HDTV.XviD-LOL", Language.Russian)] - [TestCase("Castle.2009.S01E14.Polish.HDTV.XviD-LOL", Language.Polish)] - [TestCase("Castle.2009.S01E14.Vietnamese.HDTV.XviD-LOL", Language.Vietnamese)] - [TestCase("Castle.2009.S01E14.Swedish.HDTV.XviD-LOL", Language.Swedish)] - [TestCase("Castle.2009.S01E14.Norwegian.HDTV.XviD-LOL", Language.Norwegian)] - [TestCase("Castle.2009.S01E14.Finnish.HDTV.XviD-LOL", Language.Finnish)] - [TestCase("Castle.2009.S01E14.Turkish.HDTV.XviD-LOL", Language.Turkish)] - [TestCase("Castle.2009.S01E14.Portuguese.HDTV.XviD-LOL", Language.Portuguese)] - [TestCase("Castle.2009.S01E14.HDTV.XviD-LOL", Language.English)] - [TestCase("person.of.interest.1x19.ita.720p.bdmux.x264-novarip", Language.Italian)] - [TestCase("Salamander.S01E01.FLEMISH.HDTV.x264-BRiGAND", Language.Flemish)] - [TestCase("H.Polukatoikia.S03E13.Greek.PDTV.XviD-Ouzo", Language.Greek)] - [TestCase("Burn.Notice.S04E15.Brotherly.Love.GERMAN.DUBBED.WS.WEBRiP.XviD.REPACK-TVP", Language.German)] - [TestCase("Ray Donovan - S01E01.720p.HDtv.x264-Evolve (NLsub)", Language.Dutch)] - [TestCase("Shield,.The.1x13.Tueurs.De.Flics.FR.DVDRip.XviD", Language.French)] - [TestCase("True.Detective.S01E01.1080p.WEB-DL.Rus.Eng.TVKlondike", Language.Russian)] - [TestCase("The.Trip.To.Italy.S02E01.720p.HDTV.x264-TLA", Language.English)] - [TestCase("Revolution S01E03 No Quarter 2012 WEB-DL 720p Nordic-philipo mkv", Language.Norwegian)] - [TestCase("Extant.S01E01.VOSTFR.HDTV.x264-RiDERS", Language.French)] - [TestCase("Constantine.2014.S01E01.WEBRiP.H264.AAC.5.1-NL.SUBS", Language.Dutch)] - [TestCase("Elementary - S02E16 - Kampfhaehne - mkv - by Videomann", Language.German)] - [TestCase("Two.Greedy.Italians.S01E01.The.Family.720p.HDTV.x264-FTP", Language.English)] - [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)] - public void should_parse_language(string postTitle, Language language) - { - var result = LanguageParser.ParseLanguage(postTitle); - result.Should().Be(language); - } - - [TestCase("2 Broke Girls - S01E01 - Pilot.en.sub", Language.English)] - [TestCase("2 Broke Girls - S01E01 - Pilot.eng.sub", Language.English)] - [TestCase("2 Broke Girls - S01E01 - Pilot.sub", Language.Unknown)] - public void should_parse_subtitle_language(string fileName, Language language) - { - var result = LanguageParser.ParseSubtitleLanguage(fileName); - result.Should().Be(language); - } - } -} diff --git a/src/NzbDrone.Core.Test/ParserTests/MiniSeriesEpisodeParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/MiniSeriesEpisodeParserFixture.cs deleted file mode 100644 index 982eb61ae..000000000 --- a/src/NzbDrone.Core.Test/ParserTests/MiniSeriesEpisodeParserFixture.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Linq; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.ParserTests -{ - - [TestFixture] - public class MiniSeriesEpisodeParserFixture : CoreTest - { - [TestCase("The.Kennedys.Part.2.DSR.XviD-SYS", "The Kennedys", 2)] - [TestCase("the-pacific-e07-720p", "the-pacific", 7)] - [TestCase("Hatfields and McCoys 2012 Part 1 REPACK 720p HDTV x264 2HD", "Hatfields and McCoys 2012", 1)] - //[TestCase("Band.Of.Brothers.EP02.Day.Of.Days.DVDRiP.XviD-DEiTY", "Band.Of.Brothers", 2)] - //[TestCase("", "", 0, 0)] - [TestCase("Mars.2016.E04.Power.720p.WEB-DL.DD5.1.H.264-MARS", "Mars 2016", 4)] - public void should_parse_mini_series_episode(string postTitle, string title, int episodeNumber) - { - var result = Parser.Parser.ParseTitle(postTitle); - result.Should().NotBeNull(); - result.EpisodeNumbers.Should().HaveCount(1); - result.SeasonNumber.Should().Be(1); - result.EpisodeNumbers.First().Should().Be(episodeNumber); - result.SeriesTitle.Should().Be(title); - result.AbsoluteEpisodeNumbers.Should().BeEmpty(); - result.FullSeason.Should().BeFalse(); - } - } -} diff --git a/src/NzbDrone.Core.Test/ParserTests/MultiEpisodeParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/MultiEpisodeParserFixture.cs deleted file mode 100644 index 9d694c665..000000000 --- a/src/NzbDrone.Core.Test/ParserTests/MultiEpisodeParserFixture.cs +++ /dev/null @@ -1,71 +0,0 @@ -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.ParserTests -{ - - [TestFixture] - public class MultiEpisodeParserFixture : CoreTest - { - [TestCase("WEEDS.S03E01-06.DUAL.BDRip.XviD.AC3.-HELLYWOOD", "WEEDS", 3, new[] { 1, 2, 3, 4, 5, 6 })] - [TestCase("Two.and.a.Half.Men.103.104.720p.HDTV.X264-DIMENSION", "Two and a Half Men", 1, new[] { 3, 4 })] - [TestCase("Weeds.S03E01.S03E02.720p.HDTV.X264-DIMENSION", "Weeds", 3, new[] { 1, 2 })] - [TestCase("The Borgias S01e01 e02 ShoHD On Demand 1080i DD5 1 ALANiS", "The Borgias", 1, new[] { 1, 2 })] - [TestCase("White.Collar.2x04.2x05.720p.BluRay-FUTV", "White Collar", 2, new[] { 4, 5 })] - [TestCase("Desperate.Housewives.S07E22E23.720p.HDTV.X264-DIMENSION", "Desperate Housewives", 7, new[] { 22, 23 })] - [TestCase("Desparate Housewives - S07E22 - S07E23 - And Lots of Security.. [HDTV-720p].mkv", "Desparate Housewives", 7, new[] { 22, 23 })] - [TestCase("S03E01.S03E02.720p.HDTV.X264-DIMENSION", "", 3, new[] { 1, 2 })] - [TestCase("Desparate Housewives - S07E22 - 7x23 - And Lots of Security.. [HDTV-720p].mkv", "Desparate Housewives", 7, new[] { 22, 23 })] - [TestCase("S07E22 - 7x23 - And Lots of Security.. [HDTV-720p].mkv", "", 7, new[] { 22, 23 })] - [TestCase("2x04x05.720p.BluRay-FUTV", "", 2, new[] { 4, 5 })] - [TestCase("S02E04E05.720p.BluRay-FUTV", "", 2, new[] { 4, 5 })] - [TestCase("S02E03-04-05.720p.BluRay-FUTV", "", 2, new[] { 3, 4, 5 })] - [TestCase("Breakout.Kings.S02E09-E10.HDTV.x264-ASAP", "Breakout Kings", 2, new[] { 9, 10 })] - [TestCase("Breakout Kings - 2x9-2x10 - Served Cold [SDTV] ", "Breakout Kings", 2, new[] { 9, 10 })] - [TestCase("Breakout Kings - 2x09-2x10 - Served Cold [SDTV] ", "Breakout Kings", 2, new[] { 9, 10 })] - [TestCase("Hell on Wheels S02E09 E10 HDTV x264 EVOLVE", "Hell on Wheels", 2, new[] { 9, 10 })] - [TestCase("Hell.on.Wheels.S02E09-E10.720p.HDTV.x264-EVOLVE", "Hell on Wheels", 2, new[] { 9, 10 })] - [TestCase("Grey's Anatomy - 8x01_02 - Free Falling", "Grey's Anatomy", 8, new [] { 1,2 })] - [TestCase("8x01_02 - Free Falling", "", 8, new[] { 1, 2 })] - [TestCase("Kaamelott.S01E91-E100", "Kaamelott", 1, new[] { 91, 92, 93, 94, 95, 96, 97, 98, 99, 100 })] - [TestCase("Neighbours.S29E161-E165.PDTV.x264-FQM", "Neighbours", 29, new[] { 161, 162, 163, 164, 165 })] - [TestCase("Shortland.Street.S22E5363-E5366.HDTV.x264-FiHTV", "Shortland Street", 22, new[] { 5363, 5364, 5365, 5366 })] - [TestCase("the.office.101.102.hdtv-lol", "the office", 1, new[] { 1, 2 })] - [TestCase("extant.10708.hdtv-lol.mp4", "extant", 1, new[] { 7, 8 })] - [TestCase("extant.10910.hdtv-lol.mp4", "extant", 1, new[] { 9, 10 })] - [TestCase("E.010910.HDTVx264REPACKLOL.mp4", "E", 1, new[] { 9, 10 })] - [TestCase("World Series of Poker - 2013x15 - 2013x16 - HD TV.mkv", "World Series of Poker", 2013, new[] { 15, 16 })] - [TestCase("The Librarians US S01E01-E02 720p HDTV x264", "The Librarians US", 1, new [] { 1, 2 })] - [TestCase("Series Title Season 01 Episode 05-06 720p", "Series Title", 1,new [] { 5, 6 })] - //[TestCase("My Name Is Earl - S03E01-E02 - My Name Is Inmate 28301-016 [SDTV]", "My Name Is Earl", 3, new[] { 1, 2 })] - //[TestCase("Adventure Time - 5x01 - x02 - Finn the Human (2) & Jake the Dog (3)", "Adventure Time", 5, new [] { 1, 2 })] - [TestCase("The Young And The Restless - S42 Ep10718 - Ep10722", "The Young And The Restless", 42, new[] { 10718, 10719, 10720, 10721, 10722 })] - [TestCase("The Young And The Restless - S42 Ep10688 - Ep10692", "The Young And The Restless", 42, new[] { 10688, 10689, 10690, 10691, 10692 })] - [TestCase("RWBY.S01E02E03.1080p.BluRay.x264-DeBTViD", "RWBY", 1, new [] { 2, 3 })] - [TestCase("grp-zoos01e11e12-1080p", "grp-zoo", 1, new [] { 11, 12 })] - [TestCase("grp-zoo-s01e11e12-1080p", "grp-zoo", 1, new [] { 11, 12 })] - [TestCase("Series Title.S6.E1.E2.Episode Name.1080p.WEB-DL", "Series Title", 6, new [] { 1, 2 })] - [TestCase("Series Title.S6E1-E2.Episode Name.1080p.WEB-DL", "Series Title", 6, new [] { 1, 2 })] - [TestCase("Series Title.S6E1-S6E2.Episode Name.1080p.WEB-DL", "Series Title", 6, new [] { 1, 2 })] - [TestCase("Series Title.S6E1E2.Episode Name.1080p.WEB-DL", "Series Title", 6, new [] { 1, 2 })] - [TestCase("Series Title.S6E1-E2-E3.Episode Name.1080p.WEB-DL", "Series Title", 6, new [] { 1, 2, 3})] - [TestCase("Series Title.S6.E1E3.Episode Name.1080p.WEB-DL", "Series Title", 6, new [] { 1, 2, 3 })] - [TestCase("Series Title.S6.E1-E2.Episode Name.1080p.WEB-DL", "Series Title", 6, new[] { 1, 2 })] - [TestCase("Series Title.S6.E1-S6E2.Episode Name.1080p.WEB-DL", "Series Title", 6, new[] { 1, 2 })] - [TestCase("Series Title.S6.E1E2.Episode Name.1080p.WEB-DL", "Series Title", 6, new[] { 1, 2 })] - [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("", "", , new [] { })] - public void should_parse_multiple_episodes(string postTitle, string title, int season, int[] episodes) - { - var result = Parser.Parser.ParseTitle(postTitle); - result.SeasonNumber.Should().Be(season); - result.EpisodeNumbers.Should().BeEquivalentTo(episodes); - result.SeriesTitle.Should().Be(title); - result.AbsoluteEpisodeNumbers.Should().BeEmpty(); - result.FullSeason.Should().BeFalse(); - } - } -} diff --git a/src/NzbDrone.Core.Test/ParserTests/MusicParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/MusicParserFixture.cs new file mode 100644 index 000000000..e1a7d3a86 --- /dev/null +++ b/src/NzbDrone.Core.Test/ParserTests/MusicParserFixture.cs @@ -0,0 +1,42 @@ +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Test.ParserTests +{ + [TestFixture] + public class MusicParserFixture : CoreTest + { + + //[TestCase("___▲▲▲___")] + //[TestCase("Add N to (X)")] + //[TestCase("Animal Collective")] + //[TestCase("D12")] + //[TestCase("David Sylvian[Discography]")] + //[TestCase("Eagle-Eye Cherry")] + //[TestCase("Erlend Øye")] + //[TestCase("Adult.")] // Not sure if valid, not openable in Windows OS + //[TestCase("Maroon 5")] + //[TestCase("Moimir Papalescu & The Nihilists")] + //[TestCase("N.W.A")] + //[TestCase("oOoOO")] + //[TestCase("Panic! at the Disco")] + //[TestCase("The 5 6 7 8's")] + //[TestCase("tUnE-yArDs")] + //[TestCase("U2")] + //[TestCase("Белые Братья")] + //[TestCase("Zog Bogbean - From The Marcy Playground")] + + // TODO: Rewrite this test to something that makes sense. + public void should_parse_artist_names(string title) + { + Parser.Parser.ParseMusicTitle(title).ArtistTitle.Should().Be(title); + ExceptionVerification.IgnoreWarns(); + } + } +} diff --git a/src/NzbDrone.Core.Test/ParserTests/NormalizeTitleFixture.cs b/src/NzbDrone.Core.Test/ParserTests/NormalizeTitleFixture.cs index 6e2b01366..cd42a0737 100644 --- a/src/NzbDrone.Core.Test/ParserTests/NormalizeTitleFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/NormalizeTitleFixture.cs @@ -13,10 +13,10 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("Castle (2009)", "castle2009")] [TestCase("Parenthood.2010", "parenthood2010")] [TestCase("Law_and_Order_SVU", "lawordersvu")] - public void should_normalize_series_title(string parsedSeriesName, string seriesName) + public void should_normalize_artist_title(string parsedArtistName, string artistName) { - var result = parsedSeriesName.CleanSeriesTitle(); - result.Should().Be(seriesName); + var result = parsedArtistName.CleanArtistName(); + result.Should().Be(artistName); } [TestCase("CaPitAl", "capital")] @@ -27,7 +27,7 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("24", "24")] public void should_remove_special_characters_and_casing(string dirty, string clean) { - var result = dirty.CleanSeriesTitle(); + var result = dirty.CleanArtistName(); result.Should().Be(clean); } @@ -51,7 +51,7 @@ namespace NzbDrone.Core.Test.ParserTests foreach (var s in dirtyFormat) { var dirty = string.Format(s, word); - dirty.CleanSeriesTitle().Should().Be("wordword"); + dirty.CleanArtistName().Should().Be("wordword"); } } @@ -68,7 +68,7 @@ namespace NzbDrone.Core.Test.ParserTests foreach (var s in dirtyFormat) { var dirty = string.Format(s, "a"); - dirty.CleanSeriesTitle().Should().Be("wordword"); + dirty.CleanArtistName().Should().Be("wordword"); } } @@ -93,7 +93,7 @@ namespace NzbDrone.Core.Test.ParserTests foreach (var s in dirtyFormat) { var dirty = string.Format(s, word); - dirty.CleanSeriesTitle().Should().Be(("word" + word.ToLower() + "word")); + dirty.CleanArtistName().Should().Be(("word" + word.ToLower() + "word")); } } @@ -101,10 +101,10 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("The Office", "theoffice")] [TestCase("The Tonight Show With Jay Leno", "thetonightshowwithjayleno")] [TestCase("The.Daily.Show", "thedailyshow")] - public void should_not_remove_from_the_beginning_of_the_title(string parsedSeriesName, string seriesName) + public void should_not_remove_from_the_beginning_of_the_title(string parsedArtistName, string artistName) { - var result = parsedSeriesName.CleanSeriesTitle(); - result.Should().Be(seriesName); + var result = parsedArtistName.CleanArtistName(); + result.Should().Be(artistName); } [TestCase("the")] @@ -125,14 +125,14 @@ namespace NzbDrone.Core.Test.ParserTests foreach (var s in dirtyFormat) { var dirty = string.Format(s, word); - dirty.CleanSeriesTitle().Should().Be(word + "wordword"); + dirty.CleanArtistName().Should().Be(word + "wordword"); } } [Test] public void should_not_clean_trailing_a() { - "Tokyo Ghoul A".CleanSeriesTitle().Should().Be("tokyoghoula"); + "Tokyo Ghoul A".CleanArtistName().Should().Be("tokyoghoula"); } } } diff --git a/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs index 2712c8dbf..6c4c31b82 100644 --- a/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs @@ -1,6 +1,10 @@ +using System.Collections.Generic; +using FizzWare.NBuilder; using FluentAssertions; using NUnit.Framework; +using NzbDrone.Core.Music; using NzbDrone.Core.Parser; +using NzbDrone.Core.Qualities; using NzbDrone.Core.Test.Framework; namespace NzbDrone.Core.Test.ParserTests @@ -9,58 +13,249 @@ namespace NzbDrone.Core.Test.ParserTests [TestFixture] public class ParserFixture : CoreTest { - /*Fucked-up hall of shame, - * WWE.Wrestlemania.27.PPV.HDTV.XviD-KYR - * Unreported.World.Chinas.Lost.Sons.WS.PDTV.XviD-FTP - * [TestCase("Big Time Rush 1x01 to 10 480i DD2 0 Sianto", "Big Time Rush", 1, new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }, 10)] - * [TestCase("Desparate Housewives - S07E22 - 7x23 - And Lots of Security.. [HDTV-720p].mkv", "Desparate Housewives", 7, new[] { 22, 23 }, 2)] - * [TestCase("S07E22 - 7x23 - And Lots of Security.. [HDTV-720p].mkv", "", 7, new[] { 22, 23 }, 2)] - * (Game of Thrones s03 e - "Game of Thrones Season 3 Episode 10" - * The.Man.of.Steel.1994-05.33.hybrid.DreamGirl-Novus-HD - * Superman.-.The.Man.of.Steel.1994-06.34.hybrid.DreamGirl-Novus-HD - * Superman.-.The.Man.of.Steel.1994-05.33.hybrid.DreamGirl-Novus-HD - * Constantine S1-E1-WEB-DL-1080p-NZBgeek - */ - - [TestCase("Chuck - 4x05 - Title", "Chuck")] - [TestCase("Law & Order - 4x05 - Title", "laworder")] + Artist _artist = new Artist(); + private List _albums = new List { new Album() }; + + [SetUp] + public void Setup() + { + _artist = Builder + .CreateNew() + .Build(); + _albums = Builder> + .CreateNew() + .Build(); + } + + private void GivenSearchCriteria(string artistName, string albumTitle) + { + _artist.Name = artistName; + var a = new Album(); + a.Title = albumTitle; + _albums.Add(a); + } + [TestCase("Bad Format", "badformat")] - [TestCase("Mad Men - Season 1 [Bluray720p]", "madmen")] - [TestCase("Mad Men - Season 1 [Bluray1080p]", "madmen")] - [TestCase("The Daily Show With Jon Stewart -", "thedailyshowwithjonstewart")] - [TestCase("The Venture Bros. (2004)", "theventurebros2004")] - [TestCase("Castle (2011)", "castle2011")] - [TestCase("Adventure Time S02 720p HDTV x264 CRON", "adventuretime")] - [TestCase("Hawaii Five 0", "hawaiifive0")] - [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("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) - { - var result = Parser.Parser.ParseSeriesName(postTitle).CleanSeriesTitle(); - result.Should().Be(title.CleanSeriesTitle()); + public void should_parse_artist_name(string postTitle, string title) + { + var result = Parser.Parser.ParseArtistName(postTitle).CleanArtistName(); + result.Should().Be(title.CleanArtistName()); } [Test] public void should_remove_accents_from_title() { const string title = "Carniv\u00E0le"; - - title.CleanSeriesTitle().Should().Be("carnivale"); + + title.CleanArtistName().Should().Be("carnivale"); + } + + [TestCase("Songs of Experience (Deluxe Edition)", "Songs of Experience")] + [TestCase("Songs of Experience (iTunes Deluxe Edition)", "Songs of Experience")] + [TestCase("Songs of Experience [Super Special Edition]", "Songs of Experience")] + [TestCase("Mr. Bad Guy [Special Edition]", "Mr. Bad Guy")] + [TestCase("Sweet Dreams (Album)", "Sweet Dreams")] + [TestCase("Now What?! (Limited Edition)", "Now What?!")] + [TestCase("Random Album Title (Promo CD)", "Random Album Title")] + [TestCase("Hello, I Must Be Going (2016 Remastered)", "Hello, I Must Be Going")] + [TestCase("Limited Edition", "Limited Edition")] + public void should_remove_common_tags_from_album_title(string title, string correct) + { + var result = Parser.Parser.CleanAlbumTitle(title); + result.Should().Be(correct); + } + + [TestCase("Songs of Experience (Deluxe Edition)", "Songs of Experience")] + [TestCase("Mr. Bad Guy [Special Edition]", "Mr. Bad Guy")] + [TestCase("Smooth Criminal (single)", "Smooth Criminal")] + [TestCase("Wie Maak Die Jol Vol (Ft. Isaac Mutant, Knoffel, Jaak Paarl & Scallywag)", "Wie Maak Die Jol Vol")] + [TestCase("Alles Schon Gesehen (Feat. Deichkind)", "Alles Schon Gesehen")] + [TestCase("Science Fiction/Double Feature", "Science Fiction/Double Feature")] + [TestCase("Dancing Feathers", "Dancing Feathers")] + public void should_remove_common_tags_from_track_title(string title, string correct) + { + var result = Parser.Parser.CleanTrackTitle(title); + result.Should().Be(correct); } [TestCase("Discovery TV - Gold Rush : 02 Road From Hell [S04].mp4")] public void should_clean_up_invalid_path_characters(string postTitle) { - Parser.Parser.ParseTitle(postTitle); + Parser.Parser.ParseAlbumTitle(postTitle); } - [TestCase("[scnzbefnet][509103] 2.Broke.Girls.S03E18.720p.HDTV.X264-DIMENSION", "2 Broke Girls")] + [TestCase("[scnzbefnet][509103] Jay-Z - 4:44 (Deluxe Edition) (2017) 320", "Jay-Z")] public void should_remove_request_info_from_title(string postTitle, string title) { - Parser.Parser.ParseTitle(postTitle).SeriesTitle.Should().Be(title); + Parser.Parser.ParseAlbumTitle(postTitle).ArtistName.Should().Be(title); + } + + [TestCase("02 Unchained.flac")] // This isn't valid on any regex we have. We must always have an artist + [TestCase("Fall Out Boy - 02 - Title.wav")] // This isn't valid on any regex we have. We don't support Artist - Track - TrackName + [Ignore("Ignore Test until track parsing rework")] + public void should_parse_quality_from_extension(string title) + { + Parser.Parser.ParseAlbumTitle(title).Quality.Quality.Should().NotBe(Quality.Unknown); + Parser.Parser.ParseAlbumTitle(title).Quality.QualityDetectionSource.Should().Be(QualityDetectionSource.Extension); + } + + [TestCase("VA - The Best 101 Love Ballads (2017) MP3 [192 kbps]", "VA", "The Best 101 Love Ballads")] + [TestCase("ATCQ - The Love Movement 1998 2CD 192kbps RIP", "ATCQ", "The Love Movement")] + //[TestCase("A Tribe Called Quest - The Love Movement 1998 2CD [192kbps] RIP", "A Tribe Called Quest", "The Love Movement")] + [TestCase("Maula - Jism 2 [2012] Mp3 - 192Kbps [Extended]- TK", "Maula", "Jism 2")] + [TestCase("VA - Complete Clubland - The Ultimate Ride Of Your Lfe [2014][MP3][192 kbps]", "VA", "Complete Clubland - The Ultimate Ride Of Your Lfe")] + [TestCase("Complete Clubland - The Ultimate Ride Of Your Lfe [2014][MP3](192kbps)", "Complete Clubland", "The Ultimate Ride Of Your Lfe")] + //[TestCase("The Ultimate Ride Of Your Lfe [192 KBPS][2014][MP3]", "", "The Ultimate Ride Of Your Lfe")] + [TestCase("Gary Clark Jr - Live North America 2016 (2017) MP3 192kbps", "Gary Clark Jr", "Live North America 2016")] + //[TestCase("Beyoncé Lemonade [320] 2016 Beyonce Lemonade [320] 2016", "Beyoncé", "Lemonade")] + [TestCase("Childish Gambino - Awaken, My Love Album 2016 mp3 320 Kbps", "Childish Gambino", "Awaken, My Love Album")] + //[TestCase("Maluma – Felices Los 4 MP3 320 Kbps 2017 Download", "Maluma", "Felices Los 4")] + [TestCase("Ricardo Arjona - APNEA (Single 2014) (320 kbps)", "Ricardo Arjona", "APNEA")] + [TestCase("Kehlani - SweetSexySavage (Deluxe Edition) (2017) 320", "Kehlani", "SweetSexySavage")] + [TestCase("Anderson Paak - Malibu (320)(2016)", "Anderson Paak", "Malibu")] + [TestCase("Caetano Veloso Discografia Completa MP3 @256", "Caetano Veloso", "Discography", true)] + [TestCase("Little Mix - Salute [Deluxe Edition] [2013] [M4A-256]-V3nom [GLT", "Little Mix", "Salute")] + [TestCase("Ricky Martin - A Quien Quiera Escuchar (2015) 256 kbps [GloDLS]", "Ricky Martin", "A Quien Quiera Escuchar")] + [TestCase("Jake Bugg - Jake Bugg (Album) [2012] {MP3 256 kbps}", "Jake Bugg", "Jake Bugg")] + [TestCase("Milky Chance - Sadnecessary [256 Kbps] [M4A]", "Milky Chance", "Sadnecessary")] + [TestCase("Clean Bandit - New Eyes [2014] [Mp3-256]-V3nom [GLT]", "Clean Bandit", "New Eyes")] + [TestCase("Armin van Buuren - A State Of Trance 810 (20.04.2017) 256 kbps", "Armin van Buuren", "A State Of Trance 810")] + [TestCase("PJ Harvey - Let England Shake [mp3-256-2011][trfkad]", "PJ Harvey", "Let England Shake")] + //[TestCase("X-Men Soundtracks (2006-2014) AAC, 256 kbps", "", "")] + //[TestCase("Walk the Line Soundtrack (2005) [AAC, 256 kbps]", "", "Walk the Line Soundtrack")] + //[TestCase("Emeli Sande Next To Me (512 Kbps)", "Emeli", "Next To Me")] + [TestCase("Kendrick Lamar - DAMN (2017) FLAC", "Kendrick Lamar", "DAMN")] + [TestCase("Alicia Keys - Vault Playlist Vol. 1 (2017) [FLAC CD]", "Alicia Keys", "Vault Playlist Vol 1")] + [TestCase("Gorillaz - Humanz (Deluxe) - lossless FLAC Tracks - 2017 - CDrip", "Gorillaz", "Humanz")] + [TestCase("David Bowie - Blackstar (2016) [FLAC]", "David Bowie", "Blackstar")] + [TestCase("The Cure - Greatest Hits (2001) FLAC Soup", "The Cure", "Greatest Hits")] + [TestCase("Slowdive - Souvlaki (FLAC)", "Slowdive", "Souvlaki")] + [TestCase("John Coltrane - Kulu Se Mama (1965) [EAC-FLAC]", "John Coltrane", "Kulu Se Mama")] + [TestCase("The Rolling Stones - The Very Best Of '75-'94 (1995) {FLAC}", "The Rolling Stones", "The Very Best Of '75-'94")] + [TestCase("Migos-No_Label_II-CD-FLAC-2014-FORSAKEN", "Migos", "No Label II")] + //[TestCase("ADELE 25 CD FLAC 2015 PERFECT", "Adele", "25")] + [TestCase("A.I. - Sex & Robots [2007/MP3/V0(VBR)]", "A I", "Sex & Robots")] + [TestCase("Jay-Z - 4:44 (Deluxe Edition) (2017) 320", "Jay-Z", "444")] + //[TestCase("Roberta Flack 2006 - The Very Best of", "Roberta Flack", "The Very Best of")] + [TestCase("VA - NOW Thats What I Call Music 96 (2017) [Mp3~Kbps]", "VA", "NOW Thats What I Call Music 96")] + [TestCase("Queen - The Ultimate Best Of Queen(2011)[mp3]", "Queen", "The Ultimate Best Of Queen")] + [TestCase("Little Mix - Salute [Deluxe Edition] [2013] [M4A-256]-V3nom [GLT]", "Little Mix", "Salute")] + [TestCase("Barış Manço - Ben Bilirim [1993/FLAC/Lossless/Log]", "Barış Manço", "Ben Bilirim")] + [TestCase("Imagine Dragons-Smoke And Mirrors-Deluxe Edition-2CD-FLAC-2015-JLM", "Imagine Dragons", "Smoke And Mirrors")] + [TestCase("Dani_Sbert-Togheter-WEB-2017-FURY", "Dani Sbert", "Togheter")] + [TestCase("New.Edition-One.Love-CD-FLAC-2017-MrFlac", "New Edition", "One Love")] + [TestCase("David_Gray-The_Best_of_David_Gray-(Deluxe_Edition)-2CD-2016-MTD", "David Gray", "The Best of David Gray")] + [TestCase("Shinedown-Us and Them-NMR-2005-NMR", "Shinedown", "Us and Them")] + [TestCase("Led Zeppelin - Studio Discography 1969-1982 (10 albums)(flac)", "Led Zeppelin", "Discography", true)] + [TestCase("Minor Threat - Complete Discography [1989] [Anthology]", "Minor Threat", "Discography", true)] + [TestCase("Captain-Discography_1998_-_2001-CD-FLAC-2007-UTP", "Captain", "Discography", true)] + [TestCase("Coolio - Gangsta's Paradise (1995) (FLAC Lossless)", "Coolio", "Gangsta's Paradise")] + [TestCase("Brother Ali-2007-The Undisputed Truth-FTD", "Brother Ali", "The Undisputed Truth")] + [TestCase("Brother Ali-The Undisputed Truth-2007-FTD", "Brother Ali", "The Undisputed Truth")] + + // ruTracker + [TestCase("(Eclectic Progressive Rock) [CD] Peter Hammill - From The Trees - 2017, FLAC (tracks + .cue), lossless", "Peter Hammill","From The Trees")] + [TestCase("(Folk Rock / Pop) Aztec Two-Step - Naked - 2017, MP3, 320 kbps", "Aztec Two-Step", "Naked")] + [TestCase("(Zeuhl / Progressive Rock) [WEB] Dai Kaht - Dai Kaht - 2017, FLAC (tracks), lossless", "Dai Kaht", "Dai Kaht")] + //[TestCase("(Industrial Folk) Bumblebee(Shmely, AntiVirus) - Discography, 23 albums - 1998-2011, FLAC(image + .cue), lossless")] + //[TestCase("(Heavy Metal) Sergey Mavrin(Mavrik) - Discography(14 CD) [1998-2010], FLAC(image + .cue), lossless")] + [TestCase("(Heavy Metal) [CD] Black Obelisk - Discography - 1991-2015 (36 releases, 32 CDs), FLAC(image + .cue), lossless", "Black Obelisk", "Discography", true)] + //[TestCase("(R'n'B / Soul) Moyton - One of the Sta(2014) + Ocean(2014), MP3, 320 kbps", "Moyton", "")] + [TestCase("(Heavy Metal) Aria - Discography(46 CD) [1985 - 2015], FLAC(image + .cue), lossless", "Aria", "Discography", true)] + [TestCase("(Heavy Metal) [CD] Forces United - Discography(6 CDs), 2014-2016, FLAC(image + .cue), lossless", "Forces United", "Discography", true)] + [TestCase("Gorillaz - The now now - 2018 [FLAC]", "Gorillaz", "The now now")] + + //Regex Works on below, but ParseAlbumMatchCollection cleans the "..." and converts it to spaces + // [TestCase("Metallica - ...And Justice for All (1988) [FLAC Lossless]", "Metallica", "...And Justice for All")] + public void should_parse_artist_name_and_album_title(string postTitle, string name, string title, bool discography = false) + { + var parseResult = Parser.Parser.ParseAlbumTitle(postTitle); + parseResult.ArtistName.Should().Be(name); + parseResult.AlbumTitle.Should().Be(title); + parseResult.Discography.Should().Be(discography); + } + + [TestCase("Black Sabbath - Black Sabbath FLAC")] + [TestCase("Black Sabbath Black Sabbath FLAC")] + [TestCase("BlaCk SabBaTh Black SabBatH FLAC")] + [TestCase("Black Sabbath FLAC Black Sabbath")] + [TestCase("Black.Sabbath-FLAC-Black.Sabbath")] + [TestCase("Black_Sabbath-FLAC-Black_Sabbath")] + public void should_parse_artist_name_and_album_title_by_search_criteria(string releaseTitle) + { + GivenSearchCriteria("Black Sabbath", "Black Sabbath"); + var parseResult = Parser.Parser.ParseAlbumTitleWithSearchCriteria(releaseTitle, _artist, _albums); + parseResult.ArtistName.ToLowerInvariant().Should().Be("black sabbath"); + parseResult.AlbumTitle.ToLowerInvariant().Should().Be("black sabbath"); + } + + [TestCase("Captain-Discography_1998_-_2001-CD-FLAC-2007-UTP", 1998, 2001)] + [TestCase("(Heavy Metal) Aria - Discography(46 CD) [1985 - 2015]", 1985, 2015)] + [TestCase("Led Zeppelin - Studio Discography 1969-1982 (10 albums)(flac)", 1969, 1982)] + [TestCase("Minor Threat - Complete Discography [1989] [Anthology]", 0, 1989)] + [TestCase("Caetano Veloso Discografia Completa MP3 @256", 0, 0)] + public void should_parse_year_or_year_range_from_discography(string releaseTitle, int startyear, + int endyear) + { + var parseResult = Parser.Parser.ParseAlbumTitle(releaseTitle); + parseResult.Discography.Should().BeTrue(); + parseResult.DiscographyStart.Should().Be(startyear); + parseResult.DiscographyEnd.Should().Be(endyear); + } + + [Test] + public void should_not_parse_artist_name_and_album_title_by_incorrect_search_criteria() + { + GivenSearchCriteria("Abba", "Abba"); + var parseResult = Parser.Parser.ParseAlbumTitleWithSearchCriteria("Black Sabbath Black Sabbath FLAC", _artist, _albums); + parseResult.Should().BeNull(); + } + + [TestCase("Ed Sheeran", "I See Fire", "Ed Sheeran I See Fire[Mimp3.eu].mp3 FLAC")] + [TestCase("Ed Sheeran", "Divide", "Ed Sheeran ? Divide FLAC")] + [TestCase("Ed Sheeran", "+", "Ed Sheeran + FLAC")] + //[TestCase("Glasvegas", @"EUPHORIC /// HEARTBREAK \\\", @"EUPHORIC /// HEARTBREAK \\\ FLAC")] // slashes not being escaped properly + [TestCase("XXXTENTACION", "?", "XXXTENTACION ? FLAC")] + [TestCase("Hey", "BŁYSK", "Hey - BŁYSK FLAC")] + public void should_escape_albums(string artist, string album, string releaseTitle) + { + GivenSearchCriteria(artist, album); + var parseResult = Parser.Parser.ParseAlbumTitleWithSearchCriteria(releaseTitle, _artist, _albums); + parseResult.AlbumTitle.Should().Be(album); + } + + [TestCase("???", "Album", "??? Album FLAC")] + [TestCase("+", "Album", "+ Album FLAC")] + [TestCase(@"/\", "Album", @"/\ Album FLAC")] + [TestCase("+44", "When Your Heart Stops Beating", "+44 When Your Heart Stops Beating FLAC")] + public void should_escape_artists(string artist, string album, string releaseTitle) + { + GivenSearchCriteria(artist, album); + var parseResult = Parser.Parser.ParseAlbumTitleWithSearchCriteria(releaseTitle, _artist, _albums); + parseResult.ArtistName.Should().Be(artist); + } + + [TestCase("Michael Bubl\u00E9", "Michael Bubl\u00E9", @"Michael Buble Michael Buble CD FLAC 2003 PERFECT")] + public void should_match_with_accent_in_artist_and_album(string artist, string album, string releaseTitle) + { + GivenSearchCriteria(artist, album); + var parseResult = Parser.Parser.ParseAlbumTitleWithSearchCriteria(releaseTitle, _artist, _albums); + parseResult.ArtistName.Should().Be("Michael Buble"); + parseResult.AlbumTitle.Should().Be("Michael Buble"); + } + + [Test] + public void should_find_result_if_multiple_albums_in_searchcriteria() + { + GivenSearchCriteria("Michael Bubl\u00E9", "Call Me Irresponsible"); + GivenSearchCriteria("Michael Bubl\u00E9", "Michael Bubl\u00E9"); + GivenSearchCriteria("Michael Bubl\u00E9", "love"); + GivenSearchCriteria("Michael Bubl\u00E9", "Christmas"); + GivenSearchCriteria("Michael Bubl\u00E9", "To Be Loved"); + var parseResult = Parser.Parser.ParseAlbumTitleWithSearchCriteria( + "Michael Buble Christmas (Deluxe Special Edition) CD FLAC 2012 UNDERTONE iNT", _artist, _albums); + parseResult.ArtistName.Should().Be("Michael Buble"); + parseResult.AlbumTitle.Should().Be("Christmas"); } } } diff --git a/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetAlbumsFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetAlbumsFixture.cs new file mode 100644 index 000000000..4a696478a --- /dev/null +++ b/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetAlbumsFixture.cs @@ -0,0 +1,38 @@ +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; +using FizzWare.NBuilder; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Parser.Model; +using FluentAssertions; +using System.Linq; +using System.Collections.Generic; + +namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests +{ + [TestFixture] + public class GetAlbumsFixture : CoreTest + { + [Test] + public void should_not_fail_if_search_criteria_contains_multiple_albums_with_the_same_name() + { + var artist = Builder.CreateNew().Build(); + var albums = Builder.CreateListOfSize(2).All().With(x => x.Title = "IdenticalTitle").Build().ToList(); + var criteria = new AlbumSearchCriteria { + Artist = artist, + Albums = albums + }; + + var parsed = new ParsedAlbumInfo { + AlbumTitle = "IdenticalTitle" + }; + + Subject.GetAlbums(parsed, artist, criteria).Should().BeEquivalentTo(new List()); + + Mocker.GetMock() + .Verify(s => s.FindByTitle(artist.ArtistMetadataId, "IdenticalTitle"), Times.Once()); + } + } +} diff --git a/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetArtistFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetArtistFixture.cs new file mode 100644 index 000000000..a24043507 --- /dev/null +++ b/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetArtistFixture.cs @@ -0,0 +1,34 @@ +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests +{ + [TestFixture] + public class GetArtistFixture : CoreTest + { + [Test] + public void should_use_passed_in_title_when_it_cannot_be_parsed() + { + const string title = "30 Rock"; + + Subject.GetArtist(title); + + Mocker.GetMock() + .Verify(s => s.FindByName(title), Times.Once()); + } + + [Test] + public void should_use_parsed_artist_title() + { + const string title = "30 Rock - Get Some [FLAC]"; + + Subject.GetArtist(title); + + Mocker.GetMock() + .Verify(s => s.FindByName(Parser.Parser.ParseAlbumTitle(title).ArtistName), Times.Once()); + } + } +} diff --git a/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetEpisodesFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetEpisodesFixture.cs deleted file mode 100644 index 7221038e7..000000000 --- a/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetEpisodesFixture.cs +++ /dev/null @@ -1,345 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using FizzWare.NBuilder; -using Moq; -using NUnit.Framework; -using NzbDrone.Core.DataAugmentation.Scene; -using NzbDrone.Core.IndexerSearch.Definitions; -using NzbDrone.Core.Parser; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Tv; -using NzbDrone.Test.Common; - -namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests -{ - [TestFixture] - public class GetEpisodesFixture : TestBase - { - private Series _series; - private List _episodes; - private ParsedEpisodeInfo _parsedEpisodeInfo; - private SingleEpisodeSearchCriteria _singleEpisodeSearchCriteria; - - [SetUp] - public void Setup() - { - _series = Builder.CreateNew() - .With(s => s.Title = "30 Rock") - .With(s => s.CleanTitle = "rock") - .Build(); - - _episodes = Builder.CreateListOfSize(1) - .All() - .With(e => e.AirDate = DateTime.Today.ToString(Episode.AIR_DATE_FORMAT)) - .Build() - .ToList(); - - _parsedEpisodeInfo = new ParsedEpisodeInfo - { - SeriesTitle = _series.Title, - SeasonNumber = 1, - EpisodeNumbers = new[] { 1 }, - AbsoluteEpisodeNumbers = new int[0] - }; - - _singleEpisodeSearchCriteria = new SingleEpisodeSearchCriteria - { - Series = _series, - EpisodeNumber = _episodes.First().EpisodeNumber, - SeasonNumber = _episodes.First().SeasonNumber, - Episodes = _episodes - }; - - Mocker.GetMock() - .Setup(s => s.FindByTitle(It.IsAny())) - .Returns(_series); - } - - private void GivenDailySeries() - { - _series.SeriesType = SeriesTypes.Daily; - } - - private void GivenDailyParseResult() - { - _parsedEpisodeInfo.AirDate = DateTime.Today.ToString(Episode.AIR_DATE_FORMAT); - } - - private void GivenSceneNumberingSeries() - { - _series.UseSceneNumbering = true; - } - - private void GivenAbsoluteNumberingSeries() - { - _parsedEpisodeInfo.AbsoluteEpisodeNumbers = new[] { 1 }; - } - - [Test] - public void should_get_daily_episode_episode_when_search_criteria_is_null() - { - GivenDailySeries(); - GivenDailyParseResult(); - - Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId); - - Mocker.GetMock() - .Verify(v => v.FindEpisode(It.IsAny(), It.IsAny()), Times.Once()); - } - - [Test] - public void should_use_search_criteria_episode_when_it_matches_daily() - { - GivenDailySeries(); - GivenDailyParseResult(); - - Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _singleEpisodeSearchCriteria); - - Mocker.GetMock() - .Verify(v => v.FindEpisode(It.IsAny(), It.IsAny()), Times.Never()); - } - - [Test] - public void should_fallback_to_daily_episode_lookup_when_search_criteria_episode_doesnt_match() - { - GivenDailySeries(); - _parsedEpisodeInfo.AirDate = DateTime.Today.AddDays(-5).ToString(Episode.AIR_DATE_FORMAT); ; - - Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _singleEpisodeSearchCriteria); - - Mocker.GetMock() - .Verify(v => v.FindEpisode(It.IsAny(), It.IsAny()), Times.Once()); - } - - [Test] - public void should_use_search_criteria_episode_when_it_matches_absolute() - { - GivenAbsoluteNumberingSeries(); - - Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _singleEpisodeSearchCriteria); - - Mocker.GetMock() - .Verify(v => v.FindEpisode(It.IsAny(), It.IsAny()), Times.Never()); - } - - [Test] - public void should_use_scene_numbering_when_series_uses_scene_numbering() - { - GivenSceneNumberingSeries(); - - Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId); - - Mocker.GetMock() - .Verify(v => v.FindEpisodesBySceneNumbering(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); - } - - [Test] - public void should_match_search_criteria_by_scene_numbering() - { - GivenSceneNumberingSeries(); - - Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _singleEpisodeSearchCriteria); - - Mocker.GetMock() - .Verify(v => v.FindEpisodesBySceneNumbering(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); - } - - [Test] - public void should_fallback_to_findEpisode_when_search_criteria_match_fails_for_scene_numbering() - { - GivenSceneNumberingSeries(); - _episodes.First().SceneEpisodeNumber = 10; - - Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _singleEpisodeSearchCriteria); - - Mocker.GetMock() - .Verify(v => v.FindEpisodesBySceneNumbering(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); - } - - [Test] - public void should_find_episode() - { - Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId); - - Mocker.GetMock() - .Verify(v => v.FindEpisode(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); - } - - [Test] - public void should_match_episode_with_search_criteria() - { - Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _singleEpisodeSearchCriteria); - - Mocker.GetMock() - .Verify(v => v.FindEpisode(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never()); - } - - [Test] - public void should_fallback_to_findEpisode_when_search_criteria_match_fails() - { - _episodes.First().EpisodeNumber = 10; - - Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _singleEpisodeSearchCriteria); - - Mocker.GetMock() - .Verify(v => v.FindEpisode(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); - } - - [Test] - public void should_look_for_episode_in_season_zero_if_absolute_special() - { - GivenAbsoluteNumberingSeries(); - - _parsedEpisodeInfo.Special = true; - - Subject.GetEpisodes(_parsedEpisodeInfo, _series, true, null); - - Mocker.GetMock() - .Verify(v => v.FindEpisodesBySceneNumbering(It.IsAny(), 0, It.IsAny()), Times.Never()); - - Mocker.GetMock() - .Verify(v => v.FindEpisode(It.IsAny(), 0, It.IsAny()), Times.Once()); - } - - [TestCase(0)] - [TestCase(1)] - [TestCase(2)] - public void should_use_scene_numbering_when_scene_season_number_has_value(int seasonNumber) - { - GivenAbsoluteNumberingSeries(); - - Mocker.GetMock() - .Setup(s => s.GetSceneSeasonNumber(_parsedEpisodeInfo.SeriesTitle)) - .Returns(seasonNumber); - - Mocker.GetMock() - .Setup(s => s.FindEpisodesBySceneNumbering(It.IsAny(), seasonNumber, It.IsAny())) - .Returns(new List()); - - Subject.GetEpisodes(_parsedEpisodeInfo, _series, true, null); - - 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.Once()); - } - - [TestCase(0)] - [TestCase(1)] - [TestCase(2)] - public void should_find_episode_by_season_and_scene_absolute_episode_number(int seasonNumber) - { - GivenAbsoluteNumberingSeries(); - - Mocker.GetMock() - .Setup(s => s.GetSceneSeasonNumber(_parsedEpisodeInfo.SeriesTitle)) - .Returns(seasonNumber); - - Mocker.GetMock() - .Setup(s => s.FindEpisodesBySceneNumbering(It.IsAny(), seasonNumber, It.IsAny())) - .Returns(new List { _episodes.First() }); - - Subject.GetEpisodes(_parsedEpisodeInfo, _series, true, null); - - 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_multiple_results(int seasonNumber) - { - GivenAbsoluteNumberingSeries(); - - Mocker.GetMock() - .Setup(s => s.GetSceneSeasonNumber(_parsedEpisodeInfo.SeriesTitle)) - .Returns(seasonNumber); - - Mocker.GetMock() - .Setup(s => s.FindEpisodesBySceneNumbering(It.IsAny(), seasonNumber, It.IsAny())) - .Returns(Builder.CreateListOfSize(5).Build().ToList()); - - Subject.GetEpisodes(_parsedEpisodeInfo, _series, true, null); - - 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.Once()); - } - - [Test] - public void should_use_tvdb_season_number_when_available_and_a_scene_source() - { - const int tvdbSeasonNumber = 5; - - Mocker.GetMock() - .Setup(s => s.FindSceneMapping(_parsedEpisodeInfo.SeriesTitle)) - .Returns(new SceneMapping { SeasonNumber = tvdbSeasonNumber, SceneSeasonNumber = _parsedEpisodeInfo.SeasonNumber }); - - Subject.GetEpisodes(_parsedEpisodeInfo, _series, true, null); - - Mocker.GetMock() - .Verify(v => v.FindEpisode(_series.Id, _parsedEpisodeInfo.SeasonNumber, _parsedEpisodeInfo.EpisodeNumbers.First()), Times.Never()); - - Mocker.GetMock() - .Verify(v => v.FindEpisode(_series.Id, tvdbSeasonNumber, _parsedEpisodeInfo.EpisodeNumbers.First()), Times.Once()); - } - - [Test] - public void should_not_use_tvdb_season_number_when_available_for_a_different_season_and_a_scene_source() - { - const int tvdbSeasonNumber = 5; - - Mocker.GetMock() - .Setup(s => s.FindSceneMapping(_parsedEpisodeInfo.SeriesTitle)) - .Returns(new SceneMapping { SeasonNumber = tvdbSeasonNumber, SceneSeasonNumber = _parsedEpisodeInfo.SeasonNumber + 100 }); - - Subject.GetEpisodes(_parsedEpisodeInfo, _series, true, null); - - Mocker.GetMock() - .Verify(v => v.FindEpisode(_series.Id, tvdbSeasonNumber, _parsedEpisodeInfo.EpisodeNumbers.First()), Times.Never()); - - Mocker.GetMock() - .Verify(v => v.FindEpisode(_series.Id, _parsedEpisodeInfo.SeasonNumber, _parsedEpisodeInfo.EpisodeNumbers.First()), Times.Once()); - } - - [Test] - public void should_not_use_tvdb_season_when_not_a_scene_source() - { - const int tvdbSeasonNumber = 5; - - Subject.GetEpisodes(_parsedEpisodeInfo, _series, false, null); - - Mocker.GetMock() - .Verify(v => v.FindEpisode(_series.Id, tvdbSeasonNumber, _parsedEpisodeInfo.EpisodeNumbers.First()), Times.Never()); - - Mocker.GetMock() - .Verify(v => v.FindEpisode(_series.Id, _parsedEpisodeInfo.SeasonNumber, _parsedEpisodeInfo.EpisodeNumbers.First()), Times.Once()); - } - - [Test] - public void should_not_use_tvdb_season_when_tvdb_season_number_is_less_than_zero() - { - const int tvdbSeasonNumber = -1; - - Mocker.GetMock() - .Setup(s => s.FindSceneMapping(_parsedEpisodeInfo.SeriesTitle)) - .Returns(new SceneMapping { SeasonNumber = tvdbSeasonNumber, SceneSeasonNumber = _parsedEpisodeInfo.SeasonNumber }); - - Subject.GetEpisodes(_parsedEpisodeInfo, _series, true, null); - - Mocker.GetMock() - .Verify(v => v.FindEpisode(_series.Id, tvdbSeasonNumber, _parsedEpisodeInfo.EpisodeNumbers.First()), Times.Never()); - - Mocker.GetMock() - .Verify(v => v.FindEpisode(_series.Id, _parsedEpisodeInfo.SeasonNumber, _parsedEpisodeInfo.EpisodeNumbers.First()), Times.Once()); - } - } -} diff --git a/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetSeriesFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetSeriesFixture.cs deleted file mode 100644 index bf4b399b5..000000000 --- a/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetSeriesFixture.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Moq; -using NUnit.Framework; -using NzbDrone.Core.Parser; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests -{ - [TestFixture] - public class GetSeriesFixture : CoreTest - { - [Test] - public void should_use_passed_in_title_when_it_cannot_be_parsed() - { - const string title = "30 Rock"; - - Subject.GetSeries(title); - - Mocker.GetMock() - .Verify(s => s.FindByTitle(title), Times.Once()); - } - - [Test] - public void should_use_parsed_series_title() - { - const string title = "30.Rock.S01E01.720p.hdtv"; - - Subject.GetSeries(title); - - Mocker.GetMock() - .Verify(s => s.FindByTitle(Parser.Parser.ParseTitle(title).SeriesTitle), Times.Once()); - } - - [Test] - public void should_fallback_to_title_without_year_and_year_when_title_lookup_fails() - { - const string title = "House.2004.S01E01.720p.hdtv"; - var parsedEpisodeInfo = Parser.Parser.ParseTitle(title); - - Subject.GetSeries(title); - - Mocker.GetMock() - .Verify(s => s.FindByTitle(parsedEpisodeInfo.SeriesTitleInfo.TitleWithoutYear, - parsedEpisodeInfo.SeriesTitleInfo.Year), Times.Once()); - } - } -} diff --git a/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/MapFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/MapFixture.cs deleted file mode 100644 index 2357472ce..000000000 --- a/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/MapFixture.cs +++ /dev/null @@ -1,199 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using FizzWare.NBuilder; -using FluentAssertions; -using Moq; -using NUnit.Framework; -using NzbDrone.Core.DataAugmentation.Scene; -using NzbDrone.Core.IndexerSearch.Definitions; -using NzbDrone.Core.Parser; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Tv; -using NzbDrone.Test.Common; - -namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests -{ - [TestFixture] - public class MapFixture : TestBase - { - private Series _series; - private List _episodes; - private ParsedEpisodeInfo _parsedEpisodeInfo; - private SingleEpisodeSearchCriteria _singleEpisodeSearchCriteria; - - [SetUp] - public void Setup() - { - _series = Builder.CreateNew() - .With(s => s.Title = "30 Rock") - .With(s => s.CleanTitle = "rock") - .Build(); - - _episodes = Builder.CreateListOfSize(1) - .All() - .With(e => e.AirDate = DateTime.Today.ToString(Episode.AIR_DATE_FORMAT)) - .Build() - .ToList(); - - _parsedEpisodeInfo = new ParsedEpisodeInfo - { - SeriesTitle = _series.Title, - SeasonNumber = 1, - EpisodeNumbers = new[] { 1 } - }; - - _singleEpisodeSearchCriteria = new SingleEpisodeSearchCriteria - { - Series = _series, - EpisodeNumber = _episodes.First().EpisodeNumber, - SeasonNumber = _episodes.First().SeasonNumber, - Episodes = _episodes - }; - } - - private void GivenMatchBySeriesTitle() - { - Mocker.GetMock() - .Setup(s => s.FindByTitle(It.IsAny())) - .Returns(_series); - } - - private void GivenMatchByTvdbId() - { - Mocker.GetMock() - .Setup(s => s.FindByTvdbId(It.IsAny())) - .Returns(_series); - } - - private void GivenMatchByTvRageId() - { - Mocker.GetMock() - .Setup(s => s.FindByTvRageId(It.IsAny())) - .Returns(_series); - } - - private void GivenParseResultSeriesDoesntMatchSearchCriteria() - { - _parsedEpisodeInfo.SeriesTitle = "Another Name"; - } - - [Test] - public void should_lookup_series_by_name() - { - GivenMatchBySeriesTitle(); - - Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId); - - Mocker.GetMock() - .Verify(v => v.FindByTitle(It.IsAny()), Times.Once()); - } - - [Test] - public void should_use_tvdbid_when_series_title_lookup_fails() - { - GivenMatchByTvdbId(); - - Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId); - - Mocker.GetMock() - .Verify(v => v.FindByTvdbId(It.IsAny()), Times.Once()); - } - - [Test] - public void should_use_tvrageid_when_series_title_lookup_fails() - { - GivenMatchByTvRageId(); - - Subject.Map(_parsedEpisodeInfo, 0, _series.TvRageId); - - Mocker.GetMock() - .Verify(v => v.FindByTvRageId(It.IsAny()), Times.Once()); - } - - [Test] - public void should_not_use_tvrageid_when_scene_naming_exception_exists() - { - GivenMatchByTvRageId(); - - Mocker.GetMock() - .Setup(v => v.FindTvdbId(It.IsAny())) - .Returns(10); - - var result = Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId); - - Mocker.GetMock() - .Verify(v => v.FindByTvRageId(It.IsAny()), Times.Never()); - - result.Series.Should().BeNull(); - } - - [Test] - public void should_use_search_criteria_series_title() - { - GivenMatchBySeriesTitle(); - - Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _singleEpisodeSearchCriteria); - - Mocker.GetMock() - .Verify(v => v.FindByTitle(It.IsAny()), Times.Never()); - } - - [Test] - public void should_FindByTitle_when_search_criteria_matching_fails() - { - GivenParseResultSeriesDoesntMatchSearchCriteria(); - - Subject.Map(_parsedEpisodeInfo, 10, 10, _singleEpisodeSearchCriteria); - - Mocker.GetMock() - .Verify(v => v.FindByTitle(It.IsAny()), Times.Once()); - } - - [Test] - public void should_FindByTvdbId_when_search_criteria_and_FindByTitle_matching_fails() - { - GivenParseResultSeriesDoesntMatchSearchCriteria(); - - Subject.Map(_parsedEpisodeInfo, 10, 10, _singleEpisodeSearchCriteria); - - Mocker.GetMock() - .Verify(v => v.FindByTvdbId(It.IsAny()), Times.Once()); - } - - [Test] - public void should_FindByTvRageId_when_search_criteria_and_FindByTitle_matching_fails() - { - GivenParseResultSeriesDoesntMatchSearchCriteria(); - - Subject.Map(_parsedEpisodeInfo, 10, 10, _singleEpisodeSearchCriteria); - - Mocker.GetMock() - .Verify(v => v.FindByTvRageId(It.IsAny()), Times.Once()); - } - - [Test] - public void should_use_tvdbid_matching_when_alias_is_found() - { - Mocker.GetMock() - .Setup(s => s.FindTvdbId(It.IsAny())) - .Returns(_series.TvdbId); - - Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _singleEpisodeSearchCriteria); - - Mocker.GetMock() - .Verify(v => v.FindByTitle(It.IsAny()), Times.Never()); - } - - [Test] - public void should_use_tvrageid_match_from_search_criteria_when_title_match_fails() - { - GivenParseResultSeriesDoesntMatchSearchCriteria(); - - Subject.Map(_parsedEpisodeInfo, _series.TvdbId, _series.TvRageId, _singleEpisodeSearchCriteria); - - Mocker.GetMock() - .Verify(v => v.FindByTitle(It.IsAny()), Times.Never()); - } - } -} diff --git a/src/NzbDrone.Core.Test/ParserTests/PathParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/PathParserFixture.cs index 9dfdeb851..75aa18d49 100644 --- a/src/NzbDrone.Core.Test/ParserTests/PathParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/PathParserFixture.cs @@ -33,12 +33,12 @@ namespace NzbDrone.Core.Test.ParserTests // [TestCase(@"C:\CSI.NY.S02E04.720p.WEB-DL.DD5.1.H.264\73696S02-04.mkv", 2, 4)] //Gets treated as S01E04 (because it gets parsed as anime) 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); - result.AbsoluteEpisodeNumbers.Should().BeEmpty(); - result.FullSeason.Should().BeFalse(); + var result = Parser.Parser.ParseMusicPath(path.AsOsAgnostic()); + //result.EpisodeNumbers.Should().HaveCount(1); + //result.SeasonNumber.Should().Be(season); + //result.EpisodeNumbers[0].Should().Be(episode); + //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..747f78b25 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; @@ -12,276 +12,307 @@ namespace NzbDrone.Core.Test.ParserTests { public static object[] SelfQualityParserCases = { - new object[] { Quality.SDTV }, - new object[] { Quality.DVD }, - new object[] { Quality.WEBDL480p }, - new object[] { Quality.HDTV720p }, - new object[] { Quality.HDTV1080p }, - new object[] { Quality.HDTV2160p }, - new object[] { Quality.WEBDL720p }, - new object[] { Quality.WEBDL1080p }, - new object[] { Quality.WEBDL2160p }, - new object[] { Quality.Bluray720p }, - new object[] { Quality.Bluray1080p }, - new object[] { Quality.Bluray2160p }, + new object[] {Quality.MP3_192}, + new object[] {Quality.MP3_VBR}, + new object[] {Quality.MP3_256}, + new object[] {Quality.MP3_320}, + new object[] {Quality.MP3_VBR_V2}, + new object[] {Quality.WAV}, + new object[] {Quality.WMA}, + new object[] {Quality.AAC_192}, + new object[] {Quality.AAC_256}, + new object[] {Quality.AAC_320}, + new object[] {Quality.AAC_VBR}, + new object[] {Quality.ALAC}, + new object[] {Quality.FLAC}, }; - public static object[] OtherSourceQualityParserCases = - { - new object[] { "SD TV", Quality.SDTV }, - new object[] { "SD DVD", Quality.DVD }, - new object[] { "480p WEB-DL", Quality.WEBDL480p }, - new object[] { "HD TV", Quality.HDTV720p }, - new object[] { "1080p HD TV", Quality.HDTV1080p }, - new object[] { "2160p HD TV", Quality.HDTV2160p }, - new object[] { "720p WEB-DL", Quality.WEBDL720p }, - new object[] { "1080p WEB-DL", Quality.WEBDL1080p }, - new object[] { "2160p WEB-DL", Quality.WEBDL2160p }, - new object[] { "720p BluRay", Quality.Bluray720p }, - new object[] { "1080p BluRay", Quality.Bluray1080p }, - new object[] { "2160p BluRay", Quality.Bluray2160p }, - }; + [TestCase("", "MPEG Version 1 Audio, Layer 3", 96)] + public void should_parse_mp3_96_quality(string title, string desc, int bitrate) + { + ParseAndVerifyQuality(title, desc, bitrate, Quality.MP3_096); + } + + [TestCase("", "MPEG Version 1 Audio, Layer 3", 128)] + public void should_parse_mp3_128_quality(string title, string desc, int bitrate) + { + ParseAndVerifyQuality(title, desc, bitrate, Quality.MP3_128); + } + + [TestCase("", "MPEG Version 1 Audio, Layer 3", 160)] + public void should_parse_mp3_160_quality(string title, string desc, int bitrate) + { + ParseAndVerifyQuality(title, desc, bitrate, Quality.MP3_160); + } + + [TestCase("VA - The Best 101 Love Ballads (2017) MP3 [192 kbps]", null, 0)] + [TestCase("ATCQ - The Love Movement 1998 2CD 192kbps RIP", null, 0)] + [TestCase("A Tribe Called Quest - The Love Movement 1998 2CD [192kbps] RIP", null, 0)] + [TestCase("Maula - Jism 2 [2012] Mp3 - 192Kbps [Extended]- TK", null, 0)] + [TestCase("VA - Complete Clubland - The Ultimate Ride Of Your Lfe [2014][MP3][192 kbps]", null, 0)] + [TestCase("Complete Clubland - The Ultimate Ride Of Your Lfe [2014][MP3](192kbps)", null, 0)] + [TestCase("The Ultimate Ride Of Your Lfe [192 KBPS][2014][MP3]", null, 0)] + [TestCase("Gary Clark Jr - Live North America 2016 (2017) MP3 192kbps", null, 0)] + [TestCase("Some Song [192][2014][MP3]", null, 0)] + [TestCase("Other Song (192)[2014][MP3]", null, 0)] + [TestCase("", "MPEG Version 1 Audio, Layer 3", 192)] + public void should_parse_mp3_192_quality(string title, string desc, int bitrate) + { + ParseAndVerifyQuality(title, desc, bitrate, Quality.MP3_192); + } - [TestCase("S07E23 .avi ", false)] - [TestCase("The.Shield.S01E13.x264-CtrlSD", false)] - [TestCase("Nikita S02E01 HDTV XviD 2HD", false)] - [TestCase("Gossip Girl S05E11 PROPER HDTV XviD 2HD", true)] - [TestCase("The Jonathan Ross Show S02E08 HDTV x264 FTP", false)] - [TestCase("White.Van.Man.2011.S02E01.WS.PDTV.x264-TLA", false)] - [TestCase("White.Van.Man.2011.S02E01.WS.PDTV.x264-REPACK-TLA", true)] - [TestCase("The Real Housewives of Vancouver S01E04 DSR x264 2HD", false)] - [TestCase("Vanguard S01E04 Mexicos Death Train DSR x264 MiNDTHEGAP", false)] - [TestCase("Chuck S11E03 has no periods or extension HDTV", false)] - [TestCase("Chuck.S04E05.HDTV.XviD-LOL", false)] - [TestCase("Sonny.With.a.Chance.S02E15.avi", false)] - [TestCase("Sonny.With.a.Chance.S02E15.xvid", false)] - [TestCase("Sonny.With.a.Chance.S02E15.divx", false)] - [TestCase("The.Girls.Next.Door.S03E06.HDTV-WiDE", false)] - [TestCase("Degrassi.S10E27.WS.DSR.XviD-2HD", false)] - [TestCase("[HorribleSubs] Yowamushi Pedal - 32 [480p]", false)] - [TestCase("[CR] Sailor Moon - 004 [480p][48CE2D0F]", false)] - [TestCase("[Hatsuyuki] Naruto Shippuuden - 363 [848x480][ADE35E38]", false)] - [TestCase("Muppet.Babies.S03.TVRip.XviD-NOGRP", false)] - public void should_parse_sdtv_quality(string title, bool proper) - { - ParseAndVerifyQuality(title, Quality.SDTV, proper); - } - - [TestCase("WEEDS.S03E01-06.DUAL.XviD.Bluray.AC3-REPACK.-HELLYWOOD.avi", true)] - [TestCase("The.Shield.S01E13.NTSC.x264-CtrlSD", false)] - [TestCase("WEEDS.S03E01-06.DUAL.BDRip.XviD.AC3.-HELLYWOOD", false)] - [TestCase("WEEDS.S03E01-06.DUAL.BDRip.X-viD.AC3.-HELLYWOOD", false)] - [TestCase("WEEDS.S03E01-06.DUAL.BDRip.AC3.-HELLYWOOD", false)] - [TestCase("WEEDS.S03E01-06.DUAL.BDRip.XviD.AC3.-HELLYWOOD.avi", false)] - [TestCase("WEEDS.S03E01-06.DUAL.XviD.Bluray.AC3.-HELLYWOOD.avi", false)] - [TestCase("The.Girls.Next.Door.S03E06.DVDRip.XviD-WiDE", false)] - [TestCase("The.Girls.Next.Door.S03E06.DVD.Rip.XviD-WiDE", false)] - [TestCase("the.shield.1x13.circles.ws.xvidvd-tns", false)] - [TestCase("the_x-files.9x18.sunshine_days.ac3.ws_dvdrip_xvid-fov.avi", false)] - [TestCase("[FroZen] Miyuki - 23 [DVD][7F6170E6]", false)] - [TestCase("Hannibal.S01E05.576p.BluRay.DD5.1.x264-HiSD", false)] - [TestCase("Hannibal.S01E05.480p.BluRay.DD5.1.x264-HiSD", false)] - [TestCase("Heidi Girl of the Alps (BD)(640x480(RAW) (BATCH 1) (1-13)", false)] - [TestCase("[Doki] Clannad - 02 (848x480 XviD BD MP3) [95360783]", false)] - public void should_parse_dvd_quality(string title, bool proper) - { - ParseAndVerifyQuality(title, Quality.DVD, proper); - } - - [TestCase("Elementary.S01E10.The.Leviathan.480p.WEB-DL.x264-mSD", false)] - [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)] - public void should_parse_webdl480p_quality(string title, bool proper) - { - ParseAndVerifyQuality(title, Quality.WEBDL480p, proper); - } - - [TestCase("Dexter - S01E01 - Title [HDTV]", false)] - [TestCase("Dexter - S01E01 - Title [HDTV-720p]", false)] - [TestCase("Pawn Stars S04E87 REPACK 720p HDTV x264 aAF", true)] - [TestCase("Sonny.With.a.Chance.S02E15.720p", false)] - [TestCase("S07E23 - [HDTV-720p].mkv ", false)] - [TestCase("Chuck - S22E03 - MoneyBART - HD TV.mkv", false)] - [TestCase("S07E23.mkv ", false)] - [TestCase("Two.and.a.Half.Men.S08E05.720p.HDTV.X264-DIMENSION", false)] - [TestCase("Sonny.With.a.Chance.S02E15.mkv", false)] - [TestCase(@"E:\Downloads\tv\The.Big.Bang.Theory.S01E01.720p.HDTV\ajifajjjeaeaeqwer_eppj.avi", false)] - [TestCase("Gem.Hunt.S01E08.Tourmaline.Nepal.720p.HDTV.x264-DHD", false)] - [TestCase("[Underwater-FFF] No Game No Life - 01 (720p) [27AAA0A0]", false)] - [TestCase("[Doki] Mahouka Koukou no Rettousei - 07 (1280x720 Hi10P AAC) [80AF7DDE]", false)] - [TestCase("[Doremi].Yes.Pretty.Cure.5.Go.Go!.31.[1280x720].[C65D4B1F].mkv", false)] - [TestCase("[HorribleSubs]_Fairy_Tail_-_145_[720p]", false)] - [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)] - public void should_parse_hdtv720p_quality(string title, bool proper) - { - ParseAndVerifyQuality(title, Quality.HDTV720p, proper); - } - - [TestCase("Under the Dome S01E10 Let the Games Begin 1080p", false)] - [TestCase("DEXTER.S07E01.ARE.YOU.1080P.HDTV.X264-QCF", false)] - [TestCase("DEXTER.S07E01.ARE.YOU.1080P.HDTV.x264-QCF", false)] - [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)] - public void should_parse_hdtv1080p_quality(string title, bool proper) - { - ParseAndVerifyQuality(title, Quality.HDTV1080p, proper); - } - - [TestCase("Arrested.Development.S04E01.720p.WEBRip.AAC2.0.x264-NFRiP", false)] - [TestCase("Vanguard S01E04 Mexicos Death Train 720p WEB DL", false)] - [TestCase("Hawaii Five 0 S02E21 720p WEB DL DD5 1 H 264", false)] - [TestCase("Castle S04E22 720p WEB DL DD5 1 H 264 NFHD", false)] - [TestCase("Chuck - S11E06 - D-Yikes! - 720p WEB-DL.mkv", false)] - [TestCase("Sonny.With.a.Chance.S02E15.720p.WEB-DL.DD5.1.H.264-SURFER", false)] - [TestCase("S07E23 - [WEBDL].mkv ", false)] - [TestCase("Fringe S04E22 720p WEB-DL DD5.1 H264-EbP.mkv", false)] - [TestCase("House.S04.720p.Web-Dl.Dd5.1.h264-P2PACK", false)] - [TestCase("Da.Vincis.Demons.S02E04.720p.WEB.DL.nSD.x264-NhaNc3", false)] - [TestCase("CSI.Miami.S04E25.720p.iTunesHD.AVC-TVS", false)] - [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)] - public void should_parse_webdl720p_quality(string title, bool proper) - { - ParseAndVerifyQuality(title, Quality.WEBDL720p, proper); - } - - [TestCase("Arrested.Development.S04E01.iNTERNAL.1080p.WEBRip.x264-QRUS", false)] - [TestCase("CSI NY S09E03 1080p WEB DL DD5 1 H264 NFHD", false)] - [TestCase("Two and a Half Men S10E03 1080p WEB DL DD5 1 H 264 NFHD", false)] - [TestCase("Criminal.Minds.S08E01.1080p.WEB-DL.DD5.1.H264-NFHD", false)] - [TestCase("Its.Always.Sunny.in.Philadelphia.S08E01.1080p.WEB-DL.proper.AAC2.0.H.264", true)] - [TestCase("Two and a Half Men S10E03 1080p WEB DL DD5 1 H 264 REPACK NFHD", true)] - [TestCase("Glee.S04E09.Swan.Song.1080p.WEB-DL.DD5.1.H.264-ECI", false)] - [TestCase("The.Big.Bang.Theory.S06E11.The.Santa.Simulation.1080p.WEB-DL.DD5.1.H.264", false)] - [TestCase("Rosemary's.Baby.S01E02.Night.2.[WEBDL-1080p].mkv", false)] - [TestCase("The.Nightly.Show.2016.03.14.1080p.WEB.x264-spamTV", false)] - [TestCase("The.Nightly.Show.2016.03.14.1080p.WEB.h264-spamTV", false)] - [TestCase("Psych.S01.1080p.WEB-DL.AAC2.0.AVC-TrollHD", false)] - [TestCase("Series Title S06E08 1080p WEB h264-EXCLUSIVE", false)] - [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)] - public void should_parse_webdl1080p_quality(string title, bool proper) - { - ParseAndVerifyQuality(title, Quality.WEBDL1080p, proper); - } - - [TestCase("CASANOVA S01E01.2160P AMZN WEBRIP DD2.0 HI10P X264-TROLLUHD", false)] - [TestCase("JUST ADD MAGIC S01E01.2160P AMZN WEBRIP DD2.0 X264-TROLLUHD", false)] - [TestCase("The.Man.In.The.High.Castle.S01E01.2160p.AMZN.WEBRip.DD2.0.Hi10p.X264-TrollUHD", false)] - [TestCase("The Man In the High Castle S01E01 2160p AMZN WEBRip DD2.0 Hi10P x264-TrollUHD", false)] - [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)] - public void should_parse_webdl2160p_quality(string title, bool proper) - { - ParseAndVerifyQuality(title, Quality.WEBDL2160p, proper); - } - - [TestCase("WEEDS.S03E01-06.DUAL.Bluray.AC3.-HELLYWOOD.avi", false)] - [TestCase("Chuck - S01E03 - Come Fly With Me - 720p BluRay.mkv", false)] - [TestCase("The Big Bang Theory.S03E01.The Electric Can Opener Fluctuation.m2ts", false)] - [TestCase("Revolution.S01E02.Chained.Heat.[Bluray720p].mkv", false)] - [TestCase("[FFF] DATE A LIVE - 01 [BD][720p-AAC][0601BED4]", false)] - [TestCase("[coldhell] Pupa v3 [BD720p][03192D4C]", false)] - [TestCase("[RandomRemux] Nobunagun - 01 [720p BD][043EA407].mkv", false)] - [TestCase("[Kaylith] Isshuukan Friends Specials - 01 [BD 720p AAC][B7EEE164].mkv", false)] - [TestCase("WEEDS.S03E01-06.DUAL.Blu-ray.AC3.-HELLYWOOD.avi", false)] - [TestCase("WEEDS.S03E01-06.DUAL.720p.Blu-ray.AC3.-HELLYWOOD.avi", false)] - [TestCase("[Elysium]Lucky.Star.01(BD.720p.AAC.DA)[0BB96AD8].mkv", false)] - [TestCase("Battlestar.Galactica.S01E01.33.720p.HDDVD.x264-SiNNERS.mkv", false)] - [TestCase("The.Expanse.S01E07.RERIP.720p.BluRay.x264-DEMAND", true)] - public void should_parse_bluray720p_quality(string title, bool proper) - { - ParseAndVerifyQuality(title, Quality.Bluray720p, proper); - } - - [TestCase("Chuck - S01E03 - Come Fly With Me - 1080p BluRay.mkv", false)] - [TestCase("Sons.Of.Anarchy.S02E13.1080p.BluRay.x264-AVCDVD", false)] - [TestCase("Revolution.S01E02.Chained.Heat.[Bluray1080p].mkv", false)] - [TestCase("[FFF] Namiuchigiwa no Muromi-san - 10 [BD][1080p-FLAC][0C4091AF]", false)] - [TestCase("[coldhell] Pupa v2 [BD1080p][5A45EABE].mkv", false)] - [TestCase("[Kaylith] Isshuukan Friends Specials - 01 [BD 1080p FLAC][429FD8C7].mkv", false)] - [TestCase("[Zurako] Log Horizon - 01 - The Apocalypse (BD 1080p AAC) [7AE12174].mkv", false)] - [TestCase("WEEDS.S03E01-06.DUAL.1080p.Blu-ray.AC3.-HELLYWOOD.avi", false)] - [TestCase("[Coalgirls]_Durarara!!_01_(1920x1080_Blu-ray_FLAC)_[8370CB8F].mkv", false)] - public void should_parse_bluray1080p_quality(string title, bool proper) - { - ParseAndVerifyQuality(title, Quality.Bluray1080p, 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)] - [TestCase("Californication.S07E11.1080i.HDTV.DD5.1.MPEG2-NTb.ts", false)] - [TestCase("Game of Thrones S04E10 1080i HDTV MPEG2 DD5.1-CtrlHD.ts", false)] - [TestCase("VICE.S02E05.1080i.HDTV.DD2.0.MPEG2-NTb.ts", false)] - [TestCase("Show - S03E01 - Episode Title Raw-HD.ts", false)] - [TestCase("Saturday.Night.Live.Vintage.S10E09.Eddie.Murphy.The.Honeydrippers.1080i.UPSCALE.HDTV.DD5.1.MPEG2-zebra", false)] - [TestCase("The.Colbert.Report.2011-08-04.1080i.HDTV.MPEG-2-CtrlHD", false)] - public void should_parse_raw_quality(string title, bool proper) - { - ParseAndVerifyQuality(title, Quality.RAWHD, proper); - } - - [TestCase("Sonny.With.a.Chance.S02E15", false)] - [TestCase("Law & Order: Special Victims Unit - 11x11 - Quickie", false)] - [TestCase("Series.Title.S01E01.webm", false)] - [TestCase("Droned.S01E01.The.Web.MT-dd", false)] - public void quality_parse(string title, bool proper) - { - ParseAndVerifyQuality(title, Quality.Unknown, proper); + [TestCase("Caetano Veloso Discografia Completa MP3 @256", null, 0)] + [TestCase("Ricky Martin - A Quien Quiera Escuchar (2015) 256 kbps [GloDLS]", null, 0)] + [TestCase("Jake Bugg - Jake Bugg (Album) [2012] {MP3 256 kbps}", null, 0)] + [TestCase("Clean Bandit - New Eyes [2014] [Mp3-256]-V3nom [GLT]", null, 0)] + [TestCase("Armin van Buuren - A State Of Trance 810 (20.04.2017) 256 kbps", null, 0)] + [TestCase("PJ Harvey - Let England Shake [mp3-256-2011][trfkad]", null, 0)] + [TestCase("", "MPEG Version 1 Audio, Layer 3", 256)] + public void should_parse_mp3_256_quality(string title, string desc, int bitrate) + { + ParseAndVerifyQuality(title, desc, bitrate, Quality.MP3_256); + } + + [TestCase("Beyoncé Lemonade [320] 2016 Beyonce Lemonade [320] 2016", null, 0)] + [TestCase("Childish Gambino - Awaken, My Love Album 2016 mp3 320 Kbps", null, 0)] + [TestCase("Maluma – Felices Los 4 MP3 320 Kbps 2017 Download", null, 0)] + [TestCase("Ricardo Arjona - APNEA (Single 2014) (320 kbps)", null, 0)] + [TestCase("Kehlani - SweetSexySavage (Deluxe Edition) (2017) 320", null, 0)] + [TestCase("Anderson Paak - Malibu (320)(2016)", null, 0)] + [TestCase("", "MPEG Version 1 Audio, Layer 3", 320)] + public void should_parse_mp3_320_quality(string title, string desc, int bitrate) + { + ParseAndVerifyQuality(title, desc, bitrate, Quality.MP3_320); + } + + [TestCase("Sia - This Is Acting (Standard Edition) [2016-Web-MP3-V0(VBR)]", null, 0)] + [TestCase("Mount Eerie - A Crow Looked at Me (2017) [MP3 V0 VBR)]", null, 0)] + public void should_parse_mp3_vbr_v0_quality(string title, string desc, int bitrate) + { + ParseAndVerifyQuality(title, desc, bitrate, Quality.MP3_VBR); + } + + //TODO Parser should look at bitrate range for quality to determine level of VBR + [TestCase("", "MPEG Version 1 Audio, Layer 3 VBR", 298)] + [Ignore("Parser should look at bitrate range for quality to determine level of VBR")] + public void should_parse_mp3_vbr_v2_quality(string title, string desc, int bitrate) + { + ParseAndVerifyQuality(title, desc, bitrate, Quality.MP3_VBR_V2); + } + + [TestCase("Kendrick Lamar - DAMN (2017) FLAC", null, 0)] + [TestCase("Alicia Keys - Vault Playlist Vol. 1 (2017) [FLAC CD]", null, 0)] + [TestCase("Gorillaz - Humanz (Deluxe) - lossless FLAC Tracks - 2017 - CDrip", null, 0)] + [TestCase("David Bowie - Blackstar (2016) [FLAC]", null, 0)] + [TestCase("The Cure - Greatest Hits (2001) FLAC Soup", null, 0)] + [TestCase("Slowdive- Souvlaki (FLAC)", null, 0)] + [TestCase("John Coltrane - Kulu Se Mama (1965) [EAC-FLAC]", null, 0)] + [TestCase("The Rolling Stones - The Very Best Of '75-'94 (1995) {FLAC}", null, 0)] + [TestCase("Migos-No_Label_II-CD-FLAC-2014-FORSAKEN", null, 0)] + [TestCase("ADELE 25 CD FLAC 2015 PERFECT", null, 0)] + [TestCase("", "Flac Audio", 1057)] + public void should_parse_flac_quality(string title, string desc, int bitrate) + { + ParseAndVerifyQuality(title, desc, bitrate, Quality.FLAC); + } + + [TestCase("Beck.-.Guero.2005.[2016.Remastered].24bit.96kHz.LOSSLESS.FLAC", null, 0, 0)] + [TestCase("[R.E.M - Lifes Rich Pageant(1986) [24bit192kHz 2016 Remaster]LOSSLESS FLAC]", null, 0, 0)] + [TestCase("", "Flac Audio", 5057, 24)] + public void should_parse_flac_24bit_quality(string title, string desc, int bitrate, int sampleSize) + { + ParseAndVerifyQuality(title, desc, bitrate, Quality.FLAC_24, sampleSize); + } + + [TestCase("", "Microsoft WMA2 Audio", 218)] + public void should_parse_wma_quality(string title, string desc, int bitrate) + { + ParseAndVerifyQuality(title, desc, bitrate, Quality.WMA); + } + + [TestCase("", "PCM Audio", 1411)] + public void should_parse_wav_quality(string title, string desc, int bitrate) + { + ParseAndVerifyQuality(title, desc, bitrate, Quality.WAV); + } + + + [TestCase("Chuck Berry Discography ALAC", null, 0)] + [TestCase("A$AP Rocky - LONG LIVE A$AP Deluxe asap[ALAC]", null, 0)] + [TestCase("", "MPEG-4 Audio (alac)", 0)] + public void should_parse_alac_quality(string title, string desc, int bitrate) + { + ParseAndVerifyQuality(title, desc, bitrate, Quality.ALAC); + } + + [TestCase("Stevie Ray Vaughan Discography (1981-1987) [APE]", null, 0)] + [TestCase("Brain Ape - Rig it [2014][ape]", null, 0)] + [TestCase("", "Monkey's Audio", 0)] + public void should_parse_ape_quality(string title, string desc, int bitrate) + { + ParseAndVerifyQuality(title, desc, bitrate, Quality.APE); + } + + [TestCase("Arctic Monkeys - AM {2013-Album}", null, 0)] + [TestCase("Audio Adrinaline - Audio Adrinaline", null, 0)] + [TestCase("Audio Adrinaline - Audio Adrinaline [Mixtape FLAC]", null, 0)] + [TestCase("Brain Ape - Rig it [2014][flac]", null, 0)] + [TestCase("Coil - The Ape Of Naples(2005) (FLAC)", null, 0)] + public void should_not_parse_ape_quality(string title, string desc, int bitrate) + { + var result = QualityParser.ParseQuality(title, desc, bitrate); + result.Quality.Should().NotBe(Quality.APE); + } + + [TestCase("Opus - Drums Unlimited (1966) [Flac]", null, 0)] + public void should_not_parse_opus_quality(string title, string desc, int bitrate) + { + var result = QualityParser.ParseQuality(title, desc, bitrate); + result.Quality.Should().Be(Quality.FLAC); + } + + [TestCase("Max Roach - Drums Unlimited (1966) [WavPack]", null, 0)] + [TestCase("Roxette - Charm School(2011) (2CD) [WV]", null, 0)] + [TestCase("", "WavPack", 0)] + public void should_parse_wavpack_quality(string title, string desc, int bitrate) + { + ParseAndVerifyQuality(title, desc, bitrate, Quality.WAVPACK); + } + + [TestCase("Milky Chance - Sadnecessary [256 Kbps] [M4A]", null, 0)] + [TestCase("Little Mix - Salute [Deluxe Edition] [2013] [M4A-256]-V3nom [GLT", null, 0)] + [TestCase("X-Men Soundtracks (2006-2014) AAC, 256 kbps", null, 0)] + [TestCase("The Weeknd - The Hills - Single[iTunes Plus AAC M4A]", null, 0)] + [TestCase("Walk the Line Soundtrack (2005) [AAC, 256 kbps]", null, 0)] + [TestCase("Firefly Soundtrack(2007 (2002-2003)) [AAC, 256 kbps VBR]", null, 0)] + public void should_parse_aac_256_quality(string title, string desc, int bitrate) + { + ParseAndVerifyQuality(title, desc, bitrate, Quality.AAC_256); + } + + [TestCase("", "MPEG-4 Audio (mp4a)", 320)] + [TestCase("", "MPEG-4 Audio (drms)", 320)] + public void should_parse_aac_320_quality(string title, string desc, int bitrate) + { + ParseAndVerifyQuality(title, desc, bitrate, Quality.AAC_320); + } + + [TestCase("", "MPEG-4 Audio (mp4a)", 321)] + [TestCase("", "MPEG-4 Audio (drms)", 321)] + public void should_parse_aac_vbr_quality(string title, string desc, int bitrate) + { + ParseAndVerifyQuality(title, desc, bitrate, Quality.AAC_VBR); + } + + [TestCase("Kirlian Camera - The Ice Curtain - Album 1998 - Ogg-Vorbis Q10", null, 0)] + [TestCase("", "Vorbis Version 0 Audio", 500)] + [TestCase("", "Opus Version 1 Audio", 501)] + public void should_parse_vorbis_q10_quality(string title, string desc, int bitrate) + { + ParseAndVerifyQuality(title, desc, bitrate, Quality.VORBIS_Q10); + } + + [TestCase("", "Vorbis Version 0 Audio", 320)] + [TestCase("", "Opus Version 1 Audio", 321)] + public void should_parse_vorbis_q9_quality(string title, string desc, int bitrate) + { + ParseAndVerifyQuality(title, desc, bitrate, Quality.VORBIS_Q9); + } + + [TestCase("Various Artists - No New York [1978/Ogg/q8]", null, 0)] + [TestCase("", "Vorbis Version 0 Audio", 256)] + [TestCase("", "Opus Version 1 Audio", 257)] + public void should_parse_vorbis_q8_quality(string title, string desc, int bitrate) + { + ParseAndVerifyQuality(title, desc, bitrate, Quality.VORBIS_Q8); + } + + [TestCase("Masters_At_Work-Nuyorican_Soul-.Talkin_Loud.-1997-OGG.Q7", null, 0)] + [TestCase("", "Vorbis Version 0 Audio", 224)] + [TestCase("", "Opus Version 1 Audio", 225)] + public void should_parse_vorbis_q7_quality(string title, string desc, int bitrate) + { + ParseAndVerifyQuality(title, desc, bitrate, Quality.VORBIS_Q7); + } + + [TestCase("", "Vorbis Version 0 Audio", 192)] + [TestCase("", "Opus Version 1 Audio", 193)] + public void should_parse_vorbis_q6_quality(string title, string desc, int bitrate) + { + ParseAndVerifyQuality(title, desc, bitrate, Quality.VORBIS_Q6); + } + + [TestCase("", "Vorbis Version 0 Audio", 160)] + [TestCase("", "Opus Version 1 Audio", 161)] + public void should_parse_vorbis_q5_quality(string title, string desc, int bitrate) + { + ParseAndVerifyQuality(title, desc, bitrate, Quality.VORBIS_Q5); + } + + // Flack doesn't get match for 'FLAC' quality + [TestCase("Roberta Flack 2006 - The Very Best of")] + public void should_not_parse_flac_quality(string title) + { + ParseAndVerifyQuality(title, null, 0, Quality.Unknown); + } + + [TestCase("The Chainsmokers & Coldplay - Something Just Like This")] + [TestCase("Frank Ocean Blonde 2016")] + //TODO: This should be parsed as Unknown and not MP3-96 + //[TestCase("A - NOW Thats What I Call Music 96 (2017) [Mp3~Kbps]")] + [TestCase("Queen - The Ultimate Best Of Queen(2011)[mp3]")] + [TestCase("Maroon 5 Ft Kendrick Lamar -Dont Wanna Know MP3 2016")] + public void quality_parse(string title) + { + ParseAndVerifyQuality(title, null, 0, Quality.Unknown); } [Test, TestCaseSource(nameof(SelfQualityParserCases))] public void parsing_our_own_quality_enum_name(Quality quality) { - var fileName = string.Format("My series S01E01 [{0}]", quality.Name); - var result = QualityParser.ParseQuality(fileName); + var fileName = string.Format("Some album [{0}]", quality.Name); + var result = QualityParser.ParseQuality(fileName, null, 0); result.Quality.Should().Be(quality); } - [Test, TestCaseSource(nameof(OtherSourceQualityParserCases))] - public void should_parse_quality_from_other_source(string qualityString, Quality quality) + [TestCase("Little Mix - Salute [Deluxe Edition] [2013] [M4A-256]-V3nom [GLT")] + public void should_parse_quality_from_name(string title) { - foreach (var c in new char[] { '-', '.', ' ', '_' }) - { - var title = string.Format("My series S01E01 {0}", qualityString.Replace(' ', c)); + QualityParser.ParseQuality(title, null, 0).QualityDetectionSource.Should().Be(QualityDetectionSource.Name); + } - ParseAndVerifyQuality(title, quality, false); - } + [TestCase("01. Kanye West - Ultralight Beam.mp3")] + [TestCase("01. Kanye West - Ultralight Beam.ogg")] + //These get detected by name as we are looking for the extensions as identifiers for release names + //[TestCase("01. Kanye West - Ultralight Beam.m4a")] + //[TestCase("01. Kanye West - Ultralight Beam.wma")] + //[TestCase("01. Kanye West - Ultralight Beam.wav")] + public void should_parse_quality_from_extension(string title) + { + QualityParser.ParseQuality(title, null, 0).QualityDetectionSource.Should().Be(QualityDetectionSource.Extension); } - [TestCase("Saturday.Night.Live.Vintage.S10E09.Eddie.Murphy.The.Honeydrippers.1080i.UPSCALE.HDTV.DD5.1.MPEG2-zebra")] - [TestCase("Dexter - S01E01 - Title [HDTV-1080p]")] - [TestCase("[CR] Sailor Moon - 004 [480p][48CE2D0F]")] - [TestCase("White.Van.Man.2011.S02E01.WS.PDTV.x264-REPACK-TLA")] - public void should_parse_quality_from_name(string title) + [Test] + public void should_parse_null_quality_description_as_unknown() { - QualityParser.ParseQuality(title).QualitySource.Should().Be(QualitySource.Name); + QualityParser.ParseCodec(null, null).Should().Be(Codec.Unknown); } - [TestCase("Revolution.S01E02.Chained.Heat.mkv")] - [TestCase("Dexter - S01E01 - Title.avi")] - [TestCase("the_x-files.9x18.sunshine_days.avi")] - [TestCase("[CR] Sailor Moon - 004 [48CE2D0F].avi")] - public void should_parse_quality_from_extension(string title) + [TestCase("Artist Title - Album Title 2017 REPACK FLAC aAF", true)] + [TestCase("Artist Title - Album Title 2017 RERIP FLAC aAF", true)] + [TestCase("Artist Title - Album Title 2017 PROPER FLAC aAF", false)] + public void should_be_able_to_parse_repack(string title, bool isRepack) { - QualityParser.ParseQuality(title).QualitySource.Should().Be(QualitySource.Extension); + var result = QualityParser.ParseQuality(title, null, 0); + result.Revision.Version.Should().Be(2); + result.Revision.IsRepack.Should().Be(isRepack); } - private void ParseAndVerifyQuality(string title, Quality quality, bool proper) + private void ParseAndVerifyQuality(string name, string desc, int bitrate, Quality quality, int sampleSize = 0) { - var result = QualityParser.ParseQuality(title); + var result = QualityParser.ParseQuality(name, desc, bitrate, sampleSize); result.Quality.Should().Be(quality); - - var version = proper ? 2 : 1; - result.Revision.Version.Should().Be(version); } + } } diff --git a/src/NzbDrone.Core.Test/ParserTests/ReleaseGroupParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ReleaseGroupParserFixture.cs index 18fd75856..7977a3608 100644 --- a/src/NzbDrone.Core.Test/ParserTests/ReleaseGroupParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/ReleaseGroupParserFixture.cs @@ -7,25 +7,13 @@ namespace NzbDrone.Core.Test.ParserTests [TestFixture] public class ReleaseGroupParserFixture : CoreTest { - [TestCase("Castle.2009.S01E14.English.HDTV.XviD-LOL", "LOL")] - [TestCase("Castle 2009 S01E14 English HDTV XviD LOL", null)] - [TestCase("Acropolis Now S05 EXTRAS DVDRip XviD RUNNER", null)] - [TestCase("Punky.Brewster.S01.EXTRAS.DVDRip.XviD-RUNNER", "RUNNER")] - [TestCase("2020.NZ.2011.12.02.PDTV.XviD-C4TV", "C4TV")] - [TestCase("The.Office.S03E115.DVDRip.XviD-OSiTV", "OSiTV")] - [TestCase("The Office - S01E01 - Pilot [HTDV-480p]", null)] - [TestCase("The Office - S01E01 - Pilot [HTDV-720p]", null)] - [TestCase("The Office - S01E01 - Pilot [HTDV-1080p]", null)] - [TestCase("The.Walking.Dead.S04E13.720p.WEB-DL.AAC2.0.H.264-Cyphanix", "Cyphanix")] - [TestCase("Arrow.S02E01.720p.WEB-DL.DD5.1.H.264.mkv", null)] - [TestCase("Series Title S01E01 Episode Title", null)] - [TestCase("The Colbert Report - 2014-06-02 - Thomas Piketty.mkv", null)] - [TestCase("Real Time with Bill Maher S12E17 May 23, 2014.mp4", null)] - [TestCase("Reizen Waes - S01E08 - Transistri\u00EB, Zuid-Osseti\u00EB en Abchazi\u00EB SDTV.avi", null)] - [TestCase("Simpsons 10x11 - Wild Barts Cant Be Broken [rl].avi", null)] - [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("Olafur.Arnalds-Remember-WEB-2018-ENTiTLED", "ENTiTLED")] + [TestCase("[ www.Torrenting.com ] - Olafur.Arnalds-Remember-WEB-2018-ENTiTLED", "ENTiTLED")] + [TestCase("Olafur.Arnalds-Remember-WEB-2018-ENTiTLED [eztv]-[rarbg.com]", "ENTiTLED")] + [TestCase("7s-atlantis-128.mp3", null)] + [TestCase("Olafur.Arnalds-Remember-WEB-2018-ENTiTLED-Pre", "ENTiTLED")] + [TestCase("Olafur.Arnalds-Remember-WEB-2018-ENTiTLED-postbot", "ENTiTLED")] + [TestCase("Olafur.Arnalds-Remember-WEB-2018-ENTiTLED-xpost", "ENTiTLED")] //[TestCase("", "")] public void should_parse_release_group(string title, string expected) { @@ -33,30 +21,31 @@ namespace NzbDrone.Core.Test.ParserTests } [Test] + [Ignore("Track name parsing needs to be worked on")] public void should_not_include_extension_in_release_group() { const string path = @"C:\Test\Doctor.Who.2005.s01e01.internal.bdrip.x264-archivist.mkv"; - Parser.Parser.ParsePath(path).ReleaseGroup.Should().Be("archivist"); + Parser.Parser.ParseMusicPath(path).ReleaseGroup.Should().Be("archivist"); } - [TestCase("Marvels.Daredevil.S02E04.720p.WEBRip.x264-SKGTV English", "SKGTV")] - [TestCase("Marvels.Daredevil.S02E04.720p.WEBRip.x264-SKGTV_English", "SKGTV")] - [TestCase("Marvels.Daredevil.S02E04.720p.WEBRip.x264-SKGTV.English", "SKGTV")] + [TestCase("Olafur.Arnalds-Remember-WEB-2018-SKGTV English", "SKGTV")] + [TestCase("Olafur.Arnalds-Remember-WEB-2018-SKGTV_English", "SKGTV")] + [TestCase("Olafur.Arnalds-Remember-WEB-2018-SKGTV.English", "SKGTV")] //[TestCase("", "")] public void should_not_include_language_in_release_group(string title, string expected) { Parser.Parser.ParseReleaseGroup(title).Should().Be(expected); } - [TestCase("The.Longest.Mystery.S02E04.720p.WEB-DL.AAC2.0.H.264-EVL-RP", "EVL")] - [TestCase("The.Longest.Mystery.S02E04.720p.WEB-DL.AAC2.0.H.264-EVL-RP-RP", "EVL")] - [TestCase("The.Longest.Mystery.S02E04.720p.WEB-DL.AAC2.0.H.264-EVL-Obfuscated", "EVL")] - [TestCase("Lost.S04E04.720p.BluRay.x264-xHD-NZBgeek", "xHD")] - [TestCase("Blue.Bloods.S05E11.720p.HDTV.X264-DIMENSION-NZBgeek", "DIMENSION")] - [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("Olafur.Arnalds-Remember-WEB-2018-EVL-RP", "EVL")] + [TestCase("Olafur.Arnalds-Remember-WEB-2018-EVL-RP-RP", "EVL")] + [TestCase("Olafur.Arnalds-Remember-WEB-2018-EVL-Obfuscated", "EVL")] + [TestCase("Olafur.Arnalds-Remember-WEB-2018-xHD-NZBgeek", "xHD")] + [TestCase("Olafur.Arnalds-Remember-WEB-2018-DIMENSION-NZBgeek", "DIMENSION")] + [TestCase("Olafur.Arnalds-Remember-WEB-2018-xHD-1", "xHD")] + [TestCase("Olafur.Arnalds-Remember-WEB-2018-DIMENSION-1", "DIMENSION")] + [TestCase("Olafur.Arnalds-Remember-WEB-2018-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/SceneCheckerFixture.cs b/src/NzbDrone.Core.Test/ParserTests/SceneCheckerFixture.cs deleted file mode 100644 index 487b80a12..000000000 --- a/src/NzbDrone.Core.Test/ParserTests/SceneCheckerFixture.cs +++ /dev/null @@ -1,36 +0,0 @@ -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Parser; - -namespace NzbDrone.Core.Test.ParserTests -{ - [TestFixture] - public class SceneCheckerFixture - { - [TestCase("South.Park.S04E13.Helen.Keller.The.Musical.720p.WEBRip.AAC2.0.H.264-GC")] - [TestCase("Robot.Chicken.S07E02.720p.WEB-DL.DD5.1.H.264-pcsyndicate")] - [TestCase("Archer.2009.S05E06.Baby.Shower.720p.WEB-DL.DD5.1.H.264-iT00NZ")] - [TestCase("30.Rock.S04E17.720p.HDTV.X264-DIMENSION")] - [TestCase("30.Rock.S04.720p.HDTV.X264-DIMENSION")] - public void should_return_true_for_scene_names(string title) - { - SceneChecker.IsSceneTitle(title).Should().BeTrue(); - } - - - [TestCase("S08E05 - Virtual In-Stanity [WEBDL-720p]")] - [TestCase("S08E05 - Virtual In-Stanity.With.Dots [WEBDL-720p]")] - [TestCase("Something")] - [TestCase("86de66b7ef385e2fa56a3e41b98481ea1658bfab")] - [TestCase("30.Rock.S04E17.720p.HDTV.X264", Description = "no group")] - [TestCase("S04E17.720p.HDTV.X264-DIMENSION", Description = "no series title")] - [TestCase("30.Rock.S04E17-DIMENSION", Description = "no quality")] - [TestCase("30.Rock.720p.HDTV.X264-DIMENSION", Description = "no episode")] - public void should_return_false_for_non_scene_names(string title) - { - SceneChecker.IsSceneTitle(title).Should().BeFalse(); - } - - - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/ParserTests/SeasonParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/SeasonParserFixture.cs deleted file mode 100644 index 7a4ed0b9f..000000000 --- a/src/NzbDrone.Core.Test/ParserTests/SeasonParserFixture.cs +++ /dev/null @@ -1,57 +0,0 @@ -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.ParserTests -{ - - [TestFixture] - public class SeasonParserFixture : CoreTest - { - [TestCase("30.Rock.Season.04.HDTV.XviD-DIMENSION", "30 Rock", 4)] - [TestCase("Parks.and.Recreation.S02.720p.x264-DIMENSION", "Parks and Recreation", 2)] - [TestCase("The.Office.US.S03.720p.x264-DIMENSION", "The Office US", 3)] - [TestCase(@"Sons.of.Anarchy.S03.720p.BluRay-CLUE\REWARD", "Sons of Anarchy", 3)] - [TestCase("Adventure Time S02 720p HDTV x264 CRON", "Adventure Time", 2)] - [TestCase("Sealab.2021.S04.iNTERNAL.DVDRip.XviD-VCDVaULT", "Sealab 2021", 4)] - [TestCase("Hawaii Five 0 S01 720p WEB DL DD5 1 H 264 NT", "Hawaii Five 0", 1)] - [TestCase("30 Rock S03 WS PDTV XviD FUtV", "30 Rock", 3)] - [TestCase("The Office Season 4 WS PDTV XviD FUtV", "The Office", 4)] - [TestCase("Eureka Season 1 720p WEB DL DD 5 1 h264 TjHD", "Eureka", 1)] - [TestCase("The Office Season4 WS PDTV XviD FUtV", "The Office", 4)] - [TestCase("Eureka S 01 720p WEB DL DD 5 1 h264 TjHD", "Eureka", 1)] - [TestCase("Doctor Who Confidential Season 3", "Doctor Who Confidential", 3)] - [TestCase("Fleming.S01.720p.WEBDL.DD5.1.H.264-NTb", "Fleming", 1)] - [TestCase("Holmes.Makes.It.Right.S02.720p.HDTV.AAC5.1.x265-NOGRP", "Holmes Makes It Right", 2)] - [TestCase("My.Series.S2014.720p.HDTV.x264-ME", "My Series", 2014)] - public void should_parse_full_season_release(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(); - } - - [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) - { - var result = Parser.Parser.ParseTitle(postTitle); - - result.Should().BeNull(); - } - - [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) - { - var result = Parser.Parser.ParseTitle(postTitle); - - result.Should().BeNull(); - } - } -} diff --git a/src/NzbDrone.Core.Test/ParserTests/SeriesTitleInfoFixture.cs b/src/NzbDrone.Core.Test/ParserTests/SeriesTitleInfoFixture.cs deleted file mode 100644 index 0809aae05..000000000 --- a/src/NzbDrone.Core.Test/ParserTests/SeriesTitleInfoFixture.cs +++ /dev/null @@ -1,60 +0,0 @@ -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.ParserTests -{ - [TestFixture] - public class SeriesTitleInfoFixture : CoreTest - { - [Test] - public void should_have_year_zero_when_title_doesnt_have_a_year() - { - const string title = "House.S01E01.pilot.720p.hdtv"; - - var result = Parser.Parser.ParseTitle(title).SeriesTitleInfo; - - result.Year.Should().Be(0); - } - - [Test] - public void should_have_same_title_for_title_and_title_without_year_when_title_doesnt_have_a_year() - { - const string title = "House.S01E01.pilot.720p.hdtv"; - - var result = Parser.Parser.ParseTitle(title).SeriesTitleInfo; - - result.Title.Should().Be(result.TitleWithoutYear); - } - - [Test] - public void should_have_year_when_title_has_a_year() - { - const string title = "House.2004.S01E01.pilot.720p.hdtv"; - - var result = Parser.Parser.ParseTitle(title).SeriesTitleInfo; - - result.Year.Should().Be(2004); - } - - [Test] - public void should_have_year_in_title_when_title_has_a_year() - { - const string title = "House.2004.S01E01.pilot.720p.hdtv"; - - var result = Parser.Parser.ParseTitle(title).SeriesTitleInfo; - - result.Title.Should().Be("House 2004"); - } - - [Test] - public void should_title_without_year_should_not_contain_year() - { - const string title = "House.2004.S01E01.pilot.720p.hdtv"; - - var result = Parser.Parser.ParseTitle(title).SeriesTitleInfo; - - result.TitleWithoutYear.Should().Be("House"); - } - } -} diff --git a/src/NzbDrone.Core.Test/ParserTests/SingleEpisodeParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/SingleEpisodeParserFixture.cs deleted file mode 100644 index 05cdaa6eb..000000000 --- a/src/NzbDrone.Core.Test/ParserTests/SingleEpisodeParserFixture.cs +++ /dev/null @@ -1,143 +0,0 @@ -using System.Linq; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.ParserTests -{ - - [TestFixture] - public class SingleEpisodeParserFixture : CoreTest - { - [TestCase("Sonny.With.a.Chance.S02E15", "Sonny With a Chance", 2, 15)] - [TestCase("Two.and.a.Half.Me.103.720p.HDTV.X264-DIMENSION", "Two and a Half Me", 1, 3)] - [TestCase("Two.and.a.Half.Me.113.720p.HDTV.X264-DIMENSION", "Two and a Half Me", 1, 13)] - [TestCase("Two.and.a.Half.Me.1013.720p.HDTV.X264-DIMENSION", "Two and a Half Me", 10, 13)] - [TestCase("Chuck.4x05.HDTV.XviD-LOL", "Chuck", 4, 5)] - [TestCase("The.Girls.Next.Door.S03E06.DVDRip.XviD-WiDE", "The Girls Next Door", 3, 6)] - [TestCase("Degrassi.S10E27.WS.DSR.XviD-2HD", "Degrassi", 10, 27)] - [TestCase("Parenthood.2010.S02E14.HDTV.XviD-LOL", "Parenthood 2010", 2, 14)] - [TestCase("Hawaii Five 0 S01E19 720p WEB DL DD5 1 H 264 NT", "Hawaii Five 0", 1, 19)] - [TestCase("The Event S01E14 A Message Back 720p WEB DL DD5 1 H264 SURFER", "The Event", 1, 14)] - [TestCase("Adam Hills In Gordon St Tonight S01E07 WS PDTV XviD FUtV", "Adam Hills In Gordon St Tonight", 1, 7)] - [TestCase("Adventure.Inc.S03E19.DVDRip.XviD-OSiTV", "Adventure Inc", 3, 19)] - [TestCase("S03E09 WS PDTV XviD FUtV", "", 3, 9)] - [TestCase("5x10 WS PDTV XviD FUtV", "", 5, 10)] - [TestCase("Castle.2009.S01E14.HDTV.XviD-LOL", "Castle 2009", 1, 14)] - [TestCase("Pride.and.Prejudice.1995.S03E20.HDTV.XviD-LOL", "Pride and Prejudice 1995", 3, 20)] - [TestCase("The.Office.S03E115.DVDRip.XviD-OSiTV", "The Office", 3, 115)] - [TestCase(@"Parks and Recreation - S02E21 - 94 Meetings - 720p TV.mkv", "Parks and Recreation", 2, 21)] - [TestCase(@"24-7 Penguins-Capitals- Road to the NHL Winter Classic - S01E03 - Episode 3.mkv", "24-7 Penguins-Capitals- Road to the NHL Winter Classic", 1, 3)] - [TestCase("Adventure.Inc.S03E19.DVDRip.\"XviD\"-OSiTV", "Adventure Inc", 3, 19)] - [TestCase("Hawaii Five-0 (2010) - 1x05 - Nalowale (Forgotten/Missing)", "Hawaii Five-0 (2010)", 1, 5)] - [TestCase("Hawaii Five-0 (2010) - 1x05 - Title", "Hawaii Five-0 (2010)", 1, 5)] - [TestCase("House - S06E13 - 5 to 9 [DVD]", "House", 6, 13)] - [TestCase("The Mentalist - S02E21 - 18-5-4", "The Mentalist", 2, 21)] - [TestCase("Breaking.In.S01E07.21.0.Jump.Street.720p.WEB-DL.DD5.1.h.264-KiNGS", "Breaking In", 1, 7)] - [TestCase("CSI.525", "CSI", 5, 25)] - [TestCase("King of the Hill - 10x12 - 24 Hour Propane People [SDTV]", "King of the Hill", 10, 12)] - [TestCase("Brew Masters S01E06 3 Beers For Batali DVDRip XviD SPRiNTER", "Brew Masters", 1, 6)] - [TestCase("24 7 Flyers Rangers Road to the NHL Winter Classic Part01 720p HDTV x264 ORENJI", "24 7 Flyers Rangers Road to the NHL Winter Classic", 1, 1)] - [TestCase("24 7 Flyers Rangers Road to the NHL Winter Classic Part 02 720p HDTV x264 ORENJI", "24 7 Flyers Rangers Road to the NHL Winter Classic", 1, 2)] - [TestCase("24-7 Flyers-Rangers- Road to the NHL Winter Classic - S01E01 - Part 1", "24-7 Flyers-Rangers- Road to the NHL Winter Classic", 1, 1)] - [TestCase("S6E02-Unwrapped-(Playing With Food) - [DarkData]", "", 6, 2)] - [TestCase("S06E03-Unwrapped-(Number Ones Unwrapped) - [DarkData]", "", 6, 3)] - [TestCase("The Mentalist S02E21 18 5 4 720p WEB DL DD5 1 h 264 EbP", "The Mentalist", 2, 21)] - [TestCase("01x04 - Halloween, Part 1 - 720p WEB-DL", "", 1, 4)] - [TestCase("extras.s03.e05.ws.dvdrip.xvid-m00tv", "extras", 3, 5)] - [TestCase("castle.2009.416.hdtv-lol", "castle 2009", 4, 16)] - [TestCase("hawaii.five-0.2010.217.hdtv-lol", "hawaii five-0 2010", 2, 17)] - [TestCase("Looney Tunes - S1936E18 - I Love to Singa", "Looney Tunes", 1936, 18)] - [TestCase("American_Dad!_-_7x6_-_The_Scarlett_Getter_[SDTV]", "American Dad!", 7, 6)] - [TestCase("Falling_Skies_-_1x1_-_Live_and_Learn_[HDTV-720p]", "Falling Skies", 1, 1)] - [TestCase("Top Gear - 07x03 - 2005.11.70", "Top Gear", 7, 3)] - [TestCase("Glee.S04E09.Swan.Song.1080p.WEB-DL.DD5.1.H.264-ECI", "Glee", 4, 9)] - [TestCase("S08E20 50-50 Carla [DVD]", "", 8, 20)] - [TestCase("Cheers S08E20 50-50 Carla [DVD]", "Cheers", 8, 20)] - [TestCase("S02E10 6-50 to SLC [SDTV]", "", 2, 10)] - [TestCase("Franklin & Bash S02E10 6-50 to SLC [SDTV]", "Franklin & Bash", 2, 10)] - [TestCase("The_Big_Bang_Theory_-_6x12_-_The_Egg_Salad_Equivalency_[HDTV-720p]", "The Big Bang Theory", 6, 12)] - [TestCase("Top_Gear.19x06.720p_HDTV_x264-FoV", "Top Gear", 19, 6)] - [TestCase("Portlandia.S03E10.Alexandra.720p.WEB-DL.AAC2.0.H.264-CROM.mkv", "Portlandia", 3, 10)] - [TestCase("(Game of Thrones s03 e - \"Game of Thrones Season 3 Episode 10\"", "Game of Thrones", 3, 10)] - [TestCase("House.Hunters.International.S05E607.720p.hdtv.x264", "House Hunters International", 5, 607)] - [TestCase("Adventure.Time.With.Finn.And.Jake.S01E20.720p.BluRay.x264-DEiMOS", "Adventure Time With Finn And Jake", 1, 20)] - [TestCase("Hostages.S01E04.2-45.PM.[HDTV-720p].mkv", "Hostages", 1, 4)] - [TestCase("S01E04", "", 1, 4)] - [TestCase("1x04", "", 1, 4)] - [TestCase("10.Things.You.Dont.Know.About.S02E04.Prohibition.HDTV.XviD-AFG", "10 Things You Dont Know About", 2, 4)] - [TestCase("30 Rock - S01E01 - Pilot.avi", "30 Rock", 1, 1)] - [TestCase("666 Park Avenue - S01E01", "666 Park Avenue", 1, 1)] - [TestCase("Warehouse 13 - S01E01", "Warehouse 13", 1, 1)] - [TestCase("Don't Trust The B---- in Apartment 23.S01E01", "Don't Trust The B---- in Apartment 23", 1, 1)] - [TestCase("Warehouse.13.S01E01", "Warehouse 13", 1, 1)] - [TestCase("Dont.Trust.The.B----.in.Apartment.23.S01E01", "Dont Trust The B---- in Apartment 23", 1, 1)] - [TestCase("24 S01E01", "24", 1, 1)] - [TestCase("24.S01E01", "24", 1, 1)] - [TestCase("Homeland - 2x12 - The Choice [HDTV-1080p].mkv", "Homeland", 2, 12)] - [TestCase("Homeland - 2x4 - New Car Smell [HDTV-1080p].mkv", "Homeland", 2, 4)] - [TestCase("Top Gear - 06x11 - 2005.08.07", "Top Gear", 6, 11)] - [TestCase("The_Voice_US_s06e19_04.28.2014_hdtv.x264.Poke.mp4", "The Voice US", 6, 19)] - [TestCase("the.100.110.hdtv-lol", "the 100", 1, 10)] - [TestCase("2009x09 [SDTV].avi", "", 2009, 9)] - [TestCase("S2009E09 [SDTV].avi", "", 2009, 9)] - [TestCase("Shark Week S2009E09 [SDTV].avi", "Shark Week", 2009, 9)] - [TestCase("St_Elsewhere_209_Aids_And_Comfort", "St Elsewhere", 2, 9)] - [TestCase("[Impatience] Locodol - 0x01 [720p][34073169].mkv", "Locodol", 0, 1)] - [TestCase("South.Park.S15.E06.City.Sushi", "South Park", 15, 6)] - [TestCase("South Park - S15 E06 - City Sushi", "South Park", 15, 6)] - [TestCase("Constantine S1-E1-WEB-DL-1080p-NZBgeek", "Constantine", 1, 1)] - [TestCase("Constantine S1E1-WEB-DL-1080p-NZBgeek", "Constantine", 1, 1)] - [TestCase("NCIS.S010E16.720p.HDTV.X264-DIMENSION", "NCIS", 10, 16)] - [TestCase("[ www.Torrenting.com ] - Revolution.2012.S02E17.720p.HDTV.X264-DIMENSION", "Revolution 2012", 2, 17)] - [TestCase("Revolution.2012.S02E18.720p.HDTV.X264-DIMENSION.mkv", "Revolution 2012", 2, 18)] - [TestCase("Series - Season 1 - Episode 01 (Resolution).avi", "Series", 1, 1)] - [TestCase("5x09 - 100 [720p WEB-DL].mkv", "", 5, 9)] - [TestCase("1x03 - 274 [1080p BluRay].mkv", "", 1, 3)] - [TestCase("1x03 - The 112th Congress [1080p BluRay].mkv", "", 1, 3)] - [TestCase("Revolution.2012.S02E14.720p.HDTV.X264-DIMENSION [PublicHD].mkv", "Revolution 2012", 2, 14)] - //[TestCase("Sex And The City S6E15 - Catch-38 [RavyDavy].avi", "Sex And The City", 6, 15)] // -38 is getting treated as abs number - [TestCase("Castle.2009.S06E03.720p.HDTV.X264-DIMENSION [PublicHD].mkv", "Castle 2009", 6, 3)] - [TestCase("19-2.2014.S02E01.720p.HDTV.x264-CROOKS", "19-2 2014", 2, 1)] - [TestCase("Community - S01E09 - Debate 109", "Community", 1, 9)] - [TestCase("Entourage - S02E02 - My Maserati Does 185", "Entourage", 2, 2)] - [TestCase("6x13 - The Family Guy 100th Episode Special", "", 6, 13)] - //[TestCase("Heroes - S01E01 - Genesis 101 [HDTV-720p]", "Heroes", 1, 1)] - //[TestCase("The 100 S02E01 HDTV x264-KILLERS [eztv]", "The 100", 2, 1)] - [TestCase("The Young And The Restless - S41 E10478 - 2014-08-15", "The Young And The Restless", 41, 10478)] - [TestCase("The Young And The Restless - S42 E10591 - 2015-01-27", "The Young And The Restless", 42, 10591)] - [TestCase("Series Title [1x05] Episode Title", "Series Title", 1, 5)] - [TestCase("Series Title [S01E05] Episode Title", "Series Title", 1, 5)] - [TestCase("Series Title Season 01 Episode 05 720p", "Series Title", 1, 5)] - //[TestCase("Off the Air - 101 - Animals (460p.x264.vorbis-2.0) [449].mkv", "Off the Air", 1, 1)] - [TestCase("The Young And the Restless - S42 E10713 - 2015-07-20.mp4", "The Young And the Restless", 42, 10713)] - [TestCase("quantico.103.hdtv-lol[ettv].mp4", "quantico", 1, 3)] - [TestCase("Fargo - 01x02 - The Rooster Prince - [itz_theo]", "Fargo", 1, 2)] - [TestCase("Castle (2009) - [06x16] - Room 147.mp4", "Castle (2009)", 6, 16)] - [TestCase("grp-zoos01e11-1080p", "grp-zoo", 1, 11)] - [TestCase("grp-zoo-s01e11-1080p", "grp-zoo", 1, 11)] - [TestCase("Jeopardy!.S2016E14.2016-01-20.avi", "Jeopardy!", 2016, 14)] - [TestCase("Ken.Burns.The.Civil.War.5of9.The.Universe.Of.Battle.1990.DVDRip.x264-HANDJOB", "Ken Burns The Civil War", 1, 5)] - [TestCase("Judge Judy 2016 02 25 S20E142", "Judge Judy", 20, 142)] - [TestCase("Judge Judy 2016 02 25 S20E143", "Judge Judy", 20, 143)] - [TestCase("Red Dwarf - S02 - E06 - Parallel Universe", "Red Dwarf", 2, 6)] - [TestCase("O.J.Simpson.Made.in.America.Part.Two.720p.HDTV.x264-2HD", "O J Simpson Made in America", 1, 2)] - [TestCase("The.100000.Dollar.Pyramid.2016.S01E05.720p.HDTV.x264-W4F", "The 100000 Dollar Pyramid 2016", 1, 5)] - [TestCase("Class S01E02 (22 October 2016) HDTV 720p [Webrip]", "Class", 1, 2)] - [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("", "", 0, 0)] - public void should_parse_single_episode(string postTitle, string title, int seasonNumber, int episodeNumber) - { - var result = Parser.Parser.ParseTitle(postTitle); - result.Should().NotBeNull(); - result.EpisodeNumbers.Should().HaveCount(1); - result.SeasonNumber.Should().Be(seasonNumber); - result.EpisodeNumbers.First().Should().Be(episodeNumber); - result.SeriesTitle.Should().Be(title); - result.AbsoluteEpisodeNumbers.Should().BeEmpty(); - result.FullSeason.Should().BeFalse(); - } - } -} diff --git a/src/NzbDrone.Core.Test/Profiles/Delay/DelayProfileServiceFixture.cs b/src/NzbDrone.Core.Test/Profiles/Delay/DelayProfileServiceFixture.cs new file mode 100644 index 000000000..7fb8cbd6d --- /dev/null +++ b/src/NzbDrone.Core.Test/Profiles/Delay/DelayProfileServiceFixture.cs @@ -0,0 +1,112 @@ +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Profiles.Delay; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Profiles.Delay +{ + [TestFixture] + public class DelayProfileServiceFixture : CoreTest + { + private List _delayProfiles; + private DelayProfile _first; + private DelayProfile _last; + + [SetUp] + public void Setup() + { + _delayProfiles = Builder.CreateListOfSize(4) + .TheFirst(1) + .With(d => d.Order = int.MaxValue) + .TheNext(1) + .With(d => d.Order = 1) + .TheNext(1) + .With(d => d.Order = 2) + .TheNext(1) + .With(d => d.Order = 3) + .Build() + .ToList(); + + _first = _delayProfiles[1]; + _last = _delayProfiles.Last(); + + Mocker.GetMock() + .Setup(s => s.All()) + .Returns(_delayProfiles); + } + + [Test] + public void should_move_to_first_if_afterId_is_null() + { + var moving = _last; + var result = Subject.Reorder(moving.Id, null).OrderBy(d => d.Order).ToList(); + var moved = result.First(); + + moved.Id.Should().Be(moving.Id); + moved.Order.Should().Be(1); + } + + [Test] + public void should_move_after_if_afterId_is_not_null() + { + var after = _first; + var moving = _last; + var result = Subject.Reorder(moving.Id, _first.Id).OrderBy(d => d.Order).ToList(); + var moved = result[1]; + + moved.Id.Should().Be(moving.Id); + moved.Order.Should().Be(after.Order + 1); + } + + [Test] + public void should_reorder_delay_profiles_that_are_after_moved() + { + var moving = _last; + var result = Subject.Reorder(moving.Id, null).OrderBy(d => d.Order).ToList(); + + for (int i = 1; i < result.Count; i++) + { + var delayProfile = result[i]; + + if (delayProfile.Id == 1) + { + delayProfile.Order.Should().Be(int.MaxValue); + } + + else + { + delayProfile.Order.Should().Be(i + 1); + } + } + } + + [Test] + public void should_not_change_afters_order_if_moving_was_after() + { + var after = _first; + var afterOrder = after.Order; + var moving = _last; + var result = Subject.Reorder(moving.Id, _first.Id).OrderBy(d => d.Order).ToList(); + var afterMove = result.First(); + + afterMove.Id.Should().Be(after.Id); + afterMove.Order.Should().Be(afterOrder); + } + + [Test] + public void should_change_afters_order_if_moving_was_before() + { + var after = _last; + var afterOrder = after.Order; + var moving = _first; + + var result = Subject.Reorder(moving.Id, after.Id).OrderBy(d => d.Order).ToList(); + var afterMove = result.Single(d => d.Id == after.Id); + + afterMove.Order.Should().BeLessThan(afterOrder); + } + } +} diff --git a/src/NzbDrone.Core.Test/Profiles/Metadata/MetadataProfileRepositoryFixture.cs b/src/NzbDrone.Core.Test/Profiles/Metadata/MetadataProfileRepositoryFixture.cs new file mode 100644 index 000000000..e5fd56ed9 --- /dev/null +++ b/src/NzbDrone.Core.Test/Profiles/Metadata/MetadataProfileRepositoryFixture.cs @@ -0,0 +1,48 @@ +using FluentAssertions; +using System.Linq; +using NUnit.Framework; +using NzbDrone.Core.Music; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Profiles.Metadata; + +namespace NzbDrone.Core.Test.Profiles.Metadata +{ + [TestFixture] + public class MetadataProfileRepositoryFixture : DbTest + { + [Test] + public void should_be_able_to_read_and_write() + { + var profile = new MetadataProfile + { + PrimaryAlbumTypes = PrimaryAlbumType.All.OrderByDescending(l => l.Name).Select(l => new ProfilePrimaryAlbumTypeItem + { + PrimaryAlbumType = l, + Allowed = l == PrimaryAlbumType.Album + }).ToList(), + + SecondaryAlbumTypes = SecondaryAlbumType.All.OrderByDescending(l => l.Name).Select(l => new ProfileSecondaryAlbumTypeItem + { + SecondaryAlbumType = l, + Allowed = l == SecondaryAlbumType.Studio + }).ToList(), + + ReleaseStatuses = ReleaseStatus.All.OrderByDescending(l => l.Name).Select(l => new ProfileReleaseStatusItem + { + ReleaseStatus = l, + Allowed = l == ReleaseStatus.Official + }).ToList(), + + Name = "TestProfile" + }; + + Subject.Insert(profile); + + StoredModel.Name.Should().Be(profile.Name); + + StoredModel.PrimaryAlbumTypes.Should().Equal(profile.PrimaryAlbumTypes, (a, b) => a.PrimaryAlbumType == b.PrimaryAlbumType && a.Allowed == b.Allowed); + StoredModel.SecondaryAlbumTypes.Should().Equal(profile.SecondaryAlbumTypes, (a, b) => a.SecondaryAlbumType == b.SecondaryAlbumType && a.Allowed == b.Allowed); + StoredModel.ReleaseStatuses.Should().Equal(profile.ReleaseStatuses, (a, b) => a.ReleaseStatus == b.ReleaseStatus && a.Allowed == b.Allowed); + } + } +} diff --git a/src/NzbDrone.Core.Test/Profiles/Metadata/MetadataProfileServiceFixture.cs b/src/NzbDrone.Core.Test/Profiles/Metadata/MetadataProfileServiceFixture.cs new file mode 100644 index 000000000..729b68c34 --- /dev/null +++ b/src/NzbDrone.Core.Test/Profiles/Metadata/MetadataProfileServiceFixture.cs @@ -0,0 +1,119 @@ +using System.Linq; +using FizzWare.NBuilder; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.ImportLists; +using NzbDrone.Core.ImportLists.HeadphonesImport; +using NzbDrone.Core.Lifecycle; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; +using NzbDrone.Core.Profiles.Metadata; + +namespace NzbDrone.Core.Test.Profiles.Metadata +{ + [TestFixture] + + public class MetadataProfileServiceFixture : CoreTest + { + [Test] + public void init_should_add_default_profiles() + { + Subject.Handle(new ApplicationStartedEvent()); + + Mocker.GetMock() + .Verify(v => v.Insert(It.IsAny()), Times.Once()); + } + + [Test] + //This confirms that new profiles are added only if no other profiles exists. + //We don't want to keep adding them back if a user deleted them on purpose. + public void Init_should_skip_if_any_profiles_already_exist() + { + Mocker.GetMock() + .Setup(s => s.All()) + .Returns(Builder.CreateListOfSize(2).Build().ToList()); + + Subject.Handle(new ApplicationStartedEvent()); + + Mocker.GetMock() + .Verify(v => v.Insert(It.IsAny()), Times.Never()); + } + + + [Test] + public void should_not_be_able_to_delete_profile_if_assigned_to_artist() + { + var profile = Builder.CreateNew() + .With(p => p.Id = 2) + .Build(); + + var artistList = Builder.CreateListOfSize(3) + .Random(1) + .With(c => c.MetadataProfileId = profile.Id) + .Build().ToList(); + + var importLists = Builder.CreateListOfSize(2) + .All() + .With(c => c.MetadataProfileId = 1) + .Build().ToList(); + + Mocker.GetMock().Setup(c => c.GetAllArtists()).Returns(artistList); + Mocker.GetMock().Setup(c => c.All()).Returns(importLists); + Mocker.GetMock().Setup(c => c.Get(profile.Id)).Returns(profile); + + Assert.Throws(() => Subject.Delete(profile.Id)); + + Mocker.GetMock().Verify(c => c.Delete(It.IsAny()), Times.Never()); + + } + + [Test] + public void should_not_be_able_to_delete_profile_if_assigned_to_import_list() + { + var profile = Builder.CreateNew() + .With(p => p.Id = 2) + .Build(); + + var artistList = Builder.CreateListOfSize(3) + .All() + .With(c => c.MetadataProfileId = 1) + .Build().ToList(); + + var importLists = Builder.CreateListOfSize(2) + .Random(1) + .With(c => c.MetadataProfileId = profile.Id) + .Build().ToList(); + + Mocker.GetMock().Setup(c => c.GetAllArtists()).Returns(artistList); + Mocker.GetMock().Setup(c => c.All()).Returns(importLists); + Mocker.GetMock().Setup(c => c.Get(profile.Id)).Returns(profile); + + Assert.Throws(() => Subject.Delete(profile.Id)); + + Mocker.GetMock().Verify(c => c.Delete(It.IsAny()), Times.Never()); + + } + + + [Test] + public void should_delete_profile_if_not_assigned_to_artist_or_import_list() + { + var artistList = Builder.CreateListOfSize(3) + .All() + .With(c => c.MetadataProfileId = 2) + .Build().ToList(); + + var importLists = Builder.CreateListOfSize(2) + .All() + .With(c => c.MetadataProfileId = 2) + .Build().ToList(); + + Mocker.GetMock().Setup(c => c.GetAllArtists()).Returns(artistList); + Mocker.GetMock().Setup(c => c.All()).Returns(importLists); + + Subject.Delete(1); + + Mocker.GetMock().Verify(c => c.Delete(1), Times.Once()); + } + } +} diff --git a/src/NzbDrone.Core.Test/Profiles/ProfileRepositoryFixture.cs b/src/NzbDrone.Core.Test/Profiles/ProfileRepositoryFixture.cs index 3c9003da7..7ad4f2a83 100644 --- a/src/NzbDrone.Core.Test/Profiles/ProfileRepositoryFixture.cs +++ b/src/NzbDrone.Core.Test/Profiles/ProfileRepositoryFixture.cs @@ -1,21 +1,21 @@ using FluentAssertions; using NUnit.Framework; -using NzbDrone.Core.Profiles; +using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Qualities; using NzbDrone.Core.Test.Framework; namespace NzbDrone.Core.Test.Profiles { [TestFixture] - public class ProfileRepositoryFixture : DbTest + public class ProfileRepositoryFixture : DbTest { [Test] public void should_be_able_to_read_and_write() { - var profile = new Profile + var profile = new QualityProfile { - Items = Qualities.QualityFixture.GetDefaultQualities(Quality.Bluray1080p, Quality.DVD, Quality.HDTV720p), - Cutoff = Quality.Bluray1080p, + Items = Qualities.QualityFixture.GetDefaultQualities(Quality.MP3_320, Quality.MP3_192, Quality.MP3_256), + Cutoff = Quality.MP3_320.Id, Name = "TestProfile" }; @@ -29,4 +29,4 @@ namespace NzbDrone.Core.Test.Profiles } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core.Test/Profiles/ProfileServiceFixture.cs b/src/NzbDrone.Core.Test/Profiles/ProfileServiceFixture.cs index 4f799fa7d..d7edea846 100644 --- a/src/NzbDrone.Core.Test/Profiles/ProfileServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Profiles/ProfileServiceFixture.cs @@ -2,16 +2,17 @@ using System.Linq; using FizzWare.NBuilder; using Moq; using NUnit.Framework; +using NzbDrone.Core.ImportLists; using NzbDrone.Core.Lifecycle; -using NzbDrone.Core.Profiles; +using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Test.Profiles { [TestFixture] - public class ProfileServiceFixture : CoreTest + public class ProfileServiceFixture : CoreTest { [Test] public void init_should_add_default_profiles() @@ -19,7 +20,7 @@ namespace NzbDrone.Core.Test.Profiles Subject.Handle(new ApplicationStartedEvent()); Mocker.GetMock() - .Verify(v => v.Insert(It.IsAny()), Times.Exactly(6)); + .Verify(v => v.Insert(It.IsAny()), Times.Exactly(3)); } [Test] @@ -29,47 +30,88 @@ namespace NzbDrone.Core.Test.Profiles { Mocker.GetMock() .Setup(s => s.All()) - .Returns(Builder.CreateListOfSize(2).Build().ToList()); + .Returns(Builder.CreateListOfSize(2).Build().ToList()); Subject.Handle(new ApplicationStartedEvent()); Mocker.GetMock() - .Verify(v => v.Insert(It.IsAny()), Times.Never()); + .Verify(v => v.Insert(It.IsAny()), Times.Never()); } [Test] - public void should_not_be_able_to_delete_profile_if_assigned_to_series() + public void should_not_be_able_to_delete_profile_if_assigned_to_artist() { - var seriesList = Builder.CreateListOfSize(3) + var profile = Builder.CreateNew() + .With(p => p.Id = 2) + .Build(); + + var artistList = Builder.CreateListOfSize(3) .Random(1) - .With(c => c.ProfileId = 2) + .With(c => c.QualityProfileId = profile.Id) .Build().ToList(); + var importLists = Builder.CreateListOfSize(2) + .All() + .With(c => c.ProfileId = 1) + .Build().ToList(); - Mocker.GetMock().Setup(c => c.GetAllSeries()).Returns(seriesList); + Mocker.GetMock().Setup(c => c.GetAllArtists()).Returns(artistList); + Mocker.GetMock().Setup(c => c.All()).Returns(importLists); + 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()); } + [Test] + public void should_not_be_able_to_delete_profile_if_assigned_to_import_list() + { + var profile = Builder.CreateNew() + .With(p => p.Id = 2) + .Build(); + + var artistList = Builder.CreateListOfSize(3) + .All() + .With(c => c.QualityProfileId = 1) + .Build().ToList(); + + var importLists = Builder.CreateListOfSize(2) + .Random(1) + .With(c => c.ProfileId = profile.Id) + .Build().ToList(); + + Mocker.GetMock().Setup(c => c.GetAllArtists()).Returns(artistList); + Mocker.GetMock().Setup(c => c.All()).Returns(importLists); + Mocker.GetMock().Setup(c => c.Get(profile.Id)).Returns(profile); + + Assert.Throws(() => Subject.Delete(profile.Id)); + + Mocker.GetMock().Verify(c => c.Delete(It.IsAny()), Times.Never()); + + } [Test] - public void should_delete_profile_if_not_assigned_to_series() + public void should_delete_profile_if_not_assigned_to_artist_or_import_list() { - var seriesList = Builder.CreateListOfSize(3) + var artistList = Builder.CreateListOfSize(3) .All() - .With(c => c.ProfileId = 2) + .With(c => c.QualityProfileId = 2) .Build().ToList(); + var importLists = Builder.CreateListOfSize(2) + .All() + .With(c => c.ProfileId = 2) + .Build().ToList(); - Mocker.GetMock().Setup(c => c.GetAllSeries()).Returns(seriesList); + Mocker.GetMock().Setup(c => c.GetAllArtists()).Returns(artistList); + Mocker.GetMock().Setup(c => c.All()).Returns(importLists); Subject.Delete(1); Mocker.GetMock().Verify(c => c.Delete(1), Times.Once()); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core.Test/Profiles/Qualities/QualityIndexCompareToFixture.cs b/src/NzbDrone.Core.Test/Profiles/Qualities/QualityIndexCompareToFixture.cs new file mode 100644 index 000000000..b11c046d5 --- /dev/null +++ b/src/NzbDrone.Core.Test/Profiles/Qualities/QualityIndexCompareToFixture.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Profiles.Qualities; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Qualities +{ + [TestFixture] + public class QualityIndexCompareToFixture : CoreTest + { + [TestCase(1, 0, 1, 0, 0)] + [TestCase(1, 1, 1, 0, 1)] + [TestCase(2, 0, 1, 0, 1)] + [TestCase(1, 0, 1, 1, -1)] + [TestCase(1, 0, 2, 0, -1)] + public void should_match_expected_when_respect_group_order_is_true(int leftIndex, int leftGroupIndex, int rightIndex, int rightGroupIndex, int expected) + { + var left = new QualityIndex(leftIndex, leftGroupIndex); + var right = new QualityIndex(rightIndex, rightGroupIndex); + left.CompareTo(right, true).Should().Be(expected); + } + + [TestCase(1, 0, 1, 0, 0)] + [TestCase(1, 1, 1, 0, 0)] + [TestCase(2, 0, 1, 0, 1)] + [TestCase(1, 0, 1, 1, 0)] + [TestCase(1, 0, 2, 0, -1)] + public void should_match_expected_when_respect_group_order_is_false(int leftIndex, int leftGroupIndex, int rightIndex, int rightGroupIndex, int expected) + { + var left = new QualityIndex(leftIndex, leftGroupIndex); + var right = new QualityIndex(rightIndex, rightGroupIndex); + left.CompareTo(right, false).Should().Be(expected); + } + } +} diff --git a/src/NzbDrone.Core.Test/Profiles/Releases/PreferredWordService/CalculateFixture.cs b/src/NzbDrone.Core.Test/Profiles/Releases/PreferredWordService/CalculateFixture.cs new file mode 100644 index 000000000..5c7693bae --- /dev/null +++ b/src/NzbDrone.Core.Test/Profiles/Releases/PreferredWordService/CalculateFixture.cs @@ -0,0 +1,95 @@ +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Profiles.Releases; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Test.Profiles.Releases.PreferredWordService +{ + [TestFixture] + public class CalculateFixture : CoreTest + { + private Artist _artist = null; + private List _releaseProfiles = null; + private string _title = "Artist.Name-Album.Title.2018.FLAC.24bit-Lidarr"; + + [SetUp] + public void Setup() + { + _artist = Builder.CreateNew() + .With(s => s.Tags = new HashSet(new[] {1, 2})) + .Build(); + + _releaseProfiles = new List(); + + _releaseProfiles.Add(new ReleaseProfile + { + Preferred = new List> + { + new KeyValuePair("24bit", 5), + new KeyValuePair("16bit", -10) + } + }); + + Mocker.GetMock() + .Setup(s => s.AllForTags(It.IsAny>())) + .Returns(_releaseProfiles); + } + + + private void GivenMatchingTerms(params string[] terms) + { + Mocker.GetMock() + .Setup(s => s.IsMatch(It.IsAny(), _title)) + .Returns((term, title) => terms.Contains(term)); + } + + [Test] + public void should_return_0_when_there_are_no_release_profiles() + { + Mocker.GetMock() + .Setup(s => s.AllForTags(It.IsAny>())) + .Returns(new List()); + + Subject.Calculate(_artist, _title).Should().Be(0); + } + + [Test] + public void should_return_0_when_there_are_no_matching_preferred_words() + { + GivenMatchingTerms(); + + Subject.Calculate(_artist, _title).Should().Be(0); + } + + [Test] + public void should_calculate_positive_score() + { + GivenMatchingTerms("24bit"); + + Subject.Calculate(_artist, _title).Should().Be(5); + } + + [Test] + public void should_calculate_negative_score() + { + GivenMatchingTerms("16bit"); + + Subject.Calculate(_artist, _title).Should().Be(-10); + } + + [Test] + public void should_calculate_using_multiple_profiles() + { + _releaseProfiles.Add(_releaseProfiles.First()); + + GivenMatchingTerms("24bit"); + + Subject.Calculate(_artist, _title).Should().Be(10); + } + } +} diff --git a/src/NzbDrone.Core.Test/Profiles/Releases/PreferredWordService/GetMatchingPreferredWordsFixture.cs b/src/NzbDrone.Core.Test/Profiles/Releases/PreferredWordService/GetMatchingPreferredWordsFixture.cs new file mode 100644 index 000000000..d261bf18a --- /dev/null +++ b/src/NzbDrone.Core.Test/Profiles/Releases/PreferredWordService/GetMatchingPreferredWordsFixture.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Profiles.Releases; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Test.Profiles.Releases.PreferredWordService +{ + [TestFixture] + public class GetMatchingPreferredWordsFixture : CoreTest + { + private Artist _artist = null; + private List _releaseProfiles = null; + private string _title = "Artist.Name-Album.Name-2018-Flac-Vinyl-Lidarr"; + + [SetUp] + public void Setup() + { + _artist = Builder.CreateNew() + .With(s => s.Tags = new HashSet(new[] { 1, 2 })) + .Build(); + + _releaseProfiles = new List(); + + _releaseProfiles.Add(new ReleaseProfile + { + Preferred = new List> + { + new KeyValuePair("Vinyl", 5), + new KeyValuePair("CD", -10) + } + }); + + + Mocker.GetMock() + .Setup(s => s.MatchingTerm(It.IsAny(), _title)) + .Returns((term, title) => title.Contains(term) ? term : null); + } + + + private void GivenReleaseProfile() + { + Mocker.GetMock() + .Setup(s => s.AllForTags(It.IsAny>())) + .Returns(_releaseProfiles); + } + + [Test] + public void should_return_empty_list_when_there_are_no_release_profiles() + { + Mocker.GetMock() + .Setup(s => s.AllForTags(It.IsAny>())) + .Returns(new List()); + + Subject.GetMatchingPreferredWords(_artist, _title).Should().BeEmpty(); + } + + [Test] + public void should_return_empty_list_when_there_are_no_matching_preferred_words() + { + _releaseProfiles.First().Preferred.RemoveAt(0); + GivenReleaseProfile(); + + Subject.GetMatchingPreferredWords(_artist, _title).Should().BeEmpty(); + } + + [Test] + public void should_return_list_of_matching_terms() + { + GivenReleaseProfile(); + + Subject.GetMatchingPreferredWords(_artist, _title).Should().Contain(new[] { "Vinyl" }); + } + } +} diff --git a/src/NzbDrone.Core.Test/Properties/AssemblyInfo.cs b/src/NzbDrone.Core.Test/Properties/AssemblyInfo.cs deleted file mode 100644 index fca9cdaa2..000000000 --- a/src/NzbDrone.Core.Test/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. - -[assembly: AssemblyTitle("NzbDrone.Core.Test")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("Microsoft")] -[assembly: AssemblyProduct("NzbDrone.Core.Test")] -[assembly: AssemblyCopyright("Copyright © Microsoft 2010")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. - -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM - -[assembly: Guid("699aed1b-015e-4f0d-9c81-d5557b05d260")] - -[assembly: AssemblyVersion("10.0.0.*")] - -[assembly: InternalsVisibleTo("NzbDrone.Core")] \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/ProviderTests/DiskScanProviderTests/GetAudioFilesFixture.cs b/src/NzbDrone.Core.Test/ProviderTests/DiskScanProviderTests/GetAudioFilesFixture.cs new file mode 100644 index 000000000..bd54f2f88 --- /dev/null +++ b/src/NzbDrone.Core.Test/ProviderTests/DiskScanProviderTests/GetAudioFilesFixture.cs @@ -0,0 +1,111 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Disk; +using NzbDrone.Test.Common; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Test.Framework; +using System.IO.Abstractions; + +namespace NzbDrone.Core.Test.ProviderTests.DiskScanProviderTests +{ + public class GetAudioFilesFixture : CoreTest + { + private string[] _fileNames; + private readonly string path = @"C:\Test\".AsOsAgnostic(); + + [SetUp] + public void Setup() + { + _fileNames = new[] + { + @"30 Rock1.mp3", + @"30 Rock2.flac", + @"30 Rock3.ogg", + @"30 Rock4.m4a", + @"30 Rock.avi", + @"movie.exe", + @"movie" + }; + + Mocker.GetMock() + .Setup(s => s.GetFileInfos(It.IsAny(), It.IsAny())) + .Returns(new List()); + } + + private IEnumerable GetFiles(string folder, string subFolder = "") + { + return _fileNames.Select(f => Path.Combine(folder, subFolder, f)); + } + + private void GivenFiles(IEnumerable files) + { + var filesToReturn = files.Select(x => (FileInfoBase)new FileInfo(x)).ToList(); + + foreach (var file in filesToReturn) + { + TestLogger.Debug(file.Name); + } + + Mocker.GetMock() + .Setup(s => s.GetFileInfos(It.IsAny(), SearchOption.AllDirectories)) + .Returns(filesToReturn); + } + + [Test] + public void should_check_all_directories() + { + Subject.GetAudioFiles(path); + + Mocker.GetMock().Verify(s => s.GetFileInfos(path, SearchOption.AllDirectories), Times.Once()); + Mocker.GetMock().Verify(s => s.GetFileInfos(path, SearchOption.TopDirectoryOnly), Times.Never()); + } + + [Test] + public void should_check_all_directories_when_allDirectories_is_true() + { + Subject.GetAudioFiles(path, true); + + Mocker.GetMock().Verify(s => s.GetFileInfos(path, SearchOption.AllDirectories), Times.Once()); + Mocker.GetMock().Verify(s => s.GetFileInfos(path, SearchOption.TopDirectoryOnly), Times.Never()); + } + + [Test] + public void should_check_top_level_directory_only_when_allDirectories_is_false() + { + Subject.GetAudioFiles(path, false); + + Mocker.GetMock().Verify(s => s.GetFileInfos(path, SearchOption.AllDirectories), Times.Never()); + Mocker.GetMock().Verify(s => s.GetFileInfos(path, SearchOption.TopDirectoryOnly), Times.Once()); + } + + [Test] + public void should_return_audio_files_only() + { + GivenFiles(GetFiles(path)); + + Subject.GetAudioFiles(path).Should().HaveCount(4); + } + + [TestCase("Extras")] + [TestCase("@eadir")] + [TestCase("extrafanart")] + [TestCase("Plex Versions")] + [TestCase(".secret")] + [TestCase(".hidden")] + [TestCase(".unwanted")] + public void should_filter_certain_sub_folders(string subFolder) + { + var files = GetFiles(path).ToList(); + var specialFiles = GetFiles(path, subFolder).ToList(); + var allFiles = files.Concat(specialFiles); + + var filteredFiles = Subject.FilterFiles(path, allFiles); + filteredFiles.Should().NotContain(specialFiles); + filteredFiles.Count.Should().BeGreaterThan(0); + } + } +} diff --git a/src/NzbDrone.Core.Test/ProviderTests/DiskScanProviderTests/GetVideoFilesFixture.cs b/src/NzbDrone.Core.Test/ProviderTests/DiskScanProviderTests/GetVideoFilesFixture.cs deleted file mode 100644 index 397314def..000000000 --- a/src/NzbDrone.Core.Test/ProviderTests/DiskScanProviderTests/GetVideoFilesFixture.cs +++ /dev/null @@ -1,111 +0,0 @@ -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.MediaFiles; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Test.ProviderTests.DiskScanProviderTests -{ - - public class GetVideoFilesFixture : CoreTest - { - private string[] _fileNames; - - [SetUp] - public void Setup() - { - _fileNames = new[] - { - @"30 Rock1.mkv", - @"30 Rock2.avi", - @"30 Rock3.MP4", - @"30 Rock4.wMv", - @"movie.exe", - @"movie" - }; - } - - private IEnumerable GetFiles(string folder, string subFolder = "") - { - return _fileNames.Select(f => Path.Combine(folder, subFolder, f)); - } - - private void GivenFiles(IEnumerable files) - { - var filesToReturn = files.ToArray(); - Mocker.GetMock() - .Setup(s => s.GetFiles(It.IsAny(), SearchOption.AllDirectories)) - .Returns(filesToReturn); - } - - [Test] - public void should_check_all_directories() - { - var path = @"C:\Test\"; - - Subject.GetVideoFiles(path); - - Mocker.GetMock().Verify(s => s.GetFiles(path, SearchOption.AllDirectories), Times.Once()); - Mocker.GetMock().Verify(s => s.GetFiles(path, SearchOption.TopDirectoryOnly), Times.Never()); - } - - [Test] - public void should_check_all_directories_when_allDirectories_is_true() - { - var path = @"C:\Test\"; - - Subject.GetVideoFiles(path, true); - - Mocker.GetMock().Verify(s => s.GetFiles(path, SearchOption.AllDirectories), Times.Once()); - Mocker.GetMock().Verify(s => s.GetFiles(path, SearchOption.TopDirectoryOnly), Times.Never()); - } - - [Test] - public void should_check_top_level_directory_only_when_allDirectories_is_false() - { - var path = @"C:\Test\"; - - Subject.GetVideoFiles(path, false); - - Mocker.GetMock().Verify(s => s.GetFiles(path, SearchOption.AllDirectories), Times.Never()); - Mocker.GetMock().Verify(s => s.GetFiles(path, SearchOption.TopDirectoryOnly), Times.Once()); - } - - [Test] - public void should_return_video_files_only() - { - var path = @"C:\Test\"; - GivenFiles(GetFiles(path)); - - Subject.GetVideoFiles(path).Should().HaveCount(4); - } - - [TestCase("Extras")] - [TestCase("@eadir")] - [TestCase("extrafanart")] - [TestCase("Plex Versions")] - [TestCase(".secret")] - [TestCase(".hidden")] - public void should_filter_certain_sub_folders(string subFolder) - { - var path = @"C:\Test\"; - var files = GetFiles(path).ToList(); - 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); - filteredFiles.Should().NotContain(specialFiles); - filteredFiles.Count.Should().BeGreaterThan(0); - } - } -} diff --git a/src/NzbDrone.Core.Test/ProviderTests/RecycleBinProviderTests/CleanupFixture.cs b/src/NzbDrone.Core.Test/ProviderTests/RecycleBinProviderTests/CleanupFixture.cs index 4080b6d05..d7bf8853f 100644 --- a/src/NzbDrone.Core.Test/ProviderTests/RecycleBinProviderTests/CleanupFixture.cs +++ b/src/NzbDrone.Core.Test/ProviderTests/RecycleBinProviderTests/CleanupFixture.cs @@ -37,6 +37,7 @@ namespace NzbDrone.Core.Test.ProviderTests.RecycleBinProviderTests public void Setup() { Mocker.GetMock().SetupGet(s => s.RecycleBin).Returns(RecycleBin); + Mocker.GetMock().SetupGet(s => s.RecycleBinCleanupDays).Returns(7); Mocker.GetMock().Setup(s => s.GetDirectories(RecycleBin)) .Returns(new [] { @"C:\Test\RecycleBin\Folder1", @"C:\Test\RecycleBin\Folder2", @"C:\Test\RecycleBin\Folder3" }); @@ -56,12 +57,13 @@ namespace NzbDrone.Core.Test.ProviderTests.RecycleBinProviderTests } [Test] - public void should_delete_all_expired_folders() - { - WithExpired(); + public void should_return_if_recycleBinCleanupDays_is_zero() + { + Mocker.GetMock().SetupGet(s => s.RecycleBinCleanupDays).Returns(0); + Mocker.Resolve().Cleanup(); - Mocker.GetMock().Verify(v => v.DeleteFolder(It.IsAny(), true), Times.Exactly(3)); + Mocker.GetMock().Verify(v => v.GetDirectories(It.IsAny()), Times.Never()); } [Test] diff --git a/src/NzbDrone.Core.Test/ProviderTests/RecycleBinProviderTests/DeleteFileFixture.cs b/src/NzbDrone.Core.Test/ProviderTests/RecycleBinProviderTests/DeleteFileFixture.cs index 3adb28208..4f5433a88 100644 --- a/src/NzbDrone.Core.Test/ProviderTests/RecycleBinProviderTests/DeleteFileFixture.cs +++ b/src/NzbDrone.Core.Test/ProviderTests/RecycleBinProviderTests/DeleteFileFixture.cs @@ -75,5 +75,17 @@ namespace NzbDrone.Core.Test.ProviderTests.RecycleBinProviderTests Mocker.GetMock().Verify(v => v.FileSetLastWriteTime(@"C:\Test\Recycle Bin\S01E01.avi".AsOsAgnostic(), It.IsAny()), Times.Once()); } + + [Test] + public void should_use_subfolder_when_passed_in() + { + WithRecycleBin(); + + var path = @"C:\Test\TV\30 Rock\S01E01.avi".AsOsAgnostic(); + + Mocker.Resolve().DeleteFile(path, "30 Rock"); + + Mocker.GetMock().Verify(v => v.TransferFile(path, @"C:\Test\Recycle Bin\30 Rock\S01E01.avi".AsOsAgnostic(), TransferMode.Move, false, true), Times.Once()); + } } } diff --git a/src/NzbDrone.Core.Test/Providers/XemProxyFixture.cs b/src/NzbDrone.Core.Test/Providers/XemProxyFixture.cs deleted file mode 100644 index a46ab935c..000000000 --- a/src/NzbDrone.Core.Test/Providers/XemProxyFixture.cs +++ /dev/null @@ -1,54 +0,0 @@ -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.DataAugmentation.Xem; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Test.Common.Categories; - -namespace NzbDrone.Core.Test.Providers -{ - [TestFixture] - [IntegrationTest] - public class XemProxyFixture : CoreTest - { - [SetUp] - public void Setup() - { - UseRealHttp(); - } - - [Test] - public void get_series_ids() - { - var ids = Subject.GetXemSeriesIds(); - - ids.Should().NotBeEmpty(); - ids.Should().Contain(i => i == 73141); - } - - [TestCase(12345, Description = "invalid id")] - [TestCase(279042, Description = "no single connection")] - public void should_return_empty_when_known_error(int id) - { - Subject.GetSceneTvdbMappings(id).Should().BeEmpty(); - } - - [TestCase(82807)] - [TestCase(73141, Description = "American Dad!")] - public void should_get_mapping(int seriesId) - { - var result = Subject.GetSceneTvdbMappings(seriesId); - - result.Should().NotBeEmpty(); - result.Should().OnlyContain(c => c.Scene != null); - result.Should().OnlyContain(c => c.Tvdb != null); - } - - [TestCase(78916)] - public void should_filter_out_episodes_without_scene_mapping(int seriesId) - { - var result = Subject.GetSceneTvdbMappings(seriesId); - - result.Should().NotContain(c => c.Tvdb == null); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/Qualities/QualityDefinitionServiceFixture.cs b/src/NzbDrone.Core.Test/Qualities/QualityDefinitionServiceFixture.cs index a2eec207b..27b301615 100644 --- a/src/NzbDrone.Core.Test/Qualities/QualityDefinitionServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Qualities/QualityDefinitionServiceFixture.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Moq; using NUnit.Framework; using NzbDrone.Core.Lifecycle; @@ -26,7 +26,7 @@ namespace NzbDrone.Core.Test.Qualities .Setup(s => s.All()) .Returns(new List { - new QualityDefinition(Quality.SDTV) { Weight = 1, MinSize = 0, MaxSize = 100, Id = 20 } + new QualityDefinition(Quality.MP3_192) { Weight = 1, MinSize = 0, MaxSize = 100, Id = 20 } }); Subject.Handle(new ApplicationStartedEvent()); @@ -42,7 +42,7 @@ namespace NzbDrone.Core.Test.Qualities .Setup(s => s.All()) .Returns(new List { - new QualityDefinition(Quality.SDTV) { Weight = 1, MinSize = 0, MaxSize = 100, Id = 20 } + new QualityDefinition(Quality.MP3_192) { Weight = 1, MinSize = 0, MaxSize = 100, Id = 20 } }); Subject.Handle(new ApplicationStartedEvent()); diff --git a/src/NzbDrone.Core.Test/Qualities/QualityFixture.cs b/src/NzbDrone.Core.Test/Qualities/QualityFixture.cs index 0087ef3e7..013353a12 100644 --- a/src/NzbDrone.Core.Test/Qualities/QualityFixture.cs +++ b/src/NzbDrone.Core.Test/Qualities/QualityFixture.cs @@ -1,8 +1,8 @@ -using System.Linq; +using System.Linq; using System.Collections.Generic; using FluentAssertions; using NUnit.Framework; -using NzbDrone.Core.Profiles; +using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Qualities; using NzbDrone.Core.Test.Framework; @@ -14,37 +14,21 @@ namespace NzbDrone.Core.Test.Qualities public static object[] FromIntCases = { new object[] {0, Quality.Unknown}, - new object[] {1, Quality.SDTV}, - new object[] {2, Quality.DVD}, - new object[] {3, Quality.WEBDL1080p}, - new object[] {4, Quality.HDTV720p}, - new object[] {5, Quality.WEBDL720p}, - new object[] {6, Quality.Bluray720p}, - new object[] {7, Quality.Bluray1080p}, - new object[] {8, Quality.WEBDL480p}, - new object[] {9, Quality.HDTV1080p}, - new object[] {10, Quality.RAWHD}, - new object[] {16, Quality.HDTV2160p}, - new object[] {18, Quality.WEBDL2160p}, - new object[] {19, Quality.Bluray2160p}, + new object[] {1, Quality.MP3_192}, + new object[] {2, Quality.MP3_VBR}, + new object[] {3, Quality.MP3_256}, + new object[] {4, Quality.MP3_320}, + new object[] {6, Quality.FLAC}, }; public static object[] ToIntCases = { new object[] {Quality.Unknown, 0}, - new object[] {Quality.SDTV, 1}, - new object[] {Quality.DVD, 2}, - new object[] {Quality.WEBDL1080p, 3}, - new object[] {Quality.HDTV720p, 4}, - new object[] {Quality.WEBDL720p, 5}, - new object[] {Quality.Bluray720p, 6}, - new object[] {Quality.Bluray1080p, 7}, - new object[] {Quality.WEBDL480p, 8}, - new object[] {Quality.HDTV1080p, 9}, - new object[] {Quality.RAWHD, 10}, - new object[] {Quality.HDTV2160p, 16}, - new object[] {Quality.WEBDL2160p, 18}, - new object[] {Quality.Bluray2160p, 19}, + new object[] {Quality.MP3_192, 1}, + new object[] {Quality.MP3_VBR, 2}, + new object[] {Quality.MP3_256, 3}, + new object[] {Quality.MP3_320, 4}, + new object[] {Quality.FLAC, 6}, }; [Test, TestCaseSource(nameof(FromIntCases))] @@ -61,24 +45,16 @@ namespace NzbDrone.Core.Test.Qualities i.Should().Be(expected); } - public static List GetDefaultQualities(params Quality[] allowed) + public static List GetDefaultQualities(params Quality[] allowed) { var qualities = new List { Quality.Unknown, - Quality.SDTV, - Quality.WEBDL480p, - Quality.DVD, - Quality.HDTV720p, - Quality.HDTV1080p, - Quality.HDTV2160p, - Quality.RAWHD, - Quality.WEBDL720p, - Quality.WEBDL1080p, - Quality.WEBDL2160p, - Quality.Bluray720p, - Quality.Bluray1080p, - Quality.Bluray2160p, + Quality.MP3_192, + Quality.MP3_VBR, + Quality.MP3_256, + Quality.MP3_320, + Quality.FLAC, }; if (allowed.Length == 0) @@ -87,7 +63,7 @@ namespace NzbDrone.Core.Test.Qualities var items = qualities .Except(allowed) .Concat(allowed) - .Select(v => new ProfileQualityItem { Quality = v, Allowed = allowed.Contains(v) }).ToList(); + .Select(v => new QualityProfileQualityItem { Quality = v, Allowed = allowed.Contains(v) }).ToList(); return items; } diff --git a/src/NzbDrone.Core.Test/Qualities/QualityModelComparerFixture.cs b/src/NzbDrone.Core.Test/Qualities/QualityModelComparerFixture.cs index 47ecbde16..85f97ba62 100644 --- a/src/NzbDrone.Core.Test/Qualities/QualityModelComparerFixture.cs +++ b/src/NzbDrone.Core.Test/Qualities/QualityModelComparerFixture.cs @@ -1,6 +1,7 @@ -using FluentAssertions; +using System.Collections.Generic; +using FluentAssertions; using NUnit.Framework; -using NzbDrone.Core.Profiles; +using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Qualities; using NzbDrone.Core.Test.Framework; @@ -13,21 +14,61 @@ namespace NzbDrone.Core.Test.Qualities private void GivenDefaultProfile() { - Subject = new QualityModelComparer(new Profile { Items = QualityFixture.GetDefaultQualities() }); + Subject = new QualityModelComparer(new QualityProfile { Items = QualityFixture.GetDefaultQualities() }); } private void GivenCustomProfile() { - Subject = new QualityModelComparer(new Profile { Items = QualityFixture.GetDefaultQualities(Quality.Bluray720p, Quality.DVD) }); + Subject = new QualityModelComparer(new QualityProfile { Items = QualityFixture.GetDefaultQualities(Quality.MP3_320, Quality.MP3_192) }); } + private void GivenGroupedProfile() + { + var profile = new QualityProfile + { + Items = new List + { + new QualityProfileQualityItem + { + Allowed = false, + Quality = Quality.MP3_192 + }, + new QualityProfileQualityItem + { + Allowed = true, + Items = new List + { + new QualityProfileQualityItem + { + Allowed = true, + Quality = Quality.MP3_256 + }, + new QualityProfileQualityItem + { + Allowed = true, + Quality = Quality.MP3_320 + } + } + }, + new QualityProfileQualityItem + { + Allowed = true, + Quality = Quality.FLAC + } + } + }; + + Subject = new QualityModelComparer(profile); + } + + [Test] public void should_be_greater_when_first_quality_is_greater_than_second() { GivenDefaultProfile(); - var first = new QualityModel(Quality.Bluray1080p); - var second = new QualityModel(Quality.DVD); + var first = new QualityModel(Quality.MP3_320); + var second = new QualityModel(Quality.MP3_192); var compare = Subject.Compare(first, second); @@ -39,8 +80,8 @@ namespace NzbDrone.Core.Test.Qualities { GivenDefaultProfile(); - var first = new QualityModel(Quality.DVD); - var second = new QualityModel(Quality.Bluray1080p); + var first = new QualityModel(Quality.MP3_192); + var second = new QualityModel(Quality.MP3_320); var compare = Subject.Compare(first, second); @@ -52,8 +93,8 @@ namespace NzbDrone.Core.Test.Qualities { GivenDefaultProfile(); - var first = new QualityModel(Quality.Bluray1080p, new Revision(version: 2)); - var second = new QualityModel(Quality.Bluray1080p, new Revision(version: 1)); + var first = new QualityModel(Quality.MP3_320, new Revision(version: 2)); + var second = new QualityModel(Quality.MP3_320, new Revision(version: 1)); var compare = Subject.Compare(first, second); @@ -65,12 +106,38 @@ namespace NzbDrone.Core.Test.Qualities { GivenCustomProfile(); - var first = new QualityModel(Quality.DVD); - var second = new QualityModel(Quality.Bluray720p); + var first = new QualityModel(Quality.MP3_192); + var second = new QualityModel(Quality.MP3_320); var compare = Subject.Compare(first, second); compare.Should().BeGreaterThan(0); } + + [Test] + public void should_ignore_group_order_by_default() + { + GivenGroupedProfile(); + + var first = new QualityModel(Quality.MP3_256); + var second = new QualityModel(Quality.MP3_320); + + var compare = Subject.Compare(first, second); + + compare.Should().Be(0); + } + + [Test] + public void should_respect_group_order() + { + GivenGroupedProfile(); + + var first = new QualityModel(Quality.MP3_256); + var second = new QualityModel(Quality.MP3_320); + + var compare = Subject.Compare(first, second, true); + + compare.Should().BeLessThan(0); + } } } diff --git a/src/NzbDrone.Core.Test/QueueTests/QueueServiceFixture.cs b/src/NzbDrone.Core.Test/QueueTests/QueueServiceFixture.cs index 81ca1e28d..17bb19323 100644 --- a/src/NzbDrone.Core.Test/QueueTests/QueueServiceFixture.cs +++ b/src/NzbDrone.Core.Test/QueueTests/QueueServiceFixture.cs @@ -1,15 +1,18 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; -using NzbDrone.Core.Download.TrackedDownloads; -using NzbDrone.Core.Queue; -using NzbDrone.Core.Test.Framework; using FizzWare.NBuilder; using FluentAssertions; -using NzbDrone.Core.Tv; +using Moq; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Download.TrackedDownloads; +using NzbDrone.Core.History; +using NzbDrone.Core.Music; +using NzbDrone.Core.Queue; using NzbDrone.Core.Parser.Model; + namespace NzbDrone.Core.Test.QueueTests { [TestFixture] @@ -21,29 +24,38 @@ namespace NzbDrone.Core.Test.QueueTests public void SetUp() { var downloadItem = Builder.CreateNew() - .With(v => v.RemainingTime = TimeSpan.FromSeconds(10)) - .Build(); + .With(v => v.RemainingTime = TimeSpan.FromSeconds(10)) + .Build(); + + var artist = Builder.CreateNew() + .Build(); - var series = Builder.CreateNew() - .Build(); + var albums = Builder.CreateListOfSize(3) + .All() + .With(e => e.ArtistId = artist.Id) + .Build(); - var episodes = Builder.CreateListOfSize(3) - .All() - .With(e => e.SeriesId = series.Id) - .Build(); - - var remoteEpisode = Builder.CreateNew() - .With(r => r.Series = series) - .With(r => r.Episodes = new List(episodes)) - .With(r => r.ParsedEpisodeInfo = new ParsedEpisodeInfo()) - .Build(); + var remoteAlbum = Builder.CreateNew() + .With(r => r.Artist = artist) + .With(r => r.Albums = new List(albums)) + .With(r => r.ParsedAlbumInfo = new ParsedAlbumInfo()) + .Build(); _trackedDownloads = Builder.CreateListOfSize(1) .All() .With(v => v.DownloadItem = downloadItem) - .With(v => v.RemoteEpisode = remoteEpisode) + .With(v => v.RemoteAlbum = remoteAlbum) .Build() .ToList(); + + var historyItem = Builder.CreateNew() + .Build(); + + Mocker.GetMock() + .Setup(c => c.Find(It.IsAny(), HistoryEventType.Grabbed)).Returns + ( + new List { historyItem } + ); } [Test] diff --git a/src/NzbDrone.Core.Test/RootFolderTests/RootFolderServiceFixture.cs b/src/NzbDrone.Core.Test/RootFolderTests/RootFolderServiceFixture.cs index 1f09c26d0..c86968c18 100644 --- a/src/NzbDrone.Core.Test/RootFolderTests/RootFolderServiceFixture.cs +++ b/src/NzbDrone.Core.Test/RootFolderTests/RootFolderServiceFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -7,10 +7,9 @@ using FluentAssertions; using Moq; using NUnit.Framework; using NzbDrone.Common.Disk; -using NzbDrone.Core.Configuration; using NzbDrone.Core.RootFolders; using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.RootFolderTests @@ -42,7 +41,7 @@ namespace NzbDrone.Core.Test.RootFolderTests .Returns(false); } - [TestCase("D:\\TV Shows\\")] + [TestCase("D:\\Music\\")] [TestCase("//server//folder")] public void should_be_able_to_add_root_dir(string path) { @@ -81,9 +80,9 @@ namespace NzbDrone.Core.Test.RootFolderTests [Test] public void adding_duplicated_root_folder_should_throw() { - Mocker.GetMock().Setup(c => c.All()).Returns(new List { new RootFolder { Path = "C:\\TV".AsOsAgnostic() } }); + Mocker.GetMock().Setup(c => c.All()).Returns(new List { new RootFolder { Path = "C:\\Music".AsOsAgnostic() } }); - Assert.Throws(() => Subject.Add(new RootFolder { Path = @"C:\TV".AsOsAgnostic() })); + Assert.Throws(() => Subject.Add(new RootFolder { Path = @"C:\Music".AsOsAgnostic() })); } [Test] @@ -93,21 +92,9 @@ namespace NzbDrone.Core.Test.RootFolderTests .Setup(m => m.FolderWritable(It.IsAny())) .Returns(false); - Assert.Throws(() => Subject.Add(new RootFolder { Path = @"C:\TV".AsOsAgnostic() })); + Assert.Throws(() => Subject.Add(new RootFolder { Path = @"C:\Music".AsOsAgnostic() })); } - [Test] - public void should_throw_when_same_path_as_drone_factory() - { - var path = @"C:\TV".AsOsAgnostic(); - - Mocker.GetMock() - .SetupGet(s => s.DownloadedEpisodesFolder) - .Returns(path); - - Assert.Throws(() => Subject.Add(new RootFolder { Path = path })); -} - [TestCase("$recycle.bin")] [TestCase("system volume information")] [TestCase("recycler")] @@ -119,16 +106,16 @@ namespace NzbDrone.Core.Test.RootFolderTests [TestCase(".grab")] public void should_get_root_folder_with_subfolders_excluding_special_sub_folders(string subFolder) { - var rootFolderPath = @"C:\Test\TV".AsOsAgnostic(); + var rootFolderPath = @"C:\Test\Music".AsOsAgnostic(); var rootFolder = Builder.CreateNew() .With(r => r.Path = rootFolderPath) .Build(); var subFolders = new[] { - "Series1", - "Series2", - "Series3", + "Artist1", + "Artist2", + "Artist3", subFolder }; @@ -138,9 +125,9 @@ namespace NzbDrone.Core.Test.RootFolderTests .Setup(s => s.Get(It.IsAny())) .Returns(rootFolder); - Mocker.GetMock() - .Setup(s => s.GetAllSeries()) - .Returns(new List()); + Mocker.GetMock() + .Setup(s => s.GetAllArtists()) + .Returns(new List()); Mocker.GetMock() .Setup(s => s.GetDirectories(rootFolder.Path)) @@ -152,4 +139,4 @@ namespace NzbDrone.Core.Test.RootFolderTests unmappedFolders.Should().NotContain(u => u.Name == subFolder); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core.Test/SeriesStatsTests/SeriesStatisticsFixture.cs b/src/NzbDrone.Core.Test/SeriesStatsTests/SeriesStatisticsFixture.cs deleted file mode 100644 index c8a321bc6..000000000 --- a/src/NzbDrone.Core.Test/SeriesStatsTests/SeriesStatisticsFixture.cs +++ /dev/null @@ -1,182 +0,0 @@ -using System; -using System.Linq; -using FizzWare.NBuilder; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.Qualities; -using NzbDrone.Core.SeriesStats; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Test.SeriesStatsTests -{ - [TestFixture] - public class SeriesStatisticsFixture : DbTest - { - private Series _series; - private Episode _episode; - private EpisodeFile _episodeFile; - - [SetUp] - public void Setup() - { - _series = Builder.CreateNew() - .With(s => s.Runtime = 30) - .BuildNew(); - - _series.Id = Db.Insert(_series).Id; - - _episode = Builder.CreateNew() - .With(e => e.EpisodeFileId = 0) - .With(e => e.Monitored = false) - .With(e => e.SeriesId = _series.Id) - .With(e => e.AirDateUtc = DateTime.Today.AddDays(5)) - .BuildNew(); - - _episodeFile = Builder.CreateNew() - .With(e => e.SeriesId = _series.Id) - .With(e => e.Quality = new QualityModel(Quality.HDTV720p)) - .BuildNew(); - - } - - private void GivenEpisodeWithFile() - { - _episode.EpisodeFileId = 1; - } - - private void GivenOldEpisode() - { - _episode.AirDateUtc = DateTime.Now.AddSeconds(-10); - } - - private void GivenMonitoredEpisode() - { - _episode.Monitored = true; - } - - private void GivenEpisode() - { - Db.Insert(_episode); - } - - private void GivenEpisodeFile() - { - Db.Insert(_episodeFile); - } - - [Test] - public void should_get_stats_for_series() - { - GivenMonitoredEpisode(); - GivenEpisode(); - - var stats = Subject.SeriesStatistics(); - - stats.Should().HaveCount(1); - stats.First().NextAiring.Should().Be(_episode.AirDateUtc); - stats.First().PreviousAiring.Should().NotHaveValue(); - } - - [Test] - public void should_not_have_next_airing_for_episode_with_file() - { - GivenEpisodeWithFile(); - GivenEpisode(); - - var stats = Subject.SeriesStatistics(); - - stats.Should().HaveCount(1); - stats.First().NextAiring.Should().NotHaveValue(); - } - - [Test] - public void should_have_previous_airing_for_old_episode_with_file() - { - GivenEpisodeWithFile(); - GivenOldEpisode(); - GivenEpisode(); - - var stats = Subject.SeriesStatistics(); - - stats.Should().HaveCount(1); - stats.First().NextAiring.Should().NotHaveValue(); - stats.First().PreviousAiring.Should().Be(_episode.AirDateUtc); - } - - [Test] - public void should_have_previous_airing_for_old_episode_without_file_monitored() - { - GivenMonitoredEpisode(); - GivenOldEpisode(); - GivenEpisode(); - - var stats = Subject.SeriesStatistics(); - - stats.Should().HaveCount(1); - stats.First().NextAiring.Should().NotHaveValue(); - stats.First().PreviousAiring.Should().Be(_episode.AirDateUtc); - } - - [Test] - public void should_not_have_previous_airing_for_old_episode_without_file_unmonitored() - { - GivenOldEpisode(); - GivenEpisode(); - - var stats = Subject.SeriesStatistics(); - - stats.Should().HaveCount(1); - stats.First().NextAiring.Should().NotHaveValue(); - stats.First().PreviousAiring.Should().NotHaveValue(); - } - - [Test] - public void should_not_include_unmonitored_episode_in_episode_count() - { - GivenEpisode(); - - var stats = Subject.SeriesStatistics(); - - stats.Should().HaveCount(1); - stats.First().EpisodeCount.Should().Be(0); - } - - [Test] - public void should_include_unmonitored_episode_with_file_in_episode_count() - { - GivenEpisodeWithFile(); - GivenEpisode(); - - var stats = Subject.SeriesStatistics(); - - stats.Should().HaveCount(1); - stats.First().EpisodeCount.Should().Be(1); - } - - [Test] - public void should_have_size_on_disk_of_zero_when_no_episode_file() - { - GivenEpisode(); - - var stats = Subject.SeriesStatistics(); - - stats.Should().HaveCount(1); - stats.First().SizeOnDisk.Should().Be(0); - } - - [Test] - public void should_have_size_on_disk_when_episode_file_exists() - { - GivenEpisode(); - GivenEpisodeFile(); - - var stats = Subject.SeriesStatistics(); - - stats.Should().HaveCount(1); - stats.First().SizeOnDisk.Should().Be(_episodeFile.Size); - } - - } -} diff --git a/src/NzbDrone.Core.Test/ThingiProvider/ProviderBaseFixture.cs b/src/NzbDrone.Core.Test/ThingiProviderTests/ProviderBaseFixture.cs similarity index 92% rename from src/NzbDrone.Core.Test/ThingiProvider/ProviderBaseFixture.cs rename to src/NzbDrone.Core.Test/ThingiProviderTests/ProviderBaseFixture.cs index db1e21c61..02bbdd8b2 100644 --- a/src/NzbDrone.Core.Test/ThingiProvider/ProviderBaseFixture.cs +++ b/src/NzbDrone.Core.Test/ThingiProviderTests/ProviderBaseFixture.cs @@ -1,13 +1,12 @@ -using FizzWare.NBuilder; +using FizzWare.NBuilder; using FluentAssertions; using NUnit.Framework; 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..74f2480f7 --- /dev/null +++ b/src/NzbDrone.Core.Test/ThingiProviderTests/ProviderStatusServiceFixture.cs @@ -0,0 +1,167 @@ +using System; +using System.Linq; +using FluentAssertions; +using Moq; +using NLog; +using NUnit.Framework; +using NzbDrone.Common.EnvironmentInfo; +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, IRuntimeInfo runtimeInfo, Logger logger) + : base(providerStatusRepository, eventAggregator, runtimeInfo, logger) + { + + } + } + + public class ProviderStatusServiceFixture : CoreTest + { + private DateTime _epoch; + + [SetUp] + public void SetUp() + { + _epoch = DateTime.UtcNow; + + Mocker.GetMock() + .SetupGet(v => v.StartTime) + .Returns(_epoch - TimeSpan.FromHours(1)); + } + + private void GivenRecentStartup() + { + Mocker.GetMock() + .SetupGet(v => v.StartTime) + .Returns(_epoch - TimeSpan.FromMinutes(12)); + } + + private MockProviderStatus WithStatus(MockProviderStatus 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_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); + } + + [Test] + public void should_not_escalate_further_than_5_minutes_for_15_min_after_startup() + { + GivenRecentStartup(); + + var origStatus = WithStatus(new MockProviderStatus + { + 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(); + + origStatus.EscalationLevel.Should().Be(3); + status.DisabledTill.Should().BeCloseTo(_epoch + TimeSpan.FromMinutes(5), 500); + } + } +} diff --git a/src/NzbDrone.Core.Test/TvTests/EpisodeMonitoredServiceTests/SetEpisodeMontitoredFixture.cs b/src/NzbDrone.Core.Test/TvTests/EpisodeMonitoredServiceTests/SetEpisodeMontitoredFixture.cs deleted file mode 100644 index 058a09b86..000000000 --- a/src/NzbDrone.Core.Test/TvTests/EpisodeMonitoredServiceTests/SetEpisodeMontitoredFixture.cs +++ /dev/null @@ -1,221 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using FizzWare.NBuilder; -using Moq; -using NUnit.Framework; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Test.TvTests.EpisodeMonitoredServiceTests -{ - [TestFixture] - public class SetEpisodeMontitoredFixture : CoreTest - { - private Series _series; - private List _episodes; - - [SetUp] - public void Setup() - { - var seasons = 4; - - _series = Builder.CreateNew() - .With(s => s.Seasons = Builder.CreateListOfSize(seasons) - .All() - .With(n => n.Monitored = true) - .Build() - .ToList()) - .Build(); - - _episodes = Builder.CreateListOfSize(seasons) - .All() - .With(e => e.Monitored = true) - .With(e => e.AirDateUtc = DateTime.UtcNow.AddDays(-7)) - //Missing - .TheFirst(1) - .With(e => e.EpisodeFileId = 0) - //Has File - .TheNext(1) - .With(e => e.EpisodeFileId = 1) - //Future - .TheNext(1) - .With(e => e.EpisodeFileId = 0) - .With(e => e.AirDateUtc = DateTime.UtcNow.AddDays(7)) - //Future/TBA - .TheNext(1) - .With(e => e.EpisodeFileId = 0) - .With(e => e.AirDateUtc = null) - .Build() - .ToList(); - - Mocker.GetMock() - .Setup(s => s.GetEpisodeBySeries(It.IsAny())) - .Returns(_episodes); - } - - private void GivenSpecials() - { - foreach (var episode in _episodes) - { - episode.SeasonNumber = 0; - } - - _series.Seasons = new List{new Season { Monitored = false, SeasonNumber = 0 }}; - } - - [Test] - public void should_be_able_to_monitor_series_without_changing_episodes() - { - Subject.SetEpisodeMonitoredStatus(_series, null); - - Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.IsAny()), Times.Once()); - - Mocker.GetMock() - .Verify(v => v.UpdateEpisodes(It.IsAny>()), Times.Never()); - } - - [Test] - public void should_be_able_to_monitor_all_episodes() - { - Subject.SetEpisodeMonitoredStatus(_series, new MonitoringOptions()); - - Mocker.GetMock() - .Verify(v => v.UpdateEpisodes(It.Is>(l => l.All(e => e.Monitored)))); - } - - [Test] - public void should_be_able_to_monitor_missing_episodes_only() - { - var monitoringOptions = new MonitoringOptions - { - IgnoreEpisodesWithFiles = true, - IgnoreEpisodesWithoutFiles = false - }; - - Subject.SetEpisodeMonitoredStatus(_series, monitoringOptions); - - VerifyMonitored(e => !e.HasFile); - VerifyNotMonitored(e => e.HasFile); - } - - [Test] - public void should_be_able_to_monitor_new_episodes_only() - { - var monitoringOptions = new MonitoringOptions - { - IgnoreEpisodesWithFiles = true, - IgnoreEpisodesWithoutFiles = true - }; - - Subject.SetEpisodeMonitoredStatus(_series, monitoringOptions); - - VerifyMonitored(e => e.AirDateUtc.HasValue && e.AirDateUtc.Value.After(DateTime.UtcNow)); - VerifyMonitored(e => !e.AirDateUtc.HasValue); - VerifyNotMonitored(e => e.AirDateUtc.HasValue && e.AirDateUtc.Value.Before(DateTime.UtcNow)); - } - - [Test] - public void should_not_monitor_missing_specials() - { - GivenSpecials(); - - var monitoringOptions = new MonitoringOptions - { - IgnoreEpisodesWithFiles = true, - IgnoreEpisodesWithoutFiles = false - }; - - Subject.SetEpisodeMonitoredStatus(_series, monitoringOptions); - - VerifyNotMonitored(e => e.SeasonNumber == 0); - } - - [Test] - public void should_not_monitor_new_specials() - { - GivenSpecials(); - - var monitoringOptions = new MonitoringOptions - { - IgnoreEpisodesWithFiles = true, - IgnoreEpisodesWithoutFiles = true - }; - - Subject.SetEpisodeMonitoredStatus(_series, monitoringOptions); - - VerifyNotMonitored(e => e.SeasonNumber == 0); - } - - [Test] - public void should_not_monitor_season_when_all_episodes_are_monitored_except_latest_season() - { - _series.Seasons = Builder.CreateListOfSize(2) - .All() - .With(n => n.Monitored = true) - .Build() - .ToList(); - - _episodes = Builder.CreateListOfSize(5) - .All() - .With(e => e.SeasonNumber = 1) - .With(e => e.EpisodeFileId = 0) - .With(e => e.AirDateUtc = DateTime.UtcNow.AddDays(-5)) - .TheLast(1) - .With(e => e.SeasonNumber = 2) - .Build() - .ToList(); - - Mocker.GetMock() - .Setup(s => s.GetEpisodeBySeries(It.IsAny())) - .Returns(_episodes); - - var monitoringOptions = new MonitoringOptions - { - IgnoreEpisodesWithoutFiles = true - }; - - Subject.SetEpisodeMonitoredStatus(_series, monitoringOptions); - - VerifySeasonMonitored(n => n.SeasonNumber == 2); - VerifySeasonNotMonitored(n => n.SeasonNumber == 1); - } - - [Test] - public void should_ignore_episodes_when_season_is_not_monitored() - { - _series.Seasons.ForEach(s => s.Monitored = false); - - Subject.SetEpisodeMonitoredStatus(_series, new MonitoringOptions()); - - Mocker.GetMock() - .Verify(v => v.UpdateEpisodes(It.Is>(l => l.All(e => !e.Monitored)))); - } - - private void VerifyMonitored(Func predicate) - { - Mocker.GetMock() - .Verify(v => v.UpdateEpisodes(It.Is>(l => l.Where(predicate).All(e => e.Monitored)))); - } - - private void VerifyNotMonitored(Func predicate) - { - Mocker.GetMock() - .Verify(v => v.UpdateEpisodes(It.Is>(l => l.Where(predicate).All(e => !e.Monitored)))); - } - - private void VerifySeasonMonitored(Func predicate) - { - Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.Is(s => s.Seasons.Where(predicate).All(n => n.Monitored)))); - } - - private void VerifySeasonNotMonitored(Func predicate) - { - Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.Is(s => s.Seasons.Where(predicate).All(n => !n.Monitored)))); - } - } -} diff --git a/src/NzbDrone.Core.Test/TvTests/EpisodeRepositoryTests/ByAirDateFixture.cs b/src/NzbDrone.Core.Test/TvTests/EpisodeRepositoryTests/ByAirDateFixture.cs deleted file mode 100644 index 2f6c0cef5..000000000 --- a/src/NzbDrone.Core.Test/TvTests/EpisodeRepositoryTests/ByAirDateFixture.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System; -using FizzWare.NBuilder; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Test.TvTests.EpisodeRepositoryTests -{ - [TestFixture] - public class ByAirDateFixture : DbTest - { - private const int SERIES_ID = 1; - private const string AIR_DATE = "2014-04-02"; - - private void GivenEpisode(int seasonNumber) - { - var episode = Builder.CreateNew() - .With(e => e.SeriesId = 1) - .With(e => e.SeasonNumber = seasonNumber) - .With(e => e.AirDate = AIR_DATE) - .BuildNew(); - - Db.Insert(episode); - } - - [Test] - public void should_throw_when_multiple_regular_episodes_are_found() - { - GivenEpisode(1); - GivenEpisode(2); - - Assert.Throws(() => Subject.Get(SERIES_ID, AIR_DATE)); - Assert.Throws(() => Subject.Find(SERIES_ID, AIR_DATE)); - } - - [Test] - public void should_throw_when_get_finds_no_episode() - { - Assert.Throws(() => Subject.Get(SERIES_ID, AIR_DATE)); - } - - [Test] - public void should_get_episode_when_single_episode_exists_for_air_date() - { - GivenEpisode(1); - - Subject.Get(SERIES_ID, AIR_DATE).Should().NotBeNull(); - Subject.Find(SERIES_ID, AIR_DATE).Should().NotBeNull(); - } - - [Test] - public void should_get_episode_when_regular_episode_and_special_share_the_same_air_date() - { - GivenEpisode(1); - GivenEpisode(0); - - Subject.Get(SERIES_ID, AIR_DATE).Should().NotBeNull(); - Subject.Find(SERIES_ID, AIR_DATE).Should().NotBeNull(); - } - - [Test] - public void should_get_special_when_its_the_only_episode_for_the_date_provided() - { - GivenEpisode(0); - - Subject.Get(SERIES_ID, AIR_DATE).Should().NotBeNull(); - Subject.Find(SERIES_ID, AIR_DATE).Should().NotBeNull(); - } - } -} diff --git a/src/NzbDrone.Core.Test/TvTests/EpisodeRepositoryTests/EpisodesBetweenDatesFixture.cs b/src/NzbDrone.Core.Test/TvTests/EpisodeRepositoryTests/EpisodesBetweenDatesFixture.cs deleted file mode 100644 index 10cb1393f..000000000 --- a/src/NzbDrone.Core.Test/TvTests/EpisodeRepositoryTests/EpisodesBetweenDatesFixture.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using FizzWare.NBuilder; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Test.TvTests.EpisodeRepositoryTests -{ - [TestFixture] - public class EpisodesBetweenDatesFixture : DbTest - { - [SetUp] - public void Setup() - { - var series = Builder.CreateNew() - .With(s => s.Id = 0) - .With(s => s.Runtime = 30) - .With(s => s.Monitored = true) - .Build(); - - series.Id = Db.Insert(series).Id; - - var episode = Builder.CreateNew() - .With(e => e.Id = 0) - .With(e => e.SeriesId = series.Id) - .With(e => e.Monitored = true) - .Build(); - - Db.Insert(episode); - } - - [Test] - public void should_get_episodes() - { - var episodes = Subject.EpisodesBetweenDates(DateTime.Today.AddDays(-1), DateTime.Today.AddDays(3), false); - episodes.Should().HaveCount(1); - } - } -} diff --git a/src/NzbDrone.Core.Test/TvTests/EpisodeRepositoryTests/EpisodesRepositoryReadFixture.cs b/src/NzbDrone.Core.Test/TvTests/EpisodeRepositoryTests/EpisodesRepositoryReadFixture.cs deleted file mode 100644 index 07a43b9ca..000000000 --- a/src/NzbDrone.Core.Test/TvTests/EpisodeRepositoryTests/EpisodesRepositoryReadFixture.cs +++ /dev/null @@ -1,47 +0,0 @@ -using FizzWare.NBuilder; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.Qualities; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Test.TvTests.EpisodeRepositoryTests -{ - [TestFixture] - public class EpisodesRepositoryReadFixture : DbTest - { - private Series series; - - [SetUp] - public void Setup() - { - series = Builder.CreateNew() - .With(s => s.Runtime = 30) - .BuildNew(); - - Db.Insert(series); - } - - [Test] - public void should_get_episodes_by_file() - { - var episodeFile = Builder.CreateNew() - .With(h => h.Quality = new QualityModel()) - .BuildNew(); - - Db.Insert(episodeFile); - - var episode = Builder.CreateListOfSize(2) - .All() - .With(e => e.SeriesId = series.Id) - .With(e => e.EpisodeFileId = episodeFile.Id) - .BuildListOfNew(); - - Db.InsertMany(episode); - - var episodes = Subject.GetEpisodeByFileId(episodeFile.Id); - episodes.Should().HaveCount(2); - } - } -} diff --git a/src/NzbDrone.Core.Test/TvTests/EpisodeRepositoryTests/EpisodesWhereCutoffUnmetFixture.cs b/src/NzbDrone.Core.Test/TvTests/EpisodeRepositoryTests/EpisodesWhereCutoffUnmetFixture.cs deleted file mode 100644 index 3b8ebeedf..000000000 --- a/src/NzbDrone.Core.Test/TvTests/EpisodeRepositoryTests/EpisodesWhereCutoffUnmetFixture.cs +++ /dev/null @@ -1,183 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using FizzWare.NBuilder; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Datastore; -using NzbDrone.Core.Profiles; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; -using NzbDrone.Core.Qualities; -using NzbDrone.Core.MediaFiles; - -namespace NzbDrone.Core.Test.TvTests.EpisodeRepositoryTests -{ - [TestFixture] - public class EpisodesWhereCutoffUnmetFixture : DbTest - { - private Series _monitoredSeries; - private Series _unmonitoredSeries; - private PagingSpec _pagingSpec; - private List _qualitiesBelowCutoff; - private List _unairedEpisodes; - - [SetUp] - public void Setup() - { - var profile = new Profile - { - Id = 1, - Cutoff = Quality.WEBDL480p, - Items = new List - { - new ProfileQualityItem { Allowed = true, Quality = Quality.SDTV }, - new ProfileQualityItem { Allowed = true, Quality = Quality.WEBDL480p }, - new ProfileQualityItem { Allowed = true, Quality = Quality.RAWHD } - } - }; - - _monitoredSeries = Builder.CreateNew() - .With(s => s.TvRageId = RandomNumber) - .With(s => s.Runtime = 30) - .With(s => s.Monitored = true) - .With(s => s.TitleSlug = "Title3") - .With(s => s.Id = profile.Id) - .BuildNew(); - - _unmonitoredSeries = Builder.CreateNew() - .With(s => s.TvdbId = RandomNumber) - .With(s => s.Runtime = 30) - .With(s => s.Monitored = false) - .With(s => s.TitleSlug = "Title2") - .With(s => s.Id = profile.Id) - .BuildNew(); - - _monitoredSeries.Id = Db.Insert(_monitoredSeries).Id; - _unmonitoredSeries.Id = Db.Insert(_unmonitoredSeries).Id; - - _pagingSpec = new PagingSpec - { - Page = 1, - PageSize = 10, - SortKey = "AirDate", - SortDirection = SortDirection.Ascending - }; - - _qualitiesBelowCutoff = new List - { - new QualitiesBelowCutoff(profile.Id, new[] {Quality.SDTV.Id}) - }; - - var qualityMet = new EpisodeFile { RelativePath = "a", Quality = new QualityModel { Quality = Quality.WEBDL480p } }; - var qualityUnmet = new EpisodeFile { RelativePath = "b", Quality = new QualityModel { Quality = Quality.SDTV } }; - var qualityRawHD = new EpisodeFile { RelativePath = "c", Quality = new QualityModel { Quality = Quality.RAWHD } }; - - MediaFileRepository fileRepository = Mocker.Resolve(); - - qualityMet = fileRepository.Insert(qualityMet); - qualityUnmet = fileRepository.Insert(qualityUnmet); - qualityRawHD = fileRepository.Insert(qualityRawHD); - - var monitoredSeriesEpisodes = Builder.CreateListOfSize(4) - .All() - .With(e => e.Id = 0) - .With(e => e.SeriesId = _monitoredSeries.Id) - .With(e => e.AirDateUtc = DateTime.Now.AddDays(-5)) - .With(e => e.Monitored = true) - .With(e => e.EpisodeFileId = qualityUnmet.Id) - .TheFirst(1) - .With(e => e.Monitored = false) - .With(e => e.EpisodeFileId = qualityMet.Id) - .TheNext(1) - .With(e => e.EpisodeFileId = qualityRawHD.Id) - .TheLast(1) - .With(e => e.SeasonNumber = 0) - .Build(); - - var unmonitoredSeriesEpisodes = Builder.CreateListOfSize(3) - .All() - .With(e => e.Id = 0) - .With(e => e.SeriesId = _unmonitoredSeries.Id) - .With(e => e.AirDateUtc = DateTime.Now.AddDays(-5)) - .With(e => e.Monitored = true) - .With(e => e.EpisodeFileId = qualityUnmet.Id) - .TheFirst(1) - .With(e => e.Monitored = false) - .With(e => e.EpisodeFileId = qualityMet.Id) - .TheLast(1) - .With(e => e.SeasonNumber = 0) - .Build(); - - - _unairedEpisodes = Builder.CreateListOfSize(1) - .All() - .With(e => e.Id = 0) - .With(e => e.SeriesId = _monitoredSeries.Id) - .With(e => e.AirDateUtc = DateTime.Now.AddDays(5)) - .With(e => e.Monitored = true) - .With(e => e.EpisodeFileId = qualityUnmet.Id) - .Build() - .ToList(); - - Db.InsertMany(monitoredSeriesEpisodes); - Db.InsertMany(unmonitoredSeriesEpisodes); - } - - private void GivenMonitoredFilterExpression() - { - _pagingSpec.FilterExpression = e => e.Monitored == true && e.Series.Monitored == true; - } - - private void GivenUnmonitoredFilterExpression() - { - _pagingSpec.FilterExpression = e => e.Monitored == false || e.Series.Monitored == false; - } - - [Test] - public void should_include_episodes_where_cutoff_has_not_be_met() - { - GivenMonitoredFilterExpression(); - - var spec = Subject.EpisodesWhereCutoffUnmet(_pagingSpec, _qualitiesBelowCutoff, false); - - spec.Records.Should().HaveCount(1); - spec.Records.Should().OnlyContain(e => e.EpisodeFile.Value.Quality.Quality == Quality.SDTV); - } - - [Test] - public void should_only_contain_monitored_episodes() - { - GivenMonitoredFilterExpression(); - - var spec = Subject.EpisodesWhereCutoffUnmet(_pagingSpec, _qualitiesBelowCutoff, false); - - spec.Records.Should().HaveCount(1); - spec.Records.Should().OnlyContain(e => e.Monitored); - } - - [Test] - public void should_only_contain_episode_with_monitored_series() - { - GivenMonitoredFilterExpression(); - - var spec = Subject.EpisodesWhereCutoffUnmet(_pagingSpec, _qualitiesBelowCutoff, false); - - spec.Records.Should().HaveCount(1); - spec.Records.Should().OnlyContain(e => e.Series.Monitored); - } - - [Test] - public void should_contain_unaired_episodes_if_file_does_not_meet_cutoff() - { - Db.InsertMany(_unairedEpisodes); - - GivenMonitoredFilterExpression(); - - var spec = Subject.EpisodesWhereCutoffUnmet(_pagingSpec, _qualitiesBelowCutoff, false); - - spec.Records.Should().HaveCount(2); - spec.Records.Should().OnlyContain(e => e.Series.Monitored); - } - } -} diff --git a/src/NzbDrone.Core.Test/TvTests/EpisodeRepositoryTests/EpisodesWithFilesFixture.cs b/src/NzbDrone.Core.Test/TvTests/EpisodeRepositoryTests/EpisodesWithFilesFixture.cs deleted file mode 100644 index e12a8b1c0..000000000 --- a/src/NzbDrone.Core.Test/TvTests/EpisodeRepositoryTests/EpisodesWithFilesFixture.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using FizzWare.NBuilder; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; -using NzbDrone.Core.Qualities; - -namespace NzbDrone.Core.Test.TvTests.EpisodeRepositoryTests -{ - [TestFixture] - public class EpisodesWithFilesFixture : DbTest - { - private const int SERIES_ID = 1; - private List _episodes; - private List _episodeFiles; - - [SetUp] - public void Setup() - { - _episodeFiles = Builder.CreateListOfSize(5) - .All() - .With(c => c.Quality = new QualityModel()) - .BuildListOfNew(); - - Db.InsertMany(_episodeFiles); - - _episodes = Builder.CreateListOfSize(10) - .All() - .With(e => e.EpisodeFileId = 0) - .With(e => e.SeriesId = SERIES_ID) - .BuildListOfNew() - .ToList(); - - for (int i = 0; i < _episodeFiles.Count; i++) - { - _episodes[i].EpisodeFileId = _episodeFiles[i].Id; - } - - Db.InsertMany(_episodes); - } - - - [Test] - public void should_only_get_files_that_have_episode_files() - { - var result = Subject.EpisodesWithFiles(SERIES_ID); - - result.Should().OnlyContain(e => e.EpisodeFileId > 0); - result.Should().HaveCount(_episodeFiles.Count); - } - - [Test] - public void should_only_contain_episodes_for_the_given_series() - { - var episodeFile = Builder.CreateNew() - .With(f => f.RelativePath = "another path") - .With(c => c.Quality = new QualityModel()) - .BuildNew(); - - Db.Insert(episodeFile); - - var episode = Builder.CreateNew() - .With(e => e.SeriesId = SERIES_ID + 10) - .With(e => e.EpisodeFileId = episodeFile.Id) - .BuildNew(); - - Db.Insert(episode); - - Subject.EpisodesWithFiles(episode.SeriesId).Should().OnlyContain(e => e.SeriesId == episode.SeriesId); - } - - [Test] - public void should_have_episode_file_loaded() - { - Subject.EpisodesWithFiles(SERIES_ID).Should().OnlyContain(e => e.EpisodeFile.IsLoaded); - } - } -} diff --git a/src/NzbDrone.Core.Test/TvTests/EpisodeRepositoryTests/EpisodesWithoutFilesFixture.cs b/src/NzbDrone.Core.Test/TvTests/EpisodeRepositoryTests/EpisodesWithoutFilesFixture.cs deleted file mode 100644 index 4f8f9eb23..000000000 --- a/src/NzbDrone.Core.Test/TvTests/EpisodeRepositoryTests/EpisodesWithoutFilesFixture.cs +++ /dev/null @@ -1,167 +0,0 @@ -using System; -using System.Linq; -using FizzWare.NBuilder; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Datastore; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Test.TvTests.EpisodeRepositoryTests -{ - [TestFixture] - public class EpisodesWithoutFilesFixture : DbTest - { - private Series _monitoredSeries; - private Series _unmonitoredSeries; - private PagingSpec _pagingSpec; - - [SetUp] - public void Setup() - { - _monitoredSeries = Builder.CreateNew() - .With(s => s.Id = 0) - .With(s => s.TvRageId = RandomNumber) - .With(s => s.Runtime = 30) - .With(s => s.Monitored = true) - .With(s => s.TitleSlug = "Title3") - .Build(); - - _unmonitoredSeries = Builder.CreateNew() - .With(s => s.Id = 0) - .With(s => s.TvdbId = RandomNumber) - .With(s => s.Runtime = 30) - .With(s => s.Monitored = false) - .With(s => s.TitleSlug = "Title2") - .Build(); - - _monitoredSeries.Id = Db.Insert(_monitoredSeries).Id; - _unmonitoredSeries.Id = Db.Insert(_unmonitoredSeries).Id; - - _pagingSpec = new PagingSpec - { - Page = 1, - PageSize = 10, - SortKey = "AirDate", - SortDirection = SortDirection.Ascending - }; - - var monitoredSeriesEpisodes = Builder.CreateListOfSize(3) - .All() - .With(e => e.Id = 0) - .With(e => e.SeriesId = _monitoredSeries.Id) - .With(e => e.EpisodeFileId = 0) - .With(e => e.AirDateUtc = DateTime.Now.AddDays(-5)) - .With(e => e.Monitored = true) - .TheFirst(1) - .With(e => e.Monitored = false) - .TheLast(1) - .With(e => e.SeasonNumber = 0) - .Build(); - - var unmonitoredSeriesEpisodes = Builder.CreateListOfSize(3) - .All() - .With(e => e.Id = 0) - .With(e => e.SeriesId = _unmonitoredSeries.Id) - .With(e => e.EpisodeFileId = 0) - .With(e => e.AirDateUtc = DateTime.Now.AddDays(-5)) - .With(e => e.Monitored = true) - .TheFirst(1) - .With(e => e.Monitored = false) - .TheLast(1) - .With(e => e.SeasonNumber = 0) - .Build(); - - - var unairedEpisodes = Builder.CreateListOfSize(1) - .All() - .With(e => e.Id = 0) - .With(e => e.SeriesId = _monitoredSeries.Id) - .With(e => e.EpisodeFileId = 0) - .With(e => e.AirDateUtc = DateTime.Now.AddDays(5)) - .With(e => e.Monitored = true) - .Build(); - - - Db.InsertMany(monitoredSeriesEpisodes); - Db.InsertMany(unmonitoredSeriesEpisodes); - Db.InsertMany(unairedEpisodes); - } - - private void GivenMonitoredFilterExpression() - { - _pagingSpec.FilterExpression = e => e.Monitored == true && e.Series.Monitored == true; - } - - private void GivenUnmonitoredFilterExpression() - { - _pagingSpec.FilterExpression = e => e.Monitored == false || e.Series.Monitored == false; - } - - [Test] - public void should_get_monitored_episodes() - { - GivenMonitoredFilterExpression(); - - var episodes = Subject.EpisodesWithoutFiles(_pagingSpec, false); - - episodes.Records.Should().HaveCount(1); - } - - [Test] - [Ignore("Specials not implemented")] - public void should_get_episode_including_specials() - { - var episodes = Subject.EpisodesWithoutFiles(_pagingSpec, true); - - episodes.Records.Should().HaveCount(2); - } - - [Test] - public void should_not_include_unmonitored_episodes() - { - GivenMonitoredFilterExpression(); - - var episodes = Subject.EpisodesWithoutFiles(_pagingSpec, false); - - episodes.Records.Should().NotContain(e => e.Monitored == false); - } - - [Test] - public void should_not_contain_unmonitored_series() - { - GivenMonitoredFilterExpression(); - - var episodes = Subject.EpisodesWithoutFiles(_pagingSpec, false); - - episodes.Records.Should().NotContain(e => e.SeriesId == _unmonitoredSeries.Id); - } - - [Test] - public void should_not_return_unaired() - { - var episodes = Subject.EpisodesWithoutFiles(_pagingSpec, false); - - episodes.TotalRecords.Should().Be(4); - } - - [Test] - public void should_not_return_episodes_on_air() - { - var onAirEpisode = Builder.CreateNew() - .With(e => e.Id = 0) - .With(e => e.SeriesId = _monitoredSeries.Id) - .With(e => e.EpisodeFileId = 0) - .With(e => e.AirDateUtc = DateTime.Now.AddMinutes(-15)) - .With(e => e.Monitored = true) - .Build(); - - Db.Insert(onAirEpisode); - - var episodes = Subject.EpisodesWithoutFiles(_pagingSpec, false); - - episodes.TotalRecords.Should().Be(4); - episodes.Records.Where(e => e.Id == onAirEpisode.Id).Should().BeEmpty(); - } - } -} diff --git a/src/NzbDrone.Core.Test/TvTests/EpisodeRepositoryTests/FindEpisodeFixture.cs b/src/NzbDrone.Core.Test/TvTests/EpisodeRepositoryTests/FindEpisodeFixture.cs deleted file mode 100644 index 29730bb60..000000000 --- a/src/NzbDrone.Core.Test/TvTests/EpisodeRepositoryTests/FindEpisodeFixture.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Linq; -using FizzWare.NBuilder; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Test.TvTests.EpisodeRepositoryTests -{ - [TestFixture] - public class FindEpisodeFixture : DbTest - { - private Episode _episode1; - private Episode _episode2; - - [SetUp] - public void Setup() - { - _episode1 = Builder.CreateNew() - .With(e => e.SeriesId = 1) - .With(e => e.SeasonNumber = 1) - .With(e => e.SceneSeasonNumber = 2) - .With(e => e.EpisodeNumber = 3) - .With(e => e.AbsoluteEpisodeNumber = 3) - .With(e => e.SceneEpisodeNumber = 4) - .BuildNew(); - - _episode2 = Builder.CreateNew() - .With(e => e.SeriesId = 1) - .With(e => e.SeasonNumber = 1) - .With(e => e.SceneSeasonNumber = 2) - .With(e => e.EpisodeNumber = 4) - .With(e => e.SceneEpisodeNumber = 4) - .BuildNew(); - - _episode1 = Db.Insert(_episode1); - } - - [Test] - public void should_find_episode_by_scene_numbering() - { - Subject.FindEpisodesBySceneNumbering(_episode1.SeriesId, _episode1.SceneSeasonNumber.Value, _episode1.SceneEpisodeNumber.Value) - .First() - .Id - .Should() - .Be(_episode1.Id); - } - - [Test] - public void should_find_episode_by_standard_numbering() - { - Subject.Find(_episode1.SeriesId, _episode1.SeasonNumber, _episode1.EpisodeNumber) - .Id - .Should() - .Be(_episode1.Id); - } - - [Test] - public void should_not_find_episode_that_does_not_exist() - { - Subject.Find(_episode1.SeriesId, _episode1.SeasonNumber + 1, _episode1.EpisodeNumber) - .Should() - .BeNull(); - } - - [Test] - public void should_find_episode_by_absolute_numbering() - { - Subject.Find(_episode1.SeriesId, _episode1.AbsoluteEpisodeNumber.Value) - .Id - .Should() - .Be(_episode1.Id); - } - - [Test] - public void should_return_multiple_episode_if_multiple_match_by_scene_numbering() - { - _episode2 = Db.Insert(_episode2); - - Subject.FindEpisodesBySceneNumbering(_episode1.SeriesId, _episode1.SceneSeasonNumber.Value, _episode1.SceneEpisodeNumber.Value) - .Should() - .HaveCount(2); - } - } -} diff --git a/src/NzbDrone.Core.Test/TvTests/EpisodeServiceTests/FindEpisodeByTitleFixture.cs b/src/NzbDrone.Core.Test/TvTests/EpisodeServiceTests/FindEpisodeByTitleFixture.cs deleted file mode 100644 index 46fafec3c..000000000 --- a/src/NzbDrone.Core.Test/TvTests/EpisodeServiceTests/FindEpisodeByTitleFixture.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using FizzWare.NBuilder; -using FluentAssertions; -using Moq; -using NUnit.Framework; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Test.TvTests.EpisodeServiceTests -{ - [TestFixture] - public class FindEpisodeByTitleFixture : CoreTest - { - private List _episodes; - - [SetUp] - public void Setup() - { - _episodes = Builder.CreateListOfSize(5) - .Build() - .ToList(); - } - - private void GivenEpisodesWithTitles(params string[] titles) - { - for (int i = 0; i < titles.Count(); i++) - { - _episodes[i].Title = titles[i]; - } - - Mocker.GetMock() - .Setup(s => s.GetEpisodes(It.IsAny(), It.IsAny())) - .Returns(_episodes); - } - - [Test] - public void should_find_episode_by_title() - { - const string expectedTitle = "A Journey to the Highlands"; - GivenEpisodesWithTitles(expectedTitle); - - Subject.FindEpisodeByTitle(1, 1, "Downton.Abbey.A.Journey.To.The.Highlands.720p.BluRay.x264-aAF") - .Title - .Should() - .Be(expectedTitle); - } - - [Test] - public void should_prefer_longer_match() - { - const string expectedTitle = "Inside The Walking Dead: Walker University"; - GivenEpisodesWithTitles("Inside The Walking Dead", expectedTitle); - - Subject.FindEpisodeByTitle(1, 1, "The.Walking.Dead.S04.Special.Inside.The.Walking.Dead.Walker.University.720p.HDTV.x264-W4F") - .Title - .Should() - .Be(expectedTitle); - } - - [Test] - public void should_return_null_when_no_match_is_found() - { - GivenEpisodesWithTitles(); - - Subject.FindEpisodeByTitle(1, 1, "The.Walking.Dead.S04.Special.Inside.The.Walking.Dead.Walker.University.720p.HDTV.x264-W4F") - .Should() - .BeNull(); - } - } -} diff --git a/src/NzbDrone.Core.Test/TvTests/EpisodeServiceTests/HandleEpisodeFileDeletedFixture.cs b/src/NzbDrone.Core.Test/TvTests/EpisodeServiceTests/HandleEpisodeFileDeletedFixture.cs deleted file mode 100644 index 96b5002ff..000000000 --- a/src/NzbDrone.Core.Test/TvTests/EpisodeServiceTests/HandleEpisodeFileDeletedFixture.cs +++ /dev/null @@ -1,123 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using FizzWare.NBuilder; -using Moq; -using NUnit.Framework; -using NzbDrone.Core.Configuration; -using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.MediaFiles.Events; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Test.TvTests.EpisodeServiceTests -{ - [TestFixture] - public class HandleEpisodeFileDeletedFixture : CoreTest - { - private EpisodeFile _episodeFile; - private List _episodes; - - [SetUp] - public void Setup() - { - _episodeFile = Builder - .CreateNew() - .Build(); - } - - private void GivenSingleEpisodeFile() - { - _episodes = Builder - .CreateListOfSize(1) - .All() - .With(e => e.Monitored = true) - .Build() - .ToList(); - - Mocker.GetMock() - .Setup(s => s.GetEpisodeByFileId(_episodeFile.Id)) - .Returns(_episodes); - } - - private void GivenMultiEpisodeFile() - { - _episodes = Builder - .CreateListOfSize(2) - .All() - .With(e => e.Monitored = true) - .Build() - .ToList(); - - Mocker.GetMock() - .Setup(s => s.GetEpisodeByFileId(_episodeFile.Id)) - .Returns(_episodes); - } - - [Test] - public void should_set_EpisodeFileId_to_zero() - { - GivenSingleEpisodeFile(); - - Subject.Handle(new EpisodeFileDeletedEvent(_episodeFile, DeleteMediaFileReason.MissingFromDisk)); - - Mocker.GetMock() - .Verify(v => v.Update(It.Is(e => e.EpisodeFileId == 0)), Times.Once()); - } - - [Test] - public void should_update_each_episode_for_file() - { - GivenMultiEpisodeFile(); - - Subject.Handle(new EpisodeFileDeletedEvent(_episodeFile, DeleteMediaFileReason.MissingFromDisk)); - - Mocker.GetMock() - .Verify(v => v.Update(It.Is(e => e.EpisodeFileId == 0)), Times.Exactly(2)); - } - - [Test] - public void should_set_monitored_to_false_if_autoUnmonitor_is_true_and_is_not_for_an_upgrade() - { - GivenSingleEpisodeFile(); - - Mocker.GetMock() - .SetupGet(s => s.AutoUnmonitorPreviouslyDownloadedEpisodes) - .Returns(true); - - Subject.Handle(new EpisodeFileDeletedEvent(_episodeFile, DeleteMediaFileReason.MissingFromDisk)); - - Mocker.GetMock() - .Verify(v => v.Update(It.Is(e => e.Monitored == false)), Times.Once()); - } - - [Test] - public void should_leave_monitored_to_true_if_autoUnmonitor_is_false() - { - GivenSingleEpisodeFile(); - - Mocker.GetMock() - .SetupGet(s => s.AutoUnmonitorPreviouslyDownloadedEpisodes) - .Returns(false); - - Subject.Handle(new EpisodeFileDeletedEvent(_episodeFile, DeleteMediaFileReason.Upgrade)); - - Mocker.GetMock() - .Verify(v => v.Update(It.Is(e => e.Monitored == true)), Times.Once()); - } - - [Test] - public void should_leave_monitored_to_true_if_autoUnmonitor_is_true_and_is_for_an_upgrade() - { - GivenSingleEpisodeFile(); - - Mocker.GetMock() - .SetupGet(s => s.AutoUnmonitorPreviouslyDownloadedEpisodes) - .Returns(true); - - Subject.Handle(new EpisodeFileDeletedEvent(_episodeFile, DeleteMediaFileReason.Upgrade)); - - Mocker.GetMock() - .Verify(v => v.Update(It.Is(e => e.Monitored == true)), Times.Once()); - } - } -} diff --git a/src/NzbDrone.Core.Test/TvTests/MoveSeriesServiceFixture.cs b/src/NzbDrone.Core.Test/TvTests/MoveSeriesServiceFixture.cs deleted file mode 100644 index 528816c99..000000000 --- a/src/NzbDrone.Core.Test/TvTests/MoveSeriesServiceFixture.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System.IO; -using FizzWare.NBuilder; -using Moq; -using NUnit.Framework; -using NzbDrone.Common.Disk; -using NzbDrone.Core.Organizer; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; -using NzbDrone.Core.Tv.Commands; -using NzbDrone.Test.Common; - -namespace NzbDrone.Core.Test.TvTests -{ - [TestFixture] - public class MoveSeriesServiceFixture : CoreTest - { - private Series _series; - private MoveSeriesCommand _command; - - [SetUp] - public void Setup() - { - _series = Builder - .CreateNew() - .Build(); - - _command = new MoveSeriesCommand - { - SeriesId = 1, - SourcePath = @"C:\Test\TV\Series".AsOsAgnostic(), - DestinationPath = @"C:\Test\TV2\Series".AsOsAgnostic() - }; - - Mocker.GetMock() - .Setup(s => s.GetSeries(It.IsAny())) - .Returns(_series); - } - - private void GivenFailedMove() - { - Mocker.GetMock() - .Setup(s => s.TransferFolder(It.IsAny(), It.IsAny(), TransferMode.Move, true)) - .Throws(); - } - - [Test] - public void should_log_error_when_move_throws_an_exception() - { - GivenFailedMove(); - - Assert.Throws(() => Subject.Execute(_command)); - - ExceptionVerification.ExpectedErrors(1); - } - - [Test] - public void should_no_update_series_path_on_error() - { - GivenFailedMove(); - - Assert.Throws(() => Subject.Execute(_command)); - - ExceptionVerification.ExpectedErrors(1); - - Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.IsAny()), Times.Never()); - } - - [Test] - public void should_build_new_path_when_root_folder_is_provided() - { - _command.DestinationPath = null; - _command.DestinationRootFolder = @"C:\Test\TV3".AsOsAgnostic(); - - var expectedPath = @"C:\Test\TV3\Series".AsOsAgnostic(); - - Mocker.GetMock() - .Setup(s => s.GetSeriesFolder(It.IsAny(), null)) - .Returns("Series"); - - Subject.Execute(_command); - - Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.Is(s => s.Path == expectedPath)), Times.Once()); - } - - [Test] - public void should_use_destination_path_if_destination_root_folder_is_blank() - { - Subject.Execute(_command); - - Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.Is(s => s.Path == _command.DestinationPath)), Times.Once()); - - Mocker.GetMock() - .Verify(v => v.GetSeriesFolder(It.IsAny(), null), Times.Never()); - } - } -} diff --git a/src/NzbDrone.Core.Test/TvTests/RefreshEpisodeServiceFixture.cs b/src/NzbDrone.Core.Test/TvTests/RefreshEpisodeServiceFixture.cs deleted file mode 100644 index 592b56dc3..000000000 --- a/src/NzbDrone.Core.Test/TvTests/RefreshEpisodeServiceFixture.cs +++ /dev/null @@ -1,397 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using FizzWare.NBuilder; -using FluentAssertions; -using Moq; -using NUnit.Framework; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.MetadataSource.SkyHook; -using NzbDrone.Core.Tv; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.TvTests -{ - [TestFixture] - public class RefreshEpisodeServiceFixture : CoreTest - { - private List _insertedEpisodes; - private List _updatedEpisodes; - private List _deletedEpisodes; - private Tuple> _gameOfThrones; - - [TestFixtureSetUp] - public void TestFixture() - { - UseRealHttp(); - - _gameOfThrones = Mocker.Resolve().GetSeriesInfo(121361);//Game of thrones - - // Remove specials. - _gameOfThrones.Item2.RemoveAll(v => v.SeasonNumber == 0); - } - - private List GetEpisodes() - { - return _gameOfThrones.Item2.JsonClone(); - } - - private Series GetSeries() - { - var series = _gameOfThrones.Item1.JsonClone(); - series.Seasons = new List(); - - return series; - } - - private Series GetAnimeSeries() - { - var series = Builder.CreateNew().Build(); - series.SeriesType = SeriesTypes.Anime; - series.Seasons = new List(); - - return series; - } - - [SetUp] - public void Setup() - { - _insertedEpisodes = new List(); - _updatedEpisodes = new List(); - _deletedEpisodes = new List(); - - Mocker.GetMock().Setup(c => c.InsertMany(It.IsAny>())) - .Callback>(e => _insertedEpisodes = e); - - - Mocker.GetMock().Setup(c => c.UpdateMany(It.IsAny>())) - .Callback>(e => _updatedEpisodes = e); - - - Mocker.GetMock().Setup(c => c.DeleteMany(It.IsAny>())) - .Callback>(e => _deletedEpisodes = e); - } - - [Test] - public void should_create_all_when_no_existing_episodes() - { - Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) - .Returns(new List()); - - Subject.RefreshEpisodeInfo(GetSeries(), GetEpisodes()); - - _insertedEpisodes.Should().HaveSameCount(GetEpisodes()); - _updatedEpisodes.Should().BeEmpty(); - _deletedEpisodes.Should().BeEmpty(); - } - - [Test] - public void should_update_all_when_all_existing_episodes() - { - Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) - .Returns(GetEpisodes()); - - Subject.RefreshEpisodeInfo(GetSeries(), GetEpisodes()); - - _insertedEpisodes.Should().BeEmpty(); - _updatedEpisodes.Should().HaveSameCount(GetEpisodes()); - _deletedEpisodes.Should().BeEmpty(); - } - - [Test] - public void should_delete_all_when_all_existing_episodes_are_gone_from_datasource() - { - Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) - .Returns(GetEpisodes()); - - Subject.RefreshEpisodeInfo(GetSeries(), new List()); - - _insertedEpisodes.Should().BeEmpty(); - _updatedEpisodes.Should().BeEmpty(); - _deletedEpisodes.Should().HaveSameCount(GetEpisodes()); - } - - [Test] - public void should_delete_duplicated_episodes_based_on_season_episode_number() - { - var duplicateEpisodes = GetEpisodes().Skip(5).Take(2).ToList(); - - Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) - .Returns(GetEpisodes().Union(duplicateEpisodes).ToList()); - - Subject.RefreshEpisodeInfo(GetSeries(), GetEpisodes()); - - _insertedEpisodes.Should().BeEmpty(); - _updatedEpisodes.Should().HaveSameCount(GetEpisodes()); - _deletedEpisodes.Should().HaveSameCount(duplicateEpisodes); - } - - [Test] - public void should_not_change_monitored_status_for_existing_episodes() - { - var series = GetSeries(); - series.Seasons = new List(); - series.Seasons.Add(new Season { SeasonNumber = 1, Monitored = false }); - - var episodes = GetEpisodes(); - - episodes.ForEach(e => e.Monitored = true); - - Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) - .Returns(episodes); - - Subject.RefreshEpisodeInfo(series, GetEpisodes()); - - _updatedEpisodes.Should().HaveSameCount(GetEpisodes()); - _updatedEpisodes.Should().OnlyContain(e => e.Monitored == true); - } - - [Test] - public void should_remove_duplicate_remote_episodes_before_processing() - { - Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) - .Returns(new List()); - - var episodes = Builder.CreateListOfSize(5) - .TheFirst(2) - .With(e => e.SeasonNumber = 1) - .With(e => e.EpisodeNumber = 1) - .Build() - .ToList(); - - Subject.RefreshEpisodeInfo(GetSeries(), episodes); - - _insertedEpisodes.Should().HaveCount(episodes.Count - 1); - _updatedEpisodes.Should().BeEmpty(); - _deletedEpisodes.Should().BeEmpty(); - } - - [Test] - public void should_set_absolute_episode_number_for_anime() - { - var episodes = Builder.CreateListOfSize(3).Build().ToList(); - - Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) - .Returns(new List()); - - Subject.RefreshEpisodeInfo(GetAnimeSeries(), episodes); - - _insertedEpisodes.All(e => e.AbsoluteEpisodeNumber.HasValue).Should().BeTrue(); - _updatedEpisodes.Should().BeEmpty(); - _deletedEpisodes.Should().BeEmpty(); - } - - [Test] - public void should_set_absolute_episode_number_even_if_not_previously_set_for_anime() - { - var episodes = Builder.CreateListOfSize(3).Build().ToList(); - - var existingEpisodes = episodes.JsonClone(); - existingEpisodes.ForEach(e => e.AbsoluteEpisodeNumber = null); - - Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) - .Returns(existingEpisodes); - - Subject.RefreshEpisodeInfo(GetAnimeSeries(), episodes); - - _insertedEpisodes.Should().BeEmpty(); - _updatedEpisodes.All(e => e.AbsoluteEpisodeNumber.HasValue).Should().BeTrue(); - _deletedEpisodes.Should().BeEmpty(); - } - - [Test] - public void should_get_new_season_and_episode_numbers_when_absolute_episode_number_match_found() - { - const int expectedSeasonNumber = 10; - const int expectedEpisodeNumber = 5; - const int expectedAbsoluteNumber = 3; - - var episode = Builder.CreateNew() - .With(e => e.SeasonNumber = expectedSeasonNumber) - .With(e => e.EpisodeNumber = expectedEpisodeNumber) - .With(e => e.AbsoluteEpisodeNumber = expectedAbsoluteNumber) - .Build(); - - var existingEpisode = episode.JsonClone(); - existingEpisode.SeasonNumber = 1; - existingEpisode.EpisodeNumber = 1; - existingEpisode.AbsoluteEpisodeNumber = expectedAbsoluteNumber; - - Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) - .Returns(new List{ existingEpisode }); - - Subject.RefreshEpisodeInfo(GetAnimeSeries(), new List { episode }); - - _insertedEpisodes.Should().BeEmpty(); - _deletedEpisodes.Should().BeEmpty(); - - _updatedEpisodes.First().SeasonNumber.Should().Be(expectedSeasonNumber); - _updatedEpisodes.First().EpisodeNumber.Should().Be(expectedEpisodeNumber); - _updatedEpisodes.First().AbsoluteEpisodeNumber.Should().Be(expectedAbsoluteNumber); - } - - [Test] - public void should_prefer_absolute_match_over_season_and_epsiode_match() - { - var episodes = Builder.CreateListOfSize(2) - .Build() - .ToList(); - - episodes[0].AbsoluteEpisodeNumber = null; - episodes[0].SeasonNumber.Should().NotBe(episodes[1].SeasonNumber); - episodes[0].EpisodeNumber.Should().NotBe(episodes[1].EpisodeNumber); - - var existingEpisode = new Episode - { - SeasonNumber = episodes[0].SeasonNumber, - EpisodeNumber = episodes[0].EpisodeNumber, - AbsoluteEpisodeNumber = episodes[1].AbsoluteEpisodeNumber - }; - - Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) - .Returns(new List { existingEpisode }); - - Subject.RefreshEpisodeInfo(GetAnimeSeries(), episodes); - - _updatedEpisodes.First().SeasonNumber.Should().Be(episodes[1].SeasonNumber); - _updatedEpisodes.First().EpisodeNumber.Should().Be(episodes[1].EpisodeNumber); - _updatedEpisodes.First().AbsoluteEpisodeNumber.Should().Be(episodes[1].AbsoluteEpisodeNumber); - } - - [Test] - public void should_ignore_episodes_with_no_absolute_episode_in_distinct_by_absolute() - { - var episodes = Builder.CreateListOfSize(10) - .Build() - .ToList(); - - episodes[0].AbsoluteEpisodeNumber = null; - episodes[1].AbsoluteEpisodeNumber = null; - episodes[2].AbsoluteEpisodeNumber = null; - episodes[3].AbsoluteEpisodeNumber = null; - episodes[4].AbsoluteEpisodeNumber = null; - - Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) - .Returns(new List()); - - Subject.RefreshEpisodeInfo(GetAnimeSeries(), episodes); - - _insertedEpisodes.Should().HaveCount(episodes.Count); - - } - - [Test] - public void should_override_empty_airdate_for_direct_to_dvd() - { - var series = GetSeries(); - series.Status = SeriesStatusType.Ended; - - var episodes = Builder.CreateListOfSize(10) - .All() - .With(v => v.AirDateUtc = null) - .BuildListOfNew(); - - Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) - .Returns(new List()); - - List updateEpisodes = null; - Mocker.GetMock().Setup(c => c.InsertMany(It.IsAny>())) - .Callback>(c => updateEpisodes = c); - - Subject.RefreshEpisodeInfo(series, episodes); - - updateEpisodes.Should().NotBeNull(); - updateEpisodes.Should().NotBeEmpty(); - updateEpisodes.All(v => v.AirDateUtc.HasValue).Should().BeTrue(); - } - - [Test] - public void should_use_tba_for_episode_title_when_null() - { - Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) - .Returns(new List()); - - var episodes = Builder.CreateListOfSize(1) - .All() - .With(e => e.Title = null) - .Build() - .ToList(); - - Subject.RefreshEpisodeInfo(GetSeries(), episodes); - - _insertedEpisodes.First().Title.Should().Be("TBA"); - } - - [Test] - public void should_update_air_date_when_multiple_episodes_air_on_the_same_day() - { - Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) - .Returns(new List()); - - var series = GetSeries(); - - var episodes = Builder.CreateListOfSize(2) - .All() - .With(e => e.SeasonNumber = 1) - .With(e => e.AirDate = DateTime.Now.ToShortDateString()) - .With(e => e.AirDateUtc = DateTime.UtcNow) - .Build() - .ToList(); - - Subject.RefreshEpisodeInfo(series, episodes); - - _insertedEpisodes.First().AirDateUtc.Value.ToString("s").Should().Be(episodes.First().AirDateUtc.Value.ToString("s")); - _insertedEpisodes.Last().AirDateUtc.Value.ToString("s").Should().Be(episodes.First().AirDateUtc.Value.AddMinutes(series.Runtime).ToString("s")); - } - - [Test] - public void should_not_update_air_date_when_multiple_episodes_air_on_the_same_day_for_netflix() - { - Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) - .Returns(new List()); - - var series = GetSeries(); - series.Network = "Netflix"; - - var episodes = Builder.CreateListOfSize(2) - .All() - .With(e => e.SeasonNumber = 1) - .With(e => e.AirDate = DateTime.Now.ToShortDateString()) - .With(e => e.AirDateUtc = DateTime.UtcNow) - .Build() - .ToList(); - - Subject.RefreshEpisodeInfo(series, episodes); - - _insertedEpisodes.Should().OnlyContain(e => e.AirDateUtc.Value.ToString("s") == episodes.First().AirDateUtc.Value.ToString("s")); - } - - [Test] - public void should_prefer_regular_season_when_absolute_numbers_conflict() - { - var episodes = Builder.CreateListOfSize(2) - .Build() - .ToList(); - - episodes[0].AbsoluteEpisodeNumber = episodes[1].AbsoluteEpisodeNumber; - episodes[0].SeasonNumber = 0; - episodes[0].EpisodeNumber.Should().NotBe(episodes[1].EpisodeNumber); - - var existingEpisode = new Episode - { - SeasonNumber = episodes[0].SeasonNumber, - EpisodeNumber = episodes[0].EpisodeNumber, - AbsoluteEpisodeNumber = episodes[1].AbsoluteEpisodeNumber - }; - - Mocker.GetMock().Setup(c => c.GetEpisodeBySeries(It.IsAny())) - .Returns(new List { existingEpisode }); - - Subject.RefreshEpisodeInfo(GetAnimeSeries(), episodes); - - _updatedEpisodes.First().SeasonNumber.Should().Be(episodes[1].SeasonNumber); - _updatedEpisodes.First().EpisodeNumber.Should().Be(episodes[1].EpisodeNumber); - _updatedEpisodes.First().AbsoluteEpisodeNumber.Should().Be(episodes[1].AbsoluteEpisodeNumber); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/TvTests/RefreshSeriesServiceFixture.cs b/src/NzbDrone.Core.Test/TvTests/RefreshSeriesServiceFixture.cs deleted file mode 100644 index f441496cd..000000000 --- a/src/NzbDrone.Core.Test/TvTests/RefreshSeriesServiceFixture.cs +++ /dev/null @@ -1,184 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using FizzWare.NBuilder; -using Moq; -using NUnit.Framework; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Exceptions; -using NzbDrone.Core.MetadataSource; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; -using NzbDrone.Core.Tv.Commands; -using NzbDrone.Test.Common; - -namespace NzbDrone.Core.Test.TvTests -{ - [TestFixture] - public class RefreshSeriesServiceFixture : CoreTest - { - private Series _series; - - [SetUp] - public void Setup() - { - var season1 = Builder.CreateNew() - .With(s => s.SeasonNumber = 1) - .Build(); - - _series = Builder.CreateNew() - .With(s => s.Seasons = new List - { - season1 - }) - .Build(); - - Mocker.GetMock() - .Setup(s => s.GetSeries(_series.Id)) - .Returns(_series); - - Mocker.GetMock() - .Setup(s => s.GetSeriesInfo(It.IsAny())) - .Callback(p => { throw new SeriesNotFoundException(p); }); - } - - private void GivenNewSeriesInfo(Series series) - { - Mocker.GetMock() - .Setup(s => s.GetSeriesInfo(_series.TvdbId)) - .Returns(new Tuple>(series, new List())); - } - - [Test] - public void should_monitor_new_seasons_automatically() - { - var newSeriesInfo = _series.JsonClone(); - newSeriesInfo.Seasons.Add(Builder.CreateNew() - .With(s => s.SeasonNumber = 2) - .Build()); - - GivenNewSeriesInfo(newSeriesInfo); - - 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))); - } - - [Test] - public void should_not_monitor_new_special_season_automatically() - { - var series = _series.JsonClone(); - series.Seasons.Add(Builder.CreateNew() - .With(s => s.SeasonNumber = 0) - .Build()); - - GivenNewSeriesInfo(series); - - 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))); - } - - [Test] - public void should_update_tvrage_id_if_changed() - { - var newSeriesInfo = _series.JsonClone(); - newSeriesInfo.TvRageId = _series.TvRageId + 1; - - GivenNewSeriesInfo(newSeriesInfo); - - Subject.Execute(new RefreshSeriesCommand(_series.Id)); - - Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.Is(s => s.TvRageId == newSeriesInfo.TvRageId))); - } - - [Test] - public void should_update_tvmaze_id_if_changed() - { - var newSeriesInfo = _series.JsonClone(); - newSeriesInfo.TvMazeId = _series.TvMazeId + 1; - - GivenNewSeriesInfo(newSeriesInfo); - - Subject.Execute(new RefreshSeriesCommand(_series.Id)); - - Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.Is(s => s.TvMazeId == newSeriesInfo.TvMazeId))); - } - - [Test] - public void should_log_error_if_tvdb_id_not_found() - { - Subject.Execute(new RefreshSeriesCommand(_series.Id)); - - Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.IsAny()), Times.Never()); - - ExceptionVerification.ExpectedErrors(1); - } - - [Test] - public void should_update_if_tvdb_id_changed() - { - var newSeriesInfo = _series.JsonClone(); - newSeriesInfo.TvdbId = _series.TvdbId + 1; - - GivenNewSeriesInfo(newSeriesInfo); - - Subject.Execute(new RefreshSeriesCommand(_series.Id)); - - Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.Is(s => s.TvdbId == newSeriesInfo.TvdbId))); - - ExceptionVerification.ExpectedWarns(1); - } - - [Test] - public void should_not_throw_if_duplicate_season_is_in_existing_info() - { - var newSeriesInfo = _series.JsonClone(); - newSeriesInfo.Seasons.Add(Builder.CreateNew() - .With(s => s.SeasonNumber = 2) - .Build()); - - _series.Seasons.Add(Builder.CreateNew() - .With(s => s.SeasonNumber = 2) - .Build()); - - _series.Seasons.Add(Builder.CreateNew() - .With(s => s.SeasonNumber = 2) - .Build()); - - GivenNewSeriesInfo(newSeriesInfo); - - Subject.Execute(new RefreshSeriesCommand(_series.Id)); - - Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.Is(s => s.Seasons.Count == 2))); - } - - [Test] - public void should_filter_duplicate_seasons() - { - var newSeriesInfo = _series.JsonClone(); - newSeriesInfo.Seasons.Add(Builder.CreateNew() - .With(s => s.SeasonNumber = 2) - .Build()); - - newSeriesInfo.Seasons.Add(Builder.CreateNew() - .With(s => s.SeasonNumber = 2) - .Build()); - - GivenNewSeriesInfo(newSeriesInfo); - - Subject.Execute(new RefreshSeriesCommand(_series.Id)); - - Mocker.GetMock() - .Verify(v => v.UpdateSeries(It.Is(s => s.Seasons.Count == 2))); - - } - } -} diff --git a/src/NzbDrone.Core.Test/TvTests/SeriesRepositoryTests/SeriesRepositoryFixture.cs b/src/NzbDrone.Core.Test/TvTests/SeriesRepositoryTests/SeriesRepositoryFixture.cs deleted file mode 100644 index bbd18e7e1..000000000 --- a/src/NzbDrone.Core.Test/TvTests/SeriesRepositoryTests/SeriesRepositoryFixture.cs +++ /dev/null @@ -1,40 +0,0 @@ -using FizzWare.NBuilder; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Profiles; -using NzbDrone.Core.Qualities; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Test.TvTests.SeriesRepositoryTests -{ - [TestFixture] - - public class SeriesRepositoryFixture : DbTest - { - [Test] - public void should_lazyload_quality_profile() - { - var profile = new Profile - { - Items = Qualities.QualityFixture.GetDefaultQualities(Quality.Bluray1080p, Quality.DVD, Quality.HDTV720p), - - Cutoff = Quality.Bluray1080p, - Name = "TestProfile" - }; - - - Mocker.Resolve().Insert(profile); - - var series = Builder.CreateNew().BuildNew(); - series.ProfileId = profile.Id; - - Subject.Insert(series); - - - StoredModel.Profile.Should().NotBeNull(); - - - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/TvTests/SeriesServiceTests/AddSeriesFixture.cs b/src/NzbDrone.Core.Test/TvTests/SeriesServiceTests/AddSeriesFixture.cs deleted file mode 100644 index cdc1041e7..000000000 --- a/src/NzbDrone.Core.Test/TvTests/SeriesServiceTests/AddSeriesFixture.cs +++ /dev/null @@ -1,40 +0,0 @@ -using FizzWare.NBuilder; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Organizer; -using NzbDrone.Core.Tv; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Tv.Events; - -namespace NzbDrone.Core.Test.TvTests.SeriesServiceTests -{ - [TestFixture] - public class AddSeriesFixture : CoreTest - { - private Series fakeSeries; - - [SetUp] - public void Setup() - { - fakeSeries = Builder.CreateNew().Build(); - } - - [Test] - public void series_added_event_should_have_proper_path() - { - fakeSeries.Path = null; - fakeSeries.RootFolderPath = @"C:\Test\TV"; - - Mocker.GetMock() - .Setup(s => s.GetSeriesFolder(fakeSeries, null)) - .Returns(fakeSeries.Title); - - var series = Subject.AddSeries(fakeSeries); - - series.Path.Should().NotBeNull(); - - VerifyEventPublished(); - } - - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/TvTests/SeriesServiceTests/UpdateMultipleSeriesFixture.cs b/src/NzbDrone.Core.Test/TvTests/SeriesServiceTests/UpdateMultipleSeriesFixture.cs deleted file mode 100644 index 0fa33a68f..000000000 --- a/src/NzbDrone.Core.Test/TvTests/SeriesServiceTests/UpdateMultipleSeriesFixture.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using FizzWare.NBuilder; -using FluentAssertions; -using Moq; -using NUnit.Framework; -using NzbDrone.Core.Tv; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Test.Common; - -namespace NzbDrone.Core.Test.TvTests.SeriesServiceTests -{ - [TestFixture] - public class UpdateMultipleSeriesFixture : CoreTest - { - private List _series; - - [SetUp] - public void Setup() - { - _series = Builder.CreateListOfSize(5) - .All() - .With(s => s.ProfileId = 1) - .With(s => s.Monitored) - .With(s => s.SeasonFolder) - .With(s => s.Path = @"C:\Test\name".AsOsAgnostic()) - .With(s => s.RootFolderPath = "") - .Build().ToList(); - } - - [Test] - public void should_call_repo_updateMany() - { - Subject.UpdateSeries(_series); - - Mocker.GetMock().Verify(v => v.UpdateMany(_series), Times.Once()); - } - - [Test] - public void should_update_path_when_rootFolderPath_is_supplied() - { - var newRoot = @"C:\Test\TV2".AsOsAgnostic(); - _series.ForEach(s => s.RootFolderPath = newRoot); - - Subject.UpdateSeries(_series).ForEach(s => s.Path.Should().StartWith(newRoot)); - } - - [Test] - public void should_not_update_path_when_rootFolderPath_is_empty() - { - Subject.UpdateSeries(_series).ForEach(s => - { - var expectedPath = _series.Single(ser => ser.Id == s.Id).Path; - s.Path.Should().Be(expectedPath); - }); - } - - [Test] - public void should_be_able_to_update_many_series() - { - var series = Builder.CreateListOfSize(50) - .All() - .With(s => s.Path = (@"C:\Test\TV\" + s.Path).AsOsAgnostic()) - .Build() - .ToList(); - - var newRoot = @"C:\Test\TV2".AsOsAgnostic(); - series.ForEach(s => s.RootFolderPath = newRoot); - - Subject.UpdateSeries(series); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/TvTests/SeriesServiceTests/UpdateSeriesFixture.cs b/src/NzbDrone.Core.Test/TvTests/SeriesServiceTests/UpdateSeriesFixture.cs deleted file mode 100644 index 23f77223c..000000000 --- a/src/NzbDrone.Core.Test/TvTests/SeriesServiceTests/UpdateSeriesFixture.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using FizzWare.NBuilder; -using Moq; -using NUnit.Framework; -using NzbDrone.Core.Tv; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.TvTests.SeriesServiceTests -{ - [TestFixture] - public class UpdateSeriesFixture : CoreTest - { - private Series _fakeSeries; - private Series _existingSeries; - - [SetUp] - public void Setup() - { - _fakeSeries = Builder.CreateNew().Build(); - _existingSeries = Builder.CreateNew().Build(); - - _fakeSeries.Seasons = new List - { - new Season{ SeasonNumber = 1, Monitored = true }, - new Season{ SeasonNumber = 2, Monitored = true } - }; - - _existingSeries.Seasons = new List - { - new Season{ SeasonNumber = 1, Monitored = true }, - new Season{ SeasonNumber = 2, Monitored = true } - }; - } - - private void GivenExistingSeries() - { - Mocker.GetMock() - .Setup(s => s.Get(It.IsAny())) - .Returns(_existingSeries); - } - - [Test] - public void should_not_update_episodes_if_season_hasnt_changed() - { - GivenExistingSeries(); - - Subject.UpdateSeries(_fakeSeries); - - Mocker.GetMock() - .Verify(v => v.SetEpisodeMonitoredBySeason(_fakeSeries.Id, It.IsAny(), It.IsAny()), Times.Never()); - } - - [Test] - public void should_update_series_when_it_changes() - { - GivenExistingSeries(); - var seasonNumber = 1; - var monitored = false; - - _fakeSeries.Seasons.Single(s => s.SeasonNumber == seasonNumber).Monitored = monitored; - - Subject.UpdateSeries(_fakeSeries); - - Mocker.GetMock() - .Verify(v => v.SetEpisodeMonitoredBySeason(_fakeSeries.Id, seasonNumber, monitored), Times.Once()); - - Mocker.GetMock() - .Verify(v => v.SetEpisodeMonitoredBySeason(_fakeSeries.Id, It.IsAny(), It.IsAny()), Times.Once()); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/TvTests/SeriesTitleNormalizerFixture.cs b/src/NzbDrone.Core.Test/TvTests/SeriesTitleNormalizerFixture.cs deleted file mode 100644 index 4355f77e0..000000000 --- a/src/NzbDrone.Core.Test/TvTests/SeriesTitleNormalizerFixture.cs +++ /dev/null @@ -1,29 +0,0 @@ -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Test.TvTests -{ - [TestFixture] - 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")] - public void should_use_precomputed_title(string title, int tvdbId, string expected) - { - SeriesTitleNormalizer.Normalize(title, tvdbId).Should().Be(expected); - } - - [TestCase("2 Broke Girls", "2 broke girls")] - [TestCase("Archer (2009)", "archer 2009")] - [TestCase("The Office (US)", "office us")] - [TestCase("The Mentalist", "mentalist")] - [TestCase("The Good Wife", "good wife")] - [TestCase("The Newsroom (2012)", "newsroom 2012")] - [TestCase("Special Agent Oso", "special agent oso")] - public void should_normalize_title(string title, string expected) - { - SeriesTitleNormalizer.Normalize(title, 0).Should().Be(expected); - } - } -} diff --git a/src/NzbDrone.Core.Test/TvTests/ShouldRefreshSeriesFixture.cs b/src/NzbDrone.Core.Test/TvTests/ShouldRefreshSeriesFixture.cs deleted file mode 100644 index 6fb44c09a..000000000 --- a/src/NzbDrone.Core.Test/TvTests/ShouldRefreshSeriesFixture.cs +++ /dev/null @@ -1,135 +0,0 @@ -using System; -using System.Linq; -using FizzWare.NBuilder; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Tv; -using NzbDrone.Test.Common; - -namespace NzbDrone.Core.Test.TvTests -{ - [TestFixture] - public class ShouldRefreshSeriesFixture : TestBase - { - private Series _series; - - [SetUp] - public void Setup() - { - _series = Builder.CreateNew() - .With(v => v.Status == SeriesStatusType.Continuing) - .Build(); - - Mocker.GetMock() - .Setup(s => s.GetEpisodeBySeries(_series.Id)) - .Returns(Builder.CreateListOfSize(2) - .All() - .With(e => e.AirDateUtc = DateTime.Today.AddDays(-100)) - .Build() - .ToList()); - } - - private void GivenSeriesIsEnded() - { - _series.Status = SeriesStatusType.Ended; - } - - private void GivenSeriesLastRefreshedMonthsAgo() - { - _series.LastInfoSync = DateTime.UtcNow.AddDays(-90); - } - - private void GivenSeriesLastRefreshedYesterday() - { - _series.LastInfoSync = DateTime.UtcNow.AddDays(-1); - } - - private void GivenSeriesLastRefreshedHalfADayAgo() - { - _series.LastInfoSync = DateTime.UtcNow.AddHours(-12); - } - - private void GivenSeriesLastRefreshedRecently() - { - _series.LastInfoSync = DateTime.UtcNow.AddHours(-1); - } - - private void GivenRecentlyAired() - { - Mocker.GetMock() - .Setup(s => s.GetEpisodeBySeries(_series.Id)) - .Returns(Builder.CreateListOfSize(2) - .TheFirst(1) - .With(e => e.AirDateUtc = DateTime.Today.AddDays(-7)) - .TheLast(1) - .With(e => e.AirDateUtc = DateTime.Today.AddDays(-100)) - .Build() - .ToList()); - } - - [Test] - public void should_return_true_if_running_series_last_refreshed_more_than_6_hours_ago() - { - GivenSeriesLastRefreshedHalfADayAgo(); - - Subject.ShouldRefresh(_series).Should().BeTrue(); - } - - [Test] - public void should_return_false_if_running_series_last_refreshed_less_than_6_hours_ago() - { - GivenSeriesLastRefreshedRecently(); - - Subject.ShouldRefresh(_series).Should().BeFalse(); - } - - [Test] - public void should_return_false_if_ended_series_last_refreshed_yesterday() - { - GivenSeriesIsEnded(); - GivenSeriesLastRefreshedYesterday(); - - Subject.ShouldRefresh(_series).Should().BeFalse(); - } - - [Test] - public void should_return_true_if_series_last_refreshed_more_than_30_days_ago() - { - GivenSeriesIsEnded(); - GivenSeriesLastRefreshedMonthsAgo(); - - Subject.ShouldRefresh(_series).Should().BeTrue(); - } - - [Test] - public void should_return_true_if_episode_aired_in_last_30_days() - { - GivenSeriesIsEnded(); - GivenSeriesLastRefreshedYesterday(); - - GivenRecentlyAired(); - - Subject.ShouldRefresh(_series).Should().BeTrue(); - } - - [Test] - public void should_return_false_when_recently_refreshed_ended_show_has_not_aired_for_30_days() - { - GivenSeriesIsEnded(); - GivenSeriesLastRefreshedYesterday(); - - Subject.ShouldRefresh(_series).Should().BeFalse(); - } - - [Test] - public void should_return_false_when_recently_refreshed_ended_show_aired_in_last_30_days() - { - GivenSeriesIsEnded(); - GivenSeriesLastRefreshedRecently(); - - GivenRecentlyAired(); - - Subject.ShouldRefresh(_series).Should().BeFalse(); - } - } -} diff --git a/src/NzbDrone.Core.Test/UpdateTests/UpdatePackageProviderFixture.cs b/src/NzbDrone.Core.Test/UpdateTests/UpdatePackageProviderFixture.cs index 1a2f757ca..c2af4ede7 100644 --- a/src/NzbDrone.Core.Test/UpdateTests/UpdatePackageProviderFixture.cs +++ b/src/NzbDrone.Core.Test/UpdateTests/UpdatePackageProviderFixture.cs @@ -21,34 +21,35 @@ namespace NzbDrone.Core.Test.UpdateTests public void no_update_when_version_higher() { UseRealHttp(); - Subject.GetLatestUpdate("master", new Version(10, 0)).Should().BeNull(); + Subject.GetLatestUpdate("nightly", new Version(10, 0)).Should().BeNull(); } [Test] public void finds_update_when_version_lower() { UseRealHttp(); - Subject.GetLatestUpdate("master", new Version(2, 0)).Should().NotBeNull(); + Subject.GetLatestUpdate("nightly", new Version(0, 2)).Should().NotBeNull(); } [Test] + [Ignore("Ignore until we actually release something on Master")] public void should_get_master_if_branch_doesnt_exit() { UseRealHttp(); - Subject.GetLatestUpdate("invalid_branch", new Version(2, 0)).Should().NotBeNull(); + Subject.GetLatestUpdate("invalid_branch", new Version(0, 2)).Should().NotBeNull(); } [Test] public void should_get_recent_updates() { - const string branch = "master"; + const string branch = "nightly"; UseRealHttp(); - var recent = Subject.GetRecentUpdates(branch, new Version(2, 0)); + var recent = Subject.GetRecentUpdates(branch, new Version(0, 2)); recent.Should().NotBeEmpty(); recent.Should().OnlyContain(c => c.Hash.IsNotNullOrWhiteSpace()); - recent.Should().OnlyContain(c => c.FileName.Contains("Drone.master.2")); + recent.Should().OnlyContain(c => c.FileName.Contains("Lidarr.develop.0")); recent.Should().OnlyContain(c => c.ReleaseDate.Year >= 2014); recent.Where(c => c.Changes != null).Should().OnlyContain(c => c.Changes.New != null); recent.Where(c => c.Changes != null).Should().OnlyContain(c => c.Changes.Fixed != null); diff --git a/src/NzbDrone.Core.Test/UpdateTests/UpdateServiceFixture.cs b/src/NzbDrone.Core.Test/UpdateTests/UpdateServiceFixture.cs index ef29fe797..06fec4ff1 100644 --- a/src/NzbDrone.Core.Test/UpdateTests/UpdateServiceFixture.cs +++ b/src/NzbDrone.Core.Test/UpdateTests/UpdateServiceFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using FluentAssertions; @@ -35,9 +35,9 @@ namespace NzbDrone.Core.Test.UpdateTests { _updatePackage = new UpdatePackage { - FileName = "NzbDrone.develop.2.0.0.0.tar.gz", - Url = "http://download.sonarr.tv/v2/develop/mono/NzbDrone.develop.tar.gz", - Version = new Version("2.0.0.0") + FileName = "Lidarr.develop.0.6.2.883.tar.gz", + Url = "https://github.com/lidarr/Lidarr/releases/download/v0.6.2.883/Lidarr.develop.0.6.2.883.linux.tar.gz", + Version = new Version("0.6.2.883") }; } @@ -45,21 +45,21 @@ namespace NzbDrone.Core.Test.UpdateTests { _updatePackage = new UpdatePackage { - FileName = "NzbDrone.develop.2.0.0.0.zip", - Url = "http://download.sonarr.tv/v2/develop/windows/NzbDrone.develop.zip", - Version = new Version("2.0.0.0") + FileName = "Lidarr.develop.0.6.2.883.zip", + Url = "https://github.com/lidarr/Lidarr/releases/download/v0.6.2.883/Lidarr.develop.0.6.2.883.windows.zip", + Version = new Version("0.6.2.883") }; } Mocker.GetMock().SetupGet(c => c.TempFolder).Returns(TempFolder); - Mocker.GetMock().SetupGet(c => c.StartUpFolder).Returns(@"C:\NzbDrone".AsOsAgnostic); - Mocker.GetMock().SetupGet(c => c.AppDataFolder).Returns(@"C:\ProgramData\NzbDrone".AsOsAgnostic); + Mocker.GetMock().SetupGet(c => c.StartUpFolder).Returns(@"C:\Lidarr".AsOsAgnostic); + Mocker.GetMock().SetupGet(c => c.AppDataFolder).Returns(@"C:\ProgramData\Lidarr".AsOsAgnostic); Mocker.GetMock().Setup(c => c.AvailableUpdate()).Returns(_updatePackage); Mocker.GetMock().Setup(c => c.Verify(It.IsAny(), It.IsAny())).Returns(true); Mocker.GetMock().Setup(c => c.GetCurrentProcess()).Returns(new ProcessInfo { Id = 12 }); - Mocker.GetMock().Setup(c => c.ExecutingApplication).Returns(@"C:\Test\NzbDrone.exe"); + Mocker.GetMock().Setup(c => c.ExecutingApplication).Returns(@"C:\Test\Lidarr.exe"); Mocker.GetMock() .SetupGet(s => s.UpdateAutomatically) @@ -87,6 +87,17 @@ namespace NzbDrone.Core.Test.UpdateTests .Returns(true); } + [Test] + public void should_not_update_if_inside_docker() + { + Mocker.GetMock().Setup(x => x.IsDocker).Returns(true); + + Subject.Execute(new ApplicationUpdateCommand()); + + Mocker.GetMock() + .Verify(c => c.Start(It.IsAny(), It.Is(s => s.StartsWith("12")), null, null, null), Times.Never()); + } + [Test] public void should_delete_sandbox_before_update_if_folder_exists() { @@ -173,7 +184,7 @@ namespace NzbDrone.Core.Test.UpdateTests [Platform("Mono")] public void should_run_script_if_configured() { - const string scriptPath = "/tmp/nzbdrone/update.sh"; + const string scriptPath = "/tmp/lidarr/update.sh"; GivenInstallScript(scriptPath); @@ -186,7 +197,7 @@ namespace NzbDrone.Core.Test.UpdateTests [Platform("Mono")] public void should_throw_if_script_is_not_set() { - const string scriptPath = "/tmp/nzbdrone/update.sh"; + const string scriptPath = "/tmp/lidarr/update.sh"; GivenInstallScript(""); @@ -200,7 +211,7 @@ namespace NzbDrone.Core.Test.UpdateTests [Platform("Mono")] public void should_throw_if_script_is_null() { - const string scriptPath = "/tmp/nzbdrone/update.sh"; + const string scriptPath = "/tmp/lidarr/update.sh"; GivenInstallScript(null); @@ -214,7 +225,7 @@ namespace NzbDrone.Core.Test.UpdateTests [Platform("Mono")] public void should_throw_if_script_path_does_not_exist() { - const string scriptPath = "/tmp/nzbdrone/update.sh"; + const string scriptPath = "/tmp/lidarr/update.sh"; GivenInstallScript(scriptPath); @@ -245,7 +256,7 @@ namespace NzbDrone.Core.Test.UpdateTests updateSubFolder.Refresh(); updateSubFolder.Exists.Should().BeTrue(); - updateSubFolder.GetDirectories("NzbDrone").Should().HaveCount(1); + updateSubFolder.GetDirectories("Lidarr").Should().HaveCount(1); updateSubFolder.GetDirectories().Should().HaveCount(1); updateSubFolder.GetFiles().Should().NotBeEmpty(); } @@ -253,8 +264,8 @@ namespace NzbDrone.Core.Test.UpdateTests [Test] public void should_log_error_when_app_data_is_child_of_startup_folder() { - Mocker.GetMock().SetupGet(c => c.StartUpFolder).Returns(@"C:\NzbDrone".AsOsAgnostic); - Mocker.GetMock().SetupGet(c => c.AppDataFolder).Returns(@"C:\NzbDrone\AppData".AsOsAgnostic); + Mocker.GetMock().SetupGet(c => c.StartUpFolder).Returns(@"C:\Lidarr".AsOsAgnostic); + Mocker.GetMock().SetupGet(c => c.AppDataFolder).Returns(@"C:\Lidarr\AppData".AsOsAgnostic); Assert.Throws(() => Subject.Execute(new ApplicationUpdateCommand())); ExceptionVerification.ExpectedErrors(1); diff --git a/src/NzbDrone.Core.Test/ValidationTests/GuidValidationFixture.cs b/src/NzbDrone.Core.Test/ValidationTests/GuidValidationFixture.cs new file mode 100644 index 000000000..6c03bedf4 --- /dev/null +++ b/src/NzbDrone.Core.Test/ValidationTests/GuidValidationFixture.cs @@ -0,0 +1,44 @@ +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Validation; +using NzbDrone.Test.Common; +using NzbDrone.Core.ImportLists.Exclusions; + +namespace NzbDrone.Core.Test.ValidationTests +{ + public class GuidValidationFixture : CoreTest + { + private TestValidator _validator; + + [SetUp] + public void Setup() + { + _validator = new TestValidator + { + v => v.RuleFor(s => s.ForeignId).SetValidator(Subject) + }; + } + + [Test] + public void should_not_be_valid_if_invalid_guid() + { + var listExclusion = Builder.CreateNew() + .With(s => s.ForeignId = "e1f1e33e-2e4c-4d43-b91b-7064068d328") + .Build(); + + _validator.Validate(listExclusion).IsValid.Should().BeFalse(); + } + + [Test] + public void should_be_valid_if_valid_guid() + { + var listExclusion = Builder.CreateNew() + .With(s => s.ForeignId = "e1f1e33e-2e4c-4d43-b91b-7064068d3283") + .Build(); + + _validator.Validate(listExclusion).IsValid.Should().BeTrue(); + } + } +} diff --git a/src/NzbDrone.Core.Test/ValidationTests/SystemFolderValidatorFixture.cs b/src/NzbDrone.Core.Test/ValidationTests/SystemFolderValidatorFixture.cs new file mode 100644 index 000000000..f6e4e4fb1 --- /dev/null +++ b/src/NzbDrone.Core.Test/ValidationTests/SystemFolderValidatorFixture.cs @@ -0,0 +1,77 @@ +using System; +using System.IO; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; +using NzbDrone.Core.Validation.Paths; +using NzbDrone.Test.Common; +using NzbDrone.Common.EnvironmentInfo; + +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 artist = Builder.CreateNew() + .With(s => s.Path = Environment.GetFolderPath(Environment.SpecialFolder.Windows)) + .Build(); + + _validator.Validate(artist).IsValid.Should().BeFalse(); + } + + [Test] + public void should_not_be_valid_if_child_of_windows_folder() + { + WindowsOnly(); + + var artist = Builder.CreateNew() + .With(s => s.Path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "Test")) + .Build(); + + _validator.Validate(artist).IsValid.Should().BeFalse(); + } + + [Test] + public void should_not_be_valid_if_set_to_bin_folder() + { + MonoOnly(); + + var bin = OsInfo.IsOsx ? "/System" : "/bin"; + var artist = Builder.CreateNew() + .With(s => s.Path = bin) + .Build(); + + _validator.Validate(artist).IsValid.Should().BeFalse(); + } + + [Test] + public void should_not_be_valid_if_child_of_bin_folder() + { + MonoOnly(); + + var bin = OsInfo.IsOsx ? "/System" : "/bin"; + var artist = Builder.CreateNew() + .With(s => s.Path = Path.Combine(bin, "test")) + .Build(); + + _validator.Validate(artist).IsValid.Should().BeFalse(); + } + } +} diff --git a/src/NzbDrone.Core.Test/packages.config b/src/NzbDrone.Core.Test/packages.config deleted file mode 100644 index 2371b300c..000000000 --- a/src/NzbDrone.Core.Test/packages.config +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/NzbDrone.Core/Analytics/AnalyticsService.cs b/src/NzbDrone.Core/Analytics/AnalyticsService.cs index 6e2d43382..e01f33727 100644 --- a/src/NzbDrone.Core/Analytics/AnalyticsService.cs +++ b/src/NzbDrone.Core/Analytics/AnalyticsService.cs @@ -1,4 +1,4 @@ -using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Configuration; namespace NzbDrone.Core.Analytics @@ -17,6 +17,6 @@ namespace NzbDrone.Core.Analytics _configFileProvider = configFileProvider; } - public bool IsEnabled => _configFileProvider.AnalyticsEnabled && RuntimeInfo.IsProduction; + public bool IsEnabled => _configFileProvider.AnalyticsEnabled; } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs index 85b9b044c..e02acd67d 100644 --- a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs +++ b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace NzbDrone.Core.Annotations { @@ -12,25 +12,38 @@ 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; } public bool Advanced { get; set; } public Type SelectOptions { get; set; } + public string Section { get; set; } + public HiddenType Hidden { get; set; } } public enum FieldType { Textbox, + Number, Password, Checkbox, Select, Path, FilePath, - Hidden, Tag, Action, Url, - Captcha + Captcha, + OAuth, + Device, + Playlist + } + + public enum HiddenType + { + Visible, + Hidden, + HiddenIfNotSet } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/App.config b/src/NzbDrone.Core/App.config index 043c42fe9..3afc505ff 100644 --- a/src/NzbDrone.Core/App.config +++ b/src/NzbDrone.Core/App.config @@ -1,11 +1,11 @@  - + - + diff --git a/src/NzbDrone.Core/ArtistStats/AlbumStatistics.cs b/src/NzbDrone.Core/ArtistStats/AlbumStatistics.cs new file mode 100644 index 000000000..dfcb9f4d9 --- /dev/null +++ b/src/NzbDrone.Core/ArtistStats/AlbumStatistics.cs @@ -0,0 +1,16 @@ +using System; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.ArtistStats +{ + public class AlbumStatistics : ResultSet + { + public int ArtistId { get; set; } + public int AlbumId { get; set; } + public int TrackFileCount { get; set; } + public int TrackCount { get; set; } + public int AvailableTrackCount { get; set; } + public int TotalTrackCount { get; set; } + public long SizeOnDisk { get; set; } + } +} diff --git a/src/NzbDrone.Core/ArtistStats/ArtistStatistics.cs b/src/NzbDrone.Core/ArtistStats/ArtistStatistics.cs new file mode 100644 index 000000000..3989393f4 --- /dev/null +++ b/src/NzbDrone.Core/ArtistStats/ArtistStatistics.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.ArtistStats +{ + public class ArtistStatistics : ResultSet + { + public int ArtistId { get; set; } + public int AlbumCount { get; set; } + public int TrackFileCount { get; set; } + public int TrackCount { get; set; } + public int TotalTrackCount { get; set; } + public long SizeOnDisk { get; set; } + public List AlbumStatistics { get; set; } + } +} diff --git a/src/NzbDrone.Core/ArtistStats/ArtistStatisticsRepository.cs b/src/NzbDrone.Core/ArtistStats/ArtistStatisticsRepository.cs new file mode 100644 index 000000000..84a13448e --- /dev/null +++ b/src/NzbDrone.Core/ArtistStats/ArtistStatisticsRepository.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Text; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.ArtistStats +{ + public interface IArtistStatisticsRepository + { + List ArtistStatistics(); + List ArtistStatistics(int artistId); + } + + public class ArtistStatisticsRepository : IArtistStatisticsRepository + { + private readonly IMainDatabase _database; + + public ArtistStatisticsRepository(IMainDatabase database) + { + _database = database; + } + + public List ArtistStatistics() + { + var mapper = _database.GetDataMapper(); + + mapper.AddParameter("currentDate", DateTime.UtcNow); + + var sb = new StringBuilder(); + sb.AppendLine(GetSelectClause()); + sb.AppendLine("AND Albums.ReleaseDate < @currentDate"); + sb.AppendLine(GetGroupByClause()); + var queryText = sb.ToString(); + + return mapper.Query(queryText); + } + + public List ArtistStatistics(int artistId) + { + var mapper = _database.GetDataMapper(); + + mapper.AddParameter("currentDate", DateTime.UtcNow); + mapper.AddParameter("artistId", artistId); + + var sb = new StringBuilder(); + sb.AppendLine(GetSelectClause()); + sb.AppendLine("AND Artists.Id = @artistId"); + sb.AppendLine("AND Albums.ReleaseDate < @currentDate"); + sb.AppendLine(GetGroupByClause()); + var queryText = sb.ToString(); + + return mapper.Query(queryText); + } + + private string GetSelectClause() + { + return @"SELECT + Artists.Id AS ArtistId, + Albums.Id AS AlbumId, + SUM(COALESCE(TrackFiles.Size, 0)) AS SizeOnDisk, + COUNT(Tracks.Id) AS TotalTrackCount, + SUM(CASE WHEN Tracks.TrackFileId > 0 THEN 1 ELSE 0 END) AS AvailableTrackCount, + SUM(CASE WHEN Albums.Monitored = 1 OR Tracks.TrackFileId > 0 THEN 1 ELSE 0 END) AS TrackCount, + SUM(CASE WHEN TrackFiles.Id IS NULL THEN 0 ELSE 1 END) AS TrackFileCount + FROM Tracks + JOIN AlbumReleases ON Tracks.AlbumReleaseId = AlbumReleases.Id + JOIN Albums ON AlbumReleases.AlbumId = Albums.Id + JOIN Artists on Albums.ArtistMetadataId = Artists.ArtistMetadataId + LEFT OUTER JOIN TrackFiles ON Tracks.TrackFileId = TrackFiles.Id + WHERE AlbumReleases.Monitored = 1"; + } + + private string GetGroupByClause() + { + return "GROUP BY Artists.Id, Albums.Id"; + } + } +} diff --git a/src/NzbDrone.Core/ArtistStats/ArtistStatisticsService.cs b/src/NzbDrone.Core/ArtistStats/ArtistStatisticsService.cs new file mode 100644 index 000000000..1d77713b9 --- /dev/null +++ b/src/NzbDrone.Core/ArtistStats/ArtistStatisticsService.cs @@ -0,0 +1,101 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Common.Cache; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Music.Events; + +namespace NzbDrone.Core.ArtistStats +{ + public interface IArtistStatisticsService + { + List ArtistStatistics(); + ArtistStatistics ArtistStatistics(int artistId); + } + + public class ArtistStatisticsService : IArtistStatisticsService, + IHandle, + IHandle, + IHandle, + IHandle, + IHandle + { + private readonly IArtistStatisticsRepository _artistStatisticsRepository; + private readonly ICached> _cache; + + public ArtistStatisticsService(IArtistStatisticsRepository artistStatisticsRepository, + ICacheManager cacheManager) + { + _artistStatisticsRepository = artistStatisticsRepository; + _cache = cacheManager.GetCache>(GetType()); + } + + public List ArtistStatistics() + { + var albumStatistics = _cache.Get("AllArtists", () => _artistStatisticsRepository.ArtistStatistics()); + + return albumStatistics.GroupBy(s => s.ArtistId).Select(s => MapArtistStatistics(s.ToList())).ToList(); + } + + public ArtistStatistics ArtistStatistics(int artistId) + { + var stats = _cache.Get(artistId.ToString(), () => _artistStatisticsRepository.ArtistStatistics(artistId)); + + if (stats == null || stats.Count == 0) return new ArtistStatistics(); + + return MapArtistStatistics(stats); + } + + private ArtistStatistics MapArtistStatistics(List albumStatistics) + { + var artistStatistics = new ArtistStatistics + { + AlbumStatistics = albumStatistics, + AlbumCount = albumStatistics.Count, + ArtistId = albumStatistics.First().ArtistId, + TrackFileCount = albumStatistics.Sum(s => s.TrackFileCount), + TrackCount = albumStatistics.Sum(s => s.TrackCount), + TotalTrackCount = albumStatistics.Sum(s => s.TotalTrackCount), + SizeOnDisk = albumStatistics.Sum(s => s.SizeOnDisk) + }; + + return artistStatistics; + } + + [EventHandleOrder(EventHandleOrder.First)] + public void Handle(ArtistUpdatedEvent message) + { + _cache.Remove("AllArtists"); + _cache.Remove(message.Artist.Id.ToString()); + } + + [EventHandleOrder(EventHandleOrder.First)] + public void Handle(ArtistDeletedEvent message) + { + _cache.Remove("AllArtists"); + _cache.Remove(message.Artist.Id.ToString()); + } + + [EventHandleOrder(EventHandleOrder.First)] + public void Handle(AlbumImportedEvent message) + { + _cache.Remove("AllArtists"); + _cache.Remove(message.Artist.Id.ToString()); + } + + [EventHandleOrder(EventHandleOrder.First)] + public void Handle(AlbumEditedEvent message) + { + _cache.Remove("AllArtists"); + _cache.Remove(message.Album.ArtistId.ToString()); + } + + [EventHandleOrder(EventHandleOrder.First)] + public void Handle(TrackFileDeletedEvent message) + { + _cache.Remove("AllArtists"); + _cache.Remove(message.TrackFile.Artist.Value.Id.ToString()); + } + } +} diff --git a/src/NzbDrone.Core/Backup/Backup.cs b/src/NzbDrone.Core/Backup/Backup.cs index a4505d991..4dafd4394 100644 --- a/src/NzbDrone.Core/Backup/Backup.cs +++ b/src/NzbDrone.Core/Backup/Backup.cs @@ -1,10 +1,10 @@ -using System; +using System; 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..fd6086830 100644 --- a/src/NzbDrone.Core/Backup/BackupCommand.cs +++ b/src/NzbDrone.Core/Backup/BackupCommand.cs @@ -1,10 +1,21 @@ -using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Commands; 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..6ad56ab04 100644 --- a/src/NzbDrone.Core/Backup/BackupService.cs +++ b/src/NzbDrone.Core/Backup/BackupService.cs @@ -1,16 +1,17 @@ -using System; +using System; using System.Collections.Generic; -using System.Data; using System.IO; using System.Linq; +using System.Net; +using System.Text; using System.Text.RegularExpressions; -using Marr.Data; using NLog; using NzbDrone.Common; using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Core.Configuration; using NzbDrone.Core.Datastore; using NzbDrone.Core.Messaging.Commands; @@ -20,36 +21,45 @@ namespace NzbDrone.Core.Backup { void Backup(BackupType backupType); List GetBackups(); + void Restore(string backupFileName); + string GetBackupFolder(); + string GetBackupFolder(BackupType backupType); } 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; private readonly IArchiveService _archiveService; + private readonly IConfigService _configService; private readonly Logger _logger; private string _backupTempFolder; - private static readonly Regex BackupFileRegex = new Regex(@"nzbdrone_backup_[._0-9]+\.zip", RegexOptions.Compiled | RegexOptions.IgnoreCase); + public static readonly Regex BackupFileRegex = new Regex(@"lidarr_backup_[._0-9]+\.zip", RegexOptions.Compiled | RegexOptions.IgnoreCase); public BackupService(IMainDatabase maindDb, + IMakeDatabaseBackup makeDatabaseBackup, IDiskTransferService diskTransferService, IDiskProvider diskProvider, IAppFolderInfo appFolderInfo, IArchiveService archiveService, + IConfigService configService, Logger logger) { _maindDb = maindDb; + _makeDatabaseBackup = makeDatabaseBackup; _diskTransferService = diskTransferService; _diskProvider = diskProvider; _appFolderInfo = appFolderInfo; _archiveService = archiveService; + _configService = configService; _logger = logger; - _backupTempFolder = Path.Combine(_appFolderInfo.TempFolder, "nzbdrone_backup"); + _backupTempFolder = Path.Combine(_appFolderInfo.TempFolder, "lidarr_backup"); } public void Backup(BackupType backupType) @@ -59,7 +69,7 @@ namespace NzbDrone.Core.Backup _diskProvider.EnsureFolder(_backupTempFolder); _diskProvider.EnsureFolder(GetBackupFolder(backupType)); - var backupFilename = string.Format("nzbdrone_backup_{0:yyyy.MM.dd_HH.mm.ss}.zip", DateTime.Now); + var backupFilename = string.Format("lidarr_backup_{0:yyyy.MM.dd_HH.mm.ss}.zip", DateTime.Now); var backupPath = Path.Combine(GetBackupFolder(backupType), backupFilename); Cleanup(); @@ -71,9 +81,15 @@ namespace NzbDrone.Core.Backup BackupConfigFile(); BackupDatabase(); + CreateVersionInfo(); _logger.ProgressDebug("Creating backup zip"); + + // Delete journal file created during database backup + _diskProvider.DeleteFile(Path.Combine(_backupTempFolder, "lidarr.db-journal")); + _archiveService.CreateZip(backupPath, _diskProvider.GetFiles(_backupTempFolder, SearchOption.TopDirectoryOnly)); + _logger.ProgressDebug("Backup zip created"); } @@ -89,7 +105,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) })); @@ -99,31 +115,77 @@ namespace NzbDrone.Core.Backup return backups; } - private void Cleanup() + public void Restore(string backupFileName) { - if (_diskProvider.FolderExists(_backupTempFolder)) + if (backupFileName.EndsWith(".zip")) { - _diskProvider.EmptyFolder(_backupTempFolder); + var restoredFile = false; + var temporaryPath = Path.Combine(_appFolderInfo.TempFolder, "lidarr_backup_restore"); + + _archiveService.Extract(backupFileName, temporaryPath); + + foreach (var file in _diskProvider.GetFiles(temporaryPath, SearchOption.TopDirectoryOnly)) + { + var fileName = Path.GetFileName(file); + + if (fileName.Equals("Config.xml", StringComparison.InvariantCultureIgnoreCase)) + { + _diskProvider.MoveFile(file, _appFolderInfo.GetConfigPath(), true); + restoredFile = true; + } + + if (fileName.Equals("lidarr.db", StringComparison.InvariantCultureIgnoreCase)) + { + _diskProvider.MoveFile(file, _appFolderInfo.GetDatabaseRestore(), true); + restoredFile = true; + } + } + + if (!restoredFile) + { + throw new RestoreBackupFailedException(HttpStatusCode.NotFound, "Unable to restore database file from backup"); + } + + _diskProvider.DeleteFolder(temporaryPath, true); + + return; } + + _diskProvider.MoveFile(backupFileName, _appFolderInfo.GetDatabaseRestore(), true); } - private void BackupDatabase() + public string GetBackupFolder() { - _logger.ProgressDebug("Backing up database"); + var backupFolder = _configService.BackupFolder; - using (var unitOfWork = new UnitOfWork(() => _maindDb.GetDataMapper())) + if (Path.IsPathRooted(backupFolder)) { - unitOfWork.BeginTransaction(IsolationLevel.Serializable); + return backupFolder; + } - var databaseFile = _appFolderInfo.GetNzbDroneDatabase(); - var tempDatabaseFile = Path.Combine(_backupTempFolder, Path.GetFileName(databaseFile)); + return Path.Combine(_appFolderInfo.GetAppDataPath(), backupFolder); + } - _diskTransferService.TransferFile(databaseFile, tempDatabaseFile, TransferMode.Copy); + public string GetBackupFolder(BackupType backupType) + { + return Path.Combine(GetBackupFolder(), backupType.ToString().ToLower()); + } - unitOfWork.Commit(); + private void Cleanup() + { + if (_diskProvider.FolderExists(_backupTempFolder)) + { + _diskProvider.EmptyFolder(_backupTempFolder); } } + private void BackupDatabase() + { + _logger.ProgressDebug("Backing up database"); + + _makeDatabaseBackup.BackupDatabase(_maindDb, _backupTempFolder); + } + private void BackupConfigFile() { _logger.ProgressDebug("Backing up config.xml"); @@ -134,16 +196,25 @@ namespace NzbDrone.Core.Backup _diskTransferService.TransferFile(configFile, tempConfigFile, TransferMode.Copy); } + private void CreateVersionInfo() + { + var builder = new StringBuilder(); + + builder.AppendLine(BuildInfo.Version.ToString()); + } + private void CleanupOldBackups(BackupType backupType) { - _logger.Debug("Cleaning up old backup files"); + var retention = _configService.BackupRetention; + + _logger.Debug("Cleaning up backup files older than {0} days", retention); var files = GetBackupFiles(GetBackupFolder(backupType)); foreach (var file in files) { var lastWriteTime = _diskProvider.FileGetLastWrite(file); - if (lastWriteTime.AddDays(28) < DateTime.UtcNow) + if (lastWriteTime.AddDays(retention) < DateTime.UtcNow) { _logger.Debug("Deleting old backup file: {0}", file); _diskProvider.DeleteFile(file); @@ -153,11 +224,6 @@ namespace NzbDrone.Core.Backup _logger.Debug("Finished cleaning up old backup files"); } - private string GetBackupFolder(BackupType backupType) - { - return Path.Combine(_appFolderInfo.GetBackupFolder(), backupType.ToString().ToLower()); - } - private IEnumerable GetBackupFiles(string path) { var files = _diskProvider.GetFiles(path, SearchOption.TopDirectoryOnly); diff --git a/src/NzbDrone.Core/Backup/MakeDatabaseBackup.cs b/src/NzbDrone.Core/Backup/MakeDatabaseBackup.cs new file mode 100644 index 000000000..6460daab1 --- /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/Backup/RestoreBackupFailedException.cs b/src/NzbDrone.Core/Backup/RestoreBackupFailedException.cs new file mode 100644 index 000000000..3a06b1b1b --- /dev/null +++ b/src/NzbDrone.Core/Backup/RestoreBackupFailedException.cs @@ -0,0 +1,16 @@ +using System.Net; +using NzbDrone.Core.Exceptions; + +namespace NzbDrone.Core.Backup +{ + public class RestoreBackupFailedException : NzbDroneClientException + { + public RestoreBackupFailedException(HttpStatusCode statusCode, string message, params object[] args) : base(statusCode, message, args) + { + } + + public RestoreBackupFailedException(HttpStatusCode statusCode, string message) : base(statusCode, message) + { + } + } +} diff --git a/src/NzbDrone.Core/Blacklisting/Blacklist.cs b/src/NzbDrone.Core/Blacklisting/Blacklist.cs index 1c0813ac0..6d179db61 100644 --- a/src/NzbDrone.Core/Blacklisting/Blacklist.cs +++ b/src/NzbDrone.Core/Blacklisting/Blacklist.cs @@ -1,17 +1,17 @@ -using System; +using System; using System.Collections.Generic; using NzbDrone.Core.Datastore; using NzbDrone.Core.Indexers; using NzbDrone.Core.Qualities; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Blacklisting { public class Blacklist : ModelBase { - public int SeriesId { get; set; } - public Series Series { get; set; } - public List EpisodeIds { get; set; } + public int ArtistId { get; set; } + public Artist Artist { get; set; } + public List AlbumIds { get; set; } public string SourceTitle { get; set; } public QualityModel Quality { get; set; } public DateTime Date { get; set; } diff --git a/src/NzbDrone.Core/Blacklisting/BlacklistRepository.cs b/src/NzbDrone.Core/Blacklisting/BlacklistRepository.cs index 906f2a92b..9d6e736c4 100644 --- a/src/NzbDrone.Core/Blacklisting/BlacklistRepository.cs +++ b/src/NzbDrone.Core/Blacklisting/BlacklistRepository.cs @@ -2,15 +2,15 @@ using NzbDrone.Core.Datastore; using NzbDrone.Core.Messaging.Events; using Marr.Data.QGen; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Blacklisting { public interface IBlacklistRepository : IBasicRepository { - List BlacklistedByTitle(int seriesId, string sourceTitle); - List BlacklistedByTorrentInfoHash(int seriesId, string torrentInfoHash); - List BlacklistedBySeries(int seriesId); + List BlacklistedByTitle(int artistId, string sourceTitle); + List BlacklistedByTorrentInfoHash(int artistId, string torrentInfoHash); + List BlacklistedByArtist(int artistId); } public class BlacklistRepository : BasicRepository, IBlacklistRepository @@ -20,26 +20,26 @@ namespace NzbDrone.Core.Blacklisting { } - public List BlacklistedByTitle(int seriesId, string sourceTitle) + public List BlacklistedByTitle(int artistId, string sourceTitle) { - return Query.Where(e => e.SeriesId == seriesId) + return Query.Where(e => e.ArtistId == artistId) .AndWhere(e => e.SourceTitle.Contains(sourceTitle)); } - public List BlacklistedByTorrentInfoHash(int seriesId, string torrentInfoHash) + public List BlacklistedByTorrentInfoHash(int artistId, string torrentInfoHash) { - return Query.Where(e => e.SeriesId == seriesId) + return Query.Where(e => e.ArtistId == artistId) .AndWhere(e => e.TorrentInfoHash.Contains(torrentInfoHash)); } - public List BlacklistedBySeries(int seriesId) + public List BlacklistedByArtist(int artistId) { - return Query.Where(b => b.SeriesId == seriesId); + return Query.Where(b => b.ArtistId == artistId); } protected override SortBuilder GetPagedQuery(QueryBuilder query, PagingSpec pagingSpec) { - var baseQuery = query.Join(JoinType.Inner, h => h.Series, (h, s) => h.SeriesId == s.Id); + var baseQuery = query.Join(JoinType.Inner, h => h.Artist, (h, s) => h.ArtistId == s.Id); return base.GetPagedQuery(baseQuery, pagingSpec); } diff --git a/src/NzbDrone.Core/Blacklisting/BlacklistService.cs b/src/NzbDrone.Core/Blacklisting/BlacklistService.cs index 1c0829004..731391fdd 100644 --- a/src/NzbDrone.Core/Blacklisting/BlacklistService.cs +++ b/src/NzbDrone.Core/Blacklisting/BlacklistService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using NzbDrone.Common.Extensions; using NzbDrone.Core.Datastore; @@ -7,13 +7,13 @@ using NzbDrone.Core.Indexers; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Tv.Events; +using NzbDrone.Core.Music.Events; namespace NzbDrone.Core.Blacklisting { public interface IBlacklistService { - bool Blacklisted(int seriesId, ReleaseInfo release); + bool Blacklisted(int artistId, ReleaseInfo release); PagingSpec Paged(PagingSpec pagingSpec); void Delete(int id); } @@ -21,7 +21,7 @@ namespace NzbDrone.Core.Blacklisting IExecute, IHandle, - IHandleAsync + IHandleAsync { private readonly IBlacklistRepository _blacklistRepository; @@ -30,9 +30,9 @@ namespace NzbDrone.Core.Blacklisting _blacklistRepository = blacklistRepository; } - public bool Blacklisted(int seriesId, ReleaseInfo release) + public bool Blacklisted(int artistId, ReleaseInfo release) { - var blacklistedByTitle = _blacklistRepository.BlacklistedByTitle(seriesId, release.Title); + var blacklistedByTitle = _blacklistRepository.BlacklistedByTitle(artistId, release.Title); if (release.DownloadProtocol == DownloadProtocol.Torrent) { @@ -46,7 +46,7 @@ namespace NzbDrone.Core.Blacklisting .Any(b => SameTorrent(b, torrentInfo)); } - var blacklistedByTorrentInfohash = _blacklistRepository.BlacklistedByTorrentInfoHash(seriesId, torrentInfo.InfoHash); + var blacklistedByTorrentInfohash = _blacklistRepository.BlacklistedByTorrentInfoHash(artistId, torrentInfo.InfoHash); return blacklistedByTorrentInfohash.Any(b => SameTorrent(b, torrentInfo)); } @@ -128,8 +128,8 @@ namespace NzbDrone.Core.Blacklisting { var blacklist = new Blacklist { - SeriesId = message.SeriesId, - EpisodeIds = message.EpisodeIds, + ArtistId = message.ArtistId, + AlbumIds = message.AlbumIds, SourceTitle = message.SourceTitle, Quality = message.Quality, Date = DateTime.UtcNow, @@ -139,14 +139,14 @@ namespace NzbDrone.Core.Blacklisting Protocol = (DownloadProtocol)Convert.ToInt32(message.Data.GetValueOrDefault("protocol")), Message = message.Message, TorrentInfoHash = message.Data.GetValueOrDefault("torrentInfoHash") - }; + }; _blacklistRepository.Insert(blacklist); } - public void HandleAsync(SeriesDeletedEvent message) + public void HandleAsync(ArtistDeletedEvent message) { - var blacklisted = _blacklistRepository.BlacklistedBySeries(message.Series.Id); + var blacklisted = _blacklistRepository.BlacklistedByArtist(message.Artist.Id); _blacklistRepository.DeleteMany(blacklisted); } diff --git a/src/NzbDrone.Core/Configuration/AccessDeniedConfigFileException.cs b/src/NzbDrone.Core/Configuration/AccessDeniedConfigFileException.cs new file mode 100644 index 000000000..64b23a290 --- /dev/null +++ b/src/NzbDrone.Core/Configuration/AccessDeniedConfigFileException.cs @@ -0,0 +1,16 @@ +using System; +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.Configuration +{ + public class AccessDeniedConfigFileException : NzbDroneException + { + public AccessDeniedConfigFileException(string message) : base(message) + { + } + + public AccessDeniedConfigFileException(string message, Exception innerException) : base(message, innerException) + { + } + } +} diff --git a/src/NzbDrone.Core/Configuration/AllowFingerprinting.cs b/src/NzbDrone.Core/Configuration/AllowFingerprinting.cs new file mode 100644 index 000000000..2135eae3d --- /dev/null +++ b/src/NzbDrone.Core/Configuration/AllowFingerprinting.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Configuration +{ + public enum AllowFingerprinting + { + Never, + NewFiles, + AllFiles + } +} diff --git a/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs b/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs index 73ce4ab4e..6f054f4c9 100644 --- a/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs +++ b/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs @@ -33,6 +33,8 @@ namespace NzbDrone.Core.Configuration AuthenticationType AuthenticationMethod { get; } bool AnalyticsEnabled { get; } string LogLevel { get; } + string ConsoleLogLevel { get; } + bool FilterSentryEvents { get; } string Branch { get; } string ApiKey { get; } string SslCertHash { get; } @@ -134,15 +136,29 @@ namespace NzbDrone.Core.Configuration } } - public int Port => GetValueInt("Port", 8989); + public int Port => GetValueInt("Port", 8686); - public int SslPort => GetValueInt("SslPort", 9898); + public int SslPort => GetValueInt("SslPort", 6868); public bool EnableSsl => GetValueBoolean("EnableSsl", false); 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 { @@ -164,7 +180,9 @@ namespace NzbDrone.Core.Configuration public string Branch => GetValue("Branch", "master").ToLowerInvariant(); - public string LogLevel => GetValue("LogLevel", "Info"); + public string LogLevel => GetValue("LogLevel", "info"); + public string ConsoleLogLevel => GetValue("ConsoleLogLevel", string.Empty, persist: false); + public bool FilterSentryEvents => GetValueBoolean("FilterSentryEvents", true, persist: false); public string SslCertHash => GetValue("SslCertHash", ""); @@ -304,12 +322,12 @@ namespace NzbDrone.Core.Configuration if (contents.IsNullOrWhiteSpace()) { - throw new InvalidConfigFileException($"{_configFile} is empty. Please delete the config file and Sonarr will recreate it."); + throw new InvalidConfigFileException($"{_configFile} is empty. Please delete the config file and Lidarr will recreate it."); } if (contents.All(char.IsControl)) { - throw new InvalidConfigFileException($"{_configFile} is corrupt. Please delete the config file and Sonarr will recreate it."); + throw new InvalidConfigFileException($"{_configFile} is corrupt. Please delete the config file and Lidarr will recreate it."); } return XDocument.Parse(_diskProvider.ReadAllText(_configFile)); @@ -324,16 +342,29 @@ namespace NzbDrone.Core.Configuration catch (XmlException ex) { - throw new InvalidConfigFileException($"{_configFile} is corrupt is invalid. Please delete the config file and Sonarr will recreate it.", ex); + throw new InvalidConfigFileException($"{_configFile} is corrupt is invalid. Please delete the config file and Lidarr will recreate it.", ex); + } + + catch (UnauthorizedAccessException ex) + { + throw new AccessDeniedConfigFileException($"Lidarr does not have access to config file: {_configFile}. Please fix permissions", ex); } } private void SaveConfigFile(XDocument xDoc) { - lock (Mutex) + try { - _diskProvider.WriteAllText(_configFile, xDoc.ToString()); + lock (Mutex) + { + _diskProvider.WriteAllText(_configFile, xDoc.ToString()); + } } + catch (UnauthorizedAccessException ex) + { + throw new AccessDeniedConfigFileException($"Lidarr does not have access to config file: {_configFile}. Please fix permissions", ex); + } + } private string GenerateApiKey() @@ -345,11 +376,6 @@ namespace NzbDrone.Core.Configuration { EnsureDefaultConfigFile(); DeleteOldValues(); - - if (!AnalyticsEnabled) - { - NzbDroneLogger.UnRegisterRemoteLoggers(); - } } public void Execute(ResetApiKeyCommand message) diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index 8563e1eb1..0043d3cb3 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -1,20 +1,20 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using NLog; using NzbDrone.Common.EnsureThat; -using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Configuration.Events; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Messaging.Events; using NzbDrone.Common.Http.Proxy; +using NzbDrone.Core.Qualities; namespace NzbDrone.Core.Configuration { public enum ConfigKey { - DownloadedEpisodesFolder + DownloadedAlbumsFolder } public class ConfigService : IConfigService @@ -74,17 +74,10 @@ namespace NzbDrone.Core.Configuration return _repository.Get(key.ToLower()) != null; } - public string DownloadedEpisodesFolder + public bool AutoUnmonitorPreviouslyDownloadedTracks { - get { return GetValue(ConfigKey.DownloadedEpisodesFolder.ToString()); } - - set { SetValue(ConfigKey.DownloadedEpisodesFolder.ToString(), value); } - } - - public bool AutoUnmonitorPreviouslyDownloadedEpisodes - { - get { return GetValueBoolean("AutoUnmonitorPreviouslyDownloadedEpisodes"); } - set { SetValue("AutoUnmonitorPreviouslyDownloadedEpisodes", value); } + get { return GetValueBoolean("AutoUnmonitorPreviouslyDownloadedTracks"); } + set { SetValue("AutoUnmonitorPreviouslyDownloadedTracks", value); } } public int Retention @@ -99,6 +92,12 @@ namespace NzbDrone.Core.Configuration set { SetValue("RecycleBin", value); } } + public int RecycleBinCleanupDays + { + get { return GetValueInt("RecycleBinCleanupDays", 7); } + set { SetValue("RecycleBinCleanupDays", value); } + } + public int RssSyncInterval { get { return GetValueInt("RssSyncInterval", 15); } @@ -106,6 +105,13 @@ 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); } @@ -113,11 +119,11 @@ namespace NzbDrone.Core.Configuration set { SetValue("MinimumAge", value); } } - public bool AutoDownloadPropers + public ProperDownloadTypes DownloadPropersAndRepacks { - get { return GetValueBoolean("AutoDownloadPropers", true); } + get { return GetValueEnum("DownloadPropersAndRepacks", ProperDownloadTypes.PreferAndUpgrade); } - set { SetValue("AutoDownloadPropers", value); } + set { SetValue("DownloadPropersAndRepacks", value); } } public bool EnableCompletedDownloadHandling @@ -148,11 +154,18 @@ namespace NzbDrone.Core.Configuration set { SetValue("RemoveFailedDownloads", value); } } - public bool CreateEmptySeriesFolders + public bool CreateEmptyArtistFolders + { + get { return GetValueBoolean("CreateEmptyArtistFolders", false); } + + set { SetValue("CreateEmptyArtistFolders", value); } + } + + public bool DeleteEmptyFolders { - get { return GetValueBoolean("CreateEmptySeriesFolders", false); } + get { return GetValueBoolean("DeleteEmptyFolders", false); } - set { SetValue("CreateEmptySeriesFolders", value); } + set { SetValue("DeleteEmptyFolders", value); } } public FileDateType FileDate @@ -168,13 +181,6 @@ namespace NzbDrone.Core.Configuration set { SetValue("DownloadClientWorkingFolders", value); } } - public int DownloadedEpisodesScanInterval - { - get { return GetValueInt("DownloadedEpisodesScanInterval", 1); } - - set { SetValue("DownloadedEpisodesScanInterval", value); } - } - public int DownloadClientHistoryLimit { get { return GetValueInt("DownloadClientHistoryLimit", 30); } @@ -196,20 +202,34 @@ namespace NzbDrone.Core.Configuration set { SetValue("CopyUsingHardlinks", value); } } - public bool EnableMediaInfo + public bool ImportExtraFiles { - get { return GetValueBoolean("EnableMediaInfo", true); } + get { return GetValueBoolean("ImportExtraFiles", false); } - set { SetValue("EnableMediaInfo", value); } + set { SetValue("ImportExtraFiles", value); } } public string ExtraFileExtensions { - get { return GetValue("ExtraFileExtensions", ""); } + get { return GetValue("ExtraFileExtensions", "srt"); } set { SetValue("ExtraFileExtensions", value); } } + public RescanAfterRefreshType RescanAfterRefresh + { + get { return GetValueEnum("RescanAfterRefresh", RescanAfterRefreshType.Always); } + + set { SetValue("RescanAfterRefresh", value); } + } + + public AllowFingerprinting AllowFingerprinting + { + get { return GetValueEnum("AllowFingerprinting", AllowFingerprinting.NewFiles); } + + set { SetValue("AllowFingerprinting", value); } + } + public bool SetPermissionsLinux { get { return GetValueBoolean("SetPermissionsLinux", false); } @@ -245,6 +265,27 @@ namespace NzbDrone.Core.Configuration set { SetValue("ChownGroup", value); } } + public string MetadataSource + { + get { return GetValue("MetadataSource", ""); } + + set { SetValue("MetadataSource", value); } + } + + public WriteAudioTagsType WriteAudioTags + { + get { return GetValueEnum("WriteAudioTags", WriteAudioTagsType.No); } + + set { SetValue("WriteAudioTags", value); } + } + + public bool ScrubAudioTags + { + get { return GetValueBoolean("ScrubAudioTags", false); } + + set { SetValue("ScrubAudioTags", value); } + } + public int FirstDayOfWeek { get { return GetValueInt("FirstDayOfWeek", (int)CultureInfo.CurrentCulture.DateTimeFormat.FirstDayOfWeek); } @@ -294,6 +335,41 @@ namespace NzbDrone.Core.Configuration set { SetValue("EnableColorImpairedMode", value); } } + public bool ExpandAlbumByDefault + { + get { return GetValueBoolean("ExpandAlbumByDefault", false); } + + set { SetValue("ExpandAlbumByDefault", value); } + } + + public bool ExpandEPByDefault + { + get { return GetValueBoolean("ExpandEPByDefault", false); } + + set { SetValue("ExpandEPByDefault", value); } + } + + public bool ExpandSingleByDefault + { + get { return GetValueBoolean("ExpandSingleByDefault", false); } + + set { SetValue("ExpandSingleByDefault", value); } + } + + public bool ExpandBroadcastByDefault + { + get { return GetValueBoolean("ExpandBroadcastByDefault", false); } + + set { SetValue("ExpandBroadcastByDefault", value); } + } + + public bool ExpandOtherByDefault + { + get { return GetValueBoolean("ExpandOtherByDefault", false); } + + set { SetValue("ExpandOtherByDefault", value); } + } + public bool CleanupMetadataImages { get { return GetValueBoolean("CleanupMetadataImages", true); } @@ -301,6 +377,8 @@ namespace NzbDrone.Core.Configuration set { SetValue("CleanupMetadataImages", value); } } + public string PlexClientIdentifier => GetValue("PlexClientIdentifier", Guid.NewGuid().ToString(), true); + public string RijndaelPassphrase => GetValue("RijndaelPassphrase", Guid.NewGuid().ToString(), true); public string HmacPassphrase => GetValue("HmacPassphrase", Guid.NewGuid().ToString(), true); @@ -325,6 +403,12 @@ namespace NzbDrone.Core.Configuration public bool ProxyBypassLocalAddresses => GetValueBoolean("ProxyBypassLocalAddresses", true); + public string BackupFolder => GetValue("BackupFolder", "Backups"); + + public int BackupInterval => GetValueInt("BackupInterval", 7); + + public int BackupRetention => GetValueInt("BackupRetention", 28); + private string GetValue(string key) { return GetValue(key, string.Empty); diff --git a/src/NzbDrone.Core/Configuration/IConfigService.cs b/src/NzbDrone.Core/Configuration/IConfigService.cs index e17d8d6dc..fc03d5344 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using NzbDrone.Core.MediaFiles; using NzbDrone.Common.Http.Proxy; +using NzbDrone.Core.Qualities; namespace NzbDrone.Core.Configuration { @@ -11,9 +12,7 @@ namespace NzbDrone.Core.Configuration bool IsDefined(string key); //Download Client - string DownloadedEpisodesFolder { get; set; } string DownloadClientWorkingFolders { get; set; } - int DownloadedEpisodesScanInterval { get; set; } int DownloadClientHistoryLimit { get; set; } //Completed/Failed Download Handling (Download client) @@ -24,15 +23,19 @@ namespace NzbDrone.Core.Configuration bool RemoveFailedDownloads { get; set; } //Media Management - bool AutoUnmonitorPreviouslyDownloadedEpisodes { get; set; } + bool AutoUnmonitorPreviouslyDownloadedTracks { get; set; } string RecycleBin { get; set; } - bool AutoDownloadPropers { get; set; } - bool CreateEmptySeriesFolders { get; set; } + int RecycleBinCleanupDays { get; set; } + ProperDownloadTypes DownloadPropersAndRepacks { get; set; } + bool CreateEmptyArtistFolders { get; set; } + bool DeleteEmptyFolders { get; set; } FileDateType FileDate { get; set; } bool SkipFreeSpaceCheckWhenImporting { get; set; } bool CopyUsingHardlinks { get; set; } - bool EnableMediaInfo { get; set; } + bool ImportExtraFiles { get; set; } string ExtraFileExtensions { get; set; } + RescanAfterRefreshType RescanAfterRefresh { get; set; } + AllowFingerprinting AllowFingerprinting { get; set; } //Permissions (Media Management) bool SetPermissionsLinux { get; set; } @@ -44,6 +47,7 @@ namespace NzbDrone.Core.Configuration //Indexers int Retention { get; set; } int RssSyncInterval { get; set; } + int MaximumSize { get; set; } int MinimumAge { get; set; } //UI @@ -55,10 +59,22 @@ namespace NzbDrone.Core.Configuration string TimeFormat { get; set; } bool ShowRelativeDates { get; set; } bool EnableColorImpairedMode { get; set; } + + bool ExpandAlbumByDefault { get; set; } + bool ExpandSingleByDefault { get; set; } + bool ExpandEPByDefault { get; set; } + bool ExpandBroadcastByDefault { get; set; } + bool ExpandOtherByDefault { get; set; } //Internal bool CleanupMetadataImages { get; set; } + string PlexClientIdentifier { get; } + + //Metadata + string MetadataSource { get; set; } + WriteAudioTagsType WriteAudioTags { get; set; } + bool ScrubAudioTags { get; set; } //Forms Auth string RijndaelPassphrase { get; } @@ -75,5 +91,11 @@ namespace NzbDrone.Core.Configuration string ProxyPassword { get; } string ProxyBypassFilter { get; } bool ProxyBypassLocalAddresses { get; } + + // Backups + string BackupFolder { get; } + int BackupInterval { get; } + int BackupRetention { get; } + } } diff --git a/src/NzbDrone.Core/Configuration/RescanAfterRefreshType.cs b/src/NzbDrone.Core/Configuration/RescanAfterRefreshType.cs new file mode 100644 index 000000000..d1e3a9f18 --- /dev/null +++ b/src/NzbDrone.Core/Configuration/RescanAfterRefreshType.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Configuration +{ + public enum RescanAfterRefreshType + { + Always, + AfterManual, + Never + } +} diff --git a/src/NzbDrone.Core/Configuration/WriteAudioTagsType.cs b/src/NzbDrone.Core/Configuration/WriteAudioTagsType.cs new file mode 100644 index 000000000..0cdbbf256 --- /dev/null +++ b/src/NzbDrone.Core/Configuration/WriteAudioTagsType.cs @@ -0,0 +1,10 @@ +namespace NzbDrone.Core.Configuration +{ + public enum WriteAudioTagsType + { + No, + NewFiles, + AllFiles, + Sync + } +} diff --git a/src/NzbDrone.Core/CustomFilters/CustomFilter.cs b/src/NzbDrone.Core/CustomFilters/CustomFilter.cs new file mode 100644 index 000000000..1c6e3e9b9 --- /dev/null +++ b/src/NzbDrone.Core/CustomFilters/CustomFilter.cs @@ -0,0 +1,11 @@ +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.CustomFilters +{ + public class CustomFilter : ModelBase + { + public string Type { get; set; } + public string Label { get; set; } + public string Filters { get; set; } + } +} diff --git a/src/NzbDrone.Core/CustomFilters/CustomFilterRepository.cs b/src/NzbDrone.Core/CustomFilters/CustomFilterRepository.cs new file mode 100644 index 000000000..9bdb8fd07 --- /dev/null +++ b/src/NzbDrone.Core/CustomFilters/CustomFilterRepository.cs @@ -0,0 +1,17 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.CustomFilters +{ + public interface ICustomFilterRepository : IBasicRepository + { + } + + public class CustomFilterRepository : BasicRepository, ICustomFilterRepository + { + public CustomFilterRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + } +} diff --git a/src/NzbDrone.Core/CustomFilters/CustomFilterService.cs b/src/NzbDrone.Core/CustomFilters/CustomFilterService.cs new file mode 100644 index 000000000..9ef98f8be --- /dev/null +++ b/src/NzbDrone.Core/CustomFilters/CustomFilterService.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.Linq; + +namespace NzbDrone.Core.CustomFilters +{ + public interface ICustomFilterService + { + CustomFilter Add(CustomFilter customFilter); + List All(); + void Delete(int id); + CustomFilter Get(int id); + CustomFilter Update(CustomFilter customFilter); + } + + public class CustomFilterService : ICustomFilterService + { + private readonly ICustomFilterRepository _repo; + + public CustomFilterService(ICustomFilterRepository repo) + { + _repo = repo; + } + + public CustomFilter Add(CustomFilter customFilter) + { + return _repo.Insert(customFilter); + } + + public CustomFilter Update(CustomFilter customFilter) + { + return _repo.Update(customFilter); + } + + public void Delete(int id) + { + _repo.Delete(id); + } + + public CustomFilter Get(int id) + { + return _repo.Get(id); + } + + public List All() + { + return _repo.All().ToList(); + } + } +} diff --git a/src/NzbDrone.Core/DataAugmentation/DailySeries/DailySeries.cs b/src/NzbDrone.Core/DataAugmentation/DailySeries/DailySeries.cs deleted file mode 100644 index 829ce6a24..000000000 --- a/src/NzbDrone.Core/DataAugmentation/DailySeries/DailySeries.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace NzbDrone.Core.DataAugmentation.DailySeries -{ - public class DailySeries - { - public int TvdbId { get; set; } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/DataAugmentation/DailySeries/DailySeriesDataProxy.cs b/src/NzbDrone.Core/DataAugmentation/DailySeries/DailySeriesDataProxy.cs deleted file mode 100644 index 6d1778bdc..000000000 --- a/src/NzbDrone.Core/DataAugmentation/DailySeries/DailySeriesDataProxy.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NLog; -using NzbDrone.Common.Cloud; -using NzbDrone.Common.Http; - -namespace NzbDrone.Core.DataAugmentation.DailySeries -{ - public interface IDailySeriesDataProxy - { - IEnumerable GetDailySeriesIds(); - } - - public class DailySeriesDataProxy : IDailySeriesDataProxy - { - private readonly IHttpClient _httpClient; - private readonly IHttpRequestBuilderFactory _requestBuilder; - private readonly Logger _logger; - - public DailySeriesDataProxy(IHttpClient httpClient, ISonarrCloudRequestBuilder requestBuilder, Logger logger) - { - _httpClient = httpClient; - _requestBuilder = requestBuilder.Services; - _logger = logger; - } - - public IEnumerable GetDailySeriesIds() - { - try - { - var dailySeriesRequest = _requestBuilder.Create() - .Resource("/dailyseries") - .Build(); - - var response = _httpClient.Get>(dailySeriesRequest); - return response.Resource.Select(c => c.TvdbId); - } - catch (Exception ex) - { - _logger.Warn(ex, "Failed to get Daily Series"); - return new List(); - } - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/DataAugmentation/DailySeries/DailySeriesService.cs b/src/NzbDrone.Core/DataAugmentation/DailySeries/DailySeriesService.cs deleted file mode 100644 index 6eb5f874a..000000000 --- a/src/NzbDrone.Core/DataAugmentation/DailySeries/DailySeriesService.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NzbDrone.Common.Cache; - -namespace NzbDrone.Core.DataAugmentation.DailySeries -{ - public interface IDailySeriesService - { - bool IsDailySeries(int tvdbid); - } - - public class DailySeriesService : IDailySeriesService - { - private readonly IDailySeriesDataProxy _proxy; - private readonly ICached> _cache; - - public DailySeriesService(IDailySeriesDataProxy proxy, ICacheManager cacheManager) - { - _proxy = proxy; - _cache = cacheManager.GetCache>(GetType()); - } - - public bool IsDailySeries(int tvdbid) - { - var dailySeries = _cache.Get("all", () => _proxy.GetDailySeriesIds().ToList(), TimeSpan.FromHours(1)); - return dailySeries.Any(i => i == tvdbid); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/DataAugmentation/Scene/ISceneMappingProvider.cs b/src/NzbDrone.Core/DataAugmentation/Scene/ISceneMappingProvider.cs deleted file mode 100644 index 58b69f2b9..000000000 --- a/src/NzbDrone.Core/DataAugmentation/Scene/ISceneMappingProvider.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Collections.Generic; - -namespace NzbDrone.Core.DataAugmentation.Scene -{ - public interface ISceneMappingProvider - { - List GetSceneMappings(); - } -} diff --git a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMapping.cs b/src/NzbDrone.Core/DataAugmentation/Scene/SceneMapping.cs deleted file mode 100644 index b992aa029..000000000 --- a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMapping.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Newtonsoft.Json; -using NzbDrone.Core.Datastore; - -namespace NzbDrone.Core.DataAugmentation.Scene -{ - public class SceneMapping : ModelBase - { - public string Title { get; set; } - public string ParseTerm { get; set; } - - [JsonProperty("searchTitle")] - public string SearchTerm { get; set; } - - public int TvdbId { get; set; } - - [JsonProperty("season")] - public int? SeasonNumber { get; set; } - - public int? SceneSeasonNumber { get; set; } - public string Type { get; set; } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingProxy.cs b/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingProxy.cs deleted file mode 100644 index 735af870b..000000000 --- a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingProxy.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Common.Cloud; -using NzbDrone.Common.Http; - -namespace NzbDrone.Core.DataAugmentation.Scene -{ - public interface ISceneMappingProxy - { - List Fetch(); - } - - public class SceneMappingProxy : ISceneMappingProxy - { - private readonly IHttpClient _httpClient; - private readonly IHttpRequestBuilderFactory _requestBuilder; - - public SceneMappingProxy(IHttpClient httpClient, ISonarrCloudRequestBuilder requestBuilder) - { - _httpClient = httpClient; - _requestBuilder = requestBuilder.Services; - } - - public List Fetch() - { - var request = _requestBuilder.Create() - .Resource("/scenemapping") - .Build(); - - return _httpClient.Get>(request).Resource; - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingRepository.cs b/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingRepository.cs deleted file mode 100644 index ce86916ec..000000000 --- a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingRepository.cs +++ /dev/null @@ -1,31 +0,0 @@ -using NzbDrone.Core.Datastore; -using NzbDrone.Core.Messaging.Events; -using System.Collections.Generic; - - -namespace NzbDrone.Core.DataAugmentation.Scene -{ - public interface ISceneMappingRepository : IBasicRepository - { - List FindByTvdbid(int tvdbId); - void Clear(string type); - } - - public class SceneMappingRepository : BasicRepository, ISceneMappingRepository - { - public SceneMappingRepository(IMainDatabase database, IEventAggregator eventAggregator) - : base(database, eventAggregator) - { - } - - public List FindByTvdbid(int tvdbId) - { - return Query.Where(x => x.TvdbId == tvdbId); - } - - public void Clear(string type) - { - Delete(s => s.Type == type); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingService.cs b/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingService.cs deleted file mode 100644 index 44385a88f..000000000 --- a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingService.cs +++ /dev/null @@ -1,253 +0,0 @@ -using System; -using System.Linq; -using NLog; -using NzbDrone.Common.Cache; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Messaging.Commands; -using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Parser; -using System.Collections.Generic; -using NzbDrone.Core.Tv.Events; - -namespace NzbDrone.Core.DataAugmentation.Scene -{ - public interface ISceneMappingService - { - List GetSceneNames(int tvdbId, List seasonNumbers, List sceneSeasonNumbers); - int? FindTvdbId(string title); - List FindByTvdbId(int tvdbId); - SceneMapping FindSceneMapping(string title); - int? GetSceneSeasonNumber(string title); - int? GetTvdbSeasonNumber(string title); - int? GetSceneSeasonNumber(int tvdbId, int seasonNumber); - } - - public class SceneMappingService : ISceneMappingService, - IHandle, - IExecute - { - private readonly ISceneMappingRepository _repository; - private readonly IEnumerable _sceneMappingProviders; - private readonly IEventAggregator _eventAggregator; - private readonly Logger _logger; - private readonly ICachedDictionary> _getTvdbIdCache; - private readonly ICachedDictionary> _findByTvdbIdCache; - - public SceneMappingService(ISceneMappingRepository repository, - ICacheManager cacheManager, - IEnumerable sceneMappingProviders, - IEventAggregator eventAggregator, - Logger logger) - { - _repository = repository; - _sceneMappingProviders = sceneMappingProviders; - _eventAggregator = eventAggregator; - _logger = logger; - - _getTvdbIdCache = cacheManager.GetCacheDictionary>(GetType(), "tvdb_id"); - _findByTvdbIdCache = cacheManager.GetCacheDictionary>(GetType(), "find_tvdb_id"); - } - - public List GetSceneNames(int tvdbId, List seasonNumbers, List sceneSeasonNumbers) - { - var mappings = FindByTvdbId(tvdbId); - - if (mappings == null) - { - return new List(); - } - - var names = mappings.Where(n => n.SeasonNumber.HasValue && seasonNumbers.Contains(n.SeasonNumber.Value) || - n.SceneSeasonNumber.HasValue && sceneSeasonNumbers.Contains(n.SceneSeasonNumber.Value) || - (n.SeasonNumber ?? -1) == -1 && (n.SceneSeasonNumber ?? -1) == -1) - .Select(n => n.SearchTerm).Distinct().ToList(); - - return FilterNonEnglish(names); - } - - public int? FindTvdbId(string title) - { - var mapping = FindMapping(title); - - if (mapping == null) - return null; - - return mapping.TvdbId; - } - - public List FindByTvdbId(int tvdbId) - { - if (_findByTvdbIdCache.Count == 0) - { - RefreshCache(); - } - - var mappings = _findByTvdbIdCache.Find(tvdbId.ToString()); - - if (mappings == null) - { - return new List(); - } - - return mappings; - } - - public SceneMapping FindSceneMapping(string title) - { - return FindMapping(title); - } - - public int? GetSceneSeasonNumber(string title) - { - var mapping = FindMapping(title); - - if (mapping == null) - { - return null; - } - - return mapping.SceneSeasonNumber; - } - - public int? GetTvdbSeasonNumber(string title) - { - var mapping = FindMapping(title); - - if (mapping == null) - { - return null; - } - - return mapping.SeasonNumber; - } - - public int? GetSceneSeasonNumber(int tvdbId, int seasonNumber) - { - var mappings = FindByTvdbId(tvdbId); - - if (mappings == null) - { - return null; - } - - var mapping = mappings.FirstOrDefault(e => e.SeasonNumber == seasonNumber && e.SceneSeasonNumber.HasValue); - - if (mapping == null) - { - return null; - } - - return mapping.SceneSeasonNumber; - } - - private void UpdateMappings() - { - _logger.Info("Updating Scene mappings"); - - foreach (var sceneMappingProvider in _sceneMappingProviders) - { - try - { - var mappings = sceneMappingProvider.GetSceneMappings(); - - if (mappings.Any()) - { - _repository.Clear(sceneMappingProvider.GetType().Name); - - mappings.RemoveAll(sceneMapping => - { - if (sceneMapping.Title.IsNullOrWhiteSpace() || - sceneMapping.SearchTerm.IsNullOrWhiteSpace()) - { - _logger.Warn("Invalid scene mapping found for: {0}, skipping", sceneMapping.TvdbId); - return true; - } - - return false; - }); - - foreach (var sceneMapping in mappings) - { - sceneMapping.ParseTerm = sceneMapping.Title.CleanSeriesTitle(); - sceneMapping.Type = sceneMappingProvider.GetType().Name; - } - - _repository.InsertMany(mappings.ToList()); - } - else - { - _logger.Warn("Received empty list of mapping. will not update."); - } - } - catch (Exception ex) - { - _logger.Error(ex, "Failed to Update Scene Mappings."); - } - } - - RefreshCache(); - - _eventAggregator.PublishEvent(new SceneMappingsUpdatedEvent()); - } - - private SceneMapping FindMapping(string title) - { - if (_getTvdbIdCache.Count == 0) - { - RefreshCache(); - } - - var candidates = _getTvdbIdCache.Find(title.CleanSeriesTitle()); - - if (candidates == null) - { - return null; - } - - if (candidates.Count == 1) - { - return candidates.First(); - } - - var exactMatch = candidates.OrderByDescending(v => v.SeasonNumber) - .FirstOrDefault(v => v.Title == title); - - if (exactMatch != null) - { - return exactMatch; - } - - var closestMatch = candidates.OrderBy(v => title.LevenshteinDistance(v.Title, 10, 1, 10)) - .ThenByDescending(v => v.SeasonNumber) - .First(); - - return closestMatch; - } - - private void RefreshCache() - { - var mappings = _repository.All().ToList(); - - _getTvdbIdCache.Update(mappings.GroupBy(v => v.ParseTerm).ToDictionary(v => v.Key, v => v.ToList())); - _findByTvdbIdCache.Update(mappings.GroupBy(v => v.TvdbId).ToDictionary(v => v.Key.ToString(), v => v.ToList())); - } - - private List FilterNonEnglish(List titles) - { - return titles.Where(title => title.All(c => c <= 255)).ToList(); - } - - public void Handle(SeriesRefreshStartingEvent message) - { - if (message.ManualTrigger && _findByTvdbIdCache.IsExpired(TimeSpan.FromMinutes(1))) - { - UpdateMappings(); - } - } - - public void Execute(UpdateSceneMappingCommand message) - { - UpdateMappings(); - } - } -} diff --git a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingsUpdatedEvent.cs b/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingsUpdatedEvent.cs deleted file mode 100644 index 06f6d4a3f..000000000 --- a/src/NzbDrone.Core/DataAugmentation/Scene/SceneMappingsUpdatedEvent.cs +++ /dev/null @@ -1,8 +0,0 @@ -using NzbDrone.Common.Messaging; - -namespace NzbDrone.Core.DataAugmentation.Scene -{ - public class SceneMappingsUpdatedEvent : IEvent - { - } -} diff --git a/src/NzbDrone.Core/DataAugmentation/Scene/ServicesProvider.cs b/src/NzbDrone.Core/DataAugmentation/Scene/ServicesProvider.cs deleted file mode 100644 index 605488cf9..000000000 --- a/src/NzbDrone.Core/DataAugmentation/Scene/ServicesProvider.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; - -namespace NzbDrone.Core.DataAugmentation.Scene -{ - public class ServicesProvider : ISceneMappingProvider - { - private readonly ISceneMappingProxy _sceneMappingProxy; - - public ServicesProvider(ISceneMappingProxy sceneMappingProxy) - { - _sceneMappingProxy = sceneMappingProxy; - } - - public List GetSceneMappings() - { - return _sceneMappingProxy.Fetch(); - } - } -} diff --git a/src/NzbDrone.Core/DataAugmentation/Scene/UpdateSceneMappingCommand.cs b/src/NzbDrone.Core/DataAugmentation/Scene/UpdateSceneMappingCommand.cs deleted file mode 100644 index 215f8e033..000000000 --- a/src/NzbDrone.Core/DataAugmentation/Scene/UpdateSceneMappingCommand.cs +++ /dev/null @@ -1,9 +0,0 @@ -using NzbDrone.Core.Messaging.Commands; - -namespace NzbDrone.Core.DataAugmentation.Scene -{ - public class UpdateSceneMappingCommand : Command - { - - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/DataAugmentation/Xem/Model/XemResult.cs b/src/NzbDrone.Core/DataAugmentation/Xem/Model/XemResult.cs deleted file mode 100644 index 2b041709d..000000000 --- a/src/NzbDrone.Core/DataAugmentation/Xem/Model/XemResult.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace NzbDrone.Core.DataAugmentation.Xem.Model -{ - public class XemResult - { - public string Result { get; set; } - public T Data { get; set; } - public string Message { get; set; } - } -} diff --git a/src/NzbDrone.Core/DataAugmentation/Xem/Model/XemSceneTvdbMapping.cs b/src/NzbDrone.Core/DataAugmentation/Xem/Model/XemSceneTvdbMapping.cs deleted file mode 100644 index 1cc65524a..000000000 --- a/src/NzbDrone.Core/DataAugmentation/Xem/Model/XemSceneTvdbMapping.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace NzbDrone.Core.DataAugmentation.Xem.Model -{ - public class XemSceneTvdbMapping - { - public XemValues Scene { get; set; } - public XemValues Tvdb { get; set; } - } -} diff --git a/src/NzbDrone.Core/DataAugmentation/Xem/Model/XemValues.cs b/src/NzbDrone.Core/DataAugmentation/Xem/Model/XemValues.cs deleted file mode 100644 index ab6764e18..000000000 --- a/src/NzbDrone.Core/DataAugmentation/Xem/Model/XemValues.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace NzbDrone.Core.DataAugmentation.Xem.Model -{ - public class XemValues - { - public int Season { get; set; } - public int Episode { get; set; } - public int Absolute { get; set; } - } -} diff --git a/src/NzbDrone.Core/DataAugmentation/Xem/XemProxy.cs b/src/NzbDrone.Core/DataAugmentation/Xem/XemProxy.cs deleted file mode 100644 index b2c6d6d19..000000000 --- a/src/NzbDrone.Core/DataAugmentation/Xem/XemProxy.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Newtonsoft.Json.Linq; -using NLog; -using NzbDrone.Common.Http; -using NzbDrone.Core.DataAugmentation.Scene; -using NzbDrone.Core.DataAugmentation.Xem.Model; - -namespace NzbDrone.Core.DataAugmentation.Xem -{ - public interface IXemProxy - { - List GetXemSeriesIds(); - List GetSceneTvdbMappings(int id); - List GetSceneTvdbNames(); - } - - public class XemProxy : IXemProxy - { - private const string ROOT_URL = "http://thexem.de/map/"; - - private readonly Logger _logger; - private readonly IHttpClient _httpClient; - private readonly IHttpRequestBuilderFactory _xemRequestBuilder; - - private static readonly string[] IgnoredErrors = { "no single connection", "no show with the tvdb_id" }; - - public XemProxy(IHttpClient httpClient, Logger logger) - { - _httpClient = httpClient; - _logger = logger; - - _xemRequestBuilder = new HttpRequestBuilder(ROOT_URL) - .AddSuffixQueryParam("origin", "tvdb") - .CreateFactory(); - } - - public List GetXemSeriesIds() - { - _logger.Debug("Fetching Series IDs from"); - - var request = _xemRequestBuilder.Create() - .Resource("/havemap") - .Build(); - - var response = _httpClient.Get>>(request).Resource; - CheckForFailureResult(response); - - return response.Data.Select(d => - { - int tvdbId = 0; - int.TryParse(d, out tvdbId); - - return tvdbId; - }).Where(t => t > 0).ToList(); - } - - public List GetSceneTvdbMappings(int id) - { - _logger.Debug("Fetching Mappings for: {0}", id); - - var request = _xemRequestBuilder.Create() - .Resource("/all") - .AddQueryParam("id", id) - .Build(); - - var response = _httpClient.Get>>(request).Resource; - - return response.Data.Where(c => c.Scene != null).ToList(); - } - - public List GetSceneTvdbNames() - { - _logger.Debug("Fetching alternate names"); - - var request = _xemRequestBuilder.Create() - .Resource("/allNames") - .AddQueryParam("seasonNumbers", true) - .Build(); - - var response = _httpClient.Get>>>(request).Resource; - - var result = new List(); - - foreach (var series in response.Data) - { - foreach (var name in series.Value) - { - foreach (var n in name) - { - int seasonNumber; - if (!int.TryParse(n.Value.ToString(), out seasonNumber)) - { - continue; - } - - //hack to deal with Fate/Zero - if (series.Key == 79151 && seasonNumber > 1) - { - continue; - } - - result.Add(new SceneMapping - { - Title = n.Key, - SearchTerm = n.Key, - SceneSeasonNumber = seasonNumber, - TvdbId = series.Key - }); - } - } - } - - return result; - } - - private static void CheckForFailureResult(XemResult response) - { - if (response.Result.Equals("failure", StringComparison.InvariantCultureIgnoreCase) && - !IgnoredErrors.Any(knowError => response.Message.Contains(knowError))) - { - throw new Exception("Error response received from Xem: " + response.Message); - } - } - } -} diff --git a/src/NzbDrone.Core/DataAugmentation/Xem/XemService.cs b/src/NzbDrone.Core/DataAugmentation/Xem/XemService.cs deleted file mode 100644 index c80cd8c92..000000000 --- a/src/NzbDrone.Core/DataAugmentation/Xem/XemService.cs +++ /dev/null @@ -1,243 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NLog; -using NzbDrone.Common.Cache; -using NzbDrone.Core.DataAugmentation.Scene; -using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Tv; -using NzbDrone.Core.Tv.Events; - -namespace NzbDrone.Core.DataAugmentation.Xem -{ - public class XemService : ISceneMappingProvider, IHandle, IHandle - { - private readonly IEpisodeService _episodeService; - private readonly IXemProxy _xemProxy; - private readonly ISeriesService _seriesService; - private readonly Logger _logger; - private readonly ICachedDictionary _cache; - - public XemService(IEpisodeService episodeService, - IXemProxy xemProxy, - ISeriesService seriesService, ICacheManager cacheManager, Logger logger) - { - _episodeService = episodeService; - _xemProxy = xemProxy; - _seriesService = seriesService; - _logger = logger; - _cache = cacheManager.GetCacheDictionary(GetType(), "mappedTvdbid"); - } - - private void PerformUpdate(Series series) - { - _logger.Debug("Updating scene numbering mapping for: {0}", series); - - try - { - var mappings = _xemProxy.GetSceneTvdbMappings(series.TvdbId); - - if (!mappings.Any() && !series.UseSceneNumbering) - { - _logger.Debug("Mappings for: {0} are empty, skipping", series); - return; - } - - var episodes = _episodeService.GetEpisodeBySeries(series.Id); - - foreach (var episode in episodes) - { - episode.SceneAbsoluteEpisodeNumber = null; - episode.SceneSeasonNumber = null; - episode.SceneEpisodeNumber = null; - episode.UnverifiedSceneNumbering = false; - } - - foreach (var mapping in mappings) - { - _logger.Debug("Setting scene numbering mappings for {0} S{1:00}E{2:00}", series, mapping.Tvdb.Season, mapping.Tvdb.Episode); - - var episode = episodes.SingleOrDefault(e => e.SeasonNumber == mapping.Tvdb.Season && e.EpisodeNumber == mapping.Tvdb.Episode); - - if (episode == null) - { - _logger.Debug("Information hasn't been added to TheTVDB yet, skipping."); - continue; - } - - episode.SceneAbsoluteEpisodeNumber = mapping.Scene.Absolute; - episode.SceneSeasonNumber = mapping.Scene.Season; - episode.SceneEpisodeNumber = mapping.Scene.Episode; - } - - if (episodes.Any(v => v.SceneEpisodeNumber.HasValue && v.SceneSeasonNumber != 0)) - { - ExtrapolateMappings(series, episodes, mappings); - } - - _episodeService.UpdateEpisodes(episodes); - series.UseSceneNumbering = mappings.Any(); - _seriesService.UpdateSeries(series); - - _logger.Debug("XEM mapping updated for {0}", series); - } - catch (Exception ex) - { - _logger.Error(ex, "Error updating scene numbering mappings for {0}", series); - } - } - - private void ExtrapolateMappings(Series series, List episodes, List mappings) - { - var mappedEpisodes = episodes.Where(v => v.SeasonNumber != 0 && v.SceneEpisodeNumber.HasValue).ToList(); - var mappedSeasons = new HashSet(mappedEpisodes.Select(v => v.SeasonNumber).Distinct()); - - var sceneEpisodeMappings = mappings.ToLookup(v => v.Scene.Season) - .ToDictionary(v => v.Key, e => new HashSet(e.Select(v => v.Scene.Episode))); - - var firstTvdbEpisodeBySeason = mappings.ToLookup(v => v.Tvdb.Season) - .ToDictionary(v => v.Key, e => e.Min(v => v.Tvdb.Episode)); - - var lastSceneSeason = mappings.Select(v => v.Scene.Season).Max(); - var lastTvdbSeason = mappings.Select(v => v.Tvdb.Season).Max(); - - // Mark all episodes not on the xem as unverified. - foreach (var episode in episodes) - { - if (episode.SeasonNumber == 0) continue; - if (episode.SceneEpisodeNumber.HasValue) continue; - - if (mappedSeasons.Contains(episode.SeasonNumber)) - { - // Mark if a mapping exists for an earlier episode in this season. - if (firstTvdbEpisodeBySeason[episode.SeasonNumber] <= episode.EpisodeNumber) - { - episode.UnverifiedSceneNumbering = true; - continue; - } - - // Mark if a mapping exists with a scene number to this episode. - if (sceneEpisodeMappings.ContainsKey(episode.SeasonNumber) && - sceneEpisodeMappings[episode.SeasonNumber].Contains(episode.EpisodeNumber)) - { - episode.UnverifiedSceneNumbering = true; - continue; - } - } - else if (lastSceneSeason != lastTvdbSeason && episode.SeasonNumber > lastTvdbSeason) - { - episode.UnverifiedSceneNumbering = true; - } - } - - foreach (var episode in episodes) - { - if (episode.SeasonNumber == 0) continue; - if (episode.SceneEpisodeNumber.HasValue) continue; - if (episode.SeasonNumber < lastTvdbSeason) continue; - if (!episode.UnverifiedSceneNumbering) continue; - - var seasonMappings = mappings.Where(v => v.Tvdb.Season == episode.SeasonNumber).ToList(); - if (seasonMappings.Any(v => v.Tvdb.Episode >= episode.EpisodeNumber)) - { - continue; - } - - if (seasonMappings.Any()) - { - var lastEpisodeMapping = seasonMappings.OrderBy(v => v.Tvdb.Episode).Last(); - var lastSceneSeasonMapping = mappings.Where(v => v.Scene.Season == lastEpisodeMapping.Scene.Season).OrderBy(v => v.Scene.Episode).Last(); - - if (lastSceneSeasonMapping.Tvdb.Season == 0) - { - continue; - } - - var offset = episode.EpisodeNumber - lastEpisodeMapping.Tvdb.Episode; - - episode.SceneSeasonNumber = lastEpisodeMapping.Scene.Season; - episode.SceneEpisodeNumber = lastEpisodeMapping.Scene.Episode + offset; - episode.SceneAbsoluteEpisodeNumber = lastEpisodeMapping.Scene.Absolute + offset; - } - else if (lastTvdbSeason != lastSceneSeason) - { - var offset = episode.SeasonNumber - lastTvdbSeason; - - episode.SceneSeasonNumber = lastSceneSeason + offset; - episode.SceneEpisodeNumber = episode.EpisodeNumber; - // TODO: SceneAbsoluteEpisodeNumber. - } - } - } - - private void UpdateXemSeriesIds() - { - try - { - var ids = _xemProxy.GetXemSeriesIds(); - - if (ids.Any()) - { - _cache.Update(ids.ToDictionary(v => v.ToString(), v => true)); - return; - } - - _cache.ExtendTTL(); - _logger.Warn("Failed to update Xem series list."); - } - catch (Exception ex) - { - _cache.ExtendTTL(); - _logger.Warn(ex, "Failed to update Xem series list."); - } - } - - public List GetSceneMappings() - { - var mappings = _xemProxy.GetSceneTvdbNames(); - - return mappings.Where(m => - { - int id; - - if (int.TryParse(m.Title, out id)) - { - _logger.Debug("Skipping all numeric name: {0} for {1}", m.Title, m.TvdbId); - return false; - } - - return true; - }).ToList(); - } - - public void Handle(SeriesUpdatedEvent message) - { - if (_cache.IsExpired(TimeSpan.FromHours(3))) - { - UpdateXemSeriesIds(); - } - - if (_cache.Count == 0) - { - _logger.Debug("Scene numbering is not available"); - return; - } - - if (!_cache.Find(message.Series.TvdbId.ToString()) && !message.Series.UseSceneNumbering) - { - _logger.Debug("Scene numbering is not available for {0} [{1}]", message.Series.Title, message.Series.TvdbId); - return; - } - - PerformUpdate(message.Series); - } - - public void Handle(SeriesRefreshStartingEvent message) - { - if (message.ManualTrigger && _cache.IsExpired(TimeSpan.FromMinutes(1))) - { - UpdateXemSeriesIds(); - } - } - } -} diff --git a/src/NzbDrone.Core/Datastore/BasicRepository.cs b/src/NzbDrone.Core/Datastore/BasicRepository.cs index b2afafdc4..3c823b531 100644 --- a/src/NzbDrone.Core/Datastore/BasicRepository.cs +++ b/src/NzbDrone.Core/Datastore/BasicRepository.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Data; using System.Linq; @@ -31,6 +31,7 @@ namespace NzbDrone.Core.Datastore bool HasItems(); void DeleteMany(IEnumerable ids); void SetFields(TModel model, params Expression>[] properties); + void SetFields(IEnumerable models, params Expression>[] properties); TModel Single(); PagingSpec GetPaged(PagingSpec pagingSpec); } @@ -48,7 +49,7 @@ namespace NzbDrone.Core.Datastore _eventAggregator = eventAggregator; } - protected QueryBuilder Query => DataMapper.Query(); + protected virtual QueryBuilder Query => DataMapper.Query(); protected void Delete(Expression> filter) { @@ -57,7 +58,7 @@ namespace NzbDrone.Core.Datastore public IEnumerable All() { - return DataMapper.Query().ToList(); + return Query.ToList(); } public int Count() @@ -80,7 +81,7 @@ namespace NzbDrone.Core.Datastore public IEnumerable Get(IEnumerable ids) { var idList = ids.ToList(); - var query = string.Format("Id IN ({0})", string.Join(",", idList)); + var query = string.Format("[t0].[Id] IN ({0})", string.Join(",", idList)); var result = Query.Where(query).ToList(); if (result.Count != idList.Count()) @@ -244,6 +245,30 @@ namespace NzbDrone.Core.Datastore ModelUpdated(model); } + public void SetFields(IEnumerable models, params Expression>[] properties) + { + using (var unitOfWork = new UnitOfWork(() => DataMapper)) + { + unitOfWork.BeginTransaction(IsolationLevel.ReadCommitted); + + foreach (var model in models) + { + if (model.Id == 0) + { + throw new InvalidOperationException("Can't update model with ID 0"); + } + + unitOfWork.DB.Update() + .Where(c => c.Id == model.Id) + .ColumnsIncluding(properties) + .Entity(model) + .Execute(); + } + + unitOfWork.Commit(); + } + } + public virtual PagingSpec GetPaged(PagingSpec pagingSpec) { pagingSpec.Records = GetPagedQuery(Query, pagingSpec).ToList(); @@ -254,10 +279,21 @@ namespace NzbDrone.Core.Datastore protected virtual SortBuilder GetPagedQuery(QueryBuilder query, PagingSpec pagingSpec) { - return query.Where(pagingSpec.FilterExpression) - .OrderBy(pagingSpec.OrderByClause(), pagingSpec.ToSortDirection()) - .Skip(pagingSpec.PagingOffset()) - .Take(pagingSpec.PageSize); + var filterExpressions = pagingSpec.FilterExpressions; + var sortQuery = query.Where(filterExpressions.FirstOrDefault()); + + if (filterExpressions.Count > 1) + { + // Start at the second item for the AndWhere clauses + for (var i = 1; i < filterExpressions.Count; i++) + { + sortQuery.AndWhere(filterExpressions[i]); + } + } + + return sortQuery.OrderBy(pagingSpec.OrderByClause(), pagingSpec.ToSortDirection()) + .Skip(pagingSpec.PagingOffset()) + .Take(pagingSpec.PageSize); } protected void ModelCreated(TModel model) diff --git a/src/NzbDrone.Core/Datastore/ConnectionStringFactory.cs b/src/NzbDrone.Core/Datastore/ConnectionStringFactory.cs index 522b72e94..a69883dc1 100644 --- a/src/NzbDrone.Core/Datastore/ConnectionStringFactory.cs +++ b/src/NzbDrone.Core/Datastore/ConnectionStringFactory.cs @@ -16,7 +16,7 @@ namespace NzbDrone.Core.Datastore { public ConnectionStringFactory(IAppFolderInfo appFolderInfo) { - MainDbConnectionString = GetConnectionString(appFolderInfo.GetNzbDroneDatabase()); + MainDbConnectionString = GetConnectionString(appFolderInfo.GetDatabase()); LogDbConnectionString = GetConnectionString(appFolderInfo.GetLogDatabase()); } @@ -35,7 +35,7 @@ namespace NzbDrone.Core.Datastore var connectionBuilder = new SQLiteConnectionStringBuilder(); connectionBuilder.DataSource = dbPath; - connectionBuilder.CacheSize = (int)-10.Megabytes(); + connectionBuilder.CacheSize = (int)-10000; connectionBuilder.DateTimeKind = DateTimeKind.Utc; connectionBuilder.JournalMode = OsInfo.IsOsx ? SQLiteJournalModeEnum.Truncate : SQLiteJournalModeEnum.Wal; connectionBuilder.Pooling = true; diff --git a/src/NzbDrone.Core/Datastore/Converters/EmbeddedDocumentConverter.cs b/src/NzbDrone.Core/Datastore/Converters/EmbeddedDocumentConverter.cs index d5321d794..d2b9146f2 100644 --- a/src/NzbDrone.Core/Datastore/Converters/EmbeddedDocumentConverter.cs +++ b/src/NzbDrone.Core/Datastore/Converters/EmbeddedDocumentConverter.cs @@ -22,7 +22,7 @@ namespace NzbDrone.Core.Datastore.Converters ContractResolver = new CamelCasePropertyNamesContractResolver() }; - SerializerSetting.Converters.Add(new StringEnumConverter { CamelCaseText = true }); + SerializerSetting.Converters.Add(new StringEnumConverter { NamingStrategy = new CamelCaseNamingStrategy() }); SerializerSetting.Converters.Add(new VersionConverter()); foreach (var converter in converters) 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/PrimaryAlbumTypeIntConverter.cs b/src/NzbDrone.Core/Datastore/Converters/PrimaryAlbumTypeIntConverter.cs new file mode 100644 index 000000000..6bce7a347 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Converters/PrimaryAlbumTypeIntConverter.cs @@ -0,0 +1,63 @@ +using Marr.Data.Converters; +using Marr.Data.Mapping; +using Newtonsoft.Json; +using NzbDrone.Core.Music; +using System; + +namespace NzbDrone.Core.Datastore.Converters +{ + public class PrimaryAlbumTypeIntConverter : JsonConverter, IConverter + { + public object FromDB(ConverterContext context) + { + if (context.DbValue == DBNull.Value) + { + return PrimaryAlbumType.Album; + } + + var val = Convert.ToInt32(context.DbValue); + + return (PrimaryAlbumType) val; + } + + public object FromDB(ColumnMap map, object dbValue) + { + return FromDB(new ConverterContext {ColumnMap = map, DbValue = dbValue}); + } + + public object ToDB(object clrValue) + { + if (clrValue == DBNull.Value) + { + return 0; + } + + if (clrValue as PrimaryAlbumType == null) + { + throw new InvalidOperationException("Attempted to save an album type that isn't really an album type"); + } + + var primType = (PrimaryAlbumType) clrValue; + return (int) primType; + } + + public Type DbType => typeof(int); + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(PrimaryAlbumType); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, + JsonSerializer serializer) + { + var item = reader.Value; + return (PrimaryAlbumType) Convert.ToInt32(item); + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + writer.WriteValue(ToDB(value)); + } + } +} 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/ReleaseStatusIntConverter.cs b/src/NzbDrone.Core/Datastore/Converters/ReleaseStatusIntConverter.cs new file mode 100644 index 000000000..31a41ec5e --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Converters/ReleaseStatusIntConverter.cs @@ -0,0 +1,63 @@ +using Marr.Data.Converters; +using Marr.Data.Mapping; +using Newtonsoft.Json; +using NzbDrone.Core.Music; +using System; + +namespace NzbDrone.Core.Datastore.Converters +{ + public class ReleaseStatusIntConverter : JsonConverter, IConverter + { + public object FromDB(ConverterContext context) + { + if (context.DbValue == DBNull.Value) + { + return ReleaseStatus.Official; + } + + var val = Convert.ToInt32(context.DbValue); + + return (ReleaseStatus) val; + } + + public object FromDB(ColumnMap map, object dbValue) + { + return FromDB(new ConverterContext {ColumnMap = map, DbValue = dbValue}); + } + + public object ToDB(object clrValue) + { + if (clrValue == DBNull.Value) + { + return 0; + } + + if (clrValue as ReleaseStatus == null) + { + throw new InvalidOperationException("Attempted to save a release status that isn't really a release status"); + } + + var releaseStatus = (ReleaseStatus) clrValue; + return (int) releaseStatus; + } + + public Type DbType => typeof(int); + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(ReleaseStatus); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, + JsonSerializer serializer) + { + var item = reader.Value; + return (ReleaseStatus) Convert.ToInt32(item); + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + writer.WriteValue(ToDB(value)); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Converters/SecondaryAlbumTypeIntConverter.cs b/src/NzbDrone.Core/Datastore/Converters/SecondaryAlbumTypeIntConverter.cs new file mode 100644 index 000000000..87ae2ee5f --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Converters/SecondaryAlbumTypeIntConverter.cs @@ -0,0 +1,63 @@ +using System; +using Marr.Data.Converters; +using Marr.Data.Mapping; +using Newtonsoft.Json; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Datastore.Converters +{ + public class SecondaryAlbumTypeIntConverter : JsonConverter, IConverter + { + public object FromDB(ConverterContext context) + { + if (context.DbValue == DBNull.Value) + { + return SecondaryAlbumType.Studio; + } + + var val = Convert.ToInt32(context.DbValue); + + return (SecondaryAlbumType) val; + } + + public object FromDB(ColumnMap map, object dbValue) + { + return FromDB(new ConverterContext {ColumnMap = map, DbValue = dbValue}); + } + + public object ToDB(object clrValue) + { + if (clrValue == DBNull.Value) + { + return 0; + } + + if (clrValue as SecondaryAlbumType == null) + { + throw new InvalidOperationException("Attempted to save an album type that isn't really an album type"); + } + + var secType = (SecondaryAlbumType) clrValue; + return (int) secType; + } + + public Type DbType => typeof(int); + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(SecondaryAlbumType); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, + JsonSerializer serializer) + { + var item = reader.Value; + return (SecondaryAlbumType) Convert.ToInt32(item); + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + writer.WriteValue(ToDB(value)); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Converters/TimeSpanConverter.cs b/src/NzbDrone.Core/Datastore/Converters/TimeSpanConverter.cs index 9ea6b398f..579a719a0 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()); - } - - public object FromDB(ColumnMap map, object dbValue) - { - if (dbValue == DBNull.Value) + if (context.DbValue is TimeSpan) { - return DBNull.Value; + return context.DbValue; } - if (dbValue is TimeSpan) - { - return dbValue; - } + return TimeSpan.Parse(context.DbValue.ToString(), CultureInfo.InvariantCulture); + } - return TimeSpan.Parse(dbValue.ToString(), CultureInfo.InvariantCulture); + public object FromDB(ColumnMap map, object dbValue) + { + 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/CorruptDatabaseException.cs b/src/NzbDrone.Core/Datastore/CorruptDatabaseException.cs index 1d8b6696d..ba73f6b5c 100644 --- a/src/NzbDrone.Core/Datastore/CorruptDatabaseException.cs +++ b/src/NzbDrone.Core/Datastore/CorruptDatabaseException.cs @@ -1,9 +1,9 @@ -using System; +using System; using NzbDrone.Common.Exceptions; namespace NzbDrone.Core.Datastore { - public class CorruptDatabaseException : NzbDroneException + public class CorruptDatabaseException : LidarrStartupException { public CorruptDatabaseException(string message, params object[] args) : base(message, args) { diff --git a/src/NzbDrone.Core/Datastore/Database.cs b/src/NzbDrone.Core/Datastore/Database.cs index c4e59f983..9cc9665e9 100644 --- a/src/NzbDrone.Core/Datastore/Database.cs +++ b/src/NzbDrone.Core/Datastore/Database.cs @@ -1,4 +1,4 @@ -using System; +using System; using Marr.Data; using NLog; using NzbDrone.Common.Instrumentation; @@ -9,6 +9,7 @@ namespace NzbDrone.Core.Datastore { IDataMapper GetDataMapper(); Version Version { get; } + int Migration { get; } void Vacuum(); } @@ -25,7 +26,6 @@ namespace NzbDrone.Core.Datastore _datamapperFactory = datamapperFactory; } - public IDataMapper GetDataMapper() { return _datamapperFactory(); @@ -40,6 +40,16 @@ namespace NzbDrone.Core.Datastore } } + public int Migration + { + get + { + var migration = _datamapperFactory() + .ExecuteScalar("SELECT version from VersionInfo ORDER BY version DESC LIMIT 1").ToString(); + return Convert.ToInt32(migration); + } + } + public void Vacuum() { try @@ -54,4 +64,4 @@ namespace NzbDrone.Core.Datastore } } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Datastore/DatabaseRestorationService.cs b/src/NzbDrone.Core/Datastore/DatabaseRestorationService.cs new file mode 100644 index 000000000..4be69f5d0 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/DatabaseRestorationService.cs @@ -0,0 +1,56 @@ +using System; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Instrumentation; + +namespace NzbDrone.Core.Datastore +{ + public interface IRestoreDatabase + { + void Restore(); + } + + public class DatabaseRestorationService : IRestoreDatabase + { + private readonly IDiskProvider _diskProvider; + private readonly IAppFolderInfo _appFolderInfo; + private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(DatabaseRestorationService)); + + public DatabaseRestorationService(IDiskProvider diskProvider, IAppFolderInfo appFolderInfo) + { + _diskProvider = diskProvider; + _appFolderInfo = appFolderInfo; + } + + public void Restore() + { + var dbRestorePath = _appFolderInfo.GetDatabaseRestore(); + + if (!_diskProvider.FileExists(dbRestorePath)) + { + return; + } + + try + { + Logger.Info("Restoring Database"); + + var dbPath = _appFolderInfo.GetDatabase(); + + _diskProvider.DeleteFile(dbPath + "-shm"); + _diskProvider.DeleteFile(dbPath + "-wal"); + _diskProvider.DeleteFile(dbPath + "-journal"); + _diskProvider.DeleteFile(dbPath); + + _diskProvider.MoveFile(dbRestorePath, dbPath); + } + catch (Exception e) + { + Logger.Error(e, "Failed to restore database"); + throw; + } + } + } +} diff --git a/src/NzbDrone.Core/Datastore/DbFactory.cs b/src/NzbDrone.Core/Datastore/DbFactory.cs index d2a239d6d..ed7698286 100644 --- a/src/NzbDrone.Core/Datastore/DbFactory.cs +++ b/src/NzbDrone.Core/Datastore/DbFactory.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Data.SQLite; using Marr.Data; using Marr.Data.Reflection; @@ -6,6 +6,7 @@ using NLog; using NzbDrone.Common.Composition; using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Exceptions; using NzbDrone.Common.Instrumentation; using NzbDrone.Core.Datastore.Migration.Framework; @@ -24,13 +25,24 @@ namespace NzbDrone.Core.Datastore private readonly IMigrationController _migrationController; private readonly IConnectionStringFactory _connectionStringFactory; private readonly IDiskProvider _diskProvider; + private readonly IRestoreDatabase _restoreDatabaseService; 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()); @@ -44,11 +56,13 @@ namespace NzbDrone.Core.Datastore public DbFactory(IMigrationController migrationController, IConnectionStringFactory connectionStringFactory, - IDiskProvider diskProvider) + IDiskProvider diskProvider, + IRestoreDatabase restoreDatabaseService) { _migrationController = migrationController; _connectionStringFactory = connectionStringFactory; _diskProvider = diskProvider; + _restoreDatabaseService = restoreDatabaseService; } public IDatabase Create(MigrationType migrationType = MigrationType.Main) @@ -59,18 +73,21 @@ namespace NzbDrone.Core.Datastore public IDatabase Create(MigrationContext migrationContext) { string connectionString; - - + switch (migrationContext.MigrationType) { case MigrationType.Main: { connectionString = _connectionStringFactory.MainDbConnectionString; + CreateMain(connectionString, migrationContext); + break; } case MigrationType.Log: { connectionString = _connectionStringFactory.LogDbConnectionString; + CreateLog(connectionString, migrationContext); + break; } default: @@ -79,55 +96,74 @@ namespace NzbDrone.Core.Datastore } } + var db = new Database(migrationContext.MigrationType.ToString(), () => + { + var dataMapper = new DataMapper(SQLiteFactory.Instance, connectionString) + { + SqlMode = SqlModes.Text, + }; + + return dataMapper; + }); + + return db; + } + + private void CreateMain(string connectionString, MigrationContext migrationContext) + { + try { + _restoreDatabaseService.Restore(); _migrationController.Migrate(connectionString, migrationContext); } - catch (SQLiteException ex) + catch (SQLiteException e) { var fileName = _connectionStringFactory.GetDatabasePath(connectionString); - if (migrationContext.MigrationType == MigrationType.Log) + if (OsInfo.IsOsx) { - Logger.Error(ex, "Logging database is corrupt, attempting to recreate it automatically"); - - try - { - _diskProvider.DeleteFile(fileName + "-shm"); - _diskProvider.DeleteFile(fileName + "-wal"); - _diskProvider.DeleteFile(fileName + "-journal"); - _diskProvider.DeleteFile(fileName); - } - catch (Exception) - { - Logger.Error("Unable to recreate logging database automatically. It will need to be removed manually."); - } - - _migrationController.Migrate(connectionString, migrationContext); + throw new CorruptDatabaseException("Database file: {0} is corrupt, restore from backup if available. See: https://github.com/Lidarr/Lidarr/wiki/FAQ#i-use-lidarr-on-a-mac-and-it-suddenly-stopped-working-what-happened", e, fileName); } - else - { - if (OsInfo.IsOsx) - { - throw new CorruptDatabaseException("Database file: {0} is corrupt, restore from backup if available. See: https://github.com/Sonarr/Sonarr/wiki/FAQ#i-use-sonarr-on-a-mac-and-it-suddenly-stopped-working-what-happened", ex, fileName); - } + throw new CorruptDatabaseException("Database file: {0} is corrupt, restore from backup if available. See: https://github.com/Lidarr/Lidarr/wiki/FAQ#i-am-getting-an-error-database-disk-image-is-malformed", e, fileName); + } + catch (Exception e) + { + throw new LidarrStartupException(e, "Error creating main database"); + } + } - throw new CorruptDatabaseException("Database file: {0} is corrupt, restore from backup if available. See: https://github.com/Sonarr/Sonarr/wiki/FAQ#i-am-getting-an-error-database-disk-image-is-malformed", ex, fileName); - } + private void CreateLog(string connectionString, MigrationContext migrationContext) + { + try + { + _migrationController.Migrate(connectionString, migrationContext); } + catch (SQLiteException e) + { + var fileName = _connectionStringFactory.GetDatabasePath(connectionString); - var db = new Database(migrationContext.MigrationType.ToString(), () => - { - var dataMapper = new DataMapper(SQLiteFactory.Instance, connectionString) - { - SqlMode = SqlModes.Text, - }; + Logger.Error(e, "Logging database is corrupt, attempting to recreate it automatically"); - return dataMapper; - }); + try + { + _diskProvider.DeleteFile(fileName + "-shm"); + _diskProvider.DeleteFile(fileName + "-wal"); + _diskProvider.DeleteFile(fileName + "-journal"); + _diskProvider.DeleteFile(fileName); + } + catch (Exception) + { + Logger.Error("Unable to recreate logging database automatically. It will need to be removed manually."); + } - return db; + _migrationController.Migrate(connectionString, migrationContext); + } + catch (Exception e) + { + throw new LidarrStartupException(e, "Error creating log database"); + } } } } diff --git a/src/NzbDrone.Core/Datastore/Extensions/RelationshipExtensions.cs b/src/NzbDrone.Core/Datastore/Extensions/RelationshipExtensions.cs index 7c5669c99..4fdc76a2e 100644 --- a/src/NzbDrone.Core/Datastore/Extensions/RelationshipExtensions.cs +++ b/src/NzbDrone.Core/Datastore/Extensions/RelationshipExtensions.cs @@ -25,15 +25,7 @@ namespace NzbDrone.Core.Datastore.Extensions public static RelationshipBuilder Relationship(this ColumnMapBuilder mapBuilder) { - return mapBuilder.Relationships.AutoMapComplexTypeProperties(); - } - - public static RelationshipBuilder HasMany(this RelationshipBuilder relationshipBuilder, Expression>> portalExpression, Func childIdSelector) - where TParent : ModelBase - where TChild : ModelBase - { - return relationshipBuilder.For(portalExpression.GetMemberName()) - .LazyLoad((db, parent) => db.Query().Where(c => c.Id == childIdSelector(parent)).ToList()); + return mapBuilder.Relationships.MapProperties(); } private static string GetMemberName(this Expression> member) diff --git a/src/NzbDrone.Core/Datastore/LogDatabase.cs b/src/NzbDrone.Core/Datastore/LogDatabase.cs index c454e9997..e9267ea37 100644 --- a/src/NzbDrone.Core/Datastore/LogDatabase.cs +++ b/src/NzbDrone.Core/Datastore/LogDatabase.cs @@ -1,4 +1,4 @@ -using System; +using System; using Marr.Data; namespace NzbDrone.Core.Datastore @@ -24,6 +24,8 @@ namespace NzbDrone.Core.Datastore public Version Version => _database.Version; + public int Migration => _database.Migration; + public void Vacuum() { _database.Vacuum(); diff --git a/src/NzbDrone.Core/Datastore/MainDatabase.cs b/src/NzbDrone.Core/Datastore/MainDatabase.cs index 8ce09eaf2..52736828c 100644 --- a/src/NzbDrone.Core/Datastore/MainDatabase.cs +++ b/src/NzbDrone.Core/Datastore/MainDatabase.cs @@ -1,4 +1,4 @@ -using System; +using System; using Marr.Data; namespace NzbDrone.Core.Datastore @@ -24,6 +24,8 @@ namespace NzbDrone.Core.Datastore public Version Version => _database.Version; + public int Migration => _database.Migration; + public void Vacuum() { _database.Vacuum(); diff --git a/src/NzbDrone.Core/Datastore/Migration/001_initial_setup.cs b/src/NzbDrone.Core/Datastore/Migration/001_initial_setup.cs index b2792fe56..d6cdaa68c 100644 --- a/src/NzbDrone.Core/Datastore/Migration/001_initial_setup.cs +++ b/src/NzbDrone.Core/Datastore/Migration/001_initial_setup.cs @@ -1,4 +1,4 @@ -using FluentMigrator; +using FluentMigrator; using NzbDrone.Core.Datastore.Migration.Framework; namespace NzbDrone.Core.Datastore.Migration @@ -15,127 +15,297 @@ namespace NzbDrone.Core.Datastore.Migration Create.TableForModel("RootFolders") .WithColumn("Path").AsString().Unique(); - Create.TableForModel("Series") - .WithColumn("TvdbId").AsInt32().Unique() - .WithColumn("TvRageId").AsInt32().Unique() - .WithColumn("ImdbId").AsString().Unique() - .WithColumn("Title").AsString() - .WithColumn("TitleSlug").AsString().Unique() - .WithColumn("CleanTitle").AsString() + Create.TableForModel("Artists") + .WithColumn("ForeignArtistId").AsString().Unique() + .WithColumn("MBId").AsString().Nullable() + .WithColumn("AMId").AsString().Nullable() + .WithColumn("TADBId").AsInt32().Nullable() + .WithColumn("DiscogsId").AsInt32().Nullable() + .WithColumn("Name").AsString() + .WithColumn("NameSlug").AsString().Nullable().Unique() + .WithColumn("CleanName").AsString().Indexed() .WithColumn("Status").AsInt32() .WithColumn("Overview").AsString().Nullable() - .WithColumn("AirTime").AsString().Nullable() .WithColumn("Images").AsString() - .WithColumn("Path").AsString() + .WithColumn("Path").AsString().Indexed() .WithColumn("Monitored").AsBoolean() - .WithColumn("QualityProfileId").AsInt32() - .WithColumn("SeasonFolder").AsBoolean() + .WithColumn("AlbumFolder").AsBoolean() .WithColumn("LastInfoSync").AsDateTime().Nullable() .WithColumn("LastDiskSync").AsDateTime().Nullable() - .WithColumn("Runtime").AsInt32() - .WithColumn("SeriesType").AsInt32() - .WithColumn("BacklogSetting").AsInt32() - .WithColumn("Network").AsString().Nullable() - .WithColumn("CustomStartDate").AsDateTime().Nullable() - .WithColumn("UseSceneNumbering").AsBoolean() - .WithColumn("FirstAired").AsDateTime().Nullable() - .WithColumn("NextAiring").AsDateTime().Nullable(); - - Create.TableForModel("Seasons") - .WithColumn("SeriesId").AsInt32() - .WithColumn("SeasonNumber").AsInt32() - .WithColumn("Ignored").AsBoolean(); - - Create.TableForModel("Episodes") - .WithColumn("TvDbEpisodeId").AsInt32().Unique() - .WithColumn("SeriesId").AsInt32() - .WithColumn("SeasonNumber").AsInt32() - .WithColumn("EpisodeNumber").AsInt32() - .WithColumn("Title").AsString().Nullable() + .WithColumn("DateFormed").AsDateTime().Nullable() + .WithColumn("Members").AsString().Nullable() + .WithColumn("Ratings").AsString().Nullable() + .WithColumn("Genres").AsString().Nullable() + .WithColumn("SortName").AsString().Nullable() + .WithColumn("ProfileId").AsInt32().Nullable() + .WithColumn("Tags").AsString().Nullable() + .WithColumn("Added").AsDateTime().Nullable() + .WithColumn("AddOptions").AsString().Nullable() + .WithColumn("LanguageProfileId").AsInt32().WithDefaultValue(1) + .WithColumn("Links").AsString().Nullable() + .WithColumn("ArtistType").AsString().Nullable() + .WithColumn("Disambiguation").AsString().Nullable() + .WithColumn("PrimaryAlbumTypes").AsString().Nullable() + .WithColumn("SecondaryAlbumTypes").AsString().Nullable(); + + Create.TableForModel("Albums") + .WithColumn("ForeignAlbumId").AsString().Unique() + .WithColumn("ArtistId").AsInt32() + .WithColumn("MBId").AsString().Nullable().Indexed() + .WithColumn("AMId").AsString().Nullable() + .WithColumn("TADBId").AsInt32().Nullable().Indexed() + .WithColumn("DiscogsId").AsInt32().Nullable() + .WithColumn("Title").AsString() + .WithColumn("TitleSlug").AsString().Nullable().Unique() + .WithColumn("CleanTitle").AsString().Indexed() .WithColumn("Overview").AsString().Nullable() - .WithColumn("Ignored").AsBoolean().Nullable() - .WithColumn("EpisodeFileId").AsInt32().Nullable() - .WithColumn("AirDate").AsDateTime().Nullable() - .WithColumn("AbsoluteEpisodeNumber").AsInt32().Nullable() - .WithColumn("SceneAbsoluteEpisodeNumber").AsInt32().Nullable() - .WithColumn("SceneSeasonNumber").AsInt32().Nullable() - .WithColumn("SceneEpisodeNumber").AsInt32().Nullable(); - - Create.TableForModel("EpisodeFiles") - .WithColumn("SeriesId").AsInt32() - .WithColumn("Path").AsString().Unique() - .WithColumn("Quality").AsString() - .WithColumn("Size").AsInt64() - .WithColumn("DateAdded").AsDateTime() - .WithColumn("SeasonNumber").AsInt32() - .WithColumn("SceneName").AsString().Nullable() - .WithColumn("ReleaseGroup").AsString().Nullable(); + .WithColumn("Images").AsString() + .WithColumn("Path").AsString().Indexed() + .WithColumn("Monitored").AsBoolean() + .WithColumn("LastInfoSync").AsDateTime().Nullable() + .WithColumn("LastDiskSync").AsDateTime().Nullable() + .WithColumn("ReleaseDate").AsDateTime().Nullable() + .WithColumn("Ratings").AsString().Nullable() + .WithColumn("Genres").AsString().Nullable() + .WithColumn("Label").AsString().Nullable() + .WithColumn("SortTitle").AsString().Nullable() + .WithColumn("ProfileId").AsInt32().Nullable() + .WithColumn("Tags").AsString().Nullable() + .WithColumn("Added").AsDateTime().Nullable() + .WithColumn("AlbumType").AsString() + .WithColumn("AddOptions").AsString().Nullable() + .WithColumn("Duration").AsInt32().WithDefaultValue(0); + + + Create.TableForModel("Tracks") + .WithColumn("ForeignTrackId").AsString().Unique() + .WithColumn("ArtistId").AsInt32().Indexed() + .WithColumn("AlbumId").AsInt32() + .WithColumn("TrackNumber").AsInt32() + .WithColumn("Title").AsString().Nullable() + .WithColumn("Explicit").AsBoolean() + .WithColumn("Compilation").AsBoolean() + .WithColumn("DiscNumber").AsInt32().Nullable() + .WithColumn("TrackFileId").AsInt32().Nullable().Indexed() + .WithColumn("Monitored").AsBoolean() + .WithColumn("Ratings").AsString().Nullable() + .WithColumn("Duration").AsInt32().WithDefaultValue(0); + + Create.Index().OnTable("Tracks").OnColumn("ArtistId").Ascending() + .OnColumn("AlbumId").Ascending() + .OnColumn("TrackNumber").Ascending(); + + Create.TableForModel("TrackFiles") + .WithColumn("ArtistId").AsInt32().Indexed() + .WithColumn("AlbumId").AsInt32().Indexed() + .WithColumn("Quality").AsString() + .WithColumn("Size").AsInt64() + .WithColumn("SceneName").AsString().Nullable() + .WithColumn("DateAdded").AsDateTime() + .WithColumn("ReleaseGroup").AsString().Nullable() + .WithColumn("MediaInfo").AsString().Nullable() + .WithColumn("RelativePath").AsString().Nullable() + .WithColumn("Language").AsInt32().WithDefaultValue(0); Create.TableForModel("History") - .WithColumn("EpisodeId").AsInt32() - .WithColumn("SeriesId").AsInt32() - .WithColumn("NzbTitle").AsString() - .WithColumn("Date").AsDateTime() - .WithColumn("Quality").AsString() - .WithColumn("Indexer").AsString() - .WithColumn("NzbInfoUrl").AsString().Nullable() - .WithColumn("ReleaseGroup").AsString().Nullable(); + .WithColumn("SourceTitle").AsString() + .WithColumn("Date").AsDateTime().Indexed() + .WithColumn("Quality").AsString() + .WithColumn("Data").AsString() + .WithColumn("EventType").AsInt32().Nullable().Indexed() + .WithColumn("DownloadId").AsString().Nullable().Indexed() + .WithColumn("Language").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ArtistId").AsInt32().WithDefaultValue(0) + .WithColumn("AlbumId").AsInt32().Indexed().WithDefaultValue(0) + .WithColumn("TrackId").AsInt32().WithDefaultValue(0); Create.TableForModel("Notifications") - .WithColumn("Name").AsString() - .WithColumn("OnGrab").AsBoolean() - .WithColumn("OnDownload").AsBoolean() - .WithColumn("Settings").AsString() - .WithColumn("Implementation").AsString(); + .WithColumn("Name").AsString() + .WithColumn("OnGrab").AsBoolean() + .WithColumn("OnDownload").AsBoolean() + .WithColumn("Settings").AsString() + .WithColumn("Implementation").AsString() + .WithColumn("ConfigContract").AsString().Nullable() + .WithColumn("OnUpgrade").AsBoolean().Nullable() + .WithColumn("Tags").AsString().Nullable() + .WithColumn("OnRename").AsBoolean().NotNullable(); Create.TableForModel("ScheduledTasks") - .WithColumn("TypeName").AsString().Unique() - .WithColumn("Interval").AsInt32() - .WithColumn("LastExecution").AsDateTime(); + .WithColumn("TypeName").AsString().Unique() + .WithColumn("Interval").AsInt32() + .WithColumn("LastExecution").AsDateTime(); Create.TableForModel("Indexers") - .WithColumn("Enable").AsBoolean() - .WithColumn("Name").AsString().Unique() - .WithColumn("Implementation").AsString() - .WithColumn("Settings").AsString().Nullable(); + .WithColumn("Name").AsString().Unique() + .WithColumn("Implementation").AsString() + .WithColumn("Settings").AsString().Nullable() + .WithColumn("ConfigContract").AsString().Nullable() + .WithColumn("EnableRss").AsBoolean().Nullable() + .WithColumn("EnableSearch").AsBoolean().Nullable(); + + Create.TableForModel("Profiles") + .WithColumn("Name").AsString().Unique() + .WithColumn("Cutoff").AsInt32() + .WithColumn("Items").AsString().NotNullable(); - Create.TableForModel("QualityProfiles") - .WithColumn("Name").AsString().Unique() - .WithColumn("Cutoff").AsInt32() - .WithColumn("Allowed").AsString(); + Create.TableForModel("QualityDefinitions") + .WithColumn("Quality").AsInt32().Unique() + .WithColumn("Title").AsString().Unique() + .WithColumn("MinSize").AsDouble().Nullable() + .WithColumn("MaxSize").AsDouble().Nullable(); - Create.TableForModel("QualitySizes") - .WithColumn("QualityId").AsInt32().Unique() - .WithColumn("Name").AsString().Unique() - .WithColumn("MinSize").AsInt32() - .WithColumn("MaxSize").AsInt32(); + Create.TableForModel("NamingConfig") + .WithColumn("ReplaceIllegalCharacters").AsBoolean().WithDefaultValue(true) + .WithColumn("ArtistFolderFormat").AsString().Nullable() + .WithColumn("RenameTracks").AsBoolean().Nullable() + .WithColumn("StandardTrackFormat").AsString().Nullable() + .WithColumn("AlbumFolderFormat").AsString().Nullable(); - Create.TableForModel("SceneMappings") - .WithColumn("CleanTitle").AsString() - .WithColumn("SceneName").AsString() - .WithColumn("TvdbId").AsInt32() - .WithColumn("SeasonNumber").AsInt32(); + Create.TableForModel("Blacklist") + .WithColumn("SourceTitle").AsString() + .WithColumn("Quality").AsString() + .WithColumn("Date").AsDateTime() + .WithColumn("PublishedDate").AsDateTime().Nullable() + .WithColumn("Size").AsInt64().Nullable() + .WithColumn("Protocol").AsInt32().Nullable() + .WithColumn("Indexer").AsString().Nullable() + .WithColumn("Message").AsString().Nullable() + .WithColumn("TorrentInfoHash").AsString().Nullable() + .WithColumn("Language").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ArtistId").AsInt32().WithDefaultValue(0) + .WithColumn("AlbumIds").AsString().WithDefaultValue(""); + Create.TableForModel("Metadata") + .WithColumn("Enable").AsBoolean().NotNullable() + .WithColumn("Name").AsString().NotNullable() + .WithColumn("Implementation").AsString().NotNullable() + .WithColumn("Settings").AsString().NotNullable() + .WithColumn("ConfigContract").AsString().NotNullable(); - Create.TableForModel("NamingConfig") - .WithColumn("UseSceneName").AsBoolean() - .WithColumn("Separator").AsString() - .WithColumn("NumberStyle").AsInt32() - .WithColumn("IncludeSeriesTitle").AsBoolean() - .WithColumn("MultiEpisodeStyle").AsInt32() - .WithColumn("IncludeEpisodeTitle").AsBoolean() - .WithColumn("IncludeQuality").AsBoolean() - .WithColumn("ReplaceSpaces").AsBoolean() - .WithColumn("SeasonFolderFormat").AsString(); + Create.TableForModel("MetadataFiles") + .WithColumn("ArtistId").AsInt32().NotNullable() + .WithColumn("Consumer").AsString().NotNullable() + .WithColumn("Type").AsInt32().NotNullable() + .WithColumn("RelativePath").AsString().NotNullable() + .WithColumn("LastUpdated").AsDateTime().NotNullable() + .WithColumn("AlbumId").AsInt32().Nullable() + .WithColumn("TrackFileId").AsInt32().Nullable() + .WithColumn("Hash").AsString().Nullable() + .WithColumn("Added").AsDateTime().Nullable() + .WithColumn("Extension").AsString().NotNullable(); + + Create.TableForModel("DownloadClients") + .WithColumn("Enable").AsBoolean().NotNullable() + .WithColumn("Name").AsString().NotNullable() + .WithColumn("Implementation").AsString().NotNullable() + .WithColumn("Settings").AsString().NotNullable() + .WithColumn("ConfigContract").AsString().NotNullable(); + + Create.TableForModel("PendingReleases") + .WithColumn("Title").AsString() + .WithColumn("Added").AsDateTime() + .WithColumn("Release").AsString() + .WithColumn("ArtistId").AsInt32().WithDefaultValue(0) + .WithColumn("ParsedAlbumInfo").AsString().WithDefaultValue(""); + + + Create.TableForModel("RemotePathMappings") + .WithColumn("Host").AsString() + .WithColumn("RemotePath").AsString() + .WithColumn("LocalPath").AsString(); + + Create.TableForModel("Tags") + .WithColumn("Label").AsString().Unique(); + + Create.TableForModel("Restrictions") + .WithColumn("Required").AsString().Nullable() + .WithColumn("Preferred").AsString().Nullable() + .WithColumn("Ignored").AsString().Nullable() + .WithColumn("Tags").AsString().NotNullable(); + + Create.TableForModel("DelayProfiles") + .WithColumn("EnableUsenet").AsBoolean().NotNullable() + .WithColumn("EnableTorrent").AsBoolean().NotNullable() + .WithColumn("PreferredProtocol").AsInt32().NotNullable() + .WithColumn("UsenetDelay").AsInt32().NotNullable() + .WithColumn("TorrentDelay").AsInt32().NotNullable() + .WithColumn("Order").AsInt32().NotNullable() + .WithColumn("Tags").AsString().NotNullable(); + + Create.TableForModel("Users") + .WithColumn("Identifier").AsString().NotNullable().Unique() + .WithColumn("Username").AsString().NotNullable().Unique() + .WithColumn("Password").AsString().NotNullable(); + + Create.TableForModel("Commands") + .WithColumn("Name").AsString().NotNullable() + .WithColumn("Body").AsString().NotNullable() + .WithColumn("Priority").AsInt32().NotNullable() + .WithColumn("Status").AsInt32().NotNullable() + .WithColumn("QueuedAt").AsDateTime().NotNullable() + .WithColumn("StartedAt").AsDateTime().Nullable() + .WithColumn("EndedAt").AsDateTime().Nullable() + .WithColumn("Duration").AsString().Nullable() + .WithColumn("Exception").AsString().Nullable() + .WithColumn("Trigger").AsInt32().NotNullable(); + + Create.TableForModel("IndexerStatus") + .WithColumn("ProviderId").AsInt32().NotNullable().Unique() + .WithColumn("InitialFailure").AsDateTime().Nullable() + .WithColumn("MostRecentFailure").AsDateTime().Nullable() + .WithColumn("EscalationLevel").AsInt32().NotNullable() + .WithColumn("DisabledTill").AsDateTime().Nullable() + .WithColumn("LastRssSyncReleaseInfo").AsString().Nullable(); + + Create.TableForModel("ExtraFiles") + .WithColumn("ArtistId").AsInt32().NotNullable() + .WithColumn("AlbumId").AsInt32().NotNullable() + .WithColumn("TrackFileId").AsInt32().NotNullable() + .WithColumn("RelativePath").AsString().NotNullable() + .WithColumn("Extension").AsString().NotNullable() + .WithColumn("Added").AsDateTime().NotNullable() + .WithColumn("LastUpdated").AsDateTime().NotNullable(); + + Create.TableForModel("LyricFiles") + .WithColumn("ArtistId").AsInt32().NotNullable() + .WithColumn("AlbumId").AsInt32().NotNullable() + .WithColumn("TrackFileId").AsInt32().NotNullable() + .WithColumn("RelativePath").AsString().NotNullable() + .WithColumn("Extension").AsString().NotNullable() + .WithColumn("Added").AsDateTime().NotNullable() + .WithColumn("LastUpdated").AsDateTime().NotNullable() + .WithColumn("Language").AsInt32().NotNullable(); + + Create.TableForModel("LanguageProfiles") + .WithColumn("Name").AsString().Unique() + .WithColumn("Languages").AsString() + .WithColumn("Cutoff").AsInt32(); + + Create.TableForModel("DownloadClientStatus") + .WithColumn("ProviderId").AsInt32().NotNullable().Unique() + .WithColumn("InitialFailure").AsDateTime().Nullable() + .WithColumn("MostRecentFailure").AsDateTime().Nullable() + .WithColumn("EscalationLevel").AsInt32().NotNullable() + .WithColumn("DisabledTill").AsDateTime().Nullable(); + + Insert.IntoTable("DelayProfiles").Row(new + { + EnableUsenet = 1, + EnableTorrent = 1, + PreferredProtocol = 1, + UsenetDelay = 0, + TorrentDelay = 0, + Order = int.MaxValue, + Tags = "[]" + }); } protected override void LogDbUpgrade() { Create.TableForModel("Logs") .WithColumn("Message").AsString() - .WithColumn("Time").AsDateTime() + .WithColumn("Time").AsDateTime().Indexed() .WithColumn("Logger").AsString() - .WithColumn("Method").AsString().Nullable() .WithColumn("Exception").AsString().Nullable() .WithColumn("ExceptionType").AsString().Nullable() .WithColumn("Level").AsString(); diff --git a/src/NzbDrone.Core/Datastore/Migration/002_add_reason_to_pending_releases.cs b/src/NzbDrone.Core/Datastore/Migration/002_add_reason_to_pending_releases.cs new file mode 100644 index 000000000..e4ef749e7 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/002_add_reason_to_pending_releases.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(2)] + 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/002_remove_tvrage_imdb_unique_constraint.cs b/src/NzbDrone.Core/Datastore/Migration/002_remove_tvrage_imdb_unique_constraint.cs deleted file mode 100644 index 6fc6a6cd3..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/002_remove_tvrage_imdb_unique_constraint.cs +++ /dev/null @@ -1,15 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(2)] - public class remove_tvrage_imdb_unique_constraint : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Delete.Index().OnTable("Series").OnColumn("TvRageId"); - Delete.Index().OnTable("Series").OnColumn("ImdbId"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/003_add_medium_support.cs b/src/NzbDrone.Core/Datastore/Migration/003_add_medium_support.cs new file mode 100644 index 000000000..aed0dab79 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/003_add_medium_support.cs @@ -0,0 +1,22 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(3)] + public class add_medium_support : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("Albums").AddColumn("Media").AsString().WithDefaultValue(""); + Alter.Table("Tracks").AddColumn("MediumNumber").AsInt32().WithDefaultValue(0); + Alter.Table("Tracks").AddColumn("AbsoluteTrackNumber").AsInt32().WithDefaultValue(0); + + Execute.Sql("UPDATE Tracks SET AbsoluteTrackNumber = TrackNumber"); + + Delete.Column("TrackNumber").FromTable("Tracks"); + Alter.Table("Tracks").AddColumn("TrackNumber").AsString().Nullable(); + + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/003_remove_clean_title_from_scene_mapping.cs b/src/NzbDrone.Core/Datastore/Migration/003_remove_clean_title_from_scene_mapping.cs deleted file mode 100644 index a19bae93c..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/003_remove_clean_title_from_scene_mapping.cs +++ /dev/null @@ -1,20 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(3)] - public class remove_renamed_scene_mapping_columns : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Delete.Table("SceneMappings"); - - Create.TableForModel("SceneMappings") - .WithColumn("TvdbId").AsInt32() - .WithColumn("SeasonNumber").AsInt32() - .WithColumn("SearchTerm").AsString() - .WithColumn("ParseTerm").AsString(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/004_add_various_qualities_in_profile.cs b/src/NzbDrone.Core/Datastore/Migration/004_add_various_qualities_in_profile.cs new file mode 100644 index 000000000..9d74ba404 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/004_add_various_qualities_in_profile.cs @@ -0,0 +1,335 @@ +using System.Collections.Generic; +using System.Data; +using System.Linq; +using FluentMigrator; +using Newtonsoft.Json; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(4)] + public class add_various_qualites_in_profile : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Execute.Sql("UPDATE QualityDefinitions SET Title = 'MP3-160' WHERE Quality = 5"); // Change MP3-512 to MP3-160 + Execute.WithConnection(ConvertProfile); + } + + private void ConvertProfile(IDbConnection conn, IDbTransaction tran) + { + var updater = new ProfileUpdater3(conn, tran); + + updater.AddQuality(Qualities4.WAV); + + updater.MoveQuality(Qualities4.MP3_160, Qualities4.Unknown); + + updater.CreateNewGroup(Qualities4.Unknown, 1000, "Trash Quality Lossy", new[] { Qualities4.MP3_080, + Qualities4.MP3_064, + Qualities4.MP3_056, + Qualities4.MP3_048, + Qualities4.MP3_040, + Qualities4.MP3_032, + Qualities4.MP3_024, + Qualities4.MP3_016, + Qualities4.MP3_008 }); + + updater.CreateGroupAt(Qualities4.MP3_160, 1001, "Poor Quality Lossy", new[] { Qualities4.MP3_160, + Qualities4.VORBIS_Q5, + Qualities4.MP3_128, + Qualities4.MP3_096, + Qualities4.MP3_112 }); // Group Vorbis-Q5 with MP3-160 + + updater.CreateGroupAt(Qualities4.MP3_192, 1002, "Low Quality Lossy", new[] { Qualities4.MP3_192, + Qualities4.AAC_192, + Qualities4.VORBIS_Q6, + Qualities4.WMA, + Qualities4.MP3_224 }); // Group Vorbis-Q6, AAC 192, WMA with MP3-190 + + updater.CreateGroupAt(Qualities4.MP3_256, 1003, "Mid Quality Lossy", new[] { Qualities4.MP3_256, + Qualities4.MP3_VBR_V2, + Qualities4.VORBIS_Q8, + Qualities4.VORBIS_Q7, + Qualities4.AAC_256 }); // Group Mp3-VBR-V2, Vorbis-Q7, Q8, AAC-256 with MP3-256 + + updater.CreateGroupAt(Qualities4.MP3_320, 1004, "High Quality Lossy", new[] { Qualities4.MP3_VBR, + Qualities4.MP3_320, + Qualities4.AAC_320, + Qualities4.AAC_VBR, + Qualities4.VORBIS_Q10, + Qualities4.VORBIS_Q9 }); // Group MP3-VBR-V0, AAC-VBR, Vorbis-Q10, Q9, AAC-320 with MP3-320 + + updater.CreateGroupAt(Qualities4.FLAC, 1005, "Lossless", new[] { Qualities4.FLAC, + Qualities4.ALAC, + Qualities4.FLAC_24 }); // Group ALAC with FLAC + + + updater.Commit(); + } + } + + public class Profile4 + { + public int Id { get; set; } + public string Name { get; set; } + public int Cutoff { get; set; } + public List Items { get; set; } + } + + public class ProfileItem4 + { + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public int Id { get; set; } + + public string Name { get; set; } + public int? Quality { get; set; } + public List Items { get; set; } + public bool Allowed { get; set; } + + public ProfileItem4() + { + Items = new List(); + } + } + + public enum Qualities4 + { + Unknown, + MP3_192, + MP3_VBR, + MP3_256, + MP3_320, + MP3_160, + FLAC, + ALAC, + MP3_VBR_V2, + AAC_192, + AAC_256, + AAC_320, + AAC_VBR, + WAV, + VORBIS_Q10, + VORBIS_Q9, + VORBIS_Q8, + VORBIS_Q7, + VORBIS_Q6, + VORBIS_Q5, + WMA, + FLAC_24, + MP3_128, + MP3_096, + MP3_080, + MP3_064, + MP3_056, + MP3_048, + MP3_040, + MP3_032, + MP3_024, + MP3_016, + MP3_008, + MP3_112, + MP3_224 + } + + public class ProfileUpdater3 + { + private readonly IDbConnection _connection; + private readonly IDbTransaction _transaction; + + private List _profiles; + private HashSet _changedProfiles = new HashSet(); + + public ProfileUpdater3(IDbConnection conn, IDbTransaction tran) + { + _connection = conn; + _transaction = tran; + + _profiles = GetProfiles(); + } + + public void Commit() + { + foreach (var profile in _changedProfiles) + { + using (var updateProfileCmd = _connection.CreateCommand()) + { + updateProfileCmd.Transaction = _transaction; + updateProfileCmd.CommandText = + "UPDATE Profiles SET Name = ?, Cutoff = ?, Items = ? WHERE Id = ?"; + updateProfileCmd.AddParameter(profile.Name); + updateProfileCmd.AddParameter(profile.Cutoff); + updateProfileCmd.AddParameter(profile.Items.ToJson()); + updateProfileCmd.AddParameter(profile.Id); + + updateProfileCmd.ExecuteNonQuery(); + } + } + + _changedProfiles.Clear(); + } + + public void AddQuality(Qualities4 quality) + { + foreach (var profile in _profiles) + { + profile.Items.Add(new ProfileItem4 + { + Quality = (int)quality, + Allowed = false + }); + } + + } + + public void CreateGroupAt(Qualities4 find, int groupId, string name, Qualities4[] qualities) + { + foreach (var profile in _profiles) + { + var findIndex = profile.Items.FindIndex(v => v.Quality == (int)find); + + if (findIndex > -1) + { + var findQuality = profile.Items[findIndex]; + + profile.Items.Insert(findIndex, new ProfileItem4 + { + Id = groupId, + Name = name, + Quality = null, + Items = qualities.Select(q => new ProfileItem4 + { + Quality = (int)q, + Allowed = findQuality.Allowed + }).ToList(), + Allowed = findQuality.Allowed + }); + } + else + { + // If the ID isn't found for some reason (mangled migration 71?) + + profile.Items.Add(new ProfileItem4 + { + Id = groupId, + Name = name, + Quality = null, + Items = qualities.Select(q => new ProfileItem4 + { + Quality = (int)q, + Allowed = false + }).ToList(), + Allowed = false + }); + } + + foreach (var quality in qualities) + { + var index = profile.Items.FindIndex(v => v.Quality == (int)quality); + + if (index > -1) + { + profile.Items.RemoveAt(index); + } + + if (profile.Cutoff == (int)quality) + { + profile.Cutoff = groupId; + } + } + + _changedProfiles.Add(profile); + } + } + + public void CreateNewGroup(Qualities4 createafter, int groupId, string name, Qualities4[] qualities) + { + foreach (var profile in _profiles) + { + var findIndex = profile.Items.FindIndex(v => v.Quality == (int)createafter) + 1; + + if (findIndex > -1) + { + + profile.Items.Insert(findIndex, new ProfileItem4 + { + Id = groupId, + Name = name, + Quality = null, + Items = qualities.Select(q => new ProfileItem4 + { + Quality = (int)q, + Allowed = false + }).ToList(), + Allowed = false + }); + } + else + { + + profile.Items.Add(new ProfileItem4 + { + Id = groupId, + Name = name, + Quality = null, + Items = qualities.Select(q => new ProfileItem4 + { + Quality = (int)q, + Allowed = false + }).ToList(), + Allowed = false + }); + } + } + } + + public void MoveQuality(Qualities4 quality, Qualities4 moveafter) + { + foreach (var profile in _profiles) + { + var findIndex = profile.Items.FindIndex(v => v.Quality == (int)quality); + + if (findIndex > -1) + { + var allowed = profile.Items[findIndex].Allowed; + profile.Items.RemoveAt(findIndex); + var findMoveIndex = profile.Items.FindIndex(v => v.Quality == (int)moveafter) + 1; + profile.Items.Insert(findMoveIndex, new ProfileItem4 + { + Quality = (int)quality, + Allowed = allowed + }); + } + + + } + } + + private List GetProfiles() + { + var profiles = new List(); + + using (var getProfilesCmd = _connection.CreateCommand()) + { + getProfilesCmd.Transaction = _transaction; + getProfilesCmd.CommandText = @"SELECT Id, Name, Cutoff, Items FROM Profiles"; + + using (var profileReader = getProfilesCmd.ExecuteReader()) + { + while (profileReader.Read()) + { + profiles.Add(new Profile4 + { + Id = profileReader.GetInt32(0), + Name = profileReader.GetString(1), + Cutoff = profileReader.GetInt32(2), + Items = Json.Deserialize>(profileReader.GetString(3)) + }); + } + } + } + + return profiles; + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/004_updated_history.cs b/src/NzbDrone.Core/Datastore/Migration/004_updated_history.cs deleted file mode 100644 index 5ebc51ac8..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/004_updated_history.cs +++ /dev/null @@ -1,23 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(4)] - public class updated_history : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Delete.Table("History"); - - - Create.TableForModel("History") - .WithColumn("EpisodeId").AsInt32() - .WithColumn("SeriesId").AsInt32() - .WithColumn("SourceTitle").AsString() - .WithColumn("Date").AsDateTime() - .WithColumn("Quality").AsString() - .WithColumn("Data").AsString(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/005_added_eventtype_to_history.cs b/src/NzbDrone.Core/Datastore/Migration/005_added_eventtype_to_history.cs deleted file mode 100644 index bead4c96f..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/005_added_eventtype_to_history.cs +++ /dev/null @@ -1,17 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(5)] - public class added_eventtype_to_history : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("History") - .AddColumn("EventType") - .AsInt32() - .Nullable(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/005_metadata_profiles.cs b/src/NzbDrone.Core/Datastore/Migration/005_metadata_profiles.cs new file mode 100644 index 000000000..8f226de3f --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/005_metadata_profiles.cs @@ -0,0 +1,25 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(5)] + public class metadata_profiles : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Create.TableForModel("MetadataProfiles") + .WithColumn("Name").AsString().Unique() + .WithColumn("PrimaryAlbumTypes").AsString() + .WithColumn("SecondaryAlbumTypes").AsString(); + + Alter.Table("Artists").AddColumn("MetadataProfileId").AsInt32().WithDefaultValue(1); + + Delete.Column("PrimaryAlbumTypes").FromTable("Artists"); + Delete.Column("SecondaryAlbumTypes").FromTable("Artists"); + + Alter.Table("Albums").AddColumn("SecondaryTypes").AsString().Nullable(); + + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/006_add_index_to_log_time.cs b/src/NzbDrone.Core/Datastore/Migration/006_add_index_to_log_time.cs deleted file mode 100644 index add668fdc..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/006_add_index_to_log_time.cs +++ /dev/null @@ -1,23 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(6)] - public class add_index_to_log_time : NzbDroneMigrationBase - { - protected override void LogDbUpgrade() - { - Delete.Table("Logs"); - - Create.TableForModel("Logs") - .WithColumn("Message").AsString() - .WithColumn("Time").AsDateTime().Indexed() - .WithColumn("Logger").AsString() - .WithColumn("Method").AsString().Nullable() - .WithColumn("Exception").AsString().Nullable() - .WithColumn("ExceptionType").AsString().Nullable() - .WithColumn("Level").AsString(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/006_separate_automatic_and_interactive_search.cs b/src/NzbDrone.Core/Datastore/Migration/006_separate_automatic_and_interactive_search.cs new file mode 100644 index 000000000..3f03f8207 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/006_separate_automatic_and_interactive_search.cs @@ -0,0 +1,19 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(6)] + public class separate_automatic_and_interactive_search : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Rename.Column("EnableSearch").OnTable("Indexers").To("EnableAutomaticSearch"); + Alter.Table("Indexers").AddColumn("EnableInteractiveSearch").AsBoolean().Nullable(); + + Execute.Sql("UPDATE Indexers SET EnableInteractiveSearch = EnableAutomaticSearch"); + + Alter.Table("Indexers").AlterColumn("EnableInteractiveSearch").AsBoolean().NotNullable(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/007_add_renameEpisodes_to_naming.cs b/src/NzbDrone.Core/Datastore/Migration/007_add_renameEpisodes_to_naming.cs deleted file mode 100644 index 3fc4abef9..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/007_add_renameEpisodes_to_naming.cs +++ /dev/null @@ -1,19 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(7)] - public class add_renameEpisodes_to_naming : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("NamingConfig") - .AddColumn("RenameEpisodes") - .AsBoolean() - .Nullable(); - - Execute.Sql("UPDATE NamingConfig SET RenameEpisodes =~ UseSceneName"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/007_change_album_path_to_relative.cs b/src/NzbDrone.Core/Datastore/Migration/007_change_album_path_to_relative.cs new file mode 100644 index 000000000..cafeff1e0 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/007_change_album_path_to_relative.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(7)] + public class change_album_path_to_relative : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Delete.Column("Path").FromTable("Albums"); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/008_change_quality_size_mb_to_kb.cs b/src/NzbDrone.Core/Datastore/Migration/008_change_quality_size_mb_to_kb.cs new file mode 100644 index 000000000..07cb91c3d --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/008_change_quality_size_mb_to_kb.cs @@ -0,0 +1,18 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(8)] + public class change_quality_size_mb_to_kb : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Execute.Sql("UPDATE QualityDefinitions SET MaxSize = CASE " + + "WHEN (CAST(MaxSize AS FLOAT) / 60) * 8 * 1024 < 1500 THEN " + + "ROUND((CAST(MaxSize AS FLOAT) / 60) * 8 * 1024, 0) " + + "ELSE NULL " + + "END"); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/008_remove_backlog.cs b/src/NzbDrone.Core/Datastore/Migration/008_remove_backlog.cs deleted file mode 100644 index 19e16242c..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/008_remove_backlog.cs +++ /dev/null @@ -1,15 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(8)] - public class remove_backlog : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Delete.Column("BacklogSetting").FromTable("Series"); - Delete.Column("UseSceneName").FromTable("NamingConfig"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/009_album_releases.cs b/src/NzbDrone.Core/Datastore/Migration/009_album_releases.cs new file mode 100644 index 000000000..7f93b587c --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/009_album_releases.cs @@ -0,0 +1,15 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(9)] + public class album_releases : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("Albums").AddColumn("Releases").AsString().WithDefaultValue("").Nullable(); + Alter.Table("Albums").AddColumn("CurrentRelease").AsString().WithDefaultValue("").Nullable(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/009_fix_renameEpisodes.cs b/src/NzbDrone.Core/Datastore/Migration/009_fix_renameEpisodes.cs deleted file mode 100644 index bdc0c54e5..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/009_fix_renameEpisodes.cs +++ /dev/null @@ -1,17 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(9)] - public class fix_rename_episodes : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Delete.Column("SeasonFolderFormat").FromTable("NamingConfig"); - - Execute.Sql("UPDATE NamingConfig SET RenameEpisodes = 1 WHERE RenameEpisodes = -1"); - Execute.Sql("UPDATE NamingConfig SET RenameEpisodes = 0 WHERE RenameEpisodes = -2"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/010_add_monitored.cs b/src/NzbDrone.Core/Datastore/Migration/010_add_monitored.cs deleted file mode 100644 index a64f44877..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/010_add_monitored.cs +++ /dev/null @@ -1,21 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(10)] - public class add_monitored : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("Episodes").AddColumn("Monitored").AsBoolean().Nullable(); - Alter.Table("Seasons").AddColumn("Monitored").AsBoolean().Nullable(); - - Execute.Sql("UPDATE Episodes SET Monitored = 1 WHERE Ignored = 0"); - Execute.Sql("UPDATE Episodes SET Monitored = 0 WHERE Ignored = 1"); - - Execute.Sql("UPDATE Seasons SET Monitored = 1 WHERE Ignored = 0"); - Execute.Sql("UPDATE Seasons SET Monitored = 0 WHERE Ignored = 1"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/010_album_releases_fix.cs b/src/NzbDrone.Core/Datastore/Migration/010_album_releases_fix.cs new file mode 100644 index 000000000..10b8dd79d --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/010_album_releases_fix.cs @@ -0,0 +1,15 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(10)] + public class album_releases_fix : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("Albums").AlterColumn("Releases").AsString().NotNullable(); + Alter.Table("Albums").AlterColumn("CurrentRelease").AsString().NotNullable(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/011_import_lists.cs b/src/NzbDrone.Core/Datastore/Migration/011_import_lists.cs new file mode 100644 index 000000000..91de5e5f3 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/011_import_lists.cs @@ -0,0 +1,32 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(11)] + public class import_lists : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Create.TableForModel("ImportLists") + .WithColumn("Name").AsString().Unique() + .WithColumn("Implementation").AsString() + .WithColumn("Settings").AsString().Nullable() + .WithColumn("ConfigContract").AsString().Nullable() + .WithColumn("EnableAutomaticAdd").AsBoolean().Nullable() + .WithColumn("RootFolderPath").AsString() + .WithColumn("ShouldMonitor").AsInt32() + .WithColumn("ProfileId").AsInt32() + .WithColumn("LanguageProfileId").AsInt32() + .WithColumn("MetadataProfileId").AsInt32(); + + Create.TableForModel("ImportListStatus") + .WithColumn("ProviderId").AsInt32().NotNullable().Unique() + .WithColumn("InitialFailure").AsDateTime().Nullable() + .WithColumn("MostRecentFailure").AsDateTime().Nullable() + .WithColumn("EscalationLevel").AsInt32().NotNullable() + .WithColumn("DisabledTill").AsDateTime().Nullable() + .WithColumn("LastSyncListInfo").AsString().Nullable(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/011_remove_ignored.cs b/src/NzbDrone.Core/Datastore/Migration/011_remove_ignored.cs deleted file mode 100644 index 193b25094..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/011_remove_ignored.cs +++ /dev/null @@ -1,15 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(11)] - public class remove_ignored : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Delete.Column("Ignored").FromTable("Seasons"); - Delete.Column("Ignored").FromTable("Episodes"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/012_add_release_status.cs b/src/NzbDrone.Core/Datastore/Migration/012_add_release_status.cs new file mode 100644 index 000000000..c586d533c --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/012_add_release_status.cs @@ -0,0 +1,138 @@ +using System.Collections.Generic; +using System.Data; +using System.Linq; +using FluentMigrator; +using Newtonsoft.Json; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(12)] + public class add_release_status : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("MetadataProfiles").AddColumn("ReleaseStatuses").AsString().WithDefaultValue(""); + Execute.WithConnection(ConvertProfile); + } + + private void ConvertProfile(IDbConnection conn, IDbTransaction tran) + { + var updater = new ProfileUpdater11(conn, tran); + updater.AddDefaultReleaseStatus(); + updater.Commit(); + } + } + + public class Profile12 + { + public int Id { get; set; } + public string Name { get; set; } + public List ReleaseStatuses { get; set; } + } + + public class ProfileItem12 + { + public int ReleaseStatus { get; set; } + public bool Allowed { get; set; } + } + + public enum ReleaseStatus12 + { + Official = 0, + Promotional = 1, + Bootleg = 2, + Pseudo = 3 + } + + public class ProfileUpdater11 + { + private readonly IDbConnection _connection; + private readonly IDbTransaction _transaction; + + private List _profiles; + + public ProfileUpdater11(IDbConnection conn, IDbTransaction tran) + { + _connection = conn; + _transaction = tran; + + _profiles = GetProfiles(); + } + + public void Commit() + { + foreach (var profile in _profiles) + { + using (var updateProfileCmd = _connection.CreateCommand()) + { + updateProfileCmd.Transaction = _transaction; + updateProfileCmd.CommandText = + "UPDATE MetadataProfiles SET ReleaseStatuses = ? WHERE Id = ?"; + updateProfileCmd.AddParameter(profile.ReleaseStatuses.ToJson()); + updateProfileCmd.AddParameter(profile.Id); + + updateProfileCmd.ExecuteNonQuery(); + } + } + + _profiles.Clear(); + } + + public void AddDefaultReleaseStatus() + { + foreach (var profile in _profiles) + { + profile.ReleaseStatuses = new List + { + new ProfileItem12 + { + ReleaseStatus = (int)ReleaseStatus12.Official, + Allowed = true + }, + new ProfileItem12 + { + ReleaseStatus = (int)ReleaseStatus12.Promotional, + Allowed = false + }, + new ProfileItem12 + { + ReleaseStatus = (int)ReleaseStatus12.Bootleg, + Allowed = false + }, + new ProfileItem12 + { + ReleaseStatus = (int)ReleaseStatus12.Pseudo, + Allowed = false + } + }; + } + } + + private List GetProfiles() + { + var profiles = new List(); + + using (var getProfilesCmd = _connection.CreateCommand()) + { + getProfilesCmd.Transaction = _transaction; + getProfilesCmd.CommandText = @"SELECT Id, Name FROM MetadataProfiles"; + + using (var profileReader = getProfilesCmd.ExecuteReader()) + { + while (profileReader.Read()) + { + profiles.Add(new Profile12 + { + Id = profileReader.GetInt32(0), + Name = profileReader.GetString(1) + }); + } + } + } + + return profiles; + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/012_remove_custom_start_date.cs b/src/NzbDrone.Core/Datastore/Migration/012_remove_custom_start_date.cs deleted file mode 100644 index 8b19f1a3f..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/012_remove_custom_start_date.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(12)] - public class remove_custom_start_date : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Delete.Column("CustomStartDate").FromTable("Series"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/013_add_air_date_utc.cs b/src/NzbDrone.Core/Datastore/Migration/013_add_air_date_utc.cs deleted file mode 100644 index ece91b397..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/013_add_air_date_utc.cs +++ /dev/null @@ -1,16 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(13)] - public class add_air_date_utc : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("Episodes").AddColumn("AirDateUtc").AsDateTime().Nullable(); - - Execute.Sql("UPDATE Episodes SET AirDateUtc = AirDate"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/013_album_download_notification.cs b/src/NzbDrone.Core/Datastore/Migration/013_album_download_notification.cs new file mode 100644 index 000000000..39bba97be --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/013_album_download_notification.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(13)] + public class album_download_notification : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("Notifications").AddColumn("OnAlbumDownload").AsBoolean().WithDefaultValue(0); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/014_drop_air_date.cs b/src/NzbDrone.Core/Datastore/Migration/014_drop_air_date.cs deleted file mode 100644 index 00af970b9..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/014_drop_air_date.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(14)] - public class drop_air_date : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Delete.Column("AirDate").FromTable("Episodes"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/014_fix_language_metadata_profiles.cs b/src/NzbDrone.Core/Datastore/Migration/014_fix_language_metadata_profiles.cs new file mode 100644 index 000000000..20f62fa65 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/014_fix_language_metadata_profiles.cs @@ -0,0 +1,24 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(14)] + public class fix_language_metadata_profiles : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Execute.Sql("UPDATE artists SET metadataProfileId = " + + "CASE WHEN ((SELECT COUNT(*) FROM metadataprofiles) > 0) " + + "THEN (SELECT id FROM metadataprofiles ORDER BY id ASC LIMIT 1) " + + "ELSE 0 END " + + "WHERE artists.metadataProfileId == 0"); + + Execute.Sql("UPDATE artists SET languageProfileId = " + + "CASE WHEN ((SELECT COUNT(*) FROM languageprofiles) > 0) " + + "THEN (SELECT id FROM languageprofiles ORDER BY id ASC LIMIT 1) " + + "ELSE 0 END " + + "WHERE artists.languageProfileId == 0"); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/015_add_air_date_as_string.cs b/src/NzbDrone.Core/Datastore/Migration/015_add_air_date_as_string.cs deleted file mode 100644 index 7638e6df5..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/015_add_air_date_as_string.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(15)] - public class add_air_date_as_string : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("Episodes").AddColumn("AirDate").AsString().Nullable(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/015_remove_fanzub.cs b/src/NzbDrone.Core/Datastore/Migration/015_remove_fanzub.cs new file mode 100644 index 000000000..75f4dc2c9 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/015_remove_fanzub.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(15)] + public class remove_fanzub : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Execute.Sql("DELETE FROM Indexers WHERE Implementation = 'Fanzub';"); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/016_update_artist_history_indexes.cs b/src/NzbDrone.Core/Datastore/Migration/016_update_artist_history_indexes.cs new file mode 100644 index 000000000..233b84b2a --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/016_update_artist_history_indexes.cs @@ -0,0 +1,24 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(16)] + public class update_artist_history_indexes : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Create.Index().OnTable("Albums").OnColumn("ArtistId"); + Create.Index().OnTable("Albums").OnColumn("ArtistId").Ascending() + .OnColumn("ReleaseDate").Ascending(); + + Delete.Index().OnTable("History").OnColumn("AlbumId"); + Create.Index().OnTable("History").OnColumn("AlbumId").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/016_updated_imported_history_item.cs b/src/NzbDrone.Core/Datastore/Migration/016_updated_imported_history_item.cs deleted file mode 100644 index 7a2c50e71..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/016_updated_imported_history_item.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(16)] - public class updated_imported_history_item : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Execute.Sql(@"UPDATE HISTORY SET Data = replace( Data, '""Path""', '""ImportedPath""' ) WHERE EventType=3"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/017_remove_nma.cs b/src/NzbDrone.Core/Datastore/Migration/017_remove_nma.cs new file mode 100644 index 000000000..177ca24e5 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/017_remove_nma.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(17)] + public class remove_nma : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Delete.FromTable("Notifications").Row(new { Implementation = "NotifyMyAndroid" }); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/017_reset_scene_names.cs b/src/NzbDrone.Core/Datastore/Migration/017_reset_scene_names.cs deleted file mode 100644 index e2e3a21d6..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/017_reset_scene_names.cs +++ /dev/null @@ -1,15 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(17)] - public class reset_scene_names : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - //we were storing new file name as scene name. - Execute.Sql(@"UPDATE EpisodeFiles SET SceneName = NULL where SceneName != NULL"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/018_album_disambiguation.cs b/src/NzbDrone.Core/Datastore/Migration/018_album_disambiguation.cs new file mode 100644 index 000000000..f862b207e --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/018_album_disambiguation.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(18)] + public class album_disambiguation : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("Albums").AddColumn("Disambiguation").AsString().Nullable(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/018_remove_duplicates.cs b/src/NzbDrone.Core/Datastore/Migration/018_remove_duplicates.cs deleted file mode 100644 index d788dd7dc..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/018_remove_duplicates.cs +++ /dev/null @@ -1,99 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; -using System.Linq; -using System.Data; -using System.Collections.Generic; -using System; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(18)] - public class remove_duplicates : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Execute.WithConnection(RemoveDuplicates); - } - - private void RemoveDuplicates(IDbConnection conn, IDbTransaction tran) - { - RemoveDuplicateSeries(conn, tran, "TvdbId"); - RemoveDuplicateSeries(conn, tran, "TitleSlug"); - - var duplicatedEpisodes = GetDuplicates(conn, tran, "Episodes", "TvDbEpisodeId"); - - foreach (var duplicate in duplicatedEpisodes) - { - foreach (var episodeId in duplicate.OrderBy(c => c.Key).Skip(1).Select(c => c.Key)) - { - RemoveEpisodeRows(conn, tran, episodeId); - } - } - } - - private IEnumerable>> GetDuplicates(IDbConnection conn, IDbTransaction tran, string tableName, string columnName) - { - var getDuplicates = conn.CreateCommand(); - getDuplicates.Transaction = tran; - getDuplicates.CommandText = string.Format("select id, {0} from {1}", columnName, tableName); - - var result = new List>(); - - using (var reader = getDuplicates.ExecuteReader()) - { - while (reader.Read()) - { - result.Add(new KeyValuePair(reader.GetInt32(0), (T)Convert.ChangeType(reader[1], typeof(T)))); - } - } - - return result.GroupBy(c => c.Value).Where(g => g.Count() > 1); - } - - private void RemoveDuplicateSeries(IDbConnection conn, IDbTransaction tran, string field) - { - var duplicatedSeries = GetDuplicates(conn, tran, "Series", field); - - foreach (var duplicate in duplicatedSeries) - { - foreach (var seriesId in duplicate.OrderBy(c => c.Key).Skip(1).Select(c => c.Key)) - { - RemoveSeriesRows(conn, tran, seriesId); - } - } - } - - private void RemoveSeriesRows(IDbConnection conn, IDbTransaction tran, int seriesId) - { - var deleteCmd = conn.CreateCommand(); - deleteCmd.Transaction = tran; - - deleteCmd.CommandText = string.Format("DELETE FROM Series WHERE Id = {0}", seriesId.ToString()); - deleteCmd.ExecuteNonQuery(); - - deleteCmd.CommandText = string.Format("DELETE FROM Episodes WHERE SeriesId = {0}", seriesId.ToString()); - deleteCmd.ExecuteNonQuery(); - - deleteCmd.CommandText = string.Format("DELETE FROM Seasons WHERE SeriesId = {0}", seriesId.ToString()); - deleteCmd.ExecuteNonQuery(); - - deleteCmd.CommandText = string.Format("DELETE FROM History WHERE SeriesId = {0}", seriesId.ToString()); - deleteCmd.ExecuteNonQuery(); - - deleteCmd.CommandText = string.Format("DELETE FROM EpisodeFiles WHERE SeriesId = {0}", seriesId.ToString()); - deleteCmd.ExecuteNonQuery(); - } - - private void RemoveEpisodeRows(IDbConnection conn, IDbTransaction tran, int episodeId) - { - var deleteCmd = conn.CreateCommand(); - deleteCmd.Transaction = tran; - - deleteCmd.CommandText = string.Format("DELETE FROM Episodes WHERE Id = {0}", episodeId.ToString()); - deleteCmd.ExecuteNonQuery(); - - deleteCmd.CommandText = string.Format("DELETE FROM History WHERE EpisodeId = {0}", episodeId.ToString()); - deleteCmd.ExecuteNonQuery(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/019_add_ape_quality_in_profiles.cs b/src/NzbDrone.Core/Datastore/Migration/019_add_ape_quality_in_profiles.cs new file mode 100644 index 000000000..97b4e0938 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/019_add_ape_quality_in_profiles.cs @@ -0,0 +1,135 @@ +using System.Collections.Generic; +using System.Data; +using System.Linq; +using FluentMigrator; +using Newtonsoft.Json; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(19)] + public class add_ape_quality_in_profiles : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Execute.WithConnection(ConvertProfile); + } + + private void ConvertProfile(IDbConnection conn, IDbTransaction tran) + { + var updater = new ProfileUpdater19(conn, tran); + + updater.SplitQualityAppend(6, 35); // APE after Flac + updater.SplitQualityAppend(6, 36); // WavPack after Flac + + updater.Commit(); + } + } + + public class Profile19 + { + public int Id { get; set; } + public string Name { get; set; } + public int Cutoff { get; set; } + public List Items { get; set; } + } + + public class ProfileItem19 + { + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public int Id { get; set; } + + public string Name { get; set; } + + public int? Quality { get; set; } + + public bool Allowed { get; set; } + public List Items { get; set; } + } + + public class ProfileUpdater19 + { + private readonly IDbConnection _connection; + private readonly IDbTransaction _transaction; + + private List _profiles; + private HashSet _changedProfiles = new HashSet(); + + public ProfileUpdater19(IDbConnection conn, IDbTransaction tran) + { + _connection = conn; + _transaction = tran; + + _profiles = GetProfiles(); + } + + public void Commit() + { + foreach (var profile in _changedProfiles) + { + using (var updateProfileCmd = _connection.CreateCommand()) + { + updateProfileCmd.Transaction = _transaction; + updateProfileCmd.CommandText = "UPDATE Profiles SET Name = ?, Cutoff = ?, Items = ? WHERE Id = ?"; + updateProfileCmd.AddParameter(profile.Name); + updateProfileCmd.AddParameter(profile.Cutoff); + updateProfileCmd.AddParameter(profile.Items.ToJson()); + updateProfileCmd.AddParameter(profile.Id); + + updateProfileCmd.ExecuteNonQuery(); + } + } + + _changedProfiles.Clear(); + } + + public void SplitQualityAppend(int find, int quality) + { + foreach (var profile in _profiles) + { + if (profile.Items.Any(v => v.Quality == quality)) continue; + + var findIndex = profile.Items.FindIndex(v => + { + return v.Quality == find || (v.Items != null && v.Items.Any(b => b.Quality == find)); + }); + + profile.Items.Insert(findIndex + 1, new ProfileItem19 + { + Quality = quality, + Allowed = false + }); + + _changedProfiles.Add(profile); + } + } + + private List GetProfiles() + { + var profiles = new List(); + + using (var getProfilesCmd = _connection.CreateCommand()) + { + getProfilesCmd.Transaction = _transaction; + getProfilesCmd.CommandText = @"SELECT Id, Name, Cutoff, Items FROM Profiles"; + + using (var profileReader = getProfilesCmd.ExecuteReader()) + { + while (profileReader.Read()) + { + profiles.Add(new Profile19 + { + Id = profileReader.GetInt32(0), + Name = profileReader.GetString(1), + Cutoff = profileReader.GetInt32(2), + Items = Json.Deserialize>(profileReader.GetString(3)) + }); + } + } + } + + return profiles; + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/019_restore_unique_constraints.cs b/src/NzbDrone.Core/Datastore/Migration/019_restore_unique_constraints.cs deleted file mode 100644 index bf70a9532..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/019_restore_unique_constraints.cs +++ /dev/null @@ -1,22 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(19)] - public class restore_unique_constraints : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - // During an earlier version of drone, the indexes weren't recreated during alter table. - Execute.Sql("DROP INDEX IF EXISTS \"IX_Series_TvdbId\""); - Execute.Sql("DROP INDEX IF EXISTS \"IX_Series_TitleSlug\""); - Execute.Sql("DROP INDEX IF EXISTS \"IX_Episodes_TvDbEpisodeId\""); - - Create.Index().OnTable("Series").OnColumn("TvdbId").Unique(); - Create.Index().OnTable("Series").OnColumn("TitleSlug").Unique(); - Create.Index().OnTable("Episodes").OnColumn("TvDbEpisodeId").Unique(); - } - - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/020_add_year_and_seasons_to_series.cs b/src/NzbDrone.Core/Datastore/Migration/020_add_year_and_seasons_to_series.cs deleted file mode 100644 index 0e2136141..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/020_add_year_and_seasons_to_series.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System.Collections.Generic; -using System.Data; -using FluentMigrator; -using NzbDrone.Common.Serializer; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(20)] - public class add_year_and_seasons_to_series : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("Series").AddColumn("Year").AsInt32().Nullable(); - Alter.Table("Series").AddColumn("Seasons").AsString().Nullable(); - - Execute.WithConnection(ConvertSeasons); - } - - private void ConvertSeasons(IDbConnection conn, IDbTransaction tran) - { - using (IDbCommand allSeriesCmd = conn.CreateCommand()) - { - allSeriesCmd.Transaction = tran; - allSeriesCmd.CommandText = @"SELECT Id FROM Series"; - using (IDataReader allSeriesReader = allSeriesCmd.ExecuteReader()) - { - while (allSeriesReader.Read()) - { - int seriesId = allSeriesReader.GetInt32(0); - var seasons = new List(); - - using (IDbCommand seasonsCmd = conn.CreateCommand()) - { - seasonsCmd.Transaction = tran; - seasonsCmd.CommandText = string.Format(@"SELECT SeasonNumber, Monitored FROM Seasons WHERE SeriesId = {0}", seriesId); - - using (IDataReader seasonReader = seasonsCmd.ExecuteReader()) - { - while (seasonReader.Read()) - { - int seasonNumber = seasonReader.GetInt32(0); - bool monitored = seasonReader.GetBoolean(1); - - if (seasonNumber == 0) - { - monitored = false; - } - - seasons.Add(new { seasonNumber, monitored }); - } - } - } - - using (IDbCommand updateCmd = conn.CreateCommand()) - { - var text = string.Format("UPDATE Series SET Seasons = '{0}' WHERE Id = {1}", seasons.ToJson() , seriesId); - - updateCmd.Transaction = tran; - updateCmd.CommandText = text; - updateCmd.ExecuteNonQuery(); - } - } - } - } - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/020_remove_pushalot.cs b/src/NzbDrone.Core/Datastore/Migration/020_remove_pushalot.cs new file mode 100644 index 000000000..e7339afb9 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/020_remove_pushalot.cs @@ -0,0 +1,16 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(20)] + public class remove_pushalot : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Delete.FromTable("Notifications").Row(new { Implementation = "Pushalot" }); + Delete.FromTable("Metadata").Row(new { Implementation = "MediaBrowserMetadata" }); + Delete.FromTable("MetadataFiles").Row(new { Consumer = "MediaBrowserMetadata" }); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/021_add_custom_filters.cs b/src/NzbDrone.Core/Datastore/Migration/021_add_custom_filters.cs new file mode 100644 index 000000000..15b9004eb --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/021_add_custom_filters.cs @@ -0,0 +1,17 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(021)] + public class add_custom_filters : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Create.TableForModel("CustomFilters") + .WithColumn("Type").AsString().NotNullable() + .WithColumn("Label").AsString().NotNullable() + .WithColumn("Filters").AsString().NotNullable(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/021_drop_seasons_table.cs b/src/NzbDrone.Core/Datastore/Migration/021_drop_seasons_table.cs deleted file mode 100644 index d2527c755..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/021_drop_seasons_table.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(21)] - public class drop_seasons_table : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Delete.Table("Seasons"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/022_import_list_tags.cs b/src/NzbDrone.Core/Datastore/Migration/022_import_list_tags.cs new file mode 100644 index 000000000..85739cbd2 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/022_import_list_tags.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(22)] + public class import_list_tags : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("ImportLists").AddColumn("Tags").AsString().Nullable(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/022_move_indexer_to_generic_provider.cs b/src/NzbDrone.Core/Datastore/Migration/022_move_indexer_to_generic_provider.cs deleted file mode 100644 index ea1908901..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/022_move_indexer_to_generic_provider.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(22)] - public class move_indexer_to_generic_provider : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("Indexers").AddColumn("ConfigContract").AsString().Nullable(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/023_add_config_contract_to_indexers.cs b/src/NzbDrone.Core/Datastore/Migration/023_add_config_contract_to_indexers.cs deleted file mode 100644 index 1a40a5a26..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/023_add_config_contract_to_indexers.cs +++ /dev/null @@ -1,19 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(23)] - public class add_config_contract_to_indexers : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Update.Table("Indexers").Set(new { ConfigContract = "NewznabSettings" }).Where(new { Implementation = "Newznab" }); - Update.Table("Indexers").Set(new { ConfigContract = "OmgwtfnzbsSettings" }).Where(new { Implementation = "Omgwtfnzbs" }); - Update.Table("Indexers").Set(new { ConfigContract = "NullConfig" }).Where(new { Implementation = "Wombles" }); - Update.Table("Indexers").Set(new { ConfigContract = "NullConfig" }).Where(new { Implementation = "Eztv" }); - - Delete.FromTable("Indexers").IsNull("ConfigContract"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/023_add_release_groups_etc.cs b/src/NzbDrone.Core/Datastore/Migration/023_add_release_groups_etc.cs new file mode 100644 index 000000000..6455dfe65 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/023_add_release_groups_etc.cs @@ -0,0 +1,282 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; +using NzbDrone.Common.Serializer; +using System.Collections.Generic; +using NzbDrone.Core.Music; +using System.Data; +using System; +using System.Linq; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(023)] + public class add_release_groups_etc : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + // ARTISTS TABLE + + Create.TableForModel("ArtistMetadata") + .WithColumn("ForeignArtistId").AsString().Unique() + .WithColumn("Name").AsString() + .WithColumn("Overview").AsString().Nullable() + .WithColumn("Disambiguation").AsString().Nullable() + .WithColumn("Type").AsString().Nullable() + .WithColumn("Status").AsInt32() + .WithColumn("Images").AsString() + .WithColumn("Links").AsString().Nullable() + .WithColumn("Genres").AsString().Nullable() + .WithColumn("Ratings").AsString().Nullable() + .WithColumn("Members").AsString().Nullable(); + + // we want to preserve the artist ID. Shove all the metadata into the metadata table. + Execute.Sql(@"INSERT INTO ArtistMetadata (ForeignArtistId, Name, Overview, Disambiguation, Type, Status, Images, Links, Genres, Ratings, Members) + SELECT ForeignArtistId, Name, Overview, Disambiguation, ArtistType, Status, Images, Links, Genres, Ratings, Members + FROM Artists"); + + // Add an ArtistMetadataId column to Artists + Alter.Table("Artists").AddColumn("ArtistMetadataId").AsInt32().WithDefaultValue(0); + + // Update artistmetadataId + Execute.Sql(@"UPDATE Artists + SET ArtistMetadataId = (SELECT ArtistMetadata.Id + FROM ArtistMetadata + WHERE ArtistMetadata.ForeignArtistId = Artists.ForeignArtistId)"); + + // ALBUM RELEASES TABLE - Do this before we mess with the Albums table + + Create.TableForModel("AlbumReleases") + .WithColumn("ForeignReleaseId").AsString().Unique() + .WithColumn("AlbumId").AsInt32().Indexed() + .WithColumn("Title").AsString() + .WithColumn("Status").AsString() + .WithColumn("Duration").AsInt32().WithDefaultValue(0) + .WithColumn("Label").AsString().Nullable() + .WithColumn("Disambiguation").AsString().Nullable() + .WithColumn("Country").AsString().Nullable() + .WithColumn("ReleaseDate").AsDateTime().Nullable() + .WithColumn("Media").AsString().Nullable() + .WithColumn("TrackCount").AsInt32().Nullable() + .WithColumn("Monitored").AsBoolean(); + + Execute.WithConnection(PopulateReleases); + + // ALBUMS TABLE + + // Add in the extra columns and update artist metadata id + Alter.Table("Albums").AddColumn("ArtistMetadataId").AsInt32().WithDefaultValue(0); + Alter.Table("Albums").AddColumn("AnyReleaseOk").AsBoolean().WithDefaultValue(true); + Alter.Table("Albums").AddColumn("Links").AsString().Nullable(); + + // Set metadata ID + Execute.Sql(@"UPDATE Albums + SET ArtistMetadataId = (SELECT ArtistMetadata.Id + FROM ArtistMetadata + JOIN Artists ON ArtistMetadata.Id = Artists.ArtistMetadataId + WHERE Albums.ArtistId = Artists.Id)"); + + // TRACKS TABLE + Alter.Table("Tracks").AddColumn("ForeignRecordingId").AsString().WithDefaultValue("0"); + Alter.Table("Tracks").AddColumn("AlbumReleaseId").AsInt32().WithDefaultValue(0); + Alter.Table("Tracks").AddColumn("ArtistMetadataId").AsInt32().WithDefaultValue(0); + + // Set track release to the only release we've bothered populating + Execute.Sql(@"UPDATE Tracks + SET AlbumReleaseId = (SELECT AlbumReleases.Id + FROM AlbumReleases + JOIN Albums ON AlbumReleases.AlbumId = Albums.Id + WHERE Albums.Id = Tracks.AlbumId)"); + + // Set metadata ID + Execute.Sql(@"UPDATE Tracks + SET ArtistMetadataId = (SELECT ArtistMetadata.Id + FROM ArtistMetadata + JOIN Albums ON ArtistMetadata.Id = Albums.ArtistMetadataId + WHERE Tracks.AlbumId = Albums.Id)"); + + // CLEAR OUT OLD COLUMNS + + // Remove the columns in Artists now in ArtistMetadata + Delete.Column("ForeignArtistId") + .Column("Name") + .Column("Overview") + .Column("Disambiguation") + .Column("ArtistType") + .Column("Status") + .Column("Images") + .Column("Links") + .Column("Genres") + .Column("Ratings") + .Column("Members") + // as well as the ones no longer used + .Column("MBId") + .Column("AMId") + .Column("TADBId") + .Column("DiscogsId") + .Column("NameSlug") + .Column("LastDiskSync") + .Column("DateFormed") + .FromTable("Artists"); + + // Remove old columns from Albums + Delete.Column("ArtistId") + .Column("MBId") + .Column("AMId") + .Column("TADBId") + .Column("DiscogsId") + .Column("TitleSlug") + .Column("Label") + .Column("SortTitle") + .Column("Tags") + .Column("Duration") + .Column("Media") + .Column("Releases") + .Column("CurrentRelease") + .Column("LastDiskSync") + .FromTable("Albums"); + + // Remove old columns from Tracks + Delete.Column("ArtistId") + .Column("AlbumId") + .Column("Compilation") + .Column("DiscNumber") + .Column("Monitored") + .FromTable("Tracks"); + + // Remove old columns from TrackFiles + Delete.Column("ArtistId").FromTable("TrackFiles"); + + // Add indices + Create.Index().OnTable("Artists").OnColumn("ArtistMetadataId").Ascending(); + Create.Index().OnTable("Artists").OnColumn("Monitored").Ascending(); + Create.Index().OnTable("Albums").OnColumn("ArtistMetadataId").Ascending(); + Create.Index().OnTable("Tracks").OnColumn("ArtistMetadataId").Ascending(); + Create.Index().OnTable("Tracks").OnColumn("AlbumReleaseId").Ascending(); + Create.Index().OnTable("Tracks").OnColumn("ForeignRecordingId").Ascending(); + + // Force a metadata refresh + Update.Table("Artists").Set(new { LastInfoSync = new System.DateTime(2018, 1, 1, 0, 0, 1)}).AllRows(); + Update.Table("Albums").Set(new { LastInfoSync = new System.DateTime(2018, 1, 1, 0, 0, 1)}).AllRows(); + Update.Table("ScheduledTasks") + .Set(new { LastExecution = new System.DateTime(2018, 1, 1, 0, 0, 1)}) + .Where(new { TypeName = "NzbDrone.Core.Music.Commands.RefreshArtistCommand" }); + + } + + private void PopulateReleases(IDbConnection conn, IDbTransaction tran) + { + var releases = ReadReleasesFromAlbums(conn, tran); + var dupeFreeReleases = releases.DistinctBy(x => x.ForeignReleaseId).ToList(); + var duplicates = releases.Except(dupeFreeReleases); + foreach (var release in duplicates) + { + release.ForeignReleaseId = release.AlbumId.ToString(); + } + WriteReleasesToReleases(releases, conn, tran); + } + + public class LegacyAlbumRelease : IEmbeddedDocument + { + public string Id { get; set; } + public string Title { get; set; } + public DateTime? ReleaseDate { get; set; } + public int TrackCount { get; set; } + public int MediaCount { get; set; } + public string Disambiguation { get; set; } + public List Country { get; set; } + public string Format { get; set; } + public List Label { get; set; } + } + + private List ReadReleasesFromAlbums(IDbConnection conn, IDbTransaction tran) + { + + // need to get all the old albums + var releases = new List(); + + using (var getReleasesCmd = conn.CreateCommand()) + { + getReleasesCmd.Transaction = tran; + getReleasesCmd.CommandText = @"SELECT Id, CurrentRelease FROM Albums"; + + using (var releaseReader = getReleasesCmd.ExecuteReader()) + { + while (releaseReader.Read()) + { + int albumId = releaseReader.GetInt32(0); + var albumRelease = Json.Deserialize(releaseReader.GetString(1)); + + AlbumRelease toInsert = null; + if (albumRelease != null) + { + var media = new List(); + for (var i = 1; i <= Math.Max(albumRelease.MediaCount, 1); i++) + { + media.Add(new Medium { Number = i, Name = "", Format = albumRelease.Format ?? "Unknown" } ); + } + + toInsert = new AlbumRelease { + AlbumId = albumId, + ForeignReleaseId = albumRelease.Id.IsNotNullOrWhiteSpace() ? albumRelease.Id : albumId.ToString(), + Title = albumRelease.Title.IsNotNullOrWhiteSpace() ? albumRelease.Title : "", + Status = "", + Duration = 0, + Label = albumRelease.Label, + Disambiguation = albumRelease.Disambiguation, + Country = albumRelease.Country, + Media = media, + TrackCount = albumRelease.TrackCount, + Monitored = true + }; + } + else + { + toInsert = new AlbumRelease { + AlbumId = albumId, + ForeignReleaseId = albumId.ToString(), + Title = "", + Status = "", + Label = new List(), + Country = new List(), + Media = new List { new Medium { Name = "Unknown", Number = 1, Format = "Unknown" } }, + Monitored = true + }; + } + + releases.Add(toInsert); + } + } + } + + return releases; + } + + private void WriteReleasesToReleases(List releases, IDbConnection conn, IDbTransaction tran) + { + foreach (var release in releases) + { + using (var writeReleaseCmd = conn.CreateCommand()) + { + writeReleaseCmd.Transaction = tran; + writeReleaseCmd.CommandText = + "INSERT INTO AlbumReleases (AlbumId, ForeignReleaseId, Title, Status, Duration, Label, Disambiguation, Country, Media, TrackCount, Monitored) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + writeReleaseCmd.AddParameter(release.AlbumId); + writeReleaseCmd.AddParameter(release.ForeignReleaseId); + writeReleaseCmd.AddParameter(release.Title); + writeReleaseCmd.AddParameter(release.Status); + writeReleaseCmd.AddParameter(release.Duration); + writeReleaseCmd.AddParameter(release.Label.ToJson()); + writeReleaseCmd.AddParameter(release.Disambiguation); + writeReleaseCmd.AddParameter(release.Country.ToJson()); + writeReleaseCmd.AddParameter(release.Media.ToJson()); + writeReleaseCmd.AddParameter(release.TrackCount); + writeReleaseCmd.AddParameter(release.Monitored); + + writeReleaseCmd.ExecuteNonQuery(); + } + } + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/024_clear_media_info.cs b/src/NzbDrone.Core/Datastore/Migration/024_clear_media_info.cs new file mode 100644 index 000000000..4004f4efb --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/024_clear_media_info.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(024)] + public class NewMediaInfoFormat : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Update.Table("TrackFiles").Set(new { MediaInfo = "" }).AllRows(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/024_drop_tvdb_episodeid.cs b/src/NzbDrone.Core/Datastore/Migration/024_drop_tvdb_episodeid.cs deleted file mode 100644 index c723f462c..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/024_drop_tvdb_episodeid.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(24)] - public class drop_tvdb_episodeid : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Delete.Column("TvDbEpisodeId").FromTable("Episodes"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/025_move_notification_to_generic_provider.cs b/src/NzbDrone.Core/Datastore/Migration/025_move_notification_to_generic_provider.cs deleted file mode 100644 index 1937f76eb..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/025_move_notification_to_generic_provider.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(25)] - public class move_notification_to_generic_provider : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("Notifications").AddColumn("ConfigContract").AsString().Nullable(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/025_rename_release_profiles.cs b/src/NzbDrone.Core/Datastore/Migration/025_rename_release_profiles.cs new file mode 100644 index 000000000..0ae44ae45 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/025_rename_release_profiles.cs @@ -0,0 +1,15 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(025)] + public class rename_restrictions_to_release_profiles : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Rename.Table("Restrictions").To("ReleaseProfiles"); + Alter.Table("ReleaseProfiles").AddColumn("IncludePreferredWhenRenaming").AsBoolean().WithDefaultValue(true); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/026_add_config_contract_to_notifications.cs b/src/NzbDrone.Core/Datastore/Migration/026_add_config_contract_to_notifications.cs deleted file mode 100644 index 8eb24daae..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/026_add_config_contract_to_notifications.cs +++ /dev/null @@ -1,24 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(26)] - public class add_config_contract_to_notifications : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Update.Table("Notifications").Set(new { ConfigContract = "EmailSettings" }).Where(new { Implementation = "Email" }); - Update.Table("Notifications").Set(new { ConfigContract = "GrowlSettings" }).Where(new { Implementation = "Growl" }); - Update.Table("Notifications").Set(new { ConfigContract = "NotifyMyAndroidSettings" }).Where(new { Implementation = "NotifyMyAndroid" }); - Update.Table("Notifications").Set(new { ConfigContract = "PlexClientSettings" }).Where(new { Implementation = "PlexClient" }); - Update.Table("Notifications").Set(new { ConfigContract = "PlexServerSettings" }).Where(new { Implementation = "PlexServer" }); - Update.Table("Notifications").Set(new { ConfigContract = "ProwlSettings" }).Where(new { Implementation = "Prowl" }); - Update.Table("Notifications").Set(new { ConfigContract = "PushBulletSettings" }).Where(new { Implementation = "PushBullet" }); - Update.Table("Notifications").Set(new { ConfigContract = "PushoverSettings" }).Where(new { Implementation = "Pushover" }); - Update.Table("Notifications").Set(new { ConfigContract = "XbmcSettings" }).Where(new { Implementation = "Xbmc" }); - - Delete.FromTable("Notifications").IsNull("ConfigContract"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/026_rename_quality_profiles_add_upgrade_allowed.cs b/src/NzbDrone.Core/Datastore/Migration/026_rename_quality_profiles_add_upgrade_allowed.cs new file mode 100644 index 000000000..36aa6fbc5 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/026_rename_quality_profiles_add_upgrade_allowed.cs @@ -0,0 +1,23 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(026)] + public class rename_quality_profiles_add_upgrade_allowed : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Rename.Table("Profiles").To("QualityProfiles"); + + Alter.Table("QualityProfiles").AddColumn("UpgradeAllowed").AsInt32().Nullable(); + Alter.Table("LanguageProfiles").AddColumn("UpgradeAllowed").AsInt32().Nullable(); + + // Set upgrade allowed for existing profiles (default will be false for new profiles) + Update.Table("QualityProfiles").Set(new { UpgradeAllowed = true }).AllRows(); + Update.Table("LanguageProfiles").Set(new { UpgradeAllowed = true }).AllRows(); + + Rename.Column("ProfileId").OnTable("Artists").To("QualityProfileId"); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/027_add_import_exclusions.cs b/src/NzbDrone.Core/Datastore/Migration/027_add_import_exclusions.cs new file mode 100644 index 000000000..7832ea365 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/027_add_import_exclusions.cs @@ -0,0 +1,16 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(027)] + public class add_import_exclusions : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Create.TableForModel("ImportListExclusions") + .WithColumn("ForeignId").AsString().NotNullable().Unique() + .WithColumn("Name").AsString().NotNullable(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/027_fix_omgwtfnzbs.cs b/src/NzbDrone.Core/Datastore/Migration/027_fix_omgwtfnzbs.cs deleted file mode 100644 index d7b8b31fc..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/027_fix_omgwtfnzbs.cs +++ /dev/null @@ -1,24 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(27)] - public class fix_omgwtfnzbs : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Update.Table("Indexers") - .Set(new {ConfigContract = "OmgwtfnzbsSettings"}) - .Where(new {Implementation = "Omgwtfnzbs"}); - - Update.Table("Indexers") - .Set(new {Settings = "{}"}) - .Where(new {Implementation = "Omgwtfnzbs", Settings = (string) null}); - - Update.Table("Indexers") - .Set(new { Settings = "{}" }) - .Where(new { Implementation = "Omgwtfnzbs", Settings = "" }); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/028_add_blacklist_table.cs b/src/NzbDrone.Core/Datastore/Migration/028_add_blacklist_table.cs deleted file mode 100644 index 0514c9689..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/028_add_blacklist_table.cs +++ /dev/null @@ -1,19 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(28)] - public class add_blacklist_table : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Create.TableForModel("Blacklist") - .WithColumn("SeriesId").AsInt32() - .WithColumn("EpisodeIds").AsString() - .WithColumn("SourceTitle").AsString() - .WithColumn("Quality").AsString() - .WithColumn("Date").AsDateTime(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/028_clean_artistmetadata_table.cs b/src/NzbDrone.Core/Datastore/Migration/028_clean_artistmetadata_table.cs new file mode 100644 index 000000000..51e835358 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/028_clean_artistmetadata_table.cs @@ -0,0 +1,55 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(028)] + public class clean_artist_metadata_table : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + // Remove any artists linked to missing metadata + Execute.Sql(@"DELETE FROM Artists + WHERE Id in ( + SELECT Artists.Id from Artists + LEFT OUTER JOIN ArtistMetadata ON Artists.ArtistMetadataId = ArtistMetadata.Id + WHERE ArtistMetadata.Id IS NULL)"); + + // Remove any albums linked to missing metadata + Execute.Sql(@"DELETE FROM Albums + WHERE Id in ( + SELECT Albums.Id from Albums + LEFT OUTER JOIN ArtistMetadata ON Albums.ArtistMetadataId = ArtistMetadata.Id + WHERE ArtistMetadata.Id IS NULL)"); + + // Remove any album releases linked to albums that were deleted + Execute.Sql(@"DELETE FROM AlbumReleases + WHERE Id in ( + SELECT AlbumReleases.Id from AlbumReleases + LEFT OUTER JOIN Albums ON Albums.Id = AlbumReleases.AlbumId + WHERE Albums.Id IS NULL)"); + + // Remove any tracks linked to album releases that were deleted + Execute.Sql(@"DELETE FROM Tracks + WHERE Id in ( + SELECT Tracks.Id from Tracks + LEFT OUTER JOIN AlbumReleases ON Tracks.AlbumReleaseId = AlbumReleases.Id + WHERE AlbumReleases.Id IS NULL)"); + + // Remove any tracks linked to the original missing metadata + Execute.Sql(@"DELETE FROM Tracks + WHERE Id in ( + SELECT Tracks.Id from Tracks + LEFT OUTER JOIN ArtistMetadata ON Tracks.ArtistMetadataId = ArtistMetadata.Id + WHERE ArtistMetadata.Id IS NULL)"); + + // Remove any trackfiles linked to the deleted tracks + Execute.Sql(@"DELETE FROM TrackFiles + WHERE Id IN ( + SELECT TrackFiles.Id FROM TrackFiles + LEFT OUTER JOIN Tracks + ON TrackFiles.Id = Tracks.TrackFileId + WHERE Tracks.Id IS NULL)"); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/029_add_formats_to_naming_config.cs b/src/NzbDrone.Core/Datastore/Migration/029_add_formats_to_naming_config.cs deleted file mode 100644 index 6d3dd897b..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/029_add_formats_to_naming_config.cs +++ /dev/null @@ -1,154 +0,0 @@ -using System.Collections.Generic; -using System.Data; -using System.Linq; -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(29)] - public class add_formats_to_naming_config : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("NamingConfig").AddColumn("StandardEpisodeFormat").AsString().Nullable(); - Alter.Table("NamingConfig").AddColumn("DailyEpisodeFormat").AsString().Nullable(); - - Execute.WithConnection(ConvertConfig); - } - - private void ConvertConfig(IDbConnection conn, IDbTransaction tran) - { - using (IDbCommand namingConfigCmd = conn.CreateCommand()) - { - namingConfigCmd.Transaction = tran; - namingConfigCmd.CommandText = @"SELECT * FROM NamingConfig LIMIT 1"; - using (IDataReader namingConfigReader = namingConfigCmd.ExecuteReader()) - { - var separatorIndex = namingConfigReader.GetOrdinal("Separator"); - var numberStyleIndex = namingConfigReader.GetOrdinal("NumberStyle"); - var includeSeriesTitleIndex = namingConfigReader.GetOrdinal("IncludeSeriesTitle"); - var includeEpisodeTitleIndex = namingConfigReader.GetOrdinal("IncludeEpisodeTitle"); - var includeQualityIndex = namingConfigReader.GetOrdinal("IncludeQuality"); - var replaceSpacesIndex = namingConfigReader.GetOrdinal("ReplaceSpaces"); - - while (namingConfigReader.Read()) - { - var separator = namingConfigReader.GetString(separatorIndex); - var numberStyle = namingConfigReader.GetInt32(numberStyleIndex); - var includeSeriesTitle = namingConfigReader.GetBoolean(includeSeriesTitleIndex); - var includeEpisodeTitle = namingConfigReader.GetBoolean(includeEpisodeTitleIndex); - var includeQuality = namingConfigReader.GetBoolean(includeQualityIndex); - var replaceSpaces = namingConfigReader.GetBoolean(replaceSpacesIndex); - - //Output settings - var seriesTitlePattern = ""; - var episodeTitlePattern = ""; - var dailyEpisodePattern = "{Air-Date}"; - var qualityFormat = " [{Quality Title}]"; - - if (includeSeriesTitle) - { - if (replaceSpaces) - { - seriesTitlePattern = "{Series.Title}"; - } - - else - { - seriesTitlePattern = "{Series Title}"; - } - - seriesTitlePattern += separator; - } - - if (includeEpisodeTitle) - { - episodeTitlePattern = separator; - - if (replaceSpaces) - { - episodeTitlePattern += "{Episode.Title}"; - } - - else - { - episodeTitlePattern += "{Episode Title}"; - } - } - - var standardEpisodeFormat = string.Format("{0}{1}{2}", seriesTitlePattern, - GetNumberStyle(numberStyle).Pattern, - episodeTitlePattern); - - var dailyEpisodeFormat = string.Format("{0}{1}{2}", seriesTitlePattern, - dailyEpisodePattern, - episodeTitlePattern); - - if (includeQuality) - { - if (replaceSpaces) - { - qualityFormat = ".[{Quality.Title}]"; - } - - standardEpisodeFormat += qualityFormat; - dailyEpisodeFormat += qualityFormat; - } - - using (IDbCommand updateCmd = conn.CreateCommand()) - { - var text = string.Format("UPDATE NamingConfig " + - "SET StandardEpisodeFormat = '{0}', " + - "DailyEpisodeFormat = '{1}'", - standardEpisodeFormat, - dailyEpisodeFormat); - - updateCmd.Transaction = tran; - updateCmd.CommandText = text; - updateCmd.ExecuteNonQuery(); - } - } - } - } - } - - private static readonly List NumberStyles = new List - { - new - { - Id = 0, - Name = "1x05", - Pattern = "{season}x{episode:00}", - EpisodeSeparator = "x" - - }, - new - { - Id = 1, - Name = "01x05", - Pattern = "{season:00}x{episode:00}", - EpisodeSeparator = "x" - }, - new - { - Id = 2, - Name = "S01E05", - Pattern = "S{season:00}E{episode:00}", - EpisodeSeparator = "E" - }, - new - { - Id = 3, - Name = "s01e05", - Pattern = "s{season:00}e{episode:00}", - EpisodeSeparator = "e" - } - }; - - private static dynamic GetNumberStyle(int id) - { - return NumberStyles.Single(s => s.Id == id); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/029_health_issue_notification.cs b/src/NzbDrone.Core/Datastore/Migration/029_health_issue_notification.cs new file mode 100644 index 000000000..9456f8544 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/029_health_issue_notification.cs @@ -0,0 +1,23 @@ +using FluentMigrator; + +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(29)] + public class health_issue_notification : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("Notifications").AddColumn("OnHealthIssue").AsBoolean().WithDefaultValue(0); + Alter.Table("Notifications").AddColumn("IncludeHealthWarnings").AsBoolean().WithDefaultValue(0); + Alter.Table("Notifications").AddColumn("OnDownloadFailure").AsBoolean().WithDefaultValue(0); + Alter.Table("Notifications").AddColumn("OnImportFailure").AsBoolean().WithDefaultValue(0); + Alter.Table("Notifications").AddColumn("OnTrackRetag").AsBoolean().WithDefaultValue(0); + + Delete.Column("OnDownload").FromTable("Notifications"); + + Rename.Column("OnAlbumDownload").OnTable("Notifications").To("OnReleaseImport"); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/030_add_mediafilerepository_mtime.cs b/src/NzbDrone.Core/Datastore/Migration/030_add_mediafilerepository_mtime.cs new file mode 100644 index 000000000..0645eb54b --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/030_add_mediafilerepository_mtime.cs @@ -0,0 +1,64 @@ +using System; +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(30)] + public class add_mediafilerepository_mtime : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("TrackFiles").AddColumn("Modified").AsDateTime().WithDefaultValue(new DateTime(2000, 1, 1)); + Alter.Table("TrackFiles").AddColumn("Path").AsString().Nullable(); + + // Remove anything where RelativePath is null + Execute.Sql(@"DELETE FROM TrackFiles WHERE RelativePath IS NULL"); + + // Remove anything not linked to a track (these shouldn't be present in version < 30) + Execute.Sql(@"DELETE FROM TrackFiles + WHERE Id IN ( + SELECT TrackFiles.Id FROM TrackFiles + LEFT JOIN Tracks ON TrackFiles.Id = Tracks.TrackFileId + WHERE Tracks.Id IS NULL)"); + + // Remove anything where we can't get an artist path (i.e. we don't know where it is) + Execute.Sql(@"DELETE FROM TrackFiles + WHERE Id IN ( + SELECT TrackFiles.Id FROM TrackFiles + LEFT JOIN Albums ON TrackFiles.AlbumId = Albums.Id + LEFT JOIN Artists on Artists.ArtistMetadataId = Albums.ArtistMetadataId + WHERE Artists.Path IS NULL)"); + + // Remove anything linked to unmonitored or unidentified releases. This should ensure uniqueness of track files. + Execute.Sql(@"DELETE FROM TrackFiles + WHERE Id IN ( + SELECT TrackFiles.Id FROM TrackFiles + LEFT JOIN Tracks ON TrackFiles.Id = Tracks.TrackFileId + LEFT JOIN AlbumReleases ON Tracks.AlbumReleaseId = AlbumReleases.Id + WHERE AlbumReleases.Monitored = 0 + OR AlbumReleases.Monitored IS NULL)"); + + // Populate the full paths + Execute.Sql(@"UPDATE TrackFiles + SET Path = (SELECT Artists.Path || '" + System.IO.Path.DirectorySeparatorChar + @"' || TrackFiles.RelativePath + FROM Artists + JOIN Albums ON Albums.ArtistMetadataId = Artists.ArtistMetadataId + WHERE TrackFiles.AlbumId = Albums.Id)"); + + // Belt and braces to ensure uniqueness + Execute.Sql(@"DELETE FROM TrackFiles + WHERE rowid NOT IN ( + SELECT min(rowid) + FROM TrackFiles + GROUP BY Path + )"); + + // Now enforce the uniqueness constraint + Alter.Table("TrackFiles").AlterColumn("Path").AsString().NotNullable().Unique(); + + // Finally delete the relative path column + Delete.Column("RelativePath").FromTable("TrackFiles"); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/030_add_season_folder_format_to_naming_config.cs b/src/NzbDrone.Core/Datastore/Migration/030_add_season_folder_format_to_naming_config.cs deleted file mode 100644 index 185a53a19..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/030_add_season_folder_format_to_naming_config.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Data; -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(30)] - public class add_season_folder_format_to_naming_config : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("NamingConfig").AddColumn("SeasonFolderFormat").AsString().Nullable(); - Execute.WithConnection(ConvertConfig); - Execute.Sql("DELETE FROM Config WHERE [Key] = 'seasonfolderformat'"); - Execute.Sql("DELETE FROM Config WHERE [Key] = 'useseasonfolder'"); - } - - private void ConvertConfig(IDbConnection conn, IDbTransaction tran) - { - using (IDbCommand namingConfigCmd = conn.CreateCommand()) - { - namingConfigCmd.Transaction = tran; - namingConfigCmd.CommandText = @"SELECT [Value] FROM Config WHERE [Key] = 'seasonfolderformat'"; - var seasonFormat = "Season {season}"; - - using (IDataReader namingConfigReader = namingConfigCmd.ExecuteReader()) - { - while (namingConfigReader.Read()) - { - //only getting one column, so its index is 0 - seasonFormat = namingConfigReader.GetString(0); - - seasonFormat = seasonFormat.Replace("%sn", "{Series Title}") - .Replace("%s.n", "{Series.Title}") - .Replace("%s", "{season}") - .Replace("%0s", "{season:00}") - .Replace("%e", "{episode}") - .Replace("%0e", "{episode:00}"); - } - } - - using (IDbCommand updateCmd = conn.CreateCommand()) - { - var text = string.Format("UPDATE NamingConfig " + - "SET SeasonFolderFormat = '{0}'", - seasonFormat); - - updateCmd.Transaction = tran; - updateCmd.CommandText = text; - updateCmd.ExecuteNonQuery(); - } - } - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/031_add_artistmetadataid_constraint.cs b/src/NzbDrone.Core/Datastore/Migration/031_add_artistmetadataid_constraint.cs new file mode 100644 index 000000000..8dfefa4b8 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/031_add_artistmetadataid_constraint.cs @@ -0,0 +1,25 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(31)] + public class add_artistmetadataid_constraint : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + // Remove any duplicate artists + Execute.Sql(@"DELETE FROM Artists + WHERE Id NOT IN ( + SELECT MIN(Artists.id) from Artists + JOIN ArtistMetadata ON Artists.ArtistMetadataId = ArtistMetadata.Id + GROUP BY ArtistMetadata.Id)"); + + // The index exists but will be recreated as part of unique constraint + Delete.Index().OnTable("Artists").OnColumn("ArtistMetadataId"); + + // Add a constraint to prevent any more duplicates + Alter.Column("ArtistMetadataId").OnTable("Artists").AsInt32().Unique(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/031_delete_old_naming_config_columns.cs b/src/NzbDrone.Core/Datastore/Migration/031_delete_old_naming_config_columns.cs deleted file mode 100644 index 90c7571af..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/031_delete_old_naming_config_columns.cs +++ /dev/null @@ -1,20 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(31)] - public class delete_old_naming_config_columns : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Delete.Column("Separator") - .Column("NumberStyle") - .Column("IncludeSeriesTitle") - .Column("IncludeEpisodeTitle") - .Column("IncludeQuality") - .Column("ReplaceSpaces") - .FromTable("NamingConfig"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/032_old_ids_and_artist_alias.cs b/src/NzbDrone.Core/Datastore/Migration/032_old_ids_and_artist_alias.cs new file mode 100644 index 000000000..f2df39fdc --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/032_old_ids_and_artist_alias.cs @@ -0,0 +1,20 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(32)] + public class old_ids_and_artist_alias : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("ArtistMetadata").AddColumn("Aliases").AsString().WithDefaultValue("[]"); + + Alter.Table("ArtistMetadata").AddColumn("OldForeignArtistIds").AsString().WithDefaultValue("[]"); + Alter.Table("Albums").AddColumn("OldForeignAlbumIds").AsString().WithDefaultValue("[]"); + Alter.Table("AlbumReleases").AddColumn("OldForeignReleaseIds").AsString().WithDefaultValue("[]"); + Alter.Table("Tracks").AddColumn("OldForeignRecordingIds").AsString().WithDefaultValue("[]"); + Alter.Table("Tracks").AddColumn("OldForeignTrackIds").AsString().WithDefaultValue("[]"); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/032_set_default_release_group.cs b/src/NzbDrone.Core/Datastore/Migration/032_set_default_release_group.cs deleted file mode 100644 index 5ecc4e2c0..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/032_set_default_release_group.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(32)] - public class set_default_release_group : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Execute.Sql("UPDATE EpisodeFiles SET ReleaseGroup = 'DRONE' WHERE ReleaseGroup IS NULL"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/033_add_api_key_to_pushover.cs b/src/NzbDrone.Core/Datastore/Migration/033_add_api_key_to_pushover.cs deleted file mode 100644 index 670d1f8ab..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/033_add_api_key_to_pushover.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System.Data; -using FluentMigrator; -using NzbDrone.Common.Serializer; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(33)] - public class add_api_key_to_pushover : NzbDroneMigrationBase - { - private const string API_KEY = "yz9b4U215iR4vrKFRfjNXP24NMNPKJ"; - - protected override void MainDbUpgrade() - { - Execute.WithConnection(UpdatePushoverSettings); - } - - private void UpdatePushoverSettings(IDbConnection conn, IDbTransaction tran) - { - using (IDbCommand selectCommand = conn.CreateCommand()) - { - selectCommand.Transaction = tran; - selectCommand.CommandText = @"SELECT * FROM Notifications WHERE ConfigContract = 'PushoverSettings'"; - - using (IDataReader reader = selectCommand.ExecuteReader()) - { - while (reader.Read()) - { - var idIndex = reader.GetOrdinal("Id"); - var settingsIndex = reader.GetOrdinal("Settings"); - - var id = reader.GetInt32(idIndex); - var settings = Json.Deserialize(reader.GetString(settingsIndex)); - settings.ApiKey = API_KEY; - - //Set priority to high if its currently emergency - if (settings.Priority == 2) - { - settings.Priority = 1; - } - - using (IDbCommand updateCmd = conn.CreateCommand()) - { - var text = string.Format("UPDATE Notifications " + - "SET Settings = '{0}'" + - "WHERE Id = {1}", - settings.ToJson(), id - ); - - updateCmd.Transaction = tran; - updateCmd.CommandText = text; - updateCmd.ExecuteNonQuery(); - } - } - } - } - } - - private class PushoverSettingsForV33 - { - public string ApiKey { get; set; } - public string UserKey { get; set; } - public int Priority { get; set; } - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/033_download_propers_config.cs b/src/NzbDrone.Core/Datastore/Migration/033_download_propers_config.cs new file mode 100644 index 000000000..a6acd76f1 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/033_download_propers_config.cs @@ -0,0 +1,43 @@ +using System.Data; +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(033)] + public class download_propers_config : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Execute.WithConnection(SetConfigValue); + Execute.Sql("DELETE FROM Config WHERE Key = 'autodownloadpropers'"); + } + + private void SetConfigValue(IDbConnection conn, IDbTransaction tran) + { + using (var cmd = conn.CreateCommand()) + { + cmd.Transaction = tran; + cmd.CommandText = "SELECT Value FROM Config WHERE Key = 'autodownloadpropers'"; + + using (var reader = cmd.ExecuteReader()) + { + while (reader.Read()) + { + var value = reader.GetString(0); + var newValue = bool.Parse(value) ? "PreferAndUpgrade" : "DoNotUpgrade"; + + using (var updateCmd = conn.CreateCommand()) + { + updateCmd.Transaction = tran; + updateCmd.CommandText = "INSERT INTO Config (key, value) VALUES ('downloadpropersandrepacks', ?)"; + updateCmd.AddParameter(newValue); + + updateCmd.ExecuteNonQuery(); + } + } + } + } + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/034_remove_language_profiles.cs b/src/NzbDrone.Core/Datastore/Migration/034_remove_language_profiles.cs new file mode 100644 index 000000000..39192cc06 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/034_remove_language_profiles.cs @@ -0,0 +1,21 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(34)] + public class remove_language_profiles : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Delete.Table("LanguageProfiles"); + + Delete.Column("LanguageProfileId").FromTable("Artists"); + Delete.Column("LanguageProfileId").FromTable("ImportLists"); + Delete.Column("Language").FromTable("Blacklist"); + Delete.Column("Language").FromTable("History"); + Delete.Column("Language").FromTable("LyricFiles"); + Delete.Column("Language").FromTable("TrackFiles"); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/034_remove_series_contraints.cs b/src/NzbDrone.Core/Datastore/Migration/034_remove_series_contraints.cs deleted file mode 100644 index 8eb08798a..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/034_remove_series_contraints.cs +++ /dev/null @@ -1,16 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(34)] - public class remove_series_contraints : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("Series") - .AlterColumn("ImdbId").AsString().Nullable() - .AlterColumn("TitleSlug").AsString().Nullable(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/035_add_series_folder_format_to_naming_config.cs b/src/NzbDrone.Core/Datastore/Migration/035_add_series_folder_format_to_naming_config.cs deleted file mode 100644 index 9423a54f0..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/035_add_series_folder_format_to_naming_config.cs +++ /dev/null @@ -1,16 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(35)] - public class add_series_folder_format_to_naming_config : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("NamingConfig").AddColumn("SeriesFolderFormat").AsString().Nullable(); - - Execute.Sql("UPDATE NamingConfig SET SeriesFolderFormat = '{Series Title}'"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/035_multi_disc_naming_format.cs b/src/NzbDrone.Core/Datastore/Migration/035_multi_disc_naming_format.cs new file mode 100644 index 000000000..e1f9b915b --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/035_multi_disc_naming_format.cs @@ -0,0 +1,17 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; +using System.Data; +using System.IO; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(35)] + public class multi_disc_naming_format : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("NamingConfig").AddColumn("MultiDiscTrackFormat").AsString().Nullable(); + Execute.Sql("UPDATE NamingConfig SET MultiDiscTrackFormat = '{Medium Format} {medium:00}/{Artist Name} - {Album Title} - {track:00} - {Track Title}'"); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/036_update_with_quality_converters.cs b/src/NzbDrone.Core/Datastore/Migration/036_update_with_quality_converters.cs deleted file mode 100644 index 37d94e33d..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/036_update_with_quality_converters.cs +++ /dev/null @@ -1,129 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; -using System.Data; -using System.Linq; -using NzbDrone.Common.Serializer; -using NzbDrone.Core.Profiles; -using NzbDrone.Core.Qualities; -using System.Collections.Generic; -using NzbDrone.Core.Datastore.Converters; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(36)] - public class update_with_quality_converters : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - if (!Schema.Table("QualityProfiles").Column("Items").Exists()) - { - Alter.Table("QualityProfiles").AddColumn("Items").AsString().Nullable(); - } - - Execute.WithConnection(ConvertQualityProfiles); - Execute.WithConnection(ConvertQualityModels); - } - - private void ConvertQualityProfiles(IDbConnection conn, IDbTransaction tran) - { - var qualityProfileItemConverter = new EmbeddedDocumentConverter(new QualityIntConverter()); - - // Convert 'Allowed' column in QualityProfiles from Json List to Json List (int = Quality) - using (IDbCommand qualityProfileCmd = conn.CreateCommand()) - { - qualityProfileCmd.Transaction = tran; - qualityProfileCmd.CommandText = @"SELECT Id, Allowed FROM QualityProfiles"; - using (IDataReader qualityProfileReader = qualityProfileCmd.ExecuteReader()) - { - while (qualityProfileReader.Read()) - { - var id = qualityProfileReader.GetInt32(0); - var allowedJson = qualityProfileReader.GetString(1); - - var allowed = Json.Deserialize>(allowedJson); - - var items = Quality.DefaultQualityDefinitions.OrderBy(v => v.Weight).Select(v => new ProfileQualityItem { Quality = v.Quality, Allowed = allowed.Contains(v.Quality) }).ToList(); - - var allowedNewJson = qualityProfileItemConverter.ToDB(items); - - using (IDbCommand updateCmd = conn.CreateCommand()) - { - updateCmd.Transaction = tran; - updateCmd.CommandText = "UPDATE QualityProfiles SET Items = ? WHERE Id = ?"; - updateCmd.AddParameter(allowedNewJson); - updateCmd.AddParameter(id); - - updateCmd.ExecuteNonQuery(); - } - } - } - } - } - - private void ConvertQualityModels(IDbConnection conn, IDbTransaction tran) - { - // Converts the QualityModel JSON objects to their new format (only storing the QualityId instead of the entire object) - ConvertQualityModel(conn, tran, "Blacklist"); - ConvertQualityModel(conn, tran, "EpisodeFiles"); - ConvertQualityModel(conn, tran, "History"); - } - - private void ConvertQualityModel(IDbConnection conn, IDbTransaction tran, string tableName) - { - var qualityModelConverter = new EmbeddedDocumentConverter(new QualityIntConverter()); - - using (IDbCommand qualityModelCmd = conn.CreateCommand()) - { - qualityModelCmd.Transaction = tran; - qualityModelCmd.CommandText = @"SELECT Distinct Quality FROM " + tableName; - using (IDataReader qualityModelReader = qualityModelCmd.ExecuteReader()) - { - while (qualityModelReader.Read()) - { - var qualityJson = qualityModelReader.GetString(0); - - SourceQualityModel036 sourceQuality; - - if (!Json.TryDeserialize(qualityJson, out sourceQuality)) - { - continue; - } - - var qualityNewJson = qualityModelConverter.ToDB(new DestinationQualityModel036 - { - Quality = sourceQuality.Quality.Id, - Proper = sourceQuality.Proper - }); - - using (IDbCommand updateCmd = conn.CreateCommand()) - { - updateCmd.Transaction = tran; - updateCmd.CommandText = "UPDATE " + tableName + " SET Quality = ? WHERE Quality = ?"; - updateCmd.AddParameter(qualityNewJson); - updateCmd.AddParameter(qualityJson); - - updateCmd.ExecuteNonQuery(); - } - } - } - } - } - - private class DestinationQualityModel036 - { - public int Quality { get; set; } - public bool Proper { get; set; } - } - - private class SourceQualityModel036 - { - public SourceQuality036 Quality { get; set; } - public bool Proper { get; set; } - } - - private class SourceQuality036 - { - public int Id { get; set; } - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/037_add_configurable_qualities.cs b/src/NzbDrone.Core/Datastore/Migration/037_add_configurable_qualities.cs deleted file mode 100644 index 06ced4854..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/037_add_configurable_qualities.cs +++ /dev/null @@ -1,64 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; -using System.Data; -using System.Linq; -using NzbDrone.Core.Qualities; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(37)] - public class add_configurable_qualities : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Delete.Column("Allowed").FromTable("QualityProfiles"); - - Alter.Column("Items").OnTable("QualityProfiles").AsString().NotNullable(); - - Create.TableForModel("QualityDefinitions") - .WithColumn("Quality").AsInt32().Unique() - .WithColumn("Title").AsString().Unique() - .WithColumn("Weight").AsInt32().Unique() - .WithColumn("MinSize").AsInt32() - .WithColumn("MaxSize").AsInt32(); - - Execute.WithConnection(ConvertQualities); - - Delete.Table("QualitySizes"); - } - - private void ConvertQualities(IDbConnection conn, IDbTransaction tran) - { - // Convert QualitySizes to a more generic QualityDefinitions table. - using (IDbCommand qualitySizeCmd = conn.CreateCommand()) - { - qualitySizeCmd.Transaction = tran; - qualitySizeCmd.CommandText = @"SELECT QualityId, MinSize, MaxSize FROM QualitySizes"; - using (IDataReader qualitySizeReader = qualitySizeCmd.ExecuteReader()) - { - while (qualitySizeReader.Read()) - { - var qualityId = qualitySizeReader.GetInt32(0); - var minSize = qualitySizeReader.GetInt32(1); - var maxSize = qualitySizeReader.GetInt32(2); - - var defaultConfig = Quality.DefaultQualityDefinitions.Single(p => (int)p.Quality == qualityId); - - using (IDbCommand updateCmd = conn.CreateCommand()) - { - updateCmd.Transaction = tran; - updateCmd.CommandText = "INSERT INTO QualityDefinitions (Quality, Title, Weight, MinSize, MaxSize) VALUES (?, ?, ?, ?, ?)"; - updateCmd.AddParameter(qualityId); - updateCmd.AddParameter(defaultConfig.Title); - updateCmd.AddParameter(defaultConfig.Weight); - updateCmd.AddParameter(minSize); - updateCmd.AddParameter(maxSize); - - updateCmd.ExecuteNonQuery(); - } - } - } - } - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/038_add_on_upgrade_to_notifications.cs b/src/NzbDrone.Core/Datastore/Migration/038_add_on_upgrade_to_notifications.cs deleted file mode 100644 index f5cae2ba0..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/038_add_on_upgrade_to_notifications.cs +++ /dev/null @@ -1,16 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(38)] - public class add_on_upgrade_to_notifications : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("Notifications").AddColumn("OnUpgrade").AsBoolean().Nullable(); - - Execute.Sql("UPDATE Notifications SET OnUpgrade = OnDownload"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/039_add_metadata_tables.cs b/src/NzbDrone.Core/Datastore/Migration/039_add_metadata_tables.cs deleted file mode 100644 index fdc7f2545..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/039_add_metadata_tables.cs +++ /dev/null @@ -1,28 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(39)] - public class add_metadata_tables : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Create.TableForModel("Metadata") - .WithColumn("Enable").AsBoolean().NotNullable() - .WithColumn("Name").AsString().NotNullable() - .WithColumn("Implementation").AsString().NotNullable() - .WithColumn("Settings").AsString().NotNullable() - .WithColumn("ConfigContract").AsString().NotNullable(); - - Create.TableForModel("MetadataFiles") - .WithColumn("SeriesId").AsInt32().NotNullable() - .WithColumn("Consumer").AsString().NotNullable() - .WithColumn("Type").AsInt32().NotNullable() - .WithColumn("RelativePath").AsString().NotNullable() - .WithColumn("LastUpdated").AsDateTime().NotNullable() - .WithColumn("SeasonNumber").AsInt32().Nullable() - .WithColumn("EpisodeFileId").AsInt32().Nullable(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/040_add_metadata_to_episodes_and_series.cs b/src/NzbDrone.Core/Datastore/Migration/040_add_metadata_to_episodes_and_series.cs deleted file mode 100644 index bf8119831..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/040_add_metadata_to_episodes_and_series.cs +++ /dev/null @@ -1,22 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(40)] - public class add_metadata_to_episodes_and_series : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("Series") - .AddColumn("Actors").AsString().Nullable() - .AddColumn("Ratings").AsString().Nullable() - .AddColumn("Genres").AsString().Nullable() - .AddColumn("Certification").AsString().Nullable(); - - Alter.Table("Episodes") - .AddColumn("Ratings").AsString().Nullable() - .AddColumn("Images").AsString().Nullable(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/041_fix_xbmc_season_images_metadata.cs b/src/NzbDrone.Core/Datastore/Migration/041_fix_xbmc_season_images_metadata.cs deleted file mode 100644 index 25cbc8ed4..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/041_fix_xbmc_season_images_metadata.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(41)] - public class fix_xbmc_season_images_metadata : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Execute.Sql("UPDATE MetadataFiles SET Type = 4 WHERE Consumer = 'XbmcMetadata' AND SeasonNumber IS NOT NULL"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/042_add_download_clients_table.cs b/src/NzbDrone.Core/Datastore/Migration/042_add_download_clients_table.cs deleted file mode 100644 index 08cf7622b..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/042_add_download_clients_table.cs +++ /dev/null @@ -1,20 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(42)] - public class add_download_clients_table : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Create.TableForModel("DownloadClients") - .WithColumn("Enable").AsBoolean().NotNullable() - .WithColumn("Name").AsString().NotNullable() - .WithColumn("Implementation").AsString().NotNullable() - .WithColumn("Settings").AsString().NotNullable() - .WithColumn("ConfigContract").AsString().NotNullable() - .WithColumn("Protocol").AsInt32().NotNullable(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/043_convert_config_to_download_clients.cs b/src/NzbDrone.Core/Datastore/Migration/043_convert_config_to_download_clients.cs deleted file mode 100644 index 505962776..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/043_convert_config_to_download_clients.cs +++ /dev/null @@ -1,197 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Data; -using FluentMigrator; -using NzbDrone.Common.Serializer; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(43)] - public class convert_config_to_download_clients : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Execute.WithConnection(ConvertToThingyProvder); - } - - private void ConvertToThingyProvder(IDbConnection conn, IDbTransaction tran) - { - var config = new Dictionary(); - - using (IDbCommand configCmd = conn.CreateCommand()) - { - configCmd.Transaction = tran; - configCmd.CommandText = @"SELECT * FROM Config"; - using (IDataReader configReader = configCmd.ExecuteReader()) - { - var keyIndex = configReader.GetOrdinal("Key"); - var valueIndex = configReader.GetOrdinal("Value"); - - while (configReader.Read()) - { - var key = configReader.GetString(keyIndex); - var value = configReader.GetString(valueIndex); - - config.Add(key.ToLowerInvariant(), value); - } - } - } - - var client = GetConfigValue(config, "DownloadClient", ""); - - if (string.IsNullOrWhiteSpace(client)) - { - return; - } - - if (client.Equals("sabnzbd", StringComparison.InvariantCultureIgnoreCase)) - { - var settings = new ClientSettingsForMigration - { - Host = GetConfigValue(config, "SabHost", "localhost"), - Port = GetConfigValue(config, "SabPort", 8080), - ApiKey = GetConfigValue(config, "SabApiKey", ""), - Username = GetConfigValue(config, "SabUsername", ""), - Password = GetConfigValue(config, "SabPassword", ""), - TvCategory = GetConfigValue(config, "SabTvCategory", "tv"), - RecentTvPriority = GetSabnzbdPriority(GetConfigValue(config, "NzbgetRecentTvPriority", "Default")), - OlderTvPriority = GetSabnzbdPriority(GetConfigValue(config, "NzbgetOlderTvPriority", "Default")), - UseSsl = GetConfigValue(config, "SabUseSsl", false) - }; - - AddDownloadClient(conn, tran, "Sabnzbd", "Sabnzbd", settings.ToJson(), "SabnzbdSettings", 1); - } - - else if (client.Equals("nzbget", StringComparison.InvariantCultureIgnoreCase)) - { - var settings = new ClientSettingsForMigration - { - Host = GetConfigValue(config, "NzbGetHost", "localhost"), - Port = GetConfigValue(config, "NzbgetPort", 6789), - Username = GetConfigValue(config, "NzbgetUsername", "nzbget"), - Password = GetConfigValue(config, "NzbgetPassword", ""), - TvCategory = GetConfigValue(config, "NzbgetTvCategory", "tv"), - RecentTvPriority = GetNzbgetPriority(GetConfigValue(config, "NzbgetRecentTvPriority", "Normal")), - OlderTvPriority = GetNzbgetPriority(GetConfigValue(config, "NzbgetOlderTvPriority", "Normal")), - }; - - AddDownloadClient(conn, tran, "Nzbget", "Nzbget", settings.ToJson(), "NzbgetSettings", 1); - } - - else if (client.Equals("pneumatic", StringComparison.InvariantCultureIgnoreCase)) - { - var settings = new FolderSettingsForMigration - { - Folder = GetConfigValue(config, "PneumaticFolder", "") - }; - - AddDownloadClient(conn, tran, "Pneumatic", "Pneumatic", settings.ToJson(), "FolderSettings", 1); - } - - else if (client.Equals("blackhole", StringComparison.InvariantCultureIgnoreCase)) - { - var settings = new FolderSettingsForMigration - { - Folder = GetConfigValue(config, "BlackholeFolder", "") - }; - - AddDownloadClient(conn, tran, "Blackhole", "Blackhole", settings.ToJson(), "FolderSettings", 1); - } - - DeleteOldConfigValues(conn, tran); - } - - private T GetConfigValue(Dictionary config, string key, T defaultValue) - { - key = key.ToLowerInvariant(); - - if (config.ContainsKey(key)) - { - return (T) Convert.ChangeType(config[key], typeof (T)); - } - - return defaultValue; - } - - private void AddDownloadClient(IDbConnection conn, IDbTransaction tran, string name, string implementation, string settings, - string configContract, int protocol) - { - using (IDbCommand updateCmd = conn.CreateCommand()) - { - var text = string.Format("INSERT INTO DownloadClients (Enable, Name, Implementation, Settings, ConfigContract, Protocol) VALUES (1, ?, ?, ?, ?, ?)"); - updateCmd.AddParameter(name); - updateCmd.AddParameter(implementation); - updateCmd.AddParameter(settings); - updateCmd.AddParameter(configContract); - updateCmd.AddParameter(protocol); - - updateCmd.Transaction = tran; - updateCmd.CommandText = text; - updateCmd.ExecuteNonQuery(); - } - } - - private void DeleteOldConfigValues(IDbConnection conn, IDbTransaction tran) - { - using (IDbCommand updateCmd = conn.CreateCommand()) - { - var text = "DELETE FROM Config WHERE [KEY] IN ('nzbgetusername', 'nzbgetpassword', 'nzbgethost', 'nzbgetport', " + - "'nzbgettvcategory', 'nzbgetrecenttvpriority', 'nzbgetoldertvpriority', 'sabhost', 'sabport', " + - "'sabapikey', 'sabusername', 'sabpassword', 'sabtvcategory', 'sabrecenttvpriority', " + - "'saboldertvpriority', 'sabusessl', 'downloadclient', 'blackholefolder', 'pneumaticfolder')"; - - updateCmd.Transaction = tran; - updateCmd.CommandText = text; - updateCmd.ExecuteNonQuery(); - } - } - - private int GetSabnzbdPriority(string priority) - { - return (int)Enum.Parse(typeof(SabnzbdPriorityForMigration), priority, true); - } - - private int GetNzbgetPriority(string priority) - { - return (int)Enum.Parse(typeof(NzbGetPriorityForMigration), priority, true); - } - - private class ClientSettingsForMigration - { - public string Host { get; set; } - public int Port { get; set; } - public string ApiKey { get; set; } - public string Username { get; set; } - public string Password { get; set; } - public string TvCategory { get; set; } - public int RecentTvPriority { get; set; } - public int OlderTvPriority { get; set; } - public bool UseSsl { get; set; } - } - - private class FolderSettingsForMigration - { - public string Folder { get; set; } - } - - private enum SabnzbdPriorityForMigration - { - Default = -100, - Paused = -2, - Low = -1, - Normal = 0, - High = 1, - Force = 2 - } - - private enum NzbGetPriorityForMigration - { - VeryLow = -100, - Low = -50, - Normal = 0, - High = 50, - VeryHigh = 100 - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/044_fix_xbmc_episode_metadata.cs b/src/NzbDrone.Core/Datastore/Migration/044_fix_xbmc_episode_metadata.cs deleted file mode 100644 index 0c645259b..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/044_fix_xbmc_episode_metadata.cs +++ /dev/null @@ -1,27 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(44)] - public class fix_xbmc_episode_metadata : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - //Convert Episode Metadata to proper type - Execute.Sql("UPDATE MetadataFiles " + - "SET Type = 2 " + - "WHERE Consumer = 'XbmcMetadata' " + - "AND EpisodeFileId IS NOT NULL " + - "AND Type = 4 " + - "AND RelativePath LIKE '%.nfo'"); - - //Convert Episode Images to proper type - Execute.Sql("UPDATE MetadataFiles " + - "SET Type = 5 " + - "WHERE Consumer = 'XbmcMetadata' " + - "AND EpisodeFileId IS NOT NULL " + - "AND Type = 4"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/045_add_indexes.cs b/src/NzbDrone.Core/Datastore/Migration/045_add_indexes.cs deleted file mode 100644 index 4ac4e0034..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/045_add_indexes.cs +++ /dev/null @@ -1,26 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(45)] - public class add_indexes : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Create.Index().OnTable("Blacklist").OnColumn("SeriesId"); - - Create.Index().OnTable("EpisodeFiles").OnColumn("SeriesId"); - - Create.Index().OnTable("Episodes").OnColumn("EpisodeFileId"); - Create.Index().OnTable("Episodes").OnColumn("SeriesId"); - - Create.Index().OnTable("History").OnColumn("EpisodeId"); - Create.Index().OnTable("History").OnColumn("Date"); - - Create.Index().OnTable("Series").OnColumn("Path"); - Create.Index().OnTable("Series").OnColumn("CleanTitle"); - Create.Index().OnTable("Series").OnColumn("TvRageId"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/046_fix_nzb_su_url.cs b/src/NzbDrone.Core/Datastore/Migration/046_fix_nzb_su_url.cs deleted file mode 100644 index 6d5496c0a..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/046_fix_nzb_su_url.cs +++ /dev/null @@ -1,16 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(46)] - public class fix_nzb_su_url : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Execute.Sql("UPDATE Indexers SET Settings = replace(Settings, '//nzb.su', '//api.nzb.su')" + - "WHERE Implementation = 'Newznab'" + - "AND Settings LIKE '%//nzb.su%'"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/047_add_published_date_blacklist_column.cs b/src/NzbDrone.Core/Datastore/Migration/047_add_published_date_blacklist_column.cs deleted file mode 100644 index a7bbc9b9b..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/047_add_published_date_blacklist_column.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(47)] - public class add_temporary_blacklist_columns : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("Blacklist").AddColumn("PublishedDate").AsDateTime().Nullable(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/048_add_title_to_scenemappings.cs b/src/NzbDrone.Core/Datastore/Migration/048_add_title_to_scenemappings.cs deleted file mode 100644 index 4a2e94bbf..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/048_add_title_to_scenemappings.cs +++ /dev/null @@ -1,14 +0,0 @@ -using NzbDrone.Core.Datastore.Migration.Framework; -using FluentMigrator; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(48)] - public class add_title_to_scenemappings : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("SceneMappings").AddColumn("Title").AsString().Nullable(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/049_fix_dognzb_url.cs b/src/NzbDrone.Core/Datastore/Migration/049_fix_dognzb_url.cs deleted file mode 100644 index ebbe8d8c0..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/049_fix_dognzb_url.cs +++ /dev/null @@ -1,16 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(49)] - public class fix_dognzb_url : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Execute.Sql("UPDATE Indexers SET Settings = replace(Settings, '//dognzb.cr', '//api.dognzb.cr')" + - "WHERE Implementation = 'Newznab'" + - "AND Settings LIKE '%//dognzb.cr%'"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/050_add_hash_to_metadata_files.cs b/src/NzbDrone.Core/Datastore/Migration/050_add_hash_to_metadata_files.cs deleted file mode 100644 index 8986a7fba..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/050_add_hash_to_metadata_files.cs +++ /dev/null @@ -1,14 +0,0 @@ -using NzbDrone.Core.Datastore.Migration.Framework; -using FluentMigrator; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(50)] - public class add_hash_to_metadata_files : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("MetadataFiles").AddColumn("Hash").AsString().Nullable(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/051_download_client_import.cs b/src/NzbDrone.Core/Datastore/Migration/051_download_client_import.cs deleted file mode 100644 index 549b92e59..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/051_download_client_import.cs +++ /dev/null @@ -1,243 +0,0 @@ -using System; -using System.Data; -using System.Linq; -using System.Collections.Generic; -using FluentMigrator; -using Newtonsoft.Json; -using NzbDrone.Common.Extensions; -using NzbDrone.Common.Serializer; -using NzbDrone.Core.Datastore.Migration.Framework; -using System.IO; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(51)] - public class download_client_import : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Execute.WithConnection(EnableCompletedDownloadHandlingForNewUsers); - - Execute.WithConnection(ConvertFolderSettings); - - Execute.WithConnection(AssociateImportedHistoryItems); - } - - private void EnableCompletedDownloadHandlingForNewUsers(IDbConnection conn, IDbTransaction tran) - { - using (IDbCommand cmd = conn.CreateCommand()) - { - cmd.Transaction = tran; - cmd.CommandText = @"SELECT Value FROM Config WHERE Key = 'downloadedepisodesfolder'"; - - var result = cmd.ExecuteScalar(); - - if (result == null) - { - cmd.CommandText = @"INSERT INTO Config (Key, Value) VALUES ('enablecompleteddownloadhandling', 'True')"; - cmd.ExecuteNonQuery(); - } - } - } - - private void ConvertFolderSettings(IDbConnection conn, IDbTransaction tran) - { - using (IDbCommand downloadClientsCmd = conn.CreateCommand()) - { - downloadClientsCmd.Transaction = tran; - downloadClientsCmd.CommandText = @"SELECT Value FROM Config WHERE Key = 'downloadedepisodesfolder'"; - var downloadedEpisodesFolder = downloadClientsCmd.ExecuteScalar() as string; - - downloadClientsCmd.Transaction = tran; - downloadClientsCmd.CommandText = @"SELECT Id, Implementation, Settings, ConfigContract FROM DownloadClients WHERE ConfigContract = 'FolderSettings'"; - using (IDataReader downloadClientReader = downloadClientsCmd.ExecuteReader()) - { - while (downloadClientReader.Read()) - { - var id = downloadClientReader.GetInt32(0); - var implementation = downloadClientReader.GetString(1); - var settings = downloadClientReader.GetString(2); - var configContract = downloadClientReader.GetString(3); - - var settingsJson = JsonConvert.DeserializeObject(settings) as Newtonsoft.Json.Linq.JObject; - - if (implementation == "Blackhole") - { - var newSettings = new - { - NzbFolder = settingsJson.Value("folder"), - WatchFolder = downloadedEpisodesFolder - }.ToJson(); - - using (IDbCommand updateCmd = conn.CreateCommand()) - { - updateCmd.Transaction = tran; - updateCmd.CommandText = "UPDATE DownloadClients SET Implementation = ?, Settings = ?, ConfigContract = ? WHERE Id = ?"; - updateCmd.AddParameter("UsenetBlackhole"); - updateCmd.AddParameter(newSettings); - updateCmd.AddParameter("UsenetBlackholeSettings"); - updateCmd.AddParameter(id); - - updateCmd.ExecuteNonQuery(); - } - } - else if (implementation == "Pneumatic") - { - var newSettings = new - { - NzbFolder = settingsJson.Value("folder") - }.ToJson(); - - using (IDbCommand updateCmd = conn.CreateCommand()) - { - updateCmd.Transaction = tran; - updateCmd.CommandText = "UPDATE DownloadClients SET Settings = ?, ConfigContract = ? WHERE Id = ?"; - updateCmd.AddParameter(newSettings); - updateCmd.AddParameter("PneumaticSettings"); - updateCmd.AddParameter(id); - - updateCmd.ExecuteNonQuery(); - } - } - else - { - using (IDbCommand updateCmd = conn.CreateCommand()) - { - updateCmd.Transaction = tran; - updateCmd.CommandText = "DELETE FROM DownloadClients WHERE Id = ?"; - updateCmd.AddParameter(id); - - updateCmd.ExecuteNonQuery(); - } - } - } - } - } - } - - private sealed class MigrationHistoryItem - { - public int Id { get; set; } - public int EpisodeId { get; set; } - public int SeriesId { get; set; } - public string SourceTitle { get; set; } - public DateTime Date { get; set; } - public Dictionary Data { get; set; } - public MigrationHistoryEventType EventType { get; set; } - } - - private enum MigrationHistoryEventType - { - Unknown = 0, - Grabbed = 1, - SeriesFolderImported = 2, - DownloadFolderImported = 3, - DownloadFailed = 4 - } - - private void AssociateImportedHistoryItems(IDbConnection conn, IDbTransaction tran) - { - var historyItems = new List(); - - using (IDbCommand historyCmd = conn.CreateCommand()) - { - historyCmd.Transaction = tran; - historyCmd.CommandText = @"SELECT Id, EpisodeId, SeriesId, SourceTitle, Date, Data, EventType FROM History WHERE EventType NOT NULL"; - using (IDataReader historyRead = historyCmd.ExecuteReader()) - { - while (historyRead.Read()) - { - historyItems.Add(new MigrationHistoryItem - { - Id = historyRead.GetInt32(0), - EpisodeId = historyRead.GetInt32(1), - SeriesId = historyRead.GetInt32(2), - SourceTitle = historyRead.GetString(3), - Date = historyRead.GetDateTime(4), - Data = Json.Deserialize>(historyRead.GetString(5)), - EventType = (MigrationHistoryEventType)historyRead.GetInt32(6) - }); - } - } - } - - var numHistoryItemsNotAssociated = historyItems.Count(v => v.EventType == MigrationHistoryEventType.DownloadFolderImported && - v.Data.GetValueOrDefault("downloadClientId") == null); - - if (numHistoryItemsNotAssociated == 0) - { - return; - } - - var historyItemsToAssociate = new Dictionary(); - - var historyItemsLookup = historyItems.ToLookup(v => v.EpisodeId); - - foreach (var historyItemGroup in historyItemsLookup) - { - var list = historyItemGroup.ToList(); - - for (int i = 0; i < list.Count - 1; i++) - { - var grabbedEvent = list[i]; - if (grabbedEvent.EventType != MigrationHistoryEventType.Grabbed) continue; - if (grabbedEvent.Data.GetValueOrDefault("downloadClient") == null || grabbedEvent.Data.GetValueOrDefault("downloadClientId") == null) continue; - - // Check if it is already associated with a failed/imported event. - int j; - for (j = i + 1; j < list.Count;j++) - { - if (list[j].EventType != MigrationHistoryEventType.DownloadFolderImported && - list[j].EventType != MigrationHistoryEventType.DownloadFailed) - { - continue; - } - - if (list[j].Data.ContainsKey("downloadClient") && list[j].Data["downloadClient"] == grabbedEvent.Data["downloadClient"] && - list[j].Data.ContainsKey("downloadClientId") && list[j].Data["downloadClientId"] == grabbedEvent.Data["downloadClientId"]) - { - break; - } - } - - if (j != list.Count) - { - list.RemoveAt(j); - list.RemoveAt(i--); - continue; - } - - var importedEvent = list[i + 1]; - if (importedEvent.EventType != MigrationHistoryEventType.DownloadFolderImported) continue; - - var droppedPath = importedEvent.Data.GetValueOrDefault("droppedPath"); - if (droppedPath != null && new FileInfo(droppedPath).Directory.Name == grabbedEvent.SourceTitle) - { - historyItemsToAssociate[importedEvent] = grabbedEvent; - - list.RemoveAt(i + 1); - list.RemoveAt(i--); - } - } - } - - foreach (var pair in historyItemsToAssociate) - { - using (IDbCommand updateHistoryCmd = conn.CreateCommand()) - { - pair.Key.Data["downloadClient"] = pair.Value.Data["downloadClient"]; - pair.Key.Data["downloadClientId"] = pair.Value.Data["downloadClientId"]; - - updateHistoryCmd.Transaction = tran; - updateHistoryCmd.CommandText = "UPDATE History SET Data = ? WHERE Id = ?"; - updateHistoryCmd.AddParameter(pair.Key.Data.ToJson()); - updateHistoryCmd.AddParameter(pair.Key.Id); - - updateHistoryCmd.ExecuteNonQuery(); - } - } - - _logger.Info("Updated old History items. {0}/{1} old ImportedEvents were associated with GrabbedEvents.", historyItemsToAssociate.Count, numHistoryItemsNotAssociated); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/052_add_columns_for_anime.cs b/src/NzbDrone.Core/Datastore/Migration/052_add_columns_for_anime.cs deleted file mode 100644 index e781ca010..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/052_add_columns_for_anime.cs +++ /dev/null @@ -1,20 +0,0 @@ -using NzbDrone.Core.Datastore.Migration.Framework; -using FluentMigrator; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(52)] - public class add_columns_for_anime : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - //Support XEM names - Alter.Table("SceneMappings").AddColumn("Type").AsString().Nullable(); - Execute.Sql("DELETE FROM SceneMappings"); - - //Add AnimeEpisodeFormat (set to Stardard Episode format for now) - Alter.Table("NamingConfig").AddColumn("AnimeEpisodeFormat").AsString().Nullable(); - Execute.Sql("UPDATE NamingConfig SET AnimeEpisodeFormat = StandardEpisodeFormat"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/053_add_series_sorttitle.cs b/src/NzbDrone.Core/Datastore/Migration/053_add_series_sorttitle.cs deleted file mode 100644 index 46e1b8ce3..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/053_add_series_sorttitle.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Data; -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(53)] - public class add_series_sorttitle : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Create.Column("SortTitle").OnTable("Series").AsString().Nullable(); - - Execute.WithConnection(SetSortTitles); - } - - private void SetSortTitles(IDbConnection conn, IDbTransaction tran) - { - using (IDbCommand getSeriesCmd = conn.CreateCommand()) - { - getSeriesCmd.Transaction = tran; - getSeriesCmd.CommandText = @"SELECT Id, Title FROM Series"; - using (IDataReader seriesReader = getSeriesCmd.ExecuteReader()) - { - while (seriesReader.Read()) - { - var id = seriesReader.GetInt32(0); - var title = seriesReader.GetString(1); - - var sortTitle = Parser.Parser.NormalizeTitle(title).ToLower(); - - using (IDbCommand updateCmd = conn.CreateCommand()) - { - updateCmd.Transaction = tran; - updateCmd.CommandText = "UPDATE Series SET SortTitle = ? WHERE Id = ?"; - updateCmd.AddParameter(sortTitle); - updateCmd.AddParameter(id); - - updateCmd.ExecuteNonQuery(); - } - } - } - } - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/054_rename_profiles.cs b/src/NzbDrone.Core/Datastore/Migration/054_rename_profiles.cs deleted file mode 100644 index e665c14a4..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/054_rename_profiles.cs +++ /dev/null @@ -1,31 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(54)] - public class rename_profiles : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Rename.Table("QualityProfiles").To("Profiles"); - - Alter.Table("Profiles").AddColumn("Language").AsInt32().Nullable(); - Alter.Table("Profiles").AddColumn("GrabDelay").AsInt32().Nullable(); - Alter.Table("Profiles").AddColumn("GrabDelayMode").AsInt32().Nullable(); - Execute.Sql("UPDATE Profiles SET Language = 1, GrabDelay = 0, GrabDelayMode = 0"); - - //Rename QualityProfileId in Series - Alter.Table("Series").AddColumn("ProfileId").AsInt32().Nullable(); - Execute.Sql("UPDATE Series SET ProfileId = QualityProfileId"); - - //Add HeldReleases - Create.TableForModel("PendingReleases") - .WithColumn("SeriesId").AsInt32() - .WithColumn("Title").AsString() - .WithColumn("Added").AsDateTime() - .WithColumn("ParsedEpisodeInfo").AsString() - .WithColumn("Release").AsString(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/055_drop_old_profile_columns.cs b/src/NzbDrone.Core/Datastore/Migration/055_drop_old_profile_columns.cs deleted file mode 100644 index 3f13f5e84..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/055_drop_old_profile_columns.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(55)] - public class drop_old_profile_columns : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Delete.Column("QualityProfileId").FromTable("Series"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/056_add_mediainfo_to_episodefile.cs b/src/NzbDrone.Core/Datastore/Migration/056_add_mediainfo_to_episodefile.cs deleted file mode 100644 index 42ad68493..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/056_add_mediainfo_to_episodefile.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(56)] - public class add_mediainfo_to_episodefile : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("EpisodeFiles").AddColumn("MediaInfo").AsString().Nullable(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/057_convert_episode_file_path_to_relative.cs b/src/NzbDrone.Core/Datastore/Migration/057_convert_episode_file_path_to_relative.cs deleted file mode 100644 index a1bf307fd..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/057_convert_episode_file_path_to_relative.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System.Data; -using System.IO; -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(57)] - public class convert_episode_file_path_to_relative : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Create.Column("RelativePath").OnTable("EpisodeFiles").AsString().Nullable(); - - //TODO: Add unique contraint for series ID and Relative Path - //TODO: Warn if multiple series share the same path - - Execute.WithConnection(UpdateRelativePaths); - } - - private void UpdateRelativePaths(IDbConnection conn, IDbTransaction tran) - { - using (IDbCommand getSeriesCmd = conn.CreateCommand()) - { - getSeriesCmd.Transaction = tran; - getSeriesCmd.CommandText = @"SELECT Id, Path FROM Series"; - using (IDataReader seriesReader = getSeriesCmd.ExecuteReader()) - { - while (seriesReader.Read()) - { - var seriesId = seriesReader.GetInt32(0); - var seriesPath = seriesReader.GetString(1) + Path.DirectorySeparatorChar; - - using (IDbCommand updateCmd = conn.CreateCommand()) - { - updateCmd.Transaction = tran; - updateCmd.CommandText = "UPDATE EpisodeFiles SET RelativePath = REPLACE(Path, ?, '') WHERE SeriesId = ?"; - updateCmd.AddParameter(seriesPath); - updateCmd.AddParameter(seriesId); - - updateCmd.ExecuteNonQuery(); - } - } - } - } - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/058_drop_epsiode_file_path.cs b/src/NzbDrone.Core/Datastore/Migration/058_drop_epsiode_file_path.cs deleted file mode 100644 index d2bfbcfd9..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/058_drop_epsiode_file_path.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(58)] - public class drop_episode_file_path : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Delete.Column("Path").FromTable("EpisodeFiles"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/059_add_enable_options_to_indexers.cs b/src/NzbDrone.Core/Datastore/Migration/059_add_enable_options_to_indexers.cs deleted file mode 100644 index 0905578c5..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/059_add_enable_options_to_indexers.cs +++ /dev/null @@ -1,19 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(59)] - public class add_enable_options_to_indexers : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("Indexers") - .AddColumn("EnableRss").AsBoolean().Nullable() - .AddColumn("EnableSearch").AsBoolean().Nullable(); - - Execute.Sql("UPDATE Indexers SET EnableRss = Enable, EnableSearch = Enable"); - Execute.Sql("UPDATE Indexers SET EnableSearch = 0 WHERE Implementation = 'Wombles'"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/060_remove_enable_from_indexers.cs b/src/NzbDrone.Core/Datastore/Migration/060_remove_enable_from_indexers.cs deleted file mode 100644 index 05376c1d2..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/060_remove_enable_from_indexers.cs +++ /dev/null @@ -1,15 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(60)] - public class remove_enable_from_indexers : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Delete.Column("Enable").FromTable("Indexers"); - Delete.Column("Protocol").FromTable("DownloadClients"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/061_clear_bad_scene_names.cs b/src/NzbDrone.Core/Datastore/Migration/061_clear_bad_scene_names.cs deleted file mode 100644 index 4bc2275dc..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/061_clear_bad_scene_names.cs +++ /dev/null @@ -1,22 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(61)] - public class clear_bad_scene_names : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Execute.Sql("UPDATE [EpisodeFiles] " + - "SET ReleaseGroup = NULL , SceneName = NULL " + - "WHERE " + - " ReleaseGroup IS NULL " + - " OR SceneName IS NULL " + - " OR ReleaseGroup =='DRONE' " + - " OR LENGTH(SceneName) <10 " + - " OR LENGTH(ReleaseGroup) > 20 " + - " OR SceneName NOT LIKE '%.%'"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/062_convert_quality_models.cs b/src/NzbDrone.Core/Datastore/Migration/062_convert_quality_models.cs deleted file mode 100644 index cc9cea68a..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/062_convert_quality_models.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Collections.Generic; -using System.Data; -using FluentMigrator; -using NzbDrone.Common.Serializer; -using NzbDrone.Core.Datastore.Migration.Framework; -using NzbDrone.Core.Qualities; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(62)] - public class convert_quality_models : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Execute.WithConnection(ConvertQualityModels); - } - - private void ConvertQualityModels(IDbConnection conn, IDbTransaction tran) - { - ConvertQualityModelsOnTable(conn, tran, "EpisodeFiles"); - ConvertQualityModelsOnTable(conn, tran, "Blacklist"); - ConvertQualityModelsOnTable(conn, tran, "History"); - } - - private void ConvertQualityModelsOnTable(IDbConnection conn, IDbTransaction tran, string tableName) - { - var qualitiesToUpdate = new Dictionary(); - - using (IDbCommand qualityModelCmd = conn.CreateCommand()) - { - qualityModelCmd.Transaction = tran; - qualityModelCmd.CommandText = @"SELECT Distinct Quality FROM " + tableName; - - using (IDataReader qualityModelReader = qualityModelCmd.ExecuteReader()) - { - while (qualityModelReader.Read()) - { - var qualityJson = qualityModelReader.GetString(0); - - LegacyQualityModel062 quality; - - if (!Json.TryDeserialize(qualityJson, out quality)) - { - continue; - } - - var newQualityModel = new QualityModel062 { Quality = quality.Quality, Revision = new Revision() }; - if (quality.Proper) - newQualityModel.Revision.Version = 2; - var newQualityJson = newQualityModel.ToJson(); - - qualitiesToUpdate.Add(qualityJson, newQualityJson); - } - } - } - - foreach (var quality in qualitiesToUpdate) - { - using (IDbCommand updateCmd = conn.CreateCommand()) - { - updateCmd.Transaction = tran; - updateCmd.CommandText = "UPDATE " + tableName + " SET Quality = ? WHERE Quality = ?"; - updateCmd.AddParameter(quality.Value); - updateCmd.AddParameter(quality.Key); - - updateCmd.ExecuteNonQuery(); - } - } - } - - private class LegacyQualityModel062 - { - public int Quality { get; set; } - public bool Proper { get; set; } - } - - private class QualityModel062 - { - public int Quality { get; set; } - public Revision Revision { get; set; } - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/063_add_remotepathmappings.cs b/src/NzbDrone.Core/Datastore/Migration/063_add_remotepathmappings.cs deleted file mode 100644 index 2f8c6b755..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/063_add_remotepathmappings.cs +++ /dev/null @@ -1,17 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(63)] - public class add_remotepathmappings : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Create.TableForModel("RemotePathMappings") - .WithColumn("Host").AsString() - .WithColumn("RemotePath").AsString() - .WithColumn("LocalPath").AsString(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/064_add_remove_method_from_logs.cs b/src/NzbDrone.Core/Datastore/Migration/064_add_remove_method_from_logs.cs deleted file mode 100644 index 2fd04ea97..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/064_add_remove_method_from_logs.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(64)] - public class remove_method_from_logs : NzbDroneMigrationBase - { - protected override void LogDbUpgrade() - { - Delete.Column("Method").FromTable("Logs"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/065_make_scene_numbering_nullable.cs b/src/NzbDrone.Core/Datastore/Migration/065_make_scene_numbering_nullable.cs deleted file mode 100644 index 7936f04dd..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/065_make_scene_numbering_nullable.cs +++ /dev/null @@ -1,16 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(65)] - public class make_scene_numbering_nullable : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Execute.Sql("UPDATE Episodes SET AbsoluteEpisodeNumber = NULL WHERE AbsoluteEpisodeNumber = 0"); - Execute.Sql("UPDATE Episodes SET SceneAbsoluteEpisodeNumber = NULL WHERE SceneAbsoluteEpisodeNumber = 0"); - Execute.Sql("UPDATE Episodes SET SceneSeasonNumber = NULL, SceneEpisodeNumber = NULL WHERE SceneSeasonNumber = 0 AND SceneEpisodeNumber = 0"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/066_add_tags.cs b/src/NzbDrone.Core/Datastore/Migration/066_add_tags.cs deleted file mode 100644 index 7a0c09838..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/066_add_tags.cs +++ /dev/null @@ -1,24 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(66)] - public class add_tags : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Create.TableForModel("Tags") - .WithColumn("Label").AsString().NotNullable(); - - Alter.Table("Series") - .AddColumn("Tags").AsString().Nullable(); - - Alter.Table("Notifications") - .AddColumn("Tags").AsString().Nullable(); - - Execute.Sql("UPDATE Series SET Tags = '[]'"); - Execute.Sql("UPDATE Notifications SET Tags = '[]'"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/067_add_added_to_series.cs b/src/NzbDrone.Core/Datastore/Migration/067_add_added_to_series.cs deleted file mode 100644 index cb0923e18..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/067_add_added_to_series.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(67)] - public class add_added_to_series : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("Series").AddColumn("Added").AsDateTime().Nullable(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/068_add_release_restrictions.cs b/src/NzbDrone.Core/Datastore/Migration/068_add_release_restrictions.cs deleted file mode 100644 index a7cc93e0c..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/068_add_release_restrictions.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Data; -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(68)] - public class add_release_restrictions : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Create.TableForModel("Restrictions") - .WithColumn("Required").AsString().Nullable() - .WithColumn("Preferred").AsString().Nullable() - .WithColumn("Ignored").AsString().Nullable() - .WithColumn("Tags").AsString().NotNullable(); - - Execute.WithConnection(ConvertRestrictions); - Execute.Sql("DELETE FROM Config WHERE [Key] = 'releaserestrictions'"); - } - - private void ConvertRestrictions(IDbConnection conn, IDbTransaction tran) - { - using (IDbCommand getRestictionsCmd = conn.CreateCommand()) - { - getRestictionsCmd.Transaction = tran; - getRestictionsCmd.CommandText = @"SELECT [Value] FROM Config WHERE [Key] = 'releaserestrictions'"; - - using (IDataReader configReader = getRestictionsCmd.ExecuteReader()) - { - while (configReader.Read()) - { - var restrictions = configReader.GetString(0); - restrictions = restrictions.Replace("\n", ","); - - using (IDbCommand insertCmd = conn.CreateCommand()) - { - insertCmd.Transaction = tran; - insertCmd.CommandText = "INSERT INTO Restrictions (Ignored, Tags) VALUES (?, '[]')"; - insertCmd.AddParameter(restrictions); - - insertCmd.ExecuteNonQuery(); - } - } - } - } - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/069_quality_proper.cs b/src/NzbDrone.Core/Datastore/Migration/069_quality_proper.cs deleted file mode 100644 index 9db5f2955..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/069_quality_proper.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System.Data; -using System.Linq; -using System.Text.RegularExpressions; -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(69)] - public class quality_proper : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Execute.WithConnection(ConvertQualityTitle); - } - - private static readonly Regex QualityTitleRegex = new Regex(@"\{(?[- ._\[(]*)(?(?:quality)(?:(?[- ._]+)(?:title))?)(?[- ._)\]]*)\}", - RegexOptions.Compiled | RegexOptions.IgnoreCase); - - private void ConvertQualityTitle(IDbConnection conn, IDbTransaction tran) - { - using (IDbCommand namingConfigCmd = conn.CreateCommand()) - { - namingConfigCmd.Transaction = tran; - namingConfigCmd.CommandText = @"SELECT StandardEpisodeFormat, DailyEpisodeFormat, AnimeEpisodeFormat FROM NamingConfig LIMIT 1"; - - using (IDataReader configReader = namingConfigCmd.ExecuteReader()) - { - while (configReader.Read()) - { - var currentStandard = configReader.GetString(0); - var currentDaily = configReader.GetString(1); - var currentAnime = configReader.GetString(2); - - var newStandard = GetNewFormat(currentStandard); - var newDaily = GetNewFormat(currentDaily); - var newAnime = GetNewFormat(currentAnime); - - using (IDbCommand updateCmd = conn.CreateCommand()) - { - updateCmd.Transaction = tran; - - updateCmd.CommandText = "UPDATE NamingConfig SET StandardEpisodeFormat = ?, DailyEpisodeFormat = ?, AnimeEpisodeFormat = ?"; - updateCmd.AddParameter(newStandard); - updateCmd.AddParameter(newDaily); - updateCmd.AddParameter(newAnime); - - updateCmd.ExecuteNonQuery(); - } - } - } - } - } - - private string GetNewFormat(string currentFormat) - { - var matches = QualityTitleRegex.Matches(currentFormat); - var result = currentFormat; - - foreach (Match match in matches) - { - var tokenMatch = GetTokenMatch(match); - var qualityFullToken = string.Format("Quality{0}Full", tokenMatch.Separator); ; - - if (tokenMatch.Token.All(t => !char.IsLetter(t) || char.IsLower(t))) - { - qualityFullToken = string.Format("quality{0}full", tokenMatch.Separator); - } - else if (tokenMatch.Token.All(t => !char.IsLetter(t) || char.IsUpper(t))) - { - qualityFullToken = string.Format("QUALITY{0}FULL", tokenMatch.Separator); - } - - result = result.Replace(match.Groups["token"].Value, qualityFullToken); - } - - return result; - } - - private TokenMatch69 GetTokenMatch(Match match) - { - return new TokenMatch69 - { - Prefix = match.Groups["prefix"].Value, - Token = match.Groups["token"].Value, - Separator = match.Groups["separator"].Value, - Suffix = match.Groups["suffix"].Value, - }; - } - - private class TokenMatch69 - { - public string Prefix { get; set; } - public string Token { get; set; } - public string Separator { get; set; } - public string Suffix { get; set; } - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/070_delay_profile.cs b/src/NzbDrone.Core/Datastore/Migration/070_delay_profile.cs deleted file mode 100644 index 1c9c7e58b..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/070_delay_profile.cs +++ /dev/null @@ -1,182 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Data; -using System.Linq; -using FluentMigrator; -using NzbDrone.Common.Extensions; -using NzbDrone.Common.Serializer; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(70)] - public class delay_profile : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Create.TableForModel("DelayProfiles") - .WithColumn("EnableUsenet").AsBoolean().NotNullable() - .WithColumn("EnableTorrent").AsBoolean().NotNullable() - .WithColumn("PreferredProtocol").AsInt32().NotNullable() - .WithColumn("UsenetDelay").AsInt32().NotNullable() - .WithColumn("TorrentDelay").AsInt32().NotNullable() - .WithColumn("Order").AsInt32().NotNullable() - .WithColumn("Tags").AsString().NotNullable(); - - Insert.IntoTable("DelayProfiles").Row(new - { - EnableUsenet = 1, - EnableTorrent = 1, - PreferredProtocol = 1, - UsenetDelay = 0, - TorrentDelay = 0, - Order = int.MaxValue, - Tags = "[]" - }); - - Execute.WithConnection(ConvertProfile); - - Delete.Column("GrabDelay").FromTable("Profiles"); - Delete.Column("GrabDelayMode").FromTable("Profiles"); - } - - private void ConvertProfile(IDbConnection conn, IDbTransaction tran) - { - var profiles = GetProfiles(conn, tran); - var order = 1; - - foreach (var profileClosure in profiles.DistinctBy(p => p.GrabDelay)) - { - var profile = profileClosure; - if (profile.GrabDelay == 0) continue; - - var tag = string.Format("delay-{0}", profile.GrabDelay); - var tagId = InsertTag(conn, tran, tag); - var tags = string.Format("[{0}]", tagId); - - using (IDbCommand insertDelayProfileCmd = conn.CreateCommand()) - { - insertDelayProfileCmd.Transaction = tran; - insertDelayProfileCmd.CommandText = "INSERT INTO DelayProfiles (EnableUsenet, EnableTorrent, PreferredProtocol, TorrentDelay, UsenetDelay, [Order], Tags) VALUES (1, 1, 1, 0, ?, ?, ?)"; - insertDelayProfileCmd.AddParameter(profile.GrabDelay); - insertDelayProfileCmd.AddParameter(order); - insertDelayProfileCmd.AddParameter(tags); - - insertDelayProfileCmd.ExecuteNonQuery(); - } - - var matchingProfileIds = profiles.Where(p => p.GrabDelay == profile.GrabDelay) - .Select(p => p.Id); - - UpdateSeries(conn, tran, matchingProfileIds, tagId); - - order++; - } - } - - private List GetProfiles(IDbConnection conn, IDbTransaction tran) - { - var profiles = new List(); - - using (IDbCommand getProfilesCmd = conn.CreateCommand()) - { - getProfilesCmd.Transaction = tran; - getProfilesCmd.CommandText = @"SELECT Id, GrabDelay FROM Profiles"; - - using (IDataReader profileReader = getProfilesCmd.ExecuteReader()) - { - while (profileReader.Read()) - { - var id = profileReader.GetInt32(0); - var delay = profileReader.GetInt32(1); - - profiles.Add(new Profile69 - { - Id = id, - GrabDelay = delay * 60 - }); - } - } - } - - return profiles; - } - - private int InsertTag(IDbConnection conn, IDbTransaction tran, string tagLabel) - { - using (IDbCommand insertCmd = conn.CreateCommand()) - { - insertCmd.Transaction = tran; - insertCmd.CommandText = @"INSERT INTO Tags (Label) VALUES (?); SELECT last_insert_rowid()"; - insertCmd.AddParameter(tagLabel); - - var id = insertCmd.ExecuteScalar(); - - return Convert.ToInt32(id); - } - } - - private void UpdateSeries(IDbConnection conn, IDbTransaction tran, IEnumerable profileIds, int tagId) - { - using (IDbCommand getSeriesCmd = conn.CreateCommand()) - { - getSeriesCmd.Transaction = tran; - getSeriesCmd.CommandText = "SELECT Id, Tags FROM Series WHERE ProfileId IN (?)"; - getSeriesCmd.AddParameter(string.Join(",", profileIds)); - - using (IDataReader seriesReader = getSeriesCmd.ExecuteReader()) - { - while (seriesReader.Read()) - { - var id = seriesReader.GetInt32(0); - var tagString = seriesReader.GetString(1); - - var tags = Json.Deserialize>(tagString); - tags.Add(tagId); - - using (IDbCommand updateSeriesCmd = conn.CreateCommand()) - { - updateSeriesCmd.Transaction = tran; - updateSeriesCmd.CommandText = "UPDATE Series SET Tags = ? WHERE Id = ?"; - updateSeriesCmd.AddParameter(tags.ToJson()); - updateSeriesCmd.AddParameter(id); - - updateSeriesCmd.ExecuteNonQuery(); - } - } - } - } - } - } - - public class Profile69 - { - public int Id { get; set; } - public int GrabDelay { get; set; } - } - - public class Series69 - { - public int Id { get; set; } - public List Tags { get; set; } - public DateTime? LastInfoSync { get; set; } - } - - public class Tag69 - { - public int Id { get; set; } - public string Label { get; set; } - } - - public class DelayProfile70 - { - public int Id { get; set; } - public bool EnableUsenet { get; set; } - public bool EnableTorrent { get; set; } - public int PreferredProtocol { get; set; } - public int UsenetDelay { get; set; } - public int TorrentDelay { get; set; } - public int Order { get; set; } - public List Tags { get; set; } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/071_unknown_quality_in_profile.cs b/src/NzbDrone.Core/Datastore/Migration/071_unknown_quality_in_profile.cs deleted file mode 100644 index a033e8410..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/071_unknown_quality_in_profile.cs +++ /dev/null @@ -1,180 +0,0 @@ -using System.Collections.Generic; -using System.Data; -using System.Linq; -using FluentMigrator; -using NzbDrone.Common.Serializer; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(71)] - public class unknown_quality_in_profile : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Delete.Column("Weight").FromTable("QualityDefinitions"); - - Execute.WithConnection(ConvertProfile); - } - - private void ConvertProfile(IDbConnection conn, IDbTransaction tran) - { - var updater = new ProfileUpdater70(conn, tran); - updater.PrependQuality(0); - updater.Commit(); - } - } - public class Profile70 - { - public int Id { get; set; } - public string Name { get; set; } - public int Cutoff { get; set; } - public List Items { get; set; } - public int Language { get; set; } - } - - public class ProfileItem70 - { - public int Quality { get; set; } - public bool Allowed { get; set; } - } - - public class ProfileUpdater70 - { - private readonly IDbConnection _connection; - private readonly IDbTransaction _transaction; - - private List _profiles; - private HashSet _changedProfiles = new HashSet(); - - public ProfileUpdater70(IDbConnection conn, IDbTransaction tran) - { - _connection = conn; - _transaction = tran; - - _profiles = GetProfiles(); - } - - public void Commit() - { - foreach (var profile in _changedProfiles) - { - using (var updateProfileCmd = _connection.CreateCommand()) - { - updateProfileCmd.Transaction = _transaction; - updateProfileCmd.CommandText = "UPDATE Profiles SET Name = ?, Cutoff = ?, Items = ?, Language = ? WHERE Id = ?"; - updateProfileCmd.AddParameter(profile.Name); - updateProfileCmd.AddParameter(profile.Cutoff); - updateProfileCmd.AddParameter(profile.Items.ToJson()); - updateProfileCmd.AddParameter(profile.Language); - updateProfileCmd.AddParameter(profile.Id); - - updateProfileCmd.ExecuteNonQuery(); - } - } - - _changedProfiles.Clear(); - } - - public void PrependQuality(int quality) - { - foreach (var profile in _profiles) - { - if (profile.Items.Any(v => v.Quality == quality)) continue; - - profile.Items.Insert(0, new ProfileItem70 - { - Quality = quality, - Allowed = false - }); - - _changedProfiles.Add(profile); - } - } - - public void AppendQuality(int quality) - { - foreach (var profile in _profiles) - { - if (profile.Items.Any(v => v.Quality == quality)) continue; - - profile.Items.Add(new ProfileItem70 - { - Quality = quality, - Allowed = false - }); - - _changedProfiles.Add(profile); - } - } - - public void SplitQualityPrepend(int find, int quality) - { - foreach (var profile in _profiles) - { - if (profile.Items.Any(v => v.Quality == quality)) continue; - - var findIndex = profile.Items.FindIndex(v => v.Quality == find); - - profile.Items.Insert(findIndex, new ProfileItem70 - { - Quality = quality, - Allowed = profile.Items[findIndex].Allowed - }); - - if (profile.Cutoff == find) - { - profile.Cutoff = quality; - } - - _changedProfiles.Add(profile); - } - } - - public void SplitQualityAppend(int find, int quality) - { - foreach (var profile in _profiles) - { - if (profile.Items.Any(v => v.Quality == quality)) continue; - - var findIndex = profile.Items.FindIndex(v => v.Quality == find); - - profile.Items.Insert(findIndex + 1, new ProfileItem70 - { - Quality = quality, - Allowed = false - }); - - _changedProfiles.Add(profile); - } - } - - private List GetProfiles() - { - var profiles = new List(); - - using (var getProfilesCmd = _connection.CreateCommand()) - { - getProfilesCmd.Transaction = _transaction; - getProfilesCmd.CommandText = @"SELECT Id, Name, Cutoff, Items, Language FROM Profiles"; - - using (var profileReader = getProfilesCmd.ExecuteReader()) - { - while (profileReader.Read()) - { - profiles.Add(new Profile70 - { - Id = profileReader.GetInt32(0), - Name = profileReader.GetString(1), - Cutoff = profileReader.GetInt32(2), - Items = Json.Deserialize>(profileReader.GetString(3)), - Language = profileReader.GetInt32(4) - }); - } - } - } - - return profiles; - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/072_history_grabid.cs b/src/NzbDrone.Core/Datastore/Migration/072_history_grabid.cs deleted file mode 100644 index 23523808f..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/072_history_grabid.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Data; -using FluentMigrator; -using NzbDrone.Common.Serializer; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(72)] - public class history_downloadId : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("History") - .AddColumn("DownloadId").AsString() - .Nullable() - .Indexed(); - - Execute.WithConnection(MoveToColumn); - } - - private void MoveToColumn(IDbConnection conn, IDbTransaction tran) - { - using (IDbCommand getHistory = conn.CreateCommand()) - { - getHistory.Transaction = tran; - getHistory.CommandText = @"SELECT Id, Data FROM History WHERE Data LIKE '%downloadClientId%'"; - - using (var historyReader = getHistory.ExecuteReader()) - { - while (historyReader.Read()) - { - var id = historyReader.GetInt32(0); - var data = historyReader.GetString(1); - - UpdateHistory(tran, conn, id, data); - } - } - } - } - - private void UpdateHistory(IDbTransaction tran, IDbConnection conn, int id, string data) - { - var dic = Json.Deserialize>(data); - - var downloadId = dic["downloadClientId"]; - dic.Remove("downloadClientId"); - - using (var updateHistoryCmd = conn.CreateCommand()) - { - updateHistoryCmd.Transaction = tran; - updateHistoryCmd.CommandText = @"UPDATE History SET DownloadId = ?, Data = ? WHERE Id = ?"; - - updateHistoryCmd.AddParameter(downloadId); - updateHistoryCmd.AddParameter(dic.ToJson()); - updateHistoryCmd.AddParameter(id); - - updateHistoryCmd.ExecuteNonQuery(); - - } - } - } - - public class History72 - { - public int EpisodeId { get; set; } - public int SeriesId { get; set; } - public string SourceTitle { get; set; } - public string Quality { get; set; } - public DateTime Date { get; set; } - public int EventType { get; set; } - public Dictionary Data { get; set; } - - public string DownloadId { get; set; } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/073_clear_ratings.cs b/src/NzbDrone.Core/Datastore/Migration/073_clear_ratings.cs deleted file mode 100644 index ef9c4074f..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/073_clear_ratings.cs +++ /dev/null @@ -1,20 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(73)] - public class clear_ratings : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Update.Table("Series") - .Set(new {Ratings = "{}"}) - .AllRows(); - - Update.Table("Episodes") - .Set(new { Ratings = "{}" }) - .AllRows(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/074_disable_eztv.cs b/src/NzbDrone.Core/Datastore/Migration/074_disable_eztv.cs deleted file mode 100644 index c090df19b..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/074_disable_eztv.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(74)] - public class disable_eztv : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Execute.Sql("UPDATE Indexers SET EnableRss = 0, EnableSearch = 0 WHERE Implementation = 'Eztv' AND Settings LIKE '%ezrss.it%'"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/075_force_lib_update.cs b/src/NzbDrone.Core/Datastore/Migration/075_force_lib_update.cs deleted file mode 100644 index 5a9336f64..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/075_force_lib_update.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(75)] - public class force_lib_update : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Update.Table("ScheduledTasks") - .Set(new { LastExecution = "2014-01-01 00:00:00" }) - .Where(new { TypeName = "NzbDrone.Core.Tv.Commands.RefreshSeriesCommand" }); - - Update.Table("Series") - .Set(new { LastInfoSync = "2014-01-01 00:00:00" }) - .AllRows(); - } - } - - public class ScheduledTasks75 - { - public int Id { get; set; } - public string TypeName { get; set; } - public int Interval { get; set; } - public DateTime LastExecution { get; set; } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/076_add_users_table.cs b/src/NzbDrone.Core/Datastore/Migration/076_add_users_table.cs deleted file mode 100644 index 7933d90d4..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/076_add_users_table.cs +++ /dev/null @@ -1,17 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(76)] - public class add_users_table : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Create.TableForModel("Users") - .WithColumn("Identifier").AsString().NotNullable().Unique() - .WithColumn("Username").AsString().NotNullable().Unique() - .WithColumn("Password").AsString().NotNullable(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/077_add_add_options_to_series.cs b/src/NzbDrone.Core/Datastore/Migration/077_add_add_options_to_series.cs deleted file mode 100644 index 5c4891e5c..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/077_add_add_options_to_series.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(77)] - public class add_add_options_to_series : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("Series").AddColumn("AddOptions").AsString().Nullable(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/078_add_commands_table.cs b/src/NzbDrone.Core/Datastore/Migration/078_add_commands_table.cs deleted file mode 100644 index 5a3d93716..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/078_add_commands_table.cs +++ /dev/null @@ -1,24 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(78)] - public class add_commands_table : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Create.TableForModel("Commands") - .WithColumn("Name").AsString().NotNullable() - .WithColumn("Body").AsString().NotNullable() - .WithColumn("Priority").AsInt32().NotNullable() - .WithColumn("Status").AsInt32().NotNullable() - .WithColumn("QueuedAt").AsDateTime().NotNullable() - .WithColumn("StartedAt").AsDateTime().Nullable() - .WithColumn("EndedAt").AsDateTime().Nullable() - .WithColumn("Duration").AsString().Nullable() - .WithColumn("Exception").AsString().Nullable() - .WithColumn("Trigger").AsInt32().NotNullable(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/079_dedupe_tags.cs b/src/NzbDrone.Core/Datastore/Migration/079_dedupe_tags.cs deleted file mode 100644 index b786747a2..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/079_dedupe_tags.cs +++ /dev/null @@ -1,157 +0,0 @@ -using System.Collections.Generic; -using System.Data; -using System.Linq; -using FluentMigrator; -using NzbDrone.Common.Extensions; -using NzbDrone.Common.Serializer; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(79)] - public class dedupe_tags : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Execute.WithConnection(CleanupTags); - - Alter.Table("Tags").AlterColumn("Label").AsString().Unique(); - } - - private void CleanupTags(IDbConnection conn, IDbTransaction tran) - { - var tags = GetTags(conn, tran); - var grouped = tags.GroupBy(t => t.Label.ToLowerInvariant()); - var replacements = new List(); - - foreach (var group in grouped.Where(g => g.Count() > 1)) - { - var first = group.First().Id; - - foreach (var other in group.Skip(1).Select(t => t.Id)) - { - replacements.Add(new TagReplacement079 { OldId = other, NewId = first }); - } - } - - UpdateTaggedModel(conn, tran, "Series", replacements); - UpdateTaggedModel(conn, tran, "Notifications", replacements); - UpdateTaggedModel(conn, tran, "DelayProfiles", replacements); - UpdateTaggedModel(conn, tran, "Restrictions", replacements); - - DeleteTags(conn, tran, replacements); - } - - private List GetTags(IDbConnection conn, IDbTransaction tran) - { - var tags = new List(); - - using (IDbCommand tagCmd = conn.CreateCommand()) - { - tagCmd.Transaction = tran; - tagCmd.CommandText = @"SELECT Id, Label FROM Tags"; - - using (IDataReader tagReader = tagCmd.ExecuteReader()) - { - while (tagReader.Read()) - { - var id = tagReader.GetInt32(0); - var label = tagReader.GetString(1); - - tags.Add(new Tag079 { Id = id, Label = label }); - } - } - } - - return tags; - } - - private void UpdateTaggedModel(IDbConnection conn, IDbTransaction tran, string table, List replacements) - { - var tagged = new List(); - - using (IDbCommand tagCmd = conn.CreateCommand()) - { - tagCmd.Transaction = tran; - tagCmd.CommandText = string.Format("SELECT Id, Tags FROM {0}", table); - - using (IDataReader tagReader = tagCmd.ExecuteReader()) - { - while (tagReader.Read()) - { - if (!tagReader.IsDBNull(1)) - { - var id = tagReader.GetInt32(0); - var tags = tagReader.GetString(1); - - tagged.Add(new TaggedModel079 - { - Id = id, - Tags = Json.Deserialize>(tags) - }); - } - } - } - } - - var toUpdate = new List(); - - foreach (var model in tagged) - { - foreach (var replacement in replacements) - { - if (model.Tags.Contains(replacement.OldId)) - { - model.Tags.Remove(replacement.OldId); - model.Tags.Add(replacement.NewId); - - toUpdate.Add(model); - } - } - } - - foreach (var model in toUpdate.DistinctBy(m => m.Id)) - { - using (IDbCommand updateCmd = conn.CreateCommand()) - { - updateCmd.Transaction = tran; - updateCmd.CommandText = string.Format(@"UPDATE {0} SET Tags = ? WHERE Id = ?", table); - updateCmd.AddParameter(model.Tags.ToJson()); - updateCmd.AddParameter(model.Id); - - updateCmd.ExecuteNonQuery(); - } - } - } - - private void DeleteTags(IDbConnection conn, IDbTransaction tran, List replacements) - { - var idsToRemove = replacements.Select(r => r.OldId).Distinct(); - - using (IDbCommand removeCmd = conn.CreateCommand()) - { - removeCmd.Transaction = tran; - removeCmd.CommandText = string.Format("DELETE FROM Tags WHERE Id IN ({0})", string.Join(",", idsToRemove)); - removeCmd.ExecuteNonQuery(); - } - } - - private class Tag079 - { - public int Id { get; set; } - public string Label { get; set; } - } - - private class TagReplacement079 - { - public int OldId { get; set; } - public int NewId { get; set; } - } - - private class TaggedModel079 - { - public int Id { get; set; } - public HashSet Tags { get; set; } - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/081_move_dot_prefix_to_transmission_category.cs b/src/NzbDrone.Core/Datastore/Migration/081_move_dot_prefix_to_transmission_category.cs deleted file mode 100644 index c48fd75a8..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/081_move_dot_prefix_to_transmission_category.cs +++ /dev/null @@ -1,92 +0,0 @@ -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(81)] - public class move_dot_prefix_to_transmission_category : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Execute.WithConnection(UpdateTransmissionSettings); - } - - private void UpdateTransmissionSettings(IDbConnection conn, IDbTransaction tran) - { - using (var cmd = conn.CreateCommand()) - { - cmd.Transaction = tran; - cmd.CommandText = "SELECT Id, Settings FROM DownloadClients WHERE Implementation = 'Transmission'"; - - using (var reader = cmd.ExecuteReader()) - { - while (reader.Read()) - { - var id = reader.GetInt32(0); - var settingsJson = reader.GetString(1); - - var settings = Json.Deserialize>(settingsJson); - - var tvCategory = settings.GetValueOrDefault("tvCategory") as string; - if (tvCategory.IsNotNullOrWhiteSpace()) - { - settings["tvCategory"] = "." + tvCategory; - - using (var updateCmd = conn.CreateCommand()) - { - updateCmd.Transaction = tran; - updateCmd.CommandText = "UPDATE DownloadClients SET Settings = ? WHERE Id = ?"; - updateCmd.AddParameter(settings.ToJson()); - updateCmd.AddParameter(id); - - updateCmd.ExecuteNonQuery(); - } - } - } - } - } - } - } - - public class DownloadClientDefinition81 - { - public int Id { get; set; } - public bool Enable { get; set; } - public string Name { get; set; } - public string Implementation { get; set; } - public JObject Settings { get; set; } - public string ConfigContract { get; set; } - } - - public class SabnzbdSettings81 - { - public string Host { get; set; } - public int Port { get; set; } - public string ApiKey { get; set; } - public string Username { get; set; } - public string Password { get; set; } - public string TvCategory { get; set; } - public int RecentTvPriority { get; set; } - public int OlderTvPriority { get; set; } - public bool UseSsl { get; set; } - } - - public class TransmissionSettings81 - { - public string Host { get; set; } - public int Port { get; set; } - public string UrlBase { get; set; } - public string Username { get; set; } - public string Password { get; set; } - public string TvCategory { get; set; } - public string TvDirectory { get; set; } - public int RecentTvPriority { get; set; } - public int OlderTvPriority { get; set; } - public bool UseSsl { get; set; } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/082_add_fanzub_settings.cs b/src/NzbDrone.Core/Datastore/Migration/082_add_fanzub_settings.cs deleted file mode 100644 index 43d332224..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/082_add_fanzub_settings.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(82)] - public class add_fanzub_settings : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Execute.Sql("UPDATE Indexers SET ConfigContract = 'FanzubSettings' WHERE Implementation = 'Fanzub' AND ConfigContract = 'NullConfig'"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/083_additonal_blacklist_columns.cs b/src/NzbDrone.Core/Datastore/Migration/083_additonal_blacklist_columns.cs deleted file mode 100644 index 93fa2fd51..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/083_additonal_blacklist_columns.cs +++ /dev/null @@ -1,22 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(83)] - public class additonal_blacklist_columns : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("Blacklist").AddColumn("Size").AsInt64().Nullable(); - Alter.Table("Blacklist").AddColumn("Protocol").AsInt32().Nullable(); - Alter.Table("Blacklist").AddColumn("Indexer").AsString().Nullable(); - Alter.Table("Blacklist").AddColumn("Message").AsString().Nullable(); - Alter.Table("Blacklist").AddColumn("TorrentInfoHash").AsString().Nullable(); - - Update.Table("Blacklist") - .Set(new { Protocol = 1 }) - .AllRows(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/084_update_quality_minmax_size.cs b/src/NzbDrone.Core/Datastore/Migration/084_update_quality_minmax_size.cs deleted file mode 100644 index 03a0a8ea3..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/084_update_quality_minmax_size.cs +++ /dev/null @@ -1,26 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(84)] - public class update_quality_minmax_size : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("QualityDefinitions").AlterColumn("MinSize").AsDouble().Nullable(); - Alter.Table("QualityDefinitions").AlterColumn("MaxSize").AsDouble().Nullable(); - - Execute.Sql("UPDATE QualityDefinitions SET MaxSize = NULL WHERE Quality = 10 OR MaxSize = 0"); - } - } - - public class QualityDefinition84 - { - public int Id { get; set; } - public int Quality { get; set; } - public string Title { get; set; } - public int? MinSize { get; set; } - public int? MaxSize { get; set; } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/085_expand_transmission_urlbase.cs b/src/NzbDrone.Core/Datastore/Migration/085_expand_transmission_urlbase.cs deleted file mode 100644 index 956f87bcd..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/085_expand_transmission_urlbase.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System.Collections.Generic; -using System.Data; -using FluentMigrator; -using NzbDrone.Common.Extensions; -using NzbDrone.Common.Serializer; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(85)] - public class expand_transmission_urlbase : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Execute.WithConnection(UpdateTransmissionSettings); - } - - private void UpdateTransmissionSettings(IDbConnection conn, IDbTransaction tran) - { - using (var cmd = conn.CreateCommand()) - { - cmd.Transaction = tran; - cmd.CommandText = "SELECT Id, Settings FROM DownloadClients WHERE Implementation = 'Transmission'"; - - using (var reader = cmd.ExecuteReader()) - { - while (reader.Read()) - { - var id = reader.GetInt32(0); - var settingsJson = reader.GetString(1); - - var settings = Json.Deserialize>(settingsJson); - - var urlBase = settings.GetValueOrDefault("urlBase", "") as string; - - if (urlBase.IsNullOrWhiteSpace()) - { - settings["urlBase"] = "/transmission/"; - } - else - { - settings["urlBase"] = string.Format("/{0}/transmission/", urlBase.Trim('/')); - } - - using (var updateCmd = conn.CreateCommand()) - { - updateCmd.Transaction = tran; - updateCmd.CommandText = "UPDATE DownloadClients SET Settings = ? WHERE Id = ?"; - updateCmd.AddParameter(settings.ToJson()); - updateCmd.AddParameter(id); - - updateCmd.ExecuteNonQuery(); - } - } - } - } - } - } - - public class DelugeSettings85 - { - public string Host { get; set; } - public int Port { get; set; } - public string UrlBase { get; set; } - public string Password { get; set; } - public string TvCategory { get; set; } - public int RecentTvPriority { get; set; } - public int OlderTvPriority { get; set; } - public bool UseSsl { get; set; } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/086_pushbullet_device_ids.cs b/src/NzbDrone.Core/Datastore/Migration/086_pushbullet_device_ids.cs deleted file mode 100644 index 432a13ff3..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/086_pushbullet_device_ids.cs +++ /dev/null @@ -1,77 +0,0 @@ -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(86)] - public class pushbullet_device_ids : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Execute.WithConnection(UpdateTransmissionSettings); - } - - private void UpdateTransmissionSettings(IDbConnection conn, IDbTransaction tran) - { - using (var cmd = conn.CreateCommand()) - { - cmd.Transaction = tran; - cmd.CommandText = "SELECT Id, Settings FROM Notifications WHERE Implementation = 'PushBullet'"; - - using (var reader = cmd.ExecuteReader()) - { - while (reader.Read()) - { - var id = reader.GetInt32(0); - var settingsJson = reader.GetString(1); - var settings = Json.Deserialize>(settingsJson); - - if (settings.ContainsKey("deviceId")) - { - var deviceId = settings.GetValueOrDefault("deviceId", "") as string; - - settings.Add("deviceIds", new[] { deviceId }); - settings.Remove("deviceId"); - - using (var updateCmd = conn.CreateCommand()) - { - updateCmd.Transaction = tran; - updateCmd.CommandText = "UPDATE Notifications SET Settings = ? WHERE Id = ?"; - updateCmd.AddParameter(settings.ToJson()); - updateCmd.AddParameter(id); - - updateCmd.ExecuteNonQuery(); - } - } - } - } - } - } - } - - public class Notification86 - { - public int Id { get; set; } - public string Name { get; set; } - public int OnGrab { get; set; } - public int OnDownload { get; set; } - public JObject Settings { get; set; } - public string Implementation { get; set; } - public string ConfigContract { get; set; } - public int OnUpgrade { get; set; } - public List Tags { get; set; } - } - - public class PushBulletSettings86 - { - public string ApiKey { get; set; } - public string[] DeviceIds { get; set; } - public string ChannelTags { get; set; } - public string SenderId { get; set; } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/087_remove_eztv.cs b/src/NzbDrone.Core/Datastore/Migration/087_remove_eztv.cs deleted file mode 100644 index d6990053a..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/087_remove_eztv.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(87)] - public class remove_eztv : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Execute.Sql("DELETE FROM Indexers WHERE Implementation = 'Eztv'"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/088_pushbullet_devices_channels_list.cs b/src/NzbDrone.Core/Datastore/Migration/088_pushbullet_devices_channels_list.cs deleted file mode 100644 index b219dfd59..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/088_pushbullet_devices_channels_list.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Data; -using FluentMigrator; -using NzbDrone.Common.Extensions; -using NzbDrone.Common.Serializer; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(88)] - public class pushbullet_devices_channels_list : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Execute.WithConnection(UpdateTransmissionSettings); - } - - private void UpdateTransmissionSettings(IDbConnection conn, IDbTransaction tran) - { - using (var cmd = conn.CreateCommand()) - { - cmd.Transaction = tran; - cmd.CommandText = "SELECT Id, Settings FROM Notifications WHERE Implementation = 'PushBullet'"; - - using (var reader = cmd.ExecuteReader()) - { - while (reader.Read()) - { - var id = reader.GetInt32(0); - var settingsJson = reader.GetString(1); - var settings = Json.Deserialize>(settingsJson); - - if (settings.ContainsKey("deviceIds")) - { - var deviceIdsString = settings.GetValueOrDefault("deviceIds", "") as string; - - if (deviceIdsString.IsNotNullOrWhiteSpace()) - { - var deviceIds = deviceIdsString.Split(new[] { "," }, StringSplitOptions.RemoveEmptyEntries); - - settings["deviceIds"] = deviceIds; - } - } - - if (settings.ContainsKey("channelTags")) - { - var channelTagsString = settings.GetValueOrDefault("channelTags", "") as string; - - if (channelTagsString.IsNotNullOrWhiteSpace()) - { - var channelTags = channelTagsString.Split(new[] {","}, StringSplitOptions.RemoveEmptyEntries); - - settings["channelTags"] = channelTags; - } - } - - using (var updateCmd = conn.CreateCommand()) - { - updateCmd.Transaction = tran; - updateCmd.CommandText = "UPDATE Notifications SET Settings = ? WHERE Id = ?"; - updateCmd.AddParameter(settings.ToJson()); - updateCmd.AddParameter(id); - - updateCmd.ExecuteNonQuery(); - } - } - } - } - } - } - - public class PushBulletSettings88 - { - public string ApiKey { get; set; } - public string[] DeviceIds { get; set; } - public string[] ChannelTags { get; set; } - public string SenderId { get; set; } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/089_add_on_rename_to_notifcations.cs b/src/NzbDrone.Core/Datastore/Migration/089_add_on_rename_to_notifcations.cs deleted file mode 100644 index e06db676b..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/089_add_on_rename_to_notifcations.cs +++ /dev/null @@ -1,21 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(89)] - public class add_on_rename_to_notifcations : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("Notifications").AddColumn("OnRename").AsBoolean().Nullable(); - - Execute.Sql("UPDATE Notifications SET OnRename = OnDownload WHERE Implementation IN ('PlexServer', 'Xbmc', 'MediaBrowser')"); - Execute.Sql("UPDATE Notifications SET OnRename = 0 WHERE Implementation NOT IN ('PlexServer', 'Xbmc', 'MediaBrowser')"); - - Alter.Table("Notifications").AlterColumn("OnRename").AsBoolean().NotNullable(); - - Execute.Sql("UPDATE Notifications SET OnGrab = 0 WHERE Implementation = 'PlexServer'"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/090_update_kickass_url.cs b/src/NzbDrone.Core/Datastore/Migration/090_update_kickass_url.cs deleted file mode 100644 index ec96f48dc..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/090_update_kickass_url.cs +++ /dev/null @@ -1,36 +0,0 @@ -using FluentMigrator; -using Newtonsoft.Json.Linq; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(90)] - public class update_kickass_url : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Execute.Sql( - "UPDATE Indexers SET Settings = Replace(Settings, 'kickass.so', 'kat.cr') WHERE Implementation = 'KickassTorrents';" + - "UPDATE Indexers SET Settings = Replace(Settings, 'kickass.to', 'kat.cr') WHERE Implementation = 'KickassTorrents';" + - "UPDATE Indexers SET Settings = Replace(Settings, 'http://', 'https://') WHERE Implementation = 'KickassTorrents';" - ); - } - } - - public class IndexerDefinition90 - { - public int Id { get; set; } - public string Name { get; set; } - public JObject Settings { get; set; } - public string Implementation { get; set; } - public string ConfigContract { get; set; } - public bool EnableRss { get; set; } - public bool EnableSearch { get; set; } - } - - public class KickassTorrentsSettings90 - { - public string BaseUrl { get; set; } - public bool VerifiedOnly { get; set; } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/091_added_indexerstatus.cs b/src/NzbDrone.Core/Datastore/Migration/091_added_indexerstatus.cs deleted file mode 100644 index 9a384c298..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/091_added_indexerstatus.cs +++ /dev/null @@ -1,20 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(91)] - public class added_indexerstatus : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Create.TableForModel("IndexerStatus") - .WithColumn("IndexerId").AsInt32().NotNullable().Unique() - .WithColumn("InitialFailure").AsDateTime().Nullable() - .WithColumn("MostRecentFailure").AsDateTime().Nullable() - .WithColumn("EscalationLevel").AsInt32().NotNullable() - .WithColumn("DisabledTill").AsDateTime().Nullable() - .WithColumn("LastRssSyncReleaseInfo").AsString().Nullable(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/092_add_unverifiedscenenumbering.cs b/src/NzbDrone.Core/Datastore/Migration/092_add_unverifiedscenenumbering.cs deleted file mode 100644 index 5366b0fad..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/092_add_unverifiedscenenumbering.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(92)] - public class add_unverifiedscenenumbering : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("Episodes").AddColumn("UnverifiedSceneNumbering").AsBoolean().WithDefaultValue(false); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/093_naming_config_replace_characters.cs b/src/NzbDrone.Core/Datastore/Migration/093_naming_config_replace_characters.cs deleted file mode 100644 index 4ba4be853..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/093_naming_config_replace_characters.cs +++ /dev/null @@ -1,15 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(93)] - public class naming_config_replace_illegal_characters : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("NamingConfig").AddColumn("ReplaceIllegalCharacters").AsBoolean().WithDefaultValue(true); - Update.Table("NamingConfig").Set(new { ReplaceIllegalCharacters = true }).AllRows(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/094_add_tvmazeid.cs b/src/NzbDrone.Core/Datastore/Migration/094_add_tvmazeid.cs deleted file mode 100644 index 007716bfc..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/094_add_tvmazeid.cs +++ /dev/null @@ -1,15 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(94)] - public class add_tvmazeid : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("Series").AddColumn("TvMazeId").AsInt32().WithDefaultValue(0); - Create.Index().OnTable("Series").OnColumn("TvMazeId"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/095_add_additional_episodes_index.cs b/src/NzbDrone.Core/Datastore/Migration/095_add_additional_episodes_index.cs deleted file mode 100644 index 5d7a08b62..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/095_add_additional_episodes_index.cs +++ /dev/null @@ -1,16 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(95)] - public class add_additional_episodes_index : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Create.Index().OnTable("Episodes").OnColumn("SeriesId").Ascending() - .OnColumn("SeasonNumber").Ascending() - .OnColumn("EpisodeNumber").Ascending(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/096_disable_kickass.cs b/src/NzbDrone.Core/Datastore/Migration/096_disable_kickass.cs deleted file mode 100644 index 69894cbce..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/096_disable_kickass.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(96)] - public class disable_kickass : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Execute.Sql("UPDATE Indexers SET EnableRss = 0, EnableSearch = 0, Settings = Replace(Settings, 'https://kat.cr', '') WHERE Implementation = 'KickassTorrents' AND Settings LIKE '%kat.cr%';"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/098_remove_titans_of_tv.cs b/src/NzbDrone.Core/Datastore/Migration/098_remove_titans_of_tv.cs deleted file mode 100644 index 86b2eba1e..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/098_remove_titans_of_tv.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(98)] - public class remove_titans_of_tv : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Delete.FromTable("Indexers").Row(new { Implementation = "TitansOfTv" }); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/099_extra_and_subtitle_files.cs b/src/NzbDrone.Core/Datastore/Migration/099_extra_and_subtitle_files.cs deleted file mode 100644 index f7a173157..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/099_extra_and_subtitle_files.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System; -using System.Data; -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(99)] - public class extra_and_subtitle_files : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Create.TableForModel("ExtraFiles") - .WithColumn("SeriesId").AsInt32().NotNullable() - .WithColumn("SeasonNumber").AsInt32().NotNullable() - .WithColumn("EpisodeFileId").AsInt32().NotNullable() - .WithColumn("RelativePath").AsString().NotNullable() - .WithColumn("Extension").AsString().NotNullable() - .WithColumn("Added").AsDateTime().NotNullable() - .WithColumn("LastUpdated").AsDateTime().NotNullable(); - - Create.TableForModel("SubtitleFiles") - .WithColumn("SeriesId").AsInt32().NotNullable() - .WithColumn("SeasonNumber").AsInt32().NotNullable() - .WithColumn("EpisodeFileId").AsInt32().NotNullable() - .WithColumn("RelativePath").AsString().NotNullable() - .WithColumn("Extension").AsString().NotNullable() - .WithColumn("Added").AsDateTime().NotNullable() - .WithColumn("LastUpdated").AsDateTime().NotNullable() - .WithColumn("Language").AsInt32().NotNullable(); - - Alter.Table("MetadataFiles") - .AddColumn("Added").AsDateTime().Nullable() - .AddColumn("Extension").AsString().Nullable(); - - // Remove Metadata files that don't have an extension - Execute.Sql("DELETE FROM MetadataFiles WHERE RelativePath NOT LIKE '%.%'"); - - // Set Extension using the extension from RelativePath - Execute.WithConnection(SetMetadataFileExtension); - - Alter.Table("MetadataFiles").AlterColumn("Extension").AsString().NotNullable(); - } - - private void SetMetadataFileExtension(IDbConnection conn, IDbTransaction tran) - { - using (var cmd = conn.CreateCommand()) - { - cmd.Transaction = tran; - cmd.CommandText = "SELECT Id, RelativePath FROM MetadataFiles"; - - using (var reader = cmd.ExecuteReader()) - { - while (reader.Read()) - { - var id = reader.GetInt32(0); - var relativePath = reader.GetString(1); - var extension = relativePath.Substring(relativePath.LastIndexOf(".", StringComparison.InvariantCultureIgnoreCase)); - - using (var updateCmd = conn.CreateCommand()) - { - updateCmd.Transaction = tran; - updateCmd.CommandText = "UPDATE MetadataFiles SET Extension = ? WHERE Id = ?"; - updateCmd.AddParameter(extension); - updateCmd.AddParameter(id); - - updateCmd.ExecuteNonQuery(); - } - } - } - } - } - } - - public class MetadataFile99 - { - public int Id { get; set; } - public int SeriesId { get; set; } - public int? EpisodeFileId { get; set; } - public int? SeasonNumber { get; set; } - public string RelativePath { get; set; } - public DateTime Added { get; set; } - public DateTime LastUpdated { get; set; } - public string Extension { get; set; } - public string Hash { get; set; } - public string Consumer { get; set; } - public int Type { get; set; } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/100_add_scene_season_number.cs b/src/NzbDrone.Core/Datastore/Migration/100_add_scene_season_number.cs deleted file mode 100644 index 3cd11f6f0..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/100_add_scene_season_number.cs +++ /dev/null @@ -1,15 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(100)] - public class add_scene_season_number : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Alter.Table("SceneMappings").AlterColumn("SeasonNumber").AsInt32().Nullable(); - Alter.Table("SceneMappings").AddColumn("SceneSeasonNumber").AsInt32().Nullable(); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/101_add_ultrahd_quality_in_profiles.cs b/src/NzbDrone.Core/Datastore/Migration/101_add_ultrahd_quality_in_profiles.cs deleted file mode 100644 index 171607fa6..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/101_add_ultrahd_quality_in_profiles.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Data; -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(101)] - public class add_ultrahd_quality_in_profiles : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Execute.WithConnection(ConvertProfile); - } - - private void ConvertProfile(IDbConnection conn, IDbTransaction tran) - { - var updater = new ProfileUpdater70(conn, tran); - updater.AppendQuality(16); // HDTV2160p - updater.AppendQuality(18); // WEBDL2160p - updater.AppendQuality(19); // Bluray2160p - updater.Commit(); - - // WEBRip migrations. - //updater.SplitQualityAppend(1, 11); // HDTV480p after SDTV - //updater.SplitQualityPrepend(8, 12); // WEBRip480p before WEBDL480p - //updater.SplitQualityAppend(2, 13); // Bluray480p after DVD - //updater.SplitQualityPrepend(5, 14); // WEBRip720p before WEBDL720p - //updater.SplitQualityPrepend(3, 15); // WEBRip1080p before WEBDL1080p - //updater.SplitQualityPrepend(18, 17); // WEBRip2160p before WEBDL2160p - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/103_fix_metadata_file_extensions.cs b/src/NzbDrone.Core/Datastore/Migration/103_fix_metadata_file_extensions.cs deleted file mode 100644 index d4ed853a3..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/103_fix_metadata_file_extensions.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using System.Data; -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(103)] - public class fix_metadata_file_extensions : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Execute.WithConnection(SetMetadataFileExtension); - } - - private void SetMetadataFileExtension(IDbConnection conn, IDbTransaction tran) - { - using (var cmd = conn.CreateCommand()) - { - cmd.Transaction = tran; - cmd.CommandText = "SELECT Id, Extension FROM MetadataFiles"; - - using (var reader = cmd.ExecuteReader()) - { - while (reader.Read()) - { - var id = reader.GetInt32(0); - var extension = reader.GetString(1); - extension = extension.Substring(extension.LastIndexOf(".", StringComparison.InvariantCultureIgnoreCase)); - - using (var updateCmd = conn.CreateCommand()) - { - updateCmd.Transaction = tran; - updateCmd.CommandText = "UPDATE MetadataFiles SET Extension = ? WHERE Id = ?"; - updateCmd.AddParameter(extension); - updateCmd.AddParameter(id); - - updateCmd.ExecuteNonQuery(); - } - } - } - } - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/104_remove_kickass.cs b/src/NzbDrone.Core/Datastore/Migration/104_remove_kickass.cs deleted file mode 100644 index c7f21fe38..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/104_remove_kickass.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(104)] - public class remove_kickass : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Execute.Sql("DELETE FROM Indexers WHERE Implementation = 'KickassTorrents';"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/105_rename_torrent_downloadstation.cs b/src/NzbDrone.Core/Datastore/Migration/105_rename_torrent_downloadstation.cs deleted file mode 100644 index c5a71c885..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/105_rename_torrent_downloadstation.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(105)] - public class rename_torrent_downloadstation : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Execute.Sql("UPDATE DownloadClients SET Implementation = 'TorrentDownloadStation' WHERE Implementation = 'DownloadStation';"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/106_update_btn_url.cs b/src/NzbDrone.Core/Datastore/Migration/106_update_btn_url.cs deleted file mode 100644 index f2989a2c8..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/106_update_btn_url.cs +++ /dev/null @@ -1,22 +0,0 @@ -using FluentMigrator; -using Newtonsoft.Json.Linq; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(106)] - public class update_btn_url : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Execute.Sql("UPDATE Indexers SET Settings = Replace(Settings, 'api.btnapps.net', 'api.broadcasthe.net') WHERE Implementation = 'BroadcastheNet';"); - } - } - - public class BroadcastheNetSettings106 - { - public string BaseUrl { get; set; } - - public string ApiKey { get; set; } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/107_remove_wombles.cs b/src/NzbDrone.Core/Datastore/Migration/107_remove_wombles.cs deleted file mode 100644 index 7bbb5ceb7..000000000 --- a/src/NzbDrone.Core/Datastore/Migration/107_remove_wombles.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentMigrator; -using NzbDrone.Core.Datastore.Migration.Framework; - -namespace NzbDrone.Core.Datastore.Migration -{ - [Migration(107)] - public class remove_wombles : NzbDroneMigrationBase - { - protected override void MainDbUpgrade() - { - Execute.Sql("DELETE FROM Indexers WHERE Implementation = 'Wombles';"); - } - } -} diff --git a/src/NzbDrone.Core/Datastore/Migration/Framework/MigrationController.cs b/src/NzbDrone.Core/Datastore/Migration/Framework/MigrationController.cs index 793725e9f..2e1a2d2ab 100644 --- a/src/NzbDrone.Core/Datastore/Migration/Framework/MigrationController.cs +++ b/src/NzbDrone.Core/Datastore/Migration/Framework/MigrationController.cs @@ -57,7 +57,8 @@ namespace NzbDrone.Core.Datastore.Migration.Framework SQLiteConnection.ClearAllPools(); throw; } - + + processor.Dispose(); sw.Stop(); diff --git a/src/NzbDrone.Core/Datastore/Migration/Framework/NzbDroneMigrationBase.cs b/src/NzbDrone.Core/Datastore/Migration/Framework/NzbDroneMigrationBase.cs index 7ebac899c..0d4bf0a13 100644 --- a/src/NzbDrone.Core/Datastore/Migration/Framework/NzbDroneMigrationBase.cs +++ b/src/NzbDrone.Core/Datastore/Migration/Framework/NzbDroneMigrationBase.cs @@ -1,4 +1,4 @@ -using System; +using System; using FluentMigrator; using NLog; using NzbDrone.Common.Instrumentation; diff --git a/src/NzbDrone.Core/Datastore/Migration/Framework/NzbDroneSqliteProcessor.cs b/src/NzbDrone.Core/Datastore/Migration/Framework/NzbDroneSqliteProcessor.cs index 79a9eca45..929a89e9d 100644 --- a/src/NzbDrone.Core/Datastore/Migration/Framework/NzbDroneSqliteProcessor.cs +++ b/src/NzbDrone.Core/Datastore/Migration/Framework/NzbDroneSqliteProcessor.cs @@ -1,12 +1,15 @@ -using System; +using System; +using System.Collections.Generic; using System.Data; using System.Linq; using FluentMigrator; using FluentMigrator.Expressions; using FluentMigrator.Model; using FluentMigrator.Runner; +using FluentMigrator.Runner.Announcers; using FluentMigrator.Runner.Generators.SQLite; using FluentMigrator.Runner.Processors.SQLite; +using System.Text.RegularExpressions; namespace NzbDrone.Core.Datastore.Migration.Framework { @@ -62,15 +65,55 @@ 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); + var schemaDumper = new SqliteSchemaDumper(this, Announcer); var schema = schemaDumper.ReadDbSchema(); 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 +126,13 @@ 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/Migration/Framework/SqliteSyntaxReader.cs b/src/NzbDrone.Core/Datastore/Migration/Framework/SqliteSyntaxReader.cs index e60ef4c70..703aff012 100644 --- a/src/NzbDrone.Core/Datastore/Migration/Framework/SqliteSyntaxReader.cs +++ b/src/NzbDrone.Core/Datastore/Migration/Framework/SqliteSyntaxReader.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Data; using System.Text; @@ -148,7 +148,8 @@ namespace NzbDrone.Core.Datastore.Migration.Framework { var start = Index; var end = start + 1; - while (end < Buffer.Length && (char.IsLetter(Buffer[end]) || Buffer[end] == '_')) end++; + while (end < Buffer.Length && (char.IsLetter(Buffer[end]) || char.IsNumber(Buffer[end]) || Buffer[end] == '_' || Buffer[end] == '(')) end++; + if (end >= Buffer.Length || Buffer[end] == ',' || Buffer[end] == ')' || char.IsWhiteSpace(Buffer[end])) { Index = end; diff --git a/src/NzbDrone.Core/Datastore/PagingSpec.cs b/src/NzbDrone.Core/Datastore/PagingSpec.cs index 63f8d719c..0c9179844 100644 --- a/src/NzbDrone.Core/Datastore/PagingSpec.cs +++ b/src/NzbDrone.Core/Datastore/PagingSpec.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq.Expressions; @@ -12,7 +12,12 @@ namespace NzbDrone.Core.Datastore public string SortKey { get; set; } public SortDirection SortDirection { get; set; } public List Records { get; set; } - public Expression> FilterExpression { get; set; } + public List>> FilterExpressions { get; set; } + + public PagingSpec() + { + FilterExpressions = new List>>(); + } } public enum SortDirection diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index 62f6aeb8b..fd9cb7d9e 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -1,16 +1,18 @@ -using System; +using System; using System.Collections.Generic; +using System.Linq; using Marr.Data; using Marr.Data.Mapping; using NzbDrone.Common.Reflection; using NzbDrone.Core.Blacklisting; using NzbDrone.Core.Configuration; -using NzbDrone.Core.DataAugmentation.Scene; using NzbDrone.Core.Datastore.Converters; using NzbDrone.Core.Datastore.Extensions; using NzbDrone.Core.Download; using NzbDrone.Core.Download.Pending; using NzbDrone.Core.Indexers; +using NzbDrone.Core.ImportLists; +using NzbDrone.Core.ImportLists.Exclusions; using NzbDrone.Core.Instrumentation; using NzbDrone.Core.Jobs; using NzbDrone.Core.MediaFiles; @@ -19,21 +21,24 @@ using NzbDrone.Core.RemotePathMappings; using NzbDrone.Core.Notifications; using NzbDrone.Core.Organizer; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Profiles; +using NzbDrone.Core.Profiles.Metadata; +using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Qualities; -using NzbDrone.Core.Restrictions; using NzbDrone.Core.RootFolders; -using NzbDrone.Core.SeriesStats; +using NzbDrone.Core.ArtistStats; using NzbDrone.Core.Tags; using NzbDrone.Core.ThingiProvider; -using NzbDrone.Core.Tv; using NzbDrone.Common.Disk; using NzbDrone.Core.Authentication; +using NzbDrone.Core.CustomFilters; using NzbDrone.Core.Extras.Metadata; using NzbDrone.Core.Extras.Metadata.Files; using NzbDrone.Core.Extras.Others; -using NzbDrone.Core.Extras.Subtitles; +using NzbDrone.Core.Extras.Lyrics; using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Music; +using Marr.Data.QGen; +using NzbDrone.Core.Profiles.Releases; namespace NzbDrone.Core.Datastore { @@ -46,69 +51,134 @@ 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") .Ignore(i => i.Enable) .Ignore(i => i.Protocol) .Ignore(i => i.SupportsRss) - .Ignore(i => i.SupportsSearch); + .Ignore(i => i.SupportsSearch) + .Ignore(d => d.Tags); + + Mapper.Entity().RegisterDefinition("ImportLists") + .Ignore(i => i.Enable) + .Ignore(i => i.ListType); Mapper.Entity().RegisterDefinition("Notifications") .Ignore(i => i.SupportsOnGrab) - .Ignore(i => i.SupportsOnDownload) + .Ignore(i => i.SupportsOnReleaseImport) .Ignore(i => i.SupportsOnUpgrade) - .Ignore(i => i.SupportsOnRename); + .Ignore(i => i.SupportsOnRename) + .Ignore(i => i.SupportsOnHealthIssue) + .Ignore(i => i.SupportsOnDownloadFailure) + .Ignore(i => i.SupportsOnImportFailure) + .Ignore(i => i.SupportsOnTrackRetag); - Mapper.Entity().RegisterDefinition("Metadata"); + Mapper.Entity().RegisterDefinition("Metadata") + .Ignore(d => d.Tags); Mapper.Entity().RegisterDefinition("DownloadClients") - .Ignore(d => d.Protocol); - - Mapper.Entity().RegisterModel("SceneMappings"); + .Ignore(d => d.Protocol) + .Ignore(d => d.Tags); Mapper.Entity().RegisterModel("History") - .AutoMapChildModels(); - - Mapper.Entity().RegisterModel("Series") - .Ignore(s => s.RootFolderPath) - .Relationship() - .HasOne(s => s.Profile, s => s.ProfileId); - - Mapper.Entity().RegisterModel("EpisodeFiles") - .Ignore(f => f.Path) - .Relationships.AutoMapICollectionOrComplexProperties() - .For("Episodes") - .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); - - Mapper.Entity().RegisterModel("Episodes") - .Ignore(e => e.SeriesTitle) - .Ignore(e => e.Series) - .Ignore(e => e.HasFile) - .Relationship() - .HasOne(episode => episode.EpisodeFile, episode => episode.EpisodeFileId); + .AutoMapChildModels(); + + Mapper.Entity().RegisterModel("Artists") + .Ignore(s => s.RootFolderPath) + .Ignore(s => s.Name) + .Ignore(s => s.ForeignArtistId) + .Relationship() + .HasOne(a => a.Metadata, a => a.ArtistMetadataId) + .HasOne(a => a.QualityProfile, a => a.QualityProfileId) + .HasOne(s => s.MetadataProfile, s => s.MetadataProfileId) + .For(a => a.Albums) + .LazyLoad(condition: a => a.Id > 0, query: (db, a) => db.Query().Where(rg => rg.ArtistMetadataId == a.Id).ToList()); + + Mapper.Entity().RegisterModel("ArtistMetadata"); + + Mapper.Entity().RegisterModel("Albums") + .Ignore(r => r.ArtistId) + .Relationship() + .HasOne(r => r.ArtistMetadata, r => r.ArtistMetadataId) + .For(rg => rg.AlbumReleases) + .LazyLoad(condition: rg => rg.Id > 0, query: (db, rg) => db.Query().Where(r => r.AlbumId == rg.Id).ToList()) + .For(rg => rg.Artist) + .LazyLoad(condition: rg => rg.ArtistMetadataId > 0, + query: (db, rg) => db.Query() + .Join(JoinType.Inner, a => a.Metadata, (a, m) => a.ArtistMetadataId == m.Id) + .Where(a => a.ArtistMetadataId == rg.ArtistMetadataId).SingleOrDefault()); + + Mapper.Entity().RegisterModel("AlbumReleases") + .Relationship() + .HasOne(r => r.Album, r => r.AlbumId) + .For(r => r.Tracks) + .LazyLoad(condition: r => r.Id > 0, query: (db, r) => db.Query().Where(t => t.AlbumReleaseId == r.Id).ToList()); + + Mapper.Entity().RegisterModel("Tracks") + .Ignore(t => t.HasFile) + .Ignore(t => t.AlbumId) + .Ignore(t => t.Album) + .Relationship() + .HasOne(track => track.AlbumRelease, track => track.AlbumReleaseId) + .HasOne(track => track.ArtistMetadata, track => track.ArtistMetadataId) + .For(track => track.TrackFile) + .LazyLoad(condition: track => track.TrackFileId > 0, + query: (db, track) => db.Query() + .Join(JoinType.Inner, t => t.Tracks, (t, x) => t.Id == x.TrackFileId) + .Join(JoinType.Inner, t => t.Album, (t, a) => t.AlbumId == a.Id) + .Join(JoinType.Inner, t => t.Artist, (t, a) => t.Album.Value.ArtistMetadataId == a.ArtistMetadataId) + .Join(JoinType.Inner, a => a.Metadata, (a, m) => a.ArtistMetadataId == m.Id) + .Where(t => t.Id == track.TrackFileId) + .SingleOrDefault()) + .For(t => t.Artist) + .LazyLoad(condition: t => t.AlbumReleaseId > 0, query: (db, t) => db.Query() + .Join(JoinType.Inner, a => a.Metadata, (a, m) => a.ArtistMetadataId == m.Id) + .Join(JoinType.Inner, a => a.Albums, (l, r) => l.ArtistMetadataId == r.ArtistMetadataId) + .Join(JoinType.Inner, a => a.AlbumReleases, (l, r) => l.Id == r.AlbumId) + .Where(r => r.Id == t.AlbumReleaseId) + .SingleOrDefault()); + + Mapper.Entity().RegisterModel("TrackFiles") + .Relationship() + .HasOne(f => f.Album, f => f.AlbumId) + .For(f => f.Tracks) + .LazyLoad(condition: f => f.Id > 0, query: (db, f) => db.Query() + .Where(x => x.TrackFileId == f.Id) + .ToList()) + .For(t => t.Artist) + .LazyLoad(condition: f => f.Id > 0, query: (db, f) => db.Query() + .Join(JoinType.Inner, a => a.Metadata, (a, m) => a.ArtistMetadataId == m.Id) + .Join(JoinType.Inner, a => a.Albums, (l, r) => l.ArtistMetadataId == r.ArtistMetadataId) + .Where(r => r.Id == f.AlbumId) + .SingleOrDefault()); Mapper.Entity().RegisterModel("QualityDefinitions") + .Ignore(d => d.GroupName) + .Ignore(d => d.GroupWeight) .Ignore(d => d.Weight); - Mapper.Entity().RegisterModel("Profiles"); + Mapper.Entity().RegisterModel("QualityProfiles"); + Mapper.Entity().RegisterModel("MetadataProfiles"); Mapper.Entity().RegisterModel("Logs"); Mapper.Entity().RegisterModel("NamingConfig"); - Mapper.Entity().MapResultSet(); + Mapper.Entity().MapResultSet(); Mapper.Entity().RegisterModel("Blacklist"); Mapper.Entity().RegisterModel("MetadataFiles"); - Mapper.Entity().RegisterModel("SubtitleFiles"); + Mapper.Entity().RegisterModel("LyricFiles"); Mapper.Entity().RegisterModel("ExtraFiles"); Mapper.Entity().RegisterModel("PendingReleases") - .Ignore(e => e.RemoteEpisode); + .Ignore(e => e.RemoteAlbum); Mapper.Entity().RegisterModel("RemotePathMappings"); Mapper.Entity().RegisterModel("Tags"); - Mapper.Entity().RegisterModel("Restrictions"); + Mapper.Entity().RegisterModel("ReleaseProfiles"); Mapper.Entity().RegisterModel("DelayProfiles"); Mapper.Entity().RegisterModel("Users"); @@ -116,6 +186,11 @@ namespace NzbDrone.Core.Datastore .Ignore(c => c.Message); Mapper.Entity().RegisterModel("IndexerStatus"); + Mapper.Entity().RegisterModel("DownloadClientStatus"); + Mapper.Entity().RegisterModel("ImportListStatus"); + + Mapper.Entity().RegisterModel("CustomFilters"); + Mapper.Entity().RegisterModel("ImportListExclusions"); } private static void RegisterMappers() @@ -129,12 +204,17 @@ namespace NzbDrone.Core.Datastore MapRepository.Instance.RegisterTypeConverter(typeof(bool), new BooleanIntConverter()); MapRepository.Instance.RegisterTypeConverter(typeof(Enum), new EnumIntConverter()); MapRepository.Instance.RegisterTypeConverter(typeof(Quality), new QualityIntConverter()); - MapRepository.Instance.RegisterTypeConverter(typeof(List), new EmbeddedDocumentConverter(new QualityIntConverter())); + MapRepository.Instance.RegisterTypeConverter(typeof(List), new EmbeddedDocumentConverter(new QualityIntConverter())); MapRepository.Instance.RegisterTypeConverter(typeof(QualityModel), new EmbeddedDocumentConverter(new QualityIntConverter())); MapRepository.Instance.RegisterTypeConverter(typeof(Dictionary), new EmbeddedDocumentConverter()); MapRepository.Instance.RegisterTypeConverter(typeof(List), new EmbeddedDocumentConverter()); + MapRepository.Instance.RegisterTypeConverter(typeof(List>), new EmbeddedDocumentConverter()); MapRepository.Instance.RegisterTypeConverter(typeof(List), new EmbeddedDocumentConverter()); - MapRepository.Instance.RegisterTypeConverter(typeof(ParsedEpisodeInfo), new EmbeddedDocumentConverter()); + MapRepository.Instance.RegisterTypeConverter(typeof(List), new EmbeddedDocumentConverter(new PrimaryAlbumTypeIntConverter())); + MapRepository.Instance.RegisterTypeConverter(typeof(List), new EmbeddedDocumentConverter(new SecondaryAlbumTypeIntConverter())); + MapRepository.Instance.RegisterTypeConverter(typeof(List), new EmbeddedDocumentConverter(new ReleaseStatusIntConverter())); + MapRepository.Instance.RegisterTypeConverter(typeof(ParsedAlbumInfo), new EmbeddedDocumentConverter()); + MapRepository.Instance.RegisterTypeConverter(typeof(ParsedTrackInfo), new EmbeddedDocumentConverter()); MapRepository.Instance.RegisterTypeConverter(typeof(ReleaseInfo), new EmbeddedDocumentConverter()); MapRepository.Instance.RegisterTypeConverter(typeof(HashSet), new EmbeddedDocumentConverter()); MapRepository.Instance.RegisterTypeConverter(typeof(OsPath), new OsPathConverter()); @@ -171,4 +251,4 @@ namespace NzbDrone.Core.Datastore } } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/DecisionEngine/DownloadDecision.cs b/src/NzbDrone.Core/DecisionEngine/DownloadDecision.cs index cad8177cb..c9d306ae8 100644 --- a/src/NzbDrone.Core/DecisionEngine/DownloadDecision.cs +++ b/src/NzbDrone.Core/DecisionEngine/DownloadDecision.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using NzbDrone.Core.Parser.Model; @@ -6,7 +6,7 @@ namespace NzbDrone.Core.DecisionEngine { public class DownloadDecision { - public RemoteEpisode RemoteEpisode { get; private set; } + public RemoteAlbum RemoteAlbum { get; private set; } public IEnumerable Rejections { get; private set; } public bool Approved => !Rejections.Any(); @@ -27,20 +27,20 @@ namespace NzbDrone.Core.DecisionEngine } } - public DownloadDecision(RemoteEpisode episode, params Rejection[] rejections) + public DownloadDecision(RemoteAlbum album, params Rejection[] rejections) { - RemoteEpisode = episode; + RemoteAlbum = album; Rejections = rejections.ToList(); } - + public override string ToString() { if (Approved) { - return "[OK] " + RemoteEpisode; + return "[OK] " + RemoteAlbum; } - return "[Rejected " + Rejections.Count() + "]" + RemoteEpisode; + return "[Rejected " + Rejections.Count() + "]" + RemoteAlbum; } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionComparer.cs b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionComparer.cs index a85410046..ca2178ffd 100644 --- a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionComparer.cs +++ b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionComparer.cs @@ -1,21 +1,25 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; +using NzbDrone.Core.Configuration; using NzbDrone.Core.Indexers; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Profiles.Delay; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Qualities; namespace NzbDrone.Core.DecisionEngine { public class DownloadDecisionComparer : IComparer { + private readonly IConfigService _configService; private readonly IDelayProfileService _delayProfileService; + public delegate int CompareDelegate(DownloadDecision x, DownloadDecision y); public delegate int CompareDelegate(DownloadDecision x, DownloadDecision y); - public DownloadDecisionComparer(IDelayProfileService delayProfileService) + public DownloadDecisionComparer(IConfigService configService, IDelayProfileService delayProfileService) { + _configService = configService; _delayProfileService = delayProfileService; } @@ -24,10 +28,10 @@ namespace NzbDrone.Core.DecisionEngine var comparers = new List { CompareQuality, + ComparePreferredWordScore, CompareProtocol, - CompareEpisodeCount, - CompareEpisodeNumber, ComparePeersIfTorrent, + CompareAlbumCount, CompareAgeIfUsenet, CompareSize }; @@ -47,7 +51,7 @@ namespace NzbDrone.Core.DecisionEngine private int CompareByReverse(TSubject left, TSubject right, Func funcValue) where TValue : IComparable { - return CompareBy(left, right, funcValue)*-1; + return CompareBy(left, right, funcValue) * -1; } private int CompareAll(params int[] comparers) @@ -57,67 +61,67 @@ namespace NzbDrone.Core.DecisionEngine private int CompareQuality(DownloadDecision x, DownloadDecision y) { - return CompareAll(CompareBy(x.RemoteEpisode, y.RemoteEpisode, remoteEpisode => remoteEpisode.Series.Profile.Value.Items.FindIndex(v => v.Quality == remoteEpisode.ParsedEpisodeInfo.Quality.Quality)), - CompareBy(x.RemoteEpisode, y.RemoteEpisode, remoteEpisode => remoteEpisode.ParsedEpisodeInfo.Quality.Revision.Real), - CompareBy(x.RemoteEpisode, y.RemoteEpisode, remoteEpisode => remoteEpisode.ParsedEpisodeInfo.Quality.Revision.Version)); + if (_configService.DownloadPropersAndRepacks == ProperDownloadTypes.DoNotPrefer) + { + return CompareAll(CompareBy(x.RemoteAlbum, y.RemoteAlbum, remoteAlbum => remoteAlbum.Artist.QualityProfile.Value.GetIndex(remoteAlbum.ParsedAlbumInfo.Quality.Quality)), + CompareBy(x.RemoteAlbum, y.RemoteAlbum, remoteAlbum => remoteAlbum.ParsedAlbumInfo.Quality.Revision.Real)); + } + + return CompareAll(CompareBy(x.RemoteAlbum, y.RemoteAlbum, remoteAlbum => remoteAlbum.Artist.QualityProfile.Value.GetIndex(remoteAlbum.ParsedAlbumInfo.Quality.Quality)), + CompareBy(x.RemoteAlbum, y.RemoteAlbum, remoteAlbum => remoteAlbum.ParsedAlbumInfo.Quality.Revision.Real), + CompareBy(x.RemoteAlbum, y.RemoteAlbum, remoteAlbum => remoteAlbum.ParsedAlbumInfo.Quality.Revision.Version)); + } + + private int ComparePreferredWordScore(DownloadDecision x, DownloadDecision y) + { + return CompareBy(x.RemoteAlbum, y.RemoteAlbum, remoteAlbum => remoteAlbum.PreferredWordScore); } private int CompareProtocol(DownloadDecision x, DownloadDecision y) { - var result = CompareBy(x.RemoteEpisode, y.RemoteEpisode, remoteEpisode => + var result = CompareBy(x.RemoteAlbum, y.RemoteAlbum, remoteAlbum => { - var delayProfile = _delayProfileService.BestForTags(remoteEpisode.Series.Tags); - var downloadProtocol = remoteEpisode.Release.DownloadProtocol; + var delayProfile = _delayProfileService.BestForTags(remoteAlbum.Artist.Tags); + var downloadProtocol = remoteAlbum.Release.DownloadProtocol; return downloadProtocol == delayProfile.PreferredProtocol; }); return result; } - private int CompareEpisodeCount(DownloadDecision x, DownloadDecision y) + private int CompareAlbumCount(DownloadDecision x, DownloadDecision y) { - var seasonPackCompare = CompareBy(x.RemoteEpisode, y.RemoteEpisode, - remoteEpisode => remoteEpisode.ParsedEpisodeInfo.FullSeason); + var discographyCompare = CompareBy(x.RemoteAlbum, y.RemoteAlbum, + remoteAlbum => remoteAlbum.ParsedAlbumInfo.Discography); - if (seasonPackCompare != 0) + if (discographyCompare != 0) { - return seasonPackCompare; + return discographyCompare; } - if (x.RemoteEpisode.Series.SeriesType == SeriesTypes.Anime & - y.RemoteEpisode.Series.SeriesType == SeriesTypes.Anime) - { - return CompareBy(x.RemoteEpisode, y.RemoteEpisode, remoteEpisode => remoteEpisode.Episodes.Count); - } - - return CompareByReverse(x.RemoteEpisode, y.RemoteEpisode, remoteEpisode => remoteEpisode.Episodes.Count); - } - - private int CompareEpisodeNumber(DownloadDecision x, DownloadDecision y) - { - return CompareByReverse(x.RemoteEpisode, y.RemoteEpisode, remoteEpisode => remoteEpisode.Episodes.Select(e => e.EpisodeNumber).MinOrDefault()); + return CompareByReverse(x.RemoteAlbum, y.RemoteAlbum, remoteAlbum => remoteAlbum.Albums.Count); } private int ComparePeersIfTorrent(DownloadDecision x, DownloadDecision y) { // Different protocols should get caught when checking the preferred protocol, // since we're dealing with the same series in our comparisions - if (x.RemoteEpisode.Release.DownloadProtocol != DownloadProtocol.Torrent || - y.RemoteEpisode.Release.DownloadProtocol != DownloadProtocol.Torrent) + if (x.RemoteAlbum.Release.DownloadProtocol != DownloadProtocol.Torrent || + y.RemoteAlbum.Release.DownloadProtocol != DownloadProtocol.Torrent) { return 0; } return CompareAll( - CompareBy(x.RemoteEpisode, y.RemoteEpisode, remoteEpisode => + CompareBy(x.RemoteAlbum, y.RemoteAlbum, remoteAlbum => { - var seeders = TorrentInfo.GetSeeders(remoteEpisode.Release); + var seeders = TorrentInfo.GetSeeders(remoteAlbum.Release); return seeders.HasValue && seeders.Value > 0 ? Math.Round(Math.Log10(seeders.Value)) : 0; }), - CompareBy(x.RemoteEpisode, y.RemoteEpisode, remoteEpisode => + CompareBy(x.RemoteAlbum, y.RemoteAlbum, remoteAlbum => { - var peers = TorrentInfo.GetPeers(remoteEpisode.Release); + var peers = TorrentInfo.GetPeers(remoteAlbum.Release); return peers.HasValue && peers.Value > 0 ? Math.Round(Math.Log10(peers.Value)) : 0; })); @@ -125,16 +129,16 @@ namespace NzbDrone.Core.DecisionEngine private int CompareAgeIfUsenet(DownloadDecision x, DownloadDecision y) { - if (x.RemoteEpisode.Release.DownloadProtocol != DownloadProtocol.Usenet || - y.RemoteEpisode.Release.DownloadProtocol != DownloadProtocol.Usenet) + if (x.RemoteAlbum.Release.DownloadProtocol != DownloadProtocol.Usenet || + y.RemoteAlbum.Release.DownloadProtocol != DownloadProtocol.Usenet) { return 0; } - return CompareBy(x.RemoteEpisode, y.RemoteEpisode, remoteEpisode => + return CompareBy(x.RemoteAlbum, y.RemoteAlbum, remoteAlbum => { - var ageHours = remoteEpisode.Release.AgeHours; - var age = remoteEpisode.Release.Age; + var ageHours = remoteAlbum.Release.AgeHours; + var age = remoteAlbum.Release.Age; if (ageHours < 1) { @@ -159,7 +163,7 @@ namespace NzbDrone.Core.DecisionEngine { // TODO: Is smaller better? Smaller for usenet could mean no par2 files. - return CompareBy(x.RemoteEpisode, y.RemoteEpisode, remoteEpisode => remoteEpisode.Release.Size.Round(200.Megabytes())); + return CompareBy(x.RemoteAlbum, y.RemoteAlbum, remoteAlbum => remoteAlbum.Release.Size.Round(200.Megabytes())); } } } diff --git a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs index bb1a70873..693f0797a 100644 --- a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs +++ b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionMaker.cs @@ -5,6 +5,8 @@ using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Common.Serializer; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Download.Aggregation; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; @@ -21,32 +23,36 @@ namespace NzbDrone.Core.DecisionEngine { private readonly IEnumerable _specifications; private readonly IParsingService _parsingService; + private readonly IRemoteAlbumAggregationService _aggregationService; private readonly Logger _logger; - public DownloadDecisionMaker(IEnumerable specifications, IParsingService parsingService, Logger logger) + public DownloadDecisionMaker(IEnumerable specifications, + IParsingService parsingService, + IRemoteAlbumAggregationService aggregationService, + Logger logger) { _specifications = specifications; _parsingService = parsingService; + _aggregationService = aggregationService; _logger = logger; } public List GetRssDecision(List reports) { - return GetDecisions(reports).ToList(); + return GetAlbumDecisions(reports).ToList(); } public List GetSearchDecision(List reports, SearchCriteriaBase searchCriteriaBase) { - return GetDecisions(reports, searchCriteriaBase).ToList(); + return GetAlbumDecisions(reports, searchCriteriaBase).ToList(); } - private IEnumerable GetDecisions(List reports, SearchCriteriaBase searchCriteria = null) + private IEnumerable GetAlbumDecisions(List reports, SearchCriteriaBase searchCriteria = null) { if (reports.Any()) { _logger.ProgressInfo("Processing {0} releases", reports.Count); } - else { _logger.ProgressInfo("No results found"); @@ -58,38 +64,79 @@ 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 { - var parsedEpisodeInfo = Parser.Parser.ParseTitle(report.Title); + var parsedAlbumInfo = Parser.Parser.ParseAlbumTitle(report.Title); - if (parsedEpisodeInfo == null || parsedEpisodeInfo.IsPossibleSpecialEpisode) + if (parsedAlbumInfo == null && searchCriteria != null) { - var specialEpisodeInfo = _parsingService.ParseSpecialEpisodeTitle(report.Title, report.TvdbId, report.TvRageId, searchCriteria); - - if (specialEpisodeInfo != null) - { - parsedEpisodeInfo = specialEpisodeInfo; - } + parsedAlbumInfo = Parser.Parser.ParseAlbumTitleWithSearchCriteria(report.Title, + searchCriteria.Artist, searchCriteria.Albums); } - if (parsedEpisodeInfo != null && !parsedEpisodeInfo.SeriesTitle.IsNullOrWhiteSpace()) + if (parsedAlbumInfo != null) { - var remoteEpisode = _parsingService.Map(parsedEpisodeInfo, report.TvdbId, report.TvRageId, searchCriteria); - remoteEpisode.Release = report; - - if (remoteEpisode.Series == null) - { - decision = new DownloadDecision(remoteEpisode, new Rejection("Unknown Series")); - } - else if (remoteEpisode.Episodes.Empty()) + // TODO: Artist Data Augment without calling to parse title again + //if (!report.Artist.IsNullOrWhiteSpace()) + //{ + // if (parsedAlbumInfo.ArtistName.IsNullOrWhiteSpace() || _parsingService.GetArtist(parsedAlbumInfo.ArtistName) == null) + // { + // parsedAlbumInfo.ArtistName = report.Artist; + // } + //} + + // TODO: Replace Parsed AlbumTitle with metadata Title if Parsed AlbumTitle not a valid match + //if (!report.Album.IsNullOrWhiteSpace()) + //{ + // parsedAlbumInfo.AlbumTitle = report.Album; + //} + + if (!parsedAlbumInfo.ArtistName.IsNullOrWhiteSpace()) { - decision = new DownloadDecision(remoteEpisode, new Rejection("Unable to parse episodes from release name")); - } - else - { - remoteEpisode.DownloadAllowed = remoteEpisode.Episodes.Any(); - decision = GetDecisionForReport(remoteEpisode, searchCriteria); + var remoteAlbum = _parsingService.Map(parsedAlbumInfo, searchCriteria); + + // try parsing again using the search criteria, in case it parsed but parsed incorrectly + if ((remoteAlbum.Artist == null || remoteAlbum.Albums.Empty()) && searchCriteria != null) + { + _logger.Debug("Artist/Album null for {0}, reparsing with search criteria", report.Title); + var parsedAlbumInfoWithCriteria = Parser.Parser.ParseAlbumTitleWithSearchCriteria(report.Title, + searchCriteria.Artist, + searchCriteria.Albums); + + if (parsedAlbumInfoWithCriteria != null && parsedAlbumInfoWithCriteria.ArtistName.IsNotNullOrWhiteSpace()) + { + remoteAlbum = _parsingService.Map(parsedAlbumInfoWithCriteria, searchCriteria); + } + } + + remoteAlbum.Release = report; + + if (remoteAlbum.Artist == null) + { + decision = new DownloadDecision(remoteAlbum, new Rejection("Unknown Artist")); + // shove in the searched artist in case of forced download in interactive search + if (searchCriteria != null) + { + remoteAlbum.Artist = searchCriteria.Artist; + remoteAlbum.Albums = searchCriteria.Albums; + } + } + else if (remoteAlbum.Albums.Empty()) + { + decision = new DownloadDecision(remoteAlbum, new Rejection("Unable to parse albums from release name")); + if (searchCriteria != null) + { + remoteAlbum.Albums = searchCriteria.Albums; + } + } + else + { + _aggregationService.Augment(remoteAlbum); + remoteAlbum.DownloadAllowed = remoteAlbum.Albums.Any(); + decision = GetDecisionForReport(remoteAlbum, searchCriteria); + } } } } @@ -97,8 +144,8 @@ namespace NzbDrone.Core.DecisionEngine { _logger.Error(e, "Couldn't process release."); - var remoteEpisode = new RemoteEpisode { Release = report }; - decision = new DownloadDecision(remoteEpisode, new Rejection("Unexpected error processing release")); + var remoteAlbum = new RemoteAlbum { Release = report }; + decision = new DownloadDecision(remoteAlbum, new Rejection("Unexpected error processing release")); } reportNumber++; @@ -120,30 +167,41 @@ namespace NzbDrone.Core.DecisionEngine } } - private DownloadDecision GetDecisionForReport(RemoteEpisode remoteEpisode, SearchCriteriaBase searchCriteria = null) + private DownloadDecision GetDecisionForReport(RemoteAlbum remoteAlbum, 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, remoteAlbum, searchCriteria)) + .Where(c => c != null) + .ToArray(); - return new DownloadDecision(remoteEpisode, reasons.ToArray()); + if (reasons.Any()) break; + } + return new DownloadDecision(remoteAlbum, reasons.ToArray()); } - private Rejection EvaluateSpec(IDecisionEngineSpecification spec, RemoteEpisode remoteEpisode, SearchCriteriaBase searchCriteriaBase = null) + private Rejection EvaluateSpec(IDecisionEngineSpecification spec, RemoteAlbum remoteAlbum, SearchCriteriaBase searchCriteriaBase = null) { try { - var result = spec.IsSatisfiedBy(remoteEpisode, searchCriteriaBase); + var result = spec.IsSatisfiedBy(remoteAlbum, searchCriteriaBase); if (!result.Accepted) { return new Rejection(result.Reason, spec.Type); } } + catch (NotImplementedException) + { + _logger.Trace("Spec " + spec.GetType().Name + " not implemented."); + } catch (Exception e) { - e.Data.Add("report", remoteEpisode.Release.ToJson()); - e.Data.Add("parsed", remoteEpisode.ParsedEpisodeInfo.ToJson()); - _logger.Error(e, "Couldn't evaluate decision on {0}", remoteEpisode.Release.Title); + e.Data.Add("report", remoteAlbum.Release.ToJson()); + e.Data.Add("parsed", remoteAlbum.ParsedAlbumInfo.ToJson()); + _logger.Error(e, "Couldn't evaluate decision on {0}", remoteAlbum.Release.Title); return new Rejection($"{spec.GetType().Name}: {e.Message}"); } diff --git a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionPriorizationService.cs b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionPriorizationService.cs index 33fc32f5d..a8e683e77 100644 --- a/src/NzbDrone.Core/DecisionEngine/DownloadDecisionPriorizationService.cs +++ b/src/NzbDrone.Core/DecisionEngine/DownloadDecisionPriorizationService.cs @@ -1,5 +1,6 @@ -using System.Linq; +using System.Linq; using System.Collections.Generic; +using NzbDrone.Core.Configuration; using NzbDrone.Core.Profiles.Delay; namespace NzbDrone.Core.DecisionEngine @@ -11,22 +12,24 @@ namespace NzbDrone.Core.DecisionEngine public class DownloadDecisionPriorizationService : IPrioritizeDownloadDecision { + private readonly IConfigService _configService; private readonly IDelayProfileService _delayProfileService; - public DownloadDecisionPriorizationService(IDelayProfileService delayProfileService) + public DownloadDecisionPriorizationService(IConfigService configService, IDelayProfileService delayProfileService) { + _configService = configService; _delayProfileService = delayProfileService; } public List PrioritizeDecisions(List decisions) { - return decisions.Where(c => c.RemoteEpisode.Series != null) - .GroupBy(c => c.RemoteEpisode.Series.Id, (seriesId, downloadDecisions) => + return decisions.Where(c => c.RemoteAlbum.DownloadAllowed) + .GroupBy(c => c.RemoteAlbum.Artist.Id, (artistId, downloadDecisions) => { - return downloadDecisions.OrderByDescending(decision => decision, new DownloadDecisionComparer(_delayProfileService)); + return downloadDecisions.OrderByDescending(decision => decision, new DownloadDecisionComparer(_configService, _delayProfileService)); }) .SelectMany(c => c) - .Union(decisions.Where(c => c.RemoteEpisode.Series == null)) + .Union(decisions.Where(c => !c.RemoteAlbum.DownloadAllowed)) .ToList(); } } diff --git a/src/NzbDrone.Core/DecisionEngine/IDecisionEngineSpecification.cs b/src/NzbDrone.Core/DecisionEngine/IDecisionEngineSpecification.cs deleted file mode 100644 index 199984734..000000000 --- a/src/NzbDrone.Core/DecisionEngine/IDecisionEngineSpecification.cs +++ /dev/null @@ -1,12 +0,0 @@ -using NzbDrone.Core.IndexerSearch.Definitions; -using NzbDrone.Core.Parser.Model; - -namespace NzbDrone.Core.DecisionEngine -{ - public interface IDecisionEngineSpecification - { - RejectionType Type { get; } - - Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria); - } -} diff --git a/src/NzbDrone.Core/DecisionEngine/QualityUpgradableSpecification.cs b/src/NzbDrone.Core/DecisionEngine/QualityUpgradableSpecification.cs deleted file mode 100644 index 22c4824af..000000000 --- a/src/NzbDrone.Core/DecisionEngine/QualityUpgradableSpecification.cs +++ /dev/null @@ -1,72 +0,0 @@ -using NLog; -using NzbDrone.Core.Profiles; -using NzbDrone.Core.Qualities; - -namespace NzbDrone.Core.DecisionEngine -{ - public interface IQualityUpgradableSpecification - { - bool IsUpgradable(Profile profile, QualityModel currentQuality, QualityModel newQuality = null); - bool CutoffNotMet(Profile profile, QualityModel currentQuality, QualityModel newQuality = null); - bool IsRevisionUpgrade(QualityModel currentQuality, QualityModel newQuality); - } - - public class QualityUpgradableSpecification : IQualityUpgradableSpecification - { - private readonly Logger _logger; - - public QualityUpgradableSpecification(Logger logger) - { - _logger = logger; - } - - public bool IsUpgradable(Profile profile, QualityModel currentQuality, QualityModel newQuality = null) - { - if (newQuality != null) - { - int compare = new QualityModelComparer(profile).Compare(newQuality, currentQuality); - if (compare <= 0) - { - return false; - } - - if (IsRevisionUpgrade(currentQuality, newQuality)) - { - return true; - } - } - - return true; - } - - public bool CutoffNotMet(Profile profile, QualityModel currentQuality, QualityModel newQuality = null) - { - var compare = new QualityModelComparer(profile).Compare(currentQuality.Quality, profile.Cutoff); - - if (compare < 0) - { - return true; - } - - if (newQuality != null && IsRevisionUpgrade(currentQuality, newQuality)) - { - return true; - } - - return false; - - } - - public bool IsRevisionUpgrade(QualityModel currentQuality, QualityModel newQuality) - { - var compare = newQuality.Revision.CompareTo(currentQuality.Revision); - - if (currentQuality.Quality == newQuality.Quality && compare > 0) - { - return true; - } - - return false; - } - } -} diff --git a/src/NzbDrone.Core/DecisionEngine/SameEpisodesSpecification.cs b/src/NzbDrone.Core/DecisionEngine/SameEpisodesSpecification.cs deleted file mode 100644 index 65bf4f1ec..000000000 --- a/src/NzbDrone.Core/DecisionEngine/SameEpisodesSpecification.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.DecisionEngine -{ - public class SameEpisodesSpecification - { - private readonly IEpisodeService _episodeService; - - public SameEpisodesSpecification(IEpisodeService episodeService) - { - _episodeService = episodeService; - } - - public bool IsSatisfiedBy(List episodes) - { - var episodeIds = episodes.SelectList(e => e.Id); - var episodeFileIds = episodes.Where(c => c.EpisodeFileId != 0).Select(c => c.EpisodeFileId).Distinct(); - - foreach (var episodeFileId in episodeFileIds) - { - var episodesInFile = _episodeService.GetEpisodesByFileId(episodeFileId); - - if (episodesInFile.Select(e => e.Id).Except(episodeIds).Any()) - { - return false; - } - } - - return true; - } - } -} diff --git a/src/NzbDrone.Core/DecisionEngine/SpecificationPriority.cs b/src/NzbDrone.Core/DecisionEngine/SpecificationPriority.cs new file mode 100644 index 000000000..2d720c4b9 --- /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..294ae477b 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/AcceptableSizeSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/AcceptableSizeSpecification.cs @@ -4,7 +4,6 @@ using NzbDrone.Common.Extensions; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Qualities; -using NzbDrone.Core.Tv; using System.Collections.Generic; namespace NzbDrone.Core.DecisionEngine.Specifications @@ -12,29 +11,22 @@ namespace NzbDrone.Core.DecisionEngine.Specifications public class AcceptableSizeSpecification : IDecisionEngineSpecification { private readonly IQualityDefinitionService _qualityDefinitionService; - private readonly IEpisodeService _episodeService; private readonly Logger _logger; - public AcceptableSizeSpecification(IQualityDefinitionService qualityDefinitionService, IEpisodeService episodeService, Logger logger) + public AcceptableSizeSpecification(IQualityDefinitionService qualityDefinitionService, Logger logger) { _qualityDefinitionService = qualityDefinitionService; - _episodeService = episodeService; _logger = logger; } + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Permanent; - - public Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) + + public Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria) { _logger.Debug("Beginning size check for: {0}", subject); - var quality = subject.ParsedEpisodeInfo.Quality.Quality; - - if (subject.ParsedEpisodeInfo.Special) - { - _logger.Debug("Special release found, skipping size check."); - return Decision.Accept(); - } + var quality = subject.ParsedAlbumInfo.Quality.Quality; if (subject.Release.Size == 0) { @@ -43,20 +35,22 @@ namespace NzbDrone.Core.DecisionEngine.Specifications } var qualityDefinition = _qualityDefinitionService.Get(quality); + if (qualityDefinition.MinSize.HasValue) { - var minSize = qualityDefinition.MinSize.Value.Megabytes(); + var minSize = qualityDefinition.MinSize.Value.Kilobits(); + var minReleaseDuration = subject.Albums.Select(a => a.AlbumReleases.Value.Where(r => r.Monitored || a.AnyReleaseOk).Select(r => r.Duration).Min()).Sum() / 1000; - //Multiply maxSize by Series.Runtime - minSize = minSize * subject.Series.Runtime * subject.Episodes.Count; + //Multiply minSize by smallest release duration + minSize = minSize * minReleaseDuration; //If the parsed size is smaller than minSize we don't want it if (subject.Release.Size < minSize) { - var runtimeMessage = subject.Episodes.Count == 1 ? $"{subject.Series.Runtime}min" : $"{subject.Episodes.Count}x {subject.Series.Runtime}min"; + var runtimeMessage = $"{minReleaseDuration}sec"; _logger.Debug("Item: {0}, Size: {1} is smaller than minimum allowed size ({2} bytes for {3}), rejecting.", subject, subject.Release.Size, minSize, runtimeMessage); - return Decision.Reject("{0} is smaller than minimum allowed {1} (for {2})", subject.Release.Size.SizeSuffix(), minSize.SizeSuffix(), runtimeMessage); + return Decision.Reject("{0} is smaller than minimum allowed {1}", subject.Release.Size.SizeSuffix(), minSize.SizeSuffix()); } } if (!qualityDefinition.MaxSize.HasValue || qualityDefinition.MaxSize.Value == 0) @@ -65,42 +59,19 @@ namespace NzbDrone.Core.DecisionEngine.Specifications } else { - var maxSize = qualityDefinition.MaxSize.Value.Megabytes(); - - //Multiply maxSize by Series.Runtime - maxSize = maxSize * subject.Series.Runtime * subject.Episodes.Count; - - if (subject.Episodes.Count == 1) - { - Episode episode = subject.Episodes.First(); - List seasonEpisodes; - - var seasonSearchCriteria = searchCriteria as SeasonSearchCriteria; - if (seasonSearchCriteria != null && !seasonSearchCriteria.Series.UseSceneNumbering && seasonSearchCriteria.Episodes.Any(v => v.Id == episode.Id)) - { - seasonEpisodes = (searchCriteria as SeasonSearchCriteria).Episodes; - } - else - { - seasonEpisodes = _episodeService.GetEpisodesBySeason(episode.SeriesId, episode.SeasonNumber); - } - - //Ensure that this is either the first episode - //or is the last episode in a season that has 10 or more episodes - if (seasonEpisodes.First().Id == episode.Id || (seasonEpisodes.Count() >= 10 && seasonEpisodes.Last().Id == episode.Id)) - { - _logger.Debug("Possible double episode, doubling allowed size."); - maxSize = maxSize * 2; - } - } + var maxSize = qualityDefinition.MaxSize.Value.Kilobits(); + var maxReleaseDuration = subject.Albums.Select(a => a.AlbumReleases.Value.Where(r => r.Monitored || a.AnyReleaseOk).Select(r => r.Duration).Max()).Sum() / 1000; + + //Multiply maxSize by Album.Duration + maxSize = maxSize * maxReleaseDuration; //If the parsed size is greater than maxSize we don't want it if (subject.Release.Size > maxSize) { - var runtimeMessage = subject.Episodes.Count == 1 ? $"{subject.Series.Runtime}min" : $"{subject.Episodes.Count}x {subject.Series.Runtime}min"; + var runtimeMessage = $"{maxReleaseDuration}sec"; - _logger.Debug("Item: {0}, Size: {1} is greater than maximum allowed size ({2} for {3}), rejecting.", subject, subject.Release.Size, maxSize, runtimeMessage); - return Decision.Reject("{0} is larger than maximum allowed {1} (for {2})", subject.Release.Size.SizeSuffix(), maxSize.SizeSuffix(), runtimeMessage); + _logger.Debug("Item: {0}, Size: {1} is greater than maximum allowed size ({2} bytes for {3}), rejecting.", subject, subject.Release.Size, maxSize, runtimeMessage); + return Decision.Reject("{0} is larger than maximum allowed {1}", subject.Release.Size.SizeSuffix(), maxSize.SizeSuffix()); } } diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/AlreadyImportedSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/AlreadyImportedSpecification.cs new file mode 100644 index 000000000..9e14277e2 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/AlreadyImportedSpecification.cs @@ -0,0 +1,106 @@ +using System; +using System.Linq; +using NLog; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.History; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.DecisionEngine.Specifications +{ + public class AlreadyImportedSpecification : IDecisionEngineSpecification + { + private readonly IHistoryService _historyService; + private readonly IConfigService _configService; + private readonly IMediaFileService _mediaFileService; + private readonly Logger _logger; + + public AlreadyImportedSpecification(IHistoryService historyService, + IConfigService configService, + IMediaFileService mediaFileService, + Logger logger) + { + _historyService = historyService; + _mediaFileService = mediaFileService; + _configService = configService; + _logger = logger; + } + + public SpecificationPriority Priority => SpecificationPriority.Database; + public RejectionType Type => RejectionType.Permanent; + + public Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria) + { + var cdhEnabled = _configService.EnableCompletedDownloadHandling; + + if (!cdhEnabled) + { + _logger.Debug("Skipping already imported check because CDH is disabled"); + return Decision.Accept(); + } + + _logger.Debug("Performing already imported check on report"); + foreach (var album in subject.Albums) + { + var trackFiles = _mediaFileService.GetFilesByAlbum(album.Id); + + if (trackFiles.Count() == 0) + { + _logger.Debug("Skipping already imported check for album without files"); + continue; + } + + var historyForAlbum = _historyService.GetByAlbum(album.Id, null); + var lastGrabbed = historyForAlbum.FirstOrDefault(h => h.EventType == HistoryEventType.Grabbed); + + if (lastGrabbed == null) + { + continue; + } + + var imported = historyForAlbum.FirstOrDefault(h => + h.EventType == HistoryEventType.DownloadImported && + h.DownloadId == lastGrabbed.DownloadId); + + if (imported == null) + { + continue; + } + + // This is really only a guard against redownloading the same release over + // and over when the grabbed and imported qualities do not match, if they do + // match skip this check. + if (lastGrabbed.Quality.Equals(imported.Quality)) + { + continue; + } + + var release = subject.Release; + + if (release.DownloadProtocol == DownloadProtocol.Torrent) + { + var torrentInfo = release as TorrentInfo; + + if (torrentInfo?.InfoHash != null && torrentInfo.InfoHash.ToUpper() == lastGrabbed.DownloadId) + { + _logger.Debug("Has same torrent hash as a grabbed and imported release"); + return Decision.Reject("Has same torrent hash as a grabbed and imported release"); + } + } + + // Only based on title because a release with the same title on another indexer/released at + // a different time very likely has the exact same content and we don't need to also try it. + + if (release.Title.Equals(lastGrabbed.SourceTitle, StringComparison.InvariantCultureIgnoreCase)) + { + _logger.Debug("Has same release name as a grabbed and imported release"); + return Decision.Reject("Has same release name as a grabbed and imported release"); + } + } + + return Decision.Accept(); + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/AnimeVersionUpgradeSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/AnimeVersionUpgradeSpecification.cs deleted file mode 100644 index c2f93f7c0..000000000 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/AnimeVersionUpgradeSpecification.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Linq; -using NLog; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.IndexerSearch.Definitions; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.DecisionEngine.Specifications -{ - public class AnimeVersionUpgradeSpecification : IDecisionEngineSpecification - { - private readonly QualityUpgradableSpecification _qualityUpgradableSpecification; - private readonly Logger _logger; - - public AnimeVersionUpgradeSpecification(QualityUpgradableSpecification qualityUpgradableSpecification, Logger logger) - { - _qualityUpgradableSpecification = qualityUpgradableSpecification; - _logger = logger; - } - - public RejectionType Type => RejectionType.Permanent; - - public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) - { - var releaseGroup = subject.ParsedEpisodeInfo.ReleaseGroup; - - if (subject.Series.SeriesType != SeriesTypes.Anime) - { - return Decision.Accept(); - } - - foreach (var file in subject.Episodes.Where(c => c.EpisodeFileId != 0).Select(c => c.EpisodeFile.Value)) - { - if (_qualityUpgradableSpecification.IsRevisionUpgrade(file.Quality, subject.ParsedEpisodeInfo.Quality)) - { - if (file.ReleaseGroup.IsNullOrWhiteSpace()) - { - _logger.Debug("Unable to compare release group, existing file's release group is unknown"); - return Decision.Reject("Existing release group is unknown"); - } - - if (releaseGroup.IsNullOrWhiteSpace()) - { - _logger.Debug("Unable to compare release group, release's release group is unknown"); - return Decision.Reject("Release group is unknown"); - } - - if (file.ReleaseGroup != releaseGroup) - { - _logger.Debug("Existing Release group is: {0} - release's release group is: {1}", file.ReleaseGroup, releaseGroup); - return Decision.Reject("{0} does not match existing release group {1}", releaseGroup, file.ReleaseGroup); - } - } - } - - return Decision.Accept(); - } - } -} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/BlacklistSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/BlacklistSpecification.cs index 18b216263..0f471dad9 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/BlacklistSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/BlacklistSpecification.cs @@ -16,11 +16,12 @@ 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)) + public Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria) + { + if (_blacklistService.Blacklisted(subject.Artist.Id, subject.Release)) { _logger.Debug("{0} is blacklisted, rejecting.", subject.Release.Title); return Decision.Reject("Release is blacklisted"); diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/BlockedIndexerSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/BlockedIndexerSpecification.cs new file mode 100644 index 000000000..3ec06fe7e --- /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(RemoteAlbum 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 6dfdbc64c..0d04563c3 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/CutoffSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/CutoffSpecification.cs @@ -1,34 +1,74 @@ +using System; using System.Linq; using NLog; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Music; +using NzbDrone.Common.Cache; +using NzbDrone.Core.Profiles.Releases; +using NzbDrone.Common.Extensions; namespace NzbDrone.Core.DecisionEngine.Specifications { public class CutoffSpecification : IDecisionEngineSpecification { - private readonly QualityUpgradableSpecification _qualityUpgradableSpecification; + private readonly UpgradableSpecification _upgradableSpecification; + private readonly IMediaFileService _mediaFileService; + private readonly ITrackService _trackService; private readonly Logger _logger; - - public CutoffSpecification(QualityUpgradableSpecification qualityUpgradableSpecification, Logger logger) + private readonly ICached _missingFilesCache; + private readonly IPreferredWordService _preferredWordServiceCalculator; + + public CutoffSpecification(UpgradableSpecification upgradableSpecification, + Logger logger, + ICacheManager cacheManager, + IMediaFileService mediaFileService, + IPreferredWordService preferredWordServiceCalculator, + ITrackService trackService) { - _qualityUpgradableSpecification = qualityUpgradableSpecification; + _upgradableSpecification = upgradableSpecification; + _mediaFileService = mediaFileService; + _trackService = trackService; + _missingFilesCache = cacheManager.GetCache(GetType()); + _preferredWordServiceCalculator = preferredWordServiceCalculator; _logger = logger; } + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Permanent; - public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) + public virtual Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria) { - foreach (var file in subject.Episodes.Where(c => c.EpisodeFileId != 0).Select(c => c.EpisodeFile.Value)) + var qualityProfile = subject.Artist.QualityProfile.Value; + + foreach (var album in subject.Albums) { - _logger.Debug("Comparing file quality with report. Existing file is {0}", file.Quality); + var tracksMissing = _missingFilesCache.Get(album.Id.ToString(), () => _trackService.TracksWithoutFiles(album.Id).Any(), + TimeSpan.FromSeconds(30)); + var trackFiles = _mediaFileService.GetFilesByAlbum(album.Id); - - if (!_qualityUpgradableSpecification.CutoffNotMet(subject.Series.Profile, file.Quality, subject.ParsedEpisodeInfo.Quality)) + if (!tracksMissing && trackFiles.Any()) { - _logger.Debug("Cutoff already met, rejecting."); - return Decision.Reject("Existing file meets cutoff: {0}", subject.Series.Profile.Value.Cutoff); + // Get a distinct list of all current track qualities for a given album + var currentQualities = trackFiles.Select(c => c.Quality).Distinct().ToList(); + + _logger.Debug("Comparing file quality with report. Existing files contain {0}", currentQualities.ConcatToString()); + + if (!_upgradableSpecification.CutoffNotMet(qualityProfile, + currentQualities, + _preferredWordServiceCalculator.Calculate(subject.Artist, trackFiles[0].GetSceneOrFileName()), + subject.ParsedAlbumInfo.Quality, + subject.PreferredWordScore)) + { + _logger.Debug("Cutoff already met by existing files, rejecting."); + + var qualityCutoffIndex = qualityProfile.GetIndex(qualityProfile.Cutoff); + var qualityCutoff = qualityProfile.Items[qualityCutoffIndex.Index]; + + return Decision.Reject("Existing files meets cutoff: {0}", qualityCutoff); + } + } } diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/DiscographySpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/DiscographySpecification.cs new file mode 100644 index 000000000..e11ed8761 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/DiscographySpecification.cs @@ -0,0 +1,38 @@ +using System; +using NLog; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Parser.Model; +using System.Linq; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.DecisionEngine.Specifications +{ + public class DiscographySpecification : IDecisionEngineSpecification + { + private readonly Logger _logger; + + public DiscographySpecification(Logger logger) + { + _logger = logger; + } + + public SpecificationPriority Priority => SpecificationPriority.Default; + public RejectionType Type => RejectionType.Permanent; + + public virtual Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria) + { + if (subject.ParsedAlbumInfo.Discography) + { + _logger.Debug("Checking if all albums in discography release have released. {0}", subject.Release.Title); + + if (subject.Albums.Any(e => !e.ReleaseDate.HasValue || e.ReleaseDate.Value.After(DateTime.UtcNow))) + { + _logger.Debug("Discography release {0} rejected. All albums haven't released yet.", subject.Release.Title); + return Decision.Reject("Discography release rejected. All albums haven't released yet."); + } + } + + return Decision.Accept(); + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/EarlyReleaseSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/EarlyReleaseSpecification.cs new file mode 100644 index 000000000..73fb277b2 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/EarlyReleaseSpecification.cs @@ -0,0 +1,68 @@ +using System.Linq; +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 EarlyReleaseSpecification : IDecisionEngineSpecification + { + private readonly IIndexerFactory _indexerFactory; + private readonly Logger _logger; + + public EarlyReleaseSpecification(IIndexerFactory indexerFactory, Logger logger) + { + _indexerFactory = indexerFactory; + _logger = logger; + } + + public SpecificationPriority Priority => SpecificationPriority.Default; + public RejectionType Type => RejectionType.Permanent; + + public Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria) + { + var releaseInfo = subject.Release; + + if (releaseInfo == null || releaseInfo.IndexerId == 0) + { + return Decision.Accept(); + } + + IndexerDefinition indexer; + try + { + indexer = _indexerFactory.Get(subject.Release.IndexerId); + } + catch (ModelNotFoundException) + { + _logger.Debug("Indexer with id {0} does not exist, skipping early release check", subject.Release.IndexerId); + return Decision.Accept(); + } + + var indexerSettings = indexer.Settings as IIndexerSettings; + + if (subject.Albums.Count != 1 || indexerSettings?.EarlyReleaseLimit == null) + { + return Decision.Accept(); + } + + var releaseDate = subject.Albums.First().ReleaseDate; + + if (releaseDate == null) return Decision.Accept(); + + var isEarly = (releaseDate.Value > subject.Release.PublishDate.AddDays(indexerSettings.EarlyReleaseLimit.Value)); + + if (isEarly) + { + var message = $"Release published date, {subject.Release.PublishDate.ToShortDateString()}, is outside of {indexerSettings.EarlyReleaseLimit.Value} day early grab limit allowed by user"; + + _logger.Debug(message); + return Decision.Reject(message); + } + + return Decision.Accept(); + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/FullSeasonSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/FullSeasonSpecification.cs deleted file mode 100644 index 023b6be60..000000000 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/FullSeasonSpecification.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using NLog; -using NzbDrone.Core.IndexerSearch.Definitions; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Common.Extensions; -using System.Linq; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.DecisionEngine.Specifications -{ - public class FullSeasonSpecification : IDecisionEngineSpecification - { - private readonly Logger _logger; - private readonly IEpisodeService _episodeService; - - public FullSeasonSpecification(Logger logger, IEpisodeService episodeService) - { - _logger = logger; - _episodeService = episodeService; - } - - public RejectionType Type => RejectionType.Permanent; - - public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) - { - if (subject.ParsedEpisodeInfo.FullSeason) - { - _logger.Debug("Checking if all episodes in full season release have aired. {0}", subject.Release.Title); - - if (subject.Episodes.Any(e => !e.AirDateUtc.HasValue || e.AirDateUtc.Value.After(DateTime.UtcNow))) - { - _logger.Debug("Full season release {0} rejected. All episodes haven't aired yet.", subject.Release.Title); - return Decision.Reject("Full season release rejected. All episodes haven't aired yet."); - } - } - - return Decision.Accept(); - } - } -} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/IDecisionEngineSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/IDecisionEngineSpecification.cs new file mode 100644 index 000000000..a9eec669f --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/IDecisionEngineSpecification.cs @@ -0,0 +1,14 @@ +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.DecisionEngine.Specifications +{ + public interface IDecisionEngineSpecification + { + RejectionType Type { get; } + + SpecificationPriority Priority { get; } + + Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria); + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/LanguageSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/LanguageSpecification.cs deleted file mode 100644 index 9f7f75038..000000000 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/LanguageSpecification.cs +++ /dev/null @@ -1,33 +0,0 @@ -using NLog; -using NzbDrone.Core.IndexerSearch.Definitions; -using NzbDrone.Core.Parser.Model; - -namespace NzbDrone.Core.DecisionEngine.Specifications -{ - public class LanguageSpecification : IDecisionEngineSpecification - { - private readonly Logger _logger; - - public LanguageSpecification(Logger logger) - { - _logger = logger; - } - - 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) - { - _logger.Debug("Report Language: {0} rejected because it is not wanted, wanted {1}", subject.ParsedEpisodeInfo.Language, wantedLanguage); - return Decision.Reject("{0} is wanted, but found {1}", wantedLanguage, subject.ParsedEpisodeInfo.Language); - } - - return Decision.Accept(); - } - } -} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/MaximumSizeSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/MaximumSizeSpecification.cs new file mode 100644 index 000000000..04f12ece0 --- /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(RemoteAlbum 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 (subject.Release.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..3ec9b900e 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,9 +17,10 @@ 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) + public virtual Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria) { if (subject.Release.DownloadProtocol != Indexers.DownloadProtocol.Usenet) { @@ -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..825ca3c3d 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/NotSampleSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/NotSampleSpecification.cs @@ -1,4 +1,5 @@ -using NLog; +using System; +using NLog; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser.Model; @@ -8,6 +9,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications { private readonly Logger _logger; + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Permanent; public NotSampleSpecification(Logger logger) @@ -15,15 +17,16 @@ namespace NzbDrone.Core.DecisionEngine.Specifications _logger = logger; } - public Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) + public Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria) { - if (subject.Release.Title.ToLower().Contains("sample") && subject.Release.Size < 70.Megabytes()) - { - _logger.Debug("Sample release, rejecting."); - return Decision.Reject("Sample"); - } + if (subject.Release.Title.ToLower().Contains("sample") && subject.Release.Size < 20.Megabytes()) + { + _logger.Debug("Sample release, rejecting."); + return Decision.Reject("Sample"); + } - return Decision.Accept(); + return Decision.Accept(); } + } } diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/ProtocolSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/ProtocolSpecification.cs index 008e58812..dae103196 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/ProtocolSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/ProtocolSpecification.cs @@ -18,22 +18,23 @@ 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) + public virtual Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria) { - var delayProfile = _delayProfileService.BestForTags(subject.Series.Tags); + var delayProfile = _delayProfileService.BestForTags(subject.Artist.Tags); if (subject.Release.DownloadProtocol == DownloadProtocol.Usenet && !delayProfile.EnableUsenet) { - _logger.Debug("[{0}] Usenet is not enabled for this series", subject.Release.Title); - return Decision.Reject("Usenet is not enabled for this series"); + _logger.Debug("[{0}] Usenet is not enabled for this artist", subject.Release.Title); + return Decision.Reject("Usenet is not enabled for this artist"); } if (subject.Release.DownloadProtocol == DownloadProtocol.Torrent && !delayProfile.EnableTorrent) { - _logger.Debug("[{0}] Torrent is not enabled for this series", subject.Release.Title); - return Decision.Reject("Torrent is not enabled for this series"); + _logger.Debug("[{0}] Torrent is not enabled for this artist", subject.Release.Title); + return Decision.Reject("Torrent is not enabled for this artist"); } return Decision.Accept(); diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/QualityAllowedByProfileSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/QualityAllowedByProfileSpecification.cs index 7913e0e7e..73bb23fea 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/QualityAllowedByProfileSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/QualityAllowedByProfileSpecification.cs @@ -1,3 +1,4 @@ +using System; using NLog; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser.Model; @@ -13,15 +14,21 @@ 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) + public virtual Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria) { - _logger.Debug("Checking if report meets quality requirements. {0}", subject.ParsedEpisodeInfo.Quality); - if (!subject.Series.Profile.Value.Items.Exists(v => v.Allowed && v.Quality == subject.ParsedEpisodeInfo.Quality.Quality)) + _logger.Debug("Checking if report meets quality requirements. {0}", subject.ParsedAlbumInfo.Quality); + + var profile = subject.Artist.QualityProfile.Value; + var qualityIndex = profile.GetIndex(subject.ParsedAlbumInfo.Quality.Quality); + var qualityOrGroup = profile.Items[qualityIndex.Index]; + + if (!qualityOrGroup.Allowed) { - _logger.Debug("Quality {0} rejected by Series' quality profile", subject.ParsedEpisodeInfo.Quality); - return Decision.Reject("{0} is not wanted in profile", subject.ParsedEpisodeInfo.Quality.Quality); + _logger.Debug("Quality {0} rejected by Artist's quality profile", subject.ParsedAlbumInfo.Quality); + return Decision.Reject("{0} is not wanted in profile", subject.ParsedAlbumInfo.Quality.Quality); } return Decision.Accept(); diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/QueueSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/QueueSpecification.cs index 6f3ec1bea..640b5870f 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/QueueSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/QueueSpecification.cs @@ -1,7 +1,10 @@ +using System.Collections.Generic; using System.Linq; using NLog; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Profiles.Releases; +using NzbDrone.Core.Qualities; using NzbDrone.Core.Queue; namespace NzbDrone.Core.DecisionEngine.Specifications @@ -9,46 +12,75 @@ namespace NzbDrone.Core.DecisionEngine.Specifications public class QueueSpecification : IDecisionEngineSpecification { private readonly IQueueService _queueService; - private readonly QualityUpgradableSpecification _qualityUpgradableSpecification; + private readonly UpgradableSpecification _upgradableSpecification; + private readonly IPreferredWordService _preferredWordServiceCalculator; private readonly Logger _logger; public QueueSpecification(IQueueService queueService, - QualityUpgradableSpecification qualityUpgradableSpecification, + UpgradableSpecification upgradableSpecification, + IPreferredWordService preferredWordServiceCalculator, Logger logger) { _queueService = queueService; - _qualityUpgradableSpecification = qualityUpgradableSpecification; + _upgradableSpecification = upgradableSpecification; + _preferredWordServiceCalculator = preferredWordServiceCalculator; _logger = logger; } + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Permanent; - public Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) + public Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria) { - var queue = _queueService.GetQueue() - .Select(q => q.RemoteEpisode).ToList(); + var queue = _queueService.GetQueue(); + var matchingAlbum = queue.Where(q => q.RemoteAlbum != null && + q.RemoteAlbum.Artist != null && + q.RemoteAlbum.Artist.Id == subject.Artist.Id && + q.RemoteAlbum.Albums.Select(e => e.Id).Intersect(subject.Albums.Select(e => e.Id)).Any()) + .ToList(); - var matchingSeries = queue.Where(q => q.Series.Id == subject.Series.Id); - var matchingEpisode = matchingSeries.Where(q => q.Episodes.Select(e => e.Id).Intersect(subject.Episodes.Select(e => e.Id)).Any()); - foreach (var remoteEpisode in matchingEpisode) + foreach (var queueItem in matchingAlbum) { - _logger.Debug("Checking if existing release in queue meets cutoff. Queued quality is: {0}", remoteEpisode.ParsedEpisodeInfo.Quality); + var remoteAlbum = queueItem.RemoteAlbum; + var qualityProfile = subject.Artist.QualityProfile.Value; + + _logger.Debug("Checking if existing release in queue meets cutoff. Queued quality is: {0}", remoteAlbum.ParsedAlbumInfo.Quality); + var queuedItemPreferredWordScore = _preferredWordServiceCalculator.Calculate(subject.Artist, queueItem.Title); + + if (!_upgradableSpecification.CutoffNotMet(qualityProfile, + new List { remoteAlbum.ParsedAlbumInfo.Quality }, + queuedItemPreferredWordScore, + subject.ParsedAlbumInfo.Quality, + subject.PreferredWordScore)) - if (!_qualityUpgradableSpecification.CutoffNotMet(subject.Series.Profile, remoteEpisode.ParsedEpisodeInfo.Quality, subject.ParsedEpisodeInfo.Quality)) { - return Decision.Reject("Quality for release in queue already meets cutoff: {0}", remoteEpisode.ParsedEpisodeInfo.Quality); + return Decision.Reject("Release in queue already meets cutoff: {0}", remoteAlbum.ParsedAlbumInfo.Quality); } - _logger.Debug("Checking if release is higher quality than queued release. Queued quality is: {0}", remoteEpisode.ParsedEpisodeInfo.Quality); + _logger.Debug("Checking if release is higher quality than queued release. Queued: {0}", remoteAlbum.ParsedAlbumInfo.Quality); - if (!_qualityUpgradableSpecification.IsUpgradable(subject.Series.Profile, remoteEpisode.ParsedEpisodeInfo.Quality, subject.ParsedEpisodeInfo.Quality)) + if (!_upgradableSpecification.IsUpgradable(qualityProfile, + new List { remoteAlbum.ParsedAlbumInfo.Quality }, + queuedItemPreferredWordScore, + subject.ParsedAlbumInfo.Quality, + subject.PreferredWordScore)) { - return Decision.Reject("Quality for release in queue is of equal or higher preference: {0}", remoteEpisode.ParsedEpisodeInfo.Quality); + return Decision.Reject("Release in queue is of equal or higher preference: {0}", remoteAlbum.ParsedAlbumInfo.Quality); + } + + _logger.Debug("Checking if profiles allow upgrading. Queued: {0}", remoteAlbum.ParsedAlbumInfo.Quality); + + if (!_upgradableSpecification.IsUpgradeAllowed(qualityProfile, + new List { remoteAlbum.ParsedAlbumInfo.Quality }, + subject.ParsedAlbumInfo.Quality)) + { + return Decision.Reject("Another release is queued and the Quality profile does not allow upgrades"); } } return Decision.Accept(); + } } } diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/RawDiskSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/RawDiskSpecification.cs index 7f278cb7e..a6a6acd54 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/RawDiskSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/RawDiskSpecification.cs @@ -8,9 +8,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications { public class RawDiskSpecification : IDecisionEngineSpecification { - private static readonly string[] _dvdContainerTypes = new[] { "vob", "iso" }; - - private static readonly string[] _blurayContainerTypes = new[] { "m2ts" }; + private static readonly string[] _cdContainerTypes = new[] { "vob", "iso" }; private readonly Logger _logger; @@ -19,26 +17,21 @@ 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) + public virtual Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria) { if (subject.Release == null || subject.Release.Container.IsNullOrWhiteSpace()) { return Decision.Accept(); } - if (_dvdContainerTypes.Contains(subject.Release.Container.ToLower())) - { - _logger.Debug("Release contains raw DVD, rejecting."); - return Decision.Reject("Raw DVD release"); - } - - if (_blurayContainerTypes.Contains(subject.Release.Container.ToLower())) - { - _logger.Debug("Release contains raw Bluray, rejecting."); - return Decision.Reject("Raw Bluray release"); - } + if (_cdContainerTypes.Contains(subject.Release.Container.ToLower())) + { + _logger.Debug("Release contains raw CD, rejecting."); + return Decision.Reject("Raw CD release"); + } return Decision.Accept(); } diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/ReleaseRestrictionsSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/ReleaseRestrictionsSpecification.cs index 9fb8c13f5..025d9bcad 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/ReleaseRestrictionsSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/ReleaseRestrictionsSpecification.cs @@ -5,36 +5,39 @@ using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Restrictions; +using NzbDrone.Core.Profiles.Releases; namespace NzbDrone.Core.DecisionEngine.Specifications { public class ReleaseRestrictionsSpecification : IDecisionEngineSpecification { - private readonly IRestrictionService _restrictionService; private readonly Logger _logger; + private readonly IReleaseProfileService _releaseProfileService; + private readonly ITermMatcherService _termMatcherService; - public ReleaseRestrictionsSpecification(IRestrictionService restrictionService, Logger logger) + public ReleaseRestrictionsSpecification(ITermMatcherService termMatcherService, IReleaseProfileService releaseProfileService, Logger logger) { - _restrictionService = restrictionService; _logger = logger; + _releaseProfileService = releaseProfileService; + _termMatcherService = termMatcherService; } + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Permanent; - public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) + public virtual Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria) { _logger.Debug("Checking if release meets restrictions: {0}", subject); var title = subject.Release.Title; - var restrictions = _restrictionService.AllForTags(subject.Series.Tags); + var restrictions = _releaseProfileService.AllForTags(subject.Artist.Tags); var required = restrictions.Where(r => r.Required.IsNotNullOrWhiteSpace()); var ignored = restrictions.Where(r => r.Ignored.IsNotNullOrWhiteSpace()); foreach (var r in required) { - var requiredTerms = r.Required.Split(new[] {','}, StringSplitOptions.RemoveEmptyEntries).ToList(); + var requiredTerms = r.Required.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).ToList(); var foundTerms = ContainsAny(requiredTerms, title); if (foundTerms.Empty()) @@ -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 => _termMatcherService.IsMatch(t, title)).ToList(); } } } diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/RepackSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/RepackSpecification.cs new file mode 100644 index 000000000..3214c4c41 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/RepackSpecification.cs @@ -0,0 +1,67 @@ +using System; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.DecisionEngine.Specifications +{ + public class RepackSpecification : IDecisionEngineSpecification + { + private readonly IMediaFileService _mediaFileService; + private readonly UpgradableSpecification _upgradableSpecification; + private readonly Logger _logger; + + public RepackSpecification(IMediaFileService mediaFileService, UpgradableSpecification upgradableSpecification, Logger logger) + { + _mediaFileService = mediaFileService; + _upgradableSpecification = upgradableSpecification; + _logger = logger; + } + + public SpecificationPriority Priority => SpecificationPriority.Database; + public RejectionType Type => RejectionType.Permanent; + + public Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria) + { + if (!subject.ParsedAlbumInfo.Quality.Revision.IsRepack) + { + return Decision.Accept(); + } + + foreach (var album in subject.Albums) + { + var releaseGroup = subject.ParsedAlbumInfo.ReleaseGroup; + var trackFiles = _mediaFileService.GetFilesByAlbum(album.Id); + + foreach (var file in trackFiles) + { + if (_upgradableSpecification.IsRevisionUpgrade(file.Quality, subject.ParsedAlbumInfo.Quality)) + { + var fileReleaseGroup = file.ReleaseGroup; + + if (fileReleaseGroup.IsNullOrWhiteSpace()) + { + return Decision.Reject("Unable to determine release group for the existing file"); + } + + if (releaseGroup.IsNullOrWhiteSpace()) + { + return Decision.Reject("Unable to determine release group for this release"); + } + + if (!fileReleaseGroup.Equals(releaseGroup, StringComparison.InvariantCultureIgnoreCase)) + { + _logger.Debug("Release is a repack for a different release group. Release Group: {0}. File release group: {1}", releaseGroup, fileReleaseGroup); + return Decision.Reject("Release is a repack for a different release group. Release Group: {0}. File release group: {1}", releaseGroup, fileReleaseGroup); + } + } + } + + } + + return Decision.Accept(); + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/RetentionSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/RetentionSpecification.cs index 97802f871..c3deb05fd 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/RetentionSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/RetentionSpecification.cs @@ -1,4 +1,4 @@ -using NLog; +using NLog; using NzbDrone.Core.Configuration; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser.Model; @@ -16,9 +16,10 @@ 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) + public virtual Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria) { if (subject.Release.DownloadProtocol != Indexers.DownloadProtocol.Usenet) { diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DelaySpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DelaySpecification.cs index 68551c66c..5004f0d7b 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DelaySpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DelaySpecification.cs @@ -5,30 +5,39 @@ using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Profiles.Delay; using NzbDrone.Core.Qualities; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Profiles.Releases; namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync { public class DelaySpecification : IDecisionEngineSpecification { private readonly IPendingReleaseService _pendingReleaseService; - private readonly IQualityUpgradableSpecification _qualityUpgradableSpecification; + private readonly IUpgradableSpecification _upgradableSpecification; private readonly IDelayProfileService _delayProfileService; + private readonly IMediaFileService _mediaFileService; + private readonly IPreferredWordService _preferredWordServiceCalculator; private readonly Logger _logger; public DelaySpecification(IPendingReleaseService pendingReleaseService, - IQualityUpgradableSpecification qualityUpgradableSpecification, + IUpgradableSpecification qualityUpgradableSpecification, IDelayProfileService delayProfileService, + IMediaFileService mediaFileService, + IPreferredWordService preferredWordServiceCalculator, Logger logger) { _pendingReleaseService = pendingReleaseService; - _qualityUpgradableSpecification = qualityUpgradableSpecification; + _upgradableSpecification = qualityUpgradableSpecification; _delayProfileService = delayProfileService; + _mediaFileService = mediaFileService; + _preferredWordServiceCalculator = preferredWordServiceCalculator; _logger = logger; } + public SpecificationPriority Priority => SpecificationPriority.Database; public RejectionType Type => RejectionType.Temporary; - public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) + public virtual Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria) { if (searchCriteria != null && searchCriteria.UserInvokedSearch) { @@ -36,8 +45,8 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync return Decision.Accept(); } - var profile = subject.Series.Profile.Value; - var delayProfile = _delayProfileService.BestForTags(subject.Series.Tags); + var qualityProfile = subject.Artist.QualityProfile.Value; + var delayProfile = _delayProfileService.BestForTags(subject.Artist.Tags); var delay = delayProfile.GetProtocolDelay(subject.Release.DownloadProtocol); var isPreferredProtocol = subject.Release.DownloadProtocol == delayProfile.PreferredProtocol; @@ -47,19 +56,24 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync return Decision.Accept(); } - var comparer = new QualityModelComparer(profile); + var qualityComparer = new QualityModelComparer(qualityProfile); if (isPreferredProtocol) { - foreach (var file in subject.Episodes.Where(c => c.EpisodeFileId != 0).Select(c => c.EpisodeFile.Value)) + foreach (var album in subject.Albums) { - var upgradable = _qualityUpgradableSpecification.IsUpgradable(profile, file.Quality, subject.ParsedEpisodeInfo.Quality); + var trackFiles = _mediaFileService.GetFilesByAlbum(album.Id); - if (upgradable) + if (trackFiles.Any()) { - var revisionUpgrade = _qualityUpgradableSpecification.IsRevisionUpgrade(file.Quality, subject.ParsedEpisodeInfo.Quality); - - if (revisionUpgrade) + var currentQualities = trackFiles.Select(c => c.Quality).Distinct().ToList(); + + var upgradable = _upgradableSpecification.IsUpgradable(qualityProfile, + currentQualities, + _preferredWordServiceCalculator.Calculate(subject.Artist, trackFiles[0].GetSceneOrFileName()), + subject.ParsedAlbumInfo.Quality, + subject.PreferredWordScore); + if (upgradable) { _logger.Debug("New quality is a better revision for existing quality, skipping delay"); return Decision.Accept(); @@ -69,8 +83,8 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync } // If quality meets or exceeds the best allowed quality in the profile accept it immediately - var bestQualityInProfile = new QualityModel(profile.LastAllowedQuality()); - var isBestInProfile = comparer.Compare(subject.ParsedEpisodeInfo.Quality, bestQualityInProfile) >= 0; + var bestQualityInProfile = qualityProfile.LastAllowedQuality(); + var isBestInProfile = qualityComparer.Compare(subject.ParsedAlbumInfo.Quality.Quality, bestQualityInProfile) >= 0; if (isBestInProfile && isPreferredProtocol) { @@ -78,9 +92,9 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync return Decision.Accept(); } - var episodeIds = subject.Episodes.Select(e => e.Id); + var albumIds = subject.Albums.Select(e => e.Id); - var oldest = _pendingReleaseService.OldestPendingRelease(subject.Series.Id, episodeIds); + var oldest = _pendingReleaseService.OldestPendingRelease(subject.Artist.Id, albumIds.ToArray()); if (oldest != null && oldest.Release.AgeMinutes > delay) { diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DeletedTrackFileSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DeletedTrackFileSpecification.cs new file mode 100644 index 000000000..45a25ce31 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DeletedTrackFileSpecification.cs @@ -0,0 +1,74 @@ +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.Music; + +namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync +{ + public class DeletedTrackFileSpecification : IDecisionEngineSpecification + { + private readonly IDiskProvider _diskProvider; + private readonly IConfigService _configService; + private readonly IMediaFileService _albumService; + private readonly Logger _logger; + + public DeletedTrackFileSpecification(IDiskProvider diskProvider, + IConfigService configService, + IMediaFileService albumService, + Logger logger) + { + _diskProvider = diskProvider; + _configService = configService; + _albumService = albumService; + _logger = logger; + } + + public SpecificationPriority Priority => SpecificationPriority.Disk; + public RejectionType Type => RejectionType.Temporary; + + public virtual Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria) + { + if (!_configService.AutoUnmonitorPreviouslyDownloadedTracks) + { + return Decision.Accept(); + } + + if (searchCriteria != null) + { + _logger.Debug("Skipping deleted trackfile check during search"); + return Decision.Accept(); + } + + var missingTrackFiles = subject.Albums + .SelectMany(v => _albumService.GetFilesByAlbum(v.Id)) + .DistinctBy(v => v.Id) + .Where(v => IsTrackFileMissing(subject.Artist, v)) + .ToArray(); + + + if (missingTrackFiles.Any()) + { + foreach (var missingTrackFile in missingTrackFiles) + { + _logger.Trace("Track file {0} is missing from disk.", missingTrackFile.Path); + } + + _logger.Debug("Files for this album exist in the database but not on disk, will be unmonitored on next diskscan. skipping."); + return Decision.Reject("Artist is not monitored"); + } + + return Decision.Accept(); + } + + private bool IsTrackFileMissing(Artist artist, TrackFile trackFile) + { + return !_diskProvider.FileExists(trackFile.Path); + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/HistorySpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/HistorySpecification.cs index 9aa4fabf1..276b70ecb 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/HistorySpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/HistorySpecification.cs @@ -1,34 +1,41 @@ using System; +using System.Collections.Generic; using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; using NzbDrone.Core.History; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Profiles.Releases; +using NzbDrone.Core.Qualities; namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync { public class HistorySpecification : IDecisionEngineSpecification { private readonly IHistoryService _historyService; - private readonly QualityUpgradableSpecification _qualityUpgradableSpecification; + private readonly UpgradableSpecification _upgradableSpecification; private readonly IConfigService _configService; + private readonly IPreferredWordService _preferredWordServiceCalculator; private readonly Logger _logger; public HistorySpecification(IHistoryService historyService, - QualityUpgradableSpecification qualityUpgradableSpecification, + UpgradableSpecification qualityUpgradableSpecification, IConfigService configService, + IPreferredWordService preferredWordServiceCalculator, Logger logger) { _historyService = historyService; - _qualityUpgradableSpecification = qualityUpgradableSpecification; + _upgradableSpecification = qualityUpgradableSpecification; _configService = configService; + _preferredWordServiceCalculator = preferredWordServiceCalculator; _logger = logger; } + public SpecificationPriority Priority => SpecificationPriority.Database; public RejectionType Type => RejectionType.Permanent; - public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) + public virtual Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria) { if (searchCriteria != null) { @@ -39,16 +46,31 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync var cdhEnabled = _configService.EnableCompletedDownloadHandling; _logger.Debug("Performing history status check on report"); - foreach (var episode in subject.Episodes) + foreach (var album in subject.Albums) { - _logger.Debug("Checking current status of episode [{0}] in history", episode.Id); - var mostRecent = _historyService.MostRecentForEpisode(episode.Id); + _logger.Debug("Checking current status of album [{0}] in history", album.Id); + var mostRecent = _historyService.MostRecentForAlbum(album.Id); if (mostRecent != null && mostRecent.EventType == HistoryEventType.Grabbed) { var recent = mostRecent.Date.After(DateTime.UtcNow.AddHours(-12)); - var cutoffUnmet = _qualityUpgradableSpecification.CutoffNotMet(subject.Series.Profile, mostRecent.Quality, subject.ParsedEpisodeInfo.Quality); - var upgradeable = _qualityUpgradableSpecification.IsUpgradable(subject.Series.Profile, mostRecent.Quality, subject.ParsedEpisodeInfo.Quality); + // The artist will be the same as the one in history since it's the same album. + // Instead of fetching the artist from the DB reuse the known artist. + var preferredWordScore = _preferredWordServiceCalculator.Calculate(subject.Artist, mostRecent.SourceTitle); + + var cutoffUnmet = _upgradableSpecification.CutoffNotMet( + subject.Artist.QualityProfile, + new List { mostRecent.Quality }, + preferredWordScore, + subject.ParsedAlbumInfo.Quality, + subject.PreferredWordScore); + + var upgradeable = _upgradableSpecification.IsUpgradable( + subject.Artist.QualityProfile, + new List { mostRecent.Quality }, + preferredWordScore, + subject.ParsedAlbumInfo.Quality, + subject.PreferredWordScore); if (!recent && cdhEnabled) { @@ -59,7 +81,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/MonitoredAlbumSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/MonitoredAlbumSpecification.cs new file mode 100644 index 000000000..e0f26ab11 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/MonitoredAlbumSpecification.cs @@ -0,0 +1,61 @@ +using System.Linq; +using NLog; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync +{ + public class MonitoredAlbumSpecification : IDecisionEngineSpecification + { + private readonly Logger _logger; + + public MonitoredAlbumSpecification(Logger logger) + { + _logger = logger; + } + + public SpecificationPriority Priority => SpecificationPriority.Default; + public RejectionType Type => RejectionType.Permanent; + + public virtual Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria) + { + if (searchCriteria != null) + { + if (!searchCriteria.MonitoredEpisodesOnly) + { + _logger.Debug("Skipping monitored check during search"); + return Decision.Accept(); + } + } + + if (!subject.Artist.Monitored) + { + _logger.Debug("{0} is present in the DB but not tracked. Rejecting.", subject.Artist); + return Decision.Reject("Artist is not monitored"); + } + + var monitoredCount = subject.Albums.Count(album => album.Monitored); + if (monitoredCount == subject.Albums.Count) + { + return Decision.Accept(); + } + + if (subject.Albums.Count == 1) + { + _logger.Debug("Album is not monitored. Rejecting", monitoredCount, subject.Albums.Count); + return Decision.Reject("Album is not monitored"); + } + + if (monitoredCount == 0) + { + _logger.Debug("No albums in the release are monitored. Rejecting", monitoredCount, subject.Albums.Count); + } + else + { + _logger.Debug("Only {0}/{1} albums in the release are monitored. Rejecting", monitoredCount, subject.Albums.Count); + } + + return Decision.Reject("Album is not monitored"); + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/MonitoredEpisodeSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/MonitoredEpisodeSpecification.cs deleted file mode 100644 index f56f26478..000000000 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/MonitoredEpisodeSpecification.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Linq; -using NLog; -using NzbDrone.Core.IndexerSearch.Definitions; -using NzbDrone.Core.Parser.Model; - -namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync -{ - public class MonitoredEpisodeSpecification : IDecisionEngineSpecification - { - private readonly Logger _logger; - - public MonitoredEpisodeSpecification(Logger logger) - { - _logger = logger; - } - - public RejectionType Type => RejectionType.Permanent; - - public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) - { - if (searchCriteria != null) - { - if (!searchCriteria.MonitoredEpisodesOnly) - { - _logger.Debug("Skipping monitored check during search"); - return Decision.Accept(); - } - } - - if (!subject.Series.Monitored) - { - _logger.Debug("{0} is present in the DB but not tracked. skipping.", subject.Series); - return Decision.Reject("Series is not monitored"); - } - - var monitoredCount = subject.Episodes.Count(episode => episode.Monitored); - if (monitoredCount == subject.Episodes.Count) - { - return Decision.Accept(); - } - - _logger.Debug("Only {0}/{1} episodes are monitored. skipping.", monitoredCount, subject.Episodes.Count); - return Decision.Reject("Episode is not monitored"); - } - } -} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/ProperSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/ProperSpecification.cs index 0c6632d25..b52615a51 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/ProperSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/ProperSpecification.cs @@ -4,45 +4,63 @@ using NLog; using NzbDrone.Core.Configuration; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Qualities; namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync { public class ProperSpecification : IDecisionEngineSpecification { - private readonly QualityUpgradableSpecification _qualityUpgradableSpecification; + private readonly UpgradableSpecification _qualityUpgradableSpecification; private readonly IConfigService _configService; + private readonly IMediaFileService _mediaFileService; private readonly Logger _logger; - public ProperSpecification(QualityUpgradableSpecification qualityUpgradableSpecification, IConfigService configService, Logger logger) + public ProperSpecification(UpgradableSpecification qualityUpgradableSpecification, IConfigService configService, IMediaFileService mediaFileService, Logger logger) { _qualityUpgradableSpecification = qualityUpgradableSpecification; _configService = configService; + _mediaFileService = mediaFileService; _logger = logger; } + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Permanent; - public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) + public virtual Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria) { if (searchCriteria != null) { return Decision.Accept(); } - foreach (var file in subject.Episodes.Where(c => c.EpisodeFileId != 0).Select(c => c.EpisodeFile.Value)) + var downloadPropersAndRepacks = _configService.DownloadPropersAndRepacks; + + if (downloadPropersAndRepacks == ProperDownloadTypes.DoNotPrefer) + { + _logger.Debug("Propers are not preferred, skipping check"); + return Decision.Accept(); + } + + foreach (var album in subject.Albums) { - if (_qualityUpgradableSpecification.IsRevisionUpgrade(file.Quality, subject.ParsedEpisodeInfo.Quality)) + var trackFiles = _mediaFileService.GetFilesByAlbum(album.Id); + + foreach (var file in trackFiles) { - if (file.DateAdded < DateTime.Today.AddDays(-7)) + if (_qualityUpgradableSpecification.IsRevisionUpgrade(file.Quality, subject.ParsedAlbumInfo.Quality)) { - _logger.Debug("Proper for old file, rejecting: {0}", subject); - return Decision.Reject("Proper for old file"); - } + if (downloadPropersAndRepacks == ProperDownloadTypes.DoNotUpgrade) + { + _logger.Debug("Auto downloading of propers is disabled"); + return Decision.Reject("Proper downloading is disabled"); + } - if (!_configService.AutoDownloadPropers) - { - _logger.Debug("Auto downloading of propers is disabled"); - return Decision.Reject("Proper downloading is disabled"); + if (file.DateAdded < DateTime.Today.AddDays(-7)) + { + _logger.Debug("Proper for old file, rejecting: {0}", subject); + return Decision.Reject("Proper for old file"); + } } } } diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/SameEpisodesGrabSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/SameEpisodesGrabSpecification.cs deleted file mode 100644 index 1a8c5db5b..000000000 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/SameEpisodesGrabSpecification.cs +++ /dev/null @@ -1,31 +0,0 @@ -using NLog; -using NzbDrone.Core.IndexerSearch.Definitions; -using NzbDrone.Core.Parser.Model; - -namespace NzbDrone.Core.DecisionEngine.Specifications -{ - public class SameEpisodesGrabSpecification : IDecisionEngineSpecification - { - private readonly SameEpisodesSpecification _sameEpisodesSpecification; - private readonly Logger _logger; - - public SameEpisodesGrabSpecification(SameEpisodesSpecification sameEpisodesSpecification, Logger logger) - { - _sameEpisodesSpecification = sameEpisodesSpecification; - _logger = logger; - } - - public RejectionType Type => RejectionType.Permanent; - - public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) - { - if (_sameEpisodesSpecification.IsSatisfiedBy(subject.Episodes)) - { - return Decision.Accept(); - } - - _logger.Debug("Episode file on disk contains more episodes than this release contains"); - return Decision.Reject("Episode file on disk contains more episodes than this release contains"); - } - } -} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/SameTracksGrabSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/SameTracksGrabSpecification.cs new file mode 100644 index 000000000..8f03c4cee --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/SameTracksGrabSpecification.cs @@ -0,0 +1,29 @@ +using System; +using NLog; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.DecisionEngine.Specifications +{ + public class SameTracksGrabSpecification : IDecisionEngineSpecification + { + private readonly SameTracksSpecification _sameTracksSpecification; + private readonly Logger _logger; + + public SameTracksGrabSpecification(SameTracksSpecification sameTracksSpecification, Logger logger) + { + _sameTracksSpecification = sameTracksSpecification; + _logger = logger; + } + + public SpecificationPriority Priority => SpecificationPriority.Default; + public RejectionType Type => RejectionType.Permanent; + + public Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria) + { + throw new NotImplementedException(); + + // TODO: Rework for Tracks if we can parse from release details. + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/SameTracksSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/SameTracksSpecification.cs new file mode 100644 index 000000000..dec6ac052 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/SameTracksSpecification.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.DecisionEngine.Specifications +{ + public class SameTracksSpecification + { + private readonly ITrackService _trackService; + + public SameTracksSpecification(ITrackService trackService) + { + _trackService = trackService; + } + + public bool IsSatisfiedBy(List tracks) + { + var trackIds = tracks.SelectList(e => e.Id); + var trackFileIds = tracks.Where(c => c.TrackFileId != 0).Select(c => c.TrackFileId).Distinct(); + + foreach (var trackFileId in trackFileIds) + { + var tracksInFile = _trackService.GetTracksByFileId(trackFileId); + + if (tracksInFile.Select(e => e.Id).Except(trackIds).Any()) + { + return false; + } + } + + return true; + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/AlbumRequestedSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/Search/AlbumRequestedSpecification.cs new file mode 100644 index 000000000..2be1a20b8 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/Search/AlbumRequestedSpecification.cs @@ -0,0 +1,41 @@ +using System.Linq; +using NLog; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Music; + + +namespace NzbDrone.Core.DecisionEngine.Specifications.Search +{ + public class AlbumRequestedSpecification : IDecisionEngineSpecification + { + private readonly Logger _logger; + + public AlbumRequestedSpecification(Logger logger) + { + _logger = logger; + } + + public SpecificationPriority Priority => SpecificationPriority.Default; + public RejectionType Type => RejectionType.Permanent; + + public Decision IsSatisfiedBy(RemoteAlbum remoteAlbum, SearchCriteriaBase searchCriteria) + { + if (searchCriteria == null) + { + return Decision.Accept(); + } + + var criteriaAlbum = searchCriteria.Albums.Select(v => v.Id).ToList(); + var remoteAlbums = remoteAlbum.Albums.Select(v => v.Id).ToList(); + + if (!criteriaAlbum.Intersect(remoteAlbums).Any()) + { + _logger.Debug("Release rejected since the album wasn't requested: {0}", remoteAlbum.ParsedAlbumInfo); + return Decision.Reject("Album wasn't requested"); + } + + return Decision.Accept(); + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/ArtistSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/Search/ArtistSpecification.cs new file mode 100644 index 000000000..7eb972b80 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/Search/ArtistSpecification.cs @@ -0,0 +1,37 @@ +using NLog; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.DecisionEngine.Specifications.Search +{ + public class ArtistSpecification : IDecisionEngineSpecification + { + private readonly Logger _logger; + + public ArtistSpecification(Logger logger) + { + _logger = logger; + } + + public SpecificationPriority Priority => SpecificationPriority.Default; + public RejectionType Type => RejectionType.Permanent; + + public Decision IsSatisfiedBy(RemoteAlbum remoteAlbum, SearchCriteriaBase searchCriteria) + { + if (searchCriteria == null) + { + return Decision.Accept(); + } + + _logger.Debug("Checking if artist matches searched artist"); + + if (remoteAlbum.Artist.Id != searchCriteria.Artist.Id) + { + _logger.Debug("Artist {0} does not match {1}", remoteAlbum.Artist, searchCriteria.Artist); + return Decision.Reject("Wrong artist"); + } + + return Decision.Accept(); + } + } +} 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 deleted file mode 100644 index 60640442f..000000000 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/EpisodeRequestedSpecification.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Linq; -using NLog; -using NzbDrone.Core.IndexerSearch.Definitions; -using NzbDrone.Core.Parser.Model; - - -namespace NzbDrone.Core.DecisionEngine.Specifications.Search -{ - public class EpisodeRequestedSpecification : IDecisionEngineSpecification - { - private readonly Logger _logger; - - public EpisodeRequestedSpecification(Logger logger) - { - _logger = logger; - } - - public RejectionType Type => RejectionType.Permanent; - - public Decision IsSatisfiedBy(RemoteEpisode remoteEpisode, SearchCriteriaBase searchCriteria) - { - if (searchCriteria == null) - { - return Decision.Accept(); - } - - var criteriaEpisodes = searchCriteria.Episodes.Select(v => v.Id).ToList(); - var remoteEpisodes = remoteEpisode.Episodes.Select(v => v.Id).ToList(); - - 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"); - } - - return Decision.Accept(); - } - } -} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SeasonMatchSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SeasonMatchSpecification.cs deleted file mode 100644 index b09d888ec..000000000 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SeasonMatchSpecification.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 SeasonMatchSpecification : IDecisionEngineSpecification - { - private readonly Logger _logger; - - public SeasonMatchSpecification(Logger logger) - { - _logger = logger; - } - - public RejectionType Type => RejectionType.Permanent; - - public Decision IsSatisfiedBy(RemoteEpisode remoteEpisode, SearchCriteriaBase searchCriteria) - { - if (searchCriteria == null) - { - return Decision.Accept(); - } - - var singleEpisodeSpec = searchCriteria as SeasonSearchCriteria; - if (singleEpisodeSpec == null) return Decision.Accept(); - - if (singleEpisodeSpec.SeasonNumber != remoteEpisode.ParsedEpisodeInfo.SeasonNumber) - { - _logger.Debug("Season number does not match searched season number, skipping."); - return Decision.Reject("Wrong season"); - } - - 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 deleted file mode 100644 index 7f1201b33..000000000 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SeriesSpecification.cs +++ /dev/null @@ -1,36 +0,0 @@ -using NLog; -using NzbDrone.Core.IndexerSearch.Definitions; -using NzbDrone.Core.Parser.Model; - -namespace NzbDrone.Core.DecisionEngine.Specifications.Search -{ - public class SeriesSpecification : IDecisionEngineSpecification - { - private readonly Logger _logger; - - public SeriesSpecification(Logger logger) - { - _logger = logger; - } - - public RejectionType Type => RejectionType.Permanent; - - public Decision IsSatisfiedBy(RemoteEpisode remoteEpisode, SearchCriteriaBase searchCriteria) - { - if (searchCriteria == null) - { - return Decision.Accept(); - } - - _logger.Debug("Checking if series matches searched series"); - - if (remoteEpisode.Series.Id != searchCriteria.Series.Id) - { - _logger.Debug("Series {0} does not match {1}", remoteEpisode.Series, searchCriteria.Series); - return Decision.Reject("Wrong series"); - } - - return Decision.Accept(); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SingleAlbumSearchMatchSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SingleAlbumSearchMatchSpecification.cs new file mode 100644 index 000000000..c2ea90be6 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SingleAlbumSearchMatchSpecification.cs @@ -0,0 +1,49 @@ +using System; +using System.Linq; +using NLog; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.DecisionEngine.Specifications.Search +{ + public class SingleAlbumSearchMatchSpecification : IDecisionEngineSpecification + { + private readonly Logger _logger; + + public SingleAlbumSearchMatchSpecification(Logger logger) + { + _logger = logger; + } + + public SpecificationPriority Priority => SpecificationPriority.Default; + public RejectionType Type => RejectionType.Permanent; + + public virtual Decision IsSatisfiedBy(RemoteAlbum remoteAlbum, SearchCriteriaBase searchCriteria) + { + if (searchCriteria == null) + { + return Decision.Accept(); + } + + var singleAlbumSpec = searchCriteria as AlbumSearchCriteria; + if (singleAlbumSpec == null) + { + return Decision.Accept(); + } + + if (Parser.Parser.CleanArtistName(singleAlbumSpec.AlbumTitle) != Parser.Parser.CleanArtistName(remoteAlbum.ParsedAlbumInfo.AlbumTitle)) + { + _logger.Debug("Album does not match searched album title, skipping."); + return Decision.Reject("Wrong album"); + } + + if (!remoteAlbum.ParsedAlbumInfo.AlbumTitle.Any()) + { + _logger.Debug("Full discography result during single album search, skipping."); + return Decision.Reject("Full artist pack"); + } + + return Decision.Accept(); + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SingleEpisodeMatchSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SingleEpisodeMatchSpecification.cs deleted file mode 100644 index 2a8495492..000000000 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SingleEpisodeMatchSpecification.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Linq; -using NLog; -using NzbDrone.Core.IndexerSearch.Definitions; -using NzbDrone.Core.Parser.Model; - -namespace NzbDrone.Core.DecisionEngine.Specifications.Search -{ - public class SingleEpisodeMatchSpecification : IDecisionEngineSpecification - { - private readonly Logger _logger; - - public SingleEpisodeMatchSpecification(Logger logger) - { - _logger = logger; - } - - public string RejectionReason - { - get - { - return "Episode doesn't match"; - } - } - - public bool IsSatisfiedBy(RemoteEpisode remoteEpisode, SearchDefinitionBase searchDefinitionBase) - { - var singleEpisodeSpec = searchDefinitionBase as SingleEpisodeSearchDefinition; - if (singleEpisodeSpec == null) return true; - - if (singleEpisodeSpec.SeasonNumber != remoteEpisode.ParsedEpisodeInfo.SeasonNumber) - { - _logger.Trace("Season number does not match searched season number, skipping."); - return false; - } - - if (!remoteEpisode.Episodes.Select(c => c.EpisodeNumber).Contains(singleEpisodeSpec.EpisodeNumber)) - { - _logger.Trace("Episode number does not match searched episode number, skipping."); - return false; - } - - return true; - } - } -} \ 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 deleted file mode 100644 index fb056734f..000000000 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/Search/SingleEpisodeSearchMatchSpecification.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Linq; -using NLog; -using NzbDrone.Core.IndexerSearch.Definitions; -using NzbDrone.Core.Parser.Model; - -namespace NzbDrone.Core.DecisionEngine.Specifications.Search -{ - public class SingleEpisodeSearchMatchSpecification : IDecisionEngineSpecification - { - private readonly Logger _logger; - - public SingleEpisodeSearchMatchSpecification(Logger logger) - { - _logger = logger; - } - - public RejectionType Type => RejectionType.Permanent; - - public Decision IsSatisfiedBy(RemoteEpisode remoteEpisode, SearchCriteriaBase searchCriteria) - { - if (searchCriteria == null) - { - return Decision.Accept(); - } - - var singleEpisodeSpec = searchCriteria as SingleEpisodeSearchCriteria; - if (singleEpisodeSpec == null) return Decision.Accept(); - - if (singleEpisodeSpec.SeasonNumber != remoteEpisode.ParsedEpisodeInfo.SeasonNumber) - { - _logger.Debug("Season number does not match searched season number, skipping."); - return Decision.Reject("Wrong season"); - } - - if (!remoteEpisode.ParsedEpisodeInfo.EpisodeNumbers.Any()) - { - _logger.Debug("Full season result during single episode search, skipping."); - return Decision.Reject("Full season pack"); - } - - if (!remoteEpisode.ParsedEpisodeInfo.EpisodeNumbers.Contains(singleEpisodeSpec.EpisodeNumber)) - { - _logger.Debug("Episode number does not match searched episode number, skipping."); - return Decision.Reject("Wrong episode"); - } - - 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..95b071a37 --- /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(RemoteAlbum remoteAlbum, SearchCriteriaBase searchCriteria) + { + var torrentInfo = remoteAlbum.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/UpgradableSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradableSpecification.cs new file mode 100644 index 000000000..0057b3f67 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradableSpecification.cs @@ -0,0 +1,171 @@ +using NLog; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Profiles.Qualities; +using NzbDrone.Core.Qualities; +using System.Collections.Generic; +using System.Linq; + +namespace NzbDrone.Core.DecisionEngine.Specifications +{ + public interface IUpgradableSpecification + { + bool IsUpgradable(QualityProfile profile, List currentQualities, int currentScore, QualityModel newQuality, int newScore); + bool QualityCutoffNotMet(QualityProfile profile, QualityModel currentQuality, QualityModel newQuality = null); + bool CutoffNotMet(QualityProfile profile, List currentQualities, int currentScore, QualityModel newQuality = null, int newScore = 0); + bool IsRevisionUpgrade(QualityModel currentQuality, QualityModel newQuality); + bool IsUpgradeAllowed(QualityProfile qualityProfile, List currentQualities, QualityModel newQuality); + } + + public class UpgradableSpecification : IUpgradableSpecification + { + private readonly IConfigService _configService; + private readonly Logger _logger; + + public UpgradableSpecification(IConfigService configService, Logger logger) + { + _configService = configService; + _logger = logger; + } + + private ProfileComparisonResult IsQualityUpgradable(QualityProfile profile, List currentQualities, QualityModel newQuality = null) + { + if (newQuality != null) + { + var totalCompare = 0; + + foreach (var quality in currentQualities) + { + var compare = new QualityModelComparer(profile).Compare(newQuality, quality); + + totalCompare += compare; + + if (compare < 0) + { + // Not upgradable if new quality is a downgrade for any current quality + return ProfileComparisonResult.Downgrade; + } + } + + // Not upgradable if new quality is equal to all current qualities + if (totalCompare == 0) { + return ProfileComparisonResult.Equal; + } + + // Quality Treated as Equal if Propers are not Prefered + if (_configService.DownloadPropersAndRepacks == ProperDownloadTypes.DoNotPrefer && + newQuality.Revision.CompareTo(currentQualities.Min(q => q.Revision)) > 0) + { + return ProfileComparisonResult.Equal; + } + } + + return ProfileComparisonResult.Upgrade; + } + + private bool IsPreferredWordUpgradable(int currentScore, int newScore) + { + return newScore > currentScore; + } + + public bool IsUpgradable(QualityProfile qualityProfile, List currentQualities, int currentScore, QualityModel newQuality, int newScore) + { + + var qualityUpgrade = IsQualityUpgradable(qualityProfile, currentQualities, newQuality); + + if (qualityUpgrade == ProfileComparisonResult.Upgrade) + { + return true; + } + + if (qualityUpgrade == ProfileComparisonResult.Downgrade) + { + _logger.Debug("Existing item has better quality, skipping"); + return false; + } + + if (!IsPreferredWordUpgradable(currentScore, newScore)) + { + _logger.Debug("Existing item has a better preferred word score, skipping"); + return false; + } + + return true; + } + + public bool QualityCutoffNotMet(QualityProfile profile, QualityModel currentQuality, QualityModel newQuality = null) + { + var cutoffCompare = new QualityModelComparer(profile).Compare(currentQuality.Quality.Id, profile.Cutoff); + + if (cutoffCompare < 0) + { + return true; + } + + if (newQuality != null && IsRevisionUpgrade(currentQuality, newQuality)) + { + return true; + } + + return false; + } + + public bool CutoffNotMet(QualityProfile profile, List currentQualities, int currentScore, QualityModel newQuality = null, int newScore = 0) + { + foreach (var quality in currentQualities) + { + if (QualityCutoffNotMet(profile, quality, newQuality)) + { + return true; + } + } + + if (IsPreferredWordUpgradable(currentScore, newScore)) + { + return true; + } + + _logger.Debug("Existing item meets cut-off. skipping."); + + return false; + } + + public bool IsRevisionUpgrade(QualityModel currentQuality, QualityModel newQuality) + { + var compare = newQuality.Revision.CompareTo(currentQuality.Revision); + + // Comparing the quality directly because we don't want to upgrade to a proper for a webrip from a webdl or vice versa + if (currentQuality.Quality == newQuality.Quality && compare > 0) + { + _logger.Debug("New quality is a better revision for existing quality"); + return true; + } + + return false; + } + + public bool IsUpgradeAllowed(QualityProfile qualityProfile, List currentQualities, QualityModel newQuality) + { + var isQualityUpgrade = IsQualityUpgradable(qualityProfile, currentQualities, newQuality); + + return CheckUpgradeAllowed(qualityProfile, isQualityUpgrade); + } + + private bool CheckUpgradeAllowed (QualityProfile qualityProfile, ProfileComparisonResult isQualityUpgrade) + { + if (isQualityUpgrade == ProfileComparisonResult.Upgrade && !qualityProfile.UpgradeAllowed) + { + _logger.Debug("Quality profile does not allow upgrades, skipping"); + return false; + } + + return true; + } + + private enum ProfileComparisonResult + { + Downgrade = -1, + Equal = 0, + Upgrade = 1 + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradeAllowedSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradeAllowedSpecification.cs new file mode 100644 index 000000000..2ebc916c3 --- /dev/null +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradeAllowedSpecification.cs @@ -0,0 +1,71 @@ +using System; +using System.Linq; +using NLog; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Music; +using NzbDrone.Common.Cache; +using NzbDrone.Core.Profiles.Releases; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.DecisionEngine.Specifications +{ + public class UpgradeAllowedSpecification : IDecisionEngineSpecification + { + private readonly UpgradableSpecification _upgradableSpecification; + private readonly IMediaFileService _mediaFileService; + private readonly ITrackService _trackService; + private readonly Logger _logger; + private readonly ICached _missingFilesCache; + + public UpgradeAllowedSpecification(UpgradableSpecification upgradableSpecification, + Logger logger, + ICacheManager cacheManager, + IMediaFileService mediaFileService, + ITrackService trackService) + { + _upgradableSpecification = upgradableSpecification; + _mediaFileService = mediaFileService; + _trackService = trackService; + _missingFilesCache = cacheManager.GetCache(GetType()); + _logger = logger; + } + + public SpecificationPriority Priority => SpecificationPriority.Default; + public RejectionType Type => RejectionType.Permanent; + + public virtual Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria) + { + var qualityProfile = subject.Artist.QualityProfile.Value; + + foreach (var album in subject.Albums) + { + var tracksMissing = _missingFilesCache.Get(album.Id.ToString(), () => _trackService.TracksWithoutFiles(album.Id).Any(), + TimeSpan.FromSeconds(30)); + + var trackFiles = _mediaFileService.GetFilesByAlbum(album.Id); + + if (!tracksMissing && trackFiles.Any()) + { + // Get a distinct list of all current track qualities for a given album + var currentQualities = trackFiles.Select(c => c.Quality).Distinct().ToList(); + + _logger.Debug("Comparing file quality with report. Existing files contain {0}", currentQualities.ConcatToString()); + + if (!_upgradableSpecification.IsUpgradeAllowed(qualityProfile, + currentQualities, + subject.ParsedAlbumInfo.Quality)) + { + _logger.Debug("Upgrading is not allowed by the quality profile"); + + return Decision.Reject("Existing files and the Quality profile does not allow upgrades"); + } + + } + } + + return Decision.Accept(); + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradeDiskSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradeDiskSpecification.cs index 5a24b6305..e1e75eb52 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradeDiskSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradeDiskSpecification.cs @@ -1,33 +1,66 @@ +using System; using System.Linq; using NLog; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Music; +using NzbDrone.Common.Cache; +using NzbDrone.Core.Profiles.Releases; +using NzbDrone.Common.Extensions; namespace NzbDrone.Core.DecisionEngine.Specifications { public class UpgradeDiskSpecification : IDecisionEngineSpecification { - private readonly QualityUpgradableSpecification _qualityUpgradableSpecification; + private readonly IMediaFileService _mediaFileService; + private readonly ITrackService _trackService; + private readonly UpgradableSpecification _upgradableSpecification; + private readonly IPreferredWordService _preferredWordServiceCalculator; private readonly Logger _logger; - - public UpgradeDiskSpecification(QualityUpgradableSpecification qualityUpgradableSpecification, Logger logger) + private readonly ICached _missingFilesCache; + + public UpgradeDiskSpecification(UpgradableSpecification qualityUpgradableSpecification, + IMediaFileService mediaFileService, + ITrackService trackService, + ICacheManager cacheManager, + IPreferredWordService preferredWordServiceCalculator, + Logger logger) { - _qualityUpgradableSpecification = qualityUpgradableSpecification; + _upgradableSpecification = qualityUpgradableSpecification; + _mediaFileService = mediaFileService; + _trackService = trackService; + _preferredWordServiceCalculator = preferredWordServiceCalculator; _logger = logger; + _missingFilesCache = cacheManager.GetCache(GetType()); } + public SpecificationPriority Priority => SpecificationPriority.Default; public RejectionType Type => RejectionType.Permanent; - public virtual Decision IsSatisfiedBy(RemoteEpisode subject, SearchCriteriaBase searchCriteria) + public virtual Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria) { - foreach (var file in subject.Episodes.Where(c => c.EpisodeFileId != 0).Select(c => c.EpisodeFile.Value)) + + foreach (var album in subject.Albums) { - _logger.Debug("Comparing file quality with report. Existing file is {0}", file.Quality); + var tracksMissing = _missingFilesCache.Get(album.Id.ToString(), () => _trackService.TracksWithoutFiles(album.Id).Any(), + TimeSpan.FromSeconds(30)); + var trackFiles = _mediaFileService.GetFilesByAlbum(album.Id); - if (!_qualityUpgradableSpecification.IsUpgradable(subject.Series.Profile, file.Quality, subject.ParsedEpisodeInfo.Quality)) + if (!tracksMissing && trackFiles.Any()) { - return Decision.Reject("Quality for existing file on disk is of equal or higher preference: {0}", file.Quality); + var currentQualities = trackFiles.Select(c => c.Quality).Distinct().ToList(); + + if (!_upgradableSpecification.IsUpgradable(subject.Artist.QualityProfile, + currentQualities, + _preferredWordServiceCalculator.Calculate(subject.Artist, trackFiles[0].GetSceneOrFileName()), + subject.ParsedAlbumInfo.Quality, + subject.PreferredWordScore)) + { + return Decision.Reject("Existing files on disk is of equal or higher preference: {0}", currentQualities.ConcatToString()); + } } + } return Decision.Accept(); diff --git a/src/NzbDrone.Core/DiskSpace/DiskSpaceService.cs b/src/NzbDrone.Core/DiskSpace/DiskSpaceService.cs index edc641533..ff656d60c 100644 --- a/src/NzbDrone.Core/DiskSpace/DiskSpaceService.cs +++ b/src/NzbDrone.Core/DiskSpace/DiskSpaceService.cs @@ -1,12 +1,11 @@ -using System; -using System.IO; +using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Text.RegularExpressions; using NLog; using NzbDrone.Common.Disk; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Configuration; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.DiskSpace { @@ -17,52 +16,44 @@ namespace NzbDrone.Core.DiskSpace public class DiskSpaceService : IDiskSpaceService { - private readonly ISeriesService _seriesService; - private readonly IConfigService _configService; + private readonly IArtistService _artistService; private readonly IDiskProvider _diskProvider; private readonly Logger _logger; - public DiskSpaceService(ISeriesService seriesService, IConfigService configService, IDiskProvider diskProvider, Logger logger) + private static readonly Regex _regexSpecialDrive = new Regex("^/var/lib/(docker|rancher|kubelet)(/|$)|^/(boot|etc)(/|$)|/docker(/var)?/aufs(/|$)", RegexOptions.Compiled); + + public DiskSpaceService(IArtistService artistService, IDiskProvider diskProvider, Logger logger) { - _seriesService = seriesService; - _configService = configService; + _artistService = artistService; _diskProvider = diskProvider; _logger = logger; } public List GetFreeSpace() { - var diskSpace = new List(); - diskSpace.AddRange(GetSeriesFreeSpace()); - diskSpace.AddRange(GetDroneFactoryFreeSpace()); - diskSpace.AddRange(GetFixedDisksFreeSpace()); + var importantRootFolders = GetArtistRootPaths().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 GetArtistRootPaths() { - var seriesRootPaths = _seriesService.GetAllSeries() + return _artistService.GetAllArtists() .Where(s => _diskProvider.FolderExists(s.Path)) .Select(s => _diskProvider.GetPathRoot(s.Path)) .Distinct(); - - return GetDiskSpace(seriesRootPaths); - } - - private IEnumerable GetDroneFactoryFreeSpace() - { - if (_configService.DownloadedEpisodesFolder.IsNotNullOrWhiteSpace() && _diskProvider.FolderExists(_configService.DownloadedEpisodesFolder)) - { - return GetDiskSpace(new[] { _diskProvider.GetPathRoot(_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/Aggregation/Aggregators/AggregatePreferredWordScore.cs b/src/NzbDrone.Core/Download/Aggregation/Aggregators/AggregatePreferredWordScore.cs new file mode 100644 index 000000000..cf41c32bc --- /dev/null +++ b/src/NzbDrone.Core/Download/Aggregation/Aggregators/AggregatePreferredWordScore.cs @@ -0,0 +1,22 @@ +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Profiles.Releases; + +namespace NzbDrone.Core.Download.Aggregation.Aggregators +{ + public class AggregatePreferredWordScore : IAggregateRemoteAlbum + { + private readonly IPreferredWordService _preferredWordServiceCalculator; + + public AggregatePreferredWordScore(IPreferredWordService preferredWordServiceCalculator) + { + _preferredWordServiceCalculator = preferredWordServiceCalculator; + } + + public RemoteAlbum Aggregate(RemoteAlbum remoteAlbum) + { + remoteAlbum.PreferredWordScore = _preferredWordServiceCalculator.Calculate(remoteAlbum.Artist, remoteAlbum.Release.Title); + + return remoteAlbum; + } + } +} diff --git a/src/NzbDrone.Core/Download/Aggregation/Aggregators/IAggregateRemoteAlbum.cs b/src/NzbDrone.Core/Download/Aggregation/Aggregators/IAggregateRemoteAlbum.cs new file mode 100644 index 000000000..c88c95686 --- /dev/null +++ b/src/NzbDrone.Core/Download/Aggregation/Aggregators/IAggregateRemoteAlbum.cs @@ -0,0 +1,9 @@ +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.Download.Aggregation.Aggregators +{ + public interface IAggregateRemoteAlbum + { + RemoteAlbum Aggregate(RemoteAlbum remoteAlbum); + } +} diff --git a/src/NzbDrone.Core/Download/Aggregation/RemoteAlbumAggregationService.cs b/src/NzbDrone.Core/Download/Aggregation/RemoteAlbumAggregationService.cs new file mode 100644 index 000000000..61647a7a2 --- /dev/null +++ b/src/NzbDrone.Core/Download/Aggregation/RemoteAlbumAggregationService.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using NLog; +using NzbDrone.Core.Download.Aggregation.Aggregators; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.Download.Aggregation +{ + public interface IRemoteAlbumAggregationService + { + RemoteAlbum Augment(RemoteAlbum remoteAlbum); + } + + public class RemoteAlbumAggregationService : IRemoteAlbumAggregationService + { + private readonly IEnumerable _augmenters; + private readonly Logger _logger; + + public RemoteAlbumAggregationService(IEnumerable augmenters, + Logger logger) + { + _augmenters = augmenters; + _logger = logger; + } + + public RemoteAlbum Augment(RemoteAlbum remoteAlbum) + { + foreach (var augmenter in _augmenters) + { + try + { + augmenter.Aggregate(remoteAlbum); + } + catch (Exception ex) + { + _logger.Warn(ex, ex.Message); + } + } + + + return remoteAlbum; + } + } +} diff --git a/src/NzbDrone.Core/Download/AlbumGrabbedEvent.cs b/src/NzbDrone.Core/Download/AlbumGrabbedEvent.cs new file mode 100644 index 000000000..5b7972802 --- /dev/null +++ b/src/NzbDrone.Core/Download/AlbumGrabbedEvent.cs @@ -0,0 +1,17 @@ +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.Download +{ + public class AlbumGrabbedEvent : IEvent + { + public RemoteAlbum Album { get; private set; } + public string DownloadClient { get; set; } + public string DownloadId { get; set; } + + public AlbumGrabbedEvent(RemoteAlbum album) + { + Album = album; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/CheckForFinishedDownloadCommand.cs b/src/NzbDrone.Core/Download/CheckForFinishedDownloadCommand.cs index 7dc987d84..71f7f3d5e 100644 --- a/src/NzbDrone.Core/Download/CheckForFinishedDownloadCommand.cs +++ b/src/NzbDrone.Core/Download/CheckForFinishedDownloadCommand.cs @@ -1,9 +1,9 @@ -using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Commands; namespace NzbDrone.Core.Download { public class CheckForFinishedDownloadCommand : Command { - + public override bool RequiresDiskAccess => true; } } diff --git a/src/NzbDrone.Core/Download/Clients/Blackhole/ScanWatchFolder.cs b/src/NzbDrone.Core/Download/Clients/Blackhole/ScanWatchFolder.cs index d6e80e3fd..c19f37d35 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 { @@ -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,16 +86,16 @@ namespace NzbDrone.Core.Download.Clients.Blackhole yield return newWatchItem; } - foreach (var videoFile in _diskScanService.GetVideoFiles(watchFolder, false)) + foreach (var audioFile in _diskScanService.FilterFiles(watchFolder, _diskScanService.GetAudioFiles(watchFolder, false))) { - var title = FileNameBuilder.CleanFileName(Path.GetFileName(videoFile)); + var title = FileNameBuilder.CleanFileName(audioFile.Name); var newWatchItem = new WatchFolderItem { - DownloadId = Path.GetFileName(videoFile) + "_" + _diskProvider.FileGetLastWrite(videoFile).Ticks, + DownloadId = audioFile.Name + "_" + audioFile.LastWriteTimeUtc.Ticks, Title = title, - OutputPath = new OsPath(videoFile), + OutputPath = new OsPath(audioFile.FullName), Status = DownloadItemStatus.Completed, RemainingTime = TimeSpan.Zero @@ -105,10 +105,10 @@ namespace NzbDrone.Core.Download.Clients.Blackhole if (PreCheckWatchItemExpiry(newWatchItem, oldWatchItem)) { - newWatchItem.TotalSize = _diskProvider.GetFileSize(videoFile); - newWatchItem.Hash = GetHash(videoFile); + newWatchItem.TotalSize = audioFile.Length; + newWatchItem.Hash = GetHash(audioFile.FullName); - if (_diskProvider.IsFileLocked(videoFile)) + if (_diskProvider.IsFileLocked(audioFile.FullName)) { newWatchItem.Status = DownloadItemStatus.Downloading; } diff --git a/src/NzbDrone.Core/Download/Clients/Blackhole/TorrentBlackhole.cs b/src/NzbDrone.Core/Download/Clients/Blackhole/TorrentBlackhole.cs index e95297c97..0558e4692 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; @@ -38,18 +38,18 @@ namespace NzbDrone.Core.Download.Clients.Blackhole ScanGracePeriod = TimeSpan.FromSeconds(30); } - protected override string AddFromMagnetLink(RemoteEpisode remoteEpisode, string hash, string magnetLink) + protected override string AddFromMagnetLink(RemoteAlbum remoteAlbum, string hash, string magnetLink) { if (!Settings.SaveMagnetFiles) { throw new NotSupportedException("Blackhole does not support magnet links."); } - var title = remoteEpisode.Release.Title; + var title = remoteAlbum.Release.Title; title = FileNameBuilder.CleanFileName(title); - var filepath = Path.Combine(Settings.TorrentFolder, string.Format("{0}.magnet", title)); + var filepath = Path.Combine(Settings.TorrentFolder, $"{title}.{Settings.MagnetFileExtension.Trim('.')}"); var fileContent = Encoding.UTF8.GetBytes(magnetLink); using (var stream = _diskProvider.OpenWriteStream(filepath)) @@ -62,9 +62,9 @@ namespace NzbDrone.Core.Download.Clients.Blackhole return null; } - protected override string AddFromTorrentFile(RemoteEpisode remoteEpisode, string hash, string filename, byte[] fileContent) + protected override string AddFromTorrentFile(RemoteAlbum remoteAlbum, string hash, string filename, byte[] fileContent) { - var title = remoteEpisode.Release.Title; + var title = remoteAlbum.Release.Title; title = FileNameBuilder.CleanFileName(title); @@ -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)) @@ -93,7 +90,7 @@ namespace NzbDrone.Core.Download.Clients.Blackhole { DownloadClient = Definition.Name, DownloadId = Definition.Name + "_" + item.DownloadId, - Category = "sonarr", + Category = "Lidarr", Title = item.Title, TotalSize = item.TotalSize, @@ -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/TorrentBlackholeSettings.cs b/src/NzbDrone.Core/Download/Clients/Blackhole/TorrentBlackholeSettings.cs index d05ee7f22..e08d89e39 100644 --- a/src/NzbDrone.Core/Download/Clients/Blackhole/TorrentBlackholeSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/Blackhole/TorrentBlackholeSettings.cs @@ -1,4 +1,4 @@ -using System.ComponentModel; +using System.ComponentModel; using FluentValidation; using Newtonsoft.Json; using NzbDrone.Core.Annotations; @@ -14,6 +14,7 @@ namespace NzbDrone.Core.Download.Clients.Blackhole { //Todo: Validate that the path actually exists RuleFor(c => c.TorrentFolder).IsValidPath(); + RuleFor(c => c.MagnetFileExtension).NotEmpty(); } } @@ -21,25 +22,29 @@ namespace NzbDrone.Core.Download.Clients.Blackhole { public TorrentBlackholeSettings() { + MagnetFileExtension = ".magnet"; ReadOnly = true; } private static readonly TorrentBlackholeSettingsValidator Validator = new TorrentBlackholeSettingsValidator(); - [FieldDefinition(0, Label = "Torrent Folder", Type = FieldType.Path, HelpText = "Folder in which Sonarr will store the .torrent file")] + [FieldDefinition(0, Label = "Torrent Folder", Type = FieldType.Path, HelpText = "Folder in which Lidarr will store the .torrent file")] public string TorrentFolder { get; set; } - [FieldDefinition(1, Label = "Watch Folder", Type = FieldType.Path, HelpText = "Folder from which Sonarr should import completed downloads")] + [FieldDefinition(1, Label = "Watch Folder", Type = FieldType.Path, HelpText = "Folder from which Lidarr should import completed downloads")] public string WatchFolder { get; set; } [DefaultValue(false)] [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)] - [FieldDefinition(2, Label = "Save Magnet Files", Type = FieldType.Checkbox, HelpText = "Save a .magnet file with the magnet link if no .torrent file is available (only useful if the download client supports .magnet files)")] + [FieldDefinition(2, Label = "Save Magnet Files", Type = FieldType.Checkbox, HelpText = "Save the magnet link if no .torrent file is available (only useful if the download client supports magnets saved to a file)")] public bool SaveMagnetFiles { get; set; } + [FieldDefinition(3, Label = "Magnet File Extension", Type = FieldType.Textbox, HelpText = "Extension to use for magnet links, defaults to '.magnet'")] + public string MagnetFileExtension { get; set; } + [DefaultValue(false)] [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)] - [FieldDefinition(3, Label = "Read Only", Type = FieldType.Checkbox, HelpText = "Instead of moving files this will instruct Sonarr to Copy or Hardlink (depending on settings/system configuration)")] + [FieldDefinition(4, Label = "Read Only", Type = FieldType.Checkbox, HelpText = "Instead of moving files this will instruct Lidarr to Copy or Hardlink (depending on settings/system configuration)")] public bool ReadOnly { get; set; } public NzbDroneValidationResult Validate() diff --git a/src/NzbDrone.Core/Download/Clients/Blackhole/UsenetBlackhole.cs b/src/NzbDrone.Core/Download/Clients/Blackhole/UsenetBlackhole.cs index 2cc13a235..bb9dddc85 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,17 +24,18 @@ 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; ScanGracePeriod = TimeSpan.FromSeconds(30); } - protected override string AddFromNzbFile(RemoteEpisode remoteEpisode, string filename, byte[] fileContent) + protected override string AddFromNzbFile(RemoteAlbum remoteAlbum, string filename, byte[] fileContent) { - var title = remoteEpisode.Release.Title; + var title = remoteAlbum.Release.Title; title = FileNameBuilder.CleanFileName(title); @@ -60,7 +61,7 @@ namespace NzbDrone.Core.Download.Clients.Blackhole { DownloadClient = Definition.Name, DownloadId = Definition.Name + "_" + item.DownloadId, - Category = "sonarr", + Category = "Lidarr", Title = item.Title, TotalSize = item.TotalSize, @@ -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/Blackhole/UsenetBlackholeSettings.cs b/src/NzbDrone.Core/Download/Clients/Blackhole/UsenetBlackholeSettings.cs index b2ff88149..96c175c4b 100644 --- a/src/NzbDrone.Core/Download/Clients/Blackhole/UsenetBlackholeSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/Blackhole/UsenetBlackholeSettings.cs @@ -19,10 +19,10 @@ namespace NzbDrone.Core.Download.Clients.Blackhole { private static readonly UsenetBlackholeSettingsValidator Validator = new UsenetBlackholeSettingsValidator(); - [FieldDefinition(0, Label = "Nzb Folder", Type = FieldType.Path, HelpText = "Folder in which Sonarr will store the .nzb file")] + [FieldDefinition(0, Label = "Nzb Folder", Type = FieldType.Path, HelpText = "Folder in which Lidarr will store the .nzb file")] public string NzbFolder { get; set; } - [FieldDefinition(1, Label = "Watch Folder", Type = FieldType.Path, HelpText = "Folder from which Sonarr should import completed downloads")] + [FieldDefinition(1, Label = "Watch Folder", Type = FieldType.Path, HelpText = "Folder from which Lidarr should import completed downloads")] public string WatchFolder { get; set; } public NzbDroneValidationResult Validate() diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/Deluge.cs b/src/NzbDrone.Core/Download/Clients/Deluge/Deluge.cs index 6e4d023a0..f825041cb 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; @@ -31,21 +31,26 @@ namespace NzbDrone.Core.Download.Clients.Deluge _proxy = proxy; } - protected override string AddFromMagnetLink(RemoteEpisode remoteEpisode, string hash, string magnetLink) + protected override string AddFromMagnetLink(RemoteAlbum remoteAlbum, string hash, string magnetLink) { var actualHash = _proxy.AddTorrentFromMagnet(magnetLink, Settings); - if (!Settings.TvCategory.IsNullOrWhiteSpace()) + if (actualHash.IsNullOrWhiteSpace()) { - _proxy.SetLabel(actualHash, Settings.TvCategory, Settings); + throw new DownloadClientException("Deluge failed to add magnet " + magnetLink); } - _proxy.SetTorrentConfiguration(actualHash, "remove_at_ratio", false, Settings); + if (!Settings.MusicCategory.IsNullOrWhiteSpace()) + { + _proxy.SetLabel(actualHash, Settings.MusicCategory, Settings); + } + + _proxy.SetTorrentSeedingConfiguration(actualHash, remoteAlbum.SeedConfiguration, Settings); - var isRecentEpisode = remoteEpisode.IsRecentEpisode(); + var isRecentAlbum = remoteAlbum.IsRecentAlbum(); - if (isRecentEpisode && Settings.RecentTvPriority == (int)DelugePriority.First || - !isRecentEpisode && Settings.OlderTvPriority == (int)DelugePriority.First) + if (isRecentAlbum && Settings.RecentTvPriority == (int)DelugePriority.First || + !isRecentAlbum && Settings.OlderTvPriority == (int)DelugePriority.First) { _proxy.MoveTorrentToTopInQueue(actualHash, Settings); } @@ -53,21 +58,26 @@ namespace NzbDrone.Core.Download.Clients.Deluge return actualHash.ToUpper(); } - protected override string AddFromTorrentFile(RemoteEpisode remoteEpisode, string hash, string filename, byte[] fileContent) + protected override string AddFromTorrentFile(RemoteAlbum remoteAlbum, string hash, string filename, byte[] fileContent) { var actualHash = _proxy.AddTorrentFromFile(filename, fileContent, Settings); - if (!Settings.TvCategory.IsNullOrWhiteSpace()) + if (actualHash.IsNullOrWhiteSpace()) { - _proxy.SetLabel(actualHash, Settings.TvCategory, Settings); + throw new DownloadClientException("Deluge failed to add torrent " + filename); } - _proxy.SetTorrentConfiguration(actualHash, "remove_at_ratio", false, Settings); + _proxy.SetTorrentSeedingConfiguration(actualHash, remoteAlbum.SeedConfiguration, Settings); + + if (!Settings.MusicCategory.IsNullOrWhiteSpace()) + { + _proxy.SetLabel(actualHash, Settings.MusicCategory, Settings); + } - var isRecentEpisode = remoteEpisode.IsRecentEpisode(); + var isRecentAlbum = remoteAlbum.IsRecentAlbum(); - if (isRecentEpisode && Settings.RecentTvPriority == (int)DelugePriority.First || - !isRecentEpisode && Settings.OlderTvPriority == (int)DelugePriority.First) + if (isRecentAlbum && Settings.RecentTvPriority == (int)DelugePriority.First || + !isRecentAlbum && Settings.OlderTvPriority == (int)DelugePriority.First) { _proxy.MoveTorrentToTopInQueue(actualHash, Settings); } @@ -81,38 +91,42 @@ namespace NzbDrone.Core.Download.Clients.Deluge { IEnumerable torrents; - try + if (!Settings.MusicCategory.IsNullOrWhiteSpace()) { - if (!Settings.TvCategory.IsNullOrWhiteSpace()) - { - torrents = _proxy.GetTorrentsByLabel(Settings.TvCategory, Settings); - } - else - { - torrents = _proxy.GetTorrents(Settings); - } + torrents = _proxy.GetTorrentsByLabel(Settings.MusicCategory, 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(); foreach (var torrent in torrents) { + if (torrent.Hash == null) continue; + var item = new DownloadClientItem(); item.DownloadId = torrent.Hash.ToUpper(); item.Title = torrent.Name; - item.Category = Settings.TvCategory; + item.Category = Settings.MusicCategory; item.DownloadClient = Definition.Name; 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 +151,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 +170,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 +181,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" }; @@ -198,12 +210,12 @@ namespace NzbDrone.Core.Download.Clients.Deluge } catch (DownloadClientAuthenticationException ex) { - _logger.Error(ex); + _logger.Error(ex, "Unable to authenticate"); return new NzbDroneValidationFailure("Password", "Authentication failed"); } catch (WebException ex) { - _logger.Error(ex); + _logger.Error(ex, "Unable 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); } @@ -236,7 +248,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge private ValidationFailure TestCategory() { - if (Settings.TvCategory.IsNullOrWhiteSpace()) + if (Settings.MusicCategory.IsNullOrWhiteSpace()) { return null; } @@ -245,7 +257,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge if (!enabledPlugins.Contains("Label")) { - return new NzbDroneValidationFailure("TvCategory", "Label plugin not activated") + return new NzbDroneValidationFailure("MusicCategory", "Label plugin not activated") { DetailedDescription = "You must have the Label plugin enabled in Deluge to use categories." }; @@ -253,16 +265,16 @@ namespace NzbDrone.Core.Download.Clients.Deluge var labels = _proxy.GetAvailableLabels(Settings); - if (!labels.Contains(Settings.TvCategory)) + if (!labels.Contains(Settings.MusicCategory)) { - _proxy.AddLabel(Settings.TvCategory, Settings); + _proxy.AddLabel(Settings.MusicCategory, Settings); labels = _proxy.GetAvailableLabels(Settings); - if (!labels.Contains(Settings.TvCategory)) + if (!labels.Contains(Settings.MusicCategory)) { - return new NzbDroneValidationFailure("TvCategory", "Configuration of label failed") + return new NzbDroneValidationFailure("MusicCategory", "Configuration of label failed") { - DetailedDescription = "Sonarr as unable to add the label to Deluge." + DetailedDescription = "Lidarr as unable to add the label to Deluge." }; } } @@ -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..45f6727f1 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; @@ -48,9 +48,25 @@ namespace NzbDrone.Core.Download.Clients.Deluge public string GetVersion(DelugeSettings settings) { - var response = ProcessRequest(settings, "daemon.info"); + try + { + var response = ProcessRequest(settings, "daemon.info"); - return response; + return response; + } + catch (DownloadClientException ex) + { + if (ex.Message.Contains("Unknown method")) + { + // Deluge v2 beta replaced 'daemon.info' with 'daemon.get_version'. + // It may return or become official, for now we just retry with the get_version api. + var response = ProcessRequest(settings, "daemon.get_version"); + + return response; + } + + throw; + } } public Dictionary GetConfig(DelugeSettings settings) @@ -84,21 +100,32 @@ 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 +166,20 @@ 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 +198,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 +265,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..e67e2fe59 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; @@ -12,7 +12,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge RuleFor(c => c.Host).ValidHost(); RuleFor(c => c.Port).InclusiveBetween(1, 65535); - RuleFor(c => c.TvCategory).Matches("^[-a-z]*$").WithMessage("Allowed characters a-z and -"); + RuleFor(c => c.MusicCategory).Matches("^[-a-z]*$").WithMessage("Allowed characters a-z and -"); } } @@ -25,7 +25,7 @@ namespace NzbDrone.Core.Download.Clients.Deluge Host = "localhost"; Port = 8112; Password = "deluge"; - TvCategory = "tv-sonarr"; + MusicCategory = "lidarr"; } [FieldDefinition(0, Label = "Host", Type = FieldType.Textbox)] @@ -40,16 +40,19 @@ namespace NzbDrone.Core.Download.Clients.Deluge [FieldDefinition(3, Label = "Password", Type = FieldType.Password)] public string Password { get; set; } - [FieldDefinition(4, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Sonarr avoids conflicts with unrelated downloads, but it's optional")] - public string TvCategory { get; set; } + [FieldDefinition(4, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Lidarr avoids conflicts with unrelated downloads, but it's optional")] + public string MusicCategory { get; set; } - [FieldDefinition(5, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(DelugePriority), HelpText = "Priority to use when grabbing episodes that aired within the last 14 days")] + [FieldDefinition(5, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(DelugePriority), HelpText = "Priority to use when grabbing albums released within the last 14 days")] public int RecentTvPriority { get; set; } - [FieldDefinition(6, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(DelugePriority), HelpText = "Priority to use when grabbing episodes that aired over 14 days ago")] + [FieldDefinition(6, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(DelugePriority), HelpText = "Priority to use when grabbing albums released 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..d4b46ea42 100644 --- a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeTorrent.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json; +using Newtonsoft.Json; namespace NzbDrone.Core.Download.Clients.Deluge { @@ -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/Deluge/DelugeTorrentStatus.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeTorrentStatus.cs index bb8f06396..fc5f3d4b8 100644 --- a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeTorrentStatus.cs +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeTorrentStatus.cs @@ -1,6 +1,6 @@ namespace NzbDrone.Core.Download.Clients.Deluge { - class DelugeTorrentStatus + public class DelugeTorrentStatus { public const string Paused = "Paused"; public const string Queued = "Queued"; diff --git a/src/NzbDrone.Core/Download/Clients/DownloadClientException.cs b/src/NzbDrone.Core/Download/Clients/DownloadClientException.cs index 9598e04ef..79a93ef1f 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadClientException.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadClientException.cs @@ -1,4 +1,4 @@ -using System; +using System; using NzbDrone.Common.Exceptions; namespace NzbDrone.Core.Download.Clients @@ -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..923698cef --- /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/DiskStationApiInfo.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/DiskStationApiInfo.cs index c222a258d..c2a6667ab 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/DiskStationApiInfo.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/DiskStationApiInfo.cs @@ -1,13 +1,19 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation { public class DiskStationApiInfo - { + { private string _path; public int MaxVersion { get; set; } public int MinVersion { get; set; } + public DiskStationApi Type { get; set; } + + public string Name { get; set; } + + public bool NeedsAuthentication { get; set; } + public string Path { get { return _path; } diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationSettings.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationSettings.cs index a1e12d899..627589d73 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationSettings.cs @@ -1,4 +1,4 @@ -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; using FluentValidation; using NzbDrone.Common.Extensions; using NzbDrone.Core.Annotations; @@ -18,9 +18,9 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation .When(c => c.TvDirectory.IsNotNullOrWhiteSpace()) .WithMessage("Cannot start with /"); - RuleFor(c => c.TvCategory).Matches(@"^\.?[-a-z]*$", RegexOptions.IgnoreCase).WithMessage("Allowed characters a-z and -"); + RuleFor(c => c.MusicCategory).Matches(@"^\.?[-a-z]*$", RegexOptions.IgnoreCase).WithMessage("Allowed characters a-z and -"); - RuleFor(c => c.TvCategory).Empty() + RuleFor(c => c.MusicCategory).Empty() .When(c => c.TvDirectory.IsNotNullOrWhiteSpace()) .WithMessage("Cannot use Category and Directory"); } @@ -42,8 +42,8 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation [FieldDefinition(3, Label = "Password", Type = FieldType.Password)] public string Password { get; set; } - [FieldDefinition(4, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Sonarr avoids conflicts with unrelated downloads, but it's optional. Creates a [category] subdirectory in the output directory.")] - public string TvCategory { get; set; } + [FieldDefinition(4, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Lidarr avoids conflicts with unrelated downloads, but it's optional. Creates a [category] subdirectory in the output directory.")] + public string MusicCategory { get; set; } [FieldDefinition(5, Label = "Directory", Type = FieldType.Textbox, HelpText = "Optional shared folder to put downloads into, leave blank to use the default Download Station location")] public string TvDirectory { get; set; } diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTask.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/DownloadStationTask.cs index a22cc7296..6957f3e92 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 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/DSMInfoProxy.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DSMInfoProxy.cs index b5687a0e0..9c18d5702 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DSMInfoProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DSMInfoProxy.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using NLog; +using NzbDrone.Common.Cache; using NzbDrone.Common.Http; using NzbDrone.Core.Download.Clients.DownloadStation.Responses; @@ -13,20 +14,19 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation.Proxies public class DSMInfoProxy : DiskStationProxyBase, IDSMInfoProxy { - public DSMInfoProxy(IHttpClient httpClient, Logger logger) : - base(httpClient, logger) + public DSMInfoProxy(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) : + base(DiskStationApi.DSMInfo, "SYNO.DSM.Info", httpClient, cacheManager, logger) { } public string GetSerialNumber(DownloadStationSettings settings) { - var arguments = new Dictionary() { - { "api", "SYNO.DSM.Info" }, - { "version", "2" }, - { "method", "getinfo" } - }; + var info = GetApiInfo(settings); + + var requestBuilder = BuildRequest(settings, "getinfo", info.MinVersion); + + var response = ProcessRequest(requestBuilder, "get serial number", settings); - var response = ProcessRequest(DiskStationApi.DSMInfo, arguments, settings, "get serial number"); return response.Data.SerialNumber; } } diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DiskStationProxyBase.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DiskStationProxyBase.cs index edaa2ebf6..2162a3d57 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DiskStationProxyBase.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DiskStationProxyBase.cs @@ -1,64 +1,91 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Net; using NLog; +using NzbDrone.Common.Cache; using NzbDrone.Common.Http; using NzbDrone.Common.Serializer; using NzbDrone.Core.Download.Clients.DownloadStation.Responses; namespace NzbDrone.Core.Download.Clients.DownloadStation.Proxies { - public abstract class DiskStationProxyBase + public interface IDiskStationProxy { - private static readonly Dictionary Resources; + DiskStationApiInfo GetApiInfo(DownloadStationSettings settings); + } - private readonly IHttpClient _httpClient; + public abstract class DiskStationProxyBase : IDiskStationProxy + { protected readonly Logger _logger; - private bool _authenticated; + + private readonly IHttpClient _httpClient; + private readonly ICached _infoCache; + private readonly ICached _sessionCache; + private readonly DiskStationApi _apiType; + private readonly string _apiName; + + private static readonly DiskStationApiInfo _apiInfo; static DiskStationProxyBase() { - Resources = new Dictionary + _apiInfo = new DiskStationApiInfo() { - { DiskStationApi.Info, "query.cgi" } + Type = DiskStationApi.Info, + Name = "SYNO.API.Info", + Path = "query.cgi", + MaxVersion = 1, + MinVersion = 1, + NeedsAuthentication = false }; } - public DiskStationProxyBase(IHttpClient httpClient, Logger logger) + public DiskStationProxyBase(DiskStationApi apiType, + string apiName, + IHttpClient httpClient, + ICacheManager cacheManager, + Logger logger) { _httpClient = httpClient; _logger = logger; + _infoCache = cacheManager.GetCache(typeof(DiskStationProxyBase), "apiInfo"); + _sessionCache = cacheManager.GetCache(typeof(DiskStationProxyBase), "sessions"); + _apiType = apiType; + _apiName = apiName; } + private string GenerateSessionCacheKey(DownloadStationSettings settings) + { + return $"{settings.Username}@{settings.Host}:{settings.Port}"; + } - protected DiskStationResponse ProcessRequest(DiskStationApi api, - Dictionary arguments, - DownloadStationSettings settings, - string operation, - HttpMethod method = HttpMethod.GET) + protected DiskStationResponse ProcessRequest(HttpRequestBuilder requestBuilder, + string operation, + DownloadStationSettings settings) where T : new() { - return ProcessRequest(api, arguments, settings, operation, method); + return ProcessRequest(requestBuilder, operation, _apiType, settings); } - protected DiskStationResponse ProcessRequest(DiskStationApi api, - Dictionary arguments, - DownloadStationSettings settings, - string operation, - HttpMethod method = HttpMethod.GET, - int retries = 0) where T : new() + private DiskStationResponse ProcessRequest(HttpRequestBuilder requestBuilder, + string operation, + DiskStationApi api, + DownloadStationSettings settings) where T : new() { - if (retries == 5) + var request = requestBuilder.Build(); + HttpResponse response; + + try { - throw new DownloadClientException("Try to process request to {0} with {1} more than 5 times", api, arguments.ToJson().ToString()); + response = _httpClient.Execute(request); } - - if (!_authenticated && api != DiskStationApi.Info && api != DiskStationApi.DSMInfo) + catch (HttpException ex) { - AuthenticateClient(settings); + 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); } - - var request = BuildRequest(settings, api, arguments, method); - var response = _httpClient.Execute(request); _logger.Debug("Trying to {0}", operation); @@ -77,16 +104,14 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation.Proxies if (responseContent.Error.SessionError) { - _authenticated = false; + _sessionCache.Remove(GenerateSessionCacheKey(settings)); if (responseContent.Error.Code == 105) { throw new DownloadClientAuthenticationException(msg); } - - return ProcessRequest(api, arguments, settings, operation, method, ++retries); } - + throw new DownloadClientException(msg); } } @@ -96,124 +121,126 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation.Proxies } } - private void AuthenticateClient(DownloadStationSettings settings) + private string AuthenticateClient(DownloadStationSettings settings) { - var arguments = new Dictionary - { - { "api", "SYNO.API.Auth" }, - { "version", "1" }, - { "method", "login" }, - { "account", settings.Username }, - { "passwd", settings.Password }, - { "format", "cookie" }, - { "session", "DownloadStation" }, - }; + var authInfo = GetApiInfo(DiskStationApi.Auth, settings); - var authLoginRequest = BuildRequest(settings, DiskStationApi.Auth, arguments, HttpMethod.GET); - authLoginRequest.StoreResponseCookie = true; + var requestBuilder = BuildRequest(settings, authInfo, "login", 2); + requestBuilder.AddQueryParam("account", settings.Username); + requestBuilder.AddQueryParam("passwd", settings.Password); + requestBuilder.AddQueryParam("format", "sid"); + requestBuilder.AddQueryParam("session", "DownloadStation"); - var response = _httpClient.Execute(authLoginRequest); + var authResponse = ProcessRequest(requestBuilder, "login", DiskStationApi.Auth, settings); - var downloadStationResponse = Json.Deserialize>(response.Content); - - var authResponse = Json.Deserialize>(response.Content); + return authResponse.Data.SId; + } - _authenticated = authResponse.Success; + protected HttpRequestBuilder BuildRequest(DownloadStationSettings settings, string methodName, int apiVersion, HttpMethod httpVerb = HttpMethod.GET) + { + var info = GetApiInfo(_apiType, settings); - if (!_authenticated) - { - throw new DownloadClientAuthenticationException(downloadStationResponse.Error.GetMessage(DiskStationApi.Auth)); - } + return BuildRequest(settings, info, methodName, apiVersion, httpVerb); } - private HttpRequest BuildRequest(DownloadStationSettings settings, DiskStationApi api, Dictionary arguments, HttpMethod method) + private HttpRequestBuilder BuildRequest(DownloadStationSettings settings, DiskStationApiInfo apiInfo, string methodName, int apiVersion, HttpMethod httpVerb = HttpMethod.GET) { - if (!Resources.ContainsKey(api)) - { - GetApiVersion(settings, api); - } - - var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port).Resource($"webapi/{Resources[api]}"); - requestBuilder.Method = method; + var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port).Resource($"webapi/{apiInfo.Path}"); + requestBuilder.Method = httpVerb; requestBuilder.LogResponseContent = true; requestBuilder.SuppressHttpError = true; requestBuilder.AllowAutoRedirect = false; + requestBuilder.Headers.ContentType = "application/json"; - if (requestBuilder.Method == HttpMethod.POST) + if (apiVersion < apiInfo.MinVersion || apiVersion > apiInfo.MaxVersion) { - if (api == DiskStationApi.DownloadStationTask && arguments.ContainsKey("file")) - { - requestBuilder.Headers.ContentType = "multipart/form-data"; + throw new ArgumentOutOfRangeException(nameof(apiVersion)); + } - foreach (var arg in arguments) - { - if (arg.Key == "file") - { - Dictionary file = (Dictionary)arg.Value; - requestBuilder.AddFormUpload(arg.Key, file["name"].ToString(), (byte[])file["data"]); - } - else - { - requestBuilder.AddFormParameter(arg.Key, arg.Value); - } - } - } - else + if (httpVerb == HttpMethod.POST) + { + if (apiInfo.NeedsAuthentication) { - requestBuilder.Headers.ContentType = "application/json"; + requestBuilder.AddFormParameter("_sid", _sessionCache.Get(GenerateSessionCacheKey(settings), () => AuthenticateClient(settings), TimeSpan.FromHours(6))); } + + requestBuilder.AddFormParameter("api", apiInfo.Name); + requestBuilder.AddFormParameter("version", apiVersion); + requestBuilder.AddFormParameter("method", methodName); } else { - foreach (var arg in arguments) + if (apiInfo.NeedsAuthentication) { - requestBuilder.AddQueryParam(arg.Key, arg.Value); + requestBuilder.AddQueryParam("_sid", _sessionCache.Get(GenerateSessionCacheKey(settings), () => AuthenticateClient(settings), TimeSpan.FromHours(6))); } + + requestBuilder.AddQueryParam("api", apiInfo.Name); + requestBuilder.AddQueryParam("version", apiVersion); + requestBuilder.AddQueryParam("method", methodName); } - return requestBuilder.Build(); + return requestBuilder; + } + + private string GenerateInfoCacheKey(DownloadStationSettings settings, DiskStationApi api) + { + return $"{settings.Host}:{settings.Port}->{api}"; } - protected IEnumerable GetApiVersion(DownloadStationSettings settings, DiskStationApi api) + private void UpdateApiInfo(DownloadStationSettings settings) { - var arguments = new Dictionary + var apis = new Dictionary() { - { "api", "SYNO.API.Info" }, - { "version", "1" }, - { "method", "query" }, - { "query", "SYNO.API.Auth, SYNO.DownloadStation.Info, SYNO.DownloadStation.Task, SYNO.FileStation.List, SYNO.DSM.Info" }, - }; - - var infoResponse = ProcessRequest(DiskStationApi.Info, arguments, settings, "Get api version"); - - //TODO: Refactor this into more elegant code - var infoResponeDSAuth = infoResponse.Data["SYNO.API.Auth"]; - var infoResponeDSInfo = infoResponse.Data["SYNO.DownloadStation.Info"]; - var infoResponeDSTask = infoResponse.Data["SYNO.DownloadStation.Task"]; - var infoResponseFSList = infoResponse.Data["SYNO.FileStation.List"]; - var infoResponseDSMInfo = infoResponse.Data["SYNO.DSM.Info"]; - - Resources[DiskStationApi.Auth] = infoResponeDSAuth.Path; - Resources[DiskStationApi.DownloadStationInfo] = infoResponeDSInfo.Path; - Resources[DiskStationApi.DownloadStationTask] = infoResponeDSTask.Path; - Resources[DiskStationApi.FileStationList] = infoResponseFSList.Path; - Resources[DiskStationApi.DSMInfo] = infoResponseDSMInfo.Path; - - switch (api) + { "SYNO.API.Auth", DiskStationApi.Auth }, + { _apiName, _apiType } + }; + + var requestBuilder = BuildRequest(settings, _apiInfo, "query", _apiInfo.MinVersion); + requestBuilder.AddQueryParam("query", string.Join(",", apis.Keys)); + + var infoResponse = ProcessRequest(requestBuilder, "get api info", _apiInfo.Type, settings); + + foreach (var data in infoResponse.Data) { - case DiskStationApi.Auth: - return Enumerable.Range(infoResponeDSAuth.MinVersion, infoResponeDSAuth.MaxVersion - infoResponeDSAuth.MinVersion + 1); - case DiskStationApi.DownloadStationInfo: - return Enumerable.Range(infoResponeDSInfo.MinVersion, infoResponeDSInfo.MaxVersion - infoResponeDSInfo.MinVersion + 1); - case DiskStationApi.DownloadStationTask: - return Enumerable.Range(infoResponeDSTask.MinVersion, infoResponeDSTask.MaxVersion - infoResponeDSTask.MinVersion + 1); - case DiskStationApi.FileStationList: - return Enumerable.Range(infoResponseFSList.MinVersion, infoResponseFSList.MaxVersion - infoResponseFSList.MinVersion + 1); - case DiskStationApi.DSMInfo: - return Enumerable.Range(infoResponseDSMInfo.MinVersion, infoResponseDSMInfo.MaxVersion - infoResponseDSMInfo.MinVersion + 1); - default: - throw new DownloadClientException("Api not implemented"); + if (apis.ContainsKey(data.Key)) + { + data.Value.Name = data.Key; + data.Value.Type = apis[data.Key]; + data.Value.NeedsAuthentication = apis[data.Key] != DiskStationApi.Auth; + + _infoCache.Set(GenerateInfoCacheKey(settings, apis[data.Key]), data.Value, TimeSpan.FromHours(1)); + } } } + + private DiskStationApiInfo GetApiInfo(DiskStationApi api, DownloadStationSettings settings) + { + if (api == DiskStationApi.Info) + { + return _apiInfo; + } + + var key = GenerateInfoCacheKey(settings, api); + var info = _infoCache.Find(key); + + if (info == null) + { + UpdateApiInfo(settings); + info = _infoCache.Find(key); + + if (info == null) + { + throw new DownloadClientException("Info of {0} not found on {1}:{2}", api, settings.Host, settings.Port); + } + } + + return info; + } + + public DiskStationApiInfo GetApiInfo(DownloadStationSettings settings) + { + return GetApiInfo(_apiType, settings); + } } } diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationInfoProxy.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationInfoProxy.cs new file mode 100644 index 000000000..9b0cb00ed --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationInfoProxy.cs @@ -0,0 +1,29 @@ +using NLog; +using NzbDrone.Common.Http; +using System.Collections.Generic; +using NzbDrone.Common.Cache; + +namespace NzbDrone.Core.Download.Clients.DownloadStation.Proxies +{ + public interface IDownloadStationInfoProxy : IDiskStationProxy + { + Dictionary GetConfig(DownloadStationSettings settings); + } + + public class DownloadStationInfoProxy : DiskStationProxyBase, IDownloadStationInfoProxy + { + public DownloadStationInfoProxy(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) : + base(DiskStationApi.DownloadStationInfo, "SYNO.DownloadStation.Info", httpClient, cacheManager, logger) + { + } + + public Dictionary GetConfig(DownloadStationSettings settings) + { + var requestBuilder = BuildRequest(settings, "getConfig", 1); + + var response = ProcessRequest>(requestBuilder, "get config", settings); + + return response.Data; + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationProxy.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationProxy.cs deleted file mode 100644 index 427cd3d5b..000000000 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationProxy.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NLog; -using NzbDrone.Common.Extensions; -using NzbDrone.Common.Http; -using NzbDrone.Core.Download.Clients.DownloadStation.Responses; - -namespace NzbDrone.Core.Download.Clients.DownloadStation.Proxies -{ - public interface IDownloadStationProxy - { - IEnumerable GetTasks(DownloadStationSettings settings); - Dictionary GetConfig(DownloadStationSettings settings); - void RemoveTask(string downloadId, DownloadStationSettings settings); - void AddTaskFromUrl(string url, string downloadDirectory, DownloadStationSettings settings); - void AddTaskFromData(byte[] data, string filename, string downloadDirectory, DownloadStationSettings settings); - IEnumerable GetApiVersion(DownloadStationSettings settings); - } - - public class DownloadStationProxy : DiskStationProxyBase, IDownloadStationProxy - { - public DownloadStationProxy(IHttpClient httpClient, Logger logger) - : base(httpClient, logger) - { - } - - public void AddTaskFromData(byte[] data, string filename, string downloadDirectory, DownloadStationSettings settings) - { - var arguments = new Dictionary - { - { "api", "SYNO.DownloadStation.Task" }, - { "version", "2" }, - { "method", "create" } - }; - - if (downloadDirectory.IsNotNullOrWhiteSpace()) - { - arguments.Add("destination", downloadDirectory); - } - - arguments.Add("file", new Dictionary() { { "name", filename }, { "data", data } }); - - var response = ProcessRequest(DiskStationApi.DownloadStationTask, arguments, settings, $"add task from data {filename}", HttpMethod.POST); - } - - public void AddTaskFromUrl(string url, string downloadDirectory, DownloadStationSettings settings) - { - var arguments = new Dictionary - { - { "api", "SYNO.DownloadStation.Task" }, - { "version", "3" }, - { "method", "create" }, - { "uri", url } - }; - - if (downloadDirectory.IsNotNullOrWhiteSpace()) - { - arguments.Add("destination", downloadDirectory); - } - - var response = ProcessRequest(DiskStationApi.DownloadStationTask, arguments, settings, $"add task from url {url}"); - } - - public IEnumerable GetTasks(DownloadStationSettings settings) - { - var arguments = new Dictionary - { - { "api", "SYNO.DownloadStation.Task" }, - { "version", "1" }, - { "method", "list" }, - { "additional", "detail,transfer" } - }; - - try - { - var response = ProcessRequest(DiskStationApi.DownloadStationTask, arguments, settings, "get tasks"); - - return response.Data.Tasks; - } - catch (DownloadClientException e) - { - _logger.Error(e); - return new List(); - } - } - - public Dictionary GetConfig(DownloadStationSettings settings) - { - var arguments = new Dictionary - { - { "api", "SYNO.DownloadStation.Info" }, - { "version", "1" }, - { "method", "getconfig" } - }; - - var response = ProcessRequest>(DiskStationApi.DownloadStationInfo, arguments, settings, "get config"); - - return response.Data; - } - - public void RemoveTask(string downloadId, DownloadStationSettings settings) - { - var arguments = new Dictionary - { - { "api", "SYNO.DownloadStation.Task" }, - { "version", "1" }, - { "method", "delete" }, - { "id", downloadId }, - { "force_complete", false } - }; - - var response = ProcessRequest(DiskStationApi.DownloadStationTask, arguments, settings, $"remove item {downloadId}"); - } - - public IEnumerable GetApiVersion(DownloadStationSettings settings) - { - return base.GetApiVersion(settings, DiskStationApi.DownloadStationInfo); - } - } -} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationTaskProxy.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationTaskProxy.cs new file mode 100644 index 000000000..1e6849dac --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/DownloadStationTaskProxy.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Download.Clients.DownloadStation.Responses; + +namespace NzbDrone.Core.Download.Clients.DownloadStation.Proxies +{ + public interface IDownloadStationTaskProxy : IDiskStationProxy + { + IEnumerable GetTasks(DownloadStationSettings settings); + void RemoveTask(string downloadId, DownloadStationSettings settings); + void AddTaskFromUrl(string url, string downloadDirectory, DownloadStationSettings settings); + void AddTaskFromData(byte[] data, string filename, string downloadDirectory, DownloadStationSettings settings); + } + + public class DownloadStationTaskProxy : DiskStationProxyBase, IDownloadStationTaskProxy + { + public DownloadStationTaskProxy(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) + : base(DiskStationApi.DownloadStationTask, "SYNO.DownloadStation.Task", httpClient, cacheManager, logger) + { + } + + public void AddTaskFromData(byte[] data, string filename, string downloadDirectory, DownloadStationSettings settings) + { + var requestBuilder = BuildRequest(settings, "create", 2, HttpMethod.POST); + + if (downloadDirectory.IsNotNullOrWhiteSpace()) + { + requestBuilder.AddFormParameter("destination", downloadDirectory); + } + + requestBuilder.AddFormUpload("file", filename, data); + + var response = ProcessRequest(requestBuilder, $"add task from data {filename}", settings); + } + + public void AddTaskFromUrl(string url, string downloadDirectory, DownloadStationSettings settings) + { + var requestBuilder = BuildRequest(settings, "create", 3); + requestBuilder.AddQueryParam("uri", url); + + if (downloadDirectory.IsNotNullOrWhiteSpace()) + { + requestBuilder.AddQueryParam("destination", downloadDirectory); + } + + var response = ProcessRequest(requestBuilder, $"add task from url {url}", settings); + } + + public IEnumerable GetTasks(DownloadStationSettings settings) + { + try + { + var requestBuilder = BuildRequest(settings, "list", 1); + requestBuilder.AddQueryParam("additional", "detail,transfer"); + + var response = ProcessRequest(requestBuilder, "get tasks", settings); + + return response.Data.Tasks; + } + catch (DownloadClientException e) + { + _logger.Error(e); + return new List(); + } + } + + public void RemoveTask(string downloadId, DownloadStationSettings settings) + { + var requestBuilder = BuildRequest(settings, "delete", 1); + requestBuilder.AddQueryParam("id", downloadId); + requestBuilder.AddQueryParam("force_complete", false); + + var response = ProcessRequest(requestBuilder, $"remove item {downloadId}", settings); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/FileStationProxy.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/FileStationProxy.cs index 4beacd8e9..29031e0eb 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/FileStationProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Proxies/FileStationProxy.cs @@ -2,31 +2,27 @@ using System.Collections.Generic; using System.Linq; using NLog; +using NzbDrone.Common.Cache; using NzbDrone.Common.Http; using NzbDrone.Common.Serializer; using NzbDrone.Core.Download.Clients.DownloadStation.Responses; namespace NzbDrone.Core.Download.Clients.DownloadStation.Proxies { - public interface IFileStationProxy + public interface IFileStationProxy : IDiskStationProxy { SharedFolderMapping GetSharedFolderMapping(string sharedFolder, DownloadStationSettings settings); - IEnumerable GetApiVersion(DownloadStationSettings settings); + FileStationListFileInfoResponse GetInfoFileOrDirectory(string path, DownloadStationSettings settings); } public class FileStationProxy : DiskStationProxyBase, IFileStationProxy { - public FileStationProxy(IHttpClient httpClient, Logger logger) - : base(httpClient, logger) - { - } - - public IEnumerable GetApiVersion(DownloadStationSettings settings) + public FileStationProxy(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) + : base(DiskStationApi.FileStationList, "SYNO.FileStation.List", httpClient, cacheManager, logger) { - return base.GetApiVersion(settings, DiskStationApi.FileStationList); } - + public SharedFolderMapping GetSharedFolderMapping(string sharedFolder, DownloadStationSettings settings) { var info = GetInfoFileOrDirectory(sharedFolder, settings); @@ -38,16 +34,11 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation.Proxies public FileStationListFileInfoResponse GetInfoFileOrDirectory(string path, DownloadStationSettings settings) { - var arguments = new Dictionary - { - { "api", "SYNO.FileStation.List" }, - { "version", "2" }, - { "method", "getinfo" }, - { "path", new [] { path }.ToJson() }, - { "additional", $"[\"real_path\"]" } - }; - - var response = ProcessRequest(DiskStationApi.FileStationList, arguments, settings, $"get info of {path}"); + var requestBuilder = BuildRequest(settings, "getinfo", 2); + requestBuilder.AddQueryParam("path", new[] { path }.ToJson()); + requestBuilder.AddQueryParam("additional", "[\"real_path\"]"); + + var response = ProcessRequest(requestBuilder, $"get info of {path}", settings); return response.Data.Files.First(); } diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationError.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationError.cs index d8ce31d71..ca56f127b 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationError.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/Responses/DiskStationError.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; namespace NzbDrone.Core.Download.Clients.DownloadStation.Responses { @@ -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 8378a6815..35bfc516d 100644 --- a/src/NzbDrone.Core/Download/Clients/DownloadStation/TorrentDownloadStation.cs +++ b/src/NzbDrone.Core/Download/Clients/DownloadStation/TorrentDownloadStation.cs @@ -1,11 +1,10 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using FluentValidation.Results; using NLog; -using NzbDrone.Common.Cache; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; @@ -14,13 +13,15 @@ using NzbDrone.Core.Download.Clients.DownloadStation.Proxies; using NzbDrone.Core.MediaFiles.TorrentInfo; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.RemotePathMappings; +using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; namespace NzbDrone.Core.Download.Clients.DownloadStation { public class TorrentDownloadStation : TorrentClientBase { - protected readonly IDownloadStationProxy _proxy; + protected readonly IDownloadStationInfoProxy _dsInfoProxy; + protected readonly IDownloadStationTaskProxy _dsTaskProxy; protected readonly ISharedFolderResolver _sharedFolderResolver; protected readonly ISerialNumberProvider _serialNumberProvider; protected readonly IFileStationProxy _fileStationProxy; @@ -28,7 +29,8 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation public TorrentDownloadStation(ISharedFolderResolver sharedFolderResolver, ISerialNumberProvider serialNumberProvider, IFileStationProxy fileStationProxy, - IDownloadStationProxy proxy, + IDownloadStationInfoProxy dsInfoProxy, + IDownloadStationTaskProxy dsTaskProxy, ITorrentFileInfoReader torrentFileInfoReader, IHttpClient httpClient, IConfigService configService, @@ -37,7 +39,8 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation Logger logger) : base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, logger) { - _proxy = proxy; + _dsInfoProxy = dsInfoProxy; + _dsTaskProxy = dsTaskProxy; _fileStationProxy = fileStationProxy; _sharedFolderResolver = sharedFolderResolver; _serialNumberProvider = serialNumberProvider; @@ -45,9 +48,11 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation public override string Name => "Download Station"; + public override ProviderMessage Message => new ProviderMessage("Lidarr is unable to connect to Download Station if 2-Factor Authentication is enabled on your DSM account", ProviderMessageType.Warning); + protected IEnumerable GetTasks() { - return _proxy.GetTasks(Settings).Where(v => v.Type.ToLower() == DownloadStationTaskType.BT.ToString().ToLower()); + return _dsTaskProxy.GetTasks(Settings).Where(v => v.Type.ToLower() == DownloadStationTaskType.BT.ToString().ToLower()); } public override IEnumerable GetItems() @@ -68,10 +73,10 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation continue; } } - else if (Settings.TvCategory.IsNotNullOrWhiteSpace()) + else if (Settings.MusicCategory.IsNotNullOrWhiteSpace()) { var directories = outputPath.FullPath.Split('\\', '/'); - if (!directories.Contains(Settings.TvCategory)) + if (!directories.Contains(Settings.MusicCategory)) { continue; } @@ -79,16 +84,18 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation var item = new DownloadClientItem() { - Category = Settings.TvCategory, + Category = Settings.MusicCategory, DownloadClient = Definition.Name, DownloadId = CreateDownloadId(torrent.Id, serialNumber), Title = torrent.Title, 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) @@ -102,23 +109,26 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation return items; } - public override DownloadClientStatus GetStatus() + public override DownloadClientInfo GetStatus() { try { - var path = GetDownloadDirectory(); + var serialNumber = _serialNumberProvider.GetSerialNumber(Settings); + var sharedFolder = GetDownloadDirectory() ?? GetDefaultDir(); + var outputPath = new OsPath($"/{sharedFolder.TrimStart('/')}"); + var path = _sharedFolderResolver.RemapToFullPath(outputPath, Settings, serialNumber); - 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)) } + OutputRootFolders = new List { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, path) } }; } catch (DownloadClientException e) { _logger.Debug(e, "Failed to get config from Download Station"); - throw e; + throw; } } @@ -129,7 +139,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation DeleteItemData(downloadId); } - _proxy.RemoveTask(ParseDownloadId(downloadId), Settings); + _dsTaskProxy.RemoveTask(ParseDownloadId(downloadId), Settings); _logger.Debug("{0} removed correctly", downloadId); } @@ -144,17 +154,17 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation return finalPath; } - protected override string AddFromMagnetLink(RemoteEpisode remoteEpisode, string hash, string magnetLink) + protected override string AddFromMagnetLink(RemoteAlbum remoteAlbum, string hash, string magnetLink) { var hashedSerialNumber = _serialNumberProvider.GetSerialNumber(Settings); - _proxy.AddTaskFromUrl(magnetLink, GetDownloadDirectory(), Settings); + _dsTaskProxy.AddTaskFromUrl(magnetLink, GetDownloadDirectory(), Settings); var item = GetTasks().SingleOrDefault(t => t.Additional.Detail["uri"] == magnetLink); if (item != null) { - _logger.Debug("{0} added correctly", remoteEpisode); + _logger.Debug("{0} added correctly", remoteAlbum); return CreateDownloadId(item.Id, hashedSerialNumber); } @@ -163,11 +173,11 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation throw new DownloadClientException("Failed to add magnet task to Download Station"); } - protected override string AddFromTorrentFile(RemoteEpisode remoteEpisode, string hash, string filename, byte[] fileContent) + protected override string AddFromTorrentFile(RemoteAlbum remoteAlbum, string hash, string filename, byte[] fileContent) { var hashedSerialNumber = _serialNumberProvider.GetSerialNumber(Settings); - _proxy.AddTaskFromData(fileContent, filename, GetDownloadDirectory(), Settings); + _dsTaskProxy.AddTaskFromData(fileContent, filename, GetDownloadDirectory(), Settings); var items = GetTasks().Where(t => t.Additional.Detail["uri"] == Path.GetFileNameWithoutExtension(filename)); @@ -175,7 +185,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation if (item != null) { - _logger.Debug("{0} added correctly", remoteEpisode); + _logger.Debug("{0} added correctly", remoteAlbum); return CreateDownloadId(item.Id, hashedSerialNumber); } @@ -197,7 +207,12 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation return torrent.Status == DownloadStationTaskStatus.Finished; } - protected string GetMessage(DownloadStationTask torrent) + 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) { @@ -219,7 +234,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; @@ -268,6 +285,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 @@ -287,7 +317,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation if (downloadDir != null) { var sharedFolder = downloadDir.Split('\\', '/')[0]; - var fieldName = Settings.TvDirectory.IsNotNullOrWhiteSpace() ? nameof(Settings.TvDirectory) : nameof(Settings.TvCategory); + var fieldName = Settings.TvDirectory.IsNotNullOrWhiteSpace() ? nameof(Settings.TvDirectory) : nameof(Settings.MusicCategory); var folderInfo = _fileStationProxy.GetInfoFileOrDirectory($"/{downloadDir}", Settings); @@ -312,12 +342,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, "Unable to authenticate"); 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}"); } } @@ -330,15 +360,15 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation } catch (DownloadClientAuthenticationException ex) { - _logger.Error(ex, ex.Message); + _logger.Error(ex, "Unable to authenticate"); return new NzbDroneValidationFailure("Username", "Authentication failure") { - DetailedDescription = $"Please verify your username and password. Also verify if the host running Sonarr isn't blocked from accessing {Name} by WhiteList limitations in the {Name} configuration." + DetailedDescription = $"Please verify your username and password. Also verify if the host running Lidarr isn't blocked from accessing {Name} by WhiteList limitations in the {Name} configuration." }; } catch (WebException ex) { - _logger.Error(ex); + _logger.Error(ex, "Unable to connect to Torrent Download Station"); if (ex.Status == WebExceptionStatus.ConnectFailure) { @@ -351,20 +381,20 @@ 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}"); } } protected ValidationFailure ValidateVersion() { - var versionRange = _proxy.GetApiVersion(Settings); + var info = _dsTaskProxy.GetApiInfo(Settings); - _logger.Debug("Download Station api version information: Min {0} - Max {1}", versionRange.Min(), versionRange.Max()); + _logger.Debug("Download Station api version information: Min {0} - Max {1}", info.MinVersion, info.MaxVersion); - if (!versionRange.Contains(2)) + if (info.MinVersion > 2 || info.MaxVersion < 2) { - return new ValidationFailure(string.Empty, $"Download Station API version not supported, should be at least 2. It supports from {versionRange.Min()} to {versionRange.Max()}"); + return new ValidationFailure(string.Empty, $"Download Station API version not supported, should be at least 2. It supports from {info.MinVersion} to {info.MaxVersion}"); } return null; @@ -395,7 +425,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation protected string GetDefaultDir() { - var config = _proxy.GetConfig(Settings); + var config = _dsInfoProxy.GetConfig(Settings); var path = config["default_destination"] as string; @@ -408,11 +438,11 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation { return Settings.TvDirectory.TrimStart('/'); } - else if (Settings.TvCategory.IsNotNullOrWhiteSpace()) + else if (Settings.MusicCategory.IsNotNullOrWhiteSpace()) { var destDir = GetDefaultDir(); - return $"{destDir.TrimEnd('/')}/{Settings.TvCategory}"; + return $"{destDir.TrimEnd('/')}/{Settings.MusicCategory}"; } return null; diff --git a/src/NzbDrone.Core/Download/Clients/DownloadStation/UsenetDownloadStation.cs b/src/NzbDrone.Core/Download/Clients/DownloadStation/UsenetDownloadStation.cs index ccbe4f75e..7dc638c57 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; @@ -11,13 +11,15 @@ using NzbDrone.Core.Configuration; using NzbDrone.Core.Download.Clients.DownloadStation.Proxies; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.RemotePathMappings; +using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; namespace NzbDrone.Core.Download.Clients.DownloadStation { public class UsenetDownloadStation : UsenetClientBase { - protected readonly IDownloadStationProxy _proxy; + protected readonly IDownloadStationInfoProxy _dsInfoProxy; + protected readonly IDownloadStationTaskProxy _dsTaskProxy; protected readonly ISharedFolderResolver _sharedFolderResolver; protected readonly ISerialNumberProvider _serialNumberProvider; protected readonly IFileStationProxy _fileStationProxy; @@ -25,16 +27,19 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation public UsenetDownloadStation(ISharedFolderResolver sharedFolderResolver, ISerialNumberProvider serialNumberProvider, IFileStationProxy fileStationProxy, - IDownloadStationProxy proxy, + IDownloadStationInfoProxy dsInfoProxy, + IDownloadStationTaskProxy dsTaskProxy, IHttpClient httpClient, 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; + _dsInfoProxy = dsInfoProxy; + _dsTaskProxy = dsTaskProxy; _fileStationProxy = fileStationProxy; _sharedFolderResolver = sharedFolderResolver; _serialNumberProvider = serialNumberProvider; @@ -42,9 +47,11 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation public override string Name => "Download Station"; + public override ProviderMessage Message => new ProviderMessage("Lidarr is unable to connect to Download Station if 2-Factor Authentication is enabled on your DSM account", ProviderMessageType.Warning); + protected IEnumerable GetTasks() { - return _proxy.GetTasks(Settings).Where(v => v.Type.ToLower() == DownloadStationTaskType.NZB.ToString().ToLower()); + return _dsTaskProxy.GetTasks(Settings).Where(v => v.Type.ToLower() == DownloadStationTaskType.NZB.ToString().ToLower()); } public override IEnumerable GetItems() @@ -77,10 +84,10 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation continue; } } - else if (Settings.TvCategory.IsNotNullOrWhiteSpace()) + else if (Settings.MusicCategory.IsNotNullOrWhiteSpace()) { var directories = outputPath.FullPath.Split('\\', '/'); - if (!directories.Contains(Settings.TvCategory)) + if (!directories.Contains(Settings.MusicCategory)) { continue; } @@ -88,7 +95,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation var item = new DownloadClientItem() { - Category = Settings.TvCategory, + Category = Settings.MusicCategory, DownloadClient = Definition.Name, DownloadId = CreateDownloadId(nzb.Id, serialNumber), Title = nzb.Title, @@ -96,7 +103,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) @@ -126,23 +134,26 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation return finalPath; } - public override DownloadClientStatus GetStatus() + public override DownloadClientInfo GetStatus() { try { - var path = GetDownloadDirectory(); + var serialNumber = _serialNumberProvider.GetSerialNumber(Settings); + var sharedFolder = GetDownloadDirectory() ?? GetDefaultDir(); + var outputPath = new OsPath($"/{sharedFolder.TrimStart('/')}"); + var path = _sharedFolderResolver.RemapToFullPath(outputPath, Settings, serialNumber); - 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)) } + OutputRootFolders = new List { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, path) } }; } catch (DownloadClientException e) { _logger.Debug(e, "Failed to get config from Download Station"); - throw e; + throw; } } @@ -153,15 +164,15 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation DeleteItemData(downloadId); } - _proxy.RemoveTask(ParseDownloadId(downloadId), Settings); + _dsTaskProxy.RemoveTask(ParseDownloadId(downloadId), Settings); _logger.Debug("{0} removed correctly", downloadId); } - protected override string AddFromNzbFile(RemoteEpisode remoteEpisode, string filename, byte[] fileContent) + protected override string AddFromNzbFile(RemoteAlbum remoteAlbum, string filename, byte[] fileContent) { var hashedSerialNumber = _serialNumberProvider.GetSerialNumber(Settings); - _proxy.AddTaskFromData(fileContent, filename, GetDownloadDirectory(), Settings); + _dsTaskProxy.AddTaskFromData(fileContent, filename, GetDownloadDirectory(), Settings); var items = GetTasks().Where(t => t.Additional.Detail["uri"] == filename); @@ -169,7 +180,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation if (item != null) { - _logger.Debug("{0} added correctly", remoteEpisode); + _logger.Debug("{0} added correctly", remoteAlbum); return CreateDownloadId(item.Id, hashedSerialNumber); } @@ -205,7 +216,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation if (downloadDir != null) { var sharedFolder = downloadDir.Split('\\', '/')[0]; - var fieldName = Settings.TvDirectory.IsNotNullOrWhiteSpace() ? nameof(Settings.TvDirectory) : nameof(Settings.TvCategory); + var fieldName = Settings.TvDirectory.IsNotNullOrWhiteSpace() ? nameof(Settings.TvDirectory) : nameof(Settings.MusicCategory); var folderInfo = _fileStationProxy.GetInfoFileOrDirectory($"/{downloadDir}", Settings); @@ -230,12 +241,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, "Unable to authenticate"); 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}"); } } @@ -248,15 +259,15 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation } catch (DownloadClientAuthenticationException ex) { - _logger.Error(ex, ex.Message); + _logger.Error(ex, "Unable to authenticate"); return new NzbDroneValidationFailure("Username", "Authentication failure") { - DetailedDescription = $"Please verify your username and password. Also verify if the host running Sonarr isn't blocked from accessing {Name} by WhiteList limitations in the {Name} configuration." + DetailedDescription = $"Please verify your username and password. Also verify if the host running Lidarr isn't blocked from accessing {Name} by WhiteList limitations in the {Name} configuration." }; } catch (WebException ex) { - _logger.Error(ex); + _logger.Error(ex, "Unable to connect to Usenet Download Station"); if (ex.Status == WebExceptionStatus.ConnectFailure) { @@ -269,30 +280,25 @@ 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); } } protected ValidationFailure ValidateVersion() { - var versionRange = _proxy.GetApiVersion(Settings); + var info = _dsTaskProxy.GetApiInfo(Settings); - _logger.Debug("Download Station api version information: Min {0} - Max {1}", versionRange.Min(), versionRange.Max()); + _logger.Debug("Download Station api version information: Min {0} - Max {1}", info.MinVersion, info.MaxVersion); - if (!versionRange.Contains(2)) + if (info.MinVersion > 2 || info.MaxVersion < 2) { - return new ValidationFailure(string.Empty, $"Download Station API version not supported, should be at least 2. It supports from {versionRange.Min()} to {versionRange.Max()}"); + return new ValidationFailure(string.Empty, $"Download Station API version not supported, should be at least 2. It supports from {info.MinVersion} to {info.MaxVersion}"); } return null; } - protected bool IsFinished(DownloadStationTask task) - { - return task.Status == DownloadStationTaskStatus.Finished; - } - protected string GetMessage(DownloadStationTask task) { if (task.StatusExtra != null) @@ -315,7 +321,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; @@ -394,7 +402,7 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation protected string GetDefaultDir() { - var config = _proxy.GetConfig(Settings); + var config = _dsInfoProxy.GetConfig(Settings); var path = config["default_destination"] as string; @@ -407,11 +415,11 @@ namespace NzbDrone.Core.Download.Clients.DownloadStation { return Settings.TvDirectory.TrimStart('/'); } - else if (Settings.TvCategory.IsNotNullOrWhiteSpace()) + else if (Settings.MusicCategory.IsNotNullOrWhiteSpace()) { var destDir = GetDefaultDir(); - return $"{destDir.TrimEnd('/')}/{Settings.TvCategory}"; + return $"{destDir.TrimEnd('/')}/{Settings.MusicCategory}"; } return null; diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/Hadouken.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/Hadouken.cs index 5727dea8b..60c30f61c 100644 --- a/src/NzbDrone.Core/Download/Clients/Hadouken/Hadouken.cs +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/Hadouken.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using FluentValidation.Results; @@ -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" }; @@ -149,14 +134,14 @@ namespace NzbDrone.Core.Download.Clients.Hadouken failures.AddIfNotNull(TestGetTorrents()); } - protected override string AddFromMagnetLink(RemoteEpisode remoteEpisode, string hash, string magnetLink) + protected override string AddFromMagnetLink(RemoteAlbum remoteAlbum, string hash, string magnetLink) { _proxy.AddTorrentUri(Settings, magnetLink); return hash.ToUpper(); } - protected override string AddFromTorrentFile(RemoteEpisode remoteEpisode, string hash, string filename, byte[] fileContent) + protected override string AddFromTorrentFile(RemoteAlbum remoteAlbum, string hash, string filename, byte[] fileContent) { return _proxy.AddTorrentFile(Settings, fileContent).ToUpper(); } @@ -170,12 +155,12 @@ 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) { - _logger.ErrorException(ex.Message, ex); + _logger.Error(ex, "Unable to authenticate"); return new NzbDroneValidationFailure("Password", "Authentication failed"); } @@ -191,7 +176,7 @@ namespace NzbDrone.Core.Download.Clients.Hadouken } catch (Exception ex) { - _logger.ErrorException(ex.Message, ex); + _logger.Error(ex, "Unable to validate"); return new NzbDroneValidationFailure(String.Empty, "Failed to get the list of torrents: " + ex.Message); } diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenProxy.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenProxy.cs index e044dd912..43b11a581 100644 --- a/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenProxy.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Net; using NLog; @@ -21,7 +21,6 @@ namespace NzbDrone.Core.Download.Clients.Hadouken public class HadoukenProxy : IHadoukenProxy { - private static int _callId; private readonly IHttpClient _httpClient; private readonly Logger _logger; @@ -71,13 +70,29 @@ namespace NzbDrone.Core.Download.Clients.Hadouken private T ProcessRequest(HadoukenSettings settings, string method, params object[] parameters) { var baseUrl = HttpRequestBuilder.BuildBaseUrl(settings.UseSsl, settings.Host, settings.Port, "api"); - var requestBuilder = new JsonRpcRequestBuilder(baseUrl, method, parameters); - requestBuilder.LogResponseContent = true; - requestBuilder.NetworkCredential = new NetworkCredential(settings.Username, settings.Password); + var requestBuilder = new JsonRpcRequestBuilder(baseUrl, method, parameters) + { + LogResponseContent = true, + NetworkCredential = new NetworkCredential(settings.Username, settings.Password) + }; 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 +139,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]), @@ -132,7 +148,7 @@ namespace NzbDrone.Core.Download.Clients.Hadouken } catch(Exception ex) { - _logger.ErrorException("Failed to map Hadouken torrent data.", ex); + _logger.Error(ex, "Failed to map Hadouken torrent data."); } return torrent; @@ -160,4 +176,4 @@ namespace NzbDrone.Core.Download.Clients.Hadouken return HadoukenTorrentState.Unknown; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenSettings.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenSettings.cs index f66dbb365..3568662f1 100644 --- a/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/HadoukenSettings.cs @@ -1,4 +1,4 @@ -using FluentValidation; +using FluentValidation; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; @@ -28,7 +28,7 @@ namespace NzbDrone.Core.Download.Clients.Hadouken { Host = "localhost"; Port = 7070; - Category = "sonarr-tv"; + Category = "lidarr-music"; } [FieldDefinition(0, Label = "Host", Type = FieldType.Textbox)] diff --git a/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenTorrent.cs b/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenTorrent.cs index a52180ca2..898a09f69 100644 --- a/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/Hadouken/Models/HadoukenTorrent.cs @@ -1,4 +1,4 @@ -namespace NzbDrone.Core.Download.Clients.Hadouken.Models +namespace NzbDrone.Core.Download.Clients.Hadouken.Models { public sealed class HadoukenTorrent { @@ -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..a80e1cbed 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,15 +23,16 @@ 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; } - protected override string AddFromNzbFile(RemoteEpisode remoteEpisode, string filename, byte[] fileContent) + protected override string AddFromNzbFile(RemoteAlbum remoteAlbum, string filename, byte[] fileContent) { - var priority = remoteEpisode.IsRecentEpisode() ? Settings.RecentTvPriority : Settings.OlderTvPriority; + var priority = remoteAlbum.IsRecentAlbum() ? Settings.RecentTvPriority : Settings.OlderTvPriority; var response = _proxy.DownloadNzb(fileContent, filename, priority, Settings); @@ -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,10 @@ 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 +126,7 @@ namespace NzbDrone.Core.Download.Clients.NzbVortex { _proxy.Remove(queueItem.Id, deleteData, Settings); } - } + } } protected List GetGroups() @@ -140,9 +134,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 +160,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 +181,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"); } @@ -210,13 +204,13 @@ namespace NzbDrone.Core.Download.Clients.NzbVortex private ValidationFailure TestCategory() { - var group = GetGroups().FirstOrDefault(c => c.GroupName == Settings.TvCategory); + var group = GetGroups().FirstOrDefault(c => c.GroupName == Settings.MusicCategory); if (group == null) { - if (Settings.TvCategory.IsNotNullOrWhiteSpace()) + if (Settings.MusicCategory.IsNotNullOrWhiteSpace()) { - return new NzbDroneValidationFailure("TvCategory", "Group does not exist") + return new NzbDroneValidationFailure("MusicCategory", "Group does not exist") { DetailedDescription = "The Group you entered doesn't exist in NzbVortex. Go to NzbVortex to create it." }; @@ -256,4 +250,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..f8249ba32 100644 --- a/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexProxy.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Net; using Newtonsoft.Json; @@ -43,9 +43,9 @@ namespace NzbDrone.Core.Download.Clients.NzbVortex .Post() .AddQueryParam("priority", priority.ToString()); - if (settings.TvCategory.IsNotNullOrWhiteSpace()) + if (settings.MusicCategory.IsNotNullOrWhiteSpace()) { - requestBuilder.AddQueryParam("groupname", settings.TvCategory); + requestBuilder.AddQueryParam("groupname", settings.MusicCategory); } requestBuilder.AddFormUpload("name", filename, nzbData, "application/x-nzb"); @@ -93,9 +93,9 @@ namespace NzbDrone.Core.Download.Clients.NzbVortex var requestBuilder = BuildRequest(settings).Resource("nzb"); - if (settings.TvCategory.IsNotNullOrWhiteSpace()) + if (settings.MusicCategory.IsNotNullOrWhiteSpace()) { - requestBuilder.AddQueryParam("groupName", settings.TvCategory); + requestBuilder.AddQueryParam("groupName", settings.MusicCategory); } requestBuilder.AddQueryParam("limitDone", doneLimit.ToString()); @@ -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/NzbVortex/NzbVortexSettings.cs b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexSettings.cs index e35f48f01..513b165f2 100644 --- a/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/NzbVortex/NzbVortexSettings.cs @@ -1,4 +1,4 @@ -using FluentValidation; +using FluentValidation; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; @@ -15,7 +15,7 @@ namespace NzbDrone.Core.Download.Clients.NzbVortex RuleFor(c => c.ApiKey).NotEmpty() .WithMessage("API Key is required"); - RuleFor(c => c.TvCategory).NotEmpty() + RuleFor(c => c.MusicCategory).NotEmpty() .WithMessage("A category is recommended") .AsWarning(); } @@ -29,7 +29,7 @@ namespace NzbDrone.Core.Download.Clients.NzbVortex { Host = "localhost"; Port = 4321; - TvCategory = "TV Shows"; + MusicCategory = "Music"; RecentTvPriority = (int)NzbVortexPriority.Normal; OlderTvPriority = (int)NzbVortexPriority.Normal; } @@ -43,13 +43,13 @@ namespace NzbDrone.Core.Download.Clients.NzbVortex [FieldDefinition(2, Label = "API Key", Type = FieldType.Textbox)] public string ApiKey { get; set; } - [FieldDefinition(3, Label = "Group", Type = FieldType.Textbox, HelpText = "Adding a category specific to Sonarr avoids conflicts with unrelated downloads, but it's optional")] - public string TvCategory { get; set; } + [FieldDefinition(3, Label = "Group", Type = FieldType.Textbox, HelpText = "Adding a category specific to Lidarr avoids conflicts with unrelated downloads, but it's optional")] + public string MusicCategory { get; set; } - [FieldDefinition(4, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(NzbVortexPriority), HelpText = "Priority to use when grabbing episodes that aired within the last 14 days")] + [FieldDefinition(4, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(NzbVortexPriority), HelpText = "Priority to use when grabbing albums released within the last 14 days")] public int RecentTvPriority { get; set; } - [FieldDefinition(5, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(NzbVortexPriority), HelpText = "Priority to use when grabbing episodes that aired over 14 days ago")] + [FieldDefinition(5, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(NzbVortexPriority), HelpText = "Priority to use when grabbing albums released over 14 days ago")] public int OlderTvPriority { get; set; } public NzbDroneValidationResult Validate() diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/Nzbget.cs index 949186fc7..c1c9822e2 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,17 +26,18 @@ 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; } - protected override string AddFromNzbFile(RemoteEpisode remoteEpisode, string filename, byte[] fileContent) + protected override string AddFromNzbFile(RemoteAlbum remoteAlbum, string filename, byte[] fileContent) { - var category = Settings.TvCategory; + var category = Settings.MusicCategory; - var priority = remoteEpisode.IsRecentEpisode() ? Settings.RecentTvPriority : Settings.OlderTvPriority; + var priority = remoteAlbum.IsRecentAlbum() ? Settings.RecentTvPriority : Settings.OlderTvPriority; var addpaused = Settings.AddPaused; @@ -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,34 +110,27 @@ 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(); foreach (var item in history) { var droneParameter = item.Parameters.SingleOrDefault(p => p.Name == "drone"); - var historyItem = new DownloadClientItem(); + var itemDir = item.FinalDir.IsNullOrWhiteSpace() ? item.DestDir : item.FinalDir; + historyItem.DownloadClient = Definition.Name; historyItem.DownloadId = droneParameter == null ? item.Id.ToString() : droneParameter.Value.ToString(); historyItem.Title = item.Name; historyItem.TotalSize = MakeInt64(item.FileSizeHi, item.FileSizeLo); - historyItem.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(item.DestDir)); + historyItem.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(itemDir)); historyItem.Category = item.Category; 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") { @@ -197,7 +183,7 @@ namespace NzbDrone.Core.Download.Clients.Nzbget public override IEnumerable GetItems() { - return GetQueue().Concat(GetHistory()).Where(downloadClientItem => downloadClientItem.Category == Settings.TvCategory); + return GetQueue().Concat(GetHistory()).Where(downloadClientItem => downloadClientItem.Category == Settings.MusicCategory); } public override void RemoveItem(string downloadId, bool deleteData) @@ -210,13 +196,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 category = GetCategories(config).FirstOrDefault(v => v.Name == Settings.MusicCategory); - var status = new DownloadClientStatus + var status = new DownloadClientInfo { IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost" }; @@ -285,7 +271,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"); } @@ -297,11 +283,11 @@ namespace NzbDrone.Core.Download.Clients.Nzbget var config = _proxy.GetConfig(Settings); var categories = GetCategories(config); - if (!Settings.TvCategory.IsNullOrWhiteSpace() && !categories.Any(v => v.Name == Settings.TvCategory)) + if (!Settings.MusicCategory.IsNullOrWhiteSpace() && !categories.Any(v => v.Name == Settings.MusicCategory)) { - return new NzbDroneValidationFailure("TvCategory", "Category does not exist") + return new NzbDroneValidationFailure("MusicCategory", "Category does not exist") { - InfoLink = string.Format("http://{0}:{1}/", Settings.Host, Settings.Port), + InfoLink = _proxy.GetBaseUrl(Settings), DetailedDescription = "The Category your entered doesn't exist in NzbGet. Go to NzbGet to create it." }; } @@ -313,13 +299,22 @@ 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") { - InfoLink = string.Format("http://{0}:{1}/", Settings.Host, Settings.Port), - DetailedDescription = "NzbGet setting KeepHistory is set to 0. Which prevents Sonarr from seeing completed downloads." + InfoLink = _proxy.GetBaseUrl(Settings), + DetailedDescription = "NzbGet setting KeepHistory is set to 0. Which prevents Lidarr from seeing completed downloads." + }; + } + else if (value > 25000) + { + return new NzbDroneValidationFailure(string.Empty, "NzbGet setting KeepHistory should be less than 25000") + { + InfoLink = _proxy.GetBaseUrl(Settings), + DetailedDescription = "NzbGet setting KeepHistory is set too high." }; } diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetHistoryItem.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetHistoryItem.cs index b36885cf9..a108f5176 100644 --- a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetHistoryItem.cs +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetHistoryItem.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; namespace NzbDrone.Core.Download.Clients.Nzbget { @@ -16,6 +16,7 @@ namespace NzbDrone.Core.Download.Clients.Nzbget public string DeleteStatus { get; set; } public string MarkStatus { get; set; } public string DestDir { get; set; } + public string FinalDir { get; set; } public List Parameters { get; set; } } } diff --git a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetProxy.cs b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetProxy.cs index 7a21b45b1..d79108532 100644 --- a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetProxy.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using NLog; @@ -11,6 +11,7 @@ namespace NzbDrone.Core.Download.Clients.Nzbget { public interface INzbgetProxy { + string GetBaseUrl(NzbgetSettings settings, string relativePath = null); string DownloadNzb(byte[] nzbData, string title, string category, int priority, bool addpaused, NzbgetSettings settings); NzbgetGlobalStatus GetGlobalStatus(NzbgetSettings settings); List GetQueue(NzbgetSettings settings); @@ -36,9 +37,17 @@ namespace NzbDrone.Core.Download.Clients.Nzbget _versionCache = cacheManager.GetCache(GetType(), "versions"); } + public string GetBaseUrl(NzbgetSettings settings, string relativePath = null) + { + var baseUrl = HttpRequestBuilder.BuildBaseUrl(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase); + baseUrl = HttpUri.CombinePath(baseUrl, relativePath); + + return baseUrl; + } + private bool HasVersion(int minimumVersion, NzbgetSettings settings) { - var versionString = _versionCache.Find(settings.Host + ":" + settings.Port) ?? GetVersion(settings); + var versionString = _versionCache.Find(GetBaseUrl(settings)) ?? GetVersion(settings); var version = int.Parse(versionString.Split(new[] { '.', '-' })[0]); @@ -139,7 +148,7 @@ namespace NzbDrone.Core.Download.Clients.Nzbget { var response = ProcessRequest(settings, "version"); - _versionCache.Set(settings.Host + ":" + settings.Port, response, TimeSpan.FromDays(1)); + _versionCache.Set(GetBaseUrl(settings), response, TimeSpan.FromDays(1)); return response; } @@ -161,7 +170,7 @@ namespace NzbDrone.Core.Download.Clients.Nzbget if (id.Length < 10 && int.TryParse(id, out nzbId)) { - // Download wasn't grabbed by Sonarr, so the id is the NzbId reported by nzbget. + // Download wasn't grabbed by Lidarr, so the id is the NzbId reported by nzbget. queueItem = queue.SingleOrDefault(h => h.NzbId == nzbId); historyItem = history.SingleOrDefault(h => h.Id == nzbId); } @@ -170,7 +179,7 @@ namespace NzbDrone.Core.Download.Clients.Nzbget queueItem = queue.SingleOrDefault(h => h.Parameters.Any(p => p.Name == "drone" && id == (p.Value as string))); historyItem = history.SingleOrDefault(h => h.Parameters.Any(p => p.Name == "drone" && id == (p.Value as string))); } - + if (queueItem != null) { if (!EditQueue("GroupFinalDelete", 0, "", queueItem.NzbId, settings)) @@ -218,7 +227,7 @@ namespace NzbDrone.Core.Download.Clients.Nzbget private T ProcessRequest(NzbgetSettings settings, string method, params object[] parameters) { - var baseUrl = HttpRequestBuilder.BuildBaseUrl(settings.UseSsl, settings.Host, settings.Port, "jsonrpc"); + var baseUrl = GetBaseUrl(settings, "jsonrpc"); var requestBuilder = new JsonRpcRequestBuilder(baseUrl, method, parameters); requestBuilder.LogResponseContent = true; @@ -235,14 +244,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..8b8607798 100644 --- a/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/Nzbget/NzbgetSettings.cs @@ -1,4 +1,5 @@ -using FluentValidation; +using FluentValidation; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; @@ -11,10 +12,12 @@ namespace NzbDrone.Core.Download.Clients.Nzbget { RuleFor(c => c.Host).ValidHost(); RuleFor(c => c.Port).InclusiveBetween(1, 65535); + RuleFor(c => c.UrlBase).ValidUrlBase().When(c => c.UrlBase.IsNotNullOrWhiteSpace()); + RuleFor(c => c.Username).NotEmpty().When(c => !string.IsNullOrWhiteSpace(c.Password)); RuleFor(c => c.Password).NotEmpty().When(c => !string.IsNullOrWhiteSpace(c.Username)); - RuleFor(c => c.TvCategory).NotEmpty().WithMessage("A category is recommended").AsWarning(); + RuleFor(c => c.MusicCategory).NotEmpty().WithMessage("A category is recommended").AsWarning(); } } @@ -26,7 +29,9 @@ namespace NzbDrone.Core.Download.Clients.Nzbget { Host = "localhost"; Port = 6789; - TvCategory = "tv"; + MusicCategory = "Music"; + Username = "nzbget"; + Password = "tegbzn6789"; RecentTvPriority = (int)NzbgetPriority.Normal; OlderTvPriority = (int)NzbgetPriority.Normal; } @@ -37,27 +42,30 @@ namespace NzbDrone.Core.Download.Clients.Nzbget [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox)] public int Port { get; set; } - [FieldDefinition(2, Label = "Username", Type = FieldType.Textbox)] + [FieldDefinition(2, Label = "Url Base", Type = FieldType.Textbox, Advanced = true, HelpText = "Adds a prefix to the nzbget url, e.g. http://[host]:[port]/[urlBase]/jsonrpc")] + public string UrlBase { get; set; } + + [FieldDefinition(3, Label = "Username", Type = FieldType.Textbox)] public string Username { get; set; } - [FieldDefinition(3, Label = "Password", Type = FieldType.Password)] + [FieldDefinition(4, Label = "Password", Type = FieldType.Password)] public string Password { get; set; } - [FieldDefinition(4, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Sonarr avoids conflicts with unrelated downloads, but it's optional")] - public string TvCategory { get; set; } + [FieldDefinition(5, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Lidarr avoids conflicts with unrelated downloads, but it's optional")] + public string MusicCategory { get; set; } - [FieldDefinition(5, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(NzbgetPriority), HelpText = "Priority to use when grabbing episodes that aired within the last 14 days")] + [FieldDefinition(6, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(NzbgetPriority), HelpText = "Priority to use when grabbing albums released within the last 14 days")] public int RecentTvPriority { get; set; } - [FieldDefinition(6, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(NzbgetPriority), HelpText = "Priority to use when grabbing episodes that aired over 14 days ago")] + [FieldDefinition(7, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(NzbgetPriority), HelpText = "Priority to use when grabbing albums released 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")] public bool AddPaused { get; set; } + [FieldDefinition(9, 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..f5ad06edf 100644 --- a/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs +++ b/src/NzbDrone.Core/Download/Clients/Pneumatic/Pneumatic.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using FluentValidation.Results; @@ -32,14 +32,14 @@ namespace NzbDrone.Core.Download.Clients.Pneumatic public override DownloadProtocol Protocol => DownloadProtocol.Usenet; - public override string Download(RemoteEpisode remoteEpisode) + public override string Download(RemoteAlbum remoteAlbum) { - var url = remoteEpisode.Release.DownloadUrl; - var title = remoteEpisode.Release.Title; + var url = remoteAlbum.Release.DownloadUrl; + var title = remoteAlbum.Release.Title; - if (remoteEpisode.ParsedEpisodeInfo.FullSeason) + if (remoteAlbum.ParsedAlbumInfo.Discography) { - throw new NotSupportedException("Full season releases are not supported with Pneumatic."); + throw new NotSupportedException("Discography releases are not supported with Pneumatic."); } title = FileNameBuilder.CleanFileName(title); @@ -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 }; @@ -118,25 +121,14 @@ namespace NzbDrone.Core.Download.Clients.Pneumatic private string WriteStrmFile(string title, string nzbFile) { - string folder; if (Settings.StrmFolder.IsNullOrWhiteSpace()) { - folder = _configService.DownloadedEpisodesFolder; - - if (folder.IsNullOrWhiteSpace()) - { - throw new DownloadClientException("Strm Folder needs to be set for Pneumatic Downloader"); - } - } - - else - { - folder = Settings.StrmFolder; + throw new DownloadClientException("Strm Folder needs to be set for Pneumatic Downloader"); } var contents = string.Format("plugin://plugin.program.pneumatic/?mode=strm&type=add_file&nzb={0}&nzbname={1}", nzbFile, title); - var filename = Path.Combine(folder, title + ".strm"); + var filename = Path.Combine(Settings.StrmFolder, title + ".strm"); _diskProvider.WriteAllText(filename, contents); diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs index 55eec2682..6ac50635f 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs @@ -1,25 +1,25 @@ -using System; -using System.Linq; +using System; using System.Collections.Generic; +using System.Linq; +using System.Net; +using FluentValidation.Results; +using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; using NzbDrone.Core.MediaFiles.TorrentInfo; -using NLog; -using NzbDrone.Core.Validation; -using FluentValidation.Results; -using System.Net; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.RemotePathMappings; +using NzbDrone.Core.Validation; namespace NzbDrone.Core.Download.Clients.QBittorrent { public class QBittorrent : TorrentClientBase { - private readonly IQBittorrentProxy _proxy; + private readonly IQBittorrentProxySelector _proxySelector; - public QBittorrent(IQBittorrentProxy proxy, + public QBittorrent(IQBittorrentProxySelector proxySelector, ITorrentFileInfoReader torrentFileInfoReader, IHttpClient httpClient, IConfigService configService, @@ -28,44 +28,79 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent Logger logger) : base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, logger) { - _proxy = proxy; + _proxySelector = proxySelector; } - protected override string AddFromMagnetLink(RemoteEpisode remoteEpisode, string hash, string magnetLink) + private IQBittorrentProxy Proxy => _proxySelector.GetProxy(Settings); + + protected override string AddFromMagnetLink(RemoteAlbum remoteAlbum, string hash, string magnetLink) { - _proxy.AddTorrentFromUrl(magnetLink, Settings); + if (!Proxy.GetConfig(Settings).DhtEnabled && !magnetLink.Contains("&tr=")) + { + throw new NotSupportedException("Magnet Links without trackers not supported if DHT is disabled"); + } + + Proxy.AddTorrentFromUrl(magnetLink, Settings); + + if (Settings.MusicCategory.IsNotNullOrWhiteSpace()) + { + Proxy.SetTorrentLabel(hash.ToLower(), Settings.MusicCategory, Settings); + } + + var isRecentAlbum = remoteAlbum.IsRecentAlbum(); - if (Settings.TvCategory.IsNotNullOrWhiteSpace()) + if (isRecentAlbum && Settings.RecentTvPriority == (int)QBittorrentPriority.First || + !isRecentAlbum && Settings.OlderTvPriority == (int)QBittorrentPriority.First) { - _proxy.SetTorrentLabel(hash.ToLower(), Settings.TvCategory, Settings); + Proxy.MoveTorrentToTopInQueue(hash.ToLower(), Settings); } - var isRecentEpisode = remoteEpisode.IsRecentEpisode(); + SetInitialState(hash.ToLower()); - if (isRecentEpisode && Settings.RecentTvPriority == (int)QBittorrentPriority.First || - !isRecentEpisode && Settings.OlderTvPriority == (int)QBittorrentPriority.First) + if (remoteAlbum.SeedConfiguration != null && (remoteAlbum.SeedConfiguration.Ratio.HasValue || remoteAlbum.SeedConfiguration.SeedTime.HasValue)) { - _proxy.MoveTorrentToTopInQueue(hash.ToLower(), Settings); + Proxy.SetTorrentSeedingConfiguration(hash.ToLower(), remoteAlbum.SeedConfiguration, Settings); } return hash; } - protected override string AddFromTorrentFile(RemoteEpisode remoteEpisode, string hash, string filename, Byte[] fileContent) + protected override string AddFromTorrentFile(RemoteAlbum remoteAlbum, string hash, string filename, Byte[] fileContent) { - _proxy.AddTorrentFromFile(filename, fileContent, Settings); + Proxy.AddTorrentFromFile(filename, fileContent, Settings); - if (Settings.TvCategory.IsNotNullOrWhiteSpace()) + try + { + if (Settings.MusicCategory.IsNotNullOrWhiteSpace()) + { + Proxy.SetTorrentLabel(hash.ToLower(), Settings.MusicCategory, Settings); + } + } + catch (Exception ex) { - _proxy.SetTorrentLabel(hash.ToLower(), Settings.TvCategory, Settings); + _logger.Warn(ex, "Failed to set the torrent label for {0}.", filename); } - var isRecentEpisode = remoteEpisode.IsRecentEpisode(); + try + { + var isRecentAlbum = remoteAlbum.IsRecentAlbum(); - if (isRecentEpisode && Settings.RecentTvPriority == (int)QBittorrentPriority.First || - !isRecentEpisode && Settings.OlderTvPriority == (int)QBittorrentPriority.First) + if (isRecentAlbum && Settings.RecentTvPriority == (int)QBittorrentPriority.First || + !isRecentAlbum && Settings.OlderTvPriority == (int)QBittorrentPriority.First) + { + Proxy.MoveTorrentToTopInQueue(hash.ToLower(), Settings); + } + } + catch (Exception ex) { - _proxy.MoveTorrentToTopInQueue(hash.ToLower(), Settings); + _logger.Warn(ex, "Failed to set the torrent priority for {0}.", filename); + } + + SetInitialState(hash.ToLower()); + + if (remoteAlbum.SeedConfiguration != null && (remoteAlbum.SeedConfiguration.Ratio.HasValue || remoteAlbum.SeedConfiguration.SeedTime.HasValue)) + { + Proxy.SetTorrentSeedingConfiguration(hash.ToLower(), remoteAlbum.SeedConfiguration, Settings); } return hash; @@ -75,38 +110,29 @@ 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(); foreach (var torrent in torrents) { - var item = new DownloadClientItem(); - item.DownloadId = torrent.Hash.ToUpper(); - item.Category = torrent.Category.IsNotNullOrWhiteSpace() ? torrent.Category : torrent.Label; - item.Title = torrent.Name; - item.TotalSize = torrent.Size; - item.DownloadClient = Definition.Name; - item.RemainingSize = (long)(torrent.Size * (1.0 - torrent.Progress)); - item.RemainingTime = TimeSpan.FromSeconds(torrent.Eta); - - item.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(torrent.SavePath)); + var item = new DownloadClientItem + { + DownloadId = torrent.Hash.ToUpper(), + Category = torrent.Category.IsNotNullOrWhiteSpace() ? torrent.Category : torrent.Label, + Title = torrent.Name, + TotalSize = torrent.Size, + DownloadClient = Definition.Name, + RemainingSize = (long)(torrent.Size * (1.0 - torrent.Progress)), + RemainingTime = GetRemainingTime(torrent), + SeedRatio = torrent.Ratio, + 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 = (torrent.State == "pausedUP" && HasReachedSeedLimit(torrent, config)); if (!item.OutputPath.IsEmpty && item.OutputPath.FileName != torrent.Name) { @@ -117,7 +143,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 @@ -134,6 +160,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent case "stalledUP": // torrent is being seeded, but no connection were made case "queuedUP": // queuing is enabled and torrent is queued for upload case "checkingUP": // torrent has finished downloading and is being checked + case "forcedUP": // torrent has finished downloading and is being forcibly seeded item.Status = DownloadItemStatus.Completed; item.RemainingTime = TimeSpan.Zero; // qBittorrent sends eta=8640000 for completed torrents break; @@ -143,6 +170,18 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent item.Message = "The download is stalled with no connections"; break; + case "metaDL": // torrent magnet is being downloaded + if (config.DhtEnabled) + { + item.Status = DownloadItemStatus.Queued; + } + else + { + item.Status = DownloadItemStatus.Warning; + item.Message = "qBittorrent cannot resolve magnet link with DHT disabled"; + } + break; + case "downloading": // torrent is being downloaded and data is being transfered default: // new status in API? default to downloading item.Status = DownloadItemStatus.Downloading; @@ -157,16 +196,16 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent public override void RemoveItem(string hash, bool deleteData) { - _proxy.RemoveTorrent(hash.ToLower(), deleteData, Settings); + Proxy.RemoveTorrent(hash.ToLower(), deleteData, Settings); } - public override DownloadClientStatus GetStatus() + public override DownloadClientInfo GetStatus() { - var config = _proxy.GetConfig(Settings); + 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) } @@ -176,7 +215,11 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent protected override void Test(List failures) { failures.AddIfNotNull(TestConnection()); - if (failures.Any()) return; + if (failures.Any()) + { + return; + } + failures.AddIfNotNull(TestPrioritySupport()); failures.AddIfNotNull(TestGetTorrents()); } @@ -184,8 +227,8 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent { try { - var version = _proxy.GetVersion(Settings); - if (version < 5) + var version = _proxySelector.GetProxy(Settings, true).GetApiVersion(Settings); + if (version < Version.Parse("1.5")) { // API version 5 introduced the "save_path" property in /query/torrents return new NzbDroneValidationFailure("Host", "Unsupported client version") @@ -193,10 +236,10 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent DetailedDescription = "Please upgrade to qBittorrent version 3.2.4 or higher." }; } - else if (version < 6) + else if (version < Version.Parse("1.6")) { // API version 6 introduced support for labels - if (Settings.TvCategory.IsNotNullOrWhiteSpace()) + if (Settings.MusicCategory.IsNotNullOrWhiteSpace()) { return new NzbDroneValidationFailure("Category", "Category is not supported") { @@ -204,29 +247,29 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent }; } } - else if (Settings.TvCategory.IsNullOrWhiteSpace()) + else if (Settings.MusicCategory.IsNullOrWhiteSpace()) { // warn if labels are supported, but category is not provided - return new NzbDroneValidationFailure("TvCategory", "Category is recommended") + return new NzbDroneValidationFailure("MusicCategory", "Category is recommended") { IsWarning = true, - DetailedDescription = "Sonarr will not attempt to import completed downloads without a category." + DetailedDescription = "Lidarr will not attempt to import completed downloads without a category." }; } // Complain if qBittorrent is configured to remove torrents on max ratio - var config = _proxy.GetConfig(Settings); - if (config.MaxRatioEnabled && config.RemoveOnMaxRatio) + var config = Proxy.GetConfig(Settings); + if ((config.MaxRatioEnabled || config.MaxSeedingTimeEnabled) && 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'." + DetailedDescription = "Lidarr 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'." }; } } catch (DownloadClientAuthenticationException ex) { - _logger.Error(ex); + _logger.Error(ex, "Unable to authenticate"); return new NzbDroneValidationFailure("Username", "Authentication failure") { DetailedDescription = "Please verify your username and password." @@ -234,7 +277,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 +289,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); } @@ -257,15 +335,106 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent { try { - _proxy.GetTorrents(Settings); + Proxy.GetTorrents(Settings); } 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; + } + + // qBittorrent sends eta=8640000 if unknown such as queued + if (torrent.Eta == 8640000) + { + return null; + } + + return TimeSpan.FromSeconds((int)torrent.Eta); + } + + protected bool HasReachedSeedLimit(QBittorrentTorrent torrent, QBittorrentPreferences config) + { + if (torrent.RatioLimit >= 0) + { + if (torrent.Ratio >= torrent.RatioLimit) + { + return true; + } + } + else if (torrent.RatioLimit == -2 && config.MaxRatioEnabled) + { + if (torrent.Ratio >= config.MaxRatio) + { + return true; + } + } + + if (torrent.SeedingTimeLimit >= 0) + { + if (!torrent.SeedingTime.HasValue) + { + FetchTorrentDetails(torrent); + } + + if (torrent.SeedingTime >= torrent.SeedingTimeLimit) + { + return true; + } + } + else if (torrent.SeedingTimeLimit == -2 && config.MaxSeedingTimeEnabled) + { + if (!torrent.SeedingTime.HasValue) + { + FetchTorrentDetails(torrent); + } + + if (torrent.SeedingTime >= config.MaxSeedingTime) + { + return true; + } + } + + return false; + } + + protected void FetchTorrentDetails(QBittorrentTorrent torrent) + { + var torrentProperties = Proxy.GetTorrentProperties(torrent.Hash, Settings); + + torrent.SeedingTime = torrentProperties.SeedingTime; + } } } diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPreferences.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPreferences.cs index 9fddb1116..4728e9b5d 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPreferences.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPreferences.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json; +using Newtonsoft.Json; namespace NzbDrone.Core.Download.Clients.QBittorrent { @@ -14,7 +14,19 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent [JsonProperty(PropertyName = "max_ratio")] public float MaxRatio { get; set; } // Get the global share ratio limit + [JsonProperty(PropertyName = "max_seeding_time_enabled")] + public bool MaxSeedingTimeEnabled { get; set; } // True if share time limit is enabled + + [JsonProperty(PropertyName = "max_seeding_time")] + public long MaxSeedingTime { get; set; } // Get the global share time limit in minutes + [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; + + [JsonProperty(PropertyName = "dht")] + public bool DhtEnabled { get; set; } // DHT enabled (needed for more peers and magnet downloads) } } diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxy.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxy.cs deleted file mode 100644 index e00c57585..000000000 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxy.cs +++ /dev/null @@ -1,247 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net; -using NLog; -using NzbDrone.Common.Cache; -using NzbDrone.Common.Extensions; -using NzbDrone.Common.Http; -using NzbDrone.Common.Serializer; - -namespace NzbDrone.Core.Download.Clients.QBittorrent -{ - // API https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-Documentation - - public interface IQBittorrentProxy - { - int GetVersion(QBittorrentSettings settings); - QBittorrentPreferences GetConfig(QBittorrentSettings settings); - List GetTorrents(QBittorrentSettings settings); - - void AddTorrentFromUrl(string torrentUrl, QBittorrentSettings settings); - void AddTorrentFromFile(string fileName, Byte[] fileContent, QBittorrentSettings settings); - - void RemoveTorrent(string hash, Boolean removeData, QBittorrentSettings settings); - void SetTorrentLabel(string hash, string label, QBittorrentSettings settings); - void MoveTorrentToTopInQueue(string hash, QBittorrentSettings settings); - } - - public class QBittorrentProxy : IQBittorrentProxy - { - private readonly IHttpClient _httpClient; - private readonly Logger _logger; - private readonly ICached> _authCookieCache; - - public QBittorrentProxy(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) - { - _httpClient = httpClient; - _logger = logger; - - _authCookieCache = cacheManager.GetCache>(GetType(), "authCookies"); - } - - public int GetVersion(QBittorrentSettings settings) - { - var request = BuildRequest(settings).Resource("/version/api"); - var response = ProcessRequest(request, settings); - - return response; - } - - public QBittorrentPreferences GetConfig(QBittorrentSettings settings) - { - var request = BuildRequest(settings).Resource("/query/preferences"); - var response = ProcessRequest(request, settings); - - return response; - } - - public List GetTorrents(QBittorrentSettings settings) - { - var request = BuildRequest(settings).Resource("/query/torrents") - .AddQueryParam("label", settings.TvCategory) - .AddQueryParam("category", settings.TvCategory); - - var response = ProcessRequest>(request, settings); - - return response; - } - - public void AddTorrentFromUrl(string torrentUrl, QBittorrentSettings settings) - { - var request = BuildRequest(settings).Resource("/command/download") - .Post() - .AddFormParameter("urls", torrentUrl); - - ProcessRequest(request, settings); - } - - public void AddTorrentFromFile(string fileName, Byte[] fileContent, QBittorrentSettings settings) - { - var request = BuildRequest(settings).Resource("/command/upload") - .Post() - .AddFormUpload("torrents", fileName, fileContent); - - ProcessRequest(request, settings); - } - - public void RemoveTorrent(string hash, Boolean removeData, QBittorrentSettings settings) - { - var request = BuildRequest(settings).Resource(removeData ? "/command/deletePerm" : "/command/delete") - .Post() - .AddFormParameter("hashes", hash); - - ProcessRequest(request, settings); - } - - public void SetTorrentLabel(string hash, string label, QBittorrentSettings settings) - { - var setCategoryRequest = BuildRequest(settings).Resource("/command/setCategory") - .Post() - .AddFormParameter("hashes", hash) - .AddFormParameter("category", label); - try - { - 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 (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); - } - } - } - - public void MoveTorrentToTopInQueue(string hash, QBittorrentSettings settings) - { - var request = BuildRequest(settings).Resource("/command/topPrio") - .Post() - .AddFormParameter("hashes", hash); - - try - { - var response = 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? - if (ex.InnerException is HttpException && (ex.InnerException as HttpException).Response.StatusCode == HttpStatusCode.Forbidden) - { - return; - } - - throw; - } - - } - - private HttpRequestBuilder BuildRequest(QBittorrentSettings settings) - { - var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port); - requestBuilder.LogResponseContent = true; - requestBuilder.NetworkCredential = new NetworkCredential(settings.Username, settings.Password); - - return requestBuilder; - } - - private TResult ProcessRequest(HttpRequestBuilder requestBuilder, QBittorrentSettings settings) - where TResult : new() - { - AuthenticateClient(requestBuilder, settings); - - var request = requestBuilder.Build(); - - HttpResponse response; - try - { - response = _httpClient.Execute(request); - } - catch (HttpException ex) - { - if (ex.Response.StatusCode == HttpStatusCode.Forbidden) - { - _logger.Debug("Authentication required, logging in."); - - AuthenticateClient(requestBuilder, settings, true); - - request = requestBuilder.Build(); - - response = _httpClient.Execute(request); - } - else - { - 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); - } - - return Json.Deserialize(response.Content); - } - - private void AuthenticateClient(HttpRequestBuilder requestBuilder, QBittorrentSettings settings, bool reauthenticate = false) - { - if (settings.Username.IsNullOrWhiteSpace() || settings.Password.IsNullOrWhiteSpace()) - { - return; - } - - var authKey = string.Format("{0}:{1}", requestBuilder.BaseUrl, settings.Password); - - var cookies = _authCookieCache.Find(authKey); - - if (cookies == null || reauthenticate) - { - _authCookieCache.Remove(authKey); - - var authLoginRequest = BuildRequest(settings).Resource("/login") - .Post() - .AddFormParameter("username", settings.Username ?? string.Empty) - .AddFormParameter("password", settings.Password ?? string.Empty) - .Build(); - - HttpResponse response; - try - { - response = _httpClient.Execute(authLoginRequest); - } - catch (HttpException ex) - { - _logger.Debug("qbitTorrent authentication failed."); - if (ex.Response.StatusCode == HttpStatusCode.Forbidden) - { - throw new DownloadClientAuthenticationException("Failed to authenticate with qbitTorrent.", 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); - } - - if (response.Content != "Ok.") // returns "Fails." on bad login - { - _logger.Debug("qbitTorrent authentication failed."); - throw new DownloadClientAuthenticationException("Failed to authenticate with qbitTorrent."); - } - - _logger.Debug("qbitTorrent authentication succeeded."); - - cookies = response.GetCookies(); - - _authCookieCache.Set(authKey, cookies); - } - - requestBuilder.SetCookies(cookies); - } - } -} diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs new file mode 100644 index 000000000..c60b0ff19 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; + +using NLog; +using NzbDrone.Common.Cache; + +using NzbDrone.Common.Http; + + +namespace NzbDrone.Core.Download.Clients.QBittorrent +{ + public interface IQBittorrentProxy + { + bool IsApiSupported(QBittorrentSettings settings); + Version GetApiVersion(QBittorrentSettings settings); + string GetVersion(QBittorrentSettings settings); + QBittorrentPreferences GetConfig(QBittorrentSettings settings); + List GetTorrents(QBittorrentSettings settings); + QBittorrentTorrentProperties GetTorrentProperties(string hash, QBittorrentSettings settings); + + void AddTorrentFromUrl(string torrentUrl, QBittorrentSettings settings); + void AddTorrentFromFile(string fileName, byte[] fileContent, QBittorrentSettings settings); + + void RemoveTorrent(string hash, bool removeData, QBittorrentSettings settings); + void SetTorrentLabel(string hash, string label, QBittorrentSettings settings); + void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, 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 interface IQBittorrentProxySelector + { + IQBittorrentProxy GetProxy(QBittorrentSettings settings, bool force = false); + } + + public class QBittorrentProxySelector : IQBittorrentProxySelector + { + private readonly IHttpClient _httpClient; + private readonly ICached _proxyCache; + private readonly Logger _logger; + + private readonly IQBittorrentProxy _proxyV1; + private readonly IQBittorrentProxy _proxyV2; + + public QBittorrentProxySelector(QBittorrentProxyV1 proxyV1, + QBittorrentProxyV2 proxyV2, + IHttpClient httpClient, + ICacheManager cacheManager, + Logger logger) + { + _httpClient = httpClient; + _proxyCache = cacheManager.GetCache(GetType()); + _logger = logger; + + _proxyV1 = proxyV1; + _proxyV2 = proxyV2; + } + + public IQBittorrentProxy GetProxy(QBittorrentSettings settings, bool force) + { + var proxyKey = $"{settings.Host}_{settings.Port}"; + + if (force) + { + _proxyCache.Remove(proxyKey); + } + + return _proxyCache.Get(proxyKey, () => FetchProxy(settings), TimeSpan.FromMinutes(10.0)); + } + + private IQBittorrentProxy FetchProxy(QBittorrentSettings settings) + { + if (_proxyV2.IsApiSupported(settings)) + { + _logger.Trace("Using qbitTorrent API v2"); + return _proxyV2; + } + + if (_proxyV1.IsApiSupported(settings)) + { + _logger.Trace("Using qbitTorrent API v1"); + return _proxyV1; + } + + throw new DownloadClientException("Unable to determine qBittorrent API version"); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs new file mode 100644 index 000000000..d9077a9c2 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs @@ -0,0 +1,362 @@ +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; +using System; +using System.Collections.Generic; +using System.Net; + +namespace NzbDrone.Core.Download.Clients.QBittorrent +{ + // API https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-Documentation + + public class QBittorrentProxyV1 : IQBittorrentProxy + { + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + private readonly ICached> _authCookieCache; + + public QBittorrentProxyV1(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + + _authCookieCache = cacheManager.GetCache>(GetType(), "authCookies"); + } + + public bool IsApiSupported(QBittorrentSettings settings) + { + // We can do the api test without having to authenticate since v4.1 will return 404 on the request. + var request = BuildRequest(settings).Resource("/version/api"); + request.SuppressHttpError = true; + + try + { + var response = _httpClient.Execute(request.Build()); + + // Version request will return 404 if it doesn't exist. + if (response.StatusCode == HttpStatusCode.NotFound) + { + return false; + } + + if (response.StatusCode == HttpStatusCode.Forbidden) + { + return true; + } + + if (response.HasHttpError) + { + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", new HttpException(response)); + } + + return true; + } + catch (WebException ex) + { + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", ex); + } + } + + public Version GetApiVersion(QBittorrentSettings settings) + { + // Version request does not require authentication and will return 404 if it doesn't exist. + var request = BuildRequest(settings).Resource("/version/api"); + var response = Version.Parse("1." + ProcessRequest(request, settings)); + + return response; + } + + public string GetVersion(QBittorrentSettings settings) + { + // Version request does not require authentication. + var request = BuildRequest(settings).Resource("/version/qbittorrent"); + var response = ProcessRequest(request, settings).TrimStart('v'); + + return response; + } + + public QBittorrentPreferences GetConfig(QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/query/preferences"); + var response = ProcessRequest(request, settings); + + return response; + } + + public List GetTorrents(QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/query/torrents"); + + if (settings.MusicCategory.IsNotNullOrWhiteSpace()) + { + request.AddQueryParam("label", settings.MusicCategory); + request.AddQueryParam("category", settings.MusicCategory); + } + + var response = ProcessRequest>(request, settings); + + return response; + } + + public QBittorrentTorrentProperties GetTorrentProperties(string hash, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource($"/query/propertiesGeneral/{hash}"); + var response = ProcessRequest(request, settings); + + return response; + } + + public void AddTorrentFromUrl(string torrentUrl, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/command/download") + .Post() + .AddFormParameter("urls", torrentUrl); + + if (settings.MusicCategory.IsNotNullOrWhiteSpace()) + { + request.AddFormParameter("category", settings.MusicCategory); + } + + 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) + { + var request = BuildRequest(settings).Resource("/command/upload") + .Post() + .AddFormUpload("torrents", fileName, fileContent); + + if (settings.MusicCategory.IsNotNullOrWhiteSpace()) + { + request.AddFormParameter("category", settings.MusicCategory); + } + + 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, bool removeData, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource(removeData ? "/command/deletePerm" : "/command/delete") + .Post() + .AddFormParameter("hashes", hash); + + ProcessRequest(request, settings); + } + + public void SetTorrentLabel(string hash, string label, QBittorrentSettings settings) + { + var setCategoryRequest = BuildRequest(settings).Resource("/command/setCategory") + .Post() + .AddFormParameter("hashes", hash) + .AddFormParameter("category", label); + try + { + ProcessRequest(setCategoryRequest, settings); + } + catch (DownloadClientException ex) + { + // 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); + } + } + } + + public void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings) + { + // Not supported on api v1 + } + + public void MoveTorrentToTopInQueue(string hash, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/command/topPrio") + .Post() + .AddFormParameter("hashes", hash); + + try + { + ProcessRequest(request, settings); + } + catch (DownloadClientException ex) + { + // qBittorrent rejects all Prio commands with 403: Forbidden if Options -> BitTorrent -> Torrent Queueing is not enabled + if (ex.InnerException is HttpException && (ex.InnerException as HttpException).Response.StatusCode == HttpStatusCode.Forbidden) + { + return; + } + + throw; + } + + } + + 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) + { + LogResponseContent = true, + NetworkCredential = new NetworkCredential(settings.Username, settings.Password) + }; + + return requestBuilder; + } + + 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 + { + response = _httpClient.Execute(request); + } + catch (HttpException ex) + { + if (ex.Response.StatusCode == HttpStatusCode.Forbidden) + { + _logger.Debug("Authentication required, logging in."); + + AuthenticateClient(requestBuilder, settings, true); + + request = requestBuilder.Build(); + + response = _httpClient.Execute(request); + } + else + { + 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); + } + + return response.Content; + } + + private void AuthenticateClient(HttpRequestBuilder requestBuilder, QBittorrentSettings settings, bool reauthenticate = false) + { + if (settings.Username.IsNullOrWhiteSpace() || settings.Password.IsNullOrWhiteSpace()) + { + return; + } + + var authKey = string.Format("{0}:{1}", requestBuilder.BaseUrl, settings.Password); + + var cookies = _authCookieCache.Find(authKey); + + if (cookies == null || reauthenticate) + { + _authCookieCache.Remove(authKey); + + var authLoginRequest = BuildRequest(settings).Resource("/login") + .Post() + .AddFormParameter("username", settings.Username ?? string.Empty) + .AddFormParameter("password", settings.Password ?? string.Empty) + .Build(); + + HttpResponse response; + try + { + response = _httpClient.Execute(authLoginRequest); + } + catch (HttpException ex) + { + _logger.Debug("qbitTorrent authentication failed."); + if (ex.Response.StatusCode == HttpStatusCode.Forbidden) + { + throw new DownloadClientAuthenticationException("Failed to authenticate with qBittorrent.", ex); + } + + throw new DownloadClientException("Failed to connect to qBittorrent, please check your settings.", ex); + } + catch (WebException 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."); + } + + _logger.Debug("qBittorrent authentication succeeded."); + + cookies = response.GetCookies(); + + _authCookieCache.Set(authKey, cookies); + } + + requestBuilder.SetCookies(cookies); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs new file mode 100644 index 000000000..d223f92e3 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs @@ -0,0 +1,372 @@ +using System; +using System.Collections.Generic; +using System.Net; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; + +namespace NzbDrone.Core.Download.Clients.QBittorrent +{ + // API https://github.com/qbittorrent/qBittorrent/wiki/Web-API-Documentation + + public class QBittorrentProxyV2 : IQBittorrentProxy + { + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + private readonly ICached> _authCookieCache; + + public QBittorrentProxyV2(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + + _authCookieCache = cacheManager.GetCache>(GetType(), "authCookies"); + } + + public bool IsApiSupported(QBittorrentSettings settings) + { + // We can do the api test without having to authenticate since v3.2.0-v4.0.4 will return 404 on the request. + var request = BuildRequest(settings).Resource("/api/v2/app/webapiVersion"); + request.SuppressHttpError = true; + + try + { + var response = _httpClient.Execute(request.Build()); + + // Version request will return 404 if it doesn't exist. + if (response.StatusCode == HttpStatusCode.NotFound) + { + return false; + } + + if (response.StatusCode == HttpStatusCode.Forbidden) + { + return true; + } + + if (response.HasHttpError) + { + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", new HttpException(response)); + } + + return true; + } + catch (WebException ex) + { + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", ex); + } + } + + public Version GetApiVersion(QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/app/webapiVersion"); + var response = Version.Parse(ProcessRequest(request, settings)); + + return response; + } + + public string GetVersion(QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/app/version"); + var response = ProcessRequest(request, settings).TrimStart('v'); + + // eg "4.2alpha" + return response; + } + + public QBittorrentPreferences GetConfig(QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/app/preferences"); + var response = ProcessRequest(request, settings); + + return response; + } + + public List GetTorrents(QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/info"); + if (settings.MusicCategory.IsNotNullOrWhiteSpace()) + { + request.AddQueryParam("category", settings.MusicCategory); + } + + var response = ProcessRequest>(request, settings); + + return response; + } + + public QBittorrentTorrentProperties GetTorrentProperties(string hash, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/properties") + .AddQueryParam("hash", hash); + var response = ProcessRequest(request, settings); + + return response; + } + + public void AddTorrentFromUrl(string torrentUrl, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/add") + .Post() + .AddFormParameter("urls", torrentUrl); + if (settings.MusicCategory.IsNotNullOrWhiteSpace()) + { + request.AddFormParameter("category", settings.MusicCategory); + } + + 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) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/add") + .Post() + .AddFormUpload("torrents", fileName, fileContent); + + if (settings.MusicCategory.IsNotNullOrWhiteSpace()) + { + request.AddFormParameter("category", settings.MusicCategory); + } + + 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, bool removeData, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/delete") + .Post() + .AddFormParameter("hashes", hash); + + if (removeData) + { + request.AddFormParameter("deleteFiles", "true"); + } + + ProcessRequest(request, settings); + } + + public void SetTorrentLabel(string hash, string label, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/setCategory") + .Post() + .AddFormParameter("hashes", hash) + .AddFormParameter("category", label); + ProcessRequest(request, settings); + } + + public void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings) + { + var ratioLimit = seedConfiguration.Ratio.HasValue ? seedConfiguration.Ratio : -2; + var seedingTimeLimit = seedConfiguration.SeedTime.HasValue ? (long)seedConfiguration.SeedTime.Value.TotalMinutes : -2; + + var request = BuildRequest(settings).Resource("/api/v2/torrents/setShareLimits") + .Post() + .AddFormParameter("hashes", hash) + .AddFormParameter("ratioLimit", ratioLimit) + .AddFormParameter("seedingTimeLimit", seedingTimeLimit); + + try + { + ProcessRequest(request, settings); + } + catch (DownloadClientException ex) + { + // setShareLimits was added in api v2.0.1 so catch it case of the unlikely event that someone has api v2.0 + if (ex.InnerException is HttpException && (ex.InnerException as HttpException).Response.StatusCode == HttpStatusCode.NotFound) + { + return; + } + + throw; + } + } + + public void MoveTorrentToTopInQueue(string hash, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/topPrio") + .Post() + .AddFormParameter("hashes", hash); + + try + { + ProcessRequest(request, settings); + } + catch (DownloadClientException ex) + { + // qBittorrent rejects all Prio commands with 409: Conflict if Options -> BitTorrent -> Torrent Queueing is not enabled + if (ex.InnerException is HttpException && (ex.InnerException as HttpException).Response.StatusCode == HttpStatusCode.Conflict) + { + return; + } + + throw; + } + + } + + public void PauseTorrent(string hash, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/pause") + .Post() + .AddFormParameter("hashes", hash); + ProcessRequest(request, settings); + } + + public void ResumeTorrent(string hash, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/resume") + .Post() + .AddFormParameter("hashes", hash); + ProcessRequest(request, settings); + } + + public void SetForceStart(string hash, bool enabled, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/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) + { + LogResponseContent = true, + NetworkCredential = new NetworkCredential(settings.Username, settings.Password) + }; + return requestBuilder; + } + + 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 + { + response = _httpClient.Execute(request); + } + catch (HttpException ex) + { + if (ex.Response.StatusCode == HttpStatusCode.Forbidden) + { + _logger.Debug("Authentication required, logging in."); + + AuthenticateClient(requestBuilder, settings, true); + + request = requestBuilder.Build(); + + response = _httpClient.Execute(request); + } + else + { + 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); + } + + return response.Content; + } + + private void AuthenticateClient(HttpRequestBuilder requestBuilder, QBittorrentSettings settings, bool reauthenticate = false) + { + if (settings.Username.IsNullOrWhiteSpace() || settings.Password.IsNullOrWhiteSpace()) + { + if (reauthenticate) + { + throw new DownloadClientAuthenticationException("Failed to authenticate with qBittorrent."); + } + return; + } + + var authKey = string.Format("{0}:{1}", requestBuilder.BaseUrl, settings.Password); + + var cookies = _authCookieCache.Find(authKey); + + if (cookies == null || reauthenticate) + { + _authCookieCache.Remove(authKey); + + var authLoginRequest = BuildRequest(settings).Resource("/api/v2/auth/login") + .Post() + .AddFormParameter("username", settings.Username ?? string.Empty) + .AddFormParameter("password", settings.Password ?? string.Empty) + .Build(); + + HttpResponse response; + try + { + response = _httpClient.Execute(authLoginRequest); + } + catch (HttpException ex) + { + _logger.Debug("qbitTorrent authentication failed."); + if (ex.Response.StatusCode == HttpStatusCode.Forbidden) + { + throw new DownloadClientAuthenticationException("Failed to authenticate with qBittorrent.", ex); + } + + throw new DownloadClientException("Failed to connect to qBittorrent, please check your settings.", ex); + } + catch (WebException 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."); + } + + _logger.Debug("qBittorrent authentication succeeded."); + + cookies = response.GetCookies(); + + _authCookieCache.Set(authKey, cookies); + } + + requestBuilder.SetCookies(cookies); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentSettings.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentSettings.cs index 9bb87ce63..bee84f902 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,8 +21,8 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent public QBittorrentSettings() { Host = "localhost"; - Port = 9091; - TvCategory = "tv-sonarr"; + Port = 8080; + MusicCategory = "lidarr"; } [FieldDefinition(0, Label = "Host", Type = FieldType.Textbox)] @@ -37,16 +37,19 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent [FieldDefinition(3, Label = "Password", Type = FieldType.Password)] public string Password { get; set; } - [FieldDefinition(4, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Sonarr avoids conflicts with unrelated downloads, but it's optional")] - public string TvCategory { get; set; } + [FieldDefinition(4, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Lidarr avoids conflicts with unrelated downloads, but it's optional")] + public string MusicCategory { get; set; } - [FieldDefinition(5, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(QBittorrentPriority), HelpText = "Priority to use when grabbing episodes that aired within the last 14 days")] + [FieldDefinition(5, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(QBittorrentPriority), HelpText = "Priority to use when grabbing albums released within the last 14 days")] public int RecentTvPriority { get; set; } - [FieldDefinition(6, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(QBittorrentPriority), HelpText = "Priority to use when grabbing episodes that aired over 14 days ago")] + [FieldDefinition(6, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(QBittorrentPriority), HelpText = "Priority to use when grabbing albums released 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..93a07e749 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) public string State { get; set; } // Torrent state. See possible values here below @@ -24,5 +25,23 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent public string SavePath { get; set; } // Torrent save path public float Ratio { get; set; } // Torrent share ratio + + [JsonProperty(PropertyName = "ratio_limit")] // Per torrent seeding ratio limit (-2 = use global, -1 = unlimited) + public float RatioLimit { get; set; } = -2; + + [JsonProperty(PropertyName = "seeding_time")] + public long? SeedingTime { get; set; } // Torrent seeding time (not provided by the list api) + + [JsonProperty(PropertyName = "seeding_time_limit")] // Per torrent seeding time limit (-2 = use global, -1 = unlimited) + public long SeedingTimeLimit { get; set; } = -2; + + } + + public class QBittorrentTorrentProperties + { + public string Hash { get; set; } // Torrent hash + + [JsonProperty(PropertyName = "seeding_time")] + public long SeedingTime { get; set; } // Torrent seeding time } } diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdFullStatusResponse.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdFullStatusResponse.cs new file mode 100644 index 000000000..48c9710c5 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Responses/SabnzbdFullStatusResponse.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.Download.Clients.Sabnzbd.Responses +{ + public class SabnzbdFullStatusResponse + { + public SabnzbdFullStatus Status { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/Sabnzbd.cs index 46811c3e8..3cf61d8a5 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; @@ -8,6 +8,7 @@ using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; +using NzbDrone.Core.Exceptions; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Validation; using NzbDrone.Core.RemotePathMappings; @@ -23,8 +24,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; } @@ -32,35 +34,24 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd // patch can be a number (releases) or 'x' (git) private static readonly Regex VersionRegex = new Regex(@"(?\d+)\.(?\d+)\.(?\d+|x)", RegexOptions.Compiled); - protected override string AddFromNzbFile(RemoteEpisode remoteEpisode, string filename, byte[] fileContent) + protected override string AddFromNzbFile(RemoteAlbum remoteAlbum, string filename, byte[] fileContent) { - var category = Settings.TvCategory; - var priority = remoteEpisode.IsRecentEpisode() ? Settings.RecentTvPriority : Settings.OlderTvPriority; + var category = Settings.MusicCategory; + var priority = remoteAlbum.IsRecentAlbum() ? Settings.RecentTvPriority : Settings.OlderTvPriority; var response = _proxy.DownloadNzb(fileContent, filename, category, priority, Settings); - if (response != null && response.Ids.Any()) + if (response == null || response.Ids.Empty()) { - return response.Ids.First(); + throw new DownloadClientRejectedReleaseException(remoteAlbum.Release, "SABnzbd rejected the NZB for an unknown reason"); } - return null; + return response.Ids.First(); } private IEnumerable GetQueue() { - SabnzbdQueue sabQueue; - - try - { - sabQueue = _proxy.GetQueue(0, 0, Settings); - } - catch (DownloadClientException ex) - { - _logger.Warn("Couldn't get download queue. {0}", ex.Message); - return Enumerable.Empty(); - } - + var sabQueue = _proxy.GetQueue(0, 0, Settings); var queueItems = new List(); foreach (var sabQueueItem in sabQueue.Items) @@ -78,8 +69,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 +104,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.MusicCategory, Settings); var historyItems = new List(); @@ -142,7 +126,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) @@ -195,7 +182,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd { foreach (var downloadClientItem in GetQueue().Concat(GetHistory())) { - if (downloadClientItem.Category == Settings.TvCategory || downloadClientItem.Category == "*" && Settings.TvCategory.IsNullOrWhiteSpace()) + if (downloadClientItem.Category == Settings.MusicCategory || downloadClientItem.Category == "*" && Settings.MusicCategory.IsNullOrWhiteSpace()) { yield return downloadClientItem; } @@ -220,10 +207,18 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd if (!completeDir.IsRooted) { - var queue = _proxy.GetQueue(0, 1, Settings); - var defaultRootFolder = new OsPath(queue.DefaultRootFolder); + if (HasVersion(2, 0)) + { + var status = _proxy.GetFullStatus(Settings); + completeDir = new OsPath(status.CompleteDir); + } + else + { + var queue = _proxy.GetQueue(0, 1, Settings); + var defaultRootFolder = new OsPath(queue.DefaultRootFolder); - completeDir = defaultRootFolder + completeDir; + completeDir = defaultRootFolder + completeDir; + } } foreach (var category in config.Categories) @@ -236,19 +231,19 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd } } - public override DownloadClientStatus GetStatus() + public override DownloadClientInfo GetStatus() { var config = _proxy.GetConfig(Settings); var categories = GetCategories(config).ToArray(); - var category = categories.FirstOrDefault(v => v.Name == Settings.TvCategory); + var category = categories.FirstOrDefault(v => v.Name == Settings.MusicCategory); if (category == null) { category = categories.FirstOrDefault(v => v.Name == "*"); } - var status = new DownloadClientStatus + var status = new DownloadClientInfo { IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost" }; @@ -311,6 +306,11 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd private Version ParseVersion(string version) { + if (version.IsNullOrWhiteSpace()) + { + return null; + } + var parsed = VersionRegex.Match(version); int major; @@ -348,7 +348,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)) @@ -356,7 +356,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd return new NzbDroneValidationFailure("Version", "Sabnzbd develop version, assuming version 1.1.0 or higher.") { IsWarning = true, - DetailedDescription = "Sonarr may not be able to support new features added to SABnzbd when running develop versions." + DetailedDescription = "Lidarr may not be able to support new features added to SABnzbd when running develop versions." }; } @@ -374,7 +374,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd } catch (Exception ex) { - _logger.Error(ex); + _logger.Error(ex, "Unable to authenticate"); return new ValidationFailure("Host", "Unable to connect to SABnzbd"); } } @@ -408,8 +408,8 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd { return new NzbDroneValidationFailure("", "Disable 'Check before download' option in Sabnbzd") { - InfoLink = string.Format("http://{0}:{1}/sabnzbd/config/switches/", Settings.Host, Settings.Port), - DetailedDescription = "Using Check before download affects Sonarr ability to track new downloads. Also Sabnzbd recommends 'Abort jobs that cannot be completed' instead since it's more effective." + InfoLink = _proxy.GetBaseUrl(Settings, "config/switches/"), + DetailedDescription = "Using Check before download affects Lidarr ability to track new downloads. Also Sabnzbd recommends 'Abort jobs that cannot be completed' instead since it's more effective." }; } @@ -419,74 +419,71 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd private ValidationFailure TestCategory() { var config = _proxy.GetConfig(Settings); - var category = GetCategories(config).FirstOrDefault((SabnzbdCategory v) => v.Name == Settings.TvCategory); + var category = GetCategories(config).FirstOrDefault((SabnzbdCategory v) => v.Name == Settings.MusicCategory); if (category != null) { if (category.Dir.EndsWith("*")) { - return new NzbDroneValidationFailure("TvCategory", "Enable Job folders") + return new NzbDroneValidationFailure("MusicCategory", "Enable Job folders") { - InfoLink = string.Format("http://{0}:{1}/sabnzbd/config/categories/", Settings.Host, Settings.Port), - DetailedDescription = "Sonarr prefers each download to have a separate folder. With * appended to the Folder/Path Sabnzbd will not create these job folders. Go to Sabnzbd to fix it." + InfoLink = _proxy.GetBaseUrl(Settings, "config/categories/"), + DetailedDescription = "Lidarr prefers each download to have a separate folder. With * appended to the Folder/Path Sabnzbd will not create these job folders. Go to Sabnzbd to fix it." }; } } else { - if (!Settings.TvCategory.IsNullOrWhiteSpace()) + if (!Settings.MusicCategory.IsNullOrWhiteSpace()) { - return new NzbDroneValidationFailure("TvCategory", "Category does not exist") + return new NzbDroneValidationFailure("MusicCategory", "Category does not exist") { - InfoLink = string.Format("http://{0}:{1}/sabnzbd/config/categories/", Settings.Host, Settings.Port), + InfoLink = _proxy.GetBaseUrl(Settings, "config/categories/"), DetailedDescription = "The Category your entered doesn't exist in Sabnzbd. Go to Sabnzbd to create it." }; } } - - if (config.Misc.enable_tv_sorting) + if (config.Misc.enable_tv_sorting && ContainsCategory(config.Misc.tv_categories, Settings.MusicCategory)) { - if (!config.Misc.tv_categories.Any() || - config.Misc.tv_categories.Contains(Settings.TvCategory) || - (Settings.TvCategory.IsNullOrWhiteSpace() && config.Misc.tv_categories.Contains("Default"))) + return new NzbDroneValidationFailure("MusicCategory", "Disable TV Sorting") { - return new NzbDroneValidationFailure("TvCategory", "Disable TV Sorting") - { - InfoLink = string.Format("http://{0}:{1}/sabnzbd/config/sorting/", Settings.Host, Settings.Port), - DetailedDescription = "You must disable Sabnzbd TV Sorting for the category Sonarr uses to prevent import issues. Go to Sabnzbd to fix it." - }; - } + InfoLink = _proxy.GetBaseUrl(Settings, "config/sorting/"), + DetailedDescription = "You must disable Sabnzbd TV Sorting for the category Lidarr uses to prevent import issues. Go to Sabnzbd to fix it." + }; } - - if (config.Misc.enable_movie_sorting) + if (config.Misc.enable_movie_sorting && ContainsCategory(config.Misc.movie_categories, Settings.MusicCategory)) { - if (!config.Misc.movie_categories.Any() || - config.Misc.movie_categories.Contains(Settings.TvCategory) || - (Settings.TvCategory.IsNullOrWhiteSpace() && config.Misc.movie_categories.Contains("Default"))) + return new NzbDroneValidationFailure("MusicCategory", "Disable Movie Sorting") { - return new NzbDroneValidationFailure("TvCategory", "Disable Movie Sorting") - { - InfoLink = string.Format("http://{0}:{1}/sabnzbd/config/sorting/", Settings.Host, Settings.Port), - DetailedDescription = "You must disable Sabnzbd Movie Sorting for the category Sonarr uses to prevent import issues. Go to Sabnzbd to fix it." - }; - } + InfoLink = _proxy.GetBaseUrl(Settings, "config/sorting/"), + DetailedDescription = "You must disable Sabnzbd Movie Sorting for the category Lidarr uses to prevent import issues. Go to Sabnzbd to fix it." + }; } - - if (config.Misc.enable_date_sorting) + if (config.Misc.enable_date_sorting && ContainsCategory(config.Misc.date_categories, Settings.MusicCategory)) { - if (!config.Misc.date_categories.Any() || - config.Misc.date_categories.Contains(Settings.TvCategory) || - (Settings.TvCategory.IsNullOrWhiteSpace() && config.Misc.date_categories.Contains("Default"))) + return new NzbDroneValidationFailure("MusicCategory", "Disable Date Sorting") { - return new NzbDroneValidationFailure("TvCategory", "Disable Date Sorting") - { - InfoLink = string.Format("http://{0}:{1}/sabnzbd/config/sorting/", Settings.Host, Settings.Port), - DetailedDescription = "You must disable Sabnzbd Date Sorting for the category Sonarr uses to prevent import issues. Go to Sabnzbd to fix it." - }; - } + InfoLink = _proxy.GetBaseUrl(Settings, "config/sorting/"), + DetailedDescription = "You must disable Sabnzbd Date Sorting for the category Lidarr uses to prevent import issues. Go to Sabnzbd to fix it." + }; } return null; } + + private bool ContainsCategory(IEnumerable categories, string category) + { + if (categories == null || categories.Empty()) + { + return true; + } + + if (category.IsNullOrWhiteSpace()) + { + category = "Default"; + } + + return categories.Contains(category); + } } } diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdFullStatus.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdFullStatus.cs new file mode 100644 index 000000000..d1d691fc6 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdFullStatus.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.Sabnzbd +{ + public class SabnzbdFullStatus + { + // Added in Sabnzbd 2.0.0, my_home was previously in &mode=queue. + // This is the already resolved completedir path. + [JsonProperty(PropertyName = "completedir")] + public string CompleteDir { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs index a3fc32f71..cd103cf1b 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdProxy.cs @@ -1,4 +1,4 @@ -using System; +using System; using Newtonsoft.Json.Linq; using NLog; using NzbDrone.Common.Extensions; @@ -11,10 +11,12 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd { public interface ISabnzbdProxy { + string GetBaseUrl(SabnzbdSettings settings, string relativePath = null); SabnzbdAddResponse DownloadNzb(byte[] nzbData, string filename, string category, int priority, SabnzbdSettings settings); void RemoveFrom(string source, string id,bool deleteData, SabnzbdSettings settings); string GetVersion(SabnzbdSettings settings); SabnzbdConfig GetConfig(SabnzbdSettings settings); + SabnzbdFullStatus GetFullStatus(SabnzbdSettings settings); SabnzbdQueue GetQueue(int start, int limit, SabnzbdSettings settings); SabnzbdHistory GetHistory(int start, int limit, string category, SabnzbdSettings settings); string RetryDownload(string id, SabnzbdSettings settings); @@ -31,13 +33,21 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd _logger = logger; } + public string GetBaseUrl(SabnzbdSettings settings, string relativePath = null) + { + var baseUrl = HttpRequestBuilder.BuildBaseUrl(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase); + baseUrl = HttpUri.CombinePath(baseUrl, relativePath); + + return baseUrl; + } + public SabnzbdAddResponse DownloadNzb(byte[] nzbData, string filename, string category, int priority, SabnzbdSettings settings) { var request = BuildRequest("addfile", settings).Post(); request.AddQueryParam("cat", category); request.AddQueryParam("priority", priority); - + request.AddFormUpload("name", filename, nzbData, "application/x-nzb"); SabnzbdAddResponse response; @@ -84,6 +94,16 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd return response.Config; } + public SabnzbdFullStatus GetFullStatus(SabnzbdSettings settings) + { + var request = BuildRequest("fullstatus", settings); + request.AddQueryParam("skip_dashboard", "1"); + + var response = Json.Deserialize(ProcessRequest(request, settings)); + + return response.Status; + } + public SabnzbdQueue GetQueue(int start, int limit, SabnzbdSettings settings) { var request = BuildRequest("queue", settings); @@ -129,10 +149,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd private HttpRequestBuilder BuildRequest(string mode, SabnzbdSettings settings) { - var baseUrl = string.Format(@"{0}://{1}:{2}/api", - settings.UseSsl ? "https" : "http", - settings.Host, - settings.Port); + var baseUrl = GetBaseUrl(settings, "api"); var requestBuilder = new HttpRequestBuilder(baseUrl) .Accept(HttpAccept.Json) @@ -172,7 +189,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/Sabnzbd/SabnzbdQueue.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdQueue.cs index 5640ae4ec..405d9dec9 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdQueue.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdQueue.cs @@ -5,6 +5,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd { public class SabnzbdQueue { + // Removed in Sabnzbd 2.0.0, see mode=fullstatus instead. [JsonProperty(PropertyName = "my_home")] public string DefaultRootFolder { get; set; } diff --git a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdSettings.cs b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdSettings.cs index a4f21b70a..18be6319d 100644 --- a/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/Sabnzbd/SabnzbdSettings.cs @@ -1,4 +1,5 @@ -using FluentValidation; +using FluentValidation; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; @@ -11,6 +12,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd { RuleFor(c => c.Host).ValidHost(); RuleFor(c => c.Port).InclusiveBetween(1, 65535); + RuleFor(c => c.UrlBase).ValidUrlBase().When(c => c.UrlBase.IsNotNullOrWhiteSpace()); RuleFor(c => c.ApiKey).NotEmpty() .WithMessage("API Key is required when username/password are not configured") @@ -24,7 +26,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd .WithMessage("Password is required when API key is not configured") .When(c => string.IsNullOrWhiteSpace(c.ApiKey)); - RuleFor(c => c.TvCategory).NotEmpty() + RuleFor(c => c.MusicCategory).NotEmpty() .WithMessage("A category is recommended") .AsWarning(); } @@ -38,7 +40,7 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd { Host = "localhost"; Port = 8080; - TvCategory = "tv"; + MusicCategory = "music"; RecentTvPriority = (int)SabnzbdPriority.Default; OlderTvPriority = (int)SabnzbdPriority.Default; } @@ -49,25 +51,28 @@ namespace NzbDrone.Core.Download.Clients.Sabnzbd [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox)] public int Port { get; set; } - [FieldDefinition(2, Label = "API Key", Type = FieldType.Textbox)] + [FieldDefinition(2, Label = "Url Base", Type = FieldType.Textbox, Advanced = true, HelpText = "Adds a prefix to the Sabnzbd url, e.g. http://[host]:[port]/[urlBase]/api")] + public string UrlBase { get; set; } + + [FieldDefinition(3, Label = "API Key", Type = FieldType.Textbox)] public string ApiKey { get; set; } - [FieldDefinition(3, Label = "Username", Type = FieldType.Textbox)] + [FieldDefinition(4, Label = "Username", Type = FieldType.Textbox)] public string Username { get; set; } - [FieldDefinition(4, Label = "Password", Type = FieldType.Password)] + [FieldDefinition(5, Label = "Password", Type = FieldType.Password)] public string Password { get; set; } - [FieldDefinition(5, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Sonarr avoids conflicts with unrelated downloads, but it's optional")] - public string TvCategory { get; set; } + [FieldDefinition(6, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Lidarr avoids conflicts with unrelated downloads, but it's optional")] + public string MusicCategory { get; set; } - [FieldDefinition(6, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(SabnzbdPriority), HelpText = "Priority to use when grabbing episodes that aired within the last 14 days")] + [FieldDefinition(7, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(SabnzbdPriority), HelpText = "Priority to use when grabbing albums released within the last 14 days")] public int RecentTvPriority { get; set; } - [FieldDefinition(7, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(SabnzbdPriority), HelpText = "Priority to use when grabbing episodes that aired over 14 days ago")] + [FieldDefinition(8, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(SabnzbdPriority), HelpText = "Priority to use when grabbing albums released over 14 days ago")] public int OlderTvPriority { get; set; } - [FieldDefinition(8, Label = "Use SSL", Type = FieldType.Checkbox)] + [FieldDefinition(9, Label = "Use SSL", Type = FieldType.Checkbox)] public bool UseSsl { get; set; } public NzbDroneValidationResult Validate() diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionBase.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionBase.cs index 3fa69c06b..036acd65b 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.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(); @@ -58,17 +47,17 @@ namespace NzbDrone.Core.Download.Clients.Transmission { if (!new OsPath(Settings.TvDirectory).Contains(outputPath)) continue; } - else if (Settings.TvCategory.IsNotNullOrWhiteSpace()) + else if (Settings.MusicCategory.IsNotNullOrWhiteSpace()) { var directories = outputPath.FullPath.Split('\\', '/'); - if (!directories.Contains(Settings.TvCategory)) continue; + if (!directories.Contains(Settings.MusicCategory)) continue; } outputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, outputPath); var item = new DownloadClientItem(); item.DownloadId = torrent.HashString.ToUpper(); - item.Category = Settings.TvCategory; + item.Category = Settings.MusicCategory; item.Title = torrent.Name; item.DownloadClient = Definition.Name; @@ -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,31 +113,32 @@ 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()) + if (Settings.MusicCategory.IsNotNullOrWhiteSpace()) { - destDir = string.Format("{0}/.{1}", destDir, Settings.TvCategory); + destDir = string.Format("{0}/{1}", destDir, Settings.MusicCategory); } - 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)) } }; } - protected override string AddFromMagnetLink(RemoteEpisode remoteEpisode, string hash, string magnetLink) + protected override string AddFromMagnetLink(RemoteAlbum remoteAlbum, string hash, string magnetLink) { _proxy.AddTorrentFromUrl(magnetLink, GetDownloadDirectory(), Settings); + _proxy.SetTorrentSeedingConfiguration(hash, remoteAlbum.SeedConfiguration, Settings); - var isRecentEpisode = remoteEpisode.IsRecentEpisode(); + var isRecentAlbum = remoteAlbum.IsRecentAlbum(); - if (isRecentEpisode && Settings.RecentTvPriority == (int)TransmissionPriority.First || - !isRecentEpisode && Settings.OlderTvPriority == (int)TransmissionPriority.First) + if (isRecentAlbum && Settings.RecentTvPriority == (int)TransmissionPriority.First || + !isRecentAlbum && Settings.OlderTvPriority == (int)TransmissionPriority.First) { _proxy.MoveTorrentToTopInQueue(hash, Settings); } @@ -150,14 +146,15 @@ namespace NzbDrone.Core.Download.Clients.Transmission return hash; } - protected override string AddFromTorrentFile(RemoteEpisode remoteEpisode, string hash, string filename, byte[] fileContent) + protected override string AddFromTorrentFile(RemoteAlbum remoteAlbum, string hash, string filename, byte[] fileContent) { _proxy.AddTorrentFromData(fileContent, GetDownloadDirectory(), Settings); + _proxy.SetTorrentSeedingConfiguration(hash, remoteAlbum.SeedConfiguration, Settings); - var isRecentEpisode = remoteEpisode.IsRecentEpisode(); + var isRecentAlbum = remoteAlbum.IsRecentAlbum(); - if (isRecentEpisode && Settings.RecentTvPriority == (int)TransmissionPriority.First || - !isRecentEpisode && Settings.OlderTvPriority == (int)TransmissionPriority.First) + if (isRecentAlbum && Settings.RecentTvPriority == (int)TransmissionPriority.First || + !isRecentAlbum && Settings.OlderTvPriority == (int)TransmissionPriority.First) { _proxy.MoveTorrentToTopInQueue(hash, Settings); } @@ -183,17 +180,16 @@ 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 + if (!Settings.MusicCategory.IsNotNullOrWhiteSpace()) { return null; } + + var config = _proxy.GetConfig(Settings); + var destDir = (string)config.GetValueOrDefault("download-dir"); + + return $"{destDir.TrimEnd('/')}/{Settings.MusicCategory}"; } protected ValidationFailure TestConnection() @@ -204,27 +200,23 @@ namespace NzbDrone.Core.Download.Clients.Transmission } catch (DownloadClientAuthenticationException ex) { - _logger.Error(ex); + _logger.Error(ex, "Unable to authenticate"); 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) + DetailedDescription = string.Format("Please verify your username and password. Also verify if the host running Lidarr 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, "Unable to connect to transmission"); + 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 +231,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..43dc2711c 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; @@ -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,7 +169,10 @@ namespace NzbDrone.Core.Download.Clients.Transmission "leftUntilDone", "isFinished", "eta", - "errorString" + "errorString", + "uploadedEver", + "downloadedEver", + "seedRatioLimit" }; var arguments = new Dictionary(); @@ -238,54 +245,65 @@ namespace NzbDrone.Core.Download.Clients.Transmission public TransmissionResponse ProcessRequest(string action, object arguments, TransmissionSettings settings) { - var requestBuilder = BuildRequest(settings); - requestBuilder.Headers.ContentType = "application/json"; - requestBuilder.SuppressHttpError = true; + try + { + var requestBuilder = BuildRequest(settings); + requestBuilder.Headers.ContentType = "application/json"; + requestBuilder.SuppressHttpError = true; - AuthenticateClient(requestBuilder, settings); + AuthenticateClient(requestBuilder, settings); - var request = requestBuilder.Post().Build(); + var request = requestBuilder.Post().Build(); - var data = new Dictionary(); - data.Add("method", action); + var data = new Dictionary(); + data.Add("method", action); - if (arguments != null) - { - data.Add("arguments", arguments); - } + if (arguments != null) + { + data.Add("arguments", arguments); + } - request.SetContent(data.ToJson()); - request.ContentSummary = string.Format("{0}(...)", action); + request.SetContent(data.ToJson()); + request.ContentSummary = string.Format("{0}(...)", action); - var response = _httpClient.Execute(request); - if (response.StatusCode == HttpStatusCode.Conflict) - { - AuthenticateClient(requestBuilder, settings, true); + var response = _httpClient.Execute(request); - request = requestBuilder.Post().Build(); + if (response.StatusCode == HttpStatusCode.Conflict) + { + AuthenticateClient(requestBuilder, settings, true); - request.SetContent(data.ToJson()); - request.ContentSummary = string.Format("{0}(...)", action); + request = requestBuilder.Post().Build(); - response = _httpClient.Execute(request); - } - else if (response.StatusCode == HttpStatusCode.Unauthorized) - { - throw new DownloadClientAuthenticationException("User authentication failed."); - } + 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); + var transmissionResponse = Json.Deserialize(response.Content); - if (transmissionResponse == null) + 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 TransmissionException("Unexpected response"); + throw new DownloadClientException("Unable to connect to Transmission, please check your settings", ex); } - else if (transmissionResponse.Result != "success") + catch (WebException ex) { - throw new TransmissionException(transmissionResponse.Result); + throw new DownloadClientUnavailableException("Unable to connect to Transmission, please check your settings", ex); } - - return transmissionResponse; } } } diff --git a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionSettings.cs b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionSettings.cs index d551c05d3..e456bbd30 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; @@ -16,9 +16,9 @@ namespace NzbDrone.Core.Download.Clients.Transmission RuleFor(c => c.UrlBase).ValidUrlBase(); - RuleFor(c => c.TvCategory).Matches(@"^\.?[-a-z]*$", RegexOptions.IgnoreCase).WithMessage("Allowed characters a-z and -"); + RuleFor(c => c.MusicCategory).Matches(@"^\.?[-a-z]*$", RegexOptions.IgnoreCase).WithMessage("Allowed characters a-z and -"); - RuleFor(c => c.TvCategory).Empty() + RuleFor(c => c.MusicCategory).Empty() .When(c => c.TvDirectory.IsNotNullOrWhiteSpace()) .WithMessage("Cannot use Category and Directory"); } @@ -50,19 +50,22 @@ namespace NzbDrone.Core.Download.Clients.Transmission [FieldDefinition(4, Label = "Password", Type = FieldType.Password)] public string Password { get; set; } - [FieldDefinition(5, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Sonarr avoids conflicts with unrelated downloads, but it's optional. Creates a [category] subdirectory in the output directory.")] - public string TvCategory { get; set; } + [FieldDefinition(5, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Lidarr avoids conflicts with unrelated downloads, but it's optional. Creates a [category] subdirectory in the output directory.")] + public string MusicCategory { get; set; } [FieldDefinition(6, Label = "Directory", Type = FieldType.Textbox, Advanced = true, HelpText = "Optional location to put downloads in, leave blank to use the default Transmission location")] public string TvDirectory { get; set; } - [FieldDefinition(7, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(TransmissionPriority), HelpText = "Priority to use when grabbing episodes that aired within the last 14 days")] + [FieldDefinition(7, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(TransmissionPriority), HelpText = "Priority to use when grabbing albums released within the last 14 days")] public int RecentTvPriority { get; set; } - [FieldDefinition(8, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(TransmissionPriority), HelpText = "Priority to use when grabbing episodes that aired over 14 days ago")] + [FieldDefinition(8, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(TransmissionPriority), HelpText = "Priority to use when grabbing albums released 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..10fee3a50 100644 --- a/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/Transmission/TransmissionTorrent.cs @@ -1,4 +1,4 @@ -namespace NzbDrone.Core.Download.Clients.Transmission +namespace NzbDrone.Core.Download.Clients.Transmission { public class TransmissionTorrent { @@ -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 d2f30fd9a..edf12de2b 100644 --- a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Collections.Generic; using System.Threading; @@ -37,120 +37,105 @@ namespace NzbDrone.Core.Download.Clients.RTorrent _rTorrentDirectoryValidator = rTorrentDirectoryValidator; } - protected override string AddFromMagnetLink(RemoteEpisode remoteEpisode, string hash, string magnetLink) + protected override string AddFromMagnetLink(RemoteAlbum remoteAlbum, string hash, string magnetLink) { - _proxy.AddTorrentFromUrl(magnetLink, Settings); + var priority = (RTorrentPriority)(remoteAlbum.IsRecentAlbum() ? Settings.RecentTvPriority : Settings.OlderTvPriority); - // Download the magnet to the appropriate directory. - _proxy.SetTorrentLabel(hash, Settings.TvCategory, Settings); - SetPriority(remoteEpisode, hash); - SetDownloadDirectory(hash); + _proxy.AddTorrentFromUrl(magnetLink, Settings.MusicCategory, priority, Settings.TvDirectory, Settings); - // Once the magnet meta download finishes, rTorrent replaces it with the actual torrent download with default settings. - // Schedule an event to apply the appropriate settings when that happens. - var priority = (RTorrentPriority)(remoteEpisode.IsRecentEpisode() ? Settings.RecentTvPriority : Settings.OlderTvPriority); - _proxy.SetDeferredMagnetProperties(hash, Settings.TvCategory, Settings.TvDirectory, priority, Settings); - - _proxy.StartTorrent(hash, Settings); - - // Wait for the magnet to be resolved. var tries = 10; var retryDelay = 500; - if (WaitForTorrent(hash, tries, retryDelay)) - { - return hash; - } - else + + // Wait a bit for the magnet to be resolved. + if (!WaitForTorrent(hash, tries, retryDelay)) { _logger.Warn("rTorrent could not resolve magnet within {0} seconds, download may remain stuck: {1}.", tries * retryDelay / 1000, magnetLink); return hash; } + + return hash; } - protected override string AddFromTorrentFile(RemoteEpisode remoteEpisode, string hash, string filename, byte[] fileContent) + protected override string AddFromTorrentFile(RemoteAlbum remoteAlbum, string hash, string filename, byte[] fileContent) { - _proxy.AddTorrentFromFile(filename, fileContent, Settings); - - var tries = 5; - var retryDelay = 200; - if (WaitForTorrent(hash, tries, retryDelay)) - { - _proxy.SetTorrentLabel(hash, Settings.TvCategory, Settings); - - SetPriority(remoteEpisode, hash); - SetDownloadDirectory(hash); + var priority = (RTorrentPriority)(remoteAlbum.IsRecentAlbum() ? Settings.RecentTvPriority : Settings.OlderTvPriority); - _proxy.StartTorrent(hash, Settings); + _proxy.AddTorrentFromFile(filename, fileContent, Settings.MusicCategory, priority, Settings.TvDirectory, Settings); - return hash; - } - else + var tries = 10; + var retryDelay = 500; + if (!WaitForTorrent(hash, tries, retryDelay)) { - _logger.Debug("rTorrent could not add file"); + _logger.Debug("rTorrent didn't add the torrent within {0} seconds: {1}.", tries * retryDelay / 1000, filename); - RemoveItem(hash, true); - throw new ReleaseDownloadException(remoteEpisode.Release, "Downloading torrent failed"); + throw new ReleaseDownloadException(remoteAlbum.Release, "Downloading torrent failed"); } + + return hash; } public override string Name => "rTorrent"; - public override ProviderMessage Message => new ProviderMessage("Sonarr is unable to remove torrents that have finished seeding when using rTorrent", ProviderMessageType.Warning); + public override ProviderMessage Message => new ProviderMessage("Lidarr is unable to remove torrents that have finished seeding when using rTorrent", ProviderMessageType.Warning); 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 (Settings.MusicCategory.IsNotNullOrWhiteSpace() && torrent.Category != Settings.MusicCategory) continue; + + if (torrent.Path.StartsWith(".")) + { + throw new DownloadClientException("Download paths must be absolute. Please specify variable \"directory\" in rTorrent."); + } - _logger.Debug("Retrieved metadata of {0} torrents in client", torrents.Count); + 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; + } - var items = new List(); - foreach (RTorrentTorrent torrent in torrents) + if (torrent.IsFinished) { - // 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); + item.Status = DownloadItemStatus.Completed; + } + else if (torrent.IsActive) + { + item.Status = DownloadItemStatus.Downloading; + } + else if (!torrent.IsActive) + { + item.Status = DownloadItemStatus.Paused; } - return items; - } - catch (DownloadClientException ex) - { - _logger.Error(ex); - return Enumerable.Empty(); + // 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) @@ -163,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" }; @@ -196,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); } @@ -211,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); } @@ -230,20 +215,6 @@ namespace NzbDrone.Core.Download.Clients.RTorrent return result.Errors.First(); } - private void SetPriority(RemoteEpisode remoteEpisode, string hash) - { - var priority = (RTorrentPriority)(remoteEpisode.IsRecentEpisode() ? Settings.RecentTvPriority : Settings.OlderTvPriority); - _proxy.SetTorrentPriority(hash, priority, Settings); - } - - private void SetDownloadDirectory(string hash) - { - if (Settings.TvDirectory.IsNotNullOrWhiteSpace()) - { - _proxy.SetTorrentDownloadDirectory(hash, Settings.TvDirectory, Settings); - } - } - private bool WaitForTorrent(string hash, int tries, int retryDelay) { for (var i = 0; i < tries; i++) diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentDirectoryValidator.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentDirectoryValidator.cs index 3cb2d6a8b..45a3c39c5 100644 --- a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentDirectoryValidator.cs +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentDirectoryValidator.cs @@ -1,4 +1,4 @@ -using FluentValidation; +using FluentValidation; using FluentValidation.Results; using NzbDrone.Common.Extensions; using NzbDrone.Core.Download.Clients.RTorrent; @@ -15,13 +15,11 @@ namespace NzbDrone.Core.Download.Clients.rTorrent { public RTorrentDirectoryValidator(RootFolderValidator rootFolderValidator, PathExistsValidator pathExistsValidator, - DroneFactoryValidator droneFactoryValidator, MappedNetworkDriveValidator mappedNetworkDriveValidator) { RuleFor(c => c.TvDirectory).Cascade(CascadeMode.StopOnFirstFailure) .IsValidPath() .SetValidator(rootFolderValidator) - .SetValidator(droneFactoryValidator) .SetValidator(mappedNetworkDriveValidator) .SetValidator(pathExistsValidator) .When(c => c.TvDirectory.IsNotNullOrWhiteSpace()) diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentPriority.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentPriority.cs index 99b289e8e..9ae5395d3 100644 --- a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentPriority.cs +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentPriority.cs @@ -2,7 +2,7 @@ namespace NzbDrone.Core.Download.Clients.RTorrent { public enum RTorrentPriority { - DoNotDownload = 0, + VeryLow = 0, Low = 1, Normal = 2, High = 3 diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs index a94b6fb1e..ff9a4332f 100644 --- a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs @@ -1,7 +1,9 @@ -using System; +using System; 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; @@ -13,15 +15,10 @@ namespace NzbDrone.Core.Download.Clients.RTorrent string GetVersion(RTorrentSettings settings); List GetTorrents(RTorrentSettings settings); - void AddTorrentFromUrl(string torrentUrl, RTorrentSettings settings); - void AddTorrentFromFile(string fileName, byte[] fileContent, RTorrentSettings settings); + void AddTorrentFromUrl(string torrentUrl, string label, RTorrentPriority priority, string directory, RTorrentSettings settings); + void AddTorrentFromFile(string fileName, byte[] fileContent, string label, RTorrentPriority priority, string directory, RTorrentSettings settings); void RemoveTorrent(string hash, RTorrentSettings settings); - void SetTorrentPriority(string hash, RTorrentPriority priority, RTorrentSettings settings); - void SetTorrentLabel(string hash, string label, RTorrentSettings settings); - void SetTorrentDownloadDirectory(string hash, string directory, RTorrentSettings settings); bool HasHashTorrent(string hash, RTorrentSettings settings); - void StartTorrent(string hash, RTorrentSettings settings); - void SetDeferredMagnetProperties(string hash, string category, string directory, RTorrentPriority priority, RTorrentSettings settings); } public interface IRTorrent : IXmlRpcProxy @@ -30,34 +27,25 @@ namespace NzbDrone.Core.Download.Clients.RTorrent object[] TorrentMulticall(params string[] parameters); [XmlRpcMethod("load.normal")] - int LoadUrl(string target, string data); + int LoadNormal(string target, string data, params string[] commands); + + [XmlRpcMethod("load.start")] + int LoadStart(string target, string data, params string[] commands); [XmlRpcMethod("load.raw")] - int LoadBinary(string target, byte[] data); + int LoadRaw(string target, byte[] data, params string[] commands); + + [XmlRpcMethod("load.raw_start")] + int LoadRawStart(string target, byte[] data, params string[] commands); [XmlRpcMethod("d.erase")] int Remove(string hash); - [XmlRpcMethod("d.custom1.set")] - string SetLabel(string hash, string label); - - [XmlRpcMethod("d.priority.set")] - int SetPriority(string hash, long priority); - - [XmlRpcMethod("d.directory.set")] - int SetDirectory(string hash, string directory); - - [XmlRpcMethod("method.set_key")] - int SetKey(string target, string key, string cmd_key, string value); - [XmlRpcMethod("d.name")] string GetName(string hash); [XmlRpcMethod("system.client_version")] string GetVersion(); - - [XmlRpcMethod("system.multicall")] - object[] SystemMulticall(object[] parameters); } public class RTorrentProxy : IRTorrentProxy @@ -74,8 +62,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; } @@ -85,20 +72,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]); @@ -122,26 +111,46 @@ namespace NzbDrone.Core.Download.Clients.RTorrent return items; } - public void AddTorrentFromUrl(string torrentUrl, RTorrentSettings settings) + public void AddTorrentFromUrl(string torrentUrl, string label, RTorrentPriority priority, string directory, RTorrentSettings settings) { - _logger.Debug("Executing remote method: load.normal"); - var client = BuildClient(settings); + var response = ExecuteRequest(() => + { + if (settings.AddStopped) + { + _logger.Debug("Executing remote method: load.normal"); + return client.LoadNormal("", torrentUrl, GetCommands(label, priority, directory)); + } + else + { + _logger.Debug("Executing remote method: load.start"); + return client.LoadStart("", torrentUrl, GetCommands(label, priority, directory)); + } + }); - var response = client.LoadUrl("", torrentUrl); if (response != 0) { throw new DownloadClientException("Could not add torrent: {0}.", torrentUrl); } } - public void AddTorrentFromFile(string fileName, byte[] fileContent, RTorrentSettings settings) + public void AddTorrentFromFile(string fileName, byte[] fileContent, string label, RTorrentPriority priority, string directory, RTorrentSettings settings) { - _logger.Debug("Executing remote method: load.raw"); - var client = BuildClient(settings); + var response = ExecuteRequest(() => + { + if (settings.AddStopped) + { + _logger.Debug("Executing remote method: load.raw"); + return client.LoadRaw("", fileContent, GetCommands(label, priority, directory)); + } + else + { + _logger.Debug("Executing remote method: load.raw_start"); + return client.LoadRawStart("", fileContent, GetCommands(label, priority, directory)); + } + }); - var response = client.LoadBinary("", fileContent); if (response != 0) { throw new DownloadClientException("Could not add torrent: {0}.", fileName); @@ -153,147 +162,59 @@ 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 void SetTorrentPriority(string hash, RTorrentPriority priority, RTorrentSettings settings) + public bool HasHashTorrent(string hash, RTorrentSettings settings) { - _logger.Debug("Executing remote method: d.priority.set"); + _logger.Debug("Executing remote method: d.name"); var client = BuildClient(settings); - var response = client.SetPriority(hash, (long) priority); - if (response != 0) + try { - throw new DownloadClientException("Could not set priority on torrent: {0}.", hash); - } - } - - public void SetTorrentLabel(string hash, string label, RTorrentSettings settings) - { - _logger.Debug("Executing remote method: d.custom1.set"); + var name = ExecuteRequest(() => client.GetName(hash)); - var labelEncoded = System.Web.HttpUtility.UrlEncode(label); + if (name.IsNullOrWhiteSpace()) + { + return false; + } - var client = BuildClient(settings); + var metaTorrent = name == (hash + ".meta"); - var setLabel = client.SetLabel(hash, labelEncoded); - if (setLabel != labelEncoded) - { - throw new DownloadClientException("Could set label on torrent: {0}.", hash); + return !metaTorrent; } - } - - public void SetTorrentDownloadDirectory(string hash, string directory, RTorrentSettings settings) - { - _logger.Debug("Executing remote method: d.directory.set"); - - var client = BuildClient(settings); - - var response = client.SetDirectory(hash, directory); - if (response != 0) + catch (Exception) { - throw new DownloadClientException("Could not set directory for torrent: {0}.", hash); + return false; } } - public void SetDeferredMagnetProperties(string hash, string category, string directory, RTorrentPriority priority, RTorrentSettings settings) + private string[] GetCommands(string label, RTorrentPriority priority, string directory) { - var commands = new List(); - - if (category.IsNotNullOrWhiteSpace()) - { - commands.Add("d.custom1.set=" + category); - } + var result = new List(); - if (directory.IsNotNullOrWhiteSpace()) + if (label.IsNotNullOrWhiteSpace()) { - commands.Add("d.directory.set=" + directory); + result.Add("d.custom1.set=" + label); } if (priority != RTorrentPriority.Normal) { - commands.Add("d.priority.set=" + (long)priority); + result.Add("d.priority.set=" + (int)priority); } - // Ensure it gets started if the user doesn't have schedule=...,start_tied= - commands.Add("d.open="); - commands.Add("d.start="); - - if (commands.Any()) - { - var key = "event.download.inserted_new"; - var cmd_key = "sonarr_deferred_" + hash; - - commands.Add(string.Format("print=\"Applying deferred properties to {0}\"", hash)); - - // Remove event handler once triggered. - commands.Add(string.Format("\"method.set_key={0},{1}\"", key, cmd_key)); - - var setKeyValue = string.Format("branch=\"equal=d.hash=,cat={0}\",{{{1}}}", hash, string.Join(",", commands)); - - _logger.Debug("Executing remote method: method.set_key = {0},{1},{2}", key, cmd_key, setKeyValue); - - var client = BuildClient(settings); - - // Commands need a target, in this case the target is an empty string - // See: https://github.com/rakshasa/rtorrent/issues/227 - var response = client.SetKey("", key, cmd_key, setKeyValue); - if (response != 0) - { - throw new DownloadClientException("Could set properties for torrent: {0}.", hash); - } - } - } - - 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) + if (directory.IsNotNullOrWhiteSpace()) { - return false; + result.Add("d.directory.set=" + directory); } - } - public void StartTorrent(string hash, RTorrentSettings settings) - { - _logger.Debug("Executing remote methods: d.open and d.start"); - - var client = BuildClient(settings); - - var multicallResponse = client.SystemMulticall(new[] - { - new - { - methodName = "d.open", - @params = new[] { hash } - }, - new - { - methodName = "d.start", - @params = new[] { hash } - }, - }).SelectMany(c => ((IEnumerable)c)); - - if (multicallResponse.Any(r => r != 0)) - { - throw new DownloadClientException("Could not start torrent: {0}.", hash); - } + return result.ToArray(); } private IRTorrent BuildClient(RTorrentSettings settings) @@ -315,5 +236,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/rTorrent/RTorrentSettings.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentSettings.cs index dba98c99a..e2fc57dc8 100644 --- a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentSettings.cs @@ -1,4 +1,4 @@ -using FluentValidation; +using FluentValidation; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; @@ -11,7 +11,7 @@ namespace NzbDrone.Core.Download.Clients.RTorrent { RuleFor(c => c.Host).ValidHost(); RuleFor(c => c.Port).InclusiveBetween(1, 65535); - RuleFor(c => c.TvCategory).NotEmpty() + RuleFor(c => c.MusicCategory).NotEmpty() .WithMessage("A category is recommended") .AsWarning(); } @@ -26,7 +26,7 @@ namespace NzbDrone.Core.Download.Clients.RTorrent Host = "localhost"; Port = 8080; UrlBase = "RPC2"; - TvCategory = "tv-sonarr"; + MusicCategory = "lidarr"; OlderTvPriority = (int)RTorrentPriority.Normal; RecentTvPriority = (int)RTorrentPriority.Normal; } @@ -49,18 +49,21 @@ namespace NzbDrone.Core.Download.Clients.RTorrent [FieldDefinition(5, Label = "Password", Type = FieldType.Password)] public string Password { get; set; } - [FieldDefinition(6, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Sonarr avoids conflicts with unrelated downloads, but it's optional.")] - public string TvCategory { get; set; } + [FieldDefinition(6, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Lidarr avoids conflicts with unrelated downloads, but it's optional.")] + public string MusicCategory { get; set; } [FieldDefinition(7, Label = "Directory", Type = FieldType.Textbox, Advanced = true, HelpText = "Optional location to put downloads in, leave blank to use the default rTorrent location")] public string TvDirectory { get; set; } - [FieldDefinition(8, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(RTorrentPriority), HelpText = "Priority to use when grabbing episodes that aired within the last 14 days")] + [FieldDefinition(8, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(RTorrentPriority), HelpText = "Priority to use when grabbing albums released within the last 14 days")] public int RecentTvPriority { get; set; } - [FieldDefinition(9, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(RTorrentPriority), HelpText = "Priority to use when grabbing episodes that aired over 14 days ago")] + [FieldDefinition(9, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(RTorrentPriority), HelpText = "Priority to use when grabbing albums released over 14 days ago")] public int OlderTvPriority { get; set; } + [FieldDefinition(10, Label = "Add Stopped", Type = FieldType.Checkbox, HelpText = "Enabling will prevent magnets from downloading before downloading")] + public bool AddStopped { get; set; } + public NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); diff --git a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrent.cs b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrent.cs index 8f442eb7b..041293ce3 100644 --- a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrent.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Collections.Generic; using NzbDrone.Common.Disk; @@ -36,35 +36,41 @@ namespace NzbDrone.Core.Download.Clients.UTorrent _torrentCache = cacheManager.GetCache(GetType(), "differentialTorrents"); } - protected override string AddFromMagnetLink(RemoteEpisode remoteEpisode, string hash, string magnetLink) + protected override string AddFromMagnetLink(RemoteAlbum remoteAlbum, string hash, string magnetLink) { _proxy.AddTorrentFromUrl(magnetLink, Settings); - _proxy.SetTorrentLabel(hash, Settings.TvCategory, Settings); + _proxy.SetTorrentLabel(hash, Settings.MusicCategory, Settings); + _proxy.SetTorrentSeedingConfiguration(hash, remoteAlbum.SeedConfiguration, Settings); - var isRecentEpisode = remoteEpisode.IsRecentEpisode(); + var isRecentAlbum = remoteAlbum.IsRecentAlbum(); - if (isRecentEpisode && Settings.RecentTvPriority == (int)UTorrentPriority.First || - !isRecentEpisode && Settings.OlderTvPriority == (int)UTorrentPriority.First) + if (isRecentAlbum && Settings.RecentTvPriority == (int)UTorrentPriority.First || + !isRecentAlbum && Settings.OlderTvPriority == (int)UTorrentPriority.First) { _proxy.MoveTorrentToTopInQueue(hash, Settings); } + _proxy.SetState(hash, (UTorrentState)Settings.IntialState, Settings); + return hash; } - protected override string AddFromTorrentFile(RemoteEpisode remoteEpisode, string hash, string filename, byte[] fileContent) + protected override string AddFromTorrentFile(RemoteAlbum remoteAlbum, string hash, string filename, byte[] fileContent) { _proxy.AddTorrentFromFile(filename, fileContent, Settings); - _proxy.SetTorrentLabel(hash, Settings.TvCategory, Settings); + _proxy.SetTorrentLabel(hash, Settings.MusicCategory, Settings); + _proxy.SetTorrentSeedingConfiguration(hash, remoteAlbum.SeedConfiguration, Settings); - var isRecentEpisode = remoteEpisode.IsRecentEpisode(); + var isRecentAlbum = remoteAlbum.IsRecentAlbum(); - if (isRecentEpisode && Settings.RecentTvPriority == (int)UTorrentPriority.First || - !isRecentEpisode && Settings.OlderTvPriority == (int)UTorrentPriority.First) + if (isRecentAlbum && Settings.RecentTvPriority == (int)UTorrentPriority.First || + !isRecentAlbum && Settings.OlderTvPriority == (int)UTorrentPriority.First) { _proxy.MoveTorrentToTopInQueue(hash, Settings); } + _proxy.SetState(hash, (UTorrentState)Settings.IntialState, Settings); + return hash; } @@ -72,48 +78,13 @@ 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(); foreach (var torrent in torrents) { - if (torrent.Label != Settings.TvCategory) + if (torrent.Label != Settings.MusicCategory) { continue; } @@ -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.MusicCategory); + 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); @@ -195,11 +204,11 @@ namespace NzbDrone.Core.Download.Clients.UTorrent if (config.GetValueOrDefault("dir_add_label") == "true") { - destDir = destDir + Settings.TvCategory; + destDir = destDir + Settings.MusicCategory; } } - 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, "Unable to authenticate"); 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..8f8940e76 100644 --- a/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/uTorrent/UTorrentProxy.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Net; using NLog; @@ -13,7 +13,7 @@ namespace NzbDrone.Core.Download.Clients.UTorrent { int GetVersion(UTorrentSettings settings); Dictionary GetConfig(UTorrentSettings settings); - UTorrentResponse GetTorrents(string cacheID, UTorrentSettings settings); + UTorrentResponse GetTorrents(string cacheId, UTorrentSettings settings); void AddTorrentFromUrl(string torrentUrl, UTorrentSettings settings); void AddTorrentFromFile(string fileName, byte[] fileContent, UTorrentSettings settings); @@ -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,22 @@ 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 +163,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 +259,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..ca487caed 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; @@ -11,7 +11,7 @@ namespace NzbDrone.Core.Download.Clients.UTorrent { RuleFor(c => c.Host).ValidHost(); RuleFor(c => c.Port).InclusiveBetween(1, 65535); - RuleFor(c => c.TvCategory).NotEmpty(); + RuleFor(c => c.MusicCategory).NotEmpty(); } } @@ -22,8 +22,8 @@ namespace NzbDrone.Core.Download.Clients.UTorrent public UTorrentSettings() { Host = "localhost"; - Port = 9091; - TvCategory = "tv-sonarr"; + Port = 8080; + MusicCategory = "lidarr"; } [FieldDefinition(0, Label = "Host", Type = FieldType.Textbox)] @@ -38,15 +38,18 @@ namespace NzbDrone.Core.Download.Clients.UTorrent [FieldDefinition(3, Label = "Password", Type = FieldType.Password)] public string Password { get; set; } - [FieldDefinition(4, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Sonarr avoids conflicts with unrelated downloads, but it's optional")] - public string TvCategory { get; set; } + [FieldDefinition(4, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Lidarr avoids conflicts with unrelated downloads, but it's optional")] + public string MusicCategory { get; set; } - [FieldDefinition(5, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(UTorrentPriority), HelpText = "Priority to use when grabbing episodes that aired within the last 14 days")] + [FieldDefinition(5, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(UTorrentPriority), HelpText = "Priority to use when grabbing albums released within the last 14 days")] public int RecentTvPriority { get; set; } - [FieldDefinition(6, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(UTorrentPriority), HelpText = "Priority to use when grabbing episodes that aired over 14 days ago")] + [FieldDefinition(6, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(UTorrentPriority), HelpText = "Priority to use when grabbing albums released 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..db5d3da87 --- /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/CompletedDownloadService.cs b/src/NzbDrone.Core/Download/CompletedDownloadService.cs index c4fbe11a2..64b594c7b 100644 --- a/src/NzbDrone.Core/Download/CompletedDownloadService.cs +++ b/src/NzbDrone.Core/Download/CompletedDownloadService.cs @@ -1,18 +1,18 @@ -using System; +using System; using System.IO; using System.Linq; using NLog; -using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; using NzbDrone.Core.Download.TrackedDownloads; using NzbDrone.Core.History; using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.MediaFiles.EpisodeImport; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Parser; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; +using NzbDrone.Core.MediaFiles.TrackImport; +using NzbDrone.Core.MediaFiles.Events; namespace NzbDrone.Core.Download { @@ -26,31 +26,32 @@ namespace NzbDrone.Core.Download private readonly IConfigService _configService; private readonly IEventAggregator _eventAggregator; private readonly IHistoryService _historyService; - private readonly IDownloadedEpisodesImportService _downloadedEpisodesImportService; + private readonly IDownloadedTracksImportService _downloadedTracksImportService; private readonly IParsingService _parsingService; private readonly Logger _logger; - private readonly ISeriesService _seriesService; + private readonly IArtistService _artistService; public CompletedDownloadService(IConfigService configService, IEventAggregator eventAggregator, IHistoryService historyService, - IDownloadedEpisodesImportService downloadedEpisodesImportService, + IDownloadedTracksImportService downloadedTracksImportService, IParsingService parsingService, - ISeriesService seriesService, + IArtistService artistService, Logger logger) { _configService = configService; _eventAggregator = eventAggregator; _historyService = historyService; - _downloadedEpisodesImportService = downloadedEpisodesImportService; + _downloadedTracksImportService = downloadedTracksImportService; _parsingService = parsingService; _logger = logger; - _seriesService = seriesService; + _artistService = artistService; } public void Process(TrackedDownload trackedDownload, bool ignoreWarnings = false) { - if (trackedDownload.DownloadItem.Status != DownloadItemStatus.Completed) + if (trackedDownload.DownloadItem.Status != DownloadItemStatus.Completed || + trackedDownload.RemoteAlbum == null) { return; } @@ -61,7 +62,7 @@ namespace NzbDrone.Core.Download if (historyItem == null && trackedDownload.DownloadItem.Category.IsNullOrWhiteSpace()) { - trackedDownload.Warn("Download wasn't grabbed by Sonarr and not in a category, Skipping."); + trackedDownload.Warn("Download wasn't grabbed by Lidarr and not in a category, Skipping."); return; } @@ -80,26 +81,18 @@ namespace NzbDrone.Core.Download return; } - var downloadedEpisodesFolder = new OsPath(_configService.DownloadedEpisodesFolder); + var artist = trackedDownload.RemoteAlbum.Artist; - if (downloadedEpisodesFolder.Contains(downloadItemOutputPath)) - { - trackedDownload.Warn("Intermediate Download path inside drone factory, Skipping."); - return; - } - - var series = _parsingService.GetSeries(trackedDownload.DownloadItem.Title); - - if (series == null) + if (artist == null) { if (historyItem != null) { - series = _seriesService.GetSeries(historyItem.SeriesId); + artist = _artistService.GetArtist(historyItem.ArtistId); } - if (series == null) + if (artist == null) { - trackedDownload.Warn("Series title mismatch, automatic import is not possible."); + trackedDownload.Warn("Artist name mismatch, automatic import is not possible."); return; } } @@ -111,15 +104,18 @@ namespace NzbDrone.Core.Download private void Import(TrackedDownload trackedDownload) { var outputPath = trackedDownload.DownloadItem.OutputPath.FullPath; - var importResults = _downloadedEpisodesImportService.ProcessPath(outputPath, ImportMode.Auto, trackedDownload.RemoteEpisode.Series, trackedDownload.DownloadItem); + var importResults = _downloadedTracksImportService.ProcessPath(outputPath, ImportMode.Auto, trackedDownload.RemoteAlbum.Artist, trackedDownload.DownloadItem); if (importResults.Empty()) { + trackedDownload.State = TrackedDownloadStage.ImportFailed; trackedDownload.Warn("No files found are eligible for import in {0}", outputPath); + _eventAggregator.PublishEvent(new AlbumImportIncompleteEvent(trackedDownload)); return; } - if (importResults.Count(c => c.Result == ImportResultType.Imported) >= Math.Max(1, trackedDownload.RemoteEpisode.Episodes.Count)) + if (importResults.All(c => c.Result == ImportResultType.Imported) + || importResults.Count(c => c.Result == ImportResultType.Imported) >= Math.Max(1, trackedDownload.RemoteAlbum.Albums.Sum(x => x.AlbumReleases.Value.Where(y => y.Monitored).Sum(z => z.TrackCount)))) { trackedDownload.State = TrackedDownloadStage.Imported; _eventAggregator.PublishEvent(new DownloadCompletedEvent(trackedDownload)); @@ -128,14 +124,15 @@ namespace NzbDrone.Core.Download if (importResults.Any(c => c.Result != ImportResultType.Imported)) { + trackedDownload.State = TrackedDownloadStage.ImportFailed; var statusMessages = importResults .Where(v => v.Result != ImportResultType.Imported) - .Select(v => new TrackedDownloadStatusMessage(Path.GetFileName(v.ImportDecision.LocalEpisode.Path), v.Errors)) + .Select(v => new TrackedDownloadStatusMessage(Path.GetFileName(v.ImportDecision.Item.Path), v.Errors)) .ToArray(); trackedDownload.Warn(statusMessages); + _eventAggregator.PublishEvent(new AlbumImportIncompleteEvent(trackedDownload)); } - } } } diff --git a/src/NzbDrone.Core/Download/DownloadClientBase.cs b/src/NzbDrone.Core/Download/DownloadClientBase.cs index 98ade2a69..e2b3f7d10 100644 --- a/src/NzbDrone.Core/Download/DownloadClientBase.cs +++ b/src/NzbDrone.Core/Download/DownloadClientBase.cs @@ -57,10 +57,10 @@ namespace NzbDrone.Core.Download get; } - public abstract string Download(RemoteEpisode remoteEpisode); + public abstract string Download(RemoteAlbum remoteAlbum); 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) { @@ -110,7 +110,7 @@ namespace NzbDrone.Core.Download public ValidationResult Test() { var failures = new List(); - + try { Test(failures); @@ -132,7 +132,7 @@ namespace NzbDrone.Core.Download { return new NzbDroneValidationFailure(propertyName, "Folder does not exist") { - DetailedDescription = string.Format("The folder you specified does not exist or is inaccessible. Please verify the folder permissions for the user account '{0}', which is used to execute Sonarr.", Environment.UserName) + DetailedDescription = string.Format("The folder you specified does not exist or is inaccessible. Please verify the folder permissions for the user account '{0}', which is used to execute Lidarr.", Environment.UserName) }; } @@ -141,7 +141,7 @@ namespace NzbDrone.Core.Download _logger.Error("Folder '{0}' is not writable.", folder); return new NzbDroneValidationFailure(propertyName, "Unable to write to folder") { - DetailedDescription = string.Format("The folder you specified is not writable. Please verify the folder permissions for the user account '{0}', which is used to execute Sonarr.", Environment.UserName) + DetailedDescription = string.Format("The folder you specified is not writable. Please verify the folder permissions for the user account '{0}', which is used to execute Lidarr.", Environment.UserName) }; } diff --git a/src/NzbDrone.Core/Download/DownloadClientFactory.cs b/src/NzbDrone.Core/Download/DownloadClientFactory.cs index dc0f218b5..909b48ed6 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..3348be4a9 100644 --- a/src/NzbDrone.Core/Download/DownloadClientItem.cs +++ b/src/NzbDrone.Core/Download/DownloadClientItem.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics; using NzbDrone.Common.Disk; @@ -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..7ed7cd5b9 100644 --- a/src/NzbDrone.Core/Download/DownloadClientProvider.cs +++ b/src/NzbDrone.Core/Download/DownloadClientProvider.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System.Linq; using System.Collections.Generic; using NzbDrone.Core.Indexers; @@ -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..f4d819424 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..ac6cfc0b9 --- /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..11eecfe89 --- /dev/null +++ b/src/NzbDrone.Core/Download/DownloadClientStatusService.cs @@ -0,0 +1,23 @@ +using System; +using NLog; +using NzbDrone.Common.EnvironmentInfo; +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, IRuntimeInfo runtimeInfo, Logger logger) + : base(providerStatusRepository, eventAggregator, runtimeInfo, 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..e0e3bd56e 100644 --- a/src/NzbDrone.Core/Download/DownloadEventHub.cs +++ b/src/NzbDrone.Core/Download/DownloadEventHub.cs @@ -1,4 +1,4 @@ -using System; +using System; using NLog; using NzbDrone.Common.Messaging; using NzbDrone.Core.Configuration; @@ -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/DownloadFailedEvent.cs b/src/NzbDrone.Core/Download/DownloadFailedEvent.cs index 1c0ca855a..40fadd628 100644 --- a/src/NzbDrone.Core/Download/DownloadFailedEvent.cs +++ b/src/NzbDrone.Core/Download/DownloadFailedEvent.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using NzbDrone.Common.Messaging; using NzbDrone.Core.Download.TrackedDownloads; using NzbDrone.Core.Qualities; @@ -12,8 +12,8 @@ namespace NzbDrone.Core.Download Data = new Dictionary(); } - public int SeriesId { get; set; } - public List EpisodeIds { get; set; } + public int ArtistId { get; set; } + public List AlbumIds { get; set; } public QualityModel Quality { get; set; } public string SourceTitle { get; set; } public string DownloadClient { get; set; } @@ -21,5 +21,6 @@ namespace NzbDrone.Core.Download public string Message { get; set; } public Dictionary Data { get; set; } public TrackedDownload TrackedDownload { get; set; } + public bool SkipReDownload { get; set; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Download/DownloadService.cs b/src/NzbDrone.Core/Download/DownloadService.cs index 4f76b1507..ad3fadf2b 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; @@ -14,82 +16,95 @@ namespace NzbDrone.Core.Download { public interface IDownloadService { - void DownloadReport(RemoteEpisode remoteEpisode); + void DownloadReport(RemoteAlbum remoteAlbum); } - 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; } - public void DownloadReport(RemoteEpisode remoteEpisode) + public void DownloadReport(RemoteAlbum remoteAlbum) { - Ensure.That(remoteEpisode.Series, () => remoteEpisode.Series).IsNotNull(); - Ensure.That(remoteEpisode.Episodes, () => remoteEpisode.Episodes).HasItems(); + Ensure.That(remoteAlbum.Artist, () => remoteAlbum.Artist).IsNotNull(); + Ensure.That(remoteAlbum.Albums, () => remoteAlbum.Albums).HasItems(); - var downloadTitle = remoteEpisode.Release.Title; - var downloadClient = _downloadClientProvider.GetDownloadClient(remoteEpisode.Release.DownloadProtocol); + var downloadTitle = remoteAlbum.Release.Title; + var downloadClient = _downloadClientProvider.GetDownloadClient(remoteAlbum.Release.DownloadProtocol); if (downloadClient == null) { - _logger.Warn("{0} Download client isn't configured yet.", remoteEpisode.Release.DownloadProtocol); - return; + throw new DownloadClientUnavailableException($"{remoteAlbum.Release.DownloadProtocol} Download client isn't configured yet"); } + // Get the seed configuration for this release. + remoteAlbum.SeedConfiguration = _seedConfigProvider.GetSeedConfiguration(remoteAlbum); + // Limit grabs to 2 per second. - if (remoteEpisode.Release.DownloadUrl.IsNotNullOrWhiteSpace() && !remoteEpisode.Release.DownloadUrl.StartsWith("magnet:")) + if (remoteAlbum.Release.DownloadUrl.IsNotNullOrWhiteSpace() && !remoteAlbum.Release.DownloadUrl.StartsWith("magnet:")) { - var url = new HttpUri(remoteEpisode.Release.DownloadUrl); + var url = new HttpUri(remoteAlbum.Release.DownloadUrl); _rateLimitService.WaitAndPulse(url.Host, TimeSpan.FromSeconds(2)); } string downloadClientId; try { - downloadClientId = downloadClient.Download(remoteEpisode); - _indexerStatusService.RecordSuccess(remoteEpisode.Release.IndexerId); + downloadClientId = downloadClient.Download(remoteAlbum); + _downloadClientStatusService.RecordSuccess(downloadClient.Definition.Id); + _indexerStatusService.RecordSuccess(remoteAlbum.Release.IndexerId); + } + catch (ReleaseUnavailableException) + { + _logger.Trace("Release {0} no longer available on indexer.", remoteAlbum); + throw; } catch (ReleaseDownloadException ex) { var http429 = ex.InnerException as TooManyRequestsException; if (http429 != null) { - _indexerStatusService.RecordFailure(remoteEpisode.Release.IndexerId, http429.RetryAfter); + _indexerStatusService.RecordFailure(remoteAlbum.Release.IndexerId, http429.RetryAfter); } else { - _indexerStatusService.RecordFailure(remoteEpisode.Release.IndexerId); + _indexerStatusService.RecordFailure(remoteAlbum.Release.IndexerId); } throw; } - var episodeGrabbedEvent = new EpisodeGrabbedEvent(remoteEpisode); - episodeGrabbedEvent.DownloadClient = downloadClient.GetType().Name; + var albumGrabbedEvent = new AlbumGrabbedEvent(remoteAlbum); + albumGrabbedEvent.DownloadClient = downloadClient.Name; if (!string.IsNullOrWhiteSpace(downloadClientId)) { - episodeGrabbedEvent.DownloadId = downloadClientId; + albumGrabbedEvent.DownloadId = downloadClientId; } _logger.ProgressInfo("Report sent to {0}. {1}", downloadClient.Definition.Name, downloadTitle); - _eventAggregator.PublishEvent(episodeGrabbedEvent); + _eventAggregator.PublishEvent(albumGrabbedEvent); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Download/EpisodeGrabbedEvent.cs b/src/NzbDrone.Core/Download/EpisodeGrabbedEvent.cs deleted file mode 100644 index b7861b8d7..000000000 --- a/src/NzbDrone.Core/Download/EpisodeGrabbedEvent.cs +++ /dev/null @@ -1,17 +0,0 @@ -using NzbDrone.Common.Messaging; -using NzbDrone.Core.Parser.Model; - -namespace NzbDrone.Core.Download -{ - public class EpisodeGrabbedEvent : IEvent - { - public RemoteEpisode Episode { get; private set; } - public string DownloadClient { get; set; } - public string DownloadId { get; set; } - - public EpisodeGrabbedEvent(RemoteEpisode episode) - { - Episode = episode; - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/FailedDownloadService.cs b/src/NzbDrone.Core/Download/FailedDownloadService.cs index d56349f7f..741a7d337 100644 --- a/src/NzbDrone.Core/Download/FailedDownloadService.cs +++ b/src/NzbDrone.Core/Download/FailedDownloadService.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using NzbDrone.Common.Extensions; using NzbDrone.Core.Download.TrackedDownloads; @@ -9,8 +9,8 @@ namespace NzbDrone.Core.Download { public interface IFailedDownloadService { - void MarkAsFailed(int historyId); - void MarkAsFailed(string downloadId); + void MarkAsFailed(int historyId, bool skipReDownload = false); + void MarkAsFailed(string downloadId, bool skipReDownload = false); void Process(TrackedDownload trackedDownload); } @@ -26,14 +26,14 @@ namespace NzbDrone.Core.Download _eventAggregator = eventAggregator; } - public void MarkAsFailed(int historyId) + public void MarkAsFailed(int historyId, bool skipReDownload = false) { var history = _historyService.Get(historyId); var downloadId = history.DownloadId; if (downloadId.IsNullOrWhiteSpace()) { - PublishDownloadFailedEvent(new List { history }, "Manually marked as failed"); + PublishDownloadFailedEvent(new List { history }, "Manually marked as failed", skipReDownload: skipReDownload); } else { @@ -42,13 +42,13 @@ namespace NzbDrone.Core.Download } } - public void MarkAsFailed(string downloadId) + public void MarkAsFailed(string downloadId, bool skipReDownload = false) { var history = _historyService.Find(downloadId, HistoryEventType.Grabbed); if (history.Any()) { - PublishDownloadFailedEvent(history, "Manually marked as failed"); + PublishDownloadFailedEvent(history, "Manually marked as failed", skipReDownload: skipReDownload); } } @@ -72,7 +72,7 @@ namespace NzbDrone.Core.Download if (grabbedItems.Empty()) { - trackedDownload.Warn("Download wasn't grabbed by sonarr, skipping"); + trackedDownload.Warn("Download wasn't grabbed by Lidarr, skipping"); return; } @@ -81,21 +81,22 @@ namespace NzbDrone.Core.Download } } - private void PublishDownloadFailedEvent(List historyItems, string message, TrackedDownload trackedDownload = null) + private void PublishDownloadFailedEvent(List historyItems, string message, TrackedDownload trackedDownload = null, bool skipReDownload = false) { var historyItem = historyItems.First(); var downloadFailedEvent = new DownloadFailedEvent { - SeriesId = historyItem.SeriesId, - EpisodeIds = historyItems.Select(h => h.EpisodeId).ToList(), + ArtistId = historyItem.ArtistId, + AlbumIds = historyItems.Select(h => h.AlbumId).ToList(), Quality = historyItem.Quality, SourceTitle = historyItem.SourceTitle, DownloadClient = historyItem.Data.GetValueOrDefault(History.History.DOWNLOAD_CLIENT), DownloadId = historyItem.DownloadId, Message = message, Data = historyItem.Data, - TrackedDownload = trackedDownload + TrackedDownload = trackedDownload, + SkipReDownload = skipReDownload }; _eventAggregator.PublishEvent(downloadFailedEvent); diff --git a/src/NzbDrone.Core/Download/IDownloadClient.cs b/src/NzbDrone.Core/Download/IDownloadClient.cs index 6703d8a22..cf0d1e419 100644 --- a/src/NzbDrone.Core/Download/IDownloadClient.cs +++ b/src/NzbDrone.Core/Download/IDownloadClient.cs @@ -9,9 +9,9 @@ namespace NzbDrone.Core.Download { DownloadProtocol Protocol { get; } - string Download(RemoteEpisode remoteEpisode); + string Download(RemoteAlbum remoteAlbum); 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..a9273ec2e 100644 --- a/src/NzbDrone.Core/Download/Pending/PendingRelease.cs +++ b/src/NzbDrone.Core/Download/Pending/PendingRelease.cs @@ -1,4 +1,4 @@ -using System; +using System; using NzbDrone.Core.Datastore; using NzbDrone.Core.Parser.Model; @@ -6,13 +6,14 @@ namespace NzbDrone.Core.Download.Pending { public class PendingRelease : ModelBase { - public int SeriesId { get; set; } + public int ArtistId { get; set; } public string Title { get; set; } public DateTime Added { get; set; } - public ParsedEpisodeInfo ParsedEpisodeInfo { get; set; } + public ParsedAlbumInfo ParsedAlbumInfo { get; set; } public ReleaseInfo Release { get; set; } + public PendingReleaseReason Reason { get; set; } //Not persisted - public RemoteEpisode RemoteEpisode { get; set; } + public RemoteAlbum RemoteAlbum { 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..ba83714b7 --- /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..ded73e2a6 100644 --- a/src/NzbDrone.Core/Download/Pending/PendingReleaseRepository.cs +++ b/src/NzbDrone.Core/Download/Pending/PendingReleaseRepository.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using NzbDrone.Core.Datastore; using NzbDrone.Core.Messaging.Events; @@ -6,8 +6,9 @@ namespace NzbDrone.Core.Download.Pending { public interface IPendingReleaseRepository : IBasicRepository { - void DeleteBySeriesId(int seriesId); - List AllBySeriesId(int seriesId); + void DeleteByArtistId(int artistId); + List AllByArtistId(int artistId); + List WithoutFallback(); } public class PendingReleaseRepository : BasicRepository, IPendingReleaseRepository @@ -17,14 +18,19 @@ namespace NzbDrone.Core.Download.Pending { } - public void DeleteBySeriesId(int seriesId) + public void DeleteByArtistId(int artistId) { - Delete(r => r.SeriesId == seriesId); + Delete(r => r.ArtistId == artistId); } - public List AllBySeriesId(int seriesId) + public List AllByArtistId(int artistId) { - return Query.Where(p => p.SeriesId == seriesId); + return Query.Where(p => p.ArtistId == artistId); + } + + 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..d8a9467ce 100644 --- a/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs +++ b/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; using NLog; using NzbDrone.Common.Crypto; using NzbDrone.Common.Extensions; @@ -13,31 +10,35 @@ using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Profiles.Delay; using NzbDrone.Core.Qualities; -using NzbDrone.Core.Tv; -using NzbDrone.Core.Tv.Events; +using NzbDrone.Core.Music; +using NzbDrone.Core.Music.Events; +using System; +using System.Collections.Generic; +using System.Linq; +using Marr.Data; 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 GetPendingRemoteAlbums(int artistId); List GetPendingQueue(); Queue.Queue FindPendingQueueItem(int queueId); void RemovePendingQueueItems(int queueId); - RemoteEpisode OldestPendingRelease(int seriesId, IEnumerable episodeIds); + RemoteAlbum OldestPendingRelease(int artistId, int[] albumIds); } public class PendingReleaseService : IPendingReleaseService, - IHandle, - IHandle, + IHandle, + IHandle, IHandle { private readonly IIndexerStatusService _indexerStatusService; private readonly IPendingReleaseRepository _repository; - private readonly ISeriesService _seriesService; + private readonly IArtistService _artistService; private readonly IParsingService _parsingService; private readonly IDelayProfileService _delayProfileService; private readonly ITaskManager _taskManager; @@ -47,7 +48,7 @@ namespace NzbDrone.Core.Download.Pending public PendingReleaseService(IIndexerStatusService indexerStatusService, IPendingReleaseRepository repository, - ISeriesService seriesService, + IArtistService artistService, IParsingService parsingService, IDelayProfileService delayProfileService, ITaskManager taskManager, @@ -57,7 +58,7 @@ namespace NzbDrone.Core.Download.Pending { _indexerStatusService = indexerStatusService; _repository = repository; - _seriesService = seriesService; + _artistService = artistService; _parsingService = parsingService; _delayProfileService = delayProfileService; _taskManager = taskManager; @@ -67,24 +68,74 @@ namespace NzbDrone.Core.Download.Pending } - 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); + public void AddMany(List> decisions) + { + foreach (var artistDecisions in decisions.GroupBy(v => v.Item1.RemoteAlbum.Artist.Id)) + { + var artist = artistDecisions.First().Item1.RemoteAlbum.Artist; + var alreadyPending = _repository.AllByArtistId(artist.Id); - var existingReports = alreadyPending.Where(r => r.RemoteEpisode.Episodes.Select(e => e.Id) - .Intersect(episodeIds) - .Any()); + alreadyPending = IncludeRemoteAlbums(alreadyPending, artistDecisions.ToDictionaryIgnoreDuplicates(v => v.Item1.RemoteAlbum.Release.Title, v => v.Item1.RemoteAlbum)); + var alreadyPendingByAlbum = CreateAlbumLookup(alreadyPending); - if (existingReports.Any(MatchingReleasePredicate(decision.RemoteEpisode.Release))) - { - _logger.Debug("This release is already pending, not adding again"); - return; + foreach (var pair in artistDecisions) + { + var decision = pair.Item1; + var reason = pair.Item2; + + var albumIds = decision.RemoteAlbum.Albums.Select(e => e.Id); + + var existingReports = albumIds.SelectMany(v => alreadyPendingByAlbum[v] ?? Enumerable.Empty()) + .Distinct().ToList(); + + var matchingReports = existingReports.Where(MatchingReleasePredicate(decision.RemoteAlbum.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.RemoteAlbum, 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.RemoteAlbum, reason); + } + + if (matchingReports.Count() > 1) + { + _logger.Debug("The release {0} had {1} duplicate pending, removing duplicates.", decision.RemoteAlbum, matchingReports.Count() - 1); + + foreach (var duplicate in matchingReports.Skip(1)) + { + _repository.Delete(duplicate.Id); + alreadyPending.Remove(duplicate); + alreadyPendingByAlbum = CreateAlbumLookup(alreadyPending); + } + } + + continue; + } + + _logger.Debug("Adding release {0} to pending releases with reason {1}", decision.RemoteAlbum, reason); + Insert(decision, reason); + } } + } - _logger.Debug("Adding release to pending releases"); - Insert(decision); + private ILookup CreateAlbumLookup(IEnumerable alreadyPending) + { + return alreadyPending.SelectMany(v => v.RemoteAlbum.Albums + .Select(d => new { Album = d, PendingRelease = v })) + .ToLookup(v => v.Album.Id, v => v.PendingRelease); } public List GetPending() @@ -101,14 +152,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) + public List GetPendingRemoteAlbums(int artistId) { - return _repository.AllBySeriesId(seriesId).Select(GetRemoteEpisode).ToList(); + return IncludeRemoteAlbums(_repository.AllByArtistId(artistId)).Select(v => v.RemoteAlbum).ToList(); } public List GetPendingQueue() @@ -117,11 +168,12 @@ namespace NzbDrone.Core.Download.Pending var nextRssSync = new Lazy(() => _taskManager.GetNextExecution(typeof(RssSyncCommand))); - foreach (var pendingRelease in GetPendingReleases()) + var pendingReleases = IncludeRemoteAlbums(_repository.WithoutFallback()); + foreach (var pendingRelease in pendingReleases) { - foreach (var episode in pendingRelease.RemoteEpisode.Episodes) + foreach (var album in pendingRelease.RemoteAlbum.Albums) { - var ect = pendingRelease.Release.PublishDate.AddMinutes(GetDelay(pendingRelease.RemoteEpisode)); + var ect = pendingRelease.Release.PublishDate.AddMinutes(GetDelay(pendingRelease.RemoteAlbum)); if (ect < nextRssSync.Value) { @@ -132,32 +184,41 @@ 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), - Series = pendingRelease.RemoteEpisode.Series, - Episode = episode, - Quality = pendingRelease.RemoteEpisode.ParsedEpisodeInfo.Quality, - Title = pendingRelease.Title, - Size = pendingRelease.RemoteEpisode.Release.Size, - Sizeleft = pendingRelease.RemoteEpisode.Release.Size, - RemoteEpisode = pendingRelease.RemoteEpisode, - Timeleft = ect.Subtract(DateTime.UtcNow), - EstimatedCompletionTime = ect, - Status = "Pending", - Protocol = pendingRelease.RemoteEpisode.Release.DownloadProtocol - }; + { + Id = GetQueueId(pendingRelease, album), + Artist = pendingRelease.RemoteAlbum.Artist, + Album = album, + Quality = pendingRelease.RemoteAlbum.ParsedAlbumInfo.Quality, + Title = pendingRelease.Title, + Size = pendingRelease.RemoteAlbum.Release.Size, + Sizeleft = pendingRelease.RemoteAlbum.Release.Size, + RemoteAlbum = pendingRelease.RemoteAlbum, + Timeleft = timeleft, + EstimatedCompletionTime = ect, + Status = pendingRelease.Reason.ToString(), + Protocol = pendingRelease.RemoteAlbum.Release.DownloadProtocol, + Indexer = pendingRelease.RemoteAlbum.Release.Indexer + }; + queued.Add(queue); } } - //Return best quality release for each episode - var deduped = queued.GroupBy(q => q.Episode.Id).Select(g => + //Return best quality release for each album + var deduped = queued.GroupBy(q => q.Album.Id).Select(g => { - var series = g.First().Series; + var artist = g.First().Artist; - return g.OrderByDescending(e => e.Quality, new QualityModelComparer(series.Profile)) - .ThenBy(q => PrioritizeDownloadProtocol(q.Series, q.Protocol)) + return g.OrderByDescending(e => e.Quality, new QualityModelComparer(artist.QualityProfile)) + .ThenBy(q => PrioritizeDownloadProtocol(q.Artist, q.Protocol)) .First(); }); @@ -172,67 +233,99 @@ namespace NzbDrone.Core.Download.Pending public void RemovePendingQueueItems(int queueId) { var targetItem = FindPendingRelease(queueId); - var seriesReleases = _repository.AllBySeriesId(targetItem.SeriesId); + var artistReleases = _repository.AllByArtistId(targetItem.ArtistId); - var releasesToRemove = seriesReleases.Where( - c => c.ParsedEpisodeInfo.SeasonNumber == targetItem.ParsedEpisodeInfo.SeasonNumber && - c.ParsedEpisodeInfo.EpisodeNumbers.SequenceEqual(targetItem.ParsedEpisodeInfo.EpisodeNumbers)); + var releasesToRemove = artistReleases.Where( + c => c.ParsedAlbumInfo.AlbumTitle == targetItem.ParsedAlbumInfo.AlbumTitle); _repository.DeleteMany(releasesToRemove.Select(c => c.Id)); } - public RemoteEpisode OldestPendingRelease(int seriesId, IEnumerable episodeIds) + public RemoteAlbum OldestPendingRelease(int artistId, int[] albumIds) { - return GetPendingRemoteEpisodes(seriesId).Where(r => r.Episodes.Select(e => e.Id).Intersect(episodeIds).Any()) - .OrderByDescending(p => p.Release.AgeHours) - .FirstOrDefault(); + var artistReleases = GetPendingReleases(artistId); + + return artistReleases.Select(r => r.RemoteAlbum) + .Where(r => r.Albums.Select(e => e.Id).Intersect(albumIds).Any()) + .OrderByDescending(p => p.Release.AgeHours) + .FirstOrDefault(); } private List GetPendingReleases() + { + return IncludeRemoteAlbums(_repository.All().ToList()); + } + + private List GetPendingReleases(int artistId) + { + return IncludeRemoteAlbums(_repository.AllByArtistId(artistId).ToList()); + } + + private List IncludeRemoteAlbums(List releases, Dictionary knownRemoteAlbums = null) { var result = new List(); - foreach (var release in _repository.All()) + var artistMap = new Dictionary(); + + if (knownRemoteAlbums != null) { - var remoteEpisode = GetRemoteEpisode(release); + foreach (var artist in knownRemoteAlbums.Values.Select(v => v.Artist)) + { + if (!artistMap.ContainsKey(artist.Id)) + { + artistMap[artist.Id] = artist; + } + } + } - if (remoteEpisode == null) continue; + foreach (var artist in _artistService.GetArtists(releases.Select(v => v.ArtistId).Distinct().Where(v => !artistMap.ContainsKey(v)))) + { + artistMap[artist.Id] = artist; + } - release.RemoteEpisode = remoteEpisode; + foreach (var release in releases) + { + var artist = artistMap.GetValueOrDefault(release.ArtistId); - result.Add(release); - } + // Just in case the artist was removed, but wasn't cleaned up yet (housekeeper will clean it up) + if (artist == null) return null; - return result; - } + List albums; - private RemoteEpisode GetRemoteEpisode(PendingRelease release) - { - var series = _seriesService.GetSeries(release.SeriesId); + RemoteAlbum knownRemoteAlbum; + if (knownRemoteAlbums != null && knownRemoteAlbums.TryGetValue(release.Release.Title, out knownRemoteAlbum)) + { + albums = knownRemoteAlbum.Albums; + } + else + { + albums = _parsingService.GetAlbums(release.ParsedAlbumInfo, artist); + } - //Just in case the series was removed, but wasn't cleaned up yet (housekeeper will clean it up) - if (series == null) return null; + release.RemoteAlbum = new RemoteAlbum + { + Artist = artist, + Albums = albums, + ParsedAlbumInfo = release.ParsedAlbumInfo, + Release = release.Release + }; - var episodes = _parsingService.GetEpisodes(release.ParsedEpisodeInfo, series, true); + result.Add(release); + } - return new RemoteEpisode - { - Series = series, - Episodes = episodes, - ParsedEpisodeInfo = release.ParsedEpisodeInfo, - Release = release.Release - }; + return result; } - private void Insert(DownloadDecision decision) + private void Insert(DownloadDecision decision, PendingReleaseReason reason) { _repository.Insert(new PendingRelease { - SeriesId = decision.RemoteEpisode.Series.Id, - ParsedEpisodeInfo = decision.RemoteEpisode.ParsedEpisodeInfo, - Release = decision.RemoteEpisode.Release, - Title = decision.RemoteEpisode.Release.Title, - Added = DateTime.UtcNow + ArtistId = decision.RemoteAlbum.Artist.Id, + ParsedAlbumInfo = decision.RemoteAlbum.ParsedAlbumInfo, + Release = decision.RemoteAlbum.Release, + Title = decision.RemoteAlbum.Release.Title, + Added = DateTime.UtcNow, + Reason = reason }); _eventAggregator.PublishEvent(new PendingReleasesUpdatedEvent()); @@ -251,22 +344,22 @@ namespace NzbDrone.Core.Download.Pending p.Release.Indexer == release.Indexer; } - private int GetDelay(RemoteEpisode remoteEpisode) + private int GetDelay(RemoteAlbum remoteAlbum) { - var delayProfile = _delayProfileService.AllForTags(remoteEpisode.Series.Tags).OrderBy(d => d.Order).First(); - var delay = delayProfile.GetProtocolDelay(remoteEpisode.Release.DownloadProtocol); + var delayProfile = _delayProfileService.AllForTags(remoteAlbum.Artist.Tags).OrderBy(d => d.Order).First(); + var delay = delayProfile.GetProtocolDelay(remoteAlbum.Release.DownloadProtocol); var minimumAge = _configService.MinimumAge; return new[] { delay, minimumAge }.Max(); } - private void RemoveGrabbed(RemoteEpisode remoteEpisode) + private void RemoveGrabbed(RemoteAlbum remoteAlbum) { - var pendingReleases = GetPendingReleases(); - var episodeIds = remoteEpisode.Episodes.Select(e => e.Id); + var pendingReleases = GetPendingReleases(remoteAlbum.Artist.Id); + var albumIds = remoteAlbum.Albums.Select(e => e.Id); - var existingReports = pendingReleases.Where(r => r.RemoteEpisode.Episodes.Select(e => e.Id) - .Intersect(episodeIds) + var existingReports = pendingReleases.Where(r => r.RemoteAlbum.Albums.Select(e => e.Id) + .Intersect(albumIds) .Any()) .ToList(); @@ -275,12 +368,12 @@ namespace NzbDrone.Core.Download.Pending return; } - var profile = remoteEpisode.Series.Profile.Value; + var profile = remoteAlbum.Artist.QualityProfile.Value; foreach (var existingReport in existingReports) { - var compare = new QualityModelComparer(profile).Compare(remoteEpisode.ParsedEpisodeInfo.Quality, - existingReport.RemoteEpisode.ParsedEpisodeInfo.Quality); + var compare = new QualityModelComparer(profile).Compare(remoteAlbum.ParsedAlbumInfo.Quality, + existingReport.RemoteAlbum.ParsedAlbumInfo.Quality); //Only remove lower/equal quality pending releases //It is safer to retry these releases on the next round than remove it and try to re-add it (if its still in the feed) @@ -299,7 +392,7 @@ namespace NzbDrone.Core.Download.Pending foreach (var rejectedRelease in rejected) { - var matching = pending.Where(MatchingReleasePredicate(rejectedRelease.RemoteEpisode.Release)); + var matching = pending.Where(MatchingReleasePredicate(rejectedRelease.RemoteAlbum.Release)); foreach (var pendingRelease in matching) { @@ -311,17 +404,17 @@ namespace NzbDrone.Core.Download.Pending private PendingRelease FindPendingRelease(int queueId) { - return GetPendingReleases().First(p => p.RemoteEpisode.Episodes.Any(e => queueId == GetQueueId(p, e))); + return GetPendingReleases().First(p => p.RemoteAlbum.Albums.Any(e => queueId == GetQueueId(p, e))); } - private int GetQueueId(PendingRelease pendingRelease, Episode episode) + private int GetQueueId(PendingRelease pendingRelease, Album album) { - return HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", pendingRelease.Id, episode.Id)); + return HashConverter.GetHashInt31(string.Format("pending-{0}-album{1}", pendingRelease.Id, album.Id)); } - private int PrioritizeDownloadProtocol(Series series, DownloadProtocol downloadProtocol) + private int PrioritizeDownloadProtocol(Artist artist, DownloadProtocol downloadProtocol) { - var delayProfile = _delayProfileService.BestForTags(series.Tags); + var delayProfile = _delayProfileService.BestForTags(artist.Tags); if (downloadProtocol == delayProfile.PreferredProtocol) { @@ -331,14 +424,14 @@ namespace NzbDrone.Core.Download.Pending return 1; } - public void Handle(SeriesDeletedEvent message) + public void Handle(ArtistDeletedEvent message) { - _repository.DeleteBySeriesId(message.Series.Id); + _repository.DeleteByArtistId(message.Artist.Id); } - public void Handle(EpisodeGrabbedEvent message) + public void Handle(AlbumGrabbedEvent message) { - RemoveGrabbed(message.Episode); + RemoveGrabbed(message.Album); } public void Handle(RssSyncCompleteEvent message) diff --git a/src/NzbDrone.Core/Download/ProcessDownloadDecisions.cs b/src/NzbDrone.Core/Download/ProcessDownloadDecisions.cs index 05719587d..180907f94 100644 --- a/src/NzbDrone.Core/Download/ProcessDownloadDecisions.cs +++ b/src/NzbDrone.Core/Download/ProcessDownloadDecisions.cs @@ -1,9 +1,13 @@ -using System; +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,59 +40,112 @@ namespace NzbDrone.Core.Download var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisions(qualifiedReports); var grabbed = new List(); var pending = new List(); + //var failed = 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 episodeIds = remoteEpisode.Episodes.Select(e => e.Id).ToList(); + var remoteAlbum = report.RemoteAlbum; + var downloadProtocol = report.RemoteAlbum.Release.DownloadProtocol; //Skip if already grabbed - if (grabbed.SelectMany(r => r.RemoteEpisode.Episodes) - .Select(e => e.Id) - .ToList() - .Intersect(episodeIds) - .Any()) + if (IsAlbumProcessed(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; } try { - _downloadService.DownloadReport(remoteEpisode); + _downloadService.DownloadReport(remoteAlbum); grabbed.Add(report); } - catch (Exception e) + catch (ReleaseUnavailableException) + { + _logger.Warn("Failed to download release from indexer, no longer available. " + remoteAlbum); + rejected.Add(report); + } + catch (Exception ex) { - //TODO: support for store & forward - //We'll need to differentiate between a download client error and an indexer error - _logger.Warn(e, "Couldn't add report to download queue. " + remoteEpisode); + if (ex is DownloadClientUnavailableException || ex is DownloadClientAuthenticationException) + { + _logger.Debug(ex, "Failed to send release to download client, storing until later. " + remoteAlbum); + 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. " + remoteAlbum); + } } } - 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) { //Process both approved and temporarily rejected - return decisions.Where(c => (c.Approved || c.TemporarilyRejected) && c.RemoteEpisode.Episodes.Any()).ToList(); + return decisions.Where(c => (c.Approved || c.TemporarilyRejected) && c.RemoteAlbum.Albums.Any()).ToList(); + } + + private bool IsAlbumProcessed(List decisions, DownloadDecision report) + { + var albumIds = report.RemoteAlbum.Albums.Select(e => e.Id).ToList(); + + return decisions.SelectMany(r => r.RemoteAlbum.Albums) + .Select(e => e.Id) + .ToList() + .Intersect(albumIds) + .Any(); + } + + private void PreparePending(List> queue, List grabbed, List pending, DownloadDecision report, PendingReleaseReason reason) + { + // If a release was already grabbed with matching albums 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 (IsAlbumProcessed(grabbed, report) || + IsAlbumProcessed(pending, report)) + { + reason = PendingReleaseReason.Fallback; + } + + queue.Add(Tuple.Create(report, reason)); + pending.Add(report); } } } diff --git a/src/NzbDrone.Core/Download/RedownloadFailedDownloadService.cs b/src/NzbDrone.Core/Download/RedownloadFailedDownloadService.cs index d85729775..ea3ce10d6 100644 --- a/src/NzbDrone.Core/Download/RedownloadFailedDownloadService.cs +++ b/src/NzbDrone.Core/Download/RedownloadFailedDownloadService.cs @@ -1,67 +1,71 @@ -using System.Linq; +using System.Linq; using NLog; using NzbDrone.Core.Configuration; using NzbDrone.Core.IndexerSearch; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Download { public class RedownloadFailedDownloadService : IHandleAsync { private readonly IConfigService _configService; - private readonly IEpisodeService _episodeService; + private readonly IAlbumService _albumService; private readonly IManageCommandQueue _commandQueueManager; private readonly Logger _logger; public RedownloadFailedDownloadService(IConfigService configService, - IEpisodeService episodeService, + IAlbumService albumService, IManageCommandQueue commandQueueManager, Logger logger) { _configService = configService; - _episodeService = episodeService; + _albumService = albumService; _commandQueueManager = commandQueueManager; _logger = logger; } public void HandleAsync(DownloadFailedEvent message) { + if (message.SkipReDownload) + { + _logger.Debug("Skip redownloading requested by user"); + return; + } + if (!_configService.AutoRedownloadFailed) { - _logger.Debug("Auto redownloading failed episodes is disabled"); + _logger.Debug("Auto redownloading failed albums is disabled"); return; } - if (message.EpisodeIds.Count == 1) + if (message.AlbumIds.Count == 1) { - _logger.Debug("Failed download only contains one episode, searching again"); + _logger.Debug("Failed download only contains one album, searching again"); - _commandQueueManager.Push(new EpisodeSearchCommand(message.EpisodeIds)); + _commandQueueManager.Push(new AlbumSearchCommand(message.AlbumIds)); return; } - var seasonNumber = _episodeService.GetEpisode(message.EpisodeIds.First()).SeasonNumber; - var episodesInSeason = _episodeService.GetEpisodesBySeason(message.SeriesId, seasonNumber); + var albumsInArtist = _albumService.GetAlbumsByArtist(message.ArtistId); - if (message.EpisodeIds.Count == episodesInSeason.Count) + if (message.AlbumIds.Count == albumsInArtist.Count) { - _logger.Debug("Failed download was entire season, searching again"); + _logger.Debug("Failed download was entire artist, searching again"); - _commandQueueManager.Push(new SeasonSearchCommand + _commandQueueManager.Push(new ArtistSearchCommand { - SeriesId = message.SeriesId, - SeasonNumber = seasonNumber + ArtistId = message.ArtistId }); return; } - _logger.Debug("Failed download contains multiple episodes, probably a double episode, searching again"); + _logger.Debug("Failed download contains multiple albums, searching again"); - _commandQueueManager.Push(new EpisodeSearchCommand(message.EpisodeIds)); + _commandQueueManager.Push(new AlbumSearchCommand(message.AlbumIds)); } } } diff --git a/src/NzbDrone.Core/Download/TorrentClientBase.cs b/src/NzbDrone.Core/Download/TorrentClientBase.cs index b1fcd7e2e..500a570db 100644 --- a/src/NzbDrone.Core/Download/TorrentClientBase.cs +++ b/src/NzbDrone.Core/Download/TorrentClientBase.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Net; using MonoTorrent; using NzbDrone.Common.Disk; @@ -38,23 +38,23 @@ namespace NzbDrone.Core.Download public virtual bool PreferTorrentFile => false; - protected abstract string AddFromMagnetLink(RemoteEpisode remoteEpisode, string hash, string magnetLink); - protected abstract string AddFromTorrentFile(RemoteEpisode remoteEpisode, string hash, string filename, byte[] fileContent); + protected abstract string AddFromMagnetLink(RemoteAlbum remoteAlbum, string hash, string magnetLink); + protected abstract string AddFromTorrentFile(RemoteAlbum remoteAlbum, string hash, string filename, byte[] fileContent); - public override string Download(RemoteEpisode remoteEpisode) + public override string Download(RemoteAlbum remoteAlbum) { - var torrentInfo = remoteEpisode.Release as TorrentInfo; + var torrentInfo = remoteAlbum.Release as TorrentInfo; string magnetUrl = null; string torrentUrl = null; - if (remoteEpisode.Release.DownloadUrl.IsNotNullOrWhiteSpace() && remoteEpisode.Release.DownloadUrl.StartsWith("magnet:")) + if (remoteAlbum.Release.DownloadUrl.IsNotNullOrWhiteSpace() && remoteAlbum.Release.DownloadUrl.StartsWith("magnet:")) { - magnetUrl = remoteEpisode.Release.DownloadUrl; + magnetUrl = remoteAlbum.Release.DownloadUrl; } else { - torrentUrl = remoteEpisode.Release.DownloadUrl; + torrentUrl = remoteAlbum.Release.DownloadUrl; } if (torrentInfo != null && !torrentInfo.MagnetUrl.IsNullOrWhiteSpace()) @@ -68,7 +68,7 @@ namespace NzbDrone.Core.Download { try { - return DownloadFromWebUrl(remoteEpisode, torrentUrl); + return DownloadFromWebUrl(remoteAlbum, torrentUrl); } catch (Exception ex) { @@ -85,11 +85,11 @@ namespace NzbDrone.Core.Download { try { - return DownloadFromMagnetUrl(remoteEpisode, magnetUrl); + return DownloadFromMagnetUrl(remoteAlbum, magnetUrl); } catch (NotSupportedException ex) { - throw new ReleaseDownloadException(remoteEpisode.Release, "Magnet not supported by download client. ({0})", ex.Message); + throw new ReleaseDownloadException(remoteAlbum.Release, "Magnet not supported by download client. ({0})", ex.Message); } } } @@ -99,13 +99,13 @@ namespace NzbDrone.Core.Download { try { - return DownloadFromMagnetUrl(remoteEpisode, magnetUrl); + return DownloadFromMagnetUrl(remoteAlbum, magnetUrl); } catch (NotSupportedException ex) { if (torrentUrl.IsNullOrWhiteSpace()) { - throw new ReleaseDownloadException(remoteEpisode.Release, "Magnet not supported by download client. ({0})", ex.Message); + throw new ReleaseDownloadException(remoteAlbum.Release, "Magnet not supported by download client. ({0})", ex.Message); } _logger.Debug("Magnet not supported by download client, trying torrent. ({0})", ex.Message); @@ -114,14 +114,14 @@ namespace NzbDrone.Core.Download if (torrentUrl.IsNotNullOrWhiteSpace()) { - return DownloadFromWebUrl(remoteEpisode, torrentUrl); + return DownloadFromWebUrl(remoteAlbum, torrentUrl); } } return null; } - private string DownloadFromWebUrl(RemoteEpisode remoteEpisode, string torrentUrl) + private string DownloadFromWebUrl(RemoteAlbum remoteAlbum, string torrentUrl) { byte[] torrentFile = null; @@ -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"); @@ -143,10 +145,10 @@ namespace NzbDrone.Core.Download { if (locationHeader.StartsWith("magnet:")) { - return DownloadFromMagnetUrl(remoteEpisode, locationHeader); + return DownloadFromMagnetUrl(remoteAlbum, locationHeader); } - return DownloadFromWebUrl(remoteEpisode, locationHeader); + return DownloadFromWebUrl(remoteAlbum, locationHeader); } throw new WebException("Remote website tried to redirect without providing a location."); @@ -154,43 +156,49 @@ namespace NzbDrone.Core.Download torrentFile = response.ResponseData; - _logger.Debug("Downloading torrent for episode '{0}' finished ({1} bytes from {2})", remoteEpisode.Release.Title, torrentFile.Length, torrentUrl); + _logger.Debug("Downloading torrent for release '{0}' finished ({1} bytes from {2})", remoteAlbum.Release.Title, torrentFile.Length, torrentUrl); } catch (HttpException ex) { + if (ex.Response.StatusCode == HttpStatusCode.NotFound) + { + _logger.Error(ex, "Downloading torrent file for album '{0}' failed since it no longer exists ({1})", remoteAlbum.Release.Title, torrentUrl); + throw new ReleaseUnavailableException(remoteAlbum.Release, "Downloading torrent failed", ex); + } + if ((int)ex.Response.StatusCode == 429) { _logger.Error("API Grab Limit reached for {0}", torrentUrl); } else { - _logger.Error(ex, "Downloading torrent file for episode '{0}' failed ({1})", remoteEpisode.Release.Title, torrentUrl); + _logger.Error(ex, "Downloading torrent file for release '{0}' failed ({1})", remoteAlbum.Release.Title, torrentUrl); } - throw new ReleaseDownloadException(remoteEpisode.Release, "Downloading torrent failed", ex); + throw new ReleaseDownloadException(remoteAlbum.Release, "Downloading torrent failed", ex); } catch (WebException ex) { - _logger.Error(ex, "Downloading torrent file for episode '{0}' failed ({1})", remoteEpisode.Release.Title, torrentUrl); + _logger.Error(ex, "Downloading torrent file for release '{0}' failed ({1})", remoteAlbum.Release.Title, torrentUrl); - throw new ReleaseDownloadException(remoteEpisode.Release, "Downloading torrent failed", ex); + throw new ReleaseDownloadException(remoteAlbum.Release, "Downloading torrent failed", ex); } - var filename = string.Format("{0}.torrent", FileNameBuilder.CleanFileName(remoteEpisode.Release.Title)); + var filename = string.Format("{0}.torrent", FileNameBuilder.CleanFileName(remoteAlbum.Release.Title)); var hash = _torrentFileInfoReader.GetHashFromTorrentFile(torrentFile); - var actualHash = AddFromTorrentFile(remoteEpisode, hash, filename, torrentFile); + var actualHash = AddFromTorrentFile(remoteAlbum, hash, filename, torrentFile); if (actualHash.IsNotNullOrWhiteSpace() && hash != actualHash) { _logger.Debug( - "{0} did not return the expected InfoHash for '{1}', Sonarr could potentially lose track of the download in progress.", - Definition.Implementation, remoteEpisode.Release.DownloadUrl); + "{0} did not return the expected InfoHash for '{1}', Lidarr could potentially lose track of the download in progress.", + Definition.Implementation, remoteAlbum.Release.DownloadUrl); } return actualHash; } - private string DownloadFromMagnetUrl(RemoteEpisode remoteEpisode, string magnetUrl) + private string DownloadFromMagnetUrl(RemoteAlbum remoteAlbum, string magnetUrl) { string hash = null; string actualHash = null; @@ -201,21 +209,21 @@ namespace NzbDrone.Core.Download } catch (FormatException ex) { - _logger.Error(ex, "Failed to parse magnetlink for episode '{0}': '{1}'", remoteEpisode.Release.Title, magnetUrl); + _logger.Error(ex, "Failed to parse magnetlink for release '{0}': '{1}'", remoteAlbum.Release.Title, magnetUrl); return null; } if (hash != null) { - actualHash = AddFromMagnetLink(remoteEpisode, hash, magnetUrl); + actualHash = AddFromMagnetLink(remoteAlbum, hash, magnetUrl); } if (actualHash.IsNotNullOrWhiteSpace() && hash != actualHash) { _logger.Debug( - "{0} did not return the expected InfoHash for '{1}', Sonarr could potentially lose track of the download in progress.", - Definition.Implementation, remoteEpisode.Release.DownloadUrl); + "{0} did not return the expected InfoHash for '{1}', Lidarr could potentially lose track of the download in progress.", + Definition.Implementation, remoteAlbum.Release.DownloadUrl); } return actualHash; diff --git a/src/NzbDrone.Core/Download/TrackedDownloads/DownloadMonitoringService.cs b/src/NzbDrone.Core/Download/TrackedDownloads/DownloadMonitoringService.cs index dcaefd073..6ee24ed4c 100644 --- a/src/NzbDrone.Core/Download/TrackedDownloads/DownloadMonitoringService.cs +++ b/src/NzbDrone.Core/Download/TrackedDownloads/DownloadMonitoringService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using NLog; @@ -12,10 +12,13 @@ using NzbDrone.Core.Messaging.Events; namespace NzbDrone.Core.Download.TrackedDownloads { public class DownloadMonitoringService : IExecute, - IHandle, - IHandle + IHandle, + 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 +28,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 +61,7 @@ namespace NzbDrone.Core.Download.TrackedDownloads _refreshDebounce.Pause(); try { - var downloadClients = _downloadClientProvider.GetDownloadClients(); + var downloadClients = _downloadClientFactory.DownloadHandlingEnabled(); var trackedDownloads = new List(); @@ -64,10 +69,10 @@ namespace NzbDrone.Core.Download.TrackedDownloads { var clientTrackedDownloads = ProcessClientDownloads(downloadClient); - // Only track completed downloads if trackedDownloads.AddRange(clientTrackedDownloads.Where(DownloadIsTrackable)); } + _trackedDownloadService.UpdateTrackable(trackedDownloads); _eventAggregator.PublishEvent(new TrackedDownloadRefreshedEvent(trackedDownloads)); } finally @@ -84,9 +89,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 +115,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)); } @@ -144,7 +152,8 @@ namespace NzbDrone.Core.Download.TrackedDownloads private bool DownloadIsTrackable(TrackedDownload trackedDownload) { // If the download has already been imported or failed don't track it - if (trackedDownload.State != TrackedDownloadStage.Downloading) + if (trackedDownload.State == TrackedDownloadStage.DownloadFailed + || trackedDownload.State == TrackedDownloadStage.Imported) { return false; } @@ -163,14 +172,21 @@ namespace NzbDrone.Core.Download.TrackedDownloads Refresh(); } - public void Handle(EpisodeGrabbedEvent message) + public void Handle(AlbumGrabbedEvent message) { _refreshDebounce.Execute(); } - public void Handle(EpisodeImportedEvent message) + public void Handle(TrackImportedEvent message) { _refreshDebounce.Execute(); } + + public void Handle(TrackedDownloadsRemovedEvent message) + { + var trackedDownloads = _trackedDownloadService.GetTrackedDownloads().Where(t => t.IsTrackable && DownloadIsTrackable(t)).ToList(); + + _eventAggregator.PublishEvent(new TrackedDownloadRefreshedEvent(trackedDownloads)); + } } } diff --git a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownload.cs b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownload.cs index be012d57b..14d969942 100644 --- a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownload.cs +++ b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownload.cs @@ -1,4 +1,4 @@ -using NzbDrone.Core.Indexers; +using NzbDrone.Core.Indexers; using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.Download.TrackedDownloads @@ -9,9 +9,11 @@ namespace NzbDrone.Core.Download.TrackedDownloads public DownloadClientItem DownloadItem { get; set; } public TrackedDownloadStage State { get; set; } public TrackedDownloadStatus Status { get; private set; } - public RemoteEpisode RemoteEpisode { get; set; } + public RemoteAlbum RemoteAlbum { get; set; } public TrackedDownloadStatusMessage[] StatusMessages { get; private set; } public DownloadProtocol Protocol { get; set; } + public string Indexer { get; set; } + public bool IsTrackable { get; set; } public TrackedDownload() { @@ -34,8 +36,10 @@ namespace NzbDrone.Core.Download.TrackedDownloads public enum TrackedDownloadStage { Downloading, - Imported, - DownloadFailed + DownloadFailed, + Importing, + ImportFailed, + Imported } public enum TrackedDownloadStatus diff --git a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs index 55ce7398d..7b0ae83df 100644 --- a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs +++ b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs @@ -1,33 +1,45 @@ using System; +using System.Collections.Generic; using System.Linq; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Extensions; using NzbDrone.Core.History; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Music; using NzbDrone.Core.Parser; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Music.Events; namespace NzbDrone.Core.Download.TrackedDownloads { - public interface ITrackedDownloadService + public interface ITrackedDownloadService : IHandle { TrackedDownload Find(string downloadId); + void StopTracking(string downloadId); + void StopTracking(List downloadIds); TrackedDownload TrackDownload(DownloadClientDefinition downloadClient, DownloadClientItem downloadItem); + List GetTrackedDownloads(); + void UpdateTrackable(List trackedDownloads); } public class TrackedDownloadService : ITrackedDownloadService { private readonly IParsingService _parsingService; private readonly IHistoryService _historyService; + private readonly IEventAggregator _eventAggregator; private readonly Logger _logger; private readonly ICached _cache; public TrackedDownloadService(IParsingService parsingService, - ICacheManager cacheManager, - IHistoryService historyService, - Logger logger) + ICacheManager cacheManager, + IHistoryService historyService, + IEventAggregator eventAggregator, + Logger logger) { _parsingService = parsingService; _historyService = historyService; + _eventAggregator = eventAggregator; _cache = cacheManager.GetCache(GetType()); _logger = logger; } @@ -37,13 +49,58 @@ namespace NzbDrone.Core.Download.TrackedDownloads return _cache.Find(downloadId); } + public void UpdateAlbumCache(int albumId) + { + + var updateCacheItems = _cache.Values.Where(x => x.RemoteAlbum != null && x.RemoteAlbum.Albums.Any(a => a.Id == albumId)).ToList(); + + foreach (var item in updateCacheItems) + { + var parsedAlbumInfo = Parser.Parser.ParseAlbumTitle(item.DownloadItem.Title); + item.RemoteAlbum = null; + + if (parsedAlbumInfo != null) + { + item.RemoteAlbum = _parsingService.Map(parsedAlbumInfo); + } + } + + _eventAggregator.PublishEvent(new TrackedDownloadRefreshedEvent(GetTrackedDownloads())); + } + + public void StopTracking(string downloadId) + { + var trackedDownload = _cache.Find(downloadId); + + _cache.Remove(downloadId); + _eventAggregator.PublishEvent(new TrackedDownloadsRemovedEvent(new List { trackedDownload })); + } + + public void StopTracking(List downloadIds) + { + var trackedDownloads = new List(); + + foreach (var downloadId in downloadIds) + { + var trackedDownload = _cache.Find(downloadId); + _cache.Remove(downloadId); + trackedDownloads.Add(trackedDownload); + } + + _eventAggregator.PublishEvent(new TrackedDownloadsRemovedEvent(trackedDownloads)); + } + public TrackedDownload TrackDownload(DownloadClientDefinition downloadClient, DownloadClientItem downloadItem) { var existingItem = Find(downloadItem.DownloadId); if (existingItem != null && existingItem.State != TrackedDownloadStage.Downloading) { + LogItemChange(existingItem, existingItem.DownloadItem, downloadItem); + existingItem.DownloadItem = downloadItem; + existingItem.IsTrackable = true; + return existingItem; } @@ -51,66 +108,147 @@ namespace NzbDrone.Core.Download.TrackedDownloads { DownloadClient = downloadClient.Id, DownloadItem = downloadItem, - Protocol = downloadClient.Protocol + Protocol = downloadClient.Protocol, + IsTrackable = true }; try { - var parsedEpisodeInfo = Parser.Parser.ParseTitle(trackedDownload.DownloadItem.Title); + var parsedAlbumInfo = Parser.Parser.ParseAlbumTitle(trackedDownload.DownloadItem.Title); var historyItems = _historyService.FindByDownloadId(downloadItem.DownloadId); - if (parsedEpisodeInfo != null) + if (parsedAlbumInfo != null) { - trackedDownload.RemoteEpisode = _parsingService.Map(parsedEpisodeInfo, 0, 0); + trackedDownload.RemoteAlbum = _parsingService.Map(parsedAlbumInfo); } if (historyItems.Any()) { var firstHistoryItem = historyItems.OrderByDescending(h => h.Date).First(); - trackedDownload.State = GetStateFromHistory(firstHistoryItem.EventType); + trackedDownload.State = GetStateFromHistory(firstHistoryItem); + if (firstHistoryItem.EventType == HistoryEventType.AlbumImportIncomplete) + { + var messages = Json.Deserialize>(firstHistoryItem?.Data["statusMessages"]).ToArray(); + trackedDownload.Warn(messages); + } + + var grabbedEvent = historyItems.FirstOrDefault(v => v.EventType == HistoryEventType.Grabbed); + trackedDownload.Indexer = grabbedEvent?.Data["indexer"]; + - if (parsedEpisodeInfo == null || - trackedDownload.RemoteEpisode == null || - trackedDownload.RemoteEpisode.Series == null || - trackedDownload.RemoteEpisode.Episodes.Empty()) + if (parsedAlbumInfo == null || + trackedDownload.RemoteAlbum == null || + trackedDownload.RemoteAlbum.Artist == null || + trackedDownload.RemoteAlbum.Albums.Empty()) { // 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); + var historyArtist = firstHistoryItem.Artist; + var historyAlbums = new List { firstHistoryItem.Album }; - if (parsedEpisodeInfo != null) + parsedAlbumInfo = Parser.Parser.ParseAlbumTitle(firstHistoryItem.SourceTitle); + + if (parsedAlbumInfo != null) + { + trackedDownload.RemoteAlbum = _parsingService.Map(parsedAlbumInfo, + firstHistoryItem.ArtistId, + historyItems.Where(v => v.EventType == HistoryEventType.Grabbed).Select(h => h.AlbumId) + .Distinct()); + } + else { - trackedDownload.RemoteEpisode = _parsingService.Map(parsedEpisodeInfo, firstHistoryItem.SeriesId, historyItems.Where(v => v.EventType == HistoryEventType.Grabbed).Select(h => h.EpisodeId).Distinct()); + parsedAlbumInfo = + Parser.Parser.ParseAlbumTitleWithSearchCriteria(firstHistoryItem.SourceTitle, + historyArtist, historyAlbums); + + if (parsedAlbumInfo != null) + { + trackedDownload.RemoteAlbum = _parsingService.Map(parsedAlbumInfo, + firstHistoryItem.ArtistId, + historyItems.Where(v => v.EventType == HistoryEventType.Grabbed).Select(h => h.AlbumId) + .Distinct()); + } } } } - if (trackedDownload.RemoteEpisode == null) + // Track it so it can be displayed in the queue even though we can't determine which artist it is for + if (trackedDownload.RemoteAlbum == null) { - return null; + _logger.Trace("No Album found for download '{0}'", trackedDownload.DownloadItem.Title); + trackedDownload.Warn("No Album found for download '{0}'", trackedDownload.DownloadItem.Title); } } catch (Exception e) { - _logger.Debug(e, "Failed to find episode for " + downloadItem.Title); + _logger.Debug(e, "Failed to find album for " + downloadItem.Title); return null; } + LogItemChange(trackedDownload, existingItem?.DownloadItem, trackedDownload.DownloadItem); + _cache.Set(trackedDownload.DownloadItem.DownloadId, trackedDownload); return trackedDownload; } - private static TrackedDownloadStage GetStateFromHistory(HistoryEventType eventType) + public List GetTrackedDownloads() + { + return _cache.Values.ToList(); + } + + public void UpdateTrackable(List trackedDownloads) + { + var untrackable = GetTrackedDownloads().ExceptBy(t => t.DownloadItem.DownloadId, trackedDownloads, t => t.DownloadItem.DownloadId, StringComparer.CurrentCulture).ToList(); + + foreach (var trackedDownload in untrackable) + { + trackedDownload.IsTrackable = false; + } + } + + private void LogItemChange(TrackedDownload trackedDownload, DownloadClientItem existingItem, DownloadClientItem downloadItem) { - switch (eventType) + if (existingItem == null || + existingItem.Status != downloadItem.Status || + existingItem.CanBeRemoved != downloadItem.CanBeRemoved || + existingItem.CanMoveFiles != downloadItem.CanMoveFiles) { - case HistoryEventType.DownloadFolderImported: + _logger.Debug("Tracking '{0}:{1}': ClientState={2}{3} LidarrStage={4} Album='{5}' OutputPath={6}.", + downloadItem.DownloadClient, downloadItem.Title, + downloadItem.Status, downloadItem.CanBeRemoved ? "" : + downloadItem.CanMoveFiles ? " (busy)" : " (readonly)", + trackedDownload.State, + trackedDownload.RemoteAlbum?.ParsedAlbumInfo, + downloadItem.OutputPath); + } + } + + + private static TrackedDownloadStage GetStateFromHistory(NzbDrone.Core.History.History history) + { + switch (history.EventType) + { + case HistoryEventType.AlbumImportIncomplete: + return TrackedDownloadStage.ImportFailed; + case HistoryEventType.DownloadImported: return TrackedDownloadStage.Imported; case HistoryEventType.DownloadFailed: return TrackedDownloadStage.DownloadFailed; - default: - return TrackedDownloadStage.Downloading; } + + // Since DownloadComplete is a new event type, we can't assume it exists for old downloads + if (history.EventType == HistoryEventType.TrackFileImported) + { + return DateTime.UtcNow.Subtract(history.Date).TotalSeconds < 60 ? TrackedDownloadStage.Importing : TrackedDownloadStage.Imported; + } + + return TrackedDownloadStage.Downloading; + } + + public void Handle(AlbumDeletedEvent message) + { + UpdateAlbumCache(message.Album.Id); } } } + diff --git a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadStatusMessage.cs b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadStatusMessage.cs index e0537b8c3..3e8e5164d 100644 --- a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadStatusMessage.cs +++ b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadStatusMessage.cs @@ -21,7 +21,7 @@ namespace NzbDrone.Core.Download.TrackedDownloads } //Constructor for use when deserializing JSON - private TrackedDownloadStatusMessage() + public TrackedDownloadStatusMessage() { } } diff --git a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadsRemovedEvent.cs b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadsRemovedEvent.cs new file mode 100644 index 000000000..76c926d5a --- /dev/null +++ b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadsRemovedEvent.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using NzbDrone.Common.Messaging; + +namespace NzbDrone.Core.Download.TrackedDownloads +{ + public class TrackedDownloadsRemovedEvent : IEvent + { + public List TrackedDownloads { get; private set; } + + public TrackedDownloadsRemovedEvent(List trackedDownloads) + { + TrackedDownloads = trackedDownloads; + } + } +} diff --git a/src/NzbDrone.Core/Download/UsenetClientBase.cs b/src/NzbDrone.Core/Download/UsenetClientBase.cs index a6c0ed7d5..3e74f2fbb 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; @@ -8,6 +8,7 @@ using NzbDrone.Core.Parser.Model; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Configuration; using NLog; +using NzbDrone.Common.Extensions; using NzbDrone.Core.RemotePathMappings; namespace NzbDrone.Core.Download @@ -16,56 +17,75 @@ 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); + protected abstract string AddFromNzbFile(RemoteAlbum remoteAlbum, string filename, byte[] fileContent); - public override string Download(RemoteEpisode remoteEpisode) + public override string Download(RemoteAlbum remoteAlbum) { - var url = remoteEpisode.Release.DownloadUrl; - var filename = FileNameBuilder.CleanFileName(remoteEpisode.Release.Title) + ".nzb"; + var url = remoteAlbum.Release.DownloadUrl; + var filename = FileNameBuilder.CleanFileName(remoteAlbum.Release.Title) + ".nzb"; byte[] nzbData; try { - nzbData = _httpClient.Get(new HttpRequest(url)).ResponseData; + var nzbDataRequest = new HttpRequest(url); - _logger.Debug("Downloaded nzb for episode '{0}' finished ({1} bytes from {2})", remoteEpisode.Release.Title, nzbData.Length, url); + // TODO: Look into moving download request handling to indexer + if (remoteAlbum.Release.BasicAuthString.IsNotNullOrWhiteSpace()) + { + nzbDataRequest.Headers.Set("Authorization", "Basic " + remoteAlbum.Release.BasicAuthString); + } + + nzbData = _httpClient.Get(nzbDataRequest).ResponseData; + + _logger.Debug("Downloaded nzb for release '{0}' finished ({1} bytes from {2})", remoteAlbum.Release.Title, nzbData.Length, url); } catch (HttpException ex) { + if (ex.Response.StatusCode == HttpStatusCode.NotFound) + { + _logger.Error(ex, "Downloading nzb file for album '{0}' failed since it no longer exists ({1})", remoteAlbum.Release.Title, url); + throw new ReleaseUnavailableException(remoteAlbum.Release, "Downloading torrent failed", ex); + } + if ((int)ex.Response.StatusCode == 429) { _logger.Error("API Grab Limit reached for {0}", url); } else { - _logger.Error(ex, "Downloading nzb for episode '{0}' failed ({1})", remoteEpisode.Release.Title, url); + _logger.Error(ex, "Downloading nzb for release '{0}' failed ({1})", remoteAlbum.Release.Title, url); } - throw new ReleaseDownloadException(remoteEpisode.Release, "Downloading nzb failed", ex); + throw new ReleaseDownloadException(remoteAlbum.Release, "Downloading nzb failed", ex); } catch (WebException ex) { - _logger.Error(ex, "Downloading nzb for episode '{0}' failed ({1})", remoteEpisode.Release.Title, url); + _logger.Error(ex, "Downloading nzb for release '{0}' failed ({1})", remoteAlbum.Release.Title, url); - throw new ReleaseDownloadException(remoteEpisode.Release, "Downloading nzb failed", ex); + throw new ReleaseDownloadException(remoteAlbum.Release, "Downloading nzb failed", ex); } - _logger.Info("Adding report [{0}] to the queue.", remoteEpisode.Release.Title); - return AddFromNzbFile(remoteEpisode, filename, nzbData); + _nzbValidationService.Validate(filename, nzbData); + + _logger.Info("Adding report [{0}] to the queue.", remoteAlbum.Release.Title); + return AddFromNzbFile(remoteAlbum, filename, nzbData); } } } diff --git a/src/NzbDrone.Core/Exceptions/AlbumNotFoundException.cs b/src/NzbDrone.Core/Exceptions/AlbumNotFoundException.cs new file mode 100644 index 000000000..d9ac2729f --- /dev/null +++ b/src/NzbDrone.Core/Exceptions/AlbumNotFoundException.cs @@ -0,0 +1,31 @@ +using NzbDrone.Common.Exceptions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Exceptions +{ + public class AlbumNotFoundException : NzbDroneException + { + public string MusicBrainzId { get; set; } + + public AlbumNotFoundException(string musicbrainzId) + : base(string.Format("Album with MusicBrainz {0} was not found, it may have been removed from MusicBrainz.", musicbrainzId)) + { + MusicBrainzId = musicbrainzId; + } + + public AlbumNotFoundException(string musicbrainzId, string message, params object[] args) + : base(message, args) + { + MusicBrainzId = musicbrainzId; + } + + public AlbumNotFoundException(string musicbrainzId, string message) + : base(message) + { + MusicBrainzId = musicbrainzId; + } + } +} diff --git a/src/NzbDrone.Core/Exceptions/ArtistNotFoundException.cs b/src/NzbDrone.Core/Exceptions/ArtistNotFoundException.cs new file mode 100644 index 000000000..321777c79 --- /dev/null +++ b/src/NzbDrone.Core/Exceptions/ArtistNotFoundException.cs @@ -0,0 +1,31 @@ +using NzbDrone.Common.Exceptions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Exceptions +{ + public class ArtistNotFoundException : NzbDroneException + { + public string MusicBrainzId { get; set; } + + public ArtistNotFoundException(string musicbrainzId) + : base(string.Format("Artist with MusicBrainz {0} was not found, it may have been removed from MusicBrainz.", musicbrainzId)) + { + MusicBrainzId = musicbrainzId; + } + + public ArtistNotFoundException(string musicbrainzId, string message, params object[] args) + : base(message, args) + { + MusicBrainzId = musicbrainzId; + } + + public ArtistNotFoundException(string musicbrainzId, string message) + : base(message) + { + MusicBrainzId = musicbrainzId; + } + } +} diff --git a/src/NzbDrone.Core/Exceptions/DownloadClientRejectedReleaseException.cs b/src/NzbDrone.Core/Exceptions/DownloadClientRejectedReleaseException.cs new file mode 100644 index 000000000..cb9a4c4dc --- /dev/null +++ b/src/NzbDrone.Core/Exceptions/DownloadClientRejectedReleaseException.cs @@ -0,0 +1,28 @@ +using System; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.Exceptions +{ + public class DownloadClientRejectedReleaseException : ReleaseDownloadException + { + public DownloadClientRejectedReleaseException(ReleaseInfo release, string message, params object[] args) + : base(release, message, args) + { + } + + public DownloadClientRejectedReleaseException(ReleaseInfo release, string message) + : base(release, message) + { + } + + public DownloadClientRejectedReleaseException(ReleaseInfo release, string message, Exception innerException, params object[] args) + : base(release, message, innerException, args) + { + } + + public DownloadClientRejectedReleaseException(ReleaseInfo release, string message, Exception innerException) + : base(release, message, innerException) + { + } + } +} 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/Exceptions/SeriesNotFoundException.cs b/src/NzbDrone.Core/Exceptions/SeriesNotFoundException.cs deleted file mode 100644 index b329bde8d..000000000 --- a/src/NzbDrone.Core/Exceptions/SeriesNotFoundException.cs +++ /dev/null @@ -1,27 +0,0 @@ -using NzbDrone.Common.Exceptions; - -namespace NzbDrone.Core.Exceptions -{ - public class SeriesNotFoundException : NzbDroneException - { - public int TvdbSeriesId { get; set; } - - public SeriesNotFoundException(int tvdbSeriesId) - : base(string.Format("Series with tvdbid {0} was not found, it may have been removed from TheTVDB.", tvdbSeriesId)) - { - TvdbSeriesId = tvdbSeriesId; - } - - public SeriesNotFoundException(int tvdbSeriesId, string message, params object[] args) - : base(message, args) - { - TvdbSeriesId = tvdbSeriesId; - } - - public SeriesNotFoundException(int tvdbSeriesId, string message) - : base(message) - { - TvdbSeriesId = tvdbSeriesId; - } - } -} diff --git a/src/NzbDrone.Core/Extras/ExistingExtraFileService.cs b/src/NzbDrone.Core/Extras/ExistingExtraFileService.cs index f2646d67e..14dfb622a 100644 --- a/src/NzbDrone.Core/Extras/ExistingExtraFileService.cs +++ b/src/NzbDrone.Core/Extras/ExistingExtraFileService.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using System.Linq; using NLog; @@ -10,7 +10,7 @@ using NzbDrone.Core.Messaging.Events; namespace NzbDrone.Core.Extras { - public class ExistingExtraFileService : IHandle + public class ExistingExtraFileService : IHandle { private readonly IDiskProvider _diskProvider; private readonly IDiskScanService _diskScanService; @@ -28,29 +28,29 @@ namespace NzbDrone.Core.Extras _logger = logger; } - public void Handle(SeriesScannedEvent message) + public void Handle(ArtistScannedEvent message) { - var series = message.Series; + var artist = message.Artist; var extraFiles = new List(); - if (!_diskProvider.FolderExists(series.Path)) + if (!_diskProvider.FolderExists(artist.Path)) { return; } - _logger.Debug("Looking for existing extra files in {0}", series.Path); + _logger.Debug("Looking for existing extra files in {0}", artist.Path); - var filesOnDisk = _diskScanService.GetNonVideoFiles(series.Path); - var possibleExtraFiles = _diskScanService.FilterFiles(series, filesOnDisk); + var filesOnDisk = _diskScanService.GetNonAudioFiles(artist.Path); + var possibleExtraFiles = _diskScanService.FilterFiles(artist.Path, filesOnDisk); var filteredFiles = possibleExtraFiles; var importedFiles = new List(); foreach (var existingExtraFileImporter in _existingExtraFileImporters) { - var imported = existingExtraFileImporter.ProcessFiles(series, filteredFiles, importedFiles); + var imported = existingExtraFileImporter.ProcessFiles(artist, filteredFiles, importedFiles); - importedFiles.AddRange(imported.Select(f => Path.Combine(series.Path, f.RelativePath))); + importedFiles.AddRange(imported.Select(f => Path.Combine(artist.Path, f.RelativePath))); } _logger.Info("Found {0} extra files", extraFiles.Count); diff --git a/src/NzbDrone.Core/Extras/ExtraService.cs b/src/NzbDrone.Core/Extras/ExtraService.cs index 5906de176..ced94df47 100644 --- a/src/NzbDrone.Core/Extras/ExtraService.cs +++ b/src/NzbDrone.Core/Extras/ExtraService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -12,56 +12,60 @@ using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Extras { public interface IExtraService { - void ImportExtraFiles(LocalEpisode localEpisode, EpisodeFile episodeFile, bool isReadOnly); + void ImportTrack(LocalTrack localTrack, TrackFile trackFile, bool isReadOnly); } public class ExtraService : IExtraService, IHandle, - IHandle, - IHandle + IHandle, + IHandle { private readonly IMediaFileService _mediaFileService; - private readonly IEpisodeService _episodeService; + private readonly IAlbumService _albumService; + private readonly ITrackService _trackService; private readonly IDiskProvider _diskProvider; private readonly IConfigService _configService; private readonly List _extraFileManagers; private readonly Logger _logger; public ExtraService(IMediaFileService mediaFileService, - IEpisodeService episodeService, + IAlbumService albumService, + ITrackService trackService, IDiskProvider diskProvider, IConfigService configService, List extraFileManagers, Logger logger) { _mediaFileService = mediaFileService; - _episodeService = episodeService; + _albumService = albumService; + _trackService = trackService; _diskProvider = diskProvider; _configService = configService; _extraFileManagers = extraFileManagers.OrderBy(e => e.Order).ToList(); _logger = logger; } - public void ImportExtraFiles(LocalEpisode localEpisode, EpisodeFile episodeFile, bool isReadOnly) + public void ImportTrack(LocalTrack localTrack, TrackFile trackFile, bool isReadOnly) { - var series = localEpisode.Series; + ImportExtraFiles(localTrack, trackFile, isReadOnly); - foreach (var extraFileManager in _extraFileManagers) + CreateAfterImport(localTrack.Artist, trackFile); + } + + public void ImportExtraFiles(LocalTrack localTrack, TrackFile trackFile, bool isReadOnly) + { + if (!_configService.ImportExtraFiles) { - extraFileManager.CreateAfterEpisodeImport(series, episodeFile); + return; } - // TODO: Remove - // Not importing files yet, testing that parsing is working properly first - return; - - var sourcePath = localEpisode.Path; + var sourcePath = localTrack.Path; var sourceFolder = _diskProvider.GetParentFolder(sourcePath); var sourceFileName = Path.GetFileNameWithoutExtension(sourcePath); var files = _diskProvider.GetFiles(sourceFolder, SearchOption.TopDirectoryOnly); @@ -70,7 +74,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) { @@ -85,7 +89,8 @@ namespace NzbDrone.Core.Extras { foreach (var extraFileManager in _extraFileManagers) { - var extraFile = extraFileManager.Import(series, episodeFile, matchingFilename, matchingExtension, isReadOnly); + var extension = Path.GetExtension(matchingFilename); + var extraFile = extraFileManager.Import(localTrack.Artist, trackFile, matchingFilename, extension, isReadOnly); if (extraFile != null) { @@ -100,50 +105,60 @@ namespace NzbDrone.Core.Extras } } + private void CreateAfterImport(Artist artist, TrackFile trackFile) + { + foreach (var extraFileManager in _extraFileManagers) + { + extraFileManager.CreateAfterTrackImport(artist, trackFile); + } + } + public void Handle(MediaCoversUpdatedEvent message) { - var series = message.Series; - var episodeFiles = GetEpisodeFiles(series.Id); + var artist = message.Artist; + + var trackFiles = GetTrackFiles(artist.Id); foreach (var extraFileManager in _extraFileManagers) { - extraFileManager.CreateAfterSeriesScan(series, episodeFiles); + extraFileManager.CreateAfterArtistScan(artist, trackFiles); } } - public void Handle(EpisodeFolderCreatedEvent message) + public void Handle(TrackFolderCreatedEvent message) { - var series = message.Series; + var artist = message.Artist; + var album = _albumService.GetAlbum(message.TrackFile.AlbumId); foreach (var extraFileManager in _extraFileManagers) { - extraFileManager.CreateAfterEpisodeImport(series, message.SeriesFolder, message.SeasonFolder); + extraFileManager.CreateAfterTrackImport(artist, album, message.ArtistFolder, message.AlbumFolder); } } - public void Handle(SeriesRenamedEvent message) + public void Handle(ArtistRenamedEvent message) { - var series = message.Series; - var episodeFiles = GetEpisodeFiles(series.Id); + var artist = message.Artist; + var trackFiles = GetTrackFiles(artist.Id); foreach (var extraFileManager in _extraFileManagers) { - extraFileManager.MoveFilesAfterRename(series, episodeFiles); + extraFileManager.MoveFilesAfterRename(artist, trackFiles); } } - private List GetEpisodeFiles(int seriesId) + private List GetTrackFiles(int artistId) { - var episodeFiles = _mediaFileService.GetFilesBySeries(seriesId); - var episodes = _episodeService.GetEpisodeBySeries(seriesId); + var trackFiles = _mediaFileService.GetFilesByArtist(artistId); + var tracks = _trackService.GetTracksByArtist(artistId); - foreach (var episodeFile in episodeFiles) + foreach (var trackFile in trackFiles) { - var localEpisodeFile = episodeFile; - episodeFile.Episodes = new LazyList(episodes.Where(e => e.EpisodeFileId == localEpisodeFile.Id)); + var localTrackFile = trackFile; + trackFile.Tracks = new LazyList(tracks.Where(e => e.TrackFileId == localTrackFile.Id)); } - return episodeFiles; + return trackFiles; } } } diff --git a/src/NzbDrone.Core/Extras/Files/ExtraFile.cs b/src/NzbDrone.Core/Extras/Files/ExtraFile.cs index 036eaec33..1e3c2b8bf 100644 --- a/src/NzbDrone.Core/Extras/Files/ExtraFile.cs +++ b/src/NzbDrone.Core/Extras/Files/ExtraFile.cs @@ -1,13 +1,13 @@ -using System; +using System; using NzbDrone.Core.Datastore; namespace NzbDrone.Core.Extras.Files { public abstract class ExtraFile : ModelBase { - public int SeriesId { get; set; } - public int? EpisodeFileId { get; set; } - public int? SeasonNumber { get; set; } + public int ArtistId { get; set; } + public int? TrackFileId { get; set; } + public int? AlbumId { get; set; } public string RelativePath { get; set; } public DateTime Added { get; set; } public DateTime LastUpdated { get; set; } diff --git a/src/NzbDrone.Core/Extras/Files/ExtraFileManager.cs b/src/NzbDrone.Core/Extras/Files/ExtraFileManager.cs index 3b48f1cb1..360bbb13c 100644 --- a/src/NzbDrone.Core/Extras/Files/ExtraFileManager.cs +++ b/src/NzbDrone.Core/Extras/Files/ExtraFileManager.cs @@ -1,21 +1,24 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.IO; +using System.Text; +using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Extras.Files { public interface IManageExtraFiles { int Order { get; } - IEnumerable CreateAfterSeriesScan(Series series, List episodeFiles); - IEnumerable CreateAfterEpisodeImport(Series series, EpisodeFile episodeFile); - IEnumerable CreateAfterEpisodeImport(Series series, string seriesFolder, string seasonFolder); - IEnumerable MoveFilesAfterRename(Series series, List episodeFiles); - ExtraFile Import(Series series, EpisodeFile episodeFile, string path, string extension, bool readOnly); + IEnumerable CreateAfterArtistScan(Artist artist, List trackFiles); + IEnumerable CreateAfterTrackImport(Artist artist, TrackFile trackFile); + IEnumerable CreateAfterTrackImport(Artist artist, Album album, string artistFolder, string albumFolder); + IEnumerable MoveFilesAfterRename(Artist artist, List trackFiles); + ExtraFile Import(Artist artist, TrackFile trackFile, string path, string extension, bool readOnly); } public abstract class ExtraFileManager : IManageExtraFiles @@ -23,29 +26,41 @@ namespace NzbDrone.Core.Extras.Files { private readonly IConfigService _configService; + private readonly IDiskProvider _diskProvider; private readonly IDiskTransferService _diskTransferService; - private readonly IExtraFileService _extraFileService; + private readonly Logger _logger; public ExtraFileManager(IConfigService configService, + IDiskProvider diskProvider, IDiskTransferService diskTransferService, - IExtraFileService extraFileService) + Logger logger) { _configService = configService; + _diskProvider = diskProvider; _diskTransferService = diskTransferService; - _extraFileService = extraFileService; + _logger = logger; } public abstract int Order { get; } - public abstract IEnumerable CreateAfterSeriesScan(Series series, List episodeFiles); - public abstract IEnumerable CreateAfterEpisodeImport(Series series, EpisodeFile episodeFile); - public abstract IEnumerable CreateAfterEpisodeImport(Series series, string seriesFolder, string seasonFolder); - public abstract IEnumerable MoveFilesAfterRename(Series series, List episodeFiles); - public abstract ExtraFile Import(Series series, EpisodeFile episodeFile, string path, string extension, bool readOnly); + public abstract IEnumerable CreateAfterArtistScan(Artist artist, List trackFiles); + public abstract IEnumerable CreateAfterTrackImport(Artist artist, TrackFile trackFile); + public abstract IEnumerable CreateAfterTrackImport(Artist artist, Album album, string artistFolder, string albumFolder); + public abstract IEnumerable MoveFilesAfterRename(Artist artist, List trackFiles); + public abstract ExtraFile Import(Artist artist, TrackFile trackFile, string path, string extension, bool readOnly); - protected TExtraFile ImportFile(Series series, EpisodeFile episodeFile, string path, string extension, bool readOnly) + protected TExtraFile ImportFile(Artist artist, TrackFile trackFile, string path, bool readOnly, string extension, string fileNameSuffix = null) { - var newFileName = Path.Combine(series.Path, Path.ChangeExtension(episodeFile.RelativePath, extension)); + var newFolder = Path.GetDirectoryName(trackFile.Path); + var filenameBuilder = new StringBuilder(Path.GetFileNameWithoutExtension(trackFile.Path)); + if (fileNameSuffix.IsNotNullOrWhiteSpace()) + { + filenameBuilder.Append(fileNameSuffix); + } + + filenameBuilder.Append(extension); + + var newFileName = Path.Combine(newFolder, filenameBuilder.ToString()); var transferMode = TransferMode.Move; if (readOnly) @@ -57,12 +72,45 @@ namespace NzbDrone.Core.Extras.Files return new TExtraFile { - SeriesId = series.Id, - SeasonNumber = episodeFile.SeasonNumber, - EpisodeFileId = episodeFile.Id, - RelativePath = series.Path.GetRelativePath(newFileName), - Extension = Path.GetExtension(path) + ArtistId = artist.Id, + AlbumId = trackFile.AlbumId, + TrackFileId = trackFile.Id, + RelativePath = artist.Path.GetRelativePath(newFileName), + Extension = extension }; } + + protected TExtraFile MoveFile(Artist artist, TrackFile trackFile, TExtraFile extraFile, string fileNameSuffix = null) + { + var newFolder = Path.GetDirectoryName(trackFile.Path); + var filenameBuilder = new StringBuilder(Path.GetFileNameWithoutExtension(trackFile.Path)); + + if (fileNameSuffix.IsNotNullOrWhiteSpace()) + { + filenameBuilder.Append(fileNameSuffix); + } + + filenameBuilder.Append(extraFile.Extension); + + var existingFileName = Path.Combine(artist.Path, extraFile.RelativePath); + var newFileName = Path.Combine(newFolder, filenameBuilder.ToString()); + + if (newFileName.PathNotEquals(existingFileName)) + { + try + { + _diskProvider.MoveFile(existingFileName, newFileName); + extraFile.RelativePath = artist.Path.GetRelativePath(newFileName); + + return extraFile; + } + catch (Exception ex) + { + _logger.Warn(ex, "Unable to move file after rename: {0}", existingFileName); + } + } + + return null; + } } } diff --git a/src/NzbDrone.Core/Extras/Files/ExtraFileRepository.cs b/src/NzbDrone.Core/Extras/Files/ExtraFileRepository.cs index 7cb4644c3..5e3a900a6 100644 --- a/src/NzbDrone.Core/Extras/Files/ExtraFileRepository.cs +++ b/src/NzbDrone.Core/Extras/Files/ExtraFileRepository.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using NzbDrone.Core.Datastore; using NzbDrone.Core.Messaging.Events; @@ -7,12 +7,12 @@ namespace NzbDrone.Core.Extras.Files { public interface IExtraFileRepository : IBasicRepository where TExtraFile : ExtraFile, new() { - void DeleteForSeries(int seriesId); - void DeleteForSeason(int seriesId, int seasonNumber); - void DeleteForEpisodeFile(int episodeFileId); - List GetFilesBySeries(int seriesId); - List GetFilesBySeason(int seriesId, int seasonNumber); - List GetFilesByEpisodeFile(int episodeFileId); + void DeleteForArtist(int artistId); + void DeleteForAlbum(int artistId, int albumId); + void DeleteForTrackFile(int trackFileId); + List GetFilesByArtist(int artistId); + List GetFilesByAlbum(int artistId, int albumId); + List GetFilesByTrackFile(int trackFileId); TExtraFile FindByPath(string path); } @@ -24,34 +24,34 @@ namespace NzbDrone.Core.Extras.Files { } - public void DeleteForSeries(int seriesId) + public void DeleteForArtist(int artistId) { - Delete(c => c.SeriesId == seriesId); + Delete(c => c.ArtistId == artistId); } - public void DeleteForSeason(int seriesId, int seasonNumber) + public void DeleteForAlbum(int artistId, int albumId) { - Delete(c => c.SeriesId == seriesId && c.SeasonNumber == seasonNumber); + Delete(c => c.ArtistId == artistId && c.AlbumId == albumId); } - public void DeleteForEpisodeFile(int episodeFileId) + public void DeleteForTrackFile(int trackFileId) { - Delete(c => c.EpisodeFileId == episodeFileId); + Delete(c => c.TrackFileId == trackFileId); } - public List GetFilesBySeries(int seriesId) + public List GetFilesByArtist(int artistId) { - return Query.Where(c => c.SeriesId == seriesId); + return Query.Where(c => c.ArtistId == artistId); } - public List GetFilesBySeason(int seriesId, int seasonNumber) + public List GetFilesByAlbum(int artistId, int albumId) { - return Query.Where(c => c.SeriesId == seriesId && c.SeasonNumber == seasonNumber); + return Query.Where(c => c.ArtistId == artistId && c.AlbumId == albumId); } - public List GetFilesByEpisodeFile(int episodeFileId) + public List GetFilesByTrackFile(int trackFileId) { - return Query.Where(c => c.EpisodeFileId == episodeFileId); + return Query.Where(c => c.TrackFileId == trackFileId); } public TExtraFile FindByPath(string path) diff --git a/src/NzbDrone.Core/Extras/Files/ExtraFileService.cs b/src/NzbDrone.Core/Extras/Files/ExtraFileService.cs index 54d86e908..d5a61ee6e 100644 --- a/src/NzbDrone.Core/Extras/Files/ExtraFileService.cs +++ b/src/NzbDrone.Core/Extras/Files/ExtraFileService.cs @@ -1,22 +1,23 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; using NLog; using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Tv; -using NzbDrone.Core.Tv.Events; +using NzbDrone.Core.Music; +using NzbDrone.Core.Music.Events; namespace NzbDrone.Core.Extras.Files { public interface IExtraFileService where TExtraFile : ExtraFile, new() { - List GetFilesBySeries(int seriesId); - List GetFilesByEpisodeFile(int episodeFileId); + List GetFilesByArtist(int artistId); + List GetFilesByTrackFile(int trackFileId); TExtraFile FindByPath(string path); void Upsert(TExtraFile extraFile); void Upsert(List extraFiles); @@ -25,39 +26,37 @@ namespace NzbDrone.Core.Extras.Files } public abstract class ExtraFileService : IExtraFileService, - IHandleAsync, - IHandleAsync + IHandleAsync, + IHandle where TExtraFile : ExtraFile, new() { private readonly IExtraFileRepository _repository; - private readonly ISeriesService _seriesService; + private readonly IArtistService _artistService; private readonly IDiskProvider _diskProvider; private readonly IRecycleBinProvider _recycleBinProvider; private readonly Logger _logger; public ExtraFileService(IExtraFileRepository repository, - ISeriesService seriesService, + IArtistService artistService, IDiskProvider diskProvider, IRecycleBinProvider recycleBinProvider, Logger logger) { _repository = repository; - _seriesService = seriesService; + _artistService = artistService; _diskProvider = diskProvider; _recycleBinProvider = recycleBinProvider; _logger = logger; } - public virtual bool PermanentlyDelete => false; - - public List GetFilesBySeries(int seriesId) + public List GetFilesByArtist(int artistId) { - return _repository.GetFilesBySeries(seriesId); + return _repository.GetFilesByArtist(artistId); } - public List GetFilesByEpisodeFile(int episodeFileId) + public List GetFilesByTrackFile(int trackFileId) { - return _repository.GetFilesByEpisodeFile(episodeFileId); + return _repository.GetFilesByTrackFile(trackFileId); } public TExtraFile FindByPath(string path) @@ -96,47 +95,40 @@ namespace NzbDrone.Core.Extras.Files _repository.DeleteMany(ids); } - public void HandleAsync(SeriesDeletedEvent message) + public void HandleAsync(ArtistDeletedEvent message) { - _logger.Debug("Deleting Extra from database for series: {0}", message.Series); - _repository.DeleteForSeries(message.Series.Id); + _logger.Debug("Deleting Extra from database for artist: {0}", message.Artist); + _repository.DeleteForArtist(message.Artist.Id); } - public void HandleAsync(EpisodeFileDeletedEvent message) + public void Handle(TrackFileDeletedEvent message) { - var episodeFile = message.EpisodeFile; + var trackFile = message.TrackFile; if (message.Reason == DeleteMediaFileReason.NoLinkedEpisodes) { - _logger.Debug("Removing episode file from DB as part of cleanup routine, not deleting extra files from disk."); + _logger.Debug("Removing track file from DB as part of cleanup routine, not deleting extra files from disk."); } else { - var series = _seriesService.GetSeries(message.EpisodeFile.SeriesId); + var artist = trackFile.Artist.Value; - foreach (var extra in _repository.GetFilesByEpisodeFile(episodeFile.Id)) + foreach (var extra in _repository.GetFilesByTrackFile(trackFile.Id)) { - var path = Path.Combine(series.Path, extra.RelativePath); + var path = Path.Combine(artist.Path, extra.RelativePath); if (_diskProvider.FileExists(path)) { - if (PermanentlyDelete) - { - _diskProvider.DeleteFile(path); - } - - else - { - // Send extra files to the recycling bin so they can be recovered if necessary - _recycleBinProvider.DeleteFile(path); - } + // Send to the recycling bin so they can be recovered if necessary + var subfolder = _diskProvider.GetParentFolder(artist.Path).GetRelativePath(_diskProvider.GetParentFolder(path)); + _recycleBinProvider.DeleteFile(path, subfolder); } } } - _logger.Debug("Deleting Extra from database for episode file: {0}", episodeFile); - _repository.DeleteForEpisodeFile(episodeFile.Id); + _logger.Debug("Deleting Extra from database for track file: {0}", trackFile); + _repository.DeleteForTrackFile(trackFile.Id); } } } diff --git a/src/NzbDrone.Core/Extras/IImportExistingExtraFiles.cs b/src/NzbDrone.Core/Extras/IImportExistingExtraFiles.cs index ad14b60a5..cb5a7dcff 100644 --- a/src/NzbDrone.Core/Extras/IImportExistingExtraFiles.cs +++ b/src/NzbDrone.Core/Extras/IImportExistingExtraFiles.cs @@ -1,12 +1,12 @@ -using System.Collections.Generic; +using System.Collections.Generic; using NzbDrone.Core.Extras.Files; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Extras { public interface IImportExistingExtraFiles { int Order { get; } - IEnumerable ProcessFiles(Series series, List filesOnDisk, List importedFiles); + IEnumerable ProcessFiles(Artist artist, List filesOnDisk, List importedFiles); } } diff --git a/src/NzbDrone.Core/Extras/ImportExistingExtraFilesBase.cs b/src/NzbDrone.Core/Extras/ImportExistingExtraFilesBase.cs index a2dddaa69..c232259c4 100644 --- a/src/NzbDrone.Core/Extras/ImportExistingExtraFilesBase.cs +++ b/src/NzbDrone.Core/Extras/ImportExistingExtraFilesBase.cs @@ -1,10 +1,10 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using System.Linq; using NzbDrone.Common; using NzbDrone.Common.Extensions; using NzbDrone.Core.Extras.Files; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Extras { @@ -19,21 +19,21 @@ namespace NzbDrone.Core.Extras } public abstract int Order { get; } - public abstract IEnumerable ProcessFiles(Series series, List filesOnDisk, List importedFiles); + public abstract IEnumerable ProcessFiles(Artist artist, List filesOnDisk, List importedFiles); - public virtual ImportExistingExtraFileFilterResult FilterAndClean(Series series, List filesOnDisk, List importedFiles) + public virtual ImportExistingExtraFileFilterResult FilterAndClean(Artist artist, List filesOnDisk, List importedFiles) { - var seriesFiles = _extraFileService.GetFilesBySeries(series.Id); + var artistFiles = _extraFileService.GetFilesByArtist(artist.Id); - Clean(series, filesOnDisk, importedFiles, seriesFiles); + Clean(artist, filesOnDisk, importedFiles, artistFiles); - return Filter(series, filesOnDisk, importedFiles, seriesFiles); + return Filter(artist, filesOnDisk, importedFiles, artistFiles); } - private ImportExistingExtraFileFilterResult Filter(Series series, List filesOnDisk, List importedFiles, List seriesFiles) + private ImportExistingExtraFileFilterResult Filter(Artist artist, List filesOnDisk, List importedFiles, List artistFiles) { - var previouslyImported = seriesFiles.IntersectBy(s => Path.Combine(series.Path, s.RelativePath), filesOnDisk, f => f, PathEqualityComparer.Instance).ToList(); - var filteredFiles = filesOnDisk.Except(previouslyImported.Select(f => Path.Combine(series.Path, f.RelativePath)).ToList(), PathEqualityComparer.Instance) + var previouslyImported = artistFiles.IntersectBy(s => Path.Combine(artist.Path, s.RelativePath), filesOnDisk, f => f, PathEqualityComparer.Instance).ToList(); + var filteredFiles = filesOnDisk.Except(previouslyImported.Select(f => Path.Combine(artist.Path, f.RelativePath)).ToList(), PathEqualityComparer.Instance) .Except(importedFiles, PathEqualityComparer.Instance) .ToList(); @@ -42,12 +42,12 @@ namespace NzbDrone.Core.Extras return new ImportExistingExtraFileFilterResult(previouslyImported, filteredFiles); } - private void Clean(Series series, List filesOnDisk, List importedFiles, List seriesFiles) + private void Clean(Artist artist, List filesOnDisk, List importedFiles, List artistFiles) { - var alreadyImportedFileIds = seriesFiles.IntersectBy(f => Path.Combine(series.Path, f.RelativePath), importedFiles, i => i, PathEqualityComparer.Instance) + var alreadyImportedFileIds = artistFiles.IntersectBy(f => Path.Combine(artist.Path, f.RelativePath), importedFiles, i => i, PathEqualityComparer.Instance) .Select(f => f.Id); - var deletedFiles = seriesFiles.ExceptBy(f => Path.Combine(series.Path, f.RelativePath), filesOnDisk, i => i, PathEqualityComparer.Instance) + var deletedFiles = artistFiles.ExceptBy(f => Path.Combine(artist.Path, f.RelativePath), filesOnDisk, i => i, PathEqualityComparer.Instance) .Select(f => f.Id); _extraFileService.DeleteMany(alreadyImportedFileIds); diff --git a/src/NzbDrone.Core/Extras/Lyrics/ExistingLyricImporter.cs b/src/NzbDrone.Core/Extras/Lyrics/ExistingLyricImporter.cs new file mode 100644 index 000000000..86a55f1c3 --- /dev/null +++ b/src/NzbDrone.Core/Extras/Lyrics/ExistingLyricImporter.cs @@ -0,0 +1,96 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Extras.Files; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Music; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.MediaFiles.TrackImport.Aggregation; + +namespace NzbDrone.Core.Extras.Lyrics +{ + public class ExistingLyricImporter : ImportExistingExtraFilesBase + { + private readonly IExtraFileService _lyricFileService; + private readonly IAugmentingService _augmentingService; + private readonly Logger _logger; + + public ExistingLyricImporter(IExtraFileService lyricFileService, + IAugmentingService augmentingService, + Logger logger) + : base (lyricFileService) + { + _lyricFileService = lyricFileService; + _augmentingService = augmentingService; + _logger = logger; + } + + public override int Order => 1; + + public override IEnumerable ProcessFiles(Artist artist, List filesOnDisk, List importedFiles) + { + _logger.Debug("Looking for existing lyrics files in {0}", artist.Path); + + var subtitleFiles = new List(); + var filterResult = FilterAndClean(artist, filesOnDisk, importedFiles); + + foreach (var possibleLyricFile in filterResult.FilesOnDisk) + { + var extension = Path.GetExtension(possibleLyricFile); + + if (LyricFileExtensions.Extensions.Contains(extension)) + { + var localTrack = new LocalTrack + { + FileTrackInfo = Parser.Parser.ParseMusicPath(possibleLyricFile), + Artist = artist, + Path = possibleLyricFile + }; + + try + { + _augmentingService.Augment(localTrack, false); + } + catch (AugmentingFailedException) + { + _logger.Debug("Unable to parse lyric file: {0}", possibleLyricFile); + continue; + } + + if (localTrack.Tracks.Empty()) + { + _logger.Debug("Cannot find related tracks for: {0}", possibleLyricFile); + continue; + } + + if (localTrack.Tracks.DistinctBy(e => e.TrackFileId).Count() > 1) + { + _logger.Debug("Lyric file: {0} does not match existing files.", possibleLyricFile); + continue; + } + + var subtitleFile = new LyricFile + { + ArtistId = artist.Id, + AlbumId = localTrack.Album.Id, + TrackFileId = localTrack.Tracks.First().TrackFileId, + RelativePath = artist.Path.GetRelativePath(possibleLyricFile), + Extension = extension + }; + + subtitleFiles.Add(subtitleFile); + } + } + + _logger.Info("Found {0} existing lyric files", subtitleFiles.Count); + _lyricFileService.Upsert(subtitleFiles); + + // Return files that were just imported along with files that were + // previously imported so previously imported files aren't imported twice + + return subtitleFiles.Concat(filterResult.PreviouslyImported); + } + } +} diff --git a/src/NzbDrone.Core/Extras/Lyrics/ImportedLyricFiles.cs b/src/NzbDrone.Core/Extras/Lyrics/ImportedLyricFiles.cs new file mode 100644 index 000000000..abbaa4c74 --- /dev/null +++ b/src/NzbDrone.Core/Extras/Lyrics/ImportedLyricFiles.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using NzbDrone.Core.Extras.Files; + +namespace NzbDrone.Core.Extras.Lyrics +{ + public class ImportedLyricFiles + { + public List SourceFiles { get; set; } + public List LyricFiles { get; set; } + + public ImportedLyricFiles() + { + SourceFiles = new List(); + LyricFiles = new List(); + } + } +} diff --git a/src/NzbDrone.Core/Extras/Lyrics/LyricFile.cs b/src/NzbDrone.Core/Extras/Lyrics/LyricFile.cs new file mode 100644 index 000000000..8634b167d --- /dev/null +++ b/src/NzbDrone.Core/Extras/Lyrics/LyricFile.cs @@ -0,0 +1,8 @@ +using NzbDrone.Core.Extras.Files; + +namespace NzbDrone.Core.Extras.Lyrics +{ + public class LyricFile : ExtraFile + { + } +} diff --git a/src/NzbDrone.Core/Extras/Lyrics/LyricFileExtensions.cs b/src/NzbDrone.Core/Extras/Lyrics/LyricFileExtensions.cs new file mode 100644 index 000000000..1d47fc0b1 --- /dev/null +++ b/src/NzbDrone.Core/Extras/Lyrics/LyricFileExtensions.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; + +namespace NzbDrone.Core.Extras.Lyrics +{ + public static class LyricFileExtensions + { + private static HashSet _fileExtensions; + + static LyricFileExtensions() + { + _fileExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) + { + ".lrc", + ".txt", + ".utf", + ".utf8", + ".utf-8" + }; + } + + public static HashSet Extensions => _fileExtensions; + } +} diff --git a/src/NzbDrone.Core/Extras/Lyrics/LyricFileRepository.cs b/src/NzbDrone.Core/Extras/Lyrics/LyricFileRepository.cs new file mode 100644 index 000000000..60fe50f45 --- /dev/null +++ b/src/NzbDrone.Core/Extras/Lyrics/LyricFileRepository.cs @@ -0,0 +1,18 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Extras.Files; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.Extras.Lyrics +{ + public interface ILyricFileRepository : IExtraFileRepository + { + } + + public class LyricFileRepository : ExtraFileRepository, ILyricFileRepository + { + public LyricFileRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + } +} diff --git a/src/NzbDrone.Core/Extras/Lyrics/LyricFileService.cs b/src/NzbDrone.Core/Extras/Lyrics/LyricFileService.cs new file mode 100644 index 000000000..4d2935ce5 --- /dev/null +++ b/src/NzbDrone.Core/Extras/Lyrics/LyricFileService.cs @@ -0,0 +1,20 @@ +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Core.Extras.Files; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Extras.Lyrics +{ + public interface ILyricFileService : IExtraFileService + { + } + + public class LyricFileService : ExtraFileService, ILyricFileService + { + public LyricFileService(IExtraFileRepository repository, IArtistService artistService, IDiskProvider diskProvider, IRecycleBinProvider recycleBinProvider, Logger logger) + : base(repository, artistService, diskProvider, recycleBinProvider, logger) + { + } + } +} diff --git a/src/NzbDrone.Core/Extras/Lyrics/LyricService.cs b/src/NzbDrone.Core/Extras/Lyrics/LyricService.cs new file mode 100644 index 000000000..7972cf20a --- /dev/null +++ b/src/NzbDrone.Core/Extras/Lyrics/LyricService.cs @@ -0,0 +1,113 @@ +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.Configuration; +using NzbDrone.Core.Extras.Files; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Extras.Lyrics +{ + public class LyricService : ExtraFileManager + { + private readonly ILyricFileService _lyricFileService; + private readonly Logger _logger; + + public LyricService(IConfigService configService, + IDiskProvider diskProvider, + IDiskTransferService diskTransferService, + ILyricFileService lyricFileService, + Logger logger) + : base(configService, diskProvider, diskTransferService, logger) + { + _lyricFileService = lyricFileService; + _logger = logger; + } + + public override int Order => 1; + + public override IEnumerable CreateAfterArtistScan(Artist artist, List trackFiles) + { + return Enumerable.Empty(); + } + + public override IEnumerable CreateAfterTrackImport(Artist artist, TrackFile trackFile) + { + return Enumerable.Empty(); + } + + public override IEnumerable CreateAfterTrackImport(Artist artist, Album album, string artistFolder, string albumFolder) + { + return Enumerable.Empty(); + } + + public override IEnumerable MoveFilesAfterRename(Artist artist, List trackFiles) + { + var subtitleFiles = _lyricFileService.GetFilesByArtist(artist.Id); + + var movedFiles = new List(); + + foreach (var trackFile in trackFiles) + { + var groupedExtraFilesForTrackFile = subtitleFiles.Where(m => m.TrackFileId == trackFile.Id) + .GroupBy(s => s.Extension).ToList(); + + foreach (var group in groupedExtraFilesForTrackFile) + { + var groupCount = group.Count(); + var copy = 1; + + if (groupCount > 1) + { + _logger.Warn("Multiple lyric files found with the same extension for {0}", trackFile.Path); + } + + foreach (var subtitleFile in group) + { + var suffix = GetSuffix(copy, groupCount > 1); + movedFiles.AddIfNotNull(MoveFile(artist, trackFile, subtitleFile, suffix)); + + copy++; + } + } + } + + _lyricFileService.Upsert(movedFiles); + + return movedFiles; + } + + public override ExtraFile Import(Artist artist, TrackFile trackFile, string path, string extension, bool readOnly) + { + if (LyricFileExtensions.Extensions.Contains(Path.GetExtension(path))) + { + var suffix = GetSuffix(1, false); + var subtitleFile = ImportFile(artist, trackFile, path, readOnly, extension, suffix); + + _lyricFileService.Upsert(subtitleFile); + + return subtitleFile; + } + + return null; + } + + private string GetSuffix(int copy, bool multipleCopies = false) + { + var suffixBuilder = new StringBuilder(); + + if (multipleCopies) + { + suffixBuilder.Append("."); + suffixBuilder.Append(copy); + } + + return suffixBuilder.ToString(); + } + } +} diff --git a/src/NzbDrone.Core/Extras/Metadata/Consumers/MediaBrowser/MediaBrowserMetadata.cs b/src/NzbDrone.Core/Extras/Metadata/Consumers/MediaBrowser/MediaBrowserMetadata.cs deleted file mode 100644 index d2ea82bae..000000000 --- a/src/NzbDrone.Core/Extras/Metadata/Consumers/MediaBrowser/MediaBrowserMetadata.cs +++ /dev/null @@ -1,158 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Xml; -using System.Xml.Linq; -using NLog; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Extras.Metadata.Files; -using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Extras.Metadata.Consumers.MediaBrowser -{ - public class MediaBrowserMetadata : MetadataBase - { - private readonly Logger _logger; - - public MediaBrowserMetadata( - Logger logger) - { - _logger = logger; - } - - public override string Name => "Emby (Legacy)"; - - public override MetadataFile FindMetadataFile(Series series, string path) - { - var filename = Path.GetFileName(path); - - if (filename == null) return null; - - var metadata = new MetadataFile - { - SeriesId = series.Id, - Consumer = GetType().Name, - RelativePath = series.Path.GetRelativePath(path) - }; - - if (filename.Equals("series.xml", StringComparison.InvariantCultureIgnoreCase)) - { - metadata.Type = MetadataType.SeriesMetadata; - return metadata; - } - - return null; - } - - public override MetadataFileResult SeriesMetadata(Series series) - { - if (!Settings.SeriesMetadata) - { - return null; - } - - _logger.Debug("Generating series.xml for: {0}", series.Title); - var sb = new StringBuilder(); - var xws = new XmlWriterSettings(); - xws.OmitXmlDeclaration = true; - xws.Indent = false; - - using (var xw = XmlWriter.Create(sb, xws)) - { - var tvShow = new XElement("Series"); - - tvShow.Add(new XElement("id", series.TvdbId)); - tvShow.Add(new XElement("Status", series.Status)); - tvShow.Add(new XElement("Network", series.Network)); - tvShow.Add(new XElement("Airs_Time", series.AirTime)); - - if (series.FirstAired.HasValue) - { - tvShow.Add(new XElement("FirstAired", series.FirstAired.Value.ToString("yyyy-MM-dd"))); - } - - tvShow.Add(new XElement("ContentRating", series.Certification)); - tvShow.Add(new XElement("Added", series.Added.ToString("MM/dd/yyyy HH:mm:ss tt"))); - tvShow.Add(new XElement("LockData", "false")); - tvShow.Add(new XElement("Overview", series.Overview)); - tvShow.Add(new XElement("LocalTitle", series.Title)); - - if (series.FirstAired.HasValue) - { - tvShow.Add(new XElement("PremiereDate", series.FirstAired.Value.ToString("yyyy-MM-dd"))); - } - - tvShow.Add(new XElement("Rating", series.Ratings.Value)); - tvShow.Add(new XElement("ProductionYear", series.Year)); - tvShow.Add(new XElement("RunningTime", series.Runtime)); - tvShow.Add(new XElement("IMDB", series.ImdbId)); - tvShow.Add(new XElement("TVRageId", series.TvRageId)); - tvShow.Add(new XElement("Genres", series.Genres.Select(genre => new XElement("Genre", genre)))); - - var persons = new XElement("Persons"); - - foreach (var person in series.Actors) - { - persons.Add(new XElement("Person", - new XElement("Name", person.Name), - new XElement("Type", "Actor"), - new XElement("Role", person.Character) - )); - } - - tvShow.Add(persons); - - - var doc = new XDocument(tvShow); - doc.Save(xw); - - _logger.Debug("Saving series.xml for {0}", series.Title); - - return new MetadataFileResult("series.xml", doc.ToString()); - } - } - - public override MetadataFileResult EpisodeMetadata(Series series, EpisodeFile episodeFile) - { - return null; - } - - public override List SeriesImages(Series series) - { - return new List(); - } - - public override List SeasonImages(Series series, Season season) - { - return new List(); - } - - public override List EpisodeImages(Series series, EpisodeFile episodeFile) - { - return new List(); - } - - private IEnumerable ProcessSeriesImages(Series series) - { - return new List(); - } - - private IEnumerable ProcessSeasonImages(Series series, Season season) - { - return new List(); - } - - private string GetEpisodeNfoFilename(string episodeFilePath) - { - return null; - } - - private string GetEpisodeImageFilename(string episodeFilePath) - { - return null; - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/Extras/Metadata/Consumers/MediaBrowser/MediaBrowserMetadataSettings.cs b/src/NzbDrone.Core/Extras/Metadata/Consumers/MediaBrowser/MediaBrowserMetadataSettings.cs deleted file mode 100644 index 11899124f..000000000 --- a/src/NzbDrone.Core/Extras/Metadata/Consumers/MediaBrowser/MediaBrowserMetadataSettings.cs +++ /dev/null @@ -1,34 +0,0 @@ -using FluentValidation; -using NzbDrone.Core.Annotations; -using NzbDrone.Core.ThingiProvider; -using NzbDrone.Core.Validation; - -namespace NzbDrone.Core.Extras.Metadata.Consumers.MediaBrowser -{ - public class MediaBrowserSettingsValidator : AbstractValidator - { - public MediaBrowserSettingsValidator() - { - } - } - - public class MediaBrowserMetadataSettings : IProviderConfig - { - private static readonly MediaBrowserSettingsValidator Validator = new MediaBrowserSettingsValidator(); - - public MediaBrowserMetadataSettings() - { - SeriesMetadata = true; - } - - [FieldDefinition(0, Label = "Series Metadata", Type = FieldType.Checkbox)] - public bool SeriesMetadata { get; set; } - - public bool IsValid => true; - - public NzbDroneValidationResult Validate() - { - return new NzbDroneValidationResult(Validator.Validate(this)); - } - } -} diff --git a/src/NzbDrone.Core/Extras/Metadata/Consumers/Roksbox/RoksboxMetadata.cs b/src/NzbDrone.Core/Extras/Metadata/Consumers/Roksbox/RoksboxMetadata.cs index cf5d5e61d..11618a6af 100644 --- a/src/NzbDrone.Core/Extras/Metadata/Consumers/Roksbox/RoksboxMetadata.cs +++ b/src/NzbDrone.Core/Extras/Metadata/Consumers/Roksbox/RoksboxMetadata.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.Common.Extensions; using NzbDrone.Core.Extras.Metadata.Files; using NzbDrone.Core.MediaCover; using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Extras.Metadata.Consumers.Roksbox { @@ -31,30 +31,24 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Roksbox _logger = logger; } - private static List ValidCertification = new List { "G", "NC-17", "PG", "PG-13", "R", "UR", "UNRATED", "NR", "TV-Y", "TV-Y7", "TV-Y7-FV", "TV-G", "TV-PG", "TV-14", "TV-MA" }; private static readonly Regex SeasonImagesRegex = new Regex(@"^(season (?\d+))|(?specials)", RegexOptions.Compiled | RegexOptions.IgnoreCase); public override string Name => "Roksbox"; - public override string GetFilenameAfterMove(Series series, EpisodeFile episodeFile, MetadataFile metadataFile) + public override string GetFilenameAfterMove(Artist artist, TrackFile trackFile, MetadataFile metadataFile) { - var episodeFilePath = Path.Combine(series.Path, episodeFile.RelativePath); + var trackFilePath = trackFile.Path; - if (metadataFile.Type == MetadataType.EpisodeImage) + if (metadataFile.Type == MetadataType.TrackMetadata) { - return GetEpisodeImageFilename(episodeFilePath); + return GetTrackMetadataFilename(trackFilePath); } - if (metadataFile.Type == MetadataType.EpisodeMetadata) - { - return GetEpisodeMetadataFilename(episodeFilePath); - } - - _logger.Debug("Unknown episode file metadata: {0}", metadataFile.RelativePath); - return Path.Combine(series.Path, metadataFile.RelativePath); + _logger.Debug("Unknown track file metadata: {0}", metadataFile.RelativePath); + return Path.Combine(artist.Path, metadataFile.RelativePath); } - public override MetadataFile FindMetadataFile(Series series, string path) + public override MetadataFile FindMetadataFile(Artist artist, string path) { var filename = Path.GetFileName(path); @@ -63,9 +57,9 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Roksbox var metadata = new MetadataFile { - SeriesId = series.Id, + ArtistId = artist.Id, Consumer = GetType().Name, - RelativePath = series.Path.GetRelativePath(path) + RelativePath = artist.Path.GetRelativePath(path) }; //Series and season images are both named folder.jpg, only season ones sit in season folders @@ -75,68 +69,63 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Roksbox if (seasonMatch.Success) { - metadata.Type = MetadataType.SeasonImage; + metadata.Type = MetadataType.AlbumImage; if (seasonMatch.Groups["specials"].Success) { - metadata.SeasonNumber = 0; + metadata.AlbumId = 0; } else { - metadata.SeasonNumber = Convert.ToInt32(seasonMatch.Groups["season"].Value); + metadata.AlbumId = Convert.ToInt32(seasonMatch.Groups["season"].Value); } return metadata; } - metadata.Type = MetadataType.SeriesImage; + metadata.Type = MetadataType.ArtistImage; return metadata; } - var parseResult = Parser.Parser.ParseTitle(filename); + var parseResult = Parser.Parser.ParseMusicTitle(filename); - if (parseResult != null && - !parseResult.FullSeason) + if (parseResult != null) { var extension = Path.GetExtension(filename).ToLowerInvariant(); if (extension == ".xml") { - metadata.Type = MetadataType.EpisodeMetadata; + metadata.Type = MetadataType.TrackMetadata; return metadata; - } - - if (extension == ".jpg") - { - if (!Path.GetFileNameWithoutExtension(filename).EndsWith("-thumb")) - { - metadata.Type = MetadataType.EpisodeImage; - return metadata; - } - } + } } return null; } - public override MetadataFileResult SeriesMetadata(Series series) + public override MetadataFileResult ArtistMetadata(Artist artist) { - //Series metadata is not supported + //Artist metadata is not supported return null; } - public override MetadataFileResult EpisodeMetadata(Series series, EpisodeFile episodeFile) + public override MetadataFileResult AlbumMetadata(Artist artist, Album album, string albumPath) { - if (!Settings.EpisodeMetadata) + return null; + } + + public override MetadataFileResult TrackMetadata(Artist artist, TrackFile trackFile) + { + if (!Settings.TrackMetadata) { return null; } - _logger.Debug("Generating Episode Metadata for: {0}", episodeFile.RelativePath); + _logger.Debug("Generating Track Metadata for: {0}", trackFile.Path); var xmlResult = string.Empty; - foreach (var episode in episodeFile.Episodes.Value) + foreach (var track in trackFile.Tracks.Value) { var sb = new StringBuilder(); var xws = new XmlWriterSettings(); @@ -147,25 +136,9 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Roksbox { var doc = new XDocument(); - var details = new XElement("video"); - details.Add(new XElement("title", string.Format("{0} - {1}x{2} - {3}", series.Title, episode.SeasonNumber, episode.EpisodeNumber, episode.Title))); - details.Add(new XElement("year", episode.AirDate)); - details.Add(new XElement("genre", string.Join(" / ", series.Genres))); - var actors = string.Join(" , ", series.Actors.ConvertAll(c => c.Name + " - " + c.Character).GetRange(0, Math.Min(3, series.Actors.Count))); - details.Add(new XElement("actors", actors)); - details.Add(new XElement("description", episode.Overview)); - details.Add(new XElement("length", series.Runtime)); - - if (series.Certification.IsNotNullOrWhiteSpace() && - ValidCertification.Contains(series.Certification.ToUpperInvariant())) - { - details.Add(new XElement("mpaa", series.Certification.ToUpperInvariant())); - } - - else - { - details.Add(new XElement("mpaa", "UNRATED")); - } + var details = new XElement("song"); + details.Add(new XElement("title", track.Title)); + details.Add(new XElement("performingartist", artist.Name)); doc.Add(details); doc.Save(xw); @@ -175,109 +148,42 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Roksbox } } - return new MetadataFileResult(GetEpisodeMetadataFilename(episodeFile.RelativePath), xmlResult.Trim(Environment.NewLine.ToCharArray())); + return new MetadataFileResult(GetTrackMetadataFilename(artist.Path.GetRelativePath(trackFile.Path)), xmlResult.Trim(Environment.NewLine.ToCharArray())); } - public override List SeriesImages(Series series) + public override List ArtistImages(Artist artist) { - var image = series.Images.SingleOrDefault(c => c.CoverType == MediaCoverTypes.Poster) ?? series.Images.FirstOrDefault(); - if (image == null) + if (!Settings.ArtistImages) { - _logger.Trace("Failed to find suitable Series image for series {0}.", series.Title); - return null; - } - - var source = _mediaCoverService.GetCoverPath(series.Id, image.CoverType); - var destination = Path.GetFileName(series.Path) + Path.GetExtension(source); - - return new List{ new ImageFileResult(destination, source) }; - } - - public override List SeasonImages(Series series, Season season) - { - var seasonFolders = GetSeasonFolders(series); - - string seasonFolder; - if (!seasonFolders.TryGetValue(season.SeasonNumber, out seasonFolder)) - { - _logger.Trace("Failed to find season folder for series {0}, season {1}.", series.Title, season.SeasonNumber); return new List(); } - //Roksbox only supports one season image, so first of all try for poster otherwise just use whatever is first in the collection - var image = season.Images.SingleOrDefault(c => c.CoverType == MediaCoverTypes.Poster) ?? season.Images.FirstOrDefault(); + var image = artist.Metadata.Value.Images.SingleOrDefault(c => c.CoverType == MediaCoverTypes.Poster) ?? artist.Metadata.Value.Images.FirstOrDefault(); if (image == null) { - _logger.Trace("Failed to find suitable season image for series {0}, season {1}.", series.Title, season.SeasonNumber); - return new List(); + _logger.Trace("Failed to find suitable Artist image for artist {0}.", artist.Name); + return new List(); ; } - var filename = Path.GetFileName(seasonFolder) + ".jpg"; - var path = series.Path.GetRelativePath(Path.Combine(series.Path, seasonFolder, filename)); - - return new List { new ImageFileResult(path, image.Url) }; - } - - public override List EpisodeImages(Series series, EpisodeFile episodeFile) - { - var screenshot = episodeFile.Episodes.Value.First().Images.SingleOrDefault(i => i.CoverType == MediaCoverTypes.Screenshot); - - if (screenshot == null) - { - _logger.Trace("Episode screenshot not available"); - return new List(); - } + var source = _mediaCoverService.GetCoverPath(artist.Id, MediaCoverEntity.Artist, image.CoverType, image.Extension); + var destination = Path.GetFileName(artist.Path) + Path.GetExtension(source); - return new List {new ImageFileResult(GetEpisodeImageFilename(episodeFile.RelativePath), screenshot.Url)}; + return new List{ new ImageFileResult(destination, source) }; } - private string GetEpisodeMetadataFilename(string episodeFilePath) + public override List AlbumImages(Artist artist, Album album, string albumFolder) { - return Path.ChangeExtension(episodeFilePath, "xml"); + return new List(); } - private string GetEpisodeImageFilename(string episodeFilePath) + public override List TrackImages(Artist artist, TrackFile trackFile) { - return Path.ChangeExtension(episodeFilePath, "jpg"); + return new List(); } - private Dictionary GetSeasonFolders(Series series) + private string GetTrackMetadataFilename(string trackFilePath) { - var seasonFolderMap = new Dictionary(); - - foreach (var folder in _diskProvider.GetDirectories(series.Path)) - { - var directoryinfo = new DirectoryInfo(folder); - var seasonMatch = SeasonImagesRegex.Match(directoryinfo.Name); - - if (seasonMatch.Success) - { - var seasonNumber = seasonMatch.Groups["season"].Value; - - if (seasonNumber.Contains("specials")) - { - seasonFolderMap[0] = folder; - } - else - { - int matchedSeason; - if (int.TryParse(seasonNumber, out matchedSeason)) - { - seasonFolderMap[matchedSeason] = folder; - } - else - { - _logger.Debug("Failed to parse season number from {0} for series {1}.", folder, series.Title); - } - } - } - else - { - _logger.Debug("Rejecting folder {0} for series {1}.", Path.GetDirectoryName(folder), series.Title); - } - } - - return seasonFolderMap; + return Path.ChangeExtension(trackFilePath, "xml"); } } } diff --git a/src/NzbDrone.Core/Extras/Metadata/Consumers/Roksbox/RoksboxMetadataSettings.cs b/src/NzbDrone.Core/Extras/Metadata/Consumers/Roksbox/RoksboxMetadataSettings.cs index f0da481bf..f9cb73281 100644 --- a/src/NzbDrone.Core/Extras/Metadata/Consumers/Roksbox/RoksboxMetadataSettings.cs +++ b/src/NzbDrone.Core/Extras/Metadata/Consumers/Roksbox/RoksboxMetadataSettings.cs @@ -1,4 +1,4 @@ -using FluentValidation; +using FluentValidation; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; @@ -7,9 +7,6 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Roksbox { public class RoksboxSettingsValidator : AbstractValidator { - public RoksboxSettingsValidator() - { - } } public class RoksboxMetadataSettings : IProviderConfig @@ -18,23 +15,19 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Roksbox public RoksboxMetadataSettings() { - EpisodeMetadata = true; - SeriesImages = true; - SeasonImages = true; - EpisodeImages = true; + TrackMetadata = true; + ArtistImages = true; + AlbumImages = true; } - [FieldDefinition(0, Label = "Episode Metadata", Type = FieldType.Checkbox)] - public bool EpisodeMetadata { get; set; } - - [FieldDefinition(1, Label = "Series Images", Type = FieldType.Checkbox)] - public bool SeriesImages { get; set; } + [FieldDefinition(0, Label = "Track Metadata", Type = FieldType.Checkbox, Section = MetadataSectionType.Metadata, HelpText = "Album\\filename.xml")] + public bool TrackMetadata { get; set; } - [FieldDefinition(2, Label = "Season Images", Type = FieldType.Checkbox)] - public bool SeasonImages { get; set; } + [FieldDefinition(1, Label = "Artist Images", Type = FieldType.Checkbox, Section = MetadataSectionType.Image, HelpText = "Artist Title.jpg")] + public bool ArtistImages { get; set; } - [FieldDefinition(3, Label = "Episode Images", Type = FieldType.Checkbox)] - public bool EpisodeImages { get; set; } + [FieldDefinition(2, Label = "Album Images", Type = FieldType.Checkbox, Section = MetadataSectionType.Image, HelpText = "Album Title.jpg")] + public bool AlbumImages { get; set; } public bool IsValid => true; diff --git a/src/NzbDrone.Core/Extras/Metadata/Consumers/Wdtv/WdtvMetadata.cs b/src/NzbDrone.Core/Extras/Metadata/Consumers/Wdtv/WdtvMetadata.cs index d1846c963..7ce21dbda 100644 --- a/src/NzbDrone.Core/Extras/Metadata/Consumers/Wdtv/WdtvMetadata.cs +++ b/src/NzbDrone.Core/Extras/Metadata/Consumers/Wdtv/WdtvMetadata.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.Common.Extensions; using NzbDrone.Core.Extras.Metadata.Files; using NzbDrone.Core.MediaCover; using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Extras.Metadata.Consumers.Wdtv { @@ -31,30 +31,23 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Wdtv _logger = logger; } - private static readonly Regex SeasonImagesRegex = new Regex(@"^(season (?\d+))|(?specials)", RegexOptions.Compiled | RegexOptions.IgnoreCase); - public override string Name => "WDTV"; - public override string GetFilenameAfterMove(Series series, EpisodeFile episodeFile, MetadataFile metadataFile) + public override string GetFilenameAfterMove(Artist artist, TrackFile trackFile, MetadataFile metadataFile) { - var episodeFilePath = Path.Combine(series.Path, episodeFile.RelativePath); - - if (metadataFile.Type == MetadataType.EpisodeImage) - { - return GetEpisodeImageFilename(episodeFilePath); - } + var trackFilePath = trackFile.Path; - if (metadataFile.Type == MetadataType.EpisodeMetadata) + if (metadataFile.Type == MetadataType.TrackMetadata) { - return GetEpisodeMetadataFilename(episodeFilePath); + return GetTrackMetadataFilename(trackFilePath); } - _logger.Debug("Unknown episode file metadata: {0}", metadataFile.RelativePath); - return Path.Combine(series.Path, metadataFile.RelativePath); + _logger.Debug("Unknown track file metadata: {0}", metadataFile.RelativePath); + return Path.Combine(artist.Path, metadataFile.RelativePath); } - public override MetadataFile FindMetadataFile(Series series, string path) + public override MetadataFile FindMetadataFile(Artist artist, string path) { var filename = Path.GetFileName(path); @@ -62,49 +55,19 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Wdtv var metadata = new MetadataFile { - SeriesId = series.Id, + ArtistId = artist.Id, Consumer = GetType().Name, - RelativePath = series.Path.GetRelativePath(path) + RelativePath = artist.Path.GetRelativePath(path) }; - //Series and season images are both named folder.jpg, only season ones sit in season folders - if (Path.GetFileName(filename).Equals("folder.jpg", StringComparison.InvariantCultureIgnoreCase)) - { - var parentdir = Directory.GetParent(path); - var seasonMatch = SeasonImagesRegex.Match(parentdir.Name); - if (seasonMatch.Success) - { - metadata.Type = MetadataType.SeasonImage; - - if (seasonMatch.Groups["specials"].Success) - { - metadata.SeasonNumber = 0; - } - - else - { - metadata.SeasonNumber = Convert.ToInt32(seasonMatch.Groups["season"].Value); - } + var parseResult = Parser.Parser.ParseMusicTitle(filename); - return metadata; - } - - metadata.Type = MetadataType.SeriesImage; - return metadata; - } - - var parseResult = Parser.Parser.ParseTitle(filename); - - if (parseResult != null && - !parseResult.FullSeason) + if (parseResult != null) { switch (Path.GetExtension(filename).ToLowerInvariant()) { case ".xml": - metadata.Type = MetadataType.EpisodeMetadata; - return metadata; - case ".metathumb": - metadata.Type = MetadataType.EpisodeImage; + metadata.Type = MetadataType.TrackMetadata; return metadata; } @@ -113,23 +76,28 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Wdtv return null; } - public override MetadataFileResult SeriesMetadata(Series series) + public override MetadataFileResult ArtistMetadata(Artist artist) + { + //Artist metadata is not supported + return null; + } + + public override MetadataFileResult AlbumMetadata(Artist artist, Album album, string albumPath) { - //Series metadata is not supported return null; } - public override MetadataFileResult EpisodeMetadata(Series series, EpisodeFile episodeFile) + public override MetadataFileResult TrackMetadata(Artist artist, TrackFile trackFile) { - if (!Settings.EpisodeMetadata) + if (!Settings.TrackMetadata) { return null; } - _logger.Debug("Generating Episode Metadata for: {0}", Path.Combine(series.Path, episodeFile.RelativePath)); + _logger.Debug("Generating Track Metadata for: {0}", trackFile.Path); var xmlResult = string.Empty; - foreach (var episode in episodeFile.Episodes.Value) + foreach (var track in trackFile.Tracks.Value) { var sb = new StringBuilder(); var xws = new XmlWriterSettings(); @@ -141,21 +109,12 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Wdtv var doc = new XDocument(); var details = new XElement("details"); - details.Add(new XElement("id", series.Id)); - details.Add(new XElement("title", string.Format("{0} - {1}x{2:00} - {3}", series.Title, episode.SeasonNumber, episode.EpisodeNumber, episode.Title))); - details.Add(new XElement("series_name", series.Title)); - details.Add(new XElement("episode_name", episode.Title)); - details.Add(new XElement("season_number", episode.SeasonNumber.ToString("00"))); - details.Add(new XElement("episode_number", episode.EpisodeNumber.ToString("00"))); - details.Add(new XElement("firstaired", episode.AirDate)); - details.Add(new XElement("genre", string.Join(" / ", series.Genres))); - details.Add(new XElement("actor", string.Join(" / ", series.Actors.ConvertAll(c => c.Name + " - " + c.Character)))); - details.Add(new XElement("overview", episode.Overview)); - - - //Todo: get guest stars, writer and director - //details.Add(new XElement("credits", tvdbEpisode.Writer.FirstOrDefault())); - //details.Add(new XElement("director", tvdbEpisode.Directors.FirstOrDefault())); + details.Add(new XElement("id", artist.Id)); + details.Add(new XElement("title", string.Format("{0} - {1} - {2}", artist.Name, track.TrackNumber, track.Title))); + details.Add(new XElement("artist_name", artist.Metadata.Value.Name)); + details.Add(new XElement("track_name", track.Title)); + details.Add(new XElement("track_number", track.AbsoluteTrackNumber.ToString("00"))); + details.Add(new XElement("member", string.Join(" / ", artist.Metadata.Value.Members.ConvertAll(c => c.Name + " - " + c.Instrument)))); doc.Add(details); doc.Save(xw); @@ -165,131 +124,30 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Wdtv } } - var filename = GetEpisodeMetadataFilename(episodeFile.RelativePath); + var filename = GetTrackMetadataFilename(artist.Path.GetRelativePath(trackFile.Path)); return new MetadataFileResult(filename, xmlResult.Trim(Environment.NewLine.ToCharArray())); } - public override List SeriesImages(Series series) - { - if (!Settings.SeriesImages) - { - return new List(); - } - - //Because we only support one image, attempt to get the Poster type, then if that fails grab the first - var image = series.Images.SingleOrDefault(c => c.CoverType == MediaCoverTypes.Poster) ?? series.Images.FirstOrDefault(); - if (image == null) - { - _logger.Trace("Failed to find suitable Series image for series {0}.", series.Title); - return new List(); - } - - var source = _mediaCoverService.GetCoverPath(series.Id, image.CoverType); - var destination = "folder" + Path.GetExtension(source); - - return new List - { - new ImageFileResult(destination, source) - }; - } - - public override List SeasonImages(Series series, Season season) + public override List ArtistImages(Artist artist) { - if (!Settings.SeasonImages) - { - return new List(); - } - - var seasonFolders = GetSeasonFolders(series); - - //Work out the path to this season - if we don't have a matching path then skip this season. - string seasonFolder; - if (!seasonFolders.TryGetValue(season.SeasonNumber, out seasonFolder)) - { - _logger.Trace("Failed to find season folder for series {0}, season {1}.", series.Title, season.SeasonNumber); - return new List(); - } - - //WDTV only supports one season image, so first of all try for poster otherwise just use whatever is first in the collection - var image = season.Images.SingleOrDefault(c => c.CoverType == MediaCoverTypes.Poster) ?? season.Images.FirstOrDefault(); - if (image == null) - { - _logger.Trace("Failed to find suitable season image for series {0}, season {1}.", series.Title, season.SeasonNumber); - return new List(); - } - - var path = Path.Combine(seasonFolder, "folder.jpg"); - - return new List{ new ImageFileResult(path, image.Url) }; + return new List(); } - public override List EpisodeImages(Series series, EpisodeFile episodeFile) + public override List AlbumImages(Artist artist, Album album, string albumFolder) { - if (!Settings.EpisodeImages) - { - return new List(); - } - - var screenshot = episodeFile.Episodes.Value.First().Images.SingleOrDefault(i => i.CoverType == MediaCoverTypes.Screenshot); - - if (screenshot == null) - { - _logger.Trace("Episode screenshot not available"); - return new List(); - } - - return new List{ new ImageFileResult(GetEpisodeImageFilename(episodeFile.RelativePath), screenshot.Url) }; + return new List(); } - private string GetEpisodeMetadataFilename(string episodeFilePath) + public override List TrackImages(Artist artist, TrackFile trackFile) { - return Path.ChangeExtension(episodeFilePath, "xml"); - } - private string GetEpisodeImageFilename(string episodeFilePath) - { - return Path.ChangeExtension(episodeFilePath, "metathumb"); + return new List(); } - private Dictionary GetSeasonFolders(Series series) + private string GetTrackMetadataFilename(string trackFilePath) { - var seasonFolderMap = new Dictionary(); - - foreach (var folder in _diskProvider.GetDirectories(series.Path)) - { - var directoryinfo = new DirectoryInfo(folder); - var seasonMatch = SeasonImagesRegex.Match(directoryinfo.Name); - - if (seasonMatch.Success) - { - var seasonNumber = seasonMatch.Groups["season"].Value; - - if (seasonNumber.Contains("specials")) - { - seasonFolderMap[0] = folder; - } - else - { - int matchedSeason; - if (int.TryParse(seasonNumber, out matchedSeason)) - { - seasonFolderMap[matchedSeason] = folder; - } - else - { - _logger.Debug("Failed to parse season number from {0} for series {1}.", folder, series.Title); - } - } - } - - else - { - _logger.Debug("Rejecting folder {0} for series {1}.", Path.GetDirectoryName(folder), series.Title); - } - } - - return seasonFolderMap; + return Path.ChangeExtension(trackFilePath, "xml"); } } } diff --git a/src/NzbDrone.Core/Extras/Metadata/Consumers/Wdtv/WdtvMetadataSettings.cs b/src/NzbDrone.Core/Extras/Metadata/Consumers/Wdtv/WdtvMetadataSettings.cs index e010ff7e5..3f67bc746 100644 --- a/src/NzbDrone.Core/Extras/Metadata/Consumers/Wdtv/WdtvMetadataSettings.cs +++ b/src/NzbDrone.Core/Extras/Metadata/Consumers/Wdtv/WdtvMetadataSettings.cs @@ -1,4 +1,4 @@ -using FluentValidation; +using FluentValidation; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; @@ -7,9 +7,6 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Wdtv { public class WdtvSettingsValidator : AbstractValidator { - public WdtvSettingsValidator() - { - } } public class WdtvMetadataSettings : IProviderConfig @@ -18,23 +15,11 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Wdtv public WdtvMetadataSettings() { - EpisodeMetadata = true; - SeriesImages = true; - SeasonImages = true; - EpisodeImages = true; + TrackMetadata = true; } - [FieldDefinition(0, Label = "Episode Metadata", Type = FieldType.Checkbox)] - public bool EpisodeMetadata { get; set; } - - [FieldDefinition(1, Label = "Series Images", Type = FieldType.Checkbox)] - public bool SeriesImages { get; set; } - - [FieldDefinition(2, Label = "Season Images", Type = FieldType.Checkbox)] - public bool SeasonImages { get; set; } - - [FieldDefinition(3, Label = "Episode Images", Type = FieldType.Checkbox)] - public bool EpisodeImages { get; set; } + [FieldDefinition(0, Label = "Track Metadata", Type = FieldType.Checkbox, Section = MetadataSectionType.Metadata)] + public bool TrackMetadata { get; set; } public bool IsValid => true; diff --git a/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadata.cs b/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadata.cs index 99e384cb9..e5e0a9992 100644 --- a/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadata.cs +++ b/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadata.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -11,47 +11,44 @@ using NzbDrone.Common.Extensions; using NzbDrone.Core.Extras.Metadata.Files; using NzbDrone.Core.MediaCover; using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; 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; - public XbmcMetadata(IMapCoversToLocal mediaCoverService, + public XbmcMetadata(IDetectXbmcNfo detectNfo, + IMapCoversToLocal mediaCoverService, Logger logger) { - _mediaCoverService = mediaCoverService; _logger = logger; + _mediaCoverService = mediaCoverService; + _detectNfo = detectNfo; } - private static readonly Regex SeriesImagesRegex = new Regex(@"^(?poster|banner|fanart)\.(?:png|jpg)", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex SeasonImagesRegex = new Regex(@"^season(?\d{2,}|-all|-specials)-(?poster|banner|fanart)\.(?:png|jpg)", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex EpisodeImageRegex = new Regex(@"-thumb\.(?:png|jpg)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex ArtistImagesRegex = new Regex(@"^(?folder|banner|fanart|logo)\.(?:png|jpg|jpeg)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex AlbumImagesRegex = new Regex(@"^(?cover|disc)\.(?:png|jpg|jpeg)", RegexOptions.Compiled | RegexOptions.IgnoreCase); public override string Name => "Kodi (XBMC) / Emby"; - public override string GetFilenameAfterMove(Series series, EpisodeFile episodeFile, MetadataFile metadataFile) + public override string GetFilenameAfterMove(Artist artist, TrackFile trackFile, MetadataFile metadataFile) { - var episodeFilePath = Path.Combine(series.Path, episodeFile.RelativePath); - - if (metadataFile.Type == MetadataType.EpisodeImage) - { - return GetEpisodeImageFilename(episodeFilePath); - } + var trackFilePath = trackFile.Path; - if (metadataFile.Type == MetadataType.EpisodeMetadata) + if (metadataFile.Type == MetadataType.TrackMetadata) { - return GetEpisodeMetadataFilename(episodeFilePath); + return GetTrackMetadataFilename(trackFilePath); } - _logger.Debug("Unknown episode file metadata: {0}", metadataFile.RelativePath); - return Path.Combine(series.Path, metadataFile.RelativePath); + _logger.Debug("Unknown track file metadata: {0}", metadataFile.RelativePath); + return Path.Combine(artist.Path, metadataFile.RelativePath); } - public override MetadataFile FindMetadataFile(Series series, string path) + public override MetadataFile FindMetadataFile(Artist artist, string path) { var filename = Path.GetFileName(path); @@ -59,335 +56,196 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Xbmc var metadata = new MetadataFile { - SeriesId = series.Id, + ArtistId = artist.Id, Consumer = GetType().Name, - RelativePath = series.Path.GetRelativePath(path) + RelativePath = artist.Path.GetRelativePath(path) }; - if (SeriesImagesRegex.IsMatch(filename)) + if (ArtistImagesRegex.IsMatch(filename)) { - metadata.Type = MetadataType.SeriesImage; + metadata.Type = MetadataType.ArtistImage; return metadata; } - var seasonMatch = SeasonImagesRegex.Match(filename); + var albumMatch = AlbumImagesRegex.Match(filename); - if (seasonMatch.Success) + if (albumMatch.Success) { - metadata.Type = MetadataType.SeasonImage; - - var seasonNumberMatch = seasonMatch.Groups["season"].Value; - int seasonNumber; - - if (seasonNumberMatch.Contains("specials")) - { - metadata.SeasonNumber = 0; - } - - else if (int.TryParse(seasonNumberMatch, out seasonNumber)) - { - metadata.SeasonNumber = seasonNumber; - } - - else - { - return null; - } - + metadata.Type = MetadataType.AlbumImage; return metadata; } - if (EpisodeImageRegex.IsMatch(filename)) - { - metadata.Type = MetadataType.EpisodeImage; - return metadata; - } + var isXbmcNfoFile = _detectNfo.IsXbmcNfoFile(path); - if (filename.Equals("tvshow.nfo", StringComparison.InvariantCultureIgnoreCase)) + if (filename.Equals("artist.nfo", StringComparison.OrdinalIgnoreCase) && + isXbmcNfoFile) { - metadata.Type = MetadataType.SeriesMetadata; + metadata.Type = MetadataType.ArtistMetadata; return metadata; } - var parseResult = Parser.Parser.ParseTitle(filename); - - if (parseResult != null && - !parseResult.FullSeason && - Path.GetExtension(filename) == ".nfo") + if (filename.Equals("album.nfo", StringComparison.OrdinalIgnoreCase) && + isXbmcNfoFile) { - metadata.Type = MetadataType.EpisodeMetadata; + metadata.Type = MetadataType.AlbumMetadata; return metadata; } return null; } - public override MetadataFileResult SeriesMetadata(Series series) + public override MetadataFileResult ArtistMetadata(Artist artist) { - if (!Settings.SeriesMetadata) + if (!Settings.ArtistMetadata) { return null; } - _logger.Debug("Generating tvshow.nfo for: {0}", series.Title); + _logger.Debug("Generating artist.nfo for: {0}", artist.Name); var sb = new StringBuilder(); var xws = new XmlWriterSettings(); xws.OmitXmlDeclaration = true; xws.Indent = false; - var episodeGuideUrl = string.Format("http://www.thetvdb.com/api/1D62F2F90030C444/series/{0}/all/en.zip", series.TvdbId); - using (var xw = XmlWriter.Create(sb, xws)) { - var tvShow = new XElement("tvshow"); - - tvShow.Add(new XElement("title", series.Title)); - - if (series.Ratings != null && series.Ratings.Votes > 0) - { - tvShow.Add(new XElement("rating", series.Ratings.Value)); - } - - tvShow.Add(new XElement("plot", series.Overview)); - tvShow.Add(new XElement("episodeguide", new XElement("url", episodeGuideUrl))); - tvShow.Add(new XElement("episodeguideurl", episodeGuideUrl)); - tvShow.Add(new XElement("mpaa", series.Certification)); - tvShow.Add(new XElement("id", series.TvdbId)); + var artistElement = new XElement("artist"); - foreach (var genre in series.Genres) - { - tvShow.Add(new XElement("genre", genre)); - } + artistElement.Add(new XElement("title", artist.Name)); - if (series.FirstAired.HasValue) + if (artist.Metadata.Value.Ratings != null && artist.Metadata.Value.Ratings.Votes > 0) { - tvShow.Add(new XElement("premiered", series.FirstAired.Value.ToString("yyyy-MM-dd"))); + artistElement.Add(new XElement("rating", artist.Metadata.Value.Ratings.Value)); } - tvShow.Add(new XElement("studio", series.Network)); - - foreach (var actor in series.Actors) - { - var xmlActor = new XElement("actor", - new XElement("name", actor.Name), - new XElement("role", actor.Character)); - - if (actor.Images.Any()) - { - xmlActor.Add(new XElement("thumb", actor.Images.First().Url)); - } + artistElement.Add(new XElement("musicbrainzartistid", artist.Metadata.Value.ForeignArtistId)); + artistElement.Add(new XElement("biography", artist.Metadata.Value.Overview)); + artistElement.Add(new XElement("outline", artist.Metadata.Value.Overview)); - tvShow.Add(xmlActor); - } - - var doc = new XDocument(tvShow); + var doc = new XDocument(artistElement); doc.Save(xw); - _logger.Debug("Saving tvshow.nfo for {0}", series.Title); + _logger.Debug("Saving artist.nfo for {0}", artist.Metadata.Value.Name); - return new MetadataFileResult("tvshow.nfo", doc.ToString()); + return new MetadataFileResult("artist.nfo", doc.ToString()); } } - public override MetadataFileResult EpisodeMetadata(Series series, EpisodeFile episodeFile) + public override MetadataFileResult AlbumMetadata(Artist artist, Album album, string albumPath) { - if (!Settings.EpisodeMetadata) + if (!Settings.AlbumMetadata) { return null; } - _logger.Debug("Generating Episode Metadata for: {0}", Path.Combine(series.Path, episodeFile.RelativePath)); + _logger.Debug("Generating album.nfo for: {0}", album.Title); + var sb = new StringBuilder(); + var xws = new XmlWriterSettings(); + xws.OmitXmlDeclaration = true; + xws.Indent = false; - var xmlResult = string.Empty; - foreach (var episode in episodeFile.Episodes.Value) + using (var xw = XmlWriter.Create(sb, xws)) { - var sb = new StringBuilder(); - var xws = new XmlWriterSettings(); - xws.OmitXmlDeclaration = true; - xws.Indent = false; + var albumElement = new XElement("album"); - using (var xw = XmlWriter.Create(sb, xws)) + albumElement.Add(new XElement("title", album.Title)); + + if (album.Ratings != null && album.Ratings.Votes > 0) { - var doc = new XDocument(); - var image = episode.Images.SingleOrDefault(i => i.CoverType == MediaCoverTypes.Screenshot); - - var details = new XElement("episodedetails"); - details.Add(new XElement("title", episode.Title)); - details.Add(new XElement("season", episode.SeasonNumber)); - details.Add(new XElement("episode", episode.EpisodeNumber)); - details.Add(new XElement("aired", episode.AirDate)); - details.Add(new XElement("plot", episode.Overview)); - - //If trakt ever gets airs before information for specials we should add set it - details.Add(new XElement("displayseason")); - details.Add(new XElement("displayepisode")); - - if (image == null) - { - details.Add(new XElement("thumb")); - } - - else - { - details.Add(new XElement("thumb", image.Url)); - } - - details.Add(new XElement("watched", "false")); - - if (episode.Ratings != null && episode.Ratings.Votes > 0) - { - details.Add(new XElement("rating", episode.Ratings.Value)); - } - - if (episodeFile.MediaInfo != null) - { - 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("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)); - - if (episodeFile.MediaInfo.RunTime != null) - { - video.Add(new XElement("duration", episodeFile.MediaInfo.RunTime.TotalMinutes)); - video.Add(new XElement("durationinseconds", episodeFile.MediaInfo.RunTime.TotalSeconds)); - } - - streamDetails.Add(video); - - 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("language", episodeFile.MediaInfo.AudioLanguages)); - streamDetails.Add(audio); - - if (episodeFile.MediaInfo.Subtitles != null && episodeFile.MediaInfo.Subtitles.Length > 0) - { - var subtitle = new XElement("subtitle"); - subtitle.Add(new XElement("language", episodeFile.MediaInfo.Subtitles)); - streamDetails.Add(subtitle); - } - - fileInfo.Add(streamDetails); - details.Add(fileInfo); - } - - //Todo: get guest stars, writer and director - //details.Add(new XElement("credits", tvdbEpisode.Writer.FirstOrDefault())); - //details.Add(new XElement("director", tvdbEpisode.Directors.FirstOrDefault())); - - doc.Add(details); - doc.Save(xw); - - xmlResult += doc.ToString(); - xmlResult += Environment.NewLine; + albumElement.Add(new XElement("rating", album.Ratings.Value)); } - } - return new MetadataFileResult(GetEpisodeMetadataFilename(episodeFile.RelativePath), xmlResult.Trim(Environment.NewLine.ToCharArray())); - } + albumElement.Add(new XElement("musicbrainzalbumid", album.ForeignAlbumId)); + albumElement.Add(new XElement("artistdesc", artist.Metadata.Value.Overview)); + albumElement.Add(new XElement("releasedate", album.ReleaseDate.Value.ToShortDateString())); - public override List SeriesImages(Series series) - { - if (!Settings.SeriesImages) - { - return new List(); + var doc = new XDocument(albumElement); + doc.Save(xw); + + _logger.Debug("Saving album.nfo for {0}", album.Title); + + var fileName = Path.Combine(albumPath, "album.nfo"); + + return new MetadataFileResult(fileName, doc.ToString()); } + } - return ProcessSeriesImages(series).ToList(); + public override MetadataFileResult TrackMetadata(Artist artist, TrackFile trackFile) + { + return null; } - public override List SeasonImages(Series series, Season season) + public override List ArtistImages(Artist artist) { - if (!Settings.SeasonImages) + if (!Settings.ArtistImages) { return new List(); } - return ProcessSeasonImages(series, season).ToList(); + return ProcessArtistImages(artist).ToList(); } - public override List EpisodeImages(Series series, EpisodeFile episodeFile) + public override List AlbumImages(Artist artist, Album album, string albumPath) { - if (!Settings.EpisodeImages) + if (!Settings.AlbumImages) { return new List(); } - try - { - var screenshot = episodeFile.Episodes.Value.First().Images.SingleOrDefault(i => i.CoverType == MediaCoverTypes.Screenshot); - - if (screenshot == null) - { - _logger.Debug("Episode screenshot not available"); - return new List(); - } + return ProcessAlbumImages(artist, album, albumPath).ToList(); + } - return new List - { - new ImageFileResult(GetEpisodeImageFilename(episodeFile.RelativePath), screenshot.Url) - }; - } - catch (Exception ex) - { - _logger.Error(ex, "Unable to process episode image for file: {0}", Path.Combine(series.Path, episodeFile.RelativePath)); + public override List TrackImages(Artist artist, TrackFile trackFile) + { - return new List(); - } + return new List(); } - private IEnumerable ProcessSeriesImages(Series series) + private IEnumerable ProcessArtistImages(Artist artist) { - foreach (var image in series.Images) + foreach (var image in artist.Metadata.Value.Images) { - var source = _mediaCoverService.GetCoverPath(series.Id, image.CoverType); - var destination = image.CoverType.ToString().ToLowerInvariant() + Path.GetExtension(source); + var source = _mediaCoverService.GetCoverPath(artist.Id, MediaCoverEntity.Artist, image.CoverType, image.Extension); + var destination = image.CoverType.ToString().ToLowerInvariant() + image.Extension; + if (image.CoverType == MediaCoverTypes.Poster) + { + destination = "folder" + image.Extension; + } yield return new ImageFileResult(destination, source); } } - private IEnumerable ProcessSeasonImages(Series series, Season season) + private IEnumerable ProcessAlbumImages(Artist artist, Album album, string albumPath) { - foreach (var image in season.Images) + foreach (var image in album.Images) { - var filename = string.Format("season{0:00}-{1}.jpg", season.SeasonNumber, image.CoverType.ToString().ToLower()); + // TODO: Make Source fallback to URL if local does not exist + // var source = _mediaCoverService.GetCoverPath(album.ArtistId, image.CoverType, null, album.Id); + string filename; - if (season.SeasonNumber == 0) + switch(image.CoverType) { - filename = string.Format("season-specials-{0}.jpg", image.CoverType.ToString().ToLower()); + case MediaCoverTypes.Cover: + filename = "folder"; + break; + case MediaCoverTypes.Disc: + filename = "discart"; + break; + default: + continue; } - yield return new ImageFileResult(filename, image.Url); - } - } + var destination = Path.Combine(albumPath, filename + image.Extension); - private string GetEpisodeMetadataFilename(string episodeFilePath) - { - return Path.ChangeExtension(episodeFilePath, "nfo"); + yield return new ImageFileResult(destination, image.Url); + } } - private string GetEpisodeImageFilename(string episodeFilePath) + private string GetTrackMetadataFilename(string trackFilePath) { - return Path.ChangeExtension(episodeFilePath, "").Trim('.') + "-thumb.jpg"; + return Path.ChangeExtension(trackFilePath, "nfo"); } - private string GetAudioCodec(string audioCodec) - { - if (audioCodec == "AC-3") - { - return "AC3"; - } - - return audioCodec; - } } } diff --git a/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadataSettings.cs b/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadataSettings.cs index cd4b833ae..375384e19 100644 --- a/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadataSettings.cs +++ b/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadataSettings.cs @@ -1,4 +1,4 @@ -using FluentValidation; +using FluentValidation; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; @@ -7,9 +7,6 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Xbmc { public class XbmcSettingsValidator : AbstractValidator { - public XbmcSettingsValidator() - { - } } public class XbmcMetadataSettings : IProviderConfig @@ -18,28 +15,24 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Xbmc public XbmcMetadataSettings() { - SeriesMetadata = true; - EpisodeMetadata = true; - SeriesImages = true; - SeasonImages = true; - EpisodeImages = true; + ArtistMetadata = true; + AlbumMetadata = true; + ArtistImages = true; + AlbumImages = true; } - [FieldDefinition(0, Label = "Series Metadata", Type = FieldType.Checkbox)] - public bool SeriesMetadata { get; set; } + [FieldDefinition(0, Label = "Artist Metadata", Type = FieldType.Checkbox, Section = MetadataSectionType.Metadata, HelpText = "artist.nfo")] + public bool ArtistMetadata { get; set; } - [FieldDefinition(1, Label = "Episode Metadata", Type = FieldType.Checkbox)] - public bool EpisodeMetadata { get; set; } + [FieldDefinition(1, Label = "Album Metadata", Type = FieldType.Checkbox, Section = MetadataSectionType.Metadata, HelpText = "album.nfo")] + public bool AlbumMetadata { get; set; } - [FieldDefinition(2, Label = "Series Images", Type = FieldType.Checkbox)] - public bool SeriesImages { get; set; } + [FieldDefinition(3, Label = "Artist Images", Type = FieldType.Checkbox, Section = MetadataSectionType.Image)] + public bool ArtistImages { get; set; } - [FieldDefinition(3, Label = "Season Images", Type = FieldType.Checkbox)] - public bool SeasonImages { get; set; } + [FieldDefinition(4, Label = "Album Images", Type = FieldType.Checkbox, Section = MetadataSectionType.Image)] + public bool AlbumImages { get; set; } - [FieldDefinition(4, Label = "Episode Images", Type = FieldType.Checkbox)] - public bool EpisodeImages { get; set; } - public bool IsValid => true; public NzbDroneValidationResult Validate() 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..234d27f22 --- /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..03fbe8a91 100644 --- a/src/NzbDrone.Core/Extras/Metadata/ExistingMetadataImporter.cs +++ b/src/NzbDrone.Core/Extras/Metadata/ExistingMetadataImporter.cs @@ -1,13 +1,15 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using System.Linq; using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Core.Extras.Files; using NzbDrone.Core.Extras.Metadata.Files; -using NzbDrone.Core.Extras.Subtitles; +using NzbDrone.Core.Extras.Lyrics; using NzbDrone.Core.Parser; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; +using NzbDrone.Core.MediaFiles.TrackImport.Aggregation; +using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.Extras.Metadata { @@ -15,73 +17,96 @@ namespace NzbDrone.Core.Extras.Metadata { 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(); } public override int Order => 0; - public override IEnumerable ProcessFiles(Series series, List filesOnDisk, List importedFiles) + public override IEnumerable ProcessFiles(Artist artist, List filesOnDisk, List importedFiles) { - _logger.Debug("Looking for existing metadata in {0}", series.Path); + _logger.Debug("Looking for existing metadata in {0}", artist.Path); var metadataFiles = new List(); - var filterResult = FilterAndClean(series, filesOnDisk, importedFiles); + var filterResult = FilterAndClean(artist, filesOnDisk, importedFiles); foreach (var possibleMetadataFile in filterResult.FilesOnDisk) { // Don't process files that have known Subtitle file extensions (saves a bit of unecessary processing) - if (SubtitleFileExtensions.Extensions.Contains(Path.GetExtension(possibleMetadataFile))) + if (LyricFileExtensions.Extensions.Contains(Path.GetExtension(possibleMetadataFile))) { continue; } foreach (var consumer in _consumers) { - var metadata = consumer.FindMetadataFile(series, possibleMetadataFile); + var metadata = consumer.FindMetadataFile(artist, possibleMetadataFile); if (metadata == null) { continue; } - if (metadata.Type == MetadataType.EpisodeImage || - metadata.Type == MetadataType.EpisodeMetadata) + if (metadata.Type == MetadataType.AlbumImage || metadata.Type == MetadataType.AlbumMetadata) { - var localEpisode = _parsingService.GetLocalEpisode(possibleMetadataFile, series); + var localAlbum = _parsingService.GetLocalAlbum(possibleMetadataFile, artist); - if (localEpisode == null) + if (localAlbum == null) + { + _logger.Debug("Extra file folder has multiple Albums: {0}", possibleMetadataFile); + continue; + } + + metadata.AlbumId = localAlbum.Id; + } + + if (metadata.Type == MetadataType.TrackMetadata) + { + var localTrack = new LocalTrack + { + FileTrackInfo = Parser.Parser.ParseMusicPath(possibleMetadataFile), + Artist = artist, + Path = possibleMetadataFile + }; + + try + { + _augmentingService.Augment(localTrack, false); + } + catch (AugmentingFailedException) { _logger.Debug("Unable to parse extra file: {0}", possibleMetadataFile); continue; } - if (localEpisode.Episodes.Empty()) + if (localTrack.Tracks.Empty()) { - _logger.Debug("Cannot find related episodes for: {0}", possibleMetadataFile); + _logger.Debug("Cannot find related tracks for: {0}", possibleMetadataFile); continue; } - if (localEpisode.Episodes.DistinctBy(e => e.EpisodeFileId).Count() > 1) + if (localTrack.Tracks.DistinctBy(e => e.TrackFileId).Count() > 1) { _logger.Debug("Extra file: {0} does not match existing files.", possibleMetadataFile); continue; } - - metadata.SeasonNumber = localEpisode.SeasonNumber; - metadata.EpisodeFileId = localEpisode.Episodes.First().EpisodeFileId; + + metadata.TrackFileId = localTrack.Tracks.First().TrackFileId; } metadata.Extension = Path.GetExtension(possibleMetadataFile); diff --git a/src/NzbDrone.Core/Extras/Metadata/Files/CleanMetadataFileService.cs b/src/NzbDrone.Core/Extras/Metadata/Files/CleanMetadataFileService.cs index 6166ae20b..1fe7ea41a 100644 --- a/src/NzbDrone.Core/Extras/Metadata/Files/CleanMetadataFileService.cs +++ b/src/NzbDrone.Core/Extras/Metadata/Files/CleanMetadataFileService.cs @@ -1,13 +1,13 @@ -using System.IO; +using System.IO; using NLog; using NzbDrone.Common.Disk; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Extras.Metadata.Files { public interface ICleanMetadataService { - void Clean(Series series); + void Clean(Artist artist); } public class CleanExtraFileService : ICleanMetadataService @@ -25,15 +25,15 @@ namespace NzbDrone.Core.Extras.Metadata.Files _logger = logger; } - public void Clean(Series series) + public void Clean(Artist artist) { - _logger.Debug("Cleaning missing metadata files for series: {0}", series.Title); + _logger.Debug("Cleaning missing metadata files for artist: {0}", artist.Name); - var metadataFiles = _metadataFileService.GetFilesBySeries(series.Id); + var metadataFiles = _metadataFileService.GetFilesByArtist(artist.Id); foreach (var metadataFile in metadataFiles) { - if (!_diskProvider.FileExists(Path.Combine(series.Path, metadataFile.RelativePath))) + if (!_diskProvider.FileExists(Path.Combine(artist.Path, metadataFile.RelativePath))) { _logger.Debug("Deleting metadata file from database: {0}", metadataFile.RelativePath); _metadataFileService.Delete(metadataFile.Id); diff --git a/src/NzbDrone.Core/Extras/Metadata/Files/MetadataFileService.cs b/src/NzbDrone.Core/Extras/Metadata/Files/MetadataFileService.cs index f5fc2ba69..7789cfe44 100644 --- a/src/NzbDrone.Core/Extras/Metadata/Files/MetadataFileService.cs +++ b/src/NzbDrone.Core/Extras/Metadata/Files/MetadataFileService.cs @@ -1,8 +1,8 @@ -using NLog; +using NLog; using NzbDrone.Common.Disk; using NzbDrone.Core.Extras.Files; using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Extras.Metadata.Files { @@ -12,11 +12,9 @@ namespace NzbDrone.Core.Extras.Metadata.Files public class MetadataFileService : ExtraFileService, IMetadataFileService { - public MetadataFileService(IExtraFileRepository repository, ISeriesService seriesService, IDiskProvider diskProvider, IRecycleBinProvider recycleBinProvider, Logger logger) - : base(repository, seriesService, diskProvider, recycleBinProvider, logger) + public MetadataFileService(IExtraFileRepository repository, IArtistService artistService, IDiskProvider diskProvider, IRecycleBinProvider recycleBinProvider, Logger logger) + : base(repository, artistService, diskProvider, recycleBinProvider, logger) { } - - public override bool PermanentlyDelete => true; } } diff --git a/src/NzbDrone.Core/Extras/Metadata/IMetadata.cs b/src/NzbDrone.Core/Extras/Metadata/IMetadata.cs index b631425e6..7abee9e8b 100644 --- a/src/NzbDrone.Core/Extras/Metadata/IMetadata.cs +++ b/src/NzbDrone.Core/Extras/Metadata/IMetadata.cs @@ -1,19 +1,21 @@ -using System.Collections.Generic; +using System.Collections.Generic; using NzbDrone.Core.Extras.Metadata.Files; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.ThingiProvider; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Extras.Metadata { public interface IMetadata : IProvider { - string GetFilenameAfterMove(Series series, EpisodeFile episodeFile, MetadataFile metadataFile); - MetadataFile FindMetadataFile(Series series, string path); - MetadataFileResult SeriesMetadata(Series series); - MetadataFileResult EpisodeMetadata(Series series, EpisodeFile episodeFile); - List SeriesImages(Series series); - List SeasonImages(Series series, Season season); - List EpisodeImages(Series series, EpisodeFile episodeFile); + string GetFilenameAfterMove(Artist artist, TrackFile trackFile, MetadataFile metadataFile); + string GetFilenameAfterMove(Artist artist, string albumPath, MetadataFile metadataFile); + MetadataFile FindMetadataFile(Artist artist, string path); + MetadataFileResult ArtistMetadata(Artist artist); + MetadataFileResult AlbumMetadata(Artist artist, Album album, string albumPath); + MetadataFileResult TrackMetadata(Artist artist, TrackFile trackFile); + List ArtistImages(Artist artist); + List AlbumImages(Artist artist, Album album, string albumPath); + List TrackImages(Artist artist, TrackFile trackFile); } } diff --git a/src/NzbDrone.Core/Extras/Metadata/MetadataBase.cs b/src/NzbDrone.Core/Extras/Metadata/MetadataBase.cs index f60928703..7650402a5 100644 --- a/src/NzbDrone.Core/Extras/Metadata/MetadataBase.cs +++ b/src/NzbDrone.Core/Extras/Metadata/MetadataBase.cs @@ -1,11 +1,11 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using FluentValidation.Results; using NzbDrone.Core.Extras.Metadata.Files; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.ThingiProvider; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Extras.Metadata { @@ -26,22 +26,31 @@ namespace NzbDrone.Core.Extras.Metadata return new ValidationResult(); } - public virtual string GetFilenameAfterMove(Series series, EpisodeFile episodeFile, MetadataFile metadataFile) + public virtual string GetFilenameAfterMove(Artist artist, TrackFile trackFile, MetadataFile metadataFile) { - var existingFilename = Path.Combine(series.Path, metadataFile.RelativePath); + var existingFilename = Path.Combine(artist.Path, metadataFile.RelativePath); var extension = Path.GetExtension(existingFilename).TrimStart('.'); - var newFileName = Path.ChangeExtension(Path.Combine(series.Path, episodeFile.RelativePath), extension); + var newFileName = Path.ChangeExtension(trackFile.Path, extension); return newFileName; } - public abstract MetadataFile FindMetadataFile(Series series, string path); + public virtual string GetFilenameAfterMove(Artist artist, string albumPath, MetadataFile metadataFile) + { + var existingFilename = Path.GetFileName(metadataFile.RelativePath); + var newFileName = Path.Combine(artist.Path, albumPath, existingFilename); + + return newFileName; + } + + public abstract MetadataFile FindMetadataFile(Artist artist, string path); - public abstract MetadataFileResult SeriesMetadata(Series series); - public abstract MetadataFileResult EpisodeMetadata(Series series, EpisodeFile episodeFile); - public abstract List SeriesImages(Series series); - public abstract List SeasonImages(Series series, Season season); - public abstract List EpisodeImages(Series series, EpisodeFile episodeFile); + public abstract MetadataFileResult ArtistMetadata(Artist artist); + public abstract MetadataFileResult AlbumMetadata(Artist artist, Album album, string albumPath); + public abstract MetadataFileResult TrackMetadata(Artist artist, TrackFile trackFile); + public abstract List ArtistImages(Artist artist); + public abstract List AlbumImages(Artist artist, Album album, string albumPath); + public abstract List TrackImages(Artist artist, TrackFile trackFile); public virtual object RequestAction(string action, IDictionary query) { return null; } diff --git a/src/NzbDrone.Core/Extras/Metadata/MetadataSectionType.cs b/src/NzbDrone.Core/Extras/Metadata/MetadataSectionType.cs new file mode 100644 index 000000000..7f2251d85 --- /dev/null +++ b/src/NzbDrone.Core/Extras/Metadata/MetadataSectionType.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Extras.Metadata +{ + public static class MetadataSectionType + { + public const string Metadata = "metadata"; + public const string Image = "image"; + } +} diff --git a/src/NzbDrone.Core/Extras/Metadata/MetadataService.cs b/src/NzbDrone.Core/Extras/Metadata/MetadataService.cs index 95198f2f0..ef1b2884d 100644 --- a/src/NzbDrone.Core/Extras/Metadata/MetadataService.cs +++ b/src/NzbDrone.Core/Extras/Metadata/MetadataService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -10,8 +10,10 @@ 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; +using NzbDrone.Core.Music; +using NzbDrone.Core.Organizer; namespace NzbDrone.Core.Extras.Metadata { @@ -19,44 +21,53 @@ 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; private readonly IMediaFileAttributeService _mediaFileAttributeService; private readonly IMetadataFileService _metadataFileService; + private readonly IAlbumService _albumService; private readonly Logger _logger; public MetadataService(IConfigService configService, + IDiskProvider diskProvider, IDiskTransferService diskTransferService, + IRecycleBinProvider recycleBinProvider, + IOtherExtraFileRenamer otherExtraFileRenamer, IMetadataFactory metadataFactory, ICleanMetadataService cleanMetadataService, - IDiskProvider diskProvider, IHttpClient httpClient, IMediaFileAttributeService mediaFileAttributeService, IMetadataFileService metadataFileService, + IAlbumService albumService, Logger logger) - : base(configService, diskTransferService, metadataFileService) + : base(configService, diskProvider, diskTransferService, logger) { _metadataFactory = metadataFactory; _cleanMetadataService = cleanMetadataService; + _otherExtraFileRenamer = otherExtraFileRenamer; + _recycleBinProvider = recycleBinProvider; _diskTransferService = diskTransferService; _diskProvider = diskProvider; _httpClient = httpClient; _mediaFileAttributeService = mediaFileAttributeService; _metadataFileService = metadataFileService; + _albumService = albumService; _logger = logger; } public override int Order => 0; - public override IEnumerable CreateAfterSeriesScan(Series series, List episodeFiles) + public override IEnumerable CreateAfterArtistScan(Artist artist, List trackFiles) { - var metadataFiles = _metadataFileService.GetFilesBySeries(series.Id); - _cleanMetadataService.Clean(series); + var metadataFiles = _metadataFileService.GetFilesByArtist(artist.Id); + _cleanMetadataService.Clean(artist); - if (!_diskProvider.FolderExists(series.Path)) + if (!_diskProvider.FolderExists(artist.Path)) { - _logger.Info("Series folder does not exist, skipping metadata creation"); + _logger.Info("Artist folder does not exist, skipping metadata creation"); return Enumerable.Empty(); } @@ -66,14 +77,22 @@ namespace NzbDrone.Core.Extras.Metadata { var consumerFiles = GetMetadataFilesForConsumer(consumer, metadataFiles); - files.AddIfNotNull(ProcessSeriesMetadata(consumer, series, consumerFiles)); - files.AddRange(ProcessSeriesImages(consumer, series, consumerFiles)); - files.AddRange(ProcessSeasonImages(consumer, series, consumerFiles)); + files.AddIfNotNull(ProcessArtistMetadata(consumer, artist, consumerFiles)); + files.AddRange(ProcessArtistImages(consumer, artist, consumerFiles)); + + var albumGroups = trackFiles.GroupBy(s => Path.GetDirectoryName(s.Path)).ToList(); - foreach (var episodeFile in episodeFiles) + foreach (var group in albumGroups) { - files.AddIfNotNull(ProcessEpisodeMetadata(consumer, series, episodeFile, consumerFiles)); - files.AddRange(ProcessEpisodeImages(consumer, series, episodeFile, consumerFiles)); + var album = _albumService.GetAlbum(group.First().AlbumId); + var albumFolder = group.Key; + files.AddIfNotNull(ProcessAlbumMetadata(consumer, artist, album, albumFolder, consumerFiles)); + files.AddRange(ProcessAlbumImages(consumer, artist, album, albumFolder, consumerFiles)); + + foreach (var trackFile in group) + { + files.AddIfNotNull(ProcessTrackMetadata(consumer, artist, trackFile, consumerFiles)); + } } } @@ -82,15 +101,13 @@ namespace NzbDrone.Core.Extras.Metadata return files; } - public override IEnumerable CreateAfterEpisodeImport(Series series, EpisodeFile episodeFile) + public override IEnumerable CreateAfterTrackImport(Artist artist, TrackFile trackFile) { var files = new List(); foreach (var consumer in _metadataFactory.Enabled()) { - - files.AddIfNotNull(ProcessEpisodeMetadata(consumer, series, episodeFile, new List())); - files.AddRange(ProcessEpisodeImages(consumer, series, episodeFile, new List())); + files.AddIfNotNull(ProcessTrackMetadata(consumer, artist, trackFile, new List())); } _metadataFileService.Upsert(files); @@ -98,11 +115,11 @@ namespace NzbDrone.Core.Extras.Metadata return files; } - public override IEnumerable CreateAfterEpisodeImport(Series series, string seriesFolder, string seasonFolder) + public override IEnumerable CreateAfterTrackImport(Artist artist, Album album, string artistFolder, string albumFolder) { - var metadataFiles = _metadataFileService.GetFilesBySeries(series.Id); + var metadataFiles = _metadataFileService.GetFilesByArtist(artist.Id); - if (seriesFolder.IsNullOrWhiteSpace() && seasonFolder.IsNullOrWhiteSpace()) + if (artistFolder.IsNullOrWhiteSpace() && albumFolder.IsNullOrWhiteSpace()) { return new List(); } @@ -113,15 +130,10 @@ namespace NzbDrone.Core.Extras.Metadata { var consumerFiles = GetMetadataFilesForConsumer(consumer, metadataFiles); - if (seriesFolder.IsNotNullOrWhiteSpace()) + if (artistFolder.IsNotNullOrWhiteSpace()) { - files.AddIfNotNull(ProcessSeriesMetadata(consumer, series, consumerFiles)); - files.AddRange(ProcessSeriesImages(consumer, series, consumerFiles)); - } - - if (seasonFolder.IsNotNullOrWhiteSpace()) - { - files.AddRange(ProcessSeasonImages(consumer, series, consumerFiles)); + files.AddIfNotNull(ProcessArtistMetadata(consumer, artist, consumerFiles)); + files.AddRange(ProcessArtistImages(consumer, artist, consumerFiles)); } } @@ -130,36 +142,66 @@ namespace NzbDrone.Core.Extras.Metadata return files; } - public override IEnumerable MoveFilesAfterRename(Series series, List episodeFiles) + public override IEnumerable MoveFilesAfterRename(Artist artist, List trackFiles) { - var metadataFiles = _metadataFileService.GetFilesBySeries(series.Id); + var metadataFiles = _metadataFileService.GetFilesByArtist(artist.Id); var movedFiles = new List(); + var distinctTrackFilePaths = trackFiles.DistinctBy(s => Path.GetDirectoryName(s.Path)).ToList(); // TODO: Move EpisodeImage and EpisodeMetadata metadata files, instead of relying on consumers to do it // (Xbmc's EpisodeImage is more than just the extension) foreach (var consumer in _metadataFactory.GetAvailableProviders()) { - foreach (var episodeFile in episodeFiles) + foreach (var filePath in distinctTrackFilePaths) { - var metadataFilesForConsumer = GetMetadataFilesForConsumer(consumer, metadataFiles).Where(m => m.EpisodeFileId == episodeFile.Id).ToList(); + var metadataFilesForConsumer = GetMetadataFilesForConsumer(consumer, metadataFiles) + .Where(m => m.AlbumId == filePath.AlbumId) + .Where(m => m.Type == MetadataType.AlbumImage || m.Type == MetadataType.AlbumMetadata) + .ToList(); foreach (var metadataFile in metadataFilesForConsumer) { - var newFileName = consumer.GetFilenameAfterMove(series, episodeFile, metadataFile); - var existingFileName = Path.Combine(series.Path, metadataFile.RelativePath); + var newFileName = consumer.GetFilenameAfterMove(artist, Path.GetDirectoryName(filePath.Path), metadataFile); + var existingFileName = Path.Combine(artist.Path, metadataFile.RelativePath); if (newFileName.PathNotEquals(existingFileName)) { try { _diskProvider.MoveFile(existingFileName, newFileName); - metadataFile.RelativePath = series.Path.GetRelativePath(newFileName); + metadataFile.RelativePath = artist.Path.GetRelativePath(newFileName); movedFiles.Add(metadataFile); } catch (Exception ex) { - _logger.Warn(ex, "Unable to move metadata file: {0}", existingFileName); + _logger.Warn(ex, "Unable to move metadata file after rename: {0}", existingFileName); + } + } + } + } + + + foreach (var trackFile in trackFiles) + { + var metadataFilesForConsumer = GetMetadataFilesForConsumer(consumer, metadataFiles).Where(m => m.TrackFileId == trackFile.Id).ToList(); + + foreach (var metadataFile in metadataFilesForConsumer) + { + var newFileName = consumer.GetFilenameAfterMove(artist, trackFile, metadataFile); + var existingFileName = Path.Combine(artist.Path, metadataFile.RelativePath); + + if (newFileName.PathNotEquals(existingFileName)) + { + try + { + _diskProvider.MoveFile(existingFileName, newFileName); + metadataFile.RelativePath = artist.Path.GetRelativePath(newFileName); + movedFiles.Add(metadataFile); + } + catch (Exception ex) + { + _logger.Warn(ex, "Unable to move metadata file after rename: {0}", existingFileName); } } } @@ -171,40 +213,40 @@ namespace NzbDrone.Core.Extras.Metadata return movedFiles; } - public override ExtraFile Import(Series series, EpisodeFile episodeFile, string path, string extension, bool readOnly) + public override ExtraFile Import(Artist artist, TrackFile trackFile, string path, string extension, bool readOnly) { return null; } - private List GetMetadataFilesForConsumer(IMetadata consumer, List seriesMetadata) + private List GetMetadataFilesForConsumer(IMetadata consumer, List artistMetadata) { - return seriesMetadata.Where(c => c.Consumer == consumer.GetType().Name).ToList(); + return artistMetadata.Where(c => c.Consumer == consumer.GetType().Name).ToList(); } - private MetadataFile ProcessSeriesMetadata(IMetadata consumer, Series series, List existingMetadataFiles) + private MetadataFile ProcessArtistMetadata(IMetadata consumer, Artist artist, List existingMetadataFiles) { - var seriesMetadata = consumer.SeriesMetadata(series); + var artistMetadata = consumer.ArtistMetadata(artist); - if (seriesMetadata == null) + if (artistMetadata == null) { return null; } - var hash = seriesMetadata.Contents.SHA256Hash(); + var hash = artistMetadata.Contents.SHA256Hash(); - var metadata = GetMetadataFile(series, existingMetadataFiles, e => e.Type == MetadataType.SeriesMetadata) ?? + var metadata = GetMetadataFile(artist, existingMetadataFiles, e => e.Type == MetadataType.ArtistMetadata) ?? new MetadataFile { - SeriesId = series.Id, + ArtistId = artist.Id, Consumer = consumer.GetType().Name, - Type = MetadataType.SeriesMetadata + Type = MetadataType.ArtistMetadata }; if (hash == metadata.Hash) { - if (seriesMetadata.RelativePath != metadata.RelativePath) + if (artistMetadata.RelativePath != metadata.RelativePath) { - metadata.RelativePath = seriesMetadata.RelativePath; + metadata.RelativePath = artistMetadata.RelativePath; return metadata; } @@ -212,53 +254,103 @@ namespace NzbDrone.Core.Extras.Metadata return null; } - var fullPath = Path.Combine(series.Path, seriesMetadata.RelativePath); + var fullPath = Path.Combine(artist.Path, artistMetadata.RelativePath); + + _otherExtraFileRenamer.RenameOtherExtraFile(artist, fullPath); - _logger.Debug("Writing Series Metadata to: {0}", fullPath); - SaveMetadataFile(fullPath, seriesMetadata.Contents); + _logger.Debug("Writing Artist Metadata to: {0}", fullPath); + SaveMetadataFile(fullPath, artistMetadata.Contents); metadata.Hash = hash; - metadata.RelativePath = seriesMetadata.RelativePath; + metadata.RelativePath = artistMetadata.RelativePath; metadata.Extension = Path.GetExtension(fullPath); return metadata; } - private MetadataFile ProcessEpisodeMetadata(IMetadata consumer, Series series, EpisodeFile episodeFile, List existingMetadataFiles) + private MetadataFile ProcessAlbumMetadata(IMetadata consumer, Artist artist, Album album, string albumPath, List existingMetadataFiles) { - var episodeMetadata = consumer.EpisodeMetadata(series, episodeFile); + var albumMetadata = consumer.AlbumMetadata(artist, album, albumPath); - if (episodeMetadata == null) + if (albumMetadata == null) { return null; } - var fullPath = Path.Combine(series.Path, episodeMetadata.RelativePath); + var hash = albumMetadata.Contents.SHA256Hash(); + + var metadata = GetMetadataFile(artist, existingMetadataFiles, e => e.Type == MetadataType.AlbumMetadata && e.AlbumId == album.Id) ?? + new MetadataFile + { + ArtistId = artist.Id, + AlbumId = album.Id, + Consumer = consumer.GetType().Name, + Type = MetadataType.AlbumMetadata + }; + + if (hash == metadata.Hash) + { + if (albumMetadata.RelativePath != metadata.RelativePath) + { + metadata.RelativePath = albumMetadata.RelativePath; + + return metadata; + } + + return null; + } + + var fullPath = Path.Combine(artist.Path, albumMetadata.RelativePath); + + _otherExtraFileRenamer.RenameOtherExtraFile(artist, fullPath); - var existingMetadata = GetMetadataFile(series, existingMetadataFiles, c => c.Type == MetadataType.EpisodeMetadata && - c.EpisodeFileId == episodeFile.Id); + _logger.Debug("Writing Album Metadata to: {0}", fullPath); + SaveMetadataFile(fullPath, albumMetadata.Contents); + + metadata.Hash = hash; + metadata.RelativePath = albumMetadata.RelativePath; + metadata.Extension = Path.GetExtension(fullPath); + + return metadata; + } + + private MetadataFile ProcessTrackMetadata(IMetadata consumer, Artist artist, TrackFile trackFile, List existingMetadataFiles) + { + var trackMetadata = consumer.TrackMetadata(artist, trackFile); + + if (trackMetadata == null) + { + return null; + } + + var fullPath = Path.Combine(artist.Path, trackMetadata.RelativePath); + + _otherExtraFileRenamer.RenameOtherExtraFile(artist, fullPath); + + var existingMetadata = GetMetadataFile(artist, existingMetadataFiles, c => c.Type == MetadataType.TrackMetadata && + c.TrackFileId == trackFile.Id); if (existingMetadata != null) { - var existingFullPath = Path.Combine(series.Path, existingMetadata.RelativePath); + var existingFullPath = Path.Combine(artist.Path, existingMetadata.RelativePath); if (fullPath.PathNotEquals(existingFullPath)) { _diskTransferService.TransferFile(existingFullPath, fullPath, TransferMode.Move); - existingMetadata.RelativePath = episodeMetadata.RelativePath; + existingMetadata.RelativePath = trackMetadata.RelativePath; } } - var hash = episodeMetadata.Contents.SHA256Hash(); + var hash = trackMetadata.Contents.SHA256Hash(); var metadata = existingMetadata ?? new MetadataFile { - SeriesId = series.Id, - SeasonNumber = episodeFile.SeasonNumber, - EpisodeFileId = episodeFile.Id, + ArtistId = artist.Id, + AlbumId = trackFile.AlbumId, + TrackFileId = trackFile.Id, Consumer = consumer.GetType().Name, - Type = MetadataType.EpisodeMetadata, - RelativePath = episodeMetadata.RelativePath, + Type = MetadataType.TrackMetadata, + RelativePath = trackMetadata.RelativePath, Extension = Path.GetExtension(fullPath) }; @@ -267,40 +359,42 @@ namespace NzbDrone.Core.Extras.Metadata return null; } - _logger.Debug("Writing Episode Metadata to: {0}", fullPath); - SaveMetadataFile(fullPath, episodeMetadata.Contents); + _logger.Debug("Writing Track Metadata to: {0}", fullPath); + SaveMetadataFile(fullPath, trackMetadata.Contents); metadata.Hash = hash; return metadata; } - private List ProcessSeriesImages(IMetadata consumer, Series series, List existingMetadataFiles) + private List ProcessArtistImages(IMetadata consumer, Artist artist, List existingMetadataFiles) { var result = new List(); - foreach (var image in consumer.SeriesImages(series)) + foreach (var image in consumer.ArtistImages(artist)) { - var fullPath = Path.Combine(series.Path, image.RelativePath); + var fullPath = Path.Combine(artist.Path, image.RelativePath); if (_diskProvider.FileExists(fullPath)) { - _logger.Debug("Series image already exists: {0}", fullPath); + _logger.Debug("Artist image already exists: {0}", fullPath); continue; } - var metadata = GetMetadataFile(series, existingMetadataFiles, c => c.Type == MetadataType.SeriesImage && + _otherExtraFileRenamer.RenameOtherExtraFile(artist, fullPath); + + var metadata = GetMetadataFile(artist, existingMetadataFiles, c => c.Type == MetadataType.ArtistImage && c.RelativePath == image.RelativePath) ?? new MetadataFile { - SeriesId = series.Id, + ArtistId = artist.Id, Consumer = consumer.GetType().Name, - Type = MetadataType.SeriesImage, + Type = MetadataType.ArtistImage, RelativePath = image.RelativePath, Extension = Path.GetExtension(fullPath) }; - DownloadImage(series, image); + DownloadImage(artist, image); result.Add(metadata); } @@ -308,96 +402,47 @@ namespace NzbDrone.Core.Extras.Metadata return result; } - private List ProcessSeasonImages(IMetadata consumer, Series series, List existingMetadataFiles) - { - var result = new List(); - - foreach (var season in series.Seasons) - { - foreach (var image in consumer.SeasonImages(series, season)) - { - var fullPath = Path.Combine(series.Path, image.RelativePath); - - if (_diskProvider.FileExists(fullPath)) - { - _logger.Debug("Season image already exists: {0}", fullPath); - continue; - } - - var metadata = GetMetadataFile(series, existingMetadataFiles, c => c.Type == MetadataType.SeasonImage && - c.SeasonNumber == season.SeasonNumber && - c.RelativePath == image.RelativePath) ?? - new MetadataFile - { - SeriesId = series.Id, - SeasonNumber = season.SeasonNumber, - Consumer = consumer.GetType().Name, - Type = MetadataType.SeasonImage, - RelativePath = image.RelativePath, - Extension = Path.GetExtension(fullPath) - }; - - DownloadImage(series, image); - - result.Add(metadata); - } - } - - return result; - } - - private List ProcessEpisodeImages(IMetadata consumer, Series series, EpisodeFile episodeFile, List existingMetadataFiles) + private List ProcessAlbumImages(IMetadata consumer, Artist artist, Album album, string albumFolder, List existingMetadataFiles) { var result = new List(); - foreach (var image in consumer.EpisodeImages(series, episodeFile)) + foreach (var image in consumer.AlbumImages(artist, album, albumFolder)) { - var fullPath = Path.Combine(series.Path, image.RelativePath); + var fullPath = Path.Combine(artist.Path, image.RelativePath); if (_diskProvider.FileExists(fullPath)) { - _logger.Debug("Episode image already exists: {0}", fullPath); + _logger.Debug("Album image already exists: {0}", fullPath); continue; } - var existingMetadata = GetMetadataFile(series, existingMetadataFiles, c => c.Type == MetadataType.EpisodeImage && - c.EpisodeFileId == episodeFile.Id); - - if (existingMetadata != null) - { - var existingFullPath = Path.Combine(series.Path, existingMetadata.RelativePath); - if (fullPath.PathNotEquals(existingFullPath)) - { - _diskTransferService.TransferFile(existingFullPath, fullPath, TransferMode.Move); - existingMetadata.RelativePath = image.RelativePath; + _otherExtraFileRenamer.RenameOtherExtraFile(artist, fullPath); - return new List{ existingMetadata }; - } - } - - var metadata = existingMetadata ?? - new MetadataFile - { - SeriesId = series.Id, - SeasonNumber = episodeFile.SeasonNumber, - EpisodeFileId = episodeFile.Id, - Consumer = consumer.GetType().Name, - Type = MetadataType.EpisodeImage, - RelativePath = image.RelativePath, - Extension = Path.GetExtension(fullPath) - }; + var metadata = GetMetadataFile(artist, existingMetadataFiles, c => c.Type == MetadataType.AlbumImage && + c.AlbumId == album.Id && + c.RelativePath == image.RelativePath) ?? + new MetadataFile + { + ArtistId = artist.Id, + AlbumId = album.Id, + Consumer = consumer.GetType().Name, + Type = MetadataType.AlbumImage, + RelativePath = image.RelativePath, + Extension = Path.GetExtension(fullPath) + }; - DownloadImage(series, image); + DownloadImage(artist, image); result.Add(metadata); } + return result; } - private void DownloadImage(Series series, ImageFileResult image) + private void DownloadImage(Artist artist, ImageFileResult image) { - var fullPath = Path.Combine(series.Path, image.RelativePath); + var fullPath = Path.Combine(artist.Path, image.RelativePath); try { @@ -413,11 +458,11 @@ namespace NzbDrone.Core.Extras.Metadata } catch (WebException ex) { - _logger.Warn(ex, "Couldn't download image {0} for {1}. {2}", image.Url, series, ex.Message); + _logger.Warn(ex, "Couldn't download image {0} for {1}. {2}", image.Url, artist, ex.Message); } catch (Exception ex) { - _logger.Error(ex, "Couldn't download image {0} for {1}. {2}", image.Url, series, ex.Message); + _logger.Error(ex, "Couldn't download image {0} for {1}", image.Url, artist); } } @@ -427,7 +472,7 @@ namespace NzbDrone.Core.Extras.Metadata _mediaFileAttributeService.SetFilePermissions(path); } - private MetadataFile GetMetadataFile(Series series, List existingMetadataFiles, Func predicate) + private MetadataFile GetMetadataFile(Artist artist, List existingMetadataFiles, Func predicate) { var matchingMetadataFiles = existingMetadataFiles.Where(predicate).ToList(); @@ -439,14 +484,14 @@ namespace NzbDrone.Core.Extras.Metadata //Remove duplicate metadata files from DB and disk foreach (var file in matchingMetadataFiles.Skip(1)) { - var path = Path.Combine(series.Path, file.RelativePath); + var path = Path.Combine(artist.Path, file.RelativePath); _logger.Debug("Removing duplicate Metadata file: {0}", path); - _diskProvider.DeleteFile(path); + var subfolder = _diskProvider.GetParentFolder(artist.Path).GetRelativePath(_diskProvider.GetParentFolder(path)); + _recycleBinProvider.DeleteFile(path, subfolder); _metadataFileService.Delete(file.Id); } - return matchingMetadataFiles.First(); } diff --git a/src/NzbDrone.Core/Extras/Metadata/MetadataType.cs b/src/NzbDrone.Core/Extras/Metadata/MetadataType.cs index 849bc31dd..2a827b48e 100644 --- a/src/NzbDrone.Core/Extras/Metadata/MetadataType.cs +++ b/src/NzbDrone.Core/Extras/Metadata/MetadataType.cs @@ -1,12 +1,13 @@ -namespace NzbDrone.Core.Extras.Metadata +namespace NzbDrone.Core.Extras.Metadata { public enum MetadataType { Unknown = 0, - SeriesMetadata = 1, - EpisodeMetadata = 2, - SeriesImage = 3, - SeasonImage = 4, - EpisodeImage = 5 + ArtistMetadata = 1, + TrackMetadata = 2, + ArtistImage = 3, + AlbumImage = 4, + TrackImage = 5, + AlbumMetadata = 6 } } diff --git a/src/NzbDrone.Core/Extras/Others/ExistingOtherExtraImporter.cs b/src/NzbDrone.Core/Extras/Others/ExistingOtherExtraImporter.cs index 6315daeb1..e63baf28a 100644 --- a/src/NzbDrone.Core/Extras/Others/ExistingOtherExtraImporter.cs +++ b/src/NzbDrone.Core/Extras/Others/ExistingOtherExtraImporter.cs @@ -1,56 +1,76 @@ -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.TrackImport.Aggregation; using NzbDrone.Core.Parser; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; +using NzbDrone.Core.Parser.Model; 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; } public override int Order => 2; - public override IEnumerable ProcessFiles(Series series, List filesOnDisk, List importedFiles) + public override IEnumerable ProcessFiles(Artist artist, List filesOnDisk, List importedFiles) { - _logger.Debug("Looking for existing extra files in {0}", series.Path); + _logger.Debug("Looking for existing extra files in {0}", artist.Path); var extraFiles = new List(); - var filterResult = FilterAndClean(series, filesOnDisk, importedFiles); + var filterResult = FilterAndClean(artist, filesOnDisk, importedFiles); foreach (var possibleExtraFile in filterResult.FilesOnDisk) { - var localEpisode = _parsingService.GetLocalEpisode(possibleExtraFile, series); + var extension = Path.GetExtension(possibleExtraFile); - if (localEpisode == null) + if (extension.IsNullOrWhiteSpace()) + { + _logger.Debug("No extension for file: {0}", possibleExtraFile); + continue; + } + + var localTrack = new LocalTrack + { + FileTrackInfo = Parser.Parser.ParseMusicPath(possibleExtraFile), + Artist = artist, + Path = possibleExtraFile + }; + + try + { + _augmentingService.Augment(localTrack, false); + } + catch (AugmentingFailedException) { _logger.Debug("Unable to parse extra file: {0}", possibleExtraFile); continue; } - if (localEpisode.Episodes.Empty()) + if (localTrack.Tracks.Empty()) { - _logger.Debug("Cannot find related episodes for: {0}", possibleExtraFile); + _logger.Debug("Cannot find related tracks for: {0}", possibleExtraFile); continue; } - if (localEpisode.Episodes.DistinctBy(e => e.EpisodeFileId).Count() > 1) + if (localTrack.Tracks.DistinctBy(e => e.TrackFileId).Count() > 1) { _logger.Debug("Extra file: {0} does not match existing files.", possibleExtraFile); continue; @@ -58,11 +78,11 @@ namespace NzbDrone.Core.Extras.Others var extraFile = new OtherExtraFile { - SeriesId = series.Id, - SeasonNumber = localEpisode.SeasonNumber, - EpisodeFileId = localEpisode.Episodes.First().EpisodeFileId, - RelativePath = series.Path.GetRelativePath(possibleExtraFile), - Extension = Path.GetExtension(possibleExtraFile) + ArtistId = artist.Id, + AlbumId = localTrack.Album.Id, + TrackFileId = localTrack.Tracks.First().TrackFileId, + RelativePath = artist.Path.GetRelativePath(possibleExtraFile), + Extension = extension }; extraFiles.Add(extraFile); diff --git a/src/NzbDrone.Core/Extras/Others/OtherExtraFileRenamer.cs b/src/NzbDrone.Core/Extras/Others/OtherExtraFileRenamer.cs new file mode 100644 index 000000000..8231f9563 --- /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.Music; + +namespace NzbDrone.Core.Extras.Others +{ + public interface IOtherExtraFileRenamer + { + void RenameOtherExtraFile(Artist artist, string path); + } + + public class OtherExtraFileRenamer : IOtherExtraFileRenamer + { + private readonly Logger _logger; + private readonly IDiskProvider _diskProvider; + private readonly IRecycleBinProvider _recycleBinProvider; + private readonly IArtistService _artistService; + private readonly IOtherExtraFileService _otherExtraFileService; + + public OtherExtraFileRenamer(IOtherExtraFileService otherExtraFileService, + IArtistService artistService, + IRecycleBinProvider recycleBinProvider, + IDiskProvider diskProvider, + Logger logger) + { + _logger = logger; + _diskProvider = diskProvider; + _recycleBinProvider = recycleBinProvider; + _artistService = artistService; + _otherExtraFileService = otherExtraFileService; + } + + public void RenameOtherExtraFile(Artist artist, string path) + { + if (!_diskProvider.FileExists(path)) + { + return; + } + + var relativePath = artist.Path.GetRelativePath(path); + + var otherExtraFile = _otherExtraFileService.FindByPath(relativePath); + if (otherExtraFile != null) + { + var newPath = path + "-orig"; + + // Recycle an existing -orig file. + RemoveOtherExtraFile(artist, newPath); + + // Rename the file to .*-orig + _diskProvider.MoveFile(path, newPath); + otherExtraFile.RelativePath = relativePath + "-orig"; + otherExtraFile.Extension += "-orig"; + _otherExtraFileService.Upsert(otherExtraFile); + } + } + + private void RemoveOtherExtraFile(Artist artist, string path) + { + if (!_diskProvider.FileExists(path)) + { + return; + } + + var relativePath = artist.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/OtherExtraFileService.cs b/src/NzbDrone.Core/Extras/Others/OtherExtraFileService.cs index ceeb15ff8..063d75f86 100644 --- a/src/NzbDrone.Core/Extras/Others/OtherExtraFileService.cs +++ b/src/NzbDrone.Core/Extras/Others/OtherExtraFileService.cs @@ -1,8 +1,8 @@ -using NLog; +using NLog; using NzbDrone.Common.Disk; using NzbDrone.Core.Extras.Files; using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Extras.Others { @@ -12,8 +12,8 @@ namespace NzbDrone.Core.Extras.Others public class OtherExtraFileService : ExtraFileService, IOtherExtraFileService { - public OtherExtraFileService(IExtraFileRepository repository, ISeriesService seriesService, IDiskProvider diskProvider, IRecycleBinProvider recycleBinProvider, Logger logger) - : base(repository, seriesService, diskProvider, recycleBinProvider, logger) + public OtherExtraFileService(IExtraFileRepository repository, IArtistService artistService, IDiskProvider diskProvider, IRecycleBinProvider recycleBinProvider, Logger logger) + : base(repository, artistService, diskProvider, recycleBinProvider, logger) { } } diff --git a/src/NzbDrone.Core/Extras/Others/OtherExtraService.cs b/src/NzbDrone.Core/Extras/Others/OtherExtraService.cs index 71b635710..2c4dbaa20 100644 --- a/src/NzbDrone.Core/Extras/Others/OtherExtraService.cs +++ b/src/NzbDrone.Core/Extras/Others/OtherExtraService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -8,78 +8,53 @@ using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; using NzbDrone.Core.Extras.Files; using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Extras.Others { public class OtherExtraService : ExtraFileManager { private readonly IOtherExtraFileService _otherExtraFileService; - private readonly IDiskProvider _diskProvider; - private readonly Logger _logger; public OtherExtraService(IConfigService configService, + IDiskProvider diskProvider, IDiskTransferService diskTransferService, IOtherExtraFileService otherExtraFileService, - IDiskProvider diskProvider, Logger logger) - : base(configService, diskTransferService, otherExtraFileService) + : base(configService, diskProvider, diskTransferService, logger) { _otherExtraFileService = otherExtraFileService; - _diskProvider = diskProvider; - _logger = logger; } public override int Order => 2; - public override IEnumerable CreateAfterSeriesScan(Series series, List episodeFiles) + public override IEnumerable CreateAfterArtistScan(Artist artist, List trackFiles) { return Enumerable.Empty(); } - public override IEnumerable CreateAfterEpisodeImport(Series series, EpisodeFile episodeFile) + public override IEnumerable CreateAfterTrackImport(Artist artist, TrackFile trackFile) { return Enumerable.Empty(); } - public override IEnumerable CreateAfterEpisodeImport(Series series, string seriesFolder, string seasonFolder) + public override IEnumerable CreateAfterTrackImport(Artist artist, Album album, string artistFolder, string albumFolder) { return Enumerable.Empty(); } - public override IEnumerable MoveFilesAfterRename(Series series, List episodeFiles) + public override IEnumerable MoveFilesAfterRename(Artist artist, List trackFiles) { - // TODO: Remove - // We don't want to move files after rename yet. - - return Enumerable.Empty(); - - var extraFiles = _otherExtraFileService.GetFilesBySeries(series.Id); + var extraFiles = _otherExtraFileService.GetFilesByArtist(artist.Id); var movedFiles = new List(); - foreach (var episodeFile in episodeFiles) + foreach (var trackFile in trackFiles) { - var extraFilesForEpisodeFile = extraFiles.Where(m => m.EpisodeFileId == episodeFile.Id).ToList(); + var extraFilesForTrackFile = extraFiles.Where(m => m.TrackFileId == trackFile.Id).ToList(); - foreach (var extraFile in extraFilesForEpisodeFile) + foreach (var extraFile in extraFilesForTrackFile) { - var existingFileName = Path.Combine(series.Path, extraFile.RelativePath); - var extension = Path.GetExtension(existingFileName).TrimStart('.'); - var newFileName = Path.ChangeExtension(Path.Combine(series.Path, episodeFile.RelativePath), extension); - - if (newFileName.PathNotEquals(existingFileName)) - { - try - { - _diskProvider.MoveFile(existingFileName, newFileName); - extraFile.RelativePath = series.Path.GetRelativePath(newFileName); - movedFiles.Add(extraFile); - } - catch (Exception ex) - { - _logger.Warn(ex, "Unable to move extra file: {0}", existingFileName); - } - } + movedFiles.AddIfNotNull(MoveFile(artist, trackFile, extraFile)); } } @@ -88,15 +63,9 @@ namespace NzbDrone.Core.Extras.Others return movedFiles; } - public override ExtraFile Import(Series series, EpisodeFile episodeFile, string path, string extension, bool readOnly) + public override ExtraFile Import(Artist artist, TrackFile trackFile, 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, extension, readOnly); + var extraFile = ImportFile(artist, trackFile, 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 deleted file mode 100644 index d3ae8d46b..000000000 --- a/src/NzbDrone.Core/Extras/Subtitles/ExistingSubtitleImporter.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Linq; -using NLog; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Extras.Files; -using NzbDrone.Core.Parser; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Extras.Subtitles -{ - public class ExistingSubtitleImporter : ImportExistingExtraFilesBase - { - private readonly IExtraFileService _subtitleFileService; - private readonly IParsingService _parsingService; - private readonly Logger _logger; - - public ExistingSubtitleImporter(IExtraFileService subtitleFileService, - IParsingService parsingService, - Logger logger) - : base (subtitleFileService) - { - _subtitleFileService = subtitleFileService; - _parsingService = parsingService; - _logger = logger; - } - - public override int Order => 1; - - public override IEnumerable ProcessFiles(Series series, List filesOnDisk, List importedFiles) - { - _logger.Debug("Looking for existing subtitle files in {0}", series.Path); - - var subtitleFiles = new List(); - var filterResult = FilterAndClean(series, filesOnDisk, importedFiles); - - foreach (var possibleSubtitleFile in filterResult.FilesOnDisk) - { - var extension = Path.GetExtension(possibleSubtitleFile); - - if (SubtitleFileExtensions.Extensions.Contains(extension)) - { - var localEpisode = _parsingService.GetLocalEpisode(possibleSubtitleFile, series); - - if (localEpisode == null) - { - _logger.Debug("Unable to parse subtitle file: {0}", possibleSubtitleFile); - continue; - } - - if (localEpisode.Episodes.Empty()) - { - _logger.Debug("Cannot find related episodes for: {0}", possibleSubtitleFile); - continue; - } - - if (localEpisode.Episodes.DistinctBy(e => e.EpisodeFileId).Count() > 1) - { - _logger.Debug("Subtitle file: {0} does not match existing files.", possibleSubtitleFile); - continue; - } - - var subtitleFile = new SubtitleFile - { - SeriesId = series.Id, - SeasonNumber = localEpisode.SeasonNumber, - EpisodeFileId = localEpisode.Episodes.First().EpisodeFileId, - RelativePath = series.Path.GetRelativePath(possibleSubtitleFile), - Language = LanguageParser.ParseSubtitleLanguage(possibleSubtitleFile), - Extension = extension - }; - - subtitleFiles.Add(subtitleFile); - } - } - - _logger.Info("Found {0} existing subtitle files", subtitleFiles.Count); - _subtitleFileService.Upsert(subtitleFiles); - - // Return files that were just imported along with files that were - // previously imported so previously imported files aren't imported twice - - return subtitleFiles.Concat(filterResult.PreviouslyImported); - } - } -} diff --git a/src/NzbDrone.Core/Extras/Subtitles/ImportedSubtitleFiles.cs b/src/NzbDrone.Core/Extras/Subtitles/ImportedSubtitleFiles.cs deleted file mode 100644 index 287ebdb68..000000000 --- a/src/NzbDrone.Core/Extras/Subtitles/ImportedSubtitleFiles.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Core.Extras.Files; - -namespace NzbDrone.Core.Extras.Subtitles -{ - public class ImportedSubtitleFiles - { - public List SourceFiles { get; set; } - public List SubtitleFiles { get; set; } - - public ImportedSubtitleFiles() - { - SourceFiles = new List(); - SubtitleFiles = new List(); - } - } -} diff --git a/src/NzbDrone.Core/Extras/Subtitles/SubtitleFile.cs b/src/NzbDrone.Core/Extras/Subtitles/SubtitleFile.cs deleted file mode 100644 index 0ccd3ede6..000000000 --- a/src/NzbDrone.Core/Extras/Subtitles/SubtitleFile.cs +++ /dev/null @@ -1,10 +0,0 @@ -using NzbDrone.Core.Extras.Files; -using NzbDrone.Core.Parser; - -namespace NzbDrone.Core.Extras.Subtitles -{ - public class SubtitleFile : ExtraFile - { - public Language Language { get; set; } - } -} diff --git a/src/NzbDrone.Core/Extras/Subtitles/SubtitleFileExtensions.cs b/src/NzbDrone.Core/Extras/Subtitles/SubtitleFileExtensions.cs deleted file mode 100644 index 423d14656..000000000 --- a/src/NzbDrone.Core/Extras/Subtitles/SubtitleFileExtensions.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Collections.Generic; - -namespace NzbDrone.Core.Extras.Subtitles -{ - public static class SubtitleFileExtensions - { - private static HashSet _fileExtensions; - - static SubtitleFileExtensions() - { - _fileExtensions = new HashSet - { - ".aqt", - ".ass", - ".idx", - ".jss", - ".psb", - ".rt", - ".smi", - ".srt", - ".ssa", - ".sub", - ".txt", - ".utf", - ".utf8", - ".utf-8" - }; - } - - public static HashSet Extensions => _fileExtensions; - } -} diff --git a/src/NzbDrone.Core/Extras/Subtitles/SubtitleFileRepository.cs b/src/NzbDrone.Core/Extras/Subtitles/SubtitleFileRepository.cs deleted file mode 100644 index 9b87fa9e0..000000000 --- a/src/NzbDrone.Core/Extras/Subtitles/SubtitleFileRepository.cs +++ /dev/null @@ -1,18 +0,0 @@ -using NzbDrone.Core.Datastore; -using NzbDrone.Core.Extras.Files; -using NzbDrone.Core.Messaging.Events; - -namespace NzbDrone.Core.Extras.Subtitles -{ - public interface ISubtitleFileRepository : IExtraFileRepository - { - } - - public class SubtitleFileRepository : ExtraFileRepository, ISubtitleFileRepository - { - public SubtitleFileRepository(IMainDatabase database, IEventAggregator eventAggregator) - : base(database, eventAggregator) - { - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/Extras/Subtitles/SubtitleFileService.cs b/src/NzbDrone.Core/Extras/Subtitles/SubtitleFileService.cs deleted file mode 100644 index ac7d4da2b..000000000 --- a/src/NzbDrone.Core/Extras/Subtitles/SubtitleFileService.cs +++ /dev/null @@ -1,20 +0,0 @@ -using NLog; -using NzbDrone.Common.Disk; -using NzbDrone.Core.Extras.Files; -using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Extras.Subtitles -{ - public interface ISubtitleFileService : IExtraFileService - { - } - - public class SubtitleFileService : ExtraFileService, ISubtitleFileService - { - public SubtitleFileService(IExtraFileRepository repository, ISeriesService seriesService, IDiskProvider diskProvider, IRecycleBinProvider recycleBinProvider, Logger logger) - : base(repository, seriesService, diskProvider, recycleBinProvider, logger) - { - } - } -} diff --git a/src/NzbDrone.Core/Extras/Subtitles/SubtitleService.cs b/src/NzbDrone.Core/Extras/Subtitles/SubtitleService.cs deleted file mode 100644 index 639775048..000000000 --- a/src/NzbDrone.Core/Extras/Subtitles/SubtitleService.cs +++ /dev/null @@ -1,145 +0,0 @@ -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.Configuration; -using NzbDrone.Core.Extras.Files; -using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.Parser; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Extras.Subtitles -{ - public class SubtitleService : ExtraFileManager - { - private readonly ISubtitleFileService _subtitleFileService; - private readonly IDiskProvider _diskProvider; - private readonly Logger _logger; - - public SubtitleService(IConfigService configService, - IDiskTransferService diskTransferService, - ISubtitleFileService subtitleFileService, - IDiskProvider diskProvider, - Logger logger) - : base(configService, diskTransferService, subtitleFileService) - { - _subtitleFileService = subtitleFileService; - _diskProvider = diskProvider; - _logger = logger; - } - - public override int Order => 1; - - public override IEnumerable CreateAfterSeriesScan(Series series, List episodeFiles) - { - return Enumerable.Empty(); - } - - public override IEnumerable CreateAfterEpisodeImport(Series series, EpisodeFile episodeFile) - { - return Enumerable.Empty(); - } - - public override IEnumerable CreateAfterEpisodeImport(Series series, string seriesFolder, string seasonFolder) - { - return Enumerable.Empty(); - } - - public override IEnumerable MoveFilesAfterRename(Series series, List episodeFiles) - { - // TODO: Remove - // We don't want to move files after rename yet. - - return Enumerable.Empty(); - - var subtitleFiles = _subtitleFileService.GetFilesBySeries(series.Id); - - var movedFiles = new List(); - - foreach (var episodeFile in episodeFiles) - { - var groupedExtraFilesForEpisodeFile = subtitleFiles.Where(m => m.EpisodeFileId == episodeFile.Id) - .GroupBy(s => s.Language + s.Extension).ToList(); - - foreach (var group in groupedExtraFilesForEpisodeFile) - { - var groupCount = group.Count(); - var copy = 1; - - if (groupCount > 1) - { - _logger.Warn("Multiple subtitle files found with the same language and extension for {0}", Path.Combine(series.Path, episodeFile.RelativePath)); - } - - foreach (var extraFile in group) - { - var existingFileName = Path.Combine(series.Path, extraFile.RelativePath); - var extension = GetExtension(extraFile, existingFileName, copy, groupCount > 1); - var newFileName = Path.ChangeExtension(Path.Combine(series.Path, episodeFile.RelativePath), extension); - - if (newFileName.PathNotEquals(existingFileName)) - { - try - { - _diskProvider.MoveFile(existingFileName, newFileName); - extraFile.RelativePath = series.Path.GetRelativePath(newFileName); - movedFiles.Add(extraFile); - } - catch (Exception ex) - { - _logger.Warn(ex, "Unable to move subtitle file: {0}", existingFileName); - } - } - - copy++; - } - } - } - - _subtitleFileService.Upsert(movedFiles); - - return movedFiles; - } - - public override ExtraFile Import(Series series, EpisodeFile episodeFile, string path, string extension, bool readOnly) - { - if (SubtitleFileExtensions.Extensions.Contains(Path.GetExtension(path))) - { - var subtitleFile = ImportFile(series, episodeFile, path, extension, readOnly); - subtitleFile.Language = LanguageParser.ParseSubtitleLanguage(path); - - _subtitleFileService.Upsert(subtitleFile); - - return subtitleFile; - } - - return null; - } - - private string GetExtension(SubtitleFile extraFile, string existingFileName, int copy, bool multipleCopies = false) - { - var fileExtension = Path.GetExtension(existingFileName); - var extensionBuilder = new StringBuilder(); - - if (multipleCopies) - { - extensionBuilder.Append(copy); - extensionBuilder.Append("."); - } - - if (extraFile.Language != Language.Unknown) - { - extensionBuilder.Append(IsoLanguages.Get(extraFile.Language).TwoLetterCode); - extensionBuilder.Append("."); - } - - extensionBuilder.Append(fileExtension.TrimStart('.')); - - return extensionBuilder.ToString(); - } - } -} diff --git a/src/NzbDrone.Core/Fluent.cs b/src/NzbDrone.Core/Fluent.cs index 6e2e3d2b2..98e38a1d3 100644 --- a/src/NzbDrone.Core/Fluent.cs +++ b/src/NzbDrone.Core/Fluent.cs @@ -20,6 +20,11 @@ namespace NzbDrone.Core return actual; } + public static long Kilobits(this int kilobits) + { + return Convert.ToInt64(kilobits * 128L); + } + public static long Megabytes(this int megabytes) { return Convert.ToInt64(megabytes * 1024L * 1024L); @@ -30,6 +35,11 @@ namespace NzbDrone.Core return Convert.ToInt64(gigabytes * 1024L * 1024L * 1024L); } + public static long Kilobits(this double kilobits) + { + return Convert.ToInt64(kilobits * 128L); + } + public static long Megabytes(this double megabytes) { return Convert.ToInt64(megabytes * 1024L * 1024L); diff --git a/src/NzbDrone.Core/HealthCheck/CheckOnAttribute.cs b/src/NzbDrone.Core/HealthCheck/CheckOnAttribute.cs new file mode 100644 index 000000000..dd1dcb3be --- /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..d4f9a7cc7 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.EnvironmentInfo; using NzbDrone.Common.Extensions; +using NzbDrone.Core.Configuration.Events; namespace NzbDrone.Core.HealthCheck.Checks { @@ -22,7 +23,6 @@ namespace NzbDrone.Core.HealthCheck.Checks return new HealthCheck(GetType()); } - - public override bool CheckOnConfigChange => false; + } } diff --git a/src/NzbDrone.Core/HealthCheck/Checks/DotnetVersionCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/DotnetVersionCheck.cs new file mode 100644 index 000000000..4736e670b --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/Checks/DotnetVersionCheck.cs @@ -0,0 +1,60 @@ +using System; +using NLog; +using NzbDrone.Common.EnvironmentInfo; + +namespace NzbDrone.Core.HealthCheck.Checks +{ + public class DotnetVersionCheck : HealthCheckBase + { + private readonly IPlatformInfo _platformInfo; + private readonly IOsInfo _osInfo; + private readonly Logger _logger; + + public DotnetVersionCheck(IPlatformInfo platformInfo, IOsInfo osInfo, Logger logger) + { + _platformInfo = platformInfo; + _osInfo = osInfo; + _logger = logger; + } + + public override HealthCheck Check() + { + if (!PlatformInfo.IsDotNet) + { + return new HealthCheck(GetType()); + } + + var dotnetVersion = _platformInfo.Version; + + // Target .Net version, which would allow us to increase our target framework + var targetVersion = new Version("4.7.2"); + if (Version.TryParse(_osInfo.Version, out var osVersion) && osVersion < new Version("10.0.14393")) + { + // Windows 10 LTSB 1511 and before do not support 4.7.x + targetVersion = new Version("4.6.2"); + } + + if (dotnetVersion >= targetVersion) + { + _logger.Debug("Dotnet version is {0} or better: {1}", targetVersion, dotnetVersion); + return new HealthCheck(GetType()); + } + + // Supported .net version but below our desired target + var stableVersion = new Version("4.6.2"); + if (dotnetVersion >= stableVersion) + { + _logger.Debug("Dotnet version is {0} or better: {1}", stableVersion, dotnetVersion); + return new HealthCheck(GetType(), HealthCheckResult.Notice, + $"Currently installed .Net Framework {dotnetVersion} is supported but we recommend upgrading to at least {targetVersion}.", + "#currently-installed-net-framework-is-supported-but-upgrading-is-recommended"); + } + + return new HealthCheck(GetType(), HealthCheckResult.Error, + $"Currently installed .Net Framework {dotnetVersion} is old and unsupported. Please upgrade the .Net Framework to at least {targetVersion}.", + "#currently-installed-net-framework-is-old-and-unsupported"); + } + + public override bool CheckOnSchedule => false; + } +} diff --git a/src/NzbDrone.Core/HealthCheck/Checks/DownloadClientCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/DownloadClientCheck.cs index d99eed1a3..e014a7522 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/DownloadClientCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/DownloadClientCheck.cs @@ -1,10 +1,14 @@ -using System; +using System; using System.Linq; using NLog; using NzbDrone.Core.Download; +using NzbDrone.Core.ThingiProvider.Events; namespace NzbDrone.Core.HealthCheck.Checks { + [CheckOn(typeof(ProviderAddedEvent))] + [CheckOn(typeof(ProviderUpdatedEvent))] + [CheckOn(typeof(ProviderDeletedEvent))] public class DownloadClientCheck : HealthCheckBase { private readonly IProvideDownloadClient _downloadClientProvider; @@ -33,11 +37,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..91b9b122d --- /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 deleted file mode 100644 index ffae0b4f6..000000000 --- a/src/NzbDrone.Core/HealthCheck/Checks/DroneFactoryCheck.cs +++ /dev/null @@ -1,42 +0,0 @@ -using NzbDrone.Common.Disk; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Configuration; - -namespace NzbDrone.Core.HealthCheck.Checks -{ - public class DroneFactoryCheck : HealthCheckBase - { - private readonly IConfigService _configService; - private readonly IDiskProvider _diskProvider; - - public DroneFactoryCheck(IConfigService configService, IDiskProvider diskProvider) - { - _configService = configService; - _diskProvider = diskProvider; - } - - public override HealthCheck Check() - { - var droneFactoryFolder = _configService.DownloadedEpisodesFolder; - - if (droneFactoryFolder.IsNullOrWhiteSpace()) - { - return new HealthCheck(GetType()); - } - - if (!_diskProvider.FolderExists(droneFactoryFolder)) - { - 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"); - } - - //Todo: Unable to import one or more files/folders from - - return new HealthCheck(GetType()); - } - } -} diff --git a/src/NzbDrone.Core/HealthCheck/Checks/FpcalcCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/FpcalcCheck.cs new file mode 100644 index 000000000..156a4946f --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/Checks/FpcalcCheck.cs @@ -0,0 +1,43 @@ +using System; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Configuration.Events; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.HealthCheck.Checks +{ + [CheckOn(typeof(ConfigSavedEvent))] + public class FpcalcCheck : HealthCheckBase + { + private readonly IFingerprintingService _fingerprintingService; + private readonly IConfigService _configService; + + public FpcalcCheck(IFingerprintingService fingerprintingService, + IConfigService configService) + { + _fingerprintingService = fingerprintingService; + _configService = configService; + } + + public override HealthCheck Check() + { + // always pass if fingerprinting is disabled + if (_configService.AllowFingerprinting == AllowFingerprinting.Never) + { + return new HealthCheck(GetType()); + } + + if (!_fingerprintingService.IsSetup()) + { + return new HealthCheck(GetType(), HealthCheckResult.Warning, $"fpcalc could not be found. Audio fingerprinting disabled.", "#fpcalc-missing"); + } + + var fpcalcVersion = _fingerprintingService.FpcalcVersion(); + if (fpcalcVersion == null || fpcalcVersion < new Version("1.4.3")) + { + return new HealthCheck(GetType(), HealthCheckResult.Warning, $"You have an old version of fpcalc. Please upgrade to 1.4.3.", "#fpcalc-upgrade"); + } + + return new HealthCheck(GetType()); + } + } +} diff --git a/src/NzbDrone.Core/HealthCheck/Checks/ImportListStatusCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/ImportListStatusCheck.cs new file mode 100644 index 000000000..4a3855146 --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/Checks/ImportListStatusCheck.cs @@ -0,0 +1,45 @@ +using System; +using System.Linq; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.ImportLists; +using NzbDrone.Core.ThingiProvider.Events; + +namespace NzbDrone.Core.HealthCheck.Checks +{ + [CheckOn(typeof(ProviderUpdatedEvent))] + [CheckOn(typeof(ProviderDeletedEvent))] + [CheckOn(typeof(ProviderStatusChangedEvent))] + public class ImportListStatusCheck : HealthCheckBase + { + private readonly IImportListFactory _providerFactory; + private readonly IImportListStatusService _providerStatusService; + + public ImportListStatusCheck(IImportListFactory providerFactory, IImportListStatusService 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 { ImportList = i, Status = s }) + .ToList(); + + if (backOffProviders.Empty()) + { + return new HealthCheck(GetType()); + } + + if (backOffProviders.Count == enabledProviders.Count) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, "All import lists are unavailable due to failures", "#import-lists-are-unavailable-due-to-failures"); + } + + return new HealthCheck(GetType(), HealthCheckResult.Warning, string.Format("Import lists unavailable due to failures: {0}", string.Join(", ", backOffProviders.Select(v => v.ImportList.Definition.Name))), "#import-lsits-are-unavailable-due-to-failures"); + } + } +} diff --git a/src/NzbDrone.Core/HealthCheck/Checks/ImportMechanismCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/ImportMechanismCheck.cs index 373d47bfb..c6b6fedd6 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/ImportMechanismCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/ImportMechanismCheck.cs @@ -1,71 +1,37 @@ -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.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; - private readonly IProvideDownloadClient _provideDownloadClient; - public ImportMechanismCheck(IConfigService configService, IProvideDownloadClient provideDownloadClient) + public ImportMechanismCheck(IConfigService configService) { _configService = configService; - _provideDownloadClient = provideDownloadClient; } public override HealthCheck Check() { - var droneFactoryFolder = new OsPath(_configService.DownloadedEpisodesFolder); - var downloadClients = _provideDownloadClient.GetDownloadClients().Select(v => new { downloadClient = v, status = v.GetStatus() }).ToList(); - - var downloadClientIsLocalHost = downloadClients.All(v => v.status.IsLocalhost); - var downloadClientOutputInDroneFactory = !droneFactoryFolder.IsEmpty - && downloadClients.Any(v => v.status.OutputRootFolders != null && v.status.OutputRootFolders.Any(droneFactoryFolder.Contains)); - - if (!_configService.IsDefined("EnableCompletedDownloadHandling")) - { - // Migration helper logic - if (!downloadClientIsLocalHost) - { - return new HealthCheck(GetType(), HealthCheckResult.Warning, "Enable Completed Download Handling if possible (Multi-Computer unsupported)", "Migrating-to-Completed-Download-Handling#Unsupported-download-client-on-different-computer"); - } - - if (downloadClients.All(v => v.downloadClient is Sabnzbd)) - { - // With Sabnzbd we can check if the category should be changed. - if (downloadClientOutputInDroneFactory) - { - return new HealthCheck(GetType(), HealthCheckResult.Warning, "Enable Completed Download Handling if possible (Sabnzbd - Conflicting Category)", "Migrating-to-Completed-Download-Handling#sabnzbd-conflicting-download-client-category"); - } - - return new HealthCheck(GetType(), HealthCheckResult.Warning, "Enable Completed Download Handling if possible (Sabnzbd)", "Migrating-to-Completed-Download-Handling#sabnzbd-enable-completed-download-handling"); - } - if (downloadClients.All(v => v.downloadClient is Nzbget)) - { - // With Nzbget we can check if the category should be changed. - if (downloadClientOutputInDroneFactory) - { - return new HealthCheck(GetType(), HealthCheckResult.Warning, "Enable Completed Download Handling if possible (Nzbget - Conflicting Category)", "Migrating-to-Completed-Download-Handling#nzbget-conflicting-download-client-category"); - } - - return new HealthCheck(GetType(), HealthCheckResult.Warning, "Enable Completed Download Handling if possible (Nzbget)", "Migrating-to-Completed-Download-Handling#nzbget-enable-completed-download-handling"); - } - return new HealthCheck(GetType(), HealthCheckResult.Warning, "Enable Completed Download Handling if possible", "Migrating-to-Completed-Download-Handling"); - } - - if (!_configService.EnableCompletedDownloadHandling && droneFactoryFolder.IsEmpty) + if (!_configService.EnableCompletedDownloadHandling) { - return new HealthCheck(GetType(), HealthCheckResult.Warning, "Enable Completed Download Handling or configure Drone factory"); + return new HealthCheck(GetType(), HealthCheckResult.Warning, "Enable Completed Download Handling"); } - return new HealthCheck(GetType()); } } + + public class ImportMechanismCheckStatus + { + public IDownloadClient DownloadClient { get; set; } + public DownloadClientInfo Status { get; set; } + } } diff --git a/src/NzbDrone.Core/HealthCheck/Checks/IndexerCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/IndexerCheck.cs deleted file mode 100644 index 88347b690..000000000 --- a/src/NzbDrone.Core/HealthCheck/Checks/IndexerCheck.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Linq; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Indexers; - -namespace NzbDrone.Core.HealthCheck.Checks -{ - public class IndexerCheck : HealthCheckBase - { - private readonly IIndexerFactory _indexerFactory; - - public IndexerCheck(IIndexerFactory indexerFactory) - { - _indexerFactory = indexerFactory; - } - - public override HealthCheck Check() - { - var enabled = _indexerFactory.GetAvailableProviders(); - var rssEnabled = _indexerFactory.RssEnabled(); - var searchEnabled = _indexerFactory.SearchEnabled(); - - if (enabled.Empty()) - { - return new HealthCheck(GetType(), HealthCheckResult.Error, "No indexers are enabled"); - } - - if (enabled.All(i => i.SupportsRss == false)) - { - return new HealthCheck(GetType(), HealthCheckResult.Warning, "Enabled indexers do not support RSS sync"); - } - - if (enabled.All(i => i.SupportsSearch == false)) - { - return new HealthCheck(GetType(), HealthCheckResult.Warning, "Enabled indexers do not support searching"); - } - - if (rssEnabled.Empty()) - { - return new HealthCheck(GetType(), HealthCheckResult.Warning, "Enabled indexers do not have RSS sync enabled"); - } - - if (searchEnabled.Empty()) - { - return new HealthCheck(GetType(), HealthCheckResult.Warning, "Enabled indexers do not have searching enabled"); - } - - return new HealthCheck(GetType()); - } - } -} diff --git a/src/NzbDrone.Core/HealthCheck/Checks/IndexerRssCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/IndexerRssCheck.cs new file mode 100644 index 000000000..6c4f1f60d --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/Checks/IndexerRssCheck.cs @@ -0,0 +1,40 @@ +using System.Linq; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.ThingiProvider.Events; + +namespace NzbDrone.Core.HealthCheck.Checks +{ + [CheckOn(typeof(ProviderAddedEvent))] + [CheckOn(typeof(ProviderUpdatedEvent))] + [CheckOn(typeof(ProviderDeletedEvent))] + [CheckOn(typeof(ProviderStatusChangedEvent))] + public class IndexerRssCheck : HealthCheckBase + { + private readonly IIndexerFactory _indexerFactory; + + public IndexerRssCheck(IIndexerFactory indexerFactory) + { + _indexerFactory = indexerFactory; + } + + public override HealthCheck Check() + { + var enabled = _indexerFactory.RssEnabled(false); + + if (enabled.Empty()) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, "No indexers available with RSS sync enabled, Lidarr will not grab new releases automatically"); + } + + var active = _indexerFactory.RssEnabled(true); + + if (active.Empty()) + { + return new HealthCheck(GetType(), HealthCheckResult.Warning, "All rss-capable indexers are temporarily unavailable due to recent indexer errors"); + } + + return new HealthCheck(GetType()); + } + } +} diff --git a/src/NzbDrone.Core/HealthCheck/Checks/IndexerSearchCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/IndexerSearchCheck.cs new file mode 100644 index 000000000..b15980ee3 --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/Checks/IndexerSearchCheck.cs @@ -0,0 +1,47 @@ +using System.Linq; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.ThingiProvider.Events; + +namespace NzbDrone.Core.HealthCheck.Checks +{ + [CheckOn(typeof(ProviderAddedEvent))] + [CheckOn(typeof(ProviderUpdatedEvent))] + [CheckOn(typeof(ProviderDeletedEvent))] + [CheckOn(typeof(ProviderStatusChangedEvent))] + public class IndexerSearchCheck : HealthCheckBase + { + private readonly IIndexerFactory _indexerFactory; + + public IndexerSearchCheck(IIndexerFactory indexerFactory) + { + _indexerFactory = indexerFactory; + } + + public override HealthCheck Check() + { + var automaticSearchEnabled = _indexerFactory.AutomaticSearchEnabled(false); + + if (automaticSearchEnabled.Empty()) + { + return new HealthCheck(GetType(), HealthCheckResult.Warning, "No indexers available with Automatic Search enabled, Lidarr will not provide any automatic search results"); + } + + var interactiveSearchEnabled = _indexerFactory.InteractiveSearchEnabled(false); + + if (interactiveSearchEnabled.Empty()) + { + return new HealthCheck(GetType(), HealthCheckResult.Warning, "No indexers available with Interactive Search enabled, Lidarr will not provide any interactive search results"); + } + + var active = _indexerFactory.AutomaticSearchEnabled(true); + + if (active.Empty()) + { + return new HealthCheck(GetType(), HealthCheckResult.Warning, "All search-capable indexers are temporarily unavailable due to recent indexer errors"); + } + + return new HealthCheck(GetType()); + } + } +} diff --git a/src/NzbDrone.Core/HealthCheck/Checks/IndexerStatusCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/IndexerStatusCheck.cs index 29eadb180..d608686a2 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/IndexerStatusCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/IndexerStatusCheck.cs @@ -1,42 +1,45 @@ -using System; +using System; 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, + s => s.ProviderId, (i, s) => new { Indexer = i, Status = s }) - .Where(v => (v.Status.MostRecentFailure - v.Status.InitialFailure) > TimeSpan.FromHours(1)) .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.Indexer.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 deleted file mode 100644 index 5b5a9f3f4..000000000 --- a/src/NzbDrone.Core/HealthCheck/Checks/MediaInfoDllCheck.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using System.Runtime.CompilerServices; -using NzbDrone.Core.MediaFiles.MediaInfo; - -namespace NzbDrone.Core.HealthCheck.Checks -{ - public class MediaInfoDllCheck : HealthCheckBase - { - [MethodImpl(MethodImplOptions.NoOptimization)] - public override HealthCheck Check() - { - try - { - var mediaInfo = new MediaInfo(); - } - catch (Exception e) - { - return new HealthCheck(GetType(), HealthCheckResult.Warning, $"MediaInfo Library could not be loaded {e.Message}"); - } - - 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..5d9bbce5c --- /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..01e690ac9 --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/Checks/MonoTlsCheck.cs @@ -0,0 +1,46 @@ +using System; +using System.Linq; +using System.Reflection; +using NLog; +using NLog.Fluent; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Instrumentation.Extensions; + +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.8.0") && Environment.GetEnvironmentVariable("MONO_TLS_PROVIDER") == "legacy") + { + _logger.Debug() + .Message("Mono version {0} and legacy TLS provider is selected, recommending user to switch to btls.", monoVersion) + .WriteSentryDebug("LegacyTlsProvider", monoVersion.ToString()) + .Write(); + + return new HealthCheck(GetType(), HealthCheckResult.Warning, "Lidarr Mono 4.x tls workaround still enabled, consider removing MONO_TLS_PROVIDER=legacy environment 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..3afeb6ae0 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/MonoVersionCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/MonoVersionCheck.cs @@ -1,6 +1,4 @@ -using System; -using System.Linq; -using System.Reflection; +using System; using NLog; using NzbDrone.Common.EnvironmentInfo; @@ -26,58 +24,50 @@ namespace NzbDrone.Core.HealthCheck.Checks var monoVersion = _platformInfo.Version; - if (monoVersion == new Version("3.4.0") && HasMonoBug18599()) - { - _logger.Debug("Mono version 3.4.0, checking for Mono bug #18599 returned positive."); - return new HealthCheck(GetType(), HealthCheckResult.Error, "You are running an old and unsupported version of Mono with a known bug. You should upgrade to a higher version"); - } - + // Known buggy Mono versions if (monoVersion == new Version("4.4.0") || monoVersion == new Version("4.4.1")) { _logger.Debug("Mono version {0}", monoVersion); - return new HealthCheck(GetType(), HealthCheckResult.Error, $"Your Mono version {monoVersion} has a bug that causes issues connecting to indexers/download clients. You should upgrade to a higher version"); + return new HealthCheck(GetType(), HealthCheckResult.Error, + $"Currently installed Mono version {monoVersion} has a bug that causes issues connecting to indexers/download clients. You should upgrade to a higher version", + "#currently-installed-mono-version-is-old-and-unsupported"); } - if (monoVersion >= new Version("3.10")) + // Currently best stable Mono version (5.18 gets us .net 4.7.2 support) + var bestVersion = new Version("5.20"); + var targetVersion = new Version("5.18"); + if (monoVersion >= targetVersion) { - _logger.Debug("Mono version is 3.10 or better: {0}", monoVersion); + _logger.Debug("Mono version is {0} or better: {1}", targetVersion, monoVersion); return new HealthCheck(GetType()); } - 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() - { - _logger.Debug("mono version 3.4.0, checking for mono bug #18599."); - var numberFormatterType = Type.GetType("System.NumberFormatter"); - - if (numberFormatterType == null) - { - _logger.Debug("Couldn't find System.NumberFormatter. Aborting test."); - return false; - } - - var fieldInfo = numberFormatterType.GetField("userFormatProvider", - BindingFlags.Static | BindingFlags.NonPublic); - - if (fieldInfo == null) + // Stable Mono versions + var stableVersion = new Version("5.16"); + if (monoVersion >= stableVersion) { - _logger.Debug("userFormatProvider field not found, version likely preceeds the official v3.4.0."); - return false; + _logger.Debug("Mono version is {0} or better: {1}", stableVersion, monoVersion); + return new HealthCheck(GetType(), HealthCheckResult.Notice, + $"Currently installed Mono version {monoVersion} is supported but upgrading to {bestVersion} is recommended.", + "#currently-installed-mono-version-is-supported-but-upgrading-is-recommended"); } - if (fieldInfo.GetCustomAttributes(false).Any(v => v is ThreadStaticAttribute)) + // Old but supported Mono versions, there are known bugs + var supportedVersion = new Version("5.4"); + if (monoVersion >= supportedVersion) { - _logger.Debug("userFormatProvider field doesn't contain the ThreadStatic Attribute, version is affected by the critical bug #18599."); - return true; + _logger.Debug("Mono version is {0} or better: {1}", supportedVersion, monoVersion); + return new HealthCheck(GetType(), HealthCheckResult.Warning, + $"Currently installed Mono version {monoVersion} is supported but has some known issues. Please upgrade Mono to version {bestVersion}.", + "#currently-installed-mono-version-is-supported-but-upgrading-is-recommended"); } - return false; + return new HealthCheck(GetType(), HealthCheckResult.Error, + $"Currently installed Mono version {monoVersion} is old and unsupported. Please upgrade Mono to version {bestVersion}.", + "#currently-installed-mono-version-is-old-and-unsupported"); } + + public override bool CheckOnSchedule => 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..91129d1c8 --- /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.Music; + +namespace NzbDrone.Core.HealthCheck.Checks +{ + public class MountCheck : HealthCheckBase + { + private readonly IDiskProvider _diskProvider; + private readonly IArtistService _artistService; + + public MountCheck(IDiskProvider diskProvider, IArtistService artistService) + { + _diskProvider = diskProvider; + _artistService = artistService; + } + + 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 = _artistService.GetAllArtists() + .Select(artist => _diskProvider.GetMount(artist.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 artist path is mounted read-only: " + string.Join(",", mounts.Select(m => m.Name)), "#artist-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..71ededd18 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/ProxyCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/ProxyCheck.cs @@ -1,13 +1,15 @@ -using NLog; +using NLog; using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; 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; @@ -16,7 +18,7 @@ namespace NzbDrone.Core.HealthCheck.Checks private readonly IHttpRequestBuilderFactory _cloudRequestBuilder; - public ProxyCheck(ISonarrCloudRequestBuilder cloudRequestBuilder, IConfigService configService, IHttpClient client, Logger logger) + public ProxyCheck(ILidarrCloudRequestBuilder cloudRequestBuilder, IConfigService configService, IHttpClient client, Logger logger) { _configService = configService; _client = client; @@ -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/RemotePathMappingCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/RemotePathMappingCheck.cs new file mode 100644 index 000000000..ba9ae6eaa --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/Checks/RemotePathMappingCheck.cs @@ -0,0 +1,191 @@ +using System; +using System.Linq; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Datastore.Events; +using NzbDrone.Core.Download; +using NzbDrone.Core.Download.Clients; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.RemotePathMappings; +using NzbDrone.Core.ThingiProvider.Events; + +namespace NzbDrone.Core.HealthCheck.Checks +{ + [CheckOn(typeof(ProviderAddedEvent))] + [CheckOn(typeof(ProviderUpdatedEvent))] + [CheckOn(typeof(ProviderDeletedEvent))] + [CheckOn(typeof(ModelEvent))] + [CheckOn(typeof(TrackImportedEvent), CheckOnCondition.FailedOnly)] + [CheckOn(typeof(TrackImportFailedEvent), CheckOnCondition.SuccessfulOnly)] + public class RemotePathMappingCheck : HealthCheckBase, IProvideHealthCheckWithMessage + { + private readonly IDiskProvider _diskProvider; + private readonly IProvideDownloadClient _downloadClientProvider; + private readonly Logger _logger; + private readonly IOsInfo _osInfo; + + public RemotePathMappingCheck(IDiskProvider diskProvider, + IProvideDownloadClient downloadClientProvider, + IOsInfo osInfo, + Logger logger) + { + _diskProvider = diskProvider; + _downloadClientProvider = downloadClientProvider; + _logger = logger; + _osInfo = osInfo; + } + + public override HealthCheck Check() + { + var clients = _downloadClientProvider.GetDownloadClients(); + + foreach (var client in clients) + { + try + { + var status = client.GetStatus(); + var folders = status.OutputRootFolders; + if (folders != null) + { + foreach (var folder in folders) + { + if (!folder.IsValid) + { + if (!status.IsLocalhost) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, $"Remote download client {client.Definition.Name} places downloads in {folder.FullPath} but this is not a valid {_osInfo.Name} path. Review your remote path mappings and download client settings.", "#bad-remote-path-mapping"); + } + else if (_osInfo.IsDocker) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, $"You are using docker; download client {client.Definition.Name} places downloads in {folder.FullPath} but this is not a valid {_osInfo.Name} path. Review your remote path mappings and download client settings.", "#docker-bad-remote-path-mapping"); + } + else + { + return new HealthCheck(GetType(), HealthCheckResult.Error, $"Local download client {client.Definition.Name} places downloads in {folder.FullPath} but this is not a valid {_osInfo.Name} path. Review your download client settings.", "#bad-download-client-settings"); + } + } + + if (!_diskProvider.FolderExists(folder.FullPath)) + { + if (_osInfo.IsDocker) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, $"You are using docker; download client {client.Definition.Name} places downloads in {folder.FullPath} but this directory does not appear to exist inside the container. Review your remote path mappings and container volume settings.", "#docker-bad-remote-path-mapping"); + } + else if (!status.IsLocalhost) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, $"Remote download client {client.Definition.Name} places downloads in {folder.FullPath} but this directory does not appear to exist. Likely missing or incorrect remote path mapping.", "#bad-remote-path-mapping"); + } + else + { + return new HealthCheck(GetType(), HealthCheckResult.Error, $"Download client {client.Definition.Name} places downloads in {folder.FullPath} but Lidarr cannot see this directory. You may need to adjust the folder's permissions.", "#permissions-error"); + } + } + } + } + } + catch (DownloadClientException ex) + { + _logger.Debug(ex, "Unable to communicate with {0}", client.Definition.Name); + } + catch (Exception ex) + { + _logger.Error(ex, "Unknown error occured in RemotePathMapping HealthCheck"); + } + } + return new HealthCheck(GetType()); + } + + public HealthCheck Check(IEvent message) + { + if (typeof(TrackImportFailedEvent).IsAssignableFrom(message.GetType())) + { + var failureMessage = (TrackImportFailedEvent) message; + + // if we can see the file exists but the import failed then likely a permissions issue + if (failureMessage.TrackInfo != null) + { + var trackPath = failureMessage.TrackInfo.Path; + if (_diskProvider.FileExists(trackPath)) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, $"Lidarr can see but not access downloaded track {trackPath}. Likely permissions error.", "#permissions-error"); + } + else + { + // If the file doesn't exist but TrackInfo is not null then the message is coming from + // ImportApprovedTracks and the file must have been removed part way through processing + return new HealthCheck(GetType(), HealthCheckResult.Error, $"File {trackPath} was removed part way though procesing."); + } + } + + // If the previous case did not match then the failure occured in DownloadedTracksImportService, + // while trying to locate the files reported by the download client + var client = _downloadClientProvider.GetDownloadClients().FirstOrDefault(x => x.Definition.Name == failureMessage.DownloadClient); + try + { + var status = client.GetStatus(); + var dlpath = client?.GetItems().FirstOrDefault(x => x.DownloadId == failureMessage.DownloadId)?.OutputPath.FullPath; + + // If dlpath is null then there's not much useful we can report. Give a generic message so + // that the user realises something is wrong. + if (dlpath.IsNullOrWhiteSpace()) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, $"Lidarr failed to import a track. Check your logs for details."); + } + + if (!dlpath.IsPathValid()) + { + if (!status.IsLocalhost) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, $"Remote download client {client.Definition.Name} reported files in {dlpath} but this is not a valid {_osInfo.Name} path. Review your remote path mappings and download client settings.", "#bad-remote-path-mapping"); + } + else if (_osInfo.IsDocker) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, $"You are using docker; download client {client.Definition.Name} reported files in {dlpath} but this is not a valid {_osInfo.Name} path. Review your remote path mappings and download client settings.", "#docker-bad-remote-path-mapping"); + } + else + { + return new HealthCheck(GetType(), HealthCheckResult.Error, $"Local download client {client.Definition.Name} reported files in {dlpath} but this is not a valid {_osInfo.Name} path. Review your download client settings.", "#bad-download-client-settings"); + } + } + + if (_diskProvider.FolderExists(dlpath)) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, $"Lidarr can see but not access download directory {dlpath}. Likely permissions error.", "#permissions-error"); + } + + // if it's a remote client/docker, likely missing path mappings + if (_osInfo.IsDocker) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, $"You are using docker; download client {client.Definition.Name} reported files in {dlpath} but this directory does not appear to exist inside the container. Review your remote path mappings and container volume settings.", "#docker-bad-remote-path-mapping"); + } + else if (!status.IsLocalhost) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, $"Remote download client {client.Definition.Name} reported files in {dlpath} but this directory does not appear to exist. Likely missing remote path mapping.", "#bad-remote-path-mapping"); + } + else + { + // path mappings shouldn't be needed locally so probably a permissions issue + return new HealthCheck(GetType(), HealthCheckResult.Error, $"Download client {client.Definition.Name} reported files in {dlpath} but Lidarr cannot see this directory. You may need to adjust the folder's permissions.", "#permissions-error"); + } + } + catch (DownloadClientException ex) + { + _logger.Debug(ex, "Unable to communicate with {0}", client.Definition.Name); + } + catch (Exception ex) + { + _logger.Error(ex, "Unknown error occured in RemotePathMapping HealthCheck"); + } + + return new HealthCheck(GetType()); + } + else + { + return Check(); + } + } + } +} diff --git a/src/NzbDrone.Core/HealthCheck/Checks/RootFolderCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/RootFolderCheck.cs index d7cb3f7d1..ecdaf5e24 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/RootFolderCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/RootFolderCheck.cs @@ -1,28 +1,45 @@ -using System.Linq; +using System.Linq; using NzbDrone.Common.Disk; -using NzbDrone.Core.Tv; +using NzbDrone.Core.ImportLists; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Music; +using NzbDrone.Core.Music.Events; namespace NzbDrone.Core.HealthCheck.Checks { + [CheckOn(typeof(ArtistDeletedEvent))] + [CheckOn(typeof(ArtistMovedEvent))] + [CheckOn(typeof(TrackImportedEvent), CheckOnCondition.FailedOnly)] + [CheckOn(typeof(TrackImportFailedEvent), CheckOnCondition.SuccessfulOnly)] public class RootFolderCheck : HealthCheckBase { - private readonly ISeriesService _seriesService; + private readonly IArtistService _artistService; + private readonly IImportListFactory _importListFactory; private readonly IDiskProvider _diskProvider; - public RootFolderCheck(ISeriesService seriesService, IDiskProvider diskProvider) + public RootFolderCheck(IArtistService artistService, IImportListFactory importListFactory, IDiskProvider diskProvider) { - _seriesService = seriesService; + _artistService = artistService; + _importListFactory = importListFactory; _diskProvider = diskProvider; } public override HealthCheck Check() { - var missingRootFolders = _seriesService.GetAllSeries() + var missingRootFolders = _artistService.GetAllArtists() .Select(s => _diskProvider.GetParentFolder(s.Path)) .Distinct() .Where(s => !_diskProvider.FolderExists(s)) .ToList(); + missingRootFolders.AddRange(_importListFactory.All() + .Select(s => s.RootFolderPath) + .Distinct() + .Where(s => !_diskProvider.FolderExists(s)) + .ToList()); + + missingRootFolders = missingRootFolders.Distinct().ToList(); + if (missingRootFolders.Any()) { if (missingRootFolders.Count == 1) @@ -36,7 +53,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..d474b503c 100644 --- a/src/NzbDrone.Core/HealthCheck/Checks/UpdateCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/Checks/UpdateCheck.cs @@ -1,13 +1,15 @@ -using System; +using System; using System.IO; 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/HealthCheck.cs b/src/NzbDrone.Core/HealthCheck/HealthCheck.cs index e46746d4b..89e0feb80 100644 --- a/src/NzbDrone.Core/HealthCheck/HealthCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/HealthCheck.cs @@ -39,14 +39,15 @@ namespace NzbDrone.Core.HealthCheck private static HttpUri MakeWikiUrl(string fragment) { - return new HttpUri("https://github.com/Sonarr/Sonarr/wiki/Health-checks") + new HttpUri(fragment); + return new HttpUri("https://github.com/Lidarr/Lidarr/wiki/Health-checks") + new HttpUri(fragment); } } public enum HealthCheckResult { Ok = 0, - Warning = 1, - Error = 2 + Notice = 1, + Warning = 2, + Error = 3 } } diff --git a/src/NzbDrone.Core/HealthCheck/HealthCheckBase.cs b/src/NzbDrone.Core/HealthCheck/HealthCheckBase.cs index 5e1700ac6..d9c715ca9 100644 --- a/src/NzbDrone.Core/HealthCheck/HealthCheckBase.cs +++ b/src/NzbDrone.Core/HealthCheck/HealthCheckBase.cs @@ -1,4 +1,4 @@ -namespace NzbDrone.Core.HealthCheck +namespace NzbDrone.Core.HealthCheck { public abstract class HealthCheckBase : IProvideHealthCheck { @@ -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/HealthCheckFailedEvent.cs b/src/NzbDrone.Core/HealthCheck/HealthCheckFailedEvent.cs new file mode 100644 index 000000000..1fc2af1a9 --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/HealthCheckFailedEvent.cs @@ -0,0 +1,14 @@ +using NzbDrone.Common.Messaging; + +namespace NzbDrone.Core.HealthCheck +{ + public class HealthCheckFailedEvent : IEvent + { + public HealthCheck HealthCheck { get; private set; } + + public HealthCheckFailedEvent(HealthCheck healthCheck) + { + HealthCheck = healthCheck; + } + } +} diff --git a/src/NzbDrone.Core/HealthCheck/HealthCheckService.cs b/src/NzbDrone.Core/HealthCheck/HealthCheckService.cs index 56789b8a1..f31fa7efd 100644 --- a/src/NzbDrone.Core/HealthCheck/HealthCheckService.cs +++ b/src/NzbDrone.Core/HealthCheck/HealthCheckService.cs @@ -1,8 +1,11 @@ -using System; +using System; 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,11 +58,29 @@ 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()) - .ToList(); + 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, IEvent message = null) + { + var results = new List(); + + foreach (var healthCheck in healthChecks) + { + if (healthCheck is IProvideHealthCheckWithMessage && message != null) + { + results.Add(((IProvideHealthCheckWithMessage)healthCheck).Check(message)); + } + else + { + results.Add(healthCheck.Check()); + } + } foreach (var result in results) { @@ -67,7 +91,13 @@ namespace NzbDrone.Core.HealthCheck else { + if (_healthCheckResults.Find(result.Source.Name) == null) + { + _eventAggregator.PublishEvent(new HealthCheckFailedEvent(result)); + } + _healthCheckResults.Set(result.Source.Name, result); + } } @@ -76,37 +106,63 @@ 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(), message); } } } diff --git a/src/NzbDrone.Core/HealthCheck/IProvideHealthCheck.cs b/src/NzbDrone.Core/HealthCheck/IProvideHealthCheck.cs index ece0b7952..46e7048ef 100644 --- a/src/NzbDrone.Core/HealthCheck/IProvideHealthCheck.cs +++ b/src/NzbDrone.Core/HealthCheck/IProvideHealthCheck.cs @@ -1,10 +1,16 @@ -namespace NzbDrone.Core.HealthCheck +using NzbDrone.Common.Messaging; + +namespace NzbDrone.Core.HealthCheck { public interface IProvideHealthCheck { HealthCheck Check(); bool CheckOnStartup { get; } - bool CheckOnConfigChange { get; } bool CheckOnSchedule { get; } } + + public interface IProvideHealthCheckWithMessage : IProvideHealthCheck + { + HealthCheck Check(IEvent message); + } } diff --git a/src/NzbDrone.Core/History/History.cs b/src/NzbDrone.Core/History/History.cs index be35637c8..24aad2670 100644 --- a/src/NzbDrone.Core/History/History.cs +++ b/src/NzbDrone.Core/History/History.cs @@ -1,8 +1,8 @@ -using System; +using System; using System.Collections.Generic; using NzbDrone.Core.Datastore; using NzbDrone.Core.Qualities; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.History { @@ -15,13 +15,15 @@ namespace NzbDrone.Core.History Data = new Dictionary(); } - public int EpisodeId { get; set; } - public int SeriesId { get; set; } + public int TrackId { get; set; } + public int AlbumId { get; set; } + public int ArtistId { get; set; } public string SourceTitle { get; set; } public QualityModel Quality { get; set; } public DateTime Date { get; set; } - public Episode Episode { get; set; } - public Series Series { get; set; } + public Album Album { get; set; } + public Artist Artist { get; set; } + public Track Track { get; set; } public HistoryEventType EventType { get; set; } public Dictionary Data { get; set; } @@ -33,9 +35,13 @@ namespace NzbDrone.Core.History { Unknown = 0, Grabbed = 1, - SeriesFolderImported = 2, - DownloadFolderImported = 3, + ArtistFolderImported = 2, + TrackFileImported = 3, DownloadFailed = 4, - EpisodeFileDeleted = 5 + TrackFileDeleted = 5, + TrackFileRenamed = 6, + AlbumImportIncomplete = 7, + DownloadImported = 8, + TrackFileRetagged = 9 } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/History/HistoryRepository.cs b/src/NzbDrone.Core/History/HistoryRepository.cs index 35199a878..e21efec4f 100644 --- a/src/NzbDrone.Core/History/HistoryRepository.cs +++ b/src/NzbDrone.Core/History/HistoryRepository.cs @@ -1,21 +1,25 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using Marr.Data.QGen; using NzbDrone.Core.Datastore; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Qualities; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.History { public interface IHistoryRepository : IBasicRepository { - List GetBestQualityInHistory(int episodeId); - History MostRecentForEpisode(int episodeId); + History MostRecentForAlbum(int albumId); History MostRecentForDownloadId(string downloadId); List FindByDownloadId(string downloadId); - List FindDownloadHistory(int idSeriesId, QualityModel quality); - void DeleteForSeries(int seriesId); + List GetByArtist(int artistId, HistoryEventType? eventType); + List GetByAlbum(int albumId, HistoryEventType? eventType); + List FindDownloadHistory(int idArtistId, QualityModel quality); + void DeleteForArtist(int artistId); + List Since(DateTime date, HistoryEventType? eventType); + } public class HistoryRepository : BasicRepository, IHistoryRepository @@ -27,16 +31,9 @@ namespace NzbDrone.Core.History } - public List GetBestQualityInHistory(int episodeId) - { - var history = Query.Where(c => c.EpisodeId == episodeId); - - return history.Select(h => h.Quality).ToList(); - } - - public History MostRecentForEpisode(int episodeId) + public History MostRecentForAlbum(int albumId) { - return Query.Where(h => h.EpisodeId == episodeId) + return Query.Where(h => h.AlbumId == albumId) .OrderByDescending(h => h.Date) .FirstOrDefault(); } @@ -50,31 +47,77 @@ namespace NzbDrone.Core.History public List FindByDownloadId(string downloadId) { - return Query.Where(h => h.DownloadId == downloadId); + return Query.Join(JoinType.Left, h => h.Artist, (h, a) => h.ArtistId == a.Id) + .Join(JoinType.Left, h => h.Album, (h, r) => h.AlbumId == r.Id) + .Where(h => h.DownloadId == downloadId); + } + + public List GetByArtist(int artistId, HistoryEventType? eventType) + { + var query = Query.Where(h => h.ArtistId == artistId); + + if (eventType.HasValue) + { + query.AndWhere(h => h.EventType == eventType); + } + + query.OrderByDescending(h => h.Date); + + return query; } - public List FindDownloadHistory(int idSeriesId, QualityModel quality) + public List GetByAlbum(int albumId, HistoryEventType? eventType) + { + var query = Query.Join(JoinType.Inner, h => h.Album, (h, e) => h.AlbumId == e.Id) + .Where(h => h.AlbumId == albumId); + + if (eventType.HasValue) + { + query.AndWhere(h => h.EventType == eventType); + } + + query.OrderByDescending(h => h.Date); + + return query; + } + + public List FindDownloadHistory(int idArtistId, QualityModel quality) { return Query.Where(h => - h.SeriesId == idSeriesId && + h.ArtistId == idArtistId && h.Quality == quality && (h.EventType == HistoryEventType.Grabbed || h.EventType == HistoryEventType.DownloadFailed || - h.EventType == HistoryEventType.DownloadFolderImported) + h.EventType == HistoryEventType.TrackFileImported) ).ToList(); } - public void DeleteForSeries(int seriesId) + public void DeleteForArtist(int artistId) { - Delete(c => c.SeriesId == seriesId); + Delete(c => c.ArtistId == artistId); } protected override SortBuilder GetPagedQuery(QueryBuilder query, PagingSpec pagingSpec) { - var baseQuery = query.Join(JoinType.Inner, h => h.Series, (h, s) => h.SeriesId == s.Id) - .Join(JoinType.Inner, h => h.Episode, (h, e) => h.EpisodeId == e.Id); + var baseQuery = query.Join(JoinType.Inner, h => h.Artist, (h, a) => h.ArtistId == a.Id) + .Join(JoinType.Inner, h => h.Album, (h, r) => h.AlbumId == r.Id) + .Join(JoinType.Left, h => h.Track, (h, t) => h.TrackId == t.Id); 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..7decd0d49 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; @@ -10,29 +10,36 @@ using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Profiles; +using NzbDrone.Core.Music.Events; using NzbDrone.Core.Qualities; -using NzbDrone.Core.Tv.Events; +using NzbDrone.Common.Serializer; namespace NzbDrone.Core.History { public interface IHistoryService { - QualityModel GetBestQualityInHistory(Profile profile, int episodeId); PagingSpec Paged(PagingSpec pagingSpec); - History MostRecentForEpisode(int episodeId); + History MostRecentForAlbum(int albumId); History MostRecentForDownloadId(string downloadId); History Get(int historyId); + List GetByArtist(int artistId, HistoryEventType? eventType); + List GetByAlbum(int albumId, HistoryEventType? eventType); List Find(string downloadId, HistoryEventType eventType); List FindByDownloadId(string downloadId); + List Since(DateTime date, HistoryEventType? eventType); + void UpdateMany(IList items); } public class HistoryService : IHistoryService, - IHandle, - IHandle, + IHandle, + IHandle, + IHandle, IHandle, - IHandle, - IHandle + IHandle, + IHandle, + IHandle, + IHandle, + IHandle { private readonly IHistoryRepository _historyRepository; private readonly Logger _logger; @@ -48,9 +55,9 @@ namespace NzbDrone.Core.History return _historyRepository.GetPaged(pagingSpec); } - public History MostRecentForEpisode(int episodeId) + public History MostRecentForAlbum(int albumId) { - return _historyRepository.MostRecentForEpisode(episodeId); + return _historyRepository.MostRecentForAlbum(albumId); } public History MostRecentForDownloadId(string downloadId) @@ -63,6 +70,16 @@ namespace NzbDrone.Core.History return _historyRepository.Get(historyId); } + public List GetByArtist(int artistId, HistoryEventType? eventType) + { + return _historyRepository.GetByArtist(artistId, eventType); + } + + public List GetByAlbum(int albumId, HistoryEventType? eventType) + { + return _historyRepository.GetByAlbum(albumId, eventType); + } + public List Find(string downloadId, HistoryEventType eventType) { return _historyRepository.FindByDownloadId(downloadId).Where(c => c.EventType == eventType).ToList(); @@ -73,37 +90,29 @@ namespace NzbDrone.Core.History return _historyRepository.FindByDownloadId(downloadId); } - public QualityModel GetBestQualityInHistory(Profile profile, int episodeId) - { - var comparer = new QualityModelComparer(profile); - return _historyRepository.GetBestQualityInHistory(episodeId) - .OrderByDescending(q => q, comparer) - .FirstOrDefault(); - } - - private string FindDownloadId(EpisodeImportedEvent trackedDownload) + private string FindDownloadId(TrackImportedEvent trackedDownload) { - _logger.Debug("Trying to find downloadId for {0} from history", trackedDownload.ImportedEpisode.Path); + _logger.Debug("Trying to find downloadId for {0} from history", trackedDownload.ImportedTrack.Path); - var episodeIds = trackedDownload.EpisodeInfo.Episodes.Select(c => c.Id).ToList(); + var albumIds = trackedDownload.TrackInfo.Tracks.Select(c => c.AlbumId).ToList(); - var allHistory = _historyRepository.FindDownloadHistory(trackedDownload.EpisodeInfo.Series.Id, trackedDownload.ImportedEpisode.Quality); + var allHistory = _historyRepository.FindDownloadHistory(trackedDownload.TrackInfo.Artist.Id, trackedDownload.ImportedTrack.Quality); //Find download related items for these episdoes - var episodesHistory = allHistory.Where(h => episodeIds.Contains(h.EpisodeId)).ToList(); + var albumsHistory = allHistory.Where(h => albumIds.Contains(h.AlbumId)).ToList(); - var processedDownloadId = episodesHistory + var processedDownloadId = albumsHistory .Where(c => c.EventType != HistoryEventType.Grabbed && c.DownloadId != null) .Select(c => c.DownloadId); - var stillDownloading = episodesHistory.Where(c => c.EventType == HistoryEventType.Grabbed && !processedDownloadId.Contains(c.DownloadId)).ToList(); + var stillDownloading = albumsHistory.Where(c => c.EventType == HistoryEventType.Grabbed && !processedDownloadId.Contains(c.DownloadId)).ToList(); string downloadId = null; if (stillDownloading.Any()) { - foreach (var matchingHistory in trackedDownload.EpisodeInfo.Episodes.Select(e => stillDownloading.Where(c => c.EpisodeId == e.Id).ToList())) + foreach (var matchingHistory in trackedDownload.TrackInfo.Tracks.Select(e => stillDownloading.Where(c => c.AlbumId == e.AlbumId).ToList())) { if (matchingHistory.Count != 1) { @@ -126,42 +135,41 @@ namespace NzbDrone.Core.History return downloadId; } - public void Handle(EpisodeGrabbedEvent message) + public void Handle(AlbumGrabbedEvent message) { - foreach (var episode in message.Episode.Episodes) + foreach (var album in message.Album.Albums) { var history = new History { EventType = HistoryEventType.Grabbed, Date = DateTime.UtcNow, - Quality = message.Episode.ParsedEpisodeInfo.Quality, - SourceTitle = message.Episode.Release.Title, - SeriesId = episode.SeriesId, - EpisodeId = episode.Id, + Quality = message.Album.ParsedAlbumInfo.Quality, + SourceTitle = message.Album.Release.Title, + ArtistId = album.ArtistId, + AlbumId = album.Id, DownloadId = message.DownloadId }; - history.Data.Add("Indexer", message.Episode.Release.Indexer); - history.Data.Add("NzbInfoUrl", message.Episode.Release.InfoUrl); - history.Data.Add("ReleaseGroup", message.Episode.ParsedEpisodeInfo.ReleaseGroup); - history.Data.Add("Age", message.Episode.Release.Age.ToString()); - history.Data.Add("AgeHours", message.Episode.Release.AgeHours.ToString()); - history.Data.Add("AgeMinutes", message.Episode.Release.AgeMinutes.ToString()); - history.Data.Add("PublishedDate", message.Episode.Release.PublishDate.ToString("s") + "Z"); + history.Data.Add("Indexer", message.Album.Release.Indexer); + history.Data.Add("NzbInfoUrl", message.Album.Release.InfoUrl); + history.Data.Add("ReleaseGroup", message.Album.ParsedAlbumInfo.ReleaseGroup); + history.Data.Add("Age", message.Album.Release.Age.ToString()); + history.Data.Add("AgeHours", message.Album.Release.AgeHours.ToString()); + history.Data.Add("AgeMinutes", message.Album.Release.AgeMinutes.ToString()); + history.Data.Add("PublishedDate", message.Album.Release.PublishDate.ToString("s") + "Z"); history.Data.Add("DownloadClient", message.DownloadClient); - history.Data.Add("Size", message.Episode.Release.Size.ToString()); - history.Data.Add("DownloadUrl", message.Episode.Release.DownloadUrl); - history.Data.Add("Guid", message.Episode.Release.Guid); - history.Data.Add("TvdbId", message.Episode.Release.TvdbId.ToString()); - history.Data.Add("TvRageId", message.Episode.Release.TvRageId.ToString()); - history.Data.Add("Protocol", ((int)message.Episode.Release.DownloadProtocol).ToString()); - - if (!message.Episode.ParsedEpisodeInfo.ReleaseHash.IsNullOrWhiteSpace()) + history.Data.Add("Size", message.Album.Release.Size.ToString()); + history.Data.Add("DownloadUrl", message.Album.Release.DownloadUrl); + history.Data.Add("Guid", message.Album.Release.Guid); + history.Data.Add("Protocol", ((int)message.Album.Release.DownloadProtocol).ToString()); + history.Data.Add("DownloadForced", (!message.Album.DownloadAllowed).ToString()); + + if (!message.Album.ParsedAlbumInfo.ReleaseHash.IsNullOrWhiteSpace()) { - history.Data.Add("ReleaseHash", message.Episode.ParsedEpisodeInfo.ReleaseHash); + history.Data.Add("ReleaseHash", message.Album.ParsedAlbumInfo.ReleaseHash); } - var torrentRelease = message.Episode.Release as TorrentInfo; + var torrentRelease = message.Album.Release as TorrentInfo; if (torrentRelease != null) { @@ -172,7 +180,27 @@ namespace NzbDrone.Core.History } } - public void Handle(EpisodeImportedEvent message) + public void Handle(AlbumImportIncompleteEvent message) + { + foreach (var album in message.TrackedDownload.RemoteAlbum.Albums) + { + var history = new History + { + EventType = HistoryEventType.AlbumImportIncomplete, + Date = DateTime.UtcNow, + Quality = message.TrackedDownload.RemoteAlbum.ParsedAlbumInfo?.Quality ?? new QualityModel(), + SourceTitle = message.TrackedDownload.DownloadItem.Title, + ArtistId = album.ArtistId, + AlbumId = album.Id, + DownloadId = message.TrackedDownload.DownloadItem.DownloadId + }; + + history.Data.Add("StatusMessages", message.TrackedDownload.StatusMessages.ToJson()); + _historyRepository.Insert(history); + } + } + + public void Handle(TrackImportedEvent message) { if (!message.NewDownload) { @@ -186,23 +214,24 @@ namespace NzbDrone.Core.History downloadId = FindDownloadId(message); } - foreach (var episode in message.EpisodeInfo.Episodes) + foreach (var track in message.TrackInfo.Tracks) { var history = new History { - EventType = HistoryEventType.DownloadFolderImported, + EventType = HistoryEventType.TrackFileImported, Date = DateTime.UtcNow, - Quality = message.EpisodeInfo.Quality, - SourceTitle = message.ImportedEpisode.SceneName ?? Path.GetFileNameWithoutExtension(message.EpisodeInfo.Path), - SeriesId = message.ImportedEpisode.SeriesId, - EpisodeId = episode.Id, + Quality = message.TrackInfo.Quality, + SourceTitle = message.ImportedTrack.SceneName ?? Path.GetFileNameWithoutExtension(message.TrackInfo.Path), + ArtistId = message.TrackInfo.Artist.Id, + AlbumId = message.TrackInfo.Album.Id, + TrackId = track.Id, DownloadId = downloadId - }; + }; //Won't have a value since we publish this event before saving to DB. //history.Data.Add("FileId", message.ImportedEpisode.Id.ToString()); - history.Data.Add("DroppedPath", message.EpisodeInfo.Path); - history.Data.Add("ImportedPath", Path.Combine(message.EpisodeInfo.Series.Path, message.ImportedEpisode.RelativePath)); + history.Data.Add("DroppedPath", message.TrackInfo.Path); + history.Data.Add("ImportedPath", message.ImportedTrack.Path); history.Data.Add("DownloadClient", message.DownloadClient); _historyRepository.Insert(history); @@ -211,7 +240,7 @@ namespace NzbDrone.Core.History public void Handle(DownloadFailedEvent message) { - foreach (var episodeId in message.EpisodeIds) + foreach (var albumId in message.AlbumIds) { var history = new History { @@ -219,8 +248,8 @@ namespace NzbDrone.Core.History Date = DateTime.UtcNow, Quality = message.Quality, SourceTitle = message.SourceTitle, - SeriesId = message.SeriesId, - EpisodeId = episodeId, + ArtistId = message.ArtistId, + AlbumId = albumId, DownloadId = message.DownloadId }; @@ -231,24 +260,50 @@ namespace NzbDrone.Core.History } } - public void Handle(EpisodeFileDeletedEvent message) + public void Handle(DownloadCompletedEvent message) + { + foreach (var album in message.TrackedDownload.RemoteAlbum.Albums) + { + var history = new History + { + EventType = HistoryEventType.DownloadImported, + Date = DateTime.UtcNow, + Quality = message.TrackedDownload.RemoteAlbum.ParsedAlbumInfo?.Quality ?? new QualityModel(), + SourceTitle = message.TrackedDownload.DownloadItem.Title, + ArtistId = album.ArtistId, + AlbumId = album.Id, + DownloadId = message.TrackedDownload.DownloadItem.DownloadId + }; + + _historyRepository.Insert(history); + } + } + + public void Handle(TrackFileDeletedEvent message) { if (message.Reason == DeleteMediaFileReason.NoLinkedEpisodes) { - _logger.Debug("Removing episode file from DB as part of cleanup routine, not creating history event."); + _logger.Debug("Removing track file from DB as part of cleanup routine, not creating history event."); + return; + } + else if (message.Reason == DeleteMediaFileReason.ManualOverride) + { + _logger.Debug("Removing track file from DB as part of manual override of existing file, not creating history event."); return; } - foreach (var episode in message.EpisodeFile.Episodes.Value) + + foreach (var track in message.TrackFile.Tracks.Value) { var history = new History { - EventType = HistoryEventType.EpisodeFileDeleted, + EventType = HistoryEventType.TrackFileDeleted, Date = DateTime.UtcNow, - Quality = message.EpisodeFile.Quality, - SourceTitle = message.EpisodeFile.Path, - SeriesId = message.EpisodeFile.SeriesId, - EpisodeId = episode.Id, + Quality = message.TrackFile.Quality, + SourceTitle = message.TrackFile.Path, + ArtistId = message.TrackFile.Artist.Value.Id, + AlbumId = message.TrackFile.AlbumId, + TrackId = track.Id, }; history.Data.Add("Reason", message.Reason.ToString()); @@ -257,9 +312,74 @@ namespace NzbDrone.Core.History } } - public void Handle(SeriesDeletedEvent message) + public void Handle(TrackFileRenamedEvent message) + { + var sourcePath = message.OriginalPath; + var sourceRelativePath = message.Artist.Path.GetRelativePath(message.OriginalPath); + var path = message.TrackFile.Path; + + foreach (var track in message.TrackFile.Tracks.Value) + { + var history = new History + { + EventType = HistoryEventType.TrackFileRenamed, + Date = DateTime.UtcNow, + Quality = message.TrackFile.Quality, + SourceTitle = message.OriginalPath, + ArtistId = message.TrackFile.Artist.Value.Id, + AlbumId = message.TrackFile.AlbumId, + TrackId = track.Id, + }; + + history.Data.Add("SourcePath", sourcePath); + history.Data.Add("SourceRelativePath", sourceRelativePath); + history.Data.Add("Path", path); + + _historyRepository.Insert(history); + } + } + + public void Handle(TrackFileRetaggedEvent message) + { + var path = message.TrackFile.Path; + + foreach (var track in message.TrackFile.Tracks.Value) + { + var history = new History + { + EventType = HistoryEventType.TrackFileRetagged, + Date = DateTime.UtcNow, + Quality = message.TrackFile.Quality, + SourceTitle = path, + ArtistId = message.TrackFile.Artist.Value.Id, + AlbumId = message.TrackFile.AlbumId, + TrackId = track.Id, + }; + + history.Data.Add("TagsScrubbed", message.Scrubbed.ToString()); + history.Data.Add("Diff", message.Diff.Select(x => new { + Field = x.Key, + OldValue = x.Value.Item1, + NewValue = x.Value.Item2 + }).ToJson()); + + _historyRepository.Insert(history); + } + } + + public void Handle(ArtistDeletedEvent message) + { + _historyRepository.DeleteForArtist(message.Artist.Id); + } + + public List Since(DateTime date, HistoryEventType? eventType) + { + return _historyRepository.Since(date, eventType); + } + + public void UpdateMany(IList items) { - _historyRepository.DeleteForSeries(message.Series.Id); + _historyRepository.UpdateMany(items); } } -} \ 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..eb3073a1f --- /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/CleanupDuplicateMetadataFiles.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupDuplicateMetadataFiles.cs index e65a117a1..f7df1b567 100644 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupDuplicateMetadataFiles.cs +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupDuplicateMetadataFiles.cs @@ -1,4 +1,4 @@ -using NzbDrone.Core.Datastore; +using NzbDrone.Core.Datastore; namespace NzbDrone.Core.Housekeeping.Housekeepers { @@ -13,12 +13,13 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers public void Clean() { - DeleteDuplicateSeriesMetadata(); - DeleteDuplicateEpisodeMetadata(); - DeleteDuplicateEpisodeImages(); + DeleteDuplicateArtistMetadata(); + DeleteDuplicateAlbumMetadata(); + DeleteDuplicateTrackMetadata(); + DeleteDuplicateTrackImages(); } - private void DeleteDuplicateSeriesMetadata() + private void DeleteDuplicateArtistMetadata() { var mapper = _database.GetDataMapper(); @@ -26,12 +27,25 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers WHERE Id IN ( SELECT Id FROM MetadataFiles WHERE Type = 1 - GROUP BY SeriesId, Consumer - HAVING COUNT(SeriesId) > 1 + GROUP BY ArtistId, Consumer + HAVING COUNT(ArtistId) > 1 )"); } - private void DeleteDuplicateEpisodeMetadata() + private void DeleteDuplicateAlbumMetadata() + { + var mapper = _database.GetDataMapper(); + + mapper.ExecuteNonQuery(@"DELETE FROM MetadataFiles + WHERE Id IN ( + SELECT Id FROM MetadataFiles + WHERE Type = 6 + GROUP BY AlbumId, Consumer + HAVING COUNT(AlbumId) > 1 + )"); + } + + private void DeleteDuplicateTrackMetadata() { var mapper = _database.GetDataMapper(); @@ -39,12 +53,12 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers WHERE Id IN ( SELECT Id FROM MetadataFiles WHERE Type = 2 - GROUP BY EpisodeFileId, Consumer - HAVING COUNT(EpisodeFileId) > 1 + GROUP BY TrackFileId, Consumer + HAVING COUNT(TrackFileId) > 1 )"); } - private void DeleteDuplicateEpisodeImages() + private void DeleteDuplicateTrackImages() { var mapper = _database.GetDataMapper(); @@ -52,8 +66,8 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers WHERE Id IN ( SELECT Id FROM MetadataFiles WHERE Type = 5 - GROUP BY EpisodeFileId, Consumer - HAVING COUNT(EpisodeFileId) > 1 + GROUP BY TrackFileId, Consumer + HAVING COUNT(TrackFileId) > 1 )"); } } diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedAlbums.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedAlbums.cs new file mode 100644 index 000000000..ce4893ff4 --- /dev/null +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedAlbums.cs @@ -0,0 +1,26 @@ +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Housekeeping.Housekeepers +{ + public class CleanupOrphanedAlbums : IHousekeepingTask + { + private readonly IMainDatabase _database; + + public CleanupOrphanedAlbums(IMainDatabase database) + { + _database = database; + } + + public void Clean() + { + var mapper = _database.GetDataMapper(); + + mapper.ExecuteNonQuery(@"DELETE FROM Albums + WHERE Id IN ( + SELECT Albums.Id FROM Albums + LEFT OUTER JOIN Artists + ON Albums.ArtistMetadataId = Artists.ArtistMetadataId + WHERE Artists.Id IS NULL)"); + } + } +} diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedArtistMetadata.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedArtistMetadata.cs new file mode 100644 index 000000000..f9992746c --- /dev/null +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedArtistMetadata.cs @@ -0,0 +1,27 @@ +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Housekeeping.Housekeepers +{ + public class CleanupOrphanedArtistMetadata : IHousekeepingTask + { + private readonly IMainDatabase _database; + + public CleanupOrphanedArtistMetadata(IMainDatabase database) + { + _database = database; + } + + public void Clean() + { + var mapper = _database.GetDataMapper(); + + mapper.ExecuteNonQuery(@"DELETE FROM ArtistMetadata + WHERE Id IN ( + SELECT ArtistMetadata.Id FROM ArtistMetadata + LEFT OUTER JOIN Albums ON Albums.ArtistMetadataId = ArtistMetadata.Id + LEFT OUTER JOIN Tracks ON Tracks.ArtistMetadataId = ArtistMetadata.Id + LEFT OUTER JOIN Artists ON Artists.ArtistMetadataId = ArtistMetadata.Id + WHERE Albums.Id IS NULL AND Tracks.Id IS NULL AND Artists.Id IS NULL)"); + } + } +} diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedBlacklist.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedBlacklist.cs index b1d127292..2def585c2 100644 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedBlacklist.cs +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedBlacklist.cs @@ -18,9 +18,9 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers mapper.ExecuteNonQuery(@"DELETE FROM Blacklist WHERE Id IN ( SELECT Blacklist.Id FROM Blacklist - LEFT OUTER JOIN Series - ON Blacklist.SeriesId = Series.Id - WHERE Series.Id IS NULL)"); + LEFT OUTER JOIN Artists + ON Blacklist.ArtistId = Artists.Id + WHERE Artists.Id IS NULL)"); } } } diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedDownloadClientStatus.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedDownloadClientStatus.cs new file mode 100644 index 000000000..258a51ab9 --- /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/CleanupOrphanedEpisodeFiles.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedEpisodeFiles.cs deleted file mode 100644 index 79f186a37..000000000 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedEpisodeFiles.cs +++ /dev/null @@ -1,26 +0,0 @@ -using NzbDrone.Core.Datastore; - -namespace NzbDrone.Core.Housekeeping.Housekeepers -{ - public class CleanupOrphanedEpisodeFiles : IHousekeepingTask - { - private readonly IMainDatabase _database; - - public CleanupOrphanedEpisodeFiles(IMainDatabase database) - { - _database = database; - } - - public void Clean() - { - var mapper = _database.GetDataMapper(); - - mapper.ExecuteNonQuery(@"DELETE FROM EpisodeFiles - WHERE Id IN ( - SELECT EpisodeFiles.Id FROM EpisodeFiles - LEFT OUTER JOIN Episodes - ON EpisodeFiles.Id = Episodes.EpisodeFileId - WHERE Episodes.Id IS NULL)"); - } - } -} diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedEpisodes.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedEpisodes.cs deleted file mode 100644 index 6d9d208c9..000000000 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedEpisodes.cs +++ /dev/null @@ -1,26 +0,0 @@ -using NzbDrone.Core.Datastore; - -namespace NzbDrone.Core.Housekeeping.Housekeepers -{ - public class CleanupOrphanedEpisodes : IHousekeepingTask - { - private readonly IMainDatabase _database; - - public CleanupOrphanedEpisodes(IMainDatabase database) - { - _database = database; - } - - public void Clean() - { - var mapper = _database.GetDataMapper(); - - mapper.ExecuteNonQuery(@"DELETE FROM Episodes - WHERE Id IN ( - SELECT Episodes.Id FROM Episodes - LEFT OUTER JOIN Series - ON Episodes.SeriesId = Series.Id - WHERE Series.Id IS NULL)"); - } - } -} diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedHistoryItems.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedHistoryItems.cs index ca03130e6..ded0eae75 100644 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedHistoryItems.cs +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedHistoryItems.cs @@ -13,32 +13,32 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers public void Clean() { - CleanupOrphanedBySeries(); - CleanupOrphanedByEpisode(); + CleanupOrphanedByArtist(); + CleanupOrphanedByAlbum(); } - private void CleanupOrphanedBySeries() + private void CleanupOrphanedByArtist() { var mapper = _database.GetDataMapper(); mapper.ExecuteNonQuery(@"DELETE FROM History WHERE Id IN ( SELECT History.Id FROM History - LEFT OUTER JOIN Series - ON History.SeriesId = Series.Id - WHERE Series.Id IS NULL)"); + LEFT OUTER JOIN Artists + ON History.ArtistId = Artists.Id + WHERE Artists.Id IS NULL)"); } - private void CleanupOrphanedByEpisode() + private void CleanupOrphanedByAlbum() { var mapper = _database.GetDataMapper(); mapper.ExecuteNonQuery(@"DELETE FROM History WHERE Id IN ( SELECT History.Id FROM History - LEFT OUTER JOIN Episodes - ON History.EpisodeId = Episodes.Id - WHERE Episodes.Id IS NULL)"); + LEFT OUTER JOIN Albums + ON History.AlbumId = Albums.Id + WHERE Albums.Id IS NULL)"); } } } diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedImportListStatus.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedImportListStatus.cs new file mode 100644 index 000000000..9a267ee81 --- /dev/null +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedImportListStatus.cs @@ -0,0 +1,26 @@ +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Housekeeping.Housekeepers +{ + public class CleanupOrphanedImportListStatus : IHousekeepingTask + { + private readonly IMainDatabase _database; + + public CleanupOrphanedImportListStatus(IMainDatabase database) + { + _database = database; + } + + public void Clean() + { + var mapper = _database.GetDataMapper(); + + mapper.ExecuteNonQuery(@"DELETE FROM ImportListStatus + WHERE Id IN ( + SELECT ImportListStatus.Id FROM ImportListStatus + LEFT OUTER JOIN ImportLists + ON ImportListStatus.ProviderId = ImportLists.Id + WHERE ImportLists.Id IS NULL)"); + } + } +} diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedIndexerStatus.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedIndexerStatus.cs index b3cf47027..4df6f502b 100644 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedIndexerStatus.cs +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedIndexerStatus.cs @@ -1,4 +1,4 @@ -using NzbDrone.Core.Datastore; +using NzbDrone.Core.Datastore; namespace NzbDrone.Core.Housekeeping.Housekeepers { @@ -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/CleanupOrphanedMetadataFiles.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedMetadataFiles.cs index 05ab54ea1..f83359ec1 100644 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedMetadataFiles.cs +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedMetadataFiles.cs @@ -1,4 +1,4 @@ -using NzbDrone.Core.Datastore; +using NzbDrone.Core.Datastore; namespace NzbDrone.Core.Housekeeping.Housekeepers { @@ -13,37 +13,63 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers public void Clean() { - DeleteOrphanedBySeries(); - DeleteOrphanedByEpisodeFile(); - DeleteWhereEpisodeFileIsZero(); + DeleteOrphanedByArtist(); + DeleteOrphanedByAlbum(); + DeleteOrphanedByTrackFile(); + DeleteWhereAlbumIdIsZero(); + DeleteWhereTrackFileIsZero(); } - private void DeleteOrphanedBySeries() + private void DeleteOrphanedByArtist() { var mapper = _database.GetDataMapper(); mapper.ExecuteNonQuery(@"DELETE FROM MetadataFiles WHERE Id IN ( SELECT MetadataFiles.Id FROM MetadataFiles - LEFT OUTER JOIN Series - ON MetadataFiles.SeriesId = Series.Id - WHERE Series.Id IS NULL)"); + LEFT OUTER JOIN Artists + ON MetadataFiles.ArtistId = Artists.Id + WHERE Artists.Id IS NULL)"); } - private void DeleteOrphanedByEpisodeFile() + private void DeleteOrphanedByAlbum() { var mapper = _database.GetDataMapper(); mapper.ExecuteNonQuery(@"DELETE FROM MetadataFiles WHERE Id IN ( SELECT MetadataFiles.Id FROM MetadataFiles - LEFT OUTER JOIN EpisodeFiles - ON MetadataFiles.EpisodeFileId = EpisodeFiles.Id - WHERE MetadataFiles.EpisodeFileId > 0 - AND EpisodeFiles.Id IS NULL)"); + LEFT OUTER JOIN Albums + ON MetadataFiles.AlbumId = Albums.Id + WHERE MetadataFiles.AlbumId > 0 + AND Albums.Id IS NULL)"); } - private void DeleteWhereEpisodeFileIsZero() + private void DeleteOrphanedByTrackFile() + { + var mapper = _database.GetDataMapper(); + + mapper.ExecuteNonQuery(@"DELETE FROM MetadataFiles + WHERE Id IN ( + SELECT MetadataFiles.Id FROM MetadataFiles + LEFT OUTER JOIN TrackFiles + ON MetadataFiles.TrackFileId = TrackFiles.Id + WHERE MetadataFiles.TrackFileId > 0 + AND TrackFiles.Id IS NULL)"); + } + + private void DeleteWhereAlbumIdIsZero() + { + var mapper = _database.GetDataMapper(); + + mapper.ExecuteNonQuery(@"DELETE FROM MetadataFiles + WHERE Id IN ( + SELECT Id FROM MetadataFiles + WHERE Type IN (4, 6) + AND AlbumId = 0)"); + } + + private void DeleteWhereTrackFileIsZero() { var mapper = _database.GetDataMapper(); @@ -51,7 +77,7 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers WHERE Id IN ( SELECT Id FROM MetadataFiles WHERE Type IN (2, 5) - AND EpisodeFileId = 0)"); + AND TrackFileId = 0)"); } } } diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedPendingReleases.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedPendingReleases.cs index 0366d7321..bdc45d099 100644 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedPendingReleases.cs +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedPendingReleases.cs @@ -18,9 +18,9 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers mapper.ExecuteNonQuery(@"DELETE FROM PendingReleases WHERE Id IN ( SELECT PendingReleases.Id FROM PendingReleases - LEFT OUTER JOIN Series - ON PendingReleases.SeriesId = Series.Id - WHERE Series.Id IS NULL)"); + LEFT OUTER JOIN Artists + ON PendingReleases.ArtistId = Artists.Id + WHERE Artists.Id IS NULL)"); } } } diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedReleases.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedReleases.cs new file mode 100644 index 000000000..5c7c3d1fe --- /dev/null +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedReleases.cs @@ -0,0 +1,26 @@ +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Housekeeping.Housekeepers +{ + public class CleanupOrphanedReleases : IHousekeepingTask + { + private readonly IMainDatabase _database; + + public CleanupOrphanedReleases(IMainDatabase database) + { + _database = database; + } + + public void Clean() + { + var mapper = _database.GetDataMapper(); + + mapper.ExecuteNonQuery(@"DELETE FROM AlbumReleases + WHERE Id IN ( + SELECT AlbumReleases.Id FROM AlbumReleases + LEFT OUTER JOIN Albums + ON AlbumReleases.AlbumId = Albums.Id + WHERE Albums.Id IS NULL)"); + } + } +} diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedTrackFiles.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedTrackFiles.cs new file mode 100644 index 000000000..013df88b5 --- /dev/null +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedTrackFiles.cs @@ -0,0 +1,37 @@ +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Housekeeping.Housekeepers +{ + public class CleanupOrphanedTrackFiles : IHousekeepingTask + { + private readonly IMainDatabase _database; + + public CleanupOrphanedTrackFiles(IMainDatabase database) + { + _database = database; + } + + public void Clean() + { + var mapper = _database.GetDataMapper(); + + // Unlink where track no longer exists + mapper.ExecuteNonQuery(@"UPDATE TrackFiles + SET AlbumId = 0 + WHERE Id IN ( + SELECT TrackFiles.Id FROM TrackFiles + LEFT OUTER JOIN Tracks + ON TrackFiles.Id = Tracks.TrackFileId + WHERE Tracks.Id IS NULL)"); + + // Unlink Tracks where the Trackfiles entry no longer exists + mapper.ExecuteNonQuery(@"UPDATE Tracks + SET TrackFileId = 0 + WHERE Id IN ( + SELECT Tracks.Id FROM Tracks + LEFT OUTER JOIN TrackFiles + ON Tracks.TrackFileId = TrackFiles.Id + WHERE TrackFiles.Id IS NULL)"); + } + } +} diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedTracks.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedTracks.cs new file mode 100644 index 000000000..94b8e22df --- /dev/null +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedTracks.cs @@ -0,0 +1,26 @@ +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Housekeeping.Housekeepers +{ + public class CleanupOrphanedTracks : IHousekeepingTask + { + private readonly IMainDatabase _database; + + public CleanupOrphanedTracks(IMainDatabase database) + { + _database = database; + } + + public void Clean() + { + var mapper = _database.GetDataMapper(); + + mapper.ExecuteNonQuery(@"DELETE FROM Tracks + WHERE Id IN ( + SELECT Tracks.Id FROM Tracks + LEFT OUTER JOIN AlbumReleases + ON Tracks.AlbumReleaseId = AlbumReleases.Id + WHERE AlbumReleases.Id IS NULL)"); + } + } +} diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs index 63debb4b7..ae7f1fdc6 100644 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using Marr.Data; using NzbDrone.Common.Serializer; @@ -19,7 +19,7 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers { var mapper = _database.GetDataMapper(); - var usedTags = new[] { "Series", "Notifications", "DelayProfiles", "Restrictions" } + var usedTags = new[] { "Artists", "Notifications", "DelayProfiles", "ReleaseProfiles" } .SelectMany(v => GetUsedTags(v, mapper)) .Distinct() .ToArray(); diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/DeleteBadMediaCovers.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/DeleteBadMediaCovers.cs index 0bd74614b..df2fd5389 100644 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/DeleteBadMediaCovers.cs +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/DeleteBadMediaCovers.cs @@ -1,30 +1,31 @@ -using System; +using System; +using System.Collections.Generic; using System.IO; using System.Linq; using NLog; using NzbDrone.Common.Disk; using NzbDrone.Core.Configuration; using NzbDrone.Core.Extras.Metadata.Files; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Housekeeping.Housekeepers { public class DeleteBadMediaCovers : IHousekeepingTask { private readonly IMetadataFileService _metaFileService; - private readonly ISeriesService _seriesService; + private readonly IArtistService _artistService; private readonly IDiskProvider _diskProvider; private readonly IConfigService _configService; private readonly Logger _logger; public DeleteBadMediaCovers(IMetadataFileService metaFileService, - ISeriesService seriesService, + IArtistService artistService, IDiskProvider diskProvider, IConfigService configService, Logger logger) { _metaFileService = metaFileService; - _seriesService = seriesService; + _artistService = artistService; _diskProvider = diskProvider; _configService = configService; _logger = logger; @@ -34,18 +35,19 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers { if (!_configService.CleanupMetadataImages) return; - var series = _seriesService.GetAllSeries(); + var artists = _artistService.GetAllArtists(); + var imageExtensions = new List { ".jpg", ".png", ".gif" }; - foreach (var show in series) + foreach (var artist in artists) { - var images = _metaFileService.GetFilesBySeries(show.Id) - .Where(c => c.LastUpdated > new DateTime(2014, 12, 27) && c.RelativePath.EndsWith(".jpg", StringComparison.InvariantCultureIgnoreCase)); + var images = _metaFileService.GetFilesByArtist(artist.Id) + .Where(c => c.LastUpdated > new DateTime(2014, 12, 27) && imageExtensions.Any(x => c.RelativePath.EndsWith(x, StringComparison.InvariantCultureIgnoreCase))); foreach (var image in images) { try { - var path = Path.Combine(show.Path, image.RelativePath); + var path = Path.Combine(artist.Path, image.RelativePath); if (!IsValid(path)) { _logger.Debug("Deleting invalid image file " + path); @@ -84,4 +86,4 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers return !text.ToLowerInvariant().Contains("html"); } } -} \ No newline at end of file +} 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/FixFutureImportListStatusTimes.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/FixFutureImportListStatusTimes.cs new file mode 100644 index 000000000..2c0ce3fa0 --- /dev/null +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/FixFutureImportListStatusTimes.cs @@ -0,0 +1,12 @@ +using NzbDrone.Core.ImportLists; + +namespace NzbDrone.Core.Housekeeping.Housekeepers +{ + public class FixFutureImportListStatusTimes : FixFutureProviderStatusTimes, IHousekeepingTask + { + public FixFutureImportListStatusTimes(IImportListStatusRepository importListStatusRepository) + : base(importListStatusRepository) + { + } + } +} 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/Housekeeping/Housekeepers/UpdateCleanTitleForArtist.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/UpdateCleanTitleForArtist.cs new file mode 100644 index 000000000..a1c5a3227 --- /dev/null +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/UpdateCleanTitleForArtist.cs @@ -0,0 +1,31 @@ +using System.Linq; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Housekeeping.Housekeepers +{ + public class UpdateCleanTitleForArtist : IHousekeepingTask + { + private readonly IArtistRepository _artistRepository; + + public UpdateCleanTitleForArtist(IArtistRepository artistRepository) + { + _artistRepository = artistRepository; + } + + public void Clean() + { + var artists = _artistRepository.All().ToList(); + + artists.ForEach(s => + { + var cleanName = s.Name.CleanArtistName(); + if (s.CleanName != cleanName) + { + s.CleanName = cleanName; + _artistRepository.Update(s); + } + }); + } + } +} diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/UpdateCleanTitleForSeries.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/UpdateCleanTitleForSeries.cs deleted file mode 100644 index 16b19c505..000000000 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/UpdateCleanTitleForSeries.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Linq; -using NzbDrone.Core.Parser; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Housekeeping.Housekeepers -{ - public class UpdateCleanTitleForSeries : IHousekeepingTask - { - private readonly ISeriesRepository _seriesRepository; - - public UpdateCleanTitleForSeries(ISeriesRepository seriesRepository) - { - _seriesRepository = seriesRepository; - } - - public void Clean() - { - var series = _seriesRepository.All().ToList(); - - series.ForEach(s => - { - s.CleanTitle = s.CleanTitle.CleanSeriesTitle(); - _seriesRepository.Update(s); - }); - } - } -} diff --git a/src/NzbDrone.Core/ImportLists/Exceptions/ImportListException.cs b/src/NzbDrone.Core/ImportLists/Exceptions/ImportListException.cs new file mode 100644 index 000000000..cdc61c095 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Exceptions/ImportListException.cs @@ -0,0 +1,23 @@ +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.ImportLists.Exceptions +{ + public class ImportListException : NzbDroneException + { + private readonly ImportListResponse _importListResponse; + + public ImportListException(ImportListResponse response, string message, params object[] args) + : base(message, args) + { + _importListResponse = response; + } + + public ImportListException(ImportListResponse response, string message) + : base(message) + { + _importListResponse = response; + } + + public ImportListResponse Response => _importListResponse; + } +} diff --git a/src/NzbDrone.Core/ImportLists/Exclusions/ImportListExclusion.cs b/src/NzbDrone.Core/ImportLists/Exclusions/ImportListExclusion.cs new file mode 100644 index 000000000..e91f58b30 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Exclusions/ImportListExclusion.cs @@ -0,0 +1,10 @@ +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.ImportLists.Exclusions +{ + public class ImportListExclusion : ModelBase + { + public string ForeignId { get; set; } + public string Name { get; set; } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Exclusions/ImportListExclusionExistsValidator.cs b/src/NzbDrone.Core/ImportLists/Exclusions/ImportListExclusionExistsValidator.cs new file mode 100644 index 000000000..db2179493 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Exclusions/ImportListExclusionExistsValidator.cs @@ -0,0 +1,22 @@ +using FluentValidation.Validators; + +namespace NzbDrone.Core.ImportLists.Exclusions +{ + public class ImportListExclusionExistsValidator : PropertyValidator + { + private readonly IImportListExclusionService _importListExclusionService; + + public ImportListExclusionExistsValidator(IImportListExclusionService importListExclusionService) + : base("This exclusion has already been added.") + { + _importListExclusionService = importListExclusionService; + } + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context.PropertyValue == null) return true; + + return (!_importListExclusionService.All().Exists(s => s.ForeignId == context.PropertyValue.ToString())); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Exclusions/ImportListExclusionRepository.cs b/src/NzbDrone.Core/ImportLists/Exclusions/ImportListExclusionRepository.cs new file mode 100644 index 000000000..11e538374 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Exclusions/ImportListExclusionRepository.cs @@ -0,0 +1,24 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; +using System.Linq; + +namespace NzbDrone.Core.ImportLists.Exclusions +{ + public interface IImportListExclusionRepository : IBasicRepository + { + ImportListExclusion FindByForeignId(string foreignId); + } + + public class ImportListExclusionRepository : BasicRepository, IImportListExclusionRepository + { + public ImportListExclusionRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + + public ImportListExclusion FindByForeignId(string foreignId) + { + return Query.Where(m => m.ForeignId == foreignId).SingleOrDefault(); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Exclusions/ImportListExclusionService.cs b/src/NzbDrone.Core/ImportLists/Exclusions/ImportListExclusionService.cs new file mode 100644 index 000000000..343e04a88 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Exclusions/ImportListExclusionService.cs @@ -0,0 +1,80 @@ +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Music.Events; +using System.Collections.Generic; +using System.Linq; + +namespace NzbDrone.Core.ImportLists.Exclusions +{ + public interface IImportListExclusionService + { + ImportListExclusion Add(ImportListExclusion importListExclusion); + List All(); + void Delete(int id); + ImportListExclusion Get(int id); + ImportListExclusion FindByForeignId(string foreignId); + ImportListExclusion Update(ImportListExclusion importListExclusion); + } + + public class ImportListExclusionService : IImportListExclusionService, IHandleAsync + { + private readonly IImportListExclusionRepository _repo; + + public ImportListExclusionService(IImportListExclusionRepository repo) + { + _repo = repo; + } + + public ImportListExclusion Add(ImportListExclusion importListExclusion) + { + return _repo.Insert(importListExclusion); + } + + public ImportListExclusion Update(ImportListExclusion importListExclusion) + { + return _repo.Update(importListExclusion); + } + + public void Delete(int id) + { + _repo.Delete(id); + } + + public ImportListExclusion Get(int id) + { + return _repo.Get(id); + } + + public ImportListExclusion FindByForeignId(string foreignId) + { + return _repo.FindByForeignId(foreignId); + } + + public List All() + { + return _repo.All().ToList(); + } + + public void HandleAsync(ArtistDeletedEvent message) + { + if (!message.AddImportListExclusion) + { + return; + } + + var existingExclusion = _repo.FindByForeignId(message.Artist.ForeignArtistId); + + if (existingExclusion != null) + { + return; + } + + var importExclusion = new ImportListExclusion + { + ForeignId = message.Artist.ForeignArtistId, + Name = message.Artist.Name + }; + + _repo.Insert(importExclusion); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/FetchAndParseImportListService.cs b/src/NzbDrone.Core/ImportLists/FetchAndParseImportListService.cs new file mode 100644 index 000000000..98eec4ee2 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/FetchAndParseImportListService.cs @@ -0,0 +1,127 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using NLog; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Common.TPL; +using System; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.ImportLists +{ + public interface IFetchAndParseImportList + { + List Fetch(); + List FetchSingleList(ImportListDefinition definition); + } + + public class FetchAndParseImportListService : IFetchAndParseImportList + { + private readonly IImportListFactory _importListFactory; + private readonly Logger _logger; + + public FetchAndParseImportListService(IImportListFactory importListFactory, Logger logger) + { + _importListFactory = importListFactory; + _logger = logger; + } + + public List Fetch() + { + var result = new List(); + + var importLists = _importListFactory.AutomaticAddEnabled(); + + if (!importLists.Any()) + { + _logger.Warn("No available import lists. check your configuration."); + return result; + } + + _logger.Debug("Available import lists {0}", importLists.Count); + + var taskList = new List(); + var taskFactory = new TaskFactory(TaskCreationOptions.LongRunning, TaskContinuationOptions.None); + + foreach (var importList in importLists) + { + var importListLocal = importList; + + var task = taskFactory.StartNew(() => + { + try + { + var importListReports = importListLocal.Fetch(); + + lock (result) + { + _logger.Debug("Found {0} from {1}", importListReports.Count, importList.Name); + + result.AddRange(importListReports); + } + } + catch (Exception e) + { + _logger.Error(e, "Error during Import List Sync"); + } + }).LogExceptions(); + + taskList.Add(task); + } + + Task.WaitAll(taskList.ToArray()); + + result = result.DistinctBy(r => new {r.Artist, r.Album}).ToList(); + + _logger.Debug("Found {0} reports", result.Count); + + return result; + } + + public List FetchSingleList(ImportListDefinition definition) + { + var result = new List(); + + var importList = _importListFactory.GetInstance(definition); + + if (importList == null || !definition.EnableAutomaticAdd) + { + _logger.Warn("No available import lists. check your configuration."); + return result; + } + + var taskList = new List(); + var taskFactory = new TaskFactory(TaskCreationOptions.LongRunning, TaskContinuationOptions.None); + + var importListLocal = importList; + + var task = taskFactory.StartNew(() => + { + try + { + var importListReports = importListLocal.Fetch(); + + lock (result) + { + _logger.Debug("Found {0} from {1}", importListReports.Count, importList.Name); + + result.AddRange(importListReports); + } + } + catch (Exception e) + { + _logger.Error(e, "Error during Import List Sync"); + } + }).LogExceptions(); + + taskList.Add(task); + + + Task.WaitAll(taskList.ToArray()); + + result = result.DistinctBy(r => new { r.Artist, r.Album }).ToList(); + + return result; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/HeadphonesImport/HeadphonesImport.cs b/src/NzbDrone.Core/ImportLists/HeadphonesImport/HeadphonesImport.cs new file mode 100644 index 000000000..6912858af --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/HeadphonesImport/HeadphonesImport.cs @@ -0,0 +1,32 @@ +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.ImportLists.HeadphonesImport +{ + public class HeadphonesImport : HttpImportListBase + { + public override string Name => "Headphones"; + + public override ImportListType ListType => ImportListType.Other; + + public override int PageSize => 1000; + + public HeadphonesImport(IHttpClient httpClient, IImportListStatusService importListStatusService, IConfigService configService, IParsingService parsingService, Logger logger) + : base(httpClient, importListStatusService, configService, parsingService, logger) + { + } + + public override IImportListRequestGenerator GetRequestGenerator() + { + return new HeadphonesImportRequestGenerator { Settings = Settings}; + } + + public override IParseImportListResponse GetParser() + { + return new HeadphonesImportParser(); + } + + } +} diff --git a/src/NzbDrone.Core/ImportLists/HeadphonesImport/HeadphonesImportApi.cs b/src/NzbDrone.Core/ImportLists/HeadphonesImport/HeadphonesImportApi.cs new file mode 100644 index 000000000..ce9d5341f --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/HeadphonesImport/HeadphonesImportApi.cs @@ -0,0 +1,9 @@ + +namespace NzbDrone.Core.ImportLists.HeadphonesImport +{ + public class HeadphonesImportArtist + { + public string ArtistName { get; set; } + public string ArtistId { get; set; } + } +} diff --git a/src/NzbDrone.Core/ImportLists/HeadphonesImport/HeadphonesImportParser.cs b/src/NzbDrone.Core/ImportLists/HeadphonesImport/HeadphonesImportParser.cs new file mode 100644 index 000000000..b490a6cbd --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/HeadphonesImport/HeadphonesImportParser.cs @@ -0,0 +1,63 @@ +using Newtonsoft.Json; +using NzbDrone.Core.ImportLists.Exceptions; +using NzbDrone.Core.Parser.Model; +using System.Collections.Generic; +using System.Net; +using NLog; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.ImportLists.HeadphonesImport +{ + public class HeadphonesImportParser : IParseImportListResponse + { + private ImportListResponse _importListResponse; + + public IList ParseResponse(ImportListResponse importListResponse) + { + _importListResponse = importListResponse; + + var items = new List(); + + if (!PreProcess(_importListResponse)) + { + return items; + } + + var jsonResponse = JsonConvert.DeserializeObject>(_importListResponse.Content); + + // no albums were return + if (jsonResponse == null) + { + return items; + } + + foreach (var item in jsonResponse) + { + items.AddIfNotNull(new ImportListItemInfo + { + Artist = item.ArtistName, + ArtistMusicBrainzId = item.ArtistId + }); + } + + return items; + } + + protected virtual bool PreProcess(ImportListResponse importListResponse) + { + if (importListResponse.HttpResponse.StatusCode != HttpStatusCode.OK) + { + throw new ImportListException(importListResponse, "Import List API call resulted in an unexpected StatusCode [{0}]", importListResponse.HttpResponse.StatusCode); + } + + if (importListResponse.HttpResponse.Headers.ContentType != null && importListResponse.HttpResponse.Headers.ContentType.Contains("text/json") && + importListResponse.HttpRequest.Headers.Accept != null && !importListResponse.HttpRequest.Headers.Accept.Contains("text/json")) + { + throw new ImportListException(importListResponse, "Import List responded with html content. Site is likely blocked or unavailable."); + } + + return true; + } + + } +} diff --git a/src/NzbDrone.Core/ImportLists/HeadphonesImport/HeadphonesImportRequestGenerator.cs b/src/NzbDrone.Core/ImportLists/HeadphonesImport/HeadphonesImportRequestGenerator.cs new file mode 100644 index 000000000..664c32273 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/HeadphonesImport/HeadphonesImportRequestGenerator.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using NzbDrone.Common.Http; + +namespace NzbDrone.Core.ImportLists.HeadphonesImport +{ + public class HeadphonesImportRequestGenerator : IImportListRequestGenerator + { + public HeadphonesImportSettings Settings { get; set; } + + public int MaxPages { get; set; } + public int PageSize { get; set; } + + public HeadphonesImportRequestGenerator() + { + MaxPages = 1; + PageSize = 1000; + } + + public virtual ImportListPageableRequestChain GetListItems() + { + var pageableRequests = new ImportListPageableRequestChain(); + + pageableRequests.Add(GetPagedRequests()); + + return pageableRequests; + } + + private IEnumerable GetPagedRequests() + { + yield return new ImportListRequest(string.Format("{0}/api?cmd=getIndex&apikey={1}", Settings.BaseUrl.TrimEnd('/'), Settings.ApiKey), HttpAccept.Json); + } + + } +} diff --git a/src/NzbDrone.Core/ImportLists/HeadphonesImport/HeadphonesImportSettings.cs b/src/NzbDrone.Core/ImportLists/HeadphonesImport/HeadphonesImportSettings.cs new file mode 100644 index 000000000..456b10399 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/HeadphonesImport/HeadphonesImportSettings.cs @@ -0,0 +1,35 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.ImportLists.HeadphonesImport +{ + public class HeadphonesImportSettingsValidator : AbstractValidator + { + public HeadphonesImportSettingsValidator() + { + RuleFor(c => c.BaseUrl).ValidRootUrl(); + } + } + + public class HeadphonesImportSettings : IImportListSettings + { + private static readonly HeadphonesImportSettingsValidator Validator = new HeadphonesImportSettingsValidator(); + + public HeadphonesImportSettings() + { + BaseUrl = "http://localhost:8181/"; + } + + [FieldDefinition(0, Label = "Headphones URL")] + public string BaseUrl { get; set; } + + [FieldDefinition(1, Label = "API Key")] + public string ApiKey { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/HttpImportListBase.cs b/src/NzbDrone.Core/ImportLists/HttpImportListBase.cs new file mode 100644 index 000000000..98942d450 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/HttpImportListBase.cs @@ -0,0 +1,246 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Http.CloudFlare; +using NzbDrone.Core.ImportLists.Exceptions; +using NzbDrone.Core.Indexers.Exceptions; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.ImportLists +{ + public abstract class HttpImportListBase : ImportListBase + where TSettings : IImportListSettings, new() + { + protected const int MaxNumResultsPerQuery = 1000; + + protected readonly IHttpClient _httpClient; + + public bool SupportsPaging => PageSize > 0; + + public virtual int PageSize => 0; + public virtual TimeSpan RateLimit => TimeSpan.FromSeconds(2); + + public abstract IImportListRequestGenerator GetRequestGenerator(); + public abstract IParseImportListResponse GetParser(); + + public HttpImportListBase(IHttpClient httpClient, IImportListStatusService importListStatusService, IConfigService configService, IParsingService parsingService, Logger logger) + : base(importListStatusService, configService, parsingService, logger) + { + _httpClient = httpClient; + } + + public override IList Fetch() + { + return FetchReleases(g => g.GetListItems(), true); + } + + protected virtual IList FetchReleases(Func pageableRequestChainSelector, bool isRecent = false) + { + var releases = new List(); + var url = string.Empty; + + try + { + var generator = GetRequestGenerator(); + var parser = GetParser(); + + var pageableRequestChain = pageableRequestChainSelector(generator); + + for (int i = 0; i < pageableRequestChain.Tiers; i++) + { + var pageableRequests = pageableRequestChain.GetTier(i); + + foreach (var pageableRequest in pageableRequests) + { + var pagedReleases = new List(); + + foreach (var request in pageableRequest) + { + url = request.Url.FullUri; + + var page = FetchPage(request, parser); + + pagedReleases.AddRange(page); + + if (pagedReleases.Count >= MaxNumResultsPerQuery) + { + break; + } + + if (!IsFullPage(page)) + { + break; + } + } + + releases.AddRange(pagedReleases.Where(IsValidRelease)); + } + + if (releases.Any()) + { + break; + } + } + + _importListStatusService.RecordSuccess(Definition.Id); + } + catch (WebException webException) + { + if (webException.Status == WebExceptionStatus.NameResolutionFailure || + webException.Status == WebExceptionStatus.ConnectFailure) + { + _importListStatusService.RecordConnectionFailure(Definition.Id); + } + else + { + _importListStatusService.RecordFailure(Definition.Id); + } + + if (webException.Message.Contains("502") || webException.Message.Contains("503") || + webException.Message.Contains("timed out")) + { + _logger.Warn("{0} server is currently unavailable. {1} {2}", this, url, webException.Message); + } + else + { + _logger.Warn("{0} {1} {2}", this, url, webException.Message); + } + } + catch (TooManyRequestsException ex) + { + if (ex.RetryAfter != TimeSpan.Zero) + { + _importListStatusService.RecordFailure(Definition.Id, ex.RetryAfter); + } + else + { + _importListStatusService.RecordFailure(Definition.Id, TimeSpan.FromHours(1)); + } + _logger.Warn("API Request Limit reached for {0}", this); + } + catch (HttpException ex) + { + _importListStatusService.RecordFailure(Definition.Id); + _logger.Warn("{0} {1}", this, ex.Message); + } + catch (RequestLimitReachedException) + { + _importListStatusService.RecordFailure(Definition.Id, TimeSpan.FromHours(1)); + _logger.Warn("API Request Limit reached for {0}", this); + } + catch (CloudFlareCaptchaException ex) + { + _importListStatusService.RecordFailure(Definition.Id); + ex.WithData("FeedUrl", url); + if (ex.IsExpired) + { + _logger.Error(ex, "Expired CAPTCHA token for {0}, please refresh in import list settings.", this); + } + else + { + _logger.Error(ex, "CAPTCHA token required for {0}, check import list settings.", this); + } + } + catch (ImportListException ex) + { + _importListStatusService.RecordFailure(Definition.Id); + _logger.Warn(ex, "{0}", url); + } + catch (Exception ex) + { + _importListStatusService.RecordFailure(Definition.Id); + ex.WithData("FeedUrl", url); + _logger.Error(ex, "An error occurred while processing feed. {0}", url); + } + + return CleanupListItems(releases); + } + + protected virtual bool IsValidRelease(ImportListItemInfo release) + { + if (release.Album.IsNullOrWhiteSpace() && release.Artist.IsNullOrWhiteSpace()) + { + return false; + } + + return true; + } + + protected virtual bool IsFullPage(IList page) + { + return PageSize != 0 && page.Count >= PageSize; + } + + protected virtual IList FetchPage(ImportListRequest request, IParseImportListResponse parser) + { + var response = FetchImportListResponse(request); + + return parser.ParseResponse(response).ToList(); + } + + protected virtual ImportListResponse FetchImportListResponse(ImportListRequest request) + { + _logger.Debug("Downloading Feed " + request.HttpRequest.ToString(false)); + + if (request.HttpRequest.RateLimit < RateLimit) + { + request.HttpRequest.RateLimit = RateLimit; + } + + return new ImportListResponse(request, _httpClient.Execute(request.HttpRequest)); + } + + protected override void Test(List failures) + { + failures.AddIfNotNull(TestConnection()); + } + + protected virtual ValidationFailure TestConnection() + { + try + { + var parser = GetParser(); + var generator = GetRequestGenerator(); + var releases = FetchPage(generator.GetListItems().GetAllTiers().First().First(), parser); + + if (releases.Empty()) + { + return new ValidationFailure(string.Empty, "No results were returned from your import list, please check your settings."); + } + } + catch (RequestLimitReachedException) + { + _logger.Warn("Request limit reached"); + } + catch (UnsupportedFeedException ex) + { + _logger.Warn(ex, "Import list feed is not supported"); + + return new ValidationFailure(string.Empty, "Import list feed is not supported: " + ex.Message); + } + catch (ImportListException ex) + { + _logger.Warn(ex, "Unable to connect to import list"); + + return new ValidationFailure(string.Empty, "Unable to connect to import list. " + ex.Message); + } + catch (Exception ex) + { + _logger.Warn(ex, "Unable to connect to import list"); + + return new ValidationFailure(string.Empty, "Unable to connect to import list, check the log for more details"); + } + + return null; + } + } + +} diff --git a/src/NzbDrone.Core/ImportLists/IImportList.cs b/src/NzbDrone.Core/ImportLists/IImportList.cs new file mode 100644 index 000000000..5aad5bb9d --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/IImportList.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.ImportLists +{ + public interface IImportList : IProvider + { + ImportListType ListType { get; } + IList Fetch(); + } +} diff --git a/src/NzbDrone.Core/ImportLists/IImportListRequestGenerator.cs b/src/NzbDrone.Core/ImportLists/IImportListRequestGenerator.cs new file mode 100644 index 000000000..b0cbe0410 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/IImportListRequestGenerator.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.ImportLists +{ + public interface IImportListRequestGenerator + { + ImportListPageableRequestChain GetListItems(); + } +} diff --git a/src/NzbDrone.Core/ImportLists/IImportListSettings.cs b/src/NzbDrone.Core/ImportLists/IImportListSettings.cs new file mode 100644 index 000000000..3fbd7b7e3 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/IImportListSettings.cs @@ -0,0 +1,9 @@ +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.ImportLists +{ + public interface IImportListSettings : IProviderConfig + { + string BaseUrl { get; set; } + } +} diff --git a/src/NzbDrone.Core/ImportLists/IProcessImportListResponse.cs b/src/NzbDrone.Core/ImportLists/IProcessImportListResponse.cs new file mode 100644 index 000000000..fef025282 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/IProcessImportListResponse.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.ImportLists +{ + public interface IParseImportListResponse + { + IList ParseResponse(ImportListResponse importListResponse); + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListBase.cs b/src/NzbDrone.Core/ImportLists/ImportListBase.cs new file mode 100644 index 000000000..36ae9a184 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListBase.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.ImportLists +{ + public abstract class ImportListBase : IImportList + where TSettings : IImportListSettings, new() + { + protected readonly IImportListStatusService _importListStatusService; + protected readonly IConfigService _configService; + protected readonly IParsingService _parsingService; + protected readonly Logger _logger; + + public abstract string Name { get; } + + public abstract ImportListType ListType {get; } + + public ImportListBase(IImportListStatusService importListStatusService, IConfigService configService, IParsingService parsingService, Logger logger) + { + _importListStatusService = importListStatusService; + _configService = configService; + _parsingService = parsingService; + _logger = logger; + } + + public Type ConfigContract => typeof(TSettings); + + public virtual ProviderMessage Message => null; + + public virtual IEnumerable DefaultDefinitions + { + get + { + var config = (IProviderConfig)new TSettings(); + + yield return new ImportListDefinition + { + Name = GetType().Name, + EnableAutomaticAdd = config.Validate().IsValid, + Implementation = GetType().Name, + Settings = config + }; + } + } + + public virtual ProviderDefinition Definition { get; set; } + + public virtual object RequestAction(string action, IDictionary query) { return null; } + + protected TSettings Settings => (TSettings)Definition.Settings; + + public abstract IList Fetch(); + + protected virtual IList CleanupListItems(IEnumerable releases) + { + var result = releases.DistinctBy(r => new {r.Artist, r.Album}).ToList(); + + result.ForEach(c => + { + c.ImportListId = Definition.Id; + c.ImportList = Definition.Name; + }); + + return result; + } + + public ValidationResult Test() + { + var failures = new List(); + + try + { + Test(failures); + } + catch (Exception ex) + { + _logger.Error(ex, "Test aborted due to exception"); + failures.Add(new ValidationFailure(string.Empty, "Test was aborted due to an error: " + ex.Message)); + } + + return new ValidationResult(failures); + } + + protected abstract void Test(List failures); + + public override string ToString() + { + return Definition.Name; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListDefinition.cs b/src/NzbDrone.Core/ImportLists/ImportListDefinition.cs new file mode 100644 index 000000000..39473a044 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListDefinition.cs @@ -0,0 +1,25 @@ +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.ImportLists +{ + public class ImportListDefinition : ProviderDefinition + { + public bool EnableAutomaticAdd { get; set; } + public ImportListMonitorType ShouldMonitor { get; set; } + public int ProfileId { get; set; } + public int MetadataProfileId { get; set; } + public string RootFolderPath { get; set; } + + public override bool Enable => EnableAutomaticAdd; + + public ImportListStatus Status { get; set; } + public ImportListType ListType { get; set; } + } + + public enum ImportListMonitorType + { + None, + SpecificAlbum, + EntireArtist + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListFactory.cs b/src/NzbDrone.Core/ImportLists/ImportListFactory.cs new file mode 100644 index 000000000..79165d275 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListFactory.cs @@ -0,0 +1,86 @@ +using System.Collections.Generic; +using System.Linq; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Composition; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.ImportLists +{ + public interface IImportListFactory : IProviderFactory + { + List AutomaticAddEnabled(bool filterBlockedImportLists = true); + } + + public class ImportListFactory : ProviderFactory, IImportListFactory + { + private readonly IImportListStatusService _importListStatusService; + private readonly Logger _logger; + + public ImportListFactory(IImportListStatusService importListStatusService, + IImportListRepository providerRepository, + IEnumerable providers, + IContainer container, + IEventAggregator eventAggregator, + Logger logger) + : base(providerRepository, providers, container, eventAggregator, logger) + { + _importListStatusService = importListStatusService; + _logger = logger; + } + + protected override List Active() + { + return base.Active().Where(c => c.Enable).ToList(); + } + + public override void SetProviderCharacteristics(IImportList provider, ImportListDefinition definition) + { + base.SetProviderCharacteristics(provider, definition); + + definition.ListType = provider.ListType; + } + + public List AutomaticAddEnabled(bool filterBlockedImportLists = true) + { + var enabledImportLists = GetAvailableProviders().Where(n => ((ImportListDefinition)n.Definition).EnableAutomaticAdd); + + if (filterBlockedImportLists) + { + return FilterBlockedImportLists(enabledImportLists).ToList(); + } + + return enabledImportLists.ToList(); + } + + private IEnumerable FilterBlockedImportLists(IEnumerable importLists) + { + var blockedImportLists = _importListStatusService.GetBlockedProviders().ToDictionary(v => v.ProviderId, v => v); + + foreach (var importList in importLists) + { + ImportListStatus blockedImportListStatus; + if (blockedImportLists.TryGetValue(importList.Definition.Id, out blockedImportListStatus)) + { + _logger.Debug("Temporarily ignoring import list {0} till {1} due to recent failures.", importList.Definition.Name, blockedImportListStatus.DisabledTill.Value.ToLocalTime()); + continue; + } + + yield return importList; + } + } + + public override ValidationResult Test(ImportListDefinition definition) + { + var result = base.Test(definition); + + if ((result == null || result.IsValid) && definition.Id != 0) + { + _importListStatusService.RecordSuccess(definition.Id); + } + + return result; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListPageableRequest.cs b/src/NzbDrone.Core/ImportLists/ImportListPageableRequest.cs new file mode 100644 index 000000000..64b4fbe81 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListPageableRequest.cs @@ -0,0 +1,25 @@ +using System.Collections; +using System.Collections.Generic; + +namespace NzbDrone.Core.ImportLists +{ + public class ImportListPageableRequest : IEnumerable + { + private readonly IEnumerable _enumerable; + + public ImportListPageableRequest(IEnumerable enumerable) + { + _enumerable = enumerable; + } + + public IEnumerator GetEnumerator() + { + return _enumerable.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return _enumerable.GetEnumerator(); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListPageableRequestChain.cs b/src/NzbDrone.Core/ImportLists/ImportListPageableRequestChain.cs new file mode 100644 index 000000000..9b2503c35 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListPageableRequestChain.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Linq; + +namespace NzbDrone.Core.ImportLists +{ + public class ImportListPageableRequestChain + { + private List> _chains; + + public ImportListPageableRequestChain() + { + _chains = new List>(); + _chains.Add(new List()); + } + + public int Tiers => _chains.Count; + + public IEnumerable GetAllTiers() + { + return _chains.SelectMany(v => v); + } + + public IEnumerable GetTier(int index) + { + return _chains[index]; + } + + public void Add(IEnumerable request) + { + if (request == null) + { + return; + } + + _chains.Last().Add(new ImportListPageableRequest(request)); + } + + public void AddTier(IEnumerable request) + { + AddTier(); + Add(request); + } + + public void AddTier() + { + if (_chains.Last().Count == 0) + { + return; + } + + _chains.Add(new List()); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListRepository.cs b/src/NzbDrone.Core/ImportLists/ImportListRepository.cs new file mode 100644 index 000000000..3471d39a0 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListRepository.cs @@ -0,0 +1,24 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.ImportLists +{ + public interface IImportListRepository : IProviderRepository + { + void UpdateSettings(ImportListDefinition model); + } + + public class ImportListRepository : ProviderRepository, IImportListRepository + { + public ImportListRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + + public void UpdateSettings(ImportListDefinition model) + { + SetFields(model, m => m.Settings); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListRequest.cs b/src/NzbDrone.Core/ImportLists/ImportListRequest.cs new file mode 100644 index 000000000..f95b9b95e --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListRequest.cs @@ -0,0 +1,21 @@ +using NzbDrone.Common.Http; + +namespace NzbDrone.Core.ImportLists +{ + public class ImportListRequest + { + public HttpRequest HttpRequest { get; private set; } + + public ImportListRequest(string url, HttpAccept httpAccept) + { + HttpRequest = new HttpRequest(url, httpAccept); + } + + public ImportListRequest(HttpRequest httpRequest) + { + HttpRequest = httpRequest; + } + + public HttpUri Url => HttpRequest.Url; + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListResponse.cs b/src/NzbDrone.Core/ImportLists/ImportListResponse.cs new file mode 100644 index 000000000..2dc465b8d --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListResponse.cs @@ -0,0 +1,24 @@ +using NzbDrone.Common.Http; + +namespace NzbDrone.Core.ImportLists +{ + public class ImportListResponse + { + private readonly ImportListRequest _importListRequest; + private readonly HttpResponse _httpResponse; + + public ImportListResponse(ImportListRequest importListRequest, HttpResponse httpResponse) + { + _importListRequest = importListRequest; + _httpResponse = httpResponse; + } + + public ImportListRequest Request => _importListRequest; + + public HttpRequest HttpRequest => _httpResponse.Request; + + public HttpResponse HttpResponse => _httpResponse; + + public string Content => _httpResponse.Content; + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListStatus.cs b/src/NzbDrone.Core/ImportLists/ImportListStatus.cs new file mode 100644 index 000000000..e58976744 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListStatus.cs @@ -0,0 +1,10 @@ +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.ThingiProvider.Status; + +namespace NzbDrone.Core.ImportLists +{ + public class ImportListStatus : ProviderStatusBase + { + public ImportListItemInfo LastSyncListInfo { get; set; } + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListStatusRepository.cs b/src/NzbDrone.Core/ImportLists/ImportListStatusRepository.cs new file mode 100644 index 000000000..8bf500a7a --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListStatusRepository.cs @@ -0,0 +1,19 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.ThingiProvider.Status; + +namespace NzbDrone.Core.ImportLists +{ + public interface IImportListStatusRepository : IProviderStatusRepository + { + } + + public class ImportListStatusRepository : ProviderStatusRepository, IImportListStatusRepository + + { + public ImportListStatusRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListStatusService.cs b/src/NzbDrone.Core/ImportLists/ImportListStatusService.cs new file mode 100644 index 000000000..9898a3f78 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListStatusService.cs @@ -0,0 +1,41 @@ +using NLog; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.ThingiProvider.Status; + +namespace NzbDrone.Core.ImportLists +{ + public interface IImportListStatusService : IProviderStatusServiceBase + { + ImportListItemInfo GetLastSyncListInfo(int importListId); + + void UpdateListSyncStatus(int importListId, ImportListItemInfo listItemInfo); + } + + public class ImportListStatusService : ProviderStatusServiceBase, IImportListStatusService + { + public ImportListStatusService(IImportListStatusRepository providerStatusRepository, IEventAggregator eventAggregator, IRuntimeInfo runtimeInfo, Logger logger) + : base(providerStatusRepository, eventAggregator, runtimeInfo, logger) + { + } + + public ImportListItemInfo GetLastSyncListInfo(int importListId) + { + return GetProviderStatus(importListId).LastSyncListInfo; + } + + + public void UpdateListSyncStatus(int importListId, ImportListItemInfo listItemInfo) + { + lock (_syncRoot) + { + var status = GetProviderStatus(importListId); + + status.LastSyncListInfo = listItemInfo; + + _providerStatusRepository.Upsert(status); + } + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListSyncCommand.cs b/src/NzbDrone.Core/ImportLists/ImportListSyncCommand.cs new file mode 100644 index 000000000..9051d205f --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListSyncCommand.cs @@ -0,0 +1,24 @@ +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.ImportLists +{ + public class ImportListSyncCommand : Command + { + public int? DefinitionId { get; set; } + + public ImportListSyncCommand() + { + } + + public ImportListSyncCommand(int? definition) + { + DefinitionId = definition; + } + + public override bool SendUpdatesToClient => true; + + public override bool IsTypeExclusive => true; + + public override bool UpdateScheduledTask => !DefinitionId.HasValue; + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListSyncCompleteEvent.cs b/src/NzbDrone.Core/ImportLists/ImportListSyncCompleteEvent.cs new file mode 100644 index 000000000..b055a0383 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListSyncCompleteEvent.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.ImportLists +{ + public class ImportListSyncCompleteEvent : IEvent + { + public List ProcessedDecisions { get; private set; } + + public ImportListSyncCompleteEvent(List processedDecisions) + { + ProcessedDecisions = processedDecisions; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs b/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs new file mode 100644 index 000000000..f5cd4dff1 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs @@ -0,0 +1,186 @@ +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Core.ImportLists.Exclusions; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.MetadataSource; +using NzbDrone.Core.Music; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.ImportLists +{ + public class ImportListSyncService : IExecute + { + private readonly IImportListStatusService _importListStatusService; + private readonly IImportListFactory _importListFactory; + private readonly IImportListExclusionService _importListExclusionService; + private readonly IFetchAndParseImportList _listFetcherAndParser; + private readonly ISearchForNewAlbum _albumSearchService; + private readonly ISearchForNewArtist _artistSearchService; + private readonly IArtistService _artistService; + private readonly IAddArtistService _addArtistService; + private readonly IEventAggregator _eventAggregator; + private readonly Logger _logger; + + public ImportListSyncService(IImportListStatusService importListStatusService, + IImportListFactory importListFactory, + IImportListExclusionService importListExclusionService, + IFetchAndParseImportList listFetcherAndParser, + ISearchForNewAlbum albumSearchService, + ISearchForNewArtist artistSearchService, + IArtistService artistService, + IAddArtistService addArtistService, + IEventAggregator eventAggregator, + Logger logger) + { + _importListStatusService = importListStatusService; + _importListFactory = importListFactory; + _importListExclusionService = importListExclusionService; + _listFetcherAndParser = listFetcherAndParser; + _albumSearchService = albumSearchService; + _artistSearchService = artistSearchService; + _artistService = artistService; + _addArtistService = addArtistService; + _eventAggregator = eventAggregator; + _logger = logger; + } + + + private List SyncAll() + { + _logger.ProgressInfo("Starting Import List Sync"); + + var rssReleases = _listFetcherAndParser.Fetch(); + + var reports = rssReleases.ToList(); + + return ProcessReports(reports); + + } + + private List SyncList(ImportListDefinition definition) + { + _logger.ProgressInfo(string.Format("Starting Import List Refresh for List {0}", definition.Name)); + + var rssReleases = _listFetcherAndParser.FetchSingleList(definition); + + var reports = rssReleases.ToList(); + + return ProcessReports(reports); + + } + + private List ProcessReports(List reports) + { + var processed = new List(); + var artistsToAdd = new List(); + + _logger.ProgressInfo("Processing {0} list items", reports.Count); + + var reportNumber = 1; + + var listExclusions = _importListExclusionService.All(); + + foreach (var report in reports) + { + _logger.ProgressTrace("Processing list item {0}/{1}", reportNumber, reports.Count); + + reportNumber++; + + var importList = _importListFactory.Get(report.ImportListId); + + // Map MBid if we only have an album title + if (report.AlbumMusicBrainzId.IsNullOrWhiteSpace() && report.Album.IsNotNullOrWhiteSpace()) + { + var mappedAlbum = _albumSearchService.SearchForNewAlbum(report.Album, report.Artist) + .FirstOrDefault(); + + if (mappedAlbum == null) continue; // Break if we are looking for an album and cant find it. This will avoid us from adding the artist and possibly getting it wrong. + + report.AlbumMusicBrainzId = mappedAlbum.ForeignAlbumId; + report.Album = mappedAlbum.Title; + report.Artist = mappedAlbum.ArtistMetadata?.Value?.Name; + report.ArtistMusicBrainzId = mappedAlbum?.ArtistMetadata?.Value?.ForeignArtistId; + + } + + // Map MBid if we only have a artist name + if (report.ArtistMusicBrainzId.IsNullOrWhiteSpace() && report.Artist.IsNotNullOrWhiteSpace()) + { + var mappedArtist = _artistSearchService.SearchForNewArtist(report.Artist) + .FirstOrDefault(); + report.ArtistMusicBrainzId = mappedArtist?.Metadata.Value?.ForeignArtistId; + report.Artist = mappedArtist?.Metadata.Value?.Name; + } + + // Check to see if artist in DB + var existingArtist = _artistService.FindById(report.ArtistMusicBrainzId); + + // Check to see if artist excluded + var excludedArtist = listExclusions.Where(s => s.ForeignId == report.ArtistMusicBrainzId).SingleOrDefault(); + + if (excludedArtist != null) + { + _logger.Debug("{0} [{1}] Rejected due to list exlcusion", report.ArtistMusicBrainzId, report.Artist); + } + + // Append Artist if not already in DB or already on add list + if (existingArtist == null && excludedArtist == null && artistsToAdd.All(s => s.Metadata.Value.ForeignArtistId != report.ArtistMusicBrainzId)) + { + var monitored = importList.ShouldMonitor != ImportListMonitorType.None; + artistsToAdd.Add(new Artist + { + Metadata = new ArtistMetadata { + ForeignArtistId = report.ArtistMusicBrainzId, + Name = report.Artist + }, + Monitored = monitored, + RootFolderPath = importList.RootFolderPath, + QualityProfileId = importList.ProfileId, + MetadataProfileId = importList.MetadataProfileId, + Tags = importList.Tags, + AlbumFolder = true, + AddOptions = new AddArtistOptions { + SearchForMissingAlbums = monitored, + Monitored = monitored, + Monitor = monitored ? MonitorTypes.All : MonitorTypes.None + } + }); + } + + // Add Album so we know what to monitor + if (report.AlbumMusicBrainzId.IsNotNullOrWhiteSpace() && artistsToAdd.Any(s => s.Metadata.Value.ForeignArtistId == report.ArtistMusicBrainzId) && importList.ShouldMonitor == ImportListMonitorType.SpecificAlbum) + { + artistsToAdd.Find(s => s.Metadata.Value.ForeignArtistId == report.ArtistMusicBrainzId).AddOptions.AlbumsToMonitor.Add(report.AlbumMusicBrainzId); + } + } + + _addArtistService.AddArtists(artistsToAdd); + + var message = string.Format("Import List Sync Completed. Reports found: {0}, Reports grabbed: {1}", reports.Count, processed.Count); + + _logger.ProgressInfo(message); + + return processed; + } + + public void Execute(ImportListSyncCommand message) + { + List processed; + + if (message.DefinitionId.HasValue) + { + processed = SyncList(_importListFactory.Get(message.DefinitionId.Value)); + } + else + { + processed = SyncAll(); + } + + _eventAggregator.PublishEvent(new ImportListSyncCompleteEvent(processed)); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListType.cs b/src/NzbDrone.Core/ImportLists/ImportListType.cs new file mode 100644 index 000000000..556255095 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListType.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace NzbDrone.Core.ImportLists +{ + public enum ImportListType + { + Spotify, + LastFm, + Other + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListUpdatedHandler.cs b/src/NzbDrone.Core/ImportLists/ImportListUpdatedHandler.cs new file mode 100644 index 000000000..64b126b18 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/ImportListUpdatedHandler.cs @@ -0,0 +1,26 @@ +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.ThingiProvider.Events; + +namespace NzbDrone.Core.ImportLists +{ + public class ImportListUpdatedHandler : IHandle>, IHandle> + { + private readonly IManageCommandQueue _commandQueueManager; + + public ImportListUpdatedHandler(IManageCommandQueue commandQueueManager) + { + _commandQueueManager = commandQueueManager; + } + + public void Handle(ProviderUpdatedEvent message) + { + _commandQueueManager.Push(new ImportListSyncCommand(message.Definition.Id)); + } + + public void Handle(ProviderAddedEvent message) + { + _commandQueueManager.Push(new ImportListSyncCommand(message.Definition.Id)); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/LastFm/LastFmApi.cs b/src/NzbDrone.Core/ImportLists/LastFm/LastFmApi.cs new file mode 100644 index 000000000..b52c9f710 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/LastFm/LastFmApi.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.ImportLists.LastFm +{ + public class LastFmArtistList + { + public List Artist { get; set; } + } + + public class LastFmArtistResponse + { + public LastFmArtistList TopArtists { get; set; } + } + + public class LastFmArtist + { + public string Name { get; set; } + public string Mbid { get; set; } + } +} diff --git a/src/NzbDrone.Core/ImportLists/LastFm/LastFmParser.cs b/src/NzbDrone.Core/ImportLists/LastFm/LastFmParser.cs new file mode 100644 index 000000000..333dd71c8 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/LastFm/LastFmParser.cs @@ -0,0 +1,62 @@ +using NzbDrone.Core.ImportLists.Exceptions; +using NzbDrone.Core.Parser.Model; +using System.Collections.Generic; +using System.Net; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Serializer; + +namespace NzbDrone.Core.ImportLists.LastFm +{ + public class LastFmParser : IParseImportListResponse + { + private ImportListResponse _importListResponse; + + public IList ParseResponse(ImportListResponse importListResponse) + { + _importListResponse = importListResponse; + + var items = new List(); + + if (!PreProcess(_importListResponse)) + { + return items; + } + + var jsonResponse = Json.Deserialize(_importListResponse.Content); + + if (jsonResponse == null) + { + return items; + } + + foreach (var item in jsonResponse.TopArtists.Artist) + { + items.AddIfNotNull(new ImportListItemInfo + { + Artist = item.Name, + ArtistMusicBrainzId = item.Mbid + }); + } + + return items; + } + + protected virtual bool PreProcess(ImportListResponse importListResponse) + { + if (importListResponse.HttpResponse.StatusCode != HttpStatusCode.OK) + { + throw new ImportListException(importListResponse, "Import List API call resulted in an unexpected StatusCode [{0}]", importListResponse.HttpResponse.StatusCode); + } + + if (importListResponse.HttpResponse.Headers.ContentType != null && importListResponse.HttpResponse.Headers.ContentType.Contains("text/json") && + importListResponse.HttpRequest.Headers.Accept != null && !importListResponse.HttpRequest.Headers.Accept.Contains("text/json")) + { + throw new ImportListException(importListResponse, "Import List responded with html content. Site is likely blocked or unavailable."); + } + + return true; + } + + } +} diff --git a/src/NzbDrone.Core/ImportLists/LastFm/LastFmTag.cs b/src/NzbDrone.Core/ImportLists/LastFm/LastFmTag.cs new file mode 100644 index 000000000..0d2da6c61 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/LastFm/LastFmTag.cs @@ -0,0 +1,32 @@ +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.ImportLists.LastFm +{ + public class LastFmTag : HttpImportListBase + { + public override string Name => "Last.fm Tag"; + + public override ImportListType ListType => ImportListType.LastFm; + + public override int PageSize => 1000; + + public LastFmTag(IHttpClient httpClient, IImportListStatusService importListStatusService, IConfigService configService, IParsingService parsingService, Logger logger) + : base(httpClient, importListStatusService, configService, parsingService, logger) + { + } + + public override IImportListRequestGenerator GetRequestGenerator() + { + return new LastFmTagRequestGenerator { Settings = Settings}; + } + + public override IParseImportListResponse GetParser() + { + return new LastFmParser(); + } + + } +} diff --git a/src/NzbDrone.Core/ImportLists/LastFm/LastFmTagRequestGenerator.cs b/src/NzbDrone.Core/ImportLists/LastFm/LastFmTagRequestGenerator.cs new file mode 100644 index 000000000..9c7d03ee9 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/LastFm/LastFmTagRequestGenerator.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using NzbDrone.Common.Http; + +namespace NzbDrone.Core.ImportLists.LastFm +{ + public class LastFmTagRequestGenerator : IImportListRequestGenerator + { + public LastFmTagSettings Settings { get; set; } + + public int MaxPages { get; set; } + public int PageSize { get; set; } + + public LastFmTagRequestGenerator() + { + MaxPages = 1; + PageSize = 1000; + } + + public virtual ImportListPageableRequestChain GetListItems() + { + var pageableRequests = new ImportListPageableRequestChain(); + + pageableRequests.Add(GetPagedRequests()); + + return pageableRequests; + } + + private IEnumerable GetPagedRequests() + { + yield return new ImportListRequest(string.Format("{0}&tag={1}&limit={2}&api_key={3}&format=json", Settings.BaseUrl.TrimEnd('/'), Settings.TagId, Settings.Count, Settings.ApiKey), HttpAccept.Json); + } + + } +} diff --git a/src/NzbDrone.Core/ImportLists/LastFm/LastFmTagSettings.cs b/src/NzbDrone.Core/ImportLists/LastFm/LastFmTagSettings.cs new file mode 100644 index 000000000..b9a4b41bc --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/LastFm/LastFmTagSettings.cs @@ -0,0 +1,41 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.ImportLists.LastFm +{ + public class LastFmTagSettingsValidator : AbstractValidator + { + public LastFmTagSettingsValidator() + { + RuleFor(c => c.TagId).NotEmpty(); + RuleFor(c => c.Count).LessThanOrEqualTo(1000); + } + } + + public class LastFmTagSettings : IImportListSettings + { + private static readonly LastFmTagSettingsValidator Validator = new LastFmTagSettingsValidator(); + + public LastFmTagSettings() + { + BaseUrl = "http://ws.audioscrobbler.com/2.0/?method=tag.gettopartists"; + ApiKey = "204c76646d6020eee36bbc51a2fcd810"; + Count = 25; + } + + public string BaseUrl { get; set; } + public string ApiKey { get; set; } + + [FieldDefinition(0, Label = "Last.fm Tag", HelpText = "Tag to pull artists from")] + public string TagId { get; set; } + + [FieldDefinition(1, Label = "Count", HelpText = "Number of results to pull from list (Max 1000)", Type = FieldType.Number)] + public int Count { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/LastFm/LastFmUser.cs b/src/NzbDrone.Core/ImportLists/LastFm/LastFmUser.cs new file mode 100644 index 000000000..2e944c142 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/LastFm/LastFmUser.cs @@ -0,0 +1,32 @@ +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.ImportLists.LastFm +{ + public class LastFmUser : HttpImportListBase + { + public override string Name => "Last.fm User"; + + public override ImportListType ListType => ImportListType.LastFm; + + public override int PageSize => 1000; + + public LastFmUser(IHttpClient httpClient, IImportListStatusService importListStatusService, IConfigService configService, IParsingService parsingService, Logger logger) + : base(httpClient, importListStatusService, configService, parsingService, logger) + { + } + + public override IImportListRequestGenerator GetRequestGenerator() + { + return new LastFmUserRequestGenerator { Settings = Settings}; + } + + public override IParseImportListResponse GetParser() + { + return new LastFmParser(); + } + + } +} diff --git a/src/NzbDrone.Core/ImportLists/LastFm/LastFmUserRequestGenerator.cs b/src/NzbDrone.Core/ImportLists/LastFm/LastFmUserRequestGenerator.cs new file mode 100644 index 000000000..97d541a22 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/LastFm/LastFmUserRequestGenerator.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using NzbDrone.Common.Http; + +namespace NzbDrone.Core.ImportLists.LastFm +{ + public class LastFmUserRequestGenerator : IImportListRequestGenerator + { + public LastFmUserSettings Settings { get; set; } + + public int MaxPages { get; set; } + public int PageSize { get; set; } + + public LastFmUserRequestGenerator() + { + MaxPages = 1; + PageSize = 1000; + } + + public virtual ImportListPageableRequestChain GetListItems() + { + var pageableRequests = new ImportListPageableRequestChain(); + + pageableRequests.Add(GetPagedRequests()); + + return pageableRequests; + } + + private IEnumerable GetPagedRequests() + { + yield return new ImportListRequest(string.Format("{0}&user={1}&limit={2}&api_key={3}&format=json", Settings.BaseUrl.TrimEnd('/'), Settings.UserId, Settings.Count, Settings.ApiKey), HttpAccept.Json); + } + + } +} diff --git a/src/NzbDrone.Core/ImportLists/LastFm/LastFmUserSettings.cs b/src/NzbDrone.Core/ImportLists/LastFm/LastFmUserSettings.cs new file mode 100644 index 000000000..07d7ad41c --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/LastFm/LastFmUserSettings.cs @@ -0,0 +1,41 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.ImportLists.LastFm +{ + public class LastFmSettingsValidator : AbstractValidator + { + public LastFmSettingsValidator() + { + RuleFor(c => c.UserId).NotEmpty(); + RuleFor(c => c.Count).LessThanOrEqualTo(1000); + } + } + + public class LastFmUserSettings : IImportListSettings + { + private static readonly LastFmSettingsValidator Validator = new LastFmSettingsValidator(); + + public LastFmUserSettings() + { + BaseUrl = "http://ws.audioscrobbler.com/2.0/?method=user.gettopartists"; + ApiKey = "204c76646d6020eee36bbc51a2fcd810"; + Count = 25; + } + + public string BaseUrl { get; set; } + public string ApiKey { get; set; } + + [FieldDefinition(0, Label = "Last.fm UserID", HelpText = "Last.fm UserId to pull artists from")] + public string UserId { get; set; } + + [FieldDefinition(1, Label = "Count", HelpText = "Number of results to pull from list (Max 1000)", Type = FieldType.Number)] + public int Count { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/LidarrLists/LidarrLists.cs b/src/NzbDrone.Core/ImportLists/LidarrLists/LidarrLists.cs new file mode 100644 index 000000000..fb14fb1a2 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/LidarrLists/LidarrLists.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.MetadataSource; + +namespace NzbDrone.Core.ImportLists.LidarrLists +{ + public class LidarrLists : HttpImportListBase + { + public override string Name => "Lidarr Lists"; + + public override ImportListType ListType => ImportListType.Other; + + public override int PageSize => 10; + + private readonly IMetadataRequestBuilder _requestBuilder; + + public LidarrLists(IHttpClient httpClient, IImportListStatusService importListStatusService, IConfigService configService, IParsingService parsingService, IMetadataRequestBuilder requestBuilder, Logger logger) + : base(httpClient, importListStatusService, configService, parsingService, logger) + { + _requestBuilder = requestBuilder; + } + + public override IEnumerable DefaultDefinitions + { + get + { + yield return GetDefinition("iTunes Top Albums", GetSettings("itunes/album/top")); + yield return GetDefinition("iTunes New Albums", GetSettings("itunes/album/new")); + yield return GetDefinition("Apple Music Top Albums", GetSettings("apple-music/album/top")); + yield return GetDefinition("Apple Music New Albums", GetSettings("apple-music/album/new")); + yield return GetDefinition("Billboard Top Albums", GetSettings("billboard/album/top")); + yield return GetDefinition("Billboard Top Artists", GetSettings("billboard/artist/top")); + yield return GetDefinition("Last.fm Top Artists", GetSettings("lastfm/artist/top")); + } + } + + private ImportListDefinition GetDefinition(string name, LidarrListsSettings settings) + { + return new ImportListDefinition + { + EnableAutomaticAdd = false, + Name = name, + Implementation = GetType().Name, + Settings = settings + }; + } + + private LidarrListsSettings GetSettings(string url) + { + var settings = new LidarrListsSettings { ListId = url }; + + return settings; + } + + public override IImportListRequestGenerator GetRequestGenerator() + { + return new LidarrListsRequestGenerator(_requestBuilder) { Settings = Settings }; + } + + public override IParseImportListResponse GetParser() + { + return new LidarrListsParser(Settings); + } + + } +} diff --git a/src/NzbDrone.Core/ImportLists/LidarrLists/LidarrListsApi.cs b/src/NzbDrone.Core/ImportLists/LidarrLists/LidarrListsApi.cs new file mode 100644 index 000000000..b153d5011 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/LidarrLists/LidarrListsApi.cs @@ -0,0 +1,13 @@ +using System; + +namespace NzbDrone.Core.ImportLists.LidarrLists +{ + public class LidarrListsAlbum + { + public string ArtistName { get; set; } + public string AlbumTitle { get; set; } + public string ArtistId { get; set; } + public string AlbumId { get; set; } + public DateTime? ReleaseDate { get; set; } + } +} diff --git a/src/NzbDrone.Core/ImportLists/LidarrLists/LidarrListsParser.cs b/src/NzbDrone.Core/ImportLists/LidarrLists/LidarrListsParser.cs new file mode 100644 index 000000000..42a1b48ae --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/LidarrLists/LidarrListsParser.cs @@ -0,0 +1,72 @@ +using Newtonsoft.Json; +using NzbDrone.Core.ImportLists.Exceptions; +using NzbDrone.Core.Parser.Model; +using System.Collections.Generic; +using System.Net; +using NLog; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.ImportLists.LidarrLists +{ + public class LidarrListsParser : IParseImportListResponse + { + private readonly LidarrListsSettings _settings; + private ImportListResponse _importListResponse; + + public LidarrListsParser(LidarrListsSettings settings) + { + _settings = settings; + } + + public IList ParseResponse(ImportListResponse importListResponse) + { + _importListResponse = importListResponse; + + var items = new List(); + + if (!PreProcess(_importListResponse)) + { + return items; + } + + var jsonResponse = JsonConvert.DeserializeObject>(_importListResponse.Content); + + // no albums were return + if (jsonResponse == null) + { + return items; + } + + foreach (var item in jsonResponse) + { + items.AddIfNotNull(new ImportListItemInfo + { + Artist = item.ArtistName, + Album = item.AlbumTitle, + ArtistMusicBrainzId = item.ArtistId, + AlbumMusicBrainzId = item.AlbumId, + ReleaseDate = item.ReleaseDate.GetValueOrDefault() + }); + } + + return items; + } + + protected virtual bool PreProcess(ImportListResponse importListResponse) + { + if (importListResponse.HttpResponse.StatusCode != HttpStatusCode.OK) + { + throw new ImportListException(importListResponse, "Import List API call resulted in an unexpected StatusCode [{0}]", importListResponse.HttpResponse.StatusCode); + } + + if (importListResponse.HttpResponse.Headers.ContentType != null && importListResponse.HttpResponse.Headers.ContentType.Contains("text/json") && + importListResponse.HttpRequest.Headers.Accept != null && !importListResponse.HttpRequest.Headers.Accept.Contains("text/json")) + { + throw new ImportListException(importListResponse, "Import List responded with html content. Site is likely blocked or unavailable."); + } + + return true; + } + + } +} diff --git a/src/NzbDrone.Core/ImportLists/LidarrLists/LidarrListsRequestGenerator.cs b/src/NzbDrone.Core/ImportLists/LidarrLists/LidarrListsRequestGenerator.cs new file mode 100644 index 000000000..4f623b411 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/LidarrLists/LidarrListsRequestGenerator.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using NzbDrone.Core.MetadataSource; + +namespace NzbDrone.Core.ImportLists.LidarrLists +{ + public class LidarrListsRequestGenerator : IImportListRequestGenerator + { + public LidarrListsSettings Settings { get; set; } + + private readonly IMetadataRequestBuilder _requestBulder; + + public LidarrListsRequestGenerator(IMetadataRequestBuilder requestBuilder) + { + _requestBulder = requestBuilder; + } + + public virtual ImportListPageableRequestChain GetListItems() + { + var pageableRequests = new ImportListPageableRequestChain(); + + pageableRequests.Add(GetPagedRequests()); + + return pageableRequests; + } + + private IEnumerable GetPagedRequests() + { + var request = _requestBulder.GetRequestBuilder() + .Create() + .SetSegment("route", "chart/" + Settings.ListId) + .Build(); + + yield return new ImportListRequest(request); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/LidarrLists/LidarrListsSettings.cs b/src/NzbDrone.Core/ImportLists/LidarrLists/LidarrListsSettings.cs new file mode 100644 index 000000000..8d8f9d127 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/LidarrLists/LidarrListsSettings.cs @@ -0,0 +1,33 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.ImportLists.LidarrLists +{ + public class LidarrListsSettingsValidator : AbstractValidator + { + public LidarrListsSettingsValidator() + { + } + } + + public class LidarrListsSettings : IImportListSettings + { + private static readonly LidarrListsSettingsValidator Validator = new LidarrListsSettingsValidator(); + + public LidarrListsSettings() + { + BaseUrl = ""; + } + + public string BaseUrl { get; set; } + + [FieldDefinition(0, Label = "List Id", Advanced = true)] + public string ListId { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyException.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyException.cs new file mode 100644 index 000000000..9f2125a36 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyException.cs @@ -0,0 +1,27 @@ +using System; +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.ImportLists.Spotify +{ + public class SpotifyException : NzbDroneException + { + public SpotifyException(string message) : base(message) + { + } + + public SpotifyException(string message, params object[] args) : base(message, args) + { + } + + public SpotifyException(string message, Exception innerException) : base(message, innerException) + { + } + } + + public class SpotifyAuthorizationException : SpotifyException + { + public SpotifyAuthorizationException(string message) : base(message) + { + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyFollowedArtists.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyFollowedArtists.cs new file mode 100644 index 000000000..6a9bdd92a --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyFollowedArtists.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using SpotifyAPI.Web; +using SpotifyAPI.Web.Models; + +namespace NzbDrone.Core.ImportLists.Spotify +{ + public class SpotifyFollowedArtistsSettings : SpotifySettingsBase + { + public override string Scope => "user-follow-read"; + } + + public class SpotifyFollowedArtists : SpotifyImportListBase + { + public SpotifyFollowedArtists(ISpotifyProxy spotifyProxy, + IImportListStatusService importListStatusService, + IImportListRepository importListRepository, + IConfigService configService, + IParsingService parsingService, + IHttpClient httpClient, + Logger logger) + : base(spotifyProxy, importListStatusService, importListRepository, configService, parsingService, httpClient, logger) + { + } + + public override string Name => "Spotify Followed Artists"; + + public override IList Fetch(SpotifyWebAPI api) + { + var result = new List(); + + var followedArtists = _spotifyProxy.GetFollowedArtists(this, api); + var artists = followedArtists?.Artists; + + while (true) + { + if (artists?.Items == null) + { + return result; + } + + foreach (var artist in artists.Items) + { + result.AddIfNotNull(ParseFullArtist(artist)); + } + + if (!artists.HasNext()) + { + break; + } + + artists = _spotifyProxy.GetNextPage(this, api, artists); + } + + return result; + } + + private ImportListItemInfo ParseFullArtist(FullArtist artist) + { + if (artist?.Name.IsNotNullOrWhiteSpace() ?? false) + { + return new ImportListItemInfo { + Artist = artist.Name, + }; + } + + return null; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyImportListBase.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyImportListBase.cs new file mode 100644 index 000000000..2a1a57853 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyImportListBase.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Validation; +using SpotifyAPI.Web; +using SpotifyAPI.Web.Models; + +namespace NzbDrone.Core.ImportLists.Spotify +{ + public abstract class SpotifyImportListBase : ImportListBase + where TSettings : SpotifySettingsBase, new() + { + private IHttpClient _httpClient; + private IImportListRepository _importListRepository; + + protected ISpotifyProxy _spotifyProxy; + + protected SpotifyImportListBase(ISpotifyProxy spotifyProxy, + IImportListStatusService importListStatusService, + IImportListRepository importListRepository, + IConfigService configService, + IParsingService parsingService, + IHttpClient httpClient, + Logger logger) + : base(importListStatusService, configService, parsingService, logger) + { + _httpClient = httpClient; + _importListRepository = importListRepository; + _spotifyProxy = spotifyProxy; + } + + public override ImportListType ListType => ImportListType.Spotify; + + public string AccessToken => Settings.AccessToken; + + public void RefreshToken() + { + _logger.Trace("Refreshing Token"); + + Settings.Validate().Filter("RefreshToken").ThrowOnError(); + + var request = new HttpRequestBuilder(Settings.RenewUri) + .AddQueryParam("refresh_token", Settings.RefreshToken) + .Build(); + + try + { + var response = _httpClient.Get(request); + + if (response != null && response.Resource != null) + { + var token = response.Resource; + Settings.AccessToken = token.AccessToken; + Settings.Expires = DateTime.UtcNow.AddSeconds(token.ExpiresIn); + Settings.RefreshToken = token.RefreshToken != null ? token.RefreshToken : Settings.RefreshToken; + + if (Definition.Id > 0) + { + _importListRepository.UpdateSettings((ImportListDefinition)Definition); + } + } + } + catch (HttpException) + { + _logger.Warn($"Error refreshing spotify access token"); + } + + } + + public SpotifyWebAPI GetApi() + { + Settings.Validate().Filter("AccessToken", "RefreshToken").ThrowOnError(); + _logger.Trace($"Access token expires at {Settings.Expires}"); + + if (Settings.Expires < DateTime.UtcNow.AddMinutes(5)) + { + RefreshToken(); + } + + return new SpotifyWebAPI + { + AccessToken = Settings.AccessToken, + TokenType = "Bearer" + }; + } + + public override IList Fetch() + { + using (var api = GetApi()) + { + _logger.Debug("Starting spotify import list sync"); + var releases = Fetch(api); + return CleanupListItems(releases); + } + } + + public abstract IList Fetch(SpotifyWebAPI api); + + protected DateTime ParseSpotifyDate(string date, string precision) + { + if (date.IsNullOrWhiteSpace() || precision.IsNullOrWhiteSpace()) + { + return default(DateTime); + } + + string format; + + switch (precision) { + case "year": + format = "yyyy"; + break; + case "month": + format = "yyyy-MM"; + break; + case "day": + default: + format = "yyyy-MM-dd"; + break; + } + + return DateTime.TryParseExact(date, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime result) ? result : default(DateTime); + } + + protected override void Test(List failures) + { + failures.AddIfNotNull(TestConnection()); + } + + private ValidationFailure TestConnection() + { + try + { + using (var api = GetApi()) + { + var profile = _spotifyProxy.GetPrivateProfile(this, api); + _logger.Debug($"Connected to spotify profile {profile.DisplayName} [{profile.Id}]"); + return null; + } + } + catch (SpotifyAuthorizationException ex) + { + _logger.Warn(ex, "Spotify Authentication Error"); + return new ValidationFailure(string.Empty, $"Spotify authentication error: {ex.Message}"); + } + catch (Exception ex) + { + _logger.Warn(ex, "Unable to connect to Spotify"); + + return new ValidationFailure(string.Empty, "Unable to connect to import list, check the log for more details"); + } + } + + public override object RequestAction(string action, IDictionary query) + { + if (action == "startOAuth") + { + var request = new HttpRequestBuilder(Settings.OAuthUrl) + .AddQueryParam("client_id", Settings.ClientId) + .AddQueryParam("response_type", "code") + .AddQueryParam("redirect_uri", Settings.RedirectUri) + .AddQueryParam("scope", Settings.Scope) + .AddQueryParam("state", query["callbackUrl"]) + .AddQueryParam("show_dialog", true) + .Build(); + + return new { + OauthUrl = request.Url.ToString() + }; + } + else if (action == "getOAuthToken") + { + return new { + accessToken = query["access_token"], + expires = DateTime.UtcNow.AddSeconds(int.Parse(query["expires_in"])), + refreshToken = query["refresh_token"], + }; + } + + return new { }; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylist.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylist.cs new file mode 100644 index 000000000..dc8daa267 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylist.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Validation; +using SpotifyAPI.Web; +using SpotifyAPI.Web.Models; + +namespace NzbDrone.Core.ImportLists.Spotify +{ + public class SpotifyPlaylist : SpotifyImportListBase + { + public SpotifyPlaylist(ISpotifyProxy spotifyProxy, + IImportListStatusService importListStatusService, + IImportListRepository importListRepository, + IConfigService configService, + IParsingService parsingService, + IHttpClient httpClient, + Logger logger) + : base(spotifyProxy, importListStatusService, importListRepository, configService, parsingService, httpClient, logger) + { + } + + public override string Name => "Spotify Playlists"; + + public override IList Fetch(SpotifyWebAPI api) + { + return Settings.PlaylistIds.SelectMany(x => Fetch(api, x)).ToList(); + } + + public IList Fetch(SpotifyWebAPI api, string playlistId) + { + var result = new List(); + + _logger.Trace($"Processing playlist {playlistId}"); + + var playlistTracks = _spotifyProxy.GetPlaylistTracks(this, api, playlistId, "next, items(track(name, album(name,artists)))"); + + while (true) + { + if (playlistTracks?.Items == null) + { + return result; + } + + foreach (var playlistTrack in playlistTracks.Items) + { + result.AddIfNotNull(ParsePlaylistTrack(playlistTrack)); + } + + if (!playlistTracks.HasNextPage()) + { + break; + } + + playlistTracks = _spotifyProxy.GetNextPage(this, api, playlistTracks); + } + + return result; + } + + private ImportListItemInfo ParsePlaylistTrack(PlaylistTrack playlistTrack) + { + // From spotify docs: "Note, a track object may be null. This can happen if a track is no longer available." + if (playlistTrack?.Track?.Album != null) + { + var album = playlistTrack.Track.Album; + var albumName = album.Name; + var artistName = album.Artists?.FirstOrDefault()?.Name ?? playlistTrack.Track?.Artists?.FirstOrDefault()?.Name; + + if (albumName.IsNotNullOrWhiteSpace() && artistName.IsNotNullOrWhiteSpace()) + { + return new ImportListItemInfo { + Artist = artistName, + Album = albumName, + ReleaseDate = ParseSpotifyDate(album.ReleaseDate, album.ReleaseDatePrecision) + }; + } + } + + return null; + } + + public override object RequestAction(string action, IDictionary query) + { + if (action == "getPlaylists") + { + if (Settings.AccessToken.IsNullOrWhiteSpace()) + { + return new + { + playlists = new List() + }; + } + + Settings.Validate().Filter("AccessToken").ThrowOnError(); + + using (var api = GetApi()) + { + try + { + var profile = _spotifyProxy.GetPrivateProfile(this, api); + var playlistPage = _spotifyProxy.GetUserPlaylists(this, api, profile.Id); + _logger.Trace($"Got {playlistPage.Total} playlists"); + + var playlists = new List(playlistPage.Total); + while (true) + { + if (playlistPage == null) + { + break; + } + + playlists.AddRange(playlistPage.Items); + + if (!playlistPage.HasNextPage()) + { + break; + } + + playlistPage = _spotifyProxy.GetNextPage(this, api, playlistPage); + } + + return new + { + options = new { + user = profile.DisplayName, + playlists = playlists.OrderBy(p => p.Name) + .Select(p => new + { + id = p.Id, + name = p.Name + }) + } + }; + } + catch (Exception ex) + { + _logger.Warn(ex, "Error fetching playlists from Spotify"); + return new { }; + } + } + } + else + { + return base.RequestAction(action, query); + } + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylistSettings.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylistSettings.cs new file mode 100644 index 000000000..ac4d87199 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyPlaylistSettings.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using FluentValidation; +using NzbDrone.Core.Annotations; + +namespace NzbDrone.Core.ImportLists.Spotify +{ + public class SpotifyPlaylistSettingsValidator : SpotifySettingsBaseValidator + { + public SpotifyPlaylistSettingsValidator() + : base() + { + RuleFor(c => c.PlaylistIds).NotEmpty(); + } + } + + public class SpotifyPlaylistSettings : SpotifySettingsBase + { + protected override AbstractValidator Validator => new SpotifyPlaylistSettingsValidator(); + + public SpotifyPlaylistSettings() + { + PlaylistIds = new string[] { }; + } + + public override string Scope => "playlist-read-private"; + + [FieldDefinition(1, Label = "Playlists", Type = FieldType.Playlist)] + public IEnumerable PlaylistIds { get; set; } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifyProxy.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyProxy.cs new file mode 100644 index 000000000..bcaa5cca9 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifyProxy.cs @@ -0,0 +1,109 @@ +using System; +using NLog; +using SpotifyAPI.Web; +using SpotifyAPI.Web.Enums; +using SpotifyAPI.Web.Models; + +namespace NzbDrone.Core.ImportLists.Spotify +{ + public interface ISpotifyProxy + { + PrivateProfile GetPrivateProfile(SpotifyImportListBase list, SpotifyWebAPI api) + where TSettings : SpotifySettingsBase, new(); + Paging GetUserPlaylists(SpotifyImportListBase list, SpotifyWebAPI api, string id) + where TSettings : SpotifySettingsBase, new(); + FollowedArtists GetFollowedArtists(SpotifyImportListBase list, SpotifyWebAPI api) + where TSettings : SpotifySettingsBase, new(); + Paging GetSavedAlbums(SpotifyImportListBase list, SpotifyWebAPI api) + where TSettings : SpotifySettingsBase, new(); + Paging GetPlaylistTracks(SpotifyImportListBase list, SpotifyWebAPI api, string id, string fields) + where TSettings : SpotifySettingsBase, new(); + Paging GetNextPage(SpotifyImportListBase list, SpotifyWebAPI api, Paging item) + where TSettings : SpotifySettingsBase, new(); + CursorPaging GetNextPage(SpotifyImportListBase list, SpotifyWebAPI api, CursorPaging item) + where TSettings : SpotifySettingsBase, new(); + } + + public class SpotifyProxy : ISpotifyProxy + { + private readonly Logger _logger; + + public SpotifyProxy(Logger logger) + { + _logger = logger; + } + + public PrivateProfile GetPrivateProfile(SpotifyImportListBase list, SpotifyWebAPI api) + where TSettings : SpotifySettingsBase, new() + { + return Execute(list, api, x => x.GetPrivateProfile()); + } + + public Paging GetUserPlaylists(SpotifyImportListBase list, SpotifyWebAPI api, string id) + where TSettings : SpotifySettingsBase, new() + { + return Execute(list, api, x => x.GetUserPlaylists(id)); + } + + public FollowedArtists GetFollowedArtists(SpotifyImportListBase list, SpotifyWebAPI api) + where TSettings : SpotifySettingsBase, new() + { + return Execute(list, api, x => x.GetFollowedArtists(FollowType.Artist)); + } + + public Paging GetSavedAlbums(SpotifyImportListBase list, SpotifyWebAPI api) + where TSettings : SpotifySettingsBase, new() + { + return Execute(list, api, x => x.GetSavedAlbums(50)); + } + + public Paging GetPlaylistTracks(SpotifyImportListBase list, SpotifyWebAPI api, string id, string fields) + where TSettings : SpotifySettingsBase, new() + { + return Execute(list, api, x => x.GetPlaylistTracks(id, fields: fields)); + } + + public Paging GetNextPage(SpotifyImportListBase list, SpotifyWebAPI api, Paging item) + where TSettings : SpotifySettingsBase, new() + { + return Execute(list, api, (x) => x.GetNextPage(item)); + } + + public CursorPaging GetNextPage(SpotifyImportListBase list, SpotifyWebAPI api, CursorPaging item) + where TSettings : SpotifySettingsBase, new() + { + return Execute(list, api, (x) => x.GetNextPage(item)); + } + + public T Execute(SpotifyImportListBase list, SpotifyWebAPI api, Func method, bool allowReauth = true) + where T : BasicModel + where TSettings : SpotifySettingsBase, new() + { + T result = method(api); + if (result.HasError()) + { + // If unauthorized, refresh token and try again + if (result.Error.Status == 401) + { + if (allowReauth) + { + _logger.Debug("Spotify authorization error, refreshing token and retrying"); + list.RefreshToken(); + api.AccessToken = list.AccessToken; + return Execute(list, api, method, false); + } + else + { + throw new SpotifyAuthorizationException(result.Error.Message); + } + } + else + { + throw new SpotifyException("[{0}] {1}", result.Error.Status, result.Error.Message); + } + } + + return result; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifySavedAlbums.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifySavedAlbums.cs new file mode 100644 index 000000000..bc250da45 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifySavedAlbums.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using SpotifyAPI.Web; +using SpotifyAPI.Web.Models; + +namespace NzbDrone.Core.ImportLists.Spotify +{ + public class SpotifySavedAlbumsSettings : SpotifySettingsBase + { + public override string Scope => "user-library-read"; + } + + public class SpotifySavedAlbums : SpotifyImportListBase + { + public SpotifySavedAlbums(ISpotifyProxy spotifyProxy, + IImportListStatusService importListStatusService, + IImportListRepository importListRepository, + IConfigService configService, + IParsingService parsingService, + IHttpClient httpClient, + Logger logger) + : base(spotifyProxy, importListStatusService, importListRepository, configService, parsingService, httpClient, logger) + { + } + + public override string Name => "Spotify Saved Albums"; + + public override IList Fetch(SpotifyWebAPI api) + { + var result = new List(); + + var savedAlbums = _spotifyProxy.GetSavedAlbums(this, api); + + _logger.Trace($"Got {savedAlbums?.Total ?? 0} saved albums"); + + while (true) + { + if (savedAlbums?.Items == null) + { + return result; + } + + foreach (var savedAlbum in savedAlbums.Items) + { + result.AddIfNotNull(ParseSavedAlbum(savedAlbum)); + } + + if (!savedAlbums.HasNextPage()) + { + break; + } + + savedAlbums = _spotifyProxy.GetNextPage(this, api, savedAlbums); + } + + return result; + } + + private ImportListItemInfo ParseSavedAlbum(SavedAlbum savedAlbum) + { + var artistName = savedAlbum?.Album?.Artists?.FirstOrDefault()?.Name; + var albumName = savedAlbum?.Album?.Name; + _logger.Trace($"Adding {artistName} - {albumName}"); + + if (artistName.IsNotNullOrWhiteSpace() && albumName.IsNotNullOrWhiteSpace()) + { + return new ImportListItemInfo { + Artist = artistName, + Album = albumName, + ReleaseDate = ParseSpotifyDate(savedAlbum?.Album?.ReleaseDate, savedAlbum?.Album?.ReleaseDatePrecision) + }; + } + + return null; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Spotify/SpotifySettingsBase.cs b/src/NzbDrone.Core/ImportLists/Spotify/SpotifySettingsBase.cs new file mode 100644 index 000000000..590bfcdbd --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Spotify/SpotifySettingsBase.cs @@ -0,0 +1,55 @@ +using System; +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.ImportLists.Spotify +{ + public class SpotifySettingsBaseValidator : AbstractValidator + where TSettings : SpotifySettingsBase + { + public SpotifySettingsBaseValidator() + { + RuleFor(c => c.AccessToken).NotEmpty(); + RuleFor(c => c.RefreshToken).NotEmpty(); + RuleFor(c => c.Expires).NotEmpty(); + } + } + + public class SpotifySettingsBase : IImportListSettings + where TSettings : SpotifySettingsBase + { + protected virtual AbstractValidator Validator => new SpotifySettingsBaseValidator(); + + public SpotifySettingsBase() + { + BaseUrl = "https://api.spotify.com/v1"; + SignIn = "startOAuth"; + } + + public string BaseUrl { get; set; } + + public string OAuthUrl => "https://accounts.spotify.com/authorize"; + public string RedirectUri => "https://spotify.lidarr.audio/auth"; + public string RenewUri => "https://spotify.lidarr.audio/renew"; + public string ClientId => "848082790c32436d8a0405fddca0aa18"; + public virtual string Scope => ""; + + [FieldDefinition(0, Label = "Access Token", Type = FieldType.Textbox, Hidden = HiddenType.Hidden)] + public string AccessToken { get; set; } + + [FieldDefinition(0, Label = "Refresh Token", Type = FieldType.Textbox, Hidden = HiddenType.Hidden)] + public string RefreshToken { get; set; } + + [FieldDefinition(0, Label = "Expires", Type = FieldType.Textbox, Hidden = HiddenType.Hidden)] + public DateTime Expires { get; set; } + + [FieldDefinition(99, Label = "Authenticate with Spotify", Type = FieldType.OAuth)] + public string SignIn { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate((TSettings)this)); + } + } +} diff --git a/src/NzbDrone.Core/IndexerSearch/AlbumSearchCommand.cs b/src/NzbDrone.Core/IndexerSearch/AlbumSearchCommand.cs new file mode 100644 index 000000000..1e0bbda86 --- /dev/null +++ b/src/NzbDrone.Core/IndexerSearch/AlbumSearchCommand.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.IndexerSearch +{ + public class AlbumSearchCommand : Command + { + public List AlbumIds { get; set; } + + public override bool SendUpdatesToClient => true; + + public AlbumSearchCommand() + { + } + + public AlbumSearchCommand(List albumIds) + { + AlbumIds = albumIds; + } + } +} diff --git a/src/NzbDrone.Core/IndexerSearch/AlbumSearchService.cs b/src/NzbDrone.Core/IndexerSearch/AlbumSearchService.cs new file mode 100644 index 000000000..345c9f1d3 --- /dev/null +++ b/src/NzbDrone.Core/IndexerSearch/AlbumSearchService.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Queue; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.IndexerSearch +{ + class AlbumSearchService : IExecute, + IExecute, + IExecute + { + private readonly ISearchForNzb _nzbSearchService; + private readonly IAlbumService _albumService; + private readonly IAlbumCutoffService _albumCutoffService; + private readonly IQueueService _queueService; + private readonly IProcessDownloadDecisions _processDownloadDecisions; + private readonly Logger _logger; + + public AlbumSearchService(ISearchForNzb nzbSearchService, + IAlbumService albumService, + IAlbumCutoffService albumCutoffService, + IQueueService queueService, + IProcessDownloadDecisions processDownloadDecisions, + Logger logger) + { + _nzbSearchService = nzbSearchService; + _albumService = albumService; + _albumCutoffService = albumCutoffService; + _queueService = queueService; + _processDownloadDecisions = processDownloadDecisions; + _logger = logger; + } + + private void SearchForMissingAlbums(List albums, bool userInvokedSearch) + { + _logger.ProgressInfo("Performing missing search for {0} albums", albums.Count); + var downloadedCount = 0; + + foreach (var album in albums) + { + List decisions; + decisions = _nzbSearchService.AlbumSearch(album.Id, false, userInvokedSearch, false); + var processed = _processDownloadDecisions.ProcessDecisions(decisions); + + downloadedCount += processed.Grabbed.Count; + } + + _logger.ProgressInfo("Completed missing search for {0} albums. {1} reports downloaded.", albums.Count, downloadedCount); + } + + + public void Execute(AlbumSearchCommand message) + { + foreach (var albumId in message.AlbumIds) + { + var decisions = + _nzbSearchService.AlbumSearch(albumId, false, message.Trigger == CommandTrigger.Manual, false); + var processed = _processDownloadDecisions.ProcessDecisions(decisions); + + _logger.ProgressInfo("Album search completed. {0} reports downloaded.", processed.Grabbed.Count); + } + } + + public void Execute(MissingAlbumSearchCommand message) + { + List albums; + + if (message.ArtistId.HasValue) + { + int artistId = message.ArtistId.Value; + + var pagingSpec = new PagingSpec + { + Page = 1, + PageSize = 100000, + SortDirection = SortDirection.Ascending, + SortKey = "Id" + }; + + pagingSpec.FilterExpressions.Add(v => v.Monitored == true && v.Artist.Value.Monitored == true); + + albums = _albumService.AlbumsWithoutFiles(pagingSpec).Records.Where(e => e.ArtistId.Equals(artistId)).ToList(); + + } + + else + { + var pagingSpec = new PagingSpec + { + Page = 1, + PageSize = 100000, + SortDirection = SortDirection.Ascending, + SortKey = "Id" + }; + + pagingSpec.FilterExpressions.Add(v => v.Monitored == true && v.Artist.Value.Monitored == true); + + albums = _albumService.AlbumsWithoutFiles(pagingSpec).Records.ToList(); + + } + + var queue = _queueService.GetQueue().Where(q => q.Album != null).Select(q => q.Album.Id); + var missing = albums.Where(e => !queue.Contains(e.Id)).ToList(); + + SearchForMissingAlbums(missing, message.Trigger == CommandTrigger.Manual); + } + + public void Execute(CutoffUnmetAlbumSearchCommand message) + { + Expression> filterExpression; + + filterExpression = v => + v.Monitored == true && + v.Artist.Value.Monitored == true; + + var pagingSpec = new PagingSpec + { + Page = 1, + PageSize = 100000, + SortDirection = SortDirection.Ascending, + SortKey = "Id" + }; + + pagingSpec.FilterExpressions.Add(filterExpression); + + var albums = _albumCutoffService.AlbumsWhereCutoffUnmet(pagingSpec).Records.ToList(); + + var queue = _queueService.GetQueue().Where(q => q.Album != null).Select(q => q.Album.Id); + var missing = albums.Where(e => !queue.Contains(e.Id)).ToList(); + + SearchForMissingAlbums(missing, message.Trigger == CommandTrigger.Manual); + } + } +} diff --git a/src/NzbDrone.Core/IndexerSearch/ArtistSearchCommand.cs b/src/NzbDrone.Core/IndexerSearch/ArtistSearchCommand.cs new file mode 100644 index 000000000..4233c3e7a --- /dev/null +++ b/src/NzbDrone.Core/IndexerSearch/ArtistSearchCommand.cs @@ -0,0 +1,11 @@ +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.IndexerSearch +{ + public class ArtistSearchCommand : Command + { + public int ArtistId { get; set; } + + public override bool SendUpdatesToClient => true; + } +} diff --git a/src/NzbDrone.Core/IndexerSearch/ArtistSearchService.cs b/src/NzbDrone.Core/IndexerSearch/ArtistSearchService.cs new file mode 100644 index 000000000..2e3cc5ccb --- /dev/null +++ b/src/NzbDrone.Core/IndexerSearch/ArtistSearchService.cs @@ -0,0 +1,31 @@ +using NLog; +using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Core.Download; +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.IndexerSearch +{ + public class ArtistSearchService : IExecute + { + private readonly ISearchForNzb _nzbSearchService; + private readonly IProcessDownloadDecisions _processDownloadDecisions; + private readonly Logger _logger; + + public ArtistSearchService(ISearchForNzb nzbSearchService, + IProcessDownloadDecisions processDownloadDecisions, + Logger logger) + { + _nzbSearchService = nzbSearchService; + _processDownloadDecisions = processDownloadDecisions; + _logger = logger; + } + + public void Execute(ArtistSearchCommand message) + { + var decisions = _nzbSearchService.ArtistSearch(message.ArtistId, false, message.Trigger == CommandTrigger.Manual, false); + var processed = _processDownloadDecisions.ProcessDecisions(decisions); + + _logger.ProgressInfo("Artist search completed. {0} reports downloaded.", processed.Grabbed.Count); + } + } +} diff --git a/src/NzbDrone.Core/IndexerSearch/CutoffUnmetAlbumSearchCommand.cs b/src/NzbDrone.Core/IndexerSearch/CutoffUnmetAlbumSearchCommand.cs new file mode 100644 index 000000000..cd8156357 --- /dev/null +++ b/src/NzbDrone.Core/IndexerSearch/CutoffUnmetAlbumSearchCommand.cs @@ -0,0 +1,20 @@ +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.IndexerSearch +{ + public class CutoffUnmetAlbumSearchCommand : Command + { + public int? ArtistId { get; set; } + + public override bool SendUpdatesToClient => true; + + public CutoffUnmetAlbumSearchCommand() + { + } + + public CutoffUnmetAlbumSearchCommand(int artistId) + { + ArtistId = artistId; + } + } +} diff --git a/src/NzbDrone.Core/IndexerSearch/Definitions/AlbumSearchCriteria.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/AlbumSearchCriteria.cs new file mode 100644 index 000000000..3c46443f7 --- /dev/null +++ b/src/NzbDrone.Core/IndexerSearch/Definitions/AlbumSearchCriteria.cs @@ -0,0 +1,18 @@ +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.IndexerSearch.Definitions +{ + public class AlbumSearchCriteria : SearchCriteriaBase + { + public string AlbumTitle { get; set; } + public int AlbumYear { get; set; } + public string Disambiguation { get; set; } + + public string AlbumQuery => GetQueryTitle($"{AlbumTitle}{(Disambiguation.IsNullOrWhiteSpace() ? string.Empty : $"+{Disambiguation}")}"); + + public override string ToString() + { + return $"[{Artist.Name} - {AlbumTitle}{(Disambiguation.IsNullOrWhiteSpace() ? string.Empty : $" ({Disambiguation})")} ({AlbumYear})]"; + } + } +} diff --git a/src/NzbDrone.Core/IndexerSearch/Definitions/AnimeEpisodeSearchCriteria.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/AnimeEpisodeSearchCriteria.cs deleted file mode 100644 index a7cd8b9d2..000000000 --- a/src/NzbDrone.Core/IndexerSearch/Definitions/AnimeEpisodeSearchCriteria.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace NzbDrone.Core.IndexerSearch.Definitions -{ - public class AnimeEpisodeSearchCriteria : SearchCriteriaBase - { - public int AbsoluteEpisodeNumber { get; set; } - - public override string ToString() - { - return string.Format("[{0} : {1:00}]", Series.Title, AbsoluteEpisodeNumber); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/IndexerSearch/Definitions/ArtistSearchCriteria.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/ArtistSearchCriteria.cs new file mode 100644 index 000000000..6de6dd3b1 --- /dev/null +++ b/src/NzbDrone.Core/IndexerSearch/Definitions/ArtistSearchCriteria.cs @@ -0,0 +1,12 @@ +using System; + +namespace NzbDrone.Core.IndexerSearch.Definitions +{ + public class ArtistSearchCriteria : SearchCriteriaBase + { + public override string ToString() + { + return $"[{Artist.Name}]"; + } + } +} diff --git a/src/NzbDrone.Core/IndexerSearch/Definitions/DailyEpisodeSearchCriteria.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/DailyEpisodeSearchCriteria.cs deleted file mode 100644 index d5eeb15b7..000000000 --- a/src/NzbDrone.Core/IndexerSearch/Definitions/DailyEpisodeSearchCriteria.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; - -namespace NzbDrone.Core.IndexerSearch.Definitions -{ - public class DailyEpisodeSearchCriteria : SearchCriteriaBase - { - public DateTime AirDate { get; set; } - - public override string ToString() - { - 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/SearchCriteriaBase.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs index c5e602e59..57b939fd1 100644 --- a/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs +++ b/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs @@ -3,23 +3,25 @@ using System.Linq; using System.Text.RegularExpressions; using NzbDrone.Common.EnsureThat; using NzbDrone.Common.Extensions; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.IndexerSearch.Definitions { public abstract class SearchCriteriaBase { - private static readonly Regex SpecialCharacter = new Regex(@"[`'.]", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex SpecialCharacter = new Regex(@"[`'’.]", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex NonWord = new Regex(@"[\W]", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex BeginningThe = new Regex(@"^the\s", RegexOptions.IgnoreCase | RegexOptions.Compiled); - public Series Series { get; set; } - public List SceneTitles { get; set; } - public List Episodes { get; set; } public virtual bool MonitoredEpisodesOnly { get; set; } public virtual bool UserInvokedSearch { get; set; } + public virtual bool InteractiveSearch { get; set; } - public List QueryTitles => SceneTitles.Select(GetQueryTitle).ToList(); + public Artist Artist { get; set; } + public List Albums { get; set; } + public List Tracks { get; set; } + + public string ArtistQuery => GetQueryTitle(Artist.Name); public static string GetQueryTitle(string title) { @@ -27,14 +29,16 @@ namespace NzbDrone.Core.IndexerSearch.Definitions var cleanTitle = BeginningThe.Replace(title, string.Empty); - cleanTitle = cleanTitle.Replace("&", "and"); + cleanTitle = cleanTitle.Replace(" & ", " "); cleanTitle = SpecialCharacter.Replace(cleanTitle, ""); cleanTitle = NonWord.Replace(cleanTitle, "+"); //remove any repeating +s cleanTitle = Regex.Replace(cleanTitle, @"\+{2,}", "+"); cleanTitle = cleanTitle.RemoveAccent(); - return cleanTitle.Trim('+', ' '); + cleanTitle = cleanTitle.Trim('+', ' '); + + return cleanTitle.Length == 0 ? title : cleanTitle; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/IndexerSearch/Definitions/SeasonSearchCriteria.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/SeasonSearchCriteria.cs deleted file mode 100644 index 122df795d..000000000 --- a/src/NzbDrone.Core/IndexerSearch/Definitions/SeasonSearchCriteria.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace NzbDrone.Core.IndexerSearch.Definitions -{ - public class SeasonSearchCriteria : SearchCriteriaBase - { - public int SeasonNumber { get; set; } - - public override bool MonitoredEpisodesOnly => true; - - public override string ToString() - { - return string.Format("[{0} : S{1:00}]", Series.Title, SeasonNumber); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/IndexerSearch/Definitions/SingleEpisodeSearchCriteria.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/SingleEpisodeSearchCriteria.cs deleted file mode 100644 index 797482846..000000000 --- a/src/NzbDrone.Core/IndexerSearch/Definitions/SingleEpisodeSearchCriteria.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace NzbDrone.Core.IndexerSearch.Definitions -{ - public class SingleEpisodeSearchCriteria : SearchCriteriaBase - { - public int EpisodeNumber { get; set; } - public int SeasonNumber { get; set; } - - public override string ToString() - { - return string.Format("[{0} : S{1:00}E{2:00}]", Series.Title, SeasonNumber, EpisodeNumber); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/IndexerSearch/Definitions/SpecialEpisodeSearchCriteria.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/SpecialEpisodeSearchCriteria.cs deleted file mode 100644 index 2b5c0bc0c..000000000 --- a/src/NzbDrone.Core/IndexerSearch/Definitions/SpecialEpisodeSearchCriteria.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Linq; - -namespace NzbDrone.Core.IndexerSearch.Definitions -{ - public class SpecialEpisodeSearchCriteria : SearchCriteriaBase - { - public string[] EpisodeQueryTitles { get; set; } - - public override string ToString() - { - var episodeTitles = EpisodeQueryTitles.ToList(); - - if (episodeTitles.Count > 0) - { - return string.Format("[{0}] Specials", Series.Title); - } - - return string.Format("[{0} : {1}]", Series.Title, string.Join(",", EpisodeQueryTitles)); - } - } -} diff --git a/src/NzbDrone.Core/IndexerSearch/EpisodeSearchCommand.cs b/src/NzbDrone.Core/IndexerSearch/EpisodeSearchCommand.cs deleted file mode 100644 index af0cbf1cc..000000000 --- a/src/NzbDrone.Core/IndexerSearch/EpisodeSearchCommand.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Core.Messaging.Commands; - -namespace NzbDrone.Core.IndexerSearch -{ - public class EpisodeSearchCommand : Command - { - public List EpisodeIds { get; set; } - - public override bool SendUpdatesToClient => true; - - public EpisodeSearchCommand() - { - } - - public EpisodeSearchCommand(List episodeIds) - { - EpisodeIds = episodeIds; - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/IndexerSearch/EpisodeSearchService.cs b/src/NzbDrone.Core/IndexerSearch/EpisodeSearchService.cs deleted file mode 100644 index 6762fbaa2..000000000 --- a/src/NzbDrone.Core/IndexerSearch/EpisodeSearchService.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NLog; -using NzbDrone.Common.Extensions; -using NzbDrone.Common.Instrumentation.Extensions; -using NzbDrone.Core.Datastore; -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Download; -using NzbDrone.Core.Messaging.Commands; -using NzbDrone.Core.Queue; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.IndexerSearch -{ - public class EpisodeSearchService : IExecute, IExecute - { - private readonly ISearchForNzb _nzbSearchService; - private readonly IProcessDownloadDecisions _processDownloadDecisions; - private readonly IEpisodeService _episodeService; - private readonly IQueueService _queueService; - private readonly Logger _logger; - - public EpisodeSearchService(ISearchForNzb nzbSearchService, - IProcessDownloadDecisions processDownloadDecisions, - IEpisodeService episodeService, - IQueueService queueService, - Logger logger) - { - _nzbSearchService = nzbSearchService; - _processDownloadDecisions = processDownloadDecisions; - _episodeService = episodeService; - _queueService = queueService; - _logger = logger; - } - - private void SearchForMissingEpisodes(List episodes, bool userInvokedSearch) - { - _logger.ProgressInfo("Performing missing search for {0} episodes", episodes.Count); - var downloadedCount = 0; - - foreach (var series in episodes.GroupBy(e => e.SeriesId)) - { - foreach (var season in series.Select(e => e).GroupBy(e => e.SeasonNumber)) - { - List decisions; - - if (season.Count() > 1) - { - try - { - decisions = _nzbSearchService.SeasonSearch(series.Key, season.Key, true, userInvokedSearch); - } - catch (Exception ex) - { - _logger.Error(ex, "Unable to search for missing episodes in season {0} of [{1}]", season.Key, series.Key); - continue; - } - } - - else - { - try - { - decisions = _nzbSearchService.EpisodeSearch(season.First(), userInvokedSearch); - } - catch (Exception ex) - { - _logger.Error(ex, "Unable to search for missing episode: [{0}]", season.First()); - continue; - } - } - - var processed = _processDownloadDecisions.ProcessDecisions(decisions); - - downloadedCount += processed.Grabbed.Count; - } - } - - _logger.ProgressInfo("Completed missing search for {0} episodes. {1} reports downloaded.", episodes.Count, downloadedCount); - } - - public void Execute(EpisodeSearchCommand message) - { - foreach (var episodeId in message.EpisodeIds) - { - var decisions = _nzbSearchService.EpisodeSearch(episodeId, message.Trigger == CommandTrigger.Manual); - var processed = _processDownloadDecisions.ProcessDecisions(decisions); - - _logger.ProgressInfo("Episode search completed. {0} reports downloaded.", processed.Grabbed.Count); - } - } - - public void Execute(MissingEpisodeSearchCommand message) - { - List episodes; - - if (message.SeriesId.HasValue) - { - episodes = _episodeService.GetEpisodeBySeries(message.SeriesId.Value) - .Where(e => e.Monitored && - !e.HasFile && - e.AirDateUtc.HasValue && - e.AirDateUtc.Value.Before(DateTime.UtcNow)) - .ToList(); - } - - else - { - episodes = _episodeService.EpisodesWithoutFiles(new PagingSpec - { - Page = 1, - PageSize = 100000, - SortDirection = SortDirection.Ascending, - SortKey = "Id", - FilterExpression = - v => - v.Monitored == true && - v.Series.Monitored == true - }).Records.ToList(); - } - - var queue = _queueService.GetQueue().Select(q => q.Episode.Id); - var missing = episodes.Where(e => !queue.Contains(e.Id)).ToList(); - - SearchForMissingEpisodes(missing, message.Trigger == CommandTrigger.Manual); - } - } -} diff --git a/src/NzbDrone.Core/IndexerSearch/MissingAlbumSearchCommand.cs b/src/NzbDrone.Core/IndexerSearch/MissingAlbumSearchCommand.cs new file mode 100644 index 000000000..1f4ccb7b8 --- /dev/null +++ b/src/NzbDrone.Core/IndexerSearch/MissingAlbumSearchCommand.cs @@ -0,0 +1,20 @@ +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.IndexerSearch +{ + public class MissingAlbumSearchCommand : Command + { + public int? ArtistId { get; set; } + + public override bool SendUpdatesToClient => true; + + public MissingAlbumSearchCommand() + { + } + + public MissingAlbumSearchCommand(int artistId) + { + ArtistId = artistId; + } + } +} diff --git a/src/NzbDrone.Core/IndexerSearch/MissingEpisodeSearchCommand.cs b/src/NzbDrone.Core/IndexerSearch/MissingEpisodeSearchCommand.cs deleted file mode 100644 index 3e2097be3..000000000 --- a/src/NzbDrone.Core/IndexerSearch/MissingEpisodeSearchCommand.cs +++ /dev/null @@ -1,20 +0,0 @@ -using NzbDrone.Core.Messaging.Commands; - -namespace NzbDrone.Core.IndexerSearch -{ - public class MissingEpisodeSearchCommand : Command - { - public int? SeriesId { get; set; } - - public override bool SendUpdatesToClient => true; - - public MissingEpisodeSearchCommand() - { - } - - public MissingEpisodeSearchCommand(int seriesId) - { - SeriesId = seriesId; - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs b/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs index 7c5eb1060..c1ce05294 100644 --- a/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs +++ b/src/NzbDrone.Core/IndexerSearch/NzbSearchService.cs @@ -4,250 +4,116 @@ using System.Globalization; using System.Threading.Tasks; using NLog; using NzbDrone.Common.Instrumentation.Extensions; -using NzbDrone.Core.DataAugmentation.Scene; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Indexers; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Tv; using System.Linq; +using NzbDrone.Common.Extensions; using NzbDrone.Common.TPL; +using NzbDrone.Core.Music; namespace NzbDrone.Core.IndexerSearch { public interface ISearchForNzb { - List EpisodeSearch(int episodeId, bool userInvokedSearch); - List EpisodeSearch(Episode episode, bool userInvokedSearch); - List SeasonSearch(int seriesId, int seasonNumber, bool missingOnly, bool userInvokedSearch); + List AlbumSearch(int albumId, bool missingOnly, bool userInvokedSearch, bool interactiveSearch); + List ArtistSearch(int artistId, bool missingOnly, bool userInvokedSearch, bool interactiveSearch); } public class NzbSearchService : ISearchForNzb { private readonly IIndexerFactory _indexerFactory; - private readonly ISceneMappingService _sceneMapping; - private readonly ISeriesService _seriesService; - private readonly IEpisodeService _episodeService; + private readonly IAlbumService _albumService; + private readonly IArtistService _artistService; private readonly IMakeDownloadDecision _makeDownloadDecision; private readonly Logger _logger; public NzbSearchService(IIndexerFactory indexerFactory, - ISceneMappingService sceneMapping, - ISeriesService seriesService, - IEpisodeService episodeService, + IAlbumService albumService, + IArtistService artistService, IMakeDownloadDecision makeDownloadDecision, Logger logger) { _indexerFactory = indexerFactory; - _sceneMapping = sceneMapping; - _seriesService = seriesService; - _episodeService = episodeService; + _albumService = albumService; + _artistService = artistService; _makeDownloadDecision = makeDownloadDecision; _logger = logger; } - public List EpisodeSearch(int episodeId, bool userInvokedSearch) + public List AlbumSearch(int albumId, bool missingOnly, bool userInvokedSearch, bool interactiveSearch) { - var episode = _episodeService.GetEpisode(episodeId); - - return EpisodeSearch(episode, userInvokedSearch); + var album = _albumService.GetAlbum(albumId); + return AlbumSearch(album, missingOnly, userInvokedSearch, interactiveSearch); } - public List EpisodeSearch(Episode episode, bool userInvokedSearch) + public List ArtistSearch(int artistId, bool missingOnly, bool userInvokedSearch, bool interactiveSearch) { - var series = _seriesService.GetSeries(episode.SeriesId); - - if (series.SeriesType == SeriesTypes.Daily) - { - if (string.IsNullOrWhiteSpace(episode.AirDate)) - { - throw new InvalidOperationException("Daily episode is missing AirDate. Try to refresh series info."); - } - - return SearchDaily(series, episode, userInvokedSearch); - } - if (series.SeriesType == SeriesTypes.Anime) - { - return SearchAnime(series, episode, userInvokedSearch); - } - - if (episode.SeasonNumber == 0) - { - // search for special episodes in season 0 - return SearchSpecial(series, new List { episode }, userInvokedSearch); - } - - return SearchSingle(series, episode, userInvokedSearch); - } - - public List SeasonSearch(int seriesId, int seasonNumber, bool missingOnly, bool userInvokedSearch) - { - var series = _seriesService.GetSeries(seriesId); - var episodes = _episodeService.GetEpisodesBySeason(seriesId, seasonNumber); - - if (missingOnly) - { - episodes = episodes.Where(e => e.Monitored && !e.HasFile).ToList(); - } - - if (series.SeriesType == SeriesTypes.Anime) - { - return SearchAnimeSeason(series, episodes, userInvokedSearch); - } - - if (seasonNumber == 0) - { - // search for special episodes in season 0 - return SearchSpecial(series, episodes, userInvokedSearch); - } - - var downloadDecisions = new List(); - - if (series.UseSceneNumbering) - { - var sceneSeasonGroups = episodes.GroupBy(v => - { - if (v.SceneSeasonNumber.HasValue && v.SceneEpisodeNumber.HasValue) - { - return v.SceneSeasonNumber.Value; - } - return v.SeasonNumber; - }).Distinct(); - - foreach (var sceneSeasonEpisodes in sceneSeasonGroups) - { - if (sceneSeasonEpisodes.Count() == 1) - { - var episode = sceneSeasonEpisodes.First(); - var searchSpec = Get(series, sceneSeasonEpisodes.ToList(), userInvokedSearch); - - searchSpec.SeasonNumber = sceneSeasonEpisodes.Key; - searchSpec.MonitoredEpisodesOnly = true; - - if (episode.SceneSeasonNumber.HasValue && episode.SceneEpisodeNumber.HasValue) - { - searchSpec.EpisodeNumber = episode.SceneEpisodeNumber.Value; - } - else - { - searchSpec.EpisodeNumber = episode.EpisodeNumber; - } - - var decisions = Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec); - downloadDecisions.AddRange(decisions); - } - else - { - var searchSpec = Get(series, sceneSeasonEpisodes.ToList(), userInvokedSearch); - searchSpec.SeasonNumber = sceneSeasonEpisodes.Key; - - var decisions = Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec); - downloadDecisions.AddRange(decisions); - } - } - } - else - { - var searchSpec = Get(series, episodes, userInvokedSearch); - searchSpec.SeasonNumber = seasonNumber; - - var decisions = Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec); - downloadDecisions.AddRange(decisions); - } - - return downloadDecisions; + var artist = _artistService.GetArtist(artistId); + return ArtistSearch(artist, missingOnly, userInvokedSearch, interactiveSearch); } - private List SearchSingle(Series series, Episode episode, bool userInvokedSearch) + public List ArtistSearch(Artist artist, bool missingOnly, bool userInvokedSearch, bool interactiveSearch) { - var searchSpec = Get(series, new List { episode }, userInvokedSearch); + var searchSpec = Get(artist, userInvokedSearch, interactiveSearch); + var albums = _albumService.GetAlbumsByArtist(artist.Id); - if (series.UseSceneNumbering && episode.SceneSeasonNumber.HasValue && episode.SceneEpisodeNumber.HasValue) - { - searchSpec.EpisodeNumber = episode.SceneEpisodeNumber.Value; - searchSpec.SeasonNumber = episode.SceneSeasonNumber.Value; - } - else - { - searchSpec.EpisodeNumber = episode.EpisodeNumber; - searchSpec.SeasonNumber = episode.SeasonNumber; - } + albums = albums.Where(a => a.Monitored).ToList(); + searchSpec.Albums = albums; + return Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec); } - private List SearchDaily(Series series, Episode episode, bool userInvokedSearch) + public List AlbumSearch(Album album, bool missingOnly, bool userInvokedSearch, bool interactiveSearch) { - var airDate = DateTime.ParseExact(episode.AirDate, Episode.AIR_DATE_FORMAT, CultureInfo.InvariantCulture); - var searchSpec = Get(series, new List { episode }, userInvokedSearch); - searchSpec.AirDate = airDate; + var artist = _artistService.GetArtist(album.ArtistId); - return Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec); - } + var searchSpec = Get(artist, new List { album }, userInvokedSearch, interactiveSearch); - private List SearchAnime(Series series, Episode episode, bool userInvokedSearch) - { - var searchSpec = Get(series, new List { episode }, userInvokedSearch); - - if (episode.SceneAbsoluteEpisodeNumber.HasValue) - { - searchSpec.AbsoluteEpisodeNumber = episode.SceneAbsoluteEpisodeNumber.Value; - } - else if (episode.AbsoluteEpisodeNumber.HasValue) + searchSpec.AlbumTitle = album.Title; + if (album.ReleaseDate.HasValue) { - searchSpec.AbsoluteEpisodeNumber = episode.AbsoluteEpisodeNumber.Value; + searchSpec.AlbumYear = album.ReleaseDate.Value.Year; } - else + + if (album.Disambiguation.IsNotNullOrWhiteSpace()) { - throw new ArgumentOutOfRangeException("AbsoluteEpisodeNumber", "Can not search for an episode without an absolute episode number"); + searchSpec.Disambiguation = album.Disambiguation; } return Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec); } - private List SearchSpecial(Series series, List episodes, bool userInvokedSearch) - { - var searchSpec = Get(series, episodes, userInvokedSearch); - // build list of queries for each episode in the form: " " - searchSpec.EpisodeQueryTitles = episodes.Where(e => !string.IsNullOrWhiteSpace(e.Title)) - .SelectMany(e => searchSpec.QueryTitles.Select(title => title + " " + SearchCriteriaBase.GetQueryTitle(e.Title))) - .ToArray(); - - return Dispatch(indexer => indexer.Fetch(searchSpec), searchSpec); - } - - private List SearchAnimeSeason(Series series, List episodes, bool userInvokedSearch) + private TSpec Get(Artist artist, List albums, bool userInvokedSearch, bool interactiveSearch) where TSpec : SearchCriteriaBase, new() { - var downloadDecisions = new List(); + var spec = new TSpec(); - foreach (var episode in episodes.Where(e => e.Monitored)) - { - downloadDecisions.AddRange(SearchAnime(series, episode, userInvokedSearch)); - } + spec.Albums = albums; + spec.Artist = artist; + spec.UserInvokedSearch = userInvokedSearch; + spec.InteractiveSearch = interactiveSearch; - return downloadDecisions; + return spec; } - private TSpec Get(Series series, List episodes, bool userInvokedSearch) where TSpec : SearchCriteriaBase, new() + private static TSpec Get(Artist artist, bool userInvokedSearch, bool interactiveSearch) where TSpec : SearchCriteriaBase, new() { var spec = new TSpec(); - - spec.Series = series; - spec.SceneTitles = _sceneMapping.GetSceneNames(series.TvdbId, - episodes.Select(e => e.SeasonNumber).Distinct().ToList(), - episodes.Select(e => e.SceneSeasonNumber ?? e.SeasonNumber).Distinct().ToList()); - - spec.Episodes = episodes; - - spec.SceneTitles.Add(series.Title); + spec.Artist = artist; spec.UserInvokedSearch = userInvokedSearch; + spec.InteractiveSearch = interactiveSearch; return spec; } private List Dispatch(Func> searchAction, SearchCriteriaBase criteriaBase) { - var indexers = _indexerFactory.SearchEnabled(); + var indexers = criteriaBase.InteractiveSearch ? + _indexerFactory.InteractiveSearchEnabled() : + _indexerFactory.AutomaticSearchEnabled(); + var reports = new List(); _logger.ProgressInfo("Searching {0} indexers for {1}", indexers.Count, criteriaBase); diff --git a/src/NzbDrone.Core/IndexerSearch/SeasonSearchCommand.cs b/src/NzbDrone.Core/IndexerSearch/SeasonSearchCommand.cs deleted file mode 100644 index 2ac6cd439..000000000 --- a/src/NzbDrone.Core/IndexerSearch/SeasonSearchCommand.cs +++ /dev/null @@ -1,12 +0,0 @@ -using NzbDrone.Core.Messaging.Commands; - -namespace NzbDrone.Core.IndexerSearch -{ - public class SeasonSearchCommand : Command - { - public int SeriesId { get; set; } - public int SeasonNumber { get; set; } - - public override bool SendUpdatesToClient => true; - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/IndexerSearch/SeasonSearchService.cs b/src/NzbDrone.Core/IndexerSearch/SeasonSearchService.cs deleted file mode 100644 index 84c38c07d..000000000 --- a/src/NzbDrone.Core/IndexerSearch/SeasonSearchService.cs +++ /dev/null @@ -1,31 +0,0 @@ -using NLog; -using NzbDrone.Common.Instrumentation.Extensions; -using NzbDrone.Core.Download; -using NzbDrone.Core.Messaging.Commands; - -namespace NzbDrone.Core.IndexerSearch -{ - public class SeasonSearchService : IExecute - { - private readonly ISearchForNzb _nzbSearchService; - private readonly IProcessDownloadDecisions _processDownloadDecisions; - private readonly Logger _logger; - - public SeasonSearchService(ISearchForNzb nzbSearchService, - IProcessDownloadDecisions processDownloadDecisions, - Logger logger) - { - _nzbSearchService = nzbSearchService; - _processDownloadDecisions = processDownloadDecisions; - _logger = logger; - } - - public void Execute(SeasonSearchCommand message) - { - var decisions = _nzbSearchService.SeasonSearch(message.SeriesId, message.SeasonNumber, false, message.Trigger == CommandTrigger.Manual); - var processed = _processDownloadDecisions.ProcessDecisions(decisions); - - _logger.ProgressInfo("Season search completed. {0} reports downloaded.", processed.Grabbed.Count); - } - } -} diff --git a/src/NzbDrone.Core/IndexerSearch/SeriesSearchCommand.cs b/src/NzbDrone.Core/IndexerSearch/SeriesSearchCommand.cs deleted file mode 100644 index bc1a0a51a..000000000 --- a/src/NzbDrone.Core/IndexerSearch/SeriesSearchCommand.cs +++ /dev/null @@ -1,11 +0,0 @@ -using NzbDrone.Core.Messaging.Commands; - -namespace NzbDrone.Core.IndexerSearch -{ - public class SeriesSearchCommand : Command - { - public int SeriesId { get; set; } - - public override bool SendUpdatesToClient => true; - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/IndexerSearch/SeriesSearchService.cs b/src/NzbDrone.Core/IndexerSearch/SeriesSearchService.cs deleted file mode 100644 index 388dadfd8..000000000 --- a/src/NzbDrone.Core/IndexerSearch/SeriesSearchService.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Linq; -using NLog; -using NzbDrone.Common.Instrumentation.Extensions; -using NzbDrone.Core.Download; -using NzbDrone.Core.Messaging.Commands; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.IndexerSearch -{ - public class SeriesSearchService : IExecute - { - private readonly ISeriesService _seriesService; - private readonly ISearchForNzb _nzbSearchService; - private readonly IProcessDownloadDecisions _processDownloadDecisions; - private readonly Logger _logger; - - public SeriesSearchService(ISeriesService seriesService, - ISearchForNzb nzbSearchService, - IProcessDownloadDecisions processDownloadDecisions, - Logger logger) - { - _seriesService = seriesService; - _nzbSearchService = nzbSearchService; - _processDownloadDecisions = processDownloadDecisions; - _logger = logger; - } - - public void Execute(SeriesSearchCommand message) - { - var series = _seriesService.GetSeries(message.SeriesId); - - var downloadedCount = 0; - - foreach (var season in series.Seasons.OrderBy(s => s.SeasonNumber)) - { - if (!season.Monitored) - { - _logger.Debug("Season {0} of {1} is not monitored, skipping search", season.SeasonNumber, series.Title); - continue; - } - - var decisions = _nzbSearchService.SeasonSearch(message.SeriesId, season.SeasonNumber, false, message.Trigger == CommandTrigger.Manual); - downloadedCount += _processDownloadDecisions.ProcessDecisions(decisions).Grabbed.Count; - } - - _logger.ProgressInfo("Series search completed. {0} reports downloaded.", downloadedCount); - } - } -} diff --git a/src/NzbDrone.Core/Indexers/BitMeTv/BitMeTv.cs b/src/NzbDrone.Core/Indexers/BitMeTv/BitMeTv.cs deleted file mode 100644 index d6bfec2fb..000000000 --- a/src/NzbDrone.Core/Indexers/BitMeTv/BitMeTv.cs +++ /dev/null @@ -1,32 +0,0 @@ -using NzbDrone.Common.Http; -using NzbDrone.Core.Configuration; -using NLog; -using NzbDrone.Core.Parser; - -namespace NzbDrone.Core.Indexers.BitMeTv -{ - public class BitMeTv : HttpIndexerBase - { - public override string Name => "BitMeTV"; - - public override DownloadProtocol Protocol => DownloadProtocol.Torrent; - public override bool SupportsSearch => false; - public override int PageSize => 0; - - public BitMeTv(IHttpClient httpClient, IIndexerStatusService indexerStatusService, IConfigService configService, IParsingService parsingService, Logger logger) - : base(httpClient, indexerStatusService, configService, parsingService, logger) - { - - } - - public override IIndexerRequestGenerator GetRequestGenerator() - { - return new BitMeTvRequestGenerator() { Settings = Settings }; - } - - public override IParseIndexerResponse GetParser() - { - return new TorrentRssParser() { ParseSizeInDescription = true }; - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/Indexers/BitMeTv/BitMeTvRequestGenerator.cs b/src/NzbDrone.Core/Indexers/BitMeTv/BitMeTvRequestGenerator.cs deleted file mode 100644 index e7966dcba..000000000 --- a/src/NzbDrone.Core/Indexers/BitMeTv/BitMeTvRequestGenerator.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Common.Http; -using NzbDrone.Core.IndexerSearch.Definitions; - -namespace NzbDrone.Core.Indexers.BitMeTv -{ - public class BitMeTvRequestGenerator : IIndexerRequestGenerator - { - public BitMeTvSettings Settings { get; set; } - - public virtual IndexerPageableRequestChain GetRecentRequests() - { - var pageableRequests = new IndexerPageableRequestChain(); - - pageableRequests.Add(GetRssRequests()); - - return pageableRequests; - } - - public virtual IndexerPageableRequestChain GetSearchRequests(SingleEpisodeSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); - } - - public virtual IndexerPageableRequestChain GetSearchRequests(SeasonSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); - } - - public virtual IndexerPageableRequestChain GetSearchRequests(DailyEpisodeSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); - } - - public virtual IndexerPageableRequestChain GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); - } - - public virtual IndexerPageableRequestChain GetSearchRequests(SpecialEpisodeSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); - } - - private IEnumerable GetRssRequests() - { - var request = new IndexerRequest(string.Format("{0}/rss.php?uid={1}&passkey={2}", Settings.BaseUrl.Trim().TrimEnd('/'), Settings.UserId, Settings.RssPasskey), HttpAccept.Html); - - foreach (var cookie in HttpHeader.ParseCookies(Settings.Cookie)) - { - request.HttpRequest.Cookies[cookie.Key] = cookie.Value; - } - - yield return request; - } - } -} diff --git a/src/NzbDrone.Core/Indexers/BitMeTv/BitMeTvSettings.cs b/src/NzbDrone.Core/Indexers/BitMeTv/BitMeTvSettings.cs deleted file mode 100644 index 6e48f46de..000000000 --- a/src/NzbDrone.Core/Indexers/BitMeTv/BitMeTvSettings.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.Text.RegularExpressions; -using FluentValidation; -using NzbDrone.Core.Annotations; -using NzbDrone.Core.ThingiProvider; -using NzbDrone.Core.Validation; - -namespace NzbDrone.Core.Indexers.BitMeTv -{ - public class BitMeTvSettingsValidator : AbstractValidator - { - public BitMeTvSettingsValidator() - { - RuleFor(c => c.BaseUrl).ValidRootUrl(); - RuleFor(c => c.UserId).NotEmpty(); - RuleFor(c => c.RssPasskey).NotEmpty(); - - RuleFor(c => c.Cookie).NotEmpty(); - - RuleFor(c => c.Cookie) - .Matches(@"pass=[0-9a-f]{32}", RegexOptions.IgnoreCase) - .WithMessage("Wrong pattern") - .AsWarning(); - } - } - - public class BitMeTvSettings : IProviderConfig - { - private static readonly BitMeTvSettingsValidator Validator = new BitMeTvSettingsValidator(); - - public BitMeTvSettings() - { - BaseUrl = "https://www.bitmetv.org"; - } - - [FieldDefinition(0, Label = "Website URL")] - public string BaseUrl { get; set; } - - [FieldDefinition(1, Label = "UserId")] - public string UserId { get; set; } - - [FieldDefinition(2, Label = "RSS Passkey")] - public string RssPasskey { get; set; } - - [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; } - - public NzbDroneValidationResult Validate() - { - return new NzbDroneValidationResult(Validator.Validate(this)); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNet.cs b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNet.cs deleted file mode 100644 index fec611710..000000000 --- a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNet.cs +++ /dev/null @@ -1,45 +0,0 @@ -using NLog; -using NzbDrone.Common.Http; -using NzbDrone.Core.Configuration; -using NzbDrone.Core.Parser; - -namespace NzbDrone.Core.Indexers.BroadcastheNet -{ - public class BroadcastheNet : HttpIndexerBase - { - public override string Name => "BroadcastheNet"; - - public override DownloadProtocol Protocol => DownloadProtocol.Torrent; - public override bool SupportsRss => true; - public override bool SupportsSearch => true; - public override int PageSize => 100; - - public BroadcastheNet(IHttpClient httpClient, IIndexerStatusService indexerStatusService, IConfigService configService, IParsingService parsingService, Logger logger) - : base(httpClient, indexerStatusService, configService, parsingService, logger) - { - - } - - public override IIndexerRequestGenerator GetRequestGenerator() - { - var requestGenerator = new BroadcastheNetRequestGenerator() { Settings = Settings, PageSize = PageSize }; - - var releaseInfo = _indexerStatusService.GetLastRssSyncReleaseInfo(Definition.Id); - if (releaseInfo != null) - { - int torrentID; - if (int.TryParse(releaseInfo.Guid.Replace("BTN-", string.Empty), out torrentID)) - { - requestGenerator.LastRecentTorrentID = torrentID; - } - } - - return requestGenerator; - } - - public override IParseIndexerResponse GetParser() - { - return new BroadcastheNetParser(); - } - } -} diff --git a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetParser.cs b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetParser.cs deleted file mode 100644 index 6cecc3b86..000000000 --- a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetParser.cs +++ /dev/null @@ -1,103 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net; -using System.Text.RegularExpressions; -using NzbDrone.Common.Http; -using NzbDrone.Core.Indexers.Exceptions; -using NzbDrone.Core.Parser.Model; - -namespace NzbDrone.Core.Indexers.BroadcastheNet -{ - public class BroadcastheNetParser : IParseIndexerResponse - { - private static readonly Regex RegexProtocol = new Regex("^https?:", RegexOptions.Compiled); - - public IList ParseResponse(IndexerResponse indexerResponse) - { - var results = new List(); - - switch (indexerResponse.HttpResponse.StatusCode) - { - case HttpStatusCode.Unauthorized: - throw new ApiKeyException("API Key invalid or not authorized"); - case HttpStatusCode.NotFound: - throw new IndexerException(indexerResponse, "Indexer API call returned NotFound, the Indexer API may have changed."); - case HttpStatusCode.ServiceUnavailable: - throw new RequestLimitReachedException("Cannot do more than 150 API requests per hour."); - default: - if (indexerResponse.HttpResponse.StatusCode != HttpStatusCode.OK) - { - throw new IndexerException(indexerResponse, "Indexer API call returned an unexpected StatusCode [{0}]", indexerResponse.HttpResponse.StatusCode); - } - break; - } - - if (indexerResponse.HttpResponse.Headers.ContentType != null && indexerResponse.HttpResponse.Headers.ContentType.Contains("text/html")) - { - throw new IndexerException(indexerResponse, "Indexer responded with html content. Site is likely blocked or unavailable."); - } - - if (indexerResponse.Content == "Query execution was interrupted") - { - throw new IndexerException(indexerResponse, "Indexer API returned an internal server error"); - } - - - JsonRpcResponse jsonResponse = new HttpResponse>(indexerResponse.HttpResponse).Resource; - - if (jsonResponse.Error != null || jsonResponse.Result == null) - { - throw new IndexerException(indexerResponse, "Indexer API call returned an error [{0}]", jsonResponse.Error); - } - - if (jsonResponse.Result.Results == 0) - { - return results; - } - - var protocol = indexerResponse.HttpRequest.Url.Scheme + ":"; - - foreach (var torrent in jsonResponse.Result.Torrents.Values) - { - var torrentInfo = new TorrentInfo(); - - torrentInfo.Guid = string.Format("BTN-{0}", torrent.TorrentID); - torrentInfo.Title = CleanReleaseName(torrent.ReleaseName); - torrentInfo.Size = torrent.Size; - torrentInfo.DownloadUrl = RegexProtocol.Replace(torrent.DownloadURL, protocol); - torrentInfo.InfoUrl = string.Format("{0}//broadcasthe.net/torrents.php?id={1}&torrentid={2}", protocol, torrent.GroupID, torrent.TorrentID); - //torrentInfo.CommentUrl = - if (torrent.TvdbID.HasValue) - { - torrentInfo.TvdbId = torrent.TvdbID.Value; - } - if (torrent.TvrageID.HasValue) - { - torrentInfo.TvRageId = torrent.TvrageID.Value; - } - torrentInfo.PublishDate = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc).ToUniversalTime().AddSeconds(torrent.Time); - //torrentInfo.MagnetUrl = - torrentInfo.InfoHash = torrent.InfoHash; - torrentInfo.Seeders = torrent.Seeders; - torrentInfo.Peers = torrent.Leechers + torrent.Seeders; - - torrentInfo.Origin = torrent.Origin; - torrentInfo.Source = torrent.Source; - torrentInfo.Container = torrent.Container; - torrentInfo.Codec = torrent.Codec; - torrentInfo.Resolution = torrent.Resolution; - - results.Add(torrentInfo); - } - - return results; - } - - private string CleanReleaseName(string releaseName) - { - releaseName = releaseName.Replace("\\", ""); - - return releaseName; - } - } -} diff --git a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetRequestGenerator.cs b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetRequestGenerator.cs deleted file mode 100644 index b5a39a94c..000000000 --- a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetRequestGenerator.cs +++ /dev/null @@ -1,193 +0,0 @@ -using System.Linq; -using System.Collections.Generic; -using NzbDrone.Common.Http; -using NzbDrone.Core.IndexerSearch.Definitions; - -namespace NzbDrone.Core.Indexers.BroadcastheNet -{ - public class BroadcastheNetRequestGenerator : IIndexerRequestGenerator - { - public int MaxPages { get; set; } - public int PageSize { get; set; } - public BroadcastheNetSettings Settings { get; set; } - - public int? LastRecentTorrentID { get; set; } - - public BroadcastheNetRequestGenerator() - { - MaxPages = 10; - PageSize = 100; - } - - public virtual IndexerPageableRequestChain GetRecentRequests() - { - var pageableRequests = new IndexerPageableRequestChain(); - - if (LastRecentTorrentID.HasValue) - { - pageableRequests.Add(GetPagedRequests(MaxPages, new BroadcastheNetTorrentQuery() - { - Id = ">=" + (LastRecentTorrentID.Value - 100) - })); - } - - pageableRequests.AddTier(GetPagedRequests(MaxPages, new BroadcastheNetTorrentQuery() - { - Age = "<=86400" - })); - - return pageableRequests; - } - - public virtual IndexerPageableRequestChain GetSearchRequests(SingleEpisodeSearchCriteria searchCriteria) - { - var pageableRequests = new IndexerPageableRequestChain(); - - var parameters = new BroadcastheNetTorrentQuery(); - if (AddSeriesSearchParameters(parameters, searchCriteria)) - { - 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)); - } - - foreach (var seasonNumber in searchCriteria.Episodes.Select(v => v.SeasonNumber).Distinct()) - { - parameters = parameters.Clone(); - - parameters.Category = "Season"; - parameters.Name = string.Format("Season {0}", seasonNumber); - - pageableRequests.Add(GetPagedRequests(MaxPages, parameters)); - } - } - - return pageableRequests; - } - - public virtual IndexerPageableRequestChain GetSearchRequests(SeasonSearchCriteria searchCriteria) - { - var pageableRequests = new IndexerPageableRequestChain(); - - var parameters = new BroadcastheNetTorrentQuery(); - if (AddSeriesSearchParameters(parameters, searchCriteria)) - { - foreach (var seasonNumber in searchCriteria.Episodes.Select(v => v.SeasonNumber).Distinct()) - { - parameters.Category = "Season"; - parameters.Name = string.Format("Season {0}", seasonNumber); - - pageableRequests.Add(GetPagedRequests(MaxPages, parameters)); - - parameters = parameters.Clone(); - - parameters.Category = "Episode"; - parameters.Name = string.Format("S{0:00}E%", seasonNumber); - - pageableRequests.Add(GetPagedRequests(MaxPages, parameters)); - } - } - - return pageableRequests; - } - - public virtual IndexerPageableRequestChain GetSearchRequests(DailyEpisodeSearchCriteria searchCriteria) - { - var pageableRequests = new IndexerPageableRequestChain(); - - var parameters = new BroadcastheNetTorrentQuery(); - if (AddSeriesSearchParameters(parameters, searchCriteria)) - { - parameters.Category = "Episode"; - parameters.Name = string.Format("{0:yyyy}.{0:MM}.{0:dd}", searchCriteria.AirDate); - - 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(); - - var parameters = new BroadcastheNetTorrentQuery(); - if (AddSeriesSearchParameters(parameters, searchCriteria)) - { - 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)); - } - - foreach (var seasonNumber in searchCriteria.Episodes.Select(v => v.SeasonNumber).Distinct()) - { - parameters = parameters.Clone(); - - parameters.Category = "Season"; - parameters.Name = string.Format("Season {0}", seasonNumber); - - pageableRequests.Add(GetPagedRequests(MaxPages, parameters)); - } - } - - return pageableRequests; - } - - public virtual IndexerPageableRequestChain GetSearchRequests(SpecialEpisodeSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); - } - - private bool AddSeriesSearchParameters(BroadcastheNetTorrentQuery parameters, SearchCriteriaBase searchCriteria) - { - if (searchCriteria.Series.TvdbId != 0) - { - parameters.Tvdb = string.Format("{0}", searchCriteria.Series.TvdbId); - return true; - } - if (searchCriteria.Series.TvRageId != 0) - { - parameters.Tvrage = string.Format("{0}", searchCriteria.Series.TvRageId); - return true; - } - // BTN is very neatly managed, so it's unlikely they map tvrage/tvdb wrongly. - return false; - } - - private IEnumerable GetPagedRequests(int maxPages, BroadcastheNetTorrentQuery parameters) - { - var builder = new JsonRpcRequestBuilder(Settings.BaseUrl) - .Call("getTorrents", Settings.ApiKey, parameters, PageSize, 0); - builder.SuppressHttpError = true; - - for (var page = 0; page < maxPages; page++) - { - builder.JsonParameters[3] = page * PageSize; - - yield return new IndexerRequest(builder.Build()); - } - } - } -} diff --git a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetSettings.cs b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetSettings.cs deleted file mode 100644 index 620ce9887..000000000 --- a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetSettings.cs +++ /dev/null @@ -1,37 +0,0 @@ -using FluentValidation; -using NzbDrone.Core.Annotations; -using NzbDrone.Core.ThingiProvider; -using NzbDrone.Core.Validation; - -namespace NzbDrone.Core.Indexers.BroadcastheNet -{ - public class BroadcastheNetSettingsValidator : AbstractValidator - { - public BroadcastheNetSettingsValidator() - { - RuleFor(c => c.BaseUrl).ValidRootUrl(); - RuleFor(c => c.ApiKey).NotEmpty(); - } - } - - public class BroadcastheNetSettings : IProviderConfig - { - private static readonly BroadcastheNetSettingsValidator Validator = new BroadcastheNetSettingsValidator(); - - public BroadcastheNetSettings() - { - BaseUrl = "http://api.broadcasthe.net/"; - } - - [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.")] - public string BaseUrl { get; set; } - - [FieldDefinition(1, Label = "API Key")] - public string ApiKey { get; set; } - - public NzbDroneValidationResult Validate() - { - return new NzbDroneValidationResult(Validator.Validate(this)); - } - } -} diff --git a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetTorrent.cs b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetTorrent.cs deleted file mode 100644 index fd33c3bac..000000000 --- a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetTorrent.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace NzbDrone.Core.Indexers.BroadcastheNet -{ - public class BroadcastheNetTorrent - { - public string GroupName { get; set; } - public int GroupID { get; set; } - public int TorrentID { get; set; } - public int SeriesID { get; set; } - public string Series { get; set; } - public string SeriesBanner { get; set; } - public string SeriesPoster { get; set; } - public string YoutubeTrailer { get; set; } - public string Category { get; set; } - public int? Snatched { get; set; } - public int? Seeders { get; set; } - public int? Leechers { get; set; } - public string Source { get; set; } - public string Container { get; set; } - public string Codec { get; set; } - public string Resolution { get; set; } - public string Origin { get; set; } - public string ReleaseName { get; set; } - public long Size { get; set; } - public long Time { get; set; } - public int? TvdbID { get; set; } - public int? TvrageID { get; set; } - public string ImdbID { get; set; } - public string InfoHash { get; set; } - public string DownloadURL { get; set; } - } -} diff --git a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetTorrentQuery.cs b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetTorrentQuery.cs deleted file mode 100644 index 1180f9b63..000000000 --- a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetTorrentQuery.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Newtonsoft.Json; - -namespace NzbDrone.Core.Indexers.BroadcastheNet -{ - public class BroadcastheNetTorrentQuery - { - [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] - public string Id { get; set; } - [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] - public string Category { get; set; } - [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] - public string Name { get; set; } - [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] - public string Search { get; set; } - [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] - public string Codec { get; set; } - [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] - public string Container { get; set; } - [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] - public string Source { get; set; } - [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] - public string Resolution { get; set; } - [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] - public string Origin { get; set; } - [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] - public string Hash { get; set; } - [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] - public string Tvdb { get; set; } - [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] - public string Tvrage { get; set; } - [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] - public string Age { get; set; } - - public BroadcastheNetTorrentQuery Clone() - { - return MemberwiseClone() as BroadcastheNetTorrentQuery; - } - } -} diff --git a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetTorrents.cs b/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetTorrents.cs deleted file mode 100644 index f9329e7ea..000000000 --- a/src/NzbDrone.Core/Indexers/BroadcastheNet/BroadcastheNetTorrents.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Collections.Generic; - -namespace NzbDrone.Core.Indexers.BroadcastheNet -{ - public class BroadcastheNetTorrents - { - public Dictionary Torrents { get; set; } - public int Results { get; set; } - } -} diff --git a/src/NzbDrone.Core/Indexers/Fanzub/Fanzub.cs b/src/NzbDrone.Core/Indexers/Fanzub/Fanzub.cs deleted file mode 100644 index fc66a83f1..000000000 --- a/src/NzbDrone.Core/Indexers/Fanzub/Fanzub.cs +++ /dev/null @@ -1,30 +0,0 @@ -using NLog; -using NzbDrone.Common.Http; -using NzbDrone.Core.Configuration; -using NzbDrone.Core.Parser; - -namespace NzbDrone.Core.Indexers.Fanzub -{ - public class Fanzub : HttpIndexerBase - { - public override string Name => "Fanzub"; - - public override DownloadProtocol Protocol => DownloadProtocol.Usenet; - - public Fanzub(IHttpClient httpClient, IIndexerStatusService indexerStatusService, IConfigService configService, IParsingService parsingService, Logger logger) - : base(httpClient, indexerStatusService, configService, parsingService, logger) - { - - } - - public override IIndexerRequestGenerator GetRequestGenerator() - { - return new FanzubRequestGenerator() { Settings = Settings }; - } - - public override IParseIndexerResponse GetParser() - { - return new RssParser() { UseEnclosureUrl = true, UseEnclosureLength = true }; - } - } -} diff --git a/src/NzbDrone.Core/Indexers/Fanzub/FanzubRequestGenerator.cs b/src/NzbDrone.Core/Indexers/Fanzub/FanzubRequestGenerator.cs deleted file mode 100644 index 19585dad5..000000000 --- a/src/NzbDrone.Core/Indexers/Fanzub/FanzubRequestGenerator.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Text.RegularExpressions; -using NzbDrone.Common.Extensions; -using NzbDrone.Common.Http; -using NzbDrone.Core.IndexerSearch.Definitions; - -namespace NzbDrone.Core.Indexers.Fanzub -{ - public class FanzubRequestGenerator : IIndexerRequestGenerator - { - private static readonly Regex RemoveCharactersRegex = new Regex(@"[!?`]", RegexOptions.Compiled); - - public FanzubSettings Settings { get; set; } - public int PageSize { get; set; } - - public FanzubRequestGenerator() - { - PageSize = 100; - } - - public virtual IndexerPageableRequestChain GetRecentRequests() - { - var pageableRequests = new IndexerPageableRequestChain(); - - pageableRequests.Add(GetPagedRequests(null)); - - return pageableRequests; - } - - public virtual IndexerPageableRequestChain GetSearchRequests(SingleEpisodeSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); - } - - public virtual IndexerPageableRequestChain GetSearchRequests(SeasonSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); - } - - public virtual IndexerPageableRequestChain GetSearchRequests(DailyEpisodeSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); - } - - public virtual IndexerPageableRequestChain GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) - { - var pageableRequests = new IndexerPageableRequestChain(); - - var searchTitles = searchCriteria.QueryTitles.SelectMany(v => GetTitleSearchStrings(v, searchCriteria.AbsoluteEpisodeNumber)).ToList(); - - pageableRequests.Add(GetPagedRequests(string.Join("|", searchTitles))); - - return pageableRequests; - } - - public virtual IndexerPageableRequestChain GetSearchRequests(SpecialEpisodeSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); - } - - private IEnumerable GetPagedRequests(string query) - { - var url = new StringBuilder(); - url.AppendFormat("{0}?cat=anime&max={1}", Settings.BaseUrl, PageSize); - - if (query.IsNotNullOrWhiteSpace()) - { - url.AppendFormat("&q={0}", query); - } - - yield return new IndexerRequest(url.ToString(), HttpAccept.Rss); - } - - private IEnumerable GetTitleSearchStrings(string title, int absoluteEpisodeNumber) - { - var formats = new[] { "{0}%20{1:00}", "{0}%20-%20{1:00}" }; - - return formats.Select(s => "\"" + string.Format(s, CleanTitle(title), absoluteEpisodeNumber) + "\""); - } - - private string CleanTitle(string title) - { - return RemoveCharactersRegex.Replace(title, ""); - } - } -} diff --git a/src/NzbDrone.Core/Indexers/Fanzub/FanzubSettings.cs b/src/NzbDrone.Core/Indexers/Fanzub/FanzubSettings.cs deleted file mode 100644 index 1f9f25028..000000000 --- a/src/NzbDrone.Core/Indexers/Fanzub/FanzubSettings.cs +++ /dev/null @@ -1,33 +0,0 @@ -using FluentValidation; -using NzbDrone.Core.Annotations; -using NzbDrone.Core.ThingiProvider; -using NzbDrone.Core.Validation; - -namespace NzbDrone.Core.Indexers.Fanzub -{ - public class FanzubSettingsValidator : AbstractValidator - { - public FanzubSettingsValidator() - { - RuleFor(c => c.BaseUrl).ValidRootUrl(); - } - } - - public class FanzubSettings : IProviderConfig - { - private static readonly FanzubSettingsValidator Validator = new FanzubSettingsValidator(); - - public FanzubSettings() - { - BaseUrl = "http://fanzub.com/rss/"; - } - - [FieldDefinition(0, Label = "Rss URL", HelpText = "Enter to URL to an Fanzub compatible RSS feed")] - public string BaseUrl { get; set; } - - public NzbDroneValidationResult Validate() - { - return new NzbDroneValidationResult(Validator.Validate(this)); - } - } -} 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/Gazelle/Gazelle.cs b/src/NzbDrone.Core/Indexers/Gazelle/Gazelle.cs new file mode 100644 index 000000000..fa1d0c7a5 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Gazelle/Gazelle.cs @@ -0,0 +1,88 @@ +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser; +using NzbDrone.Core.ThingiProvider; +using System.Collections.Generic; +using System.Linq; +using FluentValidation.Results; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.Indexers.Gazelle +{ + public class Gazelle : HttpIndexerBase + { + public override string Name => "Gazelle API"; + public override DownloadProtocol Protocol => DownloadProtocol.Torrent; + public override bool SupportsRss => true; + public override bool SupportsSearch => true; + public override int PageSize => 50; + + private readonly ICached> _authCookieCache; + + public Gazelle(IHttpClient httpClient, ICacheManager cacheManager, IIndexerStatusService indexerStatusService, + IConfigService configService, IParsingService parsingService, Logger logger) + : base(httpClient, indexerStatusService, configService, parsingService, logger) + { + _authCookieCache = cacheManager.GetCache>(GetType(), "authCookies"); + } + + public override IIndexerRequestGenerator GetRequestGenerator() + { + return new GazelleRequestGenerator() + { + Settings = Settings, + HttpClient = _httpClient, + Logger = _logger, + AuthCookieCache = _authCookieCache + }; + } + + public override IParseIndexerResponse GetParser() + { + return new GazelleParser(Settings); + } + + public override IEnumerable DefaultDefinitions + { + get + { + yield return GetDefinition("Orpheus Network", GetSettings("https://orpheus.network")); + yield return GetDefinition("REDacted", GetSettings("https://redacted.ch")); + yield return GetDefinition("Not What CD", GetSettings("https://notwhat.cd")); + + } + } + + private IndexerDefinition GetDefinition(string name, GazelleSettings settings) + { + return new IndexerDefinition + { + EnableRss = false, + EnableAutomaticSearch = false, + EnableInteractiveSearch = false, + Name = name, + Implementation = GetType().Name, + Settings = settings, + Protocol = DownloadProtocol.Torrent, + SupportsRss = SupportsRss, + SupportsSearch = SupportsSearch + }; + } + + private GazelleSettings GetSettings(string url) + { + var settings = new GazelleSettings { BaseUrl = url }; + + return settings; + } + + protected override void Test(List failures) + { + // Remove previous cookies when testing incase user or pwd change + _authCookieCache.Remove(Settings.BaseUrl.Trim().TrimEnd('/')); + base.Test(failures); + } + } +} diff --git a/src/NzbDrone.Core/Indexers/Gazelle/GazelleApi.cs b/src/NzbDrone.Core/Indexers/Gazelle/GazelleApi.cs new file mode 100644 index 000000000..a12532907 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Gazelle/GazelleApi.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; + +namespace NzbDrone.Core.Indexers.Gazelle +{ + public class GazelleArtist + { + public string Name { get; set; } + public string Id { get; set; } + public string Aliasid { get; set; } + } + + public class GazelleTorrent + { + public int TorrentId { get; set; } + public int EditionId { get; set; } + public List Artists { get; set; } + public bool Remastered { get; set; } + public string RemasterYear { get; set; } + public string RemasterTitle { get; set; } + public string Media { get; set; } + public string Encoding { get; set; } + public string Format { get; set; } + public bool HasLog { get; set; } + public int LogScore { get; set; } + public bool HasQueue { get; set; } + public bool Scene { get; set; } + public bool VanityHouse { get; set; } + public int FileCount { get; set; } + public DateTime Time { get; set; } + public string Size { get; set; } + public string Snatches { get; set; } + public string Seeders { get; set; } + public string Leechers { get; set; } + public bool IsFreeLeech { get; set; } + public bool IsNeutralLeech { get; set; } + public bool IsPersonalFreeLeech { get; set; } + public bool CanUseToken { get; set; } + } + + public class GazelleRelease + { + public string GroupId { get; set; } + public string GroupName { get; set; } + public string Artist { get; set; } + public string GroupYear { get; set; } + public string Cover { get; set; } + public List Tags { get; set; } + public string ReleaseType { get; set; } + public int TotalLeechers { get; set; } + public int TotalSeeders { get; set; } + public int TotalSnatched { get; set; } + public long MaxSize { get; set; } + public string GroupTime { get; set; } + public List Torrents { get; set; } + } + + public class GazelleResponse + { + public string Status { get; set; } + public GazelleBrowseResponse Response { get; set; } + } + + public class GazelleBrowseResponse + { + public List Results { get; set; } + public string CurrentPage { get; set; } + public string Pages { get; set; } + } + + public class GazelleAuthResponse + { + public string Status { get; set; } + public GazelleIndexResponse Response { get; set; } + + } + + public class GazelleIndexResponse + { + public string Username { get; set; } + public string Id { get; set; } + public string Authkey { get; set; } + public string Passkey { get; set; } + + } + +} diff --git a/src/NzbDrone.Core/Indexers/Gazelle/GazelleInfo.cs b/src/NzbDrone.Core/Indexers/Gazelle/GazelleInfo.cs new file mode 100644 index 000000000..84915f605 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Gazelle/GazelleInfo.cs @@ -0,0 +1,13 @@ +using NzbDrone.Core.Parser.Model; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Indexers.Gazelle +{ + public class GazelleInfo : TorrentInfo + { + public bool? Scene { get; set; } + } +} diff --git a/src/NzbDrone.Core/Indexers/Gazelle/GazelleParser.cs b/src/NzbDrone.Core/Indexers/Gazelle/GazelleParser.cs new file mode 100644 index 000000000..62f0b3e2a --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Gazelle/GazelleParser.cs @@ -0,0 +1,113 @@ +using System.Collections.Generic; +using System.Net; +using NzbDrone.Common.Http; +using NzbDrone.Core.Indexers.Exceptions; +using NzbDrone.Core.Parser.Model; +using System.Linq; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.Indexers.Gazelle +{ + public class GazelleParser : IParseIndexerResponse + { + private readonly GazelleSettings _settings; + public ICached> AuthCookieCache { get; set; } + + public GazelleParser(GazelleSettings settings) + { + _settings = settings; + } + + public IList ParseResponse(IndexerResponse indexerResponse) + { + var torrentInfos = new List(); + + if (indexerResponse.HttpResponse.StatusCode != HttpStatusCode.OK) + { + // Remove cookie cache + AuthCookieCache.Remove(_settings.BaseUrl.Trim().TrimEnd('/')); + + throw new IndexerException(indexerResponse, $"Unexpected response status {indexerResponse.HttpResponse.StatusCode} code from API request"); + } + + if (!indexerResponse.HttpResponse.Headers.ContentType.Contains(HttpAccept.Json.Value)) + { + // Remove cookie cache + AuthCookieCache.Remove(_settings.BaseUrl.Trim().TrimEnd('/')); + + throw new IndexerException(indexerResponse, $"Unexpected response header {indexerResponse.HttpResponse.Headers.ContentType} from API request, expected {HttpAccept.Json.Value}"); + } + + var jsonResponse = new HttpResponse(indexerResponse.HttpResponse); + if (jsonResponse.Resource.Status != "success" || + jsonResponse.Resource.Status.IsNullOrWhiteSpace() || + jsonResponse.Resource.Response == null) + { + return torrentInfos; + } + + + foreach (var result in jsonResponse.Resource.Response.Results) + { + if (result.Torrents != null) + { + foreach (var torrent in result.Torrents) + { + var id = torrent.TorrentId; + var artist = WebUtility.HtmlDecode(result.Artist); + var album = WebUtility.HtmlDecode(result.GroupName); + + torrentInfos.Add(new GazelleInfo() + { + Guid = string.Format("Gazelle-{0}", id), + Artist = artist, + // Splice Title from info to avoid calling API again for every torrent. + Title = WebUtility.HtmlDecode(result.Artist + " - " + result.GroupName + " (" + result.GroupYear +") [" + torrent.Format + " " + torrent.Encoding + "]"), + Album = album, + Container = torrent.Encoding, + Codec = torrent.Format, + Size = long.Parse(torrent.Size), + DownloadUrl = GetDownloadUrl(id, _settings.AuthKey, _settings.PassKey), + InfoUrl = GetInfoUrl(result.GroupId, id), + Seeders = int.Parse(torrent.Seeders), + Peers = int.Parse(torrent.Leechers) + int.Parse(torrent.Seeders), + PublishDate = torrent.Time.ToUniversalTime(), + Scene = torrent.Scene, + }); + } + } + } + + var torr = torrentInfos; + // order by date + return + torrentInfos + .OrderByDescending(o => o.PublishDate) + .ToArray(); + + } + + private string GetDownloadUrl(int torrentId, string authKey, string passKey) + { + var url = new HttpUri(_settings.BaseUrl) + .CombinePath("/torrents.php") + .AddQueryParam("action", "download") + .AddQueryParam("id", torrentId) + .AddQueryParam("authkey", authKey) + .AddQueryParam("torrent_pass", passKey); + + return url.FullUri; + } + + private string GetInfoUrl(string groupId, int torrentId) + { + var url = new HttpUri(_settings.BaseUrl) + .CombinePath("/torrents.php") + .AddQueryParam("id", groupId) + .AddQueryParam("torrentid", torrentId); + + return url.FullUri; + } + } +} diff --git a/src/NzbDrone.Core/Indexers/Gazelle/GazelleRequestGenerator.cs b/src/NzbDrone.Core/Indexers/Gazelle/GazelleRequestGenerator.cs new file mode 100644 index 000000000..3e226dc54 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Gazelle/GazelleRequestGenerator.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Common.Http; +using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Common.Cache; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Serializer; + +namespace NzbDrone.Core.Indexers.Gazelle +{ + public class GazelleRequestGenerator : IIndexerRequestGenerator + { + + public GazelleSettings Settings { get; set; } + + public ICached> AuthCookieCache { get; set; } + public IHttpClient HttpClient { get; set; } + public Logger Logger { get; set; } + + public virtual IndexerPageableRequestChain GetRecentRequests() + { + var pageableRequests = new IndexerPageableRequestChain(); + + pageableRequests.Add(GetRequest(null)); + + return pageableRequests; + } + + public IndexerPageableRequestChain GetSearchRequests(AlbumSearchCriteria searchCriteria) + { + var pageableRequests = new IndexerPageableRequestChain(); + pageableRequests.Add(GetRequest(string.Format("&artistname={0}&groupname={1}", searchCriteria.ArtistQuery, searchCriteria.AlbumQuery))); + return pageableRequests; + } + + public IndexerPageableRequestChain GetSearchRequests(ArtistSearchCriteria searchCriteria) + { + var pageableRequests = new IndexerPageableRequestChain(); + pageableRequests.Add(GetRequest(string.Format("&artistname={0}",searchCriteria.ArtistQuery))); + return pageableRequests; + } + + private IEnumerable GetRequest(string searchParameters) + { + Authenticate(); + + var filter = ""; + if (searchParameters == null) + { + + } + + var request = + new IndexerRequest( + $"{Settings.BaseUrl.Trim().TrimEnd('/')}/ajax.php?action=browse&searchstr={searchParameters}{filter}", + HttpAccept.Json); + + var cookies = AuthCookieCache.Find(Settings.BaseUrl.Trim().TrimEnd('/')); + foreach (var cookie in cookies) + { + request.HttpRequest.Cookies[cookie.Key] = cookie.Value; + } + + yield return request; + } + + private GazelleAuthResponse GetIndex(Dictionary cookies) + { + var indexRequestBuilder = new HttpRequestBuilder($"{Settings.BaseUrl.Trim().TrimEnd('/')}") + { + LogResponseContent = true + }; + + indexRequestBuilder.SetCookies(cookies); + indexRequestBuilder.Method = HttpMethod.POST; + indexRequestBuilder.Resource("ajax.php?action=index"); + + var authIndexRequest = indexRequestBuilder + .Accept(HttpAccept.Json) + .Build(); + + var indexResponse = HttpClient.Execute(authIndexRequest); + + var result = Json.Deserialize(indexResponse.Content); + + return result; + } + + private void Authenticate() + { + + var requestBuilder = new HttpRequestBuilder($"{Settings.BaseUrl.Trim().TrimEnd('/')}") + { + LogResponseContent = true + }; + + requestBuilder.Method = HttpMethod.POST; + requestBuilder.Resource("login.php"); + requestBuilder.PostProcess += r => r.RequestTimeout = TimeSpan.FromSeconds(15); + + var authKey = Settings.BaseUrl.Trim().TrimEnd('/'); + var cookies = AuthCookieCache.Find(authKey); + + if (cookies == null) + { + AuthCookieCache.Remove(authKey); + var authLoginRequest = requestBuilder + .AddFormParameter("username", Settings.Username) + .AddFormParameter("password", Settings.Password) + .AddFormParameter("keeplogged", "1") + .SetHeader("Content-Type", "multipart/form-data") + .Accept(HttpAccept.Json) + .Build(); + + var response = HttpClient.Execute(authLoginRequest); + + cookies = response.GetCookies(); + + AuthCookieCache.Set(authKey, cookies); + } + + var index = GetIndex(cookies); + + if (index == null || index.Status.IsNullOrWhiteSpace() || index.Status != "success") + { + Logger.Debug("Gazelle authentication failed."); + AuthCookieCache.Remove(authKey); + throw new Exception("Failed to authenticate with Gazelle."); + } + + Logger.Debug("Gazelle authentication succeeded."); + + Settings.AuthKey = index.Response.Authkey; + Settings.PassKey = index.Response.Passkey; + + } + } +} diff --git a/src/NzbDrone.Core/Indexers/Gazelle/GazelleSettings.cs b/src/NzbDrone.Core/Indexers/Gazelle/GazelleSettings.cs new file mode 100644 index 000000000..8cad83c67 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Gazelle/GazelleSettings.cs @@ -0,0 +1,52 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Indexers.Gazelle +{ + public class GazelleSettingsValidator : AbstractValidator + { + public GazelleSettingsValidator() + { + RuleFor(c => c.BaseUrl).ValidRootUrl(); + RuleFor(c => c.Username).NotEmpty(); + RuleFor(c => c.Password).NotEmpty(); + } + } + + public class GazelleSettings : ITorrentIndexerSettings + { + private static readonly GazelleSettingsValidator Validator = new GazelleSettingsValidator(); + + public GazelleSettings() + { + MinimumSeeders = IndexerDefaults.MINIMUM_SEEDERS; + } + + public string AuthKey; + public string PassKey; + + [FieldDefinition(0, Label = "URL", Advanced = true, HelpText = "Do not change this unless you know what you're doing. Since your cookie will be sent to that host.")] + public string BaseUrl { get; set; } + + [FieldDefinition(1, Label = "Username", HelpText = "Username")] + public string Username { get; set; } + + [FieldDefinition(2, Label = "Password", Type = FieldType.Password, HelpText = "Password")] + public string Password { 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(); + + [FieldDefinition(5, Type = FieldType.Number, Label = "Early Download Limit", Unit = "days", HelpText = "Time before release date Lidarr will download from this indexer, empty is no limit", Advanced = true)] + public int? EarlyReleaseLimit { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Indexers/HDBits/HDBits.cs b/src/NzbDrone.Core/Indexers/HDBits/HDBits.cs deleted file mode 100644 index 5185433a5..000000000 --- a/src/NzbDrone.Core/Indexers/HDBits/HDBits.cs +++ /dev/null @@ -1,30 +0,0 @@ -using NLog; -using NzbDrone.Common.Http; -using NzbDrone.Core.Configuration; -using NzbDrone.Core.Parser; - -namespace NzbDrone.Core.Indexers.HDBits -{ - public class HDBits : HttpIndexerBase - { - public override string Name => "HDBits"; - public override DownloadProtocol Protocol => DownloadProtocol.Torrent; - public override bool SupportsRss => true; - public override bool SupportsSearch => true; - public override int PageSize => 30; - - public HDBits(IHttpClient httpClient, IIndexerStatusService indexerStatusService, IConfigService configService, IParsingService parsingService, Logger logger) - : base(httpClient, indexerStatusService, configService, parsingService, logger) - { } - - public override IIndexerRequestGenerator GetRequestGenerator() - { - return new HDBitsRequestGenerator() { Settings = Settings }; - } - - public override IParseIndexerResponse GetParser() - { - return new HDBitsParser(Settings); - } - } -} diff --git a/src/NzbDrone.Core/Indexers/HDBits/HDBitsApi.cs b/src/NzbDrone.Core/Indexers/HDBits/HDBitsApi.cs deleted file mode 100644 index 9bb6d624b..000000000 --- a/src/NzbDrone.Core/Indexers/HDBits/HDBitsApi.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System; -using Newtonsoft.Json; - -namespace NzbDrone.Core.Indexers.HDBits -{ - public class TorrentQuery - { - [JsonProperty(Required = Required.Always)] - public string Username { get; set; } - [JsonProperty(Required = Required.Always)] - public string Passkey { get; set; } - - public string Hash { get; set; } - - public string Search { get; set; } - - public int[] Category { get; set; } - - public int[] Codec { get; set; } - - public int[] Medium { get; set; } - - public int[] Origin { get; set; } - - [JsonProperty(PropertyName = "imdb")] - public ImdbInfo ImdbInfo { get; set; } - - [JsonProperty(PropertyName = "tvdb")] - public TvdbInfo TvdbInfo { get; set; } - - [JsonProperty(PropertyName = "file_in_torrent")] - public string FileInTorrent { get; set; } - - [JsonProperty(PropertyName = "snatched_only")] - public bool? SnatchedOnly { get; set; } - public int? Limit { get; set; } - public int? Page { get; set; } - - public TorrentQuery Clone() - { - return MemberwiseClone() as TorrentQuery; - } - } - - public class HDBitsResponse - { - [JsonProperty(Required = Required.Always)] - public StatusCode Status { get; set; } - public string Message { get; set; } - public object Data { get; set; } - } - - public class TorrentQueryResponse - { - public string Id { get; set; } - public string Hash { get; set; } - public int Leechers { get; set; } - public int Seeders { get; set; } - public string Name { get; set; } - - [JsonProperty(PropertyName = "times_completed")] - - public uint TimesCompleted { get; set; } - - public long Size { get; set; } - - [JsonProperty(PropertyName = "utadded")] - public long UtAdded { get; set; } - - public DateTime Added { get; set; } - - public uint Comments { get; set; } - - [JsonProperty(PropertyName = "numfiles")] - public uint NumFiles { get; set; } - - [JsonProperty(PropertyName = "filename")] - public string FileName { get; set; } - - [JsonProperty(PropertyName = "freeleech")] - public string FreeLeech { get; set; } - - [JsonProperty(PropertyName = "type_category")] - public int TypeCategory { get; set; } - - [JsonProperty(PropertyName = "type_codec")] - public int TypeCodec { get; set; } - - [JsonProperty(PropertyName = "type_medium")] - public int TypeMedium { get; set; } - - [JsonProperty(PropertyName = "type_origin")] - public int TypeOrigin { get; set; } - - [JsonProperty(PropertyName = "imdb")] - public ImdbInfo ImdbInfo { get; set; } - - [JsonProperty(PropertyName = "tvdb")] - public TvdbInfo TvdbInfo { get; set; } - } - - public class ImdbInfo - { - public int? Id { get; set; } - public string EnglishTitle { get; set; } - public string OriginalTitle { get; set; } - public int? Year { get; set; } - public string[] Genres { get; set; } - public float? Rating { get; set; } - } - - public class TvdbInfo - { - public int? Id { get; set; } - public int? Season { get; set; } - public int? Episode { get; set; } - } - - public enum StatusCode - { - Success = 0, - Failure = 1, - SslRequired = 2, - JsonMalformed = 3, - AuthDataMissing = 4, - AuthFailed = 5, - MissingRequiredParameters = 6, - InvalidParameter = 7, - ImdbImportFail = 8, - ImdbTvNotAllowed = 9 - } -} diff --git a/src/NzbDrone.Core/Indexers/HDBits/HDBitsParser.cs b/src/NzbDrone.Core/Indexers/HDBits/HDBitsParser.cs deleted file mode 100644 index c5a6dfa4a..000000000 --- a/src/NzbDrone.Core/Indexers/HDBits/HDBitsParser.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using NzbDrone.Common.Http; -using NzbDrone.Core.Indexers.Exceptions; -using NzbDrone.Core.Parser.Model; - -namespace NzbDrone.Core.Indexers.HDBits -{ - public class HDBitsParser : IParseIndexerResponse - { - private readonly HDBitsSettings _settings; - - public HDBitsParser(HDBitsSettings settings) - { - _settings = settings; - } - - public IList ParseResponse(IndexerResponse indexerResponse) - { - var torrentInfos = new List(); - - if (indexerResponse.HttpResponse.StatusCode != HttpStatusCode.OK) - { - throw new IndexerException(indexerResponse, - "Unexpected response status {0} code from API request", - indexerResponse.HttpResponse.StatusCode); - } - - var jsonResponse = JsonConvert.DeserializeObject(indexerResponse.Content); - - if (jsonResponse.Status != StatusCode.Success) - { - throw new IndexerException(indexerResponse, - "HDBits API request returned status code {0}: {1}", - jsonResponse.Status, - jsonResponse.Message ?? string.Empty); - } - - var responseData = jsonResponse.Data as JArray; - if (responseData == null) - { - throw new IndexerException(indexerResponse, - "Indexer API call response missing result data"); - } - - var queryResults = responseData.ToObject(); - - foreach (var result in queryResults) - { - var id = result.Id; - torrentInfos.Add(new TorrentInfo() - { - Guid = string.Format("HDBits-{0}", id), - Title = result.Name, - Size = result.Size, - InfoHash = result.Hash, - DownloadUrl = GetDownloadUrl(id), - InfoUrl = GetInfoUrl(id), - Seeders = result.Seeders, - Peers = result.Leechers + result.Seeders, - PublishDate = result.Added.ToUniversalTime() - }); - } - - return torrentInfos.ToArray(); - } - - private string GetDownloadUrl(string torrentId) - { - var url = new HttpUri(_settings.BaseUrl) - .CombinePath("/download.php") - .AddQueryParam("id", torrentId) - .AddQueryParam("passkey", _settings.ApiKey); - - return url.FullUri; - } - - private string GetInfoUrl(string torrentId) - { - var url = new HttpUri(_settings.BaseUrl) - .CombinePath("/details.php") - .AddQueryParam("id", torrentId); - - return url.FullUri; - - } - } -} diff --git a/src/NzbDrone.Core/Indexers/HDBits/HDBitsRequestGenerator.cs b/src/NzbDrone.Core/Indexers/HDBits/HDBitsRequestGenerator.cs deleted file mode 100644 index dacb87490..000000000 --- a/src/NzbDrone.Core/Indexers/HDBits/HDBitsRequestGenerator.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NzbDrone.Common.Http; -using NzbDrone.Common.Serializer; -using NzbDrone.Core.IndexerSearch.Definitions; - -namespace NzbDrone.Core.Indexers.HDBits -{ - public class HDBitsRequestGenerator : IIndexerRequestGenerator - { - public HDBitsSettings Settings { get; set; } - - public virtual IndexerPageableRequestChain GetRecentRequests() - { - var pageableRequests = new IndexerPageableRequestChain(); - - pageableRequests.Add(GetRequest(new TorrentQuery())); - - return pageableRequests; - } - - public virtual IndexerPageableRequestChain GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) - { - var pageableRequests = new IndexerPageableRequestChain(); - - var queryBase = new TorrentQuery(); - if (TryAddSearchParameters(queryBase, searchCriteria)) - { - foreach (var episode in searchCriteria.Episodes) - { - var query = queryBase.Clone(); - - query.TvdbInfo.Season = episode.SeasonNumber; - query.TvdbInfo.Episode = episode.EpisodeNumber; - } - } - - return pageableRequests; - } - - public virtual IndexerPageableRequestChain GetSearchRequests(SpecialEpisodeSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); - } - - public virtual IndexerPageableRequestChain GetSearchRequests(DailyEpisodeSearchCriteria searchCriteria) - { - var pageableRequests = new IndexerPageableRequestChain(); - - var query = new TorrentQuery(); - if (TryAddSearchParameters(query, searchCriteria)) - { - query.Search = string.Format("{0:yyyy}-{0:MM}-{0:dd}", searchCriteria.AirDate); - - pageableRequests.Add(GetRequest(query)); - } - - return pageableRequests; - } - - public virtual IndexerPageableRequestChain GetSearchRequests(SeasonSearchCriteria searchCriteria) - { - var pageableRequests = new IndexerPageableRequestChain(); - - var queryBase = new TorrentQuery(); - if (TryAddSearchParameters(queryBase, searchCriteria)) - { - foreach (var seasonNumber in searchCriteria.Episodes.Select(e => e.SeasonNumber).Distinct()) - { - var query = queryBase.Clone(); - - query.TvdbInfo.Season = seasonNumber; - - pageableRequests.Add(GetRequest(query)); - } - } - - return pageableRequests; - } - - public virtual IndexerPageableRequestChain GetSearchRequests(SingleEpisodeSearchCriteria searchCriteria) - { - var pageableRequests = new IndexerPageableRequestChain(); - - var queryBase = new TorrentQuery(); - if (TryAddSearchParameters(queryBase, searchCriteria)) - { - foreach (var episode in searchCriteria.Episodes) - { - var query = queryBase.Clone(); - - query.TvdbInfo.Season = episode.SeasonNumber; - query.TvdbInfo.Episode = episode.EpisodeNumber; - - pageableRequests.Add(GetRequest(query)); - } - } - - return pageableRequests; - } - - private bool TryAddSearchParameters(TorrentQuery query, SearchCriteriaBase searchCriteria) - { - if (searchCriteria.Series.TvdbId != 0) - { - query.TvdbInfo = query.TvdbInfo ?? new TvdbInfo(); - query.TvdbInfo.Id = searchCriteria.Series.TvdbId; - return true; - } - return false; - } - - private IEnumerable GetRequest(TorrentQuery query) - { - var request = new HttpRequestBuilder(Settings.BaseUrl) - .Resource("/api/torrents") - .Build(); - - request.Method = HttpMethod.POST; - const string appJson = "application/json"; - request.Headers.Accept = appJson; - request.Headers.ContentType = appJson; - - query.Username = Settings.Username; - query.Passkey = Settings.ApiKey; - - request.SetContent(query.ToJson()); - - yield return new IndexerRequest(request); - } - } -} diff --git a/src/NzbDrone.Core/Indexers/HDBits/HDBitsSettings.cs b/src/NzbDrone.Core/Indexers/HDBits/HDBitsSettings.cs deleted file mode 100644 index 933a134d2..000000000 --- a/src/NzbDrone.Core/Indexers/HDBits/HDBitsSettings.cs +++ /dev/null @@ -1,69 +0,0 @@ -using FluentValidation; -using NzbDrone.Core.Annotations; -using NzbDrone.Core.ThingiProvider; -using NzbDrone.Core.Validation; - -namespace NzbDrone.Core.Indexers.HDBits -{ - public class HDBitsSettingsValidator : AbstractValidator - { - public HDBitsSettingsValidator() - { - RuleFor(c => c.BaseUrl).ValidRootUrl(); - RuleFor(c => c.ApiKey).NotEmpty(); - } - } - - public class HDBitsSettings : IProviderConfig - { - private static readonly HDBitsSettingsValidator Validator = new HDBitsSettingsValidator(); - - public HDBitsSettings() - { - BaseUrl = "https://hdbits.org"; - } - - [FieldDefinition(0, Label = "Username")] - public string Username { get; set; } - - [FieldDefinition(1, Label = "API Key")] - public string ApiKey { get; set; } - - [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; } - - public NzbDroneValidationResult Validate() - { - return new NzbDroneValidationResult(Validator.Validate(this)); - } - } - - public enum HdBitsCategory - { - Movie = 1, - Tv = 2, - Documentary = 3, - Music = 4, - Sport = 5, - Audio = 6, - Xxx = 7, - MiscDemo = 8 - } - - public enum HdBitsCodec - { - H264 = 1, - Mpeg2 = 2, - Vc1 = 3, - Xvid = 4 - } - - public enum HdBitsMedium - { - Bluray = 1, - Encode = 3, - Capture = 4, - Remux = 5, - WebDl = 6 - } -} diff --git a/src/NzbDrone.Core/Indexers/Headphones/Headphones.cs b/src/NzbDrone.Core/Indexers/Headphones/Headphones.cs new file mode 100644 index 000000000..14b5abab8 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Headphones/Headphones.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Indexers.Newznab; + +namespace NzbDrone.Core.Indexers.Headphones +{ + public class Headphones : HttpIndexerBase + { + private readonly IHeadphonesCapabilitiesProvider _capabilitiesProvider; + + public override string Name => "Headphones VIP"; + + public override DownloadProtocol Protocol => DownloadProtocol.Usenet; + + public override int PageSize => _capabilitiesProvider.GetCapabilities(Settings).DefaultPageSize; + + public override IIndexerRequestGenerator GetRequestGenerator() + { + return new HeadphonesRequestGenerator(_capabilitiesProvider) + { + PageSize = PageSize, + Settings = Settings + }; + } + + public override IParseIndexerResponse GetParser() + { + return new HeadphonesRssParser + { + Settings = Settings + }; + } + + public Headphones(IHeadphonesCapabilitiesProvider capabilitiesProvider, IHttpClient httpClient, IIndexerStatusService indexerStatusService, IConfigService configService, IParsingService parsingService, Logger logger) + : base(httpClient, indexerStatusService, configService, parsingService, logger) + { + _capabilitiesProvider = capabilitiesProvider; + } + + protected override void Test(List failures) + { + base.Test(failures); + + if (failures.Any()) return; + failures.AddIfNotNull(TestCapabilities()); + } + + protected virtual ValidationFailure TestCapabilities() + { + try + { + var capabilities = _capabilitiesProvider.GetCapabilities(Settings); + + if (capabilities.SupportedSearchParameters != null && capabilities.SupportedSearchParameters.Contains("q")) + { + return null; + } + + return new ValidationFailure(string.Empty, "Indexer does not support required search parameters"); + } + catch (Exception ex) + { + _logger.Warn(ex, "Unable to connect to indexer: " + ex.Message); + + return new ValidationFailure(string.Empty, "Unable to connect to indexer, check the log for more details"); + } + } + } +} diff --git a/src/NzbDrone.Core/Indexers/Headphones/HeadphonesCapabilities.cs b/src/NzbDrone.Core/Indexers/Headphones/HeadphonesCapabilities.cs new file mode 100644 index 000000000..dacf086e4 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Headphones/HeadphonesCapabilities.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using NzbDrone.Core.Indexers.Newznab; + +namespace NzbDrone.Core.Indexers.Headphones +{ + public class HeadphonesCapabilities + { + public int DefaultPageSize { get; set; } + public int MaxPageSize { get; set; } + public string[] SupportedSearchParameters { get; set; } + public List Categories { get; set; } + + public HeadphonesCapabilities() + { + DefaultPageSize = 100; + MaxPageSize = 100; + SupportedSearchParameters = new[] { "q" }; + Categories = new List(); + } + } +} diff --git a/src/NzbDrone.Core/Indexers/Headphones/HeadphonesCapabilitiesProvider.cs b/src/NzbDrone.Core/Indexers/Headphones/HeadphonesCapabilitiesProvider.cs new file mode 100644 index 000000000..9ac5b8941 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Headphones/HeadphonesCapabilitiesProvider.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Xml; +using System.Xml.Linq; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Indexers.Newznab; + +namespace NzbDrone.Core.Indexers.Headphones +{ + public interface IHeadphonesCapabilitiesProvider + { + HeadphonesCapabilities GetCapabilities(HeadphonesSettings settings); + } + + public class HeadphonesCapabilitiesProvider : IHeadphonesCapabilitiesProvider + { + private readonly ICached _capabilitiesCache; + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + + public HeadphonesCapabilitiesProvider(ICacheManager cacheManager, IHttpClient httpClient, Logger logger) + { + _capabilitiesCache = cacheManager.GetCache(GetType()); + _httpClient = httpClient; + _logger = logger; + } + + public HeadphonesCapabilities GetCapabilities(HeadphonesSettings indexerSettings) + { + var key = indexerSettings.ToJson(); + var capabilities = _capabilitiesCache.Get(key, () => FetchCapabilities(indexerSettings), TimeSpan.FromDays(7)); + + return capabilities; + } + + private HeadphonesCapabilities FetchCapabilities(HeadphonesSettings indexerSettings) + { + var capabilities = new HeadphonesCapabilities(); + + var url = string.Format("{0}{1}?t=caps", indexerSettings.BaseUrl.TrimEnd('/'), indexerSettings.ApiPath.TrimEnd('/')); + + if (indexerSettings.ApiKey.IsNotNullOrWhiteSpace()) + { + url += "&apikey=" + indexerSettings.ApiKey; + } + + var request = new HttpRequest(url, HttpAccept.Rss); + + request.AddBasicAuthentication(indexerSettings.Username, indexerSettings.Password); + + HttpResponse response; + + try + { + response = _httpClient.Get(request); + } + catch (Exception ex) + { + _logger.Debug(ex, "Failed to get headphones api capabilities from {0}", indexerSettings.BaseUrl); + throw; + } + + try + { + capabilities = ParseCapabilities(response); + } + catch (XmlException ex) + { + _logger.Debug(ex, "Failed to parse headphones api capabilities for {0}", indexerSettings.BaseUrl); + ex.WithData(response); + throw; + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to determine headphones api capabilities for {0}, using the defaults instead till Lidarr restarts", indexerSettings.BaseUrl); + } + + return capabilities; + } + + private HeadphonesCapabilities ParseCapabilities(HttpResponse response) + { + var capabilities = new HeadphonesCapabilities(); + + 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) + { + capabilities.DefaultPageSize = int.Parse(xmlLimits.Attribute("default").Value); + capabilities.MaxPageSize = int.Parse(xmlLimits.Attribute("max").Value); + } + + var xmlSearching = xmlRoot.Element("searching"); + if (xmlSearching != null) + { + var xmlBasicSearch = xmlSearching.Element("search"); + if (xmlBasicSearch == null || xmlBasicSearch.Attribute("available").Value != "yes") + { + capabilities.SupportedSearchParameters = null; + } + else if (xmlBasicSearch.Attribute("supportedParams") != null) + { + capabilities.SupportedSearchParameters = xmlBasicSearch.Attribute("supportedParams").Value.Split(','); + } + } + + var xmlCategories = xmlRoot.Element("categories"); + if (xmlCategories != null) + { + foreach (var xmlCategory in xmlCategories.Elements("category")) + { + var cat = new NewznabCategory + { + Id = int.Parse(xmlCategory.Attribute("id").Value), + Name = xmlCategory.Attribute("name").Value, + Description = xmlCategory.Attribute("description") != null ? xmlCategory.Attribute("description").Value : string.Empty, + Subcategories = new List() + }; + + foreach (var xmlSubcat in xmlCategory.Elements("subcat")) + { + cat.Subcategories.Add(new NewznabCategory + { + Id = int.Parse(xmlSubcat.Attribute("id").Value), + Name = xmlSubcat.Attribute("name").Value, + Description = xmlSubcat.Attribute("description") != null ? xmlSubcat.Attribute("description").Value : string.Empty + + }); + } + + capabilities.Categories.Add(cat); + } + } + + return capabilities; + } + } +} diff --git a/src/NzbDrone.Core/Indexers/Headphones/HeadphonesRequestGenerator.cs b/src/NzbDrone.Core/Indexers/Headphones/HeadphonesRequestGenerator.cs new file mode 100644 index 000000000..7e7a124c6 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Headphones/HeadphonesRequestGenerator.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.IndexerSearch.Definitions; + +namespace NzbDrone.Core.Indexers.Headphones +{ + public class HeadphonesRequestGenerator : IIndexerRequestGenerator + { + private readonly IHeadphonesCapabilitiesProvider _capabilitiesProvider; + public int MaxPages { get; set; } + public int PageSize { get; set; } + public HeadphonesSettings Settings { get; set; } + + public HeadphonesRequestGenerator(IHeadphonesCapabilitiesProvider capabilitiesProvider) + { + _capabilitiesProvider = capabilitiesProvider; + + MaxPages = 30; + PageSize = 100; + } + + public virtual IndexerPageableRequestChain GetRecentRequests() + { + var pageableRequests = new IndexerPageableRequestChain(); + + pageableRequests.Add(GetPagedRequests(MaxPages, Settings.Categories, "search", "")); + + return pageableRequests; + } + + public virtual IndexerPageableRequestChain GetSearchRequests(AlbumSearchCriteria searchCriteria) + { + var pageableRequests = new IndexerPageableRequestChain(); + + pageableRequests.AddTier(); + + pageableRequests.Add(GetPagedRequests(MaxPages, Settings.Categories, "search", + NewsnabifyTitle($"&q={searchCriteria.ArtistQuery}+{searchCriteria.AlbumQuery}"))); + + return pageableRequests; + } + + public virtual IndexerPageableRequestChain GetSearchRequests(ArtistSearchCriteria searchCriteria) + { + var pageableRequests = new IndexerPageableRequestChain(); + + pageableRequests.AddTier(); + + pageableRequests.Add(GetPagedRequests(MaxPages, Settings.Categories, "search", + NewsnabifyTitle($"&q={searchCriteria.ArtistQuery}"))); + + return pageableRequests; + } + + private IEnumerable GetPagedRequests(int maxPages, IEnumerable categories, string searchType, string parameters) + { + if (categories.Empty()) + { + yield break; + } + + var categoriesQuery = string.Join(",", categories.Distinct()); + + var baseUrl = + $"{Settings.BaseUrl.TrimEnd('/')}{Settings.ApiPath.TrimEnd('/')}?t={searchType}&cat={categoriesQuery}&extended=1"; + + if (Settings.ApiKey.IsNotNullOrWhiteSpace()) + { + baseUrl += "&apikey=" + Settings.ApiKey; + } + + if (PageSize == 0) + { + var request = new IndexerRequest($"{baseUrl}{parameters}", HttpAccept.Rss); + request.HttpRequest.AddBasicAuthentication(Settings.Username, Settings.Password); + + yield return request; + } + else + { + for (var page = 0; page < maxPages; page++) + { + var request = new IndexerRequest($"{baseUrl}&offset={page * PageSize}&limit={PageSize}{parameters}", HttpAccept.Rss); + request.HttpRequest.AddBasicAuthentication(Settings.Username, Settings.Password); + + yield return request; + } + } + } + + private static string NewsnabifyTitle(string title) + { + return title.Replace("+", "%20"); + } + } +} diff --git a/src/NzbDrone.Core/Indexers/Headphones/HeadphonesRssParser.cs b/src/NzbDrone.Core/Indexers/Headphones/HeadphonesRssParser.cs new file mode 100644 index 000000000..abdc9961b --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Headphones/HeadphonesRssParser.cs @@ -0,0 +1,22 @@ +using System; +using System.Text; +using NzbDrone.Core.Indexers.Newznab; + +namespace NzbDrone.Core.Indexers.Headphones +{ + public class HeadphonesRssParser : NewznabRssParser + { + public HeadphonesSettings Settings { get; set; } + + public HeadphonesRssParser() + { + PreferredEnclosureMimeTypes = UsenetEnclosureMimeTypes; + UseEnclosureUrl = true; + } + + protected override string GetBasicAuth() + { + return Convert.ToBase64String(Encoding.GetEncoding("ISO-8859-1").GetBytes($"{Settings.Username}:{Settings.Password}")); ; + } + } +} diff --git a/src/NzbDrone.Core/Indexers/Headphones/HeadphonesSettings.cs b/src/NzbDrone.Core/Indexers/Headphones/HeadphonesSettings.cs new file mode 100644 index 000000000..85bc604ce --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Headphones/HeadphonesSettings.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using FluentValidation; +using FluentValidation.Results; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Indexers.Headphones +{ + public class HeadphonesSettingsValidator : AbstractValidator + { + public HeadphonesSettingsValidator() + { + Custom(newznab => + { + if (newznab.Categories.Empty()) + { + return new ValidationFailure("", "'Categories' must be provided"); + } + + return null; + }); + + RuleFor(c => c.Username).NotEmpty(); + RuleFor(c => c.Password).NotEmpty(); + } + } + + public class HeadphonesSettings : IIndexerSettings + { + private static readonly HeadphonesSettingsValidator Validator = new HeadphonesSettingsValidator(); + + public HeadphonesSettings() + { + ApiPath = "/api"; + BaseUrl = "https://indexer.codeshy.com"; + ApiKey = "964d601959918a578a670984bdee9357"; + Categories = new[] { 3000, 3010, 3020, 3030, 3040 }; + } + + public string BaseUrl { get; set; } + + public string ApiPath { get; set; } + + public string ApiKey { get; set; } + + [FieldDefinition(0, Label = "Categories", HelpText = "Comma Separated list, leave blank to disable standard/daily shows", Advanced = true)] + public IEnumerable Categories { get; set; } + + [FieldDefinition(1, Label = "Username")] + public string Username { get; set; } + + [FieldDefinition(2, Label = "Password", Type = FieldType.Password)] + public string Password { get; set; } + + [FieldDefinition(3, Type = FieldType.Number, Label = "Early Download Limit", Unit = "days", HelpText = "Time before release date Lidarr will download from this indexer, empty is no limit", Advanced = true)] + public int? EarlyReleaseLimit { get; set; } + + public virtual NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs b/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs index b88158b14..3bd8163f3 100644 --- a/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs +++ b/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net; @@ -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,80 +46,42 @@ namespace NzbDrone.Core.Indexers return new List(); } - var generator = GetRequestGenerator(); - - return FetchReleases(generator.GetRecentRequests(), true); - } - - public override IList Fetch(SingleEpisodeSearchCriteria searchCriteria) - { - if (!SupportsSearch) - { - return new List(); - } - - var generator = GetRequestGenerator(); - - return FetchReleases(generator.GetSearchRequests(searchCriteria)); - } - - public override IList Fetch(SeasonSearchCriteria searchCriteria) - { - if (!SupportsSearch) - { - return new List(); - } - - var generator = GetRequestGenerator(); - - return FetchReleases(generator.GetSearchRequests(searchCriteria)); - } - - public override IList Fetch(DailyEpisodeSearchCriteria searchCriteria) - { - if (!SupportsSearch) - { - return new List(); - } - - var generator = GetRequestGenerator(); - - return FetchReleases(generator.GetSearchRequests(searchCriteria)); + return FetchReleases(g => g.GetRecentRequests(), true); } - public override IList Fetch(AnimeEpisodeSearchCriteria searchCriteria) + public override IList Fetch(AlbumSearchCriteria searchCriteria) { if (!SupportsSearch) { return new List(); } - var generator = GetRequestGenerator(); - - return FetchReleases(generator.GetSearchRequests(searchCriteria)); + return FetchReleases(g => g.GetSearchRequests(searchCriteria)); } - public override IList Fetch(SpecialEpisodeSearchCriteria searchCriteria) + public override IList Fetch(ArtistSearchCriteria searchCriteria) { if (!SupportsSearch) { 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 +137,7 @@ namespace NzbDrone.Core.Indexers } } - releases.AddRange(pagedReleases); + releases.AddRange(pagedReleases.Where(IsValidRelease)); } if (releases.Any()) @@ -222,18 +184,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 +214,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 +229,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 +258,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) @@ -311,7 +297,7 @@ namespace NzbDrone.Core.Indexers if (releases.Empty()) { - return new ValidationFailure(string.Empty, "No results were returned from your indexer, please check your settings."); + return new ValidationFailure(string.Empty, "Query successful, but no results were returned from your indexer. This may be an issue with the indexer or your indexer category settings."); } } catch (ApiKeyException) diff --git a/src/NzbDrone.Core/Indexers/IIndexer.cs b/src/NzbDrone.Core/Indexers/IIndexer.cs index 9f028b569..1a1e034f1 100644 --- a/src/NzbDrone.Core/Indexers/IIndexer.cs +++ b/src/NzbDrone.Core/Indexers/IIndexer.cs @@ -12,10 +12,7 @@ namespace NzbDrone.Core.Indexers DownloadProtocol Protocol { get; } IList FetchRecent(); - IList Fetch(SeasonSearchCriteria searchCriteria); - IList Fetch(SingleEpisodeSearchCriteria searchCriteria); - IList Fetch(DailyEpisodeSearchCriteria searchCriteria); - IList Fetch(AnimeEpisodeSearchCriteria searchCriteria); - IList Fetch(SpecialEpisodeSearchCriteria searchCriteria); + IList Fetch(AlbumSearchCriteria searchCriteria); + IList Fetch(ArtistSearchCriteria 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..776feee5b 100644 --- a/src/NzbDrone.Core/Indexers/IIndexerRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/IIndexerRequestGenerator.cs @@ -1,14 +1,11 @@ -using NzbDrone.Core.IndexerSearch.Definitions; +using NzbDrone.Core.IndexerSearch.Definitions; namespace NzbDrone.Core.Indexers { public interface IIndexerRequestGenerator { IndexerPageableRequestChain GetRecentRequests(); - IndexerPageableRequestChain GetSearchRequests(SingleEpisodeSearchCriteria searchCriteria); - IndexerPageableRequestChain GetSearchRequests(SeasonSearchCriteria searchCriteria); - IndexerPageableRequestChain GetSearchRequests(DailyEpisodeSearchCriteria searchCriteria); - IndexerPageableRequestChain GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria); - IndexerPageableRequestChain GetSearchRequests(SpecialEpisodeSearchCriteria searchCriteria); + IndexerPageableRequestChain GetSearchRequests(AlbumSearchCriteria searchCriteria); + IndexerPageableRequestChain GetSearchRequests(ArtistSearchCriteria 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..1fe4369cb --- /dev/null +++ b/src/NzbDrone.Core/Indexers/IIndexerSettings.cs @@ -0,0 +1,10 @@ +using NzbDrone.Core.ThingiProvider; + +namespace NzbDrone.Core.Indexers +{ + public interface IIndexerSettings : IProviderConfig + { + string BaseUrl { get; set; } + int? EarlyReleaseLimit { get; set; } + } +} diff --git a/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrentsRequestGenerator.cs b/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrentsRequestGenerator.cs index bf4d9e7b8..b5d95dfb7 100644 --- a/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrentsRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrentsRequestGenerator.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using NzbDrone.Common.Http; using NzbDrone.Core.IndexerSearch.Definitions; @@ -17,34 +17,19 @@ namespace NzbDrone.Core.Indexers.IPTorrents return pageableRequests; } - public virtual IndexerPageableRequestChain GetSearchRequests(SingleEpisodeSearchCriteria searchCriteria) + public virtual IndexerPageableRequestChain GetSearchRequests(AlbumSearchCriteria searchCriteria) { - return new IndexerPageableRequestChain(); + throw new System.NotImplementedException(); } - public virtual IndexerPageableRequestChain GetSearchRequests(SeasonSearchCriteria searchCriteria) + public virtual IndexerPageableRequestChain GetSearchRequests(ArtistSearchCriteria searchCriteria) { - return new IndexerPageableRequestChain(); - } - - public virtual IndexerPageableRequestChain GetSearchRequests(DailyEpisodeSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); - } - - public virtual IndexerPageableRequestChain GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); - } - - public virtual IndexerPageableRequestChain GetSearchRequests(SpecialEpisodeSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); + throw new System.NotImplementedException(); } 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..e697e0dc9 100644 --- a/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrentsSettings.cs +++ b/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrentsSettings.cs @@ -2,7 +2,6 @@ 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,36 @@ 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(@"(?:/|t\.)rss\?.+$"); - RuleFor(c => c.Url).Matches(@"/rss\?.+;download(?:;|$)") + RuleFor(c => c.BaseUrl).Matches(@"(?:/|t\.)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, @"(?:/|t\.)rss\?.+$")); } } - public class IPTorrentsSettings : IProviderConfig + public class IPTorrentsSettings : ITorrentIndexerSettings { private static readonly IPTorrentsSettingsValidator Validator = new IPTorrentsSettingsValidator(); public IPTorrentsSettings() { + MinimumSeeders = IndexerDefaults.MINIMUM_SEEDERS; } [FieldDefinition(0, Label = "Feed URL", HelpText = "The full RSS feed url generated by IPTorrents, using only the categories you selected (HD, SD, x264, etc ...)")] - public string Url { get; set; } + public string BaseUrl { get; set; } + + [FieldDefinition(1, Type = FieldType.Textbox, Label = "Minimum Seeders", HelpText = "Minimum number of seeders required.", Advanced = true)] + public int MinimumSeeders { get; set; } + + [FieldDefinition(2)] + public SeedCriteriaSettings SeedCriteria { get; } = new SeedCriteriaSettings(); + + [FieldDefinition(3, Type = FieldType.Number, Label = "Early Download Limit", Unit = "days", HelpText = "Time before release date Lidarr will download from this indexer, empty is no limit", Advanced = true)] + public int? EarlyReleaseLimit { get; set; } public NzbDroneValidationResult Validate() { diff --git a/src/NzbDrone.Core/Indexers/ITorrentIndexerSettings.cs b/src/NzbDrone.Core/Indexers/ITorrentIndexerSettings.cs new file mode 100644 index 000000000..10b885429 --- /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..63726ccf1 100644 --- a/src/NzbDrone.Core/Indexers/IndexerBase.cs +++ b/src/NzbDrone.Core/Indexers/IndexerBase.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using FluentValidation.Results; @@ -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; @@ -48,7 +48,8 @@ namespace NzbDrone.Core.Indexers { Name = GetType().Name, EnableRss = config.Validate().IsValid && SupportsRss, - EnableSearch = config.Validate().IsValid && SupportsSearch, + EnableAutomaticSearch = config.Validate().IsValid && SupportsSearch, + EnableInteractiveSearch = config.Validate().IsValid && SupportsSearch, Implementation = GetType().Name, Settings = config }; @@ -62,11 +63,9 @@ namespace NzbDrone.Core.Indexers protected TSettings Settings => (TSettings)Definition.Settings; public abstract IList FetchRecent(); - public abstract IList Fetch(SeasonSearchCriteria searchCriteria); - public abstract IList Fetch(SingleEpisodeSearchCriteria searchCriteria); - public abstract IList Fetch(DailyEpisodeSearchCriteria searchCriteria); - public abstract IList Fetch(AnimeEpisodeSearchCriteria searchCriteria); - public abstract IList Fetch(SpecialEpisodeSearchCriteria searchCriteria); + + public abstract IList Fetch(AlbumSearchCriteria searchCriteria); + public abstract IList Fetch(ArtistSearchCriteria searchCriteria); protected virtual IList CleanupReleases(IEnumerable releases) { @@ -74,6 +73,7 @@ namespace NzbDrone.Core.Indexers result.ForEach(c => { + c.Guid = string.Concat(Definition.Id, "_", c.Guid); c.IndexerId = Definition.Id; c.Indexer = Definition.Name; c.DownloadProtocol = Protocol; @@ -96,11 +96,6 @@ namespace NzbDrone.Core.Indexers failures.Add(new ValidationFailure(string.Empty, "Test was aborted due to an error: " + ex.Message)); } - if (Definition.Id != 0) - { - _indexerStatusService.RecordSuccess(Definition.Id); - } - return new ValidationResult(failures); } diff --git a/src/NzbDrone.Core/Indexers/IndexerDefaults.cs b/src/NzbDrone.Core/Indexers/IndexerDefaults.cs new file mode 100644 index 000000000..ba33fedfd --- /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/IndexerDefinition.cs b/src/NzbDrone.Core/Indexers/IndexerDefinition.cs index 229d35948..019a61727 100644 --- a/src/NzbDrone.Core/Indexers/IndexerDefinition.cs +++ b/src/NzbDrone.Core/Indexers/IndexerDefinition.cs @@ -1,16 +1,17 @@ -using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.ThingiProvider; namespace NzbDrone.Core.Indexers { public class IndexerDefinition : ProviderDefinition { public bool EnableRss { get; set; } - public bool EnableSearch { get; set; } + public bool EnableAutomaticSearch { get; set; } + public bool EnableInteractiveSearch { get; set; } public DownloadProtocol Protocol { get; set; } public bool SupportsRss { get; set; } public bool SupportsSearch { get; set; } - public override bool Enable => EnableRss || EnableSearch; + public override bool Enable => EnableRss || EnableAutomaticSearch || EnableInteractiveSearch; public IndexerStatus Status { get; set; } } diff --git a/src/NzbDrone.Core/Indexers/IndexerFactory.cs b/src/NzbDrone.Core/Indexers/IndexerFactory.cs index 8e45a031c..902de54b9 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.Collections.Generic; using System.Linq; +using FluentValidation.Results; using NLog; using NzbDrone.Common.Composition; using NzbDrone.Core.Messaging.Events; @@ -9,26 +10,25 @@ namespace NzbDrone.Core.Indexers { public interface IIndexerFactory : IProviderFactory { - List RssEnabled(); - List SearchEnabled(); + List RssEnabled(bool filterBlockedIndexers = true); + List AutomaticSearchEnabled(bool filterBlockedIndexers = true); + List InteractiveSearchEnabled(bool filterBlockedIndexers = true); } public class IndexerFactory : ProviderFactory, IIndexerFactory { private readonly IIndexerStatusService _indexerStatusService; - private readonly IIndexerRepository _providerRepository; private readonly Logger _logger; public IndexerFactory(IIndexerStatusService indexerStatusService, IIndexerRepository providerRepository, IEnumerable providers, - IContainer container, + IContainer container, IEventAggregator eventAggregator, Logger logger) : base(providerRepository, providers, container, eventAggregator, logger) { _indexerStatusService = indexerStatusService; - _providerRepository = providerRepository; _logger = logger; } @@ -46,27 +46,45 @@ namespace NzbDrone.Core.Indexers definition.SupportsSearch = provider.SupportsSearch; } - public List RssEnabled() + public List RssEnabled(bool filterBlockedIndexers = true) { var enabledIndexers = GetAvailableProviders().Where(n => ((IndexerDefinition)n.Definition).EnableRss); - var indexers = FilterBlockedIndexers(enabledIndexers); + if (filterBlockedIndexers) + { + return FilterBlockedIndexers(enabledIndexers).ToList(); + } + + return enabledIndexers.ToList(); + } + + public List AutomaticSearchEnabled(bool filterBlockedIndexers = true) + { + var enabledIndexers = GetAvailableProviders().Where(n => ((IndexerDefinition)n.Definition).EnableAutomaticSearch); + + if (filterBlockedIndexers) + { + return FilterBlockedIndexers(enabledIndexers).ToList(); + } - return indexers.ToList(); + return enabledIndexers.ToList(); } - public List SearchEnabled() + public List InteractiveSearchEnabled(bool filterBlockedIndexers = true) { - var enabledIndexers = GetAvailableProviders().Where(n => ((IndexerDefinition)n.Definition).EnableSearch); + var enabledIndexers = GetAvailableProviders().Where(n => ((IndexerDefinition)n.Definition).EnableInteractiveSearch); - var indexers = FilterBlockedIndexers(enabledIndexers); + if (filterBlockedIndexers) + { + return FilterBlockedIndexers(enabledIndexers).ToList(); + } - return indexers.ToList(); + return enabledIndexers.ToList(); } 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) { @@ -80,5 +98,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..72546da7c 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.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..02a656125 100644 --- a/src/NzbDrone.Core/Indexers/IndexerStatusRepository.cs +++ b/src/NzbDrone.Core/Indexers/IndexerStatusRepository.cs @@ -1,26 +1,20 @@ -using System.Linq; 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 : ProviderStatusRepository, IIndexerStatusRepository - public class IndexerStatusRepository : ProviderRepository, 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..a681622f9 100644 --- a/src/NzbDrone.Core/Indexers/IndexerStatusService.cs +++ b/src/NzbDrone.Core/Indexers/IndexerStatusService.cs @@ -1,131 +1,28 @@ -using System; -using System.Collections.Generic; -using System.Linq; using NLog; +using NzbDrone.Common.EnvironmentInfo; 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) - { - _indexerStatusRepository = indexerStatusRepository; - _logger = logger; - } - - public List GetBlockedIndexers() + public IndexerStatusService(IIndexerStatusRepository providerStatusRepository, IEventAggregator eventAggregator, IRuntimeInfo runtimeInfo, Logger logger) + : base(providerStatusRepository, eventAggregator, runtimeInfo, logger) { - return _indexerStatusRepository.All().Where(v => v.IsDisabled()).ToList(); } public ReleaseInfo GetLastRssSyncReleaseInfo(int indexerId) { - return GetIndexerStatus(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); + return GetProviderStatus(indexerId).LastRssSyncReleaseInfo; } @@ -133,21 +30,11 @@ namespace NzbDrone.Core.Indexers { 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..cb2be8f96 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using FluentValidation.Results; @@ -43,10 +43,9 @@ 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")); 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("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")); @@ -66,7 +65,8 @@ namespace NzbDrone.Core.Indexers.Newznab return new IndexerDefinition { EnableRss = false, - EnableSearch = false, + EnableAutomaticSearch = false, + EnableInteractiveSearch = false, Name = name, Implementation = GetType().Name, Settings = settings, @@ -78,7 +78,7 @@ namespace NzbDrone.Core.Indexers.Newznab private NewznabSettings GetSettings(string url, params int[] categories) { - var settings = new NewznabSettings { Url = url }; + var settings = new NewznabSettings { BaseUrl = url }; if (categories.Any()) { @@ -92,6 +92,7 @@ namespace NzbDrone.Core.Indexers.Newznab { base.Test(failures); + if (failures.Any()) return; failures.AddIfNotNull(TestCapabilities()); } @@ -106,6 +107,12 @@ namespace NzbDrone.Core.Indexers.Newznab return null; } + if (capabilities.SupportedAudioSearchParameters != null && + new[] { "artist", "album" }.All(v => capabilities.SupportedAudioSearchParameters.Contains(v))) + { + return null; + } + if (capabilities.SupportedTvSearchParameters != null && new[] { "q", "tvdbid", "rid" }.Any(v => capabilities.SupportedTvSearchParameters.Contains(v)) && new[] { "season", "ep" }.All(v => capabilities.SupportedTvSearchParameters.Contains(v))) diff --git a/src/NzbDrone.Core/Indexers/Newznab/NewznabCapabilities.cs b/src/NzbDrone.Core/Indexers/Newznab/NewznabCapabilities.cs index 11e73da34..035915704 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/NewznabCapabilities.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/NewznabCapabilities.cs @@ -8,6 +8,7 @@ namespace NzbDrone.Core.Indexers.Newznab public int MaxPageSize { get; set; } public string[] SupportedSearchParameters { get; set; } public string[] SupportedTvSearchParameters { get; set; } + public string[] SupportedAudioSearchParameters { get; set; } public bool SupportsAggregateIdSearch { get; set; } public List Categories { get; set; } @@ -17,6 +18,7 @@ namespace NzbDrone.Core.Indexers.Newznab MaxPageSize = 100; SupportedSearchParameters = new[] { "q" }; SupportedTvSearchParameters = new[] { "q", "rid", "season", "ep" }; // This should remain 'rid' for older newznab installs. + SupportedAudioSearchParameters = new[] { "q", "artist", "album" }; SupportsAggregateIdSearch = false; Categories = new List(); } diff --git a/src/NzbDrone.Core/Indexers/Newznab/NewznabCapabilitiesProvider.cs b/src/NzbDrone.Core/Indexers/Newznab/NewznabCapabilitiesProvider.cs index 9cb004f67..a9c8fd9c0 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/NewznabCapabilitiesProvider.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/NewznabCapabilitiesProvider.cs @@ -1,5 +1,7 @@ -using System; +using System; using System.Collections.Generic; +using System.Net; +using System.Xml; using System.Xml.Linq; using NLog; using NzbDrone.Common.Cache; @@ -39,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()) { @@ -48,15 +50,31 @@ namespace NzbDrone.Core.Indexers.Newznab var request = new HttpRequest(url, HttpAccept.Rss); + HttpResponse response; + try { - var response = _httpClient.Get(request); + response = _httpClient.Get(request); + } + catch (Exception ex) + { + _logger.Debug(ex, "Failed to get newznab api capabilities from {0}", indexerSettings.BaseUrl); + throw; + } + try + { capabilities = ParseCapabilities(response); } + catch (XmlException ex) + { + _logger.Debug(ex, "Failed to parse newznab api capabilities for {0}", indexerSettings.BaseUrl); + ex.WithData(response); + throw; + } catch (Exception ex) { - _logger.Debug(ex, string.Format("Failed to get capabilities from {0}: {1}", indexerSettings.Url, ex.Message)); + _logger.Error(ex, "Failed to determine newznab api capabilities for {0}, using the defaults instead till Lidarr restarts", indexerSettings.BaseUrl); } return capabilities; @@ -66,7 +84,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) @@ -98,6 +128,16 @@ namespace NzbDrone.Core.Indexers.Newznab capabilities.SupportedTvSearchParameters = xmlTvSearch.Attribute("supportedParams").Value.Split(','); capabilities.SupportsAggregateIdSearch = true; } + + var xmlAudioSearch = xmlSearching.Element("audio-search"); + if (xmlAudioSearch == null || xmlAudioSearch.Attribute("available").Value != "yes") + { + capabilities.SupportedAudioSearchParameters = null; + } + else if (xmlAudioSearch.Attribute("supportedParams") != null) + { + capabilities.SupportedAudioSearchParameters = xmlAudioSearch.Attribute("supportedParams").Value.Split(','); + } } var xmlCategories = xmlRoot.Element("categories"); diff --git a/src/NzbDrone.Core/Indexers/Newznab/NewznabRequestGenerator.cs b/src/NzbDrone.Core/Indexers/Newznab/NewznabRequestGenerator.cs index 915603c15..21f31e46f 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/NewznabRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/NewznabRequestGenerator.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; @@ -32,65 +32,16 @@ namespace NzbDrone.Core.Indexers.Newznab } } - private bool SupportsTvSearch + private bool SupportsAudioSearch { get { var capabilities = _capabilitiesProvider.GetCapabilities(Settings); - return capabilities.SupportedTvSearchParameters != null && - capabilities.SupportedTvSearchParameters.Contains("q") && - capabilities.SupportedTvSearchParameters.Contains("season") && - capabilities.SupportedTvSearchParameters.Contains("ep"); - } - } - - private bool SupportsTvdbSearch - { - get - { - var capabilities = _capabilitiesProvider.GetCapabilities(Settings); - - return capabilities.SupportedTvSearchParameters != null && - capabilities.SupportedTvSearchParameters.Contains("tvdbid") && - capabilities.SupportedTvSearchParameters.Contains("season") && - capabilities.SupportedTvSearchParameters.Contains("ep"); - } - } - - private bool SupportsTvRageSearch - { - get - { - var capabilities = _capabilitiesProvider.GetCapabilities(Settings); - - return capabilities.SupportedTvSearchParameters != null && - capabilities.SupportedTvSearchParameters.Contains("rid") && - capabilities.SupportedTvSearchParameters.Contains("season") && - capabilities.SupportedTvSearchParameters.Contains("ep"); - } - } - - private bool SupportsTvMazeSearch - { - get - { - var capabilities = _capabilitiesProvider.GetCapabilities(Settings); - - return capabilities.SupportedTvSearchParameters != null && - capabilities.SupportedTvSearchParameters.Contains("tvmazeid") && - capabilities.SupportedTvSearchParameters.Contains("season") && - capabilities.SupportedTvSearchParameters.Contains("ep"); - } - } - - private bool SupportsAggregatedIdSearch - { - get - { - var capabilities = _capabilitiesProvider.GetCapabilities(Settings); - - return capabilities.SupportsAggregateIdSearch; + return capabilities.SupportedAudioSearchParameters != null && + capabilities.SupportedAudioSearchParameters.Contains("q") && + capabilities.SupportedAudioSearchParameters.Contains("artist") && + capabilities.SupportedAudioSearchParameters.Contains("album"); } } @@ -100,144 +51,65 @@ namespace NzbDrone.Core.Indexers.Newznab var capabilities = _capabilitiesProvider.GetCapabilities(Settings); - if (capabilities.SupportedTvSearchParameters != null) + if (capabilities.SupportedAudioSearchParameters != null) { - pageableRequests.Add(GetPagedRequests(MaxPages, Settings.Categories.Concat(Settings.AnimeCategories), "tvsearch", "")); + pageableRequests.Add(GetPagedRequests(MaxPages, Settings.Categories, "music", "")); + } + else if (capabilities.SupportedSearchParameters != null) + { + pageableRequests.Add(GetPagedRequests(MaxPages, Settings.Categories, "search", "")); } return pageableRequests; } - public virtual IndexerPageableRequestChain GetSearchRequests(SingleEpisodeSearchCriteria searchCriteria) - { - var pageableRequests = new IndexerPageableRequestChain(); - - AddTvIdPageableRequests(pageableRequests, MaxPages, Settings.Categories, searchCriteria, - string.Format("&season={0}&ep={1}", - searchCriteria.SeasonNumber, - searchCriteria.EpisodeNumber)); - - return pageableRequests; - } - - public virtual IndexerPageableRequestChain GetSearchRequests(SeasonSearchCriteria searchCriteria) + public virtual IndexerPageableRequestChain GetSearchRequests(AlbumSearchCriteria searchCriteria) { var pageableRequests = new IndexerPageableRequestChain(); - AddTvIdPageableRequests(pageableRequests, MaxPages, Settings.Categories, searchCriteria, - string.Format("&season={0}", - searchCriteria.SeasonNumber)); - - return pageableRequests; - } + if (SupportsAudioSearch) + { + AddAudioPageableRequests(pageableRequests, searchCriteria, + NewsnabifyTitle($"&artist={searchCriteria.ArtistQuery}&album={searchCriteria.AlbumQuery}")); + } - public virtual IndexerPageableRequestChain GetSearchRequests(DailyEpisodeSearchCriteria searchCriteria) - { - var pageableRequests = new IndexerPageableRequestChain(); + if (SupportsSearch) + { + pageableRequests.AddTier(); - AddTvIdPageableRequests(pageableRequests, MaxPages, Settings.Categories, searchCriteria, - string.Format("&season={0:yyyy}&ep={0:MM}/{0:dd}", - searchCriteria.AirDate)); + pageableRequests.Add(GetPagedRequests(MaxPages, Settings.Categories, "search", + NewsnabifyTitle($"&q={searchCriteria.ArtistQuery}+{searchCriteria.AlbumQuery}"))); + } return pageableRequests; } - public virtual IndexerPageableRequestChain GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) + public virtual IndexerPageableRequestChain GetSearchRequests(ArtistSearchCriteria searchCriteria) { var pageableRequests = new IndexerPageableRequestChain(); - if (SupportsSearch) + if (SupportsAudioSearch) { - foreach (var queryTitle in searchCriteria.QueryTitles) - { - pageableRequests.Add(GetPagedRequests(MaxPages, Settings.AnimeCategories, "search", - string.Format("&q={0}+{1:00}", - NewsnabifyTitle(queryTitle), - searchCriteria.AbsoluteEpisodeNumber))); - } + AddAudioPageableRequests(pageableRequests, searchCriteria, + NewsnabifyTitle($"&artist={searchCriteria.ArtistQuery}")); } - return pageableRequests; - } - - public virtual IndexerPageableRequestChain GetSearchRequests(SpecialEpisodeSearchCriteria searchCriteria) - { - var pageableRequests = new IndexerPageableRequestChain(); - if (SupportsSearch) { - foreach (var queryTitle in searchCriteria.EpisodeQueryTitles) - { - var query = queryTitle.Replace('+', ' '); - query = System.Web.HttpUtility.UrlEncode(query); + pageableRequests.AddTier(); - pageableRequests.Add(GetPagedRequests(MaxPages, Settings.Categories.Concat(Settings.AnimeCategories), "search", - string.Format("&q={0}", - query))); - } + pageableRequests.Add(GetPagedRequests(MaxPages, Settings.Categories, "search", + NewsnabifyTitle($"&q={searchCriteria.ArtistQuery}"))); } return pageableRequests; } - private void AddTvIdPageableRequests(IndexerPageableRequestChain chain, int maxPages, IEnumerable categories, SearchCriteriaBase searchCriteria, string parameters) + private void AddAudioPageableRequests(IndexerPageableRequestChain chain, SearchCriteriaBase searchCriteria, string parameters) { - var includeTvdbSearch = SupportsTvdbSearch && searchCriteria.Series.TvdbId > 0; - var includeTvRageSearch = SupportsTvRageSearch && searchCriteria.Series.TvRageId > 0; - var includeTvMazeSearch = SupportsTvMazeSearch && searchCriteria.Series.TvMazeId > 0; - - if (SupportsAggregatedIdSearch && (includeTvdbSearch || includeTvRageSearch || includeTvMazeSearch)) - { - var ids = ""; - - if (includeTvdbSearch) - { - ids += "&tvdbid=" + searchCriteria.Series.TvdbId; - } + chain.AddTier(); - if (includeTvRageSearch) - { - ids += "&rid=" + searchCriteria.Series.TvRageId; - } - - if (includeTvMazeSearch) - { - ids += "&tvmazeid=" + searchCriteria.Series.TvMazeId; - } - - chain.Add(GetPagedRequests(maxPages, categories, "tvsearch", ids + parameters)); - } - else - { - if (includeTvdbSearch) - { - chain.Add(GetPagedRequests(maxPages, categories, "tvsearch", - string.Format("&tvdbid={0}{1}", searchCriteria.Series.TvdbId, parameters))); - } - else if (includeTvRageSearch) - { - chain.Add(GetPagedRequests(maxPages, categories, "tvsearch", - string.Format("&rid={0}{1}", searchCriteria.Series.TvRageId, parameters))); - } - - else if (includeTvMazeSearch) - { - chain.Add(GetPagedRequests(maxPages, categories, "tvsearch", - string.Format("&tvmazeid={0}{1}", searchCriteria.Series.TvMazeId, parameters))); - } - } - - if (SupportsTvSearch) - { - chain.AddTier(); - foreach (var queryTitle in searchCriteria.QueryTitles) - { - chain.Add(GetPagedRequests(MaxPages, Settings.Categories, "tvsearch", - string.Format("&q={0}{1}", - NewsnabifyTitle(queryTitle), - parameters))); - } - } + chain.Add(GetPagedRequests(MaxPages, Settings.Categories, "music", $"&q={parameters}")); } private IEnumerable GetPagedRequests(int maxPages, IEnumerable categories, string searchType, string parameters) @@ -249,7 +121,8 @@ 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 = + $"{Settings.BaseUrl.TrimEnd('/')}{Settings.ApiPath.TrimEnd('/')}?t={searchType}&cat={categoriesQuery}&extended=1{Settings.AdditionalParameters}"; if (Settings.ApiKey.IsNotNullOrWhiteSpace()) { @@ -258,13 +131,14 @@ namespace NzbDrone.Core.Indexers.Newznab if (PageSize == 0) { - yield return new IndexerRequest(string.Format("{0}{1}", baseUrl, parameters), HttpAccept.Rss); + yield return new IndexerRequest($"{baseUrl}{parameters}", HttpAccept.Rss); } else { for (var page = 0; page < maxPages; page++) { - yield return new IndexerRequest(string.Format("{0}&offset={1}&limit={2}{3}", baseUrl, page * PageSize, PageSize, parameters), HttpAccept.Rss); + yield return new IndexerRequest($"{baseUrl}&offset={page * PageSize}&limit={PageSize}{parameters}", + HttpAccept.Rss); } } } diff --git a/src/NzbDrone.Core/Indexers/Newznab/NewznabRssParser.cs b/src/NzbDrone.Core/Indexers/Newznab/NewznabRssParser.cs index 16c4dea9b..b9f1efa54 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; +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,25 +47,32 @@ namespace NzbDrone.Core.Indexers.Newznab throw new NewznabException(indexerResponse, errorMessage); } - protected override ReleaseInfo ProcessItem(XElement item, ReleaseInfo releaseInfo) + protected override bool PostProcess(IndexerResponse indexerResponse, List items, List releases) { - releaseInfo = base.ProcessItem(item, releaseInfo); - - releaseInfo.TvdbId = GetTvdbId(item); - releaseInfo.TvRageId = GetTvRageId(item); + 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 releaseInfo; + return true; } - protected override ReleaseInfo PostProcess(XElement item, ReleaseInfo releaseInfo) + protected override ReleaseInfo ProcessItem(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); + releaseInfo = base.ProcessItem(item, releaseInfo); + + releaseInfo.Artist = GetArtist(item); + releaseInfo.Album = GetAlbum(item); + + return releaseInfo; } protected override string GetInfoUrl(XElement item) @@ -102,51 +111,40 @@ 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) + protected virtual string GetArtist(XElement item) { - var tvdbIdString = TryGetNewznabAttribute(item, "tvdbid"); - int tvdbId; + var artistString = TryGetNewznabAttribute(item, "artist"); - if (!tvdbIdString.IsNullOrWhiteSpace() && int.TryParse(tvdbIdString, out tvdbId)) + if (!artistString.IsNullOrWhiteSpace()) { - return tvdbId; + return artistString; } - return 0; + return ""; } - - protected virtual int GetTvRageId(XElement item) + + protected virtual string GetAlbum(XElement item) { - var tvRageIdString = TryGetNewznabAttribute(item, "rageid"); - int tvRageId; + var albumString = TryGetNewznabAttribute(item, "album"); - if (!tvRageIdString.IsNullOrWhiteSpace() && int.TryParse(tvRageIdString, out tvRageId)) + if (!albumString.IsNullOrWhiteSpace()) { - return tvRageId; + return albumString; } - return 0; + return ""; } 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..ce3d3a59c 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/NewznabSettings.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/NewznabSettings.cs @@ -5,7 +5,6 @@ using FluentValidation; using FluentValidation.Results; using NzbDrone.Common.Extensions; using NzbDrone.Core.Annotations; -using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; namespace NzbDrone.Core.Indexers.Newznab @@ -25,12 +24,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); @@ -39,49 +38,56 @@ namespace NzbDrone.Core.Indexers.Newznab { Custom(newznab => { - if (newznab.Categories.Empty() && newznab.AnimeCategories.Empty()) + if (newznab.Categories.Empty()) { - return new ValidationFailure("", "Either 'Categories' or 'Anime Categories' must be provided"); + return new ValidationFailure("", "'Categories' must be provided"); } 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() { - Categories = new[] { 5030, 5040 }; - AnimeCategories = Enumerable.Empty(); + ApiPath = "/api"; + Categories = new[] { 3000, 3010, 3020, 3030, 3040 }; } [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", Advanced = true)] public IEnumerable Categories { get; set; } - [FieldDefinition(3, Label = "Anime Categories", HelpText = "Comma Separated list, leave blank to disable anime", Advanced = true)] - public IEnumerable AnimeCategories { get; set; } + [FieldDefinition(4, Type = FieldType.Number, Label = "Early Download Limit", HelpText = "Time before release date Lidarr will download from this indexer, empty is no limit", Unit = "days", Advanced = true)] + public int? EarlyReleaseLimit { 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..8157e5fc0 100644 --- a/src/NzbDrone.Core/Indexers/Nyaa/NyaaRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/Nyaa/NyaaRequestGenerator.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using NzbDrone.Common.Http; using NzbDrone.Core.IndexerSearch.Definitions; @@ -26,52 +26,14 @@ namespace NzbDrone.Core.Indexers.Nyaa return pageableRequests; } - public virtual IndexerPageableRequestChain GetSearchRequests(SingleEpisodeSearchCriteria searchCriteria) + public virtual IndexerPageableRequestChain GetSearchRequests(AlbumSearchCriteria searchCriteria) { - return new IndexerPageableRequestChain(); + throw new System.NotImplementedException(); } - public virtual IndexerPageableRequestChain GetSearchRequests(SeasonSearchCriteria searchCriteria) + public virtual IndexerPageableRequestChain GetSearchRequests(ArtistSearchCriteria searchCriteria) { - return new IndexerPageableRequestChain(); - } - - public virtual IndexerPageableRequestChain GetSearchRequests(DailyEpisodeSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); - } - - public virtual IndexerPageableRequestChain GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) - { - var pageableRequests = new IndexerPageableRequestChain(); - - foreach (var queryTitle in searchCriteria.QueryTitles) - { - var searchTitle = PrepareQuery(queryTitle); - - pageableRequests.Add(GetPagedRequests(MaxPages, $"{searchTitle}+{searchCriteria.AbsoluteEpisodeNumber:0}")); - - if (searchCriteria.AbsoluteEpisodeNumber < 10) - { - pageableRequests.Add(GetPagedRequests(MaxPages, $"{searchTitle}+{searchCriteria.AbsoluteEpisodeNumber:00}")); - } - } - - return pageableRequests; - } - - public virtual IndexerPageableRequestChain GetSearchRequests(SpecialEpisodeSearchCriteria searchCriteria) - { - var pageableRequests = new IndexerPageableRequestChain(); - - foreach (var queryTitle in searchCriteria.EpisodeQueryTitles) - { - pageableRequests.Add(GetPagedRequests(MaxPages, - string.Format("&term={0}", - PrepareQuery(queryTitle)))); - } - - return pageableRequests; + throw new System.NotImplementedException(); } private IEnumerable GetPagedRequests(int maxPages, string term) diff --git a/src/NzbDrone.Core/Indexers/Nyaa/NyaaSettings.cs b/src/NzbDrone.Core/Indexers/Nyaa/NyaaSettings.cs index 5977c2782..3bb640d4a 100644 --- a/src/NzbDrone.Core/Indexers/Nyaa/NyaaSettings.cs +++ b/src/NzbDrone.Core/Indexers/Nyaa/NyaaSettings.cs @@ -1,6 +1,5 @@ -using FluentValidation; +using FluentValidation; using NzbDrone.Core.Annotations; -using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; using System.Text.RegularExpressions; namespace NzbDrone.Core.Indexers.Nyaa @@ -14,14 +13,15 @@ namespace NzbDrone.Core.Indexers.Nyaa } } - public class NyaaSettings : IProviderConfig + public class NyaaSettings : ITorrentIndexerSettings { private static readonly NyaaSettingsValidator Validator = new NyaaSettingsValidator(); public NyaaSettings() { - BaseUrl = "https://www.nyaa.se"; + BaseUrl = ""; AdditionalParameters = "&cats=1_37&filter=1"; + MinimumSeeders = IndexerDefaults.MINIMUM_SEEDERS; } [FieldDefinition(0, Label = "Website URL")] @@ -30,9 +30,18 @@ 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(); + + [FieldDefinition(4, Type = FieldType.Number, Label = "Early Download Limit", Unit = "days", HelpText = "Time before release date Lidarr will download from this indexer, empty is no limit", Advanced = true)] + public int? EarlyReleaseLimit { get; set; } + public NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Indexers/Omgwtfnzbs/OmgwtfnzbsRequestGenerator.cs b/src/NzbDrone.Core/Indexers/Omgwtfnzbs/OmgwtfnzbsRequestGenerator.cs index 17663e8bf..45a2a7414 100644 --- a/src/NzbDrone.Core/Indexers/Omgwtfnzbs/OmgwtfnzbsRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/Omgwtfnzbs/OmgwtfnzbsRequestGenerator.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Text; using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; @@ -25,65 +25,27 @@ namespace NzbDrone.Core.Indexers.Omgwtfnzbs return pageableRequests; } - public virtual IndexerPageableRequestChain GetSearchRequests(SingleEpisodeSearchCriteria searchCriteria) + public virtual IndexerPageableRequestChain GetSearchRequests(AlbumSearchCriteria searchCriteria) { var pageableRequests = new IndexerPageableRequestChain(); - foreach (var queryTitle in searchCriteria.QueryTitles) - { - pageableRequests.Add(GetPagedRequests(string.Format("{0}+S{1:00}E{2:00}", - queryTitle, - searchCriteria.SeasonNumber, - searchCriteria.EpisodeNumber))); - } - - return pageableRequests; - } - public virtual IndexerPageableRequestChain GetSearchRequests(SeasonSearchCriteria searchCriteria) - { - var pageableRequests = new IndexerPageableRequestChain(); + pageableRequests.Add(GetPagedRequests(string.Format("{0}+{1}", + searchCriteria.ArtistQuery, + searchCriteria.AlbumQuery))); - foreach (var queryTitle in searchCriteria.QueryTitles) - { - pageableRequests.Add(GetPagedRequests(string.Format("{0}+S{1:00}", - queryTitle, - searchCriteria.SeasonNumber))); - } return pageableRequests; } - public virtual IndexerPageableRequestChain GetSearchRequests(DailyEpisodeSearchCriteria searchCriteria) + public virtual IndexerPageableRequestChain GetSearchRequests(ArtistSearchCriteria searchCriteria) { var pageableRequests = new IndexerPageableRequestChain(); - foreach (var queryTitle in searchCriteria.QueryTitles) - { - pageableRequests.Add(GetPagedRequests(string.Format("{0}+{1:yyyy MM dd}", - queryTitle, - searchCriteria.AirDate))); - } - return pageableRequests; - } - - public virtual IndexerPageableRequestChain GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); - } + pageableRequests.Add(GetPagedRequests(string.Format("{0}", + searchCriteria.ArtistQuery))); - public virtual IndexerPageableRequestChain GetSearchRequests(SpecialEpisodeSearchCriteria searchCriteria) - { - var pageableRequests = new IndexerPageableRequestChain(); - - foreach (var queryTitle in searchCriteria.EpisodeQueryTitles) - { - var query = queryTitle.Replace('+', ' '); - query = System.Web.HttpUtility.UrlEncode(query); - - pageableRequests.Add(GetPagedRequests(query)); - } return pageableRequests; } @@ -91,7 +53,9 @@ 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); + + // Category 22 is Music-FLAC, category 7 is Music-MP3 + url.AppendFormat("{0}?catid=22,7&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..f3ecaf102 100644 --- a/src/NzbDrone.Core/Indexers/Omgwtfnzbs/OmgwtfnzbsSettings.cs +++ b/src/NzbDrone.Core/Indexers/Omgwtfnzbs/OmgwtfnzbsSettings.cs @@ -1,4 +1,4 @@ -using FluentValidation; +using FluentValidation; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; @@ -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; } @@ -33,6 +36,9 @@ namespace NzbDrone.Core.Indexers.Omgwtfnzbs [FieldDefinition(2, Label = "Delay", HelpText = "Time in minutes to delay new nzbs before they appear on the RSS feed", Advanced = true)] public int Delay { get; set; } + [FieldDefinition(3, Type = FieldType.Number, Label = "Early Download Limit", Unit = "days", HelpText = "Time before release date Lidarr will download from this indexer, empty is no limit", Advanced = true)] + public int? EarlyReleaseLimit { get; set; } + public NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); diff --git a/src/NzbDrone.Core/Indexers/Rarbg/Rarbg.cs b/src/NzbDrone.Core/Indexers/Rarbg/Rarbg.cs index 049809dce..acf427eee 100644 --- a/src/NzbDrone.Core/Indexers/Rarbg/Rarbg.cs +++ b/src/NzbDrone.Core/Indexers/Rarbg/Rarbg.cs @@ -18,6 +18,7 @@ namespace NzbDrone.Core.Indexers.Rarbg public override string Name => "Rarbg"; public override DownloadProtocol Protocol => DownloadProtocol.Torrent; + public override TimeSpan RateLimit => TimeSpan.FromSeconds(2); public Rarbg(IRarbgTokenProvider tokenProvider, IHttpClient httpClient, IIndexerStatusService indexerStatusService, IConfigService configService, IParsingService parsingService, Logger logger) diff --git a/src/NzbDrone.Core/Indexers/Rarbg/RarbgParser.cs b/src/NzbDrone.Core/Indexers/Rarbg/RarbgParser.cs index 61395a5b2..cadfa5524 100644 --- a/src/NzbDrone.Core/Indexers/Rarbg/RarbgParser.cs +++ b/src/NzbDrone.Core/Indexers/Rarbg/RarbgParser.cs @@ -56,19 +56,6 @@ namespace NzbDrone.Core.Indexers.Rarbg torrentInfo.Seeders = torrent.seeders; torrentInfo.Peers = torrent.leechers + torrent.seeders; - if (torrent.episode_info != null) - { - if (torrent.episode_info.tvdb != null) - { - torrentInfo.TvdbId = torrent.episode_info.tvdb.Value; - } - - if (torrent.episode_info.tvrage != null) - { - torrentInfo.TvRageId = torrent.episode_info.tvrage.Value; - } - } - results.Add(torrentInfo); } diff --git a/src/NzbDrone.Core/Indexers/Rarbg/RarbgRequestGenerator.cs b/src/NzbDrone.Core/Indexers/Rarbg/RarbgRequestGenerator.cs index 3b43e0f35..a1fae523c 100644 --- a/src/NzbDrone.Core/Indexers/Rarbg/RarbgRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/Rarbg/RarbgRequestGenerator.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System.Collections.Generic; +using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Core.IndexerSearch.Definitions; @@ -25,49 +26,20 @@ namespace NzbDrone.Core.Indexers.Rarbg return pageableRequests; } - public virtual IndexerPageableRequestChain GetSearchRequests(SingleEpisodeSearchCriteria searchCriteria) + public virtual IndexerPageableRequestChain GetSearchRequests(AlbumSearchCriteria searchCriteria) { var pageableRequests = new IndexerPageableRequestChain(); - pageableRequests.Add(GetPagedRequests("search", searchCriteria.Series.TvdbId, "S{0:00}E{1:00}", searchCriteria.SeasonNumber, searchCriteria.EpisodeNumber)); + pageableRequests.Add(GetPagedRequests("search", null, "{0}+{1}", searchCriteria.ArtistQuery, searchCriteria.AlbumQuery)); return pageableRequests; } - public virtual IndexerPageableRequestChain GetSearchRequests(SeasonSearchCriteria searchCriteria) + public virtual IndexerPageableRequestChain GetSearchRequests(ArtistSearchCriteria searchCriteria) { var pageableRequests = new IndexerPageableRequestChain(); - pageableRequests.Add(GetPagedRequests("search", searchCriteria.Series.TvdbId, "S{0:00}", searchCriteria.SeasonNumber)); - - return pageableRequests; - } - - public virtual IndexerPageableRequestChain GetSearchRequests(DailyEpisodeSearchCriteria searchCriteria) - { - var pageableRequests = new IndexerPageableRequestChain(); - - pageableRequests.Add(GetPagedRequests("search", searchCriteria.Series.TvdbId, "\"{0:yyyy MM dd}\"", searchCriteria.AirDate)); - - return pageableRequests; - } - - public virtual IndexerPageableRequestChain GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); - } - - public virtual IndexerPageableRequestChain GetSearchRequests(SpecialEpisodeSearchCriteria searchCriteria) - { - var pageableRequests = new IndexerPageableRequestChain(); - - foreach (var queryTitle in searchCriteria.EpisodeQueryTitles) - { - var query = queryTitle.Replace('+', ' '); - query = System.Web.HttpUtility.UrlEncode(query); - - pageableRequests.Add(GetPagedRequests("search", searchCriteria.Series.TvdbId, query)); - } + pageableRequests.Add(GetPagedRequests("search", null, "{0}", searchCriteria.ArtistQuery)); return pageableRequests; } @@ -101,11 +73,11 @@ namespace NzbDrone.Core.Indexers.Rarbg requestBuilder.AddQueryParam("ranked", "0"); } - requestBuilder.AddQueryParam("category", "18;41"); + requestBuilder.AddQueryParam("category", "1;23;24;25;26"); requestBuilder.AddQueryParam("limit", "100"); requestBuilder.AddQueryParam("token", _tokenProvider.GetToken(Settings)); requestBuilder.AddQueryParam("format", "json_extended"); - requestBuilder.AddQueryParam("app_id", "Sonarr"); + requestBuilder.AddQueryParam("app_id", BuildInfo.AppName); yield return new IndexerRequest(requestBuilder.Build()); } diff --git a/src/NzbDrone.Core/Indexers/Rarbg/RarbgResponse.cs b/src/NzbDrone.Core/Indexers/Rarbg/RarbgResponse.cs index 51c2e8350..d07100a6c 100644 --- a/src/NzbDrone.Core/Indexers/Rarbg/RarbgResponse.cs +++ b/src/NzbDrone.Core/Indexers/Rarbg/RarbgResponse.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; namespace NzbDrone.Core.Indexers.Rarbg @@ -26,8 +26,8 @@ namespace NzbDrone.Core.Indexers.Rarbg public class RarbgTorrentInfo { - public string imdb { get; set; } - public int? tvrage { get; set; } - public int? tvdb { get; set; } + // For Future if RARBG decides to return metadata + public string artist { get; set; } + public string album { get; set; } } } diff --git a/src/NzbDrone.Core/Indexers/Rarbg/RarbgSettings.cs b/src/NzbDrone.Core/Indexers/Rarbg/RarbgSettings.cs index c60616b27..003f1e79c 100644 --- a/src/NzbDrone.Core/Indexers/Rarbg/RarbgSettings.cs +++ b/src/NzbDrone.Core/Indexers/Rarbg/RarbgSettings.cs @@ -1,6 +1,5 @@ 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.")] @@ -32,9 +34,18 @@ namespace NzbDrone.Core.Indexers.Rarbg [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(); + + [FieldDefinition(5, Type = FieldType.Number, Label = "Early Download Limit", HelpText = "Time before release date Lidarr will download from this indexer, empty is no limit", Advanced = true)] + public int? EarlyReleaseLimit { get; set; } + public NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Indexers/Rarbg/RarbgTokenProvider.cs b/src/NzbDrone.Core/Indexers/Rarbg/RarbgTokenProvider.cs index 628faac41..15a7a7bdb 100644 --- a/src/NzbDrone.Core/Indexers/Rarbg/RarbgTokenProvider.cs +++ b/src/NzbDrone.Core/Indexers/Rarbg/RarbgTokenProvider.cs @@ -1,4 +1,4 @@ -using System; +using System; using Newtonsoft.Json.Linq; using NLog; using NzbDrone.Common.Cache; @@ -31,7 +31,7 @@ namespace NzbDrone.Core.Indexers.Rarbg { var requestBuilder = new HttpRequestBuilder(settings.BaseUrl.Trim('/')) .WithRateLimit(3.0) - .Resource("/pubapi_v2.php?get_token=get_token&app_id=Sonarr") + .Resource("/pubapi_v2.php?get_token=get_token&app_id=Lidarr") .Accept(HttpAccept.Json); if (settings.CaptchaToken.IsNotNullOrWhiteSpace()) diff --git a/src/NzbDrone.Core/Indexers/RssEnclosure.cs b/src/NzbDrone.Core/Indexers/RssEnclosure.cs new file mode 100644 index 000000000..6c0a59c37 --- /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..0fa1a03f1 100644 --- a/src/NzbDrone.Core/Indexers/RssIndexerRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/RssIndexerRequestGenerator.cs @@ -1,4 +1,4 @@ -using NzbDrone.Common.Http; +using NzbDrone.Common.Http; using NzbDrone.Core.IndexerSearch.Definitions; namespace NzbDrone.Core.Indexers @@ -22,29 +22,14 @@ namespace NzbDrone.Core.Indexers return pageableRequests; } - public virtual IndexerPageableRequestChain GetSearchRequests(SingleEpisodeSearchCriteria searchCriteria) + public virtual IndexerPageableRequestChain GetSearchRequests(AlbumSearchCriteria searchCriteria) { - return new IndexerPageableRequestChain(); + throw new System.NotImplementedException(); } - public virtual IndexerPageableRequestChain GetSearchRequests(SeasonSearchCriteria searchCriteria) + public virtual IndexerPageableRequestChain GetSearchRequests(ArtistSearchCriteria searchCriteria) { - return new IndexerPageableRequestChain(); - } - - public virtual IndexerPageableRequestChain GetSearchRequests(DailyEpisodeSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); - } - - public virtual IndexerPageableRequestChain GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); - } - - public virtual IndexerPageableRequestChain GetSearchRequests(SpecialEpisodeSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); + throw new System.NotImplementedException(); } } } diff --git a/src/NzbDrone.Core/Indexers/RssParser.cs b/src/NzbDrone.Core/Indexers/RssParser.cs index 6006695db..c8af9f437 100644 --- a/src/NzbDrone.Core/Indexers/RssParser.cs +++ b/src/NzbDrone.Core/Indexers/RssParser.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -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; } @@ -77,8 +94,8 @@ namespace NzbDrone.Core.Indexers { try { - var content = indexerResponse.Content; - content = ReplaceEntities.Replace(content, ReplaceEntity); + var content = XmlCleaner.ReplaceEntities(indexerResponse.Content); + content = XmlCleaner.ReplaceUnicode(content); using (var xmlTextReader = XmlReader.Create(new StringReader(content), new XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore, IgnoreComments = true })) { @@ -90,26 +107,12 @@ 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; } } - protected virtual string ReplaceEntity(Match match) - { - try - { - var character = WebUtility.HtmlDecode(match.Value); - return string.Concat("&#", (int)character[0], ";"); - } - catch - { - return match.Value; - } - } - protected virtual ReleaseInfo CreateNewReleaseInfo() { return new ReleaseInfo(); @@ -131,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(); @@ -139,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) @@ -148,6 +156,7 @@ namespace NzbDrone.Core.Indexers releaseInfo.Title = GetTitle(item); releaseInfo.PublishDate = GetPublishDate(item); releaseInfo.DownloadUrl = GetDownloadUrl(item); + releaseInfo.BasicAuthString = GetBasicAuth(); releaseInfo.InfoUrl = GetInfoUrl(item); releaseInfo.CommentUrl = GetCommentUrl(item); @@ -163,7 +172,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; } @@ -190,11 +199,17 @@ namespace NzbDrone.Core.Indexers return XElementExtensions.ParseDate(dateString); } + protected virtual string GetBasicAuth() + { + return null; + } + protected virtual string GetDownloadUrl(XElement item) { 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")); @@ -235,37 +250,73 @@ 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") + .Select(v => + { + try + { + return new RssEnclosure + { + Url = v.Attribute("url").Value, + Type = v.Attribute("type").Value, + Length = (long)v.Attribute("length") + }; + } + catch (Exception e) + { + _logger.Warn(e, "Failed to get enclosure for: {0}", item.Title()); + } + + return null; + }) + .Where(v => v != null) + .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) { - var enclosures = item.Elements("enclosure").ToArray(); if (enclosures.Length == 0) { return null; } - if (enclosures.Length == 1) + if (PreferredEnclosureMimeTypes != null) { - return enclosures.First(); - } + foreach (var preferredEnclosureType in PreferredEnclosureMimeTypes) + { + var preferredEnclosure = enclosures.FirstOrDefault(v => v.Type == preferredEnclosureType); - if (PreferredEnclosureMimeType != null) - { - var preferredEnclosure = enclosures.FirstOrDefault(v => v.Attribute("type").Value == PreferredEnclosureMimeType); + if (preferredEnclosure != null) + { + return preferredEnclosure; + } + } - if (preferredEnclosure != null) + if (enforceMimeType) { - return preferredEnclosure; + return null; } } - return item.Elements("enclosure").SingleOrDefault(); + return enclosures.SingleOrDefault(); } protected IEnumerable GetItems(XDocument document) @@ -312,6 +363,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..f1e250ce9 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/SeedConfigProvider.cs @@ -0,0 +1,66 @@ +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(RemoteAlbum release); + } + + public class SeedConfigProvider : ISeedConfigProvider + { + private readonly IIndexerFactory _indexerFactory; + + public SeedConfigProvider(IIndexerFactory indexerFactory) + { + _indexerFactory = indexerFactory; + } + + public TorrentSeedConfiguration GetSeedConfiguration(RemoteAlbum remoteAlbum) + { + if (remoteAlbum.Release.DownloadProtocol != DownloadProtocol.Torrent) + { + return null; + } + + if (remoteAlbum.Release.IndexerId == 0) + { + return null; + } + + try + { + var indexer = _indexerFactory.Get(remoteAlbum.Release.IndexerId); + var torrentIndexerSettings = indexer.Settings as ITorrentIndexerSettings; + + if (torrentIndexerSettings != null && torrentIndexerSettings.SeedCriteria != null) + { + var seedConfig = new TorrentSeedConfiguration + { + Ratio = torrentIndexerSettings.SeedCriteria.SeedRatio + }; + + var seedTime = remoteAlbum.ParsedAlbumInfo.Discography ? torrentIndexerSettings.SeedCriteria.DiscographySeedTime : 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..08b9f00b5 --- /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 discographySeedTimeMinimum = 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.DiscographySeedTime).GreaterThan(0) + .When(c => c.DiscographySeedTime.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 (discographySeedTimeMinimum != 0) + { + RuleFor(c => c.DiscographySeedTime).GreaterThanOrEqualTo(discographySeedTimeMinimum) + .When(c => c.DiscographySeedTime > 0) + .AsWarning() + .WithMessage($"Under {discographySeedTimeMinimum} 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 = "Discography Seed Time", Unit = "minutes", HelpText = "The time a torrent should be seeded before stopping, empty is download client's default", Advanced = true)] + public int? DiscographySeedTime { get; set; } + } +} diff --git a/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssIndexerRequestGenerator.cs b/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssIndexerRequestGenerator.cs index a0bf58cbc..582a817e9 100644 --- a/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssIndexerRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssIndexerRequestGenerator.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Core.IndexerSearch.Definitions; @@ -18,29 +18,14 @@ namespace NzbDrone.Core.Indexers.TorrentRss return pageableRequests; } - public virtual IndexerPageableRequestChain GetSearchRequests(SingleEpisodeSearchCriteria searchCriteria) + public virtual IndexerPageableRequestChain GetSearchRequests(AlbumSearchCriteria searchCriteria) { - return new IndexerPageableRequestChain(); + throw new System.NotImplementedException(); } - public virtual IndexerPageableRequestChain GetSearchRequests(SeasonSearchCriteria searchCriteria) + public virtual IndexerPageableRequestChain GetSearchRequests(ArtistSearchCriteria searchCriteria) { - return new IndexerPageableRequestChain(); - } - - public virtual IndexerPageableRequestChain GetSearchRequests(DailyEpisodeSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); - } - - public virtual IndexerPageableRequestChain GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); - } - - public virtual IndexerPageableRequestChain GetSearchRequests(SpecialEpisodeSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); + throw new System.NotImplementedException(); } private IEnumerable GetRssRequests(string searchParameters) diff --git a/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssIndexerSettings.cs b/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssIndexerSettings.cs index ef2b74f9a..b9a1b514e 100644 --- a/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssIndexerSettings.cs +++ b/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssIndexerSettings.cs @@ -1,6 +1,5 @@ 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,18 @@ 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(); + + [FieldDefinition(5, Type = FieldType.Number, Label = "Early Download Limit", Unit = "days", HelpText = "Time before release date Lidarr will download from this indexer, empty is no limit", Advanced = true)] + public int? EarlyReleaseLimit { get; set; } + public NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(validator.Validate(this)); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssSettingsDetector.cs b/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssSettingsDetector.cs index d54f11f26..8142ac892 100644 --- a/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssSettingsDetector.cs +++ b/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssSettingsDetector.cs @@ -1,6 +1,7 @@ -using System; +using System; using System.Linq; using System.IO; +using System.Text.RegularExpressions; using System.Xml; using System.Xml.Linq; using NLog; @@ -39,22 +40,30 @@ 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) @@ -191,7 +200,10 @@ namespace NzbDrone.Core.Indexers.TorrentRss private bool IsEZTVFeed(IndexerResponse response) { - using (var xmlTextReader = XmlReader.Create(new StringReader(response.Content), new XmlReaderSettings { DtdProcessing = DtdProcessing.Parse, ValidationType = ValidationType.None, IgnoreComments = true, XmlResolver = null })) + var content = XmlCleaner.ReplaceEntities(response.Content); + content = XmlCleaner.ReplaceUnicode(content); + + using (var xmlTextReader = XmlReader.Create(new StringReader(content), new XmlReaderSettings { DtdProcessing = DtdProcessing.Parse, ValidationType = ValidationType.None, IgnoreComments = true, XmlResolver = null })) { var document = XDocument.Load(xmlTextReader); diff --git a/src/NzbDrone.Core/Indexers/TorrentRssParser.cs b/src/NzbDrone.Core/Indexers/TorrentRssParser.cs index b77022540..df55ae6a8 100644 --- a/src/NzbDrone.Core/Indexers/TorrentRssParser.cs +++ b/src/NzbDrone.Core/Indexers/TorrentRssParser.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Text.RegularExpressions; using System.Xml.Linq; using NzbDrone.Common.Extensions; @@ -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..829b485f8 100644 --- a/src/NzbDrone.Core/Indexers/Torrentleech/TorrentleechRequestGenerator.cs +++ b/src/NzbDrone.Core/Indexers/Torrentleech/TorrentleechRequestGenerator.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using NzbDrone.Common.Http; using NzbDrone.Core.IndexerSearch.Definitions; @@ -17,29 +17,14 @@ namespace NzbDrone.Core.Indexers.Torrentleech return pageableRequests; } - public virtual IndexerPageableRequestChain GetSearchRequests(SingleEpisodeSearchCriteria searchCriteria) + public virtual IndexerPageableRequestChain GetSearchRequests(AlbumSearchCriteria searchCriteria) { - return new IndexerPageableRequestChain(); + throw new System.NotImplementedException(); } - public virtual IndexerPageableRequestChain GetSearchRequests(SeasonSearchCriteria searchCriteria) + public virtual IndexerPageableRequestChain GetSearchRequests(ArtistSearchCriteria searchCriteria) { - return new IndexerPageableRequestChain(); - } - - public virtual IndexerPageableRequestChain GetSearchRequests(DailyEpisodeSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); - } - - public virtual IndexerPageableRequestChain GetSearchRequests(AnimeEpisodeSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); - } - - public virtual IndexerPageableRequestChain GetSearchRequests(SpecialEpisodeSearchCriteria searchCriteria) - { - return new IndexerPageableRequestChain(); + throw new System.NotImplementedException(); } private IEnumerable GetRssRequests(string searchParameters) diff --git a/src/NzbDrone.Core/Indexers/Torrentleech/TorrentleechSettings.cs b/src/NzbDrone.Core/Indexers/Torrentleech/TorrentleechSettings.cs index 957bfc3ed..a3528a020 100644 --- a/src/NzbDrone.Core/Indexers/Torrentleech/TorrentleechSettings.cs +++ b/src/NzbDrone.Core/Indexers/Torrentleech/TorrentleechSettings.cs @@ -1,6 +1,5 @@ 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,18 @@ 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(); + + [FieldDefinition(4, Type = FieldType.Number, Label = "Early Download Limit", Unit = "days", HelpText = "Time before release date Lidarr will download from this indexer, empty is no limit", Advanced = true)] + public int? EarlyReleaseLimit { get; set; } + public NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Indexers/Torznab/Torznab.cs b/src/NzbDrone.Core/Indexers/Torznab/Torznab.cs index 8d2649c2d..e121bbb06 100644 --- a/src/NzbDrone.Core/Indexers/Torznab/Torznab.cs +++ b/src/NzbDrone.Core/Indexers/Torznab/Torznab.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using FluentValidation.Results; @@ -35,14 +35,6 @@ namespace NzbDrone.Core.Indexers.Torznab return new TorznabRssParser(); } - public override IEnumerable DefaultDefinitions - { - get - { - yield return GetDefinition("HD4Free.xyz", GetSettings("http://hd4free.xyz")); - } - } - public Torznab(INewznabCapabilitiesProvider capabilitiesProvider, IHttpClient httpClient, IIndexerStatusService indexerStatusService, IConfigService configService, IParsingService parsingService, Logger logger) : base(httpClient, indexerStatusService, configService, parsingService, logger) { @@ -54,7 +46,8 @@ namespace NzbDrone.Core.Indexers.Torznab return new IndexerDefinition { EnableRss = false, - EnableSearch = false, + EnableAutomaticSearch = false, + EnableInteractiveSearch = false, Name = name, Implementation = GetType().Name, Settings = settings, @@ -66,7 +59,7 @@ namespace NzbDrone.Core.Indexers.Torznab private TorznabSettings GetSettings(string url, params int[] categories) { - var settings = new TorznabSettings { Url = url }; + var settings = new TorznabSettings { BaseUrl = url }; if (categories.Any()) { @@ -80,6 +73,7 @@ namespace NzbDrone.Core.Indexers.Torznab { base.Test(failures); + if (failures.Any()) return; failures.AddIfNotNull(TestCapabilities()); } @@ -94,9 +88,8 @@ namespace NzbDrone.Core.Indexers.Torznab return null; } - if (capabilities.SupportedTvSearchParameters != null && - new[] { "q", "tvdbid", "rid" }.Any(v => capabilities.SupportedTvSearchParameters.Contains(v)) && - new[] { "season", "ep" }.All(v => capabilities.SupportedTvSearchParameters.Contains(v))) + if (capabilities.SupportedAudioSearchParameters != null && + new[] { "artist", "album" }.All(v => capabilities.SupportedAudioSearchParameters.Contains(v))) { return null; } diff --git a/src/NzbDrone.Core/Indexers/Torznab/TorznabRssParser.cs b/src/NzbDrone.Core/Indexers/Torznab/TorznabRssParser.cs index 253386963..46bd46533 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; +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,25 +42,22 @@ namespace NzbDrone.Core.Indexers.Torznab throw new TorznabException("Torznab error detected: {0}", errorMessage); } - protected override ReleaseInfo ProcessItem(XElement item, ReleaseInfo releaseInfo) + protected override bool PostProcess(IndexerResponse indexerResponse, List items, List releases) { - var torrentInfo = base.ProcessItem(item, releaseInfo) as TorrentInfo; - - torrentInfo.TvdbId = GetTvdbId(item); - torrentInfo.TvRageId = GetTvRageId(item); - - return torrentInfo; - } - - protected override ReleaseInfo PostProcess(XElement item, ReleaseInfo releaseInfo) - { - var enclosureType = item.Element("enclosure").Attribute("type").Value; - if (!enclosureType.Contains("application/x-bittorrent")) + var enclosureTypes = items.SelectMany(GetEnclosures).Select(v => v.Type).Distinct().ToArray(); + if (enclosureTypes.Any() && enclosureTypes.Intersect(PreferredEnclosureMimeTypes).Empty()) { - throw new UnsupportedFeedException("Feed contains {0} instead of application/x-bittorrent", enclosureType); + 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 base.PostProcess(item, releaseInfo); + return true; } @@ -100,31 +103,6 @@ namespace NzbDrone.Core.Indexers.Torznab return url; } - protected virtual int GetTvdbId(XElement item) - { - var tvdbIdString = TryGetTorznabAttribute(item, "tvdbid"); - int tvdbId; - - if (!tvdbIdString.IsNullOrWhiteSpace() && int.TryParse(tvdbIdString, out tvdbId)) - { - return tvdbId; - } - - return 0; - } - - protected virtual int GetTvRageId(XElement item) - { - var tvRageIdString = TryGetTorznabAttribute(item, "rageid"); - int tvRageId; - - if (!tvRageIdString.IsNullOrWhiteSpace() && int.TryParse(tvRageIdString, out tvRageId)) - { - return tvRageId; - } - - return 0; - } protected override string GetInfoHash(XElement item) { return TryGetTorznabAttribute(item, "infohash"); @@ -169,11 +147,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..d59fd23de 100644 --- a/src/NzbDrone.Core/Indexers/Torznab/TorznabSettings.cs +++ b/src/NzbDrone.Core/Indexers/Torznab/TorznabSettings.cs @@ -3,6 +3,7 @@ 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); @@ -31,28 +32,42 @@ namespace NzbDrone.Core.Indexers.Torznab { Custom(newznab => { - if (newznab.Categories.Empty() && newznab.AnimeCategories.Empty()) + if (newznab.Categories.Empty()) { - return new ValidationFailure("", "Either 'Categories' or 'Anime Categories' must be provided"); + return new ValidationFailure("", "'Categories' must be provided"); } 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/Indexers/Waffles/Waffles.cs b/src/NzbDrone.Core/Indexers/Waffles/Waffles.cs new file mode 100644 index 000000000..f337ca6d5 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Waffles/Waffles.cs @@ -0,0 +1,31 @@ +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NLog; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.Indexers.Waffles +{ + public class Waffles : HttpIndexerBase + { + public override string Name => "Waffles"; + + public override DownloadProtocol Protocol => DownloadProtocol.Torrent; + public override int PageSize => 15; + + public Waffles(IHttpClient httpClient, IIndexerStatusService indexerStatusService, IConfigService configService, IParsingService parsingService, Logger logger) + : base(httpClient, indexerStatusService, configService, parsingService, logger) + { + + } + + public override IIndexerRequestGenerator GetRequestGenerator() + { + return new WafflesRequestGenerator() { Settings = Settings }; + } + + public override IParseIndexerResponse GetParser() + { + return new WafflesRssParser() { ParseSizeInDescription = true, ParseSeedersInDescription = true }; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/Indexers/Waffles/WafflesRequestGenerator.cs b/src/NzbDrone.Core/Indexers/Waffles/WafflesRequestGenerator.cs new file mode 100644 index 000000000..50d4589b5 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Waffles/WafflesRequestGenerator.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using System.Text; +using System; +using NzbDrone.Core.IndexerSearch.Definitions; + +namespace NzbDrone.Core.Indexers.Waffles +{ + public class WafflesRequestGenerator : IIndexerRequestGenerator + { + public WafflesSettings Settings { get; set; } + public int MaxPages { get; set; } + + public WafflesRequestGenerator() + { + MaxPages = 5; + } + + public virtual IndexerPageableRequestChain GetRecentRequests() + { + var pageableRequests = new IndexerPageableRequestChain(); + + pageableRequests.Add(GetPagedRequests(MaxPages, null)); + + return pageableRequests; + } + + public IndexerPageableRequestChain GetSearchRequests(AlbumSearchCriteria searchCriteria) + { + var pageableRequests = new IndexerPageableRequestChain(); + + pageableRequests.Add(GetPagedRequests(MaxPages, string.Format("&q=artist:{0} album:{1}",searchCriteria.ArtistQuery,searchCriteria.AlbumQuery))); + + return pageableRequests; + } + + public IndexerPageableRequestChain GetSearchRequests(ArtistSearchCriteria searchCriteria) + { + var pageableRequests = new IndexerPageableRequestChain(); + + pageableRequests.Add(GetPagedRequests(MaxPages, string.Format("&q=artist:{0}", searchCriteria.ArtistQuery))); + + return pageableRequests; + } + + private IEnumerable GetPagedRequests(int maxPages, string query) + { + + var url = new StringBuilder(); + + url.AppendFormat("{0}/browse.php?rss=1&c0=1&uid={1}&passkey={2}", Settings.BaseUrl.Trim().TrimEnd('/'), Settings.UserId, Settings.RssPasskey); + + if (query.IsNotNullOrWhiteSpace()) + { + url.AppendFormat(query); + } + + for (var page = 0; page < maxPages; page++) + { + yield return new IndexerRequest(string.Format("{0}&p={1}", url, page), HttpAccept.Rss); + } + } + } +} diff --git a/src/NzbDrone.Core/Indexers/Waffles/WafflesRssParser.cs b/src/NzbDrone.Core/Indexers/Waffles/WafflesRssParser.cs new file mode 100644 index 000000000..4cc8e6be6 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Waffles/WafflesRssParser.cs @@ -0,0 +1,88 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Xml.Linq; +using System.Text.RegularExpressions; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Indexers.Exceptions; +using NzbDrone.Core.Parser.Model; + + +namespace NzbDrone.Core.Indexers.Waffles +{ + public class WafflesRssParser : TorrentRssParser + { + public const string ns = "{http://purl.org/rss/1.0/}"; + public const string dc = "{http://purl.org/dc/elements/1.1/}"; + + protected override bool PreProcess(IndexerResponse indexerResponse) + { + var xdoc = LoadXmlDocument(indexerResponse); + var error = xdoc.Descendants("error").FirstOrDefault(); + + if (error == null) return true; + + var code = Convert.ToInt32(error.Attribute("code").Value); + var errorMessage = error.Attribute("description").Value; + + if (code >= 100 && code <= 199) throw new ApiKeyException("Invalid Pass key"); + + if (!indexerResponse.Request.Url.FullUri.Contains("passkey=") && errorMessage == "Missing parameter") + { + throw new ApiKeyException("Indexer requires an Pass key"); + } + + if (errorMessage == "Request limit reached") + { + throw new RequestLimitReachedException("API limit reached"); + } + + throw new IndexerException(indexerResponse, errorMessage); + } + + protected override ReleaseInfo ProcessItem(XElement item, ReleaseInfo releaseInfo) + { + var torrentInfo = base.ProcessItem(item, releaseInfo) as TorrentInfo; + + return torrentInfo; + } + + protected override string GetInfoUrl(XElement item) + { + return ParseUrl(item.TryGetValue("comments").TrimEnd("#comments")); + } + + protected override string GetCommentUrl(XElement item) + { + return ParseUrl(item.TryGetValue("comments")); + } + + private static readonly Regex ParseSizeRegex = new Regex(@"(?:Size: )(?\d+)<", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + protected override long GetSize(XElement item) + { + var match = ParseSizeRegex.Matches(item.Element("description").Value); + + if (match.Count != 0) + { + var value = decimal.Parse(Regex.Replace(match[0].Groups["value"].Value, "\\,", ""), CultureInfo.InvariantCulture); + return (long)value; + } + + return 0; + } + + protected override DateTime GetPublishDate(XElement item) + { + var dateString = item.TryGetValue(dc + "date"); + + if (dateString.IsNullOrWhiteSpace()) + { + throw new UnsupportedFeedException("Rss feed must have a pubDate element with a valid publish date."); + } + + return XElementExtensions.ParseDate(dateString); + } + } +} diff --git a/src/NzbDrone.Core/Indexers/Waffles/WafflesSettings.cs b/src/NzbDrone.Core/Indexers/Waffles/WafflesSettings.cs new file mode 100644 index 000000000..28d10c59f --- /dev/null +++ b/src/NzbDrone.Core/Indexers/Waffles/WafflesSettings.cs @@ -0,0 +1,52 @@ +using System.Text.RegularExpressions; +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Indexers.Waffles +{ + public class WafflesSettingsValidator : AbstractValidator + { + public WafflesSettingsValidator() + { + RuleFor(c => c.BaseUrl).ValidRootUrl(); + RuleFor(c => c.UserId).NotEmpty(); + RuleFor(c => c.RssPasskey).NotEmpty(); + } + } + + public class WafflesSettings : ITorrentIndexerSettings + { + private static readonly WafflesSettingsValidator Validator = new WafflesSettingsValidator(); + + public WafflesSettings() + { + BaseUrl = "https://www.waffles.ch"; + MinimumSeeders = IndexerDefaults.MINIMUM_SEEDERS; + } + + [FieldDefinition(0, Label = "Website URL")] + public string BaseUrl { get; set; } + + [FieldDefinition(1, Label = "UserId")] + public string UserId { get; set; } + + [FieldDefinition(2, Label = "RSS Passkey")] + public string RssPasskey { 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(); + + [FieldDefinition(5, Type = FieldType.Number, Label = "Early Download Limit", Unit = "days", HelpText = "Time before release date Lidarr will download from this indexer, empty is no limit", Advanced = true)] + public int? EarlyReleaseLimit { get; set; } + + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Indexers/XElementExtensions.cs b/src/NzbDrone.Core/Indexers/XElementExtensions.cs index 72da2790d..ef2b8b064 100644 --- a/src/NzbDrone.Core/Indexers/XElementExtensions.cs +++ b/src/NzbDrone.Core/Indexers/XElementExtensions.cs @@ -12,7 +12,7 @@ namespace NzbDrone.Core.Indexers { public static class XElementExtensions { - private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(XmlExtentions)); + private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(XmlExtensions)); public static readonly Regex RemoveTimeZoneRegex = new Regex(@"\s[A-Z]{2,4}$", RegexOptions.Compiled); diff --git a/src/NzbDrone.Core/Indexers/XmlCleaner.cs b/src/NzbDrone.Core/Indexers/XmlCleaner.cs new file mode 100644 index 000000000..91d0ea3b1 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/XmlCleaner.cs @@ -0,0 +1,36 @@ +using System.Globalization; +using System.Net; +using System.Text.RegularExpressions; + +namespace NzbDrone.Core.Indexers +{ + public static class XmlCleaner + { + + private static readonly Regex ReplaceEntitiesRegex = new Regex("&[a-z]+;", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex ReplaceUnicodeRegex = new Regex(@"[^\x09\x0A\x0D\u0020-\uD7FF\uE000-\uFFFD]", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public static string ReplaceEntities(string content) + { + return ReplaceEntitiesRegex.Replace(content, ReplaceEntity); + } + + public static string ReplaceUnicode(string content) + { + return ReplaceUnicodeRegex.Replace(content, string.Empty); + } + + private static string ReplaceEntity(Match match) + { + try + { + var character = WebUtility.HtmlDecode(match.Value); + return string.Concat("&#", (int)character[0], ";"); + } + catch + { + return match.Value; + } + } + } +} diff --git a/src/NzbDrone.Core/Instrumentation/ReconfigureLogging.cs b/src/NzbDrone.Core/Instrumentation/ReconfigureLogging.cs index e53bea79a..2de9edfe1 100644 --- a/src/NzbDrone.Core/Instrumentation/ReconfigureLogging.cs +++ b/src/NzbDrone.Core/Instrumentation/ReconfigureLogging.cs @@ -1,7 +1,10 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using NLog; using NLog.Config; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Instrumentation.Sentry; using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration.Events; using NzbDrone.Core.Messaging.Events; @@ -20,17 +23,28 @@ namespace NzbDrone.Core.Instrumentation public void Reconfigure() { var minimumLogLevel = LogLevel.FromString(_configFileProvider.LogLevel); + LogLevel minimumConsoleLogLevel; + + if (_configFileProvider.ConsoleLogLevel.IsNotNullOrWhiteSpace()) + minimumConsoleLogLevel = LogLevel.FromString(_configFileProvider.ConsoleLogLevel); + else if (minimumLogLevel > LogLevel.Info) + minimumConsoleLogLevel = minimumLogLevel; + else + minimumConsoleLogLevel = LogLevel.Info; var rules = LogManager.Configuration.LoggingRules; //Console - SetMinimumLogLevel(rules, "consoleLogger", minimumLogLevel); + SetMinimumLogLevel(rules, "consoleLogger", minimumConsoleLogLevel); //Log Files SetMinimumLogLevel(rules, "appFileInfo", minimumLogLevel <= LogLevel.Info ? LogLevel.Info : LogLevel.Off); SetMinimumLogLevel(rules, "appFileDebug", minimumLogLevel <= LogLevel.Debug ? LogLevel.Debug : LogLevel.Off); SetMinimumLogLevel(rules, "appFileTrace", minimumLogLevel <= LogLevel.Trace ? LogLevel.Trace : LogLevel.Off); + //Sentry + ReconfigureSentry(); + LogManager.ReconfigExistingLoggers(); } @@ -58,6 +72,16 @@ namespace NzbDrone.Core.Instrumentation } } + private void ReconfigureSentry() + { + var sentryTarget = LogManager.Configuration.AllTargets.OfType().FirstOrDefault(); + if (sentryTarget != null) + { + sentryTarget.SentryEnabled = RuntimeInfo.IsProduction && _configFileProvider.AnalyticsEnabled || RuntimeInfo.IsDevelopment; + sentryTarget.FilterEvents = _configFileProvider.FilterSentryEvents; + } + } + private List GetLogLevels() { return new List diff --git a/src/NzbDrone.Core/Instrumentation/ReconfigureSentry.cs b/src/NzbDrone.Core/Instrumentation/ReconfigureSentry.cs new file mode 100644 index 000000000..56274f6c9 --- /dev/null +++ b/src/NzbDrone.Core/Instrumentation/ReconfigureSentry.cs @@ -0,0 +1,42 @@ +using System.Linq; +using NLog; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Instrumentation.Sentry; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Lifecycle; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.Instrumentation +{ + public class ReconfigureSentry : IHandleAsync + { + private readonly IConfigFileProvider _configFileProvider; + private readonly IPlatformInfo _platformInfo; + private readonly IMainDatabase _database; + + public ReconfigureSentry(IConfigFileProvider configFileProvider, + IPlatformInfo platformInfo, + IMainDatabase database) + { + _configFileProvider = configFileProvider; + _platformInfo = platformInfo; + _database = database; + } + + public void Reconfigure() + { + // Extended sentry config + var sentryTarget = LogManager.Configuration.AllTargets.OfType().FirstOrDefault(); + if (sentryTarget != null) + { + sentryTarget.UpdateScope(_database.Version, _database.Migration, _configFileProvider.Branch, _platformInfo); + } + } + + public void HandleAsync(ApplicationStartedEvent message) + { + Reconfigure(); + } + } +} diff --git a/src/NzbDrone.Core/Jobs/TaskManager.cs b/src/NzbDrone.Core/Jobs/TaskManager.cs index 3ad7b909a..f75264d67 100644 --- a/src/NzbDrone.Core/Jobs/TaskManager.cs +++ b/src/NzbDrone.Core/Jobs/TaskManager.cs @@ -5,17 +5,16 @@ using NLog; using NzbDrone.Core.Backup; using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration.Events; -using NzbDrone.Core.DataAugmentation.Scene; using NzbDrone.Core.Download; using NzbDrone.Core.HealthCheck; using NzbDrone.Core.Housekeeping; using NzbDrone.Core.Indexers; +using NzbDrone.Core.ImportLists; using NzbDrone.Core.Lifecycle; -using NzbDrone.Core.MediaFiles.Commands; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Tv.Commands; using NzbDrone.Core.Update.Commands; +using NzbDrone.Core.Music.Commands; namespace NzbDrone.Core.Jobs { @@ -64,23 +63,27 @@ namespace NzbDrone.Core.Jobs new ScheduledTask{ Interval = 1, TypeName = typeof(CheckForFinishedDownloadCommand).FullName}, new ScheduledTask{ Interval = 5, TypeName = typeof(MessagingCleanupCommand).FullName}, new ScheduledTask{ Interval = 6*60, TypeName = typeof(ApplicationUpdateCommand).FullName}, - new ScheduledTask{ Interval = 3*60, TypeName = typeof(UpdateSceneMappingCommand).FullName}, new ScheduledTask{ Interval = 6*60, TypeName = typeof(CheckHealthCommand).FullName}, - new ScheduledTask{ Interval = 12*60, TypeName = typeof(RefreshSeriesCommand).FullName}, + new ScheduledTask{ Interval = 24*60, TypeName = typeof(RefreshArtistCommand).FullName}, new ScheduledTask{ Interval = 24*60, TypeName = typeof(HousekeepingCommand).FullName}, - new ScheduledTask{ Interval = 7*24*60, TypeName = typeof(BackupCommand).FullName}, new ScheduledTask - { - Interval = GetRssSyncInterval(), - TypeName = typeof(RssSyncCommand).FullName + { + Interval = GetBackupInterval(), + TypeName = typeof(BackupCommand).FullName }, new ScheduledTask - { - Interval = _configService.DownloadedEpisodesScanInterval, - TypeName = typeof(DownloadedEpisodesScanCommand).FullName + { + Interval = 24 * 60, // TODO: Add a setting? + TypeName = typeof(ImportListSyncCommand).FullName }, + + new ScheduledTask + { + Interval = GetRssSyncInterval(), + TypeName = typeof(RssSyncCommand).FullName + } }; var currentTasks = _scheduledTaskRepository.All().ToList(); @@ -111,6 +114,13 @@ namespace NzbDrone.Core.Jobs } } + private int GetBackupInterval() + { + var interval = _configService.BackupInterval; + + return interval * 60 * 24; + } + private int GetRssSyncInterval() { var interval = _configService.RssSyncInterval; @@ -144,10 +154,7 @@ namespace NzbDrone.Core.Jobs var rss = _scheduledTaskRepository.GetDefinition(typeof(RssSyncCommand)); rss.Interval = _configService.RssSyncInterval; - var downloadedEpisodes = _scheduledTaskRepository.GetDefinition(typeof(DownloadedEpisodesScanCommand)); - downloadedEpisodes.Interval = _configService.DownloadedEpisodesScanInterval; - - _scheduledTaskRepository.UpdateMany(new List { rss, downloadedEpisodes }); + _scheduledTaskRepository.Update(rss); } } } diff --git a/src/NzbDrone.Core/Lidarr.Core.csproj b/src/NzbDrone.Core/Lidarr.Core.csproj new file mode 100644 index 000000000..7d3bdafa9 --- /dev/null +++ b/src/NzbDrone.Core/Lidarr.Core.csproj @@ -0,0 +1,56 @@ + + + net462 + x86 + + + + + + + + + + + + + + + + + + + + + + + + ..\Libraries\Growl.Connector.dll + + + ..\Libraries\Growl.CoreLibrary.dll + + + ..\Libraries\Sqlite\System.Data.SQLite.dll + + + + + + + Resources\Logo\64.png + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + diff --git a/src/NzbDrone.Core/Lifecycle/LifecycleService.cs b/src/NzbDrone.Core/Lifecycle/LifecycleService.cs index a5c0c2a94..e4d727b0d 100644 --- a/src/NzbDrone.Core/Lifecycle/LifecycleService.cs +++ b/src/NzbDrone.Core/Lifecycle/LifecycleService.cs @@ -40,7 +40,7 @@ namespace NzbDrone.Core.Lifecycle if (_runtimeInfo.IsWindowsService) { - _serviceProvider.Stop(ServiceProvider.NZBDRONE_SERVICE_NAME); + _serviceProvider.Stop(ServiceProvider.SERVICE_NAME); } } @@ -52,7 +52,7 @@ namespace NzbDrone.Core.Lifecycle if (_runtimeInfo.IsWindowsService) { - _serviceProvider.Restart(ServiceProvider.NZBDRONE_SERVICE_NAME); + _serviceProvider.Restart(ServiceProvider.SERVICE_NAME); } } diff --git a/src/NzbDrone.Core/MediaCover/CoverAlreadyExistsSpecification.cs b/src/NzbDrone.Core/MediaCover/CoverAlreadyExistsSpecification.cs index 83f1e13de..8054aa8ec 100644 --- a/src/NzbDrone.Core/MediaCover/CoverAlreadyExistsSpecification.cs +++ b/src/NzbDrone.Core/MediaCover/CoverAlreadyExistsSpecification.cs @@ -1,34 +1,44 @@ -using NzbDrone.Common.Disk; -using NzbDrone.Common.Http; +using System; +using NzbDrone.Common.Disk; namespace NzbDrone.Core.MediaCover { public interface ICoverExistsSpecification { - bool AlreadyExists(string url, string path); + bool AlreadyExists(DateTime? serverModifiedDate, long? serverContentLength, string localPath); } public class CoverAlreadyExistsSpecification : ICoverExistsSpecification { private readonly IDiskProvider _diskProvider; - private readonly IHttpClient _httpClient; - public CoverAlreadyExistsSpecification(IDiskProvider diskProvider, IHttpClient httpClient) + public CoverAlreadyExistsSpecification(IDiskProvider diskProvider) { _diskProvider = diskProvider; - _httpClient = httpClient; } - public bool AlreadyExists(string url, string path) + public bool AlreadyExists(DateTime? serverModifiedDate, long? serverContentLength, string localPath) { - if (!_diskProvider.FileExists(path)) + if (!_diskProvider.FileExists(localPath)) { return false; } - var headers = _httpClient.Head(new HttpRequest(url)).Headers; - var fileSize = _diskProvider.GetFileSize(path); - return fileSize == headers.ContentLength; + if (serverContentLength.HasValue && serverContentLength.Value > 0) + { + var fileSize = _diskProvider.GetFileSize(localPath); + + return fileSize == serverContentLength; + } + + if (serverModifiedDate.HasValue) + { + DateTime? lastModifiedLocal = _diskProvider.FileGetLastWrite(localPath); + + return lastModifiedLocal.Value.ToUniversalTime() == serverModifiedDate.Value.ToUniversalTime(); + } + + return false; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/MediaCover/GdiPlusInterop.cs b/src/NzbDrone.Core/MediaCover/GdiPlusInterop.cs deleted file mode 100644 index 659a15d41..000000000 --- a/src/NzbDrone.Core/MediaCover/GdiPlusInterop.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.Drawing; -using NzbDrone.Common.EnvironmentInfo; - -namespace NzbDrone.Core.MediaCover -{ - public static class GdiPlusInterop - { - private static Exception _gdiPlusException; - - static GdiPlusInterop() - { - TestLibrary(); - } - - private static void TestLibrary() - { - if (OsInfo.IsWindows) - { - return; - } - - try - { - // We use StringFormat as test coz it gets properly cleaned up by the finalizer even if gdiplus is absent and is relatively non-invasive. - var strFormat = new StringFormat(); - - strFormat.Dispose(); - } - catch (Exception ex) - { - _gdiPlusException = ex; - } - } - - public static void CheckGdiPlus() - { - if (_gdiPlusException != null) - { - throw new DllNotFoundException("Couldn't load GDIPlus library", _gdiPlusException); - } - } - } -} diff --git a/src/NzbDrone.Core/MediaCover/ImageResizer.cs b/src/NzbDrone.Core/MediaCover/ImageResizer.cs index 9673cbec6..0b684d28e 100644 --- a/src/NzbDrone.Core/MediaCover/ImageResizer.cs +++ b/src/NzbDrone.Core/MediaCover/ImageResizer.cs @@ -1,5 +1,8 @@ -using ImageResizer; +using System; using NzbDrone.Common.Disk; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Processing; +using SixLabors.Memory; namespace NzbDrone.Core.MediaCover { @@ -15,25 +18,20 @@ namespace NzbDrone.Core.MediaCover public ImageResizer(IDiskProvider diskProvider) { _diskProvider = diskProvider; + + // More conservative memory allocation + SixLabors.ImageSharp.Configuration.Default.MemoryAllocator = new SimpleGcMemoryAllocator(); } public void Resize(string source, string destination, int height) { try { - GdiPlusInterop.CheckGdiPlus(); - - using (var sourceStream = _diskProvider.OpenReadStream(source)) + using (var image = Image.Load(source)) { - using (var outputStream = _diskProvider.OpenWriteStream(destination)) - { - var settings = new Instructions(); - settings.Height = height; - - var job = new ImageJob(sourceStream, outputStream, settings); - - ImageBuilder.Current.Build(job); - } + var width = (int)Math.Floor((double)image.Width * (double)height / (double)image.Height); + image.Mutate(x => x.Resize(width, height)); + image.Save(destination); } } catch diff --git a/src/NzbDrone.Core/MediaCover/MediaCover.cs b/src/NzbDrone.Core/MediaCover/MediaCover.cs index 4b4f54b00..4e5c42b7d 100644 --- a/src/NzbDrone.Core/MediaCover/MediaCover.cs +++ b/src/NzbDrone.Core/MediaCover/MediaCover.cs @@ -1,3 +1,6 @@ +using System.IO; +using NzbDrone.Common.Extensions; +using Equ; using NzbDrone.Core.Datastore; namespace NzbDrone.Core.MediaCover @@ -10,13 +13,39 @@ namespace NzbDrone.Core.MediaCover Banner = 2, Fanart = 3, Screenshot = 4, - Headshot = 5 + Headshot = 5, + Cover = 6, + Disc = 7, + Logo = 8 } - public class MediaCover : IEmbeddedDocument + public enum MediaCoverEntity { + Artist = 0, + Album = 1 + } + + public class MediaCover : MemberwiseEquatable, IEmbeddedDocument + { + private string _url; + public string Url + { + get + { + return _url; + } + set + { + _url = value; + if (Extension.IsNullOrWhiteSpace()) + { + Extension = Path.GetExtension(value); + } + } + } + public MediaCoverTypes CoverType { get; set; } - public string Url { get; set; } + public string Extension { get; private set; } public MediaCover() { @@ -28,4 +57,4 @@ namespace NzbDrone.Core.MediaCover Url = url; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/MediaCover/MediaCoverService.cs b/src/NzbDrone.Core/MediaCover/MediaCoverService.cs index f8e7b652b..db03637c5 100644 --- a/src/NzbDrone.Core/MediaCover/MediaCoverService.cs +++ b/src/NzbDrone.Core/MediaCover/MediaCoverService.cs @@ -1,6 +1,7 @@ -using System; +using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Net; using NLog; using NzbDrone.Common.Disk; @@ -9,23 +10,25 @@ using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Tv; -using NzbDrone.Core.Tv.Events; +using NzbDrone.Core.Music; +using NzbDrone.Core.Music.Events; namespace NzbDrone.Core.MediaCover { public interface IMapCoversToLocal { - void ConvertToLocalUrls(int seriesId, IEnumerable covers); - string GetCoverPath(int seriesId, MediaCoverTypes mediaCoverTypes, int? height = null); + void ConvertToLocalUrls(int entityId, MediaCoverEntity coverEntity, IEnumerable covers); + string GetCoverPath(int entityId, MediaCoverEntity coverEntity, MediaCoverTypes mediaCoverTypes, string extension, int? height = null); + void EnsureAlbumCovers(Album album); } public class MediaCoverService : - IHandleAsync, - IHandleAsync, + IHandleAsync, + IHandleAsync, IMapCoversToLocal { private readonly IImageResizer _resizer; + private readonly IAlbumService _albumService; private readonly IHttpClient _httpClient; private readonly IDiskProvider _diskProvider; private readonly ICoverExistsSpecification _coverExistsSpecification; @@ -36,6 +39,7 @@ namespace NzbDrone.Core.MediaCover private readonly string _coverRootFolder; public MediaCoverService(IImageResizer resizer, + IAlbumService albumService, IHttpClient httpClient, IDiskProvider diskProvider, IAppFolderInfo appFolderInfo, @@ -45,6 +49,7 @@ namespace NzbDrone.Core.MediaCover Logger logger) { _resizer = resizer; + _albumService = albumService; _httpClient = httpClient; _diskProvider = diskProvider; _coverExistsSpecification = coverExistsSpecification; @@ -55,20 +60,34 @@ namespace NzbDrone.Core.MediaCover _coverRootFolder = appFolderInfo.GetMediaCoverPath(); } - public string GetCoverPath(int seriesId, MediaCoverTypes coverTypes, int? height = null) + public string GetCoverPath(int entityId, MediaCoverEntity coverEntity, MediaCoverTypes coverTypes, string extension, int? height = null) { var heightSuffix = height.HasValue ? "-" + height.ToString() : ""; - return Path.Combine(GetSeriesCoverPath(seriesId), coverTypes.ToString().ToLower() + heightSuffix + ".jpg"); + if (coverEntity == MediaCoverEntity.Album) + { + return Path.Combine(GetAlbumCoverPath(entityId), coverTypes.ToString().ToLower() + heightSuffix + extension); + } + else + { + return Path.Combine(GetArtistCoverPath(entityId), coverTypes.ToString().ToLower() + heightSuffix + extension); + } } - public void ConvertToLocalUrls(int seriesId, IEnumerable covers) + public void ConvertToLocalUrls(int entityId, MediaCoverEntity coverEntity, IEnumerable covers) { foreach (var mediaCover in covers) { - var filePath = GetCoverPath(seriesId, mediaCover.CoverType); + var filePath = GetCoverPath(entityId, coverEntity, mediaCover.CoverType, mediaCover.Extension, null); - mediaCover.Url = _configFileProvider.UrlBase + @"/MediaCover/" + seriesId + "/" + mediaCover.CoverType.ToString().ToLower() + ".jpg"; + if (coverEntity == MediaCoverEntity.Album) + { + mediaCover.Url = _configFileProvider.UrlBase + @"/MediaCover/Albums/" + entityId + "/" + mediaCover.CoverType.ToString().ToLower() + mediaCover.Extension; + } + else + { + mediaCover.Url = _configFileProvider.UrlBase + @"/MediaCover/" + entityId + "/" + mediaCover.CoverType.ToString().ToLower() + mediaCover.Extension; + } if (_diskProvider.FileExists(filePath)) { @@ -78,78 +97,121 @@ namespace NzbDrone.Core.MediaCover } } - private string GetSeriesCoverPath(int seriesId) + private string GetArtistCoverPath(int artistId) + { + return Path.Combine(_coverRootFolder, artistId.ToString()); + } + + private string GetAlbumCoverPath(int albumId) { - return Path.Combine(_coverRootFolder, seriesId.ToString()); + return Path.Combine(_coverRootFolder, "Albums", albumId.ToString()); } - private void EnsureCovers(Series series) + private void EnsureArtistCovers(Artist artist) { - foreach (var cover in series.Images) + foreach (var cover in artist.Metadata.Value.Images) { - var fileName = GetCoverPath(series.Id, cover.CoverType); + var fileName = GetCoverPath(artist.Id, MediaCoverEntity.Artist, cover.CoverType, cover.Extension); var alreadyExists = false; + try { - alreadyExists = _coverExistsSpecification.AlreadyExists(cover.Url, fileName); + var serverFileHeaders = _httpClient.Head(new HttpRequest(cover.Url) { AllowAutoRedirect = true }).Headers; + + alreadyExists = _coverExistsSpecification.AlreadyExists(serverFileHeaders.LastModified, serverFileHeaders.ContentLength, fileName); + if (!alreadyExists) { - DownloadCover(series, cover); + DownloadCover(artist, cover, serverFileHeaders.LastModified ?? DateTime.Now); } } catch (WebException e) { - _logger.Warn("Couldn't download media cover for {0}. {1}", series, e.Message); + _logger.Warn("Couldn't download media cover for {0}. {1}", artist, e.Message); } catch (Exception e) { - _logger.Error(e, "Couldn't download media cover for {0}", series); + _logger.Error(e, "Couldn't download media cover for {0}", artist); } - EnsureResizedCovers(series, cover, !alreadyExists); + EnsureResizedCovers(artist, cover, !alreadyExists); } } - private void DownloadCover(Series series, MediaCover cover) + public void EnsureAlbumCovers(Album album) { - var fileName = GetCoverPath(series.Id, cover.CoverType); + foreach (var cover in album.Images.Where(e => e.CoverType == MediaCoverTypes.Cover)) + { + var fileName = GetCoverPath(album.Id, MediaCoverEntity.Album, cover.CoverType, cover.Extension, null); + var alreadyExists = false; + try + { + var serverFileHeaders = _httpClient.Head(new HttpRequest(cover.Url) { AllowAutoRedirect = true }).Headers; - _logger.Info("Downloading {0} for {1} {2}", cover.CoverType, series, cover.Url); - _httpClient.DownloadFile(cover.Url, fileName); + alreadyExists = _coverExistsSpecification.AlreadyExists(serverFileHeaders.LastModified, serverFileHeaders.ContentLength, fileName); + + if (!alreadyExists) + { + DownloadAlbumCover(album, cover, serverFileHeaders.LastModified ?? DateTime.Now); + } + } + catch (WebException e) + { + _logger.Warn("Couldn't download media cover for {0}. {1}", album, e.Message); + } + catch (Exception e) + { + _logger.Error(e, "Couldn't download media cover for {0}", album); + } + } } - private void EnsureResizedCovers(Series series, MediaCover cover, bool forceResize) + private void DownloadCover(Artist artist, MediaCover cover, DateTime lastModified) { - int[] heights; + var fileName = GetCoverPath(artist.Id, MediaCoverEntity.Artist, cover.CoverType, cover.Extension); + + _logger.Info("Downloading {0} for {1} {2}", cover.CoverType, artist, cover.Url); + _httpClient.DownloadFile(cover.Url, fileName); - switch (cover.CoverType) + try { - default: - return; + _diskProvider.FileSetLastWriteTime(fileName, lastModified); + } + catch (Exception ex) + { + _logger.Debug(ex, "Unable to set modified date for {0} image for artist {1}", cover.CoverType, artist); + } + } - case MediaCoverTypes.Poster: - case MediaCoverTypes.Headshot: - heights = new[] { 500, 250 }; - break; + private void DownloadAlbumCover(Album album, MediaCover cover, DateTime lastModified) + { + var fileName = GetCoverPath(album.Id, MediaCoverEntity.Album, cover.CoverType, cover.Extension, null); - case MediaCoverTypes.Banner: - heights = new[] { 70, 35 }; - break; + _logger.Info("Downloading {0} for {1} {2}", cover.CoverType, album, cover.Url); + _httpClient.DownloadFile(cover.Url, fileName); - case MediaCoverTypes.Fanart: - case MediaCoverTypes.Screenshot: - heights = new[] { 360, 180 }; - break; + try + { + _diskProvider.FileSetLastWriteTime(fileName, lastModified); + } + catch (Exception ex) + { + _logger.Debug(ex, "Unable to set modified date for {0} image for album {1}", cover.CoverType, album); } + } + + private void EnsureResizedCovers(Artist artist, MediaCover cover, bool forceResize, Album album = null) + { + int[] heights = GetDefaultHeights(cover.CoverType); foreach (var height in heights) { - var mainFileName = GetCoverPath(series.Id, cover.CoverType); - var resizeFileName = GetCoverPath(series.Id, cover.CoverType, height); + var mainFileName = GetCoverPath(artist.Id, MediaCoverEntity.Artist, cover.CoverType, cover.Extension); + var resizeFileName = GetCoverPath(artist.Id, MediaCoverEntity.Artist, cover.CoverType, cover.Extension, height); if (forceResize || !_diskProvider.FileExists(resizeFileName) || _diskProvider.GetFileSize(resizeFileName) == 0) { - _logger.Debug("Resizing {0}-{1} for {2}", cover.CoverType, height, series); + _logger.Debug("Resizing {0}-{1} for {2}", cover.CoverType, height, artist); try { @@ -157,25 +219,56 @@ namespace NzbDrone.Core.MediaCover } catch { - _logger.Debug("Couldn't resize media cover {0}-{1} for {2}, using full size image instead.", cover.CoverType, height, series); + _logger.Debug("Couldn't resize media cover {0}-{1} for artist {2}, using full size image instead.", cover.CoverType, height, artist); } } } } - public void HandleAsync(SeriesUpdatedEvent message) + private int[] GetDefaultHeights(MediaCoverTypes coverType) + { + switch (coverType) + { + default: + return new int[] { }; + + case MediaCoverTypes.Poster: + case MediaCoverTypes.Disc: + case MediaCoverTypes.Cover: + case MediaCoverTypes.Logo: + case MediaCoverTypes.Headshot: + return new[] { 500, 250 }; + + case MediaCoverTypes.Banner: + return new[] { 70, 35 }; + + case MediaCoverTypes.Fanart: + case MediaCoverTypes.Screenshot: + return new[] { 360, 180 }; + } + } + + public void HandleAsync(ArtistRefreshCompleteEvent message) { - EnsureCovers(message.Series); - _eventAggregator.PublishEvent(new MediaCoversUpdatedEvent(message.Series)); + EnsureArtistCovers(message.Artist); + + var albums = _albumService.GetAlbumsByArtist(message.Artist.Id); + foreach (Album album in albums) + { + EnsureAlbumCovers(album); + } + + _eventAggregator.PublishEvent(new MediaCoversUpdatedEvent(message.Artist)); } - public void HandleAsync(SeriesDeletedEvent message) + public void HandleAsync(ArtistDeletedEvent message) { - var path = GetSeriesCoverPath(message.Series.Id); + var path = GetArtistCoverPath(message.Artist.Id); if (_diskProvider.FolderExists(path)) { _diskProvider.DeleteFolder(path, true); } } + } } diff --git a/src/NzbDrone.Core/MediaCover/MediaCoversUpdatedEvent.cs b/src/NzbDrone.Core/MediaCover/MediaCoversUpdatedEvent.cs index 7335f7f9b..65ce089a1 100644 --- a/src/NzbDrone.Core/MediaCover/MediaCoversUpdatedEvent.cs +++ b/src/NzbDrone.Core/MediaCover/MediaCoversUpdatedEvent.cs @@ -1,15 +1,21 @@ -using NzbDrone.Common.Messaging; -using NzbDrone.Core.Tv; +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Music; namespace NzbDrone.Core.MediaCover { public class MediaCoversUpdatedEvent : IEvent { - public Series Series { get; set; } + public Artist Artist { get; set; } + public Album Album { get; set; } - public MediaCoversUpdatedEvent(Series series) + public MediaCoversUpdatedEvent(Artist artist) { - Series = series; + Artist = artist; + } + + public MediaCoversUpdatedEvent(Album album) + { + Album = album; } } } diff --git a/src/NzbDrone.Core/MediaFiles/AudioTag.cs b/src/NzbDrone.Core/MediaFiles/AudioTag.cs new file mode 100644 index 000000000..786f24b5b --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/AudioTag.cs @@ -0,0 +1,601 @@ +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Parser.Model; +using System.Linq; +using System.Collections.Generic; +using System; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Parser; +using NzbDrone.Common.Instrumentation; +using NLog; +using TagLib; +using TagLib.Id3v2; +using NLog.Fluent; +using NzbDrone.Common.Instrumentation.Extensions; +using System.Globalization; + +namespace NzbDrone.Core.MediaFiles +{ + public class AudioTag + { + private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(AudioTag)); + + public string Title { get; set; } + public string[] Performers { get; set; } + public string[] AlbumArtists { get; set; } + public uint Track { get; set; } + public uint TrackCount { get; set; } + public string Album { get; set; } + public uint Disc { get; set; } + public uint DiscCount { get; set; } + public string Media { get; set; } + public DateTime? Date { get; set; } + public DateTime? OriginalReleaseDate { get; set; } + public uint Year { get; set; } + public uint OriginalYear { get; set; } + public string Publisher { get; set; } + public TimeSpan Duration { get; set; } + public string[] Genres { get; set; } + public string ImageFile { get; set; } + public long ImageSize { get; set; } + public string MusicBrainzReleaseCountry { get; set; } + public string MusicBrainzReleaseStatus { get; set; } + public string MusicBrainzReleaseType { get; set; } + public string MusicBrainzReleaseId { get; set; } + public string MusicBrainzArtistId { get; set; } + public string MusicBrainzReleaseArtistId { get; set; } + public string MusicBrainzReleaseGroupId { get; set; } + public string MusicBrainzTrackId { get; set; } + public string MusicBrainzReleaseTrackId { get; set; } + public string MusicBrainzAlbumComment { get; set; } + + public bool IsValid { get; private set; } + public QualityModel Quality { get; set; } + public MediaInfoModel MediaInfo { get; set; } + + public AudioTag() + { + IsValid = true; + } + + public AudioTag(string path) + { + Read(path); + } + + public void Read(string path) + { + Logger.Debug($"Starting tag read for {path}"); + + IsValid = false; + TagLib.File file = null; + try + { + file = TagLib.File.Create(path); + var tag = file.Tag; + + Title = tag.Title ?? tag.TitleSort; + Performers = tag.Performers ?? tag.PerformersSort; + AlbumArtists = tag.AlbumArtists ?? tag.AlbumArtistsSort; + Track = tag.Track; + TrackCount = tag.TrackCount; + Album = tag.Album ?? tag.AlbumSort; + Disc = tag.Disc; + DiscCount = tag.DiscCount; + Year = tag.Year; + Publisher = tag.Publisher; + Duration = file.Properties.Duration; + Genres = tag.Genres; + ImageSize = tag.Pictures.FirstOrDefault()?.Data.Count ?? 0; + MusicBrainzReleaseCountry = tag.MusicBrainzReleaseCountry; + MusicBrainzReleaseStatus = tag.MusicBrainzReleaseStatus; + MusicBrainzReleaseType = tag.MusicBrainzReleaseType; + MusicBrainzReleaseId = tag.MusicBrainzReleaseId; + MusicBrainzArtistId = tag.MusicBrainzArtistId; + MusicBrainzReleaseArtistId = tag.MusicBrainzReleaseArtistId; + MusicBrainzReleaseGroupId = tag.MusicBrainzReleaseGroupId; + MusicBrainzTrackId = tag.MusicBrainzTrackId; + + DateTime tempDate; + + // Do the ones that aren't handled by the generic taglib implementation + if (file.TagTypesOnDisk.HasFlag(TagTypes.Id3v2)) + { + var id3tag = (TagLib.Id3v2.Tag) file.GetTag(TagTypes.Id3v2); + Media = id3tag.GetTextAsString("TMED"); + Date = ReadId3Date(id3tag, "TDRC"); + OriginalReleaseDate = ReadId3Date(id3tag, "TDOR"); + MusicBrainzAlbumComment = UserTextInformationFrame.Get(id3tag, "MusicBrainz Album Comment", false)?.Text.ExclusiveOrDefault(); + MusicBrainzReleaseTrackId = UserTextInformationFrame.Get(id3tag, "MusicBrainz Release Track Id", false)?.Text.ExclusiveOrDefault(); + } + else if (file.TagTypesOnDisk.HasFlag(TagTypes.Xiph)) + { + // while publisher is handled by taglib, it seems to be mapped to 'ORGANIZATION' and not 'LABEL' like Picard is + // https://picard.musicbrainz.org/docs/mappings/ + var flactag = (TagLib.Ogg.XiphComment) file.GetTag(TagLib.TagTypes.Xiph); + Media = flactag.GetField("MEDIA").ExclusiveOrDefault(); + Date = DateTime.TryParse(flactag.GetField("DATE").ExclusiveOrDefault(), out tempDate) ? tempDate : default(DateTime?); + OriginalReleaseDate = DateTime.TryParse(flactag.GetField("ORIGINALDATE").ExclusiveOrDefault(), out tempDate) ? tempDate : default(DateTime?); + Publisher = flactag.GetField("LABEL").ExclusiveOrDefault(); + MusicBrainzAlbumComment = flactag.GetField("MUSICBRAINZ_ALBUMCOMMENT").ExclusiveOrDefault(); + MusicBrainzReleaseTrackId = flactag.GetField("MUSICBRAINZ_RELEASETRACKID").ExclusiveOrDefault(); + + // If we haven't managed to read status/type, try the alternate mapping + if (MusicBrainzReleaseStatus.IsNullOrWhiteSpace()) + { + MusicBrainzReleaseStatus = flactag.GetField("RELEASESTATUS").ExclusiveOrDefault(); + } + + if (MusicBrainzReleaseType.IsNullOrWhiteSpace()) + { + MusicBrainzReleaseType = flactag.GetField("RELEASETYPE").ExclusiveOrDefault(); + } + } + else if (file.TagTypesOnDisk.HasFlag(TagTypes.Ape)) + { + var apetag = (TagLib.Ape.Tag) file.GetTag(TagTypes.Ape); + Media = apetag.GetItem("Media")?.ToString(); + Date = DateTime.TryParse(apetag.GetItem("Year")?.ToString(), out tempDate) ? tempDate : default(DateTime?); + OriginalReleaseDate = DateTime.TryParse(apetag.GetItem("Original Date")?.ToString(), out tempDate) ? tempDate : default(DateTime?); + Publisher = apetag.GetItem("Label")?.ToString(); + MusicBrainzAlbumComment = apetag.GetItem("MUSICBRAINZ_ALBUMCOMMENT")?.ToString(); + MusicBrainzReleaseTrackId = apetag.GetItem("MUSICBRAINZ_RELEASETRACKID")?.ToString(); + } + else if (file.TagTypesOnDisk.HasFlag(TagTypes.Asf)) + { + var asftag = (TagLib.Asf.Tag) file.GetTag(TagTypes.Asf); + Media = asftag.GetDescriptorString("WM/Media"); + Date = DateTime.TryParse(asftag.GetDescriptorString("WM/Year"), out tempDate) ? tempDate : default(DateTime?); + OriginalReleaseDate = DateTime.TryParse(asftag.GetDescriptorString("WM/OriginalReleaseTime"), out tempDate) ? tempDate : default(DateTime?); + Publisher = asftag.GetDescriptorString("WM/Publisher"); + MusicBrainzAlbumComment = asftag.GetDescriptorString("MusicBrainz/Album Comment"); + MusicBrainzReleaseTrackId = asftag.GetDescriptorString("MusicBrainz/Release Track Id"); + } + else if (file.TagTypesOnDisk.HasFlag(TagTypes.Apple)) + { + var appletag = (TagLib.Mpeg4.AppleTag) file.GetTag(TagTypes.Apple); + Media = appletag.GetDashBox("com.apple.iTunes", "MEDIA"); + Date = DateTime.TryParse(appletag.DataBoxes(FixAppleId("day")).FirstOrDefault()?.Text, out tempDate) ? tempDate : default(DateTime?); + OriginalReleaseDate = DateTime.TryParse(appletag.GetDashBox("com.apple.iTunes", "Original Date"), out tempDate) ? tempDate : default(DateTime?); + MusicBrainzAlbumComment = appletag.GetDashBox("com.apple.iTunes", "MusicBrainz Album Comment"); + MusicBrainzReleaseTrackId = appletag.GetDashBox("com.apple.iTunes", "MusicBrainz Release Track Id"); + } + + OriginalYear = OriginalReleaseDate.HasValue ? (uint)OriginalReleaseDate?.Year : 0; + + foreach (ICodec codec in file.Properties.Codecs) + { + IAudioCodec acodec = codec as IAudioCodec; + + if (acodec != null && (acodec.MediaTypes & MediaTypes.Audio) != MediaTypes.None) + { + int bitrate = acodec.AudioBitrate; + if (bitrate == 0) + { + // Taglib can't read bitrate for Opus. + bitrate = EstimateBitrate(file, path); + } + + Logger.Debug("Audio Properties: " + acodec.Description + ", Bitrate: " + bitrate + ", Sample Size: " + + file.Properties.BitsPerSample + ", SampleRate: " + acodec.AudioSampleRate + ", Channels: " + acodec.AudioChannels); + + Quality = QualityParser.ParseQuality(file.Name, acodec.Description, bitrate, file.Properties.BitsPerSample); + Logger.Debug($"Quality parsed: {Quality}, Source: {Quality.QualityDetectionSource}"); + + MediaInfo = new MediaInfoModel { + AudioFormat = acodec.Description, + AudioBitrate = bitrate, + AudioChannels = acodec.AudioChannels, + AudioBits = file.Properties.BitsPerSample, + AudioSampleRate = acodec.AudioSampleRate + }; + } + } + + IsValid = true; + } + catch (Exception ex) + { + if (ex is CorruptFileException) + { + Logger.Warn(ex, $"Tag reading failed for {path}. File is corrupt"); + } + else + { + // Log as error so it goes to sentry with correct fingerprint + Logger.Error(ex, "Tag reading failed for {0}", path); + } + } + finally + { + file?.Dispose(); + } + + // make sure these are initialized to avoid errors later on + if (Quality == null) + { + Quality = QualityParser.ParseQuality(path, null, EstimateBitrate(file, path)); + Logger.Debug($"Unable to parse qulity from tag, Quality parsed from file path: {Quality}, Source: {Quality.QualityDetectionSource}"); + } + + MediaInfo = MediaInfo ?? new MediaInfoModel(); + } + + private int EstimateBitrate(TagLib.File file, string path) + { + int bitrate = 0; + try + { + // Taglib File.Length is unreliable so use System.IO + var size = new System.IO.FileInfo(path).Length; + var duration = file.Properties.Duration.TotalSeconds; + bitrate = (int) ((size * 8L) / (duration * 1024)); + + Logger.Trace($"Estimating bitrate. Size: {size} Duration: {duration} Bitrate: {bitrate}"); + } + catch + { + } + + return bitrate; + } + + private DateTime? ReadId3Date(TagLib.Id3v2.Tag tag, string dateTag) + { + string date = tag.GetTextAsString(dateTag); + + if (tag.Version == 4) + { + // the unabused TDRC/TDOR tags + return DateTime.TryParse(date, out DateTime result) ? result : default(DateTime?); + } + else if (dateTag == "TDRC") + { + // taglib maps the v3 TYER and TDAT to TDRC but does it incorrectly + return DateTime.TryParseExact(date, "yyyy-dd-MM", CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime result) ? result : default(DateTime?); + } + else + { + // taglib maps the v3 TORY to TDRC so we just get a year + return Int32.TryParse(date, out int year) && year >= 1860 && year <= DateTime.UtcNow.Year + 1 ? new DateTime(year, 1, 1) : default(DateTime?); + } + } + + private void WriteId3Date(TagLib.Id3v2.Tag tag, string v4field, string v3yyyy, string v3ddmm, DateTime? date) + { + if (tag.Version == 4) + { + tag.SetTextFrame(v3yyyy, default(string)); + if (v3ddmm.IsNotNullOrWhiteSpace()) + { + tag.SetTextFrame(v3ddmm, default(string)); + } + tag.SetTextFrame(v4field, date.HasValue ? date.Value.ToString("yyyy-MM-dd") : null); + } + else + { + tag.SetTextFrame(v4field, default(string)); + tag.SetTextFrame(v3yyyy, date.HasValue ? date.Value.ToString("yyyy") : null); + if (v3ddmm.IsNotNullOrWhiteSpace()) + { + tag.SetTextFrame(v3ddmm, date.HasValue ? date.Value.ToString("ddMM") : null); + } + } + } + + private void WriteId3Tag(TagLib.Id3v2.Tag tag, string id, string value) + { + var frame = UserTextInformationFrame.Get(tag, id, true); + + if (value.IsNotNullOrWhiteSpace()) + { + frame.Text = value.Split(';'); + } + else + { + tag.RemoveFrame(frame); + } + } + + private static ReadOnlyByteVector FixAppleId(ByteVector id) + { + if (id.Count == 4) { + var roid = id as ReadOnlyByteVector; + if (roid != null) + return roid; + + return new ReadOnlyByteVector(id); + } + + if (id.Count == 3) + return new ReadOnlyByteVector(0xa9, id[0], id[1], id[2]); + + return null; + } + + public void Write(string path) + { + Logger.Debug($"Starting tag write for {path}"); + + // patch up any null fields to work around TagLib exception for + // WMA with null performers/albumartists + Performers = Performers ?? new string[0]; + AlbumArtists = AlbumArtists ?? new string[0]; + Genres = Genres ?? new string[0]; + + TagLib.File file = null; + try + { + file = TagLib.File.Create(path); + var tag = file.Tag; + + // do the ones with direct support in TagLib + tag.Title = Title; + tag.Performers = Performers; + tag.AlbumArtists = AlbumArtists; + tag.Track = Track; + tag.TrackCount = TrackCount; + tag.Album = Album; + tag.Disc = Disc; + tag.DiscCount = DiscCount; + tag.Publisher = Publisher; + tag.Genres = Genres; + tag.MusicBrainzReleaseCountry = MusicBrainzReleaseCountry; + tag.MusicBrainzReleaseStatus = MusicBrainzReleaseStatus; + tag.MusicBrainzReleaseType = MusicBrainzReleaseType; + tag.MusicBrainzReleaseId = MusicBrainzReleaseId; + tag.MusicBrainzArtistId = MusicBrainzArtistId; + tag.MusicBrainzReleaseArtistId = MusicBrainzReleaseArtistId; + tag.MusicBrainzReleaseGroupId = MusicBrainzReleaseGroupId; + tag.MusicBrainzTrackId = MusicBrainzTrackId; + + if (ImageFile.IsNotNullOrWhiteSpace()) + { + tag.Pictures = new IPicture[1] { new Picture(ImageFile) }; + } + + if (file.TagTypes.HasFlag(TagTypes.Id3v2)) + { + var id3tag = (TagLib.Id3v2.Tag) file.GetTag(TagTypes.Id3v2); + id3tag.SetTextFrame("TMED", Media); + WriteId3Date(id3tag, "TDRC", "TYER", "TDAT", Date); + WriteId3Date(id3tag, "TDOR", "TORY", null, OriginalReleaseDate); + WriteId3Tag(id3tag, "MusicBrainz Album Comment", MusicBrainzAlbumComment); + WriteId3Tag(id3tag, "MusicBrainz Release Track Id", MusicBrainzReleaseTrackId); + } + else if (file.TagTypes.HasFlag(TagTypes.Xiph)) + { + // while publisher is handled by taglib, it seems to be mapped to 'ORGANIZATION' and not 'LABEL' like Picard is + // https://picard.musicbrainz.org/docs/mappings/ + tag.Publisher = null; + // taglib inserts leading zeros so set manually + tag.Track = 0; + + var flactag = (TagLib.Ogg.XiphComment) file.GetTag(TagLib.TagTypes.Xiph); + + flactag.SetField("DATE", Date.HasValue ? Date.Value.ToString("yyyy-MM-dd") : null); + flactag.SetField("ORIGINALDATE", OriginalReleaseDate.HasValue ? OriginalReleaseDate.Value.ToString("yyyy-MM-dd") : null); + flactag.SetField("ORIGINALYEAR", OriginalReleaseDate.HasValue ? OriginalReleaseDate.Value.Year.ToString() : null); + flactag.SetField("TRACKTOTAL", TrackCount); + flactag.SetField("TOTALTRACKS", TrackCount); + flactag.SetField("TRACKNUMBER", Track); + flactag.SetField("TOTALDISCS", DiscCount); + flactag.SetField("MEDIA", Media); + flactag.SetField("LABEL", Publisher); + flactag.SetField("MUSICBRAINZ_ALBUMCOMMENT", MusicBrainzAlbumComment); + flactag.SetField("MUSICBRAINZ_RELEASETRACKID", MusicBrainzReleaseTrackId); + + // Add the alternate mappings used by picard (we write both) + flactag.SetField("RELEASESTATUS", MusicBrainzReleaseStatus); + flactag.SetField("RELEASETYPE", MusicBrainzReleaseType); + } + else if (file.TagTypes.HasFlag(TagTypes.Ape)) + { + var apetag = (TagLib.Ape.Tag) file.GetTag(TagTypes.Ape); + + apetag.SetValue("Year", Date.HasValue ? Date.Value.ToString("yyyy-MM-dd") : null); + apetag.SetValue("Original Date", OriginalReleaseDate.HasValue ? OriginalReleaseDate.Value.ToString("yyyy-MM-dd") : null); + apetag.SetValue("Original Year", OriginalReleaseDate.HasValue ? OriginalReleaseDate.Value.Year.ToString() : null); + apetag.SetValue("Media", Media); + apetag.SetValue("Label", Publisher); + apetag.SetValue("MUSICBRAINZ_ALBUMCOMMENT", MusicBrainzAlbumComment); + apetag.SetValue("MUSICBRAINZ_RELEASETRACKID", MusicBrainzReleaseTrackId); + } + else if (file.TagTypes.HasFlag(TagTypes.Asf)) + { + var asftag = (TagLib.Asf.Tag) file.GetTag(TagTypes.Asf); + + asftag.SetDescriptorString(Date.HasValue ? Date.Value.ToString("yyyy-MM-dd") : null, "WM/Year"); + asftag.SetDescriptorString(OriginalReleaseDate.HasValue ? OriginalReleaseDate.Value.ToString("yyyy-MM-dd") : null, "WM/OriginalReleaseTime"); + asftag.SetDescriptorString(OriginalReleaseDate.HasValue ? OriginalReleaseDate.Value.Year.ToString() : null, "WM/OriginalReleaseYear"); + asftag.SetDescriptorString(Media, "WM/Media"); + asftag.SetDescriptorString(Publisher, "WM/Publisher"); + asftag.SetDescriptorString(MusicBrainzAlbumComment, "MusicBrainz/Album Comment"); + asftag.SetDescriptorString(MusicBrainzReleaseTrackId, "MusicBrainz/Release Track Id"); + } + else if (file.TagTypes.HasFlag(TagTypes.Apple)) + { + var appletag = (TagLib.Mpeg4.AppleTag) file.GetTag(TagTypes.Apple); + + appletag.SetText(FixAppleId("day"), Date.HasValue ? Date.Value.ToString("yyyy-MM-dd") : null); + appletag.SetDashBox("com.apple.iTunes", "Original Date", OriginalReleaseDate.HasValue ? OriginalReleaseDate.Value.ToString("yyyy-MM-dd") : null); + appletag.SetDashBox("com.apple.iTunes", "Original Year", OriginalReleaseDate.HasValue ? OriginalReleaseDate.Value.Year.ToString() : null); + appletag.SetDashBox("com.apple.iTunes", "MEDIA", Media); + appletag.SetDashBox("com.apple.iTunes", "MusicBrainz Album Comment", MusicBrainzAlbumComment); + appletag.SetDashBox("com.apple.iTunes", "MusicBrainz Release Track Id", MusicBrainzReleaseTrackId); + } + + file.Save(); + } + catch (CorruptFileException ex) + { + Logger.Warn(ex, $"Tag writing failed for {path}. File is corrupt"); + } + catch (Exception ex) + { + Logger.Warn() + .Exception(ex) + .Message($"Tag writing failed for {path}") + .WriteSentryWarn("Tag writing failed") + .Write(); + } + finally + { + file?.Dispose(); + } + } + + public Dictionary> Diff(AudioTag other) + { + var output = new Dictionary>(); + + if (!IsValid || !other.IsValid) + { + return output; + } + + if (Title != other.Title) + { + output.Add("Title", Tuple.Create(Title, other.Title)); + } + + if (!Performers.SequenceEqual(other.Performers)) + { + var oldValue = Performers.Any() ? string.Join(" / ", Performers) : null; + var newValue = other.Performers.Any() ? string.Join(" / ", other.Performers) : null; + + output.Add("Artist", Tuple.Create(oldValue, newValue)); + } + + if (Album != other.Album) + { + output.Add("Album", Tuple.Create(Album, other.Album)); + } + + if (!AlbumArtists.SequenceEqual(other.AlbumArtists)) + { + var oldValue = AlbumArtists.Any() ? string.Join(" / ", AlbumArtists) : null; + var newValue = other.AlbumArtists.Any() ? string.Join(" / ", other.AlbumArtists) : null; + + output.Add("Album Artist", Tuple.Create(oldValue, newValue)); + } + + if (Track != other.Track) + { + output.Add("Track", Tuple.Create(Track.ToString(), other.Track.ToString())); + } + + if (TrackCount != other.TrackCount) + { + output.Add("Track Count", Tuple.Create(TrackCount.ToString(), other.TrackCount.ToString())); + } + + if (Disc != other.Disc) + { + output.Add("Disc", Tuple.Create(Disc.ToString(), other.Disc.ToString())); + } + + if (DiscCount != other.DiscCount) + { + output.Add("Disc Count", Tuple.Create(DiscCount.ToString(), other.DiscCount.ToString())); + } + + if (Media != other.Media) + { + output.Add("Media Format", Tuple.Create(Media, other.Media)); + } + + if (Date != other.Date) + { + var oldValue = Date.HasValue ? Date.Value.ToString("yyyy-MM-dd") : null; + var newValue = other.Date.HasValue ? other.Date.Value.ToString("yyyy-MM-dd") : null; + output.Add("Date", Tuple.Create(oldValue, newValue)); + } + + if (OriginalReleaseDate != other.OriginalReleaseDate) + { + // Id3v2.3 tags can only store the year, not the full date + if (OriginalReleaseDate.HasValue && + OriginalReleaseDate.Value.Month == 1 && + OriginalReleaseDate.Value.Day == 1) + { + if (OriginalReleaseDate.Value.Year != other.OriginalReleaseDate.Value.Year) + { + output.Add("Original Year", Tuple.Create(OriginalReleaseDate.Value.Year.ToString(), other.OriginalReleaseDate.Value.Year.ToString())); + } + } + else + { + var oldValue = OriginalReleaseDate.HasValue ? OriginalReleaseDate.Value.ToString("yyyy-MM-dd") : null; + var newValue = other.OriginalReleaseDate.HasValue ? other.OriginalReleaseDate.Value.ToString("yyyy-MM-dd") : null; + output.Add("Original Release Date", Tuple.Create(oldValue, newValue)); + } + } + + if (Publisher != other.Publisher) + { + output.Add("Label", Tuple.Create(Publisher, other.Publisher)); + } + + if (!Genres.SequenceEqual(other.Genres)) + { + output.Add("Genres", Tuple.Create(string.Join(" / ", Genres), string.Join(" / ", other.Genres))); + } + + if (ImageSize != other.ImageSize) + { + output.Add("Image Size", Tuple.Create(ImageSize.ToString(), other.ImageSize.ToString())); + } + + return output; + } + + public static implicit operator ParsedTrackInfo (AudioTag tag) + { + if (!tag.IsValid) + { + return new ParsedTrackInfo { + Quality = tag.Quality ?? new QualityModel { Quality = NzbDrone.Core.Qualities.Quality.Unknown }, + MediaInfo = tag.MediaInfo ?? new MediaInfoModel() + }; + } + + var artist = tag.AlbumArtists?.FirstOrDefault(); + + if (artist.IsNullOrWhiteSpace()) + { + artist = tag.Performers?.FirstOrDefault(); + } + + var artistTitleInfo = new ArtistTitleInfo + { + Title = artist, + Year = (int)tag.Year + }; + + return new ParsedTrackInfo { + AlbumTitle = tag.Album, + ArtistTitle = artist, + ArtistMBId = tag.MusicBrainzReleaseArtistId, + AlbumMBId = tag.MusicBrainzReleaseGroupId, + ReleaseMBId = tag.MusicBrainzReleaseId, + // SIC: the recording ID is stored in this field. + // See https://picard.musicbrainz.org/docs/mappings/ + RecordingMBId = tag.MusicBrainzTrackId, + TrackMBId = tag.MusicBrainzReleaseTrackId, + DiscNumber = (int)tag.Disc, + DiscCount = (int)tag.DiscCount, + Year = tag.Year, + Label = tag.Publisher, + TrackNumbers = new [] { (int) tag.Track }, + ArtistTitleInfo = artistTitleInfo, + Title = tag.Title, + CleanTitle = tag.Title?.CleanTrackTitle(), + Country = IsoCountries.Find(tag.MusicBrainzReleaseCountry), + Duration = tag.Duration, + Disambiguation = tag.MusicBrainzAlbumComment, + Quality = tag.Quality, + MediaInfo = tag.MediaInfo + }; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/AudioTagService.cs b/src/NzbDrone.Core/MediaFiles/AudioTagService.cs new file mode 100644 index 000000000..90ade9763 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/AudioTagService.cs @@ -0,0 +1,393 @@ +using NLog; +using NzbDrone.Core.Parser.Model; +using System.IO; +using System.Linq; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.MediaFiles.Commands; +using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Core.Music; +using System.Collections.Generic; +using NzbDrone.Core.Parser; +using NzbDrone.Common.Disk; +using System; +using NLog.Fluent; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging.Events; +using TagLib; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.MediaCover; + +namespace NzbDrone.Core.MediaFiles +{ + public interface IAudioTagService + { + ParsedTrackInfo ReadTags(string file); + void WriteTags(TrackFile trackfile, bool newDownload, bool force = false); + void SyncTags(List tracks); + void RemoveMusicBrainzTags(IEnumerable album); + void RemoveMusicBrainzTags(IEnumerable albumRelease); + void RemoveMusicBrainzTags(IEnumerable tracks); + void RemoveMusicBrainzTags(TrackFile trackfile); + List GetRetagPreviewsByArtist(int artistId); + List GetRetagPreviewsByAlbum(int artistId); + } + + public class AudioTagService : IAudioTagService, + IExecute, + IExecute + { + private readonly IConfigService _configService; + private readonly IMediaFileService _mediaFileService; + private readonly IDiskProvider _diskProvider; + private readonly IArtistService _artistService; + private readonly IMapCoversToLocal _mediaCoverService; + private readonly IEventAggregator _eventAggregator; + private readonly Logger _logger; + + public AudioTagService(IConfigService configService, + IMediaFileService mediaFileService, + IDiskProvider diskProvider, + IArtistService artistService, + IMapCoversToLocal mediaCoverService, + IEventAggregator eventAggregator, + Logger logger) + { + _configService = configService; + _mediaFileService = mediaFileService; + _diskProvider = diskProvider; + _artistService = artistService; + _mediaCoverService = mediaCoverService; + _eventAggregator = eventAggregator; + _logger = logger; + } + + public AudioTag ReadAudioTag(string path) + { + return new AudioTag(path); + } + + public ParsedTrackInfo ReadTags(string path) + { + return new AudioTag(path); + } + + public AudioTag GetTrackMetadata(TrackFile trackfile) + { + var track = trackfile.Tracks.Value[0]; + var release = track.AlbumRelease.Value; + var album = release.Album.Value; + var albumartist = album.Artist.Value; + var artist = track.ArtistMetadata.Value; + + var cover = album.Images.FirstOrDefault(x => x.CoverType == MediaCoverTypes.Cover); + string imageFile = null; + long imageSize = 0; + if (cover != null) + { + imageFile = _mediaCoverService.GetCoverPath(album.Id, MediaCoverEntity.Album, cover.CoverType, cover.Extension, null); + _logger.Trace($"Embedding: {imageFile}"); + var fileInfo = _diskProvider.GetFileInfo(imageFile); + if (fileInfo.Exists) + { + imageSize = fileInfo.Length; + } + else + { + imageFile = null; + } + } + + return new AudioTag { + Title = track.Title, + Performers = new [] { artist.Name }, + AlbumArtists = new [] { albumartist.Name }, + Track = (uint)track.AbsoluteTrackNumber, + TrackCount = (uint)release.Tracks.Value.Count(x => x.MediumNumber == track.MediumNumber), + Album = album.Title, + Disc = (uint)track.MediumNumber, + DiscCount = (uint)release.Media.Count, + // We may have omitted media so index in the list isn't the same as medium number + Media = release.Media.SingleOrDefault(x => x.Number == track.MediumNumber).Format, + Date = release.ReleaseDate, + Year = (uint)album.ReleaseDate?.Year, + OriginalReleaseDate = album.ReleaseDate, + OriginalYear = (uint)album.ReleaseDate?.Year, + Publisher = release.Label.FirstOrDefault(), + Genres = album.Genres.Any() ? album.Genres.ToArray() : artist.Genres.ToArray(), + ImageFile = imageFile, + ImageSize = imageSize, + MusicBrainzReleaseCountry = IsoCountries.Find(release.Country.FirstOrDefault())?.TwoLetterCode, + MusicBrainzReleaseStatus = release.Status.ToLower(), + MusicBrainzReleaseType = album.AlbumType.ToLower(), + MusicBrainzReleaseId = release.ForeignReleaseId, + MusicBrainzArtistId = artist.ForeignArtistId, + MusicBrainzReleaseArtistId = albumartist.ForeignArtistId, + MusicBrainzReleaseGroupId = album.ForeignAlbumId, + MusicBrainzTrackId = track.ForeignRecordingId, + MusicBrainzReleaseTrackId = track.ForeignTrackId, + MusicBrainzAlbumComment = album.Disambiguation, + }; + } + + private void UpdateTrackfileSizeAndModified(TrackFile trackfile, string path) + { + // update the saved file size so that the importer doesn't get confused on the next scan + var fileInfo = _diskProvider.GetFileInfo(path); + trackfile.Size = fileInfo.Length; + trackfile.Modified = fileInfo.LastWriteTimeUtc; + + if (trackfile.Id > 0) + { + _mediaFileService.Update(trackfile); + } + } + + public void RemoveAllTags(string path) + { + TagLib.File file = null; + try + { + file = TagLib.File.Create(path); + file.RemoveTags(TagLib.TagTypes.AllTags); + file.Save(); + } + catch (CorruptFileException ex) + { + _logger.Warn(ex, $"Tag removal failed for {path}. File is corrupt"); + } + catch (Exception ex) + { + _logger.Warn() + .Exception(ex) + .Message($"Tag removal failed for {path}") + .WriteSentryWarn("Tag removal failed") + .Write(); + } + finally + { + file?.Dispose(); + } + } + + public void RemoveMusicBrainzTags(string path) + { + var tags = new AudioTag(path); + + tags.MusicBrainzReleaseCountry = null; + tags.MusicBrainzReleaseStatus = null; + tags.MusicBrainzReleaseType = null; + tags.MusicBrainzReleaseId = null; + tags.MusicBrainzArtistId = null; + tags.MusicBrainzReleaseArtistId = null; + tags.MusicBrainzReleaseGroupId = null; + tags.MusicBrainzTrackId = null; + tags.MusicBrainzAlbumComment = null; + tags.MusicBrainzReleaseTrackId = null; + + tags.Write(path); + } + + public void WriteTags(TrackFile trackfile, bool newDownload, bool force = false) + { + if (!force) + { + if (_configService.WriteAudioTags == WriteAudioTagsType.No || + (_configService.WriteAudioTags == WriteAudioTagsType.NewFiles && !newDownload)) + { + return; + } + } + + if (trackfile.Tracks.Value.Count > 1) + { + _logger.Debug($"File {trackfile} is linked to multiple tracks. Not writing tags."); + return; + } + + var newTags = GetTrackMetadata(trackfile); + var path = trackfile.Path; + + var diff = ReadAudioTag(path).Diff(newTags); + + if (_configService.ScrubAudioTags) + { + _logger.Debug($"Scrubbing tags for {trackfile}"); + RemoveAllTags(path); + } + + _logger.Debug($"Writing tags for {trackfile}"); + newTags.Write(path); + + UpdateTrackfileSizeAndModified(trackfile, path); + + _eventAggregator.PublishEvent(new TrackFileRetaggedEvent(trackfile.Artist.Value, trackfile, diff, _configService.ScrubAudioTags)); + } + + public void SyncTags(List tracks) + { + if (_configService.WriteAudioTags != WriteAudioTagsType.Sync) + { + return; + } + + // get the tracks to update + var trackFiles = _mediaFileService.Get(tracks.Where(x => x.TrackFileId > 0).Select(x => x.TrackFileId)); + + _logger.Debug($"Syncing audio tags for {trackFiles.Count} files"); + + foreach (var file in trackFiles) + { + // populate tracks (which should also have release/album/artist set) because + // not all of the updates will have been committed to the database yet + file.Tracks = tracks.Where(x => x.TrackFileId == file.Id).ToList(); + WriteTags(file, false); + } + } + + public void RemoveMusicBrainzTags(IEnumerable albums) + { + if (_configService.WriteAudioTags < WriteAudioTagsType.AllFiles) + { + return; + } + + foreach (var album in albums) + { + var files = _mediaFileService.GetFilesByAlbum(album.Id); + foreach (var file in files) + { + RemoveMusicBrainzTags(file); + } + } + } + + public void RemoveMusicBrainzTags(IEnumerable releases) + { + if (_configService.WriteAudioTags < WriteAudioTagsType.AllFiles) + { + return; + } + + foreach (var release in releases) + { + var files = _mediaFileService.GetFilesByRelease(release.Id); + foreach (var file in files) + { + RemoveMusicBrainzTags(file); + } + } + } + + public void RemoveMusicBrainzTags(IEnumerable tracks) + { + if (_configService.WriteAudioTags < WriteAudioTagsType.AllFiles) + { + return; + } + + var files = _mediaFileService.Get(tracks.Where(x => x.TrackFileId > 0).Select(x => x.TrackFileId)); + foreach (var file in files) + { + RemoveMusicBrainzTags(file); + } + } + + public void RemoveMusicBrainzTags(TrackFile trackfile) + { + if (_configService.WriteAudioTags < WriteAudioTagsType.AllFiles) + { + return; + } + + var path = trackfile.Path; + _logger.Debug($"Removing MusicBrainz tags for {path}"); + + RemoveMusicBrainzTags(path); + + UpdateTrackfileSizeAndModified(trackfile, path); + } + + public List GetRetagPreviewsByArtist(int artistId) + { + var files = _mediaFileService.GetFilesByArtist(artistId); + + return GetPreviews(files).ToList(); + } + + public List GetRetagPreviewsByAlbum(int albumId) + { + var files = _mediaFileService.GetFilesByAlbum(albumId); + + return GetPreviews(files).ToList(); + } + + private IEnumerable GetPreviews(List files) + { + foreach (var f in files.OrderBy(x => x.Album.Value.Title) + .ThenBy(x => x.Tracks.Value.First().MediumNumber) + .ThenBy(x => x.Tracks.Value.First().AbsoluteTrackNumber)) + { + var file = f; + + if (!f.Tracks.Value.Any()) + { + _logger.Warn($"File {f} is not linked to any tracks"); + continue; + } + + if (f.Tracks.Value.Count > 1) + { + _logger.Debug($"File {f} is linked to multiple tracks. Not writing tags."); + continue; + } + + var oldTags = ReadAudioTag(f.Path); + var newTags = GetTrackMetadata(f); + var diff = oldTags.Diff(newTags); + + if (diff.Any()) + { + yield return new RetagTrackFilePreview { + ArtistId = file.Artist.Value.Id, + AlbumId = file.Album.Value.Id, + TrackNumbers = file.Tracks.Value.Select(e => e.AbsoluteTrackNumber).ToList(), + TrackFileId = file.Id, + RelativePath = file.Artist.Value.Path.GetRelativePath(file.Path), + Changes = diff + }; + } + } + } + + public void Execute(RetagFilesCommand message) + { + var artist = _artistService.GetArtist(message.ArtistId); + var trackFiles = _mediaFileService.Get(message.Files); + + _logger.ProgressInfo("Re-tagging {0} files for {1}", trackFiles.Count, artist.Name); + foreach (var file in trackFiles) + { + WriteTags(file, false, force: true); + } + _logger.ProgressInfo("Selected track files re-tagged for {0}", artist.Name); + } + + public void Execute(RetagArtistCommand message) + { + _logger.Debug("Re-tagging all files for selected artists"); + var artistToRename = _artistService.GetArtists(message.ArtistIds); + + foreach (var artist in artistToRename) + { + var trackFiles = _mediaFileService.GetFilesByArtist(artist.Id); + _logger.ProgressInfo("Re-tagging all files in artist: {0}", artist.Name); + foreach (var file in trackFiles) + { + WriteTags(file, false, force: true); + } + _logger.ProgressInfo("All track files re-tagged for {0}", artist.Name); + } + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/Commands/DownloadedAlbumsScanCommand.cs b/src/NzbDrone.Core/MediaFiles/Commands/DownloadedAlbumsScanCommand.cs new file mode 100644 index 000000000..52742bd81 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Commands/DownloadedAlbumsScanCommand.cs @@ -0,0 +1,13 @@ +using NzbDrone.Core.MediaFiles.TrackImport; +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.MediaFiles.Commands +{ + public class DownloadedAlbumsScanCommand : Command + { + // Properties used by third-party apps, do not modify. + public string Path { get; set; } + public string DownloadClientId { get; set; } + public ImportMode ImportMode { get; set; } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/Commands/DownloadedEpisodesScanCommand.cs b/src/NzbDrone.Core/MediaFiles/Commands/DownloadedEpisodesScanCommand.cs deleted file mode 100644 index ab2f80480..000000000 --- a/src/NzbDrone.Core/MediaFiles/Commands/DownloadedEpisodesScanCommand.cs +++ /dev/null @@ -1,17 +0,0 @@ -using NzbDrone.Core.MediaFiles.EpisodeImport; -using NzbDrone.Core.Messaging.Commands; - -namespace NzbDrone.Core.MediaFiles.Commands -{ - public class DownloadedEpisodesScanCommand : Command - { - public override bool SendUpdatesToClient => SendUpdates; - - public bool SendUpdates { get; set; } - - // Properties used by third-party apps, do not modify. - public string Path { get; set; } - public string DownloadClientId { get; set; } - public ImportMode ImportMode { get; set; } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/Commands/RenameArtistCommand.cs b/src/NzbDrone.Core/MediaFiles/Commands/RenameArtistCommand.cs new file mode 100644 index 000000000..1e027f570 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Commands/RenameArtistCommand.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.MediaFiles.Commands +{ + public class RenameArtistCommand : Command + { + public List ArtistIds { get; set; } + + public override bool SendUpdatesToClient => true; + public override bool RequiresDiskAccess => true; + + public RenameArtistCommand() + { + ArtistIds = new List(); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/Commands/RenameFilesCommand.cs b/src/NzbDrone.Core/MediaFiles/Commands/RenameFilesCommand.cs index e0dc34e10..e7464a2ad 100644 --- a/src/NzbDrone.Core/MediaFiles/Commands/RenameFilesCommand.cs +++ b/src/NzbDrone.Core/MediaFiles/Commands/RenameFilesCommand.cs @@ -5,19 +5,20 @@ namespace NzbDrone.Core.MediaFiles.Commands { public class RenameFilesCommand : Command { - public int SeriesId { get; set; } + public int ArtistId { get; set; } public List Files { get; set; } public override bool SendUpdatesToClient => true; + public override bool RequiresDiskAccess => true; public RenameFilesCommand() { } - public RenameFilesCommand(int seriesId, List files) + public RenameFilesCommand(int artistId, List files) { - SeriesId = seriesId; + ArtistId = artistId; Files = files; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/MediaFiles/Commands/RenameSeriesCommand.cs b/src/NzbDrone.Core/MediaFiles/Commands/RenameSeriesCommand.cs deleted file mode 100644 index a2bcda88c..000000000 --- a/src/NzbDrone.Core/MediaFiles/Commands/RenameSeriesCommand.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Core.Messaging.Commands; - -namespace NzbDrone.Core.MediaFiles.Commands -{ - public class RenameSeriesCommand : Command - { - public List SeriesIds { get; set; } - - public override bool SendUpdatesToClient => true; - - public RenameSeriesCommand() - { - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/Commands/RescanArtistCommand.cs b/src/NzbDrone.Core/MediaFiles/Commands/RescanArtistCommand.cs new file mode 100644 index 000000000..b3d1dc05a --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Commands/RescanArtistCommand.cs @@ -0,0 +1,23 @@ +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.MediaFiles.Commands +{ + public class RescanArtistCommand : Command + { + public int? ArtistId { get; set; } + public FilterFilesType Filter { get; set; } + + public override bool SendUpdatesToClient => true; + + public RescanArtistCommand(FilterFilesType filter = FilterFilesType.Known) + { + Filter = filter; + } + + public RescanArtistCommand(int artistId, FilterFilesType filter = FilterFilesType.Known) + { + ArtistId = artistId; + Filter = filter; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/Commands/RescanSeriesCommand.cs b/src/NzbDrone.Core/MediaFiles/Commands/RescanSeriesCommand.cs deleted file mode 100644 index 6330574ab..000000000 --- a/src/NzbDrone.Core/MediaFiles/Commands/RescanSeriesCommand.cs +++ /dev/null @@ -1,20 +0,0 @@ -using NzbDrone.Core.Messaging.Commands; - -namespace NzbDrone.Core.MediaFiles.Commands -{ - public class RescanSeriesCommand : Command - { - public int? SeriesId { get; set; } - - public override bool SendUpdatesToClient => true; - - public RescanSeriesCommand() - { - } - - public RescanSeriesCommand(int seriesId) - { - SeriesId = seriesId; - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/Commands/RetagArtistCommand.cs b/src/NzbDrone.Core/MediaFiles/Commands/RetagArtistCommand.cs new file mode 100644 index 000000000..6ae6514f1 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Commands/RetagArtistCommand.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.MediaFiles.Commands +{ + public class RetagArtistCommand : Command + { + public List ArtistIds { get; set; } + + public override bool SendUpdatesToClient => true; + public override bool RequiresDiskAccess => true; + + public RetagArtistCommand() + { + ArtistIds = new List(); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/Commands/RetagFilesCommand.cs b/src/NzbDrone.Core/MediaFiles/Commands/RetagFilesCommand.cs new file mode 100644 index 000000000..dcee0d979 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Commands/RetagFilesCommand.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.MediaFiles.Commands +{ + public class RetagFilesCommand : Command + { + public int ArtistId { get; set; } + public List Files { get; set; } + + public override bool SendUpdatesToClient => true; + public override bool RequiresDiskAccess => true; + + public RetagFilesCommand() + { + } + + public RetagFilesCommand(int artistId, List files) + { + ArtistId = artistId; + Files = files; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/DeleteMediaFileReason.cs b/src/NzbDrone.Core/MediaFiles/DeleteMediaFileReason.cs index 918eedc31..7c9f6eee9 100644 --- a/src/NzbDrone.Core/MediaFiles/DeleteMediaFileReason.cs +++ b/src/NzbDrone.Core/MediaFiles/DeleteMediaFileReason.cs @@ -1,10 +1,11 @@ -namespace NzbDrone.Core.MediaFiles +namespace NzbDrone.Core.MediaFiles { public enum DeleteMediaFileReason { MissingFromDisk, Manual, Upgrade, - NoLinkedEpisodes + NoLinkedEpisodes, + ManualOverride } } diff --git a/src/NzbDrone.Core/MediaFiles/DiskScanService.cs b/src/NzbDrone.Core/MediaFiles/DiskScanService.cs index bf7ac5f0c..c8ae43358 100644 --- a/src/NzbDrone.Core/MediaFiles/DiskScanService.cs +++ b/src/NzbDrone.Core/MediaFiles/DiskScanService.cs @@ -1,7 +1,8 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.IO.Abstractions; using System.Linq; using System.Text.RegularExpressions; using NLog; @@ -10,152 +11,229 @@ using NzbDrone.Common.Extensions; using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Core.Configuration; using NzbDrone.Core.MediaFiles.Commands; -using NzbDrone.Core.MediaFiles.EpisodeImport; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Tv; -using NzbDrone.Core.Tv.Events; +using NzbDrone.Core.RootFolders; +using NzbDrone.Core.Music; +using NzbDrone.Core.MediaFiles.TrackImport; +using NzbDrone.Common; namespace NzbDrone.Core.MediaFiles { public interface IDiskScanService { - void Scan(Series series); - string[] GetVideoFiles(string path, bool allDirectories = true); - string[] GetNonVideoFiles(string path, bool allDirectories = true); - List FilterFiles(Series series, IEnumerable files); + void Scan(Artist artist, FilterFilesType filter = FilterFilesType.Known); + IFileInfo[] GetAudioFiles(string path, bool allDirectories = true); + string[] GetNonAudioFiles(string path, bool allDirectories = true); + List FilterFiles(string basePath, IEnumerable files); + List FilterFiles(string basePath, IEnumerable files); } public class DiskScanService : IDiskScanService, - IHandle, - IExecute + IExecute { private readonly IDiskProvider _diskProvider; + private readonly IMediaFileService _mediaFileService; private readonly IMakeImportDecision _importDecisionMaker; - private readonly IImportApprovedEpisodes _importApprovedEpisodes; + private readonly IImportApprovedTracks _importApprovedTracks; private readonly IConfigService _configService; - private readonly ISeriesService _seriesService; + private readonly IArtistService _artistService; private readonly IMediaFileTableCleanupService _mediaFileTableCleanupService; + private readonly IRootFolderService _rootFolderService; private readonly IEventAggregator _eventAggregator; private readonly Logger _logger; public DiskScanService(IDiskProvider diskProvider, + IMediaFileService mediaFileService, IMakeImportDecision importDecisionMaker, - IImportApprovedEpisodes importApprovedEpisodes, + IImportApprovedTracks importApprovedTracks, IConfigService configService, - ISeriesService seriesService, + IArtistService artistService, + IRootFolderService rootFolderService, IMediaFileTableCleanupService mediaFileTableCleanupService, IEventAggregator eventAggregator, Logger logger) { _diskProvider = diskProvider; + _mediaFileService = mediaFileService; _importDecisionMaker = importDecisionMaker; - _importApprovedEpisodes = importApprovedEpisodes; + _importApprovedTracks = importApprovedTracks; _configService = configService; - _seriesService = seriesService; + _artistService = artistService; _mediaFileTableCleanupService = mediaFileTableCleanupService; + _rootFolderService = rootFolderService; _eventAggregator = eventAggregator; _logger = logger; } + 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$|^\.DS_store$|\.partial~$", RegexOptions.Compiled | RegexOptions.IgnoreCase); - 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); - - public void Scan(Series series) + public void Scan(Artist artist, FilterFilesType filter = FilterFilesType.Known) { - var rootFolder = _diskProvider.GetParentFolder(series.Path); + var rootFolder = _rootFolderService.GetBestRootFolderPath(artist.Path); if (!_diskProvider.FolderExists(rootFolder)) { - _logger.Warn("Series' root folder ({0}) doesn't exist.", rootFolder); - _eventAggregator.PublishEvent(new SeriesScanSkippedEvent(series, SeriesScanSkippedReason.RootFolderDoesNotExist)); + _logger.Warn("Artist' root folder ({0}) doesn't exist.", rootFolder); + _eventAggregator.PublishEvent(new ArtistScanSkippedEvent(artist, ArtistScanSkippedReason.RootFolderDoesNotExist)); return; } if (_diskProvider.GetDirectories(rootFolder).Empty()) { - _logger.Warn("Series' root folder ({0}) is empty.", rootFolder); - _eventAggregator.PublishEvent(new SeriesScanSkippedEvent(series, SeriesScanSkippedReason.RootFolderIsEmpty)); - return; + _logger.Warn("Artist' root folder ({0}) is empty.", rootFolder); + _eventAggregator.PublishEvent(new ArtistScanSkippedEvent(artist, ArtistScanSkippedReason.RootFolderIsEmpty)); + return; } - _logger.ProgressInfo("Scanning disk for {0}", series.Title); - - if (!_diskProvider.FolderExists(series.Path)) + _logger.ProgressInfo("Scanning {0}", artist.Name); + + if (!_diskProvider.FolderExists(artist.Path)) { - if (_configService.CreateEmptySeriesFolders && - _diskProvider.FolderExists(rootFolder)) + if (_configService.CreateEmptyArtistFolders) { - _logger.Debug("Creating missing series folder: {0}", series.Path); - _diskProvider.CreateFolder(series.Path); - SetPermissions(series.Path); + _logger.Debug("Creating missing artist folder: {0}", artist.Path); + _diskProvider.CreateFolder(artist.Path); + SetPermissions(artist.Path); } else { - _logger.Debug("Series folder doesn't exist: {0}", series.Path); + _logger.Debug("Artist folder doesn't exist: {0}", artist.Path); } - _eventAggregator.PublishEvent(new SeriesScanSkippedEvent(series, SeriesScanSkippedReason.SeriesFolderDoesNotExist)); + CleanMediaFiles(artist, new List()); + CompletedScanning(artist); + return; } - var videoFilesStopwatch = Stopwatch.StartNew(); - var mediaFileList = FilterFiles(series, GetVideoFiles(series.Path)).ToList(); - - videoFilesStopwatch.Stop(); - _logger.Trace("Finished getting episode files for: {0} [{1}]", series, videoFilesStopwatch.Elapsed); - - _logger.Debug("{0} Cleaning up media files in DB", series); - _mediaFileTableCleanupService.Clean(series, mediaFileList); + var musicFilesStopwatch = Stopwatch.StartNew(); + var mediaFileList = FilterFiles(artist.Path, GetAudioFiles(artist.Path)).ToList(); + musicFilesStopwatch.Stop(); + _logger.Trace("Finished getting track files for: {0} [{1}]", artist, musicFilesStopwatch.Elapsed); + CleanMediaFiles(artist, mediaFileList.Select(x => x.FullName).ToList()); + var decisionsStopwatch = Stopwatch.StartNew(); - var decisions = _importDecisionMaker.GetImportDecisions(mediaFileList, series); + var decisions = _importDecisionMaker.GetImportDecisions(mediaFileList, artist, filter, true); decisionsStopwatch.Stop(); - _logger.Trace("Import decisions complete for: {0} [{1}]", series, decisionsStopwatch.Elapsed); + _logger.Debug("Import decisions complete for: {0} [{1}]", artist, decisionsStopwatch.Elapsed); + + var importStopwatch = Stopwatch.StartNew(); + _importApprovedTracks.Import(decisions, false); - _importApprovedEpisodes.Import(decisions, false); + // decisions may have been filtered to just new files. Anything new and approved will have been inserted. + // Now we need to make sure anything new but not approved gets inserted + // Note that knownFiles will include anything imported just now + var knownFiles = _mediaFileService.GetFilesWithBasePath(artist.Path); + + var newFiles = decisions + .ExceptBy(x => x.Item.Path, knownFiles, x => x.Path, PathEqualityComparer.Instance) + .Select(decision => new TrackFile { + Path = decision.Item.Path, + Size = decision.Item.Size, + Modified = decision.Item.Modified, + DateAdded = DateTime.UtcNow, + Quality = decision.Item.Quality, + MediaInfo = decision.Item.FileTrackInfo.MediaInfo + }) + .ToList(); + _mediaFileService.AddMany(newFiles); + + _logger.Debug($"Inserted {newFiles.Count} new unmatched trackfiles"); + + // finally update info on size/modified for existing files + var updatedFiles = knownFiles + .Join(decisions, + x => x.Path, + x => x.Item.Path, + (file, decision) => new { + File = file, + Item = decision.Item + }, + PathEqualityComparer.Instance) + .Where(x => x.File.Size != x.Item.Size || + Math.Abs((x.File.Modified - x.Item.Modified).TotalSeconds) > 1 ) + .Select(x => { + x.File.Size = x.Item.Size; + x.File.Modified = x.Item.Modified; + x.File.MediaInfo = x.Item.FileTrackInfo.MediaInfo; + x.File.Quality = x.Item.Quality; + return x.File; + }) + .ToList(); + + _mediaFileService.Update(updatedFiles); + + _logger.Debug($"Updated info for {updatedFiles.Count} known files"); - _logger.Info("Completed scanning disk for {0}", series.Title); - _eventAggregator.PublishEvent(new SeriesScannedEvent(series)); + RemoveEmptyArtistFolder(artist.Path); + + CompletedScanning(artist); + importStopwatch.Stop(); + _logger.Debug("Track import complete for: {0} [{1}]", artist, importStopwatch.Elapsed); } - public string[] GetVideoFiles(string path, bool allDirectories = true) + private void CleanMediaFiles(Artist artist, List mediaFileList) { - _logger.Debug("Scanning '{0}' for video files", path); + _logger.Debug("{0} Cleaning up media files in DB", artist); + _mediaFileTableCleanupService.Clean(artist, mediaFileList); + } + + private void CompletedScanning(Artist artist) + { + _logger.Info("Completed scanning disk for {0}", artist.Name); + _eventAggregator.PublishEvent(new ArtistScannedEvent(artist)); + } + + public IFileInfo[] GetAudioFiles(string path, bool allDirectories = true) + { + _logger.Debug("Scanning '{0}' for music files", path); var searchOption = allDirectories ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; - var filesOnDisk = _diskProvider.GetFiles(path, searchOption); + var filesOnDisk = _diskProvider.GetFileInfos(path, searchOption); - var mediaFileList = filesOnDisk.Where(file => MediaFileExtensions.Extensions.Contains(Path.GetExtension(file).ToLower())) + var mediaFileList = filesOnDisk.Where(file => MediaFileExtensions.Extensions.Contains(file.Extension)) .ToList(); - _logger.Debug("{0} video files were found in {1}", mediaFileList.Count, path); + _logger.Trace("{0} files were found in {1}", filesOnDisk.Count, path); + _logger.Debug("{0} audio files were found in {1}", mediaFileList.Count, path); + return mediaFileList.ToArray(); } - public string[] GetNonVideoFiles(string path, bool allDirectories = true) + public string[] GetNonAudioFiles(string path, bool allDirectories = true) { - _logger.Debug("Scanning '{0}' for non-video files", path); + _logger.Debug("Scanning '{0}' for non-music files", path); var searchOption = allDirectories ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; - var filesOnDisk = _diskProvider.GetFiles(path, searchOption); + 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.Debug("{0} non-video files were found in {1}", mediaFileList.Count, path); + _logger.Trace("{0} files were found in {1}", filesOnDisk.Count, path); + _logger.Debug("{0} non-music files were found in {1}", mediaFileList.Count, path); + 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(); } + public List FilterFiles(string basePath, IEnumerable files) + { + return files.Where(file => !ExcludedSubFoldersRegex.IsMatch(basePath.GetRelativePath(file.FullName))) + .Where(file => !ExcludedFilesRegex.IsMatch(file.Name)) + .ToList(); + } + private void SetPermissions(string path) { if (!_configService.SetPermissionsLinux) @@ -175,30 +253,40 @@ namespace NzbDrone.Core.MediaFiles _logger.Warn(ex, "Unable to apply permissions to: " + path); _logger.Debug(ex, ex.Message); } - } + } - public void Handle(SeriesUpdatedEvent message) + private void RemoveEmptyArtistFolder(string path) { - Scan(message.Series); + if (_configService.DeleteEmptyFolders) + { + if (_diskProvider.GetFiles(path, SearchOption.AllDirectories).Empty()) + { + _diskProvider.DeleteFolder(path, true); + } + else + { + _diskProvider.RemoveEmptySubfolders(path); + } + } } - public void Execute(RescanSeriesCommand message) + public void Execute(RescanArtistCommand message) { - if (message.SeriesId.HasValue) + if (message.ArtistId.HasValue) { - var series = _seriesService.GetSeries(message.SeriesId.Value); - Scan(series); + var artist = _artistService.GetArtist(message.ArtistId.Value); + Scan(artist, message.Filter); } else { - var allSeries = _seriesService.GetAllSeries(); + var allArtists = _artistService.GetAllArtists(); - foreach (var series in allSeries) + foreach (var artist in allArtists) { - Scan(series); + Scan(artist, message.Filter); } } } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/MediaFiles/DownloadedAlbumsCommandService.cs b/src/NzbDrone.Core/MediaFiles/DownloadedAlbumsCommandService.cs new file mode 100644 index 000000000..e117b6b1f --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/DownloadedAlbumsCommandService.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; +using System; +using System.Linq; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Download.TrackedDownloads; +using NzbDrone.Core.MediaFiles.Commands; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.MediaFiles.TrackImport; +using NzbDrone.Common.Instrumentation.Extensions; + +namespace NzbDrone.Core.MediaFiles +{ + public class DownloadedAlbumsCommandService : IExecute + { + private readonly IDownloadedTracksImportService _downloadedTracksImportService; + private readonly ITrackedDownloadService _trackedDownloadService; + private readonly IDiskProvider _diskProvider; + private readonly Logger _logger; + + public DownloadedAlbumsCommandService(IDownloadedTracksImportService downloadedTracksImportService, + ITrackedDownloadService trackedDownloadService, + IDiskProvider diskProvider, + Logger logger) + { + _downloadedTracksImportService = downloadedTracksImportService; + _trackedDownloadService = trackedDownloadService; + _diskProvider = diskProvider; + _logger = logger; + } + + private List ProcessPath(DownloadedAlbumsScanCommand message) + { + if (!_diskProvider.FolderExists(message.Path) && !_diskProvider.FileExists(message.Path)) + { + _logger.Warn("Folder/File specified for import scan [{0}] doesn't exist.", message.Path); + return new List(); + } + + if (message.DownloadClientId.IsNotNullOrWhiteSpace()) + { + var trackedDownload = _trackedDownloadService.Find(message.DownloadClientId); + + if (trackedDownload != null) + { + _logger.Debug("External directory scan request for known download {0}. [{1}]", message.DownloadClientId, message.Path); + + return _downloadedTracksImportService.ProcessPath(message.Path, message.ImportMode, trackedDownload.RemoteAlbum.Artist, trackedDownload.DownloadItem); + } + else + { + _logger.Warn("External directory scan request for unknown download {0}, attempting normal import. [{1}]", message.DownloadClientId, message.Path); + + return _downloadedTracksImportService.ProcessPath(message.Path, message.ImportMode); + } + } + + return _downloadedTracksImportService.ProcessPath(message.Path, message.ImportMode); + } + + public void Execute(DownloadedAlbumsScanCommand message) + { + List importResults; + + if (message.Path.IsNotNullOrWhiteSpace()) + { + importResults = ProcessPath(message); + } + else + { + throw new ArgumentException("A path must be provided", "path"); + } + + if (importResults == null || importResults.All(v => v.Result != ImportResultType.Imported)) + { + // Atm we don't report it as a command failure, coz that would cause the download to be failed. + // Changing the message won't do a thing either, coz it will get set to 'Completed' a msec later. + //message.SetMessage("Failed to import"); + } + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesCommandService.cs b/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesCommandService.cs deleted file mode 100644 index 2f5e19a41..000000000 --- a/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesCommandService.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Linq; -using NLog; -using NzbDrone.Common.Disk; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Configuration; -using NzbDrone.Core.Download.TrackedDownloads; -using NzbDrone.Core.MediaFiles.Commands; -using NzbDrone.Core.MediaFiles.EpisodeImport; -using NzbDrone.Core.Messaging.Commands; - -namespace NzbDrone.Core.MediaFiles -{ - public class DownloadedEpisodesCommandService : IExecute - { - private readonly IDownloadedEpisodesImportService _downloadedEpisodesImportService; - private readonly ITrackedDownloadService _trackedDownloadService; - private readonly IDiskProvider _diskProvider; - private readonly IConfigService _configService; - private readonly Logger _logger; - - public DownloadedEpisodesCommandService(IDownloadedEpisodesImportService downloadedEpisodesImportService, - ITrackedDownloadService trackedDownloadService, - IDiskProvider diskProvider, - IConfigService configService, - Logger logger) - { - _downloadedEpisodesImportService = downloadedEpisodesImportService; - _trackedDownloadService = trackedDownloadService; - _diskProvider = diskProvider; - _configService = configService; - _logger = logger; - } - - private List ProcessDroneFactoryFolder() - { - var downloadedEpisodesFolder = _configService.DownloadedEpisodesFolder; - - if (string.IsNullOrEmpty(downloadedEpisodesFolder)) - { - _logger.Trace("Drone Factory folder is not configured"); - return new List(); - } - - if (!_diskProvider.FolderExists(downloadedEpisodesFolder)) - { - _logger.Warn("Drone Factory folder [{0}] doesn't exist.", downloadedEpisodesFolder); - return new List(); - } - - return _downloadedEpisodesImportService.ProcessRootFolder(new DirectoryInfo(downloadedEpisodesFolder)); - } - - private List ProcessPath(DownloadedEpisodesScanCommand message) - { - if (!_diskProvider.FolderExists(message.Path) && !_diskProvider.FileExists(message.Path)) - { - _logger.Warn("Folder/File specified for import scan [{0}] doesn't exist.", message.Path); - return new List(); - } - - if (message.DownloadClientId.IsNotNullOrWhiteSpace()) - { - var trackedDownload = _trackedDownloadService.Find(message.DownloadClientId); - - if (trackedDownload != null) - { - _logger.Debug("External directory scan request for known download {0}. [{1}]", message.DownloadClientId, message.Path); - - return _downloadedEpisodesImportService.ProcessPath(message.Path, message.ImportMode, trackedDownload.RemoteEpisode.Series, trackedDownload.DownloadItem); - } - else - { - _logger.Warn("External directory scan request for unknown download {0}, attempting normal import. [{1}]", message.DownloadClientId, message.Path); - - return _downloadedEpisodesImportService.ProcessPath(message.Path, message.ImportMode); - } - } - - return _downloadedEpisodesImportService.ProcessPath(message.Path, message.ImportMode); - } - - public void Execute(DownloadedEpisodesScanCommand message) - { - List importResults; - - if (message.Path.IsNotNullOrWhiteSpace()) - { - importResults = ProcessPath(message); - } - else - { - importResults = ProcessDroneFactoryFolder(); - } - - if (importResults == null || importResults.All(v => v.Result != ImportResultType.Imported)) - { - // Atm we don't report it as a command failure, coz that would cause the download to be failed. - // Changing the message won't do a thing either, coz it will get set to 'Completed' a msec later. - //message.SetMessage("Failed to import"); - } - } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs b/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs deleted file mode 100644 index 69e154a72..000000000 --- a/src/NzbDrone.Core/MediaFiles/DownloadedEpisodesImportService.cs +++ /dev/null @@ -1,265 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Linq; -using NLog; -using NzbDrone.Common.Disk; -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.MediaFiles.EpisodeImport; -using NzbDrone.Core.Parser; -using NzbDrone.Core.Tv; -using NzbDrone.Core.Download; -using NzbDrone.Core.Parser.Model; - -namespace NzbDrone.Core.MediaFiles -{ - public interface IDownloadedEpisodesImportService - { - List ProcessRootFolder(DirectoryInfo directoryInfo); - List ProcessPath(string path, ImportMode importMode = ImportMode.Auto, Series series = null, DownloadClientItem downloadClientItem = null); - bool ShouldDeleteFolder(DirectoryInfo directoryInfo, Series series); - } - - public class DownloadedEpisodesImportService : IDownloadedEpisodesImportService - { - private readonly IDiskProvider _diskProvider; - private readonly IDiskScanService _diskScanService; - private readonly ISeriesService _seriesService; - private readonly IParsingService _parsingService; - private readonly IMakeImportDecision _importDecisionMaker; - private readonly IImportApprovedEpisodes _importApprovedEpisodes; - private readonly IDetectSample _detectSample; - private readonly Logger _logger; - - public DownloadedEpisodesImportService(IDiskProvider diskProvider, - IDiskScanService diskScanService, - ISeriesService seriesService, - IParsingService parsingService, - IMakeImportDecision importDecisionMaker, - IImportApprovedEpisodes importApprovedEpisodes, - IDetectSample detectSample, - Logger logger) - { - _diskProvider = diskProvider; - _diskScanService = diskScanService; - _seriesService = seriesService; - _parsingService = parsingService; - _importDecisionMaker = importDecisionMaker; - _importApprovedEpisodes = importApprovedEpisodes; - _detectSample = detectSample; - _logger = logger; - } - - public List ProcessRootFolder(DirectoryInfo directoryInfo) - { - var results = new List(); - - foreach (var subFolder in _diskProvider.GetDirectories(directoryInfo.FullName)) - { - var folderResults = ProcessFolder(new DirectoryInfo(subFolder), ImportMode.Auto, null); - results.AddRange(folderResults); - } - - foreach (var videoFile in _diskScanService.GetVideoFiles(directoryInfo.FullName, false)) - { - var fileResults = ProcessFile(new FileInfo(videoFile), ImportMode.Auto, null); - results.AddRange(fileResults); - } - - return results; - } - - public List ProcessPath(string path, ImportMode importMode = ImportMode.Auto, Series series = null, DownloadClientItem downloadClientItem = null) - { - if (_diskProvider.FolderExists(path)) - { - var directoryInfo = new DirectoryInfo(path); - - if (series == null) - { - return ProcessFolder(directoryInfo, importMode, downloadClientItem); - } - - return ProcessFolder(directoryInfo, importMode, series, downloadClientItem); - } - - if (_diskProvider.FileExists(path)) - { - var fileInfo = new FileInfo(path); - - if (series == null) - { - return ProcessFile(fileInfo, importMode, downloadClientItem); - } - - return ProcessFile(fileInfo, importMode, series, downloadClientItem); - } - - _logger.Error("Import failed, path does not exist or is not accessible by Sonarr: {0}", path); - return new List(); - } - - 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"); - - foreach (var videoFile in videoFiles) - { - var episodeParseResult = Parser.Parser.ParseTitle(Path.GetFileName(videoFile)); - - if (episodeParseResult == null) - { - _logger.Warn("Unable to parse file on import: [{0}]", videoFile); - return false; - } - - var size = _diskProvider.GetFileSize(videoFile); - var quality = QualityParser.ParseQuality(videoFile); - - if (!_detectSample.IsSample(series, quality, videoFile, size, episodeParseResult.IsPossibleSpecialEpisode)) - { - _logger.Warn("Non-sample file detected: [{0}]", videoFile); - return false; - } - } - - if (rarFiles.Any(f => _diskProvider.GetFileSize(f) > 10.Megabytes())) - { - _logger.Warn("RAR file detected, will require manual cleanup"); - return false; - } - - return true; - } - - private List ProcessFolder(DirectoryInfo directoryInfo, ImportMode importMode, DownloadClientItem downloadClientItem) - { - var cleanedUpName = GetCleanedUpFolderName(directoryInfo.Name); - var series = _parsingService.GetSeries(cleanedUpName); - - if (series == null) - { - _logger.Debug("Unknown Series {0}", cleanedUpName); - - return new List - { - UnknownSeriesResult("Unknown Series") - }; - } - - return ProcessFolder(directoryInfo, importMode, series, downloadClientItem); - } - - private List ProcessFolder(DirectoryInfo directoryInfo, ImportMode importMode, Series series, DownloadClientItem downloadClientItem) - { - if (_seriesService.SeriesPathExists(directoryInfo.FullName)) - { - _logger.Warn("Unable to process folder that is mapped to an existing show"); - 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); - - if (downloadClientItem == null) - { - foreach (var videoFile in videoFiles) - { - if (_diskProvider.IsFileLocked(videoFile)) - { - return new List - { - FileIsLockedResult(videoFile) - }; - } - } - } - - var decisions = _importDecisionMaker.GetImportDecisions(videoFiles.ToList(), series, folderInfo, true); - var importResults = _importApprovedEpisodes.Import(decisions, true, downloadClientItem, importMode); - - if ((downloadClientItem == null || !downloadClientItem.IsReadOnly) && - importResults.Any(i => i.Result == ImportResultType.Imported) && - ShouldDeleteFolder(directoryInfo, series)) - { - _logger.Debug("Deleting folder after importing valid files"); - _diskProvider.DeleteFolder(directoryInfo.FullName, true); - } - - return importResults; - } - - private List ProcessFile(FileInfo fileInfo, ImportMode importMode, DownloadClientItem downloadClientItem) - { - var series = _parsingService.GetSeries(Path.GetFileNameWithoutExtension(fileInfo.Name)); - - if (series == null) - { - _logger.Debug("Unknown Series for file: {0}", fileInfo.Name); - - return new List - { - UnknownSeriesResult(string.Format("Unknown Series for file: {0}", fileInfo.Name), fileInfo.FullName) - }; - } - - return ProcessFile(fileInfo, importMode, series, downloadClientItem); - } - - private List ProcessFile(FileInfo fileInfo, ImportMode importMode, Series series, DownloadClientItem downloadClientItem) - { - if (Path.GetFileNameWithoutExtension(fileInfo.Name).StartsWith("._")) - { - _logger.Debug("[{0}] starts with '._', skipping", fileInfo.FullName); - - return new List - { - new ImportResult(new ImportDecision(new LocalEpisode { Path = fileInfo.FullName }, new Rejection("Invalid video file, filename starts with '._'")), "Invalid video file, filename starts with '._'") - }; - } - - if (downloadClientItem == null) - { - if (_diskProvider.IsFileLocked(fileInfo.FullName)) - { - return new List - { - FileIsLockedResult(fileInfo.FullName) - }; - } - } - - var decisions = _importDecisionMaker.GetImportDecisions(new List() { fileInfo.FullName }, series, null, true); - - return _importApprovedEpisodes.Import(decisions, true, downloadClientItem, importMode); - } - - private string GetCleanedUpFolderName(string folder) - { - folder = folder.Replace("_UNPACK_", "") - .Replace("_FAILED_", ""); - - return folder; - } - - private ImportResult FileIsLockedResult(string videoFile) - { - _logger.Debug("[{0}] is currently locked by another process, skipping", videoFile); - return new ImportResult(new ImportDecision(new LocalEpisode { Path = videoFile }, new Rejection("Locked file, try again later")), "Locked file, try again later"); - } - - private ImportResult UnknownSeriesResult(string message, string videoFile = null) - { - var localEpisode = videoFile == null ? null : new LocalEpisode { Path = videoFile }; - - return new ImportResult(new ImportDecision(localEpisode, new Rejection("Unknown Series")), message); - } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/DownloadedTracksImportService.cs b/src/NzbDrone.Core/MediaFiles/DownloadedTracksImportService.cs new file mode 100644 index 000000000..17b922164 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/DownloadedTracksImportService.cs @@ -0,0 +1,284 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; +using System.Linq; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.MediaFiles.TrackImport; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Music; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles +{ + public interface IDownloadedTracksImportService + { + List ProcessRootFolder(IDirectoryInfo directoryInfo); + List ProcessPath(string path, ImportMode importMode = ImportMode.Auto, Artist artist = null, DownloadClientItem downloadClientItem = null); + bool ShouldDeleteFolder(IDirectoryInfo directoryInfo, Artist artist); + } + + public class DownloadedTracksImportService : IDownloadedTracksImportService + { + private readonly IDiskProvider _diskProvider; + private readonly IDiskScanService _diskScanService; + private readonly IArtistService _artistService; + private readonly IParsingService _parsingService; + private readonly IMakeImportDecision _importDecisionMaker; + private readonly IImportApprovedTracks _importApprovedTracks; + private readonly IEventAggregator _eventAggregator; + private readonly Logger _logger; + + public DownloadedTracksImportService(IDiskProvider diskProvider, + IDiskScanService diskScanService, + IArtistService artistService, + IParsingService parsingService, + IMakeImportDecision importDecisionMaker, + IImportApprovedTracks importApprovedTracks, + IEventAggregator eventAggregator, + Logger logger) + { + _diskProvider = diskProvider; + _diskScanService = diskScanService; + _artistService = artistService; + _parsingService = parsingService; + _importDecisionMaker = importDecisionMaker; + _importApprovedTracks = importApprovedTracks; + _eventAggregator = eventAggregator; + _logger = logger; + } + + public List ProcessRootFolder(IDirectoryInfo directoryInfo) + { + var results = new List(); + + foreach (var subFolder in _diskProvider.GetDirectoryInfos(directoryInfo.FullName)) + { + var folderResults = ProcessFolder(subFolder, ImportMode.Auto, null); + results.AddRange(folderResults); + } + + foreach (var audioFile in _diskScanService.GetAudioFiles(directoryInfo.FullName, false)) + { + var fileResults = ProcessFile(audioFile, ImportMode.Auto, null); + results.AddRange(fileResults); + } + + return results; + } + + public List ProcessPath(string path, ImportMode importMode = ImportMode.Auto, Artist artist = null, DownloadClientItem downloadClientItem = null) + { + if (_diskProvider.FolderExists(path)) + { + var directoryInfo = _diskProvider.GetDirectoryInfo(path); + + if (artist == null) + { + return ProcessFolder(directoryInfo, importMode, downloadClientItem); + } + + return ProcessFolder(directoryInfo, importMode, artist, downloadClientItem); + } + + if (_diskProvider.FileExists(path)) + { + var fileInfo = _diskProvider.GetFileInfo(path); + + if (artist == null) + { + return ProcessFile(fileInfo, importMode, downloadClientItem); + } + + return ProcessFile(fileInfo, importMode, artist, downloadClientItem); + } + + _logger.Error("Import failed, path does not exist or is not accessible by Lidarr: {0}", path); + _eventAggregator.PublishEvent(new TrackImportFailedEvent(null, null, true, downloadClientItem)); + + return new List(); + } + + public bool ShouldDeleteFolder(IDirectoryInfo directoryInfo, Artist artist) + { + var audioFiles = _diskScanService.GetAudioFiles(directoryInfo.FullName); + var rarFiles = _diskProvider.GetFiles(directoryInfo.FullName, SearchOption.AllDirectories).Where(f => Path.GetExtension(f).Equals(".rar", StringComparison.OrdinalIgnoreCase)); + + foreach (var audioFile in audioFiles) + { + var albumParseResult = Parser.Parser.ParseMusicTitle(audioFile.Name); + + if (albumParseResult == null) + { + _logger.Warn("Unable to parse file on import: [{0}]", audioFile); + return false; + } + + _logger.Warn("Audio file detected: [{0}]", audioFile); + return false; + } + + if (rarFiles.Any(f => _diskProvider.GetFileSize(f) > 10.Megabytes())) + { + _logger.Warn("RAR file detected, will require manual cleanup"); + return false; + } + + return true; + } + + private List ProcessFolder(IDirectoryInfo directoryInfo, ImportMode importMode, DownloadClientItem downloadClientItem) + { + var cleanedUpName = GetCleanedUpFolderName(directoryInfo.Name); + var artist = _parsingService.GetArtist(cleanedUpName); + + if (artist == null) + { + _logger.Debug("Unknown Artist {0}", cleanedUpName); + + return new List + { + UnknownArtistResult("Unknown Artist") + }; + } + + return ProcessFolder(directoryInfo, importMode, artist, downloadClientItem); + } + + private List ProcessFolder(IDirectoryInfo directoryInfo, ImportMode importMode, Artist artist, DownloadClientItem downloadClientItem) + { + if (_artistService.ArtistPathExists(directoryInfo.FullName)) + { + _logger.Warn("Unable to process folder that is mapped to an existing artist"); + return new List(); + } + + var cleanedUpName = GetCleanedUpFolderName(directoryInfo.Name); + var folderInfo = Parser.Parser.ParseAlbumTitle(directoryInfo.Name); + var trackInfo = new ParsedTrackInfo { }; + + if (folderInfo != null) + { + _logger.Debug("{0} folder quality: {1}", cleanedUpName, folderInfo.Quality); + + trackInfo = new ParsedTrackInfo + { + AlbumTitle = folderInfo.AlbumTitle, + ArtistTitle = folderInfo.ArtistName, + Quality = folderInfo.Quality, + ReleaseGroup = folderInfo.ReleaseGroup, + ReleaseHash = folderInfo.ReleaseHash, + }; + } + else + { + trackInfo = null; + } + + var audioFiles = _diskScanService.FilterFiles(directoryInfo.FullName, _diskScanService.GetAudioFiles(directoryInfo.FullName)); + + if (downloadClientItem == null) + { + foreach (var audioFile in audioFiles) + { + if (_diskProvider.IsFileLocked(audioFile.FullName)) + { + return new List + { + FileIsLockedResult(audioFile.FullName) + }; + } + } + } + + var decisions = _importDecisionMaker.GetImportDecisions(audioFiles, artist, trackInfo); + var importResults = _importApprovedTracks.Import(decisions, true, downloadClientItem, importMode); + + 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, artist)) + { + _logger.Debug("Deleting folder after importing valid files"); + _diskProvider.DeleteFolder(directoryInfo.FullName, true); + } + + return importResults; + } + + private List ProcessFile(IFileInfo fileInfo, ImportMode importMode, DownloadClientItem downloadClientItem) + { + var artist = _parsingService.GetArtist(Path.GetFileNameWithoutExtension(fileInfo.Name)); + + if (artist == null) + { + _logger.Debug("Unknown Artist for file: {0}", fileInfo.Name); + + return new List + { + UnknownArtistResult(string.Format("Unknown Artist for file: {0}", fileInfo.Name), fileInfo.FullName) + }; + } + + return ProcessFile(fileInfo, importMode, artist, downloadClientItem); + } + + private List ProcessFile(IFileInfo fileInfo, ImportMode importMode, Artist artist, DownloadClientItem downloadClientItem) + { + if (Path.GetFileNameWithoutExtension(fileInfo.Name).StartsWith("._")) + { + _logger.Debug("[{0}] starts with '._', skipping", fileInfo.FullName); + + return new List + { + new ImportResult(new ImportDecision(new LocalTrack { Path = fileInfo.FullName }, new Rejection("Invalid music file, filename starts with '._'")), "Invalid music file, filename starts with '._'") + }; + } + + if (downloadClientItem == null) + { + if (_diskProvider.IsFileLocked(fileInfo.FullName)) + { + return new List + { + FileIsLockedResult(fileInfo.FullName) + }; + } + } + + var decisions = _importDecisionMaker.GetImportDecisions(new List() { fileInfo }, artist, null); + + return _importApprovedTracks.Import(decisions, true, downloadClientItem, importMode); + } + + private string GetCleanedUpFolderName(string folder) + { + folder = folder.Replace("_UNPACK_", "") + .Replace("_FAILED_", ""); + + return folder; + } + + private ImportResult FileIsLockedResult(string audioFile) + { + _logger.Debug("[{0}] is currently locked by another process, skipping", audioFile); + return new ImportResult(new ImportDecision(new LocalTrack { Path = audioFile }, new Rejection("Locked file, try again later")), "Locked file, try again later"); + } + + private ImportResult UnknownArtistResult(string message, string audioFile = null) + { + var localTrack = audioFile == null ? null : new LocalTrack { Path = audioFile }; + + return new ImportResult(new ImportDecision(localTrack, new Rejection("Unknown Artist")), message); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeFile.cs b/src/NzbDrone.Core/MediaFiles/EpisodeFile.cs deleted file mode 100644 index ecce449b4..000000000 --- a/src/NzbDrone.Core/MediaFiles/EpisodeFile.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using System.Collections.Generic; -using Marr.Data; -using NzbDrone.Core.Datastore; -using NzbDrone.Core.Qualities; -using NzbDrone.Core.Tv; -using NzbDrone.Core.MediaFiles.MediaInfo; - -namespace NzbDrone.Core.MediaFiles -{ - public class EpisodeFile : ModelBase - { - public int SeriesId { get; set; } - public int SeasonNumber { get; set; } - public string RelativePath { get; set; } - public string Path { get; set; } - public long Size { get; set; } - public DateTime DateAdded { get; set; } - public string SceneName { get; set; } - public string ReleaseGroup { get; set; } - public QualityModel Quality { get; set; } - public MediaInfoModel MediaInfo { get; set; } - public LazyLoaded> Episodes { get; set; } - public LazyLoaded Series { get; set; } - - public override string ToString() - { - return string.Format("[{0}] {1}", Id, RelativePath); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeFileMoveResult.cs b/src/NzbDrone.Core/MediaFiles/EpisodeFileMoveResult.cs deleted file mode 100644 index e88a10d29..000000000 --- a/src/NzbDrone.Core/MediaFiles/EpisodeFileMoveResult.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Collections.Generic; - -namespace NzbDrone.Core.MediaFiles -{ - public class EpisodeFileMoveResult - { - public EpisodeFileMoveResult() - { - OldFiles = new List(); - } - - public EpisodeFile EpisodeFile { get; set; } - public List OldFiles { get; set; } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeFileMovingService.cs b/src/NzbDrone.Core/MediaFiles/EpisodeFileMovingService.cs deleted file mode 100644 index f2a0b9be6..000000000 --- a/src/NzbDrone.Core/MediaFiles/EpisodeFileMovingService.cs +++ /dev/null @@ -1,215 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using NLog; -using NzbDrone.Common.Disk; -using NzbDrone.Common.EnsureThat; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Configuration; -using NzbDrone.Core.MediaFiles.Events; -using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Organizer; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.MediaFiles -{ - public interface IMoveEpisodeFiles - { - EpisodeFile MoveEpisodeFile(EpisodeFile episodeFile, Series series); - EpisodeFile MoveEpisodeFile(EpisodeFile episodeFile, LocalEpisode localEpisode); - EpisodeFile CopyEpisodeFile(EpisodeFile episodeFile, LocalEpisode localEpisode); - } - - public class EpisodeFileMovingService : IMoveEpisodeFiles - { - private readonly IEpisodeService _episodeService; - private readonly IUpdateEpisodeFileService _updateEpisodeFileService; - private readonly IBuildFileNames _buildFileNames; - private readonly IDiskTransferService _diskTransferService; - private readonly IDiskProvider _diskProvider; - private readonly IMediaFileAttributeService _mediaFileAttributeService; - private readonly IEventAggregator _eventAggregator; - private readonly IConfigService _configService; - private readonly Logger _logger; - - public EpisodeFileMovingService(IEpisodeService episodeService, - IUpdateEpisodeFileService updateEpisodeFileService, - IBuildFileNames buildFileNames, - IDiskTransferService diskTransferService, - IDiskProvider diskProvider, - IMediaFileAttributeService mediaFileAttributeService, - IEventAggregator eventAggregator, - IConfigService configService, - Logger logger) - { - _episodeService = episodeService; - _updateEpisodeFileService = updateEpisodeFileService; - _buildFileNames = buildFileNames; - _diskTransferService = diskTransferService; - _diskProvider = diskProvider; - _mediaFileAttributeService = mediaFileAttributeService; - _eventAggregator = eventAggregator; - _configService = configService; - _logger = logger; - } - - public EpisodeFile MoveEpisodeFile(EpisodeFile episodeFile, Series series) - { - var episodes = _episodeService.GetEpisodesByFileId(episodeFile.Id); - var newFileName = _buildFileNames.BuildFileName(episodes, series, episodeFile); - var filePath = _buildFileNames.BuildFilePath(series, episodes.First().SeasonNumber, newFileName, Path.GetExtension(episodeFile.RelativePath)); - - EnsureEpisodeFolder(episodeFile, series, episodes.Select(v => v.SeasonNumber).First(), filePath); - - _logger.Debug("Renaming episode file: {0} to {1}", episodeFile, filePath); - - return TransferFile(episodeFile, series, episodes, filePath, TransferMode.Move); - } - - public EpisodeFile MoveEpisodeFile(EpisodeFile episodeFile, LocalEpisode localEpisode) - { - var newFileName = _buildFileNames.BuildFileName(localEpisode.Episodes, localEpisode.Series, episodeFile); - var filePath = _buildFileNames.BuildFilePath(localEpisode.Series, localEpisode.SeasonNumber, newFileName, Path.GetExtension(localEpisode.Path)); - - EnsureEpisodeFolder(episodeFile, localEpisode, filePath); - - _logger.Debug("Moving episode file: {0} to {1}", episodeFile.Path, filePath); - - return TransferFile(episodeFile, localEpisode.Series, localEpisode.Episodes, filePath, TransferMode.Move); - } - - public EpisodeFile CopyEpisodeFile(EpisodeFile episodeFile, LocalEpisode localEpisode) - { - var newFileName = _buildFileNames.BuildFileName(localEpisode.Episodes, localEpisode.Series, episodeFile); - var filePath = _buildFileNames.BuildFilePath(localEpisode.Series, localEpisode.SeasonNumber, newFileName, Path.GetExtension(localEpisode.Path)); - - EnsureEpisodeFolder(episodeFile, localEpisode, filePath); - - if (_configService.CopyUsingHardlinks) - { - _logger.Debug("Hardlinking episode file: {0} to {1}", episodeFile.Path, filePath); - return TransferFile(episodeFile, localEpisode.Series, localEpisode.Episodes, filePath, TransferMode.HardLinkOrCopy); - } - - _logger.Debug("Copying episode file: {0} to {1}", episodeFile.Path, filePath); - return TransferFile(episodeFile, localEpisode.Series, localEpisode.Episodes, filePath, TransferMode.Copy); - } - - private EpisodeFile TransferFile(EpisodeFile episodeFile, Series series, List episodes, string destinationFilePath, TransferMode mode) - { - Ensure.That(episodeFile, () => episodeFile).IsNotNull(); - Ensure.That(series, () => series).IsNotNull(); - Ensure.That(destinationFilePath, () => destinationFilePath).IsValidPath(); - - var episodeFilePath = episodeFile.Path ?? Path.Combine(series.Path, episodeFile.RelativePath); - - if (!_diskProvider.FileExists(episodeFilePath)) - { - throw new FileNotFoundException("Episode file path does not exist", episodeFilePath); - } - - if (episodeFilePath == destinationFilePath) - { - throw new SameFilenameException("File not moved, source and destination are the same", episodeFilePath); - } - - _diskTransferService.TransferFile(episodeFilePath, destinationFilePath, mode); - - episodeFile.RelativePath = series.Path.GetRelativePath(destinationFilePath); - - _updateEpisodeFileService.ChangeFileDateForFile(episodeFile, series, episodes); - - try - { - _mediaFileAttributeService.SetFolderLastWriteTime(series.Path, episodeFile.DateAdded); - - if (series.SeasonFolder) - { - var seasonFolder = Path.GetDirectoryName(destinationFilePath); - - _mediaFileAttributeService.SetFolderLastWriteTime(seasonFolder, episodeFile.DateAdded); - } - } - - catch (Exception ex) - { - _logger.Warn(ex, "Unable to set last write time"); - } - - _mediaFileAttributeService.SetFilePermissions(destinationFilePath); - - return episodeFile; - } - - private void EnsureEpisodeFolder(EpisodeFile episodeFile, LocalEpisode localEpisode, string filePath) - { - EnsureEpisodeFolder(episodeFile, localEpisode.Series, localEpisode.SeasonNumber, filePath); - } - - private void EnsureEpisodeFolder(EpisodeFile episodeFile, Series series, int seasonNumber, string filePath) - { - var episodeFolder = Path.GetDirectoryName(filePath); - var seasonFolder = _buildFileNames.BuildSeasonPath(series, seasonNumber); - var seriesFolder = series.Path; - var rootFolder = new OsPath(seriesFolder).Directory.FullPath; - - if (!_diskProvider.FolderExists(rootFolder)) - { - throw new DirectoryNotFoundException(string.Format("Root folder '{0}' was not found.", rootFolder)); - } - - var changed = false; - var newEvent = new EpisodeFolderCreatedEvent(series, episodeFile); - - if (!_diskProvider.FolderExists(seriesFolder)) - { - CreateFolder(seriesFolder); - newEvent.SeriesFolder = seriesFolder; - changed = true; - } - - if (seriesFolder != seasonFolder && !_diskProvider.FolderExists(seasonFolder)) - { - CreateFolder(seasonFolder); - newEvent.SeasonFolder = seasonFolder; - changed = true; - } - - if (seasonFolder != episodeFolder && !_diskProvider.FolderExists(episodeFolder)) - { - CreateFolder(episodeFolder); - newEvent.EpisodeFolder = episodeFolder; - changed = true; - } - - if (changed) - { - _eventAggregator.PublishEvent(newEvent); - } - } - - private void CreateFolder(string directoryName) - { - Ensure.That(directoryName, () => directoryName).IsNotNullOrWhiteSpace(); - - var parentFolder = new OsPath(directoryName).Directory.FullPath; - if (!_diskProvider.FolderExists(parentFolder)) - { - CreateFolder(parentFolder); - } - - try - { - _diskProvider.CreateFolder(directoryName); - } - catch (IOException ex) - { - _logger.Error(ex, "Unable to create directory: {0}", directoryName); - } - - _mediaFileAttributeService.SetFolderPermissions(directoryName); - } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/DetectSample.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/DetectSample.cs deleted file mode 100644 index 27492d56a..000000000 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/DetectSample.cs +++ /dev/null @@ -1,119 +0,0 @@ -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); - } - - public class DetectSample : IDetectSample - { - 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) - { - if (isSpecial) - { - _logger.Debug("Special, skipping sample check"); - return false; - } - - var extension = Path.GetExtension(path); - - if (extension != null && extension.Equals(".flv", StringComparison.InvariantCultureIgnoreCase)) - { - _logger.Debug("Skipping sample check for .flv file"); - return false; - } - - if (extension != null && extension.Equals(".strm", StringComparison.InvariantCultureIgnoreCase)) - { - _logger.Debug("Skipping sample check for .strm file"); - return false; - } - - try - { - 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; - } - } - - catch (DllNotFoundException) - { - _logger.Debug("Falling back to file size detection"); - - return CheckSize(size, quality); - } - - _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; - } - - private int GetMinimumAllowedRuntime(Series series) - { - //Webisodes - 90 seconds - if (series.Runtime <= 10) - { - return 90; - } - - //30 minute episodes - 5 minutes - if (series.Runtime <= 30) - { - return 300; - } - - //60 minute episodes - 10 minutes - return 600; - } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/IImportDecisionEngineSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/IImportDecisionEngineSpecification.cs deleted file mode 100644 index 86abb87b7..000000000 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/IImportDecisionEngineSpecification.cs +++ /dev/null @@ -1,10 +0,0 @@ -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Parser.Model; - -namespace NzbDrone.Core.MediaFiles.EpisodeImport -{ - public interface IImportDecisionEngineSpecification - { - Decision IsSatisfiedBy(LocalEpisode localEpisode); - } -} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs deleted file mode 100644 index cdfd289db..000000000 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs +++ /dev/null @@ -1,175 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using NLog; -using NzbDrone.Common.Disk; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.MediaFiles.Events; -using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Parser; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Qualities; -using NzbDrone.Core.Download; -using NzbDrone.Core.Extras; - - -namespace NzbDrone.Core.MediaFiles.EpisodeImport -{ - public interface IImportApprovedEpisodes - { - List Import(List decisions, bool newDownload, DownloadClientItem downloadClientItem = null, ImportMode importMode = ImportMode.Auto); - } - - public class ImportApprovedEpisodes : IImportApprovedEpisodes - { - private readonly IUpgradeMediaFiles _episodeFileUpgrader; - private readonly IMediaFileService _mediaFileService; - private readonly IExtraService _extraService; - private readonly IDiskProvider _diskProvider; - private readonly IEventAggregator _eventAggregator; - private readonly Logger _logger; - - public ImportApprovedEpisodes(IUpgradeMediaFiles episodeFileUpgrader, - IMediaFileService mediaFileService, - IExtraService extraService, - IDiskProvider diskProvider, - IEventAggregator eventAggregator, - Logger logger) - { - _episodeFileUpgrader = episodeFileUpgrader; - _mediaFileService = mediaFileService; - _extraService = extraService; - _diskProvider = diskProvider; - _eventAggregator = eventAggregator; - _logger = logger; - } - - public List Import(List decisions, bool newDownload, DownloadClientItem downloadClientItem = null, ImportMode importMode = ImportMode.Auto) - { - var qualifiedImports = decisions.Where(c => c.Approved) - .GroupBy(c => c.LocalEpisode.Series.Id, (i, s) => s - .OrderByDescending(c => c.LocalEpisode.Quality, new QualityModelComparer(s.First().LocalEpisode.Series.Profile)) - .ThenByDescending(c => c.LocalEpisode.Size)) - .SelectMany(c => c) - .ToList(); - - var importResults = new List(); - - foreach (var importDecision in qualifiedImports.OrderBy(e => e.LocalEpisode.Episodes.Select(episode => episode.EpisodeNumber).MinOrDefault()) - .ThenByDescending(e => e.LocalEpisode.Size)) - { - var localEpisode = importDecision.LocalEpisode; - var oldFiles = new List(); - - try - { - //check if already imported - if (importResults.SelectMany(r => r.ImportDecision.LocalEpisode.Episodes) - .Select(e => e.Id) - .Intersect(localEpisode.Episodes.Select(e => e.Id)) - .Any()) - { - importResults.Add(new ImportResult(importDecision, "Episode has already been imported")); - continue; - } - - var episodeFile = new EpisodeFile(); - episodeFile.DateAdded = DateTime.UtcNow; - episodeFile.SeriesId = localEpisode.Series.Id; - episodeFile.Path = localEpisode.Path.CleanFilePath(); - episodeFile.Size = _diskProvider.GetFileSize(localEpisode.Path); - episodeFile.Quality = localEpisode.Quality; - episodeFile.MediaInfo = localEpisode.MediaInfo; - episodeFile.SeasonNumber = localEpisode.SeasonNumber; - episodeFile.Episodes = localEpisode.Episodes; - episodeFile.ReleaseGroup = localEpisode.ParsedEpisodeInfo.ReleaseGroup; - - bool copyOnly; - switch (importMode) - { - default: - case ImportMode.Auto: - copyOnly = downloadClientItem != null && downloadClientItem.IsReadOnly; - break; - case ImportMode.Move: - copyOnly = false; - break; - case ImportMode.Copy: - copyOnly = true; - break; - } - - if (newDownload) - { - episodeFile.SceneName = GetSceneName(downloadClientItem, localEpisode); - - var moveResult = _episodeFileUpgrader.UpgradeEpisodeFile(episodeFile, localEpisode, copyOnly); - oldFiles = moveResult.OldFiles; - } - else - { - episodeFile.RelativePath = localEpisode.Series.Path.GetRelativePath(episodeFile.Path); - } - - _mediaFileService.Add(episodeFile); - importResults.Add(new ImportResult(importDecision)); - - if (newDownload) - { - _extraService.ImportExtraFiles(localEpisode, episodeFile, copyOnly); - } - - if (downloadClientItem != null) - { - _eventAggregator.PublishEvent(new EpisodeImportedEvent(localEpisode, episodeFile, newDownload, downloadClientItem.DownloadClient, downloadClientItem.DownloadId, downloadClientItem.IsReadOnly)); - } - else - { - _eventAggregator.PublishEvent(new EpisodeImportedEvent(localEpisode, episodeFile, newDownload)); - } - - if (newDownload) - { - _eventAggregator.PublishEvent(new EpisodeDownloadedEvent(localEpisode, episodeFile, oldFiles)); - } - } - catch (Exception e) - { - _logger.Warn(e, "Couldn't import episode " + localEpisode); - importResults.Add(new ImportResult(importDecision, "Failed to import episode")); - } - } - - //Adding all the rejected decisions - importResults.AddRange(decisions.Where(c => !c.Approved) - .Select(d => new ImportResult(d, d.Rejections.Select(r => r.Reason).ToArray()))); - - return importResults; - } - - private string GetSceneName(DownloadClientItem downloadClientItem, LocalEpisode localEpisode) - { - if (downloadClientItem != null) - { - var title = Parser.Parser.RemoveFileExtension(downloadClientItem.Title); - - var parsedTitle = Parser.Parser.ParseTitle(title); - - if (parsedTitle != null && !parsedTitle.FullSeason) - { - return title; - } - } - - var fileName = Path.GetFileNameWithoutExtension(localEpisode.Path.CleanFilePath()); - - if (SceneChecker.IsSceneTitle(fileName)) - { - return fileName; - } - - return null; - } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecision.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecision.cs deleted file mode 100644 index 5e4e2ede2..000000000 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecision.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Parser.Model; - -namespace NzbDrone.Core.MediaFiles.EpisodeImport -{ - public class ImportDecision - { - public LocalEpisode LocalEpisode { get; private set; } - public IEnumerable Rejections { get; private set; } - - public bool Approved => Rejections.Empty(); - - public ImportDecision(LocalEpisode localEpisode, params Rejection[] rejections) - { - LocalEpisode = localEpisode; - Rejections = rejections.ToList(); - } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs deleted file mode 100644 index 764e1b88f..000000000 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportDecisionMaker.cs +++ /dev/null @@ -1,221 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using NLog; -using NzbDrone.Common.Disk; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Parser; -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); - } - - public class ImportDecisionMaker : IMakeImportDecision - { - private readonly IEnumerable _specifications; - private readonly IParsingService _parsingService; - private readonly IMediaFileService _mediaFileService; - private readonly IDiskProvider _diskProvider; - private readonly IVideoFileInfoReader _videoFileInfoReader; - private readonly IDetectSample _detectSample; - private readonly Logger _logger; - - public ImportDecisionMaker(IEnumerable specifications, - IParsingService parsingService, - IMediaFileService mediaFileService, - IDiskProvider diskProvider, - IVideoFileInfoReader videoFileInfoReader, - IDetectSample detectSample, - Logger logger) - { - _specifications = specifications; - _parsingService = parsingService; - _mediaFileService = mediaFileService; - _diskProvider = diskProvider; - _videoFileInfoReader = videoFileInfoReader; - _detectSample = detectSample; - _logger = logger; - } - - public List GetImportDecisions(List videoFiles, Series series) - { - return GetImportDecisions(videoFiles, series, null, false); - } - - public List GetImportDecisions(List videoFiles, Series series, 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); - var decisions = new List(); - - foreach (var file in newFiles) - { - decisions.AddIfNotNull(GetDecision(file, series, folderInfo, sceneSource, shouldUseFolderName)); - } - - return decisions; - } - - private ImportDecision GetDecision(string file, Series series, ParsedEpisodeInfo folderInfo, bool sceneSource, bool shouldUseFolderName) - { - ImportDecision decision = null; - - try - { - var localEpisode = _parsingService.GetLocalEpisode(file, series, shouldUseFolderName ? folderInfo : null, sceneSource); - - if (localEpisode != null) - { - 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) - { - localEpisode.MediaInfo = _videoFileInfoReader.GetMediaInfo(file); - } - - if (localEpisode.Episodes.Empty()) - { - decision = new ImportDecision(localEpisode, new Rejection("Invalid season or episode")); - } - else - { - decision = GetDecision(localEpisode); - } - } - - else - { - localEpisode = new LocalEpisode(); - localEpisode.Path = file; - - decision = new ImportDecision(localEpisode, new Rejection("Unable to parse file")); - } - } - catch (Exception e) - { - _logger.Error(e, "Couldn't import file. {0}", file); - - var localEpisode = new LocalEpisode { Path = file }; - decision = new ImportDecision(localEpisode, new Rejection("Unexpected error processing file")); - } - - return decision; - } - - private ImportDecision GetDecision(LocalEpisode localEpisode) - { - var reasons = _specifications.Select(c => EvaluateSpec(c, localEpisode)) - .Where(c => c != null); - - return new ImportDecision(localEpisode, reasons.ToArray()); - } - - private Rejection EvaluateSpec(IImportDecisionEngineSpecification spec, LocalEpisode localEpisode) - { - try - { - var result = spec.IsSatisfiedBy(localEpisode); - - if (!result.Accepted) - { - return new Rejection(result.Reason); - } - } - catch (Exception e) - { - //e.Data.Add("report", remoteEpisode.Report.ToJson()); - //e.Data.Add("parsed", remoteEpisode.ParsedEpisodeInfo.ToJson()); - _logger.Error(e, "Couldn't evaluate decision on {0}", localEpisode.Path); - return new Rejection($"{spec.GetType().Name}: {e.Message}"); - } - - return null; - } - - private bool ShouldUseFolderName(List videoFiles, Series series, ParsedEpisodeInfo folderInfo) - { - if (folderInfo == null) - { - return false; - } - - if (folderInfo.FullSeason) - { - return 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); - - if (sample) - { - return false; - } - - if (SceneChecker.IsSceneTitle(Path.GetFileName(file))) - { - return false; - } - - return true; - }) == 1; - } - - private QualityModel GetQuality(ParsedEpisodeInfo folderInfo, QualityModel fileQuality, Series series) - { - if (UseFolderQuality(folderInfo, fileQuality, series)) - { - _logger.Debug("Using quality from folder: {0}", folderInfo.Quality); - return folderInfo.Quality; - } - - 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) - { - return true; - } - - if (new QualityModelComparer(series.Profile).Compare(folderInfo.Quality, fileQuality) > 0) - { - return true; - } - - return false; - } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportMode.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportMode.cs deleted file mode 100644 index ffdf7eed8..000000000 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportMode.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace NzbDrone.Core.MediaFiles.EpisodeImport -{ - public enum ImportMode - { - Auto = 0, - Move = 1, - Copy = 2 - } -} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportResult.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportResult.cs deleted file mode 100644 index a0d989335..000000000 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportResult.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NzbDrone.Common.EnsureThat; - -namespace NzbDrone.Core.MediaFiles.EpisodeImport -{ - public class ImportResult - { - public ImportDecision ImportDecision { get; private set; } - public List Errors { get; private set; } - - public ImportResultType Result - { - get - { - if (Errors.Any()) - { - if (ImportDecision.Approved) - { - return ImportResultType.Skipped; - } - - return ImportResultType.Rejected; - } - - return ImportResultType.Imported; - } - } - - public ImportResult(ImportDecision importDecision, params string[] errors) - { - Ensure.That(importDecision, () => importDecision).IsNotNull(); - - ImportDecision = importDecision; - Errors = errors.ToList(); - } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportResultType.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportResultType.cs deleted file mode 100644 index 7c43332de..000000000 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportResultType.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace NzbDrone.Core.MediaFiles.EpisodeImport -{ - public enum ImportResultType - { - Imported, - Rejected, - Skipped - } -} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportCommand.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportCommand.cs deleted file mode 100644 index 38ed485b7..000000000 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportCommand.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Core.Messaging.Commands; - -namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual -{ - public class ManualImportCommand : Command - { - public List Files { get; set; } - - public override bool SendUpdatesToClient => true; - - public ImportMode ImportMode { get; set; } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportFile.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportFile.cs deleted file mode 100644 index 4c9fecc7c..000000000 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportFile.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Core.Qualities; - -namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual -{ - public class ManualImportFile - { - public string Path { get; set; } - public int SeriesId { get; set; } - public List EpisodeIds { get; set; } - public QualityModel Quality { get; set; } - public string DownloadId { get; set; } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportItem.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportItem.cs deleted file mode 100644 index bd3954816..000000000 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportItem.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Qualities; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual -{ - public class ManualImportItem - { - public string Path { get; set; } - public string RelativePath { get; set; } - public string Name { get; set; } - public long Size { get; set; } - public Series Series { get; set; } - public int? SeasonNumber { get; set; } - public List Episodes { get; set; } - public QualityModel Quality { get; set; } - public string DownloadId { get; set; } - public IEnumerable Rejections { get; set; } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs deleted file mode 100644 index d85a2e119..000000000 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManualImportService.cs +++ /dev/null @@ -1,268 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using NLog; -using NzbDrone.Common.Disk; -using NzbDrone.Common.Extensions; -using NzbDrone.Common.Instrumentation.Extensions; -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Download; -using NzbDrone.Core.Download.TrackedDownloads; -using NzbDrone.Core.MediaFiles.MediaInfo; -using NzbDrone.Core.Messaging.Commands; -using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Parser; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual -{ - public interface IManualImportService - { - List GetMediaFiles(string path, string downloadId); - } - - public class ManualImportService : IExecute, IManualImportService - { - private readonly IDiskProvider _diskProvider; - private readonly IParsingService _parsingService; - private readonly IDiskScanService _diskScanService; - private readonly IMakeImportDecision _importDecisionMaker; - private readonly ISeriesService _seriesService; - private readonly IEpisodeService _episodeService; - private readonly IVideoFileInfoReader _videoFileInfoReader; - private readonly IImportApprovedEpisodes _importApprovedEpisodes; - private readonly ITrackedDownloadService _trackedDownloadService; - private readonly IDownloadedEpisodesImportService _downloadedEpisodesImportService; - private readonly IEventAggregator _eventAggregator; - private readonly Logger _logger; - - public ManualImportService(IDiskProvider diskProvider, - IParsingService parsingService, - IDiskScanService diskScanService, - IMakeImportDecision importDecisionMaker, - ISeriesService seriesService, - IEpisodeService episodeService, - IVideoFileInfoReader videoFileInfoReader, - IImportApprovedEpisodes importApprovedEpisodes, - ITrackedDownloadService trackedDownloadService, - IDownloadedEpisodesImportService downloadedEpisodesImportService, - IEventAggregator eventAggregator, - Logger logger) - { - _diskProvider = diskProvider; - _parsingService = parsingService; - _diskScanService = diskScanService; - _importDecisionMaker = importDecisionMaker; - _seriesService = seriesService; - _episodeService = episodeService; - _videoFileInfoReader = videoFileInfoReader; - _importApprovedEpisodes = importApprovedEpisodes; - _trackedDownloadService = trackedDownloadService; - _downloadedEpisodesImportService = downloadedEpisodesImportService; - _eventAggregator = eventAggregator; - _logger = logger; - } - - public List GetMediaFiles(string path, string downloadId) - { - if (downloadId.IsNotNullOrWhiteSpace()) - { - var trackedDownload = _trackedDownloadService.Find(downloadId); - - if (trackedDownload == null) - { - return new List(); - } - - path = trackedDownload.DownloadItem.OutputPath.FullPath; - } - - if (!_diskProvider.FolderExists(path)) - { - if (!_diskProvider.FileExists(path)) - { - return new List(); - } - - return new List { ProcessFile(path, downloadId) }; - } - - return ProcessFolder(path, downloadId); - } - - private List ProcessFolder(string folder, string downloadId) - { - var directoryInfo = new DirectoryInfo(folder); - var series = _parsingService.GetSeries(directoryInfo.Name); - - if (series == null && downloadId.IsNotNullOrWhiteSpace()) - { - var trackedDownload = _trackedDownloadService.Find(downloadId); - series = trackedDownload.RemoteEpisode.Series; - } - - if (series == null) - { - var files = _diskScanService.GetVideoFiles(folder); - - return files.Select(file => ProcessFile(file, downloadId, folder)).Where(i => i != null).ToList(); - } - - var folderInfo = Parser.Parser.ParseTitle(directoryInfo.Name); - var seriesFiles = _diskScanService.GetVideoFiles(folder).ToList(); - var decisions = _importDecisionMaker.GetImportDecisions(seriesFiles, series, folderInfo, SceneSource(series, folder)); - - return decisions.Select(decision => MapItem(decision, folder, downloadId)).ToList(); - } - - private ManualImportItem ProcessFile(string file, string downloadId, string folder = null) - { - if (folder.IsNullOrWhiteSpace()) - { - folder = new FileInfo(file).Directory.FullName; - } - - var relativeFile = folder.GetRelativePath(file); - - var series = _parsingService.GetSeries(relativeFile.Split('\\', '/')[0]); - - if (series == null) - { - series = _parsingService.GetSeries(relativeFile); - } - - if (series == null && downloadId.IsNotNullOrWhiteSpace()) - { - var trackedDownload = _trackedDownloadService.Find(downloadId); - series = trackedDownload.RemoteEpisode.Series; - } - - if (series == null) - { - var localEpisode = new LocalEpisode(); - localEpisode.Path = file; - localEpisode.Quality = QualityParser.ParseQuality(file); - localEpisode.Size = _diskProvider.GetFileSize(file); - - return MapItem(new ImportDecision(localEpisode, new Rejection("Unknown Series")), folder, downloadId); - } - - var importDecisions = _importDecisionMaker.GetImportDecisions(new List {file}, - series, null, SceneSource(series, folder)); - - return importDecisions.Any() ? MapItem(importDecisions.First(), folder, downloadId) : null; - } - - private bool SceneSource(Series series, string folder) - { - return !(series.Path.PathEquals(folder) || series.Path.IsParentPath(folder)); - } - - private ManualImportItem MapItem(ImportDecision decision, string folder, string downloadId) - { - var item = new ManualImportItem(); - - item.Path = decision.LocalEpisode.Path; - item.RelativePath = folder.GetRelativePath(decision.LocalEpisode.Path); - item.Name = Path.GetFileNameWithoutExtension(decision.LocalEpisode.Path); - item.DownloadId = downloadId; - - if (decision.LocalEpisode.Series != null) - { - item.Series = decision.LocalEpisode.Series; - } - - if (decision.LocalEpisode.Episodes.Any()) - { - item.SeasonNumber = decision.LocalEpisode.SeasonNumber; - item.Episodes = decision.LocalEpisode.Episodes; - } - - item.Quality = decision.LocalEpisode.Quality; - item.Size = _diskProvider.GetFileSize(decision.LocalEpisode.Path); - item.Rejections = decision.Rejections; - - return item; - } - - public void Execute(ManualImportCommand message) - { - _logger.ProgressTrace("Manually importing {0} files using mode {1}", message.Files.Count, message.ImportMode); - - var imported = new List(); - var importedTrackedDownload = new List(); - - for (int i = 0; i < message.Files.Count; i++) - { - _logger.ProgressTrace("Processing file {0} of {1}", i + 1, message.Files.Count); - - 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 existingFile = series.Path.IsParentPath(file.Path); - - var localEpisode = new LocalEpisode - { - ExistingFile = false, - Episodes = episodes, - MediaInfo = mediaInfo, - ParsedEpisodeInfo = parsedEpisodeInfo, - Path = file.Path, - Quality = file.Quality, - Series = series, - Size = 0 - }; - - //TODO: Cleanup non-tracked downloads - - var importDecision = new ImportDecision(localEpisode); - - if (file.DownloadId.IsNullOrWhiteSpace()) - { - 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); - - importedTrackedDownload.Add(new ManuallyImportedFile - { - TrackedDownload = trackedDownload, - ImportResult = importResult - }); - } - } - - _logger.ProgressTrace("Manually imported {0} files", imported.Count); - - foreach (var groupedTrackedDownload in importedTrackedDownload.GroupBy(i => i.TrackedDownload.DownloadItem.DownloadId).ToList()) - { - var trackedDownload = groupedTrackedDownload.First().TrackedDownload; - - if (_diskProvider.FolderExists(trackedDownload.DownloadItem.OutputPath.FullPath)) - { - if (_downloadedEpisodesImportService.ShouldDeleteFolder( - new DirectoryInfo(trackedDownload.DownloadItem.OutputPath.FullPath), - trackedDownload.RemoteEpisode.Series) && !trackedDownload.DownloadItem.IsReadOnly) - { - _diskProvider.DeleteFolder(trackedDownload.DownloadItem.OutputPath.FullPath, true); - } - } - - if (groupedTrackedDownload.Select(c => c.ImportResult).Count(c => c.Result == ImportResultType.Imported) >= Math.Max(1, trackedDownload.RemoteEpisode.Episodes.Count)) - { - trackedDownload.State = TrackedDownloadStage.Imported; - _eventAggregator.PublishEvent(new DownloadCompletedEvent(trackedDownload)); - } - } - } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManuallyImportedFile.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManuallyImportedFile.cs deleted file mode 100644 index 32f904e4d..000000000 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Manual/ManuallyImportedFile.cs +++ /dev/null @@ -1,10 +0,0 @@ -using NzbDrone.Core.Download.TrackedDownloads; - -namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual -{ - public class ManuallyImportedFile - { - public TrackedDownload TrackedDownload { get; set; } - public ImportResult ImportResult { get; set; } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FullSeasonSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FullSeasonSpecification.cs deleted file mode 100644 index 7397c13e7..000000000 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FullSeasonSpecification.cs +++ /dev/null @@ -1,27 +0,0 @@ -using NLog; -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Parser.Model; - -namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications -{ - public class FullSeasonSpecification : IImportDecisionEngineSpecification - { - private readonly Logger _logger; - - public FullSeasonSpecification(Logger logger) - { - _logger = logger; - } - - public Decision IsSatisfiedBy(LocalEpisode localEpisode) - { - if (localEpisode.ParsedEpisodeInfo.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"); - } - - return Decision.Accept(); - } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/MatchesFolderSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/MatchesFolderSpecification.cs deleted file mode 100644 index 79ef96f88..000000000 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/MatchesFolderSpecification.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.IO; -using System.Linq; -using NLog; -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Parser.Model; - -namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications -{ - public class MatchesFolderSpecification : IImportDecisionEngineSpecification - { - private readonly Logger _logger; - - public MatchesFolderSpecification(Logger logger) - { - _logger = logger; - } - public Decision IsSatisfiedBy(LocalEpisode localEpisode) - { - if (localEpisode.ExistingFile) - { - return Decision.Accept(); - } - - var dirInfo = new FileInfo(localEpisode.Path).Directory; - - if (dirInfo == null) - { - return Decision.Accept(); - } - - var folderInfo = Parser.Parser.ParseTitle(dirInfo.Name); - - if (folderInfo == null) - { - return Decision.Accept(); - } - - if (!folderInfo.EpisodeNumbers.Any()) - { - return Decision.Accept(); - } - - if (folderInfo.FullSeason) - { - return Decision.Accept(); - } - - var unexpected = localEpisode.ParsedEpisodeInfo.EpisodeNumbers.Where(f => !folderInfo.EpisodeNumbers.Contains(f)).ToList(); - - if (unexpected.Any()) - { - _logger.Debug("Unexpected episode number(s) in file: {0}", string.Join(", ", unexpected)); - - 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 Numbers {0} were unexpected considering the {1} folder name", string.Join(", ", unexpected), dirInfo.Name); - } - - return Decision.Accept(); - } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotSampleSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotSampleSpecification.cs deleted file mode 100644 index c7b61d802..000000000 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotSampleSpecification.cs +++ /dev/null @@ -1,41 +0,0 @@ -using NLog; -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Parser.Model; - -namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications -{ - public class NotSampleSpecification : IImportDecisionEngineSpecification - { - private readonly IDetectSample _detectSample; - private readonly Logger _logger; - - public NotSampleSpecification(IDetectSample detectSample, - Logger logger) - { - _detectSample = detectSample; - _logger = logger; - } - - public Decision IsSatisfiedBy(LocalEpisode localEpisode) - { - if (localEpisode.ExistingFile) - { - _logger.Debug("Existing file, skipping sample check"); - return Decision.Accept(); - } - - var sample = _detectSample.IsSample(localEpisode.Series, - localEpisode.Quality, - localEpisode.Path, - localEpisode.Size, - localEpisode.IsSpecial); - - if (sample) - { - return Decision.Reject("Sample"); - } - - return Decision.Accept(); - } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/SameEpisodesImportSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/SameEpisodesImportSpecification.cs deleted file mode 100644 index ee6c02c53..000000000 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/SameEpisodesImportSpecification.cs +++ /dev/null @@ -1,31 +0,0 @@ -using NLog; -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Parser.Model; - -namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications -{ - public class SameEpisodesImportSpecification : IImportDecisionEngineSpecification - { - private readonly SameEpisodesSpecification _sameEpisodesSpecification; - private readonly Logger _logger; - - public SameEpisodesImportSpecification(SameEpisodesSpecification sameEpisodesSpecification, Logger logger) - { - _sameEpisodesSpecification = sameEpisodesSpecification; - _logger = logger; - } - - public RejectionType Type => RejectionType.Permanent; - - public Decision IsSatisfiedBy(LocalEpisode localEpisode) - { - if (_sameEpisodesSpecification.IsSatisfiedBy(localEpisode.Episodes)) - { - return Decision.Accept(); - } - - _logger.Debug("Episode file on disk contains more episodes than this file contains"); - return Decision.Reject("Episode file on disk contains more episodes than this file contains"); - } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UnverifiedSceneNumberingSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UnverifiedSceneNumberingSpecification.cs deleted file mode 100644 index ce65eb304..000000000 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UnverifiedSceneNumberingSpecification.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Linq; -using NLog; -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Parser.Model; -namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications -{ - public class UnverifiedSceneNumberingSpecification : IImportDecisionEngineSpecification - { - private readonly Logger _logger; - - public UnverifiedSceneNumberingSpecification(Logger logger) - { - _logger = logger; - } - - public Decision IsSatisfiedBy(LocalEpisode localEpisode) - { - if (localEpisode.ExistingFile) - { - _logger.Debug("Skipping scene numbering check for existing episode"); - return Decision.Accept(); - } - - if (localEpisode.Episodes.Any(v => v.UnverifiedSceneNumbering)) - { - _logger.Debug("This file uses unverified scene numbers, will not auto-import until numbering is confirmed on TheXEM. Skipping {0}", localEpisode.Path); - return Decision.Reject("This show has individual episode mappings on TheXEM but the mapping for this episode has not been confirmed yet by their administrators. TheXEM needs manual input."); - } - - return Decision.Accept(); - } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UpgradeSpecification.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UpgradeSpecification.cs deleted file mode 100644 index 3d07306af..000000000 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/UpgradeSpecification.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Linq; -using NLog; -using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Qualities; - -namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications -{ - public class UpgradeSpecification : IImportDecisionEngineSpecification - { - private readonly Logger _logger; - - public UpgradeSpecification(Logger logger) - { - _logger = logger; - } - - public Decision IsSatisfiedBy(LocalEpisode localEpisode) - { - var qualityComparer = new QualityModelComparer(localEpisode.Series.Profile); - if (localEpisode.Episodes.Any(e => e.EpisodeFileId != 0 && qualityComparer.Compare(e.EpisodeFile.Value.Quality, localEpisode.Quality) > 0)) - { - _logger.Debug("This file isn't an upgrade for all episodes. Skipping {0}", localEpisode.Path); - return Decision.Reject("Not an upgrade for existing episode file(s)"); - } - - return Decision.Accept(); - } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/Events/AlbumImportIncompleteEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/AlbumImportIncompleteEvent.cs new file mode 100644 index 000000000..08167337a --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/AlbumImportIncompleteEvent.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Download.TrackedDownloads; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.MediaFiles.Events +{ + public class AlbumImportIncompleteEvent : IEvent + { + public TrackedDownload TrackedDownload { get; private set; } + + public AlbumImportIncompleteEvent(TrackedDownload trackedDownload) + { + TrackedDownload = trackedDownload; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/Events/AlbumImportedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/AlbumImportedEvent.cs new file mode 100644 index 000000000..675c670b8 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/AlbumImportedEvent.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Download; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.MediaFiles.Events +{ + public class AlbumImportedEvent : IEvent + { + public Artist Artist { get; private set; } + public Album Album { get; private set; } + public AlbumRelease AlbumRelease { get; private set; } + public List ImportedTracks { 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 AlbumImportedEvent(Artist artist, Album album, AlbumRelease release, List importedTracks, List oldFiles, bool newDownload, DownloadClientItem downloadClientItem) + { + Artist = artist; + Album = album; + AlbumRelease = release; + ImportedTracks = importedTracks; + OldFiles = oldFiles; + NewDownload = newDownload; + + if (downloadClientItem != null) + { + DownloadClient = downloadClientItem.DownloadClient; + DownloadId = downloadClientItem.DownloadId; + } + + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/Events/ArtistRenamedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/ArtistRenamedEvent.cs new file mode 100644 index 000000000..f20f4f280 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/ArtistRenamedEvent.cs @@ -0,0 +1,19 @@ +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Music; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.MediaFiles.Events +{ + public class ArtistRenamedEvent : IEvent + { + public Artist Artist { get; private set; } + + public ArtistRenamedEvent(Artist artist) + { + Artist = artist; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/Events/ArtistScanSkippedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/ArtistScanSkippedEvent.cs new file mode 100644 index 000000000..5188ffd07 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/ArtistScanSkippedEvent.cs @@ -0,0 +1,27 @@ +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Music; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.MediaFiles.Events +{ + public class ArtistScanSkippedEvent : IEvent + { + public Artist Artist { get; private set; } + public ArtistScanSkippedReason Reason { get; private set; } + + public ArtistScanSkippedEvent(Artist artist, ArtistScanSkippedReason reason) + { + Artist = artist; + Reason = reason; + } + } + + public enum ArtistScanSkippedReason + { + RootFolderDoesNotExist, + RootFolderIsEmpty + } +} diff --git a/src/NzbDrone.Core/MediaFiles/Events/ArtistScannedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/ArtistScannedEvent.cs new file mode 100644 index 000000000..63f5f4656 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/ArtistScannedEvent.cs @@ -0,0 +1,19 @@ +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Music; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.MediaFiles.Events +{ + public class ArtistScannedEvent : IEvent + { + public Artist Artist { get; private set; } + + public ArtistScannedEvent(Artist artist) + { + Artist = artist; + } + } +} 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/EpisodeFileAddedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/EpisodeFileAddedEvent.cs deleted file mode 100644 index 83ea2a908..000000000 --- a/src/NzbDrone.Core/MediaFiles/Events/EpisodeFileAddedEvent.cs +++ /dev/null @@ -1,14 +0,0 @@ -using NzbDrone.Common.Messaging; - -namespace NzbDrone.Core.MediaFiles.Events -{ - public class EpisodeFileAddedEvent : IEvent - { - public EpisodeFile EpisodeFile { get; private set; } - - public EpisodeFileAddedEvent(EpisodeFile episodeFile) - { - EpisodeFile = episodeFile; - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/Events/EpisodeFileDeletedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/EpisodeFileDeletedEvent.cs deleted file mode 100644 index 2cbc177a2..000000000 --- a/src/NzbDrone.Core/MediaFiles/Events/EpisodeFileDeletedEvent.cs +++ /dev/null @@ -1,16 +0,0 @@ -using NzbDrone.Common.Messaging; - -namespace NzbDrone.Core.MediaFiles.Events -{ - public class EpisodeFileDeletedEvent : IEvent - { - public EpisodeFile EpisodeFile { get; private set; } - public DeleteMediaFileReason Reason { get; private set; } - - public EpisodeFileDeletedEvent(EpisodeFile episodeFile, DeleteMediaFileReason reason) - { - EpisodeFile = episodeFile; - Reason = reason; - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/Events/EpisodeFolderCreatedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/EpisodeFolderCreatedEvent.cs deleted file mode 100644 index 126b21222..000000000 --- a/src/NzbDrone.Core/MediaFiles/Events/EpisodeFolderCreatedEvent.cs +++ /dev/null @@ -1,20 +0,0 @@ -using NzbDrone.Common.Messaging; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.MediaFiles.Events -{ - public class EpisodeFolderCreatedEvent : IEvent - { - public Series Series { get; private set; } - public EpisodeFile EpisodeFile { get; private set; } - public string SeriesFolder { get; set; } - public string SeasonFolder { get; set; } - public string EpisodeFolder { get; set; } - - public EpisodeFolderCreatedEvent(Series series, EpisodeFile episodeFile) - { - Series = series; - EpisodeFile = episodeFile; - } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/Events/EpisodeImportedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/EpisodeImportedEvent.cs deleted file mode 100644 index 518132857..000000000 --- a/src/NzbDrone.Core/MediaFiles/Events/EpisodeImportedEvent.cs +++ /dev/null @@ -1,32 +0,0 @@ -using NzbDrone.Common.Messaging; -using NzbDrone.Core.Parser.Model; - -namespace NzbDrone.Core.MediaFiles.Events -{ - public class EpisodeImportedEvent : IEvent - { - public LocalEpisode EpisodeInfo { get; private set; } - public EpisodeFile ImportedEpisode { 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) - { - EpisodeInfo = episodeInfo; - ImportedEpisode = importedEpisode; - 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; - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/Events/SeriesRenamedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/SeriesRenamedEvent.cs deleted file mode 100644 index 8cfe96b89..000000000 --- a/src/NzbDrone.Core/MediaFiles/Events/SeriesRenamedEvent.cs +++ /dev/null @@ -1,15 +0,0 @@ -using NzbDrone.Common.Messaging; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.MediaFiles.Events -{ - public class SeriesRenamedEvent : IEvent - { - public Series Series { get; private set; } - - public SeriesRenamedEvent(Series series) - { - Series = series; - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/Events/SeriesScanSkippedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/SeriesScanSkippedEvent.cs deleted file mode 100644 index 47e8976c5..000000000 --- a/src/NzbDrone.Core/MediaFiles/Events/SeriesScanSkippedEvent.cs +++ /dev/null @@ -1,24 +0,0 @@ -using NzbDrone.Common.Messaging; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.MediaFiles.Events -{ - public class SeriesScanSkippedEvent : IEvent - { - public Series Series { get; private set; } - public SeriesScanSkippedReason Reason { get; set; } - - public SeriesScanSkippedEvent(Series series, SeriesScanSkippedReason reason) - { - Series = series; - Reason = reason; - } - } - - public enum SeriesScanSkippedReason - { - RootFolderDoesNotExist, - RootFolderIsEmpty, - SeriesFolderDoesNotExist - } -} diff --git a/src/NzbDrone.Core/MediaFiles/Events/SeriesScannedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/SeriesScannedEvent.cs deleted file mode 100644 index f82de5214..000000000 --- a/src/NzbDrone.Core/MediaFiles/Events/SeriesScannedEvent.cs +++ /dev/null @@ -1,15 +0,0 @@ -using NzbDrone.Common.Messaging; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.MediaFiles.Events -{ - public class SeriesScannedEvent : IEvent - { - public Series Series { get; private set; } - - public SeriesScannedEvent(Series series) - { - Series = series; - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/MediaFiles/Events/TrackFileAddedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/TrackFileAddedEvent.cs new file mode 100644 index 000000000..d8333833b --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/TrackFileAddedEvent.cs @@ -0,0 +1,18 @@ +using NzbDrone.Common.Messaging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.MediaFiles.Events +{ + public class TrackFileAddedEvent : IEvent + { + public TrackFile TrackFile { get; private set; } + + public TrackFileAddedEvent(TrackFile trackFile) + { + TrackFile = trackFile; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/Events/TrackFileDeletedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/TrackFileDeletedEvent.cs new file mode 100644 index 000000000..19017e686 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/TrackFileDeletedEvent.cs @@ -0,0 +1,20 @@ +using NzbDrone.Common.Messaging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.MediaFiles.Events +{ + public class TrackFileDeletedEvent : IEvent + { + public TrackFile TrackFile { get; private set; } + public DeleteMediaFileReason Reason { get; private set; } + + public TrackFileDeletedEvent(TrackFile trackFile, DeleteMediaFileReason reason) + { + TrackFile = trackFile; + Reason = reason; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/Events/TrackFileRenamedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/TrackFileRenamedEvent.cs new file mode 100644 index 000000000..a4c1847fc --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/TrackFileRenamedEvent.cs @@ -0,0 +1,19 @@ +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.MediaFiles.Events +{ + public class TrackFileRenamedEvent : IEvent + { + public Artist Artist { get; private set; } + public TrackFile TrackFile { get; private set; } + public string OriginalPath { get; private set; } + + public TrackFileRenamedEvent(Artist artist, TrackFile trackFile, string originalPath) + { + Artist = artist; + TrackFile = trackFile; + OriginalPath = originalPath; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/Events/TrackFileRetaggedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/TrackFileRetaggedEvent.cs new file mode 100644 index 000000000..3023103ec --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/TrackFileRetaggedEvent.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.MediaFiles.Events +{ + public class TrackFileRetaggedEvent : IEvent + { + public Artist Artist { get; private set; } + public TrackFile TrackFile { get; private set; } + public Dictionary> Diff { get; private set; } + public bool Scrubbed { get; private set; } + + public TrackFileRetaggedEvent(Artist artist, + TrackFile trackFile, + Dictionary> diff, + bool scrubbed) + { + Artist = artist; + TrackFile = trackFile; + Diff = diff; + Scrubbed = scrubbed; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/Events/TrackFolderCreatedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/TrackFolderCreatedEvent.cs new file mode 100644 index 000000000..79fb40666 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/TrackFolderCreatedEvent.cs @@ -0,0 +1,20 @@ +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.MediaFiles.Events +{ + public class TrackFolderCreatedEvent : IEvent + { + public Artist Artist { get; private set; } + public TrackFile TrackFile { get; private set; } + public string ArtistFolder { get; set; } + public string AlbumFolder { get; set; } + public string TrackFolder { get; set; } + + public TrackFolderCreatedEvent(Artist artist, TrackFile trackFile) + { + Artist = artist; + TrackFile = trackFile; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/Events/TrackImportFailedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/TrackImportFailedEvent.cs new file mode 100644 index 000000000..6a6c0f8bf --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/TrackImportFailedEvent.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 TrackImportFailedEvent : IEvent + { + public Exception Exception { get; set; } + public LocalTrack TrackInfo { get; } + public bool NewDownload { get; } + public string DownloadClient { get; } + public string DownloadId { get; } + + public TrackImportFailedEvent(Exception exception, LocalTrack trackInfo, bool newDownload, DownloadClientItem downloadClientItem) + { + Exception = exception; + TrackInfo = trackInfo; + NewDownload = newDownload; + + if (downloadClientItem != null) + { + DownloadClient = downloadClientItem.DownloadClient; + DownloadId = downloadClientItem.DownloadId; + } + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/Events/TrackImportedEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/TrackImportedEvent.cs new file mode 100644 index 000000000..eeb00398b --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/TrackImportedEvent.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Download; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.Events +{ + public class TrackImportedEvent : IEvent + { + public LocalTrack TrackInfo { get; private set; } + public TrackFile ImportedTrack { 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 TrackImportedEvent(LocalTrack trackInfo, TrackFile importedTrack, List oldFiles, bool newDownload, DownloadClientItem downloadClientItem) + { + TrackInfo = trackInfo; + ImportedTrack = importedTrack; + OldFiles = oldFiles; + NewDownload = newDownload; + + if (downloadClientItem != null) + { + DownloadClient = downloadClientItem.DownloadClient; + DownloadId = downloadClientItem.DownloadId; + } + + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/FileDateType.cs b/src/NzbDrone.Core/MediaFiles/FileDateType.cs index 6d78be960..5173d8c0f 100644 --- a/src/NzbDrone.Core/MediaFiles/FileDateType.cs +++ b/src/NzbDrone.Core/MediaFiles/FileDateType.cs @@ -1,9 +1,8 @@ -namespace NzbDrone.Core.MediaFiles +namespace NzbDrone.Core.MediaFiles { public enum FileDateType { None = 0, - LocalAirDate = 1, - UtcAirDate = 2 + AlbumReleaseDate = 1 } } diff --git a/src/NzbDrone.Core/MediaFiles/FilterFilesType.cs b/src/NzbDrone.Core/MediaFiles/FilterFilesType.cs new file mode 100644 index 000000000..76342b47f --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/FilterFilesType.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.MediaFiles +{ + public enum FilterFilesType + { + None, + Matched, + Known + } +} diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileAttributeService.cs b/src/NzbDrone.Core/MediaFiles/MediaFileAttributeService.cs index 1db110a15..8d75cb455 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileAttributeService.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileAttributeService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using NLog; using NzbDrone.Common.Disk; @@ -71,7 +71,7 @@ namespace NzbDrone.Core.MediaFiles { if (OsInfo.IsWindows) { - _logger.Debug("Setting last write time on series folder: {0}", path); + _logger.Debug("Setting last write time on artist folder: {0}", path); _diskProvider.FolderSetLastWriteTime(path, time); } } diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileDeletionService.cs b/src/NzbDrone.Core/MediaFiles/MediaFileDeletionService.cs new file mode 100644 index 000000000..c8124a94d --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/MediaFileDeletionService.cs @@ -0,0 +1,155 @@ +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.Music; +using NzbDrone.Core.Music.Events; + +namespace NzbDrone.Core.MediaFiles +{ + public interface IDeleteMediaFiles + { + void DeleteTrackFile(Artist artist, TrackFile trackFile); + void DeleteTrackFile(TrackFile trackFile, string subfolder = ""); + } + + public class MediaFileDeletionService : IDeleteMediaFiles, + IHandleAsync, + IHandle + { + private readonly IDiskProvider _diskProvider; + private readonly IRecycleBinProvider _recycleBinProvider; + private readonly IMediaFileService _mediaFileService; + private readonly IArtistService _artistService; + private readonly IConfigService _configService; + private readonly Logger _logger; + + public MediaFileDeletionService(IDiskProvider diskProvider, + IRecycleBinProvider recycleBinProvider, + IMediaFileService mediaFileService, + IArtistService artistService, + IConfigService configService, + Logger logger) + { + _diskProvider = diskProvider; + _recycleBinProvider = recycleBinProvider; + _mediaFileService = mediaFileService; + _artistService = artistService; + _configService = configService; + _logger = logger; + } + + public void DeleteTrackFile(Artist artist, TrackFile trackFile) + { + var fullPath = trackFile.Path; + var rootFolder = _diskProvider.GetParentFolder(artist.Path); + + if (!_diskProvider.FolderExists(rootFolder)) + { + _logger.Warn("Artist's root folder ({0}) doesn't exist.", rootFolder); + throw new NzbDroneClientException(HttpStatusCode.Conflict, "Artist's root folder ({0}) doesn't exist.", rootFolder); + } + + if (_diskProvider.GetDirectories(rootFolder).Empty()) + { + _logger.Warn("Artist's root folder ({0}) is empty.", rootFolder); + throw new NzbDroneClientException(HttpStatusCode.Conflict, "Artist's root folder ({0}) is empty.", rootFolder); + } + + if (_diskProvider.FolderExists(artist.Path)) + { + var subfolder = _diskProvider.GetParentFolder(artist.Path).GetRelativePath(_diskProvider.GetParentFolder(fullPath)); + DeleteTrackFile(trackFile, subfolder); + } + else + { + // delete from db even if the artist folder is missing + _mediaFileService.Delete(trackFile, DeleteMediaFileReason.Manual); + } + } + + public void DeleteTrackFile(TrackFile trackFile, string subfolder = "") + { + var fullPath = trackFile.Path; + + if (_diskProvider.FileExists(fullPath)) + { + _logger.Info("Deleting track file: {0}", fullPath); + + try + { + _recycleBinProvider.DeleteFile(fullPath, subfolder); + } + catch (Exception e) + { + _logger.Error(e, "Unable to delete track file"); + throw new NzbDroneClientException(HttpStatusCode.InternalServerError, "Unable to delete track file"); + } + } + + // Delete the track file from the database to clean it up even if the file was already deleted + _mediaFileService.Delete(trackFile, DeleteMediaFileReason.Manual); + } + + public void HandleAsync(ArtistDeletedEvent message) + { + if (message.DeleteFiles) + { + var artist = message.Artist; + var allArtists = _artistService.GetAllArtists(); + + foreach (var s in allArtists) + { + if (s.Id == artist.Id) continue; + + if (artist.Path.IsParentPath(s.Path)) + { + _logger.Error("Artist path: '{0}' is a parent of another artist, not deleting files.", artist.Path); + return; + } + + if (artist.Path.PathEquals(s.Path)) + { + _logger.Error("Artist path: '{0}' is the same as another artist, not deleting files.", artist.Path); + return; + } + } + if (_diskProvider.FolderExists(message.Artist.Path)) + { + _recycleBinProvider.DeleteFolder(message.Artist.Path); + } + } + } + + [EventHandleOrder(EventHandleOrder.Last)] + public void Handle(TrackFileDeletedEvent message) + { + if (message.Reason == DeleteMediaFileReason.Upgrade) + { + return; + } + + if (_configService.DeleteEmptyFolders) + { + var artist = message.TrackFile.Artist.Value; + var albumFolder = message.TrackFile.Path.GetParentPath(); + + if (_diskProvider.GetFiles(artist.Path, SearchOption.AllDirectories).Empty()) + { + _diskProvider.DeleteFolder(artist.Path, true); + } + else if (_diskProvider.GetFiles(albumFolder, SearchOption.AllDirectories).Empty()) + { + _diskProvider.RemoveEmptySubfolders(albumFolder); + } + } + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileExtensions.cs b/src/NzbDrone.Core/MediaFiles/MediaFileExtensions.cs index 6a951a3b9..e248a4796 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,67 +11,25 @@ namespace NzbDrone.Core.MediaFiles static MediaFileExtensions() { - _fileExtensions = new Dictionary + _fileExtensions = new Dictionary(StringComparer.OrdinalIgnoreCase) { - //Unknown - { ".webm", Quality.Unknown }, - - //SDTV - { ".m4v", Quality.SDTV }, - { ".3gp", Quality.SDTV }, - { ".nsv", Quality.SDTV }, - { ".ty", Quality.SDTV }, - { ".strm", Quality.SDTV }, - { ".rm", Quality.SDTV }, - { ".rmvb", Quality.SDTV }, - { ".m3u", Quality.SDTV }, - { ".ifo", Quality.SDTV }, - { ".mov", Quality.SDTV }, - { ".qt", Quality.SDTV }, - { ".divx", Quality.SDTV }, - { ".xvid", Quality.SDTV }, - { ".bivx", Quality.SDTV }, - { ".nrg", Quality.SDTV }, - { ".pva", Quality.SDTV }, - { ".wmv", Quality.SDTV }, - { ".asf", Quality.SDTV }, - { ".asx", Quality.SDTV }, - { ".ogm", Quality.SDTV }, - { ".ogv", Quality.SDTV }, - { ".m2v", Quality.SDTV }, - { ".avi", Quality.SDTV }, - { ".bin", Quality.SDTV }, - { ".dat", Quality.SDTV }, - { ".dvr-ms", Quality.SDTV }, - { ".mpg", Quality.SDTV }, - { ".mpeg", Quality.SDTV }, - { ".mp4", Quality.SDTV }, - { ".avc", Quality.SDTV }, - { ".vp3", Quality.SDTV }, - { ".svq3", Quality.SDTV }, - { ".nuv", Quality.SDTV }, - { ".viv", Quality.SDTV }, - { ".dv", Quality.SDTV }, - { ".fli", Quality.SDTV }, - { ".flv", Quality.SDTV }, - { ".wpl", Quality.SDTV }, - - //DVD - { ".img", Quality.DVD }, - { ".iso", Quality.DVD }, - { ".vob", Quality.DVD }, - - //HD - { ".mkv", Quality.HDTV720p }, - { ".ts", Quality.HDTV720p }, - { ".wtv", Quality.HDTV720p }, - - //Bluray - { ".m2ts", Quality.Bluray720p } + { ".mp2", Quality.Unknown }, + { ".mp3", Quality.Unknown }, + { ".m4a", Quality.Unknown }, + { ".m4b", Quality.Unknown }, + { ".m4p", Quality.Unknown }, + { ".ogg", Quality.Unknown }, + { ".oga", Quality.Unknown }, + { ".opus", Quality.Unknown }, + { ".wma", Quality.WMA }, + { ".wav", Quality.WAV }, + { ".wv" , Quality.WAVPACK }, + { ".flac", Quality.FLAC }, + { ".ape", Quality.APE } }; } - 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/MediaFileRepository.cs b/src/NzbDrone.Core/MediaFiles/MediaFileRepository.cs index 206942356..da8b84ae7 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileRepository.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileRepository.cs @@ -1,40 +1,93 @@ using System.Collections.Generic; +using System.Linq; +using Marr.Data; +using Marr.Data.QGen; using NzbDrone.Core.Datastore; +using NzbDrone.Core.Datastore.Extensions; using NzbDrone.Core.Messaging.Events; - +using NzbDrone.Core.Music; namespace NzbDrone.Core.MediaFiles { - public interface IMediaFileRepository : IBasicRepository + public interface IMediaFileRepository : IBasicRepository { - List GetFilesBySeries(int seriesId); - List GetFilesBySeason(int seriesId, int seasonNumber); - List GetFilesWithoutMediaInfo(); + List GetFilesByArtist(int artistId); + List GetFilesByAlbum(int albumId); + List GetFilesByRelease(int releaseId); + List GetUnmappedFiles(); + List GetFilesWithBasePath(string path); + TrackFile GetFileWithPath(string path); + void DeleteFilesByAlbum(int albumId); } - public class MediaFileRepository : BasicRepository, IMediaFileRepository + public class MediaFileRepository : BasicRepository, IMediaFileRepository { public MediaFileRepository(IMainDatabase database, IEventAggregator eventAggregator) : base(database, eventAggregator) { } - public List GetFilesBySeries(int seriesId) + // always join with all the other good stuff + // needed more often than not so better to load it all now + protected override QueryBuilder Query => + DataMapper.Query() + .Join(JoinType.Left, t => t.Tracks, (t, x) => t.Id == x.TrackFileId) + .Join(JoinType.Left, t => t.Album, (t, a) => t.AlbumId == a.Id) + .Join(JoinType.Left, t => t.Artist, (t, a) => t.Album.Value.ArtistMetadataId == a.ArtistMetadataId) + .Join(JoinType.Left, a => a.Metadata, (a, m) => a.ArtistMetadataId == m.Id); + + public List GetFilesByArtist(int artistId) { - return Query.Where(c => c.SeriesId == seriesId).ToList(); + return Query + .Join(JoinType.Inner, t => t.AlbumRelease, (t, r) => t.AlbumReleaseId == r.Id) + .Where(r => r.Monitored == true) + .AndWhere(t => t.Artist.Value.Id == artistId) + .ToList(); } - public List GetFilesBySeason(int seriesId, int seasonNumber) + public List GetFilesByAlbum(int albumId) { - return Query.Where(c => c.SeriesId == seriesId) - .AndWhere(c => c.SeasonNumber == seasonNumber) - .ToList(); + return Query + .Join(JoinType.Inner, t => t.AlbumRelease, (t, r) => t.AlbumReleaseId == r.Id) + .Where(r => r.Monitored == true) + .AndWhere(f => f.AlbumId == albumId) + .ToList(); } - public List GetFilesWithoutMediaInfo() + public List GetUnmappedFiles() + { + var query = "SELECT TrackFiles.* " + + "FROM TrackFiles " + + "LEFT JOIN Tracks ON Tracks.TrackFileId = TrackFiles.Id " + + "WHERE Tracks.Id IS NULL "; + + return DataMapper.Query().QueryText(query).ToList(); + } + + public void DeleteFilesByAlbum(int albumId) + { + var ids = DataMapper.Query().Where(x => x.AlbumId == albumId); + DeleteMany(ids); + } + + public List GetFilesByRelease(int releaseId) + { + return Query + .Where(x => x.AlbumReleaseId == releaseId) + .ToList(); + } + + public List GetFilesWithBasePath(string path) + { + return Query + .Where(x => x.Path.StartsWith(path)) + .ToList(); + } + + public TrackFile GetFileWithPath(string path) { - return Query.Where(c => c.MediaInfo == null).ToList(); + return Query.Where(x => x.Path == path).SingleOrDefault(); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileService.cs b/src/NzbDrone.Core/MediaFiles/MediaFileService.cs index ca3f68ce2..ea947992f 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileService.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileService.cs @@ -1,30 +1,38 @@ using System.Collections.Generic; -using System.IO; +using System.IO.Abstractions; using System.Linq; using NLog; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Tv; -using NzbDrone.Core.Tv.Events; using NzbDrone.Common; +using NzbDrone.Core.Music; +using System; +using NzbDrone.Core.Music.Events; +using NzbDrone.Common.Extensions; namespace NzbDrone.Core.MediaFiles { public interface IMediaFileService { - EpisodeFile Add(EpisodeFile episodeFile); - void Update(EpisodeFile episodeFile); - void Delete(EpisodeFile episodeFile, DeleteMediaFileReason reason); - List GetFilesBySeries(int seriesId); - List GetFilesBySeason(int seriesId, int seasonNumber); - List GetFilesWithoutMediaInfo(); - List FilterExistingFiles(List files, Series series); - EpisodeFile Get(int id); - List Get(IEnumerable ids); - + TrackFile Add(TrackFile trackFile); + void AddMany(List trackFiles); + void Update(TrackFile trackFile); + void Update(List trackFile); + void Delete(TrackFile trackFile, DeleteMediaFileReason reason); + void DeleteMany(List trackFiles, DeleteMediaFileReason reason); + List GetFilesByArtist(int artistId); + List GetFilesByAlbum(int albumId); + List GetFilesByRelease(int releaseId); + List GetUnmappedFiles(); + List FilterUnchangedFiles(List files, Artist artist, FilterFilesType filter); + TrackFile Get(int id); + List Get(IEnumerable ids); + List GetFilesWithBasePath(string path); + TrackFile GetFileWithPath(string path); + void UpdateMediaInfo(List trackFiles); } - public class MediaFileService : IMediaFileService, IHandleAsync + public class MediaFileService : IMediaFileService, IHandleAsync { private readonly IEventAggregator _eventAggregator; private readonly IMediaFileRepository _mediaFileRepository; @@ -37,66 +45,147 @@ namespace NzbDrone.Core.MediaFiles _logger = logger; } - public EpisodeFile Add(EpisodeFile episodeFile) + public TrackFile Add(TrackFile trackFile) { - var addedFile = _mediaFileRepository.Insert(episodeFile); - _eventAggregator.PublishEvent(new EpisodeFileAddedEvent(addedFile)); + var addedFile = _mediaFileRepository.Insert(trackFile); + _eventAggregator.PublishEvent(new TrackFileAddedEvent(addedFile)); return addedFile; } - public void Update(EpisodeFile episodeFile) + public void AddMany(List trackFiles) { - _mediaFileRepository.Update(episodeFile); + _mediaFileRepository.InsertMany(trackFiles); + foreach (var addedFile in trackFiles) + { + _eventAggregator.PublishEvent(new TrackFileAddedEvent(addedFile)); + } } - public void Delete(EpisodeFile episodeFile, DeleteMediaFileReason reason) + public void Update(TrackFile trackFile) { - //Little hack so we have the episodes and series attached for the event consumers - episodeFile.Episodes.LazyLoad(); - episodeFile.Path = Path.Combine(episodeFile.Series.Value.Path, episodeFile.RelativePath); + _mediaFileRepository.Update(trackFile); + } - _mediaFileRepository.Delete(episodeFile); - _eventAggregator.PublishEvent(new EpisodeFileDeletedEvent(episodeFile, reason)); + public void Update(List trackFiles) + { + _mediaFileRepository.UpdateMany(trackFiles); } - public List GetFilesBySeries(int seriesId) + + public void Delete(TrackFile trackFile, DeleteMediaFileReason reason) { - return _mediaFileRepository.GetFilesBySeries(seriesId); + _mediaFileRepository.Delete(trackFile); + // If the trackfile wasn't mapped to a track, don't publish an event + if (trackFile.AlbumId > 0) + { + _eventAggregator.PublishEvent(new TrackFileDeletedEvent(trackFile, reason)); + } } - public List GetFilesBySeason(int seriesId, int seasonNumber) + public void DeleteMany(List trackFiles, DeleteMediaFileReason reason) { - return _mediaFileRepository.GetFilesBySeason(seriesId, seasonNumber); + _mediaFileRepository.DeleteMany(trackFiles); + + // publish events where trackfile was mapped to a track + foreach (var trackFile in trackFiles.Where(x => x.AlbumId > 0)) + { + _eventAggregator.PublishEvent(new TrackFileDeletedEvent(trackFile, reason)); + } } - public List GetFilesWithoutMediaInfo() + public List FilterUnchangedFiles(List files, Artist artist, FilterFilesType filter) { - return _mediaFileRepository.GetFilesWithoutMediaInfo(); + _logger.Debug($"Filtering {files.Count} files for unchanged files"); + + var knownFiles = GetFilesWithBasePath(artist.Path); + _logger.Trace($"Got {knownFiles.Count} existing files"); + + if (!knownFiles.Any()) return files; + + var combined = files + .Join(knownFiles, + f => f.FullName, + af => af.Path, + (f, af) => new { DiskFile = f, DbFile = af}, + PathEqualityComparer.Instance) + .ToList(); + + List unwanted = null; + if (filter == FilterFilesType.Known) + { + unwanted = combined + .Where(x => x.DiskFile.Length == x.DbFile.Size && + Math.Abs((x.DiskFile.LastWriteTimeUtc - x.DbFile.Modified).TotalSeconds) <= 1) + .Select(x => x.DiskFile) + .ToList(); + _logger.Trace($"{unwanted.Count} unchanged existing files"); + } + else if (filter == FilterFilesType.Matched) + { + unwanted = combined + .Where(x => x.DiskFile.Length == x.DbFile.Size && + Math.Abs((x.DiskFile.LastWriteTimeUtc - x.DbFile.Modified).TotalSeconds) <= 1 && + (x.DbFile.Tracks == null || (x.DbFile.Tracks.IsLoaded && x.DbFile.Tracks.Value.Any()))) + .Select(x => x.DiskFile) + .ToList(); + _logger.Trace($"{unwanted.Count} unchanged and matched files"); + } + else + { + throw new ArgumentException("Unrecognised value of FilterFilesType filter"); + } + + return files.Except(unwanted).ToList(); } - public List FilterExistingFiles(List files, Series series) + public TrackFile Get(int id) { - var seriesFiles = GetFilesBySeries(series.Id).Select(f => Path.Combine(series.Path, f.RelativePath)).ToList(); + return _mediaFileRepository.Get(id); + } - if (!seriesFiles.Any()) return files; + public List Get(IEnumerable ids) + { + return _mediaFileRepository.Get(ids).ToList(); + } - return files.Except(seriesFiles, PathEqualityComparer.Instance).ToList(); + public List GetFilesWithBasePath(string path) + { + return _mediaFileRepository.GetFilesWithBasePath(path); } - public EpisodeFile Get(int id) + public TrackFile GetFileWithPath(string path) { - return _mediaFileRepository.Get(id); + return _mediaFileRepository.GetFileWithPath(path); } - public List Get(IEnumerable ids) + public void HandleAsync(AlbumDeletedEvent message) { - return _mediaFileRepository.Get(ids).ToList(); + _mediaFileRepository.DeleteFilesByAlbum(message.Album.Id); + } + + public List GetFilesByArtist(int artistId) + { + return _mediaFileRepository.GetFilesByArtist(artistId); + } + + public List GetFilesByAlbum(int albumId) + { + return _mediaFileRepository.GetFilesByAlbum(albumId); + } + + public List GetFilesByRelease(int releaseId) + { + return _mediaFileRepository.GetFilesByRelease(releaseId); + } + + public List GetUnmappedFiles() + { + return _mediaFileRepository.GetUnmappedFiles(); } - public void HandleAsync(SeriesDeletedEvent message) + public void UpdateMediaInfo(List trackFiles) { - var files = GetFilesBySeries(message.Series.Id); - _mediaFileRepository.DeleteMany(files); + _mediaFileRepository.SetFields(trackFiles, t => t.MediaInfo); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileTableCleanupService.cs b/src/NzbDrone.Core/MediaFiles/MediaFileTableCleanupService.cs index 0b7b2cba3..5fb72354b 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileTableCleanupService.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileTableCleanupService.cs @@ -1,87 +1,48 @@ -using System; using System.Collections.Generic; -using System.IO; +using System.Linq; using NLog; using NzbDrone.Common; using NzbDrone.Common.Extensions; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.MediaFiles { public interface IMediaFileTableCleanupService { - void Clean(Series series, List filesOnDisk); + void Clean(Artist artist, List filesOnDisk); } public class MediaFileTableCleanupService : IMediaFileTableCleanupService { private readonly IMediaFileService _mediaFileService; - private readonly IEpisodeService _episodeService; + private readonly ITrackService _trackService; private readonly Logger _logger; public MediaFileTableCleanupService(IMediaFileService mediaFileService, - IEpisodeService episodeService, + ITrackService trackService, Logger logger) { _mediaFileService = mediaFileService; - _episodeService = episodeService; + _trackService = trackService; _logger = logger; } - public void Clean(Series series, List filesOnDisk) + public void Clean(Artist artist, List filesOnDisk) { - var seriesFiles = _mediaFileService.GetFilesBySeries(series.Id); - var episodes = _episodeService.GetEpisodeBySeries(series.Id); + var dbFiles = _mediaFileService.GetFilesWithBasePath(artist.Path); - var filesOnDiskKeys = new HashSet(filesOnDisk, PathEqualityComparer.Instance); - - foreach (var seriesFile in seriesFiles) - { - var episodeFile = seriesFile; - var episodeFilePath = Path.Combine(series.Path, episodeFile.RelativePath); + // get files in database that are missing on disk and remove from database + var missingFiles = dbFiles.ExceptBy(x => x.Path, filesOnDisk, x => x, PathEqualityComparer.Instance).ToList(); - try - { - if (!filesOnDiskKeys.Contains(episodeFilePath)) - { - _logger.Debug("File [{0}] no longer exists on disk, removing from db", episodeFilePath); - _mediaFileService.Delete(seriesFile, DeleteMediaFileReason.MissingFromDisk); - continue; - } + _logger.Debug("The following files no longer exist on disk, removing from db:\n{0}", + string.Join("\n", missingFiles.Select(x => x.Path))); - if (episodes.None(e => e.EpisodeFileId == episodeFile.Id)) - { - _logger.Debug("File [{0}] is not assigned to any episodes, removing from db", episodeFilePath); - _mediaFileService.Delete(episodeFile, DeleteMediaFileReason.NoLinkedEpisodes); - continue; - } + _mediaFileService.DeleteMany(missingFiles, DeleteMediaFileReason.MissingFromDisk); -// var localEpsiode = _parsingService.GetLocalEpisode(episodeFile.Path, series); -// -// if (localEpsiode == null || episodes.Count != localEpsiode.Episodes.Count) -// { -// _logger.Debug("File [{0}] parsed episodes has changed, removing from db", episodeFile.Path); -// _mediaFileService.Delete(episodeFile); -// continue; -// } - } - - catch (Exception ex) - { - _logger.Error(ex, "Unable to cleanup EpisodeFile in DB: {0}", episodeFile.Id); - } - } - - foreach (var e in episodes) - { - var episode = e; - - if (episode.EpisodeFileId > 0 && seriesFiles.None(f => f.Id == episode.EpisodeFileId)) - { - episode.EpisodeFileId = 0; - _episodeService.UpdateEpisode(episode); - } - } + // get any tracks matched to these trackfiles and unlink them + var orphanedTracks = _trackService.GetTracksByFileId(missingFiles.Select(x => x.Id)); + orphanedTracks.ForEach(x => x.TrackFileId = 0); + _trackService.SetFileIds(orphanedTracks); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoLib.cs b/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoLib.cs deleted file mode 100644 index b7b2f7d38..000000000 --- a/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoLib.cs +++ /dev/null @@ -1,354 +0,0 @@ -using System; -using System.IO; -using System.Runtime.InteropServices; -using System.Text; -using NzbDrone.Common.Instrumentation; -using NLog; - -namespace NzbDrone.Core.MediaFiles.MediaInfo -{ - [Flags] - public enum BufferStatus - { - Accepted = 1, - Filled = 2, - Updated = 4, - Finalized = 8 - } - - public enum StreamKind - { - General, - Video, - Audio, - Text, - Other, - Image, - Menu - } - - public enum InfoKind - { - Name, - Text, - Measure, - Options, - NameText, - MeasureText, - Info, - HowTo - } - - public enum InfoOptions - { - ShowInInform, - Support, - ShowInSupported, - TypeOfValue - } - - public enum InfoFileOptions - { - FileOption_Nothing = 0x00, - FileOption_NoRecursive = 0x01, - FileOption_CloseAll = 0x02, - FileOption_Max = 0x04 - }; - - - public class MediaInfo : IDisposable - { - private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(MediaInfo)); - private IntPtr _handle; - - public bool MustUseAnsi { get; set; } - public Encoding Encoding { get; set; } - - public MediaInfo() - { - _handle = MediaInfo_New(); - - InitializeEncoding(); - } - - ~MediaInfo() - { - if (_handle != IntPtr.Zero) - { - MediaInfo_Delete(_handle); - } - } - - public void Dispose() - { - if (_handle != IntPtr.Zero) - { - MediaInfo_Delete(_handle); - } - GC.SuppressFinalize(this); - } - - private void InitializeEncoding() - { - if (Environment.OSVersion.ToString().IndexOf("Windows") != -1) - { - // Windows guaranteed UCS-2 - MustUseAnsi = false; - Encoding = Encoding.Unicode; - } - else - { - // Linux normally UCS-4. As fallback we try UCS-2 and plain Ansi. - MustUseAnsi = false; - Encoding = Encoding.UTF32; - - if (Option("Info_Version", "").StartsWith("MediaInfoLib")) - { - return; - } - - Encoding = Encoding.Unicode; - - if (Option("Info_Version", "").StartsWith("MediaInfoLib")) - { - return; - } - - MustUseAnsi = true; - Encoding = Encoding.Default; - - if (Option("Info_Version", "").StartsWith("MediaInfoLib")) - { - return; - } - - throw new NotSupportedException("Unsupported MediaInfoLib encoding"); - } - } - - private IntPtr MakeStringParameter(string value) - { - var buffer = Encoding.GetBytes(value); - - Array.Resize(ref buffer, buffer.Length + 4); - - var buf = Marshal.AllocHGlobal(buffer.Length); - Marshal.Copy(buffer, 0, buf, buffer.Length); - - return buf; - } - - private string MakeStringResult(IntPtr value) - { - if (Encoding == Encoding.Unicode) - { - return Marshal.PtrToStringUni(value); - } - else if (Encoding == Encoding.UTF32) - { - int i = 0; - for (; i < 1024; i += 4) - { - var data = Marshal.ReadInt32(value, i); - if (data == 0) - { - break; - } - } - - var buffer = new byte[i]; - Marshal.Copy(value, buffer, 0, i); - - return Encoding.GetString(buffer, 0, i); - } - else - { - return Marshal.PtrToStringAnsi(value); - } - } - - public int Open(string fileName) - { - var pFileName = MakeStringParameter(fileName); - try - { - if (MustUseAnsi) - { - return (int)MediaInfoA_Open(_handle, pFileName); - } - else - { - return (int)MediaInfo_Open(_handle, pFileName); - } - } - finally - { - Marshal.FreeHGlobal(pFileName); - } - } - - public int Open(Stream stream) - { - var isValid = (int)MediaInfo_Open_Buffer_Init(_handle, stream.Length, 0); - if (isValid == 1) - { - var buffer = new byte[16 * 1024]; - long seekStart = 0; - long totalRead = 0; - int bufferRead; - - do - { - bufferRead = stream.Read(buffer, 0, buffer.Length); - totalRead += bufferRead; - - var status = (BufferStatus)MediaInfo_Open_Buffer_Continue(_handle, buffer, (IntPtr)bufferRead); - - if (status.HasFlag(BufferStatus.Finalized) || status <= 0 || bufferRead == 0) - { - Logger.Trace("Read file offset {0}-{1} ({2} bytes)", seekStart, stream.Position, stream.Position - seekStart); - break; - } - - var seekPos = MediaInfo_Open_Buffer_Continue_GoTo_Get(_handle); - if (seekPos != -1) - { - Logger.Trace("Read file offset {0}-{1} ({2} bytes)", seekStart, stream.Position, stream.Position - seekStart); - seekPos = stream.Seek(seekPos, SeekOrigin.Begin); - seekStart = seekPos; - MediaInfo_Open_Buffer_Init(_handle, stream.Length, seekPos); - } - } while (bufferRead > 0); - - MediaInfo_Open_Buffer_Finalize(_handle); - - Logger.Trace("Read a total of {0} bytes ({1:0.0}%)", totalRead, totalRead * 100.0 / stream.Length); - } - - return isValid; - } - - public void Close() - { - MediaInfo_Close(_handle); - } - - public string Get(StreamKind streamKind, int streamNumber, string parameter, InfoKind infoKind = InfoKind.Text, InfoKind searchKind = InfoKind.Name) - { - var pParameter = MakeStringParameter(parameter); - try - { - if (MustUseAnsi) - { - return MakeStringResult(MediaInfoA_Get(_handle, (IntPtr)streamKind, (IntPtr)streamNumber, pParameter, (IntPtr)infoKind, (IntPtr)searchKind)); - } - else - { - return MakeStringResult(MediaInfo_Get(_handle, (IntPtr)streamKind, (IntPtr)streamNumber, pParameter, (IntPtr)infoKind, (IntPtr)searchKind)); - } - } - finally - { - Marshal.FreeHGlobal(pParameter); - } - } - - public string Get(StreamKind streamKind, int streamNumber, int parameter, InfoKind infoKind) - { - if (MustUseAnsi) - { - return MakeStringResult(MediaInfoA_GetI(_handle, (IntPtr)streamKind, (IntPtr)streamNumber, (IntPtr)parameter, (IntPtr)infoKind)); - } - else - { - return MakeStringResult(MediaInfo_GetI(_handle, (IntPtr)streamKind, (IntPtr)streamNumber, (IntPtr)parameter, (IntPtr)infoKind)); - } - } - - public string Option(string option, string value) - { - var pOption = MakeStringParameter(option); - var pValue = MakeStringParameter(value); - try - { - if (MustUseAnsi) - { - return MakeStringResult(MediaInfoA_Option(_handle, pOption, pValue)); - } - else - { - return MakeStringResult(MediaInfo_Option(_handle, pOption, pValue)); - } - } - finally - { - Marshal.FreeHGlobal(pOption); - Marshal.FreeHGlobal(pValue); - } - } - - public int State_Get() - { - return (int)MediaInfo_State_Get(_handle); - } - - public int Count_Get(StreamKind streamKind, int streamNumber = -1) - { - return (int)MediaInfo_Count_Get(_handle, (IntPtr)streamKind, (IntPtr)streamNumber); - } - - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfo_New(); - [DllImport("MediaInfo.dll")] - private static extern void MediaInfo_Delete(IntPtr handle); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfo_Open(IntPtr handle, IntPtr fileName); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfo_Open_Buffer_Init(IntPtr handle, long fileSize, long fileOffset); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfo_Open_Buffer_Continue(IntPtr handle, byte[] buffer, IntPtr bufferSize); - [DllImport("MediaInfo.dll")] - private static extern long MediaInfo_Open_Buffer_Continue_GoTo_Get(IntPtr handle); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfo_Open_Buffer_Finalize(IntPtr handle); - [DllImport("MediaInfo.dll")] - private static extern void MediaInfo_Close(IntPtr handle); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfo_GetI(IntPtr handle, IntPtr streamKind, IntPtr streamNumber, IntPtr parameter, IntPtr infoKind); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfo_Get(IntPtr handle, IntPtr streamKind, IntPtr streamNumber, IntPtr parameter, IntPtr infoKind, IntPtr searchKind); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfo_Option(IntPtr handle, IntPtr option, IntPtr value); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfo_State_Get(IntPtr handle); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfo_Count_Get(IntPtr handle, IntPtr StreamKind, IntPtr streamNumber); - - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfoA_New(); - [DllImport("MediaInfo.dll")] - private static extern void MediaInfoA_Delete(IntPtr handle); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfoA_Open(IntPtr handle, IntPtr fileName); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfoA_Open_Buffer_Init(IntPtr handle, long fileSize, long fileOffset); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfoA_Open_Buffer_Continue(IntPtr handle, byte[] buffer, IntPtr bufferSize); - [DllImport("MediaInfo.dll")] - private static extern long MediaInfoA_Open_Buffer_Continue_GoTo_Get(IntPtr handle); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfoA_Open_Buffer_Finalize(IntPtr handle); - [DllImport("MediaInfo.dll")] - private static extern void MediaInfoA_Close(IntPtr handle); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfoA_GetI(IntPtr handle, IntPtr streamKind, IntPtr streamNumber, IntPtr parameter, IntPtr infoKind); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfoA_Get(IntPtr handle, IntPtr streamKind, IntPtr streamNumber, IntPtr parameter, IntPtr infoKind, IntPtr searchKind); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfoA_Option(IntPtr handle, IntPtr option, IntPtr value); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfoA_State_Get(IntPtr handle); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfoA_Count_Get(IntPtr handle, IntPtr StreamKind, IntPtr streamNumber); - } -} diff --git a/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoModel.cs b/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoModel.cs deleted file mode 100644 index af02288e8..000000000 --- a/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoModel.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System; -using System.Globalization; -using System.Linq; -using Newtonsoft.Json; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Datastore; - -namespace NzbDrone.Core.MediaFiles.MediaInfo -{ - public class MediaInfoModel : IEmbeddedDocument - { - public string VideoCodec { 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 int AudioBitrate { get; set; } - public TimeSpan RunTime { get; set; } - public int AudioStreamCount { get; set; } - public int AudioChannels { get; set; } - public string AudioChannelPositions { get; set; } - public string AudioChannelPositionsText { get; set; } - public string AudioProfile { get; set; } - public decimal VideoFps { get; set; } - public string AudioLanguages { get; set; } - 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 deleted file mode 100644 index fb232f2f9..000000000 --- a/src/NzbDrone.Core/MediaFiles/MediaInfo/UpdateMediaInfoService.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System.IO; -using NLog; -using NzbDrone.Common.Disk; -using NzbDrone.Core.MediaFiles.Events; -using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Tv; -using System.Collections.Generic; -using System.Linq; -using NzbDrone.Core.Configuration; - -namespace NzbDrone.Core.MediaFiles.MediaInfo -{ - public class UpdateMediaInfoService : IHandle - { - private readonly IDiskProvider _diskProvider; - private readonly IMediaFileService _mediaFileService; - private readonly IVideoFileInfoReader _videoFileInfoReader; - 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, - IConfigService configService, - Logger logger) - { - _diskProvider = diskProvider; - _mediaFileService = mediaFileService; - _videoFileInfoReader = videoFileInfoReader; - _configService = configService; - _logger = logger; - } - - private void UpdateMediaInfo(Series series, List mediaFiles) - { - foreach (var mediaFile in mediaFiles) - { - var path = Path.Combine(series.Path, mediaFile.RelativePath); - - if (!_diskProvider.FileExists(path)) - { - _logger.Debug("Can't update MediaInfo because '{0}' does not exist", path); - continue; - } - - mediaFile.MediaInfo = _videoFileInfoReader.GetMediaInfo(path); - - if (mediaFile.MediaInfo != null) - { - mediaFile.MediaInfo.SchemaRevision = CURRENT_MEDIA_INFO_SCHEMA_REVISION; - _mediaFileService.Update(mediaFile); - _logger.Debug("Updated MediaInfo for '{0}'", path); - } - } - } - - public void Handle(SeriesScannedEvent message) - { - if (!_configService.EnableMediaInfo) - { - _logger.Debug("MediaInfo is disabled"); - return; - } - - var allMediaFiles = _mediaFileService.GetFilesBySeries(message.Series.Id); - var filteredMediaFiles = allMediaFiles.Where(c => c.MediaInfo == null || c.MediaInfo.SchemaRevision < CURRENT_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 deleted file mode 100644 index a1bf6aa86..000000000 --- a/src/NzbDrone.Core/MediaFiles/MediaInfo/VideoFileInfoReader.cs +++ /dev/null @@ -1,211 +0,0 @@ -using System; -using System.Globalization; -using System.IO; -using NLog; -using NzbDrone.Common.Disk; - -namespace NzbDrone.Core.MediaFiles.MediaInfo -{ - public interface IVideoFileInfoReader - { - MediaInfoModel GetMediaInfo(string filename); - TimeSpan GetRunTime(string filename); - } - - public class VideoFileInfoReader : IVideoFileInfoReader - { - private readonly IDiskProvider _diskProvider; - private readonly Logger _logger; - - - public VideoFileInfoReader(IDiskProvider diskProvider, Logger logger) - { - _diskProvider = diskProvider; - _logger = logger; - } - - - public MediaInfoModel GetMediaInfo(string filename) - { - if (!_diskProvider.FileExists(filename)) - { - throw new FileNotFoundException("Media file does not exist: " + filename); - } - - MediaInfo mediaInfo = null; - - try - { - mediaInfo = new MediaInfo(); - _logger.Debug("Getting media info from {0}", filename); - - if (filename.ToLower().EndsWith(".ts")) - { - mediaInfo.Option("ParseSpeed", "0.3"); - } - else - { - mediaInfo.Option("ParseSpeed", "0.0"); - } - - int open; - - using (var stream = _diskProvider.OpenReadStream(filename)) - { - open = mediaInfo.Open(stream); - } - - if (open != 0) - { - int audioRuntime; - int videoRuntime; - int generalRuntime; - - //Runtime - int.TryParse(mediaInfo.Get(StreamKind.Video, 0, "PlayTime"), out videoRuntime); - int.TryParse(mediaInfo.Get(StreamKind.Audio, 0, "PlayTime"), out audioRuntime); - int.TryParse(mediaInfo.Get(StreamKind.General, 0, "PlayTime"), out generalRuntime); - - if (audioRuntime == 0 && videoRuntime == 0 && generalRuntime == 0) - { - mediaInfo.Option("ParseSpeed", "1.0"); - - using (var stream = _diskProvider.OpenReadStream(filename)) - { - open = mediaInfo.Open(stream); - } - } - } - - if (open != 0) - { - int width; - int height; - int videoBitRate; - int audioBitRate; - int audioRuntime; - int videoRuntime; - int generalRuntime; - int streamCount; - int audioChannels; - int videoBitDepth; - decimal videoFrameRate; - - string subtitles = mediaInfo.Get(StreamKind.General, 0, "Text_Language_List"); - string scanType = mediaInfo.Get(StreamKind.Video, 0, "ScanType"); - int.TryParse(mediaInfo.Get(StreamKind.Video, 0, "Width"), out width); - int.TryParse(mediaInfo.Get(StreamKind.Video, 0, "Height"), out height); - int.TryParse(mediaInfo.Get(StreamKind.Video, 0, "BitRate"), out videoBitRate); - decimal.TryParse(mediaInfo.Get(StreamKind.Video, 0, "FrameRate"), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out videoFrameRate); - int.TryParse(mediaInfo.Get(StreamKind.Video, 0, "BitDepth"), out videoBitDepth); - - //Runtime - int.TryParse(mediaInfo.Get(StreamKind.Video, 0, "PlayTime"), out videoRuntime); - 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); - } - - 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); - } - - 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); - } - - int.TryParse(audioChannelsStr, out audioChannels); - var mediaInfoModel = new MediaInfoModel - { - VideoCodec = mediaInfo.Get(StreamKind.Video, 0, "Codec/String"), - VideoBitrate = videoBitRate, - VideoBitDepth = videoBitDepth, - Height = height, - Width = width, - AudioFormat = mediaInfo.Get(StreamKind.Audio, 0, "Format"), - 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 - }; - - return mediaInfoModel; - } - else - { - _logger.Warn("Unable to open media info from file: " + filename); - } - } - catch (DllNotFoundException ex) - { - _logger.Error(ex, "mediainfo is required but was not found"); - } - catch (Exception ex) - { - _logger.Error(ex, "Unable to parse media info from file: {0}", filename); - } - finally - { - mediaInfo?.Close(); - } - - return null; - } - - public TimeSpan GetRunTime(string filename) - { - var info = GetMediaInfo(filename); - - if (info == null) - { - return new TimeSpan(); - } - - return info.RunTime; - } - - private TimeSpan GetBestRuntime(int audio, int video, int general) - { - if (video == 0) - { - if (audio == 0) - { - return TimeSpan.FromMilliseconds(general); - } - - return TimeSpan.FromMilliseconds(audio); - } - - return TimeSpan.FromMilliseconds(video); - } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/MediaInfoFormatter.cs b/src/NzbDrone.Core/MediaFiles/MediaInfoFormatter.cs new file mode 100644 index 000000000..11bb0cf6e --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/MediaInfoFormatter.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using NLog; +using NLog.Fluent; +using NzbDrone.Common.Instrumentation; +using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles +{ + public static class MediaInfoFormatter + { + private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(MediaInfoFormatter)); + + public static string FormatAudioBitrate(MediaInfoModel mediaInfo) + { + return mediaInfo.AudioBitrate + " kbps"; + } + + public static string FormatAudioBitsPerSample(MediaInfoModel mediaInfo) + { + if (mediaInfo.AudioBits == 0) + { + return string.Empty; + } + + return mediaInfo.AudioBits + "bit"; + } + + public static string FormatAudioSampleRate(MediaInfoModel mediaInfo) + { + return $"{(double)mediaInfo.AudioSampleRate / 1000:0.#}kHz"; + } + + public static decimal FormatAudioChannels(MediaInfoModel mediaInfo) + { + return mediaInfo.AudioChannels; + } + + public static readonly Dictionary CodecNames = new Dictionary { + {Codec.MP1, "MP1"}, + {Codec.MP2, "MP2"}, + {Codec.AAC, "AAC"}, + {Codec.AACVBR, "AAC"}, + {Codec.ALAC, "ALAC"}, + {Codec.APE, "APE"}, + {Codec.FLAC, "FLAC"}, + {Codec.MP3CBR, "MP3"}, + {Codec.MP3VBR, "MP3"}, + {Codec.OGG, "OGG"}, + {Codec.OPUS, "OPUS"}, + {Codec.WAV, "PCM"}, + {Codec.WAVPACK, "WavPack"}, + {Codec.WMA, "WMA"} + }; + + public static string FormatAudioCodec(MediaInfoModel mediaInfo) + { + var codec = QualityParser.ParseCodec(mediaInfo.AudioFormat, null); + + if (CodecNames.ContainsKey(codec)) + { + return CodecNames[codec]; + } + else + { + Logger.Debug() + .Message("Unknown audio format: '{0}'.", string.Join(", ", mediaInfo.AudioFormat)) + .WriteSentryWarn("UnknownAudioFormat", mediaInfo.AudioFormat) + .Write(); + + return "Unknown"; + } + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/RecycleBinProvider.cs b/src/NzbDrone.Core/MediaFiles/RecycleBinProvider.cs index 520fbf676..94e2d13ba 100644 --- a/src/NzbDrone.Core/MediaFiles/RecycleBinProvider.cs +++ b/src/NzbDrone.Core/MediaFiles/RecycleBinProvider.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using NLog; using NzbDrone.Common.Disk; @@ -8,19 +8,19 @@ using NzbDrone.Core.Configuration; using NzbDrone.Core.MediaFiles.Commands; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Tv.Events; +using NzbDrone.Core.Music.Events; namespace NzbDrone.Core.MediaFiles { public interface IRecycleBinProvider { void DeleteFolder(string path); - void DeleteFile(string path); + void DeleteFile(string path, string subfolder = ""); void Empty(); void Cleanup(); } - public class RecycleBinProvider : IHandleAsync, IExecute, IRecycleBinProvider + public class RecycleBinProvider : IExecute, IRecycleBinProvider { private readonly IDiskTransferService _diskTransferService; private readonly IDiskProvider _diskProvider; @@ -62,18 +62,14 @@ 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); } } - public void DeleteFile(string path) + public void DeleteFile(string path, string subfolder = "") { _logger.Debug("Attempting to send '{0}' to recycling bin", path); var recyclingBin = _configService.RecycleBin; @@ -94,7 +90,10 @@ namespace NzbDrone.Core.MediaFiles else { var fileInfo = new FileInfo(path); - var destination = Path.Combine(recyclingBin, fileInfo.Name); + var destinationFolder = Path.Combine(recyclingBin, subfolder); + var destination = Path.Combine(destinationFolder, fileInfo.Name); + + _diskProvider.CreateFolder(destinationFolder); var index = 1; while (_diskProvider.FileExists(destination)) @@ -102,11 +101,11 @@ namespace NzbDrone.Core.MediaFiles index++; if (fileInfo.Extension.IsNullOrWhiteSpace()) { - destination = Path.Combine(recyclingBin, fileInfo.Name + "_" + index); + destination = Path.Combine(destinationFolder, fileInfo.Name + "_" + index); } else { - destination = Path.Combine(recyclingBin, Path.GetFileNameWithoutExtension(fileInfo.Name) + "_" + index + fileInfo.Extension); + destination = Path.Combine(destinationFolder, Path.GetFileNameWithoutExtension(fileInfo.Name) + "_" + index + fileInfo.Extension); } } @@ -120,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); } @@ -162,7 +157,15 @@ namespace NzbDrone.Core.MediaFiles return; } - _logger.Info("Removing items older than 7 days from the recycling bin"); + var cleanupDays = _configService.RecycleBinCleanupDays; + + if (cleanupDays == 0) + { + _logger.Info("Automatic cleanup of Recycle Bin is disabled"); + return; + } + + _logger.Info("Removing items older than {0} days from the recycling bin", cleanupDays); foreach (var folder in _diskProvider.GetDirectories(_configService.RecycleBin)) { @@ -177,7 +180,7 @@ namespace NzbDrone.Core.MediaFiles foreach (var file in _diskProvider.GetFiles(_configService.RecycleBin, SearchOption.TopDirectoryOnly)) { - if (_diskProvider.FileGetLastWrite(file).AddDays(7) > DateTime.UtcNow) + if (_diskProvider.FileGetLastWrite(file).AddDays(cleanupDays) > DateTime.UtcNow) { _logger.Debug("File hasn't expired yet, skipping: {0}", file); continue; @@ -189,14 +192,18 @@ 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) + { + } + catch (UnauthorizedAccessException) { - if (_diskProvider.FolderExists(message.Series.Path)) - { - DeleteFolder(message.Series.Path); - } } } diff --git a/src/NzbDrone.Core/MediaFiles/RenameEpisodeFilePreview.cs b/src/NzbDrone.Core/MediaFiles/RenameEpisodeFilePreview.cs deleted file mode 100644 index 72ba4b247..000000000 --- a/src/NzbDrone.Core/MediaFiles/RenameEpisodeFilePreview.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Collections.Generic; - -namespace NzbDrone.Core.MediaFiles -{ - public class RenameEpisodeFilePreview - { - public int SeriesId { get; set; } - public int SeasonNumber { get; set; } - public List EpisodeNumbers { get; set; } - public int EpisodeFileId { get; set; } - public string ExistingPath { get; set; } - public string NewPath { get; set; } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/RenameEpisodeFileService.cs b/src/NzbDrone.Core/MediaFiles/RenameEpisodeFileService.cs deleted file mode 100644 index 4cfe84b37..000000000 --- a/src/NzbDrone.Core/MediaFiles/RenameEpisodeFileService.cs +++ /dev/null @@ -1,171 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using NLog; -using NzbDrone.Common.Disk; -using NzbDrone.Common.Extensions; -using NzbDrone.Common.Instrumentation.Extensions; -using NzbDrone.Core.MediaFiles.Commands; -using NzbDrone.Core.MediaFiles.Events; -using NzbDrone.Core.Messaging.Commands; -using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Organizer; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.MediaFiles -{ - public interface IRenameEpisodeFileService - { - List GetRenamePreviews(int seriesId); - List GetRenamePreviews(int seriesId, int seasonNumber); - } - - public class RenameEpisodeFileService : IRenameEpisodeFileService, - IExecute, - IExecute - { - private readonly ISeriesService _seriesService; - private readonly IMediaFileService _mediaFileService; - private readonly IMoveEpisodeFiles _episodeFileMover; - private readonly IEventAggregator _eventAggregator; - private readonly IEpisodeService _episodeService; - private readonly IBuildFileNames _filenameBuilder; - private readonly IDiskProvider _diskProvider; - private readonly Logger _logger; - - public RenameEpisodeFileService(ISeriesService seriesService, - IMediaFileService mediaFileService, - IMoveEpisodeFiles episodeFileMover, - IEventAggregator eventAggregator, - IEpisodeService episodeService, - IBuildFileNames filenameBuilder, - IDiskProvider diskProvider, - Logger logger) - { - _seriesService = seriesService; - _mediaFileService = mediaFileService; - _episodeFileMover = episodeFileMover; - _eventAggregator = eventAggregator; - _episodeService = episodeService; - _filenameBuilder = filenameBuilder; - _diskProvider = diskProvider; - _logger = logger; - } - - public List GetRenamePreviews(int seriesId) - { - var series = _seriesService.GetSeries(seriesId); - var episodes = _episodeService.GetEpisodeBySeries(seriesId); - var files = _mediaFileService.GetFilesBySeries(seriesId); - - return GetPreviews(series, episodes, files) - .OrderByDescending(e => e.SeasonNumber) - .ThenByDescending(e => e.EpisodeNumbers.First()) - .ToList(); - } - - public List GetRenamePreviews(int seriesId, int seasonNumber) - { - var series = _seriesService.GetSeries(seriesId); - var episodes = _episodeService.GetEpisodesBySeason(seriesId, seasonNumber); - var files = _mediaFileService.GetFilesBySeason(seriesId, seasonNumber); - - return GetPreviews(series, episodes, files) - .OrderByDescending(e => e.EpisodeNumbers.First()).ToList(); - } - - private IEnumerable GetPreviews(Series series, List episodes, List files) - { - foreach (var f in files) - { - var file = f; - var episodesInFile = episodes.Where(e => e.EpisodeFileId == file.Id).ToList(); - var episodeFilePath = Path.Combine(series.Path, file.RelativePath); - - if (!episodesInFile.Any()) - { - _logger.Warn("File ({0}) is not linked to any episodes", episodeFilePath); - continue; - } - - var seasonNumber = episodesInFile.First().SeasonNumber; - var newName = _filenameBuilder.BuildFileName(episodesInFile, series, file); - var newPath = _filenameBuilder.BuildFilePath(series, seasonNumber, newName, Path.GetExtension(episodeFilePath)); - - if (!episodeFilePath.PathEquals(newPath, StringComparison.Ordinal)) - { - yield return new RenameEpisodeFilePreview - { - SeriesId = series.Id, - SeasonNumber = seasonNumber, - EpisodeNumbers = episodesInFile.Select(e => e.EpisodeNumber).ToList(), - EpisodeFileId = file.Id, - ExistingPath = file.RelativePath, - NewPath = series.Path.GetRelativePath(newPath) - }; - } - } - } - - private void RenameFiles(List episodeFiles, Series series) - { - var renamed = new List(); - - foreach (var episodeFile in episodeFiles) - { - var episodeFilePath = Path.Combine(series.Path, episodeFile.RelativePath); - - try - { - _logger.Debug("Renaming episode file: {0}", episodeFile); - _episodeFileMover.MoveEpisodeFile(episodeFile, series); - - _mediaFileService.Update(episodeFile); - renamed.Add(episodeFile); - - _logger.Debug("Renamed episode file: {0}", episodeFile); - } - catch (SameFilenameException ex) - { - _logger.Debug("File not renamed, source and destination are the same: {0}", ex.Filename); - } - catch (Exception ex) - { - _logger.Error(ex, "Failed to rename file {0}", episodeFilePath); - } - } - - if (renamed.Any()) - { - _diskProvider.RemoveEmptySubfolders(series.Path); - - _eventAggregator.PublishEvent(new SeriesRenamedEvent(series)); - } - } - - public void Execute(RenameFilesCommand message) - { - var series = _seriesService.GetSeries(message.SeriesId); - var episodeFiles = _mediaFileService.Get(message.Files); - - _logger.ProgressInfo("Renaming {0} files for {1}", episodeFiles.Count, series.Title); - RenameFiles(episodeFiles, series); - _logger.ProgressInfo("Selected episode files renamed for {0}", series.Title); - } - - public void Execute(RenameSeriesCommand message) - { - _logger.Debug("Renaming all files for selected series"); - var seriesToRename = _seriesService.GetSeries(message.SeriesIds); - - foreach (var series in seriesToRename) - { - var episodeFiles = _mediaFileService.GetFilesBySeries(series.Id); - _logger.ProgressInfo("Renaming all files in series: {0}", series.Title); - RenameFiles(episodeFiles, series); - _logger.ProgressInfo("All episode files renamed for {0}", series.Title); - } - } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/RenameTrackFilePreview.cs b/src/NzbDrone.Core/MediaFiles/RenameTrackFilePreview.cs new file mode 100644 index 000000000..25bfe75f7 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/RenameTrackFilePreview.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.MediaFiles +{ + public class RenameTrackFilePreview + { + public int ArtistId { get; set; } + public int AlbumId { get; set; } + public List TrackNumbers { get; set; } + public int TrackFileId { get; set; } + public string ExistingPath { get; set; } + public string NewPath { get; set; } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/RenameTrackFileService.cs b/src/NzbDrone.Core/MediaFiles/RenameTrackFileService.cs new file mode 100644 index 000000000..0e5fa572f --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/RenameTrackFileService.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Core.MediaFiles.Commands; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.MediaFiles +{ + public interface IRenameTrackFileService + { + List GetRenamePreviews(int artistId); + List GetRenamePreviews(int artistId, int albumId); + } + + public class RenameTrackFileService : IRenameTrackFileService, IExecute, IExecute + { + private readonly IArtistService _artistService; + private readonly IAlbumService _albumService; + private readonly IMediaFileService _mediaFileService; + private readonly IMoveTrackFiles _trackFileMover; + private readonly IEventAggregator _eventAggregator; + private readonly ITrackService _trackService; + private readonly IBuildFileNames _filenameBuilder; + private readonly IDiskProvider _diskProvider; + private readonly Logger _logger; + + public RenameTrackFileService(IArtistService artistService, + IAlbumService albumService, + IMediaFileService mediaFileService, + IMoveTrackFiles trackFileMover, + IEventAggregator eventAggregator, + ITrackService trackService, + IBuildFileNames filenameBuilder, + IDiskProvider diskProvider, + Logger logger) + { + _artistService = artistService; + _albumService = albumService; + _mediaFileService = mediaFileService; + _trackFileMover = trackFileMover; + _eventAggregator = eventAggregator; + _trackService = trackService; + _filenameBuilder = filenameBuilder; + _diskProvider = diskProvider; + _logger = logger; + } + + public List GetRenamePreviews(int artistId) + { + + var artist = _artistService.GetArtist(artistId); + var tracks = _trackService.GetTracksByArtist(artistId); + var files = _mediaFileService.GetFilesByArtist(artistId); + + return GetPreviews(artist, tracks, files) + .OrderByDescending(e => e.AlbumId) + .ThenByDescending(e => e.TrackNumbers.First()) + .ToList(); + } + + public List GetRenamePreviews(int artistId, int albumId) + { + + var artist = _artistService.GetArtist(artistId); + var tracks = _trackService.GetTracksByAlbum(albumId); + var files = _mediaFileService.GetFilesByAlbum(albumId); + + return GetPreviews(artist, tracks, files) + .OrderByDescending(e => e.TrackNumbers.First()).ToList(); + } + + private IEnumerable GetPreviews(Artist artist, List tracks, List files) + { + foreach (var f in files) + { + var file = f; + var tracksInFile = tracks.Where(e => e.TrackFileId == file.Id).ToList(); + var trackFilePath = file.Path; + + if (!tracksInFile.Any()) + { + _logger.Warn("File ({0}) is not linked to any tracks", trackFilePath); + continue; + } + + var album = _albumService.GetAlbum(tracksInFile.First().AlbumId); + + var newName = _filenameBuilder.BuildTrackFileName(tracksInFile, artist, album, file); + var newPath = _filenameBuilder.BuildTrackFilePath(artist, album, newName, Path.GetExtension(trackFilePath)); + + if (!trackFilePath.PathEquals(newPath, StringComparison.Ordinal)) + { + yield return new RenameTrackFilePreview + { + ArtistId = artist.Id, + AlbumId = album.Id, + TrackNumbers = tracksInFile.Select(e => e.AbsoluteTrackNumber).ToList(), + TrackFileId = file.Id, + ExistingPath = artist.Path.GetRelativePath(file.Path), + NewPath = artist.Path.GetRelativePath(newPath) + }; + } + } + } + + private void RenameFiles(List trackFiles, Artist artist) + { + var renamed = new List(); + + foreach (var trackFile in trackFiles) + { + var trackFilePath = trackFile.Path; + + try + { + _logger.Debug("Renaming track file: {0}", trackFile); + _trackFileMover.MoveTrackFile(trackFile, artist); + + _mediaFileService.Update(trackFile); + renamed.Add(trackFile); + + _logger.Debug("Renamed track file: {0}", trackFile); + + _eventAggregator.PublishEvent(new TrackFileRenamedEvent(artist, trackFile, trackFilePath)); + } + catch (SameFilenameException ex) + { + _logger.Debug("File not renamed, source and destination are the same: {0}", ex.Filename); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to rename file {0}", trackFilePath); + } + } + + if (renamed.Any()) + { + _eventAggregator.PublishEvent(new ArtistRenamedEvent(artist)); + + _logger.Debug("Removing Empty Subfolders from: {0}", artist.Path); + _diskProvider.RemoveEmptySubfolders(artist.Path); + } + } + + public void Execute(RenameFilesCommand message) + { + + var artist = _artistService.GetArtist(message.ArtistId); + var trackFiles = _mediaFileService.Get(message.Files); + + _logger.ProgressInfo("Renaming {0} files for {1}", trackFiles.Count, artist.Name); + RenameFiles(trackFiles, artist); + _logger.ProgressInfo("Selected track files renamed for {0}", artist.Name); + } + + public void Execute(RenameArtistCommand message) + { + + _logger.Debug("Renaming all files for selected artist"); + var artistToRename = _artistService.GetArtists(message.ArtistIds); + + foreach (var artist in artistToRename) + { + var trackFiles = _mediaFileService.GetFilesByArtist(artist.Id); + _logger.ProgressInfo("Renaming all files in artist: {0}", artist.Name); + RenameFiles(trackFiles, artist); + _logger.ProgressInfo("All track files renamed for {0}", artist.Name); + } + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/RetagTrackFilePreview.cs b/src/NzbDrone.Core/MediaFiles/RetagTrackFilePreview.cs new file mode 100644 index 000000000..f9026ffd7 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/RetagTrackFilePreview.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace NzbDrone.Core.MediaFiles +{ + public class RetagTrackFilePreview + { + public int ArtistId { get; set; } + public int AlbumId { get; set; } + public List TrackNumbers { get; set; } + public int TrackFileId { get; set; } + public string RelativePath { get; set; } + public Dictionary> Changes { get; set; } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackFile.cs b/src/NzbDrone.Core/MediaFiles/TrackFile.cs new file mode 100644 index 000000000..2b353541f --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackFile.cs @@ -0,0 +1,50 @@ +using Marr.Data; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Music; +using NzbDrone.Core.Qualities; +using System; +using System.Collections.Generic; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles +{ + public class TrackFile : ModelBase + { + // these are model properties + public string Path { get; set; } + public long Size { get; set; } + public DateTime Modified { get; set; } + public DateTime DateAdded { get; set; } + public string SceneName { get; set; } + public string ReleaseGroup { get; set; } + public QualityModel Quality { get; set; } + public MediaInfoModel MediaInfo { get; set; } + public int AlbumId { get; set; } + + // These are queried from the database + public LazyLoaded> Tracks { get; set; } + public LazyLoaded Artist { get; set; } + public LazyLoaded Album { get; set; } + + public override string ToString() + { + return string.Format("[{0}] {1}", Id, Path); + } + + public string GetSceneOrFileName() + { + if (SceneName.IsNotNullOrWhiteSpace()) + { + return SceneName; + } + + if (Path.IsNotNullOrWhiteSpace()) + { + return System.IO.Path.GetFileName(Path); + } + + return string.Empty; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackFileMoveResult.cs b/src/NzbDrone.Core/MediaFiles/TrackFileMoveResult.cs new file mode 100644 index 000000000..8b4b3d854 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackFileMoveResult.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.MediaFiles +{ + public class TrackFileMoveResult + { + public TrackFileMoveResult() + { + OldFiles = new List(); + } + + public TrackFile TrackFile { get; set; } + public List OldFiles { get; set; } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackFileMovingService.cs b/src/NzbDrone.Core/MediaFiles/TrackFileMovingService.cs new file mode 100644 index 000000000..17e9d285f --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackFileMovingService.cs @@ -0,0 +1,224 @@ +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.EnsureThat; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.MediaFiles.TrackImport; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Music; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Parser.Model; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.MediaFiles +{ + public interface IMoveTrackFiles + { + TrackFile MoveTrackFile(TrackFile trackFile, Artist artist); + TrackFile MoveTrackFile(TrackFile trackFile, LocalTrack localTrack); + TrackFile CopyTrackFile(TrackFile trackFile, LocalTrack localTrack); + } + + public class TrackFileMovingService : IMoveTrackFiles + { + private readonly ITrackService _trackService; + private readonly IAlbumService _albumService; + private readonly IUpdateTrackFileService _updateTrackFileService; + private readonly IBuildFileNames _buildFileNames; + private readonly IDiskTransferService _diskTransferService; + private readonly IDiskProvider _diskProvider; + private readonly IMediaFileAttributeService _mediaFileAttributeService; + private readonly IEventAggregator _eventAggregator; + private readonly IConfigService _configService; + private readonly Logger _logger; + + public TrackFileMovingService(ITrackService trackService, + IAlbumService albumService, + IUpdateTrackFileService updateTrackFileService, + IBuildFileNames buildFileNames, + IDiskTransferService diskTransferService, + IDiskProvider diskProvider, + IMediaFileAttributeService mediaFileAttributeService, + IEventAggregator eventAggregator, + IConfigService configService, + Logger logger) + { + _trackService = trackService; + _albumService = albumService; + _updateTrackFileService = updateTrackFileService; + _buildFileNames = buildFileNames; + _diskTransferService = diskTransferService; + _diskProvider = diskProvider; + _mediaFileAttributeService = mediaFileAttributeService; + _eventAggregator = eventAggregator; + _configService = configService; + _logger = logger; + } + + public TrackFile MoveTrackFile(TrackFile trackFile, Artist artist) + { + + var tracks = _trackService.GetTracksByFileId(trackFile.Id); + var album = _albumService.GetAlbum(trackFile.AlbumId); + var newFileName = _buildFileNames.BuildTrackFileName(tracks, artist, album, trackFile); + var filePath = _buildFileNames.BuildTrackFilePath(artist, album, newFileName, Path.GetExtension(trackFile.Path)); + + EnsureTrackFolder(trackFile, artist, album, filePath); + + _logger.Debug("Renaming track file: {0} to {1}", trackFile, filePath); + + return TransferFile(trackFile, artist, tracks, filePath, TransferMode.Move); + } + + public TrackFile MoveTrackFile(TrackFile trackFile, LocalTrack localTrack) + { + + var newFileName = _buildFileNames.BuildTrackFileName(localTrack.Tracks, localTrack.Artist, localTrack.Album, trackFile); + var filePath = _buildFileNames.BuildTrackFilePath(localTrack.Artist, localTrack.Album, newFileName, Path.GetExtension(localTrack.Path)); + + EnsureTrackFolder(trackFile, localTrack, filePath); + + _logger.Debug("Moving track file: {0} to {1}", trackFile.Path, filePath); + + return TransferFile(trackFile, localTrack.Artist, localTrack.Tracks, filePath, TransferMode.Move); + } + + public TrackFile CopyTrackFile(TrackFile trackFile, LocalTrack localTrack) + { + var newFileName = _buildFileNames.BuildTrackFileName(localTrack.Tracks, localTrack.Artist, localTrack.Album, trackFile); + var filePath = _buildFileNames.BuildTrackFilePath(localTrack.Artist, localTrack.Album, newFileName, Path.GetExtension(localTrack.Path)); + + EnsureTrackFolder(trackFile, localTrack, filePath); + + if (_configService.CopyUsingHardlinks) + { + _logger.Debug("Hardlinking track file: {0} to {1}", trackFile.Path, filePath); + return TransferFile(trackFile, localTrack.Artist, localTrack.Tracks, filePath, TransferMode.HardLinkOrCopy); + } + + _logger.Debug("Copying track file: {0} to {1}", trackFile.Path, filePath); + return TransferFile(trackFile, localTrack.Artist, localTrack.Tracks, filePath, TransferMode.Copy); + } + + private TrackFile TransferFile(TrackFile trackFile, Artist artist, List tracks, string destinationFilePath, TransferMode mode) + { + + Ensure.That(trackFile, () => trackFile).IsNotNull(); + Ensure.That(artist, () => artist).IsNotNull(); + Ensure.That(destinationFilePath, () => destinationFilePath).IsValidPath(); + + var trackFilePath = trackFile.Path; + + if (!_diskProvider.FileExists(trackFilePath)) + { + throw new FileNotFoundException("Track file path does not exist", trackFilePath); + } + + if (trackFilePath == destinationFilePath) + { + throw new SameFilenameException("File not moved, source and destination are the same", trackFilePath); + } + + _diskTransferService.TransferFile(trackFilePath, destinationFilePath, mode); + + trackFile.Path = destinationFilePath; + + _updateTrackFileService.ChangeFileDateForFile(trackFile, artist, tracks); + + try + { + _mediaFileAttributeService.SetFolderLastWriteTime(artist.Path, trackFile.DateAdded); + + if (artist.AlbumFolder) + { + var albumFolder = Path.GetDirectoryName(destinationFilePath); + + _mediaFileAttributeService.SetFolderLastWriteTime(albumFolder, trackFile.DateAdded); + } + } + + catch (Exception ex) + { + _logger.Warn(ex, "Unable to set last write time"); + } + + _mediaFileAttributeService.SetFilePermissions(destinationFilePath); + + return trackFile; + } + + private void EnsureTrackFolder(TrackFile trackFile, LocalTrack localTrack, string filePath) + { + EnsureTrackFolder(trackFile, localTrack.Artist, localTrack.Album, filePath); + } + + private void EnsureTrackFolder(TrackFile trackFile, Artist artist, Album album, string filePath) + { + var trackFolder = Path.GetDirectoryName(filePath); + var albumFolder = _buildFileNames.BuildAlbumPath(artist, album); + var artistFolder = artist.Path; + var rootFolder = new OsPath(artistFolder).Directory.FullPath; + + if (!_diskProvider.FolderExists(rootFolder)) + { + throw new RootFolderNotFoundException(string.Format("Root folder '{0}' was not found.", rootFolder)); + } + + var changed = false; + var newEvent = new TrackFolderCreatedEvent(artist, trackFile); + + if (!_diskProvider.FolderExists(artistFolder)) + { + CreateFolder(artistFolder); + newEvent.ArtistFolder = artistFolder; + changed = true; + } + + if (artistFolder != albumFolder && !_diskProvider.FolderExists(albumFolder)) + { + CreateFolder(albumFolder); + newEvent.AlbumFolder = albumFolder; + changed = true; + } + + if (albumFolder != trackFolder && !_diskProvider.FolderExists(trackFolder)) + { + CreateFolder(trackFolder); + newEvent.TrackFolder = trackFolder; + changed = true; + } + + if (changed) + { + _eventAggregator.PublishEvent(newEvent); + } + } + + private void CreateFolder(string directoryName) + { + Ensure.That(directoryName, () => directoryName).IsNotNullOrWhiteSpace(); + + var parentFolder = new OsPath(directoryName).Directory.FullPath; + if (!_diskProvider.FolderExists(parentFolder)) + { + CreateFolder(parentFolder); + } + + try + { + _diskProvider.CreateFolder(directoryName); + } + catch (IOException ex) + { + _logger.Error(ex, "Unable to create directory: {0}", directoryName); + } + + _mediaFileAttributeService.SetFolderPermissions(directoryName); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/AggregationFailedException.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/AggregationFailedException.cs new file mode 100644 index 000000000..a3c4a2682 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/AggregationFailedException.cs @@ -0,0 +1,24 @@ +using System; +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.MediaFiles.TrackImport.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/TrackImport/Aggregation/AggregationService.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/AggregationService.cs new file mode 100644 index 000000000..4659c73a2 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/AggregationService.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.IO; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Core.MediaFiles.TrackImport.Aggregation.Aggregators; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Aggregation +{ + public interface IAugmentingService + { + LocalTrack Augment(LocalTrack localTrack, bool otherFiles); + LocalAlbumRelease Augment(LocalAlbumRelease localAlbum); + } + + public class AugmentingService : IAugmentingService + { + private readonly IEnumerable> _trackAugmenters; + private readonly IEnumerable> _albumAugmenters; + private readonly IDiskProvider _diskProvider; + private readonly Logger _logger; + + public AugmentingService(IEnumerable> trackAugmenters, + IEnumerable> albumAugmenters, + IDiskProvider diskProvider, + Logger logger) + { + _trackAugmenters = trackAugmenters; + _albumAugmenters = albumAugmenters; + _diskProvider = diskProvider; + _logger = logger; + } + + public LocalTrack Augment(LocalTrack localTrack, bool otherFiles) + { + if (localTrack.DownloadClientAlbumInfo == null && + localTrack.FolderTrackInfo == null && + localTrack.FileTrackInfo == null) + { + if (MediaFileExtensions.Extensions.Contains(Path.GetExtension(localTrack.Path))) + { + throw new AugmentingFailedException("Unable to parse track info from path: {0}", localTrack.Path); + } + } + + localTrack.Size = _diskProvider.GetFileSize(localTrack.Path); + + foreach (var augmenter in _trackAugmenters) + { + try + { + augmenter.Aggregate(localTrack, otherFiles); + } + catch (Exception ex) + { + _logger.Warn(ex, ex.Message); + } + } + + return localTrack; + } + + public LocalAlbumRelease Augment(LocalAlbumRelease localAlbum) + { + foreach (var augmenter in _albumAugmenters) + { + try + { + augmenter.Aggregate(localAlbum, false); + } + catch (Exception ex) + { + _logger.Warn(ex, ex.Message); + } + + } + + return localAlbum; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/AggregateFilenameInfo.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/AggregateFilenameInfo.cs new file mode 100644 index 000000000..b09d2d630 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/AggregateFilenameInfo.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Aggregation.Aggregators +{ + public class AggregateFilenameInfo : IAggregate + { + private readonly Logger _logger; + + private static readonly List> charsAndSeps = new List> { + Tuple.Create(@"a-z0-9,\(\)\.&'’\s", @"\s_-"), + Tuple.Create(@"a-z0-9,\(\)\.\&'’_", @"\s-") + }; + + private static Regex[] Patterns(string chars, string sep) + { + var sep1 = $@"(?[{sep}]+)"; + var sepn = @"\k"; + var artist = $@"(?[{chars}]+)"; + var track = $@"(?\d+)"; + var title = $@"(?[{chars}]+)"; + var tag = $@"(?<tag>[{chars}]+)"; + + return new [] { + new Regex($@"^{track}{sep1}{artist}{sepn}{title}{sepn}{tag}$", RegexOptions.IgnoreCase), + new Regex($@"^{track}{sep1}{artist}{sepn}{tag}{sepn}{title}$", RegexOptions.IgnoreCase), + new Regex($@"^{track}{sep1}{artist}{sepn}{title}$", RegexOptions.IgnoreCase), + + new Regex($@"^{artist}{sep1}{tag}{sepn}{track}{sepn}{title}$", RegexOptions.IgnoreCase), + new Regex($@"^{artist}{sep1}{track}{sepn}{title}{sepn}{tag}$", RegexOptions.IgnoreCase), + new Regex($@"^{artist}{sep1}{track}{sepn}{title}$", RegexOptions.IgnoreCase), + + new Regex($@"^{artist}{sep1}{title}{sepn}{tag}$", RegexOptions.IgnoreCase), + new Regex($@"^{artist}{sep1}{tag}{sepn}{title}$", RegexOptions.IgnoreCase), + new Regex($@"^{artist}{sep1}{title}$", RegexOptions.IgnoreCase), + + new Regex($@"^{track}{sep1}{title}$", RegexOptions.IgnoreCase), + new Regex($@"^{track}{sep1}{tag}{sepn}{title}$", RegexOptions.IgnoreCase), + new Regex($@"^{track}{sep1}{title}{sepn}{tag}$", RegexOptions.IgnoreCase), + + new Regex($@"^{title}$", RegexOptions.IgnoreCase), + }; + } + + public AggregateFilenameInfo(Logger logger) + { + _logger = logger; + } + + public LocalAlbumRelease Aggregate(LocalAlbumRelease release, bool others) + { + var tracks = release.LocalTracks; + if (tracks.Count(x => x.FileTrackInfo.Title.IsNullOrWhiteSpace()) > 0 + || tracks.Count(x => x.FileTrackInfo.TrackNumbers.First() == 0) > 0 + || tracks.Count(x => x.FileTrackInfo.DiscNumber == 0) > 0) + { + _logger.Debug("Missing data in tags, trying filename augmentation"); + foreach (var charSep in charsAndSeps) + { + foreach (var pattern in Patterns(charSep.Item1, charSep.Item2)) + { + var matches = AllMatches(tracks, pattern); + if (matches != null) + { + ApplyMatches(matches, pattern); + } + } + } + } + + return release; + } + + private Dictionary<LocalTrack, Match> AllMatches(List<LocalTrack> tracks, Regex pattern) + { + var matches = new Dictionary<LocalTrack, Match>(); + foreach (var track in tracks) + { + var filename = Path.GetFileNameWithoutExtension(track.Path).RemoveAccent(); + var match = pattern.Match(filename); + _logger.Trace("Matching '{0}' against regex {1}", filename, pattern); + if (match.Success && match.Groups[0].Success) + { + matches[track] = match; + } + else + { + return null; + } + } + return matches; + } + + private bool EqualFields(IEnumerable<Match> matches, string field) + { + return matches.Select(x => x.Groups[field].Value).Distinct().Count() == 1; + } + + private void ApplyMatches(Dictionary<LocalTrack, Match> matches, Regex pattern) + { + _logger.Debug("Got filename match with regex {0}", pattern); + + var keys = pattern.GetGroupNames(); + var someMatch = matches.First().Value; + + // only proceed if the 'tag' field is equal across all filenames + if (keys.Contains("tag") && !EqualFields(matches.Values, "tag")) + { + _logger.Trace("Abort - 'tag' varies between matches"); + return; + } + + // Given both an "artist" and "title" field, assume that one is + // *actually* the artist, which must be uniform, and use the other + // for the title. This, of course, won't work for VA albums. + string titleField; + string artist; + if (keys.Contains("artist")) + { + if (EqualFields(matches.Values, "artist")) + { + artist = someMatch.Groups["artist"].Value.Trim(); + titleField = "title"; + } + else if (EqualFields(matches.Values, "title")) + { + artist = someMatch.Groups["title"].Value.Trim(); + titleField = "artist"; + } + else + { + _logger.Trace("Abort - both artist and title vary between matches"); + // both vary, abort + return; + } + + _logger.Debug("Got artist from filename: {0}", artist); + + foreach (var track in matches.Keys) + { + if (track.FileTrackInfo.ArtistTitle.IsNullOrWhiteSpace()) + { + track.FileTrackInfo.ArtistTitle = artist; + } + } + } + else + { + // no artist - remaining field is the title + titleField = "title"; + } + + // Apply the title and track + foreach (var track in matches.Keys) + { + if (track.FileTrackInfo.Title.IsNullOrWhiteSpace()) + { + var title = matches[track].Groups[titleField].Value.Trim(); + _logger.Debug("Got title from filename: {0}", title); + track.FileTrackInfo.Title = title; + } + + var trackNums = track.FileTrackInfo.TrackNumbers; + if (keys.Contains("track") && (trackNums.Count() == 0 || trackNums.First() == 0)) + { + var tracknum = Convert.ToInt32(matches[track].Groups["track"].Value); + if (tracknum > 100) + { + track.FileTrackInfo.DiscNumber = tracknum / 100; + _logger.Debug("Got disc number from filename: {0}", tracknum / 100); + tracknum = tracknum % 100; + } + _logger.Debug("Got track number from filename: {0}", tracknum); + track.FileTrackInfo.TrackNumbers = new [] { tracknum }; + } + } + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/AggregateQuality.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/AggregateQuality.cs new file mode 100644 index 000000000..b926a77c8 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/AggregateQuality.cs @@ -0,0 +1,25 @@ +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Aggregation.Aggregators +{ + public class AggregateQuality : IAggregate<LocalTrack> + { + public LocalTrack Aggregate(LocalTrack localTrack, bool otherFiles) + { + var quality = localTrack.FileTrackInfo?.Quality; + + if (quality == null) + { + quality = localTrack.FolderTrackInfo?.Quality; + } + + if (quality == null) + { + quality = localTrack.DownloadClientAlbumInfo?.Quality; + } + + localTrack.Quality = quality; + return localTrack; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/AggregateReleaseGroup.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/AggregateReleaseGroup.cs new file mode 100644 index 000000000..2bc2a80c9 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/AggregateReleaseGroup.cs @@ -0,0 +1,27 @@ +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Aggregation.Aggregators +{ + public class AggregateReleaseGroup : IAggregate<LocalTrack> + { + public LocalTrack Aggregate(LocalTrack localTrack, bool otherFiles) + { + var releaseGroup = localTrack.DownloadClientAlbumInfo?.ReleaseGroup; + + if (releaseGroup.IsNullOrWhiteSpace()) + { + releaseGroup = localTrack.FolderTrackInfo?.ReleaseGroup; + } + + if (releaseGroup.IsNullOrWhiteSpace()) + { + releaseGroup = localTrack.FileTrackInfo?.ReleaseGroup; + } + + localTrack.ReleaseGroup = releaseGroup; + + return localTrack; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/IAggregateLocalTrack.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/IAggregateLocalTrack.cs new file mode 100644 index 000000000..c9a219b5a --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/IAggregateLocalTrack.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.MediaFiles.TrackImport.Aggregation.Aggregators +{ + public interface IAggregate<T> + { + T Aggregate(T item, bool otherFiles); + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/IImportDecisionEngineSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/IImportDecisionEngineSpecification.cs new file mode 100644 index 000000000..9c4703435 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/IImportDecisionEngineSpecification.cs @@ -0,0 +1,9 @@ +using NzbDrone.Core.DecisionEngine; + +namespace NzbDrone.Core.MediaFiles.TrackImport +{ + public interface IImportDecisionEngineSpecification<T> + { + Decision IsSatisfiedBy(T item); + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/CandidateAlbumRelease.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/CandidateAlbumRelease.cs new file mode 100644 index 000000000..bfeb739fb --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/CandidateAlbumRelease.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Identification +{ + public class CandidateAlbumRelease + { + public CandidateAlbumRelease() + { + } + + public CandidateAlbumRelease(AlbumRelease release) + { + AlbumRelease = release; + ExistingTracks = new List<TrackFile>(); + } + + public AlbumRelease AlbumRelease { get; set; } + public List<TrackFile> ExistingTracks { get; set; } + } +} + diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/Distance.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/Distance.cs new file mode 100644 index 000000000..565fa260f --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/Distance.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Identification +{ + public class Distance + { + private Dictionary<string, List<double>> penalties; + + // from beets default config + private static readonly Dictionary<string, double> weights = new Dictionary<string, double> + { + { "source", 2.0 }, + { "artist", 3.0 }, + { "album", 3.0 }, + { "media_count", 1.0 }, + { "media_format", 1.0 }, + { "year", 1.0 }, + { "country", 0.5 }, + { "label", 0.5 }, + { "catalog_number", 0.5 }, + { "album_disambiguation", 0.5 }, + { "album_id", 5.0 }, + { "tracks", 2.0 }, + { "missing_tracks", 0.6 }, + { "unmatched_tracks", 0.9 }, + { "track_title", 3.0 }, + { "track_artist", 2.0 }, + { "track_index", 1.0 }, + { "track_length", 2.0 }, + { "recording_id", 10.0 }, + }; + + public Distance() + { + penalties = new Dictionary<string, List<double>>(15); + } + + public Dictionary<string, List<double>> Penalties => penalties; + public string Reasons => penalties.Count(x => x.Value.Max() > 0.0) > 0 ? "[" + string.Join(", ", Penalties.Where(x => x.Value.Max() > 0.0).Select(x => x.Key.Replace('_', ' '))) + "]" : string.Empty; + + private double MaxDistance(Dictionary<string, List<double>> penalties) + { + return penalties.Select(x => x.Value.Count * weights[x.Key]).Sum(); + } + + public double MaxDistance() + { + return MaxDistance(penalties); + } + + private double RawDistance(Dictionary<string, List<double>> penalties) + { + return penalties.Select(x => x.Value.Sum() * weights[x.Key]).Sum(); + } + + public double RawDistance() + { + return RawDistance(penalties); + } + + private double NormalizedDistance(Dictionary<string, List<double>> penalties) + { + var max = MaxDistance(penalties); + return max > 0 ? RawDistance(penalties) / max : 0; + } + + public double NormalizedDistance() + { + return NormalizedDistance(penalties); + } + + public double NormalizedDistanceExcluding(List<string> keys) + { + return NormalizedDistance(penalties.Where(x => !keys.Contains(x.Key)).ToDictionary(y => y.Key, y => y.Value)); + } + + public void Add(string key, double dist) + { + if (penalties.ContainsKey(key)) + { + penalties[key].Add(dist); + } + else + { + penalties[key] = new List<double> { dist }; + } + } + + public void AddRatio(string key, double value, double target) + { + // Adds a distance penalty for value as a ratio of target + // value is between 0 and target + var dist = target > 0 ? Math.Max(Math.Min(value, target), 0.0) / target : 0.0; + Add(key, dist); + } + + public void AddNumber(string key, int value, int target) + { + var diff = Math.Abs(value - target); + if (diff > 0) + { + for (int i = 0; i < diff; i++) + { + Add(key, 1.0); + } + } + else + { + Add(key, 0.0); + } + } + + private static string Clean(string input) + { + char[] arr = input.ToLower().RemoveAccent().ToCharArray(); + + arr = Array.FindAll<char>(arr, c => (char.IsLetterOrDigit(c))); + + return new string(arr); + } + + public void AddString(string key, string value, string target) + { + // Adds a penaltly based on the distance between value and target + var cleanValue = Clean(value); + var cleanTarget = Clean(target); + + if (cleanValue.IsNullOrWhiteSpace() && cleanTarget.IsNotNullOrWhiteSpace()) + { + Add(key, 1.0); + } + else if (cleanValue.IsNullOrWhiteSpace() && cleanTarget.IsNullOrWhiteSpace()) + { + Add(key, 0.0); + } + else + { + Add(key, 1.0 - cleanValue.LevenshteinCoefficient(cleanTarget)); + } + } + + public void AddBool(string key, bool expr) + { + Add(key, expr ? 1.0 : 0.0); + } + + public void AddEquality<T>(string key, T value, List<T> options) where T : IEquatable<T> + { + Add(key, options.Contains(value) ? 0.0 : 1.0); + } + + public void AddPriority<T>(string key, T value, List<T> options) where T : IEquatable<T> + { + var unit = 1.0 / (options.Count > 0 ? (double) options.Count : 1.0); + var index = options.IndexOf(value); + if (index == -1) + { + Add(key, 1.0); + } + else + { + Add(key, index * unit); + } + } + + public void AddPriority<T>(string key, List<T> values, List<T> options) where T : IEquatable<T> + { + for(int i = 0; i < options.Count; i++) + { + if (values.Contains(options[i])) + { + Add(key, i / (double)options.Count); + return; + } + } + + Add(key, 1.0); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/IdentificationService.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/IdentificationService.cs new file mode 100644 index 000000000..2b56e7d11 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/IdentificationService.cs @@ -0,0 +1,693 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Newtonsoft.Json; +using NLog; +using NzbDrone.Common; +using NzbDrone.Common.EnsureThat; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.MediaFiles.TrackImport.Aggregation; +using NzbDrone.Core.Music; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Identification +{ + public interface IIdentificationService + { + List<LocalAlbumRelease> Identify(List<LocalTrack> localTracks, Artist artist, Album album, AlbumRelease release, bool newDownload, bool singleRelease, bool includeExisting); + } + + public class IdentificationService : IIdentificationService + { + private readonly IArtistService _artistService; + private readonly IAlbumService _albumService; + private readonly IReleaseService _releaseService; + private readonly ITrackService _trackService; + private readonly ITrackGroupingService _trackGroupingService; + private readonly IFingerprintingService _fingerprintingService; + private readonly IAudioTagService _audioTagService; + private readonly IAugmentingService _augmentingService; + private readonly IMediaFileService _mediaFileService; + private readonly IConfigService _configService; + private readonly Logger _logger; + + public IdentificationService(IArtistService artistService, + IAlbumService albumService, + IReleaseService releaseService, + ITrackService trackService, + ITrackGroupingService trackGroupingService, + IFingerprintingService fingerprintingService, + IAudioTagService audioTagService, + IAugmentingService augmentingService, + IMediaFileService mediaFileService, + IConfigService configService, + Logger logger) + { + _artistService = artistService; + _albumService = albumService; + _releaseService = releaseService; + _trackService = trackService; + _trackGroupingService = trackGroupingService; + _fingerprintingService = fingerprintingService; + _audioTagService = audioTagService; + _augmentingService = augmentingService; + _mediaFileService = mediaFileService; + _configService = configService; + _logger = logger; + } + + private readonly List<IsoCountry> preferredCountries = new List<string> { + "United States", + "United Kingdom", + "Europe", + "[Worldwide]" + }.Select(x => IsoCountries.Find(x)).ToList(); + + private readonly List<string> VariousArtistNames = new List<string> { "various artists", "various", "va", "unknown" }; + private readonly List<string> VariousArtistIds = new List<string> { "89ad4ac3-39f7-470e-963a-56509c546377" }; + + private void LogTestCaseOutput(List<LocalTrack> localTracks, Artist artist, Album album, AlbumRelease release, bool newDownload, bool singleRelease) + { + var trackData = localTracks.Select(x => new BasicLocalTrack { + Path = x.Path, + FileTrackInfo = x.FileTrackInfo + }); + var options = new IdTestCase { + ExpectedMusicBrainzReleaseIds = new List<string> {"expected-id-1", "expected-id-2", "..."}, + LibraryArtists = new List<ArtistTestCase> { + new ArtistTestCase { + Artist = artist?.Metadata.Value.ForeignArtistId ?? "expected-artist-id (dev: don't forget to add metadata profile)", + MetadataProfile = artist?.MetadataProfile.Value + } + }, + Artist = artist?.Metadata.Value.ForeignArtistId, + Album = album?.ForeignAlbumId, + Release = release?.ForeignReleaseId, + NewDownload = newDownload, + SingleRelease = singleRelease, + Tracks = trackData.ToList() + }; + + var SerializerSettings = Json.GetSerializerSettings(); + SerializerSettings.Formatting = Formatting.None; + + var output = JsonConvert.SerializeObject(options, SerializerSettings); + + _logger.Debug($"*** IdentificationService TestCaseGenerator ***\n{output}"); + } + + public List<LocalAlbumRelease> Identify(List<LocalTrack> localTracks, Artist artist, Album album, AlbumRelease release, bool newDownload, bool singleRelease, bool includeExisting) + { + // 1 group localTracks so that we think they represent a single release + // 2 get candidates given specified artist, album and release. Candidates can include extra files already on disk. + // 3 find best candidate + // 4 If best candidate worse than threshold, try fingerprinting + + var watch = System.Diagnostics.Stopwatch.StartNew(); + + _logger.Debug("Starting track identification"); + LogTestCaseOutput(localTracks, artist, album, release, newDownload, singleRelease); + + List<LocalAlbumRelease> releases = null; + if (singleRelease) + { + releases = new List<LocalAlbumRelease>{ new LocalAlbumRelease(localTracks) }; + } + else + { + releases = _trackGroupingService.GroupTracks(localTracks); + } + + _logger.Debug($"Sorted {localTracks.Count} tracks into {releases.Count} releases in {watch.ElapsedMilliseconds}ms"); + + foreach (var localRelease in releases) + { + try + { + _augmentingService.Augment(localRelease); + } + catch (AugmentingFailedException) + { + _logger.Warn($"Augmentation failed for {localRelease}"); + } + IdentifyRelease(localRelease, artist, album, release, newDownload, includeExisting); + } + + watch.Stop(); + + _logger.Debug($"Track identification for {localTracks.Count} tracks took {watch.ElapsedMilliseconds}ms"); + + return releases; + } + + private bool FingerprintingAllowed(bool newDownload) + { + if (_configService.AllowFingerprinting == AllowFingerprinting.Never || + (_configService.AllowFingerprinting == AllowFingerprinting.NewFiles && !newDownload)) + { + return false; + } + + return true; + } + + private bool ShouldFingerprint(LocalAlbumRelease localAlbumRelease) + { + var worstTrackMatchDist = localAlbumRelease.TrackMapping?.Mapping + .OrderByDescending(x => x.Value.Item2.NormalizedDistance()) + .First() + .Value.Item2.NormalizedDistance() ?? 1.0; + + if (localAlbumRelease.Distance.NormalizedDistance() > 0.15 || + localAlbumRelease.TrackMapping.LocalExtra.Any() || + localAlbumRelease.TrackMapping.MBExtra.Any() || + worstTrackMatchDist > 0.40) + { + return true; + } + + return false; + } + + private List<LocalTrack> ToLocalTrack(IEnumerable<TrackFile> trackfiles, LocalAlbumRelease localRelease) + { + var scanned = trackfiles.Join(localRelease.LocalTracks, t => t.Path, l => l.Path, (track, localTrack) => localTrack); + var toScan = trackfiles.ExceptBy(t => t.Path, scanned, s => s.Path, StringComparer.InvariantCulture); + var localTracks = scanned.Concat(toScan.Select(x => new LocalTrack { + Path = x.Path, + Size = x.Size, + Modified = x.Modified, + FileTrackInfo = _audioTagService.ReadTags(x.Path), + ExistingFile = true, + AdditionalFile = true, + Quality = x.Quality + })) + .ToList(); + + localTracks.ForEach(x => _augmentingService.Augment(x, true)); + + return localTracks; + } + + private void IdentifyRelease(LocalAlbumRelease localAlbumRelease, Artist artist, Album album, AlbumRelease release, bool newDownload, bool includeExisting) + { + var watch = System.Diagnostics.Stopwatch.StartNew(); + bool fingerprinted = false; + + var candidateReleases = GetCandidatesFromTags(localAlbumRelease, artist, album, release, includeExisting); + if (candidateReleases.Count == 0 && FingerprintingAllowed(newDownload)) + { + _logger.Debug("No candidates found, fingerprinting"); + _fingerprintingService.Lookup(localAlbumRelease.LocalTracks, 0.5); + fingerprinted = true; + candidateReleases = GetCandidatesFromFingerprint(localAlbumRelease, artist, album, release, includeExisting); + } + + if (candidateReleases.Count == 0) + { + // can't find any candidates even after fingerprinting + return; + } + + _logger.Debug($"Got {candidateReleases.Count} candidates for {localAlbumRelease.LocalTracks.Count} tracks in {watch.ElapsedMilliseconds}ms"); + + var allTracks = _trackService.GetTracksByReleases(candidateReleases.Select(x => x.AlbumRelease.Id).ToList()); + + // convert all the TrackFiles that represent extra files to List<LocalTrack> + var allLocalTracks = ToLocalTrack(candidateReleases + .SelectMany(x => x.ExistingTracks) + .DistinctBy(x => x.Path), localAlbumRelease); + + _logger.Debug($"Retrieved {allTracks.Count} possible tracks in {watch.ElapsedMilliseconds}ms"); + + GetBestRelease(localAlbumRelease, candidateReleases, allTracks, allLocalTracks); + + // If result isn't great and we haven't fingerprinted, try that + // Note that this can improve the match even if we try the same candidates + if (!fingerprinted && FingerprintingAllowed(newDownload) && ShouldFingerprint(localAlbumRelease)) + { + _logger.Debug($"Match not good enough, fingerprinting"); + _fingerprintingService.Lookup(localAlbumRelease.LocalTracks, 0.5); + + // Only include extra possible candidates if neither album nor release are specified + // Will generally be specified as part of manual import + if (album == null && release == null) + { + var extraCandidates = GetCandidatesFromFingerprint(localAlbumRelease, artist, album, release, includeExisting); + var newCandidates = extraCandidates.ExceptBy(x => x.AlbumRelease.Id, candidateReleases, y => y.AlbumRelease.Id, EqualityComparer<int>.Default); + candidateReleases.AddRange(newCandidates); + allTracks.AddRange(_trackService.GetTracksByReleases(newCandidates.Select(x => x.AlbumRelease.Id).ToList())); + allLocalTracks.AddRange(ToLocalTrack(newCandidates + .SelectMany(x => x.ExistingTracks) + .DistinctBy(x => x.Path) + .ExceptBy(x => x.Path, allLocalTracks, x => x.Path, PathEqualityComparer.Instance), + localAlbumRelease)); + } + + // fingerprint all the local files in candidates we might be matching against + _fingerprintingService.Lookup(allLocalTracks, 0.5); + + GetBestRelease(localAlbumRelease, candidateReleases, allTracks, allLocalTracks); + } + + _logger.Debug($"Best release found in {watch.ElapsedMilliseconds}ms"); + + localAlbumRelease.PopulateMatch(); + + _logger.Debug($"IdentifyRelease done in {watch.ElapsedMilliseconds}ms"); + } + + public List<CandidateAlbumRelease> GetCandidatesFromTags(LocalAlbumRelease localAlbumRelease, Artist artist, Album album, AlbumRelease release, bool includeExisting) + { + var watch = System.Diagnostics.Stopwatch.StartNew(); + + // Generally artist, album and release are null. But if they're not then limit candidates appropriately. + // We've tried to make sure that tracks are all for a single release. + + List<CandidateAlbumRelease> candidateReleases; + + // if we have a release ID, use that + AlbumRelease tagMbidRelease = null; + List<CandidateAlbumRelease> tagCandidate = null; + + var releaseIds = localAlbumRelease.LocalTracks.Select(x => x.FileTrackInfo.ReleaseMBId).Distinct().ToList(); + if (releaseIds.Count == 1 && releaseIds[0].IsNotNullOrWhiteSpace()) + { + _logger.Debug("Selecting release from consensus ForeignReleaseId [{0}]", releaseIds[0]); + tagMbidRelease = _releaseService.GetReleaseByForeignReleaseId(releaseIds[0], true); + + if (tagMbidRelease != null) + { + tagCandidate = GetCandidatesByRelease(new List<AlbumRelease> { tagMbidRelease }, includeExisting); + } + } + + if (release != null) + { + // this case overrides the release picked up from the file tags + _logger.Debug("Release {0} [{1} tracks] was forced", release, release.TrackCount); + candidateReleases = GetCandidatesByRelease(new List<AlbumRelease> { release }, includeExisting); + } + else if (album != null) + { + // use the release from file tags if it exists and agrees with the specified album + if (tagMbidRelease?.AlbumId == album.Id) + { + candidateReleases = tagCandidate; + } + else + { + candidateReleases = GetCandidatesByAlbum(localAlbumRelease, album, includeExisting); + } + } + else if (artist != null) + { + // use the release from file tags if it exists and agrees with the specified album + if (tagMbidRelease?.Album.Value.ArtistMetadataId == artist.ArtistMetadataId) + { + candidateReleases = tagCandidate; + } + else + { + candidateReleases = GetCandidatesByArtist(localAlbumRelease, artist, includeExisting); + } + } + else + { + if (tagMbidRelease != null) + { + candidateReleases = tagCandidate; + } + else + { + candidateReleases = GetCandidates(localAlbumRelease, includeExisting); + } + } + + watch.Stop(); + _logger.Debug($"Getting candidates from tags for {localAlbumRelease.LocalTracks.Count} tracks took {watch.ElapsedMilliseconds}ms"); + + // if we haven't got any candidates then try fingerprinting + return candidateReleases; + } + + private List<CandidateAlbumRelease> GetCandidatesByRelease(List<AlbumRelease> releases, bool includeExisting) + { + // get the local tracks on disk for each album + var albumTracks = releases.Select(x => x.AlbumId) + .Distinct() + .ToDictionary(id => id, id => includeExisting ? _mediaFileService.GetFilesByAlbum(id) : new List<TrackFile>()); + + return releases.Select(x => new CandidateAlbumRelease { + AlbumRelease = x, + ExistingTracks = albumTracks[x.AlbumId] + }).ToList(); + } + + private List<CandidateAlbumRelease> GetCandidatesByAlbum(LocalAlbumRelease localAlbumRelease, Album album, bool includeExisting) + { + // sort candidate releases by closest track count so that we stand a chance of + // getting a perfect match early on + return GetCandidatesByRelease(_releaseService.GetReleasesByAlbum(album.Id) + .OrderBy(x => Math.Abs(localAlbumRelease.TrackCount - x.TrackCount)) + .ToList(), includeExisting); + } + + private List<CandidateAlbumRelease> GetCandidatesByArtist(LocalAlbumRelease localAlbumRelease, Artist artist, bool includeExisting) + { + _logger.Trace("Getting candidates for {0}", artist); + var candidateReleases = new List<CandidateAlbumRelease>(); + + var albumTag = MostCommon(localAlbumRelease.LocalTracks.Select(x => x.FileTrackInfo.AlbumTitle)) ?? ""; + if (albumTag.IsNotNullOrWhiteSpace()) + { + var possibleAlbums = _albumService.GetCandidates(artist.ArtistMetadataId, albumTag); + foreach (var album in possibleAlbums) + { + candidateReleases.AddRange(GetCandidatesByAlbum(localAlbumRelease, album, includeExisting)); + } + } + + return candidateReleases; + } + + private List<CandidateAlbumRelease> GetCandidates(LocalAlbumRelease localAlbumRelease, bool includeExisting) + { + // most general version, nothing has been specified. + // get all plausible artists, then all plausible albums, then get releases for each of these. + + // check if it looks like VA. + if (TrackGroupingService.IsVariousArtists(localAlbumRelease.LocalTracks)) + { + throw new NotImplementedException("Various artists not supported"); + } + + var candidateReleases = new List<CandidateAlbumRelease>(); + + var artistTag = MostCommon(localAlbumRelease.LocalTracks.Select(x => x.FileTrackInfo.ArtistTitle)) ?? ""; + if (artistTag.IsNotNullOrWhiteSpace()) + { + var possibleArtists = _artistService.GetCandidates(artistTag); + foreach (var artist in possibleArtists) + { + candidateReleases.AddRange(GetCandidatesByArtist(localAlbumRelease, artist, includeExisting)); + } + } + + return candidateReleases; + } + + public List<CandidateAlbumRelease> GetCandidatesFromFingerprint(LocalAlbumRelease localAlbumRelease, Artist artist, Album album, AlbumRelease release, bool includeExisting) + { + var recordingIds = localAlbumRelease.LocalTracks.Where(x => x.AcoustIdResults != null).SelectMany(x => x.AcoustIdResults).ToList(); + var allReleases = _releaseService.GetReleasesByRecordingIds(recordingIds); + + // make sure releases are consistent with those selected by the user + if (release != null) + { + allReleases = allReleases.Where(x => x.Id == release.Id).ToList(); + } + else if (album != null) + { + allReleases = allReleases.Where(x => x.AlbumId == album.Id).ToList(); + } + else if (artist != null) + { + allReleases = allReleases.Where(x => x.Album.Value.ArtistMetadataId == artist.ArtistMetadataId).ToList(); + } + + return GetCandidatesByRelease(allReleases.Select(x => new { + Release = x, + TrackCount = x.TrackCount, + CommonProportion = x.Tracks.Value.Select(y => y.ForeignRecordingId).Intersect(recordingIds).Count() / localAlbumRelease.TrackCount + }) + .Where(x => x.CommonProportion > 0.6) + .ToList() + .OrderBy(x => Math.Abs(x.TrackCount - localAlbumRelease.TrackCount)) + .ThenByDescending(x => x.CommonProportion) + .Select(x => x.Release) + .Take(10) + .ToList(), includeExisting); + } + + private T MostCommon<T>(IEnumerable<T> items) + { + return items.GroupBy(x => x).OrderByDescending(x => x.Count()).First().Key; + } + + private void GetBestRelease(LocalAlbumRelease localAlbumRelease, List<CandidateAlbumRelease> candidateReleases, List<Track> dbTracks, List<LocalTrack> extraTracksOnDisk) + { + var watch = System.Diagnostics.Stopwatch.StartNew(); + + _logger.Debug("Matching {0} track files against {1} candidates", localAlbumRelease.TrackCount, candidateReleases.Count); + _logger.Trace("Processing files:\n{0}", string.Join("\n", localAlbumRelease.LocalTracks.Select(x => x.Path))); + + double bestDistance = 1.0; + + foreach (var candidateRelease in candidateReleases) + { + var release = candidateRelease.AlbumRelease; + _logger.Debug("Trying Release {0} [{1}, {2} tracks, {3} existing]", release, release.Title, release.TrackCount, candidateRelease.ExistingTracks.Count); + var rwatch = System.Diagnostics.Stopwatch.StartNew(); + + var extraTrackPaths = candidateRelease.ExistingTracks.Select(x => x.Path).ToList(); + var extraTracks = extraTracksOnDisk.Where(x => extraTrackPaths.Contains(x.Path)).ToList(); + var allLocalTracks = localAlbumRelease.LocalTracks.Concat(extraTracks).DistinctBy(x => x.Path).ToList(); + + var mapping = MapReleaseTracks(allLocalTracks, dbTracks.Where(x => x.AlbumReleaseId == release.Id).ToList()); + var distance = AlbumReleaseDistance(allLocalTracks, release, mapping); + var currDistance = distance.NormalizedDistance(); + + rwatch.Stop(); + _logger.Debug("Release {0} [{1} tracks] has distance {2} vs best distance {3} [{4}ms]", + release, release.TrackCount, currDistance, bestDistance, rwatch.ElapsedMilliseconds); + if (currDistance < bestDistance) + { + bestDistance = currDistance; + localAlbumRelease.Distance = distance; + localAlbumRelease.AlbumRelease = release; + localAlbumRelease.ExistingTracks = extraTracks; + localAlbumRelease.TrackMapping = mapping; + if (currDistance == 0.0) + { + break; + } + } + } + + watch.Stop(); + _logger.Debug($"Best release: {localAlbumRelease.AlbumRelease} Distance {localAlbumRelease.Distance.NormalizedDistance()} found in {watch.ElapsedMilliseconds}ms"); + } + + public int GetTotalTrackNumber(Track track, List<Track> allTracks) + { + return track.AbsoluteTrackNumber + allTracks.Count(t => t.MediumNumber < track.MediumNumber); + } + + public TrackMapping MapReleaseTracks(List<LocalTrack> localTracks, List<Track> mbTracks) + { + var distances = new Distance[localTracks.Count, mbTracks.Count]; + var costs = new double[localTracks.Count, mbTracks.Count]; + + for (int col = 0; col < mbTracks.Count; col++) + { + var totalTrackNumber = GetTotalTrackNumber(mbTracks[col], mbTracks); + for (int row = 0; row < localTracks.Count; row++) + { + distances[row, col] = TrackDistance(localTracks[row], mbTracks[col], totalTrackNumber, false); + costs[row, col] = distances[row, col].NormalizedDistance(); + } + } + + var m = new Munkres(costs); + m.Run(); + + var result = new TrackMapping(); + foreach (var pair in m.Solution) + { + result.Mapping.Add(localTracks[pair.Item1], Tuple.Create(mbTracks[pair.Item2], distances[pair.Item1, pair.Item2])); + _logger.Trace("Mapped {0} to {1}, dist: {2}", localTracks[pair.Item1], mbTracks[pair.Item2], costs[pair.Item1, pair.Item2]); + } + + result.LocalExtra = localTracks.Except(result.Mapping.Keys).ToList(); + _logger.Trace($"Unmapped files:\n{string.Join("\n", result.LocalExtra)}"); + + result.MBExtra = mbTracks.Except(result.Mapping.Values.Select(x => x.Item1)).ToList(); + _logger.Trace($"Missing tracks:\n{string.Join("\n", result.MBExtra)}"); + + return result; + } + + private bool TrackIndexIncorrect(LocalTrack localTrack, Track mbTrack, int totalTrackNumber) + { + return localTrack.FileTrackInfo.TrackNumbers[0] != mbTrack.AbsoluteTrackNumber && + localTrack.FileTrackInfo.TrackNumbers[0] != totalTrackNumber; + } + + public Distance TrackDistance(LocalTrack localTrack, Track mbTrack, int totalTrackNumber, bool includeArtist = false) + { + var dist = new Distance(); + + var localLength = localTrack.FileTrackInfo.Duration.TotalSeconds; + var mbLength = mbTrack.Duration / 1000; + var diff = Math.Abs(localLength - mbLength) - 10; + + if (mbLength > 0) + { + dist.AddRatio("track_length", diff, 30); + } + + // musicbrainz never has 'featuring' in the track title + // see https://musicbrainz.org/doc/Style/Artist_Credits + dist.AddString("track_title", localTrack.FileTrackInfo.CleanTitle ?? "", mbTrack.Title); + + if (includeArtist && localTrack.FileTrackInfo.ArtistTitle.IsNotNullOrWhiteSpace() + && !VariousArtistNames.Any(x => x.Equals(localTrack.FileTrackInfo.ArtistTitle, StringComparison.InvariantCultureIgnoreCase))) + { + dist.AddString("track_artist", localTrack.FileTrackInfo.ArtistTitle, mbTrack.ArtistMetadata.Value.Name); + } + + if (localTrack.FileTrackInfo.TrackNumbers.FirstOrDefault() > 0 && mbTrack.AbsoluteTrackNumber > 0) + { + dist.AddBool("track_index", TrackIndexIncorrect(localTrack, mbTrack, totalTrackNumber)); + } + + var recordingId = localTrack.FileTrackInfo.RecordingMBId; + if (recordingId.IsNotNullOrWhiteSpace()) + { + dist.AddBool("recording_id", localTrack.FileTrackInfo.RecordingMBId != mbTrack.ForeignRecordingId && + !mbTrack.OldForeignRecordingIds.Contains(localTrack.FileTrackInfo.RecordingMBId)); + } + + // for fingerprinted files + if (localTrack.AcoustIdResults != null) + { + dist.AddBool("recording_id", !localTrack.AcoustIdResults.Contains(mbTrack.ForeignRecordingId)); + } + + return dist; + } + + public Distance AlbumReleaseDistance(List<LocalTrack> localTracks, AlbumRelease release, TrackMapping mapping) + { + var dist = new Distance(); + + if (!VariousArtistIds.Contains(release.Album.Value.ArtistMetadata.Value.ForeignArtistId)) + { + var artist = MostCommon(localTracks.Select(x => x.FileTrackInfo.ArtistTitle)) ?? ""; + dist.AddString("artist", artist, release.Album.Value.ArtistMetadata.Value.Name); + _logger.Trace("artist: {0} vs {1}; {2}", artist, release.Album.Value.ArtistMetadata.Value.Name, dist.NormalizedDistance()); + } + + var title = MostCommon(localTracks.Select(x => x.FileTrackInfo.AlbumTitle)) ?? ""; + // Use the album title since the differences in release titles can cause confusion and + // aren't always correct in the tags + dist.AddString("album", title, release.Album.Value.Title); + _logger.Trace("album: {0} vs {1}; {2}", title, release.Title, dist.NormalizedDistance()); + + // Number of discs, either as tagged or the max disc number seen + var discCount = MostCommon(localTracks.Select(x => x.FileTrackInfo.DiscCount)); + discCount = discCount != 0 ? discCount : localTracks.Max(x => x.FileTrackInfo.DiscNumber); + if (discCount > 0) + { + dist.AddNumber("media_count", discCount, release.Media.Count); + _logger.Trace("media_count: {0} vs {1}; {2}", discCount, release.Media.Count, dist.NormalizedDistance()); + } + + // Media format + if (release.Media.Select(x => x.Format).Contains("Unknown")) + { + dist.Add("media_format", 1.0); + } + + // Year + var localYear = MostCommon(localTracks.Select(x => x.FileTrackInfo.Year)); + if (localYear > 0 && (release.Album.Value.ReleaseDate.HasValue || release.ReleaseDate.HasValue)) + { + var albumYear = release.Album.Value.ReleaseDate?.Year ?? 0; + var releaseYear = release.ReleaseDate?.Year ?? 0; + if (localYear == albumYear || localYear == releaseYear) + { + dist.Add("year", 0.0); + } + else + { + var remoteYear = albumYear > 0 ? albumYear : releaseYear; + var diff = Math.Abs(localYear - remoteYear); + var diff_max = Math.Abs(DateTime.Now.Year - remoteYear); + dist.AddRatio("year", diff, diff_max); + } + _logger.Trace($"year: {localYear} vs {release.Album.Value.ReleaseDate?.Year} or {release.ReleaseDate?.Year}; {dist.NormalizedDistance()}"); + } + + // If we parsed a country from the files use that, otherwise use our preference + var country = MostCommon(localTracks.Select(x => x.FileTrackInfo.Country)); + if (release.Country.Count > 0) + { + if (country != null) + { + dist.AddEquality("country", country.Name, release.Country); + _logger.Trace("country: {0} vs {1}; {2}", country.Name, string.Join(", ", release.Country), dist.NormalizedDistance()); + } + else if (preferredCountries.Count > 0) + { + dist.AddPriority("country", release.Country, preferredCountries.Select(x => x.Name).ToList()); + _logger.Trace("country priority: {0} vs {1}; {2}", string.Join(", ", preferredCountries.Select(x => x.Name)), string.Join(", ", release.Country), dist.NormalizedDistance()); + } + } + else + { + // full penalty if MusicBrainz release is missing a country + dist.Add("country", 1.0); + } + + var label = MostCommon(localTracks.Select(x => x.FileTrackInfo.Label)); + if (label.IsNotNullOrWhiteSpace()) + { + dist.AddEquality("label", label, release.Label); + _logger.Trace("label: {0} vs {1}; {2}", label, string.Join(", ", release.Label), dist.NormalizedDistance()); + } + + var disambig = MostCommon(localTracks.Select(x => x.FileTrackInfo.Disambiguation)); + if (disambig.IsNotNullOrWhiteSpace()) + { + dist.AddString("album_disambiguation", disambig, release.Disambiguation); + _logger.Trace("album_disambiguation: {0} vs {1}; {2}", disambig, release.Disambiguation, dist.NormalizedDistance()); + } + + var mbAlbumId = MostCommon(localTracks.Select(x => x.FileTrackInfo.ReleaseMBId)); + if (mbAlbumId.IsNotNullOrWhiteSpace()) + { + dist.AddBool("album_id", mbAlbumId != release.ForeignReleaseId && !release.OldForeignReleaseIds.Contains(mbAlbumId)); + _logger.Trace("album_id: {0} vs {1} or {2}; {3}", mbAlbumId, release.ForeignReleaseId, string.Join(", ", release.OldForeignReleaseIds), dist.NormalizedDistance()); + } + + // tracks + foreach (var pair in mapping.Mapping) + { + dist.Add("tracks", pair.Value.Item2.NormalizedDistance()); + } + _logger.Trace("after trackMapping: {0}", dist.NormalizedDistance()); + + // missing tracks + foreach (var track in mapping.MBExtra.Take(localTracks.Count)) + { + dist.Add("missing_tracks", 1.0); + } + _logger.Trace("after missing tracks: {0}", dist.NormalizedDistance()); + + // unmatched tracks + foreach (var track in mapping.LocalExtra.Take(localTracks.Count)) + { + dist.Add("unmatched_tracks", 1.0); + } + _logger.Trace("after unmatched tracks: {0}", dist.NormalizedDistance()); + + return dist; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/IdentificationTestCase.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/IdentificationTestCase.cs new file mode 100644 index 000000000..5ad2aa8e2 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/IdentificationTestCase.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Profiles.Metadata; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Identification +{ + public class BasicLocalTrack + { + public string Path { get; set; } + public ParsedTrackInfo FileTrackInfo { get; set; } + } + + public class ArtistTestCase + { + public string Artist { get; set; } + public MetadataProfile MetadataProfile { get; set; } + } + + public class AcoustIdTestCase + { + public string Path { get; set; } + public List<string> AcoustIdResults { get; set; } + } + + public class IdTestCase + { + public List<string> ExpectedMusicBrainzReleaseIds { get; set; } + public List<ArtistTestCase> LibraryArtists { get; set; } + public string Artist { get; set; } + public string Album { get; set; } + public string Release { get; set; } + public bool NewDownload { get; set; } + public bool SingleRelease { get; set; } + public List<BasicLocalTrack> Tracks { get; set; } + public List<AcoustIdTestCase> Fingerprints { get; set; } + } +} + diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/Munkres.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/Munkres.cs new file mode 100644 index 000000000..651fb21a1 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/Munkres.cs @@ -0,0 +1,487 @@ +/* + The MIT License (MIT) + + Copyright (c) 2000 Robert A. Pilgrim + Murray State University + Dept. of Computer Science & Information Systems + Murray,Kentucky + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + */ + +using System; +using System.Linq; +using System.Collections.Generic; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Identification +{ + public class Munkres + { + private double[,] C; + private readonly double[,] C_orig; + private int[,] M; + private int[,] path; + private int[] RowCover; + private int[] ColCover; + private readonly int n; + private readonly int nrow_orig; + private readonly int ncol_orig; + private int path_count; + private int path_row_0; + private int path_col_0; + private int step; + + public Munkres(double[,] costMatrix) + { + C = PadMatrix(costMatrix); + n = C.GetLength(0); + nrow_orig = costMatrix.GetLength(0); + ncol_orig = costMatrix.GetLength(1); + + C_orig = C.Clone() as double[,]; + RowCover = new int[n]; + ColCover = new int[n]; + M = new int[n, n]; + path = new int[2*n + 1, 2]; + + step = 1; + } + + public int[,] Marked + { + get + { + return M; + } + } + + public List<Tuple<int, int>> Solution + { + get + { + var value = new List<Tuple<int, int>>(); + for (int row = 0; row < nrow_orig; row++) + { + for (int col = 0; col < ncol_orig; col++) + { + if (M[row, col] == 1) + { + value.Add(Tuple.Create(row, col)); + } + } + } + return value; + } + } + + public double Cost + { + get + { + var soln = Solution; + return soln.Select(x => C_orig[x.Item1, x.Item2]).Sum(); + } + } + + private double[,] PadMatrix(double[,] matrix) + { + var newdim = Math.Max(matrix.GetLength(0), matrix.GetLength(1)); + var outp = new double[newdim, newdim]; + for (int row = 0; row < matrix.GetLength(0); row++) + { + for (int col = 0; col < matrix.GetLength(1); col++) + { + outp[row, col] = matrix[row, col]; + } + } + return outp; + } + + //For each row of the cost matrix, find the smallest element and subtract + //it from every element in its row. When finished, Go to Step 2. + private void step_one(ref int step) + { + double min_in_row; + + for (int r = 0; r < n; r++) + { + min_in_row = C[r, 0]; + for (int c = 0; c < n; c++) + { + if (C[r, c] < min_in_row) + { + min_in_row = C[r, c]; + } + } + for (int c = 0; c < n; c++) + { + C[r, c] -= min_in_row; + } + } + step = 2; + } + + //Find a zero (Z) in the resulting matrix. If there is no starred + //zero in its row or column, star Z. Repeat for each element in the + //matrix. Go to Step 3. + private void step_two(ref int step) + { + for (int r = 0; r < n; r++) + { + for (int c = 0; c < n; c++) + { + if (C[r, c] == 0 && RowCover[r] == 0 && ColCover[c] == 0) + { + M[r, c] = 1; + RowCover[r] = 1; + ColCover[c] = 1; + } + } + } + for (int r = 0; r < n; r++) + { + RowCover[r] = 0; + } + for (int c = 0; c < n; c++) + { + ColCover[c] = 0; + } + step = 3; + } + + //Cover each column containing a starred zero. If K columns are covered, + //the starred zeros describe a complete set of unique assignments. In this + //case, Go to DONE, otherwise, Go to Step 4. + private void step_three(ref int step) + { + int colcount; + for (int r = 0; r < n; r++) + { + for (int c = 0; c < n; c++) + { + if (M[r, c] == 1) + { + ColCover[c] = 1; + } + } + } + + colcount = 0; + for (int c = 0; c < n; c++) + { + if (ColCover[c] == 1) + { + colcount += 1; + } + } + if (colcount >= n) + { + step = 7; + } + else + { + step = 4; + } + } + + //methods to support step 4 + private void find_a_zero(ref int row, ref int col) + { + int r = 0; + int c; + bool done; + row = -1; + col = -1; + done = false; + while (!done) + { + c = 0; + while (true) + { + if (C[r, c] == 0 && RowCover[r] == 0 && ColCover[c] == 0) + { + row = r; + col = c; + done = true; + } + c += 1; + if (c >= n || done) + { + break; + } + } + r += 1; + if (r >= n) + { + done = true; + } + } + } + + private bool star_in_row(int row) + { + bool tmp = false; + for (int c = 0; c < n; c++) + { + if (M[row, c] == 1) + { + tmp = true; + } + } + return tmp; + } + + private void find_star_in_row(int row, ref int col) + { + col = -1; + for (int c = 0; c < n; c++) + { + if (M[row, c] == 1) + { + col = c; + } + } + } + + //Find a noncovered zero and prime it. If there is no starred zero + //in the row containing this primed zero, Go to Step 5. Otherwise, + //cover this row and uncover the column containing the starred zero. + //Continue in this manner until there are no uncovered zeros left. + //Save the smallest uncovered value and Go to Step 6. + private void step_four(ref int step) + { + int row = -1; + int col = -1; + bool done; + + done = false; + while (!done) + { + find_a_zero(ref row, ref col); + if (row == -1) + { + done = true; + step = 6; + } + else + { + M[row, col] = 2; + if (star_in_row(row)) + { + find_star_in_row(row, ref col); + RowCover[row] = 1; + ColCover[col] = 0; + } + else + { + done = true; + step = 5; + path_row_0 = row; + path_col_0 = col; + } + } + } + } + + // methods to support step 5 + private void find_star_in_col(int c, ref int r) + { + r = -1; + for (int i = 0; i < n; i++) + { + if (M[i, c] == 1) + { + r = i; + } + } + } + + private void find_prime_in_row(int r, ref int c) + { + for (int j = 0; j < n; j++) + { + if (M[r, j] == 2) + { + c = j; + } + } + } + + private void augment_path() + { + for (int p = 0; p < path_count; p++) + { + if (M[path[p, 0], path[p, 1]] == 1) + { + M[path[p, 0], path[p, 1]] = 0; + } + else + { + M[path[p, 0], path[p, 1]] = 1; + } + } + } + + private void clear_covers() + { + for (int r = 0; r < n; r++) + { + RowCover[r] = 0; + } + for (int c = 0; c < n; c++) + { + ColCover[c] = 0; + } + } + + private void erase_primes() + { + for (int r = 0; r < n; r++) + { + for (int c = 0; c < n; c++) + { + if (M[r, c] == 2) + { + M[r, c] = 0; + } + } + } + } + + + //Construct a series of alternating primed and starred zeros as follows. + //Let Z0 represent the uncovered primed zero found in Step 4. Let Z1 denote + //the starred zero in the column of Z0 (if any). Let Z2 denote the primed zero + //in the row of Z1 (there will always be one). Continue until the series + //terminates at a primed zero that has no starred zero in its column. + //Unstar each starred zero of the series, star each primed zero of the series, + //erase all primes and uncover every line in the matrix. Return to Step 3. + private void step_five(ref int step) + { + bool done; + int r = -1; + int c = -1; + + path_count = 1; + path[path_count - 1, 0] = path_row_0; + path[path_count - 1, 1] = path_col_0; + done = false; + while (!done) + { + find_star_in_col(path[path_count - 1, 1], ref r); + if (r > -1) + { + path_count += 1; + path[path_count - 1, 0] = r; + path[path_count - 1, 1] = path[path_count - 2, 1]; + } + else + { + done = true; + } + if (!done) + { + find_prime_in_row(path[path_count - 1, 0], ref c); + path_count += 1; + path[path_count - 1, 0] = path[path_count - 2, 0]; + path[path_count - 1, 1] = c; + } + } + augment_path(); + clear_covers(); + erase_primes(); + step = 3; + } + + //methods to support step 6 + private void find_smallest(ref double minval) + { + for (int r = 0; r < n; r++) + { + for (int c = 0; c < n; c++) + { + if (RowCover[r] == 0 && ColCover[c] == 0) + { + if (minval > C[r, c]) + { + minval = C[r, c]; + } + } + } + } + } + + //Add the value found in Step 4 to every element of each covered row, and subtract + //it from every element of each uncovered column. Return to Step 4 without + //altering any stars, primes, or covered lines. + private void step_six(ref int step) + { + double minval = double.MaxValue; + find_smallest(ref minval); + for (int r = 0; r < n; r++) + { + for (int c = 0; c < n; c++) + { + if (RowCover[r] == 1) + { + C[r, c] += minval; + } + if (ColCover[c] == 0) + { + C[r, c] -= minval; + } + } + } + step = 4; + } + + public void Run() + { + bool done = false; + while (!done) + { + switch (step) + { + case 1: + step_one(ref step); + break; + case 2: + step_two(ref step); + break; + case 3: + step_three(ref step); + break; + case 4: + step_four(ref step); + break; + case 5: + step_five(ref step); + break; + case 6: + step_six(ref step); + break; + case 7: + done = true; + break; + } + } + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/TrackGroupingService.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/TrackGroupingService.cs new file mode 100644 index 000000000..6878f7dd1 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/TrackGroupingService.cs @@ -0,0 +1,252 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using NLog; +using NzbDrone.Common; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Instrumentation; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Identification +{ + public interface ITrackGroupingService + { + List<LocalAlbumRelease> GroupTracks(List<LocalTrack> localTracks); + } + + public class TrackGroupingService : ITrackGroupingService + { + private static readonly Logger _logger = NzbDroneLogger.GetLogger(typeof(TrackGroupingService)); + + private static readonly List<string> multiDiscMarkers = new List<string> { @"dis[ck]", @"cd" }; + private static readonly string multiDiscPatternFormat = @"^(?<root>.*%s[\W_]*)\d"; + private static readonly List<string> VariousArtistTitles = new List<string> { "", "various artists", "various", "va", "unknown" }; + + public List<LocalAlbumRelease> GroupTracks(List<LocalTrack> localTracks) + { + var releases = new List<LocalAlbumRelease>(); + + // first attempt, assume grouped by folder + var unprocessed = new List<LocalTrack>(); + foreach (var group in GroupTracksByDirectory(localTracks)) + { + var tracks = group.ToList(); + if (LooksLikeSingleRelease(tracks)) + { + releases.Add(new LocalAlbumRelease(tracks)); + } + else + { + unprocessed.AddRange(tracks); + } + } + + // If anything didn't get grouped correctly, try grouping by Album (to pick up VA) + var unprocessed2 = new List<LocalTrack>(); + foreach (var group in unprocessed.GroupBy(x => x.FileTrackInfo.AlbumTitle)) + { + _logger.Debug("Falling back to grouping by album tag"); + var tracks = group.ToList(); + if (LooksLikeSingleRelease(tracks)) + { + releases.Add(new LocalAlbumRelease(tracks)); + } + else + { + unprocessed2.AddRange(tracks); + } + } + + // Finally fall back to grouping by Album/Artist pair + foreach (var group in unprocessed2.GroupBy(x => new { x.FileTrackInfo.ArtistTitle, x.FileTrackInfo.AlbumTitle} )) + { + _logger.Debug("Falling back to grouping by album+artist tag"); + releases.Add(new LocalAlbumRelease(group.ToList())); + } + + return releases; + } + + private static bool HasCommonEntry(IEnumerable<string> values, double threshold, double fuzz) + { + var groups = values.GroupBy(x => x).OrderByDescending(x => x.Count()); + var distinctCount = groups.Count(); + var mostCommonCount = groups.First().Count(); + var mostCommonEntry = groups.First().Key; + var totalCount = values.Count(); + + // merge groups that are close to the most common value + foreach(var group in groups.Skip(1)) + { + if (mostCommonEntry.IsNotNullOrWhiteSpace() && + group.Key.IsNotNullOrWhiteSpace() && + mostCommonEntry.LevenshteinCoefficient(group.Key) > fuzz) + { + distinctCount--; + mostCommonCount += group.Count(); + } + } + + _logger.Trace($"DistinctCount {distinctCount} MostCommonCount {mostCommonCount} TotalCout {totalCount}"); + + if (distinctCount > 1 && + (distinctCount / (double)totalCount > threshold || + mostCommonCount / (double) totalCount < 1 - threshold)) + { + return false; + } + + return true; + } + + public static bool LooksLikeSingleRelease(List<LocalTrack> tracks) + { + // returns true if we think all the tracks belong to a single release + + // artist/album tags must be the same for 75% of tracks, with no more than 25% having different values + // (except in the case of various artists) + + const double albumTagThreshold = 0.25; + const double artistTagThreshold = 0.25; + const double tagFuzz = 0.9; + + // check that any Album/Release MBID is unique + if (tracks.Select(x => x.FileTrackInfo.AlbumMBId).Distinct().Where(x => x.IsNotNullOrWhiteSpace()).Count() > 1 || + tracks.Select(x => x.FileTrackInfo.ReleaseMBId).Distinct().Where(x => x.IsNotNullOrWhiteSpace()).Count() > 1) + { + _logger.Trace("LooksLikeSingleRelease: MBIDs are not unique"); + return false; + } + + // check that there's a common album tag. + var albumTags = tracks.Select(x => x.FileTrackInfo.AlbumTitle); + if (!HasCommonEntry(albumTags, albumTagThreshold, tagFuzz)) + { + _logger.Trace("LooksLikeSingleRelease: No common album tag"); + return false; + } + + // If not various artists, make sure artists are sensible + if (!IsVariousArtists(tracks)) + { + var artistTags = tracks.Select(x => x.FileTrackInfo.ArtistTitle); + if (!HasCommonEntry(artistTags, artistTagThreshold, tagFuzz)) + { + _logger.Trace("LooksLikeSingleRelease: No common artist tag"); + return false; + } + } + + return true; + } + + public static bool IsVariousArtists(List<LocalTrack> tracks) + { + // checks whether most common title is a known VA title + // Also checks whether more than 75% of tracks have a distinct artist and that the most common artist + // is responsible for < 25% of tracks + const double artistTagThreshold = 0.75; + const double tagFuzz = 0.9; + + var artistTags = tracks.Select(x => x.FileTrackInfo.ArtistTitle); + + if (!HasCommonEntry(artistTags, artistTagThreshold, tagFuzz)) + { + return true; + } + + if (VariousArtistTitles.Contains(artistTags.GroupBy(x => x).OrderByDescending(x => x.Count()).First().Key, StringComparer.OrdinalIgnoreCase)) + { + return true; + } + + return false; + } + + private IEnumerable<List<LocalTrack>> GroupTracksByDirectory(List<LocalTrack> tracks) + { + // we want to check for layouts like: + // xx/CD1/1.mp3 + // xx/CD2/1.mp3 + // or + // yy Disc 1/1.mp3 + // yy Disc 2/1.mp3 + // and group them. + + // we only bother doing this for the immediate parent directory. + var trackFolders = tracks.Select(x => Tuple.Create(x, Path.GetDirectoryName(x.Path))); + + var distinctFolders = trackFolders.Select(x => x.Item2).Distinct().ToList(); + distinctFolders.Sort(); + + _logger.Trace("Folders:\n{0}", string.Join("\n", distinctFolders)); + + Regex subdirRegex = null; + var output = new List<LocalTrack>(); + foreach (var folder in distinctFolders) + { + if (subdirRegex != null) + { + if (subdirRegex.IsMatch(folder)) + { + // current folder continues match, so append output + output.AddRange(tracks.Where(x => x.Path.StartsWith(folder))); + continue; + } + } + + // we have finished a multi disc match. yield the previous output + // and check current folder + if (output.Count > 0) + { + _logger.Trace("Yielding from 1:\n{0}", string.Join("\n", output)); + yield return output; + + output = new List<LocalTrack>(); + } + + // reset and put current folder into output + subdirRegex = null; + var currentTracks = trackFolders.Where(x => x.Item2.Equals(folder, DiskProviderBase.PathStringComparison)) + .Select(x => x.Item1); + output.AddRange(currentTracks); + + // check if the start of another multi disc match + foreach (var marker in multiDiscMarkers) + { + // check if this is the first of a multi-disc set of folders + var pattern = multiDiscPatternFormat.Replace("%s", marker); + var multiStartRegex = new Regex(pattern, RegexOptions.IgnoreCase); + + var match = multiStartRegex.Match(folder); + if (match.Success) + { + var subdirPattern = $"^{Regex.Escape(match.Groups["root"].ToString())}\\d+$"; + subdirRegex = new Regex(subdirPattern, RegexOptions.IgnoreCase); + break; + } + } + + if (subdirRegex == null) + { + // not the start of a multi-disc match, yield + _logger.Trace("Yielding from 2:\n{0}", string.Join("\n", output)); + yield return output; + + // reset output + output = new List<LocalTrack>(); + } + } + + // return the final stored output + if (output.Count > 0) + { + _logger.Trace("Yielding final:\n{0}", string.Join("\n", output)); + yield return output; + } + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs new file mode 100644 index 000000000..0c3b7afbb --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs @@ -0,0 +1,304 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Download; +using NzbDrone.Core.Extras; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Music; +using NzbDrone.Core.Music.Events; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Qualities; + +namespace NzbDrone.Core.MediaFiles.TrackImport +{ + public interface IImportApprovedTracks + { + List<ImportResult> Import(List<ImportDecision<LocalTrack>> decisions, bool replaceExisting, DownloadClientItem downloadClientItem = null, ImportMode importMode = ImportMode.Auto); + } + + public class ImportApprovedTracks : IImportApprovedTracks + { + private readonly IUpgradeMediaFiles _trackFileUpgrader; + private readonly IMediaFileService _mediaFileService; + private readonly IAudioTagService _audioTagService; + private readonly ITrackService _trackService; + private readonly IRecycleBinProvider _recycleBinProvider; + private readonly IExtraService _extraService; + private readonly IDiskProvider _diskProvider; + private readonly IReleaseService _releaseService; + private readonly IEventAggregator _eventAggregator; + private readonly Logger _logger; + + public ImportApprovedTracks(IUpgradeMediaFiles trackFileUpgrader, + IMediaFileService mediaFileService, + IAudioTagService audioTagService, + ITrackService trackService, + IRecycleBinProvider recycleBinProvider, + IExtraService extraService, + IDiskProvider diskProvider, + IReleaseService releaseService, + IEventAggregator eventAggregator, + Logger logger) + { + _trackFileUpgrader = trackFileUpgrader; + _mediaFileService = mediaFileService; + _audioTagService = audioTagService; + _trackService = trackService; + _recycleBinProvider = recycleBinProvider; + _extraService = extraService; + _diskProvider = diskProvider; + _releaseService = releaseService; + _eventAggregator = eventAggregator; + _logger = logger; + } + + public List<ImportResult> Import(List<ImportDecision<LocalTrack>> decisions, bool replaceExisting, DownloadClientItem downloadClientItem = null, ImportMode importMode = ImportMode.Auto) + { + var qualifiedImports = decisions.Where(c => c.Approved) + .GroupBy(c => c.Item.Artist.Id, (i, s) => s + .OrderByDescending(c => c.Item.Quality, new QualityModelComparer(s.First().Item.Artist.QualityProfile)) + .ThenByDescending(c => c.Item.Size)) + .SelectMany(c => c) + .ToList(); + + _logger.Debug($"Importing {qualifiedImports.Count} files. replaceExisting: {replaceExisting}"); + + var importResults = new List<ImportResult>(); + var allImportedTrackFiles = new List<TrackFile>(); + var allOldTrackFiles = new List<TrackFile>(); + + var albumDecisions = decisions.Where(e => e.Item.Album != null && e.Approved) + .GroupBy(e => e.Item.Album.Id).ToList(); + + foreach (var albumDecision in albumDecisions) + { + var album = albumDecision.First().Item.Album; + var newRelease = albumDecision.First().Item.Release; + + if (replaceExisting) + { + var artist = albumDecision.First().Item.Artist; + var rootFolder = _diskProvider.GetParentFolder(artist.Path); + var previousFiles = _mediaFileService.GetFilesByAlbum(album.Id); + + _logger.Debug($"Deleting {previousFiles.Count} existing files for {album}"); + + foreach (var previousFile in previousFiles) + { + var subfolder = rootFolder.GetRelativePath(_diskProvider.GetParentFolder(previousFile.Path)); + if (_diskProvider.FileExists(previousFile.Path)) + { + _logger.Debug("Removing existing track file: {0}", previousFile); + _recycleBinProvider.DeleteFile(previousFile.Path, subfolder); + } + _mediaFileService.Delete(previousFile, DeleteMediaFileReason.Upgrade); + } + } + + // set the correct release to be monitored before importing the new files + _logger.Debug("Updating release to {0} [{1} tracks]", newRelease, newRelease.TrackCount); + album.AlbumReleases = _releaseService.SetMonitored(newRelease); + + // Publish album edited event. + // Deliberatly don't put in the old album since we don't want to trigger an ArtistScan. + _eventAggregator.PublishEvent(new AlbumEditedEvent(album, album)); + } + + var filesToAdd = new List<TrackFile>(qualifiedImports.Count); + var albumReleasesDict = new Dictionary<int, List<AlbumRelease>>(albumDecisions.Count); + var trackImportedEvents = new List<TrackImportedEvent>(qualifiedImports.Count); + + foreach (var importDecision in qualifiedImports.OrderBy(e => e.Item.Tracks.Select(track => track.AbsoluteTrackNumber).MinOrDefault()) + .ThenByDescending(e => e.Item.Size)) + { + var localTrack = importDecision.Item; + var oldFiles = new List<TrackFile>(); + + try + { + //check if already imported + if (importResults.SelectMany(r => r.ImportDecision.Item.Tracks) + .Select(e => e.Id) + .Intersect(localTrack.Tracks.Select(e => e.Id)) + .Any()) + { + importResults.Add(new ImportResult(importDecision, "Track has already been imported")); + continue; + } + + // cache album releases and set artist to speed up firing the TrackImported events + // (otherwise they'll be retrieved from the DB for each track) + if (!albumReleasesDict.ContainsKey(localTrack.Album.Id)) + { + albumReleasesDict.Add(localTrack.Album.Id, localTrack.Album.AlbumReleases.Value); + } + if (!localTrack.Album.AlbumReleases.IsLoaded) + { + localTrack.Album.AlbumReleases = albumReleasesDict[localTrack.Album.Id]; + } + localTrack.Album.Artist = localTrack.Artist; + + foreach (var track in localTrack.Tracks) + { + track.Artist = localTrack.Artist; + track.AlbumRelease = localTrack.Release; + track.Album = localTrack.Album; + } + + var trackFile = new TrackFile { + Path = localTrack.Path.CleanFilePath(), + Size = localTrack.Size, + Modified = localTrack.Modified, + DateAdded = DateTime.UtcNow, + ReleaseGroup = localTrack.ReleaseGroup, + Quality = localTrack.Quality, + MediaInfo = localTrack.FileTrackInfo.MediaInfo, + AlbumId = localTrack.Album.Id, + Artist = localTrack.Artist, + Album = localTrack.Album, + Tracks = localTrack.Tracks + }; + + bool copyOnly; + switch (importMode) + { + default: + case ImportMode.Auto: + copyOnly = downloadClientItem != null && !downloadClientItem.CanMoveFiles; + break; + case ImportMode.Move: + copyOnly = false; + break; + case ImportMode.Copy: + copyOnly = true; + break; + } + + if (!localTrack.ExistingFile) + { + trackFile.SceneName = GetSceneReleaseName(downloadClientItem, localTrack); + + var moveResult = _trackFileUpgrader.UpgradeTrackFile(trackFile, localTrack, copyOnly); + oldFiles = moveResult.OldFiles; + } + else + { + // Delete existing files from the DB mapped to this path + var previousFile = _mediaFileService.GetFileWithPath(trackFile.Path); + + if (previousFile != null) + { + _mediaFileService.Delete(previousFile, DeleteMediaFileReason.ManualOverride); + } + + _audioTagService.WriteTags(trackFile, false); + } + + filesToAdd.Add(trackFile); + importResults.Add(new ImportResult(importDecision)); + + if (!localTrack.ExistingFile) + { + _extraService.ImportTrack(localTrack, trackFile, copyOnly); + } + + allImportedTrackFiles.Add(trackFile); + allOldTrackFiles.AddRange(oldFiles); + + // create all the import events here, but we can't publish until the trackfiles have been + // inserted and ids created + trackImportedEvents.Add(new TrackImportedEvent(localTrack, trackFile, oldFiles, !localTrack.ExistingFile, downloadClientItem)); + } + catch (RootFolderNotFoundException e) + { + _logger.Warn(e, "Couldn't import track " + localTrack); + _eventAggregator.PublishEvent(new TrackImportFailedEvent(e, localTrack, !localTrack.ExistingFile, downloadClientItem)); + + importResults.Add(new ImportResult(importDecision, "Failed to import track, Root folder missing.")); + } + catch (DestinationAlreadyExistsException e) + { + _logger.Warn(e, "Couldn't import track " + localTrack); + importResults.Add(new ImportResult(importDecision, "Failed to import track, Destination already exists.")); + } + catch (UnauthorizedAccessException e) + { + _logger.Warn(e, "Couldn't import track " + localTrack); + _eventAggregator.PublishEvent(new TrackImportFailedEvent(e, localTrack, !localTrack.ExistingFile, downloadClientItem)); + + importResults.Add(new ImportResult(importDecision, "Failed to import track, Permissions error")); + } + catch (Exception e) + { + _logger.Warn(e, "Couldn't import track " + localTrack); + importResults.Add(new ImportResult(importDecision, "Failed to import track")); + } + } + + var watch = new System.Diagnostics.Stopwatch(); + watch.Start(); + _mediaFileService.AddMany(filesToAdd); + _logger.Debug($"Inserted new trackfiles in {watch.ElapsedMilliseconds}ms"); + filesToAdd.ForEach(f => f.Tracks.Value.ForEach(t => t.TrackFileId = f.Id)); + _trackService.SetFileIds(filesToAdd.SelectMany(x => x.Tracks.Value).ToList()); + _logger.Debug($"TrackFileIds updated, total {watch.ElapsedMilliseconds}ms"); + + // now that trackfiles have been inserted and ids generated, publish the import events + foreach (var trackImportedEvent in trackImportedEvents) + { + _eventAggregator.PublishEvent(trackImportedEvent); + } + + var albumImports = importResults.Where(e => e.ImportDecision.Item.Album != null) + .GroupBy(e => e.ImportDecision.Item.Album.Id).ToList(); + + foreach (var albumImport in albumImports) + { + var release = albumImport.First().ImportDecision.Item.Release; + var album = albumImport.First().ImportDecision.Item.Album; + var artist = albumImport.First().ImportDecision.Item.Artist; + + if (albumImport.Where(e => e.Errors.Count == 0).ToList().Count > 0 && artist != null && album != null) + { + _eventAggregator.PublishEvent(new AlbumImportedEvent( + artist, + album, + release, + allImportedTrackFiles.Where(s => s.AlbumId == album.Id).ToList(), + allOldTrackFiles.Where(s => s.AlbumId == album.Id).ToList(), replaceExisting, + downloadClientItem)); + } + + } + + //Adding all the rejected decisions + importResults.AddRange(decisions.Where(c => !c.Approved) + .Select(d => new ImportResult(d, d.Rejections.Select(r => r.Reason).ToArray()))); + + return importResults; + } + + private string GetSceneReleaseName(DownloadClientItem downloadClientItem, LocalTrack localTrack) + { + if (downloadClientItem != null) + { + var title = Parser.Parser.RemoveFileExtension(downloadClientItem.Title); + + var parsedTitle = Parser.Parser.ParseAlbumTitle(title); + + if (parsedTitle != null) + { + return title; + } + } + + return null; + } + + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecision.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecision.cs new file mode 100644 index 000000000..36a1df843 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecision.cs @@ -0,0 +1,29 @@ +using NzbDrone.Common.Extensions; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Parser.Model; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.MediaFiles.TrackImport +{ + public class ImportDecision<T> + { + public T Item { get; private set; } + public IList<Rejection> Rejections { get; private set; } + + public bool Approved => Rejections.Empty(); + + public ImportDecision(T localTrack, params Rejection[] rejections) + { + Item = localTrack; + Rejections = rejections.ToList(); + } + + public void Reject(Rejection rejection) + { + Rejections.Add(rejection); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecisionMaker.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecisionMaker.cs new file mode 100644 index 000000000..f2e7c5ad7 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecisionMaker.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Music; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Download; +using NzbDrone.Core.MediaFiles.TrackImport.Aggregation; +using NzbDrone.Core.MediaFiles.TrackImport.Identification; +using System.IO.Abstractions; + +namespace NzbDrone.Core.MediaFiles.TrackImport +{ + public interface IMakeImportDecision + { + List<ImportDecision<LocalTrack>> GetImportDecisions(List<IFileInfo> musicFiles, Artist artist, FilterFilesType filter, bool includeExisting); + List<ImportDecision<LocalTrack>> GetImportDecisions(List<IFileInfo> musicFiles, Artist artist, ParsedTrackInfo folderInfo); + List<ImportDecision<LocalTrack>> GetImportDecisions(List<IFileInfo> musicFiles, Artist artist, Album album, AlbumRelease albumRelease, DownloadClientItem downloadClientItem, ParsedTrackInfo folderInfo, FilterFilesType filter, bool newDownload, bool singleRelease, bool includeExisting); + } + + public class ImportDecisionMaker : IMakeImportDecision + { + private readonly IEnumerable<IImportDecisionEngineSpecification<LocalTrack>> _trackSpecifications; + private readonly IEnumerable<IImportDecisionEngineSpecification<LocalAlbumRelease>> _albumSpecifications; + private readonly IMediaFileService _mediaFileService; + private readonly IAudioTagService _audioTagService; + private readonly IAugmentingService _augmentingService; + private readonly IIdentificationService _identificationService; + private readonly IAlbumService _albumService; + private readonly IReleaseService _releaseService; + private readonly IEventAggregator _eventAggregator; + private readonly IDiskProvider _diskProvider; + private readonly Logger _logger; + + public ImportDecisionMaker(IEnumerable<IImportDecisionEngineSpecification<LocalTrack>> trackSpecifications, + IEnumerable<IImportDecisionEngineSpecification<LocalAlbumRelease>> albumSpecifications, + IMediaFileService mediaFileService, + IAudioTagService audioTagService, + IAugmentingService augmentingService, + IIdentificationService identificationService, + IAlbumService albumService, + IReleaseService releaseService, + IEventAggregator eventAggregator, + IDiskProvider diskProvider, + Logger logger) + { + _trackSpecifications = trackSpecifications; + _albumSpecifications = albumSpecifications; + _mediaFileService = mediaFileService; + _audioTagService = audioTagService; + _augmentingService = augmentingService; + _identificationService = identificationService; + _albumService = albumService; + _releaseService = releaseService; + _eventAggregator = eventAggregator; + _diskProvider = diskProvider; + _logger = logger; + } + + public List<ImportDecision<LocalTrack>> GetImportDecisions(List<IFileInfo> musicFiles, Artist artist, FilterFilesType filter, bool includeExisting) + { + return GetImportDecisions(musicFiles, artist, null, null, null, null, filter, false, false, true); + } + + public List<ImportDecision<LocalTrack>> GetImportDecisions(List<IFileInfo> musicFiles, Artist artist, ParsedTrackInfo folderInfo) + { + return GetImportDecisions(musicFiles, artist, null, null, null, folderInfo, FilterFilesType.None, true, false, false); + } + + public List<ImportDecision<LocalTrack>> GetImportDecisions(List<IFileInfo> musicFiles, Artist artist, Album album, AlbumRelease albumRelease, DownloadClientItem downloadClientItem, ParsedTrackInfo folderInfo, FilterFilesType filter, bool newDownload, bool singleRelease, bool includeExisting) + { + var watch = new System.Diagnostics.Stopwatch(); + watch.Start(); + + var files = filter != FilterFilesType.None && (artist != null) ? _mediaFileService.FilterUnchangedFiles(musicFiles, artist, filter) : musicFiles; + + var localTracks = new List<LocalTrack>(); + var decisions = new List<ImportDecision<LocalTrack>>(); + + _logger.Debug("Analyzing {0}/{1} files.", files.Count, musicFiles.Count); + + if (!files.Any()) + { + return decisions; + } + + ParsedAlbumInfo downloadClientItemInfo = null; + + if (downloadClientItem != null) + { + downloadClientItemInfo = Parser.Parser.ParseAlbumTitle(downloadClientItem.Title); + } + + foreach (var file in files) + { + var localTrack = new LocalTrack + { + Artist = artist, + Album = album, + DownloadClientAlbumInfo = downloadClientItemInfo, + FolderTrackInfo = folderInfo, + Path = file.FullName, + Size = file.Length, + Modified = file.LastWriteTimeUtc, + FileTrackInfo = _audioTagService.ReadTags(file.FullName), + ExistingFile = !newDownload, + AdditionalFile = false + }; + + try + { + // TODO fix otherfiles? + _augmentingService.Augment(localTrack, true); + localTracks.Add(localTrack); + } + catch (AugmentingFailedException) + { + decisions.Add(new ImportDecision<LocalTrack>(localTrack, new Rejection("Unable to parse file"))); + } + catch (Exception e) + { + _logger.Error(e, "Couldn't import file. {0}", localTrack.Path); + + decisions.Add(new ImportDecision<LocalTrack>(localTrack, new Rejection("Unexpected error processing file"))); + } + } + + _logger.Debug($"Tags parsed for {files.Count} files in {watch.ElapsedMilliseconds}ms"); + + var releases = _identificationService.Identify(localTracks, artist, album, albumRelease, newDownload, singleRelease, includeExisting); + + foreach (var release in releases) + { + release.NewDownload = newDownload; + var releaseDecision = GetDecision(release); + + foreach (var localTrack in release.LocalTracks) + { + if (releaseDecision.Approved) + { + decisions.AddIfNotNull(GetDecision(localTrack)); + } + else + { + decisions.Add(new ImportDecision<LocalTrack>(localTrack, releaseDecision.Rejections.ToArray())); + } + } + } + + return decisions; + } + + private ImportDecision<LocalAlbumRelease> GetDecision(LocalAlbumRelease localAlbumRelease) + { + ImportDecision<LocalAlbumRelease> decision = null; + + if (localAlbumRelease.AlbumRelease == null) + { + decision = new ImportDecision<LocalAlbumRelease>(localAlbumRelease, new Rejection($"Couldn't find similar album for {localAlbumRelease}")); + } + else + { + var reasons = _albumSpecifications.Select(c => EvaluateSpec(c, localAlbumRelease)) + .Where(c => c != null); + + decision = new ImportDecision<LocalAlbumRelease>(localAlbumRelease, reasons.ToArray()); + } + + if (decision == null) + { + _logger.Error("Unable to make a decision on {0}", localAlbumRelease); + } + else if (decision.Rejections.Any()) + { + _logger.Debug("Album rejected for the following reasons: {0}", string.Join(", ", decision.Rejections)); + } + else + { + _logger.Debug("Album accepted"); + } + + return decision; + } + + private ImportDecision<LocalTrack> GetDecision(LocalTrack localTrack) + { + ImportDecision<LocalTrack> decision = null; + + if (localTrack.Tracks.Empty()) + { + decision = localTrack.Album != null ? new ImportDecision<LocalTrack>(localTrack, new Rejection($"Couldn't parse track from: {localTrack.FileTrackInfo}")) : + new ImportDecision<LocalTrack>(localTrack, new Rejection($"Couldn't parse album from: {localTrack.FileTrackInfo}")); + } + else + { + var reasons = _trackSpecifications.Select(c => EvaluateSpec(c, localTrack)) + .Where(c => c != null); + + decision = new ImportDecision<LocalTrack>(localTrack, reasons.ToArray()); + } + + if (decision == null) + { + _logger.Error("Unable to make a decision on {0}", localTrack.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 Rejection EvaluateSpec<T>(IImportDecisionEngineSpecification<T> spec, T item) + { + try + { + var result = spec.IsSatisfiedBy(item); + + if (!result.Accepted) + { + return new Rejection(result.Reason); + } + } + catch (Exception e) + { + _logger.Error(e, "Couldn't evaluate decision on {0}", item); + return new Rejection($"{spec.GetType().Name}: {e.Message}"); + } + + return null; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportMode.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportMode.cs new file mode 100644 index 000000000..43236bc86 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportMode.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.MediaFiles.TrackImport +{ + public enum ImportMode + { + Auto = 0, + Move = 1, + Copy = 2 + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportResult.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportResult.cs new file mode 100644 index 000000000..38235a29a --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportResult.cs @@ -0,0 +1,41 @@ +using NzbDrone.Common.EnsureThat; +using NzbDrone.Core.Parser.Model; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.MediaFiles.TrackImport +{ + public class ImportResult + { + public ImportDecision<LocalTrack> ImportDecision { get; private set; } + public List<string> Errors { get; private set; } + + public ImportResultType Result + { + get + { + if (Errors.Any()) + { + if (ImportDecision.Approved) + { + return ImportResultType.Skipped; + } + + return ImportResultType.Rejected; + } + + return ImportResultType.Imported; + } + } + + public ImportResult(ImportDecision<LocalTrack> importDecision, params string[] errors) + { + Ensure.That(importDecision, () => importDecision).IsNotNull(); + + ImportDecision = importDecision; + Errors = errors.ToList(); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportResultType.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportResultType.cs new file mode 100644 index 000000000..e4e3eaef8 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportResultType.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.MediaFiles.TrackImport +{ + public enum ImportResultType + { + Imported, + Rejected, + Skipped + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportCommand.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportCommand.cs new file mode 100644 index 000000000..93e0fc0c1 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportCommand.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Manual +{ + public class ManualImportCommand : Command + { + public List<ManualImportFile> Files { get; set; } + + public override bool SendUpdatesToClient => true; + public override bool RequiresDiskAccess => true; + + public ImportMode ImportMode { get; set; } + public bool ReplaceExistingFiles { get; set; } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportFile.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportFile.cs new file mode 100644 index 000000000..a34d2a8ab --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportFile.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Qualities; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Manual +{ + public class ManualImportFile : IEquatable<ManualImportFile> + { + public string Path { get; set; } + public int ArtistId { get; set; } + public int AlbumId { get; set; } + public int AlbumReleaseId { get; set; } + public List<int> TrackIds { get; set; } + public QualityModel Quality { get; set; } + public string DownloadId { get; set; } + public bool DisableReleaseSwitching { get; set; } + + public bool Equals(ManualImportFile other) + { + if (other == null) + { + return false; + } + return Path.PathEquals(other.Path); + } + public override bool Equals(object obj) + { + if (obj == null) + { + return false; + } + if (obj.GetType() != GetType()) + { + return false; + } + return Path.PathEquals(((ManualImportFile)obj).Path); + } + public override int GetHashCode() + { + return Path != null ? Path.GetHashCode() : 0; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportItem.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportItem.cs new file mode 100644 index 000000000..ef6c04e81 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportItem.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Music; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Manual +{ + public class ManualImportItem : ModelBase + { + public ManualImportItem() + { + Tracks = new List<Track>(); + } + + public string Path { get; set; } + public string RelativePath { get; set; } + public string Name { get; set; } + public long Size { get; set; } + public Artist Artist { get; set; } + public Album Album { get; set; } + public AlbumRelease Release { get; set; } + public List<Track> Tracks { get; set; } + public QualityModel Quality { get; set; } + public string DownloadId { get; set; } + public IEnumerable<Rejection> Rejections { get; set; } + public ParsedTrackInfo Tags { get; set; } + public bool AdditionalFile { get; set; } + public bool ReplaceExistingFiles { get; set; } + public bool DisableReleaseSwitching { get; set; } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs new file mode 100644 index 000000000..4dfbe605d --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs @@ -0,0 +1,324 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; +using System.Linq; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Core.Download; +using NzbDrone.Core.Download.TrackedDownloads; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Music; +using NzbDrone.Common.Crypto; +using NzbDrone.Common; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Manual +{ + public interface IManualImportService + { + List<ManualImportItem> GetMediaFiles(string path, string downloadId, FilterFilesType filter, bool replaceExistingFiles); + List<ManualImportItem> UpdateItems(List<ManualImportItem> item); + } + + public class ManualImportService : IExecute<ManualImportCommand>, IManualImportService + { + private readonly IDiskProvider _diskProvider; + private readonly IParsingService _parsingService; + private readonly IDiskScanService _diskScanService; + private readonly IMakeImportDecision _importDecisionMaker; + private readonly IArtistService _artistService; + private readonly IAlbumService _albumService; + private readonly IReleaseService _releaseService; + private readonly ITrackService _trackService; + private readonly IAudioTagService _audioTagService; + private readonly IImportApprovedTracks _importApprovedTracks; + private readonly ITrackedDownloadService _trackedDownloadService; + private readonly IDownloadedTracksImportService _downloadedTracksImportService; + private readonly IEventAggregator _eventAggregator; + private readonly Logger _logger; + + public ManualImportService(IDiskProvider diskProvider, + IParsingService parsingService, + IDiskScanService diskScanService, + IMakeImportDecision importDecisionMaker, + IArtistService artistService, + IAlbumService albumService, + IReleaseService releaseService, + ITrackService trackService, + IAudioTagService audioTagService, + IImportApprovedTracks importApprovedTracks, + ITrackedDownloadService trackedDownloadService, + IDownloadedTracksImportService downloadedTracksImportService, + IEventAggregator eventAggregator, + Logger logger) + { + _diskProvider = diskProvider; + _parsingService = parsingService; + _diskScanService = diskScanService; + _importDecisionMaker = importDecisionMaker; + _artistService = artistService; + _albumService = albumService; + _releaseService = releaseService; + _trackService = trackService; + _audioTagService = audioTagService; + _importApprovedTracks = importApprovedTracks; + _trackedDownloadService = trackedDownloadService; + _downloadedTracksImportService = downloadedTracksImportService; + _eventAggregator = eventAggregator; + _logger = logger; + } + + public List<ManualImportItem> GetMediaFiles(string path, string downloadId, FilterFilesType filter, bool replaceExistingFiles) + { + if (downloadId.IsNotNullOrWhiteSpace()) + { + var trackedDownload = _trackedDownloadService.Find(downloadId); + + if (trackedDownload == null) + { + return new List<ManualImportItem>(); + } + + path = trackedDownload.DownloadItem.OutputPath.FullPath; + } + + if (!_diskProvider.FolderExists(path)) + { + if (!_diskProvider.FileExists(path)) + { + return new List<ManualImportItem>(); + } + + var decision = _importDecisionMaker.GetImportDecisions(new List<IFileInfo> { _diskProvider.GetFileInfo(path) }, null, null, null, null, null, FilterFilesType.None, true, false, !replaceExistingFiles); + var result = MapItem(decision.First(), Path.GetDirectoryName(path), downloadId, replaceExistingFiles, false); + + return new List<ManualImportItem> { result }; + } + + return ProcessFolder(path, downloadId, filter, replaceExistingFiles); + } + + private List<ManualImportItem> ProcessFolder(string folder, string downloadId, FilterFilesType filter, bool replaceExistingFiles) + { + var directoryInfo = new DirectoryInfo(folder); + var artist = _parsingService.GetArtist(directoryInfo.Name); + + if (artist == null && downloadId.IsNotNullOrWhiteSpace()) + { + var trackedDownload = _trackedDownloadService.Find(downloadId); + artist = trackedDownload.RemoteAlbum?.Artist; + } + + var folderInfo = Parser.Parser.ParseMusicTitle(directoryInfo.Name); + var artistFiles = _diskScanService.GetAudioFiles(folder).ToList(); + var decisions = _importDecisionMaker.GetImportDecisions(artistFiles, artist, null, null, null, folderInfo, filter, true, false, !replaceExistingFiles); + + // paths will be different for new and old files which is why we need to map separately + var newFiles = artistFiles.Join(decisions, + f => f.FullName, + d => d.Item.Path, + (f, d) => new { File = f, Decision = d }, + PathEqualityComparer.Instance); + + var newItems = newFiles.Select(x => MapItem(x.Decision, folder, downloadId, replaceExistingFiles, false)); + var existingDecisions = decisions.Except(newFiles.Select(x => x.Decision)); + var existingItems = existingDecisions.Select(x => MapItem(x, x.Item.Artist.Path, null, replaceExistingFiles, false)); + + return newItems.Concat(existingItems).ToList(); + } + + public List<ManualImportItem> UpdateItems(List<ManualImportItem> items) + { + var replaceExistingFiles = items.All(x => x.ReplaceExistingFiles); + var groupedItems = items.Where(x => !x.AdditionalFile).GroupBy(x => x.Album?.Id); + _logger.Debug($"UpdateItems, {groupedItems.Count()} groups, replaceExisting {replaceExistingFiles}"); + + var result = new List<ManualImportItem>(); + + foreach(var group in groupedItems) + { + _logger.Debug("UpdateItems, group key: {0}", group.Key); + + var disableReleaseSwitching = group.First().DisableReleaseSwitching; + + var decisions = _importDecisionMaker.GetImportDecisions(group.Select(x => _diskProvider.GetFileInfo(x.Path)).ToList(), group.First().Artist, group.First().Album, group.First().Release, null, null, FilterFilesType.None, true, true, !replaceExistingFiles); + + var existingItems = group.Join(decisions, + i => i.Path, + d => d.Item.Path, + (i, d) => new { Item = i, Decision = d }, + PathEqualityComparer.Instance); + + foreach (var pair in existingItems) + { + var item = pair.Item; + var decision = pair.Decision; + + if (decision.Item.Artist != null) + { + item.Artist = decision.Item.Artist; + } + + if (decision.Item.Album != null) + { + item.Album = decision.Item.Album; + item.Release = decision.Item.Release; + } + + if (decision.Item.Tracks.Any()) + { + item.Tracks = decision.Item.Tracks; + } + + item.Rejections = decision.Rejections; + + result.Add(item); + } + + var newDecisions = decisions.Except(existingItems.Select(x => x.Decision)); + result.AddRange(newDecisions.Select(x => MapItem(x, x.Item.Artist.Path, null, replaceExistingFiles, disableReleaseSwitching))); + } + + return result; + } + + private ManualImportItem MapItem(ImportDecision<LocalTrack> decision, string folder, string downloadId, bool replaceExistingFiles, bool disableReleaseSwitching) + { + var item = new ManualImportItem(); + + item.Id = HashConverter.GetHashInt31(decision.Item.Path); + item.Path = decision.Item.Path; + item.RelativePath = folder.GetRelativePath(decision.Item.Path); + item.Name = Path.GetFileNameWithoutExtension(decision.Item.Path); + item.DownloadId = downloadId; + + if (decision.Item.Artist != null) + { + item.Artist = decision.Item.Artist; + } + + if (decision.Item.Album != null) + { + item.Album = decision.Item.Album; + item.Release = decision.Item.Release; + } + + if (decision.Item.Tracks.Any()) + { + item.Tracks = decision.Item.Tracks; + } + + item.Quality = decision.Item.Quality; + item.Size = _diskProvider.GetFileSize(decision.Item.Path); + item.Rejections = decision.Rejections; + item.Tags = decision.Item.FileTrackInfo; + item.AdditionalFile = decision.Item.AdditionalFile; + item.ReplaceExistingFiles = replaceExistingFiles; + item.DisableReleaseSwitching = disableReleaseSwitching; + + return item; + } + + public void Execute(ManualImportCommand message) + { + _logger.ProgressTrace("Manually importing {0} files using mode {1}", message.Files.Count, message.ImportMode); + + var imported = new List<ImportResult>(); + var importedTrackedDownload = new List<ManuallyImportedFile>(); + var albumIds = message.Files.GroupBy(e => e.AlbumId).ToList(); + var fileCount = 0; + + foreach (var importAlbumId in albumIds) + { + var albumImportDecisions = new List<ImportDecision<LocalTrack>>(); + + // turn off anyReleaseOk if specified + if (importAlbumId.First().DisableReleaseSwitching) + { + var album = _albumService.GetAlbum(importAlbumId.First().AlbumId); + album.AnyReleaseOk = false; + _albumService.UpdateAlbum(album); + } + + foreach (var file in importAlbumId) + { + _logger.ProgressTrace("Processing file {0} of {1}", fileCount + 1, message.Files.Count); + + var artist = _artistService.GetArtist(file.ArtistId); + var album = _albumService.GetAlbum(file.AlbumId); + var release = _releaseService.GetRelease(file.AlbumReleaseId); + var tracks = _trackService.GetTracks(file.TrackIds); + var fileTrackInfo = _audioTagService.ReadTags(file.Path) ?? new ParsedTrackInfo(); + var fileInfo = _diskProvider.GetFileInfo(file.Path); + + var localTrack = new LocalTrack + { + ExistingFile = artist.Path.IsParentPath(file.Path), + Tracks = tracks, + FileTrackInfo = fileTrackInfo, + Path = file.Path, + Size = fileInfo.Length, + Modified = fileInfo.LastWriteTimeUtc, + Quality = file.Quality, + Artist = artist, + Album = album, + Release = release + }; + + albumImportDecisions.Add(new ImportDecision<LocalTrack>(localTrack)); + fileCount += 1; + } + + var downloadId = importAlbumId.Select(x => x.DownloadId).FirstOrDefault(x => x.IsNotNullOrWhiteSpace()); + if (downloadId.IsNullOrWhiteSpace()) + { + imported.AddRange(_importApprovedTracks.Import(albumImportDecisions, message.ReplaceExistingFiles, null, message.ImportMode)); + } + else + { + var trackedDownload = _trackedDownloadService.Find(downloadId); + var importResults = _importApprovedTracks.Import(albumImportDecisions, message.ReplaceExistingFiles, trackedDownload.DownloadItem, message.ImportMode); + + imported.AddRange(importResults); + + foreach (var importResult in importResults) + { + importedTrackedDownload.Add(new ManuallyImportedFile + { + TrackedDownload = trackedDownload, + ImportResult = importResult + }); + } + } + } + + _logger.ProgressTrace("Manually imported {0} files", imported.Count); + + foreach (var groupedTrackedDownload in importedTrackedDownload.GroupBy(i => i.TrackedDownload.DownloadItem.DownloadId).ToList()) + { + var trackedDownload = groupedTrackedDownload.First().TrackedDownload; + + if (_diskProvider.FolderExists(trackedDownload.DownloadItem.OutputPath.FullPath)) + { + if (_downloadedTracksImportService.ShouldDeleteFolder( + _diskProvider.GetDirectoryInfo(trackedDownload.DownloadItem.OutputPath.FullPath), + trackedDownload.RemoteAlbum.Artist) && trackedDownload.DownloadItem.CanMoveFiles) + { + _diskProvider.DeleteFolder(trackedDownload.DownloadItem.OutputPath.FullPath, true); + } + } + + if (groupedTrackedDownload.Select(c => c.ImportResult).Count(c => c.Result == ImportResultType.Imported) >= Math.Max(1, trackedDownload.RemoteAlbum.Albums.Count)) + { + trackedDownload.State = TrackedDownloadStage.Imported; + _eventAggregator.PublishEvent(new DownloadCompletedEvent(trackedDownload)); + } + } + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManuallyImportedFile.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManuallyImportedFile.cs new file mode 100644 index 000000000..d5c3b622e --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManuallyImportedFile.cs @@ -0,0 +1,10 @@ +using NzbDrone.Core.Download.TrackedDownloads; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Manual +{ + public class ManuallyImportedFile + { + public TrackedDownload TrackedDownload { get; set; } + public ImportResult ImportResult { get; set; } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/RootFolderNotFoundException.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/RootFolderNotFoundException.cs new file mode 100644 index 000000000..15c3f997c --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/RootFolderNotFoundException.cs @@ -0,0 +1,25 @@ +using System; +using System.IO; +using System.Runtime.Serialization; + +namespace NzbDrone.Core.MediaFiles.TrackImport +{ + 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/TrackImport/Specifications/AlbumUpgradeSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/AlbumUpgradeSpecification.cs new file mode 100644 index 000000000..a1126f93a --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/AlbumUpgradeSpecification.cs @@ -0,0 +1,52 @@ +using System; +using System.Linq; +using NLog; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Qualities; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications +{ + public class AlbumUpgradeSpecification : IImportDecisionEngineSpecification<LocalAlbumRelease> + { + private readonly Logger _logger; + + public AlbumUpgradeSpecification(Logger logger) + { + _logger = logger; + } + + public Decision IsSatisfiedBy(LocalAlbumRelease localAlbumRelease) + { + var artist = localAlbumRelease.AlbumRelease.Album.Value.Artist.Value; + var qualityComparer = new QualityModelComparer(artist.QualityProfile); + + // check if we are changing release + var currentRelease = localAlbumRelease.AlbumRelease.Album.Value.AlbumReleases.Value.Single(x => x.Monitored); + var newRelease = localAlbumRelease.AlbumRelease; + + // if we are, check we are upgrading + if (newRelease.Id != currentRelease.Id) + { + // min quality of all new tracks + var newMinQuality = localAlbumRelease.LocalTracks.Select(x => x.Quality).OrderBy(x => x, qualityComparer).First(); + _logger.Debug("Min quality of new files: {0}", newMinQuality); + + // get minimum quality of existing release + var existingQualities = currentRelease.Tracks.Value.Where(x => x.TrackFileId != 0).Select(x => x.TrackFile.Value.Quality); + if (existingQualities.Any()) + { + var existingMinQuality = existingQualities.OrderBy(x => x, qualityComparer).First(); + _logger.Debug("Min quality of existing files: {0}", existingMinQuality); + if (qualityComparer.Compare(existingMinQuality, newMinQuality) > 0) + { + _logger.Debug("This album isn't a quality upgrade for all tracks. Skipping {0}", localAlbumRelease); + return Decision.Reject("Not an upgrade for existing album file(s)"); + } + } + } + + return Decision.Accept(); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/CloseAlbumMatchSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/CloseAlbumMatchSpecification.cs new file mode 100644 index 000000000..4c6e53f75 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/CloseAlbumMatchSpecification.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications +{ + public class CloseAlbumMatchSpecification : IImportDecisionEngineSpecification<LocalAlbumRelease> + { + private readonly Logger _logger; + private const double _albumThreshold = 0.20; + private const double _trackThreshold = 0.40; + + public CloseAlbumMatchSpecification(Logger logger) + { + _logger = logger; + } + + public Decision IsSatisfiedBy(LocalAlbumRelease localAlbumRelease) + { + double dist; + string reasons; + + // strict when a new download + if (localAlbumRelease.NewDownload) + { + dist = localAlbumRelease.Distance.NormalizedDistance(); + reasons = localAlbumRelease.Distance.Reasons; + if (dist > _albumThreshold) + { + _logger.Debug($"Album match is not close enough: {dist} vs {_albumThreshold} {reasons}. Skipping {localAlbumRelease}"); + return Decision.Reject($"Album match is not close enough: {1-dist:P1} vs {1-_albumThreshold:P0} {reasons}"); + } + + var worstTrackMatch = localAlbumRelease.LocalTracks.Where(x => x.Distance != null).OrderByDescending(x => x.Distance.NormalizedDistance()).FirstOrDefault(); + if (worstTrackMatch == null) + { + _logger.Debug($"No tracks matched"); + return Decision.Reject("No tracks matched"); + } + else + { + var maxTrackDist = worstTrackMatch.Distance.NormalizedDistance(); + var trackReasons = worstTrackMatch.Distance.Reasons; + if (maxTrackDist > _trackThreshold) + { + _logger.Debug($"Worst track match: {maxTrackDist} vs {_trackThreshold} {trackReasons}. Skipping {localAlbumRelease}"); + return Decision.Reject($"Worst track match: {1-maxTrackDist:P1} vs {1-_trackThreshold:P0} {trackReasons}"); + } + } + } + // otherwise importing existing files in library + else + { + // get album distance ignoring whether tracks are missing + dist = localAlbumRelease.Distance.NormalizedDistanceExcluding(new List<string> { "missing_tracks", "unmatched_tracks" }); + reasons = localAlbumRelease.Distance.Reasons; + if (dist > _albumThreshold) + { + _logger.Debug($"Album match is not close enough: {dist} vs {_albumThreshold} {reasons}. Skipping {localAlbumRelease}"); + return Decision.Reject($"Album match is not close enough: {1-dist:P1} vs {1-_albumThreshold:P0} {reasons}"); + } + } + + _logger.Debug($"Accepting release {localAlbumRelease}: dist {dist} vs {_albumThreshold} {reasons}"); + return Decision.Accept(); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/CloseTrackMatchSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/CloseTrackMatchSpecification.cs new file mode 100644 index 000000000..0603dce95 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/CloseTrackMatchSpecification.cs @@ -0,0 +1,33 @@ +using System.Linq; +using NLog; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications +{ + public class CloseTrackMatchSpecification : IImportDecisionEngineSpecification<LocalTrack> + { + private readonly Logger _logger; + private const double _threshold = 0.4; + + public CloseTrackMatchSpecification(Logger logger) + { + _logger = logger; + } + + public Decision IsSatisfiedBy(LocalTrack localTrack) + { + var dist = localTrack.Distance.NormalizedDistance(); + var reasons = localTrack.Distance.Reasons; + + if (dist > _threshold) + { + _logger.Debug($"Track match is not close enough: {dist} vs {_threshold} {reasons}. Skipping {localTrack}"); + return Decision.Reject($"Track match is not close enough: {1-dist:P1} vs {1-_threshold:P0} {reasons}"); + } + + _logger.Debug($"Track accepted: {dist} vs {_threshold} {reasons}."); + return Decision.Accept(); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FreeSpaceSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/FreeSpaceSpecification.cs similarity index 79% rename from src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FreeSpaceSpecification.cs rename to src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/FreeSpaceSpecification.cs index 490bdb941..b287cee4d 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/FreeSpaceSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/FreeSpaceSpecification.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using NLog; using NzbDrone.Common.Disk; @@ -6,9 +6,9 @@ using NzbDrone.Core.Configuration; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Parser.Model; -namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications +namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications { - public class FreeSpaceSpecification : IImportDecisionEngineSpecification + public class FreeSpaceSpecification : IImportDecisionEngineSpecification<LocalTrack> { private readonly IDiskProvider _diskProvider; private readonly IConfigService _configService; @@ -21,7 +21,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications _logger = logger; } - public Decision IsSatisfiedBy(LocalEpisode localEpisode) + public Decision IsSatisfiedBy(LocalTrack localTrack) { if (_configService.SkipFreeSpaceCheckWhenImporting) { @@ -31,13 +31,13 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications try { - if (localEpisode.ExistingFile) + if (localTrack.ExistingFile) { - _logger.Debug("Skipping free space check for existing episode"); + _logger.Debug("Skipping free space check for existing track"); return Decision.Accept(); } - var path = Directory.GetParent(localEpisode.Series.Path); + var path = Directory.GetParent(localTrack.Artist.Path); var freeSpace = _diskProvider.GetAvailableSpace(path.FullName); if (!freeSpace.HasValue) @@ -46,9 +46,9 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications return Decision.Accept(); } - if (freeSpace < localEpisode.Size + 100.Megabytes()) + if (freeSpace < localTrack.Size + 100.Megabytes()) { - _logger.Warn("Not enough free space ({0}) to import: {1} ({2})", freeSpace, localEpisode, localEpisode.Size); + _logger.Warn("Not enough free space ({0}) to import: {1} ({2})", freeSpace, localTrack, localTrack.Size); return Decision.Reject("Not enough free space"); } } @@ -58,7 +58,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications } catch (Exception ex) { - _logger.Error(ex, "Unable to check free disk space while importing. {0}", localEpisode.Path); + _logger.Error(ex, "Unable to check free disk space while importing. {0}", localTrack.Path); } return Decision.Accept(); diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/MoreTracksSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/MoreTracksSpecification.cs new file mode 100644 index 000000000..d1cb5ae61 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/MoreTracksSpecification.cs @@ -0,0 +1,32 @@ +using System.Linq; +using NLog; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications +{ + public class MoreTracksSpecification : IImportDecisionEngineSpecification<LocalAlbumRelease> + { + private readonly Logger _logger; + + public MoreTracksSpecification(Logger logger) + { + _logger = logger; + } + + public Decision IsSatisfiedBy(LocalAlbumRelease localAlbumRelease) + { + var existingRelease = localAlbumRelease.AlbumRelease.Album.Value.AlbumReleases.Value.Single(x => x.Monitored); + var existingTrackCount = existingRelease.Tracks.Value.Count(x => x.HasFile); + if (localAlbumRelease.AlbumRelease.Id != existingRelease.Id && + localAlbumRelease.TrackCount < existingTrackCount) + { + _logger.Debug($"This release has fewer tracks ({localAlbumRelease.TrackCount}) than existing {existingRelease} ({existingTrackCount}). Skipping {localAlbumRelease}"); + return Decision.Reject("Has fewer tracks than existing release"); + } + + _logger.Trace("Accepting release {0}", localAlbumRelease); + return Decision.Accept(); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/NoMissingOrUnmatchedTracksSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/NoMissingOrUnmatchedTracksSpecification.cs new file mode 100644 index 000000000..2d072fc91 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/NoMissingOrUnmatchedTracksSpecification.cs @@ -0,0 +1,34 @@ +using System.Linq; +using NLog; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications +{ + public class NoMissingOrUnmatchedTracksSpecification : IImportDecisionEngineSpecification<LocalAlbumRelease> + { + private readonly Logger _logger; + + public NoMissingOrUnmatchedTracksSpecification(Logger logger) + { + _logger = logger; + } + + public Decision IsSatisfiedBy(LocalAlbumRelease localAlbumRelease) + { + if (localAlbumRelease.NewDownload && localAlbumRelease.TrackMapping.LocalExtra.Count > 0) + { + _logger.Debug("This release has track files that have not been matched. Skipping {0}", localAlbumRelease); + return Decision.Reject("Has unmatched tracks"); + } + + if (localAlbumRelease.NewDownload && localAlbumRelease.TrackMapping.MBExtra.Count > 0) + { + _logger.Debug("This release is missing tracks. Skipping {0}", localAlbumRelease); + return Decision.Reject("Has missing tracks"); + } + + return Decision.Accept(); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotUnpackingSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/NotUnpackingSpecification.cs similarity index 80% rename from src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotUnpackingSpecification.cs rename to src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/NotUnpackingSpecification.cs index 2260ed71a..01f27de8d 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/Specifications/NotUnpackingSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/NotUnpackingSpecification.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using NLog; using NzbDrone.Common.Disk; @@ -7,9 +7,9 @@ using NzbDrone.Core.Configuration; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Parser.Model; -namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications +namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications { - public class NotUnpackingSpecification : IImportDecisionEngineSpecification + public class NotUnpackingSpecification : IImportDecisionEngineSpecification<LocalTrack> { private readonly IDiskProvider _diskProvider; private readonly IConfigService _configService; @@ -22,30 +22,30 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications _logger = logger; } - public Decision IsSatisfiedBy(LocalEpisode localEpisode) + public Decision IsSatisfiedBy(LocalTrack localTrack) { - if (localEpisode.ExistingFile) + if (localTrack.ExistingFile) { - _logger.Debug("{0} is in series folder, skipping unpacking check", localEpisode.Path); + _logger.Debug("{0} is in artist folder, skipping unpacking check", localTrack.Path); return Decision.Accept(); } foreach (var workingFolder in _configService.DownloadClientWorkingFolders.Split('|')) { - DirectoryInfo parent = Directory.GetParent(localEpisode.Path); + DirectoryInfo parent = Directory.GetParent(localTrack.Path); while (parent != null) { if (parent.Name.StartsWith(workingFolder)) { if (OsInfo.IsNotWindows) { - _logger.Debug("{0} is still being unpacked", localEpisode.Path); + _logger.Debug("{0} is still being unpacked", localTrack.Path); return Decision.Reject("File is still being unpacked"); } - if (_diskProvider.FileGetLastWrite(localEpisode.Path) > DateTime.UtcNow.AddMinutes(-5)) + if (_diskProvider.FileGetLastWrite(localTrack.Path) > DateTime.UtcNow.AddMinutes(-5)) { - _logger.Debug("{0} appears to be unpacking still", localEpisode.Path); + _logger.Debug("{0} appears to be unpacking still", localTrack.Path); return Decision.Reject("File is still being unpacked"); } } diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/ReleaseWantedSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/ReleaseWantedSpecification.cs new file mode 100644 index 000000000..6fd7df12e --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/ReleaseWantedSpecification.cs @@ -0,0 +1,28 @@ +using System.Linq; +using NLog; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications +{ + public class ReleaseWantedSpecification : IImportDecisionEngineSpecification<LocalAlbumRelease> + { + private readonly Logger _logger; + + public ReleaseWantedSpecification(Logger logger) + { + _logger = logger; + } + + public Decision IsSatisfiedBy(LocalAlbumRelease localAlbumRelease) + { + if (localAlbumRelease.AlbumRelease.Monitored || localAlbumRelease.AlbumRelease.Album.Value.AnyReleaseOk) + { + return Decision.Accept(); + } + + _logger.Debug("AlbumRelease {0} was not requested", localAlbumRelease); + return Decision.Reject("Album release not requested"); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/SameFileSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/SameFileSpecification.cs new file mode 100644 index 000000000..ffb9fee27 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/SameFileSpecification.cs @@ -0,0 +1,43 @@ +using System.Linq; +using NLog; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications +{ + public class SameFileSpecification : IImportDecisionEngineSpecification<LocalTrack> + { + private readonly Logger _logger; + + public SameFileSpecification(Logger logger) + { + _logger = logger; + } + + public Decision IsSatisfiedBy(LocalTrack localTrack) + { + var trackFiles = localTrack.Tracks.Where(e => e.TrackFileId != 0).Select(e => e.TrackFile).ToList(); + + if (trackFiles.Count == 0) + { + _logger.Debug("No existing track file, skipping"); + return Decision.Accept(); + } + + if (trackFiles.Count > 1) + { + _logger.Debug("More than one existing track file, skipping."); + return Decision.Accept(); + } + + if (trackFiles.First().Value.Size == localTrack.Size) + { + _logger.Debug("'{0}' Has the same filesize as existing file", localTrack.Path); + return Decision.Reject("Has the same filesize as existing file"); + } + + return Decision.Accept(); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/SameTracksImportSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/SameTracksImportSpecification.cs new file mode 100644 index 000000000..57535f0d6 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/SameTracksImportSpecification.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using NLog; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications +{ + public class SameTracksImportSpecification : IImportDecisionEngineSpecification<LocalTrack> + { + private readonly SameTracksSpecification _sameTracksSpecification; + private readonly Logger _logger; + + public SameTracksImportSpecification(SameTracksSpecification sameTracksSpecification, Logger logger) + { + _sameTracksSpecification = sameTracksSpecification; + _logger = logger; + } + + public RejectionType Type => RejectionType.Permanent; + + public Decision IsSatisfiedBy(LocalTrack localTrack) + { + if (_sameTracksSpecification.IsSatisfiedBy(localTrack.Tracks)) + { + return Decision.Accept(); + } + + _logger.Debug("Track file on disk contains more tracks than this file contains"); + return Decision.Reject("Track file on disk contains more tracks than this file contains"); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/UpgradeSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/UpgradeSpecification.cs new file mode 100644 index 000000000..e749a00a5 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/UpgradeSpecification.cs @@ -0,0 +1,49 @@ +using System; +using System.Linq; +using NLog; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Configuration; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications +{ + public class UpgradeSpecification : IImportDecisionEngineSpecification<LocalTrack> + { + private readonly IConfigService _configService; + private readonly Logger _logger; + + public UpgradeSpecification(IConfigService configService, Logger logger) + { + _configService = configService; + _logger = logger; + } + + public Decision IsSatisfiedBy(LocalTrack localTrack) + { + var downloadPropersAndRepacks = _configService.DownloadPropersAndRepacks; + var qualityComparer = new QualityModelComparer(localTrack.Artist.QualityProfile); + + foreach (var track in localTrack.Tracks.Where(e => e.TrackFileId > 0)) + { + var trackFile = track.TrackFile.Value; + var qualityCompare = qualityComparer.Compare(localTrack.Quality.Quality, trackFile.Quality.Quality); + + if (qualityCompare < 0) + { + _logger.Debug("This file isn't a quality upgrade for all tracks. Skipping {0}", localTrack.Path); + return Decision.Reject("Not an upgrade for existing track file(s)"); + } + + if (qualityCompare == 0 && downloadPropersAndRepacks != ProperDownloadTypes.DoNotPrefer && + localTrack.Quality.Revision.CompareTo(trackFile.Quality.Revision) < 0) + { + _logger.Debug("This file isn't a quality upgrade for all tracks. Skipping {0}", localTrack.Path); + return Decision.Reject("Not an upgrade for existing track file(s)"); + } + } + + return Decision.Accept(); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/UpdateEpisodeFileService.cs b/src/NzbDrone.Core/MediaFiles/UpdateEpisodeFileService.cs deleted file mode 100644 index d157a6147..000000000 --- a/src/NzbDrone.Core/MediaFiles/UpdateEpisodeFileService.cs +++ /dev/null @@ -1,173 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using NLog; -using NzbDrone.Common.Disk; -using NzbDrone.Common.Extensions; -using NzbDrone.Common.Instrumentation.Extensions; -using NzbDrone.Core.Configuration; -using NzbDrone.Core.MediaFiles.Events; -using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.MediaFiles -{ - public interface IUpdateEpisodeFileService - { - void ChangeFileDateForFile(EpisodeFile episodeFile, Series series, List<Episode> episodes); - } - - public class UpdateEpisodeFileService : IUpdateEpisodeFileService, - IHandle<SeriesScannedEvent> - { - private readonly IDiskProvider _diskProvider; - private readonly IConfigService _configService; - private readonly IEpisodeService _episodeService; - private readonly Logger _logger; - - public UpdateEpisodeFileService(IDiskProvider diskProvider, - IConfigService configService, - IEpisodeService episodeService, - Logger logger) - { - _diskProvider = diskProvider; - _configService = configService; - _episodeService = episodeService; - _logger = logger; - } - - public void ChangeFileDateForFile(EpisodeFile episodeFile, Series series, List<Episode> episodes) - { - ChangeFileDate(episodeFile, series, episodes); - } - - private bool ChangeFileDate(EpisodeFile episodeFile, Series series, List<Episode> episodes) - { - var episodeFilePath = Path.Combine(series.Path, episodeFile.RelativePath); - - switch (_configService.FileDate) - { - case FileDateType.LocalAirDate: - { - var airDate = episodes.First().AirDate; - var airTime = series.AirTime; - - if (airDate.IsNullOrWhiteSpace() || airTime.IsNullOrWhiteSpace()) - { - return false; - } - - return ChangeFileDateToLocalAirDate(episodeFilePath, airDate, airTime); - } - - case FileDateType.UtcAirDate: - { - var airDateUtc = episodes.First().AirDateUtc; - - if (!airDateUtc.HasValue) - { - return false; - } - - return ChangeFileDateToUtcAirDate(episodeFilePath, airDateUtc.Value); - } - } - - return false; - } - - public void Handle(SeriesScannedEvent message) - { - if (_configService.FileDate == FileDateType.None) - { - return; - } - - var episodes = _episodeService.EpisodesWithFiles(message.Series.Id); - - var episodeFiles = new List<EpisodeFile>(); - var updated = new List<EpisodeFile>(); - - foreach (var group in episodes.GroupBy(e => e.EpisodeFileId)) - { - var episodesInFile = group.Select(e => e).ToList(); - var episodeFile = episodesInFile.First().EpisodeFile; - - episodeFiles.Add(episodeFile); - - if (ChangeFileDate(episodeFile, message.Series, episodesInFile)) - { - updated.Add(episodeFile); - } - } - - if (updated.Any()) - { - _logger.ProgressDebug("Changed file date for {0} files of {1} in {2}", updated.Count, episodeFiles.Count, message.Series.Title); - } - - else - { - _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/UpdateTrackFileService.cs b/src/NzbDrone.Core/MediaFiles/UpdateTrackFileService.cs new file mode 100644 index 000000000..08bbe6de3 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/UpdateTrackFileService.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +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; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.MediaFiles +{ + public interface IUpdateTrackFileService + { + void ChangeFileDateForFile(TrackFile trackFile, Artist artist, List<Track> tracks); + } + + public class UpdateTrackFileService : IUpdateTrackFileService, + IHandle<ArtistScannedEvent> + { + private readonly IDiskProvider _diskProvider; + private readonly IAlbumService _albumService; + private readonly IConfigService _configService; + private readonly ITrackService _trackService; + private readonly Logger _logger; + private static readonly DateTime EpochTime = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + public UpdateTrackFileService(IDiskProvider diskProvider, + IConfigService configService, + ITrackService trackService, + IAlbumService albumService, + Logger logger) + { + _diskProvider = diskProvider; + _configService = configService; + _trackService = trackService; + _albumService = albumService; + _logger = logger; + } + + public void ChangeFileDateForFile(TrackFile trackFile, Artist artist, List<Track> tracks) + { + ChangeFileDate(trackFile, artist, tracks); + } + + private bool ChangeFileDate(TrackFile trackFile, Artist artist, List<Track> tracks) + { + var trackFilePath = trackFile.Path; + + switch (_configService.FileDate) + { + case FileDateType.AlbumReleaseDate: + { + var album = _albumService.GetAlbum(trackFile.AlbumId); + + if (!album.ReleaseDate.HasValue) + { + _logger.Debug("Could not create valid date to change file [{0}]", trackFilePath); + return false; + } + + var relDate = album.ReleaseDate.Value; + + // avoiding false +ve checks and set date skewing by not using UTC (Windows) + DateTime oldDateTime = _diskProvider.FileGetLastWrite(trackFilePath); + + if (OsInfo.IsNotWindows && relDate < EpochTime) + { + _logger.Debug("Setting date of file to 1970-01-01 as actual airdate is before that time and will not be set properly"); + relDate = EpochTime; + } + + if (!DateTime.Equals(relDate, oldDateTime)) + { + try + { + _diskProvider.FileSetLastWriteTime(trackFilePath, relDate); + _logger.Debug("Date of file [{0}] changed from '{1}' to '{2}'", trackFilePath, oldDateTime, relDate); + + return true; + } + + catch (Exception ex) + { + _logger.Warn(ex, "Unable to set date of file [" + trackFilePath + "]"); + } + } + + return false; + } + } + + return false; + } + + public void Handle(ArtistScannedEvent message) + { + if (_configService.FileDate == FileDateType.None) + { + return; + } + + var tracks = _trackService.TracksWithFiles(message.Artist.Id); + + var trackFiles = new List<TrackFile>(); + var updated = new List<TrackFile>(); + + foreach (var group in tracks.GroupBy(e => e.TrackFileId)) + { + var tracksInFile = group.Select(e => e).ToList(); + var trackFile = tracksInFile.First().TrackFile; + + trackFiles.Add(trackFile); + + if (ChangeFileDate(trackFile, message.Artist, tracksInFile)) + { + updated.Add(trackFile); + } + } + + if (updated.Any()) + { + _logger.ProgressDebug("Changed file date for {0} files of {1} in {2}", updated.Count, trackFiles.Count, message.Artist.Name); + } + + else + { + _logger.ProgressDebug("No file dates changed for {0}", message.Artist.Name); + } + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/UpgradeMediaFileService.cs b/src/NzbDrone.Core/MediaFiles/UpgradeMediaFileService.cs index 95f245e3e..ac7a010ea 100644 --- a/src/NzbDrone.Core/MediaFiles/UpgradeMediaFileService.cs +++ b/src/NzbDrone.Core/MediaFiles/UpgradeMediaFileService.cs @@ -1,54 +1,69 @@ -using System.IO; +using System.IO; using System.Linq; using NLog; using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.MediaFiles.TrackImport; using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.MediaFiles { public interface IUpgradeMediaFiles { - EpisodeFileMoveResult UpgradeEpisodeFile(EpisodeFile episodeFile, LocalEpisode localEpisode, bool copyOnly = false); + TrackFileMoveResult UpgradeTrackFile(TrackFile trackFile, LocalTrack localTrack, bool copyOnly = false); } public class UpgradeMediaFileService : IUpgradeMediaFiles { private readonly IRecycleBinProvider _recycleBinProvider; private readonly IMediaFileService _mediaFileService; - private readonly IMoveEpisodeFiles _episodeFileMover; + private readonly IAudioTagService _audioTagService; + private readonly IMoveTrackFiles _trackFileMover; private readonly IDiskProvider _diskProvider; private readonly Logger _logger; public UpgradeMediaFileService(IRecycleBinProvider recycleBinProvider, IMediaFileService mediaFileService, - IMoveEpisodeFiles episodeFileMover, + IAudioTagService audioTagService, + IMoveTrackFiles trackFileMover, IDiskProvider diskProvider, Logger logger) { _recycleBinProvider = recycleBinProvider; _mediaFileService = mediaFileService; - _episodeFileMover = episodeFileMover; + _audioTagService = audioTagService; + _trackFileMover = trackFileMover; _diskProvider = diskProvider; _logger = logger; } - public EpisodeFileMoveResult UpgradeEpisodeFile(EpisodeFile episodeFile, LocalEpisode localEpisode, bool copyOnly = false) + public TrackFileMoveResult UpgradeTrackFile(TrackFile trackFile, LocalTrack localTrack, bool copyOnly = false) { - var moveFileResult = new EpisodeFileMoveResult(); - var existingFiles = localEpisode.Episodes - .Where(e => e.EpisodeFileId > 0) - .Select(e => e.EpisodeFile.Value) - .GroupBy(e => e.Id); + var moveFileResult = new TrackFileMoveResult(); + var existingFiles = localTrack.Tracks + .Where(e => e.TrackFileId > 0) + .Select(e => e.TrackFile.Value) + .GroupBy(e => e.Id) + .ToList(); + + var rootFolder = _diskProvider.GetParentFolder(localTrack.Artist.Path); + + // If there are existing track 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 trackFilePath = file.Path; + var subfolder = rootFolder.GetRelativePath(_diskProvider.GetParentFolder(trackFilePath)); - if (_diskProvider.FileExists(episodeFilePath)) + if (_diskProvider.FileExists(trackFilePath)) { - _logger.Debug("Removing existing episode file: {0}", file); - _recycleBinProvider.DeleteFile(episodeFilePath); + _logger.Debug("Removing existing track file: {0}", file); + _recycleBinProvider.DeleteFile(trackFilePath, subfolder); } moveFileResult.OldFiles.Add(file); @@ -57,13 +72,15 @@ namespace NzbDrone.Core.MediaFiles if (copyOnly) { - moveFileResult.EpisodeFile = _episodeFileMover.CopyEpisodeFile(episodeFile, localEpisode); + moveFileResult.TrackFile = _trackFileMover.CopyTrackFile(trackFile, localTrack); } else { - moveFileResult.EpisodeFile = _episodeFileMover.MoveEpisodeFile(episodeFile, localEpisode); + moveFileResult.TrackFile = _trackFileMover.MoveTrackFile(trackFile, localTrack); } + _audioTagService.WriteTags(trackFile, true); + return moveFileResult; } } diff --git a/src/NzbDrone.Core/Messaging/Commands/Command.cs b/src/NzbDrone.Core/Messaging/Commands/Command.cs index 20becd1f0..a4575ebe9 100644 --- a/src/NzbDrone.Core/Messaging/Commands/Command.cs +++ b/src/NzbDrone.Core/Messaging/Commands/Command.cs @@ -4,15 +4,32 @@ namespace NzbDrone.Core.Messaging.Commands { public abstract class Command { - public virtual bool SendUpdatesToClient => false; + private bool _sendUpdatesToClient; - public virtual bool UpdateScheduledTask => true; + public virtual bool SendUpdatesToClient + { + get + { + return _sendUpdatesToClient; + } + + set + { + _sendUpdatesToClient = value; + } + } + public virtual bool UpdateScheduledTask => true; public virtual string CompletionMessage => "Completed"; + public virtual bool RequiresDiskAccess => false; + public virtual bool IsExclusive => false; + + public virtual bool IsTypeExclusive => false; public string Name { get; private set; } public DateTime? LastExecutionTime { get; set; } public CommandTrigger Trigger { get; set; } + public bool SuppressMessages { get; set; } public Command() { diff --git a/src/NzbDrone.Core/Messaging/Commands/CommandEqualityComparer.cs b/src/NzbDrone.Core/Messaging/Commands/CommandEqualityComparer.cs index eabbec15f..65f21b935 100644 --- a/src/NzbDrone.Core/Messaging/Commands/CommandEqualityComparer.cs +++ b/src/NzbDrone.Core/Messaging/Commands/CommandEqualityComparer.cs @@ -49,10 +49,13 @@ namespace NzbDrone.Core.Messaging.Commands if (typeof(IEnumerable).IsAssignableFrom(xProperty.PropertyType)) { - var xValueCollection = ((IEnumerable)xValue).Cast<object>().OrderBy(t => t); - var yValueCollection = ((IEnumerable)yValue).Cast<object>().OrderBy(t => t); + var xValueCollection = ((IEnumerable)xValue).Cast<object>(); + var yValueCollection = ((IEnumerable)yValue).Cast<object>(); - if (!xValueCollection.SequenceEqual(yValueCollection)) + var xNotY = xValueCollection.Except(yValueCollection); + var yNotX = yValueCollection.Except(xValueCollection); + + if (xNotY.Any() || yNotX.Any()) { return false; } diff --git a/src/NzbDrone.Core/Messaging/Commands/CommandExecutor.cs b/src/NzbDrone.Core/Messaging/Commands/CommandExecutor.cs index 15843ef7b..53ed14a6c 100644 --- a/src/NzbDrone.Core/Messaging/Commands/CommandExecutor.cs +++ b/src/NzbDrone.Core/Messaging/Commands/CommandExecutor.cs @@ -48,9 +48,13 @@ namespace NzbDrone.Core.Messaging.Commands } catch (ThreadAbortException ex) { - _logger.Error(ex); + _logger.Error(ex, "Thread aborted"); Thread.ResetAbort(); } + catch (OperationCanceledException) + { + _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/CommandQueue.cs b/src/NzbDrone.Core/Messaging/Commands/CommandQueue.cs index ad555fe6c..f5e0de695 100644 --- a/src/NzbDrone.Core/Messaging/Commands/CommandQueue.cs +++ b/src/NzbDrone.Core/Messaging/Commands/CommandQueue.cs @@ -1,27 +1,36 @@ -using System; +using System; using System.Collections; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Threading; namespace NzbDrone.Core.Messaging.Commands { - public class CommandQueue : IProducerConsumerCollection<CommandModel> + public class CommandQueue : IEnumerable { - private object Mutex = new object(); - - private List<CommandModel> _items; + private readonly object _mutex = new object(); + private readonly List<CommandModel> _items; public CommandQueue() { _items = new List<CommandModel>(); } + public int Count => _items.Count; + + public void Add(CommandModel item) + { + lock (_mutex) + { + _items.Add(item); + } + } + public IEnumerator<CommandModel> GetEnumerator() { List<CommandModel> copy = null; - lock (Mutex) + lock (_mutex) { copy = new List<CommandModel>(_items); } @@ -34,77 +43,144 @@ namespace NzbDrone.Core.Messaging.Commands return GetEnumerator(); } - public void CopyTo(Array array, int index) + public List<CommandModel> All() { - lock (Mutex) + List<CommandModel> rval = null; + + lock (_mutex) { - ((ICollection)_items).CopyTo(array, index); + rval = _items; } - } - - public int Count => _items.Count; - public object SyncRoot => Mutex; + return rval; + } - public bool IsSynchronized => true; + public CommandModel Find(int id) + { + return All().FirstOrDefault(q => q.Id == id); + } - public void CopyTo(CommandModel[] array, int index) + public void RemoveMany(IEnumerable<CommandModel> commands) { - lock (Mutex) + lock (_mutex) { - _items.CopyTo(array, index); + foreach (var command in commands) + { + _items.Remove(command); + } } } - public bool TryAdd(CommandModel item) + public bool RemoveIfQueued(int id) { - Add(item); - return true; - } + var rval = false; - public bool TryTake(out CommandModel item) - { - bool rval = true; - lock (Mutex) + lock (_mutex) { - if (_items.Count == 0) - { - item = default(CommandModel); - rval = false; - } + var command = _items.FirstOrDefault(q => q.Id == id); - else + if (command?.Status == CommandStatus.Queued) { - item = _items.Where(c => c.Status == CommandStatus.Queued) - .OrderByDescending(c => c.Priority) - .ThenBy(c => c.QueuedAt) - .First(); - - _items.Remove(item); + _items.Remove(command); + rval = true; } } return rval; } - public CommandModel[] ToArray() + public List<CommandModel> QueuedOrStarted() { - CommandModel[] rval = null; + return All().Where(q => q.Status == CommandStatus.Queued || q.Status == CommandStatus.Started) + .ToList(); + } - lock (Mutex) + public IEnumerable<CommandModel> GetConsumingEnumerable() + { + return GetConsumingEnumerable(CancellationToken.None); + } + + public IEnumerable<CommandModel> GetConsumingEnumerable(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) { - rval = _items.ToArray(); - } + if (TryGet(out var command)) + { + yield return command; + } - return rval; + Thread.Sleep(10); + } } - public void Add(CommandModel item) + public bool TryGet(out CommandModel item) { - lock (Mutex) + var rval = true; + item = default(CommandModel); + + lock (_mutex) { - _items.Add(item); + if (_items.Count == 0) + { + rval = false; + } + + else + { + var startedCommands = _items.Where(c => c.Status == CommandStatus.Started) + .ToList(); + + var exclusiveTypes = startedCommands.Where(x => x.Body.IsTypeExclusive) + .Select(x => x.Body.Name) + .ToList(); + + var queuedCommands = _items.Where(c => c.Status == CommandStatus.Queued); + + if (startedCommands.Any(x => x.Body.RequiresDiskAccess)) + { + queuedCommands = queuedCommands.Where(c => !c.Body.RequiresDiskAccess); + } + + if (startedCommands.Any(x => x.Body.IsTypeExclusive)) + { + queuedCommands = queuedCommands.Where(c => !exclusiveTypes.Any(x => x == c.Body.Name)); + } + + var localItem = queuedCommands.OrderByDescending(c => c.Priority) + .ThenBy(c => c.QueuedAt) + .FirstOrDefault(); + + // Nothing queued that meets the requirements + if (localItem == null) + { + rval = false; + } + + // If any executing command is exclusive don't want return another command until it completes. + else if (startedCommands.Any(c => c.Body.IsExclusive)) + { + rval = false; + } + + // If the next command to execute is exclusive wait for executing commands to complete. + // This will prevent other tasks from starting so the exclusive task executes in the order it should. + else if (localItem.Body.IsExclusive && startedCommands.Any()) + { + rval = false; + } + + // A command ready to execute + else + { + localItem.StartedAt = DateTime.UtcNow; + localItem.Status = CommandStatus.Started; + + item = localItem; + } + } } + + return rval; } } } diff --git a/src/NzbDrone.Core/Messaging/Commands/CommandQueueManager.cs b/src/NzbDrone.Core/Messaging/Commands/CommandQueueManager.cs index d45547b8f..ce9b3c5cc 100644 --- a/src/NzbDrone.Core/Messaging/Commands/CommandQueueManager.cs +++ b/src/NzbDrone.Core/Messaging/Commands/CommandQueueManager.cs @@ -1,30 +1,33 @@ -using System; -using System.Collections.Concurrent; +using System; using System.Collections.Generic; using System.Linq; +using System.Net; using System.Threading; using NLog; using NzbDrone.Common; -using NzbDrone.Common.Cache; using NzbDrone.Common.EnsureThat; using NzbDrone.Common.Serializer; using NzbDrone.Core.Lifecycle; +using NzbDrone.Core.Exceptions; using NzbDrone.Core.Messaging.Events; namespace NzbDrone.Core.Messaging.Commands { public interface IManageCommandQueue { + List<CommandModel> PushMany<TCommand>(List<TCommand> commands) where TCommand : Command; CommandModel Push<TCommand>(TCommand command, CommandPriority priority = CommandPriority.Normal, CommandTrigger trigger = CommandTrigger.Unspecified) where TCommand : Command; CommandModel Push(string commandName, DateTime? lastExecutionTime, CommandPriority priority = CommandPriority.Normal, CommandTrigger trigger = CommandTrigger.Unspecified); IEnumerable<CommandModel> Queue(CancellationToken cancellationToken); + List<CommandModel> All(); CommandModel Get(int id); - List<CommandModel> GetStarted(); + List<CommandModel> GetStarted(); void SetMessage(CommandModel command, string message); void Start(CommandModel command); void Complete(CommandModel command, string message); void Fail(CommandModel command, string message, Exception e); void Requeue(); + void Cancel(int id); void CleanCommands(); } @@ -34,22 +37,60 @@ namespace NzbDrone.Core.Messaging.Commands private readonly IServiceFactory _serviceFactory; private readonly Logger _logger; - private readonly ICached<CommandModel> _commandCache; - private readonly BlockingCollection<CommandModel> _commandQueue; + private readonly CommandQueue _commandQueue; - public CommandQueueManager(ICommandRepository repo, + public CommandQueueManager(ICommandRepository repo, IServiceFactory serviceFactory, - ICacheManager cacheManager, Logger logger) { _repo = repo; _serviceFactory = serviceFactory; _logger = logger; - _commandCache = cacheManager.GetCache<CommandModel>(GetType()); - _commandQueue = new BlockingCollection<CommandModel>(new CommandQueue()); + _commandQueue = new CommandQueue(); } + public List<CommandModel> PushMany<TCommand>(List<TCommand> commands) where TCommand : Command + { + _logger.Trace("Publishing {0} commands", commands.Count); + + var commandModels = new List<CommandModel>(); + var existingCommands = _commandQueue.QueuedOrStarted(); + + foreach (var command in commands) + { + var existing = existingCommands.SingleOrDefault(c => c.Name == command.Name && CommandEqualityComparer.Instance.Equals(c.Body, command)); + + if (existing != null) + { + continue; + } + + var commandModel = new CommandModel + + { + Name = command.Name, + Body = command, + QueuedAt = DateTime.UtcNow, + Trigger = CommandTrigger.Unspecified, + Priority = CommandPriority.Normal, + Status = CommandStatus.Queued + }; + + commandModels.Add(commandModel); + } + + _repo.InsertMany(commandModels); + + foreach (var commandModel in commandModels) + { + _commandQueue.Add(commandModel); + } + + return commandModels; + } + + public CommandModel Push<TCommand>(TCommand command, CommandPriority priority = CommandPriority.Normal, CommandTrigger trigger = CommandTrigger.Unspecified) where TCommand : Command { Ensure.That(command, () => command).IsNotNull(); @@ -80,7 +121,6 @@ namespace NzbDrone.Core.Messaging.Commands _logger.Trace("Inserting new command: {0}", commandModel.Name); _repo.Insert(commandModel); - _commandCache.Set(commandModel.Id.ToString(), commandModel); _commandQueue.Add(commandModel); return commandModel; @@ -100,30 +140,39 @@ namespace NzbDrone.Core.Messaging.Commands return _commandQueue.GetConsumingEnumerable(cancellationToken); } + public List<CommandModel> All() + { + _logger.Trace("Getting all commands"); + return _commandQueue.All(); + } + public CommandModel Get(int id) { - return _commandCache.Get(id.ToString(), () => FindCommand(_repo.Get(id))); + var command = _commandQueue.Find(id); + + if (command == null) + { + command = _repo.Get(id); + } + + return command; } public List<CommandModel> GetStarted() { _logger.Trace("Getting started commands"); - return _commandCache.Values.Where(c => c.Status == CommandStatus.Started).ToList(); + return _commandQueue.All().Where(c => c.Status == CommandStatus.Started).ToList(); } public void SetMessage(CommandModel command, string message) { command.Message = message; - _commandCache.Set(command.Id.ToString(), command); } public void Start(CommandModel command) { - command.StartedAt = DateTime.UtcNow; - command.Status = CommandStatus.Started; - + // Marks the command as started in the DB, the queue takes care of marking it as started on it's own _logger.Trace("Marking command as started: {0}", command.Name); - _commandCache.Set(command.Id.ToString(), command); _repo.Start(command); } @@ -135,7 +184,7 @@ namespace NzbDrone.Core.Messaging.Commands public void Fail(CommandModel command, string message, Exception e) { command.Exception = e.ToString(); - + Update(command, CommandStatus.Failed, message); } @@ -147,16 +196,23 @@ namespace NzbDrone.Core.Messaging.Commands } } + public void Cancel(int id) + { + if (!_commandQueue.RemoveIfQueued(id)) + { + throw new NzbDroneClientException(HttpStatusCode.Conflict, "Unable to cancel task"); + } + } + public void CleanCommands() { _logger.Trace("Cleaning up old commands"); - - var old = _commandCache.Values.Where(c => c.EndedAt < DateTime.UtcNow.AddMinutes(-5)); - foreach (var command in old) - { - _commandCache.Remove(command.Id.ToString()); - } + var commands = _commandQueue.All() + .Where(c => c.EndedAt < DateTime.UtcNow.AddMinutes(-5)) + .ToList(); + + _commandQueue.RemoveMany(commands); _repo.Trim(); } @@ -171,18 +227,6 @@ namespace NzbDrone.Core.Messaging.Commands return Json.Deserialize("{}", commandType); } - private CommandModel FindCommand(CommandModel command) - { - var cachedCommand = _commandCache.Find(command.Id.ToString()); - - if (cachedCommand != null) - { - command.Message = cachedCommand.Message; - } - - return command; - } - private void Update(CommandModel command, CommandStatus status, string message) { SetMessage(command, message); @@ -192,15 +236,14 @@ namespace NzbDrone.Core.Messaging.Commands command.Status = status; _logger.Trace("Updating command status"); - _commandCache.Set(command.Id.ToString(), command); _repo.End(command); } private List<CommandModel> QueuedOrStarted(string name) { - return _commandCache.Values.Where(q => q.Name == name && - (q.Status == CommandStatus.Queued || - q.Status == CommandStatus.Started)).ToList(); + return _commandQueue.QueuedOrStarted() + .Where(q => q.Name == name) + .ToList(); } public void Handle(ApplicationStartedEvent message) 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..97cc9156f 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; @@ -14,11 +16,38 @@ namespace NzbDrone.Core.Messaging.Events private readonly IServiceFactory _serviceFactory; private readonly TaskFactory _taskFactory; + private readonly Dictionary<string, object> _eventSubscribers; + + private class EventSubscribers<TEvent> where TEvent : class, IEvent + { + private IServiceFactory _serviceFactory; + + public IHandle<TEvent>[] _syncHandlers; + public IHandleAsync<TEvent>[] _asyncHandlers; + public IHandleAsync<IEvent>[] _globalHandlers; + + public EventSubscribers(IServiceFactory serviceFactory) + { + _serviceFactory = serviceFactory; + + _syncHandlers = serviceFactory.BuildAll<IHandle<TEvent>>() + .OrderBy(GetEventHandleOrder) + .ToArray(); + + _globalHandlers = serviceFactory.BuildAll<IHandleAsync<IEvent>>() + .ToArray(); + + _asyncHandlers = serviceFactory.BuildAll<IHandleAsync<TEvent>>() + .ToArray(); + } + } + public EventAggregator(Logger logger, IServiceFactory serviceFactory) { _logger = logger; _serviceFactory = serviceFactory; _taskFactory = new TaskFactory(); + _eventSubscribers = new Dictionary<string, object>(); } public void PublishEvent<TEvent>(TEvent @event) where TEvent : class, IEvent @@ -46,9 +75,23 @@ namespace NzbDrone.Core.Messaging.Events _logger.Trace("Publishing {0}", eventName); + EventSubscribers<TEvent> subscribers; + lock (_eventSubscribers) + { + object target; + if (!_eventSubscribers.TryGetValue(eventName, out target)) + { + _eventSubscribers[eventName] = target = new EventSubscribers<TEvent>(_serviceFactory); + } + + subscribers = target as EventSubscribers<TEvent>; + } + //call synchronous handlers first. - foreach (var handler in _serviceFactory.BuildAll<IHandle<TEvent>>()) + var handlers = subscribers._syncHandlers; + + foreach (var handler in handlers) { try { @@ -62,7 +105,18 @@ namespace NzbDrone.Core.Messaging.Events } } - foreach (var handler in _serviceFactory.BuildAll<IHandleAsync<TEvent>>()) + 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 +139,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<TEvent>(IHandle<TEvent> 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/MetadataSource/IMetadataRequestBuilder.cs b/src/NzbDrone.Core/MetadataSource/IMetadataRequestBuilder.cs new file mode 100644 index 000000000..552464cab --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/IMetadataRequestBuilder.cs @@ -0,0 +1,37 @@ +using NzbDrone.Common.Cloud; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; + +namespace NzbDrone.Core.MetadataSource +{ + + public interface IMetadataRequestBuilder + { + IHttpRequestBuilderFactory GetRequestBuilder(); + } + public class MetadataRequestBuilder : IMetadataRequestBuilder + { + private readonly IConfigService _configService; + + private readonly ILidarrCloudRequestBuilder _defaultRequestFactory; + + public MetadataRequestBuilder(IConfigService configService, ILidarrCloudRequestBuilder defaultRequestBuilder) + { + _configService = configService; + _defaultRequestFactory = defaultRequestBuilder; + } + + public IHttpRequestBuilderFactory GetRequestBuilder() + { + if (_configService.MetadataSource.IsNotNullOrWhiteSpace()) + { + return new HttpRequestBuilder(_configService.MetadataSource.TrimEnd("/") + "/{route}").KeepAlive().CreateFactory(); + } + else + { + return _defaultRequestFactory.Search; + } + } + } +} diff --git a/src/NzbDrone.Core/MetadataSource/IProvideAlbumInfo.cs b/src/NzbDrone.Core/MetadataSource/IProvideAlbumInfo.cs new file mode 100644 index 000000000..0aeb10200 --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/IProvideAlbumInfo.cs @@ -0,0 +1,11 @@ +using NzbDrone.Core.Music; +using System; +using System.Collections.Generic; + +namespace NzbDrone.Core.MetadataSource +{ + public interface IProvideAlbumInfo + { + Tuple<string, Album, List<ArtistMetadata>> GetAlbumInfo(string id); + } +} diff --git a/src/NzbDrone.Core/MetadataSource/IProvideArtistInfo.cs b/src/NzbDrone.Core/MetadataSource/IProvideArtistInfo.cs new file mode 100644 index 000000000..bf49d2797 --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/IProvideArtistInfo.cs @@ -0,0 +1,12 @@ +using NzbDrone.Core.Music; +using System; +using System.Collections.Generic; +using NzbDrone.Core.Profiles.Metadata; + +namespace NzbDrone.Core.MetadataSource +{ + public interface IProvideArtistInfo + { + Artist GetArtistInfo(string lidarrId, int metadataProfileId); + } +} diff --git a/src/NzbDrone.Core/MetadataSource/IProvideSeriesInfo.cs b/src/NzbDrone.Core/MetadataSource/IProvideSeriesInfo.cs deleted file mode 100644 index f2ab03336..000000000 --- a/src/NzbDrone.Core/MetadataSource/IProvideSeriesInfo.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System; -using System.Collections.Generic; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.MetadataSource -{ - public interface IProvideSeriesInfo - { - Tuple<Series, List<Episode>> GetSeriesInfo(int tvdbSeriesId); - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/MetadataSource/ISearchForNewAlbum.cs b/src/NzbDrone.Core/MetadataSource/ISearchForNewAlbum.cs new file mode 100644 index 000000000..a3e6ebc11 --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/ISearchForNewAlbum.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.MetadataSource +{ + public interface ISearchForNewAlbum + { + List<Album> SearchForNewAlbum(string title, string artist); + } +} diff --git a/src/NzbDrone.Core/MetadataSource/ISearchForNewArtist.cs b/src/NzbDrone.Core/MetadataSource/ISearchForNewArtist.cs new file mode 100644 index 000000000..65cdb28e8 --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/ISearchForNewArtist.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.MetadataSource +{ + public interface ISearchForNewArtist + { + List<Artist> SearchForNewArtist(string title); + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core/MetadataSource/ISearchForNewSeries.cs b/src/NzbDrone.Core/MetadataSource/ISearchForNewSeries.cs deleted file mode 100644 index 5abd02bcc..000000000 --- a/src/NzbDrone.Core/MetadataSource/ISearchForNewSeries.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.MetadataSource -{ - public interface ISearchForNewSeries - { - List<Series> SearchForNewSeries(string title); - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/MetadataSource/SearchArtistComparer.cs b/src/NzbDrone.Core/MetadataSource/SearchArtistComparer.cs new file mode 100644 index 000000000..f01e8107f --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/SearchArtistComparer.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.MetadataSource +{ + public class SearchArtistComparer : IComparer<Artist> + { + private static readonly Regex RegexCleanPunctuation = new Regex("[-._:]", RegexOptions.Compiled); + private static readonly Regex RegexCleanCountryYearPostfix = new Regex(@"(?<=.+)( \([A-Z]{2}\)| \(\d{4}\)| \([A-Z]{2}\) \(\d{4}\))$", RegexOptions.Compiled); + private static readonly Regex ArticleRegex = new Regex(@"^(a|an|the)\s", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + public string SearchQuery { get; private set; } + + private readonly string _searchQueryWithoutYear; + private int? _year; + + public SearchArtistComparer(string searchQuery) + { + SearchQuery = searchQuery; + + var match = Regex.Match(SearchQuery, @"^(?<query>.+)\s+(?:\((?<year>\d{4})\)|(?<year>\d{4}))$"); + if (match.Success) + { + _searchQueryWithoutYear = match.Groups["query"].Value.ToLowerInvariant(); + _year = int.Parse(match.Groups["year"].Value); + } + else + { + _searchQueryWithoutYear = searchQuery.ToLowerInvariant(); + } + } + + public int Compare(Artist x, Artist y) + { + int result = 0; + + // Prefer exact matches + result = Compare(x, y, s => CleanPunctuation(s.Name).Equals(CleanPunctuation(SearchQuery))); + if (result != 0) return -result; + + // Remove Articles (a/an/the) + result = Compare(x, y, s => CleanArticles(s.Name).Equals(CleanArticles(SearchQuery))); + if (result != 0) return -result; + + // Prefer close matches + result = Compare(x, y, s => CleanPunctuation(s.Name).LevenshteinDistance(CleanPunctuation(SearchQuery)) <= 1); + if (result != 0) return -result; + + return Compare(x, y, s => SearchQuery.LevenshteinDistanceClean(s.Name)); + } + + public int Compare<T>(Artist x, Artist y, Func<Artist, T> keySelector) + where T : IComparable<T> + { + var keyX = keySelector(x); + var keyY = keySelector(y); + + return keyX.CompareTo(keyY); + } + + private string CleanPunctuation(string title) + { + title = RegexCleanPunctuation.Replace(title, ""); + + return title.ToLowerInvariant(); + } + + private string CleanTitle(string title) + { + title = RegexCleanPunctuation.Replace(title, ""); + title = RegexCleanCountryYearPostfix.Replace(title, ""); + + return title.ToLowerInvariant(); + } + + private string CleanArticles(string title) + { + title = ArticleRegex.Replace(title, ""); + + return title.Trim().ToLowerInvariant(); + } + + } +} diff --git a/src/NzbDrone.Core/MetadataSource/SearchSeriesComparer.cs b/src/NzbDrone.Core/MetadataSource/SearchSeriesComparer.cs deleted file mode 100644 index 05d9a1223..000000000 --- a/src/NzbDrone.Core/MetadataSource/SearchSeriesComparer.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.RegularExpressions; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.MetadataSource -{ - public class SearchSeriesComparer : IComparer<Series> - { - private static readonly Regex RegexCleanPunctuation = new Regex("[-._:]", RegexOptions.Compiled); - private static readonly Regex RegexCleanCountryYearPostfix = new Regex(@"(?<=.+)( \([A-Z]{2}\)| \(\d{4}\)| \([A-Z]{2}\) \(\d{4}\))$", RegexOptions.Compiled); - private static readonly Regex ArticleRegex = new Regex(@"^(a|an|the)\s", RegexOptions.IgnoreCase | RegexOptions.Compiled); - - public string SearchQuery { get; private set; } - - private readonly string _searchQueryWithoutYear; - private int? _year; - - public SearchSeriesComparer(string searchQuery) - { - SearchQuery = searchQuery; - - var match = Regex.Match(SearchQuery, @"^(?<query>.+)\s+(?:\((?<year>\d{4})\)|(?<year>\d{4}))$"); - if (match.Success) - { - _searchQueryWithoutYear = match.Groups["query"].Value.ToLowerInvariant(); - _year = int.Parse(match.Groups["year"].Value); - } - else - { - _searchQueryWithoutYear = searchQuery.ToLowerInvariant(); - } - } - - public int Compare(Series x, Series y) - { - int result = 0; - - // Prefer exact matches - result = Compare(x, y, s => CleanPunctuation(s.Title).Equals(CleanPunctuation(SearchQuery))); - if (result != 0) return -result; - - // Remove Articles (a/an/the) - result = Compare(x, y, s => CleanArticles(s.Title).Equals(CleanArticles(SearchQuery))); - if (result != 0) return -result; - - // Prefer close matches - result = Compare(x, y, s => CleanPunctuation(s.Title).LevenshteinDistance(CleanPunctuation(SearchQuery)) <= 1); - if (result != 0) return -result; - - // Compare clean matches by year "Battlestar Galactica 1978" - result = CompareWithYear(x, y, s => CleanTitle(s.Title).LevenshteinDistance(_searchQueryWithoutYear) <= 1); - if (result != 0) return -result; - - // Compare prefix matches by year "(CSI: ..." - result = CompareWithYear(x, y, s => s.Title.ToLowerInvariant().StartsWith(_searchQueryWithoutYear + ":")); - if (result != 0) return -result; - - return Compare(x, y, s => SearchQuery.LevenshteinDistanceClean(s.Title) - GetYearFactor(s)); - } - - public int Compare<T>(Series x, Series y, Func<Series,T> keySelector) - where T : IComparable<T> - { - var keyX = keySelector(x); - var keyY = keySelector(y); - - return keyX.CompareTo(keyY); - } - - public int CompareWithYear(Series x, Series y, Predicate<Series> canMatch) - { - var matchX = canMatch(x); - var matchY = canMatch(y); - - if (matchX && matchY) - { - if (_year.HasValue) - { - var result = Compare(x, y, s => s.Year == _year.Value); - if (result != 0) return result; - } - - return Compare(x, y, s => s.Year); - } - - return matchX.CompareTo(matchY); - } - - private string CleanPunctuation(string title) - { - title = RegexCleanPunctuation.Replace(title, ""); - - return title.ToLowerInvariant(); - } - - private string CleanTitle(string title) - { - title = RegexCleanPunctuation.Replace(title, ""); - title = RegexCleanCountryYearPostfix.Replace(title, ""); - - return title.ToLowerInvariant(); - } - - private string CleanArticles(string title) - { - title = ArticleRegex.Replace(title, ""); - - return title.Trim().ToLowerInvariant(); - } - - private int GetYearFactor(Series series) - { - if (_year.HasValue) - { - var offset = Math.Abs(series.Year - _year.Value); - if (offset <= 1) - { - return 20 - 10 * offset; - } - } - - return 0; - } - } -} diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/ActorResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/ActorResource.cs deleted file mode 100644 index 180933387..000000000 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/ActorResource.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace NzbDrone.Core.MetadataSource.SkyHook.Resource -{ - public class ActorResource - { - public string Name { get; set; } - public string Character { get; set; } - public string Image { get; set; } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/AlbumResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/AlbumResource.cs new file mode 100644 index 000000000..16de7ee09 --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/AlbumResource.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; + +namespace NzbDrone.Core.MetadataSource.SkyHook.Resource +{ + public class AlbumResource + { + public string ArtistId { get; set; } + public List<ArtistResource> Artists { get; set; } + public string Disambiguation { get; set; } + public string Overview { get; set; } + public string Id { get; set; } + public List<string> OldIds { get; set; } + public List<ImageResource> Images { get; set; } + public List<LinkResource> Links { get; set; } + public List<string> Genres { get; set; } + public RatingResource Rating { get; set; } + public DateTime? ReleaseDate { get; set; } + public List<ReleaseResource> Releases { get; set; } + public List<string> SecondaryTypes { get; set; } + public string Title { get; set; } + public string Type { get; set; } + public List<string> ReleaseStatuses { get; set; } + } +} diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/ArtistResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/ArtistResource.cs new file mode 100644 index 000000000..170617391 --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/ArtistResource.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.MetadataSource.SkyHook.Resource +{ + public class ArtistResource + { + public ArtistResource() + { + Albums = new List<AlbumResource>(); + Genres = new List<string>(); + } + + public List<string> Genres { get; set; } + public string AristUrl { get; set; } + public string Overview { get; set; } + public string Type { get; set; } + public string Disambiguation { get; set; } + public string Id { get; set; } + public List<string> OldIds { get; set; } + public List<ImageResource> Images { get; set; } + public List<LinkResource> Links { get; set; } + public string ArtistName { get; set; } + public List<string> ArtistAliases { get; set; } + public List<AlbumResource> Albums { get; set; } + public string Status { get; set; } + public RatingResource Rating { get; set; } + + } +} diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/EpisodeResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/EpisodeResource.cs deleted file mode 100644 index acaffe418..000000000 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/EpisodeResource.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; - -namespace NzbDrone.Core.MetadataSource.SkyHook.Resource -{ - public class EpisodeResource - { - public int SeasonNumber { get; set; } - public int EpisodeNumber { get; set; } - public int? AbsoluteEpisodeNumber { get; set; } - public string Title { get; set; } - public string AirDate { get; set; } - public DateTime? AirDateUtc { get; set; } - public RatingResource Rating { get; set; } - public string Overview { get; set; } - public string Image { get; set; } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/ImageResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/ImageResource.cs index 81a2f578e..b600bd0c0 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/ImageResource.cs +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/ImageResource.cs @@ -1,8 +1,10 @@ -namespace NzbDrone.Core.MetadataSource.SkyHook.Resource +namespace NzbDrone.Core.MetadataSource.SkyHook.Resource { public class ImageResource { public string CoverType { get; set; } public string Url { get; set; } + public int Height { get; set; } + public int Width { get; set; } } } \ No newline at end of file diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/LinkResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/LinkResource.cs new file mode 100644 index 000000000..3021fbdfe --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/LinkResource.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.MetadataSource.SkyHook.Resource +{ + public class LinkResource + { + public string Target { get; set; } + public string Type { get; set; } + } +} diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/MediumResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/MediumResource.cs new file mode 100644 index 000000000..3f7d00ac9 --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/MediumResource.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.MetadataSource.SkyHook.Resource +{ + public class MediumResource + { + public string Name { get; set; } + public string Format { get; set; } + public int Position { get; set; } + } + + +} diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/ReleaseResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/ReleaseResource.cs new file mode 100644 index 000000000..d4f675feb --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/ReleaseResource.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; + +namespace NzbDrone.Core.MetadataSource.SkyHook.Resource +{ + public class ReleaseResource + { + public string Disambiguation { get; set; } + public List<string> Country { get; set; } + public DateTime? ReleaseDate { get; set; } + public string Id { get; set; } + public List<string> OldIds { get; set; } + public List<string> Label { get; set; } + public List<MediumResource> Media { get; set; } + public string Title { get; set; } + public string Status { get; set; } + public int TrackCount { get; set; } + public List<TrackResource> Tracks { get; set; } + } +} diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/SeasonResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/SeasonResource.cs deleted file mode 100644 index 55ce6ccf9..000000000 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/SeasonResource.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Collections.Generic; - -namespace NzbDrone.Core.MetadataSource.SkyHook.Resource -{ - public class SeasonResource - { - public SeasonResource() - { - Images = new List<ImageResource>(); - } - - public int SeasonNumber { get; set; } - public List<ImageResource> Images { get; set; } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/ShowResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/ShowResource.cs deleted file mode 100644 index fd442100d..000000000 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/ShowResource.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Collections.Generic; - -namespace NzbDrone.Core.MetadataSource.SkyHook.Resource -{ - public class ShowResource - { - public ShowResource() - { - Actors = new List<ActorResource>(); - Genres = new List<string>(); - Images = new List<ImageResource>(); - Seasons = new List<SeasonResource>(); - Episodes = new List<EpisodeResource>(); - } - - public int TvdbId { get; set; } - public string Title { get; set; } - public string Overview { get; set; } - //public string Language { get; set; } - public string Slug { get; set; } - public string FirstAired { get; set; } - public int? TvRageId { get; set; } - public int? TvMazeId { get; set; } - - public string Status { get; set; } - public int? Runtime { get; set; } - public TimeOfDayResource TimeOfDay { get; set; } - - public string Network { get; set; } - public string ImdbId { get; set; } - - public List<ActorResource> Actors { get; set; } - public List<string> Genres { get; set; } - - public string ContentRating { get; set; } - - public RatingResource Rating { get; set; } - - public List<ImageResource> Images { get; set; } - public List<SeasonResource> Seasons { get; set; } - public List<EpisodeResource> Episodes { get; set; } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/TimeOfDayResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/TimeOfDayResource.cs deleted file mode 100644 index 242f92a7c..000000000 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/TimeOfDayResource.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace NzbDrone.Core.MetadataSource.SkyHook.Resource -{ - public class TimeOfDayResource - { - public int Hours { get; set; } - public int Minutes { get; set; } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/TrackResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/TrackResource.cs new file mode 100644 index 000000000..b9296d4ad --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/TrackResource.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.MetadataSource.SkyHook.Resource +{ + public class TrackResource + { + public TrackResource() + { + + } + + public string ArtistId { get; set; } + public int DurationMs { get; set; } + public string Id { get; set; } + public List<string> OldIds { get; set; } + public string RecordingId { get; set; } + public List<string> OldRecordingIds { get; set; } + public string TrackName { get; set; } + public string TrackNumber { get; set; } + public int TrackPosition { get; set; } + public bool Explicit { get; set; } + public int MediumNumber { get; set; } + + } +} diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs index 3c1ca6740..a5af778b9 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net; @@ -9,41 +9,120 @@ using NzbDrone.Common.Http; using NzbDrone.Core.Exceptions; using NzbDrone.Core.MediaCover; using NzbDrone.Core.MetadataSource.SkyHook.Resource; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Profiles.Metadata; namespace NzbDrone.Core.MetadataSource.SkyHook { - public class SkyHookProxy : IProvideSeriesInfo, ISearchForNewSeries + public class SkyHookProxy : IProvideArtistInfo, ISearchForNewArtist, IProvideAlbumInfo, ISearchForNewAlbum { private readonly IHttpClient _httpClient; private readonly Logger _logger; - - private readonly IHttpRequestBuilderFactory _requestBuilder; - - public SkyHookProxy(IHttpClient httpClient, ISonarrCloudRequestBuilder requestBuilder, Logger logger) + private readonly IArtistService _artistService; + private readonly IAlbumService _albumService; + private readonly IMetadataRequestBuilder _requestBuilder; + private readonly IConfigService _configService; + private readonly IMetadataProfileService _metadataProfileService; + + private static readonly List<string> nonAudioMedia = new List<string> { "DVD", "DVD-Video", "Blu-ray", "HD-DVD", "VCD", "SVCD", "UMD", "VHS" }; + private static readonly List<string> skippedTracks = new List<string> { "[data track]" }; + + public SkyHookProxy(IHttpClient httpClient, + IMetadataRequestBuilder requestBuilder, + IArtistService artistService, + IAlbumService albumService, + Logger logger, + IConfigService configService, + IMetadataProfileService metadataProfileService) { _httpClient = httpClient; - _requestBuilder = requestBuilder.SkyHookTvdb; + _configService = configService; + _metadataProfileService = metadataProfileService; + _requestBuilder = requestBuilder; + _artistService = artistService; + _albumService = albumService; _logger = logger; } - public Tuple<Series, List<Episode>> GetSeriesInfo(int tvdbSeriesId) + public Artist GetArtistInfo(string foreignArtistId, int metadataProfileId) { - var httpRequest = _requestBuilder.Create() - .SetSegment("route", "shows") - .Resource(tvdbSeriesId.ToString()) + + _logger.Debug("Getting Artist with LidarrAPI.MetadataID of {0}", foreignArtistId); + + var httpRequest = _requestBuilder.GetRequestBuilder().Create() + .SetSegment("route", "artist/" + foreignArtistId) .Build(); httpRequest.AllowAutoRedirect = true; httpRequest.SuppressHttpError = true; - var httpResponse = _httpClient.Get<ShowResource>(httpRequest); + var httpResponse = _httpClient.Get<ArtistResource>(httpRequest); + + + if (httpResponse.HasHttpError) + { + if (httpResponse.StatusCode == HttpStatusCode.NotFound) + { + throw new ArtistNotFoundException(foreignArtistId); + } + else if (httpResponse.StatusCode == HttpStatusCode.BadRequest) + { + throw new BadRequestException(foreignArtistId); + } + else + { + throw new HttpException(httpRequest, httpResponse); + } + } + + var artist = new Artist(); + artist.Metadata = MapArtistMetadata(httpResponse.Resource); + artist.CleanName = Parser.Parser.CleanArtistName(artist.Metadata.Value.Name); + artist.SortName = Parser.Parser.NormalizeTitle(artist.Metadata.Value.Name); + + artist.Albums = FilterAlbums(httpResponse.Resource.Albums, metadataProfileId) + .Select(x => MapAlbum(x, null)).ToList(); + + return artist; + } + + public IEnumerable<AlbumResource> FilterAlbums(IEnumerable<AlbumResource> albums, int metadataProfileId) + { + var metadataProfile = _metadataProfileService.Exists(metadataProfileId) ? _metadataProfileService.Get(metadataProfileId) : _metadataProfileService.All().First(); + var primaryTypes = new HashSet<string>(metadataProfile.PrimaryAlbumTypes.Where(s => s.Allowed).Select(s => s.PrimaryAlbumType.Name)); + var secondaryTypes = new HashSet<string>(metadataProfile.SecondaryAlbumTypes.Where(s => s.Allowed).Select(s => s.SecondaryAlbumType.Name)); + var releaseStatuses = new HashSet<string>(metadataProfile.ReleaseStatuses.Where(s => s.Allowed).Select(s => s.ReleaseStatus.Name)); + + + return albums.Where(album => primaryTypes.Contains(album.Type) && + (!album.SecondaryTypes.Any() && secondaryTypes.Contains("Studio") || + album.SecondaryTypes.Any(x => secondaryTypes.Contains(x))) && + album.ReleaseStatuses.Any(x => releaseStatuses.Contains(x))); + } + + public Tuple<string, Album, List<ArtistMetadata>> GetAlbumInfo(string foreignAlbumId) + { + _logger.Debug("Getting Album with LidarrAPI.MetadataID of {0}", foreignAlbumId); + + var httpRequest = _requestBuilder.GetRequestBuilder().Create() + .SetSegment("route", "album/" + foreignAlbumId) + .Build(); + + httpRequest.AllowAutoRedirect = true; + httpRequest.SuppressHttpError = true; + + var httpResponse = _httpClient.Get<AlbumResource>(httpRequest); if (httpResponse.HasHttpError) { if (httpResponse.StatusCode == HttpStatusCode.NotFound) { - throw new SeriesNotFoundException(tvdbSeriesId); + throw new AlbumNotFoundException(foreignAlbumId); + } + else if (httpResponse.StatusCode == HttpStatusCode.BadRequest) + { + throw new BadRequestException(foreignAlbumId); } else { @@ -51,175 +130,305 @@ namespace NzbDrone.Core.MetadataSource.SkyHook } } - var episodes = httpResponse.Resource.Episodes.Select(MapEpisode); - var series = MapSeries(httpResponse.Resource); + var artists = httpResponse.Resource.Artists.Select(MapArtistMetadata).ToList(); + var artistDict = artists.ToDictionary(x => x.ForeignArtistId, x => x); + var album = MapAlbum(httpResponse.Resource, artistDict); + album.ArtistMetadata = artistDict[httpResponse.Resource.ArtistId]; - return new Tuple<Series, List<Episode>>(series, episodes.ToList()); + return new Tuple<string, Album, List<ArtistMetadata>>(httpResponse.Resource.ArtistId, album, artists); } - public List<Series> SearchForNewSeries(string title) + public List<Artist> SearchForNewArtist(string title) { try { var lowerTitle = title.ToLowerInvariant(); - if (lowerTitle.StartsWith("tvdb:") || lowerTitle.StartsWith("tvdbid:")) + if (lowerTitle.StartsWith("lidarr:") || lowerTitle.StartsWith("lidarrid:") || lowerTitle.StartsWith("mbid:")) { var slug = lowerTitle.Split(':')[1].Trim(); - int tvdbId; + Guid searchGuid; + + bool isValid = Guid.TryParse(slug, out searchGuid); - if (slug.IsNullOrWhiteSpace() || slug.Any(char.IsWhiteSpace) || !int.TryParse(slug, out tvdbId) || tvdbId <= 0) + if (slug.IsNullOrWhiteSpace() || slug.Any(char.IsWhiteSpace) || isValid == false) { - return new List<Series>(); + return new List<Artist>(); } try { - return new List<Series> { GetSeriesInfo(tvdbId).Item1 }; + var existingArtist = _artistService.FindById(searchGuid.ToString()); + if (existingArtist != null) + { + return new List<Artist> { existingArtist }; + } + + var metadataProfile = _metadataProfileService.All().First().Id; //Change this to Use last Used profile? + + return new List<Artist> { GetArtistInfo(searchGuid.ToString(), metadataProfile) }; } - catch (SeriesNotFoundException) + catch (ArtistNotFoundException) { - return new List<Series>(); + return new List<Artist>(); } } - var httpRequest = _requestBuilder.Create() - .SetSegment("route", "search") - .AddQueryParam("term", title.ToLower().Trim()) - .Build(); + var httpRequest = _requestBuilder.GetRequestBuilder().Create() + .SetSegment("route", "search") + .AddQueryParam("type", "artist") + .AddQueryParam("query", title.ToLower().Trim()) + .Build(); - var httpResponse = _httpClient.Get<List<ShowResource>>(httpRequest); - return httpResponse.Resource.SelectList(MapSeries); + + var httpResponse = _httpClient.Get<List<ArtistResource>>(httpRequest); + + return httpResponse.Resource.SelectList(MapSearchResult); } catch (HttpException) { - throw new SkyHookException("Search for '{0}' failed. Unable to communicate with SkyHook.", title); + throw new SkyHookException("Search for '{0}' failed. Unable to communicate with LidarrAPI.", title); } catch (Exception ex) { _logger.Warn(ex, ex.Message); - throw new SkyHookException("Search for '{0}' failed. Invalid response received from SkyHook.", title); + throw new SkyHookException("Search for '{0}' failed. Invalid response received from LidarrAPI.", title); } } - private static Series MapSeries(ShowResource show) + public List<Album> SearchForNewAlbum(string title, string artist) { - var series = new Series(); - series.TvdbId = show.TvdbId; + try + { + var lowerTitle = title.ToLowerInvariant(); + + if (lowerTitle.StartsWith("lidarr:") || lowerTitle.StartsWith("lidarrid:") || lowerTitle.StartsWith("mbid:")) + { + var slug = lowerTitle.Split(':')[1].Trim(); + + Guid searchGuid; + + bool isValid = Guid.TryParse(slug, out searchGuid); + + if (slug.IsNullOrWhiteSpace() || slug.Any(char.IsWhiteSpace) || isValid == false) + { + return new List<Album>(); + } + + try + { + var existingAlbum = _albumService.FindById(searchGuid.ToString()); + + if (existingAlbum == null) + { + var data = GetAlbumInfo(searchGuid.ToString()); + var album = data.Item2; + album.Artist = _artistService.FindById(data.Item1) ?? new Artist { + Metadata = data.Item3.Single(x => x.ForeignArtistId == data.Item1) + }; - if (show.TvRageId.HasValue) + return new List<Album> { album }; + } + + existingAlbum.Artist = _artistService.GetArtist(existingAlbum.ArtistId); + return new List<Album>{existingAlbum}; + + } + catch (ArtistNotFoundException) + { + return new List<Album>(); + } + } + + var httpRequest = _requestBuilder.GetRequestBuilder().Create() + .SetSegment("route", "search") + .AddQueryParam("type", "album") + .AddQueryParam("query", title.ToLower().Trim()) + .AddQueryParam("artist", artist.IsNotNullOrWhiteSpace() ? artist.ToLower().Trim() : string.Empty) + .Build(); + + + + var httpResponse = _httpClient.Get<List<AlbumResource>>(httpRequest); + + return httpResponse.Resource.SelectList(MapSearchResult); + } + catch (HttpException) { - series.TvRageId = show.TvRageId.Value; + throw new SkyHookException("Search for '{0}' failed. Unable to communicate with LidarrAPI.", title); } - - if (show.TvMazeId.HasValue) + catch (Exception ex) { - series.TvMazeId = show.TvMazeId.Value; + _logger.Warn(ex, ex.Message); + throw new SkyHookException("Search for '{0}' failed. Invalid response received from LidarrAPI.", title); } + } - series.ImdbId = show.ImdbId; - series.Title = show.Title; - series.CleanTitle = Parser.Parser.CleanSeriesTitle(show.Title); - series.SortTitle = SeriesTitleNormalizer.Normalize(show.Title, show.TvdbId); - - if (show.FirstAired != null) + private Artist MapSearchResult(ArtistResource resource) + { + var artist = _artistService.FindById(resource.Id); + if (artist == null) { - series.FirstAired = DateTime.Parse(show.FirstAired).ToUniversalTime(); - series.Year = series.FirstAired.Value.Year; + artist = new Artist(); + artist.Metadata = MapArtistMetadata(resource); } - series.Overview = show.Overview; + return artist; + } + + private Album MapSearchResult(AlbumResource resource) + { + var album = _albumService.FindById(resource.Id) ?? MapAlbum(resource, null); - if (show.Runtime != null) + var artist = _artistService.FindById(resource.ArtistId); + if (artist == null) { - series.Runtime = show.Runtime.Value; + artist = new Artist(); + artist.Metadata = MapArtistMetadata(resource.Artists.Single(x => x.Id == resource.ArtistId)); } + album.Artist = artist; + album.ArtistMetadata = artist.Metadata; - series.Network = show.Network; + return album; + } - if (show.TimeOfDay != null) + private static Album MapAlbum(AlbumResource resource, Dictionary<string, ArtistMetadata> artistDict) + { + Album album = new Album(); + album.ForeignAlbumId = resource.Id; + album.OldForeignAlbumIds = resource.OldIds; + album.Title = resource.Title; + album.Overview = resource.Overview; + album.Disambiguation = resource.Disambiguation; + album.ReleaseDate = resource.ReleaseDate; + + if (resource.Images != null) { - series.AirTime = string.Format("{0:00}:{1:00}", show.TimeOfDay.Hours, show.TimeOfDay.Minutes); + album.Images = resource.Images.Select(MapImage).ToList(); } - series.TitleSlug = show.Slug; - series.Status = MapSeriesStatus(show.Status); - series.Ratings = MapRatings(show.Rating); - series.Genres = show.Genres; + album.AlbumType = resource.Type; + album.SecondaryTypes = resource.SecondaryTypes.Select(MapSecondaryTypes).ToList(); + album.Ratings = MapRatings(resource.Rating); + album.Links = resource.Links?.Select(MapLink).ToList(); + album.Genres = resource.Genres; + album.CleanTitle = Parser.Parser.CleanArtistName(album.Title); - if (show.ContentRating.IsNotNullOrWhiteSpace()) + if (resource.Releases != null) { - series.Certification = show.ContentRating.ToUpper(); + album.AlbumReleases = resource.Releases.Select(x => MapRelease(x, artistDict)).Where(x => x.TrackCount > 0).ToList(); } - - series.Actors = show.Actors.Select(MapActors).ToList(); - series.Seasons = show.Seasons.Select(MapSeason).ToList(); - series.Images = show.Images.Select(MapImage).ToList(); - return series; + album.AnyReleaseOk = true; + + return album; } - private static Actor MapActors(ActorResource arg) + private static AlbumRelease MapRelease(ReleaseResource resource, Dictionary<string, ArtistMetadata> artistDict) { - var newActor = new Actor - { - Name = arg.Name, - Character = arg.Character - }; - - if (arg.Image != null) + AlbumRelease release = new AlbumRelease(); + release.ForeignReleaseId = resource.Id; + release.OldForeignReleaseIds = resource.OldIds; + release.Title = resource.Title; + release.Status = resource.Status; + release.Label = resource.Label; + release.Disambiguation = resource.Disambiguation; + release.Country = resource.Country; + release.ReleaseDate = resource.ReleaseDate; + + // Get the complete set of media/tracks returned by the API, adding missing media if necessary + var allMedia = resource.Media.Select(MapMedium).ToList(); + var allTracks = resource.Tracks.Select(x => MapTrack(x, artistDict)); + if (!allMedia.Any()) { - newActor.Images = new List<MediaCover.MediaCover> + foreach(int n in allTracks.Select(x => x.MediumNumber).Distinct()) { - new MediaCover.MediaCover(MediaCoverTypes.Headshot, arg.Image) - }; + allMedia.Add(new Medium { Name = "Unknown", Number = n, Format = "Unknown" }); + } } - return newActor; - } + // Skip non-audio media + var audioMediaNumbers = allMedia.Where(x => !nonAudioMedia.Contains(x.Format)).Select(x => x.Number); - private static Episode MapEpisode(EpisodeResource oracleEpisode) - { - var episode = new Episode(); - episode.Overview = oracleEpisode.Overview; - episode.SeasonNumber = oracleEpisode.SeasonNumber; - episode.EpisodeNumber = oracleEpisode.EpisodeNumber; - episode.AbsoluteEpisodeNumber = oracleEpisode.AbsoluteEpisodeNumber; - episode.Title = oracleEpisode.Title; + // Get tracks on the audio media and omit any that are skipped + release.Tracks = allTracks.Where(x => audioMediaNumbers.Contains(x.MediumNumber) && !skippedTracks.Contains(x.Title)).ToList(); + release.TrackCount = release.Tracks.Value.Count; - episode.AirDate = oracleEpisode.AirDate; - episode.AirDateUtc = oracleEpisode.AirDateUtc; + // Only include the media that contain the tracks we have selected + var usedMediaNumbers = release.Tracks.Value.Select(track => track.MediumNumber); + release.Media = allMedia.Where(medium => usedMediaNumbers.Contains(medium.Number)).ToList(); + + release.Duration = release.Tracks.Value.Sum(x => x.Duration); - episode.Ratings = MapRatings(oracleEpisode.Rating); + return release; + } - //Don't include series fanart images as episode screenshot - if (oracleEpisode.Image != null) + private static Medium MapMedium(MediumResource resource) + { + Medium medium = new Medium { - episode.Images.Add(new MediaCover.MediaCover(MediaCoverTypes.Screenshot, oracleEpisode.Image)); - } + Name = resource.Name, + Number = resource.Position, + Format = resource.Format + }; - return episode; + return medium; } - private static Season MapSeason(SeasonResource seasonResource) + private static Track MapTrack(TrackResource resource, Dictionary<string, ArtistMetadata> artistDict) { - return new Season + Track track = new Track { - SeasonNumber = seasonResource.SeasonNumber, - Images = seasonResource.Images.Select(MapImage).ToList() + ArtistMetadata = artistDict[resource.ArtistId], + Title = resource.TrackName, + ForeignTrackId = resource.Id, + OldForeignTrackIds = resource.OldIds, + ForeignRecordingId = resource.RecordingId, + OldForeignRecordingIds = resource.OldRecordingIds, + TrackNumber = resource.TrackNumber, + AbsoluteTrackNumber = resource.TrackPosition, + Duration = resource.DurationMs, + MediumNumber = resource.MediumNumber }; + + return track; + } + + private static ArtistMetadata MapArtistMetadata(ArtistResource resource) + { + + ArtistMetadata artist = new ArtistMetadata(); + + artist.Name = resource.ArtistName; + artist.Aliases = resource.ArtistAliases; + artist.ForeignArtistId = resource.Id; + artist.OldForeignArtistIds = resource.OldIds; + artist.Genres = resource.Genres; + artist.Overview = resource.Overview; + artist.Disambiguation = resource.Disambiguation; + artist.Type = resource.Type; + artist.Status = MapArtistStatus(resource.Status); + artist.Ratings = MapRatings(resource.Rating); + artist.Images = resource.Images?.Select(MapImage).ToList(); + artist.Links = resource.Links?.Select(MapLink).ToList(); + return artist; } - private static SeriesStatusType MapSeriesStatus(string status) + private static ArtistStatusType MapArtistStatus(string status) { + if (status == null) + { + return ArtistStatusType.Continuing; + } + if (status.Equals("ended", StringComparison.InvariantCultureIgnoreCase)) { - return SeriesStatusType.Ended; + return ArtistStatusType.Ended; } - return SeriesStatusType.Continuing; + return ArtistStatusType.Continuing; } private static Ratings MapRatings(RatingResource rating) @@ -245,6 +454,15 @@ namespace NzbDrone.Core.MetadataSource.SkyHook }; } + private static Links MapLink(LinkResource arg) + { + return new Links + { + Url = arg.Target, + Name = arg.Type + }; + } + private static MediaCoverTypes MapCoverType(string coverType) { switch (coverType.ToLower()) @@ -255,9 +473,44 @@ namespace NzbDrone.Core.MetadataSource.SkyHook return MediaCoverTypes.Banner; case "fanart": return MediaCoverTypes.Fanart; + case "cover": + return MediaCoverTypes.Cover; + case "disc": + return MediaCoverTypes.Disc; + case "logo": + return MediaCoverTypes.Logo; default: return MediaCoverTypes.Unknown; } } + + public static SecondaryAlbumType MapSecondaryTypes(string albumType) + { + switch (albumType.ToLowerInvariant()) + { + case "compilation": + return SecondaryAlbumType.Compilation; + case "soundtrack": + return SecondaryAlbumType.Soundtrack; + case "spokenword": + return SecondaryAlbumType.Spokenword; + case "interview": + return SecondaryAlbumType.Interview; + case "audiobook": + return SecondaryAlbumType.Audiobook; + case "live": + return SecondaryAlbumType.Live; + case "remix": + return SecondaryAlbumType.Remix; + case "dj-mix": + return SecondaryAlbumType.DJMix; + case "mixtape/street": + return SecondaryAlbumType.Mixtape; + case "demo": + return SecondaryAlbumType.Demo; + default: + return SecondaryAlbumType.Studio; + } + } } } diff --git a/src/NzbDrone.Core/Music/AddArtistOptions.cs b/src/NzbDrone.Core/Music/AddArtistOptions.cs new file mode 100644 index 000000000..842a44aae --- /dev/null +++ b/src/NzbDrone.Core/Music/AddArtistOptions.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Music +{ + public class AddArtistOptions : MonitoringOptions + { + public bool SearchForMissingAlbums { get; set; } + } +} diff --git a/src/NzbDrone.Core/Music/AddArtistService.cs b/src/NzbDrone.Core/Music/AddArtistService.cs new file mode 100644 index 000000000..e56127443 --- /dev/null +++ b/src/NzbDrone.Core/Music/AddArtistService.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using FluentValidation; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.EnsureThat; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.MetadataSource; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.Music +{ + public interface IAddArtistService + { + Artist AddArtist(Artist newArtist); + List<Artist> AddArtists(List<Artist> newArtists); + } + + public class AddArtistService : IAddArtistService + { + private readonly IArtistService _artistService; + private readonly IArtistMetadataService _artistMetadataService; + private readonly IProvideArtistInfo _artistInfo; + private readonly IBuildFileNames _fileNameBuilder; + private readonly IAddArtistValidator _addArtistValidator; + private readonly Logger _logger; + + public AddArtistService(IArtistService artistService, + IArtistMetadataService artistMetadataService, + IProvideArtistInfo artistInfo, + IBuildFileNames fileNameBuilder, + IAddArtistValidator addArtistValidator, + Logger logger) + { + _artistService = artistService; + _artistMetadataService = artistMetadataService; + _artistInfo = artistInfo; + _fileNameBuilder = fileNameBuilder; + _addArtistValidator = addArtistValidator; + _logger = logger; + } + + public Artist AddArtist(Artist newArtist) + { + Ensure.That(newArtist, () => newArtist).IsNotNull(); + + newArtist = AddSkyhookData(newArtist); + newArtist = SetPropertiesAndValidate(newArtist); + + _logger.Info("Adding Artist {0} Path: [{1}]", newArtist, newArtist.Path); + + // add metadata + _artistMetadataService.Upsert(newArtist.Metadata.Value); + newArtist.ArtistMetadataId = newArtist.Metadata.Value.Id; + + // add the artist itself + _artistService.AddArtist(newArtist); + + return newArtist; + } + + public List<Artist> AddArtists(List<Artist> newArtists) + { + var added = DateTime.UtcNow; + var artistsToAdd = new List<Artist>(); + + foreach (var s in newArtists) + { + // TODO: Verify if adding skyhook data will be slow + try + { + var artist = AddSkyhookData(s); + artist = SetPropertiesAndValidate(artist); + artist.Added = added; + artistsToAdd.Add(artist); + } + catch (Exception ex) + { + // Catch Import Errors for now until we get things fixed up + _logger.Error(ex, "Failed to import id: {0} - {1}", s.Metadata.Value.ForeignArtistId, s.Metadata.Value.Name); + } + + } + + // add metadata + _artistMetadataService.UpsertMany(artistsToAdd.Select(x => x.Metadata.Value).ToList()); + artistsToAdd.ForEach(x => x.ArtistMetadataId = x.Metadata.Value.Id); + + return _artistService.AddArtists(artistsToAdd); + } + + private Artist AddSkyhookData(Artist newArtist) + { + Artist artist; + + try + { + artist = _artistInfo.GetArtistInfo(newArtist.Metadata.Value.ForeignArtistId, newArtist.MetadataProfileId); + } + catch (ArtistNotFoundException) + { + _logger.Error("LidarrId {0} was not found, it may have been removed from Lidarr.", newArtist.Metadata.Value.ForeignArtistId); + + throw new ValidationException(new List<ValidationFailure> + { + new ValidationFailure("SpotifyId", "An artist with this ID was not found", newArtist.Metadata.Value.ForeignArtistId) + }); + } + + // If albums were passed in on the new artist use them, otherwise use the albums from Skyhook + if (newArtist.Albums == null || newArtist.Albums.Value == null || !newArtist.Albums.Value.Any()) + { + newArtist.Albums = artist.Albums.Value; + } + + artist.ApplyChanges(newArtist); + + return artist; + } + + private Artist SetPropertiesAndValidate(Artist newArtist) + { + if (string.IsNullOrWhiteSpace(newArtist.Path)) + { + var folderName = _fileNameBuilder.GetArtistFolder(newArtist); + newArtist.Path = Path.Combine(newArtist.RootFolderPath, folderName); + } + + newArtist.CleanName = newArtist.Metadata.Value.Name.CleanArtistName(); + newArtist.SortName = ArtistNameNormalizer.Normalize(newArtist.Metadata.Value.Name, newArtist.Metadata.Value.ForeignArtistId); + newArtist.Added = DateTime.UtcNow; + + if (newArtist.AddOptions != null && newArtist.AddOptions.Monitor == MonitorTypes.None) + { + newArtist.Monitored = false; + } + + var validationResult = _addArtistValidator.Validate(newArtist); + + if (!validationResult.IsValid) + { + throw new ValidationException(validationResult.Errors); + } + + return newArtist; + } + } +} diff --git a/src/NzbDrone.Core/Music/AddArtistValidator.cs b/src/NzbDrone.Core/Music/AddArtistValidator.cs new file mode 100644 index 000000000..75294bf50 --- /dev/null +++ b/src/NzbDrone.Core/Music/AddArtistValidator.cs @@ -0,0 +1,33 @@ +using FluentValidation; +using FluentValidation.Results; +using NzbDrone.Core.Validation.Paths; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Music +{ + public interface IAddArtistValidator + { + ValidationResult Validate(Artist instance); + } + + public class AddArtistValidator : AbstractValidator<Artist>, IAddArtistValidator + { + public AddArtistValidator(RootFolderValidator rootFolderValidator, + ArtistPathValidator artistPathValidator, + ArtistAncestorValidator artistAncestorValidator, + ProfileExistsValidator profileExistsValidator, + MetadataProfileExistsValidator metadataProfileExistsValidator) + { + RuleFor(c => c.Path).Cascade(CascadeMode.StopOnFirstFailure) + .IsValidPath() + .SetValidator(rootFolderValidator) + .SetValidator(artistPathValidator) + .SetValidator(artistAncestorValidator); + + RuleFor(c => c.QualityProfileId).SetValidator(profileExistsValidator); + + RuleFor(c => c.MetadataProfileId).SetValidator(metadataProfileExistsValidator); + + } + } +} diff --git a/src/NzbDrone.Core/Music/Album.cs b/src/NzbDrone.Core/Music/Album.cs new file mode 100644 index 000000000..2ece54b34 --- /dev/null +++ b/src/NzbDrone.Core/Music/Album.cs @@ -0,0 +1,106 @@ +using NzbDrone.Common.Extensions; +using System; +using System.Collections.Generic; +using Marr.Data; +using Equ; +using System.Linq; + +namespace NzbDrone.Core.Music +{ + public class Album : Entity<Album> + { + public Album() + { + OldForeignAlbumIds = new List<string>(); + + Images = new List<MediaCover.MediaCover>(); + Links = new List<Links>(); + Genres = new List<string>(); + SecondaryTypes = new List<SecondaryAlbumType>(); + Ratings = new Ratings(); + Artist = new Artist(); + + } + + // These correspond to columns in the Albums table + // These are metadata entries + public int ArtistMetadataId { get; set; } + public string ForeignAlbumId { get; set; } + public List<string> OldForeignAlbumIds { get; set; } + public string Title { get; set; } + public string Overview { get; set; } + public string Disambiguation { get; set; } + public DateTime? ReleaseDate { get; set; } + public List<MediaCover.MediaCover> Images { get; set; } + public List<Links> Links { get; set; } + public List<string> Genres { get; set; } + public String AlbumType { get; set; } + public List<SecondaryAlbumType> SecondaryTypes { get; set; } + public Ratings Ratings { get; set; } + + // These are Lidarr generated/config + public string CleanTitle { get; set; } + public int ProfileId { get; set; } + public bool Monitored { get; set; } + public bool AnyReleaseOk { get; set; } + public DateTime? LastInfoSync { get; set; } + public DateTime Added { get; set; } + [MemberwiseEqualityIgnore] + public AddArtistOptions AddOptions { get; set; } + + // These are dynamically queried from other tables + [MemberwiseEqualityIgnore] + public LazyLoaded<ArtistMetadata> ArtistMetadata { get; set; } + [MemberwiseEqualityIgnore] + public LazyLoaded<List<AlbumRelease>> AlbumReleases { get; set; } + [MemberwiseEqualityIgnore] + public LazyLoaded<Artist> Artist { get; set; } + + //compatibility properties with old version of Album + [MemberwiseEqualityIgnore] + public int ArtistId { get { return Artist?.Value?.Id ?? 0; } set { Artist.Value.Id = value; } } + + public override string ToString() + { + return string.Format("[{0}][{1}]", ForeignAlbumId, Title.NullSafe()); + } + + public override void UseMetadataFrom(Album other) + { + ForeignAlbumId = other.ForeignAlbumId; + OldForeignAlbumIds = other.OldForeignAlbumIds; + Title = other.Title; + Overview = other.Overview.IsNullOrWhiteSpace() ? Overview : other.Overview; + Disambiguation = other.Disambiguation; + ReleaseDate = other.ReleaseDate; + Images = other.Images.Any() ? other.Images : Images; + Links = other.Links; + Genres = other.Genres; + AlbumType = other.AlbumType; + SecondaryTypes = other.SecondaryTypes; + Ratings = other.Ratings; + CleanTitle = other.CleanTitle; + } + + public override void UseDbFieldsFrom(Album other) + { + Id = other.Id; + ArtistMetadataId = other.ArtistMetadataId; + ProfileId = other.ProfileId; + Monitored = other.Monitored; + AnyReleaseOk = other.AnyReleaseOk; + LastInfoSync = other.LastInfoSync; + Added = other.Added; + AddOptions = other.AddOptions; + } + + public override void ApplyChanges(Album otherAlbum) + { + ForeignAlbumId = otherAlbum.ForeignAlbumId; + ProfileId = otherAlbum.ProfileId; + AddOptions = otherAlbum.AddOptions; + Monitored = otherAlbum.Monitored; + AnyReleaseOk = otherAlbum.AnyReleaseOk; + } + } +} diff --git a/src/NzbDrone.Core/Music/AlbumAddedService.cs b/src/NzbDrone.Core/Music/AlbumAddedService.cs new file mode 100644 index 000000000..ea1557a51 --- /dev/null +++ b/src/NzbDrone.Core/Music/AlbumAddedService.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.IndexerSearch; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Music.Events; + +namespace NzbDrone.Core.Music +{ + public interface IAlbumAddedService + { + void SearchForRecentlyAdded(int artistId); + } + + public class AlbumAddedService : IHandle<AlbumInfoRefreshedEvent>, IAlbumAddedService + { + private readonly IManageCommandQueue _commandQueueManager; + private readonly IAlbumService _albumService; + private readonly Logger _logger; + private readonly ICached<List<int>> _addedAlbumsCache; + + public AlbumAddedService(ICacheManager cacheManager, + IManageCommandQueue commandQueueManager, + IAlbumService albumService, + Logger logger) + { + _commandQueueManager = commandQueueManager; + _albumService = albumService; + _logger = logger; + _addedAlbumsCache = cacheManager.GetCache<List<int>>(GetType()); + } + + public void SearchForRecentlyAdded(int artistId) + { + var previouslyReleased = _addedAlbumsCache.Find(artistId.ToString()); + + if (previouslyReleased != null && previouslyReleased.Any()) + { + var missing = previouslyReleased.Select(e => _albumService.GetAlbum(e)).ToList(); + + if (missing.Any()) + { + _commandQueueManager.Push(new AlbumSearchCommand(missing.Select(e => e.Id).ToList())); + } + } + + _addedAlbumsCache.Remove(artistId.ToString()); + } + + public void Handle(AlbumInfoRefreshedEvent message) + { + if (message.Artist.AddOptions == null) + { + if (!message.Artist.Monitored) + { + _logger.Debug("Artist is not monitored"); + return; + } + + if (message.Added.Empty()) + { + _logger.Debug("No new albums, skipping search"); + return; + } + + if (message.Added.None(a => a.ReleaseDate.HasValue)) + { + _logger.Debug("No new albums have an release date"); + return; + } + + var previouslyReleased = message.Added.Where(a => a.ReleaseDate.HasValue && a.ReleaseDate.Value.Before(DateTime.UtcNow.AddDays(1)) && a.Monitored).ToList(); + + if (previouslyReleased.Empty()) + { + _logger.Debug("Newly added albums all release in the future"); + return; + } + + _addedAlbumsCache.Set(message.Artist.Id.ToString(), previouslyReleased.Select(e => e.Id).ToList()); + } + } + } +} diff --git a/src/NzbDrone.Core/Music/AlbumCutoffService.cs b/src/NzbDrone.Core/Music/AlbumCutoffService.cs new file mode 100644 index 000000000..879abd3d0 --- /dev/null +++ b/src/NzbDrone.Core/Music/AlbumCutoffService.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Profiles.Qualities; +using NzbDrone.Core.Qualities; + +namespace NzbDrone.Core.Music +{ + public interface IAlbumCutoffService + { + PagingSpec<Album> AlbumsWhereCutoffUnmet(PagingSpec<Album> pagingSpec); + } + + public class AlbumCutoffService : IAlbumCutoffService + { + private readonly IAlbumRepository _albumRepository; + private readonly IProfileService _profileService; + private readonly Logger _logger; + + public AlbumCutoffService(IAlbumRepository albumRepository, IProfileService profileService, Logger logger) + { + _albumRepository = albumRepository; + _profileService = profileService; + _logger = logger; + } + + public PagingSpec<Album> AlbumsWhereCutoffUnmet(PagingSpec<Album> pagingSpec) + { + var qualitiesBelowCutoff = new List<QualitiesBelowCutoff>(); + var profiles = _profileService.All(); + + //Get all items less than the cutoff + foreach (var profile in profiles) + { + var cutoffIndex = profile.GetIndex(profile.Cutoff); + var belowCutoff = profile.Items.Take(cutoffIndex.Index).ToList(); + + if (belowCutoff.Any()) + { + qualitiesBelowCutoff.Add(new QualitiesBelowCutoff(profile.Id, belowCutoff.SelectMany(i => i.GetQualities().Select(q => q.Id)))); + } + } + + return _albumRepository.AlbumsWhereCutoffUnmet(pagingSpec, qualitiesBelowCutoff); + } + } +} diff --git a/src/NzbDrone.Core/Music/AlbumEditedService.cs b/src/NzbDrone.Core/Music/AlbumEditedService.cs new file mode 100644 index 000000000..26cacfc4e --- /dev/null +++ b/src/NzbDrone.Core/Music/AlbumEditedService.cs @@ -0,0 +1,42 @@ +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.MediaFiles.Commands; +using NzbDrone.Core.Music.Events; +using System.Linq; +using System.Collections.Generic; +using NzbDrone.Core.MediaFiles; + +namespace NzbDrone.Core.Music +{ + public class AlbumEditedService : IHandle<AlbumEditedEvent> + { + private readonly IManageCommandQueue _commandQueueManager; + private readonly ITrackService _trackService; + + public AlbumEditedService(IManageCommandQueue commandQueueManager, + ITrackService trackService) + { + _commandQueueManager = commandQueueManager; + _trackService = trackService; + } + + public void Handle(AlbumEditedEvent message) + { + if (message.Album.AlbumReleases.IsLoaded && message.OldAlbum.AlbumReleases.IsLoaded) + { + var new_monitored = new HashSet<int>(message.Album.AlbumReleases.Value.Where(x => x.Monitored).Select(x => x.Id)); + var old_monitored = new HashSet<int>(message.OldAlbum.AlbumReleases.Value.Where(x => x.Monitored).Select(x => x.Id)); + if (!new_monitored.SetEquals(old_monitored) || + (message.OldAlbum.AnyReleaseOk == false && message.Album.AnyReleaseOk == true)) + { + // Unlink any old track files + var tracks = _trackService.GetTracksByAlbum(message.Album.Id); + tracks.ForEach(x => x.TrackFileId = 0); + _trackService.SetFileIds(tracks); + + _commandQueueManager.Push(new RescanArtistCommand(message.Album.ArtistId, FilterFilesType.Matched)); + } + } + } + } +} diff --git a/src/NzbDrone.Core/Music/AlbumMonitoredService.cs b/src/NzbDrone.Core/Music/AlbumMonitoredService.cs new file mode 100644 index 000000000..c9c6756f9 --- /dev/null +++ b/src/NzbDrone.Core/Music/AlbumMonitoredService.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Net.Sockets; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Music +{ + public interface IAlbumMonitoredService + { + void SetAlbumMonitoredStatus(Artist artist, MonitoringOptions monitoringOptions); + } + + public class AlbumMonitoredService : IAlbumMonitoredService + { + private readonly IArtistService _artistService; + private readonly IAlbumService _albumService; + private readonly ITrackService _trackService; + private readonly Logger _logger; + + public AlbumMonitoredService(IArtistService artistService, IAlbumService albumService, ITrackService trackService, Logger logger) + { + _artistService = artistService; + _albumService = albumService; + _trackService = trackService; + _logger = logger; + } + + public void SetAlbumMonitoredStatus(Artist artist, MonitoringOptions monitoringOptions) + { + if (monitoringOptions != null) + { + _logger.Debug("[{0}] Setting album monitored status.", artist.Name); + + var albums = _albumService.GetAlbumsByArtist(artist.Id); + + var albumsWithFiles = _albumService.GetArtistAlbumsWithFiles(artist); + + var albumsWithoutFiles = albums.Where(c => !albumsWithFiles.Select(e => e.Id).Contains(c.Id) && c.ReleaseDate <= DateTime.UtcNow).ToList(); + + var monitoredAlbums = monitoringOptions.AlbumsToMonitor; + + // If specific albums are passed use those instead of the monitoring options. + if (monitoredAlbums.Any()) + { + ToggleAlbumsMonitoredState( + albums.Where(s => monitoredAlbums.Any(t => t == s.ForeignAlbumId)), true); + ToggleAlbumsMonitoredState( + albums.Where(s => monitoredAlbums.Any(t => t != s.ForeignAlbumId)), false); + } + else + { + switch (monitoringOptions.Monitor) + { + case MonitorTypes.All: + ToggleAlbumsMonitoredState(albums, true); + break; + case MonitorTypes.Future: + _logger.Debug("Unmonitoring Albums with Files"); + ToggleAlbumsMonitoredState(albums.Where(e => albumsWithFiles.Select(c => c.Id).Contains(e.Id)), false); + _logger.Debug("Unmonitoring Albums without Files"); + ToggleAlbumsMonitoredState(albums.Where(e => albumsWithoutFiles.Select(c => c.Id).Contains(e.Id)), false); + break; + case MonitorTypes.None: + ToggleAlbumsMonitoredState(albums, false); + break; + case MonitorTypes.Missing: + _logger.Debug("Unmonitoring Albums with Files"); + ToggleAlbumsMonitoredState(albums.Where(e => albumsWithFiles.Select(c => c.Id).Contains(e.Id)), false); + _logger.Debug("Monitoring Albums without Files"); + ToggleAlbumsMonitoredState(albums.Where(e => albumsWithoutFiles.Select(c => c.Id).Contains(e.Id)), true); + break; + case MonitorTypes.Existing: + _logger.Debug("Monitoring Albums with Files"); + ToggleAlbumsMonitoredState(albums.Where(e => albumsWithFiles.Select(c => c.Id).Contains(e.Id)), true); + _logger.Debug("Unmonitoring Albums without Files"); + ToggleAlbumsMonitoredState(albums.Where(e => albumsWithoutFiles.Select(c => c.Id).Contains(e.Id)), false); + break; + case MonitorTypes.Latest: + ToggleAlbumsMonitoredState(albums, false); + ToggleAlbumsMonitoredState(albums.OrderByDescending(e=>e.ReleaseDate).Take(1),true); + break; + case MonitorTypes.First: + ToggleAlbumsMonitoredState(albums, false); + ToggleAlbumsMonitoredState(albums.OrderBy(e => e.ReleaseDate).Take(1), true); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + _albumService.UpdateMany(albums); + } + + _artistService.UpdateArtist(artist); + } + + private void ToggleAlbumsMonitoredState(IEnumerable<Album> albums, bool monitored) + { + foreach (var album in albums) + { + album.Monitored = monitored; + } + } + } +} diff --git a/src/NzbDrone.Core/Music/AlbumRepository.cs b/src/NzbDrone.Core/Music/AlbumRepository.cs new file mode 100644 index 000000000..252af816a --- /dev/null +++ b/src/NzbDrone.Core/Music/AlbumRepository.cs @@ -0,0 +1,385 @@ +using System; +using System.Linq; +using NLog; +using Marr.Data.QGen; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Datastore.Extensions; +using System.Collections.Generic; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Qualities; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.Music +{ + public interface IAlbumRepository : IBasicRepository<Album> + { + List<Album> GetAlbums(int artistId); + List<Album> GetLastAlbums(IEnumerable<int> artistMetadataIds); + List<Album> GetNextAlbums(IEnumerable<int> artistMetadataIds); + List<Album> GetAlbumsByArtistMetadataId(int artistMetadataId); + List<Album> GetAlbumsForRefresh(int artistId, IEnumerable<string> foreignIds); + Album FindByTitle(int artistMetadataId, string title); + Album FindById(string foreignId); + PagingSpec<Album> AlbumsWithoutFiles(PagingSpec<Album> pagingSpec); + PagingSpec<Album> AlbumsWhereCutoffUnmet(PagingSpec<Album> pagingSpec, List<QualitiesBelowCutoff> qualitiesBelowCutoff); + List<Album> AlbumsBetweenDates(DateTime startDate, DateTime endDate, bool includeUnmonitored); + List<Album> ArtistAlbumsBetweenDates(Artist artist, DateTime startDate, DateTime endDate, bool includeUnmonitored); + void SetMonitoredFlat(Album album, bool monitored); + void SetMonitored(IEnumerable<int> ids, bool monitored); + Album FindAlbumByRelease(string albumReleaseId); + Album FindAlbumByTrack(int trackId); + List<Album> GetArtistAlbumsWithFiles(Artist artist); + } + + public class AlbumRepository : BasicRepository<Album>, IAlbumRepository + { + private readonly IMainDatabase _database; + private readonly Logger _logger; + + public AlbumRepository(IMainDatabase database, IEventAggregator eventAggregator, Logger logger) + : base(database, eventAggregator) + { + _database = database; + _logger = logger; + } + + public List<Album> GetAlbums(int artistId) + { + return Query.Join<Album, Artist>(JoinType.Inner, album => album.Artist, (l, r) => l.ArtistMetadataId == r.ArtistMetadataId) + .Where<Artist>(a => a.Id == artistId).ToList(); + } + + public List<Album> GetLastAlbums(IEnumerable<int> artistMetadataIds) + { + string query = string.Format("SELECT Albums.* " + + "FROM Albums " + + "WHERE Albums.ArtistMetadataId IN ({0}) " + + "AND Albums.ReleaseDate < datetime('now') " + + "GROUP BY Albums.ArtistMetadataId " + + "HAVING Albums.ReleaseDate = MAX(Albums.ReleaseDate)", + string.Join(", ", artistMetadataIds)); + + return Query.QueryText(query); + } + + public List<Album> GetNextAlbums(IEnumerable<int> artistMetadataIds) + { + string query = string.Format("SELECT Albums.* " + + "FROM Albums " + + "WHERE Albums.ArtistMetadataId IN ({0}) " + + "AND Albums.ReleaseDate > datetime('now') " + + "GROUP BY Albums.ArtistMetadataId " + + "HAVING Albums.ReleaseDate = MIN(Albums.ReleaseDate)", + string.Join(", ", artistMetadataIds)); + + return Query.QueryText(query); + } + + public List<Album> GetAlbumsByArtistMetadataId(int artistMetadataId) + { + return Query.Where(s => s.ArtistMetadataId == artistMetadataId); + } + + public List<Album> GetAlbumsForRefresh(int artistMetadataId, IEnumerable<string> foreignIds) + { + return Query + .Where(a => a.ArtistMetadataId == artistMetadataId) + .OrWhere($"[ForeignAlbumId] IN ('{string.Join("', '", foreignIds)}')") + .ToList(); + } + + public Album FindById(string foreignAlbumId) + { + return Query.Where(s => s.ForeignAlbumId == foreignAlbumId).SingleOrDefault(); + } + + public PagingSpec<Album> AlbumsWithoutFiles(PagingSpec<Album> pagingSpec) + { + var currentTime = DateTime.UtcNow; + + //pagingSpec.TotalRecords = GetMissingAlbumsQuery(pagingSpec, currentTime).GetRowCount(); Cant Use GetRowCount with a Manual Query + + pagingSpec.TotalRecords = GetMissingAlbumsQueryCount(pagingSpec, currentTime); + pagingSpec.Records = GetMissingAlbumsQuery(pagingSpec, currentTime).ToList(); + + return pagingSpec; + } + + public PagingSpec<Album> AlbumsWhereCutoffUnmet(PagingSpec<Album> pagingSpec, List<QualitiesBelowCutoff> qualitiesBelowCutoff) + { + + pagingSpec.TotalRecords = GetCutOffAlbumsQueryCount(pagingSpec, qualitiesBelowCutoff); + pagingSpec.Records = GetCutOffAlbumsQuery(pagingSpec, qualitiesBelowCutoff).ToList(); + + return pagingSpec; + } + + public List<Album> AlbumsBetweenDates(DateTime startDate, DateTime endDate, bool includeUnmonitored) + { + var query = Query.Join<Album, Artist>(JoinType.Inner, rg => rg.Artist, (rg, a) => rg.ArtistMetadataId == a.ArtistMetadataId) + .Where<Album>(rg => rg.ReleaseDate >= startDate) + .AndWhere(rg => rg.ReleaseDate <= endDate); + + + if (!includeUnmonitored) + { + query.AndWhere(e => e.Monitored) + .AndWhere(e => e.Artist.Value.Monitored); + } + + return query.ToList(); + } + + public List<Album> ArtistAlbumsBetweenDates(Artist artist, DateTime startDate, DateTime endDate, bool includeUnmonitored) + { + var query = Query.Join<Album, Artist>(JoinType.Inner, e => e.Artist, (e, s) => e.ArtistMetadataId == s.ArtistMetadataId) + .Where<Album>(e => e.ReleaseDate >= startDate) + .AndWhere(e => e.ReleaseDate <= endDate) + .AndWhere(e => e.ArtistMetadataId == artist.ArtistMetadataId); + + + if (!includeUnmonitored) + { + query.AndWhere(e => e.Monitored) + .AndWhere(e => e.Artist.Value.Monitored); + } + + return query.ToList(); + } + + private QueryBuilder<Album> GetMissingAlbumsQuery(PagingSpec<Album> pagingSpec, DateTime currentTime) + { + string sortKey; + string monitored = "(Albums.[Monitored] = 0) OR (Artists.[Monitored] = 0)"; + + if (pagingSpec.FilterExpressions.FirstOrDefault().ToString().Contains("True")) + { + monitored = "(Albums.[Monitored] = 1) AND (Artists.[Monitored] = 1)"; + } + + if (pagingSpec.SortKey == "releaseDate") + { + sortKey = "Albums." + pagingSpec.SortKey; + } + else if (pagingSpec.SortKey == "artist.sortName") + { + sortKey = "Artists." + pagingSpec.SortKey.Split('.').Last(); + } + else if (pagingSpec.SortKey == "albumTitle") + { + sortKey = "Albums.title"; + } + else + { + sortKey = "Albums.releaseDate"; + } + + string query = string.Format("SELECT Albums.* " + + "FROM Albums " + + "JOIN Artists ON Albums.ArtistMetadataId = Artists.ArtistMetadataId " + + "JOIN AlbumReleases ON AlbumReleases.AlbumId == Albums.Id " + + "JOIN Tracks ON Tracks.AlbumReleaseId == AlbumReleases.Id " + + "LEFT OUTER JOIN TrackFiles ON TrackFiles.Id == Tracks.TrackFileId " + + "WHERE TrackFiles.Id IS NULL " + + "AND AlbumReleases.Monitored = 1 " + + "AND ({0}) AND {1} " + + "GROUP BY Albums.Id " + + " ORDER BY {2} {3} LIMIT {4} OFFSET {5}", + monitored, + BuildReleaseDateCutoffWhereClause(currentTime), + sortKey, + pagingSpec.ToSortDirection(), + pagingSpec.PageSize, + pagingSpec.PagingOffset()); + + return Query.QueryText(query); + } + + private int GetMissingAlbumsQueryCount(PagingSpec<Album> pagingSpec, DateTime currentTime) + { + var monitored = "(Albums.[Monitored] = 0) OR (Artists.[Monitored] = 0)"; + + if (pagingSpec.FilterExpressions.FirstOrDefault().ToString().Contains("True")) + { + monitored = "(Albums.[Monitored] = 1) AND (Artists.[Monitored] = 1)"; + } + + string query = string.Format("SELECT Albums.* " + + "FROM Albums " + + "JOIN Artists ON Albums.ArtistMetadataId = Artists.ArtistMetadataId " + + "JOIN AlbumReleases ON AlbumReleases.AlbumId == Albums.Id " + + "JOIN Tracks ON Tracks.AlbumReleaseId == AlbumReleases.Id " + + "LEFT OUTER JOIN TrackFiles ON TrackFiles.Id == Tracks.TrackFileId " + + "WHERE TrackFiles.Id IS NULL " + + "AND AlbumReleases.Monitored = 1 " + + "AND ({0}) AND {1} " + + "GROUP BY Albums.Id ", + monitored, + BuildReleaseDateCutoffWhereClause(currentTime)); + + return Query.QueryText(query).Count(); + } + + private string BuildReleaseDateCutoffWhereClause(DateTime currentTime) + { + return string.Format("datetime(strftime('%s', Albums.[ReleaseDate]), 'unixepoch') <= '{0}'", + currentTime.ToString("yyyy-MM-dd HH:mm:ss")); + } + + private QueryBuilder<Album> GetCutOffAlbumsQuery(PagingSpec<Album> pagingSpec, List<QualitiesBelowCutoff> qualitiesBelowCutoff) + { + string sortKey; + string monitored = "(Albums.[Monitored] = 0) OR (Artists.[Monitored] = 0)"; + + if (pagingSpec.FilterExpressions.FirstOrDefault().ToString().Contains("True")) + { + monitored = "(Albums.[Monitored] = 1) AND (Artists.[Monitored] = 1)"; + } + + if (pagingSpec.SortKey == "releaseDate") + { + sortKey = "Albums." + pagingSpec.SortKey; + } + else if (pagingSpec.SortKey == "artist.sortName") + { + sortKey = "Artists." + pagingSpec.SortKey.Split('.').Last(); + } + else if (pagingSpec.SortKey == "albumTitle") + { + sortKey = "Albums.title"; + } + else + { + sortKey = "Albums.releaseDate"; + } + + string query = string.Format("SELECT Albums.* " + + "FROM Albums " + + "JOIN Artists on Albums.ArtistMetadataId == Artists.ArtistMetadataId " + + "JOIN AlbumReleases ON AlbumReleases.AlbumId == Albums.Id " + + "JOIN Tracks ON Tracks.AlbumReleaseId == AlbumReleases.Id " + + "JOIN TrackFiles ON TrackFiles.Id == Tracks.TrackFileId " + + "WHERE {0} " + + "AND AlbumReleases.Monitored = 1 " + + "GROUP BY Albums.Id " + + "HAVING {1} " + + "ORDER BY {2} {3} LIMIT {4} OFFSET {5}", + monitored, + BuildQualityCutoffWhereClause(qualitiesBelowCutoff), + sortKey, + pagingSpec.ToSortDirection(), + pagingSpec.PageSize, + pagingSpec.PagingOffset()); + + return Query.QueryText(query); + + } + + private int GetCutOffAlbumsQueryCount(PagingSpec<Album> pagingSpec, List<QualitiesBelowCutoff> qualitiesBelowCutoff) + { + var monitored = "(Albums.[Monitored] = 0) OR (Artists.[Monitored] = 0)"; + + if (pagingSpec.FilterExpressions.FirstOrDefault().ToString().Contains("True")) + { + monitored = "(Albums.[Monitored] = 1) AND (Artists.[Monitored] = 1)"; + } + + string query = string.Format("SELECT Albums.* " + + "FROM Albums " + + "JOIN Artists on Albums.ArtistMetadataId == Artists.ArtistMetadataId " + + "JOIN AlbumReleases ON AlbumReleases.AlbumId == Albums.Id " + + "JOIN Tracks ON Tracks.AlbumReleaseId == AlbumReleases.Id " + + "JOIN TrackFiles ON TrackFiles.Id == Tracks.TrackFileId " + + "WHERE {0} " + + "AND AlbumReleases.Monitored = 1 " + + "GROUP BY Albums.Id " + + "HAVING {1}", + monitored, + BuildQualityCutoffWhereClause(qualitiesBelowCutoff)); + + return Query.QueryText(query).Count(); + } + + private string BuildQualityCutoffWhereClause(List<QualitiesBelowCutoff> qualitiesBelowCutoff) + { + var clauses = new List<string>(); + + foreach (var profile in qualitiesBelowCutoff) + { + foreach (var belowCutoff in profile.QualityIds) + { + clauses.Add(string.Format("(Artists.[QualityProfileId] = {0} AND MIN(TrackFiles.Quality) LIKE '%_quality_: {1},%')", profile.ProfileId, belowCutoff)); + } + } + + return string.Format("({0})", string.Join(" OR ", clauses)); + } + + public void SetMonitoredFlat(Album album, bool monitored) + { + album.Monitored = monitored; + SetFields(album, p => p.Monitored); + } + + public void SetMonitored(IEnumerable<int> ids, bool monitored) + { + var mapper = _database.GetDataMapper(); + + mapper.AddParameter("monitored", monitored); + + var sql = "UPDATE Albums " + + "SET Monitored = @monitored " + + $"WHERE Id IN ({string.Join(", ", ids)})"; + + mapper.ExecuteNonQuery(sql); + } + + public Album FindByTitle(int artistMetadataId, string title) + { + var cleanTitle = Parser.Parser.CleanArtistName(title); + + if (string.IsNullOrEmpty(cleanTitle)) + cleanTitle = title; + + return Query.Where(s => s.CleanTitle == cleanTitle || s.Title == title) + .AndWhere(s => s.ArtistMetadataId == artistMetadataId) + .ExclusiveOrDefault(); + } + + public Album FindAlbumByRelease(string albumReleaseId) + { + string query = string.Format("SELECT Albums.* " + + "FROM Albums " + + "JOIN AlbumReleases ON AlbumReleases.AlbumId = Albums.Id " + + "WHERE AlbumReleases.ForeignReleaseId = '{0}'", + albumReleaseId); + return Query.QueryText(query).FirstOrDefault(); + } + + public Album FindAlbumByTrack(int trackId) + { + string query = string.Format("SELECT Albums.* " + + "FROM Albums " + + "JOIN AlbumReleases ON AlbumReleases.AlbumId = Albums.Id " + + "JOIN Tracks ON Tracks.AlbumReleaseId = AlbumReleases.Id " + + "WHERE Tracks.Id = {0}", + trackId); + return Query.QueryText(query).FirstOrDefault(); + } + + public List<Album> GetArtistAlbumsWithFiles(Artist artist) + { + string query = string.Format("SELECT Albums.* " + + "FROM Albums " + + "JOIN AlbumReleases ON AlbumReleases.AlbumId == Albums.Id " + + "JOIN Tracks ON Tracks.AlbumReleaseId == AlbumReleases.Id " + + "JOIN TrackFiles ON TrackFiles.Id == Tracks.TrackFileId " + + "WHERE Albums.ArtistMetadataId == {0} " + + "AND AlbumReleases.Monitored = 1 " + + "GROUP BY Albums.Id ", + artist.ArtistMetadataId); + + return Query.QueryText(query).ToList(); + } + } +} diff --git a/src/NzbDrone.Core/Music/AlbumService.cs b/src/NzbDrone.Core/Music/AlbumService.cs new file mode 100644 index 000000000..6165b1c98 --- /dev/null +++ b/src/NzbDrone.Core/Music/AlbumService.cs @@ -0,0 +1,300 @@ +using NLog; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Music.Events; +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Parser; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.Music +{ + public interface IAlbumService + { + Album GetAlbum(int albumId); + List<Album> GetAlbums(IEnumerable<int> albumIds); + List<Album> GetAlbumsByArtist(int artistId); + List<Album> GetNextAlbumsByArtistMetadataId(IEnumerable<int> artistMetadataIds); + List<Album> GetLastAlbumsByArtistMetadataId(IEnumerable<int> artistMetadataIds); + List<Album> GetAlbumsByArtistMetadataId(int artistMetadataId); + List<Album> GetAlbumsForRefresh(int artistMetadataId, IEnumerable<string> foreignIds); + Album AddAlbum(Album newAlbum); + Album FindById(string foreignId); + Album FindByTitle(int artistMetadataId, string title); + Album FindByTitleInexact(int artistMetadataId, string title); + List<Album> GetCandidates(int artistId, string title); + void DeleteAlbum(int albumId, bool deleteFiles); + List<Album> GetAllAlbums(); + Album UpdateAlbum(Album album); + void SetAlbumMonitored(int albumId, bool monitored); + void SetMonitored(IEnumerable<int> ids, bool monitored); + PagingSpec<Album> AlbumsWithoutFiles(PagingSpec<Album> pagingSpec); + List<Album> AlbumsBetweenDates(DateTime start, DateTime end, bool includeUnmonitored); + List<Album> ArtistAlbumsBetweenDates(Artist artist, DateTime start, DateTime end, bool includeUnmonitored); + void InsertMany(List<Album> albums); + void UpdateMany(List<Album> albums); + void DeleteMany(List<Album> albums); + void RemoveAddOptions(Album album); + Album FindAlbumByRelease(string albumReleaseId); + Album FindAlbumByTrackId(int trackId); + List<Album> GetArtistAlbumsWithFiles(Artist artist); + } + + public class AlbumService : IAlbumService, + IHandle<ArtistDeletedEvent> + { + private readonly IAlbumRepository _albumRepository; + private readonly IEventAggregator _eventAggregator; + private readonly Logger _logger; + + public AlbumService(IAlbumRepository albumRepository, + IEventAggregator eventAggregator, + Logger logger) + { + _albumRepository = albumRepository; + _eventAggregator = eventAggregator; + _logger = logger; + } + + public Album AddAlbum(Album newAlbum) + { + _albumRepository.Insert(newAlbum); + + //_eventAggregator.PublishEvent(new AlbumAddedEvent(GetAlbum(newAlbum.Id))); + + return newAlbum; + } + + public void DeleteAlbum(int albumId, bool deleteFiles) + { + var album = _albumRepository.Get(albumId); + _albumRepository.Delete(albumId); + _eventAggregator.PublishEvent(new AlbumDeletedEvent(album, deleteFiles)); + } + + public Album FindById(string lidarrId) + { + return _albumRepository.FindById(lidarrId); + } + + public Album FindByTitle(int artistMetadataId, string title) + { + return _albumRepository.FindByTitle(artistMetadataId, title); + } + + private List<Tuple<Func<Album, string, double>, string>> AlbumScoringFunctions(string title, string cleanTitle) + { + Func< Func<Album, string, double>, string, Tuple<Func<Album, string, double>, string>> tc = Tuple.Create; + var scoringFunctions = new List<Tuple<Func<Album, string, double>, string>> { + tc((a, t) => a.CleanTitle.FuzzyMatch(t), cleanTitle), + tc((a, t) => a.Title.FuzzyMatch(t), title), + tc((a, t) => a.CleanTitle.FuzzyMatch(t), title.RemoveBracketsAndContents().CleanArtistName()), + tc((a, t) => a.CleanTitle.FuzzyMatch(t), title.RemoveAfterDash().CleanArtistName()), + tc((a, t) => a.CleanTitle.FuzzyMatch(t), title.RemoveBracketsAndContents().RemoveAfterDash().CleanArtistName()), + tc((a, t) => t.FuzzyContains(a.CleanTitle), cleanTitle), + tc((a, t) => t.FuzzyContains(a.Title), title) + }; + + return scoringFunctions; + } + + public Album FindByTitleInexact(int artistMetadataId, string title) + { + var albums = GetAlbumsByArtistMetadataId(artistMetadataId); + + foreach (var func in AlbumScoringFunctions(title, title.CleanArtistName())) + { + var results = FindByStringInexact(albums, func.Item1, func.Item2); + if (results.Count == 1) + { + return results[0]; + } + } + + return null; + } + + public List<Album> GetCandidates(int artistMetadataId, string title) + { + var albums = GetAlbumsByArtistMetadataId(artistMetadataId); + var output = new List<Album>(); + + foreach (var func in AlbumScoringFunctions(title, title.CleanArtistName())) + { + output.AddRange(FindByStringInexact(albums, func.Item1, func.Item2)); + } + + return output.DistinctBy(x => x.Id).ToList(); + } + + private List<Album> FindByStringInexact(List<Album> albums, Func<Album, string, double> scoreFunction, string title) + { + const double fuzzThreshold = 0.7; + const double fuzzGap = 0.4; + + var sortedAlbums = albums.Select(s => new + { + MatchProb = scoreFunction(s, title), + Album = s + }) + .ToList() + .OrderByDescending(s => s.MatchProb) + .ToList(); + + _logger.Trace("\nFuzzy album match on '{0}':\n{1}", + title, + string.Join("\n", sortedAlbums.Select(x => $"[{x.Album.Title}] {x.Album.CleanTitle}: {x.MatchProb}"))); + + return sortedAlbums.TakeWhile((x, i) => i == 0 ? true : sortedAlbums[i - 1].MatchProb - x.MatchProb < fuzzGap) + .TakeWhile((x, i) => x.MatchProb > fuzzThreshold || (i > 0 && sortedAlbums[i - 1].MatchProb > fuzzThreshold)) + .Select(x => x.Album) + .ToList(); + } + + public List<Album> GetAllAlbums() + { + return _albumRepository.All().ToList(); + } + + public Album GetAlbum(int albumId) + { + return _albumRepository.Get(albumId); + } + + public List<Album> GetAlbums(IEnumerable<int> albumIds) + { + return _albumRepository.Get(albumIds).ToList(); + } + + public List<Album> GetAlbumsByArtist(int artistId) + { + return _albumRepository.GetAlbums(artistId).ToList(); + } + + public List<Album> GetNextAlbumsByArtistMetadataId(IEnumerable<int> artistMetadataIds) + { + return _albumRepository.GetNextAlbums(artistMetadataIds).ToList(); + } + + public List<Album> GetLastAlbumsByArtistMetadataId(IEnumerable<int> artistMetadataIds) + { + return _albumRepository.GetLastAlbums(artistMetadataIds).ToList(); + } + + public List<Album> GetAlbumsByArtistMetadataId(int artistMetadataId) + { + return _albumRepository.GetAlbumsByArtistMetadataId(artistMetadataId).ToList(); + } + + public List<Album> GetAlbumsForRefresh(int artistId, IEnumerable<string> foreignIds) + { + return _albumRepository.GetAlbumsForRefresh(artistId, foreignIds); + } + + public Album FindAlbumByRelease(string albumReleaseId) + { + return _albumRepository.FindAlbumByRelease(albumReleaseId); + } + + public Album FindAlbumByTrackId(int trackId) + { + return _albumRepository.FindAlbumByTrack(trackId); + } + + public void RemoveAddOptions(Album album) + { + var rg = _albumRepository.Get(album.Id); + _albumRepository.SetFields(rg, s => s.AddOptions); + } + + public PagingSpec<Album> AlbumsWithoutFiles(PagingSpec<Album> pagingSpec) + { + var albumResult = _albumRepository.AlbumsWithoutFiles(pagingSpec); + + return albumResult; + } + + public List<Album> AlbumsBetweenDates(DateTime start, DateTime end, bool includeUnmonitored) + { + var albums = _albumRepository.AlbumsBetweenDates(start.ToUniversalTime(), end.ToUniversalTime(), includeUnmonitored); + + return albums; + } + + public List<Album> ArtistAlbumsBetweenDates(Artist artist, DateTime start, DateTime end, bool includeUnmonitored) + { + var albums = _albumRepository.ArtistAlbumsBetweenDates(artist, start.ToUniversalTime(), end.ToUniversalTime(), includeUnmonitored); + + return albums; + } + + public List<Album> GetArtistAlbumsWithFiles(Artist artist) + { + return _albumRepository.GetArtistAlbumsWithFiles(artist); + } + + public void InsertMany(List<Album> albums) + { + _albumRepository.InsertMany(albums); + } + + public void UpdateMany(List<Album> albums) + { + _albumRepository.UpdateMany(albums); + } + + public void DeleteMany(List<Album> albums) + { + _albumRepository.DeleteMany(albums); + + foreach (var album in albums) + { + _eventAggregator.PublishEvent(new AlbumDeletedEvent(album, false)); + } + } + + public Album UpdateAlbum(Album album) + { + var storedAlbum = GetAlbum(album.Id); + var updatedAlbum = _albumRepository.Update(album); + + // If updatedAlbum has populated the Releases, populate in the storedAlbum too + if (updatedAlbum.AlbumReleases.IsLoaded) + { + storedAlbum.AlbumReleases.LazyLoad(); + } + _eventAggregator.PublishEvent(new AlbumEditedEvent(updatedAlbum, storedAlbum)); + + return updatedAlbum; + } + + public void SetAlbumMonitored(int albumId, bool monitored) + { + var album = _albumRepository.Get(albumId); + _albumRepository.SetMonitoredFlat(album, monitored); + + // publish album edited event so artist stats update + _eventAggregator.PublishEvent(new AlbumEditedEvent(album, album)); + + _logger.Debug("Monitored flag for Album:{0} was set to {1}", albumId, monitored); + } + + public void SetMonitored(IEnumerable<int> ids, bool monitored) + { + _albumRepository.SetMonitored(ids, monitored); + + // publish album edited event so artist stats update + foreach (var album in _albumRepository.Get(ids)) + { + _eventAggregator.PublishEvent(new AlbumEditedEvent(album, album)); + } + } + + public void Handle(ArtistDeletedEvent message) + { + var albums = GetAlbumsByArtistMetadataId(message.Artist.ArtistMetadataId); + DeleteMany(albums); + } + } +} diff --git a/src/NzbDrone.Core/Music/Artist.cs b/src/NzbDrone.Core/Music/Artist.cs new file mode 100644 index 000000000..77636b27e --- /dev/null +++ b/src/NzbDrone.Core/Music/Artist.cs @@ -0,0 +1,96 @@ +using Marr.Data; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Profiles.Qualities; +using NzbDrone.Core.Profiles.Metadata; +using System; +using System.Collections.Generic; +using Equ; + +namespace NzbDrone.Core.Music +{ + public class Artist : Entity<Artist> + { + public Artist() + { + Tags = new HashSet<int>(); + Metadata = new ArtistMetadata(); + } + + // These correspond to columns in the Artists table + public int ArtistMetadataId { get; set; } + public string CleanName { get; set; } + public string SortName { get; set; } + public bool Monitored { get; set; } + public bool AlbumFolder { get; set; } + public DateTime? LastInfoSync { get; set; } + public string Path { get; set; } + public string RootFolderPath { get; set; } + public DateTime Added { get; set; } + public int QualityProfileId { get; set; } + public int MetadataProfileId { get; set; } + public HashSet<int> Tags { get; set; } + [MemberwiseEqualityIgnore] + public AddArtistOptions AddOptions { get; set; } + + // Dynamically loaded from DB + [MemberwiseEqualityIgnore] + public LazyLoaded<ArtistMetadata> Metadata { get; set; } + [MemberwiseEqualityIgnore] + public LazyLoaded<QualityProfile> QualityProfile { get; set; } + [MemberwiseEqualityIgnore] + public LazyLoaded<MetadataProfile> MetadataProfile { get; set; } + [MemberwiseEqualityIgnore] + public LazyLoaded<List<Album>> Albums { get; set; } + + //compatibility properties + [MemberwiseEqualityIgnore] + public string Name { get { return Metadata.Value.Name; } set { Metadata.Value.Name = value; } } + [MemberwiseEqualityIgnore] + public string ForeignArtistId { get { return Metadata.Value.ForeignArtistId; } set { Metadata.Value.ForeignArtistId = value; } } + + public override string ToString() + { + return string.Format("[{0}][{1}]", Metadata.Value.ForeignArtistId.NullSafe(), Metadata.Value.Name.NullSafe()); + } + + public override void UseMetadataFrom(Artist other) + { + CleanName = other.CleanName; + SortName = other.SortName; + } + + public override void UseDbFieldsFrom(Artist other) + { + Id = other.Id; + ArtistMetadataId = other.ArtistMetadataId; + Monitored = other.Monitored; + AlbumFolder = other.AlbumFolder; + LastInfoSync = other.LastInfoSync; + Path = other.Path; + RootFolderPath = other.RootFolderPath; + Added = other.Added; + QualityProfileId = other.QualityProfileId; + MetadataProfileId = other.MetadataProfileId; + Tags = other.Tags; + AddOptions = other.AddOptions; + } + + public override void ApplyChanges(Artist otherArtist) + { + + Path = otherArtist.Path; + QualityProfileId = otherArtist.QualityProfileId; + QualityProfile = otherArtist.QualityProfile; + MetadataProfileId = otherArtist.MetadataProfileId; + MetadataProfile = otherArtist.MetadataProfile; + + Albums = otherArtist.Albums; + Tags = otherArtist.Tags; + AddOptions = otherArtist.AddOptions; + RootFolderPath = otherArtist.RootFolderPath; + Monitored = otherArtist.Monitored; + AlbumFolder = otherArtist.AlbumFolder; + + } + } +} diff --git a/src/NzbDrone.Core/Music/ArtistAddedHandler.cs b/src/NzbDrone.Core/Music/ArtistAddedHandler.cs new file mode 100644 index 000000000..5e98d6cc0 --- /dev/null +++ b/src/NzbDrone.Core/Music/ArtistAddedHandler.cs @@ -0,0 +1,29 @@ +using System.Linq; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Music.Commands; +using NzbDrone.Core.Music.Events; + +namespace NzbDrone.Core.Music +{ + public class ArtistAddedHandler : IHandle<ArtistAddedEvent>, + IHandle<ArtistsImportedEvent> + { + private readonly IManageCommandQueue _commandQueueManager; + + public ArtistAddedHandler(IManageCommandQueue commandQueueManager) + { + _commandQueueManager = commandQueueManager; + } + + public void Handle(ArtistAddedEvent message) + { + _commandQueueManager.Push(new RefreshArtistCommand(message.Artist.Id, true)); + } + + public void Handle(ArtistsImportedEvent message) + { + _commandQueueManager.PushMany(message.ArtistIds.Select(s => new RefreshArtistCommand(s, true)).ToList()); + } + } +} diff --git a/src/NzbDrone.Core/Music/ArtistEditedService.cs b/src/NzbDrone.Core/Music/ArtistEditedService.cs new file mode 100644 index 000000000..a01e2cfe8 --- /dev/null +++ b/src/NzbDrone.Core/Music/ArtistEditedService.cs @@ -0,0 +1,26 @@ +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Music.Commands; +using NzbDrone.Core.Music.Events; + +namespace NzbDrone.Core.Music +{ + public class ArtistEditedService : IHandle<ArtistEditedEvent> + { + private readonly IManageCommandQueue _commandQueueManager; + + public ArtistEditedService(IManageCommandQueue commandQueueManager) + { + _commandQueueManager = commandQueueManager; + } + + public void Handle(ArtistEditedEvent message) + { + // Refresh Artist is we change AlbumType Preferences + if (message.Artist.MetadataProfileId != message.OldArtist.MetadataProfileId) + { + _commandQueueManager.Push(new RefreshArtistCommand(message.Artist.Id, false)); + } + } + } +} diff --git a/src/NzbDrone.Core/Music/ArtistMetadata.cs b/src/NzbDrone.Core/Music/ArtistMetadata.cs new file mode 100644 index 000000000..bb56bf7c9 --- /dev/null +++ b/src/NzbDrone.Core/Music/ArtistMetadata.cs @@ -0,0 +1,55 @@ +using NzbDrone.Common.Extensions; +using System.Collections.Generic; +using System.Linq; + +namespace NzbDrone.Core.Music +{ + public class ArtistMetadata : Entity<ArtistMetadata> + { + public ArtistMetadata() + { + Images = new List<MediaCover.MediaCover>(); + Genres = new List<string>(); + Members = new List<Member>(); + Links = new List<Links>(); + OldForeignArtistIds = new List<string>(); + Aliases = new List<string>(); + } + + public string ForeignArtistId { get; set; } + public List<string> OldForeignArtistIds { get; set; } + public string Name { get; set; } + public List<string> Aliases { get; set; } + public string Overview { get; set; } + public string Disambiguation { get; set; } + public string Type { get; set; } + public ArtistStatusType Status { get; set; } + public List<MediaCover.MediaCover> Images { get; set; } + public List<Links> Links { get; set; } + public List<string> Genres { get; set; } + public Ratings Ratings { get; set; } + public List<Member> Members { get; set; } + + public override string ToString() + { + return string.Format("[{0}][{1}]", ForeignArtistId, Name.NullSafe()); + } + + public override void UseMetadataFrom(ArtistMetadata other) + { + ForeignArtistId = other.ForeignArtistId; + OldForeignArtistIds = other.OldForeignArtistIds; + Name = other.Name; + Aliases = other.Aliases; + Overview = other.Overview.IsNullOrWhiteSpace() ? Overview : other.Overview; + Disambiguation = other.Disambiguation; + Type = other.Type; + Status = other.Status; + Images = other.Images.Any() ? other.Images : Images; + Links = other.Links; + Genres = other.Genres; + Ratings = other.Ratings; + Members = other.Members; + } + } +} diff --git a/src/NzbDrone.Core/Music/ArtistMetadataRepository.cs b/src/NzbDrone.Core/Music/ArtistMetadataRepository.cs new file mode 100644 index 000000000..8ef16f41a --- /dev/null +++ b/src/NzbDrone.Core/Music/ArtistMetadataRepository.cs @@ -0,0 +1,66 @@ +using System.Linq; +using System.Collections.Generic; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; +using NLog; + +namespace NzbDrone.Core.Music +{ + public interface IArtistMetadataRepository : IBasicRepository<ArtistMetadata> + { + List<ArtistMetadata> FindById(List<string> foreignIds); + bool UpsertMany(List<ArtistMetadata> artists); + } + + public class ArtistMetadataRepository : BasicRepository<ArtistMetadata>, IArtistMetadataRepository + { + private readonly Logger _logger; + + public ArtistMetadataRepository(IMainDatabase database, IEventAggregator eventAggregator, Logger logger) + : base(database, eventAggregator) + { + _logger = logger; + } + + public List<ArtistMetadata> FindById(List<string> foreignIds) + { + return Query.Where($"[ForeignArtistId] IN ('{string.Join("','", foreignIds)}')").ToList(); + } + + public bool UpsertMany(List<ArtistMetadata> data) + { + var existingMetadata = FindById(data.Select(x => x.ForeignArtistId).ToList()); + var updateMetadataList = new List<ArtistMetadata>(); + var addMetadataList = new List<ArtistMetadata>(); + int upToDateMetadataCount = 0; + + foreach (var meta in data) + { + var existing = existingMetadata.SingleOrDefault(x => x.ForeignArtistId == meta.ForeignArtistId); + if (existing != null) + { + meta.UseDbFieldsFrom(existing); + if (!meta.Equals(existing)) + { + updateMetadataList.Add(meta); + } + else + { + upToDateMetadataCount++; + } + } + else + { + addMetadataList.Add(meta); + } + } + + UpdateMany(updateMetadataList); + InsertMany(addMetadataList); + + _logger.Debug($"{upToDateMetadataCount} artist metadata up to date; Updating {updateMetadataList.Count}, Adding {addMetadataList.Count} artist metadata entries."); + + return updateMetadataList.Count > 0 || addMetadataList.Count > 0; + } + } +} diff --git a/src/NzbDrone.Core/Music/ArtistMetadataService.cs b/src/NzbDrone.Core/Music/ArtistMetadataService.cs new file mode 100644 index 000000000..8988067dc --- /dev/null +++ b/src/NzbDrone.Core/Music/ArtistMetadataService.cs @@ -0,0 +1,34 @@ +using NLog; +using System.Collections.Generic; + +namespace NzbDrone.Core.Music +{ + public interface IArtistMetadataService + { + bool Upsert(ArtistMetadata artist); + bool UpsertMany(List<ArtistMetadata> artists); + } + + public class ArtistMetadataService : IArtistMetadataService + { + private readonly IArtistMetadataRepository _artistMetadataRepository; + private readonly Logger _logger; + + public ArtistMetadataService(IArtistMetadataRepository artistMetadataRepository, + Logger logger) + { + _artistMetadataRepository = artistMetadataRepository; + _logger = logger; + } + + public bool Upsert(ArtistMetadata artist) + { + return _artistMetadataRepository.UpsertMany(new List<ArtistMetadata> { artist }); + } + + public bool UpsertMany(List<ArtistMetadata> artists) + { + return _artistMetadataRepository.UpsertMany(artists); + } + } +} diff --git a/src/NzbDrone.Core/Music/ArtistNameNormalizer.cs b/src/NzbDrone.Core/Music/ArtistNameNormalizer.cs new file mode 100644 index 000000000..30bebbc71 --- /dev/null +++ b/src/NzbDrone.Core/Music/ArtistNameNormalizer.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Music +{ + + public static class ArtistNameNormalizer + { + private readonly static Dictionary<string, string> PreComputedTitles = new Dictionary<string, string> + { + { "281588", "a to z" }, + { "266757", "ad trials triumph early church" }, + { "289260", "ad bible continues"} + }; + + public static string Normalize(string title, string mbID) + { + if (PreComputedTitles.ContainsKey(mbID)) + { + return PreComputedTitles[mbID]; + } + + return Parser.Parser.NormalizeTitle(title).ToLower(); + } + } +} diff --git a/src/NzbDrone.Core/Music/ArtistPathBuilder.cs b/src/NzbDrone.Core/Music/ArtistPathBuilder.cs new file mode 100644 index 000000000..0f84b6de7 --- /dev/null +++ b/src/NzbDrone.Core/Music/ArtistPathBuilder.cs @@ -0,0 +1,48 @@ +using System; +using System.IO; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.RootFolders; + +namespace NzbDrone.Core.Music +{ + public interface IBuildArtistPaths + { + string BuildPath(Artist artist, bool useExistingRelativeFolder); + } + + public class ArtistPathBuilder : IBuildArtistPaths + { + private readonly IBuildFileNames _fileNameBuilder; + private readonly IRootFolderService _rootFolderService; + + public ArtistPathBuilder(IBuildFileNames fileNameBuilder, IRootFolderService rootFolderService) + { + _fileNameBuilder = fileNameBuilder; + _rootFolderService = rootFolderService; + } + + public string BuildPath(Artist artist, bool useExistingRelativeFolder) + { + if (artist.RootFolderPath.IsNullOrWhiteSpace()) + { + throw new ArgumentException("Root folder was not provided", nameof(artist)); + } + + if (useExistingRelativeFolder && artist.Path.IsNotNullOrWhiteSpace()) + { + var relativePath = GetExistingRelativePath(artist); + return Path.Combine(artist.RootFolderPath, relativePath); + } + + return Path.Combine(artist.RootFolderPath, _fileNameBuilder.GetArtistFolder(artist)); + } + + private string GetExistingRelativePath(Artist artist) + { + var rootFolderPath = _rootFolderService.GetBestRootFolderPath(artist.Path); + + return rootFolderPath.GetRelativePath(artist.Path); + } + } +} diff --git a/src/NzbDrone.Core/Music/ArtistRepository.cs b/src/NzbDrone.Core/Music/ArtistRepository.cs new file mode 100644 index 000000000..21326f124 --- /dev/null +++ b/src/NzbDrone.Core/Music/ArtistRepository.cs @@ -0,0 +1,49 @@ +using System.Linq; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; +using Marr.Data.QGen; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.Music +{ + public interface IArtistRepository : IBasicRepository<Artist> + { + bool ArtistPathExists(string path); + Artist FindByName(string cleanTitle); + Artist FindById(string foreignArtistId); + Artist GetArtistByMetadataId(int artistMetadataId); + } + + public class ArtistRepository : BasicRepository<Artist>, IArtistRepository + { + public ArtistRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + + // Always explicitly join with ArtistMetadata to populate Metadata without repeated LazyLoading + protected override QueryBuilder<Artist> Query => DataMapper.Query<Artist>().Join<Artist, ArtistMetadata>(JoinType.Inner, a => a.Metadata, (l, r) => l.ArtistMetadataId == r.Id); + + public bool ArtistPathExists(string path) + { + return Query.Where(c => c.Path == path).Any(); + } + + public Artist FindById(string foreignArtistId) + { + return Query.Where<ArtistMetadata>(m => m.ForeignArtistId == foreignArtistId).SingleOrDefault(); + } + + public Artist FindByName(string cleanName) + { + cleanName = cleanName.ToLowerInvariant(); + + return Query.Where(s => s.CleanName == cleanName).ExclusiveOrDefault(); + } + + public Artist GetArtistByMetadataId(int artistMetadataId) + { + return Query.Where(s => s.ArtistMetadataId == artistMetadataId).SingleOrDefault(); + } + } +} diff --git a/src/NzbDrone.Core/Music/ArtistScannedHandler.cs b/src/NzbDrone.Core/Music/ArtistScannedHandler.cs new file mode 100644 index 000000000..db21cdfcd --- /dev/null +++ b/src/NzbDrone.Core/Music/ArtistScannedHandler.cs @@ -0,0 +1,62 @@ +using NLog; +using NzbDrone.Core.IndexerSearch; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.Music +{ + public class ArtistScannedHandler : IHandle<ArtistScannedEvent>, + IHandle<ArtistScanSkippedEvent> + { + private readonly IAlbumMonitoredService _albumMonitoredService; + private readonly IArtistService _artistService; + private readonly IManageCommandQueue _commandQueueManager; + //private readonly IEpisodeAddedService _episodeAddedService; + + private readonly Logger _logger; + + public ArtistScannedHandler(IAlbumMonitoredService albumMonitoredService, + IArtistService artistService, + IManageCommandQueue commandQueueManager, + //IEpisodeAddedService episodeAddedService, + Logger logger) + { + _albumMonitoredService = albumMonitoredService; + _artistService = artistService; + _commandQueueManager = commandQueueManager; + //_episodeAddedService = episodeAddedService; + _logger = logger; + } + + private void HandleScanEvents(Artist artist) + { + if (artist.AddOptions == null) + { + //_episodeAddedService.SearchForRecentlyAdded(series.Id); + return; + } + + _logger.Info("[{0}] was recently added, performing post-add actions", artist.Name); + _albumMonitoredService.SetAlbumMonitoredStatus(artist, artist.AddOptions); + + if (artist.AddOptions.SearchForMissingAlbums) + { + _commandQueueManager.Push(new MissingAlbumSearchCommand(artist.Id)); + } + + artist.AddOptions = null; + _artistService.RemoveAddOptions(artist); + } + + public void Handle(ArtistScannedEvent message) + { + HandleScanEvents(message.Artist); + } + + public void Handle(ArtistScanSkippedEvent message) + { + HandleScanEvents(message.Artist); + } + } +} diff --git a/src/NzbDrone.Core/Music/ArtistService.cs b/src/NzbDrone.Core/Music/ArtistService.cs new file mode 100644 index 000000000..192bb718c --- /dev/null +++ b/src/NzbDrone.Core/Music/ArtistService.cs @@ -0,0 +1,245 @@ +using NLog; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Music.Events; +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Cache; +using NzbDrone.Core.ImportLists.Exclusions; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.Music +{ + public interface IArtistService + { + Artist GetArtist(int artistId); + Artist GetArtistByMetadataId(int artistMetadataId); + List<Artist> GetArtists(IEnumerable<int> artistIds); + Artist AddArtist(Artist newArtist); + List<Artist> AddArtists(List<Artist> newArtists); + Artist FindById(string foreignArtistId); + Artist FindByName(string title); + Artist FindByNameInexact(string title); + List<Artist> GetCandidates(string title); + void DeleteArtist(int artistId, bool deleteFiles, bool addImportListExclusion = false); + List<Artist> GetAllArtists(); + List<Artist> AllForTag(int tagId); + Artist UpdateArtist(Artist artist); + List<Artist> UpdateArtists(List<Artist> artist, bool useExistingRelativeFolder); + bool ArtistPathExists(string folder); + void RemoveAddOptions(Artist artist); + } + + public class ArtistService : IArtistService + { + private readonly IArtistRepository _artistRepository; + private readonly IEventAggregator _eventAggregator; + private readonly ITrackService _trackService; + private readonly IImportListExclusionService _importListExclusionService; + private readonly IBuildArtistPaths _artistPathBuilder; + private readonly Logger _logger; + private readonly ICached<List<Artist>> _cache; + + public ArtistService(IArtistRepository artistRepository, + IEventAggregator eventAggregator, + ITrackService trackService, + IImportListExclusionService importListExclusionService, + IBuildArtistPaths artistPathBuilder, + ICacheManager cacheManager, + Logger logger) + { + _artistRepository = artistRepository; + _eventAggregator = eventAggregator; + _trackService = trackService; + _importListExclusionService = importListExclusionService; + _artistPathBuilder = artistPathBuilder; + _cache = cacheManager.GetCache<List<Artist>>(GetType()); + _logger = logger; + } + + public Artist AddArtist(Artist newArtist) + { + _cache.Clear(); + _artistRepository.Insert(newArtist); + _eventAggregator.PublishEvent(new ArtistAddedEvent(GetArtist(newArtist.Id))); + + return newArtist; + } + + public List<Artist> AddArtists(List<Artist> newArtists) + { + _cache.Clear(); + _artistRepository.InsertMany(newArtists); + _eventAggregator.PublishEvent(new ArtistsImportedEvent(newArtists.Select(s => s.Id).ToList())); + + return newArtists; + } + + public bool ArtistPathExists(string folder) + { + return _artistRepository.ArtistPathExists(folder); + } + + public void DeleteArtist(int artistId, bool deleteFiles, bool addImportListExclusion = false) + { + _cache.Clear(); + var artist = _artistRepository.Get(artistId); + _artistRepository.Delete(artistId); + _eventAggregator.PublishEvent(new ArtistDeletedEvent(artist, deleteFiles, addImportListExclusion)); + } + + public Artist FindById(string foreignArtistId) + { + return _artistRepository.FindById(foreignArtistId); + } + + public Artist FindByName(string title) + { + return _artistRepository.FindByName(title.CleanArtistName()); + } + + public List<Tuple<Func<Artist, string, double>, string>> ArtistScoringFunctions(string title, string cleanTitle) + { + Func< Func<Artist, string, double>, string, Tuple<Func<Artist, string, double>, string>> tc = Tuple.Create; + var scoringFunctions = new List<Tuple<Func<Artist, string, double>, string>> { + tc((a, t) => a.CleanName.FuzzyMatch(t), cleanTitle), + tc((a, t) => a.Name.FuzzyMatch(t), title), + }; + + if (title.StartsWith("The ", StringComparison.CurrentCultureIgnoreCase)) + { + scoringFunctions.Add(tc((a, t) => a.CleanName.FuzzyMatch(t), title.Substring(4).CleanArtistName())); + } + else + { + scoringFunctions.Add(tc((a, t) => a.CleanName.FuzzyMatch(t), "the" + cleanTitle)); + } + + return scoringFunctions; + } + + public Artist FindByNameInexact(string title) + { + var artists = GetAllArtists(); + + foreach (var func in ArtistScoringFunctions(title, title.CleanArtistName())) + { + var results = FindByStringInexact(artists, func.Item1, func.Item2); + if (results.Count == 1) + { + return results[0]; + } + } + + return null; + } + + public List<Artist> GetCandidates(string title) + { + var artists = GetAllArtists(); + var output = new List<Artist>(); + + foreach (var func in ArtistScoringFunctions(title, title.CleanArtistName())) + { + output.AddRange(FindByStringInexact(artists, func.Item1, func.Item2)); + } + + return output.DistinctBy(x => x.Id).ToList(); + } + + private List<Artist> FindByStringInexact(List<Artist> artists, Func<Artist, string, double> scoreFunction, string title) + { + const double fuzzThreshold = 0.8; + const double fuzzGap = 0.2; + + var sortedArtists = artists.Select(s => new + { + MatchProb = scoreFunction(s, title), + Artist = s + }) + .ToList() + .OrderByDescending(s => s.MatchProb) + .ToList(); + + _logger.Trace("\nFuzzy artist match on '{0}':\n{1}", + title, + string.Join("\n", sortedArtists.Select(x => $"[{x.Artist.Name}] {x.Artist.CleanName}: {x.MatchProb}"))); + + return sortedArtists.TakeWhile((x, i) => i == 0 ? true : sortedArtists[i - 1].MatchProb - x.MatchProb < fuzzGap) + .TakeWhile((x, i) => x.MatchProb > fuzzThreshold || (i > 0 && sortedArtists[i - 1].MatchProb > fuzzThreshold)) + .Select(x => x.Artist) + .ToList(); + } + + public List<Artist> GetAllArtists() + { + return _cache.Get("GetAllArtists", () => _artistRepository.All().ToList(), TimeSpan.FromSeconds(30)); + } + + public List<Artist> AllForTag(int tagId) + { + return GetAllArtists().Where(s => s.Tags.Contains(tagId)) + .ToList(); + } + + public Artist GetArtist(int artistDBId) + { + return _artistRepository.Get(artistDBId); + } + + public Artist GetArtistByMetadataId(int artistMetadataId) + { + return _artistRepository.GetArtistByMetadataId(artistMetadataId); + } + + public List<Artist> GetArtists(IEnumerable<int> artistIds) + { + return _artistRepository.Get(artistIds).ToList(); + } + + public void RemoveAddOptions(Artist artist) + { + _artistRepository.SetFields(artist, s => s.AddOptions); + } + + public Artist UpdateArtist(Artist artist) + { + _cache.Clear(); + var storedArtist = GetArtist(artist.Id); + var updatedArtist = _artistRepository.Update(artist); + _eventAggregator.PublishEvent(new ArtistEditedEvent(updatedArtist, storedArtist)); + + return updatedArtist; + } + + public List<Artist> UpdateArtists(List<Artist> artist, bool useExistingRelativeFolder) + { + _cache.Clear(); + _logger.Debug("Updating {0} artist", artist.Count); + + foreach (var s in artist) + { + _logger.Trace("Updating: {0}", s.Name); + + if (!s.RootFolderPath.IsNullOrWhiteSpace()) + { + s.Path = _artistPathBuilder.BuildPath(s, useExistingRelativeFolder); + + //s.Path = Path.Combine(s.RootFolderPath, _fileNameBuilder.GetArtistFolder(s)); + + _logger.Trace("Changing path for {0} to {1}", s.Name, s.Path); + } + else + { + _logger.Trace("Not changing path for: {0}", s.Name); + } + } + + _artistRepository.UpdateMany(artist); + _logger.Debug("{0} artists updated", artist.Count); + + return artist; + } + } +} diff --git a/src/NzbDrone.Core/Music/ArtistStatusType.cs b/src/NzbDrone.Core/Music/ArtistStatusType.cs new file mode 100644 index 000000000..478959016 --- /dev/null +++ b/src/NzbDrone.Core/Music/ArtistStatusType.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Music +{ + public enum ArtistStatusType + { + Continuing = 0, + Ended = 1 + } +} diff --git a/src/NzbDrone.Core/Music/Commands/BulkMoveArtistCommand.cs b/src/NzbDrone.Core/Music/Commands/BulkMoveArtistCommand.cs new file mode 100644 index 000000000..8f035792b --- /dev/null +++ b/src/NzbDrone.Core/Music/Commands/BulkMoveArtistCommand.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.Music.Commands +{ + public class BulkMoveArtistCommand : Command + { + public List<BulkMoveArtist> Artist { get; set; } + public string DestinationRootFolder { get; set; } + + public override bool SendUpdatesToClient => true; + public override bool RequiresDiskAccess => true; + } + + public class BulkMoveArtist : IEquatable<BulkMoveArtist> + { + public int ArtistId { get; set; } + public string SourcePath { get; set; } + + public bool Equals(BulkMoveArtist other) + { + if (other == null) + { + return false; + } + + return ArtistId.Equals(other.ArtistId); + } + + public override bool Equals(object obj) + { + if (obj == null) + { + return false; + } + + if (obj.GetType() != GetType()) + { + return false; + } + + return ArtistId.Equals(((BulkMoveArtist)obj).ArtistId); + } + + public override int GetHashCode() + { + return ArtistId.GetHashCode(); + } + } +} diff --git a/src/NzbDrone.Core/Music/Commands/MoveArtistCommand.cs b/src/NzbDrone.Core/Music/Commands/MoveArtistCommand.cs new file mode 100644 index 000000000..c120eddd4 --- /dev/null +++ b/src/NzbDrone.Core/Music/Commands/MoveArtistCommand.cs @@ -0,0 +1,14 @@ +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.Music.Commands +{ + public class MoveArtistCommand : Command + { + public int ArtistId { get; set; } + public string SourcePath { get; set; } + public string DestinationPath { get; set; } + + public override bool SendUpdatesToClient => true; + public override bool RequiresDiskAccess => true; + } +} diff --git a/src/NzbDrone.Core/Music/Commands/RefreshAlbumCommand.cs b/src/NzbDrone.Core/Music/Commands/RefreshAlbumCommand.cs new file mode 100644 index 000000000..40652db6e --- /dev/null +++ b/src/NzbDrone.Core/Music/Commands/RefreshAlbumCommand.cs @@ -0,0 +1,22 @@ +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.Music.Commands +{ + public class RefreshAlbumCommand : Command + { + public int? AlbumId { get; set; } + + public RefreshAlbumCommand() + { + } + + public RefreshAlbumCommand(int? albumId) + { + AlbumId = albumId; + } + + public override bool SendUpdatesToClient => true; + + public override bool UpdateScheduledTask => !AlbumId.HasValue; + } +} diff --git a/src/NzbDrone.Core/Music/Commands/RefreshArtistCommand.cs b/src/NzbDrone.Core/Music/Commands/RefreshArtistCommand.cs new file mode 100644 index 000000000..abd1dacd9 --- /dev/null +++ b/src/NzbDrone.Core/Music/Commands/RefreshArtistCommand.cs @@ -0,0 +1,24 @@ +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.Music.Commands +{ + public class RefreshArtistCommand : Command + { + public int? ArtistId { get; set; } + public bool IsNewArtist { get; set; } + + public RefreshArtistCommand() + { + } + + public RefreshArtistCommand(int? artistId, bool isNewArtist = false) + { + ArtistId = artistId; + IsNewArtist = isNewArtist; + } + + public override bool SendUpdatesToClient => true; + + public override bool UpdateScheduledTask => !ArtistId.HasValue; + } +} diff --git a/src/NzbDrone.Core/Music/Entity.cs b/src/NzbDrone.Core/Music/Entity.cs new file mode 100644 index 000000000..b0dc4d822 --- /dev/null +++ b/src/NzbDrone.Core/Music/Entity.cs @@ -0,0 +1,37 @@ +using NzbDrone.Core.Datastore; +using System; +using Equ; + +namespace NzbDrone.Core.Music +{ + public abstract class Entity<T> : ModelBase, IEquatable<T> + where T : Entity<T> + { + private static readonly MemberwiseEqualityComparer<T> _comparer = + MemberwiseEqualityComparer<T>.ByProperties; + + public virtual void UseDbFieldsFrom(T other) + { + Id = other.Id; + } + + public virtual void UseMetadataFrom(T other) { } + + public virtual void ApplyChanges(T other) { } + + public bool Equals(T other) + { + return _comparer.Equals(this as T, other); + } + + public override bool Equals(object obj) + { + return Equals(obj as T); + } + + public override int GetHashCode() + { + return _comparer.GetHashCode(this as T); + } + } +} diff --git a/src/NzbDrone.Core/Music/Events/AlbumAddedEvent.cs b/src/NzbDrone.Core/Music/Events/AlbumAddedEvent.cs new file mode 100644 index 000000000..985cc419a --- /dev/null +++ b/src/NzbDrone.Core/Music/Events/AlbumAddedEvent.cs @@ -0,0 +1,18 @@ +using NzbDrone.Common.Messaging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Music.Events +{ + public class AlbumAddedEvent : IEvent + { + public Album Album { get; private set; } + + public AlbumAddedEvent(Album album) + { + Album = album; + } + } +} diff --git a/src/NzbDrone.Core/Music/Events/AlbumDeletedEvent.cs b/src/NzbDrone.Core/Music/Events/AlbumDeletedEvent.cs new file mode 100644 index 000000000..28548116c --- /dev/null +++ b/src/NzbDrone.Core/Music/Events/AlbumDeletedEvent.cs @@ -0,0 +1,20 @@ +using NzbDrone.Common.Messaging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Music.Events +{ + public class AlbumDeletedEvent : IEvent + { + public Album Album { get; private set; } + public bool DeleteFiles { get; private set; } + + public AlbumDeletedEvent(Album album, bool deleteFiles) + { + Album = album; + DeleteFiles = deleteFiles; + } + } +} diff --git a/src/NzbDrone.Core/Music/Events/AlbumEditedEvent.cs b/src/NzbDrone.Core/Music/Events/AlbumEditedEvent.cs new file mode 100644 index 000000000..aed8f155e --- /dev/null +++ b/src/NzbDrone.Core/Music/Events/AlbumEditedEvent.cs @@ -0,0 +1,20 @@ +using NzbDrone.Common.Messaging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Music.Events +{ + public class AlbumEditedEvent : IEvent + { + public Album Album { get; private set; } + public Album OldAlbum { get; private set; } + + public AlbumEditedEvent(Album album, Album oldAlbum) + { + Album = album; + OldAlbum = oldAlbum; + } + } +} diff --git a/src/NzbDrone.Core/Music/Events/AlbumInfoRefreshedEvent.cs b/src/NzbDrone.Core/Music/Events/AlbumInfoRefreshedEvent.cs new file mode 100644 index 000000000..5fdb3f539 --- /dev/null +++ b/src/NzbDrone.Core/Music/Events/AlbumInfoRefreshedEvent.cs @@ -0,0 +1,23 @@ +using NzbDrone.Common.Messaging; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Music.Events +{ + public class AlbumInfoRefreshedEvent : IEvent + { + public Artist Artist { get; set; } + public ReadOnlyCollection<Album> Added { get; private set; } + public ReadOnlyCollection<Album> Updated { get; private set; } + + public AlbumInfoRefreshedEvent(Artist artist, IList<Album> added, IList<Album> updated) + { + Artist = artist; + Added = new ReadOnlyCollection<Album>(added); + Updated = new ReadOnlyCollection<Album>(updated); + } + } +} diff --git a/src/NzbDrone.Core/Music/Events/ArtistAddedEvent.cs b/src/NzbDrone.Core/Music/Events/ArtistAddedEvent.cs new file mode 100644 index 000000000..d8b374ac3 --- /dev/null +++ b/src/NzbDrone.Core/Music/Events/ArtistAddedEvent.cs @@ -0,0 +1,18 @@ +using NzbDrone.Common.Messaging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Music.Events +{ + public class ArtistAddedEvent : IEvent + { + public Artist Artist { get; private set; } + + public ArtistAddedEvent(Artist artist) + { + Artist = artist; + } + } +} diff --git a/src/NzbDrone.Core/Music/Events/ArtistDeletedEvent.cs b/src/NzbDrone.Core/Music/Events/ArtistDeletedEvent.cs new file mode 100644 index 000000000..5c448f133 --- /dev/null +++ b/src/NzbDrone.Core/Music/Events/ArtistDeletedEvent.cs @@ -0,0 +1,22 @@ +using NzbDrone.Common.Messaging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Music.Events +{ + public class ArtistDeletedEvent : IEvent + { + public Artist Artist { get; private set; } + public bool DeleteFiles { get; private set; } + public bool AddImportListExclusion { get; private set; } + + public ArtistDeletedEvent(Artist artist, bool deleteFiles, bool addImportListExclusion) + { + Artist = artist; + DeleteFiles = deleteFiles; + AddImportListExclusion = addImportListExclusion; + } + } +} diff --git a/src/NzbDrone.Core/Music/Events/ArtistEditedEvent.cs b/src/NzbDrone.Core/Music/Events/ArtistEditedEvent.cs new file mode 100644 index 000000000..4511e8943 --- /dev/null +++ b/src/NzbDrone.Core/Music/Events/ArtistEditedEvent.cs @@ -0,0 +1,20 @@ +using NzbDrone.Common.Messaging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Music.Events +{ + public class ArtistEditedEvent : IEvent + { + public Artist Artist { get; private set; } + public Artist OldArtist { get; private set; } + + public ArtistEditedEvent(Artist artist, Artist oldArtist) + { + Artist = artist; + OldArtist = oldArtist; + } + } +} diff --git a/src/NzbDrone.Core/Music/Events/ArtistMovedEvent.cs b/src/NzbDrone.Core/Music/Events/ArtistMovedEvent.cs new file mode 100644 index 000000000..1de12f9f7 --- /dev/null +++ b/src/NzbDrone.Core/Music/Events/ArtistMovedEvent.cs @@ -0,0 +1,18 @@ +using NzbDrone.Common.Messaging; + +namespace NzbDrone.Core.Music.Events +{ + public class ArtistMovedEvent : IEvent + { + public Artist Artist { get; set; } + public string SourcePath { get; set; } + public string DestinationPath { get; set; } + + public ArtistMovedEvent(Artist artist, string sourcePath, string destinationPath) + { + Artist = artist; + SourcePath = sourcePath; + DestinationPath = destinationPath; + } + } +} diff --git a/src/NzbDrone.Core/Music/Events/ArtistRefreshCompleteEvent.cs b/src/NzbDrone.Core/Music/Events/ArtistRefreshCompleteEvent.cs new file mode 100644 index 000000000..5214be4c3 --- /dev/null +++ b/src/NzbDrone.Core/Music/Events/ArtistRefreshCompleteEvent.cs @@ -0,0 +1,14 @@ +using NzbDrone.Common.Messaging; + +namespace NzbDrone.Core.Music.Events +{ + public class ArtistRefreshCompleteEvent : IEvent + { + public Artist Artist { get; set; } + + public ArtistRefreshCompleteEvent(Artist artist) + { + Artist = artist; + } + } +} diff --git a/src/NzbDrone.Core/Music/Events/ArtistUpdatedEvent.cs b/src/NzbDrone.Core/Music/Events/ArtistUpdatedEvent.cs new file mode 100644 index 000000000..8555eba80 --- /dev/null +++ b/src/NzbDrone.Core/Music/Events/ArtistUpdatedEvent.cs @@ -0,0 +1,14 @@ +using NzbDrone.Common.Messaging; + +namespace NzbDrone.Core.Music.Events +{ + public class ArtistUpdatedEvent : IEvent + { + public Artist Artist { get; private set; } + + public ArtistUpdatedEvent(Artist artist) + { + Artist = artist; + } + } +} diff --git a/src/NzbDrone.Core/Music/Events/ArtistsImportedEvent.cs b/src/NzbDrone.Core/Music/Events/ArtistsImportedEvent.cs new file mode 100644 index 000000000..0b3833b15 --- /dev/null +++ b/src/NzbDrone.Core/Music/Events/ArtistsImportedEvent.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using NzbDrone.Common.Messaging; + +namespace NzbDrone.Core.Music.Events +{ + public class ArtistsImportedEvent : IEvent + { + public List<int> ArtistIds { get; private set; } + + public ArtistsImportedEvent(List<int> artistIds) + { + ArtistIds = artistIds; + } + } +} diff --git a/src/NzbDrone.Core/Music/Events/ReleaseDeletedEvent.cs b/src/NzbDrone.Core/Music/Events/ReleaseDeletedEvent.cs new file mode 100644 index 000000000..a9cfa26bb --- /dev/null +++ b/src/NzbDrone.Core/Music/Events/ReleaseDeletedEvent.cs @@ -0,0 +1,18 @@ +using NzbDrone.Common.Messaging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Music.Events +{ + public class ReleaseDeletedEvent : IEvent + { + public AlbumRelease Release { get; private set; } + + public ReleaseDeletedEvent(AlbumRelease release) + { + Release = release; + } + } +} diff --git a/src/NzbDrone.Core/Music/Links.cs b/src/NzbDrone.Core/Music/Links.cs new file mode 100644 index 000000000..19df7f5fd --- /dev/null +++ b/src/NzbDrone.Core/Music/Links.cs @@ -0,0 +1,11 @@ +using Equ; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Music +{ + public class Links : MemberwiseEquatable<Links>, IEmbeddedDocument + { + public string Url { get; set; } + public string Name { get; set; } + } +} diff --git a/src/NzbDrone.Core/Music/Medium.cs b/src/NzbDrone.Core/Music/Medium.cs new file mode 100644 index 000000000..63a9a72c4 --- /dev/null +++ b/src/NzbDrone.Core/Music/Medium.cs @@ -0,0 +1,12 @@ +using Equ; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Music +{ + public class Medium : MemberwiseEquatable<Medium>, IEmbeddedDocument + { + public int Number { get; set; } + public string Name { get; set; } + public string Format { get; set; } + } +} diff --git a/src/NzbDrone.Core/Music/Member.cs b/src/NzbDrone.Core/Music/Member.cs new file mode 100644 index 000000000..34f3bcbc2 --- /dev/null +++ b/src/NzbDrone.Core/Music/Member.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using Equ; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Music +{ + public class Member : MemberwiseEquatable<Member>, IEmbeddedDocument + { + public Member() + { + Images = new List<MediaCover.MediaCover>(); + } + + public string Name { get; set; } + public string Instrument { get; set; } + public List<MediaCover.MediaCover> Images { get; set; } + } +} diff --git a/src/NzbDrone.Core/Music/MonitoringOptions.cs b/src/NzbDrone.Core/Music/MonitoringOptions.cs new file mode 100644 index 000000000..a51849b35 --- /dev/null +++ b/src/NzbDrone.Core/Music/MonitoringOptions.cs @@ -0,0 +1,29 @@ +using NzbDrone.Core.Datastore; +using System.Collections.Generic; + +namespace NzbDrone.Core.Music +{ + public class MonitoringOptions : IEmbeddedDocument + { + public MonitoringOptions() + { + AlbumsToMonitor = new List<string>(); + } + + public MonitorTypes Monitor { get; set; } + public List<string> AlbumsToMonitor { get; set; } + public bool Monitored { get; set; } + } + + public enum MonitorTypes + { + All, + Future, + Missing, + Existing, + Latest, + First, + None, + Unknown + } +} diff --git a/src/NzbDrone.Core/Music/MoveArtistService.cs b/src/NzbDrone.Core/Music/MoveArtistService.cs new file mode 100644 index 000000000..846639e76 --- /dev/null +++ b/src/NzbDrone.Core/Music/MoveArtistService.cs @@ -0,0 +1,104 @@ +using System.IO; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Organizer; +using NzbDrone.Core.RootFolders; +using NzbDrone.Core.Music.Commands; +using NzbDrone.Core.Music.Events; + +namespace NzbDrone.Core.Music +{ + public class MoveArtistService : IExecute<MoveArtistCommand>, IExecute<BulkMoveArtistCommand> + { + private readonly IArtistService _artistService; + private readonly IBuildFileNames _filenameBuilder; + private readonly IDiskProvider _diskProvider; + private readonly IDiskTransferService _diskTransferService; + private readonly IEventAggregator _eventAggregator; + private readonly Logger _logger; + + public MoveArtistService(IArtistService artistService, + IBuildFileNames filenameBuilder, + IDiskProvider diskProvider, + IDiskTransferService diskTransferService, + IEventAggregator eventAggregator, + Logger logger) + { + _artistService = artistService; + _filenameBuilder = filenameBuilder; + _diskProvider = diskProvider; + _diskTransferService = diskTransferService; + _eventAggregator = eventAggregator; + _logger = logger; + } + + private void MoveSingleArtist(Artist artist, string sourcePath, string destinationPath, int? index = null, int? total = null) + { + if (!_diskProvider.FolderExists(sourcePath)) + { + _logger.Debug("Folder '{0}' for '{1}' does not exist, not moving.", sourcePath, artist.Name); + return; + } + + if (index != null && total != null) + { + _logger.ProgressInfo("Moving {0} from '{1}' to '{2}' ({3}/{4})", artist.Name, sourcePath, destinationPath, index + 1, total); + } + else + { + _logger.ProgressInfo("Moving {0} from '{1}' to '{2}'", artist.Name, sourcePath, destinationPath); + } + + try + { + _diskTransferService.TransferFolder(sourcePath, destinationPath, TransferMode.Move); + + _logger.ProgressInfo("{0} moved successfully to {1}", artist.Name, artist.Path); + + _eventAggregator.PublishEvent(new ArtistMovedEvent(artist, sourcePath, destinationPath)); + } + catch (IOException ex) + { + _logger.Error(ex, "Unable to move artist from '{0}' to '{1}'. Try moving files manually", sourcePath, destinationPath); + + RevertPath(artist.Id, sourcePath); + } + } + + private void RevertPath(int artistId, string path) + { + var artist = _artistService.GetArtist(artistId); + + artist.Path = path; + _artistService.UpdateArtist(artist); + } + + public void Execute(MoveArtistCommand message) + { + var artist = _artistService.GetArtist(message.ArtistId); + MoveSingleArtist(artist, message.SourcePath, message.DestinationPath); + } + + public void Execute(BulkMoveArtistCommand message) + { + var artistToMove = message.Artist; + var destinationRootFolder = message.DestinationRootFolder; + + _logger.ProgressInfo("Moving {0} artist to '{1}'", artistToMove.Count, destinationRootFolder); + + for (var index = 0; index < artistToMove.Count; index++) + { + var s = artistToMove[index]; + var artist = _artistService.GetArtist(s.ArtistId); + var destinationPath = Path.Combine(destinationRootFolder, _filenameBuilder.GetArtistFolder(artist)); + + MoveSingleArtist(artist, s.SourcePath, destinationPath, index, artistToMove.Count); + } + + _logger.ProgressInfo("Finished moving {0} artist to '{1}'", artistToMove.Count, destinationRootFolder); + } + } +} diff --git a/src/NzbDrone.Core/Music/PrimaryAlbumType.cs b/src/NzbDrone.Core/Music/PrimaryAlbumType.cs new file mode 100644 index 000000000..399587734 --- /dev/null +++ b/src/NzbDrone.Core/Music/PrimaryAlbumType.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Music +{ + public class PrimaryAlbumType : IEmbeddedDocument, IEquatable<PrimaryAlbumType> + { + public int Id { get; set; } + public string Name { get; set; } + + public PrimaryAlbumType() + { + } + + private PrimaryAlbumType(int id, string name) + { + Id = id; + Name = name; + } + + public override string ToString() + { + return Name; + } + + public override int GetHashCode() + { + return Id.GetHashCode(); + } + + public bool Equals(PrimaryAlbumType other) + { + if (ReferenceEquals(null, other)) + { + return false; + } + if (ReferenceEquals(this, other)) + { + return true; + } + return Id.Equals(other.Id); + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) + { + return false; + } + return ReferenceEquals(this, obj) || Equals(obj as PrimaryAlbumType); + } + + public static bool operator ==(PrimaryAlbumType left, PrimaryAlbumType right) + { + return Equals(left, right); + } + + public static bool operator !=(PrimaryAlbumType left, PrimaryAlbumType right) + { + return !Equals(left, right); + } + + public static PrimaryAlbumType Album => new PrimaryAlbumType(0, "Album"); + public static PrimaryAlbumType EP => new PrimaryAlbumType(1, "EP"); + public static PrimaryAlbumType Single => new PrimaryAlbumType(2, "Single"); + public static PrimaryAlbumType Broadcast => new PrimaryAlbumType(3, "Broadcast"); + public static PrimaryAlbumType Other => new PrimaryAlbumType(4, "Other"); + + + public static readonly List<PrimaryAlbumType> All = new List<PrimaryAlbumType> + { + Album, + EP, + Single, + Broadcast, + Other + }; + + + public static PrimaryAlbumType FindById(int id) + { + if (id == 0) + { + return Album; + } + + PrimaryAlbumType albumType = All.FirstOrDefault(v => v.Id == id); + + if (albumType == null) + { + throw new ArgumentException(@"ID does not match a known album type", nameof(id)); + } + + return albumType; + } + + public static explicit operator PrimaryAlbumType(int id) + { + return FindById(id); + } + + public static explicit operator int(PrimaryAlbumType albumType) + { + return albumType.Id; + } + + public static explicit operator PrimaryAlbumType(string type) + { + var albumType = All.FirstOrDefault(v => v.Name.Equals(type, StringComparison.InvariantCultureIgnoreCase)); + + if (albumType == null) + { + throw new ArgumentException(@"Type does not match a known album type", nameof(type)); + } + + return albumType; + } + } +} diff --git a/src/NzbDrone.Core/Music/Ratings.cs b/src/NzbDrone.Core/Music/Ratings.cs new file mode 100644 index 000000000..ae3a0526a --- /dev/null +++ b/src/NzbDrone.Core/Music/Ratings.cs @@ -0,0 +1,11 @@ +using Equ; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Music +{ + public class Ratings : MemberwiseEquatable<Ratings>, IEmbeddedDocument + { + public int Votes { get; set; } + public decimal Value { get; set; } + } +} diff --git a/src/NzbDrone.Core/Music/RefreshAlbumReleaseService.cs b/src/NzbDrone.Core/Music/RefreshAlbumReleaseService.cs new file mode 100644 index 000000000..ef8cec763 --- /dev/null +++ b/src/NzbDrone.Core/Music/RefreshAlbumReleaseService.cs @@ -0,0 +1,129 @@ +using NLog; +using NzbDrone.Common.Extensions; +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.MediaFiles; + +namespace NzbDrone.Core.Music +{ + public interface IRefreshAlbumReleaseService + { + bool RefreshEntityInfo(AlbumRelease entity, List<AlbumRelease> remoteEntityList, bool forceChildRefresh, bool forceUpdateFileTags); + bool RefreshEntityInfo(List<AlbumRelease> releases, List<AlbumRelease> remoteEntityList, bool forceChildRefresh, bool forceUpdateFileTags); + } + + public class RefreshAlbumReleaseService : RefreshEntityServiceBase<AlbumRelease, Track>, IRefreshAlbumReleaseService + { + private readonly IReleaseService _releaseService; + private readonly IRefreshTrackService _refreshTrackService; + private readonly ITrackService _trackService; + private readonly IMediaFileService _mediaFileService; + private readonly Logger _logger; + + public RefreshAlbumReleaseService(IReleaseService releaseService, + IArtistMetadataService artistMetadataService, + IRefreshTrackService refreshTrackService, + ITrackService trackService, + IMediaFileService mediaFileService, + Logger logger) + : base(logger, artistMetadataService) + { + _releaseService = releaseService; + _trackService = trackService; + _refreshTrackService = refreshTrackService; + _mediaFileService = mediaFileService; + _logger = logger; + } + + protected override RemoteData GetRemoteData(AlbumRelease local, List<AlbumRelease> remote) + { + var result = new RemoteData(); + result.Entity = remote.SingleOrDefault(x => x.ForeignReleaseId == local.ForeignReleaseId || x.OldForeignReleaseIds.Contains(local.ForeignReleaseId)); + return result; + } + + protected override bool IsMerge(AlbumRelease local, AlbumRelease remote) + { + return local.ForeignReleaseId != remote.ForeignReleaseId; + } + + protected override UpdateResult UpdateEntity(AlbumRelease local, AlbumRelease remote) + { + if (local.Equals(remote)) + { + return UpdateResult.None; + } + + local.UseMetadataFrom(remote); + + return UpdateResult.UpdateTags; + } + + protected override AlbumRelease GetEntityByForeignId(AlbumRelease local) + { + return _releaseService.GetReleaseByForeignReleaseId(local.ForeignReleaseId); + } + + protected override void SaveEntity(AlbumRelease local) + { + _releaseService.UpdateMany(new List<AlbumRelease> { local }); + } + + protected override void DeleteEntity(AlbumRelease local, bool deleteFiles) + { + _releaseService.DeleteMany(new List<AlbumRelease> { local }); + } + + protected override List<Track> GetRemoteChildren(AlbumRelease remote) + { + return remote.Tracks.Value.DistinctBy(m => m.ForeignTrackId).ToList(); + } + + protected override List<Track> GetLocalChildren(AlbumRelease entity, List<Track> remoteChildren) + { + return _trackService.GetTracksForRefresh(entity.Id, + remoteChildren.Select(x => x.ForeignTrackId) + .Concat(remoteChildren.SelectMany(x => x.OldForeignTrackIds))); + } + + protected override Tuple<Track, List<Track> > GetMatchingExistingChildren(List<Track> existingChildren, Track remote) + { + var existingChild = existingChildren.SingleOrDefault(x => x.ForeignTrackId == remote.ForeignTrackId); + var mergeChildren = existingChildren.Where(x => remote.OldForeignTrackIds.Contains(x.ForeignTrackId)).ToList(); + return Tuple.Create(existingChild, mergeChildren); + } + + protected override void PrepareNewChild(Track child, AlbumRelease entity) + { + child.AlbumReleaseId = entity.Id; + child.AlbumRelease = entity; + child.ArtistMetadataId = child.ArtistMetadata.Value.Id; + + // make sure title is not null + child.Title = child.Title ?? "Unknown"; + } + + protected override void PrepareExistingChild(Track local, Track remote, AlbumRelease entity) + { + local.AlbumRelease = entity; + local.AlbumReleaseId = entity.Id; + local.ArtistMetadataId = remote.ArtistMetadata.Value.Id; + remote.Id = local.Id; + remote.TrackFileId = local.TrackFileId; + remote.AlbumReleaseId = local.AlbumReleaseId; + remote.ArtistMetadataId = local.ArtistMetadataId; + } + + protected override void AddChildren(List<Track> children) + { + _trackService.InsertMany(children); + } + + protected override bool RefreshChildren(SortedChildren localChildren, List<Track> remoteChildren, bool forceChildRefresh, bool forceUpdateFileTags) + { + return _refreshTrackService.RefreshTrackInfo(localChildren.Added, localChildren.Updated, localChildren.Merged, localChildren.Deleted, localChildren.UpToDate, remoteChildren, forceUpdateFileTags); + } + } +} + diff --git a/src/NzbDrone.Core/Music/RefreshAlbumService.cs b/src/NzbDrone.Core/Music/RefreshAlbumService.cs new file mode 100644 index 000000000..8aa740d07 --- /dev/null +++ b/src/NzbDrone.Core/Music/RefreshAlbumService.cs @@ -0,0 +1,338 @@ +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Music.Events; +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.MetadataSource; +using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Music.Commands; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.History; +using NzbDrone.Core.MediaCover; + +namespace NzbDrone.Core.Music +{ + public interface IRefreshAlbumService + { + bool RefreshAlbumInfo(Album album, List<Album> remoteAlbums, bool forceUpdateFileTags); + bool RefreshAlbumInfo(List<Album> albums, List<Album> remoteAlbums, bool forceAlbumRefresh, bool forceUpdateFileTags); + } + + public class RefreshAlbumService : RefreshEntityServiceBase<Album, AlbumRelease>, IRefreshAlbumService, IExecute<RefreshAlbumCommand> + { + private readonly IAlbumService _albumService; + private readonly IArtistService _artistService; + private readonly IAddArtistService _addArtistService; + private readonly IReleaseService _releaseService; + private readonly IProvideAlbumInfo _albumInfo; + private readonly IRefreshAlbumReleaseService _refreshAlbumReleaseService; + private readonly IMediaFileService _mediaFileService; + private readonly IHistoryService _historyService; + private readonly IEventAggregator _eventAggregator; + private readonly ICheckIfAlbumShouldBeRefreshed _checkIfAlbumShouldBeRefreshed; + private readonly IMapCoversToLocal _mediaCoverService; + private readonly Logger _logger; + + public RefreshAlbumService(IAlbumService albumService, + IArtistService artistService, + IAddArtistService addArtistService, + IArtistMetadataService artistMetadataService, + IReleaseService releaseService, + IProvideAlbumInfo albumInfo, + IRefreshAlbumReleaseService refreshAlbumReleaseService, + IMediaFileService mediaFileService, + IHistoryService historyService, + IEventAggregator eventAggregator, + ICheckIfAlbumShouldBeRefreshed checkIfAlbumShouldBeRefreshed, + IMapCoversToLocal mediaCoverService, + Logger logger) + : base(logger, artistMetadataService) + { + _albumService = albumService; + _artistService = artistService; + _addArtistService = addArtistService; + _releaseService = releaseService; + _albumInfo = albumInfo; + _refreshAlbumReleaseService = refreshAlbumReleaseService; + _mediaFileService = mediaFileService; + _historyService = historyService; + _eventAggregator = eventAggregator; + _checkIfAlbumShouldBeRefreshed = checkIfAlbumShouldBeRefreshed; + _mediaCoverService = mediaCoverService; + _logger = logger; + } + + protected override RemoteData GetRemoteData(Album local, List<Album> remote) + { + var result = new RemoteData(); + + // remove not in remote list and ShouldDelete is true + if (remote != null && + !remote.Any(x => x.ForeignAlbumId == local.ForeignAlbumId || x.OldForeignAlbumIds.Contains(local.ForeignAlbumId)) && + ShouldDelete(local)) + { + return result; + } + + Tuple<string, Album, List<ArtistMetadata>> tuple = null; + try + { + tuple = _albumInfo.GetAlbumInfo(local.ForeignAlbumId); + } + catch (AlbumNotFoundException) + { + return result; + } + + if (tuple.Item2.AlbumReleases.Value.Count == 0) + { + _logger.Debug($"{local} has no valid releases, removing."); + return result; + } + + result.Entity = tuple.Item2; + result.Entity.Id = local.Id; + result.Metadata = tuple.Item3; + return result; + } + + protected override void EnsureNewParent(Album local, Album remote) + { + // Make sure the appropriate artist exists (it could be that an album changes parent) + // The artistMetadata entry will be in the db but make sure a corresponding artist is too + // so that the album doesn't just disappear. + + // TODO filter by metadata id before hitting database + _logger.Trace($"Ensuring parent artist exists [{remote.ArtistMetadata.Value.ForeignArtistId}]"); + + var newArtist = _artistService.FindById(remote.ArtistMetadata.Value.ForeignArtistId); + + if (newArtist == null) + { + var oldArtist = local.Artist.Value; + var addArtist = new Artist { + Metadata = remote.ArtistMetadata.Value, + MetadataProfileId = oldArtist.MetadataProfileId, + QualityProfileId = oldArtist.QualityProfileId, + RootFolderPath = oldArtist.RootFolderPath, + Monitored = oldArtist.Monitored, + AlbumFolder = oldArtist.AlbumFolder, + Tags = oldArtist.Tags + }; + _logger.Debug($"Adding missing parent artist {addArtist}"); + _addArtistService.AddArtist(addArtist); + } + } + + protected override bool ShouldDelete(Album local) + { + return !_mediaFileService.GetFilesByAlbum(local.Id).Any(); + } + + protected override void LogProgress(Album local) + { + _logger.ProgressInfo("Updating Info for {0}", local.Title); + } + + protected override bool IsMerge(Album local, Album remote) + { + return local.ForeignAlbumId != remote.ForeignAlbumId; + } + + protected override UpdateResult UpdateEntity(Album local, Album remote) + { + UpdateResult result; + + if (local.Title != (remote.Title ?? "Unknown") || + local.ForeignAlbumId != remote.ForeignAlbumId || + local.ArtistMetadata.Value.ForeignArtistId != remote.ArtistMetadata.Value.ForeignArtistId) + { + result = UpdateResult.UpdateTags; + } + else if (!local.Equals(remote)) + { + result = UpdateResult.Standard; + } + else + { + result = UpdateResult.None; + } + + // Force update and fetch covers if images have changed so that we can write them into tags + if (remote.Images.Any() && !local.Images.SequenceEqual(remote.Images)) + { + _mediaCoverService.EnsureAlbumCovers(remote); + result = UpdateResult.UpdateTags; + } + + local.UseMetadataFrom(remote); + + local.ArtistMetadataId = remote.ArtistMetadata.Value.Id; + local.LastInfoSync = DateTime.UtcNow; + local.AlbumReleases = new List<AlbumRelease>(); + + return result; + } + + protected override UpdateResult MergeEntity(Album local, Album target, Album remote) + { + _logger.Warn($"Album {local} was merged with {remote} because the original was a duplicate."); + + // move releases over to the new album and delete + var localReleases = _releaseService.GetReleasesByAlbum(local.Id); + var allReleases = localReleases.Concat(_releaseService.GetReleasesByAlbum(target.Id)).ToList(); + _logger.Trace($"Moving {localReleases.Count} releases from {local} to {remote}"); + + // Update album ID and unmonitor all releases from the old album + allReleases.ForEach(x => x.AlbumId = target.Id); + MonitorSingleRelease(allReleases); + _releaseService.UpdateMany(allReleases); + + // Update album ids for trackfiles + var files = _mediaFileService.GetFilesByAlbum(local.Id); + files.ForEach(x => x.AlbumId = target.Id); + _mediaFileService.Update(files); + + // Update album ids for history + var items = _historyService.GetByAlbum(local.Id, null); + items.ForEach(x => x.AlbumId = target.Id); + _historyService.UpdateMany(items); + + // Finally delete the old album + _albumService.DeleteMany(new List<Album> { local }); + + return UpdateResult.UpdateTags; + } + + protected override Album GetEntityByForeignId(Album local) + { + return _albumService.FindById(local.ForeignAlbumId); + } + + protected override void SaveEntity(Album local) + { + // Use UpdateMany to avoid firing the album edited event + _albumService.UpdateMany(new List<Album> { local }); + } + + protected override void DeleteEntity(Album local, bool deleteFiles) + { + _albumService.DeleteAlbum(local.Id, true); + } + + protected override List<AlbumRelease> GetRemoteChildren(Album remote) + { + return remote.AlbumReleases.Value.DistinctBy(m => m.ForeignReleaseId).ToList(); + } + + protected override List<AlbumRelease> GetLocalChildren(Album entity, List<AlbumRelease> remoteChildren) + { + var children = _releaseService.GetReleasesForRefresh(entity.Id, + remoteChildren.Select(x => x.ForeignReleaseId) + .Concat(remoteChildren.SelectMany(x => x.OldForeignReleaseIds))); + + // Make sure trackfiles point to the new album where we are grabbing a release from another album + var files = new List<TrackFile>(); + foreach (var release in children.Where(x => x.AlbumId != entity.Id)) + { + files.AddRange(_mediaFileService.GetFilesByRelease(release.Id)); + } + files.ForEach(x => x.AlbumId = entity.Id); + _mediaFileService.Update(files); + + return children; + } + + protected override Tuple<AlbumRelease, List<AlbumRelease> > GetMatchingExistingChildren(List<AlbumRelease> existingChildren, AlbumRelease remote) + { + var existingChild = existingChildren.SingleOrDefault(x => x.ForeignReleaseId == remote.ForeignReleaseId); + var mergeChildren = existingChildren.Where(x => remote.OldForeignReleaseIds.Contains(x.ForeignReleaseId)).ToList(); + return Tuple.Create(existingChild, mergeChildren); + } + + protected override void PrepareNewChild(AlbumRelease child, Album entity) + { + child.AlbumId = entity.Id; + child.Album = entity; + } + + protected override void PrepareExistingChild(AlbumRelease local, AlbumRelease remote, Album entity) + { + local.AlbumId = entity.Id; + local.Album = entity; + + remote.UseDbFieldsFrom(local); + } + + protected override void AddChildren(List<AlbumRelease> children) + { + _releaseService.InsertMany(children); + } + + private void MonitorSingleRelease(List<AlbumRelease> releases) + { + var monitored = releases.Where(x => x.Monitored).ToList(); + if (!monitored.Any()) + { + monitored = releases; + } + + var toMonitor = monitored.OrderByDescending(x => _mediaFileService.GetFilesByRelease(x.Id).Count) + .ThenByDescending(x => x.TrackCount) + .First(); + + releases.ForEach(x => x.Monitored = false); + toMonitor.Monitored = true; + } + + protected override bool RefreshChildren(SortedChildren localChildren, List<AlbumRelease> remoteChildren, bool forceChildRefresh, bool forceUpdateFileTags) + { + var refreshList = localChildren.All; + + // make sure only one of the releases ends up monitored + localChildren.Old.ForEach(x => x.Monitored = false); + MonitorSingleRelease(localChildren.Future); + + refreshList.ForEach(x => _logger.Trace($"release: {x} monitored: {x.Monitored}")); + + return _refreshAlbumReleaseService.RefreshEntityInfo(refreshList, remoteChildren, forceChildRefresh, forceUpdateFileTags); + } + + public bool RefreshAlbumInfo(List<Album> albums, List<Album> remoteAlbums, bool forceAlbumRefresh, bool forceUpdateFileTags) + { + bool updated = false; + foreach (var album in albums) + { + if (forceAlbumRefresh || _checkIfAlbumShouldBeRefreshed.ShouldRefresh(album)) + { + updated |= RefreshAlbumInfo(album, remoteAlbums, forceUpdateFileTags); + } + } + return updated; + } + + public bool RefreshAlbumInfo(Album album, List<Album> remoteAlbums, bool forceUpdateFileTags) + { + return RefreshEntityInfo(album, remoteAlbums, true, forceUpdateFileTags); + } + + public void Execute(RefreshAlbumCommand message) + { + if (message.AlbumId.HasValue) + { + var album = _albumService.GetAlbum(message.AlbumId.Value); + var artist = _artistService.GetArtistByMetadataId(album.ArtistMetadataId); + var updated = RefreshAlbumInfo(album, null, false); + if (updated) + { + _eventAggregator.PublishEvent(new ArtistUpdatedEvent(artist)); + } + } + } + } +} + diff --git a/src/NzbDrone.Core/Music/RefreshArtistService.cs b/src/NzbDrone.Core/Music/RefreshArtistService.cs new file mode 100644 index 000000000..5d3dd2fda --- /dev/null +++ b/src/NzbDrone.Core/Music/RefreshArtistService.cs @@ -0,0 +1,352 @@ +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Configuration; +using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.MetadataSource; +using NzbDrone.Core.Music.Commands; +using NzbDrone.Core.Music.Events; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NzbDrone.Core.ImportLists.Exclusions; +using NzbDrone.Common.EnsureThat; +using NzbDrone.Core.History; + +namespace NzbDrone.Core.Music +{ + public class RefreshArtistService : RefreshEntityServiceBase<Artist, Album>, IExecute<RefreshArtistCommand> + { + private readonly IProvideArtistInfo _artistInfo; + private readonly IArtistService _artistService; + private readonly IAlbumService _albumService; + private readonly IRefreshAlbumService _refreshAlbumService; + private readonly IEventAggregator _eventAggregator; + private readonly IMediaFileService _mediaFileService; + private readonly IHistoryService _historyService; + private readonly IDiskScanService _diskScanService; + private readonly ICheckIfArtistShouldBeRefreshed _checkIfArtistShouldBeRefreshed; + private readonly IConfigService _configService; + private readonly IImportListExclusionService _importListExclusionService; + private readonly Logger _logger; + + public RefreshArtistService(IProvideArtistInfo artistInfo, + IArtistService artistService, + IArtistMetadataService artistMetadataService, + IAlbumService albumService, + IRefreshAlbumService refreshAlbumService, + IEventAggregator eventAggregator, + IMediaFileService mediaFileService, + IHistoryService historyService, + IDiskScanService diskScanService, + ICheckIfArtistShouldBeRefreshed checkIfArtistShouldBeRefreshed, + IConfigService configService, + IImportListExclusionService importListExclusionService, + Logger logger) + : base(logger, artistMetadataService) + { + _artistInfo = artistInfo; + _artistService = artistService; + _albumService = albumService; + _refreshAlbumService = refreshAlbumService; + _eventAggregator = eventAggregator; + _mediaFileService = mediaFileService; + _historyService = historyService; + _diskScanService = diskScanService; + _checkIfArtistShouldBeRefreshed = checkIfArtistShouldBeRefreshed; + _configService = configService; + _importListExclusionService = importListExclusionService; + _logger = logger; + } + + protected override RemoteData GetRemoteData(Artist local, List<Artist> remote) + { + var result = new RemoteData(); + try + { + result.Entity = _artistInfo.GetArtistInfo(local.Metadata.Value.ForeignArtistId, local.MetadataProfileId); + result.Metadata = new List<ArtistMetadata> { result.Entity.Metadata.Value }; + } + catch (ArtistNotFoundException) + { + _logger.Error($"Could not find artist with id {local.Metadata.Value.ForeignArtistId}"); + } + + return result; + } + + protected override bool ShouldDelete(Artist local) + { + return !_mediaFileService.GetFilesByArtist(local.Id).Any(); + } + + protected override void LogProgress(Artist local) + { + _logger.ProgressInfo("Updating Info for {0}", local.Name); + } + + protected override bool IsMerge(Artist local, Artist remote) + { + return local.ArtistMetadataId != remote.Metadata.Value.Id; + } + + protected override UpdateResult UpdateEntity(Artist local, Artist remote) + { + UpdateResult result = UpdateResult.None; + + if(!local.Metadata.Value.Equals(remote.Metadata.Value)) + { + result = UpdateResult.UpdateTags; + } + + local.UseMetadataFrom(remote); + local.Metadata = remote.Metadata; + local.LastInfoSync = DateTime.UtcNow; + + try + { + local.Path = new DirectoryInfo(local.Path).FullName; + local.Path = local.Path.GetActualCasing(); + } + catch (Exception e) + { + _logger.Warn(e, "Couldn't update artist path for " + local.Path); + } + + return result; + } + + protected override UpdateResult MoveEntity(Artist local, Artist remote) + { + _logger.Debug($"Updating MusicBrainz id for {local} to {remote}"); + + // We are moving from one metadata to another (will already have been poplated) + local.ArtistMetadataId = remote.Metadata.Value.Id; + local.Metadata = remote.Metadata.Value; + + // Update list exclusion if one exists + var importExclusion = _importListExclusionService.FindByForeignId(local.Metadata.Value.ForeignArtistId); + + if (importExclusion != null) + { + importExclusion.ForeignId = remote.Metadata.Value.ForeignArtistId; + _importListExclusionService.Update(importExclusion); + } + + // Do the standard update + UpdateEntity(local, remote); + + // We know we need to update tags as artist id has changed + return UpdateResult.UpdateTags; + } + + protected override UpdateResult MergeEntity(Artist local, Artist target, Artist remote) + { + _logger.Warn($"Artist {local} was replaced with {remote} because the original was a duplicate."); + + // Update list exclusion if one exists + var importExclusionLocal = _importListExclusionService.FindByForeignId(local.Metadata.Value.ForeignArtistId); + + if (importExclusionLocal != null) + { + var importExclusionTarget = _importListExclusionService.FindByForeignId(target.Metadata.Value.ForeignArtistId); + if (importExclusionTarget == null) + { + importExclusionLocal.ForeignId = remote.Metadata.Value.ForeignArtistId; + _importListExclusionService.Update(importExclusionLocal); + } + } + + // move any albums over to the new artist and remove the local artist + var albums = _albumService.GetAlbumsByArtist(local.Id); + albums.ForEach(x => x.ArtistMetadataId = target.ArtistMetadataId); + _albumService.UpdateMany(albums); + _artistService.DeleteArtist(local.Id, false); + + // Update history entries to new id + var items = _historyService.GetByArtist(local.Id, null); + items.ForEach(x => x.ArtistId = target.Id); + _historyService.UpdateMany(items); + + // We know we need to update tags as artist id has changed + return UpdateResult.UpdateTags; + } + + protected override Artist GetEntityByForeignId(Artist local) + { + return _artistService.FindById(local.ForeignArtistId); + } + + protected override void SaveEntity(Artist local) + { + _artistService.UpdateArtist(local); + } + + protected override void DeleteEntity(Artist local, bool deleteFiles) + { + _artistService.DeleteArtist(local.Id, true); + } + + protected override List<Album> GetRemoteChildren(Artist remote) + { + return remote.Albums.Value.DistinctBy(m => m.ForeignAlbumId).ToList(); + } + + protected override List<Album> GetLocalChildren(Artist entity, List<Album> remoteChildren) + { + return _albumService.GetAlbumsForRefresh(entity.ArtistMetadataId, + remoteChildren.Select(x => x.ForeignAlbumId) + .Concat(remoteChildren.SelectMany(x => x.OldForeignAlbumIds))); + } + + protected override Tuple<Album, List<Album> > GetMatchingExistingChildren(List<Album> existingChildren, Album remote) + { + var existingChild = existingChildren.SingleOrDefault(x => x.ForeignAlbumId == remote.ForeignAlbumId); + var mergeChildren = existingChildren.Where(x => remote.OldForeignAlbumIds.Contains(x.ForeignAlbumId)).ToList(); + return Tuple.Create(existingChild, mergeChildren); + } + + protected override void PrepareNewChild(Album child, Artist entity) + { + child.Artist = entity; + child.ArtistMetadata = entity.Metadata.Value; + child.ArtistMetadataId = entity.Metadata.Value.Id; + child.Added = DateTime.UtcNow; + child.LastInfoSync = DateTime.MinValue; + child.ProfileId = entity.QualityProfileId; + child.Monitored = entity.Monitored; + } + + protected override void PrepareExistingChild(Album local, Album remote, Artist entity) + { + local.Artist = entity; + local.ArtistMetadata = entity.Metadata.Value; + local.ArtistMetadataId = entity.Metadata.Value.Id; + } + + protected override void AddChildren(List<Album> children) + { + _albumService.InsertMany(children); + } + + protected override bool RefreshChildren(SortedChildren localChildren, List<Album> remoteChildren, bool forceChildRefresh, bool forceUpdateFileTags) + { + // we always want to end up refreshing the albums since we don't get have proper data + Ensure.That(localChildren.UpToDate.Count, () => localChildren.UpToDate.Count).IsLessThanOrEqualTo(0); + return _refreshAlbumService.RefreshAlbumInfo(localChildren.All, remoteChildren, forceChildRefresh, forceUpdateFileTags); + } + + protected override void PublishEntityUpdatedEvent(Artist entity) + { + _eventAggregator.PublishEvent(new ArtistUpdatedEvent(entity)); + } + + protected override void PublishRefreshCompleteEvent(Artist entity) + { + _eventAggregator.PublishEvent(new ArtistRefreshCompleteEvent(entity)); + } + + protected override void PublishChildrenUpdatedEvent(Artist entity, List<Album> newChildren, List<Album> updateChildren) + { + _eventAggregator.PublishEvent(new AlbumInfoRefreshedEvent(entity, newChildren, updateChildren)); + } + + private void RescanArtist(Artist artist, bool isNew, CommandTrigger trigger, bool infoUpdated) + { + var rescanAfterRefresh = _configService.RescanAfterRefresh; + var shouldRescan = true; + + if (isNew) + { + _logger.Trace("Forcing rescan of {0}. Reason: New artist", artist); + shouldRescan = true; + } + + else if (rescanAfterRefresh == RescanAfterRefreshType.Never) + { + _logger.Trace("Skipping rescan of {0}. Reason: never recan after refresh", artist); + shouldRescan = false; + } + + else if (rescanAfterRefresh == RescanAfterRefreshType.AfterManual && trigger != CommandTrigger.Manual) + { + _logger.Trace("Skipping rescan of {0}. Reason: not after automatic scans", artist); + shouldRescan = false; + } + + if (!shouldRescan) + { + return; + } + + try + { + // If some metadata has been updated then rescan unmatched files. + // Otherwise only scan files that haven't been seen before. + var filter = infoUpdated ? FilterFilesType.Matched : FilterFilesType.Known; + _diskScanService.Scan(artist, filter); + } + catch (Exception e) + { + _logger.Error(e, "Couldn't rescan artist {0}", artist); + } + } + + public void Execute(RefreshArtistCommand message) + { + var trigger = message.Trigger; + var isNew = message.IsNewArtist; + + if (message.ArtistId.HasValue) + { + var artist = _artistService.GetArtist(message.ArtistId.Value); + bool updated = false; + try + { + updated = RefreshEntityInfo(artist, null, true, false); + _logger.Trace($"Artist {artist} updated: {updated}"); + RescanArtist(artist, isNew, trigger, updated); + } + catch (Exception e) + { + _logger.Error(e, "Couldn't refresh info for {0}", artist); + RescanArtist(artist, isNew, trigger, updated); + throw; + } + } + else + { + var allArtists = _artistService.GetAllArtists().OrderBy(c => c.Name).ToList(); + + foreach (var artist in allArtists) + { + var manualTrigger = message.Trigger == CommandTrigger.Manual; + + if (manualTrigger || _checkIfArtistShouldBeRefreshed.ShouldRefresh(artist)) + { + bool updated = false; + try + { + updated = RefreshEntityInfo(artist, null, manualTrigger, false); + } + catch (Exception e) + { + _logger.Error(e, "Couldn't refresh info for {0}", artist); + } + + RescanArtist(artist, false, trigger, updated); + } + else + { + _logger.Info("Skipping refresh of artist: {0}", artist.Name); + RescanArtist(artist, false, trigger, false); + } + } + } + } + } +} diff --git a/src/NzbDrone.Core/Music/RefreshEntityServiceBase.cs b/src/NzbDrone.Core/Music/RefreshEntityServiceBase.cs new file mode 100644 index 000000000..574f9140c --- /dev/null +++ b/src/NzbDrone.Core/Music/RefreshEntityServiceBase.cs @@ -0,0 +1,283 @@ +using NLog; +using NzbDrone.Common.Extensions; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace NzbDrone.Core.Music +{ + public abstract class RefreshEntityServiceBase<Entity, Child> + { + private readonly Logger _logger; + private readonly IArtistMetadataService _artistMetadataService; + + public RefreshEntityServiceBase(Logger logger, + IArtistMetadataService artistMetadataService) + { + _logger = logger; + _artistMetadataService = artistMetadataService; + } + + public enum UpdateResult + { + None, + Standard, + UpdateTags + }; + + public class SortedChildren + { + public SortedChildren() + { + UpToDate = new List<Child>(); + Added = new List<Child>(); + Updated = new List<Child>(); + Merged = new List<Tuple<Child, Child> >(); + Deleted = new List<Child>(); + } + + public List<Child> UpToDate { get; set; } + public List<Child> Added { get; set; } + public List<Child> Updated { get; set; } + public List<Tuple<Child, Child> > Merged { get; set; } + public List<Child> Deleted { get; set; } + + public List<Child> All => UpToDate.Concat(Added).Concat(Updated).Concat(Merged.Select(x => x.Item1)).Concat(Deleted).ToList(); + public List<Child> Future => UpToDate.Concat(Added).Concat(Updated).ToList(); + public List<Child> Old => Merged.Select(x => x.Item1).Concat(Deleted).ToList(); + } + + public class RemoteData + { + public Entity Entity { get; set; } + public List<ArtistMetadata> Metadata { get; set; } + } + + protected virtual void LogProgress(Entity local) + { + } + + protected abstract RemoteData GetRemoteData(Entity local, List<Entity> remote); + + protected virtual void EnsureNewParent(Entity local, Entity remote) + { + return; + } + + protected abstract bool IsMerge(Entity local, Entity remote); + + protected virtual bool ShouldDelete(Entity local) + { + return true; + } + + protected abstract UpdateResult UpdateEntity(Entity local, Entity remote); + + protected virtual UpdateResult MoveEntity(Entity local, Entity remote) + { + return UpdateEntity(local, remote); + } + + protected virtual UpdateResult MergeEntity(Entity local, Entity target, Entity remote) + { + DeleteEntity(local, true); + return UpdateResult.UpdateTags; + } + + protected abstract Entity GetEntityByForeignId(Entity local); + protected abstract void SaveEntity(Entity local); + protected abstract void DeleteEntity(Entity local, bool deleteFiles); + + protected abstract List<Child> GetRemoteChildren(Entity remote); + protected abstract List<Child> GetLocalChildren(Entity entity, List<Child> remoteChildren); + protected abstract Tuple<Child, List<Child> > GetMatchingExistingChildren(List<Child> existingChildren, Child remote); + + protected abstract void PrepareNewChild(Child remoteChild, Entity entity); + protected abstract void PrepareExistingChild(Child existingChild, Child remoteChild, Entity entity); + protected abstract void AddChildren(List<Child> children); + protected abstract bool RefreshChildren(SortedChildren localChildren, List<Child> remoteChildren, bool forceChildRefresh, bool forceUpdateFileTags); + + protected virtual void PublishEntityUpdatedEvent(Entity entity) + { + } + + protected virtual void PublishRefreshCompleteEvent(Entity entity) + { + } + + protected virtual void PublishChildrenUpdatedEvent(Entity entity, List<Child> newChildren, List<Child> updateChildren) + { + } + + public bool RefreshEntityInfo(Entity local, List<Entity> remoteList, bool forceChildRefresh, bool forceUpdateFileTags) + { + bool updated = false; + + LogProgress(local); + + var data = GetRemoteData(local, remoteList); + var remote = data.Entity; + + if (remote == null) + { + if (ShouldDelete(local)) + { + _logger.Warn($"{typeof(Entity).Name} {local} not found in metadata and is being deleted"); + DeleteEntity(local, true); + return false; + } + else + { + _logger.Error($"{typeof(Entity).Name} {local} was not found, it may have been removed from Metadata sources."); + return false; + } + } + + if (data.Metadata != null) + { + var metadataResult = UpdateArtistMetadata(data.Metadata); + updated |= metadataResult >= UpdateResult.Standard; + forceUpdateFileTags |= metadataResult == UpdateResult.UpdateTags; + } + + // Validate that the parent object exists (remote data might specify a different one) + EnsureNewParent(local, remote); + + UpdateResult result; + if (IsMerge(local, remote)) + { + // get entity we're merging into + var target = GetEntityByForeignId(remote); + + if (target == null) + { + _logger.Trace($"Moving {typeof(Entity).Name} {local} to {remote}"); + result = MoveEntity(local, remote); + } + else + { + _logger.Trace($"Merging {typeof(Entity).Name} {local} into {target}"); + result = MergeEntity(local, target, remote); + + // having merged local into target, do update for target using remote + local = target; + } + + // Save the entity early so that children see the updated ids + SaveEntity(local); + } + else + { + _logger.Trace($"Updating {typeof(Entity).Name} {local}"); + result = UpdateEntity(local, remote); + } + + updated |= result >= UpdateResult.Standard; + forceUpdateFileTags |= result == UpdateResult.UpdateTags; + + _logger.Trace($"updated: {updated} forceUpdateFileTags: {forceUpdateFileTags}"); + + var remoteChildren = GetRemoteChildren(remote); + updated |= SortChildren(local, remoteChildren, forceChildRefresh, forceUpdateFileTags); + + // Do this last so entity only marked as refreshed if refresh of children completed successfully + _logger.Trace($"Saving {typeof(Entity).Name} {local}"); + SaveEntity(local); + + if (updated) + { + PublishEntityUpdatedEvent(local); + } + + PublishRefreshCompleteEvent(local); + + _logger.Debug($"Finished {typeof(Entity).Name} refresh for {local}"); + + return updated; + } + + public UpdateResult UpdateArtistMetadata(List<ArtistMetadata> data) + { + var remoteMetadata = data.DistinctBy(x => x.ForeignArtistId).ToList(); + var updated = _artistMetadataService.UpsertMany(data); + return updated ? UpdateResult.UpdateTags : UpdateResult.None; + } + + public bool RefreshEntityInfo(List<Entity> localList, List<Entity> remoteList, bool forceChildRefresh, bool forceUpdateFileTags) + { + bool updated = false; + foreach (var entity in localList) + { + updated |= RefreshEntityInfo(entity, remoteList, forceChildRefresh, forceUpdateFileTags); + } + return updated; + } + + protected bool SortChildren(Entity entity, List<Child> remoteChildren, bool forceChildRefresh, bool forceUpdateFileTags) + { + // Get existing children (and children to be) from the database + var localChildren = GetLocalChildren(entity, remoteChildren); + + var sortedChildren = new SortedChildren(); + sortedChildren.Deleted.AddRange(localChildren); + + // Cycle through children + foreach (var remoteChild in remoteChildren) + { + // Check for child in existing children, if not set properties and add to new list + var tuple = GetMatchingExistingChildren(localChildren, remoteChild); + var existingChild = tuple.Item1; + var mergedChildren = tuple.Item2; + + if (existingChild != null) + { + sortedChildren.Deleted.Remove(existingChild); + + PrepareExistingChild(existingChild, remoteChild, entity); + + if (existingChild.Equals(remoteChild)) + { + sortedChildren.UpToDate.Add(existingChild); + } + else + { + sortedChildren.Updated.Add(existingChild); + } + + // note the children that are going to be merged into existingChild + foreach (var child in mergedChildren) + { + sortedChildren.Merged.Add(Tuple.Create(child, existingChild)); + sortedChildren.Deleted.Remove(child); + } + } + else + { + PrepareNewChild(remoteChild, entity); + sortedChildren.Added.Add(remoteChild); + + // note the children that will be merged into remoteChild (once added) + foreach (var child in mergedChildren) + { + sortedChildren.Merged.Add(Tuple.Create(child, remoteChild)); + sortedChildren.Deleted.Remove(child); + } + + } + } + + _logger.Debug("{0} {1} {2}s up to date. Adding {3}, Updating {4}, Merging {5}, Deleting {6}.", + entity, sortedChildren.UpToDate.Count, typeof(Child).Name.ToLower(), + sortedChildren.Added.Count, sortedChildren.Updated.Count, sortedChildren.Merged.Count, sortedChildren.Deleted.Count); + + // Add in the new children (we have checked that foreign IDs don't clash) + AddChildren(sortedChildren.Added); + + // now trigger updates + var updated = RefreshChildren(sortedChildren, remoteChildren, forceChildRefresh, forceUpdateFileTags); + + PublishChildrenUpdatedEvent(entity, sortedChildren.Added, sortedChildren.Updated); + return updated; + } + } +} diff --git a/src/NzbDrone.Core/Music/RefreshTrackService.cs b/src/NzbDrone.Core/Music/RefreshTrackService.cs new file mode 100644 index 000000000..cb4ad38f8 --- /dev/null +++ b/src/NzbDrone.Core/Music/RefreshTrackService.cs @@ -0,0 +1,76 @@ +using NLog; +using NzbDrone.Core.MediaFiles; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace NzbDrone.Core.Music +{ + public interface IRefreshTrackService + { + bool RefreshTrackInfo(List<Track> add, List<Track> update, List<Tuple<Track, Track> > merge, List<Track> delete, List<Track> upToDate, List<Track> remoteTracks, bool forceUpdateFileTags); + } + + public class RefreshTrackService : IRefreshTrackService + { + private readonly ITrackService _trackService; + private readonly IAudioTagService _audioTagService; + private readonly Logger _logger; + + public RefreshTrackService(ITrackService trackService, + IAudioTagService audioTagService, + Logger logger) + { + _trackService = trackService; + _audioTagService = audioTagService; + _logger = logger; + } + + public bool RefreshTrackInfo(List<Track> add, List<Track> update, List<Tuple<Track, Track> > merge, List<Track> delete, List<Track> upToDate, List<Track> remoteTracks, bool forceUpdateFileTags) + { + var updateList = new List<Track>(); + + // for tracks that need updating, just grab the remote track and set db ids + foreach (var track in update) + { + var remoteTrack = remoteTracks.Single(e => e.ForeignTrackId == track.ForeignTrackId); + track.UseMetadataFrom(remoteTrack); + + // make sure title is not null + track.Title = track.Title ?? "Unknown"; + updateList.Add(track); + } + + // Move trackfiles from merged entities into new one + foreach (var item in merge) + { + var trackToMerge = item.Item1; + var mergeTarget = item.Item2; + + if (mergeTarget.TrackFileId == 0) + { + mergeTarget.TrackFileId = trackToMerge.TrackFileId; + } + + if (!updateList.Contains(mergeTarget)) + { + updateList.Add(mergeTarget); + } + } + + _trackService.DeleteMany(delete.Concat(merge.Select(x => x.Item1)).ToList()); + _trackService.UpdateMany(updateList); + + var tagsToUpdate = updateList; + if (forceUpdateFileTags) + { + _logger.Debug("Forcing tag update due to Artist/Album/Release updates"); + tagsToUpdate = updateList.Concat(upToDate).ToList(); + } + _audioTagService.SyncTags(tagsToUpdate); + + return delete.Any() || updateList.Any() || merge.Any(); + } + } +} + diff --git a/src/NzbDrone.Core/Music/Release.cs b/src/NzbDrone.Core/Music/Release.cs new file mode 100644 index 000000000..2ad6936af --- /dev/null +++ b/src/NzbDrone.Core/Music/Release.cs @@ -0,0 +1,68 @@ +using NzbDrone.Common.Extensions; +using System; +using System.Collections.Generic; +using Marr.Data; +using Equ; + +namespace NzbDrone.Core.Music +{ + public class AlbumRelease : Entity<AlbumRelease> + { + public AlbumRelease() + { + OldForeignReleaseIds = new List<string>(); + Label = new List<string>(); + Country = new List<string>(); + Media = new List<Medium>(); + } + + // These correspond to columns in the AlbumReleases table + public int AlbumId { get; set; } + public string ForeignReleaseId { get; set; } + public List<string> OldForeignReleaseIds { get; set; } + public string Title { get; set; } + public string Status { get; set; } + public int Duration { get; set; } + public List<string> Label { get; set; } + public string Disambiguation { get; set; } + public List<string> Country { get; set; } + public DateTime? ReleaseDate { get; set; } + public List<Medium> Media { get; set; } + public int TrackCount { get; set; } + public bool Monitored { get; set; } + + // These are dynamically queried from other tables + [MemberwiseEqualityIgnore] + public LazyLoaded<Album> Album { get; set; } + [MemberwiseEqualityIgnore] + public LazyLoaded<List<Track>> Tracks { get; set; } + + public override string ToString() + { + return string.Format("[{0}][{1}]", ForeignReleaseId, Title.NullSafe()); + } + + public override void UseMetadataFrom(AlbumRelease other) + { + ForeignReleaseId = other.ForeignReleaseId; + OldForeignReleaseIds = other.OldForeignReleaseIds; + Title = other.Title; + Status = other.Status; + Duration = other.Duration; + Label = other.Label; + Disambiguation = other.Disambiguation; + Country = other.Country; + ReleaseDate = other.ReleaseDate; + Media = other.Media; + TrackCount = other.TrackCount; + } + + public override void UseDbFieldsFrom(AlbumRelease other) + { + Id = other.Id; + AlbumId = other.AlbumId; + Album = other.Album; + Monitored = other.Monitored; + } + } +} diff --git a/src/NzbDrone.Core/Music/ReleaseRepository.cs b/src/NzbDrone.Core/Music/ReleaseRepository.cs new file mode 100644 index 000000000..3d6714b0e --- /dev/null +++ b/src/NzbDrone.Core/Music/ReleaseRepository.cs @@ -0,0 +1,80 @@ +using System.Collections.Generic; +using System.Linq; +using Marr.Data.QGen; +using NzbDrone.Common.EnsureThat; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.Music +{ + public interface IReleaseRepository : IBasicRepository<AlbumRelease> + { + AlbumRelease FindByForeignReleaseId(string foreignReleaseId, bool checkRedirect = false); + List<AlbumRelease> FindByAlbum(int id); + List<AlbumRelease> FindByRecordingId(List<string> recordingIds); + List<AlbumRelease> GetReleasesForRefresh(int albumId, IEnumerable<string> foreignReleaseIds); + List<AlbumRelease> SetMonitored(AlbumRelease release); + } + + public class ReleaseRepository : BasicRepository<AlbumRelease>, IReleaseRepository + { + public ReleaseRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + + public AlbumRelease FindByForeignReleaseId(string foreignReleaseId, bool checkRedirect = false) + { + var release = Query + .Where(x => x.ForeignReleaseId == foreignReleaseId) + .SingleOrDefault(); + + if (release == null && checkRedirect) + { + var id = "\"" + foreignReleaseId + "\""; + release = Query.Where(x => x.OldForeignReleaseIds.Contains(id)) + .SingleOrDefault(); + } + + return release; + } + + public List<AlbumRelease> GetReleasesForRefresh(int albumId, IEnumerable<string> foreignReleaseIds) + { + return Query + .Where(r => r.AlbumId == albumId) + .OrWhere($"[ForeignReleaseId] IN ('{string.Join("', '", foreignReleaseIds)}')") + .ToList(); + } + + public List<AlbumRelease> FindByAlbum(int id) + { + // populate the albums and artist metadata also + // this hopefully speeds up the track matching a lot + return Query + .Join<AlbumRelease, Album>(JoinType.Left, r => r.Album, (r, a) => r.AlbumId == a.Id) + .Join<Album, ArtistMetadata>(JoinType.Left, a => a.ArtistMetadata, (a, m) => a.ArtistMetadataId == m.Id) + .Where<AlbumRelease>(r => r.AlbumId == id) + .ToList(); + } + + public List<AlbumRelease> FindByRecordingId(List<string> recordingIds) + { + var query = "SELECT DISTINCT AlbumReleases.*" + + "FROM AlbumReleases " + + "JOIN Tracks ON Tracks.AlbumReleaseId = AlbumReleases.Id " + + $"WHERE Tracks.ForeignRecordingId IN ('{string.Join("', '", recordingIds)}')"; + + return Query.QueryText(query).ToList(); + } + + public List<AlbumRelease> SetMonitored(AlbumRelease release) + { + var allReleases = FindByAlbum(release.AlbumId); + allReleases.ForEach(r => r.Monitored = r.Id == release.Id); + Ensure.That(allReleases.Count(x => x.Monitored) == 1).IsTrue(); + UpdateMany(allReleases); + return allReleases; + } + } +} diff --git a/src/NzbDrone.Core/Music/ReleaseService.cs b/src/NzbDrone.Core/Music/ReleaseService.cs new file mode 100644 index 000000000..a8bb90057 --- /dev/null +++ b/src/NzbDrone.Core/Music/ReleaseService.cs @@ -0,0 +1,89 @@ +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Music.Events; +using System.Collections.Generic; + +namespace NzbDrone.Core.Music +{ + public interface IReleaseService + { + AlbumRelease GetRelease(int id); + AlbumRelease GetReleaseByForeignReleaseId(string foreignReleaseId, bool checkRedirect = false); + void InsertMany(List<AlbumRelease> releases); + void UpdateMany(List<AlbumRelease> releases); + void DeleteMany(List<AlbumRelease> releases); + List<AlbumRelease> GetReleasesForRefresh(int albumId, IEnumerable<string> foreignReleaseIds); + List<AlbumRelease> GetReleasesByAlbum(int releaseGroupId); + List<AlbumRelease> GetReleasesByRecordingIds(List<string> recordingIds); + List<AlbumRelease> SetMonitored(AlbumRelease release); + } + + public class ReleaseService : IReleaseService, + IHandle<AlbumDeletedEvent> + { + private readonly IReleaseRepository _releaseRepository; + private readonly IEventAggregator _eventAggregator; + + public ReleaseService(IReleaseRepository releaseRepository, + IEventAggregator eventAggregator) + { + _releaseRepository = releaseRepository; + _eventAggregator = eventAggregator; + } + + public AlbumRelease GetRelease(int id) + { + return _releaseRepository.Get(id); + } + + public AlbumRelease GetReleaseByForeignReleaseId(string foreignReleaseId, bool checkRedirect = false) + { + return _releaseRepository.FindByForeignReleaseId(foreignReleaseId, checkRedirect); + } + + public void InsertMany(List<AlbumRelease> releases) + { + _releaseRepository.InsertMany(releases); + } + + public void UpdateMany(List<AlbumRelease> releases) + { + _releaseRepository.UpdateMany(releases); + } + + public void DeleteMany(List<AlbumRelease> releases) + { + _releaseRepository.DeleteMany(releases); + foreach (var release in releases) + { + _eventAggregator.PublishEvent(new ReleaseDeletedEvent(release)); + } + } + + public List<AlbumRelease> GetReleasesForRefresh(int albumId, IEnumerable<string> foreignReleaseIds) + { + return _releaseRepository.GetReleasesForRefresh(albumId, foreignReleaseIds); + } + + public List<AlbumRelease> GetReleasesByAlbum(int releaseGroupId) + { + return _releaseRepository.FindByAlbum(releaseGroupId); + } + + public List<AlbumRelease> GetReleasesByRecordingIds(List<string> recordingIds) + { + return _releaseRepository.FindByRecordingId(recordingIds); + } + + public List<AlbumRelease> SetMonitored(AlbumRelease release) + { + return _releaseRepository.SetMonitored(release); + } + + public void Handle(AlbumDeletedEvent message) + { + var releases = GetReleasesByAlbum(message.Album.Id); + DeleteMany(releases); + } + + } +} diff --git a/src/NzbDrone.Core/Music/ReleaseStatus.cs b/src/NzbDrone.Core/Music/ReleaseStatus.cs new file mode 100644 index 000000000..0b62dcc92 --- /dev/null +++ b/src/NzbDrone.Core/Music/ReleaseStatus.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Music +{ + public class ReleaseStatus : IEmbeddedDocument, IEquatable<ReleaseStatus> + { + public int Id { get; set; } + public string Name { get; set; } + + public ReleaseStatus() + { + } + + private ReleaseStatus(int id, string name) + { + Id = id; + Name = name; + } + + public override string ToString() + { + return Name; + } + + public override int GetHashCode() + { + return Id.GetHashCode(); + } + + public bool Equals(ReleaseStatus other) + { + if (ReferenceEquals(null, other)) + { + return false; + } + if (ReferenceEquals(this, other)) + { + return true; + } + return Id.Equals(other.Id); + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) + { + return false; + } + return ReferenceEquals(this, obj) || Equals(obj as ReleaseStatus); + } + + public static bool operator ==(ReleaseStatus left, ReleaseStatus right) + { + return Equals(left, right); + } + + public static bool operator !=(ReleaseStatus left, ReleaseStatus right) + { + return !Equals(left, right); + } + + public static ReleaseStatus Official => new ReleaseStatus(0, "Official"); + public static ReleaseStatus Promotion => new ReleaseStatus(1, "Promotion"); + public static ReleaseStatus Bootleg => new ReleaseStatus(2, "Bootleg"); + public static ReleaseStatus Pseudo => new ReleaseStatus(3, "Pseudo"); + + + public static readonly List<ReleaseStatus> All = new List<ReleaseStatus> + { + Official, + Promotion, + Bootleg, + Pseudo + }; + + + public static ReleaseStatus FindById(int id) + { + if (id == 0) + { + return Official; + } + + ReleaseStatus albumType = All.FirstOrDefault(v => v.Id == id); + + if (albumType == null) + { + throw new ArgumentException(@"ID does not match a known album type", nameof(id)); + } + + return albumType; + } + + public static explicit operator ReleaseStatus(int id) + { + return FindById(id); + } + + public static explicit operator int(ReleaseStatus albumType) + { + return albumType.Id; + } + + public static explicit operator ReleaseStatus(string type) + { + var releaseStatus = All.FirstOrDefault(v => v.Name.Equals(type, StringComparison.InvariantCultureIgnoreCase)); + + if (releaseStatus == null) + { + throw new ArgumentException(@"Status does not match a known release status", nameof(type)); + } + + return releaseStatus; + } + } +} diff --git a/src/NzbDrone.Core/Music/SecondaryAlbumType.cs b/src/NzbDrone.Core/Music/SecondaryAlbumType.cs new file mode 100644 index 000000000..9e43cac8a --- /dev/null +++ b/src/NzbDrone.Core/Music/SecondaryAlbumType.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Music +{ + public class SecondaryAlbumType : IEmbeddedDocument, IEquatable<SecondaryAlbumType> + { + public int Id { get; set; } + public string Name { get; set; } + + public SecondaryAlbumType() + { + } + + private SecondaryAlbumType(int id, string name) + { + Id = id; + Name = name; + } + + public override string ToString() + { + return Name; + } + + public override int GetHashCode() + { + return Id.GetHashCode(); + } + + public bool Equals(SecondaryAlbumType other) + { + if (ReferenceEquals(null, other)) + { + return false; + } + if (ReferenceEquals(this, other)) + { + return true; + } + return Id.Equals(other.Id); + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) + { + return false; + } + return ReferenceEquals(this, obj) || Equals(obj as SecondaryAlbumType); + } + + public static bool operator ==(SecondaryAlbumType left, SecondaryAlbumType right) + { + return Equals(left, right); + } + + public static bool operator !=(SecondaryAlbumType left, SecondaryAlbumType right) + { + return !Equals(left, right); + } + + public static SecondaryAlbumType Studio => new SecondaryAlbumType(0, "Studio"); + public static SecondaryAlbumType Compilation => new SecondaryAlbumType(1, "Compilation"); + public static SecondaryAlbumType Soundtrack => new SecondaryAlbumType(2, "Soundtrack"); + public static SecondaryAlbumType Spokenword => new SecondaryAlbumType(3, "Spokenword"); + public static SecondaryAlbumType Interview => new SecondaryAlbumType(4, "Interview"); + public static SecondaryAlbumType Audiobook => new SecondaryAlbumType(5, "Audiobook"); + public static SecondaryAlbumType Live => new SecondaryAlbumType(6, "Live"); + public static SecondaryAlbumType Remix => new SecondaryAlbumType(7, "Remix"); + public static SecondaryAlbumType DJMix => new SecondaryAlbumType(8, "DJ-mix"); + public static SecondaryAlbumType Mixtape => new SecondaryAlbumType(9, "Mixtape/Street"); + public static SecondaryAlbumType Demo => new SecondaryAlbumType(10, "Demo"); + + + public static readonly List<SecondaryAlbumType> All = new List<SecondaryAlbumType> + { + Studio, + Compilation, + Soundtrack, + Spokenword, + Interview, + Live, + Remix, + DJMix, + Mixtape, + Demo + }; + + + public static SecondaryAlbumType FindById(int id) + { + if (id == 0) + { + return Studio; + } + + SecondaryAlbumType albumType = All.FirstOrDefault(v => v.Id == id); + + if (albumType == null) + { + throw new ArgumentException(@"ID does not match a known album type", nameof(id)); + } + + return albumType; + } + + public static explicit operator SecondaryAlbumType(int id) + { + return FindById(id); + } + + public static explicit operator int(SecondaryAlbumType albumType) + { + return albumType.Id; + } + + public static explicit operator SecondaryAlbumType(string type) + { + var albumType = All.FirstOrDefault(v => v.Name.Equals(type, StringComparison.InvariantCultureIgnoreCase)); + + if (albumType == null) + { + throw new ArgumentException(@"Type does not match a known album type", nameof(type)); + } + + return albumType; + } + } +} diff --git a/src/NzbDrone.Core/Music/ShouldRefreshAlbum.cs b/src/NzbDrone.Core/Music/ShouldRefreshAlbum.cs new file mode 100644 index 000000000..ee8d6383a --- /dev/null +++ b/src/NzbDrone.Core/Music/ShouldRefreshAlbum.cs @@ -0,0 +1,44 @@ +using NLog; +using System; + +namespace NzbDrone.Core.Music +{ + public interface ICheckIfAlbumShouldBeRefreshed + { + bool ShouldRefresh(Album album); + } + + public class ShouldRefreshAlbum : ICheckIfAlbumShouldBeRefreshed + { + private readonly Logger _logger; + + public ShouldRefreshAlbum(Logger logger) + { + _logger = logger; + } + + public bool ShouldRefresh(Album album) + { + if (album.LastInfoSync < DateTime.UtcNow.AddDays(-60)) + { + _logger.Trace("Album {0} last updated more than 60 days ago, should refresh.", album.Title); + return true; + } + + if (album.LastInfoSync >= DateTime.UtcNow.AddHours(-12)) + { + _logger.Trace("Album {0} last updated less than 12 hours ago, should not be refreshed.", album.Title); + return false; + } + + if (album.ReleaseDate > DateTime.UtcNow.AddDays(-30)) + { + _logger.Trace("album {0} released less than 30 days ago, should refresh.", album.Title); + return true; + } + + _logger.Trace("Album {0} released long ago and recently refreshed, should not be refreshed.", album.Title); + return false; + } + } +} diff --git a/src/NzbDrone.Core/Music/ShouldRefreshArtist.cs b/src/NzbDrone.Core/Music/ShouldRefreshArtist.cs new file mode 100644 index 000000000..82f4bb8d7 --- /dev/null +++ b/src/NzbDrone.Core/Music/ShouldRefreshArtist.cs @@ -0,0 +1,57 @@ +using NLog; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Music +{ + public interface ICheckIfArtistShouldBeRefreshed + { + bool ShouldRefresh(Artist artist); + } + + public class ShouldRefreshArtist : ICheckIfArtistShouldBeRefreshed + { + private readonly IAlbumService _albumService; + private readonly Logger _logger; + + public ShouldRefreshArtist(IAlbumService albumService, Logger logger) + { + _albumService = albumService; + _logger = logger; + } + + public bool ShouldRefresh(Artist artist) + { + if (artist.LastInfoSync < DateTime.UtcNow.AddDays(-30)) + { + _logger.Trace("Artist {0} last updated more than 30 days ago, should refresh.", artist.Name); + return true; + } + + if (artist.LastInfoSync >= DateTime.UtcNow.AddHours(-12)) + { + _logger.Trace("Artist {0} last updated less than 12 hours ago, should not be refreshed.", artist.Name); + return false; + } + + if (artist.Metadata.Value.Status == ArtistStatusType.Continuing && artist.LastInfoSync < DateTime.UtcNow.AddDays(-2)) + { + _logger.Trace("Artist {0} is continuing and has not been refreshed in 2 days, should refresh.", artist.Name); + return true; + } + + var lastAlbum = _albumService.GetAlbumsByArtist(artist.Id).OrderByDescending(e => e.ReleaseDate).FirstOrDefault(); + + if (lastAlbum != null && lastAlbum.ReleaseDate > DateTime.UtcNow.AddDays(-30)) + { + _logger.Trace("Last album in {0} aired less than 30 days ago, should refresh.", artist.Name); + return true; + } + + _logger.Trace("Artist {0} ended long ago, should not be refreshed.", artist.Name); + return false; + } + } +} diff --git a/src/NzbDrone.Core/Music/Track.cs b/src/NzbDrone.Core/Music/Track.cs new file mode 100644 index 000000000..ceaabc3d9 --- /dev/null +++ b/src/NzbDrone.Core/Music/Track.cs @@ -0,0 +1,82 @@ +using NzbDrone.Core.MediaFiles; +using Marr.Data; +using NzbDrone.Common.Extensions; +using System.Collections.Generic; +using Equ; + +namespace NzbDrone.Core.Music +{ + public class Track : Entity<Track> + { + public Track() + { + OldForeignTrackIds = new List<string>(); + OldForeignRecordingIds = new List<string>(); + Ratings = new Ratings(); + } + + // These are model fields + public string ForeignTrackId { get; set; } + public List<string> OldForeignTrackIds { get; set; } + public string ForeignRecordingId { get; set; } + public List<string> OldForeignRecordingIds { get; set; } + public int AlbumReleaseId { get; set; } + public int ArtistMetadataId { get; set; } + public string TrackNumber { get; set; } + public int AbsoluteTrackNumber { get; set; } + public string Title { get; set; } + public int Duration { get; set; } + public bool Explicit { get; set; } + public Ratings Ratings { get; set; } + public int MediumNumber { get; set; } + public int TrackFileId { get; set; } + + [MemberwiseEqualityIgnore] + public bool HasFile => TrackFileId > 0; + + // These are dynamically queried from the DB + [MemberwiseEqualityIgnore] + public LazyLoaded<AlbumRelease> AlbumRelease { get; set; } + [MemberwiseEqualityIgnore] + public LazyLoaded<ArtistMetadata> ArtistMetadata { get; set; } + [MemberwiseEqualityIgnore] + public LazyLoaded<TrackFile> TrackFile { get; set; } + [MemberwiseEqualityIgnore] + public LazyLoaded<Artist> Artist { get; set; } + + // These are retained for compatibility + // TODO: Remove set, bodged in because tests expect this to be writable + [MemberwiseEqualityIgnore] + public int AlbumId { get { return AlbumRelease?.Value?.Album?.Value?.Id ?? 0; } set { /* empty */ } } + [MemberwiseEqualityIgnore] + public Album Album { get; set; } + + public override string ToString() + { + return string.Format("[{0}]{1}", ForeignTrackId, Title.NullSafe()); + } + + public override void UseMetadataFrom(Track other) + { + ForeignTrackId = other.ForeignTrackId; + OldForeignTrackIds = other.OldForeignTrackIds; + ForeignRecordingId = other.ForeignRecordingId; + OldForeignRecordingIds = other.OldForeignRecordingIds; + TrackNumber = other.TrackNumber; + AbsoluteTrackNumber = other.AbsoluteTrackNumber; + Title = other.Title; + Duration = other.Duration; + Explicit = other.Explicit; + Ratings = other.Ratings; + MediumNumber = other.MediumNumber; + } + + public override void UseDbFieldsFrom(Track other) + { + Id = other.Id; + AlbumReleaseId = other.AlbumReleaseId; + ArtistMetadataId = other.ArtistMetadataId; + TrackFileId = other.TrackFileId; + } + } +} diff --git a/src/NzbDrone.Core/Music/TrackRepository.cs b/src/NzbDrone.Core/Music/TrackRepository.cs new file mode 100644 index 000000000..ee8aa1e6d --- /dev/null +++ b/src/NzbDrone.Core/Music/TrackRepository.cs @@ -0,0 +1,138 @@ +using NzbDrone.Core.Datastore; +using System.Collections.Generic; +using NLog; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.Music +{ + public interface ITrackRepository : IBasicRepository<Track> + { + List<Track> GetTracks(int artistId); + List<Track> GetTracksByAlbum(int albumId); + List<Track> GetTracksByRelease(int albumReleaseId); + List<Track> GetTracksByReleases(List<int> albumReleaseId); + List<Track> GetTracksForRefresh(int albumReleaseId, IEnumerable<string> foreignTrackIds); + List<Track> GetTracksByFileId(int fileId); + List<Track> GetTracksByFileId(IEnumerable<int> ids); + List<Track> TracksWithFiles(int artistId); + List<Track> TracksWithoutFiles(int albumId); + void SetFileId(List<Track> tracks); + void DetachTrackFile(int trackFileId); + } + + public class TrackRepository : BasicRepository<Track>, ITrackRepository + { + private readonly IMainDatabase _database; + private readonly Logger _logger; + + public TrackRepository(IMainDatabase database, IEventAggregator eventAggregator, Logger logger) + : base(database, eventAggregator) + { + _database = database; + _logger = logger; + } + + public List<Track> GetTracks(int artistId) + { + string query = string.Format("SELECT Tracks.* " + + "FROM Artists " + + "JOIN Albums ON Albums.ArtistMetadataId == Artists.ArtistMetadataId " + + "JOIN AlbumReleases ON AlbumReleases.AlbumId == Albums.Id " + + "JOIN Tracks ON Tracks.AlbumReleaseId == AlbumReleases.Id " + + "WHERE Artists.Id = {0} " + + "AND AlbumReleases.Monitored = 1", + artistId); + + return Query.QueryText(query).ToList(); + } + + public List<Track> GetTracksByAlbum(int albumId) + { + string query = string.Format("SELECT Tracks.* " + + "FROM Albums " + + "JOIN AlbumReleases ON AlbumReleases.AlbumId == Albums.Id " + + "JOIN Tracks ON Tracks.AlbumReleaseId == AlbumReleases.Id " + + "WHERE Albums.Id = {0} " + + "AND AlbumReleases.Monitored = 1", + albumId); + + return Query.QueryText(query).ToList(); + } + + public List<Track> GetTracksByRelease(int albumReleaseId) + { + return Query.Where(t => t.AlbumReleaseId == albumReleaseId).ToList(); + } + + public List<Track> GetTracksByReleases(List<int> albumReleaseIds) + { + // this will populate the artist metadata also + return Query + .Join<Track, ArtistMetadata>(Marr.Data.QGen.JoinType.Inner, t => t.ArtistMetadata, (l, r) => l.ArtistMetadataId == r.Id) + .Where($"[AlbumReleaseId] IN ({string.Join(", ", albumReleaseIds)})") + .ToList(); + } + + public List<Track> GetTracksForRefresh(int albumReleaseId, IEnumerable<string> foreignTrackIds) + { + return Query + .Where(t => t.AlbumReleaseId == albumReleaseId) + .OrWhere($"[ForeignTrackId] IN ('{string.Join("', '", foreignTrackIds)}')") + .ToList(); + } + + public List<Track> GetTracksByFileId(int fileId) + { + return Query.Where(e => e.TrackFileId == fileId).ToList(); + } + + public List<Track> GetTracksByFileId(IEnumerable<int> ids) + { + return Query.Where($"[TrackFileId] IN ({string.Join(", ", ids)})").ToList(); + } + + public List<Track> TracksWithFiles(int artistId) + { + string query = string.Format("SELECT Tracks.* " + + "FROM Artists " + + "JOIN Albums ON Albums.ArtistMetadataId = Artists.ArtistMetadataId " + + "JOIN AlbumReleases ON AlbumReleases.AlbumId == Albums.Id " + + "JOIN Tracks ON Tracks.AlbumReleaseId == AlbumReleases.Id " + + "JOIN TrackFiles ON TrackFiles.Id == Tracks.TrackFileId " + + "WHERE Artists.Id == {0} " + + "AND AlbumReleases.Monitored = 1 ", + artistId); + + return Query.QueryText(query).ToList(); + } + + public List<Track> TracksWithoutFiles(int albumId) + { + string query = string.Format("SELECT Tracks.* " + + "FROM Albums " + + "JOIN AlbumReleases ON AlbumReleases.AlbumId == Albums.Id " + + "JOIN Tracks ON Tracks.AlbumReleaseId == AlbumReleases.Id " + + "LEFT OUTER JOIN TrackFiles ON TrackFiles.Id == Tracks.TrackFileId " + + "WHERE Albums.Id == {0} " + + "AND AlbumReleases.Monitored = 1 " + + "AND TrackFiles.Id IS NULL", + albumId); + + return Query.QueryText(query).ToList(); + } + + public void SetFileId(List<Track> tracks) + { + SetFields(tracks, t => t.TrackFileId); + } + + public void DetachTrackFile(int trackFileId) + { + DataMapper.Update<Track>() + .Where(x => x.TrackFileId == trackFileId) + .ColumnsIncluding(x => x.TrackFileId) + .Entity(new Track { TrackFileId = 0 }) + .Execute(); + } + } +} diff --git a/src/NzbDrone.Core/Music/TrackService.cs b/src/NzbDrone.Core/Music/TrackService.cs new file mode 100644 index 000000000..005fdf67b --- /dev/null +++ b/src/NzbDrone.Core/Music/TrackService.cs @@ -0,0 +1,139 @@ +using NLog; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Music.Events; +using System.Collections.Generic; +using System.Linq; + +namespace NzbDrone.Core.Music +{ + public interface ITrackService + { + Track GetTrack(int id); + List<Track> GetTracks(IEnumerable<int> ids); + List<Track> GetTracksByArtist(int artistId); + List<Track> GetTracksByAlbum(int albumId); + List<Track> GetTracksByRelease(int albumReleaseId); + List<Track> GetTracksByReleases(List<int> albumReleaseIds); + List<Track> GetTracksForRefresh(int albumReleaseId, IEnumerable<string> foreignTrackIds); + List<Track> TracksWithFiles(int artistId); + List<Track> TracksWithoutFiles(int albumId); + List<Track> GetTracksByFileId(int trackFileId); + List<Track> GetTracksByFileId(IEnumerable<int> trackFileIds); + void UpdateTrack(Track track); + void InsertMany(List<Track> tracks); + void UpdateMany(List<Track> tracks); + void DeleteMany(List<Track> tracks); + void SetFileIds(List<Track> tracks); + } + + public class TrackService : ITrackService, + IHandle<ReleaseDeletedEvent>, + IHandle<TrackFileDeletedEvent> + { + private readonly ITrackRepository _trackRepository; + private readonly IConfigService _configService; + private readonly Logger _logger; + + public TrackService(ITrackRepository trackRepository, IConfigService configService, Logger logger) + { + _trackRepository = trackRepository; + _configService = configService; + _logger = logger; + } + + public Track GetTrack(int id) + { + return _trackRepository.Get(id); + } + + public List<Track> GetTracks(IEnumerable<int> ids) + { + return _trackRepository.Get(ids).ToList(); + } + + public List<Track> GetTracksByArtist(int artistId) + { + _logger.Debug("Getting Tracks for ArtistId {0}", artistId); + return _trackRepository.GetTracks(artistId).ToList(); + } + + public List<Track> GetTracksByAlbum(int albumId) + { + return _trackRepository.GetTracksByAlbum(albumId); + } + + public List<Track> GetTracksByRelease(int albumReleaseId) + { + return _trackRepository.GetTracksByRelease(albumReleaseId); + } + + public List<Track> GetTracksByReleases(List<int> albumReleaseIds) + { + return _trackRepository.GetTracksByReleases(albumReleaseIds); + } + + public List<Track> GetTracksForRefresh(int albumReleaseId, IEnumerable<string> foreignTrackIds) + { + return _trackRepository.GetTracksForRefresh(albumReleaseId, foreignTrackIds); + } + + public List<Track> TracksWithFiles(int artistId) + { + return _trackRepository.TracksWithFiles(artistId); + } + + public List<Track> TracksWithoutFiles(int albumId) + { + return _trackRepository.TracksWithoutFiles(albumId); + } + + public List<Track> GetTracksByFileId(int trackFileId) + { + return _trackRepository.GetTracksByFileId(trackFileId); + } + + public List<Track> GetTracksByFileId(IEnumerable<int> trackFileIds) + { + return _trackRepository.GetTracksByFileId(trackFileIds); + } + + public void UpdateTrack(Track track) + { + _trackRepository.Update(track); + } + + public void InsertMany(List<Track> tracks) + { + _trackRepository.InsertMany(tracks); + } + + public void UpdateMany(List<Track> tracks) + { + _trackRepository.UpdateMany(tracks); + } + + public void DeleteMany(List<Track> tracks) + { + _trackRepository.DeleteMany(tracks); + } + + public void SetFileIds(List<Track> tracks) + { + _trackRepository.SetFileId(tracks); + } + + public void Handle(ReleaseDeletedEvent message) + { + var tracks = GetTracksByRelease(message.Release.Id); + _trackRepository.DeleteMany(tracks); + } + + public void Handle(TrackFileDeletedEvent message) + { + _logger.Debug($"Detaching tracks from file {message.TrackFile}"); + _trackRepository.DetachTrackFile(message.TrackFile.Id); + } + } +} diff --git a/src/NzbDrone.Core/Notifications/AlbumDownloadMessage.cs b/src/NzbDrone.Core/Notifications/AlbumDownloadMessage.cs new file mode 100644 index 000000000..f7781d98a --- /dev/null +++ b/src/NzbDrone.Core/Notifications/AlbumDownloadMessage.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Notifications +{ + public class AlbumDownloadMessage + { + public string Message { get; set; } + public Artist Artist { get; set; } + public Album Album { get; set; } + public AlbumRelease Release { get; set; } + public List<TrackFile> TrackFiles { get; set; } + public List<TrackFile> OldFiles { get; set; } + public string DownloadClient { get; set; } + public string DownloadId { get; set; } + + public override string ToString() + { + return Message; + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Boxcar/Boxcar.cs b/src/NzbDrone.Core/Notifications/Boxcar/Boxcar.cs index f5bc2b40c..f493c7f96 100644 --- a/src/NzbDrone.Core/Notifications/Boxcar/Boxcar.cs +++ b/src/NzbDrone.Core/Notifications/Boxcar/Boxcar.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using FluentValidation.Results; using NzbDrone.Common.Extensions; @@ -18,12 +18,27 @@ namespace NzbDrone.Core.Notifications.Boxcar public override void OnGrab(GrabMessage grabMessage) { - _proxy.SendNotification(EPISODE_GRABBED_TITLE, grabMessage.Message, Settings); + _proxy.SendNotification(ALBUM_GRABBED_TITLE, grabMessage.Message, Settings); } - public override void OnDownload(DownloadMessage message) + public override void OnReleaseImport(AlbumDownloadMessage message) { - _proxy.SendNotification(EPISODE_DOWNLOADED_TITLE , message.Message, Settings); + _proxy.SendNotification(ALBUM_DOWNLOADED_TITLE, message.Message, Settings); + } + + public override void OnHealthIssue(HealthCheck.HealthCheck message) + { + _proxy.SendNotification(HEALTH_ISSUE_TITLE, message.Message, Settings); + } + + public override void OnDownloadFailure(DownloadFailedMessage message) + { + _proxy.SendNotification(DOWNLOAD_FAILURE_TITLE, message.Message, Settings); + } + + public override void OnImportFailure(AlbumDownloadMessage message) + { + _proxy.SendNotification(IMPORT_FAILURE_TITLE, message.Message, Settings); } public override ValidationResult Test() diff --git a/src/NzbDrone.Core/Notifications/Boxcar/BoxcarProxy.cs b/src/NzbDrone.Core/Notifications/Boxcar/BoxcarProxy.cs index 61cd7e663..75b70c415 100644 --- a/src/NzbDrone.Core/Notifications/Boxcar/BoxcarProxy.cs +++ b/src/NzbDrone.Core/Notifications/Boxcar/BoxcarProxy.cs @@ -4,6 +4,7 @@ using FluentValidation.Results; using NLog; using RestSharp; using NzbDrone.Core.Rest; +using NzbDrone.Common.EnvironmentInfo; namespace NzbDrone.Core.Notifications.Boxcar { @@ -43,7 +44,7 @@ namespace NzbDrone.Core.Notifications.Boxcar try { const string title = "Test Notification"; - const string body = "This is a test message from Sonarr"; + const string body = "This is a test message from Lidarr"; SendNotification(title, body, settings); return null; @@ -75,8 +76,8 @@ namespace NzbDrone.Core.Notifications.Boxcar request.AddParameter("user_credentials", settings.Token); request.AddParameter("notification[title]", title); request.AddParameter("notification[long_message]", message); - request.AddParameter("notification[source_name]", "Sonarr"); - request.AddParameter("notification[icon_url]", "https://raw.githubusercontent.com/Sonarr/Sonarr/7818f0c59b787312f0bcbc5c0eafc3c9dd7e5451/Logo/64.png"); + request.AddParameter("notification[source_name]", BuildInfo.AppName); + request.AddParameter("notification[icon_url]", "https://github.com/lidarr/Lidarr/raw/develop/Logo/64.png"); client.ExecuteAndValidate(request); } diff --git a/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs b/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs index 69733ae54..ad117451b 100644 --- a/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs +++ b/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs @@ -1,12 +1,14 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Collections.Specialized; -using System.IO; using System.Linq; using FluentValidation.Results; using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.Processes; -using NzbDrone.Core.Tv; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Music; +using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; namespace NzbDrone.Core.Notifications.CustomScript @@ -26,77 +28,131 @@ namespace NzbDrone.Core.Notifications.CustomScript public override string Name => "Custom Script"; - public override string Link => "https://github.com/Sonarr/Sonarr/wiki/Custom-Post-Processing-Scripts"; + public override string Link => "https://github.com/Lidarr/Lidarr/wiki/Custom-Post-Processing-Scripts"; + + public override ProviderMessage Message => new ProviderMessage("Testing will execute the script with the EventType set to Test, ensure your script handles this correctly", ProviderMessageType.Warning); public override void OnGrab(GrabMessage message) { - var series = message.Series; - var remoteEpisode = message.Episode; - var releaseGroup = remoteEpisode.ParsedEpisodeInfo.ReleaseGroup; + var artist = message.Artist; + var remoteAlbum = message.Album; + var releaseGroup = remoteAlbum.ParsedAlbumInfo.ReleaseGroup; var environmentVariables = new StringDictionary(); - environmentVariables.Add("Sonarr_EventType", "Grab"); - 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_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_Title", remoteEpisode.Release.Title); - environmentVariables.Add("Sonarr_Release_Indexer", remoteEpisode.Release.Indexer); - environmentVariables.Add("Sonarr_Release_Size", remoteEpisode.Release.Size.ToString()); - environmentVariables.Add("Sonarr_Release_ReleaseGroup", releaseGroup); + environmentVariables.Add("Lidarr_EventType", "Grab"); + environmentVariables.Add("Lidarr_Artist_Id", artist.Id.ToString()); + environmentVariables.Add("Lidarr_Artist_Name", artist.Metadata.Value.Name); + environmentVariables.Add("Lidarr_Artist_MBId", artist.Metadata.Value.ForeignArtistId); + environmentVariables.Add("Lidarr_Artist_Type", artist.Metadata.Value.Type); + environmentVariables.Add("Lidarr_Release_AlbumCount", remoteAlbum.Albums.Count.ToString()); + environmentVariables.Add("Lidarr_Release_AlbumReleaseDates", string.Join(",", remoteAlbum.Albums.Select(e => e.ReleaseDate))); + environmentVariables.Add("Lidarr_Release_AlbumTitles", string.Join("|", remoteAlbum.Albums.Select(e => e.Title))); + environmentVariables.Add("Lidarr_Release_Title", remoteAlbum.Release.Title); + environmentVariables.Add("Lidarr_Release_Indexer", remoteAlbum.Release.Indexer ?? string.Empty); + environmentVariables.Add("Lidarr_Release_Size", remoteAlbum.Release.Size.ToString()); + environmentVariables.Add("Lidarr_Release_Quality", remoteAlbum.ParsedAlbumInfo.Quality.Quality.Name); + environmentVariables.Add("Lidarr_Release_QualityVersion", remoteAlbum.ParsedAlbumInfo.Quality.Revision.Version.ToString()); + environmentVariables.Add("Lidarr_Release_ReleaseGroup", releaseGroup ?? string.Empty); + environmentVariables.Add("Lidarr_Download_Client", message.DownloadClient ?? string.Empty); + environmentVariables.Add("Lidarr_Download_Id", message.DownloadId ?? string.Empty); ExecuteScript(environmentVariables); } - public override void OnDownload(DownloadMessage message) + public override void OnReleaseImport(AlbumDownloadMessage message) { - var series = message.Series; - var episodeFile = message.EpisodeFile; - var sourcePath = message.SourcePath; + var artist = message.Artist; + var album = message.Album; + var release = message.Release; var environmentVariables = new StringDictionary(); - environmentVariables.Add("Sonarr_EventType", "Download"); - 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_Type", series.SeriesType.ToString()); - environmentVariables.Add("Sonarr_EpisodeFile_Id", episodeFile.Id.ToString()); - environmentVariables.Add("Sonarr_EpisodeFile_EpisodeCount", episodeFile.Episodes.Value.Count.ToString()); - environmentVariables.Add("Sonarr_EpisodeFile_RelativePath", episodeFile.RelativePath); - environmentVariables.Add("Sonarr_EpisodeFile_Path", Path.Combine(series.Path, episodeFile.RelativePath)); - environmentVariables.Add("Sonarr_EpisodeFile_SeasonNumber", episodeFile.SeasonNumber.ToString()); - environmentVariables.Add("Sonarr_EpisodeFile_EpisodeNumbers", string.Join(",", episodeFile.Episodes.Value.Select(e => e.EpisodeNumber))); - environmentVariables.Add("Sonarr_EpisodeFile_EpisodeAirDates", string.Join(",", episodeFile.Episodes.Value.Select(e => e.AirDate))); - environmentVariables.Add("Sonarr_EpisodeFile_EpisodeAirDatesUtc", string.Join(",", episodeFile.Episodes.Value.Select(e => e.AirDateUtc))); - environmentVariables.Add("Sonarr_EpisodeFile_EpisodeTitles", string.Join("|", episodeFile.Episodes.Value.Select(e => e.Title))); - environmentVariables.Add("Sonarr_EpisodeFile_Quality", episodeFile.Quality.Quality.Name); - environmentVariables.Add("Sonarr_EpisodeFile_QualityVersion", episodeFile.Quality.Revision.Version.ToString()); - environmentVariables.Add("Sonarr_EpisodeFile_ReleaseGroup", episodeFile.ReleaseGroup ?? string.Empty); - 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("Lidarr_EventType", "AlbumDownload"); + environmentVariables.Add("Lidarr_Artist_Id", artist.Id.ToString()); + environmentVariables.Add("Lidarr_Artist_Name", artist.Metadata.Value.Name); + environmentVariables.Add("Lidarr_Artist_Path", artist.Path); + environmentVariables.Add("Lidarr_Artist_MBId", artist.Metadata.Value.ForeignArtistId); + environmentVariables.Add("Lidarr_Artist_Type", artist.Metadata.Value.Type); + environmentVariables.Add("Lidarr_Album_Id", album.Id.ToString()); + environmentVariables.Add("Lidarr_Album_Title", album.Title); + environmentVariables.Add("Lidarr_Album_MBId", album.ForeignAlbumId); + environmentVariables.Add("Lidarr_AlbumRelease_MBId", release.ForeignReleaseId); + environmentVariables.Add("Lidarr_Album_ReleaseDate", album.ReleaseDate.ToString()); + environmentVariables.Add("Lidarr_Download_Client", message.DownloadClient ?? string.Empty); + environmentVariables.Add("Lidarr_Download_Id", message.DownloadId ?? string.Empty); + + if (message.TrackFiles.Any()) + { + environmentVariables.Add("Lidarr_AddedTrackPaths", string.Join("|", message.TrackFiles.Select(e => e.Path))); + } + + if (message.OldFiles.Any()) + { + environmentVariables.Add("Lidarr_DeletedPaths", string.Join("|", message.OldFiles.Select(e => e.Path))); + } ExecuteScript(environmentVariables); } - public override void OnRename(Series series) + public override void OnRename(Artist artist) { var environmentVariables = new StringDictionary(); - environmentVariables.Add("Sonarr_EventType", "Rename"); - 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_Type", series.SeriesType.ToString()); + environmentVariables.Add("Lidarr_EventType", "Rename"); + environmentVariables.Add("Lidarr_Artist_Id", artist.Id.ToString()); + environmentVariables.Add("Lidarr_Artist_Name", artist.Metadata.Value.Name); + environmentVariables.Add("Lidarr_Artist_Path", artist.Path); + environmentVariables.Add("Lidarr_Artist_MBId", artist.Metadata.Value.ForeignArtistId); + environmentVariables.Add("Lidarr_Artist_Type", artist.Metadata.Value.Type); ExecuteScript(environmentVariables); } + public override void OnTrackRetag(TrackRetagMessage message) + { + var artist = message.Artist; + var album = message.Album; + var release = message.Release; + var trackFile = message.TrackFile; + var environmentVariables = new StringDictionary(); + + environmentVariables.Add("Lidarr_EventType", "TrackRetag"); + environmentVariables.Add("Lidarr_Artist_Id", artist.Id.ToString()); + environmentVariables.Add("Lidarr_Artist_Name", artist.Metadata.Value.Name); + environmentVariables.Add("Lidarr_Artist_Path", artist.Path); + environmentVariables.Add("Lidarr_Artist_MBId", artist.Metadata.Value.ForeignArtistId); + environmentVariables.Add("Lidarr_Artist_Type", artist.Metadata.Value.Type); + environmentVariables.Add("Lidarr_Album_Id", album.Id.ToString()); + environmentVariables.Add("Lidarr_Album_Title", album.Title); + environmentVariables.Add("Lidarr_Album_MBId", album.ForeignAlbumId); + environmentVariables.Add("Lidarr_AlbumRelease_MBId", release.ForeignReleaseId); + environmentVariables.Add("Lidarr_Album_ReleaseDate", album.ReleaseDate.ToString()); + environmentVariables.Add("Lidarr_TrackFile_Id", trackFile.Id.ToString()); + environmentVariables.Add("Lidarr_TrackFile_TrackCount", trackFile.Tracks.Value.Count.ToString()); + environmentVariables.Add("Lidarr_TrackFile_Path", trackFile.Path); + environmentVariables.Add("Lidarr_TrackFile_TrackNumbers", string.Join(",", trackFile.Tracks.Value.Select(e => e.TrackNumber))); + environmentVariables.Add("Lidarr_TrackFile_TrackTitles", string.Join("|", trackFile.Tracks.Value.Select(e => e.Title))); + environmentVariables.Add("Lidarr_TrackFile_Quality", trackFile.Quality.Quality.Name); + environmentVariables.Add("Lidarr_TrackFile_QualityVersion", trackFile.Quality.Revision.Version.ToString()); + environmentVariables.Add("Lidarr_TrackFile_ReleaseGroup", trackFile.ReleaseGroup ?? string.Empty); + environmentVariables.Add("Lidarr_TrackFile_SceneName", trackFile.SceneName ?? string.Empty); + environmentVariables.Add("Lidarr_Tags_Diff", message.Diff.ToJson()); + environmentVariables.Add("Lidarr_Tags_Scrubbed", message.Scrubbed.ToString()); + + ExecuteScript(environmentVariables); + } + + public override void OnHealthIssue(HealthCheck.HealthCheck healthCheck) + { + var environmentVariables = new StringDictionary(); + + environmentVariables.Add("Lidarr_EventType", "HealthIssue"); + environmentVariables.Add("Lidarr_Health_Issue_Level", nameof(healthCheck.Type)); + environmentVariables.Add("Lidarr_Health_Issue_Message", healthCheck.Message); + environmentVariables.Add("Lidarr_Health_Issue_Type", healthCheck.Source.Name); + environmentVariables.Add("Lidarr_Health_Issue_Wiki", healthCheck.WikiUrl.ToString() ?? string.Empty); + + ExecuteScript(environmentVariables); + } public override ValidationResult Test() { @@ -107,17 +163,37 @@ namespace NzbDrone.Core.Notifications.CustomScript failures.Add(new NzbDroneValidationFailure("Path", "File does not exist")); } + try + { + var environmentVariables = new StringDictionary(); + environmentVariables.Add("Lidarr_EventType", "Test"); + + var processOutput = ExecuteScript(environmentVariables); + + if (processOutput.ExitCode != 0) + { + failures.Add(new NzbDroneValidationFailure(string.Empty, $"Script exited with code: {processOutput.ExitCode}")); + } + } + catch (Exception ex) + { + _logger.Error(ex); + failures.Add(new NzbDroneValidationFailure(string.Empty, ex.Message)); + } + return new ValidationResult(failures); } - private void ExecuteScript(StringDictionary environmentVariables) + private ProcessOutput ExecuteScript(StringDictionary environmentVariables) { _logger.Debug("Executing external script: {0}", Settings.Path); - var process = _processProvider.StartAndCapture(Settings.Path, Settings.Arguments, environmentVariables); + var processOutput = _processProvider.StartAndCapture(Settings.Path, Settings.Arguments, environmentVariables); + + _logger.Debug("Executed external script: {0} - Status: {1}", Settings.Path, processOutput.ExitCode); + _logger.Debug($"Script Output: {System.Environment.NewLine}{string.Join(System.Environment.NewLine, processOutput.Lines)}"); - _logger.Debug("Executed external script: {0} - Status: {1}", Settings.Path, process.ExitCode); - _logger.Debug("Script Output: \r\n{0}", string.Join("\r\n", process.Lines)); + return processOutput; } } } diff --git a/src/NzbDrone.Core/Notifications/CustomScript/CustomScriptSettings.cs b/src/NzbDrone.Core/Notifications/CustomScript/CustomScriptSettings.cs index e426ec651..f4d4d7803 100644 --- a/src/NzbDrone.Core/Notifications/CustomScript/CustomScriptSettings.cs +++ b/src/NzbDrone.Core/Notifications/CustomScript/CustomScriptSettings.cs @@ -11,6 +11,7 @@ namespace NzbDrone.Core.Notifications.CustomScript public CustomScriptSettingsValidator() { RuleFor(c => c.Path).IsValidPath(); + RuleFor(c => c.Arguments).Empty().WithMessage("Arguments are no longer supported for custom scripts"); } } @@ -21,7 +22,7 @@ namespace NzbDrone.Core.Notifications.CustomScript [FieldDefinition(0, Label = "Path", Type = FieldType.FilePath)] public string Path { get; set; } - [FieldDefinition(1, Label = "Arguments", HelpText = "Arguments to pass to the script")] + [FieldDefinition(1, Label = "Arguments", HelpText = "Arguments to pass to the script", Hidden = HiddenType.HiddenIfNotSet)] public string Arguments { get; set; } public NzbDroneValidationResult Validate() diff --git a/src/NzbDrone.Core/Notifications/Discord/Discord.cs b/src/NzbDrone.Core/Notifications/Discord/Discord.cs new file mode 100644 index 000000000..42a5a5729 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Discord/Discord.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using FluentValidation.Results; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Notifications.Discord.Payloads; +using NzbDrone.Core.Music; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Notifications.Discord +{ + public class Discord : NotificationBase<DiscordSettings> + { + private readonly IDiscordProxy _proxy; + + public Discord(IDiscordProxy proxy) + { + _proxy = proxy; + } + + public override string Name => "Discord"; + public override string Link => "https://support.discordapp.com/hc/en-us/articles/228383668-Intro-to-Webhooks"; + + public override void OnGrab(GrabMessage message) + { + var embeds = new List<Embed> + { + new Embed + { + Description = message.Message, + Title = message.Artist.Name, + Text = message.Message, + Color = (int)DiscordColors.Warning + } + }; + var payload = CreatePayload($"Grabbed: {message.Message}", embeds); + + _proxy.SendPayload(payload, Settings); + } + + public override void OnReleaseImport(AlbumDownloadMessage message) + { + var attachments = new List<Embed> + { + new Embed + { + Description = message.Message, + Title = message.Artist.Name, + Text = message.Message, + Color = (int)DiscordColors.Success + } + }; + var payload = CreatePayload($"Imported: {message.Message}", attachments); + + _proxy.SendPayload(payload, Settings); + } + + public override void OnRename(Artist artist) + { + var attachments = new List<Embed> + { + new Embed + { + Title = artist.Name, + } + }; + + var payload = CreatePayload("Renamed", attachments); + + _proxy.SendPayload(payload, Settings); + } + + public override void OnHealthIssue(HealthCheck.HealthCheck healthCheck) + { + var attachments = new List<Embed> + { + new Embed + { + Title = healthCheck.Source.Name, + Text = healthCheck.Message, + Color = healthCheck.Type == HealthCheck.HealthCheckResult.Warning ? (int)DiscordColors.Warning : (int)DiscordColors.Danger + } + }; + + var payload = CreatePayload("Health Issue", attachments); + + _proxy.SendPayload(payload, Settings); + } + + public override void OnTrackRetag(TrackRetagMessage message) + { + var attachments = new List<Embed> + { + new Embed + { + Title = TRACK_RETAGGED_TITLE, + Text = message.Message + } + }; + + var payload = CreatePayload($"Track file tags updated: {message.Message}", attachments); + + _proxy.SendPayload(payload, Settings); + } + + public override void OnDownloadFailure(DownloadFailedMessage message) + { + var attachments = new List<Embed> + { + new Embed + { + Description = message.Message, + Title = message.SourceTitle, + Text = message.Message, + Color = (int)DiscordColors.Danger + } + }; + var payload = CreatePayload($"Download Failed: {message.Message}", attachments); + + _proxy.SendPayload(payload, Settings); + } + + public override void OnImportFailure(AlbumDownloadMessage message) + { + var attachments = new List<Embed> + { + new Embed + { + Description = message.Message, + Title = message.Album.Title, + Text = message.Message, + Color = (int)DiscordColors.Warning + } + }; + var payload = CreatePayload($"Import Failed: {message.Message}", attachments); + + _proxy.SendPayload(payload, Settings); + } + + public override ValidationResult Test() + { + var failures = new List<ValidationFailure>(); + + failures.AddIfNotNull(TestMessage()); + + return new ValidationResult(failures); + } + + public ValidationFailure TestMessage() + { + try + { + var message = $"Test message from Lidarr posted at {DateTime.Now}"; + var payload = CreatePayload(message); + + _proxy.SendPayload(payload, Settings); + + } + catch (DiscordException ex) + { + return new NzbDroneValidationFailure("Unable to post", ex.Message); + } + + return null; + } + + private DiscordPayload CreatePayload(string message, List<Embed> embeds = null) + { + var avatar = Settings.Avatar; + + var payload = new DiscordPayload + { + Username = Settings.Username, + Content = message, + Embeds = embeds + }; + + if (avatar.IsNotNullOrWhiteSpace()) + { + payload.AvatarUrl = avatar; + } + + if (Settings.Username.IsNotNullOrWhiteSpace()) + { + payload.Username = Settings.Username; + } + + return payload; + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Discord/DiscordColors.cs b/src/NzbDrone.Core/Notifications/Discord/DiscordColors.cs new file mode 100644 index 000000000..16590aade --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Discord/DiscordColors.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Notifications.Discord +{ + public enum DiscordColors + { + Danger = 15749200, + Success = 2605644, + Warning = 16753920 + } +} diff --git a/src/NzbDrone.Core/Notifications/Discord/DiscordException.cs b/src/NzbDrone.Core/Notifications/Discord/DiscordException.cs new file mode 100644 index 000000000..1bc0d6294 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Discord/DiscordException.cs @@ -0,0 +1,16 @@ +using System; +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.Notifications.Discord +{ + class DiscordException : NzbDroneException + { + public DiscordException(string message) : base(message) + { + } + + public DiscordException(string message, Exception innerException, params object[] args) : base(message, innerException, args) + { + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Discord/DiscordProxy.cs b/src/NzbDrone.Core/Notifications/Discord/DiscordProxy.cs new file mode 100644 index 000000000..425364253 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Discord/DiscordProxy.cs @@ -0,0 +1,46 @@ +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Notifications.Discord.Payloads; +using NzbDrone.Core.Rest; + +namespace NzbDrone.Core.Notifications.Discord +{ + public interface IDiscordProxy + { + void SendPayload(DiscordPayload payload, DiscordSettings settings); + } + + public class DiscordProxy : IDiscordProxy + { + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + + public DiscordProxy(IHttpClient httpClient, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public void SendPayload(DiscordPayload payload, DiscordSettings 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 DiscordException("Unable to post payload", ex); + } + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Discord/DiscordSettings.cs b/src/NzbDrone.Core/Notifications/Discord/DiscordSettings.cs new file mode 100644 index 000000000..e4ba51571 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Discord/DiscordSettings.cs @@ -0,0 +1,35 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Notifications.Discord +{ + public class DiscordSettingsValidator : AbstractValidator<DiscordSettings> + { + public DiscordSettingsValidator() + { + RuleFor(c => c.WebHookUrl).IsValidUrl(); + } + } + + public class DiscordSettings : IProviderConfig + { + private static readonly DiscordSettingsValidator Validator = new DiscordSettingsValidator(); + + [FieldDefinition(0, Label = "Webhook URL", HelpText = "Discord channel webhook url")] + public string WebHookUrl { get; set; } + + [FieldDefinition(1, Label = "Username", HelpText = "The username to post as, defaults to Discord webhook default")] + public string Username { get; set; } + + [FieldDefinition(2, Label = "Avatar", HelpText = "Change the avatar that is used for messages from this integration", Type = FieldType.Textbox)] + public string Avatar { get; set; } + + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Discord/Payloads/DiscordPayload.cs b/src/NzbDrone.Core/Notifications/Discord/Payloads/DiscordPayload.cs new file mode 100644 index 000000000..37f1f1c3d --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Discord/Payloads/DiscordPayload.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Notifications.Discord.Payloads +{ + public class DiscordPayload + { + public string Content { get; set; } + + public string Username { get; set; } + + [JsonProperty("avatar_url")] + public string AvatarUrl { get; set; } + + public List<Embed> Embeds { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Discord/Payloads/Embed.cs b/src/NzbDrone.Core/Notifications/Discord/Payloads/Embed.cs new file mode 100644 index 000000000..50e27914b --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Discord/Payloads/Embed.cs @@ -0,0 +1,10 @@ +namespace NzbDrone.Core.Notifications.Discord.Payloads +{ + public class Embed + { + public string Description { get; set; } + public string Title { get; set; } + public string Text { get; set; } + public int Color { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/DownloadFailedMessage.cs b/src/NzbDrone.Core/Notifications/DownloadFailedMessage.cs new file mode 100644 index 000000000..9af4d2577 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/DownloadFailedMessage.cs @@ -0,0 +1,20 @@ +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Notifications +{ + public class DownloadFailedMessage + { + public string Message { get; set; } + public string SourceTitle { get; set; } + public QualityModel Quality { get; set; } + public string DownloadClient { get; set; } + public string DownloadId { get; set; } + + public override string ToString() + { + return Message; + } + } +} diff --git a/src/NzbDrone.Core/Notifications/DownloadMessage.cs b/src/NzbDrone.Core/Notifications/DownloadMessage.cs deleted file mode 100644 index a16ecea80..000000000 --- a/src/NzbDrone.Core/Notifications/DownloadMessage.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Notifications -{ - public class DownloadMessage - { - public string Message { get; set; } - public Series Series { get; set; } - public EpisodeFile EpisodeFile { get; set; } - public List<EpisodeFile> OldFiles { get; set; } - public string SourcePath { get; set; } - - public override string ToString() - { - return Message; - } - } -} diff --git a/src/NzbDrone.Core/Notifications/Email/Email.cs b/src/NzbDrone.Core/Notifications/Email/Email.cs index 52597861d..3ecc2f11a 100644 --- a/src/NzbDrone.Core/Notifications/Email/Email.cs +++ b/src/NzbDrone.Core/Notifications/Email/Email.cs @@ -1,7 +1,6 @@ -using System.Collections.Generic; +using System.Collections.Generic; using FluentValidation.Results; using NzbDrone.Common.Extensions; -using NzbDrone.Core.Tv; namespace NzbDrone.Core.Notifications.Email { @@ -23,16 +22,30 @@ namespace NzbDrone.Core.Notifications.Email { var body = $"{grabMessage.Message} sent to queue."; - _emailService.SendEmail(Settings, EPISODE_GRABBED_TITLE_BRANDED, body); + _emailService.SendEmail(Settings, ALBUM_GRABBED_TITLE_BRANDED, body); } - public override void OnDownload(DownloadMessage message) + public override void OnReleaseImport(AlbumDownloadMessage message) { var body = $"{message.Message} Downloaded and sorted."; - _emailService.SendEmail(Settings, EPISODE_DOWNLOADED_TITLE_BRANDED, body); + _emailService.SendEmail(Settings, ALBUM_DOWNLOADED_TITLE_BRANDED, body); } + public override void OnHealthIssue(HealthCheck.HealthCheck message) + { + _emailService.SendEmail(Settings, HEALTH_ISSUE_TITLE_BRANDED, message.Message); + } + + public override void OnDownloadFailure(DownloadFailedMessage message) + { + _emailService.SendEmail(Settings, DOWNLOAD_FAILURE_TITLE_BRANDED, message.Message); + } + + public override void OnImportFailure(AlbumDownloadMessage message) + { + _emailService.SendEmail(Settings, IMPORT_FAILURE_TITLE_BRANDED, message.Message); + } public override ValidationResult Test() { diff --git a/src/NzbDrone.Core/Notifications/Email/EmailService.cs b/src/NzbDrone.Core/Notifications/Email/EmailService.cs index 2599e5de4..7cd8bf0c5 100644 --- a/src/NzbDrone.Core/Notifications/Email/EmailService.cs +++ b/src/NzbDrone.Core/Notifications/Email/EmailService.cs @@ -64,7 +64,7 @@ namespace NzbDrone.Core.Notifications.Email try { - SendEmail(settings, "Sonarr - Test Notification", body); + SendEmail(settings, "Lidarr - Test Notification", body); } catch (Exception ex) { diff --git a/src/NzbDrone.Core/Notifications/Gotify/Gotify.cs b/src/NzbDrone.Core/Notifications/Gotify/Gotify.cs new file mode 100644 index 000000000..d5a287514 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Gotify/Gotify.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using FluentValidation.Results; +using NLog; + +namespace NzbDrone.Core.Notifications.Gotify +{ + public class Gotify : NotificationBase<GotifySettings> + { + private readonly IGotifyProxy _proxy; + private readonly Logger _logger; + + public Gotify(IGotifyProxy proxy, Logger logger) + { + _proxy = proxy; + _logger = logger; + } + + public override string Name => "Gotify"; + public override string Link => "https://gotify.net/"; + + public override void OnGrab(GrabMessage grabMessage) + { + _proxy.SendNotification(ALBUM_GRABBED_TITLE, grabMessage.Message, Settings); + } + + public override void OnReleaseImport(AlbumDownloadMessage message) + { + _proxy.SendNotification(ALBUM_DOWNLOADED_TITLE, message.Message, Settings); + } + + public override void OnHealthIssue(HealthCheck.HealthCheck healthCheck) + { + _proxy.SendNotification(HEALTH_ISSUE_TITLE, healthCheck.Message, Settings); + } + + public override void OnDownloadFailure(DownloadFailedMessage message) + { + _proxy.SendNotification(DOWNLOAD_FAILURE_TITLE, message.Message, Settings); + } + + public override void OnImportFailure(AlbumDownloadMessage message) + { + _proxy.SendNotification(IMPORT_FAILURE_TITLE, message.Message, Settings); + } + + public override ValidationResult Test() + { + var failures = new List<ValidationFailure>(); + + try + { + const string title = "Test Notification"; + const string body = "This is a test message from Lidarr"; + + _proxy.SendNotification(title, body, Settings); + } + catch (Exception ex) + { + _logger.Error(ex, "Unable to send test message"); + failures.Add(new ValidationFailure("", "Unable to send test message")); + } + + return new ValidationResult(failures); + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Gotify/GotifyPriority.cs b/src/NzbDrone.Core/Notifications/Gotify/GotifyPriority.cs new file mode 100644 index 000000000..8782d4dcd --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Gotify/GotifyPriority.cs @@ -0,0 +1,11 @@ +namespace NzbDrone.Core.Notifications.Gotify + +{ + public enum GotifyPriority + { + Min = 0, + Low = 2, + Normal = 5, + High = 8 + } +} diff --git a/src/NzbDrone.Core/Notifications/Gotify/GotifyProxy.cs b/src/NzbDrone.Core/Notifications/Gotify/GotifyProxy.cs new file mode 100644 index 000000000..b14b530d3 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Gotify/GotifyProxy.cs @@ -0,0 +1,26 @@ +using RestSharp; +using NzbDrone.Core.Rest; + +namespace NzbDrone.Core.Notifications.Gotify +{ + public interface IGotifyProxy + { + void SendNotification(string title, string message, GotifySettings settings); + } + + public class GotifyProxy : IGotifyProxy + { + public void SendNotification(string title, string message, GotifySettings settings) + { + var client = RestClientFactory.BuildClient(settings.Server); + var request = new RestRequest("message", Method.POST); + + request.AddQueryParameter("token", settings.AppToken); + request.AddParameter("title", title); + request.AddParameter("message", message); + request.AddParameter("priority", settings.Priority); + + client.ExecuteAndValidate(request); + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Gotify/GotifySettings.cs b/src/NzbDrone.Core/Notifications/Gotify/GotifySettings.cs new file mode 100644 index 000000000..4e6f929c9 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Gotify/GotifySettings.cs @@ -0,0 +1,40 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Notifications.Gotify +{ + public class GotifySettingsValidator : AbstractValidator<GotifySettings> + { + public GotifySettingsValidator() + { + RuleFor(c => c.Server).IsValidUrl(); + RuleFor(c => c.AppToken).NotEmpty(); + } + } + + public class GotifySettings : IProviderConfig + { + private static readonly GotifySettingsValidator Validator = new GotifySettingsValidator(); + + public GotifySettings() + { + Priority = 5; + } + + [FieldDefinition(0, Label = "Gotify Server", HelpText = "Gotify server URL, including http(s):// and port if needed")] + public string Server { get; set; } + + [FieldDefinition(1, Label = "App Token", HelpText = "The Application Token generated by Gotify")] + public string AppToken { get; set; } + + [FieldDefinition(2, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(GotifyPriority), HelpText = "Priority of the notification")] + public int Priority { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Gotify/InvalidResponseException.cs b/src/NzbDrone.Core/Notifications/Gotify/InvalidResponseException.cs new file mode 100644 index 000000000..0a4c20b4d --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Gotify/InvalidResponseException.cs @@ -0,0 +1,15 @@ +using System; + +namespace NzbDrone.Core.Notifications.Gotify +{ + public class InvalidResponseException : Exception + { + public InvalidResponseException() + { + } + + public InvalidResponseException(string message) : base(message) + { + } + } +} diff --git a/src/NzbDrone.Core/Notifications/GrabMessage.cs b/src/NzbDrone.Core/Notifications/GrabMessage.cs index e62dbe701..b9eda36cd 100644 --- a/src/NzbDrone.Core/Notifications/GrabMessage.cs +++ b/src/NzbDrone.Core/Notifications/GrabMessage.cs @@ -1,15 +1,17 @@ -using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Qualities; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Notifications { public class GrabMessage { public string Message { get; set; } - public Series Series { get; set; } - public RemoteEpisode Episode { get; set; } - public QualityModel Quality { get; set; } + public Artist Artist { get; set; } + public RemoteAlbum Album { 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/Growl/Growl.cs b/src/NzbDrone.Core/Notifications/Growl/Growl.cs index 97232be70..6ae211cee 100644 --- a/src/NzbDrone.Core/Notifications/Growl/Growl.cs +++ b/src/NzbDrone.Core/Notifications/Growl/Growl.cs @@ -1,7 +1,6 @@ -using System.Collections.Generic; +using System.Collections.Generic; using FluentValidation.Results; using NzbDrone.Common.Extensions; -using NzbDrone.Core.Tv; namespace NzbDrone.Core.Notifications.Growl { @@ -21,14 +20,28 @@ namespace NzbDrone.Core.Notifications.Growl public override void OnGrab(GrabMessage grabMessage) { - _growlService.SendNotification(EPISODE_GRABBED_TITLE, grabMessage.Message, "GRAB", Settings.Host, Settings.Port, Settings.Password); + _growlService.SendNotification(ALBUM_GRABBED_TITLE, grabMessage.Message, "GRAB", Settings.Host, Settings.Port, Settings.Password); } - public override void OnDownload(DownloadMessage message) + public override void OnReleaseImport(AlbumDownloadMessage message) { - _growlService.SendNotification(EPISODE_DOWNLOADED_TITLE, message.Message, "DOWNLOAD", Settings.Host, Settings.Port, Settings.Password); + _growlService.SendNotification(ALBUM_DOWNLOADED_TITLE, message.Message, "ALBUMDOWNLOAD", Settings.Host, Settings.Port, Settings.Password); } + public override void OnHealthIssue(HealthCheck.HealthCheck message) + { + _growlService.SendNotification(HEALTH_ISSUE_TITLE, message.Message, "HEALTHISSUE", Settings.Host, Settings.Port, Settings.Password); + } + + public override void OnDownloadFailure(DownloadFailedMessage message) + { + _growlService.SendNotification(DOWNLOAD_FAILURE_TITLE, message.Message, "DOWNLOADFAILURE", Settings.Host, Settings.Port, Settings.Password); + } + + public override void OnImportFailure(AlbumDownloadMessage message) + { + _growlService.SendNotification(IMPORT_FAILURE_TITLE, message.Message, "IMPORTFAILURE", Settings.Host, Settings.Port, Settings.Password); + } public override ValidationResult Test() { diff --git a/src/NzbDrone.Core/Notifications/Growl/GrowlService.cs b/src/NzbDrone.Core/Notifications/Growl/GrowlService.cs index e4d6c3d08..fa2a6971e 100644 --- a/src/NzbDrone.Core/Notifications/Growl/GrowlService.cs +++ b/src/NzbDrone.Core/Notifications/Growl/GrowlService.cs @@ -1,4 +1,4 @@ -using FluentValidation.Results; +using FluentValidation.Results; using Growl.Connector; using Growl.CoreLibrary; using NzbDrone.Common.Extensions; @@ -20,9 +20,8 @@ namespace NzbDrone.Core.Notifications.Growl public class GrowlService : IGrowlService { private readonly Logger _logger; - - //TODO: Change this to Sonarr, but it is a breaking change (v3) - private readonly Application _growlApplication = new Application("NzbDrone"); + + private readonly Application _growlApplication = new Application("Lidarr"); private readonly NotificationType[] _notificationTypes; private class GrowlRequestState @@ -102,7 +101,7 @@ namespace NzbDrone.Core.Notifications.Growl private void Register(string host, int port, string password) { - _logger.Debug("Registering Sonarr with Growl host: {0}:{1}", host, port); + _logger.Debug("Registering Lidarr with Growl host: {0}:{1}", host, port); var growlConnector = GetGrowlConnector(host, port, password); @@ -133,8 +132,9 @@ namespace NzbDrone.Core.Notifications.Growl { var notificationTypes = new List<NotificationType>(); notificationTypes.Add(new NotificationType("TEST", "Test")); - notificationTypes.Add(new NotificationType("GRAB", "Episode Grabbed")); - notificationTypes.Add(new NotificationType("DOWNLOAD", "Episode Complete")); + notificationTypes.Add(new NotificationType("GRAB", "Album Grabbed")); + notificationTypes.Add(new NotificationType("TRACKDOWNLOAD", "Track Complete")); + notificationTypes.Add(new NotificationType("ALBUMDOWNLOAD", "Album Complete")); return notificationTypes.ToArray(); } @@ -146,7 +146,7 @@ namespace NzbDrone.Core.Notifications.Growl Register(settings.Host, settings.Port, settings.Password); const string title = "Test Notification"; - const string body = "This is a test message from Sonarr"; + const string body = "This is a test message from Lidarr"; SendNotification(title, body, "TEST", settings.Host, settings.Port, settings.Password); } diff --git a/src/NzbDrone.Core/Notifications/INotification.cs b/src/NzbDrone.Core/Notifications/INotification.cs index 7c4e105b9..c5ec56f0e 100644 --- a/src/NzbDrone.Core/Notifications/INotification.cs +++ b/src/NzbDrone.Core/Notifications/INotification.cs @@ -1,5 +1,5 @@ -using NzbDrone.Core.ThingiProvider; -using NzbDrone.Core.Tv; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Notifications { @@ -8,11 +8,19 @@ namespace NzbDrone.Core.Notifications string Link { get; } void OnGrab(GrabMessage grabMessage); - void OnDownload(DownloadMessage message); - void OnRename(Series series); + void OnReleaseImport(AlbumDownloadMessage message); + void OnRename(Artist artist); + void OnHealthIssue(HealthCheck.HealthCheck healthCheck); + void OnDownloadFailure(DownloadFailedMessage message); + void OnImportFailure(AlbumDownloadMessage message); + void OnTrackRetag(TrackRetagMessage message); bool SupportsOnGrab { get; } - bool SupportsOnDownload { get; } + bool SupportsOnReleaseImport { get; } bool SupportsOnUpgrade { get; } bool SupportsOnRename { get; } + bool SupportsOnHealthIssue { get; } + bool SupportsOnDownloadFailure { get; } + bool SupportsOnImportFailure { get; } + bool SupportsOnTrackRetag { get; } } } diff --git a/src/NzbDrone.Core/Notifications/Join/Join.cs b/src/NzbDrone.Core/Notifications/Join/Join.cs index e73e237df..a61c71581 100644 --- a/src/NzbDrone.Core/Notifications/Join/Join.cs +++ b/src/NzbDrone.Core/Notifications/Join/Join.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using FluentValidation.Results; using NzbDrone.Common.Extensions; @@ -17,14 +17,19 @@ namespace NzbDrone.Core.Notifications.Join public override string Link => "https://joaoapps.com/join/"; - public override void OnGrab(GrabMessage grabMessage) + public override void OnGrab(GrabMessage message) { - _proxy.SendNotification(EPISODE_GRABBED_TITLE_BRANDED, Message.Message, Settings); + _proxy.SendNotification(ALBUM_GRABBED_TITLE_BRANDED, message.Message, Settings); } - public override void OnDownload(DownloadMessage message) + public override void OnReleaseImport(AlbumDownloadMessage message) { - _proxy.SendNotification(EPISODE_DOWNLOADED_TITLE_BRANDED, message.Message, Settings); + _proxy.SendNotification(ALBUM_DOWNLOADED_TITLE_BRANDED, message.Message, Settings); + } + + public override void OnHealthIssue(HealthCheck.HealthCheck message) + { + _proxy.SendNotification(HEALTH_ISSUE_TITLE_BRANDED, message.Message, Settings); } public override ValidationResult Test() diff --git a/src/NzbDrone.Core/Notifications/Join/JoinPriority.cs b/src/NzbDrone.Core/Notifications/Join/JoinPriority.cs new file mode 100644 index 000000000..9f2cab16f --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Join/JoinPriority.cs @@ -0,0 +1,11 @@ +namespace NzbDrone.Core.Notifications.Join +{ + public enum JoinPriority + { + Silent = -2, + Quiet = -1, + Normal = 0, + High = 1, + Emergency = 2 + } +} diff --git a/src/NzbDrone.Core/Notifications/Join/JoinProxy.cs b/src/NzbDrone.Core/Notifications/Join/JoinProxy.cs index 548fe6bde..50ee7b981 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; @@ -41,7 +42,7 @@ namespace NzbDrone.Core.Notifications.Join public ValidationFailure Test(JoinSettings settings) { const string title = "Test Notification"; - const string body = "This is a test message from Sonarr."; + const string body = "This is a test message from Lidarr."; try { @@ -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); } @@ -83,11 +88,13 @@ namespace NzbDrone.Core.Notifications.Join { request.AddParameter("deviceId", "group.all"); } - + request.AddParameter("apikey", settings.ApiKey); request.AddParameter("title", title); request.AddParameter("text", message); - request.AddParameter("icon", "https://cdn.rawgit.com/Sonarr/Sonarr/develop/Logo/256.png"); // Use the Sonarr logo. + request.AddParameter("icon", "https://cdn.rawgit.com/Lidarr/Lidarr/develop/Logo/256.png"); // Use the Lidarr logo. + request.AddParameter("smallicon", "https://cdn.rawgit.com/Lidarr/Lidarr/develop/Logo/96-Outline-White.png"); // 96x96px with outline at 88x88px on a transparent background. + request.AddParameter("priority", settings.Priority); var response = client.ExecuteAndValidate(request); var res = Json.Deserialize<JoinResponseModel>(response.Content); @@ -108,7 +115,7 @@ namespace NzbDrone.Core.Notifications.Join throw new JoinInvalidDeviceException(res.errorMessage); } // Oddly enough, rather than give us an "Invalid API key", the Join API seems to assume the key is valid, - // but fails when doing a device lookup associated with that key. + // but fails when doing a device lookup associated with that key. // In our case we are using "deviceIds" rather than "deviceId" so when the singular form error shows up // we know the API key was the fault. else if (res.errorMessage.Equals("No device to send message to")) diff --git a/src/NzbDrone.Core/Notifications/Join/JoinSettings.cs b/src/NzbDrone.Core/Notifications/Join/JoinSettings.cs index 29d750782..db5c8550a 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,20 +10,31 @@ 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"); } } public class JoinSettings : IProviderConfig { + public JoinSettings() + { + Priority = (int)JoinPriority.Normal; + } + private static readonly JoinSettingsValidator Validator = new JoinSettingsValidator(); [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; } + + [FieldDefinition(3, Label = "Notification Priority", Type = FieldType.Select, SelectOptions = typeof(JoinPriority))] + public int Priority { get; set; } + public NzbDroneValidationResult Validate() { return new NzbDroneValidationResult(Validator.Validate(this)); diff --git a/src/NzbDrone.Core/Notifications/MediaBrowser/MediaBrowser.cs b/src/NzbDrone.Core/Notifications/MediaBrowser/MediaBrowser.cs index b13409dc7..ad2ff5bea 100644 --- a/src/NzbDrone.Core/Notifications/MediaBrowser/MediaBrowser.cs +++ b/src/NzbDrone.Core/Notifications/MediaBrowser/MediaBrowser.cs @@ -1,7 +1,7 @@ -using System.Collections.Generic; +using System.Collections.Generic; using FluentValidation.Results; using NzbDrone.Common.Extensions; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Notifications.Emby { @@ -22,31 +22,46 @@ namespace NzbDrone.Core.Notifications.Emby { if (Settings.Notify) { - _mediaBrowserService.Notify(Settings, EPISODE_GRABBED_TITLE_BRANDED, grabMessage.Message); + _mediaBrowserService.Notify(Settings, ALBUM_GRABBED_TITLE_BRANDED, grabMessage.Message); } } - public override void OnDownload(DownloadMessage message) + public override void OnReleaseImport(AlbumDownloadMessage message) { if (Settings.Notify) { - _mediaBrowserService.Notify(Settings, EPISODE_DOWNLOADED_TITLE_BRANDED, message.Message); + _mediaBrowserService.Notify(Settings, ALBUM_DOWNLOADED_TITLE_BRANDED, message.Message); } if (Settings.UpdateLibrary) { - _mediaBrowserService.Update(Settings, message.Series); + _mediaBrowserService.Update(Settings, message.Artist); } } - public override void OnRename(Series series) + public override void OnRename(Artist artist) { if (Settings.UpdateLibrary) { - _mediaBrowserService.Update(Settings, series); + _mediaBrowserService.Update(Settings, artist); } } + public override void OnHealthIssue(HealthCheck.HealthCheck message) + { + if (Settings.Notify) + { + _mediaBrowserService.Notify(Settings, HEALTH_ISSUE_TITLE_BRANDED, message.Message); + } + } + + public override void OnTrackRetag(TrackRetagMessage message) + { + if (Settings.Notify) + { + _mediaBrowserService.Notify(Settings, TRACK_RETAGGED_TITLE_BRANDED, message.Message); + } + } public override ValidationResult Test() { diff --git a/src/NzbDrone.Core/Notifications/MediaBrowser/MediaBrowserProxy.cs b/src/NzbDrone.Core/Notifications/MediaBrowser/MediaBrowserProxy.cs index b5e1da15a..9bfdf948f 100644 --- a/src/NzbDrone.Core/Notifications/MediaBrowser/MediaBrowserProxy.cs +++ b/src/NzbDrone.Core/Notifications/MediaBrowser/MediaBrowserProxy.cs @@ -1,6 +1,9 @@ -using NLog; +using System.Collections.Generic; +using System.Linq; +using NLog; using NzbDrone.Common.Http; using NzbDrone.Common.Serializer; +using NzbDrone.Core.Notifications.MediaBrowser.Model; namespace NzbDrone.Core.Notifications.Emby { @@ -20,22 +23,52 @@ namespace NzbDrone.Core.Notifications.Emby var path = "/Notifications/Admin"; var request = BuildRequest(path, settings); request.Headers.ContentType = "application/json"; + request.Method = HttpMethod.POST; request.SetContent(new - { - Name = title, - Description = message, - ImageUrl = "https://raw.github.com/NzbDrone/NzbDrone/develop/Logo/64.png" - }.ToJson()); + { + Name = title, + Description = message, + ImageUrl = "https://raw.github.com/lidarr/Lidarr/develop/Logo/64.png" + }.ToJson()); ProcessRequest(request, settings); } - public void Update(MediaBrowserSettings settings, int tvdbId) + public void Update(MediaBrowserSettings settings, List<string> musicCollectionPaths) { - var path = string.Format("/Library/Series/Updated?tvdbid={0}", tvdbId); - var request = BuildRequest(path, settings); - request.Headers.Add("Content-Length", "0"); + string path; + HttpRequest request; + + if (musicCollectionPaths.Any()) + { + path = "/Library/Media/Updated"; + request = BuildRequest(path, settings); + request.Headers.ContentType = "application/json"; + + var updateInfo = new List<EmbyMediaUpdateInfo>(); + + foreach (var colPath in musicCollectionPaths) + { + updateInfo.Add(new EmbyMediaUpdateInfo + { + Path = colPath, + UpdateType = "Created" + }); + } + + request.SetContent(new + { + Updates = updateInfo + }.ToJson()); + } + else + { + path = "/Library/Refresh"; + request = BuildRequest(path, settings); + } + + request.Method = HttpMethod.POST; ProcessRequest(request, settings); } @@ -44,7 +77,8 @@ namespace NzbDrone.Core.Notifications.Emby { request.Headers.Add("X-MediaBrowser-Token", settings.ApiKey); - var response = _httpClient.Post(request); + var response = _httpClient.Execute(request); + _logger.Trace("Response: {0}", response.Content); CheckForError(response); @@ -54,8 +88,9 @@ namespace NzbDrone.Core.Notifications.Emby private HttpRequest BuildRequest(string path, MediaBrowserSettings settings) { - var url = string.Format(@"http://{0}/mediabrowser", settings.Address); - + var scheme = settings.UseSsl ? "https" : "http"; + var url = $@"{scheme}://{settings.Address}/mediabrowser"; + return new HttpRequestBuilder(url).Resource(path).Build(); } @@ -65,5 +100,16 @@ namespace NzbDrone.Core.Notifications.Emby //TODO: actually check for the error } + + public List<EmbyMediaFolder> GetArtist(MediaBrowserSettings settings) + { + var path = "/Library/MediaFolders"; + var request = BuildRequest(path, settings); + request.Method = HttpMethod.GET; + + var response = ProcessRequest(request, settings); + + return Json.Deserialize<EmbyMediaFoldersResponse>(response).Items; + } } } diff --git a/src/NzbDrone.Core/Notifications/MediaBrowser/MediaBrowserService.cs b/src/NzbDrone.Core/Notifications/MediaBrowser/MediaBrowserService.cs index c1280c309..34cae900c 100644 --- a/src/NzbDrone.Core/Notifications/MediaBrowser/MediaBrowserService.cs +++ b/src/NzbDrone.Core/Notifications/MediaBrowser/MediaBrowserService.cs @@ -1,16 +1,17 @@ -using System; +using System; +using System.Linq; using System.Net; using FluentValidation.Results; using NLog; using NzbDrone.Core.Rest; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Notifications.Emby { public interface IMediaBrowserService { void Notify(MediaBrowserSettings settings, string title, string message); - void Update(MediaBrowserSettings settings, Series series); + void Update(MediaBrowserSettings settings, Artist artist); ValidationFailure Test(MediaBrowserSettings settings); } @@ -30,9 +31,13 @@ namespace NzbDrone.Core.Notifications.Emby _proxy.Notify(settings, title, message); } - public void Update(MediaBrowserSettings settings, Series series) + public void Update(MediaBrowserSettings settings, Artist artist) { - _proxy.Update(settings, series.TvdbId); + var folders = _proxy.GetArtist(settings); + + var musicPaths = folders.Select(e => e.CollectionType = "music").ToList(); + + _proxy.Update(settings, musicPaths); } public ValidationFailure Test(MediaBrowserSettings settings) @@ -41,7 +46,7 @@ namespace NzbDrone.Core.Notifications.Emby { _logger.Debug("Testing connection to MediaBrowser: {0}", settings.Address); - Notify(settings, "Test from Sonarr", "Success! MediaBrowser has been successfully configured!"); + Notify(settings, "Test from Lidarr", "Success! MediaBrowser has been successfully configured!"); } catch (RestException ex) { diff --git a/src/NzbDrone.Core/Notifications/MediaBrowser/MediaBrowserSettings.cs b/src/NzbDrone.Core/Notifications/MediaBrowser/MediaBrowserSettings.cs index cf2ffe8ab..5669c77b6 100644 --- a/src/NzbDrone.Core/Notifications/MediaBrowser/MediaBrowserSettings.cs +++ b/src/NzbDrone.Core/Notifications/MediaBrowser/MediaBrowserSettings.cs @@ -1,4 +1,4 @@ -using FluentValidation; +using FluentValidation; using Newtonsoft.Json; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; @@ -30,13 +30,16 @@ namespace NzbDrone.Core.Notifications.Emby [FieldDefinition(1, Label = "Port")] public int Port { get; set; } - [FieldDefinition(2, Label = "API Key")] + [FieldDefinition(2, Label = "Use SSL", Type = FieldType.Checkbox, HelpText = "Connect to Emby over HTTPS instead of HTTP")] + public bool UseSsl { get; set; } + + [FieldDefinition(3, Label = "API Key")] public string ApiKey { get; set; } - [FieldDefinition(3, Label = "Send Notifications", HelpText = "Have MediaBrowser send notfications to configured providers", Type = FieldType.Checkbox)] + [FieldDefinition(4, Label = "Send Notifications", HelpText = "Have MediaBrowser send notfications to configured providers", Type = FieldType.Checkbox)] public bool Notify { get; set; } - [FieldDefinition(4, Label = "Update Library", HelpText = "Update Library on Download & Rename?", Type = FieldType.Checkbox)] + [FieldDefinition(5, Label = "Update Library", HelpText = "Update Library on Download & Rename?", Type = FieldType.Checkbox)] public bool UpdateLibrary { get; set; } [JsonIgnore] diff --git a/src/NzbDrone.Core/Notifications/MediaBrowser/Model/EmbyMediaFolder.cs b/src/NzbDrone.Core/Notifications/MediaBrowser/Model/EmbyMediaFolder.cs new file mode 100644 index 000000000..e6a7c3559 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/MediaBrowser/Model/EmbyMediaFolder.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Notifications.MediaBrowser.Model +{ + public class EmbyMediaFolder + { + public string Path { get; set; } + public string CollectionType { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/MediaBrowser/Model/EmbyMediaFoldersResponse.cs b/src/NzbDrone.Core/Notifications/MediaBrowser/Model/EmbyMediaFoldersResponse.cs new file mode 100644 index 000000000..dd693001c --- /dev/null +++ b/src/NzbDrone.Core/Notifications/MediaBrowser/Model/EmbyMediaFoldersResponse.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Notifications.MediaBrowser.Model +{ + public class EmbyMediaFoldersResponse + { + public List<EmbyMediaFolder> Items { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/MediaBrowser/Model/EmbyMediaUpdateInfo.cs b/src/NzbDrone.Core/Notifications/MediaBrowser/Model/EmbyMediaUpdateInfo.cs new file mode 100644 index 000000000..38cd68694 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/MediaBrowser/Model/EmbyMediaUpdateInfo.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Notifications.MediaBrowser.Model +{ + public class EmbyMediaUpdateInfo + { + public string Path { get; set; } + public string UpdateType { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/NotificationBase.cs b/src/NzbDrone.Core/Notifications/NotificationBase.cs index a84cf55ff..f7b91b49d 100644 --- a/src/NzbDrone.Core/Notifications/NotificationBase.cs +++ b/src/NzbDrone.Core/Notifications/NotificationBase.cs @@ -1,18 +1,26 @@ -using System; +using System; using System.Collections.Generic; using FluentValidation.Results; using NzbDrone.Core.ThingiProvider; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Notifications { public abstract class NotificationBase<TSettings> : INotification where TSettings : IProviderConfig, new() { - protected const string EPISODE_GRABBED_TITLE = "Episode Grabbed"; - protected const string EPISODE_DOWNLOADED_TITLE = "Episode Downloaded"; - - protected const string EPISODE_GRABBED_TITLE_BRANDED = "Sonarr - " + EPISODE_GRABBED_TITLE; - protected const string EPISODE_DOWNLOADED_TITLE_BRANDED = "Sonarr - " + EPISODE_DOWNLOADED_TITLE; + protected const string ALBUM_GRABBED_TITLE = "Album Grabbed"; + protected const string ALBUM_DOWNLOADED_TITLE = "Album Downloaded"; + protected const string HEALTH_ISSUE_TITLE = "Health Check Failure"; + protected const string DOWNLOAD_FAILURE_TITLE = "Download Failed"; + protected const string IMPORT_FAILURE_TITLE = "Import Failed"; + protected const string TRACK_RETAGGED_TITLE = "Track File Tags Updated"; + + protected const string ALBUM_GRABBED_TITLE_BRANDED = "Lidarr - " + ALBUM_GRABBED_TITLE; + protected const string ALBUM_DOWNLOADED_TITLE_BRANDED = "Lidarr - " + ALBUM_DOWNLOADED_TITLE; + protected const string HEALTH_ISSUE_TITLE_BRANDED = "Lidarr - " + HEALTH_ISSUE_TITLE; + protected const string DOWNLOAD_FAILURE_TITLE_BRANDED = "Lidarr - " + DOWNLOAD_FAILURE_TITLE; + protected const string IMPORT_FAILURE_TITLE_BRANDED = "Lidarr - " + IMPORT_FAILURE_TITLE; + protected const string TRACK_RETAGGED_TITLE_BRANDED = "Lidarr - " + TRACK_RETAGGED_TITLE; public abstract string Name { get; } @@ -32,20 +40,44 @@ namespace NzbDrone.Core.Notifications } - public virtual void OnDownload(DownloadMessage message) + public virtual void OnReleaseImport(AlbumDownloadMessage message) + { + + } + + public virtual void OnRename(Artist artist) + { + + } + + public virtual void OnHealthIssue(HealthCheck.HealthCheck healthCheck) + { + + } + + public virtual void OnDownloadFailure(DownloadFailedMessage message) + { + + } + + public virtual void OnImportFailure(AlbumDownloadMessage message) { } - public virtual void OnRename(Series series) + public virtual void OnTrackRetag(TrackRetagMessage message) { } public bool SupportsOnGrab => HasConcreteImplementation("OnGrab"); public bool SupportsOnRename => HasConcreteImplementation("OnRename"); - public bool SupportsOnDownload => HasConcreteImplementation("OnDownload"); - public bool SupportsOnUpgrade => SupportsOnDownload; + public bool SupportsOnReleaseImport => HasConcreteImplementation("OnReleaseImport"); + public bool SupportsOnUpgrade => SupportsOnReleaseImport; + public bool SupportsOnHealthIssue => HasConcreteImplementation("OnHealthIssue"); + public bool SupportsOnDownloadFailure => HasConcreteImplementation("OnDownloadFailure"); + public bool SupportsOnImportFailure => HasConcreteImplementation("OnImportFailure"); + public bool SupportsOnTrackRetag => HasConcreteImplementation("OnTrackRetag"); protected TSettings Settings => (TSettings)Definition.Settings; diff --git a/src/NzbDrone.Core/Notifications/NotificationDefinition.cs b/src/NzbDrone.Core/Notifications/NotificationDefinition.cs index 5c2d10045..350ddda61 100644 --- a/src/NzbDrone.Core/Notifications/NotificationDefinition.cs +++ b/src/NzbDrone.Core/Notifications/NotificationDefinition.cs @@ -1,25 +1,28 @@ -using System.Collections.Generic; using NzbDrone.Core.ThingiProvider; namespace NzbDrone.Core.Notifications { public class NotificationDefinition : ProviderDefinition { - public NotificationDefinition() - { - Tags = new HashSet<int>(); - } public bool OnGrab { get; set; } - public bool OnDownload { get; set; } + public bool OnReleaseImport { get; set; } public bool OnUpgrade { get; set; } public bool OnRename { get; set; } + public bool OnHealthIssue { get; set; } + public bool OnDownloadFailure { get; set; } + public bool OnImportFailure { get; set; } + public bool OnTrackRetag { get; set; } public bool SupportsOnGrab { get; set; } - public bool SupportsOnDownload { get; set; } + public bool SupportsOnReleaseImport { get; set; } public bool SupportsOnUpgrade { get; set; } public bool SupportsOnRename { get; set; } - public HashSet<int> Tags { get; set; } + public bool SupportsOnHealthIssue { get; set; } + public bool IncludeHealthWarnings { get; set; } + public bool SupportsOnDownloadFailure { get; set; } + public bool SupportsOnImportFailure { get; set; } + public bool SupportsOnTrackRetag { get; set; } - public override bool Enable => OnGrab || OnDownload || (OnDownload && OnUpgrade); + public override bool Enable => OnGrab || OnReleaseImport || (OnReleaseImport && OnUpgrade) || OnHealthIssue || OnDownloadFailure || OnImportFailure || OnTrackRetag; } } diff --git a/src/NzbDrone.Core/Notifications/NotificationFactory.cs b/src/NzbDrone.Core/Notifications/NotificationFactory.cs index 235a6ba3c..3d4fbb74b 100644 --- a/src/NzbDrone.Core/Notifications/NotificationFactory.cs +++ b/src/NzbDrone.Core/Notifications/NotificationFactory.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using NLog; using NzbDrone.Common.Composition; @@ -10,9 +10,13 @@ namespace NzbDrone.Core.Notifications public interface INotificationFactory : IProviderFactory<INotification, NotificationDefinition> { List<INotification> OnGrabEnabled(); - List<INotification> OnDownloadEnabled(); + List<INotification> OnReleaseImportEnabled(); List<INotification> OnUpgradeEnabled(); List<INotification> OnRenameEnabled(); + List<INotification> OnHealthIssueEnabled(); + List<INotification> OnDownloadFailureEnabled(); + List<INotification> OnImportFailureEnabled(); + List<INotification> OnTrackRetagEnabled(); } public class NotificationFactory : ProviderFactory<INotification, NotificationDefinition>, INotificationFactory @@ -27,9 +31,9 @@ namespace NzbDrone.Core.Notifications return GetAvailableProviders().Where(n => ((NotificationDefinition)n.Definition).OnGrab).ToList(); } - public List<INotification> OnDownloadEnabled() + public List<INotification> OnReleaseImportEnabled() { - return GetAvailableProviders().Where(n => ((NotificationDefinition)n.Definition).OnDownload).ToList(); + return GetAvailableProviders().Where(n => ((NotificationDefinition)n.Definition).OnReleaseImport).ToList(); } public List<INotification> OnUpgradeEnabled() @@ -42,14 +46,38 @@ namespace NzbDrone.Core.Notifications return GetAvailableProviders().Where(n => ((NotificationDefinition)n.Definition).OnRename).ToList(); } + public List<INotification> OnHealthIssueEnabled() + { + return GetAvailableProviders().Where(n => ((NotificationDefinition)n.Definition).OnHealthIssue).ToList(); + } + + public List<INotification> OnDownloadFailureEnabled() + { + return GetAvailableProviders().Where(n => ((NotificationDefinition)n.Definition).OnDownloadFailure).ToList(); + } + + public List<INotification> OnImportFailureEnabled() + { + return GetAvailableProviders().Where(n => ((NotificationDefinition)n.Definition).OnImportFailure).ToList(); + } + + public List<INotification> OnTrackRetagEnabled() + { + return GetAvailableProviders().Where(n => ((NotificationDefinition)n.Definition).OnTrackRetag).ToList(); + } + public override void SetProviderCharacteristics(INotification provider, NotificationDefinition definition) { base.SetProviderCharacteristics(provider, definition); definition.SupportsOnGrab = provider.SupportsOnGrab; - definition.SupportsOnDownload = provider.SupportsOnDownload; + definition.SupportsOnReleaseImport = provider.SupportsOnReleaseImport; definition.SupportsOnUpgrade = provider.SupportsOnUpgrade; definition.SupportsOnRename = provider.SupportsOnRename; + definition.SupportsOnHealthIssue = provider.SupportsOnHealthIssue; + definition.SupportsOnDownloadFailure = provider.SupportsOnDownloadFailure; + definition.SupportsOnImportFailure = provider.SupportsOnImportFailure; + definition.SupportsOnTrackRetag = provider.SupportsOnTrackRetag; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Notifications/NotificationRepository.cs b/src/NzbDrone.Core/Notifications/NotificationRepository.cs index b012bd620..d163e1946 100644 --- a/src/NzbDrone.Core/Notifications/NotificationRepository.cs +++ b/src/NzbDrone.Core/Notifications/NotificationRepository.cs @@ -1,4 +1,4 @@ -using NzbDrone.Core.Datastore; +using NzbDrone.Core.Datastore; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.ThingiProvider; @@ -7,7 +7,6 @@ namespace NzbDrone.Core.Notifications { public interface INotificationRepository : IProviderRepository<NotificationDefinition> { - } public class NotificationRepository : ProviderRepository<NotificationDefinition>, INotificationRepository @@ -17,4 +16,4 @@ namespace NzbDrone.Core.Notifications { } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Notifications/NotificationService.cs b/src/NzbDrone.Core/Notifications/NotificationService.cs index 985126f19..98e57f5e3 100644 --- a/src/NzbDrone.Core/Notifications/NotificationService.cs +++ b/src/NzbDrone.Core/Notifications/NotificationService.cs @@ -1,21 +1,28 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Core.Download; +using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Qualities; using NzbDrone.Core.ThingiProvider; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; +using NzbDrone.Core.HealthCheck; +using System.IO; namespace NzbDrone.Core.Notifications { public class NotificationService - : IHandle<EpisodeGrabbedEvent>, - IHandle<EpisodeDownloadedEvent>, - IHandle<SeriesRenamedEvent> + : IHandle<AlbumGrabbedEvent>, + IHandle<AlbumImportedEvent>, + IHandle<ArtistRenamedEvent>, + IHandle<HealthCheckFailedEvent>, + IHandle<DownloadFailedEvent>, + IHandle<AlbumImportIncompleteEvent>, + IHandle<TrackFileRetaggedEvent> { private readonly INotificationFactory _notificationFactory; private readonly Logger _logger; @@ -26,83 +33,102 @@ namespace NzbDrone.Core.Notifications _logger = logger; } - private string GetMessage(Series series, List<Episode> episodes, QualityModel quality) + private string GetMessage(Artist artist, List<Album> albums, QualityModel quality) { var qualityString = quality.Quality.ToString(); if (quality.Revision.Version > 1) { - if (series.SeriesType == SeriesTypes.Anime) - { - qualityString += " v" + quality.Revision.Version; - } - - else - { - qualityString += " Proper"; - } + qualityString += " Proper"; } - if (series.SeriesType == SeriesTypes.Daily) - { - var episode = episodes.First(); - return string.Format("{0} - {1} - {2} [{3}]", - series.Title, - episode.AirDate, - episode.Title, - qualityString); - } + var albumTitles = string.Join(" + ", albums.Select(e => e.Title)); + + return string.Format("{0} - {1} - [{2}]", + artist.Name, + albumTitles, + qualityString); + } - var episodeNumbers = string.Concat(episodes.Select(e => e.EpisodeNumber) - .Select(i => string.Format("x{0:00}", i))); + private string GetAlbumDownloadMessage(Artist artist, Album album, List<TrackFile> tracks) + { + return string.Format("{0} - {1} ({2} Tracks Imported)", + artist.Name, + album.Title, + tracks.Count); + } - var episodeTitles = string.Join(" + ", episodes.Select(e => e.Title)); + private string GetAlbumIncompleteImportMessage(string source) + { + return string.Format("Lidarr failed to Import all tracks for {0}", + source); + } - return string.Format("{0} - {1}{2} - {3} [{4}]", - series.Title, - episodes.First().SeasonNumber, - episodeNumbers, - episodeTitles, - qualityString); + private string FormatMissing(object value) + { + var text = value?.ToString(); + return text.IsNullOrWhiteSpace() ? "<missing>" : text; } - private bool ShouldHandleSeries(ProviderDefinition definition, Series series) + private string GetTrackRetagMessage(Artist artist, TrackFile trackFile, Dictionary<string, Tuple<string, string>> diff) { - var notificationDefinition = (NotificationDefinition)definition; + return string.Format("{0}:\n{1}", + trackFile.Path, + string.Join("\n", diff.Select(x => $"{x.Key}: {FormatMissing(x.Value.Item1)} → {FormatMissing(x.Value.Item2)}"))); + } - if (notificationDefinition.Tags.Empty()) + private bool ShouldHandleArtist(ProviderDefinition definition, Artist artist) + { + if (definition.Tags.Empty()) { _logger.Debug("No tags set for this notification."); return true; } - if (notificationDefinition.Tags.Intersect(series.Tags).Any()) + if (definition.Tags.Intersect(artist.Tags).Any()) { - _logger.Debug("Notification and series have one or more matching tags."); + _logger.Debug("Notification and artist have one or more intersecting tags."); return true; } //TODO: this message could be more clear - _logger.Debug("{0} does not have any tags that match {1}'s tags", notificationDefinition.Name, series.Title); + _logger.Debug("{0} does not have any intersecting tags with {1}. Notification will not be sent.", definition.Name, artist.Name); + return false; + } + + private bool ShouldHandleHealthFailure(HealthCheck.HealthCheck healthCheck, bool includeWarnings) + { + if (healthCheck.Type == HealthCheckResult.Error) + { + return true; + } + + if (healthCheck.Type == HealthCheckResult.Warning && includeWarnings) + { + return true; + } + return false; } - public void Handle(EpisodeGrabbedEvent message) + public void Handle(AlbumGrabbedEvent message) { var grabMessage = new GrabMessage { - Message = GetMessage(message.Episode.Series, message.Episode.Episodes, message.Episode.ParsedEpisodeInfo.Quality), - Series = message.Episode.Series, - Quality = message.Episode.ParsedEpisodeInfo.Quality, - Episode = message.Episode + Message = GetMessage(message.Album.Artist, message.Album.Albums, message.Album.ParsedAlbumInfo.Quality), + Artist = message.Album.Artist, + Quality = message.Album.ParsedAlbumInfo.Quality, + Album = message.Album, + DownloadClient = message.DownloadClient, + DownloadId = message.DownloadId }; foreach (var notification in _notificationFactory.OnGrabEnabled()) { try { - if (!ShouldHandleSeries(notification.Definition, message.Episode.Series)) continue; + if (!ShouldHandleArtist(notification.Definition, message.Album.Artist)) continue; notification.OnGrab(grabMessage); } @@ -113,44 +139,55 @@ namespace NzbDrone.Core.Notifications } } - public void Handle(EpisodeDownloadedEvent message) + public void Handle(AlbumImportedEvent 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 AlbumDownloadMessage + + { + Message = GetAlbumDownloadMessage(message.Artist, message.Album, message.ImportedTracks), + Artist = message.Artist, + Album = message.Album, + Release = message.AlbumRelease, + DownloadClient = message.DownloadClient, + DownloadId = message.DownloadId, + TrackFiles = message.ImportedTracks, + OldFiles = message.OldFiles, + }; - foreach (var notification in _notificationFactory.OnDownloadEnabled()) + foreach (var notification in _notificationFactory.OnReleaseImportEnabled()) { try { - if (ShouldHandleSeries(notification.Definition, message.Episode.Series)) + if (ShouldHandleArtist(notification.Definition, message.Artist)) { if (downloadMessage.OldFiles.Empty() || ((NotificationDefinition)notification.Definition).OnUpgrade) { - notification.OnDownload(downloadMessage); + notification.OnReleaseImport(downloadMessage); } } } catch (Exception ex) { - _logger.Warn(ex, "Unable to send OnDownload notification to: " + notification.Definition.Name); + _logger.Warn(ex, "Unable to send OnReleaseImport notification to: " + notification.Definition.Name); } } } - public void Handle(SeriesRenamedEvent message) + public void Handle(ArtistRenamedEvent message) { foreach (var notification in _notificationFactory.OnRenameEnabled()) { try { - if (ShouldHandleSeries(notification.Definition, message.Series)) + if (ShouldHandleArtist(notification.Definition, message.Artist)) { - notification.OnRename(message.Series); + notification.OnRename(message.Artist); } } @@ -160,5 +197,83 @@ namespace NzbDrone.Core.Notifications } } } + + public void Handle(HealthCheckFailedEvent message) + { + foreach (var notification in _notificationFactory.OnHealthIssueEnabled()) + { + try + { + if (ShouldHandleHealthFailure(message.HealthCheck, ((NotificationDefinition)notification.Definition).IncludeHealthWarnings)) + { + notification.OnHealthIssue(message.HealthCheck); + } + } + + catch (Exception ex) + { + _logger.Warn(ex, "Unable to send OnHealthIssue notification to: " + notification.Definition.Name); + } + } + } + + public void Handle(DownloadFailedEvent message) + { + var downloadFailedMessage = new DownloadFailedMessage + { + DownloadId = message.DownloadId, + DownloadClient = message.DownloadClient, + Quality = message.Quality, + SourceTitle = message.SourceTitle, + Message = message.Message + }; + + foreach (var notification in _notificationFactory.OnDownloadFailureEnabled()) + { + if (ShouldHandleArtist(notification.Definition, message.TrackedDownload.RemoteAlbum.Artist)) + { + notification.OnDownloadFailure(downloadFailedMessage); + } + } + } + + public void Handle(AlbumImportIncompleteEvent message) + { + // TODO: Build out this message so that we can pass on what failed and what was successful + var downloadMessage = new AlbumDownloadMessage + { + Message = GetAlbumIncompleteImportMessage(message.TrackedDownload.DownloadItem.Title), + }; + + foreach (var notification in _notificationFactory.OnImportFailureEnabled()) + { + if (ShouldHandleArtist(notification.Definition, message.TrackedDownload.RemoteAlbum.Artist)) + { + notification.OnImportFailure(downloadMessage); + } + } + } + + public void Handle(TrackFileRetaggedEvent message) + { + var retagMessage = new TrackRetagMessage + { + Message = GetTrackRetagMessage(message.Artist, message.TrackFile, message.Diff), + Artist = message.Artist, + Album = message.TrackFile.Album, + Release = message.TrackFile.Tracks.Value.First().AlbumRelease.Value, + TrackFile = message.TrackFile, + Diff = message.Diff, + Scrubbed = message.Scrubbed + }; + + foreach (var notification in _notificationFactory.OnTrackRetagEnabled()) + { + if (ShouldHandleArtist(notification.Definition, message.Artist)) + { + notification.OnTrackRetag(retagMessage); + } + } + } } } diff --git a/src/NzbDrone.Core/Notifications/NotifyMyAndroid/NotifyMyAndroid.cs b/src/NzbDrone.Core/Notifications/NotifyMyAndroid/NotifyMyAndroid.cs deleted file mode 100644 index 693cb6537..000000000 --- a/src/NzbDrone.Core/Notifications/NotifyMyAndroid/NotifyMyAndroid.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Collections.Generic; -using FluentValidation.Results; -using NzbDrone.Common.Extensions; - -namespace NzbDrone.Core.Notifications.NotifyMyAndroid -{ - public class NotifyMyAndroid : NotificationBase<NotifyMyAndroidSettings> - { - private readonly INotifyMyAndroidProxy _proxy; - - public NotifyMyAndroid(INotifyMyAndroidProxy proxy) - { - _proxy = proxy; - } - - public override string Link => "https://www.notifymyandroid.com/"; - public override string Name => "Notify My Android"; - - public override void OnGrab(GrabMessage grabMessage) - { - _proxy.SendNotification(EPISODE_GRABBED_TITLE, grabMessage.Message, Settings.ApiKey, (NotifyMyAndroidPriority)Settings.Priority); - } - - public override void OnDownload(DownloadMessage message) - { - _proxy.SendNotification(EPISODE_DOWNLOADED_TITLE, message.Message, Settings.ApiKey, (NotifyMyAndroidPriority)Settings.Priority); - } - - public override ValidationResult Test() - { - var failures = new List<ValidationFailure>(); - - failures.AddIfNotNull(_proxy.Test(Settings)); - - return new ValidationResult(failures); - } - } -} diff --git a/src/NzbDrone.Core/Notifications/NotifyMyAndroid/NotifyMyAndroidPriority.cs b/src/NzbDrone.Core/Notifications/NotifyMyAndroid/NotifyMyAndroidPriority.cs deleted file mode 100644 index fd91e91d5..000000000 --- a/src/NzbDrone.Core/Notifications/NotifyMyAndroid/NotifyMyAndroidPriority.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace NzbDrone.Core.Notifications.NotifyMyAndroid -{ - public enum NotifyMyAndroidPriority - { - VeryLow = -2, - Moderate = -1, - Normal = 0, - High = 1, - Emergency = 2 - } -} diff --git a/src/NzbDrone.Core/Notifications/NotifyMyAndroid/NotifyMyAndroidProxy.cs b/src/NzbDrone.Core/Notifications/NotifyMyAndroid/NotifyMyAndroidProxy.cs deleted file mode 100644 index 070cf1591..000000000 --- a/src/NzbDrone.Core/Notifications/NotifyMyAndroid/NotifyMyAndroidProxy.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Linq; -using System.Net; -using System.Xml.Linq; -using FluentValidation.Results; -using NLog; -using NzbDrone.Core.Exceptions; -using RestSharp; -using NzbDrone.Core.Rest; - -namespace NzbDrone.Core.Notifications.NotifyMyAndroid -{ - public interface INotifyMyAndroidProxy - { - void SendNotification(string title, string message, string apiKye, NotifyMyAndroidPriority priority); - ValidationFailure Test(NotifyMyAndroidSettings settings); - } - - public class NotifyMyAndroidProxy : INotifyMyAndroidProxy - { - private readonly Logger _logger; - private const string URL = "https://www.notifymyandroid.com/publicapi"; - - public NotifyMyAndroidProxy(Logger logger) - { - _logger = logger; - } - - public void SendNotification(string title, string message, string apiKey, NotifyMyAndroidPriority priority) - { - var client = RestClientFactory.BuildClient(URL); - var request = new RestRequest("notify", Method.POST); - request.RequestFormat = DataFormat.Xml; - request.AddParameter("apikey", apiKey); - request.AddParameter("application", "Sonarr"); - request.AddParameter("event", title); - request.AddParameter("description", message); - request.AddParameter("priority", (int)priority); - - var response = client.ExecuteAndValidate(request); - ValidateResponse(response); - } - - private void Verify(string apiKey) - { - var client = RestClientFactory.BuildClient(URL); - var request = new RestRequest("verify", Method.GET); - request.RequestFormat = DataFormat.Xml; - request.AddParameter("apikey", apiKey, ParameterType.GetOrPost); - - var response = client.ExecuteAndValidate(request); - ValidateResponse(response); - } - - private void ValidateResponse(IRestResponse response) - { - var xDoc = XDocument.Parse(response.Content); - var nma = xDoc.Descendants("nma").Single(); - var error = nma.Descendants("error").SingleOrDefault(); - - if (error != null) - { - ((HttpStatusCode)Convert.ToInt32(error.Attribute("code").Value)).VerifyStatusCode(error.Value); - } - } - - public ValidationFailure Test(NotifyMyAndroidSettings settings) - { - try - { - const string title = "Test Notification"; - const string body = "This is a test message from Sonarr"; - Verify(settings.ApiKey); - SendNotification(title, body, settings.ApiKey, (NotifyMyAndroidPriority)settings.Priority); - } - catch (Exception ex) - { - _logger.Error(ex, "Unable to send test message"); - return new ValidationFailure("ApiKey", "Unable to send test message"); - } - - return null; - } - } -} diff --git a/src/NzbDrone.Core/Notifications/NotifyMyAndroid/NotifyMyAndroidSettings.cs b/src/NzbDrone.Core/Notifications/NotifyMyAndroid/NotifyMyAndroidSettings.cs deleted file mode 100644 index 7dd3a96bf..000000000 --- a/src/NzbDrone.Core/Notifications/NotifyMyAndroid/NotifyMyAndroidSettings.cs +++ /dev/null @@ -1,33 +0,0 @@ -using FluentValidation; -using NzbDrone.Core.Annotations; -using NzbDrone.Core.ThingiProvider; -using NzbDrone.Core.Validation; - -namespace NzbDrone.Core.Notifications.NotifyMyAndroid -{ - public class NotifyMyAndroidSettingsValidator : AbstractValidator<NotifyMyAndroidSettings> - { - public NotifyMyAndroidSettingsValidator() - { - RuleFor(c => c.ApiKey).NotEmpty(); - } - } - - public class NotifyMyAndroidSettings : IProviderConfig - { - private static readonly NotifyMyAndroidSettingsValidator Validator = new NotifyMyAndroidSettingsValidator(); - - [FieldDefinition(0, Label = "API Key", HelpLink = "http://www.notifymyandroid.com/")] - public string ApiKey { get; set; } - - [FieldDefinition(1, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(NotifyMyAndroidPriority))] - public int Priority { get; set; } - - public bool IsValid => !string.IsNullOrWhiteSpace(ApiKey) && Priority >= -1 && Priority <= 2; - - public NzbDroneValidationResult Validate() - { - return new NzbDroneValidationResult(Validator.Validate(this)); - } - } -} diff --git a/src/NzbDrone.Core/Notifications/Plex/HomeTheater/PlexClient.cs b/src/NzbDrone.Core/Notifications/Plex/HomeTheater/PlexClient.cs new file mode 100644 index 000000000..78dacf1fe --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/HomeTheater/PlexClient.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using FluentValidation.Results; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.Notifications.Plex.HomeTheater +{ + public class PlexClient : NotificationBase<PlexClientSettings> + { + private readonly IPlexClientService _plexClientService; + + public override string Link => "https://www.plex.tv/"; + public override string Name => "Plex Media Center"; + + public PlexClient(IPlexClientService plexClientService) + { + _plexClientService = plexClientService; + } + + public override void OnGrab(GrabMessage grabMessage) + { + _plexClientService.Notify(Settings, ALBUM_GRABBED_TITLE_BRANDED, grabMessage.Message); + } + + public override void OnReleaseImport(AlbumDownloadMessage message) + { + _plexClientService.Notify(Settings, ALBUM_DOWNLOADED_TITLE_BRANDED, message.Message); + } + + public override void OnHealthIssue(HealthCheck.HealthCheck message) + { + _plexClientService.Notify(Settings, HEALTH_ISSUE_TITLE_BRANDED, message.Message); + } + + public override ValidationResult Test() + { + var failures = new List<ValidationFailure>(); + + failures.AddIfNotNull(_plexClientService.Test(Settings)); + + return new ValidationResult(failures); + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexClientService.cs b/src/NzbDrone.Core/Notifications/Plex/HomeTheater/PlexClientService.cs similarity index 97% rename from src/NzbDrone.Core/Notifications/Plex/PlexClientService.cs rename to src/NzbDrone.Core/Notifications/Plex/HomeTheater/PlexClientService.cs index 476b9d27d..7844e3243 100644 --- a/src/NzbDrone.Core/Notifications/Plex/PlexClientService.cs +++ b/src/NzbDrone.Core/Notifications/Plex/HomeTheater/PlexClientService.cs @@ -1,9 +1,9 @@ -using System; +using System; using FluentValidation.Results; using NLog; using NzbDrone.Common.Http; -namespace NzbDrone.Core.Notifications.Plex +namespace NzbDrone.Core.Notifications.Plex.HomeTheater { public interface IPlexClientService { diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexClientSettings.cs b/src/NzbDrone.Core/Notifications/Plex/HomeTheater/PlexClientSettings.cs similarity index 88% rename from src/NzbDrone.Core/Notifications/Plex/PlexClientSettings.cs rename to src/NzbDrone.Core/Notifications/Plex/HomeTheater/PlexClientSettings.cs index d10993d79..3235592b0 100644 --- a/src/NzbDrone.Core/Notifications/Plex/PlexClientSettings.cs +++ b/src/NzbDrone.Core/Notifications/Plex/HomeTheater/PlexClientSettings.cs @@ -1,9 +1,9 @@ -using FluentValidation; +using FluentValidation; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; -namespace NzbDrone.Core.Notifications.Plex +namespace NzbDrone.Core.Notifications.Plex.HomeTheater { public class PlexClientSettingsValidator : AbstractValidator<PlexClientSettings> { @@ -32,7 +32,7 @@ namespace NzbDrone.Core.Notifications.Plex [FieldDefinition(2, Label = "Username")] public string Username { get; set; } - [FieldDefinition(3, Label = "Password")] + [FieldDefinition(3, Label = "Password", Type = FieldType.Password)] public string Password { get; set; } public bool IsValid => !string.IsNullOrWhiteSpace(Host); diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexHomeTheater.cs b/src/NzbDrone.Core/Notifications/Plex/HomeTheater/PlexHomeTheater.cs similarity index 77% rename from src/NzbDrone.Core/Notifications/Plex/PlexHomeTheater.cs rename to src/NzbDrone.Core/Notifications/Plex/HomeTheater/PlexHomeTheater.cs index c90473471..41720a502 100644 --- a/src/NzbDrone.Core/Notifications/Plex/PlexHomeTheater.cs +++ b/src/NzbDrone.Core/Notifications/Plex/HomeTheater/PlexHomeTheater.cs @@ -1,12 +1,11 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Net.Sockets; using FluentValidation.Results; using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Core.Notifications.Xbmc; -using NzbDrone.Core.Tv; -namespace NzbDrone.Core.Notifications.Plex +namespace NzbDrone.Core.Notifications.Plex.HomeTheater { public class PlexHomeTheater : NotificationBase<PlexHomeTheaterSettings> { @@ -24,12 +23,12 @@ namespace NzbDrone.Core.Notifications.Plex public override void OnGrab(GrabMessage grabMessage) { - Notify(Settings, EPISODE_GRABBED_TITLE_BRANDED, grabMessage.Message); + Notify(ALBUM_GRABBED_TITLE_BRANDED, grabMessage.Message); } - public override void OnDownload(DownloadMessage message) + public override void OnReleaseImport(AlbumDownloadMessage message) { - Notify(Settings, EPISODE_DOWNLOADED_TITLE_BRANDED, message.Message); + Notify(ALBUM_DOWNLOADED_TITLE_BRANDED, message.Message); } public override ValidationResult Test() @@ -41,7 +40,7 @@ namespace NzbDrone.Core.Notifications.Plex return new ValidationResult(failures); } - private void Notify(XbmcSettings settings, string header, string message) + private void Notify(string header, string message) { try { diff --git a/src/NzbDrone.Core/Notifications/Plex/HomeTheater/PlexHomeTheaterSettings.cs b/src/NzbDrone.Core/Notifications/Plex/HomeTheater/PlexHomeTheaterSettings.cs new file mode 100644 index 000000000..330699227 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/HomeTheater/PlexHomeTheaterSettings.cs @@ -0,0 +1,34 @@ +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Notifications.Xbmc; + +namespace NzbDrone.Core.Notifications.Plex.HomeTheater +{ + public class PlexHomeTheaterSettings : XbmcSettings + { + public PlexHomeTheaterSettings() + { + Port = 3005; + Notify = true; + } + + //These need to be kept in the same order as XBMC Settings, but we don't want them displayed + + [FieldDefinition(2, Label = "Username", Hidden = HiddenType.Hidden)] + public new string Username { get; set; } + + [FieldDefinition(3, Label = "Password", Hidden = HiddenType.Hidden)] + public new string Password { get; set; } + + [FieldDefinition(5, Label = "GUI Notification", Type = FieldType.Checkbox, Hidden = HiddenType.Hidden)] + public new bool Notify { get; set; } + + [FieldDefinition(6, Label = "Update Library", HelpText = "Update Library on Download & Rename?", Type = FieldType.Checkbox, Hidden = HiddenType.Hidden)] + public new bool UpdateLibrary { get; set; } + + [FieldDefinition(7, Label = "Clean Library", HelpText = "Clean Library after update?", Type = FieldType.Checkbox, Hidden = HiddenType.Hidden)] + public new bool CleanLibrary { get; set; } + + [FieldDefinition(8, Label = "Always Update", HelpText = "Update Library even when a video is playing?", Type = FieldType.Checkbox, Hidden = HiddenType.Hidden)] + public new bool AlwaysUpdate { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/Models/PlexIdentity.cs b/src/NzbDrone.Core/Notifications/Plex/Models/PlexIdentity.cs deleted file mode 100644 index 1d2b03c0f..000000000 --- a/src/NzbDrone.Core/Notifications/Plex/Models/PlexIdentity.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace NzbDrone.Core.Notifications.Plex.Models -{ - public class PlexIdentity - { - public string MachineIdentifier { get; set; } - public string Version { get; set; } - } -} diff --git a/src/NzbDrone.Core/Notifications/Plex/Models/PlexResponse.cs b/src/NzbDrone.Core/Notifications/Plex/Models/PlexResponse.cs deleted file mode 100644 index 7d2214f54..000000000 --- a/src/NzbDrone.Core/Notifications/Plex/Models/PlexResponse.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace NzbDrone.Core.Notifications.Plex.Models -{ - public class PlexResponse<T> - { - public T MediaContainer { get; set; } - } -} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexClient.cs b/src/NzbDrone.Core/Notifications/Plex/PlexClient.cs deleted file mode 100644 index 1294c6a40..000000000 --- a/src/NzbDrone.Core/Notifications/Plex/PlexClient.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Collections.Generic; -using FluentValidation.Results; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Notifications.Plex -{ - public class PlexClient : NotificationBase<PlexClientSettings> - { - private readonly IPlexClientService _plexClientService; - - public override string Link => "https://www.plex.tv/"; - public override string Name => "Plex Media Center"; - - public PlexClient(IPlexClientService plexClientService) - { - _plexClientService = plexClientService; - } - - public override void OnGrab(GrabMessage grabMessage) - { - _plexClientService.Notify(Settings, EPISODE_GRABBED_TITLE_BRANDED, grabMessage.Message); - } - - public override void OnDownload(DownloadMessage message) - { - _plexClientService.Notify(Settings, EPISODE_DOWNLOADED_TITLE_BRANDED, message.Message); - } - - - public override ValidationResult Test() - { - var failures = new List<ValidationFailure>(); - - failures.AddIfNotNull(_plexClientService.Test(Settings)); - - return new ValidationResult(failures); - } - } -} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexError.cs b/src/NzbDrone.Core/Notifications/Plex/PlexError.cs deleted file mode 100644 index 9bb7b33a8..000000000 --- a/src/NzbDrone.Core/Notifications/Plex/PlexError.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace NzbDrone.Core.Notifications.Plex -{ - public class PlexError - { - public string Error { get; set; } - } -} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexException.cs b/src/NzbDrone.Core/Notifications/Plex/PlexException.cs index 2123235cd..789e3fa51 100644 --- a/src/NzbDrone.Core/Notifications/Plex/PlexException.cs +++ b/src/NzbDrone.Core/Notifications/Plex/PlexException.cs @@ -1,4 +1,5 @@ -using NzbDrone.Common.Exceptions; +using System; +using NzbDrone.Common.Exceptions; namespace NzbDrone.Core.Notifications.Plex { @@ -11,5 +12,9 @@ namespace NzbDrone.Core.Notifications.Plex public PlexException(string message, params object[] args) : base(message, args) { } + + public PlexException(string message, Exception innerException) : base(message, innerException) + { + } } } diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexHomeTheaterSettings.cs b/src/NzbDrone.Core/Notifications/Plex/PlexHomeTheaterSettings.cs deleted file mode 100644 index dab60fa96..000000000 --- a/src/NzbDrone.Core/Notifications/Plex/PlexHomeTheaterSettings.cs +++ /dev/null @@ -1,34 +0,0 @@ -using NzbDrone.Core.Annotations; -using NzbDrone.Core.Notifications.Xbmc; - -namespace NzbDrone.Core.Notifications.Plex -{ - public class PlexHomeTheaterSettings : XbmcSettings - { - public PlexHomeTheaterSettings() - { - Port = 3005; - Notify = true; - } - - //These need to be kept in the same order as XBMC Settings, but we don't want them displayed - - [FieldDefinition(2, Label = "Username", Type = FieldType.Hidden)] - public new string Username { get; set; } - - [FieldDefinition(3, Label = "Password", Type = FieldType.Hidden)] - public new string Password { get; set; } - - [FieldDefinition(5, Label = "GUI Notification", Type = FieldType.Hidden)] - public new bool Notify { get; set; } - - [FieldDefinition(6, Label = "Update Library", HelpText = "Update Library on Download & Rename?", Type = FieldType.Hidden)] - public new bool UpdateLibrary { get; set; } - - [FieldDefinition(7, Label = "Clean Library", HelpText = "Clean Library after update?", Type = FieldType.Hidden)] - public new bool CleanLibrary { get; set; } - - [FieldDefinition(8, Label = "Always Update", HelpText = "Update Library even when a video is playing?", Type = FieldType.Hidden)] - public new bool AlwaysUpdate { get; set; } - } -} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexServer.cs b/src/NzbDrone.Core/Notifications/Plex/PlexServer.cs deleted file mode 100644 index b691ae282..000000000 --- a/src/NzbDrone.Core/Notifications/Plex/PlexServer.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Collections.Generic; -using FluentValidation.Results; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Notifications.Plex -{ - public class PlexServer : NotificationBase<PlexServerSettings> - { - private readonly IPlexServerService _plexServerService; - - public PlexServer(IPlexServerService plexServerService) - { - _plexServerService = plexServerService; - } - - public override string Link => "https://www.plex.tv/"; - public override string Name => "Plex Media Server"; - - public override void OnDownload(DownloadMessage message) - { - UpdateIfEnabled(message.Series); - } - - public override void OnRename(Series series) - { - UpdateIfEnabled(series); - } - - private void UpdateIfEnabled(Series series) - { - if (Settings.UpdateLibrary) - { - _plexServerService.UpdateLibrary(series, Settings); - } - } - - public override ValidationResult Test() - { - var failures = new List<ValidationFailure>(); - - failures.AddIfNotNull(_plexServerService.Test(Settings)); - - return new ValidationResult(failures); - } - } -} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexServerProxy.cs b/src/NzbDrone.Core/Notifications/Plex/PlexServerProxy.cs deleted file mode 100644 index 10b500b71..000000000 --- a/src/NzbDrone.Core/Notifications/Plex/PlexServerProxy.cs +++ /dev/null @@ -1,268 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Net; -using Newtonsoft.Json.Linq; -using NLog; -using NzbDrone.Common.Cache; -using NzbDrone.Common.EnvironmentInfo; -using NzbDrone.Common.Extensions; -using NzbDrone.Common.Serializer; -using NzbDrone.Core.Notifications.Plex.Models; -using NzbDrone.Core.Rest; -using RestSharp; -using RestSharp.Authenticators; - -namespace NzbDrone.Core.Notifications.Plex -{ - public interface IPlexServerProxy - { - List<PlexSection> GetTvSections(PlexServerSettings settings); - void Update(int sectionId, PlexServerSettings settings); - void UpdateSeries(int metadataId, PlexServerSettings settings); - string Version(PlexServerSettings settings); - List<PlexPreference> Preferences(PlexServerSettings settings); - int? GetMetadataId(int sectionId, int tvdbId, string language, PlexServerSettings settings); - } - - public class PlexServerProxy : IPlexServerProxy - { - private readonly ICached<string> _authCache; - private readonly Logger _logger; - - public PlexServerProxy(ICacheManager cacheManager, Logger logger) - { - _authCache = cacheManager.GetCache<string>(GetType(), "authCache"); - _logger = logger; - } - - public List<PlexSection> GetTvSections(PlexServerSettings settings) - { - var request = GetPlexServerRequest("library/sections", Method.GET, settings); - var client = GetPlexServerClient(settings); - var response = client.Execute(request); - - _logger.Trace("Sections response: {0}", response.Content); - CheckForError(response, settings); - - if (response.Content.Contains("_children")) - { - return Json.Deserialize<PlexMediaContainerLegacy>(response.Content) - .Sections - .Where(d => d.Type == "show") - .Select(s => new PlexSection - { - Id = s.Id, - Language = s.Language, - Locations = s.Locations, - Type = s.Type - }) - .ToList(); - } - - return Json.Deserialize<PlexResponse<PlexSectionsContainer>>(response.Content) - .MediaContainer - .Sections - .Where(d => d.Type == "show") - .ToList(); - } - - public void Update(int sectionId, PlexServerSettings settings) - { - var resource = string.Format("library/sections/{0}/refresh", sectionId); - var request = GetPlexServerRequest(resource, Method.GET, settings); - var client = GetPlexServerClient(settings); - var response = client.Execute(request); - - _logger.Trace("Update response: {0}", response.Content); - CheckForError(response, settings); - } - - public void UpdateSeries(int metadataId, PlexServerSettings settings) - { - var resource = string.Format("library/metadata/{0}/refresh", metadataId); - var request = GetPlexServerRequest(resource, Method.PUT, settings); - var client = GetPlexServerClient(settings); - var response = client.Execute(request); - - _logger.Trace("Update Series response: {0}", response.Content); - CheckForError(response, settings); - } - - public string Version(PlexServerSettings settings) - { - var request = GetPlexServerRequest("identity", Method.GET, settings); - var client = GetPlexServerClient(settings); - var response = client.Execute(request); - - _logger.Trace("Version response: {0}", response.Content); - CheckForError(response, settings); - - if (response.Content.Contains("_children")) - { - return Json.Deserialize<PlexIdentity>(response.Content) - .Version; - } - - return Json.Deserialize<PlexResponse<PlexIdentity>>(response.Content) - .MediaContainer - .Version; - } - - public List<PlexPreference> Preferences(PlexServerSettings settings) - { - var request = GetPlexServerRequest(":/prefs", Method.GET, settings); - var client = GetPlexServerClient(settings); - var response = client.Execute(request); - - _logger.Trace("Preferences response: {0}", response.Content); - CheckForError(response, settings); - - if (response.Content.Contains("_children")) - { - return Json.Deserialize<PlexPreferencesLegacy>(response.Content) - .Preferences; - } - - return Json.Deserialize<PlexResponse<PlexPreferences>>(response.Content) - .MediaContainer - .Preferences; - } - - public int? GetMetadataId(int sectionId, int tvdbId, string language, PlexServerSettings settings) - { - var guid = string.Format("com.plexapp.agents.thetvdb://{0}?lang={1}", tvdbId, language); - var resource = string.Format("library/sections/{0}/all?guid={1}", sectionId, System.Web.HttpUtility.UrlEncode(guid)); - var request = GetPlexServerRequest(resource, Method.GET, settings); - var client = GetPlexServerClient(settings); - var response = client.Execute(request); - - _logger.Trace("Sections response: {0}", response.Content); - CheckForError(response, settings); - - List<PlexSectionItem> items; - - if (response.Content.Contains("_children")) - { - items = Json.Deserialize<PlexSectionResponseLegacy>(response.Content) - .Items; - } - - else - { - items = Json.Deserialize<PlexResponse<PlexSectionResponse>>(response.Content) - .MediaContainer - .Items; - } - - if (items == null || items.Empty()) - { - return null; - } - - return items.First().Id; - } - - private string Authenticate(PlexServerSettings settings) - { - var request = GetPlexTvRequest("users/sign_in.json", Method.POST); - var client = GetPlexTvClient(settings.Username, settings.Password); - - var response = client.Execute(request); - - _logger.Debug("Authentication Response: {0}", response.Content); - CheckForError(response, settings); - - var user = Json.Deserialize<PlexUser>(JObject.Parse(response.Content).SelectToken("user").ToString()); - - return user.AuthenticationToken; - } - - private RestClient GetPlexTvClient(string username, string password) - { - var client = RestClientFactory.BuildClient("https://plex.tv"); - client.Authenticator = new HttpBasicAuthenticator(username, password); - - return client; - } - - private RestRequest GetPlexTvRequest(string resource, Method method) - { - var request = new RestRequest(resource, method); - request.AddHeader("X-Plex-Platform", "Windows"); - request.AddHeader("X-Plex-Platform-Version", "7"); - request.AddHeader("X-Plex-Provides", "player"); - request.AddHeader("X-Plex-Client-Identifier", "AB6CCCC7-5CF5-4523-826A-B969E0FFD8A0"); - request.AddHeader("X-Plex-Device-Name", "Sonarr"); - request.AddHeader("X-Plex-Product", "Sonarr"); - request.AddHeader("X-Plex-Version", BuildInfo.Version.ToString()); - - return request; - } - - private RestClient GetPlexServerClient(PlexServerSettings settings) - { - var protocol = settings.UseSsl ? "https" : "http"; - - return RestClientFactory.BuildClient(string.Format("{0}://{1}:{2}", protocol, settings.Host, settings.Port)); - } - - private RestRequest GetPlexServerRequest(string resource, Method method, PlexServerSettings settings) - { - var request = new RestRequest(resource, method); - request.AddHeader("Accept", "application/json"); - - if (settings.Username.IsNotNullOrWhiteSpace()) - { - request.AddParameter("X-Plex-Token", GetAuthenticationToken(settings), ParameterType.HttpHeader); - } - - return request; - } - - private string GetAuthenticationToken(PlexServerSettings settings) - { - var token = _authCache.Get(settings.Username + settings.Password, () => Authenticate(settings)); - - if (token.IsNullOrWhiteSpace()) - { - throw new PlexAuthenticationException("Invalid Token - Update your username and password"); - } - - return token; - } - - private void CheckForError(IRestResponse response, PlexServerSettings settings) - { - _logger.Trace("Checking for error"); - - if (response.StatusCode == HttpStatusCode.Unauthorized) - { - if (settings.Username.IsNullOrWhiteSpace()) - { - throw new PlexAuthenticationException("Unauthorized - Username and password required"); - } - - //Set the token to null in the cache so we don't keep trying with bad credentials - _authCache.Set(settings.Username + settings.Password, null); - throw new PlexAuthenticationException("Unauthorized - Username or password is incorrect"); - } - - if (response.Content.IsNullOrWhiteSpace()) - { - _logger.Trace("No response body returned, no error detected"); - return; - } - - var error = response.Content.Contains("_children") ? - Json.Deserialize<PlexError>(response.Content) : - Json.Deserialize<PlexResponse<PlexError>>(response.Content).MediaContainer; - - if (error != null && !error.Error.IsNullOrWhiteSpace()) - { - throw new PlexException(error.Error); - } - - _logger.Trace("No error detected"); - } - } -} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvPinResponse.cs b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvPinResponse.cs new file mode 100644 index 000000000..aa46edb48 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvPinResponse.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Notifications.Plex.PlexTv +{ + public class PlexTvPinResponse + { + public int Id { get; set; } + public string Code { get; set; } + public string AuthToken { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvPinUrlResponse.cs b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvPinUrlResponse.cs new file mode 100644 index 000000000..4dace5645 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvPinUrlResponse.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Notifications.Plex.PlexTv +{ + public class PlexTvPinUrlResponse + { + public string Url { get; set; } + public string Method => "POST"; + public Dictionary<string, string> Headers { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvProxy.cs b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvProxy.cs new file mode 100644 index 000000000..c4fafe006 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvProxy.cs @@ -0,0 +1,79 @@ +using System.Net; +using NLog; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Exceptions; + +namespace NzbDrone.Core.Notifications.Plex.PlexTv +{ + public interface IPlexTvProxy + { + string GetAuthToken(string clientIdentifier, int pinId); + } + + public class PlexTvProxy : IPlexTvProxy + { + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + + public PlexTvProxy(IHttpClient httpClient, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public string GetAuthToken(string clientIdentifier, int pinId) + { + var request = BuildRequest(clientIdentifier); + request.ResourceUrl = $"/api/v2/pins/{pinId}"; + + PlexTvPinResponse response; + + if (!Json.TryDeserialize<PlexTvPinResponse>(ProcessRequest(request), out response)) + { + response = new PlexTvPinResponse(); + } + + return response.AuthToken; + } + + private HttpRequestBuilder BuildRequest(string clientIdentifier) + { + var requestBuilder = new HttpRequestBuilder("https://plex.tv") + .Accept(HttpAccept.Json) + .AddQueryParam("X-Plex-Client-Identifier", clientIdentifier) + .AddQueryParam("X-Plex-Product", BuildInfo.AppName) + .AddQueryParam("X-Plex-Platform", "Windows") + .AddQueryParam("X-Plex-Platform-Version", "7") + .AddQueryParam("X-Plex-Device-Name", BuildInfo.AppName) + .AddQueryParam("X-Plex-Version", BuildInfo.Version.ToString()); + + return requestBuilder; + } + + private string ProcessRequest(HttpRequestBuilder requestBuilder) + { + var httpRequest = requestBuilder.Build(); + + HttpResponse response; + + _logger.Debug("Url: {0}", httpRequest.Url); + + try + { + response = _httpClient.Execute(httpRequest); + } + catch (HttpException ex) + { + throw new NzbDroneClientException(ex.Response.StatusCode, "Unable to connect to plex.tv"); + } + catch (WebException) + { + throw new NzbDroneClientException(HttpStatusCode.BadRequest, "Unable to connect to plex.tv"); + } + + return response.Content; + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs new file mode 100644 index 000000000..8866775c6 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs @@ -0,0 +1,84 @@ +using System.Linq; +using System.Text; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; + +namespace NzbDrone.Core.Notifications.Plex.PlexTv +{ + public interface IPlexTvService + { + PlexTvPinUrlResponse GetPinUrl(); + PlexTvSignInUrlResponse GetSignInUrl(string callbackUrl, int pinId, string pinCode); + string GetAuthToken(int pinId); + } + + public class PlexTvService : IPlexTvService + { + private readonly IPlexTvProxy _proxy; + private readonly IConfigService _configService; + + public PlexTvService(IPlexTvProxy proxy, IConfigService configService) + { + _proxy = proxy; + _configService = configService; + } + + public PlexTvPinUrlResponse GetPinUrl() + { + var clientIdentifier = _configService.PlexClientIdentifier; + + var requestBuilder = new HttpRequestBuilder("https://plex.tv/api/v2/pins") + .Accept(HttpAccept.Json) + .AddQueryParam("X-Plex-Client-Identifier", clientIdentifier) + .AddQueryParam("X-Plex-Product", BuildInfo.AppName) + .AddQueryParam("X-Plex-Platform", "Windows") + .AddQueryParam("X-Plex-Platform-Version", "7") + .AddQueryParam("X-Plex-Device-Name", BuildInfo.AppName) + .AddQueryParam("X-Plex-Version", BuildInfo.Version.ToString()) + .AddQueryParam("strong", true); + + requestBuilder.Method = HttpMethod.POST; + + var request = requestBuilder.Build(); + + return new PlexTvPinUrlResponse + { + Url = request.Url.ToString(), + Headers = request.Headers.ToDictionary(h => h.Key, h => h.Value) + }; + } + + public PlexTvSignInUrlResponse GetSignInUrl(string callbackUrl, int pinId, string pinCode) + { + var clientIdentifier = _configService.PlexClientIdentifier; + + var requestBuilder = new HttpRequestBuilder("https://app.plex.tv/auth/hashBang") + .AddQueryParam("clientID", clientIdentifier) + .AddQueryParam("forwardUrl", callbackUrl) + .AddQueryParam("code", pinCode) + .AddQueryParam("context[device][product]", BuildInfo.AppName) + .AddQueryParam("context[device][platform]", "Windows") + .AddQueryParam("context[device][platformVersion]", "7") + .AddQueryParam("context[device][version]", BuildInfo.Version.ToString()); + + // #! is stripped out of the URL when building, this works around it. + requestBuilder.Segments.Add("hashBang", "#!"); + + var request = requestBuilder.Build(); + + return new PlexTvSignInUrlResponse + { + OauthUrl = request.Url.ToString(), + PinId = pinId + }; + } + + public string GetAuthToken(int pinId) + { + var authToken = _proxy.GetAuthToken(_configService.PlexClientIdentifier, pinId); + + return authToken; + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvSignInUrlResponse.cs b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvSignInUrlResponse.cs new file mode 100644 index 000000000..33bd2a8ff --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvSignInUrlResponse.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Notifications.Plex.PlexTv +{ + public class PlexTvSignInUrlResponse + { + public string OauthUrl { get; set; } + public int PinId { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexUser.cs b/src/NzbDrone.Core/Notifications/Plex/PlexUser.cs deleted file mode 100644 index 105166227..000000000 --- a/src/NzbDrone.Core/Notifications/Plex/PlexUser.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Newtonsoft.Json; - -namespace NzbDrone.Core.Notifications.Plex -{ - public class PlexUser - { - [JsonProperty("authentication_token")] - public string AuthenticationToken { get; set; } - } -} diff --git a/src/NzbDrone.Core/Notifications/Plex/Server/PlexError.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexError.cs new file mode 100644 index 000000000..3018c080a --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexError.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.Notifications.Plex.Server +{ + public class PlexError + { + public string Error { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/Server/PlexIdentity.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexIdentity.cs new file mode 100644 index 000000000..9762421e8 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexIdentity.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Notifications.Plex.Server +{ + public class PlexIdentity + { + public string MachineIdentifier { get; set; } + public string Version { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/Models/PlexPreferences.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexPreferences.cs similarity index 84% rename from src/NzbDrone.Core/Notifications/Plex/Models/PlexPreferences.cs rename to src/NzbDrone.Core/Notifications/Plex/Server/PlexPreferences.cs index 1cea5ef58..dc1ebc3a1 100644 --- a/src/NzbDrone.Core/Notifications/Plex/Models/PlexPreferences.cs +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexPreferences.cs @@ -1,7 +1,7 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Newtonsoft.Json; -namespace NzbDrone.Core.Notifications.Plex.Models +namespace NzbDrone.Core.Notifications.Plex.Server { public class PlexPreferences { diff --git a/src/NzbDrone.Core/Notifications/Plex/Server/PlexResponse.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexResponse.cs new file mode 100644 index 000000000..af57abc50 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexResponse.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.Notifications.Plex.Server +{ + public class PlexResponse<T> + { + public T MediaContainer { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/Models/PlexSection.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexSection.cs similarity index 77% rename from src/NzbDrone.Core/Notifications/Plex/Models/PlexSection.cs rename to src/NzbDrone.Core/Notifications/Plex/Server/PlexSection.cs index 71aab1988..f366b246b 100644 --- a/src/NzbDrone.Core/Notifications/Plex/Models/PlexSection.cs +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexSection.cs @@ -1,7 +1,7 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Newtonsoft.Json; -namespace NzbDrone.Core.Notifications.Plex.Models +namespace NzbDrone.Core.Notifications.Plex.Server { public class PlexSectionLocation { @@ -11,6 +11,11 @@ namespace NzbDrone.Core.Notifications.Plex.Models public class PlexSection { + public PlexSection() + { + Locations = new List<PlexSectionLocation>(); + } + [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<PlexSection>(); + } + [JsonProperty("Directory")] public List<PlexSection> Sections { get; set; } } diff --git a/src/NzbDrone.Core/Notifications/Plex/Models/PlexSectionItem.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexSectionItem.cs similarity index 84% rename from src/NzbDrone.Core/Notifications/Plex/Models/PlexSectionItem.cs rename to src/NzbDrone.Core/Notifications/Plex/Server/PlexSectionItem.cs index 1531d677d..dfd381465 100644 --- a/src/NzbDrone.Core/Notifications/Plex/Models/PlexSectionItem.cs +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexSectionItem.cs @@ -1,7 +1,7 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Newtonsoft.Json; -namespace NzbDrone.Core.Notifications.Plex.Models +namespace NzbDrone.Core.Notifications.Plex.Server { public class PlexSectionItem { diff --git a/src/NzbDrone.Core/Notifications/Plex/Server/PlexServer.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServer.cs new file mode 100644 index 000000000..636f85997 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServer.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using FluentValidation.Results; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.Notifications.Plex.PlexTv; +using NzbDrone.Core.Music; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Notifications.Plex.Server +{ + public class PlexServer : NotificationBase<PlexServerSettings> + { + private readonly IPlexServerService _plexServerService; + private readonly IPlexTvService _plexTvService; + + public PlexServer(IPlexServerService plexServerService, IPlexTvService plexTvService) + { + _plexServerService = plexServerService; + _plexTvService = plexTvService; + } + + public override string Link => "https://www.plex.tv/"; + public override string Name => "Plex Media Server"; + + public override void OnReleaseImport(AlbumDownloadMessage message) + { + UpdateIfEnabled(message.Artist); + } + + public override void OnRename(Artist artist) + { + UpdateIfEnabled(artist); + } + + public override void OnTrackRetag(TrackRetagMessage message) + { + UpdateIfEnabled(message.Artist); + } + + private void UpdateIfEnabled(Artist artist) + { + if (Settings.UpdateLibrary) + { + _plexServerService.UpdateLibrary(artist, Settings); + } + } + + public override ValidationResult Test() + { + var failures = new List<ValidationFailure>(); + + failures.AddIfNotNull(_plexServerService.Test(Settings)); + + return new ValidationResult(failures); + } + + public override object RequestAction(string action, IDictionary<string, string> query) + { + if (action == "startOAuth") + { + Settings.Validate().Filter("ConsumerKey", "ConsumerSecret").ThrowOnError(); + + return _plexTvService.GetPinUrl(); + } + else if (action == "continueOAuth") + { + Settings.Validate().Filter("ConsumerKey", "ConsumerSecret").ThrowOnError(); + + if (query["callbackUrl"].IsNullOrWhiteSpace()) + { + throw new BadRequestException("QueryParam callbackUrl invalid."); + } + + if (query["id"].IsNullOrWhiteSpace()) + { + throw new BadRequestException("QueryParam id invalid."); + } + + if (query["code"].IsNullOrWhiteSpace()) + { + throw new BadRequestException("QueryParam code invalid."); + } + + return _plexTvService.GetSignInUrl(query["callbackUrl"], Convert.ToInt32(query["id"]), query["code"]); + } + else if (action == "getOAuthToken") + { + Settings.Validate().Filter("ConsumerKey", "ConsumerSecret").ThrowOnError(); + + if (query["pinId"].IsNullOrWhiteSpace()) + { + throw new BadRequestException("QueryParam pinId invalid."); + } + + var authToken = _plexTvService.GetAuthToken(Convert.ToInt32(query["pinId"])); + + return new + { + authToken + }; + } + + return new { }; + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerProxy.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerProxy.cs new file mode 100644 index 000000000..1fdd63df8 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerProxy.cs @@ -0,0 +1,222 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using NLog; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Configuration; + +namespace NzbDrone.Core.Notifications.Plex.Server +{ + public interface IPlexServerProxy + { + List<PlexSection> GetArtistSections(PlexServerSettings settings); + void Update(int sectionId, PlexServerSettings settings); + void UpdateArtist(int metadataId, PlexServerSettings settings); + string Version(PlexServerSettings settings); + List<PlexPreference> Preferences(PlexServerSettings settings); + int? GetMetadataId(int sectionId, string mbId, string language, PlexServerSettings settings); + } + + public class PlexServerProxy : IPlexServerProxy + { + private readonly IHttpClient _httpClient; + private readonly IConfigService _configService; + private readonly Logger _logger; + + public PlexServerProxy(IHttpClient httpClient, IConfigService configService, Logger logger) + { + _httpClient = httpClient; + _configService = configService; + _logger = logger; + } + + public List<PlexSection> GetArtistSections(PlexServerSettings settings) + { + var request = BuildRequest("library/sections", HttpMethod.GET, settings); + var response = ProcessRequest(request); + + CheckForError(response); + + if (response.Contains("_children")) + { + return Json.Deserialize<PlexMediaContainerLegacy>(response) + .Sections + .Where(d => d.Type == "artist") + .Select(s => new PlexSection + { + Id = s.Id, + Language = s.Language, + Locations = s.Locations, + Type = s.Type + }) + .ToList(); + } + + return Json.Deserialize<PlexResponse<PlexSectionsContainer>>(response) + .MediaContainer + .Sections + .Where(d => d.Type == "artist") + .ToList(); + } + + public void Update(int sectionId, PlexServerSettings settings) + { + var resource = $"library/sections/{sectionId}/refresh"; + var request = BuildRequest(resource, HttpMethod.GET, settings); + var response = ProcessRequest(request); + + CheckForError(response); + } + + public void UpdateArtist(int metadataId, PlexServerSettings settings) + { + var resource = $"library/metadata/{metadataId}/refresh"; + var request = BuildRequest(resource, HttpMethod.PUT, settings); + var response = ProcessRequest(request); + + CheckForError(response); + } + + public string Version(PlexServerSettings settings) + { + var request = BuildRequest("identity", HttpMethod.GET, settings); + var response = ProcessRequest(request); + + CheckForError(response); + + if (response.Contains("_children")) + { + return Json.Deserialize<PlexIdentity>(response) + .Version; + } + + return Json.Deserialize<PlexResponse<PlexIdentity>>(response) + .MediaContainer + .Version; + } + public List<PlexPreference> Preferences(PlexServerSettings settings) + { + var request = BuildRequest(":/prefs", HttpMethod.GET, settings); + var response = ProcessRequest(request); + + CheckForError(response); + + if (response.Contains("_children")) + { + return Json.Deserialize<PlexPreferencesLegacy>(response) + .Preferences; + } + + return Json.Deserialize<PlexResponse<PlexPreferences>>(response) + .MediaContainer + .Preferences; + } + + public int? GetMetadataId(int sectionId, string mbId, string language, PlexServerSettings settings) + { + var guid = string.Format("com.plexapp.agents.lastfm://{0}?lang={1}", mbId, language); // TODO Plex Route for MB? LastFM? + var resource = $"library/sections/{sectionId}/all?guid={System.Web.HttpUtility.UrlEncode(guid)}"; + var request = BuildRequest(resource, HttpMethod.GET, settings); + var response = ProcessRequest(request); + + CheckForError(response); + + List<PlexSectionItem> items; + + if (response.Contains("_children")) + { + items = Json.Deserialize<PlexSectionResponseLegacy>(response) + .Items; + } + else + { + items = Json.Deserialize<PlexResponse<PlexSectionResponse>>(response) + .MediaContainer + .Items; + } + + if (items == null || items.Empty()) + { + return null; + } + + return items.First().Id; + } + + private HttpRequestBuilder BuildRequest(string resource, HttpMethod method, PlexServerSettings settings) + { + var scheme = settings.UseSsl ? "https" : "http"; + var requestBuilder = new HttpRequestBuilder($"{scheme}://{settings.Host}:{settings.Port}") + .Accept(HttpAccept.Json) + .AddQueryParam("X-Plex-Client-Identifier", _configService.PlexClientIdentifier) + .AddQueryParam("X-Plex-Product", BuildInfo.AppName) + .AddQueryParam("X-Plex-Platform", "Windows") + .AddQueryParam("X-Plex-Platform-Version", "7") + .AddQueryParam("X-Plex-Device-Name", BuildInfo.AppName) + .AddQueryParam("X-Plex-Version", BuildInfo.Version.ToString()); + + if (settings.AuthToken.IsNotNullOrWhiteSpace()) + { + requestBuilder.AddQueryParam("X-Plex-Token", settings.AuthToken); + } + + requestBuilder.ResourceUrl = resource; + requestBuilder.Method = method; + + return requestBuilder; + } + + private string ProcessRequest(HttpRequestBuilder requestBuilder) + { + var httpRequest = requestBuilder.Build(); + + HttpResponse response; + + _logger.Debug("Url: {0}", httpRequest.Url); + + try + { + response = _httpClient.Execute(httpRequest); + } + catch (HttpException ex) + { + if (ex.Response.StatusCode == HttpStatusCode.Unauthorized) + { + throw new PlexAuthenticationException("Unauthorized - AuthToken is invalid"); + } + throw new PlexException("Unable to connect to Plex Media Server. Status Code: {0}", ex.Response.StatusCode); + } + catch (WebException ex) + { + throw new PlexException("Unable to connect to Plex Media Server", ex); + } + + return response.Content; + } + + private void CheckForError(string response) + { + _logger.Trace("Checking for error"); + + if (response.IsNullOrWhiteSpace()) + { + _logger.Trace("No response body returned, no error detected"); + return; + } + + var error = response.Contains("_children") ? + Json.Deserialize<PlexError>(response) : + Json.Deserialize<PlexResponse<PlexError>>(response).MediaContainer; + + if (error != null && !error.Error.IsNullOrWhiteSpace()) + { + throw new PlexException(error.Error); + } + + _logger.Trace("No error detected"); + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexServerService.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerService.cs similarity index 84% rename from src/NzbDrone.Core/Notifications/Plex/PlexServerService.cs rename to src/NzbDrone.Core/Notifications/Plex/Server/PlexServerService.cs index 891815529..e29191c46 100644 --- a/src/NzbDrone.Core/Notifications/Plex/PlexServerService.cs +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; @@ -6,14 +6,13 @@ using FluentValidation.Results; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Extensions; -using NzbDrone.Core.Notifications.Plex.Models; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; -namespace NzbDrone.Core.Notifications.Plex +namespace NzbDrone.Core.Notifications.Plex.Server { public interface IPlexServerService { - void UpdateLibrary(Series series, PlexServerSettings settings); + void UpdateLibrary(Artist artist, PlexServerSettings settings); ValidationFailure Test(PlexServerSettings settings); } @@ -32,7 +31,7 @@ namespace NzbDrone.Core.Notifications.Plex _logger = logger; } - public void UpdateLibrary(Series series, PlexServerSettings settings) + public void UpdateLibrary(Artist artist, PlexServerSettings settings) { try { @@ -46,7 +45,7 @@ namespace NzbDrone.Core.Notifications.Plex if (partialUpdates) { - UpdatePartialSection(series, sections, settings); + UpdatePartialSection(artist, sections, settings); } else @@ -66,7 +65,7 @@ namespace NzbDrone.Core.Notifications.Plex { _logger.Debug("Getting sections from Plex host: {0}", settings.Host); - return _plexServerProxy.GetTvSections(settings).ToList(); + return _plexServerProxy.GetArtistSections(settings).ToList(); } private bool PartialUpdatesAllowed(PlexServerSettings settings, Version version) @@ -98,7 +97,7 @@ namespace NzbDrone.Core.Notifications.Plex { if (version >= new Version(1, 3, 0) && version < new Version(1, 3, 1)) { - throw new PlexVersionException("Found version {0}, upgrade to PMS 1.3.1 to fix library updating and then restart Sonarr", version); + throw new PlexVersionException("Found version {0}, upgrade to PMS 1.3.1 to fix library updating and then restart Lidarr", version); } } @@ -108,14 +107,10 @@ namespace NzbDrone.Core.Notifications.Plex var rawVersion = _plexServerProxy.Version(settings); var version = new Version(Regex.Match(rawVersion, @"^(\d+[.-]){4}").Value.Trim('.', '-')); - - return version; } - - private List<PlexPreference> GetPreferences(PlexServerSettings settings) { _logger.Debug("Getting preferences from Plex host: {0}", settings.Host); @@ -130,18 +125,18 @@ namespace NzbDrone.Core.Notifications.Plex _plexServerProxy.Update(sectionId, settings); } - private void UpdatePartialSection(Series series, List<PlexSection> sections, PlexServerSettings settings) + private void UpdatePartialSection(Artist artist, List<PlexSection> sections, PlexServerSettings settings) { var partiallyUpdated = false; foreach (var section in sections) { - var metadataId = GetMetadataId(section.Id, series, section.Language, settings); + var metadataId = GetMetadataId(section.Id, artist, section.Language, settings); if (metadataId.HasValue) { - _logger.Debug("Updating Plex host: {0}, Section: {1}, Series: {2}", settings.Host, section.Id, series); - _plexServerProxy.UpdateSeries(metadataId.Value, settings); + _logger.Debug("Updating Plex host: {0}, Section: {1}, Artist: {2}", settings.Host, section.Id, artist); + _plexServerProxy.UpdateArtist(metadataId.Value, settings); partiallyUpdated = true; } @@ -150,16 +145,16 @@ namespace NzbDrone.Core.Notifications.Plex // Only update complete sections if all partial updates failed if (!partiallyUpdated) { - _logger.Debug("Unable to update partial section, updating all TV sections"); + _logger.Debug("Unable to update partial section, updating all Music sections"); sections.ForEach(s => UpdateSection(s.Id, settings)); } } - private int? GetMetadataId(int sectionId, Series series, string language, PlexServerSettings settings) + private int? GetMetadataId(int sectionId, Artist artist, string language, PlexServerSettings settings) { - _logger.Debug("Getting metadata from Plex host: {0} for series: {1}", settings.Host, series); + _logger.Debug("Getting metadata from Plex host: {0} for artist: {1}", settings.Host, artist); - return _plexServerProxy.GetMetadataId(sectionId, series.TvdbId, language, settings); + return _plexServerProxy.GetMetadataId(sectionId, artist.Metadata.Value.ForeignArtistId, language, settings); } public ValidationFailure Test(PlexServerSettings settings) @@ -170,13 +165,13 @@ namespace NzbDrone.Core.Notifications.Plex if (sections.Empty()) { - return new ValidationFailure("Host", "At least one TV library is required"); + return new ValidationFailure("Host", "At least one Music library is required"); } } catch(PlexAuthenticationException ex) { _logger.Error(ex, "Unable to connect to Plex Server"); - return new ValidationFailure("Username", "Incorrect username or password"); + return new ValidationFailure("AuthToken", "Invalid authentication token"); } catch (Exception ex) { diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexServerSettings.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerSettings.cs similarity index 78% rename from src/NzbDrone.Core/Notifications/Plex/PlexServerSettings.cs rename to src/NzbDrone.Core/Notifications/Plex/Server/PlexServerSettings.cs index 9a5d0587c..fd843ebba 100644 --- a/src/NzbDrone.Core/Notifications/Plex/PlexServerSettings.cs +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerSettings.cs @@ -1,9 +1,9 @@ -using FluentValidation; +using FluentValidation; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; -namespace NzbDrone.Core.Notifications.Plex +namespace NzbDrone.Core.Notifications.Plex.Server { public class PlexServerSettingsValidator : AbstractValidator<PlexServerSettings> { @@ -22,6 +22,7 @@ namespace NzbDrone.Core.Notifications.Plex { Port = 32400; UpdateLibrary = true; + SignIn = "startOAuth"; } [FieldDefinition(0, Label = "Host")] @@ -30,12 +31,11 @@ namespace NzbDrone.Core.Notifications.Plex [FieldDefinition(1, Label = "Port")] public int Port { get; set; } - //TODO: Change username and password to token and get a plex.tv OAuth token properly - [FieldDefinition(2, Label = "Username")] - public string Username { get; set; } + [FieldDefinition(2, Label = "Auth Token", Type = FieldType.Textbox, Advanced = true)] + public string AuthToken { get; set; } - [FieldDefinition(3, Label = "Password", Type = FieldType.Password)] - public string Password { get; set; } + [FieldDefinition(3, Label = "Authenticate with Plex.tv", Type = FieldType.OAuth)] + public string SignIn { get; set; } [FieldDefinition(4, Label = "Update Library", Type = FieldType.Checkbox)] public bool UpdateLibrary { get; set; } diff --git a/src/NzbDrone.Core/Notifications/Prowl/Prowl.cs b/src/NzbDrone.Core/Notifications/Prowl/Prowl.cs index 2b6352073..505f4e099 100644 --- a/src/NzbDrone.Core/Notifications/Prowl/Prowl.cs +++ b/src/NzbDrone.Core/Notifications/Prowl/Prowl.cs @@ -1,7 +1,6 @@ -using System.Collections.Generic; +using System.Collections.Generic; using FluentValidation.Results; using NzbDrone.Common.Extensions; -using NzbDrone.Core.Tv; using Prowlin; namespace NzbDrone.Core.Notifications.Prowl @@ -20,12 +19,17 @@ namespace NzbDrone.Core.Notifications.Prowl public override void OnGrab(GrabMessage grabMessage) { - _prowlService.SendNotification(EPISODE_GRABBED_TITLE, grabMessage.Message, Settings.ApiKey, (NotificationPriority)Settings.Priority); + _prowlService.SendNotification(ALBUM_GRABBED_TITLE, grabMessage.Message, Settings.ApiKey, (NotificationPriority)Settings.Priority); } - public override void OnDownload(DownloadMessage message) + public override void OnReleaseImport(AlbumDownloadMessage message) { - _prowlService.SendNotification(EPISODE_DOWNLOADED_TITLE, message.Message, Settings.ApiKey, (NotificationPriority)Settings.Priority); + _prowlService.SendNotification(ALBUM_DOWNLOADED_TITLE, message.Message, Settings.ApiKey, (NotificationPriority)Settings.Priority); + } + + public override void OnHealthIssue(HealthCheck.HealthCheck message) + { + _prowlService.SendNotification(HEALTH_ISSUE_TITLE, message.Message, Settings.ApiKey, (NotificationPriority)Settings.Priority); } public override ValidationResult Test() diff --git a/src/NzbDrone.Core/Notifications/Prowl/ProwlService.cs b/src/NzbDrone.Core/Notifications/Prowl/ProwlService.cs index 7b70a8b6e..a49663686 100644 --- a/src/NzbDrone.Core/Notifications/Prowl/ProwlService.cs +++ b/src/NzbDrone.Core/Notifications/Prowl/ProwlService.cs @@ -1,6 +1,7 @@ using System; using FluentValidation.Results; using NLog; +using NzbDrone.Common.EnvironmentInfo; using Prowlin; namespace NzbDrone.Core.Notifications.Prowl @@ -26,7 +27,7 @@ namespace NzbDrone.Core.Notifications.Prowl { var notification = new Prowlin.Notification { - Application = "Sonarr", + Application = BuildInfo.AppName, Description = message, Event = title, Priority = priority, @@ -88,7 +89,7 @@ namespace NzbDrone.Core.Notifications.Prowl Verify(settings.ApiKey); const string title = "Test Notification"; - const string body = "This is a test message from Sonarr"; + string body = $"This is a test message from {BuildInfo.AppName}"; SendNotification(title, body, settings.ApiKey); } diff --git a/src/NzbDrone.Core/Notifications/PushBullet/PushBullet.cs b/src/NzbDrone.Core/Notifications/PushBullet/PushBullet.cs index 066cd6f57..bede24023 100644 --- a/src/NzbDrone.Core/Notifications/PushBullet/PushBullet.cs +++ b/src/NzbDrone.Core/Notifications/PushBullet/PushBullet.cs @@ -1,6 +1,9 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Linq; using FluentValidation.Results; using NzbDrone.Common.Extensions; +using NzbDrone.Core.Validation; namespace NzbDrone.Core.Notifications.PushBullet { @@ -16,15 +19,29 @@ namespace NzbDrone.Core.Notifications.PushBullet public override string Name => "Pushbullet"; public override string Link => "https://www.pushbullet.com/"; - public override void OnGrab(GrabMessage grabMessage) { - _proxy.SendNotification(EPISODE_GRABBED_TITLE_BRANDED, grabMessage.Message, Settings); + _proxy.SendNotification(ALBUM_GRABBED_TITLE_BRANDED, grabMessage.Message, Settings); + } + + public override void OnReleaseImport(AlbumDownloadMessage message) + { + _proxy.SendNotification(ALBUM_DOWNLOADED_TITLE_BRANDED, message.Message, Settings); + } + + public override void OnHealthIssue(HealthCheck.HealthCheck healthCheck) + { + _proxy.SendNotification(HEALTH_ISSUE_TITLE_BRANDED, healthCheck.Message, Settings); + } + + public override void OnDownloadFailure(DownloadFailedMessage message) + { + _proxy.SendNotification(DOWNLOAD_FAILURE_TITLE_BRANDED, message.Message, Settings); } - public override void OnDownload(DownloadMessage message) + public override void OnImportFailure(AlbumDownloadMessage message) { - _proxy.SendNotification(EPISODE_DOWNLOADED_TITLE_BRANDED, message.Message, Settings); + _proxy.SendNotification(IMPORT_FAILURE_TITLE_BRANDED, message.Message, Settings); } public override ValidationResult Test() @@ -35,5 +52,36 @@ namespace NzbDrone.Core.Notifications.PushBullet return new ValidationResult(failures); } + + public override object RequestAction(string action, IDictionary<string, string> query) + { + if (action == "getDevices") + { + // Return early if there is not an API key + if (Settings.ApiKey.IsNullOrWhiteSpace()) + { + return new + { + devices = new List<object>() + }; + } + + Settings.Validate().Filter("ApiKey").ThrowOnError(); + var devices = _proxy.GetDevices(Settings); + + return new + { + options = devices.Where(d => d.Nickname.IsNotNullOrWhiteSpace()) + .OrderBy(d => d.Nickname, StringComparer.InvariantCultureIgnoreCase) + .Select(d => new + { + id = d.Id, + name = d.Nickname + }) + }; + } + + return new { }; + } } } diff --git a/src/NzbDrone.Core/Notifications/PushBullet/PushBulletDevice.cs b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletDevice.cs new file mode 100644 index 000000000..5d88e906e --- /dev/null +++ b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletDevice.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace NzbDrone.Core.Notifications.PushBullet +{ + public class PushBulletDevice + { + [JsonProperty(PropertyName = "Iden")] + public string Id { get; set; } + + public string Nickname { get; set; } + public string Manufacturer { get; set; } + public string Model { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/PushBullet/PushBulletDevicesResponse.cs b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletDevicesResponse.cs new file mode 100644 index 000000000..ae50f82b0 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletDevicesResponse.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Notifications.PushBullet +{ + public class PushBulletDevicesResponse + { + public List<PushBulletDevice> Devices { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/PushBullet/PushBulletProxy.cs b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletProxy.cs index 497d8e7f0..8bc8a04a9 100644 --- a/src/NzbDrone.Core/Notifications/PushBullet/PushBulletProxy.cs +++ b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletProxy.cs @@ -1,4 +1,5 @@ -using System; +using System; +using System.Collections.Generic; using System.Linq; using System.Net; using FluentValidation.Results; @@ -6,6 +7,7 @@ using NLog; using RestSharp; using NzbDrone.Core.Rest; using NzbDrone.Common.Extensions; +using NzbDrone.Common.Serializer; using RestSharp.Authenticators; namespace NzbDrone.Core.Notifications.PushBullet @@ -13,13 +15,15 @@ namespace NzbDrone.Core.Notifications.PushBullet public interface IPushBulletProxy { void SendNotification(string title, string message, PushBulletSettings settings); + List<PushBulletDevice> GetDevices(PushBulletSettings settings); ValidationFailure Test(PushBulletSettings settings); } public class PushBulletProxy : IPushBulletProxy { private readonly Logger _logger; - private const string URL = "https://api.pushbullet.com/v2/pushes"; + private const string PUSH_URL = "https://api.pushbullet.com/v2/pushes"; + private const string DEVICE_URL = "https://api.pushbullet.com/v2/devices"; public PushBulletProxy(Logger logger) { @@ -88,12 +92,36 @@ namespace NzbDrone.Core.Notifications.PushBullet } } + public List<PushBulletDevice> GetDevices(PushBulletSettings settings) + { + try + { + var client = RestClientFactory.BuildClient(DEVICE_URL); + var request = new RestRequest(Method.GET); + + client.Authenticator = new HttpBasicAuthenticator(settings.ApiKey, string.Empty); + var response = client.ExecuteAndValidate(request); + + return Json.Deserialize<PushBulletDevicesResponse>(response.Content).Devices; + } + catch (RestException ex) + { + if (ex.Response.StatusCode == HttpStatusCode.Unauthorized) + { + _logger.Error(ex, "Access token is invalid"); + throw; + } + } + + return new List<PushBulletDevice>(); + } + public ValidationFailure Test(PushBulletSettings settings) { try { - const string title = "Sonarr - Test Notification"; - const string body = "This is a test message from Sonarr"; + const string title = "Lidarr - Test Notification"; + const string body = "This is a test message from Lidarr"; SendNotification(title, body, settings); } @@ -147,7 +175,7 @@ namespace NzbDrone.Core.Notifications.PushBullet { try { - var client = RestClientFactory.BuildClient(URL); + var client = RestClientFactory.BuildClient(PUSH_URL); request.AddParameter("type", "note"); request.AddParameter("title", title); @@ -165,7 +193,7 @@ namespace NzbDrone.Core.Notifications.PushBullet { if (ex.Response.StatusCode == HttpStatusCode.Unauthorized) { - _logger.Error(ex, "API Key is invalid"); + _logger.Error(ex, "Access token is invalid"); throw; } diff --git a/src/NzbDrone.Core/Notifications/PushBullet/PushBulletSettings.cs b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletSettings.cs index d2c6d1e4c..4e8ee404f 100644 --- a/src/NzbDrone.Core/Notifications/PushBullet/PushBulletSettings.cs +++ b/src/NzbDrone.Core/Notifications/PushBullet/PushBulletSettings.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using FluentValidation; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; @@ -20,14 +20,14 @@ namespace NzbDrone.Core.Notifications.PushBullet public PushBulletSettings() { - DeviceIds = new string[]{}; - ChannelTags = new string[]{}; + DeviceIds = new string[] { }; + ChannelTags = new string[] { }; } - [FieldDefinition(0, Label = "API Key", HelpLink = "https://www.pushbullet.com/")] + [FieldDefinition(0, Label = "Access Token", HelpLink = "https://www.pushbullet.com/#settings/account")] public string ApiKey { get; set; } - [FieldDefinition(1, Label = "Device IDs", HelpText = "List of device IDs, use device_iden in the device's URL on pushbullet.com (leave blank to send to all devices)", Type = FieldType.Tag)] + [FieldDefinition(1, Label = "Device IDs", HelpText = "List of device IDs (leave blank to send to all devices)", Type = FieldType.Device)] public IEnumerable<string> DeviceIds { get; set; } [FieldDefinition(2, Label = "Channel Tags", HelpText = "List of Channel Tags to send notifications to", Type = FieldType.Tag)] diff --git a/src/NzbDrone.Core/Notifications/Pushalot/Pushalot.cs b/src/NzbDrone.Core/Notifications/Pushalot/Pushalot.cs deleted file mode 100644 index c505034b8..000000000 --- a/src/NzbDrone.Core/Notifications/Pushalot/Pushalot.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Collections.Generic; -using FluentValidation.Results; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Notifications.Pushalot -{ - public class Pushalot : NotificationBase<PushalotSettings> - { - private readonly IPushalotProxy _proxy; - - public Pushalot(IPushalotProxy proxy) - { - _proxy = proxy; - } - - public override string Name => "Pushalot"; - public override string Link => "https://pushalot.com/"; - - public override void OnGrab(GrabMessage grabMessage) - { - _proxy.SendNotification(EPISODE_GRABBED_TITLE, grabMessage.Message, Settings); - } - - public override void OnDownload(DownloadMessage message) - { - _proxy.SendNotification(EPISODE_DOWNLOADED_TITLE, message.Message, Settings); - } - - - public override ValidationResult Test() - { - var failures = new List<ValidationFailure>(); - - failures.AddIfNotNull(_proxy.Test(Settings)); - - return new ValidationResult(failures); - } - } -} diff --git a/src/NzbDrone.Core/Notifications/Pushalot/PushalotPriority.cs b/src/NzbDrone.Core/Notifications/Pushalot/PushalotPriority.cs deleted file mode 100644 index 58effba2f..000000000 --- a/src/NzbDrone.Core/Notifications/Pushalot/PushalotPriority.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace NzbDrone.Core.Notifications.Pushalot -{ - public enum PushalotPriority - { - Silent = -1, - Normal = 0, - Important = 1 - } -} diff --git a/src/NzbDrone.Core/Notifications/Pushalot/PushalotProxy.cs b/src/NzbDrone.Core/Notifications/Pushalot/PushalotProxy.cs deleted file mode 100644 index 574441e65..000000000 --- a/src/NzbDrone.Core/Notifications/Pushalot/PushalotProxy.cs +++ /dev/null @@ -1,106 +0,0 @@ -using System; -using System.Net; -using FluentValidation.Results; -using NLog; -using NzbDrone.Common.Serializer; -using NzbDrone.Core.Rest; -using RestSharp; - -namespace NzbDrone.Core.Notifications.Pushalot -{ - public interface IPushalotProxy - { - void SendNotification(string title, string message, PushalotSettings settings); - ValidationFailure Test(PushalotSettings settings); - } - - public class PushalotProxy : IPushalotProxy - { - private readonly Logger _logger; - private const string URL = "https://pushalot.com/api/sendmessage"; - - public PushalotProxy(Logger logger) - { - _logger = logger; - } - - public void SendNotification(string title, string message, PushalotSettings settings) - { - var client = RestClientFactory.BuildClient(URL); - var request = BuildRequest(); - - request.AddParameter("Source", "Sonarr"); - - if (settings.Image) - { - request.AddParameter("Image", "https://raw.githubusercontent.com/Sonarr/Sonarr/develop/Logo/128.png"); - } - - request.AddParameter("Title", title); - request.AddParameter("Body", message); - request.AddParameter("AuthorizationToken", settings.AuthToken); - - if ((PushalotPriority)settings.Priority == PushalotPriority.Important) - { - request.AddParameter("IsImportant", true); - } - - if ((PushalotPriority)settings.Priority == PushalotPriority.Silent) - { - request.AddParameter("IsSilent", true); - } - - client.ExecuteAndValidate(request); - } - - public RestRequest BuildRequest() - { - var request = new RestRequest(Method.POST); - - return request; - } - - public ValidationFailure Test(PushalotSettings settings) - { - try - { - const string title = "Test Notification"; - const string body = "This is a test message from Sonarr"; - - SendNotification(title, body, settings); - } - catch (RestException ex) - { - if (ex.Response.StatusCode == HttpStatusCode.Unauthorized) - { - _logger.Error(ex, "Authentication Token is invalid"); - return new ValidationFailure("AuthToken", "Authentication Token is invalid"); - } - - if (ex.Response.StatusCode == HttpStatusCode.NotAcceptable) - { - _logger.Error(ex, "Message limit reached"); - return new ValidationFailure("AuthToken", "Message limit reached"); - } - - if (ex.Response.StatusCode == HttpStatusCode.Gone) - { - _logger.Error(ex, "Authorization Token is no longer valid"); - return new ValidationFailure("AuthToken", "Authorization Token is no longer valid, please use a new one."); - } - - var response = Json.Deserialize<PushalotResponse>(ex.Response.Content); - - _logger.Error(ex, "Unable to send test message"); - return new ValidationFailure("AuthToken", response.Description); - } - catch (Exception ex) - { - _logger.Error(ex, "Unable to send test message"); - return new ValidationFailure("", "Unable to send test message"); - } - - return null; - } - } -} diff --git a/src/NzbDrone.Core/Notifications/Pushalot/PushalotResponse.cs b/src/NzbDrone.Core/Notifications/Pushalot/PushalotResponse.cs deleted file mode 100644 index 860be65c6..000000000 --- a/src/NzbDrone.Core/Notifications/Pushalot/PushalotResponse.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace NzbDrone.Core.Notifications.Pushalot -{ - public class PushalotResponse - { - public bool Success { get; set; } - public int Status { get; set; } - public string Description { get; set; } - } -} diff --git a/src/NzbDrone.Core/Notifications/Pushalot/PushalotSettings.cs b/src/NzbDrone.Core/Notifications/Pushalot/PushalotSettings.cs deleted file mode 100644 index a0fbd08e1..000000000 --- a/src/NzbDrone.Core/Notifications/Pushalot/PushalotSettings.cs +++ /dev/null @@ -1,41 +0,0 @@ -using FluentValidation; -using NzbDrone.Core.Annotations; -using NzbDrone.Core.ThingiProvider; -using NzbDrone.Core.Validation; - -namespace NzbDrone.Core.Notifications.Pushalot -{ - public class PushalotSettingsValidator : AbstractValidator<PushalotSettings> - { - public PushalotSettingsValidator() - { - RuleFor(c => c.AuthToken).NotEmpty(); - } - } - - public class PushalotSettings : IProviderConfig - { - public PushalotSettings() - { - Image = true; - } - - private static readonly PushalotSettingsValidator Validator = new PushalotSettingsValidator(); - - [FieldDefinition(0, Label = "Authorization Token", HelpLink = "https://pushalot.com/manager/authorizations")] - public string AuthToken { get; set; } - - [FieldDefinition(1, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(PushalotPriority))] - public int Priority { get; set; } - - [FieldDefinition(2, Label = "Image", Type = FieldType.Checkbox, HelpText = "Include Sonarr logo with notifications")] - public bool Image { get; set; } - - public bool IsValid => !string.IsNullOrWhiteSpace(AuthToken); - - public NzbDroneValidationResult Validate() - { - return new NzbDroneValidationResult(Validator.Validate(this)); - } - } -} diff --git a/src/NzbDrone.Core/Notifications/Pushover/Pushover.cs b/src/NzbDrone.Core/Notifications/Pushover/Pushover.cs index 0b7349d71..f9d7e662e 100644 --- a/src/NzbDrone.Core/Notifications/Pushover/Pushover.cs +++ b/src/NzbDrone.Core/Notifications/Pushover/Pushover.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using FluentValidation.Results; using NzbDrone.Common.Extensions; @@ -18,12 +18,27 @@ namespace NzbDrone.Core.Notifications.Pushover public override void OnGrab(GrabMessage grabMessage) { - _proxy.SendNotification(EPISODE_GRABBED_TITLE, grabMessage.Message, Settings); + _proxy.SendNotification(ALBUM_GRABBED_TITLE, grabMessage.Message, Settings); } - public override void OnDownload(DownloadMessage message) + public override void OnReleaseImport(AlbumDownloadMessage message) { - _proxy.SendNotification(EPISODE_DOWNLOADED_TITLE, message.Message, Settings); + _proxy.SendNotification(ALBUM_DOWNLOADED_TITLE, message.Message, Settings); + } + + public override void OnHealthIssue(HealthCheck.HealthCheck healthCheck) + { + _proxy.SendNotification(HEALTH_ISSUE_TITLE, healthCheck.Message, Settings); + } + + public override void OnDownloadFailure(DownloadFailedMessage message) + { + _proxy.SendNotification(DOWNLOAD_FAILURE_TITLE, message.Message, Settings); + } + + public override void OnImportFailure(AlbumDownloadMessage message) + { + _proxy.SendNotification(IMPORT_FAILURE_TITLE, message.Message, Settings); } public override ValidationResult Test() diff --git a/src/NzbDrone.Core/Notifications/Pushover/PushoverProxy.cs b/src/NzbDrone.Core/Notifications/Pushover/PushoverProxy.cs new file mode 100644 index 000000000..2dd303c10 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Pushover/PushoverProxy.cs @@ -0,0 +1,70 @@ +using System; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Extensions; +using RestSharp; +using NzbDrone.Core.Rest; + +namespace NzbDrone.Core.Notifications.Pushover +{ + public interface IPushoverProxy + { + void SendNotification(string title, string message, PushoverSettings settings); + ValidationFailure Test(PushoverSettings settings); + } + + public class PushoverProxy : IPushoverProxy + { + private readonly Logger _logger; + private const string URL = "https://api.pushover.net/1/messages.json"; + + public PushoverProxy(Logger logger) + { + _logger = logger; + } + + public void SendNotification(string title, string message, PushoverSettings settings) + { + var client = RestClientFactory.BuildClient(URL); + var request = new RestRequest(Method.POST); + request.AddParameter("token", settings.ApiKey); + request.AddParameter("user", settings.UserKey); + request.AddParameter("device", string.Join(",", settings.Devices)); + request.AddParameter("title", title); + request.AddParameter("message", message); + request.AddParameter("priority", settings.Priority); + + if ((PushoverPriority)settings.Priority == PushoverPriority.Emergency) + { + request.AddParameter("retry", settings.Retry); + request.AddParameter("expire", settings.Expire); + } + + if (!settings.Sound.IsNullOrWhiteSpace()) + { + request.AddParameter("sound", settings.Sound); + } + + + client.ExecuteAndValidate(request); + } + + public ValidationFailure Test(PushoverSettings settings) + { + try + { + const string title = "Test Notification"; + const string body = "This is a test message from Lidarr"; + + SendNotification(title, body, settings); + } + catch (Exception ex) + { + _logger.Error(ex, "Unable to send test message"); + return new ValidationFailure("ApiKey", "Unable to send test message"); + } + + return null; + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Pushover/PushoverService.cs b/src/NzbDrone.Core/Notifications/Pushover/PushoverService.cs deleted file mode 100644 index 63684f012..000000000 --- a/src/NzbDrone.Core/Notifications/Pushover/PushoverService.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System; -using FluentValidation.Results; -using NLog; -using NzbDrone.Common.Extensions; -using RestSharp; -using NzbDrone.Core.Rest; - -namespace NzbDrone.Core.Notifications.Pushover -{ - public interface IPushoverProxy - { - void SendNotification(string title, string message, PushoverSettings settings); - ValidationFailure Test(PushoverSettings settings); - } - - public class PushoverProxy : IPushoverProxy - { - private readonly Logger _logger; - private const string URL = "https://api.pushover.net/1/messages.json"; - - public PushoverProxy(Logger logger) - { - _logger = logger; - } - - public void SendNotification(string title, string message, PushoverSettings settings) - { - var client = RestClientFactory.BuildClient(URL); - var request = new RestRequest(Method.POST); - request.AddParameter("token", settings.ApiKey); - request.AddParameter("user", settings.UserKey); - request.AddParameter("title", title); - request.AddParameter("message", message); - request.AddParameter("priority", settings.Priority); - - if ((PushoverPriority)settings.Priority == PushoverPriority.Emergency) - { - request.AddParameter("retry", settings.Retry); - request.AddParameter("expire", settings.Expire); - } - - if (!settings.Sound.IsNullOrWhiteSpace()) - { - request.AddParameter("sound", settings.Sound); - } - - - client.ExecuteAndValidate(request); - } - - public ValidationFailure Test(PushoverSettings settings) - { - try - { - const string title = "Test Notification"; - const string body = "This is a test message from Sonarr"; - - SendNotification(title, body, settings); - } - catch (Exception ex) - { - _logger.Error(ex, "Unable to send test message"); - return new ValidationFailure("ApiKey", "Unable to send test message"); - } - - return null; - } - } -} diff --git a/src/NzbDrone.Core/Notifications/Pushover/PushoverSettings.cs b/src/NzbDrone.Core/Notifications/Pushover/PushoverSettings.cs index c03ea7cc6..29ac22032 100644 --- a/src/NzbDrone.Core/Notifications/Pushover/PushoverSettings.cs +++ b/src/NzbDrone.Core/Notifications/Pushover/PushoverSettings.cs @@ -1,7 +1,8 @@ -using FluentValidation; +using FluentValidation; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; +using System.Collections.Generic; namespace NzbDrone.Core.Notifications.Pushover { @@ -22,25 +23,29 @@ namespace NzbDrone.Core.Notifications.Pushover public PushoverSettings() { Priority = 0; + Devices = new string[] { }; } //TODO: Get Pushover to change our app name (or create a new app) when we have a new logo - [FieldDefinition(0, Label = "API Key", HelpLink = "https://pushover.net/apps/clone/nzbdrone")] + [FieldDefinition(0, Label = "API Key", HelpLink = "https://pushover.net/apps/clone/lidarr")] public string ApiKey { get; set; } [FieldDefinition(1, Label = "User Key", HelpLink = "https://pushover.net/")] public string UserKey { get; set; } - [FieldDefinition(2, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(PushoverPriority) )] + [FieldDefinition(2, Label = "Devices", HelpText = "List of device names (leave blank to send to all devices)", Type = FieldType.Tag)] + public IEnumerable<string> Devices { get; set; } + + [FieldDefinition(3, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(PushoverPriority))] public int Priority { get; set; } - [FieldDefinition(3, Label = "Retry", Type = FieldType.Textbox, HelpText = "Interval to retry Emergency alerts, minimum 30 seconds")] + [FieldDefinition(4, Label = "Retry", Type = FieldType.Textbox, HelpText = "Interval to retry Emergency alerts, minimum 30 seconds")] public int Retry { get; set; } - [FieldDefinition(4, Label = "Expire", Type = FieldType.Textbox, HelpText = "Maximum time to retry Emergency alerts, maximum 86400 seconds")] + [FieldDefinition(5, Label = "Expire", Type = FieldType.Textbox, HelpText = "Maximum time to retry Emergency alerts, maximum 86400 seconds")] public int Expire { get; set; } - [FieldDefinition(5, Label = "Sound", Type = FieldType.Textbox, HelpText = "Notification sound, leave blank to use the default", HelpLink = "https://pushover.net/api#sounds")] + [FieldDefinition(6, Label = "Sound", Type = FieldType.Textbox, HelpText = "Notification sound, leave blank to use the default", HelpLink = "https://pushover.net/api#sounds")] public string Sound { get; set; } public bool IsValid => !string.IsNullOrWhiteSpace(UserKey) && Priority >= -1 && Priority <= 2; 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<Attachment> Attachments { get; set; } } } diff --git a/src/NzbDrone.Core/Notifications/Slack/Slack.cs b/src/NzbDrone.Core/Notifications/Slack/Slack.cs index 8748e5b73..d77313e36 100644 --- a/src/NzbDrone.Core/Notifications/Slack/Slack.cs +++ b/src/NzbDrone.Core/Notifications/Slack/Slack.cs @@ -1,24 +1,22 @@ -using System; +using System; using System.Collections.Generic; using FluentValidation.Results; -using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Core.Notifications.Slack.Payloads; using NzbDrone.Core.Rest; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; using NzbDrone.Core.Validation; -using RestSharp; namespace NzbDrone.Core.Notifications.Slack { public class Slack : NotificationBase<SlackSettings> { - private readonly Logger _logger; + private readonly ISlackProxy _proxy; - public Slack(Logger logger) + public Slack(ISlackProxy proxy) { - _logger = logger; + _proxy = proxy; } public override string Name => "Slack"; @@ -26,65 +24,117 @@ namespace NzbDrone.Core.Notifications.Slack public override void OnGrab(GrabMessage message) { - var payload = new SlackPayload + var attachments = new List<Attachment> + { + new Attachment + { + Fallback = message.Message, + Title = message.Artist.Name, + Text = message.Message, + Color = "warning" + } + }; + var payload = CreatePayload($"Grabbed: {message.Message}", attachments); + + _proxy.SendPayload(payload, Settings); + } + + public override void OnReleaseImport(AlbumDownloadMessage message) + { + var attachments = new List<Attachment> { - IconEmoji = Settings.Icon, - Username = Settings.Username, - Text = $"Grabbed: {message.Message}", - Attachments = new List<Attachment> + new Attachment { - new Attachment - { - Fallback = message.Message, - Title = message.Series.Title, - Text = message.Message, - Color = "warning" - } + Fallback = message.Message, + Title = message.Artist.Name, + Text = message.Message, + Color = "good" } }; + var payload = CreatePayload($"Imported: {message.Message}", attachments); - NotifySlack(payload); + _proxy.SendPayload(payload, Settings); } - public override void OnDownload(DownloadMessage message) + public override void OnRename(Artist artist) { - var payload = new SlackPayload + var attachments = new List<Attachment> + { + new Attachment + { + Title = artist.Name, + } + }; + + var payload = CreatePayload("Renamed", attachments); + + _proxy.SendPayload(payload, Settings); + } + + public override void OnHealthIssue(HealthCheck.HealthCheck healthCheck) + { + var attachments = new List<Attachment> + { + new Attachment + { + Title = healthCheck.Source.Name, + Text = healthCheck.Message, + Color = healthCheck.Type == HealthCheck.HealthCheckResult.Warning ? "warning" : "danger" + } + }; + + var payload = CreatePayload("Health Issue", attachments); + + _proxy.SendPayload(payload, Settings); + } + + public override void OnTrackRetag(TrackRetagMessage message) + { + var attachments = new List<Attachment> + { + new Attachment + { + Title = TRACK_RETAGGED_TITLE, + Text = message.Message + } + }; + + var payload = CreatePayload(TRACK_RETAGGED_TITLE, attachments); + + _proxy.SendPayload(payload, Settings); + } + + public override void OnDownloadFailure(DownloadFailedMessage message) + { + var attachments = new List<Attachment> { - IconEmoji = Settings.Icon, - Username = Settings.Username, - Text = $"Imported: {message.Message}", - Attachments = new List<Attachment> + new Attachment { - new Attachment - { - Fallback = message.Message, - Title = message.Series.Title, - Text = message.Message, - Color = "good" - } + Fallback = message.Message, + Title = message.SourceTitle, + Text = message.Message, + Color = "danger" } }; + var payload = CreatePayload($"Download Failed: {message.Message}", attachments); - NotifySlack(payload); + _proxy.SendPayload(payload, Settings); } - public override void OnRename(Series series) + public override void OnImportFailure(AlbumDownloadMessage message) { - var payload = new SlackPayload + var attachments = new List<Attachment> { - IconEmoji = Settings.Icon, - Username = Settings.Username, - Text = "Renamed", - Attachments = new List<Attachment> + new Attachment { - new Attachment - { - Title = series.Title, - } + Fallback = message.Message, + Text = message.Message, + Color = "warning" } }; + var payload = CreatePayload($"Import Failed: {message.Message}", attachments); - NotifySlack(payload); + _proxy.SendPayload(payload, Settings); } public override ValidationResult Test() @@ -100,15 +150,10 @@ 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 message = $"Test message from Lidarr posted at {DateTime.Now}"; + var payload = CreatePayload(message); - NotifySlack(payload); + _proxy.SendPayload(payload, Settings); } catch (SlackExeption ex) @@ -119,24 +164,37 @@ namespace NzbDrone.Core.Notifications.Slack return null; } - private void NotifySlack(SlackPayload payload) + private SlackPayload CreatePayload(string message, List<Attachment> attachments = null) { - try + var icon = Settings.Icon; + var channel = Settings.Channel; + + var payload = new SlackPayload + { + Username = Settings.Username, + Text = message, + Attachments = attachments + }; + + if (icon.IsNotNullOrWhiteSpace()) { - var client = RestClientFactory.BuildClient(Settings.WebHookUrl); - var request = new RestRequest(Method.POST) + // Set the correct icon based on the value + if (icon.StartsWith(":") && icon.EndsWith(":")) + { + payload.IconEmoji = icon; + } + else { - RequestFormat = DataFormat.Json, - JsonSerializer = new JsonNetSerializer() - }; - request.AddBody(payload); - client.ExecuteAndValidate(request); + 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..f510464d3 --- /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/Subsonic/Subsonic.cs b/src/NzbDrone.Core/Notifications/Subsonic/Subsonic.cs new file mode 100644 index 000000000..f65864e20 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Subsonic/Subsonic.cs @@ -0,0 +1,97 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net.Sockets; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Notifications.Subsonic +{ + public class Subsonic : NotificationBase<SubsonicSettings> + { + private readonly ISubsonicService _subsonicService; + private readonly Logger _logger; + + public Subsonic(ISubsonicService subsonicService, Logger logger) + { + _subsonicService = subsonicService; + _logger = logger; + } + + public override string Link => "http://subsonic.org/"; + + public override void OnGrab(GrabMessage grabMessage) + { + const string header = "Lidarr - Grabbed"; + + Notify(Settings, header, grabMessage.Message); + } + + public override void OnReleaseImport(AlbumDownloadMessage message) + { + const string header = "Lidarr - Downloaded"; + + Notify(Settings, header, message.Message); + Update(); + } + + public override void OnRename(Artist artist) + { + Update(); + } + + public override void OnHealthIssue(HealthCheck.HealthCheck healthCheck) + { + Notify(Settings, HEALTH_ISSUE_TITLE_BRANDED, healthCheck.Message); + } + + public override void OnTrackRetag(TrackRetagMessage message) + { + Notify(Settings, TRACK_RETAGGED_TITLE_BRANDED, message.Message); + } + + public override string Name => "Subsonic"; + + public override ValidationResult Test() + { + var failures = new List<ValidationFailure>(); + + failures.AddIfNotNull(_subsonicService.Test(Settings, "Success! Subsonic has been successfully configured!")); + + return new ValidationResult(failures); + } + + private void Notify(SubsonicSettings settings, string header, string message) + { + try + { + if (Settings.Notify) + { + _subsonicService.Notify(Settings, $"{header} - {message}"); + } + } + catch (SocketException ex) + { + var logMessage = $"Unable to connect to Subsonic Host: {Settings.Host}:{Settings.Port}"; + _logger.Debug(ex, logMessage); + } + } + + private void Update() + { + try + { + if (Settings.UpdateLibrary) + { + _subsonicService.Update(Settings); + } + } + catch (SocketException ex) + { + var logMessage = $"Unable to connect to Subsonic Host: {Settings.Host}:{Settings.Port}"; + _logger.Debug(ex, logMessage); + } + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Subsonic/SubsonicAuthenticationException.cs b/src/NzbDrone.Core/Notifications/Subsonic/SubsonicAuthenticationException.cs new file mode 100644 index 000000000..8b72513e6 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Subsonic/SubsonicAuthenticationException.cs @@ -0,0 +1,14 @@ +namespace NzbDrone.Core.Notifications.Subsonic +{ + public class SubsonicAuthenticationException : SubsonicException + { + public SubsonicAuthenticationException(string message) : base(message) + { + } + + public SubsonicAuthenticationException(string message, params object[] args) + : base(message, args) + { + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Subsonic/SubsonicException.cs b/src/NzbDrone.Core/Notifications/Subsonic/SubsonicException.cs new file mode 100644 index 000000000..8f94df21a --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Subsonic/SubsonicException.cs @@ -0,0 +1,15 @@ +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.Notifications.Subsonic +{ + public class SubsonicException : NzbDroneException + { + public SubsonicException(string message) : base(message) + { + } + + public SubsonicException(string message, params object[] args) : base(message, args) + { + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Subsonic/SubsonicServerProxy.cs b/src/NzbDrone.Core/Notifications/Subsonic/SubsonicServerProxy.cs new file mode 100644 index 000000000..741701fd1 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Subsonic/SubsonicServerProxy.cs @@ -0,0 +1,141 @@ +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Rest; +using RestSharp; +using System.IO; +using System.Xml.Linq; + +namespace NzbDrone.Core.Notifications.Subsonic +{ + public interface ISubsonicServerProxy + { + string GetBaseUrl(SubsonicSettings settings, string relativePath = null); + void Notify(SubsonicSettings settings, string message); + void Update(SubsonicSettings settings); + string Version(SubsonicSettings settings); + } + + public class SubsonicServerProxy : ISubsonicServerProxy + { + private readonly Logger _logger; + + public SubsonicServerProxy(Logger logger) + { + _logger = logger; + } + + public string GetBaseUrl(SubsonicSettings settings, string relativePath = null) + { + var baseUrl = HttpRequestBuilder.BuildBaseUrl(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase); + baseUrl = HttpUri.CombinePath(baseUrl, relativePath); + + return baseUrl; + } + + public void Notify(SubsonicSettings settings, string message) + { + var resource = "addChatMessage"; + var request = GetSubsonicServerRequest(resource, Method.GET, settings); + request.AddParameter("message", message); + var client = GetSubsonicServerClient(settings); + var response = client.Execute(request); + + _logger.Trace("Update response: {0}", response.Content); + CheckForError(response, settings); + } + + public void Update(SubsonicSettings settings) + { + var resource = "startScan"; + var request = GetSubsonicServerRequest(resource, Method.GET, settings); + var client = GetSubsonicServerClient(settings); + var response = client.Execute(request); + + _logger.Trace("Update response: {0}", response.Content); + CheckForError(response, settings); + } + + public string Version(SubsonicSettings settings) + { + var request = GetSubsonicServerRequest("ping", Method.GET, settings); + var client = GetSubsonicServerClient(settings); + var response = client.Execute(request); + + _logger.Trace("Version response: {0}", response.Content); + CheckForError(response, settings); + + var xDoc = XDocument.Load(new StringReader(response.Content.Replace("&", "&"))); + var version = xDoc.Root?.Attribute("version")?.Value; + + if (version == null) + { + throw new SubsonicException("Could not read version from Subsonic"); + } + + return version; + } + + private RestClient GetSubsonicServerClient(SubsonicSettings settings) + { + return RestClientFactory.BuildClient(GetBaseUrl(settings, "rest")); + } + + private RestRequest GetSubsonicServerRequest(string resource, Method method, SubsonicSettings settings) + { + var request = new RestRequest(resource, method); + + if (settings.Username.IsNotNullOrWhiteSpace()) + { + request.AddParameter("u", settings.Username); + request.AddParameter("p", settings.Password); + request.AddParameter("c", "Lidarr"); + request.AddParameter("v", "1.15.0"); + } + + return request; + } + + private void CheckForError(IRestResponse response, SubsonicSettings settings) + { + _logger.Trace("Checking for error"); + + var xDoc = XDocument.Load(new StringReader(response.Content.Replace("&", "&"))); + var status = xDoc.Root?.Attribute("status")?.Value; + + if (status == null) + { + throw new SubsonicException("Invalid Response, Check Server Settings"); + } + + if (status == "failed") + { + var ns = xDoc.Root.GetDefaultNamespace(); + var error = xDoc.Root.Element(XName.Get("error", ns.ToString())); + var errorMessage = error?.Attribute("message")?.Value; + var errorCode = error?.Attribute("code")?.Value; + + if (errorCode == null) + { + throw new SubsonicException("Subsonic returned error, check settings"); + } + + if (errorCode == "40") + { + throw new SubsonicAuthenticationException(errorMessage); + } + + throw new SubsonicException(errorMessage); + + } + + if (response.Content.IsNullOrWhiteSpace()) + { + _logger.Trace("No response body returned, no error detected"); + return; + } + + _logger.Trace("No error detected"); + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Subsonic/SubsonicService.cs b/src/NzbDrone.Core/Notifications/Subsonic/SubsonicService.cs new file mode 100644 index 000000000..523b3c77d --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Subsonic/SubsonicService.cs @@ -0,0 +1,68 @@ +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Core.Music; +using System; + +namespace NzbDrone.Core.Notifications.Subsonic +{ + public interface ISubsonicService + { + void Notify(SubsonicSettings settings, string message); + void Update(SubsonicSettings settings); + ValidationFailure Test(SubsonicSettings settings, string message); + } + + public class SubsonicService : ISubsonicService + { + private readonly ISubsonicServerProxy _proxy; + private readonly Logger _logger; + + public SubsonicService(ISubsonicServerProxy proxy, + Logger logger) + { + _proxy = proxy; + _logger = logger; + + } + + public void Notify(SubsonicSettings settings, string message) + { + _proxy.Notify(settings, message); + } + + public void Update(SubsonicSettings settings) + { + _proxy.Update(settings); + } + + private string GetVersion(SubsonicSettings settings) + { + var result = _proxy.Version(settings); + + return result; + } + + public ValidationFailure Test(SubsonicSettings settings, string message) + { + try + { + _logger.Debug("Determining version of Host: {0}", _proxy.GetBaseUrl(settings)); + var version = GetVersion(settings); + _logger.Debug("Version is: {0}", version); + } + catch (SubsonicAuthenticationException ex) + { + _logger.Error(ex, "Unable to connect to Subsonic Server"); + return new ValidationFailure("Username", "Incorrect username or password"); + } + catch (Exception ex) + { + _logger.Error(ex, "Unable to connect to Subsonic Server"); + return new ValidationFailure("Host", "Unable to connect to Subsonic Server"); + } + + return null; + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Subsonic/SubsonicSettings.cs b/src/NzbDrone.Core/Notifications/Subsonic/SubsonicSettings.cs new file mode 100644 index 000000000..4fbae8f33 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Subsonic/SubsonicSettings.cs @@ -0,0 +1,58 @@ +using FluentValidation; +using Newtonsoft.Json; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Notifications.Subsonic +{ + public class SubsonicSettingsValidator : AbstractValidator<SubsonicSettings> + { + public SubsonicSettingsValidator() + { + RuleFor(c => c.Host).ValidHost(); + RuleFor(c => c.Port).InclusiveBetween(1, 65535); + RuleFor(c => c.UrlBase).ValidUrlBase().When(c => c.UrlBase.IsNotNullOrWhiteSpace()); + } + } + + public class SubsonicSettings : IProviderConfig + { + private static readonly SubsonicSettingsValidator Validator = new SubsonicSettingsValidator(); + + public SubsonicSettings() + { + Port = 4040; + } + + [FieldDefinition(0, Label = "Host")] + public string Host { get; set; } + + [FieldDefinition(1, Label = "Port")] + public int Port { get; set; } + + [FieldDefinition(2, Label = "Url Base", Type = FieldType.Textbox, Advanced = true, HelpText = "Adds a prefix to the Subsonic url, e.g. http://[host]:[port]/[urlBase]/rest")] + public string UrlBase { get; set; } + + [FieldDefinition(3, Label = "Username")] + public string Username { get; set; } + + [FieldDefinition(4, Label = "Password", Type = FieldType.Password)] + public string Password { get; set; } + + [FieldDefinition(5, Label = "Notify with Chat Message", Type = FieldType.Checkbox)] + public bool Notify { get; set; } + + [FieldDefinition(6, Label = "Update Library", HelpText = "Update Library on Download & Rename?", Type = FieldType.Checkbox)] + public bool UpdateLibrary { get; set; } + + [FieldDefinition(7, Label = "Use SSL", Type = FieldType.Checkbox, HelpText = "Connect to Subsonic over HTTPS instead of HTTP")] + public bool UseSsl { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Synology/SynologyIndexer.cs b/src/NzbDrone.Core/Notifications/Synology/SynologyIndexer.cs index 7006b8d29..e412d77af 100644 --- a/src/NzbDrone.Core/Notifications/Synology/SynologyIndexer.cs +++ b/src/NzbDrone.Core/Notifications/Synology/SynologyIndexer.cs @@ -1,9 +1,10 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.IO; using FluentValidation.Results; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Notifications.Synology { @@ -19,34 +20,41 @@ namespace NzbDrone.Core.Notifications.Synology public override string Link => "https://www.synology.com"; public override string Name => "Synology Indexer"; - - public override void OnDownload(DownloadMessage message) + public override void OnReleaseImport(AlbumDownloadMessage message) { if (Settings.UpdateLibrary) { foreach (var oldFile in message.OldFiles) { - var fullPath = Path.Combine(message.Series.Path, oldFile.RelativePath); + var fullPath = oldFile.Path; _indexerProxy.DeleteFile(fullPath); } + foreach (var newFile in message.TrackFiles) { - var fullPath = Path.Combine(message.Series.Path, message.EpisodeFile.RelativePath); + var fullPath = newFile.Path; _indexerProxy.AddFile(fullPath); } } } - public override void OnRename(Series series) + public override void OnRename(Artist artist) { if (Settings.UpdateLibrary) { - _indexerProxy.UpdateFolder(series.Path); + _indexerProxy.UpdateFolder(artist.Path); } } + public override void OnTrackRetag(TrackRetagMessage message) + { + if (Settings.UpdateLibrary) + { + _indexerProxy.UpdateFolder(message.Artist.Path); + } + } public override ValidationResult Test() { diff --git a/src/NzbDrone.Core/Notifications/Telegram/Telegram.cs b/src/NzbDrone.Core/Notifications/Telegram/Telegram.cs index 83648e9f2..670cc1d6d 100644 --- a/src/NzbDrone.Core/Notifications/Telegram/Telegram.cs +++ b/src/NzbDrone.Core/Notifications/Telegram/Telegram.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using FluentValidation.Results; using NzbDrone.Common.Extensions; @@ -18,12 +18,27 @@ namespace NzbDrone.Core.Notifications.Telegram public override void OnGrab(GrabMessage grabMessage) { - _proxy.SendNotification(EPISODE_GRABBED_TITLE, grabMessage.Message, Settings); + _proxy.SendNotification(ALBUM_GRABBED_TITLE, grabMessage.Message, Settings); } - public override void OnDownload(DownloadMessage message) + public override void OnReleaseImport(AlbumDownloadMessage message) { - _proxy.SendNotification(EPISODE_DOWNLOADED_TITLE, message.Message, Settings); + _proxy.SendNotification(ALBUM_DOWNLOADED_TITLE, message.Message, Settings); + } + + public override void OnHealthIssue(HealthCheck.HealthCheck healthCheck) + { + _proxy.SendNotification(HEALTH_ISSUE_TITLE, healthCheck.Message, Settings); + } + + public override void OnDownloadFailure(DownloadFailedMessage message) + { + _proxy.SendNotification(DOWNLOAD_FAILURE_TITLE, message.Message, Settings); + } + + public override void OnImportFailure(AlbumDownloadMessage message) + { + _proxy.SendNotification(IMPORT_FAILURE_TITLE, message.Message, Settings); } public override ValidationResult Test() diff --git a/src/NzbDrone.Core/Notifications/Telegram/TelegramService.cs b/src/NzbDrone.Core/Notifications/Telegram/TelegramService.cs index a0cb33a82..8ab78ff36 100644 --- a/src/NzbDrone.Core/Notifications/Telegram/TelegramService.cs +++ b/src/NzbDrone.Core/Notifications/Telegram/TelegramService.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.Net; +using System.Web; using FluentValidation.Results; using NLog; using NzbDrone.Common.Extensions; @@ -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 = $"<b>{HttpUtility.HtmlEncode(title)}</b>\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); @@ -45,7 +46,7 @@ namespace NzbDrone.Core.Notifications.Telegram try { const string title = "Test Notification"; - const string body = "This is a test message from Sonarr"; + const string body = "This is a test message from Lidarr"; SendNotification(title, body, settings); } diff --git a/src/NzbDrone.Core/Notifications/TrackRetagMessage.cs b/src/NzbDrone.Core/Notifications/TrackRetagMessage.cs new file mode 100644 index 000000000..6f6702544 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/TrackRetagMessage.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Notifications +{ + public class TrackRetagMessage + { + public string Message { get; set; } + public Artist Artist { get; set; } + public Album Album { get; set; } + public AlbumRelease Release { get; set; } + public TrackFile TrackFile { get; set; } + public Dictionary<string, Tuple<string, string>> Diff { get; set; } + public bool Scrubbed { get; set; } + + public override string ToString() + { + return Message; + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Twitter/Twitter.cs b/src/NzbDrone.Core/Notifications/Twitter/Twitter.cs index e517d1902..a8f5e06c2 100644 --- a/src/NzbDrone.Core/Notifications/Twitter/Twitter.cs +++ b/src/NzbDrone.Core/Notifications/Twitter/Twitter.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using FluentValidation.Results; using NzbDrone.Common.Extensions; using NzbDrone.Core.Exceptions; @@ -24,11 +24,26 @@ namespace NzbDrone.Core.Notifications.Twitter _twitterService.SendNotification($"Grabbed: {message.Message}", Settings); } - public override void OnDownload(DownloadMessage message) + public override void OnReleaseImport(AlbumDownloadMessage message) { _twitterService.SendNotification($"Imported: {message.Message}", Settings); } + public override void OnHealthIssue(HealthCheck.HealthCheck healthCheck) + { + _twitterService.SendNotification($"Health Issue: {healthCheck.Message}", Settings); + } + + public override void OnDownloadFailure(DownloadFailedMessage message) + { + _twitterService.SendNotification($"Download Failed: {message.Message}", Settings); + } + + public override void OnImportFailure(AlbumDownloadMessage message) + { + _twitterService.SendNotification($"Import Failed: {message.Message}", Settings); + } + public override object RequestAction(string action, IDictionary<string, string> query) { if (action == "startOAuth") diff --git a/src/NzbDrone.Core/Notifications/Twitter/TwitterService.cs b/src/NzbDrone.Core/Notifications/Twitter/TwitterService.cs index cf3c687fa..54f76a5c8 100644 --- a/src/NzbDrone.Core/Notifications/Twitter/TwitterService.cs +++ b/src/NzbDrone.Core/Notifications/Twitter/TwitterService.cs @@ -24,9 +24,6 @@ namespace NzbDrone.Core.Notifications.Twitter private readonly IHttpClient _httpClient; private readonly Logger _logger; -// private static string _consumerKey = "5jSR8a3cp0ToOqSMLMv5GtMQD"; -// private static string _consumerSecret = "dxoZjyMq4BLsC8KxyhSOrIndhCzJ0Dik2hrLzqyJcqoGk4Pfsp"; - public TwitterService(IHttpClient httpClient, Logger logger) { _httpClient = httpClient; @@ -125,7 +122,7 @@ namespace NzbDrone.Core.Notifications.Twitter { try { - var body = "Sonarr: Test Message @ " + DateTime.Now; + var body = "Lidarr: Test Message @ " + DateTime.Now; SendNotification(body, settings); } diff --git a/src/NzbDrone.Core/Notifications/Twitter/TwitterSettings.cs b/src/NzbDrone.Core/Notifications/Twitter/TwitterSettings.cs index 36b18285a..44d176675 100644 --- a/src/NzbDrone.Core/Notifications/Twitter/TwitterSettings.cs +++ b/src/NzbDrone.Core/Notifications/Twitter/TwitterSettings.cs @@ -1,4 +1,4 @@ -using FluentValidation; +using FluentValidation; using NzbDrone.Common.Extensions; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; @@ -34,13 +34,13 @@ namespace NzbDrone.Core.Notifications.Twitter public TwitterSettings() { DirectMessage = true; - AuthorizeNotification = "step1"; + AuthorizeNotification = "startOAuth"; } - [FieldDefinition(0, Label = "Consumer Key", HelpText = "Consumer key from a Twitter application", HelpLink = "https://github.com/Sonarr/Sonarr/wiki/Twitter-Notifications")] + [FieldDefinition(0, Label = "Consumer Key", HelpText = "Consumer key from a Twitter application", HelpLink = "https://github.com/Lidarr/Lidarr/wiki/Twitter-Notifications")] public string ConsumerKey { get; set; } - [FieldDefinition(1, Label = "Consumer Secret", HelpText = "Consumer secret from a Twitter application", HelpLink = "https://github.com/Sonarr/Sonarr/wiki/Twitter-Notifications")] + [FieldDefinition(1, Label = "Consumer Secret", HelpText = "Consumer secret from a Twitter application", HelpLink = "https://github.com/Lidarr/Lidarr/wiki/Twitter-Notifications")] public string ConsumerSecret { get; set; } [FieldDefinition(2, Label = "Access Token", Advanced = true)] @@ -55,7 +55,7 @@ namespace NzbDrone.Core.Notifications.Twitter [FieldDefinition(5, Label = "Direct Message", Type = FieldType.Checkbox, HelpText = "Send a direct message instead of a public message")] public bool DirectMessage { get; set; } - [FieldDefinition(6, Label = "Connect to twitter", Type = FieldType.Action)] + [FieldDefinition(6, Label = "Connect to Twitter", Type = FieldType.OAuth)] public string AuthorizeNotification { get; set; } public NzbDroneValidationResult Validate() diff --git a/src/NzbDrone.Core/Notifications/Webhook/Webhook.cs b/src/NzbDrone.Core/Notifications/Webhook/Webhook.cs index 4bfcb867c..595d2d348 100644 --- a/src/NzbDrone.Core/Notifications/Webhook/Webhook.cs +++ b/src/NzbDrone.Core/Notifications/Webhook/Webhook.cs @@ -1,35 +1,89 @@ - using System.Collections.Generic; +using System.Linq; using FluentValidation.Results; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; using NzbDrone.Common.Extensions; +using NzbDrone.Core.Validation; namespace NzbDrone.Core.Notifications.Webhook { public class Webhook : NotificationBase<WebhookSettings> { - 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 string Link => "https://github.com/Lidarr/Lidarr/wiki/Webhook"; public override void OnGrab(GrabMessage message) { - _service.OnGrab(message.Series, message.Episode, message.Quality, Settings); + var remoteAlbum = message.Album; + var quality = message.Quality; + + var payload = new WebhookGrabPayload + + { + EventType = "Grab", + Artist = new WebhookArtist(message.Artist), + Albums = remoteAlbum.Albums.ConvertAll(x => new WebhookAlbum(x) + { + // TODO: Stop passing these parameters inside an album v3 + Quality = quality.Quality.Name, + QualityVersion = quality.Revision.Version, + ReleaseGroup = remoteAlbum.ParsedAlbumInfo.ReleaseGroup + }), + Release = new WebhookRelease(quality, remoteAlbum) + }; + + _proxy.SendWebhook(payload, Settings); + } + + public override void OnReleaseImport(AlbumDownloadMessage message) + { + var trackFiles = message.TrackFiles; + + var payload = new WebhookImportPayload + + { + EventType = "Download", + Artist = new WebhookArtist(message.Artist), + Tracks = trackFiles.SelectMany(x => x.Tracks.Value.Select(y => new WebhookTrack(y) + { + // TODO: Stop passing these parameters inside an episode v3 + Quality = x.Quality.Quality.Name, + QualityVersion = x.Quality.Revision.Version, + ReleaseGroup = x.ReleaseGroup + })).ToList(), + TrackFiles = trackFiles.ConvertAll(x => new WebhookTrackFile(x)), + IsUpgrade = message.OldFiles.Any() + }; + + _proxy.SendWebhook(payload, Settings); } - public override void OnDownload(DownloadMessage message) + public override void OnRename(Artist artist) { - _service.OnDownload(message.Series, message.EpisodeFile, Settings); + var payload = new WebhookPayload + { + EventType = "Rename", + Artist = new WebhookArtist(artist) + }; + + _proxy.SendWebhook(payload, Settings); } - public override void OnRename(Series series) + public override void OnTrackRetag(TrackRetagMessage message) { - _service.OnRename(series, Settings); + var payload = new WebhookPayload + { + EventType = "Retag", + Artist = new WebhookArtist(message.Artist) + }; + + _proxy.SendWebhook(payload, Settings); } public override string Name => "Webhook"; @@ -38,9 +92,42 @@ namespace NzbDrone.Core.Notifications.Webhook { var failures = new List<ValidationFailure>(); - failures.AddIfNotNull(_service.Test(Settings)); + failures.AddIfNotNull(SendWebhookTest()); return new ValidationResult(failures); } + + private ValidationFailure SendWebhookTest() + { + try + { + var payload = new WebhookGrabPayload + { + EventType = "Test", + Artist = new WebhookArtist() + { + Id = 1, + Name = "Test Name", + Path = "C:\\testpath", + MBId = "aaaaa-aaa-aaaa-aaaaaa" + }, + Albums = new List<WebhookAlbum>() { + new WebhookAlbum() + { + Id = 123, + 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/WebhookAlbum.cs b/src/NzbDrone.Core/Notifications/Webhook/WebhookAlbum.cs new file mode 100644 index 000000000..433bb7370 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Webhook/WebhookAlbum.cs @@ -0,0 +1,26 @@ +using NzbDrone.Core.Music; +using System; + +namespace NzbDrone.Core.Notifications.Webhook +{ + public class WebhookAlbum + { + public WebhookAlbum() { } + + public WebhookAlbum(Album album) + { + Id = album.Id; + Title = album.Title; + ReleaseDate = album.ReleaseDate; + } + + public int Id { get; set; } + public string Title { get; set; } + public DateTime? ReleaseDate { get; set; } + + public string Quality { get; set; } + public int QualityVersion { get; set; } + public string ReleaseGroup { get; set; } + public string SceneName { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Webhook/WebhookArtist.cs b/src/NzbDrone.Core/Notifications/Webhook/WebhookArtist.cs new file mode 100644 index 000000000..7e291dc9d --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Webhook/WebhookArtist.cs @@ -0,0 +1,22 @@ +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Notifications.Webhook +{ + public class WebhookArtist + { + public int Id { get; set; } + public string Name { get; set; } + public string Path { get; set; } + public string MBId { get; set; } + + public WebhookArtist() { } + + public WebhookArtist(Artist artist) + { + Id = artist.Id; + Name = artist.Name; + Path = artist.Path; + MBId = artist.Metadata.Value.ForeignArtistId; + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Webhook/WebhookEpisode.cs b/src/NzbDrone.Core/Notifications/Webhook/WebhookEpisode.cs deleted file mode 100644 index a7979b726..000000000 --- a/src/NzbDrone.Core/Notifications/Webhook/WebhookEpisode.cs +++ /dev/null @@ -1,32 +0,0 @@ -using NzbDrone.Core.Tv; -using System; - -namespace NzbDrone.Core.Notifications.Webhook -{ - public class WebhookEpisode - { - public WebhookEpisode() { } - - public WebhookEpisode(Episode episode) - { - Id = episode.Id; - SeasonNumber = episode.SeasonNumber; - EpisodeNumber = episode.EpisodeNumber; - Title = episode.Title; - AirDate = episode.AirDate; - AirDateUtc = episode.AirDateUtc; - } - - public int Id { get; set; } - public int EpisodeNumber { get; set; } - public int SeasonNumber { get; set; } - public string Title { get; set; } - public string AirDate { get; set; } - public DateTime? AirDateUtc { 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..4cb7c868b --- /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<WebhookAlbum> Albums { 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..ec19633d7 --- /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<WebhookTrack> Tracks { get; set; } + public List<WebhookTrackFile> TrackFiles { 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..5d6e859a6 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..3beff4105 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 { public class WebhookPayload { public string EventType { get; set; } - public WebhookSeries Series { get; set; } - public List<WebhookEpisode> Episodes { get; set; } + public WebhookArtist Artist { 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..db33d5f28 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Webhook/WebhookProxy.cs @@ -0,0 +1,47 @@ +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..60ff931da --- /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, RemoteAlbum remoteAlbum) + { + Quality = quality.Quality.Name; + QualityVersion = quality.Revision.Version; + ReleaseGroup = remoteAlbum.ParsedAlbumInfo.ReleaseGroup; + ReleaseTitle = remoteAlbum.Release.Title; + Indexer = remoteAlbum.Release.Indexer; + Size = remoteAlbum.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; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Webhook/WebhookSeries.cs b/src/NzbDrone.Core/Notifications/Webhook/WebhookSeries.cs deleted file mode 100644 index 222f9eebb..000000000 --- a/src/NzbDrone.Core/Notifications/Webhook/WebhookSeries.cs +++ /dev/null @@ -1,22 +0,0 @@ -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Notifications.Webhook -{ - public class WebhookSeries - { - public int Id { get; set; } - public string Title { get; set; } - public string Path { get; set; } - public int TvdbId { get; set; } - - public WebhookSeries() { } - - public WebhookSeries(Series series) - { - Id = series.Id; - Title = series.Title; - Path = series.Path; - TvdbId = series.TvdbId; - } - } -} \ 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<WebhookEpisode>() { - 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..2e950e9d8 100644 --- a/src/NzbDrone.Core/Notifications/Webhook/WebhookSettings.cs +++ b/src/NzbDrone.Core/Notifications/Webhook/WebhookSettings.cs @@ -1,4 +1,4 @@ -using System; +using System; using FluentValidation; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; @@ -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/Webhook/WebhookTrack.cs b/src/NzbDrone.Core/Notifications/Webhook/WebhookTrack.cs new file mode 100644 index 000000000..098fd0078 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Webhook/WebhookTrack.cs @@ -0,0 +1,26 @@ +using NzbDrone.Core.Music; +using System; + +namespace NzbDrone.Core.Notifications.Webhook +{ + public class WebhookTrack + { + public WebhookTrack() { } + + public WebhookTrack(Track track) + { + Id = track.Id; + Title = track.Title; + TrackNumber = track.TrackNumber; + + } + + public int Id { get; set; } + public string Title { get; set; } + public string TrackNumber { get; set; } + + public string Quality { get; set; } + public int QualityVersion { get; set; } + public string ReleaseGroup { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Webhook/WebhookTrackFile.cs b/src/NzbDrone.Core/Notifications/Webhook/WebhookTrackFile.cs new file mode 100644 index 000000000..eeefbaf09 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Webhook/WebhookTrackFile.cs @@ -0,0 +1,26 @@ +using NzbDrone.Core.MediaFiles; + +namespace NzbDrone.Core.Notifications.Webhook +{ + public class WebhookTrackFile + { + public WebhookTrackFile() { } + + public WebhookTrackFile(TrackFile trackFile) + { + Id = trackFile.Id; + Path = trackFile.Path; + Quality = trackFile.Quality.Quality.Name; + QualityVersion = trackFile.Quality.Revision.Version; + ReleaseGroup = trackFile.ReleaseGroup; + SceneName = trackFile.SceneName; + } + + public int Id { 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; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Xbmc/HttpApiProvider.cs b/src/NzbDrone.Core/Notifications/Xbmc/HttpApiProvider.cs index 76f2bc91f..94b331f60 100644 --- a/src/NzbDrone.Core/Notifications/Xbmc/HttpApiProvider.cs +++ b/src/NzbDrone.Core/Notifications/Xbmc/HttpApiProvider.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -6,7 +6,7 @@ using System.Xml.Linq; using NLog; using NzbDrone.Common.Http; using NzbDrone.Core.Notifications.Xbmc.Model; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Notifications.Xbmc { @@ -28,33 +28,33 @@ namespace NzbDrone.Core.Notifications.Xbmc public void Notify(XbmcSettings settings, string title, string message) { - var notification = string.Format("Notification({0},{1},{2},{3})", title, message, settings.DisplayTime * 1000, "https://raw.github.com/Sonarr/Sonarr/develop/Logo/64.png"); + var notification = string.Format("Notification({0},{1},{2},{3})", title, message, settings.DisplayTime * 1000, "https://raw.github.com/Lidarr/Lidarr/develop/Logo/64.png"); var command = BuildExecBuiltInCommand(notification); SendCommand(settings, command); } - public void Update(XbmcSettings settings, Series series) + public void Update(XbmcSettings settings, Artist artist) { if (!settings.AlwaysUpdate) { _logger.Debug("Determining if there are any active players on XBMC host: {0}", settings.Address); var activePlayers = GetActivePlayers(settings); - if (activePlayers.Any(a => a.Type.Equals("video"))) + if (activePlayers.Any(a => a.Type.Equals("audio"))) { - _logger.Debug("Video is currently playing, skipping library update"); + _logger.Debug("Audio is currently playing, skipping library update"); return; } } - UpdateLibrary(settings, series); + UpdateLibrary(settings, artist); } public void Clean(XbmcSettings settings) { - const string cleanVideoLibrary = "CleanLibrary(video)"; - var command = BuildExecBuiltInCommand(cleanVideoLibrary); + const string cleanMusicLibrary = "CleanLibrary(music)"; + var command = BuildExecBuiltInCommand(cleanMusicLibrary); SendCommand(settings, command); } @@ -67,7 +67,7 @@ namespace NzbDrone.Core.Notifications.Xbmc var response = SendCommand(settings, "getcurrentlyplaying"); if (response.Contains("<li>Filename:[Nothing Playing]")) return new List<ActivePlayer>(); - if (response.Contains("<li>Type:Video")) result.Add(new ActivePlayer(1, "video")); + if (response.Contains("<li>Type:Audio")) result.Add(new ActivePlayer(1, "audio")); return result; } @@ -80,13 +80,13 @@ namespace NzbDrone.Core.Notifications.Xbmc return new List<ActivePlayer>(); } - internal string GetSeriesPath(XbmcSettings settings, Series series) + internal string GetArtistPath(XbmcSettings settings, Artist artist) { var query = string.Format( - "select path.strPath from path, tvshow, tvshowlinkpath where tvshow.c12 = {0} and tvshowlinkpath.idShow = tvshow.idShow and tvshowlinkpath.idPath = path.idPath", - series.TvdbId); - var command = string.Format("QueryVideoDatabase({0})", query); + "select path.strPath from path, artist, artistlinkpath where artist.c12 = {0} and artistlinkpath.idArtist = artist.idArtist and artistlinkpath.idPath = path.idPath", + artist.Metadata.Value.ForeignArtistId); + var command = string.Format("QueryMusicDatabase({0})", query); const string setResponseCommand = "SetResponseFormat(webheader;false;webfooter;false;header;<xml>;footer;</xml>;opentag;<tag>;closetag;</tag>;closefinaltag;false)"; @@ -137,26 +137,26 @@ namespace NzbDrone.Core.Notifications.Xbmc return false; } - private void UpdateLibrary(XbmcSettings settings, Series series) + private void UpdateLibrary(XbmcSettings settings, Artist artist) { try { _logger.Debug("Sending Update DB Request to XBMC Host: {0}", settings.Address); - var xbmcSeriesPath = GetSeriesPath(settings, series); + var xbmcArtistPath = GetArtistPath(settings, artist); //If the path is found update it, else update the whole library - if (!string.IsNullOrEmpty(xbmcSeriesPath)) + if (!string.IsNullOrEmpty(xbmcArtistPath)) { - _logger.Debug("Updating series [{0}] on XBMC host: {1}", series, settings.Address); - var command = BuildExecBuiltInCommand(string.Format("UpdateLibrary(video,{0})", xbmcSeriesPath)); + _logger.Debug("Updating artist [{0}] on XBMC host: {1}", artist, settings.Address); + var command = BuildExecBuiltInCommand(string.Format("UpdateLibrary(music,{0})", xbmcArtistPath)); SendCommand(settings, command); } else { //Update the entire library - _logger.Debug("Series [{0}] doesn't exist on XBMC host: {1}, Updating Entire Library", series, settings.Address); - var command = BuildExecBuiltInCommand("UpdateLibrary(video)"); + _logger.Debug("Artist [{0}] doesn't exist on XBMC host: {1}, Updating Entire Library", artist, settings.Address); + var command = BuildExecBuiltInCommand("UpdateLibrary(music)"); SendCommand(settings, command); } } diff --git a/src/NzbDrone.Core/Notifications/Xbmc/IApiProvider.cs b/src/NzbDrone.Core/Notifications/Xbmc/IApiProvider.cs index bf250edc3..4c93ac123 100644 --- a/src/NzbDrone.Core/Notifications/Xbmc/IApiProvider.cs +++ b/src/NzbDrone.Core/Notifications/Xbmc/IApiProvider.cs @@ -1,12 +1,12 @@ -using NzbDrone.Core.Notifications.Xbmc.Model; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Notifications.Xbmc.Model; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Notifications.Xbmc { public interface IApiProvider { void Notify(XbmcSettings settings, string title, string message); - void Update(XbmcSettings settings, Series series); + void Update(XbmcSettings settings, Artist artist); void Clean(XbmcSettings settings); bool CanHandle(XbmcVersion version); } diff --git a/src/NzbDrone.Core/Notifications/Xbmc/JsonApiProvider.cs b/src/NzbDrone.Core/Notifications/Xbmc/JsonApiProvider.cs index 1a0674908..404877d7d 100644 --- a/src/NzbDrone.Core/Notifications/Xbmc/JsonApiProvider.cs +++ b/src/NzbDrone.Core/Notifications/Xbmc/JsonApiProvider.cs @@ -1,9 +1,9 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using NLog; using NzbDrone.Core.Notifications.Xbmc.Model; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Notifications.Xbmc { @@ -28,21 +28,21 @@ namespace NzbDrone.Core.Notifications.Xbmc _proxy.Notify(settings, title, message); } - public void Update(XbmcSettings settings, Series series) + public void Update(XbmcSettings settings, Artist artist) { if (!settings.AlwaysUpdate) { _logger.Debug("Determining if there are any active players on XBMC host: {0}", settings.Address); var activePlayers = _proxy.GetActivePlayers(settings); - if (activePlayers.Any(a => a.Type.Equals("video"))) + if (activePlayers.Any(a => a.Type.Equals("audio"))) { - _logger.Debug("Video is currently playing, skipping library update"); + _logger.Debug("Audio is currently playing, skipping library update"); return; } } - UpdateLibrary(settings, series); + UpdateLibrary(settings, artist); } public void Clean(XbmcSettings settings) @@ -55,47 +55,44 @@ namespace NzbDrone.Core.Notifications.Xbmc return _proxy.GetActivePlayers(settings); } - public string GetSeriesPath(XbmcSettings settings, Series series) + public string GetArtistPath(XbmcSettings settings, Artist artist) { - var allSeries = _proxy.GetSeries(settings); + var allArtists = _proxy.GetArtist(settings); - if (!allSeries.Any()) + if (!allArtists.Any()) { - _logger.Debug("No TV shows returned from XBMC"); + _logger.Debug("No Artists returned from XBMC"); return null; } - var matchingSeries = allSeries.FirstOrDefault(s => + var matchingArtist = allArtists.FirstOrDefault(s => { - var tvdbId = 0; - int.TryParse(s.ImdbNumber, out tvdbId); + var musicBrainzId = s.MusicbrainzArtistId.FirstOrDefault(); - return tvdbId == series.TvdbId || s.Label == series.Title; + return musicBrainzId == artist.Metadata.Value.ForeignArtistId || s.Label == artist.Name; }); - if (matchingSeries != null) return matchingSeries.File; - - return null; + return matchingArtist?.File; } - private void UpdateLibrary(XbmcSettings settings, Series series) + private void UpdateLibrary(XbmcSettings settings, Artist artist) { try { - var seriesPath = GetSeriesPath(settings, series); + var artistPath = GetArtistPath(settings, artist); - if (seriesPath != null) + if (artistPath != null) { - _logger.Debug("Updating series {0} (Path: {1}) on XBMC host: {2}", series, seriesPath, settings.Address); + _logger.Debug("Updating artist {0} (Path: {1}) on XBMC host: {2}", artist, artistPath, settings.Address); } else { - _logger.Debug("Series {0} doesn't exist on XBMC host: {1}, Updating Entire Library", series, + _logger.Debug("Artist {0} doesn't exist on XBMC host: {1}, Updating Entire Library", artist, settings.Address); } - var response = _proxy.UpdateLibrary(settings, seriesPath); + var response = _proxy.UpdateLibrary(settings, artistPath); if (!response.Equals("OK", StringComparison.InvariantCultureIgnoreCase)) { diff --git a/src/NzbDrone.Core/Notifications/Xbmc/Model/ArtistResponse.cs b/src/NzbDrone.Core/Notifications/Xbmc/Model/ArtistResponse.cs new file mode 100644 index 000000000..bf1911901 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Xbmc/Model/ArtistResponse.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Notifications.Xbmc.Model +{ + public class ArtistResponse + { + public string Id { get; set; } + public string JsonRpc { get; set; } + public ArtistResult Result { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Xbmc/Model/ArtistResult.cs b/src/NzbDrone.Core/Notifications/Xbmc/Model/ArtistResult.cs new file mode 100644 index 000000000..9acb0ad11 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Xbmc/Model/ArtistResult.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Notifications.Xbmc.Model +{ + public class ArtistResult + { + public Dictionary<string, int> Limits { get; set; } + public List<KodiArtist> Artists; + + public ArtistResult() + { + Artists = new List<KodiArtist>(); + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Xbmc/Model/KodiArtist.cs b/src/NzbDrone.Core/Notifications/Xbmc/Model/KodiArtist.cs new file mode 100644 index 000000000..1e86c7c9d --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Xbmc/Model/KodiArtist.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Notifications.Xbmc.Model +{ + public class KodiArtist + { + public int ArtistId { get; set; } + public string Label { get; set; } + public List<string> MusicbrainzArtistId { get; set; } + public string File { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Xbmc/Model/TvShow.cs b/src/NzbDrone.Core/Notifications/Xbmc/Model/TvShow.cs deleted file mode 100644 index 437285107..000000000 --- a/src/NzbDrone.Core/Notifications/Xbmc/Model/TvShow.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace NzbDrone.Core.Notifications.Xbmc.Model -{ - public class TvShow - { - public int TvShowId { get; set; } - public string Label { get; set; } - public string ImdbNumber { get; set; } - public string File { get; set; } - } -} diff --git a/src/NzbDrone.Core/Notifications/Xbmc/Model/TvShowResponse.cs b/src/NzbDrone.Core/Notifications/Xbmc/Model/TvShowResponse.cs deleted file mode 100644 index 079ede558..000000000 --- a/src/NzbDrone.Core/Notifications/Xbmc/Model/TvShowResponse.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace NzbDrone.Core.Notifications.Xbmc.Model -{ - public class TvShowResponse - { - public string Id { get; set; } - public string JsonRpc { get; set; } - public TvShowResult Result { get; set; } - } -} diff --git a/src/NzbDrone.Core/Notifications/Xbmc/Model/TvShowResult.cs b/src/NzbDrone.Core/Notifications/Xbmc/Model/TvShowResult.cs deleted file mode 100644 index f3fb4dd4a..000000000 --- a/src/NzbDrone.Core/Notifications/Xbmc/Model/TvShowResult.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Collections.Generic; - -namespace NzbDrone.Core.Notifications.Xbmc.Model -{ - public class TvShowResult - { - public Dictionary<string, int> Limits { get; set; } - public List<TvShow> TvShows; - - public TvShowResult() - { - TvShows = new List<TvShow>(); - } - } -} diff --git a/src/NzbDrone.Core/Notifications/Xbmc/Xbmc.cs b/src/NzbDrone.Core/Notifications/Xbmc/Xbmc.cs index c6a0c82df..c3c9eb911 100644 --- a/src/NzbDrone.Core/Notifications/Xbmc/Xbmc.cs +++ b/src/NzbDrone.Core/Notifications/Xbmc/Xbmc.cs @@ -1,10 +1,10 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Net.Sockets; using FluentValidation.Results; using NLog; using NzbDrone.Common.Extensions; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Notifications.Xbmc { @@ -23,22 +23,32 @@ namespace NzbDrone.Core.Notifications.Xbmc public override void OnGrab(GrabMessage grabMessage) { - const string header = "Sonarr - Grabbed"; + const string header = "Lidarr - Grabbed"; Notify(Settings, header, grabMessage.Message); } - public override void OnDownload(DownloadMessage message) + public override void OnReleaseImport(AlbumDownloadMessage message) { - const string header = "Sonarr - Downloaded"; + const string header = "Lidarr - Downloaded"; Notify(Settings, header, message.Message); - UpdateAndClean(message.Series, message.OldFiles.Any()); + UpdateAndClean(message.Artist, message.OldFiles.Any()); } - public override void OnRename(Series series) + public override void OnRename(Artist artist) { - UpdateAndClean(series); + UpdateAndClean(artist); + } + + public override void OnHealthIssue(HealthCheck.HealthCheck healthCheck) + { + Notify(Settings, HEALTH_ISSUE_TITLE_BRANDED, healthCheck.Message); + } + + public override void OnTrackRetag(TrackRetagMessage message) + { + UpdateAndClean(message.Artist); } public override string Name => "Kodi (XBMC)"; @@ -47,7 +57,7 @@ namespace NzbDrone.Core.Notifications.Xbmc { var failures = new List<ValidationFailure>(); - failures.AddIfNotNull(_xbmcService.Test(Settings, "Success! XBMC has been successfully configured!")); + failures.AddIfNotNull(_xbmcService.Test(Settings, "Success! Kodi has been successfully configured!")); return new ValidationResult(failures); } @@ -63,18 +73,18 @@ namespace NzbDrone.Core.Notifications.Xbmc } catch (SocketException ex) { - var logMessage = string.Format("Unable to connect to XBMC Host: {0}:{1}", Settings.Host, Settings.Port); + var logMessage = string.Format("Unable to connect to Kodi Host: {0}:{1}", Settings.Host, Settings.Port); _logger.Debug(ex, logMessage); } } - private void UpdateAndClean(Series series, bool clean = true) + private void UpdateAndClean(Artist artist, bool clean = true) { try { if (Settings.UpdateLibrary) { - _xbmcService.Update(Settings, series); + _xbmcService.Update(Settings, artist); } if (clean && Settings.CleanLibrary) @@ -84,7 +94,7 @@ namespace NzbDrone.Core.Notifications.Xbmc } catch (SocketException ex) { - var logMessage = string.Format("Unable to connect to XBMC Host: {0}:{1}", Settings.Host, Settings.Port); + var logMessage = string.Format("Unable to connect to Kodi Host: {0}:{1}", Settings.Host, Settings.Port); _logger.Debug(ex, logMessage); } } diff --git a/src/NzbDrone.Core/Notifications/Xbmc/XbmcJsonApiProxy.cs b/src/NzbDrone.Core/Notifications/Xbmc/XbmcJsonApiProxy.cs index 943a80cd3..aa330296d 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; @@ -16,7 +16,7 @@ namespace NzbDrone.Core.Notifications.Xbmc string UpdateLibrary(XbmcSettings settings, string path); void CleanLibrary(XbmcSettings settings); List<ActivePlayer> GetActivePlayers(XbmcSettings settings); - List<TvShow> GetSeries(XbmcSettings settings); + List<KodiArtist> GetArtist(XbmcSettings settings); } public class XbmcJsonApiProxy : IXbmcJsonApiProxy @@ -41,7 +41,7 @@ namespace NzbDrone.Core.Notifications.Xbmc var parameters = new Dictionary<string, object>(); parameters.Add("title", title); parameters.Add("message", message); - parameters.Add("image", "https://raw.github.com/Sonarr/Sonarr/develop/Logo/64.png"); + parameters.Add("image", "https://raw.github.com/Lidarr/Lidarr/develop/Logo/64.png"); parameters.Add("displaytime", settings.DisplayTime * 1000); ProcessRequest(request, settings, "GUI.ShowNotification", parameters); @@ -58,7 +58,7 @@ namespace NzbDrone.Core.Notifications.Xbmc parameters = null; } - var response = ProcessRequest(request, settings, "VideoLibrary.Scan", parameters); + var response = ProcessRequest(request, settings, "AudioLibrary.Scan", parameters); return Json.Deserialize<XbmcJsonResult<string>>(response).Result; } @@ -67,7 +67,7 @@ namespace NzbDrone.Core.Notifications.Xbmc { var request = new RestRequest(); - ProcessRequest(request, settings, "VideoLibrary.Clean"); + ProcessRequest(request, settings, "AudioLibrary.Clean"); } public List<ActivePlayer> GetActivePlayers(XbmcSettings settings) @@ -79,15 +79,15 @@ namespace NzbDrone.Core.Notifications.Xbmc return Json.Deserialize<ActivePlayersEdenResult>(response).Result; } - public List<TvShow> GetSeries(XbmcSettings settings) + public List<KodiArtist> GetArtist(XbmcSettings settings) { var request = new RestRequest(); var parameters = new Dictionary<string, object>(); - parameters.Add("properties", new[] { "file", "imdbnumber" }); + parameters.Add("properties", new[] { "musicbrainzartistid" }); //TODO: Figure out why AudioLibrary doesnt list file location like videoLibray - var response = ProcessRequest(request, settings, "VideoLibrary.GetTvShows", parameters); + var response = ProcessRequest(request, settings, "AudioLibrary.GetArtists", parameters); - return Json.Deserialize<TvShowResponse>(response).Result.TvShows; + return Json.Deserialize<ArtistResponse>(response).Result.Artists; } private string ProcessRequest(IRestRequest request, XbmcSettings settings, string method, Dictionary<string, object> parameters = null) @@ -122,13 +122,13 @@ 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<ErrorResult>(response.Content); diff --git a/src/NzbDrone.Core/Notifications/Xbmc/XbmcService.cs b/src/NzbDrone.Core/Notifications/Xbmc/XbmcService.cs index d9cacf8f8..040b1a269 100644 --- a/src/NzbDrone.Core/Notifications/Xbmc/XbmcService.cs +++ b/src/NzbDrone.Core/Notifications/Xbmc/XbmcService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using FluentValidation.Results; @@ -7,14 +7,14 @@ using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Serializer; using NzbDrone.Core.Notifications.Xbmc.Model; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Notifications.Xbmc { public interface IXbmcService { void Notify(XbmcSettings settings, string title, string message); - void Update(XbmcSettings settings, Series series); + void Update(XbmcSettings settings, Artist artist); void Clean(XbmcSettings settings); ValidationFailure Test(XbmcSettings settings, string message); } @@ -45,10 +45,10 @@ namespace NzbDrone.Core.Notifications.Xbmc provider.Notify(settings, title, message); } - public void Update(XbmcSettings settings, Series series) + public void Update(XbmcSettings settings, Artist artist) { var provider = GetApiProvider(settings); - provider.Update(settings, series); + provider.Update(settings, artist); } public void Clean(XbmcSettings settings) @@ -122,4 +122,4 @@ namespace NzbDrone.Core.Notifications.Xbmc return null; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Notifications/Xbmc/XbmcSettings.cs b/src/NzbDrone.Core/Notifications/Xbmc/XbmcSettings.cs index f861dd654..21293e843 100644 --- a/src/NzbDrone.Core/Notifications/Xbmc/XbmcSettings.cs +++ b/src/NzbDrone.Core/Notifications/Xbmc/XbmcSettings.cs @@ -1,4 +1,4 @@ -using System.ComponentModel; +using System.ComponentModel; using FluentValidation; using Newtonsoft.Json; using NzbDrone.Core.Annotations; @@ -51,7 +51,7 @@ namespace NzbDrone.Core.Notifications.Xbmc [FieldDefinition(7, Label = "Clean Library", HelpText = "Clean Library after update?", Type = FieldType.Checkbox)] public bool CleanLibrary { get; set; } - [FieldDefinition(8, Label = "Always Update", HelpText = "Update Library even when a video is playing?", Type = FieldType.Checkbox)] + [FieldDefinition(8, Label = "Always Update", HelpText = "Update Library even when a file is playing?", Type = FieldType.Checkbox)] public bool AlwaysUpdate { get; set; } [JsonIgnore] diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj deleted file mode 100644 index 66423add1..000000000 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ /dev/null @@ -1,1217 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <PropertyGroup> - <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> - <Platform Condition=" '$(Platform)' == '' ">x86</Platform> - <ProductVersion>8.0.30703</ProductVersion> - <SchemaVersion>2.0</SchemaVersion> - <ProjectGuid>{FF5EE3B6-913B-47CE-9CEB-11C51B4E1205}</ProjectGuid> - <OutputType>Library</OutputType> - <AppDesignerFolder>Properties</AppDesignerFolder> - <RootNamespace>NzbDrone.Core</RootNamespace> - <AssemblyName>NzbDrone.Core</AssemblyName> - <TargetFrameworkVersion>v4.0</TargetFrameworkVersion> - <TargetFrameworkProfile> - </TargetFrameworkProfile> - <FileAlignment>512</FileAlignment> - <PublishUrl>publish\</PublishUrl> - <Install>true</Install> - <InstallFrom>Disk</InstallFrom> - <UpdateEnabled>false</UpdateEnabled> - <UpdateMode>Foreground</UpdateMode> - <UpdateInterval>7</UpdateInterval> - <UpdateIntervalUnits>Days</UpdateIntervalUnits> - <UpdatePeriodically>false</UpdatePeriodically> - <UpdateRequired>false</UpdateRequired> - <MapFileExtensions>true</MapFileExtensions> - <ApplicationRevision>0</ApplicationRevision> - <ApplicationVersion>1.0.0.%2a</ApplicationVersion> - <IsWebBootstrapper>false</IsWebBootstrapper> - <UseApplicationTrust>false</UseApplicationTrust> - <BootstrapperEnabled>true</BootstrapperEnabled> - <SolutionDir Condition="$(SolutionDir) == '' Or $(SolutionDir) == '*Undefined*'">..\</SolutionDir> - <RestorePackages>true</RestorePackages> - </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|x86' "> - <PlatformTarget>x86</PlatformTarget> - <DebugSymbols>true</DebugSymbols> - <DebugType>full</DebugType> - <Optimize>false</Optimize> - <OutputPath>..\..\_output\</OutputPath> - <DefineConstants>DEBUG;TRACE</DefineConstants> - <ErrorReport>prompt</ErrorReport> - <WarningLevel>4</WarningLevel> - </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|x86' "> - <PlatformTarget>x86</PlatformTarget> - <DebugType>pdbonly</DebugType> - <Optimize>true</Optimize> - <OutputPath>..\..\_output\</OutputPath> - <DefineConstants>TRACE</DefineConstants> - <ErrorReport>prompt</ErrorReport> - <WarningLevel>4</WarningLevel> - </PropertyGroup> - <ItemGroup> - <Reference Include="FluentMigrator, Version=1.6.2.0, Culture=neutral, PublicKeyToken=aacfc7de5acabf05, processorArchitecture=MSIL"> - <HintPath>..\packages\FluentMigrator.1.6.2\lib\40\FluentMigrator.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="FluentMigrator.Runner, Version=1.6.2.0, Culture=neutral, PublicKeyToken=aacfc7de5acabf05, processorArchitecture=MSIL"> - <HintPath>..\packages\FluentMigrator.Runner.1.6.2\lib\40\FluentMigrator.Runner.dll</HintPath> - <Private>True</Private> - </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> - <Private>True</Private> - </Reference> - <Reference Include="Growl.Connector, Version=2.0.0.0, Culture=neutral, PublicKeyToken=980c2339411be384, processorArchitecture=MSIL"> - <SpecificVersion>False</SpecificVersion> - <HintPath>..\Libraries\Growl.Connector.dll</HintPath> - </Reference> - <Reference Include="Growl.CoreLibrary, Version=2.0.0.0, Culture=neutral, PublicKeyToken=13e59d82e007b064, processorArchitecture=MSIL"> - <SpecificVersion>False</SpecificVersion> - <HintPath>..\Libraries\Growl.CoreLibrary.dll</HintPath> - </Reference> - <Reference Include="ImageResizer, Version=3.4.3.103, Culture=neutral, processorArchitecture=MSIL"> - <HintPath>..\packages\ImageResizer.3.4.3\lib\ImageResizer.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.1\lib\net40\NLog.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="OAuth"> - <HintPath>..\packages\OAuth.1.0.3\lib\net40\OAuth.dll</HintPath> - </Reference> - <Reference Include="CookComputing.XmlRpc, Version=2.5.0.0, Culture=neutral, processorArchitecture=MSIL"> - <SpecificVersion>False</SpecificVersion> - <HintPath>..\packages\xmlrpcnet.2.5.0\lib\net20\CookComputing.XmlRpcV2.dll</HintPath> - </Reference> - <Reference Include="RestSharp, Version=105.2.3.0, Culture=neutral, processorArchitecture=MSIL"> - <HintPath>..\packages\RestSharp.105.2.3\lib\net4\RestSharp.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="System" /> - <Reference Include="System.Core" /> - <Reference Include="System.Data" /> - <Reference Include="System.Drawing" /> - <Reference Include="System.Web" /> - <Reference Include="System.Web.Extensions" /> - <Reference Include="System.Windows.Forms" /> - <Reference Include="System.Xml" /> - <Reference Include="System.Xml.Linq" /> - <Reference Include="Microsoft.CSharp" /> - <Reference Include="Prowlin"> - <HintPath>..\packages\Prowlin.0.9.4456.26422\lib\net40\Prowlin.dll</HintPath> - </Reference> - <Reference Include="System.Data.SQLite"> - <HintPath>..\Libraries\Sqlite\System.Data.SQLite.dll</HintPath> - </Reference> - </ItemGroup> - <ItemGroup> - <Compile Include="..\NzbDrone.Common\Properties\SharedAssemblyInfo.cs"> - <Link>Properties\SharedAssemblyInfo.cs</Link> - </Compile> - <Compile Include="Analytics\AnalyticsService.cs" /> - <Compile Include="Annotations\FieldDefinitionAttribute.cs" /> - <Compile Include="Authentication\AuthenticationType.cs" /> - <Compile Include="Authentication\User.cs" /> - <Compile Include="Authentication\UserRepository.cs" /> - <Compile Include="Authentication\UserService.cs" /> - <Compile Include="Backup\Backup.cs" /> - <Compile Include="Backup\BackupCommand.cs" /> - <Compile Include="Backup\BackupService.cs" /> - <Compile Include="Blacklisting\Blacklist.cs" /> - <Compile Include="Blacklisting\BlacklistRepository.cs" /> - <Compile Include="Blacklisting\BlacklistService.cs" /> - <Compile Include="Blacklisting\ClearBlacklistCommand.cs" /> - <Compile Include="Configuration\Config.cs" /> - <Compile Include="Configuration\ConfigFileProvider.cs" /> - <Compile Include="Configuration\ConfigRepository.cs" /> - <Compile Include="Configuration\ConfigService.cs" /> - <Compile Include="Configuration\Events\ConfigFileSavedEvent.cs" /> - <Compile Include="Configuration\Events\ConfigSavedEvent.cs" /> - <Compile Include="Configuration\IConfigService.cs" /> - <Compile Include="Configuration\InvalidConfigFileException.cs" /> - <Compile Include="Configuration\ResetApiKeyCommand.cs" /> - <Compile Include="DataAugmentation\DailySeries\DailySeries.cs" /> - <Compile Include="DataAugmentation\DailySeries\DailySeriesDataProxy.cs" /> - <Compile Include="DataAugmentation\DailySeries\DailySeriesService.cs" /> - <Compile Include="DataAugmentation\Scene\ISceneMappingProvider.cs" /> - <Compile Include="DataAugmentation\Scene\SceneMapping.cs" /> - <Compile Include="DataAugmentation\Scene\SceneMappingProxy.cs" /> - <Compile Include="DataAugmentation\Scene\SceneMappingRepository.cs" /> - <Compile Include="DataAugmentation\Scene\SceneMappingService.cs" /> - <Compile Include="DataAugmentation\Scene\SceneMappingsUpdatedEvent.cs" /> - <Compile Include="DataAugmentation\Scene\ServicesProvider.cs" /> - <Compile Include="DataAugmentation\Scene\UpdateSceneMappingCommand.cs" /> - <Compile Include="DataAugmentation\Xem\Model\XemResult.cs" /> - <Compile Include="DataAugmentation\Xem\Model\XemSceneTvdbMapping.cs" /> - <Compile Include="DataAugmentation\Xem\Model\XemValues.cs" /> - <Compile Include="DataAugmentation\Xem\XemProxy.cs" /> - <Compile Include="DataAugmentation\Xem\XemService.cs" /> - <Compile Include="Datastore\BasicRepository.cs" /> - <Compile Include="Datastore\ConnectionStringFactory.cs" /> - <Compile Include="Datastore\Converters\BooleanIntConverter.cs" /> - <Compile Include="Datastore\Converters\DoubleConverter.cs" /> - <Compile Include="Datastore\Converters\EmbeddedDocumentConverter.cs" /> - <Compile Include="Datastore\Converters\EnumIntConverter.cs" /> - <Compile Include="Datastore\Converters\TimeSpanConverter.cs" /> - <Compile Include="Datastore\Converters\Int32Converter.cs" /> - <Compile Include="Datastore\Converters\GuidConverter.cs" /> - <Compile Include="Datastore\Converters\OsPathConverter.cs" /> - <Compile Include="Datastore\Converters\CommandConverter.cs" /> - <Compile Include="Datastore\Converters\ProviderSettingConverter.cs" /> - <Compile Include="Datastore\Converters\QualityIntConverter.cs" /> - <Compile Include="Datastore\Converters\UtcConverter.cs" /> - <Compile Include="Datastore\CorruptDatabaseException.cs" /> - <Compile Include="Datastore\Database.cs" /> - <Compile Include="Datastore\DbFactory.cs" /> - <Compile Include="Datastore\Events\ModelEvent.cs" /> - <Compile Include="Datastore\Extensions\MappingExtensions.cs" /> - <Compile Include="Datastore\Extensions\PagingSpecExtensions.cs" /> - <Compile Include="Datastore\Extensions\RelationshipExtensions.cs" /> - <Compile Include="Datastore\IEmbeddedDocument.cs" /> - <Compile Include="Datastore\LazyList.cs" /> - <Compile Include="Datastore\MainDatabase.cs" /> - <Compile Include="Datastore\LogDatabase.cs" /> - <Compile Include="Datastore\Migration\001_initial_setup.cs" /> - <Compile Include="Datastore\Migration\002_remove_tvrage_imdb_unique_constraint.cs" /> - <Compile Include="Datastore\Migration\003_remove_clean_title_from_scene_mapping.cs" /> - <Compile Include="Datastore\Migration\004_updated_history.cs" /> - <Compile Include="Datastore\Migration\005_added_eventtype_to_history.cs" /> - <Compile Include="Datastore\Migration\006_add_index_to_log_time.cs" /> - <Compile Include="Datastore\Migration\007_add_renameEpisodes_to_naming.cs" /> - <Compile Include="Datastore\Migration\008_remove_backlog.cs" /> - <Compile Include="Datastore\Migration\009_fix_renameEpisodes.cs" /> - <Compile Include="Datastore\Migration\010_add_monitored.cs" /> - <Compile Include="Datastore\Migration\011_remove_ignored.cs" /> - <Compile Include="Datastore\Migration\012_remove_custom_start_date.cs" /> - <Compile Include="Datastore\Migration\013_add_air_date_utc.cs" /> - <Compile Include="Datastore\Migration\014_drop_air_date.cs" /> - <Compile Include="Datastore\Migration\015_add_air_date_as_string.cs" /> - <Compile Include="Datastore\Migration\016_updated_imported_history_item.cs" /> - <Compile Include="Datastore\Migration\017_reset_scene_names.cs" /> - <Compile Include="Datastore\Migration\018_remove_duplicates.cs" /> - <Compile Include="Datastore\Migration\019_restore_unique_constraints.cs" /> - <Compile Include="Datastore\Migration\020_add_year_and_seasons_to_series.cs" /> - <Compile Include="Datastore\Migration\021_drop_seasons_table.cs" /> - <Compile Include="Datastore\Migration\022_move_indexer_to_generic_provider.cs" /> - <Compile Include="Datastore\Migration\023_add_config_contract_to_indexers.cs" /> - <Compile Include="Datastore\Migration\024_drop_tvdb_episodeid.cs" /> - <Compile Include="Datastore\Migration\025_move_notification_to_generic_provider.cs" /> - <Compile Include="Datastore\Migration\026_add_config_contract_to_notifications.cs" /> - <Compile Include="Datastore\Migration\027_fix_omgwtfnzbs.cs" /> - <Compile Include="Datastore\Migration\028_add_blacklist_table.cs" /> - <Compile Include="Datastore\Migration\029_add_formats_to_naming_config.cs" /> - <Compile Include="Datastore\Migration\030_add_season_folder_format_to_naming_config.cs" /> - <Compile Include="Datastore\Migration\031_delete_old_naming_config_columns.cs" /> - <Compile Include="Datastore\Migration\032_set_default_release_group.cs" /> - <Compile Include="Datastore\Migration\033_add_api_key_to_pushover.cs" /> - <Compile Include="Datastore\Migration\034_remove_series_contraints.cs" /> - <Compile Include="Datastore\Migration\035_add_series_folder_format_to_naming_config.cs" /> - <Compile Include="Datastore\Migration\036_update_with_quality_converters.cs" /> - <Compile Include="Datastore\Migration\037_add_configurable_qualities.cs" /> - <Compile Include="Datastore\Migration\038_add_on_upgrade_to_notifications.cs" /> - <Compile Include="Datastore\Migration\039_add_metadata_tables.cs" /> - <Compile Include="Datastore\Migration\040_add_metadata_to_episodes_and_series.cs" /> - <Compile Include="Datastore\Migration\041_fix_xbmc_season_images_metadata.cs" /> - <Compile Include="Datastore\Migration\042_add_download_clients_table.cs" /> - <Compile Include="Datastore\Migration\043_convert_config_to_download_clients.cs" /> - <Compile Include="Datastore\Migration\044_fix_xbmc_episode_metadata.cs" /> - <Compile Include="Datastore\Migration\045_add_indexes.cs" /> - <Compile Include="Datastore\Migration\046_fix_nzb_su_url.cs" /> - <Compile Include="Datastore\Migration\047_add_published_date_blacklist_column.cs" /> - <Compile Include="Datastore\Migration\048_add_title_to_scenemappings.cs" /> - <Compile Include="Datastore\Migration\049_fix_dognzb_url.cs" /> - <Compile Include="Datastore\Migration\050_add_hash_to_metadata_files.cs" /> - <Compile Include="Datastore\Migration\051_download_client_import.cs" /> - <Compile Include="Datastore\Migration\052_add_columns_for_anime.cs" /> - <Compile Include="Datastore\Migration\053_add_series_sorttitle.cs" /> - <Compile Include="Datastore\Migration\054_rename_profiles.cs" /> - <Compile Include="Datastore\Migration\055_drop_old_profile_columns.cs" /> - <Compile Include="Datastore\Migration\056_add_mediainfo_to_episodefile.cs" /> - <Compile Include="Datastore\Migration\057_convert_episode_file_path_to_relative.cs" /> - <Compile Include="Datastore\Migration\058_drop_epsiode_file_path.cs" /> - <Compile Include="Datastore\Migration\059_add_enable_options_to_indexers.cs" /> - <Compile Include="Datastore\Migration\060_remove_enable_from_indexers.cs" /> - <Compile Include="Datastore\Migration\061_clear_bad_scene_names.cs" /> - <Compile Include="Datastore\Migration\062_convert_quality_models.cs" /> - <Compile Include="Datastore\Migration\063_add_remotepathmappings.cs" /> - <Compile Include="Datastore\Migration\064_add_remove_method_from_logs.cs" /> - <Compile Include="Datastore\Migration\065_make_scene_numbering_nullable.cs" /> - <Compile Include="Datastore\Migration\066_add_tags.cs" /> - <Compile Include="Datastore\Migration\067_add_added_to_series.cs" /> - <Compile Include="Datastore\Migration\068_add_release_restrictions.cs" /> - <Compile Include="Datastore\Migration\069_quality_proper.cs" /> - <Compile Include="Datastore\Migration\070_delay_profile.cs" /> - <Compile Include="Datastore\Migration\107_remove_wombles.cs" /> - <Compile Include="Datastore\Migration\106_update_btn_url.cs" /> - <Compile Include="Datastore\Migration\096_disable_kickass.cs" /> - <Compile Include="Datastore\Migration\095_add_additional_episodes_index.cs" /> - <Compile Include="Datastore\Migration\104_remove_kickass.cs" /> - <Compile Include="Datastore\Migration\103_fix_metadata_file_extensions.cs" /> - <Compile Include="Datastore\Migration\101_add_ultrahd_quality_in_profiles.cs" /> - <Compile Include="Datastore\Migration\071_unknown_quality_in_profile.cs" /> - <Compile Include="Datastore\Migration\072_history_grabid.cs" /> - <Compile Include="Datastore\Migration\073_clear_ratings.cs" /> - <Compile Include="Datastore\Migration\074_disable_eztv.cs" /> - <Compile Include="Datastore\Migration\075_force_lib_update.cs" /> - <Compile Include="Datastore\Migration\076_add_users_table.cs" /> - <Compile Include="Datastore\Migration\077_add_add_options_to_series.cs" /> - <Compile Include="Datastore\Migration\078_add_commands_table.cs" /> - <Compile Include="Datastore\Migration\079_dedupe_tags.cs" /> - <Compile Include="Datastore\Migration\081_move_dot_prefix_to_transmission_category.cs" /> - <Compile Include="Datastore\Migration\082_add_fanzub_settings.cs" /> - <Compile Include="Datastore\Migration\083_additonal_blacklist_columns.cs" /> - <Compile Include="Datastore\Migration\084_update_quality_minmax_size.cs" /> - <Compile Include="Datastore\Migration\085_expand_transmission_urlbase.cs" /> - <Compile Include="Datastore\Migration\086_pushbullet_device_ids.cs" /> - <Compile Include="Datastore\Migration\087_remove_eztv.cs" /> - <Compile Include="Datastore\Migration\088_pushbullet_devices_channels_list.cs" /> - <Compile Include="Datastore\Migration\089_add_on_rename_to_notifcations.cs" /> - <Compile Include="Datastore\Migration\090_update_kickass_url.cs" /> - <Compile Include="Datastore\Migration\091_added_indexerstatus.cs" /> - <Compile Include="Datastore\Migration\093_naming_config_replace_characters.cs" /> - <Compile Include="Datastore\Migration\092_add_unverifiedscenenumbering.cs" /> - <Compile Include="Datastore\Migration\100_add_scene_season_number.cs" /> - <Compile Include="Datastore\Migration\099_extra_and_subtitle_files.cs" /> - <Compile Include="Datastore\Migration\094_add_tvmazeid.cs" /> - <Compile Include="Datastore\Migration\098_remove_titans_of_tv.cs"> - <SubType>Code</SubType> - </Compile> - <Compile Include="Datastore\Migration\105_rename_torrent_downloadstation.cs" /> - <Compile Include="Datastore\Migration\Framework\MigrationContext.cs" /> - <Compile Include="Datastore\Migration\Framework\MigrationController.cs" /> - <Compile Include="Datastore\Migration\Framework\MigrationDbFactory.cs" /> - <Compile Include="Datastore\Migration\Framework\MigrationExtension.cs" /> - <Compile Include="Datastore\Migration\Framework\MigrationLogger.cs" /> - <Compile Include="Datastore\Migration\Framework\MigrationOptions.cs" /> - <Compile Include="Datastore\Migration\Framework\MigrationType.cs" /> - <Compile Include="Datastore\Migration\Framework\NzbDroneMigrationBase.cs" /> - <Compile Include="Datastore\Migration\Framework\NzbDroneSqliteProcessor.cs" /> - <Compile Include="Datastore\Migration\Framework\NzbDroneSqliteProcessorFactory.cs" /> - <Compile Include="Datastore\Migration\Framework\SqliteSchemaDumper.cs" /> - <Compile Include="Datastore\Migration\Framework\SqliteSyntaxReader.cs" /> - <Compile Include="Datastore\ModelBase.cs" /> - <Compile Include="Datastore\ModelNotFoundException.cs" /> - <Compile Include="Datastore\PagingSpec.cs" /> - <Compile Include="Datastore\ResultSet.cs" /> - <Compile Include="Datastore\TableMapping.cs" /> - <Compile Include="DecisionEngine\Decision.cs" /> - <Compile Include="DecisionEngine\DownloadDecision.cs" /> - <Compile Include="DecisionEngine\DownloadDecisionComparer.cs" /> - <Compile Include="DecisionEngine\DownloadDecisionMaker.cs" /> - <Compile Include="DecisionEngine\DownloadDecisionPriorizationService.cs" /> - <Compile Include="DecisionEngine\IDecisionEngineSpecification.cs" /> - <Compile Include="DecisionEngine\IRejectWithReason.cs" /> - <Compile Include="DecisionEngine\QualityUpgradableSpecification.cs" /> - <Compile Include="DecisionEngine\Rejection.cs" /> - <Compile Include="DecisionEngine\RejectionType.cs" /> - <Compile Include="DecisionEngine\SameEpisodesSpecification.cs" /> - <Compile Include="DecisionEngine\Specifications\AcceptableSizeSpecification.cs" /> - <Compile Include="DecisionEngine\Specifications\BlacklistSpecification.cs" /> - <Compile Include="DecisionEngine\Specifications\AnimeVersionUpgradeSpecification.cs" /> - <Compile Include="DecisionEngine\Specifications\FullSeasonSpecification.cs" /> - <Compile Include="DecisionEngine\Specifications\CutoffSpecification.cs" /> - <Compile Include="DecisionEngine\Specifications\ProtocolSpecification.cs" /> - <Compile Include="DecisionEngine\Specifications\LanguageSpecification.cs" /> - <Compile Include="DecisionEngine\Specifications\QueueSpecification.cs" /> - <Compile Include="DecisionEngine\Specifications\ReleaseRestrictionsSpecification.cs" /> - <Compile Include="DecisionEngine\Specifications\NotSampleSpecification.cs" /> - <Compile Include="DecisionEngine\Specifications\QualityAllowedByProfileSpecification.cs" /> - <Compile Include="DecisionEngine\Specifications\MinimumAgeSpecification.cs" /> - <Compile Include="DecisionEngine\Specifications\RetentionSpecification.cs" /> - <Compile Include="DecisionEngine\Specifications\RssSync\DelaySpecification.cs" /> - <Compile Include="DecisionEngine\Specifications\RssSync\HistorySpecification.cs" /> - <Compile Include="DecisionEngine\Specifications\RssSync\MonitoredEpisodeSpecification.cs" /> - <Compile Include="DecisionEngine\Specifications\RssSync\ProperSpecification.cs" /> - <Compile Include="DecisionEngine\Specifications\Search\DailyEpisodeMatchSpecification.cs" /> - <Compile Include="DecisionEngine\Specifications\Search\EpisodeRequestedSpecification.cs" /> - <Compile Include="DecisionEngine\Specifications\Search\SeasonMatchSpecification.cs" /> - <Compile Include="DecisionEngine\Specifications\Search\SeriesSpecification.cs" /> - <Compile Include="DecisionEngine\Specifications\Search\SingleEpisodeSearchMatchSpecification.cs" /> - <Compile Include="DecisionEngine\Specifications\Search\TorrentSeedingSpecification.cs" /> - <Compile Include="DecisionEngine\Specifications\SameEpisodesGrabSpecification.cs" /> - <Compile Include="DecisionEngine\Specifications\RawDiskSpecification.cs" /> - <Compile Include="DecisionEngine\Specifications\UpgradeDiskSpecification.cs" /> - <Compile Include="DiskSpace\DiskSpace.cs" /> - <Compile Include="DiskSpace\DiskSpaceService.cs" /> - <Compile Include="Download\CheckForFinishedDownloadCommand.cs" /> - <Compile Include="Download\Clients\Blackhole\ScanWatchFolder.cs" /> - <Compile Include="Download\Clients\Blackhole\WatchFolderItem.cs" /> - <Compile Include="Download\Clients\Deluge\Deluge.cs" /> - <Compile Include="Download\Clients\Deluge\DelugeError.cs" /> - <Compile Include="Download\Clients\Deluge\DelugeException.cs" /> - <Compile Include="Download\Clients\Deluge\DelugeProxy.cs" /> - <Compile Include="Download\Clients\Deluge\DelugeSettings.cs" /> - <Compile Include="Download\Clients\Deluge\DelugeTorrent.cs" /> - <Compile Include="Download\Clients\Deluge\DelugeTorrentStatus.cs" /> - <Compile Include="Download\Clients\Deluge\DelugePriority.cs" /> - <Compile Include="Download\Clients\Deluge\DelugeUpdateUIResult.cs" /> - <Compile Include="Download\Clients\DownloadClientAuthenticationException.cs" /> - <Compile Include="Download\Clients\DownloadClientException.cs" /> - <Compile Include="Download\Clients\DownloadStation\TorrentDownloadStation.cs" /> - <Compile Include="Download\Clients\DownloadStation\Proxies\DownloadStationProxy.cs" /> - <Compile Include="Download\Clients\DownloadStation\DownloadStationSettings.cs" /> - <Compile Include="Download\Clients\DownloadStation\DownloadStationTask.cs" /> - <Compile Include="Download\Clients\DownloadStation\DownloadStationTaskAdditional.cs" /> - <Compile Include="Download\Clients\DownloadStation\Proxies\DSMInfoProxy.cs" /> - <Compile Include="Download\Clients\DownloadStation\Proxies\FileStationProxy.cs" /> - <Compile Include="Download\Clients\DownloadStation\Proxies\DiskStationProxyBase.cs" /> - <Compile Include="Download\Clients\DownloadStation\Responses\DiskStationAuthResponse.cs" /> - <Compile Include="Download\Clients\DownloadStation\DownloadStationTaskFile.cs" /> - <Compile Include="Download\Clients\DownloadStation\Responses\DSMInfoResponse.cs" /> - <Compile Include="Download\Clients\DownloadStation\Responses\FileStationListFileInfoResponse.cs" /> - <Compile Include="Download\Clients\DownloadStation\Responses\FileStationListResponse.cs" /> - <Compile Include="Download\Clients\DownloadStation\Responses\DiskStationError.cs" /> - <Compile Include="Download\Clients\DownloadStation\Responses\DiskStationInfoResponse.cs" /> - <Compile Include="Download\Clients\DownloadStation\Responses\DiskStationResponse.cs" /> - <Compile Include="Download\Clients\DownloadStation\Responses\DownloadStationTaskInfoResponse.cs" /> - <Compile Include="Download\Clients\DownloadStation\DiskStationApiInfo.cs" /> - <Compile Include="Download\Clients\DownloadStation\SerialNumberProvider.cs" /> - <Compile Include="Download\Clients\DownloadStation\SharedFolderMapping.cs" /> - <Compile Include="Download\Clients\DownloadStation\SharedFolderResolver.cs" /> - <Compile Include="Download\Clients\DownloadStation\DiskStationApi.cs" /> - <Compile Include="Download\Clients\DownloadStation\UsenetDownloadStation.cs" /> - <Compile Include="Download\Clients\Hadouken\Hadouken.cs" /> - <Compile Include="Download\Clients\Hadouken\HadoukenProxy.cs" /> - <Compile Include="Download\Clients\Hadouken\HadoukenSettings.cs" /> - <Compile Include="Download\Clients\Hadouken\Models\HadoukenTorrentResponse.cs" /> - <Compile Include="Download\Clients\Hadouken\Models\HadoukenTorrentState.cs" /> - <Compile Include="Download\Clients\Hadouken\Models\HadoukenSystemInfo.cs" /> - <Compile Include="Download\Clients\Hadouken\Models\HadoukenTorrent.cs" /> - <Compile Include="Download\Clients\Nzbget\ErrorModel.cs" /> - <Compile Include="Download\Clients\Nzbget\JsonError.cs" /> - <Compile Include="Download\Clients\Nzbget\Nzbget.cs" /> - <Compile Include="Download\Clients\Nzbget\NzbgetCategory.cs" /> - <Compile Include="Download\Clients\Nzbget\NzbgetConfigItem.cs" /> - <Compile Include="Download\Clients\Nzbget\NzbgetGlobalStatus.cs" /> - <Compile Include="Download\Clients\Nzbget\NzbgetHistoryItem.cs" /> - <Compile Include="Download\Clients\Nzbget\NzbgetParameter.cs" /> - <Compile Include="Download\Clients\Nzbget\NzbgetPostQueueItem.cs" /> - <Compile Include="Download\Clients\Nzbget\NzbgetPriority.cs" /> - <Compile Include="Download\Clients\Nzbget\NzbgetProxy.cs" /> - <Compile Include="Download\Clients\Nzbget\NzbgetQueueItem.cs" /> - <Compile Include="Download\Clients\Nzbget\NzbgetResponse.cs" /> - <Compile Include="Download\Clients\Nzbget\NzbgetSettings.cs" /> - <Compile Include="Download\Clients\NzbVortex\JsonConverters\NzbVortexLoginResultTypeConverter.cs" /> - <Compile Include="Download\Clients\NzbVortex\JsonConverters\NzbVortexResultTypeConverter.cs" /> - <Compile Include="Download\Clients\NzbVortex\NzbVortex.cs"> - <SubType>Code</SubType> - </Compile> - <Compile Include="Download\Clients\NzbVortex\NzbVortexGroup.cs" /> - <Compile Include="Download\Clients\NzbVortex\NzbVortexNotLoggedInException.cs" /> - <Compile Include="Download\Clients\NzbVortex\NzbVortexAuthenticationException.cs" /> - <Compile Include="Download\Clients\NzbVortex\NzbVortexJsonError.cs" /> - <Compile Include="Download\Clients\NzbVortex\NzbVortexPriority.cs" /> - <Compile Include="Download\Clients\NzbVortex\NzbVortexProxy.cs" /> - <Compile Include="Download\Clients\NzbVortex\NzbVortexFile.cs" /> - <Compile Include="Download\Clients\NzbVortex\NzbVortexQueueItem.cs" /> - <Compile Include="Download\Clients\NzbVortex\NzbVortexLoginResultType.cs" /> - <Compile Include="Download\Clients\NzbVortex\NzbVortexStateType.cs" /> - <Compile Include="Download\Clients\NzbVortex\NzbVortexResultType.cs" /> - <Compile Include="Download\Clients\NzbVortex\NzbVortexSettings.cs" /> - <Compile Include="Download\Clients\NzbVortex\Responses\NzbVortexAddResponse.cs" /> - <Compile Include="Download\Clients\NzbVortex\Responses\NzbVortexAuthNonceResponse.cs" /> - <Compile Include="Download\Clients\NzbVortex\Responses\NzbVortexAuthResponse.cs" /> - <Compile Include="Download\Clients\NzbVortex\Responses\NzbVortexFilesResponse.cs" /> - <Compile Include="Download\Clients\NzbVortex\Responses\NzbVortexGroupResponse.cs" /> - <Compile Include="Download\Clients\NzbVortex\Responses\NzbVortexQueueResponse.cs" /> - <Compile Include="Download\Clients\NzbVortex\Responses\NzbVortexResponseBase.cs" /> - <Compile Include="Download\Clients\NzbVortex\Responses\NzbVortexRetryResponse.cs" /> - <Compile Include="Download\Clients\NzbVortex\Responses\NzbVortexApiVersionResponse.cs" /> - <Compile Include="Download\Clients\NzbVortex\Responses\NzbVortexVersionResponse.cs" /> - <Compile Include="Download\Clients\Pneumatic\Pneumatic.cs" /> - <Compile Include="Download\Clients\Pneumatic\PneumaticSettings.cs" /> - <Compile Include="Download\Clients\QBittorrent\QBittorrentPreferences.cs" /> - <Compile Include="Download\Clients\rTorrent\RTorrentDirectoryValidator.cs" /> - <Compile Include="Download\Clients\QBittorrent\QBittorrent.cs" /> - <Compile Include="Download\Clients\QBittorrent\QBittorrentPriority.cs" /> - <Compile Include="Download\Clients\QBittorrent\QBittorrentProxy.cs" /> - <Compile Include="Download\Clients\QBittorrent\QBittorrentSettings.cs" /> - <Compile Include="Download\Clients\QBittorrent\QBittorrentTorrent.cs" /> - <Compile Include="Download\Clients\Sabnzbd\JsonConverters\SabnzbdPriorityTypeConverter.cs" /> - <Compile Include="Download\Clients\Sabnzbd\JsonConverters\SabnzbdStringArrayConverter.cs" /> - <Compile Include="Download\Clients\Sabnzbd\JsonConverters\SabnzbdQueueTimeConverter.cs" /> - <Compile Include="Download\Clients\Sabnzbd\Responses\SabnzbdRetryResponse.cs" /> - <Compile Include="Download\Clients\Sabnzbd\Responses\SabnzbdAddResponse.cs" /> - <Compile Include="Download\Clients\Sabnzbd\Responses\SabnzbdCategoryResponse.cs" /> - <Compile Include="Download\Clients\Sabnzbd\Responses\SabnzbdConfigResponse.cs" /> - <Compile Include="Download\Clients\Sabnzbd\Responses\SabnzbdVersionResponse.cs" /> - <Compile Include="Download\Clients\Sabnzbd\Sabnzbd.cs" /> - <Compile Include="Download\Clients\Sabnzbd\SabnzbdCategory.cs" /> - <Compile Include="Download\Clients\Sabnzbd\SabnzbdDownloadStatus.cs" /> - <Compile Include="Download\Clients\Sabnzbd\SabnzbdHistory.cs" /> - <Compile Include="Download\Clients\Sabnzbd\SabnzbdHistoryItem.cs" /> - <Compile Include="Download\Clients\Sabnzbd\SabnzbdJsonError.cs" /> - <Compile Include="Download\Clients\Sabnzbd\SabnzbdPriority.cs" /> - <Compile Include="Download\Clients\Sabnzbd\SabnzbdProxy.cs" /> - <Compile Include="Download\Clients\Sabnzbd\SabnzbdQueue.cs" /> - <Compile Include="Download\Clients\Sabnzbd\SabnzbdQueueItem.cs" /> - <Compile Include="Download\Clients\Sabnzbd\SabnzbdSettings.cs" /> - <Compile Include="Download\Clients\Blackhole\TorrentBlackhole.cs" /> - <Compile Include="Download\Clients\Blackhole\TorrentBlackholeSettings.cs" /> - <Compile Include="Download\Clients\TorrentSeedConfiguration.cs" /> - <Compile Include="Download\Clients\rTorrent\RTorrent.cs" /> - <Compile Include="Download\Clients\rTorrent\RTorrentPriority.cs" /> - <Compile Include="Download\Clients\rTorrent\RTorrentProxy.cs" /> - <Compile Include="Download\Clients\rTorrent\RTorrentSettings.cs" /> - <Compile Include="Download\Clients\rTorrent\RTorrentTorrent.cs" /> - <Compile Include="Download\Clients\Transmission\Transmission.cs" /> - <Compile Include="Download\Clients\Transmission\TransmissionBase.cs" /> - <Compile Include="Download\Clients\Transmission\TransmissionException.cs" /> - <Compile Include="Download\Clients\Transmission\TransmissionProxy.cs" /> - <Compile Include="Download\Clients\Transmission\TransmissionResponse.cs" /> - <Compile Include="Download\Clients\Transmission\TransmissionSettings.cs" /> - <Compile Include="Download\Clients\Transmission\TransmissionTorrent.cs" /> - <Compile Include="Download\Clients\Transmission\TransmissionTorrentStatus.cs" /> - <Compile Include="Download\Clients\Transmission\TransmissionPriority.cs" /> - <Compile Include="Download\Clients\Blackhole\UsenetBlackhole.cs" /> - <Compile Include="Download\Clients\Blackhole\UsenetBlackholeSettings.cs" /> - <Compile Include="Download\Clients\uTorrent\UTorrentPriority.cs" /> - <Compile Include="Download\Clients\uTorrent\UTorrent.cs" /> - <Compile Include="Download\Clients\uTorrent\UTorrentProxy.cs" /> - <Compile Include="Download\Clients\uTorrent\UTorrentResponse.cs" /> - <Compile Include="Download\Clients\uTorrent\UTorrentSettings.cs" /> - <Compile Include="Download\Clients\uTorrent\UTorrentTorrent.cs" /> - <Compile Include="Download\Clients\uTorrent\UTorrentTorrentCache.cs" /> - <Compile Include="Download\Clients\uTorrent\UTorrentTorrentStatus.cs" /> - <Compile Include="Download\Clients\Vuze\Vuze.cs" /> - <Compile Include="Download\CompletedDownloadService.cs" /> - <Compile Include="Download\DownloadEventHub.cs" /> - <Compile Include="Download\TrackedDownloads\DownloadMonitoringService.cs" /> - <Compile Include="Download\TrackedDownloads\TrackedDownload.cs" /> - <Compile Include="Download\TrackedDownloads\TrackedDownloadService.cs" /> - <Compile Include="Download\TrackedDownloads\TrackedDownloadStatusMessage.cs" /> - <Compile Include="Download\TrackedDownloads\TrackedDownloadRefreshedEvent.cs" /> - <Compile Include="Download\UsenetClientBase.cs" /> - <Compile Include="Download\TorrentClientBase.cs" /> - <Compile Include="Download\DownloadClientBase.cs" /> - <Compile Include="Download\DownloadClientDefinition.cs" /> - <Compile Include="Download\DownloadClientFactory.cs" /> - <Compile Include="Download\DownloadClientItem.cs" /> - <Compile Include="Download\DownloadClientProvider.cs" /> - <Compile Include="Download\DownloadClientRepository.cs" /> - <Compile Include="Download\DownloadClientStatus.cs" /> - <Compile Include="Download\DownloadClientType.cs" /> - <Compile Include="Download\DownloadFailedEvent.cs" /> - <Compile Include="Download\DownloadItemStatus.cs" /> - <Compile Include="Download\DownloadService.cs" /> - <Compile Include="Download\EpisodeGrabbedEvent.cs" /> - <Compile Include="Download\FailedDownloadService.cs" /> - <Compile Include="Download\IDownloadClient.cs" /> - <Compile Include="Download\Pending\PendingRelease.cs" /> - <Compile Include="Download\Pending\PendingReleaseRepository.cs" /> - <Compile Include="Download\Pending\PendingReleaseService.cs" /> - <Compile Include="Download\Pending\PendingReleasesUpdatedEvent.cs" /> - <Compile Include="Download\ProcessDownloadDecisions.cs" /> - <Compile Include="Download\ProcessedDecisions.cs" /> - <Compile Include="Download\RedownloadFailedDownloadService.cs" /> - <Compile Include="Exceptions\BadRequestException.cs" /> - <Compile Include="Exceptions\DownstreamException.cs" /> - <Compile Include="Exceptions\NzbDroneClientException.cs" /> - <Compile Include="Exceptions\SeriesNotFoundException.cs" /> - <Compile Include="Exceptions\ReleaseDownloadException.cs" /> - <Compile Include="Exceptions\StatusCodeToExceptions.cs" /> - <Compile Include="Extras\ExistingExtraFileService.cs" /> - <Compile Include="Extras\Files\ExtraFile.cs" /> - <Compile Include="Extras\Files\ExtraFileManager.cs" /> - <Compile Include="Extras\Files\ExtraFileService.cs" /> - <Compile Include="Extras\Files\ExtraFileRepository.cs" /> - <Compile Include="Extras\ExtraService.cs" /> - <Compile Include="Extras\IImportExistingExtraFiles.cs" /> - <Compile Include="Extras\ImportExistingExtraFileFilterResult.cs" /> - <Compile Include="Extras\ImportExistingExtraFilesBase.cs" /> - <Compile Include="Extras\Metadata\Files\MetadataFile.cs" /> - <Compile Include="Extras\Metadata\Files\MetadataFileRepository.cs" /> - <Compile Include="Extras\Metadata\Files\MetadataFileService.cs" /> - <Compile Include="Extras\Others\ExistingOtherExtraImporter.cs" /> - <Compile Include="Extras\Others\OtherExtraFileRepository.cs" /> - <Compile Include="Extras\Others\OtherExtraFileService.cs" /> - <Compile Include="Extras\Others\OtherExtraFile.cs" /> - <Compile Include="Extras\Others\OtherExtraService.cs" /> - <Compile Include="Extras\Subtitles\ExistingSubtitleImporter.cs" /> - <Compile Include="Extras\Subtitles\SubtitleFileRepository.cs" /> - <Compile Include="Extras\Subtitles\SubtitleFileService.cs" /> - <Compile Include="Extras\Subtitles\SubtitleFile.cs" /> - <Compile Include="Extras\Subtitles\SubtitleFileExtensions.cs" /> - <Compile Include="Extras\Subtitles\ImportedSubtitleFiles.cs" /> - <Compile Include="Extras\Subtitles\SubtitleService.cs" /> - <Compile Include="Fluent.cs" /> - <Compile Include="HealthCheck\CheckHealthCommand.cs" /> - <Compile Include="HealthCheck\Checks\AppDataLocationCheck.cs" /> - <Compile Include="HealthCheck\Checks\DownloadClientCheck.cs" /> - <Compile Include="HealthCheck\Checks\DroneFactoryCheck.cs" /> - <Compile Include="HealthCheck\Checks\ImportMechanismCheck.cs" /> - <Compile Include="HealthCheck\Checks\IndexerStatusCheck.cs" /> - <Compile Include="HealthCheck\Checks\IndexerCheck.cs" /> - <Compile Include="HealthCheck\Checks\MediaInfoDllCheck.cs" /> - <Compile Include="HealthCheck\Checks\MonoVersionCheck.cs" /> - <Compile Include="HealthCheck\Checks\ProxyCheck.cs" /> - <Compile Include="HealthCheck\Checks\RootFolderCheck.cs" /> - <Compile Include="HealthCheck\Checks\UpdateCheck.cs" /> - <Compile Include="HealthCheck\HealthCheck.cs" /> - <Compile Include="HealthCheck\HealthCheckBase.cs" /> - <Compile Include="HealthCheck\HealthCheckCompleteEvent.cs" /> - <Compile Include="HealthCheck\HealthCheckService.cs" /> - <Compile Include="HealthCheck\IProvideHealthCheck.cs" /> - <Compile Include="History\History.cs" /> - <Compile Include="History\HistoryRepository.cs" /> - <Compile Include="History\HistoryService.cs" /> - <Compile Include="Housekeeping\Housekeepers\CleanupAdditionalUsers.cs" /> - <Compile Include="Housekeeping\Housekeepers\CleanupAdditionalNamingSpecs.cs" /> - <Compile Include="Housekeeping\Housekeepers\CleanupCommandQueue.cs" /> - <Compile Include="Housekeeping\Housekeepers\CleanupAbsolutePathMetadataFiles.cs" /> - <Compile Include="Housekeeping\Housekeepers\CleanupDuplicateMetadataFiles.cs" /> - <Compile Include="Housekeeping\Housekeepers\CleanupOrphanedBlacklist.cs" /> - <Compile Include="Housekeeping\Housekeepers\CleanupOrphanedEpisodeFiles.cs" /> - <Compile Include="Housekeeping\Housekeepers\CleanupOrphanedEpisodes.cs" /> - <Compile Include="Housekeeping\Housekeepers\CleanupOrphanedIndexerStatus.cs" /> - <Compile Include="Housekeeping\Housekeepers\CleanupOrphanedHistoryItems.cs" /> - <Compile Include="Housekeeping\Housekeepers\CleanupOrphanedMetadataFiles.cs" /> - <Compile Include="Housekeeping\Housekeepers\CleanupUnusedTags.cs" /> - <Compile Include="Housekeeping\Housekeepers\CleanupOrphanedPendingReleases.cs" /> - <Compile Include="Housekeeping\Housekeepers\DeleteBadMediaCovers.cs" /> - <Compile Include="Housekeeping\Housekeepers\FixFutureRunScheduledTasks.cs" /> - <Compile Include="Housekeeping\Housekeepers\TrimLogDatabase.cs" /> - <Compile Include="Housekeeping\Housekeepers\UpdateCleanTitleForSeries.cs" /> - <Compile Include="Housekeeping\HousekeepingCommand.cs" /> - <Compile Include="Housekeeping\HousekeepingService.cs" /> - <Compile Include="Housekeeping\IHousekeepingTask.cs" /> - <Compile Include="Http\CloudFlare\CloudFlareCaptchaException.cs" /> - <Compile Include="Http\CloudFlare\CloudFlareCaptchaRequest.cs" /> - <Compile Include="Http\CloudFlare\CloudFlareHttpInterceptor.cs" /> - <Compile Include="Http\HttpProxySettingsProvider.cs" /> - <Compile Include="Http\TorcacheHttpInterceptor.cs" /> - <Compile Include="Indexers\BitMeTv\BitMeTv.cs" /> - <Compile Include="Indexers\BitMeTv\BitMeTvSettings.cs" /> - <Compile Include="Indexers\BitMeTv\BitMeTvRequestGenerator.cs" /> - <Compile Include="Indexers\BroadcastheNet\BroadcastheNetRequestGenerator.cs" /> - <Compile Include="Indexers\BroadcastheNet\BroadcastheNet.cs" /> - <Compile Include="Indexers\BroadcastheNet\BroadcastheNetSettings.cs" /> - <Compile Include="Indexers\BroadcastheNet\BroadcastheNetParser.cs" /> - <Compile Include="Indexers\BroadcastheNet\BroadcastheNetTorrent.cs" /> - <Compile Include="Indexers\BroadcastheNet\BroadcastheNetTorrentQuery.cs" /> - <Compile Include="Indexers\BroadcastheNet\BroadcastheNetTorrents.cs" /> - <Compile Include="Indexers\DownloadProtocol.cs" /> - <Compile Include="Indexers\Exceptions\ApiKeyException.cs" /> - <Compile Include="Indexers\Exceptions\IndexerException.cs" /> - <Compile Include="Indexers\Exceptions\RequestLimitReachedException.cs" /> - <Compile Include="Indexers\Exceptions\UnsupportedFeedException.cs" /> - <Compile Include="Indexers\EzrssTorrentRssParser.cs" /> - <Compile Include="Indexers\Fanzub\Fanzub.cs" /> - <Compile Include="Indexers\Fanzub\FanzubRequestGenerator.cs" /> - <Compile Include="Indexers\Fanzub\FanzubSettings.cs" /> - <Compile Include="Indexers\FetchAndParseRssService.cs" /> - <Compile Include="Indexers\HDBits\HDBits.cs" /> - <Compile Include="Indexers\HDBits\HDBitsApi.cs" /> - <Compile Include="Indexers\HDBits\HDBitsParser.cs" /> - <Compile Include="Indexers\HDBits\HDBitsRequestGenerator.cs" /> - <Compile Include="Indexers\HDBits\HDBitsSettings.cs" /> - <Compile Include="Indexers\IIndexer.cs" /> - <Compile Include="Indexers\IIndexerRequestGenerator.cs" /> - <Compile Include="Indexers\IndexerBase.cs" /> - <Compile Include="Indexers\IndexerDefinition.cs" /> - <Compile Include="Indexers\IndexerFactory.cs" /> - <Compile Include="Indexers\IndexerPageableRequest.cs" /> - <Compile Include="Indexers\IndexerPageableRequestChain.cs" /> - <Compile Include="Indexers\IndexerStatusRepository.cs" /> - <Compile Include="Indexers\IndexerRepository.cs" /> - <Compile Include="Indexers\IndexerRequest.cs" /> - <Compile Include="Indexers\IndexerResponse.cs" /> - <Compile Include="Indexers\IndexerSettingUpdatedEvent.cs" /> - <Compile Include="Indexers\IndexerStatus.cs" /> - <Compile Include="Indexers\IndexerStatusService.cs" /> - <Compile Include="Indexers\IProcessIndexerResponse.cs" /> - <Compile Include="Indexers\IPTorrents\IPTorrentsRequestGenerator.cs" /> - <Compile Include="Indexers\IPTorrents\IPTorrents.cs" /> - <Compile Include="Indexers\IPTorrents\IPTorrentsSettings.cs" /> - <Compile Include="Indexers\Newznab\Newznab.cs" /> - <Compile Include="Indexers\Newznab\NewznabCapabilities.cs" /> - <Compile Include="Indexers\Newznab\NewznabCapabilitiesProvider.cs" /> - <Compile Include="Indexers\Newznab\NewznabException.cs" /> - <Compile Include="Indexers\Newznab\NewznabRequestGenerator.cs" /> - <Compile Include="Indexers\Newznab\NewznabRssParser.cs" /> - <Compile Include="Indexers\Newznab\NewznabSettings.cs" /> - <Compile Include="Indexers\Exceptions\SizeParsingException.cs" /> - <Compile Include="Indexers\Nyaa\NyaaRequestGenerator.cs" /> - <Compile Include="Indexers\Omgwtfnzbs\OmgwtfnzbsRequestGenerator.cs" /> - <Compile Include="Indexers\Nyaa\Nyaa.cs" /> - <Compile Include="Indexers\Nyaa\NyaaSettings.cs" /> - <Compile Include="Indexers\Omgwtfnzbs\Omgwtfnzbs.cs" /> - <Compile Include="Indexers\Omgwtfnzbs\OmgwtfnzbsRssParser.cs" /> - <Compile Include="Indexers\Omgwtfnzbs\OmgwtfnzbsSettings.cs" /> - <Compile Include="Indexers\HttpIndexerBase.cs" /> - <Compile Include="Indexers\Rarbg\Rarbg.cs" /> - <Compile Include="Indexers\Rarbg\RarbgRequestGenerator.cs" /> - <Compile Include="Indexers\Rarbg\RarbgResponse.cs" /> - <Compile Include="Indexers\Rarbg\RarbgSettings.cs" /> - <Compile Include="Indexers\Rarbg\RarbgParser.cs" /> - <Compile Include="Indexers\Rarbg\RarbgTokenProvider.cs" /> - <Compile Include="Indexers\RssIndexerRequestGenerator.cs" /> - <Compile Include="Indexers\RssParser.cs" /> - <Compile Include="Indexers\RssSyncCommand.cs" /> - <Compile Include="Indexers\RssSyncCompleteEvent.cs" /> - <Compile Include="Indexers\RssSyncService.cs" /> - <Compile Include="Indexers\Torrentleech\TorrentleechRequestGenerator.cs" /> - <Compile Include="Indexers\Torrentleech\Torrentleech.cs" /> - <Compile Include="Indexers\Torrentleech\TorrentleechSettings.cs" /> - <Compile Include="Indexers\TorrentRss\TorrentRssIndexer.cs" /> - <Compile Include="Indexers\TorrentRss\TorrentRssIndexerParserSettings.cs" /> - <Compile Include="Indexers\TorrentRss\TorrentRssIndexerRequestGenerator.cs" /> - <Compile Include="Indexers\TorrentRss\TorrentRssIndexerSettings.cs" /> - <Compile Include="Indexers\TorrentRss\TorrentRssParserFactory.cs" /> - <Compile Include="Indexers\TorrentRss\TorrentRssSettingsDetector.cs" /> - <Compile Include="Indexers\TorrentRssParser.cs" /> - <Compile Include="Indexers\Torznab\Torznab.cs" /> - <Compile Include="Indexers\Torznab\TorznabException.cs" /> - <Compile Include="Indexers\Torznab\TorznabRssParser.cs" /> - <Compile Include="Indexers\Torznab\TorznabSettings.cs" /> - <Compile Include="Indexers\XElementExtensions.cs" /> - <Compile Include="IndexerSearch\Definitions\AnimeEpisodeSearchCriteria.cs" /> - <Compile Include="IndexerSearch\Definitions\DailyEpisodeSearchCriteria.cs" /> - <Compile Include="IndexerSearch\Definitions\SearchCriteriaBase.cs" /> - <Compile Include="IndexerSearch\Definitions\SeasonSearchCriteria.cs" /> - <Compile Include="IndexerSearch\Definitions\SingleEpisodeSearchCriteria.cs" /> - <Compile Include="IndexerSearch\Definitions\SpecialEpisodeSearchCriteria.cs" /> - <Compile Include="IndexerSearch\EpisodeSearchCommand.cs" /> - <Compile Include="IndexerSearch\EpisodeSearchService.cs" /> - <Compile Include="IndexerSearch\MissingEpisodeSearchCommand.cs" /> - <Compile Include="IndexerSearch\NzbSearchService.cs" /> - <Compile Include="IndexerSearch\SeasonSearchCommand.cs" /> - <Compile Include="IndexerSearch\SeasonSearchService.cs" /> - <Compile Include="IndexerSearch\SeriesSearchCommand.cs" /> - <Compile Include="IndexerSearch\SeriesSearchService.cs" /> - <Compile Include="Instrumentation\Commands\ClearLogCommand.cs" /> - <Compile Include="Instrumentation\Commands\DeleteLogFilesCommand.cs" /> - <Compile Include="Instrumentation\Commands\DeleteUpdateLogFilesCommand.cs" /> - <Compile Include="Instrumentation\DatabaseTarget.cs" /> - <Compile Include="Instrumentation\DeleteLogFilesService.cs" /> - <Compile Include="Instrumentation\Log.cs" /> - <Compile Include="Instrumentation\LogRepository.cs" /> - <Compile Include="Instrumentation\LogService.cs" /> - <Compile Include="Instrumentation\ReconfigureLogging.cs" /> - <Compile Include="Instrumentation\SlowRunningAsyncTargetWrapper.cs" /> - <Compile Include="Jobs\ScheduledTaskRepository.cs" /> - <Compile Include="Jobs\ScheduledTask.cs" /> - <Compile Include="Jobs\Scheduler.cs" /> - <Compile Include="Jobs\TaskManager.cs" /> - <Compile Include="Lifecycle\ApplicationShutdownRequested.cs" /> - <Compile Include="Lifecycle\ApplicationStartedEvent.cs" /> - <Compile Include="Lifecycle\Commands\RestartCommand.cs" /> - <Compile Include="Lifecycle\Commands\ShutdownCommand.cs" /> - <Compile Include="Lifecycle\LifecycleService.cs" /> - <Compile Include="MediaCover\CoverAlreadyExistsSpecification.cs" /> - <Compile Include="MediaCover\GdiPlusInterop.cs" /> - <Compile Include="MediaCover\MediaCover.cs" /> - <Compile Include="MediaCover\ImageResizer.cs" /> - <Compile Include="MediaCover\MediaCoverService.cs" /> - <Compile Include="MediaCover\MediaCoversUpdatedEvent.cs" /> - <Compile Include="MediaFiles\Commands\BackendCommandAttribute.cs" /> - <Compile Include="MediaFiles\Commands\CleanUpRecycleBinCommand.cs" /> - <Compile Include="MediaFiles\Commands\DownloadedEpisodesScanCommand.cs" /> - <Compile Include="MediaFiles\EpisodeImport\ImportMode.cs" /> - <Compile Include="MediaFiles\Commands\RenameFilesCommand.cs" /> - <Compile Include="MediaFiles\Commands\RenameSeriesCommand.cs" /> - <Compile Include="MediaFiles\Commands\RescanSeriesCommand.cs" /> - <Compile Include="MediaFiles\DeleteMediaFileReason.cs" /> - <Compile Include="MediaFiles\DiskScanService.cs"> - <SubType>Code</SubType> - </Compile> - <Compile Include="MediaFiles\DownloadedEpisodesImportService.cs"> - <SubType>Code</SubType> - </Compile> - <Compile Include="MediaFiles\DownloadedEpisodesCommandService.cs" /> - <Compile Include="MediaFiles\EpisodeFile.cs" /> - <Compile Include="MediaFiles\EpisodeFileMoveResult.cs" /> - <Compile Include="MediaFiles\EpisodeFileMovingService.cs" /> - <Compile Include="MediaFiles\EpisodeImport\ImportResult.cs" /> - <Compile Include="MediaFiles\EpisodeImport\IImportDecisionEngineSpecification.cs" /> - <Compile Include="MediaFiles\EpisodeImport\ImportApprovedEpisodes.cs" /> - <Compile Include="MediaFiles\EpisodeImport\ImportDecision.cs" /> - <Compile Include="MediaFiles\EpisodeImport\ImportDecisionMaker.cs" /> - <Compile Include="MediaFiles\EpisodeImport\ImportResultType.cs" /> - <Compile Include="MediaFiles\EpisodeImport\Manual\ManualImportFile.cs" /> - <Compile Include="MediaFiles\EpisodeImport\Manual\ManualImportCommand.cs" /> - <Compile Include="MediaFiles\EpisodeImport\Manual\ManualImportItem.cs" /> - <Compile Include="MediaFiles\EpisodeImport\Manual\ManualImportService.cs" /> - <Compile Include="MediaFiles\EpisodeImport\DetectSample.cs" /> - <Compile Include="MediaFiles\EpisodeImport\Manual\ManuallyImportedFile.cs" /> - <Compile Include="MediaFiles\EpisodeImport\Specifications\FreeSpaceSpecification.cs" /> - <Compile Include="MediaFiles\EpisodeImport\Specifications\MatchesFolderSpecification.cs" /> - <Compile Include="MediaFiles\EpisodeImport\Specifications\FullSeasonSpecification.cs" /> - <Compile Include="MediaFiles\EpisodeImport\Specifications\NotSampleSpecification.cs" /> - <Compile Include="MediaFiles\EpisodeImport\Specifications\NotUnpackingSpecification.cs" /> - <Compile Include="MediaFiles\EpisodeImport\Specifications\SameEpisodesImportSpecification.cs" /> - <Compile Include="MediaFiles\EpisodeImport\Specifications\UnverifiedSceneNumberingSpecification.cs" /> - <Compile Include="MediaFiles\EpisodeImport\Specifications\UpgradeSpecification.cs" /> - <Compile Include="MediaFiles\Events\EpisodeDownloadedEvent.cs" /> - <Compile Include="MediaFiles\Events\EpisodeFileAddedEvent.cs" /> - <Compile Include="MediaFiles\Events\EpisodeFileDeletedEvent.cs" /> - <Compile Include="MediaFiles\Events\EpisodeFolderCreatedEvent.cs" /> - <Compile Include="MediaFiles\Events\EpisodeImportedEvent.cs" /> - <Compile Include="MediaFiles\Events\SeriesRenamedEvent.cs" /> - <Compile Include="MediaFiles\Events\SeriesScanSkippedEvent.cs" /> - <Compile Include="MediaFiles\Events\SeriesScannedEvent.cs" /> - <Compile Include="MediaFiles\FileDateType.cs" /> - <Compile Include="MediaFiles\MediaFileAttributeService.cs" /> - <Compile Include="MediaFiles\MediaFileExtensions.cs" /> - <Compile Include="MediaFiles\MediaFileRepository.cs" /> - <Compile Include="MediaFiles\MediaFileService.cs"> - <SubType>Code</SubType> - </Compile> - <Compile Include="MediaFiles\MediaFileTableCleanupService.cs" /> - <Compile Include="MediaFiles\MediaInfo\MediaInfoLib.cs" /> - <Compile Include="MediaFiles\MediaInfo\MediaInfoModel.cs" /> - <Compile Include="MediaFiles\MediaInfo\UpdateMediaInfoService.cs" /> - <Compile Include="MediaFiles\MediaInfo\VideoFileInfoReader.cs" /> - <Compile Include="MediaFiles\RecycleBinProvider.cs" /> - <Compile Include="MediaFiles\RenameEpisodeFilePreview.cs" /> - <Compile Include="MediaFiles\RenameEpisodeFileService.cs" /> - <Compile Include="MediaFiles\SameFilenameException.cs" /> - <Compile Include="MediaFiles\UpdateEpisodeFileService.cs" /> - <Compile Include="MediaFiles\UpgradeMediaFileService.cs" /> - <Compile Include="Messaging\Commands\BackendCommandAttribute.cs" /> - <Compile Include="Messaging\Commands\CleanupCommandMessagingService.cs" /> - <Compile Include="Messaging\Commands\Command.cs" /> - <Compile Include="Messaging\Commands\CommandEqualityComparer.cs" /> - <Compile Include="Messaging\Commands\CommandExecutor.cs" /> - <Compile Include="Messaging\Commands\CommandFailedException.cs" /> - <Compile Include="Messaging\Commands\MessagingCleanupCommand.cs" /> - <Compile Include="Messaging\Commands\CommandModel.cs" /> - <Compile Include="Messaging\Commands\CommandPriority.cs" /> - <Compile Include="Messaging\Commands\CommandNotFoundException.cs" /> - <Compile Include="Messaging\Commands\CommandQueue.cs" /> - <Compile Include="Messaging\Commands\CommandStatus.cs" /> - <Compile Include="Messaging\Commands\CommandRepository.cs" /> - <Compile Include="Messaging\Commands\CommandQueueManager.cs" /> - <Compile Include="Messaging\Commands\CommandTrigger.cs" /> - <Compile Include="Messaging\Commands\IExecute.cs" /> - <Compile Include="Messaging\Commands\TestCommand.cs" /> - <Compile Include="Messaging\Commands\TestCommandExecutor.cs" /> - <Compile Include="Messaging\Events\CommandExecutedEvent.cs" /> - <Compile Include="Messaging\Events\EventAggregator.cs" /> - <Compile Include="Messaging\Events\IEventAggregator.cs" /> - <Compile Include="Messaging\Events\IHandle.cs" /> - <Compile Include="Messaging\IProcessMessage.cs" /> - <Compile Include="MetadataSource\SkyHook\Resource\ActorResource.cs" /> - <Compile Include="MetadataSource\SkyHook\Resource\EpisodeResource.cs" /> - <Compile Include="MetadataSource\SkyHook\Resource\ImageResource.cs" /> - <Compile Include="MetadataSource\SkyHook\Resource\RatingResource.cs" /> - <Compile Include="MetadataSource\SkyHook\Resource\SeasonResource.cs" /> - <Compile Include="MetadataSource\SkyHook\Resource\ShowResource.cs" /> - <Compile Include="MetadataSource\SkyHook\Resource\TimeOfDayResource.cs" /> - <Compile Include="MetadataSource\SkyHook\SkyHookProxy.cs" /> - <Compile Include="MetadataSource\SearchSeriesComparer.cs" /> - <Compile Include="MetadataSource\SkyHook\SkyHookException.cs" /> - <Compile Include="Extras\Metadata\Consumers\MediaBrowser\MediaBrowserMetadata.cs" /> - <Compile Include="Extras\Metadata\Consumers\MediaBrowser\MediaBrowserMetadataSettings.cs" /> - <Compile Include="Extras\Metadata\Consumers\Roksbox\RoksboxMetadata.cs" /> - <Compile Include="Extras\Metadata\Consumers\Roksbox\RoksboxMetadataSettings.cs" /> - <Compile Include="Extras\Metadata\Consumers\Wdtv\WdtvMetadata.cs" /> - <Compile Include="Extras\Metadata\Consumers\Wdtv\WdtvMetadataSettings.cs" /> - <Compile Include="Extras\Metadata\Consumers\Xbmc\XbmcMetadata.cs" /> - <Compile Include="Extras\Metadata\Consumers\Xbmc\XbmcMetadataSettings.cs" /> - <Compile Include="Extras\Metadata\ExistingMetadataImporter.cs" /> - <Compile Include="Extras\Metadata\Files\CleanMetadataFileService.cs" /> - <Compile Include="Extras\Metadata\Files\ImageFileResult.cs" /> - <Compile Include="Extras\Metadata\Files\MetadataFileResult.cs" /> - <Compile Include="Extras\Metadata\IMetadata.cs" /> - <Compile Include="Extras\Metadata\MetadataBase.cs" /> - <Compile Include="Extras\Metadata\MetadataDefinition.cs" /> - <Compile Include="Extras\Metadata\MetadataFactory.cs" /> - <Compile Include="Extras\Metadata\MetadataRepository.cs" /> - <Compile Include="Extras\Metadata\MetadataService.cs" /> - <Compile Include="Extras\Metadata\MetadataType.cs" /> - <Compile Include="MetadataSource\IProvideSeriesInfo.cs" /> - <Compile Include="MetadataSource\ISearchForNewSeries.cs" /> - <Compile Include="Notifications\Join\JoinAuthException.cs" /> - <Compile Include="Notifications\Join\JoinInvalidDeviceException.cs" /> - <Compile Include="Notifications\Join\JoinResponseModel.cs" /> - <Compile Include="Notifications\Join\Join.cs" /> - <Compile Include="Notifications\Join\JoinException.cs" /> - <Compile Include="Notifications\Join\JoinProxy.cs" /> - <Compile Include="Notifications\Join\JoinSettings.cs" /> - <Compile Include="Notifications\Boxcar\Boxcar.cs" /> - <Compile Include="Notifications\Boxcar\BoxcarException.cs" /> - <Compile Include="Notifications\Boxcar\BoxcarProxy.cs" /> - <Compile Include="Notifications\Boxcar\BoxcarSettings.cs" /> - <Compile Include="Notifications\GrabMessage.cs" /> - <Compile Include="Notifications\Plex\Models\PlexIdentity.cs" /> - <Compile Include="Notifications\Plex\Models\PlexResponse.cs" /> - <Compile Include="Notifications\Plex\Models\PlexPreferences.cs" /> - <Compile Include="Notifications\Plex\Models\PlexSectionItem.cs" /> - <Compile Include="Notifications\Plex\Models\PlexSection.cs" /> - <Compile Include="Notifications\Plex\PlexAuthenticationException.cs" /> - <Compile Include="Notifications\CustomScript\CustomScript.cs" /> - <Compile Include="Notifications\CustomScript\CustomScriptSettings.cs" /> - <Compile Include="Notifications\Plex\PlexVersionException.cs" /> - <Compile Include="Notifications\Plex\PlexHomeTheater.cs" /> - <Compile Include="Notifications\Plex\PlexHomeTheaterSettings.cs" /> - <Compile Include="Notifications\Plex\PlexClientService.cs" /> - <Compile Include="Notifications\PushBullet\PushBulletException.cs" /> - <Compile Include="Notifications\Slack\Payloads\Attachment.cs" /> - <Compile Include="Notifications\Slack\Payloads\SlackPayload.cs" /> - <Compile Include="Notifications\Slack\Slack.cs" /> - <Compile Include="Notifications\Slack\SlackExeption.cs" /> - <Compile Include="Notifications\Slack\SlackSettings.cs" /> - <Compile Include="Notifications\Synology\SynologyException.cs" /> - <Compile Include="Notifications\Synology\SynologyIndexer.cs" /> - <Compile Include="Notifications\Synology\SynologyIndexerProxy.cs" /> - <Compile Include="Notifications\Synology\SynologyIndexerSettings.cs" /> - <Compile Include="Notifications\Telegram\InvalidResponseException.cs" /> - <Compile Include="Notifications\Telegram\Telegram.cs" /> - <Compile Include="Notifications\Telegram\TelegramService.cs" /> - <Compile Include="Notifications\Telegram\TelegramSettings.cs" /> - <Compile Include="Notifications\Twitter\OAuthToken.cs" /> - <Compile Include="Notifications\Twitter\TwitterException.cs" /> - <Compile Include="Notifications\Webhook\WebhookEpisode.cs" /> - <Compile Include="Notifications\Webhook\WebhookException.cs" /> - <Compile Include="Notifications\Webhook\WebhookMethod.cs" /> - <Compile Include="Notifications\Webhook\WebhookPayload.cs" /> - <Compile Include="Notifications\Webhook\WebhookSeries.cs" /> - <Compile Include="Notifications\Webhook\WebhookService.cs" /> - <Compile Include="Notifications\Webhook\WebhookSettings.cs" /> - <Compile Include="Notifications\Webhook\Webhook.cs" /> - <Compile Include="Organizer\NamingConfigRepository.cs" /> - <Compile Include="Notifications\Twitter\Twitter.cs" /> - <Compile Include="Notifications\Twitter\TwitterService.cs" /> - <Compile Include="Notifications\Twitter\TwitterSettings.cs" /> - <Compile Include="Parser\IsoLanguage.cs" /> - <Compile Include="Parser\IsoLanguages.cs" /> - <Compile Include="Parser\LanguageParser.cs" /> - <Compile Include="Profiles\Delay\DelayProfile.cs" /> - <Compile Include="Profiles\Delay\DelayProfileService.cs" /> - <Compile Include="Profiles\Delay\DelayProfileTagInUseValidator.cs" /> - <Compile Include="Profiles\ProfileRepository.cs" /> - <Compile Include="ProgressMessaging\ProgressMessageContext.cs" /> - <Compile Include="Qualities\QualitySource.cs" /> - <Compile Include="Qualities\Revision.cs" /> - <Compile Include="RemotePathMappings\RemotePathMapping.cs" /> - <Compile Include="RemotePathMappings\RemotePathMappingRepository.cs" /> - <Compile Include="RemotePathMappings\RemotePathMappingService.cs" /> - <Compile Include="MediaFiles\TorrentInfo\TorrentFileInfoReader.cs" /> - <Compile Include="Notifications\DownloadMessage.cs" /> - <Compile Include="Notifications\Email\Email.cs"> - <SubType>Code</SubType> - </Compile> - <Compile Include="Notifications\Email\EmailService.cs"> - <SubType>Code</SubType> - </Compile> - <Compile Include="Notifications\Email\EmailSettings.cs" /> - <Compile Include="Notifications\Growl\Growl.cs"> - <SubType>Code</SubType> - </Compile> - <Compile Include="Notifications\Growl\GrowlService.cs"> - <SubType>Code</SubType> - </Compile> - <Compile Include="Notifications\Growl\GrowlSettings.cs" /> - <Compile Include="Notifications\INotification.cs" /> - <Compile Include="Notifications\MediaBrowser\MediaBrowser.cs" /> - <Compile Include="Notifications\MediaBrowser\MediaBrowserProxy.cs" /> - <Compile Include="Notifications\MediaBrowser\MediaBrowserService.cs" /> - <Compile Include="Notifications\MediaBrowser\MediaBrowserSettings.cs" /> - <Compile Include="Notifications\NotificationBase.cs"> - <SubType>Code</SubType> - </Compile> - <Compile Include="Notifications\NotificationDefinition.cs" /> - <Compile Include="Notifications\NotificationFactory.cs" /> - <Compile Include="Notifications\NotificationRepository.cs" /> - <Compile Include="Notifications\NotificationService.cs" /> - <Compile Include="Notifications\NotifyMyAndroid\NotifyMyAndroid.cs" /> - <Compile Include="Notifications\NotifyMyAndroid\NotifyMyAndroidPriority.cs" /> - <Compile Include="Notifications\NotifyMyAndroid\NotifyMyAndroidProxy.cs" /> - <Compile Include="Notifications\NotifyMyAndroid\NotifyMyAndroidSettings.cs" /> - <Compile Include="Notifications\Plex\PlexClient.cs"> - <SubType>Code</SubType> - </Compile> - <Compile Include="Notifications\Plex\PlexClientSettings.cs" /> - <Compile Include="Notifications\Plex\PlexError.cs" /> - <Compile Include="Notifications\Plex\PlexException.cs" /> - <Compile Include="Notifications\Plex\PlexServer.cs" /> - <Compile Include="Notifications\Plex\PlexServerProxy.cs" /> - <Compile Include="Notifications\Plex\PlexServerSettings.cs" /> - <Compile Include="Notifications\Plex\PlexServerService.cs" /> - <Compile Include="Notifications\Plex\PlexUser.cs" /> - <Compile Include="Notifications\Prowl\InvalidApiKeyException.cs" /> - <Compile Include="Notifications\Prowl\Prowl.cs"> - <SubType>Code</SubType> - </Compile> - <Compile Include="Notifications\Prowl\ProwlPriority.cs" /> - <Compile Include="Notifications\Prowl\ProwlService.cs"> - <SubType>Code</SubType> - </Compile> - <Compile Include="Notifications\Prowl\ProwlSettings.cs" /> - <Compile Include="Notifications\Pushalot\Pushalot.cs" /> - <Compile Include="Notifications\Pushalot\PushalotPriority.cs" /> - <Compile Include="Notifications\Pushalot\PushalotProxy.cs" /> - <Compile Include="Notifications\Pushalot\PushalotResponse.cs" /> - <Compile Include="Notifications\Pushalot\PushalotSettings.cs" /> - <Compile Include="Notifications\PushBullet\PushBullet.cs" /> - <Compile Include="Notifications\PushBullet\PushBulletProxy.cs" /> - <Compile Include="Notifications\PushBullet\PushBulletSettings.cs" /> - <Compile Include="Notifications\Pushover\InvalidResponseException.cs" /> - <Compile Include="Notifications\Pushover\Pushover.cs" /> - <Compile Include="Notifications\Pushover\PushoverPriority.cs" /> - <Compile Include="Notifications\Pushover\PushoverService.cs" /> - <Compile Include="Notifications\Pushover\PushoverSettings.cs" /> - <Compile Include="Notifications\Xbmc\XbmcJsonException.cs" /> - <Compile Include="Notifications\Xbmc\HttpApiProvider.cs" /> - <Compile Include="Notifications\Xbmc\IApiProvider.cs" /> - <Compile Include="Notifications\Xbmc\InvalidXbmcVersionException.cs" /> - <Compile Include="Notifications\Xbmc\JsonApiProvider.cs" /> - <Compile Include="Notifications\Xbmc\Model\ActivePlayer.cs" /> - <Compile Include="Notifications\Xbmc\Model\ActivePlayersDharmaResult.cs" /> - <Compile Include="Notifications\Xbmc\Model\ActivePlayersEdenResult.cs" /> - <Compile Include="Notifications\Xbmc\Model\ErrorResult.cs" /> - <Compile Include="Notifications\Xbmc\Model\TvShow.cs" /> - <Compile Include="Notifications\Xbmc\Model\TvShowResponse.cs" /> - <Compile Include="Notifications\Xbmc\Model\TvShowResult.cs" /> - <Compile Include="Notifications\Xbmc\Model\VersionResult.cs" /> - <Compile Include="Notifications\Xbmc\Model\XbmcJsonResult.cs" /> - <Compile Include="Notifications\Xbmc\Model\XbmcVersion.cs" /> - <Compile Include="Notifications\Xbmc\Xbmc.cs"> - <SubType>Code</SubType> - </Compile> - <Compile Include="Notifications\Xbmc\XbmcJsonApiProxy.cs" /> - <Compile Include="Notifications\Xbmc\XbmcService.cs"> - <SubType>Code</SubType> - </Compile> - <Compile Include="Notifications\Xbmc\XbmcSettings.cs" /> - <Compile Include="Organizer\AbsoluteEpisodeFormat.cs" /> - <Compile Include="Organizer\BasicNamingConfig.cs" /> - <Compile Include="Organizer\EpisodeFormat.cs" /> - <Compile Include="Organizer\EpisodeSortingType.cs" /> - <Compile Include="Organizer\Exception.cs" /> - <Compile Include="Organizer\FileNameBuilder.cs" /> - <Compile Include="Organizer\FileNameBuilderTokenEqualityComparer.cs" /> - <Compile Include="Organizer\FileNameSampleService.cs" /> - <Compile Include="Organizer\FileNameValidation.cs" /> - <Compile Include="Organizer\FileNameValidationService.cs" /> - <Compile Include="Organizer\NamingConfig.cs" /> - <Compile Include="Organizer\NamingConfigService.cs" /> - <Compile Include="Organizer\SampleResult.cs" /> - <Compile Include="Parser\InvalidDateException.cs" /> - <Compile Include="Parser\Language.cs" /> - <Compile Include="Parser\Model\LocalEpisode.cs" /> - <Compile Include="Parser\Model\ParsedEpisodeInfo.cs" /> - <Compile Include="Parser\Model\ReleaseInfo.cs" /> - <Compile Include="Parser\Model\RemoteEpisode.cs" /> - <Compile Include="Parser\Model\SeriesTitleInfo.cs" /> - <Compile Include="Parser\Model\TorrentInfo.cs" /> - <Compile Include="Parser\Parser.cs" /> - <Compile Include="Parser\ParsingService.cs" /> - <Compile Include="Parser\SceneChecker.cs" /> - <Compile Include="Parser\QualityParser.cs" /> - <Compile Include="Profiles\Profile.cs" /> - <Compile Include="Profiles\ProfileInUseException.cs" /> - <Compile Include="Profiles\ProfileQualityItem.cs" /> - <Compile Include="Profiles\Delay\DelayProfileRepository.cs" /> - <Compile Include="Profiles\ProfileService.cs" /> - <Compile Include="ProgressMessaging\CommandUpdatedEvent.cs" /> - <Compile Include="ProgressMessaging\ProgressMessageTarget.cs" /> - <Compile Include="Properties\AssemblyInfo.cs" /> - <Compile Include="Qualities\QualitiesBelowCutoff.cs" /> - <Compile Include="Qualities\Quality.cs" /> - <Compile Include="Qualities\QualityDefinition.cs" /> - <Compile Include="Qualities\QualityDefinitionRepository.cs" /> - <Compile Include="Qualities\QualityDefinitionService.cs"> - <SubType>Code</SubType> - </Compile> - <Compile Include="Qualities\QualityModel.cs" /> - <Compile Include="Qualities\QualityModelComparer.cs" /> - <Compile Include="Queue\Queue.cs" /> - <Compile Include="Queue\QueueService.cs" /> - <Compile Include="Queue\QueueUpdatedEvent.cs" /> - <Compile Include="Restrictions\Restriction.cs" /> - <Compile Include="Restrictions\RestrictionRepository.cs" /> - <Compile Include="Restrictions\RestrictionService.cs" /> - <Compile Include="Rest\JsonNetSerializer.cs" /> - <Compile Include="Rest\RestClientFactory.cs" /> - <Compile Include="Rest\RestException.cs" /> - <Compile Include="Rest\RestSharpExtensions.cs" /> - <Compile Include="RootFolders\RootFolder.cs" /> - <Compile Include="RootFolders\RootFolderRepository.cs" /> - <Compile Include="RootFolders\RootFolderService.cs"> - <SubType>Code</SubType> - </Compile> - <Compile Include="RootFolders\UnmappedFolder.cs" /> - <Compile Include="Security.cs" /> - <Compile Include="SeriesStats\SeasonStatistics.cs" /> - <Compile Include="SeriesStats\SeriesStatistics.cs" /> - <Compile Include="SeriesStats\SeriesStatisticsRepository.cs" /> - <Compile Include="SeriesStats\SeriesStatisticsService.cs" /> - <Compile Include="Tags\Tag.cs" /> - <Compile Include="Tags\TagRepository.cs" /> - <Compile Include="Tags\TagService.cs" /> - <Compile Include="Tags\TagsUpdatedEvent.cs" /> - <Compile Include="ThingiProvider\ConfigContractNotFoundException.cs" /> - <Compile Include="ThingiProvider\Events\ProviderDeletedEvent.cs" /> - <Compile Include="ThingiProvider\Events\ProviderUpdatedEvent.cs" /> - <Compile Include="ThingiProvider\IProvider.cs" /> - <Compile Include="ThingiProvider\IProviderConfig.cs" /> - <Compile Include="ThingiProvider\IProviderFactory.cs" /> - <Compile Include="ThingiProvider\IProviderRepository.cs" /> - <Compile Include="ThingiProvider\NullConfig.cs" /> - <Compile Include="ThingiProvider\ProviderDefinition.cs" /> - <Compile Include="ThingiProvider\ProviderFactory.cs" /> - <Compile Include="ThingiProvider\ProviderMessage.cs" /> - <Compile Include="ThingiProvider\ProviderRepository.cs" /> - <Compile Include="TinyTwitter.cs" /> - <Compile Include="Tv\Actor.cs" /> - <Compile Include="Tv\AddSeriesOptions.cs" /> - <Compile Include="Tv\Commands\MoveSeriesCommand.cs" /> - <Compile Include="Tv\Commands\RefreshSeriesCommand.cs" /> - <Compile Include="Tv\Episode.cs" /> - <Compile Include="Tv\EpisodeAddedService.cs" /> - <Compile Include="Tv\EpisodeCutoffService.cs" /> - <Compile Include="Tv\EpisodeMonitoredService.cs" /> - <Compile Include="Tv\EpisodeRepository.cs"> - <SubType>Code</SubType> - </Compile> - <Compile Include="Tv\EpisodeService.cs" /> - <Compile Include="Tv\Events\EpisodeInfoRefreshedEvent.cs" /> - <Compile Include="Tv\Events\SeriesAddedEvent.cs" /> - <Compile Include="Tv\Events\SeriesDeletedEvent.cs" /> - <Compile Include="Tv\Events\SeriesEditedEvent.cs" /> - <Compile Include="Tv\Events\SeriesMovedEvent.cs" /> - <Compile Include="Tv\Events\SeriesRefreshStartingEvent.cs" /> - <Compile Include="Tv\Events\SeriesUpdatedEvent.cs" /> - <Compile Include="Tv\MonitoringOptions.cs" /> - <Compile Include="Tv\MoveSeriesService.cs" /> - <Compile Include="Tv\Ratings.cs" /> - <Compile Include="Tv\RefreshEpisodeService.cs" /> - <Compile Include="Tv\RefreshSeriesService.cs" /> - <Compile Include="Tv\Season.cs" /> - <Compile Include="Tv\Series.cs" /> - <Compile Include="Tv\SeriesAddedHandler.cs" /> - <Compile Include="Tv\SeriesScannedHandler.cs" /> - <Compile Include="Tv\SeriesEditedService.cs" /> - <Compile Include="Tv\SeriesRepository.cs" /> - <Compile Include="Tv\SeriesService.cs"> - <SubType>Code</SubType> - </Compile> - <Compile Include="Tv\SeriesStatusType.cs" /> - <Compile Include="Tv\SeriesTitleNormalizer.cs" /> - <Compile Include="Tv\SeriesTypes.cs" /> - <Compile Include="Tv\ShouldRefreshSeries.cs" /> - <Compile Include="Update\Commands\ApplicationUpdateCommand.cs" /> - <Compile Include="Update\InstallUpdateService.cs" /> - <Compile Include="Update\RecentUpdateProvider.cs" /> - <Compile Include="Update\UpdateAbortedException.cs" /> - <Compile Include="Update\UpdateChanges.cs" /> - <Compile Include="Update\UpdateCheckService.cs" /> - <Compile Include="Update\UpdateFolderNotWritableException.cs" /> - <Compile Include="Update\UpdateMechanism.cs" /> - <Compile Include="Update\UpdatePackage.cs" /> - <Compile Include="Update\UpdatePackageAvailable.cs" /> - <Compile Include="Update\UpdatePackageProvider.cs" /> - <Compile Include="Update\UpdateVerification.cs" /> - <Compile Include="Update\UpdateVerificationFailedException.cs" /> - <Compile Include="Validation\FolderValidator.cs" /> - <Compile Include="Validation\IpValidation.cs" /> - <Compile Include="Validation\LanguageValidator.cs" /> - <Compile Include="Validation\NzbDroneValidationExtensions.cs" /> - <Compile Include="Validation\NzbDroneValidationFailure.cs" /> - <Compile Include="Validation\NzbDroneValidationResult.cs" /> - <Compile Include="Validation\NzbDroneValidationState.cs" /> - <Compile Include="Validation\Paths\MappedNetworkDriveValidator.cs" /> - <Compile Include="Validation\Paths\DroneFactoryValidator.cs" /> - <Compile Include="Validation\Paths\FolderWritableValidator.cs" /> - <Compile Include="Validation\Paths\PathExistsValidator.cs" /> - <Compile Include="Validation\Paths\PathValidator.cs" /> - <Compile Include="Validation\Paths\StartupFolderValidator.cs" /> - <Compile Include="Validation\Paths\RootFolderValidator.cs" /> - <Compile Include="Validation\Paths\SeriesAncestorValidator.cs" /> - <Compile Include="Validation\Paths\SeriesExistsValidator.cs" /> - <Compile Include="Validation\Paths\SeriesPathValidator.cs" /> - <Compile Include="Validation\ProfileExistsValidator.cs" /> - <Compile Include="Validation\RuleBuilderExtensions.cs" /> - <Compile Include="Validation\UrlValidator.cs" /> - </ItemGroup> - <ItemGroup> - <BootstrapperPackage Include=".NETFramework,Version=v4.0,Profile=Client"> - <Visible>False</Visible> - <ProductName>Microsoft .NET Framework 4 Client Profile %28x86 and x64%29</ProductName> - <Install>true</Install> - </BootstrapperPackage> - <BootstrapperPackage Include="Microsoft.Net.Client.3.5"> - <Visible>False</Visible> - <ProductName>.NET Framework 3.5 SP1 Client Profile</ProductName> - <Install>false</Install> - </BootstrapperPackage> - <BootstrapperPackage Include="Microsoft.Net.Framework.3.5.SP1"> - <Visible>False</Visible> - <ProductName>.NET Framework 3.5 SP1</ProductName> - <Install>false</Install> - </BootstrapperPackage> - <BootstrapperPackage Include="Microsoft.Windows.Installer.3.1"> - <Visible>False</Visible> - <ProductName>Windows Installer 3.1</ProductName> - <Install>true</Install> - </BootstrapperPackage> - </ItemGroup> - <ItemGroup> - <None Include="App.config" /> - <None Include="NzbDrone.Core.dll.config"> - <CopyToOutputDirectory>Always</CopyToOutputDirectory> - </None> - <None Include="packages.config" /> - <None Include="Properties\AnalysisRules.ruleset" /> - </ItemGroup> - <ItemGroup> - <Service Include="{508349B6-6B84-4DF5-91F0-309BEEBAD82D}" /> - </ItemGroup> - <ItemGroup> - <ProjectReference Include="..\Marr.Data\Marr.Data.csproj"> - <Project>{F6FC6BE7-0847-4817-A1ED-223DC647C3D7}</Project> - <Name>Marr.Data</Name> - </ProjectReference> - <ProjectReference Include="..\MonoTorrent\MonoTorrent.csproj"> - <Project>{411a9e0e-fdc6-4e25-828a-0c2cd1cd96f8}</Project> - <Name>MonoTorrent</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.Common\NzbDrone.Common.csproj"> - <Project>{F2BE0FDF-6E47-4827-A420-DD4EF82407F8}</Project> - <Name>NzbDrone.Common</Name> - </ProjectReference> - </ItemGroup> - <ItemGroup> - <EmbeddedResource Include="..\..\Logo\64.png"> - <Link>Resources\Logo\64.png</Link> - </EmbeddedResource> - </ItemGroup> - <ItemGroup> - <Content Include="..\Libraries\MediaInfo\MediaInfo.dll"> - <Link>MediaInfo.dll</Link> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </Content> - <Content Include="..\Libraries\MediaInfo\libmediainfo.0.dylib"> - <Link>libmediainfo.0.dylib</Link> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </Content> - <Content Include="..\Libraries\Sqlite\libsqlite3.0.dylib"> - <Link>libsqlite3.0.dylib</Link> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </Content> - <Compile Include="Notifications\Telegram\TelegramError.cs" /> - </ItemGroup> - <ItemGroup /> - <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> - <PropertyGroup> - <PostBuildEvent> - </PostBuildEvent> - </PropertyGroup> - <!-- To modify your build process, add your task inside one of the targets below and uncomment it. - Other similar extension points exist, see Microsoft.Common.targets. - <Target Name="BeforeBuild"> - </Target> - <Target Name="AfterBuild"> - </Target> - --> -</Project> \ No newline at end of file diff --git a/src/NzbDrone.Core/NzbDrone.Core.dll.config b/src/NzbDrone.Core/NzbDrone.Core.dll.config deleted file mode 100644 index a139791b4..000000000 --- a/src/NzbDrone.Core/NzbDrone.Core.dll.config +++ /dev/null @@ -1,7 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<configuration> - <dllmap os="osx" dll="MediaInfo.dll" target="libmediainfo.0.dylib"/> - <dllmap os="linux" dll="MediaInfo.dll" target="libmediainfo.so.0" /> - <dllmap os="freebsd" dll="MediaInfo.dll" target="libmediainfo.so.0" /> - <dllmap os="solaris" dll="MediaInfo.dll" target="libmediainfo.so.0.0.0" /> -</configuration> diff --git a/src/NzbDrone.Core/Organizer/AbsoluteEpisodeFormat.cs b/src/NzbDrone.Core/Organizer/AbsoluteEpisodeFormat.cs deleted file mode 100644 index a56466b7a..000000000 --- a/src/NzbDrone.Core/Organizer/AbsoluteEpisodeFormat.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace NzbDrone.Core.Organizer -{ - public class EpisodeFormat - { - public string Separator { get; set; } - public string EpisodePattern { get; set; } - public string EpisodeSeparator { get; set; } - public string SeasonEpisodePattern { get; set; } - } -} diff --git a/src/NzbDrone.Core/Organizer/AbsoluteTrackFormat.cs b/src/NzbDrone.Core/Organizer/AbsoluteTrackFormat.cs new file mode 100644 index 000000000..77febda7b --- /dev/null +++ b/src/NzbDrone.Core/Organizer/AbsoluteTrackFormat.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Organizer +{ + public class TrackFormat + { + public string Separator { get; set; } + public string TrackPattern { get; set; } + public string TrackSeparator { get; set; } + } +} diff --git a/src/NzbDrone.Core/Organizer/BasicNamingConfig.cs b/src/NzbDrone.Core/Organizer/BasicNamingConfig.cs index b0dc16f6a..824f45cf8 100644 --- a/src/NzbDrone.Core/Organizer/BasicNamingConfig.cs +++ b/src/NzbDrone.Core/Organizer/BasicNamingConfig.cs @@ -2,8 +2,8 @@ { public class BasicNamingConfig { - public bool IncludeSeriesTitle { get; set; } - public bool IncludeEpisodeTitle { get; set; } + public bool IncludeArtistName { get; set; } + public bool IncludeAlbumTitle { get; set; } public bool IncludeQuality { get; set; } public bool ReplaceSpaces { get; set; } public string Separator { get; set; } diff --git a/src/NzbDrone.Core/Organizer/EpisodeFormat.cs b/src/NzbDrone.Core/Organizer/EpisodeFormat.cs deleted file mode 100644 index c23dc85aa..000000000 --- a/src/NzbDrone.Core/Organizer/EpisodeFormat.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace NzbDrone.Core.Organizer -{ - public class AbsoluteEpisodeFormat - { - public string Separator { get; set; } - public string AbsoluteEpisodePattern { get; set; } - } -} diff --git a/src/NzbDrone.Core/Organizer/EpisodeSortingType.cs b/src/NzbDrone.Core/Organizer/EpisodeSortingType.cs deleted file mode 100644 index d68549f07..000000000 --- a/src/NzbDrone.Core/Organizer/EpisodeSortingType.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace NzbDrone.Core.Organizer -{ - public class EpisodeSortingType - { - public int Id { get; set; } - public string Name { get; set; } - public string Pattern { get; set; } - public string EpisodeSeparator { get; set; } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index 31cbd53ef..4bdb6b2d6 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -9,50 +9,52 @@ using NzbDrone.Common.Cache; using NzbDrone.Common.EnsureThat; using NzbDrone.Common.Extensions; using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Profiles.Releases; using NzbDrone.Core.Qualities; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Organizer { public interface IBuildFileNames { - string BuildFileName(List<Episode> episodes, Series series, EpisodeFile episodeFile, NamingConfig namingConfig = null); - string BuildFilePath(Series series, int seasonNumber, string fileName, string extension); - string BuildSeasonPath(Series series, int seasonNumber); + string BuildTrackFileName(List<Track> tracks, Artist artist, Album album, TrackFile trackFile, NamingConfig namingConfig = null, List<string> preferredWords = null); + string BuildTrackFilePath(Artist artist, Album album, string fileName, string extension); + string BuildAlbumPath(Artist artist, Album album); BasicNamingConfig GetBasicNamingConfig(NamingConfig nameSpec); - string GetSeriesFolder(Series series, NamingConfig namingConfig = null); - string GetSeasonFolder(Series series, int seasonNumber, NamingConfig namingConfig = null); + string GetArtistFolder(Artist artist, NamingConfig namingConfig = null); + string GetAlbumFolder(Artist artist, Album album, NamingConfig namingConfig = null); } public class FileNameBuilder : IBuildFileNames { private readonly INamingConfigService _namingConfigService; private readonly IQualityDefinitionService _qualityDefinitionService; - private readonly ICached<EpisodeFormat[]> _episodeFormatCache; - private readonly ICached<AbsoluteEpisodeFormat[]> _absoluteEpisodeFormatCache; + private readonly IPreferredWordService _preferredWordService; + private readonly ICached<TrackFormat[]> _trackFormatCache; + private readonly ICached<AbsoluteTrackFormat[]> _absoluteTrackFormatCache; private readonly Logger _logger; private static readonly Regex TitleRegex = new Regex(@"\{(?<prefix>[- ._\[(]*)(?<token>(?:[a-z0-9]+)(?:(?<separator>[- ._]+)(?:[a-z0-9]+))?)(?::(?<customFormat>[a-z0-9]+))?(?<suffix>[- ._)\]]*)\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex EpisodeRegex = new Regex(@"(?<episode>\{episode(?:\:0+)?})", + public static readonly Regex TrackRegex = new Regex(@"(?<track>\{track(?:\:0+)?})", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex SeasonRegex = new Regex(@"(?<season>\{season(?:\:0+)?})", - RegexOptions.Compiled | RegexOptions.IgnoreCase); - - private static readonly Regex AbsoluteEpisodeRegex = new Regex(@"(?<absolute>\{absolute(?:\:0+)?})", + private static readonly Regex MediumRegex = new Regex(@"(?<medium>\{medium(?:\:0+)?})", RegexOptions.Compiled | RegexOptions.IgnoreCase); public static readonly Regex SeasonEpisodePatternRegex = new Regex(@"(?<separator>(?<=})[- ._]+?)?(?<seasonEpisode>s?{season(?:\:0+)?}(?<episodeSeparator>[- ._]?[ex])(?<episode>{episode(?:\:0+)?}))(?<separator>[- ._]+?(?={))?", RegexOptions.Compiled | RegexOptions.IgnoreCase); - public static readonly Regex AbsoluteEpisodePatternRegex = new Regex(@"(?<separator>(?<=})[- ._]+?)?(?<absolute>{absolute(?:\:0+)?})(?<separator>[- ._]+?(?={))?", - RegexOptions.Compiled | RegexOptions.IgnoreCase); + public static readonly Regex ReleaseDateRegex = new Regex(@"\{Release(\s|\W|_)Year\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public static readonly Regex ArtistNameRegex = new Regex(@"(?<token>\{(?:Artist)(?<separator>[- ._])(Clean)?Name(The)?\})", + RegexOptions.Compiled | RegexOptions.IgnoreCase); - public static readonly Regex AirDateRegex = new Regex(@"\{Air(\s|\W|_)Date\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); + public static readonly Regex AlbumTitleRegex = new Regex(@"(?<token>\{(?:Album)(?<separator>[- ._])(Clean)?Title(The)?\})", + RegexOptions.Compiled | RegexOptions.IgnoreCase); - public static readonly Regex SeriesTitleRegex = new Regex(@"(?<token>\{(?:Series)(?<separator>[- ._])(Clean)?Title\})", + public static readonly Regex TrackTitleRegex = new Regex(@"(?<token>\{(?:Track)(?<separator>[- ._])(Clean)?Title(The)?\})", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex FileNameCleanupRegex = new Regex(@"([- ._])(\1)+", RegexOptions.Compiled); @@ -64,105 +66,96 @@ namespace NzbDrone.Core.Organizer //TODO: Support Written numbers (One, Two, etc) and Roman Numerals (I, II, III etc) private static readonly Regex MultiPartCleanupRegex = new Regex(@"(?:\(\d+\)|(Part|Pt\.?)\s?\d+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly char[] EpisodeTitleTrimCharacters = new[] { ' ', '.', '?' }; + private static readonly char[] TrackTitleTrimCharacters = new[] { ' ', '.', '?' }; + + private static readonly Regex TitlePrefixRegex = new Regex(@"^(The|An|A) (.*?)((?: *\([^)]+\))*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); public FileNameBuilder(INamingConfigService namingConfigService, IQualityDefinitionService qualityDefinitionService, ICacheManager cacheManager, + IPreferredWordService preferredWordService, Logger logger) { _namingConfigService = namingConfigService; _qualityDefinitionService = qualityDefinitionService; - _episodeFormatCache = cacheManager.GetCache<EpisodeFormat[]>(GetType(), "episodeFormat"); - _absoluteEpisodeFormatCache = cacheManager.GetCache<AbsoluteEpisodeFormat[]>(GetType(), "absoluteEpisodeFormat"); + _preferredWordService = preferredWordService; + _trackFormatCache = cacheManager.GetCache<TrackFormat[]>(GetType(), "trackFormat"); + _absoluteTrackFormatCache = cacheManager.GetCache<AbsoluteTrackFormat[]>(GetType(), "absoluteTrackFormat"); _logger = logger; } - public string BuildFileName(List<Episode> episodes, Series series, EpisodeFile episodeFile, NamingConfig namingConfig = null) + public string BuildTrackFileName(List<Track> tracks, Artist artist, Album album, TrackFile trackFile, NamingConfig namingConfig = null, List<string> preferredWords = null) { if (namingConfig == null) { namingConfig = _namingConfigService.GetConfig(); } - if (!namingConfig.RenameEpisodes) + if (!namingConfig.RenameTracks) { - return GetOriginalTitle(episodeFile); + return GetOriginalFileName(trackFile); } - if (namingConfig.StandardEpisodeFormat.IsNullOrWhiteSpace() && series.SeriesType == SeriesTypes.Standard) + if (namingConfig.StandardTrackFormat.IsNullOrWhiteSpace() || namingConfig.MultiDiscTrackFormat.IsNullOrWhiteSpace()) { - throw new NamingFormatException("Standard episode format cannot be empty"); + throw new NamingFormatException("Standard and Multi track formats cannot be empty"); } - if (namingConfig.DailyEpisodeFormat.IsNullOrWhiteSpace() && series.SeriesType == SeriesTypes.Daily) - { - throw new NamingFormatException("Daily episode format cannot be empty"); - } + var pattern = namingConfig.StandardTrackFormat; - if (namingConfig.AnimeEpisodeFormat.IsNullOrWhiteSpace() && series.SeriesType == SeriesTypes.Anime) + if (tracks.First().AlbumRelease.Value.Media.Count() > 1) { - throw new NamingFormatException("Anime episode format cannot be empty"); + pattern = namingConfig.MultiDiscTrackFormat; } - var pattern = namingConfig.StandardEpisodeFormat; + var subFolders = pattern.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries); + var safePattern = subFolders.Aggregate("", (current, folderLevel) => Path.Combine(current, (folderLevel))); + var tokenHandlers = new Dictionary<string, Func<TokenMatch, string>>(FileNameBuilderTokenEqualityComparer.Instance); - episodes = episodes.OrderBy(e => e.SeasonNumber).ThenBy(e => e.EpisodeNumber).ToList(); + tracks = tracks.OrderBy(e => e.AlbumReleaseId).ThenBy(e => e.TrackNumber).ToList(); - if (series.SeriesType == SeriesTypes.Daily) - { - pattern = namingConfig.DailyEpisodeFormat; - } + safePattern = FormatTrackNumberTokens(safePattern, "", tracks); + safePattern = FormatMediumNumberTokens(safePattern, "", tracks); - if (series.SeriesType == SeriesTypes.Anime && episodes.All(e => e.AbsoluteEpisodeNumber.HasValue)) - { - pattern = namingConfig.AnimeEpisodeFormat; - } - - pattern = AddSeasonEpisodeNumberingTokens(pattern, tokenHandlers, episodes, namingConfig); - pattern = AddAbsoluteNumberingTokens(pattern, tokenHandlers, series, episodes, namingConfig); + AddArtistTokens(tokenHandlers, artist); + AddAlbumTokens(tokenHandlers, album); + AddMediumTokens(tokenHandlers, tracks.First().AlbumRelease.Value.Media.SingleOrDefault(m => m.Number == tracks.First().MediumNumber)); + AddTrackTokens(tokenHandlers, tracks); + AddTrackFileTokens(tokenHandlers, trackFile); + AddQualityTokens(tokenHandlers, artist, trackFile); + AddMediaInfoTokens(tokenHandlers, trackFile); + AddPreferredWords(tokenHandlers, artist, trackFile, preferredWords); - AddSeriesTokens(tokenHandlers, series); - AddEpisodeTokens(tokenHandlers, episodes); - AddEpisodeFileTokens(tokenHandlers, episodeFile); - AddQualityTokens(tokenHandlers, series, episodeFile); - AddMediaInfoTokens(tokenHandlers, episodeFile); - - var fileName = ReplaceTokens(pattern, tokenHandlers, namingConfig).Trim(); + var fileName = ReplaceTokens(safePattern, tokenHandlers, namingConfig).Trim(); fileName = FileNameCleanupRegex.Replace(fileName, match => match.Captures[0].Value[0].ToString()); fileName = TrimSeparatorsRegex.Replace(fileName, string.Empty); return fileName; } - public string BuildFilePath(Series series, int seasonNumber, string fileName, string extension) + public string BuildTrackFilePath(Artist artist, Album album, string fileName, string extension) { Ensure.That(extension, () => extension).IsNotNullOrWhiteSpace(); - var path = BuildSeasonPath(series, seasonNumber); + var path = BuildAlbumPath(artist, album); return Path.Combine(path, fileName + extension); } - public string BuildSeasonPath(Series series, int seasonNumber) + public string BuildAlbumPath(Artist artist, Album album) { - var path = series.Path; + var path = artist.Path; - if (series.SeasonFolder) + if (artist.AlbumFolder) { - if (seasonNumber == 0) - { - path = Path.Combine(path, "Specials"); - } - else - { - var seasonFolder = GetSeasonFolder(series, seasonNumber); - seasonFolder = CleanFileName(seasonFolder); + var albumFolder = GetAlbumFolder(artist, album); + + albumFolder = CleanFileName(albumFolder); + + path = Path.Combine(path, albumFolder); - path = Path.Combine(path, seasonFolder); - } } return path; @@ -170,20 +163,19 @@ namespace NzbDrone.Core.Organizer public BasicNamingConfig GetBasicNamingConfig(NamingConfig nameSpec) { - var episodeFormat = GetEpisodeFormat(nameSpec.StandardEpisodeFormat).LastOrDefault(); + var trackFormat = GetTrackFormat(nameSpec.StandardTrackFormat).LastOrDefault(); - if (episodeFormat == null) + if (trackFormat == null) { return new BasicNamingConfig(); } var basicNamingConfig = new BasicNamingConfig - { - Separator = episodeFormat.Separator, - NumberStyle = episodeFormat.SeasonEpisodePattern - }; + { + Separator = trackFormat.Separator + }; - var titleTokens = TitleRegex.Matches(nameSpec.StandardEpisodeFormat); + var titleTokens = TitleRegex.Matches(nameSpec.StandardTrackFormat); foreach (Match match in titleTokens) { @@ -195,14 +187,14 @@ namespace NzbDrone.Core.Organizer basicNamingConfig.ReplaceSpaces = true; } - if (token.StartsWith("{Series", StringComparison.InvariantCultureIgnoreCase)) + if (token.StartsWith("{Artist", StringComparison.InvariantCultureIgnoreCase)) { - basicNamingConfig.IncludeSeriesTitle = true; + basicNamingConfig.IncludeArtistName = true; } - if (token.StartsWith("{Episode", StringComparison.InvariantCultureIgnoreCase)) + if (token.StartsWith("{Album", StringComparison.InvariantCultureIgnoreCase)) { - basicNamingConfig.IncludeEpisodeTitle = true; + basicNamingConfig.IncludeAlbumTitle = true; } if (token.StartsWith("{Quality", StringComparison.InvariantCultureIgnoreCase)) @@ -214,7 +206,7 @@ namespace NzbDrone.Core.Organizer return basicNamingConfig; } - public string GetSeriesFolder(Series series, NamingConfig namingConfig = null) + public string GetArtistFolder(Artist artist, NamingConfig namingConfig = null) { if (namingConfig == null) { @@ -223,12 +215,12 @@ namespace NzbDrone.Core.Organizer var tokenHandlers = new Dictionary<string, Func<TokenMatch, string>>(FileNameBuilderTokenEqualityComparer.Instance); - AddSeriesTokens(tokenHandlers, series); + AddArtistTokens(tokenHandlers, artist); - return CleanFolderName(ReplaceTokens(namingConfig.SeriesFolderFormat, tokenHandlers, namingConfig)); + return CleanFolderName(ReplaceTokens(namingConfig.ArtistFolderFormat, tokenHandlers, namingConfig)); } - public string GetSeasonFolder(Series series, int seasonNumber, NamingConfig namingConfig = null) + public string GetAlbumFolder(Artist artist, Album album, NamingConfig namingConfig = null) { if (namingConfig == null) { @@ -237,10 +229,10 @@ namespace NzbDrone.Core.Organizer var tokenHandlers = new Dictionary<string, Func<TokenMatch, string>>(FileNameBuilderTokenEqualityComparer.Instance); - AddSeriesTokens(tokenHandlers, series); - AddSeasonTokens(tokenHandlers, seasonNumber); + AddAlbumTokens(tokenHandlers, album); + AddArtistTokens(tokenHandlers, artist); - return CleanFolderName(ReplaceTokens(namingConfig.SeasonFolderFormat, tokenHandlers, namingConfig)); + return CleanFolderName(ReplaceTokens(namingConfig.AlbumFolderFormat, tokenHandlers, namingConfig)); } public static string CleanTitle(string title) @@ -252,6 +244,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; @@ -272,304 +269,100 @@ namespace NzbDrone.Core.Organizer return name.Trim(' ', '.'); } - private void AddSeriesTokens(Dictionary<string, Func<TokenMatch, string>> tokenHandlers, Series series) - { - tokenHandlers["{Series Title}"] = m => series.Title; - tokenHandlers["{Series CleanTitle}"] = m => CleanTitle(series.Title); - } - - private string AddSeasonEpisodeNumberingTokens(string pattern, Dictionary<string, Func<TokenMatch, string>> tokenHandlers, List<Episode> episodes, NamingConfig namingConfig) + private void AddArtistTokens(Dictionary<string, Func<TokenMatch, string>> tokenHandlers, Artist artist) { - var episodeFormats = GetEpisodeFormat(pattern).DistinctBy(v => v.SeasonEpisodePattern).ToList(); + tokenHandlers["{Artist Name}"] = m => artist.Name; + tokenHandlers["{Artist CleanName}"] = m => CleanTitle(artist.Name); + tokenHandlers["{Artist NameThe}"] = m => TitleThe(artist.Name); - int index = 1; - foreach (var episodeFormat in episodeFormats) + if (artist.Metadata.Value.Disambiguation != null) { - var seasonEpisodePattern = episodeFormat.SeasonEpisodePattern; - string formatPattern; - - switch ((MultiEpisodeStyle)namingConfig.MultiEpisodeStyle) - { - case MultiEpisodeStyle.Duplicate: - formatPattern = episodeFormat.Separator + episodeFormat.SeasonEpisodePattern; - seasonEpisodePattern = FormatNumberTokens(seasonEpisodePattern, formatPattern, episodes); - break; - - case MultiEpisodeStyle.Repeat: - formatPattern = episodeFormat.EpisodeSeparator + episodeFormat.EpisodePattern; - seasonEpisodePattern = FormatNumberTokens(seasonEpisodePattern, formatPattern, episodes); - break; - - case MultiEpisodeStyle.Scene: - formatPattern = "-" + episodeFormat.EpisodeSeparator + episodeFormat.EpisodePattern; - seasonEpisodePattern = FormatNumberTokens(seasonEpisodePattern, formatPattern, episodes); - break; - - case MultiEpisodeStyle.Range: - formatPattern = "-" + episodeFormat.EpisodePattern; - seasonEpisodePattern = FormatRangeNumberTokens(seasonEpisodePattern, formatPattern, episodes); - break; - - case MultiEpisodeStyle.PrefixedRange: - formatPattern = "-" + episodeFormat.EpisodeSeparator + episodeFormat.EpisodePattern; - seasonEpisodePattern = FormatRangeNumberTokens(seasonEpisodePattern, formatPattern, episodes); - break; - - //MultiEpisodeStyle.Extend - default: - formatPattern = "-" + episodeFormat.EpisodePattern; - seasonEpisodePattern = FormatNumberTokens(seasonEpisodePattern, formatPattern, episodes); - break; - } - - var token = string.Format("{{Season Episode{0}}}", index++); - pattern = pattern.Replace(episodeFormat.SeasonEpisodePattern, token); - tokenHandlers[token] = m => seasonEpisodePattern; + tokenHandlers["{Artist Disambiguation}"] = m => artist.Metadata.Value.Disambiguation; } + } - AddSeasonTokens(tokenHandlers, episodes.First().SeasonNumber); + private void AddAlbumTokens(Dictionary<string, Func<TokenMatch, string>> tokenHandlers, Album album) + { + tokenHandlers["{Album Title}"] = m => album.Title; + tokenHandlers["{Album CleanTitle}"] = m => CleanTitle(album.Title); + tokenHandlers["{Album TitleThe}"] = m => TitleThe(album.Title); + tokenHandlers["{Album Type}"] = m => album.AlbumType; - if (episodes.Count > 1) + if (album.Disambiguation != null) { - tokenHandlers["{Episode}"] = m => episodes.First().EpisodeNumber.ToString(m.CustomFormat) + "-" + episodes.Last().EpisodeNumber.ToString(m.CustomFormat); + tokenHandlers["{Album Disambiguation}"] = m => album.Disambiguation; } - else + + if (album.ReleaseDate.HasValue) { - tokenHandlers["{Episode}"] = m => episodes.First().EpisodeNumber.ToString(m.CustomFormat); + tokenHandlers["{Release Year}"] = m => album.ReleaseDate.Value.Year.ToString(); } - - return pattern; - } - - private string AddAbsoluteNumberingTokens(string pattern, Dictionary<string, Func<TokenMatch, string>> tokenHandlers, Series series, List<Episode> episodes, NamingConfig namingConfig) - { - var absoluteEpisodeFormats = GetAbsoluteFormat(pattern).DistinctBy(v => v.AbsoluteEpisodePattern).ToList(); - - int index = 1; - foreach (var absoluteEpisodeFormat in absoluteEpisodeFormats) + else { - if (series.SeriesType != SeriesTypes.Anime) - { - pattern = pattern.Replace(absoluteEpisodeFormat.AbsoluteEpisodePattern, ""); - continue; - } - - var absoluteEpisodePattern = absoluteEpisodeFormat.AbsoluteEpisodePattern; - string formatPattern; - - switch ((MultiEpisodeStyle) namingConfig.MultiEpisodeStyle) - { - - case MultiEpisodeStyle.Duplicate: - formatPattern = absoluteEpisodeFormat.Separator + absoluteEpisodeFormat.AbsoluteEpisodePattern; - absoluteEpisodePattern = FormatAbsoluteNumberTokens(absoluteEpisodePattern, formatPattern, episodes); - break; - - case MultiEpisodeStyle.Repeat: - var repeatSeparator = absoluteEpisodeFormat.Separator.Trim().IsNullOrWhiteSpace() ? " " : absoluteEpisodeFormat.Separator.Trim(); - - formatPattern = repeatSeparator + absoluteEpisodeFormat.AbsoluteEpisodePattern; - absoluteEpisodePattern = FormatAbsoluteNumberTokens(absoluteEpisodePattern, formatPattern, episodes); - break; - - case MultiEpisodeStyle.Scene: - formatPattern = "-" + absoluteEpisodeFormat.AbsoluteEpisodePattern; - absoluteEpisodePattern = FormatAbsoluteNumberTokens(absoluteEpisodePattern, formatPattern, episodes); - break; - - case MultiEpisodeStyle.Range: - case MultiEpisodeStyle.PrefixedRange: - formatPattern = "-" + absoluteEpisodeFormat.AbsoluteEpisodePattern; - var eps = new List<Episode> {episodes.First()}; - - if (episodes.Count > 1) eps.Add(episodes.Last()); - - absoluteEpisodePattern = FormatAbsoluteNumberTokens(absoluteEpisodePattern, formatPattern, eps); - break; - - //MultiEpisodeStyle.Extend - default: - formatPattern = "-" + absoluteEpisodeFormat.AbsoluteEpisodePattern; - absoluteEpisodePattern = FormatAbsoluteNumberTokens(absoluteEpisodePattern, formatPattern, episodes); - break; - } - - var token = string.Format("{{Absolute Pattern{0}}}", index++); - pattern = pattern.Replace(absoluteEpisodeFormat.AbsoluteEpisodePattern, token); - tokenHandlers[token] = m => absoluteEpisodePattern; + tokenHandlers["{Release Year}"] = m => "Unknown"; } - - return pattern; } - private void AddSeasonTokens(Dictionary<string, Func<TokenMatch, string>> tokenHandlers, int seasonNumber) + private void AddMediumTokens(Dictionary<string, Func<TokenMatch, string>> tokenHandlers, Medium medium) { - tokenHandlers["{Season}"] = m => seasonNumber.ToString(m.CustomFormat); + tokenHandlers["{Medium Format}"] = m => medium.Format; } - private void AddEpisodeTokens(Dictionary<string, Func<TokenMatch, string>> tokenHandlers, List<Episode> episodes) + private void AddTrackTokens(Dictionary<string, Func<TokenMatch, string>> tokenHandlers, List<Track> tracks) { - if (!episodes.First().AirDate.IsNullOrWhiteSpace()) - { - tokenHandlers["{Air Date}"] = m => episodes.First().AirDate.Replace('-', ' '); - } - else - { - tokenHandlers["{Air Date}"] = m => "Unknown"; - } - - tokenHandlers["{Episode Title}"] = m => GetEpisodeTitle(episodes, "+"); - tokenHandlers["{Episode CleanTitle}"] = m => CleanTitle(GetEpisodeTitle(episodes, "and")); + tokenHandlers["{Track Title}"] = m => GetTrackTitle(tracks, "+"); + tokenHandlers["{Track CleanTitle}"] = m => CleanTitle(GetTrackTitle(tracks, "and")); } - private void AddEpisodeFileTokens(Dictionary<string, Func<TokenMatch, string>> tokenHandlers, EpisodeFile episodeFile) + private void AddTrackFileTokens(Dictionary<string, Func<TokenMatch, string>> tokenHandlers, TrackFile trackFile) { - tokenHandlers["{Original Title}"] = m => GetOriginalTitle(episodeFile); - tokenHandlers["{Original Filename}"] = m => GetOriginalFileName(episodeFile); - tokenHandlers["{Release Group}"] = m => episodeFile.ReleaseGroup ?? m.DefaultValue("Sonarr"); + tokenHandlers["{Original Title}"] = m => GetOriginalTitle(trackFile); + tokenHandlers["{Original Filename}"] = m => GetOriginalFileName(trackFile); + tokenHandlers["{Release Group}"] = m => trackFile.ReleaseGroup ?? m.DefaultValue("Lidarr"); } - private void AddQualityTokens(Dictionary<string, Func<TokenMatch, string>> tokenHandlers, Series series, EpisodeFile episodeFile) + private void AddQualityTokens(Dictionary<string, Func<TokenMatch, string>> tokenHandlers, Artist artist, TrackFile trackFile) { - var qualityTitle = _qualityDefinitionService.Get(episodeFile.Quality.Quality).Title; - var qualityProper = GetQualityProper(series, episodeFile.Quality); - var qualityReal = GetQualityReal(series, episodeFile.Quality); + var qualityTitle = _qualityDefinitionService.Get(trackFile.Quality.Quality).Title; + var qualityProper = GetQualityProper(trackFile.Quality); + //var qualityReal = GetQualityReal(artist, trackFile.Quality); - tokenHandlers["{Quality Full}"] = m => String.Format("{0} {1} {2}", qualityTitle, qualityProper, qualityReal); + tokenHandlers["{Quality Full}"] = m => String.Format("{0}", qualityTitle); tokenHandlers["{Quality Title}"] = m => qualityTitle; tokenHandlers["{Quality Proper}"] = m => qualityProper; - tokenHandlers["{Quality Real}"] = m => qualityReal; + //tokenHandlers["{Quality Real}"] = m => qualityReal; } - private void AddMediaInfoTokens(Dictionary<string, Func<TokenMatch, string>> tokenHandlers, EpisodeFile episodeFile) + private void AddMediaInfoTokens(Dictionary<string, Func<TokenMatch, string>> tokenHandlers, TrackFile trackFile) { - 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; - - 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) + if (trackFile.MediaInfo == null) { - 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 mediaInfoAudioLanguages = GetLanguagesToken(episodeFile.MediaInfo.AudioLanguages); - if (!mediaInfoAudioLanguages.IsNullOrWhiteSpace()) - { - mediaInfoAudioLanguages = string.Format("[{0}]", mediaInfoAudioLanguages); - } - - if (mediaInfoAudioLanguages == "[EN]") - { - mediaInfoAudioLanguages = string.Empty; - } + _logger.Trace("Media info is unavailable for {0}", trackFile); - var mediaInfoSubtitleLanguages = GetLanguagesToken(episodeFile.MediaInfo.Subtitles); - if (!mediaInfoSubtitleLanguages.IsNullOrWhiteSpace()) - { - mediaInfoSubtitleLanguages = string.Format("[{0}]", mediaInfoSubtitleLanguages); + return; } - 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 audioCodec = MediaInfoFormatter.FormatAudioCodec(trackFile.MediaInfo); + var audioChannels = MediaInfoFormatter.FormatAudioChannels(trackFile.MediaInfo); + var audioChannelsFormatted = audioChannels > 0 ? + audioChannels.ToString("F1", CultureInfo.InvariantCulture) : string.Empty; - tokenHandlers["{MediaInfo Video}"] = m => videoCodec; - tokenHandlers["{MediaInfo VideoCodec}"] = m => videoCodec; - tokenHandlers["{MediaInfo VideoBitDepth}"] = m => videoBitDepth; - - tokenHandlers["{MediaInfo Audio}"] = m => audioCodec; tokenHandlers["{MediaInfo AudioCodec}"] = m => audioCodec; - tokenHandlers["{MediaInfo AudioChannels}"] = m => audioChannels; - - tokenHandlers["{MediaInfo Simple}"] = m => string.Format("{0} {1}", videoCodec, audioCodec); - - tokenHandlers["{MediaInfo Full}"] = m => string.Format("{0} {1}{2} {3}", videoCodec, audioCodec, mediaInfoAudioLanguages, mediaInfoSubtitleLanguages); + tokenHandlers["{MediaInfo AudioChannels}"] = m => audioChannelsFormatted; + tokenHandlers["{MediaInfo AudioBitRate}"] = m => MediaInfoFormatter.FormatAudioBitrate(trackFile.MediaInfo); + tokenHandlers["{MediaInfo AudioBitsPerSample}"] = m => MediaInfoFormatter.FormatAudioBitsPerSample(trackFile.MediaInfo); + tokenHandlers["{MediaInfo AudioSampleRate}"] = m => MediaInfoFormatter.FormatAudioSampleRate(trackFile.MediaInfo); } - private string GetLanguagesToken(string mediaInfoLanguages) + private void AddPreferredWords(Dictionary<string, Func<TokenMatch, string>> tokenHandlers, Artist artist, TrackFile trackFile, List<string> preferredWords = null) { - List<string> tokens = new List<string>(); - foreach (var item in mediaInfoLanguages.Split('/')) - { - if (!string.IsNullOrWhiteSpace(item)) - tokens.Add(item.Trim()); - } - - var cultures = System.Globalization.CultureInfo.GetCultures(System.Globalization.CultureTypes.NeutralCultures); - for (int i = 0; i < tokens.Count; i++) + if (preferredWords == null) { - try - { - var cultureInfo = cultures.FirstOrDefault(p => p.EnglishName == tokens[i]); - - if (cultureInfo != null) - tokens[i] = cultureInfo.TwoLetterISOLanguageName.ToUpper(); - } - catch - { - } + preferredWords = _preferredWordService.GetMatchingPreferredWords(artist, trackFile.GetSceneOrFileName()); } - return string.Join("+", tokens.Distinct()); + tokenHandlers["{Preferred Words}"] = m => string.Join(" ", preferredWords); } private string ReplaceTokens(string pattern, Dictionary<string, Func<TokenMatch, string>> tokenHandlers, NamingConfig namingConfig) @@ -622,46 +415,32 @@ namespace NzbDrone.Core.Organizer return replacementText; } - private string FormatNumberTokens(string basePattern, string formatPattern, List<Episode> episodes) + private string FormatTrackNumberTokens(string basePattern, string formatPattern, List<Track> tracks) { var pattern = string.Empty; - for (int i = 0; i < episodes.Count; i++) + for (int i = 0; i < tracks.Count; i++) { var patternToReplace = i == 0 ? basePattern : formatPattern; - pattern += EpisodeRegex.Replace(patternToReplace, match => ReplaceNumberToken(match.Groups["episode"].Value, episodes[i].EpisodeNumber)); + pattern += TrackRegex.Replace(patternToReplace, match => ReplaceNumberToken(match.Groups["track"].Value, tracks[i].AbsoluteTrackNumber)); } - return ReplaceSeasonTokens(pattern, episodes.First().SeasonNumber); + return pattern; } - private string FormatAbsoluteNumberTokens(string basePattern, string formatPattern, List<Episode> episodes) + private string FormatMediumNumberTokens(string basePattern, string formatPattern, List<Track> tracks) { var pattern = string.Empty; - for (int i = 0; i < episodes.Count; i++) + for (int i = 0; i < tracks.Count; i++) { var patternToReplace = i == 0 ? basePattern : formatPattern; - pattern += AbsoluteEpisodeRegex.Replace(patternToReplace, match => ReplaceNumberToken(match.Groups["absolute"].Value, episodes[i].AbsoluteEpisodeNumber.Value)); + pattern += MediumRegex.Replace(patternToReplace, match => ReplaceNumberToken(match.Groups["medium"].Value, tracks[i].MediumNumber)); } - return ReplaceSeasonTokens(pattern, episodes.First().SeasonNumber); - } - - private string FormatRangeNumberTokens(string seasonEpisodePattern, string formatPattern, List<Episode> episodes) - { - var eps = new List<Episode> { episodes.First() }; - - if (episodes.Count > 1) eps.Add(episodes.Last()); - - return FormatNumberTokens(seasonEpisodePattern, formatPattern, eps); - } - - private string ReplaceSeasonTokens(string pattern, int seasonNumber) - { - return SeasonRegex.Replace(pattern, match => ReplaceNumberToken(match.Groups["season"].Value, seasonNumber)); + return pattern; } private string ReplaceNumberToken(string token, int value) @@ -672,45 +451,34 @@ namespace NzbDrone.Core.Organizer return value.ToString(split[1]); } - private EpisodeFormat[] GetEpisodeFormat(string pattern) + private TrackFormat[] GetTrackFormat(string pattern) { - return _episodeFormatCache.Get(pattern, () => SeasonEpisodePatternRegex.Matches(pattern).OfType<Match>() - .Select(match => new EpisodeFormat + return _trackFormatCache.Get(pattern, () => SeasonEpisodePatternRegex.Matches(pattern).OfType<Match>() + .Select(match => new TrackFormat { - EpisodeSeparator = match.Groups["episodeSeparator"].Value, + TrackSeparator = match.Groups["episodeSeparator"].Value, Separator = match.Groups["separator"].Value, - EpisodePattern = match.Groups["episode"].Value, - SeasonEpisodePattern = match.Groups["seasonEpisode"].Value, + TrackPattern = match.Groups["episode"].Value, }).ToArray()); } - private AbsoluteEpisodeFormat[] GetAbsoluteFormat(string pattern) - { - return _absoluteEpisodeFormatCache.Get(pattern, () => AbsoluteEpisodePatternRegex.Matches(pattern).OfType<Match>() - .Select(match => new AbsoluteEpisodeFormat - { - Separator = match.Groups["separator"].Value.IsNotNullOrWhiteSpace() ? match.Groups["separator"].Value : "-", - AbsoluteEpisodePattern = match.Groups["absolute"].Value - }).ToArray()); - } - - private string GetEpisodeTitle(List<Episode> episodes, string separator) + private string GetTrackTitle(List<Track> tracks, string separator) { separator = string.Format(" {0} ", separator.Trim()); - if (episodes.Count == 1) + if (tracks.Count == 1) { - return episodes.First().Title.TrimEnd(EpisodeTitleTrimCharacters); + return tracks.First().Title.TrimEnd(TrackTitleTrimCharacters); } - var titles = episodes.Select(c => c.Title.TrimEnd(EpisodeTitleTrimCharacters)) - .Select(CleanupEpisodeTitle) + var titles = tracks.Select(c => c.Title.TrimEnd(TrackTitleTrimCharacters)) + .Select(CleanupTrackTitle) .Distinct() .ToList(); if (titles.All(t => t.IsNullOrWhiteSpace())) { - titles = episodes.Select(c => c.Title.TrimEnd(EpisodeTitleTrimCharacters)) + titles = tracks.Select(c => c.Title.TrimEnd(TrackTitleTrimCharacters)) .Distinct() .ToList(); } @@ -718,19 +486,19 @@ namespace NzbDrone.Core.Organizer return string.Join(separator, titles); } - private string CleanupEpisodeTitle(string title) + private string CleanupTrackTitle(string title) { //this will remove (1),(2) from the end of multi part episodes. return MultiPartCleanupRegex.Replace(title, string.Empty).Trim(); } - private string GetQualityProper(Series series, QualityModel quality) + private string GetQualityProper(QualityModel quality) { if (quality.Revision.Version > 1) { - if (series.SeriesType == SeriesTypes.Anime) + if (quality.Revision.IsRepack) { - return "v" + quality.Revision.Version; + return "Repack"; } return "Proper"; @@ -739,35 +507,31 @@ namespace NzbDrone.Core.Organizer return String.Empty; } - private string GetQualityReal(Series series, QualityModel quality) - { - if (quality.Revision.Real > 0) - { - return "REAL"; - } + //private string GetQualityReal(Series series, QualityModel quality) + //{ + // if (quality.Revision.Real > 0) + // { + // return "REAL"; + // } - return string.Empty; - } + // return string.Empty; + //} - private string GetOriginalTitle(EpisodeFile episodeFile) + private string GetOriginalTitle(TrackFile trackFile) { - if (episodeFile.SceneName.IsNullOrWhiteSpace()) + if (trackFile.SceneName.IsNullOrWhiteSpace()) { - return GetOriginalFileName(episodeFile); + return GetOriginalFileName(trackFile); } - return episodeFile.SceneName; + return trackFile.SceneName; } - private string GetOriginalFileName(EpisodeFile episodeFile) + private string GetOriginalFileName(TrackFile trackFile) { - if (episodeFile.RelativePath.IsNullOrWhiteSpace()) - { - return Path.GetFileNameWithoutExtension(episodeFile.Path); - } - - return Path.GetFileNameWithoutExtension(episodeFile.RelativePath); + return Path.GetFileNameWithoutExtension(trackFile.Path); } + } internal sealed class TokenMatch diff --git a/src/NzbDrone.Core/Organizer/FileNameSampleService.cs b/src/NzbDrone.Core/Organizer/FileNameSampleService.cs index 966061fb3..5ded480da 100644 --- a/src/NzbDrone.Core/Organizer/FileNameSampleService.cs +++ b/src/NzbDrone.Core/Organizer/FileNameSampleService.cs @@ -1,237 +1,175 @@ -using System.Collections.Generic; +using System.Collections.Generic; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Qualities; -using NzbDrone.Core.Tv; -using NzbDrone.Core.MediaFiles.MediaInfo; +using NzbDrone.Core.Music; +using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.Organizer { public interface IFilenameSampleService { - SampleResult GetStandardSample(NamingConfig nameSpec); - SampleResult GetMultiEpisodeSample(NamingConfig nameSpec); - SampleResult GetDailySample(NamingConfig nameSpec); - SampleResult GetAnimeSample(NamingConfig nameSpec); - SampleResult GetAnimeMultiEpisodeSample(NamingConfig nameSpec); - string GetSeriesFolderSample(NamingConfig nameSpec); - string GetSeasonFolderSample(NamingConfig nameSpec); + SampleResult GetStandardTrackSample(NamingConfig nameSpec); + SampleResult GetMultiDiscTrackSample(NamingConfig nameSpec); + string GetArtistFolderSample(NamingConfig nameSpec); + string GetAlbumFolderSample(NamingConfig nameSpec); } public class FileNameSampleService : IFilenameSampleService { private readonly IBuildFileNames _buildFileNames; - private static Series _standardSeries; - private static Series _dailySeries; - private static Series _animeSeries; - private static Episode _episode1; - private static Episode _episode2; - private static Episode _episode3; - private static List<Episode> _singleEpisode; - private static List<Episode> _multiEpisodes; - private static EpisodeFile _singleEpisodeFile; - private static EpisodeFile _multiEpisodeFile; - private static EpisodeFile _dailyEpisodeFile; - private static EpisodeFile _animeEpisodeFile; - private static EpisodeFile _animeMultiEpisodeFile; + + private static Artist _standardArtist; + private static Album _standardAlbum; + private static AlbumRelease _singleRelease; + private static AlbumRelease _multiRelease; + private static Track _track1; + private static List<Track> _singleTrack; + private static TrackFile _singleTrackFile; + private static List<string> _preferredWords; public FileNameSampleService(IBuildFileNames buildFileNames) { _buildFileNames = buildFileNames; - _standardSeries = new Series - { - SeriesType = SeriesTypes.Standard, - Title = "Series Title (2010)" - }; - - _dailySeries = new Series + _standardArtist = new Artist { - SeriesType = SeriesTypes.Daily, - Title = "Series Title (2010)" + Metadata = new ArtistMetadata + { + Name = "The Artist Name", + Disambiguation = "US Rock Band" + + } }; - _animeSeries = new Series + _standardAlbum = new Album { - SeriesType = SeriesTypes.Anime, - Title = "Series Title (2010)" + Title = "The Album Title", + ReleaseDate = System.DateTime.Today, + AlbumType = "Album", + Disambiguation = "The Best Album", }; - _episode1 = new Episode + _singleRelease = new AlbumRelease { - SeasonNumber = 1, - EpisodeNumber = 1, - Title = "Episode Title (1)", - AirDate = "2013-10-30", - AbsoluteEpisodeNumber = 1, + Album = _standardAlbum, + Media = new List<Medium> + { + new Medium + { + Name = "CD 1: First Years", + Format = "CD", + Number = 1 + } + }, + Monitored = true }; - _episode2 = new Episode + _multiRelease = new AlbumRelease { - SeasonNumber = 1, - EpisodeNumber = 2, - Title = "Episode Title (2)", - AbsoluteEpisodeNumber = 2 + Album = _standardAlbum, + Media = new List<Medium> + { + new Medium + { + Name = "CD 1: First Years", + Format = "CD", + Number = 1 + }, + new Medium + { + Name = "CD 2: Second Best", + Format = "CD", + Number = 2 + } + }, + Monitored = true }; - _episode3 = new Episode + _track1 = new Track { - SeasonNumber = 1, - EpisodeNumber = 3, - Title = "Episode Title (3)", - AbsoluteEpisodeNumber = 3 + AlbumRelease = _singleRelease, + AbsoluteTrackNumber = 3, + MediumNumber = 1, + + Title = "Track Title (1)", + }; - _singleEpisode = new List<Episode> { _episode1 }; - _multiEpisodes = new List<Episode> { _episode1, _episode2, _episode3 }; + _singleTrack = new List<Track> { _track1 }; var mediaInfo = new MediaInfoModel() { - VideoCodec = "AVC", - VideoBitDepth = 8, - AudioFormat = "DTS", - AudioChannels = 6, - AudioChannelPositions = "3/2/0.1", - AudioLanguages = "English", - Subtitles = "English/German" + AudioFormat = "Flac Audio", + AudioChannels = 2, + AudioBitrate = 875, + AudioBits = 24, + AudioSampleRate = 44100 }; - var mediaInfoAnime = new MediaInfoModel() + _singleTrackFile = new TrackFile { - VideoCodec = "AVC", - VideoBitDepth = 8, - AudioFormat = "DTS", - AudioChannels = 6, - AudioChannelPositions = "3/2/0.1", - AudioLanguages = "Japanese", - Subtitles = "Japanese/English" - }; - - _singleEpisodeFile = new EpisodeFile - { - Quality = new QualityModel(Quality.HDTV720p, new Revision(2)), - RelativePath = "Series.Title.S01E01.720p.HDTV.x264-EVOLVE.mkv", - SceneName = "Series.Title.S01E01.720p.HDTV.x264-EVOLVE", + Quality = new QualityModel(Quality.MP3_256, new Revision(2)), + Path = "/music/Artist.Name.Album.Name.TrackNum.Track.Title.MP3256.mp3", + SceneName = "Artist.Name.Album.Name.TrackNum.Track.Title.MP3256", ReleaseGroup = "RlsGrp", MediaInfo = mediaInfo }; - _multiEpisodeFile = new EpisodeFile + _preferredWords = new List<string> { - Quality = new QualityModel(Quality.HDTV720p, new Revision(2)), - RelativePath = "Series.Title.S01E01-E03.720p.HDTV.x264-EVOLVE.mkv", - SceneName = "Series.Title.S01E01-E03.720p.HDTV.x264-EVOLVE", - ReleaseGroup = "RlsGrp", - MediaInfo = mediaInfo, + "iNTERNAL" }; - _dailyEpisodeFile = new EpisodeFile - { - Quality = new QualityModel(Quality.HDTV720p, new Revision(2)), - RelativePath = "Series.Title.2013.10.30.HDTV.x264-EVOLVE.mkv", - SceneName = "Series.Title.2013.10.30.HDTV.x264-EVOLVE", - ReleaseGroup = "RlsGrp", - MediaInfo = mediaInfo - }; - - _animeEpisodeFile = new EpisodeFile - { - Quality = new QualityModel(Quality.HDTV720p, new Revision(2)), - RelativePath = "[RlsGroup] Series Title - 001 [720p].mkv", - SceneName = "[RlsGroup] Series Title - 001 [720p]", - ReleaseGroup = "RlsGrp", - MediaInfo = mediaInfoAnime - }; - _animeMultiEpisodeFile = new EpisodeFile - { - Quality = new QualityModel(Quality.HDTV720p, new Revision(2)), - RelativePath = "[RlsGroup] Series Title - 001 - 103 [720p].mkv", - SceneName = "[RlsGroup] Series Title - 001 - 103 [720p]", - ReleaseGroup = "RlsGrp", - MediaInfo = mediaInfoAnime - }; } - public SampleResult GetStandardSample(NamingConfig nameSpec) + public SampleResult GetStandardTrackSample(NamingConfig nameSpec) { - var result = new SampleResult - { - FileName = BuildSample(_singleEpisode, _standardSeries, _singleEpisodeFile, nameSpec), - Series = _standardSeries, - Episodes = _singleEpisode, - EpisodeFile = _singleEpisodeFile - }; - - return result; - } - - public SampleResult GetMultiEpisodeSample(NamingConfig nameSpec) - { - var result = new SampleResult - { - FileName = BuildSample(_multiEpisodes, _standardSeries, _multiEpisodeFile, nameSpec), - Series = _standardSeries, - Episodes = _multiEpisodes, - EpisodeFile = _multiEpisodeFile - }; - - return result; - } + _track1.AlbumRelease = _singleRelease; - public SampleResult GetDailySample(NamingConfig nameSpec) - { var result = new SampleResult { - FileName = BuildSample(_singleEpisode, _dailySeries, _dailyEpisodeFile, nameSpec), - Series = _dailySeries, - Episodes = _singleEpisode, - EpisodeFile = _dailyEpisodeFile + FileName = BuildTrackSample(_singleTrack, _standardArtist, _standardAlbum, _singleTrackFile, nameSpec), + Artist = _standardArtist, + Album = _standardAlbum, + Tracks = _singleTrack, + TrackFile = _singleTrackFile }; return result; } - public SampleResult GetAnimeSample(NamingConfig nameSpec) + public SampleResult GetMultiDiscTrackSample(NamingConfig nameSpec) { - var result = new SampleResult - { - FileName = BuildSample(_singleEpisode, _animeSeries, _animeEpisodeFile, nameSpec), - Series = _animeSeries, - Episodes = _singleEpisode, - EpisodeFile = _animeEpisodeFile - }; + _track1.AlbumRelease = _multiRelease; - return result; - } - - public SampleResult GetAnimeMultiEpisodeSample(NamingConfig nameSpec) - { var result = new SampleResult { - FileName = BuildSample(_multiEpisodes, _animeSeries, _animeMultiEpisodeFile, nameSpec), - Series = _animeSeries, - Episodes = _multiEpisodes, - EpisodeFile = _animeMultiEpisodeFile + FileName = BuildTrackSample(_singleTrack, _standardArtist, _standardAlbum, _singleTrackFile, nameSpec), + Artist = _standardArtist, + Album = _standardAlbum, + Tracks = _singleTrack, + TrackFile = _singleTrackFile }; return result; } - public string GetSeriesFolderSample(NamingConfig nameSpec) + public string GetArtistFolderSample(NamingConfig nameSpec) { - return _buildFileNames.GetSeriesFolder(_standardSeries, nameSpec); + return _buildFileNames.GetArtistFolder(_standardArtist, nameSpec); } - public string GetSeasonFolderSample(NamingConfig nameSpec) + public string GetAlbumFolderSample(NamingConfig nameSpec) { - return _buildFileNames.GetSeasonFolder(_standardSeries, _episode1.SeasonNumber, nameSpec); + return _buildFileNames.GetAlbumFolder(_standardArtist, _standardAlbum, nameSpec); } - private string BuildSample(List<Episode> episodes, Series series, EpisodeFile episodeFile, NamingConfig nameSpec) + private string BuildTrackSample(List<Track> tracks, Artist artist, Album album, TrackFile trackFile, NamingConfig nameSpec) { try { - return _buildFileNames.BuildFileName(episodes, series, episodeFile, nameSpec); + return _buildFileNames.BuildTrackFileName(tracks, artist, album, trackFile, nameSpec, _preferredWords); } catch (NamingFormatException) { diff --git a/src/NzbDrone.Core/Organizer/FileNameValidation.cs b/src/NzbDrone.Core/Organizer/FileNameValidation.cs index 930b8a044..0f9744211 100644 --- a/src/NzbDrone.Core/Organizer/FileNameValidation.cs +++ b/src/NzbDrone.Core/Organizer/FileNameValidation.cs @@ -1,4 +1,4 @@ -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; using FluentValidation; using FluentValidation.Validators; @@ -6,92 +6,33 @@ namespace NzbDrone.Core.Organizer { public static class FileNameValidation { - private static readonly Regex SeasonFolderRegex = new Regex(@"(\{season(\:\d+)?\})", - RegexOptions.Compiled | RegexOptions.IgnoreCase); - internal static readonly Regex OriginalTokenRegex = new Regex(@"(\{original[- ._](?:title|filename)\})", RegexOptions.Compiled | RegexOptions.IgnoreCase); - public static IRuleBuilderOptions<T, string> ValidEpisodeFormat<T>(this IRuleBuilder<T, string> ruleBuilder) - { - ruleBuilder.SetValidator(new NotEmptyValidator(null)); - return ruleBuilder.SetValidator(new ValidStandardEpisodeFormatValidator()); - } - - public static IRuleBuilderOptions<T, string> ValidDailyEpisodeFormat<T>(this IRuleBuilder<T, string> ruleBuilder) + public static IRuleBuilderOptions<T, string> ValidTrackFormat<T>(this IRuleBuilder<T, string> ruleBuilder) { ruleBuilder.SetValidator(new NotEmptyValidator(null)); - return ruleBuilder.SetValidator(new ValidDailyEpisodeFormatValidator()); + return ruleBuilder.SetValidator(new ValidStandardTrackFormatValidator()); } - public static IRuleBuilderOptions<T, string> ValidAnimeEpisodeFormat<T>(this IRuleBuilder<T, string> ruleBuilder) + public static IRuleBuilderOptions<T, string> ValidArtistFolderFormat<T>(this IRuleBuilder<T, string> ruleBuilder) { ruleBuilder.SetValidator(new NotEmptyValidator(null)); - return ruleBuilder.SetValidator(new ValidAnimeEpisodeFormatValidator()); + return ruleBuilder.SetValidator(new RegularExpressionValidator(FileNameBuilder.ArtistNameRegex)).WithMessage("Must contain Artist name"); } - public static IRuleBuilderOptions<T, string> ValidSeriesFolderFormat<T>(this IRuleBuilder<T, string> ruleBuilder) + public static IRuleBuilderOptions<T, string> ValidAlbumFolderFormat<T>(this IRuleBuilder<T, string> ruleBuilder) { ruleBuilder.SetValidator(new NotEmptyValidator(null)); - return ruleBuilder.SetValidator(new RegularExpressionValidator(FileNameBuilder.SeriesTitleRegex)).WithMessage("Must contain series title"); - } - - public static IRuleBuilderOptions<T, string> ValidSeasonFolderFormat<T>(this IRuleBuilder<T, string> ruleBuilder) - { - ruleBuilder.SetValidator(new NotEmptyValidator(null)); - return ruleBuilder.SetValidator(new RegularExpressionValidator(SeasonFolderRegex)).WithMessage("Must contain season number"); - } - } - - public class ValidStandardEpisodeFormatValidator : PropertyValidator - { - public ValidStandardEpisodeFormatValidator() - : base("Must contain season and episode numbers OR Original Title") - { - - } - - protected override bool IsValid(PropertyValidatorContext context) - { - var value = context.PropertyValue as string; - - if (!FileNameBuilder.SeasonEpisodePatternRegex.IsMatch(value) && - !FileNameValidation.OriginalTokenRegex.IsMatch(value)) - { - return false; - } - - return true; - } - } - - public class ValidDailyEpisodeFormatValidator : PropertyValidator - { - public ValidDailyEpisodeFormatValidator() - : base("Must contain Air Date OR Season and Episode OR Original Title") - { - - } - - protected override bool IsValid(PropertyValidatorContext context) - { - var value = context.PropertyValue as string; - - if (!FileNameBuilder.SeasonEpisodePatternRegex.IsMatch(value) && - !FileNameBuilder.AirDateRegex.IsMatch(value) && - !FileNameValidation.OriginalTokenRegex.IsMatch(value)) - { - return false; - } - - return true; + return ruleBuilder.SetValidator(new RegularExpressionValidator(FileNameBuilder.AlbumTitleRegex)).WithMessage("Must contain Album title"); + //.SetValidator(new RegularExpressionValidator(FileNameBuilder.ReleaseDateRegex)).WithMessage("Must contain Release year"); } } - public class ValidAnimeEpisodeFormatValidator : PropertyValidator + public class ValidStandardTrackFormatValidator : PropertyValidator { - public ValidAnimeEpisodeFormatValidator() - : base("Must contain Absolute Episode number OR Season and Episode OR Original Title") + public ValidStandardTrackFormatValidator() + : base("Must contain Track Title and Track numbers OR Original Title") { } @@ -100,8 +41,8 @@ namespace NzbDrone.Core.Organizer { var value = context.PropertyValue as string; - if (!FileNameBuilder.SeasonEpisodePatternRegex.IsMatch(value) && - !FileNameBuilder.AbsoluteEpisodePatternRegex.IsMatch(value) && + if (!(FileNameBuilder.TrackTitleRegex.IsMatch(value) && + FileNameBuilder.TrackRegex.IsMatch(value)) && !FileNameValidation.OriginalTokenRegex.IsMatch(value)) { return false; diff --git a/src/NzbDrone.Core/Organizer/FileNameValidationService.cs b/src/NzbDrone.Core/Organizer/FileNameValidationService.cs index 9367c11d8..324940453 100644 --- a/src/NzbDrone.Core/Organizer/FileNameValidationService.cs +++ b/src/NzbDrone.Core/Organizer/FileNameValidationService.cs @@ -1,105 +1,39 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using FluentValidation.Results; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Tv; namespace NzbDrone.Core.Organizer { public interface IFilenameValidationService { - ValidationFailure ValidateStandardFilename(SampleResult sampleResult); - ValidationFailure ValidateDailyFilename(SampleResult sampleResult); - ValidationFailure ValidateAnimeFilename(SampleResult sampleResult); + ValidationFailure ValidateTrackFilename(SampleResult sampleResult); } public class FileNameValidationService : IFilenameValidationService { private const string ERROR_MESSAGE = "Produces invalid file names"; - public ValidationFailure ValidateStandardFilename(SampleResult sampleResult) + public ValidationFailure ValidateTrackFilename(SampleResult sampleResult) { - var validationFailure = new ValidationFailure("StandardEpisodeFormat", ERROR_MESSAGE); - var parsedEpisodeInfo = Parser.Parser.ParseTitle(sampleResult.FileName); + var validationFailure = new ValidationFailure("StandardTrackFormat", ERROR_MESSAGE); - if (parsedEpisodeInfo == null) - { - return validationFailure; - } + //TODO Add Validation for TrackFilename + //var parsedEpisodeInfo = Parser.Parser.ParseTitle(sampleResult.FileName); - if (!ValidateSeasonAndEpisodeNumbers(sampleResult.Episodes, parsedEpisodeInfo)) - { - return validationFailure; - } - return null; - } - - public ValidationFailure ValidateDailyFilename(SampleResult sampleResult) - { - var validationFailure = new ValidationFailure("DailyEpisodeFormat", ERROR_MESSAGE); - var parsedEpisodeInfo = Parser.Parser.ParseTitle(sampleResult.FileName); - - if (parsedEpisodeInfo == null) - { - return validationFailure; - } + //if (parsedEpisodeInfo == null) + //{ + // return validationFailure; + //} - if (parsedEpisodeInfo.IsDaily) - { - if (!parsedEpisodeInfo.AirDate.Equals(sampleResult.Episodes.Single().AirDate)) - { - return validationFailure; - } - - return null; - } - - if (!ValidateSeasonAndEpisodeNumbers(sampleResult.Episodes, parsedEpisodeInfo)) - { - return validationFailure; - } + //if (!ValidateSeasonAndEpisodeNumbers(sampleResult.Episodes, parsedEpisodeInfo)) + //{ + // return validationFailure; + //} return null; } - public ValidationFailure ValidateAnimeFilename(SampleResult sampleResult) - { - var validationFailure = new ValidationFailure("AnimeEpisodeFormat", ERROR_MESSAGE); - var parsedEpisodeInfo = Parser.Parser.ParseTitle(sampleResult.FileName); - - if (parsedEpisodeInfo == null) - { - return validationFailure; - } - - if (parsedEpisodeInfo.AbsoluteEpisodeNumbers.Any()) - { - if (!parsedEpisodeInfo.AbsoluteEpisodeNumbers.First().Equals(sampleResult.Episodes.First().AbsoluteEpisodeNumber)) - { - return validationFailure; - } - - return null; - } - - if (!ValidateSeasonAndEpisodeNumbers(sampleResult.Episodes, parsedEpisodeInfo)) - { - return validationFailure; - } - - return null; - } - - private bool ValidateSeasonAndEpisodeNumbers(List<Episode> episodes, ParsedEpisodeInfo parsedEpisodeInfo) - { - if (parsedEpisodeInfo.SeasonNumber != episodes.First().SeasonNumber || - !parsedEpisodeInfo.EpisodeNumbers.OrderBy(e => e).SequenceEqual(episodes.Select(e => e.EpisodeNumber).OrderBy(e => e))) - { - return false; - } - - return true; - } } } diff --git a/src/NzbDrone.Core/Organizer/NamingConfig.cs b/src/NzbDrone.Core/Organizer/NamingConfig.cs index 5de62a090..4226200d5 100644 --- a/src/NzbDrone.Core/Organizer/NamingConfig.cs +++ b/src/NzbDrone.Core/Organizer/NamingConfig.cs @@ -6,23 +6,19 @@ namespace NzbDrone.Core.Organizer { public static NamingConfig Default => new NamingConfig { - RenameEpisodes = false, + RenameTracks = false, ReplaceIllegalCharacters = true, - MultiEpisodeStyle = 0, - StandardEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}", - DailyEpisodeFormat = "{Series Title} - {Air-Date} - {Episode Title} {Quality Full}", - AnimeEpisodeFormat = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}", - SeriesFolderFormat = "{Series Title}", - SeasonFolderFormat = "Season {season}" + StandardTrackFormat = "{Artist Name} - {Album Title} - {track:00} - {Track Title}", + MultiDiscTrackFormat = "{Medium Format} {medium:00}/{Artist Name} - {Album Title} - {track:00} - {Track Title}", + ArtistFolderFormat = "{Artist Name}", + AlbumFolderFormat = "{Album Title} ({Release Year})" }; - public bool RenameEpisodes { get; set; } + public bool RenameTracks { get; set; } public bool ReplaceIllegalCharacters { get; set; } - public int MultiEpisodeStyle { get; set; } - public string StandardEpisodeFormat { get; set; } - public string DailyEpisodeFormat { get; set; } - public string AnimeEpisodeFormat { get; set; } - public string SeriesFolderFormat { get; set; } - public string SeasonFolderFormat { get; set; } + public string StandardTrackFormat { get; set; } + public string MultiDiscTrackFormat { get; set; } + public string ArtistFolderFormat { get; set; } + public string AlbumFolderFormat { get; set; } } } diff --git a/src/NzbDrone.Core/Organizer/NamingConfigService.cs b/src/NzbDrone.Core/Organizer/NamingConfigService.cs index 1cbe993dc..ec802bc00 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/Organizer/SampleResult.cs b/src/NzbDrone.Core/Organizer/SampleResult.cs index 0f3885a1b..fb85df202 100644 --- a/src/NzbDrone.Core/Organizer/SampleResult.cs +++ b/src/NzbDrone.Core/Organizer/SampleResult.cs @@ -1,14 +1,15 @@ -using System.Collections.Generic; +using System.Collections.Generic; using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Organizer { public class SampleResult { public string FileName { get; set; } - public Series Series { get; set; } - public List<Episode> Episodes { get; set; } - public EpisodeFile EpisodeFile { get; set; } + public Artist Artist { get; set; } + public Album Album { get; set; } + public List<Track> Tracks { get; set; } + public TrackFile TrackFile { get; set; } } } diff --git a/src/NzbDrone.Core/Organizer/TrackFormat.cs b/src/NzbDrone.Core/Organizer/TrackFormat.cs new file mode 100644 index 000000000..bf37011e3 --- /dev/null +++ b/src/NzbDrone.Core/Organizer/TrackFormat.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Organizer +{ + public class AbsoluteTrackFormat + { + public string Separator { get; set; } + public string AbsoluteTrackPattern { get; set; } + } +} diff --git a/src/NzbDrone.Core/Parser/FingerprintingService.cs b/src/NzbDrone.Core/Parser/FingerprintingService.cs new file mode 100644 index 000000000..596583488 --- /dev/null +++ b/src/NzbDrone.Core/Parser/FingerprintingService.cs @@ -0,0 +1,461 @@ +using System.IO; +using NLog; +using NzbDrone.Core.Parser.Model; +using System.Diagnostics; +using System.Linq; +using NzbDrone.Common.Http; +using NzbDrone.Common.Extensions; +using System.Collections.Generic; +using System.IO.Compression; +using System.Text; +using NzbDrone.Common.Serializer; +using System; +using NzbDrone.Common.EnvironmentInfo; +using System.Threading; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using NzbDrone.Common.Cache; + +namespace NzbDrone.Core.Parser +{ + public interface IFingerprintingService + { + bool IsSetup(); + Version FpcalcVersion(); + void Lookup(List<LocalTrack> tracks, double threshold); + } + + public class AcoustId + { + public double Duration { get; set; } + public string Fingerprint { get; set; } + } + + public class FingerprintingService : IFingerprintingService + { + private const string _acoustIdUrl = "https://api.acoustid.org/v2/lookup"; + private const string _acoustIdApiKey = "QANd68ji1L"; + private const int _fingerprintingTimeout = 10000; + + private readonly Logger _logger; + private readonly IHttpClient _httpClient; + private readonly IHttpRequestBuilderFactory _customerRequestBuilder; + private readonly ICached<AcoustId> _cache; + + private readonly string _fpcalcPath; + private readonly Version _fpcalcVersion; + private readonly string _fpcalcArgs; + + public FingerprintingService(Logger logger, + IHttpClient httpClient, + ICacheManager cacheManager) + { + _logger = logger; + _httpClient = httpClient; + _cache = cacheManager.GetCache<AcoustId>(GetType()); + + _customerRequestBuilder = new HttpRequestBuilder(_acoustIdUrl).CreateFactory(); + + // An exception here will cause Lidarr to fail to start, so catch any errors + try + { + _fpcalcPath = GetFpcalcPath(); + + if (_fpcalcPath.IsNotNullOrWhiteSpace()) + { + _fpcalcVersion = GetFpcalcVersion(); + _fpcalcArgs = GetFpcalcArgs(); + } + } + catch (Exception ex) + { + _logger.Error(ex, "Somthing went wrong detecting fpcalc"); + } + } + + public bool IsSetup() => _fpcalcPath.IsNotNullOrWhiteSpace(); + public Version FpcalcVersion() => _fpcalcVersion; + + private string GetFpcalcPath() + { + string path = null; + if (OsInfo.IsLinux) + { + // must be on users path on Linux + path = "fpcalc"; + + // check that the command exists + Process p = new Process(); + p.StartInfo.FileName = path; + p.StartInfo.Arguments = "-version"; + p.StartInfo.UseShellExecute = false; + p.StartInfo.CreateNoWindow = true; + p.StartInfo.RedirectStandardOutput = true; + + try + { + p.Start(); + // To avoid deadlocks, always read the output stream first and then wait. + string output = p.StandardOutput.ReadToEnd(); + p.WaitForExit(1000); + } + catch + { + _logger.Debug("fpcalc not found"); + return null; + } + } + else + { + // on OSX / Windows, we have put fpcalc in the application folder + path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "fpcalc"); + if (OsInfo.IsWindows) + { + path += ".exe"; + } + + if (!File.Exists(path)) + { + _logger.Warn("fpcalc missing from application directory"); + return null; + } + } + + _logger.Debug($"fpcalc path: {path}"); + return path; + } + + private Version GetFpcalcVersion() + { + if (_fpcalcPath == null) + { + return null; + } + + Process p = new Process(); + p.StartInfo.FileName = _fpcalcPath; + p.StartInfo.Arguments = $"-version"; + p.StartInfo.UseShellExecute = false; + p.StartInfo.CreateNoWindow = true; + p.StartInfo.RedirectStandardOutput = true; + + p.Start(); + // To avoid deadlocks, always read the output stream first and then wait. + string output = p.StandardOutput.ReadToEnd(); + p.WaitForExit(1000); + + if (p.ExitCode != 0) + { + _logger.Warn("Could not get fpcalc version (may be known issue with fpcalc v1.4)"); + return null; + } + + var versionstring = Regex.Match(output, @"\d\.\d\.\d").Value; + if (versionstring.IsNullOrWhiteSpace()) + { + return null; + } + + var version = new Version(versionstring); + _logger.Debug($"fpcalc version: {version}"); + + return version; + } + + private string GetFpcalcArgs() + { + var args = ""; + + if (_fpcalcVersion == null) + { + return args; + } + + if (_fpcalcVersion >= new Version("1.4.0")) + { + args = "-json"; + } + + if (_fpcalcVersion >= new Version("1.4.3")) + { + args += " -ignore-errors"; + } + + return args; + } + + public AcoustId ParseFpcalcJsonOutput(string output) + { + return Json.Deserialize<AcoustId>(output); + } + + public AcoustId ParseFpcalcTextOutput(string output) + { + var durationstring = Regex.Match(output, @"(?<=DURATION=)[\d\.]+(?=\s)").Value; + double duration; + if (durationstring.IsNullOrWhiteSpace() || !double.TryParse(durationstring, out duration)) + { + return null; + } + + var fingerprint = Regex.Match(output, @"(?<=FINGERPRINT=)[^\s]+").Value; + if (fingerprint.IsNullOrWhiteSpace()) + { + return null; + } + + return new AcoustId { + Duration = duration, + Fingerprint = fingerprint + }; + } + + public AcoustId ParseFpcalcOutput(string output) + { + if (output.IsNullOrWhiteSpace()) + { + return null; + } + + if (_fpcalcArgs.Contains("-json")) + { + return ParseFpcalcJsonOutput(output); + } + else + { + return ParseFpcalcTextOutput(output); + } + } + + public AcoustId GetFingerprint(string file) + { + return _cache.Get(file, () => GetFingerprintUncached(file), TimeSpan.FromMinutes(30)); + } + + private AcoustId GetFingerprintUncached(string file) + { + if (IsSetup() && File.Exists(file)) + { + Process p = new Process(); + p.StartInfo.FileName = _fpcalcPath; + p.StartInfo.Arguments = $"{_fpcalcArgs} \"{file}\""; + p.StartInfo.UseShellExecute = false; + p.StartInfo.CreateNoWindow = true; + p.StartInfo.RedirectStandardOutput = true; + p.StartInfo.RedirectStandardError = true; + + _logger.Trace("Executing {0} {1}", p.StartInfo.FileName, p.StartInfo.Arguments); + + StringBuilder output = new StringBuilder(); + StringBuilder error = new StringBuilder(); + + // see https://stackoverflow.com/questions/139593/processstartinfo-hanging-on-waitforexit-why?lq=1 + // this is most likely overkill... + using (AutoResetEvent outputWaitHandle = new AutoResetEvent(false)) + { + using (AutoResetEvent errorWaitHandle = new AutoResetEvent(false)) + { + DataReceivedEventHandler outputHandler = delegate(object sender, DataReceivedEventArgs e) + { + if (e.Data == null) + { + outputWaitHandle.Set(); + } + else + { + output.AppendLine(e.Data); + } + }; + + DataReceivedEventHandler errorHandler = delegate(object sender, DataReceivedEventArgs e) + { + if (e.Data == null) + { + errorWaitHandle.Set(); + } + else + { + error.AppendLine(e.Data); + } + }; + + p.OutputDataReceived += outputHandler; + p.ErrorDataReceived += errorHandler; + + p.Start(); + + p.BeginOutputReadLine(); + p.BeginErrorReadLine(); + + if (p.WaitForExit(_fingerprintingTimeout) && + outputWaitHandle.WaitOne(_fingerprintingTimeout) && + errorWaitHandle.WaitOne(_fingerprintingTimeout)) + { + // Process completed. + if (p.ExitCode != 0) + { + _logger.Warn($"fpcalc error: {error}"); + return null; + } + else + { + return ParseFpcalcOutput(output.ToString()); + } + } + else + { + // Timed out. Remove handlers to avoid object disposed error + p.OutputDataReceived -= outputHandler; + p.ErrorDataReceived -= errorHandler; + + _logger.Warn($"fpcalc timed out. {error}"); + return null; + } + } + } + } + + return null; + } + + private static byte[] Compress(byte[] data) + { + using (var compressedStream = new MemoryStream()) + using (var zipStream = new GZipStream(compressedStream, CompressionMode.Compress)) + { + zipStream.Write(data, 0, data.Length); + zipStream.Close(); + return compressedStream.ToArray(); + } + } + + public void Lookup(List<LocalTrack> tracks, double threshold) + { + if (!IsSetup()) + { + return; + } + + Lookup(tracks.Select(x => Tuple.Create(x, GetFingerprint(x.Path))).ToList(), threshold); + } + + public void Lookup(List<Tuple<LocalTrack, AcoustId>> files, double threshold) + { + var toLookup = files.Where(x => x.Item2 != null).ToList(); + if (!toLookup.Any()) + { + return; + } + + var httpRequest = _customerRequestBuilder.Create() + .WithRateLimit(0.334) + .Build(); + + var sb = new StringBuilder($"client={_acoustIdApiKey}&format=json&meta=recordingids&batch=1", 2000); + for (int i = 0; i < toLookup.Count; i++) + { + sb.Append($"&duration.{i}={toLookup[i].Item2.Duration:F0}&fingerprint.{i}={toLookup[i].Item2.Fingerprint}"); + } + + // they prefer a gzipped body + httpRequest.SetContent(Compress(Encoding.UTF8.GetBytes(sb.ToString()))); + httpRequest.Headers.Add("Content-Encoding", "gzip"); + httpRequest.Headers.ContentType = "application/x-www-form-urlencoded"; + httpRequest.SuppressHttpError = true; + httpRequest.RequestTimeout = TimeSpan.FromSeconds(5); + + HttpResponse<LookupResponse> httpResponse; + + try + { + httpResponse = _httpClient.Post<LookupResponse>(httpRequest); + } + catch (UnexpectedHtmlContentException e) + { + _logger.Warn(e, "AcoustId API gave invalid response"); + return; + } + catch (Exception e) + { + _logger.Warn(e, "AcoustId API lookup failed"); + return; + } + + var response = httpResponse.Resource; + + // The API will give errors if fingerprint isn't found or is invalid. + // We don't want to stop the entire import because the fingerprinting failed + // so just log and return. + if (httpResponse.HasHttpError || (response != null && response.Status != "ok")) + { + if (response != null && response.Error != null) + { + _logger.Debug($"Webservice error {response.Error.Code}: {response.Error.Message}"); + } + else + { + _logger.Warn("HTTP Error - {0}", httpResponse); + } + + return; + } + + foreach (var fileResponse in response.Fingerprints) + { + if (fileResponse.Results.Count == 0) + { + _logger.Debug("No results for given fingerprint."); + continue; + } + + foreach (var result in fileResponse.Results.Where(x => x.Recordings != null)) + { + _logger.Trace("Found: {0}, {1}, {2}", result.Id, result.Score, string.Join(", ", result.Recordings.Select(x => x.Id))); + } + + var ids = fileResponse.Results.Where(x => x.Score > threshold && x.Recordings != null).SelectMany(y => y.Recordings.Select(z => z.Id)).Distinct().ToList(); + _logger.Trace("All recordings: {0}", string.Join("\n", ids)); + + toLookup[fileResponse.index].Item1.AcoustIdResults = ids; + } + + _logger.Debug("Fingerprinting complete."); + + var SerializerSettings = Json.GetSerializerSettings(); + SerializerSettings.Formatting = Formatting.None; + var output = new { Fingerprints = toLookup.Select(x => new { Path = x.Item1.Path, AcoustIdResults = x.Item1.AcoustIdResults }) }; + _logger.Debug($"*** FingerprintingService TestCaseGenerator ***\n{JsonConvert.SerializeObject(output, SerializerSettings)}"); + } + + public class LookupResponse + { + public string Status { get; set; } + public LookupError Error { get; set; } + public List<LookupResultListItem> Fingerprints { get; set; } + } + + public class LookupError + { + public string Message { get; set; } + public int Code { get; set; } + } + + public class LookupResultListItem + { + public int index { get; set; } + public List<LookupResult> Results { get; set; } + } + + public class LookupResult + { + public string Id { get; set; } + public double Score { get; set; } + public List<RecordingResult> Recordings { get; set; } + } + + public class RecordingResult + { + public string Id { get; set; } + } + } +} diff --git a/src/NzbDrone.Core/Parser/IsoCountries.cs b/src/NzbDrone.Core/Parser/IsoCountries.cs new file mode 100644 index 000000000..2f207be28 --- /dev/null +++ b/src/NzbDrone.Core/Parser/IsoCountries.cs @@ -0,0 +1,289 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.Parser +{ + public static class IsoCountries + { + // see https://wiki.musicbrainz.org/Release_Country + private static readonly HashSet<IsoCountry> All = new HashSet<IsoCountry> + { + new IsoCountry("AF", "Afghanistan" ), + new IsoCountry("AX", "Åland Islands"), + new IsoCountry("AL", "Albania"), + new IsoCountry("DZ", "Algeria"), + new IsoCountry("AS", "American Samoa"), + new IsoCountry("AD", "Andorra"), + new IsoCountry("AO", "Angola"), + new IsoCountry("AI", "Anguilla"), + new IsoCountry("AQ", "Antarctica"), + new IsoCountry("AG", "Antigua and Barbuda"), + new IsoCountry("AR", "Argentina"), + new IsoCountry("AM", "Armenia"), + new IsoCountry("AW", "Aruba"), + new IsoCountry("AU", "Australia"), + new IsoCountry("AT", "Austria"), + new IsoCountry("AZ", "Azerbaijan"), + new IsoCountry("BS", "Bahamas"), + new IsoCountry("BH", "Bahrain"), + new IsoCountry("BD", "Bangladesh"), + new IsoCountry("BB", "Barbados"), + new IsoCountry("BY", "Belarus"), + new IsoCountry("BE", "Belgium"), + new IsoCountry("BZ", "Belize"), + new IsoCountry("BJ", "Benin"), + new IsoCountry("BM", "Bermuda"), + new IsoCountry("BT", "Bhutan"), + new IsoCountry("BO", "Bolivia"), + new IsoCountry("BA", "Bosnia and Herzegovina"), + new IsoCountry("BW", "Botswana"), + new IsoCountry("BV", "Bouvet Island"), + new IsoCountry("BR", "Brazil"), + new IsoCountry("IO", "British Indian Ocean Territory"), + new IsoCountry("BN", "Brunei Darussalam"), + new IsoCountry("BG", "Bulgaria"), + new IsoCountry("BF", "Burkina Faso"), + new IsoCountry("BI", "Burundi"), + new IsoCountry("KH", "Cambodia"), + new IsoCountry("CM", "Cameroon"), + new IsoCountry("CA", "Canada"), + new IsoCountry("CV", "Cape Verde"), + new IsoCountry("KY", "Cayman Islands"), + new IsoCountry("CF", "Central African Republic"), + new IsoCountry("TD", "Chad"), + new IsoCountry("CL", "Chile"), + new IsoCountry("CN", "China"), + new IsoCountry("CX", "Christmas Island"), + new IsoCountry("CC", "Cocos (Keeling) Islands"), + new IsoCountry("CO", "Colombia"), + new IsoCountry("KM", "Comoros"), + new IsoCountry("CG", "Congo"), + new IsoCountry("CD", "Congo, The Democratic Republic of the"), + new IsoCountry("CK", "Cook Islands"), + new IsoCountry("CR", "Costa Rica"), + new IsoCountry("CI", "Cote d'Ivoire"), + new IsoCountry("HR", "Croatia"), + new IsoCountry("CU", "Cuba"), + new IsoCountry("CY", "Cyprus"), + new IsoCountry("XC", "Czechoslovakia"), + new IsoCountry("CZ", "Czech Republic"), + new IsoCountry("DK", "Denmark"), + new IsoCountry("DJ", "Djibouti"), + new IsoCountry("DM", "Dominica"), + new IsoCountry("DO", "Dominican Republic"), + new IsoCountry("XG", "East Germany"), + new IsoCountry("EC", "Ecuador"), + new IsoCountry("EG", "Egypt"), + new IsoCountry("SV", "El Salvador"), + new IsoCountry("GQ", "Equatorial Guinea"), + new IsoCountry("ER", "Eritrea"), + new IsoCountry("EE", "Estonia"), + new IsoCountry("ET", "Ethiopia"), + new IsoCountry("XE", "Europe"), + new IsoCountry("FK", "Falkland Islands (Malvinas)"), + new IsoCountry("FO", "Faroe Islands"), + new IsoCountry("FJ", "Fiji"), + new IsoCountry("FI", "Finland"), + new IsoCountry("FR", "France"), + new IsoCountry("GF", "French Guiana"), + new IsoCountry("PF", "French Polynesia"), + new IsoCountry("TF", "French Southern Territories"), + new IsoCountry("GA", "Gabon"), + new IsoCountry("GM", "Gambia"), + new IsoCountry("GE", "Georgia"), + new IsoCountry("DE", "Germany"), + new IsoCountry("GH", "Ghana"), + new IsoCountry("GI", "Gibraltar"), + new IsoCountry("GR", "Greece"), + new IsoCountry("GL", "Greenland"), + new IsoCountry("GD", "Grenada"), + new IsoCountry("GP", "Guadeloupe"), + new IsoCountry("GU", "Guam"), + new IsoCountry("GT", "Guatemala"), + new IsoCountry("GG", "Guernsey"), + new IsoCountry("GN", "Guinea"), + new IsoCountry("GW", "Guinea-Bissau"), + new IsoCountry("GY", "Guyana"), + new IsoCountry("HT", "Haiti"), + new IsoCountry("HM", "Heard and Mc Donald Islands"), + new IsoCountry("HN", "Honduras"), + new IsoCountry("HK", "Hong Kong"), + new IsoCountry("HU", "Hungary"), + new IsoCountry("IS", "Iceland"), + new IsoCountry("IN", "India"), + new IsoCountry("ID", "Indonesia"), + new IsoCountry("IR", "Iran (Islamic Republic of)"), + new IsoCountry("IQ", "Iraq"), + new IsoCountry("IE", "Ireland"), + new IsoCountry("IM", "Isle of Man"), + new IsoCountry("IL", "Israel"), + new IsoCountry("IT", "Italy"), + new IsoCountry("JM", "Jamaica"), + new IsoCountry("JP", "Japan"), + new IsoCountry("JE", "Jersey"), + new IsoCountry("JO", "Jordan"), + new IsoCountry("KZ", "Kazakhstan"), + new IsoCountry("KE", "Kenya"), + new IsoCountry("KI", "Kiribati"), + new IsoCountry("KP", "Korea (North), Democratic People's Republic of"), + new IsoCountry("KR", "Korea (South), Republic of"), + new IsoCountry("KW", "Kuwait"), + new IsoCountry("KG", "Kyrgyzstan"), + new IsoCountry("LA", "Lao People's Democratic Republic"), + new IsoCountry("LV", "Latvia"), + new IsoCountry("LB", "Lebanon"), + new IsoCountry("LS", "Lesotho"), + new IsoCountry("LR", "Liberia"), + new IsoCountry("LY", "Libyan Arab Jamahiriya"), + new IsoCountry("LI", "Liechtenstein"), + new IsoCountry("LT", "Lithuania"), + new IsoCountry("LU", "Luxembourg"), + new IsoCountry("MO", "Macau"), + new IsoCountry("MK", "Macedonia, The Former Yugoslav Republic of"), + new IsoCountry("MG", "Madagascar"), + new IsoCountry("MW", "Malawi"), + new IsoCountry("MY", "Malaysia"), + new IsoCountry("MV", "Maldives"), + new IsoCountry("ML", "Mali"), + new IsoCountry("MT", "Malta"), + new IsoCountry("MH", "Marshall Islands"), + new IsoCountry("MQ", "Martinique"), + new IsoCountry("MR", "Mauritania"), + new IsoCountry("MU", "Mauritius"), + new IsoCountry("YT", "Mayotte"), + new IsoCountry("MX", "Mexico"), + new IsoCountry("FM", "Micronesia, Federated States of"), + new IsoCountry("MD", "Moldova, Republic of"), + new IsoCountry("MC", "Monaco"), + new IsoCountry("MN", "Mongolia"), + new IsoCountry("ME", "Montenegro"), + new IsoCountry("MS", "Montserrat"), + new IsoCountry("MA", "Morocco"), + new IsoCountry("MZ", "Mozambique"), + new IsoCountry("MM", "Myanmar"), + new IsoCountry("NA", "Namibia"), + new IsoCountry("NR", "Nauru"), + new IsoCountry("NP", "Nepal"), + new IsoCountry("NL", "Netherlands"), + new IsoCountry("AN", "Netherlands Antilles"), + new IsoCountry("NC", "New Caledonia"), + new IsoCountry("NZ", "New Zealand"), + new IsoCountry("NI", "Nicaragua"), + new IsoCountry("NE", "Niger"), + new IsoCountry("NG", "Nigeria"), + new IsoCountry("NU", "Niue"), + new IsoCountry("NF", "Norfolk Island"), + new IsoCountry("MP", "Northern Mariana Islands"), + new IsoCountry("NO", "Norway"), + new IsoCountry("OM", "Oman"), + new IsoCountry("PK", "Pakistan"), + new IsoCountry("PW", "Palau"), + new IsoCountry("PS", "Palestinian Territory"), + new IsoCountry("PA", "Panama"), + new IsoCountry("PG", "Papua New Guinea"), + new IsoCountry("PY", "Paraguay"), + new IsoCountry("PE", "Peru"), + new IsoCountry("PH", "Philippines"), + new IsoCountry("PN", "Pitcairn"), + new IsoCountry("PL", "Poland"), + new IsoCountry("PT", "Portugal"), + new IsoCountry("PR", "Puerto Rico"), + new IsoCountry("QA", "Qatar"), + new IsoCountry("RE", "Reunion"), + new IsoCountry("RO", "Romania"), + new IsoCountry("RU", "Russian Federation"), + new IsoCountry("RW", "Rwanda"), + new IsoCountry("BL", "Saint Barthélemy"), + new IsoCountry("SH", "Saint Helena"), + new IsoCountry("KN", "Saint Kitts and Nevis"), + new IsoCountry("LC", "Saint Lucia"), + new IsoCountry("MF", "Saint Martin"), + new IsoCountry("PM", "Saint Pierre and Miquelon"), + new IsoCountry("VC", "Saint Vincent and The Grenadines"), + new IsoCountry("WS", "Samoa"), + new IsoCountry("SM", "San Marino"), + new IsoCountry("ST", "Sao Tome and Principe"), + new IsoCountry("SA", "Saudi Arabia"), + new IsoCountry("SN", "Senegal"), + new IsoCountry("RS", "Serbia"), + new IsoCountry("CS", "Serbia and Montenegro"), + new IsoCountry("SC", "Seychelles"), + new IsoCountry("SL", "Sierra Leone"), + new IsoCountry("SG", "Singapore"), + new IsoCountry("SK", "Slovakia"), + new IsoCountry("SI", "Slovenia"), + new IsoCountry("SB", "Solomon Islands"), + new IsoCountry("SO", "Somalia"), + new IsoCountry("ZA", "South Africa"), + new IsoCountry("GS", "South Georgia and the South Sandwich Islands"), + new IsoCountry("SU", "Soviet Union"), + new IsoCountry("ES", "Spain"), + new IsoCountry("LK", "Sri Lanka"), + new IsoCountry("SD", "Sudan"), + new IsoCountry("SR", "Suriname"), + new IsoCountry("SJ", "Svalbard and Jan Mayen"), + new IsoCountry("SZ", "Swaziland"), + new IsoCountry("SE", "Sweden"), + new IsoCountry("CH", "Switzerland"), + new IsoCountry("SY", "Syrian Arab Republic"), + new IsoCountry("TW", "Taiwan"), + new IsoCountry("TJ", "Tajikistan"), + new IsoCountry("TZ", "Tanzania, United Republic of"), + new IsoCountry("TH", "Thailand"), + new IsoCountry("TL", "Timor-Leste"), + new IsoCountry("TG", "Togo"), + new IsoCountry("TK", "Tokelau"), + new IsoCountry("TO", "Tonga"), + new IsoCountry("TT", "Trinidad and Tobago"), + new IsoCountry("TN", "Tunisia"), + new IsoCountry("TR", "Turkey"), + new IsoCountry("TM", "Turkmenistan"), + new IsoCountry("TC", "Turks and Caicos Islands"), + new IsoCountry("TV", "Tuvalu"), + new IsoCountry("UG", "Uganda"), + new IsoCountry("UA", "Ukraine"), + new IsoCountry("AE", "United Arab Emirates"), + new IsoCountry("GB", "United Kingdom"), + new IsoCountry("US", "United States"), + new IsoCountry("UM", "United States Minor Outlying Islands"), + new IsoCountry("XU", "[Unknown Country]"), + new IsoCountry("UY", "Uruguay"), + new IsoCountry("UZ", "Uzbekistan"), + new IsoCountry("VU", "Vanuatu"), + new IsoCountry("VA", "Vatican City State (Holy See)"), + new IsoCountry("VE", "Venezuela"), + new IsoCountry("VN", "Viet Nam"), + new IsoCountry("VG", "Virgin Islands, British"), + new IsoCountry("VI", "Virgin Islands, U.S."), + new IsoCountry("WF", "Wallis and Futuna Islands"), + new IsoCountry("EH", "Western Sahara"), + new IsoCountry("XW", "[Worldwide]"), + new IsoCountry("YE", "Yemen"), + new IsoCountry("YU", "Yugoslavia"), + new IsoCountry("ZM", "Zambia"), + new IsoCountry("ZW", "Zimbabwe") + }; + + public static IsoCountry Find(string value) + { + if (value.IsNullOrWhiteSpace()) + { + return null; + } + else if (value.Length == 2) + { + return All.SingleOrDefault(l => l.TwoLetterCode.Equals(value, StringComparison.InvariantCultureIgnoreCase)); + } + else if (value.Length == 3) + { + return All.SingleOrDefault(l => l.TwoLetterCode.Equals(value.Substring(0, 2), StringComparison.InvariantCultureIgnoreCase)); + } + else + { + return All.SingleOrDefault(l => l.Name.Equals(value, StringComparison.InvariantCultureIgnoreCase)); + } + } + } +} diff --git a/src/NzbDrone.Core/Parser/IsoCountry.cs b/src/NzbDrone.Core/Parser/IsoCountry.cs new file mode 100644 index 000000000..9fd93b1ed --- /dev/null +++ b/src/NzbDrone.Core/Parser/IsoCountry.cs @@ -0,0 +1,14 @@ +namespace NzbDrone.Core.Parser +{ + public class IsoCountry + { + public string TwoLetterCode { get; set; } + public string Name { get; set; } + + public IsoCountry(string twoLetterCode, string name) + { + TwoLetterCode = twoLetterCode; + Name = name; + } + } +} diff --git a/src/NzbDrone.Core/Parser/IsoLanguage.cs b/src/NzbDrone.Core/Parser/IsoLanguage.cs index 1bd198e50..3a4bbd502 100644 --- a/src/NzbDrone.Core/Parser/IsoLanguage.cs +++ b/src/NzbDrone.Core/Parser/IsoLanguage.cs @@ -1,16 +1,15 @@ -namespace NzbDrone.Core.Parser + +namespace NzbDrone.Core.Parser { public class IsoLanguage { public string TwoLetterCode { get; set; } public string ThreeLetterCode { get; set; } - public Language Language { get; set; } - public IsoLanguage(string twoLetterCode, string threeLetterCode, Language language) + public IsoLanguage(string twoLetterCode, string threeLetterCode) { TwoLetterCode = twoLetterCode; ThreeLetterCode = threeLetterCode; - Language = language; } } } diff --git a/src/NzbDrone.Core/Parser/IsoLanguages.cs b/src/NzbDrone.Core/Parser/IsoLanguages.cs index ddbbe74c2..b961b5ecb 100644 --- a/src/NzbDrone.Core/Parser/IsoLanguages.cs +++ b/src/NzbDrone.Core/Parser/IsoLanguages.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; namespace NzbDrone.Core.Parser @@ -7,28 +7,28 @@ namespace NzbDrone.Core.Parser { private static readonly HashSet<IsoLanguage> All = new HashSet<IsoLanguage> { - new IsoLanguage("en", "eng", Language.English), - new IsoLanguage("fr", "fra", Language.French), - new IsoLanguage("es", "spa", Language.Spanish), - new IsoLanguage("de", "deu", Language.German), - new IsoLanguage("it", "ita", Language.Italian), - new IsoLanguage("da", "dan", Language.Danish), - new IsoLanguage("nl", "nld", Language.Dutch), - new IsoLanguage("ja", "jpn", Language.Japanese), -// new IsoLanguage("", "", Language.Cantonese), -// new IsoLanguage("", "", Language.Mandarin), - new IsoLanguage("ru", "rus", Language.Russian), - new IsoLanguage("pl", "pol", Language.Polish), - new IsoLanguage("vi", "vie", Language.Vietnamese), - new IsoLanguage("sv", "swe", Language.Swedish), - new IsoLanguage("no", "nor", Language.Norwegian), - new IsoLanguage("fi", "fin", Language.Finnish), - new IsoLanguage("tr", "tur", Language.Turkish), - new IsoLanguage("pt", "por", Language.Portuguese), -// 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("en", "eng"), + new IsoLanguage("fr", "fra"), + new IsoLanguage("es", "spa"), + new IsoLanguage("de", "deu"), + new IsoLanguage("it", "ita"), + new IsoLanguage("da", "dan"), + new IsoLanguage("nl", "nld"), + new IsoLanguage("ja", "jpn"), +// new IsoLanguage("", ""), +// new IsoLanguage("", ""), + new IsoLanguage("ru", "rus"), + new IsoLanguage("pl", "pol"), + new IsoLanguage("vi", "vie"), + new IsoLanguage("sv", "swe"), + new IsoLanguage("no", "nor"), + new IsoLanguage("fi", "fin"), + new IsoLanguage("tr", "tur"), + new IsoLanguage("pt", "por"), +// new IsoLanguage("nl", "nld"), + new IsoLanguage("el", "ell"), + new IsoLanguage("ko", "kor"), + new IsoLanguage("hu", "hun") }; public static IsoLanguage Find(string isoCode) @@ -46,10 +46,5 @@ namespace NzbDrone.Core.Parser return null; } - - public static IsoLanguage Get(Language language) - { - return All.SingleOrDefault(l => l.Language == language); - } } } diff --git a/src/NzbDrone.Core/Parser/Language.cs b/src/NzbDrone.Core/Parser/Language.cs deleted file mode 100644 index f85281dd1..000000000 --- a/src/NzbDrone.Core/Parser/Language.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace NzbDrone.Core.Parser -{ - public enum Language - { - Unknown = 0, - English = 1, - French = 2, - Spanish = 3, - German = 4, - Italian = 5, - Danish = 6, - Dutch = 7, - Japanese = 8, - Cantonese = 9, - Mandarin = 10, - Russian = 11, - Polish = 12, - Vietnamese = 13, - Swedish = 14, - Norwegian = 15, - Finnish = 16, - Turkish = 17, - Portuguese = 18, - Flemish = 19, - Greek = 20, - Korean = 21, - Hungarian = 22 - } -} diff --git a/src/NzbDrone.Core/Parser/LanguageParser.cs b/src/NzbDrone.Core/Parser/LanguageParser.cs deleted file mode 100644 index 9e5b63a81..000000000 --- a/src/NzbDrone.Core/Parser/LanguageParser.cs +++ /dev/null @@ -1,136 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Text.RegularExpressions; -using NLog; -using NzbDrone.Common.Instrumentation; - -namespace NzbDrone.Core.Parser -{ - public static class LanguageParser - { - private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(LanguageParser)); - - private static readonly Regex LanguageRegex = new Regex(@"(?:\W|_)(?<italian>\b(?:ita|italian)\b)|(?<german>german\b|videomann)|(?<flemish>flemish)|(?<greek>greek)|(?<french>(?:\W|_)(?:FR|VOSTFR)(?:\W|_))|(?<russian>\brus\b)|(?<dutch>nl\W?subs?)|(?<hungarian>\b(?:HUNDUB|HUN)\b)", - RegexOptions.IgnoreCase | RegexOptions.Compiled); - - private static readonly Regex SubtitleLanguageRegex = new Regex(".+?[-_. ](?<iso_code>[a-z]{2,3})$", RegexOptions.Compiled | RegexOptions.IgnoreCase); - - public static Language ParseLanguage(string title) - { - var lowerTitle = title.ToLower(); - - if (lowerTitle.Contains("english")) - return Language.English; - - if (lowerTitle.Contains("french")) - return Language.French; - - if (lowerTitle.Contains("spanish")) - return Language.Spanish; - - if (lowerTitle.Contains("danish")) - return Language.Danish; - - if (lowerTitle.Contains("dutch")) - return Language.Dutch; - - if (lowerTitle.Contains("japanese")) - return Language.Japanese; - - if (lowerTitle.Contains("cantonese")) - return Language.Cantonese; - - if (lowerTitle.Contains("mandarin")) - return Language.Mandarin; - - if (lowerTitle.Contains("korean")) - return Language.Korean; - - if (lowerTitle.Contains("russian")) - return Language.Russian; - - if (lowerTitle.Contains("polish")) - return Language.Polish; - - if (lowerTitle.Contains("vietnamese")) - return Language.Vietnamese; - - if (lowerTitle.Contains("swedish")) - return Language.Swedish; - - if (lowerTitle.Contains("norwegian")) - return Language.Norwegian; - - if (lowerTitle.Contains("nordic")) - return Language.Norwegian; - - if (lowerTitle.Contains("finnish")) - return Language.Finnish; - - if (lowerTitle.Contains("turkish")) - return Language.Turkish; - - if (lowerTitle.Contains("portuguese")) - return Language.Portuguese; - - if (lowerTitle.Contains("hungarian")) - return Language.Hungarian; - - var match = LanguageRegex.Match(title); - - if (match.Groups["italian"].Captures.Cast<Capture>().Any()) - return Language.Italian; - - if (match.Groups["german"].Captures.Cast<Capture>().Any()) - return Language.German; - - if (match.Groups["flemish"].Captures.Cast<Capture>().Any()) - return Language.Flemish; - - if (match.Groups["greek"].Captures.Cast<Capture>().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; - - return Language.English; - } - - public static Language ParseSubtitleLanguage(string fileName) - { - try - { - Logger.Debug("Parsing language from subtitlte file: {0}", fileName); - - var simpleFilename = Path.GetFileNameWithoutExtension(fileName); - var languageMatch = SubtitleLanguageRegex.Match(simpleFilename); - - if (languageMatch.Success) - { - var isoCode = languageMatch.Groups["iso_code"].Value; - var isoLanguage = IsoLanguages.Find(isoCode); - - return isoLanguage?.Language ?? Language.Unknown; - } - - Logger.Debug("Unable to parse langauge from subtitle file: {0}", fileName); - } - catch (Exception ex) - { - Logger.Debug("Failed parsing langauge from subtitle file: {0}", fileName); - } - - return Language.Unknown; - } - } -} diff --git a/src/NzbDrone.Core/Parser/Model/ArtistTitleInfo.cs b/src/NzbDrone.Core/Parser/Model/ArtistTitleInfo.cs new file mode 100644 index 000000000..0d1938d24 --- /dev/null +++ b/src/NzbDrone.Core/Parser/Model/ArtistTitleInfo.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Parser.Model +{ + public class ArtistTitleInfo + { + public string Title { get; set; } + public string TitleWithoutYear { get; set; } + public int Year { get; set; } + } +} diff --git a/src/NzbDrone.Core/Parser/Model/ImportListItemInfo.cs b/src/NzbDrone.Core/Parser/Model/ImportListItemInfo.cs new file mode 100644 index 000000000..97353c564 --- /dev/null +++ b/src/NzbDrone.Core/Parser/Model/ImportListItemInfo.cs @@ -0,0 +1,21 @@ +using System; +using System.Text; + +namespace NzbDrone.Core.Parser.Model +{ + public class ImportListItemInfo + { + public int ImportListId { get; set; } + public string ImportList { get; set; } + public string Artist { get; set; } + public string ArtistMusicBrainzId { get; set; } + public string Album { get; set; } + public string AlbumMusicBrainzId { get; set; } + public DateTime ReleaseDate { get; set; } + + public override string ToString() + { + return string.Format("[{0}] {1} [{2}]", ReleaseDate, Artist, Album); + } + } +} diff --git a/src/NzbDrone.Core/Parser/Model/LocalAlbumRelease.cs b/src/NzbDrone.Core/Parser/Model/LocalAlbumRelease.cs new file mode 100644 index 000000000..184ab3e6e --- /dev/null +++ b/src/NzbDrone.Core/Parser/Model/LocalAlbumRelease.cs @@ -0,0 +1,79 @@ +using NzbDrone.Core.Music; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.MediaFiles.TrackImport.Identification; +using System.IO; +using System; +using NzbDrone.Common.Extensions; +using NzbDrone.Common; + +namespace NzbDrone.Core.Parser.Model +{ + public class LocalAlbumRelease + { + public LocalAlbumRelease() + { + LocalTracks = new List<LocalTrack>(); + + // A dummy distance, will be replaced + Distance = new Distance(); + Distance.Add("album_id", 1.0); + } + + public LocalAlbumRelease(List<LocalTrack> tracks) + { + LocalTracks = tracks; + + // A dummy distance, will be replaced + Distance = new Distance(); + Distance.Add("album_id", 1.0); + } + + public List<LocalTrack> LocalTracks { get; set; } + public int TrackCount => LocalTracks.Count; + + public TrackMapping TrackMapping { get; set; } + public Distance Distance { get; set; } + public AlbumRelease AlbumRelease { get; set; } + public List<LocalTrack> ExistingTracks { get; set; } + public bool NewDownload { get; set; } + + public void PopulateMatch() + { + if (AlbumRelease != null) + { + LocalTracks = LocalTracks.Concat(ExistingTracks).DistinctBy(x => x.Path).ToList(); + foreach (var localTrack in LocalTracks) + { + localTrack.Release = AlbumRelease; + localTrack.Album = AlbumRelease.Album.Value; + localTrack.Artist = localTrack.Album.Artist.Value; + + if (TrackMapping.Mapping.ContainsKey(localTrack)) + { + var track = TrackMapping.Mapping[localTrack].Item1; + localTrack.Tracks = new List<Track> { track }; + localTrack.Distance = TrackMapping.Mapping[localTrack].Item2; + } + } + } + } + + public override string ToString() + { + return "[" + string.Join(", ", LocalTracks.Select(x => Path.GetDirectoryName(x.Path)).Distinct()) + "]"; + } + } + + public class TrackMapping + { + public TrackMapping() + { + Mapping = new Dictionary<LocalTrack, Tuple<Track, Distance>>(); + } + + public Dictionary<LocalTrack, Tuple<Track, Distance>> Mapping { get; set; } + public List<LocalTrack> LocalExtra { get; set; } + public List<Track> MBExtra { get; set; } + } +} diff --git a/src/NzbDrone.Core/Parser/Model/LocalEpisode.cs b/src/NzbDrone.Core/Parser/Model/LocalEpisode.cs deleted file mode 100644 index 67ec2d873..000000000 --- a/src/NzbDrone.Core/Parser/Model/LocalEpisode.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Linq; -using System.Collections.Generic; -using NzbDrone.Core.Qualities; -using NzbDrone.Core.Tv; -using NzbDrone.Core.MediaFiles.MediaInfo; - -namespace NzbDrone.Core.Parser.Model -{ - public class LocalEpisode - { - public LocalEpisode() - { - Episodes = new List<Episode>(); - } - - public string Path { get; set; } - public long Size { get; set; } - public ParsedEpisodeInfo ParsedEpisodeInfo { get; set; } - public Series Series { get; set; } - public List<Episode> Episodes { get; set; } - public QualityModel Quality { get; set; } - public MediaInfoModel MediaInfo { get; set; } - public bool ExistingFile { get; set; } - - public int SeasonNumber - { - get - { - return Episodes.Select(c => c.SeasonNumber).Distinct().Single(); - } - } - - public bool IsSpecial => SeasonNumber == 0; - - public override string ToString() - { - return Path; - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/Parser/Model/LocalTrack.cs b/src/NzbDrone.Core/Parser/Model/LocalTrack.cs new file mode 100644 index 000000000..edc7ff3dc --- /dev/null +++ b/src/NzbDrone.Core/Parser/Model/LocalTrack.cs @@ -0,0 +1,39 @@ +using NzbDrone.Core.Music; +using NzbDrone.Core.Qualities; +using System.Collections.Generic; +using NzbDrone.Core.MediaFiles.TrackImport.Identification; +using System; + +namespace NzbDrone.Core.Parser.Model +{ + public class LocalTrack + { + public LocalTrack() + { + Tracks = new List<Track>(); + } + + public string Path { get; set; } + public long Size { get; set; } + public DateTime Modified { get; set; } + public ParsedTrackInfo FileTrackInfo { get; set; } + public ParsedTrackInfo FolderTrackInfo { get; set; } + public ParsedAlbumInfo DownloadClientAlbumInfo { get; set; } + public List<string> AcoustIdResults { get; set; } + public Artist Artist { get; set; } + public Album Album { get; set; } + public AlbumRelease Release { get; set; } + public List<Track> Tracks { get; set; } + public Distance Distance { get; set; } + public QualityModel Quality { get; set; } + public bool ExistingFile { get; set; } + public bool AdditionalFile { get; set; } + public bool SceneSource { get; set; } + public string ReleaseGroup { get; set; } + + public override string ToString() + { + return Path; + } + } +} diff --git a/src/NzbDrone.Core/Parser/Model/MediaInfoModel.cs b/src/NzbDrone.Core/Parser/Model/MediaInfoModel.cs new file mode 100644 index 000000000..a79d79702 --- /dev/null +++ b/src/NzbDrone.Core/Parser/Model/MediaInfoModel.cs @@ -0,0 +1,13 @@ +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Parser.Model +{ + public class MediaInfoModel : IEmbeddedDocument + { + public string AudioFormat { get; set; } + public int AudioBitrate { get; set; } + public int AudioChannels { get; set; } + public int AudioBits { get; set; } + public int AudioSampleRate { get; set; } + } +} diff --git a/src/NzbDrone.Core/Parser/Model/ParsedAlbumInfo.cs b/src/NzbDrone.Core/Parser/Model/ParsedAlbumInfo.cs new file mode 100644 index 000000000..d54705e79 --- /dev/null +++ b/src/NzbDrone.Core/Parser/Model/ParsedAlbumInfo.cs @@ -0,0 +1,38 @@ +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Qualities; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Parser.Model +{ + public class ParsedAlbumInfo + { + public string AlbumTitle { get; set; } + public string ArtistName { get; set; } + public ArtistTitleInfo ArtistTitleInfo { get; set; } + public QualityModel Quality { get; set; } + public string ReleaseDate { get; set; } + public bool Discography { get; set; } + public int DiscographyStart { get; set; } + public int DiscographyEnd { get; set; } + public string ReleaseGroup { get; set; } + public string ReleaseHash { get; set; } + public string ReleaseVersion { get; set; } + + public override string ToString() + { + string albumString = "[Unknown Album]"; + + + if (AlbumTitle != null ) + { + albumString = string.Format("{0}", AlbumTitle); + } + + + return string.Format("{0} - {1} {2}", ArtistName, albumString, Quality); + } + } +} diff --git a/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs b/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs deleted file mode 100644 index 256269c36..000000000 --- a/src/NzbDrone.Core/Parser/Model/ParsedEpisodeInfo.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Linq; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Qualities; - -namespace NzbDrone.Core.Parser.Model -{ - public class ParsedEpisodeInfo - { - public string SeriesTitle { get; set; } - public SeriesTitleInfo SeriesTitleInfo { get; set; } - public QualityModel Quality { get; set; } - public int SeasonNumber { get; set; } - public int[] EpisodeNumbers { get; set; } - public int[] AbsoluteEpisodeNumbers { get; set; } - public string AirDate { get; set; } - public Language Language { get; set; } - public bool FullSeason { get; set; } - public bool Special { get; set; } - public string ReleaseGroup { get; set; } - public string ReleaseHash { get; set; } - - public ParsedEpisodeInfo() - { - EpisodeNumbers = new int[0]; - AbsoluteEpisodeNumbers = new int[0]; - } - - public bool IsDaily - { - get - { - return !string.IsNullOrWhiteSpace(AirDate); - } - - //This prevents manually downloading a release from blowing up in mono - //TODO: Is there a better way? - private set { } - } - - public bool IsAbsoluteNumbering - { - get - { - return AbsoluteEpisodeNumbers.Any(); - } - - //This prevents manually downloading a release from blowing up in mono - //TODO: Is there a better way? - private set { } - } - - public bool IsPossibleSpecialEpisode - { - get - { - // if we don't have eny 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); - } - - //This prevents manually downloading a release from blowing up in mono - //TODO: Is there a better way? - private set {} - } - - public override string ToString() - { - string episodeString = "[Unknown Episode]"; - - if (IsDaily && EpisodeNumbers.Empty()) - { - episodeString = string.Format("{0}", AirDate); - } - else if (FullSeason) - { - episodeString = string.Format("Season {0:00}", SeasonNumber); - } - else if (EpisodeNumbers != null && EpisodeNumbers.Any()) - { - episodeString = string.Format("S{0:00}E{1}", SeasonNumber, string.Join("-", EpisodeNumbers.Select(c => c.ToString("00")))); - } - else if (AbsoluteEpisodeNumbers != null && AbsoluteEpisodeNumbers.Any()) - { - episodeString = string.Format("{0}", string.Join("-", AbsoluteEpisodeNumbers.Select(c => c.ToString("000")))); - } - - return string.Format("{0} - {1} {2}", SeriesTitle, episodeString, Quality); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/Parser/Model/ParsedTrackInfo.cs b/src/NzbDrone.Core/Parser/Model/ParsedTrackInfo.cs new file mode 100644 index 000000000..86819329f --- /dev/null +++ b/src/NzbDrone.Core/Parser/Model/ParsedTrackInfo.cs @@ -0,0 +1,52 @@ +using NzbDrone.Core.Qualities; +using System; +using System.Linq; + +namespace NzbDrone.Core.Parser.Model +{ + public class ParsedTrackInfo + { + //public int TrackNumber { get; set; } + public string Title { get; set; } + public string CleanTitle { get; set; } + public string ArtistTitle { get; set; } + public string AlbumTitle { get; set; } + public ArtistTitleInfo ArtistTitleInfo { get; set; } + public string ArtistMBId { get; set; } + public string AlbumMBId { get; set; } + public string ReleaseMBId { get; set; } + public string RecordingMBId { get; set; } + public string TrackMBId { get; set; } + public int DiscNumber { get; set; } + public int DiscCount { get; set; } + public IsoCountry Country { get; set; } + public uint Year { get; set; } + public string Label { get; set; } + public string CatalogNumber { get; set; } + public string Disambiguation { get; set; } + public TimeSpan Duration { get; set; } + public QualityModel Quality { get; set; } + public MediaInfoModel MediaInfo { get; set; } + public int[] TrackNumbers { get; set; } + public string ReleaseGroup { get; set; } + public string ReleaseHash { get; set; } + + public ParsedTrackInfo() + { + TrackNumbers = new int[0]; + } + + public override string ToString() + { + string trackString = "[Unknown Track]"; + + + if (TrackNumbers != null && TrackNumbers.Any()) + { + trackString = string.Format("{0}", string.Join("-", TrackNumbers.Select(c => c.ToString("00")))); + } + + return string.Format("{0} - {1} - {2}:{3} {4}: {5}", ArtistTitle, AlbumTitle, DiscNumber, trackString, Title, Quality); + } + } +} diff --git a/src/NzbDrone.Core/Parser/Model/ReleaseInfo.cs b/src/NzbDrone.Core/Parser/Model/ReleaseInfo.cs index 7c1680196..1bd53ae1f 100644 --- a/src/NzbDrone.Core/Parser/Model/ReleaseInfo.cs +++ b/src/NzbDrone.Core/Parser/Model/ReleaseInfo.cs @@ -10,13 +10,14 @@ namespace NzbDrone.Core.Parser.Model public string Title { get; set; } public long Size { get; set; } public string DownloadUrl { get; set; } + public string BasicAuthString { get; set; } public string InfoUrl { get; set; } public string CommentUrl { get; set; } public int IndexerId { get; set; } public string Indexer { get; set; } + public string Artist { get; set; } + public string Album { get; set; } public DownloadProtocol DownloadProtocol { get; set; } - public int TvdbId { get; set; } - public int TvRageId { get; set; } public DateTime PublishDate { get; set; } public string Origin { get; set; } @@ -80,8 +81,6 @@ namespace NzbDrone.Core.Parser.Model stringBuilder.AppendLine("Indexer: " + Indexer ?? "Empty"); stringBuilder.AppendLine("CommentUrl: " + CommentUrl ?? "Empty"); stringBuilder.AppendLine("DownloadProtocol: " + DownloadProtocol ?? "Empty"); - stringBuilder.AppendLine("TvdbId: " + TvdbId ?? "Empty"); - stringBuilder.AppendLine("TvRageId: " + TvRageId ?? "Empty"); stringBuilder.AppendLine("PublishDate: " + PublishDate ?? "Empty"); return stringBuilder.ToString(); default: @@ -89,4 +88,4 @@ namespace NzbDrone.Core.Parser.Model } } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Parser/Model/RemoteAlbum.cs b/src/NzbDrone.Core/Parser/Model/RemoteAlbum.cs new file mode 100644 index 000000000..ea4b35190 --- /dev/null +++ b/src/NzbDrone.Core/Parser/Model/RemoteAlbum.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Download.Clients; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Parser.Model +{ + public class RemoteAlbum + { + public ReleaseInfo Release { get; set; } + public ParsedAlbumInfo ParsedAlbumInfo { get; set; } + public Artist Artist { get; set; } + public List<Album> Albums { get; set; } + public bool DownloadAllowed { get; set; } + public TorrentSeedConfiguration SeedConfiguration { get; set; } + public int PreferredWordScore { get; set; } + + public RemoteAlbum() + { + Albums = new List<Album>(); + } + + public bool IsRecentAlbum() + { + return Albums.Any(e => e.ReleaseDate >= DateTime.UtcNow.Date.AddDays(-14)); + } + + public override string ToString() + { + return Release.Title; + } + } +} diff --git a/src/NzbDrone.Core/Parser/Model/RemoteEpisode.cs b/src/NzbDrone.Core/Parser/Model/RemoteEpisode.cs deleted file mode 100644 index 319606781..000000000 --- a/src/NzbDrone.Core/Parser/Model/RemoteEpisode.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Parser.Model -{ - public class RemoteEpisode - { - public ReleaseInfo Release { get; set; } - public ParsedEpisodeInfo ParsedEpisodeInfo { get; set; } - public Series Series { get; set; } - public List<Episode> Episodes { get; set; } - public bool DownloadAllowed { get; set; } - - public bool IsRecentEpisode() - { - return Episodes.Any(e => e.AirDateUtc >= DateTime.UtcNow.Date.AddDays(-14)); - } - - public override string ToString() - { - return Release.Title; - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/Parser/Model/SeriesTitleInfo.cs b/src/NzbDrone.Core/Parser/Model/SeriesTitleInfo.cs deleted file mode 100644 index e9befbf39..000000000 --- a/src/NzbDrone.Core/Parser/Model/SeriesTitleInfo.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace NzbDrone.Core.Parser.Model -{ - public class SeriesTitleInfo - { - public string Title { get; set; } - public string TitleWithoutYear { get; set; } - public int Year { get; set; } - } -} diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index 4855926a9..307f5860d 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; @@ -6,8 +6,8 @@ using System.Text.RegularExpressions; using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Common.Instrumentation; +using NzbDrone.Core.Music; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Tv; namespace NzbDrone.Core.Parser { @@ -15,201 +15,125 @@ namespace NzbDrone.Core.Parser { private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(Parser)); - private static readonly Regex[] ReportTitleRegex = new[] - { - //Anime - Absolute Episode Number + Title + Season+Episode - //Todo: This currently breaks series that start with numbers -// new Regex(@"^(?:(?<absoluteepisode>\d{2,3})(?:_|-|\s|\.)+)+(?<title>.+?)(?:\W|_)+(?:S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:\-|[ex]|\W[ex]){1,2}(?<episode>\d{2}(?!\d+)))+)", -// RegexOptions.IgnoreCase | RegexOptions.Compiled), - - //Multi-Part episodes without a title (S01E05.S01E06) - new Regex(@"^(?:\W*S?(?<season>(?<!\d+)(?:\d{1,2}|\d{4})(?!\d+))(?:(?:[ex]){1,2}(?<episode>\d{1,3}(?!\d+)))+){2,}", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - - //Episodes without a title, Single (S01E05, 1x05) AND Multi (S01E04E05, 1x04x05, etc) - new Regex(@"^(?:S?(?<season>(?<!\d+)(?:\d{1,2}|\d{4})(?!\d+))(?:(?:\-|[ex]|\W[ex]|_){1,2}(?<episode>\d{2,3}(?!\d+)))+)", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - - //Anime - [SubGroup] Title Episode Absolute Episode Number ([SubGroup] Series Title Episode 01) - new Regex(@"^(?:\[(?<subgroup>.+?)\][-_. ]?)(?<title>.+?)[-_. ](?:Episode)(?:[-_. ]+(?<absoluteepisode>(?<!\d+)\d{2,3}(?!\d+)))+(?:_|-|\s|\.)*?(?<hash>\[.{8}\])?(?:$|\.)?", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - - //Anime - [SubGroup] Title Absolute Episode Number + Season+Episode - new Regex(@"^(?:\[(?<subgroup>.+?)\](?:_|-|\s|\.)?)(?<title>.+?)(?:(?:[-_\W](?<![()\[!]))+(?<absoluteepisode>\d{2,3}))+(?:_|-|\s|\.)+(?:S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:\-|[ex]|\W[ex]){1,2}(?<episode>\d{2}(?!\d+)))+).*?(?<hash>[(\[]\w{8}[)\]])?(?:$|\.)", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - - //Anime - [SubGroup] Title Season+Episode + Absolute Episode Number - new Regex(@"^(?:\[(?<subgroup>.+?)\](?:_|-|\s|\.)?)(?<title>.+?)(?:[-_\W](?<![()\[!]))+(?:S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:\-|[ex]|\W[ex]){1,2}(?<episode>\d{2}(?!\d+)))+)(?:(?:_|-|\s|\.)+(?<absoluteepisode>(?<!\d+)\d{2,3}(?!\d+)))+.*?(?<hash>\[\w{8}\])?(?:$|\.)", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - - //Anime - [SubGroup] Title Season+Episode - new Regex(@"^(?:\[(?<subgroup>.+?)\](?:_|-|\s|\.)?)(?<title>.+?)(?:[-_\W](?<![()\[!]))+(?:S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:[ex]|\W[ex]){1,2}(?<episode>\d{2}(?!\d+)))+)(?:\s|\.).*?(?<hash>\[\w{8}\])?(?:$|\.)", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - - //Anime - [SubGroup] Title with trailing number Absolute Episode Number - new Regex(@"^\[(?<subgroup>.+?)\][-_. ]?(?<title>[^-]+?\d+?)[-_. ]+(?:[-_. ]?(?<absoluteepisode>\d{3}(?!\d+)))+(?:[-_. ]+(?<special>special|ova|ovd))?.*?(?<hash>\[\w{8}\])?(?:$|\.mkv)", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - - //Anime - [SubGroup] Title - Absolute Episode Number - new Regex(@"^\[(?<subgroup>.+?)\][-_. ]?(?<title>.+?)(?:[. ]-[. ](?<absoluteepisode>\d{2,3}(?!\d+|[-])))+(?:[-_. ]+(?<special>special|ova|ovd))?.*?(?<hash>\[\w{8}\])?(?:$|\.mkv)", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - - //Anime - [SubGroup] Title Absolute Episode Number - new Regex(@"^\[(?<subgroup>.+?)\][-_. ]?(?<title>.+?)[-_. ]+\(?(?:[-_. ]?(?<absoluteepisode>\d{2,3}(?!\d+)))+\)?(?:[-_. ]+(?<special>special|ova|ovd))?.*?(?<hash>\[\w{8}\])?(?:$|\.mkv)", - 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)", - 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), - - //Anime - Title Absolute Episode Number [Hash] - new Regex(@"^(?<title>.+?)(?:(?:_|-|\s|\.)+(?<absoluteepisode>\d{2,3}(?!\d+)))+(?:[-_. ]+(?<special>special|ova|ovd))?[-_. ]+.*?(?<hash>\[\w{8}\])(?:$|\.)", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - - //Episodes with airdate AND season/episode number, capture season/epsiode only - new Regex(@"^(?<title>.+?)?\W*(?<airdate>\d{4}\W+[0-1][0-9]\W+[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), - - //Episodes with airdate AND season/episode number - 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), - - //Episodes with a title, 4 digit season number, Single episodes (S2016E05, etc) & Multi-episode (S2016E05E06, S2016E05-06, S2016E05 E06, etc) - new Regex(@"^(?<title>.+?)(?:(?:[-_\W](?<![()\[!]))+S(?<season>(?<!\d+)(?:\d{4})(?!\d+))(?:e|\We|_){1,2}(?<episode>\d{2,3}(?!\d+))(?:(?:\-|e|\We|_){1,2}(?<episode>\d{2,3}(?!\d+)))*)\W?(?!\\)", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - - //Episodes with a title, 4 digit season number, Single episodes (2016x05, etc) & Multi-episode (2016x05x06, 2016x05-06, 2016x05 x06, etc) - 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), - - //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, 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), - - //Mini-Series, treated as season 1, episodes are labelled as Part One/Two/Three/...Nine, Part.One, Part_One - new Regex(@"^(?<title>.+?)(?:\W+(?:Part[-._ ](?<episode>One|Two|Three|Four|Five|Six|Seven|Eight|Nine)(?>[-._ ])))", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - - //Mini-Series, treated as season 1, episodes are labelled as XofY - new Regex(@"^(?<title>.+?)(?:\W+(?:(?<episode>(?<!\d+)\d{1,2}(?!\d+))of\d+)+)", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - - //Supports Season 01 Episode 03 - new Regex(@"(?:.*(?:\""|^))(?<title>.*?)(?:[-_\W](?<![()\[]))+(?:\W?Season\W?)(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:\W|_)+(?:Episode\W)(?:[-_. ]?(?<episode>(?<!\d+)\d{1,2}(?!\d+)))+", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - - //Multi-episode release with no space between series title and season (S01E11E12) - new Regex(@"(?:.*(?:^))(?<title>.*?)(?:\W?|_)S(?<season>(?<!\d+)\d{2}(?!\d+))(?:E(?<episode>(?<!\d+)\d{2}(?!\d+)))+", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - - //Multi-episode with single episode numbers (S6.E1-E2, S6.E1E2, S6E1E2, etc) - 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+))", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - - //3 digit season S010E05 - new Regex(@"(?:.*(?:\""|^))(?<title>.*?)(?:\W?|_)S(?<season>(?<!\d+)\d{3}(?!\d+))(?:\W|_)?E(?<episode>(?<!\d+)\d{1,2}(?!\d+))", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - - //5 digit episode number with a title - new Regex(@"^(?:(?<title>.+?)(?:_|-|\s|\.)+)(?:S?(?<season>(?<!\d+)\d{1,2}(?!\d+)))(?:(?:\-|[ex]|\W[ex]|_){1,2}(?<episode>(?<!\d+)\d{5}(?!\d+)))", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - - //5 digit multi-episode with a title - new Regex(@"^(?:(?<title>.+?)(?:_|-|\s|\.)+)(?:S?(?<season>(?<!\d+)\d{1,2}(?!\d+)))(?:(?:[-_. ]{1,3}ep){1,2}(?<episode>(?<!\d+)\d{5}(?!\d+)))+", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - - // Separated season and episode numbers S01 - E01 - new Regex(@"^(?<title>.+?)(?:_|-|\s|\.)+S(?<season>\d{2}(?!\d+))(\W-\W)E(?<episode>(?<!\d+)\d{2}(?!\d+))(?!\\)", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - - //Season only releases - new Regex(@"^(?<title>.+?)\W(?:S|Season)\W?(?<season>\d{1,2}(?!\d+))(\W+|_|$)(?<extras>EXTRAS|SUBPACK)?(?!\\)", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - - //4 digit season only releases - new Regex(@"^(?<title>.+?)\W(?:S|Season)\W?(?<season>\d{4}(?!\d+))(\W+|_|$)(?<extras>EXTRAS|SUBPACK)?(?!\\)", - RegexOptions.IgnoreCase | RegexOptions.Compiled), + private static readonly Regex[] ReportMusicTitleRegex = new[] + { + // Track with artist (01 - artist - trackName) + new Regex(@"(?<trackNumber>\d*){0,1}([-| ]{0,1})(?<artist>[a-zA-Z0-9, ().&_]*)[-| ]{0,1}(?<trackName>[a-zA-Z0-9, ().&_]+)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), - //Episodes with a title and season/episode in square brackets - new Regex(@"^(?<title>.+?)(?:(?:[-_\W](?<![()\[!]))+\[S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:\-|[ex]|\W[ex]|_){1,2}(?<episode>(?<!\d+)\d{2}(?!\d+|i|p)))+\])\W?(?!\\)", - RegexOptions.IgnoreCase | RegexOptions.Compiled), + // Track without artist (01 - trackName) + new Regex(@"(?<trackNumber>\d*)[-| .]{0,1}(?<trackName>[a-zA-Z0-9, ().&_]+)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), - //Supports 103/113 naming - new Regex(@"^(?<title>.+?)?(?:(?:[-_\W](?<![()\[!]))+(?<season>(?<!\d+)[1-9])(?<episode>[1-9][0-9]|[0][1-9])(?![a-z]|\d+))+", - RegexOptions.IgnoreCase | RegexOptions.Compiled), + // Track without trackNumber or artist(trackName) + new Regex(@"(?<trackNumber>\d*)[-| .]{0,1}(?<trackName>[a-zA-Z0-9, ().&_]+)", + 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), + // Track without trackNumber and with artist(artist - trackName) + new Regex(@"(?<trackNumber>\d*)[-| .]{0,1}(?<trackName>[a-zA-Z0-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), + // Track with artist and starting title (01 - artist - trackName) + new Regex(@"(?<trackNumber>\d*){0,1}[-| ]{0,1}(?<artist>[a-zA-Z0-9, ().&_]*)[-| ]{0,1}(?<trackName>[a-zA-Z0-9, ().&_]+)", + 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), - - //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 single digit episode number (S01E1, S01E5E6, etc) - new Regex(@"^(?<title>.*?)(?:(?:[-_\W](?<![()\[!]))+S?(?<season>(?<!\d+)\d{1,2}(?!\d+))(?:(?:\-|[ex]){1,2}(?<episode>\d{1}))+)+(\W+|_|$)(?!\\)", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - - //iTunes Season 1\05 Title (Quality).ext - new Regex(@"^(?:Season(?:_|-|\s|\.)(?<season>(?<!\d+)\d{1,2}(?!\d+)))(?:_|-|\s|\.)(?<episode>(?<!\d+)\d{1,2}(?!\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), - - //Anime - Title Episode Absolute Episode Number (Series Title Episode 01) - new Regex(@"^(?<title>.+?)[-_. ](?:Episode)(?:[-_. ]+(?<absoluteepisode>(?<!\d+)\d{2,3}(?!\d+)))+(?:_|-|\s|\.)*?(?<hash>\[.{8}\])?(?:$|\.)?", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - - //Anime - Title Absolute Episode Number - new Regex(@"^(?:\[(?<subgroup>.+?)\][-_. ]?)?(?<title>.+?)(?:[-_. ]+(?<absoluteepisode>(?<!\d+)\d{2,3}(?!\d+)))+(?:_|-|\s|\.)*?(?<hash>\[.{8}\])?(?:$|\.)?", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - - //Anime - Title {Absolute Episode Number} - new Regex(@"^(?:\[(?<subgroup>.+?)\][-_. ]?)?(?<title>.+?)(?:(?:[-_\W](?<![()\[!]))+(?<absoluteepisode>(?<!\d+)\d{2,3}(?!\d+)))+(?:_|-|\s|\.)*?(?<hash>\[.{8}\])?(?:$|\.)?", - RegexOptions.IgnoreCase | RegexOptions.Compiled), - - //Extant, terrible multi-episode naming (extant.10708.hdtv-lol.mp4) - new Regex(@"^(?<title>.+?)[-_. ](?<season>[0]?\d?)(?:(?<episode>\d{2}){2}(?!\d+))[-_. ]", - RegexOptions.IgnoreCase | RegexOptions.Compiled) - }; + private static readonly Regex[] ReportAlbumTitleRegex = new[] + { + //ruTracker - (Genre) [Source]? Artist - Discography + new Regex(@"^(?:\(.+?\))(?:\W*(?:\[(?<source>.+?)\]))?\W*(?<artist>.+?)(?: - )(?<discography>Discography|Discografia).+?(?<startyear>\d{4}).+?(?<endyear>\d{4})", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //Artist - Discography with two years + new Regex(@"^(?<artist>.+?)(?: - )(?:.+?)?(?<discography>Discography|Discografia).+?(?<startyear>\d{4}).+?(?<endyear>\d{4})", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //Artist - Discography with end year + new Regex(@"^(?<artist>.+?)(?: - )(?:.+?)?(?<discography>Discography|Discografia).+?(?<endyear>\d{4})", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //Artist Discography with two years + new Regex(@"^(?<artist>.+?)\W*(?<discography>Discography|Discografia).+?(?<startyear>\d{4}).+?(?<endyear>\d{4})", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //Artist Discography with end year + new Regex(@"^(?<artist>.+?)\W*(?<discography>Discography|Discografia).+?(?<endyear>\d{4})", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //Artist Discography + new Regex(@"^(?<artist>.+?)\W*(?<discography>Discography|Discografia)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //ruTracker - (Genre) [Source]? Artist - Album - Year + new Regex(@"^(?:\(.+?\))(?:\W*(?:\[(?<source>.+?)\]))?\W*(?<artist>.+?)(?: - )(?<album>.+?)(?: - )(?<releaseyear>\d{4})", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //Artist-Album-Version-Source-Year + //ex. Imagine Dragons-Smoke And Mirrors-Deluxe Edition-2CD-FLAC-2015-JLM + new Regex(@"^(?<artist>.+?)[-](?<album>.+?)[-](?:[\(|\[]?)(?<version>.+?(?:Edition)?)(?:[\)|\]]?)[-](?<source>\d?CD|WEB).+?(?<releaseyear>\d{4})", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //Artist-Album-Source-Year + //ex. Dani_Sbert-Togheter-WEB-2017-FURY + new Regex(@"^(?<artist>.+?)[-](?<album>.+?)[-](?<source>\d?CD|WEB).+?(?<releaseyear>\d{4})", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //Artist - Album (Year) Strict + new Regex(@"^(?:(?<artist>.+?)(?: - )+)(?<album>.+?)\W*(?:\(|\[).+?(?<releaseyear>\d{4})", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //Artist - Album (Year) + new Regex(@"^(?:(?<artist>.+?)(?: - )+)(?<album>.+?)\W*(?:\(|\[)(?<releaseyear>\d{4})", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //Artist - Album - Year [something] + new Regex(@"^(?:(?<artist>.+?)(?: - )+)(?<album>.+?)\W*(?: - )(?<releaseyear>\d{4})\W*(?:\(|\[)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //Artist - Album [something] or Artist - Album (something) + new Regex(@"^(?:(?<artist>.+?)(?: - )+)(?<album>.+?)\W*(?:\(|\[)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //Artist - Album Year + new Regex(@"^(?:(?<artist>.+?)(?: - )+)(?<album>.+?)\W*(?<releaseyear>\d{4})", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //Artist-Album (Year) Strict + //Hyphen no space between artist and album + new Regex(@"^(?:(?<artist>.+?)(?:-)+)(?<album>.+?)\W*(?:\(|\[).+?(?<releaseyear>\d{4})", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //Artist-Album (Year) + //Hyphen no space between artist and album + new Regex(@"^(?:(?<artist>.+?)(?:-)+)(?<album>.+?)\W*(?:\(|\[)(?<releaseyear>\d{4})", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //Artist-Album [something] or Artist-Album (something) + //Hyphen no space between artist and album + new Regex(@"^(?:(?<artist>.+?)(?:-)+)(?<album>.+?)\W*(?:\(|\[)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //Artist-Album-something-Year + new Regex(@"^(?:(?<artist>.+?)(?:-)+)(?<album>.+?)(?:-.+?)(?<releaseyear>\d{4})", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //Artist-Album Year + //Hyphen no space between artist and album + new Regex(@"^(?:(?<artist>.+?)(?:-)+)(?:(?<album>.+?)(?:-)+)(?<releaseyear>\d{4})", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + + //Artist - Year - Album + // Hypen with no or more spaces between artist/album/year + new Regex(@"^(?:(?<artist>.+?)(?:-))(?<releaseyear>\d{4})(?:-)(?<album>[^-]+)", + 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), @@ -231,19 +155,17 @@ namespace NzbDrone.Core.Parser new Regex(@"^b00bs$", RegexOptions.Compiled | RegexOptions.IgnoreCase) }; - //Regex to detect whether the title was reversed. - private static readonly Regex ReversedTitleRegex = new Regex(@"[-._ ](p027|p0801|\d{2}E\d{2}S)[-._ ]", RegexOptions.Compiled); - private static readonly Regex NormalizeRegex = new Regex(@"((?:\b|_)(?<!^)(a(?!$)|an|the|and|or|of)(?:\b|_))|\W|_", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex FileExtensionRegex = new Regex(@"\.[a-z0-9]{2,4}$", RegexOptions.IgnoreCase | RegexOptions.Compiled); - 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*", + //TODO Rework this Regex for Music + private static readonly Regex SimpleTitleRegex = new Regex(@"(?:(480|720|1080|2160|320)[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)", @@ -252,13 +174,13 @@ namespace NzbDrone.Core.Parser private static readonly Regex SixDigitAirDateRegex = new Regex(@"(?<=[_.-])(?<airdate>(?<!\d)(?<airyear>[1-9]\d{1})(?<airmonth>[0-1][0-9])(?<airday>[0-3][0-9]))(?=[_.-])", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex CleanReleaseGroupRegex = new Regex(@"^(.*?[-._ ](S\d+E\d+)[-._ ])|(-(RP|1|NZBGeek|Obfuscated|sample))+$", + private static readonly Regex CleanReleaseGroupRegex = new Regex(@"^(.*?[-._ ])|(-(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 ReleaseGroupRegex = new Regex(@"-(?<releasegroup>[a-z0-9]+)(?<!WEB-DL|480p|720p|1080p|2160p)(?:\b|[-._ ])", + private static readonly Regex ReleaseGroupRegex = new Regex(@"-(?<releasegroup>[a-z0-9]+)(?<!MP3|ALAC|FLAC|WEB)(?:\b|[-._ ])", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex AnimeReleaseGroupRegex = new Regex(@"^(?:\[(?<subgroup>(?!\s).+?(?<!\s))\](?:_|-|\s|\.)?)", @@ -277,28 +199,38 @@ namespace NzbDrone.Core.Parser private static readonly string[] Numbers = new[] { "zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine" }; - public static ParsedEpisodeInfo ParsePath(string path) + private static readonly Regex[] CommonTagRegex = new Regex[] { + new Regex(@"(\[|\()*\b((featuring|feat.|feat|ft|ft.)\s{1}){1}\s*.*(\]|\))*", RegexOptions.IgnoreCase | RegexOptions.Compiled), + new Regex(@"(?:\(|\[)(?:[^\(\[]*)(?:version|limited|deluxe|single|clean|album|special|bonus|promo|remastered)(?:[^\)\]]*)(?:\)|\])", RegexOptions.IgnoreCase | RegexOptions.Compiled) + }; + + private static readonly Regex[] BracketRegex = new Regex[] + { + new Regex(@"\(.*\)", RegexOptions.Compiled), + new Regex(@"\[.*\]", RegexOptions.Compiled) + }; + + private static readonly Regex AfterDashRegex = new Regex(@"[-:].*", RegexOptions.Compiled); + + public static ParsedTrackInfo ParseMusicPath(string path) { var fileInfo = new FileInfo(path); - var result = ParseTitle(fileInfo.Name); + ParsedTrackInfo result = null; - if (result == null) - { - Logger.Debug("Attempting to parse episode info using directory and file names. {0}", fileInfo.Directory.Name); - result = ParseTitle(fileInfo.Directory.Name + " " + fileInfo.Name); - } + Logger.Debug("Attempting to parse track info using directory and file names. {0}", fileInfo.Directory.Name); + result = ParseMusicTitle(fileInfo.Directory.Name + " " + fileInfo.Name); if (result == null) { - Logger.Debug("Attempting to parse episode info using directory name. {0}", fileInfo.Directory.Name); - result = ParseTitle(fileInfo.Directory.Name + fileInfo.Extension); + Logger.Debug("Attempting to parse track info using directory name. {0}", fileInfo.Directory.Name); + result = ParseMusicTitle(fileInfo.Directory.Name + fileInfo.Extension); } return result; } - public static ParsedEpisodeInfo ParseTitle(string title) + public static ParsedTrackInfo ParseMusicTitle(string title) { try { @@ -306,19 +238,154 @@ namespace NzbDrone.Core.Parser Logger.Debug("Parsing string '{0}'", title); - if (ReversedTitleRegex.IsMatch(title)) + var releaseTitle = RemoveFileExtension(title); + + 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) { - var titleWithoutExtension = RemoveFileExtension(title).ToCharArray(); - Array.Reverse(titleWithoutExtension); + simpleTitle = airDateMatch.Groups[1].Value + airDateMatch.Groups["airyear"].Value + "." + airDateMatch.Groups["airmonth"].Value + "." + airDateMatch.Groups["airday"].Value; + } - title = new string(titleWithoutExtension) + title.Substring(titleWithoutExtension.Length); + var sixDigitAirDateMatch = SixDigitAirDateRegex.Match(simpleTitle); + if (sixDigitAirDateMatch.Success) + { + var airYear = sixDigitAirDateMatch.Groups["airyear"].Value; + var airMonth = sixDigitAirDateMatch.Groups["airmonth"].Value; + var airDay = sixDigitAirDateMatch.Groups["airday"].Value; - Logger.Debug("Reversed name detected. Converted to '{0}'", title); + if (airMonth != "00" || airDay != "00") + { + var fixedDate = string.Format("20{0}.{1}.{2}", airYear, airMonth, airDay); + + simpleTitle = simpleTitle.Replace(sixDigitAirDateMatch.Groups["airdate"].Value, fixedDate); + } } - var simpleTitle = SimpleTitleRegex.Replace(title, string.Empty); + foreach (var regex in ReportMusicTitleRegex) + { + var match = regex.Matches(simpleTitle); + + if (match.Count != 0) + { + Logger.Trace(regex); + try + { + var result = ParseMatchMusicCollection(match); + + if (result != null) + { + result.Quality = QualityParser.ParseQuality(title, null, 0); + Logger.Debug("Quality parsed: {0}", result.Quality); + + return result; + } + } + catch (InvalidDateException ex) + { + Logger.Debug(ex, ex.Message); + break; + } + } + } + } + catch (Exception e) + { + if (!title.ToLower().Contains("password") && !title.ToLower().Contains("yenc")) + Logger.Error(e, "An error has occurred while trying to parse {0}", title); + } + + Logger.Debug("Unable to parse {0}", title); + return null; + } + + public static ParsedAlbumInfo ParseAlbumTitleWithSearchCriteria(string title, Artist artist, List<Album> album) + { + try + { + if (!ValidateBeforeParsing(title)) return null; + + Logger.Debug("Parsing string '{0}' using search criteria artist: '{1}' album: '{2}'", + title, artist.Name.RemoveAccent(), string.Join(", ", album.Select(a => a.Title.RemoveAccent()))); + + var releaseTitle = RemoveFileExtension(title); + + var simpleTitle = SimpleTitleRegex.Replace(releaseTitle, string.Empty); + + simpleTitle = WebsitePrefixRegex.Replace(simpleTitle, string.Empty); + + simpleTitle = CleanTorrentSuffixRegex.Replace(simpleTitle, string.Empty); + + var escapedArtist = Regex.Escape(artist.Name.RemoveAccent()).Replace(@"\ ", @"[\W_]"); + var escapedAlbums = string.Join("|", album.Select(s => Regex.Escape(s.Title.RemoveAccent())).ToList()).Replace(@"\ ", @"[\W_]"); + + var releaseRegex = new Regex(@"^(\W*|\b)(?<artist>" + escapedArtist + @")(\W*|\b).*(\W*|\b)(?<album>" + escapedAlbums + @")(\W*|\b)", RegexOptions.IgnoreCase); + + var match = releaseRegex.Matches(simpleTitle); + + if (match.Count != 0) + { + try + { + var result = ParseAlbumMatchCollection(match); + + if (result != null) + { + result.Quality = QualityParser.ParseQuality(title, null, 0); + Logger.Debug("Quality parsed: {0}", result.Quality); - simpleTitle = RemoveFileExtension(simpleTitle); + result.ReleaseGroup = ParseReleaseGroup(releaseTitle); + + var subGroup = GetSubGroup(match); + if (!subGroup.IsNullOrWhiteSpace()) + { + result.ReleaseGroup = subGroup; + } + + Logger.Debug("Release Group parsed: {0}", result.ReleaseGroup); + + result.ReleaseHash = GetReleaseHash(match); + if (!result.ReleaseHash.IsNullOrWhiteSpace()) + { + Logger.Debug("Release Hash parsed: {0}", result.ReleaseHash); + } + + return result; + } + } + catch (InvalidDateException ex) + { + Logger.Debug(ex, ex.Message); + } + } + } + catch (Exception e) + { + if (!title.ToLower().Contains("password") && !title.ToLower().Contains("yenc")) + Logger.Error(e, "An error has occurred while trying to parse {0}", title); + } + + Logger.Debug("Unable to parse {0}", title); + return null; + } + + public static ParsedAlbumInfo ParseAlbumTitle(string title) + { + try + { + if (!ValidateBeforeParsing(title)) return null; + + Logger.Debug("Parsing string '{0}'", title); + + var releaseTitle = RemoveFileExtension(title); + + var simpleTitle = SimpleTitleRegex.Replace(releaseTitle, string.Empty); // TODO: Quick fix stripping [url] - prefixes. simpleTitle = WebsitePrefixRegex.Replace(simpleTitle, string.Empty); @@ -346,7 +413,7 @@ namespace NzbDrone.Core.Parser } } - foreach (var regex in ReportTitleRegex) + foreach (var regex in ReportAlbumTitleRegex) { var match = regex.Matches(simpleTitle); @@ -355,23 +422,14 @@ namespace NzbDrone.Core.Parser Logger.Trace(regex); try { - var result = ParseMatchCollection(match); + var result = ParseAlbumMatchCollection(match); if (result != null) { - if (result.FullSeason && title.ContainsIgnoreCase("Special")) - { - result.FullSeason = false; - result.Special = true; - } - - result.Language = LanguageParser.ParseLanguage(title); - Logger.Debug("Language parsed: {0}", result.Language); - - result.Quality = QualityParser.ParseQuality(title); + result.Quality = QualityParser.ParseQuality(title, null, 0); Logger.Debug("Quality parsed: {0}", result.Quality); - result.ReleaseGroup = ParseReleaseGroup(title); + result.ReleaseGroup = ParseReleaseGroup(releaseTitle); var subGroup = GetSubGroup(match); if (!subGroup.IsNullOrWhiteSpace()) @@ -408,39 +466,24 @@ namespace NzbDrone.Core.Parser return null; } - public static string ParseSeriesName(string title) - { - Logger.Debug("Parsing string '{0}'", title); - - var parseResult = ParseTitle(title); - - if (parseResult == null) - { - return CleanSeriesTitle(title); - } - - return parseResult.SeriesTitle; - } - - public static string CleanSeriesTitle(this string title) + public static string CleanArtistName(this string name) { long number = 0; //If Title only contains numbers return it as is. - if (long.TryParse(title, out number)) - return title; + if (long.TryParse(name, out number)) + return name; - return NormalizeRegex.Replace(title, string.Empty).ToLower().RemoveAccent(); + return NormalizeRegex.Replace(name, string.Empty).ToLower().RemoveAccent(); } - public static string NormalizeEpisodeTitle(string title) + public static string NormalizeTrackTitle(this string title) { title = SpecialEpisodeWordRegex.Replace(title, string.Empty); title = PunctuationRegex.Replace(title, " "); title = DuplicateSpacesRegex.Replace(title, " "); - return title.Trim() - .ToLower(); + return title.Trim().ToLower(); } public static string NormalizeTitle(string title) @@ -501,157 +544,158 @@ namespace NzbDrone.Core.Parser return title; } - private static SeriesTitleInfo GetSeriesTitleInfo(string title) + public static string CleanAlbumTitle(this string album) { - var seriesTitleInfo = new SeriesTitleInfo(); - seriesTitleInfo.Title = title; - - var match = YearInTitleRegex.Match(title); + return CommonTagRegex[1].Replace(album, string.Empty).Trim(); + } - if (!match.Success) + public static string RemoveBracketsAndContents(this string album) + { + var intermediate = album; + foreach (var regex in BracketRegex) { - seriesTitleInfo.TitleWithoutYear = title; + intermediate = regex.Replace(intermediate, string.Empty).Trim(); } + + return intermediate; + } + + public static string RemoveAfterDash(this string text) + { + return AfterDashRegex.Replace(text, string.Empty).Trim(); + } - else + public static string CleanTrackTitle(this string title) + { + var intermediateTitle = title; + foreach (var regex in CommonTagRegex) { - seriesTitleInfo.TitleWithoutYear = match.Groups["title"].Value; - seriesTitleInfo.Year = Convert.ToInt32(match.Groups["year"].Value); + intermediateTitle = regex.Replace(intermediateTitle, string.Empty).Trim(); } - return seriesTitleInfo; + return intermediateTitle; } - private static ParsedEpisodeInfo ParseMatchCollection(MatchCollection matchCollection) + private static ParsedTrackInfo ParseMatchMusicCollection(MatchCollection matchCollection) { - var seriesName = matchCollection[0].Groups["title"].Value.Replace('.', ' ').Replace('_', ' '); - seriesName = RequestInfoRegex.Replace(seriesName, "").Trim(' '); - - int airYear; - int.TryParse(matchCollection[0].Groups["airyear"].Value, out airYear); - - ParsedEpisodeInfo result; - - if (airYear < 1900) + var artistName = matchCollection[0].Groups["artist"].Value./*Removed for cases like Will.I.Am Replace('.', ' ').*/Replace('_', ' '); + artistName = RequestInfoRegex.Replace(artistName, "").Trim(' '); + + // Coppied from Radarr (https://github.com/Radarr/Radarr/blob/develop/src/NzbDrone.Core/Parser/Parser.cs) + // TODO: Split into separate method and write unit tests for. + var parts = artistName.Split('.'); + artistName = ""; + int n = 0; + bool previousAcronym = false; + string nextPart = ""; + foreach (var part in parts) { - var seasons = new List<int>(); - - foreach (Capture seasonCapture in matchCollection[0].Groups["season"].Captures) + if (parts.Length >= n + 2) + { + nextPart = parts[n + 1]; + } + if (part.Length == 1 && part.ToLower() != "a" && !int.TryParse(part, out n)) + { + artistName += part + "."; + previousAcronym = true; + } + else if (part.ToLower() == "a" && (previousAcronym == true || nextPart.Length == 1)) { - int parsedSeason; - if (int.TryParse(seasonCapture.Value, out parsedSeason)) - seasons.Add(parsedSeason); + artistName += part + "."; + previousAcronym = true; + } + else + { + if (previousAcronym) + { + artistName += " "; + previousAcronym = false; + } + artistName += part + " "; } + n++; + } - //If no season was found it should be treated as a mini series and season 1 - if (seasons.Count == 0) seasons.Add(1); + artistName = artistName.Trim(' '); - //If more than 1 season was parsed go to the next REGEX (A multi-season release is unlikely) - if (seasons.Distinct().Count() > 1) return null; + int trackNumber; + int.TryParse(matchCollection[0].Groups["trackNumber"].Value, out trackNumber); - result = new ParsedEpisodeInfo - { - SeasonNumber = seasons.First(), - EpisodeNumbers = new int[0], - AbsoluteEpisodeNumbers = new int[0] - }; + ParsedTrackInfo result = new ParsedTrackInfo(); - foreach (Match matchGroup in matchCollection) - { - var episodeCaptures = matchGroup.Groups["episode"].Captures.Cast<Capture>().ToList(); - var absoluteEpisodeCaptures = matchGroup.Groups["absoluteepisode"].Captures.Cast<Capture>().ToList(); + result.ArtistTitle = artistName; + result.ArtistTitleInfo = GetArtistTitleInfo(result.ArtistTitle); - //Allows use to return a list of 0 episodes (We can handle that as a full season release) - if (episodeCaptures.Any()) - { - var first = ParseNumber(episodeCaptures.First().Value); - var last = ParseNumber(episodeCaptures.Last().Value); + Logger.Debug("Track Parsed. {0}", result); + return result; + } - if (first > last) - { - return null; - } + private static ArtistTitleInfo GetArtistTitleInfo(string title) + { + var artistTitleInfo = new ArtistTitleInfo(); + artistTitleInfo.Title = title; - var count = last - first + 1; - result.EpisodeNumbers = Enumerable.Range(first, count).ToArray(); - } + return artistTitleInfo; + } - if (absoluteEpisodeCaptures.Any()) - { - var first = Convert.ToInt32(absoluteEpisodeCaptures.First().Value); - var last = Convert.ToInt32(absoluteEpisodeCaptures.Last().Value); + public static string ParseArtistName(string title) + { + Logger.Debug("Parsing string '{0}'", title); - if (first > last) - { - return null; - } + var parseResult = ParseAlbumTitle(title); - var count = last - first + 1; - result.AbsoluteEpisodeNumbers = Enumerable.Range(first, count).ToArray(); + if (parseResult == null) + { + return CleanArtistName(title); + } - if (matchGroup.Groups["special"].Success) - { - result.Special = true; - } - } + return parseResult.ArtistName; + } - 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; + private static ParsedAlbumInfo ParseAlbumMatchCollection(MatchCollection matchCollection) + { + var artistName = matchCollection[0].Groups["artist"].Value.Replace('.', ' ').Replace('_', ' '); + var albumTitle = matchCollection[0].Groups["album"].Value.Replace('.', ' ').Replace('_', ' '); + var releaseVersion = matchCollection[0].Groups["version"].Value.Replace('.', ' ').Replace('_', ' '); + artistName = RequestInfoRegex.Replace(artistName, "").Trim(' '); + albumTitle = RequestInfoRegex.Replace(albumTitle, "").Trim(' '); + releaseVersion = RequestInfoRegex.Replace(releaseVersion, "").Trim(' '); - result.FullSeason = true; - } - } + int releaseYear; + int.TryParse(matchCollection[0].Groups["releaseyear"].Value, out releaseYear); - if (result.AbsoluteEpisodeNumbers.Any() && !result.EpisodeNumbers.Any()) - { - result.SeasonNumber = 0; - } - } + ParsedAlbumInfo result; - else - { - //Try to Parse as a daily show - var airmonth = Convert.ToInt32(matchCollection[0].Groups["airmonth"].Value); - var airday = Convert.ToInt32(matchCollection[0].Groups["airday"].Value); + result = new ParsedAlbumInfo(); - //Swap day and month if month is bigger than 12 (scene fail) - if (airmonth > 12) - { - var tempDay = airday; - airday = airmonth; - airmonth = tempDay; - } + result.ArtistName = artistName; + result.AlbumTitle = albumTitle; + result.ArtistTitleInfo = GetArtistTitleInfo(result.ArtistName); + result.ReleaseDate = releaseYear.ToString(); + result.ReleaseVersion = releaseVersion; - DateTime airDate; + if (matchCollection[0].Groups["discography"].Success) + { + int discStart; + int discEnd; + int.TryParse(matchCollection[0].Groups["startyear"].Value, out discStart); + int.TryParse(matchCollection[0].Groups["endyear"].Value, out discEnd); + result.Discography = true; - try - { - airDate = new DateTime(airYear, airmonth, airday); - } - catch (Exception) + if (discStart > 0 && discEnd > 0) { - throw new InvalidDateException("Invalid date found: {0}-{1}-{2}", airYear, airmonth, airday); + result.DiscographyStart = discStart; + result.DiscographyEnd = discEnd; } - - //Check if episode is in the future (most likely a parse error) - if (airDate > DateTime.Now.AddDays(1).Date || airDate < new DateTime(1970, 1, 1)) + else if (discEnd > 0) { - throw new InvalidDateException("Invalid date found: {0}", airDate); + result.DiscographyEnd = discEnd; } - result = new ParsedEpisodeInfo - { - AirDate = airDate.ToString(Episode.AIR_DATE_FORMAT), - }; + result.AlbumTitle = "Discography"; } - result.SeriesTitle = seriesName; - result.SeriesTitleInfo = GetSeriesTitleInfo(result.SeriesTitle); - - Logger.Debug("Episode Parsed. {0}", result); + Logger.Debug("Album Parsed. {0}", result); return result; } diff --git a/src/NzbDrone.Core/Parser/ParsingService.cs b/src/NzbDrone.Core/Parser/ParsingService.cs index 4ab4fd4c7..8dd21b942 100644 --- a/src/NzbDrone.Core/Parser/ParsingService.cs +++ b/src/NzbDrone.Core/Parser/ParsingService.cs @@ -1,473 +1,236 @@ -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; +using NzbDrone.Core.Music; +using System; +using System.IO; 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); + Artist GetArtist(string title); + Artist GetArtistFromTag(string file); + RemoteAlbum Map(ParsedAlbumInfo parsedAlbumInfo, SearchCriteriaBase searchCriteria = null); + RemoteAlbum Map(ParsedAlbumInfo parsedAlbumInfo, int artistId, IEnumerable<int> albumIds); + List<Album> GetAlbums(ParsedAlbumInfo parsedAlbumInfo, Artist artist, SearchCriteriaBase searchCriteria = null); + + // Music stuff here + Album GetLocalAlbum(string filename, Artist artist); } public class ParsingService : IParsingService { - private readonly IEpisodeService _episodeService; - private readonly ISeriesService _seriesService; - private readonly ISceneMappingService _sceneMappingService; + private readonly IArtistService _artistService; + private readonly IAlbumService _albumService; + private readonly ITrackService _trackService; + private readonly IMediaFileService _mediaFileService; private readonly Logger _logger; - public ParsingService(IEpisodeService episodeService, - ISeriesService seriesService, - ISceneMappingService sceneMappingService, + public ParsingService(ITrackService trackService, + IArtistService artistService, + IAlbumService albumService, + IMediaFileService mediaFileService, Logger logger) { - _episodeService = episodeService; - _seriesService = seriesService; - _sceneMappingService = sceneMappingService; + _albumService = albumService; + _artistService = artistService; + _trackService = trackService; + _mediaFileService = mediaFileService; _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) + public Artist GetArtist(string title) { - ParsedEpisodeInfo parsedEpisodeInfo; + var parsedAlbumInfo = Parser.ParseAlbumTitle(title); - if (folderInfo != null) + if (parsedAlbumInfo != null && !parsedAlbumInfo.ArtistName.IsNullOrWhiteSpace()) { - parsedEpisodeInfo = folderInfo.JsonClone(); - parsedEpisodeInfo.Quality = QualityParser.ParseQuality(Path.GetFileName(filename)); + title = parsedAlbumInfo.ArtistName; } + + var artistInfo = _artistService.FindByName(title); - else + if (artistInfo == null) { - parsedEpisodeInfo = Parser.ParsePath(filename); + _logger.Debug("Trying inexact artist match for {0}", title); + artistInfo = _artistService.FindByNameInexact(title); } - if (parsedEpisodeInfo == null || parsedEpisodeInfo.IsPossibleSpecialEpisode) - { - var title = Path.GetFileNameWithoutExtension(filename); - var specialEpisodeInfo = ParseSpecialEpisodeTitle(title, series); + return artistInfo; + } - if (specialEpisodeInfo != null) - { - parsedEpisodeInfo = specialEpisodeInfo; - } - } + public Artist GetArtistFromTag(string file) + { + var parsedTrackInfo = Parser.ParseMusicPath(file); + + var artist = new Artist(); - if (parsedEpisodeInfo == null) + if (parsedTrackInfo.ArtistMBId.IsNotNullOrWhiteSpace()) { - if (MediaFileExtensions.Extensions.Contains(Path.GetExtension(filename))) + artist = _artistService.FindById(parsedTrackInfo.ArtistMBId); + + if (artist != null) { - _logger.Warn("Unable to parse episode info from path {0}", filename); + return artist; } - - return null; } - var episodes = GetEpisodes(parsedEpisodeInfo, series, sceneSource); - - return new LocalEpisode + if (parsedTrackInfo == null || parsedTrackInfo.ArtistTitle.IsNullOrWhiteSpace()) { - 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); - - if (parsedEpisodeInfo == null) - { - return _seriesService.FindByTitle(title); + return null; } - var series = _seriesService.FindByTitle(parsedEpisodeInfo.SeriesTitle); + artist = _artistService.FindByName(parsedTrackInfo.ArtistTitle); - if (series == null) + if (artist == null) { - series = _seriesService.FindByTitle(parsedEpisodeInfo.SeriesTitleInfo.TitleWithoutYear, - parsedEpisodeInfo.SeriesTitleInfo.Year); + _logger.Debug("Trying inexact artist match for {0}", parsedTrackInfo.ArtistTitle); + artist = _artistService.FindByNameInexact(parsedTrackInfo.ArtistTitle); } - return series; + return artist; } - public RemoteEpisode Map(ParsedEpisodeInfo parsedEpisodeInfo, int tvdbId, int tvRageId, SearchCriteriaBase searchCriteria = null) + public RemoteAlbum Map(ParsedAlbumInfo parsedAlbumInfo, SearchCriteriaBase searchCriteria = null) { - var remoteEpisode = new RemoteEpisode - { - ParsedEpisodeInfo = parsedEpisodeInfo, - }; + var remoteAlbum = new RemoteAlbum + { + ParsedAlbumInfo = parsedAlbumInfo, + }; - var series = GetSeries(parsedEpisodeInfo, tvdbId, tvRageId, searchCriteria); + var artist = GetArtist(parsedAlbumInfo, searchCriteria); - if (series == null) + if (artist == null) { - return remoteEpisode; + return remoteAlbum; } - remoteEpisode.Series = series; - remoteEpisode.Episodes = GetEpisodes(parsedEpisodeInfo, series, true, searchCriteria); + remoteAlbum.Artist = artist; + remoteAlbum.Albums = GetAlbums(parsedAlbumInfo, artist, searchCriteria); - return remoteEpisode; + return remoteAlbum; } - public RemoteEpisode Map(ParsedEpisodeInfo parsedEpisodeInfo, int seriesId, IEnumerable<int> episodeIds) + public List<Album> GetAlbums(ParsedAlbumInfo parsedAlbumInfo, Artist artist, SearchCriteriaBase searchCriteria = null) { - return new RemoteEpisode - { - ParsedEpisodeInfo = parsedEpisodeInfo, - Series = _seriesService.GetSeries(seriesId), - Episodes = _episodeService.GetEpisodes(episodeIds) - }; - } + var albumTitle = parsedAlbumInfo.AlbumTitle; + var result = new List<Album>(); - public List<Episode> GetEpisodes(ParsedEpisodeInfo parsedEpisodeInfo, Series series, bool sceneSource, SearchCriteriaBase searchCriteria = null) - { - if (parsedEpisodeInfo.FullSeason) + if (parsedAlbumInfo.AlbumTitle == null) { - return _episodeService.GetEpisodesBySeason(series.Id, parsedEpisodeInfo.SeasonNumber); + return new List<Album>(); } - if (parsedEpisodeInfo.IsDaily) + Album albumInfo = null; + + if (parsedAlbumInfo.Discography) { - if (series.SeriesType == SeriesTypes.Standard) + if (parsedAlbumInfo.DiscographyStart > 0) { - _logger.Warn("Found daily-style episode for non-daily series: {0}.", series); - return new List<Episode>(); + return _albumService.ArtistAlbumsBetweenDates(artist, + new DateTime(parsedAlbumInfo.DiscographyStart, 1, 1), + new DateTime(parsedAlbumInfo.DiscographyEnd, 12, 31), false); } - var episodeInfo = GetDailyEpisode(series, parsedEpisodeInfo.AirDate, searchCriteria); - - if (episodeInfo != null) + if (parsedAlbumInfo.DiscographyEnd > 0) { - return new List<Episode> { episodeInfo }; + return _albumService.ArtistAlbumsBetweenDates(artist, + new DateTime(1800, 1, 1), + new DateTime(parsedAlbumInfo.DiscographyEnd, 12, 31), false); } - return new List<Episode>(); + return _albumService.GetAlbumsByArtist(artist.Id); } - if (parsedEpisodeInfo.IsAbsoluteNumbering) - { - return GetAnimeEpisodes(series, parsedEpisodeInfo, sceneSource); - } - - return GetStandardEpisodes(series, parsedEpisodeInfo, sceneSource, searchCriteria); - } - - public ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, 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); - } - - if (tvRageId != 0 && tvRageId == searchCriteria.Series.TvRageId) - { - return ParseSpecialEpisodeTitle(title, searchCriteria.Series); - } + albumInfo = searchCriteria.Albums.ExclusiveOrDefault(e => e.Title == albumTitle); } - var series = GetSeries(title); - - if (series == null) + if (albumInfo == null) { - series = _seriesService.FindByTitleInexact(title); + // TODO: Search by Title and Year instead of just Title when matching + albumInfo = _albumService.FindByTitle(artist.ArtistMetadataId, parsedAlbumInfo.AlbumTitle); } - if (series == null && tvdbId > 0) + if (albumInfo == null) { - series = _seriesService.FindByTvdbId(tvdbId); + _logger.Debug("Trying inexact album match for {0}", parsedAlbumInfo.AlbumTitle); + albumInfo = _albumService.FindByTitleInexact(artist.ArtistMetadataId, parsedAlbumInfo.AlbumTitle); } - if (series == null && tvRageId > 0) + if (albumInfo != null) { - series = _seriesService.FindByTvRageId(tvRageId); + result.Add(albumInfo); } - if (series == null) + else { - _logger.Debug("No matching series {0}", title); - return null; + _logger.Debug("Unable to find {0}", parsedAlbumInfo); } - return ParseSpecialEpisodeTitle(title, series); - } - - private ParsedEpisodeInfo ParseSpecialEpisodeTitle(string title, Series series) - { - // find special episode in series season 0 - var episode = _episodeService.FindEpisodeByTitle(series.Id, 0, title); - 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; - - _logger.Debug("Found special episode {0} for title '{1}'", info, title); - return info; - } + return result; - return null; } - private Series GetSeries(ParsedEpisodeInfo parsedEpisodeInfo, int tvdbId, int tvRageId, SearchCriteriaBase searchCriteria) + public RemoteAlbum Map(ParsedAlbumInfo parsedAlbumInfo, int artistId, IEnumerable<int> albumIds) { - Series series = null; - - var sceneMappingTvdbId = _sceneMappingService.FindTvdbId(parsedEpisodeInfo.SeriesTitle); - if (sceneMappingTvdbId.HasValue) + return new RemoteAlbum { - if (searchCriteria != null && searchCriteria.Series.TvdbId == sceneMappingTvdbId.Value) - { - return searchCriteria.Series; - } - - series = _seriesService.FindByTvdbId(sceneMappingTvdbId.Value); - - if (series == null) - { - _logger.Debug("No matching series {0}", parsedEpisodeInfo.SeriesTitle); - return null; - } + ParsedAlbumInfo = parsedAlbumInfo, + Artist = _artistService.GetArtist(artistId), + Albums = _albumService.GetAlbums(albumIds) + }; + } - return series; - } + private Artist GetArtist(ParsedAlbumInfo parsedAlbumInfo, SearchCriteriaBase searchCriteria) + { + Artist artist = null; if (searchCriteria != null) { - if (searchCriteria.Series.CleanTitle == parsedEpisodeInfo.SeriesTitle.CleanSeriesTitle()) - { - return searchCriteria.Series; - } - - if (tvdbId > 0 && tvdbId == searchCriteria.Series.TvdbId) + if (searchCriteria.Artist.CleanName == parsedAlbumInfo.ArtistName.CleanArtistName()) { - //TODO: If series is found by TvdbId, we should report it as a scene naming exception, since it will fail to import - return searchCriteria.Series; - } - - if (tvRageId > 0 && tvRageId == searchCriteria.Series.TvRageId) - { - //TODO: If series is found by TvRageId, we should report it as a scene naming exception, since it will fail to import - return searchCriteria.Series; + return searchCriteria.Artist; } } - series = _seriesService.FindByTitle(parsedEpisodeInfo.SeriesTitle); + artist = _artistService.FindByName(parsedAlbumInfo.ArtistName); - if (series == null && tvdbId > 0) + if (artist == null) { - //TODO: If series is found by TvdbId, we should report it as a scene naming exception, since it will fail to import - series = _seriesService.FindByTvdbId(tvdbId); + _logger.Debug("Trying inexact artist match for {0}", parsedAlbumInfo.ArtistName); + artist = _artistService.FindByNameInexact(parsedAlbumInfo.ArtistName); } - if (series == null && tvRageId > 0) + if (artist == null) { - //TODO: If series is found by TvRageId, we should report it as a scene naming exception, since it will fail to import - series = _seriesService.FindByTvRageId(tvRageId); - } - - if (series == null) - { - _logger.Debug("No matching series {0}", parsedEpisodeInfo.SeriesTitle); + _logger.Debug("No matching artist {0}", parsedAlbumInfo.ArtistName); return null; } - return series; + return artist; } - private Episode GetDailyEpisode(Series series, string airDate, SearchCriteriaBase searchCriteria) + public Album GetLocalAlbum(string filename, Artist artist) { - Episode episodeInfo = null; - - if (searchCriteria != null) + if (Path.HasExtension(filename)) { - episodeInfo = searchCriteria.Episodes.SingleOrDefault( - e => e.AirDate == airDate); + filename = Path.GetDirectoryName(filename); } - if (episodeInfo == null) - { - episodeInfo = _episodeService.FindEpisode(series.Id, airDate); - } - - return episodeInfo; - } + var tracksInAlbum = _mediaFileService.GetFilesByArtist(artist.Id) + .FindAll(s => Path.GetDirectoryName(s.Path) == filename) + .DistinctBy(s => s.AlbumId) + .ToList(); - private List<Episode> GetAnimeEpisodes(Series series, ParsedEpisodeInfo parsedEpisodeInfo, bool sceneSource) - { - var result = new List<Episode>(); - - var sceneSeasonNumber = _sceneMappingService.GetSceneSeasonNumber(parsedEpisodeInfo.SeriesTitle); - - foreach (var absoluteEpisodeNumber in parsedEpisodeInfo.AbsoluteEpisodeNumbers) - { - Episode episode = null; - - if (parsedEpisodeInfo.Special) - { - episode = _episodeService.FindEpisode(series.Id, 0, absoluteEpisodeNumber); - } - - else if (sceneSource) - { - // Is there a reason why we excluded season 1 from this handling before? - // Might have something to do with the scene name to season number check - // If this needs to be reverted tests will need to be added - if (sceneSeasonNumber.HasValue) - { - var episodes = _episodeService.FindEpisodesBySceneNumbering(series.Id, sceneSeasonNumber.Value, absoluteEpisodeNumber); - - if (episodes.Count == 1) - { - episode = episodes.First(); - } - - if (episode == null) - { - episode = _episodeService.FindEpisode(series.Id, sceneSeasonNumber.Value, absoluteEpisodeNumber); - } - } - - else - { - episode = _episodeService.FindEpisodeBySceneNumbering(series.Id, absoluteEpisodeNumber); - } - } - - if (episode == null) - { - episode = _episodeService.FindEpisode(series.Id, absoluteEpisodeNumber); - } - - if (episode != null) - { - _logger.Debug("Using absolute episode number {0} for: {1} - TVDB: {2}x{3:00}", - absoluteEpisodeNumber, - series.Title, - episode.SeasonNumber, - episode.EpisodeNumber); - - result.Add(episode); - } - } - - return result; - } - - private List<Episode> GetStandardEpisodes(Series series, ParsedEpisodeInfo parsedEpisodeInfo, bool sceneSource, SearchCriteriaBase searchCriteria) - { - var result = new List<Episode>(); - var seasonNumber = parsedEpisodeInfo.SeasonNumber; - - if (sceneSource) - { - var sceneMapping = _sceneMappingService.FindSceneMapping(parsedEpisodeInfo.SeriesTitle); - - if (sceneMapping != null && sceneMapping.SeasonNumber.HasValue && sceneMapping.SeasonNumber.Value >= 0 && - sceneMapping.SceneSeasonNumber == seasonNumber) - { - seasonNumber = sceneMapping.SeasonNumber.Value; - } - } - - if (parsedEpisodeInfo.EpisodeNumbers == null) - { - return new List<Episode>(); - } - - foreach (var episodeNumber in parsedEpisodeInfo.EpisodeNumbers) - { - if (series.UseSceneNumbering && sceneSource) - { - List<Episode> episodes = new List<Episode>(); - - if (searchCriteria != null) - { - episodes = searchCriteria.Episodes.Where(e => e.SceneSeasonNumber == parsedEpisodeInfo.SeasonNumber && - e.SceneEpisodeNumber == episodeNumber).ToList(); - } - - if (!episodes.Any()) - { - episodes = _episodeService.FindEpisodesBySceneNumbering(series.Id, seasonNumber, episodeNumber); - } - - if (episodes != null && episodes.Any()) - { - _logger.Debug("Using Scene to TVDB Mapping for: {0} - Scene: {1}x{2:00} - TVDB: {3}", - series.Title, - episodes.First().SceneSeasonNumber, - episodes.First().SceneEpisodeNumber, - string.Join(", ", episodes.Select(e => string.Format("{0}x{1:00}", e.SeasonNumber, e.EpisodeNumber)))); - - result.AddRange(episodes); - continue; - } - } - - Episode episodeInfo = null; - - if (searchCriteria != null) - { - episodeInfo = searchCriteria.Episodes.SingleOrDefault(e => e.SeasonNumber == seasonNumber && e.EpisodeNumber == episodeNumber); - } - - if (episodeInfo == null) - { - episodeInfo = _episodeService.FindEpisode(series.Id, seasonNumber, episodeNumber); - } - - if (episodeInfo != null) - { - result.Add(episodeInfo); - } - - else - { - _logger.Debug("Unable to find {0}", parsedEpisodeInfo); - } - } - - return result; + return tracksInAlbum.Count == 1 ? _albumService.GetAlbum(tracksInAlbum.First().AlbumId) : null; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Parser/QualityParser.cs b/src/NzbDrone.Core/Parser/QualityParser.cs index 7154cd3fd..19a30710c 100644 --- a/src/NzbDrone.Core/Parser/QualityParser.cs +++ b/src/NzbDrone.Core/Parser/QualityParser.cs @@ -1,6 +1,5 @@ -using System; +using System; using System.IO; -using System.Linq; using System.Text.RegularExpressions; using NLog; using NzbDrone.Common.Extensions; @@ -14,24 +13,10 @@ namespace NzbDrone.Core.Parser { private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(QualityParser)); - 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[. ])| - (?<hdtv>HDTV)| - (?<bdrip>BDRip)| - (?<brrip>BRRip)| - (?<dvd>DVD|DVDRip|NTSC|PAL|xvidvd)| - (?<dsr>WS[-_. ]DSR|DSR)| - (?<pdtv>PDTV)| - (?<sdtv>SDTV)| - (?<tvrip>TVRip) - )\b", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace); - - private static readonly Regex RawHDRegex = new Regex(@"\b(?<rawhd>RawHD|1080i[-_. ]HDTV|Raw[-_. ]HD|MPEG[-_. ]?2)\b", + private static readonly Regex ProperRegex = new Regex(@"\b(?<proper>proper)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex ProperRegex = new Regex(@"\b(?<proper>proper|repack|rerip)\b", + private static readonly Regex RepackRegex = new Regex(@"\b(?<repack>repack|rerip)\b", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex VersionRegex = new Regex(@"\dv(?<version>\d)\b|\[v(?<version>\d)\]", @@ -40,262 +25,116 @@ 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", - RegexOptions.Compiled | RegexOptions.IgnoreCase); - - private static readonly Regex CodecRegex = new Regex(@"\b(?:(?<x264>x264)|(?<h264>h264)|(?<xvidhd>XvidHD)|(?<xvid>Xvid)|(?<divx>divx))\b", - RegexOptions.Compiled | RegexOptions.IgnoreCase); - - private static readonly Regex OtherSourceRegex = new Regex(@"(?<hdtv>HD[-_. ]TV)|(?<sdtv>SD[-_. ]TV)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex BitRateRegex = new Regex(@"\b(?:(?<B096>96[ ]?kbps|96|[\[\(].*96.*[\]\)])| + (?<B128>128[ ]?kbps|128|[\[\(].*128.*[\]\)])| + (?<B160>160[ ]?kbps|160|[\[\(].*160.*[\]\)]|q5)| + (?<B192>192[ ]?kbps|192|[\[\(].*192.*[\]\)]|q6)| + (?<B224>224[ ]?kbps|224|[\[\(].*224.*[\]\)]|q7)| + (?<B256>256[ ]?kbps|256|itunes\splus|[\[\(].*256.*[\]\)]|q8)| + (?<B320>320[ ]?kbps|320|[\[\(].*320.*[\]\)]|q9)| + (?<B500>500[ ]?kbps|500|[\[\(].*500.*[\]\)]|q10)| + (?<VBRV0>V0[ ]?kbps|V0|[\[\(].*V0.*[\]\)])| + (?<VBRV2>V2[ ]?kbps|V2|[\[\(].*V2.*[\]\)]))\b", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace); - private static readonly Regex AnimeBlurayRegex = new Regex(@"bd(?:720|1080)|(?<=[-_. (\[])bd(?=[-_. )\]])", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex SampleSizeRegex = new Regex(@"\b(?:(?<S24>24[ ]bit|24bit|[\[\(].*24bit.*[\]\)]))"); - private static readonly Regex HighDefPdtvRegex = new Regex(@"hr[-_. ]ws", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex CodecRegex = new Regex(@"\b(?:(?<MP1>MPEG Version \d(.5)? Audio, Layer 1|MP1)|(?<MP2>MPEG Version \d(.5)? Audio, Layer 2|MP2)|(?<MP3VBR>MP3.*VBR|MPEG Version \d(.5)? Audio, Layer 3 vbr)|(?<MP3CBR>MP3|MPEG Version \d(.5)? Audio, Layer 3)|(?<FLAC>flac)|(?<WAVPACK>wavpack|wv)|(?<ALAC>alac)|(?<WMA>WMA\d?)|(?<WAV>WAV|PCM)|(?<AAC>M4A|M4P|M4B|AAC|mp4a|MPEG-4 Audio(?!.*alac))|(?<OGG>OGG|OGA|Vorbis))\b|(?<APE>monkey's audio|[\[|\(].*\bape\b.*[\]|\)])|(?<OPUS>Opus Version \d(.5)? Audio|[\[|\(].*\bopus\b.*[\]|\)])", + RegexOptions.Compiled | RegexOptions.IgnoreCase); - public static QualityModel ParseQuality(string name) + public static QualityModel ParseQuality(string name, string desc, int fileBitrate, int fileSampleSize = 0) { Logger.Debug("Trying to parse quality for {0}", name); - name = name.Trim(); var normalizedName = name.Replace('_', ' ').Trim().ToLower(); var result = ParseQualityModifiers(name, normalizedName); - if (RawHDRegex.IsMatch(normalizedName)) + if (desc.IsNotNullOrWhiteSpace()) { - result.Quality = Quality.RAWHD; - return result; - } - - var sourceMatch = SourceRegex.Matches(normalizedName).OfType<Match>().LastOrDefault(); - var resolution = ParseResolution(normalizedName); - var codecRegex = CodecRegex.Match(normalizedName); - - if (sourceMatch != null && sourceMatch.Success) - { - if (sourceMatch.Groups["bluray"].Success) - { - if (codecRegex.Groups["xvid"].Success || codecRegex.Groups["divx"].Success) - { - result.Quality = Quality.DVD; - return result; - } - - if (resolution == Resolution.R2160p) - { - result.Quality = Quality.Bluray2160p; - return result; - } - - if (resolution == Resolution.R1080p) - { - result.Quality = Quality.Bluray1080p; - return result; - } - - if (resolution == Resolution.R480P || resolution == Resolution.R576p) - { - result.Quality = Quality.DVD; - return result; - } - - result.Quality = Quality.Bluray720p; - return result; - } - - if (sourceMatch.Groups["webdl"].Success) - { - if (resolution == Resolution.R2160p) - { - result.Quality = Quality.WEBDL2160p; - return result; - } - - if (resolution == Resolution.R1080p) - { - result.Quality = Quality.WEBDL1080p; - return result; - } - - if (resolution == Resolution.R720p) - { - result.Quality = Quality.WEBDL720p; - return result; - } - - if (name.Contains("[WEBDL]")) - { - result.Quality = Quality.WEBDL720p; - return result; - } - - result.Quality = Quality.WEBDL480p; - return result; - } - - if (sourceMatch.Groups["hdtv"].Success) - { - if (resolution == Resolution.R2160p) - { - result.Quality = Quality.HDTV2160p; - return result; - } - - if (resolution == Resolution.R1080p) - { - result.Quality = Quality.HDTV1080p; - return result; - } - - if (resolution == Resolution.R720p) - { - result.Quality = Quality.HDTV720p; - return result; - } - - if (name.Contains("[HDTV]")) - { - result.Quality = Quality.HDTV720p; - return result; - } - - result.Quality = Quality.SDTV; - return result; - } - - if (sourceMatch.Groups["bdrip"].Success || - sourceMatch.Groups["brrip"].Success) - { - switch (resolution) - { - case Resolution.R720p: - result.Quality = Quality.Bluray720p; - return result; - case Resolution.R1080p: - result.Quality = Quality.Bluray1080p; - return result; - default: - result.Quality = Quality.DVD; - return result; - } - } + var descCodec = ParseCodec(desc, ""); + Logger.Trace($"Got codec {descCodec}"); - if (sourceMatch.Groups["dvd"].Success) - { - result.Quality = Quality.DVD; - return result; - } + result.Quality = FindQuality(descCodec, fileBitrate, fileSampleSize); - if (sourceMatch.Groups["pdtv"].Success || - sourceMatch.Groups["sdtv"].Success || - sourceMatch.Groups["dsr"].Success || - sourceMatch.Groups["tvrip"].Success) + if (result.Quality != Quality.Unknown) { - if (HighDefPdtvRegex.IsMatch(normalizedName)) - { - result.Quality = Quality.HDTV720p; - return result; - } - - result.Quality = Quality.SDTV; + result.QualityDetectionSource = QualityDetectionSource.TagLib; return result; } } + var codec = ParseCodec(normalizedName,name); + var bitrate = ParseBitRate(normalizedName); + var sampleSize = ParseSampleSize(normalizedName); - //Anime Bluray matching - if (AnimeBlurayRegex.Match(normalizedName).Success) - { - if (resolution == Resolution.R480P || resolution == Resolution.R576p || normalizedName.Contains("480p")) - { - result.Quality = Quality.DVD; - return result; - } - - if (resolution == Resolution.R1080p || normalizedName.Contains("1080p")) - { - result.Quality = Quality.Bluray1080p; - return result; - } - - result.Quality = Quality.Bluray720p; - return result; - } - - if (resolution == Resolution.R2160p) - { - result.Quality = Quality.HDTV2160p; - return result; - } - - if (resolution == Resolution.R1080p) - { - result.Quality = Quality.HDTV1080p; - return result; - } - - if (resolution == Resolution.R720p) - { - result.Quality = Quality.HDTV720p; - return result; - } - - if (resolution == Resolution.R480P) - { - result.Quality = Quality.SDTV; - return result; - } - - if (codecRegex.Groups["x264"].Success) - { - result.Quality = Quality.SDTV; - return result; - } - - if (normalizedName.Contains("848x480")) - { - if (normalizedName.Contains("dvd")) - { - result.Quality = Quality.DVD; - } - - result.Quality = Quality.SDTV; - } - - if (normalizedName.Contains("1280x720")) - { - if (normalizedName.Contains("bluray")) - { - result.Quality = Quality.Bluray720p; - } - - result.Quality = Quality.HDTV720p; - } - - if (normalizedName.Contains("1920x1080")) - { - if (normalizedName.Contains("bluray")) - { - result.Quality = Quality.Bluray1080p; - } - - result.Quality = Quality.HDTV1080p; - } - - if (normalizedName.Contains("bluray720p")) - { - result.Quality = Quality.Bluray720p; - } - - if (normalizedName.Contains("bluray1080p")) - { - result.Quality = Quality.Bluray1080p; - } - - var otherSourceMatch = OtherSourceMatch(normalizedName); - - if (otherSourceMatch != Quality.Unknown) + switch(codec) { - result.Quality = otherSourceMatch; + case Codec.MP1: + case Codec.MP2: + result.Quality = Quality.Unknown; + break; + case Codec.MP3VBR: + if (bitrate == BitRate.VBRV0) { result.Quality = Quality.MP3_VBR; } + else if (bitrate == BitRate.VBRV2) { result.Quality = Quality.MP3_VBR_V2; } + else { result.Quality = Quality.Unknown; } + break; + case Codec.MP3CBR: + if (bitrate == BitRate.B096) { result.Quality = Quality.MP3_096; } + else if (bitrate == BitRate.B128) { result.Quality = Quality.MP3_128; } + else if (bitrate == BitRate.B160) { result.Quality = Quality.MP3_160; } + else if (bitrate == BitRate.B192) { result.Quality = Quality.MP3_192; } + else if (bitrate == BitRate.B256) { result.Quality = Quality.MP3_256; } + else if (bitrate == BitRate.B320) { result.Quality = Quality.MP3_320; } + else { result.Quality = Quality.Unknown; } + break; + case Codec.FLAC: + if (sampleSize == SampleSize.S24) {result.Quality = Quality.FLAC_24;} + else {result.Quality = Quality.FLAC;} + break; + case Codec.ALAC: + result.Quality = Quality.ALAC; + break; + case Codec.WAVPACK: + result.Quality = Quality.WAVPACK; + break; + case Codec.APE: + result.Quality = Quality.APE; + break; + case Codec.WMA: + result.Quality = Quality.WMA; + break; + case Codec.WAV: + result.Quality = Quality.WAV; + break; + case Codec.AAC: + if (bitrate == BitRate.B192) { result.Quality = Quality.AAC_192; } + else if (bitrate == BitRate.B256) { result.Quality = Quality.AAC_256; } + else if (bitrate == BitRate.B320) { result.Quality = Quality.AAC_320; } + else { result.Quality = Quality.AAC_VBR; } + break; + case Codec.AACVBR: + result.Quality = Quality.AAC_VBR; + break; + case Codec.OGG: + case Codec.OPUS: + if (bitrate == BitRate.B160) { result.Quality = Quality.VORBIS_Q5; } + else if (bitrate == BitRate.B192) { result.Quality = Quality.VORBIS_Q6; } + else if (bitrate == BitRate.B224) { result.Quality = Quality.VORBIS_Q7; } + else if (bitrate == BitRate.B256) { result.Quality = Quality.VORBIS_Q8; } + else if (bitrate == BitRate.B320) { result.Quality = Quality.VORBIS_Q9; } + else if (bitrate == BitRate.B500) { result.Quality = Quality.VORBIS_Q10; } + else { result.Quality = Quality.Unknown; } + break; + case Codec.Unknown: + if (bitrate == BitRate.B192) { result.Quality = Quality.MP3_192; } + else if (bitrate == BitRate.B256) { result.Quality = Quality.MP3_256; } + else if (bitrate == BitRate.B320) { result.Quality = Quality.MP3_320; } + else if (bitrate == BitRate.VBR) { result.Quality = Quality.MP3_VBR_V2; } + else { result.Quality = Quality.Unknown; } + break; + default: + result.Quality = Quality.Unknown; + break; } //Based on extension @@ -304,7 +143,7 @@ namespace NzbDrone.Core.Parser try { result.Quality = MediaFileExtensions.GetQualityForExtension(Path.GetExtension(name)); - result.QualitySource = QualitySource.Extension; + result.QualityDetectionSource = QualityDetectionSource.Extension; } catch (ArgumentException) { @@ -316,29 +155,129 @@ namespace NzbDrone.Core.Parser return result; } - private static Resolution ParseResolution(string name) + public static Codec ParseCodec(string name, string origName) { - var match = ResolutionRegex.Match(name); + if (name.IsNullOrWhiteSpace()) + { + return Codec.Unknown; + } - if (!match.Success) return Resolution.Unknown; - if (match.Groups["R480p"].Success) return Resolution.R480P; - if (match.Groups["R576p"].Success) return Resolution.R576p; - if (match.Groups["R720p"].Success) return Resolution.R720p; - if (match.Groups["R1080p"].Success) return Resolution.R1080p; - if (match.Groups["R2160p"].Success) return Resolution.R2160p; + var match = CodecRegex.Match(name); + + if (!match.Success) { return Codec.Unknown; } + if (match.Groups["FLAC"].Success) { return Codec.FLAC; } + if (match.Groups["ALAC"].Success) { return Codec.ALAC; } + if (match.Groups["WMA"].Success) { return Codec.WMA; } + if (match.Groups["WAV"].Success) { return Codec.WAV; } + if (match.Groups["AAC"].Success) { return Codec.AAC; } + if (match.Groups["OGG"].Success) { return Codec.OGG; } + if (match.Groups["OPUS"].Success) { return Codec.OPUS; } + if (match.Groups["MP1"].Success) { return Codec.MP1; } + if (match.Groups["MP2"].Success) { return Codec.MP2; } + if (match.Groups["MP3VBR"].Success) { return Codec.MP3VBR; } + if (match.Groups["MP3CBR"].Success) { return Codec.MP3CBR; } + if (match.Groups["WAVPACK"].Success) { return Codec.WAVPACK; } + if (match.Groups["APE"].Success) { return Codec.APE; } + + return Codec.Unknown; + } - return Resolution.Unknown; + private static BitRate ParseBitRate(string name) + { + //var nameWithNoSpaces = Regex.Replace(name, @"\s+", ""); + var match = BitRateRegex.Match(name); + + if (!match.Success) return BitRate.Unknown; + if (match.Groups["B096"].Success) { return BitRate.B096; } + if (match.Groups["B128"].Success) { return BitRate.B128; } + if (match.Groups["B160"].Success) { return BitRate.B160; } + if (match.Groups["B192"].Success) { return BitRate.B192; } + if (match.Groups["B224"].Success) { return BitRate.B224; } + if (match.Groups["B256"].Success) { return BitRate.B256; } + if (match.Groups["B320"].Success) { return BitRate.B320; } + if (match.Groups["B500"].Success) { return BitRate.B500; } + if (match.Groups["VBR"].Success) { return BitRate.VBR; } + if (match.Groups["VBRV0"].Success) { return BitRate.VBRV0; } + if (match.Groups["VBRV2"].Success) { return BitRate.VBRV2; } + + return BitRate.Unknown; } - private static Quality OtherSourceMatch(string name) + private static SampleSize ParseSampleSize(string name) { - var match = OtherSourceRegex.Match(name); + var match = SampleSizeRegex.Match(name); - if (!match.Success) return Quality.Unknown; - if (match.Groups["sdtv"].Success) return Quality.SDTV; - if (match.Groups["hdtv"].Success) return Quality.HDTV720p; + if (!match.Success) { return SampleSize.Unknown; } + if (match.Groups["S24"].Success) { return SampleSize.S24; } - return Quality.Unknown; + return SampleSize.Unknown; + } + + private static Quality FindQuality(Codec codec, int bitrate, int sampleSize = 0) + { + switch (codec) + { + case Codec.MP1: + case Codec.MP2: + return Quality.Unknown; + case Codec.MP3VBR: + return Quality.MP3_VBR; + case Codec.MP3CBR: + if (bitrate == 8) { return Quality.MP3_008; } + if (bitrate == 16) { return Quality.MP3_016; } + if (bitrate == 24) { return Quality.MP3_024; } + if (bitrate == 32) { return Quality.MP3_032; } + if (bitrate == 40) { return Quality.MP3_040; } + if (bitrate == 48) { return Quality.MP3_048; } + if (bitrate == 56) { return Quality.MP3_056; } + if (bitrate == 64) { return Quality.MP3_064; } + if (bitrate == 80) { return Quality.MP3_080; } + if (bitrate == 96) { return Quality.MP3_096; } + if (bitrate == 112) { return Quality.MP3_112; } + if (bitrate == 128) { return Quality.MP3_128; } + if (bitrate == 160) { return Quality.MP3_160; } + if (bitrate == 192) { return Quality.MP3_192; } + if (bitrate == 224) { return Quality.MP3_224; } + if (bitrate == 256) { return Quality.MP3_256; } + if (bitrate == 320) { return Quality.MP3_320; } + return Quality.Unknown; + case Codec.FLAC: + if (sampleSize == 24) {return Quality.FLAC_24;} + return Quality.FLAC; + case Codec.ALAC: + return Quality.ALAC; + case Codec.WAVPACK: + return Quality.WAVPACK; + case Codec.APE: + return Quality.APE; + case Codec.WMA: + return Quality.WMA; + case Codec.WAV: + return Quality.WAV; + case Codec.AAC: + if (bitrate == 192) { return Quality.AAC_192; } + if (bitrate == 256) { return Quality.AAC_256; } + if (bitrate == 320) { return Quality.AAC_320; } + return Quality.AAC_VBR; + case Codec.OGG: + if (bitrate == 160) { return Quality.VORBIS_Q5; } + if (bitrate == 192) { return Quality.VORBIS_Q6; } + if (bitrate == 224) { return Quality.VORBIS_Q7; } + if (bitrate == 256) { return Quality.VORBIS_Q8; } + if (bitrate == 320) { return Quality.VORBIS_Q9; } + if (bitrate == 500) { return Quality.VORBIS_Q10; } + return Quality.Unknown; + case Codec.OPUS: + if (bitrate < 130) { return Quality.Unknown; } + if (bitrate < 180) { return Quality.VORBIS_Q5; } + if (bitrate < 205) { return Quality.VORBIS_Q6; } + if (bitrate < 240) { return Quality.VORBIS_Q7; } + if (bitrate < 290) { return Quality.VORBIS_Q8; } + if (bitrate < 410) { return Quality.VORBIS_Q9; } + return Quality.VORBIS_Q10; + default: + return Quality.Unknown; + } } private static QualityModel ParseQualityModifiers(string name, string normalizedName) @@ -350,7 +289,13 @@ namespace NzbDrone.Core.Parser result.Revision.Version = 2; } - var versionRegexResult = VersionRegex.Match(normalizedName); + if (RepackRegex.IsMatch(normalizedName)) + { + result.Revision.Version = 2; + result.Revision.IsRepack = true; + } + + Match versionRegexResult = VersionRegex.Match(normalizedName); if (versionRegexResult.Success) { @@ -358,8 +303,7 @@ namespace NzbDrone.Core.Parser } //TODO: re-enable this when we have a reliable way to determine real - //TODO: Only treat it as a real if it comes AFTER the season/epsiode number - var realRegexResult = RealRegex.Matches(name); + MatchCollection realRegexResult = RealRegex.Matches(name); if (realRegexResult.Count > 0) { @@ -370,13 +314,44 @@ namespace NzbDrone.Core.Parser } } - public enum Resolution + public enum Codec + { + MP1, + MP2, + MP3CBR, + MP3VBR, + FLAC, + ALAC, + APE, + WAVPACK, + WMA, + AAC, + AACVBR, + OGG, + OPUS, + WAV, + Unknown + } + + public enum BitRate + { + B096, + B128, + B160, + B192, + B224, + B256, + B320, + B500, + VBR, + VBRV0, + VBRV2, + Unknown, + } + + public enum SampleSize { - R480P, - R576p, - R720p, - R1080p, - R2160p, + S24, Unknown } } diff --git a/src/NzbDrone.Core/Parser/SceneChecker.cs b/src/NzbDrone.Core/Parser/SceneChecker.cs index 188027153..36dcb78ca 100644 --- a/src/NzbDrone.Core/Parser/SceneChecker.cs +++ b/src/NzbDrone.Core/Parser/SceneChecker.cs @@ -1,4 +1,4 @@ -namespace NzbDrone.Core.Parser +namespace NzbDrone.Core.Parser { public static class SceneChecker { @@ -9,12 +9,12 @@ if (!title.Contains(".")) return false; if (title.Contains(" ")) return false; - var parsedTitle = Parser.ParseTitle(title); + var parsedTitle = Parser.ParseMusicTitle(title); if (parsedTitle == null || parsedTitle.ReleaseGroup == null || parsedTitle.Quality.Quality == Qualities.Quality.Unknown || - string.IsNullOrWhiteSpace(parsedTitle.SeriesTitle)) + string.IsNullOrWhiteSpace(parsedTitle.ArtistTitle)) { return false; } diff --git a/src/NzbDrone.Core/Profiles/Delay/DelayProfileService.cs b/src/NzbDrone.Core/Profiles/Delay/DelayProfileService.cs index a367ce4eb..cb0b19201 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 @@ -11,27 +13,38 @@ namespace NzbDrone.Core.Profiles.Delay void Delete(int id); List<DelayProfile> All(); DelayProfile Get(int id); + List<DelayProfile> AllForTag(int tagId); List<DelayProfile> AllForTags(HashSet<int> tagIds); DelayProfile BestForTags(HashSet<int> tagIds); + List<DelayProfile> Reorder(int id, int? afterId); } 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); + profile.Order = _repo.Count(); + + 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 +61,7 @@ namespace NzbDrone.Core.Profiles.Delay } _repo.UpdateMany(all); + _bestForTagsCache.Clear(); } public List<DelayProfile> All() @@ -60,15 +74,95 @@ namespace NzbDrone.Core.Profiles.Delay return _repo.Get(id); } + public List<DelayProfile> AllForTag(int tagId) + { + return All().Where(r => r.Tags.Contains(tagId)) + .ToList(); + } + public List<DelayProfile> AllForTags(HashSet<int> tagIds) { - return _repo.All().Where(r => r.Tags.Intersect(tagIds).Any() || r.Tags.Empty()).ToList(); + return All().Where(r => r.Tags.Intersect(tagIds).Any() || r.Tags.Empty()).ToList(); } public DelayProfile BestForTags(HashSet<int> tagIds) { - return _repo.All().Where(r => r.Tags.Intersect(tagIds).Any() || r.Tags.Empty()) - .OrderBy(d => d.Order).First(); + 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(); + } + + public List<DelayProfile> Reorder(int id, int? afterId) + { + var all = All().OrderBy(d => d.Order) + .ToList(); + + var moving = all.SingleOrDefault(d => d.Id == id); + var after = afterId.HasValue ? all.SingleOrDefault(d => d.Id == afterId) : null; + + if (moving == null) + { + // TODO: This should throw + return all; + } + + var afterOrder = GetAfterOrder(moving, after); + var afterCount = afterOrder + 2; + var movingOrder = moving.Order; + + foreach (var delayProfile in all) + { + if (delayProfile.Id == 1) + { + continue; + } + + if (delayProfile.Id == id) + { + delayProfile.Order = afterOrder + 1; + } + + else if (delayProfile.Id == after?.Id) + { + delayProfile.Order = afterOrder; + } + + else if (delayProfile.Order > afterOrder) + { + delayProfile.Order = afterCount; + afterCount++; + } + + else if (delayProfile.Order > movingOrder) + { + delayProfile.Order--; + } + } + + _repo.UpdateMany(all); + + return All(); + } + + private int GetAfterOrder(DelayProfile moving, DelayProfile after) + { + if (after == null) + { + return 0; + } + + if (moving.Order < after.Order) + { + return after.Order - 1; + } + + return after.Order; } } } diff --git a/src/NzbDrone.Core/Profiles/Metadata/MetadataProfile.cs b/src/NzbDrone.Core/Profiles/Metadata/MetadataProfile.cs new file mode 100644 index 000000000..79082f28e --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Metadata/MetadataProfile.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Indexers; + +namespace NzbDrone.Core.Profiles.Metadata +{ + public class MetadataProfile : ModelBase + { + public string Name { get; set; } + public List<ProfilePrimaryAlbumTypeItem> PrimaryAlbumTypes { get; set; } + public List<ProfileSecondaryAlbumTypeItem> SecondaryAlbumTypes { get; set; } + public List<ProfileReleaseStatusItem> ReleaseStatuses { get; set; } + + } +} diff --git a/src/NzbDrone.Core/Profiles/Metadata/MetadataProfileInUseException.cs b/src/NzbDrone.Core/Profiles/Metadata/MetadataProfileInUseException.cs new file mode 100644 index 000000000..7f215f8dc --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Metadata/MetadataProfileInUseException.cs @@ -0,0 +1,14 @@ +using System.Net; +using NzbDrone.Core.Exceptions; + +namespace NzbDrone.Core.Profiles.Metadata +{ + public class MetadataProfileInUseException : NzbDroneClientException + { + public MetadataProfileInUseException(string name) + : base(HttpStatusCode.BadRequest, "Metadata profile [{0}] is in use.", name) + { + + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Metadata/MetadataProfileRepository.cs b/src/NzbDrone.Core/Profiles/Metadata/MetadataProfileRepository.cs new file mode 100644 index 000000000..df725610d --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Metadata/MetadataProfileRepository.cs @@ -0,0 +1,23 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.Profiles.Metadata +{ + public interface IMetadataProfileRepository : IBasicRepository<MetadataProfile> + { + bool Exists(int id); + } + + public class MetadataProfileRepository : BasicRepository<MetadataProfile>, IMetadataProfileRepository + { + public MetadataProfileRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + + public bool Exists(int id) + { + return DataMapper.Query<MetadataProfile>().Where(p => p.Id == id).GetRowCount() == 1; + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Metadata/MetadataProfileService.cs b/src/NzbDrone.Core/Profiles/Metadata/MetadataProfileService.cs new file mode 100644 index 000000000..0cd1fb60e --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Metadata/MetadataProfileService.cs @@ -0,0 +1,115 @@ +using NLog; +using NzbDrone.Core.Lifecycle; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Music; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.ImportLists; + +namespace NzbDrone.Core.Profiles.Metadata +{ + public interface IMetadataProfileService + { + MetadataProfile Add(MetadataProfile profile); + void Update(MetadataProfile profile); + void Delete(int id); + List<MetadataProfile> All(); + MetadataProfile Get(int id); + bool Exists(int id); + } + + public class MetadataProfileService : IMetadataProfileService, IHandle<ApplicationStartedEvent> + { + private readonly IMetadataProfileRepository _profileRepository; + private readonly IArtistService _artistService; + private readonly IImportListFactory _importListFactory; + private readonly Logger _logger; + + public MetadataProfileService(IMetadataProfileRepository profileRepository, + IArtistService artistService, + IImportListFactory importListFactory, + Logger logger) + { + _profileRepository = profileRepository; + _artistService = artistService; + _importListFactory = importListFactory; + _logger = logger; + } + + public MetadataProfile Add(MetadataProfile profile) + { + return _profileRepository.Insert(profile); + } + + public void Update(MetadataProfile profile) + { + _profileRepository.Update(profile); + } + + public void Delete(int id) + { + if (_artistService.GetAllArtists().Any(c => c.MetadataProfileId == id) || _importListFactory.All().Any(c => c.MetadataProfileId == id)) + { + var profile = _profileRepository.Get(id); + throw new MetadataProfileInUseException(profile.Name); + } + + _profileRepository.Delete(id); + } + + public List<MetadataProfile> All() + { + return _profileRepository.All().ToList(); + } + + public MetadataProfile Get(int id) + { + return _profileRepository.Get(id); + } + + public bool Exists(int id) + { + return _profileRepository.Exists(id); + } + + private void AddDefaultProfile(string name, List<PrimaryAlbumType> primAllowed, List<SecondaryAlbumType> secAllowed, List<ReleaseStatus> relAllowed) + { + var primaryTypes = PrimaryAlbumType.All + .OrderByDescending(l => l.Name) + .Select(v => new ProfilePrimaryAlbumTypeItem {PrimaryAlbumType = v, Allowed = primAllowed.Contains(v)}) + .ToList(); + + var secondaryTypes = SecondaryAlbumType.All + .OrderByDescending(l => l.Name) + .Select(v => new ProfileSecondaryAlbumTypeItem {SecondaryAlbumType = v, Allowed = secAllowed.Contains(v)}) + .ToList(); + + var releaseStatues = ReleaseStatus.All + .OrderByDescending(l => l.Name) + .Select(v => new ProfileReleaseStatusItem {ReleaseStatus = v, Allowed = relAllowed.Contains(v)}) + .ToList(); + + var profile = new MetadataProfile + { + Name = name, + PrimaryAlbumTypes = primaryTypes, + SecondaryAlbumTypes = secondaryTypes, + ReleaseStatuses = releaseStatues + }; + + Add(profile); + } + + public void Handle(ApplicationStartedEvent message) + { + if (All().Any()) + { + return; + } + + _logger.Info("Setting up default metadata profile"); + + AddDefaultProfile("Standard", new List<PrimaryAlbumType>{PrimaryAlbumType.Album}, new List<SecondaryAlbumType>{ SecondaryAlbumType.Studio }, new List<ReleaseStatus>{ReleaseStatus.Official}); + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Metadata/ProfilePrimaryAlbumTypeItem.cs b/src/NzbDrone.Core/Profiles/Metadata/ProfilePrimaryAlbumTypeItem.cs new file mode 100644 index 000000000..6f1c008f1 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Metadata/ProfilePrimaryAlbumTypeItem.cs @@ -0,0 +1,11 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Profiles.Metadata +{ + public class ProfilePrimaryAlbumTypeItem : IEmbeddedDocument + { + public PrimaryAlbumType PrimaryAlbumType { get; set; } + public bool Allowed { get; set; } + } +} diff --git a/src/NzbDrone.Core/Profiles/Metadata/ProfileReleaseStatusTypeItem.cs b/src/NzbDrone.Core/Profiles/Metadata/ProfileReleaseStatusTypeItem.cs new file mode 100644 index 000000000..2475c2534 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Metadata/ProfileReleaseStatusTypeItem.cs @@ -0,0 +1,11 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Profiles.Metadata +{ + public class ProfileReleaseStatusItem : IEmbeddedDocument + { + public ReleaseStatus ReleaseStatus { get; set; } + public bool Allowed { get; set; } + } +} diff --git a/src/NzbDrone.Core/Profiles/Metadata/ProfileSecondaryAlbumTypeItem.cs b/src/NzbDrone.Core/Profiles/Metadata/ProfileSecondaryAlbumTypeItem.cs new file mode 100644 index 000000000..d9aacf570 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Metadata/ProfileSecondaryAlbumTypeItem.cs @@ -0,0 +1,11 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Profiles.Metadata +{ + public class ProfileSecondaryAlbumTypeItem : IEmbeddedDocument + { + public SecondaryAlbumType SecondaryAlbumType { get; set; } + public bool Allowed { get; set; } + } +} diff --git a/src/NzbDrone.Core/Profiles/Profile.cs b/src/NzbDrone.Core/Profiles/Profile.cs deleted file mode 100644 index 6215e9474..000000000 --- a/src/NzbDrone.Core/Profiles/Profile.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NzbDrone.Core.Datastore; -using NzbDrone.Core.Parser; -using NzbDrone.Core.Qualities; - -namespace NzbDrone.Core.Profiles -{ - public class Profile : ModelBase - { - public string Name { get; set; } - public Quality Cutoff { get; set; } - public List<ProfileQualityItem> Items { get; set; } - public Language Language { get; set; } - - public Quality LastAllowedQuality() - { - return Items.Last(q => q.Allowed).Quality; - } - } -} diff --git a/src/NzbDrone.Core/Profiles/ProfileInUseException.cs b/src/NzbDrone.Core/Profiles/ProfileInUseException.cs deleted file mode 100644 index d55523d9a..000000000 --- a/src/NzbDrone.Core/Profiles/ProfileInUseException.cs +++ /dev/null @@ -1,13 +0,0 @@ -using NzbDrone.Common.Exceptions; - -namespace NzbDrone.Core.Profiles -{ - public class ProfileInUseException : NzbDroneException - { - public ProfileInUseException(int profileId) - : base("Profile [{0}] is in use.", profileId) - { - - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/Profiles/ProfileQualityItem.cs b/src/NzbDrone.Core/Profiles/ProfileQualityItem.cs deleted file mode 100644 index 35c9ce360..000000000 --- a/src/NzbDrone.Core/Profiles/ProfileQualityItem.cs +++ /dev/null @@ -1,11 +0,0 @@ -using NzbDrone.Core.Datastore; -using NzbDrone.Core.Qualities; - -namespace NzbDrone.Core.Profiles -{ - public class ProfileQualityItem : IEmbeddedDocument - { - public Quality Quality { get; set; } - public bool Allowed { get; set; } - } -} diff --git a/src/NzbDrone.Core/Profiles/ProfileRepository.cs b/src/NzbDrone.Core/Profiles/ProfileRepository.cs deleted file mode 100644 index 4e071a0cf..000000000 --- a/src/NzbDrone.Core/Profiles/ProfileRepository.cs +++ /dev/null @@ -1,23 +0,0 @@ -using NzbDrone.Core.Datastore; -using NzbDrone.Core.Messaging.Events; - -namespace NzbDrone.Core.Profiles -{ - public interface IProfileRepository : IBasicRepository<Profile> - { - bool Exists(int id); - } - - public class ProfileRepository : BasicRepository<Profile>, IProfileRepository - { - public ProfileRepository(IMainDatabase database, IEventAggregator eventAggregator) - : base(database, eventAggregator) - { - } - - public bool Exists(int id) - { - return DataMapper.Query<Profile>().Where(p => p.Id == id).GetRowCount() == 1; - } - } -} diff --git a/src/NzbDrone.Core/Profiles/ProfileService.cs b/src/NzbDrone.Core/Profiles/ProfileService.cs deleted file mode 100644 index 89c569ff1..000000000 --- a/src/NzbDrone.Core/Profiles/ProfileService.cs +++ /dev/null @@ -1,128 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NLog; -using NzbDrone.Core.Lifecycle; -using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Parser; -using NzbDrone.Core.Qualities; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Profiles -{ - public interface IProfileService - { - Profile Add(Profile profile); - void Update(Profile profile); - void Delete(int id); - List<Profile> All(); - Profile Get(int id); - bool Exists(int id); - } - - public class ProfileService : IProfileService, IHandle<ApplicationStartedEvent> - { - private readonly IProfileRepository _profileRepository; - private readonly ISeriesService _seriesService; - private readonly Logger _logger; - - public ProfileService(IProfileRepository profileRepository, ISeriesService seriesService, Logger logger) - { - _profileRepository = profileRepository; - _seriesService = seriesService; - _logger = logger; - } - - public Profile Add(Profile profile) - { - return _profileRepository.Insert(profile); - } - - public void Update(Profile profile) - { - _profileRepository.Update(profile); - } - - public void Delete(int id) - { - if (_seriesService.GetAllSeries().Any(c => c.ProfileId == id)) - { - throw new ProfileInUseException(id); - } - - _profileRepository.Delete(id); - } - - public List<Profile> All() - { - return _profileRepository.All().ToList(); - } - - public Profile Get(int id) - { - return _profileRepository.Get(id); - } - - public bool Exists(int id) - { - return _profileRepository.Exists(id); - } - - private Profile AddDefaultProfile(string name, Quality cutoff, params Quality[] allowed) - { - var items = Quality.DefaultQualityDefinitions - .OrderBy(v => v.Weight) - .Select(v => new ProfileQualityItem { Quality = v.Quality, Allowed = allowed.Contains(v.Quality) }) - .ToList(); - - var profile = new Profile { Name = name, Cutoff = cutoff, Items = items, Language = Language.English }; - - return Add(profile); - } - - public void Handle(ApplicationStartedEvent message) - { - if (All().Any()) return; - - _logger.Info("Setting up default quality profiles"); - - AddDefaultProfile("Any", Quality.SDTV, - Quality.SDTV, - Quality.WEBDL480p, - Quality.DVD, - Quality.HDTV720p, - Quality.HDTV1080p, - Quality.WEBDL720p, - Quality.WEBDL1080p, - Quality.Bluray720p, - Quality.Bluray1080p); - - AddDefaultProfile("SD", Quality.SDTV, - Quality.SDTV, - Quality.WEBDL480p, - Quality.DVD); - - AddDefaultProfile("HD-720p", Quality.HDTV720p, - Quality.HDTV720p, - Quality.WEBDL720p, - Quality.Bluray720p); - - AddDefaultProfile("HD-1080p", Quality.HDTV1080p, - Quality.HDTV1080p, - Quality.WEBDL1080p, - Quality.Bluray1080p); - - AddDefaultProfile("Ultra-HD", Quality.HDTV2160p, - Quality.HDTV2160p, - Quality.WEBDL2160p, - Quality.Bluray2160p); - - AddDefaultProfile("HD - 720p/1080p", Quality.HDTV720p, - Quality.HDTV720p, - Quality.HDTV1080p, - Quality.WEBDL720p, - Quality.WEBDL1080p, - Quality.Bluray720p, - Quality.Bluray1080p); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/Profiles/Qualities/QualityIndex.cs b/src/NzbDrone.Core/Profiles/Qualities/QualityIndex.cs new file mode 100644 index 000000000..c4adf42a6 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Qualities/QualityIndex.cs @@ -0,0 +1,55 @@ +using System; + +namespace NzbDrone.Core.Profiles.Qualities +{ + public class QualityIndex : IComparable, IComparable<QualityIndex> + { + public int Index { get; set; } + public int GroupIndex { get; set; } + + public QualityIndex() + { + Index = 0; + GroupIndex = 0; + } + + public QualityIndex(int index) + { + Index = index; + GroupIndex = 0; + } + + public QualityIndex(int index, int groupIndex) + { + Index = index; + GroupIndex = groupIndex; + } + + public int CompareTo(object obj) + { + return CompareTo((QualityIndex)obj, true); + } + + public int CompareTo(QualityIndex other) + { + return CompareTo(other, true); + } + + public int CompareTo(QualityIndex right, bool respectGroupOrder) + { + if (right == null) + { + return 1; + } + + var indexCompare = Index.CompareTo(right.Index); + + if (respectGroupOrder && indexCompare == 0) + { + return GroupIndex.CompareTo(right.GroupIndex); + } + + return indexCompare; + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Qualities/QualityProfile.cs b/src/NzbDrone.Core/Profiles/Qualities/QualityProfile.cs new file mode 100644 index 000000000..083a5ca9c --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Qualities/QualityProfile.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Qualities; + +namespace NzbDrone.Core.Profiles.Qualities +{ + public class QualityProfile : ModelBase + { + public string Name { get; set; } + public bool UpgradeAllowed { get; set; } + public int Cutoff { get; set; } + public List<QualityProfileQualityItem> Items { get; set; } + + public Quality LastAllowedQuality() + { + var lastAllowed = Items.Last(q => q.Allowed); + + if (lastAllowed.Quality != null) + { + return lastAllowed.Quality; + } + + // Returning any item from the group will work, + // returning the last because it's the true last quality. + return lastAllowed.Items.Last().Quality; + } + + public QualityIndex GetIndex(Quality quality, bool respectGroupOrder = false) + { + return GetIndex(quality.Id, respectGroupOrder); + } + + public QualityIndex GetIndex(int id, bool respectGroupOrder = false) + { + for (var i = 0; i < Items.Count; i++) + { + var item = Items[i]; + var quality = item.Quality; + + // Quality matches by ID + if (quality != null && quality.Id == id) + { + return new QualityIndex(i); + } + + // Group matches by ID + if (item.Id > 0 && item.Id == id) + { + return new QualityIndex(i); + } + + for (var g = 0; g < item.Items.Count; g++) + { + var groupItem = item.Items[g]; + + if (groupItem.Quality.Id == id) + { + return respectGroupOrder ? new QualityIndex(i, g) : new QualityIndex(i); + } + } + } + + return new QualityIndex(); + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Qualities/QualityProfileInUseException.cs b/src/NzbDrone.Core/Profiles/Qualities/QualityProfileInUseException.cs new file mode 100644 index 000000000..cf64e2dbe --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Qualities/QualityProfileInUseException.cs @@ -0,0 +1,14 @@ +using System.Net; +using NzbDrone.Core.Exceptions; + +namespace NzbDrone.Core.Profiles.Qualities +{ + public class QualityProfileInUseException : NzbDroneClientException + { + public QualityProfileInUseException(string name) + : base(HttpStatusCode.BadRequest, "Profile [{0}] is in use.", name) + { + + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Qualities/QualityProfileQualityItem.cs b/src/NzbDrone.Core/Profiles/Qualities/QualityProfileQualityItem.cs new file mode 100644 index 000000000..4b5369749 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Qualities/QualityProfileQualityItem.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Qualities; + +namespace NzbDrone.Core.Profiles.Qualities +{ + public class QualityProfileQualityItem : IEmbeddedDocument + { + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + public int Id { get; set; } + + public string Name { get; set; } + public Quality Quality { get; set; } + public List<QualityProfileQualityItem> Items { get; set; } + public bool Allowed { get; set; } + + public QualityProfileQualityItem() + { + Items = new List<QualityProfileQualityItem>(); + } + + public List<Quality> GetQualities() + { + if (Quality == null) + { + return Items.Select(s => s.Quality).ToList(); + } + + return new List<Quality> { Quality }; + } + + public override string ToString() + { + var qualitiesString = string.Join(", ", GetQualities()); + + if (Name.IsNotNullOrWhiteSpace()) + { + return $"{Name} ({qualitiesString})"; + } + + return qualitiesString; + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Qualities/QualityProfileRepository.cs b/src/NzbDrone.Core/Profiles/Qualities/QualityProfileRepository.cs new file mode 100644 index 000000000..9f8bf9a0a --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Qualities/QualityProfileRepository.cs @@ -0,0 +1,23 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.Profiles.Qualities +{ + public interface IProfileRepository : IBasicRepository<QualityProfile> + { + bool Exists(int id); + } + + public class QualityProfileRepository : BasicRepository<QualityProfile>, IProfileRepository + { + public QualityProfileRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + + public bool Exists(int id) + { + return DataMapper.Query<QualityProfile>().Where(p => p.Id == id).GetRowCount() == 1; + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Qualities/QualityProfileService.cs b/src/NzbDrone.Core/Profiles/Qualities/QualityProfileService.cs new file mode 100644 index 000000000..74012bed2 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Qualities/QualityProfileService.cs @@ -0,0 +1,185 @@ +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http.Dispatchers; +using NzbDrone.Core.ImportLists; +using NzbDrone.Core.Lifecycle; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Profiles.Qualities +{ + public interface IProfileService + { + QualityProfile Add(QualityProfile profile); + void Update(QualityProfile profile); + void Delete(int id); + List<QualityProfile> All(); + QualityProfile Get(int id); + bool Exists(int id); + QualityProfile GetDefaultProfile(string name, Quality cutoff = null, params Quality[] allowed); + + } + + public class QualityProfileService : IProfileService, IHandle<ApplicationStartedEvent> + { + private readonly IProfileRepository _profileRepository; + private readonly IArtistService _artistService; + private readonly IImportListFactory _importListFactory; + private readonly Logger _logger; + + public QualityProfileService(IProfileRepository profileRepository, IArtistService artistService, IImportListFactory importListFactory, Logger logger) + { + _profileRepository = profileRepository; + _artistService = artistService; + _importListFactory = importListFactory; + _logger = logger; + } + + public QualityProfile Add(QualityProfile profile) + { + return _profileRepository.Insert(profile); + } + + public void Update(QualityProfile profile) + { + _profileRepository.Update(profile); + } + + public void Delete(int id) + { + if (_artistService.GetAllArtists().Any(c => c.QualityProfileId == id) || _importListFactory.All().Any(c => c.ProfileId == id)) + { + var profile = _profileRepository.Get(id); + throw new QualityProfileInUseException(profile.Name); + } + + _profileRepository.Delete(id); + } + + public List<QualityProfile> All() + { + return _profileRepository.All().ToList(); + } + + public QualityProfile Get(int id) + { + return _profileRepository.Get(id); + } + + public bool Exists(int id) + { + return _profileRepository.Exists(id); + } + + public void Handle(ApplicationStartedEvent message) + { + if (All().Any()) return; + + _logger.Info("Setting up default quality profiles"); + + AddDefaultProfile("Any", Quality.Unknown, + Quality.Unknown, + Quality.MP3_008, + Quality.MP3_016, + Quality.MP3_024, + Quality.MP3_032, + Quality.MP3_040, + Quality.MP3_048, + Quality.MP3_056, + Quality.MP3_064, + Quality.MP3_080, + Quality.MP3_096, + Quality.MP3_112, + Quality.MP3_128, + Quality.MP3_160, + Quality.MP3_192, + Quality.MP3_224, + Quality.MP3_256, + Quality.MP3_320, + Quality.MP3_VBR, + Quality.MP3_VBR_V2, + Quality.AAC_192, + Quality.AAC_256, + Quality.AAC_320, + Quality.AAC_VBR, + Quality.VORBIS_Q5, + Quality.VORBIS_Q6, + Quality.VORBIS_Q7, + Quality.VORBIS_Q8, + Quality.VORBIS_Q9, + Quality.VORBIS_Q10, + Quality.WMA, + Quality.ALAC, + Quality.FLAC, + Quality.FLAC_24); + + AddDefaultProfile("Lossless", Quality.FLAC, + Quality.FLAC, + Quality.ALAC, + Quality.FLAC_24); + + AddDefaultProfile("Standard", Quality.MP3_192, + Quality.MP3_192, + Quality.MP3_256, + Quality.MP3_320); + } + + public QualityProfile GetDefaultProfile(string name, Quality cutoff = null, params Quality[] allowed) + { + var groupedQualites = Quality.DefaultQualityDefinitions.GroupBy(q => q.GroupWeight); + var items = new List<QualityProfileQualityItem>(); + var groupId = 1000; + var profileCutoff = cutoff == null ? Quality.Unknown.Id : cutoff.Id; + + foreach (var group in groupedQualites) + { + if (group.Count() == 1) + { + var quality = group.First().Quality; + items.Add(new QualityProfileQualityItem { Quality = quality, Allowed = allowed.Contains(quality) }); + continue; + } + + var groupAllowed = group.Any(g => allowed.Contains(g.Quality)); + + items.Add(new QualityProfileQualityItem + { + Id = groupId, + Name = group.First().GroupName, + Items = group.Select(g => new QualityProfileQualityItem + { + Quality = g.Quality, + Allowed = groupAllowed + }).ToList(), + Allowed = groupAllowed + }); + + if (group.Any(s => s.Quality.Id == profileCutoff)) + { + profileCutoff = groupId; + } + + groupId++; + } + + var qualityProfile = new QualityProfile + { + Name = name, + Cutoff = profileCutoff, + Items = items + }; + + return qualityProfile; + } + + private QualityProfile AddDefaultProfile(string name, Quality cutoff, params Quality[] allowed) + { + var profile = GetDefaultProfile(name, cutoff, allowed); + + return Add(profile); + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Releases/PerlRegexFactory.cs b/src/NzbDrone.Core/Profiles/Releases/PerlRegexFactory.cs new file mode 100644 index 000000000..b75e80db9 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Releases/PerlRegexFactory.cs @@ -0,0 +1,68 @@ +using System; +using System.Text.RegularExpressions; + +namespace NzbDrone.Core.Profiles.Releases +{ + 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/Profiles/Releases/PreferredWordService.cs b/src/NzbDrone.Core/Profiles/Releases/PreferredWordService.cs new file mode 100644 index 000000000..d94d2d000 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Releases/PreferredWordService.cs @@ -0,0 +1,86 @@ +using NLog; +using NzbDrone.Core.Music; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.Profiles.Releases +{ + public interface IPreferredWordService + { + int Calculate(Artist artist, string title); + List<string> GetMatchingPreferredWords(Artist artist, string title); + } + + public class PreferredWordService : IPreferredWordService + { + private readonly IReleaseProfileService _releaseProfileService; + private readonly ITermMatcherService _termMatcherService; + private readonly Logger _logger; + + public PreferredWordService(IReleaseProfileService releaseProfileService, ITermMatcherService termMatcherService, Logger logger) + { + _releaseProfileService = releaseProfileService; + _termMatcherService = termMatcherService; + _logger = logger; + } + + public int Calculate(Artist series, string title) + { + _logger.Trace("Calculating preferred word score for '{0}'", title); + + var releaseProfiles = _releaseProfileService.AllForTags(series.Tags); + var matchingPairs = new List<KeyValuePair<string, int>>(); + + foreach (var releaseProfile in releaseProfiles) + { + foreach (var preferredPair in releaseProfile.Preferred) + { + var term = preferredPair.Key; + + if (_termMatcherService.IsMatch(term, title)) + { + matchingPairs.Add(preferredPair); + } + } + } + + var score = matchingPairs.Sum(p => p.Value); + + _logger.Trace("Calculated preferred word score for '{0}': {1}", title, score); + + return score; + } + + public List<string> GetMatchingPreferredWords(Artist artist, string title) + { + var releaseProfiles = _releaseProfileService.AllForTags(artist.Tags); + var matchingPairs = new List<KeyValuePair<string, int>>(); + + _logger.Trace("Calculating preferred word score for '{0}'", title); + + foreach (var releaseProfile in releaseProfiles) + { + if (!releaseProfile.IncludePreferredWhenRenaming) + { + continue; + } + + foreach (var preferredPair in releaseProfile.Preferred) + { + var term = preferredPair.Key; + var matchingTerm = _termMatcherService.MatchingTerm(term, title); + + if (matchingTerm.IsNotNullOrWhiteSpace()) + { + matchingPairs.Add(new KeyValuePair<string, int>(matchingTerm, preferredPair.Value)); + } + } + } + + return matchingPairs.OrderByDescending(p => p.Value) + .Select(p => p.Key) + .ToList(); + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Releases/ReleaseProfile.cs b/src/NzbDrone.Core/Profiles/Releases/ReleaseProfile.cs new file mode 100644 index 000000000..e642ce094 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Releases/ReleaseProfile.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Profiles.Releases +{ + public class ReleaseProfile : ModelBase + { + public string Required { get; set; } + public string Ignored { get; set; } + public List<KeyValuePair<string, int>> Preferred { get; set; } + public bool IncludePreferredWhenRenaming { get; set; } + public HashSet<int> Tags { get; set; } + + public ReleaseProfile() + { + Preferred = new List<KeyValuePair<string, int>>(); + IncludePreferredWhenRenaming = true; + Tags = new HashSet<int>(); + } + } + + public class ReleaseProfilePreferredComparer : IComparer<KeyValuePair<string, int>> + { + public int Compare(KeyValuePair<string, int> x, KeyValuePair<string, int> y) + { + return y.Value.CompareTo(x.Value); + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Releases/ReleaseProfileRepository.cs b/src/NzbDrone.Core/Profiles/Releases/ReleaseProfileRepository.cs new file mode 100644 index 000000000..b8bcf3b49 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Releases/ReleaseProfileRepository.cs @@ -0,0 +1,17 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.Profiles.Releases +{ + public interface IRestrictionRepository : IBasicRepository<ReleaseProfile> + { + } + + public class ReleaseProfileRepository : BasicRepository<ReleaseProfile>, IRestrictionRepository + { + public ReleaseProfileRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Releases/ReleaseProfileService.cs b/src/NzbDrone.Core/Profiles/Releases/ReleaseProfileService.cs new file mode 100644 index 000000000..4d79de81f --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Releases/ReleaseProfileService.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.Profiles.Releases +{ + public interface IReleaseProfileService + { + List<ReleaseProfile> All(); + List<ReleaseProfile> AllForTag(int tagId); + List<ReleaseProfile> AllForTags(HashSet<int> tagIds); + ReleaseProfile Get(int id); + void Delete(int id); + ReleaseProfile Add(ReleaseProfile restriction); + ReleaseProfile Update(ReleaseProfile restriction); + } + + public class ReleaseProfileService : IReleaseProfileService + { + private readonly ReleaseProfilePreferredComparer _preferredComparer; + private readonly IRestrictionRepository _repo; + private readonly Logger _logger; + + public ReleaseProfileService(IRestrictionRepository repo, Logger logger) + { + _preferredComparer = new ReleaseProfilePreferredComparer(); + + _repo = repo; + _logger = logger; + } + + public List<ReleaseProfile> All() + { + var all = _repo.All().ToList(); + all.ForEach(r => r.Preferred.Sort(_preferredComparer)); + + return all; + } + + public List<ReleaseProfile> AllForTag(int tagId) + { + return _repo.All().Where(r => r.Tags.Contains(tagId)).ToList(); + } + + public List<ReleaseProfile> AllForTags(HashSet<int> tagIds) + { + return _repo.All().Where(r => r.Tags.Intersect(tagIds).Any() || r.Tags.Empty()).ToList(); + } + + public ReleaseProfile Get(int id) + { + return _repo.Get(id); + } + + public void Delete(int id) + { + _repo.Delete(id); + } + + public ReleaseProfile Add(ReleaseProfile restriction) + { + restriction.Preferred.Sort(_preferredComparer); + + return _repo.Insert(restriction); + } + + public ReleaseProfile Update(ReleaseProfile restriction) + { + restriction.Preferred.Sort(_preferredComparer); + + return _repo.Update(restriction); + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Releases/TermMatcherService.cs b/src/NzbDrone.Core/Profiles/Releases/TermMatcherService.cs new file mode 100644 index 000000000..6c1b52d41 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Releases/TermMatcherService.cs @@ -0,0 +1,52 @@ +using System; +using System.Text.RegularExpressions; +using NzbDrone.Common.Cache; +using NzbDrone.Core.Profiles.Releases.TermMatchers; + +namespace NzbDrone.Core.Profiles.Releases +{ + public interface ITermMatcherService + { + bool IsMatch(string term, string value); + string MatchingTerm(string term, string value); + } + + public class TermMatcherService : ITermMatcherService + { + private ICached<ITermMatcher> _matcherCache; + + public TermMatcherService(ICacheManager cacheManager) + { + _matcherCache = cacheManager.GetCache<ITermMatcher>(GetType()); + } + + public bool IsMatch(string term, string value) + { + return GetMatcher(term).IsMatch(value); + } + + public string MatchingTerm(string term, string value) + { + return GetMatcher(term).MatchingTerm(value); + } + + public ITermMatcher GetMatcher(string term) + { + return _matcherCache.Get(term, () => CreateMatcherInternal(term), TimeSpan.FromHours(24)); + } + + private ITermMatcher CreateMatcherInternal(string term) + { + Regex regex; + if (PerlRegexFactory.TryCreateRegex(term, out regex)) + { + return new RegexTermMatcher(regex); + } + else + { + return new CaseInsensitiveTermMatcher(term); + + } + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Releases/TermMatchers/CaseInsensitiveTermMatcher.cs b/src/NzbDrone.Core/Profiles/Releases/TermMatchers/CaseInsensitiveTermMatcher.cs new file mode 100644 index 000000000..d8fa49120 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Releases/TermMatchers/CaseInsensitiveTermMatcher.cs @@ -0,0 +1,27 @@ +namespace NzbDrone.Core.Profiles.Releases.TermMatchers +{ + public sealed class CaseInsensitiveTermMatcher : ITermMatcher + { + private readonly string _term; + + public CaseInsensitiveTermMatcher(string term) + { + _term = term.ToLowerInvariant(); + } + + public bool IsMatch(string value) + { + return value.ToLowerInvariant().Contains(_term); + } + + public string MatchingTerm(string value) + { + if (value.ToLowerInvariant().Contains(_term)) + { + return _term; + } + + return null; + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Releases/TermMatchers/ITermMatcher.cs b/src/NzbDrone.Core/Profiles/Releases/TermMatchers/ITermMatcher.cs new file mode 100644 index 000000000..205321397 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Releases/TermMatchers/ITermMatcher.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Profiles.Releases.TermMatchers +{ + public interface ITermMatcher + { + bool IsMatch(string value); + string MatchingTerm(string value); + } +} diff --git a/src/NzbDrone.Core/Profiles/Releases/TermMatchers/RegexTermMatcher.cs b/src/NzbDrone.Core/Profiles/Releases/TermMatchers/RegexTermMatcher.cs new file mode 100644 index 000000000..e4a6e97c4 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Releases/TermMatchers/RegexTermMatcher.cs @@ -0,0 +1,24 @@ +using System.Text.RegularExpressions; + +namespace NzbDrone.Core.Profiles.Releases.TermMatchers +{ + public class RegexTermMatcher : ITermMatcher + { + private readonly Regex _regex; + + public RegexTermMatcher(Regex regex) + { + _regex = regex; + } + + public bool IsMatch(string value) + { + return _regex.IsMatch(value); + } + + public string MatchingTerm(string value) + { + return _regex.Match(value).Value; + } + } +} diff --git a/src/NzbDrone.Core/Properties/AssemblyInfo.cs b/src/NzbDrone.Core/Properties/AssemblyInfo.cs index 4593d015a..9b6339c95 100644 --- a/src/NzbDrone.Core/Properties/AssemblyInfo.cs +++ b/src/NzbDrone.Core/Properties/AssemblyInfo.cs @@ -1,16 +1,3 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; +using System.Runtime.CompilerServices; -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. - -[assembly: AssemblyTitle("NzbDrone.Core")] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("3C29FEF7-4B07-49ED-822E-1C29DC49BFAB")] - -[assembly: AssemblyVersion("10.0.0.*")] - -[assembly: InternalsVisibleTo("NzbDrone.Core.Test")] +[assembly: InternalsVisibleTo("Lidarr.Core.Test")] diff --git a/src/NzbDrone.Core/Qualities/ProperDownloadTypes.cs b/src/NzbDrone.Core/Qualities/ProperDownloadTypes.cs new file mode 100644 index 000000000..d61101e5a --- /dev/null +++ b/src/NzbDrone.Core/Qualities/ProperDownloadTypes.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Qualities +{ + public enum ProperDownloadTypes + { + PreferAndUpgrade, + DoNotUpgrade, + DoNotPrefer + } +} diff --git a/src/NzbDrone.Core/Qualities/Quality.cs b/src/NzbDrone.Core/Qualities/Quality.cs index d41a05d35..5aae603f6 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; @@ -56,44 +56,84 @@ namespace NzbDrone.Core.Qualities } 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 MP3_192 => new Quality(1, "MP3-192"); + public static Quality MP3_VBR => new Quality(2, "MP3-VBR-V0"); + public static Quality MP3_256 => new Quality(3, "MP3-256"); + public static Quality MP3_320 => new Quality(4, "MP3-320"); + public static Quality MP3_160 => new Quality(5, "MP3-160"); + public static Quality FLAC => new Quality(6, "FLAC"); + public static Quality ALAC => new Quality(7, "ALAC"); + public static Quality MP3_VBR_V2 => new Quality(8, "MP3-VBR-V2"); + public static Quality AAC_192 => new Quality(9, "AAC-192"); + public static Quality AAC_256 => new Quality(10, "AAC-256"); + public static Quality AAC_320 => new Quality(11, "AAC-320"); + public static Quality AAC_VBR => new Quality(12, "AAC-VBR"); + public static Quality WAV => new Quality(13, "WAV"); + public static Quality VORBIS_Q10 => new Quality(14, "OGG Vorbis Q10"); + public static Quality VORBIS_Q9 => new Quality(15, "OGG Vorbis Q9"); + public static Quality VORBIS_Q8 => new Quality(16, "OGG Vorbis Q8"); + public static Quality VORBIS_Q7 => new Quality(17, "OGG Vorbis Q7"); + public static Quality VORBIS_Q6 => new Quality(18, "OGG Vorbis Q6"); + public static Quality VORBIS_Q5 => new Quality(19, "OGG Vorbis Q5"); + public static Quality WMA => new Quality(20, "WMA"); + public static Quality FLAC_24 => new Quality(21, "FLAC 24bit"); + public static Quality MP3_128 => new Quality(22, "MP3-128"); + public static Quality MP3_096 => new Quality(23, "MP3-96"); // For Current Files Only + public static Quality MP3_080 => new Quality(24, "MP3-80"); // For Current Files Only + public static Quality MP3_064 => new Quality(25, "MP3-64"); // For Current Files Only + public static Quality MP3_056 => new Quality(26, "MP3-56"); // For Current Files Only + public static Quality MP3_048 => new Quality(27, "MP3-48"); // For Current Files Only + public static Quality MP3_040 => new Quality(28, "MP3-40"); // For Current Files Only + public static Quality MP3_032 => new Quality(29, "MP3-32"); // For Current Files Only + public static Quality MP3_024 => new Quality(30, "MP3-24"); // For Current Files Only + public static Quality MP3_016 => new Quality(31, "MP3-16"); // For Current Files Only + public static Quality MP3_008 => new Quality(32, "MP3-8"); // For Current Files Only + public static Quality MP3_112 => new Quality(33, "MP3-112"); // For Current Files Only + public static Quality MP3_224 => new Quality(34, "MP3-224"); // For Current Files Only + public static Quality APE => new Quality(35, "APE"); + public static Quality WAVPACK => new Quality(36, "WavPack"); static Quality() { All = new List<Quality> { Unknown, - SDTV, - DVD, - WEBDL1080p, - HDTV720p, - WEBDL720p, - Bluray720p, - Bluray1080p, - WEBDL480p, - HDTV1080p, - RAWHD, - HDTV2160p, - WEBDL2160p, - Bluray2160p, + MP3_008, + MP3_016, + MP3_024, + MP3_032, + MP3_040, + MP3_048, + MP3_056, + MP3_064, + MP3_080, + MP3_096, + MP3_112, + MP3_128, + MP3_160, + MP3_192, + MP3_224, + MP3_VBR, + MP3_256, + MP3_320, + MP3_VBR_V2, + AAC_192, + AAC_256, + AAC_320, + AAC_VBR, + WMA, + VORBIS_Q10, + VORBIS_Q9, + VORBIS_Q8, + VORBIS_Q7, + VORBIS_Q6, + VORBIS_Q5, + ALAC, + FLAC, + APE, + WAVPACK, + FLAC_24, + WAV }; AllLookup = new Quality[All.Select(v => v.Id).Max() + 1]; @@ -104,20 +144,43 @@ namespace NzbDrone.Core.Qualities DefaultQualityDefinitions = new HashSet<QualityDefinition> { - new QualityDefinition(Quality.Unknown) { Weight = 1, MinSize = 0, MaxSize = 100 }, - new QualityDefinition(Quality.SDTV) { Weight = 2, MinSize = 0, MaxSize = 100 }, - new QualityDefinition(Quality.WEBDL480p) { Weight = 3, MinSize = 0, MaxSize = 100 }, - new QualityDefinition(Quality.DVD) { Weight = 4, MinSize = 0, MaxSize = 100 }, - new QualityDefinition(Quality.HDTV720p) { Weight = 5, MinSize = 0, MaxSize = 100 }, - new QualityDefinition(Quality.HDTV1080p) { Weight = 6, MinSize = 0, MaxSize = 100 }, - new QualityDefinition(Quality.RAWHD) { Weight = 7, MinSize = 0, MaxSize = null }, - new QualityDefinition(Quality.WEBDL720p) { Weight = 8, MinSize = 0, MaxSize = 100 }, - new QualityDefinition(Quality.Bluray720p) { Weight = 9, MinSize = 0, MaxSize = 100 }, - new QualityDefinition(Quality.WEBDL1080p) { Weight = 10, MinSize = 0, MaxSize = 100 }, - new QualityDefinition(Quality.Bluray1080p) { Weight = 11, MinSize = 0, MaxSize = 100 }, - new QualityDefinition(Quality.HDTV2160p) { Weight = 12, MinSize = 0, MaxSize = null }, - new QualityDefinition(Quality.WEBDL2160p) { Weight = 13, MinSize = 0, MaxSize = null }, - new QualityDefinition(Quality.Bluray2160p) { Weight = 14, MinSize = 0, MaxSize = null }, + new QualityDefinition(Quality.Unknown) { Weight = 1, MinSize = 0, MaxSize = 350, GroupWeight = 1}, + new QualityDefinition(Quality.MP3_008) { Weight = 2, MinSize = 0, MaxSize = 10, GroupName = "Trash Quality Lossy", GroupWeight = 2 }, + new QualityDefinition(Quality.MP3_016) { Weight = 3, MinSize = 0, MaxSize = 20, GroupName = "Trash Quality Lossy", GroupWeight = 2 }, + new QualityDefinition(Quality.MP3_024) { Weight = 4, MinSize = 0, MaxSize = 30, GroupName = "Trash Quality Lossy", GroupWeight = 2 }, + new QualityDefinition(Quality.MP3_032) { Weight = 5, MinSize = 0, MaxSize = 40, GroupName = "Trash Quality Lossy", GroupWeight = 2 }, + new QualityDefinition(Quality.MP3_040) { Weight = 6, MinSize = 0, MaxSize = 45, GroupName = "Trash Quality Lossy", GroupWeight = 2 }, + new QualityDefinition(Quality.MP3_048) { Weight = 7, MinSize = 0, MaxSize = 55, GroupName = "Trash Quality Lossy", GroupWeight = 2 }, + new QualityDefinition(Quality.MP3_056) { Weight = 8, MinSize = 0, MaxSize = 65, GroupName = "Trash Quality Lossy", GroupWeight = 2 }, + new QualityDefinition(Quality.MP3_064) { Weight = 9, MinSize = 0, MaxSize = 75, GroupName = "Trash Quality Lossy", GroupWeight = 2 }, + new QualityDefinition(Quality.MP3_080) { Weight = 10, MinSize = 0, MaxSize = 95, GroupName = "Trash Quality Lossy", GroupWeight = 2 }, + new QualityDefinition(Quality.MP3_096) { Weight = 11, MinSize = 0, MaxSize = 110, GroupName = "Poor Quality Lossy", GroupWeight = 3 }, + new QualityDefinition(Quality.MP3_112) { Weight = 12, MinSize = 0, MaxSize = 125, GroupName = "Poor Quality Lossy", GroupWeight = 3 }, + new QualityDefinition(Quality.MP3_128) { Weight = 13, MinSize = 0, MaxSize = 140, GroupName = "Poor Quality Lossy", GroupWeight = 3 }, + new QualityDefinition(Quality.VORBIS_Q5) { Weight = 14, MinSize = 0, MaxSize = 175, GroupName = "Poor Quality Lossy", GroupWeight = 3 }, + new QualityDefinition(Quality.MP3_160) { Weight = 14, MinSize = 0, MaxSize = 175, GroupName = "Poor Quality Lossy", GroupWeight = 3 }, + new QualityDefinition(Quality.MP3_192) { Weight = 15, MinSize = 0, MaxSize = 210, GroupName = "Low Quality Lossy", GroupWeight = 4 }, + new QualityDefinition(Quality.VORBIS_Q6) { Weight = 15, MinSize = 0, MaxSize = 210, GroupName = "Low Quality Lossy", GroupWeight = 4 }, + new QualityDefinition(Quality.AAC_192) { Weight = 15, MinSize = 0, MaxSize = 210, GroupName = "Low Quality Lossy", GroupWeight = 4 }, + new QualityDefinition(Quality.WMA) { Weight = 15, MinSize = 0, MaxSize = 350, GroupName = "Low Quality Lossy", GroupWeight = 4 }, + new QualityDefinition(Quality.MP3_224) { Weight = 16, MinSize = 0, MaxSize = 245, GroupName = "Low Quality Lossy", GroupWeight = 4 }, + new QualityDefinition(Quality.VORBIS_Q7) { Weight = 17, MinSize = 0, MaxSize = 245, GroupName = "Mid Quality Lossy", GroupWeight = 5 }, + new QualityDefinition(Quality.MP3_VBR_V2) { Weight = 18, MinSize = 0, MaxSize = 280, GroupName = "Mid Quality Lossy", GroupWeight = 5 }, + new QualityDefinition(Quality.MP3_256) { Weight = 18, MinSize = 0, MaxSize = 280, GroupName = "Mid Quality Lossy", GroupWeight = 5 }, + new QualityDefinition(Quality.VORBIS_Q8) { Weight = 18, MinSize = 0, MaxSize = 280, GroupName = "Mid Quality Lossy", GroupWeight = 5 }, + new QualityDefinition(Quality.AAC_256) { Weight = 18, MinSize = 0, MaxSize = 280, GroupName = "Mid Quality Lossy", GroupWeight = 5 }, + new QualityDefinition(Quality.MP3_VBR) { Weight = 19, MinSize = 0, MaxSize = 350, GroupName = "High Quality Lossy", GroupWeight = 6 }, + new QualityDefinition(Quality.AAC_VBR) { Weight = 19, MinSize = 0, MaxSize = 350, GroupName = "High Quality Lossy", GroupWeight = 6 }, + new QualityDefinition(Quality.MP3_320) { Weight = 20, MinSize = 0, MaxSize = 350, GroupName = "High Quality Lossy", GroupWeight = 6 }, + new QualityDefinition(Quality.VORBIS_Q9) { Weight = 20, MinSize = 0, MaxSize = 350, GroupName = "High Quality Lossy", GroupWeight = 6 }, + new QualityDefinition(Quality.AAC_320) { Weight = 20, MinSize = 0, MaxSize = 350, GroupName = "High Quality Lossy", GroupWeight = 6 }, + new QualityDefinition(Quality.VORBIS_Q10) { Weight = 21, MinSize = 0, MaxSize = 550, GroupName = "High Quality Lossy", GroupWeight = 6 }, + new QualityDefinition(Quality.ALAC) { Weight = 22, MinSize = 0, MaxSize = null, GroupName = "Lossless", GroupWeight = 7 }, + new QualityDefinition(Quality.FLAC) { Weight = 22, MinSize = 0, MaxSize = null, GroupName = "Lossless", GroupWeight = 7 }, + new QualityDefinition(Quality.APE) { Weight = 22, MinSize = 0, MaxSize = null, GroupName = "Lossless", GroupWeight = 7 }, + new QualityDefinition(Quality.WAVPACK) { Weight = 22, MinSize = 0, MaxSize = null, GroupName = "Lossless", GroupWeight = 7 }, + new QualityDefinition(Quality.FLAC_24) { Weight = 23, MinSize = 0, MaxSize = null, GroupName = "Lossless", GroupWeight = 7 }, + new QualityDefinition(Quality.WAV) { Weight = 24, MinSize = 0, MaxSize = null, GroupWeight = 8} }; } @@ -130,12 +193,18 @@ namespace NzbDrone.Core.Qualities public static Quality FindById(int id) { if (id == 0) return Unknown; + else if (id > AllLookup.Length) + { + throw new ArgumentException("ID does not match a known quality", nameof(id)); + } var quality = AllLookup[id]; if (quality == null) + { throw new ArgumentException("ID does not match a known quality", nameof(id)); - + } + return quality; } @@ -149,4 +218,4 @@ namespace NzbDrone.Core.Qualities return quality.Id; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Qualities/QualityDefinition.cs b/src/NzbDrone.Core/Qualities/QualityDefinition.cs index 372002333..e8669ddd3 100644 --- a/src/NzbDrone.Core/Qualities/QualityDefinition.cs +++ b/src/NzbDrone.Core/Qualities/QualityDefinition.cs @@ -1,4 +1,4 @@ -using NzbDrone.Core.Datastore; +using NzbDrone.Core.Datastore; namespace NzbDrone.Core.Qualities @@ -9,6 +9,8 @@ namespace NzbDrone.Core.Qualities public string Title { get; set; } + public string GroupName { get; set; } + public int GroupWeight { get; set; } public int Weight { get; set; } public double? MinSize { get; set; } @@ -30,4 +32,4 @@ namespace NzbDrone.Core.Qualities return Quality.Name; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Qualities/QualityDefinitionService.cs b/src/NzbDrone.Core/Qualities/QualityDefinitionService.cs index d2fc46e3c..edff516eb 100644 --- a/src/NzbDrone.Core/Qualities/QualityDefinitionService.cs +++ b/src/NzbDrone.Core/Qualities/QualityDefinitionService.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using NLog; using NzbDrone.Core.Lifecycle; @@ -11,6 +11,7 @@ namespace NzbDrone.Core.Qualities public interface IQualityDefinitionService { void Update(QualityDefinition qualityDefinition); + void UpdateMany(List<QualityDefinition> qualityDefinitions); List<QualityDefinition> All(); QualityDefinition GetById(int id); QualityDefinition Get(Quality quality); @@ -41,6 +42,11 @@ namespace NzbDrone.Core.Qualities _cache.Clear(); } + public void UpdateMany(List<QualityDefinition> qualityDefinitions) + { + _repo.UpdateMany(qualityDefinitions); + } + public List<QualityDefinition> All() { return GetAll().Values.OrderBy(d => d.Weight).ToList(); @@ -50,17 +56,17 @@ namespace NzbDrone.Core.Qualities { return GetAll().Values.Single(v => v.Id == id); } - + public QualityDefinition Get(Quality quality) { return GetAll()[quality]; } - + private void InsertMissingDefinitions() { List<QualityDefinition> insertList = new List<QualityDefinition>(); List<QualityDefinition> updateList = new List<QualityDefinition>(); - + var allDefinitions = Quality.DefaultQualityDefinitions.OrderBy(d => d.Weight).ToList(); var existingDefinitions = _repo.All().ToList(); @@ -83,7 +89,7 @@ namespace NzbDrone.Core.Qualities _repo.InsertMany(insertList); _repo.UpdateMany(updateList); _repo.DeleteMany(existingDefinitions); - + _cache.Clear(); } diff --git a/src/NzbDrone.Core/Qualities/QualityDetectionSource.cs b/src/NzbDrone.Core/Qualities/QualityDetectionSource.cs new file mode 100644 index 000000000..9260c14bc --- /dev/null +++ b/src/NzbDrone.Core/Qualities/QualityDetectionSource.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Qualities +{ + public enum QualityDetectionSource + { + Name, + Extension, + TagLib + } +} diff --git a/src/NzbDrone.Core/Qualities/QualityModel.cs b/src/NzbDrone.Core/Qualities/QualityModel.cs index a483d22c2..7b813e09c 100644 --- a/src/NzbDrone.Core/Qualities/QualityModel.cs +++ b/src/NzbDrone.Core/Qualities/QualityModel.cs @@ -1,17 +1,18 @@ -using System; +using System; +using System.Linq; using Newtonsoft.Json; using NzbDrone.Core.Datastore; namespace NzbDrone.Core.Qualities { - public class QualityModel : IEmbeddedDocument, IEquatable<QualityModel> + public class QualityModel : IEmbeddedDocument, IEquatable<QualityModel>, IComparable { public Quality Quality { get; set; } public Revision Revision { get; set; } [JsonIgnore] - public QualitySource QualitySource { get; set; } - + public QualityDetectionSource QualityDetectionSource { get; set; } + public QualityModel() : this(Quality.Unknown, new Revision()) { @@ -40,6 +41,45 @@ namespace NzbDrone.Core.Qualities } } + public int CompareTo(object obj) + { + var other = (QualityModel)obj; + var definition = Quality.DefaultQualityDefinitions.First(q => q.Quality == Quality); + var otherDefinition = Quality.DefaultQualityDefinitions.First(q => q.Quality == other.Quality); + + if (definition.Weight > otherDefinition.Weight) + { + return 1; + } + + if (definition.Weight < otherDefinition.Weight) + { + return -1; + } + + if (Revision.Real > other.Revision.Real) + { + return 1; + } + + if (Revision.Real < other.Revision.Real) + { + return -1; + } + + if (Revision.Version > other.Revision.Version) + { + return 1; + } + + if (Revision.Version < other.Revision.Version) + { + return -1; + } + + return 0; + } + public bool Equals(QualityModel other) { if (ReferenceEquals(null, other)) return false; diff --git a/src/NzbDrone.Core/Qualities/QualityModelComparer.cs b/src/NzbDrone.Core/Qualities/QualityModelComparer.cs index 64f1939b8..46e408e93 100644 --- a/src/NzbDrone.Core/Qualities/QualityModelComparer.cs +++ b/src/NzbDrone.Core/Qualities/QualityModelComparer.cs @@ -1,14 +1,14 @@ -using System.Collections.Generic; +using System.Collections.Generic; using NzbDrone.Common.EnsureThat; -using NzbDrone.Core.Profiles; +using NzbDrone.Core.Profiles.Qualities; namespace NzbDrone.Core.Qualities { public class QualityModelComparer : IComparer<Quality>, IComparer<QualityModel> { - private readonly Profile _profile; + private readonly QualityProfile _profile; - public QualityModelComparer(Profile profile) + public QualityModelComparer(QualityProfile profile) { Ensure.That(profile, () => profile).IsNotNull(); Ensure.That(profile.Items, () => profile.Items).HasItems(); @@ -16,17 +16,35 @@ namespace NzbDrone.Core.Qualities _profile = profile; } + public int Compare(int left, int right, bool respectGroupOrder = false) + { + var leftIndex = _profile.GetIndex(left); + var rightIndex = _profile.GetIndex(right); + + return leftIndex.CompareTo(rightIndex, respectGroupOrder); + } + public int Compare(Quality left, Quality right) { - int leftIndex = _profile.Items.FindIndex(v => v.Quality == left); - int rightIndex = _profile.Items.FindIndex(v => v.Quality == right); + return Compare(left, right, false); + } + + public int Compare(Quality left, Quality right, bool respectGroupOrder) + { + var leftIndex = _profile.GetIndex(left, respectGroupOrder); + var rightIndex = _profile.GetIndex(right, respectGroupOrder); - return leftIndex.CompareTo(rightIndex); + return leftIndex.CompareTo(rightIndex, respectGroupOrder); } public int Compare(QualityModel left, QualityModel right) { - int result = Compare(left.Quality, right.Quality); + return Compare(left, right, false); + } + + public int Compare(QualityModel left, QualityModel right, bool respectGroupOrder) + { + int result = Compare(left.Quality, right.Quality, respectGroupOrder); if (result == 0) { diff --git a/src/NzbDrone.Core/Qualities/QualitySource.cs b/src/NzbDrone.Core/Qualities/QualitySource.cs deleted file mode 100644 index 5c0c2c81f..000000000 --- a/src/NzbDrone.Core/Qualities/QualitySource.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace NzbDrone.Core.Qualities -{ - public enum QualitySource - { - Name, - Extension, - MediaInfo - } -} diff --git a/src/NzbDrone.Core/Qualities/Revision.cs b/src/NzbDrone.Core/Qualities/Revision.cs index 7ec095cda..c0477eb45 100644 --- a/src/NzbDrone.Core/Qualities/Revision.cs +++ b/src/NzbDrone.Core/Qualities/Revision.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Text; namespace NzbDrone.Core.Qualities @@ -9,14 +9,16 @@ namespace NzbDrone.Core.Qualities { } - public Revision(int version = 1, int real = 0) + public Revision(int version = 1, int real = 0, bool isRepack = false) { Version = version; Real = real; + IsRepack = isRepack; } public int Version { get; set; } public int Real { get; set; } + public bool IsRepack { get; set; } public bool Equals(Revision other) { diff --git a/src/NzbDrone.Core/Queue/EstimatedCompletionTimeComparer.cs b/src/NzbDrone.Core/Queue/EstimatedCompletionTimeComparer.cs new file mode 100644 index 000000000..e8c52e1ab --- /dev/null +++ b/src/NzbDrone.Core/Queue/EstimatedCompletionTimeComparer.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; + +namespace NzbDrone.Core.Queue +{ + public class EstimatedCompletionTimeComparer : IComparer<DateTime?> + { + public int Compare(DateTime? x, DateTime? y) + { + if (!x.HasValue && !y.HasValue) + { + return 0; + } + + if (!x.HasValue && y.HasValue) + { + return 1; + } + + if (x.HasValue && !y.HasValue) + { + return -1; + } + + if (x.Value > y.Value) + { + return 1; + } + + if (x.Value < y.Value) + { + return -1; + } + + return 0; + } + } +} diff --git a/src/NzbDrone.Core/Queue/Queue.cs b/src/NzbDrone.Core/Queue/Queue.cs index 7164a17ae..7c5ca327a 100644 --- a/src/NzbDrone.Core/Queue/Queue.cs +++ b/src/NzbDrone.Core/Queue/Queue.cs @@ -1,18 +1,18 @@ -using System; +using System; using System.Collections.Generic; using NzbDrone.Core.Datastore; using NzbDrone.Core.Download.TrackedDownloads; using NzbDrone.Core.Indexers; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Qualities; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Queue { public class Queue : ModelBase { - public Series Series { get; set; } - public Episode Episode { get; set; } + public Artist Artist { get; set; } + public Album Album { get; set; } public QualityModel Quality { get; set; } public decimal Size { get; set; } public string Title { get; set; } @@ -23,7 +23,12 @@ namespace NzbDrone.Core.Queue public string TrackedDownloadStatus { get; set; } public List<TrackedDownloadStatusMessage> StatusMessages { get; set; } public string DownloadId { get; set; } - public RemoteEpisode RemoteEpisode { get; set; } + public RemoteAlbum RemoteAlbum { get; set; } public DownloadProtocol Protocol { get; set; } + public string DownloadClient { get; set; } + public string Indexer { get; set; } + public string OutputPath { get; set; } + public string ErrorMessage { get; set; } + public bool DownloadForced { get; set; } } } diff --git a/src/NzbDrone.Core/Queue/QueueService.cs b/src/NzbDrone.Core/Queue/QueueService.cs index 264645ed8..e14ed2610 100644 --- a/src/NzbDrone.Core/Queue/QueueService.cs +++ b/src/NzbDrone.Core/Queue/QueueService.cs @@ -1,10 +1,12 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using NzbDrone.Common.Crypto; using NzbDrone.Core.Download.TrackedDownloads; +using NzbDrone.Core.History; using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Queue { @@ -12,16 +14,20 @@ namespace NzbDrone.Core.Queue { List<Queue> GetQueue(); Queue Find(int id); + void Remove(int id); } public class QueueService : IQueueService, IHandle<TrackedDownloadRefreshedEvent> { private readonly IEventAggregator _eventAggregator; private static List<Queue> _queue = new List<Queue>(); + private readonly IHistoryService _historyService; - public QueueService(IEventAggregator eventAggregator) + public QueueService(IEventAggregator eventAggregator, + IHistoryService historyService) { _eventAggregator = eventAggregator; + _historyService = historyService; } public List<Queue> GetQueue() @@ -34,49 +40,66 @@ namespace NzbDrone.Core.Queue return _queue.SingleOrDefault(q => q.Id == id); } - public void Handle(TrackedDownloadRefreshedEvent message) + public void Remove(int id) { - _queue = message.TrackedDownloads.OrderBy(c => c.DownloadItem.RemainingTime).SelectMany(MapQueue) - .ToList(); - - _eventAggregator.PublishEvent(new QueueUpdatedEvent()); + _queue.Remove(Find(id)); } private IEnumerable<Queue> MapQueue(TrackedDownload trackedDownload) { - if (trackedDownload.RemoteEpisode.Episodes != null && trackedDownload.RemoteEpisode.Episodes.Any()) + if (trackedDownload.RemoteAlbum?.Albums != null && trackedDownload.RemoteAlbum.Albums.Any()) { - foreach (var episode in trackedDownload.RemoteEpisode.Episodes) + foreach (var album in trackedDownload.RemoteAlbum.Albums) { - yield return MapEpisode(trackedDownload, episode); + yield return MapQueueItem(trackedDownload, album); } } else { - // FIXME: Present queue items with unknown series/episodes + yield return MapQueueItem(trackedDownload, null); } } - private Queue MapEpisode(TrackedDownload trackedDownload, Episode episode) + private Queue MapQueueItem(TrackedDownload trackedDownload, Album album) { + bool downloadForced = false; + var history = _historyService.Find(trackedDownload.DownloadItem.DownloadId, HistoryEventType.Grabbed).FirstOrDefault(); + if (history != null && history.Data.ContainsKey("downloadForced")) + { + downloadForced = bool.Parse(history.Data["downloadForced"]); + } + var queue = new Queue { - Id = HashConverter.GetHashInt31(string.Format("trackedDownload-{0}-ep{1}", trackedDownload.DownloadItem.DownloadId, episode.Id)), - Series = trackedDownload.RemoteEpisode.Series, - Episode = episode, - Quality = trackedDownload.RemoteEpisode.ParsedEpisodeInfo.Quality, - Title = trackedDownload.DownloadItem.Title, + Artist = trackedDownload.RemoteAlbum?.Artist, + Album = album, + Quality = trackedDownload.RemoteAlbum?.ParsedAlbumInfo.Quality ?? new QualityModel(Quality.Unknown), + Title = Parser.Parser.RemoveFileExtension(trackedDownload.DownloadItem.Title), Size = trackedDownload.DownloadItem.TotalSize, Sizeleft = trackedDownload.DownloadItem.RemainingSize, Timeleft = trackedDownload.DownloadItem.RemainingTime, Status = trackedDownload.DownloadItem.Status.ToString(), TrackedDownloadStatus = trackedDownload.Status.ToString(), StatusMessages = trackedDownload.StatusMessages.ToList(), - RemoteEpisode = trackedDownload.RemoteEpisode, + ErrorMessage = trackedDownload.DownloadItem.Message, + RemoteAlbum = trackedDownload.RemoteAlbum, DownloadId = trackedDownload.DownloadItem.DownloadId, - Protocol = trackedDownload.Protocol + Protocol = trackedDownload.Protocol, + DownloadClient = trackedDownload.DownloadItem.DownloadClient, + Indexer = trackedDownload.Indexer, + OutputPath = trackedDownload.DownloadItem.OutputPath.ToString(), + DownloadForced = downloadForced }; + if (album != null) + { + queue.Id = HashConverter.GetHashInt31(string.Format("trackedDownload-{0}-album{1}", trackedDownload.DownloadItem.DownloadId, album.Id)); + } + else + { + queue.Id = HashConverter.GetHashInt31(string.Format("trackedDownload-{0}", trackedDownload.DownloadItem.DownloadId)); + } + if (queue.Timeleft.HasValue) { queue.EstimatedCompletionTime = DateTime.UtcNow.Add(queue.Timeleft.Value); @@ -84,5 +107,13 @@ namespace NzbDrone.Core.Queue return queue; } + + public void Handle(TrackedDownloadRefreshedEvent message) + { + _queue = message.TrackedDownloads.OrderBy(c => c.DownloadItem.RemainingTime).SelectMany(MapQueue) + .ToList(); + + _eventAggregator.PublishEvent(new QueueUpdatedEvent()); + } } } diff --git a/src/NzbDrone.Core/Queue/TimeleftComparer.cs b/src/NzbDrone.Core/Queue/TimeleftComparer.cs new file mode 100644 index 000000000..2c051deeb --- /dev/null +++ b/src/NzbDrone.Core/Queue/TimeleftComparer.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; + +namespace NzbDrone.Core.Queue +{ + public class TimeleftComparer : IComparer<TimeSpan?> + { + public int Compare(TimeSpan? x, TimeSpan? y) + { + if (!x.HasValue && !y.HasValue) + { + return 0; + } + + if (!x.HasValue && y.HasValue) + { + return 1; + } + + if (x.HasValue && !y.HasValue) + { + return -1; + } + + if (x.Value > y.Value) + { + return 1; + } + + if (x.Value < y.Value) + { + return -1; + } + + return 0; + } + } +} diff --git a/src/NzbDrone.Core/RemotePathMappings/RemotePathMappingRepository.cs b/src/NzbDrone.Core/RemotePathMappings/RemotePathMappingRepository.cs index a7df3c35a..4a20f1576 100644 --- a/src/NzbDrone.Core/RemotePathMappings/RemotePathMappingRepository.cs +++ b/src/NzbDrone.Core/RemotePathMappings/RemotePathMappingRepository.cs @@ -16,6 +16,13 @@ namespace NzbDrone.Core.RemotePathMappings { } + public new void Delete(int id) + { + var model = Get(id); + base.Delete(id); + ModelDeleted(model); + } + protected override bool PublishModelEvents => true; } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Rest/RestClientFactory.cs b/src/NzbDrone.Core/Rest/RestClientFactory.cs index f0259e54e..cc657c4b6 100644 --- a/src/NzbDrone.Core/Rest/RestClientFactory.cs +++ b/src/NzbDrone.Core/Rest/RestClientFactory.cs @@ -9,7 +9,7 @@ namespace NzbDrone.Core.Rest { var restClient = new RestClient(baseUrl) { - UserAgent = $"Sonarr/{BuildInfo.Version} ({OsInfo.Os})" + UserAgent = $"{BuildInfo.AppName}/{BuildInfo.Version} ({OsInfo.Os})" }; diff --git a/src/NzbDrone.Core/Restrictions/Restriction.cs b/src/NzbDrone.Core/Restrictions/Restriction.cs deleted file mode 100644 index 9be667d81..000000000 --- a/src/NzbDrone.Core/Restrictions/Restriction.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Core.Datastore; - -namespace NzbDrone.Core.Restrictions -{ - public class Restriction : ModelBase - { - public string Required { get; set; } - public string Preferred { get; set; } - public string Ignored { get; set; } - public HashSet<int> Tags { get; set; } - - public Restriction() - { - Tags = new HashSet<int>(); - } - } -} diff --git a/src/NzbDrone.Core/Restrictions/RestrictionRepository.cs b/src/NzbDrone.Core/Restrictions/RestrictionRepository.cs deleted file mode 100644 index a88b0e67f..000000000 --- a/src/NzbDrone.Core/Restrictions/RestrictionRepository.cs +++ /dev/null @@ -1,17 +0,0 @@ -using NzbDrone.Core.Datastore; -using NzbDrone.Core.Messaging.Events; - -namespace NzbDrone.Core.Restrictions -{ - public interface IRestrictionRepository : IBasicRepository<Restriction> - { - } - - public class RestrictionRepository : BasicRepository<Restriction>, IRestrictionRepository - { - public RestrictionRepository(IMainDatabase database, IEventAggregator eventAggregator) - : base(database, eventAggregator) - { - } - } -} diff --git a/src/NzbDrone.Core/Restrictions/RestrictionService.cs b/src/NzbDrone.Core/Restrictions/RestrictionService.cs deleted file mode 100644 index 5d7cfba8d..000000000 --- a/src/NzbDrone.Core/Restrictions/RestrictionService.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NLog; -using NzbDrone.Common.Extensions; - -namespace NzbDrone.Core.Restrictions -{ - public interface IRestrictionService - { - List<Restriction> All(); - List<Restriction> AllForTag(int tagId); - List<Restriction> AllForTags(HashSet<int> tagIds); - Restriction Get(int id); - void Delete(int id); - Restriction Add(Restriction restriction); - Restriction Update(Restriction restriction); - } - - public class RestrictionService : IRestrictionService - { - private readonly IRestrictionRepository _repo; - private readonly Logger _logger; - - public RestrictionService(IRestrictionRepository repo, Logger logger) - { - _repo = repo; - _logger = logger; - } - - public List<Restriction> All() - { - return _repo.All().ToList(); - } - - public List<Restriction> AllForTag(int tagId) - { - return _repo.All().Where(r => r.Tags.Contains(tagId) || r.Tags.Empty()).ToList(); - } - - public List<Restriction> AllForTags(HashSet<int> tagIds) - { - return _repo.All().Where(r => r.Tags.Intersect(tagIds).Any() || r.Tags.Empty()).ToList(); - } - - public Restriction Get(int id) - { - return _repo.Get(id); - } - - public void Delete(int id) - { - _repo.Delete(id); - } - - public Restriction Add(Restriction restriction) - { - return _repo.Insert(restriction); - } - - public Restriction Update(Restriction restriction) - { - return _repo.Update(restriction); - } - } -} 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..0b2d29d45 100644 --- a/src/NzbDrone.Core/RootFolders/RootFolderService.cs +++ b/src/NzbDrone.Core/RootFolders/RootFolderService.cs @@ -1,13 +1,14 @@ -using System.Linq; +using System.Linq; using System; using System.Collections.Generic; using System.IO; +using System.Threading; +using System.Threading.Tasks; using NLog; using NzbDrone.Common; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; -using NzbDrone.Core.Configuration; -using NzbDrone.Core.Tv; +using NzbDrone.Core.Music; namespace NzbDrone.Core.RootFolders { @@ -18,14 +19,14 @@ namespace NzbDrone.Core.RootFolders RootFolder Add(RootFolder rootDir); void Remove(int id); RootFolder Get(int id); + string GetBestRootFolderPath(string path); } public class RootFolderService : IRootFolderService { private readonly IRootFolderRepository _rootFolderRepository; private readonly IDiskProvider _diskProvider; - private readonly ISeriesRepository _seriesRepository; - private readonly IConfigService _configService; + private readonly IArtistRepository _artistRepository; private readonly Logger _logger; private static readonly HashSet<string> SpecialFolders = new HashSet<string> @@ -44,14 +45,12 @@ namespace NzbDrone.Core.RootFolders public RootFolderService(IRootFolderRepository rootFolderRepository, IDiskProvider diskProvider, - ISeriesRepository seriesRepository, - IConfigService configService, + IArtistRepository artistRepository, Logger logger) { _rootFolderRepository = rootFolderRepository; _diskProvider = diskProvider; - _seriesRepository = seriesRepository; - _configService = configService; + _artistRepository = artistRepository; _logger = logger; } @@ -70,17 +69,15 @@ namespace NzbDrone.Core.RootFolders { try { - if (folder.Path.IsPathValid() && _diskProvider.FolderExists(folder.Path)) + if (folder.Path.IsPathValid()) { - folder.FreeSpace = _diskProvider.GetAvailableSpace(folder.Path); - folder.UnmappedFolders = GetUnmappedFolders(folder.Path); + GetDetails(folder); } } //We don't want an exception to prevent the root folders from loading in the UI, so they can still be deleted 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>(); } }); @@ -107,11 +104,6 @@ namespace NzbDrone.Core.RootFolders throw new InvalidOperationException("Recent directory already exists."); } - if (_configService.DownloadedEpisodesFolder.IsNotNullOrWhiteSpace() && _configService.DownloadedEpisodesFolder.PathEquals(rootFolder.Path)) - { - throw new InvalidOperationException("Drone Factory folder cannot be used."); - } - if (!_diskProvider.FolderWritable(rootFolder.Path)) { throw new UnauthorizedAccessException(string.Format("Root folder path '{0}' is not writable by user '{1}'", rootFolder.Path, Environment.UserName)); @@ -119,8 +111,8 @@ namespace NzbDrone.Core.RootFolders _rootFolderRepository.Insert(rootFolder); - rootFolder.FreeSpace = _diskProvider.GetAvailableSpace(rootFolder.Path); - rootFolder.UnmappedFolders = GetUnmappedFolders(rootFolder.Path); + GetDetails(rootFolder); + return rootFolder; } @@ -139,7 +131,7 @@ namespace NzbDrone.Core.RootFolders } var results = new List<UnmappedFolder>(); - var series = _seriesRepository.All().ToList(); + var artist = _artistRepository.All().ToList(); if (!_diskProvider.FolderExists(path)) { @@ -147,8 +139,8 @@ namespace NzbDrone.Core.RootFolders return results; } - var possibleSeriesFolders = _diskProvider.GetDirectories(path).ToList(); - var unmappedFolders = possibleSeriesFolders.Except(series.Select(s => s.Path), PathEqualityComparer.Instance).ToList(); + var possibleArtistFolders = _diskProvider.GetDirectories(path).ToList(); + var unmappedFolders = possibleArtistFolders.Except(artist.Select(s => s.Path), PathEqualityComparer.Instance).ToList(); foreach (string unmappedFolder in unmappedFolders) { @@ -166,9 +158,37 @@ namespace NzbDrone.Core.RootFolders public RootFolder Get(int id) { var rootFolder = _rootFolderRepository.Get(id); - rootFolder.FreeSpace = _diskProvider.GetAvailableSpace(rootFolder.Path); - rootFolder.UnmappedFolders = GetUnmappedFolders(rootFolder.Path); + GetDetails(rootFolder); + return rootFolder; } + + public string GetBestRootFolderPath(string path) + { + var possibleRootFolder = All().Where(r => r.Path.IsParentPath(path)) + .OrderByDescending(r => r.Path.Length) + .FirstOrDefault(); + + if (possibleRootFolder == null) + { + return Path.GetDirectoryName(path); + } + + return possibleRootFolder.Path; + } + + private void GetDetails(RootFolder rootFolder) + { + Task.Run(() => + { + if (_diskProvider.FolderExists(rootFolder.Path)) + { + rootFolder.FreeSpace = _diskProvider.GetAvailableSpace(rootFolder.Path); + rootFolder.TotalSpace = _diskProvider.GetTotalSize(rootFolder.Path); + rootFolder.UnmappedFolders = GetUnmappedFolders(rootFolder.Path); + } + }) + .Wait(5000); + } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/SeriesStats/SeasonStatistics.cs b/src/NzbDrone.Core/SeriesStats/SeasonStatistics.cs deleted file mode 100644 index 3b3731ed5..000000000 --- a/src/NzbDrone.Core/SeriesStats/SeasonStatistics.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using NzbDrone.Core.Datastore; - -namespace NzbDrone.Core.SeriesStats -{ - public class SeasonStatistics : ResultSet - { - public int SeriesId { get; set; } - public int SeasonNumber { get; set; } - public string NextAiringString { get; set; } - public string PreviousAiringString { get; set; } - public int EpisodeFileCount { get; set; } - public int EpisodeCount { get; set; } - public int TotalEpisodeCount { get; set; } - public long SizeOnDisk { get; set; } - - public DateTime? NextAiring - { - get - { - DateTime nextAiring; - - if (!DateTime.TryParse(NextAiringString, out nextAiring)) return null; - - return nextAiring; - } - } - - public DateTime? PreviousAiring - { - get - { - DateTime previousAiring; - - if (!DateTime.TryParse(PreviousAiringString, out previousAiring)) return null; - - return previousAiring; - } - } - } -} diff --git a/src/NzbDrone.Core/SeriesStats/SeriesStatistics.cs b/src/NzbDrone.Core/SeriesStats/SeriesStatistics.cs deleted file mode 100644 index 25a82d68f..000000000 --- a/src/NzbDrone.Core/SeriesStats/SeriesStatistics.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System; -using System.Collections.Generic; -using NzbDrone.Core.Datastore; - -namespace NzbDrone.Core.SeriesStats -{ - public class SeriesStatistics : ResultSet - { - public int SeriesId { get; set; } - public string NextAiringString { get; set; } - public string PreviousAiringString { get; set; } - public int EpisodeFileCount { get; set; } - public int EpisodeCount { get; set; } - public int TotalEpisodeCount { get; set; } - public long SizeOnDisk { get; set; } - public List<SeasonStatistics> SeasonStatistics { get; set; } - - public DateTime? NextAiring - { - get - { - DateTime nextAiring; - - if (!DateTime.TryParse(NextAiringString, out nextAiring)) return null; - - return nextAiring; - } - } - - public DateTime? PreviousAiring - { - get - { - DateTime previousAiring; - - if (!DateTime.TryParse(PreviousAiringString, out previousAiring)) return null; - - return previousAiring; - } - } - } -} diff --git a/src/NzbDrone.Core/SeriesStats/SeriesStatisticsRepository.cs b/src/NzbDrone.Core/SeriesStats/SeriesStatisticsRepository.cs deleted file mode 100644 index 73e4e8b4b..000000000 --- a/src/NzbDrone.Core/SeriesStats/SeriesStatisticsRepository.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using NzbDrone.Core.Datastore; - -namespace NzbDrone.Core.SeriesStats -{ - public interface ISeriesStatisticsRepository - { - List<SeasonStatistics> SeriesStatistics(); - List<SeasonStatistics> SeriesStatistics(int seriesId); - } - - public class SeriesStatisticsRepository : ISeriesStatisticsRepository - { - private readonly IMainDatabase _database; - - public SeriesStatisticsRepository(IMainDatabase database) - { - _database = database; - } - - public List<SeasonStatistics> SeriesStatistics() - { - var mapper = _database.GetDataMapper(); - - mapper.AddParameter("currentDate", DateTime.UtcNow); - - var sb = new StringBuilder(); - sb.AppendLine(GetSelectClause()); - sb.AppendLine(GetEpisodeFilesJoin()); - sb.AppendLine(GetGroupByClause()); - var queryText = sb.ToString(); - - return mapper.Query<SeasonStatistics>(queryText); - } - - public List<SeasonStatistics> SeriesStatistics(int seriesId) - { - var mapper = _database.GetDataMapper(); - - mapper.AddParameter("currentDate", DateTime.UtcNow); - mapper.AddParameter("seriesId", seriesId); - - var sb = new StringBuilder(); - sb.AppendLine(GetSelectClause()); - sb.AppendLine(GetEpisodeFilesJoin()); - sb.AppendLine("WHERE Episodes.SeriesId = @seriesId"); - sb.AppendLine(GetGroupByClause()); - var queryText = sb.ToString(); - - return mapper.Query<SeasonStatistics>(queryText); - } - - private string GetSelectClause() - { - return @"SELECT Episodes.*, SUM(EpisodeFiles.Size) as SizeOnDisk FROM - (SELECT - Episodes.SeriesId, - Episodes.SeasonNumber, - SUM(CASE WHEN AirdateUtc <= @currentDate OR EpisodeFileId > 0 THEN 1 ELSE 0 END) AS TotalEpisodeCount, - SUM(CASE WHEN (Monitored = 1 AND AirdateUtc <= @currentDate) OR EpisodeFileId > 0 THEN 1 ELSE 0 END) AS EpisodeCount, - SUM(CASE WHEN EpisodeFileId > 0 THEN 1 ELSE 0 END) AS EpisodeFileCount, - MIN(CASE WHEN AirDateUtc < @currentDate OR EpisodeFileId > 0 OR Monitored = 0 THEN NULL ELSE AirDateUtc END) AS NextAiringString, - MAX(CASE WHEN AirDateUtc >= @currentDate OR EpisodeFileId = 0 AND Monitored = 0 THEN NULL ELSE AirDateUtc END) AS PreviousAiringString - FROM Episodes - GROUP BY Episodes.SeriesId, Episodes.SeasonNumber) as Episodes"; - } - - private string GetGroupByClause() - { - return "GROUP BY Episodes.SeriesId, Episodes.SeasonNumber"; - } - - private string GetEpisodeFilesJoin() - { - return @"LEFT OUTER JOIN EpisodeFiles - ON EpisodeFiles.SeriesId = Episodes.SeriesId - AND EpisodeFiles.SeasonNumber = Episodes.SeasonNumber"; - } - } -} diff --git a/src/NzbDrone.Core/SeriesStats/SeriesStatisticsService.cs b/src/NzbDrone.Core/SeriesStats/SeriesStatisticsService.cs deleted file mode 100644 index b273f84ce..000000000 --- a/src/NzbDrone.Core/SeriesStats/SeriesStatisticsService.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System.Collections.Generic; -using System.Linq; - -namespace NzbDrone.Core.SeriesStats -{ - public interface ISeriesStatisticsService - { - List<SeriesStatistics> SeriesStatistics(); - SeriesStatistics SeriesStatistics(int seriesId); - } - - public class SeriesStatisticsService : ISeriesStatisticsService - { - private readonly ISeriesStatisticsRepository _seriesStatisticsRepository; - - public SeriesStatisticsService(ISeriesStatisticsRepository seriesStatisticsRepository) - { - _seriesStatisticsRepository = seriesStatisticsRepository; - } - - public List<SeriesStatistics> SeriesStatistics() - { - var seasonStatistics = _seriesStatisticsRepository.SeriesStatistics(); - - return seasonStatistics.GroupBy(s => s.SeriesId).Select(s => MapSeriesStatistics(s.ToList())).ToList(); - } - - public SeriesStatistics SeriesStatistics(int seriesId) - { - var stats = _seriesStatisticsRepository.SeriesStatistics(seriesId); - - if (stats == null || stats.Count == 0) return new SeriesStatistics(); - - return MapSeriesStatistics(stats); - } - - private SeriesStatistics MapSeriesStatistics(List<SeasonStatistics> seasonStatistics) - { - var seriesStatistics = new SeriesStatistics - { - SeasonStatistics = seasonStatistics, - SeriesId = seasonStatistics.First().SeriesId, - EpisodeFileCount = seasonStatistics.Sum(s => s.EpisodeFileCount), - EpisodeCount = seasonStatistics.Sum(s => s.EpisodeCount), - TotalEpisodeCount = seasonStatistics.Sum(s => s.TotalEpisodeCount), - SizeOnDisk = seasonStatistics.Sum(s => s.SizeOnDisk) - }; - - var nextAiring = seasonStatistics.Where(s => s.NextAiring != null) - .OrderBy(s => s.NextAiring) - .FirstOrDefault(); - - var previousAiring = seasonStatistics.Where(s => s.PreviousAiring != null) - .OrderBy(s => s.PreviousAiring) - .LastOrDefault(); - - seriesStatistics.NextAiringString = nextAiring != null ? nextAiring.NextAiringString : null; - seriesStatistics.PreviousAiringString = previousAiring != null ? previousAiring.PreviousAiringString : null; - - return seriesStatistics; - } - } -} diff --git a/src/NzbDrone.Core/Tags/TagDetails.cs b/src/NzbDrone.Core/Tags/TagDetails.cs new file mode 100644 index 000000000..ce433d1c5 --- /dev/null +++ b/src/NzbDrone.Core/Tags/TagDetails.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Tags +{ + public class TagDetails : ModelBase + { + public string Label { get; set; } + public List<int> ArtistIds { get; set; } + public List<int> NotificationIds { get; set; } + public List<int> RestrictionIds { get; set; } + public List<int> DelayProfileIds { get; set; } + public List<int> ImportListIds { get; set; } + } +} diff --git a/src/NzbDrone.Core/Tags/TagRepository.cs b/src/NzbDrone.Core/Tags/TagRepository.cs index 500502843..2921ca7c8 100644 --- a/src/NzbDrone.Core/Tags/TagRepository.cs +++ b/src/NzbDrone.Core/Tags/TagRepository.cs @@ -8,6 +8,7 @@ namespace NzbDrone.Core.Tags public interface ITagRepository : IBasicRepository<Tag> { Tag GetByLabel(string label); + Tag FindByLabel(string label); } public class TagRepository : BasicRepository<Tag>, ITagRepository @@ -28,5 +29,10 @@ namespace NzbDrone.Core.Tags return model; } + + public Tag FindByLabel(string label) + { + return Query.Where(c => c.Label == label).SingleOrDefault(); + } } } diff --git a/src/NzbDrone.Core/Tags/TagService.cs b/src/NzbDrone.Core/Tags/TagService.cs index b7637a6f8..ce5335cf3 100644 --- a/src/NzbDrone.Core/Tags/TagService.cs +++ b/src/NzbDrone.Core/Tags/TagService.cs @@ -1,6 +1,11 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; +using NzbDrone.Core.ImportLists; using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Notifications; +using NzbDrone.Core.Profiles.Delay; +using NzbDrone.Core.Profiles.Releases; +using NzbDrone.Core.Music; namespace NzbDrone.Core.Tags { @@ -8,6 +13,8 @@ namespace NzbDrone.Core.Tags { Tag GetTag(int tagId); Tag GetTag(string tag); + TagDetails Details(int tagId); + List<TagDetails> Details(); List<Tag> All(); Tag Add(Tag tag); Tag Update(Tag tag); @@ -18,11 +25,27 @@ namespace NzbDrone.Core.Tags { private readonly ITagRepository _repo; private readonly IEventAggregator _eventAggregator; + private readonly IDelayProfileService _delayProfileService; + private readonly IImportListFactory _importListFactory; + private readonly INotificationFactory _notificationFactory; + private readonly IReleaseProfileService _releaseProfileService; + private readonly IArtistService _artistService; - public TagService(ITagRepository repo, IEventAggregator eventAggregator) + public TagService(ITagRepository repo, + IEventAggregator eventAggregator, + IDelayProfileService delayProfileService, + ImportListFactory importListFactory, + INotificationFactory notificationFactory, + IReleaseProfileService releaseProfileService, + IArtistService artistService) { _repo = repo; _eventAggregator = eventAggregator; + _delayProfileService = delayProfileService; + _importListFactory = importListFactory; + _notificationFactory = notificationFactory; + _releaseProfileService = releaseProfileService; + _artistService = artistService; } public Tag GetTag(int tagId) @@ -42,6 +65,56 @@ namespace NzbDrone.Core.Tags } } + public TagDetails Details(int tagId) + { + var tag = GetTag(tagId); + var delayProfiles = _delayProfileService.AllForTag(tagId); + var importLists = _importListFactory.AllForTag(tagId); + var notifications = _notificationFactory.AllForTag(tagId); + var restrictions = _releaseProfileService.AllForTag(tagId); + var artist = _artistService.AllForTag(tagId); + + return new TagDetails + { + Id = tagId, + Label = tag.Label, + DelayProfileIds = delayProfiles.Select(c => c.Id).ToList(), + ImportListIds = importLists.Select(c => c.Id).ToList(), + NotificationIds = notifications.Select(c => c.Id).ToList(), + RestrictionIds = restrictions.Select(c => c.Id).ToList(), + ArtistIds = artist.Select(c => c.Id).ToList() + }; + } + + public List<TagDetails> Details() + { + var tags = All(); + var delayProfiles = _delayProfileService.All(); + var importLists = _importListFactory.All(); + var notifications = _notificationFactory.All(); + var restrictions = _releaseProfileService.All(); + var artists = _artistService.GetAllArtists(); + + var details = new List<TagDetails>(); + + foreach (var tag in tags) + { + details.Add(new TagDetails + { + Id = tag.Id, + Label = tag.Label, + DelayProfileIds = delayProfiles.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), + ImportListIds = importLists.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), + NotificationIds = notifications.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), + RestrictionIds = restrictions.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), + ArtistIds = artists.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList() + } + ); + } + + return details; + } + public List<Tag> All() { return _repo.All().OrderBy(t => t.Label).ToList(); @@ -49,7 +122,12 @@ namespace NzbDrone.Core.Tags public Tag Add(Tag tag) { - //TODO: check for duplicate tag by label and return that tag instead? + var existingTag = _repo.FindByLabel(tag.Label); + + if (existingTag != null) + { + return existingTag; + } tag.Label = tag.Label.ToLowerInvariant(); diff --git a/src/NzbDrone.Core/ThingiProvider/Events/ProviderAddedEvent.cs b/src/NzbDrone.Core/ThingiProvider/Events/ProviderAddedEvent.cs new file mode 100644 index 000000000..d1bc038b9 --- /dev/null +++ b/src/NzbDrone.Core/ThingiProvider/Events/ProviderAddedEvent.cs @@ -0,0 +1,14 @@ +using NzbDrone.Common.Messaging; + +namespace NzbDrone.Core.ThingiProvider.Events +{ + public class ProviderAddedEvent<TProvider> : IEvent + { + public ProviderDefinition Definition { get; private set; } + + public ProviderAddedEvent(ProviderDefinition definition) + { + Definition = definition; + } + } +} 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/IProviderFactory.cs b/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs index ce6519e1b..2627cec14 100644 --- a/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs +++ b/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs @@ -20,5 +20,6 @@ namespace NzbDrone.Core.ThingiProvider TProvider GetInstance(TProviderDefinition definition); ValidationResult Test(TProviderDefinition definition); object RequestAction(TProviderDefinition definition, string action, IDictionary<string, string> query); + List<TProviderDefinition> AllForTag(int tagId); } } \ No newline at end of file diff --git a/src/NzbDrone.Core/ThingiProvider/ProviderDefinition.cs b/src/NzbDrone.Core/ThingiProvider/ProviderDefinition.cs index 45bd5a25a..d83c7dfda 100644 --- a/src/NzbDrone.Core/ThingiProvider/ProviderDefinition.cs +++ b/src/NzbDrone.Core/ThingiProvider/ProviderDefinition.cs @@ -1,9 +1,15 @@ -using NzbDrone.Core.Datastore; +using System.Collections.Generic; +using NzbDrone.Core.Datastore; namespace NzbDrone.Core.ThingiProvider { public abstract class ProviderDefinition : ModelBase { + protected ProviderDefinition() + { + Tags = new HashSet<int>(); + } + private IProviderConfig _settings; public string Name { get; set; } @@ -12,6 +18,7 @@ namespace NzbDrone.Core.ThingiProvider public string ConfigContract { get; set; } public virtual bool Enable { get; set; } public ProviderMessage Message { get; set; } + public HashSet<int> Tags { get; set; } public IProviderConfig Settings { diff --git a/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs b/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs index 0c64aa994..b37aefda9 100644 --- a/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs +++ b/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using FluentValidation.Results; @@ -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(); } @@ -98,7 +98,9 @@ namespace NzbDrone.Core.ThingiProvider public virtual TProviderDefinition Create(TProviderDefinition definition) { - return _providerRepository.Insert(definition); + var addedDefinition = _providerRepository.Insert(definition); + _eventAggregator.PublishEvent(new ProviderAddedEvent<TProvider>(definition)); + return addedDefinition; } public virtual void Update(TProviderDefinition definition) @@ -167,5 +169,11 @@ namespace NzbDrone.Core.ThingiProvider _providerRepository.Delete(invalidDefinition); } } + + public List<TProviderDefinition> AllForTag(int tagId) + { + return All().Where(p => p.Tags.Contains(tagId)) + .ToList(); + } } -} \ 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..395a43efd --- /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..c2782b409 --- /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..36cdebd45 --- /dev/null +++ b/src/NzbDrone.Core/ThingiProvider/Status/ProviderStatusServiceBase.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Common.EnvironmentInfo; +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 IRuntimeInfo _runtimeInfo; + protected readonly Logger _logger; + + protected int MaximumEscalationLevel { get; set; } = EscalationBackOff.Periods.Length - 1; + protected TimeSpan MinimumTimeSinceInitialFailure { get; set; } = TimeSpan.Zero; + protected TimeSpan MinimumTimeSinceStartup { get; set; } = TimeSpan.FromMinutes(15); + + public ProviderStatusServiceBase(IProviderStatusRepository<TModel> providerStatusRepository, IEventAggregator eventAggregator, IRuntimeInfo runtimeInfo, Logger logger) + { + _providerStatusRepository = providerStatusRepository; + _eventAggregator = eventAggregator; + _runtimeInfo = runtimeInfo; + _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 inStartupGracePeriod = (_runtimeInfo.StartTime + MinimumTimeSinceStartup) > now; + var inGracePeriod = (status.InitialFailure.Value + MinimumTimeSinceInitialFailure) > now; + + if (escalate && !inGracePeriod && !inStartupGracePeriod) + { + 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); + } + + if (inStartupGracePeriod && minimumBackOff == TimeSpan.Zero && status.DisabledTill.HasValue) + { + var maximumDisabledTill = now + TimeSpan.FromSeconds(EscalationBackOff.Periods[1]); + if (maximumDisabledTill < status.DisabledTill) + { + status.DisabledTill = maximumDisabledTill; + } + } + + _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/TinyTwitter.cs b/src/NzbDrone.Core/TinyTwitter.cs index 508783b6b..abb39393a 100644 --- a/src/NzbDrone.Core/TinyTwitter.cs +++ b/src/NzbDrone.Core/TinyTwitter.cs @@ -47,7 +47,7 @@ namespace TinyTwitter /** * * As of June 26th 2015 Direct Messaging is not part of TinyTwitter. - * I have added it to Sonarr's copy to make our implementation easier + * I have added it to Lidarr's copy to make our implementation easier * and added this banner so it's not blindly updated. * **/ diff --git a/src/NzbDrone.Core/Tv/Actor.cs b/src/NzbDrone.Core/Tv/Actor.cs deleted file mode 100644 index cfc8a0bbd..000000000 --- a/src/NzbDrone.Core/Tv/Actor.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Core.Datastore; - -namespace NzbDrone.Core.Tv -{ - public class Actor : IEmbeddedDocument - { - public Actor() - { - Images = new List<MediaCover.MediaCover>(); - } - - public string Name { get; set; } - public string Character { get; set; } - public List<MediaCover.MediaCover> Images { get; set; } - } -} diff --git a/src/NzbDrone.Core/Tv/AddSeriesOptions.cs b/src/NzbDrone.Core/Tv/AddSeriesOptions.cs deleted file mode 100644 index fceae6586..000000000 --- a/src/NzbDrone.Core/Tv/AddSeriesOptions.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace NzbDrone.Core.Tv -{ - public class AddSeriesOptions : MonitoringOptions - { - public bool SearchForMissingEpisodes { get; set; } - } -} diff --git a/src/NzbDrone.Core/Tv/Commands/MoveSeriesCommand.cs b/src/NzbDrone.Core/Tv/Commands/MoveSeriesCommand.cs deleted file mode 100644 index 1a283e80d..000000000 --- a/src/NzbDrone.Core/Tv/Commands/MoveSeriesCommand.cs +++ /dev/null @@ -1,12 +0,0 @@ -using NzbDrone.Core.Messaging.Commands; - -namespace NzbDrone.Core.Tv.Commands -{ - public class MoveSeriesCommand : Command - { - public int SeriesId { get; set; } - public string SourcePath { get; set; } - public string DestinationPath { get; set; } - public string DestinationRootFolder { get; set; } - } -} diff --git a/src/NzbDrone.Core/Tv/Commands/RefreshSeriesCommand.cs b/src/NzbDrone.Core/Tv/Commands/RefreshSeriesCommand.cs deleted file mode 100644 index 4cae630cd..000000000 --- a/src/NzbDrone.Core/Tv/Commands/RefreshSeriesCommand.cs +++ /dev/null @@ -1,22 +0,0 @@ -using NzbDrone.Core.Messaging.Commands; - -namespace NzbDrone.Core.Tv.Commands -{ - public class RefreshSeriesCommand : Command - { - public int? SeriesId { get; set; } - - public RefreshSeriesCommand() - { - } - - public RefreshSeriesCommand(int? seriesId) - { - SeriesId = seriesId; - } - - public override bool SendUpdatesToClient => true; - - public override bool UpdateScheduledTask => !SeriesId.HasValue; - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/Tv/Episode.cs b/src/NzbDrone.Core/Tv/Episode.cs deleted file mode 100644 index dcb95069e..000000000 --- a/src/NzbDrone.Core/Tv/Episode.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; -using System.Collections.Generic; -using Marr.Data; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Datastore; -using NzbDrone.Core.MediaFiles; - -namespace NzbDrone.Core.Tv -{ - public class Episode : ModelBase - { - public Episode() - { - Images = new List<MediaCover.MediaCover>(); - } - - public const string AIR_DATE_FORMAT = "yyyy-MM-dd"; - - public int SeriesId { get; set; } - public int EpisodeFileId { get; set; } - public int SeasonNumber { get; set; } - public int EpisodeNumber { get; set; } - public string Title { get; set; } - public string AirDate { get; set; } - public DateTime? AirDateUtc { get; set; } - public string Overview { get; set; } - public bool Monitored { get; set; } - public int? AbsoluteEpisodeNumber { get; set; } - public int? SceneAbsoluteEpisodeNumber { get; set; } - public int? SceneSeasonNumber { get; set; } - public int? SceneEpisodeNumber { get; set; } - public bool UnverifiedSceneNumbering { get; set; } - public Ratings Ratings { get; set; } - public List<MediaCover.MediaCover> Images { get; set; } - - public string SeriesTitle { get; private set; } - - public LazyLoaded<EpisodeFile> EpisodeFile { get; set; } - - public Series Series { get; set; } - - public bool HasFile => EpisodeFileId > 0; - - public override string ToString() - { - return string.Format("[{0}]{1}", Id, Title.NullSafe()); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/Tv/EpisodeAddedService.cs b/src/NzbDrone.Core/Tv/EpisodeAddedService.cs deleted file mode 100644 index 54e3d2991..000000000 --- a/src/NzbDrone.Core/Tv/EpisodeAddedService.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NLog; -using NzbDrone.Common.Cache; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.IndexerSearch; -using NzbDrone.Core.Messaging.Commands; -using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Tv.Events; - -namespace NzbDrone.Core.Tv -{ - public interface IEpisodeAddedService - { - void SearchForRecentlyAdded(int seriesId); - } - - public class EpisodeAddedService : IHandle<EpisodeInfoRefreshedEvent>, IEpisodeAddedService - { - private readonly IManageCommandQueue _commandQueueManager; - private readonly IEpisodeService _episodeService; - private readonly Logger _logger; - private readonly ICached<List<int>> _addedEpisodesCache; - - public EpisodeAddedService(ICacheManager cacheManager, - IManageCommandQueue commandQueueManager, - IEpisodeService episodeService, - Logger logger) - { - _commandQueueManager = commandQueueManager; - _episodeService = episodeService; - _logger = logger; - _addedEpisodesCache = cacheManager.GetCache<List<int>>(GetType()); - } - - public void SearchForRecentlyAdded(int seriesId) - { - var previouslyAired = _addedEpisodesCache.Find(seriesId.ToString()); - - if (previouslyAired != null && previouslyAired.Any()) - { - var missing = previouslyAired.Select(e => _episodeService.GetEpisode(e)).Where(e => !e.HasFile).ToList(); - - if (missing.Any()) - { - _commandQueueManager.Push(new EpisodeSearchCommand(missing.Select(e => e.Id).ToList())); - } - } - - _addedEpisodesCache.Remove(seriesId.ToString()); - } - - public void Handle(EpisodeInfoRefreshedEvent message) - { - if (message.Series.AddOptions == null) - { - if (!message.Series.Monitored) - { - _logger.Debug("Series is not monitored"); - return; - } - - if (message.Added.Empty()) - { - _logger.Debug("No new episodes, skipping search"); - return; - } - - if (message.Added.None(a => a.AirDateUtc.HasValue)) - { - _logger.Debug("No new episodes have an air date"); - return; - } - - var previouslyAired = message.Added.Where(a => a.AirDateUtc.HasValue && a.AirDateUtc.Value.Before(DateTime.UtcNow.AddDays(1)) && a.Monitored).ToList(); - - if (previouslyAired.Empty()) - { - _logger.Debug("Newly added episodes all air in the future"); - return; - } - - _addedEpisodesCache.Set(message.Series.Id.ToString(), previouslyAired.Select(e => e.Id).ToList()); - } - } - } -} diff --git a/src/NzbDrone.Core/Tv/EpisodeCutoffService.cs b/src/NzbDrone.Core/Tv/EpisodeCutoffService.cs deleted file mode 100644 index 6747aa87e..000000000 --- a/src/NzbDrone.Core/Tv/EpisodeCutoffService.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using NLog; -using NzbDrone.Core.Datastore; -using NzbDrone.Core.Profiles; -using NzbDrone.Core.Qualities; - -namespace NzbDrone.Core.Tv -{ - public interface IEpisodeCutoffService - { - PagingSpec<Episode> EpisodesWhereCutoffUnmet(PagingSpec<Episode> pagingSpec); - } - - public class EpisodeCutoffService : IEpisodeCutoffService - { - private readonly IEpisodeRepository _episodeRepository; - private readonly IProfileService _profileService; - private readonly Logger _logger; - - public EpisodeCutoffService(IEpisodeRepository episodeRepository, IProfileService profileService, Logger logger) - { - _episodeRepository = episodeRepository; - _profileService = profileService; - _logger = logger; - } - - public PagingSpec<Episode> EpisodesWhereCutoffUnmet(PagingSpec<Episode> pagingSpec) - { - var qualitiesBelowCutoff = new List<QualitiesBelowCutoff>(); - var profiles = _profileService.All(); - - //Get all items less than the cutoff - foreach (var profile in profiles) - { - var cutoffIndex = profile.Items.FindIndex(v => v.Quality == profile.Cutoff); - var belowCutoff = profile.Items.Take(cutoffIndex).ToList(); - - if (belowCutoff.Any()) - { - qualitiesBelowCutoff.Add(new QualitiesBelowCutoff(profile.Id, belowCutoff.Select(i => i.Quality.Id))); - } - } - - return _episodeRepository.EpisodesWhereCutoffUnmet(pagingSpec, qualitiesBelowCutoff, false); - } - } -} diff --git a/src/NzbDrone.Core/Tv/EpisodeMonitoredService.cs b/src/NzbDrone.Core/Tv/EpisodeMonitoredService.cs deleted file mode 100644 index b15c130be..000000000 --- a/src/NzbDrone.Core/Tv/EpisodeMonitoredService.cs +++ /dev/null @@ -1,109 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NLog; -using NzbDrone.Common.Extensions; - -namespace NzbDrone.Core.Tv -{ - public interface IEpisodeMonitoredService - { - void SetEpisodeMonitoredStatus(Series series, MonitoringOptions monitoringOptions); - } - - public class EpisodeMonitoredService : IEpisodeMonitoredService - { - private readonly ISeriesService _seriesService; - private readonly IEpisodeService _episodeService; - private readonly Logger _logger; - - public EpisodeMonitoredService(ISeriesService seriesService, IEpisodeService episodeService, Logger logger) - { - _seriesService = seriesService; - _episodeService = episodeService; - _logger = logger; - } - - public void SetEpisodeMonitoredStatus(Series series, MonitoringOptions monitoringOptions) - { - if (monitoringOptions != null) - { - _logger.Debug("[{0}] Setting episode monitored status.", series.Title); - - var episodes = _episodeService.GetEpisodeBySeries(series.Id); - - if (monitoringOptions.IgnoreEpisodesWithFiles) - { - _logger.Debug("Ignoring Episodes with Files"); - ToggleEpisodesMonitoredState(episodes.Where(e => e.HasFile), false); - } - - else - { - _logger.Debug("Monitoring Episodes with Files"); - ToggleEpisodesMonitoredState(episodes.Where(e => e.HasFile), true); - } - - if (monitoringOptions.IgnoreEpisodesWithoutFiles) - { - _logger.Debug("Ignoring 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"); - ToggleEpisodesMonitoredState(episodes.Where(e => !e.HasFile && e.AirDateUtc.HasValue && e.AirDateUtc.Value.Before(DateTime.UtcNow)), true); - } - - var lastSeason = series.Seasons.Select(s => s.SeasonNumber).MaxOrDefault(); - - foreach (var s in series.Seasons) - { - var season = s; - - if (season.Monitored) - { - if (!monitoringOptions.IgnoreEpisodesWithFiles && !monitoringOptions.IgnoreEpisodesWithoutFiles) - { - ToggleEpisodesMonitoredState(episodes.Where(e => e.SeasonNumber == season.SeasonNumber), true); - } - } - - 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 (season.SeasonNumber < lastSeason) - { - if (episodes.Where(e => e.SeasonNumber == season.SeasonNumber).All(e => !e.Monitored)) - { - season.Monitored = false; - } - } - } - - _episodeService.UpdateEpisodes(episodes); - } - - _seriesService.UpdateSeries(series); - } - - private void ToggleEpisodesMonitoredState(IEnumerable<Episode> episodes, bool monitored) - { - foreach (var episode in episodes) - { - episode.Monitored = monitored; - } - } - } -} diff --git a/src/NzbDrone.Core/Tv/EpisodeRepository.cs b/src/NzbDrone.Core/Tv/EpisodeRepository.cs deleted file mode 100644 index 5a1f413ad..000000000 --- a/src/NzbDrone.Core/Tv/EpisodeRepository.cs +++ /dev/null @@ -1,266 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Marr.Data.QGen; -using NLog; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Datastore; -using NzbDrone.Core.Datastore.Extensions; -using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.Qualities; - -namespace NzbDrone.Core.Tv -{ - public interface IEpisodeRepository : IBasicRepository<Episode> - { - Episode Find(int seriesId, int season, int episodeNumber); - Episode Find(int seriesId, int absoluteEpisodeNumber); - Episode Get(int seriesId, string date); - Episode Find(int seriesId, string date); - List<Episode> GetEpisodes(int seriesId); - List<Episode> GetEpisodes(int seriesId, int seasonNumber); - List<Episode> GetEpisodeByFileId(int fileId); - List<Episode> EpisodesWithFiles(int seriesId); - 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> EpisodesBetweenDates(DateTime startDate, DateTime endDate, bool includeUnmonitored); - void SetMonitoredFlat(Episode episode, bool monitored); - void SetMonitoredBySeason(int seriesId, int seasonNumber, bool monitored); - void SetFileId(int episodeId, int fileId); - } - - public class EpisodeRepository : BasicRepository<Episode>, IEpisodeRepository - { - private readonly IMainDatabase _database; - private readonly Logger _logger; - - public EpisodeRepository(IMainDatabase database, IEventAggregator eventAggregator, Logger logger) - : base(database, eventAggregator) - { - _database = database; - _logger = logger; - } - - public Episode Find(int seriesId, int season, int episodeNumber) - { - return Query.Where(s => s.SeriesId == seriesId) - .AndWhere(s => s.SeasonNumber == season) - .AndWhere(s => s.EpisodeNumber == episodeNumber) - .SingleOrDefault(); - } - - public Episode Find(int seriesId, int absoluteEpisodeNumber) - { - return Query.Where(s => s.SeriesId == seriesId) - .AndWhere(s => s.AbsoluteEpisodeNumber == absoluteEpisodeNumber) - .SingleOrDefault(); - } - - public Episode Get(int seriesId, string date) - { - var episode = FindOneByAirDate(seriesId, date); - - if (episode == null) - { - throw new InvalidOperationException("Expected at one episode"); - } - - return episode; - } - - public Episode Find(int seriesId, string date) - { - return FindOneByAirDate(seriesId, date); - } - - public List<Episode> GetEpisodes(int seriesId) - { - return Query.Where(s => s.SeriesId == seriesId).ToList(); - } - - public List<Episode> GetEpisodes(int seriesId, int seasonNumber) - { - return Query.Where(s => s.SeriesId == seriesId) - .AndWhere(s => s.SeasonNumber == seasonNumber) - .ToList(); - } - - public List<Episode> GetEpisodeByFileId(int fileId) - { - return Query.Where(e => e.EpisodeFileId == fileId).ToList(); - } - - public List<Episode> EpisodesWithFiles(int seriesId) - { - return Query.Join<Episode, EpisodeFile>(JoinType.Inner, e => e.EpisodeFile, (e, ef) => e.EpisodeFileId == ef.Id) - .Where(e => e.SeriesId == seriesId); - } - - public PagingSpec<Episode> EpisodesWithoutFiles(PagingSpec<Episode> pagingSpec, bool includeSpecials) - { - var currentTime = DateTime.UtcNow; - var startingSeasonNumber = 1; - - if (includeSpecials) - { - startingSeasonNumber = 0; - } - - pagingSpec.TotalRecords = GetMissingEpisodesQuery(pagingSpec, currentTime, startingSeasonNumber).GetRowCount(); - pagingSpec.Records = GetMissingEpisodesQuery(pagingSpec, currentTime, startingSeasonNumber).ToList(); - - return pagingSpec; - } - - public PagingSpec<Episode> EpisodesWhereCutoffUnmet(PagingSpec<Episode> pagingSpec, List<QualitiesBelowCutoff> qualitiesBelowCutoff, bool includeSpecials) - { - var startingSeasonNumber = 1; - - if (includeSpecials) - { - startingSeasonNumber = 0; - } - - pagingSpec.TotalRecords = EpisodesWhereCutoffUnmetQuery(pagingSpec, qualitiesBelowCutoff, startingSeasonNumber).GetRowCount(); - pagingSpec.Records = EpisodesWhereCutoffUnmetQuery(pagingSpec, qualitiesBelowCutoff, startingSeasonNumber).ToList(); - - return pagingSpec; - } - - public List<Episode> FindEpisodesBySceneNumbering(int seriesId, int seasonNumber, int episodeNumber) - { - return Query.Where(s => s.SeriesId == seriesId) - .AndWhere(s => s.SceneSeasonNumber == seasonNumber) - .AndWhere(s => s.SceneEpisodeNumber == episodeNumber); - } - - public Episode FindEpisodeBySceneNumbering(int seriesId, int sceneAbsoluteEpisodeNumber) - { - var episodes = 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) - { - var query = Query.Join<Episode, Series>(JoinType.Inner, e => e.Series, (e, s) => e.SeriesId == s.Id) - .Where<Episode>(e => e.AirDateUtc >= startDate) - .AndWhere(e => e.AirDateUtc <= endDate); - - - if (!includeUnmonitored) - { - query.AndWhere(e => e.Monitored) - .AndWhere(e => e.Series.Monitored); - } - - return query.ToList(); - } - - public void SetMonitoredFlat(Episode episode, bool monitored) - { - episode.Monitored = monitored; - SetFields(episode, p => p.Monitored); - } - - public void SetMonitoredBySeason(int seriesId, int seasonNumber, bool monitored) - { - var mapper = _database.GetDataMapper(); - - mapper.AddParameter("seriesId", seriesId); - mapper.AddParameter("seasonNumber", seasonNumber); - mapper.AddParameter("monitored", monitored); - - const string sql = "UPDATE Episodes " + - "SET Monitored = @monitored " + - "WHERE SeriesId = @seriesId " + - "AND SeasonNumber = @seasonNumber"; - - mapper.ExecuteNonQuery(sql); - } - - public void SetFileId(int episodeId, int fileId) - { - SetFields(new Episode { Id = episodeId, EpisodeFileId = fileId }, episode => episode.EpisodeFileId); - } - - private SortBuilder<Episode> GetMissingEpisodesQuery(PagingSpec<Episode> pagingSpec, DateTime currentTime, int startingSeasonNumber) - { - return Query.Join<Episode, Series>(JoinType.Inner, e => e.Series, (e, s) => e.SeriesId == s.Id) - .Where(pagingSpec.FilterExpression) - .AndWhere(e => e.EpisodeFileId == 0) - .AndWhere(e => e.SeasonNumber >= startingSeasonNumber) - .AndWhere(BuildAirDateUtcCutoffWhereClause(currentTime)) - .OrderBy(pagingSpec.OrderByClause(), pagingSpec.ToSortDirection()) - .Skip(pagingSpec.PagingOffset()) - .Take(pagingSpec.PageSize); - } - - private SortBuilder<Episode> EpisodesWhereCutoffUnmetQuery(PagingSpec<Episode> pagingSpec, List<QualitiesBelowCutoff> qualitiesBelowCutoff, int startingSeasonNumber) - { - return Query.Join<Episode, Series>(JoinType.Inner, e => e.Series, (e, s) => e.SeriesId == s.Id) - .Join<Episode, EpisodeFile>(JoinType.Left, e => e.EpisodeFile, (e, s) => e.EpisodeFileId == s.Id) - .Where(pagingSpec.FilterExpression) - .AndWhere(e => e.EpisodeFileId != 0) - .AndWhere(e => e.SeasonNumber >= startingSeasonNumber) - .AndWhere(BuildQualityCutoffWhereClause(qualitiesBelowCutoff)) - .OrderBy(pagingSpec.OrderByClause(), pagingSpec.ToSortDirection()) - .Skip(pagingSpec.PagingOffset()) - .Take(pagingSpec.PageSize); - } - - private string BuildAirDateUtcCutoffWhereClause(DateTime currentTime) - { - return string.Format("WHERE datetime(strftime('%s', [t0].[AirDateUtc]) + [t1].[RunTime] * 60, 'unixepoch') <= '{0}'", - currentTime.ToString("yyyy-MM-dd HH:mm:ss")); - } - - private string BuildQualityCutoffWhereClause(List<QualitiesBelowCutoff> qualitiesBelowCutoff) - { - var clauses = new List<string>(); - - foreach (var profile in qualitiesBelowCutoff) - { - foreach (var belowCutoff in profile.QualityIds) - { - clauses.Add(string.Format("([t1].[ProfileId] = {0} AND [t2].[Quality] LIKE '%_quality_: {1},%')", profile.ProfileId, belowCutoff)); - } - } - - return string.Format("({0})", string.Join(" OR ", clauses)); - } - - private Episode FindOneByAirDate(int seriesId, string date) - { - var episodes = Query.Where(s => s.SeriesId == seriesId) - .AndWhere(s => s.AirDate == date) - .ToList(); - - if (!episodes.Any()) return null; - - if (episodes.Count == 1) return episodes.First(); - - _logger.Debug("Multiple episodes with the same air date were found, will exclude specials"); - - var regularEpisodes = episodes.Where(e => e.SeasonNumber > 0).ToList(); - - if (regularEpisodes.Count == 1) - { - _logger.Debug("Left with one episode after excluding specials"); - return regularEpisodes.First(); - } - - throw new InvalidOperationException("Multiple episodes with the same air date found"); - } - } -} diff --git a/src/NzbDrone.Core/Tv/EpisodeService.cs b/src/NzbDrone.Core/Tv/EpisodeService.cs deleted file mode 100644 index 32a46ec45..000000000 --- a/src/NzbDrone.Core/Tv/EpisodeService.cs +++ /dev/null @@ -1,225 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NLog; -using NzbDrone.Core.Configuration; -using NzbDrone.Core.Datastore; -using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.MediaFiles.Events; -using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Tv.Events; - -namespace NzbDrone.Core.Tv -{ - public interface IEpisodeService - { - Episode GetEpisode(int id); - List<Episode> GetEpisodes(IEnumerable<int> ids); - Episode FindEpisode(int seriesId, int seasonNumber, int episodeNumber); - 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); - Episode GetEpisode(int seriesId, string date); - Episode FindEpisode(int seriesId, string date); - List<Episode> GetEpisodeBySeries(int seriesId); - List<Episode> GetEpisodesBySeason(int seriesId, int seasonNumber); - List<Episode> EpisodesWithFiles(int seriesId); - PagingSpec<Episode> EpisodesWithoutFiles(PagingSpec<Episode> pagingSpec); - List<Episode> GetEpisodesByFileId(int episodeFileId); - void UpdateEpisode(Episode episode); - void SetEpisodeMonitored(int episodeId, bool monitored); - void UpdateEpisodes(List<Episode> episodes); - List<Episode> EpisodesBetweenDates(DateTime start, DateTime end, bool includeUnmonitored); - void InsertMany(List<Episode> episodes); - void UpdateMany(List<Episode> episodes); - void DeleteMany(List<Episode> episodes); - void SetEpisodeMonitoredBySeason(int seriesId, int seasonNumber, bool monitored); - } - - public class EpisodeService : IEpisodeService, - IHandle<EpisodeFileDeletedEvent>, - IHandle<EpisodeFileAddedEvent>, - IHandleAsync<SeriesDeletedEvent> - { - private readonly IEpisodeRepository _episodeRepository; - private readonly IConfigService _configService; - private readonly Logger _logger; - - public EpisodeService(IEpisodeRepository episodeRepository, IConfigService configService, Logger logger) - { - _episodeRepository = episodeRepository; - _configService = configService; - _logger = logger; - } - - public Episode GetEpisode(int id) - { - return _episodeRepository.Get(id); - } - - public List<Episode> GetEpisodes(IEnumerable<int> ids) - { - return _episodeRepository.Get(ids).ToList(); - } - - public Episode FindEpisode(int seriesId, int seasonNumber, int episodeNumber) - { - return _episodeRepository.Find(seriesId, seasonNumber, episodeNumber); - } - - public Episode FindEpisode(int seriesId, int absoluteEpisodeNumber) - { - return _episodeRepository.Find(seriesId, absoluteEpisodeNumber); - } - - public List<Episode> FindEpisodesBySceneNumbering(int seriesId, int seasonNumber, int episodeNumber) - { - return _episodeRepository.FindEpisodesBySceneNumbering(seriesId, seasonNumber, episodeNumber); - } - - public Episode FindEpisodeBySceneNumbering(int seriesId, int sceneAbsoluteEpisodeNumber) - { - return _episodeRepository.FindEpisodeBySceneNumbering(seriesId, sceneAbsoluteEpisodeNumber); - } - - public Episode GetEpisode(int seriesId, string date) - { - return _episodeRepository.Get(seriesId, date); - } - - public Episode FindEpisode(int seriesId, string date) - { - return _episodeRepository.Find(seriesId, date); - } - - public List<Episode> GetEpisodeBySeries(int seriesId) - { - return _episodeRepository.GetEpisodes(seriesId).ToList(); - } - - public List<Episode> GetEpisodesBySeason(int seriesId, int seasonNumber) - { - return _episodeRepository.GetEpisodes(seriesId, seasonNumber); - } - - 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 episodes = _episodeRepository.GetEpisodes(seriesId, seasonNumber); - - var matches = episodes.Select( - episode => new - { - Position = normalizedReleaseTitle.IndexOf(Parser.Parser.NormalizeEpisodeTitle(episode.Title), StringComparison.CurrentCultureIgnoreCase), - Length = Parser.Parser.NormalizeEpisodeTitle(episode.Title).Length, - Episode = episode - }) - .Where(e => e.Episode.Title.Length > 0 && e.Position >= 0) - .OrderBy(e => e.Position) - .ThenByDescending(e => e.Length) - .ToList(); - - if (matches.Any()) - { - return matches.First().Episode; - } - - return null; - } - - public List<Episode> EpisodesWithFiles(int seriesId) - { - return _episodeRepository.EpisodesWithFiles(seriesId); - } - - public PagingSpec<Episode> EpisodesWithoutFiles(PagingSpec<Episode> pagingSpec) - { - var episodeResult = _episodeRepository.EpisodesWithoutFiles(pagingSpec, true); - - return episodeResult; - } - - public List<Episode> GetEpisodesByFileId(int episodeFileId) - { - return _episodeRepository.GetEpisodeByFileId(episodeFileId); - } - - public void UpdateEpisode(Episode episode) - { - _episodeRepository.Update(episode); - } - - public void SetEpisodeMonitored(int episodeId, bool monitored) - { - var episode = _episodeRepository.Get(episodeId); - _episodeRepository.SetMonitoredFlat(episode, monitored); - - _logger.Debug("Monitored flag for Episode:{0} was set to {1}", episodeId, monitored); - } - - public void SetEpisodeMonitoredBySeason(int seriesId, int seasonNumber, bool monitored) - { - _episodeRepository.SetMonitoredBySeason(seriesId, seasonNumber, monitored); - } - - public void UpdateEpisodes(List<Episode> episodes) - { - _episodeRepository.UpdateMany(episodes); - } - - public List<Episode> EpisodesBetweenDates(DateTime start, DateTime end, bool includeUnmonitored) - { - var episodes = _episodeRepository.EpisodesBetweenDates(start.ToUniversalTime(), end.ToUniversalTime(), includeUnmonitored); - - return episodes; - } - - public void InsertMany(List<Episode> episodes) - { - _episodeRepository.InsertMany(episodes); - } - - public void UpdateMany(List<Episode> episodes) - { - _episodeRepository.UpdateMany(episodes); - } - - public void DeleteMany(List<Episode> episodes) - { - _episodeRepository.DeleteMany(episodes); - } - - public void HandleAsync(SeriesDeletedEvent message) - { - var episodes = GetEpisodeBySeries(message.Series.Id); - _episodeRepository.DeleteMany(episodes); - } - - public void Handle(EpisodeFileDeletedEvent message) - { - foreach (var episode in GetEpisodesByFileId(message.EpisodeFile.Id)) - { - _logger.Debug("Detaching episode {0} from file.", episode.Id); - episode.EpisodeFileId = 0; - - if (message.Reason != DeleteMediaFileReason.Upgrade && _configService.AutoUnmonitorPreviouslyDownloadedEpisodes) - { - episode.Monitored = false; - } - - UpdateEpisode(episode); - } - } - - public void Handle(EpisodeFileAddedEvent message) - { - foreach (var episode in message.EpisodeFile.Episodes.Value) - { - _episodeRepository.SetFileId(episode.Id, message.EpisodeFile.Id); - _logger.Debug("Linking [{0}] > [{1}]", message.EpisodeFile.RelativePath, episode); - } - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/Tv/Events/EpisodeInfoRefreshedEvent.cs b/src/NzbDrone.Core/Tv/Events/EpisodeInfoRefreshedEvent.cs deleted file mode 100644 index 4eded3b79..000000000 --- a/src/NzbDrone.Core/Tv/Events/EpisodeInfoRefreshedEvent.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Collections.Generic; -using System.Collections.ObjectModel; -using NzbDrone.Common.Messaging; - -namespace NzbDrone.Core.Tv.Events -{ - public class EpisodeInfoRefreshedEvent : IEvent - { - public Series Series { get; set; } - public ReadOnlyCollection<Episode> Added { get; private set; } - public ReadOnlyCollection<Episode> Updated { get; private set; } - - public EpisodeInfoRefreshedEvent(Series series, IList<Episode> added, IList<Episode> updated) - { - Series = series; - Added = new ReadOnlyCollection<Episode>(added); - Updated = new ReadOnlyCollection<Episode>(updated); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/Tv/Events/SeriesAddedEvent.cs b/src/NzbDrone.Core/Tv/Events/SeriesAddedEvent.cs deleted file mode 100644 index 1a18c2b8d..000000000 --- a/src/NzbDrone.Core/Tv/Events/SeriesAddedEvent.cs +++ /dev/null @@ -1,14 +0,0 @@ -using NzbDrone.Common.Messaging; - -namespace NzbDrone.Core.Tv.Events -{ - public class SeriesAddedEvent : IEvent - { - public Series Series { get; private set; } - - public SeriesAddedEvent(Series series) - { - Series = series; - } - } -} diff --git a/src/NzbDrone.Core/Tv/Events/SeriesDeletedEvent.cs b/src/NzbDrone.Core/Tv/Events/SeriesDeletedEvent.cs deleted file mode 100644 index e04d8f60e..000000000 --- a/src/NzbDrone.Core/Tv/Events/SeriesDeletedEvent.cs +++ /dev/null @@ -1,16 +0,0 @@ -using NzbDrone.Common.Messaging; - -namespace NzbDrone.Core.Tv.Events -{ - public class SeriesDeletedEvent : IEvent - { - public Series Series { get; private set; } - public bool DeleteFiles { get; private set; } - - public SeriesDeletedEvent(Series series, bool deleteFiles) - { - Series = series; - DeleteFiles = deleteFiles; - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/Tv/Events/SeriesEditedEvent.cs b/src/NzbDrone.Core/Tv/Events/SeriesEditedEvent.cs deleted file mode 100644 index a37a6c902..000000000 --- a/src/NzbDrone.Core/Tv/Events/SeriesEditedEvent.cs +++ /dev/null @@ -1,16 +0,0 @@ -using NzbDrone.Common.Messaging; - -namespace NzbDrone.Core.Tv.Events -{ - public class SeriesEditedEvent : IEvent - { - public Series Series { get; private set; } - public Series OldSeries { get; private set; } - - public SeriesEditedEvent(Series series, Series oldSeries) - { - Series = series; - OldSeries = oldSeries; - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/Tv/Events/SeriesMovedEvent.cs b/src/NzbDrone.Core/Tv/Events/SeriesMovedEvent.cs deleted file mode 100644 index 72c48c269..000000000 --- a/src/NzbDrone.Core/Tv/Events/SeriesMovedEvent.cs +++ /dev/null @@ -1,18 +0,0 @@ -using NzbDrone.Common.Messaging; - -namespace NzbDrone.Core.Tv.Events -{ - public class SeriesMovedEvent : IEvent - { - public Series Series { get; set; } - public string SourcePath { get; set; } - public string DestinationPath { get; set; } - - public SeriesMovedEvent(Series series, string sourcePath, string destinationPath) - { - Series = series; - SourcePath = sourcePath; - DestinationPath = destinationPath; - } - } -} diff --git a/src/NzbDrone.Core/Tv/Events/SeriesRefreshStartingEvent.cs b/src/NzbDrone.Core/Tv/Events/SeriesRefreshStartingEvent.cs deleted file mode 100644 index e330b0004..000000000 --- a/src/NzbDrone.Core/Tv/Events/SeriesRefreshStartingEvent.cs +++ /dev/null @@ -1,14 +0,0 @@ -using NzbDrone.Common.Messaging; - -namespace NzbDrone.Core.Tv.Events -{ - public class SeriesRefreshStartingEvent : IEvent - { - public bool ManualTrigger { get; set; } - - public SeriesRefreshStartingEvent(bool manualTrigger) - { - ManualTrigger = manualTrigger; - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/Tv/Events/SeriesUpdatedEvent.cs b/src/NzbDrone.Core/Tv/Events/SeriesUpdatedEvent.cs deleted file mode 100644 index 8dafe0563..000000000 --- a/src/NzbDrone.Core/Tv/Events/SeriesUpdatedEvent.cs +++ /dev/null @@ -1,14 +0,0 @@ -using NzbDrone.Common.Messaging; - -namespace NzbDrone.Core.Tv.Events -{ - public class SeriesUpdatedEvent : IEvent - { - public Series Series { get; private set; } - - public SeriesUpdatedEvent(Series series) - { - Series = series; - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/Tv/MonitoringOptions.cs b/src/NzbDrone.Core/Tv/MonitoringOptions.cs deleted file mode 100644 index 2cda68b1c..000000000 --- a/src/NzbDrone.Core/Tv/MonitoringOptions.cs +++ /dev/null @@ -1,10 +0,0 @@ -using NzbDrone.Core.Datastore; - -namespace NzbDrone.Core.Tv -{ - public class MonitoringOptions : IEmbeddedDocument - { - public bool IgnoreEpisodesWithFiles { get; set; } - public bool IgnoreEpisodesWithoutFiles { get; set; } - } -} diff --git a/src/NzbDrone.Core/Tv/MoveSeriesService.cs b/src/NzbDrone.Core/Tv/MoveSeriesService.cs deleted file mode 100644 index a6ffb0578..000000000 --- a/src/NzbDrone.Core/Tv/MoveSeriesService.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System.IO; -using NLog; -using NzbDrone.Common.Disk; -using NzbDrone.Common.Extensions; -using NzbDrone.Common.Instrumentation.Extensions; -using NzbDrone.Core.Messaging.Commands; -using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Organizer; -using NzbDrone.Core.Tv.Commands; -using NzbDrone.Core.Tv.Events; - -namespace NzbDrone.Core.Tv -{ - public class MoveSeriesService : IExecute<MoveSeriesCommand> - { - private readonly ISeriesService _seriesService; - private readonly IBuildFileNames _filenameBuilder; - private readonly IDiskTransferService _diskTransferService; - private readonly IEventAggregator _eventAggregator; - private readonly Logger _logger; - - public MoveSeriesService(ISeriesService seriesService, - IBuildFileNames filenameBuilder, - IDiskTransferService diskTransferService, - IEventAggregator eventAggregator, - Logger logger) - { - _seriesService = seriesService; - _filenameBuilder = filenameBuilder; - _diskTransferService = diskTransferService; - _eventAggregator = eventAggregator; - _logger = logger; - } - - public void Execute(MoveSeriesCommand message) - { - var series = _seriesService.GetSeries(message.SeriesId); - var source = message.SourcePath; - var destination = message.DestinationPath; - - if (!message.DestinationRootFolder.IsNullOrWhiteSpace()) - { - _logger.Debug("Buiding destination path using root folder: {0} and the series title", message.DestinationRootFolder); - destination = Path.Combine(message.DestinationRootFolder, _filenameBuilder.GetSeriesFolder(series)); - } - - _logger.ProgressInfo("Moving {0} from '{1}' to '{2}'", series.Title, source, destination); - - //TODO: Move to transactional disk operations - try - { - _diskTransferService.TransferFolder(source, destination, TransferMode.Move); - } - catch (IOException ex) - { - _logger.Error(ex, "Unable to move series from '{0}' to '{1}'", source, destination); - throw; - } - - _logger.ProgressInfo("{0} moved successfully to {1}", series.Title, series.Path); - - //Update the series path to the new path - series.Path = destination; - series = _seriesService.UpdateSeries(series); - - _eventAggregator.PublishEvent(new SeriesMovedEvent(series, source, destination)); - } - } -} diff --git a/src/NzbDrone.Core/Tv/Ratings.cs b/src/NzbDrone.Core/Tv/Ratings.cs deleted file mode 100644 index 6c66fbb7e..000000000 --- a/src/NzbDrone.Core/Tv/Ratings.cs +++ /dev/null @@ -1,10 +0,0 @@ -using NzbDrone.Core.Datastore; - -namespace NzbDrone.Core.Tv -{ - public class Ratings : IEmbeddedDocument - { - public int Votes { get; set; } - public decimal Value { get; set; } - } -} diff --git a/src/NzbDrone.Core/Tv/RefreshEpisodeService.cs b/src/NzbDrone.Core/Tv/RefreshEpisodeService.cs deleted file mode 100644 index b81292219..000000000 --- a/src/NzbDrone.Core/Tv/RefreshEpisodeService.cs +++ /dev/null @@ -1,199 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using NLog; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Tv.Events; - -namespace NzbDrone.Core.Tv -{ - public interface IRefreshEpisodeService - { - void RefreshEpisodeInfo(Series series, IEnumerable<Episode> remoteEpisodes); - } - - public class RefreshEpisodeService : IRefreshEpisodeService - { - private readonly IEpisodeService _episodeService; - private readonly IEventAggregator _eventAggregator; - private readonly Logger _logger; - - public RefreshEpisodeService(IEpisodeService episodeService, IEventAggregator eventAggregator, Logger logger) - { - _episodeService = episodeService; - _eventAggregator = eventAggregator; - _logger = logger; - } - - public void RefreshEpisodeInfo(Series series, IEnumerable<Episode> remoteEpisodes) - { - _logger.Info("Starting episode info refresh for: {0}", series); - var successCount = 0; - var failCount = 0; - - var existingEpisodes = _episodeService.GetEpisodeBySeries(series.Id); - var seasons = series.Seasons; - - var updateList = new List<Episode>(); - var newList = new List<Episode>(); - var dupeFreeRemoteEpisodes = remoteEpisodes.DistinctBy(m => new { m.SeasonNumber, m.EpisodeNumber }).ToList(); - - if (series.SeriesType == SeriesTypes.Anime) - { - dupeFreeRemoteEpisodes = MapAbsoluteEpisodeNumbers(dupeFreeRemoteEpisodes); - } - - foreach (var episode in OrderEpisodes(series, dupeFreeRemoteEpisodes)) - { - try - { - var episodeToUpdate = GetEpisodeToUpdate(series, episode, existingEpisodes); - - if (episodeToUpdate != null) - { - existingEpisodes.Remove(episodeToUpdate); - updateList.Add(episodeToUpdate); - } - else - { - episodeToUpdate = new Episode(); - episodeToUpdate.Monitored = GetMonitoredStatus(episode, seasons); - newList.Add(episodeToUpdate); - } - - episodeToUpdate.SeriesId = series.Id; - episodeToUpdate.EpisodeNumber = episode.EpisodeNumber; - episodeToUpdate.SeasonNumber = episode.SeasonNumber; - episodeToUpdate.AbsoluteEpisodeNumber = episode.AbsoluteEpisodeNumber; - episodeToUpdate.Title = episode.Title ?? "TBA"; - episodeToUpdate.Overview = episode.Overview; - episodeToUpdate.AirDate = episode.AirDate; - episodeToUpdate.AirDateUtc = episode.AirDateUtc; - episodeToUpdate.Ratings = episode.Ratings; - episodeToUpdate.Images = episode.Images; - - successCount++; - } - catch (Exception e) - { - _logger.Fatal(e, "An error has occurred while updating episode info for series {0}. {1}", series, episode); - failCount++; - } - } - - var allEpisodes = new List<Episode>(); - allEpisodes.AddRange(newList); - allEpisodes.AddRange(updateList); - - AdjustMultiEpisodeAirTime(series, allEpisodes); - AdjustDirectToDvdAirDate(series, allEpisodes); - - _episodeService.DeleteMany(existingEpisodes); - _episodeService.UpdateMany(updateList); - _episodeService.InsertMany(newList); - - _eventAggregator.PublishEvent(new EpisodeInfoRefreshedEvent(series, newList, updateList)); - - if (failCount != 0) - { - _logger.Info("Finished episode refresh for series: {0}. Successful: {1} - Failed: {2} ", - series.Title, successCount, failCount); - } - else - { - _logger.Info("Finished episode refresh for series: {0}.", series); - } - } - - private bool GetMonitoredStatus(Episode episode, IEnumerable<Season> seasons) - { - if (episode.EpisodeNumber == 0 && episode.SeasonNumber != 1) - { - return false; - } - - var season = seasons.SingleOrDefault(c => c.SeasonNumber == episode.SeasonNumber); - return season == null || season.Monitored; - } - - private void AdjustMultiEpisodeAirTime(Series series, IEnumerable<Episode> allEpisodes) - { - if (series.Network == "Netflix") - { - _logger.Debug("Not adjusting episode air times for Netflix series {0}", series.Title); - return; - } - - var groups = allEpisodes.Where(c => c.AirDateUtc.HasValue) - .GroupBy(e => new { e.SeasonNumber, e.AirDate }) - .Where(g => g.Count() > 1) - .ToList(); - - foreach (var group in groups) - { - var episodeCount = 0; - - foreach (var episode in group.OrderBy(e => e.SeasonNumber).ThenBy(e => e.EpisodeNumber)) - { - episode.AirDateUtc = episode.AirDateUtc.Value.AddMinutes(series.Runtime * episodeCount); - episodeCount++; - } - } - } - - private void AdjustDirectToDvdAirDate(Series series, IEnumerable<Episode> allEpisodes) - { - if (series.Status == SeriesStatusType.Ended && allEpisodes.All(v => !v.AirDateUtc.HasValue) && series.FirstAired.HasValue) - { - foreach (var episode in allEpisodes) - { - episode.AirDateUtc = series.FirstAired; - episode.AirDate = series.FirstAired.Value.ToString("yyyy-MM-dd"); - } - } - } - - private List<Episode> MapAbsoluteEpisodeNumbers(List<Episode> remoteEpisodes) - { - //Return all episodes with no abs number, but distinct for those with abs number - return remoteEpisodes.Where(e => e.AbsoluteEpisodeNumber.HasValue) - .OrderByDescending(e => e.SeasonNumber) - .DistinctBy(e => e.AbsoluteEpisodeNumber.Value) - .Concat(remoteEpisodes.Where(e => !e.AbsoluteEpisodeNumber.HasValue)) - .ToList(); - } - - private Episode GetEpisodeToUpdate(Series series, Episode episode, List<Episode> existingEpisodes) - { - if (series.SeriesType == SeriesTypes.Anime) - { - if (episode.AbsoluteEpisodeNumber.HasValue) - { - var matchingEpisode = existingEpisodes.FirstOrDefault(e => e.AbsoluteEpisodeNumber == episode.AbsoluteEpisodeNumber); - - if (matchingEpisode != null) return matchingEpisode; - } - } - - return existingEpisodes.FirstOrDefault(e => e.SeasonNumber == episode.SeasonNumber && e.EpisodeNumber == episode.EpisodeNumber); - } - - private IEnumerable<Episode> OrderEpisodes(Series series, List<Episode> episodes) - { - if (series.SeriesType == SeriesTypes.Anime) - { - var withAbs = episodes.Where(e => e.AbsoluteEpisodeNumber.HasValue) - .OrderBy(e => e.AbsoluteEpisodeNumber); - - var withoutAbs = episodes.Where(e => !e.AbsoluteEpisodeNumber.HasValue) - .OrderBy(e => e.SeasonNumber) - .ThenBy(e => e.EpisodeNumber); - - return withAbs.Concat(withoutAbs); - } - - return episodes.OrderBy(e => e.SeasonNumber).ThenBy(e => e.EpisodeNumber); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/Tv/RefreshSeriesService.cs b/src/NzbDrone.Core/Tv/RefreshSeriesService.cs deleted file mode 100644 index f177b5857..000000000 --- a/src/NzbDrone.Core/Tv/RefreshSeriesService.cs +++ /dev/null @@ -1,190 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using NLog; -using NzbDrone.Common.Extensions; -using NzbDrone.Common.Instrumentation.Extensions; -using NzbDrone.Core.DataAugmentation.DailySeries; -using NzbDrone.Core.Exceptions; -using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.Messaging.Commands; -using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.MetadataSource; -using NzbDrone.Core.Tv.Commands; -using NzbDrone.Core.Tv.Events; - -namespace NzbDrone.Core.Tv -{ - public class RefreshSeriesService : IExecute<RefreshSeriesCommand> - { - private readonly IProvideSeriesInfo _seriesInfo; - private readonly ISeriesService _seriesService; - private readonly IRefreshEpisodeService _refreshEpisodeService; - private readonly IEventAggregator _eventAggregator; - private readonly IDailySeriesService _dailySeriesService; - private readonly IDiskScanService _diskScanService; - private readonly ICheckIfSeriesShouldBeRefreshed _checkIfSeriesShouldBeRefreshed; - private readonly Logger _logger; - - public RefreshSeriesService(IProvideSeriesInfo seriesInfo, - ISeriesService seriesService, - IRefreshEpisodeService refreshEpisodeService, - IEventAggregator eventAggregator, - IDailySeriesService dailySeriesService, - IDiskScanService diskScanService, - ICheckIfSeriesShouldBeRefreshed checkIfSeriesShouldBeRefreshed, - Logger logger) - { - _seriesInfo = seriesInfo; - _seriesService = seriesService; - _refreshEpisodeService = refreshEpisodeService; - _eventAggregator = eventAggregator; - _dailySeriesService = dailySeriesService; - _diskScanService = diskScanService; - _checkIfSeriesShouldBeRefreshed = checkIfSeriesShouldBeRefreshed; - _logger = logger; - } - - private void RefreshSeriesInfo(Series series) - { - _logger.ProgressInfo("Updating Info for {0}", series.Title); - - Tuple<Series, List<Episode>> tuple; - - try - { - tuple = _seriesInfo.GetSeriesInfo(series.TvdbId); - } - catch (SeriesNotFoundException) - { - _logger.Error("Series '{0}' (tvdbid {1}) was not found, it may have been removed from TheTVDB.", series.Title, series.TvdbId); - return; - } - - var seriesInfo = tuple.Item1; - - if (series.TvdbId != seriesInfo.TvdbId) - { - _logger.Warn("Series '{0}' (tvdbid {1}) was replaced with '{2}' (tvdbid {3}), because the original was a duplicate.", series.Title, series.TvdbId, seriesInfo.Title, seriesInfo.TvdbId); - series.TvdbId = seriesInfo.TvdbId; - } - - series.Title = seriesInfo.Title; - series.TitleSlug = seriesInfo.TitleSlug; - series.TvRageId = seriesInfo.TvRageId; - series.TvMazeId = seriesInfo.TvMazeId; - series.ImdbId = seriesInfo.ImdbId; - series.AirTime = seriesInfo.AirTime; - series.Overview = seriesInfo.Overview; - series.Status = seriesInfo.Status; - series.CleanTitle = seriesInfo.CleanTitle; - series.SortTitle = seriesInfo.SortTitle; - series.LastInfoSync = DateTime.UtcNow; - series.Runtime = seriesInfo.Runtime; - series.Images = seriesInfo.Images; - series.Network = seriesInfo.Network; - series.FirstAired = seriesInfo.FirstAired; - series.Ratings = seriesInfo.Ratings; - series.Actors = seriesInfo.Actors; - series.Genres = seriesInfo.Genres; - series.Certification = seriesInfo.Certification; - - if (_dailySeriesService.IsDailySeries(series.TvdbId)) - { - series.SeriesType = SeriesTypes.Daily; - } - - try - { - series.Path = new DirectoryInfo(series.Path).FullName; - series.Path = series.Path.GetActualCasing(); - } - catch (Exception e) - { - _logger.Warn(e, "Couldn't update series path for " + series.Path); - } - - series.Seasons = UpdateSeasons(series, seriesInfo); - - _seriesService.UpdateSeries(series); - _refreshEpisodeService.RefreshEpisodeInfo(series, tuple.Item2); - - _logger.Debug("Finished series refresh for {0}", series.Title); - _eventAggregator.PublishEvent(new SeriesUpdatedEvent(series)); - } - - private List<Season> UpdateSeasons(Series series, Series seriesInfo) - { - var seasons = seriesInfo.Seasons.DistinctBy(s => s.SeasonNumber).ToList(); - - foreach (var season in seasons) - { - var existingSeason = series.Seasons.FirstOrDefault(s => s.SeasonNumber == season.SeasonNumber); - - //Todo: Should this should use the previous season's monitored state? - if (existingSeason == null) - { - if (season.SeasonNumber == 0) - { - season.Monitored = false; - continue; - } - - _logger.Debug("New season ({0}) for series: [{1}] {2}, setting monitored to true", season.SeasonNumber, series.TvdbId, series.Title); - season.Monitored = true; - } - - else - { - season.Monitored = existingSeason.Monitored; - } - } - - return seasons; - } - - public void Execute(RefreshSeriesCommand message) - { - _eventAggregator.PublishEvent(new SeriesRefreshStartingEvent(message.Trigger == CommandTrigger.Manual)); - - if (message.SeriesId.HasValue) - { - var series = _seriesService.GetSeries(message.SeriesId.Value); - RefreshSeriesInfo(series); - } - else - { - var allSeries = _seriesService.GetAllSeries().OrderBy(c => c.SortTitle).ToList(); - - foreach (var series in allSeries) - { - if (message.Trigger == CommandTrigger.Manual || _checkIfSeriesShouldBeRefreshed.ShouldRefresh(series)) - { - try - { - RefreshSeriesInfo(series); - } - catch (Exception e) - { - _logger.Error(e, "Couldn't refresh info for {0}", 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); - } - } - } - } - } - } -} diff --git a/src/NzbDrone.Core/Tv/Season.cs b/src/NzbDrone.Core/Tv/Season.cs deleted file mode 100644 index e233c734f..000000000 --- a/src/NzbDrone.Core/Tv/Season.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Core.Datastore; - -namespace NzbDrone.Core.Tv -{ - public class Season : IEmbeddedDocument - { - public Season() - { - Images = new List<MediaCover.MediaCover>(); - } - - public int SeasonNumber { get; set; } - public bool Monitored { get; set; } - public List<MediaCover.MediaCover> Images { get; set; } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/Tv/Series.cs b/src/NzbDrone.Core/Tv/Series.cs deleted file mode 100644 index a3fdb986f..000000000 --- a/src/NzbDrone.Core/Tv/Series.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System; -using System.Collections.Generic; -using Marr.Data; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Datastore; -using NzbDrone.Core.Profiles; - -namespace NzbDrone.Core.Tv -{ - public class Series : ModelBase - { - public Series() - { - Images = new List<MediaCover.MediaCover>(); - Genres = new List<string>(); - Actors = new List<Actor>(); - Seasons = new List<Season>(); - Tags = new HashSet<int>(); - } - - public int TvdbId { get; set; } - public int TvRageId { get; set; } - public int TvMazeId { get; set; } - public string ImdbId { get; set; } - public string Title { get; set; } - public string CleanTitle { get; set; } - public string SortTitle { get; set; } - public SeriesStatusType Status { get; set; } - public string Overview { get; set; } - public string AirTime { get; set; } - public bool Monitored { get; set; } - public int ProfileId { get; set; } - public bool SeasonFolder { get; set; } - public DateTime? LastInfoSync { get; set; } - public int Runtime { get; set; } - public List<MediaCover.MediaCover> Images { get; set; } - public SeriesTypes SeriesType { get; set; } - public string Network { get; set; } - public bool UseSceneNumbering { get; set; } - public string TitleSlug { get; set; } - public string Path { get; set; } - public int Year { get; set; } - public Ratings Ratings { get; set; } - public List<string> Genres { get; set; } - public List<Actor> Actors { get; set; } - public string Certification { get; set; } - public string RootFolderPath { get; set; } - public DateTime Added { get; set; } - public DateTime? FirstAired { get; set; } - public LazyLoaded<Profile> Profile { get; set; } - - public List<Season> Seasons { get; set; } - public HashSet<int> Tags { get; set; } - public AddSeriesOptions AddOptions { get; set; } - - public override string ToString() - { - return string.Format("[{0}][{1}]", TvdbId, Title.NullSafe()); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/Tv/SeriesAddedHandler.cs b/src/NzbDrone.Core/Tv/SeriesAddedHandler.cs deleted file mode 100644 index 2e7ee8005..000000000 --- a/src/NzbDrone.Core/Tv/SeriesAddedHandler.cs +++ /dev/null @@ -1,22 +0,0 @@ -using NzbDrone.Core.Messaging.Commands; -using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Tv.Commands; -using NzbDrone.Core.Tv.Events; - -namespace NzbDrone.Core.Tv -{ - public class SeriesAddedHandler : IHandle<SeriesAddedEvent> - { - private readonly IManageCommandQueue _commandQueueManager; - - public SeriesAddedHandler(IManageCommandQueue commandQueueManager) - { - _commandQueueManager = commandQueueManager; - } - - public void Handle(SeriesAddedEvent message) - { - _commandQueueManager.Push(new RefreshSeriesCommand(message.Series.Id)); - } - } -} diff --git a/src/NzbDrone.Core/Tv/SeriesEditedService.cs b/src/NzbDrone.Core/Tv/SeriesEditedService.cs deleted file mode 100644 index 063537f18..000000000 --- a/src/NzbDrone.Core/Tv/SeriesEditedService.cs +++ /dev/null @@ -1,25 +0,0 @@ -using NzbDrone.Core.Messaging.Commands; -using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Tv.Commands; -using NzbDrone.Core.Tv.Events; - -namespace NzbDrone.Core.Tv -{ - public class SeriesEditedService : IHandle<SeriesEditedEvent> - { - private readonly IManageCommandQueue _commandQueueManager; - - public SeriesEditedService(IManageCommandQueue commandQueueManager) - { - _commandQueueManager = commandQueueManager; - } - - public void Handle(SeriesEditedEvent message) - { - if (message.Series.SeriesType != message.OldSeries.SeriesType) - { - _commandQueueManager.Push(new RefreshSeriesCommand(message.Series.Id)); - } - } - } -} diff --git a/src/NzbDrone.Core/Tv/SeriesRepository.cs b/src/NzbDrone.Core/Tv/SeriesRepository.cs deleted file mode 100644 index d5bc343ff..000000000 --- a/src/NzbDrone.Core/Tv/SeriesRepository.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.Linq; -using NzbDrone.Core.Datastore; -using NzbDrone.Core.Messaging.Events; - - -namespace NzbDrone.Core.Tv -{ - public interface ISeriesRepository : IBasicRepository<Series> - { - bool SeriesPathExists(string path); - Series FindByTitle(string cleanTitle); - Series FindByTitle(string cleanTitle, int year); - Series FindByTvdbId(int tvdbId); - Series FindByTvRageId(int tvRageId); - } - - public class SeriesRepository : BasicRepository<Series>, ISeriesRepository - { - public SeriesRepository(IMainDatabase database, IEventAggregator eventAggregator) - : base(database, eventAggregator) - { - } - - public bool SeriesPathExists(string path) - { - return Query.Where(c => c.Path == path).Any(); - } - - public Series FindByTitle(string cleanTitle) - { - cleanTitle = cleanTitle.ToLowerInvariant(); - - return Query.Where(s => s.CleanTitle == cleanTitle) - .SingleOrDefault(); - } - - public Series FindByTitle(string cleanTitle, int year) - { - cleanTitle = cleanTitle.ToLowerInvariant(); - - return Query.Where(s => s.CleanTitle == cleanTitle) - .AndWhere(s => s.Year == year) - .SingleOrDefault(); - } - - public Series FindByTvdbId(int tvdbId) - { - return Query.Where(s => s.TvdbId == tvdbId).SingleOrDefault(); - } - - public Series FindByTvRageId(int tvRageId) - { - return Query.Where(s => s.TvRageId == tvRageId).SingleOrDefault(); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/Tv/SeriesScannedHandler.cs b/src/NzbDrone.Core/Tv/SeriesScannedHandler.cs deleted file mode 100644 index 9d208c764..000000000 --- a/src/NzbDrone.Core/Tv/SeriesScannedHandler.cs +++ /dev/null @@ -1,62 +0,0 @@ -using NLog; -using NzbDrone.Core.IndexerSearch; -using NzbDrone.Core.MediaFiles.Events; -using NzbDrone.Core.Messaging.Commands; -using NzbDrone.Core.Messaging.Events; - -namespace NzbDrone.Core.Tv -{ - public class SeriesScannedHandler : IHandle<SeriesScannedEvent>, - IHandle<SeriesScanSkippedEvent> - { - private readonly IEpisodeMonitoredService _episodeMonitoredService; - private readonly ISeriesService _seriesService; - private readonly IManageCommandQueue _commandQueueManager; - private readonly IEpisodeAddedService _episodeAddedService; - - private readonly Logger _logger; - - public SeriesScannedHandler(IEpisodeMonitoredService episodeMonitoredService, - ISeriesService seriesService, - IManageCommandQueue commandQueueManager, - IEpisodeAddedService episodeAddedService, - Logger logger) - { - _episodeMonitoredService = episodeMonitoredService; - _seriesService = seriesService; - _commandQueueManager = commandQueueManager; - _episodeAddedService = episodeAddedService; - _logger = logger; - } - - private void HandleScanEvents(Series series) - { - if (series.AddOptions == null) - { - _episodeAddedService.SearchForRecentlyAdded(series.Id); - return; - } - - _logger.Info("[{0}] was recently added, performing post-add actions", series.Title); - _episodeMonitoredService.SetEpisodeMonitoredStatus(series, series.AddOptions); - - if (series.AddOptions.SearchForMissingEpisodes) - { - _commandQueueManager.Push(new MissingEpisodeSearchCommand(series.Id)); - } - - series.AddOptions = null; - _seriesService.RemoveAddOptions(series); - } - - public void Handle(SeriesScannedEvent message) - { - HandleScanEvents(message.Series); - } - - public void Handle(SeriesScanSkippedEvent message) - { - HandleScanEvents(message.Series); - } - } -} diff --git a/src/NzbDrone.Core/Tv/SeriesService.cs b/src/NzbDrone.Core/Tv/SeriesService.cs deleted file mode 100644 index 941284407..000000000 --- a/src/NzbDrone.Core/Tv/SeriesService.cs +++ /dev/null @@ -1,226 +0,0 @@ -using System; -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; -using NzbDrone.Core.Tv.Events; - -namespace NzbDrone.Core.Tv -{ - public interface ISeriesService - { - Series GetSeries(int seriesId); - List<Series> GetSeries(IEnumerable<int> seriesIds); - Series AddSeries(Series newSeries); - Series FindByTvdbId(int tvdbId); - Series FindByTvRageId(int tvRageId); - Series FindByTitle(string title); - Series FindByTitle(string title, int year); - Series FindByTitleInexact(string title); - void DeleteSeries(int seriesId, bool deleteFiles); - List<Series> GetAllSeries(); - Series UpdateSeries(Series series); - List<Series> UpdateSeries(List<Series> series); - bool SeriesPathExists(string folder); - void RemoveAddOptions(Series series); - } - - public class SeriesService : ISeriesService - { - 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; - } - - public Series GetSeries(int seriesId) - { - return _seriesRepository.Get(seriesId); - } - - public List<Series> GetSeries(IEnumerable<int> seriesIds) - { - return _seriesRepository.Get(seriesIds).ToList(); - } - - public Series AddSeries(Series newSeries) - { - Ensure.That(newSeries, () => newSeries).IsNotNull(); - - if (string.IsNullOrWhiteSpace(newSeries.Path)) - { - var folderName = _fileNameBuilder.GetSeriesFolder(newSeries); - newSeries.Path = Path.Combine(newSeries.RootFolderPath, folderName); - } - - _logger.Info("Adding Series {0} Path: [{1}]", newSeries, newSeries.Path); - - newSeries.CleanTitle = newSeries.Title.CleanSeriesTitle(); - newSeries.SortTitle = SeriesTitleNormalizer.Normalize(newSeries.Title, newSeries.TvdbId); - newSeries.Added = DateTime.UtcNow; - - _seriesRepository.Insert(newSeries); - _eventAggregator.PublishEvent(new SeriesAddedEvent(GetSeries(newSeries.Id))); - - return newSeries; - } - - public Series FindByTvdbId(int tvRageId) - { - return _seriesRepository.FindByTvdbId(tvRageId); - } - - public Series FindByTvRageId(int tvRageId) - { - return _seriesRepository.FindByTvRageId(tvRageId); - } - - public Series FindByTitle(string title) - { - var tvdbId = _sceneMappingService.FindTvdbId(title); - - if (tvdbId.HasValue) - { - return _seriesRepository.FindByTvdbId(tvdbId.Value); - } - - return _seriesRepository.FindByTitle(title.CleanSeriesTitle()); - } - - public Series FindByTitleInexact(string title) - { - // find any series clean title within the provided release title - string cleanTitle = title.CleanSeriesTitle(); - var list = _seriesRepository.All().Where(s => cleanTitle.Contains(s.CleanTitle)).ToList(); - if (!list.Any()) - { - // no series matched - return null; - } - if (list.Count == 1) - { - // 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 = - list.Select(series => new - { - position = cleanTitle.IndexOf(series.CleanTitle), - length = series.CleanTitle.Length, - series = series - }) - .Where(s => (s.position>=0)) - .ToList() - .OrderBy(s => s.position) - .ThenByDescending(s => s.length) - .ToList(); - - // get the leftmost series that is the longest - // series are usually the first thing in release title, so we select the leftmost and longest match - var match = query.First().series; - - _logger.Debug("Multiple series matched {0} from title {1}", match.Title, title); - foreach (var entry in list) - { - _logger.Debug("Multiple series match candidate: {0} cleantitle: {1}", entry.Title, entry.CleanTitle); - } - - return match; - } - - public Series FindByTitle(string title, int year) - { - return _seriesRepository.FindByTitle(title.CleanSeriesTitle(), year); - } - - public void DeleteSeries(int seriesId, bool deleteFiles) - { - var series = _seriesRepository.Get(seriesId); - _seriesRepository.Delete(seriesId); - _eventAggregator.PublishEvent(new SeriesDeletedEvent(series, deleteFiles)); - } - - public List<Series> GetAllSeries() - { - return _seriesRepository.All().ToList(); - } - - public Series UpdateSeries(Series series) - { - var storedSeries = GetSeries(series.Id); - - foreach (var season in series.Seasons) - { - var storedSeason = storedSeries.Seasons.SingleOrDefault(s => s.SeasonNumber == season.SeasonNumber); - - if (storedSeason != null && season.Monitored != storedSeason.Monitored) - { - _episodeService.SetEpisodeMonitoredBySeason(series.Id, season.SeasonNumber, season.Monitored); - } - } - - var updatedSeries = _seriesRepository.Update(series); - _eventAggregator.PublishEvent(new SeriesEditedEvent(updatedSeries, storedSeries)); - - return updatedSeries; - } - - public List<Series> UpdateSeries(List<Series> series) - { - _logger.Debug("Updating {0} series", series.Count); - foreach (var s in series) - { - _logger.Trace("Updating: {0}", s.Title); - if (!s.RootFolderPath.IsNullOrWhiteSpace()) - { - var folderName = new DirectoryInfo(s.Path).Name; - s.Path = Path.Combine(s.RootFolderPath, folderName); - _logger.Trace("Changing path for {0} to {1}", s.Title, s.Path); - } - - else - { - _logger.Trace("Not changing path for: {0}", s.Title); - } - } - - _seriesRepository.UpdateMany(series); - _logger.Debug("{0} series updated", series.Count); - - return series; - } - - public bool SeriesPathExists(string folder) - { - return _seriesRepository.SeriesPathExists(folder); - } - - public void RemoveAddOptions(Series series) - { - _seriesRepository.SetFields(series, s => s.AddOptions); - } - } -} diff --git a/src/NzbDrone.Core/Tv/SeriesStatusType.cs b/src/NzbDrone.Core/Tv/SeriesStatusType.cs deleted file mode 100644 index acc9fbf81..000000000 --- a/src/NzbDrone.Core/Tv/SeriesStatusType.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace NzbDrone.Core.Tv -{ - public enum SeriesStatusType - { - Continuing = 0, - Ended = 1 - } -} diff --git a/src/NzbDrone.Core/Tv/SeriesTitleNormalizer.cs b/src/NzbDrone.Core/Tv/SeriesTitleNormalizer.cs deleted file mode 100644 index 9fc2c5933..000000000 --- a/src/NzbDrone.Core/Tv/SeriesTitleNormalizer.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Collections.Generic; - -namespace NzbDrone.Core.Tv -{ - public static class SeriesTitleNormalizer - { - 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"} - }; - - public static string Normalize(string title, int tvdbId) - { - if (PreComputedTitles.ContainsKey(tvdbId)) - { - return PreComputedTitles[tvdbId]; - } - - return Parser.Parser.NormalizeTitle(title).ToLower(); - } - } -} diff --git a/src/NzbDrone.Core/Tv/SeriesTypes.cs b/src/NzbDrone.Core/Tv/SeriesTypes.cs deleted file mode 100644 index 176ff7655..000000000 --- a/src/NzbDrone.Core/Tv/SeriesTypes.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace NzbDrone.Core.Tv -{ - public enum SeriesTypes - { - Standard = 0, - Daily = 1, - Anime = 2, - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/Tv/ShouldRefreshSeries.cs b/src/NzbDrone.Core/Tv/ShouldRefreshSeries.cs deleted file mode 100644 index bbf48cbb8..000000000 --- a/src/NzbDrone.Core/Tv/ShouldRefreshSeries.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using System.Linq; -using NLog; - -namespace NzbDrone.Core.Tv -{ - public interface ICheckIfSeriesShouldBeRefreshed - { - bool ShouldRefresh(Series series); - } - - public class ShouldRefreshSeries : ICheckIfSeriesShouldBeRefreshed - { - private readonly IEpisodeService _episodeService; - private readonly Logger _logger; - - public ShouldRefreshSeries(IEpisodeService episodeService, Logger logger) - { - _episodeService = episodeService; - _logger = logger; - } - - public bool ShouldRefresh(Series series) - { - if (series.LastInfoSync < DateTime.UtcNow.AddDays(-30)) - { - _logger.Trace("Series {0} last updated more than 30 days ago, should refresh.", series.Title); - return true; - } - - if (series.LastInfoSync >= DateTime.UtcNow.AddHours(-6)) - { - _logger.Trace("Series {0} last updated less than 6 hours ago, should not be refreshed.", series.Title); - return false; - } - - if (series.Status == SeriesStatusType.Continuing) - { - _logger.Trace("Series {0} is continuing, should refresh.", series.Title); - return true; - } - - var lastEpisode = _episodeService.GetEpisodeBySeries(series.Id).OrderByDescending(e => e.AirDateUtc).FirstOrDefault(); - - if (lastEpisode != null && lastEpisode.AirDateUtc > DateTime.UtcNow.AddDays(-30)) - { - _logger.Trace("Last episode in {0} aired less than 30 days ago, should refresh.", series.Title); - return true; - } - - _logger.Trace("Series {0} ended long ago, should not be refreshed.", series.Title); - return false; - } - } -} diff --git a/src/NzbDrone.Core/Update/Commands/ApplicationUpdateCommand.cs b/src/NzbDrone.Core/Update/Commands/ApplicationUpdateCommand.cs index 5911a9a13..0ca1d8074 100644 --- a/src/NzbDrone.Core/Update/Commands/ApplicationUpdateCommand.cs +++ b/src/NzbDrone.Core/Update/Commands/ApplicationUpdateCommand.cs @@ -1,11 +1,12 @@ -using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Commands; namespace NzbDrone.Core.Update.Commands { public class ApplicationUpdateCommand : Command { public override bool SendUpdatesToClient => true; + public override bool IsExclusive => 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..54ef9cf8e 100644 --- a/src/NzbDrone.Core/Update/InstallUpdateService.cs +++ b/src/NzbDrone.Core/Update/InstallUpdateService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using NLog; @@ -32,6 +32,7 @@ namespace NzbDrone.Core.Update private readonly IConfigFileProvider _configFileProvider; private readonly IRuntimeInfo _runtimeInfo; private readonly IBackupService _backupService; + private readonly IOsInfo _osInfo; public InstallUpdateService(ICheckUpdateService checkUpdateService, @@ -46,6 +47,7 @@ namespace NzbDrone.Core.Update IConfigFileProvider configFileProvider, IRuntimeInfo runtimeInfo, IBackupService backupService, + IOsInfo osInfo, Logger logger) { if (configFileProvider == null) @@ -64,6 +66,7 @@ namespace NzbDrone.Core.Update _configFileProvider = configFileProvider; _runtimeInfo = runtimeInfo; _backupService = backupService; + _osInfo = osInfo; _logger = logger; } @@ -129,7 +132,7 @@ namespace NzbDrone.Core.Update _diskTransferService.TransferFolder(_appFolderInfo.GetUpdateClientFolder(), updateSandboxFolder, TransferMode.Move, false); _logger.Info("Starting update client {0}", _appFolderInfo.GetUpdateClientExePath()); - _logger.ProgressInfo("Sonarr will restart shortly."); + _logger.ProgressInfo("Lidarr will restart shortly."); _processProvider.Start(_appFolderInfo.GetUpdateClientExePath(), GetUpdaterArgs(updateSandboxFolder)); } @@ -167,7 +170,7 @@ namespace NzbDrone.Core.Update throw new UpdateFailedException("Update Script: '{0}' does not exist", scriptPath); } - _logger.Info("Removing NzbDrone.Update"); + _logger.Info("Removing Lidarr.Update"); _diskProvider.DeleteFolder(_appFolderInfo.GetUpdateClientFolder(), true); _logger.ProgressInfo("Starting update script: {0}", _configFileProvider.UpdateScriptPath); @@ -178,8 +181,9 @@ namespace NzbDrone.Core.Update { var processId = _processProvider.GetCurrentProcess().Id.ToString(); var executingApplication = _runtimeInfo.ExecutingApplication; - - return string.Join(" ", processId, updateSandboxFolder.TrimEnd(Path.DirectorySeparatorChar).WrapInQuotes(), executingApplication.WrapInQuotes(), _startupContext.PreservedArguments); + var args = string.Join(" ", processId, updateSandboxFolder.TrimEnd(Path.DirectorySeparatorChar).WrapInQuotes(), executingApplication.WrapInQuotes(), _startupContext.PreservedArguments); + _logger.Info("Updater Arguments: " + args); + return args; } private void EnsureAppDataSafety() @@ -187,7 +191,7 @@ namespace NzbDrone.Core.Update if (_appFolderInfo.StartUpFolder.IsParentPath(_appFolderInfo.AppDataFolder) || _appFolderInfo.StartUpFolder.PathEquals(_appFolderInfo.AppDataFolder)) { - throw new UpdateFailedException("Your Sonarr configuration '{0}' is being stored in application folder '{1}' which will cause data lost during the upgrade. Please remove any symlinks or redirects before trying again.", _appFolderInfo.AppDataFolder, _appFolderInfo.StartUpFolder); + throw new UpdateFailedException("Your Lidarr configuration '{0}' is being stored in application folder '{1}' which will cause data lost during the upgrade. Please remove any symlinks or redirects before trying again.", _appFolderInfo.AppDataFolder, _appFolderInfo.StartUpFolder); } } @@ -199,19 +203,26 @@ namespace NzbDrone.Core.Update if (latestAvailable == null) { - _logger.ProgressDebug("No update available."); + _logger.ProgressDebug("No update available"); + return; + } + + if (_osInfo.IsDocker) + { + _logger.ProgressDebug("Updating is disabled inside a docker container. Please update the container image."); 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 Lidarr to apply updates"); } catch (UpdateFolderNotWritableException ex) { diff --git a/src/NzbDrone.Core/Update/RecentUpdateProvider.cs b/src/NzbDrone.Core/Update/RecentUpdateProvider.cs index 6fcaf42c2..73bc38119 100644 --- a/src/NzbDrone.Core/Update/RecentUpdateProvider.cs +++ b/src/NzbDrone.Core/Update/RecentUpdateProvider.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Configuration; diff --git a/src/NzbDrone.Core/Update/UpdatePackageProvider.cs b/src/NzbDrone.Core/Update/UpdatePackageProvider.cs index cbcab70dc..3334c2057 100644 --- a/src/NzbDrone.Core/Update/UpdatePackageProvider.cs +++ b/src/NzbDrone.Core/Update/UpdatePackageProvider.cs @@ -18,7 +18,7 @@ namespace NzbDrone.Core.Update private readonly IPlatformInfo _platformInfo; private readonly IHttpRequestBuilderFactory _requestBuilder; - public UpdatePackageProvider(IHttpClient httpClient, ISonarrCloudRequestBuilder requestBuilder, IPlatformInfo platformInfo) + public UpdatePackageProvider(IHttpClient httpClient, ILidarrCloudRequestBuilder requestBuilder, IPlatformInfo platformInfo) { _httpClient = httpClient; _platformInfo = platformInfo; diff --git a/src/NzbDrone.Core/Validation/GuidValidator.cs b/src/NzbDrone.Core/Validation/GuidValidator.cs new file mode 100644 index 000000000..37e9d0a9d --- /dev/null +++ b/src/NzbDrone.Core/Validation/GuidValidator.cs @@ -0,0 +1,20 @@ +using System; +using FluentValidation.Validators; + +namespace NzbDrone.Core.Validation +{ + public class GuidValidator : PropertyValidator + { + public GuidValidator() + : base("String is not a valid Guid") + { + } + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context.PropertyValue == null) return false; + + return Guid.TryParse(context.PropertyValue.ToString(), out Guid guidOutput); + } + } +} diff --git a/src/NzbDrone.Core/Validation/LanguageValidator.cs b/src/NzbDrone.Core/Validation/LanguageValidator.cs deleted file mode 100644 index 9edfc9085..000000000 --- a/src/NzbDrone.Core/Validation/LanguageValidator.cs +++ /dev/null @@ -1,21 +0,0 @@ -using FluentValidation.Validators; - -namespace NzbDrone.Core.Validation -{ - public class LanguageValidator : PropertyValidator - { - public LanguageValidator() - : base("Unknown Language") - { - } - - protected override bool IsValid(PropertyValidatorContext context) - { - if (context.PropertyValue == null) return false; - - if ((int) context.PropertyValue == 0) return false; - - return true; - } - } -} diff --git a/src/NzbDrone.Core/Validation/MetadataProfileExistsValidator.cs b/src/NzbDrone.Core/Validation/MetadataProfileExistsValidator.cs new file mode 100644 index 000000000..82499a304 --- /dev/null +++ b/src/NzbDrone.Core/Validation/MetadataProfileExistsValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation.Validators; +using NzbDrone.Core.Profiles.Metadata; + +namespace NzbDrone.Core.Validation +{ + public class MetadataProfileExistsValidator : PropertyValidator + { + private readonly IMetadataProfileService _profileService; + + public MetadataProfileExistsValidator(IMetadataProfileService profileService) + : base("Metadata profile does not exist") + { + _profileService = profileService; + } + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context.PropertyValue == null) return true; + + return _profileService.Exists((int)context.PropertyValue); + } + } +} diff --git a/src/NzbDrone.Core/Validation/Paths/ArtistAncestorValidator.cs b/src/NzbDrone.Core/Validation/Paths/ArtistAncestorValidator.cs new file mode 100644 index 000000000..d6de1df78 --- /dev/null +++ b/src/NzbDrone.Core/Validation/Paths/ArtistAncestorValidator.cs @@ -0,0 +1,25 @@ +using System.Linq; +using FluentValidation.Validators; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Validation.Paths +{ + public class ArtistAncestorValidator : PropertyValidator + { + private readonly IArtistService _artistService; + + public ArtistAncestorValidator(IArtistService artistService) + : base("Path is an ancestor of an existing artist") + { + _artistService = artistService; + } + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context.PropertyValue == null) return true; + + return !_artistService.GetAllArtists().Any(s => context.PropertyValue.ToString().IsParentPath(s.Path)); + } + } +} diff --git a/src/NzbDrone.Core/Validation/Paths/ArtistExistsValidator.cs b/src/NzbDrone.Core/Validation/Paths/ArtistExistsValidator.cs new file mode 100644 index 000000000..6b8a12c26 --- /dev/null +++ b/src/NzbDrone.Core/Validation/Paths/ArtistExistsValidator.cs @@ -0,0 +1,27 @@ +using FluentValidation.Validators; +using NzbDrone.Core.Music; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Validation.Paths +{ + public class ArtistExistsValidator : PropertyValidator + { + private readonly IArtistService _artistService; + + public ArtistExistsValidator(IArtistService artistService) + : base("This artist has already been added.") + { + _artistService = artistService; + } + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context.PropertyValue == null) return true; + + return (!_artistService.GetAllArtists().Exists(s => s.Metadata.Value.ForeignArtistId == context.PropertyValue.ToString())); + } + } +} diff --git a/src/NzbDrone.Core/Validation/Paths/ArtistPathValidator.cs b/src/NzbDrone.Core/Validation/Paths/ArtistPathValidator.cs new file mode 100644 index 000000000..f901127f3 --- /dev/null +++ b/src/NzbDrone.Core/Validation/Paths/ArtistPathValidator.cs @@ -0,0 +1,31 @@ +using FluentValidation.Validators; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Music; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Validation.Paths +{ + public class ArtistPathValidator : PropertyValidator + { + private readonly IArtistService _artistService; + + public ArtistPathValidator(IArtistService artistService) + : base("Path is already configured for another artist") + { + _artistService = artistService; + } + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context.PropertyValue == null) return true; + + dynamic instance = context.ParentContext.InstanceToValidate; + var instanceId = (int)instance.Id; + + return (!_artistService.GetAllArtists().Exists(s => s.Path.PathEquals(context.PropertyValue.ToString()) && s.Id != instanceId)); + } + } +} diff --git a/src/NzbDrone.Core/Validation/Paths/DroneFactoryValidator.cs b/src/NzbDrone.Core/Validation/Paths/DroneFactoryValidator.cs deleted file mode 100644 index cc2aec19c..000000000 --- a/src/NzbDrone.Core/Validation/Paths/DroneFactoryValidator.cs +++ /dev/null @@ -1,28 +0,0 @@ -using FluentValidation.Validators; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Configuration; - -namespace NzbDrone.Core.Validation.Paths -{ - public class DroneFactoryValidator : PropertyValidator - { - private readonly IConfigService _configService; - - public DroneFactoryValidator(IConfigService configService) - : base("Path is already used for drone factory") - { - _configService = configService; - } - - protected override bool IsValid(PropertyValidatorContext context) - { - if (context.PropertyValue == null) return false; - - var droneFactory = _configService.DownloadedEpisodesFolder; - - if (string.IsNullOrWhiteSpace(droneFactory)) return true; - - return !droneFactory.PathEquals(context.PropertyValue.ToString()); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/Validation/Paths/SeriesAncestorValidator.cs b/src/NzbDrone.Core/Validation/Paths/SeriesAncestorValidator.cs deleted file mode 100644 index c91560873..000000000 --- a/src/NzbDrone.Core/Validation/Paths/SeriesAncestorValidator.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Linq; -using FluentValidation.Validators; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Validation.Paths -{ - public class SeriesAncestorValidator : PropertyValidator - { - private readonly ISeriesService _seriesService; - - public SeriesAncestorValidator(ISeriesService seriesService) - : base("Path is an ancestor of an existing path") - { - _seriesService = seriesService; - } - - protected override bool IsValid(PropertyValidatorContext context) - { - if (context.PropertyValue == null) return true; - - 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/SeriesExistsValidator.cs b/src/NzbDrone.Core/Validation/Paths/SeriesExistsValidator.cs deleted file mode 100644 index 21e4ea629..000000000 --- a/src/NzbDrone.Core/Validation/Paths/SeriesExistsValidator.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using FluentValidation.Validators; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Validation.Paths -{ - public class SeriesExistsValidator : PropertyValidator - { - private readonly ISeriesService _seriesService; - - public SeriesExistsValidator(ISeriesService seriesService) - : base("This series has already been added") - { - _seriesService = seriesService; - } - - protected override bool IsValid(PropertyValidatorContext context) - { - if (context.PropertyValue == null) return true; - - var tvdbId = Convert.ToInt32(context.PropertyValue.ToString()); - - return (!_seriesService.GetAllSeries().Exists(s => s.TvdbId == tvdbId)); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Core/Validation/Paths/SeriesPathValidator.cs b/src/NzbDrone.Core/Validation/Paths/SeriesPathValidator.cs deleted file mode 100644 index fa4d8fa59..000000000 --- a/src/NzbDrone.Core/Validation/Paths/SeriesPathValidator.cs +++ /dev/null @@ -1,27 +0,0 @@ -using FluentValidation.Validators; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Tv; - -namespace NzbDrone.Core.Validation.Paths -{ - public class SeriesPathValidator : PropertyValidator - { - private readonly ISeriesService _seriesService; - - public SeriesPathValidator(ISeriesService seriesService) - : base("Path is already configured for another series") - { - _seriesService = seriesService; - } - - protected override bool IsValid(PropertyValidatorContext context) - { - if (context.PropertyValue == null) return true; - - dynamic instance = context.ParentContext.InstanceToValidate; - var instanceId = (int)instance.Id; - - return (!_seriesService.GetAllSeries().Exists(s => s.Path.PathEquals(context.PropertyValue.ToString()) && s.Id != instanceId)); - } - } -} \ 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..d8f3a7f15 --- /dev/null +++ b/src/NzbDrone.Core/Validation/Paths/SystemFolderValidator.cs @@ -0,0 +1,40 @@ +using FluentValidation.Validators; +using NzbDrone.Common.Disk; +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(); + + foreach (var systemFolder in SystemFolders.GetSystemFolders()) + { + context.MessageFormatter.AppendArgument("systemFolder", systemFolder); + + if (systemFolder.PathEquals(folder)) + { + context.MessageFormatter.AppendArgument("relationship", "set to"); + + return false; + } + + if (systemFolder.IsParentPath(folder)) + { + context.MessageFormatter.AppendArgument("relationship", "child of"); + + return false; + } + } + + return true; + } + } +} diff --git a/src/NzbDrone.Core/Validation/ProfileExistsValidator.cs b/src/NzbDrone.Core/Validation/ProfileExistsValidator.cs index e7ff62b67..b5d9bdb9f 100644 --- a/src/NzbDrone.Core/Validation/ProfileExistsValidator.cs +++ b/src/NzbDrone.Core/Validation/ProfileExistsValidator.cs @@ -1,5 +1,5 @@ -using FluentValidation.Validators; -using NzbDrone.Core.Profiles; +using FluentValidation.Validators; +using NzbDrone.Core.Profiles.Qualities; namespace NzbDrone.Core.Validation { @@ -8,7 +8,7 @@ namespace NzbDrone.Core.Validation private readonly IProfileService _profileService; public ProfileExistsValidator(IProfileService profileService) - : base("Profile does not exist") + : base("Quality Profile does not exist") { _profileService = profileService; } @@ -20,4 +20,4 @@ namespace NzbDrone.Core.Validation return _profileService.Exists((int)context.PropertyValue); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/Validation/RuleBuilderExtensions.cs b/src/NzbDrone.Core/Validation/RuleBuilderExtensions.cs index df1d8056b..fb4f14c32 100644 --- a/src/NzbDrone.Core/Validation/RuleBuilderExtensions.cs +++ b/src/NzbDrone.Core/Validation/RuleBuilderExtensions.cs @@ -1,4 +1,4 @@ -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; using FluentValidation; using FluentValidation.Validators; using NzbDrone.Core.Parser; @@ -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 = "/lidarr") { - 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) @@ -58,14 +58,9 @@ namespace NzbDrone.Core.Validation }); } - public static IRuleBuilderOptions<T, Language> ValidLanguage<T>(this IRuleBuilder<T, Language> ruleBuilder) - { - return ruleBuilder.SetValidator(new LanguageValidator()); - } - public static IRuleBuilderOptions<T, TProp> AsWarning<T, TProp>(this IRuleBuilderOptions<T, TProp> ruleBuilder) { 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 deleted file mode 100644 index b73a9dfee..000000000 --- a/src/NzbDrone.Core/packages.config +++ /dev/null @@ -1,14 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<packages> - <package id="FluentMigrator" version="1.6.2" targetFramework="net40" /> - <package id="FluentMigrator.Runner" version="1.6.2" targetFramework="net40" /> - <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.1" 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" /> - <package id="TinyTwitter" version="1.1.1" targetFramework="net40" /> - <package id="xmlrpcnet" version="2.5.0" targetFramework="net40" /> -</packages> \ No newline at end of file diff --git a/src/NzbDrone.App.Test/ContainerFixture.cs b/src/NzbDrone.Host.Test/ContainerFixture.cs similarity index 86% rename from src/NzbDrone.App.Test/ContainerFixture.cs rename to src/NzbDrone.Host.Test/ContainerFixture.cs index 1064d1c5b..0cd54bd0d 100644 --- a/src/NzbDrone.App.Test/ContainerFixture.cs +++ b/src/NzbDrone.Host.Test/ContainerFixture.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using NUnit.Framework; using NzbDrone.Common; using NzbDrone.Common.EnvironmentInfo; @@ -76,8 +76,8 @@ namespace NzbDrone.App.Test [Test] public void should_return_same_instance_of_singletons_by_different_same_interface() { - var first = _container.ResolveAll<IHandle<EpisodeGrabbedEvent>>().OfType<DownloadMonitoringService>().Single(); - var second = _container.ResolveAll<IHandle<EpisodeGrabbedEvent>>().OfType<DownloadMonitoringService>().Single(); + var first = _container.ResolveAll<IHandle<AlbumGrabbedEvent>>().OfType<DownloadMonitoringService>().Single(); + var second = _container.ResolveAll<IHandle<AlbumGrabbedEvent>>().OfType<DownloadMonitoringService>().Single(); first.Should().BeSameAs(second); } @@ -85,10 +85,10 @@ namespace NzbDrone.App.Test [Test] public void should_return_same_instance_of_singletons_by_different_interfaces() { - var first = _container.ResolveAll<IHandle<EpisodeGrabbedEvent>>().OfType<DownloadMonitoringService>().Single(); + var first = _container.ResolveAll<IHandle<AlbumGrabbedEvent>>().OfType<DownloadMonitoringService>().Single(); var second = (DownloadMonitoringService)_container.Resolve<IExecute<CheckForFinishedDownloadCommand>>(); first.Should().BeSameAs(second); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Host.Test/Lidarr.Host.Test.csproj b/src/NzbDrone.Host.Test/Lidarr.Host.Test.csproj new file mode 100644 index 000000000..8a17b5bb3 --- /dev/null +++ b/src/NzbDrone.Host.Test/Lidarr.Host.Test.csproj @@ -0,0 +1,13 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net462</TargetFramework> + <Platforms>x86</Platforms> + </PropertyGroup> + <ItemGroup> + <ProjectReference Include="..\NzbDrone.Host\Lidarr.Host.csproj" /> + <ProjectReference Include="..\NzbDrone.Test.Common\Lidarr.Test.Common.csproj" /> + </ItemGroup> + <ItemGroup> + <Reference Include="System.ServiceProcess" /> + </ItemGroup> +</Project> diff --git a/src/NzbDrone.App.Test/NzbDroneProcessServiceFixture.cs b/src/NzbDrone.Host.Test/NzbDroneProcessServiceFixture.cs similarity index 94% rename from src/NzbDrone.App.Test/NzbDroneProcessServiceFixture.cs rename to src/NzbDrone.Host.Test/NzbDroneProcessServiceFixture.cs index 1ee1ee522..fed0c03e6 100644 --- a/src/NzbDrone.App.Test/NzbDroneProcessServiceFixture.cs +++ b/src/NzbDrone.Host.Test/NzbDroneProcessServiceFixture.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Moq; using NUnit.Framework; using NzbDrone.Common.Model; @@ -20,11 +20,11 @@ namespace NzbDrone.App.Test .Returns(new ProcessInfo() { Id = CURRENT_PROCESS_ID }); Mocker.GetMock<IProcessProvider>() - .Setup(s => s.FindProcessByName(ProcessProvider.NZB_DRONE_CONSOLE_PROCESS_NAME)) + .Setup(s => s.FindProcessByName(ProcessProvider.LIDARR_CONSOLE_PROCESS_NAME)) .Returns(new List<ProcessInfo>()); Mocker.GetMock<IProcessProvider>() - .Setup(s => s.FindProcessByName(ProcessProvider.NZB_DRONE_PROCESS_NAME)) + .Setup(s => s.FindProcessByName(ProcessProvider.LIDARR_PROCESS_NAME)) .Returns(new List<ProcessInfo>()); } @@ -47,7 +47,7 @@ namespace NzbDrone.App.Test public void should_enforce_if_another_console_is_running() { Mocker.GetMock<IProcessProvider>() - .Setup(c => c.FindProcessByName(ProcessProvider.NZB_DRONE_CONSOLE_PROCESS_NAME)) + .Setup(c => c.FindProcessByName(ProcessProvider.LIDARR_CONSOLE_PROCESS_NAME)) .Returns(new List<ProcessInfo> { new ProcessInfo {Id = 10}, @@ -63,7 +63,7 @@ namespace NzbDrone.App.Test public void should_return_false_if_another_gui_is_running() { Mocker.GetMock<IProcessProvider>() - .Setup(c => c.FindProcessByName(ProcessProvider.NZB_DRONE_PROCESS_NAME)) + .Setup(c => c.FindProcessByName(ProcessProvider.LIDARR_PROCESS_NAME)) .Returns(new List<ProcessInfo> { new ProcessInfo {Id = CURRENT_PROCESS_ID}, diff --git a/src/NzbDrone.App.Test/RouterTest.cs b/src/NzbDrone.Host.Test/RouterTest.cs similarity index 83% rename from src/NzbDrone.App.Test/RouterTest.cs rename to src/NzbDrone.Host.Test/RouterTest.cs index 0cf7b6c3d..de1f7db63 100644 --- a/src/NzbDrone.App.Test/RouterTest.cs +++ b/src/NzbDrone.Host.Test/RouterTest.cs @@ -1,8 +1,9 @@ -using System.ServiceProcess; +using System.ServiceProcess; using Moq; using NUnit.Framework; using NzbDrone.Common; using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Processes; using NzbDrone.Host; using NzbDrone.Test.Common; @@ -22,14 +23,17 @@ namespace NzbDrone.App.Test public void Route_should_call_install_service_when_application_mode_is_install() { var serviceProviderMock = Mocker.GetMock<IServiceProvider>(MockBehavior.Strict); - serviceProviderMock.Setup(c => c.Install(ServiceProvider.NZBDRONE_SERVICE_NAME)); - serviceProviderMock.Setup(c => c.ServiceExist(ServiceProvider.NZBDRONE_SERVICE_NAME)).Returns(false); - serviceProviderMock.Setup(c => c.Start(ServiceProvider.NZBDRONE_SERVICE_NAME)); + serviceProviderMock.Setup(c => c.ServiceExist(ServiceProvider.SERVICE_NAME)).Returns(false); + serviceProviderMock.Setup(c => c.Install(ServiceProvider.SERVICE_NAME)); + serviceProviderMock.Setup(c => c.SetPermissions(ServiceProvider.SERVICE_NAME)); + + Mocker.GetMock<IProcessProvider>() + .Setup(c => c.SpawnNewProcess("sc.exe", It.IsAny<string>(), null, true)); Mocker.GetMock<IRuntimeInfo>().SetupGet(c => c.IsUserInteractive).Returns(true); Subject.Route(ApplicationModes.InstallService); - serviceProviderMock.Verify(c => c.Install(ServiceProvider.NZBDRONE_SERVICE_NAME), Times.Once()); + serviceProviderMock.Verify(c => c.Install(ServiceProvider.SERVICE_NAME), Times.Once()); } @@ -37,13 +41,13 @@ namespace NzbDrone.App.Test public void Route_should_call_uninstall_service_when_application_mode_is_uninstall() { var serviceProviderMock = Mocker.GetMock<IServiceProvider>(); - serviceProviderMock.Setup(c => c.UnInstall(ServiceProvider.NZBDRONE_SERVICE_NAME)); + serviceProviderMock.Setup(c => c.Uninstall(ServiceProvider.SERVICE_NAME)); Mocker.GetMock<IRuntimeInfo>().SetupGet(c => c.IsUserInteractive).Returns(true); - serviceProviderMock.Setup(c => c.ServiceExist(ServiceProvider.NZBDRONE_SERVICE_NAME)).Returns(true); + serviceProviderMock.Setup(c => c.ServiceExist(ServiceProvider.SERVICE_NAME)).Returns(true); Subject.Route(ApplicationModes.UninstallService); - serviceProviderMock.Verify(c => c.UnInstall(ServiceProvider.NZBDRONE_SERVICE_NAME), Times.Once()); + serviceProviderMock.Verify(c => c.Uninstall(ServiceProvider.SERVICE_NAME), Times.Once()); } [Test] @@ -82,7 +86,7 @@ namespace NzbDrone.App.Test Mocker.GetMock<IRuntimeInfo>().SetupGet(c => c.IsUserInteractive).Returns(true); consoleMock.Setup(c => c.PrintServiceAlreadyExist()); - serviceMock.Setup(c => c.ServiceExist(ServiceProvider.NZBDRONE_SERVICE_NAME)).Returns(true); + serviceMock.Setup(c => c.ServiceExist(ServiceProvider.SERVICE_NAME)).Returns(true); Subject.Route(ApplicationModes.InstallService); @@ -96,7 +100,7 @@ namespace NzbDrone.App.Test Mocker.GetMock<IRuntimeInfo>().SetupGet(c => c.IsUserInteractive).Returns(true); consoleMock.Setup(c => c.PrintServiceDoesNotExist()); - serviceMock.Setup(c => c.ServiceExist(ServiceProvider.NZBDRONE_SERVICE_NAME)).Returns(false); + serviceMock.Setup(c => c.ServiceExist(ServiceProvider.SERVICE_NAME)).Returns(false); Subject.Route(ApplicationModes.UninstallService); diff --git a/src/NzbDrone.App.Test/app.config b/src/NzbDrone.Host.Test/app.config similarity index 100% rename from src/NzbDrone.App.Test/app.config rename to src/NzbDrone.Host.Test/app.config diff --git a/src/NzbDrone.Host/AccessControl/FirewallAdapter.cs b/src/NzbDrone.Host/AccessControl/FirewallAdapter.cs index 794e9edff..ebc38f0c4 100644 --- a/src/NzbDrone.Host/AccessControl/FirewallAdapter.cs +++ b/src/NzbDrone.Host/AccessControl/FirewallAdapter.cs @@ -31,13 +31,13 @@ namespace NzbDrone.Host.AccessControl { if (!IsNzbDronePortOpen(_configFileProvider.Port)) { - _logger.Debug("Opening Port for NzbDrone: {0}", _configFileProvider.Port); + _logger.Debug("Opening Port for Lidarr: {0}", _configFileProvider.Port); OpenFirewallPort(_configFileProvider.Port); } if (_configFileProvider.EnableSsl && !IsNzbDronePortOpen(_configFileProvider.SslPort)) { - _logger.Debug("Opening SSL Port for NzbDrone: {0}", _configFileProvider.SslPort); + _logger.Debug("Opening SSL Port for Lidarr: {0}", _configFileProvider.SslPort); OpenFirewallPort(_configFileProvider.SslPort); } } @@ -81,7 +81,7 @@ namespace NzbDrone.Host.AccessControl } catch (Exception ex) { - _logger.Warn(ex, "Failed to open port in firewall for NzbDrone " + portNumber); + _logger.Warn(ex, "Failed to open port in firewall for Lidarr " + portNumber); } } diff --git a/src/NzbDrone.Host/AccessControl/RemoteAccessAdapter.cs b/src/NzbDrone.Host/AccessControl/RemoteAccessAdapter.cs new file mode 100644 index 000000000..d71474fdb --- /dev/null +++ b/src/NzbDrone.Host/AccessControl/RemoteAccessAdapter.cs @@ -0,0 +1,46 @@ +using NzbDrone.Common.EnvironmentInfo; + +namespace NzbDrone.Host.AccessControl +{ + public interface IRemoteAccessAdapter + { + void MakeAccessible(bool passive); + } + + public class RemoteAccessAdapter : IRemoteAccessAdapter + { + private readonly IRuntimeInfo _runtimeInfo; + private readonly IUrlAclAdapter _urlAclAdapter; + private readonly IFirewallAdapter _firewallAdapter; + private readonly ISslAdapter _sslAdapter; + + public RemoteAccessAdapter(IRuntimeInfo runtimeInfo, + IUrlAclAdapter urlAclAdapter, + IFirewallAdapter firewallAdapter, + ISslAdapter sslAdapter) + { + _runtimeInfo = runtimeInfo; + _urlAclAdapter = urlAclAdapter; + _firewallAdapter = firewallAdapter; + _sslAdapter = sslAdapter; + } + + public void MakeAccessible(bool passive) + { + if (OsInfo.IsWindows) + { + if (_runtimeInfo.IsAdmin) + { + _firewallAdapter.MakeAccessible(); + _sslAdapter.Register(); + } + else if (!passive) + { + throw new RemoteAccessException("Failed to register URLs for Lidarr. Lidarr will not be accessible remotely"); + } + } + + _urlAclAdapter.ConfigureUrls(); + } + } +} diff --git a/src/NzbDrone.Host/AccessControl/RemoteAccessException.cs b/src/NzbDrone.Host/AccessControl/RemoteAccessException.cs new file mode 100644 index 000000000..abd481040 --- /dev/null +++ b/src/NzbDrone.Host/AccessControl/RemoteAccessException.cs @@ -0,0 +1,24 @@ +using System; +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Host.AccessControl +{ + public class RemoteAccessException : NzbDroneException + { + public RemoteAccessException(string message, params object[] args) : base(message, args) + { + } + + public RemoteAccessException(string message) : base(message) + { + } + + public RemoteAccessException(string message, Exception innerException, params object[] args) : base(message, innerException, args) + { + } + + public RemoteAccessException(string message, Exception innerException) : base(message, innerException) + { + } + } +} diff --git a/src/NzbDrone.Host/AccessControl/SslAdapter.cs b/src/NzbDrone.Host/AccessControl/SslAdapter.cs index 12784ba87..27a60785e 100644 --- a/src/NzbDrone.Host/AccessControl/SslAdapter.cs +++ b/src/NzbDrone.Host/AccessControl/SslAdapter.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System.Linq; using System.Text.RegularExpressions; using NLog; using NzbDrone.Core.Configuration; @@ -12,7 +12,7 @@ namespace NzbDrone.Host.AccessControl public class SslAdapter : ISslAdapter { - private const string APP_ID = "C2172AF4-F9A6-4D91-BAEE-C2E4EE680613"; + private const string APP_ID = "87CAF14C-6750-42DB-B6A0-3BB826315E91"; private static readonly Regex CertificateHashRegex = new Regex(@"^\s+(?:Certificate Hash\s+:\s+)(?<hash>\w+)", RegexOptions.Compiled); private readonly INetshProvider _netshProvider; diff --git a/src/NzbDrone.Host/AccessControl/UrlAclAdapter.cs b/src/NzbDrone.Host/AccessControl/UrlAclAdapter.cs index fd483479b..1733baa93 100644 --- a/src/NzbDrone.Host/AccessControl/UrlAclAdapter.cs +++ b/src/NzbDrone.Host/AccessControl/UrlAclAdapter.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Text.RegularExpressions; using NLog; using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Exceptions; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; @@ -32,7 +33,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,18 +50,32 @@ namespace NzbDrone.Host.AccessControl _logger = logger; InternalUrls = new List<UrlAcl>(); - RegisteredUrls = GetRegisteredUrls(); + RegisteredUrls = new List<UrlAcl>(); } public void ConfigureUrls() { - var localHostHttpUrls = BuildUrlAcls("http", "localhost", _configFileProvider.Port); - var interfaceHttpUrls = BuildUrlAcls("http", _configFileProvider.BindAddress, _configFileProvider.Port); + var enableSsl = _configFileProvider.EnableSsl; + var port = _configFileProvider.Port; + var sslPort = _configFileProvider.SslPort; - var localHostHttpsUrls = BuildUrlAcls("https", "localhost", _configFileProvider.SslPort); - var interfaceHttpsUrls = BuildUrlAcls("https", _configFileProvider.BindAddress, _configFileProvider.SslPort); + if (enableSsl && sslPort == port) + { + throw new LidarrStartupException("Cannot use the same port for HTTP and HTTPS. Port {0}", port); + } + + if (RegisteredUrls.Empty()) + { + GetRegisteredUrls(); + } - if (!_configFileProvider.EnableSsl) + var localHostHttpUrls = BuildUrlAcls("http", "localhost", port); + var interfaceHttpUrls = BuildUrlAcls("http", _configFileProvider.BindAddress, port); + + var localHostHttpsUrls = BuildUrlAcls("https", "localhost", sslPort); + var interfaceHttpsUrls = BuildUrlAcls("https", _configFileProvider.BindAddress, sslPort); + + if (!enableSsl) { localHostHttpsUrls.Clear(); interfaceHttpsUrls.Clear(); @@ -128,19 +143,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/ApplicationModes.cs b/src/NzbDrone.Host/ApplicationModes.cs index aa425948c..ec06ea1f3 100644 --- a/src/NzbDrone.Host/ApplicationModes.cs +++ b/src/NzbDrone.Host/ApplicationModes.cs @@ -1,4 +1,4 @@ -namespace NzbDrone.Host +namespace NzbDrone.Host { public enum ApplicationModes { @@ -7,5 +7,6 @@ InstallService, UninstallService, Service, + RegisterUrl } } 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..b3fa8ca99 100644 --- a/src/NzbDrone.Host/Bootstrap.cs +++ b/src/NzbDrone.Host/Bootstrap.cs @@ -1,13 +1,15 @@ -using System; +using System; using System.Reflection; using System.Threading; using NLog; using NzbDrone.Common.Composition; +using NzbDrone.Common.Disk; 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 @@ -24,14 +26,17 @@ namespace NzbDrone.Host SecurityProtocolPolicy.Register(); X509CertificateValidationPolicy.Register(); - Logger.Info("Starting Sonarr - {0} - Version {1}", Assembly.GetCallingAssembly().Location, Assembly.GetExecutingAssembly().GetName().Version); + Logger.Info("Starting Lidarr - {0} - Version {1}", Assembly.GetCallingAssembly().Location, Assembly.GetExecutingAssembly().GetName().Version); if (!PlatformValidation.IsValidate(userAlert)) { throw new TerminateApplicationException("Missing system requirements"); } + LongPathSupport.Enable(); + _container = MainAppContainerBuilder.BuildContainer(startupContext); + _container.Resolve<InitializeLogger>().Initialize(); _container.Resolve<IAppFolderFactory>().Register(); _container.Resolve<IProvidePidFile>().Write(); @@ -49,9 +54,17 @@ namespace NzbDrone.Host SpinToExit(appMode); } } - catch (TerminateApplicationException e) + catch (InvalidConfigFileException ex) + { + throw new LidarrStartupException(ex); + } + catch (AccessDeniedConfigFileException ex) + { + throw new LidarrStartupException(ex); + } + catch (TerminateApplicationException ex) { - Logger.Info(e.Message); + Logger.Info(ex.Message); LogManager.Configuration = null; } } @@ -70,7 +83,6 @@ namespace NzbDrone.Host EnsureSingleInstance(applicationModes == ApplicationModes.Service, startupContext); } - DbFactory.RegisterDatabase(_container); _container.Resolve<Router>().Route(applicationModes); } @@ -88,11 +100,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(); } @@ -104,11 +120,16 @@ namespace NzbDrone.Host private static ApplicationModes GetApplicationMode(IStartupContext startupContext) { - if (startupContext.Flags.Contains(StartupContext.HELP)) + if (startupContext.Help) { return ApplicationModes.Help; } + if (OsInfo.IsWindows && startupContext.RegisterUrl) + { + return ApplicationModes.RegisterUrl; + } + if (OsInfo.IsWindows && startupContext.InstallService) { return ApplicationModes.InstallService; @@ -133,6 +154,7 @@ namespace NzbDrone.Host { case ApplicationModes.InstallService: case ApplicationModes.UninstallService: + case ApplicationModes.RegisterUrl: case ApplicationModes.Help: { return true; diff --git a/src/NzbDrone.Host/Lidarr.Host.csproj b/src/NzbDrone.Host/Lidarr.Host.csproj new file mode 100644 index 000000000..4062811fc --- /dev/null +++ b/src/NzbDrone.Host/Lidarr.Host.csproj @@ -0,0 +1,24 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net462</TargetFramework> + <Platforms>x86</Platforms> + </PropertyGroup> + <ItemGroup> + <PackageReference Include="Microsoft.AspNet.SignalR.SelfHost" Version="2.4.1" /> + <PackageReference Include="Nancy.Owin" Version="1.4.1" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\Lidarr.Api.V1\Lidarr.Api.V1.csproj" /> + <ProjectReference Include="..\NzbDrone.Common\Lidarr.Common.csproj" /> + <ProjectReference Include="..\NzbDrone.Core\Lidarr.Core.csproj" /> + <ProjectReference Include="..\NzbDrone.SignalR\Lidarr.SignalR.csproj" /> + <ProjectReference Include="..\Lidarr.Http\Lidarr.Http.csproj" /> + </ItemGroup> + <ItemGroup> + <Reference Include="System.ServiceProcess" /> + <Reference Include="Interop.NetFwTypeLib"> + <HintPath>..\Libraries\Interop.NetFwTypeLib.dll</HintPath> + <EmbedInteropTypes>True</EmbedInteropTypes> + </Reference> + </ItemGroup> +</Project> diff --git a/src/NzbDrone.Host/MainAppContainerBuilder.cs b/src/NzbDrone.Host/MainAppContainerBuilder.cs index 23ba6a0dd..9537aa929 100644 --- a/src/NzbDrone.Host/MainAppContainerBuilder.cs +++ b/src/NzbDrone.Host/MainAppContainerBuilder.cs @@ -1,6 +1,6 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Nancy.Bootstrapper; -using NzbDrone.Api; +using Lidarr.Http; using NzbDrone.Common.Composition; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Http.Dispatchers; @@ -14,10 +14,11 @@ namespace NzbDrone.Host { var assemblies = new List<string> { - "NzbDrone.Host", - "NzbDrone.Core", - "NzbDrone.Api", - "NzbDrone.SignalR" + "Lidarr.Host", + "Lidarr.Core", + "Lidarr.SignalR", + "Lidarr.Api.V1", + "Lidarr.Http" }; return new MainAppContainerBuilder(args, assemblies).Container; @@ -28,8 +29,7 @@ namespace NzbDrone.Host { AutoRegisterImplementations<NzbDronePersistentConnection>(); - Container.Register<INancyBootstrapper, NancyBootstrapper>(); - Container.Register<IHttpDispatcher, FallbackHttpDispatcher>(); + Container.Register<INancyBootstrapper, LidarrBootstrapper>(); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Host/NzbDrone.Host.csproj b/src/NzbDrone.Host/NzbDrone.Host.csproj deleted file mode 100644 index 98f4670c7..000000000 --- a/src/NzbDrone.Host/NzbDrone.Host.csproj +++ /dev/null @@ -1,209 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <PropertyGroup> - <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> - <Platform Condition=" '$(Platform)' == '' ">x86</Platform> - <ProductVersion>8.0.30703</ProductVersion> - <SchemaVersion>2.0</SchemaVersion> - <ProjectGuid>{95C11A9E-56ED-456A-8447-2C89C1139266}</ProjectGuid> - <OutputType>Library</OutputType> - <AppDesignerFolder>Properties</AppDesignerFolder> - <RootNamespace>NzbDrone.Host</RootNamespace> - <AssemblyName>NzbDrone.Host</AssemblyName> - <TargetFrameworkVersion>v4.0</TargetFrameworkVersion> - <FileAlignment>512</FileAlignment> - <TargetFrameworkProfile> - </TargetFrameworkProfile> - <IsWebBootstrapper>false</IsWebBootstrapper> - <PublishUrl>publish\</PublishUrl> - <Install>true</Install> - <InstallFrom>Disk</InstallFrom> - <UpdateEnabled>false</UpdateEnabled> - <UpdateMode>Foreground</UpdateMode> - <UpdateInterval>7</UpdateInterval> - <UpdateIntervalUnits>Days</UpdateIntervalUnits> - <UpdatePeriodically>false</UpdatePeriodically> - <UpdateRequired>false</UpdateRequired> - <MapFileExtensions>true</MapFileExtensions> - <ApplicationRevision>0</ApplicationRevision> - <ApplicationVersion>1.0.0.%2a</ApplicationVersion> - <UseApplicationTrust>false</UseApplicationTrust> - <BootstrapperEnabled>true</BootstrapperEnabled> - <SolutionDir Condition="$(SolutionDir) == '' Or $(SolutionDir) == '*Undefined*'">..\</SolutionDir> - <RestorePackages>true</RestorePackages> - </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|x86' "> - <PlatformTarget>x86</PlatformTarget> - <DebugSymbols>true</DebugSymbols> - <DebugType>full</DebugType> - <Optimize>false</Optimize> - <OutputPath>..\..\_output\</OutputPath> - <DefineConstants>DEBUG;TRACE</DefineConstants> - <ErrorReport>prompt</ErrorReport> - <WarningLevel>4</WarningLevel> - <UseVSHostingProcess>true</UseVSHostingProcess> - <CodeAnalysisRuleSet>BasicCorrectnessRules.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|x86' "> - <PlatformTarget>x86</PlatformTarget> - <DebugType>pdbonly</DebugType> - <Optimize>true</Optimize> - <OutputPath>..\..\_output\</OutputPath> - <DefineConstants>TRACE</DefineConstants> - <ErrorReport>prompt</ErrorReport> - <WarningLevel>4</WarningLevel> - </PropertyGroup> - <PropertyGroup> - <RunPostBuildEvent>OnOutputUpdated</RunPostBuildEvent> - </PropertyGroup> - <ItemGroup> - <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> - </Reference> - <Reference Include="Microsoft.Owin.Host.HttpListener, Version=2.1.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL"> - <SpecificVersion>False</SpecificVersion> - <HintPath>..\packages\Microsoft.Owin.Host.HttpListener.2.1.0\lib\net40\Microsoft.Owin.Host.HttpListener.dll</HintPath> - </Reference> - <Reference Include="Microsoft.Owin.Hosting, Version=2.1.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL"> - <SpecificVersion>False</SpecificVersion> - <HintPath>..\packages\Microsoft.Owin.Hosting.2.1.0\lib\net40\Microsoft.Owin.Hosting.dll</HintPath> - </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> - </Reference> - <Reference Include="Nancy.Owin, Version=1.4.1.0, Culture=neutral, processorArchitecture=MSIL"> - <HintPath>..\packages\Nancy.Owin.1.4.1\lib\net40\Nancy.Owin.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.1\lib\net40\NLog.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="System" /> - <Reference Include="System.Core" /> - <Reference Include="System.ServiceProcess" /> - <Reference Include="Interop.NetFwTypeLib"> - <HintPath>..\Libraries\Interop.NetFwTypeLib.dll</HintPath> - <EmbedInteropTypes>True</EmbedInteropTypes> - </Reference> - <Reference Include="Owin"> - <HintPath>..\packages\Owin.1.0\lib\net40\Owin.dll</HintPath> - </Reference> - </ItemGroup> - <ItemGroup> - <Compile Include="..\NzbDrone.Common\Properties\SharedAssemblyInfo.cs"> - <Link>Properties\SharedAssemblyInfo.cs</Link> - </Compile> - <Compile Include="AccessControl\FirewallAdapter.cs" /> - <Compile Include="AccessControl\NetshProvider.cs" /> - <Compile Include="AccessControl\UrlAcl.cs" /> - <Compile Include="AccessControl\SslAdapter.cs" /> - <Compile Include="AccessControl\UrlAclAdapter.cs" /> - <Compile Include="ApplicationModes.cs" /> - <Compile Include="ApplicationServer.cs"> - <SubType>Component</SubType> - </Compile> - <Compile Include="Bootstrap.cs" /> - <Compile Include="BrowserService.cs" /> - <Compile Include="IUserAlert.cs" /> - <Compile Include="MainAppContainerBuilder.cs" /> - <Compile Include="Owin\IHostController.cs" /> - <Compile Include="Owin\MiddleWare\IOwinMiddleWare.cs" /> - <Compile Include="Owin\MiddleWare\NancyMiddleWare.cs" /> - <Compile Include="Owin\MiddleWare\NzbDroneVersionMiddleWare.cs" /> - <Compile Include="Owin\MiddleWare\SignalRMiddleWare.cs" /> - <Compile Include="Owin\NlogTextWriter.cs" /> - <Compile Include="Owin\OwinHostController.cs" /> - <Compile Include="Owin\OwinServiceProvider.cs" /> - <Compile Include="Owin\OwinTraceOutputFactory.cs" /> - <Compile Include="Owin\PortInUseException.cs" /> - <Compile Include="PlatformValidation.cs" /> - <Compile Include="Properties\AssemblyInfo.cs" /> - <Compile Include="Router.cs" /> - <Compile Include="SingleInstancePolicy.cs" /> - <Compile Include="SpinService.cs" /> - <Compile Include="TerminateApplicationException.cs" /> - </ItemGroup> - <ItemGroup> - <None Include="app.config" /> - <None Include="packages.config" /> - </ItemGroup> - <ItemGroup> - <None Include="NzbDrone.ico" /> - </ItemGroup> - <ItemGroup> - <BootstrapperPackage Include=".NETFramework,Version=v4.0"> - <Visible>False</Visible> - <ProductName>Microsoft .NET Framework 4 %28x86 and x64%29</ProductName> - <Install>true</Install> - </BootstrapperPackage> - <BootstrapperPackage Include="Microsoft.Net.Client.3.5"> - <Visible>False</Visible> - <ProductName>.NET Framework 3.5 SP1 Client Profile</ProductName> - <Install>false</Install> - </BootstrapperPackage> - <BootstrapperPackage Include="Microsoft.Net.Framework.3.5.SP1"> - <Visible>False</Visible> - <ProductName>.NET Framework 3.5 SP1</ProductName> - <Install>false</Install> - </BootstrapperPackage> - <BootstrapperPackage Include="Microsoft.Windows.Installer.3.1"> - <Visible>False</Visible> - <ProductName>Windows Installer 3.1</ProductName> - <Install>true</Install> - </BootstrapperPackage> - </ItemGroup> - <ItemGroup> - <ProjectReference Include="..\Microsoft.AspNet.SignalR.Core\Microsoft.AspNet.SignalR.Core.csproj"> - <Project>{1B9A82C4-BCA1-4834-A33E-226F17BE070B}</Project> - <Name>Microsoft.AspNet.SignalR.Core</Name> - </ProjectReference> - <ProjectReference Include="..\Microsoft.AspNet.SignalR.Owin\Microsoft.AspNet.SignalR.Owin.csproj"> - <Project>{2B8C6DAD-4D85-41B1-83FD-248D9F347522}</Project> - <Name>Microsoft.AspNet.SignalR.Owin</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.Api\NzbDrone.Api.csproj"> - <Project>{FD286DF8-2D3A-4394-8AD5-443FADE55FB2}</Project> - <Name>NzbDrone.Api</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.Common\NzbDrone.Common.csproj"> - <Project>{F2BE0FDF-6E47-4827-A420-DD4EF82407F8}</Project> - <Name>NzbDrone.Common</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.Core\NzbDrone.Core.csproj"> - <Project>{FF5EE3B6-913B-47CE-9CEB-11C51B4E1205}</Project> - <Name>NzbDrone.Core</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.SignalR\NzbDrone.SignalR.csproj"> - <Project>{7C2CC69F-5CA0-4E5C-85CB-983F9F6C3B36}</Project> - <Name>NzbDrone.SignalR</Name> - </ProjectReference> - </ItemGroup> - <ItemGroup /> - <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> - <PropertyGroup> - <PreBuildEvent> - </PreBuildEvent> - </PropertyGroup> - <PropertyGroup> - <PostBuildEvent Condition="('$(OS)' == 'Windows_NT')"> - xcopy /s /y "$(SolutionDir)\Libraries\Sqlite\*.*" "$(TargetDir)" - </PostBuildEvent> - <PostBuildEvent Condition="('$(OS)' != 'Windows_NT')"> - cp -rv $(SolutionDir)Libraries\Sqlite\*.* $(TargetDir) - </PostBuildEvent> - </PropertyGroup> - <!-- To modify your build process, add your task inside one of the targets below and uncomment it. - Other similar extension points exist, see Microsoft.Common.targets. - <Target Name="BeforeBuild"> - </Target> - <Target Name="AfterBuild"> - </Target> - --> -</Project> \ No newline at end of file diff --git a/src/NzbDrone.Host/NzbDrone.ico b/src/NzbDrone.Host/NzbDrone.ico index 1922557d6..ef74f8e5f 100644 Binary files a/src/NzbDrone.Host/NzbDrone.ico and b/src/NzbDrone.Host/NzbDrone.ico differ diff --git a/src/NzbDrone.Host/Owin/MiddleWare/NzbDroneVersionMiddleWare.cs b/src/NzbDrone.Host/Owin/MiddleWare/NzbDroneVersionMiddleWare.cs index a74d9b1d3..5ef687b74 100644 --- a/src/NzbDrone.Host/Owin/MiddleWare/NzbDroneVersionMiddleWare.cs +++ b/src/NzbDrone.Host/Owin/MiddleWare/NzbDroneVersionMiddleWare.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.Owin; using NzbDrone.Common.EnvironmentInfo; @@ -26,10 +26,10 @@ namespace NzbDrone.Host.Owin.MiddleWare _versionHeader = new KeyValuePair<string, string[]>("X-ApplicationVersion", new[] { BuildInfo.Version.ToString() }); } - public override Task Invoke(IOwinContext context) + public override async Task Invoke(IOwinContext context) { context.Response.Headers.Add(_versionHeader); - return Next.Invoke(context); + await Next.Invoke(context); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Host/Owin/MiddleWare/SignalRMiddleWare.cs b/src/NzbDrone.Host/Owin/MiddleWare/SignalRMiddleWare.cs index 0df60a326..1b3e3e2d5 100644 --- a/src/NzbDrone.Host/Owin/MiddleWare/SignalRMiddleWare.cs +++ b/src/NzbDrone.Host/Owin/MiddleWare/SignalRMiddleWare.cs @@ -1,6 +1,7 @@ using System; using Microsoft.AspNet.SignalR; using NzbDrone.Common.Composition; +using NzbDrone.Common.EnvironmentInfo; using NzbDrone.SignalR; using Owin; @@ -12,14 +13,22 @@ namespace NzbDrone.Host.Owin.MiddleWare public SignalRMiddleWare(IContainer container) { - SignalrDependencyResolver.Register(container); + SignalRDependencyResolver.Register(container); + SignalRJsonSerializer.Register(); - GlobalHost.Configuration.DisconnectTimeout = TimeSpan.FromMinutes(3); + // Note there are some important timeouts involved here: + // nginx has a default 60 sec proxy_read_timeout, this means the connection will be terminated if the server doesn't send anything within that time. + // Previously we lowered the ConnectionTimeout from 110s to 55s to remedy that, however all we should've done is set an appropriate KeepAlive. + // By default KeepAlive is 1/3rd of the DisconnectTimeout, which we set incredibly high 5 years ago, resulting in KeepAlive being 1 minute. + // So when adjusting these values in the future, please keep that all in mind. + GlobalHost.Configuration.ConnectionTimeout = TimeSpan.FromSeconds(110); + GlobalHost.Configuration.DisconnectTimeout = TimeSpan.FromSeconds(180); + GlobalHost.Configuration.KeepAlive = TimeSpan.FromSeconds(30); } public void Attach(IAppBuilder appBuilder) { - appBuilder.MapConnection("signalr", typeof(NzbDronePersistentConnection), new ConnectionConfiguration { EnableCrossDomain = true }); + appBuilder.MapSignalR("/signalr", typeof(NzbDronePersistentConnection), new ConnectionConfiguration ()); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Host/Owin/OwinHostController.cs b/src/NzbDrone.Host/Owin/OwinHostController.cs index 82357c24c..3befa0b78 100644 --- a/src/NzbDrone.Host/Owin/OwinHostController.cs +++ b/src/NzbDrone.Host/Owin/OwinHostController.cs @@ -1,6 +1,5 @@ using System; using NLog; -using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Host.AccessControl; namespace NzbDrone.Host.Owin @@ -8,41 +7,26 @@ namespace NzbDrone.Host.Owin public class OwinHostController : IHostController { private readonly IOwinAppFactory _owinAppFactory; - private readonly IRuntimeInfo _runtimeInfo; + private readonly IRemoteAccessAdapter _remoteAccessAdapter; private readonly IUrlAclAdapter _urlAclAdapter; - private readonly IFirewallAdapter _firewallAdapter; - private readonly ISslAdapter _sslAdapter; private readonly Logger _logger; private IDisposable _owinApp; public OwinHostController( IOwinAppFactory owinAppFactory, - IRuntimeInfo runtimeInfo, + IRemoteAccessAdapter remoteAccessAdapter, IUrlAclAdapter urlAclAdapter, - IFirewallAdapter firewallAdapter, - ISslAdapter sslAdapter, Logger logger) { _owinAppFactory = owinAppFactory; - _runtimeInfo = runtimeInfo; + _remoteAccessAdapter = remoteAccessAdapter; _urlAclAdapter = urlAclAdapter; - _firewallAdapter = firewallAdapter; - _sslAdapter = sslAdapter; _logger = logger; } public void StartServer() { - if (OsInfo.IsWindows) - { - if (_runtimeInfo.IsAdmin) - { - _firewallAdapter.MakeAccessible(); - _sslAdapter.Register(); - } - } - - _urlAclAdapter.ConfigureUrls(); + _remoteAccessAdapter.MakeAccessible(true); _logger.Info("Listening on the following URLs:"); foreach (var url in _urlAclAdapter.Urls) @@ -53,7 +37,6 @@ namespace NzbDrone.Host.Owin _owinApp = _owinAppFactory.CreateApp(_urlAclAdapter.Urls); } - public void StopServer() { if (_owinApp == null) return; @@ -63,8 +46,5 @@ namespace NzbDrone.Host.Owin _owinApp = null; _logger.Info("Host has stopped"); } - - - } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Host/Owin/OwinServiceProvider.cs b/src/NzbDrone.Host/Owin/OwinServiceProvider.cs index c0676cd24..d0ce2425e 100644 --- a/src/NzbDrone.Host/Owin/OwinServiceProvider.cs +++ b/src/NzbDrone.Host/Owin/OwinServiceProvider.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net; @@ -8,6 +8,7 @@ using Microsoft.Owin.Hosting.Engine; using Microsoft.Owin.Hosting.Services; using Microsoft.Owin.Hosting.Tracing; using NLog; +using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Configuration; using NzbDrone.Host.Owin.MiddleWare; using Owin; @@ -60,7 +61,7 @@ namespace NzbDrone.Host.Owin if (ex.InnerException is HttpListenerException) { - throw new PortInUseException("Port {0} is already in use, please ensure NzbDrone is not already running.", ex, _configFileProvider.Port); + throw new PortInUseException("Port {0} is already in use, please ensure {1} is not already running.", ex, _configFileProvider.Port, BuildInfo.AppName); } throw ex.InnerException; @@ -70,7 +71,7 @@ namespace NzbDrone.Host.Owin private void BuildApp(IAppBuilder appBuilder) { - appBuilder.Properties["host.AppName"] = "NzbDrone"; + appBuilder.Properties["host.AppName"] = BuildInfo.AppName; foreach (var middleWare in _owinMiddleWares.OrderBy(c => c.Order)) { @@ -88,4 +89,4 @@ namespace NzbDrone.Host.Owin return provider; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Host/Properties/AssemblyInfo.cs b/src/NzbDrone.Host/Properties/AssemblyInfo.cs deleted file mode 100644 index dd667bbdd..000000000 --- a/src/NzbDrone.Host/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. - -[assembly: AssemblyTitle("NzbDrone.exe")] -[assembly: Guid("C2172AF4-F9A6-4D91-BAEE-C2E4EE680613")] - -[assembly: AssemblyVersion("10.0.0.*")] diff --git a/src/NzbDrone.Host/Router.cs b/src/NzbDrone.Host/Router.cs index 72d1c8f67..e6dd1c6f0 100644 --- a/src/NzbDrone.Host/Router.cs +++ b/src/NzbDrone.Host/Router.cs @@ -1,5 +1,11 @@ -using NLog; +using System; +using NLog; using NzbDrone.Common; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Processes; +using NzbDrone.Host.AccessControl; +using IServiceProvider = NzbDrone.Common.IServiceProvider; + namespace NzbDrone.Host { @@ -8,14 +14,25 @@ namespace NzbDrone.Host private readonly INzbDroneServiceFactory _nzbDroneServiceFactory; private readonly IServiceProvider _serviceProvider; private readonly IConsoleService _consoleService; + private readonly IRuntimeInfo _runtimeInfo; + private readonly IProcessProvider _processProvider; + private readonly IRemoteAccessAdapter _remoteAccessAdapter; private readonly Logger _logger; - public Router(INzbDroneServiceFactory nzbDroneServiceFactory, IServiceProvider serviceProvider, - IConsoleService consoleService, Logger logger) + public Router(INzbDroneServiceFactory nzbDroneServiceFactory, + IServiceProvider serviceProvider, + IConsoleService consoleService, + IRuntimeInfo runtimeInfo, + IProcessProvider processProvider, + IRemoteAccessAdapter remoteAccessAdapter, + Logger logger) { _nzbDroneServiceFactory = nzbDroneServiceFactory; _serviceProvider = serviceProvider; _consoleService = consoleService; + _runtimeInfo = runtimeInfo; + _processProvider = processProvider; + _remoteAccessAdapter = remoteAccessAdapter; _logger = logger; } @@ -34,36 +51,48 @@ namespace NzbDrone.Host case ApplicationModes.Interactive: { - _logger.Debug("Console selected"); + _logger.Debug(_runtimeInfo.IsWindowsTray ? "Tray selected" : "Console selected"); _nzbDroneServiceFactory.Start(); break; } case ApplicationModes.InstallService: { _logger.Debug("Install Service selected"); - if (_serviceProvider.ServiceExist(ServiceProvider.NZBDRONE_SERVICE_NAME)) + if (_serviceProvider.ServiceExist(ServiceProvider.SERVICE_NAME)) { _consoleService.PrintServiceAlreadyExist(); } else { - _serviceProvider.Install(ServiceProvider.NZBDRONE_SERVICE_NAME); - _serviceProvider.Start(ServiceProvider.NZBDRONE_SERVICE_NAME); + _remoteAccessAdapter.MakeAccessible(true); + _serviceProvider.Install(ServiceProvider.SERVICE_NAME); + _serviceProvider.SetPermissions(ServiceProvider.SERVICE_NAME); + + // Start the service and exit. + // Ensures that there isn't an instance of Lidarr already running that the service account cannot stop. + _processProvider.SpawnNewProcess("sc.exe", $"start {ServiceProvider.SERVICE_NAME}", null, true); } break; } case ApplicationModes.UninstallService: { _logger.Debug("Uninstall Service selected"); - if (!_serviceProvider.ServiceExist(ServiceProvider.NZBDRONE_SERVICE_NAME)) + if (!_serviceProvider.ServiceExist(ServiceProvider.SERVICE_NAME)) { _consoleService.PrintServiceDoesNotExist(); } else { - _serviceProvider.UnInstall(ServiceProvider.NZBDRONE_SERVICE_NAME); + _serviceProvider.Uninstall(ServiceProvider.SERVICE_NAME); } + break; + } + case ApplicationModes.RegisterUrl: + { + _logger.Debug("Regiser URL selected"); + _remoteAccessAdapter.MakeAccessible(false); + break; } default: @@ -73,7 +102,5 @@ namespace NzbDrone.Host } } } - - } } diff --git a/src/NzbDrone.Host/SingleInstancePolicy.cs b/src/NzbDrone.Host/SingleInstancePolicy.cs index 75b8bb13e..0235b9845 100644 --- a/src/NzbDrone.Host/SingleInstancePolicy.cs +++ b/src/NzbDrone.Host/SingleInstancePolicy.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using NLog; @@ -10,6 +10,7 @@ namespace NzbDrone.Host { void PreventStartIfAlreadyRunning(); void KillAllOtherInstance(); + void WarnIfAlreadyRunning(); } public class SingleInstancePolicy : ISingleInstancePolicy @@ -31,7 +32,7 @@ namespace NzbDrone.Host { if (IsAlreadyRunning()) { - _logger.Warn("Another instance of Sonarr is already running."); + _logger.Warn("Another instance of Lidarr is already running."); _browserService.LaunchWebUI(); throw new TerminateApplicationException("Another instance is already running"); } @@ -45,6 +46,14 @@ namespace NzbDrone.Host } } + public void WarnIfAlreadyRunning() + { + if (IsAlreadyRunning()) + { + _logger.Debug("Another instance of Lidarr is already running."); + } + } + private bool IsAlreadyRunning() { return GetOtherNzbDroneProcessIds().Any(); @@ -56,24 +65,24 @@ namespace NzbDrone.Host { var currentId = _processProvider.GetCurrentProcess().Id; - var otherProcesses = _processProvider.FindProcessByName(ProcessProvider.NZB_DRONE_CONSOLE_PROCESS_NAME) - .Union(_processProvider.FindProcessByName(ProcessProvider.NZB_DRONE_PROCESS_NAME)) + var otherProcesses = _processProvider.FindProcessByName(ProcessProvider.LIDARR_CONSOLE_PROCESS_NAME) + .Union(_processProvider.FindProcessByName(ProcessProvider.LIDARR_PROCESS_NAME)) .Select(c => c.Id) .Except(new[] { currentId }) .ToList(); if (otherProcesses.Any()) { - _logger.Info("{0} instance(s) of Sonarr are running", otherProcesses.Count); + _logger.Info("{0} instance(s) of Lidarr are running", otherProcesses.Count); } return otherProcesses; } catch (Exception ex) { - _logger.Warn(ex, "Failed to check for multiple instances of Sonarr."); + _logger.Warn(ex, "Failed to check for multiple instances of Lidarr."); return new List<int>(); } } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Host/app.config b/src/NzbDrone.Host/app.config index 885d99232..027d1c664 100644 --- a/src/NzbDrone.Host/app.config +++ b/src/NzbDrone.Host/app.config @@ -6,7 +6,7 @@ </connectionManagement> </system.net> <startup useLegacyV2RuntimeActivationPolicy="true"> - <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.0" /> + <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.6.2" /> </startup> <runtime> <loadFromRemoteSources enabled="true" /> @@ -14,7 +14,7 @@ <probing privatePath="libs" /> <dependentAssembly> <assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" /> - <bindingRedirect oldVersion="0.0.0.0-9.0.0.0" newVersion="9.0.0.0" /> + <bindingRedirect oldVersion="0.0.0.0-12.0.0.0" newVersion="12.0.0.0" /> </dependentAssembly> <dependentAssembly> <assemblyIdentity name="NLog" publicKeyToken="5120e14c03d0593c" culture="neutral" /> @@ -22,7 +22,7 @@ </dependentAssembly> <dependentAssembly> <assemblyIdentity name="Microsoft.AspNet.SignalR.Core" publicKeyToken="31bf3856ad364e35" culture="neutral" /> - <bindingRedirect oldVersion="0.0.0.0-1.1.0.0" newVersion="1.1.0.0" /> + <bindingRedirect oldVersion="0.0.0.0-2.4.0.0" newVersion="2.4.0.0" /> </dependentAssembly> <dependentAssembly> <assemblyIdentity name="FluentMigrator" publicKeyToken="aacfc7de5acabf05" culture="neutral" /> @@ -30,7 +30,11 @@ </dependentAssembly> <dependentAssembly> <assemblyIdentity name="Microsoft.Owin" publicKeyToken="31bf3856ad364e35" culture="neutral" /> - <bindingRedirect oldVersion="0.0.0.0-2.1.0.0" newVersion="2.1.0.0" /> + <bindingRedirect oldVersion="0.0.0.0-3.1.0.0" newVersion="3.1.0.0" /> + </dependentAssembly> + <dependentAssembly> + <assemblyIdentity name="Microsoft.Owin.Security" publicKeyToken="31bf3856ad364e35" culture="neutral" /> + <bindingRedirect oldVersion="0.0.0.0-3.1.0.0" newVersion="3.1.0.0" /> </dependentAssembly> </assemblyBinding> </runtime> diff --git a/src/NzbDrone.Host/packages.config b/src/NzbDrone.Host/packages.config deleted file mode 100644 index c42f59378..000000000 --- a/src/NzbDrone.Host/packages.config +++ /dev/null @@ -1,11 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<packages> - <package id="Microsoft.Owin" version="2.1.0" targetFramework="net40" /> - <package id="Microsoft.Owin.Host.HttpListener" version="2.1.0" targetFramework="net40" /> - <package id="Microsoft.Owin.Hosting" version="2.1.0" targetFramework="net40" /> - <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.1" 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/ArtistEditorFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/ArtistEditorFixture.cs new file mode 100644 index 000000000..d683b304e --- /dev/null +++ b/src/NzbDrone.Integration.Test/ApiTests/ArtistEditorFixture.cs @@ -0,0 +1,45 @@ +using FluentAssertions; +using NUnit.Framework; +using Lidarr.Api.V1.Artist; +using System.Linq; +using NzbDrone.Test.Common; + +namespace NzbDrone.Integration.Test.ApiTests +{ + [TestFixture] + public class ArtistEditorFixture : IntegrationTest + { + private void GivenExistingArtist() + { + foreach (var name in new[] { "Alien Ant Farm", "Kiss" }) + { + var newArtist = Artist.Lookup(name).First(); + + newArtist.QualityProfileId = 1; + newArtist.MetadataProfileId = 1; + newArtist.Path = string.Format(@"C:\Test\{0}", name).AsOsAgnostic(); + + Artist.Post(newArtist); + } + } + + [Test] + public void should_be_able_to_update_multiple_artist() + { + GivenExistingArtist(); + + var artist = Artist.All(); + + var artistEditor = new ArtistEditorResource + { + QualityProfileId = 2, + ArtistIds = artist.Select(o => o.Id).ToList() + }; + + var result = Artist.Editor(artistEditor); + + result.Should().HaveCount(2); + result.TrueForAll(s => s.QualityProfileId == 2).Should().BeTrue(); + } + } +} diff --git a/src/NzbDrone.Integration.Test/ApiTests/ArtistFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/ArtistFixture.cs new file mode 100644 index 000000000..df7b29013 --- /dev/null +++ b/src/NzbDrone.Integration.Test/ApiTests/ArtistFixture.cs @@ -0,0 +1,178 @@ +using FluentAssertions; +using NUnit.Framework; +using System.Linq; +using System.IO; +using System.Collections.Generic; + +namespace NzbDrone.Integration.Test.ApiTests +{ + [TestFixture] + public class ArtistFixture : IntegrationTest + { + [Test, Order(0)] + public void add_artist_with_tags_should_store_them() + { + EnsureNoArtist("f59c5520-5f46-4d2c-b2c4-822eabf53419", "Linkin Park"); + var tag = EnsureTag("abc"); + + var artist = Artist.Lookup("lidarr:f59c5520-5f46-4d2c-b2c4-822eabf53419").Single(); + + artist.QualityProfileId = 1; + artist.MetadataProfileId = 1; + artist.Path = Path.Combine(ArtistRootFolder, artist.ArtistName); + artist.Tags = new HashSet<int>(); + artist.Tags.Add(tag.Id); + + var result = Artist.Post(artist); + + result.Should().NotBeNull(); + result.Tags.Should().Equal(tag.Id); + } + + [Test, Order(0)] + public void add_artist_without_profileid_should_return_badrequest() + { + EnsureNoArtist("f59c5520-5f46-4d2c-b2c4-822eabf53419", "Linkin Park"); + + var artist = Artist.Lookup("lidarr:f59c5520-5f46-4d2c-b2c4-822eabf53419").Single(); + + artist.Path = Path.Combine(ArtistRootFolder, artist.ArtistName); + + Artist.InvalidPost(artist); + } + + [Test, Order(0)] + public void add_artist_without_path_should_return_badrequest() + { + EnsureNoArtist("f59c5520-5f46-4d2c-b2c4-822eabf53419", "Linkin Park"); + + var artist = Artist.Lookup("lidarr:f59c5520-5f46-4d2c-b2c4-822eabf53419").Single(); + + artist.QualityProfileId = 1; + + Artist.InvalidPost(artist); + } + + [Test, Order(1)] + public void add_artist() + { + EnsureNoArtist("f59c5520-5f46-4d2c-b2c4-822eabf53419", "Linkin Park"); + + var artist = Artist.Lookup("lidarr:f59c5520-5f46-4d2c-b2c4-822eabf53419").Single(); + + artist.QualityProfileId = 1; + artist.MetadataProfileId = 1; + artist.Path = Path.Combine(ArtistRootFolder, artist.ArtistName); + + var result = Artist.Post(artist); + + result.Should().NotBeNull(); + result.Id.Should().NotBe(0); + result.QualityProfileId.Should().Be(1); + result.MetadataProfileId.Should().Be(1); + result.Path.Should().Be(Path.Combine(ArtistRootFolder, artist.ArtistName)); + } + + + [Test, Order(2)] + public void get_all_artist() + { + EnsureArtist("8ac6cc32-8ddf-43b1-9ac4-4b04f9053176", "Alien Ant Farm"); + EnsureArtist("cc197bad-dc9c-440d-a5b5-d52ba2e14234", "Coldplay"); + + var artists = Artist.All(); + + artists.Should().NotBeNullOrEmpty(); + artists.Should().Contain(v => v.ForeignArtistId == "8ac6cc32-8ddf-43b1-9ac4-4b04f9053176"); + artists.Should().Contain(v => v.ForeignArtistId == "cc197bad-dc9c-440d-a5b5-d52ba2e14234"); + } + + [Test, Order(2)] + public void get_artist_by_id() + { + var artist = EnsureArtist("8ac6cc32-8ddf-43b1-9ac4-4b04f9053176", "Alien Ant Farm"); + + var result = Artist.Get(artist.Id); + + result.ForeignArtistId.Should().Be("8ac6cc32-8ddf-43b1-9ac4-4b04f9053176"); + } + + [Test] + public void get_artist_by_unknown_id_should_return_404() + { + var result = Artist.InvalidGet(1000000); + } + + [Test, Order(2)] + public void update_artist_profile_id() + { + var artist = EnsureArtist("8ac6cc32-8ddf-43b1-9ac4-4b04f9053176", "Alien Ant Farm"); + + var profileId = 1; + if (artist.QualityProfileId == profileId) + { + profileId = 2; + } + + artist.QualityProfileId = profileId; + + var result = Artist.Put(artist); + + Artist.Get(artist.Id).QualityProfileId.Should().Be(profileId); + } + + [Test, Order(3)] + public void update_artist_monitored() + { + var artist = EnsureArtist("f59c5520-5f46-4d2c-b2c4-822eabf53419", "Linkin Park", false); + + artist.Monitored.Should().BeFalse(); + //artist.Seasons.First().Monitored.Should().BeFalse(); + + artist.Monitored = true; + //artist.Seasons.ForEach(season => + //{ + // season.Monitored = true; + //}); + + var result = Artist.Put(artist); + + result.Monitored.Should().BeTrue(); + //result.Seasons.First().Monitored.Should().BeTrue(); + } + + [Test, Order(3)] + public void update_artist_tags() + { + var artist = EnsureArtist("8ac6cc32-8ddf-43b1-9ac4-4b04f9053176", "Alien Ant Farm"); + var tag = EnsureTag("abc"); + + if (artist.Tags.Contains(tag.Id)) + { + artist.Tags.Remove(tag.Id); + + var result = Artist.Put(artist); + Artist.Get(artist.Id).Tags.Should().NotContain(tag.Id); + } + else + { + artist.Tags.Add(tag.Id); + + var result = Artist.Put(artist); + Artist.Get(artist.Id).Tags.Should().Contain(tag.Id); + } + } + + [Test, Order(4)] + public void delete_artist() + { + var artist = EnsureArtist("8ac6cc32-8ddf-43b1-9ac4-4b04f9053176", "Alien Ant Farm"); + + Artist.Get(artist.Id).Should().NotBeNull(); + + Artist.Delete(artist.Id); + + Artist.All().Should().NotContain(v => v.ForeignArtistId == "8ac6cc32-8ddf-43b1-9ac4-4b04f9053176"); + } + } +} diff --git a/src/NzbDrone.Integration.Test/ApiTests/ArtistLookupFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/ArtistLookupFixture.cs new file mode 100644 index 000000000..af78cd1b5 --- /dev/null +++ b/src/NzbDrone.Integration.Test/ApiTests/ArtistLookupFixture.cs @@ -0,0 +1,37 @@ +using FluentAssertions; +using NUnit.Framework; + +namespace NzbDrone.Integration.Test.ApiTests +{ + [TestFixture] + public class ArtistLookupFixture : IntegrationTest + { + [TestCase("Kiss", "Kiss")] + [TestCase("Linkin Park", "Linkin Park")] + public void lookup_new_artist_by_name(string term, string name) + { + var artist = Artist.Lookup(term); + + artist.Should().NotBeEmpty(); + artist.Should().Contain(c => c.ArtistName == name); + } + + [Test] + public void lookup_new_artist_by_mbid() + { + var artist = Artist.Lookup("lidarr:f59c5520-5f46-4d2c-b2c4-822eabf53419"); + + artist.Should().NotBeEmpty(); + artist.Should().Contain(c => c.ArtistName == "Linkin Park"); + } + + [Test] + [Ignore("Unreliable")] + public void lookup_random_artist_using_asterix() + { + var artist = Artist.Lookup("*"); + + artist.Should().NotBeEmpty(); + } + } +} diff --git a/src/NzbDrone.Integration.Test/ApiTests/BlacklistFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/BlacklistFixture.cs index b8b18b70a..a67bb8e3c 100644 --- a/src/NzbDrone.Integration.Test/ApiTests/BlacklistFixture.cs +++ b/src/NzbDrone.Integration.Test/ApiTests/BlacklistFixture.cs @@ -1,24 +1,25 @@ -using FluentAssertions; +using FluentAssertions; using NUnit.Framework; -using NzbDrone.Api.Series; +using Lidarr.Api.V1.Artist; +using Lidarr.Api.V1.Blacklist; namespace NzbDrone.Integration.Test.ApiTests { [TestFixture] public class BlacklistFixture : IntegrationTest { - private SeriesResource _series; + private ArtistResource _artist; [Test] [Ignore("Adding to blacklist not supported")] public void should_be_able_to_add_to_blacklist() { - _series = EnsureSeries(266189, "The Blacklist"); - - Blacklist.Post(new Api.Blacklist.BlacklistResource + _artist = EnsureArtist("8ac6cc32-8ddf-43b1-9ac4-4b04f9053176", "Alien Ant Farm"); + + Blacklist.Post(new BlacklistResource { - SeriesId = _series.Id, - SourceTitle = "Blacklist.S01E01.Brought.To.You.By-BoomBoxHD" + ArtistId = _artist.Id, + SourceTitle = "Blacklist - Album 1 [2015 FLAC]" }); } diff --git a/src/NzbDrone.Integration.Test/ApiTests/CalendarFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/CalendarFixture.cs index fdc5c194f..c386aacf2 100644 --- a/src/NzbDrone.Integration.Test/ApiTests/CalendarFixture.cs +++ b/src/NzbDrone.Integration.Test/ApiTests/CalendarFixture.cs @@ -1,6 +1,6 @@ -using FluentAssertions; +using FluentAssertions; using NUnit.Framework; -using NzbDrone.Api.Episodes; +using Lidarr.Api.V1.Albums; using NzbDrone.Integration.Test.Client; using System; using System.Collections.Generic; @@ -11,62 +11,62 @@ namespace NzbDrone.Integration.Test.ApiTests [TestFixture] public class CalendarFixture : IntegrationTest { - public ClientBase<EpisodeResource> Calendar; + public ClientBase<AlbumResource> Calendar; protected override void InitRestClients() { base.InitRestClients(); - Calendar = new ClientBase<EpisodeResource>(RestClient, ApiKey, "calendar"); + Calendar = new ClientBase<AlbumResource>(RestClient, ApiKey, "calendar"); } [Test] - public void should_be_able_to_get_episodes() + public void should_be_able_to_get_albums() { - var series = EnsureSeries(266189, "The Blacklist", true); + var artist = EnsureArtist("cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", "Adele", true); var request = Calendar.BuildRequest(); - request.AddParameter("start", new DateTime(2015, 10, 1).ToString("s") + "Z"); - request.AddParameter("end", new DateTime(2015, 10, 3).ToString("s") + "Z"); - var items = Calendar.Get<List<EpisodeResource>>(request); + request.AddParameter("start", new DateTime(2015, 11, 19).ToString("s") + "Z"); + request.AddParameter("end", new DateTime(2015, 11, 21).ToString("s") + "Z"); + var items = Calendar.Get<List<AlbumResource>>(request); - items = items.Where(v => v.SeriesId == series.Id).ToList(); + items = items.Where(v => v.ArtistId == artist.Id).ToList(); items.Should().HaveCount(1); - items.First().Title.Should().Be("The Troll Farmer"); + items.First().Title.Should().Be("25"); } [Test] - public void should_not_be_able_to_get_unmonitored_episodes() + public void should_not_be_able_to_get_unmonitored_albums() { - var series = EnsureSeries(266189, "The Blacklist", false); + var artist = EnsureArtist("cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", "Adele", false); var request = Calendar.BuildRequest(); - request.AddParameter("start", new DateTime(2015, 10, 1).ToString("s") + "Z"); - request.AddParameter("end", new DateTime(2015, 10, 3).ToString("s") + "Z"); + request.AddParameter("start", new DateTime(2015, 11, 19).ToString("s") + "Z"); + request.AddParameter("end", new DateTime(2015, 11, 21).ToString("s") + "Z"); request.AddParameter("unmonitored", "false"); - var items = Calendar.Get<List<EpisodeResource>>(request); + var items = Calendar.Get<List<AlbumResource>>(request); - items = items.Where(v => v.SeriesId == series.Id).ToList(); + items = items.Where(v => v.ArtistId == artist.Id).ToList(); items.Should().BeEmpty(); } [Test] - public void should_be_able_to_get_unmonitored_episodes() + public void should_be_able_to_get_unmonitored_albums() { - var series = EnsureSeries(266189, "The Blacklist", false); + var artist = EnsureArtist("cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", "Adele", false); var request = Calendar.BuildRequest(); - request.AddParameter("start", new DateTime(2015, 10, 1).ToString("s") + "Z"); - request.AddParameter("end", new DateTime(2015, 10, 3).ToString("s") + "Z"); + request.AddParameter("start", new DateTime(2015, 11, 19).ToString("s") + "Z"); + request.AddParameter("end", new DateTime(2015, 11, 21).ToString("s") + "Z"); request.AddParameter("unmonitored", "true"); - var items = Calendar.Get<List<EpisodeResource>>(request); + var items = Calendar.Get<List<AlbumResource>>(request); - items = items.Where(v => v.SeriesId == series.Id).ToList(); + items = items.Where(v => v.ArtistId == artist.Id).ToList(); items.Should().HaveCount(1); - items.First().Title.Should().Be("The Troll Farmer"); + items.First().Title.Should().Be("25"); } } } diff --git a/src/NzbDrone.Integration.Test/ApiTests/CommandFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/CommandFixture.cs index bf17b6d12..4f40de1ae 100644 --- a/src/NzbDrone.Integration.Test/ApiTests/CommandFixture.cs +++ b/src/NzbDrone.Integration.Test/ApiTests/CommandFixture.cs @@ -1,6 +1,6 @@ -using FluentAssertions; +using FluentAssertions; using NUnit.Framework; -using NzbDrone.Api.Commands; +using NzbDrone.Integration.Test.Client; namespace NzbDrone.Integration.Test.ApiTests { @@ -11,9 +11,9 @@ namespace NzbDrone.Integration.Test.ApiTests [Test] public void should_be_able_to_run_rss_sync() { - var response = Commands.Post(new CommandResource { Name = "rsssync" }); + var response = Commands.Post(new SimpleCommandResource { Name = "rsssync" }); response.Id.Should().NotBe(0); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Integration.Test/ApiTests/DiskSpaceFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/DiskSpaceFixture.cs index 527f18346..deb675433 100644 --- a/src/NzbDrone.Integration.Test/ApiTests/DiskSpaceFixture.cs +++ b/src/NzbDrone.Integration.Test/ApiTests/DiskSpaceFixture.cs @@ -1,7 +1,7 @@ -using System.Linq; +using System.Linq; using FluentAssertions; using NUnit.Framework; -using NzbDrone.Api.DiskSpace; +using Lidarr.Api.V1.DiskSpace; using NzbDrone.Integration.Test.Client; namespace NzbDrone.Integration.Test.ApiTests diff --git a/src/NzbDrone.Integration.Test/ApiTests/DownloadClientFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/DownloadClientFixture.cs index 5032fd3c6..302eb780a 100644 --- a/src/NzbDrone.Integration.Test/ApiTests/DownloadClientFixture.cs +++ b/src/NzbDrone.Integration.Test/ApiTests/DownloadClientFixture.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System.Linq; using FluentAssertions; using NUnit.Framework; @@ -16,8 +16,8 @@ namespace NzbDrone.Integration.Test.ApiTests var schema = DownloadClients.Schema().First(v => v.Implementation == "UsenetBlackhole"); schema.Enable = true; - schema.Fields.First(v => v.Name == "WatchFolder").Value = GetTempDirectory("Download", "UsenetBlackhole", "Watch"); - schema.Fields.First(v => v.Name == "NzbFolder").Value = GetTempDirectory("Download", "UsenetBlackhole", "Nzb"); + schema.Fields.First(v => v.Name == "watchFolder").Value = GetTempDirectory("Download", "UsenetBlackhole", "Watch"); + schema.Fields.First(v => v.Name == "nzbFolder").Value = GetTempDirectory("Download", "UsenetBlackhole", "Nzb"); DownloadClients.InvalidPost(schema); } @@ -31,7 +31,7 @@ namespace NzbDrone.Integration.Test.ApiTests schema.Enable = true; schema.Name = "Test UsenetBlackhole"; - schema.Fields.First(v => v.Name == "WatchFolder").Value = GetTempDirectory("Download", "UsenetBlackhole", "Watch"); + schema.Fields.First(v => v.Name == "watchFolder").Value = GetTempDirectory("Download", "UsenetBlackhole", "Watch"); DownloadClients.InvalidPost(schema); } @@ -45,7 +45,7 @@ namespace NzbDrone.Integration.Test.ApiTests schema.Enable = true; schema.Name = "Test UsenetBlackhole"; - schema.Fields.First(v => v.Name == "NzbFolder").Value = GetTempDirectory("Download", "UsenetBlackhole", "Nzb"); + schema.Fields.First(v => v.Name == "nzbFolder").Value = GetTempDirectory("Download", "UsenetBlackhole", "Nzb"); DownloadClients.InvalidPost(schema); } @@ -59,8 +59,8 @@ namespace NzbDrone.Integration.Test.ApiTests schema.Enable = true; schema.Name = "Test UsenetBlackhole"; - schema.Fields.First(v => v.Name == "WatchFolder").Value = GetTempDirectory("Download", "UsenetBlackhole", "Watch"); - schema.Fields.First(v => v.Name == "NzbFolder").Value = GetTempDirectory("Download", "UsenetBlackhole", "Nzb"); + schema.Fields.First(v => v.Name == "watchFolder").Value = GetTempDirectory("Download", "UsenetBlackhole", "Watch"); + schema.Fields.First(v => v.Name == "nzbFolder").Value = GetTempDirectory("Download", "UsenetBlackhole", "Nzb"); var result = DownloadClients.Post(schema); @@ -99,7 +99,7 @@ namespace NzbDrone.Integration.Test.ApiTests EnsureNoDownloadClient(); var client = EnsureDownloadClient(); - client.Fields.First(v => v.Name == "NzbFolder").Value = GetTempDirectory("Download", "UsenetBlackhole", "Nzb2"); + client.Fields.First(v => v.Name == "nzbFolder").Value = GetTempDirectory("Download", "UsenetBlackhole", "Nzb2"); var result = DownloadClients.Put(client); result.Should().NotBeNull(); @@ -117,4 +117,4 @@ namespace NzbDrone.Integration.Test.ApiTests DownloadClients.All().Should().NotContain(v => v.Id == client.Id); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Integration.Test/ApiTests/EpisodeFileFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/EpisodeFileFixture.cs deleted file mode 100644 index 90e4bbe49..000000000 --- a/src/NzbDrone.Integration.Test/ApiTests/EpisodeFileFixture.cs +++ /dev/null @@ -1,14 +0,0 @@ -using NUnit.Framework; - -namespace NzbDrone.Integration.Test.ApiTests -{ - [TestFixture] - public class EpisodeFileFixture : IntegrationTest - { - [Test] - public void get_all_episodefiles() - { - Assert.Ignore("TODO"); - } - } -} diff --git a/src/NzbDrone.Integration.Test/ApiTests/EpisodeFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/EpisodeFixture.cs deleted file mode 100644 index b59cf1668..000000000 --- a/src/NzbDrone.Integration.Test/ApiTests/EpisodeFixture.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System.Threading; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Api.Series; -using System.Linq; -using NzbDrone.Test.Common; - -namespace NzbDrone.Integration.Test.ApiTests -{ - [TestFixture] - public class EpisodeFixture : IntegrationTest - { - private SeriesResource series; - - [SetUp] - public void Setup() - { - series = GivenSeriesWithEpisodes(); - } - - private SeriesResource GivenSeriesWithEpisodes() - { - var newSeries = Series.Lookup("archer").Single(c => c.TvdbId == 110381); - - newSeries.ProfileId = 1; - newSeries.Path = @"C:\Test\Archer".AsOsAgnostic(); - - newSeries = Series.Post(newSeries); - - WaitForCompletion(() => Episodes.GetEpisodesInSeries(newSeries.Id).Count > 0); - - return newSeries; - } - - [Test] - public void should_be_able_to_get_all_episodes_in_series() - { - Episodes.GetEpisodesInSeries(series.Id).Count.Should().BeGreaterThan(0); - } - - [Test] - public void should_be_able_to_get_a_single_episode() - { - var episodes = Episodes.GetEpisodesInSeries(series.Id); - - Episodes.Get(episodes.First().Id).Should().NotBeNull(); - } - - [Test] - public void should_be_able_to_set_monitor_status() - { - var episodes = Episodes.GetEpisodesInSeries(series.Id); - var updatedEpisode = episodes.First(); - updatedEpisode.Monitored = false; - - Episodes.Put(updatedEpisode).Monitored.Should().BeFalse(); - } - - - [TearDown] - public void TearDown() - { - Series.Delete(series.Id); - Thread.Sleep(2000); - } - } -} diff --git a/src/NzbDrone.Integration.Test/ApiTests/FileSystemFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/FileSystemFixture.cs index 4d4c49821..a94071764 100644 --- a/src/NzbDrone.Integration.Test/ApiTests/FileSystemFixture.cs +++ b/src/NzbDrone.Integration.Test/ApiTests/FileSystemFixture.cs @@ -1,4 +1,4 @@ -using FluentAssertions; +using FluentAssertions; using NUnit.Framework; using System.Linq; using NzbDrone.Integration.Test.Client; @@ -104,7 +104,7 @@ namespace NzbDrone.Integration.Test.ApiTests public void get_all_mediafiles() { var tempDir = GetTempDirectory("mediaDir"); - File.WriteAllText(Path.Combine(tempDir, "somevideo.mkv"), "video"); + File.WriteAllText(Path.Combine(tempDir, "somevideo.mp3"), "audio"); var request = FileSystem.BuildRequest("mediafiles"); request.Method = Method.GET; @@ -117,7 +117,7 @@ namespace NzbDrone.Integration.Test.ApiTests result.First().Should().ContainKey("relativePath"); result.First().Should().ContainKey("name"); - result.First()["name"].Should().Be("somevideo.mkv"); + result.First()["name"].Should().Be("somevideo.mp3"); } } } diff --git a/src/NzbDrone.Integration.Test/ApiTests/NamingConfigFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/NamingConfigFixture.cs index 3dcfa5c83..3de293f9a 100644 --- a/src/NzbDrone.Integration.Test/ApiTests/NamingConfigFixture.cs +++ b/src/NzbDrone.Integration.Test/ApiTests/NamingConfigFixture.cs @@ -1,4 +1,4 @@ -using FluentAssertions; +using FluentAssertions; using NUnit.Framework; namespace NzbDrone.Integration.Test.ApiTests @@ -25,103 +25,68 @@ namespace NzbDrone.Integration.Test.ApiTests public void should_be_able_to_update() { var config = NamingConfig.GetSingle(); - config.RenameEpisodes = false; - config.StandardEpisodeFormat = "{Series Title} - {season}x{episode:00} - {Episode Title}"; - config.DailyEpisodeFormat = "{Series Title} - {Air-Date} - {Episode Title}"; - config.AnimeEpisodeFormat = "{Series Title} - {season}x{episode:00} - {Episode Title}"; + config.RenameTracks = false; + config.StandardTrackFormat = "{Artist Name} - {Album Title} - {track:00} - {Track Title}"; var result = NamingConfig.Put(config); - result.RenameEpisodes.Should().BeFalse(); - result.StandardEpisodeFormat.Should().Be(config.StandardEpisodeFormat); - result.DailyEpisodeFormat.Should().Be(config.DailyEpisodeFormat); - result.AnimeEpisodeFormat.Should().Be(config.AnimeEpisodeFormat); + result.RenameTracks.Should().BeFalse(); + result.StandardTrackFormat.Should().Be(config.StandardTrackFormat); } [Test] public void should_get_bad_request_if_standard_format_is_empty() { var config = NamingConfig.GetSingle(); - config.RenameEpisodes = true; - config.StandardEpisodeFormat = ""; - config.DailyEpisodeFormat = "{Series Title} - {Air-Date} - {Episode Title}"; - config.AnimeEpisodeFormat = "{Series Title} - {season}x{episode:00} - {Episode Title}"; + config.RenameTracks = true; + config.StandardTrackFormat = ""; - var errors = NamingConfig.InvalidPut(config); - errors.Should().NotBeNull(); - } - - [Test] - public void should_get_bad_request_if_standard_format_doesnt_contain_season_and_episode() - { - var config = NamingConfig.GetSingle(); - config.RenameEpisodes = true; - config.StandardEpisodeFormat = "{season}"; - config.DailyEpisodeFormat = "{Series Title} - {Air-Date} - {Episode Title}"; - config.AnimeEpisodeFormat = "{Series Title} - {season}x{episode:00} - {Episode Title}"; - - var errors = NamingConfig.InvalidPut(config); - errors.Should().NotBeNull(); - } - - [Test] - public void should_get_bad_request_if_daily_format_doesnt_contain_season_and_episode_or_air_date() - { - var config = NamingConfig.GetSingle(); - config.RenameEpisodes = true; - config.StandardEpisodeFormat = "{Series Title} - {season}x{episode:00} - {Episode Title}"; - config.DailyEpisodeFormat = "{Series Title} - {season} - {Episode Title}"; - config.AnimeEpisodeFormat = "{Series Title} - {season}x{episode:00} - {Episode Title}"; var errors = NamingConfig.InvalidPut(config); errors.Should().NotBeNull(); } [Test] - public void should_get_bad_request_if_anime_format_doesnt_contain_season_and_episode_or_absolute() + public void should_get_bad_request_if_standard_format_doesnt_contain_track_number_and_title() { var config = NamingConfig.GetSingle(); - config.RenameEpisodes = false; - config.StandardEpisodeFormat = "{Series Title} - {season}x{episode:00} - {Episode Title}"; - config.DailyEpisodeFormat = "{Series Title} - {Air-Date} - {Episode Title}"; - config.AnimeEpisodeFormat = "{Series Title} - {season} - {Episode Title}"; + config.RenameTracks = true; + config.StandardTrackFormat = "{track:00}"; var errors = NamingConfig.InvalidPut(config); errors.Should().NotBeNull(); } [Test] - public void should_not_require_format_when_rename_episodes_is_false() + public void should_not_require_format_when_rename_tracks_is_false() { var config = NamingConfig.GetSingle(); - config.RenameEpisodes = false; - config.StandardEpisodeFormat = ""; - config.DailyEpisodeFormat = ""; + config.RenameTracks = false; + config.StandardTrackFormat = ""; var errors = NamingConfig.InvalidPut(config); errors.Should().NotBeNull(); } [Test] - public void should_require_format_when_rename_episodes_is_true() + public void should_require_format_when_rename_tracks_is_true() { var config = NamingConfig.GetSingle(); - config.RenameEpisodes = true; - config.StandardEpisodeFormat = ""; - config.DailyEpisodeFormat = ""; + config.RenameTracks = true; + config.StandardTrackFormat = ""; var errors = NamingConfig.InvalidPut(config); errors.Should().NotBeNull(); } [Test] - public void should_get_bad_request_if_series_folder_format_does_not_contain_series_title() + public void should_get_bad_request_if_artist_folder_format_does_not_contain_artist_name() { var config = NamingConfig.GetSingle(); - config.RenameEpisodes = true; - config.SeriesFolderFormat = "This and That"; + config.RenameTracks = true; + config.ArtistFolderFormat = "This and That"; var errors = NamingConfig.InvalidPut(config); errors.Should().NotBeNull(); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Integration.Test/ApiTests/NotificationFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/NotificationFixture.cs index c5ebfa8ef..85ba510ff 100644 --- a/src/NzbDrone.Integration.Test/ApiTests/NotificationFixture.cs +++ b/src/NzbDrone.Integration.Test/ApiTests/NotificationFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using FluentAssertions; using NUnit.Framework; @@ -34,10 +34,10 @@ namespace NzbDrone.Integration.Test.ApiTests var xbmc = schema.Single(s => s.Implementation.Equals("Xbmc", StringComparison.InvariantCultureIgnoreCase)); xbmc.Name = "Test XBMC"; - xbmc.Fields.Single(f => f.Name.Equals("Host")).Value = "localhost"; + xbmc.Fields.Single(f => f.Name.Equals("host")).Value = "localhost"; var result = Notifications.Post(xbmc); Notifications.Delete(result.Id); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Integration.Test/ApiTests/ReleaseFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/ReleaseFixture.cs index d915b6ca3..2101497f8 100644 --- a/src/NzbDrone.Integration.Test/ApiTests/ReleaseFixture.cs +++ b/src/NzbDrone.Integration.Test/ApiTests/ReleaseFixture.cs @@ -1,6 +1,6 @@ -using FluentAssertions; +using FluentAssertions; using NUnit.Framework; -using NzbDrone.Api.Indexers; +using Lidarr.Api.V1.Indexers; using System.Linq; using System.Net; @@ -48,7 +48,7 @@ namespace NzbDrone.Integration.Test.ApiTests releaseResource.Age.Should().BeGreaterOrEqualTo(-1); releaseResource.Title.Should().NotBeNullOrWhiteSpace(); releaseResource.DownloadUrl.Should().NotBeNullOrWhiteSpace(); - releaseResource.SeriesTitle.Should().NotBeNullOrWhiteSpace(); + releaseResource.ArtistName.Should().NotBeNullOrWhiteSpace(); //TODO: uncomment these after moving to restsharp for rss //releaseResource.NzbInfoUrl.Should().NotBeNullOrWhiteSpace(); //releaseResource.Size.Should().BeGreaterThan(0); diff --git a/src/NzbDrone.Integration.Test/ApiTests/ReleasePushFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/ReleasePushFixture.cs new file mode 100644 index 000000000..b1201c872 --- /dev/null +++ b/src/NzbDrone.Integration.Test/ApiTests/ReleasePushFixture.cs @@ -0,0 +1,31 @@ +using FluentAssertions; +using NUnit.Framework; +using Lidarr.Api.V1.Indexers; +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("title", "The Artist - The Album (2008) [FLAC]"); + body.Add("protocol", "Torrent"); + body.Add("downloadUrl", "https://lidarr.audio/test.torrent"); + 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/RootFolderFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/RootFolderFixture.cs index 5133a5da7..5bae86b11 100644 --- a/src/NzbDrone.Integration.Test/ApiTests/RootFolderFixture.cs +++ b/src/NzbDrone.Integration.Test/ApiTests/RootFolderFixture.cs @@ -1,7 +1,7 @@ -using System; +using System; using FluentAssertions; using NUnit.Framework; -using NzbDrone.Api.RootFolders; +using Lidarr.Api.V1.RootFolders; namespace NzbDrone.Integration.Test.ApiTests { @@ -53,4 +53,4 @@ namespace NzbDrone.Integration.Test.ApiTests postResponse.Should().NotBeNull(); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Integration.Test/ApiTests/SeriesEditorFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/SeriesEditorFixture.cs deleted file mode 100644 index e6a36ca0d..000000000 --- a/src/NzbDrone.Integration.Test/ApiTests/SeriesEditorFixture.cs +++ /dev/null @@ -1,42 +0,0 @@ -using FluentAssertions; -using NUnit.Framework; -using System.Linq; -using NzbDrone.Test.Common; - -namespace NzbDrone.Integration.Test.ApiTests -{ - [TestFixture] - public class SeriesEditorFixture : IntegrationTest - { - private void GivenExistingSeries() - { - foreach (var title in new[] { "90210", "Dexter" }) - { - var newSeries = Series.Lookup(title).First(); - - newSeries.ProfileId = 1; - newSeries.Path = string.Format(@"C:\Test\{0}", title).AsOsAgnostic(); - - Series.Post(newSeries); - } - } - - [Test] - public void should_be_able_to_update_multiple_series() - { - GivenExistingSeries(); - - var series = Series.All(); - - foreach (var s in series) - { - s.ProfileId = 2; - } - - var result = Series.Editor(series); - - result.Should().HaveCount(2); - result.TrueForAll(s => s.ProfileId == 2).Should().BeTrue(); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Integration.Test/ApiTests/SeriesFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/SeriesFixture.cs deleted file mode 100644 index 3c44e2336..000000000 --- a/src/NzbDrone.Integration.Test/ApiTests/SeriesFixture.cs +++ /dev/null @@ -1,173 +0,0 @@ -using FluentAssertions; -using NUnit.Framework; -using System.Linq; -using System.IO; -using System.Collections.Generic; - -namespace NzbDrone.Integration.Test.ApiTests -{ - [TestFixture] - public class SeriesFixture : IntegrationTest - { - [Test, Order(0)] - public void add_series_with_tags_should_store_them() - { - EnsureNoSeries(266189, "The Blacklist"); - var tag = EnsureTag("abc"); - - var series = Series.Lookup("tvdb:266189").Single(); - - series.ProfileId = 1; - series.Path = Path.Combine(SeriesRootFolder, series.Title); - series.Tags = new HashSet<int>(); - series.Tags.Add(tag.Id); - - var result = Series.Post(series); - - result.Should().NotBeNull(); - result.Tags.Should().Equal(tag.Id); - } - - [Test, Order(0)] - public void add_series_without_profileid_should_return_badrequest() - { - EnsureNoSeries(266189, "The Blacklist"); - - var series = Series.Lookup("tvdb:266189").Single(); - - series.Path = Path.Combine(SeriesRootFolder, series.Title); - - Series.InvalidPost(series); - } - - [Test, Order(0)] - public void add_series_without_path_should_return_badrequest() - { - EnsureNoSeries(266189, "The Blacklist"); - - var series = Series.Lookup("tvdb:266189").Single(); - - series.ProfileId = 1; - - Series.InvalidPost(series); - } - - [Test, Order(1)] - public void add_series() - { - EnsureNoSeries(266189, "The Blacklist"); - - var series = Series.Lookup("tvdb:266189").Single(); - - series.ProfileId = 1; - series.Path = Path.Combine(SeriesRootFolder, series.Title); - - var result = Series.Post(series); - - result.Should().NotBeNull(); - result.Id.Should().NotBe(0); - result.ProfileId.Should().Be(1); - result.Path.Should().Be(Path.Combine(SeriesRootFolder, series.Title)); - } - - - [Test, Order(2)] - public void get_all_series() - { - EnsureSeries(266189, "The Blacklist"); - EnsureSeries(73065, "Archer"); - - Series.All().Should().NotBeNullOrEmpty(); - Series.All().Should().Contain(v => v.TvdbId == 73065); - Series.All().Should().Contain(v => v.TvdbId == 266189); - } - - [Test, Order(2)] - public void get_series_by_id() - { - var series = EnsureSeries(266189, "The Blacklist"); - - var result = Series.Get(series.Id); - - result.TvdbId.Should().Be(266189); - } - - [Test] - public void get_series_by_unknown_id_should_return_404() - { - var result = Series.InvalidGet(1000000); - } - - [Test, Order(2)] - public void update_series_profile_id() - { - var series = EnsureSeries(266189, "The Blacklist"); - - var profileId = 1; - if (series.ProfileId == profileId) - { - profileId = 2; - } - - series.ProfileId = profileId; - - var result = Series.Put(series); - - Series.Get(series.Id).ProfileId.Should().Be(profileId); - } - - [Test, Order(3)] - public void update_series_monitored() - { - var series = EnsureSeries(266189, "The Blacklist", false); - - series.Monitored.Should().BeFalse(); - series.Seasons.First().Monitored.Should().BeFalse(); - - series.Monitored = true; - series.Seasons.ForEach(season => - { - season.Monitored = true; - }); - - var result = Series.Put(series); - - result.Monitored.Should().BeTrue(); - result.Seasons.First().Monitored.Should().BeTrue(); - } - - [Test, Order(3)] - public void update_series_tags() - { - var series = EnsureSeries(266189, "The Blacklist"); - var tag = EnsureTag("abc"); - - if (series.Tags.Contains(tag.Id)) - { - series.Tags.Remove(tag.Id); - - var result = Series.Put(series); - Series.Get(series.Id).Tags.Should().NotContain(tag.Id); - } - else - { - series.Tags.Add(tag.Id); - - var result = Series.Put(series); - Series.Get(series.Id).Tags.Should().Contain(tag.Id); - } - } - - [Test, Order(4)] - public void delete_series() - { - var series = EnsureSeries(266189, "The Blacklist"); - - Series.Get(series.Id).Should().NotBeNull(); - - Series.Delete(series.Id); - - Series.All().Should().NotContain(v => v.TvdbId == 266189); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Integration.Test/ApiTests/SeriesLookupFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/SeriesLookupFixture.cs deleted file mode 100644 index f45169551..000000000 --- a/src/NzbDrone.Integration.Test/ApiTests/SeriesLookupFixture.cs +++ /dev/null @@ -1,37 +0,0 @@ -using FluentAssertions; -using NUnit.Framework; - -namespace NzbDrone.Integration.Test.ApiTests -{ - [TestFixture] - public class SeriesLookupFixture : IntegrationTest - { - [TestCase("archer", "Archer (2009)")] - [TestCase("90210", "90210")] - public void lookup_new_series_by_title(string term, string title) - { - var series = Series.Lookup(term); - - series.Should().NotBeEmpty(); - series.Should().Contain(c => c.Title == title); - } - - [Test] - public void lookup_new_series_by_tvdbid() - { - var series = Series.Lookup("tvdb:266189"); - - series.Should().NotBeEmpty(); - series.Should().Contain(c => c.Title == "The Blacklist"); - } - - [Test] - [Ignore("Unreliable")] - public void lookup_random_series_using_asterix() - { - var series = Series.Lookup("*"); - - series.Should().NotBeEmpty(); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.Integration.Test/ApiTests/TrackFileFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/TrackFileFixture.cs new file mode 100644 index 000000000..226c8910c --- /dev/null +++ b/src/NzbDrone.Integration.Test/ApiTests/TrackFileFixture.cs @@ -0,0 +1,14 @@ +using NUnit.Framework; + +namespace NzbDrone.Integration.Test.ApiTests +{ + [TestFixture] + public class TrackFileFixture : IntegrationTest + { + [Test] + public void get_all_trackfiles() + { + Assert.Ignore("TODO"); + } + } +} diff --git a/src/NzbDrone.Integration.Test/ApiTests/TrackFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/TrackFixture.cs new file mode 100644 index 000000000..e398fb801 --- /dev/null +++ b/src/NzbDrone.Integration.Test/ApiTests/TrackFixture.cs @@ -0,0 +1,41 @@ +using System.Threading; +using FluentAssertions; +using NUnit.Framework; +using Lidarr.Api.V1.Artist; +using System.Linq; + +namespace NzbDrone.Integration.Test.ApiTests +{ + [TestFixture] + public class TrackFixture : IntegrationTest + { + private ArtistResource _artist; + + [SetUp] + public void Setup() + { + _artist = EnsureArtist("8ac6cc32-8ddf-43b1-9ac4-4b04f9053176", "Alien Ant Farm"); + } + + [Test, Order(0)] + public void should_be_able_to_get_all_tracks_in_artist() + { + Tracks.GetTracksInArtist(_artist.Id).Count.Should().BeGreaterThan(0); + } + + [Test, Order(1)] + public void should_be_able_to_get_a_single_track() + { + var tracks = Tracks.GetTracksInArtist(_artist.Id); + + Tracks.Get(tracks.First().Id).Should().NotBeNull(); + } + + [TearDown] + public void TearDown() + { + Artist.Delete(_artist.Id); + Thread.Sleep(2000); + } + } +} diff --git a/src/NzbDrone.Integration.Test/ApiTests/WantedFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/WantedFixture.cs index 01e5df8e5..a3d54d564 100644 --- a/src/NzbDrone.Integration.Test/ApiTests/WantedFixture.cs +++ b/src/NzbDrone.Integration.Test/ApiTests/WantedFixture.cs @@ -11,9 +11,9 @@ namespace NzbDrone.Integration.Test.ApiTests [Test, Order(0)] public void missing_should_be_empty() { - EnsureNoSeries(266189, "The Blacklist"); + EnsureNoArtist("8ac6cc32-8ddf-43b1-9ac4-4b04f9053176", "Alien Ant Farm"); - var result = WantedMissing.GetPaged(0, 15, "airDateUtc", "desc"); + var result = WantedMissing.GetPaged(0, 15, "releaseDate", "desc"); result.Records.Should().BeEmpty(); } @@ -21,32 +21,32 @@ namespace NzbDrone.Integration.Test.ApiTests [Test, Order(1)] public void missing_should_have_monitored_items() { - EnsureSeries(266189, "The Blacklist", true); + EnsureArtist("8ac6cc32-8ddf-43b1-9ac4-4b04f9053176", "Alien Ant Farm", true); - var result = WantedMissing.GetPaged(0, 15, "airDateUtc", "desc"); + var result = WantedMissing.GetPaged(0, 15, "releaseDate", "desc"); result.Records.Should().NotBeEmpty(); } [Test, Order(1)] - public void missing_should_have_series() + public void missing_should_have_artist() { - EnsureSeries(266189, "The Blacklist", true); + EnsureArtist("8ac6cc32-8ddf-43b1-9ac4-4b04f9053176", "Alien Ant Farm", true); - var result = WantedMissing.GetPaged(0, 15, "airDateUtc", "desc"); + var result = WantedMissing.GetPaged(0, 15, "releaseDate", "desc"); - result.Records.First().Series.Should().NotBeNull(); - result.Records.First().Series.Title.Should().Be("The Blacklist"); + result.Records.First().Artist.Should().NotBeNull(); + result.Records.First().Artist.ArtistName.Should().Be("Alien Ant Farm"); } [Test, Order(1)] public void cutoff_should_have_monitored_items() { - EnsureProfileCutoff(1, Quality.HDTV720p); - var series = EnsureSeries(266189, "The Blacklist", true); - EnsureEpisodeFile(series, 1, 1, Quality.SDTV); + EnsureProfileCutoff(1, "Lossless"); + var artist = EnsureArtist("8ac6cc32-8ddf-43b1-9ac4-4b04f9053176", "Alien Ant Farm", true); + EnsureTrackFile(artist, 1, 1, 1, Quality.MP3_192); - var result = WantedCutoffUnmet.GetPaged(0, 15, "airDateUtc", "desc"); + var result = WantedCutoffUnmet.GetPaged(0, 15, "releaseDate", "desc"); result.Records.Should().NotBeEmpty(); } @@ -54,9 +54,9 @@ namespace NzbDrone.Integration.Test.ApiTests [Test, Order(1)] public void missing_should_not_have_unmonitored_items() { - EnsureSeries(266189, "The Blacklist", false); + EnsureArtist("8ac6cc32-8ddf-43b1-9ac4-4b04f9053176", "Alien Ant Farm", false); - var result = WantedMissing.GetPaged(0, 15, "airDateUtc", "desc"); + var result = WantedMissing.GetPaged(0, 15, "releaseDate", "desc"); result.Records.Should().BeEmpty(); } @@ -64,34 +64,34 @@ namespace NzbDrone.Integration.Test.ApiTests [Test, Order(1)] public void cutoff_should_not_have_unmonitored_items() { - EnsureProfileCutoff(1, Quality.HDTV720p); - var series = EnsureSeries(266189, "The Blacklist", false); - EnsureEpisodeFile(series, 1, 1, Quality.SDTV); + EnsureProfileCutoff(1, "Lossless"); + var artist = EnsureArtist("8ac6cc32-8ddf-43b1-9ac4-4b04f9053176", "Alien Ant Farm", false); + EnsureTrackFile(artist, 1, 1, 1, Quality.MP3_192); - var result = WantedCutoffUnmet.GetPaged(0, 15, "airDateUtc", "desc"); + var result = WantedCutoffUnmet.GetPaged(0, 15, "releaseDate", "desc"); result.Records.Should().BeEmpty(); } [Test, Order(1)] - public void cutoff_should_have_series() + public void cutoff_should_have_artist() { - EnsureProfileCutoff(1, Quality.HDTV720p); - var series = EnsureSeries(266189, "The Blacklist", true); - EnsureEpisodeFile(series, 1, 1, Quality.SDTV); + EnsureProfileCutoff(1, "Lossless"); + var artist = EnsureArtist("8ac6cc32-8ddf-43b1-9ac4-4b04f9053176", "Alien Ant Farm", true); + EnsureTrackFile(artist, 1, 1, 1, Quality.MP3_192); - var result = WantedCutoffUnmet.GetPaged(0, 15, "airDateUtc", "desc"); + var result = WantedCutoffUnmet.GetPaged(0, 15, "releaseDate", "desc"); - result.Records.First().Series.Should().NotBeNull(); - result.Records.First().Series.Title.Should().Be("The Blacklist"); + result.Records.First().Artist.Should().NotBeNull(); + result.Records.First().Artist.ArtistName.Should().Be("Alien Ant Farm"); } [Test, Order(2)] public void missing_should_have_unmonitored_items() { - EnsureSeries(266189, "The Blacklist", false); + EnsureArtist("8ac6cc32-8ddf-43b1-9ac4-4b04f9053176", "Alien Ant Farm", false); - var result = WantedMissing.GetPaged(0, 15, "airDateUtc", "desc", "monitored", "false"); + var result = WantedMissing.GetPaged(0, 15, "releaseDate", "desc", "monitored", "false"); result.Records.Should().NotBeEmpty(); } @@ -99,11 +99,11 @@ namespace NzbDrone.Integration.Test.ApiTests [Test, Order(2)] public void cutoff_should_have_unmonitored_items() { - EnsureProfileCutoff(1, Quality.HDTV720p); - var series = EnsureSeries(266189, "The Blacklist", false); - EnsureEpisodeFile(series, 1, 1, Quality.SDTV); + EnsureProfileCutoff(1, "Lossless"); + var artist = EnsureArtist("8ac6cc32-8ddf-43b1-9ac4-4b04f9053176", "Alien Ant Farm", false); + EnsureTrackFile(artist, 1, 1, 1, Quality.MP3_192); - var result = WantedCutoffUnmet.GetPaged(0, 15, "airDateUtc", "desc", "monitored", "false"); + var result = WantedCutoffUnmet.GetPaged(0, 15, "releaseDate", "desc", "monitored", "false"); result.Records.Should().NotBeEmpty(); } diff --git a/src/NzbDrone.Integration.Test/Client/AlbumClient.cs b/src/NzbDrone.Integration.Test/Client/AlbumClient.cs new file mode 100644 index 000000000..535e42c07 --- /dev/null +++ b/src/NzbDrone.Integration.Test/Client/AlbumClient.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using Lidarr.Api.V1.Albums; +using RestSharp; + +namespace NzbDrone.Integration.Test.Client +{ + public class AlbumClient : ClientBase<AlbumResource> + { + public AlbumClient(IRestClient restClient, string apiKey) + : base(restClient, apiKey, "album") + { + } + + public List<AlbumResource> GetAlbumsInArtist(int artistId) + { + var request = BuildRequest("?artistId=" + artistId.ToString()); + return Get<List<AlbumResource>>(request); + } + } +} diff --git a/src/NzbDrone.Integration.Test/Client/ArtistClient.cs b/src/NzbDrone.Integration.Test/Client/ArtistClient.cs new file mode 100644 index 000000000..b8a454a0c --- /dev/null +++ b/src/NzbDrone.Integration.Test/Client/ArtistClient.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Net; +using Lidarr.Api.V1.Artist; +using RestSharp; + +namespace NzbDrone.Integration.Test.Client +{ + public class ArtistClient : ClientBase<ArtistResource> + { + public ArtistClient(IRestClient restClient, string apiKey) + : base(restClient, apiKey) + { + } + + public List<ArtistResource> Lookup(string term) + { + var request = BuildRequest("lookup?term={term}"); + request.AddUrlSegment("term", term); + return Get<List<ArtistResource>>(request); + } + + public List<ArtistResource> Editor(ArtistEditorResource artist) + { + var request = BuildRequest("editor"); + request.AddBody(artist); + return Put<List<ArtistResource>>(request); + } + + public ArtistResource Get(string slug, HttpStatusCode statusCode = HttpStatusCode.OK) + { + var request = BuildRequest(slug); + return Get<ArtistResource>(request, statusCode); + } + + } + + public class SystemInfoClient : ClientBase<ArtistResource> + { + public SystemInfoClient(IRestClient restClient, string apiKey) + : base(restClient, apiKey) + { + } + } +} diff --git a/src/NzbDrone.Integration.Test/Client/ClientBase.cs b/src/NzbDrone.Integration.Test/Client/ClientBase.cs index 884fe992a..41963cbc8 100644 --- a/src/NzbDrone.Integration.Test/Client/ClientBase.cs +++ b/src/NzbDrone.Integration.Test/Client/ClientBase.cs @@ -1,12 +1,13 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Net; using FluentAssertions; using NLog; -using NzbDrone.Api; -using NzbDrone.Api.REST; +using Lidarr.Api.V1; +using Lidarr.Http.REST; using NzbDrone.Common.Serializer; using RestSharp; using System.Linq; +using Lidarr.Http; namespace NzbDrone.Integration.Test.Client { @@ -39,7 +40,7 @@ namespace NzbDrone.Integration.Test.Client return request; } - public T Execute<T>(IRestRequest request, HttpStatusCode statusCode) where T : class, new() + public string Execute(IRestRequest request, HttpStatusCode statusCode) { _logger.Info("{0}: {1}", request.Method, _restClient.BuildUri(request)); @@ -57,12 +58,19 @@ namespace NzbDrone.Integration.Test.Client response.StatusCode.Should().Be(statusCode); - return Json.Deserialize<T>(response.Content); + return response.Content; + } + + public T Execute<T>(IRestRequest request, HttpStatusCode statusCode) where T : class, new() + { + var content = Execute(request, statusCode); + + return Json.Deserialize<T>(content); } private static void AssertDisableCache(IList<Parameter> headers) { - headers.Single(c => c.Name == "Cache-Control").Value.Should().Be("no-cache, no-store, must-revalidate"); + headers.Single(c => c.Name == "Cache-Control").Value.Should().Be("no-cache, no-store, must-revalidate, max-age=0"); headers.Single(c => c.Name == "Pragma").Value.Should().Be("no-cache"); headers.Single(c => c.Name == "Expires").Value.Should().Be("0"); } @@ -176,4 +184,4 @@ namespace NzbDrone.Integration.Test.Client Execute<object>(request, statusCode); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Integration.Test/Client/CommandClient.cs b/src/NzbDrone.Integration.Test/Client/CommandClient.cs index 047427a98..940a28ed6 100644 --- a/src/NzbDrone.Integration.Test/Client/CommandClient.cs +++ b/src/NzbDrone.Integration.Test/Client/CommandClient.cs @@ -1,23 +1,47 @@ -using NzbDrone.Api.Commands; using RestSharp; using NzbDrone.Core.Messaging.Commands; using FluentAssertions; using System.Threading; using NUnit.Framework; using System.Linq; +using System; +using Lidarr.Http.REST; +using Newtonsoft.Json; namespace NzbDrone.Integration.Test.Client { - public class CommandClient : ClientBase<CommandResource> + public class SimpleCommandResource : RestResource + { + public string Name { get; set; } + public string CommandName { get; set; } + public string Message { get; set; } + public CommandPriority Priority { get; set; } + public CommandStatus Status { get; set; } + public DateTime Queued { get; set; } + public DateTime? Started { get; set; } + public DateTime? Ended { get; set; } + public TimeSpan? Duration { get; set; } + public string Exception { get; set; } + public CommandTrigger Trigger { get; set; } + + [JsonIgnore] + public Command Body { get; set; } + [JsonProperty("body")] + public Command BodyReadOnly { get { return Body; } } + } + + public class CommandClient : ClientBase<SimpleCommandResource> { public CommandClient(IRestClient restClient, string apiKey) - : base(restClient, apiKey) + : base(restClient, apiKey, "command") { } - public CommandResource PostAndWait(CommandResource command) + public SimpleCommandResource PostAndWait<T>(T command) where T : Command, new() { - var result = Post(command); + var request = BuildRequest(); + request.AddBody(command); + var result = Post<SimpleCommandResource>(request); result.Id.Should().NotBe(0); for (var i = 0; i < 50; i++) diff --git a/src/NzbDrone.Integration.Test/Client/DownloadClientClient.cs b/src/NzbDrone.Integration.Test/Client/DownloadClientClient.cs index e31e38748..7b24bc01c 100644 --- a/src/NzbDrone.Integration.Test/Client/DownloadClientClient.cs +++ b/src/NzbDrone.Integration.Test/Client/DownloadClientClient.cs @@ -1,5 +1,5 @@ -using System.Collections.Generic; -using NzbDrone.Api.DownloadClient; +using System.Collections.Generic; +using Lidarr.Api.V1.DownloadClient; using RestSharp; namespace NzbDrone.Integration.Test.Client @@ -17,4 +17,4 @@ namespace NzbDrone.Integration.Test.Client return Get<List<DownloadClientResource>>(request); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Integration.Test/Client/EpisodeClient.cs b/src/NzbDrone.Integration.Test/Client/EpisodeClient.cs deleted file mode 100644 index 46d0b8e03..000000000 --- a/src/NzbDrone.Integration.Test/Client/EpisodeClient.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Api.Episodes; -using RestSharp; - -namespace NzbDrone.Integration.Test.Client -{ - public class EpisodeClient : ClientBase<EpisodeResource> - { - public EpisodeClient(IRestClient restClient, string apiKey) - : base(restClient, apiKey, "episode") - { - } - - public List<EpisodeResource> GetEpisodesInSeries(int seriesId) - { - var request = BuildRequest("?seriesId=" + seriesId.ToString()); - return Get<List<EpisodeResource>>(request); - } - } -} diff --git a/src/NzbDrone.Integration.Test/Client/IndexerClient.cs b/src/NzbDrone.Integration.Test/Client/IndexerClient.cs index 9d6f9b974..9e56dd21a 100644 --- a/src/NzbDrone.Integration.Test/Client/IndexerClient.cs +++ b/src/NzbDrone.Integration.Test/Client/IndexerClient.cs @@ -1,4 +1,4 @@ -using NzbDrone.Api.Indexers; +using Lidarr.Api.V1.Indexers; using RestSharp; namespace NzbDrone.Integration.Test.Client @@ -10,4 +10,4 @@ namespace NzbDrone.Integration.Test.Client { } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Integration.Test/Client/LogsClient.cs b/src/NzbDrone.Integration.Test/Client/LogsClient.cs new file mode 100644 index 000000000..b64ec2971 --- /dev/null +++ b/src/NzbDrone.Integration.Test/Client/LogsClient.cs @@ -0,0 +1,24 @@ +using System; +using RestSharp; + +namespace NzbDrone.Integration.Test.Client +{ + public class LogsClient : ClientBase + { + public LogsClient(IRestClient restClient, string apiKey) + : base(restClient, apiKey, "log/file") + { + } + + public string[] GetLogFileLines(string filename) + { + var request = BuildRequest(filename); + var content = Execute(request, System.Net.HttpStatusCode.OK); + + var lines = content.Split('\n'); + lines = Array.ConvertAll(lines, s => s.TrimEnd('\r')); + Array.Resize(ref lines, lines.Length - 1); + return lines; + } + } +} \ No newline at end of file diff --git a/src/NzbDrone.Integration.Test/Client/NotificationClient.cs b/src/NzbDrone.Integration.Test/Client/NotificationClient.cs index 6f0f06eb5..678b1c210 100644 --- a/src/NzbDrone.Integration.Test/Client/NotificationClient.cs +++ b/src/NzbDrone.Integration.Test/Client/NotificationClient.cs @@ -1,5 +1,5 @@ -using System.Collections.Generic; -using NzbDrone.Api.Notifications; +using System.Collections.Generic; +using Lidarr.Api.V1.Notifications; using RestSharp; namespace NzbDrone.Integration.Test.Client @@ -17,4 +17,4 @@ namespace NzbDrone.Integration.Test.Client return Get<List<NotificationResource>>(request); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Integration.Test/Client/ReleaseClient.cs b/src/NzbDrone.Integration.Test/Client/ReleaseClient.cs index 46a6db839..1d7ad3548 100644 --- a/src/NzbDrone.Integration.Test/Client/ReleaseClient.cs +++ b/src/NzbDrone.Integration.Test/Client/ReleaseClient.cs @@ -1,4 +1,4 @@ -using NzbDrone.Api.Indexers; +using Lidarr.Api.V1.Indexers; using RestSharp; namespace NzbDrone.Integration.Test.Client diff --git a/src/NzbDrone.Integration.Test/Client/ReleasePushClient.cs b/src/NzbDrone.Integration.Test/Client/ReleasePushClient.cs new file mode 100644 index 000000000..3a65d4676 --- /dev/null +++ b/src/NzbDrone.Integration.Test/Client/ReleasePushClient.cs @@ -0,0 +1,13 @@ +using Lidarr.Api.V1.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/Client/SeriesClient.cs b/src/NzbDrone.Integration.Test/Client/SeriesClient.cs deleted file mode 100644 index 01ec8bfc7..000000000 --- a/src/NzbDrone.Integration.Test/Client/SeriesClient.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using NzbDrone.Api.Series; -using RestSharp; - -namespace NzbDrone.Integration.Test.Client -{ - public class SeriesClient : ClientBase<SeriesResource> - { - public SeriesClient(IRestClient restClient, string apiKey) - : base(restClient, apiKey) - { - } - - public List<SeriesResource> Lookup(string term) - { - var request = BuildRequest("lookup?term={term}"); - request.AddUrlSegment("term", term); - return Get<List<SeriesResource>>(request); - } - - public List<SeriesResource> Editor(List<SeriesResource> series) - { - var request = BuildRequest("editor"); - request.AddBody(series); - return Put<List<SeriesResource>>(request); - } - - public SeriesResource Get(string slug, HttpStatusCode statusCode = HttpStatusCode.OK) - { - var request = BuildRequest(slug); - return Get<SeriesResource>(request, statusCode); - } - - } - - public class SystemInfoClient : ClientBase<SeriesResource> - { - public SystemInfoClient(IRestClient restClient, string apiKey) - : base(restClient, apiKey) - { - } - } -} diff --git a/src/NzbDrone.Integration.Test/Client/TrackClient.cs b/src/NzbDrone.Integration.Test/Client/TrackClient.cs new file mode 100644 index 000000000..ad3ec8436 --- /dev/null +++ b/src/NzbDrone.Integration.Test/Client/TrackClient.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using Lidarr.Api.V1.Tracks; +using RestSharp; + +namespace NzbDrone.Integration.Test.Client +{ + public class TrackClient : ClientBase<TrackResource> + { + public TrackClient(IRestClient restClient, string apiKey) + : base(restClient, apiKey, "track") + { + } + + public List<TrackResource> GetTracksInArtist(int artistId) + { + var request = BuildRequest("?artistId=" + artistId.ToString()); + return Get<List<TrackResource>>(request); + } + } +} diff --git a/src/NzbDrone.Integration.Test/CorsFixture.cs b/src/NzbDrone.Integration.Test/CorsFixture.cs index 2d9d8ac4f..6b0a7bc70 100644 --- a/src/NzbDrone.Integration.Test/CorsFixture.cs +++ b/src/NzbDrone.Integration.Test/CorsFixture.cs @@ -1,6 +1,6 @@ -using FluentAssertions; +using FluentAssertions; using NUnit.Framework; -using NzbDrone.Api.Extensions; +using Lidarr.Http.Extensions; using RestSharp; namespace NzbDrone.Integration.Test @@ -8,19 +8,26 @@ namespace NzbDrone.Integration.Test [TestFixture] public class CorsFixture : IntegrationTest { - private RestRequest BuildRequest() + private RestRequest BuildGet(string route = "artist") { - var request = new RestRequest("series"); + var request = new RestRequest(route, Method.GET); request.AddHeader(AccessControlHeaders.RequestMethod, "POST"); return request; } - [Test] + private RestRequest BuildOptions(string route = "artist") + { + 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); } @@ -28,10 +35,10 @@ namespace NzbDrone.Integration.Test [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/HttpLogFixture.cs b/src/NzbDrone.Integration.Test/HttpLogFixture.cs index dfa2d722f..15e9fa949 100644 --- a/src/NzbDrone.Integration.Test/HttpLogFixture.cs +++ b/src/NzbDrone.Integration.Test/HttpLogFixture.cs @@ -1,4 +1,4 @@ -using System.IO; +using System; using System.Linq; using FluentAssertions; using NUnit.Framework; @@ -15,16 +15,20 @@ namespace NzbDrone.Integration.Test config.LogLevel = "Trace"; HostConfig.Put(config); - var logFile = Path.Combine(_runner.AppData, "logs", "sonarr.trace.txt"); - var logLines = File.ReadAllLines(logFile); + var resultGet = Artist.All(); - var result = Series.InvalidPost(new Api.Series.SeriesResource()); + var logFile = "Lidarr.trace.txt"; + var logLines = Logs.GetLogFileLines(logFile); - logLines = File.ReadAllLines(logFile).Skip(logLines.Length).ToArray(); + var result = Artist.InvalidPost(new Lidarr.Api.V1.Artist.ArtistResource()); - logLines.Should().Contain(v => v.Contains("|Trace|Http|Req")); - logLines.Should().Contain(v => v.Contains("|Trace|Http|Res")); - logLines.Should().Contain(v => v.Contains("|Debug|Api|")); + // Skip 2 and 1 to ignore the logs endpoint + logLines = Logs.GetLogFileLines(logFile).Skip(logLines.Length + 2).ToArray(); + Array.Resize(ref logLines, logLines.Length - 1); + + logLines.Should().Contain(v => v.Contains("|Trace|Http|Req") && v.Contains("/api/v1/artist/")); + logLines.Should().Contain(v => v.Contains("|Trace|Http|Res") && v.Contains("/api/v1/artist/: 400.BadRequest")); + logLines.Should().Contain(v => v.Contains("|Debug|Api|") && v.Contains("/api/v1/artist/: 400.BadRequest")); } } } diff --git a/src/NzbDrone.Integration.Test/IntegrationTest.cs b/src/NzbDrone.Integration.Test/IntegrationTest.cs index 87a71ec7a..d56c8b82c 100644 --- a/src/NzbDrone.Integration.Test/IntegrationTest.cs +++ b/src/NzbDrone.Integration.Test/IntegrationTest.cs @@ -1,7 +1,7 @@ -using System.Collections.Generic; using NLog; using NzbDrone.Core.Indexers.Newznab; using NzbDrone.Test.Common; +using Lidarr.Http.ClientSchema; namespace NzbDrone.Integration.Test { @@ -9,9 +9,9 @@ namespace NzbDrone.Integration.Test { protected NzbDroneRunner _runner; - public override string SeriesRootFolder => GetTempDirectory("SeriesRootFolder"); + public override string ArtistRootFolder => GetTempDirectory("ArtistRootFolder"); - protected override string RootUrl => "http://localhost:8989/"; + protected override string RootUrl => "http://localhost:8686/"; protected override string ApiKey => _runner.ApiKey; @@ -25,16 +25,22 @@ namespace NzbDrone.Integration.Test protected override void InitializeTestTarget() { - Indexers.Post(new Api.Indexers.IndexerResource + Indexers.Post(new Lidarr.Api.V1.Indexers.IndexerResource { EnableRss = false, - EnableSearch = false, + EnableInteractiveSearch = false, + EnableAutomaticSearch = false, ConfigContract = nameof(NewznabSettings), Implementation = nameof(Newznab), Name = "NewznabTest", Protocol = Core.Indexers.DownloadProtocol.Usenet, - Fields = Api.ClientSchema.SchemaBuilder.ToSchema(new NewznabSettings()) + Fields = SchemaBuilder.ToSchema(new NewznabSettings()) }); + + // Change Console Log Level to Debug so we get more details. + var config = HostConfig.Get(1); + config.ConsoleLogLevel = "Debug"; + HostConfig.Put(config); } protected override void StopTestTarget() diff --git a/src/NzbDrone.Integration.Test/IntegrationTestBase.cs b/src/NzbDrone.Integration.Test/IntegrationTestBase.cs index cf6593d04..8d10c2b70 100644 --- a/src/NzbDrone.Integration.Test/IntegrationTestBase.cs +++ b/src/NzbDrone.Integration.Test/IntegrationTestBase.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Threading; @@ -10,25 +11,28 @@ using NLog; using NLog.Config; using NLog.Targets; using NUnit.Framework; -using NzbDrone.Api.Blacklist; -using NzbDrone.Api.Commands; -using NzbDrone.Api.Config; -using NzbDrone.Api.DownloadClient; -using NzbDrone.Api.EpisodeFiles; -using NzbDrone.Api.Episodes; -using NzbDrone.Api.History; -using NzbDrone.Api.Profiles; -using NzbDrone.Api.RootFolders; -using NzbDrone.Api.Series; -using NzbDrone.Api.Tags; +using Lidarr.Api.V1.Blacklist; +using Lidarr.Api.V1.Commands; +using Lidarr.Api.V1.Config; +using Lidarr.Api.V1.DownloadClient; +using Lidarr.Api.V1.TrackFiles; +using Lidarr.Api.V1.History; +using Lidarr.Api.V1.Profiles.Quality; +using Lidarr.Api.V1.RootFolders; +using Lidarr.Api.V1.Artist; +using Lidarr.Api.V1.Albums; +using Lidarr.Api.V1.Tracks; +using Lidarr.Api.V1.Tags; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Serializer; using NzbDrone.Core.Qualities; -using NzbDrone.Core.Tv.Commands; +using NzbDrone.Core.Music.Commands; using NzbDrone.Integration.Test.Client; using NzbDrone.SignalR; using NzbDrone.Test.Common.Categories; using RestSharp; +using NzbDrone.Core.MediaFiles.TrackImport.Manual; +using NzbDrone.Test.Common; namespace NzbDrone.Integration.Test { @@ -40,19 +44,22 @@ namespace NzbDrone.Integration.Test public ClientBase<BlacklistResource> Blacklist; public CommandClient Commands; public DownloadClientClient DownloadClients; - public EpisodeClient Episodes; + public AlbumClient Albums; + public TrackClient Tracks; public ClientBase<HistoryResource> History; public ClientBase<HostConfigResource> HostConfig; public IndexerClient Indexers; + public LogsClient Logs; public ClientBase<NamingConfigResource> NamingConfig; public NotificationClient Notifications; - public ClientBase<ProfileResource> Profiles; + public ClientBase<QualityProfileResource> Profiles; public ReleaseClient Releases; + public ReleasePushClient ReleasePush; public ClientBase<RootFolderResource> RootFolders; - public SeriesClient Series; + public ArtistClient Artist; public ClientBase<TagResource> Tags; - public ClientBase<EpisodeResource> WantedMissing; - public ClientBase<EpisodeResource> WantedCutoffUnmet; + public ClientBase<AlbumResource> WantedMissing; + public ClientBase<AlbumResource> WantedCutoffUnmet; private List<SignalRMessage> _signalRReceived; private Connection _signalrConnection; @@ -71,7 +78,7 @@ namespace NzbDrone.Integration.Test public string TempDirectory { get; private set; } - public abstract string SeriesRootFolder { get; } + public abstract string ArtistRootFolder { get; } protected abstract string RootUrl { get; } @@ -93,26 +100,29 @@ namespace NzbDrone.Integration.Test protected virtual void InitRestClients() { - RestClient = new RestClient(RootUrl + "api/"); + RestClient = new RestClient(RootUrl + "api/v1/"); RestClient.AddDefaultHeader("Authentication", ApiKey); RestClient.AddDefaultHeader("X-Api-Key", ApiKey); Blacklist = new ClientBase<BlacklistResource>(RestClient, ApiKey); Commands = new CommandClient(RestClient, ApiKey); DownloadClients = new DownloadClientClient(RestClient, ApiKey); - Episodes = new EpisodeClient(RestClient, ApiKey); + Albums = new AlbumClient(RestClient, ApiKey); + Tracks = new TrackClient(RestClient, ApiKey); History = new ClientBase<HistoryResource>(RestClient, ApiKey); HostConfig = new ClientBase<HostConfigResource>(RestClient, ApiKey, "config/host"); Indexers = new IndexerClient(RestClient, ApiKey); + Logs = new LogsClient(RestClient, ApiKey); NamingConfig = new ClientBase<NamingConfigResource>(RestClient, ApiKey, "config/naming"); Notifications = new NotificationClient(RestClient, ApiKey); - Profiles = new ClientBase<ProfileResource>(RestClient, ApiKey); + Profiles = new ClientBase<QualityProfileResource>(RestClient, ApiKey); Releases = new ReleaseClient(RestClient, ApiKey); + ReleasePush = new ReleasePushClient(RestClient, ApiKey); RootFolders = new ClientBase<RootFolderResource>(RestClient, ApiKey); - Series = new SeriesClient(RestClient, ApiKey); + Artist = new ArtistClient(RestClient, ApiKey); Tags = new ClientBase<TagResource>(RestClient, ApiKey); - WantedMissing = new ClientBase<EpisodeResource>(RestClient, ApiKey, "wanted/missing"); - WantedCutoffUnmet = new ClientBase<EpisodeResource>(RestClient, ApiKey, "wanted/cutoff"); + WantedMissing = new ClientBase<AlbumResource>(RestClient, ApiKey, "wanted/missing"); + WantedCutoffUnmet = new ClientBase<AlbumResource>(RestClient, ApiKey, "wanted/cutoff"); } [OneTimeTearDown] @@ -124,7 +134,10 @@ namespace NzbDrone.Integration.Test [SetUp] public void IntegrationSetUp() { - TempDirectory = Path.Combine(TestContext.CurrentContext.TestDirectory, "_test_" + DateTime.UtcNow.Ticks); + TempDirectory = Path.Combine(TestContext.CurrentContext.TestDirectory, "_test_" + TestBase.GetUID()); + + // Wait for things to get quiet, otherwise the previous test might influence the current one. + Commands.WaitAll(); } [TearDown] @@ -145,6 +158,17 @@ namespace NzbDrone.Integration.Test _signalrConnection = null; _signalRReceived = new List<SignalRMessage>(); } + + if (Directory.Exists(TempDirectory)) + { + try + { + Directory.Delete(TempDirectory, true); + } + catch + { + } + } } public string GetTempDirectory(params string[] args) @@ -159,7 +183,7 @@ namespace NzbDrone.Integration.Test protected void ConnectSignalR() { _signalRReceived = new List<SignalRMessage>(); - _signalrConnection = new Connection("http://localhost:8989/signalr"); + _signalrConnection = new Connection("http://localhost:8686/signalr"); _signalrConnection.Start(new LongPollingTransport()).ContinueWith(task => { if (task.IsFaulted) @@ -202,92 +226,102 @@ namespace NzbDrone.Integration.Test Assert.Fail("Timed on wait"); } - public SeriesResource EnsureSeries(int tvdbId, string seriesTitle, bool? monitored = null) + public ArtistResource EnsureArtist(string lidarrId, string artistName, bool? monitored = null) { - var result = Series.All().FirstOrDefault(v => v.TvdbId == tvdbId); + var result = Artist.All().FirstOrDefault(v => v.ForeignArtistId == lidarrId); if (result == null) { - var lookup = Series.Lookup("tvdb:" + tvdbId); - var series = lookup.First(); - series.ProfileId = 1; - series.Path = Path.Combine(SeriesRootFolder, series.Title); - series.Monitored = true; - series.Seasons.ForEach(v => v.Monitored = true); - series.AddOptions = new Core.Tv.AddSeriesOptions(); - Directory.CreateDirectory(series.Path); - - result = Series.Post(series); + var lookup = Artist.Lookup("lidarr:" + lidarrId); + var artist = lookup.First(); + artist.QualityProfileId = 1; + artist.MetadataProfileId = 1; + artist.Path = Path.Combine(ArtistRootFolder, artist.ArtistName); + artist.Monitored = true; + artist.AddOptions = new Core.Music.AddArtistOptions(); + Directory.CreateDirectory(artist.Path); + + result = Artist.Post(artist); Commands.WaitAll(); - WaitForCompletion(() => Episodes.GetEpisodesInSeries(result.Id).Count > 0); + WaitForCompletion(() => Tracks.GetTracksInArtist(result.Id).Count > 0); + } + + var changed = false; + + if (result.RootFolderPath != ArtistRootFolder) + { + changed = true; + result.RootFolderPath = ArtistRootFolder; + result.Path = Path.Combine(ArtistRootFolder, result.ArtistName); } if (monitored.HasValue) { - var changed = false; if (result.Monitored != monitored.Value) { result.Monitored = monitored.Value; changed = true; } + } - result.Seasons.ForEach(season => - { - if (season.Monitored != monitored.Value) - { - season.Monitored = monitored.Value; - changed = true; - } - }); - - if (changed) - { - Series.Put(result); - } + if (changed) + { + Artist.Put(result); } return result; } - public void EnsureNoSeries(int tvdbId, string seriesTitle) + + public void EnsureNoArtist(string lidarrId, string artistTitle) { - var result = Series.All().FirstOrDefault(v => v.TvdbId == tvdbId); + var result = Artist.All().FirstOrDefault(v => v.ForeignArtistId == lidarrId); if (result != null) { - Series.Delete(result.Id); + Artist.Delete(result.Id); } } - public EpisodeFileResource EnsureEpisodeFile(SeriesResource series, int season, int episode, Quality quality) + public void EnsureTrackFile(ArtistResource artist, int albumId, int albumReleaseId, int trackId, Quality quality) { - var result = Episodes.GetEpisodesInSeries(series.Id).Single(v => v.SeasonNumber == season && v.EpisodeNumber == episode); + var result = Tracks.GetTracksInArtist(artist.Id).Single(v => v.Id == trackId); - if (result.EpisodeFile == null) + if (result.TrackFile == null) { - var path = Path.Combine(SeriesRootFolder, series.Title, string.Format("Series.S{0}E{1}.{2}.mkv", season, episode, quality.Name)); + var path = Path.Combine(ArtistRootFolder, artist.ArtistName, "Track.mp3"); Directory.CreateDirectory(Path.GetDirectoryName(path)); - File.WriteAllText(path, "Fake Episode"); - - Commands.PostAndWait(new CommandResource { Name = "refreshseries", Body = new RefreshSeriesCommand(series.Id) }); + File.WriteAllText(path, "Fake Track"); + + Commands.PostAndWait(new ManualImportCommand { + Files = new List<ManualImportFile> { + new ManualImportFile { + Path = path, + ArtistId = artist.Id, + AlbumId = albumId, + AlbumReleaseId = albumReleaseId, + TrackIds = new List<int> { trackId }, + Quality = new QualityModel(quality) + } + } + }); Commands.WaitAll(); - result = Episodes.GetEpisodesInSeries(series.Id).Single(v => v.SeasonNumber == season && v.EpisodeNumber == episode); + var track = Tracks.GetTracksInArtist(artist.Id).Single(x => x.Id == trackId); - result.EpisodeFile.Should().NotBeNull(); + track.TrackFileId.Should().NotBe(0); } - - return result.EpisodeFile; } - public ProfileResource EnsureProfileCutoff(int profileId, Quality cutoff) + public QualityProfileResource EnsureProfileCutoff(int profileId, string cutoff) { var profile = Profiles.Get(profileId); + var cutoffItem = profile.Items.First(x => x.Name == cutoff); - if (profile.Cutoff != cutoff) + if (profile.Cutoff != cutoffItem.Id) { - profile.Cutoff = cutoff; + profile.Cutoff = cutoffItem.Id; profile = Profiles.Put(profile); } @@ -326,8 +360,8 @@ namespace NzbDrone.Integration.Test schema.Enable = enabled; schema.Name = "Test UsenetBlackhole"; - schema.Fields.First(v => v.Name == "WatchFolder").Value = GetTempDirectory("Download", "UsenetBlackhole", "Watch"); - schema.Fields.First(v => v.Name == "NzbFolder").Value = GetTempDirectory("Download", "UsenetBlackhole", "Nzb"); + schema.Fields.First(v => v.Name == "watchFolder").Value = GetTempDirectory("Download", "UsenetBlackhole", "Watch"); + schema.Fields.First(v => v.Name == "nzbFolder").Value = GetTempDirectory("Download", "UsenetBlackhole", "Nzb"); client = DownloadClients.Post(schema); } diff --git a/src/NzbDrone.Integration.Test/Lidarr.Integration.Test.csproj b/src/NzbDrone.Integration.Test/Lidarr.Integration.Test.csproj new file mode 100644 index 000000000..051d08dbd --- /dev/null +++ b/src/NzbDrone.Integration.Test/Lidarr.Integration.Test.csproj @@ -0,0 +1,13 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net462</TargetFramework> + <Platforms>x86</Platforms> + </PropertyGroup> + <ItemGroup> + <PackageReference Include="Microsoft.AspNet.SignalR.Client" Version="2.4.0" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\Lidarr.Api.V1\Lidarr.Api.V1.csproj" /> + <ProjectReference Include="..\NzbDrone.Test.Common\Lidarr.Test.Common.csproj" /> + </ItemGroup> +</Project> diff --git a/src/NzbDrone.Integration.Test/NzbDrone.Integration.Test.csproj b/src/NzbDrone.Integration.Test/NzbDrone.Integration.Test.csproj deleted file mode 100644 index e4561030a..000000000 --- a/src/NzbDrone.Integration.Test/NzbDrone.Integration.Test.csproj +++ /dev/null @@ -1,198 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" /> - <PropertyGroup> - <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> - <Platform Condition=" '$(Platform)' == '' ">x86</Platform> - <ProjectGuid>{8CEFECD0-A6C2-498F-98B1-3FBE5820F9AB}</ProjectGuid> - <OutputType>Library</OutputType> - <AppDesignerFolder>Properties</AppDesignerFolder> - <RootNamespace>NzbDrone.Integration.Test</RootNamespace> - <AssemblyName>NzbDrone.Integration.Test</AssemblyName> - <TargetFrameworkVersion>v4.0</TargetFrameworkVersion> - <FileAlignment>512</FileAlignment> - <SolutionDir Condition="$(SolutionDir) == '' Or $(SolutionDir) == '*Undefined*'">..\</SolutionDir> - <RestorePackages>true</RestorePackages> - <ProductVersion>12.0.0</ProductVersion> - <SchemaVersion>2.0</SchemaVersion> - </PropertyGroup> - <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x86'"> - <DebugSymbols>true</DebugSymbols> - <OutputPath>bin\x86\Debug\</OutputPath> - <DefineConstants>DEBUG;TRACE</DefineConstants> - <DebugType>full</DebugType> - <PlatformTarget>x86</PlatformTarget> - <ErrorReport>prompt</ErrorReport> - <CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet> - <WarningLevel>4</WarningLevel> - <Optimize>false</Optimize> - </PropertyGroup> - <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x86'"> - <OutputPath>bin\x86\Release\</OutputPath> - <DefineConstants>TRACE</DefineConstants> - <Optimize>true</Optimize> - <DebugType>pdbonly</DebugType> - <PlatformTarget>x86</PlatformTarget> - <ErrorReport>prompt</ErrorReport> - <CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet> - <WarningLevel>4</WarningLevel> - </PropertyGroup> - <ItemGroup> - <Reference Include="FluentAssertions, Version=4.18.0.0, Culture=neutral, PublicKeyToken=33f2691a05b67b6a, processorArchitecture=MSIL"> - <HintPath>..\packages\FluentAssertions.4.18.0\lib\net40\FluentAssertions.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="FluentAssertions.Core, Version=4.18.0.0, Culture=neutral, PublicKeyToken=33f2691a05b67b6a, processorArchitecture=MSIL"> - <HintPath>..\packages\FluentAssertions.4.18.0\lib\net40\FluentAssertions.Core.dll</HintPath> - <Private>True</Private> - </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> - <Private>True</Private> - </Reference> - <Reference Include="Microsoft.AspNet.SignalR.Client"> - <HintPath>..\packages\Microsoft.AspNet.SignalR.Client.1.2.1\lib\net40\Microsoft.AspNet.SignalR.Client.dll</HintPath> - </Reference> - <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> - </Reference> - <Reference Include="Microsoft.Owin.Host.HttpListener"> - <HintPath>..\packages\Microsoft.Owin.Host.HttpListener.2.1.0\lib\net40\Microsoft.Owin.Host.HttpListener.dll</HintPath> - </Reference> - <Reference Include="Microsoft.Owin.Hosting, Version=2.1.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL"> - <SpecificVersion>False</SpecificVersion> - <HintPath>..\packages\Microsoft.Owin.Hosting.2.1.0\lib\net40\Microsoft.Owin.Hosting.dll</HintPath> - </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> - </Reference> - <Reference Include="Nancy.Owin, Version=1.4.1.0, Culture=neutral, processorArchitecture=MSIL"> - <HintPath>..\packages\Nancy.Owin.1.4.1\lib\net40\Nancy.Owin.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.1\lib\net40\NLog.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="nunit.framework, Version=3.5.0.0, Culture=neutral, PublicKeyToken=2638cd05610744eb, processorArchitecture=MSIL"> - <HintPath>..\packages\NUnit.3.5.0\lib\net40\nunit.framework.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="RestSharp, Version=105.2.3.0, Culture=neutral, processorArchitecture=MSIL"> - <HintPath>..\packages\RestSharp.105.2.3\lib\net4\RestSharp.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="System" /> - <Reference Include="System.Core" /> - <Reference Include="System.Data" /> - <Reference Include="System.Data.DataSetExtensions" /> - <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> - </ItemGroup> - <ItemGroup> - <Compile Include="ApiTests\DiskSpaceFixture.cs" /> - <Compile Include="ApiTests\CalendarFixture.cs" /> - <Compile Include="ApiTests\BlacklistFixture.cs" /> - <Compile Include="ApiTests\DownloadClientFixture.cs" /> - <Compile Include="ApiTests\EpisodeFileFixture.cs" /> - <Compile Include="ApiTests\FileSystemFixture.cs" /> - <Compile Include="ApiTests\SeriesLookupFixture.cs" /> - <Compile Include="ApiTests\WantedFixture.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\ReleaseClient.cs" /> - <Compile Include="Client\SeriesClient.cs" /> - <Compile Include="ApiTests\CommandFixture.cs" /> - <Compile Include="CorsFixture.cs" /> - <Compile Include="ApiTests\EpisodeFixture.cs" /> - <Compile Include="ApiTests\HistoryFixture.cs" /> - <Compile Include="ApiTests\IndexerFixture.cs" /> - <Compile Include="HttpLogFixture.cs" /> - <Compile Include="IndexHtmlFixture.cs" /> - <Compile Include="IntegrationTest.cs" /> - <Compile Include="IntegrationTestBase.cs" /> - <Compile Include="ApiTests\NamingConfigFixture.cs" /> - <Compile Include="ApiTests\NotificationFixture.cs" /> - <Compile Include="Properties\AssemblyInfo.cs" /> - <Compile Include="ApiTests\ReleaseFixture.cs" /> - <Compile Include="ApiTests\RootFolderFixture.cs" /> - <Compile Include="ApiTests\SeriesEditorFixture.cs" /> - <Compile Include="ApiTests\SeriesFixture.cs" /> - </ItemGroup> - <ItemGroup> - <None Include="..\NzbDrone.Test.Common\App.config"> - <Link>App.config</Link> - </None> - <None Include="packages.config" /> - </ItemGroup> - <ItemGroup> - <ProjectReference Include="..\NzbDrone.Api\NzbDrone.Api.csproj"> - <Project>{FD286DF8-2D3A-4394-8AD5-443FADE55FB2}</Project> - <Name>NzbDrone.Api</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.Common\NzbDrone.Common.csproj"> - <Project>{F2BE0FDF-6E47-4827-A420-DD4EF82407F8}</Project> - <Name>NzbDrone.Common</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.Core\NzbDrone.Core.csproj"> - <Project>{FF5EE3B6-913B-47CE-9CEB-11C51B4E1205}</Project> - <Name>NzbDrone.Core</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.Host\NzbDrone.Host.csproj"> - <Project>{95C11A9E-56ED-456A-8447-2C89C1139266}</Project> - <Name>NzbDrone.Host</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.SignalR\NzbDrone.SignalR.csproj"> - <Project>{7C2CC69F-5CA0-4E5C-85CB-983F9F6C3B36}</Project> - <Name>NzbDrone.SignalR</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.Test.Common\NzbDrone.Test.Common.csproj"> - <Project>{CADDFCE0-7509-4430-8364-2074E1EEFCA2}</Project> - <Name>NzbDrone.Test.Common</Name> - </ProjectReference> - </ItemGroup> - <ItemGroup> - <Content Include="..\Libraries\Sqlite\sqlite3.dll"> - <Link>sqlite3.dll</Link> - <CopyToOutputDirectory>Always</CopyToOutputDirectory> - </Content> - </ItemGroup> - <ItemGroup> - <Service Include="{82A7F48D-3B50-4B1E-B82E-3ADA8210C358}" /> - </ItemGroup> - <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> - <PropertyGroup> - <PostBuildEvent Condition="('$(OS)' == 'Windows_NT')"> - xcopy /s /y "$(SolutionDir)\..\_output\NzbDrone.Mono.*" "$(TargetDir)" - xcopy /s /y "$(SolutionDir)\..\_output\NzbDrone.Windows.*" "$(TargetDir)" - </PostBuildEvent> - <PostBuildEvent Condition="('$(OS)' != 'Windows_NT')"> - cp -rv $(SolutionDir)\..\_output\NzbDrone.Mono.* $(TargetDir) - cp -rv $(SolutionDir)\..\_output\NzbDrone.Windows.* $(TargetDir) - </PostBuildEvent> - </PropertyGroup> - <!-- To modify your build process, add your task inside one of the targets below and uncomment it. - Other similar extension points exist, see Microsoft.Common.targets. - <Target Name="BeforeBuild"> - </Target> - <Target Name="AfterBuild"> - </Target> - --> -</Project> \ No newline at end of file diff --git a/src/NzbDrone.Integration.Test/Properties/AssemblyInfo.cs b/src/NzbDrone.Integration.Test/Properties/AssemblyInfo.cs deleted file mode 100644 index 5183f6f7e..000000000 --- a/src/NzbDrone.Integration.Test/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("NzbDrone.Smoke.Test")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("NzbDrone.Smoke.Test")] -[assembly: AssemblyCopyright("Copyright © 2013")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("8a49cb1d-87ac-42f9-a582-607365a6bd79")] - -[assembly: AssemblyVersion("10.0.0.*")] diff --git a/src/NzbDrone.Integration.Test/packages.config b/src/NzbDrone.Integration.Test/packages.config deleted file mode 100644 index f3318330a..000000000 --- a/src/NzbDrone.Integration.Test/packages.config +++ /dev/null @@ -1,17 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<packages> - <package id="FluentAssertions" version="4.18.0" targetFramework="net40" /> - <package id="FluentValidation" version="6.2.1.0" targetFramework="net40" /> - <package id="Microsoft.AspNet.SignalR.Client" version="1.2.1" targetFramework="net40" /> - <package id="Microsoft.Owin" version="2.1.0" targetFramework="net40" /> - <package id="Microsoft.Owin.Host.HttpListener" version="2.1.0" targetFramework="net40" /> - <package id="Microsoft.Owin.Hosting" version="2.1.0" targetFramework="net40" /> - <package id="Moq" version="4.0.10827" targetFramework="net40" /> - <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.1" targetFramework="net40" /> - <package id="NUnit" version="3.5.0" targetFramework="net40" /> - <package id="Owin" version="1.0" targetFramework="net40" /> - <package id="RestSharp" version="105.2.3" targetFramework="net40" /> -</packages> \ No newline at end of file diff --git a/src/NzbDrone.Libraries.Test/Lidarr.Libraries.Test.csproj b/src/NzbDrone.Libraries.Test/Lidarr.Libraries.Test.csproj new file mode 100644 index 000000000..33985d0a6 --- /dev/null +++ b/src/NzbDrone.Libraries.Test/Lidarr.Libraries.Test.csproj @@ -0,0 +1,9 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net462</TargetFramework> + <Platforms>x86</Platforms> + </PropertyGroup> + <ItemGroup> + <ProjectReference Include="..\NzbDrone.Test.Common\Lidarr.Test.Common.csproj" /> + </ItemGroup> +</Project> diff --git a/src/NzbDrone.Libraries.Test/NzbDrone.Libraries.Test.csproj b/src/NzbDrone.Libraries.Test/NzbDrone.Libraries.Test.csproj deleted file mode 100644 index b4c570063..000000000 --- a/src/NzbDrone.Libraries.Test/NzbDrone.Libraries.Test.csproj +++ /dev/null @@ -1,98 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" /> - <PropertyGroup> - <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> - <Platform Condition=" '$(Platform)' == '' ">x86</Platform> - <ProjectGuid>{CBF6B8B0-A015-413A-8C86-01238BB45770}</ProjectGuid> - <OutputType>Library</OutputType> - <AppDesignerFolder>Properties</AppDesignerFolder> - <RootNamespace>NzbDrone.Libraries.Test</RootNamespace> - <AssemblyName>NzbDrone.Libraries.Test</AssemblyName> - <TargetFrameworkVersion>v4.0</TargetFrameworkVersion> - <FileAlignment>512</FileAlignment> - <SolutionDir Condition="$(SolutionDir) == '' Or $(SolutionDir) == '*Undefined*'">..\</SolutionDir> - <RestorePackages>true</RestorePackages> - <ProductVersion>12.0.0</ProductVersion> - <SchemaVersion>2.0</SchemaVersion> - </PropertyGroup> - <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x86'"> - <DebugSymbols>true</DebugSymbols> - <OutputPath>bin\x86\Debug\</OutputPath> - <DefineConstants>DEBUG;TRACE</DefineConstants> - <DebugType>full</DebugType> - <PlatformTarget>x86</PlatformTarget> - <ErrorReport>prompt</ErrorReport> - <CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet> - <WarningLevel>4</WarningLevel> - <Optimize>false</Optimize> - </PropertyGroup> - <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x86'"> - <OutputPath>bin\x86\Release\</OutputPath> - <DefineConstants>TRACE</DefineConstants> - <Optimize>true</Optimize> - <DebugType>pdbonly</DebugType> - <PlatformTarget>x86</PlatformTarget> - <ErrorReport>prompt</ErrorReport> - <CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet> - <WarningLevel>4</WarningLevel> - </PropertyGroup> - <ItemGroup> - <Reference Include="FluentAssertions, Version=4.18.0.0, Culture=neutral, PublicKeyToken=33f2691a05b67b6a, processorArchitecture=MSIL"> - <HintPath>..\packages\FluentAssertions.4.18.0\lib\net40\FluentAssertions.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="FluentAssertions.Core, Version=4.18.0.0, Culture=neutral, PublicKeyToken=33f2691a05b67b6a, processorArchitecture=MSIL"> - <HintPath>..\packages\FluentAssertions.4.18.0\lib\net40\FluentAssertions.Core.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="nunit.framework, Version=3.5.0.0, Culture=neutral, PublicKeyToken=2638cd05610744eb, processorArchitecture=MSIL"> - <HintPath>..\packages\NUnit.3.5.0\lib\net40\nunit.framework.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="System" /> - <Reference Include="System.Core" /> - <Reference Include="System.Data" /> - <Reference Include="System.Data.DataSetExtensions" /> - <Reference Include="System.Xml" /> - <Reference Include="System.Xml.Linq" /> - <Reference Include="Microsoft.CSharp" /> - </ItemGroup> - <ItemGroup> - <Compile Include="JsonTests\JsonFixture.cs" /> - <Compile Include="Properties\AssemblyInfo.cs" /> - </ItemGroup> - <ItemGroup> - <None Include="app.config" /> - <None Include="packages.config" /> - </ItemGroup> - <ItemGroup> - <ProjectReference Include="..\NzbDrone.Common\NzbDrone.Common.csproj"> - <Project>{F2BE0FDF-6E47-4827-A420-DD4EF82407F8}</Project> - <Name>NzbDrone.Common</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.Core\NzbDrone.Core.csproj"> - <Project>{FF5EE3B6-913B-47CE-9CEB-11C51B4E1205}</Project> - <Name>NzbDrone.Core</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.Test.Common\NzbDrone.Test.Common.csproj"> - <Project>{CADDFCE0-7509-4430-8364-2074E1EEFCA2}</Project> - <Name>NzbDrone.Test.Common</Name> - </ProjectReference> - </ItemGroup> - <ItemGroup> - <Service Include="{82A7F48D-3B50-4B1E-B82E-3ADA8210C358}" /> - </ItemGroup> - <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> - <!-- To modify your build process, add your task inside one of the targets below and uncomment it. - Other similar extension points exist, see Microsoft.Common.targets. - <Target Name="BeforeBuild"> - </Target> - <Target Name="AfterBuild"> - </Target> - --> -</Project> \ No newline at end of file diff --git a/src/NzbDrone.Libraries.Test/Properties/AssemblyInfo.cs b/src/NzbDrone.Libraries.Test/Properties/AssemblyInfo.cs deleted file mode 100644 index 8d91461ae..000000000 --- a/src/NzbDrone.Libraries.Test/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("NzbDrone.Libraries.Test")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("NzbDrone.Libraries.Test")] -[assembly: AssemblyCopyright("Copyright © 2013")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("32ec29e2-40ba-4050-917d-e295d85d4969")] - -[assembly: AssemblyVersion("10.0.0.*")] diff --git a/src/NzbDrone.Libraries.Test/app.config b/src/NzbDrone.Libraries.Test/app.config index c1684a7be..4d2f70473 100644 --- a/src/NzbDrone.Libraries.Test/app.config +++ b/src/NzbDrone.Libraries.Test/app.config @@ -4,12 +4,16 @@ <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1"> <dependentAssembly> <assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" /> - <bindingRedirect oldVersion="0.0.0.0-9.0.0.0" newVersion="9.0.0.0" /> + <bindingRedirect oldVersion="0.0.0.0-12.0.0.0" newVersion="12.0.0.0" /> </dependentAssembly> <dependentAssembly> <assemblyIdentity name="FluentMigrator" publicKeyToken="aacfc7de5acabf05" culture="neutral" /> <bindingRedirect oldVersion="0.0.0.0-1.3.1.0" newVersion="1.3.1.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 +<startup><supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.6.2" /></startup></configuration> diff --git a/src/NzbDrone.Libraries.Test/packages.config b/src/NzbDrone.Libraries.Test/packages.config deleted file mode 100644 index 378e2d631..000000000 --- a/src/NzbDrone.Libraries.Test/packages.config +++ /dev/null @@ -1,6 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<packages> - <package id="FluentAssertions" version="4.18.0" targetFramework="net40" /> - <package id="Newtonsoft.Json" version="9.0.1" targetFramework="net40" /> - <package id="NUnit" version="3.5.0" targetFramework="net40" /> -</packages> \ No newline at end of file diff --git a/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs b/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs index 47cb7fccc..956734535 100644 --- a/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs +++ b/src/NzbDrone.Mono.Test/DiskProviderTests/DiskProviderFixture.cs @@ -1,6 +1,12 @@ -using System; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using FluentAssertions; using Mono.Unix; +using Moq; using NUnit.Framework; +using NzbDrone.Common.Disk; using NzbDrone.Common.Test.DiskTests; using NzbDrone.Mono.Disk; @@ -35,5 +41,91 @@ 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"); + } + + private void GivenSpecialMount(string rootDir) + { + Mocker.GetMock<ISymbolicLinkResolver>() + .Setup(v => v.GetCompleteRealPath(It.IsAny<string>())) + .Returns<string>(s => s); + + Mocker.GetMock<IProcMountProvider>() + .Setup(v => v.GetMounts()) + .Returns(new List<IMount> { + new ProcMount(DriveType.Fixed, rootDir, rootDir, "myfs", new MountOptions(new Dictionary<string, string>())) + }); + } + + [TestCase("/snap/blaat")] + [TestCase("/var/lib/docker/zfs-storage-mount")] + public void should_ignore_special_mounts(string rootDir) + { + GivenSpecialMount(rootDir); + + var mounts = Subject.GetMounts(); + + mounts.Select(d => d.RootDirectory).Should().NotContain(rootDir); + } + + [TestCase("/snap/blaat")] + [TestCase("/var/lib/docker/zfs-storage-mount")] + public void should_return_special_mount_when_queried(string rootDir) + { + GivenSpecialMount(rootDir); + + var mount = Subject.GetMount(Path.Combine(rootDir, "dir/somefile.mkv")); + + mount.Should().NotBeNull(); + mount.RootDirectory.Should().Be(rootDir); + } } } diff --git a/src/NzbDrone.Mono.Test/DiskProviderTests/FreeSpaceFixture.cs b/src/NzbDrone.Mono.Test/DiskProviderTests/FreeSpaceFixture.cs index 5c2e67b4d..1f61f6c65 100644 --- a/src/NzbDrone.Mono.Test/DiskProviderTests/FreeSpaceFixture.cs +++ b/src/NzbDrone.Mono.Test/DiskProviderTests/FreeSpaceFixture.cs @@ -1,4 +1,7 @@ -using NUnit.Framework; +using System.Collections.Generic; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Disk; using NzbDrone.Common.Test.DiskTests; using NzbDrone.Mono.Disk; @@ -8,6 +11,13 @@ namespace NzbDrone.Mono.Test.DiskProviderTests [Platform("Mono")] public class FreeSpaceFixture : FreeSpaceFixtureBase<DiskProvider> { + [SetUp] + public void Setup() + { + Mocker.SetConstant<ISymbolicLinkResolver>(Mocker.Resolve<SymbolicLinkResolver>()); + Mocker.SetConstant<IProcMountProvider>(Mocker.Resolve<ProcMountProvider>()); + } + public FreeSpaceFixture() { MonoOnly(); diff --git a/src/NzbDrone.Mono.Test/EnvironmentInfo/MonoPlatformInfoFixture.cs b/src/NzbDrone.Mono.Test/EnvironmentInfo/MonoPlatformInfoFixture.cs index 3e0c17794..80fce9cc1 100644 --- a/src/NzbDrone.Mono.Test/EnvironmentInfo/MonoPlatformInfoFixture.cs +++ b/src/NzbDrone.Mono.Test/EnvironmentInfo/MonoPlatformInfoFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using FluentAssertions; using NUnit.Framework; using NzbDrone.Mono.EnvironmentInfo; @@ -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, 6); + if (Subject.Version.Major == 4) + { + Subject.Version.Minor.Should().BeOneOf(0, 5, 6); + } } } } diff --git a/src/NzbDrone.Mono.Test/EnvironmentInfo/ReleaseFileVersionAdapterFixture.cs b/src/NzbDrone.Mono.Test/EnvironmentInfo/ReleaseFileVersionAdapterFixture.cs index c2772e215..126b8ab86 100644 --- a/src/NzbDrone.Mono.Test/EnvironmentInfo/ReleaseFileVersionAdapterFixture.cs +++ b/src/NzbDrone.Mono.Test/EnvironmentInfo/ReleaseFileVersionAdapterFixture.cs @@ -8,7 +8,7 @@ using NzbDrone.Test.Common; namespace NzbDrone.Mono.Test.EnvironmentInfo { [TestFixture] - [Platform("Mono")] + [Platform("Linux")] public class ReleaseFileVersionAdapterFixture : TestBase<ReleaseFileVersionAdapter> { [SetUp] @@ -26,4 +26,4 @@ namespace NzbDrone.Mono.Test.EnvironmentInfo info.Version.Should().NotBeNullOrWhiteSpace(); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Mono.Test/EnvironmentInfo/VersionAdapters/ReleaseFileVersionAdapterFixture.cs b/src/NzbDrone.Mono.Test/EnvironmentInfo/VersionAdapters/ReleaseFileVersionAdapterFixture.cs index 2979b1544..f3f9d28a5 100644 --- a/src/NzbDrone.Mono.Test/EnvironmentInfo/VersionAdapters/ReleaseFileVersionAdapterFixture.cs +++ b/src/NzbDrone.Mono.Test/EnvironmentInfo/VersionAdapters/ReleaseFileVersionAdapterFixture.cs @@ -15,7 +15,7 @@ namespace NzbDrone.Mono.Test.EnvironmentInfo.VersionAdapters { [Test] [IntegrationTest] - [Platform("Mono")] + [Platform("Linux")] public void should_get_version_info_from_actual_linux() { Mocker.SetConstant<IDiskProvider>(Mocker.Resolve<DiskProvider>()); @@ -79,4 +79,4 @@ namespace NzbDrone.Mono.Test.EnvironmentInfo.VersionAdapters } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Mono.Test/Lidarr.Mono.Test.csproj b/src/NzbDrone.Mono.Test/Lidarr.Mono.Test.csproj new file mode 100644 index 000000000..ba380658b --- /dev/null +++ b/src/NzbDrone.Mono.Test/Lidarr.Mono.Test.csproj @@ -0,0 +1,21 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net462</TargetFramework> + <Platforms>x86</Platforms> + </PropertyGroup> + <ItemGroup> + <ProjectReference Include="..\NzbDrone.Common.Test\Lidarr.Common.Test.csproj" /> + <ProjectReference Include="..\NzbDrone.Test.Common\Lidarr.Test.Common.csproj" /> + <ProjectReference Include="..\NzbDrone.Mono\Lidarr.Mono.csproj" /> + </ItemGroup> + <ItemGroup> + <Reference Include="Mono.Posix"> + <HintPath>..\Libraries\Mono.Posix.dll</HintPath> + </Reference> + </ItemGroup> + <ItemGroup> + <None Update="Files\**\*.*"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + </ItemGroup> +</Project> diff --git a/src/NzbDrone.Mono.Test/NzbDrone.Mono.Test.csproj b/src/NzbDrone.Mono.Test/NzbDrone.Mono.Test.csproj deleted file mode 100644 index 33eb1bbea..000000000 --- a/src/NzbDrone.Mono.Test/NzbDrone.Mono.Test.csproj +++ /dev/null @@ -1,147 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" /> - <PropertyGroup> - <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> - <Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform> - <ProjectGuid>{40D72824-7D02-4A77-9106-8FE0EEA2B997}</ProjectGuid> - <OutputType>Library</OutputType> - <AppDesignerFolder>Properties</AppDesignerFolder> - <RootNamespace>NzbDrone.Mono.Test</RootNamespace> - <AssemblyName>NzbDrone.Mono.Test</AssemblyName> - <TargetFrameworkVersion>v4.0</TargetFrameworkVersion> - <FileAlignment>512</FileAlignment> - <SolutionDir Condition="$(SolutionDir) == '' Or $(SolutionDir) == '*Undefined*'">..\</SolutionDir> - <RestorePackages>true</RestorePackages> - </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "> - <DebugSymbols>true</DebugSymbols> - <DebugType>full</DebugType> - <Optimize>false</Optimize> - <OutputPath>bin\Debug\</OutputPath> - <DefineConstants>DEBUG;TRACE</DefineConstants> - <ErrorReport>prompt</ErrorReport> - <WarningLevel>4</WarningLevel> - </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' "> - <DebugType>pdbonly</DebugType> - <Optimize>true</Optimize> - <OutputPath>bin\Release\</OutputPath> - <DefineConstants>TRACE</DefineConstants> - <ErrorReport>prompt</ErrorReport> - <WarningLevel>4</WarningLevel> - </PropertyGroup> - <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x86'"> - <DebugSymbols>true</DebugSymbols> - <OutputPath>bin\x86\Debug\</OutputPath> - <DefineConstants>DEBUG;TRACE</DefineConstants> - <DebugType>full</DebugType> - <PlatformTarget>x86</PlatformTarget> - <ErrorReport>prompt</ErrorReport> - <CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x86'"> - <OutputPath>bin\x86\Release\</OutputPath> - <DefineConstants>TRACE</DefineConstants> - <Optimize>true</Optimize> - <DebugType>pdbonly</DebugType> - <PlatformTarget>x86</PlatformTarget> - <ErrorReport>prompt</ErrorReport> - <CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <ItemGroup> - <Reference Include="FluentAssertions, Version=4.18.0.0, Culture=neutral, PublicKeyToken=33f2691a05b67b6a, processorArchitecture=MSIL"> - <HintPath>..\packages\FluentAssertions.4.18.0\lib\net40\FluentAssertions.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="FluentAssertions.Core, Version=4.18.0.0, Culture=neutral, PublicKeyToken=33f2691a05b67b6a, processorArchitecture=MSIL"> - <HintPath>..\packages\FluentAssertions.4.18.0\lib\net40\FluentAssertions.Core.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="Mono.Posix, Version=4.0.0.0, Culture=neutral, PublicKeyToken=0738eb9f132ed756, processorArchitecture=MSIL"> - <SpecificVersion>False</SpecificVersion> - <HintPath>..\Libraries\Mono.Posix.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="nunit.framework, Version=3.5.0.0, Culture=neutral, PublicKeyToken=2638cd05610744eb, processorArchitecture=MSIL"> - <HintPath>..\packages\NUnit.3.5.0\lib\net40\nunit.framework.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="System" /> - <Reference Include="System.Core" /> - <Reference Include="System.Data" /> - <Reference Include="System.Data.DataSetExtensions" /> - <Reference Include="System.Xml" /> - <Reference Include="System.Xml.Linq" /> - <Reference Include="Microsoft.CSharp" /> - </ItemGroup> - <ItemGroup> - <Compile Include="DiskProviderTests\DiskProviderFixture.cs" /> - <Compile Include="DiskProviderTests\FreeSpaceFixture.cs" /> - <Compile Include="EnvironmentInfo\MonoPlatformInfoFixture.cs" /> - <Compile Include="EnvironmentInfo\ReleaseFileVersionAdapterFixture.cs" /> - <Compile Include="EnvironmentInfo\VersionAdapters\ReleaseFileVersionAdapterFixture.cs" /> - <Compile Include="EnvironmentInfo\VersionAdapters\MacOsVersionAdapterFixture.cs" /> - <Compile Include="Properties\AssemblyInfo.cs" /> - </ItemGroup> - <ItemGroup> - <None Include="app.config" /> - <None Include="Files\macOS\SystemVersion.plist"> - <CopyToOutputDirectory>Always</CopyToOutputDirectory> - </None> - <None Include="packages.config" /> - </ItemGroup> - <ItemGroup> - <ProjectReference Include="..\NzbDrone.Common\NzbDrone.Common.csproj"> - <Project>{f2be0fdf-6e47-4827-a420-dd4ef82407f8}</Project> - <Name>NzbDrone.Common</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.Common.Test\NzbDrone.Common.Test.csproj"> - <Project>{bec74619-ddbb-4fba-b517-d3e20afc9997}</Project> - <Name>NzbDrone.Common.Test</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.Core\NzbDrone.Core.csproj"> - <Project>{ff5ee3b6-913b-47ce-9ceb-11c51b4e1205}</Project> - <Name>NzbDrone.Core</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.Host\NzbDrone.Host.csproj"> - <Project>{95c11a9e-56ed-456a-8447-2c89c1139266}</Project> - <Name>NzbDrone.Host</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.Mono\NzbDrone.Mono.csproj"> - <Project>{15ad7579-a314-4626-b556-663f51d97cd1}</Project> - <Name>NzbDrone.Mono</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.Test.Common\NzbDrone.Test.Common.csproj"> - <Project>{caddfce0-7509-4430-8364-2074e1eefca2}</Project> - <Name>NzbDrone.Test.Common</Name> - </ProjectReference> - </ItemGroup> - <ItemGroup> - <Service Include="{82A7F48D-3B50-4B1E-B82E-3ADA8210C358}" /> - </ItemGroup> - <ItemGroup> - <None Include="Files\linux\lsb-release"> - <CopyToOutputDirectory>Always</CopyToOutputDirectory> - </None> - </ItemGroup> - <ItemGroup> - <None Include="Files\linux\os-release"> - <CopyToOutputDirectory>Always</CopyToOutputDirectory> - </None> - </ItemGroup> - <ItemGroup> - <Folder Include="Files\synology\" /> - </ItemGroup> - <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> - <!-- To modify your build process, add your task inside one of the targets below and uncomment it. - Other similar extension points exist, see Microsoft.Common.targets. - <Target Name="BeforeBuild"> - </Target> - <Target Name="AfterBuild"> - </Target> - --> -</Project> \ No newline at end of file diff --git a/src/NzbDrone.Mono.Test/Properties/AssemblyInfo.cs b/src/NzbDrone.Mono.Test/Properties/AssemblyInfo.cs deleted file mode 100644 index 012007b52..000000000 --- a/src/NzbDrone.Mono.Test/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("NzbDrone.Mono.Test")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("NzbDrone.Mono.Test")] -[assembly: AssemblyCopyright("Copyright © 2014")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("45299d3c-34ff-48ca-9093-de2f037c38ac")] - -[assembly: AssemblyVersion("10.0.0.*")] diff --git a/src/NzbDrone.Mono.Test/app.config b/src/NzbDrone.Mono.Test/app.config index b6d9543c5..e294808dc 100644 --- a/src/NzbDrone.Mono.Test/app.config +++ b/src/NzbDrone.Mono.Test/app.config @@ -4,16 +4,24 @@ <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1"> <dependentAssembly> <assemblyIdentity name="Microsoft.Owin" publicKeyToken="31bf3856ad364e35" culture="neutral" /> - <bindingRedirect oldVersion="0.0.0.0-2.1.0.0" newVersion="2.1.0.0" /> + <bindingRedirect oldVersion="0.0.0.0-3.1.0.0" newVersion="3.1.0.0" /> </dependentAssembly> <dependentAssembly> <assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" /> - <bindingRedirect oldVersion="0.0.0.0-9.0.0.0" newVersion="9.0.0.0" /> + <bindingRedirect oldVersion="0.0.0.0-12.0.0.0" newVersion="12.0.0.0" /> </dependentAssembly> <dependentAssembly> <assemblyIdentity name="FluentMigrator" publicKeyToken="aacfc7de5acabf05" culture="neutral" /> <bindingRedirect oldVersion="0.0.0.0-1.3.1.0" newVersion="1.3.1.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> + <dependentAssembly> + <assemblyIdentity name="Microsoft.Owin.Security" publicKeyToken="31bf3856ad364e35" culture="neutral" /> + <bindingRedirect oldVersion="0.0.0.0-3.1.0.0" newVersion="3.1.0.0" /> + </dependentAssembly> </assemblyBinding> </runtime> -</configuration> \ No newline at end of file +<startup><supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.6.2" /></startup></configuration> diff --git a/src/NzbDrone.Mono.Test/packages.config b/src/NzbDrone.Mono.Test/packages.config deleted file mode 100644 index c2b5ba10f..000000000 --- a/src/NzbDrone.Mono.Test/packages.config +++ /dev/null @@ -1,6 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<packages> - <package id="FluentAssertions" version="4.18.0" targetFramework="net40" /> - <package id="Moq" version="4.0.10827" targetFramework="net40" /> - <package id="NUnit" version="3.5.0" targetFramework="net40" /> -</packages> \ No newline at end of file diff --git a/src/NzbDrone.Mono/Disk/DiskProvider.cs b/src/NzbDrone.Mono/Disk/DiskProvider.cs index d9f3cba29..2eb525966 100644 --- a/src/NzbDrone.Mono/Disk/DiskProvider.cs +++ b/src/NzbDrone.Mono/Disk/DiskProvider.cs @@ -1,6 +1,7 @@ -using System; +using System; using System.Collections.Generic; using System.IO; +using System.IO.Abstractions; using System.Linq; using Mono.Unix; using Mono.Unix.Native; @@ -23,7 +24,16 @@ namespace NzbDrone.Mono.Disk // `unchecked((uint)-1)` and `uint.MaxValue` are the same thing. private const uint UNCHANGED_ID = uint.MaxValue; - public DiskProvider(IProcMountProvider procMountProvider, ISymbolicLinkResolver symLinkResolver) + public DiskProvider(IProcMountProvider procMountProvider, + ISymbolicLinkResolver symLinkResolver) + : this(new FileSystem(), procMountProvider, symLinkResolver) + { + } + + public DiskProvider(IFileSystem fileSystem, + IProcMountProvider procMountProvider, + ISymbolicLinkResolver symLinkResolver) + : base(fileSystem) { _procMountProvider = procMountProvider; _symLinkResolver = symLinkResolver; @@ -40,24 +50,17 @@ namespace NzbDrone.Mono.Disk { Ensure.That(path, () => path).IsValidPath(); - try - { - var mount = GetMount(path); + Logger.Debug($"path: {path}"); - if (mount == null) - { - Logger.Debug("Unable to get free space for '{0}', unable to find suitable drive", path); - return null; - } + var mount = GetMount(path); - 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) @@ -66,9 +69,9 @@ namespace NzbDrone.Mono.Disk try { - var fs = File.GetAccessControl(filename); + var fs = _fileSystem.File.GetAccessControl(filename); fs.SetAccessRuleProtection(false, false); - File.SetAccessControl(filename, fs); + _fileSystem.File.SetAccessControl(filename, fs); } catch (NotImplementedException) { @@ -84,40 +87,130 @@ namespace NzbDrone.Mono.Disk SetOwner(path, user, group); } - public override List<IMount> GetMounts() + protected override List<IMount> GetAllMounts() { - 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(); + } + + protected override bool IsSpecialMount(IMount mount) + { + var root = mount.RootDirectory; + + if (root.StartsWith("/var/lib/")) + { + // Could be /var/lib/docker when docker uses zfs. Very unlikely that a useful mount is located in /var/lib. + return true; + } + + if (root.StartsWith("/snap/")) + { + // Mount point for snap packages + return true; + } + + return false; } 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; + + var newFile = new UnixSymbolicLinkInfo(destination); - if (mount == null) return null; + if (FileExists(destination) && overwrite) + { + DeleteFile(destination); + } - return mount.TotalSize; + 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); } + } + + 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); - return null; + 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) @@ -206,8 +299,6 @@ namespace NzbDrone.Mono.Disk } return g.gr_gid; - - } } } diff --git a/src/NzbDrone.Mono/Disk/FindDriveType.cs b/src/NzbDrone.Mono/Disk/FindDriveType.cs index 80f4ab252..efb07c5df 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,8 @@ namespace NzbDrone.Mono.Disk private static readonly Dictionary<string, DriveType> DriveTypeMap = new Dictionary<string, DriveType> { { "afpfs", DriveType.Network }, + { "apfs", DriveType.Fixed }, + { "fuse.mergerfs", DriveType.Fixed }, { "zfs", DriveType.Fixed } }; diff --git a/src/NzbDrone.Mono/Disk/ProcMount.cs b/src/NzbDrone.Mono/Disk/ProcMount.cs index 87e428112..5d7160440 100644 --- a/src/NzbDrone.Mono/Disk/ProcMount.cs +++ b/src/NzbDrone.Mono/Disk/ProcMount.cs @@ -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..88e834d8a 100644 --- a/src/NzbDrone.Mono/Disk/ProcMountProvider.cs +++ b/src/NzbDrone.Mono/Disk/ProcMountProvider.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -111,12 +111,6 @@ namespace NzbDrone.Mono.Disk return null; } - if (mount.StartsWith("/var/lib/")) - { - // Could be /var/lib/docker when docker uses zfs. Very unlikely that a useful mount is located in /var/lib. - return null; - } - var driveType = FindDriveType.Find(type); if (name.StartsWith("/dev/") || GetFileSystems().GetValueOrDefault(type, false)) @@ -130,7 +124,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/MonoPlatformInfo.cs b/src/NzbDrone.Mono/EnvironmentInfo/MonoPlatformInfo.cs index e883cea94..e85540dfa 100644 --- a/src/NzbDrone.Mono/EnvironmentInfo/MonoPlatformInfo.cs +++ b/src/NzbDrone.Mono/EnvironmentInfo/MonoPlatformInfo.cs @@ -30,7 +30,6 @@ namespace NzbDrone.Mono.EnvironmentInfo if (versionMatch.Success) { runTimeVersion = new Version(versionMatch.Groups["version"].Value); - Environment.SetEnvironmentVariable("RUNTIME_VERSION", runTimeVersion.ToString()); } } } 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/Lidarr.Mono.csproj b/src/NzbDrone.Mono/Lidarr.Mono.csproj new file mode 100644 index 000000000..3d20b6915 --- /dev/null +++ b/src/NzbDrone.Mono/Lidarr.Mono.csproj @@ -0,0 +1,18 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net462</TargetFramework> + <Platforms>x86</Platforms> + </PropertyGroup> + <ItemGroup> + <PackageReference Include="NLog" Version="4.5.4" /> + <PackageReference Include="System.IO.Abstractions" Version="4.0.11" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\NzbDrone.Common\Lidarr.Common.csproj" /> + </ItemGroup> + <ItemGroup> + <Reference Include="Mono.Posix"> + <HintPath>..\Libraries\Mono.Posix.dll</HintPath> + </Reference> + </ItemGroup> +</Project> diff --git a/src/NzbDrone.Mono/NzbDrone.Mono.csproj b/src/NzbDrone.Mono/NzbDrone.Mono.csproj deleted file mode 100644 index d44a56051..000000000 --- a/src/NzbDrone.Mono/NzbDrone.Mono.csproj +++ /dev/null @@ -1,102 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" /> - <PropertyGroup> - <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> - <Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform> - <ProjectGuid>{15AD7579-A314-4626-B556-663F51D97CD1}</ProjectGuid> - <OutputType>Library</OutputType> - <AppDesignerFolder>Properties</AppDesignerFolder> - <RootNamespace>NzbDrone.Mono</RootNamespace> - <AssemblyName>NzbDrone.Mono</AssemblyName> - <TargetFrameworkVersion>v4.0</TargetFrameworkVersion> - <FileAlignment>512</FileAlignment> - <TargetFrameworkProfile /> - <SolutionDir Condition="$(SolutionDir) == '' Or $(SolutionDir) == '*Undefined*'">..\</SolutionDir> - <RestorePackages>true</RestorePackages> - </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "> - <DebugSymbols>true</DebugSymbols> - <DebugType>full</DebugType> - <Optimize>false</Optimize> - <OutputPath>..\..\_output\</OutputPath> - <DefineConstants>DEBUG;TRACE</DefineConstants> - <ErrorReport>prompt</ErrorReport> - <WarningLevel>4</WarningLevel> - </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' "> - <DebugType>pdbonly</DebugType> - <Optimize>true</Optimize> - <OutputPath>bin\Release\</OutputPath> - <DefineConstants>TRACE</DefineConstants> - <ErrorReport>prompt</ErrorReport> - <WarningLevel>4</WarningLevel> - </PropertyGroup> - <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x86'"> - <DebugSymbols>true</DebugSymbols> - <OutputPath>..\..\_output\</OutputPath> - <DefineConstants>DEBUG;TRACE</DefineConstants> - <DebugType>full</DebugType> - <PlatformTarget>x86</PlatformTarget> - <ErrorReport>prompt</ErrorReport> - <CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x86'"> - <OutputPath>..\..\_output\</OutputPath> - <DefineConstants>TRACE</DefineConstants> - <Optimize>true</Optimize> - <DebugType>pdbonly</DebugType> - <PlatformTarget>x86</PlatformTarget> - <ErrorReport>prompt</ErrorReport> - <CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <ItemGroup> - <Reference Include="NLog, Version=4.0.0.0, Culture=neutral, PublicKeyToken=5120e14c03d0593c, processorArchitecture=MSIL"> - <HintPath>..\packages\NLog.4.4.1\lib\net40\NLog.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="System" /> - <Reference Include="System.Core" /> - <Reference Include="System.Data" /> - <Reference Include="System.Data.DataSetExtensions" /> - <Reference Include="System.Xml" /> - <Reference Include="System.Xml.Linq" /> - <Reference Include="Microsoft.CSharp" /> - <Reference Include="Mono.Posix, Version=2.0.0.0, Culture=neutral, PublicKeyToken=0738eb9f132ed756, processorArchitecture=MSIL"> - <SpecificVersion>False</SpecificVersion> - <HintPath>..\Libraries\Mono.Posix.dll</HintPath> - </Reference> - </ItemGroup> - <ItemGroup> - <Compile Include="Disk\DiskProvider.cs" /> - <Compile Include="Disk\FindDriveType.cs" /> - <Compile Include="Disk\LinuxPermissionsException.cs" /> - <Compile Include="EnvironmentInfo\VersionAdapters\IssueFileVersionAdapter.cs" /> - <Compile Include="EnvironmentInfo\VersionAdapters\MacOsVersionAdapter.cs" /> - <Compile Include="EnvironmentInfo\MonoPlatformInfo.cs" /> - <Compile Include="Disk\ProcMount.cs" /> - <Compile Include="Disk\ProcMountProvider.cs" /> - <Compile Include="EnvironmentInfo\VersionAdapters\SynologyVersionAdapter.cs" /> - <Compile Include="EnvironmentInfo\VersionAdapters\ReleaseFileVersionAdapter.cs" /> - <Compile Include="Properties\AssemblyInfo.cs" /> - <Compile Include="Disk\SymbolicLinkResolver.cs" /> - </ItemGroup> - <ItemGroup> - <ProjectReference Include="..\NzbDrone.Common\NzbDrone.Common.csproj"> - <Project>{f2be0fdf-6e47-4827-a420-dd4ef82407f8}</Project> - <Name>NzbDrone.Common</Name> - </ProjectReference> - </ItemGroup> - <ItemGroup> - <None Include="app.config" /> - <None Include="packages.config" /> - </ItemGroup> - <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> - <!-- To modify your build process, add your task inside one of the targets below and uncomment it. - Other similar extension points exist, see Microsoft.Common.targets. - <Target Name="BeforeBuild"> - </Target> - <Target Name="AfterBuild"> - </Target> - --> -</Project> \ No newline at end of file diff --git a/src/NzbDrone.Mono/Properties/AssemblyInfo.cs b/src/NzbDrone.Mono/Properties/AssemblyInfo.cs deleted file mode 100644 index f78631ed8..000000000 --- a/src/NzbDrone.Mono/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("NzbDrone.Mono")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("NzbDrone.Mono")] -[assembly: AssemblyCopyright("Copyright © 2014")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("01493ea5-494f-43bf-be18-8ae4d0708fc6")] - -[assembly: AssemblyVersion("10.0.0.*")] diff --git a/src/NzbDrone.Mono/app.config b/src/NzbDrone.Mono/app.config index 8460dd432..835ae48cc 100644 --- a/src/NzbDrone.Mono/app.config +++ b/src/NzbDrone.Mono/app.config @@ -1,11 +1,11 @@ -<?xml version="1.0" encoding="utf-8"?> +<?xml version="1.0" encoding="utf-8"?> <configuration> <runtime> <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1"> <dependentAssembly> - <assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" /> - <bindingRedirect oldVersion="0.0.0.0-9.0.0.0" newVersion="9.0.0.0" /> + <assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral"/> + <bindingRedirect oldVersion="0.0.0.0-12.0.0.0" newVersion="12.0.0.0"/> </dependentAssembly> </assemblyBinding> </runtime> -</configuration> \ No newline at end of file +<startup><supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.6.2"/></startup></configuration> diff --git a/src/NzbDrone.Mono/packages.config b/src/NzbDrone.Mono/packages.config deleted file mode 100644 index be7a78cb3..000000000 --- a/src/NzbDrone.Mono/packages.config +++ /dev/null @@ -1,4 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<packages> - <package id="NLog" version="4.4.1" targetFramework="net40" /> -</packages> \ No newline at end of file diff --git a/src/NzbDrone.SignalR/Lidarr.SignalR.csproj b/src/NzbDrone.SignalR/Lidarr.SignalR.csproj new file mode 100644 index 000000000..4875b908d --- /dev/null +++ b/src/NzbDrone.SignalR/Lidarr.SignalR.csproj @@ -0,0 +1,17 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net462</TargetFramework> + <Platforms>x86</Platforms> + </PropertyGroup> + <ItemGroup> + <PackageReference Include="Microsoft.AspNet.SignalR.SelfHost" Version="2.4.1" /> + <PackageReference Include="Microsoft.AspNet.SignalR.SystemWeb" Version="2.4.1" /> + <PackageReference Include="Microsoft.Owin.Host.SystemWeb" Version="3.1.0" /> + <PackageReference Include="Microsoft.Owin.Security" Version="3.1.0" /> + <PackageReference Include="Microsoft.Owin.SelfHost" Version="3.1.0" /> + <PackageReference Include="Newtonsoft.Json" Version="12.0.2" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\NzbDrone.Core\Lidarr.Core.csproj" /> + </ItemGroup> +</Project> diff --git a/src/NzbDrone.SignalR/LidarrPerformanceCounterManager.cs b/src/NzbDrone.SignalR/LidarrPerformanceCounterManager.cs new file mode 100644 index 000000000..1576b5e28 --- /dev/null +++ b/src/NzbDrone.SignalR/LidarrPerformanceCounterManager.cs @@ -0,0 +1,58 @@ +using System.Threading; +using Microsoft.AspNet.SignalR.Infrastructure; + +namespace NzbDrone.SignalR +{ + public class LidarrPerformanceCounterManager : IPerformanceCounterManager + { + private readonly IPerformanceCounter _counter = new NoOpPerformanceCounter(); + + public void Initialize(string instanceName, CancellationToken hostShutdownToken) + { + + } + + public IPerformanceCounter LoadCounter(string categoryName, string counterName, string instanceName, bool isReadOnly) + { + return _counter; + } + + public IPerformanceCounter ConnectionsConnected => _counter; + public IPerformanceCounter ConnectionsReconnected => _counter; + public IPerformanceCounter ConnectionsDisconnected => _counter; + public IPerformanceCounter ConnectionsCurrent => _counter; + public IPerformanceCounter ConnectionMessagesReceivedTotal => _counter; + public IPerformanceCounter ConnectionMessagesSentTotal => _counter; + public IPerformanceCounter ConnectionMessagesReceivedPerSec => _counter; + public IPerformanceCounter ConnectionMessagesSentPerSec => _counter; + public IPerformanceCounter MessageBusMessagesReceivedTotal => _counter; + public IPerformanceCounter MessageBusMessagesReceivedPerSec => _counter; + public IPerformanceCounter ScaleoutMessageBusMessagesReceivedPerSec => _counter; + public IPerformanceCounter MessageBusMessagesPublishedTotal => _counter; + public IPerformanceCounter MessageBusMessagesPublishedPerSec => _counter; + public IPerformanceCounter MessageBusSubscribersCurrent => _counter; + public IPerformanceCounter MessageBusSubscribersTotal => _counter; + public IPerformanceCounter MessageBusSubscribersPerSec => _counter; + public IPerformanceCounter MessageBusAllocatedWorkers => _counter; + public IPerformanceCounter MessageBusBusyWorkers => _counter; + public IPerformanceCounter MessageBusTopicsCurrent => _counter; + public IPerformanceCounter ErrorsAllTotal => _counter; + public IPerformanceCounter ErrorsAllPerSec => _counter; + public IPerformanceCounter ErrorsHubResolutionTotal => _counter; + public IPerformanceCounter ErrorsHubResolutionPerSec => _counter; + public IPerformanceCounter ErrorsHubInvocationTotal => _counter; + public IPerformanceCounter ErrorsHubInvocationPerSec => _counter; + public IPerformanceCounter ErrorsTransportTotal => _counter; + public IPerformanceCounter ErrorsTransportPerSec => _counter; + public IPerformanceCounter ScaleoutStreamCountTotal => _counter; + public IPerformanceCounter ScaleoutStreamCountOpen => _counter; + public IPerformanceCounter ScaleoutStreamCountBuffering => _counter; + public IPerformanceCounter ScaleoutErrorsTotal => _counter; + public IPerformanceCounter ScaleoutErrorsPerSec => _counter; + public IPerformanceCounter ScaleoutSendQueueLength => _counter; + public IPerformanceCounter ConnectionsCurrentForeverFrame => _counter; + public IPerformanceCounter ConnectionsCurrentLongPolling => _counter; + public IPerformanceCounter ConnectionsCurrentServerSentEvents => _counter; + public IPerformanceCounter ConnectionsCurrentWebSockets => _counter; + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/NoOpPerformanceCounter.cs b/src/NzbDrone.SignalR/NoOpPerformanceCounter.cs similarity index 79% rename from src/Microsoft.AspNet.SignalR.Core/Infrastructure/NoOpPerformanceCounter.cs rename to src/NzbDrone.SignalR/NoOpPerformanceCounter.cs index cf45a92fa..301d89138 100644 --- a/src/Microsoft.AspNet.SignalR.Core/Infrastructure/NoOpPerformanceCounter.cs +++ b/src/NzbDrone.SignalR/NoOpPerformanceCounter.cs @@ -1,8 +1,7 @@ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - using System.Diagnostics; +using Microsoft.AspNet.SignalR.Infrastructure; -namespace Microsoft.AspNet.SignalR.Infrastructure +namespace NzbDrone.SignalR { public class NoOpPerformanceCounter : IPerformanceCounter { @@ -42,7 +41,7 @@ namespace Microsoft.AspNet.SignalR.Infrastructure public void RemoveInstance() { - + } public CounterSample NextSample() diff --git a/src/NzbDrone.SignalR/NzbDrone.SignalR.csproj b/src/NzbDrone.SignalR/NzbDrone.SignalR.csproj deleted file mode 100644 index 0cabc3c01..000000000 --- a/src/NzbDrone.SignalR/NzbDrone.SignalR.csproj +++ /dev/null @@ -1,86 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" /> - <PropertyGroup> - <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> - <Platform Condition=" '$(Platform)' == '' ">x86</Platform> - <ProjectGuid>{7C2CC69F-5CA0-4E5C-85CB-983F9F6C3B36}</ProjectGuid> - <OutputType>Library</OutputType> - <AppDesignerFolder>Properties</AppDesignerFolder> - <RootNamespace>NzbDrone.SignalR</RootNamespace> - <AssemblyName>NzbDrone.SignalR</AssemblyName> - <TargetFrameworkVersion>v4.0</TargetFrameworkVersion> - <FileAlignment>512</FileAlignment> - <SolutionDir Condition="$(SolutionDir) == '' Or $(SolutionDir) == '*Undefined*'">..\</SolutionDir> - <RestorePackages>true</RestorePackages> - <ProductVersion>12.0.0</ProductVersion> - <SchemaVersion>2.0</SchemaVersion> - </PropertyGroup> - <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x86'"> - <DebugSymbols>true</DebugSymbols> - <OutputPath>..\..\_output\</OutputPath> - <DefineConstants>DEBUG;TRACE</DefineConstants> - <DebugType>full</DebugType> - <PlatformTarget>x86</PlatformTarget> - <ErrorReport>prompt</ErrorReport> - <CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet> - <WarningLevel>4</WarningLevel> - <Optimize>false</Optimize> - </PropertyGroup> - <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x86'"> - <OutputPath>..\..\_output\</OutputPath> - <DefineConstants>TRACE</DefineConstants> - <Optimize>true</Optimize> - <DebugType>pdbonly</DebugType> - <PlatformTarget>x86</PlatformTarget> - <ErrorReport>prompt</ErrorReport> - <CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet> - <WarningLevel>4</WarningLevel> - </PropertyGroup> - <ItemGroup> - <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="System" /> - <Reference Include="System.Core" /> - <Reference Include="Microsoft.CSharp" /> - </ItemGroup> - <ItemGroup> - <Compile Include="..\NzbDrone.Common\Properties\SharedAssemblyInfo.cs"> - <Link>Properties\SharedAssemblyInfo.cs</Link> - </Compile> - <Compile Include="NzbDronePersistentConnection.cs" /> - <Compile Include="Properties\AssemblyInfo.cs" /> - <Compile Include="Serializer.cs" /> - <Compile Include="SignalrDependencyResolver.cs" /> - <Compile Include="SignalRMessage.cs" /> - <Compile Include="SonarrPerformanceCounterManager.cs" /> - </ItemGroup> - <ItemGroup> - <ProjectReference Include="..\Microsoft.AspNet.SignalR.Core\Microsoft.AspNet.SignalR.Core.csproj"> - <Project>{1B9A82C4-BCA1-4834-A33E-226F17BE070B}</Project> - <Name>Microsoft.AspNet.SignalR.Core</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.Common\NzbDrone.Common.csproj"> - <Project>{F2BE0FDF-6E47-4827-A420-DD4EF82407F8}</Project> - <Name>NzbDrone.Common</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.Core\NzbDrone.Core.csproj"> - <Project>{FF5EE3B6-913B-47CE-9CEB-11C51B4E1205}</Project> - <Name>NzbDrone.Core</Name> - </ProjectReference> - </ItemGroup> - <ItemGroup> - <None Include="app.config" /> - <None Include="packages.config" /> - </ItemGroup> - <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> - <!-- To modify your build process, add your task inside one of the targets below and uncomment it. - Other similar extension points exist, see Microsoft.Common.targets. - <Target Name="BeforeBuild"> - </Target> - <Target Name="AfterBuild"> - </Target> - --> -</Project> \ No newline at end of file diff --git a/src/NzbDrone.SignalR/NzbDronePersistentConnection.cs b/src/NzbDrone.SignalR/NzbDronePersistentConnection.cs index dfa063a0e..ef9ed0332 100644 --- a/src/NzbDrone.SignalR/NzbDronePersistentConnection.cs +++ b/src/NzbDrone.SignalR/NzbDronePersistentConnection.cs @@ -1,10 +1,18 @@ +using System.Collections.Generic; +using System.Threading.Tasks; using Microsoft.AspNet.SignalR; using Microsoft.AspNet.SignalR.Infrastructure; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Serializer; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Datastore.Events; namespace NzbDrone.SignalR { public interface IBroadcastSignalRMessage { + bool IsConnected { get; } void BroadcastMessage(SignalRMessage message); } @@ -12,9 +20,95 @@ namespace NzbDrone.SignalR { private IPersistentConnectionContext Context => ((ConnectionManager)GlobalHost.ConnectionManager).GetConnection(GetType()); + private static string API_KEY; + private readonly Dictionary<string, string> _messageHistory; + private HashSet<string> _connections = new HashSet<string>(); + + public NzbDronePersistentConnection(IConfigFileProvider configFileProvider) + { + API_KEY = configFileProvider.ApiKey; + _messageHistory = new Dictionary<string, string>(); + } + + public bool IsConnected + { + get + { + lock (_connections) + { + return _connections.Count != 0; + } + } + } + public void BroadcastMessage(SignalRMessage message) { + string lastMessage; + if (_messageHistory.TryGetValue(message.Name, out lastMessage)) + { + if (message.Action == ModelAction.Updated && message.Body.ToJson() == lastMessage) + { + return; + } + } + + _messageHistory[message.Name] = message.Body.ToJson(); + 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; + } + + protected override Task OnConnected(IRequest request, string connectionId) + { + lock (_connections) + { + _connections.Add(connectionId); + } + + return SendVersion(connectionId); + } + + protected override Task OnReconnected(IRequest request, string connectionId) + { + lock (_connections) + { + _connections.Add(connectionId); + } + + return SendVersion(connectionId); + } + + protected override Task OnDisconnected(IRequest request, string connectionId, bool stopCalled) + { + lock (_connections) + { + _connections.Remove(connectionId); + } + + return base.OnDisconnected(request, connectionId, stopCalled); + } + + private Task SendVersion(string connectionId) + { + return Context.Connection.Send(connectionId, new SignalRMessage + { + Name = "version", + Body = new + { + Version = BuildInfo.Version.ToString() + } + }); + } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.SignalR/Properties/AssemblyInfo.cs b/src/NzbDrone.SignalR/Properties/AssemblyInfo.cs deleted file mode 100644 index 7d5972415..000000000 --- a/src/NzbDrone.SignalR/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("NzbDrone.SignalR")] -[assembly: Guid("98bd985a-4f23-4201-8ed3-f6f3d7f2a5fe")] - -[assembly: AssemblyVersion("10.0.0.*")] diff --git a/src/NzbDrone.SignalR/Serializer.cs b/src/NzbDrone.SignalR/Serializer.cs deleted file mode 100644 index e631ef146..000000000 --- a/src/NzbDrone.SignalR/Serializer.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using System.IO; -using Microsoft.AspNet.SignalR.Json; -using NzbDrone.Common.Serializer; - -namespace NzbDrone.SignalR -{ - public class Serializer : IJsonSerializer - { - private readonly JsonNetSerializer _signalRSerializer = new JsonNetSerializer(); - - public void Serialize(object value, TextWriter writer) - { - if (value.GetType().FullName.StartsWith("NzbDrone")) - { - Json.Serialize(value, writer); - } - else - { - _signalRSerializer.Serialize(value, writer); - } - } - - public object Parse(TextReader reader, Type targetType) - { - return Json.Deserialize(reader.ReadToEnd(), targetType); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.SignalR/SignalRContractResolver.cs b/src/NzbDrone.SignalR/SignalRContractResolver.cs new file mode 100644 index 000000000..0f766d90a --- /dev/null +++ b/src/NzbDrone.SignalR/SignalRContractResolver.cs @@ -0,0 +1,28 @@ +using System; +using Newtonsoft.Json.Serialization; + +namespace NzbDrone.SignalR +{ + public class SignalRContractResolver : IContractResolver + { + private readonly IContractResolver _camelCaseContractResolver; + private readonly IContractResolver _defaultContractSerializer; + + public SignalRContractResolver() + { + _defaultContractSerializer = new DefaultContractResolver(); + _camelCaseContractResolver = new CamelCasePropertyNamesContractResolver(); + } + + public JsonContract ResolveContract(Type type) + { + var fullName = type.FullName; + if (fullName.StartsWith("NzbDrone") || fullName.StartsWith("Lidarr")) + { + return _camelCaseContractResolver.ResolveContract(type); + } + + return _defaultContractSerializer.ResolveContract(type); + } + } +} diff --git a/src/NzbDrone.SignalR/SignalRDependencyResolver.cs b/src/NzbDrone.SignalR/SignalRDependencyResolver.cs new file mode 100644 index 000000000..95e906352 --- /dev/null +++ b/src/NzbDrone.SignalR/SignalRDependencyResolver.cs @@ -0,0 +1,46 @@ +using System; +using Microsoft.AspNet.SignalR; +using Microsoft.AspNet.SignalR.Infrastructure; +using NzbDrone.Common.Composition; + +namespace NzbDrone.SignalR +{ + public class SignalRDependencyResolver : DefaultDependencyResolver + { + private readonly IContainer _container; + + public static void Register(IContainer container) + { + GlobalHost.DependencyResolver = new SignalRDependencyResolver(container); + } + + private SignalRDependencyResolver(IContainer container) + { + _container = container; + var performanceCounterManager = new LidarrPerformanceCounterManager(); + Register(typeof(IPerformanceCounterManager), () => performanceCounterManager); + } + + public override object GetService(Type serviceType) + { + // Microsoft.AspNet.SignalR.Infrastructure.AckSubscriber is not registered in our internal contaiiner, + // but it still gets treated like it is (possibly due to being a concrete type). + + var fullName = serviceType.FullName; + + if (fullName == "Microsoft.AspNet.SignalR.Infrastructure.AckSubscriber" || + fullName == "Newtonsoft.Json.JsonSerializer") + { + return base.GetService(serviceType); + } + + if (_container.IsTypeRegistered(serviceType)) + { + return _container.Resolve(serviceType); + } + + return base.GetService(serviceType); + } + } +} + diff --git a/src/NzbDrone.SignalR/SignalRJsonSerializer.cs b/src/NzbDrone.SignalR/SignalRJsonSerializer.cs new file mode 100644 index 000000000..c6603ebc7 --- /dev/null +++ b/src/NzbDrone.SignalR/SignalRJsonSerializer.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNet.SignalR; +using Newtonsoft.Json; +using NzbDrone.Common.Serializer; + +namespace NzbDrone.SignalR +{ + public static class SignalRJsonSerializer + { + private static JsonSerializer _serializer; + private static JsonSerializerSettings _serializerSettings; + + public static void Register() + { + _serializerSettings = Json.GetSerializerSettings(); + _serializerSettings.ContractResolver = new SignalRContractResolver(); + _serializerSettings.Formatting = Formatting.None; // ServerSentEvents doesn't like newlines + + _serializer = JsonSerializer.Create(_serializerSettings); + + GlobalHost.DependencyResolver.Register(typeof(JsonSerializer), () => _serializer); + } + } +} diff --git a/src/NzbDrone.SignalR/SignalRMessage.cs b/src/NzbDrone.SignalR/SignalRMessage.cs index e8993c286..17a7d4187 100644 --- a/src/NzbDrone.SignalR/SignalRMessage.cs +++ b/src/NzbDrone.SignalR/SignalRMessage.cs @@ -1,8 +1,14 @@ +using Newtonsoft.Json; +using NzbDrone.Core.Datastore.Events; + namespace NzbDrone.SignalR { public class SignalRMessage { public object Body { get; set; } public string Name { get; set; } + + [JsonIgnore] + public ModelAction Action { get; set; } } } \ No newline at end of file diff --git a/src/NzbDrone.SignalR/SignalrDependencyResolver.cs b/src/NzbDrone.SignalR/SignalrDependencyResolver.cs deleted file mode 100644 index f44f756a9..000000000 --- a/src/NzbDrone.SignalR/SignalrDependencyResolver.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using Microsoft.AspNet.SignalR; -using Microsoft.AspNet.SignalR.Infrastructure; -using NzbDrone.Common.Composition; - -namespace NzbDrone.SignalR -{ - public class SignalrDependencyResolver : DefaultDependencyResolver - { - private readonly IContainer _container; - - public static void Register(IContainer container) - { - GlobalHost.DependencyResolver = new SignalrDependencyResolver(container); - } - - private SignalrDependencyResolver(IContainer container) - { - _container = container; - var performanceCounterManager = new SonarrPerformanceCounterManager(); - Register(typeof(IPerformanceCounterManager), () => performanceCounterManager); - } - - public override object GetService(Type serviceType) - { - if (_container.IsTypeRegistered(serviceType)) - { - return _container.Resolve(serviceType); - } - - return base.GetService(serviceType); - } - } -} \ No newline at end of file diff --git a/src/NzbDrone.SignalR/SonarrPerformanceCounterManager.cs b/src/NzbDrone.SignalR/SonarrPerformanceCounterManager.cs deleted file mode 100644 index fd1a9e4e3..000000000 --- a/src/NzbDrone.SignalR/SonarrPerformanceCounterManager.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Threading; -using Microsoft.AspNet.SignalR.Infrastructure; - -namespace NzbDrone.SignalR -{ - public class SonarrPerformanceCounterManager : IPerformanceCounterManager - { - private readonly IPerformanceCounter _counter = new NoOpPerformanceCounter(); - - public void Initialize(string instanceName, CancellationToken hostShutdownToken) - { - - } - - public IPerformanceCounter LoadCounter(string categoryName, string counterName, string instanceName, bool isReadOnly) - { - return _counter; - } - - public IPerformanceCounter ConnectionsConnected => _counter; - public IPerformanceCounter ConnectionsReconnected => _counter; - public IPerformanceCounter ConnectionsDisconnected => _counter; - public IPerformanceCounter ConnectionsCurrent => _counter; - public IPerformanceCounter ConnectionMessagesReceivedTotal => _counter; - public IPerformanceCounter ConnectionMessagesSentTotal => _counter; - public IPerformanceCounter ConnectionMessagesReceivedPerSec => _counter; - public IPerformanceCounter ConnectionMessagesSentPerSec => _counter; - public IPerformanceCounter MessageBusMessagesReceivedTotal => _counter; - public IPerformanceCounter MessageBusMessagesReceivedPerSec => _counter; - public IPerformanceCounter ScaleoutMessageBusMessagesReceivedPerSec => _counter; - public IPerformanceCounter MessageBusMessagesPublishedTotal => _counter; - public IPerformanceCounter MessageBusMessagesPublishedPerSec => _counter; - public IPerformanceCounter MessageBusSubscribersCurrent => _counter; - public IPerformanceCounter MessageBusSubscribersTotal => _counter; - public IPerformanceCounter MessageBusSubscribersPerSec => _counter; - public IPerformanceCounter MessageBusAllocatedWorkers => _counter; - public IPerformanceCounter MessageBusBusyWorkers => _counter; - public IPerformanceCounter MessageBusTopicsCurrent => _counter; - public IPerformanceCounter ErrorsAllTotal => _counter; - public IPerformanceCounter ErrorsAllPerSec => _counter; - public IPerformanceCounter ErrorsHubResolutionTotal => _counter; - public IPerformanceCounter ErrorsHubResolutionPerSec => _counter; - public IPerformanceCounter ErrorsHubInvocationTotal => _counter; - public IPerformanceCounter ErrorsHubInvocationPerSec => _counter; - public IPerformanceCounter ErrorsTransportTotal => _counter; - public IPerformanceCounter ErrorsTransportPerSec => _counter; - public IPerformanceCounter ScaleoutStreamCountTotal => _counter; - public IPerformanceCounter ScaleoutStreamCountOpen => _counter; - public IPerformanceCounter ScaleoutStreamCountBuffering => _counter; - public IPerformanceCounter ScaleoutErrorsTotal => _counter; - public IPerformanceCounter ScaleoutErrorsPerSec => _counter; - public IPerformanceCounter ScaleoutSendQueueLength => _counter; - } -} \ No newline at end of file diff --git a/src/NzbDrone.SignalR/app.config b/src/NzbDrone.SignalR/app.config index c1684a7be..7f4612edc 100644 --- a/src/NzbDrone.SignalR/app.config +++ b/src/NzbDrone.SignalR/app.config @@ -4,12 +4,24 @@ <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1"> <dependentAssembly> <assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" /> - <bindingRedirect oldVersion="0.0.0.0-9.0.0.0" newVersion="9.0.0.0" /> + <bindingRedirect oldVersion="0.0.0.0-12.0.0.0" newVersion="12.0.0.0" /> </dependentAssembly> <dependentAssembly> <assemblyIdentity name="FluentMigrator" publicKeyToken="aacfc7de5acabf05" culture="neutral" /> <bindingRedirect oldVersion="0.0.0.0-1.3.1.0" newVersion="1.3.1.0" /> </dependentAssembly> + <dependentAssembly> + <assemblyIdentity name="Microsoft.Owin" publicKeyToken="31bf3856ad364e35" culture="neutral" /> + <bindingRedirect oldVersion="0.0.0.0-3.1.0.0" newVersion="3.1.0.0" /> + </dependentAssembly> + <dependentAssembly> + <assemblyIdentity name="Microsoft.AspNet.SignalR.Core" publicKeyToken="31bf3856ad364e35" culture="neutral" /> + <bindingRedirect oldVersion="0.0.0.0-2.4.0.0" newVersion="2.4.0.0" /> + </dependentAssembly> + <dependentAssembly> + <assemblyIdentity name="Microsoft.Owin.Security" publicKeyToken="31bf3856ad364e35" culture="neutral" /> + <bindingRedirect oldVersion="0.0.0.0-3.1.0.0" newVersion="3.1.0.0" /> + </dependentAssembly> </assemblyBinding> </runtime> -</configuration> \ No newline at end of file +<startup><supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.6.2" /></startup></configuration> diff --git a/src/NzbDrone.SignalR/packages.config b/src/NzbDrone.SignalR/packages.config deleted file mode 100644 index 7c276ed86..000000000 --- a/src/NzbDrone.SignalR/packages.config +++ /dev/null @@ -1,4 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<packages> - <package id="Newtonsoft.Json" version="9.0.1" targetFramework="net40" /> -</packages> \ No newline at end of file diff --git a/src/NzbDrone.Test.Common/App.config b/src/NzbDrone.Test.Common/App.config index 886337c3a..91d5ed4e1 100644 --- a/src/NzbDrone.Test.Common/App.config +++ b/src/NzbDrone.Test.Common/App.config @@ -7,7 +7,7 @@ <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1"> <dependentAssembly> <assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" /> - <bindingRedirect oldVersion="0.0.0.0-9.0.0.0" newVersion="9.0.0.0" /> + <bindingRedirect oldVersion="0.0.0.0-12.0.0.0" newVersion="12.0.0.0" /> </dependentAssembly> <dependentAssembly> <assemblyIdentity name="NLog" publicKeyToken="5120e14c03d0593c" culture="neutral" /> @@ -21,6 +21,18 @@ <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> + <dependentAssembly> + <assemblyIdentity name="Microsoft.Owin" publicKeyToken="31bf3856ad364e35" culture="neutral" /> + <bindingRedirect oldVersion="0.0.0.0-3.1.0.0" newVersion="3.1.0.0" /> + </dependentAssembly> + <dependentAssembly> + <assemblyIdentity name="Microsoft.Owin.Security" publicKeyToken="31bf3856ad364e35" culture="neutral" /> + <bindingRedirect oldVersion="0.0.0.0-3.1.0.0" newVersion="3.1.0.0" /> + </dependentAssembly> </assemblyBinding> </runtime> -</configuration> \ No newline at end of file +<startup><supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.6.2" /></startup></configuration> diff --git a/src/NzbDrone.Test.Common/AutoMoq/AutoMoqer.cs b/src/NzbDrone.Test.Common/AutoMoq/AutoMoqer.cs index 5cf6805a8..f83e1d789 100644 --- a/src/NzbDrone.Test.Common/AutoMoq/AutoMoqer.cs +++ b/src/NzbDrone.Test.Common/AutoMoq/AutoMoqer.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.IO; +using System.IO.Abstractions; using System.Linq; using System.Linq.Expressions; using System.Reflection; @@ -9,6 +9,7 @@ using System.Runtime.CompilerServices; using Microsoft.Practices.Unity; using Moq; using Moq.Language.Flow; +using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Test.Common.AutoMoq.Unity; @@ -50,6 +51,15 @@ namespace NzbDrone.Test.Common.AutoMoq return result; } + public virtual T Resolve<T>(string name, params ResolverOverride[] resolverOverrides) + { + ResolveType = typeof(T); + var result = _container.Resolve<T>(name, resolverOverrides); + SetConstant(result); + ResolveType = null; + return result; + } + public virtual Mock<T> GetMock<T>() where T : class { return GetMock<T>(DefaultBehavior); @@ -135,9 +145,9 @@ namespace NzbDrone.Test.Common.AutoMoq _container = container; container.RegisterInstance(this); - RegisterPlatformLibrary(container); - _registeredMocks = new Dictionary<Type, object>(); + + RegisterPlatformLibrary(container); AddTheAutoMockingContainerExtensionToTheContainer(container); } @@ -171,21 +181,24 @@ namespace NzbDrone.Test.Common.AutoMoq private void RegisterPlatformLibrary(IUnityContainer container) { - var assemblyName = "NzbDrone.Windows"; + var assemblyName = "Lidarr.Windows"; if (OsInfo.IsNotWindows) { - assemblyName = "NzbDrone.Mono"; + assemblyName = "Lidarr.Mono"; } - if (!File.Exists(assemblyName + ".dll")) - { - return; - } + var assembly = Assembly.Load(assemblyName); + + // This allows us to resolve the platform specific disk provider in FileSystemTest + var diskProvider = assembly.GetTypes().Where(x => x.Name == "DiskProvider").SingleOrDefault(); + container.RegisterType(typeof(IDiskProvider), diskProvider, "ActualDiskProvider"); - Assembly.Load(assemblyName); + // This tells the mocker to resolve IFileSystem using an actual filesystem (and not a mock) + // if not specified, giving the old behaviour before we switched to System.IO.Abstractions. + SetConstant<IFileSystem>(new FileSystem()); } #endregion } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Test.Common/ExceptionVerification.cs b/src/NzbDrone.Test.Common/ExceptionVerification.cs index b86220f7c..a60d1297b 100644 --- a/src/NzbDrone.Test.Common/ExceptionVerification.cs +++ b/src/NzbDrone.Test.Common/ExceptionVerification.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using NLog; using NLog.Targets; using NUnit.Framework; @@ -11,17 +12,27 @@ namespace NzbDrone.Test.Common { private static List<LogEventInfo> _logs = new List<LogEventInfo>(); + private static ManualResetEventSlim _waitEvent = new ManualResetEventSlim(); + protected override void Write(LogEventInfo logEvent) { - if (logEvent.Level >= LogLevel.Warn) + lock (_logs) { - _logs.Add(logEvent); + if (logEvent.Level >= LogLevel.Warn) + { + _logs.Add(logEvent); + _waitEvent.Set(); + } } } public static void Reset() { - _logs = new List<LogEventInfo>(); + lock (_logs) + { + _logs.Clear(); + _waitEvent.Reset(); + } } public static void AssertNoUnexpectedLogs() @@ -47,6 +58,29 @@ namespace NzbDrone.Test.Common return errors; } + public static void WaitForErrors(int count, int msec) + { + while (true) + { + lock (_logs) + { + var levelLogs = _logs.Where(l => l.Level == LogLevel.Error).ToList(); + + if (levelLogs.Count >= count) + { + break; + } + + _waitEvent.Reset(); + } + + if (!_waitEvent.Wait(msec)) + break; + } + + Expected(LogLevel.Error, count); + } + public static void ExpectedErrors(int count) { Expected(LogLevel.Error, count); @@ -74,50 +108,62 @@ namespace NzbDrone.Test.Common public static void MarkInconclusive(Type exception) { - var inconclusiveLogs = _logs.Where(l => l.Exception != null && l.Exception.GetType() == exception).ToList(); - - if (inconclusiveLogs.Any()) + lock (_logs) { - inconclusiveLogs.ForEach(c => _logs.Remove(c)); - Assert.Inconclusive(GetLogsString(inconclusiveLogs)); + var inconclusiveLogs = _logs.Where(l => l.Exception != null && l.Exception.GetType() == exception).ToList(); + + if (inconclusiveLogs.Any()) + { + inconclusiveLogs.ForEach(c => _logs.Remove(c)); + Assert.Inconclusive(GetLogsString(inconclusiveLogs)); + } } } public static void MarkInconclusive(string text) { - var inconclusiveLogs = _logs.Where(l => l.FormattedMessage.ToLower().Contains(text.ToLower())).ToList(); - - if (inconclusiveLogs.Any()) + lock (_logs) { - inconclusiveLogs.ForEach(c => _logs.Remove(c)); - Assert.Inconclusive(GetLogsString(inconclusiveLogs)); + var inconclusiveLogs = _logs.Where(l => l.FormattedMessage.ToLower().Contains(text.ToLower())).ToList(); + + if (inconclusiveLogs.Any()) + { + inconclusiveLogs.ForEach(c => _logs.Remove(c)); + Assert.Inconclusive(GetLogsString(inconclusiveLogs)); + } } } private static void Expected(LogLevel level, int count) { - var levelLogs = _logs.Where(l => l.Level == level).ToList(); - - if (levelLogs.Count != count) + lock (_logs) { + var levelLogs = _logs.Where(l => l.Level == level).ToList(); + + if (levelLogs.Count != count) + { - var message = string.Format("{0} {1}(s) were expected but {2} were logged.\n\r{3}", - count, level, levelLogs.Count, GetLogsString(levelLogs)); + var message = string.Format("{0} {1}(s) were expected but {2} were logged.\n\r{3}", + count, level, levelLogs.Count, GetLogsString(levelLogs)); - message = "\n\r****************************************************************************************\n\r" - + message + - "\n\r****************************************************************************************"; + message = "\n\r****************************************************************************************\n\r" + + message + + "\n\r****************************************************************************************"; - Assert.Fail(message); - } + Assert.Fail(message); + } - levelLogs.ForEach(c => _logs.Remove(c)); + levelLogs.ForEach(c => _logs.Remove(c)); + } } private static void Ignore(LogLevel level) { - var levelLogs = _logs.Where(l => l.Level == level).ToList(); - levelLogs.ForEach(c => _logs.Remove(c)); + lock (_logs) + { + var levelLogs = _logs.Where(l => l.Level == level).ToList(); + levelLogs.ForEach(c => _logs.Remove(c)); + } } } } \ No newline at end of file diff --git a/src/NzbDrone.Test.Common/License.txt b/src/NzbDrone.Test.Common/License.txt deleted file mode 100644 index 5ead6991a..000000000 --- a/src/NzbDrone.Test.Common/License.txt +++ /dev/null @@ -1,22 +0,0 @@ - Copyright (c) 2010 Darren Cauthon - - Permission is hereby granted, free of charge, to any person - obtaining a copy of this software and associated documentation - files (the "Software"), to deal in the Software without - restriction, including without limitation the rights to use, - copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the - Software is furnished to do so, subject to the following - conditions: - - The above copyright notice and this permission notice shall be - included in all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES - OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR - OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/NzbDrone.Test.Common/Lidarr.Test.Common.csproj b/src/NzbDrone.Test.Common/Lidarr.Test.Common.csproj new file mode 100644 index 000000000..4b4765b04 --- /dev/null +++ b/src/NzbDrone.Test.Common/Lidarr.Test.Common.csproj @@ -0,0 +1,21 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net462</TargetFramework> + <Platforms>x86</Platforms> + </PropertyGroup> + <ItemGroup> + <PackageReference Include="FluentAssertions" Version="4.19.0" /> + <PackageReference Include="FluentValidation" Version="6.2.1" /> + <PackageReference Include="Moq" Version="4.12.0" /> + <PackageReference Include="Newtonsoft.Json" Version="12.0.2" /> + <PackageReference Include="NLog" Version="4.5.4" /> + <PackageReference Include="NUnit" Version="3.11.0" /> + <PackageReference Include="RestSharp" Version="105.2.3" /> + <PackageReference Include="System.IO.Abstractions" Version="4.0.11" /> + <PackageReference Include="Unity" Version="4.0.1" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\NzbDrone.Common\Lidarr.Common.csproj" /> + <ProjectReference Include="..\NzbDrone.Core\Lidarr.Core.csproj" /> + </ItemGroup> +</Project> diff --git a/src/NzbDrone.Test.Common/LoggingTest.cs b/src/NzbDrone.Test.Common/LoggingTest.cs index b8aba6dcd..b2d783761 100644 --- a/src/NzbDrone.Test.Common/LoggingTest.cs +++ b/src/NzbDrone.Test.Common/LoggingTest.cs @@ -1,6 +1,8 @@ using NLog; using NLog.Config; using NLog.Targets; +using System; +using System.IO; using NUnit.Framework; using NUnit.Framework.Interfaces; using NzbDrone.Common.EnvironmentInfo; @@ -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("LIDARR_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 deleted file mode 100644 index 9b27aef93..000000000 --- a/src/NzbDrone.Test.Common/NzbDrone.Test.Common.csproj +++ /dev/null @@ -1,130 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <PropertyGroup> - <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> - <Platform Condition=" '$(Platform)' == '' ">x86</Platform> - <ProductVersion>8.0.30703</ProductVersion> - <SchemaVersion>2.0</SchemaVersion> - <ProjectGuid>{CADDFCE0-7509-4430-8364-2074E1EEFCA2}</ProjectGuid> - <OutputType>Library</OutputType> - <AppDesignerFolder>Properties</AppDesignerFolder> - <RootNamespace>NzbDrone.Test.Common</RootNamespace> - <AssemblyName>NzbDrone.Test.Common</AssemblyName> - <TargetFrameworkVersion>v4.0</TargetFrameworkVersion> - <FileAlignment>512</FileAlignment> - <SolutionDir Condition="$(SolutionDir) == '' Or $(SolutionDir) == '*Undefined*'">..\</SolutionDir> - <RestorePackages>true</RestorePackages> - </PropertyGroup> - <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x86'"> - <DebugSymbols>true</DebugSymbols> - <OutputPath>bin\x86\Debug\</OutputPath> - <DefineConstants>DEBUG;TRACE</DefineConstants> - <DebugType>full</DebugType> - <PlatformTarget>x86</PlatformTarget> - <ErrorReport>prompt</ErrorReport> - <CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet> - <WarningLevel>4</WarningLevel> - <Optimize>false</Optimize> - </PropertyGroup> - <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x86'"> - <OutputPath>bin\x86\Release\</OutputPath> - <DefineConstants>TRACE</DefineConstants> - <Optimize>true</Optimize> - <DebugType>pdbonly</DebugType> - <PlatformTarget>x86</PlatformTarget> - <ErrorReport>prompt</ErrorReport> - <CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet> - <WarningLevel>4</WarningLevel> - </PropertyGroup> - <ItemGroup> - <Reference Include="FluentAssertions, Version=4.18.0.0, Culture=neutral, PublicKeyToken=33f2691a05b67b6a, processorArchitecture=MSIL"> - <HintPath>..\packages\FluentAssertions.4.18.0\lib\net40\FluentAssertions.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="FluentAssertions.Core, Version=4.18.0.0, Culture=neutral, PublicKeyToken=33f2691a05b67b6a, processorArchitecture=MSIL"> - <HintPath>..\packages\FluentAssertions.4.18.0\lib\net40\FluentAssertions.Core.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.1\lib\net40\NLog.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="nunit.framework, Version=3.5.0.0, Culture=neutral, PublicKeyToken=2638cd05610744eb, processorArchitecture=MSIL"> - <HintPath>..\packages\NUnit.3.5.0\lib\net40\nunit.framework.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="RestSharp, Version=105.2.3.0, Culture=neutral, processorArchitecture=MSIL"> - <HintPath>..\packages\RestSharp.105.2.3\lib\net4\RestSharp.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="System" /> - <Reference Include="System.Core" /> - <Reference Include="System.Data" /> - <Reference Include="System.Data.DataSetExtensions" /> - <Reference Include="System.Xml" /> - <Reference Include="System.Xml.Linq" /> - <Reference Include="Microsoft.CSharp" /> - <Reference Include="Microsoft.Practices.ServiceLocation"> - <HintPath>..\packages\CommonServiceLocator.1.0\lib\NET35\Microsoft.Practices.ServiceLocation.dll</HintPath> - </Reference> - <Reference Include="Microsoft.Practices.Unity"> - <HintPath>..\packages\Unity.2.1.505.2\lib\NET35\Microsoft.Practices.Unity.dll</HintPath> - </Reference> - <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" /> - <Compile Include="AutoMoq\Unity\AutoMockingBuilderStrategy.cs" /> - <Compile Include="AutoMoq\Unity\AutoMockingContainerExtension.cs" /> - <Compile Include="Categories\DiskAccessTestAttribute.cs" /> - <Compile Include="Categories\ManualTestAttribute.cs" /> - <Compile Include="Categories\IntegrationTestAttribute.cs" /> - <Compile Include="ConcurrencyCounter.cs" /> - <Compile Include="ExceptionVerification.cs" /> - <Compile Include="LoggingTest.cs" /> - <Compile Include="MockerExtensions.cs" /> - <Compile Include="NzbDroneRunner.cs" /> - <Compile Include="Properties\AssemblyInfo.cs" /> - <Compile Include="ReflectionExtensions.cs" /> - <Compile Include="StringExtensions.cs" /> - <Compile Include="TestBase.cs" /> - <Compile Include="TestException.cs" /> - </ItemGroup> - <ItemGroup> - <Content Include="AutoMoq\License.txt" /> - </ItemGroup> - <ItemGroup> - <None Include="App.config"> - <SubType>Designer</SubType> - <CopyToOutputDirectory>Always</CopyToOutputDirectory> - </None> - <None Include="packages.config" /> - </ItemGroup> - <ItemGroup> - <ProjectReference Include="..\NzbDrone.Common\NzbDrone.Common.csproj"> - <Project>{F2BE0FDF-6E47-4827-A420-DD4EF82407F8}</Project> - <Name>NzbDrone.Common</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.Core\NzbDrone.Core.csproj"> - <Project>{FF5EE3B6-913B-47CE-9CEB-11C51B4E1205}</Project> - <Name>NzbDrone.Core</Name> - </ProjectReference> - </ItemGroup> - <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> - <!-- To modify your build process, add your task inside one of the targets below and uncomment it. - Other similar extension points exist, see Microsoft.Common.targets. - <Target Name="BeforeBuild"> - </Target> - <Target Name="AfterBuild"> - </Target> - --> -</Project> \ No newline at end of file diff --git a/src/NzbDrone.Test.Common/NzbDroneRunner.cs b/src/NzbDrone.Test.Common/NzbDroneRunner.cs index 2f1d8e3f5..4901be8ef 100644 --- a/src/NzbDrone.Test.Common/NzbDroneRunner.cs +++ b/src/NzbDrone.Test.Common/NzbDroneRunner.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Diagnostics; using System.IO; using System.Threading; @@ -9,6 +9,7 @@ using NLog; using NUnit.Framework; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Processes; +using NzbDrone.Core.Configuration; using RestSharp; namespace NzbDrone.Test.Common @@ -22,30 +23,28 @@ namespace NzbDrone.Test.Common public string AppData { get; private set; } public string ApiKey { get; private set; } - public NzbDroneRunner(Logger logger, int port = 8989) + public NzbDroneRunner(Logger logger, int port = 8686) { _processProvider = new ProcessProvider(logger); - _restClient = new RestClient("http://localhost:8989/api"); + _restClient = new RestClient("http://localhost:8686/api"); } public void Start() { - AppData = Path.Combine(TestContext.CurrentContext.TestDirectory, "_intg_" + DateTime.Now.Ticks); + AppData = Path.Combine(TestContext.CurrentContext.TestDirectory, "_intg_" + TestBase.GetUID()); + Directory.CreateDirectory(AppData); - var nzbdroneConsoleExe = "NzbDrone.Console.exe"; - - if (OsInfo.IsNotWindows) - { - nzbdroneConsoleExe = "NzbDrone.exe"; - } + GenerateConfigFile(); + + var lidarrConsoleExe = OsInfo.IsWindows ? "Lidarr.Console.exe" : "Lidarr.exe"; if (BuildInfo.IsDebug) { - Start(Path.Combine(TestContext.CurrentContext.TestDirectory, "..\\..\\..\\..\\..\\_output\\NzbDrone.Console.exe")); + Start(Path.Combine(TestContext.CurrentContext.TestDirectory, "..", "_output", "Lidarr.Console.exe")); } else { - Start(Path.Combine("bin", nzbdroneConsoleExe)); + Start(Path.Combine("bin", lidarrConsoleExe)); } while (true) @@ -57,8 +56,6 @@ namespace NzbDrone.Test.Common Assert.Fail("Process has exited"); } - SetApiKey(); - var request = new RestRequest("system/status"); request.AddHeader("Authorization", ApiKey); request.AddHeader("X-Api-Key", ApiKey); @@ -67,11 +64,11 @@ namespace NzbDrone.Test.Common if (statusCall.ResponseStatus == ResponseStatus.Completed) { - Console.WriteLine("NzbDrone is started. Running Tests"); + Console.WriteLine("Lidarr is started. Running Tests"); return; } - Console.WriteLine("Waiting for NzbDrone to start. Response Status : {0} [{1}] {2}", statusCall.ResponseStatus, statusCall.StatusDescription, statusCall.ErrorException); + Console.WriteLine("Waiting for Lidarr to start. Response Status : {0} [{1}] {2}", statusCall.ResponseStatus, statusCall.StatusDescription, statusCall.ErrorException); Thread.Sleep(500); } @@ -79,13 +76,22 @@ namespace NzbDrone.Test.Common public void KillAll() { - if (_nzbDroneProcess != null) + try + { + if (_nzbDroneProcess != null) + { + _processProvider.Kill(_nzbDroneProcess.Id); + } + + _processProvider.KillAll(ProcessProvider.LIDARR_CONSOLE_PROCESS_NAME); + _processProvider.KillAll(ProcessProvider.LIDARR_PROCESS_NAME); + } + catch (InvalidOperationException) { - _processProvider.Kill(_nzbDroneProcess.Id); + // May happen if the process closes while being closed } - _processProvider.KillAll(ProcessProvider.NZB_DRONE_CONSOLE_PROCESS_NAME); - _processProvider.KillAll(ProcessProvider.NZB_DRONE_PROCESS_NAME); + TestBase.DeleteTempFolder(AppData); } private void Start(string outputNzbdroneConsoleExe) @@ -105,33 +111,26 @@ namespace NzbDrone.Test.Common } } - private void SetApiKey() + private void GenerateConfigFile() { var configFile = Path.Combine(AppData, "config.xml"); - var attempts = 0; - while (ApiKey == null && attempts < 50) - { - try - { - if (File.Exists(configFile)) - { - var apiKeyElement = XDocument.Load(configFile) - .XPathSelectElement("Config/ApiKey"); - if (apiKeyElement != null) - { - ApiKey = apiKeyElement.Value; - } - } - } - catch (XmlException ex) - { - Console.WriteLine("Error getting API Key from XML file: " + ex.Message, ex); - } + // Generate and set the api key so we don't have to poll the config file + var apiKey = Guid.NewGuid().ToString().Replace("-", ""); - attempts++; - Thread.Sleep(1000); - } + var xDoc = new XDocument( + new XDeclaration("1.0", "utf-8", "yes"), + new XElement(ConfigFileProvider.CONFIG_ELEMENT_NAME, + new XElement(nameof(ConfigFileProvider.ApiKey), apiKey), + new XElement(nameof(ConfigFileProvider.AnalyticsEnabled), false) + ) + ); + + var data = xDoc.ToString(); + + File.WriteAllText(configFile, data); + + ApiKey = apiKey; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Test.Common/Properties/AssemblyInfo.cs b/src/NzbDrone.Test.Common/Properties/AssemblyInfo.cs deleted file mode 100644 index d82d940d5..000000000 --- a/src/NzbDrone.Test.Common/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("NzbDrone.Test.Common")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("Microsoft")] -[assembly: AssemblyProduct("NzbDrone.Test.Common")] -[assembly: AssemblyCopyright("Copyright © Microsoft 2011")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("f3e91f6e-d01d-4f20-8255-147cc10f04e3")] - -[assembly: AssemblyVersion("10.0.0.*")] diff --git a/src/NzbDrone.Test.Common/TestBase.cs b/src/NzbDrone.Test.Common/TestBase.cs index df23f75c6..9e21ab010 100644 --- a/src/NzbDrone.Test.Common/TestBase.cs +++ b/src/NzbDrone.Test.Common/TestBase.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.IO; using System.Threading; using FluentAssertions; @@ -19,7 +20,7 @@ namespace NzbDrone.Test.Common private TSubject _subject; [SetUp] - public void CoreTestSetup() + public virtual void CoreTestSetup() { _subject = null; @@ -43,8 +44,8 @@ namespace NzbDrone.Test.Common public abstract class TestBase : LoggingTest { - private static readonly Random _random = new Random(); + private static int _nextUid; private AutoMoqer _mocker; protected AutoMoqer Mocker @@ -84,7 +85,21 @@ namespace NzbDrone.Test.Common } } - protected string TempFolder { get; private set; } + private string _tempFolder; + protected string TempFolder + { + get + { + if (_tempFolder == null) + { + _tempFolder = Path.Combine(TestContext.CurrentContext.TestDirectory, "_temp_" + GetUID()); + + Directory.CreateDirectory(_tempFolder); + } + + return _tempFolder; + } + } [SetUp] public void TestBaseSetup() @@ -93,9 +108,7 @@ namespace NzbDrone.Test.Common LogManager.ReconfigExistingLoggers(); - TempFolder = Path.Combine(TestContext.CurrentContext.TestDirectory, "_temp_" + DateTime.Now.Ticks); - - Directory.CreateDirectory(TempFolder); + _tempFolder = null; } [TearDown] @@ -103,9 +116,25 @@ namespace NzbDrone.Test.Common { _mocker = null; + DeleteTempFolder(_tempFolder); + } + + + public static string GetUID() + { + return Process.GetCurrentProcess().Id + "_" + DateTime.Now.Ticks + "_" + Interlocked.Increment(ref _nextUid); + } + + public static void DeleteTempFolder(string folder) + { + if (folder == null) + { + return; + } + try { - var tempFolder = new DirectoryInfo(TempFolder); + var tempFolder = new DirectoryInfo(folder); if (tempFolder.Exists) { foreach (var file in tempFolder.GetFiles("*", SearchOption.AllDirectories)) 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..e6851b01f --- /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 deleted file mode 100644 index 7cc31b27c..000000000 --- a/src/NzbDrone.Test.Common/packages.config +++ /dev/null @@ -1,11 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<packages> - <package id="CommonServiceLocator" version="1.0" /> - <package id="FluentAssertions" version="4.18.0" targetFramework="net40" /> - <package id="Moq" version="4.0.10827" /> - <package id="Newtonsoft.Json" version="9.0.1" targetFramework="net40" /> - <package id="NLog" version="4.4.1" targetFramework="net40" /> - <package id="NUnit" version="3.5.0" targetFramework="net40" /> - <package id="RestSharp" version="105.2.3" targetFramework="net40" /> - <package id="Unity" version="2.1.505.2" targetFramework="net40" /> -</packages> \ No newline at end of file diff --git a/src/NzbDrone.Test.Dummy/DummyApp.cs b/src/NzbDrone.Test.Dummy/DummyApp.cs index 5176dab6f..37b7f261e 100644 --- a/src/NzbDrone.Test.Dummy/DummyApp.cs +++ b/src/NzbDrone.Test.Dummy/DummyApp.cs @@ -1,11 +1,11 @@ -using System; +using System; using System.Diagnostics; namespace NzbDrone.Test.Dummy { public class DummyApp { - public const string DUMMY_PROCCESS_NAME = "NzbDrone.Test.Dummy"; + public const string DUMMY_PROCCESS_NAME = "Lidarr.Test.Dummy"; static void Main(string[] args) { diff --git a/src/NzbDrone.Test.Dummy/Lidarr.Test.Dummy.csproj b/src/NzbDrone.Test.Dummy/Lidarr.Test.Dummy.csproj new file mode 100644 index 000000000..2da82570e --- /dev/null +++ b/src/NzbDrone.Test.Dummy/Lidarr.Test.Dummy.csproj @@ -0,0 +1,7 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <OutputType>Exe</OutputType> + <TargetFramework>net462</TargetFramework> + <Platforms>x86</Platforms> + </PropertyGroup> +</Project> diff --git a/src/NzbDrone.Test.Dummy/NzbDrone.Test.Dummy.csproj b/src/NzbDrone.Test.Dummy/NzbDrone.Test.Dummy.csproj deleted file mode 100644 index fb311dbd5..000000000 --- a/src/NzbDrone.Test.Dummy/NzbDrone.Test.Dummy.csproj +++ /dev/null @@ -1,63 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <PropertyGroup> - <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> - <Platform Condition=" '$(Platform)' == '' ">x86</Platform> - <ProductVersion>8.0.30703</ProductVersion> - <SchemaVersion>2.0</SchemaVersion> - <ProjectGuid>{FAFB5948-A222-4CF6-AD14-026BE7564802}</ProjectGuid> - <OutputType>Exe</OutputType> - <AppDesignerFolder>Properties</AppDesignerFolder> - <RootNamespace>NzbDrone.Test.Dummy</RootNamespace> - <AssemblyName>NzbDrone.Test.Dummy</AssemblyName> - <TargetFrameworkVersion>v4.0</TargetFrameworkVersion> - <TargetFrameworkProfile> - </TargetFrameworkProfile> - <FileAlignment>512</FileAlignment> - <SolutionDir Condition="$(SolutionDir) == '' Or $(SolutionDir) == '*Undefined*'">..\</SolutionDir> - <RestorePackages>true</RestorePackages> - </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|x86' "> - <PlatformTarget>x86</PlatformTarget> - <DebugSymbols>true</DebugSymbols> - <DebugType>full</DebugType> - <Optimize>false</Optimize> - <OutputPath>bin\Debug\</OutputPath> - <DefineConstants>DEBUG;TRACE</DefineConstants> - <ErrorReport>prompt</ErrorReport> - <WarningLevel>4</WarningLevel> - </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|x86' "> - <PlatformTarget>x86</PlatformTarget> - <DebugType>pdbonly</DebugType> - <Optimize>true</Optimize> - <OutputPath>bin\Release\</OutputPath> - <DefineConstants>TRACE</DefineConstants> - <ErrorReport>prompt</ErrorReport> - <WarningLevel>4</WarningLevel> - </PropertyGroup> - <ItemGroup> - <Reference Include="System" /> - <Reference Include="System.Core" /> - <Reference Include="System.Data" /> - <Reference Include="System.Data.DataSetExtensions" /> - <Reference Include="System.Xml" /> - <Reference Include="System.Xml.Linq" /> - <Reference Include="Microsoft.CSharp" /> - </ItemGroup> - <ItemGroup> - <Compile Include="DummyApp.cs" /> - <Compile Include="Properties\AssemblyInfo.cs" /> - </ItemGroup> - <ItemGroup> - <None Include="app.config" /> - </ItemGroup> - <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> - <!-- To modify your build process, add your task inside one of the targets below and uncomment it. - Other similar extension points exist, see Microsoft.Common.targets. - <Target Name="BeforeBuild"> - </Target> - <Target Name="AfterBuild"> - </Target> - --> -</Project> diff --git a/src/NzbDrone.Test.Dummy/Properties/AssemblyInfo.cs b/src/NzbDrone.Test.Dummy/Properties/AssemblyInfo.cs deleted file mode 100644 index d2e93dadf..000000000 --- a/src/NzbDrone.Test.Dummy/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("NzbDrone.Test.Dummy")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("Microsoft")] -[assembly: AssemblyProduct("NzbDrone.Test.Dummy")] -[assembly: AssemblyCopyright("Copyright © Microsoft 2011")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("7b773a86-574d-48c3-9e89-6f2e0dff714b")] - -[assembly: AssemblyVersion("10.0.0.*")] diff --git a/src/NzbDrone.Test.Dummy/app.config b/src/NzbDrone.Test.Dummy/app.config index e36560333..ac5aa757c 100644 --- a/src/NzbDrone.Test.Dummy/app.config +++ b/src/NzbDrone.Test.Dummy/app.config @@ -1,3 +1,3 @@ <?xml version="1.0"?> <configuration> -<startup><supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.0"/></startup></configuration> +<startup><supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.6.2"/></startup></configuration> diff --git a/src/NzbDrone.Update.Test/License.txt b/src/NzbDrone.Update.Test/License.txt deleted file mode 100644 index 5ead6991a..000000000 --- a/src/NzbDrone.Update.Test/License.txt +++ /dev/null @@ -1,22 +0,0 @@ - Copyright (c) 2010 Darren Cauthon - - Permission is hereby granted, free of charge, to any person - obtaining a copy of this software and associated documentation - files (the "Software"), to deal in the Software without - restriction, including without limitation the rights to use, - copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the - Software is furnished to do so, subject to the following - conditions: - - The above copyright notice and this permission notice shall be - included in all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES - OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR - OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/NzbDrone.Update.Test/Lidarr.Update.Test.csproj b/src/NzbDrone.Update.Test/Lidarr.Update.Test.csproj new file mode 100644 index 000000000..b67841419 --- /dev/null +++ b/src/NzbDrone.Update.Test/Lidarr.Update.Test.csproj @@ -0,0 +1,10 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net462</TargetFramework> + <Platforms>x86</Platforms> + </PropertyGroup> + <ItemGroup> + <ProjectReference Include="..\NzbDrone.Test.Common\Lidarr.Test.Common.csproj" /> + <ProjectReference Include="..\NzbDrone.Update\Lidarr.Update.csproj" /> + </ItemGroup> +</Project> diff --git a/src/NzbDrone.Update.Test/NzbDrone.Update.Test.csproj b/src/NzbDrone.Update.Test/NzbDrone.Update.Test.csproj deleted file mode 100644 index ea3aa9f23..000000000 --- a/src/NzbDrone.Update.Test/NzbDrone.Update.Test.csproj +++ /dev/null @@ -1,109 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <PropertyGroup> - <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> - <Platform Condition=" '$(Platform)' == '' ">x86</Platform> - <ProductVersion>8.0.30703</ProductVersion> - <SchemaVersion>2.0</SchemaVersion> - <ProjectGuid>{35388E8E-0CDB-4A84-AD16-E4B6EFDA5D97}</ProjectGuid> - <OutputType>Library</OutputType> - <AppDesignerFolder>Properties</AppDesignerFolder> - <RootNamespace>NzbDrone.Update.Test</RootNamespace> - <AssemblyName>NzbDrone.Update.Test</AssemblyName> - <TargetFrameworkVersion>v4.0</TargetFrameworkVersion> - <TargetFrameworkProfile> - </TargetFrameworkProfile> - <FileAlignment>512</FileAlignment> - <SolutionDir Condition="$(SolutionDir) == '' Or $(SolutionDir) == '*Undefined*'">..\</SolutionDir> - <RestorePackages>true</RestorePackages> - </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|x86' "> - <PlatformTarget>x86</PlatformTarget> - <DebugSymbols>true</DebugSymbols> - <DebugType>full</DebugType> - <Optimize>false</Optimize> - <OutputPath>bin\Debug\</OutputPath> - <DefineConstants>DEBUG;TRACE</DefineConstants> - <ErrorReport>prompt</ErrorReport> - <WarningLevel>4</WarningLevel> - </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|x86' "> - <PlatformTarget>x86</PlatformTarget> - <DebugType>pdbonly</DebugType> - <Optimize>true</Optimize> - <OutputPath>bin\Release\</OutputPath> - <DefineConstants>TRACE</DefineConstants> - <ErrorReport>prompt</ErrorReport> - <WarningLevel>4</WarningLevel> - </PropertyGroup> - <ItemGroup> - <Reference Include="FizzWare.NBuilder, Version=4.0.0.115, Culture=neutral, PublicKeyToken=5651b03e12e42c12, processorArchitecture=MSIL"> - <HintPath>..\packages\NBuilder.4.0.0\lib\net40\FizzWare.NBuilder.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="FluentAssertions, Version=4.18.0.0, Culture=neutral, PublicKeyToken=33f2691a05b67b6a, processorArchitecture=MSIL"> - <HintPath>..\packages\FluentAssertions.4.18.0\lib\net40\FluentAssertions.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="FluentAssertions.Core, Version=4.18.0.0, Culture=neutral, PublicKeyToken=33f2691a05b67b6a, processorArchitecture=MSIL"> - <HintPath>..\packages\FluentAssertions.4.18.0\lib\net40\FluentAssertions.Core.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.1\lib\net40\NLog.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="nunit.framework, Version=3.5.0.0, Culture=neutral, PublicKeyToken=2638cd05610744eb, processorArchitecture=MSIL"> - <HintPath>..\packages\NUnit.3.5.0\lib\net40\nunit.framework.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="System" /> - <Reference Include="System.Core" /> - <Reference Include="System.Data" /> - <Reference Include="System.Data.DataSetExtensions" /> - <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" /> - <Compile Include="ProgramFixture.cs" /> - <Compile Include="Properties\AssemblyInfo.cs" /> - <Compile Include="StartNzbDroneService.cs" /> - <Compile Include="UpdateProviderStartFixture.cs" /> - </ItemGroup> - <ItemGroup> - <None Include="..\NzbDrone.Test.Common\App.config"> - <Link>App.config</Link> - </None> - <None Include="packages.config" /> - </ItemGroup> - <ItemGroup> - <ProjectReference Include="..\NzbDrone.Common\NzbDrone.Common.csproj"> - <Project>{F2BE0FDF-6E47-4827-A420-DD4EF82407F8}</Project> - <Name>NzbDrone.Common</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.Test.Common\NzbDrone.Test.Common.csproj"> - <Project>{CADDFCE0-7509-4430-8364-2074E1EEFCA2}</Project> - <Name>NzbDrone.Test.Common</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.Update\NzbDrone.Update.csproj"> - <Project>{4CCC53CD-8D5E-4CC4-97D2-5C9312AC2BD7}</Project> - <Name>NzbDrone.Update</Name> - </ProjectReference> - </ItemGroup> - <ItemGroup> - <Service Include="{82A7F48D-3B50-4B1E-B82E-3ADA8210C358}" /> - </ItemGroup> - <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> - <!-- To modify your build process, add your task inside one of the targets below and uncomment it. - Other similar extension points exist, see Microsoft.Common.targets. - <Target Name="BeforeBuild"> - </Target> - <Target Name="AfterBuild"> - </Target> - --> -</Project> \ No newline at end of file diff --git a/src/NzbDrone.Update.Test/ProgramFixture.cs b/src/NzbDrone.Update.Test/ProgramFixture.cs index 5d9b7243a..7c3688682 100644 --- a/src/NzbDrone.Update.Test/ProgramFixture.cs +++ b/src/NzbDrone.Update.Test/ProgramFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using Moq; using NUnit.Framework; using NzbDrone.Common.Model; @@ -34,7 +34,7 @@ namespace NzbDrone.Update.Test [Test] public void should_call_update_with_correct_path() { - var ProcessPath = @"C:\NzbDrone\nzbdrone.exe".AsOsAgnostic(); + var ProcessPath = @"C:\Lidarr\lidarr.exe".AsOsAgnostic(); Mocker.GetMock<IProcessProvider>().Setup(c => c.GetProcessById(12)) .Returns(new ProcessInfo() { StartPath = ProcessPath }); @@ -43,7 +43,7 @@ namespace NzbDrone.Update.Test Subject.Start(new[] { "12", "", ProcessPath }); - Mocker.GetMock<IInstallUpdateService>().Verify(c => c.Start(@"C:\NzbDrone".AsOsAgnostic(), 12), Times.Once()); + Mocker.GetMock<IInstallUpdateService>().Verify(c => c.Start(@"C:\Lidarr".AsOsAgnostic(), 12), Times.Once()); } diff --git a/src/NzbDrone.Update.Test/Properties/AssemblyInfo.cs b/src/NzbDrone.Update.Test/Properties/AssemblyInfo.cs deleted file mode 100644 index 35dc227d7..000000000 --- a/src/NzbDrone.Update.Test/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("NzbDrone.Update.Test")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("Microsoft")] -[assembly: AssemblyProduct("NzbDrone.Update.Test")] -[assembly: AssemblyCopyright("Copyright © Microsoft 2011")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("b323e212-2d04-4c7f-9097-c356749ace4d")] - -[assembly: AssemblyVersion("10.0.0.*")] diff --git a/src/NzbDrone.Update.Test/StartNzbDroneService.cs b/src/NzbDrone.Update.Test/StartNzbDroneService.cs index 4cb97c91d..439cac23a 100644 --- a/src/NzbDrone.Update.Test/StartNzbDroneService.cs +++ b/src/NzbDrone.Update.Test/StartNzbDroneService.cs @@ -1,4 +1,4 @@ -using System; +using System; using Moq; using NUnit.Framework; using NzbDrone.Common; @@ -16,23 +16,24 @@ namespace NzbDrone.Update.Test [Test] public void should_start_service_if_app_type_was_serivce() { - const string targetFolder = "c:\\NzbDrone\\"; + string targetFolder = "c:\\Lidarr\\".AsOsAgnostic(); Subject.Start(AppType.Service, targetFolder); - Mocker.GetMock<IServiceProvider>().Verify(c => c.Start(ServiceProvider.NZBDRONE_SERVICE_NAME), Times.Once()); + Mocker.GetMock<IServiceProvider>().Verify(c => c.Start(ServiceProvider.SERVICE_NAME), Times.Once()); } [Test] public void should_start_console_if_app_type_was_service_but_start_failed_because_of_permissions() { - const string targetFolder = "c:\\NzbDrone\\"; + string targetFolder = "c:\\Lidarr\\".AsOsAgnostic(); + string targetProcess = "c:\\Lidarr\\Lidarr.Console.exe".AsOsAgnostic(); - Mocker.GetMock<IServiceProvider>().Setup(c => c.Start(ServiceProvider.NZBDRONE_SERVICE_NAME)).Throws(new InvalidOperationException()); + Mocker.GetMock<IServiceProvider>().Setup(c => c.Start(ServiceProvider.SERVICE_NAME)).Throws(new InvalidOperationException()); Subject.Start(AppType.Service, targetFolder); - Mocker.GetMock<IProcessProvider>().Verify(c => c.SpawnNewProcess("c:\\NzbDrone\\NzbDrone.Console.exe", "/" + StartupContext.NO_BROWSER, null), Times.Once()); + Mocker.GetMock<IProcessProvider>().Verify(c => c.SpawnNewProcess(targetProcess, "/" + StartupContext.NO_BROWSER, null, false), Times.Once()); ExceptionVerification.ExpectedWarns(1); } diff --git a/src/NzbDrone.Update.Test/packages.config b/src/NzbDrone.Update.Test/packages.config deleted file mode 100644 index 1efb88f74..000000000 --- a/src/NzbDrone.Update.Test/packages.config +++ /dev/null @@ -1,8 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<packages> - <package id="FluentAssertions" version="4.18.0" targetFramework="net40" /> - <package id="Moq" version="4.0.10827" /> - <package id="NBuilder" version="4.0.0" targetFramework="net40" /> - <package id="NLog" version="4.4.1" targetFramework="net40" /> - <package id="NUnit" version="3.5.0" targetFramework="net40" /> -</packages> \ No newline at end of file diff --git a/src/NzbDrone.Update/Lidarr.Update.csproj b/src/NzbDrone.Update/Lidarr.Update.csproj new file mode 100644 index 000000000..412ce5e94 --- /dev/null +++ b/src/NzbDrone.Update/Lidarr.Update.csproj @@ -0,0 +1,13 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <OutputType>WinExe</OutputType> + <TargetFramework>net462</TargetFramework> + <Platforms>x86</Platforms> + </PropertyGroup> + <ItemGroup> + <PackageReference Include="NLog" Version="4.5.4" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\NzbDrone.Common\Lidarr.Common.csproj" /> + </ItemGroup> +</Project> diff --git a/src/NzbDrone.Update/NzbDrone.Update.csproj b/src/NzbDrone.Update/NzbDrone.Update.csproj deleted file mode 100644 index 97a8e151c..000000000 --- a/src/NzbDrone.Update/NzbDrone.Update.csproj +++ /dev/null @@ -1,91 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <PropertyGroup> - <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> - <Platform Condition=" '$(Platform)' == '' ">x86</Platform> - <ProductVersion>8.0.30703</ProductVersion> - <SchemaVersion>2.0</SchemaVersion> - <ProjectGuid>{4CCC53CD-8D5E-4CC4-97D2-5C9312AC2BD7}</ProjectGuid> - <OutputType>WinExe</OutputType> - <AppDesignerFolder>Properties</AppDesignerFolder> - <RootNamespace>NzbDrone.Update</RootNamespace> - <AssemblyName>NzbDrone.Update</AssemblyName> - <TargetFrameworkVersion>v4.0</TargetFrameworkVersion> - <TargetFrameworkProfile> - </TargetFrameworkProfile> - <FileAlignment>512</FileAlignment> - <SolutionDir Condition="$(SolutionDir) == '' Or $(SolutionDir) == '*Undefined*'">..\</SolutionDir> - <RestorePackages>true</RestorePackages> - </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|x86' "> - <PlatformTarget>x86</PlatformTarget> - <DebugSymbols>true</DebugSymbols> - <DebugType>full</DebugType> - <Optimize>false</Optimize> - <OutputPath>..\..\_output\NzbDrone.Update\</OutputPath> - <DefineConstants>DEBUG;TRACE</DefineConstants> - <ErrorReport>prompt</ErrorReport> - <WarningLevel>4</WarningLevel> - </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|x86' "> - <PlatformTarget>x86</PlatformTarget> - <DebugType>pdbonly</DebugType> - <Optimize>true</Optimize> - <OutputPath>..\..\_output\NzbDrone.Update\</OutputPath> - <DefineConstants>TRACE</DefineConstants> - <ErrorReport>prompt</ErrorReport> - <WarningLevel>4</WarningLevel> - </PropertyGroup> - <PropertyGroup> - <ApplicationManifest>app.manifest</ApplicationManifest> - </PropertyGroup> - <ItemGroup> - <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.1\lib\net40\NLog.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="System" /> - <Reference Include="System.Core" /> - </ItemGroup> - <ItemGroup> - <Compile Include="..\NzbDrone.Common\Properties\SharedAssemblyInfo.cs"> - <Link>Properties\SharedAssemblyInfo.cs</Link> - </Compile> - <Compile Include="AppType.cs" /> - <Compile Include="Properties\AssemblyInfo.cs" /> - <Compile Include="UpdateApp.cs" /> - <Compile Include="UpdateContainerBuilder.cs" /> - <Compile Include="UpdateEngine\BackupAndRestore.cs" /> - <Compile Include="UpdateEngine\BackupAppData.cs" /> - <Compile Include="UpdateEngine\DetectExistingVersion.cs" /> - <Compile Include="UpdateEngine\DetectApplicationType.cs" /> - <Compile Include="UpdateEngine\InstallUpdateService.cs" /> - <Compile Include="UpdateEngine\StartNzbDrone.cs" /> - <Compile Include="UpdateEngine\TerminateNzbDrone.cs" /> - <Compile Include="UpdateStartupContext.cs" /> - </ItemGroup> - <ItemGroup> - <None Include="app.config" /> - <None Include="app.manifest" /> - <None Include="packages.config" /> - </ItemGroup> - <ItemGroup> - <ProjectReference Include="..\NzbDrone.Common\NzbDrone.Common.csproj"> - <Project>{F2BE0FDF-6E47-4827-A420-DD4EF82407F8}</Project> - <Name>NzbDrone.Common</Name> - </ProjectReference> - </ItemGroup> - <ItemGroup /> - <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> - <!-- To modify your build process, add your task inside one of the targets below and uncomment it. - Other similar extension points exist, see Microsoft.Common.targets. - <Target Name="BeforeBuild"> - </Target> - <Target Name="AfterBuild"> - </Target> - --> -</Project> \ No newline at end of file diff --git a/src/NzbDrone.Update/Properties/AssemblyInfo.cs b/src/NzbDrone.Update/Properties/AssemblyInfo.cs deleted file mode 100644 index 5a577baf3..000000000 --- a/src/NzbDrone.Update/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("NzbDrone.Update")] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("e4560a3d-8053-4d57-a260-bfe52f4cc357")] - -[assembly: AssemblyVersion("10.0.0.*")] diff --git a/src/NzbDrone.Update/UpdateApp.cs b/src/NzbDrone.Update/UpdateApp.cs index eda4f17a5..25bb5ab63 100644 --- a/src/NzbDrone.Update/UpdateApp.cs +++ b/src/NzbDrone.Update/UpdateApp.cs @@ -36,10 +36,10 @@ namespace NzbDrone.Update var startupContext = new StartupContext(args); NzbDroneLogger.Register(startupContext, true, true); - Logger.Info("Starting Sonarr Update Client"); + Logger.Info("Starting Lidarr Update Client"); _container = UpdateContainerBuilder.Build(startupContext); - + _container.Resolve<InitializeLogger>().Initialize(); _container.Resolve<UpdateApp>().Start(args); Logger.Info("Update completed successfully"); @@ -66,9 +66,9 @@ namespace NzbDrone.Update } var startupContext = new UpdateStartupContext - { - ProcessId = ParseProcessId(args[0]) - }; + { + ProcessId = ParseProcessId(args[0]) + }; if (OsInfo.IsNotWindows) { @@ -104,7 +104,7 @@ namespace NzbDrone.Update throw new ArgumentOutOfRangeException(nameof(arg), "Invalid process ID"); } - Logger.Debug("NzbDrone process ID: {0}", id); + Logger.Debug("Lidarr process ID: {0}", id); return id; } diff --git a/src/NzbDrone.Update/UpdateContainerBuilder.cs b/src/NzbDrone.Update/UpdateContainerBuilder.cs index 0dd0fc079..8a0f2fa4d 100644 --- a/src/NzbDrone.Update/UpdateContainerBuilder.cs +++ b/src/NzbDrone.Update/UpdateContainerBuilder.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using NzbDrone.Common.Composition; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Http.Dispatchers; @@ -10,14 +10,14 @@ namespace NzbDrone.Update private UpdateContainerBuilder(IStartupContext startupContext, List<string> assemblies) : base(startupContext, assemblies) { - Container.Register<IHttpDispatcher, FallbackHttpDispatcher>(); + } public static IContainer Build(IStartupContext startupContext) { var assemblies = new List<string> { - "NzbDrone.Update" + "Lidarr.Update" }; return new UpdateContainerBuilder(startupContext, assemblies).Container; diff --git a/src/NzbDrone.Update/UpdateEngine/BackupAppData.cs b/src/NzbDrone.Update/UpdateEngine/BackupAppData.cs index a93bca1f5..0031b55cb 100644 --- a/src/NzbDrone.Update/UpdateEngine/BackupAppData.cs +++ b/src/NzbDrone.Update/UpdateEngine/BackupAppData.cs @@ -47,7 +47,7 @@ namespace NzbDrone.Update.UpdateEngine try { _diskTransferService.TransferFile(_appFolderInfo.GetConfigPath(), _appFolderInfo.GetUpdateBackupConfigFile(), TransferMode.Copy); - _diskTransferService.TransferFile(_appFolderInfo.GetNzbDroneDatabase(), _appFolderInfo.GetUpdateBackupDatabase(), TransferMode.Copy); + _diskTransferService.TransferFile(_appFolderInfo.GetDatabase(), _appFolderInfo.GetUpdateBackupDatabase(), TransferMode.Copy); } catch (Exception e) { diff --git a/src/NzbDrone.Update/UpdateEngine/DetectApplicationType.cs b/src/NzbDrone.Update/UpdateEngine/DetectApplicationType.cs index 7df590a66..23434ec51 100644 --- a/src/NzbDrone.Update/UpdateEngine/DetectApplicationType.cs +++ b/src/NzbDrone.Update/UpdateEngine/DetectApplicationType.cs @@ -28,13 +28,13 @@ namespace NzbDrone.Update.UpdateEngine return AppType.Normal; } - if (_serviceProvider.ServiceExist(ServiceProvider.NZBDRONE_SERVICE_NAME) - && _serviceProvider.IsServiceRunning(ServiceProvider.NZBDRONE_SERVICE_NAME)) + if (_serviceProvider.ServiceExist(ServiceProvider.SERVICE_NAME) + && _serviceProvider.IsServiceRunning(ServiceProvider.SERVICE_NAME)) { return AppType.Service; } - if (_processProvider.Exists(ProcessProvider.NZB_DRONE_CONSOLE_PROCESS_NAME)) + if (_processProvider.Exists(ProcessProvider.LIDARR_CONSOLE_PROCESS_NAME)) { return AppType.Console; } diff --git a/src/NzbDrone.Update/UpdateEngine/DetectExistingVersion.cs b/src/NzbDrone.Update/UpdateEngine/DetectExistingVersion.cs index d27190f17..88665ce82 100644 --- a/src/NzbDrone.Update/UpdateEngine/DetectExistingVersion.cs +++ b/src/NzbDrone.Update/UpdateEngine/DetectExistingVersion.cs @@ -22,7 +22,7 @@ namespace NzbDrone.Update.UpdateEngine { try { - var targetExecutable = Path.Combine(targetFolder, "NzbDrone.exe"); + var targetExecutable = Path.Combine(targetFolder, "Lidarr.exe"); if (File.Exists(targetExecutable)) { diff --git a/src/NzbDrone.Update/UpdateEngine/InstallUpdateService.cs b/src/NzbDrone.Update/UpdateEngine/InstallUpdateService.cs index 9c2866330..9b7240ab2 100644 --- a/src/NzbDrone.Update/UpdateEngine/InstallUpdateService.cs +++ b/src/NzbDrone.Update/UpdateEngine/InstallUpdateService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using NLog; using NzbDrone.Common.Disk; @@ -80,14 +80,14 @@ namespace NzbDrone.Update.UpdateEngine public void Start(string installationFolder, int processId) { _logger.Info("Installation Folder: {0}", installationFolder); - _logger.Info("Updating Sonarr from version {0} to version {1}", _detectExistingVersion.GetExistingVersion(installationFolder), BuildInfo.Version); + _logger.Info("Updating Lidarr from version {0} to version {1}", _detectExistingVersion.GetExistingVersion(installationFolder), BuildInfo.Version); Verify(installationFolder, processId); var appType = _detectApplicationType.GetAppType(); - _processProvider.FindProcessByName(ProcessProvider.NZB_DRONE_CONSOLE_PROCESS_NAME); - _processProvider.FindProcessByName(ProcessProvider.NZB_DRONE_PROCESS_NAME); + _processProvider.FindProcessByName(ProcessProvider.LIDARR_CONSOLE_PROCESS_NAME); + _processProvider.FindProcessByName(ProcessProvider.LIDARR_PROCESS_NAME); if (OsInfo.IsWindows) { @@ -101,25 +101,22 @@ namespace NzbDrone.Update.UpdateEngine if (OsInfo.IsWindows) { - if (_processProvider.Exists(ProcessProvider.NZB_DRONE_CONSOLE_PROCESS_NAME) || _processProvider.Exists(ProcessProvider.NZB_DRONE_PROCESS_NAME)) + if (_processProvider.Exists(ProcessProvider.LIDARR_CONSOLE_PROCESS_NAME) || _processProvider.Exists(ProcessProvider.LIDARR_PROCESS_NAME)) { - _logger.Error("Sonarr was restarted prematurely by external process."); + _logger.Error("Lidarr was restarted prematurely by external process."); return; } } try { - _logger.Info("Emptying installation folder"); - _diskProvider.EmptyFolder(installationFolder); - _logger.Info("Copying new files to target folder"); _diskTransferService.MirrorFolder(_appFolderInfo.GetUpdatePackageFolder(), installationFolder); - // Set executable flag on Sonarr app + // Set executable flag on Lidarr app if (OsInfo.IsOsx) { - _diskProvider.SetPermissions(Path.Combine(installationFolder, "Sonarr"), "0755", null, null); + _diskProvider.SetPermissions(Path.Combine(installationFolder, "Lidarr"), "0755", null, null); } } catch (Exception e) @@ -144,14 +141,14 @@ namespace NzbDrone.Update.UpdateEngine { System.Threading.Thread.Sleep(1000); - if (_processProvider.Exists(ProcessProvider.NZB_DRONE_PROCESS_NAME)) + if (_processProvider.Exists(ProcessProvider.LIDARR_PROCESS_NAME)) { - _logger.Info("Sonarr was restarted by external process."); + _logger.Info("Lidarr was restarted by external process."); break; } } - if (!_processProvider.Exists(ProcessProvider.NZB_DRONE_PROCESS_NAME)) + if (!_processProvider.Exists(ProcessProvider.LIDARR_PROCESS_NAME)) { _startNzbDrone.Start(appType, installationFolder); } diff --git a/src/NzbDrone.Update/UpdateEngine/StartNzbDrone.cs b/src/NzbDrone.Update/UpdateEngine/StartNzbDrone.cs index 279ee8a56..cadd1ac15 100644 --- a/src/NzbDrone.Update/UpdateEngine/StartNzbDrone.cs +++ b/src/NzbDrone.Update/UpdateEngine/StartNzbDrone.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using NLog; using NzbDrone.Common; @@ -30,7 +30,7 @@ namespace NzbDrone.Update.UpdateEngine public void Start(AppType appType, string installationFolder) { - _logger.Info("Starting NzbDrone"); + _logger.Info("Starting Lidarr"); if (appType == AppType.Service) { try @@ -40,7 +40,7 @@ namespace NzbDrone.Update.UpdateEngine } catch (InvalidOperationException e) { - _logger.Warn(e, "Couldn't start NzbDrone Service (Most likely due to permission issues). falling back to console."); + _logger.Warn(e, "Couldn't start Lidarr Service (Most likely due to permission issues). Falling back to console."); StartConsole(installationFolder); } } @@ -56,18 +56,18 @@ namespace NzbDrone.Update.UpdateEngine private void StartService() { - _logger.Info("Starting NzbDrone service"); - _serviceProvider.Start(ServiceProvider.NZBDRONE_SERVICE_NAME); + _logger.Info("Starting Lidarr service"); + _serviceProvider.Start(ServiceProvider.SERVICE_NAME); } private void StartWinform(string installationFolder) { - Start(installationFolder, "NzbDrone.exe"); + Start(installationFolder, "Lidarr.exe"); } private void StartConsole(string installationFolder) { - Start(installationFolder, "NzbDrone.Console.exe"); + Start(installationFolder, "Lidarr.Console.exe"); } private void Start(string installationFolder, string fileName) diff --git a/src/NzbDrone.Update/UpdateEngine/TerminateNzbDrone.cs b/src/NzbDrone.Update/UpdateEngine/TerminateNzbDrone.cs index 45a584919..7ad7173c3 100644 --- a/src/NzbDrone.Update/UpdateEngine/TerminateNzbDrone.cs +++ b/src/NzbDrone.Update/UpdateEngine/TerminateNzbDrone.cs @@ -31,13 +31,13 @@ namespace NzbDrone.Update.UpdateEngine { _logger.Info("Stopping all running services"); - if (_serviceProvider.ServiceExist(ServiceProvider.NZBDRONE_SERVICE_NAME) - && _serviceProvider.IsServiceRunning(ServiceProvider.NZBDRONE_SERVICE_NAME)) + if (_serviceProvider.ServiceExist(ServiceProvider.SERVICE_NAME) + && _serviceProvider.IsServiceRunning(ServiceProvider.SERVICE_NAME)) { try { _logger.Info("NzbDrone Service is installed and running"); - _serviceProvider.Stop(ServiceProvider.NZBDRONE_SERVICE_NAME); + _serviceProvider.Stop(ServiceProvider.SERVICE_NAME); } catch (Exception e) { @@ -47,15 +47,15 @@ namespace NzbDrone.Update.UpdateEngine _logger.Info("Killing all running processes"); - _processProvider.KillAll(ProcessProvider.NZB_DRONE_CONSOLE_PROCESS_NAME); - _processProvider.KillAll(ProcessProvider.NZB_DRONE_PROCESS_NAME); + _processProvider.KillAll(ProcessProvider.LIDARR_CONSOLE_PROCESS_NAME); + _processProvider.KillAll(ProcessProvider.LIDARR_PROCESS_NAME); } else { _logger.Info("Killing all running processes"); - _processProvider.KillAll(ProcessProvider.NZB_DRONE_CONSOLE_PROCESS_NAME); - _processProvider.KillAll(ProcessProvider.NZB_DRONE_PROCESS_NAME); + _processProvider.KillAll(ProcessProvider.LIDARR_CONSOLE_PROCESS_NAME); + _processProvider.KillAll(ProcessProvider.LIDARR_PROCESS_NAME); _processProvider.Kill(processId); } diff --git a/src/NzbDrone.Update/app.config b/src/NzbDrone.Update/app.config index 76f6c07e9..6500a0e80 100644 --- a/src/NzbDrone.Update/app.config +++ b/src/NzbDrone.Update/app.config @@ -1,18 +1,18 @@ -<?xml version="1.0" encoding="utf-8"?> +<?xml version="1.0" encoding="utf-8"?> <configuration> <startup> - <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.0" /> + <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.6.2"/> </startup> <runtime> <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1"> <dependentAssembly> - <assemblyIdentity name="NLog" publicKeyToken="5120e14c03d0593c" culture="neutral" /> - <bindingRedirect oldVersion="0.0.0.0-2.0.1.0" newVersion="2.0.1.0" /> + <assemblyIdentity name="NLog" publicKeyToken="5120e14c03d0593c" culture="neutral"/> + <bindingRedirect oldVersion="0.0.0.0-2.0.1.0" newVersion="2.0.1.0"/> </dependentAssembly> <dependentAssembly> - <assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" /> - <bindingRedirect oldVersion="0.0.0.0-9.0.0.0" newVersion="9.0.0.0" /> + <assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral"/> + <bindingRedirect oldVersion="0.0.0.0-12.0.0.0" newVersion="12.0.0.0"/> </dependentAssembly> </assemblyBinding> </runtime> -</configuration> \ No newline at end of file +</configuration> diff --git a/src/NzbDrone.Update/packages.config b/src/NzbDrone.Update/packages.config deleted file mode 100644 index b267929ea..000000000 --- a/src/NzbDrone.Update/packages.config +++ /dev/null @@ -1,5 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<packages> - <package id="Newtonsoft.Json" version="9.0.1" targetFramework="net40" /> - <package id="NLog" version="4.4.1" targetFramework="net40" /> -</packages> \ No newline at end of file diff --git a/src/NzbDrone.Windows.Test/EnvironmentInfo/DotNetPlatformInfoFixture.cs b/src/NzbDrone.Windows.Test/EnvironmentInfo/DotNetPlatformInfoFixture.cs index 4675d5e0f..526605b97 100644 --- a/src/NzbDrone.Windows.Test/EnvironmentInfo/DotNetPlatformInfoFixture.cs +++ b/src/NzbDrone.Windows.Test/EnvironmentInfo/DotNetPlatformInfoFixture.cs @@ -13,7 +13,7 @@ namespace NzbDrone.Windows.Test.EnvironmentInfo public void should_get_framework_version() { Subject.Version.Major.Should().Be(4); - Subject.Version.Minor.Should().BeOneOf(0, 5, 6); + Subject.Version.Minor.Should().BeOneOf(0, 5, 6, 7, 8); } } } diff --git a/src/NzbDrone.Windows.Test/Lidarr.Windows.Test.csproj b/src/NzbDrone.Windows.Test/Lidarr.Windows.Test.csproj new file mode 100644 index 000000000..99384245e --- /dev/null +++ b/src/NzbDrone.Windows.Test/Lidarr.Windows.Test.csproj @@ -0,0 +1,11 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net462</TargetFramework> + <Platforms>x86</Platforms> + </PropertyGroup> + <ItemGroup> + <ProjectReference Include="..\NzbDrone.Common.Test\Lidarr.Common.Test.csproj" /> + <ProjectReference Include="..\NzbDrone.Test.Common\Lidarr.Test.Common.csproj" /> + <ProjectReference Include="..\NzbDrone.Windows\Lidarr.Windows.csproj" /> + </ItemGroup> +</Project> diff --git a/src/NzbDrone.Windows.Test/NzbDrone.Windows.Test.csproj b/src/NzbDrone.Windows.Test/NzbDrone.Windows.Test.csproj deleted file mode 100644 index e3a14160d..000000000 --- a/src/NzbDrone.Windows.Test/NzbDrone.Windows.Test.csproj +++ /dev/null @@ -1,121 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" /> - <PropertyGroup> - <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> - <Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform> - <ProjectGuid>{80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA}</ProjectGuid> - <OutputType>Library</OutputType> - <AppDesignerFolder>Properties</AppDesignerFolder> - <RootNamespace>NzbDrone.Windows.Test</RootNamespace> - <AssemblyName>NzbDrone.Windows.Test</AssemblyName> - <TargetFrameworkVersion>v4.0</TargetFrameworkVersion> - <FileAlignment>512</FileAlignment> - <SolutionDir Condition="$(SolutionDir) == '' Or $(SolutionDir) == '*Undefined*'">..\</SolutionDir> - <RestorePackages>true</RestorePackages> - </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "> - <DebugSymbols>true</DebugSymbols> - <DebugType>full</DebugType> - <Optimize>false</Optimize> - <OutputPath>bin\Debug\</OutputPath> - <DefineConstants>DEBUG;TRACE</DefineConstants> - <ErrorReport>prompt</ErrorReport> - <WarningLevel>4</WarningLevel> - </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' "> - <DebugType>pdbonly</DebugType> - <Optimize>true</Optimize> - <OutputPath>bin\Release\</OutputPath> - <DefineConstants>TRACE</DefineConstants> - <ErrorReport>prompt</ErrorReport> - <WarningLevel>4</WarningLevel> - </PropertyGroup> - <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x86'"> - <DebugSymbols>true</DebugSymbols> - <OutputPath>bin\x86\Debug\</OutputPath> - <DefineConstants>DEBUG;TRACE</DefineConstants> - <DebugType>full</DebugType> - <PlatformTarget>x86</PlatformTarget> - <ErrorReport>prompt</ErrorReport> - <CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x86'"> - <OutputPath>bin\x86\Release\</OutputPath> - <DefineConstants>TRACE</DefineConstants> - <Optimize>true</Optimize> - <DebugType>pdbonly</DebugType> - <PlatformTarget>x86</PlatformTarget> - <ErrorReport>prompt</ErrorReport> - <CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <ItemGroup> - <Reference Include="FluentAssertions, Version=4.18.0.0, Culture=neutral, PublicKeyToken=33f2691a05b67b6a, processorArchitecture=MSIL"> - <HintPath>..\packages\FluentAssertions.4.18.0\lib\net40\FluentAssertions.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="FluentAssertions.Core, Version=4.18.0.0, Culture=neutral, PublicKeyToken=33f2691a05b67b6a, processorArchitecture=MSIL"> - <HintPath>..\packages\FluentAssertions.4.18.0\lib\net40\FluentAssertions.Core.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="nunit.framework, Version=3.5.0.0, Culture=neutral, PublicKeyToken=2638cd05610744eb, processorArchitecture=MSIL"> - <HintPath>..\packages\NUnit.3.5.0\lib\net40\nunit.framework.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="System" /> - <Reference Include="System.Core" /> - <Reference Include="System.Data" /> - <Reference Include="System.Data.DataSetExtensions" /> - <Reference Include="System.Xml" /> - <Reference Include="System.Xml.Linq" /> - <Reference Include="Microsoft.CSharp" /> - </ItemGroup> - <ItemGroup> - <Compile Include="DiskProviderTests\DiskProviderFixture.cs" /> - <Compile Include="DiskProviderTests\FreeSpaceFixture.cs" /> - <Compile Include="EnvironmentInfo\WindowsVersionInfoFixture.cs" /> - <Compile Include="EnvironmentInfo\DotNetPlatformInfoFixture.cs" /> - <Compile Include="Properties\AssemblyInfo.cs" /> - </ItemGroup> - <ItemGroup> - <ProjectReference Include="..\NzbDrone.Common\NzbDrone.Common.csproj"> - <Project>{f2be0fdf-6e47-4827-a420-dd4ef82407f8}</Project> - <Name>NzbDrone.Common</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.Common.Test\NzbDrone.Common.Test.csproj"> - <Project>{bec74619-ddbb-4fba-b517-d3e20afc9997}</Project> - <Name>NzbDrone.Common.Test</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.Core\NzbDrone.Core.csproj"> - <Project>{ff5ee3b6-913b-47ce-9ceb-11c51b4e1205}</Project> - <Name>NzbDrone.Core</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.Host\NzbDrone.Host.csproj"> - <Project>{95c11a9e-56ed-456a-8447-2c89c1139266}</Project> - <Name>NzbDrone.Host</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.Test.Common\NzbDrone.Test.Common.csproj"> - <Project>{caddfce0-7509-4430-8364-2074e1eefca2}</Project> - <Name>NzbDrone.Test.Common</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.Windows\NzbDrone.Windows.csproj"> - <Project>{911284d3-f130-459e-836c-2430b6fbf21d}</Project> - <Name>NzbDrone.Windows</Name> - </ProjectReference> - </ItemGroup> - <ItemGroup> - <None Include="app.config" /> - <None Include="packages.config" /> - </ItemGroup> - <ItemGroup> - <Service Include="{82A7F48D-3B50-4B1E-B82E-3ADA8210C358}" /> - </ItemGroup> - <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> - <!-- To modify your build process, add your task inside one of the targets below and uncomment it. - Other similar extension points exist, see Microsoft.Common.targets. - <Target Name="BeforeBuild"> - </Target> - <Target Name="AfterBuild"> - </Target> - --> -</Project> \ No newline at end of file diff --git a/src/NzbDrone.Windows.Test/Properties/AssemblyInfo.cs b/src/NzbDrone.Windows.Test/Properties/AssemblyInfo.cs deleted file mode 100644 index c881ae54e..000000000 --- a/src/NzbDrone.Windows.Test/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("NzbDrone.Windows.Test")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("NzbDrone.Windows.Test")] -[assembly: AssemblyCopyright("Copyright © 2014")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("372cb8dc-5cdf-4fe4-9e1d-725827889bc7")] - -[assembly: AssemblyVersion("10.0.0.*")] diff --git a/src/NzbDrone.Windows.Test/app.config b/src/NzbDrone.Windows.Test/app.config index b6d9543c5..e294808dc 100644 --- a/src/NzbDrone.Windows.Test/app.config +++ b/src/NzbDrone.Windows.Test/app.config @@ -4,16 +4,24 @@ <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1"> <dependentAssembly> <assemblyIdentity name="Microsoft.Owin" publicKeyToken="31bf3856ad364e35" culture="neutral" /> - <bindingRedirect oldVersion="0.0.0.0-2.1.0.0" newVersion="2.1.0.0" /> + <bindingRedirect oldVersion="0.0.0.0-3.1.0.0" newVersion="3.1.0.0" /> </dependentAssembly> <dependentAssembly> <assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" /> - <bindingRedirect oldVersion="0.0.0.0-9.0.0.0" newVersion="9.0.0.0" /> + <bindingRedirect oldVersion="0.0.0.0-12.0.0.0" newVersion="12.0.0.0" /> </dependentAssembly> <dependentAssembly> <assemblyIdentity name="FluentMigrator" publicKeyToken="aacfc7de5acabf05" culture="neutral" /> <bindingRedirect oldVersion="0.0.0.0-1.3.1.0" newVersion="1.3.1.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> + <dependentAssembly> + <assemblyIdentity name="Microsoft.Owin.Security" publicKeyToken="31bf3856ad364e35" culture="neutral" /> + <bindingRedirect oldVersion="0.0.0.0-3.1.0.0" newVersion="3.1.0.0" /> + </dependentAssembly> </assemblyBinding> </runtime> -</configuration> \ No newline at end of file +<startup><supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.6.2" /></startup></configuration> diff --git a/src/NzbDrone.Windows.Test/packages.config b/src/NzbDrone.Windows.Test/packages.config deleted file mode 100644 index 839883b92..000000000 --- a/src/NzbDrone.Windows.Test/packages.config +++ /dev/null @@ -1,5 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<packages> - <package id="FluentAssertions" version="4.18.0" targetFramework="net40" /> - <package id="NUnit" version="3.5.0" targetFramework="net40" /> -</packages> \ No newline at end of file diff --git a/src/NzbDrone.Windows/Disk/DiskProvider.cs b/src/NzbDrone.Windows/Disk/DiskProvider.cs index 83ee14c50..92a2567a1 100644 --- a/src/NzbDrone.Windows/Disk/DiskProvider.cs +++ b/src/NzbDrone.Windows/Disk/DiskProvider.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.IO.Abstractions; using System.Runtime.InteropServices; using NLog; using NzbDrone.Common.Disk; @@ -23,6 +24,16 @@ namespace NzbDrone.Windows.Disk [return: MarshalAs(UnmanagedType.Bool)] static extern bool CreateHardLink(string lpFileName, string lpExistingFileName, IntPtr lpSecurityAttributes); + public DiskProvider() + : this(new FileSystem()) + { + } + + public DiskProvider(IFileSystem fileSystem) + : base(fileSystem) + { + } + public override long? GetAvailableSpace(string path) { Ensure.That(path, () => path).IsValidPath(); diff --git a/src/NzbDrone.Windows/EnvironmentInfo/DotNetPlatformInfo.cs b/src/NzbDrone.Windows/EnvironmentInfo/DotNetPlatformInfo.cs index 015b1270c..f05cba7bb 100644 --- a/src/NzbDrone.Windows/EnvironmentInfo/DotNetPlatformInfo.cs +++ b/src/NzbDrone.Windows/EnvironmentInfo/DotNetPlatformInfo.cs @@ -13,7 +13,6 @@ namespace NzbDrone.Windows.EnvironmentInfo { _logger = logger; var version = GetFrameworkVersion(); - Environment.SetEnvironmentVariable("RUNTIME_VERSION", version.ToString()); Version = version; } @@ -33,6 +32,22 @@ namespace NzbDrone.Windows.EnvironmentInfo var releaseKey = (int)ndpKey.GetValue("Release"); + if (releaseKey >= 528040) + { + return new Version(4, 8, 0); + } + if (releaseKey >= 461808) + { + return new Version(4, 7, 2); + } + if (releaseKey >= 461308) + { + return new Version(4, 7, 1); + } + if (releaseKey >= 460798) + { + return new Version(4, 7); + } if (releaseKey >= 394802) { return new Version(4, 6, 2); diff --git a/src/NzbDrone.Windows/Lidarr.Windows.csproj b/src/NzbDrone.Windows/Lidarr.Windows.csproj new file mode 100644 index 000000000..fc43c72cf --- /dev/null +++ b/src/NzbDrone.Windows/Lidarr.Windows.csproj @@ -0,0 +1,12 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net462</TargetFramework> + <Platforms>x86</Platforms> + </PropertyGroup> + <ItemGroup> + <PackageReference Include="NLog" Version="4.6.6" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\NzbDrone.Common\Lidarr.Common.csproj" /> + </ItemGroup> +</Project> diff --git a/src/NzbDrone.Windows/NzbDrone.Windows.csproj b/src/NzbDrone.Windows/NzbDrone.Windows.csproj deleted file mode 100644 index 03905568e..000000000 --- a/src/NzbDrone.Windows/NzbDrone.Windows.csproj +++ /dev/null @@ -1,89 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" /> - <PropertyGroup> - <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> - <Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform> - <ProjectGuid>{911284D3-F130-459E-836C-2430B6FBF21D}</ProjectGuid> - <OutputType>Library</OutputType> - <AppDesignerFolder>Properties</AppDesignerFolder> - <RootNamespace>NzbDrone.Windows</RootNamespace> - <AssemblyName>NzbDrone.Windows</AssemblyName> - <TargetFrameworkVersion>v4.0</TargetFrameworkVersion> - <FileAlignment>512</FileAlignment> - <SolutionDir Condition="$(SolutionDir) == '' Or $(SolutionDir) == '*Undefined*'">..\</SolutionDir> - <RestorePackages>true</RestorePackages> - </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "> - <DebugSymbols>true</DebugSymbols> - <DebugType>full</DebugType> - <Optimize>false</Optimize> - <OutputPath>..\..\_output\</OutputPath> - <DefineConstants>DEBUG;TRACE</DefineConstants> - <ErrorReport>prompt</ErrorReport> - <WarningLevel>4</WarningLevel> - </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' "> - <DebugType>pdbonly</DebugType> - <Optimize>true</Optimize> - <OutputPath>bin\Release\</OutputPath> - <DefineConstants>TRACE</DefineConstants> - <ErrorReport>prompt</ErrorReport> - <WarningLevel>4</WarningLevel> - </PropertyGroup> - <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x86'"> - <DebugSymbols>true</DebugSymbols> - <OutputPath>..\..\_output\</OutputPath> - <DefineConstants>DEBUG;TRACE</DefineConstants> - <DebugType>full</DebugType> - <PlatformTarget>x86</PlatformTarget> - <ErrorReport>prompt</ErrorReport> - <CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x86'"> - <OutputPath>..\..\_output\</OutputPath> - <DefineConstants>TRACE</DefineConstants> - <Optimize>true</Optimize> - <DebugType>pdbonly</DebugType> - <PlatformTarget>x86</PlatformTarget> - <ErrorReport>prompt</ErrorReport> - <CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <ItemGroup> - <Reference Include="NLog, Version=4.0.0.0, Culture=neutral, PublicKeyToken=5120e14c03d0593c, processorArchitecture=MSIL"> - <HintPath>..\packages\NLog.4.4.1\lib\net40\NLog.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="System" /> - <Reference Include="System.Core" /> - <Reference Include="System.Data" /> - <Reference Include="System.Data.DataSetExtensions" /> - <Reference Include="System.Xml" /> - <Reference Include="System.Xml.Linq" /> - <Reference Include="Microsoft.CSharp" /> - </ItemGroup> - <ItemGroup> - <Compile Include="Disk\DiskProvider.cs" /> - <Compile Include="EnvironmentInfo\DotNetPlatformInfo.cs" /> - <Compile Include="EnvironmentInfo\WindowsVersionInfo.cs" /> - <Compile Include="Properties\AssemblyInfo.cs" /> - </ItemGroup> - <ItemGroup> - <ProjectReference Include="..\NzbDrone.Common\NzbDrone.Common.csproj"> - <Project>{f2be0fdf-6e47-4827-a420-dd4ef82407f8}</Project> - <Name>NzbDrone.Common</Name> - </ProjectReference> - </ItemGroup> - <ItemGroup> - <None Include="app.config" /> - <None Include="packages.config" /> - </ItemGroup> - <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> - <!-- To modify your build process, add your task inside one of the targets below and uncomment it. - Other similar extension points exist, see Microsoft.Common.targets. - <Target Name="BeforeBuild"> - </Target> - <Target Name="AfterBuild"> - </Target> - --> -</Project> \ No newline at end of file diff --git a/src/NzbDrone.Windows/Properties/AssemblyInfo.cs b/src/NzbDrone.Windows/Properties/AssemblyInfo.cs deleted file mode 100644 index bbeee6014..000000000 --- a/src/NzbDrone.Windows/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("NzbDrone.Windows")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("")] -[assembly: AssemblyProduct("NzbDrone.Windows")] -[assembly: AssemblyCopyright("Copyright © 2014")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("cea28fa9-43d0-4682-99f2-d364377adbdf")] - -[assembly: AssemblyVersion("10.0.0.*")] diff --git a/src/NzbDrone.Windows/app.config b/src/NzbDrone.Windows/app.config index 8460dd432..835ae48cc 100644 --- a/src/NzbDrone.Windows/app.config +++ b/src/NzbDrone.Windows/app.config @@ -1,11 +1,11 @@ -<?xml version="1.0" encoding="utf-8"?> +<?xml version="1.0" encoding="utf-8"?> <configuration> <runtime> <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1"> <dependentAssembly> - <assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" /> - <bindingRedirect oldVersion="0.0.0.0-9.0.0.0" newVersion="9.0.0.0" /> + <assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral"/> + <bindingRedirect oldVersion="0.0.0.0-12.0.0.0" newVersion="12.0.0.0"/> </dependentAssembly> </assemblyBinding> </runtime> -</configuration> \ No newline at end of file +<startup><supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.6.2"/></startup></configuration> diff --git a/src/NzbDrone.Windows/packages.config b/src/NzbDrone.Windows/packages.config deleted file mode 100644 index be7a78cb3..000000000 --- a/src/NzbDrone.Windows/packages.config +++ /dev/null @@ -1,4 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<packages> - <package id="NLog" version="4.4.1" targetFramework="net40" /> -</packages> \ No newline at end of file diff --git a/src/NzbDrone.sln b/src/NzbDrone.sln deleted file mode 100644 index 1acdb9d16..000000000 --- a/src/NzbDrone.sln +++ /dev/null @@ -1,322 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.25420.1 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{57A04B72-8088-4F75-A582-1158CF8291F7}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Test.Common", "Test.Common", "{47697CDB-27B6-4B05-B4F8-0CBE6F6EDF97}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Test.Dummy", "NzbDrone.Test.Dummy\NzbDrone.Test.Dummy.csproj", "{FAFB5948-A222-4CF6-AD14-026BE7564802}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Test.Common", "NzbDrone.Test.Common\NzbDrone.Test.Common.csproj", "{CADDFCE0-7509-4430-8364-2074E1EEFCA2}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Core.Test", "NzbDrone.Core.Test\NzbDrone.Core.Test.csproj", "{193ADD3B-792B-4173-8E4C-5A3F8F0237F0}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Host.Test", "NzbDrone.App.Test\NzbDrone.Host.Test.csproj", "{C0EA1A40-91AD-4EEB-BD16-2DDDEBD20AE5}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Update.Test", "NzbDrone.Update.Test\NzbDrone.Update.Test.csproj", "{35388E8E-0CDB-4A84-AD16-E4B6EFDA5D97}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Common.Test", "NzbDrone.Common.Test\NzbDrone.Common.Test.csproj", "{BEC74619-DDBB-4FBA-B517-D3E20AFC9997}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Api.Test", "NzbDrone.Api.Test\NzbDrone.Api.Test.csproj", "{D18A5DEB-5102-4775-A1AF-B75DAAA8907B}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Libraries.Test", "NzbDrone.Libraries.Test\NzbDrone.Libraries.Test.csproj", "{CBF6B8B0-A015-413A-8C86-01238BB45770}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Integration.Test", "NzbDrone.Integration.Test\NzbDrone.Integration.Test.csproj", "{8CEFECD0-A6C2-498F-98B1-3FBE5820F9AB}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Automation.Test", "NzbDrone.Automation.Test\NzbDrone.Automation.Test.csproj", "{CC26800D-F67E-464B-88DE-8EB1A0C227A3}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "WindowsServiceHelpers", "WindowsServiceHelpers", "{F9E67978-5CD6-4A5F-827B-4249711C0B02}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceInstall", "ServiceHelpers\ServiceInstall\ServiceInstall.csproj", "{6BCE712F-846D-4846-9D1B-A66B858DA755}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServiceUninstall", "ServiceHelpers\ServiceUninstall\ServiceUninstall.csproj", "{700D0B95-95CD-43F3-B6C9-FAA0FC1358D4}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Core", "NzbDrone.Core\NzbDrone.Core.csproj", "{FF5EE3B6-913B-47CE-9CEB-11C51B4E1205}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Update", "NzbDrone.Update\NzbDrone.Update.csproj", "{4CCC53CD-8D5E-4CC4-97D2-5C9312AC2BD7}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Common", "NzbDrone.Common\NzbDrone.Common.csproj", "{F2BE0FDF-6E47-4827-A420-DD4EF82407F8}" - ProjectSection(ProjectDependencies) = postProject - {9DC31DE3-79FF-47A8-96B4-6BA18F6BB1CB} = {9DC31DE3-79FF-47A8-96B4-6BA18F6BB1CB} - EndProjectSection -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".nuget", ".nuget", "{1E6B3CBE-1578-41C1-9BF9-78D818740BE9}" - ProjectSection(SolutionItems) = preProject - .nuget\NuGet.exe = .nuget\NuGet.exe - EndProjectSection -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Api", "NzbDrone.Api\NzbDrone.Api.csproj", "{FD286DF8-2D3A-4394-8AD5-443FADE55FB2}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Host", "Host", "{486ADF86-DD89-4E19-B805-9D94F19800D9}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Console", "NzbDrone.Console\NzbDrone.Console.csproj", "{3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Host", "NzbDrone.Host\NzbDrone.Host.csproj", "{95C11A9E-56ED-456A-8447-2C89C1139266}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone", "NzbDrone\NzbDrone.csproj", "{D12F7F2F-8A3C-415F-88FA-6DD061A84869}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.SignalR", "NzbDrone.SignalR\NzbDrone.SignalR.csproj", "{7C2CC69F-5CA0-4E5C-85CB-983F9F6C3B36}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "External", "External", "{F6E3A728-AE77-4D02-BAC8-82FBC1402DDA}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNet.SignalR.Core", "Microsoft.AspNet.SignalR.Core\Microsoft.AspNet.SignalR.Core.csproj", "{1B9A82C4-BCA1-4834-A33E-226F17BE070B}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNet.SignalR.Owin", "Microsoft.AspNet.SignalR.Owin\Microsoft.AspNet.SignalR.Owin.csproj", "{2B8C6DAD-4D85-41B1-83FD-248D9F347522}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Marr.Data", "Marr.Data\Marr.Data.csproj", "{F6FC6BE7-0847-4817-A1ED-223DC647C3D7}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Mono", "NzbDrone.Mono\NzbDrone.Mono.csproj", "{15AD7579-A314-4626-B556-663F51D97CD1}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Windows", "NzbDrone.Windows\NzbDrone.Windows.csproj", "{911284D3-F130-459E-836C-2430B6FBF21D}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Platform", "Platform", "{0F0D4998-8F5D-4467-A909-BB192C4B3B4B}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Platform", "Platform", "{4EACDBBC-BCD7-4765-A57B-3E08331E4749}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Windows.Test", "NzbDrone.Windows.Test\NzbDrone.Windows.Test.csproj", "{80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Mono.Test", "NzbDrone.Mono.Test\NzbDrone.Mono.Test.csproj", "{40D72824-7D02-4A77-9106-8FE0EEA2B997}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MonoTorrent", "MonoTorrent\MonoTorrent.csproj", "{411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LogentriesCore", "LogentriesCore\LogentriesCore.csproj", "{90D6E9FC-7B88-4E1B-B018-8FA742274558}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LogentriesNLog", "LogentriesNLog\LogentriesNLog.csproj", "{9DC31DE3-79FF-47A8-96B4-6BA18F6BB1CB}" - ProjectSection(ProjectDependencies) = postProject - {90D6E9FC-7B88-4E1B-B018-8FA742274558} = {90D6E9FC-7B88-4E1B-B018-8FA742274558} - EndProjectSection -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CurlSharp", "ExternalModules\CurlSharp\CurlSharp\CurlSharp.csproj", "{74420A79-CC16-442C-8B1E-7C1B913844F0}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|x86 = Debug|x86 - Mono|x86 = Mono|x86 - Release|x86 = Release|x86 - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {FAFB5948-A222-4CF6-AD14-026BE7564802}.Debug|x86.ActiveCfg = Debug|x86 - {FAFB5948-A222-4CF6-AD14-026BE7564802}.Debug|x86.Build.0 = Debug|x86 - {FAFB5948-A222-4CF6-AD14-026BE7564802}.Mono|x86.ActiveCfg = Release|x86 - {FAFB5948-A222-4CF6-AD14-026BE7564802}.Mono|x86.Build.0 = Release|x86 - {FAFB5948-A222-4CF6-AD14-026BE7564802}.Release|x86.ActiveCfg = Release|x86 - {FAFB5948-A222-4CF6-AD14-026BE7564802}.Release|x86.Build.0 = Release|x86 - {CADDFCE0-7509-4430-8364-2074E1EEFCA2}.Debug|x86.ActiveCfg = Debug|x86 - {CADDFCE0-7509-4430-8364-2074E1EEFCA2}.Debug|x86.Build.0 = Debug|x86 - {CADDFCE0-7509-4430-8364-2074E1EEFCA2}.Mono|x86.ActiveCfg = Debug|x86 - {CADDFCE0-7509-4430-8364-2074E1EEFCA2}.Mono|x86.Build.0 = Debug|x86 - {CADDFCE0-7509-4430-8364-2074E1EEFCA2}.Release|x86.ActiveCfg = Release|x86 - {CADDFCE0-7509-4430-8364-2074E1EEFCA2}.Release|x86.Build.0 = Release|x86 - {193ADD3B-792B-4173-8E4C-5A3F8F0237F0}.Debug|x86.ActiveCfg = Debug|x86 - {193ADD3B-792B-4173-8E4C-5A3F8F0237F0}.Debug|x86.Build.0 = Debug|x86 - {193ADD3B-792B-4173-8E4C-5A3F8F0237F0}.Mono|x86.ActiveCfg = Debug|x86 - {193ADD3B-792B-4173-8E4C-5A3F8F0237F0}.Mono|x86.Build.0 = Debug|x86 - {193ADD3B-792B-4173-8E4C-5A3F8F0237F0}.Release|x86.ActiveCfg = Release|x86 - {193ADD3B-792B-4173-8E4C-5A3F8F0237F0}.Release|x86.Build.0 = Release|x86 - {C0EA1A40-91AD-4EEB-BD16-2DDDEBD20AE5}.Debug|x86.ActiveCfg = Debug|x86 - {C0EA1A40-91AD-4EEB-BD16-2DDDEBD20AE5}.Debug|x86.Build.0 = Debug|x86 - {C0EA1A40-91AD-4EEB-BD16-2DDDEBD20AE5}.Mono|x86.ActiveCfg = Debug|x86 - {C0EA1A40-91AD-4EEB-BD16-2DDDEBD20AE5}.Mono|x86.Build.0 = Debug|x86 - {C0EA1A40-91AD-4EEB-BD16-2DDDEBD20AE5}.Release|x86.ActiveCfg = Release|x86 - {C0EA1A40-91AD-4EEB-BD16-2DDDEBD20AE5}.Release|x86.Build.0 = Release|x86 - {35388E8E-0CDB-4A84-AD16-E4B6EFDA5D97}.Debug|x86.ActiveCfg = Debug|x86 - {35388E8E-0CDB-4A84-AD16-E4B6EFDA5D97}.Debug|x86.Build.0 = Debug|x86 - {35388E8E-0CDB-4A84-AD16-E4B6EFDA5D97}.Mono|x86.ActiveCfg = Debug|x86 - {35388E8E-0CDB-4A84-AD16-E4B6EFDA5D97}.Mono|x86.Build.0 = Debug|x86 - {35388E8E-0CDB-4A84-AD16-E4B6EFDA5D97}.Release|x86.ActiveCfg = Release|x86 - {35388E8E-0CDB-4A84-AD16-E4B6EFDA5D97}.Release|x86.Build.0 = Release|x86 - {BEC74619-DDBB-4FBA-B517-D3E20AFC9997}.Debug|x86.ActiveCfg = Debug|x86 - {BEC74619-DDBB-4FBA-B517-D3E20AFC9997}.Debug|x86.Build.0 = Debug|x86 - {BEC74619-DDBB-4FBA-B517-D3E20AFC9997}.Mono|x86.ActiveCfg = Debug|x86 - {BEC74619-DDBB-4FBA-B517-D3E20AFC9997}.Mono|x86.Build.0 = Debug|x86 - {BEC74619-DDBB-4FBA-B517-D3E20AFC9997}.Release|x86.ActiveCfg = Release|x86 - {BEC74619-DDBB-4FBA-B517-D3E20AFC9997}.Release|x86.Build.0 = Release|x86 - {D18A5DEB-5102-4775-A1AF-B75DAAA8907B}.Debug|x86.ActiveCfg = Debug|x86 - {D18A5DEB-5102-4775-A1AF-B75DAAA8907B}.Debug|x86.Build.0 = Debug|x86 - {D18A5DEB-5102-4775-A1AF-B75DAAA8907B}.Mono|x86.ActiveCfg = Release|x86 - {D18A5DEB-5102-4775-A1AF-B75DAAA8907B}.Mono|x86.Build.0 = Release|x86 - {D18A5DEB-5102-4775-A1AF-B75DAAA8907B}.Release|x86.ActiveCfg = Release|x86 - {D18A5DEB-5102-4775-A1AF-B75DAAA8907B}.Release|x86.Build.0 = Release|x86 - {CBF6B8B0-A015-413A-8C86-01238BB45770}.Debug|x86.ActiveCfg = Debug|x86 - {CBF6B8B0-A015-413A-8C86-01238BB45770}.Debug|x86.Build.0 = Debug|x86 - {CBF6B8B0-A015-413A-8C86-01238BB45770}.Mono|x86.ActiveCfg = Debug|x86 - {CBF6B8B0-A015-413A-8C86-01238BB45770}.Mono|x86.Build.0 = Debug|x86 - {CBF6B8B0-A015-413A-8C86-01238BB45770}.Release|x86.ActiveCfg = Release|x86 - {CBF6B8B0-A015-413A-8C86-01238BB45770}.Release|x86.Build.0 = Release|x86 - {8CEFECD0-A6C2-498F-98B1-3FBE5820F9AB}.Debug|x86.ActiveCfg = Debug|x86 - {8CEFECD0-A6C2-498F-98B1-3FBE5820F9AB}.Debug|x86.Build.0 = Debug|x86 - {8CEFECD0-A6C2-498F-98B1-3FBE5820F9AB}.Mono|x86.ActiveCfg = Debug|x86 - {8CEFECD0-A6C2-498F-98B1-3FBE5820F9AB}.Mono|x86.Build.0 = Debug|x86 - {8CEFECD0-A6C2-498F-98B1-3FBE5820F9AB}.Release|x86.ActiveCfg = Release|x86 - {8CEFECD0-A6C2-498F-98B1-3FBE5820F9AB}.Release|x86.Build.0 = Release|x86 - {CC26800D-F67E-464B-88DE-8EB1A0C227A3}.Debug|x86.ActiveCfg = Debug|x86 - {CC26800D-F67E-464B-88DE-8EB1A0C227A3}.Debug|x86.Build.0 = Debug|x86 - {CC26800D-F67E-464B-88DE-8EB1A0C227A3}.Mono|x86.ActiveCfg = Debug|x86 - {CC26800D-F67E-464B-88DE-8EB1A0C227A3}.Mono|x86.Build.0 = Debug|x86 - {CC26800D-F67E-464B-88DE-8EB1A0C227A3}.Release|x86.ActiveCfg = Release|x86 - {CC26800D-F67E-464B-88DE-8EB1A0C227A3}.Release|x86.Build.0 = Release|x86 - {6BCE712F-846D-4846-9D1B-A66B858DA755}.Debug|x86.ActiveCfg = Debug|x86 - {6BCE712F-846D-4846-9D1B-A66B858DA755}.Debug|x86.Build.0 = Debug|x86 - {6BCE712F-846D-4846-9D1B-A66B858DA755}.Mono|x86.ActiveCfg = Debug|x86 - {6BCE712F-846D-4846-9D1B-A66B858DA755}.Release|x86.ActiveCfg = Release|x86 - {6BCE712F-846D-4846-9D1B-A66B858DA755}.Release|x86.Build.0 = Release|x86 - {700D0B95-95CD-43F3-B6C9-FAA0FC1358D4}.Debug|x86.ActiveCfg = Debug|x86 - {700D0B95-95CD-43F3-B6C9-FAA0FC1358D4}.Debug|x86.Build.0 = Debug|x86 - {700D0B95-95CD-43F3-B6C9-FAA0FC1358D4}.Mono|x86.ActiveCfg = Debug|x86 - {700D0B95-95CD-43F3-B6C9-FAA0FC1358D4}.Release|x86.ActiveCfg = Release|x86 - {700D0B95-95CD-43F3-B6C9-FAA0FC1358D4}.Release|x86.Build.0 = Release|x86 - {FF5EE3B6-913B-47CE-9CEB-11C51B4E1205}.Debug|x86.ActiveCfg = Debug|x86 - {FF5EE3B6-913B-47CE-9CEB-11C51B4E1205}.Debug|x86.Build.0 = Debug|x86 - {FF5EE3B6-913B-47CE-9CEB-11C51B4E1205}.Mono|x86.ActiveCfg = Release|x86 - {FF5EE3B6-913B-47CE-9CEB-11C51B4E1205}.Mono|x86.Build.0 = Release|x86 - {FF5EE3B6-913B-47CE-9CEB-11C51B4E1205}.Release|x86.ActiveCfg = Release|x86 - {FF5EE3B6-913B-47CE-9CEB-11C51B4E1205}.Release|x86.Build.0 = Release|x86 - {4CCC53CD-8D5E-4CC4-97D2-5C9312AC2BD7}.Debug|x86.ActiveCfg = Debug|x86 - {4CCC53CD-8D5E-4CC4-97D2-5C9312AC2BD7}.Debug|x86.Build.0 = Debug|x86 - {4CCC53CD-8D5E-4CC4-97D2-5C9312AC2BD7}.Mono|x86.ActiveCfg = Debug|x86 - {4CCC53CD-8D5E-4CC4-97D2-5C9312AC2BD7}.Mono|x86.Build.0 = Debug|x86 - {4CCC53CD-8D5E-4CC4-97D2-5C9312AC2BD7}.Release|x86.ActiveCfg = Release|x86 - {4CCC53CD-8D5E-4CC4-97D2-5C9312AC2BD7}.Release|x86.Build.0 = Release|x86 - {F2BE0FDF-6E47-4827-A420-DD4EF82407F8}.Debug|x86.ActiveCfg = Debug|x86 - {F2BE0FDF-6E47-4827-A420-DD4EF82407F8}.Debug|x86.Build.0 = Debug|x86 - {F2BE0FDF-6E47-4827-A420-DD4EF82407F8}.Mono|x86.ActiveCfg = Release|x86 - {F2BE0FDF-6E47-4827-A420-DD4EF82407F8}.Mono|x86.Build.0 = Release|x86 - {F2BE0FDF-6E47-4827-A420-DD4EF82407F8}.Release|x86.ActiveCfg = Release|x86 - {F2BE0FDF-6E47-4827-A420-DD4EF82407F8}.Release|x86.Build.0 = Release|x86 - {FD286DF8-2D3A-4394-8AD5-443FADE55FB2}.Debug|x86.ActiveCfg = Debug|x86 - {FD286DF8-2D3A-4394-8AD5-443FADE55FB2}.Debug|x86.Build.0 = Debug|x86 - {FD286DF8-2D3A-4394-8AD5-443FADE55FB2}.Mono|x86.ActiveCfg = Release|x86 - {FD286DF8-2D3A-4394-8AD5-443FADE55FB2}.Mono|x86.Build.0 = Release|x86 - {FD286DF8-2D3A-4394-8AD5-443FADE55FB2}.Release|x86.ActiveCfg = Release|x86 - {FD286DF8-2D3A-4394-8AD5-443FADE55FB2}.Release|x86.Build.0 = Release|x86 - {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Debug|x86.ActiveCfg = Debug|x86 - {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Debug|x86.Build.0 = Debug|x86 - {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Mono|x86.ActiveCfg = Debug|x86 - {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Mono|x86.Build.0 = Debug|x86 - {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Release|x86.ActiveCfg = Release|x86 - {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Release|x86.Build.0 = Release|x86 - {95C11A9E-56ED-456A-8447-2C89C1139266}.Debug|x86.ActiveCfg = Debug|x86 - {95C11A9E-56ED-456A-8447-2C89C1139266}.Debug|x86.Build.0 = Debug|x86 - {95C11A9E-56ED-456A-8447-2C89C1139266}.Mono|x86.ActiveCfg = Debug|x86 - {95C11A9E-56ED-456A-8447-2C89C1139266}.Mono|x86.Build.0 = Debug|x86 - {95C11A9E-56ED-456A-8447-2C89C1139266}.Release|x86.ActiveCfg = Release|x86 - {95C11A9E-56ED-456A-8447-2C89C1139266}.Release|x86.Build.0 = Release|x86 - {D12F7F2F-8A3C-415F-88FA-6DD061A84869}.Debug|x86.ActiveCfg = Debug|x86 - {D12F7F2F-8A3C-415F-88FA-6DD061A84869}.Debug|x86.Build.0 = Debug|x86 - {D12F7F2F-8A3C-415F-88FA-6DD061A84869}.Mono|x86.ActiveCfg = Release|x86 - {D12F7F2F-8A3C-415F-88FA-6DD061A84869}.Release|x86.ActiveCfg = Release|x86 - {D12F7F2F-8A3C-415F-88FA-6DD061A84869}.Release|x86.Build.0 = Release|x86 - {7C2CC69F-5CA0-4E5C-85CB-983F9F6C3B36}.Debug|x86.ActiveCfg = Debug|x86 - {7C2CC69F-5CA0-4E5C-85CB-983F9F6C3B36}.Debug|x86.Build.0 = Debug|x86 - {7C2CC69F-5CA0-4E5C-85CB-983F9F6C3B36}.Mono|x86.ActiveCfg = Debug|x86 - {7C2CC69F-5CA0-4E5C-85CB-983F9F6C3B36}.Mono|x86.Build.0 = Debug|x86 - {7C2CC69F-5CA0-4E5C-85CB-983F9F6C3B36}.Release|x86.ActiveCfg = Release|x86 - {7C2CC69F-5CA0-4E5C-85CB-983F9F6C3B36}.Release|x86.Build.0 = Release|x86 - {1B9A82C4-BCA1-4834-A33E-226F17BE070B}.Debug|x86.ActiveCfg = Debug|x86 - {1B9A82C4-BCA1-4834-A33E-226F17BE070B}.Debug|x86.Build.0 = Debug|x86 - {1B9A82C4-BCA1-4834-A33E-226F17BE070B}.Mono|x86.ActiveCfg = Debug|x86 - {1B9A82C4-BCA1-4834-A33E-226F17BE070B}.Mono|x86.Build.0 = Debug|x86 - {1B9A82C4-BCA1-4834-A33E-226F17BE070B}.Release|x86.ActiveCfg = Release|x86 - {1B9A82C4-BCA1-4834-A33E-226F17BE070B}.Release|x86.Build.0 = Release|x86 - {2B8C6DAD-4D85-41B1-83FD-248D9F347522}.Debug|x86.ActiveCfg = Debug|x86 - {2B8C6DAD-4D85-41B1-83FD-248D9F347522}.Debug|x86.Build.0 = Debug|x86 - {2B8C6DAD-4D85-41B1-83FD-248D9F347522}.Mono|x86.ActiveCfg = Release|x86 - {2B8C6DAD-4D85-41B1-83FD-248D9F347522}.Mono|x86.Build.0 = Release|x86 - {2B8C6DAD-4D85-41B1-83FD-248D9F347522}.Release|x86.ActiveCfg = Release|x86 - {2B8C6DAD-4D85-41B1-83FD-248D9F347522}.Release|x86.Build.0 = Release|x86 - {F6FC6BE7-0847-4817-A1ED-223DC647C3D7}.Debug|x86.ActiveCfg = Debug|x86 - {F6FC6BE7-0847-4817-A1ED-223DC647C3D7}.Debug|x86.Build.0 = Debug|x86 - {F6FC6BE7-0847-4817-A1ED-223DC647C3D7}.Mono|x86.ActiveCfg = Release|x86 - {F6FC6BE7-0847-4817-A1ED-223DC647C3D7}.Mono|x86.Build.0 = Release|x86 - {F6FC6BE7-0847-4817-A1ED-223DC647C3D7}.Release|x86.ActiveCfg = Release|x86 - {F6FC6BE7-0847-4817-A1ED-223DC647C3D7}.Release|x86.Build.0 = Release|x86 - {15AD7579-A314-4626-B556-663F51D97CD1}.Debug|x86.ActiveCfg = Debug|x86 - {15AD7579-A314-4626-B556-663F51D97CD1}.Debug|x86.Build.0 = Debug|x86 - {15AD7579-A314-4626-B556-663F51D97CD1}.Mono|x86.ActiveCfg = Release|x86 - {15AD7579-A314-4626-B556-663F51D97CD1}.Release|x86.ActiveCfg = Release|x86 - {15AD7579-A314-4626-B556-663F51D97CD1}.Release|x86.Build.0 = Release|x86 - {911284D3-F130-459E-836C-2430B6FBF21D}.Debug|x86.ActiveCfg = Debug|x86 - {911284D3-F130-459E-836C-2430B6FBF21D}.Debug|x86.Build.0 = Debug|x86 - {911284D3-F130-459E-836C-2430B6FBF21D}.Mono|x86.ActiveCfg = Release|x86 - {911284D3-F130-459E-836C-2430B6FBF21D}.Release|x86.ActiveCfg = Release|x86 - {911284D3-F130-459E-836C-2430B6FBF21D}.Release|x86.Build.0 = Release|x86 - {80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA}.Debug|x86.ActiveCfg = Debug|x86 - {80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA}.Debug|x86.Build.0 = Debug|x86 - {80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA}.Mono|x86.ActiveCfg = Release|x86 - {80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA}.Release|x86.ActiveCfg = Release|x86 - {80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA}.Release|x86.Build.0 = Release|x86 - {40D72824-7D02-4A77-9106-8FE0EEA2B997}.Debug|x86.ActiveCfg = Debug|x86 - {40D72824-7D02-4A77-9106-8FE0EEA2B997}.Debug|x86.Build.0 = Debug|x86 - {40D72824-7D02-4A77-9106-8FE0EEA2B997}.Mono|x86.ActiveCfg = Release|x86 - {40D72824-7D02-4A77-9106-8FE0EEA2B997}.Release|x86.ActiveCfg = Release|x86 - {40D72824-7D02-4A77-9106-8FE0EEA2B997}.Release|x86.Build.0 = Release|x86 - {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Debug|x86.ActiveCfg = Debug|x86 - {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Debug|x86.Build.0 = Debug|x86 - {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Mono|x86.ActiveCfg = Release|x86 - {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Mono|x86.Build.0 = Release|x86 - {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Release|x86.ActiveCfg = Release|x86 - {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8}.Release|x86.Build.0 = Release|x86 - {90D6E9FC-7B88-4E1B-B018-8FA742274558}.Debug|x86.ActiveCfg = Debug|x86 - {90D6E9FC-7B88-4E1B-B018-8FA742274558}.Debug|x86.Build.0 = Debug|x86 - {90D6E9FC-7B88-4E1B-B018-8FA742274558}.Mono|x86.ActiveCfg = Release|x86 - {90D6E9FC-7B88-4E1B-B018-8FA742274558}.Mono|x86.Build.0 = Release|x86 - {90D6E9FC-7B88-4E1B-B018-8FA742274558}.Release|x86.ActiveCfg = Release|x86 - {90D6E9FC-7B88-4E1B-B018-8FA742274558}.Release|x86.Build.0 = Release|x86 - {9DC31DE3-79FF-47A8-96B4-6BA18F6BB1CB}.Debug|x86.ActiveCfg = Debug|x86 - {9DC31DE3-79FF-47A8-96B4-6BA18F6BB1CB}.Debug|x86.Build.0 = Debug|x86 - {9DC31DE3-79FF-47A8-96B4-6BA18F6BB1CB}.Mono|x86.ActiveCfg = Release|x86 - {9DC31DE3-79FF-47A8-96B4-6BA18F6BB1CB}.Mono|x86.Build.0 = Release|x86 - {9DC31DE3-79FF-47A8-96B4-6BA18F6BB1CB}.Release|x86.ActiveCfg = Release|x86 - {9DC31DE3-79FF-47A8-96B4-6BA18F6BB1CB}.Release|x86.Build.0 = Release|x86 - {74420A79-CC16-442C-8B1E-7C1B913844F0}.Debug|x86.ActiveCfg = Debug|Any CPU - {74420A79-CC16-442C-8B1E-7C1B913844F0}.Debug|x86.Build.0 = Debug|Any CPU - {74420A79-CC16-442C-8B1E-7C1B913844F0}.Mono|x86.ActiveCfg = Release|Any CPU - {74420A79-CC16-442C-8B1E-7C1B913844F0}.Mono|x86.Build.0 = Release|Any CPU - {74420A79-CC16-442C-8B1E-7C1B913844F0}.Release|x86.ActiveCfg = Release|Any CPU - {74420A79-CC16-442C-8B1E-7C1B913844F0}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {47697CDB-27B6-4B05-B4F8-0CBE6F6EDF97} = {57A04B72-8088-4F75-A582-1158CF8291F7} - {FAFB5948-A222-4CF6-AD14-026BE7564802} = {47697CDB-27B6-4B05-B4F8-0CBE6F6EDF97} - {CADDFCE0-7509-4430-8364-2074E1EEFCA2} = {47697CDB-27B6-4B05-B4F8-0CBE6F6EDF97} - {193ADD3B-792B-4173-8E4C-5A3F8F0237F0} = {57A04B72-8088-4F75-A582-1158CF8291F7} - {C0EA1A40-91AD-4EEB-BD16-2DDDEBD20AE5} = {57A04B72-8088-4F75-A582-1158CF8291F7} - {35388E8E-0CDB-4A84-AD16-E4B6EFDA5D97} = {57A04B72-8088-4F75-A582-1158CF8291F7} - {BEC74619-DDBB-4FBA-B517-D3E20AFC9997} = {57A04B72-8088-4F75-A582-1158CF8291F7} - {D18A5DEB-5102-4775-A1AF-B75DAAA8907B} = {57A04B72-8088-4F75-A582-1158CF8291F7} - {CBF6B8B0-A015-413A-8C86-01238BB45770} = {57A04B72-8088-4F75-A582-1158CF8291F7} - {8CEFECD0-A6C2-498F-98B1-3FBE5820F9AB} = {57A04B72-8088-4F75-A582-1158CF8291F7} - {CC26800D-F67E-464B-88DE-8EB1A0C227A3} = {57A04B72-8088-4F75-A582-1158CF8291F7} - {6BCE712F-846D-4846-9D1B-A66B858DA755} = {F9E67978-5CD6-4A5F-827B-4249711C0B02} - {700D0B95-95CD-43F3-B6C9-FAA0FC1358D4} = {F9E67978-5CD6-4A5F-827B-4249711C0B02} - {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976} = {486ADF86-DD89-4E19-B805-9D94F19800D9} - {95C11A9E-56ED-456A-8447-2C89C1139266} = {486ADF86-DD89-4E19-B805-9D94F19800D9} - {D12F7F2F-8A3C-415F-88FA-6DD061A84869} = {486ADF86-DD89-4E19-B805-9D94F19800D9} - {1B9A82C4-BCA1-4834-A33E-226F17BE070B} = {F6E3A728-AE77-4D02-BAC8-82FBC1402DDA} - {2B8C6DAD-4D85-41B1-83FD-248D9F347522} = {F6E3A728-AE77-4D02-BAC8-82FBC1402DDA} - {F6FC6BE7-0847-4817-A1ED-223DC647C3D7} = {F6E3A728-AE77-4D02-BAC8-82FBC1402DDA} - {15AD7579-A314-4626-B556-663F51D97CD1} = {0F0D4998-8F5D-4467-A909-BB192C4B3B4B} - {911284D3-F130-459E-836C-2430B6FBF21D} = {0F0D4998-8F5D-4467-A909-BB192C4B3B4B} - {4EACDBBC-BCD7-4765-A57B-3E08331E4749} = {57A04B72-8088-4F75-A582-1158CF8291F7} - {80B51429-7A0E-46D6-BEE3-C80DCB1C4EAA} = {4EACDBBC-BCD7-4765-A57B-3E08331E4749} - {40D72824-7D02-4A77-9106-8FE0EEA2B997} = {4EACDBBC-BCD7-4765-A57B-3E08331E4749} - {411A9E0E-FDC6-4E25-828A-0C2CD1CD96F8} = {F6E3A728-AE77-4D02-BAC8-82FBC1402DDA} - {90D6E9FC-7B88-4E1B-B018-8FA742274558} = {F6E3A728-AE77-4D02-BAC8-82FBC1402DDA} - {9DC31DE3-79FF-47A8-96B4-6BA18F6BB1CB} = {F6E3A728-AE77-4D02-BAC8-82FBC1402DDA} - {74420A79-CC16-442C-8B1E-7C1B913844F0} = {F6E3A728-AE77-4D02-BAC8-82FBC1402DDA} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - EnterpriseLibraryConfigurationToolBinariesPath = packages\Unity.2.1.505.0\lib\NET35;packages\Unity.2.1.505.2\lib\NET35 - EndGlobalSection - GlobalSection(MonoDevelopProperties) = preSolution - StartupItem = NzbDrone.Console\NzbDrone.Console.csproj - EndGlobalSection - GlobalSection(JSLint) = preSolution - SolutionConfigurationLocation = JSLintOptions.xml - EndGlobalSection -EndGlobal diff --git a/src/NzbDrone.sln.DotSettings b/src/NzbDrone.sln.DotSettings deleted file mode 100644 index 1d351d0ce..000000000 --- a/src/NzbDrone.sln.DotSettings +++ /dev/null @@ -1,287 +0,0 @@ -<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> - <s:Int64 x:Key="/Default/CodeEditing/Intellisense/CodeCompletion/KeywordCompletionMinLength/@EntryValue">0</s:Int64> - <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=CheckNamespace/@EntryIndexedValue">ERROR</s:String> - <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=ClassNeverInstantiated_002EGlobal/@EntryIndexedValue">DO_NOT_SHOW</s:String> - <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=ConvertIfStatementToReturnStatement/@EntryIndexedValue">DO_NOT_SHOW</s:String> - <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=ConvertNullableToShortForm/@EntryIndexedValue">DO_NOT_SHOW</s:String> - <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=FormatStringProblem/@EntryIndexedValue">ERROR</s:String> - <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=FunctionRecursiveOnAllPaths/@EntryIndexedValue">ERROR</s:String> - <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=InvokeAsExtensionMethod/@EntryIndexedValue">ERROR</s:String> - <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=LocalizableElement/@EntryIndexedValue">DO_NOT_SHOW</s:String> - <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=NUnit_002ENonPublicMethodWithTestAttribute/@EntryIndexedValue">ERROR</s:String> - <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=PossibleIntendedRethrow/@EntryIndexedValue">ERROR</s:String> - <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=ReturnTypeCanBeEnumerable_002EGlobal/@EntryIndexedValue">HINT</s:String> - <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=StringLiteralTypo/@EntryIndexedValue">WARNING</s:String> - <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=TestClassNameDoesNotMatchFileNameWarning/@EntryIndexedValue">WARNING</s:String> - <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=TestClassNameSuffixWarning/@EntryIndexedValue">WARNING</s:String> - <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=UnusedParameter_002ELocal/@EntryIndexedValue">WARNING</s:String> - <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=UseObjectOrCollectionInitializer/@EntryIndexedValue">HINT</s:String> - <s:Boolean x:Key="/Default/CodeInspection/TestFileAnalysis/SeachForOrphanedProjectFiles/@EntryValue">True</s:Boolean> - <s:String x:Key="/Default/CodeInspection/TestFileAnalysis/TestClassSuffix/@EntryValue">Fixture</s:String> - <s:String x:Key="/Default/CodeInspection/TestFileAnalysis/TestProjectToCodeProjectNameSpaceRegEx/@EntryValue">^(.*)\.Test$</s:String> - <s:String x:Key="/Default/CodeStyle/CodeCleanup/Profiles/=NzbDrone/@EntryIndexedValue"><?xml version="1.0" encoding="utf-16"?><Profile name="NzbDrone"><CSArrangeThisQualifier>True</CSArrangeThisQualifier><RemoveCodeRedundancies>True</RemoveCodeRedundancies><CSUseAutoProperty>True</CSUseAutoProperty><CSMakeFieldReadonly>True</CSMakeFieldReadonly><CSUseVar><BehavourStyle>CAN_CHANGE_TO_IMPLICIT</BehavourStyle><LocalVariableStyle>IMPLICIT_EXCEPT_SIMPLE_TYPES</LocalVariableStyle><ForeachVariableStyle>ALWAYS_IMPLICIT</ForeachVariableStyle></CSUseVar><CSUpdateFileHeader>True</CSUpdateFileHeader><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings><EmbraceInRegion>False</EmbraceInRegion><RegionName></RegionName></CSOptimizeUsings><CSShortenReferences>True</CSShortenReferences><CSReorderTypeMembers>True</CSReorderTypeMembers><XAMLCollapseEmptyTags>False</XAMLCollapseEmptyTags></Profile></s:String> - <s:String x:Key="/Default/CodeStyle/CodeCleanup/SilentCleanupProfile/@EntryValue">NzbDrone</s:String> - <s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/FORCE_CHOP_COMPOUND_WHILE_EXPRESSION/@EntryValue">True</s:Boolean> - <s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/FORCE_FIXED_BRACES_STYLE/@EntryValue">ALWAYS_ADD</s:String> - <s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/FORCE_FOR_BRACES_STYLE/@EntryValue">ALWAYS_ADD</s:String> - <s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/FORCE_FOREACH_BRACES_STYLE/@EntryValue">ALWAYS_ADD</s:String> - <s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/FORCE_IFELSE_BRACES_STYLE/@EntryValue">ALWAYS_ADD</s:String> - <s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/FORCE_USING_BRACES_STYLE/@EntryValue">ALWAYS_ADD</s:String> - <s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/FORCE_WHILE_BRACES_STYLE/@EntryValue">ALWAYS_ADD</s:String> - <s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INDENT_NESTED_FIXED_STMT/@EntryValue">True</s:Boolean> - <s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INDENT_NESTED_USINGS_STMT/@EntryValue">True</s:Boolean> - <s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/PLACE_FIELD_ATTRIBUTE_ON_SAME_LINE/@EntryValue">False</s:Boolean> - <s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/PLACE_SIMPLE_ACCESSOR_ATTRIBUTE_ON_SAME_LINE/@EntryValue">False</s:Boolean> - <s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/PLACE_SIMPLE_ACCESSOR_ON_SINGLE_LINE/@EntryValue">False</s:Boolean> - <s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/SIMPLE_EMBEDDED_STATEMENT_STYLE/@EntryValue">ON_SINGLE_LINE</s:String> - <s:Boolean x:Key="/Default/CodeStyle/CSharpUsing/AllowAlias/@EntryValue">False</s:Boolean> - <s:Boolean x:Key="/Default/CodeStyle/CSharpUsing/CanUseGlobalAlias/@EntryValue">False</s:Boolean> - <s:Boolean x:Key="/Default/CodeStyle/CSharpUsing/PreferQualifiedReference/@EntryValue">False</s:Boolean> - <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/PredefinedNamingRules/=Constants/@EntryIndexedValue"><Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /></s:String> - <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/PredefinedNamingRules/=PrivateConstants/@EntryIndexedValue"><Policy Inspect="True" Prefix="" Suffix="" Style="AA_BB" /></s:String> - <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=6658173a_002Dfe71_002D4efa_002D9d9e_002Da36d4499375e/@EntryIndexedValue"><Policy><Descriptor Staticness="Static, Instance" AccessRightKinds="Public" Description="Test Methods"><ElementKinds><Kind Name="TEST_MEMBER" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="should_" Suffix="" Style="aa_bb" /></Policy></s:String> - <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=f5dc62ff_002De860_002D4dc4_002Dacef_002Dd674121c2124/@EntryIndexedValue"><Policy><Descriptor Staticness="Static, Instance" AccessRightKinds="Public" Description="Test Fixtures"><ElementKinds><Kind Name="TEST_TYPE" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="Fixture" Style="AaBb" /></Policy></s:String> - <s:String x:Key="/Default/CodeStyle/Naming/JavaScriptNaming/UserRules/=JS_005FPARAMETER/@EntryIndexedValue"><Policy Inspect="True" Prefix="" Suffix="" Style="aaBb"><ExtraRule Prefix="" Suffix="" Style="AaBb" /></Policy></s:String> - <s:String x:Key="/Default/Environment/Editor/MatchingBraceHighlighting/Position/@EntryValue">BOTH_SIDES</s:String> - <s:String x:Key="/Default/Environment/Editor/MatchingBraceHighlighting/Style/@EntryValue">COLOR</s:String> - <s:Boolean x:Key="/Default/Environment/ExternalSources/Decompiler/ReorderMembers/@EntryValue">True</s:Boolean> - <s:String x:Key="/Default/Environment/Hierarchy/PsiConfigurationSettingsKey/LocationType/@EntryValue">SOLUTION_FOLDER</s:String> - - - - <s:Boolean x:Key="/Default/Environment/InjectedLayers/FileInjectedLayer/=5C7F3FB135E52A44B9447C48B2EEEE92/@KeyIndexDefined">True</s:Boolean> - <s:String x:Key="/Default/Environment/InjectedLayers/FileInjectedLayer/=5C7F3FB135E52A44B9447C48B2EEEE92/AbsolutePath/@EntryValue">C:\Dropbox\Git\NzbDrone\NzbDrone.sln.DotSettings</s:String> - <s:Boolean x:Key="/Default/Environment/InjectedLayers/FileInjectedLayer/=EAB6F2886783AB41B46249432F57475A/@KeyIndexDefined">True</s:Boolean> - <s:String x:Key="/Default/Environment/InjectedLayers/FileInjectedLayer/=EAB6F2886783AB41B46249432F57475A/AbsolutePath/@EntryValue">C:\Dropbox\Git\NzbDrone\src\Microsoft.AspNet.SignalR.Core\Microsoft.AspNet.SignalR.Core.csproj.DotSettings</s:String> - <s:String x:Key="/Default/Environment/InjectedLayers/FileInjectedLayer/=EAB6F2886783AB41B46249432F57475A/RelativePath/@EntryValue">..\Microsoft.AspNet.SignalR.Core\Microsoft.AspNet.SignalR.Core.csproj.DotSettings</s:String> - - - - - - <s:Boolean x:Key="/Default/Environment/InjectedLayers/InjectedLayerCustomization/=File5C7F3FB135E52A44B9447C48B2EEEE92/@KeyIndexDefined">True</s:Boolean> - <s:Boolean x:Key="/Default/Environment/InjectedLayers/InjectedLayerCustomization/=File5C7F3FB135E52A44B9447C48B2EEEE92/IsOn/@EntryValue">False</s:Boolean> - <s:Double x:Key="/Default/Environment/InjectedLayers/InjectedLayerCustomization/=File5C7F3FB135E52A44B9447C48B2EEEE92/RelativePriority/@EntryValue">1</s:Double> - <s:Boolean x:Key="/Default/Environment/InjectedLayers/InjectedLayerCustomization/=FileEAB6F2886783AB41B46249432F57475A/@KeyIndexDefined">True</s:Boolean> - <s:Boolean x:Key="/Default/Environment/InjectedLayers/InjectedLayerCustomization/=FileEAB6F2886783AB41B46249432F57475A/IsOn/@EntryValue">False</s:Boolean> - <s:Double x:Key="/Default/Environment/InjectedLayers/InjectedLayerCustomization/=FileEAB6F2886783AB41B46249432F57475A/RelativePriority/@EntryValue">3</s:Double> - - - - <s:Boolean x:Key="/Default/Environment/InjectedLayers/InjectedLayerCustomization/=File_003A_003AC_003A_005CDropbox_005CGit_005CNzbDrone_005CNzbDrone_002Esln_002EDotSettings/@KeyIndexDefined">True</s:Boolean> - <s:Double x:Key="/Default/Environment/InjectedLayers/InjectedLayerCustomization/=File_003A_003AC_003A_005CDropbox_005CGit_005CNzbDrone_005CNzbDrone_002Esln_002EDotSettings/RelativePriority/@EntryValue">2</s:Double> - <s:Boolean x:Key="/Default/Environment/MemoryUsageIndicator/IsVisible/@EntryValue">False</s:Boolean> - <s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EAddAccessorOwnerDeclarationBracesMigration/@EntryIndexedValue">True</s:Boolean> - <s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EMigrateBlankLinesAroundFieldToBlankLinesAroundProperty/@EntryIndexedValue">True</s:Boolean> - <s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EMigrateThisQualifierSettings/@EntryIndexedValue">True</s:Boolean> - <s:Boolean x:Key="/Default/Environment/TextControl/HighlightCurrentLine/@EntryValue">True</s:Boolean> - <s:Boolean x:Key="/Default/Environment/UnitTesting/DisabledProviders/=Jasmine/@EntryIndexedValue">True</s:Boolean> - <s:Boolean x:Key="/Default/Environment/UnitTesting/DisabledProviders/=MSTest/@EntryIndexedValue">True</s:Boolean> - <s:Boolean x:Key="/Default/Environment/UnitTesting/DisabledProviders/=QUnit/@EntryIndexedValue">True</s:Boolean> - <s:Int64 x:Key="/Default/Environment/UnitTesting/ParallelProcessesCount/@EntryValue">4</s:Int64> - <s:Boolean x:Key="/Default/Environment/UnitTesting/SeparateAppDomainPerAssembly/@EntryValue">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/QuickList/=26E712D4B91E2E49A0E92C0AFE6FF57E/Entry/=38860059D7978D4DAF1997C7CBC46A78/EntryName/@EntryValue">Backbone model</s:String> - <s:Int64 x:Key="/Default/PatternsAndTemplates/LiveTemplates/QuickList/=26E712D4B91E2E49A0E92C0AFE6FF57E/Entry/=38860059D7978D4DAF1997C7CBC46A78/Position/@EntryValue">5</s:Int64> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=09B531154963914B9AB9E2A05E1F2B44/@KeyIndexDefined">True</s:Boolean> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=09B531154963914B9AB9E2A05E1F2B44/Applicability/=Live/@EntryIndexedValue">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=09B531154963914B9AB9E2A05E1F2B44/Description/@EntryValue">Nunit Test</s:String> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=09B531154963914B9AB9E2A05E1F2B44/Field/=testname/@KeyIndexDefined">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=09B531154963914B9AB9E2A05E1F2B44/Field/=testname/Expression/@EntryValue">spacestounderstrokes(testname)</s:String> - <s:Int64 x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=09B531154963914B9AB9E2A05E1F2B44/Field/=testname/Order/@EntryValue">0</s:Int64> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=09B531154963914B9AB9E2A05E1F2B44/Reformat/@EntryValue">True</s:Boolean> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=09B531154963914B9AB9E2A05E1F2B44/Scope/=CE6825B6B50BCB44A4991BEC7FBA3363/@KeyIndexDefined">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=09B531154963914B9AB9E2A05E1F2B44/Scope/=CE6825B6B50BCB44A4991BEC7FBA3363/CustomProperties/=minimumLanguageVersion/@EntryIndexedValue">4.0</s:String> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=09B531154963914B9AB9E2A05E1F2B44/Scope/=CE6825B6B50BCB44A4991BEC7FBA3363/Type/@EntryValue">InCSharpQuery</s:String> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=09B531154963914B9AB9E2A05E1F2B44/Shortcut/@EntryValue">test</s:String> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=09B531154963914B9AB9E2A05E1F2B44/ShortenQualifiedReferences/@EntryValue">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=09B531154963914B9AB9E2A05E1F2B44/Text/@EntryValue">[Test] -public void $testname$() -{ - - -}</s:String> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=4BC8C4E3B166924493A3914451281686/@KeyIndexDefined">True</s:Boolean> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=4BC8C4E3B166924493A3914451281686/Applicability/=Live/@EntryIndexedValue">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=4BC8C4E3B166924493A3914451281686/Description/@EntryValue">Nunit Setup</s:String> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=4BC8C4E3B166924493A3914451281686/Reformat/@EntryValue">True</s:Boolean> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=4BC8C4E3B166924493A3914451281686/Scope/=C3001E7C0DA78E4487072B7E050D86C5/@KeyIndexDefined">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=4BC8C4E3B166924493A3914451281686/Scope/=C3001E7C0DA78E4487072B7E050D86C5/CustomProperties/=minimumLanguageVersion/@EntryIndexedValue">2.0</s:String> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=4BC8C4E3B166924493A3914451281686/Scope/=C3001E7C0DA78E4487072B7E050D86C5/Type/@EntryValue">InCSharpFile</s:String> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=4BC8C4E3B166924493A3914451281686/Shortcut/@EntryValue">Setup</s:String> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=4BC8C4E3B166924493A3914451281686/ShortenQualifiedReferences/@EntryValue">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=4BC8C4E3B166924493A3914451281686/Text/@EntryValue"> [SetUp] - public void Setup() - { - - }</s:String> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=4F6A0CA32FE60746A73308F7E39A63C1/@KeyIndexDefined">True</s:Boolean> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=4F6A0CA32FE60746A73308F7E39A63C1/Applicability/=File/@EntryIndexedValue">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=4F6A0CA32FE60746A73308F7E39A63C1/CustomProperties/=Extension/@EntryIndexedValue">js</s:String> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=4F6A0CA32FE60746A73308F7E39A63C1/CustomProperties/=FileName/@EntryIndexedValue">Model</s:String> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=4F6A0CA32FE60746A73308F7E39A63C1/CustomProperties/=ValidateFileName/@EntryIndexedValue">True</s:String> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=4F6A0CA32FE60746A73308F7E39A63C1/Description/@EntryValue">Backbone Model</s:String> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=4F6A0CA32FE60746A73308F7E39A63C1/Field/=ModelName/@KeyIndexDefined">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=4F6A0CA32FE60746A73308F7E39A63C1/Field/=ModelName/Expression/@EntryValue">getFileNameWithoutExtension()</s:String> - <s:Int64 x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=4F6A0CA32FE60746A73308F7E39A63C1/Field/=ModelName/Order/@EntryValue">0</s:Int64> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=4F6A0CA32FE60746A73308F7E39A63C1/Field/=resource/@KeyIndexDefined">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=4F6A0CA32FE60746A73308F7E39A63C1/Field/=resource/Expression/@EntryValue">getFileNameWithoutExtension()</s:String> - <s:Int64 x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=4F6A0CA32FE60746A73308F7E39A63C1/Field/=resource/InitialRange/@EntryValue">-1</s:Int64> - <s:Int64 x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=4F6A0CA32FE60746A73308F7E39A63C1/Field/=resource/Order/@EntryValue">1</s:Int64> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=4F6A0CA32FE60746A73308F7E39A63C1/Reformat/@EntryValue">True</s:Boolean> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=4F6A0CA32FE60746A73308F7E39A63C1/Scope/=0A12C11AC0ACCA4E921B6B2CEE180C57/@KeyIndexDefined">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=4F6A0CA32FE60746A73308F7E39A63C1/Scope/=0A12C11AC0ACCA4E921B6B2CEE180C57/Type/@EntryValue">InAnyWebProject</s:String> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=4F6A0CA32FE60746A73308F7E39A63C1/ShortenQualifiedReferences/@EntryValue">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=4F6A0CA32FE60746A73308F7E39A63C1/Text/@EntryValue">NzbDrone.$ModelName$Model = Backbone.Model.extend({ - -}); - - -$ModelName$Collection = Backbone.Collection.extend({ - - model: NzbDrone.$ModelName$Model, - url: NzbDrone.Constants.ApiRoot + '/$resource$', - -}); -</s:String> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=5FF6ECA7884F0F45BAB165819AF9DA75/@KeyIndexDefined">True</s:Boolean> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=5FF6ECA7884F0F45BAB165819AF9DA75/Applicability/=Live/@EntryIndexedValue">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=5FF6ECA7884F0F45BAB165819AF9DA75/Description/@EntryValue">Create a new Method</s:String> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=5FF6ECA7884F0F45BAB165819AF9DA75/Field/=name/@KeyIndexDefined">True</s:Boolean> - <s:Int64 x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=5FF6ECA7884F0F45BAB165819AF9DA75/Field/=name/Order/@EntryValue">0</s:Int64> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=5FF6ECA7884F0F45BAB165819AF9DA75/Reformat/@EntryValue">True</s:Boolean> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=5FF6ECA7884F0F45BAB165819AF9DA75/Scope/=FFA15E6CFCBE90499C572A859225B012/@KeyIndexDefined">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=5FF6ECA7884F0F45BAB165819AF9DA75/Scope/=FFA15E6CFCBE90499C572A859225B012/Type/@EntryValue">InJavaScriptFile</s:String> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=5FF6ECA7884F0F45BAB165819AF9DA75/Shortcut/@EntryValue">func</s:String> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=5FF6ECA7884F0F45BAB165819AF9DA75/ShortenQualifiedReferences/@EntryValue">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=5FF6ECA7884F0F45BAB165819AF9DA75/Text/@EntryValue"> $name$: function () { - - },</s:String> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=686B0D0C738CD1449F9389FEB5A34944/@KeyIndexDefined">True</s:Boolean> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=686B0D0C738CD1449F9389FEB5A34944/Applicability/=Live/@EntryIndexedValue">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=686B0D0C738CD1449F9389FEB5A34944/Description/@EntryValue">Backbone Model</s:String> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=686B0D0C738CD1449F9389FEB5A34944/Field/=ModelName/@KeyIndexDefined">True</s:Boolean> - <s:Int64 x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=686B0D0C738CD1449F9389FEB5A34944/Field/=ModelName/Order/@EntryValue">0</s:Int64> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=686B0D0C738CD1449F9389FEB5A34944/Reformat/@EntryValue">True</s:Boolean> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=686B0D0C738CD1449F9389FEB5A34944/Scope/=FFA15E6CFCBE90499C572A859225B012/@KeyIndexDefined">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=686B0D0C738CD1449F9389FEB5A34944/Scope/=FFA15E6CFCBE90499C572A859225B012/Type/@EntryValue">InJavaScriptFile</s:String> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=686B0D0C738CD1449F9389FEB5A34944/Shortcut/@EntryValue">model</s:String> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=686B0D0C738CD1449F9389FEB5A34944/ShortenQualifiedReferences/@EntryValue">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=686B0D0C738CD1449F9389FEB5A34944/Text/@EntryValue">$ModelName$ = Backbone.M</s:String> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8EC91D4AC875274D9804299C81802FB3/@KeyIndexDefined">True</s:Boolean> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8EC91D4AC875274D9804299C81802FB3/Applicability/=Live/@EntryIndexedValue">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8EC91D4AC875274D9804299C81802FB3/Description/@EntryValue">Subscribe to event</s:String> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8EC91D4AC875274D9804299C81802FB3/Field/=Event/@KeyIndexDefined">True</s:Boolean> - <s:Int64 x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8EC91D4AC875274D9804299C81802FB3/Field/=Event/Order/@EntryValue">1</s:Int64> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8EC91D4AC875274D9804299C81802FB3/Field/=Handler/@KeyIndexDefined">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8EC91D4AC875274D9804299C81802FB3/Field/=Handler/Expression/@EntryValue">typeMember()</s:String> - <s:Int64 x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8EC91D4AC875274D9804299C81802FB3/Field/=Handler/Order/@EntryValue">2</s:Int64> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8EC91D4AC875274D9804299C81802FB3/Field/=Target/@KeyIndexDefined">True</s:Boolean> - <s:Int64 x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8EC91D4AC875274D9804299C81802FB3/Field/=Target/Order/@EntryValue">0</s:Int64> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8EC91D4AC875274D9804299C81802FB3/Reformat/@EntryValue">True</s:Boolean> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8EC91D4AC875274D9804299C81802FB3/Scope/=FFA15E6CFCBE90499C572A859225B012/@KeyIndexDefined">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8EC91D4AC875274D9804299C81802FB3/Scope/=FFA15E6CFCBE90499C572A859225B012/Type/@EntryValue">InJavaScriptFile</s:String> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8EC91D4AC875274D9804299C81802FB3/Shortcut/@EntryValue">vent</s:String> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8EC91D4AC875274D9804299C81802FB3/ShortenQualifiedReferences/@EntryValue">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=8EC91D4AC875274D9804299C81802FB3/Text/@EntryValue">NzbDrone.vent.listenTo($Target$, '$Event$', this.$Handler$, this); -</s:String> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=CB2236C947CEAB4B90BDEB514C88F7B9/@KeyIndexDefined">True</s:Boolean> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=CB2236C947CEAB4B90BDEB514C88F7B9/Applicability/=Live/@EntryIndexedValue">True</s:Boolean> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=CB2236C947CEAB4B90BDEB514C88F7B9/Field/=event/@KeyIndexDefined">True</s:Boolean> - <s:Int64 x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=CB2236C947CEAB4B90BDEB514C88F7B9/Field/=event/Order/@EntryValue">0</s:Int64> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=CB2236C947CEAB4B90BDEB514C88F7B9/Field/=handler/@KeyIndexDefined">True</s:Boolean> - <s:Int64 x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=CB2236C947CEAB4B90BDEB514C88F7B9/Field/=handler/Order/@EntryValue">2</s:Int64> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=CB2236C947CEAB4B90BDEB514C88F7B9/Field/=selector/@KeyIndexDefined">True</s:Boolean> - <s:Int64 x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=CB2236C947CEAB4B90BDEB514C88F7B9/Field/=selector/Order/@EntryValue">1</s:Int64> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=CB2236C947CEAB4B90BDEB514C88F7B9/Reformat/@EntryValue">True</s:Boolean> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=CB2236C947CEAB4B90BDEB514C88F7B9/Scope/=FFA15E6CFCBE90499C572A859225B012/@KeyIndexDefined">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=CB2236C947CEAB4B90BDEB514C88F7B9/Scope/=FFA15E6CFCBE90499C572A859225B012/Type/@EntryValue">InJavaScriptFile</s:String> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=CB2236C947CEAB4B90BDEB514C88F7B9/Shortcut/@EntryValue">events</s:String> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=CB2236C947CEAB4B90BDEB514C88F7B9/ShortenQualifiedReferences/@EntryValue">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=CB2236C947CEAB4B90BDEB514C88F7B9/Text/@EntryValue"> events: { - '$event$ .x-$selector$': '$handler$' - },</s:String> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=D02CEEFCB5BA1E4C8660DD8D7D09D183/@KeyIndexDefined">True</s:Boolean> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=D02CEEFCB5BA1E4C8660DD8D7D09D183/Applicability/=Live/@EntryIndexedValue">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=D02CEEFCB5BA1E4C8660DD8D7D09D183/Description/@EntryValue">Add Initialize Method</s:String> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=D02CEEFCB5BA1E4C8660DD8D7D09D183/Reformat/@EntryValue">True</s:Boolean> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=D02CEEFCB5BA1E4C8660DD8D7D09D183/Scope/=FFA15E6CFCBE90499C572A859225B012/@KeyIndexDefined">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=D02CEEFCB5BA1E4C8660DD8D7D09D183/Scope/=FFA15E6CFCBE90499C572A859225B012/Type/@EntryValue">InJavaScriptFile</s:String> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=D02CEEFCB5BA1E4C8660DD8D7D09D183/Shortcut/@EntryValue">init</s:String> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=D02CEEFCB5BA1E4C8660DD8D7D09D183/ShortenQualifiedReferences/@EntryValue">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=D02CEEFCB5BA1E4C8660DD8D7D09D183/Text/@EntryValue"> initialize: function () { - - },</s:String> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=DFB2B8E186019749B105D91A01D4D269/@KeyIndexDefined">True</s:Boolean> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=DFB2B8E186019749B105D91A01D4D269/Applicability/=File/@EntryIndexedValue">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=DFB2B8E186019749B105D91A01D4D269/CustomProperties/=Extension/@EntryIndexedValue">cs</s:String> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=DFB2B8E186019749B105D91A01D4D269/CustomProperties/=FileName/@EntryIndexedValue">$ServiceName$Fixture</s:String> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=DFB2B8E186019749B105D91A01D4D269/CustomProperties/=ValidateFileName/@EntryIndexedValue">True</s:String> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=DFB2B8E186019749B105D91A01D4D269/Description/@EntryValue">TestFixture</s:String> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=DFB2B8E186019749B105D91A01D4D269/Field/=ServiceName/@KeyIndexDefined">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=DFB2B8E186019749B105D91A01D4D269/Field/=ServiceName/Expression/@EntryValue">completeType()</s:String> - <s:Int64 x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=DFB2B8E186019749B105D91A01D4D269/Field/=ServiceName/Order/@EntryValue">0</s:Int64> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=DFB2B8E186019749B105D91A01D4D269/Field/=TestBase/@KeyIndexDefined">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=DFB2B8E186019749B105D91A01D4D269/Field/=TestBase/Expression/@EntryValue">list("CoreTest, ObjectDbTest")</s:String> - <s:Int64 x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=DFB2B8E186019749B105D91A01D4D269/Field/=TestBase/Order/@EntryValue">1</s:Int64> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=DFB2B8E186019749B105D91A01D4D269/Reformat/@EntryValue">True</s:Boolean> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=DFB2B8E186019749B105D91A01D4D269/Scope/=E8F0594528C33E45BBFEC6CFE851095D/@KeyIndexDefined">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=DFB2B8E186019749B105D91A01D4D269/Scope/=E8F0594528C33E45BBFEC6CFE851095D/Type/@EntryValue">InCSharpProjectFile</s:String> - <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=DFB2B8E186019749B105D91A01D4D269/ShortenQualifiedReferences/@EntryValue">True</s:Boolean> - <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=DFB2B8E186019749B105D91A01D4D269/Text/@EntryValue">using System; -using System.Collections.Generic; -using System.Linq; -using FizzWare.NBuilder; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Datastore; -using NzbDrone.Core.Repository; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.Datastore -{ - [TestFixture] - public class $ServiceName$Fixture: $TestBase$<$ServiceName$> - { - - } -}</s:String> - - - - - - - - - - - </wpf:ResourceDictionary> \ No newline at end of file diff --git a/src/NzbDrone/Lidarr.csproj b/src/NzbDrone/Lidarr.csproj new file mode 100644 index 000000000..878ea4d85 --- /dev/null +++ b/src/NzbDrone/Lidarr.csproj @@ -0,0 +1,29 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <OutputType>WinExe</OutputType> + <TargetFramework>net462</TargetFramework> + <Platforms>x86</Platforms> + + <ApplicationIcon>..\NzbDrone.Host\NzbDrone.ico</ApplicationIcon> + <ApplicationManifest>app.manifest</ApplicationManifest> + </PropertyGroup> + <ItemGroup> + <ProjectReference Include="..\NzbDrone.Host\Lidarr.Host.csproj" /> + </ItemGroup> + <ItemGroup> + <Reference Include="System.Windows.Forms" /> + </ItemGroup> + <ItemGroup> + <Compile Update="Properties\Resources.Designer.cs"> + <AutoGen>True</AutoGen> + <DesignTime>True</DesignTime> + <DependentUpon>Resources.resx</DependentUpon> + </Compile> + </ItemGroup> + <ItemGroup> + <EmbeddedResource Update="Properties\Resources.resx"> + <Generator>ResXFileCodeGenerator</Generator> + <LastGenOutput>Resources.Designer.cs</LastGenOutput> + </EmbeddedResource> + </ItemGroup> +</Project> diff --git a/src/NzbDrone/NzbDrone.csproj b/src/NzbDrone/NzbDrone.csproj deleted file mode 100644 index 48160fd7b..000000000 --- a/src/NzbDrone/NzbDrone.csproj +++ /dev/null @@ -1,179 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <PropertyGroup> - <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> - <Platform Condition=" '$(Platform)' == '' ">x86</Platform> - <ProductVersion>8.0.30703</ProductVersion> - <SchemaVersion>2.0</SchemaVersion> - <ProjectGuid>{D12F7F2F-8A3C-415F-88FA-6DD061A84869}</ProjectGuid> - <OutputType>WinExe</OutputType> - <AppDesignerFolder>Properties</AppDesignerFolder> - <RootNamespace>NzbDrone</RootNamespace> - <AssemblyName>NzbDrone</AssemblyName> - <TargetFrameworkVersion>v4.0</TargetFrameworkVersion> - <FileAlignment>512</FileAlignment> - <TargetFrameworkProfile> - </TargetFrameworkProfile> - <IsWebBootstrapper>false</IsWebBootstrapper> - <SolutionDir Condition="$(SolutionDir) == '' Or $(SolutionDir) == '*Undefined*'">..\</SolutionDir> - <RestorePackages>true</RestorePackages> - <PublishUrl>publish\</PublishUrl> - <Install>true</Install> - <InstallFrom>Disk</InstallFrom> - <UpdateEnabled>false</UpdateEnabled> - <UpdateMode>Foreground</UpdateMode> - <UpdateInterval>7</UpdateInterval> - <UpdateIntervalUnits>Days</UpdateIntervalUnits> - <UpdatePeriodically>false</UpdatePeriodically> - <UpdateRequired>false</UpdateRequired> - <MapFileExtensions>true</MapFileExtensions> - <ApplicationRevision>0</ApplicationRevision> - <ApplicationVersion>1.0.0.%2a</ApplicationVersion> - <UseApplicationTrust>false</UseApplicationTrust> - <BootstrapperEnabled>true</BootstrapperEnabled> - </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|x86' "> - <PlatformTarget>x86</PlatformTarget> - <DebugSymbols>true</DebugSymbols> - <DebugType>full</DebugType> - <Optimize>false</Optimize> - <OutputPath>..\..\_output\</OutputPath> - <DefineConstants>DEBUG;TRACE</DefineConstants> - <ErrorReport>prompt</ErrorReport> - <WarningLevel>4</WarningLevel> - <UseVSHostingProcess>true</UseVSHostingProcess> - <CodeAnalysisRuleSet>BasicCorrectnessRules.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|x86' "> - <PlatformTarget>x86</PlatformTarget> - <DebugType>pdbonly</DebugType> - <Optimize>true</Optimize> - <OutputPath>..\..\_output\</OutputPath> - <DefineConstants>TRACE</DefineConstants> - <ErrorReport>prompt</ErrorReport> - <WarningLevel>4</WarningLevel> - </PropertyGroup> - <PropertyGroup> - <ApplicationIcon>..\NzbDrone.Host\NzbDrone.ico</ApplicationIcon> - </PropertyGroup> - <PropertyGroup> - <StartupObject>NzbDrone.WindowsApp</StartupObject> - </PropertyGroup> - <PropertyGroup> - <RunPostBuildEvent>OnOutputUpdated</RunPostBuildEvent> - </PropertyGroup> - <PropertyGroup> - <ApplicationManifest>app.manifest</ApplicationManifest> - </PropertyGroup> - <ItemGroup> - <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> - </Reference> - <Reference Include="Microsoft.Owin.Hosting, Version=1.1.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35, processorArchitecture=MSIL"> - <SpecificVersion>False</SpecificVersion> - <HintPath>..\packages\Microsoft.Owin.Hosting.2.1.0\lib\net40\Microsoft.Owin.Hosting.dll</HintPath> - </Reference> - <Reference Include="Newtonsoft.Json, Version=9.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL"> - <HintPath>..\packages\Newtonsoft.Json.9.0.1\lib\net40\Newtonsoft.Json.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="NLog, Version=4.0.0.0, Culture=neutral, PublicKeyToken=5120e14c03d0593c, processorArchitecture=MSIL"> - <HintPath>..\packages\NLog.4.4.1\lib\net40\NLog.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="System" /> - <Reference Include="System.Core" /> - <Reference Include="System.Drawing" /> - <Reference Include="System.Windows.Forms" /> - <Reference Include="Owin"> - <HintPath>..\packages\Owin.1.0\lib\net40\Owin.dll</HintPath> - </Reference> - </ItemGroup> - <ItemGroup> - <Compile Include="..\NzbDrone.Common\Properties\SharedAssemblyInfo.cs"> - <Link>Properties\SharedAssemblyInfo.cs</Link> - </Compile> - <Compile Include="MessageBoxUserAlert.cs" /> - <Compile Include="Properties\AssemblyInfo.cs" /> - <Compile Include="Properties\Resources.Designer.cs"> - <AutoGen>True</AutoGen> - <DesignTime>True</DesignTime> - <DependentUpon>Resources.resx</DependentUpon> - </Compile> - <Compile Include="SysTray\SysTrayApp.cs"> - <SubType>Form</SubType> - </Compile> - <Compile Include="WindowsApp.cs" /> - </ItemGroup> - <ItemGroup> - <BootstrapperPackage Include=".NETFramework,Version=v4.0"> - <Visible>False</Visible> - <ProductName>Microsoft .NET Framework 4 %28x86 and x64%29</ProductName> - <Install>true</Install> - </BootstrapperPackage> - <BootstrapperPackage Include="Microsoft.Net.Client.3.5"> - <Visible>False</Visible> - <ProductName>.NET Framework 3.5 SP1 Client Profile</ProductName> - <Install>false</Install> - </BootstrapperPackage> - <BootstrapperPackage Include="Microsoft.Net.Framework.3.5.SP1"> - <Visible>False</Visible> - <ProductName>.NET Framework 3.5 SP1</ProductName> - <Install>false</Install> - </BootstrapperPackage> - <BootstrapperPackage Include="Microsoft.Windows.Installer.3.1"> - <Visible>False</Visible> - <ProductName>Windows Installer 3.1</ProductName> - <Install>true</Install> - </BootstrapperPackage> - </ItemGroup> - <ItemGroup> - <ProjectReference Include="..\Microsoft.AspNet.SignalR.Core\Microsoft.AspNet.SignalR.Core.csproj"> - <Project>{1B9A82C4-BCA1-4834-A33E-226F17BE070B}</Project> - <Name>Microsoft.AspNet.SignalR.Core</Name> - </ProjectReference> - <ProjectReference Include="..\Microsoft.AspNet.SignalR.Owin\Microsoft.AspNet.SignalR.Owin.csproj"> - <Project>{2B8C6DAD-4D85-41B1-83FD-248D9F347522}</Project> - <Name>Microsoft.AspNet.SignalR.Owin</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.Common\NzbDrone.Common.csproj"> - <Project>{F2BE0FDF-6E47-4827-A420-DD4EF82407F8}</Project> - <Name>NzbDrone.Common</Name> - </ProjectReference> - <ProjectReference Include="..\NzbDrone.Host\NzbDrone.Host.csproj"> - <Project>{95C11A9E-56ED-456A-8447-2C89C1139266}</Project> - <Name>NzbDrone.Host</Name> - </ProjectReference> - </ItemGroup> - <ItemGroup> - <EmbeddedResource Include="Properties\Resources.resx"> - <Generator>ResXFileCodeGenerator</Generator> - <LastGenOutput>Resources.Designer.cs</LastGenOutput> - </EmbeddedResource> - </ItemGroup> - <ItemGroup> - <None Include="..\NzbDrone.Host\app.config"> - <Link>app.config</Link> - </None> - <None Include="app.manifest" /> - <None Include="packages.config" /> - </ItemGroup> - <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> - <PropertyGroup> - <PreBuildEvent> - </PreBuildEvent> - </PropertyGroup> - <PropertyGroup> - <PostBuildEvent Condition="('$(OS)' == 'Windows_NT')"> - xcopy /s /y "$(SolutionDir)\Libraries\Sqlite\*.*" "$(TargetDir)" - </PostBuildEvent> - </PropertyGroup> - <!-- To modify your build process, add your task inside one of the targets below and uncomment it. - Other similar extension points exist, see Microsoft.Common.targets. - <Target Name="BeforeBuild"> - </Target> - <Target Name="AfterBuild"> - </Target> - --> -</Project> \ No newline at end of file diff --git a/src/NzbDrone/Properties/AssemblyInfo.cs b/src/NzbDrone/Properties/AssemblyInfo.cs deleted file mode 100644 index c1bca6872..000000000 --- a/src/NzbDrone/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. - -[assembly: AssemblyTitle("NzbDrone.exe")] -[assembly: Guid("67AADCD9-89AA-4D95-8281-3193740E70E5")] - -[assembly: AssemblyVersion("10.0.0.*")] diff --git a/src/NzbDrone/Properties/Resources.Designer.cs b/src/NzbDrone/Properties/Resources.Designer.cs index 65584111d..5fa1ed9fe 100644 --- a/src/NzbDrone/Properties/Resources.Designer.cs +++ b/src/NzbDrone/Properties/Resources.Designer.cs @@ -1,7 +1,7 @@ //------------------------------------------------------------------------------ // <auto-generated> // This code was generated by a tool. -// Runtime Version:4.0.30319.32559 +// Runtime Version:4.0.30319.42000 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. @@ -9,6 +9,9 @@ //------------------------------------------------------------------------------ namespace NzbDrone.Properties { + using System; + + /// <summary> /// A strongly-typed resource class, for looking up localized strings, etc. /// </summary> @@ -16,7 +19,7 @@ namespace NzbDrone.Properties { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] internal class Resources { diff --git a/src/NzbDrone/SysTray/SysTrayApp.cs b/src/NzbDrone/SysTray/SysTrayApp.cs index 6325593e1..70af0b0ab 100644 --- a/src/NzbDrone/SysTray/SysTrayApp.cs +++ b/src/NzbDrone/SysTray/SysTrayApp.cs @@ -38,7 +38,7 @@ namespace NzbDrone.SysTray _trayMenu.MenuItems.Add("-"); _trayMenu.MenuItems.Add("Exit", OnExit); - _trayIcon.Text = string.Format("Sonarr - {0}", BuildInfo.Version); + _trayIcon.Text = string.Format("Lidarr - {0}", BuildInfo.Version); _trayIcon.Icon = Properties.Resources.NzbDroneIcon; _trayIcon.ContextMenu = _trayMenu; diff --git a/src/NzbDrone/app.manifest b/src/NzbDrone/app.manifest index 523cc7301..8e6eb2fea 100644 --- a/src/NzbDrone/app.manifest +++ b/src/NzbDrone/app.manifest @@ -43,4 +43,10 @@ </application> </compatibility> + + <application xmlns="urn:schemas-microsoft-com:asm.v3"> + <windowsSettings> + <longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware> + </windowsSettings> + </application> </assembly> diff --git a/src/NzbDrone/packages.config b/src/NzbDrone/packages.config deleted file mode 100644 index 0c7772d2c..000000000 --- a/src/NzbDrone/packages.config +++ /dev/null @@ -1,8 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<packages> - <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.1" targetFramework="net40" /> - <package id="Owin" version="1.0" targetFramework="net40" /> -</packages> \ No newline at end of file diff --git a/src/ServiceHelpers/ServiceInstall/Properties/AssemblyInfo.cs b/src/ServiceHelpers/ServiceInstall/Properties/AssemblyInfo.cs deleted file mode 100644 index 63a2e4bc0..000000000 --- a/src/ServiceHelpers/ServiceInstall/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -[assembly: AssemblyTitle("InstallService")] - - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("13976baa-e5ba-42b2-8ad7-8d568b68a53b")] - -[assembly: AssemblyVersion("10.0.0.*")] diff --git a/src/ServiceHelpers/ServiceInstall/ServiceHelper.cs b/src/ServiceHelpers/ServiceInstall/ServiceHelper.cs index 78e881170..265b86c43 100644 --- a/src/ServiceHelpers/ServiceInstall/ServiceHelper.cs +++ b/src/ServiceHelpers/ServiceInstall/ServiceHelper.cs @@ -8,7 +8,7 @@ namespace ServiceInstall { public static class ServiceHelper { - private static string NzbDroneExe => Path.Combine(new FileInfo(Assembly.GetExecutingAssembly().Location).Directory.FullName, "NzbDrone.Console.exe"); + private static string LidarrExe => Path.Combine(new FileInfo(Assembly.GetExecutingAssembly().Location).Directory.FullName, "Lidarr.Console.exe"); private static bool IsAnAdministrator() { @@ -18,9 +18,9 @@ namespace ServiceInstall public static void Run(string arg) { - if (!File.Exists(NzbDroneExe)) + if (!File.Exists(LidarrExe)) { - Console.WriteLine("Unable to find NzbDrone.Console.exe in the current directory."); + Console.WriteLine("Unable to find Lidarr.Console.exe in the current directory."); return; } @@ -32,7 +32,7 @@ namespace ServiceInstall var startInfo = new ProcessStartInfo { - FileName = NzbDroneExe, + FileName = LidarrExe, Arguments = arg, UseShellExecute = false, RedirectStandardOutput = true, @@ -50,13 +50,11 @@ namespace ServiceInstall process.BeginOutputReadLine(); process.WaitForExit(); - } private static void OnDataReceived(object sender, DataReceivedEventArgs e) { Console.WriteLine(e.Data); } - } -} \ No newline at end of file +} diff --git a/src/ServiceHelpers/ServiceInstall/ServiceInstall.csproj b/src/ServiceHelpers/ServiceInstall/ServiceInstall.csproj index 5334a144e..2da82570e 100644 --- a/src/ServiceHelpers/ServiceInstall/ServiceInstall.csproj +++ b/src/ServiceHelpers/ServiceInstall/ServiceInstall.csproj @@ -1,80 +1,7 @@ -<?xml version="1.0" encoding="utf-8"?> -<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> +<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> - <Platform Condition=" '$(Platform)' == '' ">x86</Platform> - <ProductVersion>8.0.30703</ProductVersion> - <SchemaVersion>2.0</SchemaVersion> - <ProjectGuid>{6BCE712F-846D-4846-9D1B-A66B858DA755}</ProjectGuid> <OutputType>Exe</OutputType> - <AppDesignerFolder>Properties</AppDesignerFolder> - <RootNamespace>ServiceInstall</RootNamespace> - <AssemblyName>ServiceInstall</AssemblyName> - <TargetFrameworkVersion>v4.0</TargetFrameworkVersion> - <TargetFrameworkProfile> - </TargetFrameworkProfile> - <FileAlignment>512</FileAlignment> - <SolutionDir Condition="$(SolutionDir) == '' Or $(SolutionDir) == '*Undefined*'">..\..\</SolutionDir> - <RestorePackages>true</RestorePackages> + <TargetFramework>net462</TargetFramework> + <Platforms>x86</Platforms> </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|x86' "> - <PlatformTarget>x86</PlatformTarget> - <DebugSymbols>true</DebugSymbols> - <DebugType>full</DebugType> - <Optimize>false</Optimize> - <OutputPath>..\..\..\_output\</OutputPath> - <DefineConstants>DEBUG;TRACE</DefineConstants> - <ErrorReport>prompt</ErrorReport> - <WarningLevel>4</WarningLevel> - </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|x86' "> - <PlatformTarget>x86</PlatformTarget> - <DebugType>pdbonly</DebugType> - <Optimize>true</Optimize> - <OutputPath>..\..\..\_output\</OutputPath> - <DefineConstants>TRACE</DefineConstants> - <ErrorReport>prompt</ErrorReport> - <WarningLevel>4</WarningLevel> - </PropertyGroup> - <PropertyGroup> - <ApplicationManifest>app.manifest</ApplicationManifest> - </PropertyGroup> - <PropertyGroup> - <RunPostBuildEvent>OnBuildSuccess</RunPostBuildEvent> - </PropertyGroup> - <PropertyGroup> - <StartupObject>ServiceInstall.Program</StartupObject> - </PropertyGroup> - <PropertyGroup> - <ApplicationIcon>green_puzzle.ico</ApplicationIcon> - </PropertyGroup> - <ItemGroup> - <Reference Include="System" /> - <Reference Include="System.Core" /> - </ItemGroup> - <ItemGroup> - <Compile Include="..\..\NzbDrone.Common\Properties\SharedAssemblyInfo.cs"> - <Link>Properties\SharedAssemblyInfo.cs</Link> - </Compile> - <Compile Include="Program.cs" /> - <Compile Include="Properties\AssemblyInfo.cs" /> - <Compile Include="ServiceHelper.cs" /> - </ItemGroup> - <ItemGroup> - <None Include="app.config" /> - <None Include="app.manifest"> - <SubType>Designer</SubType> - </None> - </ItemGroup> - <ItemGroup> - <Content Include="green_puzzle.ico" /> - </ItemGroup> - <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> - <!-- To modify your build process, add your task inside one of the targets below and uncomment it. - Other similar extension points exist, see Microsoft.Common.targets. - <Target Name="BeforeBuild"> - </Target> - <Target Name="AfterBuild"> - </Target> - --> </Project> diff --git a/src/ServiceHelpers/ServiceInstall/app.config b/src/ServiceHelpers/ServiceInstall/app.config index e33d6f761..d0244c521 100644 --- a/src/ServiceHelpers/ServiceInstall/app.config +++ b/src/ServiceHelpers/ServiceInstall/app.config @@ -1,6 +1,6 @@ <?xml version="1.0"?> <configuration> <startup useLegacyV2RuntimeActivationPolicy="true"> - <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.0"/> + <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.6.2"/> </startup> </configuration> diff --git a/src/ServiceHelpers/ServiceUninstall/Properties/AssemblyInfo.cs b/src/ServiceHelpers/ServiceUninstall/Properties/AssemblyInfo.cs deleted file mode 100644 index c5e087a13..000000000 --- a/src/ServiceHelpers/ServiceUninstall/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,7 +0,0 @@ -using System.Reflection; -using System.Runtime.InteropServices; - -[assembly: AssemblyTitle("UninstallService")] -[assembly: Guid("0a964b21-9de9-40b3-9378-0474fd5f21a8")] - -[assembly: AssemblyVersion("10.0.0.*")] diff --git a/src/ServiceHelpers/ServiceUninstall/ServiceHelper.cs b/src/ServiceHelpers/ServiceUninstall/ServiceHelper.cs index e5fedb19e..9ad0944ee 100644 --- a/src/ServiceHelpers/ServiceUninstall/ServiceHelper.cs +++ b/src/ServiceHelpers/ServiceUninstall/ServiceHelper.cs @@ -8,7 +8,7 @@ namespace ServiceUninstall { public static class ServiceHelper { - private static string NzbDroneExe => Path.Combine(new FileInfo(Assembly.GetExecutingAssembly().Location).Directory.FullName, "NzbDrone.Console.exe"); + private static string LidarrExe => Path.Combine(new FileInfo(Assembly.GetExecutingAssembly().Location).Directory.FullName, "Lidarr.Console.exe"); private static bool IsAnAdministrator() { @@ -18,9 +18,9 @@ namespace ServiceUninstall public static void Run(string arg) { - if (!File.Exists(NzbDroneExe)) + if (!File.Exists(LidarrExe)) { - Console.WriteLine("Unable to find NzbDrone.exe in the current directory."); + Console.WriteLine("Unable to find Lidarr.exe in the current directory."); return; } @@ -32,7 +32,7 @@ namespace ServiceUninstall var startInfo = new ProcessStartInfo { - FileName = NzbDroneExe, + FileName = LidarrExe, Arguments = arg, UseShellExecute = false, RedirectStandardOutput = true, @@ -50,13 +50,11 @@ namespace ServiceUninstall process.BeginOutputReadLine(); process.WaitForExit(); - } private static void OnDataReceived(object sender, DataReceivedEventArgs e) { Console.WriteLine(e.Data); } - } -} \ No newline at end of file +} diff --git a/src/ServiceHelpers/ServiceUninstall/ServiceUninstall.csproj b/src/ServiceHelpers/ServiceUninstall/ServiceUninstall.csproj index 5905c1dbd..2da82570e 100644 --- a/src/ServiceHelpers/ServiceUninstall/ServiceUninstall.csproj +++ b/src/ServiceHelpers/ServiceUninstall/ServiceUninstall.csproj @@ -1,80 +1,7 @@ -<?xml version="1.0" encoding="utf-8"?> -<Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> +<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> - <Platform Condition=" '$(Platform)' == '' ">x86</Platform> - <ProductVersion>8.0.30703</ProductVersion> - <SchemaVersion>2.0</SchemaVersion> - <ProjectGuid>{700D0B95-95CD-43F3-B6C9-FAA0FC1358D4}</ProjectGuid> <OutputType>Exe</OutputType> - <AppDesignerFolder>Properties</AppDesignerFolder> - <RootNamespace>ServiceUninstall</RootNamespace> - <AssemblyName>ServiceUninstall</AssemblyName> - <TargetFrameworkVersion>v4.0</TargetFrameworkVersion> - <TargetFrameworkProfile> - </TargetFrameworkProfile> - <FileAlignment>512</FileAlignment> - <SolutionDir Condition="$(SolutionDir) == '' Or $(SolutionDir) == '*Undefined*'">..\..\</SolutionDir> - <RestorePackages>true</RestorePackages> + <TargetFramework>net462</TargetFramework> + <Platforms>x86</Platforms> </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|x86' "> - <PlatformTarget>x86</PlatformTarget> - <DebugSymbols>true</DebugSymbols> - <DebugType>full</DebugType> - <Optimize>false</Optimize> - <OutputPath>..\..\..\_output\</OutputPath> - <DefineConstants>DEBUG;TRACE</DefineConstants> - <ErrorReport>prompt</ErrorReport> - <WarningLevel>4</WarningLevel> - </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|x86' "> - <PlatformTarget>x86</PlatformTarget> - <DebugType>pdbonly</DebugType> - <Optimize>true</Optimize> - <OutputPath>..\..\..\_output\</OutputPath> - <DefineConstants>TRACE</DefineConstants> - <ErrorReport>prompt</ErrorReport> - <WarningLevel>4</WarningLevel> - </PropertyGroup> - <PropertyGroup> - <StartupObject>ServiceUninstall.Program</StartupObject> - </PropertyGroup> - <PropertyGroup> - <ApplicationIcon>red_puzzle.ico</ApplicationIcon> - </PropertyGroup> - <PropertyGroup> - <ApplicationManifest>app.manifest</ApplicationManifest> - </PropertyGroup> - <PropertyGroup> - <RunPostBuildEvent>OnBuildSuccess</RunPostBuildEvent> - </PropertyGroup> - <ItemGroup> - <Reference Include="System" /> - <Reference Include="System.Core" /> - </ItemGroup> - <ItemGroup> - <Compile Include="..\..\NzbDrone.Common\Properties\SharedAssemblyInfo.cs"> - <Link>Properties\SharedAssemblyInfo.cs</Link> - </Compile> - <Compile Include="Program.cs" /> - <Compile Include="Properties\AssemblyInfo.cs" /> - <Compile Include="ServiceHelper.cs" /> - </ItemGroup> - <ItemGroup> - <None Include="app.config" /> - <None Include="app.manifest"> - <SubType>Designer</SubType> - </None> - </ItemGroup> - <ItemGroup> - <Content Include="red_puzzle.ico" /> - </ItemGroup> - <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> - <!-- To modify your build process, add your task inside one of the targets below and uncomment it. - Other similar extension points exist, see Microsoft.Common.targets. - <Target Name="BeforeBuild"> - </Target> - <Target Name="AfterBuild"> - </Target> - --> </Project> diff --git a/src/ServiceHelpers/ServiceUninstall/app.config b/src/ServiceHelpers/ServiceUninstall/app.config index e33d6f761..d0244c521 100644 --- a/src/ServiceHelpers/ServiceUninstall/app.config +++ b/src/ServiceHelpers/ServiceUninstall/app.config @@ -1,6 +1,6 @@ <?xml version="1.0"?> <configuration> <startup useLegacyV2RuntimeActivationPolicy="true"> - <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.0"/> + <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.6.2"/> </startup> </configuration> diff --git a/src/UI/.idea/.name b/src/UI/.idea/.name deleted file mode 100644 index 78ec2c0fe..000000000 --- a/src/UI/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -NzbDrone.UI \ No newline at end of file diff --git a/src/UI/.idea/NzbDrone.UI.iml b/src/UI/.idea/NzbDrone.UI.iml deleted file mode 100644 index 2184ad470..000000000 --- a/src/UI/.idea/NzbDrone.UI.iml +++ /dev/null @@ -1,11 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<module type="WEB_MODULE" version="4"> - <component name="NewModuleRootManager"> - <content url="file://$MODULE_DIR$" /> - <orderEntry type="inheritedJdk" /> - <orderEntry type="sourceFolder" forTests="false" /> - <orderEntry type="library" name="jQuery-1.9.1" level="application" /> - <orderEntry type="library" name="backbone.backgrid.filter.js" level="project" /> - </component> -</module> - diff --git a/src/UI/.idea/codeStyleSettings.xml b/src/UI/.idea/codeStyleSettings.xml deleted file mode 100644 index 7598f4c8e..000000000 --- a/src/UI/.idea/codeStyleSettings.xml +++ /dev/null @@ -1,59 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<project version="4"> - <component name="ProjectCodeStyleSettingsManager"> - <option name="PER_PROJECT_SETTINGS"> - <value> - <option name="LINE_SEPARATOR" value=" " /> - <option name="RIGHT_MARGIN" value="190" /> - <option name="HTML_ATTRIBUTE_WRAP" value="0" /> - <option name="HTML_KEEP_LINE_BREAKS" value="false" /> - <option name="HTML_KEEP_BLANK_LINES" value="1" /> - <option name="HTML_ALIGN_ATTRIBUTES" value="false" /> - <option name="HTML_INLINE_ELEMENTS" value="" /> - <option name="HTML_DONT_ADD_BREAKS_IF_INLINE_CONTENT" value="" /> - <CssCodeStyleSettings> - <option name="HEX_COLOR_LOWER_CASE" value="true" /> - <option name="HEX_COLOR_LONG_FORMAT" value="true" /> - <option name="VALUE_ALIGNMENT" value="1" /> - </CssCodeStyleSettings> - <JSCodeStyleSettings> - <option name="SPACE_BEFORE_PROPERTY_COLON" value="true" /> - <option name="ALIGN_OBJECT_PROPERTIES" value="2" /> - <option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" /> - <option name="OBJECT_LITERAL_WRAP" value="2" /> - <option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" /> - </JSCodeStyleSettings> - <XML> - <option name="XML_LEGACY_SETTINGS_IMPORTED" value="true" /> - </XML> - <codeStyleSettings language="CSS"> - <indentOptions> - <option name="SMART_TABS" value="true" /> - </indentOptions> - </codeStyleSettings> - <codeStyleSettings language="JavaScript"> - <option name="LINE_COMMENT_AT_FIRST_COLUMN" value="true" /> - <option name="KEEP_LINE_BREAKS" value="false" /> - <option name="KEEP_FIRST_COLUMN_COMMENT" value="false" /> - <option name="KEEP_BLANK_LINES_IN_CODE" value="1" /> - <option name="CATCH_ON_NEW_LINE" value="true" /> - <option name="FINALLY_ON_NEW_LINE" value="true" /> - <option name="ALIGN_MULTILINE_PARAMETERS" value="false" /> - <option name="ALIGN_MULTILINE_BINARY_OPERATION" value="true" /> - <option name="SPACE_BEFORE_METHOD_PARENTHESES" value="true" /> - <option name="CALL_PARAMETERS_WRAP" value="1" /> - <option name="BINARY_OPERATION_WRAP" value="1" /> - <option name="TERNARY_OPERATION_SIGNS_ON_NEXT_LINE" value="true" /> - <option name="ARRAY_INITIALIZER_WRAP" value="2" /> - <option name="ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE" value="true" /> - <option name="ARRAY_INITIALIZER_RBRACE_ON_NEXT_LINE" value="true" /> - <option name="IF_BRACE_FORCE" value="3" /> - <option name="DOWHILE_BRACE_FORCE" value="3" /> - <option name="WHILE_BRACE_FORCE" value="3" /> - <option name="FOR_BRACE_FORCE" value="3" /> - </codeStyleSettings> - </value> - </option> - <option name="USE_PER_PROJECT_SETTINGS" value="true" /> - </component> -</project> \ No newline at end of file diff --git a/src/UI/.idea/dictionaries/Keivan.xml b/src/UI/.idea/dictionaries/Keivan.xml deleted file mode 100644 index e85a39c0f..000000000 --- a/src/UI/.idea/dictionaries/Keivan.xml +++ /dev/null @@ -1,20 +0,0 @@ -<component name="ProjectDictionaryState"> - <dictionary name="Keivan"> - <words> - <w>deps</w> - <w>mixins</w> - <w>nzbdrone</w> - <w>rootdir</w> - <w>rootfolder</w> - <w>rootfolders</w> - <w>signalr</w> - <w>sonarr</w> - <w>templated</w> - <w>thetvdb</w> - <w>trakt</w> - <w>tvdb</w> - <w>xlarge</w> - <w>yyyy</w> - </words> - </dictionary> -</component> \ No newline at end of file diff --git a/src/UI/.idea/dictionaries/Keivan_Beigi.xml b/src/UI/.idea/dictionaries/Keivan_Beigi.xml deleted file mode 100644 index 00d8e4cec..000000000 --- a/src/UI/.idea/dictionaries/Keivan_Beigi.xml +++ /dev/null @@ -1,13 +0,0 @@ -<component name="ProjectDictionaryState"> - <dictionary name="Keivan.Beigi"> - <words> - <w>backgrid</w> - <w>bnzbd</w> - <w>clickable</w> - <w>couldn</w> - <w>mouseenter</w> - <w>mouseleave</w> - <w>navbar</w> - </words> - </dictionary> -</component> \ No newline at end of file diff --git a/src/UI/.idea/dictionaries/Mark.xml b/src/UI/.idea/dictionaries/Mark.xml deleted file mode 100644 index ecbbe884c..000000000 --- a/src/UI/.idea/dictionaries/Mark.xml +++ /dev/null @@ -1,3 +0,0 @@ -<component name="ProjectDictionaryState"> - <dictionary name="Mark" /> -</component> \ No newline at end of file diff --git a/src/UI/.idea/encodings.xml b/src/UI/.idea/encodings.xml deleted file mode 100644 index e55d06786..000000000 --- a/src/UI/.idea/encodings.xml +++ /dev/null @@ -1,7 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<project version="4"> - <component name="Encoding" useUTFGuessing="true" native2AsciiForPropertiesFiles="false"> - <file url="file://$PROJECT_DIR$/System/Logs/Files/LogFileModel.js" charset="UTF-8" /> - <file url="PROJECT" charset="UTF-8" /> - </component> -</project> \ No newline at end of file diff --git a/src/UI/.idea/inspectionProfiles/Project_Default.xml b/src/UI/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 7aba4e3c2..000000000 --- a/src/UI/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,117 +0,0 @@ -<component name="InspectionProjectProfileManager"> - <profile version="1.0" is_locked="false"> - <option name="myName" value="Project Default" /> - <option name="myLocal" value="false" /> - <inspection_tool class="AssignmentResultUsedJS" enabled="true" level="WARNING" enabled_by_default="true" /> - <inspection_tool class="AssignmentToForLoopParameterJS" enabled="true" level="ERROR" enabled_by_default="true" /> - <inspection_tool class="AssignmentToFunctionParameterJS" enabled="false" level="WEAK WARNING" enabled_by_default="false" /> - <inspection_tool class="BadExpressionStatementJS" enabled="true" level="ERROR" enabled_by_default="true" /> - <inspection_tool class="BreakStatementJS" enabled="true" level="SERVER PROBLEM" enabled_by_default="true" /> - <inspection_tool class="BreakStatementWithLabelJS" enabled="true" level="ERROR" enabled_by_default="true" /> - <inspection_tool class="ChainedEqualityJS" enabled="true" level="WARNING" enabled_by_default="true" /> - <inspection_tool class="CheckEmptyScriptTag" enabled="false" level="WARNING" enabled_by_default="false" /> - <inspection_tool class="ConditionalExpressionWithIdenticalBranchesJS" enabled="true" level="ERROR" enabled_by_default="true" /> - <inspection_tool class="ConstantIfStatementJS" enabled="true" level="ERROR" enabled_by_default="true" /> - <inspection_tool class="ConstantOnLHSOfComparisonJS" enabled="true" level="ERROR" enabled_by_default="true" /> - <inspection_tool class="ContinueStatementWithLabelJS" enabled="true" level="ERROR" enabled_by_default="true" /> - <inspection_tool class="CssMissingSemicolonInspection" enabled="true" level="WARNING" enabled_by_default="true" /> - <inspection_tool class="CyclomaticComplexityJS" enabled="true" level="WARNING" enabled_by_default="true"> - <option name="m_limit" value="10" /> - </inspection_tool> - <inspection_tool class="DefaultNotLastCaseInSwitchJS" enabled="true" level="ERROR" enabled_by_default="true" /> - <inspection_tool class="DuplicateCaseLabelJS" enabled="true" level="ERROR" enabled_by_default="true" /> - <inspection_tool class="DuplicateConditionJS" enabled="true" level="WARNING" enabled_by_default="true" /> - <inspection_tool class="DynamicallyGeneratedCodeJS" enabled="true" level="WARNING" enabled_by_default="true" /> - <inspection_tool class="EmptyTryBlockJS" enabled="true" level="ERROR" enabled_by_default="true" /> - <inspection_tool class="ExceptionCaughtLocallyJS" enabled="true" level="ERROR" enabled_by_default="true" /> - <inspection_tool class="ForLoopReplaceableByWhileJS" enabled="true" level="INFO" enabled_by_default="true"> - <option name="m_ignoreLoopsWithoutConditions" value="false" /> - </inspection_tool> - <inspection_tool class="FunctionNamingConventionJS" enabled="true" level="WARNING" enabled_by_default="true"> - <option name="m_regex" value="[a-z][A-Za-z]*" /> - <option name="m_minLength" value="4" /> - <option name="m_maxLength" value="32" /> - </inspection_tool> - <inspection_tool class="FunctionWithInconsistentReturnsJS" enabled="true" level="ERROR" enabled_by_default="true" /> - <inspection_tool class="HtmlFormInputWithoutLabel" enabled="false" level="WEAK WARNING" enabled_by_default="false" /> - <inspection_tool class="HtmlPresentationalElement" enabled="false" level="WEAK WARNING" enabled_by_default="false" /> - <inspection_tool class="HtmlUnknownAttribute" enabled="true" level="WARNING" enabled_by_default="true"> - <option name="myValues"> - <value> - <list size="2"> - <item index="0" class="java.lang.String" itemvalue="name" /> - <item index="1" class="java.lang.String" itemvalue="validation-name" /> - </list> - </value> - </option> - <option name="myCustomValuesEnabled" value="true" /> - </inspection_tool> - <inspection_tool class="HtmlUnknownTag" enabled="true" level="WARNING" enabled_by_default="true"> - <option name="myValues"> - <value> - <list size="8"> - <item index="0" class="java.lang.String" itemvalue="nobr" /> - <item index="1" class="java.lang.String" itemvalue="noembed" /> - <item index="2" class="java.lang.String" itemvalue="comment" /> - <item index="3" class="java.lang.String" itemvalue="noscript" /> - <item index="4" class="java.lang.String" itemvalue="embed" /> - <item index="5" class="java.lang.String" itemvalue="script" /> - <item index="6" class="java.lang.String" itemvalue="icon" /> - <item index="7" class="java.lang.String" itemvalue="p" /> - </list> - </value> - </option> - <option name="myCustomValuesEnabled" value="true" /> - </inspection_tool> - <inspection_tool class="IfStatementWithIdenticalBranchesJS" enabled="true" level="ERROR" enabled_by_default="true" /> - <inspection_tool class="IfStatementWithTooManyBranchesJS" enabled="true" level="ERROR" enabled_by_default="true"> - <option name="m_limit" value="3" /> - </inspection_tool> - <inspection_tool class="JSDuplicatedDeclaration" enabled="true" level="ERROR" enabled_by_default="true" /> - <inspection_tool class="JSHint" enabled="true" level="ERROR" enabled_by_default="true" /> - <inspection_tool class="JSLastCommaInArrayLiteral" enabled="true" level="ERROR" enabled_by_default="true" /> - <inspection_tool class="JSPotentiallyInvalidUsageOfThis" enabled="true" level="SERVER PROBLEM" enabled_by_default="true" /> - <inspection_tool class="JSUndeclaredVariable" enabled="true" level="ERROR" enabled_by_default="true" /> - <inspection_tool class="JSUnnecessarySemicolon" enabled="true" level="ERROR" enabled_by_default="true" /> - <inspection_tool class="JSUnresolvedFunction" enabled="true" level="WARNING" enabled_by_default="true" /> - <inspection_tool class="JSUnresolvedVariable" enabled="true" level="WARNING" enabled_by_default="true" /> - <inspection_tool class="JSUnusedGlobalSymbols" enabled="true" level="WARNING" enabled_by_default="true"> - <option name="myReportUnusedDefinitions" value="true" /> - <option name="myReportUnusedProperties" value="true" /> - </inspection_tool> - <inspection_tool class="LabeledStatementJS" enabled="true" level="ERROR" enabled_by_default="true" /> - <inspection_tool class="LocalVariableNamingConventionJS" enabled="true" level="WARNING" enabled_by_default="true"> - <option name="m_regex" value="[a-z][A-Za-z]*" /> - <option name="m_minLength" value="1" /> - <option name="m_maxLength" value="32" /> - </inspection_tool> - <inspection_tool class="NegatedIfStatementJS" enabled="true" level="WARNING" enabled_by_default="true" /> - <inspection_tool class="NestedAssignmentJS" enabled="true" level="WARNING" enabled_by_default="true" /> - <inspection_tool class="NestedFunctionCallJS" enabled="false" level="ERROR" enabled_by_default="false" /> - <inspection_tool class="NestedSwitchStatementJS" enabled="true" level="WARNING" enabled_by_default="true" /> - <inspection_tool class="NestingDepthJS" enabled="true" level="WARNING" enabled_by_default="true"> - <option name="m_limit" value="5" /> - </inspection_tool> - <inspection_tool class="NonBlockStatementBodyJS" enabled="true" level="ERROR" enabled_by_default="true" /> - <inspection_tool class="ParameterNamingConventionJS" enabled="true" level="WARNING" enabled_by_default="true"> - <option name="m_regex" value="[a-z][A-Za-z]*" /> - <option name="m_minLength" value="1" /> - <option name="m_maxLength" value="32" /> - </inspection_tool> - <inspection_tool class="ParametersPerFunctionJS" enabled="true" level="WARNING" enabled_by_default="true"> - <option name="m_limit" value="4" /> - </inspection_tool> - <inspection_tool class="ReservedWordUsedAsNameJS" enabled="true" level="ERROR" enabled_by_default="true" /> - <inspection_tool class="ReuseOfLocalVariableJS" enabled="true" level="WARNING" enabled_by_default="true" /> - <inspection_tool class="StatementsPerFunctionJS" enabled="true" level="WARNING" enabled_by_default="true"> - <option name="m_limit" value="30" /> - </inspection_tool> - <inspection_tool class="SwitchStatementWithNoDefaultBranchJS" enabled="true" level="WARNING" enabled_by_default="true" /> - <inspection_tool class="TailRecursionJS" enabled="true" level="WARNING" enabled_by_default="true" /> - <inspection_tool class="ThisExpressionReferencesGlobalObjectJS" enabled="true" level="ERROR" enabled_by_default="true" /> - <inspection_tool class="ThreeNegationsPerFunctionJS" enabled="true" level="WARNING" enabled_by_default="true" /> - <inspection_tool class="UnterminatedStatementJS" enabled="true" level="ERROR" enabled_by_default="true"> - <option name="ignoreSemicolonAtEndOfBlock" value="true" /> - </inspection_tool> - </profile> -</component> \ No newline at end of file diff --git a/src/UI/.idea/inspectionProfiles/profiles_settings.xml b/src/UI/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 3b312839b..000000000 --- a/src/UI/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,7 +0,0 @@ -<component name="InspectionProjectProfileManager"> - <settings> - <option name="PROJECT_PROFILE" value="Project Default" /> - <option name="USE_PROJECT_PROFILE" value="true" /> - <version value="1.0" /> - </settings> -</component> \ No newline at end of file diff --git a/src/UI/.idea/jsLibraryMappings.xml b/src/UI/.idea/jsLibraryMappings.xml deleted file mode 100644 index 62c621f94..000000000 --- a/src/UI/.idea/jsLibraryMappings.xml +++ /dev/null @@ -1,8 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<project version="4"> - <component name="JavaScriptLibraryMappings"> - <excludedPredefinedLibrary name="HTML" /> - <excludedPredefinedLibrary name="HTML5 / EcmaScript 5" /> - </component> -</project> - diff --git a/src/UI/.idea/jsLinters/jshint.xml b/src/UI/.idea/jsLinters/jshint.xml deleted file mode 100644 index 0b5c0e41e..000000000 --- a/src/UI/.idea/jsLinters/jshint.xml +++ /dev/null @@ -1,72 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<project version="4"> - <component name="JSHintConfiguration" version="2.6.0" use-config-file="false"> - <option asi="false" /> - <option bitwise="true" /> - <option boss="false" /> - <option browser="true" /> - <option camelcase="true" /> - <option couch="false" /> - <option curly="true" /> - <option debug="false" /> - <option devel="true" /> - <option dojo="false" /> - <option eqeqeq="true" /> - <option eqnull="false" /> - <option es3="false" /> - <option esnext="false" /> - <option evil="false" /> - <option expr="false" /> - <option forin="true" /> - <option freeze="false" /> - <option funcscope="false" /> - <option gcl="false" /> - <option globalstrict="true" /> - <option immed="true" /> - <option iterator="false" /> - <option jquery="false" /> - <option lastsemic="false" /> - <option latedef="true" /> - <option laxbreak="false" /> - <option laxcomma="false" /> - <option loopfunc="false" /> - <option maxdepth="3" /> - <option maxerr="50" /> - <option mootools="false" /> - <option moz="false" /> - <option multistr="false" /> - <option newcap="true" /> - <option noarg="true" /> - <option node="true" /> - <option noempty="false" /> - <option nomen="false" /> - <option nonbsp="false" /> - <option nonew="true" /> - <option nonstandard="false" /> - <option notypeof="false" /> - <option noyield="false" /> - <option onevar="false" /> - <option passfail="false" /> - <option phantom="false" /> - <option plusplus="false" /> - <option predef="window, define, require, module" /> - <option proto="false" /> - <option prototypejs="false" /> - <option quotmark="single" /> - <option rhino="false" /> - <option scripturl="false" /> - <option shadow="false" /> - <option smarttabs="false" /> - <option strict="false" /> - <option sub="false" /> - <option supernew="false" /> - <option trailing="false" /> - <option undef="true" /> - <option unused="true" /> - <option validthis="false" /> - <option white="false" /> - <option worker="false" /> - <option wsh="false" /> - <option yui="false" /> - </component> -</project> \ No newline at end of file diff --git a/src/UI/.idea/jsLinters/jslint.xml b/src/UI/.idea/jsLinters/jslint.xml deleted file mode 100644 index 822a7aa5e..000000000 --- a/src/UI/.idea/jsLinters/jslint.xml +++ /dev/null @@ -1,13 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<project version="4"> - <component name="JSLintConfiguration" html="true" json="true"> - <option browser="true" /> - <option devel="true" /> - <option indent="4" /> - <option maxerr="50" /> - <option plusplus="true" /> - <option todo="true" /> - <option white="true" /> - </component> -</project> - diff --git a/src/UI/.idea/misc.xml b/src/UI/.idea/misc.xml deleted file mode 100644 index e9e9ba1c3..000000000 --- a/src/UI/.idea/misc.xml +++ /dev/null @@ -1,6 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<project version="4"> - <component name="ProjectKey"> - <option name="state" value="git@github.com:NzbDrone/NzbDrone.git" /> - </component> -</project> \ No newline at end of file diff --git a/src/UI/.idea/modules.xml b/src/UI/.idea/modules.xml deleted file mode 100644 index ab774833e..000000000 --- a/src/UI/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<project version="4"> - <component name="ProjectModuleManager"> - <modules> - <module fileurl="file://$PROJECT_DIR$/.idea/NzbDrone.UI.iml" filepath="$PROJECT_DIR$/.idea/NzbDrone.UI.iml" /> - </modules> - </component> -</project> \ No newline at end of file diff --git a/src/UI/.idea/runConfigurations/Debug___Chrome.xml b/src/UI/.idea/runConfigurations/Debug___Chrome.xml deleted file mode 100644 index 47bd06dc9..000000000 --- a/src/UI/.idea/runConfigurations/Debug___Chrome.xml +++ /dev/null @@ -1,23 +0,0 @@ -<component name="ProjectRunConfigurationManager"> - <configuration default="false" name="Debug - Chrome" type="JavascriptDebugType" factoryName="JavaScript Debug" singleton="true" uri="http://localhost:8989"> - <mapping url="http://localhost:8989/Calendar" local-file="$PROJECT_DIR$/Calendar" /> - <mapping url="http://localhost:8989/MainMenuView.js" local-file="$PROJECT_DIR$/MainMenuView.js" /> - <mapping url="http://localhost:8989/Settings" local-file="$PROJECT_DIR$/Settings" /> - <mapping url="http://localhost:8989/Upcoming" local-file="$PROJECT_DIR$/Upcoming" /> - <mapping url="http://localhost:8989/app.js" local-file="$PROJECT_DIR$/app.js" /> - <mapping url="http://localhost:8989/Mixins" local-file="$PROJECT_DIR$/Mixins" /> - <mapping url="http://localhost:8989/Wanted" local-file="$PROJECT_DIR$/Wanted" /> - <mapping url="http://localhost:8989/Quality" local-file="$PROJECT_DIR$/Quality" /> - <mapping url="http://localhost:8989/Config.js" local-file="$PROJECT_DIR$/Config.js" /> - <mapping url="http://localhost:8989/Shared" local-file="$PROJECT_DIR$/Shared" /> - <mapping url="http://localhost:8989/AddSeries" local-file="$PROJECT_DIR$/AddSeries" /> - <mapping url="http://localhost:8989/HeaderView.js" local-file="$PROJECT_DIR$/HeaderView.js" /> - <mapping url="http://localhost:8989" local-file="$PROJECT_DIR$" /> - <mapping url="http://localhost:8989/Routing.js" local-file="$PROJECT_DIR$/Routing.js" /> - <mapping url="http://localhost:8989/Controller.js" local-file="$PROJECT_DIR$/Controller.js" /> - <mapping url="http://localhost:8989/Series" local-file="$PROJECT_DIR$/Series" /> - <RunnerSettings RunnerId="JavascriptDebugRunner" /> - <ConfigurationWrapper RunnerId="JavascriptDebugRunner" /> - <method /> - </configuration> -</component> \ No newline at end of file diff --git a/src/UI/.idea/runConfigurations/Debug___Firefox.xml b/src/UI/.idea/runConfigurations/Debug___Firefox.xml deleted file mode 100644 index d9e99acc3..000000000 --- a/src/UI/.idea/runConfigurations/Debug___Firefox.xml +++ /dev/null @@ -1,23 +0,0 @@ -<component name="ProjectRunConfigurationManager"> - <configuration default="false" name="Debug - Firefox" type="JavascriptDebugType" factoryName="JavaScript Debug" singleton="true" engineId="firefox" uri="http://localhost:8989"> - <mapping url="http://localhost:8989/Calendar" local-file="$PROJECT_DIR$/Calendar" /> - <mapping url="http://localhost:8989/MainMenuView.js" local-file="$PROJECT_DIR$/MainMenuView.js" /> - <mapping url="http://localhost:8989/Settings" local-file="$PROJECT_DIR$/Settings" /> - <mapping url="http://localhost:8989/Upcoming" local-file="$PROJECT_DIR$/Upcoming" /> - <mapping url="http://localhost:8989/app.js" local-file="$PROJECT_DIR$/app.js" /> - <mapping url="http://localhost:8989/Mixins" local-file="$PROJECT_DIR$/Mixins" /> - <mapping url="http://localhost:8989/Wanted" local-file="$PROJECT_DIR$/Wanted" /> - <mapping url="http://localhost:8989/Config.js" local-file="$PROJECT_DIR$/Config.js" /> - <mapping url="http://localhost:8989/Quality" local-file="$PROJECT_DIR$/Quality" /> - <mapping url="http://localhost:8989/AddSeries" local-file="$PROJECT_DIR$/AddSeries" /> - <mapping url="http://localhost:8989/Shared" local-file="$PROJECT_DIR$/Shared" /> - <mapping url="http://localhost:8989/HeaderView.js" local-file="$PROJECT_DIR$/HeaderView.js" /> - <mapping url="http://localhost:8989" local-file="$PROJECT_DIR$" /> - <mapping url="http://localhost:8989/Routing.js" local-file="$PROJECT_DIR$/Routing.js" /> - <mapping url="http://localhost:8989/Controller.js" local-file="$PROJECT_DIR$/Controller.js" /> - <mapping url="http://localhost:8989/Series" local-file="$PROJECT_DIR$/Series" /> - <RunnerSettings RunnerId="JavascriptDebugRunner" /> - <ConfigurationWrapper RunnerId="JavascriptDebugRunner" /> - <method /> - </configuration> -</component> \ No newline at end of file diff --git a/src/UI/.idea/scopes/NzbDrone.xml b/src/UI/.idea/scopes/NzbDrone.xml deleted file mode 100644 index 17c1c9c5e..000000000 --- a/src/UI/.idea/scopes/NzbDrone.xml +++ /dev/null @@ -1,3 +0,0 @@ -<component name="DependencyValidationManager"> - <scope name="NzbDrone" pattern="!file:JsLibraries//*" /> -</component> \ No newline at end of file diff --git a/src/UI/.idea/scopes/scope_settings.xml b/src/UI/.idea/scopes/scope_settings.xml deleted file mode 100644 index 922003b84..000000000 --- a/src/UI/.idea/scopes/scope_settings.xml +++ /dev/null @@ -1,5 +0,0 @@ -<component name="DependencyValidationManager"> - <state> - <option name="SKIP_IMPORT_STATEMENTS" value="false" /> - </state> -</component> \ No newline at end of file diff --git a/src/UI/.idea/vcs.xml b/src/UI/.idea/vcs.xml deleted file mode 100644 index 9ab281ac8..000000000 --- a/src/UI/.idea/vcs.xml +++ /dev/null @@ -1,7 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<project version="4"> - <component name="VcsDirectoryMappings"> - <mapping directory="$PROJECT_DIR$/../.." vcs="Git" /> - </component> -</project> - diff --git a/src/UI/.jshintrc b/src/UI/.jshintrc deleted file mode 100644 index 888afe448..000000000 --- a/src/UI/.jshintrc +++ /dev/null @@ -1,19 +0,0 @@ -{ - "-W030": false, - "-W064": false, - "-W097": false, - "-W100": false, - "undef": true, - "curly": true, - "immed": true, - "eqeqeq": true, - "latedef": true, - "globals": { - "module": true, - "require": true, - "define": true, - "window": true, - "document": true, - "console": true - } -} diff --git a/src/UI/Activity/ActivityLayout.js b/src/UI/Activity/ActivityLayout.js deleted file mode 100644 index a8826a714..000000000 --- a/src/UI/Activity/ActivityLayout.js +++ /dev/null @@ -1,84 +0,0 @@ -var Marionette = require('marionette'); -var Backbone = require('backbone'); -var Backgrid = require('backgrid'); -var HistoryLayout = require('./History/HistoryLayout'); -var BlacklistLayout = require('./Blacklist/BlacklistLayout'); -var QueueLayout = require('./Queue/QueueLayout'); - -module.exports = Marionette.Layout.extend({ - template : 'Activity/ActivityLayoutTemplate', - - regions : { - queueRegion : '#queue', - history : '#history', - blacklist : '#blacklist' - }, - - ui : { - queueTab : '.x-queue-tab', - historyTab : '.x-history-tab', - blacklistTab : '.x-blacklist-tab' - }, - - events : { - 'click .x-queue-tab' : '_showQueue', - 'click .x-history-tab' : '_showHistory', - 'click .x-blacklist-tab' : '_showBlacklist' - }, - - initialize : function(options) { - if (options.action) { - this.action = options.action.toLowerCase(); - } - }, - - onShow : function() { - switch (this.action) { - case 'history': - this._showHistory(); - break; - case 'blacklist': - this._showBlacklist(); - break; - default: - this._showQueue(); - } - }, - - _navigate : function(route) { - Backbone.history.navigate(route, { - trigger : false, - replace : true - }); - }, - - _showHistory : function(e) { - if (e) { - e.preventDefault(); - } - - this.history.show(new HistoryLayout()); - this.ui.historyTab.tab('show'); - this._navigate('/activity/history'); - }, - - _showBlacklist : function(e) { - if (e) { - e.preventDefault(); - } - - this.blacklist.show(new BlacklistLayout()); - this.ui.blacklistTab.tab('show'); - this._navigate('/activity/blacklist'); - }, - - _showQueue : function(e) { - if (e) { - e.preventDefault(); - } - - this.queueRegion.show(new QueueLayout()); - this.ui.queueTab.tab('show'); - this._navigate('/activity/queue'); - } -}); \ No newline at end of file diff --git a/src/UI/Activity/ActivityLayoutTemplate.hbs b/src/UI/Activity/ActivityLayoutTemplate.hbs deleted file mode 100644 index c9c08ecf7..000000000 --- a/src/UI/Activity/ActivityLayoutTemplate.hbs +++ /dev/null @@ -1,11 +0,0 @@ -<ul class="nav nav-tabs"> - <li><a href="#queue" class="x-queue-tab no-router">Queue</a></li> - <li><a href="#history" class="x-history-tab no-router">History</a></li> - <li><a href="#blacklist" class="x-blacklist-tab no-router">Blacklist</a></li> -</ul> - -<div class="tab-content"> - <div class="tab-pane" id="queue"></div> - <div class="tab-pane" id="history"></div> - <div class="tab-pane" id="blacklist"></div> -</div> \ No newline at end of file diff --git a/src/UI/Activity/Blacklist/BlacklistActionsCell.js b/src/UI/Activity/Blacklist/BlacklistActionsCell.js deleted file mode 100644 index 61ce7d102..000000000 --- a/src/UI/Activity/Blacklist/BlacklistActionsCell.js +++ /dev/null @@ -1,28 +0,0 @@ -var vent = require('vent'); -var NzbDroneCell = require('../../Cells/NzbDroneCell'); -var BlacklistDetailsLayout = require('./Details/BlacklistDetailsLayout'); - -module.exports = NzbDroneCell.extend({ - className : 'blacklist-actions-cell', - - events : { - 'click .x-details' : '_details', - 'click .x-delete' : '_delete' - }, - - render : function() { - this.$el.empty(); - this.$el.html('<i class="icon-sonarr-info x-details"></i>' + - '<i class="icon-sonarr-delete x-delete"></i>'); - - return this; - }, - - _details : function() { - vent.trigger(vent.Commands.OpenModalCommand, new BlacklistDetailsLayout({ model : this.model })); - }, - - _delete : function() { - this.model.destroy(); - } -}); diff --git a/src/UI/Activity/Blacklist/BlacklistCollection.js b/src/UI/Activity/Blacklist/BlacklistCollection.js deleted file mode 100644 index d7e2f1a16..000000000 --- a/src/UI/Activity/Blacklist/BlacklistCollection.js +++ /dev/null @@ -1,47 +0,0 @@ -var BlacklistModel = require('./BlacklistModel'); -var PageableCollection = require('backbone.pageable'); -var AsSortedCollection = require('../../Mixins/AsSortedCollection'); -var AsPersistedStateCollection = require('../../Mixins/AsPersistedStateCollection'); - -var Collection = PageableCollection.extend({ - url : window.NzbDrone.ApiRoot + '/blacklist', - model : BlacklistModel, - - state : { - pageSize : 15, - sortKey : 'date', - order : 1 - }, - - queryParams : { - totalPages : null, - totalRecords : null, - pageSize : 'pageSize', - sortKey : 'sortKey', - order : 'sortDir', - directions : { - '-1' : 'asc', - '1' : 'desc' - } - }, - - sortMappings : { - 'series' : { sortKey : 'series.sortTitle' } - }, - - parseState : function(resp) { - return { totalRecords : resp.totalRecords }; - }, - - parseRecords : function(resp) { - if (resp) { - return resp.records; - } - - return resp; - } -}); -Collection = AsSortedCollection.call(Collection); -Collection = AsPersistedStateCollection.call(Collection); - -module.exports = Collection; \ No newline at end of file diff --git a/src/UI/Activity/Blacklist/BlacklistLayout.js b/src/UI/Activity/Blacklist/BlacklistLayout.js deleted file mode 100644 index 22d7da60e..000000000 --- a/src/UI/Activity/Blacklist/BlacklistLayout.js +++ /dev/null @@ -1,114 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); -var Backgrid = require('backgrid'); -var BlacklistCollection = require('./BlacklistCollection'); -var SeriesTitleCell = require('../../Cells/SeriesTitleCell'); -var QualityCell = require('../../Cells/QualityCell'); -var RelativeDateCell = require('../../Cells/RelativeDateCell'); -var BlacklistActionsCell = require('./BlacklistActionsCell'); -var GridPager = require('../../Shared/Grid/Pager'); -var LoadingView = require('../../Shared/LoadingView'); -var ToolbarLayout = require('../../Shared/Toolbar/ToolbarLayout'); - -module.exports = Marionette.Layout.extend({ - template : 'Activity/Blacklist/BlacklistLayoutTemplate', - - regions : { - blacklist : '#x-blacklist', - toolbar : '#x-toolbar', - pager : '#x-pager' - }, - - columns : [ - { - name : 'series', - label : 'Series', - cell : SeriesTitleCell - }, - { - name : 'sourceTitle', - label : 'Source Title', - cell : 'string' - }, - { - name : 'quality', - label : 'Quality', - cell : QualityCell, - sortable : false - }, - { - name : 'date', - label : 'Date', - cell : RelativeDateCell - }, - { - name : 'this', - label : '', - cell : BlacklistActionsCell, - sortable : false - } - ], - - initialize : function() { - this.collection = new BlacklistCollection({ tableName : 'blacklist' }); - - this.listenTo(this.collection, 'sync', this._showTable); - this.listenTo(vent, vent.Events.CommandComplete, this._commandComplete); - }, - - onShow : function() { - this.blacklist.show(new LoadingView()); - this._showToolbar(); - this.collection.fetch(); - }, - - _showTable : function(collection) { - - this.blacklist.show(new Backgrid.Grid({ - columns : this.columns, - collection : collection, - className : 'table table-hover' - })); - - this.pager.show(new GridPager({ - columns : this.columns, - collection : collection - })); - }, - - _showToolbar : function() { - var leftSideButtons = { - type : 'default', - storeState : false, - items : [ - { - title : 'Clear Blacklist', - icon : 'icon-sonarr-clear', - command : 'clearBlacklist' - } - ] - }; - - this.toolbar.show(new ToolbarLayout({ - left : [ - leftSideButtons - ], - context : this - })); - }, - - _refreshTable : function(buttonContext) { - this.collection.state.currentPage = 1; - var promise = this.collection.fetch({ reset : true }); - - if (buttonContext) { - buttonContext.ui.icon.spinForPromise(promise); - } - }, - - _commandComplete : function(options) { - if (options.command.get('name') === 'clearblacklist') { - this._refreshTable(); - } - } -}); diff --git a/src/UI/Activity/Blacklist/BlacklistLayoutTemplate.hbs b/src/UI/Activity/Blacklist/BlacklistLayoutTemplate.hbs deleted file mode 100644 index 8f78eb0db..000000000 --- a/src/UI/Activity/Blacklist/BlacklistLayoutTemplate.hbs +++ /dev/null @@ -1,11 +0,0 @@ -<div id="x-toolbar"/> -<div class="row"> - <div class="col-md-12"> - <div id="x-blacklist" class="table-responsive"/> - </div> -</div> -<div class="row"> - <div class="col-md-12"> - <div id="x-pager"/> - </div> -</div> diff --git a/src/UI/Activity/Blacklist/BlacklistModel.js b/src/UI/Activity/Blacklist/BlacklistModel.js deleted file mode 100644 index e103f718f..000000000 --- a/src/UI/Activity/Blacklist/BlacklistModel.js +++ /dev/null @@ -1,17 +0,0 @@ -var Backbone = require('backbone'); -var SeriesCollection = require('../../Series/SeriesCollection'); - -module.exports = Backbone.Model.extend({ - - //Hack to deal with Backbone 1.0's bug - initialize : function() { - this.url = function() { - return this.collection.url + '/' + this.get('id'); - }; - }, - - parse : function(model) { - model.series = SeriesCollection.get(model.seriesId); - return model; - } -}); \ No newline at end of file diff --git a/src/UI/Activity/Blacklist/Details/BlacklistDetailsLayout.js b/src/UI/Activity/Blacklist/Details/BlacklistDetailsLayout.js deleted file mode 100644 index cdcbf25f0..000000000 --- a/src/UI/Activity/Blacklist/Details/BlacklistDetailsLayout.js +++ /dev/null @@ -1,14 +0,0 @@ -var Marionette = require('marionette'); -var BlacklistDetailsView = require('./BlacklistDetailsView'); - -module.exports = Marionette.Layout.extend({ - template : 'Activity/Blacklist/Details/BlacklistDetailsLayoutTemplate', - - regions : { - bodyRegion : '.modal-body' - }, - - onShow : function() { - this.bodyRegion.show(new BlacklistDetailsView({ model : this.model })); - } -}); \ No newline at end of file diff --git a/src/UI/Activity/Blacklist/Details/BlacklistDetailsLayoutTemplate.hbs b/src/UI/Activity/Blacklist/Details/BlacklistDetailsLayoutTemplate.hbs deleted file mode 100644 index 3cdfa99c7..000000000 --- a/src/UI/Activity/Blacklist/Details/BlacklistDetailsLayoutTemplate.hbs +++ /dev/null @@ -1,18 +0,0 @@ -<div class="modal-content"> - <div class="history-detail-modal"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - - <h3> - Blacklisted - </h3> - - </div> - <div class="modal-body"> - - </div> - <div class="modal-footer"> - <button class="btn" data-dismiss="modal">Close</button> - </div> - </div> -</div> diff --git a/src/UI/Activity/Blacklist/Details/BlacklistDetailsView.js b/src/UI/Activity/Blacklist/Details/BlacklistDetailsView.js deleted file mode 100644 index 1b7bc883d..000000000 --- a/src/UI/Activity/Blacklist/Details/BlacklistDetailsView.js +++ /dev/null @@ -1,5 +0,0 @@ -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'Activity/Blacklist/Details/BlacklistDetailsViewTemplate' -}); \ No newline at end of file diff --git a/src/UI/Activity/Blacklist/Details/BlacklistDetailsViewTemplate.hbs b/src/UI/Activity/Blacklist/Details/BlacklistDetailsViewTemplate.hbs deleted file mode 100644 index d29a878fc..000000000 --- a/src/UI/Activity/Blacklist/Details/BlacklistDetailsViewTemplate.hbs +++ /dev/null @@ -1,23 +0,0 @@ -<dl class="dl-horizontal info"> - - <dt>Name:</dt> - <dd>{{sourceTitle}}</dd> - - {{#if protocol}} - {{#unless_eq protocol compare="unknown"}} - <dt>Protocol:</dt> - <dd>{{protocol}}</dd> - {{/unless_eq}} - {{/if}} - - {{#if indexer}} - <dt>Indexer:</dt> - <dd>{{indexer}}</dd> - {{/if}} - - - {{#if message}} - <dt>Message:</dt> - <dd>{{message}}</dd> - {{/if}} -</dl> diff --git a/src/UI/Activity/History/Details/HistoryDetailsAge.js b/src/UI/Activity/History/Details/HistoryDetailsAge.js deleted file mode 100644 index a7c40f69a..000000000 --- a/src/UI/Activity/History/Details/HistoryDetailsAge.js +++ /dev/null @@ -1,22 +0,0 @@ -var Handlebars = require('handlebars'); -var FormatHelpers = require('../../../Shared/FormatHelpers'); - -Handlebars.registerHelper('historyAge', function() { - - var age = this.age; - var unit = FormatHelpers.plural(Math.round(age), 'day'); - var ageHours = parseFloat(this.ageHours); - var ageMinutes = this.ageMinutes ? parseFloat(this.ageMinutes) : null; - - if (age < 2) { - age = ageHours.toFixed(1); - unit = FormatHelpers.plural(Math.round(ageHours), 'hour'); - } - - if (age < 2 && ageMinutes) { - age = parseFloat(ageMinutes).toFixed(1); - unit = FormatHelpers.plural(Math.round(ageMinutes), 'minute'); - } - - return new Handlebars.SafeString('<dt>Age (when grabbed):</dt><dd>{0} {1}</dd>'.format(age, unit)); -}); diff --git a/src/UI/Activity/History/Details/HistoryDetailsLayout.js b/src/UI/Activity/History/Details/HistoryDetailsLayout.js deleted file mode 100644 index 5654a3e72..000000000 --- a/src/UI/Activity/History/Details/HistoryDetailsLayout.js +++ /dev/null @@ -1,35 +0,0 @@ -var $ = require('jquery'); -var vent = require('vent'); -var Marionette = require('marionette'); -var HistoryDetailsView = require('./HistoryDetailsView'); - -module.exports = Marionette.Layout.extend({ - template : 'Activity/History/Details/HistoryDetailsLayoutTemplate', - - regions : { - bodyRegion : '.modal-body' - }, - - events : { - 'click .x-mark-as-failed' : '_markAsFailed' - }, - - onShow : function() { - this.bodyRegion.show(new HistoryDetailsView({ model : this.model })); - }, - - _markAsFailed : function() { - var url = window.NzbDrone.ApiRoot + '/history/failed'; - var data = { - id : this.model.get('id') - }; - - $.ajax({ - url : url, - type : 'POST', - data : data - }); - - vent.trigger(vent.Commands.CloseModalCommand); - } -}); \ No newline at end of file diff --git a/src/UI/Activity/History/Details/HistoryDetailsLayoutTemplate.hbs b/src/UI/Activity/History/Details/HistoryDetailsLayoutTemplate.hbs deleted file mode 100644 index 892dbfc35..000000000 --- a/src/UI/Activity/History/Details/HistoryDetailsLayoutTemplate.hbs +++ /dev/null @@ -1,22 +0,0 @@ -<div class="modal-content"> - <div class="history-detail-modal"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - - <h3> - {{#if_eq eventType compare="grabbed"}}Grabbed{{/if_eq}} - {{#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}} - </h3> - - </div> - <div class="modal-body"> - - </div> - <div class="modal-footer"> - {{#if_eq eventType compare="grabbed"}}<button class="btn btn-danger x-mark-as-failed">Mark As Failed</button>{{/if_eq}} - <button class="btn" data-dismiss="modal">Close</button> - </div> - </div> -</div> diff --git a/src/UI/Activity/History/Details/HistoryDetailsView.js b/src/UI/Activity/History/Details/HistoryDetailsView.js deleted file mode 100644 index a883b0cb4..000000000 --- a/src/UI/Activity/History/Details/HistoryDetailsView.js +++ /dev/null @@ -1,6 +0,0 @@ -var Marionette = require('marionette'); -require('./HistoryDetailsAge'); - -module.exports = Marionette.ItemView.extend({ - template : 'Activity/History/Details/HistoryDetailsViewTemplate' -}); \ No newline at end of file diff --git a/src/UI/Activity/History/Details/HistoryDetailsViewTemplate.hbs b/src/UI/Activity/History/Details/HistoryDetailsViewTemplate.hbs deleted file mode 100644 index 89a757660..000000000 --- a/src/UI/Activity/History/Details/HistoryDetailsViewTemplate.hbs +++ /dev/null @@ -1,103 +0,0 @@ -{{#if_eq eventType compare="grabbed"}} -<dl class="dl-horizontal info"> - - <dt>Name:</dt> - <dd>{{sourceTitle}}</dd> - - {{#with data}} - {{#if indexer}} - <dt>Indexer:</dt> - <dd>{{indexer}}</dd> - {{/if}} - - {{#if releaseGroup}} - <dt>Release Group:</dt> - <dd>{{releaseGroup}}</dd> - {{/if}} - - {{#if nzbInfoUrl}} - <dt>Info:</dt> - <dd><a href="{{nzbInfoUrl}}">{{nzbInfoUrl}}</a></dd> - {{/if}} - - {{#if downloadClient}} - <dt>Download Client:</dt> - <dd>{{downloadClient}}</dd> - {{/if}} - - {{#if downloadId}} - <dt>Grab ID:</dt> - <dd>{{downloadId}}</dd> - {{/if}} - - {{#if age}} - {{historyAge}} - {{/if}} - - {{#if publishedDate}} - <dt>Published Date:</dt> - <dd>{{ShortDate publishedDate}} {{LTS publishedDate}}</dd> - {{/if}} - {{/with}} -</dl> -{{/if_eq}} - -{{#if_eq eventType compare="downloadFailed"}} -<dl class="dl-horizontal"> - - <dt>Name:</dt> - <dd>{{sourceTitle}}</dd> - - {{#with data}} - <dt>Message:</dt> - <dd>{{message}}</dd> - {{/with}} -</dl> -{{/if_eq}} - -{{#if_eq eventType compare="downloadFolderImported"}} -<dl class="dl-horizontal"> - - {{#if sourceTitle}} - <dt>Name:</dt> - <dd>{{sourceTitle}}</dd> - {{/if}} - - {{#with data}} - {{#if droppedPath}} - <dt>Source:</dt> - <dd>{{droppedPath}}</dd> - {{/if}} - - {{#if importedPath}} - <dt>Imported To:</dt> - <dd>{{importedPath}}</dd> - {{/if}} - {{/with}} -</dl> -{{/if_eq}} - -{{#if_eq eventType compare="episodeFileDeleted"}} -<dl class="dl-horizontal"> - - <dt>Path:</dt> - <dd>{{sourceTitle}}</dd> - - {{#with data}} - <dt>Reason:</dt> - <dd> - {{#if_eq reason compare="Manual"}} - File was deleted by via UI - {{/if_eq}} - - {{#if_eq reason compare="MissingFromDisk"}} - Sonarr was unable to find the file on disk so it was removed - {{/if_eq}} - - {{#if_eq reason compare="Upgrade"}} - File was deleted to import an upgrade - {{/if_eq}} - </dd> - {{/with}} -</dl> -{{/if_eq}} \ No newline at end of file diff --git a/src/UI/Activity/History/HistoryCollection.js b/src/UI/Activity/History/HistoryCollection.js deleted file mode 100644 index 3bd564309..000000000 --- a/src/UI/Activity/History/HistoryCollection.js +++ /dev/null @@ -1,83 +0,0 @@ -var HistoryModel = require('./HistoryModel'); -var PageableCollection = require('backbone.pageable'); -var AsFilteredCollection = require('../../Mixins/AsFilteredCollection'); -var AsSortedCollection = require('../../Mixins/AsSortedCollection'); -var AsPersistedStateCollection = require('../../Mixins/AsPersistedStateCollection'); - -var Collection = PageableCollection.extend({ - url : window.NzbDrone.ApiRoot + '/history', - model : HistoryModel, - - state : { - pageSize : 15, - sortKey : 'date', - order : 1 - }, - - queryParams : { - totalPages : null, - totalRecords : null, - pageSize : 'pageSize', - sortKey : 'sortKey', - order : 'sortDir', - directions : { - '-1' : 'asc', - '1' : 'desc' - } - }, - - filterModes : { - 'all' : [ - null, - null - ], - 'grabbed' : [ - 'eventType', - '1' - ], - 'imported' : [ - 'eventType', - '3' - ], - 'failed' : [ - 'eventType', - '4' - ], - 'deleted' : [ - 'eventType', - '5' - ] - }, - - sortMappings : { - 'series' : { sortKey : 'series.sortTitle' } - }, - - initialize : function(options) { - delete this.queryParams.episodeId; - - if (options) { - if (options.episodeId) { - this.queryParams.episodeId = options.episodeId; - } - } - }, - - parseState : function(resp) { - return { totalRecords : resp.totalRecords }; - }, - - parseRecords : function(resp) { - if (resp) { - return resp.records; - } - - return resp; - } -}); - -Collection = AsFilteredCollection.call(Collection); -Collection = AsSortedCollection.call(Collection); -Collection = AsPersistedStateCollection.call(Collection); - -module.exports = Collection; \ No newline at end of file diff --git a/src/UI/Activity/History/HistoryDetailsCell.js b/src/UI/Activity/History/HistoryDetailsCell.js deleted file mode 100644 index 4a1a8a53f..000000000 --- a/src/UI/Activity/History/HistoryDetailsCell.js +++ /dev/null @@ -1,21 +0,0 @@ -var vent = require('vent'); -var NzbDroneCell = require('../../Cells/NzbDroneCell'); - -module.exports = NzbDroneCell.extend({ - className : 'history-details-cell', - - events : { - 'click' : '_showDetails' - }, - - render : function() { - this.$el.empty(); - this.$el.html('<i class="icon-sonarr-info"></i>'); - - return this; - }, - - _showDetails : function() { - vent.trigger(vent.Commands.ShowHistoryDetails, { model : this.model }); - } -}); \ No newline at end of file diff --git a/src/UI/Activity/History/HistoryLayout.js b/src/UI/Activity/History/HistoryLayout.js deleted file mode 100644 index ae7e4c93e..000000000 --- a/src/UI/Activity/History/HistoryLayout.js +++ /dev/null @@ -1,154 +0,0 @@ -var Marionette = require('marionette'); -var Backgrid = require('backgrid'); -var HistoryCollection = require('./HistoryCollection'); -var EventTypeCell = require('../../Cells/EventTypeCell'); -var SeriesTitleCell = require('../../Cells/SeriesTitleCell'); -var EpisodeNumberCell = require('../../Cells/EpisodeNumberCell'); -var EpisodeTitleCell = require('../../Cells/EpisodeTitleCell'); -var HistoryQualityCell = require('./HistoryQualityCell'); -var RelativeDateCell = require('../../Cells/RelativeDateCell'); -var HistoryDetailsCell = require('./HistoryDetailsCell'); -var GridPager = require('../../Shared/Grid/Pager'); -var ToolbarLayout = require('../../Shared/Toolbar/ToolbarLayout'); -var LoadingView = require('../../Shared/LoadingView'); - -module.exports = Marionette.Layout.extend({ - template : 'Activity/History/HistoryLayoutTemplate', - - regions : { - history : '#x-history', - toolbar : '#x-history-toolbar', - pager : '#x-history-pager' - }, - - columns : [ - { - name : 'eventType', - label : '', - cell : EventTypeCell, - cellValue : 'this' - }, - { - name : 'series', - label : 'Series', - cell : SeriesTitleCell - }, - { - name : 'episode', - label : 'Episode', - cell : EpisodeNumberCell, - sortable : false - }, - { - name : 'episode', - label : 'Episode Title', - cell : EpisodeTitleCell, - sortable : false - }, - { - name : 'this', - label : 'Quality', - cell : HistoryQualityCell, - sortable : false - }, - { - name : 'date', - label : 'Date', - cell : RelativeDateCell - }, - { - name : 'this', - label : '', - cell : HistoryDetailsCell, - sortable : false - } - ], - - initialize : function() { - this.collection = new HistoryCollection({ tableName : 'history' }); - this.listenTo(this.collection, 'sync', this._showTable); - }, - - onShow : function() { - this.history.show(new LoadingView()); - this._showToolbar(); - }, - - _showTable : function(collection) { - - this.history.show(new Backgrid.Grid({ - columns : this.columns, - collection : collection, - className : 'table table-hover' - })); - - this.pager.show(new GridPager({ - columns : this.columns, - collection : collection - })); - }, - - _showToolbar : function() { - var filterOptions = { - type : 'radio', - storeState : true, - menuKey : 'history.filterMode', - defaultAction : 'all', - items : [ - { - key : 'all', - title : '', - tooltip : 'All', - icon : 'icon-sonarr-all', - callback : this._setFilter - }, - { - key : 'grabbed', - title : '', - tooltip : 'Grabbed', - icon : 'icon-sonarr-downloading', - callback : this._setFilter - }, - { - key : 'imported', - title : '', - tooltip : 'Imported', - icon : 'icon-sonarr-imported', - callback : this._setFilter - }, - { - key : 'failed', - title : '', - tooltip : 'Failed', - icon : 'icon-sonarr-download-failed', - callback : this._setFilter - }, - { - key : 'deleted', - title : '', - tooltip : 'Deleted', - icon : 'icon-sonarr-deleted', - callback : this._setFilter - } - ] - }; - - this.toolbar.show(new ToolbarLayout({ - right : [ - filterOptions - ], - context : this - })); - }, - - _setFilter : function(buttonContext) { - var mode = buttonContext.model.get('key'); - - this.collection.state.currentPage = 1; - var promise = this.collection.setFilterMode(mode); - - if (buttonContext) { - buttonContext.ui.icon.spinForPromise(promise); - } - } -}); diff --git a/src/UI/Activity/History/HistoryLayoutTemplate.hbs b/src/UI/Activity/History/HistoryLayoutTemplate.hbs deleted file mode 100644 index bffb274fe..000000000 --- a/src/UI/Activity/History/HistoryLayoutTemplate.hbs +++ /dev/null @@ -1,11 +0,0 @@ -<div id="x-history-toolbar"/> -<div class="row"> - <div class="col-md-12"> - <div id="x-history" class="table-responsive"/> - </div> -</div> -<div class="row"> - <div class="col-md-12"> - <div id="x-history-pager"/> - </div> -</div> diff --git a/src/UI/Activity/History/HistoryModel.js b/src/UI/Activity/History/HistoryModel.js deleted file mode 100644 index f8ec8c538..000000000 --- a/src/UI/Activity/History/HistoryModel.js +++ /dev/null @@ -1,12 +0,0 @@ -var Backbone = require('backbone'); -var SeriesModel = require('../../Series/SeriesModel'); -var EpisodeModel = require('../../Series/EpisodeModel'); - -module.exports = Backbone.Model.extend({ - parse : function(model) { - model.series = new SeriesModel(model.series); - model.episode = new EpisodeModel(model.episode); - model.episode.set('series', model.series); - return model; - } -}); \ No newline at end of file diff --git a/src/UI/Activity/History/HistoryQualityCell.js b/src/UI/Activity/History/HistoryQualityCell.js deleted file mode 100644 index c65aa042b..000000000 --- a/src/UI/Activity/History/HistoryQualityCell.js +++ /dev/null @@ -1,30 +0,0 @@ -var NzbDroneCell = require('../../Cells/NzbDroneCell'); - -module.exports = NzbDroneCell.extend({ - className : 'history-quality-cell', - - render : function() { - - var title = ''; - var quality = this.model.get('quality'); - var revision = quality.revision; - - if (revision.real && revision.real > 0) { - title += ' REAL'; - } - - if (revision.version && revision.version > 1) { - title += ' PROPER'; - } - - title = title.trim(); - - if (this.model.get('qualityCutoffNotMet')) { - this.$el.html('<span class="badge badge-inverse" title="{0}">{1}</span>'.format(title, quality.quality.name)); - } else { - this.$el.html('<span class="badge" title="{0}">{1}</span>'.format(title, quality.quality.name)); - } - - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Activity/Queue/ProgressCell.js b/src/UI/Activity/Queue/ProgressCell.js deleted file mode 100644 index 1f69bf017..000000000 --- a/src/UI/Activity/Queue/ProgressCell.js +++ /dev/null @@ -1,23 +0,0 @@ -var NzbDroneCell = require('../../Cells/NzbDroneCell'); - -module.exports = NzbDroneCell.extend({ - className : 'progress-cell', - - render : function() { - this.$el.empty(); - - if (this.cellValue) { - - var status = this.model.get('status').toLowerCase(); - - if (status === 'downloading') { - var progress = 100 - (this.model.get('sizeleft') / this.model.get('size') * 100); - - this.$el.html('<div class="progress" title="{0}%">'.format(progress.toFixed(1)) + - '<div class="progress-bar progress-bar-purple" style="width: {0}%;"></div></div>'.format(progress)); - } - } - - return this; - } -}); diff --git a/src/UI/Activity/Queue/QueueActionsCell.js b/src/UI/Activity/Queue/QueueActionsCell.js deleted file mode 100644 index eb2297fda..000000000 --- a/src/UI/Activity/Queue/QueueActionsCell.js +++ /dev/null @@ -1,59 +0,0 @@ -'use strict'; - -var $ = require('jquery'); -var _ = require('underscore'); -var vent = require('../../vent'); -var TemplatedCell = require('../../Cells/TemplatedCell'); -var RemoveFromQueueView = require('./RemoveFromQueueView'); - -module.exports = TemplatedCell.extend({ - - template : 'Activity/Queue/QueueActionsCellTemplate', - className : 'queue-actions-cell', - - events : { - 'click .x-remove' : '_remove', - 'click .x-manual-import' : '_manualImport', - 'click .x-grab' : '_grab' - }, - - ui : { - import : '.x-import', - grab : '.x-grab' - }, - - _remove : function() { - var showBlacklist = this.model.get('status') !== 'Pending'; - - vent.trigger(vent.Commands.OpenModalCommand, new RemoveFromQueueView({ - model : this.model, - showBlacklist : showBlacklist - })); - }, - - _manualImport : function () { - vent.trigger(vent.Commands.ShowManualImport, - { - downloadId: this.model.get('downloadId'), - title: this.model.get('title') - }); - }, - - _grab : function() { - var self = this; - var data = _.omit(this.model.toJSON(), 'series', 'episode'); - - var promise = $.ajax({ - url : window.NzbDrone.ApiRoot + '/queue/grab', - type : 'POST', - data : JSON.stringify(data) - }); - - this.$(this.ui.grab).spinForPromise(promise); - - promise.success(function() { - //find models that have the same series id and episode ids and remove them - self.model.trigger('destroy', self.model); - }); - } -}); diff --git a/src/UI/Activity/Queue/QueueActionsCellTemplate.hbs b/src/UI/Activity/Queue/QueueActionsCellTemplate.hbs deleted file mode 100644 index 13bee034e..000000000 --- a/src/UI/Activity/Queue/QueueActionsCellTemplate.hbs +++ /dev/null @@ -1,12 +0,0 @@ -{{#if_eq status compare="Completed"}} - {{#if_eq trackedDownloadStatus compare="Warning"}} - <i class="icon-sonarr-import-manual x-manual-import" title="Manual import"></i> - {{/if_eq}} -{{/if_eq}} - -{{#if_eq status compare="Pending"}} - <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> -{{/if_eq}} diff --git a/src/UI/Activity/Queue/QueueCollection.js b/src/UI/Activity/Queue/QueueCollection.js deleted file mode 100644 index 474cafe6b..000000000 --- a/src/UI/Activity/Queue/QueueCollection.js +++ /dev/null @@ -1,87 +0,0 @@ -var _ = require('underscore'); -var PageableCollection = require('backbone.pageable'); -//var PageableCollection = require('../../Shared/Grid/SonarrPageableCollection'); -var QueueModel = require('./QueueModel'); -var FormatHelpers = require('../../Shared/FormatHelpers'); -var AsSortedCollection = require('../../Mixins/AsSortedCollection'); -var AsPageableCollection = require('../../Mixins/AsPageableCollection'); -var moment = require('moment'); - -require('../../Mixins/backbone.signalr.mixin'); - -var QueueCollection = PageableCollection.extend({ - url : window.NzbDrone.ApiRoot + '/queue', - model : QueueModel, - - state : { - pageSize : 15, - sortKey: 'timeleft' - }, - - mode : 'client', - - findEpisode : function(episodeId) { - return _.find(this.fullCollection.models, function(queueModel) { - return queueModel.get('episode').id === episodeId; - }); - }, - - sortMappings : { - series : { - sortValue : function(model, attr) { - var series = model.get(attr); - - return series.get('sortTitle'); - } - }, - - episode : { - sortValue : function(model, attr) { - var episode = model.get('episode'); - - return FormatHelpers.pad(episode.get('seasonNumber'), 4) + FormatHelpers.pad(episode.get('episodeNumber'), 4); - } - }, - - episodeTitle : { - sortValue : function(model, attr) { - var episode = model.get('episode'); - - return episode.get('title'); - } - }, - - timeleft : { - sortValue : function(model, attr) { - var eta = model.get('estimatedCompletionTime'); - - if (eta) { - return moment(eta).unix(); - } - - return Number.MAX_VALUE; - } - }, - - sizeleft : { - sortValue : function(model, attr) { - var size = model.get('size'); - var sizeleft = model.get('sizeleft'); - - if (size && sizeleft) { - return sizeleft / size; - } - - return 0; - } - } - } -}); - -QueueCollection = AsSortedCollection.call(QueueCollection); -QueueCollection = AsPageableCollection.call(QueueCollection); - -var collection = new QueueCollection().bindSignalR(); -collection.fetch(); - -module.exports = collection; \ No newline at end of file diff --git a/src/UI/Activity/Queue/QueueLayout.js b/src/UI/Activity/Queue/QueueLayout.js deleted file mode 100644 index 462c6a568..000000000 --- a/src/UI/Activity/Queue/QueueLayout.js +++ /dev/null @@ -1,97 +0,0 @@ -var Marionette = require('marionette'); -var Backgrid = require('backgrid'); -var QueueCollection = require('./QueueCollection'); -var SeriesTitleCell = require('../../Cells/SeriesTitleCell'); -var EpisodeNumberCell = require('../../Cells/EpisodeNumberCell'); -var EpisodeTitleCell = require('../../Cells/EpisodeTitleCell'); -var QualityCell = require('../../Cells/QualityCell'); -var QueueStatusCell = require('./QueueStatusCell'); -var QueueActionsCell = require('./QueueActionsCell'); -var TimeleftCell = require('./TimeleftCell'); -var ProgressCell = require('./ProgressCell'); -var ProtocolCell = require('../../Release/ProtocolCell'); -var GridPager = require('../../Shared/Grid/Pager'); - -module.exports = Marionette.Layout.extend({ - template : 'Activity/Queue/QueueLayoutTemplate', - - regions : { - table : '#x-queue', - pager : '#x-queue-pager' - }, - - columns : [ - { - name : 'status', - label : '', - cell : QueueStatusCell, - cellValue : 'this' - }, - { - name : 'series', - label : 'Series', - cell : SeriesTitleCell - }, - { - name : 'episode', - label : 'Episode', - cell : EpisodeNumberCell - }, - { - name : 'episodeTitle', - label : 'Episode Title', - cell : EpisodeTitleCell, - cellValue : 'episode' - }, - { - name : 'quality', - label : 'Quality', - cell : QualityCell, - sortable : false - }, - { - name : 'protocol', - label : 'Protocol', - cell : ProtocolCell - }, - { - name : 'timeleft', - label : 'Time Left', - cell : TimeleftCell, - cellValue : 'this' - }, - { - name : 'sizeleft', - label : 'Progress', - cell : ProgressCell, - cellValue : 'this' - }, - { - name : 'status', - label : '', - cell : QueueActionsCell, - cellValue : 'this' - } - ], - - initialize : function() { - this.listenTo(QueueCollection, 'sync', this._showTable); - }, - - onShow : function() { - this._showTable(); - }, - - _showTable : function() { - this.table.show(new Backgrid.Grid({ - columns : this.columns, - collection : QueueCollection, - className : 'table table-hover' - })); - - this.pager.show(new GridPager({ - columns : this.columns, - collection : QueueCollection - })); - } -}); diff --git a/src/UI/Activity/Queue/QueueLayoutTemplate.hbs b/src/UI/Activity/Queue/QueueLayoutTemplate.hbs deleted file mode 100644 index e8e6a3c12..000000000 --- a/src/UI/Activity/Queue/QueueLayoutTemplate.hbs +++ /dev/null @@ -1,11 +0,0 @@ -<div class="row"> - <div class="col-md-12"> - <div id="x-queue" class="queue table-responsive"/> - </div> -</div> - -<div class="row"> - <div class="col-md-12"> - <div id="x-queue-pager"/> - </div> -</div> \ No newline at end of file diff --git a/src/UI/Activity/Queue/QueueModel.js b/src/UI/Activity/Queue/QueueModel.js deleted file mode 100644 index f8ec8c538..000000000 --- a/src/UI/Activity/Queue/QueueModel.js +++ /dev/null @@ -1,12 +0,0 @@ -var Backbone = require('backbone'); -var SeriesModel = require('../../Series/SeriesModel'); -var EpisodeModel = require('../../Series/EpisodeModel'); - -module.exports = Backbone.Model.extend({ - parse : function(model) { - model.series = new SeriesModel(model.series); - model.episode = new EpisodeModel(model.episode); - model.episode.set('series', model.series); - return model; - } -}); \ No newline at end of file diff --git a/src/UI/Activity/Queue/QueueStatusCell.js b/src/UI/Activity/Queue/QueueStatusCell.js deleted file mode 100644 index 04c027b50..000000000 --- a/src/UI/Activity/Queue/QueueStatusCell.js +++ /dev/null @@ -1,81 +0,0 @@ -var Marionette = require('marionette'); -var NzbDroneCell = require('../../Cells/NzbDroneCell'); - -module.exports = NzbDroneCell.extend({ - className : 'queue-status-cell', - template : 'Activity/Queue/QueueStatusCellTemplate', - - render : function() { - this.$el.empty(); - - if (this.cellValue) { - var status = this.cellValue.get('status').toLowerCase(); - var trackedDownloadStatus = this.cellValue.has('trackedDownloadStatus') ? this.cellValue.get('trackedDownloadStatus').toLowerCase() : 'ok'; - var icon = 'icon-sonarr-downloading'; - var title = 'Downloading'; - var itemTitle = this.cellValue.get('title'); - var content = itemTitle; - - if (status === 'paused') { - icon = 'icon-sonarr-paused'; - title = 'Paused'; - } - - if (status === 'queued') { - icon = 'icon-sonarr-queued'; - title = 'Queued'; - } - - if (status === 'completed') { - icon = 'icon-sonarr-downloaded'; - title = 'Downloaded'; - } - - if (status === 'pending') { - icon = 'icon-sonarr-pending'; - title = 'Pending'; - } - - if (status === 'failed') { - icon = 'icon-sonarr-download-failed'; - title = 'Download failed'; - } - - if (status === 'warning') { - icon = 'icon-sonarr-download-warning'; - title = 'Download warning: check download client for more details'; - } - - if (trackedDownloadStatus === 'warning') { - icon += ' icon-sonarr-warning'; - - this.templateFunction = Marionette.TemplateCache.get(this.template); - content = this.templateFunction(this.cellValue.toJSON()); - } - - if (trackedDownloadStatus === 'error') { - if (status === 'completed') { - icon = 'icon-sonarr-import-failed'; - title = 'Import failed: ' + itemTitle; - } else { - icon = 'icon-sonarr-download-failed'; - title = 'Download failed'; - } - - this.templateFunction = Marionette.TemplateCache.get(this.template); - content = this.templateFunction(this.cellValue.toJSON()); - } - - this.$el.html('<i class="{0}"></i>'.format(icon)); - this.$el.popover({ - content : content, - html : true, - trigger : 'hover', - title : title, - placement : 'right', - container : this.$el - }); - } - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Activity/Queue/QueueStatusCellTemplate.hbs b/src/UI/Activity/Queue/QueueStatusCellTemplate.hbs deleted file mode 100644 index 477fdd028..000000000 --- a/src/UI/Activity/Queue/QueueStatusCellTemplate.hbs +++ /dev/null @@ -1,8 +0,0 @@ -{{#each statusMessages}} - {{title}} - <ul> - {{#each messages}} - <li>{{this}}</li> - {{/each}} - </ul> -{{/each}} \ No newline at end of file diff --git a/src/UI/Activity/Queue/QueueView.js b/src/UI/Activity/Queue/QueueView.js deleted file mode 100644 index ccddebbc9..000000000 --- a/src/UI/Activity/Queue/QueueView.js +++ /dev/null @@ -1,40 +0,0 @@ -var _ = require('underscore'); -var Marionette = require('marionette'); -var QueueCollection = require('./QueueCollection'); - -module.exports = Marionette.ItemView.extend({ - tagName : 'span', - - initialize : function() { - this.listenTo(QueueCollection, 'sync', this.render); - QueueCollection.fetch(); - }, - - render : function() { - this.$el.empty(); - - if (QueueCollection.length === 0) { - return this; - } - - var count = QueueCollection.fullCollection.length; - var label = 'label-info'; - - var errors = QueueCollection.fullCollection.some(function(model) { - return model.has('trackedDownloadStatus') && model.get('trackedDownloadStatus').toLowerCase() === 'error'; - }); - - var warnings = QueueCollection.fullCollection.some(function(model) { - return model.has('trackedDownloadStatus') && model.get('trackedDownloadStatus').toLowerCase() === 'warning'; - }); - - if (errors) { - label = 'label-danger'; - } else if (warnings) { - label = 'label-warning'; - } - - this.$el.html('<span class="label {0}">{1}</span>'.format(label, count)); - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Activity/Queue/RemoveFromQueueView.js b/src/UI/Activity/Queue/RemoveFromQueueView.js deleted file mode 100644 index 571738d7a..000000000 --- a/src/UI/Activity/Queue/RemoveFromQueueView.js +++ /dev/null @@ -1,34 +0,0 @@ -var vent = require('../../vent'); -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'Activity/Queue/RemoveFromQueueViewTemplate', - - events : { - 'click .x-confirm-remove' : 'removeItem' - }, - - ui : { - blacklist : '.x-blacklist', - indicator : '.x-indicator' - }, - - initialize : function(options) { - this.templateHelpers = { - showBlacklist : options.showBlacklist - }; - }, - - removeItem : function() { - var blacklist = this.ui.blacklist.prop('checked') || false; - - this.ui.indicator.show(); - - this.model.destroy({ - data : { 'blacklist' : blacklist }, - wait : true - }).done(function() { - vent.trigger(vent.Commands.CloseModalCommand); - }); - } -}); diff --git a/src/UI/Activity/Queue/RemoveFromQueueViewTemplate.hbs b/src/UI/Activity/Queue/RemoveFromQueueViewTemplate.hbs deleted file mode 100644 index c0834ea69..000000000 --- a/src/UI/Activity/Queue/RemoveFromQueueViewTemplate.hbs +++ /dev/null @@ -1,49 +0,0 @@ -<div class="modal-content"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - <h3>{{title}}</h3> - </div> - <div class="modal-body remove-from-queue-modal"> - - <div class="row"> - <div class="col-sm-12"> - Are you sure you want to remove '{{title}}'? - </div> - </div> - - {{#if showBlacklist}} - <div class="row"> - <div class="col-sm-12"> - <div class="form-horizontal"> - <div class="form-group"> - <label class="col-sm-4 control-label">Blacklist release</label> - - <div class="col-sm-8"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" class="x-blacklist"/> - <p> - <span>Yes</span> - <span>No</span> - </p> - - <div class="btn slide-button btn-danger"/> - </label> - - <span class="help-inline-checkbox"> - <i class="icon-sonarr-form-info" title="Do you want to blacklist this release?"/> - </span> - </div> - </div> - </div> - </div> - </div> - </div> - {{/if}} - </div> - <div class="modal-footer"> - <span class="indicator x-indicator"><i class="icon-sonarr-spinner fa-spin"></i></span> - <button class="btn" data-dismiss="modal">Cancel</button> - <button class="btn btn-danger x-confirm-remove">Remove</button> - </div> -</div> diff --git a/src/UI/Activity/Queue/TimeleftCell.js b/src/UI/Activity/Queue/TimeleftCell.js deleted file mode 100644 index 766d9df2d..000000000 --- a/src/UI/Activity/Queue/TimeleftCell.js +++ /dev/null @@ -1,33 +0,0 @@ -var NzbDroneCell = require('../../Cells/NzbDroneCell'); -var moment = require('moment'); -var UiSettingsModel = require('../../Shared/UiSettingsModel'); -var FormatHelpers = require('../../Shared/FormatHelpers'); - -module.exports = NzbDroneCell.extend({ - className : 'timeleft-cell', - - render : function() { - 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))); - 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 { - this.$el.html('<span title="{1} / {2}">{0}</span>'.format(timeleft, remainingSize, totalSize)); - } - } - - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Activity/activity.less b/src/UI/Activity/activity.less deleted file mode 100644 index cb4c538cb..000000000 --- a/src/UI/Activity/activity.less +++ /dev/null @@ -1,27 +0,0 @@ - -.queue-status-cell .popover { - max-width : 800px; -} - -.queue { - .protocol-cell { - text-align : center; - width : 80px; - } - - .episode-number-cell { - min-width : 90px; - } -} - -.remove-from-queue-modal { - .form-horizontal { - margin-top : 20px; - } -} - -.history-detail-modal { - .info { - word-wrap: break-word; - } -} diff --git a/src/UI/AddSeries/AddSeriesCollection.js b/src/UI/AddSeries/AddSeriesCollection.js deleted file mode 100644 index 5be24d3a7..000000000 --- a/src/UI/AddSeries/AddSeriesCollection.js +++ /dev/null @@ -1,22 +0,0 @@ -var Backbone = require('backbone'); -var SeriesModel = require('../Series/SeriesModel'); -var _ = require('underscore'); - -module.exports = Backbone.Collection.extend({ - url : window.NzbDrone.ApiRoot + '/series/lookup', - model : SeriesModel, - - parse : function(response) { - var self = this; - - _.each(response, function(model) { - model.id = undefined; - - if (self.unmappedFolderModel) { - model.path = self.unmappedFolderModel.get('folder').path; - } - }); - - return response; - } -}); \ No newline at end of file diff --git a/src/UI/AddSeries/AddSeriesLayout.js b/src/UI/AddSeries/AddSeriesLayout.js deleted file mode 100644 index 166aedb5a..000000000 --- a/src/UI/AddSeries/AddSeriesLayout.js +++ /dev/null @@ -1,53 +0,0 @@ -var vent = require('vent'); -var AppLayout = require('../AppLayout'); -var Marionette = require('marionette'); -var RootFolderLayout = require('./RootFolders/RootFolderLayout'); -var ExistingSeriesCollectionView = require('./Existing/AddExistingSeriesCollectionView'); -var AddSeriesView = require('./AddSeriesView'); -var ProfileCollection = require('../Profile/ProfileCollection'); -var RootFolderCollection = require('./RootFolders/RootFolderCollection'); -require('../Series/SeriesCollection'); - -module.exports = Marionette.Layout.extend({ - template : 'AddSeries/AddSeriesLayoutTemplate', - - regions : { - workspace : '#add-series-workspace' - }, - - events : { - 'click .x-import' : '_importSeries', - 'click .x-add-new' : '_addSeries' - }, - - attributes : { - id : 'add-series-screen' - }, - - initialize : function() { - ProfileCollection.fetch(); - RootFolderCollection.fetch().done(function() { - RootFolderCollection.synced = true; - }); - }, - - onShow : function() { - this.workspace.show(new AddSeriesView()); - }, - - _folderSelected : function(options) { - vent.trigger(vent.Commands.CloseModalCommand); - - this.workspace.show(new ExistingSeriesCollectionView({ model : options.model })); - }, - - _importSeries : function() { - this.rootFolderLayout = new RootFolderLayout(); - this.listenTo(this.rootFolderLayout, 'folderSelected', this._folderSelected); - AppLayout.modalRegion.show(this.rootFolderLayout); - }, - - _addSeries : function() { - this.workspace.show(new AddSeriesView()); - } -}); \ No newline at end of file diff --git a/src/UI/AddSeries/AddSeriesLayoutTemplate.hbs b/src/UI/AddSeries/AddSeriesLayoutTemplate.hbs deleted file mode 100644 index ab6e5e6c0..000000000 --- a/src/UI/AddSeries/AddSeriesLayoutTemplate.hbs +++ /dev/null @@ -1,17 +0,0 @@ -<div class="row"> - <div class="col-md-12"> - <div class="btn-group add-series-btn-group btn-group-lg btn-block"> - <button type="button" class="btn btn-default col-md-10 col-xs-8 add-series-import-btn x-import"> - <i class="icon-sonarr-hdd"/> - Import existing series on disk - </button> - <button class="btn btn-default col-md-2 col-xs-4 x-add-new"><i class="icon-sonarr-active hidden-xs"></i> Add New Series</button> - </div> - </div> -</div> -<div class="row"> - <div class="col-md-12"> - <div id="add-series-workspace"></div> - </div> -</div> - diff --git a/src/UI/AddSeries/AddSeriesView.js b/src/UI/AddSeries/AddSeriesView.js deleted file mode 100644 index 3cda1db63..000000000 --- a/src/UI/AddSeries/AddSeriesView.js +++ /dev/null @@ -1,182 +0,0 @@ -var _ = require('underscore'); -var vent = require('vent'); -var Marionette = require('marionette'); -var AddSeriesCollection = require('./AddSeriesCollection'); -var SearchResultCollectionView = require('./SearchResultCollectionView'); -var EmptyView = require('./EmptyView'); -var NotFoundView = require('./NotFoundView'); -var ErrorView = require('./ErrorView'); -var LoadingView = require('../Shared/LoadingView'); - -module.exports = Marionette.Layout.extend({ - template : 'AddSeries/AddSeriesViewTemplate', - - regions : { - searchResult : '#search-result' - }, - - ui : { - seriesSearch : '.x-series-search', - searchBar : '.x-search-bar', - loadMore : '.x-load-more' - }, - - events : { - 'click .x-load-more' : '_onLoadMore' - }, - - initialize : function(options) { - this.isExisting = options.isExisting; - this.collection = new AddSeriesCollection(); - - if (this.isExisting) { - this.collection.unmappedFolderModel = this.model; - } - - if (this.isExisting) { - this.className = 'existing-series'; - } else { - this.className = 'new-series'; - } - - this.listenTo(vent, vent.Events.SeriesAdded, this._onSeriesAdded); - this.listenTo(this.collection, 'sync', this._showResults); - - this.resultCollectionView = new SearchResultCollectionView({ - collection : this.collection, - isExisting : this.isExisting - }); - - this.throttledSearch = _.debounce(this.search, 1000, { trailing : true }).bind(this); - }, - - onRender : function() { - var self = this; - - this.$el.addClass(this.className); - - this.ui.seriesSearch.keyup(function(e) { - - if (_.contains([ - 9, - 16, - 17, - 18, - 19, - 20, - 33, - 34, - 35, - 36, - 37, - 38, - 39, - 40, - 91, - 92, - 93 - ], e.keyCode)) { - return; - } - - self._abortExistingSearch(); - self.throttledSearch({ - term : self.ui.seriesSearch.val() - }); - }); - - this._clearResults(); - - if (this.isExisting) { - this.ui.searchBar.hide(); - } - }, - - onShow : function() { - this.ui.seriesSearch.focus(); - }, - - search : function(options) { - var self = this; - - this.collection.reset(); - - if (!options.term || options.term === this.collection.term) { - return Marionette.$.Deferred().resolve(); - } - - this.searchResult.show(new LoadingView()); - this.collection.term = options.term; - this.currentSearchPromise = this.collection.fetch({ - data : { term : options.term } - }); - - this.currentSearchPromise.fail(function() { - self._showError(); - }); - - return this.currentSearchPromise; - }, - - _onSeriesAdded : function(options) { - if (this.isExisting && options.series.get('path') === this.model.get('folder').path) { - this.close(); - } - - else if (!this.isExisting) { - this.collection.term = ''; - this.collection.reset(); - this._clearResults(); - this.ui.seriesSearch.val(''); - this.ui.seriesSearch.focus(); - } - }, - - _onLoadMore : function() { - var showingAll = this.resultCollectionView.showMore(); - this.ui.searchBar.show(); - - if (showingAll) { - this.ui.loadMore.hide(); - } - }, - - _clearResults : function() { - if (!this.isExisting) { - this.searchResult.show(new EmptyView()); - } else { - this.searchResult.close(); - } - }, - - _showResults : function() { - if (!this.isClosed) { - if (this.collection.length === 0) { - this.ui.searchBar.show(); - this.searchResult.show(new NotFoundView({ term : this.collection.term })); - } else { - this.searchResult.show(this.resultCollectionView); - if (!this.showingAll && this.isExisting) { - this.ui.loadMore.show(); - } - } - } - }, - - _abortExistingSearch : function() { - if (this.currentSearchPromise && this.currentSearchPromise.readyState > 0 && this.currentSearchPromise.readyState < 4) { - console.log('aborting previous pending search request.'); - this.currentSearchPromise.abort(); - } else { - this._clearResults(); - } - }, - - _showError : function() { - if (!this.isClosed) { - this.ui.searchBar.show(); - this.searchResult.show(new ErrorView({ term : this.collection.term })); - this.collection.term = ''; - } - } -}); \ No newline at end of file diff --git a/src/UI/AddSeries/AddSeriesViewTemplate.hbs b/src/UI/AddSeries/AddSeriesViewTemplate.hbs deleted file mode 100644 index 18ed2ffb3..000000000 --- a/src/UI/AddSeries/AddSeriesViewTemplate.hbs +++ /dev/null @@ -1,24 +0,0 @@ -{{#if folder.path}} -<div class="unmapped-folder-path"> - <div class="col-md-12"> - {{folder.path}} - </div> -</div>{{/if}} -<div class="x-search-bar"> - <div class="input-group input-group-lg add-series-search"> - <span class="input-group-addon"><i class="icon-sonarr-search"/></span> - - {{#if folder}} - <input type="text" class="form-control x-series-search" value="{{folder.name}}"> - {{else}} - <input type="text" class="form-control x-series-search" placeholder="Start typing the name of series you want to add ..."> - {{/if}} - </div> -</div> -<div class="row"> - <div id="search-result" class="result-list col-md-12"/> -</div> -<div class="btn btn-block text-center new-series-loadmore x-load-more" style="display: none;"> - <i class="icon-sonarr-load-more"/> - more -</div> diff --git a/src/UI/AddSeries/EmptyView.js b/src/UI/AddSeries/EmptyView.js deleted file mode 100644 index 047a07ca5..000000000 --- a/src/UI/AddSeries/EmptyView.js +++ /dev/null @@ -1,5 +0,0 @@ -var Marionette = require('marionette'); - -module.exports = Marionette.CompositeView.extend({ - template : 'AddSeries/EmptyViewTemplate' -}); \ No newline at end of file diff --git a/src/UI/AddSeries/EmptyViewTemplate.hbs b/src/UI/AddSeries/EmptyViewTemplate.hbs deleted file mode 100644 index 60346f0c0..000000000 --- a/src/UI/AddSeries/EmptyViewTemplate.hbs +++ /dev/null @@ -1,3 +0,0 @@ -<div class="text-center hint col-md-12"> - <span>You can also search by tvdbid using the tvdb: prefixes.</span> -</div> diff --git a/src/UI/AddSeries/ErrorView.js b/src/UI/AddSeries/ErrorView.js deleted file mode 100644 index 3b619bcb2..000000000 --- a/src/UI/AddSeries/ErrorView.js +++ /dev/null @@ -1,13 +0,0 @@ -var Marionette = require('marionette'); - -module.exports = Marionette.CompositeView.extend({ - template : 'AddSeries/ErrorViewTemplate', - - initialize : function(options) { - this.options = options; - }, - - templateHelpers : function() { - return this.options; - } -}); \ No newline at end of file diff --git a/src/UI/AddSeries/ErrorViewTemplate.hbs b/src/UI/AddSeries/ErrorViewTemplate.hbs deleted file mode 100644 index 163779c26..000000000 --- a/src/UI/AddSeries/ErrorViewTemplate.hbs +++ /dev/null @@ -1,7 +0,0 @@ -<div class="text-center col-md-12"> - <h3> - There was an error searching for '{{term}}'. - </h3> - - If the series title contains non-alphanumeric characters try removing them, otherwise try your search again later. -</div> diff --git a/src/UI/AddSeries/Existing/AddExistingSeriesCollectionView.js b/src/UI/AddSeries/Existing/AddExistingSeriesCollectionView.js deleted file mode 100644 index 5c5eddc64..000000000 --- a/src/UI/AddSeries/Existing/AddExistingSeriesCollectionView.js +++ /dev/null @@ -1,51 +0,0 @@ -var Marionette = require('marionette'); -var AddSeriesView = require('../AddSeriesView'); -var UnmappedFolderCollection = require('./UnmappedFolderCollection'); - -module.exports = Marionette.CompositeView.extend({ - itemView : AddSeriesView, - itemViewContainer : '.x-loading-folders', - template : 'AddSeries/Existing/AddExistingSeriesCollectionViewTemplate', - - ui : { - loadingFolders : '.x-loading-folders' - }, - - initialize : function() { - this.collection = new UnmappedFolderCollection(); - this.collection.importItems(this.model); - }, - - showCollection : function() { - this._showAndSearch(0); - }, - - appendHtml : function(collectionView, itemView, index) { - collectionView.ui.loadingFolders.before(itemView.el); - }, - - _showAndSearch : function(index) { - var self = this; - var model = this.collection.at(index); - - if (model) { - var currentIndex = index; - var folderName = model.get('folder').name; - this.addItemView(model, this.getItemView(), index); - this.children.findByModel(model).search({ term : folderName }).always(function() { - if (!self.isClosed) { - self._showAndSearch(currentIndex + 1); - } - }); - } - - else { - this.ui.loadingFolders.hide(); - } - }, - - itemViewOptions : { - isExisting : true - } - -}); \ No newline at end of file diff --git a/src/UI/AddSeries/Existing/AddExistingSeriesCollectionViewTemplate.hbs b/src/UI/AddSeries/Existing/AddExistingSeriesCollectionViewTemplate.hbs deleted file mode 100644 index d613a52d4..000000000 --- a/src/UI/AddSeries/Existing/AddExistingSeriesCollectionViewTemplate.hbs +++ /dev/null @@ -1,5 +0,0 @@ -<div class="x-existing-folders"> - <div class="loading-folders x-loading-folders"> - Loading search results from TheTVDB for your series, this may take a few minutes. - </div> -</div> \ No newline at end of file diff --git a/src/UI/AddSeries/Existing/UnmappedFolderCollection.js b/src/UI/AddSeries/Existing/UnmappedFolderCollection.js deleted file mode 100644 index bd2a83f49..000000000 --- a/src/UI/AddSeries/Existing/UnmappedFolderCollection.js +++ /dev/null @@ -1,20 +0,0 @@ -var Backbone = require('backbone'); -var UnmappedFolderModel = require('./UnmappedFolderModel'); -var _ = require('underscore'); - -module.exports = Backbone.Collection.extend({ - model : UnmappedFolderModel, - - importItems : function(rootFolderModel) { - - this.reset(); - var rootFolder = rootFolderModel; - - _.each(rootFolderModel.get('unmappedFolders'), function(folder) { - this.push(new UnmappedFolderModel({ - rootFolder : rootFolder, - folder : folder - })); - }, this); - } -}); \ No newline at end of file diff --git a/src/UI/AddSeries/Existing/UnmappedFolderModel.js b/src/UI/AddSeries/Existing/UnmappedFolderModel.js deleted file mode 100644 index 3986a5948..000000000 --- a/src/UI/AddSeries/Existing/UnmappedFolderModel.js +++ /dev/null @@ -1,3 +0,0 @@ -var Backbone = require('backbone'); - -module.exports = Backbone.Model.extend({}); \ No newline at end of file diff --git a/src/UI/AddSeries/MonitoringTooltipTemplate.hbs b/src/UI/AddSeries/MonitoringTooltipTemplate.hbs deleted file mode 100644 index 0cf813e98..000000000 --- a/src/UI/AddSeries/MonitoringTooltipTemplate.hbs +++ /dev/null @@ -1,18 +0,0 @@ -<dl class="monitor-tooltip-contents"> - <dt>All</dt> - <dd>Monitor all episodes except specials</dd> - <dt>Future</dt> - <dd>Monitor episodes that have not aired yet</dd> - <dt>Missing</dt> - <dd>Monitor episodes that do not have files or have not aired yet</dd> - <dt>Existing</dt> - <dd>Monitor episodes that have files or have not aired yet</dd> - <dt>First Season</dt> - <dd>Monitor all episodes of the first season. All other seasons will be ignored</dd> - <dt>Latest Season</dt> - <dd>Monitor all episodes of the latest season and future seasons</dd> - <dt>None</dt> - <dd>No episodes will be monitored.</dd> - <!--<dt>Latest Season</dt>--> - <!--<dd>Monitor all episodes the latest season only, previous seasons will be ignored</dd>--> -</dl> \ No newline at end of file diff --git a/src/UI/AddSeries/NotFoundView.js b/src/UI/AddSeries/NotFoundView.js deleted file mode 100644 index 9dce2bf85..000000000 --- a/src/UI/AddSeries/NotFoundView.js +++ /dev/null @@ -1,13 +0,0 @@ -var Marionette = require('marionette'); - -module.exports = Marionette.CompositeView.extend({ - template : 'AddSeries/NotFoundViewTemplate', - - initialize : function(options) { - this.options = options; - }, - - templateHelpers : function() { - return this.options; - } -}); \ No newline at end of file diff --git a/src/UI/AddSeries/NotFoundViewTemplate.hbs b/src/UI/AddSeries/NotFoundViewTemplate.hbs deleted file mode 100644 index f203260e2..000000000 --- a/src/UI/AddSeries/NotFoundViewTemplate.hbs +++ /dev/null @@ -1,7 +0,0 @@ -<div class="text-center col-md-12"> - <h3> - Sorry. We couldn't find any series matching '{{term}}' - </h3> - <a href="https://github.com/NzbDrone/NzbDrone/wiki/FAQ#wiki-why-cant-i-add-a-new-show-to-nzbdrone-its-on-thetvdb">Why can't I find my show?</a> - -</div> diff --git a/src/UI/AddSeries/RootFolders/RootFolderCollection.js b/src/UI/AddSeries/RootFolders/RootFolderCollection.js deleted file mode 100644 index 81050c19d..000000000 --- a/src/UI/AddSeries/RootFolders/RootFolderCollection.js +++ /dev/null @@ -1,10 +0,0 @@ -var Backbone = require('backbone'); -var RootFolderModel = require('./RootFolderModel'); -require('../../Mixins/backbone.signalr.mixin'); - -var RootFolderCollection = Backbone.Collection.extend({ - url : window.NzbDrone.ApiRoot + '/rootfolder', - model : RootFolderModel -}); - -module.exports = new RootFolderCollection(); \ No newline at end of file diff --git a/src/UI/AddSeries/RootFolders/RootFolderCollectionView.js b/src/UI/AddSeries/RootFolders/RootFolderCollectionView.js deleted file mode 100644 index f781f21d7..000000000 --- a/src/UI/AddSeries/RootFolders/RootFolderCollectionView.js +++ /dev/null @@ -1,8 +0,0 @@ -var Marionette = require('marionette'); -var RootFolderItemView = require('./RootFolderItemView'); - -module.exports = Marionette.CompositeView.extend({ - template : 'AddSeries/RootFolders/RootFolderCollectionViewTemplate', - itemViewContainer : '.x-root-folders', - itemView : RootFolderItemView -}); \ No newline at end of file diff --git a/src/UI/AddSeries/RootFolders/RootFolderCollectionViewTemplate.hbs b/src/UI/AddSeries/RootFolders/RootFolderCollectionViewTemplate.hbs deleted file mode 100644 index 70755bbca..000000000 --- a/src/UI/AddSeries/RootFolders/RootFolderCollectionViewTemplate.hbs +++ /dev/null @@ -1,13 +0,0 @@ -<table class="table table-hover"> - <thead> - <tr> - <th class="col-md-10 "> - Path - </th> - <th class="col-md-3"> - Free Space - </th> - </tr> - </thead> - <tbody class="x-root-folders"></tbody> -</table> \ No newline at end of file diff --git a/src/UI/AddSeries/RootFolders/RootFolderItemView.js b/src/UI/AddSeries/RootFolders/RootFolderItemView.js deleted file mode 100644 index a0e98100b..000000000 --- a/src/UI/AddSeries/RootFolders/RootFolderItemView.js +++ /dev/null @@ -1,28 +0,0 @@ -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'AddSeries/RootFolders/RootFolderItemViewTemplate', - className : 'recent-folder', - tagName : 'tr', - - initialize : function() { - this.listenTo(this.model, 'change', this.render); - }, - - events : { - 'click .x-delete' : 'removeFolder', - 'click .x-folder' : 'folderSelected' - }, - - removeFolder : function() { - var self = this; - - this.model.destroy().success(function() { - self.close(); - }); - }, - - folderSelected : function() { - this.trigger('folderSelected', this.model); - } -}); \ No newline at end of file diff --git a/src/UI/AddSeries/RootFolders/RootFolderItemViewTemplate.hbs b/src/UI/AddSeries/RootFolders/RootFolderItemViewTemplate.hbs deleted file mode 100644 index 2203e1efd..000000000 --- a/src/UI/AddSeries/RootFolders/RootFolderItemViewTemplate.hbs +++ /dev/null @@ -1,9 +0,0 @@ -<td class="col-md-10 x-folder folder-path"> - {{path}} -</td> -<td class="col-md-3 x-folder folder-free-space"> - <span>{{Bytes freeSpace}}</span> -</td> -<td class="col-md-1"> - <i class="icon-sonarr-delete x-delete"></i> -</td> diff --git a/src/UI/AddSeries/RootFolders/RootFolderLayout.js b/src/UI/AddSeries/RootFolders/RootFolderLayout.js deleted file mode 100644 index d96b01a7b..000000000 --- a/src/UI/AddSeries/RootFolders/RootFolderLayout.js +++ /dev/null @@ -1,80 +0,0 @@ -var Marionette = require('marionette'); -var RootFolderCollectionView = require('./RootFolderCollectionView'); -var RootFolderCollection = require('./RootFolderCollection'); -var RootFolderModel = require('./RootFolderModel'); -var LoadingView = require('../../Shared/LoadingView'); -var AsValidatedView = require('../../Mixins/AsValidatedView'); -require('../../Mixins/FileBrowser'); - -var Layout = Marionette.Layout.extend({ - template : 'AddSeries/RootFolders/RootFolderLayoutTemplate', - - ui : { - pathInput : '.x-path' - }, - - regions : { - currentDirs : '#current-dirs' - }, - - events : { - 'click .x-add' : '_addFolder', - 'keydown .x-path input' : '_keydown' - }, - - initialize : function() { - this.collection = RootFolderCollection; - this.rootfolderListView = null; - }, - - onShow : function() { - this.listenTo(RootFolderCollection, 'sync', this._showCurrentDirs); - this.currentDirs.show(new LoadingView()); - - if (RootFolderCollection.synced) { - this._showCurrentDirs(); - } - - this.ui.pathInput.fileBrowser(); - }, - - _onFolderSelected : function(options) { - this.trigger('folderSelected', options); - }, - - _addFolder : function() { - var self = this; - - var newDir = new RootFolderModel({ - Path : this.ui.pathInput.val() - }); - - this.bindToModelValidation(newDir); - - newDir.save().done(function() { - RootFolderCollection.add(newDir); - self.trigger('folderSelected', { model : newDir }); - }); - }, - - _showCurrentDirs : function() { - if (!this.rootfolderListView) { - this.rootfolderListView = new RootFolderCollectionView({ collection : RootFolderCollection }); - this.currentDirs.show(this.rootfolderListView); - - this.listenTo(this.rootfolderListView, 'itemview:folderSelected', this._onFolderSelected); - } - }, - - _keydown : function(e) { - if (e.keyCode !== 13) { - return; - } - - this._addFolder(); - } -}); - -var Layout = AsValidatedView.apply(Layout); - -module.exports = Layout; diff --git a/src/UI/AddSeries/RootFolders/RootFolderLayoutTemplate.hbs b/src/UI/AddSeries/RootFolders/RootFolderLayoutTemplate.hbs deleted file mode 100644 index 83cb9535d..000000000 --- a/src/UI/AddSeries/RootFolders/RootFolderLayoutTemplate.hbs +++ /dev/null @@ -1,36 +0,0 @@ -<div class="modal-content"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - <h3>Select Folder</h3> - </div> - <div class="modal-body root-folders-modal"> - <div class="validation-errors"></div> - <div class="alert alert-info">Enter the path that contains some or all of your TV series, you will be able to choose which series you want to import<button type="button" class="close" data-dismiss="alert">×</button></div> - - <div class="row"> - <div class="form-group"> - - <div class="col-md-12"> - - <div class="input-group"> - <span class="input-group-addon"> <i class="icon-sonarr-folder-open"></i></span> - <input class="form-control x-path" type="text" validation-name="path" placeholder="Enter path to folder that contains your shows"> - <span class="input-group-btn"><button class="btn btn-success x-add"><i class="icon-sonarr-ok"/></button></span> - </div> - </div> - </div> - </div> - - <div class="row root-folders"> - <div class="col-md-12"> - {{#if items}} - <h4>Recent Folders</h4> - {{/if}} - <div id="current-dirs" class="root-folders-list"></div> - </div> - </div> - </div> - <div class="modal-footer"> - <button class="btn" data-dismiss="modal">Close</button> - </div> -</div> diff --git a/src/UI/AddSeries/RootFolders/RootFolderModel.js b/src/UI/AddSeries/RootFolders/RootFolderModel.js deleted file mode 100644 index 28681768b..000000000 --- a/src/UI/AddSeries/RootFolders/RootFolderModel.js +++ /dev/null @@ -1,8 +0,0 @@ -var Backbone = require('backbone'); - -module.exports = Backbone.Model.extend({ - urlRoot : window.NzbDrone.ApiRoot + '/rootfolder', - defaults : { - freeSpace : 0 - } -}); \ No newline at end of file diff --git a/src/UI/AddSeries/RootFolders/RootFolderSelectionPartial.hbs b/src/UI/AddSeries/RootFolders/RootFolderSelectionPartial.hbs deleted file mode 100644 index 56729b0dd..000000000 --- a/src/UI/AddSeries/RootFolders/RootFolderSelectionPartial.hbs +++ /dev/null @@ -1,11 +0,0 @@ -<select class="col-md-4 form-control x-root-folder" validation-name="RootFolderPath"> - {{#if this}} - {{#each this}} - <option value="{{id}}">{{path}}</option> - {{/each}} - {{else}} - <option value="">Select Path</option> - {{/if}} - <option value="addNew">Add a different path</option> -</select> - diff --git a/src/UI/AddSeries/SearchResultCollectionView.js b/src/UI/AddSeries/SearchResultCollectionView.js deleted file mode 100644 index e533085ac..000000000 --- a/src/UI/AddSeries/SearchResultCollectionView.js +++ /dev/null @@ -1,29 +0,0 @@ -var Marionette = require('marionette'); -var SearchResultView = require('./SearchResultView'); - -module.exports = Marionette.CollectionView.extend({ - itemView : SearchResultView, - - initialize : function(options) { - this.isExisting = options.isExisting; - this.showing = 1; - }, - - showAll : function() { - this.showingAll = true; - this.render(); - }, - - showMore : function() { - this.showing += 5; - this.render(); - - return this.showing >= this.collection.length; - }, - - appendHtml : function(collectionView, itemView, index) { - if (!this.isExisting || index < this.showing || index === 0) { - collectionView.$el.append(itemView.el); - } - } -}); \ No newline at end of file diff --git a/src/UI/AddSeries/SearchResultView.js b/src/UI/AddSeries/SearchResultView.js deleted file mode 100644 index 817ab78ea..000000000 --- a/src/UI/AddSeries/SearchResultView.js +++ /dev/null @@ -1,288 +0,0 @@ -var _ = require('underscore'); -var vent = require('vent'); -var AppLayout = require('../AppLayout'); -var Backbone = require('backbone'); -var Marionette = require('marionette'); -var Profiles = require('../Profile/ProfileCollection'); -var RootFolders = require('./RootFolders/RootFolderCollection'); -var RootFolderLayout = require('./RootFolders/RootFolderLayout'); -var SeriesCollection = require('../Series/SeriesCollection'); -var Config = require('../Config'); -var Messenger = require('../Shared/Messenger'); -var AsValidatedView = require('../Mixins/AsValidatedView'); - -require('jquery.dotdotdot'); - -var view = Marionette.ItemView.extend({ - - template : 'AddSeries/SearchResultViewTemplate', - - ui : { - profile : '.x-profile', - rootFolder : '.x-root-folder', - seasonFolder : '.x-season-folder', - seriesType : '.x-series-type', - monitor : '.x-monitor', - monitorTooltip : '.x-monitor-tooltip', - addButton : '.x-add', - addSearchButton : '.x-add-search', - overview : '.x-overview' - }, - - events : { - 'click .x-add' : '_addWithoutSearch', - 'click .x-add-search' : '_addAndSearch', - 'change .x-profile' : '_profileChanged', - 'change .x-root-folder' : '_rootFolderChanged', - 'change .x-season-folder' : '_seasonFolderChanged', - 'change .x-series-type' : '_seriesTypeChanged', - 'change .x-monitor' : '_monitorChanged' - }, - - initialize : function() { - - if (!this.model) { - throw 'model is required'; - } - - this.templateHelpers = {}; - this._configureTemplateHelpers(); - - this.listenTo(vent, Config.Events.ConfigUpdatedEvent, this._onConfigUpdated); - this.listenTo(this.model, 'change', this.render); - this.listenTo(RootFolders, 'all', this._rootFoldersUpdated); - }, - - onRender : function() { - - var defaultProfile = Config.getValue(Config.Keys.DefaultProfileId); - var defaultRoot = Config.getValue(Config.Keys.DefaultRootFolderId); - var useSeasonFolder = Config.getValueBoolean(Config.Keys.UseSeasonFolder, true); - var defaultSeriesType = Config.getValue(Config.Keys.DefaultSeriesType, 'standard'); - var defaultMonitorEpisodes = Config.getValue(Config.Keys.MonitorEpisodes, 'missing'); - - if (Profiles.get(defaultProfile)) { - this.ui.profile.val(defaultProfile); - } - - if (RootFolders.get(defaultRoot)) { - this.ui.rootFolder.val(defaultRoot); - } - - this.ui.seasonFolder.prop('checked', useSeasonFolder); - this.ui.seriesType.val(defaultSeriesType); - this.ui.monitor.val(defaultMonitorEpisodes); - - //TODO: make this work via onRender, FM? - //works with onShow, but stops working after the first render - this.ui.overview.dotdotdot({ - height : 120 - }); - - this.templateFunction = Marionette.TemplateCache.get('AddSeries/MonitoringTooltipTemplate'); - var content = this.templateFunction(); - - this.ui.monitorTooltip.popover({ - content : content, - html : true, - trigger : 'hover', - title : 'Episode Monitoring Options', - placement : 'right', - container : this.$el - }); - }, - - _configureTemplateHelpers : function() { - var existingSeries = SeriesCollection.where({ tvdbId : this.model.get('tvdbId') }); - - if (existingSeries.length > 0) { - this.templateHelpers.existing = existingSeries[0].toJSON(); - } - - this.templateHelpers.profiles = Profiles.toJSON(); - - if (!this.model.get('isExisting')) { - this.templateHelpers.rootFolders = RootFolders.toJSON(); - } - }, - - _onConfigUpdated : function(options) { - if (options.key === Config.Keys.DefaultProfileId) { - this.ui.profile.val(options.value); - } - - else if (options.key === Config.Keys.DefaultRootFolderId) { - this.ui.rootFolder.val(options.value); - } - - else if (options.key === Config.Keys.UseSeasonFolder) { - this.ui.seasonFolder.prop('checked', options.value); - } - - else if (options.key === Config.Keys.DefaultSeriesType) { - this.ui.seriesType.val(options.value); - } - - else if (options.key === Config.Keys.MonitorEpisodes) { - this.ui.monitor.val(options.value); - } - }, - - _profileChanged : function() { - Config.setValue(Config.Keys.DefaultProfileId, this.ui.profile.val()); - }, - - _seasonFolderChanged : function() { - Config.setValue(Config.Keys.UseSeasonFolder, this.ui.seasonFolder.prop('checked')); - }, - - _rootFolderChanged : function() { - var rootFolderValue = this.ui.rootFolder.val(); - if (rootFolderValue === 'addNew') { - var rootFolderLayout = new RootFolderLayout(); - this.listenToOnce(rootFolderLayout, 'folderSelected', this._setRootFolder); - AppLayout.modalRegion.show(rootFolderLayout); - } else { - Config.setValue(Config.Keys.DefaultRootFolderId, rootFolderValue); - } - }, - - _seriesTypeChanged : function() { - Config.setValue(Config.Keys.DefaultSeriesType, this.ui.seriesType.val()); - }, - - _monitorChanged : function() { - Config.setValue(Config.Keys.MonitorEpisodes, this.ui.monitor.val()); - }, - - _setRootFolder : function(options) { - vent.trigger(vent.Commands.CloseModalCommand); - this.ui.rootFolder.val(options.model.id); - this._rootFolderChanged(); - }, - - _addWithoutSearch : function() { - this._addSeries(false); - }, - - _addAndSearch : function() { - this._addSeries(true); - }, - - _addSeries : function(searchForMissingEpisodes) { - var addButton = this.ui.addButton; - var addSearchButton = this.ui.addSearchButton; - - addButton.addClass('disabled'); - addSearchButton.addClass('disabled'); - - var profile = this.ui.profile.val(); - var rootFolderPath = this.ui.rootFolder.children(':selected').text(); - var seriesType = this.ui.seriesType.val(); - var seasonFolder = this.ui.seasonFolder.prop('checked'); - - var options = this._getAddSeriesOptions(); - options.searchForMissingEpisodes = searchForMissingEpisodes; - - this.model.set({ - profileId : profile, - rootFolderPath : rootFolderPath, - seasonFolder : seasonFolder, - seriesType : seriesType, - addOptions : options, - monitored : true - }, { silent : true }); - - var self = this; - var promise = this.model.save(); - - if (searchForMissingEpisodes) { - this.ui.addSearchButton.spinForPromise(promise); - } - - else { - this.ui.addButton.spinForPromise(promise); - } - - promise.always(function() { - addButton.removeClass('disabled'); - addSearchButton.removeClass('disabled'); - }); - - promise.done(function() { - SeriesCollection.add(self.model); - - self.close(); - - Messenger.show({ - message : 'Added: ' + self.model.get('title'), - actions : { - goToSeries : { - label : 'Go to Series', - action : function() { - Backbone.history.navigate('/series/' + self.model.get('titleSlug'), { trigger : true }); - } - } - }, - hideAfter : 8, - hideOnNavigate : true - }); - - vent.trigger(vent.Events.SeriesAdded, { series : self.model }); - }); - }, - - _rootFoldersUpdated : function() { - this._configureTemplateHelpers(); - this.render(); - }, - - _getAddSeriesOptions : function() { - var monitor = this.ui.monitor.val(); - var lastSeason = _.max(this.model.get('seasons'), 'seasonNumber'); - var firstSeason = _.min(_.reject(this.model.get('seasons'), { seasonNumber : 0 }), 'seasonNumber'); - - this.model.setSeasonPass(firstSeason.seasonNumber); - - var options = { - ignoreEpisodesWithFiles : false, - ignoreEpisodesWithoutFiles : false - }; - - if (monitor === 'all') { - return options; - } - - else if (monitor === 'future') { - options.ignoreEpisodesWithFiles = true; - options.ignoreEpisodesWithoutFiles = true; - } - - else if (monitor === 'latest') { - this.model.setSeasonPass(lastSeason.seasonNumber); - } - - else if (monitor === 'first') { - this.model.setSeasonPass(lastSeason.seasonNumber + 1); - this.model.setSeasonMonitored(firstSeason.seasonNumber); - } - - else if (monitor === 'missing') { - options.ignoreEpisodesWithFiles = true; - } - - else if (monitor === 'existing') { - options.ignoreEpisodesWithoutFiles = true; - } - - else if (monitor === 'none') { - this.model.setSeasonPass(lastSeason.seasonNumber + 1); - } - - return options; - } -}); - -AsValidatedView.apply(view); - -module.exports = view; diff --git a/src/UI/AddSeries/SearchResultViewTemplate.hbs b/src/UI/AddSeries/SearchResultViewTemplate.hbs deleted file mode 100644 index 2eafdf2b0..000000000 --- a/src/UI/AddSeries/SearchResultViewTemplate.hbs +++ /dev/null @@ -1,109 +0,0 @@ -<div class="search-item {{#unless isExisting}}search-item-new{{/unless}}"> - <div class="row"> - <div class="col-md-2"> - <a href="{{tvdbUrl}}" target="_blank"> - {{poster}} - </a> - </div> - <div class="col-md-10"> - <div class="row"> - <div class="col-md-12"> - <h2 class="series-title"> - {{titleWithYear}} - - <span class="labels"> - <span class="label label-default">{{network}}</span> - {{#unless_eq status compare="continuing"}} - <span class="label label-danger">Ended</span> - {{/unless_eq}} - </span> - </h2> - </div> - </div> - <div class="row new-series-overview x-overview"> - <div class="col-md-12 overview-internal"> - {{overview}} - </div> - </div> - <div class="row"> - {{#unless existing}} - {{#unless path}} - <div class="form-group col-md-4"> - <label>Path</label> - {{> RootFolderSelectionPartial rootFolders}} - </div> - {{/unless}} - - <div class="form-group col-md-2"> - <label>Monitor <i class="icon-sonarr-form-info monitor-tooltip x-monitor-tooltip"></i></label> - <select class="form-control col-md-2 x-monitor"> - <option value="all">All</option> - <option value="future">Future</option> - <option value="missing">Missing</option> - <option value="existing">Existing</option> - <option value="first">First Season</option> - <option value="latest">Latest Season</option> - <option value="none">None</option> - </select> - </div> - - <div class="form-group col-md-2"> - <label>Profile</label> - {{> ProfileSelectionPartial profiles}} - </div> - - <div class="form-group col-md-2"> - <label>Series Type</label> - {{> SeriesTypeSelectionPartial}} - </div> - - <div class="form-group col-md-2"> - <label>Season Folders</label> - - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" class="x-season-folder"/> - <p> - <span>Yes</span> - <span>No</span> - </p> - <div class="btn btn-primary slide-button"/> - </label> - </div> - </div> - {{/unless}} - </div> - <div class="row"> - {{#unless existing}} - {{#if title}} - <div class="form-group col-md-2 col-md-offset-10"> - <!--Uncomment if we need to add even more controls to add series--> - <!--<label style="visibility: hidden">Add</label>--> - <div class="btn-group"> - <button class="btn btn-success add x-add" title="Add"> - <i class="icon-sonarr-add"></i> - </button> - - <button class="btn btn-success add x-add-search" title="Add and Search for missing episodes"> - <i class="icon-sonarr-search"></i> - </button> - </div> - </div> - {{else}} - <div class="col-md-2 col-md-offset-10" title="Series requires an English title"> - <button class="btn add-series disabled"> - Add - </button> - </div> - {{/if}} - {{else}} - <div class="col-md-2 col-md-offset-10"> - <a class="btn btn-default" href="{{route}}"> - Already Exists - </a> - </div> - {{/unless}} - </div> - </div> - </div> -</div> diff --git a/src/UI/AddSeries/SeriesTypeSelectionPartial.hbs b/src/UI/AddSeries/SeriesTypeSelectionPartial.hbs deleted file mode 100644 index ec2990640..000000000 --- a/src/UI/AddSeries/SeriesTypeSelectionPartial.hbs +++ /dev/null @@ -1,5 +0,0 @@ -<select class="form-control col-md-2 x-series-type" name="seriesType"> - <option value="standard">Standard</option> - <option value="daily">Daily</option> - <option value="anime">Anime</option> -</select> diff --git a/src/UI/AddSeries/StartingSeasonSelectionPartial.hbs b/src/UI/AddSeries/StartingSeasonSelectionPartial.hbs deleted file mode 100644 index e5623e33a..000000000 --- a/src/UI/AddSeries/StartingSeasonSelectionPartial.hbs +++ /dev/null @@ -1,13 +0,0 @@ -<select class="form-control col-md-2 starting-season x-starting-season"> - - - {{#each this}} - {{#if_eq seasonNumber compare="0"}} - <option value="{{seasonNumber}}">Specials</option> - {{else}} - <option value="{{seasonNumber}}">Season {{seasonNumber}}</option> - {{/if_eq}} - {{/each}} - - <option value="5000000">None</option> -</select> diff --git a/src/UI/AddSeries/addSeries.less b/src/UI/AddSeries/addSeries.less deleted file mode 100644 index 2ca8090f9..000000000 --- a/src/UI/AddSeries/addSeries.less +++ /dev/null @@ -1,173 +0,0 @@ -@import "../Shared/Styles/card.less"; -@import "../Shared/Styles/clickable.less"; - -#add-series-screen { - .existing-series { - - .card(); - margin : 30px 0px; - - .unmapped-folder-path { - padding: 20px; - margin-left : 0px; - font-weight : 100; - font-size : 25px; - text-align : center; - } - - .new-series-loadmore { - font-size : 30px; - font-weight : 300; - padding-top : 10px; - padding-bottom : 10px; - } - } - - .new-series { - .search-item { - .card(); - margin : 40px 0px; - } - } - - .add-series-search { - margin-top : 20px; - margin-bottom : 20px; - } - - .search-item { - - padding-bottom : 20px; - - .series-title { - margin-top : 5px; - - .labels { - margin-left : 10px; - - .label { - font-size : 12px; - vertical-align : middle; - } - } - - .year { - font-style : italic; - color : #aaaaaa; - } - } - - .new-series-overview { - overflow : hidden; - height : 103px; - - .overview-internal { - overflow : hidden; - height : 80px; - } - } - - .series-poster { - min-width : 138px; - min-height : 203px; - max-width : 138px; - max-height : 203px; - margin : 10px; - } - - a { - color : #343434; - } - - a:hover { - text-decoration : none; - } - - select { - font-size : 14px; - } - - .checkbox { - margin-top : 0px; - } - - .add { - i { - &:before { - color : #ffffff; - } - } - } - - .monitor-tooltip { - margin-left : 5px; - } - } - - .loading-folders { - margin : 30px 0px; - text-align: center; - } - - .hint { - color : #999999; - font-style : italic; - } - - .monitor-tooltip-contents { - padding-bottom : 0px; - - dd { - padding-bottom : 8px; - } - } -} - -li.add-new { - .clickable; - - display: block; - padding: 3px 20px; - clear: both; - font-weight: normal; - line-height: 20px; - color: rgb(51, 51, 51); - white-space: nowrap; -} - -li.add-new:hover { - text-decoration: none; - color: rgb(255, 255, 255); - background-color: rgb(0, 129, 194); -} - -.root-folders-modal { - overflow : visible; - - .root-folders-list { - overflow-y : auto; - max-height : 300px; - - i { - .clickable(); - } - } - - .validation-errors { - display : none; - } - - .input-group { - .form-control { - background-color : white; - } - } - - .root-folders { - margin-top : 20px; - } - - .recent-folder { - .clickable(); - } -} diff --git a/src/UI/AppLayout.js b/src/UI/AppLayout.js deleted file mode 100644 index 862961423..000000000 --- a/src/UI/AppLayout.js +++ /dev/null @@ -1,20 +0,0 @@ -var Marionette = require('marionette'); -var ModalRegion = require('./Shared/Modal/ModalRegion'); -var ModalRegion2 = require('./Shared/Modal/ModalRegion2'); -var ControlPanelRegion = require('./Shared/ControlPanel/ControlPanelRegion'); - -var Layout = Marionette.Layout.extend({ - regions : { - navbarRegion : '#nav-region', - mainRegion : '#main-region' - }, - - initialize : function() { - this.addRegions({ - modalRegion : ModalRegion, - modalRegion2 : ModalRegion2, - controlPanelRegion : ControlPanelRegion - }); - } -}); -module.exports = new Layout({ el : 'body' }); \ No newline at end of file diff --git a/src/UI/Calendar/CalendarCollection.js b/src/UI/Calendar/CalendarCollection.js deleted file mode 100644 index 12739955c..000000000 --- a/src/UI/Calendar/CalendarCollection.js +++ /dev/null @@ -1,14 +0,0 @@ -var Backbone = require('backbone'); -var EpisodeModel = require('../Series/EpisodeModel'); - -module.exports = Backbone.Collection.extend({ - url : window.NzbDrone.ApiRoot + '/calendar', - model : EpisodeModel, - tableName : 'calendar', - - comparator : function(model) { - var date = new Date(model.get('airDateUtc')); - var time = date.getTime(); - return time; - } -}); \ No newline at end of file diff --git a/src/UI/Calendar/CalendarFeedView.js b/src/UI/Calendar/CalendarFeedView.js deleted file mode 100644 index 96bf3f518..000000000 --- a/src/UI/Calendar/CalendarFeedView.js +++ /dev/null @@ -1,60 +0,0 @@ -var Marionette = require('marionette'); -var StatusModel = require('../System/StatusModel'); -require('../Mixins/CopyToClipboard'); -require('../Mixins/TagInput'); - -module.exports = Marionette.Layout.extend({ - template : 'Calendar/CalendarFeedViewTemplate', - - ui : { - includeUnmonitored : '.x-includeUnmonitored', - premiersOnly : '.x-premiersOnly', - asAllDay : '.x-asAllDay', - tags : '.x-tags', - icalUrl : '.x-ical-url', - icalCopy : '.x-ical-copy', - icalWebCal : '.x-ical-webcal' - }, - - events : { - 'click .x-includeUnmonitored' : '_updateUrl', - 'click .x-premiersOnly' : '_updateUrl', - 'click .x-asAllDay' : '_updateUrl', - 'itemAdded .x-tags' : '_updateUrl', - 'itemRemoved .x-tags' : '_updateUrl' - }, - - onShow : function() { - this._updateUrl(); - this.ui.icalCopy.copyToClipboard(this.ui.icalUrl); - this.ui.tags.tagInput({ allowNew: false }); - }, - - _updateUrl : function() { - var icalUrl = window.location.host + StatusModel.get('urlBase') + '/feed/calendar/Sonarr.ics?'; - - if (this.ui.includeUnmonitored.prop('checked')) { - icalUrl += 'unmonitored=true&'; - } - - if (this.ui.premiersOnly.prop('checked')) { - icalUrl += 'premiersOnly=true&'; - } - - if (this.ui.asAllDay.prop('checked')) { - icalUrl += 'asAllDay=true&'; - } - - if (this.ui.tags.val()) { - icalUrl += 'tags=' + this.ui.tags.val() + '&'; - } - - icalUrl += 'apikey=' + window.NzbDrone.ApiKey; - - var icalHttpUrl = window.location.protocol + '//' + icalUrl; - var icalWebCalUrl = 'webcal://' + icalUrl; - - this.ui.icalUrl.attr('value', icalHttpUrl); - this.ui.icalWebCal.attr('href', icalWebCalUrl); - } -}); \ No newline at end of file diff --git a/src/UI/Calendar/CalendarFeedViewTemplate.hbs b/src/UI/Calendar/CalendarFeedViewTemplate.hbs deleted file mode 100644 index 763ac5b10..000000000 --- a/src/UI/Calendar/CalendarFeedViewTemplate.hbs +++ /dev/null @@ -1,93 +0,0 @@ -<div class="modal-content"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - <h3>Sonarr Calendar feed</h3> - </div> - <div class="modal-body edit-series-modal"> - <div class="form-horizontal"> - <div class="form-group"> - <label class="col-sm-3 control-label">Include Unmonitored</label> - - <div class="col-sm-4"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="includeUnmonitored" class="form-control x-includeUnmonitored"/> - - <p> - <span>Yes</span> - <span>No</span> - </p> - - <div class="btn btn-primary slide-button"/> - </label> - </div> - </div> - </div> - <div class="form-group"> - <label class="col-sm-3 control-label">Season Premiers Only</label> - - <div class="col-sm-4"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="premiersOnly" class="form-control x-premiersOnly"/> - - <p> - <span>Yes</span> - <span>No</span> - </p> - - <div class="btn btn-primary slide-button"/> - </label> - </div> - </div> - </div> - <div class="form-group"> - <label class="col-sm-3 control-label">Show as All-Day Events</label> - - <div class="col-sm-4"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="asAllDay" class="form-control x-asAllDay"/> - - <p> - <span>Yes</span> - <span>No</span> - </p> - - <div class="btn btn-primary slide-button"/> - </label> - </div> - </div> - </div> - <div class="form-group"> - <label class="col-sm-3 control-label">Tags</label> - - <div class="col-sm-1 col-sm-push-5 help-inline"> - <i class="icon-sonarr-form-info" title="One or more tags only show matching series" /> - </div> - - <div class="col-sm-5 col-sm-pull-1"> - <input type="text" class="form-control x-tags"> - </div> - </div> - <div class="form-group"> - <label class="col-sm-3 control-label">iCal feed</label> - <div class="col-sm-1 col-sm-push-8 help-inline"> - <i class="icon-sonarr-form-info" title="Copy this url into your clients subscription form or use the subscribe button if your browser support webcal" /> - </div> - <div class="col-sm-8 col-sm-pull-1"> - <div class="input-group ical-url"> - <input type="text" class="form-control x-ical-url" readonly="readonly" /> - <div class="input-group-btn"> - <button class="btn btn-icon-only x-ical-copy"><i class="icon-sonarr-copy"></i></button> - <button class="btn btn-icon-only no-router x-ical-webcal" title="Subscribe" target="_blank"><i class="icon-sonarr-calendar-o"></i></button> - </div> - </div> - </div> - </div> - </div> - </div> - <div class="modal-footer"> - <button class="btn" data-dismiss="modal">Close</button> - </div> -</div> diff --git a/src/UI/Calendar/CalendarLayout.js b/src/UI/Calendar/CalendarLayout.js deleted file mode 100644 index 15aa74dfe..000000000 --- a/src/UI/Calendar/CalendarLayout.js +++ /dev/null @@ -1,96 +0,0 @@ -var AppLayout = require('../AppLayout'); -var Marionette = require('marionette'); -var UpcomingCollectionView = require('./UpcomingCollectionView'); -var CalendarView = require('./CalendarView'); -var CalendarFeedView = require('./CalendarFeedView'); -var ToolbarLayout = require('../Shared/Toolbar/ToolbarLayout'); - -module.exports = Marionette.Layout.extend({ - template : 'Calendar/CalendarLayoutTemplate', - - regions : { - upcoming : '#x-upcoming', - calendar : '#x-calendar', - toolbar : '#x-toolbar' - }, - - onShow : function() { - this._showUpcoming(); - this._showCalendar(); - this._showToolbar(); - }, - - _showUpcoming : function() { - this.upcomingView = new UpcomingCollectionView(); - this.upcoming.show(this.upcomingView); - }, - - _showCalendar : function() { - this.calendarView = new CalendarView(); - this.calendar.show(this.calendarView); - }, - - _showiCal : function() { - var view = new CalendarFeedView(); - AppLayout.modalRegion.show(view); - }, - - _showToolbar : function() { - var leftSideButtons = { - type : 'default', - storeState : false, - items : [ - { - title : 'Get iCal Link', - icon : 'icon-sonarr-calendar-o', - callback : this._showiCal, - ownerContext : this - } - ] - }; - - var filterOptions = { - type : 'radio', - storeState : true, - menuKey : 'calendar.show', - defaultAction : 'monitored', - items : [ - { - key : 'all', - title : '', - tooltip : 'All', - icon : 'icon-sonarr-all', - callback : this._setCalendarFilter - }, - { - key : 'monitored', - title : '', - tooltip : 'Monitored Only', - icon : 'icon-sonarr-monitored', - callback : this._setCalendarFilter - } - ] - }; - - this.toolbar.show(new ToolbarLayout({ - left : [leftSideButtons], - right : [filterOptions], - context : this, - floatOnMobile : true - })); - }, - - _setCalendarFilter : function(buttonContext) { - var mode = buttonContext.model.get('key'); - - if (mode === 'all') { - this.calendarView.setShowUnmonitored(true); - this.upcomingView.setShowUnmonitored(true); - } - - else { - this.calendarView.setShowUnmonitored(false); - this.upcomingView.setShowUnmonitored(false); - } - } -}); \ No newline at end of file diff --git a/src/UI/Calendar/CalendarLayoutTemplate.hbs b/src/UI/Calendar/CalendarLayoutTemplate.hbs deleted file mode 100644 index db8c097be..000000000 --- a/src/UI/Calendar/CalendarLayoutTemplate.hbs +++ /dev/null @@ -1,23 +0,0 @@ -<div class="row"> - <div class="col-md-3 hidden-xs"> - <div class="pull-left"> - <h4>Upcoming</h4> - </div> - <div id="x-upcoming"/> - </div> - <div class="col-md-9 col-xs-12"> - <div id="x-toolbar" class="calendar-toolbar"/> - <div id="x-calendar" class="calendar"/> - <div class="legend calendar"> - <ul class='legend-labels'> - <li class="legend-label"><span class="premiere" title="Premiere episode hasn't aired yet"></span>Unaired Premiere</li> - <li class="legend-label"><span class="primary" title="Episode hasn't aired yet"></span>Unaired</li> - <li class="legend-label"><span class="warning" title="Episode is currently airing"></span>On Air</li> - <li class="legend-label"><span class="purple" title="Episode is currently downloading"></span>Downloading</li> - <li class="legend-label"><span class="danger" title="Episode file has not been found"></span>Missing</li> - <li class="legend-label"><span class="success" title="Episode was downloaded and sorted"></span>Downloaded</li> - <li class="legend-label"><span class="unmonitored" title="Episode is unmonitored"></span>Unmonitored</li> - </ul> - </div> - </div> -</div> diff --git a/src/UI/Calendar/CalendarView.js b/src/UI/Calendar/CalendarView.js deleted file mode 100644 index 871db9343..000000000 --- a/src/UI/Calendar/CalendarView.js +++ /dev/null @@ -1,284 +0,0 @@ -var $ = require('jquery'); -var vent = require('vent'); -var Marionette = require('marionette'); -var moment = require('moment'); -var CalendarCollection = require('./CalendarCollection'); -var UiSettings = require('../Shared/UiSettingsModel'); -var QueueCollection = require('../Activity/Queue/QueueCollection'); -var Config = require('../Config'); - -require('../Mixins/backbone.signalr.mixin'); -require('fullcalendar'); -require('jquery.easypiechart'); - -module.exports = Marionette.ItemView.extend({ - storageKey : 'calendar.view', - - initialize : function() { - this.showUnmonitored = Config.getValue('calendar.show', 'monitored') === 'all'; - this.collection = new CalendarCollection().bindSignalR({ updateOnly : true }); - this.listenTo(this.collection, 'change', this._reloadCalendarEvents); - this.listenTo(QueueCollection, 'sync', this._reloadCalendarEvents); - }, - - render : function() { - this.$el.empty().fullCalendar(this._getOptions()); - }, - - onShow : function() { - this.$('.fc-today-button').click(); - }, - - setShowUnmonitored : function (showUnmonitored) { - if (this.showUnmonitored !== showUnmonitored) { - this.showUnmonitored = showUnmonitored; - this._getEvents(this.$el.fullCalendar('getView')); - } - }, - - _viewRender : function(view, element) { - if (Config.getValue(this.storageKey) !== view.name) { - Config.setValue(this.storageKey, view.name); - } - - this._getEvents(view); - element.find('.fc-day-grid-container').css('height', ''); - }, - - _eventRender : function(event, element) { - element.addClass(event.statusLevel); - element.children('.fc-content').addClass(event.statusLevel); - - if (event.downloading) { - var progress = 100 - event.downloading.get('sizeleft') / event.downloading.get('size') * 100; - var releaseTitle = event.downloading.get('title'); - var estimatedCompletionTime = moment(event.downloading.get('estimatedCompletionTime')).fromNow(); - var status = event.downloading.get('status').toLocaleLowerCase(); - var errorMessage = event.downloading.get('errorMessage'); - - if (status === 'pending') { - this._addStatusIcon(element, 'icon-sonarr-pending', 'Release will be processed {0}'.format(estimatedCompletionTime)); - } - - else if (errorMessage) { - if (status === 'completed') { - this._addStatusIcon(element, 'icon-sonarr-import-failed', 'Import failed: {0}'.format(errorMessage)); - } else { - this._addStatusIcon(element, 'icon-sonarr-download-failed', 'Download failed: {0}'.format(errorMessage)); - } - } - - else if (status === 'failed') { - this._addStatusIcon(element, 'icon-sonarr-download-failed', 'Download failed: check download client for more details'); - } - - else if (status === 'warning') { - this._addStatusIcon(element, 'icon-sonarr-download-warning', 'Download warning: check download client for more details'); - } - - else { - element.find('.fc-time').after('<span class="chart pull-right" data-percent="{0}"></span>'.format(progress)); - - element.find('.chart').easyPieChart({ - barColor : '#ffffff', - trackColor : false, - scaleColor : false, - lineWidth : 2, - size : 14, - animate : false - }); - - element.find('.chart').tooltip({ - title : 'Episode is downloading - {0}% {1}'.format(progress.toFixed(1), releaseTitle), - container : '.fc' - }); - } - } - - else if (event.model.get('unverifiedSceneNumbering')) { - this._addStatusIcon(element, 'icon-sonarr-form-warning', 'Scene number hasn\'t been verified yet.'); - } - - else if (event.model.get('series').seriesType === 'anime' && event.model.get('seasonNumber') > 0 && !event.model.has('absoluteEpisodeNumber')) { - this._addStatusIcon(element, 'icon-sonarr-form-warning', 'Episode does not have an absolute episode number'); - } - }, - - _eventAfterAllRender : function () { - if ($(window).width() < 768) { - this.$('.fc-center').show(); - this.$('.calendar-title').remove(); - - var title = this.$('.fc-center').html(); - var titleDiv = '<div class="calendar-title">{0}</div>'.format(title); - - this.$('.fc-toolbar').before(titleDiv); - this.$('.fc-center').hide(); - } - - this._clearScrollBar(); - }, - - _windowResize : function () { - this._clearScrollBar(); - }, - - _getEvents : function(view) { - var start = moment(view.start.toISOString()).toISOString(); - var end = moment(view.end.toISOString()).toISOString(); - - this.$el.fullCalendar('removeEvents'); - - this.collection.fetch({ - data : { - start : start, - end : end, - unmonitored : this.showUnmonitored - }, - success : this._setEventData.bind(this) - }); - }, - - _setEventData : function(collection) { - if (collection.length === 0) { - return; - } - - var events = []; - var self = this; - - collection.each(function(model) { - 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 event = { - title : seriesTitle, - start : moment(start), - end : moment(end), - allDay : false, - statusLevel : self._getStatusLevel(model, end), - downloading : QueueCollection.findEpisode(model.get('id')), - model : model, - sortOrder : (model.get('seasonNumber') === 0 ? 1000000 : model.get('seasonNumber') * 10000) + model.get('episodeNumber') - }; - - events.push(event); - }); - - this.$el.fullCalendar('addEventSource', events); - }, - - _getStatusLevel : function(element, endTime) { - var hasFile = element.get('hasFile'); - var downloading = QueueCollection.findEpisode(element.get('id')) || element.get('grabbed'); - var currentTime = moment(); - var start = moment(element.get('airDateUtc')); - var end = moment(endTime); - var monitored = element.get('series').monitored && element.get('monitored'); - - var statusLevel = 'primary'; - - if (hasFile) { - statusLevel = 'success'; - } - - else if (downloading) { - statusLevel = 'purple'; - } - - else if (!monitored) { - statusLevel = 'unmonitored'; - } - - else if (currentTime.isAfter(start) && currentTime.isBefore(end)) { - statusLevel = 'warning'; - } - - else if (start.isBefore(currentTime) && !hasFile) { - statusLevel = 'danger'; - } - - else if (element.get('episodeNumber') === 1) { - statusLevel = 'premiere'; - } - - if (end.isBefore(currentTime.startOf('day'))) { - statusLevel += ' past'; - } - - return statusLevel; - }, - - _reloadCalendarEvents : function() { - this.$el.fullCalendar('removeEvents'); - this._setEventData(this.collection); - }, - - _getOptions : function() { - var options = { - allDayDefault : false, - weekMode : 'variable', - firstDay : UiSettings.get('firstDayOfWeek'), - timeFormat : 'h(:mm)t', - viewRender : this._viewRender.bind(this), - eventRender : this._eventRender.bind(this), - eventAfterAllRender : this._eventAfterAllRender.bind(this), - windowResize : this._windowResize.bind(this), - eventClick : function(event) { - vent.trigger(vent.Commands.ShowEpisodeDetails, { episode : event.model }); - } - }; - - if ($(window).width() < 768) { - options.defaultView = Config.getValue(this.storageKey, 'basicDay'); - - options.header = { - left : 'prev,next today', - center : 'title', - right : 'basicWeek,basicDay' - }; - } - - else { - options.defaultView = Config.getValue(this.storageKey, 'basicWeek'); - - options.header = { - left : 'prev,next today', - center : 'title', - right : 'month,basicWeek,basicDay' - }; - } - - options.titleFormat = { - month : 'MMMM YYYY', - week : UiSettings.get('shortDateFormat'), - day : UiSettings.get('longDateFormat') - }; - - options.columnFormat = { - month : 'ddd', - week : UiSettings.get('calendarWeekColumnHeader'), - day : 'dddd' - }; - - options.timeFormat = UiSettings.get('timeFormat'); - - return options; - }, - - _addStatusIcon : function(element, icon, tooltip) { - element.find('.fc-time').after('<span class="status pull-right"><i class="{0}"></i></span>'.format(icon)); - element.find('.status').tooltip({ - title : tooltip, - container : '.fc' - }); - }, - - _clearScrollBar : function () { - // Remove height from calendar so we don't have another scroll bar - 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/UpcomingCollection.js b/src/UI/Calendar/UpcomingCollection.js deleted file mode 100644 index 5c0e9542e..000000000 --- a/src/UI/Calendar/UpcomingCollection.js +++ /dev/null @@ -1,28 +0,0 @@ -var Backbone = require('backbone'); -var moment = require('moment'); -var EpisodeModel = require('../Series/EpisodeModel'); - -module.exports = Backbone.Collection.extend({ - url : window.NzbDrone.ApiRoot + '/calendar', - model : EpisodeModel, - - comparator : function(model1, model2) { - var airDate1 = model1.get('airDateUtc'); - var date1 = moment(airDate1); - var time1 = date1.unix(); - - var airDate2 = model2.get('airDateUtc'); - var date2 = moment(airDate2); - var time2 = date2.unix(); - - if (time1 < time2) { - return -1; - } - - if (time1 > time2) { - return 1; - } - - return 0; - } -}); \ No newline at end of file diff --git a/src/UI/Calendar/UpcomingCollectionView.js b/src/UI/Calendar/UpcomingCollectionView.js deleted file mode 100644 index 9a8944f3d..000000000 --- a/src/UI/Calendar/UpcomingCollectionView.js +++ /dev/null @@ -1,34 +0,0 @@ -var _ = require('underscore'); -var Marionette = require('marionette'); -var UpcomingCollection = require('./UpcomingCollection'); -var UpcomingItemView = require('./UpcomingItemView'); -var Config = require('../Config'); -require('../Mixins/backbone.signalr.mixin'); - -module.exports = Marionette.CollectionView.extend({ - itemView : UpcomingItemView, - - initialize : function() { - this.showUnmonitored = Config.getValue('calendar.show', 'monitored') === 'all'; - this.collection = new UpcomingCollection().bindSignalR({ updateOnly : true }); - this._fetchCollection(); - - this._fetchCollection = _.bind(this._fetchCollection, this); - this.timer = window.setInterval(this._fetchCollection, 60 * 60 * 1000); - }, - - onClose : function() { - window.clearInterval(this.timer); - }, - - setShowUnmonitored : function (showUnmonitored) { - if (this.showUnmonitored !== showUnmonitored) { - this.showUnmonitored = showUnmonitored; - this._fetchCollection(); - } - }, - - _fetchCollection : function() { - this.collection.fetch({ data: { unmonitored : this.showUnmonitored }}); - } -}); \ No newline at end of file diff --git a/src/UI/Calendar/UpcomingItemView.js b/src/UI/Calendar/UpcomingItemView.js deleted file mode 100644 index f0b8eb18c..000000000 --- a/src/UI/Calendar/UpcomingItemView.js +++ /dev/null @@ -1,28 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); -var moment = require('moment'); - -module.exports = Marionette.ItemView.extend({ - template : 'Calendar/UpcomingItemViewTemplate', - tagName : 'div', - - events : { - 'click .x-episode-title' : '_showEpisodeDetails' - }, - - initialize : function() { - var start = this.model.get('airDateUtc'); - var runtime = this.model.get('series').runtime; - var end = moment(start).add('minutes', runtime); - - this.model.set({ - end : end.toISOString() - }); - - this.listenTo(this.model, 'change', this.render); - }, - - _showEpisodeDetails : function() { - vent.trigger(vent.Commands.ShowEpisodeDetails, { episode : this.model }); - } -}); \ No newline at end of file diff --git a/src/UI/Calendar/UpcomingItemViewTemplate.hbs b/src/UI/Calendar/UpcomingItemViewTemplate.hbs deleted file mode 100644 index eae2491bd..000000000 --- a/src/UI/Calendar/UpcomingItemViewTemplate.hbs +++ /dev/null @@ -1,18 +0,0 @@ -<div class="event"> - <div class="date {{StatusLevel}}"> - <h1>{{Day airDateUtc}}</h1> - <h4>{{Month airDateUtc}}</h4> - </div> - {{#with series}} - <a href="{{route}}"> - <h4>{{title}}</h4> - </a> - {{/with}} - <p>{{StartTime airDateUtc}} {{#unless_today airDateUtc}}{{ShortDate airDateUtc}}{{/unless_today}}</p> - <p> - <span class="episode-title x-episode-title"> - {{title}} - </span> - <span class="pull-right">{{seasonNumber}}x{{Pad2 episodeNumber}}</span> - </p> -</div> diff --git a/src/UI/Calendar/calendar.less b/src/UI/Calendar/calendar.less deleted file mode 100644 index d836c6720..000000000 --- a/src/UI/Calendar/calendar.less +++ /dev/null @@ -1,254 +0,0 @@ -@import "../Content/Bootstrap/mixins"; -@import "../Content/Bootstrap/variables"; -@import "../Content/Bootstrap/buttons"; -@import "../Shared/Styles/clickable"; -@import "../Content/variables"; -@import "../Content/mixins"; -@import "../Content/Overrides/bootstrap"; - -.calendar { - width: 100%; - - th, td { - border-color : #eeeeee; - } - - .fc-event-skin { - background-color : #007ccd; - border : 1px solid #007ccd; - border-radius : 4px; - text-align : center; - } - - .fc-event { - .clickable; - - .status { - margin-right : 4px; - } - } - - th { - background-color : #eeeeee; - } - - h2 { - font-size : 17.5px; - } - - .fc-state-highlight { - background : #dbdbdb; - } - - .past { - opacity : 0.8; - } -} - -.event { - display : inline-block; - width : 100%; - margin-bottom : 10px; - border-top : 1px solid #eeeeee; - padding-top : 10px; - - h4 { - font-weight : 500; - color : #008dcd; - margin : 5px 0px; - } - - p { - color : #999999; - margin : 0px; - } - - .date { - text-align : center; - display : inline-block; - border-left : 4px solid #eeeeee; - padding-left : 16px; - float : left; - margin-right : 20px; - - h4 { - line-height : 1em; - color : #555555; - font-weight : 300; - text-transform : uppercase; - } - - h1 { - font-weight : 500; - line-height : 0.8em; - } - } - - .primary { - border-color : @btn-primary-bg; - } - - .info { - border-color : @btn-info-bg; - } - - .inverse { - border-color : @btn-link-disabled-color; - } - - .warning { - border-color : @btn-warning-bg; - } - - .danger { - border-color : @btn-danger-bg; - } - - .success { - border-color : @btn-success-bg; - } - - .purple { - border-color : @nzbdronePurple; - } - - .pink { - border-color : @nzbdronePink; - } - - .premiere { - border-color : @droneTeal; - } - - .unmonitored { - border-color : grey; - } - - .episode-title { - .btn-link; - .text-overflow; - color : @link-color; - margin-top : 1px; - display : inline-block; - - @media (max-width: @screen-xs-min) { - width : 140px; - } - - @media (min-width: @screen-md-min) { - width : 135px; - } - } -} - -.calendar { - -// background-position : -160px -128px; - - .primary { - border-color : @btn-primary-bg; - background-color : @btn-primary-bg; - - .color-impaired-background-gradient(90deg, @btn-primary-bg); - } - - .info { - border-color : @btn-info-bg; - background-color : @btn-info-bg; - } - - .inverse { - border-color : @btn-link-disabled-color; - background-color : @btn-link-disabled-color; - } - - .warning { - border-color : @btn-warning-bg; - background-color : @btn-warning-bg; - - .color-impaired-background-gradient(90deg, @btn-warning-bg); - } - - .danger { - border-color : @btn-danger-bg; - background-color : @btn-danger-bg; - - .color-impaired-background-gradient(90deg, @btn-danger-bg); - } - - .success { - border-color : @btn-success-bg; - background-color : @btn-success-bg; - } - - .purple { - border-color : @nzbdronePurple; - background-color : @nzbdronePurple; - } - - .pink { - border-color : @nzbdronePink; - background-color : @nzbdronePink; - } - - .premiere { - border-color : @droneTeal; - background-color : @droneTeal; - - .color-impaired-background-gradient(90deg, @droneTeal); - } - - .unmonitored { - border-color : grey; - background-color : grey; - - .color-impaired-background-gradient(45deg, grey); - } - - .chart { - margin-top : 2px; - margin-right : 2px; - line-height : 12px; - } - - .legend-labels { - max-width : 100%; - width : 500px; - - @media (max-width: @screen-xs-min) { - width : 100%; - } - } - - .legend-label { - display : inline-block; - width : 150px; - } -} - -.ical { - color: @btn-link-disabled-color; - cursor: pointer; -} - -.ical-url { - - input, input[readonly] { - cursor : text; - } -} - -.calendar-title { - text-align : center; - - h2 { - margin-top : 0px; - margin-bottom : 5px; - } -} - -.calendar-toolbar { - .page-toolbar { - margin-bottom : 10px; - } -} diff --git a/src/UI/Cells/ApprovalStatusCell.js b/src/UI/Cells/ApprovalStatusCell.js deleted file mode 100644 index 08c6ba575..000000000 --- a/src/UI/Cells/ApprovalStatusCell.js +++ /dev/null @@ -1,33 +0,0 @@ -var Backgrid = require('backgrid'); -var Marionette = require('marionette'); -require('bootstrap'); - -module.exports = Backgrid.Cell.extend({ - className : 'approval-status-cell', - template : 'Cells/ApprovalStatusCellTemplate', - - render : function() { - - var rejections = this.model.get(this.column.get('name')); - - if (rejections.length === 0) { - return this; - } - - this.templateFunction = Marionette.TemplateCache.get(this.template); - - var html = this.templateFunction(rejections); - this.$el.html('<i class="icon-sonarr-form-danger"/>'); - - this.$el.popover({ - content : html, - html : true, - trigger : 'hover', - title : this.column.get('title'), - placement : 'left', - container : this.$el - }); - - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Cells/ApprovalStatusCellTemplate.hbs b/src/UI/Cells/ApprovalStatusCellTemplate.hbs deleted file mode 100644 index 87f28cbcb..000000000 --- a/src/UI/Cells/ApprovalStatusCellTemplate.hbs +++ /dev/null @@ -1,11 +0,0 @@ -<ul> - {{#each this}} - <li> - {{#if reason}} - {{reason}} - {{else}} - {{this}} - {{/if}} - </li> - {{/each}} -</ul> diff --git a/src/UI/Cells/DeleteEpisodeFileCell.js b/src/UI/Cells/DeleteEpisodeFileCell.js deleted file mode 100644 index 88ddf8b82..000000000 --- a/src/UI/Cells/DeleteEpisodeFileCell.js +++ /dev/null @@ -1,27 +0,0 @@ -var vent = require('vent'); -var Backgrid = require('backgrid'); - -module.exports = Backgrid.Cell.extend({ - className : 'delete-episode-file-cell', - - events : { - 'click' : '_onClick' - }, - - render : function() { - this.$el.empty(); - this.$el.html('<i class="icon-sonarr-delete" title="Delete episode file from disk"></i>'); - - return this; - }, - - _onClick : function() { - 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() { - vent.trigger(vent.Events.EpisodeFileDeleted, { episodeFile : self.model }); - }); - } - } -}); \ No newline at end of file diff --git a/src/UI/Cells/Edit/QualityCellEditor.js b/src/UI/Cells/Edit/QualityCellEditor.js deleted file mode 100644 index 00e469d83..000000000 --- a/src/UI/Cells/Edit/QualityCellEditor.js +++ /dev/null @@ -1,74 +0,0 @@ -var _ = require('underscore'); -var Backgrid = require('backgrid'); -var Marionette = require('marionette'); -var ProfileSchemaCollection = require('../../Settings/Profile/ProfileSchemaCollection'); - -module.exports = Backgrid.CellEditor.extend({ - className : 'quality-cell-editor', - template : 'Cells/Edit/QualityCellEditorTemplate', - tagName : 'select', - - events : { - 'change' : 'save', - 'blur' : 'close', - 'keydown' : 'close' - }, - - render : function() { - var self = this; - - var profileSchemaCollection = new ProfileSchemaCollection(); - var promise = profileSchemaCollection.fetch(); - - promise.done(function() { - var templateName = self.template; - self.schema = profileSchemaCollection.first(); - - var selected = _.find(self.schema.get('items'), function(model) { - return model.quality.id === self.model.get(self.column.get('name')).quality.id; - }); - - if (selected) { - selected.quality.selected = true; - } - - self.templateFunction = Marionette.TemplateCache.get(templateName); - var data = self.schema.toJSON(); - var html = self.templateFunction(data); - self.$el.html(html); - }); - - return this; - }, - - save : function(e) { - var model = this.model; - var column = this.column; - var selected = parseInt(this.$el.val(), 10); - - var profileItem = _.find(this.schema.get('items'), function(model) { - return model.quality.id === selected; - }); - - var newQuality = { - quality : profileItem.quality, - revision : { - version : 1, - real : 0 - } - }; - - model.set(column.get('name'), newQuality); - model.save(); - - model.trigger('backgrid:edited', model, column, new Backgrid.Command(e)); - }, - - close : function(e) { - var model = this.model; - var column = this.column; - var command = new Backgrid.Command(e); - - model.trigger('backgrid:edited', model, column, command); - } -}); \ No newline at end of file diff --git a/src/UI/Cells/Edit/QualityCellEditorTemplate.hbs b/src/UI/Cells/Edit/QualityCellEditorTemplate.hbs deleted file mode 100644 index b7039dd44..000000000 --- a/src/UI/Cells/Edit/QualityCellEditorTemplate.hbs +++ /dev/null @@ -1,9 +0,0 @@ -{{#eachReverse items}} - {{#with quality}} - {{#if selected}} - <option value="{{id}}" selected="selected">{{name}}</option> - {{else}} - <option value="{{id}}">{{name}}</option> - {{/if}} - {{/with}} -{{/eachReverse}} \ No newline at end of file diff --git a/src/UI/Cells/EpisodeActionsCell.js b/src/UI/Cells/EpisodeActionsCell.js deleted file mode 100644 index 383942d34..000000000 --- a/src/UI/Cells/EpisodeActionsCell.js +++ /dev/null @@ -1,44 +0,0 @@ -var vent = require('vent'); -var NzbDroneCell = require('./NzbDroneCell'); -var CommandController = require('../Commands/CommandController'); - -module.exports = NzbDroneCell.extend({ - className : 'episode-actions-cell', - - events : { - 'click .x-automatic-search' : '_automaticSearch', - 'click .x-manual-search' : '_manualSearch' - }, - - render : function() { - this.$el.empty(); - - this.$el.html('<i class="icon-sonarr-search x-automatic-search" title="Automatic Search"></i>' + '<i class="icon-sonarr-search-manual x-manual-search" title="Manual Search"></i>'); - - CommandController.bindToCommand({ - element : this.$el.find('.x-automatic-search'), - command : { - name : 'episodeSearch', - episodeIds : [this.model.get('id')] - } - }); - - this.delegateEvents(); - return this; - }, - - _automaticSearch : function() { - CommandController.Execute('episodeSearch', { - name : 'episodeSearch', - episodeIds : [this.model.get('id')] - }); - }, - - _manualSearch : function() { - vent.trigger(vent.Commands.ShowEpisodeDetails, { - episode : this.cellValue, - hideSeriesLink : true, - openingTab : 'search' - }); - } -}); \ No newline at end of file diff --git a/src/UI/Cells/EpisodeFilePathCell.js b/src/UI/Cells/EpisodeFilePathCell.js deleted file mode 100644 index 5f3916ead..000000000 --- a/src/UI/Cells/EpisodeFilePathCell.js +++ /dev/null @@ -1,19 +0,0 @@ -var reqres = require('../reqres'); -var NzbDroneCell = require('./NzbDroneCell'); - -module.exports = NzbDroneCell.extend({ - className : 'episode-file-path-cell', - - render : function() { - this.$el.empty(); - - if (reqres.hasHandler(reqres.Requests.GetEpisodeFileById)) { - var episodeFile = reqres.request(reqres.Requests.GetEpisodeFileById, this.model.get('episodeFileId')); - - this.$el.html(episodeFile.get('relativePath')); - } - - this.delegateEvents(); - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Cells/EpisodeMonitoredCell.js b/src/UI/Cells/EpisodeMonitoredCell.js deleted file mode 100644 index b615d2909..000000000 --- a/src/UI/Cells/EpisodeMonitoredCell.js +++ /dev/null @@ -1,57 +0,0 @@ -var _ = require('underscore'); -var ToggleCell = require('./ToggleCell'); -var SeriesCollection = require('../Series/SeriesCollection'); -var Messenger = require('../Shared/Messenger'); - -module.exports = ToggleCell.extend({ - className : 'toggle-cell episode-monitored', - - _originalOnClick : ToggleCell.prototype._onClick, - - _onClick : function(e) { - - var series = SeriesCollection.get(this.model.get('seriesId')); - - if (!series.get('monitored')) { - - Messenger.show({ - message : 'Unable to change monitored state when series is not monitored', - type : 'error' - }); - - return; - } - - if (e.shiftKey && this.model.episodeCollection.lastToggled) { - this._selectRange(); - - return; - } - - this._originalOnClick.apply(this, arguments); - this.model.episodeCollection.lastToggled = this.model; - }, - - _selectRange : function() { - var episodeCollection = this.model.episodeCollection; - var lastToggled = episodeCollection.lastToggled; - - var currentIndex = episodeCollection.indexOf(this.model); - var lastIndex = episodeCollection.indexOf(lastToggled); - - var low = Math.min(currentIndex, lastIndex); - var high = Math.max(currentIndex, lastIndex); - var range = _.range(low + 1, high); - - _.each(range, function(index) { - var model = episodeCollection.at(index); - - model.set('monitored', lastToggled.get('monitored')); - model.save(); - }); - - this.model.set('monitored', lastToggled.get('monitored')); - this.model.save(); - this.model.episodeCollection.lastToggled = undefined; - } -}); \ No newline at end of file diff --git a/src/UI/Cells/EpisodeNumberCell.js b/src/UI/Cells/EpisodeNumberCell.js deleted file mode 100644 index 6d4d804b2..000000000 --- a/src/UI/Cells/EpisodeNumberCell.js +++ /dev/null @@ -1,71 +0,0 @@ -var NzbDroneCell = require('./NzbDroneCell'); -var FormatHelpers = require('../Shared/FormatHelpers'); -var _ = require('underscore'); - -module.exports = NzbDroneCell.extend({ - className : 'episode-number-cell', - - render : function() { - - this.$el.empty(); - - var airDateField = this.column.get('airDateUtc') || 'airDateUtc'; - var seasonField = this.column.get('seasonNumber') || 'seasonNumber'; - var episodeField = this.column.get('episodes') || 'episodeNumber'; - var absoluteEpisodeField = 'absoluteEpisodeNumber'; - - if (this.model) { - var result = 'Unknown'; - - var airDate = this.model.get(airDateField); - var seasonNumber = this.model.get(seasonField); - var episodes = this.model.get(episodeField); - var absoluteEpisodeNumber = this.model.get(absoluteEpisodeField); - - if (this.cellValue) { - if (!seasonNumber) { - seasonNumber = this.cellValue.get(seasonField); - } - - if (!episodes) { - episodes = this.cellValue.get(episodeField); - } - - if (absoluteEpisodeNumber === undefined) { - absoluteEpisodeNumber = this.cellValue.get(absoluteEpisodeField); - } - - if (!airDate) { - this.model.get(airDateField); - } - } - - if (episodes) { - - var paddedEpisodes; - var paddedAbsoluteEpisode; - - if (episodes.constructor === Array) { - paddedEpisodes = _.map(episodes, function(episodeNumber) { - return FormatHelpers.pad(episodeNumber, 2); - }).join(); - } else { - paddedEpisodes = FormatHelpers.pad(episodes, 2); - paddedAbsoluteEpisode = FormatHelpers.pad(absoluteEpisodeNumber, 2); - } - - result = '{0}x{1}'.format(seasonNumber, paddedEpisodes); - - if (absoluteEpisodeNumber !== undefined && paddedAbsoluteEpisode) { - result += ' ({0})'.format(paddedAbsoluteEpisode); - } - } else if (airDate) { - result = new Date(airDate).toLocaleDateString(); - } - - this.$el.html(result); - } - this.delegateEvents(); - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Cells/EpisodeProgressCell.js b/src/UI/Cells/EpisodeProgressCell.js deleted file mode 100644 index 6208040c4..000000000 --- a/src/UI/Cells/EpisodeProgressCell.js +++ /dev/null @@ -1,28 +0,0 @@ -var Marionette = require('marionette'); -var NzbDroneCell = require('./NzbDroneCell'); - -module.exports = NzbDroneCell.extend({ - className : 'episode-progress-cell', - template : 'Cells/EpisodeProgressCellTemplate', - - render : function() { - - var episodeCount = this.model.get('episodeCount'); - var episodeFileCount = this.model.get('episodeFileCount'); - - var percent = 100; - - if (episodeCount > 0) { - percent = episodeFileCount / episodeCount * 100; - } - - this.model.set('percentOfEpisodes', percent); - - this.templateFunction = Marionette.TemplateCache.get(this.template); - var data = this.model.toJSON(); - var html = this.templateFunction(data); - this.$el.html(html); - - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Cells/EpisodeProgressCellTemplate.hbs b/src/UI/Cells/EpisodeProgressCellTemplate.hbs deleted file mode 100644 index 98c06f4c0..000000000 --- a/src/UI/Cells/EpisodeProgressCellTemplate.hbs +++ /dev/null @@ -1 +0,0 @@ -{{> EpisodeProgressPartial }} \ No newline at end of file diff --git a/src/UI/Cells/EpisodeStatusCell.js b/src/UI/Cells/EpisodeStatusCell.js deleted file mode 100644 index 5913e372d..000000000 --- a/src/UI/Cells/EpisodeStatusCell.js +++ /dev/null @@ -1,127 +0,0 @@ -var reqres = require('../reqres'); -var Backbone = require('backbone'); -var NzbDroneCell = require('./NzbDroneCell'); -var QueueCollection = require('../Activity/Queue/QueueCollection'); -var moment = require('moment'); -var FormatHelpers = require('../Shared/FormatHelpers'); - -module.exports = NzbDroneCell.extend({ - className : 'episode-status-cell', - - render : function() { - this.listenTo(QueueCollection, 'sync', this._renderCell); - - this._renderCell(); - - return this; - }, - - _renderCell : function() { - - if (this.episodeFile) { - this.stopListening(this.episodeFile, 'change', this._refresh); - } - - this.$el.empty(); - - if (this.model) { - - var icon; - var tooltip; - - var hasAired = moment(this.model.get('airDateUtc')).isBefore(moment()); - this.episodeFile = this._getFile(); - - if (this.episodeFile) { - this.listenTo(this.episodeFile, 'change', this._refresh); - - var quality = this.episodeFile.get('quality'); - var revision = quality.revision; - var size = FormatHelpers.bytes(this.episodeFile.get('size')); - var title = 'Episode downloaded'; - - if (revision.real && revision.real > 0) { - title += '[REAL]'; - } - - if (revision.version && revision.version > 1) { - title += ' [PROPER]'; - } - - if (size !== '') { - title += ' - {0}'.format(size); - } - - if (this.episodeFile.get('qualityCutoffNotMet')) { - this.$el.html('<span class="badge badge-inverse" title="{0}">{1}</span>'.format(title, quality.quality.name)); - } else { - this.$el.html('<span class="badge" title="{0}">{1}</span>'.format(title, quality.quality.name)); - } - - return; - } - - else { - var model = this.model; - var downloading = QueueCollection.findEpisode(model.get('id')); - - if (downloading) { - var progress = 100 - (downloading.get('sizeleft') / downloading.get('size') * 100); - - if (progress === 0) { - icon = 'icon-sonarr-downloading'; - tooltip = 'Episode is downloading'; - } - - else { - this.$el.html('<div class="progress" title="Episode is downloading - {0}% {1}">'.format(progress.toFixed(1), downloading.get('title')) + - '<div class="progress-bar progress-bar-purple" style="width: {0}%;"></div></div>'.format(progress)); - return; - } - } - - else if (this.model.get('grabbed')) { - icon = 'icon-sonarr-downloading'; - tooltip = 'Episode is downloading'; - } - - else if (!this.model.get('airDateUtc')) { - icon = 'icon-sonarr-tba'; - tooltip = 'TBA'; - } - - else if (hasAired) { - icon = 'icon-sonarr-missing'; - tooltip = 'Episode missing from disk'; - } else { - icon = 'icon-sonarr-not-aired'; - tooltip = 'Episode has not aired'; - } - } - - this.$el.html('<i class="{0}" title="{1}"/>'.format(icon, tooltip)); - } - }, - - _getFile : function() { - var hasFile = this.model.get('hasFile'); - - if (hasFile) { - var episodeFile; - - if (reqres.hasHandler(reqres.Requests.GetEpisodeFileById)) { - episodeFile = reqres.request(reqres.Requests.GetEpisodeFileById, this.model.get('episodeFileId')); - } - - else if (this.model.has('episodeFile')) { - episodeFile = new Backbone.Model(this.model.get('episodeFile')); - } - - if (episodeFile) { - return episodeFile; - } - } - - return undefined; - } -}); \ No newline at end of file diff --git a/src/UI/Cells/EpisodeTitleCell.js b/src/UI/Cells/EpisodeTitleCell.js deleted file mode 100644 index 7dce10ede..000000000 --- a/src/UI/Cells/EpisodeTitleCell.js +++ /dev/null @@ -1,29 +0,0 @@ -var vent = require('vent'); -var NzbDroneCell = require('./NzbDroneCell'); - -module.exports = NzbDroneCell.extend({ - className : 'episode-title-cell', - - events : { - 'click' : '_showDetails' - }, - - render : function() { - var title = this.cellValue.get('title'); - - if (!title || title === '') { - title = 'TBA'; - } - - this.$el.html(title); - return this; - }, - - _showDetails : function() { - var hideSeriesLink = this.column.get('hideSeriesLink'); - vent.trigger(vent.Commands.ShowEpisodeDetails, { - episode : this.cellValue, - hideSeriesLink : hideSeriesLink - }); - } -}); \ No newline at end of file diff --git a/src/UI/Cells/EventTypeCell.js b/src/UI/Cells/EventTypeCell.js deleted file mode 100644 index 4ca9a85ae..000000000 --- a/src/UI/Cells/EventTypeCell.js +++ /dev/null @@ -1,44 +0,0 @@ -var NzbDroneCell = require('./NzbDroneCell'); - -module.exports = NzbDroneCell.extend({ - className : 'history-event-type-cell', - - render : function() { - this.$el.empty(); - - if (this.cellValue) { - var icon; - var toolTip; - - switch (this.cellValue.get('eventType')) { - case 'grabbed': - icon = 'icon-sonarr-downloading'; - toolTip = 'Episode grabbed from {0} and sent to download client'.format(this.cellValue.get('data').indexer); - break; - case 'seriesFolderImported': - icon = 'icon-sonarr-hdd'; - toolTip = 'Existing episode file added to library'; - break; - case 'downloadFolderImported': - icon = 'icon-sonarr-imported'; - toolTip = 'Episode downloaded successfully and picked up from download client'; - break; - case 'downloadFailed': - icon = 'icon-sonarr-download-failed'; - toolTip = 'Episode download failed'; - break; - case 'episodeFileDeleted': - icon = 'icon-sonarr-deleted'; - toolTip = 'Episode file deleted'; - break; - default: - icon = 'icon-sonarr-unknown'; - toolTip = 'unknown event'; - } - - this.$el.html('<i class="{0}" title="{1}" data-placement="right"/>'.format(icon, toolTip)); - } - - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Cells/FileSizeCell.js b/src/UI/Cells/FileSizeCell.js deleted file mode 100644 index 586d5f35c..000000000 --- a/src/UI/Cells/FileSizeCell.js +++ /dev/null @@ -1,13 +0,0 @@ -var Backgrid = require('backgrid'); -var FormatHelpers = require('../Shared/FormatHelpers'); - -module.exports = Backgrid.Cell.extend({ - className : 'file-size-cell', - - render : function() { - var size = this.model.get(this.column.get('name')); - this.$el.html(FormatHelpers.bytes(size)); - this.delegateEvents(); - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Cells/IndexerCell.js b/src/UI/Cells/IndexerCell.js deleted file mode 100644 index bbd2e90df..000000000 --- a/src/UI/Cells/IndexerCell.js +++ /dev/null @@ -1,11 +0,0 @@ -var Backgrid = require('backgrid'); - -module.exports = Backgrid.Cell.extend({ - className : 'indexer-cell', - - render : function() { - var indexer = this.model.get(this.column.get('name')); - this.$el.html(indexer); - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Cells/NzbDroneCell.js b/src/UI/Cells/NzbDroneCell.js deleted file mode 100644 index 7bd6125f3..000000000 --- a/src/UI/Cells/NzbDroneCell.js +++ /dev/null @@ -1,61 +0,0 @@ -var Backgrid = require('backgrid'); -var Backbone = require('backbone'); - -module.exports = Backgrid.Cell.extend({ - - _originalInit : Backgrid.Cell.prototype.initialize, - - initialize : function() { - this._originalInit.apply(this, arguments); - this.cellValue = this._getValue(); - - this.listenTo(this.model, 'change', this._refresh); - - if (this._onEdit) { - this.listenTo(this.model, 'backgrid:edit', function(model, column, cell, editor) { - if (column.get('name') === this.column.get('name')) { - this._onEdit(model, column, cell, editor); - } - }); - } - }, - - _refresh : function() { - this.cellValue = this._getValue(); - this.render(); - }, - - _getValue : function() { - - var cellValue = this.column.get('cellValue'); - - if (cellValue) { - if (cellValue === 'this') { - return this.model; - } - - else { - return this.model.get(cellValue); - } - } - - var name = this.column.get('name'); - - if (name === 'this') { - return this.model; - } - - var value = this.model.get(name); - - if (!value) { - return undefined; - } - - //if not a model - if (!value.get && typeof value === 'object') { - value = new Backbone.Model(value); - } - - return value; - } -}); \ No newline at end of file diff --git a/src/UI/Cells/ProfileCell.js b/src/UI/Cells/ProfileCell.js deleted file mode 100644 index d87ee1af9..000000000 --- a/src/UI/Cells/ProfileCell.js +++ /dev/null @@ -1,29 +0,0 @@ -var Backgrid = require('backgrid'); -var ProfileCollection = require('../Profile/ProfileCollection'); -var _ = require('underscore'); - -module.exports = Backgrid.Cell.extend({ - className : 'profile-cell', - - _originalInit : Backgrid.Cell.prototype.initialize, - - initialize : function () { - this._originalInit.apply(this, arguments); - - this.listenTo(ProfileCollection, 'sync', this.render); - }, - - render : function() { - - this.$el.empty(); - var profileId = this.model.get(this.column.get('name')); - - var profile = _.findWhere(ProfileCollection.models, { id : profileId }); - - if (profile) { - this.$el.html(profile.get('name')); - } - - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Cells/QualityCell.js b/src/UI/Cells/QualityCell.js deleted file mode 100644 index 962bd2ab4..000000000 --- a/src/UI/Cells/QualityCell.js +++ /dev/null @@ -1,8 +0,0 @@ -var TemplatedCell = require('./TemplatedCell'); -var QualityCellEditor = require('./Edit/QualityCellEditor'); - -module.exports = TemplatedCell.extend({ - className : 'quality-cell', - template : 'Cells/QualityCellTemplate', - editor : QualityCellEditor -}); \ No newline at end of file diff --git a/src/UI/Cells/QualityCellTemplate.hbs b/src/UI/Cells/QualityCellTemplate.hbs deleted file mode 100644 index 6625ade9b..000000000 --- a/src/UI/Cells/QualityCellTemplate.hbs +++ /dev/null @@ -1,5 +0,0 @@ -{{#if_gt proper compare="1"}} - <span class="badge badge-info" title="PROPER">{{quality.name}}</span> -{{else}} - <span class="badge">{{quality.name}}</span> -{{/if_gt}} \ No newline at end of file diff --git a/src/UI/Cells/RelativeDateCell.js b/src/UI/Cells/RelativeDateCell.js deleted file mode 100644 index eb69fc855..000000000 --- a/src/UI/Cells/RelativeDateCell.js +++ /dev/null @@ -1,34 +0,0 @@ -var NzbDroneCell = require('./NzbDroneCell'); -var moment = require('moment'); -var FormatHelpers = require('../Shared/FormatHelpers'); -var UiSettings = require('../Shared/UiSettingsModel'); - -module.exports = NzbDroneCell.extend({ - className : 'relative-date-cell', - - render : function() { - - var dateStr = this.model.get(this.column.get('name')); - - if (dateStr) { - var date = moment(dateStr); - var diff = date.diff(moment().zone(date.zone()).startOf('day'), 'days', true); - var result = '<span title="{0}">{1}</span>'; - var tooltip = date.format(UiSettings.longDateTime()); - var text; - - if (diff > 0 && diff < 1) { - text = date.format(UiSettings.time(true, false)); - } else { - if (UiSettings.get('showRelativeDates')) { - text = FormatHelpers.relativeDate(dateStr); - } else { - text = date.format(UiSettings.get('shortDateFormat')); - } - } - - this.$el.html(result.format(tooltip, text)); - } - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Cells/RelativeTimeCell.js b/src/UI/Cells/RelativeTimeCell.js deleted file mode 100644 index b0d552bfd..000000000 --- a/src/UI/Cells/RelativeTimeCell.js +++ /dev/null @@ -1,30 +0,0 @@ -var NzbDroneCell = require('./NzbDroneCell'); -var moment = require('moment'); -var FormatHelpers = require('../Shared/FormatHelpers'); -var UiSettings = require('../Shared/UiSettingsModel'); - -module.exports = NzbDroneCell.extend({ - className : 'relative-time-cell', - - render : function() { - - var dateStr = this.model.get(this.column.get('name')); - - if (dateStr) { - var date = moment(dateStr); - var result = '<span title="{0}">{1}</span>'; - var tooltip = date.format(UiSettings.longDateTime()); - var text; - - if (UiSettings.get('showRelativeDates')) { - text = date.fromNow(); - } else { - text = date.format(UiSettings.shortDateTime()); - } - - this.$el.html(result.format(tooltip, text)); - } - - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Cells/ReleaseTitleCell.js b/src/UI/Cells/ReleaseTitleCell.js deleted file mode 100644 index 7d3551e41..000000000 --- a/src/UI/Cells/ReleaseTitleCell.js +++ /dev/null @@ -1,20 +0,0 @@ -var NzbDroneCell = require('./NzbDroneCell'); - -module.exports = NzbDroneCell.extend({ - className : 'release-title-cell', - - render : function() { - this.$el.empty(); - - var title = this.model.get('title'); - var infoUrl = this.model.get('infoUrl'); - - if (infoUrl) { - this.$el.html('<a href="{0}">{1}</a>'.format(infoUrl, title)); - } else { - this.$el.html(title); - } - - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Cells/SeasonFolderCell.js b/src/UI/Cells/SeasonFolderCell.js deleted file mode 100644 index 7a9385b84..000000000 --- a/src/UI/Cells/SeasonFolderCell.js +++ /dev/null @@ -1,14 +0,0 @@ -var Backgrid = require('backgrid'); - -module.exports = Backgrid.Cell.extend({ - className : 'season-folder-cell', - - render : function() { - this.$el.empty(); - - var seasonFolder = this.model.get(this.column.get('name')); - this.$el.html(seasonFolder.toString()); - - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Cells/SelectAllCell.js b/src/UI/Cells/SelectAllCell.js deleted file mode 100644 index e89289f40..000000000 --- a/src/UI/Cells/SelectAllCell.js +++ /dev/null @@ -1,45 +0,0 @@ -var $ = require('jquery'); -var _ = require('underscore'); -var BackgridSelectAll = require('backgrid.selectall'); - -module.exports = BackgridSelectAll.extend({ - enterEditMode : function(e) { - var collection = this.column.get('sortedCollection') || this.model.collection; - - if (e.shiftKey && collection.lastToggled) { - this._selectRange(collection); - } - - var checked = $(e.target).prop('checked'); - - collection.lastToggled = this.model; - collection.checked = checked; - }, - - onChange : function(e) { - var checked = $(e.target).prop('checked'); - this.$el.parent().toggleClass('selected', checked); - this.model.trigger('backgrid:selected', this.model, checked); - }, - - _selectRange : function(collection) { - var lastToggled = collection.lastToggled; - var checked = collection.checked; - - var currentIndex = collection.indexOf(this.model); - var lastIndex = collection.indexOf(lastToggled); - - var low = Math.min(currentIndex, lastIndex); - var high = Math.max(currentIndex, lastIndex); - var range = _.range(low + 1, high); - - _.each(range, function(index) { - var model = collection.at(index); - - model.trigger('backgrid:select', model, checked); - }); - - collection.lastToggled = undefined; - collection.checked = undefined; - } -}); \ No newline at end of file diff --git a/src/UI/Cells/SeriesActionsCell.js b/src/UI/Cells/SeriesActionsCell.js deleted file mode 100644 index eb62191ef..000000000 --- a/src/UI/Cells/SeriesActionsCell.js +++ /dev/null @@ -1,45 +0,0 @@ -var vent = require('vent'); -var NzbDroneCell = require('./NzbDroneCell'); -var CommandController = require('../Commands/CommandController'); - -module.exports = NzbDroneCell.extend({ - className : 'series-actions-cell', - - ui : { - refresh : '.x-refresh' - }, - - events : { - 'click .x-edit' : '_editSeries', - 'click .x-refresh' : '_refreshSeries' - }, - - render : function() { - this.$el.empty(); - - this.$el.html('<i class="icon-sonarr-refresh x-refresh hidden-xs" title="" data-original-title="Update series info and scan disk"></i> ' + - '<i class="icon-sonarr-edit x-edit" title="" data-original-title="Edit Series"></i>'); - - CommandController.bindToCommand({ - element : this.$el.find('.x-refresh'), - command : { - name : 'refreshSeries', - seriesId : this.model.get('id') - } - }); - - this.delegateEvents(); - return this; - }, - - _editSeries : function() { - vent.trigger(vent.Commands.EditSeriesCommand, { series : this.model }); - }, - - _refreshSeries : function() { - CommandController.Execute('refreshSeries', { - name : 'refreshSeries', - seriesId : this.model.id - }); - } -}); \ No newline at end of file diff --git a/src/UI/Cells/SeriesStatusCell.js b/src/UI/Cells/SeriesStatusCell.js deleted file mode 100644 index e240f6100..000000000 --- a/src/UI/Cells/SeriesStatusCell.js +++ /dev/null @@ -1,32 +0,0 @@ -var NzbDroneCell = require('./NzbDroneCell'); - -module.exports = NzbDroneCell.extend({ - className : 'series-status-cell', - - render : function() { - this.$el.empty(); - var monitored = this.model.get('monitored'); - var status = this.model.get('status'); - - if (status === 'ended') { - this.$el.html('<i class="icon-sonarr-series-ended grid-icon" title="Ended"></i>'); - this._setStatusWeight(3); - } - - else if (!monitored) { - this.$el.html('<i class="icon-sonarr-series-unmonitored grid-icon" title="Not Monitored"></i>'); - this._setStatusWeight(2); - } - - else { - this.$el.html('<i class="icon-sonarr-series-continuing grid-icon" title="Continuing"></i>'); - this._setStatusWeight(1); - } - - return this; - }, - - _setStatusWeight : function(weight) { - this.model.set('statusWeight', weight, { silent : true }); - } -}); \ No newline at end of file diff --git a/src/UI/Cells/SeriesTitleCell.js b/src/UI/Cells/SeriesTitleCell.js deleted file mode 100644 index a516b4e09..000000000 --- a/src/UI/Cells/SeriesTitleCell.js +++ /dev/null @@ -1,6 +0,0 @@ -var TemplatedCell = require('./TemplatedCell'); - -module.exports = TemplatedCell.extend({ - className : 'series-title-cell', - template : 'Cells/SeriesTitleTemplate' -}); \ No newline at end of file diff --git a/src/UI/Cells/SeriesTitleTemplate.hbs b/src/UI/Cells/SeriesTitleTemplate.hbs deleted file mode 100644 index 99205b00a..000000000 --- a/src/UI/Cells/SeriesTitleTemplate.hbs +++ /dev/null @@ -1 +0,0 @@ -<a href="{{route}}">{{title}}</a> diff --git a/src/UI/Cells/TemplatedCell.js b/src/UI/Cells/TemplatedCell.js deleted file mode 100644 index 1299d4e36..000000000 --- a/src/UI/Cells/TemplatedCell.js +++ /dev/null @@ -1,21 +0,0 @@ -var Marionette = require('marionette'); -var NzbDroneCell = require('./NzbDroneCell'); - -module.exports = NzbDroneCell.extend({ - render : function() { - - var templateName = this.column.get('template') || this.template; - - this.templateFunction = Marionette.TemplateCache.get(templateName); - this.$el.empty(); - - if (this.cellValue) { - var data = this.cellValue.toJSON(); - var html = this.templateFunction(data); - this.$el.html(html); - } - - this.delegateEvents(); - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Cells/ToggleCell.js b/src/UI/Cells/ToggleCell.js deleted file mode 100644 index 0de0762fd..000000000 --- a/src/UI/Cells/ToggleCell.js +++ /dev/null @@ -1,48 +0,0 @@ -var Backgrid = require('backgrid'); - -module.exports = Backgrid.Cell.extend({ - className : 'toggle-cell', - - events : { - 'click' : '_onClick' - }, - - _onClick : function() { - - var self = this; - - this.$el.tooltip('hide'); - - var name = this.column.get('name'); - this.model.set(name, !this.model.get(name)); - - var promise = this.model.save(); - - this.$('i').spinForPromise(promise); - - promise.always(function() { - self.render(); - }); - }, - - render : function() { - this.$el.empty(); - this.$el.html('<i />'); - - var name = this.column.get('name'); - - if (this.model.get(name)) { - this.$('i').addClass(this.column.get('trueClass')); - } else { - this.$('i').addClass(this.column.get('falseClass')); - } - - var tooltip = this.column.get('tooltip'); - - if (tooltip) { - this.$('i').attr('title', tooltip); - } - - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Cells/cells.less b/src/UI/Cells/cells.less deleted file mode 100644 index ca71defbd..000000000 --- a/src/UI/Cells/cells.less +++ /dev/null @@ -1,253 +0,0 @@ -@import "../Content/Bootstrap/mixins"; -@import "../Content/Bootstrap/variables"; -@import "../Content/Bootstrap/buttons"; -@import "../Shared/Styles/clickable"; -@import "../Content/mixins"; -@import "../Content/variables"; - -.series-title-cell { - .text-overflow(); - - max-width: 450px; - - @media @sm { - max-width: 250px - } -} - -.episode-title-cell { - .text-overflow(); - - color: #428bca; - text-decoration: none; - - &:focus, &:hover { - color: #2a6496; - text-decoration: underline; - cursor: pointer; - } - - @media @lg { - max-width: 350px; - } - - @media @md { - max-width: 250px; - } - - @media @sm { - max-width: 200px; - } -} - -.air-date-cell { - width : 120px; - cursor: default; - .text-overflow(); -} - -.relative-date-cell, .relative-time-cell { - .text-overflow(); - cursor : default; -} - -.relative-date-cell { - width : 150px; -} - -.history-event-type-cell { - width : 10px; -} - -.download-report-cell { - .clickable(); - - width : 32px; - - i { - .clickable(); - } -} - -.toggle-cell{ - .clickable(); - .not-selectable; -} - -.approval-status-cell { - - .popover { - max-width : 400px; - - ul { - margin-left: -25px; - } - } - - i { - color : @brand-danger; - } -} - -td.episode-status-cell, td.quality-cell, td.history-quality-cell, td.progress-cell { - text-align: center; - width: 80px; - - .badge { - font-size: 10px; - } - - .progress { - height : 10px; - margin-top : 5px; - margin-bottom : 0px; - } -} - -.history-details-cell { - .clickable(); - width: 10px; - - i { - .clickable(); - } -} - -.release-title-cell { - max-width: 400px; - word-wrap: break-word; -} - -.episode-actions-cell { - width: 55px; - - i { - .clickable(); - margin-left : 8px; - - &:first-of-type { - margin-left : 0px; - } - } -} - -.episode-history-details-cell { - width : 18px; -} - -.episode-detail-modal { - .episode-actions-cell { - width : 18px; - } -} - -.series-actions-cell { - width : 56px; - min-width : 56px; -} - -.timeleft-cell { - cursor : default; - width : 80px; - text-align : center; -} - -.queue-status-cell { - width : 20px; - text-align : center !important; -} - -.queue-actions-cell { - min-width : 65px; - width : 65px; - text-align : right !important; - - i { - .clickable(); - margin-left : 1px; - margin-right : 1px; - } -} - -.download-log-cell { - width : 80px; -} - -td.delete-episode-file-cell { - .clickable(); - - text-align : center; - width : 20px; - - i { - .clickable(); - } -} - -.series-status-cell { - width: 16px; -} - -.episode-number-cell { - cursor : default; -} - -.backup-type-cell { - width : 20px; -} - -.table>tbody>tr>td, .table>thead>tr>th { - - &.episode-warning-cell { - width : 1px; - padding-left : 0px; - padding-right : 0px; - } -} - -.log-message-cell { - word-break: break-all; - word-wrap: break-word; -} - -.execute-task-cell { - width : 28px; - - i { - .clickable(); - } -} - -.task-interval-cell, .next-execution-cell { - cursor : default; -} - -.task-interval-cell { - width : 150px; -} - -.next-execution-cell { - width : 200px; -} - -.tasks { - .relative-time-cell { - width : 200px; - } -} - -.age-cell { - cursor : default; -} - -.blacklist-actions-cell { - min-width : 55px; - width : 55px; - text-align : right !important; - - i { - .clickable(); - margin-left : 2px; - margin-right : 2px; - } -} diff --git a/src/UI/Commands/CommandCollection.js b/src/UI/Commands/CommandCollection.js deleted file mode 100644 index b8eaae543..000000000 --- a/src/UI/Commands/CommandCollection.js +++ /dev/null @@ -1,20 +0,0 @@ -var Backbone = require('backbone'); -var CommandModel = require('./CommandModel'); -require('../Mixins/backbone.signalr.mixin'); - -var CommandCollection = Backbone.Collection.extend({ - url : window.NzbDrone.ApiRoot + '/command', - model : CommandModel, - - findCommand : function(command) { - return this.find(function(model) { - return model.isSameCommand(command); - }); - } -}); - -var collection = new CommandCollection().bindSignalR(); - -collection.fetch(); - -module.exports = collection; \ No newline at end of file diff --git a/src/UI/Commands/CommandController.js b/src/UI/Commands/CommandController.js deleted file mode 100644 index 2232d45ae..000000000 --- a/src/UI/Commands/CommandController.js +++ /dev/null @@ -1,94 +0,0 @@ -var vent = require('vent'); -var CommandModel = require('./CommandModel'); -var CommandCollection = require('./CommandCollection'); -var CommandMessengerCollectionView = require('./CommandMessengerCollectionView'); -var _ = require('underscore'); -var moment = require('moment'); -var Messenger = require('../Shared/Messenger'); -require('../jQuery/jquery.spin'); - -CommandMessengerCollectionView.render(); - -var singleton = function() { - - return { - - _lastCommand : {}, - - Execute : function(name, properties) { - - 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)) { - - Messenger.show({ - message : 'Please wait at least 5 seconds before running this command again', - hideAfter : 5, - type : 'error' - }); - - return this._lastCommand.promise; - } - - var promise = commandModel.save().success(function() { - CommandCollection.add(commandModel); - }); - - this._lastCommand = { - command : commandModel, - promise : promise, - time : moment() - }; - - return promise; - }, - - bindToCommand : function(options) { - - var self = this; - var existingCommand = CommandCollection.findCommand(options.command); - - if (existingCommand) { - this._bindToCommandModel.call(this, existingCommand, options); - } - - CommandCollection.bind('add', function(model) { - if (model.isSameCommand(options.command)) { - self._bindToCommandModel.call(self, model, options); - } - }); - - CommandCollection.bind('sync', function() { - var command = CommandCollection.findCommand(options.command); - if (command) { - self._bindToCommandModel.call(self, command, options); - } - }); - }, - - _bindToCommandModel : function bindToCommand (model, options) { - - if (!model.isActive()) { - options.element.stopSpin(); - return; - } - - model.bind('change:status', function(model) { - if (!model.isActive()) { - options.element.stopSpin(); - - if (model.isComplete()) { - vent.trigger(vent.Events.CommandComplete, { - command : model, - model : options.model - }); - } - } - }); - - options.element.startSpin(); - } - }; -}; -module.exports = singleton(); diff --git a/src/UI/Commands/CommandMessengerCollectionView.js b/src/UI/Commands/CommandMessengerCollectionView.js deleted file mode 100644 index 007760087..000000000 --- a/src/UI/Commands/CommandMessengerCollectionView.js +++ /dev/null @@ -1,11 +0,0 @@ -var Marionette = require('marionette'); -var commandCollection = require('./CommandCollection'); -var CommandMessengerItemView = require('./CommandMessengerItemView'); - -var CollectionView = Marionette.CollectionView.extend({ - itemView : CommandMessengerItemView -}); - -module.exports = new CollectionView({ - collection : commandCollection -}); diff --git a/src/UI/Commands/CommandMessengerItemView.js b/src/UI/Commands/CommandMessengerItemView.js deleted file mode 100644 index c7f419f31..000000000 --- a/src/UI/Commands/CommandMessengerItemView.js +++ /dev/null @@ -1,45 +0,0 @@ -var Marionette = require('marionette'); -var Messenger = require('../Shared/Messenger'); - -module.exports = Marionette.ItemView.extend({ - initialize : function() { - this.listenTo(this.model, 'change', this.render); - }, - - render : function() { - if (!this.model.get('message') || !this.model.get('sendUpdatesToClient')) { - return; - } - - var message = { - type : 'info', - message : '[{0}] {1}'.format(this.model.get('name'), this.model.get('message')), - id : this.model.id, - hideAfter : 0 - }; - - var isManual = this.model.get('manual'); - - switch (this.model.get('state')) { - case 'completed': - message.hideAfter = 4; - break; - case 'failed': - message.hideAfter = isManual ? 10 : 4; - message.type = 'error'; - break; - default : - message.hideAfter = 0; - } - - if (this.messenger) { - this.messenger.update(message); - } - - else { - this.messenger = Messenger.show(message); - } - - console.log(message.message); - } -}); \ No newline at end of file diff --git a/src/UI/Commands/CommandModel.js b/src/UI/Commands/CommandModel.js deleted file mode 100644 index 674067b24..000000000 --- a/src/UI/Commands/CommandModel.js +++ /dev/null @@ -1,50 +0,0 @@ -var _ = require('underscore'); -var Backbone = require('backbone'); - -module.exports = Backbone.Model.extend({ - url : window.NzbDrone.ApiRoot + '/command', - - parse : function(response) { - response.name = response.name.toLocaleLowerCase(); - response.body.name = response.body.name.toLocaleLowerCase(); - - for (var key in response.body) { - response[key] = response.body[key]; - } - - delete response.body; - - return response; - }, - - isSameCommand : function(command) { - - if (command.name.toLocaleLowerCase() !== this.get('name').toLocaleLowerCase()) { - return false; - } - - for (var key in command) { - if (key !== 'name') { - if (Array.isArray(command[key])) { - if (_.difference(command[key], this.get(key)).length > 0) { - return false; - } - } - - else if (command[key] !== this.get(key)) { - return false; - } - } - } - - return true; - }, - - isActive : function() { - return this.get('status') !== 'completed' && this.get('status') !== 'failed'; - }, - - isComplete : function() { - return this.get('status') === 'completed'; - } -}); \ No newline at end of file diff --git a/src/UI/Config.js b/src/UI/Config.js deleted file mode 100644 index 2115d076a..000000000 --- a/src/UI/Config.js +++ /dev/null @@ -1,69 +0,0 @@ -var $ = require('jquery'); -var vent = require('./vent'); - -module.exports = { - Events : { - ConfigUpdatedEvent : 'ConfigUpdatedEvent' - }, - - Keys : { - DefaultProfileId : 'DefaultProfileId', - DefaultRootFolderId : 'DefaultRootFolderId', - UseSeasonFolder : 'UseSeasonFolder', - DefaultSeriesType : 'DefaultSeriesType', - MonitorEpisodes : 'MonitorEpisodes', - AdvancedSettings : 'advancedSettings' - }, - - getValueJson : function (key, defaultValue) { - defaultValue = defaultValue || {}; - - var storeValue = window.localStorage.getItem(key); - - if (!storeValue) { - return defaultValue; - } - - return $.parseJSON(storeValue); - }, - - getValueBoolean : function(key, defaultValue) { - defaultValue = defaultValue || false; - - return this.getValue(key, defaultValue.toString()) === 'true'; - }, - - getValue : function(key, defaultValue) { - var storeValue = window.localStorage.getItem(key); - - if (!storeValue) { - return defaultValue; - } - - return storeValue.toString(); - }, - - setValueJson : function(key, value) { - return this.setValue(key, JSON.stringify(value)); - }, - - setValue : function(key, value) { - - console.log('Config: [{0}] => [{1}]'.format(key, value)); - - if (this.getValue(key) === value.toString()) { - return; - } - - try { - window.localStorage.setItem(key, value); - vent.trigger(this.Events.ConfigUpdatedEvent, { - key : key, - value : value - }); - } - catch (error) { - console.error('Unable to save config: [{0}] => [{1}]'.format(key, value)); - } - } -}; diff --git a/src/UI/Content/Backgrid/backgrid.less b/src/UI/Content/Backgrid/backgrid.less deleted file mode 100644 index ae1d46943..000000000 --- a/src/UI/Content/Backgrid/backgrid.less +++ /dev/null @@ -1,3 +0,0 @@ -@import "filter"; -@import "paginator"; -@import "selectall"; \ No newline at end of file diff --git a/src/UI/Content/Backgrid/filter.less b/src/UI/Content/Backgrid/filter.less deleted file mode 100644 index 84313310a..000000000 --- a/src/UI/Content/Backgrid/filter.less +++ /dev/null @@ -1,11 +0,0 @@ -.backgrid-filter .close { - display : inline-block; - float : none; - width : 20px; - height : 20px; - margin-top : -4px; - font-size : 20px; - line-height : 20px; - text-align : center; - vertical-align : text-top; -} diff --git a/src/UI/Content/Backgrid/paginator.less b/src/UI/Content/Backgrid/paginator.less deleted file mode 100644 index 61fced052..000000000 --- a/src/UI/Content/Backgrid/paginator.less +++ /dev/null @@ -1,66 +0,0 @@ -@import "../prefixer"; -@import "../../Shared/Styles/clickable.less"; - -.backgrid-paginator { - text-align : center; - box-sizing : border-box; - border-top : none; - .box-sizing(border-box); - .border-radius(0 0 4px 4px); - position: relative; - - .total-records { - display : inline-block; - height : 30px; - padding : 0; - line-height: 30px; - font-size : 13px; - position : absolute; - right : 0; - - .label { - margin-top: 5px; - } - } - - ul { - display : inline-block; - - li { - display : inline; - - i, span { - float : left; - width : 30px; - height : 30px; - padding : 0; - line-height : 30px; - text-decoration : none; - } - - select { - width: auto; - } - - .pager-btn { - .clickable; - } - } - .active { - span { - background-color : #f5f5f5; - color : #999999; - cursor : default; - width : inherit; - padding : 0px 2px; - } - } - - .disabled { - i, span { - color : #999999; - cursor : default; - } - } - } -} diff --git a/src/UI/Content/Backgrid/selectall.less b/src/UI/Content/Backgrid/selectall.less deleted file mode 100644 index 322853304..000000000 --- a/src/UI/Content/Backgrid/selectall.less +++ /dev/null @@ -1,12 +0,0 @@ -/* - backgrid-select-all - http://github.com/wyuenho/backgrid - - Copyright (c) 2013 Jimmy Yuen Ho Wong and contributors - Licensed under the MIT @license. -*/ - -.select-row-cell, .select-all-header-cell { - text-align: center; - width: 16px; -} \ No newline at end of file diff --git a/src/UI/Content/Bootstrap/.csscomb.json b/src/UI/Content/Bootstrap/.csscomb.json deleted file mode 100644 index 40695a478..000000000 --- a/src/UI/Content/Bootstrap/.csscomb.json +++ /dev/null @@ -1,304 +0,0 @@ -{ - "always-semicolon": true, - "block-indent": 2, - "color-case": "lower", - "color-shorthand": true, - "element-case": "lower", - "eof-newline": true, - "leading-zero": false, - "remove-empty-rulesets": true, - "space-after-colon": 1, - "space-after-combinator": 1, - "space-before-selector-delimiter": 0, - "space-between-declarations": "\n", - "space-after-opening-brace": "\n", - "space-before-closing-brace": "\n", - "space-before-colon": 0, - "space-before-combinator": 1, - "space-before-opening-brace": 1, - "strip-spaces": true, - "unitless-zero": true, - "vendor-prefix-align": true, - "sort-order": [ - [ - "position", - "top", - "right", - "bottom", - "left", - "z-index", - "display", - "float", - "width", - "min-width", - "max-width", - "height", - "min-height", - "max-height", - "-webkit-box-sizing", - "-moz-box-sizing", - "box-sizing", - "-webkit-appearance", - "padding", - "padding-top", - "padding-right", - "padding-bottom", - "padding-left", - "margin", - "margin-top", - "margin-right", - "margin-bottom", - "margin-left", - "overflow", - "overflow-x", - "overflow-y", - "-webkit-overflow-scrolling", - "-ms-overflow-x", - "-ms-overflow-y", - "-ms-overflow-style", - "clip", - "clear", - "font", - "font-family", - "font-size", - "font-style", - "font-weight", - "font-variant", - "font-size-adjust", - "font-stretch", - "font-effect", - "font-emphasize", - "font-emphasize-position", - "font-emphasize-style", - "font-smooth", - "-webkit-hyphens", - "-moz-hyphens", - "hyphens", - "line-height", - "color", - "text-align", - "-webkit-text-align-last", - "-moz-text-align-last", - "-ms-text-align-last", - "text-align-last", - "text-emphasis", - "text-emphasis-color", - "text-emphasis-style", - "text-emphasis-position", - "text-decoration", - "text-indent", - "text-justify", - "text-outline", - "-ms-text-overflow", - "text-overflow", - "text-overflow-ellipsis", - "text-overflow-mode", - "text-shadow", - "text-transform", - "text-wrap", - "-webkit-text-size-adjust", - "-ms-text-size-adjust", - "letter-spacing", - "-ms-word-break", - "word-break", - "word-spacing", - "-ms-word-wrap", - "word-wrap", - "-moz-tab-size", - "-o-tab-size", - "tab-size", - "white-space", - "vertical-align", - "list-style", - "list-style-position", - "list-style-type", - "list-style-image", - "pointer-events", - "-ms-touch-action", - "touch-action", - "cursor", - "visibility", - "zoom", - "flex-direction", - "flex-order", - "flex-pack", - "flex-align", - "table-layout", - "empty-cells", - "caption-side", - "border-spacing", - "border-collapse", - "content", - "quotes", - "counter-reset", - "counter-increment", - "resize", - "-webkit-user-select", - "-moz-user-select", - "-ms-user-select", - "-o-user-select", - "user-select", - "nav-index", - "nav-up", - "nav-right", - "nav-down", - "nav-left", - "background", - "background-color", - "background-image", - "-ms-filter:\\'progid:DXImageTransform.Microsoft.gradient", - "filter:progid:DXImageTransform.Microsoft.gradient", - "filter:progid:DXImageTransform.Microsoft.AlphaImageLoader", - "filter", - "background-repeat", - "background-attachment", - "background-position", - "background-position-x", - "background-position-y", - "-webkit-background-clip", - "-moz-background-clip", - "background-clip", - "background-origin", - "-webkit-background-size", - "-moz-background-size", - "-o-background-size", - "background-size", - "border", - "border-color", - "border-style", - "border-width", - "border-top", - "border-top-color", - "border-top-style", - "border-top-width", - "border-right", - "border-right-color", - "border-right-style", - "border-right-width", - "border-bottom", - "border-bottom-color", - "border-bottom-style", - "border-bottom-width", - "border-left", - "border-left-color", - "border-left-style", - "border-left-width", - "border-radius", - "border-top-left-radius", - "border-top-right-radius", - "border-bottom-right-radius", - "border-bottom-left-radius", - "-webkit-border-image", - "-moz-border-image", - "-o-border-image", - "border-image", - "-webkit-border-image-source", - "-moz-border-image-source", - "-o-border-image-source", - "border-image-source", - "-webkit-border-image-slice", - "-moz-border-image-slice", - "-o-border-image-slice", - "border-image-slice", - "-webkit-border-image-width", - "-moz-border-image-width", - "-o-border-image-width", - "border-image-width", - "-webkit-border-image-outset", - "-moz-border-image-outset", - "-o-border-image-outset", - "border-image-outset", - "-webkit-border-image-repeat", - "-moz-border-image-repeat", - "-o-border-image-repeat", - "border-image-repeat", - "outline", - "outline-width", - "outline-style", - "outline-color", - "outline-offset", - "-webkit-box-shadow", - "-moz-box-shadow", - "box-shadow", - "filter:progid:DXImageTransform.Microsoft.Alpha(Opacity", - "-ms-filter:\\'progid:DXImageTransform.Microsoft.Alpha", - "opacity", - "-ms-interpolation-mode", - "-webkit-transition", - "-moz-transition", - "-ms-transition", - "-o-transition", - "transition", - "-webkit-transition-delay", - "-moz-transition-delay", - "-ms-transition-delay", - "-o-transition-delay", - "transition-delay", - "-webkit-transition-timing-function", - "-moz-transition-timing-function", - "-ms-transition-timing-function", - "-o-transition-timing-function", - "transition-timing-function", - "-webkit-transition-duration", - "-moz-transition-duration", - "-ms-transition-duration", - "-o-transition-duration", - "transition-duration", - "-webkit-transition-property", - "-moz-transition-property", - "-ms-transition-property", - "-o-transition-property", - "transition-property", - "-webkit-transform", - "-moz-transform", - "-ms-transform", - "-o-transform", - "transform", - "-webkit-transform-origin", - "-moz-transform-origin", - "-ms-transform-origin", - "-o-transform-origin", - "transform-origin", - "-webkit-animation", - "-moz-animation", - "-ms-animation", - "-o-animation", - "animation", - "-webkit-animation-name", - "-moz-animation-name", - "-ms-animation-name", - "-o-animation-name", - "animation-name", - "-webkit-animation-duration", - "-moz-animation-duration", - "-ms-animation-duration", - "-o-animation-duration", - "animation-duration", - "-webkit-animation-play-state", - "-moz-animation-play-state", - "-ms-animation-play-state", - "-o-animation-play-state", - "animation-play-state", - "-webkit-animation-timing-function", - "-moz-animation-timing-function", - "-ms-animation-timing-function", - "-o-animation-timing-function", - "animation-timing-function", - "-webkit-animation-delay", - "-moz-animation-delay", - "-ms-animation-delay", - "-o-animation-delay", - "animation-delay", - "-webkit-animation-iteration-count", - "-moz-animation-iteration-count", - "-ms-animation-iteration-count", - "-o-animation-iteration-count", - "animation-iteration-count", - "-webkit-animation-direction", - "-moz-animation-direction", - "-ms-animation-direction", - "-o-animation-direction", - "animation-direction" - ] - ] -} diff --git a/src/UI/Content/Bootstrap/.csslintrc b/src/UI/Content/Bootstrap/.csslintrc deleted file mode 100644 index 005b86236..000000000 --- a/src/UI/Content/Bootstrap/.csslintrc +++ /dev/null @@ -1,19 +0,0 @@ -{ - "adjoining-classes": false, - "box-sizing": false, - "box-model": false, - "compatible-vendor-prefixes": false, - "floats": false, - "font-sizes": false, - "gradients": false, - "important": false, - "known-properties": false, - "outline-none": false, - "qualified-headings": false, - "regex-selectors": false, - "shorthand": false, - "text-indent": false, - "unique-headings": false, - "universal-selector": false, - "unqualified-attributes": false -} diff --git a/src/UI/Content/Bootstrap/alerts.less b/src/UI/Content/Bootstrap/alerts.less deleted file mode 100644 index c4199db92..000000000 --- a/src/UI/Content/Bootstrap/alerts.less +++ /dev/null @@ -1,73 +0,0 @@ -// -// Alerts -// -------------------------------------------------- - - -// Base styles -// ------------------------- - -.alert { - padding: @alert-padding; - margin-bottom: @line-height-computed; - border: 1px solid transparent; - border-radius: @alert-border-radius; - - // Headings for larger alerts - h4 { - margin-top: 0; - // Specified for the h4 to prevent conflicts of changing @headings-color - color: inherit; - } - - // Provide class for links that match alerts - .alert-link { - font-weight: @alert-link-font-weight; - } - - // Improve alignment and spacing of inner content - > p, - > ul { - margin-bottom: 0; - } - - > p + p { - margin-top: 5px; - } -} - -// Dismissible alerts -// -// Expand the right padding and account for the close button's positioning. - -.alert-dismissable, // The misspelled .alert-dismissable was deprecated in 3.2.0. -.alert-dismissible { - padding-right: (@alert-padding + 20); - - // Adjust close link position - .close { - position: relative; - top: -2px; - right: -21px; - color: inherit; - } -} - -// Alternate styles -// -// Generate contextual modifier classes for colorizing the alert. - -.alert-success { - .alert-variant(@alert-success-bg; @alert-success-border; @alert-success-text); -} - -.alert-info { - .alert-variant(@alert-info-bg; @alert-info-border; @alert-info-text); -} - -.alert-warning { - .alert-variant(@alert-warning-bg; @alert-warning-border; @alert-warning-text); -} - -.alert-danger { - .alert-variant(@alert-danger-bg; @alert-danger-border; @alert-danger-text); -} diff --git a/src/UI/Content/Bootstrap/badges.less b/src/UI/Content/Bootstrap/badges.less deleted file mode 100644 index 6ee16dca4..000000000 --- a/src/UI/Content/Bootstrap/badges.less +++ /dev/null @@ -1,66 +0,0 @@ -// -// Badges -// -------------------------------------------------- - - -// Base class -.badge { - display: inline-block; - min-width: 10px; - padding: 3px 7px; - font-size: @font-size-small; - font-weight: @badge-font-weight; - color: @badge-color; - line-height: @badge-line-height; - vertical-align: middle; - white-space: nowrap; - text-align: center; - background-color: @badge-bg; - border-radius: @badge-border-radius; - - // Empty badges collapse automatically (not available in IE8) - &:empty { - display: none; - } - - // Quick fix for badges in buttons - .btn & { - position: relative; - top: -1px; - } - - .btn-xs &, - .btn-group-xs > .btn & { - top: 0; - padding: 1px 5px; - } - - // Hover state, but only for links - a& { - &:hover, - &:focus { - color: @badge-link-hover-color; - text-decoration: none; - cursor: pointer; - } - } - - // Account for badges in navs - .list-group-item.active > &, - .nav-pills > .active > a > & { - color: @badge-active-color; - background-color: @badge-active-bg; - } - - .list-group-item > & { - float: right; - } - - .list-group-item > & + & { - margin-right: 5px; - } - - .nav-pills > li > a > & { - margin-left: 3px; - } -} diff --git a/src/UI/Content/Bootstrap/bootstrap.less b/src/UI/Content/Bootstrap/bootstrap.less deleted file mode 100644 index 4b9916e6c..000000000 --- a/src/UI/Content/Bootstrap/bootstrap.less +++ /dev/null @@ -1,56 +0,0 @@ -/*! - * Bootstrap v3.3.5 (http://getbootstrap.com) - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - */ - -// Core variables and mixins -@import "variables.less"; -@import "mixins.less"; - -// Reset and dependencies -@import "normalize.less"; -@import "print.less"; -@import "glyphicons.less"; - -// Core CSS -@import "scaffolding.less"; -@import "type.less"; -@import "code.less"; -@import "grid.less"; -@import "tables.less"; -@import "forms.less"; -@import "buttons.less"; - -// Components -@import "component-animations.less"; -@import "dropdowns.less"; -@import "button-groups.less"; -@import "input-groups.less"; -@import "navs.less"; -@import "navbar.less"; -@import "breadcrumbs.less"; -@import "pagination.less"; -@import "pager.less"; -@import "labels.less"; -@import "badges.less"; -@import "jumbotron.less"; -@import "thumbnails.less"; -@import "alerts.less"; -@import "progress-bars.less"; -@import "media.less"; -@import "list-group.less"; -@import "panels.less"; -@import "responsive-embed.less"; -@import "wells.less"; -@import "close.less"; - -// Components w/ JavaScript -@import "modals.less"; -@import "tooltip.less"; -@import "popovers.less"; -@import "carousel.less"; - -// Utility classes -@import "utilities.less"; -@import "responsive-utilities.less"; diff --git a/src/UI/Content/Bootstrap/breadcrumbs.less b/src/UI/Content/Bootstrap/breadcrumbs.less deleted file mode 100644 index cb01d503f..000000000 --- a/src/UI/Content/Bootstrap/breadcrumbs.less +++ /dev/null @@ -1,26 +0,0 @@ -// -// Breadcrumbs -// -------------------------------------------------- - - -.breadcrumb { - padding: @breadcrumb-padding-vertical @breadcrumb-padding-horizontal; - margin-bottom: @line-height-computed; - list-style: none; - background-color: @breadcrumb-bg; - border-radius: @border-radius-base; - - > li { - display: inline-block; - - + li:before { - content: "@{breadcrumb-separator}\00a0"; // Unicode space added since inline-block means non-collapsing white-space - padding: 0 5px; - color: @breadcrumb-color; - } - } - - > .active { - color: @breadcrumb-active-color; - } -} diff --git a/src/UI/Content/Bootstrap/button-groups.less b/src/UI/Content/Bootstrap/button-groups.less deleted file mode 100644 index 6a0c5a865..000000000 --- a/src/UI/Content/Bootstrap/button-groups.less +++ /dev/null @@ -1,244 +0,0 @@ -// -// Button groups -// -------------------------------------------------- - -// Make the div behave like a button -.btn-group, -.btn-group-vertical { - position: relative; - display: inline-block; - vertical-align: middle; // match .btn alignment given font-size hack above - > .btn { - position: relative; - float: left; - // Bring the "active" button to the front - &:hover, - &:focus, - &:active, - &.active { - z-index: 2; - } - } -} - -// Prevent double borders when buttons are next to each other -.btn-group { - .btn + .btn, - .btn + .btn-group, - .btn-group + .btn, - .btn-group + .btn-group { - margin-left: -1px; - } -} - -// Optional: Group multiple button groups together for a toolbar -.btn-toolbar { - margin-left: -5px; // Offset the first child's margin - &:extend(.clearfix all); - - .btn, - .btn-group, - .input-group { - float: left; - } - > .btn, - > .btn-group, - > .input-group { - margin-left: 5px; - } -} - -.btn-group > .btn:not(:first-child):not(:last-child):not(.dropdown-toggle) { - border-radius: 0; -} - -// Set corners individual because sometimes a single button can be in a .btn-group and we need :first-child and :last-child to both match -.btn-group > .btn:first-child { - margin-left: 0; - &:not(:last-child):not(.dropdown-toggle) { - .border-right-radius(0); - } -} -// Need .dropdown-toggle since :last-child doesn't apply given a .dropdown-menu immediately after it -.btn-group > .btn:last-child:not(:first-child), -.btn-group > .dropdown-toggle:not(:first-child) { - .border-left-radius(0); -} - -// Custom edits for including btn-groups within btn-groups (useful for including dropdown buttons within a btn-group) -.btn-group > .btn-group { - float: left; -} -.btn-group > .btn-group:not(:first-child):not(:last-child) > .btn { - border-radius: 0; -} -.btn-group > .btn-group:first-child:not(:last-child) { - > .btn:last-child, - > .dropdown-toggle { - .border-right-radius(0); - } -} -.btn-group > .btn-group:last-child:not(:first-child) > .btn:first-child { - .border-left-radius(0); -} - -// On active and open, don't show outline -.btn-group .dropdown-toggle:active, -.btn-group.open .dropdown-toggle { - outline: 0; -} - - -// Sizing -// -// Remix the default button sizing classes into new ones for easier manipulation. - -.btn-group-xs > .btn { &:extend(.btn-xs); } -.btn-group-sm > .btn { &:extend(.btn-sm); } -.btn-group-lg > .btn { &:extend(.btn-lg); } - - -// Split button dropdowns -// ---------------------- - -// Give the line between buttons some depth -.btn-group > .btn + .dropdown-toggle { - padding-left: 8px; - padding-right: 8px; -} -.btn-group > .btn-lg + .dropdown-toggle { - padding-left: 12px; - padding-right: 12px; -} - -// The clickable button for toggling the menu -// Remove the gradient and set the same inset shadow as the :active state -.btn-group.open .dropdown-toggle { - .box-shadow(inset 0 3px 5px rgba(0,0,0,.125)); - - // Show no shadow for `.btn-link` since it has no other button styles. - &.btn-link { - .box-shadow(none); - } -} - - -// Reposition the caret -.btn .caret { - margin-left: 0; -} -// Carets in other button sizes -.btn-lg .caret { - border-width: @caret-width-large @caret-width-large 0; - border-bottom-width: 0; -} -// Upside down carets for .dropup -.dropup .btn-lg .caret { - border-width: 0 @caret-width-large @caret-width-large; -} - - -// Vertical button groups -// ---------------------- - -.btn-group-vertical { - > .btn, - > .btn-group, - > .btn-group > .btn { - display: block; - float: none; - width: 100%; - max-width: 100%; - } - - // Clear floats so dropdown menus can be properly placed - > .btn-group { - &:extend(.clearfix all); - > .btn { - float: none; - } - } - - > .btn + .btn, - > .btn + .btn-group, - > .btn-group + .btn, - > .btn-group + .btn-group { - margin-top: -1px; - margin-left: 0; - } -} - -.btn-group-vertical > .btn { - &:not(:first-child):not(:last-child) { - border-radius: 0; - } - &:first-child:not(:last-child) { - border-top-right-radius: @btn-border-radius-base; - .border-bottom-radius(0); - } - &:last-child:not(:first-child) { - border-bottom-left-radius: @btn-border-radius-base; - .border-top-radius(0); - } -} -.btn-group-vertical > .btn-group:not(:first-child):not(:last-child) > .btn { - border-radius: 0; -} -.btn-group-vertical > .btn-group:first-child:not(:last-child) { - > .btn:last-child, - > .dropdown-toggle { - .border-bottom-radius(0); - } -} -.btn-group-vertical > .btn-group:last-child:not(:first-child) > .btn:first-child { - .border-top-radius(0); -} - - -// Justified button groups -// ---------------------- - -.btn-group-justified { - display: table; - width: 100%; - table-layout: fixed; - border-collapse: separate; - > .btn, - > .btn-group { - float: none; - display: table-cell; - width: 1%; - } - > .btn-group .btn { - width: 100%; - } - - > .btn-group .dropdown-menu { - left: auto; - } -} - - -// Checkbox and radio options -// -// In order to support the browser's form validation feedback, powered by the -// `required` attribute, we have to "hide" the inputs via `clip`. We cannot use -// `display: none;` or `visibility: hidden;` as that also hides the popover. -// Simply visually hiding the inputs via `opacity` would leave them clickable in -// certain cases which is prevented by using `clip` and `pointer-events`. -// This way, we ensure a DOM element is visible to position the popover from. -// -// See https://github.com/twbs/bootstrap/pull/12794 and -// https://github.com/twbs/bootstrap/pull/14559 for more information. - -[data-toggle="buttons"] { - > .btn, - > .btn-group > .btn { - input[type="radio"], - input[type="checkbox"] { - position: absolute; - clip: rect(0,0,0,0); - pointer-events: none; - } - } -} diff --git a/src/UI/Content/Bootstrap/buttons.less b/src/UI/Content/Bootstrap/buttons.less deleted file mode 100644 index 9cbb8f416..000000000 --- a/src/UI/Content/Bootstrap/buttons.less +++ /dev/null @@ -1,166 +0,0 @@ -// -// Buttons -// -------------------------------------------------- - - -// Base styles -// -------------------------------------------------- - -.btn { - display: inline-block; - margin-bottom: 0; // For input.btn - font-weight: @btn-font-weight; - text-align: center; - vertical-align: middle; - touch-action: manipulation; - cursor: pointer; - background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214 - border: 1px solid transparent; - white-space: nowrap; - .button-size(@padding-base-vertical; @padding-base-horizontal; @font-size-base; @line-height-base; @btn-border-radius-base); - .user-select(none); - - &, - &:active, - &.active { - &:focus, - &.focus { - .tab-focus(); - } - } - - &:hover, - &:focus, - &.focus { - color: @btn-default-color; - text-decoration: none; - } - - &:active, - &.active { - outline: 0; - background-image: none; - .box-shadow(inset 0 3px 5px rgba(0,0,0,.125)); - } - - &.disabled, - &[disabled], - fieldset[disabled] & { - cursor: @cursor-disabled; - .opacity(.65); - .box-shadow(none); - } - - a& { - &.disabled, - fieldset[disabled] & { - pointer-events: none; // Future-proof disabling of clicks on `<a>` elements - } - } -} - - -// Alternate buttons -// -------------------------------------------------- - -.btn-default { - .button-variant(@btn-default-color; @btn-default-bg; @btn-default-border); -} -.btn-primary { - .button-variant(@btn-primary-color; @btn-primary-bg; @btn-primary-border); -} -// Success appears as green -.btn-success { - .button-variant(@btn-success-color; @btn-success-bg; @btn-success-border); -} -// Info appears as blue-green -.btn-info { - .button-variant(@btn-info-color; @btn-info-bg; @btn-info-border); -} -// Warning appears as orange -.btn-warning { - .button-variant(@btn-warning-color; @btn-warning-bg; @btn-warning-border); -} -// Danger and error appear as red -.btn-danger { - .button-variant(@btn-danger-color; @btn-danger-bg; @btn-danger-border); -} - - -// Link buttons -// ------------------------- - -// Make a button look and behave like a link -.btn-link { - color: @link-color; - font-weight: normal; - border-radius: 0; - - &, - &:active, - &.active, - &[disabled], - fieldset[disabled] & { - background-color: transparent; - .box-shadow(none); - } - &, - &:hover, - &:focus, - &:active { - border-color: transparent; - } - &:hover, - &:focus { - color: @link-hover-color; - text-decoration: @link-hover-decoration; - background-color: transparent; - } - &[disabled], - fieldset[disabled] & { - &:hover, - &:focus { - color: @btn-link-disabled-color; - text-decoration: none; - } - } -} - - -// Button Sizes -// -------------------------------------------------- - -.btn-lg { - // line-height: ensure even-numbered height of button next to large input - .button-size(@padding-large-vertical; @padding-large-horizontal; @font-size-large; @line-height-large; @btn-border-radius-large); -} -.btn-sm { - // line-height: ensure proper height of button next to small input - .button-size(@padding-small-vertical; @padding-small-horizontal; @font-size-small; @line-height-small; @btn-border-radius-small); -} -.btn-xs { - .button-size(@padding-xs-vertical; @padding-xs-horizontal; @font-size-small; @line-height-small; @btn-border-radius-small); -} - - -// Block button -// -------------------------------------------------- - -.btn-block { - display: block; - width: 100%; -} - -// Vertically space out multiple block buttons -.btn-block + .btn-block { - margin-top: 5px; -} - -// Specificity overrides -input[type="submit"], -input[type="reset"], -input[type="button"] { - &.btn-block { - width: 100%; - } -} diff --git a/src/UI/Content/Bootstrap/carousel.less b/src/UI/Content/Bootstrap/carousel.less deleted file mode 100644 index 87ed6961d..000000000 --- a/src/UI/Content/Bootstrap/carousel.less +++ /dev/null @@ -1,269 +0,0 @@ -// -// Carousel -// -------------------------------------------------- - - -// Wrapper for the slide container and indicators -.carousel { - position: relative; -} - -.carousel-inner { - position: relative; - overflow: hidden; - width: 100%; - - > .item { - display: none; - position: relative; - .transition(.6s ease-in-out left); - - // Account for jankitude on images - > img, - > a > img { - &:extend(.img-responsive); - line-height: 1; - } - - // WebKit CSS3 transforms for supported devices - @media all and (transform-3d), (-webkit-transform-3d) { - .transition-transform(~'0.6s ease-in-out'); - .backface-visibility(~'hidden'); - .perspective(1000px); - - &.next, - &.active.right { - .translate3d(100%, 0, 0); - left: 0; - } - &.prev, - &.active.left { - .translate3d(-100%, 0, 0); - left: 0; - } - &.next.left, - &.prev.right, - &.active { - .translate3d(0, 0, 0); - left: 0; - } - } - } - - > .active, - > .next, - > .prev { - display: block; - } - - > .active { - left: 0; - } - - > .next, - > .prev { - position: absolute; - top: 0; - width: 100%; - } - - > .next { - left: 100%; - } - > .prev { - left: -100%; - } - > .next.left, - > .prev.right { - left: 0; - } - - > .active.left { - left: -100%; - } - > .active.right { - left: 100%; - } - -} - -// Left/right controls for nav -// --------------------------- - -.carousel-control { - position: absolute; - top: 0; - left: 0; - bottom: 0; - width: @carousel-control-width; - .opacity(@carousel-control-opacity); - font-size: @carousel-control-font-size; - color: @carousel-control-color; - text-align: center; - text-shadow: @carousel-text-shadow; - // We can't have this transition here because WebKit cancels the carousel - // animation if you trip this while in the middle of another animation. - - // Set gradients for backgrounds - &.left { - #gradient > .horizontal(@start-color: rgba(0,0,0,.5); @end-color: rgba(0,0,0,.0001)); - } - &.right { - left: auto; - right: 0; - #gradient > .horizontal(@start-color: rgba(0,0,0,.0001); @end-color: rgba(0,0,0,.5)); - } - - // Hover/focus state - &:hover, - &:focus { - outline: 0; - color: @carousel-control-color; - text-decoration: none; - .opacity(.9); - } - - // Toggles - .icon-prev, - .icon-next, - .glyphicon-chevron-left, - .glyphicon-chevron-right { - position: absolute; - top: 50%; - margin-top: -10px; - z-index: 5; - display: inline-block; - } - .icon-prev, - .glyphicon-chevron-left { - left: 50%; - margin-left: -10px; - } - .icon-next, - .glyphicon-chevron-right { - right: 50%; - margin-right: -10px; - } - .icon-prev, - .icon-next { - width: 20px; - height: 20px; - line-height: 1; - font-family: serif; - } - - - .icon-prev { - &:before { - content: '\2039';// SINGLE LEFT-POINTING ANGLE QUOTATION MARK (U+2039) - } - } - .icon-next { - &:before { - content: '\203a';// SINGLE RIGHT-POINTING ANGLE QUOTATION MARK (U+203A) - } - } -} - -// Optional indicator pips -// -// Add an unordered list with the following class and add a list item for each -// slide your carousel holds. - -.carousel-indicators { - position: absolute; - bottom: 10px; - left: 50%; - z-index: 15; - width: 60%; - margin-left: -30%; - padding-left: 0; - list-style: none; - text-align: center; - - li { - display: inline-block; - width: 10px; - height: 10px; - margin: 1px; - text-indent: -999px; - border: 1px solid @carousel-indicator-border-color; - border-radius: 10px; - cursor: pointer; - - // IE8-9 hack for event handling - // - // Internet Explorer 8-9 does not support clicks on elements without a set - // `background-color`. We cannot use `filter` since that's not viewed as a - // background color by the browser. Thus, a hack is needed. - // See https://developer.mozilla.org/en-US/docs/Web/Events/click#Internet_Explorer - // - // For IE8, we set solid black as it doesn't support `rgba()`. For IE9, we - // set alpha transparency for the best results possible. - background-color: #000 \9; // IE8 - background-color: rgba(0,0,0,0); // IE9 - } - .active { - margin: 0; - width: 12px; - height: 12px; - background-color: @carousel-indicator-active-bg; - } -} - -// Optional captions -// ----------------------------- -// Hidden by default for smaller viewports -.carousel-caption { - position: absolute; - left: 15%; - right: 15%; - bottom: 20px; - z-index: 10; - padding-top: 20px; - padding-bottom: 20px; - color: @carousel-caption-color; - text-align: center; - text-shadow: @carousel-text-shadow; - & .btn { - text-shadow: none; // No shadow for button elements in carousel-caption - } -} - - -// Scale up controls for tablets and up -@media screen and (min-width: @screen-sm-min) { - - // Scale up the controls a smidge - .carousel-control { - .glyphicon-chevron-left, - .glyphicon-chevron-right, - .icon-prev, - .icon-next { - width: 30px; - height: 30px; - margin-top: -15px; - font-size: 30px; - } - .glyphicon-chevron-left, - .icon-prev { - margin-left: -15px; - } - .glyphicon-chevron-right, - .icon-next { - margin-right: -15px; - } - } - - // Show and left align the captions - .carousel-caption { - left: 20%; - right: 20%; - padding-bottom: 30px; - } - - // Move up the indicators - .carousel-indicators { - bottom: 20px; - } -} diff --git a/src/UI/Content/Bootstrap/close.less b/src/UI/Content/Bootstrap/close.less deleted file mode 100644 index 6d5bfe087..000000000 --- a/src/UI/Content/Bootstrap/close.less +++ /dev/null @@ -1,34 +0,0 @@ -// -// Close icons -// -------------------------------------------------- - - -.close { - float: right; - font-size: (@font-size-base * 1.5); - font-weight: @close-font-weight; - line-height: 1; - color: @close-color; - text-shadow: @close-text-shadow; - .opacity(.2); - - &:hover, - &:focus { - color: @close-color; - text-decoration: none; - cursor: pointer; - .opacity(.5); - } - - // Additional properties for button version - // iOS requires the button element instead of an anchor tag. - // If you want the anchor version, it requires `href="#"`. - // See https://developer.mozilla.org/en-US/docs/Web/Events/click#Safari_Mobile - button& { - padding: 0; - cursor: pointer; - background: transparent; - border: 0; - -webkit-appearance: none; - } -} diff --git a/src/UI/Content/Bootstrap/code.less b/src/UI/Content/Bootstrap/code.less deleted file mode 100644 index a08b4d48c..000000000 --- a/src/UI/Content/Bootstrap/code.less +++ /dev/null @@ -1,69 +0,0 @@ -// -// Code (inline and block) -// -------------------------------------------------- - - -// Inline and block code styles -code, -kbd, -pre, -samp { - font-family: @font-family-monospace; -} - -// Inline code -code { - padding: 2px 4px; - font-size: 90%; - color: @code-color; - background-color: @code-bg; - border-radius: @border-radius-base; -} - -// User input typically entered via keyboard -kbd { - padding: 2px 4px; - font-size: 90%; - color: @kbd-color; - background-color: @kbd-bg; - border-radius: @border-radius-small; - box-shadow: inset 0 -1px 0 rgba(0,0,0,.25); - - kbd { - padding: 0; - font-size: 100%; - font-weight: bold; - box-shadow: none; - } -} - -// Blocks of code -pre { - display: block; - padding: ((@line-height-computed - 1) / 2); - margin: 0 0 (@line-height-computed / 2); - font-size: (@font-size-base - 1); // 14px to 13px - line-height: @line-height-base; - word-break: break-all; - word-wrap: break-word; - color: @pre-color; - background-color: @pre-bg; - border: 1px solid @pre-border-color; - border-radius: @border-radius-base; - - // Account for some code outputs that place code tags in pre tags - code { - padding: 0; - font-size: inherit; - color: inherit; - white-space: pre-wrap; - background-color: transparent; - border-radius: 0; - } -} - -// Enable scrollable blocks of code -.pre-scrollable { - max-height: @pre-scrollable-max-height; - overflow-y: scroll; -} diff --git a/src/UI/Content/Bootstrap/component-animations.less b/src/UI/Content/Bootstrap/component-animations.less deleted file mode 100644 index 0bcee910a..000000000 --- a/src/UI/Content/Bootstrap/component-animations.less +++ /dev/null @@ -1,33 +0,0 @@ -// -// Component animations -// -------------------------------------------------- - -// Heads up! -// -// We don't use the `.opacity()` mixin here since it causes a bug with text -// fields in IE7-8. Source: https://github.com/twbs/bootstrap/pull/3552. - -.fade { - opacity: 0; - .transition(opacity .15s linear); - &.in { - opacity: 1; - } -} - -.collapse { - display: none; - - &.in { display: block; } - tr&.in { display: table-row; } - tbody&.in { display: table-row-group; } -} - -.collapsing { - position: relative; - height: 0; - overflow: hidden; - .transition-property(~"height, visibility"); - .transition-duration(.35s); - .transition-timing-function(ease); -} diff --git a/src/UI/Content/Bootstrap/dropdowns.less b/src/UI/Content/Bootstrap/dropdowns.less deleted file mode 100644 index f6876c1a9..000000000 --- a/src/UI/Content/Bootstrap/dropdowns.less +++ /dev/null @@ -1,216 +0,0 @@ -// -// Dropdown menus -// -------------------------------------------------- - - -// Dropdown arrow/caret -.caret { - display: inline-block; - width: 0; - height: 0; - margin-left: 2px; - vertical-align: middle; - border-top: @caret-width-base dashed; - border-top: @caret-width-base solid ~"\9"; // IE8 - border-right: @caret-width-base solid transparent; - border-left: @caret-width-base solid transparent; -} - -// The dropdown wrapper (div) -.dropup, -.dropdown { - position: relative; -} - -// Prevent the focus on the dropdown toggle when closing dropdowns -.dropdown-toggle:focus { - outline: 0; -} - -// The dropdown menu (ul) -.dropdown-menu { - position: absolute; - top: 100%; - left: 0; - z-index: @zindex-dropdown; - display: none; // none by default, but block on "open" of the menu - float: left; - min-width: 160px; - padding: 5px 0; - margin: 2px 0 0; // override default ul - list-style: none; - font-size: @font-size-base; - text-align: left; // Ensures proper alignment if parent has it changed (e.g., modal footer) - background-color: @dropdown-bg; - border: 1px solid @dropdown-fallback-border; // IE8 fallback - border: 1px solid @dropdown-border; - border-radius: @border-radius-base; - .box-shadow(0 6px 12px rgba(0,0,0,.175)); - background-clip: padding-box; - - // Aligns the dropdown menu to right - // - // Deprecated as of 3.1.0 in favor of `.dropdown-menu-[dir]` - &.pull-right { - right: 0; - left: auto; - } - - // Dividers (basically an hr) within the dropdown - .divider { - .nav-divider(@dropdown-divider-bg); - } - - // Links within the dropdown menu - > li > a { - display: block; - padding: 3px 20px; - clear: both; - font-weight: normal; - line-height: @line-height-base; - color: @dropdown-link-color; - white-space: nowrap; // prevent links from randomly breaking onto new lines - } -} - -// Hover/Focus state -.dropdown-menu > li > a { - &:hover, - &:focus { - text-decoration: none; - color: @dropdown-link-hover-color; - background-color: @dropdown-link-hover-bg; - } -} - -// Active state -.dropdown-menu > .active > a { - &, - &:hover, - &:focus { - color: @dropdown-link-active-color; - text-decoration: none; - outline: 0; - background-color: @dropdown-link-active-bg; - } -} - -// Disabled state -// -// Gray out text and ensure the hover/focus state remains gray - -.dropdown-menu > .disabled > a { - &, - &:hover, - &:focus { - color: @dropdown-link-disabled-color; - } - - // Nuke hover/focus effects - &:hover, - &:focus { - text-decoration: none; - background-color: transparent; - background-image: none; // Remove CSS gradient - .reset-filter(); - cursor: @cursor-disabled; - } -} - -// Open state for the dropdown -.open { - // Show the menu - > .dropdown-menu { - display: block; - } - - // Remove the outline when :focus is triggered - > a { - outline: 0; - } -} - -// Menu positioning -// -// Add extra class to `.dropdown-menu` to flip the alignment of the dropdown -// menu with the parent. -.dropdown-menu-right { - left: auto; // Reset the default from `.dropdown-menu` - right: 0; -} -// With v3, we enabled auto-flipping if you have a dropdown within a right -// aligned nav component. To enable the undoing of that, we provide an override -// to restore the default dropdown menu alignment. -// -// This is only for left-aligning a dropdown menu within a `.navbar-right` or -// `.pull-right` nav component. -.dropdown-menu-left { - left: 0; - right: auto; -} - -// Dropdown section headers -.dropdown-header { - display: block; - padding: 3px 20px; - font-size: @font-size-small; - line-height: @line-height-base; - color: @dropdown-header-color; - white-space: nowrap; // as with > li > a -} - -// Backdrop to catch body clicks on mobile, etc. -.dropdown-backdrop { - position: fixed; - left: 0; - right: 0; - bottom: 0; - top: 0; - z-index: (@zindex-dropdown - 10); -} - -// Right aligned dropdowns -.pull-right > .dropdown-menu { - right: 0; - left: auto; -} - -// Allow for dropdowns to go bottom up (aka, dropup-menu) -// -// Just add .dropup after the standard .dropdown class and you're set, bro. -// TODO: abstract this so that the navbar fixed styles are not placed here? - -.dropup, -.navbar-fixed-bottom .dropdown { - // Reverse the caret - .caret { - border-top: 0; - border-bottom: @caret-width-base dashed; - border-bottom: @caret-width-base solid ~"\9"; // IE8 - content: ""; - } - // Different positioning for bottom up menu - .dropdown-menu { - top: auto; - bottom: 100%; - margin-bottom: 2px; - } -} - - -// Component alignment -// -// Reiterate per navbar.less and the modified component alignment there. - -@media (min-width: @grid-float-breakpoint) { - .navbar-right { - .dropdown-menu { - .dropdown-menu-right(); - } - // Necessary for overrides of the default right aligned menu. - // Will remove come v4 in all likelihood. - .dropdown-menu-left { - .dropdown-menu-left(); - } - } -} diff --git a/src/UI/Content/Bootstrap/forms.less b/src/UI/Content/Bootstrap/forms.less deleted file mode 100644 index b064ede46..000000000 --- a/src/UI/Content/Bootstrap/forms.less +++ /dev/null @@ -1,607 +0,0 @@ -// -// Forms -// -------------------------------------------------- - - -// Normalize non-controls -// -// Restyle and baseline non-control form elements. - -fieldset { - padding: 0; - margin: 0; - border: 0; - // Chrome and Firefox set a `min-width: min-content;` on fieldsets, - // so we reset that to ensure it behaves more like a standard block element. - // See https://github.com/twbs/bootstrap/issues/12359. - min-width: 0; -} - -legend { - display: block; - width: 100%; - padding: 0; - margin-bottom: @line-height-computed; - font-size: (@font-size-base * 1.5); - line-height: inherit; - color: @legend-color; - border: 0; - border-bottom: 1px solid @legend-border-color; -} - -label { - display: inline-block; - max-width: 100%; // Force IE8 to wrap long content (see https://github.com/twbs/bootstrap/issues/13141) - margin-bottom: 5px; - font-weight: bold; -} - - -// Normalize form controls -// -// While most of our form styles require extra classes, some basic normalization -// is required to ensure optimum display with or without those classes to better -// address browser inconsistencies. - -// Override content-box in Normalize (* isn't specific enough) -input[type="search"] { - .box-sizing(border-box); -} - -// Position radios and checkboxes better -input[type="radio"], -input[type="checkbox"] { - margin: 4px 0 0; - margin-top: 1px \9; // IE8-9 - line-height: normal; -} - -input[type="file"] { - display: block; -} - -// Make range inputs behave like textual form controls -input[type="range"] { - display: block; - width: 100%; -} - -// Make multiple select elements height not fixed -select[multiple], -select[size] { - height: auto; -} - -// Focus for file, radio, and checkbox -input[type="file"]:focus, -input[type="radio"]:focus, -input[type="checkbox"]:focus { - .tab-focus(); -} - -// Adjust output element -output { - display: block; - padding-top: (@padding-base-vertical + 1); - font-size: @font-size-base; - line-height: @line-height-base; - color: @input-color; -} - - -// Common form controls -// -// Shared size and type resets for form controls. Apply `.form-control` to any -// of the following form controls: -// -// select -// textarea -// input[type="text"] -// input[type="password"] -// input[type="datetime"] -// input[type="datetime-local"] -// input[type="date"] -// input[type="month"] -// input[type="time"] -// input[type="week"] -// input[type="number"] -// input[type="email"] -// input[type="url"] -// input[type="search"] -// input[type="tel"] -// input[type="color"] - -.form-control { - display: block; - width: 100%; - height: @input-height-base; // Make inputs at least the height of their button counterpart (base line-height + padding + border) - padding: @padding-base-vertical @padding-base-horizontal; - font-size: @font-size-base; - line-height: @line-height-base; - color: @input-color; - background-color: @input-bg; - background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214 - border: 1px solid @input-border; - border-radius: @input-border-radius; // Note: This has no effect on <select>s in some browsers, due to the limited stylability of <select>s in CSS. - .box-shadow(inset 0 1px 1px rgba(0,0,0,.075)); - .transition(~"border-color ease-in-out .15s, box-shadow ease-in-out .15s"); - - // Customize the `:focus` state to imitate native WebKit styles. - .form-control-focus(); - - // Placeholder - .placeholder(); - - // Disabled and read-only inputs - // - // HTML5 says that controls under a fieldset > legend:first-child won't be - // disabled if the fieldset is disabled. Due to implementation difficulty, we - // don't honor that edge case; we style them as disabled anyway. - &[disabled], - &[readonly], - fieldset[disabled] & { - background-color: @input-bg-disabled; - opacity: 1; // iOS fix for unreadable disabled content; see https://github.com/twbs/bootstrap/issues/11655 - } - - &[disabled], - fieldset[disabled] & { - cursor: @cursor-disabled; - } - - // Reset height for `textarea`s - textarea& { - height: auto; - } -} - - -// Search inputs in iOS -// -// This overrides the extra rounded corners on search inputs in iOS so that our -// `.form-control` class can properly style them. Note that this cannot simply -// be added to `.form-control` as it's not specific enough. For details, see -// https://github.com/twbs/bootstrap/issues/11586. - -input[type="search"] { - -webkit-appearance: none; -} - - -// Special styles for iOS temporal inputs -// -// In Mobile Safari, setting `display: block` on temporal inputs causes the -// text within the input to become vertically misaligned. As a workaround, we -// set a pixel line-height that matches the given height of the input, but only -// for Safari. See https://bugs.webkit.org/show_bug.cgi?id=139848 -// -// Note that as of 8.3, iOS doesn't support `datetime` or `week`. - -@media screen and (-webkit-min-device-pixel-ratio: 0) { - input[type="date"], - input[type="time"], - input[type="datetime-local"], - input[type="month"] { - &.form-control { - line-height: @input-height-base; - } - - &.input-sm, - .input-group-sm & { - line-height: @input-height-small; - } - - &.input-lg, - .input-group-lg & { - line-height: @input-height-large; - } - } -} - - -// Form groups -// -// Designed to help with the organization and spacing of vertical forms. For -// horizontal forms, use the predefined grid classes. - -.form-group { - margin-bottom: @form-group-margin-bottom; -} - - -// Checkboxes and radios -// -// Indent the labels to position radios/checkboxes as hanging controls. - -.radio, -.checkbox { - position: relative; - display: block; - margin-top: 10px; - margin-bottom: 10px; - - label { - min-height: @line-height-computed; // Ensure the input doesn't jump when there is no text - padding-left: 20px; - margin-bottom: 0; - font-weight: normal; - cursor: pointer; - } -} -.radio input[type="radio"], -.radio-inline input[type="radio"], -.checkbox input[type="checkbox"], -.checkbox-inline input[type="checkbox"] { - position: absolute; - margin-left: -20px; - margin-top: 4px \9; -} - -.radio + .radio, -.checkbox + .checkbox { - margin-top: -5px; // Move up sibling radios or checkboxes for tighter spacing -} - -// Radios and checkboxes on same line -.radio-inline, -.checkbox-inline { - position: relative; - display: inline-block; - padding-left: 20px; - margin-bottom: 0; - vertical-align: middle; - font-weight: normal; - cursor: pointer; -} -.radio-inline + .radio-inline, -.checkbox-inline + .checkbox-inline { - margin-top: 0; - margin-left: 10px; // space out consecutive inline controls -} - -// Apply same disabled cursor tweak as for inputs -// Some special care is needed because <label>s don't inherit their parent's `cursor`. -// -// Note: Neither radios nor checkboxes can be readonly. -input[type="radio"], -input[type="checkbox"] { - &[disabled], - &.disabled, - fieldset[disabled] & { - cursor: @cursor-disabled; - } -} -// These classes are used directly on <label>s -.radio-inline, -.checkbox-inline { - &.disabled, - fieldset[disabled] & { - cursor: @cursor-disabled; - } -} -// These classes are used on elements with <label> descendants -.radio, -.checkbox { - &.disabled, - fieldset[disabled] & { - label { - cursor: @cursor-disabled; - } - } -} - - -// Static form control text -// -// Apply class to a `p` element to make any string of text align with labels in -// a horizontal form layout. - -.form-control-static { - // Size it appropriately next to real form controls - padding-top: (@padding-base-vertical + 1); - padding-bottom: (@padding-base-vertical + 1); - // Remove default margin from `p` - margin-bottom: 0; - min-height: (@line-height-computed + @font-size-base); - - &.input-lg, - &.input-sm { - padding-left: 0; - padding-right: 0; - } -} - - -// Form control sizing -// -// Build on `.form-control` with modifier classes to decrease or increase the -// height and font-size of form controls. -// -// The `.form-group-* form-control` variations are sadly duplicated to avoid the -// issue documented in https://github.com/twbs/bootstrap/issues/15074. - -.input-sm { - .input-size(@input-height-small; @padding-small-vertical; @padding-small-horizontal; @font-size-small; @line-height-small; @input-border-radius-small); -} -.form-group-sm { - .form-control { - height: @input-height-small; - padding: @padding-small-vertical @padding-small-horizontal; - font-size: @font-size-small; - line-height: @line-height-small; - border-radius: @input-border-radius-small; - } - select.form-control { - height: @input-height-small; - line-height: @input-height-small; - } - textarea.form-control, - select[multiple].form-control { - height: auto; - } - .form-control-static { - height: @input-height-small; - min-height: (@line-height-computed + @font-size-small); - padding: (@padding-small-vertical + 1) @padding-small-horizontal; - font-size: @font-size-small; - line-height: @line-height-small; - } -} - -.input-lg { - .input-size(@input-height-large; @padding-large-vertical; @padding-large-horizontal; @font-size-large; @line-height-large; @input-border-radius-large); -} -.form-group-lg { - .form-control { - height: @input-height-large; - padding: @padding-large-vertical @padding-large-horizontal; - font-size: @font-size-large; - line-height: @line-height-large; - border-radius: @input-border-radius-large; - } - select.form-control { - height: @input-height-large; - line-height: @input-height-large; - } - textarea.form-control, - select[multiple].form-control { - height: auto; - } - .form-control-static { - height: @input-height-large; - min-height: (@line-height-computed + @font-size-large); - padding: (@padding-large-vertical + 1) @padding-large-horizontal; - font-size: @font-size-large; - line-height: @line-height-large; - } -} - - -// Form control feedback states -// -// Apply contextual and semantic states to individual form controls. - -.has-feedback { - // Enable absolute positioning - position: relative; - - // Ensure icons don't overlap text - .form-control { - padding-right: (@input-height-base * 1.25); - } -} -// Feedback icon (requires .glyphicon classes) -.form-control-feedback { - position: absolute; - top: 0; - right: 0; - z-index: 2; // Ensure icon is above input groups - display: block; - width: @input-height-base; - height: @input-height-base; - line-height: @input-height-base; - text-align: center; - pointer-events: none; -} -.input-lg + .form-control-feedback, -.input-group-lg + .form-control-feedback, -.form-group-lg .form-control + .form-control-feedback { - width: @input-height-large; - height: @input-height-large; - line-height: @input-height-large; -} -.input-sm + .form-control-feedback, -.input-group-sm + .form-control-feedback, -.form-group-sm .form-control + .form-control-feedback { - width: @input-height-small; - height: @input-height-small; - line-height: @input-height-small; -} - -// Feedback states -.has-success { - .form-control-validation(@state-success-text; @state-success-text; @state-success-bg); -} -.has-warning { - .form-control-validation(@state-warning-text; @state-warning-text; @state-warning-bg); -} -.has-error { - .form-control-validation(@state-danger-text; @state-danger-text; @state-danger-bg); -} - -// Reposition feedback icon if input has visible label above -.has-feedback label { - - & ~ .form-control-feedback { - top: (@line-height-computed + 5); // Height of the `label` and its margin - } - &.sr-only ~ .form-control-feedback { - top: 0; - } -} - - -// Help text -// -// Apply to any element you wish to create light text for placement immediately -// below a form control. Use for general help, formatting, or instructional text. - -.help-block { - display: block; // account for any element using help-block - margin-top: 5px; - margin-bottom: 10px; - color: lighten(@text-color, 25%); // lighten the text some for contrast -} - - -// Inline forms -// -// Make forms appear inline(-block) by adding the `.form-inline` class. Inline -// forms begin stacked on extra small (mobile) devices and then go inline when -// viewports reach <768px. -// -// Requires wrapping inputs and labels with `.form-group` for proper display of -// default HTML form controls and our custom form controls (e.g., input groups). -// -// Heads up! This is mixin-ed into `.navbar-form` in navbars.less. - -.form-inline { - - // Kick in the inline - @media (min-width: @screen-sm-min) { - // Inline-block all the things for "inline" - .form-group { - display: inline-block; - margin-bottom: 0; - vertical-align: middle; - } - - // In navbar-form, allow folks to *not* use `.form-group` - .form-control { - display: inline-block; - width: auto; // Prevent labels from stacking above inputs in `.form-group` - vertical-align: middle; - } - - // Make static controls behave like regular ones - .form-control-static { - display: inline-block; - } - - .input-group { - display: inline-table; - vertical-align: middle; - - .input-group-addon, - .input-group-btn, - .form-control { - width: auto; - } - } - - // Input groups need that 100% width though - .input-group > .form-control { - width: 100%; - } - - .control-label { - margin-bottom: 0; - vertical-align: middle; - } - - // Remove default margin on radios/checkboxes that were used for stacking, and - // then undo the floating of radios and checkboxes to match. - .radio, - .checkbox { - display: inline-block; - margin-top: 0; - margin-bottom: 0; - vertical-align: middle; - - label { - padding-left: 0; - } - } - .radio input[type="radio"], - .checkbox input[type="checkbox"] { - position: relative; - margin-left: 0; - } - - // Re-override the feedback icon. - .has-feedback .form-control-feedback { - top: 0; - } - } -} - - -// Horizontal forms -// -// Horizontal forms are built on grid classes and allow you to create forms with -// labels on the left and inputs on the right. - -.form-horizontal { - - // Consistent vertical alignment of radios and checkboxes - // - // Labels also get some reset styles, but that is scoped to a media query below. - .radio, - .checkbox, - .radio-inline, - .checkbox-inline { - margin-top: 0; - margin-bottom: 0; - padding-top: (@padding-base-vertical + 1); // Default padding plus a border - } - // Account for padding we're adding to ensure the alignment and of help text - // and other content below items - .radio, - .checkbox { - min-height: (@line-height-computed + (@padding-base-vertical + 1)); - } - - // Make form groups behave like rows - .form-group { - .make-row(); - } - - // Reset spacing and right align labels, but scope to media queries so that - // labels on narrow viewports stack the same as a default form example. - @media (min-width: @screen-sm-min) { - .control-label { - text-align: right; - margin-bottom: 0; - padding-top: (@padding-base-vertical + 1); // Default padding plus a border - } - } - - // Validation states - // - // Reposition the icon because it's now within a grid column and columns have - // `position: relative;` on them. Also accounts for the grid gutter padding. - .has-feedback .form-control-feedback { - right: floor((@grid-gutter-width / 2)); - } - - // Form group sizes - // - // Quick utility class for applying `.input-lg` and `.input-sm` styles to the - // inputs and labels within a `.form-group`. - .form-group-lg { - @media (min-width: @screen-sm-min) { - .control-label { - padding-top: ((@padding-large-vertical * @line-height-large) + 1); - font-size: @font-size-large; - } - } - } - .form-group-sm { - @media (min-width: @screen-sm-min) { - .control-label { - padding-top: (@padding-small-vertical + 1); - font-size: @font-size-small; - } - } - } -} diff --git a/src/UI/Content/Bootstrap/glyphicons.less b/src/UI/Content/Bootstrap/glyphicons.less deleted file mode 100644 index 335d80aa6..000000000 --- a/src/UI/Content/Bootstrap/glyphicons.less +++ /dev/null @@ -1,305 +0,0 @@ -// -// Glyphicons for Bootstrap -// -// Since icons are fonts, they can be placed anywhere text is placed and are -// thus automatically sized to match the surrounding child. To use, create an -// inline element with the appropriate classes, like so: -// -// <a href="#"><span class="glyphicon glyphicon-star"></span> Star</a> - -// Import the fonts -@font-face { - font-family: 'Glyphicons Halflings'; - src: url('@{icon-font-path}@{icon-font-name}.eot'); - src: url('@{icon-font-path}@{icon-font-name}.eot?#iefix') format('embedded-opentype'), - url('@{icon-font-path}@{icon-font-name}.woff2') format('woff2'), - url('@{icon-font-path}@{icon-font-name}.woff') format('woff'), - url('@{icon-font-path}@{icon-font-name}.ttf') format('truetype'), - url('@{icon-font-path}@{icon-font-name}.svg#@{icon-font-svg-id}') format('svg'); -} - -// Catchall baseclass -.glyphicon { - position: relative; - top: 1px; - display: inline-block; - font-family: 'Glyphicons Halflings'; - font-style: normal; - font-weight: normal; - line-height: 1; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -// Individual icons -.glyphicon-asterisk { &:before { content: "\2a"; } } -.glyphicon-plus { &:before { content: "\2b"; } } -.glyphicon-euro, -.glyphicon-eur { &:before { content: "\20ac"; } } -.glyphicon-minus { &:before { content: "\2212"; } } -.glyphicon-cloud { &:before { content: "\2601"; } } -.glyphicon-envelope { &:before { content: "\2709"; } } -.glyphicon-pencil { &:before { content: "\270f"; } } -.glyphicon-glass { &:before { content: "\e001"; } } -.glyphicon-music { &:before { content: "\e002"; } } -.glyphicon-search { &:before { content: "\e003"; } } -.glyphicon-heart { &:before { content: "\e005"; } } -.glyphicon-star { &:before { content: "\e006"; } } -.glyphicon-star-empty { &:before { content: "\e007"; } } -.glyphicon-user { &:before { content: "\e008"; } } -.glyphicon-film { &:before { content: "\e009"; } } -.glyphicon-th-large { &:before { content: "\e010"; } } -.glyphicon-th { &:before { content: "\e011"; } } -.glyphicon-th-list { &:before { content: "\e012"; } } -.glyphicon-ok { &:before { content: "\e013"; } } -.glyphicon-remove { &:before { content: "\e014"; } } -.glyphicon-zoom-in { &:before { content: "\e015"; } } -.glyphicon-zoom-out { &:before { content: "\e016"; } } -.glyphicon-off { &:before { content: "\e017"; } } -.glyphicon-signal { &:before { content: "\e018"; } } -.glyphicon-cog { &:before { content: "\e019"; } } -.glyphicon-trash { &:before { content: "\e020"; } } -.glyphicon-home { &:before { content: "\e021"; } } -.glyphicon-file { &:before { content: "\e022"; } } -.glyphicon-time { &:before { content: "\e023"; } } -.glyphicon-road { &:before { content: "\e024"; } } -.glyphicon-download-alt { &:before { content: "\e025"; } } -.glyphicon-download { &:before { content: "\e026"; } } -.glyphicon-upload { &:before { content: "\e027"; } } -.glyphicon-inbox { &:before { content: "\e028"; } } -.glyphicon-play-circle { &:before { content: "\e029"; } } -.glyphicon-repeat { &:before { content: "\e030"; } } -.glyphicon-refresh { &:before { content: "\e031"; } } -.glyphicon-list-alt { &:before { content: "\e032"; } } -.glyphicon-lock { &:before { content: "\e033"; } } -.glyphicon-flag { &:before { content: "\e034"; } } -.glyphicon-headphones { &:before { content: "\e035"; } } -.glyphicon-volume-off { &:before { content: "\e036"; } } -.glyphicon-volume-down { &:before { content: "\e037"; } } -.glyphicon-volume-up { &:before { content: "\e038"; } } -.glyphicon-qrcode { &:before { content: "\e039"; } } -.glyphicon-barcode { &:before { content: "\e040"; } } -.glyphicon-tag { &:before { content: "\e041"; } } -.glyphicon-tags { &:before { content: "\e042"; } } -.glyphicon-book { &:before { content: "\e043"; } } -.glyphicon-bookmark { &:before { content: "\e044"; } } -.glyphicon-print { &:before { content: "\e045"; } } -.glyphicon-camera { &:before { content: "\e046"; } } -.glyphicon-font { &:before { content: "\e047"; } } -.glyphicon-bold { &:before { content: "\e048"; } } -.glyphicon-italic { &:before { content: "\e049"; } } -.glyphicon-text-height { &:before { content: "\e050"; } } -.glyphicon-text-width { &:before { content: "\e051"; } } -.glyphicon-align-left { &:before { content: "\e052"; } } -.glyphicon-align-center { &:before { content: "\e053"; } } -.glyphicon-align-right { &:before { content: "\e054"; } } -.glyphicon-align-justify { &:before { content: "\e055"; } } -.glyphicon-list { &:before { content: "\e056"; } } -.glyphicon-indent-left { &:before { content: "\e057"; } } -.glyphicon-indent-right { &:before { content: "\e058"; } } -.glyphicon-facetime-video { &:before { content: "\e059"; } } -.glyphicon-picture { &:before { content: "\e060"; } } -.glyphicon-map-marker { &:before { content: "\e062"; } } -.glyphicon-adjust { &:before { content: "\e063"; } } -.glyphicon-tint { &:before { content: "\e064"; } } -.glyphicon-edit { &:before { content: "\e065"; } } -.glyphicon-share { &:before { content: "\e066"; } } -.glyphicon-check { &:before { content: "\e067"; } } -.glyphicon-move { &:before { content: "\e068"; } } -.glyphicon-step-backward { &:before { content: "\e069"; } } -.glyphicon-fast-backward { &:before { content: "\e070"; } } -.glyphicon-backward { &:before { content: "\e071"; } } -.glyphicon-play { &:before { content: "\e072"; } } -.glyphicon-pause { &:before { content: "\e073"; } } -.glyphicon-stop { &:before { content: "\e074"; } } -.glyphicon-forward { &:before { content: "\e075"; } } -.glyphicon-fast-forward { &:before { content: "\e076"; } } -.glyphicon-step-forward { &:before { content: "\e077"; } } -.glyphicon-eject { &:before { content: "\e078"; } } -.glyphicon-chevron-left { &:before { content: "\e079"; } } -.glyphicon-chevron-right { &:before { content: "\e080"; } } -.glyphicon-plus-sign { &:before { content: "\e081"; } } -.glyphicon-minus-sign { &:before { content: "\e082"; } } -.glyphicon-remove-sign { &:before { content: "\e083"; } } -.glyphicon-ok-sign { &:before { content: "\e084"; } } -.glyphicon-question-sign { &:before { content: "\e085"; } } -.glyphicon-info-sign { &:before { content: "\e086"; } } -.glyphicon-screenshot { &:before { content: "\e087"; } } -.glyphicon-remove-circle { &:before { content: "\e088"; } } -.glyphicon-ok-circle { &:before { content: "\e089"; } } -.glyphicon-ban-circle { &:before { content: "\e090"; } } -.glyphicon-arrow-left { &:before { content: "\e091"; } } -.glyphicon-arrow-right { &:before { content: "\e092"; } } -.glyphicon-arrow-up { &:before { content: "\e093"; } } -.glyphicon-arrow-down { &:before { content: "\e094"; } } -.glyphicon-share-alt { &:before { content: "\e095"; } } -.glyphicon-resize-full { &:before { content: "\e096"; } } -.glyphicon-resize-small { &:before { content: "\e097"; } } -.glyphicon-exclamation-sign { &:before { content: "\e101"; } } -.glyphicon-gift { &:before { content: "\e102"; } } -.glyphicon-leaf { &:before { content: "\e103"; } } -.glyphicon-fire { &:before { content: "\e104"; } } -.glyphicon-eye-open { &:before { content: "\e105"; } } -.glyphicon-eye-close { &:before { content: "\e106"; } } -.glyphicon-warning-sign { &:before { content: "\e107"; } } -.glyphicon-plane { &:before { content: "\e108"; } } -.glyphicon-calendar { &:before { content: "\e109"; } } -.glyphicon-random { &:before { content: "\e110"; } } -.glyphicon-comment { &:before { content: "\e111"; } } -.glyphicon-magnet { &:before { content: "\e112"; } } -.glyphicon-chevron-up { &:before { content: "\e113"; } } -.glyphicon-chevron-down { &:before { content: "\e114"; } } -.glyphicon-retweet { &:before { content: "\e115"; } } -.glyphicon-shopping-cart { &:before { content: "\e116"; } } -.glyphicon-folder-close { &:before { content: "\e117"; } } -.glyphicon-folder-open { &:before { content: "\e118"; } } -.glyphicon-resize-vertical { &:before { content: "\e119"; } } -.glyphicon-resize-horizontal { &:before { content: "\e120"; } } -.glyphicon-hdd { &:before { content: "\e121"; } } -.glyphicon-bullhorn { &:before { content: "\e122"; } } -.glyphicon-bell { &:before { content: "\e123"; } } -.glyphicon-certificate { &:before { content: "\e124"; } } -.glyphicon-thumbs-up { &:before { content: "\e125"; } } -.glyphicon-thumbs-down { &:before { content: "\e126"; } } -.glyphicon-hand-right { &:before { content: "\e127"; } } -.glyphicon-hand-left { &:before { content: "\e128"; } } -.glyphicon-hand-up { &:before { content: "\e129"; } } -.glyphicon-hand-down { &:before { content: "\e130"; } } -.glyphicon-circle-arrow-right { &:before { content: "\e131"; } } -.glyphicon-circle-arrow-left { &:before { content: "\e132"; } } -.glyphicon-circle-arrow-up { &:before { content: "\e133"; } } -.glyphicon-circle-arrow-down { &:before { content: "\e134"; } } -.glyphicon-globe { &:before { content: "\e135"; } } -.glyphicon-wrench { &:before { content: "\e136"; } } -.glyphicon-tasks { &:before { content: "\e137"; } } -.glyphicon-filter { &:before { content: "\e138"; } } -.glyphicon-briefcase { &:before { content: "\e139"; } } -.glyphicon-fullscreen { &:before { content: "\e140"; } } -.glyphicon-dashboard { &:before { content: "\e141"; } } -.glyphicon-paperclip { &:before { content: "\e142"; } } -.glyphicon-heart-empty { &:before { content: "\e143"; } } -.glyphicon-link { &:before { content: "\e144"; } } -.glyphicon-phone { &:before { content: "\e145"; } } -.glyphicon-pushpin { &:before { content: "\e146"; } } -.glyphicon-usd { &:before { content: "\e148"; } } -.glyphicon-gbp { &:before { content: "\e149"; } } -.glyphicon-sort { &:before { content: "\e150"; } } -.glyphicon-sort-by-alphabet { &:before { content: "\e151"; } } -.glyphicon-sort-by-alphabet-alt { &:before { content: "\e152"; } } -.glyphicon-sort-by-order { &:before { content: "\e153"; } } -.glyphicon-sort-by-order-alt { &:before { content: "\e154"; } } -.glyphicon-sort-by-attributes { &:before { content: "\e155"; } } -.glyphicon-sort-by-attributes-alt { &:before { content: "\e156"; } } -.glyphicon-unchecked { &:before { content: "\e157"; } } -.glyphicon-expand { &:before { content: "\e158"; } } -.glyphicon-collapse-down { &:before { content: "\e159"; } } -.glyphicon-collapse-up { &:before { content: "\e160"; } } -.glyphicon-log-in { &:before { content: "\e161"; } } -.glyphicon-flash { &:before { content: "\e162"; } } -.glyphicon-log-out { &:before { content: "\e163"; } } -.glyphicon-new-window { &:before { content: "\e164"; } } -.glyphicon-record { &:before { content: "\e165"; } } -.glyphicon-save { &:before { content: "\e166"; } } -.glyphicon-open { &:before { content: "\e167"; } } -.glyphicon-saved { &:before { content: "\e168"; } } -.glyphicon-import { &:before { content: "\e169"; } } -.glyphicon-export { &:before { content: "\e170"; } } -.glyphicon-send { &:before { content: "\e171"; } } -.glyphicon-floppy-disk { &:before { content: "\e172"; } } -.glyphicon-floppy-saved { &:before { content: "\e173"; } } -.glyphicon-floppy-remove { &:before { content: "\e174"; } } -.glyphicon-floppy-save { &:before { content: "\e175"; } } -.glyphicon-floppy-open { &:before { content: "\e176"; } } -.glyphicon-credit-card { &:before { content: "\e177"; } } -.glyphicon-transfer { &:before { content: "\e178"; } } -.glyphicon-cutlery { &:before { content: "\e179"; } } -.glyphicon-header { &:before { content: "\e180"; } } -.glyphicon-compressed { &:before { content: "\e181"; } } -.glyphicon-earphone { &:before { content: "\e182"; } } -.glyphicon-phone-alt { &:before { content: "\e183"; } } -.glyphicon-tower { &:before { content: "\e184"; } } -.glyphicon-stats { &:before { content: "\e185"; } } -.glyphicon-sd-video { &:before { content: "\e186"; } } -.glyphicon-hd-video { &:before { content: "\e187"; } } -.glyphicon-subtitles { &:before { content: "\e188"; } } -.glyphicon-sound-stereo { &:before { content: "\e189"; } } -.glyphicon-sound-dolby { &:before { content: "\e190"; } } -.glyphicon-sound-5-1 { &:before { content: "\e191"; } } -.glyphicon-sound-6-1 { &:before { content: "\e192"; } } -.glyphicon-sound-7-1 { &:before { content: "\e193"; } } -.glyphicon-copyright-mark { &:before { content: "\e194"; } } -.glyphicon-registration-mark { &:before { content: "\e195"; } } -.glyphicon-cloud-download { &:before { content: "\e197"; } } -.glyphicon-cloud-upload { &:before { content: "\e198"; } } -.glyphicon-tree-conifer { &:before { content: "\e199"; } } -.glyphicon-tree-deciduous { &:before { content: "\e200"; } } -.glyphicon-cd { &:before { content: "\e201"; } } -.glyphicon-save-file { &:before { content: "\e202"; } } -.glyphicon-open-file { &:before { content: "\e203"; } } -.glyphicon-level-up { &:before { content: "\e204"; } } -.glyphicon-copy { &:before { content: "\e205"; } } -.glyphicon-paste { &:before { content: "\e206"; } } -// The following 2 Glyphicons are omitted for the time being because -// they currently use Unicode codepoints that are outside the -// Basic Multilingual Plane (BMP). Older buggy versions of WebKit can't handle -// non-BMP codepoints in CSS string escapes, and thus can't display these two icons. -// Notably, the bug affects some older versions of the Android Browser. -// More info: https://github.com/twbs/bootstrap/issues/10106 -// .glyphicon-door { &:before { content: "\1f6aa"; } } -// .glyphicon-key { &:before { content: "\1f511"; } } -.glyphicon-alert { &:before { content: "\e209"; } } -.glyphicon-equalizer { &:before { content: "\e210"; } } -.glyphicon-king { &:before { content: "\e211"; } } -.glyphicon-queen { &:before { content: "\e212"; } } -.glyphicon-pawn { &:before { content: "\e213"; } } -.glyphicon-bishop { &:before { content: "\e214"; } } -.glyphicon-knight { &:before { content: "\e215"; } } -.glyphicon-baby-formula { &:before { content: "\e216"; } } -.glyphicon-tent { &:before { content: "\26fa"; } } -.glyphicon-blackboard { &:before { content: "\e218"; } } -.glyphicon-bed { &:before { content: "\e219"; } } -.glyphicon-apple { &:before { content: "\f8ff"; } } -.glyphicon-erase { &:before { content: "\e221"; } } -.glyphicon-hourglass { &:before { content: "\231b"; } } -.glyphicon-lamp { &:before { content: "\e223"; } } -.glyphicon-duplicate { &:before { content: "\e224"; } } -.glyphicon-piggy-bank { &:before { content: "\e225"; } } -.glyphicon-scissors { &:before { content: "\e226"; } } -.glyphicon-bitcoin { &:before { content: "\e227"; } } -.glyphicon-btc { &:before { content: "\e227"; } } -.glyphicon-xbt { &:before { content: "\e227"; } } -.glyphicon-yen { &:before { content: "\00a5"; } } -.glyphicon-jpy { &:before { content: "\00a5"; } } -.glyphicon-ruble { &:before { content: "\20bd"; } } -.glyphicon-rub { &:before { content: "\20bd"; } } -.glyphicon-scale { &:before { content: "\e230"; } } -.glyphicon-ice-lolly { &:before { content: "\e231"; } } -.glyphicon-ice-lolly-tasted { &:before { content: "\e232"; } } -.glyphicon-education { &:before { content: "\e233"; } } -.glyphicon-option-horizontal { &:before { content: "\e234"; } } -.glyphicon-option-vertical { &:before { content: "\e235"; } } -.glyphicon-menu-hamburger { &:before { content: "\e236"; } } -.glyphicon-modal-window { &:before { content: "\e237"; } } -.glyphicon-oil { &:before { content: "\e238"; } } -.glyphicon-grain { &:before { content: "\e239"; } } -.glyphicon-sunglasses { &:before { content: "\e240"; } } -.glyphicon-text-size { &:before { content: "\e241"; } } -.glyphicon-text-color { &:before { content: "\e242"; } } -.glyphicon-text-background { &:before { content: "\e243"; } } -.glyphicon-object-align-top { &:before { content: "\e244"; } } -.glyphicon-object-align-bottom { &:before { content: "\e245"; } } -.glyphicon-object-align-horizontal{ &:before { content: "\e246"; } } -.glyphicon-object-align-left { &:before { content: "\e247"; } } -.glyphicon-object-align-vertical { &:before { content: "\e248"; } } -.glyphicon-object-align-right { &:before { content: "\e249"; } } -.glyphicon-triangle-right { &:before { content: "\e250"; } } -.glyphicon-triangle-left { &:before { content: "\e251"; } } -.glyphicon-triangle-bottom { &:before { content: "\e252"; } } -.glyphicon-triangle-top { &:before { content: "\e253"; } } -.glyphicon-console { &:before { content: "\e254"; } } -.glyphicon-superscript { &:before { content: "\e255"; } } -.glyphicon-subscript { &:before { content: "\e256"; } } -.glyphicon-menu-left { &:before { content: "\e257"; } } -.glyphicon-menu-right { &:before { content: "\e258"; } } -.glyphicon-menu-down { &:before { content: "\e259"; } } -.glyphicon-menu-up { &:before { content: "\e260"; } } diff --git a/src/UI/Content/Bootstrap/grid.less b/src/UI/Content/Bootstrap/grid.less deleted file mode 100644 index e100655b7..000000000 --- a/src/UI/Content/Bootstrap/grid.less +++ /dev/null @@ -1,84 +0,0 @@ -// -// Grid system -// -------------------------------------------------- - - -// Container widths -// -// Set the container width, and override it for fixed navbars in media queries. - -.container { - .container-fixed(); - - @media (min-width: @screen-sm-min) { - width: @container-sm; - } - @media (min-width: @screen-md-min) { - width: @container-md; - } - @media (min-width: @screen-lg-min) { - width: @container-lg; - } -} - - -// Fluid container -// -// Utilizes the mixin meant for fixed width containers, but without any defined -// width for fluid, full width layouts. - -.container-fluid { - .container-fixed(); -} - - -// Row -// -// Rows contain and clear the floats of your columns. - -.row { - .make-row(); -} - - -// Columns -// -// Common styles for small and large grid columns - -.make-grid-columns(); - - -// Extra small grid -// -// Columns, offsets, pushes, and pulls for extra small devices like -// smartphones. - -.make-grid(xs); - - -// Small grid -// -// Columns, offsets, pushes, and pulls for the small device range, from phones -// to tablets. - -@media (min-width: @screen-sm-min) { - .make-grid(sm); -} - - -// Medium grid -// -// Columns, offsets, pushes, and pulls for the desktop device range. - -@media (min-width: @screen-md-min) { - .make-grid(md); -} - - -// Large grid -// -// Columns, offsets, pushes, and pulls for the large desktop device range. - -@media (min-width: @screen-lg-min) { - .make-grid(lg); -} diff --git a/src/UI/Content/Bootstrap/input-groups.less b/src/UI/Content/Bootstrap/input-groups.less deleted file mode 100644 index 457ea60ba..000000000 --- a/src/UI/Content/Bootstrap/input-groups.less +++ /dev/null @@ -1,167 +0,0 @@ -// -// Input groups -// -------------------------------------------------- - -// Base styles -// ------------------------- -.input-group { - position: relative; // For dropdowns - display: table; - border-collapse: separate; // prevent input groups from inheriting border styles from table cells when placed within a table - - // Undo padding and float of grid classes - &[class*="col-"] { - float: none; - padding-left: 0; - padding-right: 0; - } - - .form-control { - // Ensure that the input is always above the *appended* addon button for - // proper border colors. - position: relative; - z-index: 2; - - // IE9 fubars the placeholder attribute in text inputs and the arrows on - // select elements in input groups. To fix it, we float the input. Details: - // https://github.com/twbs/bootstrap/issues/11561#issuecomment-28936855 - float: left; - - width: 100%; - margin-bottom: 0; - } -} - -// Sizing options -// -// Remix the default form control sizing classes into new ones for easier -// manipulation. - -.input-group-lg > .form-control, -.input-group-lg > .input-group-addon, -.input-group-lg > .input-group-btn > .btn { - .input-lg(); -} -.input-group-sm > .form-control, -.input-group-sm > .input-group-addon, -.input-group-sm > .input-group-btn > .btn { - .input-sm(); -} - - -// Display as table-cell -// ------------------------- -.input-group-addon, -.input-group-btn, -.input-group .form-control { - display: table-cell; - - &:not(:first-child):not(:last-child) { - border-radius: 0; - } -} -// Addon and addon wrapper for buttons -.input-group-addon, -.input-group-btn { - width: 1%; - white-space: nowrap; - vertical-align: middle; // Match the inputs -} - -// Text input groups -// ------------------------- -.input-group-addon { - padding: @padding-base-vertical @padding-base-horizontal; - font-size: @font-size-base; - font-weight: normal; - line-height: 1; - color: @input-color; - text-align: center; - background-color: @input-group-addon-bg; - border: 1px solid @input-group-addon-border-color; - border-radius: @border-radius-base; - - // Sizing - &.input-sm { - padding: @padding-small-vertical @padding-small-horizontal; - font-size: @font-size-small; - border-radius: @border-radius-small; - } - &.input-lg { - padding: @padding-large-vertical @padding-large-horizontal; - font-size: @font-size-large; - border-radius: @border-radius-large; - } - - // Nuke default margins from checkboxes and radios to vertically center within. - input[type="radio"], - input[type="checkbox"] { - margin-top: 0; - } -} - -// Reset rounded corners -.input-group .form-control:first-child, -.input-group-addon:first-child, -.input-group-btn:first-child > .btn, -.input-group-btn:first-child > .btn-group > .btn, -.input-group-btn:first-child > .dropdown-toggle, -.input-group-btn:last-child > .btn:not(:last-child):not(.dropdown-toggle), -.input-group-btn:last-child > .btn-group:not(:last-child) > .btn { - .border-right-radius(0); -} -.input-group-addon:first-child { - border-right: 0; -} -.input-group .form-control:last-child, -.input-group-addon:last-child, -.input-group-btn:last-child > .btn, -.input-group-btn:last-child > .btn-group > .btn, -.input-group-btn:last-child > .dropdown-toggle, -.input-group-btn:first-child > .btn:not(:first-child), -.input-group-btn:first-child > .btn-group:not(:first-child) > .btn { - .border-left-radius(0); -} -.input-group-addon:last-child { - border-left: 0; -} - -// Button input groups -// ------------------------- -.input-group-btn { - position: relative; - // Jankily prevent input button groups from wrapping with `white-space` and - // `font-size` in combination with `inline-block` on buttons. - font-size: 0; - white-space: nowrap; - - // Negative margin for spacing, position for bringing hovered/focused/actived - // element above the siblings. - > .btn { - position: relative; - + .btn { - margin-left: -1px; - } - // Bring the "active" button to the front - &:hover, - &:focus, - &:active { - z-index: 2; - } - } - - // Negative margin to only have a 1px border between the two - &:first-child { - > .btn, - > .btn-group { - margin-right: -1px; - } - } - &:last-child { - > .btn, - > .btn-group { - z-index: 2; - margin-left: -1px; - } - } -} diff --git a/src/UI/Content/Bootstrap/jumbotron.less b/src/UI/Content/Bootstrap/jumbotron.less deleted file mode 100644 index fa80a38c6..000000000 --- a/src/UI/Content/Bootstrap/jumbotron.less +++ /dev/null @@ -1,52 +0,0 @@ -// -// Jumbotron -// -------------------------------------------------- - - -.jumbotron { - padding-top: @jumbotron-padding; - padding-bottom: @jumbotron-padding; - margin-bottom: @jumbotron-padding; - color: @jumbotron-color; - background-color: @jumbotron-bg; - - h1, - .h1 { - color: @jumbotron-heading-color; - } - - p { - margin-bottom: (@jumbotron-padding / 2); - font-size: @jumbotron-font-size; - font-weight: 200; - } - - > hr { - border-top-color: darken(@jumbotron-bg, 10%); - } - - .container &, - .container-fluid & { - border-radius: @border-radius-large; // Only round corners at higher resolutions if contained in a container - } - - .container { - max-width: 100%; - } - - @media screen and (min-width: @screen-sm-min) { - padding-top: (@jumbotron-padding * 1.6); - padding-bottom: (@jumbotron-padding * 1.6); - - .container &, - .container-fluid & { - padding-left: (@jumbotron-padding * 2); - padding-right: (@jumbotron-padding * 2); - } - - h1, - .h1 { - font-size: @jumbotron-heading-font-size; - } - } -} diff --git a/src/UI/Content/Bootstrap/labels.less b/src/UI/Content/Bootstrap/labels.less deleted file mode 100644 index 9a5a27006..000000000 --- a/src/UI/Content/Bootstrap/labels.less +++ /dev/null @@ -1,64 +0,0 @@ -// -// Labels -// -------------------------------------------------- - -.label { - display: inline; - padding: .2em .6em .3em; - font-size: 75%; - font-weight: bold; - line-height: 1; - color: @label-color; - text-align: center; - white-space: nowrap; - vertical-align: baseline; - border-radius: .25em; - - // Add hover effects, but only for links - a& { - &:hover, - &:focus { - color: @label-link-hover-color; - text-decoration: none; - cursor: pointer; - } - } - - // Empty labels collapse automatically (not available in IE8) - &:empty { - display: none; - } - - // Quick fix for labels in buttons - .btn & { - position: relative; - top: -1px; - } -} - -// Colors -// Contextual variations (linked labels get darker on :hover) - -.label-default { - .label-variant(@label-default-bg); -} - -.label-primary { - .label-variant(@label-primary-bg); -} - -.label-success { - .label-variant(@label-success-bg); -} - -.label-info { - .label-variant(@label-info-bg); -} - -.label-warning { - .label-variant(@label-warning-bg); -} - -.label-danger { - .label-variant(@label-danger-bg); -} diff --git a/src/UI/Content/Bootstrap/list-group.less b/src/UI/Content/Bootstrap/list-group.less deleted file mode 100644 index 216b91230..000000000 --- a/src/UI/Content/Bootstrap/list-group.less +++ /dev/null @@ -1,130 +0,0 @@ -// -// List groups -// -------------------------------------------------- - - -// Base class -// -// Easily usable on <ul>, <ol>, or <div>. - -.list-group { - // No need to set list-style: none; since .list-group-item is block level - margin-bottom: 20px; - padding-left: 0; // reset padding because ul and ol -} - - -// Individual list items -// -// Use on `li`s or `div`s within the `.list-group` parent. - -.list-group-item { - position: relative; - display: block; - padding: 10px 15px; - // Place the border on the list items and negative margin up for better styling - margin-bottom: -1px; - background-color: @list-group-bg; - border: 1px solid @list-group-border; - - // Round the first and last items - &:first-child { - .border-top-radius(@list-group-border-radius); - } - &:last-child { - margin-bottom: 0; - .border-bottom-radius(@list-group-border-radius); - } -} - - -// Interactive list items -// -// Use anchor or button elements instead of `li`s or `div`s to create interactive items. -// Includes an extra `.active` modifier class for showing selected items. - -a.list-group-item, -button.list-group-item { - color: @list-group-link-color; - - .list-group-item-heading { - color: @list-group-link-heading-color; - } - - // Hover state - &:hover, - &:focus { - text-decoration: none; - color: @list-group-link-hover-color; - background-color: @list-group-hover-bg; - } -} - -button.list-group-item { - width: 100%; - text-align: left; -} - -.list-group-item { - // Disabled state - &.disabled, - &.disabled:hover, - &.disabled:focus { - background-color: @list-group-disabled-bg; - color: @list-group-disabled-color; - cursor: @cursor-disabled; - - // Force color to inherit for custom content - .list-group-item-heading { - color: inherit; - } - .list-group-item-text { - color: @list-group-disabled-text-color; - } - } - - // Active class on item itself, not parent - &.active, - &.active:hover, - &.active:focus { - z-index: 2; // Place active items above their siblings for proper border styling - color: @list-group-active-color; - background-color: @list-group-active-bg; - border-color: @list-group-active-border; - - // Force color to inherit for custom content - .list-group-item-heading, - .list-group-item-heading > small, - .list-group-item-heading > .small { - color: inherit; - } - .list-group-item-text { - color: @list-group-active-text-color; - } - } -} - - -// Contextual variants -// -// Add modifier classes to change text and background color on individual items. -// Organizationally, this must come after the `:hover` states. - -.list-group-item-variant(success; @state-success-bg; @state-success-text); -.list-group-item-variant(info; @state-info-bg; @state-info-text); -.list-group-item-variant(warning; @state-warning-bg; @state-warning-text); -.list-group-item-variant(danger; @state-danger-bg; @state-danger-text); - - -// Custom content options -// -// Extra classes for creating well-formatted content within `.list-group-item`s. - -.list-group-item-heading { - margin-top: 0; - margin-bottom: 5px; -} -.list-group-item-text { - margin-bottom: 0; - line-height: 1.3; -} diff --git a/src/UI/Content/Bootstrap/media.less b/src/UI/Content/Bootstrap/media.less deleted file mode 100644 index 8c835e861..000000000 --- a/src/UI/Content/Bootstrap/media.less +++ /dev/null @@ -1,66 +0,0 @@ -.media { - // Proper spacing between instances of .media - margin-top: 15px; - - &:first-child { - margin-top: 0; - } -} - -.media, -.media-body { - zoom: 1; - overflow: hidden; -} - -.media-body { - width: 10000px; -} - -.media-object { - display: block; - - // Fix collapse in webkit from max-width: 100% and display: table-cell. - &.img-thumbnail { - max-width: none; - } -} - -.media-right, -.media > .pull-right { - padding-left: 10px; -} - -.media-left, -.media > .pull-left { - padding-right: 10px; -} - -.media-left, -.media-right, -.media-body { - display: table-cell; - vertical-align: top; -} - -.media-middle { - vertical-align: middle; -} - -.media-bottom { - vertical-align: bottom; -} - -// Reset margins on headings for tighter default spacing -.media-heading { - margin-top: 0; - margin-bottom: 5px; -} - -// Media list variation -// -// Undo default ul/ol styles -.media-list { - padding-left: 0; - list-style: none; -} diff --git a/src/UI/Content/Bootstrap/mixins.less b/src/UI/Content/Bootstrap/mixins.less deleted file mode 100644 index e6f9fe684..000000000 --- a/src/UI/Content/Bootstrap/mixins.less +++ /dev/null @@ -1,40 +0,0 @@ -// Mixins -// -------------------------------------------------- - -// Utilities -@import "mixins/hide-text.less"; -@import "mixins/opacity.less"; -@import "mixins/image.less"; -@import "mixins/labels.less"; -@import "mixins/reset-filter.less"; -@import "mixins/resize.less"; -@import "mixins/responsive-visibility.less"; -@import "mixins/size.less"; -@import "mixins/tab-focus.less"; -@import "mixins/reset-text.less"; -@import "mixins/text-emphasis.less"; -@import "mixins/text-overflow.less"; -@import "mixins/vendor-prefixes.less"; - -// Components -@import "mixins/alerts.less"; -@import "mixins/buttons.less"; -@import "mixins/panels.less"; -@import "mixins/pagination.less"; -@import "mixins/list-group.less"; -@import "mixins/nav-divider.less"; -@import "mixins/forms.less"; -@import "mixins/progress-bar.less"; -@import "mixins/table-row.less"; - -// Skins -@import "mixins/background-variant.less"; -@import "mixins/border-radius.less"; -@import "mixins/gradients.less"; - -// Layout -@import "mixins/clearfix.less"; -@import "mixins/center-block.less"; -@import "mixins/nav-vertical-align.less"; -@import "mixins/grid-framework.less"; -@import "mixins/grid.less"; diff --git a/src/UI/Content/Bootstrap/mixins/alerts.less b/src/UI/Content/Bootstrap/mixins/alerts.less deleted file mode 100644 index 396196f43..000000000 --- a/src/UI/Content/Bootstrap/mixins/alerts.less +++ /dev/null @@ -1,14 +0,0 @@ -// Alerts - -.alert-variant(@background; @border; @text-color) { - background-color: @background; - border-color: @border; - color: @text-color; - - hr { - border-top-color: darken(@border, 5%); - } - .alert-link { - color: darken(@text-color, 10%); - } -} diff --git a/src/UI/Content/Bootstrap/mixins/background-variant.less b/src/UI/Content/Bootstrap/mixins/background-variant.less deleted file mode 100644 index a85c22b74..000000000 --- a/src/UI/Content/Bootstrap/mixins/background-variant.less +++ /dev/null @@ -1,9 +0,0 @@ -// Contextual backgrounds - -.bg-variant(@color) { - background-color: @color; - a&:hover, - a&:focus { - background-color: darken(@color, 10%); - } -} diff --git a/src/UI/Content/Bootstrap/mixins/border-radius.less b/src/UI/Content/Bootstrap/mixins/border-radius.less deleted file mode 100644 index ca05dbf45..000000000 --- a/src/UI/Content/Bootstrap/mixins/border-radius.less +++ /dev/null @@ -1,18 +0,0 @@ -// Single side border-radius - -.border-top-radius(@radius) { - border-top-right-radius: @radius; - border-top-left-radius: @radius; -} -.border-right-radius(@radius) { - border-bottom-right-radius: @radius; - border-top-right-radius: @radius; -} -.border-bottom-radius(@radius) { - border-bottom-right-radius: @radius; - border-bottom-left-radius: @radius; -} -.border-left-radius(@radius) { - border-bottom-left-radius: @radius; - border-top-left-radius: @radius; -} diff --git a/src/UI/Content/Bootstrap/mixins/buttons.less b/src/UI/Content/Bootstrap/mixins/buttons.less deleted file mode 100644 index 6875a97c8..000000000 --- a/src/UI/Content/Bootstrap/mixins/buttons.less +++ /dev/null @@ -1,68 +0,0 @@ -// Button variants -// -// Easily pump out default styles, as well as :hover, :focus, :active, -// and disabled options for all buttons - -.button-variant(@color; @background; @border) { - color: @color; - background-color: @background; - border-color: @border; - - &:focus, - &.focus { - color: @color; - background-color: darken(@background, 10%); - border-color: darken(@border, 25%); - } - &:hover { - color: @color; - background-color: darken(@background, 10%); - border-color: darken(@border, 12%); - } - &:active, - &.active, - .open > .dropdown-toggle& { - color: @color; - background-color: darken(@background, 10%); - border-color: darken(@border, 12%); - - &:hover, - &:focus, - &.focus { - color: @color; - background-color: darken(@background, 17%); - border-color: darken(@border, 25%); - } - } - &:active, - &.active, - .open > .dropdown-toggle& { - background-image: none; - } - &.disabled, - &[disabled], - fieldset[disabled] & { - &, - &:hover, - &:focus, - &.focus, - &:active, - &.active { - background-color: @background; - border-color: @border; - } - } - - .badge { - color: @background; - background-color: @color; - } -} - -// Button sizes -.button-size(@padding-vertical; @padding-horizontal; @font-size; @line-height; @border-radius) { - padding: @padding-vertical @padding-horizontal; - font-size: @font-size; - line-height: @line-height; - border-radius: @border-radius; -} diff --git a/src/UI/Content/Bootstrap/mixins/center-block.less b/src/UI/Content/Bootstrap/mixins/center-block.less deleted file mode 100644 index d18d6de9e..000000000 --- a/src/UI/Content/Bootstrap/mixins/center-block.less +++ /dev/null @@ -1,7 +0,0 @@ -// Center-align a block level element - -.center-block() { - display: block; - margin-left: auto; - margin-right: auto; -} diff --git a/src/UI/Content/Bootstrap/mixins/clearfix.less b/src/UI/Content/Bootstrap/mixins/clearfix.less deleted file mode 100644 index 3f7a3820c..000000000 --- a/src/UI/Content/Bootstrap/mixins/clearfix.less +++ /dev/null @@ -1,22 +0,0 @@ -// Clearfix -// -// For modern browsers -// 1. The space content is one way to avoid an Opera bug when the -// contenteditable attribute is included anywhere else in the document. -// Otherwise it causes space to appear at the top and bottom of elements -// that are clearfixed. -// 2. The use of `table` rather than `block` is only necessary if using -// `:before` to contain the top-margins of child elements. -// -// Source: http://nicolasgallagher.com/micro-clearfix-hack/ - -.clearfix() { - &:before, - &:after { - content: " "; // 1 - display: table; // 2 - } - &:after { - clear: both; - } -} diff --git a/src/UI/Content/Bootstrap/mixins/forms.less b/src/UI/Content/Bootstrap/mixins/forms.less deleted file mode 100644 index 6f55ed967..000000000 --- a/src/UI/Content/Bootstrap/mixins/forms.less +++ /dev/null @@ -1,85 +0,0 @@ -// Form validation states -// -// Used in forms.less to generate the form validation CSS for warnings, errors, -// and successes. - -.form-control-validation(@text-color: #555; @border-color: #ccc; @background-color: #f5f5f5) { - // Color the label and help text - .help-block, - .control-label, - .radio, - .checkbox, - .radio-inline, - .checkbox-inline, - &.radio label, - &.checkbox label, - &.radio-inline label, - &.checkbox-inline label { - color: @text-color; - } - // Set the border and box shadow on specific inputs to match - .form-control { - border-color: @border-color; - .box-shadow(inset 0 1px 1px rgba(0,0,0,.075)); // Redeclare so transitions work - &:focus { - border-color: darken(@border-color, 10%); - @shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 6px lighten(@border-color, 20%); - .box-shadow(@shadow); - } - } - // Set validation states also for addons - .input-group-addon { - color: @text-color; - border-color: @border-color; - background-color: @background-color; - } - // Optional feedback icon - .form-control-feedback { - color: @text-color; - } -} - - -// Form control focus state -// -// Generate a customized focus state and for any input with the specified color, -// which defaults to the `@input-border-focus` variable. -// -// We highly encourage you to not customize the default value, but instead use -// this to tweak colors on an as-needed basis. This aesthetic change is based on -// WebKit's default styles, but applicable to a wider range of browsers. Its -// usability and accessibility should be taken into account with any change. -// -// Example usage: change the default blue border and shadow to white for better -// contrast against a dark gray background. -.form-control-focus(@color: @input-border-focus) { - @color-rgba: rgba(red(@color), green(@color), blue(@color), .6); - &:focus { - border-color: @color; - outline: 0; - .box-shadow(~"inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px @{color-rgba}"); - } -} - -// Form control sizing -// -// Relative text size, padding, and border-radii changes for form controls. For -// horizontal sizing, wrap controls in the predefined grid classes. `<select>` -// element gets special love because it's special, and that's a fact! -.input-size(@input-height; @padding-vertical; @padding-horizontal; @font-size; @line-height; @border-radius) { - height: @input-height; - padding: @padding-vertical @padding-horizontal; - font-size: @font-size; - line-height: @line-height; - border-radius: @border-radius; - - select& { - height: @input-height; - line-height: @input-height; - } - - textarea&, - select[multiple]& { - height: auto; - } -} diff --git a/src/UI/Content/Bootstrap/mixins/gradients.less b/src/UI/Content/Bootstrap/mixins/gradients.less deleted file mode 100644 index 0b88a89cc..000000000 --- a/src/UI/Content/Bootstrap/mixins/gradients.less +++ /dev/null @@ -1,59 +0,0 @@ -// Gradients - -#gradient { - - // Horizontal gradient, from left to right - // - // Creates two color stops, start and end, by specifying a color and position for each color stop. - // Color stops are not available in IE9 and below. - .horizontal(@start-color: #555; @end-color: #333; @start-percent: 0%; @end-percent: 100%) { - background-image: -webkit-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // Safari 5.1-6, Chrome 10+ - background-image: -o-linear-gradient(left, @start-color @start-percent, @end-color @end-percent); // Opera 12 - background-image: linear-gradient(to right, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+ - background-repeat: repeat-x; - filter: e(%("progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)",argb(@start-color),argb(@end-color))); // IE9 and down - } - - // Vertical gradient, from top to bottom - // - // Creates two color stops, start and end, by specifying a color and position for each color stop. - // Color stops are not available in IE9 and below. - .vertical(@start-color: #555; @end-color: #333; @start-percent: 0%; @end-percent: 100%) { - background-image: -webkit-linear-gradient(top, @start-color @start-percent, @end-color @end-percent); // Safari 5.1-6, Chrome 10+ - background-image: -o-linear-gradient(top, @start-color @start-percent, @end-color @end-percent); // Opera 12 - background-image: linear-gradient(to bottom, @start-color @start-percent, @end-color @end-percent); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+ - background-repeat: repeat-x; - filter: e(%("progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)",argb(@start-color),argb(@end-color))); // IE9 and down - } - - .directional(@start-color: #555; @end-color: #333; @deg: 45deg) { - background-repeat: repeat-x; - background-image: -webkit-linear-gradient(@deg, @start-color, @end-color); // Safari 5.1-6, Chrome 10+ - background-image: -o-linear-gradient(@deg, @start-color, @end-color); // Opera 12 - background-image: linear-gradient(@deg, @start-color, @end-color); // Standard, IE10, Firefox 16+, Opera 12.10+, Safari 7+, Chrome 26+ - } - .horizontal-three-colors(@start-color: #00b3ee; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #c3325f) { - background-image: -webkit-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color); - background-image: -o-linear-gradient(left, @start-color, @mid-color @color-stop, @end-color); - background-image: linear-gradient(to right, @start-color, @mid-color @color-stop, @end-color); - background-repeat: no-repeat; - filter: e(%("progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=1)",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback - } - .vertical-three-colors(@start-color: #00b3ee; @mid-color: #7a43b6; @color-stop: 50%; @end-color: #c3325f) { - background-image: -webkit-linear-gradient(@start-color, @mid-color @color-stop, @end-color); - background-image: -o-linear-gradient(@start-color, @mid-color @color-stop, @end-color); - background-image: linear-gradient(@start-color, @mid-color @color-stop, @end-color); - background-repeat: no-repeat; - filter: e(%("progid:DXImageTransform.Microsoft.gradient(startColorstr='%d', endColorstr='%d', GradientType=0)",argb(@start-color),argb(@end-color))); // IE9 and down, gets no color-stop at all for proper fallback - } - .radial(@inner-color: #555; @outer-color: #333) { - background-image: -webkit-radial-gradient(circle, @inner-color, @outer-color); - background-image: radial-gradient(circle, @inner-color, @outer-color); - background-repeat: no-repeat; - } - .striped(@color: rgba(255,255,255,.15); @angle: 45deg) { - background-image: -webkit-linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent); - background-image: -o-linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent); - background-image: linear-gradient(@angle, @color 25%, transparent 25%, transparent 50%, @color 50%, @color 75%, transparent 75%, transparent); - } -} diff --git a/src/UI/Content/Bootstrap/mixins/grid-framework.less b/src/UI/Content/Bootstrap/mixins/grid-framework.less deleted file mode 100644 index 8c23eed24..000000000 --- a/src/UI/Content/Bootstrap/mixins/grid-framework.less +++ /dev/null @@ -1,91 +0,0 @@ -// Framework grid generation -// -// Used only by Bootstrap to generate the correct number of grid classes given -// any value of `@grid-columns`. - -.make-grid-columns() { - // Common styles for all sizes of grid columns, widths 1-12 - .col(@index) { // initial - @item: ~".col-xs-@{index}, .col-sm-@{index}, .col-md-@{index}, .col-lg-@{index}"; - .col((@index + 1), @item); - } - .col(@index, @list) when (@index =< @grid-columns) { // general; "=<" isn't a typo - @item: ~".col-xs-@{index}, .col-sm-@{index}, .col-md-@{index}, .col-lg-@{index}"; - .col((@index + 1), ~"@{list}, @{item}"); - } - .col(@index, @list) when (@index > @grid-columns) { // terminal - @{list} { - position: relative; - // Prevent columns from collapsing when empty - min-height: 1px; - // Inner gutter via padding - padding-left: ceil((@grid-gutter-width / 2)); - padding-right: floor((@grid-gutter-width / 2)); - } - } - .col(1); // kickstart it -} - -.float-grid-columns(@class) { - .col(@index) { // initial - @item: ~".col-@{class}-@{index}"; - .col((@index + 1), @item); - } - .col(@index, @list) when (@index =< @grid-columns) { // general - @item: ~".col-@{class}-@{index}"; - .col((@index + 1), ~"@{list}, @{item}"); - } - .col(@index, @list) when (@index > @grid-columns) { // terminal - @{list} { - float: left; - } - } - .col(1); // kickstart it -} - -.calc-grid-column(@index, @class, @type) when (@type = width) and (@index > 0) { - .col-@{class}-@{index} { - width: percentage((@index / @grid-columns)); - } -} -.calc-grid-column(@index, @class, @type) when (@type = push) and (@index > 0) { - .col-@{class}-push-@{index} { - left: percentage((@index / @grid-columns)); - } -} -.calc-grid-column(@index, @class, @type) when (@type = push) and (@index = 0) { - .col-@{class}-push-0 { - left: auto; - } -} -.calc-grid-column(@index, @class, @type) when (@type = pull) and (@index > 0) { - .col-@{class}-pull-@{index} { - right: percentage((@index / @grid-columns)); - } -} -.calc-grid-column(@index, @class, @type) when (@type = pull) and (@index = 0) { - .col-@{class}-pull-0 { - right: auto; - } -} -.calc-grid-column(@index, @class, @type) when (@type = offset) { - .col-@{class}-offset-@{index} { - margin-left: percentage((@index / @grid-columns)); - } -} - -// Basic looping in LESS -.loop-grid-columns(@index, @class, @type) when (@index >= 0) { - .calc-grid-column(@index, @class, @type); - // next iteration - .loop-grid-columns((@index - 1), @class, @type); -} - -// Create grid for specific class -.make-grid(@class) { - .float-grid-columns(@class); - .loop-grid-columns(@grid-columns, @class, width); - .loop-grid-columns(@grid-columns, @class, pull); - .loop-grid-columns(@grid-columns, @class, push); - .loop-grid-columns(@grid-columns, @class, offset); -} diff --git a/src/UI/Content/Bootstrap/mixins/grid.less b/src/UI/Content/Bootstrap/mixins/grid.less deleted file mode 100644 index f144c15f4..000000000 --- a/src/UI/Content/Bootstrap/mixins/grid.less +++ /dev/null @@ -1,122 +0,0 @@ -// Grid system -// -// Generate semantic grid columns with these mixins. - -// Centered container element -.container-fixed(@gutter: @grid-gutter-width) { - margin-right: auto; - margin-left: auto; - padding-left: (@gutter / 2); - padding-right: (@gutter / 2); - &:extend(.clearfix all); -} - -// Creates a wrapper for a series of columns -.make-row(@gutter: @grid-gutter-width) { - margin-left: ceil((@gutter / -2)); - margin-right: floor((@gutter / -2)); - &:extend(.clearfix all); -} - -// Generate the extra small columns -.make-xs-column(@columns; @gutter: @grid-gutter-width) { - position: relative; - float: left; - width: percentage((@columns / @grid-columns)); - min-height: 1px; - padding-left: (@gutter / 2); - padding-right: (@gutter / 2); -} -.make-xs-column-offset(@columns) { - margin-left: percentage((@columns / @grid-columns)); -} -.make-xs-column-push(@columns) { - left: percentage((@columns / @grid-columns)); -} -.make-xs-column-pull(@columns) { - right: percentage((@columns / @grid-columns)); -} - -// Generate the small columns -.make-sm-column(@columns; @gutter: @grid-gutter-width) { - position: relative; - min-height: 1px; - padding-left: (@gutter / 2); - padding-right: (@gutter / 2); - - @media (min-width: @screen-sm-min) { - float: left; - width: percentage((@columns / @grid-columns)); - } -} -.make-sm-column-offset(@columns) { - @media (min-width: @screen-sm-min) { - margin-left: percentage((@columns / @grid-columns)); - } -} -.make-sm-column-push(@columns) { - @media (min-width: @screen-sm-min) { - left: percentage((@columns / @grid-columns)); - } -} -.make-sm-column-pull(@columns) { - @media (min-width: @screen-sm-min) { - right: percentage((@columns / @grid-columns)); - } -} - -// Generate the medium columns -.make-md-column(@columns; @gutter: @grid-gutter-width) { - position: relative; - min-height: 1px; - padding-left: (@gutter / 2); - padding-right: (@gutter / 2); - - @media (min-width: @screen-md-min) { - float: left; - width: percentage((@columns / @grid-columns)); - } -} -.make-md-column-offset(@columns) { - @media (min-width: @screen-md-min) { - margin-left: percentage((@columns / @grid-columns)); - } -} -.make-md-column-push(@columns) { - @media (min-width: @screen-md-min) { - left: percentage((@columns / @grid-columns)); - } -} -.make-md-column-pull(@columns) { - @media (min-width: @screen-md-min) { - right: percentage((@columns / @grid-columns)); - } -} - -// Generate the large columns -.make-lg-column(@columns; @gutter: @grid-gutter-width) { - position: relative; - min-height: 1px; - padding-left: (@gutter / 2); - padding-right: (@gutter / 2); - - @media (min-width: @screen-lg-min) { - float: left; - width: percentage((@columns / @grid-columns)); - } -} -.make-lg-column-offset(@columns) { - @media (min-width: @screen-lg-min) { - margin-left: percentage((@columns / @grid-columns)); - } -} -.make-lg-column-push(@columns) { - @media (min-width: @screen-lg-min) { - left: percentage((@columns / @grid-columns)); - } -} -.make-lg-column-pull(@columns) { - @media (min-width: @screen-lg-min) { - right: percentage((@columns / @grid-columns)); - } -} diff --git a/src/UI/Content/Bootstrap/mixins/hide-text.less b/src/UI/Content/Bootstrap/mixins/hide-text.less deleted file mode 100644 index bc7011850..000000000 --- a/src/UI/Content/Bootstrap/mixins/hide-text.less +++ /dev/null @@ -1,21 +0,0 @@ -// CSS image replacement -// -// Heads up! v3 launched with only `.hide-text()`, but per our pattern for -// mixins being reused as classes with the same name, this doesn't hold up. As -// of v3.0.1 we have added `.text-hide()` and deprecated `.hide-text()`. -// -// Source: https://github.com/h5bp/html5-boilerplate/commit/aa0396eae757 - -// Deprecated as of v3.0.1 (will be removed in v4) -.hide-text() { - font: ~"0/0" a; - color: transparent; - text-shadow: none; - background-color: transparent; - border: 0; -} - -// New mixin to use as of v3.0.1 -.text-hide() { - .hide-text(); -} diff --git a/src/UI/Content/Bootstrap/mixins/image.less b/src/UI/Content/Bootstrap/mixins/image.less deleted file mode 100644 index f233cb3e1..000000000 --- a/src/UI/Content/Bootstrap/mixins/image.less +++ /dev/null @@ -1,33 +0,0 @@ -// Image Mixins -// - Responsive image -// - Retina image - - -// Responsive image -// -// Keep images from scaling beyond the width of their parents. -.img-responsive(@display: block) { - display: @display; - max-width: 100%; // Part 1: Set a maximum relative to the parent - height: auto; // Part 2: Scale the height according to the width, otherwise you get stretching -} - - -// Retina image -// -// Short retina mixin for setting background-image and -size. Note that the -// spelling of `min--moz-device-pixel-ratio` is intentional. -.img-retina(@file-1x; @file-2x; @width-1x; @height-1x) { - background-image: url("@{file-1x}"); - - @media - only screen and (-webkit-min-device-pixel-ratio: 2), - only screen and ( min--moz-device-pixel-ratio: 2), - only screen and ( -o-min-device-pixel-ratio: 2/1), - only screen and ( min-device-pixel-ratio: 2), - only screen and ( min-resolution: 192dpi), - only screen and ( min-resolution: 2dppx) { - background-image: url("@{file-2x}"); - background-size: @width-1x @height-1x; - } -} diff --git a/src/UI/Content/Bootstrap/mixins/labels.less b/src/UI/Content/Bootstrap/mixins/labels.less deleted file mode 100644 index 9f7a67ee3..000000000 --- a/src/UI/Content/Bootstrap/mixins/labels.less +++ /dev/null @@ -1,12 +0,0 @@ -// Labels - -.label-variant(@color) { - background-color: @color; - - &[href] { - &:hover, - &:focus { - background-color: darken(@color, 10%); - } - } -} diff --git a/src/UI/Content/Bootstrap/mixins/list-group.less b/src/UI/Content/Bootstrap/mixins/list-group.less deleted file mode 100644 index 03aa19069..000000000 --- a/src/UI/Content/Bootstrap/mixins/list-group.less +++ /dev/null @@ -1,30 +0,0 @@ -// List Groups - -.list-group-item-variant(@state; @background; @color) { - .list-group-item-@{state} { - color: @color; - background-color: @background; - - a&, - button& { - color: @color; - - .list-group-item-heading { - color: inherit; - } - - &:hover, - &:focus { - color: @color; - background-color: darken(@background, 5%); - } - &.active, - &.active:hover, - &.active:focus { - color: #fff; - background-color: @color; - border-color: @color; - } - } - } -} diff --git a/src/UI/Content/Bootstrap/mixins/nav-divider.less b/src/UI/Content/Bootstrap/mixins/nav-divider.less deleted file mode 100644 index feb1e9ed0..000000000 --- a/src/UI/Content/Bootstrap/mixins/nav-divider.less +++ /dev/null @@ -1,10 +0,0 @@ -// Horizontal dividers -// -// Dividers (basically an hr) within dropdowns and nav lists - -.nav-divider(@color: #e5e5e5) { - height: 1px; - margin: ((@line-height-computed / 2) - 1) 0; - overflow: hidden; - background-color: @color; -} diff --git a/src/UI/Content/Bootstrap/mixins/nav-vertical-align.less b/src/UI/Content/Bootstrap/mixins/nav-vertical-align.less deleted file mode 100644 index d458c7861..000000000 --- a/src/UI/Content/Bootstrap/mixins/nav-vertical-align.less +++ /dev/null @@ -1,9 +0,0 @@ -// Navbar vertical align -// -// Vertically center elements in the navbar. -// Example: an element has a height of 30px, so write out `.navbar-vertical-align(30px);` to calculate the appropriate top margin. - -.navbar-vertical-align(@element-height) { - margin-top: ((@navbar-height - @element-height) / 2); - margin-bottom: ((@navbar-height - @element-height) / 2); -} diff --git a/src/UI/Content/Bootstrap/mixins/opacity.less b/src/UI/Content/Bootstrap/mixins/opacity.less deleted file mode 100644 index 33ed25ce6..000000000 --- a/src/UI/Content/Bootstrap/mixins/opacity.less +++ /dev/null @@ -1,8 +0,0 @@ -// Opacity - -.opacity(@opacity) { - opacity: @opacity; - // IE8 filter - @opacity-ie: (@opacity * 100); - filter: ~"alpha(opacity=@{opacity-ie})"; -} diff --git a/src/UI/Content/Bootstrap/mixins/pagination.less b/src/UI/Content/Bootstrap/mixins/pagination.less deleted file mode 100644 index 618804f2d..000000000 --- a/src/UI/Content/Bootstrap/mixins/pagination.less +++ /dev/null @@ -1,24 +0,0 @@ -// Pagination - -.pagination-size(@padding-vertical; @padding-horizontal; @font-size; @line-height; @border-radius) { - > li { - > a, - > span { - padding: @padding-vertical @padding-horizontal; - font-size: @font-size; - line-height: @line-height; - } - &:first-child { - > a, - > span { - .border-left-radius(@border-radius); - } - } - &:last-child { - > a, - > span { - .border-right-radius(@border-radius); - } - } - } -} diff --git a/src/UI/Content/Bootstrap/mixins/panels.less b/src/UI/Content/Bootstrap/mixins/panels.less deleted file mode 100644 index 49ee10d4a..000000000 --- a/src/UI/Content/Bootstrap/mixins/panels.less +++ /dev/null @@ -1,24 +0,0 @@ -// Panels - -.panel-variant(@border; @heading-text-color; @heading-bg-color; @heading-border) { - border-color: @border; - - & > .panel-heading { - color: @heading-text-color; - background-color: @heading-bg-color; - border-color: @heading-border; - - + .panel-collapse > .panel-body { - border-top-color: @border; - } - .badge { - color: @heading-bg-color; - background-color: @heading-text-color; - } - } - & > .panel-footer { - + .panel-collapse > .panel-body { - border-bottom-color: @border; - } - } -} diff --git a/src/UI/Content/Bootstrap/mixins/progress-bar.less b/src/UI/Content/Bootstrap/mixins/progress-bar.less deleted file mode 100644 index f07996a34..000000000 --- a/src/UI/Content/Bootstrap/mixins/progress-bar.less +++ /dev/null @@ -1,10 +0,0 @@ -// Progress bars - -.progress-bar-variant(@color) { - background-color: @color; - - // Deprecated parent class requirement as of v3.2.0 - .progress-striped & { - #gradient > .striped(); - } -} diff --git a/src/UI/Content/Bootstrap/mixins/reset-filter.less b/src/UI/Content/Bootstrap/mixins/reset-filter.less deleted file mode 100644 index 68cdb5e18..000000000 --- a/src/UI/Content/Bootstrap/mixins/reset-filter.less +++ /dev/null @@ -1,8 +0,0 @@ -// Reset filters for IE -// -// When you need to remove a gradient background, do not forget to use this to reset -// the IE filter for IE9 and below. - -.reset-filter() { - filter: e(%("progid:DXImageTransform.Microsoft.gradient(enabled = false)")); -} diff --git a/src/UI/Content/Bootstrap/mixins/reset-text.less b/src/UI/Content/Bootstrap/mixins/reset-text.less deleted file mode 100644 index 58dd4d19b..000000000 --- a/src/UI/Content/Bootstrap/mixins/reset-text.less +++ /dev/null @@ -1,18 +0,0 @@ -.reset-text() { - font-family: @font-family-base; - // We deliberately do NOT reset font-size. - font-style: normal; - font-weight: normal; - letter-spacing: normal; - line-break: auto; - line-height: @line-height-base; - text-align: left; // Fallback for where `start` is not supported - text-align: start; - text-decoration: none; - text-shadow: none; - text-transform: none; - white-space: normal; - word-break: normal; - word-spacing: normal; - word-wrap: normal; -} diff --git a/src/UI/Content/Bootstrap/mixins/resize.less b/src/UI/Content/Bootstrap/mixins/resize.less deleted file mode 100644 index 3acd3afdb..000000000 --- a/src/UI/Content/Bootstrap/mixins/resize.less +++ /dev/null @@ -1,6 +0,0 @@ -// Resize anything - -.resizable(@direction) { - resize: @direction; // Options: horizontal, vertical, both - overflow: auto; // Per CSS3 UI, `resize` only applies when `overflow` isn't `visible` -} diff --git a/src/UI/Content/Bootstrap/mixins/responsive-visibility.less b/src/UI/Content/Bootstrap/mixins/responsive-visibility.less deleted file mode 100644 index ecf1e979f..000000000 --- a/src/UI/Content/Bootstrap/mixins/responsive-visibility.less +++ /dev/null @@ -1,15 +0,0 @@ -// Responsive utilities - -// -// More easily include all the states for responsive-utilities.less. -.responsive-visibility() { - display: block !important; - table& { display: table !important; } - tr& { display: table-row !important; } - th&, - td& { display: table-cell !important; } -} - -.responsive-invisibility() { - display: none !important; -} diff --git a/src/UI/Content/Bootstrap/mixins/size.less b/src/UI/Content/Bootstrap/mixins/size.less deleted file mode 100644 index a8be65089..000000000 --- a/src/UI/Content/Bootstrap/mixins/size.less +++ /dev/null @@ -1,10 +0,0 @@ -// Sizing shortcuts - -.size(@width; @height) { - width: @width; - height: @height; -} - -.square(@size) { - .size(@size; @size); -} diff --git a/src/UI/Content/Bootstrap/mixins/tab-focus.less b/src/UI/Content/Bootstrap/mixins/tab-focus.less deleted file mode 100644 index 1f1f05ab0..000000000 --- a/src/UI/Content/Bootstrap/mixins/tab-focus.less +++ /dev/null @@ -1,9 +0,0 @@ -// WebKit-style focus - -.tab-focus() { - // Default - outline: thin dotted; - // WebKit - outline: 5px auto -webkit-focus-ring-color; - outline-offset: -2px; -} diff --git a/src/UI/Content/Bootstrap/mixins/table-row.less b/src/UI/Content/Bootstrap/mixins/table-row.less deleted file mode 100644 index 0f287f1a8..000000000 --- a/src/UI/Content/Bootstrap/mixins/table-row.less +++ /dev/null @@ -1,28 +0,0 @@ -// Tables - -.table-row-variant(@state; @background) { - // Exact selectors below required to override `.table-striped` and prevent - // inheritance to nested tables. - .table > thead > tr, - .table > tbody > tr, - .table > tfoot > tr { - > td.@{state}, - > th.@{state}, - &.@{state} > td, - &.@{state} > th { - background-color: @background; - } - } - - // Hover states for `.table-hover` - // Note: this is not available for cells or rows within `thead` or `tfoot`. - .table-hover > tbody > tr { - > td.@{state}:hover, - > th.@{state}:hover, - &.@{state}:hover > td, - &:hover > .@{state}, - &.@{state}:hover > th { - background-color: darken(@background, 5%); - } - } -} diff --git a/src/UI/Content/Bootstrap/mixins/text-emphasis.less b/src/UI/Content/Bootstrap/mixins/text-emphasis.less deleted file mode 100644 index 9e8a77a69..000000000 --- a/src/UI/Content/Bootstrap/mixins/text-emphasis.less +++ /dev/null @@ -1,9 +0,0 @@ -// Typography - -.text-emphasis-variant(@color) { - color: @color; - a&:hover, - a&:focus { - color: darken(@color, 10%); - } -} diff --git a/src/UI/Content/Bootstrap/mixins/text-overflow.less b/src/UI/Content/Bootstrap/mixins/text-overflow.less deleted file mode 100644 index c11ad2fb7..000000000 --- a/src/UI/Content/Bootstrap/mixins/text-overflow.less +++ /dev/null @@ -1,8 +0,0 @@ -// Text overflow -// Requires inline-block or block for proper styling - -.text-overflow() { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} diff --git a/src/UI/Content/Bootstrap/mixins/vendor-prefixes.less b/src/UI/Content/Bootstrap/mixins/vendor-prefixes.less deleted file mode 100644 index afd3331c3..000000000 --- a/src/UI/Content/Bootstrap/mixins/vendor-prefixes.less +++ /dev/null @@ -1,227 +0,0 @@ -// Vendor Prefixes -// -// All vendor mixins are deprecated as of v3.2.0 due to the introduction of -// Autoprefixer in our Gruntfile. They will be removed in v4. - -// - Animations -// - Backface visibility -// - Box shadow -// - Box sizing -// - Content columns -// - Hyphens -// - Placeholder text -// - Transformations -// - Transitions -// - User Select - - -// Animations -.animation(@animation) { - -webkit-animation: @animation; - -o-animation: @animation; - animation: @animation; -} -.animation-name(@name) { - -webkit-animation-name: @name; - animation-name: @name; -} -.animation-duration(@duration) { - -webkit-animation-duration: @duration; - animation-duration: @duration; -} -.animation-timing-function(@timing-function) { - -webkit-animation-timing-function: @timing-function; - animation-timing-function: @timing-function; -} -.animation-delay(@delay) { - -webkit-animation-delay: @delay; - animation-delay: @delay; -} -.animation-iteration-count(@iteration-count) { - -webkit-animation-iteration-count: @iteration-count; - animation-iteration-count: @iteration-count; -} -.animation-direction(@direction) { - -webkit-animation-direction: @direction; - animation-direction: @direction; -} -.animation-fill-mode(@fill-mode) { - -webkit-animation-fill-mode: @fill-mode; - animation-fill-mode: @fill-mode; -} - -// Backface visibility -// Prevent browsers from flickering when using CSS 3D transforms. -// Default value is `visible`, but can be changed to `hidden` - -.backface-visibility(@visibility){ - -webkit-backface-visibility: @visibility; - -moz-backface-visibility: @visibility; - backface-visibility: @visibility; -} - -// Drop shadows -// -// Note: Deprecated `.box-shadow()` as of v3.1.0 since all of Bootstrap's -// supported browsers that have box shadow capabilities now support it. - -.box-shadow(@shadow) { - -webkit-box-shadow: @shadow; // iOS <4.3 & Android <4.1 - box-shadow: @shadow; -} - -// Box sizing -.box-sizing(@boxmodel) { - -webkit-box-sizing: @boxmodel; - -moz-box-sizing: @boxmodel; - box-sizing: @boxmodel; -} - -// CSS3 Content Columns -.content-columns(@column-count; @column-gap: @grid-gutter-width) { - -webkit-column-count: @column-count; - -moz-column-count: @column-count; - column-count: @column-count; - -webkit-column-gap: @column-gap; - -moz-column-gap: @column-gap; - column-gap: @column-gap; -} - -// Optional hyphenation -.hyphens(@mode: auto) { - word-wrap: break-word; - -webkit-hyphens: @mode; - -moz-hyphens: @mode; - -ms-hyphens: @mode; // IE10+ - -o-hyphens: @mode; - hyphens: @mode; -} - -// Placeholder text -.placeholder(@color: @input-color-placeholder) { - // Firefox - &::-moz-placeholder { - color: @color; - opacity: 1; // Override Firefox's unusual default opacity; see https://github.com/twbs/bootstrap/pull/11526 - } - &:-ms-input-placeholder { color: @color; } // Internet Explorer 10+ - &::-webkit-input-placeholder { color: @color; } // Safari and Chrome -} - -// Transformations -.scale(@ratio) { - -webkit-transform: scale(@ratio); - -ms-transform: scale(@ratio); // IE9 only - -o-transform: scale(@ratio); - transform: scale(@ratio); -} -.scale(@ratioX; @ratioY) { - -webkit-transform: scale(@ratioX, @ratioY); - -ms-transform: scale(@ratioX, @ratioY); // IE9 only - -o-transform: scale(@ratioX, @ratioY); - transform: scale(@ratioX, @ratioY); -} -.scaleX(@ratio) { - -webkit-transform: scaleX(@ratio); - -ms-transform: scaleX(@ratio); // IE9 only - -o-transform: scaleX(@ratio); - transform: scaleX(@ratio); -} -.scaleY(@ratio) { - -webkit-transform: scaleY(@ratio); - -ms-transform: scaleY(@ratio); // IE9 only - -o-transform: scaleY(@ratio); - transform: scaleY(@ratio); -} -.skew(@x; @y) { - -webkit-transform: skewX(@x) skewY(@y); - -ms-transform: skewX(@x) skewY(@y); // See https://github.com/twbs/bootstrap/issues/4885; IE9+ - -o-transform: skewX(@x) skewY(@y); - transform: skewX(@x) skewY(@y); -} -.translate(@x; @y) { - -webkit-transform: translate(@x, @y); - -ms-transform: translate(@x, @y); // IE9 only - -o-transform: translate(@x, @y); - transform: translate(@x, @y); -} -.translate3d(@x; @y; @z) { - -webkit-transform: translate3d(@x, @y, @z); - transform: translate3d(@x, @y, @z); -} -.rotate(@degrees) { - -webkit-transform: rotate(@degrees); - -ms-transform: rotate(@degrees); // IE9 only - -o-transform: rotate(@degrees); - transform: rotate(@degrees); -} -.rotateX(@degrees) { - -webkit-transform: rotateX(@degrees); - -ms-transform: rotateX(@degrees); // IE9 only - -o-transform: rotateX(@degrees); - transform: rotateX(@degrees); -} -.rotateY(@degrees) { - -webkit-transform: rotateY(@degrees); - -ms-transform: rotateY(@degrees); // IE9 only - -o-transform: rotateY(@degrees); - transform: rotateY(@degrees); -} -.perspective(@perspective) { - -webkit-perspective: @perspective; - -moz-perspective: @perspective; - perspective: @perspective; -} -.perspective-origin(@perspective) { - -webkit-perspective-origin: @perspective; - -moz-perspective-origin: @perspective; - perspective-origin: @perspective; -} -.transform-origin(@origin) { - -webkit-transform-origin: @origin; - -moz-transform-origin: @origin; - -ms-transform-origin: @origin; // IE9 only - transform-origin: @origin; -} - - -// Transitions - -.transition(@transition) { - -webkit-transition: @transition; - -o-transition: @transition; - transition: @transition; -} -.transition-property(@transition-property) { - -webkit-transition-property: @transition-property; - transition-property: @transition-property; -} -.transition-delay(@transition-delay) { - -webkit-transition-delay: @transition-delay; - transition-delay: @transition-delay; -} -.transition-duration(@transition-duration) { - -webkit-transition-duration: @transition-duration; - transition-duration: @transition-duration; -} -.transition-timing-function(@timing-function) { - -webkit-transition-timing-function: @timing-function; - transition-timing-function: @timing-function; -} -.transition-transform(@transition) { - -webkit-transition: -webkit-transform @transition; - -moz-transition: -moz-transform @transition; - -o-transition: -o-transform @transition; - transition: transform @transition; -} - - -// User select -// For selecting text on the page - -.user-select(@select) { - -webkit-user-select: @select; - -moz-user-select: @select; - -ms-user-select: @select; // IE10+ - user-select: @select; -} diff --git a/src/UI/Content/Bootstrap/modals.less b/src/UI/Content/Bootstrap/modals.less deleted file mode 100644 index 1de622050..000000000 --- a/src/UI/Content/Bootstrap/modals.less +++ /dev/null @@ -1,150 +0,0 @@ -// -// Modals -// -------------------------------------------------- - -// .modal-open - body class for killing the scroll -// .modal - container to scroll within -// .modal-dialog - positioning shell for the actual modal -// .modal-content - actual modal w/ bg and corners and shit - -// Kill the scroll on the body -.modal-open { - overflow: hidden; -} - -// Container that the modal scrolls within -.modal { - display: none; - overflow: hidden; - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: @zindex-modal; - -webkit-overflow-scrolling: touch; - - // Prevent Chrome on Windows from adding a focus outline. For details, see - // https://github.com/twbs/bootstrap/pull/10951. - outline: 0; - - // When fading in the modal, animate it to slide down - &.fade .modal-dialog { - .translate(0, -25%); - .transition-transform(~"0.3s ease-out"); - } - &.in .modal-dialog { .translate(0, 0) } -} -.modal-open .modal { - overflow-x: hidden; - overflow-y: auto; -} - -// Shell div to position the modal with bottom padding -.modal-dialog { - position: relative; - width: auto; - margin: 10px; -} - -// Actual modal -.modal-content { - position: relative; - background-color: @modal-content-bg; - border: 1px solid @modal-content-fallback-border-color; //old browsers fallback (ie8 etc) - border: 1px solid @modal-content-border-color; - border-radius: @border-radius-large; - .box-shadow(0 3px 9px rgba(0,0,0,.5)); - background-clip: padding-box; - // Remove focus outline from opened modal - outline: 0; -} - -// Modal background -.modal-backdrop { - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: @zindex-modal-background; - background-color: @modal-backdrop-bg; - // Fade for backdrop - &.fade { .opacity(0); } - &.in { .opacity(@modal-backdrop-opacity); } -} - -// Modal header -// Top section of the modal w/ title and dismiss -.modal-header { - padding: @modal-title-padding; - border-bottom: 1px solid @modal-header-border-color; - min-height: (@modal-title-padding + @modal-title-line-height); -} -// Close icon -.modal-header .close { - margin-top: -2px; -} - -// Title text within header -.modal-title { - margin: 0; - line-height: @modal-title-line-height; -} - -// Modal body -// Where all modal content resides (sibling of .modal-header and .modal-footer) -.modal-body { - position: relative; - padding: @modal-inner-padding; -} - -// Footer (for actions) -.modal-footer { - padding: @modal-inner-padding; - text-align: right; // right align buttons - border-top: 1px solid @modal-footer-border-color; - &:extend(.clearfix all); // clear it in case folks use .pull-* classes on buttons - - // Properly space out buttons - .btn + .btn { - margin-left: 5px; - margin-bottom: 0; // account for input[type="submit"] which gets the bottom margin like all other inputs - } - // but override that for button groups - .btn-group .btn + .btn { - margin-left: -1px; - } - // and override it for block buttons as well - .btn-block + .btn-block { - margin-left: 0; - } -} - -// Measure scrollbar width for padding body during modal show/hide -.modal-scrollbar-measure { - position: absolute; - top: -9999px; - width: 50px; - height: 50px; - overflow: scroll; -} - -// Scale up the modal -@media (min-width: @screen-sm-min) { - // Automatically set modal's width for larger viewports - .modal-dialog { - width: @modal-md; - margin: 30px auto; - } - .modal-content { - .box-shadow(0 5px 15px rgba(0,0,0,.5)); - } - - // Modal sizes - .modal-sm { width: @modal-sm; } -} - -@media (min-width: @screen-md-min) { - .modal-lg { width: @modal-lg; } -} diff --git a/src/UI/Content/Bootstrap/navbar.less b/src/UI/Content/Bootstrap/navbar.less deleted file mode 100644 index 6d751bb9c..000000000 --- a/src/UI/Content/Bootstrap/navbar.less +++ /dev/null @@ -1,660 +0,0 @@ -// -// Navbars -// -------------------------------------------------- - - -// Wrapper and base class -// -// Provide a static navbar from which we expand to create full-width, fixed, and -// other navbar variations. - -.navbar { - position: relative; - min-height: @navbar-height; // Ensure a navbar always shows (e.g., without a .navbar-brand in collapsed mode) - margin-bottom: @navbar-margin-bottom; - border: 1px solid transparent; - - // Prevent floats from breaking the navbar - &:extend(.clearfix all); - - @media (min-width: @grid-float-breakpoint) { - border-radius: @navbar-border-radius; - } -} - - -// Navbar heading -// -// Groups `.navbar-brand` and `.navbar-toggle` into a single component for easy -// styling of responsive aspects. - -.navbar-header { - &:extend(.clearfix all); - - @media (min-width: @grid-float-breakpoint) { - float: left; - } -} - - -// Navbar collapse (body) -// -// Group your navbar content into this for easy collapsing and expanding across -// various device sizes. By default, this content is collapsed when <768px, but -// will expand past that for a horizontal display. -// -// To start (on mobile devices) the navbar links, forms, and buttons are stacked -// vertically and include a `max-height` to overflow in case you have too much -// content for the user's viewport. - -.navbar-collapse { - overflow-x: visible; - padding-right: @navbar-padding-horizontal; - padding-left: @navbar-padding-horizontal; - border-top: 1px solid transparent; - box-shadow: inset 0 1px 0 rgba(255,255,255,.1); - &:extend(.clearfix all); - -webkit-overflow-scrolling: touch; - - &.in { - overflow-y: auto; - } - - @media (min-width: @grid-float-breakpoint) { - width: auto; - border-top: 0; - box-shadow: none; - - &.collapse { - display: block !important; - height: auto !important; - padding-bottom: 0; // Override default setting - overflow: visible !important; - } - - &.in { - overflow-y: visible; - } - - // Undo the collapse side padding for navbars with containers to ensure - // alignment of right-aligned contents. - .navbar-fixed-top &, - .navbar-static-top &, - .navbar-fixed-bottom & { - padding-left: 0; - padding-right: 0; - } - } -} - -.navbar-fixed-top, -.navbar-fixed-bottom { - .navbar-collapse { - max-height: @navbar-collapse-max-height; - - @media (max-device-width: @screen-xs-min) and (orientation: landscape) { - max-height: 200px; - } - } -} - - -// Both navbar header and collapse -// -// When a container is present, change the behavior of the header and collapse. - -.container, -.container-fluid { - > .navbar-header, - > .navbar-collapse { - margin-right: -@navbar-padding-horizontal; - margin-left: -@navbar-padding-horizontal; - - @media (min-width: @grid-float-breakpoint) { - margin-right: 0; - margin-left: 0; - } - } -} - - -// -// Navbar alignment options -// -// Display the navbar across the entirety of the page or fixed it to the top or -// bottom of the page. - -// Static top (unfixed, but 100% wide) navbar -.navbar-static-top { - z-index: @zindex-navbar; - border-width: 0 0 1px; - - @media (min-width: @grid-float-breakpoint) { - border-radius: 0; - } -} - -// Fix the top/bottom navbars when screen real estate supports it -.navbar-fixed-top, -.navbar-fixed-bottom { - position: fixed; - right: 0; - left: 0; - z-index: @zindex-navbar-fixed; - - // Undo the rounded corners - @media (min-width: @grid-float-breakpoint) { - border-radius: 0; - } -} -.navbar-fixed-top { - top: 0; - border-width: 0 0 1px; -} -.navbar-fixed-bottom { - bottom: 0; - margin-bottom: 0; // override .navbar defaults - border-width: 1px 0 0; -} - - -// Brand/project name - -.navbar-brand { - float: left; - padding: @navbar-padding-vertical @navbar-padding-horizontal; - font-size: @font-size-large; - line-height: @line-height-computed; - height: @navbar-height; - - &:hover, - &:focus { - text-decoration: none; - } - - > img { - display: block; - } - - @media (min-width: @grid-float-breakpoint) { - .navbar > .container &, - .navbar > .container-fluid & { - margin-left: -@navbar-padding-horizontal; - } - } -} - - -// Navbar toggle -// -// Custom button for toggling the `.navbar-collapse`, powered by the collapse -// JavaScript plugin. - -.navbar-toggle { - position: relative; - float: right; - margin-right: @navbar-padding-horizontal; - padding: 9px 10px; - .navbar-vertical-align(34px); - background-color: transparent; - background-image: none; // Reset unusual Firefox-on-Android default style; see https://github.com/necolas/normalize.css/issues/214 - border: 1px solid transparent; - border-radius: @border-radius-base; - - // We remove the `outline` here, but later compensate by attaching `:hover` - // styles to `:focus`. - &:focus { - outline: 0; - } - - // Bars - .icon-bar { - display: block; - width: 22px; - height: 2px; - border-radius: 1px; - } - .icon-bar + .icon-bar { - margin-top: 4px; - } - - @media (min-width: @grid-float-breakpoint) { - display: none; - } -} - - -// Navbar nav links -// -// Builds on top of the `.nav` components with its own modifier class to make -// the nav the full height of the horizontal nav (above 768px). - -.navbar-nav { - margin: (@navbar-padding-vertical / 2) -@navbar-padding-horizontal; - - > li > a { - padding-top: 10px; - padding-bottom: 10px; - line-height: @line-height-computed; - } - - @media (max-width: @grid-float-breakpoint-max) { - // Dropdowns get custom display when collapsed - .open .dropdown-menu { - position: static; - float: none; - width: auto; - margin-top: 0; - background-color: transparent; - border: 0; - box-shadow: none; - > li > a, - .dropdown-header { - padding: 5px 15px 5px 25px; - } - > li > a { - line-height: @line-height-computed; - &:hover, - &:focus { - background-image: none; - } - } - } - } - - // Uncollapse the nav - @media (min-width: @grid-float-breakpoint) { - float: left; - margin: 0; - - > li { - float: left; - > a { - padding-top: @navbar-padding-vertical; - padding-bottom: @navbar-padding-vertical; - } - } - } -} - - -// Navbar form -// -// Extension of the `.form-inline` with some extra flavor for optimum display in -// our navbars. - -.navbar-form { - margin-left: -@navbar-padding-horizontal; - margin-right: -@navbar-padding-horizontal; - padding: 10px @navbar-padding-horizontal; - border-top: 1px solid transparent; - border-bottom: 1px solid transparent; - @shadow: inset 0 1px 0 rgba(255,255,255,.1), 0 1px 0 rgba(255,255,255,.1); - .box-shadow(@shadow); - - // Mixin behavior for optimum display - .form-inline(); - - .form-group { - @media (max-width: @grid-float-breakpoint-max) { - margin-bottom: 5px; - - &:last-child { - margin-bottom: 0; - } - } - } - - // Vertically center in expanded, horizontal navbar - .navbar-vertical-align(@input-height-base); - - // Undo 100% width for pull classes - @media (min-width: @grid-float-breakpoint) { - width: auto; - border: 0; - margin-left: 0; - margin-right: 0; - padding-top: 0; - padding-bottom: 0; - .box-shadow(none); - } -} - - -// Dropdown menus - -// Menu position and menu carets -.navbar-nav > li > .dropdown-menu { - margin-top: 0; - .border-top-radius(0); -} -// Menu position and menu caret support for dropups via extra dropup class -.navbar-fixed-bottom .navbar-nav > li > .dropdown-menu { - margin-bottom: 0; - .border-top-radius(@navbar-border-radius); - .border-bottom-radius(0); -} - - -// Buttons in navbars -// -// Vertically center a button within a navbar (when *not* in a form). - -.navbar-btn { - .navbar-vertical-align(@input-height-base); - - &.btn-sm { - .navbar-vertical-align(@input-height-small); - } - &.btn-xs { - .navbar-vertical-align(22); - } -} - - -// Text in navbars -// -// Add a class to make any element properly align itself vertically within the navbars. - -.navbar-text { - .navbar-vertical-align(@line-height-computed); - - @media (min-width: @grid-float-breakpoint) { - float: left; - margin-left: @navbar-padding-horizontal; - margin-right: @navbar-padding-horizontal; - } -} - - -// Component alignment -// -// Repurpose the pull utilities as their own navbar utilities to avoid specificity -// issues with parents and chaining. Only do this when the navbar is uncollapsed -// though so that navbar contents properly stack and align in mobile. -// -// Declared after the navbar components to ensure more specificity on the margins. - -@media (min-width: @grid-float-breakpoint) { - .navbar-left { .pull-left(); } - .navbar-right { - .pull-right(); - margin-right: -@navbar-padding-horizontal; - - ~ .navbar-right { - margin-right: 0; - } - } -} - - -// Alternate navbars -// -------------------------------------------------- - -// Default navbar -.navbar-default { - background-color: @navbar-default-bg; - border-color: @navbar-default-border; - - .navbar-brand { - color: @navbar-default-brand-color; - &:hover, - &:focus { - color: @navbar-default-brand-hover-color; - background-color: @navbar-default-brand-hover-bg; - } - } - - .navbar-text { - color: @navbar-default-color; - } - - .navbar-nav { - > li > a { - color: @navbar-default-link-color; - - &:hover, - &:focus { - color: @navbar-default-link-hover-color; - background-color: @navbar-default-link-hover-bg; - } - } - > .active > a { - &, - &:hover, - &:focus { - color: @navbar-default-link-active-color; - background-color: @navbar-default-link-active-bg; - } - } - > .disabled > a { - &, - &:hover, - &:focus { - color: @navbar-default-link-disabled-color; - background-color: @navbar-default-link-disabled-bg; - } - } - } - - .navbar-toggle { - border-color: @navbar-default-toggle-border-color; - &:hover, - &:focus { - background-color: @navbar-default-toggle-hover-bg; - } - .icon-bar { - background-color: @navbar-default-toggle-icon-bar-bg; - } - } - - .navbar-collapse, - .navbar-form { - border-color: @navbar-default-border; - } - - // Dropdown menu items - .navbar-nav { - // Remove background color from open dropdown - > .open > a { - &, - &:hover, - &:focus { - background-color: @navbar-default-link-active-bg; - color: @navbar-default-link-active-color; - } - } - - @media (max-width: @grid-float-breakpoint-max) { - // Dropdowns get custom display when collapsed - .open .dropdown-menu { - > li > a { - color: @navbar-default-link-color; - &:hover, - &:focus { - color: @navbar-default-link-hover-color; - background-color: @navbar-default-link-hover-bg; - } - } - > .active > a { - &, - &:hover, - &:focus { - color: @navbar-default-link-active-color; - background-color: @navbar-default-link-active-bg; - } - } - > .disabled > a { - &, - &:hover, - &:focus { - color: @navbar-default-link-disabled-color; - background-color: @navbar-default-link-disabled-bg; - } - } - } - } - } - - - // Links in navbars - // - // Add a class to ensure links outside the navbar nav are colored correctly. - - .navbar-link { - color: @navbar-default-link-color; - &:hover { - color: @navbar-default-link-hover-color; - } - } - - .btn-link { - color: @navbar-default-link-color; - &:hover, - &:focus { - color: @navbar-default-link-hover-color; - } - &[disabled], - fieldset[disabled] & { - &:hover, - &:focus { - color: @navbar-default-link-disabled-color; - } - } - } -} - -// Inverse navbar - -.navbar-inverse { - background-color: @navbar-inverse-bg; - border-color: @navbar-inverse-border; - - .navbar-brand { - color: @navbar-inverse-brand-color; - &:hover, - &:focus { - color: @navbar-inverse-brand-hover-color; - background-color: @navbar-inverse-brand-hover-bg; - } - } - - .navbar-text { - color: @navbar-inverse-color; - } - - .navbar-nav { - > li > a { - color: @navbar-inverse-link-color; - - &:hover, - &:focus { - color: @navbar-inverse-link-hover-color; - background-color: @navbar-inverse-link-hover-bg; - } - } - > .active > a { - &, - &:hover, - &:focus { - color: @navbar-inverse-link-active-color; - background-color: @navbar-inverse-link-active-bg; - } - } - > .disabled > a { - &, - &:hover, - &:focus { - color: @navbar-inverse-link-disabled-color; - background-color: @navbar-inverse-link-disabled-bg; - } - } - } - - // Darken the responsive nav toggle - .navbar-toggle { - border-color: @navbar-inverse-toggle-border-color; - &:hover, - &:focus { - background-color: @navbar-inverse-toggle-hover-bg; - } - .icon-bar { - background-color: @navbar-inverse-toggle-icon-bar-bg; - } - } - - .navbar-collapse, - .navbar-form { - border-color: darken(@navbar-inverse-bg, 7%); - } - - // Dropdowns - .navbar-nav { - > .open > a { - &, - &:hover, - &:focus { - background-color: @navbar-inverse-link-active-bg; - color: @navbar-inverse-link-active-color; - } - } - - @media (max-width: @grid-float-breakpoint-max) { - // Dropdowns get custom display - .open .dropdown-menu { - > .dropdown-header { - border-color: @navbar-inverse-border; - } - .divider { - background-color: @navbar-inverse-border; - } - > li > a { - color: @navbar-inverse-link-color; - &:hover, - &:focus { - color: @navbar-inverse-link-hover-color; - background-color: @navbar-inverse-link-hover-bg; - } - } - > .active > a { - &, - &:hover, - &:focus { - color: @navbar-inverse-link-active-color; - background-color: @navbar-inverse-link-active-bg; - } - } - > .disabled > a { - &, - &:hover, - &:focus { - color: @navbar-inverse-link-disabled-color; - background-color: @navbar-inverse-link-disabled-bg; - } - } - } - } - } - - .navbar-link { - color: @navbar-inverse-link-color; - &:hover { - color: @navbar-inverse-link-hover-color; - } - } - - .btn-link { - color: @navbar-inverse-link-color; - &:hover, - &:focus { - color: @navbar-inverse-link-hover-color; - } - &[disabled], - fieldset[disabled] & { - &:hover, - &:focus { - color: @navbar-inverse-link-disabled-color; - } - } - } -} diff --git a/src/UI/Content/Bootstrap/navs.less b/src/UI/Content/Bootstrap/navs.less deleted file mode 100644 index a3d11b136..000000000 --- a/src/UI/Content/Bootstrap/navs.less +++ /dev/null @@ -1,242 +0,0 @@ -// -// Navs -// -------------------------------------------------- - - -// Base class -// -------------------------------------------------- - -.nav { - margin-bottom: 0; - padding-left: 0; // Override default ul/ol - list-style: none; - &:extend(.clearfix all); - - > li { - position: relative; - display: block; - - > a { - position: relative; - display: block; - padding: @nav-link-padding; - &:hover, - &:focus { - text-decoration: none; - background-color: @nav-link-hover-bg; - } - } - - // Disabled state sets text to gray and nukes hover/tab effects - &.disabled > a { - color: @nav-disabled-link-color; - - &:hover, - &:focus { - color: @nav-disabled-link-hover-color; - text-decoration: none; - background-color: transparent; - cursor: @cursor-disabled; - } - } - } - - // Open dropdowns - .open > a { - &, - &:hover, - &:focus { - background-color: @nav-link-hover-bg; - border-color: @link-color; - } - } - - // Nav dividers (deprecated with v3.0.1) - // - // This should have been removed in v3 with the dropping of `.nav-list`, but - // we missed it. We don't currently support this anywhere, but in the interest - // of maintaining backward compatibility in case you use it, it's deprecated. - .nav-divider { - .nav-divider(); - } - - // Prevent IE8 from misplacing imgs - // - // See https://github.com/h5bp/html5-boilerplate/issues/984#issuecomment-3985989 - > li > a > img { - max-width: none; - } -} - - -// Tabs -// ------------------------- - -// Give the tabs something to sit on -.nav-tabs { - border-bottom: 1px solid @nav-tabs-border-color; - > li { - float: left; - // Make the list-items overlay the bottom border - margin-bottom: -1px; - - // Actual tabs (as links) - > a { - margin-right: 2px; - line-height: @line-height-base; - border: 1px solid transparent; - border-radius: @border-radius-base @border-radius-base 0 0; - &:hover { - border-color: @nav-tabs-link-hover-border-color @nav-tabs-link-hover-border-color @nav-tabs-border-color; - } - } - - // Active state, and its :hover to override normal :hover - &.active > a { - &, - &:hover, - &:focus { - color: @nav-tabs-active-link-hover-color; - background-color: @nav-tabs-active-link-hover-bg; - border: 1px solid @nav-tabs-active-link-hover-border-color; - border-bottom-color: transparent; - cursor: default; - } - } - } - // pulling this in mainly for less shorthand - &.nav-justified { - .nav-justified(); - .nav-tabs-justified(); - } -} - - -// Pills -// ------------------------- -.nav-pills { - > li { - float: left; - - // Links rendered as pills - > a { - border-radius: @nav-pills-border-radius; - } - + li { - margin-left: 2px; - } - - // Active state - &.active > a { - &, - &:hover, - &:focus { - color: @nav-pills-active-link-hover-color; - background-color: @nav-pills-active-link-hover-bg; - } - } - } -} - - -// Stacked pills -.nav-stacked { - > li { - float: none; - + li { - margin-top: 2px; - margin-left: 0; // no need for this gap between nav items - } - } -} - - -// Nav variations -// -------------------------------------------------- - -// Justified nav links -// ------------------------- - -.nav-justified { - width: 100%; - - > li { - float: none; - > a { - text-align: center; - margin-bottom: 5px; - } - } - - > .dropdown .dropdown-menu { - top: auto; - left: auto; - } - - @media (min-width: @screen-sm-min) { - > li { - display: table-cell; - width: 1%; - > a { - margin-bottom: 0; - } - } - } -} - -// Move borders to anchors instead of bottom of list -// -// Mixin for adding on top the shared `.nav-justified` styles for our tabs -.nav-tabs-justified { - border-bottom: 0; - - > li > a { - // Override margin from .nav-tabs - margin-right: 0; - border-radius: @border-radius-base; - } - - > .active > a, - > .active > a:hover, - > .active > a:focus { - border: 1px solid @nav-tabs-justified-link-border-color; - } - - @media (min-width: @screen-sm-min) { - > li > a { - border-bottom: 1px solid @nav-tabs-justified-link-border-color; - border-radius: @border-radius-base @border-radius-base 0 0; - } - > .active > a, - > .active > a:hover, - > .active > a:focus { - border-bottom-color: @nav-tabs-justified-active-link-border-color; - } - } -} - - -// Tabbable tabs -// ------------------------- - -// Hide tabbable panes to start, show them when `.active` -.tab-content { - > .tab-pane { - display: none; - } - > .active { - display: block; - } -} - - -// Dropdowns -// ------------------------- - -// Specific dropdowns -.nav-tabs .dropdown-menu { - // make dropdown border overlap tab border - margin-top: -1px; - // Remove the top rounded corners here since there is a hard edge above the menu - .border-top-radius(0); -} diff --git a/src/UI/Content/Bootstrap/normalize.less b/src/UI/Content/Bootstrap/normalize.less deleted file mode 100644 index 9dddf73ad..000000000 --- a/src/UI/Content/Bootstrap/normalize.less +++ /dev/null @@ -1,424 +0,0 @@ -/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */ - -// -// 1. Set default font family to sans-serif. -// 2. Prevent iOS and IE text size adjust after device orientation change, -// without disabling user zoom. -// - -html { - font-family: sans-serif; // 1 - -ms-text-size-adjust: 100%; // 2 - -webkit-text-size-adjust: 100%; // 2 -} - -// -// Remove default margin. -// - -body { - margin: 0; -} - -// HTML5 display definitions -// ========================================================================== - -// -// Correct `block` display not defined for any HTML5 element in IE 8/9. -// Correct `block` display not defined for `details` or `summary` in IE 10/11 -// and Firefox. -// Correct `block` display not defined for `main` in IE 11. -// - -article, -aside, -details, -figcaption, -figure, -footer, -header, -hgroup, -main, -menu, -nav, -section, -summary { - display: block; -} - -// -// 1. Correct `inline-block` display not defined in IE 8/9. -// 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. -// - -audio, -canvas, -progress, -video { - display: inline-block; // 1 - vertical-align: baseline; // 2 -} - -// -// Prevent modern browsers from displaying `audio` without controls. -// Remove excess height in iOS 5 devices. -// - -audio:not([controls]) { - display: none; - height: 0; -} - -// -// Address `[hidden]` styling not present in IE 8/9/10. -// Hide the `template` element in IE 8/9/10/11, Safari, and Firefox < 22. -// - -[hidden], -template { - display: none; -} - -// Links -// ========================================================================== - -// -// Remove the gray background color from active links in IE 10. -// - -a { - background-color: transparent; -} - -// -// Improve readability of focused elements when they are also in an -// active/hover state. -// - -a:active, -a:hover { - outline: 0; -} - -// Text-level semantics -// ========================================================================== - -// -// Address styling not present in IE 8/9/10/11, Safari, and Chrome. -// - -abbr[title] { - border-bottom: 1px dotted; -} - -// -// Address style set to `bolder` in Firefox 4+, Safari, and Chrome. -// - -b, -strong { - font-weight: bold; -} - -// -// Address styling not present in Safari and Chrome. -// - -dfn { - font-style: italic; -} - -// -// Address variable `h1` font-size and margin within `section` and `article` -// contexts in Firefox 4+, Safari, and Chrome. -// - -h1 { - font-size: 2em; - margin: 0.67em 0; -} - -// -// Address styling not present in IE 8/9. -// - -mark { - background: #ff0; - color: #000; -} - -// -// Address inconsistent and variable font size in all browsers. -// - -small { - font-size: 80%; -} - -// -// Prevent `sub` and `sup` affecting `line-height` in all browsers. -// - -sub, -sup { - font-size: 75%; - line-height: 0; - position: relative; - vertical-align: baseline; -} - -sup { - top: -0.5em; -} - -sub { - bottom: -0.25em; -} - -// Embedded content -// ========================================================================== - -// -// Remove border when inside `a` element in IE 8/9/10. -// - -img { - border: 0; -} - -// -// Correct overflow not hidden in IE 9/10/11. -// - -svg:not(:root) { - overflow: hidden; -} - -// Grouping content -// ========================================================================== - -// -// Address margin not present in IE 8/9 and Safari. -// - -figure { - margin: 1em 40px; -} - -// -// Address differences between Firefox and other browsers. -// - -hr { - box-sizing: content-box; - height: 0; -} - -// -// Contain overflow in all browsers. -// - -pre { - overflow: auto; -} - -// -// Address odd `em`-unit font size rendering in all browsers. -// - -code, -kbd, -pre, -samp { - font-family: monospace, monospace; - font-size: 1em; -} - -// Forms -// ========================================================================== - -// -// Known limitation: by default, Chrome and Safari on OS X allow very limited -// styling of `select`, unless a `border` property is set. -// - -// -// 1. Correct color not being inherited. -// Known issue: affects color of disabled elements. -// 2. Correct font properties not being inherited. -// 3. Address margins set differently in Firefox 4+, Safari, and Chrome. -// - -button, -input, -optgroup, -select, -textarea { - color: inherit; // 1 - font: inherit; // 2 - margin: 0; // 3 -} - -// -// Address `overflow` set to `hidden` in IE 8/9/10/11. -// - -button { - overflow: visible; -} - -// -// Address inconsistent `text-transform` inheritance for `button` and `select`. -// All other form control elements do not inherit `text-transform` values. -// Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. -// Correct `select` style inheritance in Firefox. -// - -button, -select { - text-transform: none; -} - -// -// 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` -// and `video` controls. -// 2. Correct inability to style clickable `input` types in iOS. -// 3. Improve usability and consistency of cursor style between image-type -// `input` and others. -// - -button, -html input[type="button"], // 1 -input[type="reset"], -input[type="submit"] { - -webkit-appearance: button; // 2 - cursor: pointer; // 3 -} - -// -// Re-set default cursor for disabled elements. -// - -button[disabled], -html input[disabled] { - cursor: default; -} - -// -// Remove inner padding and border in Firefox 4+. -// - -button::-moz-focus-inner, -input::-moz-focus-inner { - border: 0; - padding: 0; -} - -// -// Address Firefox 4+ setting `line-height` on `input` using `!important` in -// the UA stylesheet. -// - -input { - line-height: normal; -} - -// -// It's recommended that you don't attempt to style these elements. -// Firefox's implementation doesn't respect box-sizing, padding, or width. -// -// 1. Address box sizing set to `content-box` in IE 8/9/10. -// 2. Remove excess padding in IE 8/9/10. -// - -input[type="checkbox"], -input[type="radio"] { - box-sizing: border-box; // 1 - padding: 0; // 2 -} - -// -// Fix the cursor style for Chrome's increment/decrement buttons. For certain -// `font-size` values of the `input`, it causes the cursor style of the -// decrement button to change from `default` to `text`. -// - -input[type="number"]::-webkit-inner-spin-button, -input[type="number"]::-webkit-outer-spin-button { - height: auto; -} - -// -// 1. Address `appearance` set to `searchfield` in Safari and Chrome. -// 2. Address `box-sizing` set to `border-box` in Safari and Chrome. -// - -input[type="search"] { - -webkit-appearance: textfield; // 1 - box-sizing: content-box; //2 -} - -// -// Remove inner padding and search cancel button in Safari and Chrome on OS X. -// Safari (but not Chrome) clips the cancel button when the search input has -// padding (and `textfield` appearance). -// - -input[type="search"]::-webkit-search-cancel-button, -input[type="search"]::-webkit-search-decoration { - -webkit-appearance: none; -} - -// -// Define consistent border, margin, and padding. -// - -fieldset { - border: 1px solid #c0c0c0; - margin: 0 2px; - padding: 0.35em 0.625em 0.75em; -} - -// -// 1. Correct `color` not being inherited in IE 8/9/10/11. -// 2. Remove padding so people aren't caught out if they zero out fieldsets. -// - -legend { - border: 0; // 1 - padding: 0; // 2 -} - -// -// Remove default vertical scrollbar in IE 8/9/10/11. -// - -textarea { - overflow: auto; -} - -// -// Don't inherit the `font-weight` (applied by a rule above). -// NOTE: the default cannot safely be changed in Chrome and Safari on OS X. -// - -optgroup { - font-weight: bold; -} - -// Tables -// ========================================================================== - -// -// Remove most spacing between table cells. -// - -table { - border-collapse: collapse; - border-spacing: 0; -} - -td, -th { - padding: 0; -} diff --git a/src/UI/Content/Bootstrap/pager.less b/src/UI/Content/Bootstrap/pager.less deleted file mode 100644 index 41abaaadc..000000000 --- a/src/UI/Content/Bootstrap/pager.less +++ /dev/null @@ -1,54 +0,0 @@ -// -// Pager pagination -// -------------------------------------------------- - - -.pager { - padding-left: 0; - margin: @line-height-computed 0; - list-style: none; - text-align: center; - &:extend(.clearfix all); - li { - display: inline; - > a, - > span { - display: inline-block; - padding: 5px 14px; - background-color: @pager-bg; - border: 1px solid @pager-border; - border-radius: @pager-border-radius; - } - - > a:hover, - > a:focus { - text-decoration: none; - background-color: @pager-hover-bg; - } - } - - .next { - > a, - > span { - float: right; - } - } - - .previous { - > a, - > span { - float: left; - } - } - - .disabled { - > a, - > a:hover, - > a:focus, - > span { - color: @pager-disabled-color; - background-color: @pager-bg; - cursor: @cursor-disabled; - } - } -} diff --git a/src/UI/Content/Bootstrap/pagination.less b/src/UI/Content/Bootstrap/pagination.less deleted file mode 100644 index 31a23bf79..000000000 --- a/src/UI/Content/Bootstrap/pagination.less +++ /dev/null @@ -1,89 +0,0 @@ -// -// Pagination (multiple pages) -// -------------------------------------------------- -.pagination { - display: inline-block; - padding-left: 0; - margin: @line-height-computed 0; - border-radius: @border-radius-base; - - > li { - display: inline; // Remove list-style and block-level defaults - > a, - > span { - position: relative; - float: left; // Collapse white-space - padding: @padding-base-vertical @padding-base-horizontal; - line-height: @line-height-base; - text-decoration: none; - color: @pagination-color; - background-color: @pagination-bg; - border: 1px solid @pagination-border; - margin-left: -1px; - } - &:first-child { - > a, - > span { - margin-left: 0; - .border-left-radius(@border-radius-base); - } - } - &:last-child { - > a, - > span { - .border-right-radius(@border-radius-base); - } - } - } - - > li > a, - > li > span { - &:hover, - &:focus { - z-index: 3; - color: @pagination-hover-color; - background-color: @pagination-hover-bg; - border-color: @pagination-hover-border; - } - } - - > .active > a, - > .active > span { - &, - &:hover, - &:focus { - z-index: 2; - color: @pagination-active-color; - background-color: @pagination-active-bg; - border-color: @pagination-active-border; - cursor: default; - } - } - - > .disabled { - > span, - > span:hover, - > span:focus, - > a, - > a:hover, - > a:focus { - color: @pagination-disabled-color; - background-color: @pagination-disabled-bg; - border-color: @pagination-disabled-border; - cursor: @cursor-disabled; - } - } -} - -// Sizing -// -------------------------------------------------- - -// Large -.pagination-lg { - .pagination-size(@padding-large-vertical; @padding-large-horizontal; @font-size-large; @line-height-large; @border-radius-large); -} - -// Small -.pagination-sm { - .pagination-size(@padding-small-vertical; @padding-small-horizontal; @font-size-small; @line-height-small; @border-radius-small); -} diff --git a/src/UI/Content/Bootstrap/panels.less b/src/UI/Content/Bootstrap/panels.less deleted file mode 100644 index 425eb5e64..000000000 --- a/src/UI/Content/Bootstrap/panels.less +++ /dev/null @@ -1,271 +0,0 @@ -// -// Panels -// -------------------------------------------------- - - -// Base class -.panel { - margin-bottom: @line-height-computed; - background-color: @panel-bg; - border: 1px solid transparent; - border-radius: @panel-border-radius; - .box-shadow(0 1px 1px rgba(0,0,0,.05)); -} - -// Panel contents -.panel-body { - padding: @panel-body-padding; - &:extend(.clearfix all); -} - -// Optional heading -.panel-heading { - padding: @panel-heading-padding; - border-bottom: 1px solid transparent; - .border-top-radius((@panel-border-radius - 1)); - - > .dropdown .dropdown-toggle { - color: inherit; - } -} - -// Within heading, strip any `h*` tag of its default margins for spacing. -.panel-title { - margin-top: 0; - margin-bottom: 0; - font-size: ceil((@font-size-base * 1.125)); - color: inherit; - - > a, - > small, - > .small, - > small > a, - > .small > a { - color: inherit; - } -} - -// Optional footer (stays gray in every modifier class) -.panel-footer { - padding: @panel-footer-padding; - background-color: @panel-footer-bg; - border-top: 1px solid @panel-inner-border; - .border-bottom-radius((@panel-border-radius - 1)); -} - - -// List groups in panels -// -// By default, space out list group content from panel headings to account for -// any kind of custom content between the two. - -.panel { - > .list-group, - > .panel-collapse > .list-group { - margin-bottom: 0; - - .list-group-item { - border-width: 1px 0; - border-radius: 0; - } - - // Add border top radius for first one - &:first-child { - .list-group-item:first-child { - border-top: 0; - .border-top-radius((@panel-border-radius - 1)); - } - } - - // Add border bottom radius for last one - &:last-child { - .list-group-item:last-child { - border-bottom: 0; - .border-bottom-radius((@panel-border-radius - 1)); - } - } - } - > .panel-heading + .panel-collapse > .list-group { - .list-group-item:first-child { - .border-top-radius(0); - } - } -} -// Collapse space between when there's no additional content. -.panel-heading + .list-group { - .list-group-item:first-child { - border-top-width: 0; - } -} -.list-group + .panel-footer { - border-top-width: 0; -} - -// Tables in panels -// -// Place a non-bordered `.table` within a panel (not within a `.panel-body`) and -// watch it go full width. - -.panel { - > .table, - > .table-responsive > .table, - > .panel-collapse > .table { - margin-bottom: 0; - - caption { - padding-left: @panel-body-padding; - padding-right: @panel-body-padding; - } - } - // Add border top radius for first one - > .table:first-child, - > .table-responsive:first-child > .table:first-child { - .border-top-radius((@panel-border-radius - 1)); - - > thead:first-child, - > tbody:first-child { - > tr:first-child { - border-top-left-radius: (@panel-border-radius - 1); - border-top-right-radius: (@panel-border-radius - 1); - - td:first-child, - th:first-child { - border-top-left-radius: (@panel-border-radius - 1); - } - td:last-child, - th:last-child { - border-top-right-radius: (@panel-border-radius - 1); - } - } - } - } - // Add border bottom radius for last one - > .table:last-child, - > .table-responsive:last-child > .table:last-child { - .border-bottom-radius((@panel-border-radius - 1)); - - > tbody:last-child, - > tfoot:last-child { - > tr:last-child { - border-bottom-left-radius: (@panel-border-radius - 1); - border-bottom-right-radius: (@panel-border-radius - 1); - - td:first-child, - th:first-child { - border-bottom-left-radius: (@panel-border-radius - 1); - } - td:last-child, - th:last-child { - border-bottom-right-radius: (@panel-border-radius - 1); - } - } - } - } - > .panel-body + .table, - > .panel-body + .table-responsive, - > .table + .panel-body, - > .table-responsive + .panel-body { - border-top: 1px solid @table-border-color; - } - > .table > tbody:first-child > tr:first-child th, - > .table > tbody:first-child > tr:first-child td { - border-top: 0; - } - > .table-bordered, - > .table-responsive > .table-bordered { - border: 0; - > thead, - > tbody, - > tfoot { - > tr { - > th:first-child, - > td:first-child { - border-left: 0; - } - > th:last-child, - > td:last-child { - border-right: 0; - } - } - } - > thead, - > tbody { - > tr:first-child { - > td, - > th { - border-bottom: 0; - } - } - } - > tbody, - > tfoot { - > tr:last-child { - > td, - > th { - border-bottom: 0; - } - } - } - } - > .table-responsive { - border: 0; - margin-bottom: 0; - } -} - - -// Collapsable panels (aka, accordion) -// -// Wrap a series of panels in `.panel-group` to turn them into an accordion with -// the help of our collapse JavaScript plugin. - -.panel-group { - margin-bottom: @line-height-computed; - - // Tighten up margin so it's only between panels - .panel { - margin-bottom: 0; - border-radius: @panel-border-radius; - - + .panel { - margin-top: 5px; - } - } - - .panel-heading { - border-bottom: 0; - - + .panel-collapse > .panel-body, - + .panel-collapse > .list-group { - border-top: 1px solid @panel-inner-border; - } - } - - .panel-footer { - border-top: 0; - + .panel-collapse .panel-body { - border-bottom: 1px solid @panel-inner-border; - } - } -} - - -// Contextual variations -.panel-default { - .panel-variant(@panel-default-border; @panel-default-text; @panel-default-heading-bg; @panel-default-border); -} -.panel-primary { - .panel-variant(@panel-primary-border; @panel-primary-text; @panel-primary-heading-bg; @panel-primary-border); -} -.panel-success { - .panel-variant(@panel-success-border; @panel-success-text; @panel-success-heading-bg; @panel-success-border); -} -.panel-info { - .panel-variant(@panel-info-border; @panel-info-text; @panel-info-heading-bg; @panel-info-border); -} -.panel-warning { - .panel-variant(@panel-warning-border; @panel-warning-text; @panel-warning-heading-bg; @panel-warning-border); -} -.panel-danger { - .panel-variant(@panel-danger-border; @panel-danger-text; @panel-danger-heading-bg; @panel-danger-border); -} diff --git a/src/UI/Content/Bootstrap/popovers.less b/src/UI/Content/Bootstrap/popovers.less deleted file mode 100644 index 3a62a6455..000000000 --- a/src/UI/Content/Bootstrap/popovers.less +++ /dev/null @@ -1,131 +0,0 @@ -// -// Popovers -// -------------------------------------------------- - - -.popover { - position: absolute; - top: 0; - left: 0; - z-index: @zindex-popover; - display: none; - max-width: @popover-max-width; - padding: 1px; - // Our parent element can be arbitrary since popovers are by default inserted as a sibling of their target element. - // So reset our font and text properties to avoid inheriting weird values. - .reset-text(); - font-size: @font-size-base; - - background-color: @popover-bg; - background-clip: padding-box; - border: 1px solid @popover-fallback-border-color; - border: 1px solid @popover-border-color; - border-radius: @border-radius-large; - .box-shadow(0 5px 10px rgba(0,0,0,.2)); - - // Offset the popover to account for the popover arrow - &.top { margin-top: -@popover-arrow-width; } - &.right { margin-left: @popover-arrow-width; } - &.bottom { margin-top: @popover-arrow-width; } - &.left { margin-left: -@popover-arrow-width; } -} - -.popover-title { - margin: 0; // reset heading margin - padding: 8px 14px; - font-size: @font-size-base; - background-color: @popover-title-bg; - border-bottom: 1px solid darken(@popover-title-bg, 5%); - border-radius: (@border-radius-large - 1) (@border-radius-large - 1) 0 0; -} - -.popover-content { - padding: 9px 14px; -} - -// Arrows -// -// .arrow is outer, .arrow:after is inner - -.popover > .arrow { - &, - &:after { - position: absolute; - display: block; - width: 0; - height: 0; - border-color: transparent; - border-style: solid; - } -} -.popover > .arrow { - border-width: @popover-arrow-outer-width; -} -.popover > .arrow:after { - border-width: @popover-arrow-width; - content: ""; -} - -.popover { - &.top > .arrow { - left: 50%; - margin-left: -@popover-arrow-outer-width; - border-bottom-width: 0; - border-top-color: @popover-arrow-outer-fallback-color; // IE8 fallback - border-top-color: @popover-arrow-outer-color; - bottom: -@popover-arrow-outer-width; - &:after { - content: " "; - bottom: 1px; - margin-left: -@popover-arrow-width; - border-bottom-width: 0; - border-top-color: @popover-arrow-color; - } - } - &.right > .arrow { - top: 50%; - left: -@popover-arrow-outer-width; - margin-top: -@popover-arrow-outer-width; - border-left-width: 0; - border-right-color: @popover-arrow-outer-fallback-color; // IE8 fallback - border-right-color: @popover-arrow-outer-color; - &:after { - content: " "; - left: 1px; - bottom: -@popover-arrow-width; - border-left-width: 0; - border-right-color: @popover-arrow-color; - } - } - &.bottom > .arrow { - left: 50%; - margin-left: -@popover-arrow-outer-width; - border-top-width: 0; - border-bottom-color: @popover-arrow-outer-fallback-color; // IE8 fallback - border-bottom-color: @popover-arrow-outer-color; - top: -@popover-arrow-outer-width; - &:after { - content: " "; - top: 1px; - margin-left: -@popover-arrow-width; - border-top-width: 0; - border-bottom-color: @popover-arrow-color; - } - } - - &.left > .arrow { - top: 50%; - right: -@popover-arrow-outer-width; - margin-top: -@popover-arrow-outer-width; - border-right-width: 0; - border-left-color: @popover-arrow-outer-fallback-color; // IE8 fallback - border-left-color: @popover-arrow-outer-color; - &:after { - content: " "; - right: 1px; - border-right-width: 0; - border-left-color: @popover-arrow-color; - bottom: -@popover-arrow-width; - } - } -} diff --git a/src/UI/Content/Bootstrap/print.less b/src/UI/Content/Bootstrap/print.less deleted file mode 100644 index 66e54ab48..000000000 --- a/src/UI/Content/Bootstrap/print.less +++ /dev/null @@ -1,101 +0,0 @@ -/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */ - -// ========================================================================== -// Print styles. -// Inlined to avoid the additional HTTP request: h5bp.com/r -// ========================================================================== - -@media print { - *, - *:before, - *:after { - background: transparent !important; - color: #000 !important; // Black prints faster: h5bp.com/s - box-shadow: none !important; - text-shadow: none !important; - } - - a, - a:visited { - text-decoration: underline; - } - - a[href]:after { - content: " (" attr(href) ")"; - } - - abbr[title]:after { - content: " (" attr(title) ")"; - } - - // Don't show links that are fragment identifiers, - // or use the `javascript:` pseudo protocol - a[href^="#"]:after, - a[href^="javascript:"]:after { - content: ""; - } - - pre, - blockquote { - border: 1px solid #999; - page-break-inside: avoid; - } - - thead { - display: table-header-group; // h5bp.com/t - } - - tr, - img { - page-break-inside: avoid; - } - - img { - max-width: 100% !important; - } - - p, - h2, - h3 { - orphans: 3; - widows: 3; - } - - h2, - h3 { - page-break-after: avoid; - } - - // Bootstrap specific changes start - - // Bootstrap components - .navbar { - display: none; - } - .btn, - .dropup > .btn { - > .caret { - border-top-color: #000 !important; - } - } - .label { - border: 1px solid #000; - } - - .table { - border-collapse: collapse !important; - - td, - th { - background-color: #fff !important; - } - } - .table-bordered { - th, - td { - border: 1px solid #ddd !important; - } - } - - // Bootstrap specific changes end -} diff --git a/src/UI/Content/Bootstrap/progress-bars.less b/src/UI/Content/Bootstrap/progress-bars.less deleted file mode 100644 index 8868a1fee..000000000 --- a/src/UI/Content/Bootstrap/progress-bars.less +++ /dev/null @@ -1,87 +0,0 @@ -// -// Progress bars -// -------------------------------------------------- - - -// Bar animations -// ------------------------- - -// WebKit -@-webkit-keyframes progress-bar-stripes { - from { background-position: 40px 0; } - to { background-position: 0 0; } -} - -// Spec and IE10+ -@keyframes progress-bar-stripes { - from { background-position: 40px 0; } - to { background-position: 0 0; } -} - - -// Bar itself -// ------------------------- - -// Outer container -.progress { - overflow: hidden; - height: @line-height-computed; - margin-bottom: @line-height-computed; - background-color: @progress-bg; - border-radius: @progress-border-radius; - .box-shadow(inset 0 1px 2px rgba(0,0,0,.1)); -} - -// Bar of progress -.progress-bar { - float: left; - width: 0%; - height: 100%; - font-size: @font-size-small; - line-height: @line-height-computed; - color: @progress-bar-color; - text-align: center; - background-color: @progress-bar-bg; - .box-shadow(inset 0 -1px 0 rgba(0,0,0,.15)); - .transition(width .6s ease); -} - -// Striped bars -// -// `.progress-striped .progress-bar` is deprecated as of v3.2.0 in favor of the -// `.progress-bar-striped` class, which you just add to an existing -// `.progress-bar`. -.progress-striped .progress-bar, -.progress-bar-striped { - #gradient > .striped(); - background-size: 40px 40px; -} - -// Call animation for the active one -// -// `.progress.active .progress-bar` is deprecated as of v3.2.0 in favor of the -// `.progress-bar.active` approach. -.progress.active .progress-bar, -.progress-bar.active { - .animation(progress-bar-stripes 2s linear infinite); -} - - -// Variations -// ------------------------- - -.progress-bar-success { - .progress-bar-variant(@progress-bar-success-bg); -} - -.progress-bar-info { - .progress-bar-variant(@progress-bar-info-bg); -} - -.progress-bar-warning { - .progress-bar-variant(@progress-bar-warning-bg); -} - -.progress-bar-danger { - .progress-bar-variant(@progress-bar-danger-bg); -} diff --git a/src/UI/Content/Bootstrap/responsive-embed.less b/src/UI/Content/Bootstrap/responsive-embed.less deleted file mode 100644 index 080a5118f..000000000 --- a/src/UI/Content/Bootstrap/responsive-embed.less +++ /dev/null @@ -1,35 +0,0 @@ -// Embeds responsive -// -// Credit: Nicolas Gallagher and SUIT CSS. - -.embed-responsive { - position: relative; - display: block; - height: 0; - padding: 0; - overflow: hidden; - - .embed-responsive-item, - iframe, - embed, - object, - video { - position: absolute; - top: 0; - left: 0; - bottom: 0; - height: 100%; - width: 100%; - border: 0; - } -} - -// Modifier class for 16:9 aspect ratio -.embed-responsive-16by9 { - padding-bottom: 56.25%; -} - -// Modifier class for 4:3 aspect ratio -.embed-responsive-4by3 { - padding-bottom: 75%; -} diff --git a/src/UI/Content/Bootstrap/responsive-utilities.less b/src/UI/Content/Bootstrap/responsive-utilities.less deleted file mode 100644 index b1db31d7b..000000000 --- a/src/UI/Content/Bootstrap/responsive-utilities.less +++ /dev/null @@ -1,194 +0,0 @@ -// -// Responsive: Utility classes -// -------------------------------------------------- - - -// IE10 in Windows (Phone) 8 -// -// Support for responsive views via media queries is kind of borked in IE10, for -// Surface/desktop in split view and for Windows Phone 8. This particular fix -// must be accompanied by a snippet of JavaScript to sniff the user agent and -// apply some conditional CSS to *only* the Surface/desktop Windows 8. Look at -// our Getting Started page for more information on this bug. -// -// For more information, see the following: -// -// Issue: https://github.com/twbs/bootstrap/issues/10497 -// Docs: http://getbootstrap.com/getting-started/#support-ie10-width -// Source: http://timkadlec.com/2013/01/windows-phone-8-and-device-width/ -// Source: http://timkadlec.com/2012/10/ie10-snap-mode-and-responsive-design/ - -@-ms-viewport { - width: device-width; -} - - -// Visibility utilities -// Note: Deprecated .visible-xs, .visible-sm, .visible-md, and .visible-lg as of v3.2.0 -.visible-xs, -.visible-sm, -.visible-md, -.visible-lg { - .responsive-invisibility(); -} - -.visible-xs-block, -.visible-xs-inline, -.visible-xs-inline-block, -.visible-sm-block, -.visible-sm-inline, -.visible-sm-inline-block, -.visible-md-block, -.visible-md-inline, -.visible-md-inline-block, -.visible-lg-block, -.visible-lg-inline, -.visible-lg-inline-block { - display: none !important; -} - -.visible-xs { - @media (max-width: @screen-xs-max) { - .responsive-visibility(); - } -} -.visible-xs-block { - @media (max-width: @screen-xs-max) { - display: block !important; - } -} -.visible-xs-inline { - @media (max-width: @screen-xs-max) { - display: inline !important; - } -} -.visible-xs-inline-block { - @media (max-width: @screen-xs-max) { - display: inline-block !important; - } -} - -.visible-sm { - @media (min-width: @screen-sm-min) and (max-width: @screen-sm-max) { - .responsive-visibility(); - } -} -.visible-sm-block { - @media (min-width: @screen-sm-min) and (max-width: @screen-sm-max) { - display: block !important; - } -} -.visible-sm-inline { - @media (min-width: @screen-sm-min) and (max-width: @screen-sm-max) { - display: inline !important; - } -} -.visible-sm-inline-block { - @media (min-width: @screen-sm-min) and (max-width: @screen-sm-max) { - display: inline-block !important; - } -} - -.visible-md { - @media (min-width: @screen-md-min) and (max-width: @screen-md-max) { - .responsive-visibility(); - } -} -.visible-md-block { - @media (min-width: @screen-md-min) and (max-width: @screen-md-max) { - display: block !important; - } -} -.visible-md-inline { - @media (min-width: @screen-md-min) and (max-width: @screen-md-max) { - display: inline !important; - } -} -.visible-md-inline-block { - @media (min-width: @screen-md-min) and (max-width: @screen-md-max) { - display: inline-block !important; - } -} - -.visible-lg { - @media (min-width: @screen-lg-min) { - .responsive-visibility(); - } -} -.visible-lg-block { - @media (min-width: @screen-lg-min) { - display: block !important; - } -} -.visible-lg-inline { - @media (min-width: @screen-lg-min) { - display: inline !important; - } -} -.visible-lg-inline-block { - @media (min-width: @screen-lg-min) { - display: inline-block !important; - } -} - -.hidden-xs { - @media (max-width: @screen-xs-max) { - .responsive-invisibility(); - } -} -.hidden-sm { - @media (min-width: @screen-sm-min) and (max-width: @screen-sm-max) { - .responsive-invisibility(); - } -} -.hidden-md { - @media (min-width: @screen-md-min) and (max-width: @screen-md-max) { - .responsive-invisibility(); - } -} -.hidden-lg { - @media (min-width: @screen-lg-min) { - .responsive-invisibility(); - } -} - - -// Print utilities -// -// Media queries are placed on the inside to be mixin-friendly. - -// Note: Deprecated .visible-print as of v3.2.0 -.visible-print { - .responsive-invisibility(); - - @media print { - .responsive-visibility(); - } -} -.visible-print-block { - display: none !important; - - @media print { - display: block !important; - } -} -.visible-print-inline { - display: none !important; - - @media print { - display: inline !important; - } -} -.visible-print-inline-block { - display: none !important; - - @media print { - display: inline-block !important; - } -} - -.hidden-print { - @media print { - .responsive-invisibility(); - } -} diff --git a/src/UI/Content/Bootstrap/scaffolding.less b/src/UI/Content/Bootstrap/scaffolding.less deleted file mode 100644 index 1929bfc5c..000000000 --- a/src/UI/Content/Bootstrap/scaffolding.less +++ /dev/null @@ -1,161 +0,0 @@ -// -// Scaffolding -// -------------------------------------------------- - - -// Reset the box-sizing -// -// Heads up! This reset may cause conflicts with some third-party widgets. -// For recommendations on resolving such conflicts, see -// http://getbootstrap.com/getting-started/#third-box-sizing -* { - .box-sizing(border-box); -} -*:before, -*:after { - .box-sizing(border-box); -} - - -// Body reset - -html { - font-size: 10px; - -webkit-tap-highlight-color: rgba(0,0,0,0); -} - -body { - font-family: @font-family-base; - font-size: @font-size-base; - line-height: @line-height-base; - color: @text-color; - background-color: @body-bg; -} - -// Reset fonts for relevant elements -input, -button, -select, -textarea { - font-family: inherit; - font-size: inherit; - line-height: inherit; -} - - -// Links - -a { - color: @link-color; - text-decoration: none; - - &:hover, - &:focus { - color: @link-hover-color; - text-decoration: @link-hover-decoration; - } - - &:focus { - .tab-focus(); - } -} - - -// Figures -// -// We reset this here because previously Normalize had no `figure` margins. This -// ensures we don't break anyone's use of the element. - -figure { - margin: 0; -} - - -// Images - -img { - vertical-align: middle; -} - -// Responsive images (ensure images don't scale beyond their parents) -.img-responsive { - .img-responsive(); -} - -// Rounded corners -.img-rounded { - border-radius: @border-radius-large; -} - -// Image thumbnails -// -// Heads up! This is mixin-ed into thumbnails.less for `.thumbnail`. -.img-thumbnail { - padding: @thumbnail-padding; - line-height: @line-height-base; - background-color: @thumbnail-bg; - border: 1px solid @thumbnail-border; - border-radius: @thumbnail-border-radius; - .transition(all .2s ease-in-out); - - // Keep them at most 100% wide - .img-responsive(inline-block); -} - -// Perfect circle -.img-circle { - border-radius: 50%; // set radius in percents -} - - -// Horizontal rules - -hr { - margin-top: @line-height-computed; - margin-bottom: @line-height-computed; - border: 0; - border-top: 1px solid @hr-border; -} - - -// Only display content to screen readers -// -// See: http://a11yproject.com/posts/how-to-hide-content/ - -.sr-only { - position: absolute; - width: 1px; - height: 1px; - margin: -1px; - padding: 0; - overflow: hidden; - clip: rect(0,0,0,0); - border: 0; -} - -// Use in conjunction with .sr-only to only display content when it's focused. -// Useful for "Skip to main content" links; see http://www.w3.org/TR/2013/NOTE-WCAG20-TECHS-20130905/G1 -// Credit: HTML5 Boilerplate - -.sr-only-focusable { - &:active, - &:focus { - position: static; - width: auto; - height: auto; - margin: 0; - overflow: visible; - clip: auto; - } -} - - -// iOS "clickable elements" fix for role="button" -// -// Fixes "clickability" issue (and more generally, the firing of events such as focus as well) -// for traditionally non-focusable elements with role="button" -// see https://developer.mozilla.org/en-US/docs/Web/Events/click#Safari_Mobile - -[role="button"] { - cursor: pointer; -} diff --git a/src/UI/Content/Bootstrap/tables.less b/src/UI/Content/Bootstrap/tables.less deleted file mode 100644 index 2242c0368..000000000 --- a/src/UI/Content/Bootstrap/tables.less +++ /dev/null @@ -1,234 +0,0 @@ -// -// Tables -// -------------------------------------------------- - - -table { - background-color: @table-bg; -} -caption { - padding-top: @table-cell-padding; - padding-bottom: @table-cell-padding; - color: @text-muted; - text-align: left; -} -th { - text-align: left; -} - - -// Baseline styles - -.table { - width: 100%; - max-width: 100%; - margin-bottom: @line-height-computed; - // Cells - > thead, - > tbody, - > tfoot { - > tr { - > th, - > td { - padding: @table-cell-padding; - line-height: @line-height-base; - vertical-align: top; - border-top: 1px solid @table-border-color; - } - } - } - // Bottom align for column headings - > thead > tr > th { - vertical-align: bottom; - border-bottom: 2px solid @table-border-color; - } - // Remove top border from thead by default - > caption + thead, - > colgroup + thead, - > thead:first-child { - > tr:first-child { - > th, - > td { - border-top: 0; - } - } - } - // Account for multiple tbody instances - > tbody + tbody { - border-top: 2px solid @table-border-color; - } - - // Nesting - .table { - background-color: @body-bg; - } -} - - -// Condensed table w/ half padding - -.table-condensed { - > thead, - > tbody, - > tfoot { - > tr { - > th, - > td { - padding: @table-condensed-cell-padding; - } - } - } -} - - -// Bordered version -// -// Add borders all around the table and between all the columns. - -.table-bordered { - border: 1px solid @table-border-color; - > thead, - > tbody, - > tfoot { - > tr { - > th, - > td { - border: 1px solid @table-border-color; - } - } - } - > thead > tr { - > th, - > td { - border-bottom-width: 2px; - } - } -} - - -// Zebra-striping -// -// Default zebra-stripe styles (alternating gray and transparent backgrounds) - -.table-striped { - > tbody > tr:nth-of-type(odd) { - background-color: @table-bg-accent; - } -} - - -// Hover effect -// -// Placed here since it has to come after the potential zebra striping - -.table-hover { - > tbody > tr:hover { - background-color: @table-bg-hover; - } -} - - -// Table cell sizing -// -// Reset default table behavior - -table col[class*="col-"] { - position: static; // Prevent border hiding in Firefox and IE9-11 (see https://github.com/twbs/bootstrap/issues/11623) - float: none; - display: table-column; -} -table { - td, - th { - &[class*="col-"] { - position: static; // Prevent border hiding in Firefox and IE9-11 (see https://github.com/twbs/bootstrap/issues/11623) - float: none; - display: table-cell; - } - } -} - - -// Table backgrounds -// -// Exact selectors below required to override `.table-striped` and prevent -// inheritance to nested tables. - -// Generate the contextual variants -.table-row-variant(active; @table-bg-active); -.table-row-variant(success; @state-success-bg); -.table-row-variant(info; @state-info-bg); -.table-row-variant(warning; @state-warning-bg); -.table-row-variant(danger; @state-danger-bg); - - -// Responsive tables -// -// Wrap your tables in `.table-responsive` and we'll make them mobile friendly -// by enabling horizontal scrolling. Only applies <768px. Everything above that -// will display normally. - -.table-responsive { - overflow-x: auto; - min-height: 0.01%; // Workaround for IE9 bug (see https://github.com/twbs/bootstrap/issues/14837) - - @media screen and (max-width: @screen-xs-max) { - width: 100%; - margin-bottom: (@line-height-computed * 0.75); - overflow-y: hidden; - -ms-overflow-style: -ms-autohiding-scrollbar; - border: 1px solid @table-border-color; - - // Tighten up spacing - > .table { - margin-bottom: 0; - - // Ensure the content doesn't wrap - > thead, - > tbody, - > tfoot { - > tr { - > th, - > td { - white-space: nowrap; - } - } - } - } - - // Special overrides for the bordered tables - > .table-bordered { - border: 0; - - // Nuke the appropriate borders so that the parent can handle them - > thead, - > tbody, - > tfoot { - > tr { - > th:first-child, - > td:first-child { - border-left: 0; - } - > th:last-child, - > td:last-child { - border-right: 0; - } - } - } - - // Only nuke the last row's bottom-border in `tbody` and `tfoot` since - // chances are there will be only one `tr` in a `thead` and that would - // remove the border altogether. - > tbody, - > tfoot { - > tr:last-child { - > th, - > td { - border-bottom: 0; - } - } - } - - } - } -} diff --git a/src/UI/Content/Bootstrap/theme.less b/src/UI/Content/Bootstrap/theme.less deleted file mode 100644 index 8371872b0..000000000 --- a/src/UI/Content/Bootstrap/theme.less +++ /dev/null @@ -1,291 +0,0 @@ -/*! - * Bootstrap v3.3.5 (http://getbootstrap.com) - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - */ - -// -// Load core variables and mixins -// -------------------------------------------------- - -@import "variables.less"; -@import "mixins.less"; - - -// -// Buttons -// -------------------------------------------------- - -// Common styles -.btn-default, -.btn-primary, -.btn-success, -.btn-info, -.btn-warning, -.btn-danger { - text-shadow: 0 -1px 0 rgba(0,0,0,.2); - @shadow: inset 0 1px 0 rgba(255,255,255,.15), 0 1px 1px rgba(0,0,0,.075); - .box-shadow(@shadow); - - // Reset the shadow - &:active, - &.active { - .box-shadow(inset 0 3px 5px rgba(0,0,0,.125)); - } - - &.disabled, - &[disabled], - fieldset[disabled] & { - .box-shadow(none); - } - - .badge { - text-shadow: none; - } -} - -// Mixin for generating new styles -.btn-styles(@btn-color: #555) { - #gradient > .vertical(@start-color: @btn-color; @end-color: darken(@btn-color, 12%)); - .reset-filter(); // Disable gradients for IE9 because filter bleeds through rounded corners; see https://github.com/twbs/bootstrap/issues/10620 - background-repeat: repeat-x; - border-color: darken(@btn-color, 14%); - - &:hover, - &:focus { - background-color: darken(@btn-color, 12%); - background-position: 0 -15px; - } - - &:active, - &.active { - background-color: darken(@btn-color, 12%); - border-color: darken(@btn-color, 14%); - } - - &.disabled, - &[disabled], - fieldset[disabled] & { - &, - &:hover, - &:focus, - &.focus, - &:active, - &.active { - background-color: darken(@btn-color, 12%); - background-image: none; - } - } -} - -// Common styles -.btn { - // Remove the gradient for the pressed/active state - &:active, - &.active { - background-image: none; - } -} - -// Apply the mixin to the buttons -.btn-default { .btn-styles(@btn-default-bg); text-shadow: 0 1px 0 #fff; border-color: #ccc; } -.btn-primary { .btn-styles(@btn-primary-bg); } -.btn-success { .btn-styles(@btn-success-bg); } -.btn-info { .btn-styles(@btn-info-bg); } -.btn-warning { .btn-styles(@btn-warning-bg); } -.btn-danger { .btn-styles(@btn-danger-bg); } - - -// -// Images -// -------------------------------------------------- - -.thumbnail, -.img-thumbnail { - .box-shadow(0 1px 2px rgba(0,0,0,.075)); -} - - -// -// Dropdowns -// -------------------------------------------------- - -.dropdown-menu > li > a:hover, -.dropdown-menu > li > a:focus { - #gradient > .vertical(@start-color: @dropdown-link-hover-bg; @end-color: darken(@dropdown-link-hover-bg, 5%)); - background-color: darken(@dropdown-link-hover-bg, 5%); -} -.dropdown-menu > .active > a, -.dropdown-menu > .active > a:hover, -.dropdown-menu > .active > a:focus { - #gradient > .vertical(@start-color: @dropdown-link-active-bg; @end-color: darken(@dropdown-link-active-bg, 5%)); - background-color: darken(@dropdown-link-active-bg, 5%); -} - - -// -// Navbar -// -------------------------------------------------- - -// Default navbar -.navbar-default { - #gradient > .vertical(@start-color: lighten(@navbar-default-bg, 10%); @end-color: @navbar-default-bg); - .reset-filter(); // Remove gradient in IE<10 to fix bug where dropdowns don't get triggered - border-radius: @navbar-border-radius; - @shadow: inset 0 1px 0 rgba(255,255,255,.15), 0 1px 5px rgba(0,0,0,.075); - .box-shadow(@shadow); - - .navbar-nav > .open > a, - .navbar-nav > .active > a { - #gradient > .vertical(@start-color: darken(@navbar-default-link-active-bg, 5%); @end-color: darken(@navbar-default-link-active-bg, 2%)); - .box-shadow(inset 0 3px 9px rgba(0,0,0,.075)); - } -} -.navbar-brand, -.navbar-nav > li > a { - text-shadow: 0 1px 0 rgba(255,255,255,.25); -} - -// Inverted navbar -.navbar-inverse { - #gradient > .vertical(@start-color: lighten(@navbar-inverse-bg, 10%); @end-color: @navbar-inverse-bg); - .reset-filter(); // Remove gradient in IE<10 to fix bug where dropdowns don't get triggered; see https://github.com/twbs/bootstrap/issues/10257 - border-radius: @navbar-border-radius; - .navbar-nav > .open > a, - .navbar-nav > .active > a { - #gradient > .vertical(@start-color: @navbar-inverse-link-active-bg; @end-color: lighten(@navbar-inverse-link-active-bg, 2.5%)); - .box-shadow(inset 0 3px 9px rgba(0,0,0,.25)); - } - - .navbar-brand, - .navbar-nav > li > a { - text-shadow: 0 -1px 0 rgba(0,0,0,.25); - } -} - -// Undo rounded corners in static and fixed navbars -.navbar-static-top, -.navbar-fixed-top, -.navbar-fixed-bottom { - border-radius: 0; -} - -// Fix active state of dropdown items in collapsed mode -@media (max-width: @grid-float-breakpoint-max) { - .navbar .navbar-nav .open .dropdown-menu > .active > a { - &, - &:hover, - &:focus { - color: #fff; - #gradient > .vertical(@start-color: @dropdown-link-active-bg; @end-color: darken(@dropdown-link-active-bg, 5%)); - } - } -} - - -// -// Alerts -// -------------------------------------------------- - -// Common styles -.alert { - text-shadow: 0 1px 0 rgba(255,255,255,.2); - @shadow: inset 0 1px 0 rgba(255,255,255,.25), 0 1px 2px rgba(0,0,0,.05); - .box-shadow(@shadow); -} - -// Mixin for generating new styles -.alert-styles(@color) { - #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 7.5%)); - border-color: darken(@color, 15%); -} - -// Apply the mixin to the alerts -.alert-success { .alert-styles(@alert-success-bg); } -.alert-info { .alert-styles(@alert-info-bg); } -.alert-warning { .alert-styles(@alert-warning-bg); } -.alert-danger { .alert-styles(@alert-danger-bg); } - - -// -// Progress bars -// -------------------------------------------------- - -// Give the progress background some depth -.progress { - #gradient > .vertical(@start-color: darken(@progress-bg, 4%); @end-color: @progress-bg) -} - -// Mixin for generating new styles -.progress-bar-styles(@color) { - #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 10%)); -} - -// Apply the mixin to the progress bars -.progress-bar { .progress-bar-styles(@progress-bar-bg); } -.progress-bar-success { .progress-bar-styles(@progress-bar-success-bg); } -.progress-bar-info { .progress-bar-styles(@progress-bar-info-bg); } -.progress-bar-warning { .progress-bar-styles(@progress-bar-warning-bg); } -.progress-bar-danger { .progress-bar-styles(@progress-bar-danger-bg); } - -// Reset the striped class because our mixins don't do multiple gradients and -// the above custom styles override the new `.progress-bar-striped` in v3.2.0. -.progress-bar-striped { - #gradient > .striped(); -} - - -// -// List groups -// -------------------------------------------------- - -.list-group { - border-radius: @border-radius-base; - .box-shadow(0 1px 2px rgba(0,0,0,.075)); -} -.list-group-item.active, -.list-group-item.active:hover, -.list-group-item.active:focus { - text-shadow: 0 -1px 0 darken(@list-group-active-bg, 10%); - #gradient > .vertical(@start-color: @list-group-active-bg; @end-color: darken(@list-group-active-bg, 7.5%)); - border-color: darken(@list-group-active-border, 7.5%); - - .badge { - text-shadow: none; - } -} - - -// -// Panels -// -------------------------------------------------- - -// Common styles -.panel { - .box-shadow(0 1px 2px rgba(0,0,0,.05)); -} - -// Mixin for generating new styles -.panel-heading-styles(@color) { - #gradient > .vertical(@start-color: @color; @end-color: darken(@color, 5%)); -} - -// Apply the mixin to the panel headings only -.panel-default > .panel-heading { .panel-heading-styles(@panel-default-heading-bg); } -.panel-primary > .panel-heading { .panel-heading-styles(@panel-primary-heading-bg); } -.panel-success > .panel-heading { .panel-heading-styles(@panel-success-heading-bg); } -.panel-info > .panel-heading { .panel-heading-styles(@panel-info-heading-bg); } -.panel-warning > .panel-heading { .panel-heading-styles(@panel-warning-heading-bg); } -.panel-danger > .panel-heading { .panel-heading-styles(@panel-danger-heading-bg); } - - -// -// Wells -// -------------------------------------------------- - -.well { - #gradient > .vertical(@start-color: darken(@well-bg, 5%); @end-color: @well-bg); - border-color: darken(@well-bg, 10%); - @shadow: inset 0 1px 3px rgba(0,0,0,.05), 0 1px 0 rgba(255,255,255,.1); - .box-shadow(@shadow); -} diff --git a/src/UI/Content/Bootstrap/thumbnails.less b/src/UI/Content/Bootstrap/thumbnails.less deleted file mode 100644 index 0713e67d0..000000000 --- a/src/UI/Content/Bootstrap/thumbnails.less +++ /dev/null @@ -1,36 +0,0 @@ -// -// Thumbnails -// -------------------------------------------------- - - -// Mixin and adjust the regular image class -.thumbnail { - display: block; - padding: @thumbnail-padding; - margin-bottom: @line-height-computed; - line-height: @line-height-base; - background-color: @thumbnail-bg; - border: 1px solid @thumbnail-border; - border-radius: @thumbnail-border-radius; - .transition(border .2s ease-in-out); - - > img, - a > img { - &:extend(.img-responsive); - margin-left: auto; - margin-right: auto; - } - - // Add a hover state for linked versions only - a&:hover, - a&:focus, - a&.active { - border-color: @link-color; - } - - // Image captions - .caption { - padding: @thumbnail-caption-padding; - color: @thumbnail-caption-color; - } -} diff --git a/src/UI/Content/Bootstrap/tooltip.less b/src/UI/Content/Bootstrap/tooltip.less deleted file mode 100644 index b48d63e07..000000000 --- a/src/UI/Content/Bootstrap/tooltip.less +++ /dev/null @@ -1,101 +0,0 @@ -// -// Tooltips -// -------------------------------------------------- - - -// Base class -.tooltip { - position: absolute; - z-index: @zindex-tooltip; - display: block; - // Our parent element can be arbitrary since tooltips are by default inserted as a sibling of their target element. - // So reset our font and text properties to avoid inheriting weird values. - .reset-text(); - font-size: @font-size-small; - - .opacity(0); - - &.in { .opacity(@tooltip-opacity); } - &.top { margin-top: -3px; padding: @tooltip-arrow-width 0; } - &.right { margin-left: 3px; padding: 0 @tooltip-arrow-width; } - &.bottom { margin-top: 3px; padding: @tooltip-arrow-width 0; } - &.left { margin-left: -3px; padding: 0 @tooltip-arrow-width; } -} - -// Wrapper for the tooltip content -.tooltip-inner { - max-width: @tooltip-max-width; - padding: 3px 8px; - color: @tooltip-color; - text-align: center; - background-color: @tooltip-bg; - border-radius: @border-radius-base; -} - -// Arrows -.tooltip-arrow { - position: absolute; - width: 0; - height: 0; - border-color: transparent; - border-style: solid; -} -// Note: Deprecated .top-left, .top-right, .bottom-left, and .bottom-right as of v3.3.1 -.tooltip { - &.top .tooltip-arrow { - bottom: 0; - left: 50%; - margin-left: -@tooltip-arrow-width; - border-width: @tooltip-arrow-width @tooltip-arrow-width 0; - border-top-color: @tooltip-arrow-color; - } - &.top-left .tooltip-arrow { - bottom: 0; - right: @tooltip-arrow-width; - margin-bottom: -@tooltip-arrow-width; - border-width: @tooltip-arrow-width @tooltip-arrow-width 0; - border-top-color: @tooltip-arrow-color; - } - &.top-right .tooltip-arrow { - bottom: 0; - left: @tooltip-arrow-width; - margin-bottom: -@tooltip-arrow-width; - border-width: @tooltip-arrow-width @tooltip-arrow-width 0; - border-top-color: @tooltip-arrow-color; - } - &.right .tooltip-arrow { - top: 50%; - left: 0; - margin-top: -@tooltip-arrow-width; - border-width: @tooltip-arrow-width @tooltip-arrow-width @tooltip-arrow-width 0; - border-right-color: @tooltip-arrow-color; - } - &.left .tooltip-arrow { - top: 50%; - right: 0; - margin-top: -@tooltip-arrow-width; - border-width: @tooltip-arrow-width 0 @tooltip-arrow-width @tooltip-arrow-width; - border-left-color: @tooltip-arrow-color; - } - &.bottom .tooltip-arrow { - top: 0; - left: 50%; - margin-left: -@tooltip-arrow-width; - border-width: 0 @tooltip-arrow-width @tooltip-arrow-width; - border-bottom-color: @tooltip-arrow-color; - } - &.bottom-left .tooltip-arrow { - top: 0; - right: @tooltip-arrow-width; - margin-top: -@tooltip-arrow-width; - border-width: 0 @tooltip-arrow-width @tooltip-arrow-width; - border-bottom-color: @tooltip-arrow-color; - } - &.bottom-right .tooltip-arrow { - top: 0; - left: @tooltip-arrow-width; - margin-top: -@tooltip-arrow-width; - border-width: 0 @tooltip-arrow-width @tooltip-arrow-width; - border-bottom-color: @tooltip-arrow-color; - } -} diff --git a/src/UI/Content/Bootstrap/type.less b/src/UI/Content/Bootstrap/type.less deleted file mode 100644 index 68ba6017b..000000000 --- a/src/UI/Content/Bootstrap/type.less +++ /dev/null @@ -1,302 +0,0 @@ -// -// Typography -// -------------------------------------------------- - - -// Headings -// ------------------------- - -h1, h2, h3, h4, h5, h6, -.h1, .h2, .h3, .h4, .h5, .h6 { - font-family: @headings-font-family; - font-weight: @headings-font-weight; - line-height: @headings-line-height; - color: @headings-color; - - small, - .small { - font-weight: normal; - line-height: 1; - color: @headings-small-color; - } -} - -h1, .h1, -h2, .h2, -h3, .h3 { - margin-top: @line-height-computed; - margin-bottom: (@line-height-computed / 2); - - small, - .small { - font-size: 65%; - } -} -h4, .h4, -h5, .h5, -h6, .h6 { - margin-top: (@line-height-computed / 2); - margin-bottom: (@line-height-computed / 2); - - small, - .small { - font-size: 75%; - } -} - -h1, .h1 { font-size: @font-size-h1; } -h2, .h2 { font-size: @font-size-h2; } -h3, .h3 { font-size: @font-size-h3; } -h4, .h4 { font-size: @font-size-h4; } -h5, .h5 { font-size: @font-size-h5; } -h6, .h6 { font-size: @font-size-h6; } - - -// Body text -// ------------------------- - -p { - margin: 0 0 (@line-height-computed / 2); -} - -.lead { - margin-bottom: @line-height-computed; - font-size: floor((@font-size-base * 1.15)); - font-weight: 300; - line-height: 1.4; - - @media (min-width: @screen-sm-min) { - font-size: (@font-size-base * 1.5); - } -} - - -// Emphasis & misc -// ------------------------- - -// Ex: (12px small font / 14px base font) * 100% = about 85% -small, -.small { - font-size: floor((100% * @font-size-small / @font-size-base)); -} - -mark, -.mark { - background-color: @state-warning-bg; - padding: .2em; -} - -// Alignment -.text-left { text-align: left; } -.text-right { text-align: right; } -.text-center { text-align: center; } -.text-justify { text-align: justify; } -.text-nowrap { white-space: nowrap; } - -// Transformation -.text-lowercase { text-transform: lowercase; } -.text-uppercase { text-transform: uppercase; } -.text-capitalize { text-transform: capitalize; } - -// Contextual colors -.text-muted { - color: @text-muted; -} -.text-primary { - .text-emphasis-variant(@brand-primary); -} -.text-success { - .text-emphasis-variant(@state-success-text); -} -.text-info { - .text-emphasis-variant(@state-info-text); -} -.text-warning { - .text-emphasis-variant(@state-warning-text); -} -.text-danger { - .text-emphasis-variant(@state-danger-text); -} - -// Contextual backgrounds -// For now we'll leave these alongside the text classes until v4 when we can -// safely shift things around (per SemVer rules). -.bg-primary { - // Given the contrast here, this is the only class to have its color inverted - // automatically. - color: #fff; - .bg-variant(@brand-primary); -} -.bg-success { - .bg-variant(@state-success-bg); -} -.bg-info { - .bg-variant(@state-info-bg); -} -.bg-warning { - .bg-variant(@state-warning-bg); -} -.bg-danger { - .bg-variant(@state-danger-bg); -} - - -// Page header -// ------------------------- - -.page-header { - padding-bottom: ((@line-height-computed / 2) - 1); - margin: (@line-height-computed * 2) 0 @line-height-computed; - border-bottom: 1px solid @page-header-border-color; -} - - -// Lists -// ------------------------- - -// Unordered and Ordered lists -ul, -ol { - margin-top: 0; - margin-bottom: (@line-height-computed / 2); - ul, - ol { - margin-bottom: 0; - } -} - -// List options - -// Unstyled keeps list items block level, just removes default browser padding and list-style -.list-unstyled { - padding-left: 0; - list-style: none; -} - -// Inline turns list items into inline-block -.list-inline { - .list-unstyled(); - margin-left: -5px; - - > li { - display: inline-block; - padding-left: 5px; - padding-right: 5px; - } -} - -// Description Lists -dl { - margin-top: 0; // Remove browser default - margin-bottom: @line-height-computed; -} -dt, -dd { - line-height: @line-height-base; -} -dt { - font-weight: bold; -} -dd { - margin-left: 0; // Undo browser default -} - -// Horizontal description lists -// -// Defaults to being stacked without any of the below styles applied, until the -// grid breakpoint is reached (default of ~768px). - -.dl-horizontal { - dd { - &:extend(.clearfix all); // Clear the floated `dt` if an empty `dd` is present - } - - @media (min-width: @grid-float-breakpoint) { - dt { - float: left; - width: (@dl-horizontal-offset - 20); - clear: left; - text-align: right; - .text-overflow(); - } - dd { - margin-left: @dl-horizontal-offset; - } - } -} - - -// Misc -// ------------------------- - -// Abbreviations and acronyms -abbr[title], -// Add data-* attribute to help out our tooltip plugin, per https://github.com/twbs/bootstrap/issues/5257 -abbr[data-original-title] { - cursor: help; - border-bottom: 1px dotted @abbr-border-color; -} -.initialism { - font-size: 90%; - .text-uppercase(); -} - -// Blockquotes -blockquote { - padding: (@line-height-computed / 2) @line-height-computed; - margin: 0 0 @line-height-computed; - font-size: @blockquote-font-size; - border-left: 5px solid @blockquote-border-color; - - p, - ul, - ol { - &:last-child { - margin-bottom: 0; - } - } - - // Note: Deprecated small and .small as of v3.1.0 - // Context: https://github.com/twbs/bootstrap/issues/11660 - footer, - small, - .small { - display: block; - font-size: 80%; // back to default font-size - line-height: @line-height-base; - color: @blockquote-small-color; - - &:before { - content: '\2014 \00A0'; // em dash, nbsp - } - } -} - -// Opposite alignment of blockquote -// -// Heads up: `blockquote.pull-right` has been deprecated as of v3.1.0. -.blockquote-reverse, -blockquote.pull-right { - padding-right: 15px; - padding-left: 0; - border-right: 5px solid @blockquote-border-color; - border-left: 0; - text-align: right; - - // Account for citation - footer, - small, - .small { - &:before { content: ''; } - &:after { - content: '\00A0 \2014'; // nbsp, em dash - } - } -} - -// Addresses -address { - margin-bottom: @line-height-computed; - font-style: normal; - line-height: @line-height-base; -} diff --git a/src/UI/Content/Bootstrap/utilities.less b/src/UI/Content/Bootstrap/utilities.less deleted file mode 100644 index 7a8ca27a8..000000000 --- a/src/UI/Content/Bootstrap/utilities.less +++ /dev/null @@ -1,55 +0,0 @@ -// -// Utility classes -// -------------------------------------------------- - - -// Floats -// ------------------------- - -.clearfix { - .clearfix(); -} -.center-block { - .center-block(); -} -.pull-right { - float: right !important; -} -.pull-left { - float: left !important; -} - - -// Toggling content -// ------------------------- - -// Note: Deprecated .hide in favor of .hidden or .sr-only (as appropriate) in v3.0.1 -.hide { - display: none !important; -} -.show { - display: block !important; -} -.invisible { - visibility: hidden; -} -.text-hide { - .text-hide(); -} - - -// Hide from screenreaders and browsers -// -// Credit: HTML5 Boilerplate - -.hidden { - display: none !important; -} - - -// For Affix plugin -// ------------------------- - -.affix { - position: fixed; -} diff --git a/src/UI/Content/Bootstrap/variables.less b/src/UI/Content/Bootstrap/variables.less deleted file mode 100644 index c1861a8e0..000000000 --- a/src/UI/Content/Bootstrap/variables.less +++ /dev/null @@ -1,867 +0,0 @@ -// -// Variables -// -------------------------------------------------- - - -//== Colors -// -//## Gray and brand colors for use across Bootstrap. - -@gray-base: #000; -@gray-darker: lighten(@gray-base, 13.5%); // #222 -@gray-dark: lighten(@gray-base, 20%); // #333 -@gray: lighten(@gray-base, 33.5%); // #555 -@gray-light: lighten(@gray-base, 46.7%); // #777 -@gray-lighter: lighten(@gray-base, 93.5%); // #eee - -@brand-primary: darken(#428bca, 6.5%); // #337ab7 -@brand-success: #5cb85c; -@brand-info: #5bc0de; -@brand-warning: #f0ad4e; -@brand-danger: #d9534f; - - -//== Scaffolding -// -//## Settings for some of the most global styles. - -//** Background color for `<body>`. -@body-bg: #fff; -//** Global text color on `<body>`. -@text-color: @gray-dark; - -//** Global textual link color. -@link-color: @brand-primary; -//** Link hover color set via `darken()` function. -@link-hover-color: darken(@link-color, 15%); -//** Link hover decoration. -@link-hover-decoration: underline; - - -//== Typography -// -//## Font, line-height, and color for body text, headings, and more. - -@font-family-sans-serif: "Helvetica Neue", Helvetica, Arial, sans-serif; -@font-family-serif: Georgia, "Times New Roman", Times, serif; -//** Default monospace fonts for `<code>`, `<kbd>`, and `<pre>`. -@font-family-monospace: Menlo, Monaco, Consolas, "Courier New", monospace; -@font-family-base: @font-family-sans-serif; - -@font-size-base: 14px; -@font-size-large: ceil((@font-size-base * 1.25)); // ~18px -@font-size-small: ceil((@font-size-base * 0.85)); // ~12px - -@font-size-h1: floor((@font-size-base * 2.6)); // ~36px -@font-size-h2: floor((@font-size-base * 2.15)); // ~30px -@font-size-h3: ceil((@font-size-base * 1.7)); // ~24px -@font-size-h4: ceil((@font-size-base * 1.25)); // ~18px -@font-size-h5: @font-size-base; -@font-size-h6: ceil((@font-size-base * 0.85)); // ~12px - -//** Unit-less `line-height` for use in components like buttons. -@line-height-base: 1.428571429; // 20/14 -//** Computed "line-height" (`font-size` * `line-height`) for use with `margin`, `padding`, etc. -@line-height-computed: floor((@font-size-base * @line-height-base)); // ~20px - -//** By default, this inherits from the `<body>`. -@headings-font-family: inherit; -@headings-font-weight: 500; -@headings-line-height: 1.1; -@headings-color: inherit; - - -//== Iconography -// -//## Specify custom location and filename of the included Glyphicons icon font. Useful for those including Bootstrap via Bower. - -//** Load fonts from this directory. -@icon-font-path: "../fonts/"; -//** File name for all font files. -@icon-font-name: "glyphicons-halflings-regular"; -//** Element ID within SVG icon file. -@icon-font-svg-id: "glyphicons_halflingsregular"; - - -//== Components -// -//## Define common padding and border radius sizes and more. Values based on 14px text and 1.428 line-height (~20px to start). - -@padding-base-vertical: 6px; -@padding-base-horizontal: 12px; - -@padding-large-vertical: 10px; -@padding-large-horizontal: 16px; - -@padding-small-vertical: 5px; -@padding-small-horizontal: 10px; - -@padding-xs-vertical: 1px; -@padding-xs-horizontal: 5px; - -@line-height-large: 1.3333333; // extra decimals for Win 8.1 Chrome -@line-height-small: 1.5; - -@border-radius-base: 4px; -@border-radius-large: 6px; -@border-radius-small: 3px; - -//** Global color for active items (e.g., navs or dropdowns). -@component-active-color: #fff; -//** Global background color for active items (e.g., navs or dropdowns). -@component-active-bg: @brand-primary; - -//** Width of the `border` for generating carets that indicator dropdowns. -@caret-width-base: 4px; -//** Carets increase slightly in size for larger components. -@caret-width-large: 5px; - - -//== Tables -// -//## Customizes the `.table` component with basic values, each used across all table variations. - -//** Padding for `<th>`s and `<td>`s. -@table-cell-padding: 8px; -//** Padding for cells in `.table-condensed`. -@table-condensed-cell-padding: 5px; - -//** Default background color used for all tables. -@table-bg: transparent; -//** Background color used for `.table-striped`. -@table-bg-accent: #f9f9f9; -//** Background color used for `.table-hover`. -@table-bg-hover: #f5f5f5; -@table-bg-active: @table-bg-hover; - -//** Border color for table and cell borders. -@table-border-color: #ddd; - - -//== Buttons -// -//## For each of Bootstrap's buttons, define text, background and border color. - -@btn-font-weight: normal; - -@btn-default-color: #333; -@btn-default-bg: #fff; -@btn-default-border: #ccc; - -@btn-primary-color: #fff; -@btn-primary-bg: @brand-primary; -@btn-primary-border: darken(@btn-primary-bg, 5%); - -@btn-success-color: #fff; -@btn-success-bg: @brand-success; -@btn-success-border: darken(@btn-success-bg, 5%); - -@btn-info-color: #fff; -@btn-info-bg: @brand-info; -@btn-info-border: darken(@btn-info-bg, 5%); - -@btn-warning-color: #fff; -@btn-warning-bg: @brand-warning; -@btn-warning-border: darken(@btn-warning-bg, 5%); - -@btn-danger-color: #fff; -@btn-danger-bg: @brand-danger; -@btn-danger-border: darken(@btn-danger-bg, 5%); - -@btn-link-disabled-color: @gray-light; - -// Allows for customizing button radius independently from global border radius -@btn-border-radius-base: @border-radius-base; -@btn-border-radius-large: @border-radius-large; -@btn-border-radius-small: @border-radius-small; - - -//== Forms -// -//## - -//** `<input>` background color -@input-bg: #fff; -//** `<input disabled>` background color -@input-bg-disabled: @gray-lighter; - -//** Text color for `<input>`s -@input-color: @gray; -//** `<input>` border color -@input-border: #ccc; - -// TODO: Rename `@input-border-radius` to `@input-border-radius-base` in v4 -//** Default `.form-control` border radius -// This has no effect on `<select>`s in some browsers, due to the limited stylability of `<select>`s in CSS. -@input-border-radius: @border-radius-base; -//** Large `.form-control` border radius -@input-border-radius-large: @border-radius-large; -//** Small `.form-control` border radius -@input-border-radius-small: @border-radius-small; - -//** Border color for inputs on focus -@input-border-focus: #66afe9; - -//** Placeholder text color -@input-color-placeholder: #999; - -//** Default `.form-control` height -@input-height-base: (@line-height-computed + (@padding-base-vertical * 2) + 2); -//** Large `.form-control` height -@input-height-large: (ceil(@font-size-large * @line-height-large) + (@padding-large-vertical * 2) + 2); -//** Small `.form-control` height -@input-height-small: (floor(@font-size-small * @line-height-small) + (@padding-small-vertical * 2) + 2); - -//** `.form-group` margin -@form-group-margin-bottom: 15px; - -@legend-color: @gray-dark; -@legend-border-color: #e5e5e5; - -//** Background color for textual input addons -@input-group-addon-bg: @gray-lighter; -//** Border color for textual input addons -@input-group-addon-border-color: @input-border; - -//** Disabled cursor for form controls and buttons. -@cursor-disabled: not-allowed; - - -//== Dropdowns -// -//## Dropdown menu container and contents. - -//** Background for the dropdown menu. -@dropdown-bg: #fff; -//** Dropdown menu `border-color`. -@dropdown-border: rgba(0,0,0,.15); -//** Dropdown menu `border-color` **for IE8**. -@dropdown-fallback-border: #ccc; -//** Divider color for between dropdown items. -@dropdown-divider-bg: #e5e5e5; - -//** Dropdown link text color. -@dropdown-link-color: @gray-dark; -//** Hover color for dropdown links. -@dropdown-link-hover-color: darken(@gray-dark, 5%); -//** Hover background for dropdown links. -@dropdown-link-hover-bg: #f5f5f5; - -//** Active dropdown menu item text color. -@dropdown-link-active-color: @component-active-color; -//** Active dropdown menu item background color. -@dropdown-link-active-bg: @component-active-bg; - -//** Disabled dropdown menu item background color. -@dropdown-link-disabled-color: @gray-light; - -//** Text color for headers within dropdown menus. -@dropdown-header-color: @gray-light; - -//** Deprecated `@dropdown-caret-color` as of v3.1.0 -@dropdown-caret-color: #000; - - -//-- Z-index master list -// -// Warning: Avoid customizing these values. They're used for a bird's eye view -// of components dependent on the z-axis and are designed to all work together. -// -// Note: These variables are not generated into the Customizer. - -@zindex-navbar: 1000; -@zindex-dropdown: 1000; -@zindex-popover: 1060; -@zindex-tooltip: 1070; -@zindex-navbar-fixed: 1030; -@zindex-modal-background: 1040; -@zindex-modal: 1050; - - -//== Media queries breakpoints -// -//## Define the breakpoints at which your layout will change, adapting to different screen sizes. - -// Extra small screen / phone -//** Deprecated `@screen-xs` as of v3.0.1 -@screen-xs: 480px; -//** Deprecated `@screen-xs-min` as of v3.2.0 -@screen-xs-min: @screen-xs; -//** Deprecated `@screen-phone` as of v3.0.1 -@screen-phone: @screen-xs-min; - -// Small screen / tablet -//** Deprecated `@screen-sm` as of v3.0.1 -@screen-sm: 768px; -@screen-sm-min: @screen-sm; -//** Deprecated `@screen-tablet` as of v3.0.1 -@screen-tablet: @screen-sm-min; - -// Medium screen / desktop -//** Deprecated `@screen-md` as of v3.0.1 -@screen-md: 992px; -@screen-md-min: @screen-md; -//** Deprecated `@screen-desktop` as of v3.0.1 -@screen-desktop: @screen-md-min; - -// Large screen / wide desktop -//** Deprecated `@screen-lg` as of v3.0.1 -@screen-lg: 1200px; -@screen-lg-min: @screen-lg; -//** Deprecated `@screen-lg-desktop` as of v3.0.1 -@screen-lg-desktop: @screen-lg-min; - -// So media queries don't overlap when required, provide a maximum -@screen-xs-max: (@screen-sm-min - 1); -@screen-sm-max: (@screen-md-min - 1); -@screen-md-max: (@screen-lg-min - 1); - - -//== Grid system -// -//## Define your custom responsive grid. - -//** Number of columns in the grid. -@grid-columns: 12; -//** Padding between columns. Gets divided in half for the left and right. -@grid-gutter-width: 30px; -// Navbar collapse -//** Point at which the navbar becomes uncollapsed. -@grid-float-breakpoint: @screen-sm-min; -//** Point at which the navbar begins collapsing. -@grid-float-breakpoint-max: (@grid-float-breakpoint - 1); - - -//== Container sizes -// -//## Define the maximum width of `.container` for different screen sizes. - -// Small screen / tablet -@container-tablet: (720px + @grid-gutter-width); -//** For `@screen-sm-min` and up. -@container-sm: @container-tablet; - -// Medium screen / desktop -@container-desktop: (940px + @grid-gutter-width); -//** For `@screen-md-min` and up. -@container-md: @container-desktop; - -// Large screen / wide desktop -@container-large-desktop: (1140px + @grid-gutter-width); -//** For `@screen-lg-min` and up. -@container-lg: @container-large-desktop; - - -//== Navbar -// -//## - -// Basics of a navbar -@navbar-height: 50px; -@navbar-margin-bottom: @line-height-computed; -@navbar-border-radius: @border-radius-base; -@navbar-padding-horizontal: floor((@grid-gutter-width / 2)); -@navbar-padding-vertical: ((@navbar-height - @line-height-computed) / 2); -@navbar-collapse-max-height: 340px; - -@navbar-default-color: #777; -@navbar-default-bg: #f8f8f8; -@navbar-default-border: darken(@navbar-default-bg, 6.5%); - -// Navbar links -@navbar-default-link-color: #777; -@navbar-default-link-hover-color: #333; -@navbar-default-link-hover-bg: transparent; -@navbar-default-link-active-color: #555; -@navbar-default-link-active-bg: darken(@navbar-default-bg, 6.5%); -@navbar-default-link-disabled-color: #ccc; -@navbar-default-link-disabled-bg: transparent; - -// Navbar brand label -@navbar-default-brand-color: @navbar-default-link-color; -@navbar-default-brand-hover-color: darken(@navbar-default-brand-color, 10%); -@navbar-default-brand-hover-bg: transparent; - -// Navbar toggle -@navbar-default-toggle-hover-bg: #ddd; -@navbar-default-toggle-icon-bar-bg: #888; -@navbar-default-toggle-border-color: #ddd; - - -//=== Inverted navbar -// Reset inverted navbar basics -@navbar-inverse-color: lighten(@gray-light, 15%); -@navbar-inverse-bg: #222; -@navbar-inverse-border: darken(@navbar-inverse-bg, 10%); - -// Inverted navbar links -@navbar-inverse-link-color: lighten(@gray-light, 15%); -@navbar-inverse-link-hover-color: #fff; -@navbar-inverse-link-hover-bg: transparent; -@navbar-inverse-link-active-color: @navbar-inverse-link-hover-color; -@navbar-inverse-link-active-bg: darken(@navbar-inverse-bg, 10%); -@navbar-inverse-link-disabled-color: #444; -@navbar-inverse-link-disabled-bg: transparent; - -// Inverted navbar brand label -@navbar-inverse-brand-color: @navbar-inverse-link-color; -@navbar-inverse-brand-hover-color: #fff; -@navbar-inverse-brand-hover-bg: transparent; - -// Inverted navbar toggle -@navbar-inverse-toggle-hover-bg: #333; -@navbar-inverse-toggle-icon-bar-bg: #fff; -@navbar-inverse-toggle-border-color: #333; - - -//== Navs -// -//## - -//=== Shared nav styles -@nav-link-padding: 10px 15px; -@nav-link-hover-bg: @gray-lighter; - -@nav-disabled-link-color: @gray-light; -@nav-disabled-link-hover-color: @gray-light; - -//== Tabs -@nav-tabs-border-color: #ddd; - -@nav-tabs-link-hover-border-color: @gray-lighter; - -@nav-tabs-active-link-hover-bg: @body-bg; -@nav-tabs-active-link-hover-color: @gray; -@nav-tabs-active-link-hover-border-color: #ddd; - -@nav-tabs-justified-link-border-color: #ddd; -@nav-tabs-justified-active-link-border-color: @body-bg; - -//== Pills -@nav-pills-border-radius: @border-radius-base; -@nav-pills-active-link-hover-bg: @component-active-bg; -@nav-pills-active-link-hover-color: @component-active-color; - - -//== Pagination -// -//## - -@pagination-color: @link-color; -@pagination-bg: #fff; -@pagination-border: #ddd; - -@pagination-hover-color: @link-hover-color; -@pagination-hover-bg: @gray-lighter; -@pagination-hover-border: #ddd; - -@pagination-active-color: #fff; -@pagination-active-bg: @brand-primary; -@pagination-active-border: @brand-primary; - -@pagination-disabled-color: @gray-light; -@pagination-disabled-bg: #fff; -@pagination-disabled-border: #ddd; - - -//== Pager -// -//## - -@pager-bg: @pagination-bg; -@pager-border: @pagination-border; -@pager-border-radius: 15px; - -@pager-hover-bg: @pagination-hover-bg; - -@pager-active-bg: @pagination-active-bg; -@pager-active-color: @pagination-active-color; - -@pager-disabled-color: @pagination-disabled-color; - - -//== Jumbotron -// -//## - -@jumbotron-padding: 30px; -@jumbotron-color: inherit; -@jumbotron-bg: @gray-lighter; -@jumbotron-heading-color: inherit; -@jumbotron-font-size: ceil((@font-size-base * 1.5)); -@jumbotron-heading-font-size: ceil((@font-size-base * 4.5)); - - -//== Form states and alerts -// -//## Define colors for form feedback states and, by default, alerts. - -@state-success-text: #3c763d; -@state-success-bg: #dff0d8; -@state-success-border: darken(spin(@state-success-bg, -10), 5%); - -@state-info-text: #31708f; -@state-info-bg: #d9edf7; -@state-info-border: darken(spin(@state-info-bg, -10), 7%); - -@state-warning-text: #8a6d3b; -@state-warning-bg: #fcf8e3; -@state-warning-border: darken(spin(@state-warning-bg, -10), 5%); - -@state-danger-text: #a94442; -@state-danger-bg: #f2dede; -@state-danger-border: darken(spin(@state-danger-bg, -10), 5%); - - -//== Tooltips -// -//## - -//** Tooltip max width -@tooltip-max-width: 200px; -//** Tooltip text color -@tooltip-color: #fff; -//** Tooltip background color -@tooltip-bg: #000; -@tooltip-opacity: .9; - -//** Tooltip arrow width -@tooltip-arrow-width: 5px; -//** Tooltip arrow color -@tooltip-arrow-color: @tooltip-bg; - - -//== Popovers -// -//## - -//** Popover body background color -@popover-bg: #fff; -//** Popover maximum width -@popover-max-width: 276px; -//** Popover border color -@popover-border-color: rgba(0,0,0,.2); -//** Popover fallback border color -@popover-fallback-border-color: #ccc; - -//** Popover title background color -@popover-title-bg: darken(@popover-bg, 3%); - -//** Popover arrow width -@popover-arrow-width: 10px; -//** Popover arrow color -@popover-arrow-color: @popover-bg; - -//** Popover outer arrow width -@popover-arrow-outer-width: (@popover-arrow-width + 1); -//** Popover outer arrow color -@popover-arrow-outer-color: fadein(@popover-border-color, 5%); -//** Popover outer arrow fallback color -@popover-arrow-outer-fallback-color: darken(@popover-fallback-border-color, 20%); - - -//== Labels -// -//## - -//** Default label background color -@label-default-bg: @gray-light; -//** Primary label background color -@label-primary-bg: @brand-primary; -//** Success label background color -@label-success-bg: @brand-success; -//** Info label background color -@label-info-bg: @brand-info; -//** Warning label background color -@label-warning-bg: @brand-warning; -//** Danger label background color -@label-danger-bg: @brand-danger; - -//** Default label text color -@label-color: #fff; -//** Default text color of a linked label -@label-link-hover-color: #fff; - - -//== Modals -// -//## - -//** Padding applied to the modal body -@modal-inner-padding: 15px; - -//** Padding applied to the modal title -@modal-title-padding: 15px; -//** Modal title line-height -@modal-title-line-height: @line-height-base; - -//** Background color of modal content area -@modal-content-bg: #fff; -//** Modal content border color -@modal-content-border-color: rgba(0,0,0,.2); -//** Modal content border color **for IE8** -@modal-content-fallback-border-color: #999; - -//** Modal backdrop background color -@modal-backdrop-bg: #000; -//** Modal backdrop opacity -@modal-backdrop-opacity: .5; -//** Modal header border color -@modal-header-border-color: #e5e5e5; -//** Modal footer border color -@modal-footer-border-color: @modal-header-border-color; - -@modal-lg: 900px; -@modal-md: 600px; -@modal-sm: 300px; - - -//== Alerts -// -//## Define alert colors, border radius, and padding. - -@alert-padding: 15px; -@alert-border-radius: @border-radius-base; -@alert-link-font-weight: bold; - -@alert-success-bg: @state-success-bg; -@alert-success-text: @state-success-text; -@alert-success-border: @state-success-border; - -@alert-info-bg: @state-info-bg; -@alert-info-text: @state-info-text; -@alert-info-border: @state-info-border; - -@alert-warning-bg: @state-warning-bg; -@alert-warning-text: @state-warning-text; -@alert-warning-border: @state-warning-border; - -@alert-danger-bg: @state-danger-bg; -@alert-danger-text: @state-danger-text; -@alert-danger-border: @state-danger-border; - - -//== Progress bars -// -//## - -//** Background color of the whole progress component -@progress-bg: #f5f5f5; -//** Progress bar text color -@progress-bar-color: #fff; -//** Variable for setting rounded corners on progress bar. -@progress-border-radius: @border-radius-base; - -//** Default progress bar color -@progress-bar-bg: @brand-primary; -//** Success progress bar color -@progress-bar-success-bg: @brand-success; -//** Warning progress bar color -@progress-bar-warning-bg: @brand-warning; -//** Danger progress bar color -@progress-bar-danger-bg: @brand-danger; -//** Info progress bar color -@progress-bar-info-bg: @brand-info; - - -//== List group -// -//## - -//** Background color on `.list-group-item` -@list-group-bg: #fff; -//** `.list-group-item` border color -@list-group-border: #ddd; -//** List group border radius -@list-group-border-radius: @border-radius-base; - -//** Background color of single list items on hover -@list-group-hover-bg: #f5f5f5; -//** Text color of active list items -@list-group-active-color: @component-active-color; -//** Background color of active list items -@list-group-active-bg: @component-active-bg; -//** Border color of active list elements -@list-group-active-border: @list-group-active-bg; -//** Text color for content within active list items -@list-group-active-text-color: lighten(@list-group-active-bg, 40%); - -//** Text color of disabled list items -@list-group-disabled-color: @gray-light; -//** Background color of disabled list items -@list-group-disabled-bg: @gray-lighter; -//** Text color for content within disabled list items -@list-group-disabled-text-color: @list-group-disabled-color; - -@list-group-link-color: #555; -@list-group-link-hover-color: @list-group-link-color; -@list-group-link-heading-color: #333; - - -//== Panels -// -//## - -@panel-bg: #fff; -@panel-body-padding: 15px; -@panel-heading-padding: 10px 15px; -@panel-footer-padding: @panel-heading-padding; -@panel-border-radius: @border-radius-base; - -//** Border color for elements within panels -@panel-inner-border: #ddd; -@panel-footer-bg: #f5f5f5; - -@panel-default-text: @gray-dark; -@panel-default-border: #ddd; -@panel-default-heading-bg: #f5f5f5; - -@panel-primary-text: #fff; -@panel-primary-border: @brand-primary; -@panel-primary-heading-bg: @brand-primary; - -@panel-success-text: @state-success-text; -@panel-success-border: @state-success-border; -@panel-success-heading-bg: @state-success-bg; - -@panel-info-text: @state-info-text; -@panel-info-border: @state-info-border; -@panel-info-heading-bg: @state-info-bg; - -@panel-warning-text: @state-warning-text; -@panel-warning-border: @state-warning-border; -@panel-warning-heading-bg: @state-warning-bg; - -@panel-danger-text: @state-danger-text; -@panel-danger-border: @state-danger-border; -@panel-danger-heading-bg: @state-danger-bg; - - -//== Thumbnails -// -//## - -//** Padding around the thumbnail image -@thumbnail-padding: 4px; -//** Thumbnail background color -@thumbnail-bg: @body-bg; -//** Thumbnail border color -@thumbnail-border: #ddd; -//** Thumbnail border radius -@thumbnail-border-radius: @border-radius-base; - -//** Custom text color for thumbnail captions -@thumbnail-caption-color: @text-color; -//** Padding around the thumbnail caption -@thumbnail-caption-padding: 9px; - - -//== Wells -// -//## - -@well-bg: #f5f5f5; -@well-border: darken(@well-bg, 7%); - - -//== Badges -// -//## - -@badge-color: #fff; -//** Linked badge text color on hover -@badge-link-hover-color: #fff; -@badge-bg: @gray-light; - -//** Badge text color in active nav link -@badge-active-color: @link-color; -//** Badge background color in active nav link -@badge-active-bg: #fff; - -@badge-font-weight: bold; -@badge-line-height: 1; -@badge-border-radius: 10px; - - -//== Breadcrumbs -// -//## - -@breadcrumb-padding-vertical: 8px; -@breadcrumb-padding-horizontal: 15px; -//** Breadcrumb background color -@breadcrumb-bg: #f5f5f5; -//** Breadcrumb text color -@breadcrumb-color: #ccc; -//** Text color of current page in the breadcrumb -@breadcrumb-active-color: @gray-light; -//** Textual separator for between breadcrumb elements -@breadcrumb-separator: "/"; - - -//== Carousel -// -//## - -@carousel-text-shadow: 0 1px 2px rgba(0,0,0,.6); - -@carousel-control-color: #fff; -@carousel-control-width: 15%; -@carousel-control-opacity: .5; -@carousel-control-font-size: 20px; - -@carousel-indicator-active-bg: #fff; -@carousel-indicator-border-color: #fff; - -@carousel-caption-color: #fff; - - -//== Close -// -//## - -@close-font-weight: bold; -@close-color: #000; -@close-text-shadow: 0 1px 0 #fff; - - -//== Code -// -//## - -@code-color: #c7254e; -@code-bg: #f9f2f4; - -@kbd-color: #fff; -@kbd-bg: #333; - -@pre-bg: #f5f5f5; -@pre-color: @gray-dark; -@pre-border-color: #ccc; -@pre-scrollable-max-height: 340px; - - -//== Type -// -//## - -//** Horizontal offset for forms and lists. -@component-offset-horizontal: 180px; -//** Text muted color -@text-muted: @gray-light; -//** Abbreviations and acronyms border color -@abbr-border-color: @gray-light; -//** Headings small color -@headings-small-color: @gray-light; -//** Blockquote small color -@blockquote-small-color: @gray-light; -//** Blockquote font size -@blockquote-font-size: (@font-size-base * 1.25); -//** Blockquote border color -@blockquote-border-color: @gray-lighter; -//** Page header border color -@page-header-border-color: @gray-lighter; -//** Width of horizontal description list titles -@dl-horizontal-offset: @component-offset-horizontal; -//** Horizontal line color. -@hr-border: @gray-lighter; diff --git a/src/UI/Content/Bootstrap/wells.less b/src/UI/Content/Bootstrap/wells.less deleted file mode 100644 index 15d072b0c..000000000 --- a/src/UI/Content/Bootstrap/wells.less +++ /dev/null @@ -1,29 +0,0 @@ -// -// Wells -// -------------------------------------------------- - - -// Base class -.well { - min-height: 20px; - padding: 19px; - margin-bottom: 20px; - background-color: @well-bg; - border: 1px solid @well-border; - border-radius: @border-radius-base; - .box-shadow(inset 0 1px 1px rgba(0,0,0,.05)); - blockquote { - border-color: #ddd; - border-color: rgba(0,0,0,.15); - } -} - -// Sizes -.well-lg { - padding: 24px; - border-radius: @border-radius-large; -} -.well-sm { - padding: 9px; - border-radius: @border-radius-small; -} diff --git a/src/UI/Content/FontAwesome/FontAwesome.otf b/src/UI/Content/FontAwesome/FontAwesome.otf deleted file mode 100644 index f7936cc1e..000000000 Binary files a/src/UI/Content/FontAwesome/FontAwesome.otf and /dev/null differ diff --git a/src/UI/Content/FontAwesome/animated.less b/src/UI/Content/FontAwesome/animated.less deleted file mode 100644 index 66ad52a5b..000000000 --- a/src/UI/Content/FontAwesome/animated.less +++ /dev/null @@ -1,34 +0,0 @@ -// Animated Icons -// -------------------------- - -.@{fa-css-prefix}-spin { - -webkit-animation: fa-spin 2s infinite linear; - animation: fa-spin 2s infinite linear; -} - -.@{fa-css-prefix}-pulse { - -webkit-animation: fa-spin 1s infinite steps(8); - animation: fa-spin 1s infinite steps(8); -} - -@-webkit-keyframes fa-spin { - 0% { - -webkit-transform: rotate(0deg); - transform: rotate(0deg); - } - 100% { - -webkit-transform: rotate(359deg); - transform: rotate(359deg); - } -} - -@keyframes fa-spin { - 0% { - -webkit-transform: rotate(0deg); - transform: rotate(0deg); - } - 100% { - -webkit-transform: rotate(359deg); - transform: rotate(359deg); - } -} diff --git a/src/UI/Content/FontAwesome/bordered-pulled.less b/src/UI/Content/FontAwesome/bordered-pulled.less deleted file mode 100644 index 0c90eb567..000000000 --- a/src/UI/Content/FontAwesome/bordered-pulled.less +++ /dev/null @@ -1,16 +0,0 @@ -// Bordered & Pulled -// ------------------------- - -.@{fa-css-prefix}-border { - padding: .2em .25em .15em; - border: solid .08em @fa-border-color; - border-radius: .1em; -} - -.pull-right { float: right; } -.pull-left { float: left; } - -.@{fa-css-prefix} { - &.pull-left { margin-right: .3em; } - &.pull-right { margin-left: .3em; } -} diff --git a/src/UI/Content/FontAwesome/core.less b/src/UI/Content/FontAwesome/core.less deleted file mode 100644 index f814f1e17..000000000 --- a/src/UI/Content/FontAwesome/core.less +++ /dev/null @@ -1,13 +0,0 @@ -// Base Class Definition -// ------------------------- - -.@{fa-css-prefix} { - display: inline-block; - font: normal normal normal @fa-font-size-base/1 FontAwesome; // shortening font declaration - font-size: inherit; // can't have font-size inherit on line above, so need to override - text-rendering: auto; // optimizelegibility throws things off #1094 - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - transform: translate(0, 0); // ensures no half-pixel rendering in firefox - -} diff --git a/src/UI/Content/FontAwesome/fixed-width.less b/src/UI/Content/FontAwesome/fixed-width.less deleted file mode 100644 index 110289f2f..000000000 --- a/src/UI/Content/FontAwesome/fixed-width.less +++ /dev/null @@ -1,6 +0,0 @@ -// Fixed Width Icons -// ------------------------- -.@{fa-css-prefix}-fw { - width: (18em / 14); - text-align: center; -} diff --git a/src/UI/Content/FontAwesome/font-awesome.less b/src/UI/Content/FontAwesome/font-awesome.less deleted file mode 100644 index 1f45c63d1..000000000 --- a/src/UI/Content/FontAwesome/font-awesome.less +++ /dev/null @@ -1,17 +0,0 @@ -/*! - * Font Awesome 4.3.0 by @davegandy - http://fontawesome.io - @fontawesome - * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) - */ - -@import "variables.less"; -@import "mixins.less"; -@import "path.less"; -@import "core.less"; -@import "larger.less"; -@import "fixed-width.less"; -@import "list.less"; -@import "bordered-pulled.less"; -@import "animated.less"; -@import "rotated-flipped.less"; -@import "stacked.less"; -@import "icons.less"; diff --git a/src/UI/Content/FontAwesome/fontawesome-webfont.eot b/src/UI/Content/FontAwesome/fontawesome-webfont.eot deleted file mode 100644 index 33b2bb800..000000000 Binary files a/src/UI/Content/FontAwesome/fontawesome-webfont.eot and /dev/null differ diff --git a/src/UI/Content/FontAwesome/fontawesome-webfont.svg b/src/UI/Content/FontAwesome/fontawesome-webfont.svg deleted file mode 100644 index 1ee89d436..000000000 --- a/src/UI/Content/FontAwesome/fontawesome-webfont.svg +++ /dev/null @@ -1,565 +0,0 @@ -<?xml version="1.0" standalone="no"?> -<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" > -<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"> -<metadata></metadata> -<defs> -<font id="fontawesomeregular" horiz-adv-x="1536" > -<font-face units-per-em="1792" ascent="1536" descent="-256" /> -<missing-glyph horiz-adv-x="448" /> -<glyph unicode=" " horiz-adv-x="448" /> -<glyph unicode=" " horiz-adv-x="448" /> -<glyph unicode=" " horiz-adv-x="448" /> -<glyph unicode="¨" horiz-adv-x="1792" /> -<glyph unicode="©" horiz-adv-x="1792" /> -<glyph unicode="®" horiz-adv-x="1792" /> -<glyph unicode="´" horiz-adv-x="1792" /> -<glyph unicode="Æ" horiz-adv-x="1792" /> -<glyph unicode="Ø" horiz-adv-x="1792" /> -<glyph unicode=" " horiz-adv-x="768" /> -<glyph unicode=" " horiz-adv-x="1537" /> -<glyph unicode=" " horiz-adv-x="768" /> -<glyph unicode=" " horiz-adv-x="1537" /> -<glyph unicode=" " horiz-adv-x="512" /> -<glyph unicode=" " horiz-adv-x="384" /> -<glyph unicode=" " horiz-adv-x="256" /> -<glyph unicode=" " horiz-adv-x="256" /> -<glyph unicode=" " horiz-adv-x="192" /> -<glyph unicode=" " horiz-adv-x="307" /> -<glyph unicode=" " horiz-adv-x="85" /> -<glyph unicode=" " horiz-adv-x="307" /> -<glyph unicode=" " horiz-adv-x="384" /> -<glyph unicode="™" horiz-adv-x="1792" /> -<glyph unicode="∞" horiz-adv-x="1792" /> -<glyph unicode="≠" horiz-adv-x="1792" /> -<glyph unicode="◼" horiz-adv-x="500" d="M0 0z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1699 1350q0 -35 -43 -78l-632 -632v-768h320q26 0 45 -19t19 -45t-19 -45t-45 -19h-896q-26 0 -45 19t-19 45t19 45t45 19h320v768l-632 632q-43 43 -43 78q0 23 18 36.5t38 17.5t43 4h1408q23 0 43 -4t38 -17.5t18 -36.5z" /> -<glyph unicode="" d="M1536 1312v-1120q0 -50 -34 -89t-86 -60.5t-103.5 -32t-96.5 -10.5t-96.5 10.5t-103.5 32t-86 60.5t-34 89t34 89t86 60.5t103.5 32t96.5 10.5q105 0 192 -39v537l-768 -237v-709q0 -50 -34 -89t-86 -60.5t-103.5 -32t-96.5 -10.5t-96.5 10.5t-103.5 32t-86 60.5t-34 89 t34 89t86 60.5t103.5 32t96.5 10.5q105 0 192 -39v967q0 31 19 56.5t49 35.5l832 256q12 4 28 4q40 0 68 -28t28 -68z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1152 704q0 185 -131.5 316.5t-316.5 131.5t-316.5 -131.5t-131.5 -316.5t131.5 -316.5t316.5 -131.5t316.5 131.5t131.5 316.5zM1664 -128q0 -52 -38 -90t-90 -38q-54 0 -90 38l-343 342q-179 -124 -399 -124q-143 0 -273.5 55.5t-225 150t-150 225t-55.5 273.5 t55.5 273.5t150 225t225 150t273.5 55.5t273.5 -55.5t225 -150t150 -225t55.5 -273.5q0 -220 -124 -399l343 -343q37 -37 37 -90z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1664 32v768q-32 -36 -69 -66q-268 -206 -426 -338q-51 -43 -83 -67t-86.5 -48.5t-102.5 -24.5h-1h-1q-48 0 -102.5 24.5t-86.5 48.5t-83 67q-158 132 -426 338q-37 30 -69 66v-768q0 -13 9.5 -22.5t22.5 -9.5h1472q13 0 22.5 9.5t9.5 22.5zM1664 1083v11v13.5t-0.5 13 t-3 12.5t-5.5 9t-9 7.5t-14 2.5h-1472q-13 0 -22.5 -9.5t-9.5 -22.5q0 -168 147 -284q193 -152 401 -317q6 -5 35 -29.5t46 -37.5t44.5 -31.5t50.5 -27.5t43 -9h1h1q20 0 43 9t50.5 27.5t44.5 31.5t46 37.5t35 29.5q208 165 401 317q54 43 100.5 115.5t46.5 131.5z M1792 1120v-1088q0 -66 -47 -113t-113 -47h-1472q-66 0 -113 47t-47 113v1088q0 66 47 113t113 47h1472q66 0 113 -47t47 -113z" /> -<glyph unicode="" horiz-adv-x="1792" d="M896 -128q-26 0 -44 18l-624 602q-10 8 -27.5 26t-55.5 65.5t-68 97.5t-53.5 121t-23.5 138q0 220 127 344t351 124q62 0 126.5 -21.5t120 -58t95.5 -68.5t76 -68q36 36 76 68t95.5 68.5t120 58t126.5 21.5q224 0 351 -124t127 -344q0 -221 -229 -450l-623 -600 q-18 -18 -44 -18z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1664 889q0 -22 -26 -48l-363 -354l86 -500q1 -7 1 -20q0 -21 -10.5 -35.5t-30.5 -14.5q-19 0 -40 12l-449 236l-449 -236q-22 -12 -40 -12q-21 0 -31.5 14.5t-10.5 35.5q0 6 2 20l86 500l-364 354q-25 27 -25 48q0 37 56 46l502 73l225 455q19 41 49 41t49 -41l225 -455 l502 -73q56 -9 56 -46z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1137 532l306 297l-422 62l-189 382l-189 -382l-422 -62l306 -297l-73 -421l378 199l377 -199zM1664 889q0 -22 -26 -48l-363 -354l86 -500q1 -7 1 -20q0 -50 -41 -50q-19 0 -40 12l-449 236l-449 -236q-22 -12 -40 -12q-21 0 -31.5 14.5t-10.5 35.5q0 6 2 20l86 500 l-364 354q-25 27 -25 48q0 37 56 46l502 73l225 455q19 41 49 41t49 -41l225 -455l502 -73q56 -9 56 -46z" /> -<glyph unicode="" horiz-adv-x="1408" d="M1408 131q0 -120 -73 -189.5t-194 -69.5h-874q-121 0 -194 69.5t-73 189.5q0 53 3.5 103.5t14 109t26.5 108.5t43 97.5t62 81t85.5 53.5t111.5 20q9 0 42 -21.5t74.5 -48t108 -48t133.5 -21.5t133.5 21.5t108 48t74.5 48t42 21.5q61 0 111.5 -20t85.5 -53.5t62 -81 t43 -97.5t26.5 -108.5t14 -109t3.5 -103.5zM1088 1024q0 -159 -112.5 -271.5t-271.5 -112.5t-271.5 112.5t-112.5 271.5t112.5 271.5t271.5 112.5t271.5 -112.5t112.5 -271.5z" /> -<glyph unicode="" horiz-adv-x="1920" d="M384 -64v128q0 26 -19 45t-45 19h-128q-26 0 -45 -19t-19 -45v-128q0 -26 19 -45t45 -19h128q26 0 45 19t19 45zM384 320v128q0 26 -19 45t-45 19h-128q-26 0 -45 -19t-19 -45v-128q0 -26 19 -45t45 -19h128q26 0 45 19t19 45zM384 704v128q0 26 -19 45t-45 19h-128 q-26 0 -45 -19t-19 -45v-128q0 -26 19 -45t45 -19h128q26 0 45 19t19 45zM1408 -64v512q0 26 -19 45t-45 19h-768q-26 0 -45 -19t-19 -45v-512q0 -26 19 -45t45 -19h768q26 0 45 19t19 45zM384 1088v128q0 26 -19 45t-45 19h-128q-26 0 -45 -19t-19 -45v-128q0 -26 19 -45 t45 -19h128q26 0 45 19t19 45zM1792 -64v128q0 26 -19 45t-45 19h-128q-26 0 -45 -19t-19 -45v-128q0 -26 19 -45t45 -19h128q26 0 45 19t19 45zM1408 704v512q0 26 -19 45t-45 19h-768q-26 0 -45 -19t-19 -45v-512q0 -26 19 -45t45 -19h768q26 0 45 19t19 45zM1792 320v128 q0 26 -19 45t-45 19h-128q-26 0 -45 -19t-19 -45v-128q0 -26 19 -45t45 -19h128q26 0 45 19t19 45zM1792 704v128q0 26 -19 45t-45 19h-128q-26 0 -45 -19t-19 -45v-128q0 -26 19 -45t45 -19h128q26 0 45 19t19 45zM1792 1088v128q0 26 -19 45t-45 19h-128q-26 0 -45 -19 t-19 -45v-128q0 -26 19 -45t45 -19h128q26 0 45 19t19 45zM1920 1248v-1344q0 -66 -47 -113t-113 -47h-1600q-66 0 -113 47t-47 113v1344q0 66 47 113t113 47h1600q66 0 113 -47t47 -113z" /> -<glyph unicode="" horiz-adv-x="1664" d="M768 512v-384q0 -52 -38 -90t-90 -38h-512q-52 0 -90 38t-38 90v384q0 52 38 90t90 38h512q52 0 90 -38t38 -90zM768 1280v-384q0 -52 -38 -90t-90 -38h-512q-52 0 -90 38t-38 90v384q0 52 38 90t90 38h512q52 0 90 -38t38 -90zM1664 512v-384q0 -52 -38 -90t-90 -38 h-512q-52 0 -90 38t-38 90v384q0 52 38 90t90 38h512q52 0 90 -38t38 -90zM1664 1280v-384q0 -52 -38 -90t-90 -38h-512q-52 0 -90 38t-38 90v384q0 52 38 90t90 38h512q52 0 90 -38t38 -90z" /> -<glyph unicode="" horiz-adv-x="1792" d="M512 288v-192q0 -40 -28 -68t-68 -28h-320q-40 0 -68 28t-28 68v192q0 40 28 68t68 28h320q40 0 68 -28t28 -68zM512 800v-192q0 -40 -28 -68t-68 -28h-320q-40 0 -68 28t-28 68v192q0 40 28 68t68 28h320q40 0 68 -28t28 -68zM1152 288v-192q0 -40 -28 -68t-68 -28h-320 q-40 0 -68 28t-28 68v192q0 40 28 68t68 28h320q40 0 68 -28t28 -68zM512 1312v-192q0 -40 -28 -68t-68 -28h-320q-40 0 -68 28t-28 68v192q0 40 28 68t68 28h320q40 0 68 -28t28 -68zM1152 800v-192q0 -40 -28 -68t-68 -28h-320q-40 0 -68 28t-28 68v192q0 40 28 68t68 28 h320q40 0 68 -28t28 -68zM1792 288v-192q0 -40 -28 -68t-68 -28h-320q-40 0 -68 28t-28 68v192q0 40 28 68t68 28h320q40 0 68 -28t28 -68zM1152 1312v-192q0 -40 -28 -68t-68 -28h-320q-40 0 -68 28t-28 68v192q0 40 28 68t68 28h320q40 0 68 -28t28 -68zM1792 800v-192 q0 -40 -28 -68t-68 -28h-320q-40 0 -68 28t-28 68v192q0 40 28 68t68 28h320q40 0 68 -28t28 -68zM1792 1312v-192q0 -40 -28 -68t-68 -28h-320q-40 0 -68 28t-28 68v192q0 40 28 68t68 28h320q40 0 68 -28t28 -68z" /> -<glyph unicode="" horiz-adv-x="1792" d="M512 288v-192q0 -40 -28 -68t-68 -28h-320q-40 0 -68 28t-28 68v192q0 40 28 68t68 28h320q40 0 68 -28t28 -68zM512 800v-192q0 -40 -28 -68t-68 -28h-320q-40 0 -68 28t-28 68v192q0 40 28 68t68 28h320q40 0 68 -28t28 -68zM1792 288v-192q0 -40 -28 -68t-68 -28h-960 q-40 0 -68 28t-28 68v192q0 40 28 68t68 28h960q40 0 68 -28t28 -68zM512 1312v-192q0 -40 -28 -68t-68 -28h-320q-40 0 -68 28t-28 68v192q0 40 28 68t68 28h320q40 0 68 -28t28 -68zM1792 800v-192q0 -40 -28 -68t-68 -28h-960q-40 0 -68 28t-28 68v192q0 40 28 68t68 28 h960q40 0 68 -28t28 -68zM1792 1312v-192q0 -40 -28 -68t-68 -28h-960q-40 0 -68 28t-28 68v192q0 40 28 68t68 28h960q40 0 68 -28t28 -68z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1671 970q0 -40 -28 -68l-724 -724l-136 -136q-28 -28 -68 -28t-68 28l-136 136l-362 362q-28 28 -28 68t28 68l136 136q28 28 68 28t68 -28l294 -295l656 657q28 28 68 28t68 -28l136 -136q28 -28 28 -68z" /> -<glyph unicode="" horiz-adv-x="1408" d="M1298 214q0 -40 -28 -68l-136 -136q-28 -28 -68 -28t-68 28l-294 294l-294 -294q-28 -28 -68 -28t-68 28l-136 136q-28 28 -28 68t28 68l294 294l-294 294q-28 28 -28 68t28 68l136 136q28 28 68 28t68 -28l294 -294l294 294q28 28 68 28t68 -28l136 -136q28 -28 28 -68 t-28 -68l-294 -294l294 -294q28 -28 28 -68z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1024 736v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-224v-224q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v224h-224q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h224v224q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5v-224h224 q13 0 22.5 -9.5t9.5 -22.5zM1152 704q0 185 -131.5 316.5t-316.5 131.5t-316.5 -131.5t-131.5 -316.5t131.5 -316.5t316.5 -131.5t316.5 131.5t131.5 316.5zM1664 -128q0 -53 -37.5 -90.5t-90.5 -37.5q-54 0 -90 38l-343 342q-179 -124 -399 -124q-143 0 -273.5 55.5 t-225 150t-150 225t-55.5 273.5t55.5 273.5t150 225t225 150t273.5 55.5t273.5 -55.5t225 -150t150 -225t55.5 -273.5q0 -220 -124 -399l343 -343q37 -37 37 -90z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1024 736v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-576q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h576q13 0 22.5 -9.5t9.5 -22.5zM1152 704q0 185 -131.5 316.5t-316.5 131.5t-316.5 -131.5t-131.5 -316.5t131.5 -316.5t316.5 -131.5t316.5 131.5t131.5 316.5z M1664 -128q0 -53 -37.5 -90.5t-90.5 -37.5q-54 0 -90 38l-343 342q-179 -124 -399 -124q-143 0 -273.5 55.5t-225 150t-150 225t-55.5 273.5t55.5 273.5t150 225t225 150t273.5 55.5t273.5 -55.5t225 -150t150 -225t55.5 -273.5q0 -220 -124 -399l343 -343q37 -37 37 -90z " /> -<glyph unicode="" d="M1536 640q0 -156 -61 -298t-164 -245t-245 -164t-298 -61t-298 61t-245 164t-164 245t-61 298q0 182 80.5 343t226.5 270q43 32 95.5 25t83.5 -50q32 -42 24.5 -94.5t-49.5 -84.5q-98 -74 -151.5 -181t-53.5 -228q0 -104 40.5 -198.5t109.5 -163.5t163.5 -109.5 t198.5 -40.5t198.5 40.5t163.5 109.5t109.5 163.5t40.5 198.5q0 121 -53.5 228t-151.5 181q-42 32 -49.5 84.5t24.5 94.5q31 43 84 50t95 -25q146 -109 226.5 -270t80.5 -343zM896 1408v-640q0 -52 -38 -90t-90 -38t-90 38t-38 90v640q0 52 38 90t90 38t90 -38t38 -90z" /> -<glyph unicode="" horiz-adv-x="1792" d="M256 96v-192q0 -14 -9 -23t-23 -9h-192q-14 0 -23 9t-9 23v192q0 14 9 23t23 9h192q14 0 23 -9t9 -23zM640 224v-320q0 -14 -9 -23t-23 -9h-192q-14 0 -23 9t-9 23v320q0 14 9 23t23 9h192q14 0 23 -9t9 -23zM1024 480v-576q0 -14 -9 -23t-23 -9h-192q-14 0 -23 9t-9 23 v576q0 14 9 23t23 9h192q14 0 23 -9t9 -23zM1408 864v-960q0 -14 -9 -23t-23 -9h-192q-14 0 -23 9t-9 23v960q0 14 9 23t23 9h192q14 0 23 -9t9 -23zM1792 1376v-1472q0 -14 -9 -23t-23 -9h-192q-14 0 -23 9t-9 23v1472q0 14 9 23t23 9h192q14 0 23 -9t9 -23z" /> -<glyph unicode="" d="M1024 640q0 106 -75 181t-181 75t-181 -75t-75 -181t75 -181t181 -75t181 75t75 181zM1536 749v-222q0 -12 -8 -23t-20 -13l-185 -28q-19 -54 -39 -91q35 -50 107 -138q10 -12 10 -25t-9 -23q-27 -37 -99 -108t-94 -71q-12 0 -26 9l-138 108q-44 -23 -91 -38 q-16 -136 -29 -186q-7 -28 -36 -28h-222q-14 0 -24.5 8.5t-11.5 21.5l-28 184q-49 16 -90 37l-141 -107q-10 -9 -25 -9q-14 0 -25 11q-126 114 -165 168q-7 10 -7 23q0 12 8 23q15 21 51 66.5t54 70.5q-27 50 -41 99l-183 27q-13 2 -21 12.5t-8 23.5v222q0 12 8 23t19 13 l186 28q14 46 39 92q-40 57 -107 138q-10 12 -10 24q0 10 9 23q26 36 98.5 107.5t94.5 71.5q13 0 26 -10l138 -107q44 23 91 38q16 136 29 186q7 28 36 28h222q14 0 24.5 -8.5t11.5 -21.5l28 -184q49 -16 90 -37l142 107q9 9 24 9q13 0 25 -10q129 -119 165 -170q7 -8 7 -22 q0 -12 -8 -23q-15 -21 -51 -66.5t-54 -70.5q26 -50 41 -98l183 -28q13 -2 21 -12.5t8 -23.5z" /> -<glyph unicode="" horiz-adv-x="1408" d="M512 800v-576q0 -14 -9 -23t-23 -9h-64q-14 0 -23 9t-9 23v576q0 14 9 23t23 9h64q14 0 23 -9t9 -23zM768 800v-576q0 -14 -9 -23t-23 -9h-64q-14 0 -23 9t-9 23v576q0 14 9 23t23 9h64q14 0 23 -9t9 -23zM1024 800v-576q0 -14 -9 -23t-23 -9h-64q-14 0 -23 9t-9 23v576 q0 14 9 23t23 9h64q14 0 23 -9t9 -23zM1152 76v948h-896v-948q0 -22 7 -40.5t14.5 -27t10.5 -8.5h832q3 0 10.5 8.5t14.5 27t7 40.5zM480 1152h448l-48 117q-7 9 -17 11h-317q-10 -2 -17 -11zM1408 1120v-64q0 -14 -9 -23t-23 -9h-96v-948q0 -83 -47 -143.5t-113 -60.5h-832 q-66 0 -113 58.5t-47 141.5v952h-96q-14 0 -23 9t-9 23v64q0 14 9 23t23 9h309l70 167q15 37 54 63t79 26h320q40 0 79 -26t54 -63l70 -167h309q14 0 23 -9t9 -23z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1408 544v-480q0 -26 -19 -45t-45 -19h-384v384h-256v-384h-384q-26 0 -45 19t-19 45v480q0 1 0.5 3t0.5 3l575 474l575 -474q1 -2 1 -6zM1631 613l-62 -74q-8 -9 -21 -11h-3q-13 0 -21 7l-692 577l-692 -577q-12 -8 -24 -7q-13 2 -21 11l-62 74q-8 10 -7 23.5t11 21.5 l719 599q32 26 76 26t76 -26l244 -204v195q0 14 9 23t23 9h192q14 0 23 -9t9 -23v-408l219 -182q10 -8 11 -21.5t-7 -23.5z" /> -<glyph unicode="" d="M1468 1156q28 -28 48 -76t20 -88v-1152q0 -40 -28 -68t-68 -28h-1344q-40 0 -68 28t-28 68v1600q0 40 28 68t68 28h896q40 0 88 -20t76 -48zM1024 1400v-376h376q-10 29 -22 41l-313 313q-12 12 -41 22zM1408 -128v1024h-416q-40 0 -68 28t-28 68v416h-768v-1536h1280z " /> -<glyph unicode="" d="M896 992v-448q0 -14 -9 -23t-23 -9h-320q-14 0 -23 9t-9 23v64q0 14 9 23t23 9h224v352q0 14 9 23t23 9h64q14 0 23 -9t9 -23zM1312 640q0 148 -73 273t-198 198t-273 73t-273 -73t-198 -198t-73 -273t73 -273t198 -198t273 -73t273 73t198 198t73 273zM1536 640 q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" horiz-adv-x="1920" d="M1111 540v4l-24 320q-1 13 -11 22.5t-23 9.5h-186q-13 0 -23 -9.5t-11 -22.5l-24 -320v-4q-1 -12 8 -20t21 -8h244q12 0 21 8t8 20zM1870 73q0 -73 -46 -73h-704q13 0 22 9.5t8 22.5l-20 256q-1 13 -11 22.5t-23 9.5h-272q-13 0 -23 -9.5t-11 -22.5l-20 -256 q-1 -13 8 -22.5t22 -9.5h-704q-46 0 -46 73q0 54 26 116l417 1044q8 19 26 33t38 14h339q-13 0 -23 -9.5t-11 -22.5l-15 -192q-1 -14 8 -23t22 -9h166q13 0 22 9t8 23l-15 192q-1 13 -11 22.5t-23 9.5h339q20 0 38 -14t26 -33l417 -1044q26 -62 26 -116z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1280 192q0 26 -19 45t-45 19t-45 -19t-19 -45t19 -45t45 -19t45 19t19 45zM1536 192q0 26 -19 45t-45 19t-45 -19t-19 -45t19 -45t45 -19t45 19t19 45zM1664 416v-320q0 -40 -28 -68t-68 -28h-1472q-40 0 -68 28t-28 68v320q0 40 28 68t68 28h465l135 -136 q58 -56 136 -56t136 56l136 136h464q40 0 68 -28t28 -68zM1339 985q17 -41 -14 -70l-448 -448q-18 -19 -45 -19t-45 19l-448 448q-31 29 -14 70q17 39 59 39h256v448q0 26 19 45t45 19h256q26 0 45 -19t19 -45v-448h256q42 0 59 -39z" /> -<glyph unicode="" d="M1120 608q0 -12 -10 -24l-319 -319q-11 -9 -23 -9t-23 9l-320 320q-15 16 -7 35q8 20 30 20h192v352q0 14 9 23t23 9h192q14 0 23 -9t9 -23v-352h192q14 0 23 -9t9 -23zM768 1184q-148 0 -273 -73t-198 -198t-73 -273t73 -273t198 -198t273 -73t273 73t198 198t73 273 t-73 273t-198 198t-273 73zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" d="M1118 660q-8 -20 -30 -20h-192v-352q0 -14 -9 -23t-23 -9h-192q-14 0 -23 9t-9 23v352h-192q-14 0 -23 9t-9 23q0 12 10 24l319 319q11 9 23 9t23 -9l320 -320q15 -16 7 -35zM768 1184q-148 0 -273 -73t-198 -198t-73 -273t73 -273t198 -198t273 -73t273 73t198 198 t73 273t-73 273t-198 198t-273 73zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" d="M1023 576h316q-1 3 -2.5 8t-2.5 8l-212 496h-708l-212 -496q-1 -2 -2.5 -8t-2.5 -8h316l95 -192h320zM1536 546v-482q0 -26 -19 -45t-45 -19h-1408q-26 0 -45 19t-19 45v482q0 62 25 123l238 552q10 25 36.5 42t52.5 17h832q26 0 52.5 -17t36.5 -42l238 -552 q25 -61 25 -123z" /> -<glyph unicode="" d="M1184 640q0 -37 -32 -55l-544 -320q-15 -9 -32 -9q-16 0 -32 8q-32 19 -32 56v640q0 37 32 56q33 18 64 -1l544 -320q32 -18 32 -55zM1312 640q0 148 -73 273t-198 198t-273 73t-273 -73t-198 -198t-73 -273t73 -273t198 -198t273 -73t273 73t198 198t73 273zM1536 640 q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" d="M1536 1280v-448q0 -26 -19 -45t-45 -19h-448q-42 0 -59 40q-17 39 14 69l138 138q-148 137 -349 137q-104 0 -198.5 -40.5t-163.5 -109.5t-109.5 -163.5t-40.5 -198.5t40.5 -198.5t109.5 -163.5t163.5 -109.5t198.5 -40.5q119 0 225 52t179 147q7 10 23 12q14 0 25 -9 l137 -138q9 -8 9.5 -20.5t-7.5 -22.5q-109 -132 -264 -204.5t-327 -72.5q-156 0 -298 61t-245 164t-164 245t-61 298t61 298t164 245t245 164t298 61q147 0 284.5 -55.5t244.5 -156.5l130 129q29 31 70 14q39 -17 39 -59z" /> -<glyph unicode="" d="M1511 480q0 -5 -1 -7q-64 -268 -268 -434.5t-478 -166.5q-146 0 -282.5 55t-243.5 157l-129 -129q-19 -19 -45 -19t-45 19t-19 45v448q0 26 19 45t45 19h448q26 0 45 -19t19 -45t-19 -45l-137 -137q71 -66 161 -102t187 -36q134 0 250 65t186 179q11 17 53 117 q8 23 30 23h192q13 0 22.5 -9.5t9.5 -22.5zM1536 1280v-448q0 -26 -19 -45t-45 -19h-448q-26 0 -45 19t-19 45t19 45l138 138q-148 137 -349 137q-134 0 -250 -65t-186 -179q-11 -17 -53 -117q-8 -23 -30 -23h-199q-13 0 -22.5 9.5t-9.5 22.5v7q65 268 270 434.5t480 166.5 q146 0 284 -55.5t245 -156.5l130 129q19 19 45 19t45 -19t19 -45z" /> -<glyph unicode="" horiz-adv-x="1792" d="M384 352v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5zM384 608v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5z M384 864v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5zM1536 352v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-960q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h960q13 0 22.5 -9.5t9.5 -22.5z M1536 608v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-960q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h960q13 0 22.5 -9.5t9.5 -22.5zM1536 864v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-960q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h960q13 0 22.5 -9.5 t9.5 -22.5zM1664 160v832q0 13 -9.5 22.5t-22.5 9.5h-1472q-13 0 -22.5 -9.5t-9.5 -22.5v-832q0 -13 9.5 -22.5t22.5 -9.5h1472q13 0 22.5 9.5t9.5 22.5zM1792 1248v-1088q0 -66 -47 -113t-113 -47h-1472q-66 0 -113 47t-47 113v1088q0 66 47 113t113 47h1472q66 0 113 -47 t47 -113z" /> -<glyph unicode="" horiz-adv-x="1152" d="M320 768h512v192q0 106 -75 181t-181 75t-181 -75t-75 -181v-192zM1152 672v-576q0 -40 -28 -68t-68 -28h-960q-40 0 -68 28t-28 68v576q0 40 28 68t68 28h32v192q0 184 132 316t316 132t316 -132t132 -316v-192h32q40 0 68 -28t28 -68z" /> -<glyph unicode="" horiz-adv-x="1792" d="M320 1280q0 -72 -64 -110v-1266q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v1266q-64 38 -64 110q0 53 37.5 90.5t90.5 37.5t90.5 -37.5t37.5 -90.5zM1792 1216v-763q0 -25 -12.5 -38.5t-39.5 -27.5q-215 -116 -369 -116q-61 0 -123.5 22t-108.5 48 t-115.5 48t-142.5 22q-192 0 -464 -146q-17 -9 -33 -9q-26 0 -45 19t-19 45v742q0 32 31 55q21 14 79 43q236 120 421 120q107 0 200 -29t219 -88q38 -19 88 -19q54 0 117.5 21t110 47t88 47t54.5 21q26 0 45 -19t19 -45z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1664 650q0 -166 -60 -314l-20 -49l-185 -33q-22 -83 -90.5 -136.5t-156.5 -53.5v-32q0 -14 -9 -23t-23 -9h-64q-14 0 -23 9t-9 23v576q0 14 9 23t23 9h64q14 0 23 -9t9 -23v-32q71 0 130 -35.5t93 -95.5l68 12q29 95 29 193q0 148 -88 279t-236.5 209t-315.5 78 t-315.5 -78t-236.5 -209t-88 -279q0 -98 29 -193l68 -12q34 60 93 95.5t130 35.5v32q0 14 9 23t23 9h64q14 0 23 -9t9 -23v-576q0 -14 -9 -23t-23 -9h-64q-14 0 -23 9t-9 23v32q-88 0 -156.5 53.5t-90.5 136.5l-185 33l-20 49q-60 148 -60 314q0 151 67 291t179 242.5 t266 163.5t320 61t320 -61t266 -163.5t179 -242.5t67 -291z" /> -<glyph unicode="" horiz-adv-x="768" d="M768 1184v-1088q0 -26 -19 -45t-45 -19t-45 19l-333 333h-262q-26 0 -45 19t-19 45v384q0 26 19 45t45 19h262l333 333q19 19 45 19t45 -19t19 -45z" /> -<glyph unicode="" horiz-adv-x="1152" d="M768 1184v-1088q0 -26 -19 -45t-45 -19t-45 19l-333 333h-262q-26 0 -45 19t-19 45v384q0 26 19 45t45 19h262l333 333q19 19 45 19t45 -19t19 -45zM1152 640q0 -76 -42.5 -141.5t-112.5 -93.5q-10 -5 -25 -5q-26 0 -45 18.5t-19 45.5q0 21 12 35.5t29 25t34 23t29 35.5 t12 57t-12 57t-29 35.5t-34 23t-29 25t-12 35.5q0 27 19 45.5t45 18.5q15 0 25 -5q70 -27 112.5 -93t42.5 -142z" /> -<glyph unicode="" horiz-adv-x="1664" d="M768 1184v-1088q0 -26 -19 -45t-45 -19t-45 19l-333 333h-262q-26 0 -45 19t-19 45v384q0 26 19 45t45 19h262l333 333q19 19 45 19t45 -19t19 -45zM1152 640q0 -76 -42.5 -141.5t-112.5 -93.5q-10 -5 -25 -5q-26 0 -45 18.5t-19 45.5q0 21 12 35.5t29 25t34 23t29 35.5 t12 57t-12 57t-29 35.5t-34 23t-29 25t-12 35.5q0 27 19 45.5t45 18.5q15 0 25 -5q70 -27 112.5 -93t42.5 -142zM1408 640q0 -153 -85 -282.5t-225 -188.5q-13 -5 -25 -5q-27 0 -46 19t-19 45q0 39 39 59q56 29 76 44q74 54 115.5 135.5t41.5 173.5t-41.5 173.5 t-115.5 135.5q-20 15 -76 44q-39 20 -39 59q0 26 19 45t45 19q13 0 26 -5q140 -59 225 -188.5t85 -282.5zM1664 640q0 -230 -127 -422.5t-338 -283.5q-13 -5 -26 -5q-26 0 -45 19t-19 45q0 36 39 59q7 4 22.5 10.5t22.5 10.5q46 25 82 51q123 91 192 227t69 289t-69 289 t-192 227q-36 26 -82 51q-7 4 -22.5 10.5t-22.5 10.5q-39 23 -39 59q0 26 19 45t45 19q13 0 26 -5q211 -91 338 -283.5t127 -422.5z" /> -<glyph unicode="" horiz-adv-x="1408" d="M384 384v-128h-128v128h128zM384 1152v-128h-128v128h128zM1152 1152v-128h-128v128h128zM128 129h384v383h-384v-383zM128 896h384v384h-384v-384zM896 896h384v384h-384v-384zM640 640v-640h-640v640h640zM1152 128v-128h-128v128h128zM1408 128v-128h-128v128h128z M1408 640v-384h-384v128h-128v-384h-128v640h384v-128h128v128h128zM640 1408v-640h-640v640h640zM1408 1408v-640h-640v640h640z" /> -<glyph unicode="" horiz-adv-x="1792" d="M63 0h-63v1408h63v-1408zM126 1h-32v1407h32v-1407zM220 1h-31v1407h31v-1407zM377 1h-31v1407h31v-1407zM534 1h-62v1407h62v-1407zM660 1h-31v1407h31v-1407zM723 1h-31v1407h31v-1407zM786 1h-31v1407h31v-1407zM943 1h-63v1407h63v-1407zM1100 1h-63v1407h63v-1407z M1226 1h-63v1407h63v-1407zM1352 1h-63v1407h63v-1407zM1446 1h-63v1407h63v-1407zM1635 1h-94v1407h94v-1407zM1698 1h-32v1407h32v-1407zM1792 0h-63v1408h63v-1408z" /> -<glyph unicode="" d="M448 1088q0 53 -37.5 90.5t-90.5 37.5t-90.5 -37.5t-37.5 -90.5t37.5 -90.5t90.5 -37.5t90.5 37.5t37.5 90.5zM1515 512q0 -53 -37 -90l-491 -492q-39 -37 -91 -37q-53 0 -90 37l-715 716q-38 37 -64.5 101t-26.5 117v416q0 52 38 90t90 38h416q53 0 117 -26.5t102 -64.5 l715 -714q37 -39 37 -91z" /> -<glyph unicode="" horiz-adv-x="1920" d="M448 1088q0 53 -37.5 90.5t-90.5 37.5t-90.5 -37.5t-37.5 -90.5t37.5 -90.5t90.5 -37.5t90.5 37.5t37.5 90.5zM1515 512q0 -53 -37 -90l-491 -492q-39 -37 -91 -37q-53 0 -90 37l-715 716q-38 37 -64.5 101t-26.5 117v416q0 52 38 90t90 38h416q53 0 117 -26.5t102 -64.5 l715 -714q37 -39 37 -91zM1899 512q0 -53 -37 -90l-491 -492q-39 -37 -91 -37q-36 0 -59 14t-53 45l470 470q37 37 37 90q0 52 -37 91l-715 714q-38 38 -102 64.5t-117 26.5h224q53 0 117 -26.5t102 -64.5l715 -714q37 -39 37 -91z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1639 1058q40 -57 18 -129l-275 -906q-19 -64 -76.5 -107.5t-122.5 -43.5h-923q-77 0 -148.5 53.5t-99.5 131.5q-24 67 -2 127q0 4 3 27t4 37q1 8 -3 21.5t-3 19.5q2 11 8 21t16.5 23.5t16.5 23.5q23 38 45 91.5t30 91.5q3 10 0.5 30t-0.5 28q3 11 17 28t17 23 q21 36 42 92t25 90q1 9 -2.5 32t0.5 28q4 13 22 30.5t22 22.5q19 26 42.5 84.5t27.5 96.5q1 8 -3 25.5t-2 26.5q2 8 9 18t18 23t17 21q8 12 16.5 30.5t15 35t16 36t19.5 32t26.5 23.5t36 11.5t47.5 -5.5l-1 -3q38 9 51 9h761q74 0 114 -56t18 -130l-274 -906 q-36 -119 -71.5 -153.5t-128.5 -34.5h-869q-27 0 -38 -15q-11 -16 -1 -43q24 -70 144 -70h923q29 0 56 15.5t35 41.5l300 987q7 22 5 57q38 -15 59 -43zM575 1056q-4 -13 2 -22.5t20 -9.5h608q13 0 25.5 9.5t16.5 22.5l21 64q4 13 -2 22.5t-20 9.5h-608q-13 0 -25.5 -9.5 t-16.5 -22.5zM492 800q-4 -13 2 -22.5t20 -9.5h608q13 0 25.5 9.5t16.5 22.5l21 64q4 13 -2 22.5t-20 9.5h-608q-13 0 -25.5 -9.5t-16.5 -22.5z" /> -<glyph unicode="" horiz-adv-x="1280" d="M1164 1408q23 0 44 -9q33 -13 52.5 -41t19.5 -62v-1289q0 -34 -19.5 -62t-52.5 -41q-19 -8 -44 -8q-48 0 -83 32l-441 424l-441 -424q-36 -33 -83 -33q-23 0 -44 9q-33 13 -52.5 41t-19.5 62v1289q0 34 19.5 62t52.5 41q21 9 44 9h1048z" /> -<glyph unicode="" horiz-adv-x="1664" d="M384 0h896v256h-896v-256zM384 640h896v384h-160q-40 0 -68 28t-28 68v160h-640v-640zM1536 576q0 26 -19 45t-45 19t-45 -19t-19 -45t19 -45t45 -19t45 19t19 45zM1664 576v-416q0 -13 -9.5 -22.5t-22.5 -9.5h-224v-160q0 -40 -28 -68t-68 -28h-960q-40 0 -68 28t-28 68 v160h-224q-13 0 -22.5 9.5t-9.5 22.5v416q0 79 56.5 135.5t135.5 56.5h64v544q0 40 28 68t68 28h672q40 0 88 -20t76 -48l152 -152q28 -28 48 -76t20 -88v-256h64q79 0 135.5 -56.5t56.5 -135.5z" /> -<glyph unicode="" horiz-adv-x="1920" d="M960 864q119 0 203.5 -84.5t84.5 -203.5t-84.5 -203.5t-203.5 -84.5t-203.5 84.5t-84.5 203.5t84.5 203.5t203.5 84.5zM1664 1280q106 0 181 -75t75 -181v-896q0 -106 -75 -181t-181 -75h-1408q-106 0 -181 75t-75 181v896q0 106 75 181t181 75h224l51 136 q19 49 69.5 84.5t103.5 35.5h512q53 0 103.5 -35.5t69.5 -84.5l51 -136h224zM960 128q185 0 316.5 131.5t131.5 316.5t-131.5 316.5t-316.5 131.5t-316.5 -131.5t-131.5 -316.5t131.5 -316.5t316.5 -131.5z" /> -<glyph unicode="" horiz-adv-x="1664" d="M725 977l-170 -450q33 0 136.5 -2t160.5 -2q19 0 57 2q-87 253 -184 452zM0 -128l2 79q23 7 56 12.5t57 10.5t49.5 14.5t44.5 29t31 50.5l237 616l280 724h75h53q8 -14 11 -21l205 -480q33 -78 106 -257.5t114 -274.5q15 -34 58 -144.5t72 -168.5q20 -45 35 -57 q19 -15 88 -29.5t84 -20.5q6 -38 6 -57q0 -4 -0.5 -13t-0.5 -13q-63 0 -190 8t-191 8q-76 0 -215 -7t-178 -8q0 43 4 78l131 28q1 0 12.5 2.5t15.5 3.5t14.5 4.5t15 6.5t11 8t9 11t2.5 14q0 16 -31 96.5t-72 177.5t-42 100l-450 2q-26 -58 -76.5 -195.5t-50.5 -162.5 q0 -22 14 -37.5t43.5 -24.5t48.5 -13.5t57 -8.5t41 -4q1 -19 1 -58q0 -9 -2 -27q-58 0 -174.5 10t-174.5 10q-8 0 -26.5 -4t-21.5 -4q-80 -14 -188 -14z" /> -<glyph unicode="" horiz-adv-x="1408" d="M555 15q74 -32 140 -32q376 0 376 335q0 114 -41 180q-27 44 -61.5 74t-67.5 46.5t-80.5 25t-84 10.5t-94.5 2q-73 0 -101 -10q0 -53 -0.5 -159t-0.5 -158q0 -8 -1 -67.5t-0.5 -96.5t4.5 -83.5t12 -66.5zM541 761q42 -7 109 -7q82 0 143 13t110 44.5t74.5 89.5t25.5 142 q0 70 -29 122.5t-79 82t-108 43.5t-124 14q-50 0 -130 -13q0 -50 4 -151t4 -152q0 -27 -0.5 -80t-0.5 -79q0 -46 1 -69zM0 -128l2 94q15 4 85 16t106 27q7 12 12.5 27t8.5 33.5t5.5 32.5t3 37.5t0.5 34v35.5v30q0 982 -22 1025q-4 8 -22 14.5t-44.5 11t-49.5 7t-48.5 4.5 t-30.5 3l-4 83q98 2 340 11.5t373 9.5q23 0 68.5 -0.5t67.5 -0.5q70 0 136.5 -13t128.5 -42t108 -71t74 -104.5t28 -137.5q0 -52 -16.5 -95.5t-39 -72t-64.5 -57.5t-73 -45t-84 -40q154 -35 256.5 -134t102.5 -248q0 -100 -35 -179.5t-93.5 -130.5t-138 -85.5t-163.5 -48.5 t-176 -14q-44 0 -132 3t-132 3q-106 0 -307 -11t-231 -12z" /> -<glyph unicode="" horiz-adv-x="1024" d="M0 -126l17 85q6 2 81.5 21.5t111.5 37.5q28 35 41 101q1 7 62 289t114 543.5t52 296.5v25q-24 13 -54.5 18.5t-69.5 8t-58 5.5l19 103q33 -2 120 -6.5t149.5 -7t120.5 -2.5q48 0 98.5 2.5t121 7t98.5 6.5q-5 -39 -19 -89q-30 -10 -101.5 -28.5t-108.5 -33.5 q-8 -19 -14 -42.5t-9 -40t-7.5 -45.5t-6.5 -42q-27 -148 -87.5 -419.5t-77.5 -355.5q-2 -9 -13 -58t-20 -90t-16 -83.5t-6 -57.5l1 -18q17 -4 185 -31q-3 -44 -16 -99q-11 0 -32.5 -1.5t-32.5 -1.5q-29 0 -87 10t-86 10q-138 2 -206 2q-51 0 -143 -9t-121 -11z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1744 128q33 0 42 -18.5t-11 -44.5l-126 -162q-20 -26 -49 -26t-49 26l-126 162q-20 26 -11 44.5t42 18.5h80v1024h-80q-33 0 -42 18.5t11 44.5l126 162q20 26 49 26t49 -26l126 -162q20 -26 11 -44.5t-42 -18.5h-80v-1024h80zM81 1407l54 -27q12 -5 211 -5q44 0 132 2 t132 2q36 0 107.5 -0.5t107.5 -0.5h293q6 0 21 -0.5t20.5 0t16 3t17.5 9t15 17.5l42 1q4 0 14 -0.5t14 -0.5q2 -112 2 -336q0 -80 -5 -109q-39 -14 -68 -18q-25 44 -54 128q-3 9 -11 48t-14.5 73.5t-7.5 35.5q-6 8 -12 12.5t-15.5 6t-13 2.5t-18 0.5t-16.5 -0.5 q-17 0 -66.5 0.5t-74.5 0.5t-64 -2t-71 -6q-9 -81 -8 -136q0 -94 2 -388t2 -455q0 -16 -2.5 -71.5t0 -91.5t12.5 -69q40 -21 124 -42.5t120 -37.5q5 -40 5 -50q0 -14 -3 -29l-34 -1q-76 -2 -218 8t-207 10q-50 0 -151 -9t-152 -9q-3 51 -3 52v9q17 27 61.5 43t98.5 29t78 27 q19 42 19 383q0 101 -3 303t-3 303v117q0 2 0.5 15.5t0.5 25t-1 25.5t-3 24t-5 14q-11 12 -162 12q-33 0 -93 -12t-80 -26q-19 -13 -34 -72.5t-31.5 -111t-42.5 -53.5q-42 26 -56 44v383z" /> -<glyph unicode="" d="M81 1407l54 -27q12 -5 211 -5q44 0 132 2t132 2q70 0 246.5 1t304.5 0.5t247 -4.5q33 -1 56 31l42 1q4 0 14 -0.5t14 -0.5q2 -112 2 -336q0 -80 -5 -109q-39 -14 -68 -18q-25 44 -54 128q-3 9 -11 47.5t-15 73.5t-7 36q-10 13 -27 19q-5 2 -66 2q-30 0 -93 1t-103 1 t-94 -2t-96 -7q-9 -81 -8 -136l1 -152v52q0 -55 1 -154t1.5 -180t0.5 -153q0 -16 -2.5 -71.5t0 -91.5t12.5 -69q40 -21 124 -42.5t120 -37.5q5 -40 5 -50q0 -14 -3 -29l-34 -1q-76 -2 -218 8t-207 10q-50 0 -151 -9t-152 -9q-3 51 -3 52v9q17 27 61.5 43t98.5 29t78 27 q7 16 11.5 74t6 145.5t1.5 155t-0.5 153.5t-0.5 89q0 7 -2.5 21.5t-2.5 22.5q0 7 0.5 44t1 73t0 76.5t-3 67.5t-6.5 32q-11 12 -162 12q-41 0 -163 -13.5t-138 -24.5q-19 -12 -34 -71.5t-31.5 -111.5t-42.5 -54q-42 26 -56 44v383zM1310 125q12 0 42 -19.5t57.5 -41.5 t59.5 -49t36 -30q26 -21 26 -49t-26 -49q-4 -3 -36 -30t-59.5 -49t-57.5 -41.5t-42 -19.5q-13 0 -20.5 10.5t-10 28.5t-2.5 33.5t1.5 33t1.5 19.5h-1024q0 -2 1.5 -19.5t1.5 -33t-2.5 -33.5t-10 -28.5t-20.5 -10.5q-12 0 -42 19.5t-57.5 41.5t-59.5 49t-36 30q-26 21 -26 49 t26 49q4 3 36 30t59.5 49t57.5 41.5t42 19.5q13 0 20.5 -10.5t10 -28.5t2.5 -33.5t-1.5 -33t-1.5 -19.5h1024q0 2 -1.5 19.5t-1.5 33t2.5 33.5t10 28.5t20.5 10.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1792 192v-128q0 -26 -19 -45t-45 -19h-1664q-26 0 -45 19t-19 45v128q0 26 19 45t45 19h1664q26 0 45 -19t19 -45zM1408 576v-128q0 -26 -19 -45t-45 -19h-1280q-26 0 -45 19t-19 45v128q0 26 19 45t45 19h1280q26 0 45 -19t19 -45zM1664 960v-128q0 -26 -19 -45 t-45 -19h-1536q-26 0 -45 19t-19 45v128q0 26 19 45t45 19h1536q26 0 45 -19t19 -45zM1280 1344v-128q0 -26 -19 -45t-45 -19h-1152q-26 0 -45 19t-19 45v128q0 26 19 45t45 19h1152q26 0 45 -19t19 -45z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1792 192v-128q0 -26 -19 -45t-45 -19h-1664q-26 0 -45 19t-19 45v128q0 26 19 45t45 19h1664q26 0 45 -19t19 -45zM1408 576v-128q0 -26 -19 -45t-45 -19h-896q-26 0 -45 19t-19 45v128q0 26 19 45t45 19h896q26 0 45 -19t19 -45zM1664 960v-128q0 -26 -19 -45t-45 -19 h-1408q-26 0 -45 19t-19 45v128q0 26 19 45t45 19h1408q26 0 45 -19t19 -45zM1280 1344v-128q0 -26 -19 -45t-45 -19h-640q-26 0 -45 19t-19 45v128q0 26 19 45t45 19h640q26 0 45 -19t19 -45z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1792 192v-128q0 -26 -19 -45t-45 -19h-1664q-26 0 -45 19t-19 45v128q0 26 19 45t45 19h1664q26 0 45 -19t19 -45zM1792 576v-128q0 -26 -19 -45t-45 -19h-1280q-26 0 -45 19t-19 45v128q0 26 19 45t45 19h1280q26 0 45 -19t19 -45zM1792 960v-128q0 -26 -19 -45 t-45 -19h-1536q-26 0 -45 19t-19 45v128q0 26 19 45t45 19h1536q26 0 45 -19t19 -45zM1792 1344v-128q0 -26 -19 -45t-45 -19h-1152q-26 0 -45 19t-19 45v128q0 26 19 45t45 19h1152q26 0 45 -19t19 -45z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1792 192v-128q0 -26 -19 -45t-45 -19h-1664q-26 0 -45 19t-19 45v128q0 26 19 45t45 19h1664q26 0 45 -19t19 -45zM1792 576v-128q0 -26 -19 -45t-45 -19h-1664q-26 0 -45 19t-19 45v128q0 26 19 45t45 19h1664q26 0 45 -19t19 -45zM1792 960v-128q0 -26 -19 -45 t-45 -19h-1664q-26 0 -45 19t-19 45v128q0 26 19 45t45 19h1664q26 0 45 -19t19 -45zM1792 1344v-128q0 -26 -19 -45t-45 -19h-1664q-26 0 -45 19t-19 45v128q0 26 19 45t45 19h1664q26 0 45 -19t19 -45z" /> -<glyph unicode="" horiz-adv-x="1792" d="M256 224v-192q0 -13 -9.5 -22.5t-22.5 -9.5h-192q-13 0 -22.5 9.5t-9.5 22.5v192q0 13 9.5 22.5t22.5 9.5h192q13 0 22.5 -9.5t9.5 -22.5zM256 608v-192q0 -13 -9.5 -22.5t-22.5 -9.5h-192q-13 0 -22.5 9.5t-9.5 22.5v192q0 13 9.5 22.5t22.5 9.5h192q13 0 22.5 -9.5 t9.5 -22.5zM256 992v-192q0 -13 -9.5 -22.5t-22.5 -9.5h-192q-13 0 -22.5 9.5t-9.5 22.5v192q0 13 9.5 22.5t22.5 9.5h192q13 0 22.5 -9.5t9.5 -22.5zM1792 224v-192q0 -13 -9.5 -22.5t-22.5 -9.5h-1344q-13 0 -22.5 9.5t-9.5 22.5v192q0 13 9.5 22.5t22.5 9.5h1344 q13 0 22.5 -9.5t9.5 -22.5zM256 1376v-192q0 -13 -9.5 -22.5t-22.5 -9.5h-192q-13 0 -22.5 9.5t-9.5 22.5v192q0 13 9.5 22.5t22.5 9.5h192q13 0 22.5 -9.5t9.5 -22.5zM1792 608v-192q0 -13 -9.5 -22.5t-22.5 -9.5h-1344q-13 0 -22.5 9.5t-9.5 22.5v192q0 13 9.5 22.5 t22.5 9.5h1344q13 0 22.5 -9.5t9.5 -22.5zM1792 992v-192q0 -13 -9.5 -22.5t-22.5 -9.5h-1344q-13 0 -22.5 9.5t-9.5 22.5v192q0 13 9.5 22.5t22.5 9.5h1344q13 0 22.5 -9.5t9.5 -22.5zM1792 1376v-192q0 -13 -9.5 -22.5t-22.5 -9.5h-1344q-13 0 -22.5 9.5t-9.5 22.5v192 q0 13 9.5 22.5t22.5 9.5h1344q13 0 22.5 -9.5t9.5 -22.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M384 992v-576q0 -13 -9.5 -22.5t-22.5 -9.5q-14 0 -23 9l-288 288q-9 9 -9 23t9 23l288 288q9 9 23 9q13 0 22.5 -9.5t9.5 -22.5zM1792 224v-192q0 -13 -9.5 -22.5t-22.5 -9.5h-1728q-13 0 -22.5 9.5t-9.5 22.5v192q0 13 9.5 22.5t22.5 9.5h1728q13 0 22.5 -9.5 t9.5 -22.5zM1792 608v-192q0 -13 -9.5 -22.5t-22.5 -9.5h-1088q-13 0 -22.5 9.5t-9.5 22.5v192q0 13 9.5 22.5t22.5 9.5h1088q13 0 22.5 -9.5t9.5 -22.5zM1792 992v-192q0 -13 -9.5 -22.5t-22.5 -9.5h-1088q-13 0 -22.5 9.5t-9.5 22.5v192q0 13 9.5 22.5t22.5 9.5h1088 q13 0 22.5 -9.5t9.5 -22.5zM1792 1376v-192q0 -13 -9.5 -22.5t-22.5 -9.5h-1728q-13 0 -22.5 9.5t-9.5 22.5v192q0 13 9.5 22.5t22.5 9.5h1728q13 0 22.5 -9.5t9.5 -22.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M352 704q0 -14 -9 -23l-288 -288q-9 -9 -23 -9q-13 0 -22.5 9.5t-9.5 22.5v576q0 13 9.5 22.5t22.5 9.5q14 0 23 -9l288 -288q9 -9 9 -23zM1792 224v-192q0 -13 -9.5 -22.5t-22.5 -9.5h-1728q-13 0 -22.5 9.5t-9.5 22.5v192q0 13 9.5 22.5t22.5 9.5h1728q13 0 22.5 -9.5 t9.5 -22.5zM1792 608v-192q0 -13 -9.5 -22.5t-22.5 -9.5h-1088q-13 0 -22.5 9.5t-9.5 22.5v192q0 13 9.5 22.5t22.5 9.5h1088q13 0 22.5 -9.5t9.5 -22.5zM1792 992v-192q0 -13 -9.5 -22.5t-22.5 -9.5h-1088q-13 0 -22.5 9.5t-9.5 22.5v192q0 13 9.5 22.5t22.5 9.5h1088 q13 0 22.5 -9.5t9.5 -22.5zM1792 1376v-192q0 -13 -9.5 -22.5t-22.5 -9.5h-1728q-13 0 -22.5 9.5t-9.5 22.5v192q0 13 9.5 22.5t22.5 9.5h1728q13 0 22.5 -9.5t9.5 -22.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1792 1184v-1088q0 -42 -39 -59q-13 -5 -25 -5q-27 0 -45 19l-403 403v-166q0 -119 -84.5 -203.5t-203.5 -84.5h-704q-119 0 -203.5 84.5t-84.5 203.5v704q0 119 84.5 203.5t203.5 84.5h704q119 0 203.5 -84.5t84.5 -203.5v-165l403 402q18 19 45 19q12 0 25 -5 q39 -17 39 -59z" /> -<glyph unicode="" horiz-adv-x="1920" d="M640 960q0 -80 -56 -136t-136 -56t-136 56t-56 136t56 136t136 56t136 -56t56 -136zM1664 576v-448h-1408v192l320 320l160 -160l512 512zM1760 1280h-1600q-13 0 -22.5 -9.5t-9.5 -22.5v-1216q0 -13 9.5 -22.5t22.5 -9.5h1600q13 0 22.5 9.5t9.5 22.5v1216 q0 13 -9.5 22.5t-22.5 9.5zM1920 1248v-1216q0 -66 -47 -113t-113 -47h-1600q-66 0 -113 47t-47 113v1216q0 66 47 113t113 47h1600q66 0 113 -47t47 -113z" /> -<glyph unicode="" d="M363 0l91 91l-235 235l-91 -91v-107h128v-128h107zM886 928q0 22 -22 22q-10 0 -17 -7l-542 -542q-7 -7 -7 -17q0 -22 22 -22q10 0 17 7l542 542q7 7 7 17zM832 1120l416 -416l-832 -832h-416v416zM1515 1024q0 -53 -37 -90l-166 -166l-416 416l166 165q36 38 90 38 q53 0 91 -38l235 -234q37 -39 37 -91z" /> -<glyph unicode="" horiz-adv-x="1024" d="M768 896q0 106 -75 181t-181 75t-181 -75t-75 -181t75 -181t181 -75t181 75t75 181zM1024 896q0 -109 -33 -179l-364 -774q-16 -33 -47.5 -52t-67.5 -19t-67.5 19t-46.5 52l-365 774q-33 70 -33 179q0 212 150 362t362 150t362 -150t150 -362z" /> -<glyph unicode="" d="M768 96v1088q-148 0 -273 -73t-198 -198t-73 -273t73 -273t198 -198t273 -73zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" horiz-adv-x="1024" d="M512 384q0 36 -20 69q-1 1 -15.5 22.5t-25.5 38t-25 44t-21 50.5q-4 16 -21 16t-21 -16q-7 -23 -21 -50.5t-25 -44t-25.5 -38t-15.5 -22.5q-20 -33 -20 -69q0 -53 37.5 -90.5t90.5 -37.5t90.5 37.5t37.5 90.5zM1024 512q0 -212 -150 -362t-362 -150t-362 150t-150 362 q0 145 81 275q6 9 62.5 90.5t101 151t99.5 178t83 201.5q9 30 34 47t51 17t51.5 -17t33.5 -47q28 -93 83 -201.5t99.5 -178t101 -151t62.5 -90.5q81 -127 81 -275z" /> -<glyph unicode="" horiz-adv-x="1792" d="M888 352l116 116l-152 152l-116 -116v-56h96v-96h56zM1328 1072q-16 16 -33 -1l-350 -350q-17 -17 -1 -33t33 1l350 350q17 17 1 33zM1408 478v-190q0 -119 -84.5 -203.5t-203.5 -84.5h-832q-119 0 -203.5 84.5t-84.5 203.5v832q0 119 84.5 203.5t203.5 84.5h832 q63 0 117 -25q15 -7 18 -23q3 -17 -9 -29l-49 -49q-14 -14 -32 -8q-23 6 -45 6h-832q-66 0 -113 -47t-47 -113v-832q0 -66 47 -113t113 -47h832q66 0 113 47t47 113v126q0 13 9 22l64 64q15 15 35 7t20 -29zM1312 1216l288 -288l-672 -672h-288v288zM1756 1084l-92 -92 l-288 288l92 92q28 28 68 28t68 -28l152 -152q28 -28 28 -68t-28 -68z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1408 547v-259q0 -119 -84.5 -203.5t-203.5 -84.5h-832q-119 0 -203.5 84.5t-84.5 203.5v832q0 119 84.5 203.5t203.5 84.5h255v0q13 0 22.5 -9.5t9.5 -22.5q0 -27 -26 -32q-77 -26 -133 -60q-10 -4 -16 -4h-112q-66 0 -113 -47t-47 -113v-832q0 -66 47 -113t113 -47h832 q66 0 113 47t47 113v214q0 19 18 29q28 13 54 37q16 16 35 8q21 -9 21 -29zM1645 1043l-384 -384q-18 -19 -45 -19q-12 0 -25 5q-39 17 -39 59v192h-160q-323 0 -438 -131q-119 -137 -74 -473q3 -23 -20 -34q-8 -2 -12 -2q-16 0 -26 13q-10 14 -21 31t-39.5 68.5t-49.5 99.5 t-38.5 114t-17.5 122q0 49 3.5 91t14 90t28 88t47 81.5t68.5 74t94.5 61.5t124.5 48.5t159.5 30.5t196.5 11h160v192q0 42 39 59q13 5 25 5q26 0 45 -19l384 -384q19 -19 19 -45t-19 -45z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1408 606v-318q0 -119 -84.5 -203.5t-203.5 -84.5h-832q-119 0 -203.5 84.5t-84.5 203.5v832q0 119 84.5 203.5t203.5 84.5h832q63 0 117 -25q15 -7 18 -23q3 -17 -9 -29l-49 -49q-10 -10 -23 -10q-3 0 -9 2q-23 6 -45 6h-832q-66 0 -113 -47t-47 -113v-832 q0 -66 47 -113t113 -47h832q66 0 113 47t47 113v254q0 13 9 22l64 64q10 10 23 10q6 0 12 -3q20 -8 20 -29zM1639 1095l-814 -814q-24 -24 -57 -24t-57 24l-430 430q-24 24 -24 57t24 57l110 110q24 24 57 24t57 -24l263 -263l647 647q24 24 57 24t57 -24l110 -110 q24 -24 24 -57t-24 -57z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1792 640q0 -26 -19 -45l-256 -256q-19 -19 -45 -19t-45 19t-19 45v128h-384v-384h128q26 0 45 -19t19 -45t-19 -45l-256 -256q-19 -19 -45 -19t-45 19l-256 256q-19 19 -19 45t19 45t45 19h128v384h-384v-128q0 -26 -19 -45t-45 -19t-45 19l-256 256q-19 19 -19 45 t19 45l256 256q19 19 45 19t45 -19t19 -45v-128h384v384h-128q-26 0 -45 19t-19 45t19 45l256 256q19 19 45 19t45 -19l256 -256q19 -19 19 -45t-19 -45t-45 -19h-128v-384h384v128q0 26 19 45t45 19t45 -19l256 -256q19 -19 19 -45z" /> -<glyph unicode="" horiz-adv-x="1024" d="M979 1395q19 19 32 13t13 -32v-1472q0 -26 -13 -32t-32 13l-710 710q-9 9 -13 19v-678q0 -26 -19 -45t-45 -19h-128q-26 0 -45 19t-19 45v1408q0 26 19 45t45 19h128q26 0 45 -19t19 -45v-678q4 11 13 19z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1747 1395q19 19 32 13t13 -32v-1472q0 -26 -13 -32t-32 13l-710 710q-9 9 -13 19v-710q0 -26 -13 -32t-32 13l-710 710q-9 9 -13 19v-678q0 -26 -19 -45t-45 -19h-128q-26 0 -45 19t-19 45v1408q0 26 19 45t45 19h128q26 0 45 -19t19 -45v-678q4 11 13 19l710 710 q19 19 32 13t13 -32v-710q4 11 13 19z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1619 1395q19 19 32 13t13 -32v-1472q0 -26 -13 -32t-32 13l-710 710q-8 9 -13 19v-710q0 -26 -13 -32t-32 13l-710 710q-19 19 -19 45t19 45l710 710q19 19 32 13t13 -32v-710q5 11 13 19z" /> -<glyph unicode="" horiz-adv-x="1408" d="M1384 609l-1328 -738q-23 -13 -39.5 -3t-16.5 36v1472q0 26 16.5 36t39.5 -3l1328 -738q23 -13 23 -31t-23 -31z" /> -<glyph unicode="" d="M1536 1344v-1408q0 -26 -19 -45t-45 -19h-512q-26 0 -45 19t-19 45v1408q0 26 19 45t45 19h512q26 0 45 -19t19 -45zM640 1344v-1408q0 -26 -19 -45t-45 -19h-512q-26 0 -45 19t-19 45v1408q0 26 19 45t45 19h512q26 0 45 -19t19 -45z" /> -<glyph unicode="" d="M1536 1344v-1408q0 -26 -19 -45t-45 -19h-1408q-26 0 -45 19t-19 45v1408q0 26 19 45t45 19h1408q26 0 45 -19t19 -45z" /> -<glyph unicode="" horiz-adv-x="1664" d="M45 -115q-19 -19 -32 -13t-13 32v1472q0 26 13 32t32 -13l710 -710q8 -8 13 -19v710q0 26 13 32t32 -13l710 -710q19 -19 19 -45t-19 -45l-710 -710q-19 -19 -32 -13t-13 32v710q-5 -10 -13 -19z" /> -<glyph unicode="" horiz-adv-x="1792" d="M45 -115q-19 -19 -32 -13t-13 32v1472q0 26 13 32t32 -13l710 -710q8 -8 13 -19v710q0 26 13 32t32 -13l710 -710q8 -8 13 -19v678q0 26 19 45t45 19h128q26 0 45 -19t19 -45v-1408q0 -26 -19 -45t-45 -19h-128q-26 0 -45 19t-19 45v678q-5 -10 -13 -19l-710 -710 q-19 -19 -32 -13t-13 32v710q-5 -10 -13 -19z" /> -<glyph unicode="" horiz-adv-x="1024" d="M45 -115q-19 -19 -32 -13t-13 32v1472q0 26 13 32t32 -13l710 -710q8 -8 13 -19v678q0 26 19 45t45 19h128q26 0 45 -19t19 -45v-1408q0 -26 -19 -45t-45 -19h-128q-26 0 -45 19t-19 45v678q-5 -10 -13 -19z" /> -<glyph unicode="" horiz-adv-x="1538" d="M14 557l710 710q19 19 45 19t45 -19l710 -710q19 -19 13 -32t-32 -13h-1472q-26 0 -32 13t13 32zM1473 0h-1408q-26 0 -45 19t-19 45v256q0 26 19 45t45 19h1408q26 0 45 -19t19 -45v-256q0 -26 -19 -45t-45 -19z" /> -<glyph unicode="" horiz-adv-x="1280" d="M1171 1235l-531 -531l531 -531q19 -19 19 -45t-19 -45l-166 -166q-19 -19 -45 -19t-45 19l-742 742q-19 19 -19 45t19 45l742 742q19 19 45 19t45 -19l166 -166q19 -19 19 -45t-19 -45z" /> -<glyph unicode="" horiz-adv-x="1280" d="M1107 659l-742 -742q-19 -19 -45 -19t-45 19l-166 166q-19 19 -19 45t19 45l531 531l-531 531q-19 19 -19 45t19 45l166 166q19 19 45 19t45 -19l742 -742q19 -19 19 -45t-19 -45z" /> -<glyph unicode="" d="M1216 576v128q0 26 -19 45t-45 19h-256v256q0 26 -19 45t-45 19h-128q-26 0 -45 -19t-19 -45v-256h-256q-26 0 -45 -19t-19 -45v-128q0 -26 19 -45t45 -19h256v-256q0 -26 19 -45t45 -19h128q26 0 45 19t19 45v256h256q26 0 45 19t19 45zM1536 640q0 -209 -103 -385.5 t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" d="M1216 576v128q0 26 -19 45t-45 19h-768q-26 0 -45 -19t-19 -45v-128q0 -26 19 -45t45 -19h768q26 0 45 19t19 45zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5 t103 -385.5z" /> -<glyph unicode="" d="M1149 414q0 26 -19 45l-181 181l181 181q19 19 19 45q0 27 -19 46l-90 90q-19 19 -46 19q-26 0 -45 -19l-181 -181l-181 181q-19 19 -45 19q-27 0 -46 -19l-90 -90q-19 -19 -19 -46q0 -26 19 -45l181 -181l-181 -181q-19 -19 -19 -45q0 -27 19 -46l90 -90q19 -19 46 -19 q26 0 45 19l181 181l181 -181q19 -19 45 -19q27 0 46 19l90 90q19 19 19 46zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" d="M1284 802q0 28 -18 46l-91 90q-19 19 -45 19t-45 -19l-408 -407l-226 226q-19 19 -45 19t-45 -19l-91 -90q-18 -18 -18 -46q0 -27 18 -45l362 -362q19 -19 45 -19q27 0 46 19l543 543q18 18 18 45zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103 t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" d="M896 160v192q0 14 -9 23t-23 9h-192q-14 0 -23 -9t-9 -23v-192q0 -14 9 -23t23 -9h192q14 0 23 9t9 23zM1152 832q0 88 -55.5 163t-138.5 116t-170 41q-243 0 -371 -213q-15 -24 8 -42l132 -100q7 -6 19 -6q16 0 25 12q53 68 86 92q34 24 86 24q48 0 85.5 -26t37.5 -59 q0 -38 -20 -61t-68 -45q-63 -28 -115.5 -86.5t-52.5 -125.5v-36q0 -14 9 -23t23 -9h192q14 0 23 9t9 23q0 19 21.5 49.5t54.5 49.5q32 18 49 28.5t46 35t44.5 48t28 60.5t12.5 81zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5 t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" d="M1024 160v160q0 14 -9 23t-23 9h-96v512q0 14 -9 23t-23 9h-320q-14 0 -23 -9t-9 -23v-160q0 -14 9 -23t23 -9h96v-320h-96q-14 0 -23 -9t-9 -23v-160q0 -14 9 -23t23 -9h448q14 0 23 9t9 23zM896 1056v160q0 14 -9 23t-23 9h-192q-14 0 -23 -9t-9 -23v-160q0 -14 9 -23 t23 -9h192q14 0 23 9t9 23zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" d="M1197 512h-109q-26 0 -45 19t-19 45v128q0 26 19 45t45 19h109q-32 108 -112.5 188.5t-188.5 112.5v-109q0 -26 -19 -45t-45 -19h-128q-26 0 -45 19t-19 45v109q-108 -32 -188.5 -112.5t-112.5 -188.5h109q26 0 45 -19t19 -45v-128q0 -26 -19 -45t-45 -19h-109 q32 -108 112.5 -188.5t188.5 -112.5v109q0 26 19 45t45 19h128q26 0 45 -19t19 -45v-109q108 32 188.5 112.5t112.5 188.5zM1536 704v-128q0 -26 -19 -45t-45 -19h-143q-37 -161 -154.5 -278.5t-278.5 -154.5v-143q0 -26 -19 -45t-45 -19h-128q-26 0 -45 19t-19 45v143 q-161 37 -278.5 154.5t-154.5 278.5h-143q-26 0 -45 19t-19 45v128q0 26 19 45t45 19h143q37 161 154.5 278.5t278.5 154.5v143q0 26 19 45t45 19h128q26 0 45 -19t19 -45v-143q161 -37 278.5 -154.5t154.5 -278.5h143q26 0 45 -19t19 -45z" /> -<glyph unicode="" d="M1097 457l-146 -146q-10 -10 -23 -10t-23 10l-137 137l-137 -137q-10 -10 -23 -10t-23 10l-146 146q-10 10 -10 23t10 23l137 137l-137 137q-10 10 -10 23t10 23l146 146q10 10 23 10t23 -10l137 -137l137 137q10 10 23 10t23 -10l146 -146q10 -10 10 -23t-10 -23 l-137 -137l137 -137q10 -10 10 -23t-10 -23zM1312 640q0 148 -73 273t-198 198t-273 73t-273 -73t-198 -198t-73 -273t73 -273t198 -198t273 -73t273 73t198 198t73 273zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5 t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" d="M1171 723l-422 -422q-19 -19 -45 -19t-45 19l-294 294q-19 19 -19 45t19 45l102 102q19 19 45 19t45 -19l147 -147l275 275q19 19 45 19t45 -19l102 -102q19 -19 19 -45t-19 -45zM1312 640q0 148 -73 273t-198 198t-273 73t-273 -73t-198 -198t-73 -273t73 -273t198 -198 t273 -73t273 73t198 198t73 273zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" d="M1312 643q0 161 -87 295l-754 -753q137 -89 297 -89q111 0 211.5 43.5t173.5 116.5t116 174.5t43 212.5zM313 344l755 754q-135 91 -300 91q-148 0 -273 -73t-198 -199t-73 -274q0 -162 89 -299zM1536 643q0 -157 -61 -300t-163.5 -246t-245 -164t-298.5 -61t-298.5 61 t-245 164t-163.5 246t-61 300t61 299.5t163.5 245.5t245 164t298.5 61t298.5 -61t245 -164t163.5 -245.5t61 -299.5z" /> -<glyph unicode="" d="M1536 640v-128q0 -53 -32.5 -90.5t-84.5 -37.5h-704l293 -294q38 -36 38 -90t-38 -90l-75 -76q-37 -37 -90 -37q-52 0 -91 37l-651 652q-37 37 -37 90q0 52 37 91l651 650q38 38 91 38q52 0 90 -38l75 -74q38 -38 38 -91t-38 -91l-293 -293h704q52 0 84.5 -37.5 t32.5 -90.5z" /> -<glyph unicode="" d="M1472 576q0 -54 -37 -91l-651 -651q-39 -37 -91 -37q-51 0 -90 37l-75 75q-38 38 -38 91t38 91l293 293h-704q-52 0 -84.5 37.5t-32.5 90.5v128q0 53 32.5 90.5t84.5 37.5h704l-293 294q-38 36 -38 90t38 90l75 75q38 38 90 38q53 0 91 -38l651 -651q37 -35 37 -90z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1611 565q0 -51 -37 -90l-75 -75q-38 -38 -91 -38q-54 0 -90 38l-294 293v-704q0 -52 -37.5 -84.5t-90.5 -32.5h-128q-53 0 -90.5 32.5t-37.5 84.5v704l-294 -293q-36 -38 -90 -38t-90 38l-75 75q-38 38 -38 90q0 53 38 91l651 651q35 37 90 37q54 0 91 -37l651 -651 q37 -39 37 -91z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1611 704q0 -53 -37 -90l-651 -652q-39 -37 -91 -37q-53 0 -90 37l-651 652q-38 36 -38 90q0 53 38 91l74 75q39 37 91 37q53 0 90 -37l294 -294v704q0 52 38 90t90 38h128q52 0 90 -38t38 -90v-704l294 294q37 37 90 37q52 0 91 -37l75 -75q37 -39 37 -91z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1792 896q0 -26 -19 -45l-512 -512q-19 -19 -45 -19t-45 19t-19 45v256h-224q-98 0 -175.5 -6t-154 -21.5t-133 -42.5t-105.5 -69.5t-80 -101t-48.5 -138.5t-17.5 -181q0 -55 5 -123q0 -6 2.5 -23.5t2.5 -26.5q0 -15 -8.5 -25t-23.5 -10q-16 0 -28 17q-7 9 -13 22 t-13.5 30t-10.5 24q-127 285 -127 451q0 199 53 333q162 403 875 403h224v256q0 26 19 45t45 19t45 -19l512 -512q19 -19 19 -45z" /> -<glyph unicode="" d="M755 480q0 -13 -10 -23l-332 -332l144 -144q19 -19 19 -45t-19 -45t-45 -19h-448q-26 0 -45 19t-19 45v448q0 26 19 45t45 19t45 -19l144 -144l332 332q10 10 23 10t23 -10l114 -114q10 -10 10 -23zM1536 1344v-448q0 -26 -19 -45t-45 -19t-45 19l-144 144l-332 -332 q-10 -10 -23 -10t-23 10l-114 114q-10 10 -10 23t10 23l332 332l-144 144q-19 19 -19 45t19 45t45 19h448q26 0 45 -19t19 -45z" /> -<glyph unicode="" d="M768 576v-448q0 -26 -19 -45t-45 -19t-45 19l-144 144l-332 -332q-10 -10 -23 -10t-23 10l-114 114q-10 10 -10 23t10 23l332 332l-144 144q-19 19 -19 45t19 45t45 19h448q26 0 45 -19t19 -45zM1523 1248q0 -13 -10 -23l-332 -332l144 -144q19 -19 19 -45t-19 -45 t-45 -19h-448q-26 0 -45 19t-19 45v448q0 26 19 45t45 19t45 -19l144 -144l332 332q10 10 23 10t23 -10l114 -114q10 -10 10 -23z" /> -<glyph unicode="" horiz-adv-x="1408" d="M1408 800v-192q0 -40 -28 -68t-68 -28h-416v-416q0 -40 -28 -68t-68 -28h-192q-40 0 -68 28t-28 68v416h-416q-40 0 -68 28t-28 68v192q0 40 28 68t68 28h416v416q0 40 28 68t68 28h192q40 0 68 -28t28 -68v-416h416q40 0 68 -28t28 -68z" /> -<glyph unicode="" horiz-adv-x="1408" d="M1408 800v-192q0 -40 -28 -68t-68 -28h-1216q-40 0 -68 28t-28 68v192q0 40 28 68t68 28h1216q40 0 68 -28t28 -68z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1482 486q46 -26 59.5 -77.5t-12.5 -97.5l-64 -110q-26 -46 -77.5 -59.5t-97.5 12.5l-266 153v-307q0 -52 -38 -90t-90 -38h-128q-52 0 -90 38t-38 90v307l-266 -153q-46 -26 -97.5 -12.5t-77.5 59.5l-64 110q-26 46 -12.5 97.5t59.5 77.5l266 154l-266 154 q-46 26 -59.5 77.5t12.5 97.5l64 110q26 46 77.5 59.5t97.5 -12.5l266 -153v307q0 52 38 90t90 38h128q52 0 90 -38t38 -90v-307l266 153q46 26 97.5 12.5t77.5 -59.5l64 -110q26 -46 12.5 -97.5t-59.5 -77.5l-266 -154z" /> -<glyph unicode="" d="M768 1408q209 0 385.5 -103t279.5 -279.5t103 -385.5t-103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103zM896 161v190q0 14 -9 23.5t-22 9.5h-192q-13 0 -23 -10t-10 -23v-190q0 -13 10 -23t23 -10h192 q13 0 22 9.5t9 23.5zM894 505l18 621q0 12 -10 18q-10 8 -24 8h-220q-14 0 -24 -8q-10 -6 -10 -18l17 -621q0 -10 10 -17.5t24 -7.5h185q14 0 23.5 7.5t10.5 17.5z" /> -<glyph unicode="" d="M928 180v56v468v192h-320v-192v-468v-56q0 -25 18 -38.5t46 -13.5h192q28 0 46 13.5t18 38.5zM472 1024h195l-126 161q-26 31 -69 31q-40 0 -68 -28t-28 -68t28 -68t68 -28zM1160 1120q0 40 -28 68t-68 28q-43 0 -69 -31l-125 -161h194q40 0 68 28t28 68zM1536 864v-320 q0 -14 -9 -23t-23 -9h-96v-416q0 -40 -28 -68t-68 -28h-1088q-40 0 -68 28t-28 68v416h-96q-14 0 -23 9t-9 23v320q0 14 9 23t23 9h440q-93 0 -158.5 65.5t-65.5 158.5t65.5 158.5t158.5 65.5q107 0 168 -77l128 -165l128 165q61 77 168 77q93 0 158.5 -65.5t65.5 -158.5 t-65.5 -158.5t-158.5 -65.5h440q14 0 23 -9t9 -23z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1280 832q0 26 -19 45t-45 19q-172 0 -318 -49.5t-259.5 -134t-235.5 -219.5q-19 -21 -19 -45q0 -26 19 -45t45 -19q24 0 45 19q27 24 74 71t67 66q137 124 268.5 176t313.5 52q26 0 45 19t19 45zM1792 1030q0 -95 -20 -193q-46 -224 -184.5 -383t-357.5 -268 q-214 -108 -438 -108q-148 0 -286 47q-15 5 -88 42t-96 37q-16 0 -39.5 -32t-45 -70t-52.5 -70t-60 -32q-30 0 -51 11t-31 24t-27 42q-2 4 -6 11t-5.5 10t-3 9.5t-1.5 13.5q0 35 31 73.5t68 65.5t68 56t31 48q0 4 -14 38t-16 44q-9 51 -9 104q0 115 43.5 220t119 184.5 t170.5 139t204 95.5q55 18 145 25.5t179.5 9t178.5 6t163.5 24t113.5 56.5l29.5 29.5t29.5 28t27 20t36.5 16t43.5 4.5q39 0 70.5 -46t47.5 -112t24 -124t8 -96z" /> -<glyph unicode="" horiz-adv-x="1408" d="M1408 -160v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-1344q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h1344q13 0 22.5 -9.5t9.5 -22.5zM1152 896q0 -78 -24.5 -144t-64 -112.5t-87.5 -88t-96 -77.5t-87.5 -72t-64 -81.5t-24.5 -96.5q0 -96 67 -224l-4 1l1 -1 q-90 41 -160 83t-138.5 100t-113.5 122.5t-72.5 150.5t-27.5 184q0 78 24.5 144t64 112.5t87.5 88t96 77.5t87.5 72t64 81.5t24.5 96.5q0 94 -66 224l3 -1l-1 1q90 -41 160 -83t138.5 -100t113.5 -122.5t72.5 -150.5t27.5 -184z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1664 576q-152 236 -381 353q61 -104 61 -225q0 -185 -131.5 -316.5t-316.5 -131.5t-316.5 131.5t-131.5 316.5q0 121 61 225q-229 -117 -381 -353q133 -205 333.5 -326.5t434.5 -121.5t434.5 121.5t333.5 326.5zM944 960q0 20 -14 34t-34 14q-125 0 -214.5 -89.5 t-89.5 -214.5q0 -20 14 -34t34 -14t34 14t14 34q0 86 61 147t147 61q20 0 34 14t14 34zM1792 576q0 -34 -20 -69q-140 -230 -376.5 -368.5t-499.5 -138.5t-499.5 139t-376.5 368q-20 35 -20 69t20 69q140 229 376.5 368t499.5 139t499.5 -139t376.5 -368q20 -35 20 -69z" /> -<glyph unicode="" horiz-adv-x="1792" d="M555 201l78 141q-87 63 -136 159t-49 203q0 121 61 225q-229 -117 -381 -353q167 -258 427 -375zM944 960q0 20 -14 34t-34 14q-125 0 -214.5 -89.5t-89.5 -214.5q0 -20 14 -34t34 -14t34 14t14 34q0 86 61 147t147 61q20 0 34 14t14 34zM1307 1151q0 -7 -1 -9 q-105 -188 -315 -566t-316 -567l-49 -89q-10 -16 -28 -16q-12 0 -134 70q-16 10 -16 28q0 12 44 87q-143 65 -263.5 173t-208.5 245q-20 31 -20 69t20 69q153 235 380 371t496 136q89 0 180 -17l54 97q10 16 28 16q5 0 18 -6t31 -15.5t33 -18.5t31.5 -18.5t19.5 -11.5 q16 -10 16 -27zM1344 704q0 -139 -79 -253.5t-209 -164.5l280 502q8 -45 8 -84zM1792 576q0 -35 -20 -69q-39 -64 -109 -145q-150 -172 -347.5 -267t-419.5 -95l74 132q212 18 392.5 137t301.5 307q-115 179 -282 294l63 112q95 -64 182.5 -153t144.5 -184q20 -34 20 -69z " /> -<glyph unicode="" horiz-adv-x="1792" d="M1024 161v190q0 14 -9.5 23.5t-22.5 9.5h-192q-13 0 -22.5 -9.5t-9.5 -23.5v-190q0 -14 9.5 -23.5t22.5 -9.5h192q13 0 22.5 9.5t9.5 23.5zM1022 535l18 459q0 12 -10 19q-13 11 -24 11h-220q-11 0 -24 -11q-10 -7 -10 -21l17 -457q0 -10 10 -16.5t24 -6.5h185 q14 0 23.5 6.5t10.5 16.5zM1008 1469l768 -1408q35 -63 -2 -126q-17 -29 -46.5 -46t-63.5 -17h-1536q-34 0 -63.5 17t-46.5 46q-37 63 -2 126l768 1408q17 31 47 49t65 18t65 -18t47 -49z" /> -<glyph unicode="" horiz-adv-x="1408" d="M1376 1376q44 -52 12 -148t-108 -172l-161 -161l160 -696q5 -19 -12 -33l-128 -96q-7 -6 -19 -6q-4 0 -7 1q-15 3 -21 16l-279 508l-259 -259l53 -194q5 -17 -8 -31l-96 -96q-9 -9 -23 -9h-2q-15 2 -24 13l-189 252l-252 189q-11 7 -13 23q-1 13 9 25l96 97q9 9 23 9 q6 0 8 -1l194 -53l259 259l-508 279q-14 8 -17 24q-2 16 9 27l128 128q14 13 30 8l665 -159l160 160q76 76 172 108t148 -12z" /> -<glyph unicode="" horiz-adv-x="1664" d="M128 -128h288v288h-288v-288zM480 -128h320v288h-320v-288zM128 224h288v320h-288v-320zM480 224h320v320h-320v-320zM128 608h288v288h-288v-288zM864 -128h320v288h-320v-288zM480 608h320v288h-320v-288zM1248 -128h288v288h-288v-288zM864 224h320v320h-320v-320z M512 1088v288q0 13 -9.5 22.5t-22.5 9.5h-64q-13 0 -22.5 -9.5t-9.5 -22.5v-288q0 -13 9.5 -22.5t22.5 -9.5h64q13 0 22.5 9.5t9.5 22.5zM1248 224h288v320h-288v-320zM864 608h320v288h-320v-288zM1248 608h288v288h-288v-288zM1280 1088v288q0 13 -9.5 22.5t-22.5 9.5h-64 q-13 0 -22.5 -9.5t-9.5 -22.5v-288q0 -13 9.5 -22.5t22.5 -9.5h64q13 0 22.5 9.5t9.5 22.5zM1664 1152v-1280q0 -52 -38 -90t-90 -38h-1408q-52 0 -90 38t-38 90v1280q0 52 38 90t90 38h128v96q0 66 47 113t113 47h64q66 0 113 -47t47 -113v-96h384v96q0 66 47 113t113 47 h64q66 0 113 -47t47 -113v-96h128q52 0 90 -38t38 -90z" /> -<glyph unicode="" horiz-adv-x="1792" d="M666 1055q-60 -92 -137 -273q-22 45 -37 72.5t-40.5 63.5t-51 56.5t-63 35t-81.5 14.5h-224q-14 0 -23 9t-9 23v192q0 14 9 23t23 9h224q250 0 410 -225zM1792 256q0 -14 -9 -23l-320 -320q-9 -9 -23 -9q-13 0 -22.5 9.5t-9.5 22.5v192q-32 0 -85 -0.5t-81 -1t-73 1 t-71 5t-64 10.5t-63 18.5t-58 28.5t-59 40t-55 53.5t-56 69.5q59 93 136 273q22 -45 37 -72.5t40.5 -63.5t51 -56.5t63 -35t81.5 -14.5h256v192q0 14 9 23t23 9q12 0 24 -10l319 -319q9 -9 9 -23zM1792 1152q0 -14 -9 -23l-320 -320q-9 -9 -23 -9q-13 0 -22.5 9.5t-9.5 22.5 v192h-256q-48 0 -87 -15t-69 -45t-51 -61.5t-45 -77.5q-32 -62 -78 -171q-29 -66 -49.5 -111t-54 -105t-64 -100t-74 -83t-90 -68.5t-106.5 -42t-128 -16.5h-224q-14 0 -23 9t-9 23v192q0 14 9 23t23 9h224q48 0 87 15t69 45t51 61.5t45 77.5q32 62 78 171q29 66 49.5 111 t54 105t64 100t74 83t90 68.5t106.5 42t128 16.5h256v192q0 14 9 23t23 9q12 0 24 -10l319 -319q9 -9 9 -23z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1792 640q0 -174 -120 -321.5t-326 -233t-450 -85.5q-70 0 -145 8q-198 -175 -460 -242q-49 -14 -114 -22q-17 -2 -30.5 9t-17.5 29v1q-3 4 -0.5 12t2 10t4.5 9.5l6 9t7 8.5t8 9q7 8 31 34.5t34.5 38t31 39.5t32.5 51t27 59t26 76q-157 89 -247.5 220t-90.5 281 q0 130 71 248.5t191 204.5t286 136.5t348 50.5q244 0 450 -85.5t326 -233t120 -321.5z" /> -<glyph unicode="" d="M1536 704v-128q0 -201 -98.5 -362t-274 -251.5t-395.5 -90.5t-395.5 90.5t-274 251.5t-98.5 362v128q0 26 19 45t45 19h384q26 0 45 -19t19 -45v-128q0 -52 23.5 -90t53.5 -57t71 -30t64 -13t44 -2t44 2t64 13t71 30t53.5 57t23.5 90v128q0 26 19 45t45 19h384 q26 0 45 -19t19 -45zM512 1344v-384q0 -26 -19 -45t-45 -19h-384q-26 0 -45 19t-19 45v384q0 26 19 45t45 19h384q26 0 45 -19t19 -45zM1536 1344v-384q0 -26 -19 -45t-45 -19h-384q-26 0 -45 19t-19 45v384q0 26 19 45t45 19h384q26 0 45 -19t19 -45z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1683 205l-166 -165q-19 -19 -45 -19t-45 19l-531 531l-531 -531q-19 -19 -45 -19t-45 19l-166 165q-19 19 -19 45.5t19 45.5l742 741q19 19 45 19t45 -19l742 -741q19 -19 19 -45.5t-19 -45.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1683 728l-742 -741q-19 -19 -45 -19t-45 19l-742 741q-19 19 -19 45.5t19 45.5l166 165q19 19 45 19t45 -19l531 -531l531 531q19 19 45 19t45 -19l166 -165q19 -19 19 -45.5t-19 -45.5z" /> -<glyph unicode="" horiz-adv-x="1920" d="M1280 32q0 -13 -9.5 -22.5t-22.5 -9.5h-960q-8 0 -13.5 2t-9 7t-5.5 8t-3 11.5t-1 11.5v13v11v160v416h-192q-26 0 -45 19t-19 45q0 24 15 41l320 384q19 22 49 22t49 -22l320 -384q15 -17 15 -41q0 -26 -19 -45t-45 -19h-192v-384h576q16 0 25 -11l160 -192q7 -11 7 -21 zM1920 448q0 -24 -15 -41l-320 -384q-20 -23 -49 -23t-49 23l-320 384q-15 17 -15 41q0 26 19 45t45 19h192v384h-576q-16 0 -25 12l-160 192q-7 9 -7 20q0 13 9.5 22.5t22.5 9.5h960q8 0 13.5 -2t9 -7t5.5 -8t3 -11.5t1 -11.5v-13v-11v-160v-416h192q26 0 45 -19t19 -45z " /> -<glyph unicode="" horiz-adv-x="1664" d="M640 0q0 -52 -38 -90t-90 -38t-90 38t-38 90t38 90t90 38t90 -38t38 -90zM1536 0q0 -52 -38 -90t-90 -38t-90 38t-38 90t38 90t90 38t90 -38t38 -90zM1664 1088v-512q0 -24 -16.5 -42.5t-40.5 -21.5l-1044 -122q13 -60 13 -70q0 -16 -24 -64h920q26 0 45 -19t19 -45 t-19 -45t-45 -19h-1024q-26 0 -45 19t-19 45q0 11 8 31.5t16 36t21.5 40t15.5 29.5l-177 823h-204q-26 0 -45 19t-19 45t19 45t45 19h256q16 0 28.5 -6.5t19.5 -15.5t13 -24.5t8 -26t5.5 -29.5t4.5 -26h1201q26 0 45 -19t19 -45z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1664 928v-704q0 -92 -66 -158t-158 -66h-1216q-92 0 -158 66t-66 158v960q0 92 66 158t158 66h320q92 0 158 -66t66 -158v-32h672q92 0 158 -66t66 -158z" /> -<glyph unicode="" horiz-adv-x="1920" d="M1879 584q0 -31 -31 -66l-336 -396q-43 -51 -120.5 -86.5t-143.5 -35.5h-1088q-34 0 -60.5 13t-26.5 43q0 31 31 66l336 396q43 51 120.5 86.5t143.5 35.5h1088q34 0 60.5 -13t26.5 -43zM1536 928v-160h-832q-94 0 -197 -47.5t-164 -119.5l-337 -396l-5 -6q0 4 -0.5 12.5 t-0.5 12.5v960q0 92 66 158t158 66h320q92 0 158 -66t66 -158v-32h544q92 0 158 -66t66 -158z" /> -<glyph unicode="" horiz-adv-x="768" d="M704 1216q0 -26 -19 -45t-45 -19h-128v-1024h128q26 0 45 -19t19 -45t-19 -45l-256 -256q-19 -19 -45 -19t-45 19l-256 256q-19 19 -19 45t19 45t45 19h128v1024h-128q-26 0 -45 19t-19 45t19 45l256 256q19 19 45 19t45 -19l256 -256q19 -19 19 -45z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1792 640q0 -26 -19 -45l-256 -256q-19 -19 -45 -19t-45 19t-19 45v128h-1024v-128q0 -26 -19 -45t-45 -19t-45 19l-256 256q-19 19 -19 45t19 45l256 256q19 19 45 19t45 -19t19 -45v-128h1024v128q0 26 19 45t45 19t45 -19l256 -256q19 -19 19 -45z" /> -<glyph unicode="" horiz-adv-x="2048" d="M640 640v-512h-256v512h256zM1024 1152v-1024h-256v1024h256zM2048 0v-128h-2048v1536h128v-1408h1920zM1408 896v-768h-256v768h256zM1792 1280v-1152h-256v1152h256z" /> -<glyph unicode="" d="M1280 926q-56 -25 -121 -34q68 40 93 117q-65 -38 -134 -51q-61 66 -153 66q-87 0 -148.5 -61.5t-61.5 -148.5q0 -29 5 -48q-129 7 -242 65t-192 155q-29 -50 -29 -106q0 -114 91 -175q-47 1 -100 26v-2q0 -75 50 -133.5t123 -72.5q-29 -8 -51 -8q-13 0 -39 4 q21 -63 74.5 -104t121.5 -42q-116 -90 -261 -90q-26 0 -50 3q148 -94 322 -94q112 0 210 35.5t168 95t120.5 137t75 162t24.5 168.5q0 18 -1 27q63 45 105 109zM1536 1120v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5 t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" d="M1248 1408q119 0 203.5 -84.5t84.5 -203.5v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-188v595h199l30 232h-229v148q0 56 23.5 84t91.5 28l122 1v207q-63 9 -178 9q-136 0 -217.5 -80t-81.5 -226v-171h-200v-232h200v-595h-532q-119 0 -203.5 84.5t-84.5 203.5v960 q0 119 84.5 203.5t203.5 84.5h960z" /> -<glyph unicode="" horiz-adv-x="1792" d="M928 704q0 14 -9 23t-23 9q-66 0 -113 -47t-47 -113q0 -14 9 -23t23 -9t23 9t9 23q0 40 28 68t68 28q14 0 23 9t9 23zM1152 574q0 -106 -75 -181t-181 -75t-181 75t-75 181t75 181t181 75t181 -75t75 -181zM128 0h1536v128h-1536v-128zM1280 574q0 159 -112.5 271.5 t-271.5 112.5t-271.5 -112.5t-112.5 -271.5t112.5 -271.5t271.5 -112.5t271.5 112.5t112.5 271.5zM256 1216h384v128h-384v-128zM128 1024h1536v118v138h-828l-64 -128h-644v-128zM1792 1280v-1280q0 -53 -37.5 -90.5t-90.5 -37.5h-1536q-53 0 -90.5 37.5t-37.5 90.5v1280 q0 53 37.5 90.5t90.5 37.5h1536q53 0 90.5 -37.5t37.5 -90.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M832 1024q0 80 -56 136t-136 56t-136 -56t-56 -136q0 -42 19 -83q-41 19 -83 19q-80 0 -136 -56t-56 -136t56 -136t136 -56t136 56t56 136q0 42 -19 83q41 -19 83 -19q80 0 136 56t56 136zM1683 320q0 -17 -49 -66t-66 -49q-9 0 -28.5 16t-36.5 33t-38.5 40t-24.5 26 l-96 -96l220 -220q28 -28 28 -68q0 -42 -39 -81t-81 -39q-40 0 -68 28l-671 671q-176 -131 -365 -131q-163 0 -265.5 102.5t-102.5 265.5q0 160 95 313t248 248t313 95q163 0 265.5 -102.5t102.5 -265.5q0 -189 -131 -365l355 -355l96 96q-3 3 -26 24.5t-40 38.5t-33 36.5 t-16 28.5q0 17 49 66t66 49q13 0 23 -10q6 -6 46 -44.5t82 -79.5t86.5 -86t73 -78t28.5 -41z" /> -<glyph unicode="" horiz-adv-x="1920" d="M896 640q0 106 -75 181t-181 75t-181 -75t-75 -181t75 -181t181 -75t181 75t75 181zM1664 128q0 52 -38 90t-90 38t-90 -38t-38 -90q0 -53 37.5 -90.5t90.5 -37.5t90.5 37.5t37.5 90.5zM1664 1152q0 52 -38 90t-90 38t-90 -38t-38 -90q0 -53 37.5 -90.5t90.5 -37.5 t90.5 37.5t37.5 90.5zM1280 731v-185q0 -10 -7 -19.5t-16 -10.5l-155 -24q-11 -35 -32 -76q34 -48 90 -115q7 -10 7 -20q0 -12 -7 -19q-23 -30 -82.5 -89.5t-78.5 -59.5q-11 0 -21 7l-115 90q-37 -19 -77 -31q-11 -108 -23 -155q-7 -24 -30 -24h-186q-11 0 -20 7.5t-10 17.5 l-23 153q-34 10 -75 31l-118 -89q-7 -7 -20 -7q-11 0 -21 8q-144 133 -144 160q0 9 7 19q10 14 41 53t47 61q-23 44 -35 82l-152 24q-10 1 -17 9.5t-7 19.5v185q0 10 7 19.5t16 10.5l155 24q11 35 32 76q-34 48 -90 115q-7 11 -7 20q0 12 7 20q22 30 82 89t79 59q11 0 21 -7 l115 -90q34 18 77 32q11 108 23 154q7 24 30 24h186q11 0 20 -7.5t10 -17.5l23 -153q34 -10 75 -31l118 89q8 7 20 7q11 0 21 -8q144 -133 144 -160q0 -9 -7 -19q-12 -16 -42 -54t-45 -60q23 -48 34 -82l152 -23q10 -2 17 -10.5t7 -19.5zM1920 198v-140q0 -16 -149 -31 q-12 -27 -30 -52q51 -113 51 -138q0 -4 -4 -7q-122 -71 -124 -71q-8 0 -46 47t-52 68q-20 -2 -30 -2t-30 2q-14 -21 -52 -68t-46 -47q-2 0 -124 71q-4 3 -4 7q0 25 51 138q-18 25 -30 52q-149 15 -149 31v140q0 16 149 31q13 29 30 52q-51 113 -51 138q0 4 4 7q4 2 35 20 t59 34t30 16q8 0 46 -46.5t52 -67.5q20 2 30 2t30 -2q51 71 92 112l6 2q4 0 124 -70q4 -3 4 -7q0 -25 -51 -138q17 -23 30 -52q149 -15 149 -31zM1920 1222v-140q0 -16 -149 -31q-12 -27 -30 -52q51 -113 51 -138q0 -4 -4 -7q-122 -71 -124 -71q-8 0 -46 47t-52 68 q-20 -2 -30 -2t-30 2q-14 -21 -52 -68t-46 -47q-2 0 -124 71q-4 3 -4 7q0 25 51 138q-18 25 -30 52q-149 15 -149 31v140q0 16 149 31q13 29 30 52q-51 113 -51 138q0 4 4 7q4 2 35 20t59 34t30 16q8 0 46 -46.5t52 -67.5q20 2 30 2t30 -2q51 71 92 112l6 2q4 0 124 -70 q4 -3 4 -7q0 -25 -51 -138q17 -23 30 -52q149 -15 149 -31z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1408 768q0 -139 -94 -257t-256.5 -186.5t-353.5 -68.5q-86 0 -176 16q-124 -88 -278 -128q-36 -9 -86 -16h-3q-11 0 -20.5 8t-11.5 21q-1 3 -1 6.5t0.5 6.5t2 6l2.5 5t3.5 5.5t4 5t4.5 5t4 4.5q5 6 23 25t26 29.5t22.5 29t25 38.5t20.5 44q-124 72 -195 177t-71 224 q0 139 94 257t256.5 186.5t353.5 68.5t353.5 -68.5t256.5 -186.5t94 -257zM1792 512q0 -120 -71 -224.5t-195 -176.5q10 -24 20.5 -44t25 -38.5t22.5 -29t26 -29.5t23 -25q1 -1 4 -4.5t4.5 -5t4 -5t3.5 -5.5l2.5 -5t2 -6t0.5 -6.5t-1 -6.5q-3 -14 -13 -22t-22 -7 q-50 7 -86 16q-154 40 -278 128q-90 -16 -176 -16q-271 0 -472 132q58 -4 88 -4q161 0 309 45t264 129q125 92 192 212t67 254q0 77 -23 152q129 -71 204 -178t75 -230z" /> -<glyph unicode="" d="M256 192q0 26 -19 45t-45 19t-45 -19t-19 -45t19 -45t45 -19t45 19t19 45zM1408 768q0 51 -39 89.5t-89 38.5h-352q0 58 48 159.5t48 160.5q0 98 -32 145t-128 47q-26 -26 -38 -85t-30.5 -125.5t-59.5 -109.5q-22 -23 -77 -91q-4 -5 -23 -30t-31.5 -41t-34.5 -42.5 t-40 -44t-38.5 -35.5t-40 -27t-35.5 -9h-32v-640h32q13 0 31.5 -3t33 -6.5t38 -11t35 -11.5t35.5 -12.5t29 -10.5q211 -73 342 -73h121q192 0 192 167q0 26 -5 56q30 16 47.5 52.5t17.5 73.5t-18 69q53 50 53 119q0 25 -10 55.5t-25 47.5q32 1 53.5 47t21.5 81zM1536 769 q0 -89 -49 -163q9 -33 9 -69q0 -77 -38 -144q3 -21 3 -43q0 -101 -60 -178q1 -139 -85 -219.5t-227 -80.5h-36h-93q-96 0 -189.5 22.5t-216.5 65.5q-116 40 -138 40h-288q-53 0 -90.5 37.5t-37.5 90.5v640q0 53 37.5 90.5t90.5 37.5h274q36 24 137 155q58 75 107 128 q24 25 35.5 85.5t30.5 126.5t62 108q39 37 90 37q84 0 151 -32.5t102 -101.5t35 -186q0 -93 -48 -192h176q104 0 180 -76t76 -179z" /> -<glyph unicode="" d="M256 1088q0 26 -19 45t-45 19t-45 -19t-19 -45t19 -45t45 -19t45 19t19 45zM1408 512q0 35 -21.5 81t-53.5 47q15 17 25 47.5t10 55.5q0 69 -53 119q18 32 18 69t-17.5 73.5t-47.5 52.5q5 30 5 56q0 85 -49 126t-136 41h-128q-131 0 -342 -73q-5 -2 -29 -10.5 t-35.5 -12.5t-35 -11.5t-38 -11t-33 -6.5t-31.5 -3h-32v-640h32q16 0 35.5 -9t40 -27t38.5 -35.5t40 -44t34.5 -42.5t31.5 -41t23 -30q55 -68 77 -91q41 -43 59.5 -109.5t30.5 -125.5t38 -85q96 0 128 47t32 145q0 59 -48 160.5t-48 159.5h352q50 0 89 38.5t39 89.5z M1536 511q0 -103 -76 -179t-180 -76h-176q48 -99 48 -192q0 -118 -35 -186q-35 -69 -102 -101.5t-151 -32.5q-51 0 -90 37q-34 33 -54 82t-25.5 90.5t-17.5 84.5t-31 64q-48 50 -107 127q-101 131 -137 155h-274q-53 0 -90.5 37.5t-37.5 90.5v640q0 53 37.5 90.5t90.5 37.5 h288q22 0 138 40q128 44 223 66t200 22h112q140 0 226.5 -79t85.5 -216v-5q60 -77 60 -178q0 -22 -3 -43q38 -67 38 -144q0 -36 -9 -69q49 -74 49 -163z" /> -<glyph unicode="" horiz-adv-x="896" d="M832 1504v-1339l-449 -236q-22 -12 -40 -12q-21 0 -31.5 14.5t-10.5 35.5q0 6 2 20l86 500l-364 354q-25 27 -25 48q0 37 56 46l502 73l225 455q19 41 49 41z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1664 940q0 81 -21.5 143t-55 98.5t-81.5 59.5t-94 31t-98 8t-112 -25.5t-110.5 -64t-86.5 -72t-60 -61.5q-18 -22 -49 -22t-49 22q-24 28 -60 61.5t-86.5 72t-110.5 64t-112 25.5t-98 -8t-94 -31t-81.5 -59.5t-55 -98.5t-21.5 -143q0 -168 187 -355l581 -560l580 559 q188 188 188 356zM1792 940q0 -221 -229 -450l-623 -600q-18 -18 -44 -18t-44 18l-624 602q-10 8 -27.5 26t-55.5 65.5t-68 97.5t-53.5 121t-23.5 138q0 220 127 344t351 124q62 0 126.5 -21.5t120 -58t95.5 -68.5t76 -68q36 36 76 68t95.5 68.5t120 58t126.5 21.5 q224 0 351 -124t127 -344z" /> -<glyph unicode="" horiz-adv-x="1664" d="M640 96q0 -4 1 -20t0.5 -26.5t-3 -23.5t-10 -19.5t-20.5 -6.5h-320q-119 0 -203.5 84.5t-84.5 203.5v704q0 119 84.5 203.5t203.5 84.5h320q13 0 22.5 -9.5t9.5 -22.5q0 -4 1 -20t0.5 -26.5t-3 -23.5t-10 -19.5t-20.5 -6.5h-320q-66 0 -113 -47t-47 -113v-704 q0 -66 47 -113t113 -47h288h11h13t11.5 -1t11.5 -3t8 -5.5t7 -9t2 -13.5zM1568 640q0 -26 -19 -45l-544 -544q-19 -19 -45 -19t-45 19t-19 45v288h-448q-26 0 -45 19t-19 45v384q0 26 19 45t45 19h448v288q0 26 19 45t45 19t45 -19l544 -544q19 -19 19 -45z" /> -<glyph unicode="" d="M237 122h231v694h-231v-694zM483 1030q-1 52 -36 86t-93 34t-94.5 -34t-36.5 -86q0 -51 35.5 -85.5t92.5 -34.5h1q59 0 95 34.5t36 85.5zM1068 122h231v398q0 154 -73 233t-193 79q-136 0 -209 -117h2v101h-231q3 -66 0 -694h231v388q0 38 7 56q15 35 45 59.5t74 24.5 q116 0 116 -157v-371zM1536 1120v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" horiz-adv-x="1152" d="M480 672v448q0 14 -9 23t-23 9t-23 -9t-9 -23v-448q0 -14 9 -23t23 -9t23 9t9 23zM1152 320q0 -26 -19 -45t-45 -19h-429l-51 -483q-2 -12 -10.5 -20.5t-20.5 -8.5h-1q-27 0 -32 27l-76 485h-404q-26 0 -45 19t-19 45q0 123 78.5 221.5t177.5 98.5v512q-52 0 -90 38 t-38 90t38 90t90 38h640q52 0 90 -38t38 -90t-38 -90t-90 -38v-512q99 0 177.5 -98.5t78.5 -221.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1408 608v-320q0 -119 -84.5 -203.5t-203.5 -84.5h-832q-119 0 -203.5 84.5t-84.5 203.5v832q0 119 84.5 203.5t203.5 84.5h704q14 0 23 -9t9 -23v-64q0 -14 -9 -23t-23 -9h-704q-66 0 -113 -47t-47 -113v-832q0 -66 47 -113t113 -47h832q66 0 113 47t47 113v320 q0 14 9 23t23 9h64q14 0 23 -9t9 -23zM1792 1472v-512q0 -26 -19 -45t-45 -19t-45 19l-176 176l-652 -652q-10 -10 -23 -10t-23 10l-114 114q-10 10 -10 23t10 23l652 652l-176 176q-19 19 -19 45t19 45t45 19h512q26 0 45 -19t19 -45z" /> -<glyph unicode="" d="M1184 640q0 -26 -19 -45l-544 -544q-19 -19 -45 -19t-45 19t-19 45v288h-448q-26 0 -45 19t-19 45v384q0 26 19 45t45 19h448v288q0 26 19 45t45 19t45 -19l544 -544q19 -19 19 -45zM1536 992v-704q0 -119 -84.5 -203.5t-203.5 -84.5h-320q-13 0 -22.5 9.5t-9.5 22.5 q0 4 -1 20t-0.5 26.5t3 23.5t10 19.5t20.5 6.5h320q66 0 113 47t47 113v704q0 66 -47 113t-113 47h-288h-11h-13t-11.5 1t-11.5 3t-8 5.5t-7 9t-2 13.5q0 4 -1 20t-0.5 26.5t3 23.5t10 19.5t20.5 6.5h320q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" horiz-adv-x="1664" d="M458 653q-74 162 -74 371h-256v-96q0 -78 94.5 -162t235.5 -113zM1536 928v96h-256q0 -209 -74 -371q141 29 235.5 113t94.5 162zM1664 1056v-128q0 -71 -41.5 -143t-112 -130t-173 -97.5t-215.5 -44.5q-42 -54 -95 -95q-38 -34 -52.5 -72.5t-14.5 -89.5q0 -54 30.5 -91 t97.5 -37q75 0 133.5 -45.5t58.5 -114.5v-64q0 -14 -9 -23t-23 -9h-832q-14 0 -23 9t-9 23v64q0 69 58.5 114.5t133.5 45.5q67 0 97.5 37t30.5 91q0 51 -14.5 89.5t-52.5 72.5q-53 41 -95 95q-113 5 -215.5 44.5t-173 97.5t-112 130t-41.5 143v128q0 40 28 68t68 28h288v96 q0 66 47 113t113 47h576q66 0 113 -47t47 -113v-96h288q40 0 68 -28t28 -68z" /> -<glyph unicode="" d="M394 184q-8 -9 -20 3q-13 11 -4 19q8 9 20 -3q12 -11 4 -19zM352 245q9 -12 0 -19q-8 -6 -17 7t0 18q9 7 17 -6zM291 305q-5 -7 -13 -2q-10 5 -7 12q3 5 13 2q10 -5 7 -12zM322 271q-6 -7 -16 3q-9 11 -2 16q6 6 16 -3q9 -11 2 -16zM451 159q-4 -12 -19 -6q-17 4 -13 15 t19 7q16 -5 13 -16zM514 154q0 -11 -16 -11q-17 -2 -17 11q0 11 16 11q17 2 17 -11zM572 164q2 -10 -14 -14t-18 8t14 15q16 2 18 -9zM1536 1120v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-224q-16 0 -24.5 1t-19.5 5t-16 14.5t-5 27.5v239q0 97 -52 142q57 6 102.5 18t94 39 t81 66.5t53 105t20.5 150.5q0 121 -79 206q37 91 -8 204q-28 9 -81 -11t-92 -44l-38 -24q-93 26 -192 26t-192 -26q-16 11 -42.5 27t-83.5 38.5t-86 13.5q-44 -113 -7 -204q-79 -85 -79 -206q0 -85 20.5 -150t52.5 -105t80.5 -67t94 -39t102.5 -18q-40 -36 -49 -103 q-21 -10 -45 -15t-57 -5t-65.5 21.5t-55.5 62.5q-19 32 -48.5 52t-49.5 24l-20 3q-21 0 -29 -4.5t-5 -11.5t9 -14t13 -12l7 -5q22 -10 43.5 -38t31.5 -51l10 -23q13 -38 44 -61.5t67 -30t69.5 -7t55.5 3.5l23 4q0 -38 0.5 -103t0.5 -68q0 -22 -11 -33.5t-22 -13t-33 -1.5 h-224q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1280 64q0 26 -19 45t-45 19t-45 -19t-19 -45t19 -45t45 -19t45 19t19 45zM1536 64q0 26 -19 45t-45 19t-45 -19t-19 -45t19 -45t45 -19t45 19t19 45zM1664 288v-320q0 -40 -28 -68t-68 -28h-1472q-40 0 -68 28t-28 68v320q0 40 28 68t68 28h427q21 -56 70.5 -92 t110.5 -36h256q61 0 110.5 36t70.5 92h427q40 0 68 -28t28 -68zM1339 936q-17 -40 -59 -40h-256v-448q0 -26 -19 -45t-45 -19h-256q-26 0 -45 19t-19 45v448h-256q-42 0 -59 40q-17 39 14 69l448 448q18 19 45 19t45 -19l448 -448q31 -30 14 -69z" /> -<glyph unicode="" d="M1407 710q0 44 -7 113.5t-18 96.5q-12 30 -17 44t-9 36.5t-4 48.5q0 23 5 68.5t5 67.5q0 37 -10 55q-4 1 -13 1q-19 0 -58 -4.5t-59 -4.5q-60 0 -176 24t-175 24q-43 0 -94.5 -11.5t-85 -23.5t-89.5 -34q-137 -54 -202 -103q-96 -73 -159.5 -189.5t-88 -236t-24.5 -248.5 q0 -40 12.5 -120t12.5 -121q0 -23 -11 -66.5t-11 -65.5t12 -36.5t34 -14.5q24 0 72.5 11t73.5 11q57 0 169.5 -15.5t169.5 -15.5q181 0 284 36q129 45 235.5 152.5t166 245.5t59.5 275zM1535 712q0 -165 -70 -327.5t-196 -288t-281 -180.5q-124 -44 -326 -44 q-57 0 -170 14.5t-169 14.5q-24 0 -72.5 -14.5t-73.5 -14.5q-73 0 -123.5 55.5t-50.5 128.5q0 24 11 68t11 67q0 40 -12.5 120.5t-12.5 121.5q0 111 18 217.5t54.5 209.5t100.5 194t150 156q78 59 232 120q194 78 316 78q60 0 175.5 -24t173.5 -24q19 0 57 5t58 5 q81 0 118 -50.5t37 -134.5q0 -23 -5 -68t-5 -68q0 -10 1 -18.5t3 -17t4 -13.5t6.5 -16t6.5 -17q16 -40 25 -118.5t9 -136.5z" /> -<glyph unicode="" horiz-adv-x="1408" d="M1408 296q0 -27 -10 -70.5t-21 -68.5q-21 -50 -122 -106q-94 -51 -186 -51q-27 0 -52.5 3.5t-57.5 12.5t-47.5 14.5t-55.5 20.5t-49 18q-98 35 -175 83q-128 79 -264.5 215.5t-215.5 264.5q-48 77 -83 175q-3 9 -18 49t-20.5 55.5t-14.5 47.5t-12.5 57.5t-3.5 52.5 q0 92 51 186q56 101 106 122q25 11 68.5 21t70.5 10q14 0 21 -3q18 -6 53 -76q11 -19 30 -54t35 -63.5t31 -53.5q3 -4 17.5 -25t21.5 -35.5t7 -28.5q0 -20 -28.5 -50t-62 -55t-62 -53t-28.5 -46q0 -9 5 -22.5t8.5 -20.5t14 -24t11.5 -19q76 -137 174 -235t235 -174 q2 -1 19 -11.5t24 -14t20.5 -8.5t22.5 -5q18 0 46 28.5t53 62t55 62t50 28.5q14 0 28.5 -7t35.5 -21.5t25 -17.5q25 -15 53.5 -31t63.5 -35t54 -30q70 -35 76 -53q3 -7 3 -21z" /> -<glyph unicode="" horiz-adv-x="1408" d="M1120 1280h-832q-66 0 -113 -47t-47 -113v-832q0 -66 47 -113t113 -47h832q66 0 113 47t47 113v832q0 66 -47 113t-113 47zM1408 1120v-832q0 -119 -84.5 -203.5t-203.5 -84.5h-832q-119 0 -203.5 84.5t-84.5 203.5v832q0 119 84.5 203.5t203.5 84.5h832 q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" horiz-adv-x="1280" d="M1152 1280h-1024v-1242l423 406l89 85l89 -85l423 -406v1242zM1164 1408q23 0 44 -9q33 -13 52.5 -41t19.5 -62v-1289q0 -34 -19.5 -62t-52.5 -41q-19 -8 -44 -8q-48 0 -83 32l-441 424l-441 -424q-36 -33 -83 -33q-23 0 -44 9q-33 13 -52.5 41t-19.5 62v1289 q0 34 19.5 62t52.5 41q21 9 44 9h1048z" /> -<glyph unicode="" d="M1280 343q0 11 -2 16q-3 8 -38.5 29.5t-88.5 49.5l-53 29q-5 3 -19 13t-25 15t-21 5q-18 0 -47 -32.5t-57 -65.5t-44 -33q-7 0 -16.5 3.5t-15.5 6.5t-17 9.5t-14 8.5q-99 55 -170.5 126.5t-126.5 170.5q-2 3 -8.5 14t-9.5 17t-6.5 15.5t-3.5 16.5q0 13 20.5 33.5t45 38.5 t45 39.5t20.5 36.5q0 10 -5 21t-15 25t-13 19q-3 6 -15 28.5t-25 45.5t-26.5 47.5t-25 40.5t-16.5 18t-16 2q-48 0 -101 -22q-46 -21 -80 -94.5t-34 -130.5q0 -16 2.5 -34t5 -30.5t9 -33t10 -29.5t12.5 -33t11 -30q60 -164 216.5 -320.5t320.5 -216.5q6 -2 30 -11t33 -12.5 t29.5 -10t33 -9t30.5 -5t34 -2.5q57 0 130.5 34t94.5 80q22 53 22 101zM1536 1120v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1620 1128q-67 -98 -162 -167q1 -14 1 -42q0 -130 -38 -259.5t-115.5 -248.5t-184.5 -210.5t-258 -146t-323 -54.5q-271 0 -496 145q35 -4 78 -4q225 0 401 138q-105 2 -188 64.5t-114 159.5q33 -5 61 -5q43 0 85 11q-112 23 -185.5 111.5t-73.5 205.5v4q68 -38 146 -41 q-66 44 -105 115t-39 154q0 88 44 163q121 -149 294.5 -238.5t371.5 -99.5q-8 38 -8 74q0 134 94.5 228.5t228.5 94.5q140 0 236 -102q109 21 205 78q-37 -115 -142 -178q93 10 186 50z" /> -<glyph unicode="" horiz-adv-x="1024" d="M959 1524v-264h-157q-86 0 -116 -36t-30 -108v-189h293l-39 -296h-254v-759h-306v759h-255v296h255v218q0 186 104 288.5t277 102.5q147 0 228 -12z" /> -<glyph unicode="" d="M1536 640q0 -251 -146.5 -451.5t-378.5 -277.5q-27 -5 -39.5 7t-12.5 30v211q0 97 -52 142q57 6 102.5 18t94 39t81 66.5t53 105t20.5 150.5q0 121 -79 206q37 91 -8 204q-28 9 -81 -11t-92 -44l-38 -24q-93 26 -192 26t-192 -26q-16 11 -42.5 27t-83.5 38.5t-86 13.5 q-44 -113 -7 -204q-79 -85 -79 -206q0 -85 20.5 -150t52.5 -105t80.5 -67t94 -39t102.5 -18q-40 -36 -49 -103q-21 -10 -45 -15t-57 -5t-65.5 21.5t-55.5 62.5q-19 32 -48.5 52t-49.5 24l-20 3q-21 0 -29 -4.5t-5 -11.5t9 -14t13 -12l7 -5q22 -10 43.5 -38t31.5 -51l10 -23 q13 -38 44 -61.5t67 -30t69.5 -7t55.5 3.5l23 4q0 -38 0.5 -89t0.5 -54q0 -18 -13 -30t-40 -7q-232 77 -378.5 277.5t-146.5 451.5q0 209 103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1664 960v-256q0 -26 -19 -45t-45 -19h-64q-26 0 -45 19t-19 45v256q0 106 -75 181t-181 75t-181 -75t-75 -181v-192h96q40 0 68 -28t28 -68v-576q0 -40 -28 -68t-68 -28h-960q-40 0 -68 28t-28 68v576q0 40 28 68t68 28h672v192q0 185 131.5 316.5t316.5 131.5 t316.5 -131.5t131.5 -316.5z" /> -<glyph unicode="" horiz-adv-x="1920" d="M1760 1408q66 0 113 -47t47 -113v-1216q0 -66 -47 -113t-113 -47h-1600q-66 0 -113 47t-47 113v1216q0 66 47 113t113 47h1600zM160 1280q-13 0 -22.5 -9.5t-9.5 -22.5v-224h1664v224q0 13 -9.5 22.5t-22.5 9.5h-1600zM1760 0q13 0 22.5 9.5t9.5 22.5v608h-1664v-608 q0 -13 9.5 -22.5t22.5 -9.5h1600zM256 128v128h256v-128h-256zM640 128v128h384v-128h-384z" /> -<glyph unicode="" horiz-adv-x="1408" d="M384 192q0 -80 -56 -136t-136 -56t-136 56t-56 136t56 136t136 56t136 -56t56 -136zM896 69q2 -28 -17 -48q-18 -21 -47 -21h-135q-25 0 -43 16.5t-20 41.5q-22 229 -184.5 391.5t-391.5 184.5q-25 2 -41.5 20t-16.5 43v135q0 29 21 47q17 17 43 17h5q160 -13 306 -80.5 t259 -181.5q114 -113 181.5 -259t80.5 -306zM1408 67q2 -27 -18 -47q-18 -20 -46 -20h-143q-26 0 -44.5 17.5t-19.5 42.5q-12 215 -101 408.5t-231.5 336t-336 231.5t-408.5 102q-25 1 -42.5 19.5t-17.5 43.5v143q0 28 20 46q18 18 44 18h3q262 -13 501.5 -120t425.5 -294 q187 -186 294 -425.5t120 -501.5z" /> -<glyph unicode="" d="M1040 320q0 -33 -23.5 -56.5t-56.5 -23.5t-56.5 23.5t-23.5 56.5t23.5 56.5t56.5 23.5t56.5 -23.5t23.5 -56.5zM1296 320q0 -33 -23.5 -56.5t-56.5 -23.5t-56.5 23.5t-23.5 56.5t23.5 56.5t56.5 23.5t56.5 -23.5t23.5 -56.5zM1408 160v320q0 13 -9.5 22.5t-22.5 9.5 h-1216q-13 0 -22.5 -9.5t-9.5 -22.5v-320q0 -13 9.5 -22.5t22.5 -9.5h1216q13 0 22.5 9.5t9.5 22.5zM178 640h1180l-157 482q-4 13 -16 21.5t-26 8.5h-782q-14 0 -26 -8.5t-16 -21.5zM1536 480v-320q0 -66 -47 -113t-113 -47h-1216q-66 0 -113 47t-47 113v320q0 25 16 75 l197 606q17 53 63 86t101 33h782q55 0 101 -33t63 -86l197 -606q16 -50 16 -75z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1664 896q53 0 90.5 -37.5t37.5 -90.5t-37.5 -90.5t-90.5 -37.5v-384q0 -52 -38 -90t-90 -38q-417 347 -812 380q-58 -19 -91 -66t-31 -100.5t40 -92.5q-20 -33 -23 -65.5t6 -58t33.5 -55t48 -50t61.5 -50.5q-29 -58 -111.5 -83t-168.5 -11.5t-132 55.5q-7 23 -29.5 87.5 t-32 94.5t-23 89t-15 101t3.5 98.5t22 110.5h-122q-66 0 -113 47t-47 113v192q0 66 47 113t113 47h480q435 0 896 384q52 0 90 -38t38 -90v-384zM1536 292v954q-394 -302 -768 -343v-270q377 -42 768 -341z" /> -<glyph unicode="" horiz-adv-x="1792" d="M912 -160q0 16 -16 16q-59 0 -101.5 42.5t-42.5 101.5q0 16 -16 16t-16 -16q0 -73 51.5 -124.5t124.5 -51.5q16 0 16 16zM246 128h1300q-266 300 -266 832q0 51 -24 105t-69 103t-121.5 80.5t-169.5 31.5t-169.5 -31.5t-121.5 -80.5t-69 -103t-24 -105q0 -532 -266 -832z M1728 128q0 -52 -38 -90t-90 -38h-448q0 -106 -75 -181t-181 -75t-181 75t-75 181h-448q-52 0 -90 38t-38 90q50 42 91 88t85 119.5t74.5 158.5t50 206t19.5 260q0 152 117 282.5t307 158.5q-8 19 -8 39q0 40 28 68t68 28t68 -28t28 -68q0 -20 -8 -39q190 -28 307 -158.5 t117 -282.5q0 -139 19.5 -260t50 -206t74.5 -158.5t85 -119.5t91 -88z" /> -<glyph unicode="" d="M1376 640l138 -135q30 -28 20 -70q-12 -41 -52 -51l-188 -48l53 -186q12 -41 -19 -70q-29 -31 -70 -19l-186 53l-48 -188q-10 -40 -51 -52q-12 -2 -19 -2q-31 0 -51 22l-135 138l-135 -138q-28 -30 -70 -20q-41 11 -51 52l-48 188l-186 -53q-41 -12 -70 19q-31 29 -19 70 l53 186l-188 48q-40 10 -52 51q-10 42 20 70l138 135l-138 135q-30 28 -20 70q12 41 52 51l188 48l-53 186q-12 41 19 70q29 31 70 19l186 -53l48 188q10 41 51 51q41 12 70 -19l135 -139l135 139q29 30 70 19q41 -10 51 -51l48 -188l186 53q41 12 70 -19q31 -29 19 -70 l-53 -186l188 -48q40 -10 52 -51q10 -42 -20 -70z" /> -<glyph unicode="" horiz-adv-x="1792" d="M256 192q0 26 -19 45t-45 19t-45 -19t-19 -45t19 -45t45 -19t45 19t19 45zM1664 768q0 51 -39 89.5t-89 38.5h-576q0 20 15 48.5t33 55t33 68t15 84.5q0 67 -44.5 97.5t-115.5 30.5q-24 0 -90 -139q-24 -44 -37 -65q-40 -64 -112 -145q-71 -81 -101 -106 q-69 -57 -140 -57h-32v-640h32q72 0 167 -32t193.5 -64t179.5 -32q189 0 189 167q0 26 -5 56q30 16 47.5 52.5t17.5 73.5t-18 69q53 50 53 119q0 25 -10 55.5t-25 47.5h331q52 0 90 38t38 90zM1792 769q0 -105 -75.5 -181t-180.5 -76h-169q-4 -62 -37 -119q3 -21 3 -43 q0 -101 -60 -178q1 -139 -85 -219.5t-227 -80.5q-133 0 -322 69q-164 59 -223 59h-288q-53 0 -90.5 37.5t-37.5 90.5v640q0 53 37.5 90.5t90.5 37.5h288q10 0 21.5 4.5t23.5 14t22.5 18t24 22.5t20.5 21.5t19 21.5t14 17q65 74 100 129q13 21 33 62t37 72t40.5 63t55 49.5 t69.5 17.5q125 0 206.5 -67t81.5 -189q0 -68 -22 -128h374q104 0 180 -76t76 -179z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1376 128h32v640h-32q-35 0 -67.5 12t-62.5 37t-50 46t-49 54q-2 3 -3.5 4.5t-4 4.5t-4.5 5q-72 81 -112 145q-14 22 -38 68q-1 3 -10.5 22.5t-18.5 36t-20 35.5t-21.5 30.5t-18.5 11.5q-71 0 -115.5 -30.5t-44.5 -97.5q0 -43 15 -84.5t33 -68t33 -55t15 -48.5h-576 q-50 0 -89 -38.5t-39 -89.5q0 -52 38 -90t90 -38h331q-15 -17 -25 -47.5t-10 -55.5q0 -69 53 -119q-18 -32 -18 -69t17.5 -73.5t47.5 -52.5q-4 -24 -4 -56q0 -85 48.5 -126t135.5 -41q84 0 183 32t194 64t167 32zM1664 192q0 26 -19 45t-45 19t-45 -19t-19 -45t19 -45 t45 -19t45 19t19 45zM1792 768v-640q0 -53 -37.5 -90.5t-90.5 -37.5h-288q-59 0 -223 -59q-190 -69 -317 -69q-142 0 -230 77.5t-87 217.5l1 5q-61 76 -61 178q0 22 3 43q-33 57 -37 119h-169q-105 0 -180.5 76t-75.5 181q0 103 76 179t180 76h374q-22 60 -22 128 q0 122 81.5 189t206.5 67q38 0 69.5 -17.5t55 -49.5t40.5 -63t37 -72t33 -62q35 -55 100 -129q2 -3 14 -17t19 -21.5t20.5 -21.5t24 -22.5t22.5 -18t23.5 -14t21.5 -4.5h288q53 0 90.5 -37.5t37.5 -90.5z" /> -<glyph unicode="" d="M1280 -64q0 26 -19 45t-45 19t-45 -19t-19 -45t19 -45t45 -19t45 19t19 45zM1408 700q0 189 -167 189q-26 0 -56 -5q-16 30 -52.5 47.5t-73.5 17.5t-69 -18q-50 53 -119 53q-25 0 -55.5 -10t-47.5 -25v331q0 52 -38 90t-90 38q-51 0 -89.5 -39t-38.5 -89v-576 q-20 0 -48.5 15t-55 33t-68 33t-84.5 15q-67 0 -97.5 -44.5t-30.5 -115.5q0 -24 139 -90q44 -24 65 -37q64 -40 145 -112q81 -71 106 -101q57 -69 57 -140v-32h640v32q0 72 32 167t64 193.5t32 179.5zM1536 705q0 -133 -69 -322q-59 -164 -59 -223v-288q0 -53 -37.5 -90.5 t-90.5 -37.5h-640q-53 0 -90.5 37.5t-37.5 90.5v288q0 10 -4.5 21.5t-14 23.5t-18 22.5t-22.5 24t-21.5 20.5t-21.5 19t-17 14q-74 65 -129 100q-21 13 -62 33t-72 37t-63 40.5t-49.5 55t-17.5 69.5q0 125 67 206.5t189 81.5q68 0 128 -22v374q0 104 76 180t179 76 q105 0 181 -75.5t76 -180.5v-169q62 -4 119 -37q21 3 43 3q101 0 178 -60q139 1 219.5 -85t80.5 -227z" /> -<glyph unicode="" d="M1408 576q0 84 -32 183t-64 194t-32 167v32h-640v-32q0 -35 -12 -67.5t-37 -62.5t-46 -50t-54 -49q-9 -8 -14 -12q-81 -72 -145 -112q-22 -14 -68 -38q-3 -1 -22.5 -10.5t-36 -18.5t-35.5 -20t-30.5 -21.5t-11.5 -18.5q0 -71 30.5 -115.5t97.5 -44.5q43 0 84.5 15t68 33 t55 33t48.5 15v-576q0 -50 38.5 -89t89.5 -39q52 0 90 38t38 90v331q46 -35 103 -35q69 0 119 53q32 -18 69 -18t73.5 17.5t52.5 47.5q24 -4 56 -4q85 0 126 48.5t41 135.5zM1280 1344q0 26 -19 45t-45 19t-45 -19t-19 -45t19 -45t45 -19t45 19t19 45zM1536 580 q0 -142 -77.5 -230t-217.5 -87l-5 1q-76 -61 -178 -61q-22 0 -43 3q-54 -30 -119 -37v-169q0 -105 -76 -180.5t-181 -75.5q-103 0 -179 76t-76 180v374q-54 -22 -128 -22q-121 0 -188.5 81.5t-67.5 206.5q0 38 17.5 69.5t49.5 55t63 40.5t72 37t62 33q55 35 129 100 q3 2 17 14t21.5 19t21.5 20.5t22.5 24t18 22.5t14 23.5t4.5 21.5v288q0 53 37.5 90.5t90.5 37.5h640q53 0 90.5 -37.5t37.5 -90.5v-288q0 -59 59 -223q69 -190 69 -317z" /> -<glyph unicode="" d="M1280 576v128q0 26 -19 45t-45 19h-502l189 189q19 19 19 45t-19 45l-91 91q-18 18 -45 18t-45 -18l-362 -362l-91 -91q-18 -18 -18 -45t18 -45l91 -91l362 -362q18 -18 45 -18t45 18l91 91q18 18 18 45t-18 45l-189 189h502q26 0 45 19t19 45zM1536 640 q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" d="M1285 640q0 27 -18 45l-91 91l-362 362q-18 18 -45 18t-45 -18l-91 -91q-18 -18 -18 -45t18 -45l189 -189h-502q-26 0 -45 -19t-19 -45v-128q0 -26 19 -45t45 -19h502l-189 -189q-19 -19 -19 -45t19 -45l91 -91q18 -18 45 -18t45 18l362 362l91 91q18 18 18 45zM1536 640 q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" d="M1284 641q0 27 -18 45l-362 362l-91 91q-18 18 -45 18t-45 -18l-91 -91l-362 -362q-18 -18 -18 -45t18 -45l91 -91q18 -18 45 -18t45 18l189 189v-502q0 -26 19 -45t45 -19h128q26 0 45 19t19 45v502l189 -189q19 -19 45 -19t45 19l91 91q18 18 18 45zM1536 640 q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" d="M1284 639q0 27 -18 45l-91 91q-18 18 -45 18t-45 -18l-189 -189v502q0 26 -19 45t-45 19h-128q-26 0 -45 -19t-19 -45v-502l-189 189q-19 19 -45 19t-45 -19l-91 -91q-18 -18 -18 -45t18 -45l362 -362l91 -91q18 -18 45 -18t45 18l91 91l362 362q18 18 18 45zM1536 640 q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" d="M768 1408q209 0 385.5 -103t279.5 -279.5t103 -385.5t-103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103zM1042 887q-2 -1 -9.5 -9.5t-13.5 -9.5q2 0 4.5 5t5 11t3.5 7q6 7 22 15q14 6 52 12q34 8 51 -11 q-2 2 9.5 13t14.5 12q3 2 15 4.5t15 7.5l2 22q-12 -1 -17.5 7t-6.5 21q0 -2 -6 -8q0 7 -4.5 8t-11.5 -1t-9 -1q-10 3 -15 7.5t-8 16.5t-4 15q-2 5 -9.5 10.5t-9.5 10.5q-1 2 -2.5 5.5t-3 6.5t-4 5.5t-5.5 2.5t-7 -5t-7.5 -10t-4.5 -5q-3 2 -6 1.5t-4.5 -1t-4.5 -3t-5 -3.5 q-3 -2 -8.5 -3t-8.5 -2q15 5 -1 11q-10 4 -16 3q9 4 7.5 12t-8.5 14h5q-1 4 -8.5 8.5t-17.5 8.5t-13 6q-8 5 -34 9.5t-33 0.5q-5 -6 -4.5 -10.5t4 -14t3.5 -12.5q1 -6 -5.5 -13t-6.5 -12q0 -7 14 -15.5t10 -21.5q-3 -8 -16 -16t-16 -12q-5 -8 -1.5 -18.5t10.5 -16.5 q2 -2 1.5 -4t-3.5 -4.5t-5.5 -4t-6.5 -3.5l-3 -2q-11 -5 -20.5 6t-13.5 26q-7 25 -16 30q-23 8 -29 -1q-5 13 -41 26q-25 9 -58 4q6 1 0 15q-7 15 -19 12q3 6 4 17.5t1 13.5q3 13 12 23q1 1 7 8.5t9.5 13.5t0.5 6q35 -4 50 11q5 5 11.5 17t10.5 17q9 6 14 5.5t14.5 -5.5 t14.5 -5q14 -1 15.5 11t-7.5 20q12 -1 3 17q-5 7 -8 9q-12 4 -27 -5q-8 -4 2 -8q-1 1 -9.5 -10.5t-16.5 -17.5t-16 5q-1 1 -5.5 13.5t-9.5 13.5q-8 0 -16 -15q3 8 -11 15t-24 8q19 12 -8 27q-7 4 -20.5 5t-19.5 -4q-5 -7 -5.5 -11.5t5 -8t10.5 -5.5t11.5 -4t8.5 -3 q14 -10 8 -14q-2 -1 -8.5 -3.5t-11.5 -4.5t-6 -4q-3 -4 0 -14t-2 -14q-5 5 -9 17.5t-7 16.5q7 -9 -25 -6l-10 1q-4 0 -16 -2t-20.5 -1t-13.5 8q-4 8 0 20q1 4 4 2q-4 3 -11 9.5t-10 8.5q-46 -15 -94 -41q6 -1 12 1q5 2 13 6.5t10 5.5q34 14 42 7l5 5q14 -16 20 -25 q-7 4 -30 1q-20 -6 -22 -12q7 -12 5 -18q-4 3 -11.5 10t-14.5 11t-15 5q-16 0 -22 -1q-146 -80 -235 -222q7 -7 12 -8q4 -1 5 -9t2.5 -11t11.5 3q9 -8 3 -19q1 1 44 -27q19 -17 21 -21q3 -11 -10 -18q-1 2 -9 9t-9 4q-3 -5 0.5 -18.5t10.5 -12.5q-7 0 -9.5 -16t-2.5 -35.5 t-1 -23.5l2 -1q-3 -12 5.5 -34.5t21.5 -19.5q-13 -3 20 -43q6 -8 8 -9q3 -2 12 -7.5t15 -10t10 -10.5q4 -5 10 -22.5t14 -23.5q-2 -6 9.5 -20t10.5 -23q-1 0 -2.5 -1t-2.5 -1q3 -7 15.5 -14t15.5 -13q1 -3 2 -10t3 -11t8 -2q2 20 -24 62q-15 25 -17 29q-3 5 -5.5 15.5 t-4.5 14.5q2 0 6 -1.5t8.5 -3.5t7.5 -4t2 -3q-3 -7 2 -17.5t12 -18.5t17 -19t12 -13q6 -6 14 -19.5t0 -13.5q9 0 20 -10t17 -20q5 -8 8 -26t5 -24q2 -7 8.5 -13.5t12.5 -9.5l16 -8t13 -7q5 -2 18.5 -10.5t21.5 -11.5q10 -4 16 -4t14.5 2.5t13.5 3.5q15 2 29 -15t21 -21 q36 -19 55 -11q-2 -1 0.5 -7.5t8 -15.5t9 -14.5t5.5 -8.5q5 -6 18 -15t18 -15q6 4 7 9q-3 -8 7 -20t18 -10q14 3 14 32q-31 -15 -49 18q0 1 -2.5 5.5t-4 8.5t-2.5 8.5t0 7.5t5 3q9 0 10 3.5t-2 12.5t-4 13q-1 8 -11 20t-12 15q-5 -9 -16 -8t-16 9q0 -1 -1.5 -5.5t-1.5 -6.5 q-13 0 -15 1q1 3 2.5 17.5t3.5 22.5q1 4 5.5 12t7.5 14.5t4 12.5t-4.5 9.5t-17.5 2.5q-19 -1 -26 -20q-1 -3 -3 -10.5t-5 -11.5t-9 -7q-7 -3 -24 -2t-24 5q-13 8 -22.5 29t-9.5 37q0 10 2.5 26.5t3 25t-5.5 24.5q3 2 9 9.5t10 10.5q2 1 4.5 1.5t4.5 0t4 1.5t3 6q-1 1 -4 3 q-3 3 -4 3q7 -3 28.5 1.5t27.5 -1.5q15 -11 22 2q0 1 -2.5 9.5t-0.5 13.5q5 -27 29 -9q3 -3 15.5 -5t17.5 -5q3 -2 7 -5.5t5.5 -4.5t5 0.5t8.5 6.5q10 -14 12 -24q11 -40 19 -44q7 -3 11 -2t4.5 9.5t0 14t-1.5 12.5l-1 8v18l-1 8q-15 3 -18.5 12t1.5 18.5t15 18.5q1 1 8 3.5 t15.5 6.5t12.5 8q21 19 15 35q7 0 11 9q-1 0 -5 3t-7.5 5t-4.5 2q9 5 2 16q5 3 7.5 11t7.5 10q9 -12 21 -2q7 8 1 16q5 7 20.5 10.5t18.5 9.5q7 -2 8 2t1 12t3 12q4 5 15 9t13 5l17 11q3 4 0 4q18 -2 31 11q10 11 -6 20q3 6 -3 9.5t-15 5.5q3 1 11.5 0.5t10.5 1.5 q15 10 -7 16q-17 5 -43 -12zM879 10q206 36 351 189q-3 3 -12.5 4.5t-12.5 3.5q-18 7 -24 8q1 7 -2.5 13t-8 9t-12.5 8t-11 7q-2 2 -7 6t-7 5.5t-7.5 4.5t-8.5 2t-10 -1l-3 -1q-3 -1 -5.5 -2.5t-5.5 -3t-4 -3t0 -2.5q-21 17 -36 22q-5 1 -11 5.5t-10.5 7t-10 1.5t-11.5 -7 q-5 -5 -6 -15t-2 -13q-7 5 0 17.5t2 18.5q-3 6 -10.5 4.5t-12 -4.5t-11.5 -8.5t-9 -6.5t-8.5 -5.5t-8.5 -7.5q-3 -4 -6 -12t-5 -11q-2 4 -11.5 6.5t-9.5 5.5q2 -10 4 -35t5 -38q7 -31 -12 -48q-27 -25 -29 -40q-4 -22 12 -26q0 -7 -8 -20.5t-7 -21.5q0 -6 2 -16z" /> -<glyph unicode="" horiz-adv-x="1664" d="M384 64q0 26 -19 45t-45 19t-45 -19t-19 -45t19 -45t45 -19t45 19t19 45zM1028 484l-682 -682q-37 -37 -90 -37q-52 0 -91 37l-106 108q-38 36 -38 90q0 53 38 91l681 681q39 -98 114.5 -173.5t173.5 -114.5zM1662 919q0 -39 -23 -106q-47 -134 -164.5 -217.5 t-258.5 -83.5q-185 0 -316.5 131.5t-131.5 316.5t131.5 316.5t316.5 131.5q58 0 121.5 -16.5t107.5 -46.5q16 -11 16 -28t-16 -28l-293 -169v-224l193 -107q5 3 79 48.5t135.5 81t70.5 35.5q15 0 23.5 -10t8.5 -25z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1024 128h640v128h-640v-128zM640 640h1024v128h-1024v-128zM1280 1152h384v128h-384v-128zM1792 320v-256q0 -26 -19 -45t-45 -19h-1664q-26 0 -45 19t-19 45v256q0 26 19 45t45 19h1664q26 0 45 -19t19 -45zM1792 832v-256q0 -26 -19 -45t-45 -19h-1664q-26 0 -45 19 t-19 45v256q0 26 19 45t45 19h1664q26 0 45 -19t19 -45zM1792 1344v-256q0 -26 -19 -45t-45 -19h-1664q-26 0 -45 19t-19 45v256q0 26 19 45t45 19h1664q26 0 45 -19t19 -45z" /> -<glyph unicode="" horiz-adv-x="1408" d="M1403 1241q17 -41 -14 -70l-493 -493v-742q0 -42 -39 -59q-13 -5 -25 -5q-27 0 -45 19l-256 256q-19 19 -19 45v486l-493 493q-31 29 -14 70q17 39 59 39h1280q42 0 59 -39z" /> -<glyph unicode="" horiz-adv-x="1792" d="M640 1280h512v128h-512v-128zM1792 640v-480q0 -66 -47 -113t-113 -47h-1472q-66 0 -113 47t-47 113v480h672v-160q0 -26 19 -45t45 -19h320q26 0 45 19t19 45v160h672zM1024 640v-128h-256v128h256zM1792 1120v-384h-1792v384q0 66 47 113t113 47h352v160q0 40 28 68 t68 28h576q40 0 68 -28t28 -68v-160h352q66 0 113 -47t47 -113z" /> -<glyph unicode="" d="M1283 995l-355 -355l355 -355l144 144q29 31 70 14q39 -17 39 -59v-448q0 -26 -19 -45t-45 -19h-448q-42 0 -59 40q-17 39 14 69l144 144l-355 355l-355 -355l144 -144q31 -30 14 -69q-17 -40 -59 -40h-448q-26 0 -45 19t-19 45v448q0 42 40 59q39 17 69 -14l144 -144 l355 355l-355 355l-144 -144q-19 -19 -45 -19q-12 0 -24 5q-40 17 -40 59v448q0 26 19 45t45 19h448q42 0 59 -40q17 -39 -14 -69l-144 -144l355 -355l355 355l-144 144q-31 30 -14 69q17 40 59 40h448q26 0 45 -19t19 -45v-448q0 -42 -39 -59q-13 -5 -25 -5q-26 0 -45 19z " /> -<glyph unicode="" horiz-adv-x="1920" d="M593 640q-162 -5 -265 -128h-134q-82 0 -138 40.5t-56 118.5q0 353 124 353q6 0 43.5 -21t97.5 -42.5t119 -21.5q67 0 133 23q-5 -37 -5 -66q0 -139 81 -256zM1664 3q0 -120 -73 -189.5t-194 -69.5h-874q-121 0 -194 69.5t-73 189.5q0 53 3.5 103.5t14 109t26.5 108.5 t43 97.5t62 81t85.5 53.5t111.5 20q10 0 43 -21.5t73 -48t107 -48t135 -21.5t135 21.5t107 48t73 48t43 21.5q61 0 111.5 -20t85.5 -53.5t62 -81t43 -97.5t26.5 -108.5t14 -109t3.5 -103.5zM640 1280q0 -106 -75 -181t-181 -75t-181 75t-75 181t75 181t181 75t181 -75 t75 -181zM1344 896q0 -159 -112.5 -271.5t-271.5 -112.5t-271.5 112.5t-112.5 271.5t112.5 271.5t271.5 112.5t271.5 -112.5t112.5 -271.5zM1920 671q0 -78 -56 -118.5t-138 -40.5h-134q-103 123 -265 128q81 117 81 256q0 29 -5 66q66 -23 133 -23q59 0 119 21.5t97.5 42.5 t43.5 21q124 0 124 -353zM1792 1280q0 -106 -75 -181t-181 -75t-181 75t-75 181t75 181t181 75t181 -75t75 -181z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1456 320q0 40 -28 68l-208 208q-28 28 -68 28q-42 0 -72 -32q3 -3 19 -18.5t21.5 -21.5t15 -19t13 -25.5t3.5 -27.5q0 -40 -28 -68t-68 -28q-15 0 -27.5 3.5t-25.5 13t-19 15t-21.5 21.5t-18.5 19q-33 -31 -33 -73q0 -40 28 -68l206 -207q27 -27 68 -27q40 0 68 26 l147 146q28 28 28 67zM753 1025q0 40 -28 68l-206 207q-28 28 -68 28q-39 0 -68 -27l-147 -146q-28 -28 -28 -67q0 -40 28 -68l208 -208q27 -27 68 -27q42 0 72 31q-3 3 -19 18.5t-21.5 21.5t-15 19t-13 25.5t-3.5 27.5q0 40 28 68t68 28q15 0 27.5 -3.5t25.5 -13t19 -15 t21.5 -21.5t18.5 -19q33 31 33 73zM1648 320q0 -120 -85 -203l-147 -146q-83 -83 -203 -83q-121 0 -204 85l-206 207q-83 83 -83 203q0 123 88 209l-88 88q-86 -88 -208 -88q-120 0 -204 84l-208 208q-84 84 -84 204t85 203l147 146q83 83 203 83q121 0 204 -85l206 -207 q83 -83 83 -203q0 -123 -88 -209l88 -88q86 88 208 88q120 0 204 -84l208 -208q84 -84 84 -204z" /> -<glyph unicode="" horiz-adv-x="1920" d="M1920 384q0 -159 -112.5 -271.5t-271.5 -112.5h-1088q-185 0 -316.5 131.5t-131.5 316.5q0 132 71 241.5t187 163.5q-2 28 -2 43q0 212 150 362t362 150q158 0 286.5 -88t187.5 -230q70 62 166 62q106 0 181 -75t75 -181q0 -75 -41 -138q129 -30 213 -134.5t84 -239.5z " /> -<glyph unicode="" horiz-adv-x="1664" d="M1527 88q56 -89 21.5 -152.5t-140.5 -63.5h-1152q-106 0 -140.5 63.5t21.5 152.5l503 793v399h-64q-26 0 -45 19t-19 45t19 45t45 19h512q26 0 45 -19t19 -45t-19 -45t-45 -19h-64v-399zM748 813l-272 -429h712l-272 429l-20 31v37v399h-128v-399v-37z" /> -<glyph unicode="" horiz-adv-x="1792" d="M960 640q26 0 45 -19t19 -45t-19 -45t-45 -19t-45 19t-19 45t19 45t45 19zM1260 576l507 -398q28 -20 25 -56q-5 -35 -35 -51l-128 -64q-13 -7 -29 -7q-17 0 -31 8l-690 387l-110 -66q-8 -4 -12 -5q14 -49 10 -97q-7 -77 -56 -147.5t-132 -123.5q-132 -84 -277 -84 q-136 0 -222 78q-90 84 -79 207q7 76 56 147t131 124q132 84 278 84q83 0 151 -31q9 13 22 22l122 73l-122 73q-13 9 -22 22q-68 -31 -151 -31q-146 0 -278 84q-82 53 -131 124t-56 147q-5 59 15.5 113t63.5 93q85 79 222 79q145 0 277 -84q83 -52 132 -123t56 -148 q4 -48 -10 -97q4 -1 12 -5l110 -66l690 387q14 8 31 8q16 0 29 -7l128 -64q30 -16 35 -51q3 -36 -25 -56zM579 836q46 42 21 108t-106 117q-92 59 -192 59q-74 0 -113 -36q-46 -42 -21 -108t106 -117q92 -59 192 -59q74 0 113 36zM494 91q81 51 106 117t-21 108 q-39 36 -113 36q-100 0 -192 -59q-81 -51 -106 -117t21 -108q39 -36 113 -36q100 0 192 59zM672 704l96 -58v11q0 36 33 56l14 8l-79 47l-26 -26q-3 -3 -10 -11t-12 -12q-2 -2 -4 -3.5t-3 -2.5zM896 480l96 -32l736 576l-128 64l-768 -431v-113l-160 -96l9 -8q2 -2 7 -6 q4 -4 11 -12t11 -12l26 -26zM1600 64l128 64l-520 408l-177 -138q-2 -3 -13 -7z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1696 1152q40 0 68 -28t28 -68v-1216q0 -40 -28 -68t-68 -28h-960q-40 0 -68 28t-28 68v288h-544q-40 0 -68 28t-28 68v672q0 40 20 88t48 76l408 408q28 28 76 48t88 20h416q40 0 68 -28t28 -68v-328q68 40 128 40h416zM1152 939l-299 -299h299v299zM512 1323l-299 -299 h299v299zM708 676l316 316v416h-384v-416q0 -40 -28 -68t-68 -28h-416v-640h512v256q0 40 20 88t48 76zM1664 -128v1152h-384v-416q0 -40 -28 -68t-68 -28h-416v-640h896z" /> -<glyph unicode="" horiz-adv-x="1408" d="M1404 151q0 -117 -79 -196t-196 -79q-135 0 -235 100l-777 776q-113 115 -113 271q0 159 110 270t269 111q158 0 273 -113l605 -606q10 -10 10 -22q0 -16 -30.5 -46.5t-46.5 -30.5q-13 0 -23 10l-606 607q-79 77 -181 77q-106 0 -179 -75t-73 -181q0 -105 76 -181 l776 -777q63 -63 145 -63q64 0 106 42t42 106q0 82 -63 145l-581 581q-26 24 -60 24q-29 0 -48 -19t-19 -48q0 -32 25 -59l410 -410q10 -10 10 -22q0 -16 -31 -47t-47 -31q-12 0 -22 10l-410 410q-63 61 -63 149q0 82 57 139t139 57q88 0 149 -63l581 -581q100 -98 100 -235 z" /> -<glyph unicode="" d="M384 0h768v384h-768v-384zM1280 0h128v896q0 14 -10 38.5t-20 34.5l-281 281q-10 10 -34 20t-39 10v-416q0 -40 -28 -68t-68 -28h-576q-40 0 -68 28t-28 68v416h-128v-1280h128v416q0 40 28 68t68 28h832q40 0 68 -28t28 -68v-416zM896 928v320q0 13 -9.5 22.5t-22.5 9.5 h-192q-13 0 -22.5 -9.5t-9.5 -22.5v-320q0 -13 9.5 -22.5t22.5 -9.5h192q13 0 22.5 9.5t9.5 22.5zM1536 896v-928q0 -40 -28 -68t-68 -28h-1344q-40 0 -68 28t-28 68v1344q0 40 28 68t68 28h928q40 0 88 -20t76 -48l280 -280q28 -28 48 -76t20 -88z" /> -<glyph unicode="" d="M1536 1120v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" d="M1536 192v-128q0 -26 -19 -45t-45 -19h-1408q-26 0 -45 19t-19 45v128q0 26 19 45t45 19h1408q26 0 45 -19t19 -45zM1536 704v-128q0 -26 -19 -45t-45 -19h-1408q-26 0 -45 19t-19 45v128q0 26 19 45t45 19h1408q26 0 45 -19t19 -45zM1536 1216v-128q0 -26 -19 -45 t-45 -19h-1408q-26 0 -45 19t-19 45v128q0 26 19 45t45 19h1408q26 0 45 -19t19 -45z" /> -<glyph unicode="" horiz-adv-x="1792" d="M384 128q0 -80 -56 -136t-136 -56t-136 56t-56 136t56 136t136 56t136 -56t56 -136zM384 640q0 -80 -56 -136t-136 -56t-136 56t-56 136t56 136t136 56t136 -56t56 -136zM1792 224v-192q0 -13 -9.5 -22.5t-22.5 -9.5h-1216q-13 0 -22.5 9.5t-9.5 22.5v192q0 13 9.5 22.5 t22.5 9.5h1216q13 0 22.5 -9.5t9.5 -22.5zM384 1152q0 -80 -56 -136t-136 -56t-136 56t-56 136t56 136t136 56t136 -56t56 -136zM1792 736v-192q0 -13 -9.5 -22.5t-22.5 -9.5h-1216q-13 0 -22.5 9.5t-9.5 22.5v192q0 13 9.5 22.5t22.5 9.5h1216q13 0 22.5 -9.5t9.5 -22.5z M1792 1248v-192q0 -13 -9.5 -22.5t-22.5 -9.5h-1216q-13 0 -22.5 9.5t-9.5 22.5v192q0 13 9.5 22.5t22.5 9.5h1216q13 0 22.5 -9.5t9.5 -22.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M381 -84q0 -80 -54.5 -126t-135.5 -46q-106 0 -172 66l57 88q49 -45 106 -45q29 0 50.5 14.5t21.5 42.5q0 64 -105 56l-26 56q8 10 32.5 43.5t42.5 54t37 38.5v1q-16 0 -48.5 -1t-48.5 -1v-53h-106v152h333v-88l-95 -115q51 -12 81 -49t30 -88zM383 543v-159h-362 q-6 36 -6 54q0 51 23.5 93t56.5 68t66 47.5t56.5 43.5t23.5 45q0 25 -14.5 38.5t-39.5 13.5q-46 0 -81 -58l-85 59q24 51 71.5 79.5t105.5 28.5q73 0 123 -41.5t50 -112.5q0 -50 -34 -91.5t-75 -64.5t-75.5 -50.5t-35.5 -52.5h127v60h105zM1792 224v-192q0 -13 -9.5 -22.5 t-22.5 -9.5h-1216q-13 0 -22.5 9.5t-9.5 22.5v192q0 14 9 23t23 9h1216q13 0 22.5 -9.5t9.5 -22.5zM384 1123v-99h-335v99h107q0 41 0.5 122t0.5 121v12h-2q-8 -17 -50 -54l-71 76l136 127h106v-404h108zM1792 736v-192q0 -13 -9.5 -22.5t-22.5 -9.5h-1216q-13 0 -22.5 9.5 t-9.5 22.5v192q0 14 9 23t23 9h1216q13 0 22.5 -9.5t9.5 -22.5zM1792 1248v-192q0 -13 -9.5 -22.5t-22.5 -9.5h-1216q-13 0 -22.5 9.5t-9.5 22.5v192q0 13 9.5 22.5t22.5 9.5h1216q13 0 22.5 -9.5t9.5 -22.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1760 640q14 0 23 -9t9 -23v-64q0 -14 -9 -23t-23 -9h-1728q-14 0 -23 9t-9 23v64q0 14 9 23t23 9h1728zM483 704q-28 35 -51 80q-48 97 -48 188q0 181 134 309q133 127 393 127q50 0 167 -19q66 -12 177 -48q10 -38 21 -118q14 -123 14 -183q0 -18 -5 -45l-12 -3l-84 6 l-14 2q-50 149 -103 205q-88 91 -210 91q-114 0 -182 -59q-67 -58 -67 -146q0 -73 66 -140t279 -129q69 -20 173 -66q58 -28 95 -52h-743zM990 448h411q7 -39 7 -92q0 -111 -41 -212q-23 -55 -71 -104q-37 -35 -109 -81q-80 -48 -153 -66q-80 -21 -203 -21q-114 0 -195 23 l-140 40q-57 16 -72 28q-8 8 -8 22v13q0 108 -2 156q-1 30 0 68l2 37v44l102 2q15 -34 30 -71t22.5 -56t12.5 -27q35 -57 80 -94q43 -36 105 -57q59 -22 132 -22q64 0 139 27q77 26 122 86q47 61 47 129q0 84 -81 157q-34 29 -137 71z" /> -<glyph unicode="" d="M48 1313q-37 2 -45 4l-3 88q13 1 40 1q60 0 112 -4q132 -7 166 -7q86 0 168 3q116 4 146 5q56 0 86 2l-1 -14l2 -64v-9q-60 -9 -124 -9q-60 0 -79 -25q-13 -14 -13 -132q0 -13 0.5 -32.5t0.5 -25.5l1 -229l14 -280q6 -124 51 -202q35 -59 96 -92q88 -47 177 -47 q104 0 191 28q56 18 99 51q48 36 65 64q36 56 53 114q21 73 21 229q0 79 -3.5 128t-11 122.5t-13.5 159.5l-4 59q-5 67 -24 88q-34 35 -77 34l-100 -2l-14 3l2 86h84l205 -10q76 -3 196 10l18 -2q6 -38 6 -51q0 -7 -4 -31q-45 -12 -84 -13q-73 -11 -79 -17q-15 -15 -15 -41 q0 -7 1.5 -27t1.5 -31q8 -19 22 -396q6 -195 -15 -304q-15 -76 -41 -122q-38 -65 -112 -123q-75 -57 -182 -89q-109 -33 -255 -33q-167 0 -284 46q-119 47 -179 122q-61 76 -83 195q-16 80 -16 237v333q0 188 -17 213q-25 36 -147 39zM1536 -96v64q0 14 -9 23t-23 9h-1472 q-14 0 -23 -9t-9 -23v-64q0 -14 9 -23t23 -9h1472q14 0 23 9t9 23z" /> -<glyph unicode="" horiz-adv-x="1664" d="M512 160v192q0 14 -9 23t-23 9h-320q-14 0 -23 -9t-9 -23v-192q0 -14 9 -23t23 -9h320q14 0 23 9t9 23zM512 544v192q0 14 -9 23t-23 9h-320q-14 0 -23 -9t-9 -23v-192q0 -14 9 -23t23 -9h320q14 0 23 9t9 23zM1024 160v192q0 14 -9 23t-23 9h-320q-14 0 -23 -9t-9 -23 v-192q0 -14 9 -23t23 -9h320q14 0 23 9t9 23zM512 928v192q0 14 -9 23t-23 9h-320q-14 0 -23 -9t-9 -23v-192q0 -14 9 -23t23 -9h320q14 0 23 9t9 23zM1024 544v192q0 14 -9 23t-23 9h-320q-14 0 -23 -9t-9 -23v-192q0 -14 9 -23t23 -9h320q14 0 23 9t9 23zM1536 160v192 q0 14 -9 23t-23 9h-320q-14 0 -23 -9t-9 -23v-192q0 -14 9 -23t23 -9h320q14 0 23 9t9 23zM1024 928v192q0 14 -9 23t-23 9h-320q-14 0 -23 -9t-9 -23v-192q0 -14 9 -23t23 -9h320q14 0 23 9t9 23zM1536 544v192q0 14 -9 23t-23 9h-320q-14 0 -23 -9t-9 -23v-192 q0 -14 9 -23t23 -9h320q14 0 23 9t9 23zM1536 928v192q0 14 -9 23t-23 9h-320q-14 0 -23 -9t-9 -23v-192q0 -14 9 -23t23 -9h320q14 0 23 9t9 23zM1664 1248v-1088q0 -66 -47 -113t-113 -47h-1344q-66 0 -113 47t-47 113v1088q0 66 47 113t113 47h1344q66 0 113 -47t47 -113 z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1190 955l293 293l-107 107l-293 -293zM1637 1248q0 -27 -18 -45l-1286 -1286q-18 -18 -45 -18t-45 18l-198 198q-18 18 -18 45t18 45l1286 1286q18 18 45 18t45 -18l198 -198q18 -18 18 -45zM286 1438l98 -30l-98 -30l-30 -98l-30 98l-98 30l98 30l30 98zM636 1276 l196 -60l-196 -60l-60 -196l-60 196l-196 60l196 60l60 196zM1566 798l98 -30l-98 -30l-30 -98l-30 98l-98 30l98 30l30 98zM926 1438l98 -30l-98 -30l-30 -98l-30 98l-98 30l98 30l30 98z" /> -<glyph unicode="" horiz-adv-x="1792" d="M640 128q0 52 -38 90t-90 38t-90 -38t-38 -90t38 -90t90 -38t90 38t38 90zM256 640h384v256h-158q-13 0 -22 -9l-195 -195q-9 -9 -9 -22v-30zM1536 128q0 52 -38 90t-90 38t-90 -38t-38 -90t38 -90t90 -38t90 38t38 90zM1792 1216v-1024q0 -15 -4 -26.5t-13.5 -18.5 t-16.5 -11.5t-23.5 -6t-22.5 -2t-25.5 0t-22.5 0.5q0 -106 -75 -181t-181 -75t-181 75t-75 181h-384q0 -106 -75 -181t-181 -75t-181 75t-75 181h-64q-3 0 -22.5 -0.5t-25.5 0t-22.5 2t-23.5 6t-16.5 11.5t-13.5 18.5t-4 26.5q0 26 19 45t45 19v320q0 8 -0.5 35t0 38 t2.5 34.5t6.5 37t14 30.5t22.5 30l198 198q19 19 50.5 32t58.5 13h160v192q0 26 19 45t45 19h1024q26 0 45 -19t19 -45z" /> -<glyph unicode="" d="M1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103q-111 0 -218 32q59 93 78 164q9 34 54 211q20 -39 73 -67.5t114 -28.5q121 0 216 68.5t147 188.5t52 270q0 114 -59.5 214t-172.5 163t-255 63q-105 0 -196 -29t-154.5 -77t-109 -110.5t-67 -129.5t-21.5 -134 q0 -104 40 -183t117 -111q30 -12 38 20q2 7 8 31t8 30q6 23 -11 43q-51 61 -51 151q0 151 104.5 259.5t273.5 108.5q151 0 235.5 -82t84.5 -213q0 -170 -68.5 -289t-175.5 -119q-61 0 -98 43.5t-23 104.5q8 35 26.5 93.5t30 103t11.5 75.5q0 50 -27 83t-77 33 q-62 0 -105 -57t-43 -142q0 -73 25 -122l-99 -418q-17 -70 -13 -177q-206 91 -333 281t-127 423q0 209 103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" d="M1248 1408q119 0 203.5 -84.5t84.5 -203.5v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-725q85 122 108 210q9 34 53 209q21 -39 73.5 -67t112.5 -28q181 0 295.5 147.5t114.5 373.5q0 84 -35 162.5t-96.5 139t-152.5 97t-197 36.5q-104 0 -194.5 -28.5t-153 -76.5 t-107.5 -109.5t-66.5 -128t-21.5 -132.5q0 -102 39.5 -180t116.5 -110q13 -5 23.5 0t14.5 19q10 44 15 61q6 23 -11 42q-50 62 -50 150q0 150 103.5 256.5t270.5 106.5q149 0 232.5 -81t83.5 -210q0 -168 -67.5 -286t-173.5 -118q-60 0 -97 43.5t-23 103.5q8 34 26.5 92.5 t29.5 102t11 74.5q0 49 -26.5 81.5t-75.5 32.5q-61 0 -103.5 -56.5t-42.5 -139.5q0 -72 24 -121l-98 -414q-24 -100 -7 -254h-183q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960z" /> -<glyph unicode="" d="M829 318q0 -76 -58.5 -112.5t-139.5 -36.5q-41 0 -80.5 9.5t-75.5 28.5t-58 53t-22 78q0 46 25 80t65.5 51.5t82 25t84.5 7.5q20 0 31 -2q2 -1 23 -16.5t26 -19t23 -18t24.5 -22t19 -22.5t17 -26t9 -26.5t4.5 -31.5zM755 863q0 -60 -33 -99.5t-92 -39.5q-53 0 -93 42.5 t-57.5 96.5t-17.5 106q0 61 32 104t92 43q53 0 93.5 -45t58 -101t17.5 -107zM861 1120l88 64h-265q-85 0 -161 -32t-127.5 -98t-51.5 -153q0 -93 64.5 -154.5t158.5 -61.5q22 0 43 3q-13 -29 -13 -54q0 -44 40 -94q-175 -12 -257 -63q-47 -29 -75.5 -73t-28.5 -95 q0 -43 18.5 -77.5t48.5 -56.5t69 -37t77.5 -21t76.5 -6q60 0 120.5 15.5t113.5 46t86 82.5t33 117q0 49 -20 89.5t-49 66.5t-58 47.5t-49 44t-20 44.5t15.5 42.5t37.5 39.5t44 42t37.5 59.5t15.5 82.5q0 60 -22.5 99.5t-72.5 90.5h83zM1152 672h128v64h-128v128h-64v-128 h-128v-64h128v-160h64v160zM1536 1120v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" horiz-adv-x="1664" d="M735 740q0 -36 32 -70.5t77.5 -68t90.5 -73.5t77 -104t32 -142q0 -90 -48 -173q-72 -122 -211 -179.5t-298 -57.5q-132 0 -246.5 41.5t-171.5 137.5q-37 60 -37 131q0 81 44.5 150t118.5 115q131 82 404 100q-32 42 -47.5 74t-15.5 73q0 36 21 85q-46 -4 -68 -4 q-148 0 -249.5 96.5t-101.5 244.5q0 82 36 159t99 131q77 66 182.5 98t217.5 32h418l-138 -88h-131q74 -63 112 -133t38 -160q0 -72 -24.5 -129.5t-59 -93t-69.5 -65t-59.5 -61.5t-24.5 -66zM589 836q38 0 78 16.5t66 43.5q53 57 53 159q0 58 -17 125t-48.5 129.5 t-84.5 103.5t-117 41q-42 0 -82.5 -19.5t-65.5 -52.5q-47 -59 -47 -160q0 -46 10 -97.5t31.5 -103t52 -92.5t75 -67t96.5 -26zM591 -37q58 0 111.5 13t99 39t73 73t27.5 109q0 25 -7 49t-14.5 42t-27 41.5t-29.5 35t-38.5 34.5t-36.5 29t-41.5 30t-36.5 26q-16 2 -48 2 q-53 0 -105 -7t-107.5 -25t-97 -46t-68.5 -74.5t-27 -105.5q0 -70 35 -123.5t91.5 -83t119 -44t127.5 -14.5zM1401 839h213v-108h-213v-219h-105v219h-212v108h212v217h105v-217z" /> -<glyph unicode="" horiz-adv-x="1920" d="M768 384h384v96h-128v448h-114l-148 -137l77 -80q42 37 55 57h2v-288h-128v-96zM1280 640q0 -70 -21 -142t-59.5 -134t-101.5 -101t-138 -39t-138 39t-101.5 101t-59.5 134t-21 142t21 142t59.5 134t101.5 101t138 39t138 -39t101.5 -101t59.5 -134t21 -142zM1792 384 v512q-106 0 -181 75t-75 181h-1152q0 -106 -75 -181t-181 -75v-512q106 0 181 -75t75 -181h1152q0 106 75 181t181 75zM1920 1216v-1152q0 -26 -19 -45t-45 -19h-1792q-26 0 -45 19t-19 45v1152q0 26 19 45t45 19h1792q26 0 45 -19t19 -45z" /> -<glyph unicode="" horiz-adv-x="1024" d="M1024 832q0 -26 -19 -45l-448 -448q-19 -19 -45 -19t-45 19l-448 448q-19 19 -19 45t19 45t45 19h896q26 0 45 -19t19 -45z" /> -<glyph unicode="" horiz-adv-x="1024" d="M1024 320q0 -26 -19 -45t-45 -19h-896q-26 0 -45 19t-19 45t19 45l448 448q19 19 45 19t45 -19l448 -448q19 -19 19 -45z" /> -<glyph unicode="" horiz-adv-x="640" d="M640 1088v-896q0 -26 -19 -45t-45 -19t-45 19l-448 448q-19 19 -19 45t19 45l448 448q19 19 45 19t45 -19t19 -45z" /> -<glyph unicode="" horiz-adv-x="640" d="M576 640q0 -26 -19 -45l-448 -448q-19 -19 -45 -19t-45 19t-19 45v896q0 26 19 45t45 19t45 -19l448 -448q19 -19 19 -45z" /> -<glyph unicode="" horiz-adv-x="1664" d="M160 0h608v1152h-640v-1120q0 -13 9.5 -22.5t22.5 -9.5zM1536 32v1120h-640v-1152h608q13 0 22.5 9.5t9.5 22.5zM1664 1248v-1216q0 -66 -47 -113t-113 -47h-1344q-66 0 -113 47t-47 113v1216q0 66 47 113t113 47h1344q66 0 113 -47t47 -113z" /> -<glyph unicode="" horiz-adv-x="1024" d="M1024 448q0 -26 -19 -45l-448 -448q-19 -19 -45 -19t-45 19l-448 448q-19 19 -19 45t19 45t45 19h896q26 0 45 -19t19 -45zM1024 832q0 -26 -19 -45t-45 -19h-896q-26 0 -45 19t-19 45t19 45l448 448q19 19 45 19t45 -19l448 -448q19 -19 19 -45z" /> -<glyph unicode="" horiz-adv-x="1024" d="M1024 448q0 -26 -19 -45l-448 -448q-19 -19 -45 -19t-45 19l-448 448q-19 19 -19 45t19 45t45 19h896q26 0 45 -19t19 -45z" /> -<glyph unicode="" horiz-adv-x="1024" d="M1024 832q0 -26 -19 -45t-45 -19h-896q-26 0 -45 19t-19 45t19 45l448 448q19 19 45 19t45 -19l448 -448q19 -19 19 -45z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1792 826v-794q0 -66 -47 -113t-113 -47h-1472q-66 0 -113 47t-47 113v794q44 -49 101 -87q362 -246 497 -345q57 -42 92.5 -65.5t94.5 -48t110 -24.5h1h1q51 0 110 24.5t94.5 48t92.5 65.5q170 123 498 345q57 39 100 87zM1792 1120q0 -79 -49 -151t-122 -123 q-376 -261 -468 -325q-10 -7 -42.5 -30.5t-54 -38t-52 -32.5t-57.5 -27t-50 -9h-1h-1q-23 0 -50 9t-57.5 27t-52 32.5t-54 38t-42.5 30.5q-91 64 -262 182.5t-205 142.5q-62 42 -117 115.5t-55 136.5q0 78 41.5 130t118.5 52h1472q65 0 112.5 -47t47.5 -113z" /> -<glyph unicode="" d="M349 911v-991h-330v991h330zM370 1217q1 -73 -50.5 -122t-135.5 -49h-2q-82 0 -132 49t-50 122q0 74 51.5 122.5t134.5 48.5t133 -48.5t51 -122.5zM1536 488v-568h-329v530q0 105 -40.5 164.5t-126.5 59.5q-63 0 -105.5 -34.5t-63.5 -85.5q-11 -30 -11 -81v-553h-329 q2 399 2 647t-1 296l-1 48h329v-144h-2q20 32 41 56t56.5 52t87 43.5t114.5 15.5q171 0 275 -113.5t104 -332.5z" /> -<glyph unicode="" d="M1536 640q0 -156 -61 -298t-164 -245t-245 -164t-298 -61q-172 0 -327 72.5t-264 204.5q-7 10 -6.5 22.5t8.5 20.5l137 138q10 9 25 9q16 -2 23 -12q73 -95 179 -147t225 -52q104 0 198.5 40.5t163.5 109.5t109.5 163.5t40.5 198.5t-40.5 198.5t-109.5 163.5 t-163.5 109.5t-198.5 40.5q-98 0 -188 -35.5t-160 -101.5l137 -138q31 -30 14 -69q-17 -40 -59 -40h-448q-26 0 -45 19t-19 45v448q0 42 40 59q39 17 69 -14l130 -129q107 101 244.5 156.5t284.5 55.5q156 0 298 -61t245 -164t164 -245t61 -298z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1771 0q0 -53 -37 -90l-107 -108q-39 -37 -91 -37q-53 0 -90 37l-363 364q-38 36 -38 90q0 53 43 96l-256 256l-126 -126q-14 -14 -34 -14t-34 14q2 -2 12.5 -12t12.5 -13t10 -11.5t10 -13.5t6 -13.5t5.5 -16.5t1.5 -18q0 -38 -28 -68q-3 -3 -16.5 -18t-19 -20.5 t-18.5 -16.5t-22 -15.5t-22 -9t-26 -4.5q-40 0 -68 28l-408 408q-28 28 -28 68q0 13 4.5 26t9 22t15.5 22t16.5 18.5t20.5 19t18 16.5q30 28 68 28q10 0 18 -1.5t16.5 -5.5t13.5 -6t13.5 -10t11.5 -10t13 -12.5t12 -12.5q-14 14 -14 34t14 34l348 348q14 14 34 14t34 -14 q-2 2 -12.5 12t-12.5 13t-10 11.5t-10 13.5t-6 13.5t-5.5 16.5t-1.5 18q0 38 28 68q3 3 16.5 18t19 20.5t18.5 16.5t22 15.5t22 9t26 4.5q40 0 68 -28l408 -408q28 -28 28 -68q0 -13 -4.5 -26t-9 -22t-15.5 -22t-16.5 -18.5t-20.5 -19t-18 -16.5q-30 -28 -68 -28 q-10 0 -18 1.5t-16.5 5.5t-13.5 6t-13.5 10t-11.5 10t-13 12.5t-12 12.5q14 -14 14 -34t-14 -34l-126 -126l256 -256q43 43 96 43q52 0 91 -37l363 -363q37 -39 37 -91z" /> -<glyph unicode="" horiz-adv-x="1792" d="M384 384q0 53 -37.5 90.5t-90.5 37.5t-90.5 -37.5t-37.5 -90.5t37.5 -90.5t90.5 -37.5t90.5 37.5t37.5 90.5zM576 832q0 53 -37.5 90.5t-90.5 37.5t-90.5 -37.5t-37.5 -90.5t37.5 -90.5t90.5 -37.5t90.5 37.5t37.5 90.5zM1004 351l101 382q6 26 -7.5 48.5t-38.5 29.5 t-48 -6.5t-30 -39.5l-101 -382q-60 -5 -107 -43.5t-63 -98.5q-20 -77 20 -146t117 -89t146 20t89 117q16 60 -6 117t-72 91zM1664 384q0 53 -37.5 90.5t-90.5 37.5t-90.5 -37.5t-37.5 -90.5t37.5 -90.5t90.5 -37.5t90.5 37.5t37.5 90.5zM1024 1024q0 53 -37.5 90.5 t-90.5 37.5t-90.5 -37.5t-37.5 -90.5t37.5 -90.5t90.5 -37.5t90.5 37.5t37.5 90.5zM1472 832q0 53 -37.5 90.5t-90.5 37.5t-90.5 -37.5t-37.5 -90.5t37.5 -90.5t90.5 -37.5t90.5 37.5t37.5 90.5zM1792 384q0 -261 -141 -483q-19 -29 -54 -29h-1402q-35 0 -54 29 q-141 221 -141 483q0 182 71 348t191 286t286 191t348 71t348 -71t286 -191t191 -286t71 -348z" /> -<glyph unicode="" horiz-adv-x="1792" d="M896 1152q-204 0 -381.5 -69.5t-282 -187.5t-104.5 -255q0 -112 71.5 -213.5t201.5 -175.5l87 -50l-27 -96q-24 -91 -70 -172q152 63 275 171l43 38l57 -6q69 -8 130 -8q204 0 381.5 69.5t282 187.5t104.5 255t-104.5 255t-282 187.5t-381.5 69.5zM1792 640 q0 -174 -120 -321.5t-326 -233t-450 -85.5q-70 0 -145 8q-198 -175 -460 -242q-49 -14 -114 -22h-5q-15 0 -27 10.5t-16 27.5v1q-3 4 -0.5 12t2 10t4.5 9.5l6 9t7 8.5t8 9q7 8 31 34.5t34.5 38t31 39.5t32.5 51t27 59t26 76q-157 89 -247.5 220t-90.5 281q0 174 120 321.5 t326 233t450 85.5t450 -85.5t326 -233t120 -321.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M704 1152q-153 0 -286 -52t-211.5 -141t-78.5 -191q0 -82 53 -158t149 -132l97 -56l-35 -84q34 20 62 39l44 31l53 -10q78 -14 153 -14q153 0 286 52t211.5 141t78.5 191t-78.5 191t-211.5 141t-286 52zM704 1280q191 0 353.5 -68.5t256.5 -186.5t94 -257t-94 -257 t-256.5 -186.5t-353.5 -68.5q-86 0 -176 16q-124 -88 -278 -128q-36 -9 -86 -16h-3q-11 0 -20.5 8t-11.5 21q-1 3 -1 6.5t0.5 6.5t2 6l2.5 5t3.5 5.5t4 5t4.5 5t4 4.5q5 6 23 25t26 29.5t22.5 29t25 38.5t20.5 44q-124 72 -195 177t-71 224q0 139 94 257t256.5 186.5 t353.5 68.5zM1526 111q10 -24 20.5 -44t25 -38.5t22.5 -29t26 -29.5t23 -25q1 -1 4 -4.5t4.5 -5t4 -5t3.5 -5.5l2.5 -5t2 -6t0.5 -6.5t-1 -6.5q-3 -14 -13 -22t-22 -7q-50 7 -86 16q-154 40 -278 128q-90 -16 -176 -16q-271 0 -472 132q58 -4 88 -4q161 0 309 45t264 129 q125 92 192 212t67 254q0 77 -23 152q129 -71 204 -178t75 -230q0 -120 -71 -224.5t-195 -176.5z" /> -<glyph unicode="" horiz-adv-x="896" d="M885 970q18 -20 7 -44l-540 -1157q-13 -25 -42 -25q-4 0 -14 2q-17 5 -25.5 19t-4.5 30l197 808l-406 -101q-4 -1 -12 -1q-18 0 -31 11q-18 15 -13 39l201 825q4 14 16 23t28 9h328q19 0 32 -12.5t13 -29.5q0 -8 -5 -18l-171 -463l396 98q8 2 12 2q19 0 34 -15z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1792 288v-320q0 -40 -28 -68t-68 -28h-320q-40 0 -68 28t-28 68v320q0 40 28 68t68 28h96v192h-512v-192h96q40 0 68 -28t28 -68v-320q0 -40 -28 -68t-68 -28h-320q-40 0 -68 28t-28 68v320q0 40 28 68t68 28h96v192h-512v-192h96q40 0 68 -28t28 -68v-320 q0 -40 -28 -68t-68 -28h-320q-40 0 -68 28t-28 68v320q0 40 28 68t68 28h96v192q0 52 38 90t90 38h512v192h-96q-40 0 -68 28t-28 68v320q0 40 28 68t68 28h320q40 0 68 -28t28 -68v-320q0 -40 -28 -68t-68 -28h-96v-192h512q52 0 90 -38t38 -90v-192h96q40 0 68 -28t28 -68 z" /> -<glyph unicode="" horiz-adv-x="1664" d="M896 708v-580q0 -104 -76 -180t-180 -76t-180 76t-76 180q0 26 19 45t45 19t45 -19t19 -45q0 -50 39 -89t89 -39t89 39t39 89v580q33 11 64 11t64 -11zM1664 681q0 -13 -9.5 -22.5t-22.5 -9.5q-11 0 -23 10q-49 46 -93 69t-102 23q-68 0 -128 -37t-103 -97 q-7 -10 -17.5 -28t-14.5 -24q-11 -17 -28 -17q-18 0 -29 17q-4 6 -14.5 24t-17.5 28q-43 60 -102.5 97t-127.5 37t-127.5 -37t-102.5 -97q-7 -10 -17.5 -28t-14.5 -24q-11 -17 -29 -17q-17 0 -28 17q-4 6 -14.5 24t-17.5 28q-43 60 -103 97t-128 37q-58 0 -102 -23t-93 -69 q-12 -10 -23 -10q-13 0 -22.5 9.5t-9.5 22.5q0 5 1 7q45 183 172.5 319.5t298 204.5t360.5 68q140 0 274.5 -40t246.5 -113.5t194.5 -187t115.5 -251.5q1 -2 1 -7zM896 1408v-98q-42 2 -64 2t-64 -2v98q0 26 19 45t45 19t45 -19t19 -45z" /> -<glyph unicode="" horiz-adv-x="1792" d="M768 -128h896v640h-416q-40 0 -68 28t-28 68v416h-384v-1152zM1024 1312v64q0 13 -9.5 22.5t-22.5 9.5h-704q-13 0 -22.5 -9.5t-9.5 -22.5v-64q0 -13 9.5 -22.5t22.5 -9.5h704q13 0 22.5 9.5t9.5 22.5zM1280 640h299l-299 299v-299zM1792 512v-672q0 -40 -28 -68t-68 -28 h-960q-40 0 -68 28t-28 68v160h-544q-40 0 -68 28t-28 68v1344q0 40 28 68t68 28h1088q40 0 68 -28t28 -68v-328q21 -13 36 -28l408 -408q28 -28 48 -76t20 -88z" /> -<glyph unicode="" horiz-adv-x="1024" d="M736 960q0 -13 -9.5 -22.5t-22.5 -9.5t-22.5 9.5t-9.5 22.5q0 46 -54 71t-106 25q-13 0 -22.5 9.5t-9.5 22.5t9.5 22.5t22.5 9.5q50 0 99.5 -16t87 -54t37.5 -90zM896 960q0 72 -34.5 134t-90 101.5t-123 62t-136.5 22.5t-136.5 -22.5t-123 -62t-90 -101.5t-34.5 -134 q0 -101 68 -180q10 -11 30.5 -33t30.5 -33q128 -153 141 -298h228q13 145 141 298q10 11 30.5 33t30.5 33q68 79 68 180zM1024 960q0 -155 -103 -268q-45 -49 -74.5 -87t-59.5 -95.5t-34 -107.5q47 -28 47 -82q0 -37 -25 -64q25 -27 25 -64q0 -52 -45 -81q13 -23 13 -47 q0 -46 -31.5 -71t-77.5 -25q-20 -44 -60 -70t-87 -26t-87 26t-60 70q-46 0 -77.5 25t-31.5 71q0 24 13 47q-45 29 -45 81q0 37 25 64q-25 27 -25 64q0 54 47 82q-4 50 -34 107.5t-59.5 95.5t-74.5 87q-103 113 -103 268q0 99 44.5 184.5t117 142t164 89t186.5 32.5 t186.5 -32.5t164 -89t117 -142t44.5 -184.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1792 352v-192q0 -13 -9.5 -22.5t-22.5 -9.5h-1376v-192q0 -13 -9.5 -22.5t-22.5 -9.5q-12 0 -24 10l-319 320q-9 9 -9 22q0 14 9 23l320 320q9 9 23 9q13 0 22.5 -9.5t9.5 -22.5v-192h1376q13 0 22.5 -9.5t9.5 -22.5zM1792 896q0 -14 -9 -23l-320 -320q-9 -9 -23 -9 q-13 0 -22.5 9.5t-9.5 22.5v192h-1376q-13 0 -22.5 9.5t-9.5 22.5v192q0 13 9.5 22.5t22.5 9.5h1376v192q0 14 9 23t23 9q12 0 24 -10l319 -319q9 -9 9 -23z" /> -<glyph unicode="" horiz-adv-x="1920" d="M1280 608q0 14 -9 23t-23 9h-224v352q0 13 -9.5 22.5t-22.5 9.5h-192q-13 0 -22.5 -9.5t-9.5 -22.5v-352h-224q-13 0 -22.5 -9.5t-9.5 -22.5q0 -14 9 -23l352 -352q9 -9 23 -9t23 9l351 351q10 12 10 24zM1920 384q0 -159 -112.5 -271.5t-271.5 -112.5h-1088 q-185 0 -316.5 131.5t-131.5 316.5q0 130 70 240t188 165q-2 30 -2 43q0 212 150 362t362 150q156 0 285.5 -87t188.5 -231q71 62 166 62q106 0 181 -75t75 -181q0 -76 -41 -138q130 -31 213.5 -135.5t83.5 -238.5z" /> -<glyph unicode="" horiz-adv-x="1920" d="M1280 672q0 14 -9 23l-352 352q-9 9 -23 9t-23 -9l-351 -351q-10 -12 -10 -24q0 -14 9 -23t23 -9h224v-352q0 -13 9.5 -22.5t22.5 -9.5h192q13 0 22.5 9.5t9.5 22.5v352h224q13 0 22.5 9.5t9.5 22.5zM1920 384q0 -159 -112.5 -271.5t-271.5 -112.5h-1088 q-185 0 -316.5 131.5t-131.5 316.5q0 130 70 240t188 165q-2 30 -2 43q0 212 150 362t362 150q156 0 285.5 -87t188.5 -231q71 62 166 62q106 0 181 -75t75 -181q0 -76 -41 -138q130 -31 213.5 -135.5t83.5 -238.5z" /> -<glyph unicode="" horiz-adv-x="1408" d="M384 192q0 -26 -19 -45t-45 -19t-45 19t-19 45t19 45t45 19t45 -19t19 -45zM1408 131q0 -121 -73 -190t-194 -69h-874q-121 0 -194 69t-73 190q0 68 5.5 131t24 138t47.5 132.5t81 103t120 60.5q-22 -52 -22 -120v-203q-58 -20 -93 -70t-35 -111q0 -80 56 -136t136 -56 t136 56t56 136q0 61 -35.5 111t-92.5 70v203q0 62 25 93q132 -104 295 -104t295 104q25 -31 25 -93v-64q-106 0 -181 -75t-75 -181v-89q-32 -29 -32 -71q0 -40 28 -68t68 -28t68 28t28 68q0 42 -32 71v89q0 52 38 90t90 38t90 -38t38 -90v-89q-32 -29 -32 -71q0 -40 28 -68 t68 -28t68 28t28 68q0 42 -32 71v89q0 68 -34.5 127.5t-93.5 93.5q0 10 0.5 42.5t0 48t-2.5 41.5t-7 47t-13 40q68 -15 120 -60.5t81 -103t47.5 -132.5t24 -138t5.5 -131zM1088 1024q0 -159 -112.5 -271.5t-271.5 -112.5t-271.5 112.5t-112.5 271.5t112.5 271.5t271.5 112.5 t271.5 -112.5t112.5 -271.5z" /> -<glyph unicode="" horiz-adv-x="1408" d="M1280 832q0 26 -19 45t-45 19t-45 -19t-19 -45t19 -45t45 -19t45 19t19 45zM1408 832q0 -62 -35.5 -111t-92.5 -70v-395q0 -159 -131.5 -271.5t-316.5 -112.5t-316.5 112.5t-131.5 271.5v132q-164 20 -274 128t-110 252v512q0 26 19 45t45 19q6 0 16 -2q17 30 47 48 t65 18q53 0 90.5 -37.5t37.5 -90.5t-37.5 -90.5t-90.5 -37.5q-33 0 -64 18v-402q0 -106 94 -181t226 -75t226 75t94 181v402q-31 -18 -64 -18q-53 0 -90.5 37.5t-37.5 90.5t37.5 90.5t90.5 37.5q35 0 65 -18t47 -48q10 2 16 2q26 0 45 -19t19 -45v-512q0 -144 -110 -252 t-274 -128v-132q0 -106 94 -181t226 -75t226 75t94 181v395q-57 21 -92.5 70t-35.5 111q0 80 56 136t136 56t136 -56t56 -136z" /> -<glyph unicode="" horiz-adv-x="1792" d="M640 1152h512v128h-512v-128zM288 1152v-1280h-64q-92 0 -158 66t-66 158v832q0 92 66 158t158 66h64zM1408 1152v-1280h-1024v1280h128v160q0 40 28 68t68 28h576q40 0 68 -28t28 -68v-160h128zM1792 928v-832q0 -92 -66 -158t-158 -66h-64v1280h64q92 0 158 -66 t66 -158z" /> -<glyph unicode="" horiz-adv-x="1792" d="M912 -160q0 16 -16 16q-59 0 -101.5 42.5t-42.5 101.5q0 16 -16 16t-16 -16q0 -73 51.5 -124.5t124.5 -51.5q16 0 16 16zM1728 128q0 -52 -38 -90t-90 -38h-448q0 -106 -75 -181t-181 -75t-181 75t-75 181h-448q-52 0 -90 38t-38 90q50 42 91 88t85 119.5t74.5 158.5 t50 206t19.5 260q0 152 117 282.5t307 158.5q-8 19 -8 39q0 40 28 68t68 28t68 -28t28 -68q0 -20 -8 -39q190 -28 307 -158.5t117 -282.5q0 -139 19.5 -260t50 -206t74.5 -158.5t85 -119.5t91 -88z" /> -<glyph unicode="" horiz-adv-x="1920" d="M1664 896q0 80 -56 136t-136 56h-64v-384h64q80 0 136 56t56 136zM0 128h1792q0 -106 -75 -181t-181 -75h-1280q-106 0 -181 75t-75 181zM1856 896q0 -159 -112.5 -271.5t-271.5 -112.5h-64v-32q0 -92 -66 -158t-158 -66h-704q-92 0 -158 66t-66 158v736q0 26 19 45 t45 19h1152q159 0 271.5 -112.5t112.5 -271.5z" /> -<glyph unicode="" horiz-adv-x="1408" d="M640 1472v-640q0 -61 -35.5 -111t-92.5 -70v-779q0 -52 -38 -90t-90 -38h-128q-52 0 -90 38t-38 90v779q-57 20 -92.5 70t-35.5 111v640q0 26 19 45t45 19t45 -19t19 -45v-416q0 -26 19 -45t45 -19t45 19t19 45v416q0 26 19 45t45 19t45 -19t19 -45v-416q0 -26 19 -45 t45 -19t45 19t19 45v416q0 26 19 45t45 19t45 -19t19 -45zM1408 1472v-1600q0 -52 -38 -90t-90 -38h-128q-52 0 -90 38t-38 90v512h-224q-13 0 -22.5 9.5t-9.5 22.5v800q0 132 94 226t226 94h256q26 0 45 -19t19 -45z" /> -<glyph unicode="" d="M1468 1156q28 -28 48 -76t20 -88v-1152q0 -40 -28 -68t-68 -28h-1344q-40 0 -68 28t-28 68v1600q0 40 28 68t68 28h896q40 0 88 -20t76 -48zM1024 1400v-376h376q-10 29 -22 41l-313 313q-12 12 -41 22zM1408 -128v1024h-416q-40 0 -68 28t-28 68v416h-768v-1536h1280z M384 736q0 14 9 23t23 9h704q14 0 23 -9t9 -23v-64q0 -14 -9 -23t-23 -9h-704q-14 0 -23 9t-9 23v64zM1120 512q14 0 23 -9t9 -23v-64q0 -14 -9 -23t-23 -9h-704q-14 0 -23 9t-9 23v64q0 14 9 23t23 9h704zM1120 256q14 0 23 -9t9 -23v-64q0 -14 -9 -23t-23 -9h-704 q-14 0 -23 9t-9 23v64q0 14 9 23t23 9h704z" /> -<glyph unicode="" horiz-adv-x="1408" d="M384 224v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5zM384 480v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5z M640 480v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5zM384 736v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5z M1152 224v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5zM896 480v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5z M640 736v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5zM384 992v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5z M1152 480v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5zM896 736v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5z M640 992v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5zM384 1248v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5z M1152 736v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5zM896 992v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5z M640 1248v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5zM1152 992v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5z M896 1248v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5zM1152 1248v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5z M896 -128h384v1536h-1152v-1536h384v224q0 13 9.5 22.5t22.5 9.5h320q13 0 22.5 -9.5t9.5 -22.5v-224zM1408 1472v-1664q0 -26 -19 -45t-45 -19h-1280q-26 0 -45 19t-19 45v1664q0 26 19 45t45 19h1280q26 0 45 -19t19 -45z" /> -<glyph unicode="" horiz-adv-x="1408" d="M384 224v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5zM384 480v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5z M640 480v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5zM384 736v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5z M1152 224v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5zM896 480v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5z M640 736v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5zM1152 480v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5z M896 736v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5zM1152 736v-64q0 -13 -9.5 -22.5t-22.5 -9.5h-64q-13 0 -22.5 9.5t-9.5 22.5v64q0 13 9.5 22.5t22.5 9.5h64q13 0 22.5 -9.5t9.5 -22.5z M896 -128h384v1152h-256v-32q0 -40 -28 -68t-68 -28h-448q-40 0 -68 28t-28 68v32h-256v-1152h384v224q0 13 9.5 22.5t22.5 9.5h320q13 0 22.5 -9.5t9.5 -22.5v-224zM896 1056v320q0 13 -9.5 22.5t-22.5 9.5h-64q-13 0 -22.5 -9.5t-9.5 -22.5v-96h-128v96q0 13 -9.5 22.5 t-22.5 9.5h-64q-13 0 -22.5 -9.5t-9.5 -22.5v-320q0 -13 9.5 -22.5t22.5 -9.5h64q13 0 22.5 9.5t9.5 22.5v96h128v-96q0 -13 9.5 -22.5t22.5 -9.5h64q13 0 22.5 9.5t9.5 22.5zM1408 1088v-1280q0 -26 -19 -45t-45 -19h-1280q-26 0 -45 19t-19 45v1280q0 26 19 45t45 19h320 v288q0 40 28 68t68 28h448q40 0 68 -28t28 -68v-288h320q26 0 45 -19t19 -45z" /> -<glyph unicode="" horiz-adv-x="1920" d="M640 128q0 53 -37.5 90.5t-90.5 37.5t-90.5 -37.5t-37.5 -90.5t37.5 -90.5t90.5 -37.5t90.5 37.5t37.5 90.5zM256 640h384v256h-158q-14 -2 -22 -9l-195 -195q-7 -12 -9 -22v-30zM1536 128q0 53 -37.5 90.5t-90.5 37.5t-90.5 -37.5t-37.5 -90.5t37.5 -90.5t90.5 -37.5 t90.5 37.5t37.5 90.5zM1664 800v192q0 14 -9 23t-23 9h-224v224q0 14 -9 23t-23 9h-192q-14 0 -23 -9t-9 -23v-224h-224q-14 0 -23 -9t-9 -23v-192q0 -14 9 -23t23 -9h224v-224q0 -14 9 -23t23 -9h192q14 0 23 9t9 23v224h224q14 0 23 9t9 23zM1920 1344v-1152 q0 -26 -19 -45t-45 -19h-192q0 -106 -75 -181t-181 -75t-181 75t-75 181h-384q0 -106 -75 -181t-181 -75t-181 75t-75 181h-128q-26 0 -45 19t-19 45t19 45t45 19v416q0 26 13 58t32 51l198 198q19 19 51 32t58 13h160v320q0 26 19 45t45 19h1152q26 0 45 -19t19 -45z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1280 416v192q0 14 -9 23t-23 9h-224v224q0 14 -9 23t-23 9h-192q-14 0 -23 -9t-9 -23v-224h-224q-14 0 -23 -9t-9 -23v-192q0 -14 9 -23t23 -9h224v-224q0 -14 9 -23t23 -9h192q14 0 23 9t9 23v224h224q14 0 23 9t9 23zM640 1152h512v128h-512v-128zM256 1152v-1280h-32 q-92 0 -158 66t-66 158v832q0 92 66 158t158 66h32zM1440 1152v-1280h-1088v1280h160v160q0 40 28 68t68 28h576q40 0 68 -28t28 -68v-160h160zM1792 928v-832q0 -92 -66 -158t-158 -66h-32v1280h32q92 0 158 -66t66 -158z" /> -<glyph unicode="" horiz-adv-x="1920" d="M1920 576q-1 -32 -288 -96l-352 -32l-224 -64h-64l-293 -352h69q26 0 45 -4.5t19 -11.5t-19 -11.5t-45 -4.5h-96h-160h-64v32h64v416h-160l-192 -224h-96l-32 32v192h32v32h128v8l-192 24v128l192 24v8h-128v32h-32v192l32 32h96l192 -224h160v416h-64v32h64h160h96 q26 0 45 -4.5t19 -11.5t-19 -11.5t-45 -4.5h-69l293 -352h64l224 -64l352 -32q261 -58 287 -93z" /> -<glyph unicode="" horiz-adv-x="1664" d="M640 640v384h-256v-256q0 -53 37.5 -90.5t90.5 -37.5h128zM1664 192v-192h-1152v192l128 192h-128q-159 0 -271.5 112.5t-112.5 271.5v320l-64 64l32 128h480l32 128h960l32 -192l-64 -32v-800z" /> -<glyph unicode="" d="M1280 192v896q0 26 -19 45t-45 19h-128q-26 0 -45 -19t-19 -45v-320h-512v320q0 26 -19 45t-45 19h-128q-26 0 -45 -19t-19 -45v-896q0 -26 19 -45t45 -19h128q26 0 45 19t19 45v320h512v-320q0 -26 19 -45t45 -19h128q26 0 45 19t19 45zM1536 1120v-960 q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" d="M1280 576v128q0 26 -19 45t-45 19h-320v320q0 26 -19 45t-45 19h-128q-26 0 -45 -19t-19 -45v-320h-320q-26 0 -45 -19t-19 -45v-128q0 -26 19 -45t45 -19h320v-320q0 -26 19 -45t45 -19h128q26 0 45 19t19 45v320h320q26 0 45 19t19 45zM1536 1120v-960 q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" horiz-adv-x="1024" d="M627 160q0 -13 -10 -23l-50 -50q-10 -10 -23 -10t-23 10l-466 466q-10 10 -10 23t10 23l466 466q10 10 23 10t23 -10l50 -50q10 -10 10 -23t-10 -23l-393 -393l393 -393q10 -10 10 -23zM1011 160q0 -13 -10 -23l-50 -50q-10 -10 -23 -10t-23 10l-466 466q-10 10 -10 23 t10 23l466 466q10 10 23 10t23 -10l50 -50q10 -10 10 -23t-10 -23l-393 -393l393 -393q10 -10 10 -23z" /> -<glyph unicode="" horiz-adv-x="1024" d="M595 576q0 -13 -10 -23l-466 -466q-10 -10 -23 -10t-23 10l-50 50q-10 10 -10 23t10 23l393 393l-393 393q-10 10 -10 23t10 23l50 50q10 10 23 10t23 -10l466 -466q10 -10 10 -23zM979 576q0 -13 -10 -23l-466 -466q-10 -10 -23 -10t-23 10l-50 50q-10 10 -10 23t10 23 l393 393l-393 393q-10 10 -10 23t10 23l50 50q10 10 23 10t23 -10l466 -466q10 -10 10 -23z" /> -<glyph unicode="" horiz-adv-x="1152" d="M1075 224q0 -13 -10 -23l-50 -50q-10 -10 -23 -10t-23 10l-393 393l-393 -393q-10 -10 -23 -10t-23 10l-50 50q-10 10 -10 23t10 23l466 466q10 10 23 10t23 -10l466 -466q10 -10 10 -23zM1075 608q0 -13 -10 -23l-50 -50q-10 -10 -23 -10t-23 10l-393 393l-393 -393 q-10 -10 -23 -10t-23 10l-50 50q-10 10 -10 23t10 23l466 466q10 10 23 10t23 -10l466 -466q10 -10 10 -23z" /> -<glyph unicode="" horiz-adv-x="1152" d="M1075 672q0 -13 -10 -23l-466 -466q-10 -10 -23 -10t-23 10l-466 466q-10 10 -10 23t10 23l50 50q10 10 23 10t23 -10l393 -393l393 393q10 10 23 10t23 -10l50 -50q10 -10 10 -23zM1075 1056q0 -13 -10 -23l-466 -466q-10 -10 -23 -10t-23 10l-466 466q-10 10 -10 23 t10 23l50 50q10 10 23 10t23 -10l393 -393l393 393q10 10 23 10t23 -10l50 -50q10 -10 10 -23z" /> -<glyph unicode="" horiz-adv-x="640" d="M627 992q0 -13 -10 -23l-393 -393l393 -393q10 -10 10 -23t-10 -23l-50 -50q-10 -10 -23 -10t-23 10l-466 466q-10 10 -10 23t10 23l466 466q10 10 23 10t23 -10l50 -50q10 -10 10 -23z" /> -<glyph unicode="" horiz-adv-x="640" d="M595 576q0 -13 -10 -23l-466 -466q-10 -10 -23 -10t-23 10l-50 50q-10 10 -10 23t10 23l393 393l-393 393q-10 10 -10 23t10 23l50 50q10 10 23 10t23 -10l466 -466q10 -10 10 -23z" /> -<glyph unicode="" horiz-adv-x="1152" d="M1075 352q0 -13 -10 -23l-50 -50q-10 -10 -23 -10t-23 10l-393 393l-393 -393q-10 -10 -23 -10t-23 10l-50 50q-10 10 -10 23t10 23l466 466q10 10 23 10t23 -10l466 -466q10 -10 10 -23z" /> -<glyph unicode="" horiz-adv-x="1152" d="M1075 800q0 -13 -10 -23l-466 -466q-10 -10 -23 -10t-23 10l-466 466q-10 10 -10 23t10 23l50 50q10 10 23 10t23 -10l393 -393l393 393q10 10 23 10t23 -10l50 -50q10 -10 10 -23z" /> -<glyph unicode="" horiz-adv-x="1920" d="M1792 544v832q0 13 -9.5 22.5t-22.5 9.5h-1600q-13 0 -22.5 -9.5t-9.5 -22.5v-832q0 -13 9.5 -22.5t22.5 -9.5h1600q13 0 22.5 9.5t9.5 22.5zM1920 1376v-1088q0 -66 -47 -113t-113 -47h-544q0 -37 16 -77.5t32 -71t16 -43.5q0 -26 -19 -45t-45 -19h-512q-26 0 -45 19 t-19 45q0 14 16 44t32 70t16 78h-544q-66 0 -113 47t-47 113v1088q0 66 47 113t113 47h1600q66 0 113 -47t47 -113z" /> -<glyph unicode="" horiz-adv-x="1920" d="M416 256q-66 0 -113 47t-47 113v704q0 66 47 113t113 47h1088q66 0 113 -47t47 -113v-704q0 -66 -47 -113t-113 -47h-1088zM384 1120v-704q0 -13 9.5 -22.5t22.5 -9.5h1088q13 0 22.5 9.5t9.5 22.5v704q0 13 -9.5 22.5t-22.5 9.5h-1088q-13 0 -22.5 -9.5t-9.5 -22.5z M1760 192h160v-96q0 -40 -47 -68t-113 -28h-1600q-66 0 -113 28t-47 68v96h160h1600zM1040 96q16 0 16 16t-16 16h-160q-16 0 -16 -16t16 -16h160z" /> -<glyph unicode="" horiz-adv-x="1152" d="M640 128q0 26 -19 45t-45 19t-45 -19t-19 -45t19 -45t45 -19t45 19t19 45zM1024 288v960q0 13 -9.5 22.5t-22.5 9.5h-832q-13 0 -22.5 -9.5t-9.5 -22.5v-960q0 -13 9.5 -22.5t22.5 -9.5h832q13 0 22.5 9.5t9.5 22.5zM1152 1248v-1088q0 -66 -47 -113t-113 -47h-832 q-66 0 -113 47t-47 113v1088q0 66 47 113t113 47h832q66 0 113 -47t47 -113z" /> -<glyph unicode="" horiz-adv-x="768" d="M464 128q0 33 -23.5 56.5t-56.5 23.5t-56.5 -23.5t-23.5 -56.5t23.5 -56.5t56.5 -23.5t56.5 23.5t23.5 56.5zM672 288v704q0 13 -9.5 22.5t-22.5 9.5h-512q-13 0 -22.5 -9.5t-9.5 -22.5v-704q0 -13 9.5 -22.5t22.5 -9.5h512q13 0 22.5 9.5t9.5 22.5zM480 1136 q0 16 -16 16h-160q-16 0 -16 -16t16 -16h160q16 0 16 16zM768 1152v-1024q0 -52 -38 -90t-90 -38h-512q-52 0 -90 38t-38 90v1024q0 52 38 90t90 38h512q52 0 90 -38t38 -90z" /> -<glyph unicode="" d="M768 1184q-148 0 -273 -73t-198 -198t-73 -273t73 -273t198 -198t273 -73t273 73t198 198t73 273t-73 273t-198 198t-273 73zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103 t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" horiz-adv-x="1664" d="M768 576v-384q0 -80 -56 -136t-136 -56h-384q-80 0 -136 56t-56 136v704q0 104 40.5 198.5t109.5 163.5t163.5 109.5t198.5 40.5h64q26 0 45 -19t19 -45v-128q0 -26 -19 -45t-45 -19h-64q-106 0 -181 -75t-75 -181v-32q0 -40 28 -68t68 -28h224q80 0 136 -56t56 -136z M1664 576v-384q0 -80 -56 -136t-136 -56h-384q-80 0 -136 56t-56 136v704q0 104 40.5 198.5t109.5 163.5t163.5 109.5t198.5 40.5h64q26 0 45 -19t19 -45v-128q0 -26 -19 -45t-45 -19h-64q-106 0 -181 -75t-75 -181v-32q0 -40 28 -68t68 -28h224q80 0 136 -56t56 -136z" /> -<glyph unicode="" horiz-adv-x="1664" d="M768 1216v-704q0 -104 -40.5 -198.5t-109.5 -163.5t-163.5 -109.5t-198.5 -40.5h-64q-26 0 -45 19t-19 45v128q0 26 19 45t45 19h64q106 0 181 75t75 181v32q0 40 -28 68t-68 28h-224q-80 0 -136 56t-56 136v384q0 80 56 136t136 56h384q80 0 136 -56t56 -136zM1664 1216 v-704q0 -104 -40.5 -198.5t-109.5 -163.5t-163.5 -109.5t-198.5 -40.5h-64q-26 0 -45 19t-19 45v128q0 26 19 45t45 19h64q106 0 181 75t75 181v32q0 40 -28 68t-68 28h-224q-80 0 -136 56t-56 136v384q0 80 56 136t136 56h384q80 0 136 -56t56 -136z" /> -<glyph unicode="" horiz-adv-x="1792" d="M526 142q0 -53 -37.5 -90.5t-90.5 -37.5q-52 0 -90 38t-38 90q0 53 37.5 90.5t90.5 37.5t90.5 -37.5t37.5 -90.5zM1024 -64q0 -53 -37.5 -90.5t-90.5 -37.5t-90.5 37.5t-37.5 90.5t37.5 90.5t90.5 37.5t90.5 -37.5t37.5 -90.5zM320 640q0 -53 -37.5 -90.5t-90.5 -37.5 t-90.5 37.5t-37.5 90.5t37.5 90.5t90.5 37.5t90.5 -37.5t37.5 -90.5zM1522 142q0 -52 -38 -90t-90 -38q-53 0 -90.5 37.5t-37.5 90.5t37.5 90.5t90.5 37.5t90.5 -37.5t37.5 -90.5zM558 1138q0 -66 -47 -113t-113 -47t-113 47t-47 113t47 113t113 47t113 -47t47 -113z M1728 640q0 -53 -37.5 -90.5t-90.5 -37.5t-90.5 37.5t-37.5 90.5t37.5 90.5t90.5 37.5t90.5 -37.5t37.5 -90.5zM1088 1344q0 -80 -56 -136t-136 -56t-136 56t-56 136t56 136t136 56t136 -56t56 -136zM1618 1138q0 -93 -66 -158.5t-158 -65.5q-93 0 -158.5 65.5t-65.5 158.5 q0 92 65.5 158t158.5 66q92 0 158 -66t66 -158z" /> -<glyph unicode="" d="M1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1792 416q0 -166 -127 -451q-3 -7 -10.5 -24t-13.5 -30t-13 -22q-12 -17 -28 -17q-15 0 -23.5 10t-8.5 25q0 9 2.5 26.5t2.5 23.5q5 68 5 123q0 101 -17.5 181t-48.5 138.5t-80 101t-105.5 69.5t-133 42.5t-154 21.5t-175.5 6h-224v-256q0 -26 -19 -45t-45 -19t-45 19 l-512 512q-19 19 -19 45t19 45l512 512q19 19 45 19t45 -19t19 -45v-256h224q713 0 875 -403q53 -134 53 -333z" /> -<glyph unicode="" horiz-adv-x="1664" d="M640 320q0 -40 -12.5 -82t-43 -76t-72.5 -34t-72.5 34t-43 76t-12.5 82t12.5 82t43 76t72.5 34t72.5 -34t43 -76t12.5 -82zM1280 320q0 -40 -12.5 -82t-43 -76t-72.5 -34t-72.5 34t-43 76t-12.5 82t12.5 82t43 76t72.5 34t72.5 -34t43 -76t12.5 -82zM1440 320 q0 120 -69 204t-187 84q-41 0 -195 -21q-71 -11 -157 -11t-157 11q-152 21 -195 21q-118 0 -187 -84t-69 -204q0 -88 32 -153.5t81 -103t122 -60t140 -29.5t149 -7h168q82 0 149 7t140 29.5t122 60t81 103t32 153.5zM1664 496q0 -207 -61 -331q-38 -77 -105.5 -133t-141 -86 t-170 -47.5t-171.5 -22t-167 -4.5q-78 0 -142 3t-147.5 12.5t-152.5 30t-137 51.5t-121 81t-86 115q-62 123 -62 331q0 237 136 396q-27 82 -27 170q0 116 51 218q108 0 190 -39.5t189 -123.5q147 35 309 35q148 0 280 -32q105 82 187 121t189 39q51 -102 51 -218 q0 -87 -27 -168q136 -160 136 -398z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1536 224v704q0 40 -28 68t-68 28h-704q-40 0 -68 28t-28 68v64q0 40 -28 68t-68 28h-320q-40 0 -68 -28t-28 -68v-960q0 -40 28 -68t68 -28h1216q40 0 68 28t28 68zM1664 928v-704q0 -92 -66 -158t-158 -66h-1216q-92 0 -158 66t-66 158v960q0 92 66 158t158 66h320 q92 0 158 -66t66 -158v-32h672q92 0 158 -66t66 -158z" /> -<glyph unicode="" horiz-adv-x="1920" d="M1781 605q0 35 -53 35h-1088q-40 0 -85.5 -21.5t-71.5 -52.5l-294 -363q-18 -24 -18 -40q0 -35 53 -35h1088q40 0 86 22t71 53l294 363q18 22 18 39zM640 768h768v160q0 40 -28 68t-68 28h-576q-40 0 -68 28t-28 68v64q0 40 -28 68t-68 28h-320q-40 0 -68 -28t-28 -68 v-853l256 315q44 53 116 87.5t140 34.5zM1909 605q0 -62 -46 -120l-295 -363q-43 -53 -116 -87.5t-140 -34.5h-1088q-92 0 -158 66t-66 158v960q0 92 66 158t158 66h320q92 0 158 -66t66 -158v-32h544q92 0 158 -66t66 -158v-160h192q54 0 99 -24.5t67 -70.5q15 -32 15 -68z " /> -<glyph unicode="" horiz-adv-x="1792" /> -<glyph unicode="" horiz-adv-x="1792" /> -<glyph unicode="" d="M1134 461q-37 -121 -138 -195t-228 -74t-228 74t-138 195q-8 25 4 48.5t38 31.5q25 8 48.5 -4t31.5 -38q25 -80 92.5 -129.5t151.5 -49.5t151.5 49.5t92.5 129.5q8 26 32 38t49 4t37 -31.5t4 -48.5zM640 896q0 -53 -37.5 -90.5t-90.5 -37.5t-90.5 37.5t-37.5 90.5 t37.5 90.5t90.5 37.5t90.5 -37.5t37.5 -90.5zM1152 896q0 -53 -37.5 -90.5t-90.5 -37.5t-90.5 37.5t-37.5 90.5t37.5 90.5t90.5 37.5t90.5 -37.5t37.5 -90.5zM1408 640q0 130 -51 248.5t-136.5 204t-204 136.5t-248.5 51t-248.5 -51t-204 -136.5t-136.5 -204t-51 -248.5 t51 -248.5t136.5 -204t204 -136.5t248.5 -51t248.5 51t204 136.5t136.5 204t51 248.5zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" d="M1134 307q8 -25 -4 -48.5t-37 -31.5t-49 4t-32 38q-25 80 -92.5 129.5t-151.5 49.5t-151.5 -49.5t-92.5 -129.5q-8 -26 -31.5 -38t-48.5 -4q-26 8 -38 31.5t-4 48.5q37 121 138 195t228 74t228 -74t138 -195zM640 896q0 -53 -37.5 -90.5t-90.5 -37.5t-90.5 37.5 t-37.5 90.5t37.5 90.5t90.5 37.5t90.5 -37.5t37.5 -90.5zM1152 896q0 -53 -37.5 -90.5t-90.5 -37.5t-90.5 37.5t-37.5 90.5t37.5 90.5t90.5 37.5t90.5 -37.5t37.5 -90.5zM1408 640q0 130 -51 248.5t-136.5 204t-204 136.5t-248.5 51t-248.5 -51t-204 -136.5t-136.5 -204 t-51 -248.5t51 -248.5t136.5 -204t204 -136.5t248.5 -51t248.5 51t204 136.5t136.5 204t51 248.5zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" d="M1152 448q0 -26 -19 -45t-45 -19h-640q-26 0 -45 19t-19 45t19 45t45 19h640q26 0 45 -19t19 -45zM640 896q0 -53 -37.5 -90.5t-90.5 -37.5t-90.5 37.5t-37.5 90.5t37.5 90.5t90.5 37.5t90.5 -37.5t37.5 -90.5zM1152 896q0 -53 -37.5 -90.5t-90.5 -37.5t-90.5 37.5 t-37.5 90.5t37.5 90.5t90.5 37.5t90.5 -37.5t37.5 -90.5zM1408 640q0 130 -51 248.5t-136.5 204t-204 136.5t-248.5 51t-248.5 -51t-204 -136.5t-136.5 -204t-51 -248.5t51 -248.5t136.5 -204t204 -136.5t248.5 -51t248.5 51t204 136.5t136.5 204t51 248.5zM1536 640 q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" horiz-adv-x="1920" d="M832 448v128q0 14 -9 23t-23 9h-192v192q0 14 -9 23t-23 9h-128q-14 0 -23 -9t-9 -23v-192h-192q-14 0 -23 -9t-9 -23v-128q0 -14 9 -23t23 -9h192v-192q0 -14 9 -23t23 -9h128q14 0 23 9t9 23v192h192q14 0 23 9t9 23zM1408 384q0 53 -37.5 90.5t-90.5 37.5t-90.5 -37.5 t-37.5 -90.5t37.5 -90.5t90.5 -37.5t90.5 37.5t37.5 90.5zM1664 640q0 53 -37.5 90.5t-90.5 37.5t-90.5 -37.5t-37.5 -90.5t37.5 -90.5t90.5 -37.5t90.5 37.5t37.5 90.5zM1920 512q0 -212 -150 -362t-362 -150q-192 0 -338 128h-220q-146 -128 -338 -128q-212 0 -362 150 t-150 362t150 362t362 150h896q212 0 362 -150t150 -362z" /> -<glyph unicode="" horiz-adv-x="1920" d="M384 368v-96q0 -16 -16 -16h-96q-16 0 -16 16v96q0 16 16 16h96q16 0 16 -16zM512 624v-96q0 -16 -16 -16h-224q-16 0 -16 16v96q0 16 16 16h224q16 0 16 -16zM384 880v-96q0 -16 -16 -16h-96q-16 0 -16 16v96q0 16 16 16h96q16 0 16 -16zM1408 368v-96q0 -16 -16 -16 h-864q-16 0 -16 16v96q0 16 16 16h864q16 0 16 -16zM768 624v-96q0 -16 -16 -16h-96q-16 0 -16 16v96q0 16 16 16h96q16 0 16 -16zM640 880v-96q0 -16 -16 -16h-96q-16 0 -16 16v96q0 16 16 16h96q16 0 16 -16zM1024 624v-96q0 -16 -16 -16h-96q-16 0 -16 16v96q0 16 16 16 h96q16 0 16 -16zM896 880v-96q0 -16 -16 -16h-96q-16 0 -16 16v96q0 16 16 16h96q16 0 16 -16zM1280 624v-96q0 -16 -16 -16h-96q-16 0 -16 16v96q0 16 16 16h96q16 0 16 -16zM1664 368v-96q0 -16 -16 -16h-96q-16 0 -16 16v96q0 16 16 16h96q16 0 16 -16zM1152 880v-96 q0 -16 -16 -16h-96q-16 0 -16 16v96q0 16 16 16h96q16 0 16 -16zM1408 880v-96q0 -16 -16 -16h-96q-16 0 -16 16v96q0 16 16 16h96q16 0 16 -16zM1664 880v-352q0 -16 -16 -16h-224q-16 0 -16 16v96q0 16 16 16h112v240q0 16 16 16h96q16 0 16 -16zM1792 128v896h-1664v-896 h1664zM1920 1024v-896q0 -53 -37.5 -90.5t-90.5 -37.5h-1664q-53 0 -90.5 37.5t-37.5 90.5v896q0 53 37.5 90.5t90.5 37.5h1664q53 0 90.5 -37.5t37.5 -90.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1664 491v616q-169 -91 -306 -91q-82 0 -145 32q-100 49 -184 76.5t-178 27.5q-173 0 -403 -127v-599q245 113 433 113q55 0 103.5 -7.5t98 -26t77 -31t82.5 -39.5l28 -14q44 -22 101 -22q120 0 293 92zM320 1280q0 -35 -17.5 -64t-46.5 -46v-1266q0 -14 -9 -23t-23 -9 h-64q-14 0 -23 9t-9 23v1266q-29 17 -46.5 46t-17.5 64q0 53 37.5 90.5t90.5 37.5t90.5 -37.5t37.5 -90.5zM1792 1216v-763q0 -39 -35 -57q-10 -5 -17 -9q-218 -116 -369 -116q-88 0 -158 35l-28 14q-64 33 -99 48t-91 29t-114 14q-102 0 -235.5 -44t-228.5 -102 q-15 -9 -33 -9q-16 0 -32 8q-32 19 -32 56v742q0 35 31 55q35 21 78.5 42.5t114 52t152.5 49.5t155 19q112 0 209 -31t209 -86q38 -19 89 -19q122 0 310 112q22 12 31 17q31 16 62 -2q31 -20 31 -55z" /> -<glyph unicode="" horiz-adv-x="1792" d="M832 536v192q-181 -16 -384 -117v-185q205 96 384 110zM832 954v197q-172 -8 -384 -126v-189q215 111 384 118zM1664 491v184q-235 -116 -384 -71v224q-20 6 -39 15q-5 3 -33 17t-34.5 17t-31.5 15t-34.5 15.5t-32.5 13t-36 12.5t-35 8.5t-39.5 7.5t-39.5 4t-44 2 q-23 0 -49 -3v-222h19q102 0 192.5 -29t197.5 -82q19 -9 39 -15v-188q42 -17 91 -17q120 0 293 92zM1664 918v189q-169 -91 -306 -91q-45 0 -78 8v-196q148 -42 384 90zM320 1280q0 -35 -17.5 -64t-46.5 -46v-1266q0 -14 -9 -23t-23 -9h-64q-14 0 -23 9t-9 23v1266 q-29 17 -46.5 46t-17.5 64q0 53 37.5 90.5t90.5 37.5t90.5 -37.5t37.5 -90.5zM1792 1216v-763q0 -39 -35 -57q-10 -5 -17 -9q-218 -116 -369 -116q-88 0 -158 35l-28 14q-64 33 -99 48t-91 29t-114 14q-102 0 -235.5 -44t-228.5 -102q-15 -9 -33 -9q-16 0 -32 8 q-32 19 -32 56v742q0 35 31 55q35 21 78.5 42.5t114 52t152.5 49.5t155 19q112 0 209 -31t209 -86q38 -19 89 -19q122 0 310 112q22 12 31 17q31 16 62 -2q31 -20 31 -55z" /> -<glyph unicode="" horiz-adv-x="1664" d="M585 553l-466 -466q-10 -10 -23 -10t-23 10l-50 50q-10 10 -10 23t10 23l393 393l-393 393q-10 10 -10 23t10 23l50 50q10 10 23 10t23 -10l466 -466q10 -10 10 -23t-10 -23zM1664 96v-64q0 -14 -9 -23t-23 -9h-960q-14 0 -23 9t-9 23v64q0 14 9 23t23 9h960q14 0 23 -9 t9 -23z" /> -<glyph unicode="" horiz-adv-x="1920" d="M617 137l-50 -50q-10 -10 -23 -10t-23 10l-466 466q-10 10 -10 23t10 23l466 466q10 10 23 10t23 -10l50 -50q10 -10 10 -23t-10 -23l-393 -393l393 -393q10 -10 10 -23t-10 -23zM1208 1204l-373 -1291q-4 -13 -15.5 -19.5t-23.5 -2.5l-62 17q-13 4 -19.5 15.5t-2.5 24.5 l373 1291q4 13 15.5 19.5t23.5 2.5l62 -17q13 -4 19.5 -15.5t2.5 -24.5zM1865 553l-466 -466q-10 -10 -23 -10t-23 10l-50 50q-10 10 -10 23t10 23l393 393l-393 393q-10 10 -10 23t10 23l50 50q10 10 23 10t23 -10l466 -466q10 -10 10 -23t-10 -23z" /> -<glyph unicode="" horiz-adv-x="1792" d="M640 454v-70q0 -42 -39 -59q-13 -5 -25 -5q-27 0 -45 19l-512 512q-19 19 -19 45t19 45l512 512q29 31 70 14q39 -17 39 -59v-69l-397 -398q-19 -19 -19 -45t19 -45zM1792 416q0 -58 -17 -133.5t-38.5 -138t-48 -125t-40.5 -90.5l-20 -40q-8 -17 -28 -17q-6 0 -9 1 q-25 8 -23 34q43 400 -106 565q-64 71 -170.5 110.5t-267.5 52.5v-251q0 -42 -39 -59q-13 -5 -25 -5q-27 0 -45 19l-512 512q-19 19 -19 45t19 45l512 512q29 31 70 14q39 -17 39 -59v-262q411 -28 599 -221q169 -173 169 -509z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1186 579l257 250l-356 52l-66 10l-30 60l-159 322v-963l59 -31l318 -168l-60 355l-12 66zM1638 841l-363 -354l86 -500q5 -33 -6 -51.5t-34 -18.5q-17 0 -40 12l-449 236l-449 -236q-23 -12 -40 -12q-23 0 -34 18.5t-6 51.5l86 500l-364 354q-32 32 -23 59.5t54 34.5 l502 73l225 455q20 41 49 41q28 0 49 -41l225 -455l502 -73q45 -7 54 -34.5t-24 -59.5z" /> -<glyph unicode="" horiz-adv-x="1408" d="M1401 1187l-640 -1280q-17 -35 -57 -35q-5 0 -15 2q-22 5 -35.5 22.5t-13.5 39.5v576h-576q-22 0 -39.5 13.5t-22.5 35.5t4 42t29 30l1280 640q13 7 29 7q27 0 45 -19q15 -14 18.5 -34.5t-6.5 -39.5z" /> -<glyph unicode="" horiz-adv-x="1664" d="M557 256h595v595zM512 301l595 595h-595v-595zM1664 224v-192q0 -14 -9 -23t-23 -9h-224v-224q0 -14 -9 -23t-23 -9h-192q-14 0 -23 9t-9 23v224h-864q-14 0 -23 9t-9 23v864h-224q-14 0 -23 9t-9 23v192q0 14 9 23t23 9h224v224q0 14 9 23t23 9h192q14 0 23 -9t9 -23 v-224h851l246 247q10 9 23 9t23 -9q9 -10 9 -23t-9 -23l-247 -246v-851h224q14 0 23 -9t9 -23z" /> -<glyph unicode="" horiz-adv-x="1024" d="M288 64q0 40 -28 68t-68 28t-68 -28t-28 -68t28 -68t68 -28t68 28t28 68zM288 1216q0 40 -28 68t-68 28t-68 -28t-28 -68t28 -68t68 -28t68 28t28 68zM928 1088q0 40 -28 68t-68 28t-68 -28t-28 -68t28 -68t68 -28t68 28t28 68zM1024 1088q0 -52 -26 -96.5t-70 -69.5 q-2 -287 -226 -414q-68 -38 -203 -81q-128 -40 -169.5 -71t-41.5 -100v-26q44 -25 70 -69.5t26 -96.5q0 -80 -56 -136t-136 -56t-136 56t-56 136q0 52 26 96.5t70 69.5v820q-44 25 -70 69.5t-26 96.5q0 80 56 136t136 56t136 -56t56 -136q0 -52 -26 -96.5t-70 -69.5v-497 q54 26 154 57q55 17 87.5 29.5t70.5 31t59 39.5t40.5 51t28 69.5t8.5 91.5q-44 25 -70 69.5t-26 96.5q0 80 56 136t136 56t136 -56t56 -136z" /> -<glyph unicode="" horiz-adv-x="1664" d="M439 265l-256 -256q-10 -9 -23 -9q-12 0 -23 9q-9 10 -9 23t9 23l256 256q10 9 23 9t23 -9q9 -10 9 -23t-9 -23zM608 224v-320q0 -14 -9 -23t-23 -9t-23 9t-9 23v320q0 14 9 23t23 9t23 -9t9 -23zM384 448q0 -14 -9 -23t-23 -9h-320q-14 0 -23 9t-9 23t9 23t23 9h320 q14 0 23 -9t9 -23zM1648 320q0 -120 -85 -203l-147 -146q-83 -83 -203 -83q-121 0 -204 85l-334 335q-21 21 -42 56l239 18l273 -274q27 -27 68 -27.5t68 26.5l147 146q28 28 28 67q0 40 -28 68l-274 275l18 239q35 -21 56 -42l336 -336q84 -86 84 -204zM1031 1044l-239 -18 l-273 274q-28 28 -68 28q-39 0 -68 -27l-147 -146q-28 -28 -28 -67q0 -40 28 -68l274 -274l-18 -240q-35 21 -56 42l-336 336q-84 86 -84 204q0 120 85 203l147 146q83 83 203 83q121 0 204 -85l334 -335q21 -21 42 -56zM1664 960q0 -14 -9 -23t-23 -9h-320q-14 0 -23 9 t-9 23t9 23t23 9h320q14 0 23 -9t9 -23zM1120 1504v-320q0 -14 -9 -23t-23 -9t-23 9t-9 23v320q0 14 9 23t23 9t23 -9t9 -23zM1527 1353l-256 -256q-11 -9 -23 -9t-23 9q-9 10 -9 23t9 23l256 256q10 9 23 9t23 -9q9 -10 9 -23t-9 -23z" /> -<glyph unicode="" horiz-adv-x="1024" d="M704 280v-240q0 -16 -12 -28t-28 -12h-240q-16 0 -28 12t-12 28v240q0 16 12 28t28 12h240q16 0 28 -12t12 -28zM1020 880q0 -54 -15.5 -101t-35 -76.5t-55 -59.5t-57.5 -43.5t-61 -35.5q-41 -23 -68.5 -65t-27.5 -67q0 -17 -12 -32.5t-28 -15.5h-240q-15 0 -25.5 18.5 t-10.5 37.5v45q0 83 65 156.5t143 108.5q59 27 84 56t25 76q0 42 -46.5 74t-107.5 32q-65 0 -108 -29q-35 -25 -107 -115q-13 -16 -31 -16q-12 0 -25 8l-164 125q-13 10 -15.5 25t5.5 28q160 266 464 266q80 0 161 -31t146 -83t106 -127.5t41 -158.5z" /> -<glyph unicode="" horiz-adv-x="640" d="M640 192v-128q0 -26 -19 -45t-45 -19h-512q-26 0 -45 19t-19 45v128q0 26 19 45t45 19h64v384h-64q-26 0 -45 19t-19 45v128q0 26 19 45t45 19h384q26 0 45 -19t19 -45v-576h64q26 0 45 -19t19 -45zM512 1344v-192q0 -26 -19 -45t-45 -19h-256q-26 0 -45 19t-19 45v192 q0 26 19 45t45 19h256q26 0 45 -19t19 -45z" /> -<glyph unicode="" horiz-adv-x="640" d="M512 288v-224q0 -26 -19 -45t-45 -19h-256q-26 0 -45 19t-19 45v224q0 26 19 45t45 19h256q26 0 45 -19t19 -45zM542 1344l-28 -768q-1 -26 -20.5 -45t-45.5 -19h-256q-26 0 -45.5 19t-20.5 45l-28 768q-1 26 17.5 45t44.5 19h320q26 0 44.5 -19t17.5 -45z" /> -<glyph unicode="" d="M897 167v-167h-248l-159 252l-24 42q-8 9 -11 21h-3l-9 -21q-10 -20 -25 -44l-155 -250h-258v167h128l197 291l-185 272h-137v168h276l139 -228q2 -4 23 -42q8 -9 11 -21h3q3 9 11 21l25 42l140 228h257v-168h-125l-184 -267l204 -296h109zM1534 846v-206h-514l-3 27 q-4 28 -4 46q0 64 26 117t65 86.5t84 65t84 54.5t65 54t26 64q0 38 -29.5 62.5t-70.5 24.5q-51 0 -97 -39q-14 -11 -36 -38l-105 92q26 37 63 66q83 65 188 65q110 0 178 -59.5t68 -158.5q0 -56 -24.5 -103t-62 -76.5t-81.5 -58.5t-82 -50.5t-65.5 -51.5t-30.5 -63h232v80 h126z" /> -<glyph unicode="" d="M897 167v-167h-248l-159 252l-24 42q-8 9 -11 21h-3l-9 -21q-10 -20 -25 -44l-155 -250h-258v167h128l197 291l-185 272h-137v168h276l139 -228q2 -4 23 -42q8 -9 11 -21h3q3 9 11 21l25 42l140 228h257v-168h-125l-184 -267l204 -296h109zM1536 -50v-206h-514l-4 27 q-3 45 -3 46q0 64 26 117t65 86.5t84 65t84 54.5t65 54t26 64q0 38 -29.5 62.5t-70.5 24.5q-51 0 -97 -39q-14 -11 -36 -38l-105 92q26 37 63 66q80 65 188 65q110 0 178 -59.5t68 -158.5q0 -66 -34.5 -118.5t-84 -86t-99.5 -62.5t-87 -63t-41 -73h232v80h126z" /> -<glyph unicode="" horiz-adv-x="1920" d="M896 128l336 384h-768l-336 -384h768zM1909 1205q15 -34 9.5 -71.5t-30.5 -65.5l-896 -1024q-38 -44 -96 -44h-768q-38 0 -69.5 20.5t-47.5 54.5q-15 34 -9.5 71.5t30.5 65.5l896 1024q38 44 96 44h768q38 0 69.5 -20.5t47.5 -54.5z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1664 438q0 -81 -44.5 -135t-123.5 -54q-41 0 -77.5 17.5t-59 38t-56.5 38t-71 17.5q-110 0 -110 -124q0 -39 16 -115t15 -115v-5q-22 0 -33 -1q-34 -3 -97.5 -11.5t-115.5 -13.5t-98 -5q-61 0 -103 26.5t-42 83.5q0 37 17.5 71t38 56.5t38 59t17.5 77.5q0 79 -54 123.5 t-135 44.5q-84 0 -143 -45.5t-59 -127.5q0 -43 15 -83t33.5 -64.5t33.5 -53t15 -50.5q0 -45 -46 -89q-37 -35 -117 -35q-95 0 -245 24q-9 2 -27.5 4t-27.5 4l-13 2q-1 0 -3 1q-2 0 -2 1v1024q2 -1 17.5 -3.5t34 -5t21.5 -3.5q150 -24 245 -24q80 0 117 35q46 44 46 89 q0 22 -15 50.5t-33.5 53t-33.5 64.5t-15 83q0 82 59 127.5t144 45.5q80 0 134 -44.5t54 -123.5q0 -41 -17.5 -77.5t-38 -59t-38 -56.5t-17.5 -71q0 -57 42 -83.5t103 -26.5q64 0 180 15t163 17v-2q-1 -2 -3.5 -17.5t-5 -34t-3.5 -21.5q-24 -150 -24 -245q0 -80 35 -117 q44 -46 89 -46q22 0 50.5 15t53 33.5t64.5 33.5t83 15q82 0 127.5 -59t45.5 -143z" /> -<glyph unicode="" horiz-adv-x="1152" d="M1152 832v-128q0 -221 -147.5 -384.5t-364.5 -187.5v-132h256q26 0 45 -19t19 -45t-19 -45t-45 -19h-640q-26 0 -45 19t-19 45t19 45t45 19h256v132q-217 24 -364.5 187.5t-147.5 384.5v128q0 26 19 45t45 19t45 -19t19 -45v-128q0 -185 131.5 -316.5t316.5 -131.5 t316.5 131.5t131.5 316.5v128q0 26 19 45t45 19t45 -19t19 -45zM896 1216v-512q0 -132 -94 -226t-226 -94t-226 94t-94 226v512q0 132 94 226t226 94t226 -94t94 -226z" /> -<glyph unicode="" horiz-adv-x="1408" d="M271 591l-101 -101q-42 103 -42 214v128q0 26 19 45t45 19t45 -19t19 -45v-128q0 -53 15 -113zM1385 1193l-361 -361v-128q0 -132 -94 -226t-226 -94q-55 0 -109 19l-96 -96q97 -51 205 -51q185 0 316.5 131.5t131.5 316.5v128q0 26 19 45t45 19t45 -19t19 -45v-128 q0 -221 -147.5 -384.5t-364.5 -187.5v-132h256q26 0 45 -19t19 -45t-19 -45t-45 -19h-640q-26 0 -45 19t-19 45t19 45t45 19h256v132q-125 13 -235 81l-254 -254q-10 -10 -23 -10t-23 10l-82 82q-10 10 -10 23t10 23l1234 1234q10 10 23 10t23 -10l82 -82q10 -10 10 -23 t-10 -23zM1005 1325l-621 -621v512q0 132 94 226t226 94q102 0 184.5 -59t116.5 -152z" /> -<glyph unicode="" horiz-adv-x="1280" d="M1088 576v640h-448v-1137q119 63 213 137q235 184 235 360zM1280 1344v-768q0 -86 -33.5 -170.5t-83 -150t-118 -127.5t-126.5 -103t-121 -77.5t-89.5 -49.5t-42.5 -20q-12 -6 -26 -6t-26 6q-16 7 -42.5 20t-89.5 49.5t-121 77.5t-126.5 103t-118 127.5t-83 150 t-33.5 170.5v768q0 26 19 45t45 19h1152q26 0 45 -19t19 -45z" /> -<glyph unicode="" horiz-adv-x="1664" d="M128 -128h1408v1024h-1408v-1024zM512 1088v288q0 14 -9 23t-23 9h-64q-14 0 -23 -9t-9 -23v-288q0 -14 9 -23t23 -9h64q14 0 23 9t9 23zM1280 1088v288q0 14 -9 23t-23 9h-64q-14 0 -23 -9t-9 -23v-288q0 -14 9 -23t23 -9h64q14 0 23 9t9 23zM1664 1152v-1280 q0 -52 -38 -90t-90 -38h-1408q-52 0 -90 38t-38 90v1280q0 52 38 90t90 38h128v96q0 66 47 113t113 47h64q66 0 113 -47t47 -113v-96h384v96q0 66 47 113t113 47h64q66 0 113 -47t47 -113v-96h128q52 0 90 -38t38 -90z" /> -<glyph unicode="" horiz-adv-x="1408" d="M512 1344q0 26 -19 45t-45 19t-45 -19t-19 -45t19 -45t45 -19t45 19t19 45zM1408 1376v-320q0 -16 -12 -25q-8 -7 -20 -7q-4 0 -7 1l-448 96q-11 2 -18 11t-7 20h-256v-102q111 -23 183.5 -111t72.5 -203v-800q0 -26 -19 -45t-45 -19h-512q-26 0 -45 19t-19 45v800 q0 106 62.5 190.5t161.5 114.5v111h-32q-59 0 -115 -23.5t-91.5 -53t-66 -66.5t-40.5 -53.5t-14 -24.5q-17 -35 -57 -35q-16 0 -29 7q-23 12 -31.5 37t3.5 49q5 10 14.5 26t37.5 53.5t60.5 70t85 67t108.5 52.5q-25 42 -25 86q0 66 47 113t113 47t113 -47t47 -113 q0 -33 -14 -64h302q0 11 7 20t18 11l448 96q3 1 7 1q12 0 20 -7q12 -9 12 -25z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1440 1088q0 40 -28 68t-68 28t-68 -28t-28 -68t28 -68t68 -28t68 28t28 68zM1664 1376q0 -249 -75.5 -430.5t-253.5 -360.5q-81 -80 -195 -176l-20 -379q-2 -16 -16 -26l-384 -224q-7 -4 -16 -4q-12 0 -23 9l-64 64q-13 14 -8 32l85 276l-281 281l-276 -85q-3 -1 -9 -1 q-14 0 -23 9l-64 64q-17 19 -5 39l224 384q10 14 26 16l379 20q96 114 176 195q188 187 358 258t431 71q14 0 24 -9.5t10 -22.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1745 763l-164 -763h-334l178 832q13 56 -15 88q-27 33 -83 33h-169l-204 -953h-334l204 953h-286l-204 -953h-334l204 953l-153 327h1276q101 0 189.5 -40.5t147.5 -113.5q60 -73 81 -168.5t0 -194.5z" /> -<glyph unicode="" d="M909 141l102 102q19 19 19 45t-19 45l-307 307l307 307q19 19 19 45t-19 45l-102 102q-19 19 -45 19t-45 -19l-454 -454q-19 -19 -19 -45t19 -45l454 -454q19 -19 45 -19t45 19zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5 t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" d="M717 141l454 454q19 19 19 45t-19 45l-454 454q-19 19 -45 19t-45 -19l-102 -102q-19 -19 -19 -45t19 -45l307 -307l-307 -307q-19 -19 -19 -45t19 -45l102 -102q19 -19 45 -19t45 19zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5 t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" d="M1165 397l102 102q19 19 19 45t-19 45l-454 454q-19 19 -45 19t-45 -19l-454 -454q-19 -19 -19 -45t19 -45l102 -102q19 -19 45 -19t45 19l307 307l307 -307q19 -19 45 -19t45 19zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5 t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" d="M813 237l454 454q19 19 19 45t-19 45l-102 102q-19 19 -45 19t-45 -19l-307 -307l-307 307q-19 19 -45 19t-45 -19l-102 -102q-19 -19 -19 -45t19 -45l454 -454q19 -19 45 -19t45 19zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5 t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" horiz-adv-x="1408" d="M1130 939l16 175h-884l47 -534h612l-22 -228l-197 -53l-196 53l-13 140h-175l22 -278l362 -100h4v1l359 99l50 544h-644l-15 181h674zM0 1408h1408l-128 -1438l-578 -162l-574 162z" /> -<glyph unicode="" horiz-adv-x="1792" d="M275 1408h1505l-266 -1333l-804 -267l-698 267l71 356h297l-29 -147l422 -161l486 161l68 339h-1208l58 297h1209l38 191h-1208z" /> -<glyph unicode="" horiz-adv-x="1792" d="M960 1280q0 26 -19 45t-45 19t-45 -19t-19 -45t19 -45t45 -19t45 19t19 45zM1792 352v-352q0 -22 -20 -30q-8 -2 -12 -2q-13 0 -23 9l-93 93q-119 -143 -318.5 -226.5t-429.5 -83.5t-429.5 83.5t-318.5 226.5l-93 -93q-9 -9 -23 -9q-4 0 -12 2q-20 8 -20 30v352 q0 14 9 23t23 9h352q22 0 30 -20q8 -19 -7 -35l-100 -100q67 -91 189.5 -153.5t271.5 -82.5v647h-192q-26 0 -45 19t-19 45v128q0 26 19 45t45 19h192v163q-58 34 -93 92.5t-35 128.5q0 106 75 181t181 75t181 -75t75 -181q0 -70 -35 -128.5t-93 -92.5v-163h192q26 0 45 -19 t19 -45v-128q0 -26 -19 -45t-45 -19h-192v-647q149 20 271.5 82.5t189.5 153.5l-100 100q-15 16 -7 35q8 20 30 20h352q14 0 23 -9t9 -23z" /> -<glyph unicode="" horiz-adv-x="1152" d="M1056 768q40 0 68 -28t28 -68v-576q0 -40 -28 -68t-68 -28h-960q-40 0 -68 28t-28 68v576q0 40 28 68t68 28h32v320q0 185 131.5 316.5t316.5 131.5t316.5 -131.5t131.5 -316.5q0 -26 -19 -45t-45 -19h-64q-26 0 -45 19t-19 45q0 106 -75 181t-181 75t-181 -75t-75 -181 v-320h736z" /> -<glyph unicode="" d="M1024 640q0 -106 -75 -181t-181 -75t-181 75t-75 181t75 181t181 75t181 -75t75 -181zM1152 640q0 159 -112.5 271.5t-271.5 112.5t-271.5 -112.5t-112.5 -271.5t112.5 -271.5t271.5 -112.5t271.5 112.5t112.5 271.5zM1280 640q0 -212 -150 -362t-362 -150t-362 150 t-150 362t150 362t362 150t362 -150t150 -362zM1408 640q0 130 -51 248.5t-136.5 204t-204 136.5t-248.5 51t-248.5 -51t-204 -136.5t-136.5 -204t-51 -248.5t51 -248.5t136.5 -204t204 -136.5t248.5 -51t248.5 51t204 136.5t136.5 204t51 248.5zM1536 640 q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" horiz-adv-x="1408" d="M384 800v-192q0 -40 -28 -68t-68 -28h-192q-40 0 -68 28t-28 68v192q0 40 28 68t68 28h192q40 0 68 -28t28 -68zM896 800v-192q0 -40 -28 -68t-68 -28h-192q-40 0 -68 28t-28 68v192q0 40 28 68t68 28h192q40 0 68 -28t28 -68zM1408 800v-192q0 -40 -28 -68t-68 -28h-192 q-40 0 -68 28t-28 68v192q0 40 28 68t68 28h192q40 0 68 -28t28 -68z" /> -<glyph unicode="" horiz-adv-x="384" d="M384 288v-192q0 -40 -28 -68t-68 -28h-192q-40 0 -68 28t-28 68v192q0 40 28 68t68 28h192q40 0 68 -28t28 -68zM384 800v-192q0 -40 -28 -68t-68 -28h-192q-40 0 -68 28t-28 68v192q0 40 28 68t68 28h192q40 0 68 -28t28 -68zM384 1312v-192q0 -40 -28 -68t-68 -28h-192 q-40 0 -68 28t-28 68v192q0 40 28 68t68 28h192q40 0 68 -28t28 -68z" /> -<glyph unicode="" d="M512 256q0 53 -37.5 90.5t-90.5 37.5t-90.5 -37.5t-37.5 -90.5t37.5 -90.5t90.5 -37.5t90.5 37.5t37.5 90.5zM863 162q-13 232 -177 396t-396 177q-14 1 -24 -9t-10 -23v-128q0 -13 8.5 -22t21.5 -10q154 -11 264 -121t121 -264q1 -13 10 -21.5t22 -8.5h128q13 0 23 10 t9 24zM1247 161q-5 154 -56 297.5t-139.5 260t-205 205t-260 139.5t-297.5 56q-14 1 -23 -9q-10 -10 -10 -23v-128q0 -13 9 -22t22 -10q204 -7 378 -111.5t278.5 -278.5t111.5 -378q1 -13 10 -22t22 -9h128q13 0 23 10q11 9 9 23zM1536 1120v-960q0 -119 -84.5 -203.5 t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" d="M768 1408q209 0 385.5 -103t279.5 -279.5t103 -385.5t-103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103zM1152 585q32 18 32 55t-32 55l-544 320q-31 19 -64 1q-32 -19 -32 -56v-640q0 -37 32 -56 q16 -8 32 -8q17 0 32 9z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1024 1084l316 -316l-572 -572l-316 316zM813 105l618 618q19 19 19 45t-19 45l-362 362q-18 18 -45 18t-45 -18l-618 -618q-19 -19 -19 -45t19 -45l362 -362q18 -18 45 -18t45 18zM1702 742l-907 -908q-37 -37 -90.5 -37t-90.5 37l-126 126q56 56 56 136t-56 136 t-136 56t-136 -56l-125 126q-37 37 -37 90.5t37 90.5l907 906q37 37 90.5 37t90.5 -37l125 -125q-56 -56 -56 -136t56 -136t136 -56t136 56l126 -125q37 -37 37 -90.5t-37 -90.5z" /> -<glyph unicode="" d="M1280 576v128q0 26 -19 45t-45 19h-896q-26 0 -45 -19t-19 -45v-128q0 -26 19 -45t45 -19h896q26 0 45 19t19 45zM1536 1120v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5 t84.5 -203.5z" /> -<glyph unicode="" horiz-adv-x="1408" d="M1152 736v-64q0 -14 -9 -23t-23 -9h-832q-14 0 -23 9t-9 23v64q0 14 9 23t23 9h832q14 0 23 -9t9 -23zM1280 288v832q0 66 -47 113t-113 47h-832q-66 0 -113 -47t-47 -113v-832q0 -66 47 -113t113 -47h832q66 0 113 47t47 113zM1408 1120v-832q0 -119 -84.5 -203.5 t-203.5 -84.5h-832q-119 0 -203.5 84.5t-84.5 203.5v832q0 119 84.5 203.5t203.5 84.5h832q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" horiz-adv-x="1024" d="M1018 933q-18 -37 -58 -37h-192v-864q0 -14 -9 -23t-23 -9h-704q-21 0 -29 18q-8 20 4 35l160 192q9 11 25 11h320v640h-192q-40 0 -58 37q-17 37 9 68l320 384q18 22 49 22t49 -22l320 -384q27 -32 9 -68z" /> -<glyph unicode="" horiz-adv-x="1024" d="M32 1280h704q13 0 22.5 -9.5t9.5 -23.5v-863h192q40 0 58 -37t-9 -69l-320 -384q-18 -22 -49 -22t-49 22l-320 384q-26 31 -9 69q18 37 58 37h192v640h-320q-14 0 -25 11l-160 192q-13 14 -4 34q9 19 29 19z" /> -<glyph unicode="" d="M685 237l614 614q19 19 19 45t-19 45l-102 102q-19 19 -45 19t-45 -19l-467 -467l-211 211q-19 19 -45 19t-45 -19l-102 -102q-19 -19 -19 -45t19 -45l358 -358q19 -19 45 -19t45 19zM1536 1120v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5 t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" d="M404 428l152 -152l-52 -52h-56v96h-96v56zM818 818q14 -13 -3 -30l-291 -291q-17 -17 -30 -3q-14 13 3 30l291 291q17 17 30 3zM544 128l544 544l-288 288l-544 -544v-288h288zM1152 736l92 92q28 28 28 68t-28 68l-152 152q-28 28 -68 28t-68 -28l-92 -92zM1536 1120 v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" d="M1280 608v480q0 26 -19 45t-45 19h-480q-42 0 -59 -39q-17 -41 14 -70l144 -144l-534 -534q-19 -19 -19 -45t19 -45l102 -102q19 -19 45 -19t45 19l534 534l144 -144q18 -19 45 -19q12 0 25 5q39 17 39 59zM1536 1120v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960 q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" d="M1005 435l352 352q19 19 19 45t-19 45l-352 352q-30 31 -69 14q-40 -17 -40 -59v-160q-119 0 -216 -19.5t-162.5 -51t-114 -79t-76.5 -95.5t-44.5 -109t-21.5 -111.5t-5 -110.5q0 -181 167 -404q10 -12 25 -12q7 0 13 3q22 9 19 33q-44 354 62 473q46 52 130 75.5 t224 23.5v-160q0 -42 40 -59q12 -5 24 -5q26 0 45 19zM1536 1120v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" d="M640 448l256 128l-256 128v-256zM1024 1039v-542l-512 -256v542zM1312 640q0 148 -73 273t-198 198t-273 73t-273 -73t-198 -198t-73 -273t73 -273t198 -198t273 -73t273 73t198 198t73 273zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103 t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" d="M1145 861q18 -35 -5 -66l-320 -448q-19 -27 -52 -27t-52 27l-320 448q-23 31 -5 66q17 35 57 35h640q40 0 57 -35zM1280 160v960q0 13 -9.5 22.5t-22.5 9.5h-960q-13 0 -22.5 -9.5t-9.5 -22.5v-960q0 -13 9.5 -22.5t22.5 -9.5h960q13 0 22.5 9.5t9.5 22.5zM1536 1120 v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" d="M1145 419q-17 -35 -57 -35h-640q-40 0 -57 35q-18 35 5 66l320 448q19 27 52 27t52 -27l320 -448q23 -31 5 -66zM1280 160v960q0 13 -9.5 22.5t-22.5 9.5h-960q-13 0 -22.5 -9.5t-9.5 -22.5v-960q0 -13 9.5 -22.5t22.5 -9.5h960q13 0 22.5 9.5t9.5 22.5zM1536 1120v-960 q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" d="M1088 640q0 -33 -27 -52l-448 -320q-31 -23 -66 -5q-35 17 -35 57v640q0 40 35 57q35 18 66 -5l448 -320q27 -19 27 -52zM1280 160v960q0 14 -9 23t-23 9h-960q-14 0 -23 -9t-9 -23v-960q0 -14 9 -23t23 -9h960q14 0 23 9t9 23zM1536 1120v-960q0 -119 -84.5 -203.5 t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" horiz-adv-x="1024" d="M976 229l35 -159q3 -12 -3 -22.5t-17 -14.5l-5 -1q-4 -2 -10.5 -3.5t-16 -4.5t-21.5 -5.5t-25.5 -5t-30 -5t-33.5 -4.5t-36.5 -3t-38.5 -1q-234 0 -409 130.5t-238 351.5h-95q-13 0 -22.5 9.5t-9.5 22.5v113q0 13 9.5 22.5t22.5 9.5h66q-2 57 1 105h-67q-14 0 -23 9 t-9 23v114q0 14 9 23t23 9h98q67 210 243.5 338t400.5 128q102 0 194 -23q11 -3 20 -15q6 -11 3 -24l-43 -159q-3 -13 -14 -19.5t-24 -2.5l-4 1q-4 1 -11.5 2.5l-17.5 3.5t-22.5 3.5t-26 3t-29 2.5t-29.5 1q-126 0 -226 -64t-150 -176h468q16 0 25 -12q10 -12 7 -26 l-24 -114q-5 -26 -32 -26h-488q-3 -37 0 -105h459q15 0 25 -12q9 -12 6 -27l-24 -112q-2 -11 -11 -18.5t-20 -7.5h-387q48 -117 149.5 -185.5t228.5 -68.5q18 0 36 1.5t33.5 3.5t29.5 4.5t24.5 5t18.5 4.5l12 3l5 2q13 5 26 -2q12 -7 15 -21z" /> -<glyph unicode="" horiz-adv-x="1024" d="M1020 399v-367q0 -14 -9 -23t-23 -9h-956q-14 0 -23 9t-9 23v150q0 13 9.5 22.5t22.5 9.5h97v383h-95q-14 0 -23 9.5t-9 22.5v131q0 14 9 23t23 9h95v223q0 171 123.5 282t314.5 111q185 0 335 -125q9 -8 10 -20.5t-7 -22.5l-103 -127q-9 -11 -22 -12q-13 -2 -23 7 q-5 5 -26 19t-69 32t-93 18q-85 0 -137 -47t-52 -123v-215h305q13 0 22.5 -9t9.5 -23v-131q0 -13 -9.5 -22.5t-22.5 -9.5h-305v-379h414v181q0 13 9 22.5t23 9.5h162q14 0 23 -9.5t9 -22.5z" /> -<glyph unicode="" horiz-adv-x="1024" d="M978 351q0 -153 -99.5 -263.5t-258.5 -136.5v-175q0 -14 -9 -23t-23 -9h-135q-13 0 -22.5 9.5t-9.5 22.5v175q-66 9 -127.5 31t-101.5 44.5t-74 48t-46.5 37.5t-17.5 18q-17 21 -2 41l103 135q7 10 23 12q15 2 24 -9l2 -2q113 -99 243 -125q37 -8 74 -8q81 0 142.5 43 t61.5 122q0 28 -15 53t-33.5 42t-58.5 37.5t-66 32t-80 32.5q-39 16 -61.5 25t-61.5 26.5t-62.5 31t-56.5 35.5t-53.5 42.5t-43.5 49t-35.5 58t-21 66.5t-8.5 78q0 138 98 242t255 134v180q0 13 9.5 22.5t22.5 9.5h135q14 0 23 -9t9 -23v-176q57 -6 110.5 -23t87 -33.5 t63.5 -37.5t39 -29t15 -14q17 -18 5 -38l-81 -146q-8 -15 -23 -16q-14 -3 -27 7q-3 3 -14.5 12t-39 26.5t-58.5 32t-74.5 26t-85.5 11.5q-95 0 -155 -43t-60 -111q0 -26 8.5 -48t29.5 -41.5t39.5 -33t56 -31t60.5 -27t70 -27.5q53 -20 81 -31.5t76 -35t75.5 -42.5t62 -50 t53 -63.5t31.5 -76.5t13 -94z" /> -<glyph unicode="" horiz-adv-x="898" d="M898 1066v-102q0 -14 -9 -23t-23 -9h-168q-23 -144 -129 -234t-276 -110q167 -178 459 -536q14 -16 4 -34q-8 -18 -29 -18h-195q-16 0 -25 12q-306 367 -498 571q-9 9 -9 22v127q0 13 9.5 22.5t22.5 9.5h112q132 0 212.5 43t102.5 125h-427q-14 0 -23 9t-9 23v102 q0 14 9 23t23 9h413q-57 113 -268 113h-145q-13 0 -22.5 9.5t-9.5 22.5v133q0 14 9 23t23 9h832q14 0 23 -9t9 -23v-102q0 -14 -9 -23t-23 -9h-233q47 -61 64 -144h171q14 0 23 -9t9 -23z" /> -<glyph unicode="" horiz-adv-x="1027" d="M603 0h-172q-13 0 -22.5 9t-9.5 23v330h-288q-13 0 -22.5 9t-9.5 23v103q0 13 9.5 22.5t22.5 9.5h288v85h-288q-13 0 -22.5 9t-9.5 23v104q0 13 9.5 22.5t22.5 9.5h214l-321 578q-8 16 0 32q10 16 28 16h194q19 0 29 -18l215 -425q19 -38 56 -125q10 24 30.5 68t27.5 61 l191 420q8 19 29 19h191q17 0 27 -16q9 -14 1 -31l-313 -579h215q13 0 22.5 -9.5t9.5 -22.5v-104q0 -14 -9.5 -23t-22.5 -9h-290v-85h290q13 0 22.5 -9.5t9.5 -22.5v-103q0 -14 -9.5 -23t-22.5 -9h-290v-330q0 -13 -9.5 -22.5t-22.5 -9.5z" /> -<glyph unicode="" horiz-adv-x="1280" d="M1043 971q0 100 -65 162t-171 62h-320v-448h320q106 0 171 62t65 162zM1280 971q0 -193 -126.5 -315t-326.5 -122h-340v-118h505q14 0 23 -9t9 -23v-128q0 -14 -9 -23t-23 -9h-505v-192q0 -14 -9.5 -23t-22.5 -9h-167q-14 0 -23 9t-9 23v192h-224q-14 0 -23 9t-9 23v128 q0 14 9 23t23 9h224v118h-224q-14 0 -23 9t-9 23v149q0 13 9 22.5t23 9.5h224v629q0 14 9 23t23 9h539q200 0 326.5 -122t126.5 -315z" /> -<glyph unicode="" horiz-adv-x="1792" d="M514 341l81 299h-159l75 -300q1 -1 1 -3t1 -3q0 1 0.5 3.5t0.5 3.5zM630 768l35 128h-292l32 -128h225zM822 768h139l-35 128h-70zM1271 340l78 300h-162l81 -299q0 -1 0.5 -3.5t1.5 -3.5q0 1 0.5 3t0.5 3zM1382 768l33 128h-297l34 -128h230zM1792 736v-64q0 -14 -9 -23 t-23 -9h-213l-164 -616q-7 -24 -31 -24h-159q-24 0 -31 24l-166 616h-209l-167 -616q-7 -24 -31 -24h-159q-11 0 -19.5 7t-10.5 17l-160 616h-208q-14 0 -23 9t-9 23v64q0 14 9 23t23 9h175l-33 128h-142q-14 0 -23 9t-9 23v64q0 14 9 23t23 9h109l-89 344q-5 15 5 28 q10 12 26 12h137q26 0 31 -24l90 -360h359l97 360q7 24 31 24h126q24 0 31 -24l98 -360h365l93 360q5 24 31 24h137q16 0 26 -12q10 -13 5 -28l-91 -344h111q14 0 23 -9t9 -23v-64q0 -14 -9 -23t-23 -9h-145l-34 -128h179q14 0 23 -9t9 -23z" /> -<glyph unicode="" horiz-adv-x="1280" d="M1167 896q18 -182 -131 -258q117 -28 175 -103t45 -214q-7 -71 -32.5 -125t-64.5 -89t-97 -58.5t-121.5 -34.5t-145.5 -15v-255h-154v251q-80 0 -122 1v-252h-154v255q-18 0 -54 0.5t-55 0.5h-200l31 183h111q50 0 58 51v402h16q-6 1 -16 1v287q-13 68 -89 68h-111v164 l212 -1q64 0 97 1v252h154v-247q82 2 122 2v245h154v-252q79 -7 140 -22.5t113 -45t82.5 -78t36.5 -114.5zM952 351q0 36 -15 64t-37 46t-57.5 30.5t-65.5 18.5t-74 9t-69 3t-64.5 -1t-47.5 -1v-338q8 0 37 -0.5t48 -0.5t53 1.5t58.5 4t57 8.5t55.5 14t47.5 21t39.5 30 t24.5 40t9.5 51zM881 827q0 33 -12.5 58.5t-30.5 42t-48 28t-55 16.5t-61.5 8t-58 2.5t-54 -1t-39.5 -0.5v-307q5 0 34.5 -0.5t46.5 0t50 2t55 5.5t51.5 11t48.5 18.5t37 27t27 38.5t9 51z" /> -<glyph unicode="" d="M1024 1024v472q22 -14 36 -28l408 -408q14 -14 28 -36h-472zM896 992q0 -40 28 -68t68 -28h544v-1056q0 -40 -28 -68t-68 -28h-1344q-40 0 -68 28t-28 68v1600q0 40 28 68t68 28h800v-544z" /> -<glyph unicode="" d="M1468 1060q14 -14 28 -36h-472v472q22 -14 36 -28zM992 896h544v-1056q0 -40 -28 -68t-68 -28h-1344q-40 0 -68 28t-28 68v1600q0 40 28 68t68 28h800v-544q0 -40 28 -68t68 -28zM1152 160v64q0 14 -9 23t-23 9h-704q-14 0 -23 -9t-9 -23v-64q0 -14 9 -23t23 -9h704 q14 0 23 9t9 23zM1152 416v64q0 14 -9 23t-23 9h-704q-14 0 -23 -9t-9 -23v-64q0 -14 9 -23t23 -9h704q14 0 23 9t9 23zM1152 672v64q0 14 -9 23t-23 9h-704q-14 0 -23 -9t-9 -23v-64q0 -14 9 -23t23 -9h704q14 0 23 9t9 23z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1191 1128h177l-72 218l-12 47q-2 16 -2 20h-4l-3 -20q0 -1 -3.5 -18t-7.5 -29zM736 96q0 -12 -10 -24l-319 -319q-10 -9 -23 -9q-12 0 -23 9l-320 320q-15 16 -7 35q8 20 30 20h192v1376q0 14 9 23t23 9h192q14 0 23 -9t9 -23v-1376h192q14 0 23 -9t9 -23zM1572 -23 v-233h-584v90l369 529q12 18 21 27l11 9v3q-2 0 -6.5 -0.5t-7.5 -0.5q-12 -3 -30 -3h-232v-115h-120v229h567v-89l-369 -530q-6 -8 -21 -26l-11 -11v-2l14 2q9 2 30 2h248v119h121zM1661 874v-106h-288v106h75l-47 144h-243l-47 -144h75v-106h-287v106h70l230 662h162 l230 -662h70z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1191 104h177l-72 218l-12 47q-2 16 -2 20h-4l-3 -20q0 -1 -3.5 -18t-7.5 -29zM736 96q0 -12 -10 -24l-319 -319q-10 -9 -23 -9q-12 0 -23 9l-320 320q-15 16 -7 35q8 20 30 20h192v1376q0 14 9 23t23 9h192q14 0 23 -9t9 -23v-1376h192q14 0 23 -9t9 -23zM1661 -150 v-106h-288v106h75l-47 144h-243l-47 -144h75v-106h-287v106h70l230 662h162l230 -662h70zM1572 1001v-233h-584v90l369 529q12 18 21 27l11 9v3q-2 0 -6.5 -0.5t-7.5 -0.5q-12 -3 -30 -3h-232v-115h-120v229h567v-89l-369 -530q-6 -8 -21 -26l-11 -10v-3l14 3q9 1 30 1h248 v119h121z" /> -<glyph unicode="" horiz-adv-x="1792" d="M736 96q0 -12 -10 -24l-319 -319q-10 -9 -23 -9q-12 0 -23 9l-320 320q-15 16 -7 35q8 20 30 20h192v1376q0 14 9 23t23 9h192q14 0 23 -9t9 -23v-1376h192q14 0 23 -9t9 -23zM1792 -32v-192q0 -14 -9 -23t-23 -9h-832q-14 0 -23 9t-9 23v192q0 14 9 23t23 9h832 q14 0 23 -9t9 -23zM1600 480v-192q0 -14 -9 -23t-23 -9h-640q-14 0 -23 9t-9 23v192q0 14 9 23t23 9h640q14 0 23 -9t9 -23zM1408 992v-192q0 -14 -9 -23t-23 -9h-448q-14 0 -23 9t-9 23v192q0 14 9 23t23 9h448q14 0 23 -9t9 -23zM1216 1504v-192q0 -14 -9 -23t-23 -9h-256 q-14 0 -23 9t-9 23v192q0 14 9 23t23 9h256q14 0 23 -9t9 -23z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1216 -32v-192q0 -14 -9 -23t-23 -9h-256q-14 0 -23 9t-9 23v192q0 14 9 23t23 9h256q14 0 23 -9t9 -23zM736 96q0 -12 -10 -24l-319 -319q-10 -9 -23 -9q-12 0 -23 9l-320 320q-15 16 -7 35q8 20 30 20h192v1376q0 14 9 23t23 9h192q14 0 23 -9t9 -23v-1376h192 q14 0 23 -9t9 -23zM1408 480v-192q0 -14 -9 -23t-23 -9h-448q-14 0 -23 9t-9 23v192q0 14 9 23t23 9h448q14 0 23 -9t9 -23zM1600 992v-192q0 -14 -9 -23t-23 -9h-640q-14 0 -23 9t-9 23v192q0 14 9 23t23 9h640q14 0 23 -9t9 -23zM1792 1504v-192q0 -14 -9 -23t-23 -9h-832 q-14 0 -23 9t-9 23v192q0 14 9 23t23 9h832q14 0 23 -9t9 -23z" /> -<glyph unicode="" d="M1346 223q0 63 -44 116t-103 53q-52 0 -83 -37t-31 -94t36.5 -95t104.5 -38q50 0 85 27t35 68zM736 96q0 -12 -10 -24l-319 -319q-10 -9 -23 -9q-12 0 -23 9l-320 320q-15 16 -7 35q8 20 30 20h192v1376q0 14 9 23t23 9h192q14 0 23 -9t9 -23v-1376h192q14 0 23 -9t9 -23 zM1486 165q0 -62 -13 -121.5t-41 -114t-68 -95.5t-98.5 -65.5t-127.5 -24.5q-62 0 -108 16q-24 8 -42 15l39 113q15 -7 31 -11q37 -13 75 -13q84 0 134.5 58.5t66.5 145.5h-2q-21 -23 -61.5 -37t-84.5 -14q-106 0 -173 71.5t-67 172.5q0 105 72 178t181 73q123 0 205 -94.5 t82 -252.5zM1456 882v-114h-469v114h167v432q0 7 0.5 19t0.5 17v16h-2l-7 -12q-8 -13 -26 -31l-62 -58l-82 86l192 185h123v-654h165z" /> -<glyph unicode="" d="M1346 1247q0 63 -44 116t-103 53q-52 0 -83 -37t-31 -94t36.5 -95t104.5 -38q50 0 85 27t35 68zM736 96q0 -12 -10 -24l-319 -319q-10 -9 -23 -9q-12 0 -23 9l-320 320q-15 16 -7 35q8 20 30 20h192v1376q0 14 9 23t23 9h192q14 0 23 -9t9 -23v-1376h192q14 0 23 -9 t9 -23zM1456 -142v-114h-469v114h167v432q0 7 0.5 19t0.5 17v16h-2l-7 -12q-8 -13 -26 -31l-62 -58l-82 86l192 185h123v-654h165zM1486 1189q0 -62 -13 -121.5t-41 -114t-68 -95.5t-98.5 -65.5t-127.5 -24.5q-62 0 -108 16q-24 8 -42 15l39 113q15 -7 31 -11q37 -13 75 -13 q84 0 134.5 58.5t66.5 145.5h-2q-21 -23 -61.5 -37t-84.5 -14q-106 0 -173 71.5t-67 172.5q0 105 72 178t181 73q123 0 205 -94.5t82 -252.5z" /> -<glyph unicode="" horiz-adv-x="1664" d="M256 192q0 26 -19 45t-45 19q-27 0 -45.5 -19t-18.5 -45q0 -27 18.5 -45.5t45.5 -18.5q26 0 45 18.5t19 45.5zM416 704v-640q0 -26 -19 -45t-45 -19h-288q-26 0 -45 19t-19 45v640q0 26 19 45t45 19h288q26 0 45 -19t19 -45zM1600 704q0 -86 -55 -149q15 -44 15 -76 q3 -76 -43 -137q17 -56 0 -117q-15 -57 -54 -94q9 -112 -49 -181q-64 -76 -197 -78h-36h-76h-17q-66 0 -144 15.5t-121.5 29t-120.5 39.5q-123 43 -158 44q-26 1 -45 19.5t-19 44.5v641q0 25 18 43.5t43 20.5q24 2 76 59t101 121q68 87 101 120q18 18 31 48t17.5 48.5 t13.5 60.5q7 39 12.5 61t19.5 52t34 50q19 19 45 19q46 0 82.5 -10.5t60 -26t40 -40.5t24 -45t12 -50t5 -45t0.5 -39q0 -38 -9.5 -76t-19 -60t-27.5 -56q-3 -6 -10 -18t-11 -22t-8 -24h277q78 0 135 -57t57 -135z" /> -<glyph unicode="" horiz-adv-x="1664" d="M256 960q0 -26 -19 -45t-45 -19q-27 0 -45.5 19t-18.5 45q0 27 18.5 45.5t45.5 18.5q26 0 45 -18.5t19 -45.5zM416 448v640q0 26 -19 45t-45 19h-288q-26 0 -45 -19t-19 -45v-640q0 -26 19 -45t45 -19h288q26 0 45 19t19 45zM1545 597q55 -61 55 -149q-1 -78 -57.5 -135 t-134.5 -57h-277q4 -14 8 -24t11 -22t10 -18q18 -37 27 -57t19 -58.5t10 -76.5q0 -24 -0.5 -39t-5 -45t-12 -50t-24 -45t-40 -40.5t-60 -26t-82.5 -10.5q-26 0 -45 19q-20 20 -34 50t-19.5 52t-12.5 61q-9 42 -13.5 60.5t-17.5 48.5t-31 48q-33 33 -101 120q-49 64 -101 121 t-76 59q-25 2 -43 20.5t-18 43.5v641q0 26 19 44.5t45 19.5q35 1 158 44q77 26 120.5 39.5t121.5 29t144 15.5h17h76h36q133 -2 197 -78q58 -69 49 -181q39 -37 54 -94q17 -61 0 -117q46 -61 43 -137q0 -32 -15 -76z" /> -<glyph unicode="" d="M919 233v157q0 50 -29 50q-17 0 -33 -16v-224q16 -16 33 -16q29 0 29 49zM1103 355h66v34q0 51 -33 51t-33 -51v-34zM532 621v-70h-80v-423h-74v423h-78v70h232zM733 495v-367h-67v40q-39 -45 -76 -45q-33 0 -42 28q-6 16 -6 54v290h66v-270q0 -24 1 -26q1 -15 15 -15 q20 0 42 31v280h67zM985 384v-146q0 -52 -7 -73q-12 -42 -53 -42q-35 0 -68 41v-36h-67v493h67v-161q32 40 68 40q41 0 53 -42q7 -21 7 -74zM1236 255v-9q0 -29 -2 -43q-3 -22 -15 -40q-27 -40 -80 -40q-52 0 -81 38q-21 27 -21 86v129q0 59 20 86q29 38 80 38t78 -38 q21 -28 21 -86v-76h-133v-65q0 -51 34 -51q24 0 30 26q0 1 0.5 7t0.5 16.5v21.5h68zM785 1079v-156q0 -51 -32 -51t-32 51v156q0 52 32 52t32 -52zM1318 366q0 177 -19 260q-10 44 -43 73.5t-76 34.5q-136 15 -412 15q-275 0 -411 -15q-44 -5 -76.5 -34.5t-42.5 -73.5 q-20 -87 -20 -260q0 -176 20 -260q10 -43 42.5 -73t75.5 -35q137 -15 412 -15t412 15q43 5 75.5 35t42.5 73q20 84 20 260zM563 1017l90 296h-75l-51 -195l-53 195h-78l24 -69t23 -69q35 -103 46 -158v-201h74v201zM852 936v130q0 58 -21 87q-29 38 -78 38q-51 0 -78 -38 q-21 -29 -21 -87v-130q0 -58 21 -87q27 -38 78 -38q49 0 78 38q21 27 21 87zM1033 816h67v370h-67v-283q-22 -31 -42 -31q-15 0 -16 16q-1 2 -1 26v272h-67v-293q0 -37 6 -55q11 -27 43 -27q36 0 77 45v-40zM1536 1120v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960 q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" d="M971 292v-211q0 -67 -39 -67q-23 0 -45 22v301q22 22 45 22q39 0 39 -67zM1309 291v-46h-90v46q0 68 45 68t45 -68zM343 509h107v94h-312v-94h105v-569h100v569zM631 -60h89v494h-89v-378q-30 -42 -57 -42q-18 0 -21 21q-1 3 -1 35v364h-89v-391q0 -49 8 -73 q12 -37 58 -37q48 0 102 61v-54zM1060 88v197q0 73 -9 99q-17 56 -71 56q-50 0 -93 -54v217h-89v-663h89v48q45 -55 93 -55q54 0 71 55q9 27 9 100zM1398 98v13h-91q0 -51 -2 -61q-7 -36 -40 -36q-46 0 -46 69v87h179v103q0 79 -27 116q-39 51 -106 51q-68 0 -107 -51 q-28 -37 -28 -116v-173q0 -79 29 -116q39 -51 108 -51q72 0 108 53q18 27 21 54q2 9 2 58zM790 1011v210q0 69 -43 69t-43 -69v-210q0 -70 43 -70t43 70zM1509 260q0 -234 -26 -350q-14 -59 -58 -99t-102 -46q-184 -21 -555 -21t-555 21q-58 6 -102.5 46t-57.5 99 q-26 112 -26 350q0 234 26 350q14 59 58 99t103 47q183 20 554 20t555 -20q58 -7 102.5 -47t57.5 -99q26 -112 26 -350zM511 1536h102l-121 -399v-271h-100v271q-14 74 -61 212q-37 103 -65 187h106l71 -263zM881 1203v-175q0 -81 -28 -118q-37 -51 -106 -51q-67 0 -105 51 q-28 38 -28 118v175q0 80 28 117q38 51 105 51q69 0 106 -51q28 -37 28 -117zM1216 1365v-499h-91v55q-53 -62 -103 -62q-46 0 -59 37q-8 24 -8 75v394h91v-367q0 -33 1 -35q3 -22 21 -22q27 0 57 43v381h91z" /> -<glyph unicode="" horiz-adv-x="1408" d="M597 869q-10 -18 -257 -456q-27 -46 -65 -46h-239q-21 0 -31 17t0 36l253 448q1 0 0 1l-161 279q-12 22 -1 37q9 15 32 15h239q40 0 66 -45zM1403 1511q11 -16 0 -37l-528 -934v-1l336 -615q11 -20 1 -37q-10 -15 -32 -15h-239q-42 0 -66 45l-339 622q18 32 531 942 q25 45 64 45h241q22 0 31 -15z" /> -<glyph unicode="" d="M685 771q0 1 -126 222q-21 34 -52 34h-184q-18 0 -26 -11q-7 -12 1 -29l125 -216v-1l-196 -346q-9 -14 0 -28q8 -13 24 -13h185q31 0 50 36zM1309 1268q-7 12 -24 12h-187q-30 0 -49 -35l-411 -729q1 -2 262 -481q20 -35 52 -35h184q18 0 25 12q8 13 -1 28l-260 476v1 l409 723q8 16 0 28zM1536 1120v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1280 640q0 37 -30 54l-512 320q-31 20 -65 2q-33 -18 -33 -56v-640q0 -38 33 -56q16 -8 31 -8q20 0 34 10l512 320q30 17 30 54zM1792 640q0 -96 -1 -150t-8.5 -136.5t-22.5 -147.5q-16 -73 -69 -123t-124 -58q-222 -25 -671 -25t-671 25q-71 8 -124.5 58t-69.5 123 q-14 65 -21.5 147.5t-8.5 136.5t-1 150t1 150t8.5 136.5t22.5 147.5q16 73 69 123t124 58q222 25 671 25t671 -25q71 -8 124.5 -58t69.5 -123q14 -65 21.5 -147.5t8.5 -136.5t1 -150z" /> -<glyph unicode="" horiz-adv-x="1792" d="M402 829l494 -305l-342 -285l-490 319zM1388 274v-108l-490 -293v-1l-1 1l-1 -1v1l-489 293v108l147 -96l342 284v2l1 -1l1 1v-2l343 -284zM554 1418l342 -285l-494 -304l-338 270zM1390 829l338 -271l-489 -319l-343 285zM1239 1418l489 -319l-338 -270l-494 304z" /> -<glyph unicode="" horiz-adv-x="1408" d="M928 135v-151l-707 -1v151zM1169 481v-701l-1 -35v-1h-1132l-35 1h-1v736h121v-618h928v618h120zM241 393l704 -65l-13 -150l-705 65zM309 709l683 -183l-39 -146l-683 183zM472 1058l609 -360l-77 -130l-609 360zM832 1389l398 -585l-124 -85l-399 584zM1285 1536 l121 -697l-149 -26l-121 697z" /> -<glyph unicode="" d="M1362 110v648h-135q20 -63 20 -131q0 -126 -64 -232.5t-174 -168.5t-240 -62q-197 0 -337 135.5t-140 327.5q0 68 20 131h-141v-648q0 -26 17.5 -43.5t43.5 -17.5h1069q25 0 43 17.5t18 43.5zM1078 643q0 124 -90.5 211.5t-218.5 87.5q-127 0 -217.5 -87.5t-90.5 -211.5 t90.5 -211.5t217.5 -87.5q128 0 218.5 87.5t90.5 211.5zM1362 1003v165q0 28 -20 48.5t-49 20.5h-174q-29 0 -49 -20.5t-20 -48.5v-165q0 -29 20 -49t49 -20h174q29 0 49 20t20 49zM1536 1211v-1142q0 -81 -58 -139t-139 -58h-1142q-81 0 -139 58t-58 139v1142q0 81 58 139 t139 58h1142q81 0 139 -58t58 -139z" /> -<glyph unicode="" d="M1248 1408q119 0 203.5 -84.5t84.5 -203.5v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960zM698 640q0 88 -62 150t-150 62t-150 -62t-62 -150t62 -150t150 -62t150 62t62 150zM1262 640q0 88 -62 150 t-150 62t-150 -62t-62 -150t62 -150t150 -62t150 62t62 150z" /> -<glyph unicode="" d="M768 914l201 -306h-402zM1133 384h94l-459 691l-459 -691h94l104 160h522zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" horiz-adv-x="1408" d="M815 677q8 -63 -50.5 -101t-111.5 -6q-39 17 -53.5 58t-0.5 82t52 58q36 18 72.5 12t64 -35.5t27.5 -67.5zM926 698q-14 107 -113 164t-197 13q-63 -28 -100.5 -88.5t-34.5 -129.5q4 -91 77.5 -155t165.5 -56q91 8 152 84t50 168zM1165 1240q-20 27 -56 44.5t-58 22 t-71 12.5q-291 47 -566 -2q-43 -7 -66 -12t-55 -22t-50 -43q30 -28 76 -45.5t73.5 -22t87.5 -11.5q228 -29 448 -1q63 8 89.5 12t72.5 21.5t75 46.5zM1222 205q-8 -26 -15.5 -76.5t-14 -84t-28.5 -70t-58 -56.5q-86 -48 -189.5 -71.5t-202 -22t-201.5 18.5q-46 8 -81.5 18 t-76.5 27t-73 43.5t-52 61.5q-25 96 -57 292l6 16l18 9q223 -148 506.5 -148t507.5 148q21 -6 24 -23t-5 -45t-8 -37zM1403 1166q-26 -167 -111 -655q-5 -30 -27 -56t-43.5 -40t-54.5 -31q-252 -126 -610 -88q-248 27 -394 139q-15 12 -25.5 26.5t-17 35t-9 34t-6 39.5 t-5.5 35q-9 50 -26.5 150t-28 161.5t-23.5 147.5t-22 158q3 26 17.5 48.5t31.5 37.5t45 30t46 22.5t48 18.5q125 46 313 64q379 37 676 -50q155 -46 215 -122q16 -20 16.5 -51t-5.5 -54z" /> -<glyph unicode="" d="M848 666q0 43 -41 66t-77 1q-43 -20 -42.5 -72.5t43.5 -70.5q39 -23 81 4t36 72zM928 682q8 -66 -36 -121t-110 -61t-119 40t-56 113q-2 49 25.5 93t72.5 64q70 31 141.5 -10t81.5 -118zM1100 1073q-20 -21 -53.5 -34t-53 -16t-63.5 -8q-155 -20 -324 0q-44 6 -63 9.5 t-52.5 16t-54.5 32.5q13 19 36 31t40 15.5t47 8.5q198 35 408 1q33 -5 51 -8.5t43 -16t39 -31.5zM1142 327q0 7 5.5 26.5t3 32t-17.5 16.5q-161 -106 -365 -106t-366 106l-12 -6l-5 -12q26 -154 41 -210q47 -81 204 -108q249 -46 428 53q34 19 49 51.5t22.5 85.5t12.5 71z M1272 1020q9 53 -8 75q-43 55 -155 88q-216 63 -487 36q-132 -12 -226 -46q-38 -15 -59.5 -25t-47 -34t-29.5 -54q8 -68 19 -138t29 -171t24 -137q1 -5 5 -31t7 -36t12 -27t22 -28q105 -80 284 -100q259 -28 440 63q24 13 39.5 23t31 29t19.5 40q48 267 80 473zM1536 1120 v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" horiz-adv-x="1024" d="M944 207l80 -237q-23 -35 -111 -66t-177 -32q-104 -2 -190.5 26t-142.5 74t-95 106t-55.5 120t-16.5 118v544h-168v215q72 26 129 69.5t91 90t58 102t34 99t15 88.5q1 5 4.5 8.5t7.5 3.5h244v-424h333v-252h-334v-518q0 -30 6.5 -56t22.5 -52.5t49.5 -41.5t81.5 -14 q78 2 134 29z" /> -<glyph unicode="" d="M1136 75l-62 183q-44 -22 -103 -22q-36 -1 -62 10.5t-38.5 31.5t-17.5 40.5t-5 43.5v398h257v194h-256v326h-188q-8 0 -9 -10q-5 -44 -17.5 -87t-39 -95t-77 -95t-118.5 -68v-165h130v-418q0 -57 21.5 -115t65 -111t121 -85.5t176.5 -30.5q69 1 136.5 25t85.5 50z M1536 1120v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" horiz-adv-x="768" d="M765 237q8 -19 -5 -35l-350 -384q-10 -10 -23 -10q-14 0 -24 10l-355 384q-13 16 -5 35q9 19 29 19h224v1248q0 14 9 23t23 9h192q14 0 23 -9t9 -23v-1248h224q21 0 29 -19z" /> -<glyph unicode="" horiz-adv-x="768" d="M765 1043q-9 -19 -29 -19h-224v-1248q0 -14 -9 -23t-23 -9h-192q-14 0 -23 9t-9 23v1248h-224q-21 0 -29 19t5 35l350 384q10 10 23 10q14 0 24 -10l355 -384q13 -16 5 -35z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1792 736v-192q0 -14 -9 -23t-23 -9h-1248v-224q0 -21 -19 -29t-35 5l-384 350q-10 10 -10 23q0 14 10 24l384 354q16 14 35 6q19 -9 19 -29v-224h1248q14 0 23 -9t9 -23z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1728 643q0 -14 -10 -24l-384 -354q-16 -14 -35 -6q-19 9 -19 29v224h-1248q-14 0 -23 9t-9 23v192q0 14 9 23t23 9h1248v224q0 21 19 29t35 -5l384 -350q10 -10 10 -23z" /> -<glyph unicode="" horiz-adv-x="1408" d="M1393 321q-39 -125 -123 -250q-129 -196 -257 -196q-49 0 -140 32q-86 32 -151 32q-61 0 -142 -33q-81 -34 -132 -34q-152 0 -301 259q-147 261 -147 503q0 228 113 374q112 144 284 144q72 0 177 -30q104 -30 138 -30q45 0 143 34q102 34 173 34q119 0 213 -65 q52 -36 104 -100q-79 -67 -114 -118q-65 -94 -65 -207q0 -124 69 -223t158 -126zM1017 1494q0 -61 -29 -136q-30 -75 -93 -138q-54 -54 -108 -72q-37 -11 -104 -17q3 149 78 257q74 107 250 148q1 -3 2.5 -11t2.5 -11q0 -4 0.5 -10t0.5 -10z" /> -<glyph unicode="" horiz-adv-x="1664" d="M682 530v-651l-682 94v557h682zM682 1273v-659h-682v565zM1664 530v-786l-907 125v661h907zM1664 1408v-794h-907v669z" /> -<glyph unicode="" horiz-adv-x="1408" d="M493 1053q16 0 27.5 11.5t11.5 27.5t-11.5 27.5t-27.5 11.5t-27 -11.5t-11 -27.5t11 -27.5t27 -11.5zM915 1053q16 0 27 11.5t11 27.5t-11 27.5t-27 11.5t-27.5 -11.5t-11.5 -27.5t11.5 -27.5t27.5 -11.5zM103 869q42 0 72 -30t30 -72v-430q0 -43 -29.5 -73t-72.5 -30 t-73 30t-30 73v430q0 42 30 72t73 30zM1163 850v-666q0 -46 -32 -78t-77 -32h-75v-227q0 -43 -30 -73t-73 -30t-73 30t-30 73v227h-138v-227q0 -43 -30 -73t-73 -30q-42 0 -72 30t-30 73l-1 227h-74q-46 0 -78 32t-32 78v666h918zM931 1255q107 -55 171 -153.5t64 -215.5 h-925q0 117 64 215.5t172 153.5l-71 131q-7 13 5 20q13 6 20 -6l72 -132q95 42 201 42t201 -42l72 132q7 12 20 6q12 -7 5 -20zM1408 767v-430q0 -43 -30 -73t-73 -30q-42 0 -72 30t-30 73v430q0 43 30 72.5t72 29.5q43 0 73 -29.5t30 -72.5z" /> -<glyph unicode="" d="M663 1125q-11 -1 -15.5 -10.5t-8.5 -9.5q-5 -1 -5 5q0 12 19 15h10zM750 1111q-4 -1 -11.5 6.5t-17.5 4.5q24 11 32 -2q3 -6 -3 -9zM399 684q-4 1 -6 -3t-4.5 -12.5t-5.5 -13.5t-10 -13q-7 -10 -1 -12q4 -1 12.5 7t12.5 18q1 3 2 7t2 6t1.5 4.5t0.5 4v3t-1 2.5t-3 2z M1254 325q0 18 -55 42q4 15 7.5 27.5t5 26t3 21.5t0.5 22.5t-1 19.5t-3.5 22t-4 20.5t-5 25t-5.5 26.5q-10 48 -47 103t-72 75q24 -20 57 -83q87 -162 54 -278q-11 -40 -50 -42q-31 -4 -38.5 18.5t-8 83.5t-11.5 107q-9 39 -19.5 69t-19.5 45.5t-15.5 24.5t-13 15t-7.5 7 q-14 62 -31 103t-29.5 56t-23.5 33t-15 40q-4 21 6 53.5t4.5 49.5t-44.5 25q-15 3 -44.5 18t-35.5 16q-8 1 -11 26t8 51t36 27q37 3 51 -30t4 -58q-11 -19 -2 -26.5t30 -0.5q13 4 13 36v37q-5 30 -13.5 50t-21 30.5t-23.5 15t-27 7.5q-107 -8 -89 -134q0 -15 -1 -15 q-9 9 -29.5 10.5t-33 -0.5t-15.5 5q1 57 -16 90t-45 34q-27 1 -41.5 -27.5t-16.5 -59.5q-1 -15 3.5 -37t13 -37.5t15.5 -13.5q10 3 16 14q4 9 -7 8q-7 0 -15.5 14.5t-9.5 33.5q-1 22 9 37t34 14q17 0 27 -21t9.5 -39t-1.5 -22q-22 -15 -31 -29q-8 -12 -27.5 -23.5 t-20.5 -12.5q-13 -14 -15.5 -27t7.5 -18q14 -8 25 -19.5t16 -19t18.5 -13t35.5 -6.5q47 -2 102 15q2 1 23 7t34.5 10.5t29.5 13t21 17.5q9 14 20 8q5 -3 6.5 -8.5t-3 -12t-16.5 -9.5q-20 -6 -56.5 -21.5t-45.5 -19.5q-44 -19 -70 -23q-25 -5 -79 2q-10 2 -9 -2t17 -19 q25 -23 67 -22q17 1 36 7t36 14t33.5 17.5t30 17t24.5 12t17.5 2.5t8.5 -11q0 -2 -1 -4.5t-4 -5t-6 -4.5t-8.5 -5t-9 -4.5t-10 -5t-9.5 -4.5q-28 -14 -67.5 -44t-66.5 -43t-49 -1q-21 11 -63 73q-22 31 -25 22q-1 -3 -1 -10q0 -25 -15 -56.5t-29.5 -55.5t-21 -58t11.5 -63 q-23 -6 -62.5 -90t-47.5 -141q-2 -18 -1.5 -69t-5.5 -59q-8 -24 -29 -3q-32 31 -36 94q-2 28 4 56q4 19 -1 18l-4 -5q-36 -65 10 -166q5 -12 25 -28t24 -20q20 -23 104 -90.5t93 -76.5q16 -15 17.5 -38t-14 -43t-45.5 -23q8 -15 29 -44.5t28 -54t7 -70.5q46 24 7 92 q-4 8 -10.5 16t-9.5 12t-2 6q3 5 13 9.5t20 -2.5q46 -52 166 -36q133 15 177 87q23 38 34 30q12 -6 10 -52q-1 -25 -23 -92q-9 -23 -6 -37.5t24 -15.5q3 19 14.5 77t13.5 90q2 21 -6.5 73.5t-7.5 97t23 70.5q15 18 51 18q1 37 34.5 53t72.5 10.5t60 -22.5zM626 1152 q3 17 -2.5 30t-11.5 15q-9 2 -9 -7q2 -5 5 -6q10 0 7 -15q-3 -20 8 -20q3 0 3 3zM1045 955q-2 8 -6.5 11.5t-13 5t-14.5 5.5q-5 3 -9.5 8t-7 8t-5.5 6.5t-4 4t-4 -1.5q-14 -16 7 -43.5t39 -31.5q9 -1 14.5 8t3.5 20zM867 1168q0 11 -5 19.5t-11 12.5t-9 3q-14 -1 -7 -7l4 -2 q14 -4 18 -31q0 -3 8 2zM921 1401q0 2 -2.5 5t-9 7t-9.5 6q-15 15 -24 15q-9 -1 -11.5 -7.5t-1 -13t-0.5 -12.5q-1 -4 -6 -10.5t-6 -9t3 -8.5q4 -3 8 0t11 9t15 9q1 1 9 1t15 2t9 7zM1486 60q20 -12 31 -24.5t12 -24t-2.5 -22.5t-15.5 -22t-23.5 -19.5t-30 -18.5 t-31.5 -16.5t-32 -15.5t-27 -13q-38 -19 -85.5 -56t-75.5 -64q-17 -16 -68 -19.5t-89 14.5q-18 9 -29.5 23.5t-16.5 25.5t-22 19.5t-47 9.5q-44 1 -130 1q-19 0 -57 -1.5t-58 -2.5q-44 -1 -79.5 -15t-53.5 -30t-43.5 -28.5t-53.5 -11.5q-29 1 -111 31t-146 43q-19 4 -51 9.5 t-50 9t-39.5 9.5t-33.5 14.5t-17 19.5q-10 23 7 66.5t18 54.5q1 16 -4 40t-10 42.5t-4.5 36.5t10.5 27q14 12 57 14t60 12q30 18 42 35t12 51q21 -73 -32 -106q-32 -20 -83 -15q-34 3 -43 -10q-13 -15 5 -57q2 -6 8 -18t8.5 -18t4.5 -17t1 -22q0 -15 -17 -49t-14 -48 q3 -17 37 -26q20 -6 84.5 -18.5t99.5 -20.5q24 -6 74 -22t82.5 -23t55.5 -4q43 6 64.5 28t23 48t-7.5 58.5t-19 52t-20 36.5q-121 190 -169 242q-68 74 -113 40q-11 -9 -15 15q-3 16 -2 38q1 29 10 52t24 47t22 42q8 21 26.5 72t29.5 78t30 61t39 54q110 143 124 195 q-12 112 -16 310q-2 90 24 151.5t106 104.5q39 21 104 21q53 1 106 -13.5t89 -41.5q57 -42 91.5 -121.5t29.5 -147.5q-5 -95 30 -214q34 -113 133 -218q55 -59 99.5 -163t59.5 -191q8 -49 5 -84.5t-12 -55.5t-20 -22q-10 -2 -23.5 -19t-27 -35.5t-40.5 -33.5t-61 -14 q-18 1 -31.5 5t-22.5 13.5t-13.5 15.5t-11.5 20.5t-9 19.5q-22 37 -41 30t-28 -49t7 -97q20 -70 1 -195q-10 -65 18 -100.5t73 -33t85 35.5q59 49 89.5 66.5t103.5 42.5q53 18 77 36.5t18.5 34.5t-25 28.5t-51.5 23.5q-33 11 -49.5 48t-15 72.5t15.5 47.5q1 -31 8 -56.5 t14.5 -40.5t20.5 -28.5t21 -19t21.5 -13t16.5 -9.5z" /> -<glyph unicode="" d="M1024 36q-42 241 -140 498h-2l-2 -1q-16 -6 -43 -16.5t-101 -49t-137 -82t-131 -114.5t-103 -148l-15 11q184 -150 418 -150q132 0 256 52zM839 643q-21 49 -53 111q-311 -93 -673 -93q-1 -7 -1 -21q0 -124 44 -236.5t124 -201.5q50 89 123.5 166.5t142.5 124.5t130.5 81 t99.5 48l37 13q4 1 13 3.5t13 4.5zM732 855q-120 213 -244 378q-138 -65 -234 -186t-128 -272q302 0 606 80zM1416 536q-210 60 -409 29q87 -239 128 -469q111 75 185 189.5t96 250.5zM611 1277q-1 0 -2 -1q1 1 2 1zM1201 1132q-185 164 -433 164q-76 0 -155 -19 q131 -170 246 -382q69 26 130 60.5t96.5 61.5t65.5 57t37.5 40.5zM1424 647q-3 232 -149 410l-1 -1q-9 -12 -19 -24.5t-43.5 -44.5t-71 -60.5t-100 -65t-131.5 -64.5q25 -53 44 -95q2 -6 6.5 -17.5t7.5 -16.5q36 5 74.5 7t73.5 2t69 -1.5t64 -4t56.5 -5.5t48 -6.5t36.5 -6 t25 -4.5zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" d="M1173 473q0 50 -19.5 91.5t-48.5 68.5t-73 49t-82.5 34t-87.5 23l-104 24q-30 7 -44 10.5t-35 11.5t-30 16t-16.5 21t-7.5 30q0 77 144 77q43 0 77 -12t54 -28.5t38 -33.5t40 -29t48 -12q47 0 75.5 32t28.5 77q0 55 -56 99.5t-142 67.5t-182 23q-68 0 -132 -15.5 t-119.5 -47t-89 -87t-33.5 -128.5q0 -61 19 -106.5t56 -75.5t80 -48.5t103 -32.5l146 -36q90 -22 112 -36q32 -20 32 -60q0 -39 -40 -64.5t-105 -25.5q-51 0 -91.5 16t-65 38.5t-45.5 45t-46 38.5t-54 16q-50 0 -75.5 -30t-25.5 -75q0 -92 122 -157.5t291 -65.5 q73 0 140 18.5t122.5 53.5t88.5 93.5t33 131.5zM1536 256q0 -159 -112.5 -271.5t-271.5 -112.5q-130 0 -234 80q-77 -16 -150 -16q-143 0 -273.5 55.5t-225 150t-150 225t-55.5 273.5q0 73 16 150q-80 104 -80 234q0 159 112.5 271.5t271.5 112.5q130 0 234 -80 q77 16 150 16q143 0 273.5 -55.5t225 -150t150 -225t55.5 -273.5q0 -73 -16 -150q80 -104 80 -234z" /> -<glyph unicode="" horiz-adv-x="1280" d="M1000 1102l37 194q5 23 -9 40t-35 17h-712q-23 0 -38.5 -17t-15.5 -37v-1101q0 -7 6 -1l291 352q23 26 38 33.5t48 7.5h239q22 0 37 14.5t18 29.5q24 130 37 191q4 21 -11.5 40t-36.5 19h-294q-29 0 -48 19t-19 48v42q0 29 19 47.5t48 18.5h346q18 0 35 13.5t20 29.5z M1227 1324q-15 -73 -53.5 -266.5t-69.5 -350t-35 -173.5q-6 -22 -9 -32.5t-14 -32.5t-24.5 -33t-38.5 -21t-58 -10h-271q-13 0 -22 -10q-8 -9 -426 -494q-22 -25 -58.5 -28.5t-48.5 5.5q-55 22 -55 98v1410q0 55 38 102.5t120 47.5h888q95 0 127 -53t10 -159zM1227 1324 l-158 -790q4 17 35 173.5t69.5 350t53.5 266.5z" /> -<glyph unicode="" d="M704 192v1024q0 14 -9 23t-23 9h-480q-14 0 -23 -9t-9 -23v-1024q0 -14 9 -23t23 -9h480q14 0 23 9t9 23zM1376 576v640q0 14 -9 23t-23 9h-480q-14 0 -23 -9t-9 -23v-640q0 -14 9 -23t23 -9h480q14 0 23 9t9 23zM1536 1344v-1408q0 -26 -19 -45t-45 -19h-1408 q-26 0 -45 19t-19 45v1408q0 26 19 45t45 19h1408q26 0 45 -19t19 -45z" /> -<glyph unicode="" horiz-adv-x="1280" d="M1280 480q0 -40 -28 -68t-68 -28q-51 0 -80 43l-227 341h-45v-132l247 -411q9 -15 9 -33q0 -26 -19 -45t-45 -19h-192v-272q0 -46 -33 -79t-79 -33h-160q-46 0 -79 33t-33 79v272h-192q-26 0 -45 19t-19 45q0 18 9 33l247 411v132h-45l-227 -341q-29 -43 -80 -43 q-40 0 -68 28t-28 68q0 29 16 53l256 384q73 107 176 107h384q103 0 176 -107l256 -384q16 -24 16 -53zM864 1280q0 -93 -65.5 -158.5t-158.5 -65.5t-158.5 65.5t-65.5 158.5t65.5 158.5t158.5 65.5t158.5 -65.5t65.5 -158.5z" /> -<glyph unicode="" horiz-adv-x="1024" d="M1024 832v-416q0 -40 -28 -68t-68 -28t-68 28t-28 68v352h-64v-912q0 -46 -33 -79t-79 -33t-79 33t-33 79v464h-64v-464q0 -46 -33 -79t-79 -33t-79 33t-33 79v912h-64v-352q0 -40 -28 -68t-68 -28t-68 28t-28 68v416q0 80 56 136t136 56h640q80 0 136 -56t56 -136z M736 1280q0 -93 -65.5 -158.5t-158.5 -65.5t-158.5 65.5t-65.5 158.5t65.5 158.5t158.5 65.5t158.5 -65.5t65.5 -158.5z" /> -<glyph unicode="" d="M773 234l350 473q16 22 24.5 59t-6 85t-61.5 79q-40 26 -83 25.5t-73.5 -17.5t-54.5 -45q-36 -40 -96 -40q-59 0 -95 40q-24 28 -54.5 45t-73.5 17.5t-84 -25.5q-46 -31 -60.5 -79t-6 -85t24.5 -59zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103 t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1472 640q0 117 -45.5 223.5t-123 184t-184 123t-223.5 45.5t-223.5 -45.5t-184 -123t-123 -184t-45.5 -223.5t45.5 -223.5t123 -184t184 -123t223.5 -45.5t223.5 45.5t184 123t123 184t45.5 223.5zM1748 363q-4 -15 -20 -20l-292 -96v-306q0 -16 -13 -26q-15 -10 -29 -4 l-292 94l-180 -248q-10 -13 -26 -13t-26 13l-180 248l-292 -94q-14 -6 -29 4q-13 10 -13 26v306l-292 96q-16 5 -20 20q-5 17 4 29l180 248l-180 248q-9 13 -4 29q4 15 20 20l292 96v306q0 16 13 26q15 10 29 4l292 -94l180 248q9 12 26 12t26 -12l180 -248l292 94 q14 6 29 -4q13 -10 13 -26v-306l292 -96q16 -5 20 -20q5 -16 -4 -29l-180 -248l180 -248q9 -12 4 -29z" /> -<glyph unicode="" d="M1262 233q-54 -9 -110 -9q-182 0 -337 90t-245 245t-90 337q0 192 104 357q-201 -60 -328.5 -229t-127.5 -384q0 -130 51 -248.5t136.5 -204t204 -136.5t248.5 -51q144 0 273.5 61.5t220.5 171.5zM1465 318q-94 -203 -283.5 -324.5t-413.5 -121.5q-156 0 -298 61 t-245 164t-164 245t-61 298q0 153 57.5 292.5t156 241.5t235.5 164.5t290 68.5q44 2 61 -39q18 -41 -15 -72q-86 -78 -131.5 -181.5t-45.5 -218.5q0 -148 73 -273t198 -198t273 -73q118 0 228 51q41 18 72 -13q14 -14 17.5 -34t-4.5 -38z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1088 704q0 26 -19 45t-45 19h-256q-26 0 -45 -19t-19 -45t19 -45t45 -19h256q26 0 45 19t19 45zM1664 896v-960q0 -26 -19 -45t-45 -19h-1408q-26 0 -45 19t-19 45v960q0 26 19 45t45 19h1408q26 0 45 -19t19 -45zM1728 1344v-256q0 -26 -19 -45t-45 -19h-1536 q-26 0 -45 19t-19 45v256q0 26 19 45t45 19h1536q26 0 45 -19t19 -45z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1632 576q0 -26 -19 -45t-45 -19h-224q0 -171 -67 -290l208 -209q19 -19 19 -45t-19 -45q-18 -19 -45 -19t-45 19l-198 197q-5 -5 -15 -13t-42 -28.5t-65 -36.5t-82 -29t-97 -13v896h-128v-896q-51 0 -101.5 13.5t-87 33t-66 39t-43.5 32.5l-15 14l-183 -207 q-20 -21 -48 -21q-24 0 -43 16q-19 18 -20.5 44.5t15.5 46.5l202 227q-58 114 -58 274h-224q-26 0 -45 19t-19 45t19 45t45 19h224v294l-173 173q-19 19 -19 45t19 45t45 19t45 -19l173 -173h844l173 173q19 19 45 19t45 -19t19 -45t-19 -45l-173 -173v-294h224q26 0 45 -19 t19 -45zM1152 1152h-640q0 133 93.5 226.5t226.5 93.5t226.5 -93.5t93.5 -226.5z" /> -<glyph unicode="" horiz-adv-x="1920" d="M1917 1016q23 -64 -150 -294q-24 -32 -65 -85q-78 -100 -90 -131q-17 -41 14 -81q17 -21 81 -82h1l1 -1l1 -1l2 -2q141 -131 191 -221q3 -5 6.5 -12.5t7 -26.5t-0.5 -34t-25 -27.5t-59 -12.5l-256 -4q-24 -5 -56 5t-52 22l-20 12q-30 21 -70 64t-68.5 77.5t-61 58 t-56.5 15.5q-3 -1 -8 -3.5t-17 -14.5t-21.5 -29.5t-17 -52t-6.5 -77.5q0 -15 -3.5 -27.5t-7.5 -18.5l-4 -5q-18 -19 -53 -22h-115q-71 -4 -146 16.5t-131.5 53t-103 66t-70.5 57.5l-25 24q-10 10 -27.5 30t-71.5 91t-106 151t-122.5 211t-130.5 272q-6 16 -6 27t3 16l4 6 q15 19 57 19l274 2q12 -2 23 -6.5t16 -8.5l5 -3q16 -11 24 -32q20 -50 46 -103.5t41 -81.5l16 -29q29 -60 56 -104t48.5 -68.5t41.5 -38.5t34 -14t27 5q2 1 5 5t12 22t13.5 47t9.5 81t0 125q-2 40 -9 73t-14 46l-6 12q-25 34 -85 43q-13 2 5 24q17 19 38 30q53 26 239 24 q82 -1 135 -13q20 -5 33.5 -13.5t20.5 -24t10.5 -32t3.5 -45.5t-1 -55t-2.5 -70.5t-1.5 -82.5q0 -11 -1 -42t-0.5 -48t3.5 -40.5t11.5 -39t22.5 -24.5q8 -2 17 -4t26 11t38 34.5t52 67t68 107.5q60 104 107 225q4 10 10 17.5t11 10.5l4 3l5 2.5t13 3t20 0.5l288 2 q39 5 64 -2.5t31 -16.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M675 252q21 34 11 69t-45 50q-34 14 -73 1t-60 -46q-22 -34 -13 -68.5t43 -50.5t74.5 -2.5t62.5 47.5zM769 373q8 13 3.5 26.5t-17.5 18.5q-14 5 -28.5 -0.5t-21.5 -18.5q-17 -31 13 -45q14 -5 29 0.5t22 18.5zM943 266q-45 -102 -158 -150t-224 -12 q-107 34 -147.5 126.5t6.5 187.5q47 93 151.5 139t210.5 19q111 -29 158.5 -119.5t2.5 -190.5zM1255 426q-9 96 -89 170t-208.5 109t-274.5 21q-223 -23 -369.5 -141.5t-132.5 -264.5q9 -96 89 -170t208.5 -109t274.5 -21q223 23 369.5 141.5t132.5 264.5zM1563 422 q0 -68 -37 -139.5t-109 -137t-168.5 -117.5t-226 -83t-270.5 -31t-275 33.5t-240.5 93t-171.5 151t-65 199.5q0 115 69.5 245t197.5 258q169 169 341.5 236t246.5 -7q65 -64 20 -209q-4 -14 -1 -20t10 -7t14.5 0.5t13.5 3.5l6 2q139 59 246 59t153 -61q45 -63 0 -178 q-2 -13 -4.5 -20t4.5 -12.5t12 -7.5t17 -6q57 -18 103 -47t80 -81.5t34 -116.5zM1489 1046q42 -47 54.5 -108.5t-6.5 -117.5q-8 -23 -29.5 -34t-44.5 -4q-23 8 -34 29.5t-4 44.5q20 63 -24 111t-107 35q-24 -5 -45 8t-25 37q-5 24 8 44.5t37 25.5q60 13 119 -5.5t101 -65.5z M1670 1209q87 -96 112.5 -222.5t-13.5 -241.5q-9 -27 -34 -40t-52 -4t-40 34t-5 52q28 82 10 172t-80 158q-62 69 -148 95.5t-173 8.5q-28 -6 -52 9.5t-30 43.5t9.5 51.5t43.5 29.5q123 26 244 -11.5t208 -134.5z" /> -<glyph unicode="" d="M1133 -34q-171 -94 -368 -94q-196 0 -367 94q138 87 235.5 211t131.5 268q35 -144 132.5 -268t235.5 -211zM638 1394v-485q0 -252 -126.5 -459.5t-330.5 -306.5q-181 215 -181 495q0 187 83.5 349.5t229.5 269.5t325 137zM1536 638q0 -280 -181 -495 q-204 99 -330.5 306.5t-126.5 459.5v485q179 -30 325 -137t229.5 -269.5t83.5 -349.5z" /> -<glyph unicode="" horiz-adv-x="1408" d="M1402 433q-32 -80 -76 -138t-91 -88.5t-99 -46.5t-101.5 -14.5t-96.5 8.5t-86.5 22t-69.5 27.5t-46 22.5l-17 10q-113 -228 -289.5 -359.5t-384.5 -132.5q-19 0 -32 13t-13 32t13 31.5t32 12.5q173 1 322.5 107.5t251.5 294.5q-36 -14 -72 -23t-83 -13t-91 2.5t-93 28.5 t-92 59t-84.5 100t-74.5 146q114 47 214 57t167.5 -7.5t124.5 -56.5t88.5 -77t56.5 -82q53 131 79 291q-7 -1 -18 -2.5t-46.5 -2.5t-69.5 0.5t-81.5 10t-88.5 23t-84 42.5t-75 65t-54.5 94.5t-28.5 127.5q70 28 133.5 36.5t112.5 -1t92 -30t73.5 -50t56 -61t42 -63t27.5 -56 t16 -39.5l4 -16q12 122 12 195q-8 6 -21.5 16t-49 44.5t-63.5 71.5t-54 93t-33 112.5t12 127t70 138.5q73 -25 127.5 -61.5t84.5 -76.5t48 -85t20.5 -89t-0.5 -85.5t-13 -76.5t-19 -62t-17 -42l-7 -15q1 -5 1 -50.5t-1 -71.5q3 7 10 18.5t30.5 43t50.5 58t71 55.5t91.5 44.5 t112 14.5t132.5 -24q-2 -78 -21.5 -141.5t-50 -104.5t-69.5 -71.5t-81.5 -45.5t-84.5 -24t-80 -9.5t-67.5 1t-46.5 4.5l-17 3q-23 -147 -73 -283q6 7 18 18.5t49.5 41t77.5 52.5t99.5 42t117.5 20t129 -23.5t137 -77.5z" /> -<glyph unicode="" horiz-adv-x="1280" d="M1259 283v-66q0 -85 -57.5 -144.5t-138.5 -59.5h-57l-260 -269v269h-529q-81 0 -138.5 59.5t-57.5 144.5v66h1238zM1259 609v-255h-1238v255h1238zM1259 937v-255h-1238v255h1238zM1259 1077v-67h-1238v67q0 84 57.5 143.5t138.5 59.5h846q81 0 138.5 -59.5t57.5 -143.5z " /> -<glyph unicode="" d="M1152 640q0 -14 -9 -23l-320 -320q-9 -9 -23 -9q-13 0 -22.5 9.5t-9.5 22.5v192h-352q-13 0 -22.5 9.5t-9.5 22.5v192q0 13 9.5 22.5t22.5 9.5h352v192q0 14 9 23t23 9q12 0 24 -10l319 -319q9 -9 9 -23zM1312 640q0 148 -73 273t-198 198t-273 73t-273 -73t-198 -198 t-73 -273t73 -273t198 -198t273 -73t273 73t198 198t73 273zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" d="M1152 736v-192q0 -13 -9.5 -22.5t-22.5 -9.5h-352v-192q0 -14 -9 -23t-23 -9q-12 0 -24 10l-319 319q-9 9 -9 23t9 23l320 320q9 9 23 9q13 0 22.5 -9.5t9.5 -22.5v-192h352q13 0 22.5 -9.5t9.5 -22.5zM1312 640q0 148 -73 273t-198 198t-273 73t-273 -73t-198 -198 t-73 -273t73 -273t198 -198t273 -73t273 73t198 198t73 273zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" d="M1024 960v-640q0 -26 -19 -45t-45 -19q-20 0 -37 12l-448 320q-27 19 -27 52t27 52l448 320q17 12 37 12q26 0 45 -19t19 -45zM1280 160v960q0 13 -9.5 22.5t-22.5 9.5h-960q-13 0 -22.5 -9.5t-9.5 -22.5v-960q0 -13 9.5 -22.5t22.5 -9.5h960q13 0 22.5 9.5t9.5 22.5z M1536 1120v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" d="M1024 640q0 -106 -75 -181t-181 -75t-181 75t-75 181t75 181t181 75t181 -75t75 -181zM768 1184q-148 0 -273 -73t-198 -198t-73 -273t73 -273t198 -198t273 -73t273 73t198 198t73 273t-73 273t-198 198t-273 73zM1536 640q0 -209 -103 -385.5t-279.5 -279.5 t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1023 349l102 -204q-58 -179 -210 -290t-339 -111q-156 0 -288.5 77.5t-210 210t-77.5 288.5q0 181 104.5 330t274.5 211l17 -131q-122 -54 -195 -165.5t-73 -244.5q0 -185 131.5 -316.5t316.5 -131.5q126 0 232.5 65t165 175.5t49.5 236.5zM1571 249l58 -114l-256 -128 q-13 -7 -29 -7q-40 0 -57 35l-239 477h-472q-24 0 -42.5 16.5t-21.5 40.5l-96 779q-2 16 6 42q14 51 57 82.5t97 31.5q66 0 113 -47t47 -113q0 -69 -52 -117.5t-120 -41.5l37 -289h423v-128h-407l16 -128h455q40 0 57 -35l228 -455z" /> -<glyph unicode="" d="M1254 899q16 85 -21 132q-52 65 -187 45q-17 -3 -41 -12.5t-57.5 -30.5t-64.5 -48.5t-59.5 -70t-44.5 -91.5q80 7 113.5 -16t26.5 -99q-5 -52 -52 -143q-43 -78 -71 -99q-44 -32 -87 14q-23 24 -37.5 64.5t-19 73t-10 84t-8.5 71.5q-23 129 -34 164q-12 37 -35.5 69 t-50.5 40q-57 16 -127 -25q-54 -32 -136.5 -106t-122.5 -102v-7q16 -8 25.5 -26t21.5 -20q21 -3 54.5 8.5t58 10.5t41.5 -30q11 -18 18.5 -38.5t15 -48t12.5 -40.5q17 -46 53 -187q36 -146 57 -197q42 -99 103 -125q43 -12 85 -1.5t76 31.5q131 77 250 237 q104 139 172.5 292.5t82.5 226.5zM1536 1120v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" horiz-adv-x="1152" d="M1152 704q0 -191 -94.5 -353t-256.5 -256.5t-353 -94.5h-160q-14 0 -23 9t-9 23v611l-215 -66q-3 -1 -9 -1q-10 0 -19 6q-13 10 -13 26v128q0 23 23 31l233 71v93l-215 -66q-3 -1 -9 -1q-10 0 -19 6q-13 10 -13 26v128q0 23 23 31l233 71v250q0 14 9 23t23 9h160 q14 0 23 -9t9 -23v-181l375 116q15 5 28 -5t13 -26v-128q0 -23 -23 -31l-393 -121v-93l375 116q15 5 28 -5t13 -26v-128q0 -23 -23 -31l-393 -121v-487q188 13 318 151t130 328q0 14 9 23t23 9h160q14 0 23 -9t9 -23z" /> -<glyph unicode="" horiz-adv-x="1408" d="M1152 736v-64q0 -14 -9 -23t-23 -9h-352v-352q0 -14 -9 -23t-23 -9h-64q-14 0 -23 9t-9 23v352h-352q-14 0 -23 9t-9 23v64q0 14 9 23t23 9h352v352q0 14 9 23t23 9h64q14 0 23 -9t9 -23v-352h352q14 0 23 -9t9 -23zM1280 288v832q0 66 -47 113t-113 47h-832 q-66 0 -113 -47t-47 -113v-832q0 -66 47 -113t113 -47h832q66 0 113 47t47 113zM1408 1120v-832q0 -119 -84.5 -203.5t-203.5 -84.5h-832q-119 0 -203.5 84.5t-84.5 203.5v832q0 119 84.5 203.5t203.5 84.5h832q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" horiz-adv-x="2176" d="M620 416q-110 -64 -268 -64h-128v64h-64q-13 0 -22.5 23.5t-9.5 56.5q0 24 7 49q-58 2 -96.5 10.5t-38.5 20.5t38.5 20.5t96.5 10.5q-7 25 -7 49q0 33 9.5 56.5t22.5 23.5h64v64h128q158 0 268 -64h1113q42 -7 106.5 -18t80.5 -14q89 -15 150 -40.5t83.5 -47.5t22.5 -40 t-22.5 -40t-83.5 -47.5t-150 -40.5q-16 -3 -80.5 -14t-106.5 -18h-1113zM1739 668q53 -36 53 -92t-53 -92l81 -30q68 48 68 122t-68 122zM625 400h1015q-217 -38 -456 -80q-57 0 -113 -24t-83 -48l-28 -24l-288 -288q-26 -26 -70.5 -45t-89.5 -19h-96l-93 464h29 q157 0 273 64zM352 816h-29l93 464h96q46 0 90 -19t70 -45l288 -288q4 -4 11 -10.5t30.5 -23t48.5 -29t61.5 -23t72.5 -10.5l456 -80h-1015q-116 64 -273 64z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1519 760q62 0 103.5 -40.5t41.5 -101.5q0 -97 -93 -130l-172 -59l56 -167q7 -21 7 -47q0 -59 -42 -102t-101 -43q-47 0 -85.5 27t-53.5 72l-55 165l-310 -106l55 -164q8 -24 8 -47q0 -59 -42 -102t-102 -43q-47 0 -85 27t-53 72l-55 163l-153 -53q-29 -9 -50 -9 q-61 0 -101.5 40t-40.5 101q0 47 27.5 85t71.5 53l156 53l-105 313l-156 -54q-26 -8 -48 -8q-60 0 -101 40.5t-41 100.5q0 47 27.5 85t71.5 53l157 53l-53 159q-8 24 -8 47q0 60 42 102.5t102 42.5q47 0 85 -27t53 -72l54 -160l310 105l-54 160q-8 24 -8 47q0 59 42.5 102 t101.5 43q47 0 85.5 -27.5t53.5 -71.5l53 -161l162 55q21 6 43 6q60 0 102.5 -39.5t42.5 -98.5q0 -45 -30 -81.5t-74 -51.5l-157 -54l105 -316l164 56q24 8 46 8zM725 498l310 105l-105 315l-310 -107z" /> -<glyph unicode="" d="M1248 1408q119 0 203.5 -84.5t84.5 -203.5v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960zM1280 352v436q-31 -35 -64 -55q-34 -22 -132.5 -85t-151.5 -99q-98 -69 -164 -69v0v0q-66 0 -164 69 q-46 32 -141.5 92.5t-142.5 92.5q-12 8 -33 27t-31 27v-436q0 -40 28 -68t68 -28h832q40 0 68 28t28 68zM1280 925q0 41 -27.5 70t-68.5 29h-832q-40 0 -68 -28t-28 -68q0 -37 30.5 -76.5t67.5 -64.5q47 -32 137.5 -89t129.5 -83q3 -2 17 -11.5t21 -14t21 -13t23.5 -13 t21.5 -9.5t22.5 -7.5t20.5 -2.5t20.5 2.5t22.5 7.5t21.5 9.5t23.5 13t21 13t21 14t17 11.5l267 174q35 23 66.5 62.5t31.5 73.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M127 640q0 163 67 313l367 -1005q-196 95 -315 281t-119 411zM1415 679q0 -19 -2.5 -38.5t-10 -49.5t-11.5 -44t-17.5 -59t-17.5 -58l-76 -256l-278 826q46 3 88 8q19 2 26 18.5t-2.5 31t-28.5 13.5l-205 -10q-75 1 -202 10q-12 1 -20.5 -5t-11.5 -15t-1.5 -18.5t9 -16.5 t19.5 -8l80 -8l120 -328l-168 -504l-280 832q46 3 88 8q19 2 26 18.5t-2.5 31t-28.5 13.5l-205 -10q-7 0 -23 0.5t-26 0.5q105 160 274.5 253.5t367.5 93.5q147 0 280.5 -53t238.5 -149h-10q-55 0 -92 -40.5t-37 -95.5q0 -12 2 -24t4 -21.5t8 -23t9 -21t12 -22.5t12.5 -21 t14.5 -24t14 -23q63 -107 63 -212zM909 573l237 -647q1 -6 5 -11q-126 -44 -255 -44q-112 0 -217 32zM1570 1009q95 -174 95 -369q0 -209 -104 -385.5t-279 -278.5l235 678q59 169 59 276q0 42 -6 79zM896 1536q182 0 348 -71t286 -191t191 -286t71 -348t-71 -348t-191 -286 t-286 -191t-348 -71t-348 71t-286 191t-191 286t-71 348t71 348t191 286t286 191t348 71zM896 -215q173 0 331.5 68t273 182.5t182.5 273t68 331.5t-68 331.5t-182.5 273t-273 182.5t-331.5 68t-331.5 -68t-273 -182.5t-182.5 -273t-68 -331.5t68 -331.5t182.5 -273 t273 -182.5t331.5 -68z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1086 1536v-1536l-272 -128q-228 20 -414 102t-293 208.5t-107 272.5q0 140 100.5 263.5t275 205.5t391.5 108v-172q-217 -38 -356.5 -150t-139.5 -255q0 -152 154.5 -267t388.5 -145v1360zM1755 954l37 -390l-525 114l147 83q-119 70 -280 99v172q277 -33 481 -157z" /> -<glyph unicode="" horiz-adv-x="2048" d="M960 1536l960 -384v-128h-128q0 -26 -20.5 -45t-48.5 -19h-1526q-28 0 -48.5 19t-20.5 45h-128v128zM256 896h256v-768h128v768h256v-768h128v768h256v-768h128v768h256v-768h59q28 0 48.5 -19t20.5 -45v-64h-1664v64q0 26 20.5 45t48.5 19h59v768zM1851 -64 q28 0 48.5 -19t20.5 -45v-128h-1920v128q0 26 20.5 45t48.5 19h1782z" /> -<glyph unicode="" horiz-adv-x="2304" d="M1774 700l18 -316q4 -69 -82 -128t-235 -93.5t-323 -34.5t-323 34.5t-235 93.5t-82 128l18 316l574 -181q22 -7 48 -7t48 7zM2304 1024q0 -23 -22 -31l-1120 -352q-4 -1 -10 -1t-10 1l-652 206q-43 -34 -71 -111.5t-34 -178.5q63 -36 63 -109q0 -69 -58 -107l58 -433 q2 -14 -8 -25q-9 -11 -24 -11h-192q-15 0 -24 11q-10 11 -8 25l58 433q-58 38 -58 107q0 73 65 111q11 207 98 330l-333 104q-22 8 -22 31t22 31l1120 352q4 1 10 1t10 -1l1120 -352q22 -8 22 -31z" /> -<glyph unicode="" d="M859 579l13 -707q-62 11 -105 11q-41 0 -105 -11l13 707q-40 69 -168.5 295.5t-216.5 374.5t-181 287q58 -15 108 -15q43 0 111 15q63 -111 133.5 -229.5t167 -276.5t138.5 -227q37 61 109.5 177.5t117.5 190t105 176t107 189.5q54 -14 107 -14q56 0 114 14v0 q-28 -39 -60 -88.5t-49.5 -78.5t-56.5 -96t-49 -84q-146 -248 -353 -610z" /> -<glyph unicode="" horiz-adv-x="1280" d="M981 197q0 25 -7 49t-14.5 42t-27 41.5t-29.5 35t-38.5 34.5t-36.5 29t-41.5 30t-36.5 26q-16 2 -49 2q-53 0 -104.5 -7t-107 -25t-97 -46t-68.5 -74.5t-27 -105.5q0 -56 23.5 -102t61 -75.5t87 -50t100 -29t101.5 -8.5q58 0 111.5 13t99 39t73 73t27.5 109zM864 1055 q0 59 -17 125.5t-48 129t-84 103.5t-117 41q-42 0 -82.5 -19.5t-66.5 -52.5q-46 -59 -46 -160q0 -46 10 -97.5t31.5 -103t52 -92.5t75 -67t96.5 -26q37 0 77.5 16.5t65.5 43.5q53 56 53 159zM752 1536h417l-137 -88h-132q75 -63 113 -133t38 -160q0 -72 -24.5 -129.5 t-59.5 -93t-69.5 -65t-59 -61.5t-24.5 -66q0 -36 32 -70.5t77 -68t90.5 -73.5t77.5 -104t32 -142q0 -91 -49 -173q-71 -122 -209.5 -179.5t-298.5 -57.5q-132 0 -246.5 41.5t-172.5 137.5q-36 59 -36 131q0 81 44.5 150t118.5 115q131 82 404 100q-32 41 -47.5 73.5 t-15.5 73.5q0 40 21 85q-46 -4 -68 -4q-148 0 -249.5 96.5t-101.5 244.5q0 82 36 159t99 131q76 66 182 98t218 32z" /> -<glyph unicode="" horiz-adv-x="2304" d="M1509 107q0 -14 -12 -29q-52 -59 -147.5 -83t-196.5 -24q-252 0 -346 107q-12 15 -12 29q0 17 12 29.5t29 12.5q15 0 30 -12q58 -49 125.5 -66t159.5 -17t160 17t127 66q15 12 30 12q17 0 29 -12.5t12 -29.5zM978 498q0 -61 -43 -104t-104 -43q-60 0 -104.5 43.5 t-44.5 103.5q0 61 44 105t105 44t104 -44t43 -105zM1622 498q0 -61 -43 -104t-104 -43q-60 0 -104.5 43.5t-44.5 103.5q0 61 44 105t105 44t104 -44t43 -105zM415 793q-39 27 -88 27q-66 0 -113 -47t-47 -113q0 -72 54 -121q53 141 194 254zM2020 382q0 222 -249 387 q-128 85 -291.5 126.5t-331.5 41.5t-331.5 -41.5t-292.5 -126.5q-249 -165 -249 -387t249 -387q129 -85 292.5 -126.5t331.5 -41.5t331.5 41.5t291.5 126.5q249 165 249 387zM2137 660q0 66 -47 113t-113 47q-50 0 -93 -30q140 -114 192 -256q61 48 61 126zM1993 1335 q0 49 -34.5 83.5t-82.5 34.5q-49 0 -83.5 -34.5t-34.5 -83.5q0 -48 34.5 -82.5t83.5 -34.5q48 0 82.5 34.5t34.5 82.5zM2220 660q0 -65 -33 -122t-89 -90q5 -35 5 -66q0 -139 -79 -255.5t-208 -201.5q-140 -92 -313.5 -136.5t-354.5 -44.5t-355 44.5t-314 136.5 q-129 85 -208 201.5t-79 255.5q0 36 6 71q-53 33 -83.5 88.5t-30.5 118.5q0 100 71 171.5t172 71.5q91 0 159 -60q265 170 638 177l144 456q10 29 40 29q24 0 384 -90q24 55 74 88t110 33q82 0 141 -59t59 -142t-59 -141.5t-141 -58.5q-83 0 -141.5 58.5t-59.5 140.5 l-339 80l-125 -395q349 -15 603 -179q71 63 163 63q101 0 172 -71.5t71 -171.5z" /> -<glyph unicode="" d="M950 393q7 7 17.5 7t17.5 -7t7 -18t-7 -18q-65 -64 -208 -64h-1h-1q-143 0 -207 64q-8 7 -8 18t8 18q7 7 17.5 7t17.5 -7q49 -51 172 -51h1h1q122 0 173 51zM671 613q0 -37 -26 -64t-63 -27t-63 27t-26 64t26 63t63 26t63 -26t26 -63zM1214 1049q-29 0 -50 21t-21 50 q0 30 21 51t50 21q30 0 51 -21t21 -51q0 -29 -21 -50t-51 -21zM1216 1408q132 0 226 -94t94 -227v-894q0 -133 -94 -227t-226 -94h-896q-132 0 -226 94t-94 227v894q0 133 94 227t226 94h896zM1321 596q35 14 57 45.5t22 70.5q0 51 -36 87.5t-87 36.5q-60 0 -98 -48 q-151 107 -375 115l83 265l206 -49q1 -50 36.5 -85t84.5 -35q50 0 86 35.5t36 85.5t-36 86t-86 36q-36 0 -66 -20.5t-45 -53.5l-227 54q-9 2 -17.5 -2.5t-11.5 -14.5l-95 -302q-224 -4 -381 -113q-36 43 -93 43q-51 0 -87 -36.5t-36 -87.5q0 -37 19.5 -67.5t52.5 -45.5 q-7 -25 -7 -54q0 -98 74 -181.5t201.5 -132t278.5 -48.5q150 0 277.5 48.5t201.5 132t74 181.5q0 27 -6 54zM971 702q37 0 63 -26t26 -63t-26 -64t-63 -27t-63 27t-26 64t26 63t63 26z" /> -<glyph unicode="" d="M866 697l90 27v62q0 79 -58 135t-138 56t-138 -55.5t-58 -134.5v-283q0 -20 -14 -33.5t-33 -13.5t-32.5 13.5t-13.5 33.5v120h-151v-122q0 -82 57.5 -139t139.5 -57q81 0 138.5 56.5t57.5 136.5v280q0 19 13.5 33t33.5 14q19 0 32.5 -14t13.5 -33v-54zM1199 502v122h-150 v-126q0 -20 -13.5 -33.5t-33.5 -13.5q-19 0 -32.5 14t-13.5 33v123l-90 -26l-60 28v-123q0 -80 58 -137t139 -57t138.5 57t57.5 139zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103 t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" horiz-adv-x="1920" d="M1062 824v118q0 42 -30 72t-72 30t-72 -30t-30 -72v-612q0 -175 -126 -299t-303 -124q-178 0 -303.5 125.5t-125.5 303.5v266h328v-262q0 -43 30 -72.5t72 -29.5t72 29.5t30 72.5v620q0 171 126.5 292t301.5 121q176 0 302 -122t126 -294v-136l-195 -58zM1592 602h328 v-266q0 -178 -125.5 -303.5t-303.5 -125.5q-177 0 -303 124.5t-126 300.5v268l131 -61l195 58v-270q0 -42 30 -71.5t72 -29.5t72 29.5t30 71.5v275z" /> -<glyph unicode="" d="M1472 160v480h-704v704h-480q-93 0 -158.5 -65.5t-65.5 -158.5v-480h704v-704h480q93 0 158.5 65.5t65.5 158.5zM1536 1120v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5 t84.5 -203.5z" /> -<glyph unicode="" horiz-adv-x="2048" d="M328 1254h204v-983h-532v697h328v286zM328 435v369h-123v-369h123zM614 968v-697h205v697h-205zM614 1254v-204h205v204h-205zM901 968h533v-942h-533v163h328v82h-328v697zM1229 435v369h-123v-369h123zM1516 968h532v-942h-532v163h327v82h-327v697zM1843 435v369h-123 v-369h123z" /> -<glyph unicode="" d="M1046 516q0 -64 -38 -109t-91 -45q-43 0 -70 15v277q28 17 70 17q53 0 91 -45.5t38 -109.5zM703 944q0 -64 -38 -109.5t-91 -45.5q-43 0 -70 15v277q28 17 70 17q53 0 91 -45t38 -109zM1265 513q0 134 -88 229t-213 95q-20 0 -39 -3q-23 -78 -78 -136q-87 -95 -211 -101 v-636l211 41v206q51 -19 117 -19q125 0 213 95t88 229zM922 940q0 134 -88.5 229t-213.5 95q-74 0 -141 -36h-186v-840l211 41v206q55 -19 116 -19q125 0 213.5 95t88.5 229zM1536 1120v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960 q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" horiz-adv-x="2038" d="M1222 607q75 3 143.5 -20.5t118 -58.5t101 -94.5t84 -108t75.5 -120.5q33 -56 78.5 -109t75.5 -80.5t99 -88.5q-48 -30 -108.5 -57.5t-138.5 -59t-114 -47.5q-44 37 -74 115t-43.5 164.5t-33 180.5t-42.5 168.5t-72.5 123t-122.5 48.5l-10 -2l-6 -4q4 -5 13 -14 q6 -5 28 -23.5t25.5 -22t19 -18t18 -20.5t11.5 -21t10.5 -27.5t4.5 -31t4 -40.5l1 -33q1 -26 -2.5 -57.5t-7.5 -52t-12.5 -58.5t-11.5 -53q-35 1 -101 -9.5t-98 -10.5q-39 0 -72 10q-2 16 -2 47q0 74 3 96q2 13 31.5 41.5t57 59t26.5 51.5q-24 2 -43 -24 q-36 -53 -111.5 -99.5t-136.5 -46.5q-25 0 -75.5 63t-106.5 139.5t-84 96.5q-6 4 -27 30q-482 -112 -513 -112q-16 0 -28 11t-12 27q0 15 8.5 26.5t22.5 14.5l486 106q-8 14 -8 25t5.5 17.5t16 11.5t20 7t23 4.5t18.5 4.5q4 1 15.5 7.5t17.5 6.5q15 0 28 -16t20 -33 q163 37 172 37q17 0 29.5 -11t12.5 -28q0 -15 -8.5 -26t-23.5 -14l-182 -40l-1 -16q-1 -26 81.5 -117.5t104.5 -91.5q47 0 119 80t72 129q0 36 -23.5 53t-51 18.5t-51 11.5t-23.5 34q0 16 10 34l-68 19q43 44 43 117q0 26 -5 58q82 16 144 16q44 0 71.5 -1.5t48.5 -8.5 t31 -13.5t20.5 -24.5t15.5 -33.5t17 -47.5t24 -60l50 25q-3 -40 -23 -60t-42.5 -21t-40 -6.5t-16.5 -20.5zM1282 842q-5 5 -13.5 15.5t-12 14.5t-10.5 11.5t-10 10.5l-8 8t-8.5 7.5t-8 5t-8.5 4.5q-7 3 -14.5 5t-20.5 2.5t-22 0.5h-32.5h-37.5q-126 0 -217 -43 q16 30 36 46.5t54 29.5t65.5 36t46 36.5t50 55t43.5 50.5q12 -9 28 -31.5t32 -36.5t38 -13l12 1v-76l22 -1q247 95 371 190q28 21 50 39t42.5 37.5t33 31t29.5 34t24 31t24.5 37t23 38t27 47.5t29.5 53l7 9q-2 -53 -43 -139q-79 -165 -205 -264t-306 -142q-14 -3 -42 -7.5 t-50 -9.5t-39 -14q3 -19 24.5 -46t21.5 -34q0 -11 -26 -30zM1061 -79q39 26 131.5 47.5t146.5 21.5q9 0 22.5 -15.5t28 -42.5t26 -50t24 -51t14.5 -33q-121 -45 -244 -45q-61 0 -125 11zM822 568l48 12l109 -177l-73 -48zM1323 51q3 -15 3 -16q0 -7 -17.5 -14.5t-46 -13 t-54 -9.5t-53.5 -7.5t-32 -4.5l-7 43q21 2 60.5 8.5t72 10t60.5 3.5h14zM866 679l-96 -20l-6 17q10 1 32.5 7t34.5 6q19 0 35 -10zM1061 45h31l10 -83l-41 -12v95zM1950 1535v1v-1zM1950 1535l-1 -5l-2 -2l1 3zM1950 1535l1 1z" /> -<glyph unicode="" d="M1167 -50q-5 19 -24 5q-30 -22 -87 -39t-131 -17q-129 0 -193 49q-5 4 -13 4q-11 0 -26 -12q-7 -6 -7.5 -16t7.5 -20q34 -32 87.5 -46t102.5 -12.5t99 4.5q41 4 84.5 20.5t65 30t28.5 20.5q12 12 7 29zM1128 65q-19 47 -39 61q-23 15 -76 15q-47 0 -71 -10 q-29 -12 -78 -56q-26 -24 -12 -44q9 -8 17.5 -4.5t31.5 23.5q3 2 10.5 8.5t10.5 8.5t10 7t11.5 7t12.5 5t15 4.5t16.5 2.5t20.5 1q27 0 44.5 -7.5t23 -14.5t13.5 -22q10 -17 12.5 -20t12.5 1q23 12 14 34zM1483 346q0 22 -5 44.5t-16.5 45t-34 36.5t-52.5 14 q-33 0 -97 -41.5t-129 -83.5t-101 -42q-27 -1 -63.5 19t-76 49t-83.5 58t-100 49t-111 19q-115 -1 -197 -78.5t-84 -178.5q-2 -112 74 -164q29 -20 62.5 -28.5t103.5 -8.5q57 0 132 32.5t134 71t120 70.5t93 31q26 -1 65 -31.5t71.5 -67t68 -67.5t55.5 -32q35 -3 58.5 14 t55.5 63q28 41 42.5 101t14.5 106zM1536 506q0 -164 -62 -304.5t-166 -236t-242.5 -149.5t-290.5 -54t-293 57.5t-247.5 157t-170.5 241.5t-64 302q0 89 19.5 172.5t49 145.5t70.5 118.5t78.5 94t78.5 69.5t64.5 46.5t42.5 24.5q14 8 51 26.5t54.5 28.5t48 30t60.5 44 q36 28 58 72.5t30 125.5q129 -155 186 -193q44 -29 130 -68t129 -66q21 -13 39 -25t60.5 -46.5t76 -70.5t75 -95t69 -122t47 -148.5t19.5 -177.5z" /> -<glyph unicode="" d="M1070 463l-160 -160l-151 -152l-30 -30q-65 -64 -151.5 -87t-171.5 -2q-16 -70 -72 -115t-129 -45q-85 0 -145 60.5t-60 145.5q0 72 44.5 128t113.5 72q-22 86 1 173t88 152l12 12l151 -152l-11 -11q-37 -37 -37 -89t37 -90q37 -37 89 -37t89 37l30 30l151 152l161 160z M729 1145l12 -12l-152 -152l-12 12q-37 37 -89 37t-89 -37t-37 -89.5t37 -89.5l29 -29l152 -152l160 -160l-151 -152l-161 160l-151 152l-30 30q-68 67 -90 159.5t5 179.5q-70 15 -115 71t-45 129q0 85 60 145.5t145 60.5q76 0 133.5 -49t69.5 -123q84 20 169.5 -3.5 t149.5 -87.5zM1536 78q0 -85 -60 -145.5t-145 -60.5q-74 0 -131 47t-71 118q-86 -28 -179.5 -6t-161.5 90l-11 12l151 152l12 -12q37 -37 89 -37t89 37t37 89t-37 89l-30 30l-152 152l-160 160l152 152l160 -160l152 -152l29 -30q64 -64 87.5 -150.5t2.5 -171.5 q76 -11 126.5 -68.5t50.5 -134.5zM1534 1202q0 -77 -51 -135t-127 -69q26 -85 3 -176.5t-90 -158.5l-12 -12l-151 152l12 12q37 37 37 89t-37 89t-89 37t-89 -37l-30 -30l-152 -152l-160 -160l-152 152l161 160l152 152l29 30q67 67 159 89.5t178 -3.5q11 75 68.5 126 t135.5 51q85 0 145 -60.5t60 -145.5z" /> -<glyph unicode="" d="M654 458q-1 -3 -12.5 0.5t-31.5 11.5l-20 9q-44 20 -87 49q-7 5 -41 31.5t-38 28.5q-67 -103 -134 -181q-81 -95 -105 -110q-4 -2 -19.5 -4t-18.5 0q6 4 82 92q21 24 85.5 115t78.5 118q17 30 51 98.5t36 77.5q-8 1 -110 -33q-8 -2 -27.5 -7.5t-34.5 -9.5t-17 -5 q-2 -2 -2 -10.5t-1 -9.5q-5 -10 -31 -15q-23 -7 -47 0q-18 4 -28 21q-4 6 -5 23q6 2 24.5 5t29.5 6q58 16 105 32q100 35 102 35q10 2 43 19.5t44 21.5q9 3 21.5 8t14.5 5.5t6 -0.5q2 -12 -1 -33q0 -2 -12.5 -27t-26.5 -53.5t-17 -33.5q-25 -50 -77 -131l64 -28 q12 -6 74.5 -32t67.5 -28q4 -1 10.5 -25.5t4.5 -30.5zM449 944q3 -15 -4 -28q-12 -23 -50 -38q-30 -12 -60 -12q-26 3 -49 26q-14 15 -18 41l1 3q3 -3 19.5 -5t26.5 0t58 16q36 12 55 14q17 0 21 -17zM1147 815l63 -227l-139 42zM39 15l694 232v1032l-694 -233v-1031z M1280 332l102 -31l-181 657l-100 31l-216 -536l102 -31l45 110l211 -65zM777 1294l573 -184v380zM1088 -29l158 -13l-54 -160l-40 66q-130 -83 -276 -108q-58 -12 -91 -12h-84q-79 0 -199.5 39t-183.5 85q-8 7 -8 16q0 8 5 13.5t13 5.5q4 0 18 -7.5t30.5 -16.5t20.5 -11 q73 -37 159.5 -61.5t157.5 -24.5q95 0 167 14.5t157 50.5q15 7 30.5 15.5t34 19t28.5 16.5zM1536 1050v-1079l-774 246q-14 -6 -375 -127.5t-368 -121.5q-13 0 -18 13q0 1 -1 3v1078q3 9 4 10q5 6 20 11q106 35 149 50v384l558 -198q2 0 160.5 55t316 108.5t161.5 53.5 q20 0 20 -21v-418z" /> -<glyph unicode="" horiz-adv-x="1792" d="M288 1152q66 0 113 -47t47 -113v-1088q0 -66 -47 -113t-113 -47h-128q-66 0 -113 47t-47 113v1088q0 66 47 113t113 47h128zM1664 989q58 -34 93 -93t35 -128v-768q0 -106 -75 -181t-181 -75h-864q-66 0 -113 47t-47 113v1536q0 40 28 68t68 28h672q40 0 88 -20t76 -48 l152 -152q28 -28 48 -76t20 -88v-163zM928 0v128q0 14 -9 23t-23 9h-128q-14 0 -23 -9t-9 -23v-128q0 -14 9 -23t23 -9h128q14 0 23 9t9 23zM928 256v128q0 14 -9 23t-23 9h-128q-14 0 -23 -9t-9 -23v-128q0 -14 9 -23t23 -9h128q14 0 23 9t9 23zM928 512v128q0 14 -9 23 t-23 9h-128q-14 0 -23 -9t-9 -23v-128q0 -14 9 -23t23 -9h128q14 0 23 9t9 23zM1184 0v128q0 14 -9 23t-23 9h-128q-14 0 -23 -9t-9 -23v-128q0 -14 9 -23t23 -9h128q14 0 23 9t9 23zM1184 256v128q0 14 -9 23t-23 9h-128q-14 0 -23 -9t-9 -23v-128q0 -14 9 -23t23 -9h128 q14 0 23 9t9 23zM1184 512v128q0 14 -9 23t-23 9h-128q-14 0 -23 -9t-9 -23v-128q0 -14 9 -23t23 -9h128q14 0 23 9t9 23zM1440 0v128q0 14 -9 23t-23 9h-128q-14 0 -23 -9t-9 -23v-128q0 -14 9 -23t23 -9h128q14 0 23 9t9 23zM1440 256v128q0 14 -9 23t-23 9h-128 q-14 0 -23 -9t-9 -23v-128q0 -14 9 -23t23 -9h128q14 0 23 9t9 23zM1440 512v128q0 14 -9 23t-23 9h-128q-14 0 -23 -9t-9 -23v-128q0 -14 9 -23t23 -9h128q14 0 23 9t9 23zM1536 896v256h-160q-40 0 -68 28t-28 68v160h-640v-512h896z" /> -<glyph unicode="" d="M1344 1536q26 0 45 -19t19 -45v-1664q0 -26 -19 -45t-45 -19h-1280q-26 0 -45 19t-19 45v1664q0 26 19 45t45 19h1280zM512 1248v-64q0 -14 9 -23t23 -9h64q14 0 23 9t9 23v64q0 14 -9 23t-23 9h-64q-14 0 -23 -9t-9 -23zM512 992v-64q0 -14 9 -23t23 -9h64q14 0 23 9 t9 23v64q0 14 -9 23t-23 9h-64q-14 0 -23 -9t-9 -23zM512 736v-64q0 -14 9 -23t23 -9h64q14 0 23 9t9 23v64q0 14 -9 23t-23 9h-64q-14 0 -23 -9t-9 -23zM512 480v-64q0 -14 9 -23t23 -9h64q14 0 23 9t9 23v64q0 14 -9 23t-23 9h-64q-14 0 -23 -9t-9 -23zM384 160v64 q0 14 -9 23t-23 9h-64q-14 0 -23 -9t-9 -23v-64q0 -14 9 -23t23 -9h64q14 0 23 9t9 23zM384 416v64q0 14 -9 23t-23 9h-64q-14 0 -23 -9t-9 -23v-64q0 -14 9 -23t23 -9h64q14 0 23 9t9 23zM384 672v64q0 14 -9 23t-23 9h-64q-14 0 -23 -9t-9 -23v-64q0 -14 9 -23t23 -9h64 q14 0 23 9t9 23zM384 928v64q0 14 -9 23t-23 9h-64q-14 0 -23 -9t-9 -23v-64q0 -14 9 -23t23 -9h64q14 0 23 9t9 23zM384 1184v64q0 14 -9 23t-23 9h-64q-14 0 -23 -9t-9 -23v-64q0 -14 9 -23t23 -9h64q14 0 23 9t9 23zM896 -96v192q0 14 -9 23t-23 9h-320q-14 0 -23 -9 t-9 -23v-192q0 -14 9 -23t23 -9h320q14 0 23 9t9 23zM896 416v64q0 14 -9 23t-23 9h-64q-14 0 -23 -9t-9 -23v-64q0 -14 9 -23t23 -9h64q14 0 23 9t9 23zM896 672v64q0 14 -9 23t-23 9h-64q-14 0 -23 -9t-9 -23v-64q0 -14 9 -23t23 -9h64q14 0 23 9t9 23zM896 928v64 q0 14 -9 23t-23 9h-64q-14 0 -23 -9t-9 -23v-64q0 -14 9 -23t23 -9h64q14 0 23 9t9 23zM896 1184v64q0 14 -9 23t-23 9h-64q-14 0 -23 -9t-9 -23v-64q0 -14 9 -23t23 -9h64q14 0 23 9t9 23zM1152 160v64q0 14 -9 23t-23 9h-64q-14 0 -23 -9t-9 -23v-64q0 -14 9 -23t23 -9h64 q14 0 23 9t9 23zM1152 416v64q0 14 -9 23t-23 9h-64q-14 0 -23 -9t-9 -23v-64q0 -14 9 -23t23 -9h64q14 0 23 9t9 23zM1152 672v64q0 14 -9 23t-23 9h-64q-14 0 -23 -9t-9 -23v-64q0 -14 9 -23t23 -9h64q14 0 23 9t9 23zM1152 928v64q0 14 -9 23t-23 9h-64q-14 0 -23 -9 t-9 -23v-64q0 -14 9 -23t23 -9h64q14 0 23 9t9 23zM1152 1184v64q0 14 -9 23t-23 9h-64q-14 0 -23 -9t-9 -23v-64q0 -14 9 -23t23 -9h64q14 0 23 9t9 23z" /> -<glyph unicode="" horiz-adv-x="1280" d="M1188 988l-292 -292v-824q0 -46 -33 -79t-79 -33t-79 33t-33 79v384h-64v-384q0 -46 -33 -79t-79 -33t-79 33t-33 79v824l-292 292q-28 28 -28 68t28 68t68 28t68 -28l228 -228h368l228 228q28 28 68 28t68 -28t28 -68t-28 -68zM864 1152q0 -93 -65.5 -158.5 t-158.5 -65.5t-158.5 65.5t-65.5 158.5t65.5 158.5t158.5 65.5t158.5 -65.5t65.5 -158.5z" /> -<glyph unicode="" horiz-adv-x="1664" d="M780 1064q0 -60 -19 -113.5t-63 -92.5t-105 -39q-76 0 -138 57.5t-92 135.5t-30 151q0 60 19 113.5t63 92.5t105 39q77 0 138.5 -57.5t91.5 -135t30 -151.5zM438 581q0 -80 -42 -139t-119 -59q-76 0 -141.5 55.5t-100.5 133.5t-35 152q0 80 42 139.5t119 59.5 q76 0 141.5 -55.5t100.5 -134t35 -152.5zM832 608q118 0 255 -97.5t229 -237t92 -254.5q0 -46 -17 -76.5t-48.5 -45t-64.5 -20t-76 -5.5q-68 0 -187.5 45t-182.5 45q-66 0 -192.5 -44.5t-200.5 -44.5q-183 0 -183 146q0 86 56 191.5t139.5 192.5t187.5 146t193 59zM1071 819 q-61 0 -105 39t-63 92.5t-19 113.5q0 74 30 151.5t91.5 135t138.5 57.5q61 0 105 -39t63 -92.5t19 -113.5q0 -73 -30 -151t-92 -135.5t-138 -57.5zM1503 923q77 0 119 -59.5t42 -139.5q0 -74 -35 -152t-100.5 -133.5t-141.5 -55.5q-77 0 -119 59t-42 139q0 74 35 152.5 t100.5 134t141.5 55.5z" /> -<glyph unicode="" horiz-adv-x="768" d="M704 1008q0 -145 -57 -243.5t-152 -135.5l45 -821q2 -26 -16 -45t-44 -19h-192q-26 0 -44 19t-16 45l45 821q-95 37 -152 135.5t-57 243.5q0 128 42.5 249.5t117.5 200t160 78.5t160 -78.5t117.5 -200t42.5 -249.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M896 -93l640 349v636l-640 -233v-752zM832 772l698 254l-698 254l-698 -254zM1664 1024v-768q0 -35 -18 -65t-49 -47l-704 -384q-28 -16 -61 -16t-61 16l-704 384q-31 17 -49 47t-18 65v768q0 40 23 73t61 47l704 256q22 8 44 8t44 -8l704 -256q38 -14 61 -47t23 -73z " /> -<glyph unicode="" horiz-adv-x="2304" d="M640 -96l384 192v314l-384 -164v-342zM576 358l404 173l-404 173l-404 -173zM1664 -96l384 192v314l-384 -164v-342zM1600 358l404 173l-404 173l-404 -173zM1152 651l384 165v266l-384 -164v-267zM1088 1030l441 189l-441 189l-441 -189zM2176 512v-416q0 -36 -19 -67 t-52 -47l-448 -224q-25 -14 -57 -14t-57 14l-448 224q-5 2 -7 4q-2 -2 -7 -4l-448 -224q-25 -14 -57 -14t-57 14l-448 224q-33 16 -52 47t-19 67v416q0 38 21.5 70t56.5 48l434 186v400q0 38 21.5 70t56.5 48l448 192q23 10 50 10t50 -10l448 -192q35 -16 56.5 -48t21.5 -70 v-400l434 -186q36 -16 57 -48t21 -70z" /> -<glyph unicode="" horiz-adv-x="2048" d="M1848 1197h-511v-124h511v124zM1596 771q-90 0 -146 -52.5t-62 -142.5h408q-18 195 -200 195zM1612 186q63 0 122 32t76 87h221q-100 -307 -427 -307q-214 0 -340.5 132t-126.5 347q0 208 130.5 345.5t336.5 137.5q138 0 240.5 -68t153 -179t50.5 -248q0 -17 -2 -47h-658 q0 -111 57.5 -171.5t166.5 -60.5zM277 236h296q205 0 205 167q0 180 -199 180h-302v-347zM277 773h281q78 0 123.5 36.5t45.5 113.5q0 144 -190 144h-260v-294zM0 1282h594q87 0 155 -14t126.5 -47.5t90 -96.5t31.5 -154q0 -181 -172 -263q114 -32 172 -115t58 -204 q0 -75 -24.5 -136.5t-66 -103.5t-98.5 -71t-121 -42t-134 -13h-611v1260z" /> -<glyph unicode="" d="M1248 1408q119 0 203.5 -84.5t84.5 -203.5v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960zM499 1041h-371v-787h382q117 0 197 57.5t80 170.5q0 158 -143 200q107 52 107 164q0 57 -19.5 96.5 t-56.5 60.5t-79 29.5t-97 8.5zM477 723h-176v184h163q119 0 119 -90q0 -94 -106 -94zM486 388h-185v217h189q124 0 124 -113q0 -104 -128 -104zM1136 356q-68 0 -104 38t-36 107h411q1 10 1 30q0 132 -74.5 220.5t-203.5 88.5q-128 0 -210 -86t-82 -216q0 -135 79 -217 t213 -82q205 0 267 191h-138q-11 -34 -47.5 -54t-75.5 -20zM1126 722q113 0 124 -122h-254q4 56 39 89t91 33zM964 988h319v-77h-319v77z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1582 954q0 -101 -71.5 -172.5t-172.5 -71.5t-172.5 71.5t-71.5 172.5t71.5 172.5t172.5 71.5t172.5 -71.5t71.5 -172.5zM812 212q0 104 -73 177t-177 73q-27 0 -54 -6l104 -42q77 -31 109.5 -106.5t1.5 -151.5q-31 -77 -107 -109t-152 -1q-21 8 -62 24.5t-61 24.5 q32 -60 91 -96.5t130 -36.5q104 0 177 73t73 177zM1642 953q0 126 -89.5 215.5t-215.5 89.5q-127 0 -216.5 -89.5t-89.5 -215.5q0 -127 89.5 -216t216.5 -89q126 0 215.5 89t89.5 216zM1792 953q0 -189 -133.5 -322t-321.5 -133l-437 -319q-12 -129 -109 -218t-229 -89 q-121 0 -214 76t-118 192l-230 92v429l389 -157q79 48 173 48q13 0 35 -2l284 407q2 187 135.5 319t320.5 132q188 0 321.5 -133.5t133.5 -321.5z" /> -<glyph unicode="" d="M1242 889q0 80 -57 136.5t-137 56.5t-136.5 -57t-56.5 -136q0 -80 56.5 -136.5t136.5 -56.5t137 56.5t57 136.5zM632 301q0 -83 -58 -140.5t-140 -57.5q-56 0 -103 29t-72 77q52 -20 98 -40q60 -24 120 1.5t85 86.5q24 60 -1.5 120t-86.5 84l-82 33q22 5 42 5 q82 0 140 -57.5t58 -140.5zM1536 1120v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v153l172 -69q20 -92 93.5 -152t168.5 -60q104 0 181 70t87 173l345 252q150 0 255.5 105.5t105.5 254.5q0 150 -105.5 255.5t-255.5 105.5 q-148 0 -253 -104.5t-107 -252.5l-225 -322q-9 1 -28 1q-75 0 -137 -37l-297 119v468q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5zM1289 887q0 -100 -71 -170.5t-171 -70.5t-170.5 70.5t-70.5 170.5t70.5 171t170.5 71q101 0 171.5 -70.5t70.5 -171.5z " /> -<glyph unicode="" horiz-adv-x="1792" d="M836 367l-15 -368l-2 -22l-420 29q-36 3 -67 31.5t-47 65.5q-11 27 -14.5 55t4 65t12 55t21.5 64t19 53q78 -12 509 -28zM449 953l180 -379l-147 92q-63 -72 -111.5 -144.5t-72.5 -125t-39.5 -94.5t-18.5 -63l-4 -21l-190 357q-17 26 -18 56t6 47l8 18q35 63 114 188 l-140 86zM1680 436l-188 -359q-12 -29 -36.5 -46.5t-43.5 -20.5l-18 -4q-71 -7 -219 -12l8 -164l-230 367l211 362l7 -173q170 -16 283 -5t170 33zM895 1360q-47 -63 -265 -435l-317 187l-19 12l225 356q20 31 60 45t80 10q24 -2 48.5 -12t42 -21t41.5 -33t36 -34.5 t36 -39.5t32 -35zM1550 1053l212 -363q18 -37 12.5 -76t-27.5 -74q-13 -20 -33 -37t-38 -28t-48.5 -22t-47 -16t-51.5 -14t-46 -12q-34 72 -265 436l313 195zM1407 1279l142 83l-220 -373l-419 20l151 86q-34 89 -75 166t-75.5 123.5t-64.5 80t-47 46.5l-17 13l405 -1 q31 3 58 -10.5t39 -28.5l11 -15q39 -61 112 -190z" /> -<glyph unicode="" horiz-adv-x="2048" d="M480 448q0 66 -47 113t-113 47t-113 -47t-47 -113t47 -113t113 -47t113 47t47 113zM516 768h1016l-89 357q-2 8 -14 17.5t-21 9.5h-768q-9 0 -21 -9.5t-14 -17.5zM1888 448q0 66 -47 113t-113 47t-113 -47t-47 -113t47 -113t113 -47t113 47t47 113zM2048 544v-384 q0 -14 -9 -23t-23 -9h-96v-128q0 -80 -56 -136t-136 -56t-136 56t-56 136v128h-1024v-128q0 -80 -56 -136t-136 -56t-136 56t-56 136v128h-96q-14 0 -23 9t-9 23v384q0 93 65.5 158.5t158.5 65.5h28l105 419q23 94 104 157.5t179 63.5h768q98 0 179 -63.5t104 -157.5 l105 -419h28q93 0 158.5 -65.5t65.5 -158.5z" /> -<glyph unicode="" horiz-adv-x="2048" d="M1824 640q93 0 158.5 -65.5t65.5 -158.5v-384q0 -14 -9 -23t-23 -9h-96v-64q0 -80 -56 -136t-136 -56t-136 56t-56 136v64h-1024v-64q0 -80 -56 -136t-136 -56t-136 56t-56 136v64h-96q-14 0 -23 9t-9 23v384q0 93 65.5 158.5t158.5 65.5h28l105 419q23 94 104 157.5 t179 63.5h128v224q0 14 9 23t23 9h448q14 0 23 -9t9 -23v-224h128q98 0 179 -63.5t104 -157.5l105 -419h28zM320 160q66 0 113 47t47 113t-47 113t-113 47t-113 -47t-47 -113t47 -113t113 -47zM516 640h1016l-89 357q-2 8 -14 17.5t-21 9.5h-768q-9 0 -21 -9.5t-14 -17.5z M1728 160q66 0 113 47t47 113t-47 113t-113 47t-113 -47t-47 -113t47 -113t113 -47z" /> -<glyph unicode="" d="M1504 64q0 -26 -19 -45t-45 -19h-462q1 -17 6 -87.5t5 -108.5q0 -25 -18 -42.5t-43 -17.5h-320q-25 0 -43 17.5t-18 42.5q0 38 5 108.5t6 87.5h-462q-26 0 -45 19t-19 45t19 45l402 403h-229q-26 0 -45 19t-19 45t19 45l402 403h-197q-26 0 -45 19t-19 45t19 45l384 384 q19 19 45 19t45 -19l384 -384q19 -19 19 -45t-19 -45t-45 -19h-197l402 -403q19 -19 19 -45t-19 -45t-45 -19h-229l402 -403q19 -19 19 -45z" /> -<glyph unicode="" d="M1127 326q0 32 -30 51q-193 115 -447 115q-133 0 -287 -34q-42 -9 -42 -52q0 -20 13.5 -34.5t35.5 -14.5q5 0 37 8q132 27 243 27q226 0 397 -103q19 -11 33 -11q19 0 33 13.5t14 34.5zM1223 541q0 40 -35 61q-237 141 -548 141q-153 0 -303 -42q-48 -13 -48 -64 q0 -25 17.5 -42.5t42.5 -17.5q7 0 37 8q122 33 251 33q279 0 488 -124q24 -13 38 -13q25 0 42.5 17.5t17.5 42.5zM1331 789q0 47 -40 70q-126 73 -293 110.5t-343 37.5q-204 0 -364 -47q-23 -7 -38.5 -25.5t-15.5 -48.5q0 -31 20.5 -52t51.5 -21q11 0 40 8q133 37 307 37 q159 0 309.5 -34t253.5 -95q21 -12 40 -12q29 0 50.5 20.5t21.5 51.5zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" horiz-adv-x="1024" d="M1024 1233l-303 -582l24 -31h279v-415h-507l-44 -30l-142 -273l-30 -30h-301v303l303 583l-24 30h-279v415h507l44 30l142 273l30 30h301v-303z" /> -<glyph unicode="" horiz-adv-x="2304" d="M784 164l16 241l-16 523q-1 10 -7.5 17t-16.5 7q-9 0 -16 -7t-7 -17l-14 -523l14 -241q1 -10 7.5 -16.5t15.5 -6.5q22 0 24 23zM1080 193l11 211l-12 586q0 16 -13 24q-8 5 -16 5t-16 -5q-13 -8 -13 -24l-1 -6l-10 -579q0 -1 11 -236v-1q0 -10 6 -17q9 -11 23 -11 q11 0 20 9q9 7 9 20zM35 533l20 -128l-20 -126q-2 -9 -9 -9t-9 9l-17 126l17 128q2 9 9 9t9 -9zM121 612l26 -207l-26 -203q-2 -9 -10 -9q-9 0 -9 10l-23 202l23 207q0 9 9 9q8 0 10 -9zM401 159zM213 650l25 -245l-25 -237q0 -11 -11 -11q-10 0 -12 11l-21 237l21 245 q2 12 12 12q11 0 11 -12zM307 657l23 -252l-23 -244q-2 -13 -14 -13q-13 0 -13 13l-21 244l21 252q0 13 13 13q12 0 14 -13zM401 639l21 -234l-21 -246q-2 -16 -16 -16q-6 0 -10.5 4.5t-4.5 11.5l-20 246l20 234q0 6 4.5 10.5t10.5 4.5q14 0 16 -15zM784 164zM495 785 l21 -380l-21 -246q0 -7 -5 -12.5t-12 -5.5q-16 0 -18 18l-18 246l18 380q2 18 18 18q7 0 12 -5.5t5 -12.5zM589 871l19 -468l-19 -244q0 -8 -5.5 -13.5t-13.5 -5.5q-18 0 -20 19l-16 244l16 468q2 19 20 19q8 0 13.5 -5.5t5.5 -13.5zM687 911l18 -506l-18 -242 q-2 -21 -22 -21q-19 0 -21 21l-16 242l16 506q0 9 6.5 15.5t14.5 6.5q9 0 15 -6.5t7 -15.5zM1079 169v0v0zM881 915l15 -510l-15 -239q0 -10 -7.5 -17.5t-17.5 -7.5t-17 7t-8 18l-14 239l14 510q0 11 7.5 18t17.5 7t17.5 -7t7.5 -18zM980 896l14 -492l-14 -236q0 -11 -8 -19 t-19 -8t-19 8t-9 19l-12 236l12 492q1 12 9 20t19 8t18.5 -8t8.5 -20zM1192 404l-14 -231v0q0 -13 -9 -22t-22 -9t-22 9t-10 22l-6 114l-6 117l12 636v3q2 15 12 24q9 7 20 7q8 0 15 -5q14 -8 16 -26zM2304 423q0 -117 -83 -199.5t-200 -82.5h-786q-13 2 -22 11t-9 22v899 q0 23 28 33q85 34 181 34q195 0 338 -131.5t160 -323.5q53 22 110 22q117 0 200 -83t83 -201z" /> -<glyph unicode="" d="M768 768q237 0 443 43t325 127v-170q0 -69 -103 -128t-280 -93.5t-385 -34.5t-385 34.5t-280 93.5t-103 128v170q119 -84 325 -127t443 -43zM768 0q237 0 443 43t325 127v-170q0 -69 -103 -128t-280 -93.5t-385 -34.5t-385 34.5t-280 93.5t-103 128v170q119 -84 325 -127 t443 -43zM768 384q237 0 443 43t325 127v-170q0 -69 -103 -128t-280 -93.5t-385 -34.5t-385 34.5t-280 93.5t-103 128v170q119 -84 325 -127t443 -43zM768 1536q208 0 385 -34.5t280 -93.5t103 -128v-128q0 -69 -103 -128t-280 -93.5t-385 -34.5t-385 34.5t-280 93.5 t-103 128v128q0 69 103 128t280 93.5t385 34.5z" /> -<glyph unicode="" d="M1468 1156q28 -28 48 -76t20 -88v-1152q0 -40 -28 -68t-68 -28h-1344q-40 0 -68 28t-28 68v1600q0 40 28 68t68 28h896q40 0 88 -20t76 -48zM1024 1400v-376h376q-10 29 -22 41l-313 313q-12 12 -41 22zM1408 -128v1024h-416q-40 0 -68 28t-28 68v416h-768v-1536h1280z M894 465q33 -26 84 -56q59 7 117 7q147 0 177 -49q16 -22 2 -52q0 -1 -1 -2l-2 -2v-1q-6 -38 -71 -38q-48 0 -115 20t-130 53q-221 -24 -392 -83q-153 -262 -242 -262q-15 0 -28 7l-24 12q-1 1 -6 5q-10 10 -6 36q9 40 56 91.5t132 96.5q14 9 23 -6q2 -2 2 -4q52 85 107 197 q68 136 104 262q-24 82 -30.5 159.5t6.5 127.5q11 40 42 40h21h1q23 0 35 -15q18 -21 9 -68q-2 -6 -4 -8q1 -3 1 -8v-30q-2 -123 -14 -192q55 -164 146 -238zM318 54q52 24 137 158q-51 -40 -87.5 -84t-49.5 -74zM716 974q-15 -42 -2 -132q1 7 7 44q0 3 7 43q1 4 4 8 q-1 1 -1 2t-0.5 1.5t-0.5 1.5q-1 22 -13 36q0 -1 -1 -2v-2zM592 313q135 54 284 81q-2 1 -13 9.5t-16 13.5q-76 67 -127 176q-27 -86 -83 -197q-30 -56 -45 -83zM1238 329q-24 24 -140 24q76 -28 124 -28q14 0 18 1q0 1 -2 3z" /> -<glyph unicode="" d="M1468 1156q28 -28 48 -76t20 -88v-1152q0 -40 -28 -68t-68 -28h-1344q-40 0 -68 28t-28 68v1600q0 40 28 68t68 28h896q40 0 88 -20t76 -48zM1024 1400v-376h376q-10 29 -22 41l-313 313q-12 12 -41 22zM1408 -128v1024h-416q-40 0 -68 28t-28 68v416h-768v-1536h1280z M233 768v-107h70l164 -661h159l128 485q7 20 10 46q2 16 2 24h4l3 -24q1 -3 3.5 -20t5.5 -26l128 -485h159l164 661h70v107h-300v-107h90l-99 -438q-5 -20 -7 -46l-2 -21h-4l-3 21q-1 5 -4 21t-5 25l-144 545h-114l-144 -545q-2 -9 -4.5 -24.5t-3.5 -21.5l-4 -21h-4l-2 21 q-2 26 -7 46l-99 438h90v107h-300z" /> -<glyph unicode="" d="M1468 1156q28 -28 48 -76t20 -88v-1152q0 -40 -28 -68t-68 -28h-1344q-40 0 -68 28t-28 68v1600q0 40 28 68t68 28h896q40 0 88 -20t76 -48zM1024 1400v-376h376q-10 29 -22 41l-313 313q-12 12 -41 22zM1408 -128v1024h-416q-40 0 -68 28t-28 68v416h-768v-1536h1280z M429 106v-106h281v106h-75l103 161q5 7 10 16.5t7.5 13.5t3.5 4h2q1 -4 5 -10q2 -4 4.5 -7.5t6 -8t6.5 -8.5l107 -161h-76v-106h291v106h-68l-192 273l195 282h67v107h-279v-107h74l-103 -159q-4 -7 -10 -16.5t-9 -13.5l-2 -3h-2q-1 4 -5 10q-6 11 -17 23l-106 159h76v107 h-290v-107h68l189 -272l-194 -283h-68z" /> -<glyph unicode="" d="M1468 1156q28 -28 48 -76t20 -88v-1152q0 -40 -28 -68t-68 -28h-1344q-40 0 -68 28t-28 68v1600q0 40 28 68t68 28h896q40 0 88 -20t76 -48zM1024 1400v-376h376q-10 29 -22 41l-313 313q-12 12 -41 22zM1408 -128v1024h-416q-40 0 -68 28t-28 68v416h-768v-1536h1280z M416 106v-106h327v106h-93v167h137q76 0 118 15q67 23 106.5 87t39.5 146q0 81 -37 141t-100 87q-48 19 -130 19h-368v-107h92v-555h-92zM769 386h-119v268h120q52 0 83 -18q56 -33 56 -115q0 -89 -62 -120q-31 -15 -78 -15z" /> -<glyph unicode="" d="M1468 1156q28 -28 48 -76t20 -88v-1152q0 -40 -28 -68t-68 -28h-1344q-40 0 -68 28t-28 68v1600q0 40 28 68t68 28h896q40 0 88 -20t76 -48zM1024 1400v-376h376q-10 29 -22 41l-313 313q-12 12 -41 22zM1408 -128v1024h-416q-40 0 -68 28t-28 68v416h-768v-1536h1280z M1280 320v-320h-1024v192l192 192l128 -128l384 384zM448 512q-80 0 -136 56t-56 136t56 136t136 56t136 -56t56 -136t-56 -136t-136 -56z" /> -<glyph unicode="" d="M640 1152v128h-128v-128h128zM768 1024v128h-128v-128h128zM640 896v128h-128v-128h128zM768 768v128h-128v-128h128zM1468 1156q28 -28 48 -76t20 -88v-1152q0 -40 -28 -68t-68 -28h-1344q-40 0 -68 28t-28 68v1600q0 40 28 68t68 28h896q40 0 88 -20t76 -48zM1024 1400 v-376h376q-10 29 -22 41l-313 313q-12 12 -41 22zM1408 -128v1024h-416q-40 0 -68 28t-28 68v416h-128v-128h-128v128h-512v-1536h1280zM781 593l107 -349q8 -27 8 -52q0 -83 -72.5 -137.5t-183.5 -54.5t-183.5 54.5t-72.5 137.5q0 25 8 52q21 63 120 396v128h128v-128h79 q22 0 39 -13t23 -34zM640 128q53 0 90.5 19t37.5 45t-37.5 45t-90.5 19t-90.5 -19t-37.5 -45t37.5 -45t90.5 -19z" /> -<glyph unicode="" d="M1468 1156q28 -28 48 -76t20 -88v-1152q0 -40 -28 -68t-68 -28h-1344q-40 0 -68 28t-28 68v1600q0 40 28 68t68 28h896q40 0 88 -20t76 -48zM1024 1400v-376h376q-10 29 -22 41l-313 313q-12 12 -41 22zM1408 -128v1024h-416q-40 0 -68 28t-28 68v416h-768v-1536h1280z M620 686q20 -8 20 -30v-544q0 -22 -20 -30q-8 -2 -12 -2q-12 0 -23 9l-166 167h-131q-14 0 -23 9t-9 23v192q0 14 9 23t23 9h131l166 167q16 15 35 7zM1037 -3q31 0 50 24q129 159 129 363t-129 363q-16 21 -43 24t-47 -14q-21 -17 -23.5 -43.5t14.5 -47.5 q100 -123 100 -282t-100 -282q-17 -21 -14.5 -47.5t23.5 -42.5q18 -15 40 -15zM826 145q27 0 47 20q87 93 87 219t-87 219q-18 19 -45 20t-46 -17t-20 -44.5t18 -46.5q52 -57 52 -131t-52 -131q-19 -20 -18 -46.5t20 -44.5q20 -17 44 -17z" /> -<glyph unicode="" d="M1468 1156q28 -28 48 -76t20 -88v-1152q0 -40 -28 -68t-68 -28h-1344q-40 0 -68 28t-28 68v1600q0 40 28 68t68 28h896q40 0 88 -20t76 -48zM1024 1400v-376h376q-10 29 -22 41l-313 313q-12 12 -41 22zM1408 -128v1024h-416q-40 0 -68 28t-28 68v416h-768v-1536h1280z M768 768q52 0 90 -38t38 -90v-384q0 -52 -38 -90t-90 -38h-384q-52 0 -90 38t-38 90v384q0 52 38 90t90 38h384zM1260 766q20 -8 20 -30v-576q0 -22 -20 -30q-8 -2 -12 -2q-14 0 -23 9l-265 266v90l265 266q9 9 23 9q4 0 12 -2z" /> -<glyph unicode="" d="M1468 1156q28 -28 48 -76t20 -88v-1152q0 -40 -28 -68t-68 -28h-1344q-40 0 -68 28t-28 68v1600q0 40 28 68t68 28h896q40 0 88 -20t76 -48zM1024 1400v-376h376q-10 29 -22 41l-313 313q-12 12 -41 22zM1408 -128v1024h-416q-40 0 -68 28t-28 68v416h-768v-1536h1280z M480 768q8 11 21 12.5t24 -6.5l51 -38q11 -8 12.5 -21t-6.5 -24l-182 -243l182 -243q8 -11 6.5 -24t-12.5 -21l-51 -38q-11 -8 -24 -6.5t-21 12.5l-226 301q-14 19 0 38zM1282 467q14 -19 0 -38l-226 -301q-8 -11 -21 -12.5t-24 6.5l-51 38q-11 8 -12.5 21t6.5 24l182 243 l-182 243q-8 11 -6.5 24t12.5 21l51 38q11 8 24 6.5t21 -12.5zM662 6q-13 2 -20.5 13t-5.5 24l138 831q2 13 13 20.5t24 5.5l63 -10q13 -2 20.5 -13t5.5 -24l-138 -831q-2 -13 -13 -20.5t-24 -5.5z" /> -<glyph unicode="" d="M1497 709v-198q-101 -23 -198 -23q-65 -136 -165.5 -271t-181.5 -215.5t-128 -106.5q-80 -45 -162 3q-28 17 -60.5 43.5t-85 83.5t-102.5 128.5t-107.5 184t-105.5 244t-91.5 314.5t-70.5 390h283q26 -218 70 -398.5t104.5 -317t121.5 -235.5t140 -195q169 169 287 406 q-142 72 -223 220t-81 333q0 192 104 314.5t284 122.5q178 0 273 -105.5t95 -297.5q0 -159 -58 -286q-7 -1 -19.5 -3t-46 -2t-63 6t-62 25.5t-50.5 51.5q31 103 31 184q0 87 -29 132t-79 45q-53 0 -85 -49.5t-32 -140.5q0 -186 105 -293.5t267 -107.5q62 0 121 14z" /> -<glyph unicode="" horiz-adv-x="1792" d="M216 367l603 -402v359l-334 223zM154 511l193 129l-193 129v-258zM973 -35l603 402l-269 180l-334 -223v-359zM896 458l272 182l-272 182l-272 -182zM485 733l334 223v359l-603 -402zM1445 640l193 -129v258zM1307 733l269 180l-603 402v-359zM1792 913v-546 q0 -41 -34 -64l-819 -546q-21 -13 -43 -13t-43 13l-819 546q-34 23 -34 64v546q0 41 34 64l819 546q21 13 43 13t43 -13l819 -546q34 -23 34 -64z" /> -<glyph unicode="" horiz-adv-x="2048" d="M1800 764q111 -46 179.5 -145.5t68.5 -221.5q0 -164 -118 -280.5t-285 -116.5q-4 0 -11.5 0.5t-10.5 0.5h-1209h-1h-2h-5q-170 10 -288 125.5t-118 280.5q0 110 55 203t147 147q-12 39 -12 82q0 115 82 196t199 81q95 0 172 -58q75 154 222.5 248t326.5 94 q166 0 306 -80.5t221.5 -218.5t81.5 -301q0 -6 -0.5 -18t-0.5 -18zM468 498q0 -122 84 -193t208 -71q137 0 240 99q-16 20 -47.5 56.5t-43.5 50.5q-67 -65 -144 -65q-55 0 -93.5 33.5t-38.5 87.5q0 53 38.5 87t91.5 34q44 0 84.5 -21t73 -55t65 -75t69 -82t77 -75t97 -55 t121.5 -21q121 0 204.5 71.5t83.5 190.5q0 121 -84 192t-207 71q-143 0 -241 -97q14 -16 29.5 -34t34.5 -40t29 -34q66 64 142 64q52 0 92 -33t40 -84q0 -57 -37 -91.5t-94 -34.5q-43 0 -82.5 21t-72 55t-65.5 75t-69.5 82t-77.5 75t-96.5 55t-118.5 21q-122 0 -207 -70.5 t-85 -189.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M896 1536q182 0 348 -71t286 -191t191 -286t71 -348t-71 -348t-191 -286t-286 -191t-348 -71t-348 71t-286 191t-191 286t-71 348t71 348t191 286t286 191t348 71zM896 1408q-190 0 -361 -90l194 -194q82 28 167 28t167 -28l194 194q-171 90 -361 90zM218 279l194 194 q-28 82 -28 167t28 167l-194 194q-90 -171 -90 -361t90 -361zM896 -128q190 0 361 90l-194 194q-82 -28 -167 -28t-167 28l-194 -194q171 -90 361 -90zM896 256q159 0 271.5 112.5t112.5 271.5t-112.5 271.5t-271.5 112.5t-271.5 -112.5t-112.5 -271.5t112.5 -271.5 t271.5 -112.5zM1380 473l194 -194q90 171 90 361t-90 361l-194 -194q28 -82 28 -167t-28 -167z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1792 640q0 -182 -71 -348t-191 -286t-286 -191t-348 -71t-348 71t-286 191t-191 286t-71 348q0 222 101 414.5t276.5 317t390.5 155.5v-260q-221 -45 -366.5 -221t-145.5 -406q0 -130 51 -248.5t136.5 -204t204 -136.5t248.5 -51t248.5 51t204 136.5t136.5 204t51 248.5 q0 230 -145.5 406t-366.5 221v260q215 -31 390.5 -155.5t276.5 -317t101 -414.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M19 662q8 217 116 406t305 318h5q0 -1 -1 -3q-8 -8 -28 -33.5t-52 -76.5t-60 -110.5t-44.5 -135.5t-14 -150.5t39 -157.5t108.5 -154q50 -50 102 -69.5t90.5 -11.5t69.5 23.5t47 32.5l16 16q39 51 53 116.5t6.5 122.5t-21 107t-26.5 80l-14 29q-10 25 -30.5 49.5t-43 41 t-43.5 29.5t-35 19l-13 6l104 115q39 -17 78 -52t59 -61l19 -27q1 48 -18.5 103.5t-40.5 87.5l-20 31l161 183l160 -181q-33 -46 -52.5 -102.5t-22.5 -90.5l-4 -33q22 37 61.5 72.5t67.5 52.5l28 17l103 -115q-44 -14 -85 -50t-60 -65l-19 -29q-31 -56 -48 -133.5t-7 -170 t57 -156.5q33 -45 77.5 -60.5t85 -5.5t76 26.5t57.5 33.5l21 16q60 53 96.5 115t48.5 121.5t10 121.5t-18 118t-37 107.5t-45.5 93t-45 72t-34.5 47.5l-13 17q-14 13 -7 13l10 -3q40 -29 62.5 -46t62 -50t64 -58t58.5 -65t55.5 -77t45.5 -88t38 -103t23.5 -117t10.5 -136 q3 -259 -108 -465t-312 -321t-456 -115q-185 0 -351 74t-283.5 198t-184 293t-60.5 353z" /> -<glyph unicode="" horiz-adv-x="1792" d="M874 -102v-66q-208 6 -385 109.5t-283 275.5l58 34q29 -49 73 -99l65 57q148 -168 368 -212l-17 -86q65 -12 121 -13zM276 428l-83 -28q22 -60 49 -112l-57 -33q-98 180 -98 385t98 385l57 -33q-30 -56 -49 -112l82 -28q-35 -100 -35 -212q0 -109 36 -212zM1528 251 l58 -34q-106 -172 -283 -275.5t-385 -109.5v66q56 1 121 13l-17 86q220 44 368 212l65 -57q44 50 73 99zM1377 805l-233 -80q14 -42 14 -85t-14 -85l232 -80q-31 -92 -98 -169l-185 162q-57 -67 -147 -85l48 -241q-52 -10 -98 -10t-98 10l48 241q-90 18 -147 85l-185 -162 q-67 77 -98 169l232 80q-14 42 -14 85t14 85l-233 80q33 93 99 169l185 -162q59 68 147 86l-48 240q44 10 98 10t98 -10l-48 -240q88 -18 147 -86l185 162q66 -76 99 -169zM874 1448v-66q-65 -2 -121 -13l17 -86q-220 -42 -368 -211l-65 56q-38 -42 -73 -98l-57 33 q106 172 282 275.5t385 109.5zM1705 640q0 -205 -98 -385l-57 33q27 52 49 112l-83 28q36 103 36 212q0 112 -35 212l82 28q-19 56 -49 112l57 33q98 -180 98 -385zM1585 1063l-57 -33q-35 56 -73 98l-65 -56q-148 169 -368 211l17 86q-56 11 -121 13v66q209 -6 385 -109.5 t282 -275.5zM1748 640q0 173 -67.5 331t-181.5 272t-272 181.5t-331 67.5t-331 -67.5t-272 -181.5t-181.5 -272t-67.5 -331t67.5 -331t181.5 -272t272 -181.5t331 -67.5t331 67.5t272 181.5t181.5 272t67.5 331zM1792 640q0 -182 -71 -348t-191 -286t-286 -191t-348 -71 t-348 71t-286 191t-191 286t-71 348t71 348t191 286t286 191t348 71t348 -71t286 -191t191 -286t71 -348z" /> -<glyph unicode="" d="M582 228q0 -66 -93 -66q-107 0 -107 63q0 64 98 64q102 0 102 -61zM546 694q0 -85 -74 -85q-77 0 -77 84q0 90 77 90q36 0 55 -25.5t19 -63.5zM712 769v125q-78 -29 -135 -29q-50 29 -110 29q-86 0 -145 -57t-59 -143q0 -50 29.5 -102t73.5 -67v-3q-38 -17 -38 -85 q0 -53 41 -77v-3q-113 -37 -113 -139q0 -45 20 -78.5t54 -51t72 -25.5t81 -8q224 0 224 188q0 67 -48 99t-126 46q-27 5 -51.5 20.5t-24.5 39.5q0 44 49 52q77 15 122 70t45 134q0 24 -10 52q37 9 49 13zM771 350h137q-2 27 -2 82v387q0 46 2 69h-137q3 -23 3 -71v-392 q0 -50 -3 -75zM1280 366v121q-30 -21 -68 -21q-53 0 -53 82v225h52q9 0 26.5 -1t26.5 -1v117h-105q0 82 3 102h-140q4 -24 4 -55v-47h-60v-117q36 3 37 3q3 0 11 -0.5t12 -0.5v-2h-2v-217q0 -37 2.5 -64t11.5 -56.5t24.5 -48.5t43.5 -31t66 -12q64 0 108 24zM924 1072 q0 36 -24 63.5t-60 27.5t-60.5 -27t-24.5 -64q0 -36 25 -62.5t60 -26.5t59.5 27t24.5 62zM1536 1120v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M595 22q0 100 -165 100q-158 0 -158 -104q0 -101 172 -101q151 0 151 105zM536 777q0 61 -30 102t-89 41q-124 0 -124 -145q0 -135 124 -135q119 0 119 137zM805 1101v-202q-36 -12 -79 -22q16 -43 16 -84q0 -127 -73 -216.5t-197 -112.5q-40 -8 -59.5 -27t-19.5 -58 q0 -31 22.5 -51.5t58 -32t78.5 -22t86 -25.5t78.5 -37.5t58 -64t22.5 -98.5q0 -304 -363 -304q-69 0 -130 12.5t-116 41t-87.5 82t-32.5 127.5q0 165 182 225v4q-67 41 -67 126q0 109 63 137v4q-72 24 -119.5 108.5t-47.5 165.5q0 139 95 231.5t235 92.5q96 0 178 -47 q98 0 218 47zM1123 220h-222q4 45 4 134v609q0 94 -4 128h222q-4 -33 -4 -124v-613q0 -89 4 -134zM1724 442v-196q-71 -39 -174 -39q-62 0 -107 20t-70 50t-39.5 78t-18.5 92t-4 103v351h2v4q-7 0 -19 1t-18 1q-21 0 -59 -6v190h96v76q0 54 -6 89h227q-6 -41 -6 -165h171 v-190q-15 0 -43.5 2t-42.5 2h-85v-365q0 -131 87 -131q61 0 109 33zM1148 1389q0 -58 -39 -101.5t-96 -43.5q-58 0 -98 43.5t-40 101.5q0 59 39.5 103t98.5 44q58 0 96.5 -44.5t38.5 -102.5z" /> -<glyph unicode="" d="M825 547l343 588h-150q-21 -39 -63.5 -118.5t-68 -128.5t-59.5 -118.5t-60 -128.5h-3q-21 48 -44.5 97t-52 105.5t-46.5 92t-54 104.5t-49 95h-150l323 -589v-435h134v436zM1536 1120v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960 q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" horiz-adv-x="1280" d="M842 964q0 -80 -57 -136.5t-136 -56.5q-60 0 -111 35q-62 -67 -115 -146q-247 -371 -202 -859q1 -22 -12.5 -38.5t-34.5 -18.5h-5q-20 0 -35 13.5t-17 33.5q-14 126 -3.5 247.5t29.5 217t54 186t69 155.5t74 125q61 90 132 165q-16 35 -16 77q0 80 56.5 136.5t136.5 56.5 t136.5 -56.5t56.5 -136.5zM1223 953q0 -158 -78 -292t-212.5 -212t-292.5 -78q-64 0 -131 14q-21 5 -32.5 23.5t-6.5 39.5q5 20 23 31.5t39 7.5q51 -13 108 -13q97 0 186 38t153 102t102 153t38 186t-38 186t-102 153t-153 102t-186 38t-186 -38t-153 -102t-102 -153 t-38 -186q0 -114 52 -218q10 -20 3.5 -40t-25.5 -30t-39.5 -3t-30.5 26q-64 123 -64 265q0 119 46.5 227t124.5 186t186 124t226 46q158 0 292.5 -78t212.5 -212.5t78 -292.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M270 730q-8 19 -8 52q0 20 11 49t24 45q-1 22 7.5 53t22.5 43q0 139 92.5 288.5t217.5 209.5q139 66 324 66q133 0 266 -55q49 -21 90 -48t71 -56t55 -68t42 -74t32.5 -84.5t25.5 -89.5t22 -98l1 -5q55 -83 55 -150q0 -14 -9 -40t-9 -38q0 -1 1.5 -3.5t3.5 -5t2 -3.5 q77 -114 120.5 -214.5t43.5 -208.5q0 -43 -19.5 -100t-55.5 -57q-9 0 -19.5 7.5t-19 17.5t-19 26t-16 26.5t-13.5 26t-9 17.5q-1 1 -3 1l-5 -4q-59 -154 -132 -223q20 -20 61.5 -38.5t69 -41.5t35.5 -65q-2 -4 -4 -16t-7 -18q-64 -97 -302 -97q-53 0 -110.5 9t-98 20 t-104.5 30q-15 5 -23 7q-14 4 -46 4.5t-40 1.5q-41 -45 -127.5 -65t-168.5 -20q-35 0 -69 1.5t-93 9t-101 20.5t-74.5 40t-32.5 64q0 40 10 59.5t41 48.5q11 2 40.5 13t49.5 12q4 0 14 2q2 2 2 4l-2 3q-48 11 -108 105.5t-73 156.5l-5 3q-4 0 -12 -20q-18 -41 -54.5 -74.5 t-77.5 -37.5h-1q-4 0 -6 4.5t-5 5.5q-23 54 -23 100q0 275 252 466z" /> -<glyph unicode="" horiz-adv-x="2048" d="M580 1075q0 41 -25 66t-66 25q-43 0 -76 -25.5t-33 -65.5q0 -39 33 -64.5t76 -25.5q41 0 66 24.5t25 65.5zM1323 568q0 28 -25.5 50t-65.5 22q-27 0 -49.5 -22.5t-22.5 -49.5q0 -28 22.5 -50.5t49.5 -22.5q40 0 65.5 22t25.5 51zM1087 1075q0 41 -24.5 66t-65.5 25 q-43 0 -76 -25.5t-33 -65.5q0 -39 33 -64.5t76 -25.5q41 0 65.5 24.5t24.5 65.5zM1722 568q0 28 -26 50t-65 22q-27 0 -49.5 -22.5t-22.5 -49.5q0 -28 22.5 -50.5t49.5 -22.5q39 0 65 22t26 51zM1456 965q-31 4 -70 4q-169 0 -311 -77t-223.5 -208.5t-81.5 -287.5 q0 -78 23 -152q-35 -3 -68 -3q-26 0 -50 1.5t-55 6.5t-44.5 7t-54.5 10.5t-50 10.5l-253 -127l72 218q-290 203 -290 490q0 169 97.5 311t264 223.5t363.5 81.5q176 0 332.5 -66t262 -182.5t136.5 -260.5zM2048 404q0 -117 -68.5 -223.5t-185.5 -193.5l55 -181l-199 109 q-150 -37 -218 -37q-169 0 -311 70.5t-223.5 191.5t-81.5 264t81.5 264t223.5 191.5t311 70.5q161 0 303 -70.5t227.5 -192t85.5 -263.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1764 1525q33 -24 27 -64l-256 -1536q-5 -29 -32 -45q-14 -8 -31 -8q-11 0 -24 5l-453 185l-242 -295q-18 -23 -49 -23q-13 0 -22 4q-19 7 -30.5 23.5t-11.5 36.5v349l864 1059l-1069 -925l-395 162q-37 14 -40 55q-2 40 32 59l1664 960q15 9 32 9q20 0 36 -11z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1764 1525q33 -24 27 -64l-256 -1536q-5 -29 -32 -45q-14 -8 -31 -8q-11 0 -24 5l-527 215l-298 -327q-18 -21 -47 -21q-14 0 -23 4q-19 7 -30 23.5t-11 36.5v452l-472 193q-37 14 -40 55q-3 39 32 59l1664 960q35 21 68 -2zM1422 26l221 1323l-1434 -827l336 -137 l863 639l-478 -797z" /> -<glyph unicode="" d="M1536 640q0 -156 -61 -298t-164 -245t-245 -164t-298 -61q-172 0 -327 72.5t-264 204.5q-7 10 -6.5 22.5t8.5 20.5l137 138q10 9 25 9q16 -2 23 -12q73 -95 179 -147t225 -52q104 0 198.5 40.5t163.5 109.5t109.5 163.5t40.5 198.5t-40.5 198.5t-109.5 163.5 t-163.5 109.5t-198.5 40.5q-98 0 -188 -35.5t-160 -101.5l137 -138q31 -30 14 -69q-17 -40 -59 -40h-448q-26 0 -45 19t-19 45v448q0 42 40 59q39 17 69 -14l130 -129q107 101 244.5 156.5t284.5 55.5q156 0 298 -61t245 -164t164 -245t61 -298zM896 928v-448q0 -14 -9 -23 t-23 -9h-320q-14 0 -23 9t-9 23v64q0 14 9 23t23 9h224v352q0 14 9 23t23 9h64q14 0 23 -9t9 -23z" /> -<glyph unicode="" d="M768 1280q-130 0 -248.5 -51t-204 -136.5t-136.5 -204t-51 -248.5t51 -248.5t136.5 -204t204 -136.5t248.5 -51t248.5 51t204 136.5t136.5 204t51 248.5t-51 248.5t-136.5 204t-204 136.5t-248.5 51zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103 t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1682 -128q-44 0 -132.5 3.5t-133.5 3.5q-44 0 -132 -3.5t-132 -3.5q-24 0 -37 20.5t-13 45.5q0 31 17 46t39 17t51 7t45 15q33 21 33 140l-1 391q0 21 -1 31q-13 4 -50 4h-675q-38 0 -51 -4q-1 -10 -1 -31l-1 -371q0 -142 37 -164q16 -10 48 -13t57 -3.5t45 -15 t20 -45.5q0 -26 -12.5 -48t-36.5 -22q-47 0 -139.5 3.5t-138.5 3.5q-43 0 -128 -3.5t-127 -3.5q-23 0 -35.5 21t-12.5 45q0 30 15.5 45t36 17.5t47.5 7.5t42 15q33 23 33 143l-1 57v813q0 3 0.5 26t0 36.5t-1.5 38.5t-3.5 42t-6.5 36.5t-11 31.5t-16 18q-15 10 -45 12t-53 2 t-41 14t-18 45q0 26 12 48t36 22q46 0 138.5 -3.5t138.5 -3.5q42 0 126.5 3.5t126.5 3.5q25 0 37.5 -22t12.5 -48q0 -30 -17 -43.5t-38.5 -14.5t-49.5 -4t-43 -13q-35 -21 -35 -160l1 -320q0 -21 1 -32q13 -3 39 -3h699q25 0 38 3q1 11 1 32l1 320q0 139 -35 160 q-18 11 -58.5 12.5t-66 13t-25.5 49.5q0 26 12.5 48t37.5 22q44 0 132 -3.5t132 -3.5q43 0 129 3.5t129 3.5q25 0 37.5 -22t12.5 -48q0 -30 -17.5 -44t-40 -14.5t-51.5 -3t-44 -12.5q-35 -23 -35 -161l1 -943q0 -119 34 -140q16 -10 46 -13.5t53.5 -4.5t41.5 -15.5t18 -44.5 q0 -26 -12 -48t-36 -22z" /> -<glyph unicode="" horiz-adv-x="1280" d="M1278 1347v-73q0 -29 -18.5 -61t-42.5 -32q-50 0 -54 -1q-26 -6 -32 -31q-3 -11 -3 -64v-1152q0 -25 -18 -43t-43 -18h-108q-25 0 -43 18t-18 43v1218h-143v-1218q0 -25 -17.5 -43t-43.5 -18h-108q-26 0 -43.5 18t-17.5 43v496q-147 12 -245 59q-126 58 -192 179 q-64 117 -64 259q0 166 88 286q88 118 209 159q111 37 417 37h479q25 0 43 -18t18 -43z" /> -<glyph unicode="" d="M352 128v-128h-352v128h352zM704 256q26 0 45 -19t19 -45v-256q0 -26 -19 -45t-45 -19h-256q-26 0 -45 19t-19 45v256q0 26 19 45t45 19h256zM864 640v-128h-864v128h864zM224 1152v-128h-224v128h224zM1536 128v-128h-736v128h736zM576 1280q26 0 45 -19t19 -45v-256 q0 -26 -19 -45t-45 -19h-256q-26 0 -45 19t-19 45v256q0 26 19 45t45 19h256zM1216 768q26 0 45 -19t19 -45v-256q0 -26 -19 -45t-45 -19h-256q-26 0 -45 19t-19 45v256q0 26 19 45t45 19h256zM1536 640v-128h-224v128h224zM1536 1152v-128h-864v128h864z" /> -<glyph unicode="" d="M1216 512q133 0 226.5 -93.5t93.5 -226.5t-93.5 -226.5t-226.5 -93.5t-226.5 93.5t-93.5 226.5q0 12 2 34l-360 180q-92 -86 -218 -86q-133 0 -226.5 93.5t-93.5 226.5t93.5 226.5t226.5 93.5q126 0 218 -86l360 180q-2 22 -2 34q0 133 93.5 226.5t226.5 93.5 t226.5 -93.5t93.5 -226.5t-93.5 -226.5t-226.5 -93.5q-126 0 -218 86l-360 -180q2 -22 2 -34t-2 -34l360 -180q92 86 218 86z" /> -<glyph unicode="" d="M1280 341q0 88 -62.5 151t-150.5 63q-84 0 -145 -58l-241 120q2 16 2 23t-2 23l241 120q61 -58 145 -58q88 0 150.5 63t62.5 151t-62.5 150.5t-150.5 62.5t-151 -62.5t-63 -150.5q0 -7 2 -23l-241 -120q-62 57 -145 57q-88 0 -150.5 -62.5t-62.5 -150.5t62.5 -150.5 t150.5 -62.5q83 0 145 57l241 -120q-2 -16 -2 -23q0 -88 63 -150.5t151 -62.5t150.5 62.5t62.5 150.5zM1536 1120v-960q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M571 947q-10 25 -34 35t-49 0q-108 -44 -191 -127t-127 -191q-10 -25 0 -49t35 -34q13 -5 24 -5q42 0 60 40q34 84 98.5 148.5t148.5 98.5q25 11 35 35t0 49zM1513 1303l46 -46l-244 -243l68 -68q19 -19 19 -45.5t-19 -45.5l-64 -64q89 -161 89 -343q0 -143 -55.5 -273.5 t-150 -225t-225 -150t-273.5 -55.5t-273.5 55.5t-225 150t-150 225t-55.5 273.5t55.5 273.5t150 225t225 150t273.5 55.5q182 0 343 -89l64 64q19 19 45.5 19t45.5 -19l68 -68zM1521 1359q-10 -10 -22 -10q-13 0 -23 10l-91 90q-9 10 -9 23t9 23q10 9 23 9t23 -9l90 -91 q10 -9 10 -22.5t-10 -22.5zM1751 1129q-11 -9 -23 -9t-23 9l-90 91q-10 9 -10 22.5t10 22.5q9 10 22.5 10t22.5 -10l91 -90q9 -10 9 -23t-9 -23zM1792 1312q0 -14 -9 -23t-23 -9h-96q-14 0 -23 9t-9 23t9 23t23 9h96q14 0 23 -9t9 -23zM1600 1504v-96q0 -14 -9 -23t-23 -9 t-23 9t-9 23v96q0 14 9 23t23 9t23 -9t9 -23zM1751 1449l-91 -90q-10 -10 -22 -10q-13 0 -23 10q-10 9 -10 22.5t10 22.5l90 91q10 9 23 9t23 -9q9 -10 9 -23t-9 -23z" /> -<glyph unicode="" horiz-adv-x="1792" d="M609 720l287 208l287 -208l-109 -336h-355zM896 1536q182 0 348 -71t286 -191t191 -286t71 -348t-71 -348t-191 -286t-286 -191t-348 -71t-348 71t-286 191t-191 286t-71 348t71 348t191 286t286 191t348 71zM1515 186q149 203 149 454v3l-102 -89l-240 224l63 323 l134 -12q-150 206 -389 282l53 -124l-287 -159l-287 159l53 124q-239 -76 -389 -282l135 12l62 -323l-240 -224l-102 89v-3q0 -251 149 -454l30 132l326 -40l139 -298l-116 -69q117 -39 240 -39t240 39l-116 69l139 298l326 40z" /> -<glyph unicode="" horiz-adv-x="1792" d="M448 224v-192q0 -14 -9 -23t-23 -9h-192q-14 0 -23 9t-9 23v192q0 14 9 23t23 9h192q14 0 23 -9t9 -23zM256 608v-192q0 -14 -9 -23t-23 -9h-192q-14 0 -23 9t-9 23v192q0 14 9 23t23 9h192q14 0 23 -9t9 -23zM832 224v-192q0 -14 -9 -23t-23 -9h-192q-14 0 -23 9t-9 23 v192q0 14 9 23t23 9h192q14 0 23 -9t9 -23zM640 608v-192q0 -14 -9 -23t-23 -9h-192q-14 0 -23 9t-9 23v192q0 14 9 23t23 9h192q14 0 23 -9t9 -23zM66 768q-28 0 -47 19t-19 46v129h514v-129q0 -27 -19 -46t-46 -19h-383zM1216 224v-192q0 -14 -9 -23t-23 -9h-192 q-14 0 -23 9t-9 23v192q0 14 9 23t23 9h192q14 0 23 -9t9 -23zM1024 608v-192q0 -14 -9 -23t-23 -9h-192q-14 0 -23 9t-9 23v192q0 14 9 23t23 9h192q14 0 23 -9t9 -23zM1600 224v-192q0 -14 -9 -23t-23 -9h-192q-14 0 -23 9t-9 23v192q0 14 9 23t23 9h192q14 0 23 -9t9 -23 zM1408 608v-192q0 -14 -9 -23t-23 -9h-192q-14 0 -23 9t-9 23v192q0 14 9 23t23 9h192q14 0 23 -9t9 -23zM1792 1016v-13h-514v10q0 104 -382 102q-382 -1 -382 -102v-10h-514v13q0 17 8.5 43t34 64t65.5 75.5t110.5 76t160 67.5t224 47.5t293.5 18.5t293 -18.5t224 -47.5 t160.5 -67.5t110.5 -76t65.5 -75.5t34 -64t8.5 -43zM1792 608v-192q0 -14 -9 -23t-23 -9h-192q-14 0 -23 9t-9 23v192q0 14 9 23t23 9h192q14 0 23 -9t9 -23zM1792 962v-129q0 -27 -19 -46t-46 -19h-384q-27 0 -46 19t-19 46v129h514z" /> -<glyph unicode="" horiz-adv-x="1792" d="M704 1216v-768q0 -26 -19 -45t-45 -19v-576q0 -26 -19 -45t-45 -19h-512q-26 0 -45 19t-19 45v512l249 873q7 23 31 23h424zM1024 1216v-704h-256v704h256zM1792 320v-512q0 -26 -19 -45t-45 -19h-512q-26 0 -45 19t-19 45v576q-26 0 -45 19t-19 45v768h424q24 0 31 -23z M736 1504v-224h-352v224q0 14 9 23t23 9h288q14 0 23 -9t9 -23zM1408 1504v-224h-352v224q0 14 9 23t23 9h288q14 0 23 -9t9 -23z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1755 1083q37 -37 37 -90t-37 -91l-401 -400l150 -150l-160 -160q-163 -163 -389.5 -186.5t-411.5 100.5l-362 -362h-181v181l362 362q-124 185 -100.5 411.5t186.5 389.5l160 160l150 -150l400 401q38 37 91 37t90 -37t37 -90.5t-37 -90.5l-400 -401l234 -234l401 400 q38 37 91 37t90 -37z" /> -<glyph unicode="" horiz-adv-x="1792" d="M873 796q0 -83 -63.5 -142.5t-152.5 -59.5t-152.5 59.5t-63.5 142.5q0 84 63.5 143t152.5 59t152.5 -59t63.5 -143zM1375 796q0 -83 -63 -142.5t-153 -59.5q-89 0 -152.5 59.5t-63.5 142.5q0 84 63.5 143t152.5 59q90 0 153 -59t63 -143zM1600 616v667q0 87 -32 123.5 t-111 36.5h-1112q-83 0 -112.5 -34t-29.5 -126v-673q43 -23 88.5 -40t81 -28t81 -18.5t71 -11t70 -4t58.5 -0.5t56.5 2t44.5 2q68 1 95 -27q6 -6 10 -9q26 -25 61 -51q7 91 118 87q5 0 36.5 -1.5t43 -2t45.5 -1t53 1t54.5 4.5t61 8.5t62 13.5t67 19.5t67.5 27t72 34.5z M1763 621q-121 -149 -372 -252q84 -285 -23 -465q-66 -113 -183 -148q-104 -32 -182 15q-86 51 -82 164l-1 326v1q-8 2 -24.5 6t-23.5 5l-1 -338q4 -114 -83 -164q-79 -47 -183 -15q-117 36 -182 150q-105 180 -22 463q-251 103 -372 252q-25 37 -4 63t60 -1q3 -2 11 -7 t11 -8v694q0 72 47 123t114 51h1257q67 0 114 -51t47 -123v-694l21 15q39 27 60 1t-4 -63z" /> -<glyph unicode="" horiz-adv-x="1792" d="M896 1102v-434h-145v434h145zM1294 1102v-434h-145v434h145zM1294 342l253 254v795h-1194v-1049h326v-217l217 217h398zM1692 1536v-1013l-434 -434h-326l-217 -217h-217v217h-398v1158l109 289h1483z" /> -<glyph unicode="" d="M773 217v-127q-1 -292 -6 -305q-12 -32 -51 -40q-54 -9 -181.5 38t-162.5 89q-13 15 -17 36q-1 12 4 26q4 10 34 47t181 216q1 0 60 70q15 19 39.5 24.5t49.5 -3.5q24 -10 37.5 -29t12.5 -42zM624 468q-3 -55 -52 -70l-120 -39q-275 -88 -292 -88q-35 2 -54 36 q-12 25 -17 75q-8 76 1 166.5t30 124.5t56 32q13 0 202 -77q70 -29 115 -47l84 -34q23 -9 35.5 -30.5t11.5 -48.5zM1450 171q-7 -54 -91.5 -161t-135.5 -127q-37 -14 -63 7q-14 10 -184 287l-47 77q-14 21 -11.5 46t19.5 46q35 43 83 26q1 -1 119 -40q203 -66 242 -79.5 t47 -20.5q28 -22 22 -61zM778 803q5 -102 -54 -122q-58 -17 -114 71l-378 598q-8 35 19 62q41 43 207.5 89.5t224.5 31.5q40 -10 49 -45q3 -18 22 -305.5t24 -379.5zM1440 695q3 -39 -26 -59q-15 -10 -329 -86q-67 -15 -91 -23l1 2q-23 -6 -46 4t-37 32q-30 47 0 87 q1 1 75 102q125 171 150 204t34 39q28 19 65 2q48 -23 123 -133.5t81 -167.5v-3z" /> -<glyph unicode="" horiz-adv-x="2048" d="M1024 1024h-384v-384h384v384zM1152 384v-128h-640v128h640zM1152 1152v-640h-640v640h640zM1792 384v-128h-512v128h512zM1792 640v-128h-512v128h512zM1792 896v-128h-512v128h512zM1792 1152v-128h-512v128h512zM256 192v960h-128v-960q0 -26 19 -45t45 -19t45 19 t19 45zM1920 192v1088h-1536v-1088q0 -33 -11 -64h1483q26 0 45 19t19 45zM2048 1408v-1216q0 -80 -56 -136t-136 -56h-1664q-80 0 -136 56t-56 136v1088h256v128h1792z" /> -<glyph unicode="" horiz-adv-x="2048" d="M1024 13q-20 0 -93 73.5t-73 93.5q0 32 62.5 54t103.5 22t103.5 -22t62.5 -54q0 -20 -73 -93.5t-93 -73.5zM1294 284q-2 0 -40 25t-101.5 50t-128.5 25t-128.5 -25t-101 -50t-40.5 -25q-18 0 -93.5 75t-75.5 93q0 13 10 23q78 77 196 121t233 44t233 -44t196 -121 q10 -10 10 -23q0 -18 -75.5 -93t-93.5 -75zM1567 556q-11 0 -23 8q-136 105 -252 154.5t-268 49.5q-85 0 -170.5 -22t-149 -53t-113.5 -62t-79 -53t-31 -22q-17 0 -92 75t-75 93q0 12 10 22q132 132 320 205t380 73t380 -73t320 -205q10 -10 10 -22q0 -18 -75 -93t-92 -75z M1838 827q-11 0 -22 9q-179 157 -371.5 236.5t-420.5 79.5t-420.5 -79.5t-371.5 -236.5q-11 -9 -22 -9q-17 0 -92.5 75t-75.5 93q0 13 10 23q187 186 445 288t527 102t527 -102t445 -288q10 -10 10 -23q0 -18 -75.5 -93t-92.5 -75z" /> -<glyph unicode="" horiz-adv-x="1792" d="M384 0q0 53 -37.5 90.5t-90.5 37.5t-90.5 -37.5t-37.5 -90.5t37.5 -90.5t90.5 -37.5t90.5 37.5t37.5 90.5zM768 0q0 53 -37.5 90.5t-90.5 37.5t-90.5 -37.5t-37.5 -90.5t37.5 -90.5t90.5 -37.5t90.5 37.5t37.5 90.5zM384 384q0 53 -37.5 90.5t-90.5 37.5t-90.5 -37.5 t-37.5 -90.5t37.5 -90.5t90.5 -37.5t90.5 37.5t37.5 90.5zM1152 0q0 53 -37.5 90.5t-90.5 37.5t-90.5 -37.5t-37.5 -90.5t37.5 -90.5t90.5 -37.5t90.5 37.5t37.5 90.5zM768 384q0 53 -37.5 90.5t-90.5 37.5t-90.5 -37.5t-37.5 -90.5t37.5 -90.5t90.5 -37.5t90.5 37.5 t37.5 90.5zM384 768q0 53 -37.5 90.5t-90.5 37.5t-90.5 -37.5t-37.5 -90.5t37.5 -90.5t90.5 -37.5t90.5 37.5t37.5 90.5zM1152 384q0 53 -37.5 90.5t-90.5 37.5t-90.5 -37.5t-37.5 -90.5t37.5 -90.5t90.5 -37.5t90.5 37.5t37.5 90.5zM768 768q0 53 -37.5 90.5t-90.5 37.5 t-90.5 -37.5t-37.5 -90.5t37.5 -90.5t90.5 -37.5t90.5 37.5t37.5 90.5zM1536 0v384q0 52 -38 90t-90 38t-90 -38t-38 -90v-384q0 -52 38 -90t90 -38t90 38t38 90zM1152 768q0 53 -37.5 90.5t-90.5 37.5t-90.5 -37.5t-37.5 -90.5t37.5 -90.5t90.5 -37.5t90.5 37.5t37.5 90.5z M1536 1088v256q0 26 -19 45t-45 19h-1280q-26 0 -45 -19t-19 -45v-256q0 -26 19 -45t45 -19h1280q26 0 45 19t19 45zM1536 768q0 53 -37.5 90.5t-90.5 37.5t-90.5 -37.5t-37.5 -90.5t37.5 -90.5t90.5 -37.5t90.5 37.5t37.5 90.5zM1664 1408v-1536q0 -52 -38 -90t-90 -38 h-1408q-52 0 -90 38t-38 90v1536q0 52 38 90t90 38h1408q52 0 90 -38t38 -90z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1112 1090q0 159 -237 159h-70q-32 0 -59.5 -21.5t-34.5 -52.5l-63 -276q-2 -5 -2 -16q0 -24 17 -39.5t41 -15.5h53q69 0 128.5 13t112.5 41t83.5 81.5t30.5 126.5zM1716 938q0 -265 -220 -428q-219 -161 -612 -161h-61q-32 0 -59 -21.5t-34 -52.5l-73 -316 q-8 -36 -40.5 -61.5t-69.5 -25.5h-213q-31 0 -53 20t-22 51q0 10 13 65h151q34 0 64 23.5t38 56.5l73 316q8 33 37.5 57t63.5 24h61q390 0 607 160t217 421q0 129 -51 207q183 -92 183 -335zM1533 1123q0 -264 -221 -428q-218 -161 -612 -161h-60q-32 0 -59.5 -22t-34.5 -53 l-73 -315q-8 -36 -40 -61.5t-69 -25.5h-214q-31 0 -52.5 19.5t-21.5 51.5q0 8 2 20l300 1301q8 36 40.5 61.5t69.5 25.5h444q68 0 125 -4t120.5 -15t113.5 -30t96.5 -50.5t77.5 -74t49.5 -103.5t18.5 -136z" /> -<glyph unicode="" horiz-adv-x="1792" d="M602 949q19 -61 31 -123.5t17 -141.5t-14 -159t-62 -145q-21 81 -67 157t-95.5 127t-99 90.5t-78.5 57.5t-33 19q-62 34 -81.5 100t14.5 128t101 81.5t129 -14.5q138 -83 238 -177zM927 1236q11 -25 20.5 -46t36.5 -100.5t42.5 -150.5t25.5 -179.5t0 -205.5t-47.5 -209.5 t-105.5 -208.5q-51 -72 -138 -72q-54 0 -98 31q-57 40 -69 109t28 127q60 85 81 195t13 199.5t-32 180.5t-39 128t-22 52q-31 63 -8.5 129.5t85.5 97.5q34 17 75 17q47 0 88.5 -25t63.5 -69zM1248 567q-17 -160 -72 -311q-17 131 -63 246q25 174 -5 361q-27 178 -94 342 q114 -90 212 -211q9 -37 15 -80q26 -179 7 -347zM1520 1440q9 -17 23.5 -49.5t43.5 -117.5t50.5 -178t34 -227.5t5 -269t-47 -300t-112.5 -323.5q-22 -48 -66 -75.5t-95 -27.5q-39 0 -74 16q-67 31 -92.5 100t4.5 136q58 126 90 257.5t37.5 239.5t-3.5 213.5t-26.5 180.5 t-38.5 138.5t-32.5 90t-15.5 32.5q-34 65 -11.5 135.5t87.5 104.5q37 20 81 20q49 0 91.5 -25.5t66.5 -70.5z" /> -<glyph unicode="" horiz-adv-x="2304" d="M1975 546h-138q14 37 66 179l3 9q4 10 10 26t9 26l12 -55zM531 611l-58 295q-11 54 -75 54h-268l-2 -13q311 -79 403 -336zM710 960l-162 -438l-17 89q-26 70 -85 129.5t-131 88.5l135 -510h175l261 641h-176zM849 318h166l104 642h-166zM1617 944q-69 27 -149 27 q-123 0 -201 -59t-79 -153q-1 -102 145 -174q48 -23 67 -41t19 -39q0 -30 -30 -46t-69 -16q-86 0 -156 33l-22 11l-23 -144q74 -34 185 -34q130 -1 208.5 59t80.5 160q0 106 -140 174q-49 25 -71 42t-22 38q0 22 24.5 38.5t70.5 16.5q70 1 124 -24l15 -8zM2042 960h-128 q-65 0 -87 -54l-246 -588h174l35 96h212q5 -22 20 -96h154zM2304 1280v-1280q0 -52 -38 -90t-90 -38h-2048q-52 0 -90 38t-38 90v1280q0 52 38 90t90 38h2048q52 0 90 -38t38 -90z" /> -<glyph unicode="" horiz-adv-x="2304" d="M671 603h-13q-47 0 -47 -32q0 -22 20 -22q17 0 28 15t12 39zM1066 639h62v3q1 4 0.5 6.5t-1 7t-2 8t-4.5 6.5t-7.5 5t-11.5 2q-28 0 -36 -38zM1606 603h-12q-48 0 -48 -32q0 -22 20 -22q17 0 28 15t12 39zM1925 629q0 41 -30 41q-19 0 -31 -20t-12 -51q0 -42 28 -42 q20 0 32.5 20t12.5 52zM480 770h87l-44 -262h-56l32 201l-71 -201h-39l-4 200l-34 -200h-53l44 262h81l2 -163zM733 663q0 -6 -4 -42q-16 -101 -17 -113h-47l1 22q-20 -26 -58 -26q-23 0 -37.5 16t-14.5 42q0 39 26 60.5t73 21.5q14 0 23 -1q0 3 0.5 5.5t1 4.5t0.5 3 q0 20 -36 20q-29 0 -59 -10q0 4 7 48q38 11 67 11q74 0 74 -62zM889 721l-8 -49q-22 3 -41 3q-27 0 -27 -17q0 -8 4.5 -12t21.5 -11q40 -19 40 -60q0 -72 -87 -71q-34 0 -58 6q0 2 7 49q29 -8 51 -8q32 0 32 19q0 7 -4.5 11.5t-21.5 12.5q-43 20 -43 59q0 72 84 72 q30 0 50 -4zM977 721h28l-7 -52h-29q-2 -17 -6.5 -40.5t-7 -38.5t-2.5 -18q0 -16 19 -16q8 0 16 2l-8 -47q-21 -7 -40 -7q-43 0 -45 47q0 12 8 56q3 20 25 146h55zM1180 648q0 -23 -7 -52h-111q-3 -22 10 -33t38 -11q30 0 58 14l-9 -54q-30 -8 -57 -8q-95 0 -95 95 q0 55 27.5 90.5t69.5 35.5q35 0 55.5 -21t20.5 -56zM1319 722q-13 -23 -22 -62q-22 2 -31 -24t-25 -128h-56l3 14q22 130 29 199h51l-3 -33q14 21 25.5 29.5t28.5 4.5zM1506 763l-9 -57q-28 14 -50 14q-31 0 -51 -27.5t-20 -70.5q0 -30 13.5 -47t38.5 -17q21 0 48 13 l-10 -59q-28 -8 -50 -8q-45 0 -71.5 30.5t-26.5 82.5q0 70 35.5 114.5t91.5 44.5q26 0 61 -13zM1668 663q0 -18 -4 -42q-13 -79 -17 -113h-46l1 22q-20 -26 -59 -26q-23 0 -37 16t-14 42q0 39 25.5 60.5t72.5 21.5q15 0 23 -1q2 7 2 13q0 20 -36 20q-29 0 -59 -10q0 4 8 48 q38 11 67 11q73 0 73 -62zM1809 722q-14 -24 -21 -62q-23 2 -31.5 -23t-25.5 -129h-56l3 14q19 104 29 199h52q0 -11 -4 -33q15 21 26.5 29.5t27.5 4.5zM1950 770h56l-43 -262h-53l3 19q-23 -23 -52 -23q-31 0 -49.5 24t-18.5 64q0 53 27.5 92t64.5 39q31 0 53 -29z M2061 640q0 148 -72.5 273t-198 198t-273.5 73q-181 0 -328 -110q127 -116 171 -284h-50q-44 150 -158 253q-114 -103 -158 -253h-50q44 168 171 284q-147 110 -328 110q-148 0 -273.5 -73t-198 -198t-72.5 -273t72.5 -273t198 -198t273.5 -73q181 0 328 110 q-120 111 -165 264h50q46 -138 152 -233q106 95 152 233h50q-45 -153 -165 -264q147 -110 328 -110q148 0 273.5 73t198 198t72.5 273zM2304 1280v-1280q0 -52 -38 -90t-90 -38h-2048q-52 0 -90 38t-38 90v1280q0 52 38 90t90 38h2048q52 0 90 -38t38 -90z" /> -<glyph unicode="" horiz-adv-x="2304" d="M313 759q0 -51 -36 -84q-29 -26 -89 -26h-17v220h17q61 0 89 -27q36 -31 36 -83zM2089 824q0 -52 -64 -52h-19v101h20q63 0 63 -49zM380 759q0 74 -50 120.5t-129 46.5h-95v-333h95q74 0 119 38q60 51 60 128zM410 593h65v333h-65v-333zM730 694q0 40 -20.5 62t-75.5 42 q-29 10 -39.5 19t-10.5 23q0 16 13.5 26.5t34.5 10.5q29 0 53 -27l34 44q-41 37 -98 37q-44 0 -74 -27.5t-30 -67.5q0 -35 18 -55.5t64 -36.5q37 -13 45 -19q19 -12 19 -34q0 -20 -14 -33.5t-36 -13.5q-48 0 -71 44l-42 -40q44 -64 115 -64q51 0 83 30.5t32 79.5zM1008 604 v77q-37 -37 -78 -37q-49 0 -80.5 32.5t-31.5 82.5q0 48 31.5 81.5t77.5 33.5q43 0 81 -38v77q-40 20 -80 20q-74 0 -125.5 -50.5t-51.5 -123.5t51 -123.5t125 -50.5q42 0 81 19zM2240 0v527q-65 -40 -144.5 -84t-237.5 -117t-329.5 -137.5t-417.5 -134.5t-504 -118h1569 q26 0 45 19t19 45zM1389 757q0 75 -53 128t-128 53t-128 -53t-53 -128t53 -128t128 -53t128 53t53 128zM1541 584l144 342h-71l-90 -224l-89 224h-71l142 -342h35zM1714 593h184v56h-119v90h115v56h-115v74h119v57h-184v-333zM2105 593h80l-105 140q76 16 76 94q0 47 -31 73 t-87 26h-97v-333h65v133h9zM2304 1274v-1268q0 -56 -38.5 -95t-93.5 -39h-2040q-55 0 -93.5 39t-38.5 95v1268q0 56 38.5 95t93.5 39h2040q55 0 93.5 -39t38.5 -95z" /> -<glyph unicode="" horiz-adv-x="2304" d="M119 854h89l-45 108zM740 328l74 79l-70 79h-163v-49h142v-55h-142v-54h159zM898 406l99 -110v217zM1186 453q0 33 -40 33h-84v-69h83q41 0 41 36zM1475 457q0 29 -42 29h-82v-61h81q43 0 43 32zM1197 923q0 29 -42 29h-82v-60h81q43 0 43 31zM1656 854h89l-44 108z M699 1009v-271h-66v212l-94 -212h-57l-94 212v-212h-132l-25 60h-135l-25 -60h-70l116 271h96l110 -257v257h106l85 -184l77 184h108zM1255 453q0 -20 -5.5 -35t-14 -25t-22.5 -16.5t-26 -10t-31.5 -4.5t-31.5 -1t-32.5 0.5t-29.5 0.5v-91h-126l-80 90l-83 -90h-256v271h260 l80 -89l82 89h207q109 0 109 -89zM964 794v-56h-217v271h217v-57h-152v-49h148v-55h-148v-54h152zM2304 235v-229q0 -55 -38.5 -94.5t-93.5 -39.5h-2040q-55 0 -93.5 39.5t-38.5 94.5v678h111l25 61h55l25 -61h218v46l19 -46h113l20 47v-47h541v99l10 1q10 0 10 -14v-86h279 v23q23 -12 55 -18t52.5 -6.5t63 0.5t51.5 1l25 61h56l25 -61h227v58l34 -58h182v378h-180v-44l-25 44h-185v-44l-23 44h-249q-69 0 -109 -22v22h-172v-22q-24 22 -73 22h-628l-43 -97l-43 97h-198v-44l-22 44h-169l-78 -179v391q0 55 38.5 94.5t93.5 39.5h2040 q55 0 93.5 -39.5t38.5 -94.5v-678h-120q-51 0 -81 -22v22h-177q-55 0 -78 -22v22h-316v-22q-31 22 -87 22h-209v-22q-23 22 -91 22h-234l-54 -58l-50 58h-349v-378h343l55 59l52 -59h211v89h21q59 0 90 13v-102h174v99h8q8 0 10 -2t2 -10v-87h529q57 0 88 24v-24h168 q60 0 95 17zM1546 469q0 -23 -12 -43t-34 -29q25 -9 34 -26t9 -46v-54h-65v45q0 33 -12 43.5t-46 10.5h-69v-99h-65v271h154q48 0 77 -15t29 -58zM1269 936q0 -24 -12.5 -44t-33.5 -29q26 -9 34.5 -25.5t8.5 -46.5v-53h-65q0 9 0.5 26.5t0 25t-3 18.5t-8.5 16t-17.5 8.5 t-29.5 3.5h-70v-98h-64v271l153 -1q49 0 78 -14.5t29 -57.5zM1798 327v-56h-216v271h216v-56h-151v-49h148v-55h-148v-54zM1372 1009v-271h-66v271h66zM2065 357q0 -86 -102 -86h-126v58h126q34 0 34 25q0 16 -17 21t-41.5 5t-49.5 3.5t-42 22.5t-17 55q0 39 26 60t66 21 h130v-57h-119q-36 0 -36 -25q0 -16 17.5 -20.5t42 -4t49 -2.5t42 -21.5t17.5 -54.5zM2304 407v-101q-24 -35 -88 -35h-125v58h125q33 0 33 25q0 13 -12.5 19t-31 5.5t-40 2t-40 8t-31 24t-12.5 48.5q0 39 26.5 60t66.5 21h129v-57h-118q-36 0 -36 -25q0 -20 29 -22t68.5 -5 t56.5 -26zM2139 1008v-270h-92l-122 203v-203h-132l-26 60h-134l-25 -60h-75q-129 0 -129 133q0 138 133 138h63v-59q-7 0 -28 1t-28.5 0.5t-23 -2t-21.5 -6.5t-14.5 -13.5t-11.5 -23t-3 -33.5q0 -38 13.5 -58t49.5 -20h29l92 213h97l109 -256v256h99l114 -188v188h66z" /> -<glyph unicode="" horiz-adv-x="2304" d="M322 689h-15q-19 0 -19 18q0 28 19 85q5 15 15 19.5t28 4.5q77 0 77 -49q0 -41 -30.5 -59.5t-74.5 -18.5zM664 528q-47 0 -47 29q0 62 123 62l3 -3q-5 -88 -79 -88zM1438 687h-15q-19 0 -19 19q0 28 19 85q5 15 14.5 19t28.5 4q77 0 77 -49q0 -41 -30.5 -59.5 t-74.5 -18.5zM1780 527q-47 0 -47 30q0 62 123 62l3 -3q-5 -89 -79 -89zM373 894h-128q-8 0 -14.5 -4t-8.5 -7.5t-7 -12.5q-3 -7 -45 -190t-42 -192q0 -7 5.5 -12.5t13.5 -5.5h62q25 0 32.5 34.5l15 69t32.5 34.5q47 0 87.5 7.5t80.5 24.5t63.5 52.5t23.5 84.5 q0 36 -14.5 61t-41 36.5t-53.5 15.5t-62 4zM719 798q-38 0 -74 -6q-2 0 -8.5 -1t-9 -1.5l-7.5 -1.5t-7.5 -2t-6.5 -3t-6.5 -4t-5 -5t-4.5 -7t-4 -9q-9 -29 -9 -39t9 -10q5 0 21.5 5t19.5 6q30 8 58 8q74 0 74 -36q0 -11 -10 -14q-8 -2 -18 -3t-21.5 -1.5t-17.5 -1.5 q-38 -4 -64.5 -10t-56.5 -19.5t-45.5 -39t-15.5 -62.5q0 -38 26 -59.5t64 -21.5q24 0 45.5 6.5t33 13t38.5 23.5q-3 -7 -3 -15t5.5 -13.5t12.5 -5.5h56q1 1 7 3.5t7.5 3.5t5 3.5t5 5.5t2.5 8l45 194q4 13 4 30q0 81 -145 81zM1247 793h-74q-22 0 -39 -23q-5 -7 -29.5 -51 t-46.5 -81.5t-26 -38.5l-5 4q0 77 -27 166q-1 5 -3.5 8.5t-6 6.5t-6.5 5t-8.5 3t-8.5 1.5t-9.5 1t-9 0.5h-10h-8.5q-38 0 -38 -21l1 -5q5 -53 25 -151t25 -143q2 -16 2 -24q0 -19 -30.5 -61.5t-30.5 -58.5q0 -13 40 -13q61 0 76 25l245 415q10 20 10 26q0 9 -8 9zM1489 892 h-129q-18 0 -29 -23q-6 -13 -46.5 -191.5t-40.5 -190.5q0 -20 43 -20h7.5h9h9t9.5 1t8.5 2t8.5 3t6.5 4.5t5.5 6t3 8.5l21 91q2 10 10.5 17t19.5 7q47 0 87.5 7t80.5 24.5t63.5 52.5t23.5 84q0 36 -14.5 61t-41 36.5t-53.5 15.5t-62 4zM1835 798q-26 0 -74 -6 q-38 -6 -48 -16q-7 -8 -11 -19q-8 -24 -8 -39q0 -10 8 -10q1 0 41 12q30 8 58 8q74 0 74 -36q0 -12 -10 -14q-4 -1 -57 -7q-38 -4 -64.5 -10t-56.5 -19.5t-45.5 -39t-15.5 -62.5t26 -58.5t64 -21.5q24 0 45 6t34 13t38 24q-3 -15 -3 -16q0 -5 2 -8.5t6.5 -5.5t8 -3.5 t10.5 -2t9.5 -0.5h9.5h8q42 0 48 25l45 194q3 15 3 31q0 81 -145 81zM2157 889h-55q-25 0 -33 -40q-10 -44 -36.5 -167t-42.5 -190v-5q0 -16 16 -18h1h57q10 0 18.5 6.5t10.5 16.5l83 374h-1l1 5q0 7 -5.5 12.5t-13.5 5.5zM2304 1280v-1280q0 -52 -38 -90t-90 -38h-2048 q-52 0 -90 38t-38 90v1280q0 52 38 90t90 38h2048q52 0 90 -38t38 -90z" /> -<glyph unicode="" horiz-adv-x="2304" d="M1597 633q0 -69 -21 -106q-19 -35 -52 -35q-23 0 -41 9v224q29 30 57 30q57 0 57 -122zM2035 669h-110q6 98 56 98q51 0 54 -98zM476 534q0 59 -33 91.5t-101 57.5q-36 13 -52 24t-16 25q0 26 38 26q58 0 124 -33l18 112q-67 32 -149 32q-77 0 -123 -38q-48 -39 -48 -109 q0 -58 32.5 -90.5t99.5 -56.5q39 -14 54.5 -25.5t15.5 -27.5q0 -31 -48 -31q-29 0 -70 12.5t-72 30.5l-18 -113q72 -41 168 -41q81 0 129 37q51 41 51 117zM771 749l19 111h-96v135l-129 -21l-18 -114l-46 -8l-17 -103h62v-219q0 -84 44 -120q38 -30 111 -30q32 0 79 11v118 q-32 -7 -44 -7q-42 0 -42 50v197h77zM1087 724v139q-15 3 -28 3q-32 0 -55.5 -16t-33.5 -46l-10 56h-131v-471h150v306q26 31 82 31q16 0 26 -2zM1124 389h150v471h-150v-471zM1746 638q0 122 -45 179q-40 52 -111 52q-64 0 -117 -56l-8 47h-132v-645l150 25v151 q36 -11 68 -11q83 0 134 56q61 65 61 202zM1278 986q0 33 -23 56t-56 23t-56 -23t-23 -56t23 -56.5t56 -23.5t56 23.5t23 56.5zM2176 629q0 113 -48 176q-50 64 -144 64q-96 0 -151.5 -66t-55.5 -180q0 -128 63 -188q55 -55 161 -55q101 0 160 40l-16 103q-57 -31 -128 -31 q-43 0 -63 19q-23 19 -28 66h248q2 14 2 52zM2304 1280v-1280q0 -52 -38 -90t-90 -38h-2048q-52 0 -90 38t-38 90v1280q0 52 38 90t90 38h2048q52 0 90 -38t38 -90z" /> -<glyph unicode="" horiz-adv-x="2048" d="M1558 684q61 -356 298 -556q0 -52 -38 -90t-90 -38h-448q0 -106 -75 -181t-181 -75t-180.5 74.5t-75.5 180.5zM1024 -176q16 0 16 16t-16 16q-59 0 -101.5 42.5t-42.5 101.5q0 16 -16 16t-16 -16q0 -73 51.5 -124.5t124.5 -51.5zM2026 1424q8 -10 7.5 -23.5t-10.5 -22.5 l-1872 -1622q-10 -8 -23.5 -7t-21.5 11l-84 96q-8 10 -7.5 23.5t10.5 21.5l186 161q-19 32 -19 66q50 42 91 88t85 119.5t74.5 158.5t50 206t19.5 260q0 152 117 282.5t307 158.5q-8 19 -8 39q0 40 28 68t68 28t68 -28t28 -68q0 -20 -8 -39q124 -18 219 -82.5t148 -157.5 l418 363q10 8 23.5 7t21.5 -11z" /> -<glyph unicode="" horiz-adv-x="2048" d="M1040 -160q0 16 -16 16q-59 0 -101.5 42.5t-42.5 101.5q0 16 -16 16t-16 -16q0 -73 51.5 -124.5t124.5 -51.5q16 0 16 16zM503 315l877 760q-42 88 -132.5 146.5t-223.5 58.5q-93 0 -169.5 -31.5t-121.5 -80.5t-69 -103t-24 -105q0 -384 -137 -645zM1856 128 q0 -52 -38 -90t-90 -38h-448q0 -106 -75 -181t-181 -75t-180.5 74.5t-75.5 180.5l149 129h757q-166 187 -227 459l111 97q61 -356 298 -556zM1942 1520l84 -96q8 -10 7.5 -23.5t-10.5 -22.5l-1872 -1622q-10 -8 -23.5 -7t-21.5 11l-84 96q-8 10 -7.5 23.5t10.5 21.5l186 161 q-19 32 -19 66q50 42 91 88t85 119.5t74.5 158.5t50 206t19.5 260q0 152 117 282.5t307 158.5q-8 19 -8 39q0 40 28 68t68 28t68 -28t28 -68q0 -20 -8 -39q124 -18 219 -82.5t148 -157.5l418 363q10 8 23.5 7t21.5 -11z" /> -<glyph unicode="" horiz-adv-x="1408" d="M512 160v704q0 14 -9 23t-23 9h-64q-14 0 -23 -9t-9 -23v-704q0 -14 9 -23t23 -9h64q14 0 23 9t9 23zM768 160v704q0 14 -9 23t-23 9h-64q-14 0 -23 -9t-9 -23v-704q0 -14 9 -23t23 -9h64q14 0 23 9t9 23zM1024 160v704q0 14 -9 23t-23 9h-64q-14 0 -23 -9t-9 -23v-704 q0 -14 9 -23t23 -9h64q14 0 23 9t9 23zM480 1152h448l-48 117q-7 9 -17 11h-317q-10 -2 -17 -11zM1408 1120v-64q0 -14 -9 -23t-23 -9h-96v-948q0 -83 -47 -143.5t-113 -60.5h-832q-66 0 -113 58.5t-47 141.5v952h-96q-14 0 -23 9t-9 23v64q0 14 9 23t23 9h309l70 167 q15 37 54 63t79 26h320q40 0 79 -26t54 -63l70 -167h309q14 0 23 -9t9 -23z" /> -<glyph unicode="" d="M1150 462v-109q0 -50 -36.5 -89t-94 -60.5t-118 -32.5t-117.5 -11q-205 0 -342.5 139t-137.5 346q0 203 136 339t339 136q34 0 75.5 -4.5t93 -18t92.5 -34t69 -56.5t28 -81v-109q0 -16 -16 -16h-118q-16 0 -16 16v70q0 43 -65.5 67.5t-137.5 24.5q-140 0 -228.5 -91.5 t-88.5 -237.5q0 -151 91.5 -249.5t233.5 -98.5q68 0 138 24t70 66v70q0 7 4.5 11.5t10.5 4.5h119q6 0 11 -4.5t5 -11.5zM768 1280q-130 0 -248.5 -51t-204 -136.5t-136.5 -204t-51 -248.5t51 -248.5t136.5 -204t204 -136.5t248.5 -51t248.5 51t204 136.5t136.5 204t51 248.5 t-51 248.5t-136.5 204t-204 136.5t-248.5 51zM1536 640q0 -209 -103 -385.5t-279.5 -279.5t-385.5 -103t-385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103t385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" d="M972 761q0 108 -53.5 169t-147.5 61q-63 0 -124 -30.5t-110 -84.5t-79.5 -137t-30.5 -180q0 -112 53.5 -173t150.5 -61q96 0 176 66.5t122.5 166t42.5 203.5zM1536 640q0 -111 -37 -197t-98.5 -135t-131.5 -74.5t-145 -27.5q-6 0 -15.5 -0.5t-16.5 -0.5q-95 0 -142 53 q-28 33 -33 83q-52 -66 -131.5 -110t-173.5 -44q-161 0 -249.5 95.5t-88.5 269.5q0 157 66 290t179 210.5t246 77.5q87 0 155 -35.5t106 -99.5l2 19l11 56q1 6 5.5 12t9.5 6h118q5 0 13 -11q5 -5 3 -16l-120 -614q-5 -24 -5 -48q0 -39 12.5 -52t44.5 -13q28 1 57 5.5t73 24 t77 50t57 89.5t24 137q0 292 -174 466t-466 174q-130 0 -248.5 -51t-204 -136.5t-136.5 -204t-51 -248.5t51 -248.5t136.5 -204t204 -136.5t248.5 -51q228 0 405 144q11 9 24 8t21 -12l41 -49q8 -12 7 -24q-2 -13 -12 -22q-102 -83 -227.5 -128t-258.5 -45q-156 0 -298 61 t-245 164t-164 245t-61 298t61 298t164 245t245 164t298 61q344 0 556 -212t212 -556z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1698 1442q94 -94 94 -226.5t-94 -225.5l-225 -223l104 -104q10 -10 10 -23t-10 -23l-210 -210q-10 -10 -23 -10t-23 10l-105 105l-603 -603q-37 -37 -90 -37h-203l-256 -128l-64 64l128 256v203q0 53 37 90l603 603l-105 105q-10 10 -10 23t10 23l210 210q10 10 23 10 t23 -10l104 -104l223 225q93 94 225.5 94t226.5 -94zM512 64l576 576l-192 192l-576 -576v-192h192z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1615 1536q70 0 122.5 -46.5t52.5 -116.5q0 -63 -45 -151q-332 -629 -465 -752q-97 -91 -218 -91q-126 0 -216.5 92.5t-90.5 219.5q0 128 92 212l638 579q59 54 130 54zM706 502q39 -76 106.5 -130t150.5 -76l1 -71q4 -213 -129.5 -347t-348.5 -134q-123 0 -218 46.5 t-152.5 127.5t-86.5 183t-29 220q7 -5 41 -30t62 -44.5t59 -36.5t46 -17q41 0 55 37q25 66 57.5 112.5t69.5 76t88 47.5t103 25.5t125 10.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1792 128v-384h-1792v384q45 0 85 14t59 27.5t47 37.5q30 27 51.5 38t56.5 11t55.5 -11t52.5 -38q29 -25 47 -38t58 -27t86 -14q45 0 85 14.5t58 27t48 37.5q21 19 32.5 27t31 15t43.5 7q35 0 56.5 -11t51.5 -38q28 -24 47 -37.5t59 -27.5t85 -14t85 14t59 27.5t47 37.5 q30 27 51.5 38t56.5 11q34 0 55.5 -11t51.5 -38q28 -24 47 -37.5t59 -27.5t85 -14zM1792 448v-192q-35 0 -55.5 11t-52.5 38q-29 25 -47 38t-58 27t-85 14q-46 0 -86 -14t-58 -27t-47 -38q-22 -19 -33 -27t-31 -15t-44 -7q-35 0 -56.5 11t-51.5 38q-29 25 -47 38t-58 27 t-86 14q-45 0 -85 -14.5t-58 -27t-48 -37.5q-21 -19 -32.5 -27t-31 -15t-43.5 -7q-35 0 -56.5 11t-51.5 38q-28 24 -47 37.5t-59 27.5t-85 14q-46 0 -86 -14t-58 -27t-47 -38q-30 -27 -51.5 -38t-56.5 -11v192q0 80 56 136t136 56h64v448h256v-448h256v448h256v-448h256v448 h256v-448h64q80 0 136 -56t56 -136zM512 1312q0 -77 -36 -118.5t-92 -41.5q-53 0 -90.5 37.5t-37.5 90.5q0 29 9.5 51t23.5 34t31 28t31 31.5t23.5 44.5t9.5 67q38 0 83 -74t45 -150zM1024 1312q0 -77 -36 -118.5t-92 -41.5q-53 0 -90.5 37.5t-37.5 90.5q0 29 9.5 51 t23.5 34t31 28t31 31.5t23.5 44.5t9.5 67q38 0 83 -74t45 -150zM1536 1312q0 -77 -36 -118.5t-92 -41.5q-53 0 -90.5 37.5t-37.5 90.5q0 29 9.5 51t23.5 34t31 28t31 31.5t23.5 44.5t9.5 67q38 0 83 -74t45 -150z" /> -<glyph unicode="" horiz-adv-x="2048" d="M2048 0v-128h-2048v1536h128v-1408h1920zM1664 1024l256 -896h-1664v576l448 576l576 -576z" /> -<glyph unicode="" horiz-adv-x="1792" d="M768 646l546 -546q-106 -108 -247.5 -168t-298.5 -60q-209 0 -385.5 103t-279.5 279.5t-103 385.5t103 385.5t279.5 279.5t385.5 103v-762zM955 640h773q0 -157 -60 -298.5t-168 -247.5zM1664 768h-768v768q209 0 385.5 -103t279.5 -279.5t103 -385.5z" /> -<glyph unicode="" horiz-adv-x="2048" d="M2048 0v-128h-2048v1536h128v-1408h1920zM1920 1248v-435q0 -21 -19.5 -29.5t-35.5 7.5l-121 121l-633 -633q-10 -10 -23 -10t-23 10l-233 233l-416 -416l-192 192l585 585q10 10 23 10t23 -10l233 -233l464 464l-121 121q-16 16 -7.5 35.5t29.5 19.5h435q14 0 23 -9 t9 -23z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1292 832q0 -6 10 -41q10 -29 25 -49.5t41 -34t44 -20t55 -16.5q325 -91 325 -332q0 -146 -105.5 -242.5t-254.5 -96.5q-59 0 -111.5 18.5t-91.5 45.5t-77 74.5t-63 87.5t-53.5 103.5t-43.5 103t-39.5 106.5t-35.5 95q-32 81 -61.5 133.5t-73.5 96.5t-104 64t-142 20 q-96 0 -183 -55.5t-138 -144.5t-51 -185q0 -160 106.5 -279.5t263.5 -119.5q177 0 258 95q56 63 83 116l84 -152q-15 -34 -44 -70l1 -1q-131 -152 -388 -152q-147 0 -269.5 79t-190.5 207.5t-68 274.5q0 105 43.5 206t116 176.5t172 121.5t204.5 46q87 0 159 -19t123.5 -50 t95 -80t72.5 -99t58.5 -117t50.5 -124.5t50 -130.5t55 -127q96 -200 233 -200q81 0 138.5 48.5t57.5 128.5q0 42 -19 72t-50.5 46t-72.5 31.5t-84.5 27t-87.5 34t-81 52t-65 82t-39 122.5q-3 16 -3 33q0 110 87.5 192t198.5 78q78 -3 120.5 -14.5t90.5 -53.5h-1 q12 -11 23 -24.5t26 -36t19 -27.5l-129 -99q-26 49 -54 70v1q-23 21 -97 21q-49 0 -84 -33t-35 -83z" /> -<glyph unicode="" d="M1432 484q0 173 -234 239q-35 10 -53 16.5t-38 25t-29 46.5q0 2 -2 8.5t-3 12t-1 7.5q0 36 24.5 59.5t60.5 23.5q54 0 71 -15h-1q20 -15 39 -51l93 71q-39 54 -49 64q-33 29 -67.5 39t-85.5 10q-80 0 -142 -57.5t-62 -137.5q0 -7 2 -23q16 -96 64.5 -140t148.5 -73 q29 -8 49 -15.5t45 -21.5t38.5 -34.5t13.5 -46.5v-5q1 -58 -40.5 -93t-100.5 -35q-97 0 -167 144q-23 47 -51.5 121.5t-48 125.5t-54 110.5t-74 95.5t-103.5 60.5t-147 24.5q-101 0 -192 -56t-144 -148t-50 -192v-1q4 -108 50.5 -199t133.5 -147.5t196 -56.5q186 0 279 110 q20 27 31 51l-60 109q-42 -80 -99 -116t-146 -36q-115 0 -191 87t-76 204q0 105 82 189t186 84q112 0 170 -53.5t104 -172.5q8 -21 25.5 -68.5t28.5 -76.5t31.5 -74.5t38.5 -74t45.5 -62.5t55.5 -53.5t66 -33t80 -13.5q107 0 183 69.5t76 174.5zM1536 1120v-960 q0 -119 -84.5 -203.5t-203.5 -84.5h-960q-119 0 -203.5 84.5t-84.5 203.5v960q0 119 84.5 203.5t203.5 84.5h960q119 0 203.5 -84.5t84.5 -203.5z" /> -<glyph unicode="" horiz-adv-x="2048" d="M1152 640q0 104 -40.5 198.5t-109.5 163.5t-163.5 109.5t-198.5 40.5t-198.5 -40.5t-163.5 -109.5t-109.5 -163.5t-40.5 -198.5t40.5 -198.5t109.5 -163.5t163.5 -109.5t198.5 -40.5t198.5 40.5t163.5 109.5t109.5 163.5t40.5 198.5zM1920 640q0 104 -40.5 198.5 t-109.5 163.5t-163.5 109.5t-198.5 40.5h-386q119 -90 188.5 -224t69.5 -288t-69.5 -288t-188.5 -224h386q104 0 198.5 40.5t163.5 109.5t109.5 163.5t40.5 198.5zM2048 640q0 -130 -51 -248.5t-136.5 -204t-204 -136.5t-248.5 -51h-768q-130 0 -248.5 51t-204 136.5 t-136.5 204t-51 248.5t51 248.5t136.5 204t204 136.5t248.5 51h768q130 0 248.5 -51t204 -136.5t136.5 -204t51 -248.5z" /> -<glyph unicode="" horiz-adv-x="2048" d="M0 640q0 130 51 248.5t136.5 204t204 136.5t248.5 51h768q130 0 248.5 -51t204 -136.5t136.5 -204t51 -248.5t-51 -248.5t-136.5 -204t-204 -136.5t-248.5 -51h-768q-130 0 -248.5 51t-204 136.5t-136.5 204t-51 248.5zM1408 128q104 0 198.5 40.5t163.5 109.5 t109.5 163.5t40.5 198.5t-40.5 198.5t-109.5 163.5t-163.5 109.5t-198.5 40.5t-198.5 -40.5t-163.5 -109.5t-109.5 -163.5t-40.5 -198.5t40.5 -198.5t109.5 -163.5t163.5 -109.5t198.5 -40.5z" /> -<glyph unicode="" horiz-adv-x="2304" d="M762 384h-314q-40 0 -57.5 35t6.5 67l188 251q-65 31 -137 31q-132 0 -226 -94t-94 -226t94 -226t226 -94q115 0 203 72.5t111 183.5zM576 512h186q-18 85 -75 148zM1056 512l288 384h-480l-99 -132q105 -103 126 -252h165zM2176 448q0 132 -94 226t-226 94 q-60 0 -121 -24l174 -260q15 -23 10 -49t-27 -40q-15 -11 -36 -11q-35 0 -53 29l-174 260q-93 -95 -93 -225q0 -132 94 -226t226 -94t226 94t94 226zM2304 448q0 -185 -131.5 -316.5t-316.5 -131.5t-316.5 131.5t-131.5 316.5q0 97 39.5 183.5t109.5 149.5l-65 98l-353 -469 q-18 -26 -51 -26h-197q-23 -164 -149 -274t-294 -110q-185 0 -316.5 131.5t-131.5 316.5t131.5 316.5t316.5 131.5q114 0 215 -55l137 183h-224q-26 0 -45 19t-19 45t19 45t45 19h384v-128h435l-85 128h-222q-26 0 -45 19t-19 45t19 45t45 19h256q33 0 53 -28l267 -400 q91 44 192 44q185 0 316.5 -131.5t131.5 -316.5z" /> -<glyph unicode="" d="M384 320q0 53 -37.5 90.5t-90.5 37.5t-90.5 -37.5t-37.5 -90.5t37.5 -90.5t90.5 -37.5t90.5 37.5t37.5 90.5zM1408 320q0 53 -37.5 90.5t-90.5 37.5t-90.5 -37.5t-37.5 -90.5t37.5 -90.5t90.5 -37.5t90.5 37.5t37.5 90.5zM1362 716l-72 384q-5 23 -22.5 37.5t-40.5 14.5 h-918q-23 0 -40.5 -14.5t-22.5 -37.5l-72 -384q-5 -30 14 -53t49 -23h1062q30 0 49 23t14 53zM1136 1328q0 20 -14 34t-34 14h-640q-20 0 -34 -14t-14 -34t14 -34t34 -14h640q20 0 34 14t14 34zM1536 603v-603h-128v-128q0 -53 -37.5 -90.5t-90.5 -37.5t-90.5 37.5 t-37.5 90.5v128h-768v-128q0 -53 -37.5 -90.5t-90.5 -37.5t-90.5 37.5t-37.5 90.5v128h-128v603q0 112 25 223l103 454q9 78 97.5 137t230 89t312.5 30t312.5 -30t230 -89t97.5 -137l105 -454q23 -102 23 -223z" /> -<glyph unicode="" horiz-adv-x="2048" d="M1463 704q0 -35 -25 -60.5t-61 -25.5h-702q-36 0 -61 25.5t-25 60.5t25 60.5t61 25.5h702q36 0 61 -25.5t25 -60.5zM1677 704q0 86 -23 170h-982q-36 0 -61 25t-25 60q0 36 25 61t61 25h908q-88 143 -235 227t-320 84q-177 0 -327.5 -87.5t-238 -237.5t-87.5 -327 q0 -86 23 -170h982q36 0 61 -25t25 -60q0 -36 -25 -61t-61 -25h-908q88 -143 235.5 -227t320.5 -84q132 0 253 51.5t208 139t139 208t52 253.5zM2048 959q0 -35 -25 -60t-61 -25h-131q17 -85 17 -170q0 -167 -65.5 -319.5t-175.5 -263t-262.5 -176t-319.5 -65.5 q-246 0 -448.5 133t-301.5 350h-189q-36 0 -61 25t-25 61q0 35 25 60t61 25h132q-17 85 -17 170q0 167 65.5 319.5t175.5 263t262.5 176t320.5 65.5q245 0 447.5 -133t301.5 -350h188q36 0 61 -25t25 -61z" /> -<glyph unicode="" horiz-adv-x="1280" d="M953 1158l-114 -328l117 -21q165 451 165 518q0 56 -38 56q-57 0 -130 -225zM654 471l33 -88q37 42 71 67l-33 5.5t-38.5 7t-32.5 8.5zM362 1367q0 -98 159 -521q18 10 49 10q15 0 75 -5l-121 351q-75 220 -123 220q-19 0 -29 -17.5t-10 -37.5zM283 608q0 -36 51.5 -119 t117.5 -153t100 -70q14 0 25.5 13t11.5 27q0 24 -32 102q-13 32 -32 72t-47.5 89t-61.5 81t-62 32q-20 0 -45.5 -27t-25.5 -47zM125 273q0 -41 25 -104q59 -145 183.5 -227t281.5 -82q227 0 382 170q152 169 152 427q0 43 -1 67t-11.5 62t-30.5 56q-56 49 -211.5 75.5 t-270.5 26.5q-37 0 -49 -11q-12 -5 -12 -35q0 -34 21.5 -60t55.5 -40t77.5 -23.5t87.5 -11.5t85 -4t70 0h23q24 0 40 -19q15 -19 19 -55q-28 -28 -96 -54q-61 -22 -93 -46q-64 -46 -108.5 -114t-44.5 -137q0 -31 18.5 -88.5t18.5 -87.5l-3 -12q-4 -12 -4 -14 q-137 10 -146 216q-8 -2 -41 -2q2 -7 2 -21q0 -53 -40.5 -89.5t-94.5 -36.5q-82 0 -166.5 78t-84.5 159q0 34 33 67q52 -64 60 -76q77 -104 133 -104q12 0 26.5 8.5t14.5 20.5q0 34 -87.5 145t-116.5 111q-43 0 -70 -44.5t-27 -90.5zM11 264q0 101 42.5 163t136.5 88 q-28 74 -28 104q0 62 61 123t122 61q29 0 70 -15q-163 462 -163 567q0 80 41 130.5t119 50.5q131 0 325 -581q6 -17 8 -23q6 16 29 79.5t43.5 118.5t54 127.5t64.5 123t70.5 86.5t76.5 36q71 0 112 -49t41 -122q0 -108 -159 -550q61 -15 100.5 -46t58.5 -78t26 -93.5 t7 -110.5q0 -150 -47 -280t-132 -225t-211 -150t-278 -55q-111 0 -223 42q-149 57 -258 191.5t-109 286.5z" /> -<glyph unicode="" horiz-adv-x="2048" d="M785 528h207q-14 -158 -98.5 -248.5t-214.5 -90.5q-162 0 -254.5 116t-92.5 316q0 194 93 311.5t233 117.5q148 0 232 -87t97 -247h-203q-5 64 -35.5 99t-81.5 35q-57 0 -88.5 -60.5t-31.5 -177.5q0 -48 5 -84t18 -69.5t40 -51.5t66 -18q95 0 109 139zM1497 528h206 q-14 -158 -98 -248.5t-214 -90.5q-162 0 -254.5 116t-92.5 316q0 194 93 311.5t233 117.5q148 0 232 -87t97 -247h-204q-4 64 -35 99t-81 35q-57 0 -88.5 -60.5t-31.5 -177.5q0 -48 5 -84t18 -69.5t39.5 -51.5t65.5 -18q49 0 76.5 38t33.5 101zM1856 647q0 207 -15.5 307 t-60.5 161q-6 8 -13.5 14t-21.5 15t-16 11q-86 63 -697 63q-625 0 -710 -63q-5 -4 -17.5 -11.5t-21 -14t-14.5 -14.5q-45 -60 -60 -159.5t-15 -308.5q0 -208 15 -307.5t60 -160.5q6 -8 15 -15t20.5 -14t17.5 -12q44 -33 239.5 -49t470.5 -16q610 0 697 65q5 4 17 11t20.5 14 t13.5 16q46 60 61 159t15 309zM2048 1408v-1536h-2048v1536h2048z" /> -<glyph unicode="" d="M992 912v-496q0 -14 -9 -23t-23 -9h-160q-14 0 -23 9t-9 23v496q0 112 -80 192t-192 80h-272v-1152q0 -14 -9 -23t-23 -9h-160q-14 0 -23 9t-9 23v1344q0 14 9 23t23 9h464q135 0 249 -66.5t180.5 -180.5t66.5 -249zM1376 1376v-880q0 -135 -66.5 -249t-180.5 -180.5 t-249 -66.5h-464q-14 0 -23 9t-9 23v960q0 14 9 23t23 9h160q14 0 23 -9t9 -23v-768h272q112 0 192 80t80 192v880q0 14 9 23t23 9h160q14 0 23 -9t9 -23z" /> -<glyph unicode="" d="M1311 694v-114q0 -24 -13.5 -38t-37.5 -14h-202q-24 0 -38 14t-14 38v114q0 24 14 38t38 14h202q24 0 37.5 -14t13.5 -38zM821 464v250q0 53 -32.5 85.5t-85.5 32.5h-133q-68 0 -96 -52q-28 52 -96 52h-130q-53 0 -85.5 -32.5t-32.5 -85.5v-250q0 -22 21 -22h55 q22 0 22 22v230q0 24 13.5 38t38.5 14h94q24 0 38 -14t14 -38v-230q0 -22 21 -22h54q22 0 22 22v230q0 24 14 38t38 14h97q24 0 37.5 -14t13.5 -38v-230q0 -22 22 -22h55q21 0 21 22zM1410 560v154q0 53 -33 85.5t-86 32.5h-264q-53 0 -86 -32.5t-33 -85.5v-410 q0 -21 22 -21h55q21 0 21 21v180q31 -42 94 -42h191q53 0 86 32.5t33 85.5zM1536 1176v-1072q0 -96 -68 -164t-164 -68h-1072q-96 0 -164 68t-68 164v1072q0 96 68 164t164 68h1072q96 0 164 -68t68 -164z" /> -<glyph unicode="" d="M915 450h-294l147 551zM1001 128h311l-324 1024h-440l-324 -1024h311l383 314zM1536 1120v-960q0 -118 -85 -203t-203 -85h-960q-118 0 -203 85t-85 203v960q0 118 85 203t203 85h960q118 0 203 -85t85 -203z" /> -<glyph unicode="" horiz-adv-x="2048" d="M2048 641q0 -21 -13 -36.5t-33 -19.5l-205 -356q3 -9 3 -18q0 -20 -12.5 -35.5t-32.5 -19.5l-193 -337q3 -8 3 -16q0 -23 -16.5 -40t-40.5 -17q-25 0 -41 18h-400q-17 -20 -43 -20t-43 20h-399q-17 -20 -43 -20q-23 0 -40 16.5t-17 40.5q0 8 4 20l-193 335 q-20 4 -32.5 19.5t-12.5 35.5q0 9 3 18l-206 356q-20 5 -32.5 20.5t-12.5 35.5q0 21 13.5 36.5t33.5 19.5l199 344q0 1 -0.5 3t-0.5 3q0 36 34 51l209 363q-4 10 -4 18q0 24 17 40.5t40 16.5q26 0 44 -21h396q16 21 43 21t43 -21h398q18 21 44 21q23 0 40 -16.5t17 -40.5 q0 -6 -4 -18l207 -358q23 -1 39 -17.5t16 -38.5q0 -13 -7 -27l187 -324q19 -4 31.5 -19.5t12.5 -35.5zM1063 -158h389l-342 354h-143l-342 -354h360q18 16 39 16t39 -16zM112 654q1 -4 1 -13q0 -10 -2 -15l208 -360q2 0 4.5 -1t5.5 -2.5l5 -2.5l188 199v347l-187 194 q-13 -8 -29 -10zM986 1438h-388l190 -200l554 200h-280q-16 -16 -38 -16t-38 16zM1689 226q1 6 5 11l-64 68l-17 -79h76zM1583 226l22 105l-252 266l-296 -307l63 -64h463zM1495 -142l16 28l65 310h-427l333 -343q8 4 13 5zM578 -158h5l342 354h-373v-335l4 -6q14 -5 22 -13 zM552 226h402l64 66l-309 321l-157 -166v-221zM359 226h163v189l-168 -177q4 -8 5 -12zM358 1051q0 -1 0.5 -2t0.5 -2q0 -16 -8 -29l171 -177v269zM552 1121v-311l153 -157l297 314l-223 236zM556 1425l-4 -8v-264l205 74l-191 201q-6 -2 -10 -3zM1447 1438h-16l-621 -224 l213 -225zM1023 946l-297 -315l311 -319l296 307zM688 634l-136 141v-284zM1038 270l-42 -44h85zM1374 618l238 -251l132 624l-3 5l-1 1zM1718 1018q-8 13 -8 29v2l-216 376q-5 1 -13 5l-437 -463l310 -327zM522 1142v223l-163 -282zM522 196h-163l163 -283v283zM1607 196 l-48 -227l130 227h-82zM1729 266l207 361q-2 10 -2 14q0 1 3 16l-171 296l-129 -612l77 -82q5 3 15 7z" /> -<glyph unicode="" d="M0 856q0 131 91.5 226.5t222.5 95.5h742l352 358v-1470q0 -132 -91.5 -227t-222.5 -95h-780q-131 0 -222.5 95t-91.5 227v790zM1232 102l-176 180v425q0 46 -32 79t-78 33h-484q-46 0 -78 -33t-32 -79v-492q0 -46 32.5 -79.5t77.5 -33.5h770z" /> -<glyph unicode="" d="M934 1386q-317 -121 -556 -362.5t-358 -560.5q-20 89 -20 176q0 208 102.5 384.5t278.5 279t384 102.5q82 0 169 -19zM1203 1267q93 -65 164 -155q-389 -113 -674.5 -400.5t-396.5 -676.5q-93 72 -155 162q112 386 395 671t667 399zM470 -67q115 356 379.5 622t619.5 384 q40 -92 54 -195q-292 -120 -516 -345t-343 -518q-103 14 -194 52zM1536 -125q-193 50 -367 115q-135 -84 -290 -107q109 205 274 370.5t369 275.5q-21 -152 -101 -284q65 -175 115 -370z" /> -<glyph unicode="" horiz-adv-x="2048" d="M1893 1144l155 -1272q-131 0 -257 57q-200 91 -393 91q-226 0 -374 -148q-148 148 -374 148q-193 0 -393 -91q-128 -57 -252 -57h-5l155 1272q224 127 482 127q233 0 387 -106q154 106 387 106q258 0 482 -127zM1398 157q129 0 232 -28.5t260 -93.5l-124 1021 q-171 78 -368 78q-224 0 -374 -141q-150 141 -374 141q-197 0 -368 -78l-124 -1021q105 43 165.5 65t148.5 39.5t178 17.5q202 0 374 -108q172 108 374 108zM1438 191l-55 907q-211 -4 -359 -155q-152 155 -374 155q-176 0 -336 -66l-114 -941q124 51 228.5 76t221.5 25 q209 0 374 -102q172 107 374 102z" /> -<glyph unicode="" horiz-adv-x="2048" d="M1500 165v733q0 21 -15 36t-35 15h-93q-20 0 -35 -15t-15 -36v-733q0 -20 15 -35t35 -15h93q20 0 35 15t15 35zM1216 165v531q0 20 -15 35t-35 15h-101q-20 0 -35 -15t-15 -35v-531q0 -20 15 -35t35 -15h101q20 0 35 15t15 35zM924 165v429q0 20 -15 35t-35 15h-101 q-20 0 -35 -15t-15 -35v-429q0 -20 15 -35t35 -15h101q20 0 35 15t15 35zM632 165v362q0 20 -15 35t-35 15h-101q-20 0 -35 -15t-15 -35v-362q0 -20 15 -35t35 -15h101q20 0 35 15t15 35zM2048 311q0 -166 -118 -284t-284 -118h-1244q-166 0 -284 118t-118 284 q0 116 63 214.5t168 148.5q-10 34 -10 73q0 113 80.5 193.5t193.5 80.5q102 0 180 -67q45 183 194 300t338 117q149 0 275 -73.5t199.5 -199.5t73.5 -275q0 -66 -14 -122q135 -33 221 -142.5t86 -247.5z" /> -<glyph unicode="" d="M0 1536h1536v-1392l-776 -338l-760 338v1392zM1436 209v926h-1336v-926l661 -294zM1436 1235v201h-1336v-201h1336zM181 937v-115h-37v115h37zM181 789v-115h-37v115h37zM181 641v-115h-37v115h37zM181 493v-115h-37v115h37zM181 345v-115h-37v115h37zM207 202l15 34 l105 -47l-15 -33zM343 142l15 34l105 -46l-15 -34zM478 82l15 34l105 -46l-15 -34zM614 23l15 33l104 -46l-15 -34zM797 10l105 46l15 -33l-105 -47zM932 70l105 46l15 -34l-105 -46zM1068 130l105 46l15 -34l-105 -46zM1203 189l105 47l15 -34l-105 -46zM259 1389v-36h-114 v36h114zM421 1389v-36h-115v36h115zM583 1389v-36h-115v36h115zM744 1389v-36h-114v36h114zM906 1389v-36h-114v36h114zM1068 1389v-36h-115v36h115zM1230 1389v-36h-115v36h115zM1391 1389v-36h-114v36h114zM181 1049v-79h-37v115h115v-36h-78zM421 1085v-36h-115v36h115z M583 1085v-36h-115v36h115zM744 1085v-36h-114v36h114zM906 1085v-36h-114v36h114zM1068 1085v-36h-115v36h115zM1230 1085v-36h-115v36h115zM1355 970v79h-78v36h115v-115h-37zM1355 822v115h37v-115h-37zM1355 674v115h37v-115h-37zM1355 526v115h37v-115h-37zM1355 378 v115h37v-115h-37zM1355 230v115h37v-115h-37zM760 265q-129 0 -221 91.5t-92 221.5q0 129 92 221t221 92q130 0 221.5 -92t91.5 -221q0 -130 -91.5 -221.5t-221.5 -91.5zM595 646q0 -36 19.5 -56.5t49.5 -25t64 -7t64 -2t49.5 -9t19.5 -30.5q0 -49 -112 -49q-97 0 -123 51 h-3l-31 -63q67 -42 162 -42q29 0 56.5 5t55.5 16t45.5 33t17.5 53q0 46 -27.5 69.5t-67.5 27t-79.5 3t-67 5t-27.5 25.5q0 21 20.5 33t40.5 15t41 3q34 0 70.5 -11t51.5 -34h3l30 58q-3 1 -21 8.5t-22.5 9t-19.5 7t-22 7t-20 4.5t-24 4t-23 1q-29 0 -56.5 -5t-54 -16.5 t-43 -34t-16.5 -53.5z" /> -<glyph unicode="" horiz-adv-x="2048" d="M863 504q0 112 -79.5 191.5t-191.5 79.5t-191 -79.5t-79 -191.5t79 -191t191 -79t191.5 79t79.5 191zM1726 505q0 112 -79 191t-191 79t-191.5 -79t-79.5 -191q0 -113 79.5 -192t191.5 -79t191 79.5t79 191.5zM2048 1314v-1348q0 -44 -31.5 -75.5t-76.5 -31.5h-1832 q-45 0 -76.5 31.5t-31.5 75.5v1348q0 44 31.5 75.5t76.5 31.5h431q44 0 76 -31.5t32 -75.5v-161h754v161q0 44 32 75.5t76 31.5h431q45 0 76.5 -31.5t31.5 -75.5z" /> -<glyph unicode="" horiz-adv-x="2048" d="M1430 953zM1690 749q148 0 253 -98.5t105 -244.5q0 -157 -109 -261.5t-267 -104.5q-85 0 -162 27.5t-138 73.5t-118 106t-109 126.5t-103.5 132.5t-108.5 126t-117 106t-136 73.5t-159 27.5q-154 0 -251.5 -91.5t-97.5 -244.5q0 -157 104 -250t263 -93q100 0 208 37.5 t193 98.5q5 4 21 18.5t30 24t22 9.5q14 0 24.5 -10.5t10.5 -24.5q0 -24 -60 -77q-101 -88 -234.5 -142t-260.5 -54q-133 0 -245.5 58t-180 165t-67.5 241q0 205 141.5 341t347.5 136q120 0 226.5 -43.5t185.5 -113t151.5 -153t139 -167.5t133.5 -153.5t149.5 -113 t172.5 -43.5q102 0 168.5 61.5t66.5 162.5q0 95 -64.5 159t-159.5 64q-30 0 -81.5 -18.5t-68.5 -18.5q-20 0 -35.5 15t-15.5 35q0 18 8.5 57t8.5 59q0 159 -107.5 263t-266.5 104q-58 0 -111.5 -18.5t-84 -40.5t-55.5 -40.5t-33 -18.5q-15 0 -25.5 10.5t-10.5 25.5 q0 19 25 46q59 67 147 103.5t182 36.5q191 0 318 -125.5t127 -315.5q0 -37 -4 -66q57 15 115 15z" /> -<glyph unicode="" horiz-adv-x="1664" d="M1216 832q0 26 -19 45t-45 19h-128v128q0 26 -19 45t-45 19t-45 -19t-19 -45v-128h-128q-26 0 -45 -19t-19 -45t19 -45t45 -19h128v-128q0 -26 19 -45t45 -19t45 19t19 45v128h128q26 0 45 19t19 45zM640 0q0 -53 -37.5 -90.5t-90.5 -37.5t-90.5 37.5t-37.5 90.5 t37.5 90.5t90.5 37.5t90.5 -37.5t37.5 -90.5zM1536 0q0 -53 -37.5 -90.5t-90.5 -37.5t-90.5 37.5t-37.5 90.5t37.5 90.5t90.5 37.5t90.5 -37.5t37.5 -90.5zM1664 1088v-512q0 -24 -16 -42.5t-41 -21.5l-1044 -122q1 -7 4.5 -21.5t6 -26.5t2.5 -22q0 -16 -24 -64h920 q26 0 45 -19t19 -45t-19 -45t-45 -19h-1024q-26 0 -45 19t-19 45q0 14 11 39.5t29.5 59.5t20.5 38l-177 823h-204q-26 0 -45 19t-19 45t19 45t45 19h256q16 0 28.5 -6.5t20 -15.5t13 -24.5t7.5 -26.5t5.5 -29.5t4.5 -25.5h1201q26 0 45 -19t19 -45z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1280 832q0 26 -19 45t-45 19t-45 -19l-147 -146v293q0 26 -19 45t-45 19t-45 -19t-19 -45v-293l-147 146q-19 19 -45 19t-45 -19t-19 -45t19 -45l256 -256q19 -19 45 -19t45 19l256 256q19 19 19 45zM640 0q0 -53 -37.5 -90.5t-90.5 -37.5t-90.5 37.5t-37.5 90.5 t37.5 90.5t90.5 37.5t90.5 -37.5t37.5 -90.5zM1536 0q0 -53 -37.5 -90.5t-90.5 -37.5t-90.5 37.5t-37.5 90.5t37.5 90.5t90.5 37.5t90.5 -37.5t37.5 -90.5zM1664 1088v-512q0 -24 -16 -42.5t-41 -21.5l-1044 -122q1 -7 4.5 -21.5t6 -26.5t2.5 -22q0 -16 -24 -64h920 q26 0 45 -19t19 -45t-19 -45t-45 -19h-1024q-26 0 -45 19t-19 45q0 14 11 39.5t29.5 59.5t20.5 38l-177 823h-204q-26 0 -45 19t-19 45t19 45t45 19h256q16 0 28.5 -6.5t20 -15.5t13 -24.5t7.5 -26.5t5.5 -29.5t4.5 -25.5h1201q26 0 45 -19t19 -45z" /> -<glyph unicode="" horiz-adv-x="2048" d="M212 768l623 -665l-300 665h-323zM1024 -4l349 772h-698zM538 896l204 384h-262l-288 -384h346zM1213 103l623 665h-323zM683 896h682l-204 384h-274zM1510 896h346l-288 384h-262zM1651 1382l384 -512q14 -18 13 -41.5t-17 -40.5l-960 -1024q-18 -20 -47 -20t-47 20 l-960 1024q-16 17 -17 40.5t13 41.5l384 512q18 26 51 26h1152q33 0 51 -26z" /> -<glyph unicode="" horiz-adv-x="2048" d="M1811 -19q19 19 45 19t45 -19l128 -128l-90 -90l-83 83l-83 -83q-18 -19 -45 -19t-45 19l-83 83l-83 -83q-19 -19 -45 -19t-45 19l-83 83l-83 -83q-19 -19 -45 -19t-45 19l-83 83l-83 -83q-19 -19 -45 -19t-45 19l-83 83l-83 -83q-19 -19 -45 -19t-45 19l-83 83l-83 -83 q-19 -19 -45 -19t-45 19l-83 83l-83 -83q-19 -19 -45 -19t-45 19l-128 128l90 90l83 -83l83 83q19 19 45 19t45 -19l83 -83l83 83q19 19 45 19t45 -19l83 -83l83 83q19 19 45 19t45 -19l83 -83l83 83q19 19 45 19t45 -19l83 -83l83 83q19 19 45 19t45 -19l83 -83l83 83 q19 19 45 19t45 -19l83 -83zM237 19q-19 -19 -45 -19t-45 19l-128 128l90 90l83 -82l83 82q19 19 45 19t45 -19l83 -82l64 64v293l-210 314q-17 26 -7 56.5t40 40.5l177 58v299h128v128h256v128h256v-128h256v-128h128v-299l177 -58q30 -10 40 -40.5t-7 -56.5l-210 -314 v-293l19 18q19 19 45 19t45 -19l83 -82l83 82q19 19 45 19t45 -19l128 -128l-90 -90l-83 83l-83 -83q-18 -19 -45 -19t-45 19l-83 83l-83 -83q-19 -19 -45 -19t-45 19l-83 83l-83 -83q-19 -19 -45 -19t-45 19l-83 83l-83 -83q-19 -19 -45 -19t-45 19l-83 83l-83 -83 q-19 -19 -45 -19t-45 19l-83 83l-83 -83q-19 -19 -45 -19t-45 19l-83 83zM640 1152v-128l384 128l384 -128v128h-128v128h-512v-128h-128z" /> -<glyph unicode="" d="M576 0l96 448l-96 128l-128 64zM832 0l128 640l-128 -64l-96 -128zM992 1010q-2 4 -4 6q-10 8 -96 8q-70 0 -167 -19q-7 -2 -21 -2t-21 2q-97 19 -167 19q-86 0 -96 -8q-2 -2 -4 -6q2 -18 4 -27q2 -3 7.5 -6.5t7.5 -10.5q2 -4 7.5 -20.5t7 -20.5t7.5 -17t8.5 -17t9 -14 t12 -13.5t14 -9.5t17.5 -8t20.5 -4t24.5 -2q36 0 59 12.5t32.5 30t14.5 34.5t11.5 29.5t17.5 12.5h12q11 0 17.5 -12.5t11.5 -29.5t14.5 -34.5t32.5 -30t59 -12.5q13 0 24.5 2t20.5 4t17.5 8t14 9.5t12 13.5t9 14t8.5 17t7.5 17t7 20.5t7.5 20.5q2 7 7.5 10.5t7.5 6.5 q2 9 4 27zM1408 131q0 -121 -73 -190t-194 -69h-874q-121 0 -194 69t-73 190q0 61 4.5 118t19 125.5t37.5 123.5t63.5 103.5t93.5 74.5l-90 220h214q-22 64 -22 128q0 12 2 32q-194 40 -194 96q0 57 210 99q17 62 51.5 134t70.5 114q32 37 76 37q30 0 84 -31t84 -31t84 31 t84 31q44 0 76 -37q36 -42 70.5 -114t51.5 -134q210 -42 210 -99q0 -56 -194 -96q7 -81 -20 -160h214l-82 -225q63 -33 107.5 -96.5t65.5 -143.5t29 -151.5t8 -148.5z" /> -<glyph unicode="" horiz-adv-x="2304" d="M2301 500q12 -103 -22 -198.5t-99 -163.5t-158.5 -106t-196.5 -31q-161 11 -279.5 125t-134.5 274q-12 111 27.5 210.5t118.5 170.5l-71 107q-96 -80 -151 -194t-55 -244q0 -27 -18.5 -46.5t-45.5 -19.5h-256h-69q-23 -164 -149 -274t-294 -110q-185 0 -316.5 131.5 t-131.5 316.5t131.5 316.5t316.5 131.5q76 0 152 -27l24 45q-123 110 -304 110h-64q-26 0 -45 19t-19 45t19 45t45 19h128q78 0 145 -13.5t116.5 -38.5t71.5 -39.5t51 -36.5h512h115l-85 128h-222q-30 0 -49 22.5t-14 52.5q4 23 23 38t43 15h253q33 0 53 -28l70 -105 l114 114q19 19 46 19h101q26 0 45 -19t19 -45v-128q0 -26 -19 -45t-45 -19h-179l115 -172q131 63 275 36q143 -26 244 -134.5t118 -253.5zM448 128q115 0 203 72.5t111 183.5h-314q-35 0 -55 31q-18 32 -1 63l147 277q-47 13 -91 13q-132 0 -226 -94t-94 -226t94 -226 t226 -94zM1856 128q132 0 226 94t94 226t-94 226t-226 94q-60 0 -121 -24l174 -260q15 -23 10 -49t-27 -40q-15 -11 -36 -11q-35 0 -53 29l-174 260q-93 -95 -93 -225q0 -132 94 -226t226 -94z" /> -<glyph unicode="" d="M1408 0q0 -63 -61.5 -113.5t-164 -81t-225 -46t-253.5 -15.5t-253.5 15.5t-225 46t-164 81t-61.5 113.5q0 49 33 88.5t91 66.5t118 44.5t131 29.5q26 5 48 -10.5t26 -41.5q5 -26 -10.5 -48t-41.5 -26q-58 -10 -106 -23.5t-76.5 -25.5t-48.5 -23.5t-27.5 -19.5t-8.5 -12 q3 -11 27 -26.5t73 -33t114 -32.5t160.5 -25t201.5 -10t201.5 10t160.5 25t114 33t73 33.5t27 27.5q-1 4 -8.5 11t-27.5 19t-48.5 23.5t-76.5 25t-106 23.5q-26 4 -41.5 26t-10.5 48q4 26 26 41.5t48 10.5q71 -12 131 -29.5t118 -44.5t91 -66.5t33 -88.5zM1024 896v-384 q0 -26 -19 -45t-45 -19h-64v-384q0 -26 -19 -45t-45 -19h-256q-26 0 -45 19t-19 45v384h-64q-26 0 -45 19t-19 45v384q0 53 37.5 90.5t90.5 37.5h384q53 0 90.5 -37.5t37.5 -90.5zM928 1280q0 -93 -65.5 -158.5t-158.5 -65.5t-158.5 65.5t-65.5 158.5t65.5 158.5t158.5 65.5 t158.5 -65.5t65.5 -158.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1280 512h305q-5 -6 -10 -10.5t-9 -7.5l-3 -4l-623 -600q-18 -18 -44 -18t-44 18l-624 602q-5 2 -21 20h369q22 0 39.5 13.5t22.5 34.5l70 281l190 -667q6 -20 23 -33t39 -13q21 0 38 13t23 33l146 485l56 -112q18 -35 57 -35zM1792 940q0 -145 -103 -300h-369l-111 221 q-8 17 -25.5 27t-36.5 8q-45 -5 -56 -46l-129 -430l-196 686q-6 20 -23.5 33t-39.5 13t-39 -13.5t-22 -34.5l-116 -464h-423q-103 155 -103 300q0 220 127 344t351 124q62 0 126.5 -21.5t120 -58t95.5 -68.5t76 -68q36 36 76 68t95.5 68.5t120 58t126.5 21.5q224 0 351 -124 t127 -344z" /> -<glyph unicode="" horiz-adv-x="1280" d="M1152 960q0 -221 -147.5 -384.5t-364.5 -187.5v-260h224q14 0 23 -9t9 -23v-64q0 -14 -9 -23t-23 -9h-224v-224q0 -14 -9 -23t-23 -9h-64q-14 0 -23 9t-9 23v224h-224q-14 0 -23 9t-9 23v64q0 14 9 23t23 9h224v260q-150 16 -271.5 103t-186 224t-52.5 292 q11 134 80.5 249t182 188t245.5 88q170 19 319 -54t236 -212t87 -306zM128 960q0 -185 131.5 -316.5t316.5 -131.5t316.5 131.5t131.5 316.5t-131.5 316.5t-316.5 131.5t-316.5 -131.5t-131.5 -316.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1280 1504q0 14 9 23t23 9h416q26 0 45 -19t19 -45v-416q0 -14 -9 -23t-23 -9h-64q-14 0 -23 9t-9 23v262l-419 -420q87 -104 129.5 -236.5t30.5 -276.5q-22 -250 -200.5 -431t-428.5 -206q-163 -17 -314 39.5t-256.5 162t-162 256.5t-39.5 314q25 250 206 428.5 t431 200.5q144 12 276.5 -30.5t236.5 -129.5l419 419h-261q-14 0 -23 9t-9 23v64zM704 -128q117 0 223.5 45.5t184 123t123 184t45.5 223.5t-45.5 223.5t-123 184t-184 123t-223.5 45.5t-223.5 -45.5t-184 -123t-123 -184t-45.5 -223.5t45.5 -223.5t123 -184t184 -123 t223.5 -45.5z" /> -<glyph unicode="" horiz-adv-x="1280" d="M830 1220q145 -72 233.5 -210.5t88.5 -305.5q0 -221 -147.5 -384.5t-364.5 -187.5v-132h96q14 0 23 -9t9 -23v-64q0 -14 -9 -23t-23 -9h-96v-96q0 -14 -9 -23t-23 -9h-64q-14 0 -23 9t-9 23v96h-96q-14 0 -23 9t-9 23v64q0 14 9 23t23 9h96v132q-217 24 -364.5 187.5 t-147.5 384.5q0 167 88.5 305.5t233.5 210.5q-165 96 -228 273q-6 16 3.5 29.5t26.5 13.5h69q21 0 29 -20q44 -106 140 -171t214 -65t214 65t140 171q8 20 37 20h61q17 0 26.5 -13.5t3.5 -29.5q-63 -177 -228 -273zM576 256q185 0 316.5 131.5t131.5 316.5t-131.5 316.5 t-316.5 131.5t-316.5 -131.5t-131.5 -316.5t131.5 -316.5t316.5 -131.5z" /> -<glyph unicode="" d="M1024 1504q0 14 9 23t23 9h288q26 0 45 -19t19 -45v-288q0 -14 -9 -23t-23 -9h-64q-14 0 -23 9t-9 23v134l-254 -255q126 -158 126 -359q0 -221 -147.5 -384.5t-364.5 -187.5v-132h96q14 0 23 -9t9 -23v-64q0 -14 -9 -23t-23 -9h-96v-96q0 -14 -9 -23t-23 -9h-64 q-14 0 -23 9t-9 23v96h-96q-14 0 -23 9t-9 23v64q0 14 9 23t23 9h96v132q-149 16 -270.5 103t-186.5 223.5t-53 291.5q16 204 160 353.5t347 172.5q118 14 228 -19t198 -103l255 254h-134q-14 0 -23 9t-9 23v64zM576 256q185 0 316.5 131.5t131.5 316.5t-131.5 316.5 t-316.5 131.5t-316.5 -131.5t-131.5 -316.5t131.5 -316.5t316.5 -131.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1280 1504q0 14 9 23t23 9h288q26 0 45 -19t19 -45v-288q0 -14 -9 -23t-23 -9h-64q-14 0 -23 9t-9 23v134l-254 -255q126 -158 126 -359q0 -221 -147.5 -384.5t-364.5 -187.5v-132h96q14 0 23 -9t9 -23v-64q0 -14 -9 -23t-23 -9h-96v-96q0 -14 -9 -23t-23 -9h-64 q-14 0 -23 9t-9 23v96h-96q-14 0 -23 9t-9 23v64q0 14 9 23t23 9h96v132q-217 24 -364.5 187.5t-147.5 384.5q0 201 126 359l-52 53l-101 -111q-9 -10 -22 -10.5t-23 7.5l-48 44q-10 8 -10.5 21.5t8.5 23.5l105 115l-111 112v-134q0 -14 -9 -23t-23 -9h-64q-14 0 -23 9 t-9 23v288q0 26 19 45t45 19h288q14 0 23 -9t9 -23v-64q0 -14 -9 -23t-23 -9h-133l106 -107l86 94q9 10 22 10.5t23 -7.5l48 -44q10 -8 10.5 -21.5t-8.5 -23.5l-90 -99l57 -56q158 126 359 126t359 -126l255 254h-134q-14 0 -23 9t-9 23v64zM832 256q185 0 316.5 131.5 t131.5 316.5t-131.5 316.5t-316.5 131.5t-316.5 -131.5t-131.5 -316.5t131.5 -316.5t316.5 -131.5z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1790 1007q12 -155 -52.5 -292t-186 -224t-271.5 -103v-260h224q14 0 23 -9t9 -23v-64q0 -14 -9 -23t-23 -9h-224v-224q0 -14 -9 -23t-23 -9h-64q-14 0 -23 9t-9 23v224h-512v-224q0 -14 -9 -23t-23 -9h-64q-14 0 -23 9t-9 23v224h-224q-14 0 -23 9t-9 23v64q0 14 9 23 t23 9h224v260q-150 16 -271.5 103t-186 224t-52.5 292q17 206 164.5 356.5t352.5 169.5q206 21 377 -94q171 115 377 94q205 -19 352.5 -169.5t164.5 -356.5zM896 647q128 131 128 313t-128 313q-128 -131 -128 -313t128 -313zM576 512q115 0 218 57q-154 165 -154 391 q0 224 154 391q-103 57 -218 57q-185 0 -316.5 -131.5t-131.5 -316.5t131.5 -316.5t316.5 -131.5zM1152 128v260q-137 15 -256 94q-119 -79 -256 -94v-260h512zM1216 512q185 0 316.5 131.5t131.5 316.5t-131.5 316.5t-316.5 131.5q-115 0 -218 -57q154 -167 154 -391 q0 -226 -154 -391q103 -57 218 -57z" /> -<glyph unicode="" horiz-adv-x="1920" d="M1536 1120q0 14 9 23t23 9h288q26 0 45 -19t19 -45v-288q0 -14 -9 -23t-23 -9h-64q-14 0 -23 9t-9 23v134l-254 -255q76 -95 107.5 -214t9.5 -247q-31 -182 -166 -312t-318 -156q-210 -29 -384.5 80t-241.5 300q-117 6 -221 57.5t-177.5 133t-113.5 192.5t-32 230 q9 135 78 252t182 191.5t248 89.5q118 14 227.5 -19t198.5 -103l255 254h-134q-14 0 -23 9t-9 23v64q0 14 9 23t23 9h288q26 0 45 -19t19 -45v-288q0 -14 -9 -23t-23 -9h-64q-14 0 -23 9t-9 23v134l-254 -255q59 -74 93 -169q182 -9 328 -124l255 254h-134q-14 0 -23 9 t-9 23v64zM1024 704q0 20 -4 58q-162 -25 -271 -150t-109 -292q0 -20 4 -58q162 25 271 150t109 292zM128 704q0 -168 111 -294t276 -149q-3 29 -3 59q0 210 135 369.5t338 196.5q-53 120 -163.5 193t-245.5 73q-185 0 -316.5 -131.5t-131.5 -316.5zM1088 -128 q185 0 316.5 131.5t131.5 316.5q0 168 -111 294t-276 149q3 -29 3 -59q0 -210 -135 -369.5t-338 -196.5q53 -120 163.5 -193t245.5 -73z" /> -<glyph unicode="" horiz-adv-x="2048" d="M1664 1504q0 14 9 23t23 9h288q26 0 45 -19t19 -45v-288q0 -14 -9 -23t-23 -9h-64q-14 0 -23 9t-9 23v134l-254 -255q76 -95 107.5 -214t9.5 -247q-32 -180 -164.5 -310t-313.5 -157q-223 -34 -409 90q-117 -78 -256 -93v-132h96q14 0 23 -9t9 -23v-64q0 -14 -9 -23 t-23 -9h-96v-96q0 -14 -9 -23t-23 -9h-64q-14 0 -23 9t-9 23v96h-96q-14 0 -23 9t-9 23v64q0 14 9 23t23 9h96v132q-155 17 -279.5 109.5t-187 237.5t-39.5 307q25 187 159.5 322.5t320.5 164.5q224 34 410 -90q146 97 320 97q201 0 359 -126l255 254h-134q-14 0 -23 9 t-9 23v64zM896 391q128 131 128 313t-128 313q-128 -131 -128 -313t128 -313zM128 704q0 -185 131.5 -316.5t316.5 -131.5q117 0 218 57q-154 167 -154 391t154 391q-101 57 -218 57q-185 0 -316.5 -131.5t-131.5 -316.5zM1216 256q185 0 316.5 131.5t131.5 316.5 t-131.5 316.5t-316.5 131.5q-117 0 -218 -57q154 -167 154 -391t-154 -391q101 -57 218 -57z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1728 1536q26 0 45 -19t19 -45v-416q0 -14 -9 -23t-23 -9h-64q-14 0 -23 9t-9 23v262l-229 -230l156 -156q9 -10 9 -23t-9 -22l-46 -46q-9 -9 -22 -9t-23 9l-156 157l-99 -100q87 -104 129.5 -236.5t30.5 -276.5q-22 -250 -200.5 -431t-428.5 -206q-163 -17 -314 39.5 t-256.5 162t-162 256.5t-39.5 314q25 250 206 428.5t431 200.5q144 12 276.5 -30.5t236.5 -129.5l99 99l-156 156q-9 10 -9 23t9 22l46 46q9 9 22 9t23 -9l156 -156l229 229h-261q-14 0 -23 9t-9 23v64q0 14 9 23t23 9h416zM1280 448q0 117 -45.5 223.5t-123 184t-184 123 t-223.5 45.5t-223.5 -45.5t-184 -123t-123 -184t-45.5 -223.5t45.5 -223.5t123 -184t184 -123t223.5 -45.5t223.5 45.5t184 123t123 184t45.5 223.5z" /> -<glyph unicode="" horiz-adv-x="1280" d="M640 892q217 -24 364.5 -187.5t147.5 -384.5q0 -167 -87 -306t-236 -212t-319 -54q-133 15 -245.5 88t-182 188t-80.5 249q-12 155 52.5 292t186 224t271.5 103v132h-160q-14 0 -23 9t-9 23v64q0 14 9 23t23 9h160v165l-92 -92q-10 -9 -23 -9t-22 9l-46 46q-9 9 -9 22 t9 23l202 201q19 19 45 19t45 -19l202 -201q9 -10 9 -23t-9 -22l-46 -46q-9 -9 -22 -9t-23 9l-92 92v-165h160q14 0 23 -9t9 -23v-64q0 -14 -9 -23t-23 -9h-160v-132zM576 -128q185 0 316.5 131.5t131.5 316.5t-131.5 316.5t-316.5 131.5t-316.5 -131.5t-131.5 -316.5 t131.5 -316.5t316.5 -131.5z" /> -<glyph unicode="" horiz-adv-x="2048" d="M2029 685q19 -19 19 -45t-19 -45l-294 -294q-9 -10 -22.5 -10t-22.5 10l-45 45q-10 9 -10 22.5t10 22.5l185 185h-294v-224q0 -14 -9 -23t-23 -9h-64q-14 0 -23 9t-9 23v224h-131q-12 -119 -67 -226t-139 -183.5t-196.5 -121.5t-234.5 -45q-180 0 -330.5 91t-234.5 247 t-74 337q8 162 94 300t226.5 219.5t302.5 85.5q166 4 310.5 -71.5t235.5 -208.5t107 -296h131v224q0 14 9 23t23 9h64q14 0 23 -9t9 -23v-224h294l-185 185q-10 9 -10 22.5t10 22.5l45 45q9 10 22.5 10t22.5 -10zM640 128q104 0 198.5 40.5t163.5 109.5t109.5 163.5 t40.5 198.5t-40.5 198.5t-109.5 163.5t-163.5 109.5t-198.5 40.5t-198.5 -40.5t-163.5 -109.5t-109.5 -163.5t-40.5 -198.5t40.5 -198.5t109.5 -163.5t163.5 -109.5t198.5 -40.5z" /> -<glyph unicode="" horiz-adv-x="1280" d="M1152 960q0 -221 -147.5 -384.5t-364.5 -187.5v-612q0 -14 -9 -23t-23 -9h-64q-14 0 -23 9t-9 23v612q-217 24 -364.5 187.5t-147.5 384.5q0 117 45.5 223.5t123 184t184 123t223.5 45.5t223.5 -45.5t184 -123t123 -184t45.5 -223.5zM576 512q185 0 316.5 131.5 t131.5 316.5t-131.5 316.5t-316.5 131.5t-316.5 -131.5t-131.5 -316.5t131.5 -316.5t316.5 -131.5z" /> -<glyph unicode="" horiz-adv-x="1792" /> -<glyph unicode="" horiz-adv-x="1792" /> -<glyph unicode="" horiz-adv-x="1792" /> -<glyph unicode="" d="M1451 1408q35 0 60 -25t25 -60v-1366q0 -35 -25 -60t-60 -25h-391v595h199l30 232h-229v148q0 56 23.5 84t91.5 28l122 1v207q-63 9 -178 9q-136 0 -217.5 -80t-81.5 -226v-171h-200v-232h200v-595h-735q-35 0 -60 25t-25 60v1366q0 35 25 60t60 25h1366z" /> -<glyph unicode="" horiz-adv-x="1280" d="M0 939q0 108 37.5 203.5t103.5 166.5t152 123t185 78t202 26q158 0 294 -66.5t221 -193.5t85 -287q0 -96 -19 -188t-60 -177t-100 -149.5t-145 -103t-189 -38.5q-68 0 -135 32t-96 88q-10 -39 -28 -112.5t-23.5 -95t-20.5 -71t-26 -71t-32 -62.5t-46 -77.5t-62 -86.5 l-14 -5l-9 10q-15 157 -15 188q0 92 21.5 206.5t66.5 287.5t52 203q-32 65 -32 169q0 83 52 156t132 73q61 0 95 -40.5t34 -102.5q0 -66 -44 -191t-44 -187q0 -63 45 -104.5t109 -41.5q55 0 102 25t78.5 68t56 95t38 110.5t20 111t6.5 99.5q0 173 -109.5 269.5t-285.5 96.5 q-200 0 -334 -129.5t-134 -328.5q0 -44 12.5 -85t27 -65t27 -45.5t12.5 -30.5q0 -28 -15 -73t-37 -45q-2 0 -17 3q-51 15 -90.5 56t-61 94.5t-32.5 108t-11 106.5z" /> -<glyph unicode="" d="M985 562q13 0 97.5 -44t89.5 -53q2 -5 2 -15q0 -33 -17 -76q-16 -39 -71 -65.5t-102 -26.5q-57 0 -190 62q-98 45 -170 118t-148 185q-72 107 -71 194v8q3 91 74 158q24 22 52 22q6 0 18 -1.5t19 -1.5q19 0 26.5 -6.5t15.5 -27.5q8 -20 33 -88t25 -75q0 -21 -34.5 -57.5 t-34.5 -46.5q0 -7 5 -15q34 -73 102 -137q56 -53 151 -101q12 -7 22 -7q15 0 54 48.5t52 48.5zM782 32q127 0 243.5 50t200.5 134t134 200.5t50 243.5t-50 243.5t-134 200.5t-200.5 134t-243.5 50t-243.5 -50t-200.5 -134t-134 -200.5t-50 -243.5q0 -203 120 -368l-79 -233 l242 77q158 -104 345 -104zM782 1414q153 0 292.5 -60t240.5 -161t161 -240.5t60 -292.5t-60 -292.5t-161 -240.5t-240.5 -161t-292.5 -60q-195 0 -365 94l-417 -134l136 405q-108 178 -108 389q0 153 60 292.5t161 240.5t240.5 161t292.5 60z" /> -<glyph unicode="" horiz-adv-x="1792" d="M128 128h1024v128h-1024v-128zM128 640h1024v128h-1024v-128zM1696 192q0 40 -28 68t-68 28t-68 -28t-28 -68t28 -68t68 -28t68 28t28 68zM128 1152h1024v128h-1024v-128zM1696 704q0 40 -28 68t-68 28t-68 -28t-28 -68t28 -68t68 -28t68 28t28 68zM1696 1216 q0 40 -28 68t-68 28t-68 -28t-28 -68t28 -68t68 -28t68 28t28 68zM1792 384v-384h-1792v384h1792zM1792 896v-384h-1792v384h1792zM1792 1408v-384h-1792v384h1792z" /> -<glyph unicode="" horiz-adv-x="2048" d="M704 640q-159 0 -271.5 112.5t-112.5 271.5t112.5 271.5t271.5 112.5t271.5 -112.5t112.5 -271.5t-112.5 -271.5t-271.5 -112.5zM1664 512h352q13 0 22.5 -9.5t9.5 -22.5v-192q0 -13 -9.5 -22.5t-22.5 -9.5h-352v-352q0 -13 -9.5 -22.5t-22.5 -9.5h-192q-13 0 -22.5 9.5 t-9.5 22.5v352h-352q-13 0 -22.5 9.5t-9.5 22.5v192q0 13 9.5 22.5t22.5 9.5h352v352q0 13 9.5 22.5t22.5 9.5h192q13 0 22.5 -9.5t9.5 -22.5v-352zM928 288q0 -52 38 -90t90 -38h256v-238q-68 -50 -171 -50h-874q-121 0 -194 69t-73 190q0 53 3.5 103.5t14 109t26.5 108.5 t43 97.5t62 81t85.5 53.5t111.5 20q19 0 39 -17q79 -61 154.5 -91.5t164.5 -30.5t164.5 30.5t154.5 91.5q20 17 39 17q132 0 217 -96h-223q-52 0 -90 -38t-38 -90v-192z" /> -<glyph unicode="" horiz-adv-x="2048" d="M704 640q-159 0 -271.5 112.5t-112.5 271.5t112.5 271.5t271.5 112.5t271.5 -112.5t112.5 -271.5t-112.5 -271.5t-271.5 -112.5zM1781 320l249 -249q9 -9 9 -23q0 -13 -9 -22l-136 -136q-9 -9 -22 -9q-14 0 -23 9l-249 249l-249 -249q-9 -9 -23 -9q-13 0 -22 9l-136 136 q-9 9 -9 22q0 14 9 23l249 249l-249 249q-9 9 -9 23q0 13 9 22l136 136q9 9 22 9q14 0 23 -9l249 -249l249 249q9 9 23 9q13 0 22 -9l136 -136q9 -9 9 -22q0 -14 -9 -23zM1283 320l-181 -181q-37 -37 -37 -91q0 -53 37 -90l83 -83q-21 -3 -44 -3h-874q-121 0 -194 69 t-73 190q0 53 3.5 103.5t14 109t26.5 108.5t43 97.5t62 81t85.5 53.5t111.5 20q19 0 39 -17q154 -122 319 -122t319 122q20 17 39 17q28 0 57 -6q-28 -27 -41 -50t-13 -56q0 -54 37 -91z" /> -<glyph unicode="" horiz-adv-x="2048" d="M256 512h1728q26 0 45 -19t19 -45v-448h-256v256h-1536v-256h-256v1216q0 26 19 45t45 19h128q26 0 45 -19t19 -45v-704zM832 832q0 106 -75 181t-181 75t-181 -75t-75 -181t75 -181t181 -75t181 75t75 181zM2048 576v64q0 159 -112.5 271.5t-271.5 112.5h-704 q-26 0 -45 -19t-19 -45v-384h1152z" /> -<glyph unicode="" d="M1536 1536l-192 -448h192v-192h-274l-55 -128h329v-192h-411l-357 -832l-357 832h-411v192h329l-55 128h-274v192h192l-192 448h256l323 -768h378l323 768h256zM768 320l108 256h-216z" /> -<glyph unicode="" d="M1088 1536q185 0 316.5 -93.5t131.5 -226.5v-896q0 -130 -125.5 -222t-305.5 -97l213 -202q16 -15 8 -35t-30 -20h-1056q-22 0 -30 20t8 35l213 202q-180 5 -305.5 97t-125.5 222v896q0 133 131.5 226.5t316.5 93.5h640zM768 192q80 0 136 56t56 136t-56 136t-136 56 t-136 -56t-56 -136t56 -136t136 -56zM1344 768v512h-1152v-512h1152z" /> -<glyph unicode="" d="M1088 1536q185 0 316.5 -93.5t131.5 -226.5v-896q0 -130 -125.5 -222t-305.5 -97l213 -202q16 -15 8 -35t-30 -20h-1056q-22 0 -30 20t8 35l213 202q-180 5 -305.5 97t-125.5 222v896q0 133 131.5 226.5t316.5 93.5h640zM288 224q66 0 113 47t47 113t-47 113t-113 47 t-113 -47t-47 -113t47 -113t113 -47zM704 768v512h-544v-512h544zM1248 224q66 0 113 47t47 113t-47 113t-113 47t-113 -47t-47 -113t47 -113t113 -47zM1408 768v512h-576v-512h576z" /> -<glyph unicode="" horiz-adv-x="1792" d="M1792 204v-209h-642v209h134v926h-6l-314 -1135h-243l-310 1135h-8v-926h135v-209h-538v209h69q21 0 43 19.5t22 37.5v881q0 18 -22 40t-43 22h-69v209h672l221 -821h6l223 821h670v-209h-71q-19 0 -41 -22t-22 -40v-881q0 -18 21.5 -37.5t41.5 -19.5h71z" /> -<glyph unicode="" horiz-adv-x="1792" /> -<glyph unicode="" horiz-adv-x="1792" /> -<glyph unicode="" horiz-adv-x="1792" /> -<glyph unicode="" horiz-adv-x="1792" /> -<glyph unicode="" horiz-adv-x="1792" /> -</font> -</defs></svg> \ No newline at end of file diff --git a/src/UI/Content/FontAwesome/fontawesome-webfont.ttf b/src/UI/Content/FontAwesome/fontawesome-webfont.ttf deleted file mode 100644 index ed9372f8e..000000000 Binary files a/src/UI/Content/FontAwesome/fontawesome-webfont.ttf and /dev/null differ diff --git a/src/UI/Content/FontAwesome/fontawesome-webfont.woff b/src/UI/Content/FontAwesome/fontawesome-webfont.woff deleted file mode 100644 index 8b280b98f..000000000 Binary files a/src/UI/Content/FontAwesome/fontawesome-webfont.woff and /dev/null differ diff --git a/src/UI/Content/FontAwesome/fontawesome-webfont.woff2 b/src/UI/Content/FontAwesome/fontawesome-webfont.woff2 deleted file mode 100644 index 3311d5851..000000000 Binary files a/src/UI/Content/FontAwesome/fontawesome-webfont.woff2 and /dev/null differ diff --git a/src/UI/Content/FontAwesome/icons.less b/src/UI/Content/FontAwesome/icons.less deleted file mode 100644 index c265de5a6..000000000 --- a/src/UI/Content/FontAwesome/icons.less +++ /dev/null @@ -1,596 +0,0 @@ -/* Font Awesome uses the Unicode Private Use Area (PUA) to ensure screen - readers do not read off random characters that represent icons */ - -.@{fa-css-prefix}-glass:before { content: @fa-var-glass; } -.@{fa-css-prefix}-music:before { content: @fa-var-music; } -.@{fa-css-prefix}-search:before { content: @fa-var-search; } -.@{fa-css-prefix}-envelope-o:before { content: @fa-var-envelope-o; } -.@{fa-css-prefix}-heart:before { content: @fa-var-heart; } -.@{fa-css-prefix}-star:before { content: @fa-var-star; } -.@{fa-css-prefix}-star-o:before { content: @fa-var-star-o; } -.@{fa-css-prefix}-user:before { content: @fa-var-user; } -.@{fa-css-prefix}-film:before { content: @fa-var-film; } -.@{fa-css-prefix}-th-large:before { content: @fa-var-th-large; } -.@{fa-css-prefix}-th:before { content: @fa-var-th; } -.@{fa-css-prefix}-th-list:before { content: @fa-var-th-list; } -.@{fa-css-prefix}-check:before { content: @fa-var-check; } -.@{fa-css-prefix}-remove:before, -.@{fa-css-prefix}-close:before, -.@{fa-css-prefix}-times:before { content: @fa-var-times; } -.@{fa-css-prefix}-search-plus:before { content: @fa-var-search-plus; } -.@{fa-css-prefix}-search-minus:before { content: @fa-var-search-minus; } -.@{fa-css-prefix}-power-off:before { content: @fa-var-power-off; } -.@{fa-css-prefix}-signal:before { content: @fa-var-signal; } -.@{fa-css-prefix}-gear:before, -.@{fa-css-prefix}-cog:before { content: @fa-var-cog; } -.@{fa-css-prefix}-trash-o:before { content: @fa-var-trash-o; } -.@{fa-css-prefix}-home:before { content: @fa-var-home; } -.@{fa-css-prefix}-file-o:before { content: @fa-var-file-o; } -.@{fa-css-prefix}-clock-o:before { content: @fa-var-clock-o; } -.@{fa-css-prefix}-road:before { content: @fa-var-road; } -.@{fa-css-prefix}-download:before { content: @fa-var-download; } -.@{fa-css-prefix}-arrow-circle-o-down:before { content: @fa-var-arrow-circle-o-down; } -.@{fa-css-prefix}-arrow-circle-o-up:before { content: @fa-var-arrow-circle-o-up; } -.@{fa-css-prefix}-inbox:before { content: @fa-var-inbox; } -.@{fa-css-prefix}-play-circle-o:before { content: @fa-var-play-circle-o; } -.@{fa-css-prefix}-rotate-right:before, -.@{fa-css-prefix}-repeat:before { content: @fa-var-repeat; } -.@{fa-css-prefix}-refresh:before { content: @fa-var-refresh; } -.@{fa-css-prefix}-list-alt:before { content: @fa-var-list-alt; } -.@{fa-css-prefix}-lock:before { content: @fa-var-lock; } -.@{fa-css-prefix}-flag:before { content: @fa-var-flag; } -.@{fa-css-prefix}-headphones:before { content: @fa-var-headphones; } -.@{fa-css-prefix}-volume-off:before { content: @fa-var-volume-off; } -.@{fa-css-prefix}-volume-down:before { content: @fa-var-volume-down; } -.@{fa-css-prefix}-volume-up:before { content: @fa-var-volume-up; } -.@{fa-css-prefix}-qrcode:before { content: @fa-var-qrcode; } -.@{fa-css-prefix}-barcode:before { content: @fa-var-barcode; } -.@{fa-css-prefix}-tag:before { content: @fa-var-tag; } -.@{fa-css-prefix}-tags:before { content: @fa-var-tags; } -.@{fa-css-prefix}-book:before { content: @fa-var-book; } -.@{fa-css-prefix}-bookmark:before { content: @fa-var-bookmark; } -.@{fa-css-prefix}-print:before { content: @fa-var-print; } -.@{fa-css-prefix}-camera:before { content: @fa-var-camera; } -.@{fa-css-prefix}-font:before { content: @fa-var-font; } -.@{fa-css-prefix}-bold:before { content: @fa-var-bold; } -.@{fa-css-prefix}-italic:before { content: @fa-var-italic; } -.@{fa-css-prefix}-text-height:before { content: @fa-var-text-height; } -.@{fa-css-prefix}-text-width:before { content: @fa-var-text-width; } -.@{fa-css-prefix}-align-left:before { content: @fa-var-align-left; } -.@{fa-css-prefix}-align-center:before { content: @fa-var-align-center; } -.@{fa-css-prefix}-align-right:before { content: @fa-var-align-right; } -.@{fa-css-prefix}-align-justify:before { content: @fa-var-align-justify; } -.@{fa-css-prefix}-list:before { content: @fa-var-list; } -.@{fa-css-prefix}-dedent:before, -.@{fa-css-prefix}-outdent:before { content: @fa-var-outdent; } -.@{fa-css-prefix}-indent:before { content: @fa-var-indent; } -.@{fa-css-prefix}-video-camera:before { content: @fa-var-video-camera; } -.@{fa-css-prefix}-photo:before, -.@{fa-css-prefix}-image:before, -.@{fa-css-prefix}-picture-o:before { content: @fa-var-picture-o; } -.@{fa-css-prefix}-pencil:before { content: @fa-var-pencil; } -.@{fa-css-prefix}-map-marker:before { content: @fa-var-map-marker; } -.@{fa-css-prefix}-adjust:before { content: @fa-var-adjust; } -.@{fa-css-prefix}-tint:before { content: @fa-var-tint; } -.@{fa-css-prefix}-edit:before, -.@{fa-css-prefix}-pencil-square-o:before { content: @fa-var-pencil-square-o; } -.@{fa-css-prefix}-share-square-o:before { content: @fa-var-share-square-o; } -.@{fa-css-prefix}-check-square-o:before { content: @fa-var-check-square-o; } -.@{fa-css-prefix}-arrows:before { content: @fa-var-arrows; } -.@{fa-css-prefix}-step-backward:before { content: @fa-var-step-backward; } -.@{fa-css-prefix}-fast-backward:before { content: @fa-var-fast-backward; } -.@{fa-css-prefix}-backward:before { content: @fa-var-backward; } -.@{fa-css-prefix}-play:before { content: @fa-var-play; } -.@{fa-css-prefix}-pause:before { content: @fa-var-pause; } -.@{fa-css-prefix}-stop:before { content: @fa-var-stop; } -.@{fa-css-prefix}-forward:before { content: @fa-var-forward; } -.@{fa-css-prefix}-fast-forward:before { content: @fa-var-fast-forward; } -.@{fa-css-prefix}-step-forward:before { content: @fa-var-step-forward; } -.@{fa-css-prefix}-eject:before { content: @fa-var-eject; } -.@{fa-css-prefix}-chevron-left:before { content: @fa-var-chevron-left; } -.@{fa-css-prefix}-chevron-right:before { content: @fa-var-chevron-right; } -.@{fa-css-prefix}-plus-circle:before { content: @fa-var-plus-circle; } -.@{fa-css-prefix}-minus-circle:before { content: @fa-var-minus-circle; } -.@{fa-css-prefix}-times-circle:before { content: @fa-var-times-circle; } -.@{fa-css-prefix}-check-circle:before { content: @fa-var-check-circle; } -.@{fa-css-prefix}-question-circle:before { content: @fa-var-question-circle; } -.@{fa-css-prefix}-info-circle:before { content: @fa-var-info-circle; } -.@{fa-css-prefix}-crosshairs:before { content: @fa-var-crosshairs; } -.@{fa-css-prefix}-times-circle-o:before { content: @fa-var-times-circle-o; } -.@{fa-css-prefix}-check-circle-o:before { content: @fa-var-check-circle-o; } -.@{fa-css-prefix}-ban:before { content: @fa-var-ban; } -.@{fa-css-prefix}-arrow-left:before { content: @fa-var-arrow-left; } -.@{fa-css-prefix}-arrow-right:before { content: @fa-var-arrow-right; } -.@{fa-css-prefix}-arrow-up:before { content: @fa-var-arrow-up; } -.@{fa-css-prefix}-arrow-down:before { content: @fa-var-arrow-down; } -.@{fa-css-prefix}-mail-forward:before, -.@{fa-css-prefix}-share:before { content: @fa-var-share; } -.@{fa-css-prefix}-expand:before { content: @fa-var-expand; } -.@{fa-css-prefix}-compress:before { content: @fa-var-compress; } -.@{fa-css-prefix}-plus:before { content: @fa-var-plus; } -.@{fa-css-prefix}-minus:before { content: @fa-var-minus; } -.@{fa-css-prefix}-asterisk:before { content: @fa-var-asterisk; } -.@{fa-css-prefix}-exclamation-circle:before { content: @fa-var-exclamation-circle; } -.@{fa-css-prefix}-gift:before { content: @fa-var-gift; } -.@{fa-css-prefix}-leaf:before { content: @fa-var-leaf; } -.@{fa-css-prefix}-fire:before { content: @fa-var-fire; } -.@{fa-css-prefix}-eye:before { content: @fa-var-eye; } -.@{fa-css-prefix}-eye-slash:before { content: @fa-var-eye-slash; } -.@{fa-css-prefix}-warning:before, -.@{fa-css-prefix}-exclamation-triangle:before { content: @fa-var-exclamation-triangle; } -.@{fa-css-prefix}-plane:before { content: @fa-var-plane; } -.@{fa-css-prefix}-calendar:before { content: @fa-var-calendar; } -.@{fa-css-prefix}-random:before { content: @fa-var-random; } -.@{fa-css-prefix}-comment:before { content: @fa-var-comment; } -.@{fa-css-prefix}-magnet:before { content: @fa-var-magnet; } -.@{fa-css-prefix}-chevron-up:before { content: @fa-var-chevron-up; } -.@{fa-css-prefix}-chevron-down:before { content: @fa-var-chevron-down; } -.@{fa-css-prefix}-retweet:before { content: @fa-var-retweet; } -.@{fa-css-prefix}-shopping-cart:before { content: @fa-var-shopping-cart; } -.@{fa-css-prefix}-folder:before { content: @fa-var-folder; } -.@{fa-css-prefix}-folder-open:before { content: @fa-var-folder-open; } -.@{fa-css-prefix}-arrows-v:before { content: @fa-var-arrows-v; } -.@{fa-css-prefix}-arrows-h:before { content: @fa-var-arrows-h; } -.@{fa-css-prefix}-bar-chart-o:before, -.@{fa-css-prefix}-bar-chart:before { content: @fa-var-bar-chart; } -.@{fa-css-prefix}-twitter-square:before { content: @fa-var-twitter-square; } -.@{fa-css-prefix}-facebook-square:before { content: @fa-var-facebook-square; } -.@{fa-css-prefix}-camera-retro:before { content: @fa-var-camera-retro; } -.@{fa-css-prefix}-key:before { content: @fa-var-key; } -.@{fa-css-prefix}-gears:before, -.@{fa-css-prefix}-cogs:before { content: @fa-var-cogs; } -.@{fa-css-prefix}-comments:before { content: @fa-var-comments; } -.@{fa-css-prefix}-thumbs-o-up:before { content: @fa-var-thumbs-o-up; } -.@{fa-css-prefix}-thumbs-o-down:before { content: @fa-var-thumbs-o-down; } -.@{fa-css-prefix}-star-half:before { content: @fa-var-star-half; } -.@{fa-css-prefix}-heart-o:before { content: @fa-var-heart-o; } -.@{fa-css-prefix}-sign-out:before { content: @fa-var-sign-out; } -.@{fa-css-prefix}-linkedin-square:before { content: @fa-var-linkedin-square; } -.@{fa-css-prefix}-thumb-tack:before { content: @fa-var-thumb-tack; } -.@{fa-css-prefix}-external-link:before { content: @fa-var-external-link; } -.@{fa-css-prefix}-sign-in:before { content: @fa-var-sign-in; } -.@{fa-css-prefix}-trophy:before { content: @fa-var-trophy; } -.@{fa-css-prefix}-github-square:before { content: @fa-var-github-square; } -.@{fa-css-prefix}-upload:before { content: @fa-var-upload; } -.@{fa-css-prefix}-lemon-o:before { content: @fa-var-lemon-o; } -.@{fa-css-prefix}-phone:before { content: @fa-var-phone; } -.@{fa-css-prefix}-square-o:before { content: @fa-var-square-o; } -.@{fa-css-prefix}-bookmark-o:before { content: @fa-var-bookmark-o; } -.@{fa-css-prefix}-phone-square:before { content: @fa-var-phone-square; } -.@{fa-css-prefix}-twitter:before { content: @fa-var-twitter; } -.@{fa-css-prefix}-facebook-f:before, -.@{fa-css-prefix}-facebook:before { content: @fa-var-facebook; } -.@{fa-css-prefix}-github:before { content: @fa-var-github; } -.@{fa-css-prefix}-unlock:before { content: @fa-var-unlock; } -.@{fa-css-prefix}-credit-card:before { content: @fa-var-credit-card; } -.@{fa-css-prefix}-rss:before { content: @fa-var-rss; } -.@{fa-css-prefix}-hdd-o:before { content: @fa-var-hdd-o; } -.@{fa-css-prefix}-bullhorn:before { content: @fa-var-bullhorn; } -.@{fa-css-prefix}-bell:before { content: @fa-var-bell; } -.@{fa-css-prefix}-certificate:before { content: @fa-var-certificate; } -.@{fa-css-prefix}-hand-o-right:before { content: @fa-var-hand-o-right; } -.@{fa-css-prefix}-hand-o-left:before { content: @fa-var-hand-o-left; } -.@{fa-css-prefix}-hand-o-up:before { content: @fa-var-hand-o-up; } -.@{fa-css-prefix}-hand-o-down:before { content: @fa-var-hand-o-down; } -.@{fa-css-prefix}-arrow-circle-left:before { content: @fa-var-arrow-circle-left; } -.@{fa-css-prefix}-arrow-circle-right:before { content: @fa-var-arrow-circle-right; } -.@{fa-css-prefix}-arrow-circle-up:before { content: @fa-var-arrow-circle-up; } -.@{fa-css-prefix}-arrow-circle-down:before { content: @fa-var-arrow-circle-down; } -.@{fa-css-prefix}-globe:before { content: @fa-var-globe; } -.@{fa-css-prefix}-wrench:before { content: @fa-var-wrench; } -.@{fa-css-prefix}-tasks:before { content: @fa-var-tasks; } -.@{fa-css-prefix}-filter:before { content: @fa-var-filter; } -.@{fa-css-prefix}-briefcase:before { content: @fa-var-briefcase; } -.@{fa-css-prefix}-arrows-alt:before { content: @fa-var-arrows-alt; } -.@{fa-css-prefix}-group:before, -.@{fa-css-prefix}-users:before { content: @fa-var-users; } -.@{fa-css-prefix}-chain:before, -.@{fa-css-prefix}-link:before { content: @fa-var-link; } -.@{fa-css-prefix}-cloud:before { content: @fa-var-cloud; } -.@{fa-css-prefix}-flask:before { content: @fa-var-flask; } -.@{fa-css-prefix}-cut:before, -.@{fa-css-prefix}-scissors:before { content: @fa-var-scissors; } -.@{fa-css-prefix}-copy:before, -.@{fa-css-prefix}-files-o:before { content: @fa-var-files-o; } -.@{fa-css-prefix}-paperclip:before { content: @fa-var-paperclip; } -.@{fa-css-prefix}-save:before, -.@{fa-css-prefix}-floppy-o:before { content: @fa-var-floppy-o; } -.@{fa-css-prefix}-square:before { content: @fa-var-square; } -.@{fa-css-prefix}-navicon:before, -.@{fa-css-prefix}-reorder:before, -.@{fa-css-prefix}-bars:before { content: @fa-var-bars; } -.@{fa-css-prefix}-list-ul:before { content: @fa-var-list-ul; } -.@{fa-css-prefix}-list-ol:before { content: @fa-var-list-ol; } -.@{fa-css-prefix}-strikethrough:before { content: @fa-var-strikethrough; } -.@{fa-css-prefix}-underline:before { content: @fa-var-underline; } -.@{fa-css-prefix}-table:before { content: @fa-var-table; } -.@{fa-css-prefix}-magic:before { content: @fa-var-magic; } -.@{fa-css-prefix}-truck:before { content: @fa-var-truck; } -.@{fa-css-prefix}-pinterest:before { content: @fa-var-pinterest; } -.@{fa-css-prefix}-pinterest-square:before { content: @fa-var-pinterest-square; } -.@{fa-css-prefix}-google-plus-square:before { content: @fa-var-google-plus-square; } -.@{fa-css-prefix}-google-plus:before { content: @fa-var-google-plus; } -.@{fa-css-prefix}-money:before { content: @fa-var-money; } -.@{fa-css-prefix}-caret-down:before { content: @fa-var-caret-down; } -.@{fa-css-prefix}-caret-up:before { content: @fa-var-caret-up; } -.@{fa-css-prefix}-caret-left:before { content: @fa-var-caret-left; } -.@{fa-css-prefix}-caret-right:before { content: @fa-var-caret-right; } -.@{fa-css-prefix}-columns:before { content: @fa-var-columns; } -.@{fa-css-prefix}-unsorted:before, -.@{fa-css-prefix}-sort:before { content: @fa-var-sort; } -.@{fa-css-prefix}-sort-down:before, -.@{fa-css-prefix}-sort-desc:before { content: @fa-var-sort-desc; } -.@{fa-css-prefix}-sort-up:before, -.@{fa-css-prefix}-sort-asc:before { content: @fa-var-sort-asc; } -.@{fa-css-prefix}-envelope:before { content: @fa-var-envelope; } -.@{fa-css-prefix}-linkedin:before { content: @fa-var-linkedin; } -.@{fa-css-prefix}-rotate-left:before, -.@{fa-css-prefix}-undo:before { content: @fa-var-undo; } -.@{fa-css-prefix}-legal:before, -.@{fa-css-prefix}-gavel:before { content: @fa-var-gavel; } -.@{fa-css-prefix}-dashboard:before, -.@{fa-css-prefix}-tachometer:before { content: @fa-var-tachometer; } -.@{fa-css-prefix}-comment-o:before { content: @fa-var-comment-o; } -.@{fa-css-prefix}-comments-o:before { content: @fa-var-comments-o; } -.@{fa-css-prefix}-flash:before, -.@{fa-css-prefix}-bolt:before { content: @fa-var-bolt; } -.@{fa-css-prefix}-sitemap:before { content: @fa-var-sitemap; } -.@{fa-css-prefix}-umbrella:before { content: @fa-var-umbrella; } -.@{fa-css-prefix}-paste:before, -.@{fa-css-prefix}-clipboard:before { content: @fa-var-clipboard; } -.@{fa-css-prefix}-lightbulb-o:before { content: @fa-var-lightbulb-o; } -.@{fa-css-prefix}-exchange:before { content: @fa-var-exchange; } -.@{fa-css-prefix}-cloud-download:before { content: @fa-var-cloud-download; } -.@{fa-css-prefix}-cloud-upload:before { content: @fa-var-cloud-upload; } -.@{fa-css-prefix}-user-md:before { content: @fa-var-user-md; } -.@{fa-css-prefix}-stethoscope:before { content: @fa-var-stethoscope; } -.@{fa-css-prefix}-suitcase:before { content: @fa-var-suitcase; } -.@{fa-css-prefix}-bell-o:before { content: @fa-var-bell-o; } -.@{fa-css-prefix}-coffee:before { content: @fa-var-coffee; } -.@{fa-css-prefix}-cutlery:before { content: @fa-var-cutlery; } -.@{fa-css-prefix}-file-text-o:before { content: @fa-var-file-text-o; } -.@{fa-css-prefix}-building-o:before { content: @fa-var-building-o; } -.@{fa-css-prefix}-hospital-o:before { content: @fa-var-hospital-o; } -.@{fa-css-prefix}-ambulance:before { content: @fa-var-ambulance; } -.@{fa-css-prefix}-medkit:before { content: @fa-var-medkit; } -.@{fa-css-prefix}-fighter-jet:before { content: @fa-var-fighter-jet; } -.@{fa-css-prefix}-beer:before { content: @fa-var-beer; } -.@{fa-css-prefix}-h-square:before { content: @fa-var-h-square; } -.@{fa-css-prefix}-plus-square:before { content: @fa-var-plus-square; } -.@{fa-css-prefix}-angle-double-left:before { content: @fa-var-angle-double-left; } -.@{fa-css-prefix}-angle-double-right:before { content: @fa-var-angle-double-right; } -.@{fa-css-prefix}-angle-double-up:before { content: @fa-var-angle-double-up; } -.@{fa-css-prefix}-angle-double-down:before { content: @fa-var-angle-double-down; } -.@{fa-css-prefix}-angle-left:before { content: @fa-var-angle-left; } -.@{fa-css-prefix}-angle-right:before { content: @fa-var-angle-right; } -.@{fa-css-prefix}-angle-up:before { content: @fa-var-angle-up; } -.@{fa-css-prefix}-angle-down:before { content: @fa-var-angle-down; } -.@{fa-css-prefix}-desktop:before { content: @fa-var-desktop; } -.@{fa-css-prefix}-laptop:before { content: @fa-var-laptop; } -.@{fa-css-prefix}-tablet:before { content: @fa-var-tablet; } -.@{fa-css-prefix}-mobile-phone:before, -.@{fa-css-prefix}-mobile:before { content: @fa-var-mobile; } -.@{fa-css-prefix}-circle-o:before { content: @fa-var-circle-o; } -.@{fa-css-prefix}-quote-left:before { content: @fa-var-quote-left; } -.@{fa-css-prefix}-quote-right:before { content: @fa-var-quote-right; } -.@{fa-css-prefix}-spinner:before { content: @fa-var-spinner; } -.@{fa-css-prefix}-circle:before { content: @fa-var-circle; } -.@{fa-css-prefix}-mail-reply:before, -.@{fa-css-prefix}-reply:before { content: @fa-var-reply; } -.@{fa-css-prefix}-github-alt:before { content: @fa-var-github-alt; } -.@{fa-css-prefix}-folder-o:before { content: @fa-var-folder-o; } -.@{fa-css-prefix}-folder-open-o:before { content: @fa-var-folder-open-o; } -.@{fa-css-prefix}-smile-o:before { content: @fa-var-smile-o; } -.@{fa-css-prefix}-frown-o:before { content: @fa-var-frown-o; } -.@{fa-css-prefix}-meh-o:before { content: @fa-var-meh-o; } -.@{fa-css-prefix}-gamepad:before { content: @fa-var-gamepad; } -.@{fa-css-prefix}-keyboard-o:before { content: @fa-var-keyboard-o; } -.@{fa-css-prefix}-flag-o:before { content: @fa-var-flag-o; } -.@{fa-css-prefix}-flag-checkered:before { content: @fa-var-flag-checkered; } -.@{fa-css-prefix}-terminal:before { content: @fa-var-terminal; } -.@{fa-css-prefix}-code:before { content: @fa-var-code; } -.@{fa-css-prefix}-mail-reply-all:before, -.@{fa-css-prefix}-reply-all:before { content: @fa-var-reply-all; } -.@{fa-css-prefix}-star-half-empty:before, -.@{fa-css-prefix}-star-half-full:before, -.@{fa-css-prefix}-star-half-o:before { content: @fa-var-star-half-o; } -.@{fa-css-prefix}-location-arrow:before { content: @fa-var-location-arrow; } -.@{fa-css-prefix}-crop:before { content: @fa-var-crop; } -.@{fa-css-prefix}-code-fork:before { content: @fa-var-code-fork; } -.@{fa-css-prefix}-unlink:before, -.@{fa-css-prefix}-chain-broken:before { content: @fa-var-chain-broken; } -.@{fa-css-prefix}-question:before { content: @fa-var-question; } -.@{fa-css-prefix}-info:before { content: @fa-var-info; } -.@{fa-css-prefix}-exclamation:before { content: @fa-var-exclamation; } -.@{fa-css-prefix}-superscript:before { content: @fa-var-superscript; } -.@{fa-css-prefix}-subscript:before { content: @fa-var-subscript; } -.@{fa-css-prefix}-eraser:before { content: @fa-var-eraser; } -.@{fa-css-prefix}-puzzle-piece:before { content: @fa-var-puzzle-piece; } -.@{fa-css-prefix}-microphone:before { content: @fa-var-microphone; } -.@{fa-css-prefix}-microphone-slash:before { content: @fa-var-microphone-slash; } -.@{fa-css-prefix}-shield:before { content: @fa-var-shield; } -.@{fa-css-prefix}-calendar-o:before { content: @fa-var-calendar-o; } -.@{fa-css-prefix}-fire-extinguisher:before { content: @fa-var-fire-extinguisher; } -.@{fa-css-prefix}-rocket:before { content: @fa-var-rocket; } -.@{fa-css-prefix}-maxcdn:before { content: @fa-var-maxcdn; } -.@{fa-css-prefix}-chevron-circle-left:before { content: @fa-var-chevron-circle-left; } -.@{fa-css-prefix}-chevron-circle-right:before { content: @fa-var-chevron-circle-right; } -.@{fa-css-prefix}-chevron-circle-up:before { content: @fa-var-chevron-circle-up; } -.@{fa-css-prefix}-chevron-circle-down:before { content: @fa-var-chevron-circle-down; } -.@{fa-css-prefix}-html5:before { content: @fa-var-html5; } -.@{fa-css-prefix}-css3:before { content: @fa-var-css3; } -.@{fa-css-prefix}-anchor:before { content: @fa-var-anchor; } -.@{fa-css-prefix}-unlock-alt:before { content: @fa-var-unlock-alt; } -.@{fa-css-prefix}-bullseye:before { content: @fa-var-bullseye; } -.@{fa-css-prefix}-ellipsis-h:before { content: @fa-var-ellipsis-h; } -.@{fa-css-prefix}-ellipsis-v:before { content: @fa-var-ellipsis-v; } -.@{fa-css-prefix}-rss-square:before { content: @fa-var-rss-square; } -.@{fa-css-prefix}-play-circle:before { content: @fa-var-play-circle; } -.@{fa-css-prefix}-ticket:before { content: @fa-var-ticket; } -.@{fa-css-prefix}-minus-square:before { content: @fa-var-minus-square; } -.@{fa-css-prefix}-minus-square-o:before { content: @fa-var-minus-square-o; } -.@{fa-css-prefix}-level-up:before { content: @fa-var-level-up; } -.@{fa-css-prefix}-level-down:before { content: @fa-var-level-down; } -.@{fa-css-prefix}-check-square:before { content: @fa-var-check-square; } -.@{fa-css-prefix}-pencil-square:before { content: @fa-var-pencil-square; } -.@{fa-css-prefix}-external-link-square:before { content: @fa-var-external-link-square; } -.@{fa-css-prefix}-share-square:before { content: @fa-var-share-square; } -.@{fa-css-prefix}-compass:before { content: @fa-var-compass; } -.@{fa-css-prefix}-toggle-down:before, -.@{fa-css-prefix}-caret-square-o-down:before { content: @fa-var-caret-square-o-down; } -.@{fa-css-prefix}-toggle-up:before, -.@{fa-css-prefix}-caret-square-o-up:before { content: @fa-var-caret-square-o-up; } -.@{fa-css-prefix}-toggle-right:before, -.@{fa-css-prefix}-caret-square-o-right:before { content: @fa-var-caret-square-o-right; } -.@{fa-css-prefix}-euro:before, -.@{fa-css-prefix}-eur:before { content: @fa-var-eur; } -.@{fa-css-prefix}-gbp:before { content: @fa-var-gbp; } -.@{fa-css-prefix}-dollar:before, -.@{fa-css-prefix}-usd:before { content: @fa-var-usd; } -.@{fa-css-prefix}-rupee:before, -.@{fa-css-prefix}-inr:before { content: @fa-var-inr; } -.@{fa-css-prefix}-cny:before, -.@{fa-css-prefix}-rmb:before, -.@{fa-css-prefix}-yen:before, -.@{fa-css-prefix}-jpy:before { content: @fa-var-jpy; } -.@{fa-css-prefix}-ruble:before, -.@{fa-css-prefix}-rouble:before, -.@{fa-css-prefix}-rub:before { content: @fa-var-rub; } -.@{fa-css-prefix}-won:before, -.@{fa-css-prefix}-krw:before { content: @fa-var-krw; } -.@{fa-css-prefix}-bitcoin:before, -.@{fa-css-prefix}-btc:before { content: @fa-var-btc; } -.@{fa-css-prefix}-file:before { content: @fa-var-file; } -.@{fa-css-prefix}-file-text:before { content: @fa-var-file-text; } -.@{fa-css-prefix}-sort-alpha-asc:before { content: @fa-var-sort-alpha-asc; } -.@{fa-css-prefix}-sort-alpha-desc:before { content: @fa-var-sort-alpha-desc; } -.@{fa-css-prefix}-sort-amount-asc:before { content: @fa-var-sort-amount-asc; } -.@{fa-css-prefix}-sort-amount-desc:before { content: @fa-var-sort-amount-desc; } -.@{fa-css-prefix}-sort-numeric-asc:before { content: @fa-var-sort-numeric-asc; } -.@{fa-css-prefix}-sort-numeric-desc:before { content: @fa-var-sort-numeric-desc; } -.@{fa-css-prefix}-thumbs-up:before { content: @fa-var-thumbs-up; } -.@{fa-css-prefix}-thumbs-down:before { content: @fa-var-thumbs-down; } -.@{fa-css-prefix}-youtube-square:before { content: @fa-var-youtube-square; } -.@{fa-css-prefix}-youtube:before { content: @fa-var-youtube; } -.@{fa-css-prefix}-xing:before { content: @fa-var-xing; } -.@{fa-css-prefix}-xing-square:before { content: @fa-var-xing-square; } -.@{fa-css-prefix}-youtube-play:before { content: @fa-var-youtube-play; } -.@{fa-css-prefix}-dropbox:before { content: @fa-var-dropbox; } -.@{fa-css-prefix}-stack-overflow:before { content: @fa-var-stack-overflow; } -.@{fa-css-prefix}-instagram:before { content: @fa-var-instagram; } -.@{fa-css-prefix}-flickr:before { content: @fa-var-flickr; } -.@{fa-css-prefix}-adn:before { content: @fa-var-adn; } -.@{fa-css-prefix}-bitbucket:before { content: @fa-var-bitbucket; } -.@{fa-css-prefix}-bitbucket-square:before { content: @fa-var-bitbucket-square; } -.@{fa-css-prefix}-tumblr:before { content: @fa-var-tumblr; } -.@{fa-css-prefix}-tumblr-square:before { content: @fa-var-tumblr-square; } -.@{fa-css-prefix}-long-arrow-down:before { content: @fa-var-long-arrow-down; } -.@{fa-css-prefix}-long-arrow-up:before { content: @fa-var-long-arrow-up; } -.@{fa-css-prefix}-long-arrow-left:before { content: @fa-var-long-arrow-left; } -.@{fa-css-prefix}-long-arrow-right:before { content: @fa-var-long-arrow-right; } -.@{fa-css-prefix}-apple:before { content: @fa-var-apple; } -.@{fa-css-prefix}-windows:before { content: @fa-var-windows; } -.@{fa-css-prefix}-android:before { content: @fa-var-android; } -.@{fa-css-prefix}-linux:before { content: @fa-var-linux; } -.@{fa-css-prefix}-dribbble:before { content: @fa-var-dribbble; } -.@{fa-css-prefix}-skype:before { content: @fa-var-skype; } -.@{fa-css-prefix}-foursquare:before { content: @fa-var-foursquare; } -.@{fa-css-prefix}-trello:before { content: @fa-var-trello; } -.@{fa-css-prefix}-female:before { content: @fa-var-female; } -.@{fa-css-prefix}-male:before { content: @fa-var-male; } -.@{fa-css-prefix}-gittip:before, -.@{fa-css-prefix}-gratipay:before { content: @fa-var-gratipay; } -.@{fa-css-prefix}-sun-o:before { content: @fa-var-sun-o; } -.@{fa-css-prefix}-moon-o:before { content: @fa-var-moon-o; } -.@{fa-css-prefix}-archive:before { content: @fa-var-archive; } -.@{fa-css-prefix}-bug:before { content: @fa-var-bug; } -.@{fa-css-prefix}-vk:before { content: @fa-var-vk; } -.@{fa-css-prefix}-weibo:before { content: @fa-var-weibo; } -.@{fa-css-prefix}-renren:before { content: @fa-var-renren; } -.@{fa-css-prefix}-pagelines:before { content: @fa-var-pagelines; } -.@{fa-css-prefix}-stack-exchange:before { content: @fa-var-stack-exchange; } -.@{fa-css-prefix}-arrow-circle-o-right:before { content: @fa-var-arrow-circle-o-right; } -.@{fa-css-prefix}-arrow-circle-o-left:before { content: @fa-var-arrow-circle-o-left; } -.@{fa-css-prefix}-toggle-left:before, -.@{fa-css-prefix}-caret-square-o-left:before { content: @fa-var-caret-square-o-left; } -.@{fa-css-prefix}-dot-circle-o:before { content: @fa-var-dot-circle-o; } -.@{fa-css-prefix}-wheelchair:before { content: @fa-var-wheelchair; } -.@{fa-css-prefix}-vimeo-square:before { content: @fa-var-vimeo-square; } -.@{fa-css-prefix}-turkish-lira:before, -.@{fa-css-prefix}-try:before { content: @fa-var-try; } -.@{fa-css-prefix}-plus-square-o:before { content: @fa-var-plus-square-o; } -.@{fa-css-prefix}-space-shuttle:before { content: @fa-var-space-shuttle; } -.@{fa-css-prefix}-slack:before { content: @fa-var-slack; } -.@{fa-css-prefix}-envelope-square:before { content: @fa-var-envelope-square; } -.@{fa-css-prefix}-wordpress:before { content: @fa-var-wordpress; } -.@{fa-css-prefix}-openid:before { content: @fa-var-openid; } -.@{fa-css-prefix}-institution:before, -.@{fa-css-prefix}-bank:before, -.@{fa-css-prefix}-university:before { content: @fa-var-university; } -.@{fa-css-prefix}-mortar-board:before, -.@{fa-css-prefix}-graduation-cap:before { content: @fa-var-graduation-cap; } -.@{fa-css-prefix}-yahoo:before { content: @fa-var-yahoo; } -.@{fa-css-prefix}-google:before { content: @fa-var-google; } -.@{fa-css-prefix}-reddit:before { content: @fa-var-reddit; } -.@{fa-css-prefix}-reddit-square:before { content: @fa-var-reddit-square; } -.@{fa-css-prefix}-stumbleupon-circle:before { content: @fa-var-stumbleupon-circle; } -.@{fa-css-prefix}-stumbleupon:before { content: @fa-var-stumbleupon; } -.@{fa-css-prefix}-delicious:before { content: @fa-var-delicious; } -.@{fa-css-prefix}-digg:before { content: @fa-var-digg; } -.@{fa-css-prefix}-pied-piper:before { content: @fa-var-pied-piper; } -.@{fa-css-prefix}-pied-piper-alt:before { content: @fa-var-pied-piper-alt; } -.@{fa-css-prefix}-drupal:before { content: @fa-var-drupal; } -.@{fa-css-prefix}-joomla:before { content: @fa-var-joomla; } -.@{fa-css-prefix}-language:before { content: @fa-var-language; } -.@{fa-css-prefix}-fax:before { content: @fa-var-fax; } -.@{fa-css-prefix}-building:before { content: @fa-var-building; } -.@{fa-css-prefix}-child:before { content: @fa-var-child; } -.@{fa-css-prefix}-paw:before { content: @fa-var-paw; } -.@{fa-css-prefix}-spoon:before { content: @fa-var-spoon; } -.@{fa-css-prefix}-cube:before { content: @fa-var-cube; } -.@{fa-css-prefix}-cubes:before { content: @fa-var-cubes; } -.@{fa-css-prefix}-behance:before { content: @fa-var-behance; } -.@{fa-css-prefix}-behance-square:before { content: @fa-var-behance-square; } -.@{fa-css-prefix}-steam:before { content: @fa-var-steam; } -.@{fa-css-prefix}-steam-square:before { content: @fa-var-steam-square; } -.@{fa-css-prefix}-recycle:before { content: @fa-var-recycle; } -.@{fa-css-prefix}-automobile:before, -.@{fa-css-prefix}-car:before { content: @fa-var-car; } -.@{fa-css-prefix}-cab:before, -.@{fa-css-prefix}-taxi:before { content: @fa-var-taxi; } -.@{fa-css-prefix}-tree:before { content: @fa-var-tree; } -.@{fa-css-prefix}-spotify:before { content: @fa-var-spotify; } -.@{fa-css-prefix}-deviantart:before { content: @fa-var-deviantart; } -.@{fa-css-prefix}-soundcloud:before { content: @fa-var-soundcloud; } -.@{fa-css-prefix}-database:before { content: @fa-var-database; } -.@{fa-css-prefix}-file-pdf-o:before { content: @fa-var-file-pdf-o; } -.@{fa-css-prefix}-file-word-o:before { content: @fa-var-file-word-o; } -.@{fa-css-prefix}-file-excel-o:before { content: @fa-var-file-excel-o; } -.@{fa-css-prefix}-file-powerpoint-o:before { content: @fa-var-file-powerpoint-o; } -.@{fa-css-prefix}-file-photo-o:before, -.@{fa-css-prefix}-file-picture-o:before, -.@{fa-css-prefix}-file-image-o:before { content: @fa-var-file-image-o; } -.@{fa-css-prefix}-file-zip-o:before, -.@{fa-css-prefix}-file-archive-o:before { content: @fa-var-file-archive-o; } -.@{fa-css-prefix}-file-sound-o:before, -.@{fa-css-prefix}-file-audio-o:before { content: @fa-var-file-audio-o; } -.@{fa-css-prefix}-file-movie-o:before, -.@{fa-css-prefix}-file-video-o:before { content: @fa-var-file-video-o; } -.@{fa-css-prefix}-file-code-o:before { content: @fa-var-file-code-o; } -.@{fa-css-prefix}-vine:before { content: @fa-var-vine; } -.@{fa-css-prefix}-codepen:before { content: @fa-var-codepen; } -.@{fa-css-prefix}-jsfiddle:before { content: @fa-var-jsfiddle; } -.@{fa-css-prefix}-life-bouy:before, -.@{fa-css-prefix}-life-buoy:before, -.@{fa-css-prefix}-life-saver:before, -.@{fa-css-prefix}-support:before, -.@{fa-css-prefix}-life-ring:before { content: @fa-var-life-ring; } -.@{fa-css-prefix}-circle-o-notch:before { content: @fa-var-circle-o-notch; } -.@{fa-css-prefix}-ra:before, -.@{fa-css-prefix}-rebel:before { content: @fa-var-rebel; } -.@{fa-css-prefix}-ge:before, -.@{fa-css-prefix}-empire:before { content: @fa-var-empire; } -.@{fa-css-prefix}-git-square:before { content: @fa-var-git-square; } -.@{fa-css-prefix}-git:before { content: @fa-var-git; } -.@{fa-css-prefix}-hacker-news:before { content: @fa-var-hacker-news; } -.@{fa-css-prefix}-tencent-weibo:before { content: @fa-var-tencent-weibo; } -.@{fa-css-prefix}-qq:before { content: @fa-var-qq; } -.@{fa-css-prefix}-wechat:before, -.@{fa-css-prefix}-weixin:before { content: @fa-var-weixin; } -.@{fa-css-prefix}-send:before, -.@{fa-css-prefix}-paper-plane:before { content: @fa-var-paper-plane; } -.@{fa-css-prefix}-send-o:before, -.@{fa-css-prefix}-paper-plane-o:before { content: @fa-var-paper-plane-o; } -.@{fa-css-prefix}-history:before { content: @fa-var-history; } -.@{fa-css-prefix}-genderless:before, -.@{fa-css-prefix}-circle-thin:before { content: @fa-var-circle-thin; } -.@{fa-css-prefix}-header:before { content: @fa-var-header; } -.@{fa-css-prefix}-paragraph:before { content: @fa-var-paragraph; } -.@{fa-css-prefix}-sliders:before { content: @fa-var-sliders; } -.@{fa-css-prefix}-share-alt:before { content: @fa-var-share-alt; } -.@{fa-css-prefix}-share-alt-square:before { content: @fa-var-share-alt-square; } -.@{fa-css-prefix}-bomb:before { content: @fa-var-bomb; } -.@{fa-css-prefix}-soccer-ball-o:before, -.@{fa-css-prefix}-futbol-o:before { content: @fa-var-futbol-o; } -.@{fa-css-prefix}-tty:before { content: @fa-var-tty; } -.@{fa-css-prefix}-binoculars:before { content: @fa-var-binoculars; } -.@{fa-css-prefix}-plug:before { content: @fa-var-plug; } -.@{fa-css-prefix}-slideshare:before { content: @fa-var-slideshare; } -.@{fa-css-prefix}-twitch:before { content: @fa-var-twitch; } -.@{fa-css-prefix}-yelp:before { content: @fa-var-yelp; } -.@{fa-css-prefix}-newspaper-o:before { content: @fa-var-newspaper-o; } -.@{fa-css-prefix}-wifi:before { content: @fa-var-wifi; } -.@{fa-css-prefix}-calculator:before { content: @fa-var-calculator; } -.@{fa-css-prefix}-paypal:before { content: @fa-var-paypal; } -.@{fa-css-prefix}-google-wallet:before { content: @fa-var-google-wallet; } -.@{fa-css-prefix}-cc-visa:before { content: @fa-var-cc-visa; } -.@{fa-css-prefix}-cc-mastercard:before { content: @fa-var-cc-mastercard; } -.@{fa-css-prefix}-cc-discover:before { content: @fa-var-cc-discover; } -.@{fa-css-prefix}-cc-amex:before { content: @fa-var-cc-amex; } -.@{fa-css-prefix}-cc-paypal:before { content: @fa-var-cc-paypal; } -.@{fa-css-prefix}-cc-stripe:before { content: @fa-var-cc-stripe; } -.@{fa-css-prefix}-bell-slash:before { content: @fa-var-bell-slash; } -.@{fa-css-prefix}-bell-slash-o:before { content: @fa-var-bell-slash-o; } -.@{fa-css-prefix}-trash:before { content: @fa-var-trash; } -.@{fa-css-prefix}-copyright:before { content: @fa-var-copyright; } -.@{fa-css-prefix}-at:before { content: @fa-var-at; } -.@{fa-css-prefix}-eyedropper:before { content: @fa-var-eyedropper; } -.@{fa-css-prefix}-paint-brush:before { content: @fa-var-paint-brush; } -.@{fa-css-prefix}-birthday-cake:before { content: @fa-var-birthday-cake; } -.@{fa-css-prefix}-area-chart:before { content: @fa-var-area-chart; } -.@{fa-css-prefix}-pie-chart:before { content: @fa-var-pie-chart; } -.@{fa-css-prefix}-line-chart:before { content: @fa-var-line-chart; } -.@{fa-css-prefix}-lastfm:before { content: @fa-var-lastfm; } -.@{fa-css-prefix}-lastfm-square:before { content: @fa-var-lastfm-square; } -.@{fa-css-prefix}-toggle-off:before { content: @fa-var-toggle-off; } -.@{fa-css-prefix}-toggle-on:before { content: @fa-var-toggle-on; } -.@{fa-css-prefix}-bicycle:before { content: @fa-var-bicycle; } -.@{fa-css-prefix}-bus:before { content: @fa-var-bus; } -.@{fa-css-prefix}-ioxhost:before { content: @fa-var-ioxhost; } -.@{fa-css-prefix}-angellist:before { content: @fa-var-angellist; } -.@{fa-css-prefix}-cc:before { content: @fa-var-cc; } -.@{fa-css-prefix}-shekel:before, -.@{fa-css-prefix}-sheqel:before, -.@{fa-css-prefix}-ils:before { content: @fa-var-ils; } -.@{fa-css-prefix}-meanpath:before { content: @fa-var-meanpath; } -.@{fa-css-prefix}-buysellads:before { content: @fa-var-buysellads; } -.@{fa-css-prefix}-connectdevelop:before { content: @fa-var-connectdevelop; } -.@{fa-css-prefix}-dashcube:before { content: @fa-var-dashcube; } -.@{fa-css-prefix}-forumbee:before { content: @fa-var-forumbee; } -.@{fa-css-prefix}-leanpub:before { content: @fa-var-leanpub; } -.@{fa-css-prefix}-sellsy:before { content: @fa-var-sellsy; } -.@{fa-css-prefix}-shirtsinbulk:before { content: @fa-var-shirtsinbulk; } -.@{fa-css-prefix}-simplybuilt:before { content: @fa-var-simplybuilt; } -.@{fa-css-prefix}-skyatlas:before { content: @fa-var-skyatlas; } -.@{fa-css-prefix}-cart-plus:before { content: @fa-var-cart-plus; } -.@{fa-css-prefix}-cart-arrow-down:before { content: @fa-var-cart-arrow-down; } -.@{fa-css-prefix}-diamond:before { content: @fa-var-diamond; } -.@{fa-css-prefix}-ship:before { content: @fa-var-ship; } -.@{fa-css-prefix}-user-secret:before { content: @fa-var-user-secret; } -.@{fa-css-prefix}-motorcycle:before { content: @fa-var-motorcycle; } -.@{fa-css-prefix}-street-view:before { content: @fa-var-street-view; } -.@{fa-css-prefix}-heartbeat:before { content: @fa-var-heartbeat; } -.@{fa-css-prefix}-venus:before { content: @fa-var-venus; } -.@{fa-css-prefix}-mars:before { content: @fa-var-mars; } -.@{fa-css-prefix}-mercury:before { content: @fa-var-mercury; } -.@{fa-css-prefix}-transgender:before { content: @fa-var-transgender; } -.@{fa-css-prefix}-transgender-alt:before { content: @fa-var-transgender-alt; } -.@{fa-css-prefix}-venus-double:before { content: @fa-var-venus-double; } -.@{fa-css-prefix}-mars-double:before { content: @fa-var-mars-double; } -.@{fa-css-prefix}-venus-mars:before { content: @fa-var-venus-mars; } -.@{fa-css-prefix}-mars-stroke:before { content: @fa-var-mars-stroke; } -.@{fa-css-prefix}-mars-stroke-v:before { content: @fa-var-mars-stroke-v; } -.@{fa-css-prefix}-mars-stroke-h:before { content: @fa-var-mars-stroke-h; } -.@{fa-css-prefix}-neuter:before { content: @fa-var-neuter; } -.@{fa-css-prefix}-facebook-official:before { content: @fa-var-facebook-official; } -.@{fa-css-prefix}-pinterest-p:before { content: @fa-var-pinterest-p; } -.@{fa-css-prefix}-whatsapp:before { content: @fa-var-whatsapp; } -.@{fa-css-prefix}-server:before { content: @fa-var-server; } -.@{fa-css-prefix}-user-plus:before { content: @fa-var-user-plus; } -.@{fa-css-prefix}-user-times:before { content: @fa-var-user-times; } -.@{fa-css-prefix}-hotel:before, -.@{fa-css-prefix}-bed:before { content: @fa-var-bed; } -.@{fa-css-prefix}-viacoin:before { content: @fa-var-viacoin; } -.@{fa-css-prefix}-train:before { content: @fa-var-train; } -.@{fa-css-prefix}-subway:before { content: @fa-var-subway; } -.@{fa-css-prefix}-medium:before { content: @fa-var-medium; } diff --git a/src/UI/Content/FontAwesome/larger.less b/src/UI/Content/FontAwesome/larger.less deleted file mode 100644 index c9d646770..000000000 --- a/src/UI/Content/FontAwesome/larger.less +++ /dev/null @@ -1,13 +0,0 @@ -// Icon Sizes -// ------------------------- - -/* makes the font 33% larger relative to the icon container */ -.@{fa-css-prefix}-lg { - font-size: (4em / 3); - line-height: (3em / 4); - vertical-align: -15%; -} -.@{fa-css-prefix}-2x { font-size: 2em; } -.@{fa-css-prefix}-3x { font-size: 3em; } -.@{fa-css-prefix}-4x { font-size: 4em; } -.@{fa-css-prefix}-5x { font-size: 5em; } diff --git a/src/UI/Content/FontAwesome/list.less b/src/UI/Content/FontAwesome/list.less deleted file mode 100644 index 0b440382f..000000000 --- a/src/UI/Content/FontAwesome/list.less +++ /dev/null @@ -1,19 +0,0 @@ -// List Icons -// ------------------------- - -.@{fa-css-prefix}-ul { - padding-left: 0; - margin-left: @fa-li-width; - list-style-type: none; - > li { position: relative; } -} -.@{fa-css-prefix}-li { - position: absolute; - left: -@fa-li-width; - width: @fa-li-width; - top: (2em / 14); - text-align: center; - &.@{fa-css-prefix}-lg { - left: (-@fa-li-width + (4em / 14)); - } -} diff --git a/src/UI/Content/FontAwesome/mixins.less b/src/UI/Content/FontAwesome/mixins.less deleted file mode 100644 index c97f4604c..000000000 --- a/src/UI/Content/FontAwesome/mixins.less +++ /dev/null @@ -1,27 +0,0 @@ -// Mixins -// -------------------------- - -.fa-icon() { - display: inline-block; - font: normal normal normal @fa-font-size-base/1 FontAwesome; // shortening font declaration - font-size: inherit; // can't have font-size inherit on line above, so need to override - text-rendering: auto; // optimizelegibility throws things off #1094 - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - transform: translate(0, 0); // ensures no half-pixel rendering in firefox - -} - -.fa-icon-rotate(@degrees, @rotation) { - filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=@rotation); - -webkit-transform: rotate(@degrees); - -ms-transform: rotate(@degrees); - transform: rotate(@degrees); -} - -.fa-icon-flip(@horiz, @vert, @rotation) { - filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=@rotation, mirror=1); - -webkit-transform: scale(@horiz, @vert); - -ms-transform: scale(@horiz, @vert); - transform: scale(@horiz, @vert); -} diff --git a/src/UI/Content/FontAwesome/path.less b/src/UI/Content/FontAwesome/path.less deleted file mode 100644 index 9211e6659..000000000 --- a/src/UI/Content/FontAwesome/path.less +++ /dev/null @@ -1,15 +0,0 @@ -/* FONT PATH - * -------------------------- */ - -@font-face { - font-family: 'FontAwesome'; - src: url('@{fa-font-path}/fontawesome-webfont.eot?v=@{fa-version}'); - src: url('@{fa-font-path}/fontawesome-webfont.eot?#iefix&v=@{fa-version}') format('embedded-opentype'), - url('@{fa-font-path}/fontawesome-webfont.woff2?v=@{fa-version}') format('woff2'), - url('@{fa-font-path}/fontawesome-webfont.woff?v=@{fa-version}') format('woff'), - url('@{fa-font-path}/fontawesome-webfont.ttf?v=@{fa-version}') format('truetype'), - url('@{fa-font-path}/fontawesome-webfont.svg?v=@{fa-version}#fontawesomeregular') format('svg'); -// src: url('@{fa-font-path}/FontAwesome.otf') format('opentype'); // used when developing fonts - font-weight: normal; - font-style: normal; -} diff --git a/src/UI/Content/FontAwesome/rotated-flipped.less b/src/UI/Content/FontAwesome/rotated-flipped.less deleted file mode 100644 index f6ba81475..000000000 --- a/src/UI/Content/FontAwesome/rotated-flipped.less +++ /dev/null @@ -1,20 +0,0 @@ -// Rotated & Flipped Icons -// ------------------------- - -.@{fa-css-prefix}-rotate-90 { .fa-icon-rotate(90deg, 1); } -.@{fa-css-prefix}-rotate-180 { .fa-icon-rotate(180deg, 2); } -.@{fa-css-prefix}-rotate-270 { .fa-icon-rotate(270deg, 3); } - -.@{fa-css-prefix}-flip-horizontal { .fa-icon-flip(-1, 1, 0); } -.@{fa-css-prefix}-flip-vertical { .fa-icon-flip(1, -1, 2); } - -// Hook for IE8-9 -// ------------------------- - -:root .@{fa-css-prefix}-rotate-90, -:root .@{fa-css-prefix}-rotate-180, -:root .@{fa-css-prefix}-rotate-270, -:root .@{fa-css-prefix}-flip-horizontal, -:root .@{fa-css-prefix}-flip-vertical { - filter: none; -} diff --git a/src/UI/Content/FontAwesome/stacked.less b/src/UI/Content/FontAwesome/stacked.less deleted file mode 100644 index fc53fb0e7..000000000 --- a/src/UI/Content/FontAwesome/stacked.less +++ /dev/null @@ -1,20 +0,0 @@ -// Stacked Icons -// ------------------------- - -.@{fa-css-prefix}-stack { - position: relative; - display: inline-block; - width: 2em; - height: 2em; - line-height: 2em; - vertical-align: middle; -} -.@{fa-css-prefix}-stack-1x, .@{fa-css-prefix}-stack-2x { - position: absolute; - left: 0; - width: 100%; - text-align: center; -} -.@{fa-css-prefix}-stack-1x { line-height: inherit; } -.@{fa-css-prefix}-stack-2x { font-size: 2em; } -.@{fa-css-prefix}-inverse { color: @fa-inverse; } diff --git a/src/UI/Content/FontAwesome/variables.less b/src/UI/Content/FontAwesome/variables.less deleted file mode 100644 index 7d026a20d..000000000 --- a/src/UI/Content/FontAwesome/variables.less +++ /dev/null @@ -1,606 +0,0 @@ -// Variables -// -------------------------- - -@fa-font-path: "../Content/FontAwesome"; -@fa-font-size-base: 14px; -//@fa-font-path: "//netdna.bootstrapcdn.com/font-awesome/4.3.0/fonts"; // for referencing Bootstrap CDN font files directly -@fa-css-prefix: fa; -@fa-version: "4.3.0"; -@fa-border-color: #eee; -@fa-inverse: #fff; -@fa-li-width: (30em / 14); - -@fa-var-adjust: "\f042"; -@fa-var-adn: "\f170"; -@fa-var-align-center: "\f037"; -@fa-var-align-justify: "\f039"; -@fa-var-align-left: "\f036"; -@fa-var-align-right: "\f038"; -@fa-var-ambulance: "\f0f9"; -@fa-var-anchor: "\f13d"; -@fa-var-android: "\f17b"; -@fa-var-angellist: "\f209"; -@fa-var-angle-double-down: "\f103"; -@fa-var-angle-double-left: "\f100"; -@fa-var-angle-double-right: "\f101"; -@fa-var-angle-double-up: "\f102"; -@fa-var-angle-down: "\f107"; -@fa-var-angle-left: "\f104"; -@fa-var-angle-right: "\f105"; -@fa-var-angle-up: "\f106"; -@fa-var-apple: "\f179"; -@fa-var-archive: "\f187"; -@fa-var-area-chart: "\f1fe"; -@fa-var-arrow-circle-down: "\f0ab"; -@fa-var-arrow-circle-left: "\f0a8"; -@fa-var-arrow-circle-o-down: "\f01a"; -@fa-var-arrow-circle-o-left: "\f190"; -@fa-var-arrow-circle-o-right: "\f18e"; -@fa-var-arrow-circle-o-up: "\f01b"; -@fa-var-arrow-circle-right: "\f0a9"; -@fa-var-arrow-circle-up: "\f0aa"; -@fa-var-arrow-down: "\f063"; -@fa-var-arrow-left: "\f060"; -@fa-var-arrow-right: "\f061"; -@fa-var-arrow-up: "\f062"; -@fa-var-arrows: "\f047"; -@fa-var-arrows-alt: "\f0b2"; -@fa-var-arrows-h: "\f07e"; -@fa-var-arrows-v: "\f07d"; -@fa-var-asterisk: "\f069"; -@fa-var-at: "\f1fa"; -@fa-var-automobile: "\f1b9"; -@fa-var-backward: "\f04a"; -@fa-var-ban: "\f05e"; -@fa-var-bank: "\f19c"; -@fa-var-bar-chart: "\f080"; -@fa-var-bar-chart-o: "\f080"; -@fa-var-barcode: "\f02a"; -@fa-var-bars: "\f0c9"; -@fa-var-bed: "\f236"; -@fa-var-beer: "\f0fc"; -@fa-var-behance: "\f1b4"; -@fa-var-behance-square: "\f1b5"; -@fa-var-bell: "\f0f3"; -@fa-var-bell-o: "\f0a2"; -@fa-var-bell-slash: "\f1f6"; -@fa-var-bell-slash-o: "\f1f7"; -@fa-var-bicycle: "\f206"; -@fa-var-binoculars: "\f1e5"; -@fa-var-birthday-cake: "\f1fd"; -@fa-var-bitbucket: "\f171"; -@fa-var-bitbucket-square: "\f172"; -@fa-var-bitcoin: "\f15a"; -@fa-var-bold: "\f032"; -@fa-var-bolt: "\f0e7"; -@fa-var-bomb: "\f1e2"; -@fa-var-book: "\f02d"; -@fa-var-bookmark: "\f02e"; -@fa-var-bookmark-o: "\f097"; -@fa-var-briefcase: "\f0b1"; -@fa-var-btc: "\f15a"; -@fa-var-bug: "\f188"; -@fa-var-building: "\f1ad"; -@fa-var-building-o: "\f0f7"; -@fa-var-bullhorn: "\f0a1"; -@fa-var-bullseye: "\f140"; -@fa-var-bus: "\f207"; -@fa-var-buysellads: "\f20d"; -@fa-var-cab: "\f1ba"; -@fa-var-calculator: "\f1ec"; -@fa-var-calendar: "\f073"; -@fa-var-calendar-o: "\f133"; -@fa-var-camera: "\f030"; -@fa-var-camera-retro: "\f083"; -@fa-var-car: "\f1b9"; -@fa-var-caret-down: "\f0d7"; -@fa-var-caret-left: "\f0d9"; -@fa-var-caret-right: "\f0da"; -@fa-var-caret-square-o-down: "\f150"; -@fa-var-caret-square-o-left: "\f191"; -@fa-var-caret-square-o-right: "\f152"; -@fa-var-caret-square-o-up: "\f151"; -@fa-var-caret-up: "\f0d8"; -@fa-var-cart-arrow-down: "\f218"; -@fa-var-cart-plus: "\f217"; -@fa-var-cc: "\f20a"; -@fa-var-cc-amex: "\f1f3"; -@fa-var-cc-discover: "\f1f2"; -@fa-var-cc-mastercard: "\f1f1"; -@fa-var-cc-paypal: "\f1f4"; -@fa-var-cc-stripe: "\f1f5"; -@fa-var-cc-visa: "\f1f0"; -@fa-var-certificate: "\f0a3"; -@fa-var-chain: "\f0c1"; -@fa-var-chain-broken: "\f127"; -@fa-var-check: "\f00c"; -@fa-var-check-circle: "\f058"; -@fa-var-check-circle-o: "\f05d"; -@fa-var-check-square: "\f14a"; -@fa-var-check-square-o: "\f046"; -@fa-var-chevron-circle-down: "\f13a"; -@fa-var-chevron-circle-left: "\f137"; -@fa-var-chevron-circle-right: "\f138"; -@fa-var-chevron-circle-up: "\f139"; -@fa-var-chevron-down: "\f078"; -@fa-var-chevron-left: "\f053"; -@fa-var-chevron-right: "\f054"; -@fa-var-chevron-up: "\f077"; -@fa-var-child: "\f1ae"; -@fa-var-circle: "\f111"; -@fa-var-circle-o: "\f10c"; -@fa-var-circle-o-notch: "\f1ce"; -@fa-var-circle-thin: "\f1db"; -@fa-var-clipboard: "\f0ea"; -@fa-var-clock-o: "\f017"; -@fa-var-close: "\f00d"; -@fa-var-cloud: "\f0c2"; -@fa-var-cloud-download: "\f0ed"; -@fa-var-cloud-upload: "\f0ee"; -@fa-var-cny: "\f157"; -@fa-var-code: "\f121"; -@fa-var-code-fork: "\f126"; -@fa-var-codepen: "\f1cb"; -@fa-var-coffee: "\f0f4"; -@fa-var-cog: "\f013"; -@fa-var-cogs: "\f085"; -@fa-var-columns: "\f0db"; -@fa-var-comment: "\f075"; -@fa-var-comment-o: "\f0e5"; -@fa-var-comments: "\f086"; -@fa-var-comments-o: "\f0e6"; -@fa-var-compass: "\f14e"; -@fa-var-compress: "\f066"; -@fa-var-connectdevelop: "\f20e"; -@fa-var-copy: "\f0c5"; -@fa-var-copyright: "\f1f9"; -@fa-var-credit-card: "\f09d"; -@fa-var-crop: "\f125"; -@fa-var-crosshairs: "\f05b"; -@fa-var-css3: "\f13c"; -@fa-var-cube: "\f1b2"; -@fa-var-cubes: "\f1b3"; -@fa-var-cut: "\f0c4"; -@fa-var-cutlery: "\f0f5"; -@fa-var-dashboard: "\f0e4"; -@fa-var-dashcube: "\f210"; -@fa-var-database: "\f1c0"; -@fa-var-dedent: "\f03b"; -@fa-var-delicious: "\f1a5"; -@fa-var-desktop: "\f108"; -@fa-var-deviantart: "\f1bd"; -@fa-var-diamond: "\f219"; -@fa-var-digg: "\f1a6"; -@fa-var-dollar: "\f155"; -@fa-var-dot-circle-o: "\f192"; -@fa-var-download: "\f019"; -@fa-var-dribbble: "\f17d"; -@fa-var-dropbox: "\f16b"; -@fa-var-drupal: "\f1a9"; -@fa-var-edit: "\f044"; -@fa-var-eject: "\f052"; -@fa-var-ellipsis-h: "\f141"; -@fa-var-ellipsis-v: "\f142"; -@fa-var-empire: "\f1d1"; -@fa-var-envelope: "\f0e0"; -@fa-var-envelope-o: "\f003"; -@fa-var-envelope-square: "\f199"; -@fa-var-eraser: "\f12d"; -@fa-var-eur: "\f153"; -@fa-var-euro: "\f153"; -@fa-var-exchange: "\f0ec"; -@fa-var-exclamation: "\f12a"; -@fa-var-exclamation-circle: "\f06a"; -@fa-var-exclamation-triangle: "\f071"; -@fa-var-expand: "\f065"; -@fa-var-external-link: "\f08e"; -@fa-var-external-link-square: "\f14c"; -@fa-var-eye: "\f06e"; -@fa-var-eye-slash: "\f070"; -@fa-var-eyedropper: "\f1fb"; -@fa-var-facebook: "\f09a"; -@fa-var-facebook-f: "\f09a"; -@fa-var-facebook-official: "\f230"; -@fa-var-facebook-square: "\f082"; -@fa-var-fast-backward: "\f049"; -@fa-var-fast-forward: "\f050"; -@fa-var-fax: "\f1ac"; -@fa-var-female: "\f182"; -@fa-var-fighter-jet: "\f0fb"; -@fa-var-file: "\f15b"; -@fa-var-file-archive-o: "\f1c6"; -@fa-var-file-audio-o: "\f1c7"; -@fa-var-file-code-o: "\f1c9"; -@fa-var-file-excel-o: "\f1c3"; -@fa-var-file-image-o: "\f1c5"; -@fa-var-file-movie-o: "\f1c8"; -@fa-var-file-o: "\f016"; -@fa-var-file-pdf-o: "\f1c1"; -@fa-var-file-photo-o: "\f1c5"; -@fa-var-file-picture-o: "\f1c5"; -@fa-var-file-powerpoint-o: "\f1c4"; -@fa-var-file-sound-o: "\f1c7"; -@fa-var-file-text: "\f15c"; -@fa-var-file-text-o: "\f0f6"; -@fa-var-file-video-o: "\f1c8"; -@fa-var-file-word-o: "\f1c2"; -@fa-var-file-zip-o: "\f1c6"; -@fa-var-files-o: "\f0c5"; -@fa-var-film: "\f008"; -@fa-var-filter: "\f0b0"; -@fa-var-fire: "\f06d"; -@fa-var-fire-extinguisher: "\f134"; -@fa-var-flag: "\f024"; -@fa-var-flag-checkered: "\f11e"; -@fa-var-flag-o: "\f11d"; -@fa-var-flash: "\f0e7"; -@fa-var-flask: "\f0c3"; -@fa-var-flickr: "\f16e"; -@fa-var-floppy-o: "\f0c7"; -@fa-var-folder: "\f07b"; -@fa-var-folder-o: "\f114"; -@fa-var-folder-open: "\f07c"; -@fa-var-folder-open-o: "\f115"; -@fa-var-font: "\f031"; -@fa-var-forumbee: "\f211"; -@fa-var-forward: "\f04e"; -@fa-var-foursquare: "\f180"; -@fa-var-frown-o: "\f119"; -@fa-var-futbol-o: "\f1e3"; -@fa-var-gamepad: "\f11b"; -@fa-var-gavel: "\f0e3"; -@fa-var-gbp: "\f154"; -@fa-var-ge: "\f1d1"; -@fa-var-gear: "\f013"; -@fa-var-gears: "\f085"; -@fa-var-genderless: "\f1db"; -@fa-var-gift: "\f06b"; -@fa-var-git: "\f1d3"; -@fa-var-git-square: "\f1d2"; -@fa-var-github: "\f09b"; -@fa-var-github-alt: "\f113"; -@fa-var-github-square: "\f092"; -@fa-var-gittip: "\f184"; -@fa-var-glass: "\f000"; -@fa-var-globe: "\f0ac"; -@fa-var-google: "\f1a0"; -@fa-var-google-plus: "\f0d5"; -@fa-var-google-plus-square: "\f0d4"; -@fa-var-google-wallet: "\f1ee"; -@fa-var-graduation-cap: "\f19d"; -@fa-var-gratipay: "\f184"; -@fa-var-group: "\f0c0"; -@fa-var-h-square: "\f0fd"; -@fa-var-hacker-news: "\f1d4"; -@fa-var-hand-o-down: "\f0a7"; -@fa-var-hand-o-left: "\f0a5"; -@fa-var-hand-o-right: "\f0a4"; -@fa-var-hand-o-up: "\f0a6"; -@fa-var-hdd-o: "\f0a0"; -@fa-var-header: "\f1dc"; -@fa-var-headphones: "\f025"; -@fa-var-heart: "\f004"; -@fa-var-heart-o: "\f08a"; -@fa-var-heartbeat: "\f21e"; -@fa-var-history: "\f1da"; -@fa-var-home: "\f015"; -@fa-var-hospital-o: "\f0f8"; -@fa-var-hotel: "\f236"; -@fa-var-html5: "\f13b"; -@fa-var-ils: "\f20b"; -@fa-var-image: "\f03e"; -@fa-var-inbox: "\f01c"; -@fa-var-indent: "\f03c"; -@fa-var-info: "\f129"; -@fa-var-info-circle: "\f05a"; -@fa-var-inr: "\f156"; -@fa-var-instagram: "\f16d"; -@fa-var-institution: "\f19c"; -@fa-var-ioxhost: "\f208"; -@fa-var-italic: "\f033"; -@fa-var-joomla: "\f1aa"; -@fa-var-jpy: "\f157"; -@fa-var-jsfiddle: "\f1cc"; -@fa-var-key: "\f084"; -@fa-var-keyboard-o: "\f11c"; -@fa-var-krw: "\f159"; -@fa-var-language: "\f1ab"; -@fa-var-laptop: "\f109"; -@fa-var-lastfm: "\f202"; -@fa-var-lastfm-square: "\f203"; -@fa-var-leaf: "\f06c"; -@fa-var-leanpub: "\f212"; -@fa-var-legal: "\f0e3"; -@fa-var-lemon-o: "\f094"; -@fa-var-level-down: "\f149"; -@fa-var-level-up: "\f148"; -@fa-var-life-bouy: "\f1cd"; -@fa-var-life-buoy: "\f1cd"; -@fa-var-life-ring: "\f1cd"; -@fa-var-life-saver: "\f1cd"; -@fa-var-lightbulb-o: "\f0eb"; -@fa-var-line-chart: "\f201"; -@fa-var-link: "\f0c1"; -@fa-var-linkedin: "\f0e1"; -@fa-var-linkedin-square: "\f08c"; -@fa-var-linux: "\f17c"; -@fa-var-list: "\f03a"; -@fa-var-list-alt: "\f022"; -@fa-var-list-ol: "\f0cb"; -@fa-var-list-ul: "\f0ca"; -@fa-var-location-arrow: "\f124"; -@fa-var-lock: "\f023"; -@fa-var-long-arrow-down: "\f175"; -@fa-var-long-arrow-left: "\f177"; -@fa-var-long-arrow-right: "\f178"; -@fa-var-long-arrow-up: "\f176"; -@fa-var-magic: "\f0d0"; -@fa-var-magnet: "\f076"; -@fa-var-mail-forward: "\f064"; -@fa-var-mail-reply: "\f112"; -@fa-var-mail-reply-all: "\f122"; -@fa-var-male: "\f183"; -@fa-var-map-marker: "\f041"; -@fa-var-mars: "\f222"; -@fa-var-mars-double: "\f227"; -@fa-var-mars-stroke: "\f229"; -@fa-var-mars-stroke-h: "\f22b"; -@fa-var-mars-stroke-v: "\f22a"; -@fa-var-maxcdn: "\f136"; -@fa-var-meanpath: "\f20c"; -@fa-var-medium: "\f23a"; -@fa-var-medkit: "\f0fa"; -@fa-var-meh-o: "\f11a"; -@fa-var-mercury: "\f223"; -@fa-var-microphone: "\f130"; -@fa-var-microphone-slash: "\f131"; -@fa-var-minus: "\f068"; -@fa-var-minus-circle: "\f056"; -@fa-var-minus-square: "\f146"; -@fa-var-minus-square-o: "\f147"; -@fa-var-mobile: "\f10b"; -@fa-var-mobile-phone: "\f10b"; -@fa-var-money: "\f0d6"; -@fa-var-moon-o: "\f186"; -@fa-var-mortar-board: "\f19d"; -@fa-var-motorcycle: "\f21c"; -@fa-var-music: "\f001"; -@fa-var-navicon: "\f0c9"; -@fa-var-neuter: "\f22c"; -@fa-var-newspaper-o: "\f1ea"; -@fa-var-openid: "\f19b"; -@fa-var-outdent: "\f03b"; -@fa-var-pagelines: "\f18c"; -@fa-var-paint-brush: "\f1fc"; -@fa-var-paper-plane: "\f1d8"; -@fa-var-paper-plane-o: "\f1d9"; -@fa-var-paperclip: "\f0c6"; -@fa-var-paragraph: "\f1dd"; -@fa-var-paste: "\f0ea"; -@fa-var-pause: "\f04c"; -@fa-var-paw: "\f1b0"; -@fa-var-paypal: "\f1ed"; -@fa-var-pencil: "\f040"; -@fa-var-pencil-square: "\f14b"; -@fa-var-pencil-square-o: "\f044"; -@fa-var-phone: "\f095"; -@fa-var-phone-square: "\f098"; -@fa-var-photo: "\f03e"; -@fa-var-picture-o: "\f03e"; -@fa-var-pie-chart: "\f200"; -@fa-var-pied-piper: "\f1a7"; -@fa-var-pied-piper-alt: "\f1a8"; -@fa-var-pinterest: "\f0d2"; -@fa-var-pinterest-p: "\f231"; -@fa-var-pinterest-square: "\f0d3"; -@fa-var-plane: "\f072"; -@fa-var-play: "\f04b"; -@fa-var-play-circle: "\f144"; -@fa-var-play-circle-o: "\f01d"; -@fa-var-plug: "\f1e6"; -@fa-var-plus: "\f067"; -@fa-var-plus-circle: "\f055"; -@fa-var-plus-square: "\f0fe"; -@fa-var-plus-square-o: "\f196"; -@fa-var-power-off: "\f011"; -@fa-var-print: "\f02f"; -@fa-var-puzzle-piece: "\f12e"; -@fa-var-qq: "\f1d6"; -@fa-var-qrcode: "\f029"; -@fa-var-question: "\f128"; -@fa-var-question-circle: "\f059"; -@fa-var-quote-left: "\f10d"; -@fa-var-quote-right: "\f10e"; -@fa-var-ra: "\f1d0"; -@fa-var-random: "\f074"; -@fa-var-rebel: "\f1d0"; -@fa-var-recycle: "\f1b8"; -@fa-var-reddit: "\f1a1"; -@fa-var-reddit-square: "\f1a2"; -@fa-var-refresh: "\f021"; -@fa-var-remove: "\f00d"; -@fa-var-renren: "\f18b"; -@fa-var-reorder: "\f0c9"; -@fa-var-repeat: "\f01e"; -@fa-var-reply: "\f112"; -@fa-var-reply-all: "\f122"; -@fa-var-retweet: "\f079"; -@fa-var-rmb: "\f157"; -@fa-var-road: "\f018"; -@fa-var-rocket: "\f135"; -@fa-var-rotate-left: "\f0e2"; -@fa-var-rotate-right: "\f01e"; -@fa-var-rouble: "\f158"; -@fa-var-rss: "\f09e"; -@fa-var-rss-square: "\f143"; -@fa-var-rub: "\f158"; -@fa-var-ruble: "\f158"; -@fa-var-rupee: "\f156"; -@fa-var-save: "\f0c7"; -@fa-var-scissors: "\f0c4"; -@fa-var-search: "\f002"; -@fa-var-search-minus: "\f010"; -@fa-var-search-plus: "\f00e"; -@fa-var-sellsy: "\f213"; -@fa-var-send: "\f1d8"; -@fa-var-send-o: "\f1d9"; -@fa-var-server: "\f233"; -@fa-var-share: "\f064"; -@fa-var-share-alt: "\f1e0"; -@fa-var-share-alt-square: "\f1e1"; -@fa-var-share-square: "\f14d"; -@fa-var-share-square-o: "\f045"; -@fa-var-shekel: "\f20b"; -@fa-var-sheqel: "\f20b"; -@fa-var-shield: "\f132"; -@fa-var-ship: "\f21a"; -@fa-var-shirtsinbulk: "\f214"; -@fa-var-shopping-cart: "\f07a"; -@fa-var-sign-in: "\f090"; -@fa-var-sign-out: "\f08b"; -@fa-var-signal: "\f012"; -@fa-var-simplybuilt: "\f215"; -@fa-var-sitemap: "\f0e8"; -@fa-var-skyatlas: "\f216"; -@fa-var-skype: "\f17e"; -@fa-var-slack: "\f198"; -@fa-var-sliders: "\f1de"; -@fa-var-slideshare: "\f1e7"; -@fa-var-smile-o: "\f118"; -@fa-var-soccer-ball-o: "\f1e3"; -@fa-var-sort: "\f0dc"; -@fa-var-sort-alpha-asc: "\f15d"; -@fa-var-sort-alpha-desc: "\f15e"; -@fa-var-sort-amount-asc: "\f160"; -@fa-var-sort-amount-desc: "\f161"; -@fa-var-sort-asc: "\f0de"; -@fa-var-sort-desc: "\f0dd"; -@fa-var-sort-down: "\f0dd"; -@fa-var-sort-numeric-asc: "\f162"; -@fa-var-sort-numeric-desc: "\f163"; -@fa-var-sort-up: "\f0de"; -@fa-var-soundcloud: "\f1be"; -@fa-var-space-shuttle: "\f197"; -@fa-var-spinner: "\f110"; -@fa-var-spoon: "\f1b1"; -@fa-var-spotify: "\f1bc"; -@fa-var-square: "\f0c8"; -@fa-var-square-o: "\f096"; -@fa-var-stack-exchange: "\f18d"; -@fa-var-stack-overflow: "\f16c"; -@fa-var-star: "\f005"; -@fa-var-star-half: "\f089"; -@fa-var-star-half-empty: "\f123"; -@fa-var-star-half-full: "\f123"; -@fa-var-star-half-o: "\f123"; -@fa-var-star-o: "\f006"; -@fa-var-steam: "\f1b6"; -@fa-var-steam-square: "\f1b7"; -@fa-var-step-backward: "\f048"; -@fa-var-step-forward: "\f051"; -@fa-var-stethoscope: "\f0f1"; -@fa-var-stop: "\f04d"; -@fa-var-street-view: "\f21d"; -@fa-var-strikethrough: "\f0cc"; -@fa-var-stumbleupon: "\f1a4"; -@fa-var-stumbleupon-circle: "\f1a3"; -@fa-var-subscript: "\f12c"; -@fa-var-subway: "\f239"; -@fa-var-suitcase: "\f0f2"; -@fa-var-sun-o: "\f185"; -@fa-var-superscript: "\f12b"; -@fa-var-support: "\f1cd"; -@fa-var-table: "\f0ce"; -@fa-var-tablet: "\f10a"; -@fa-var-tachometer: "\f0e4"; -@fa-var-tag: "\f02b"; -@fa-var-tags: "\f02c"; -@fa-var-tasks: "\f0ae"; -@fa-var-taxi: "\f1ba"; -@fa-var-tencent-weibo: "\f1d5"; -@fa-var-terminal: "\f120"; -@fa-var-text-height: "\f034"; -@fa-var-text-width: "\f035"; -@fa-var-th: "\f00a"; -@fa-var-th-large: "\f009"; -@fa-var-th-list: "\f00b"; -@fa-var-thumb-tack: "\f08d"; -@fa-var-thumbs-down: "\f165"; -@fa-var-thumbs-o-down: "\f088"; -@fa-var-thumbs-o-up: "\f087"; -@fa-var-thumbs-up: "\f164"; -@fa-var-ticket: "\f145"; -@fa-var-times: "\f00d"; -@fa-var-times-circle: "\f057"; -@fa-var-times-circle-o: "\f05c"; -@fa-var-tint: "\f043"; -@fa-var-toggle-down: "\f150"; -@fa-var-toggle-left: "\f191"; -@fa-var-toggle-off: "\f204"; -@fa-var-toggle-on: "\f205"; -@fa-var-toggle-right: "\f152"; -@fa-var-toggle-up: "\f151"; -@fa-var-train: "\f238"; -@fa-var-transgender: "\f224"; -@fa-var-transgender-alt: "\f225"; -@fa-var-trash: "\f1f8"; -@fa-var-trash-o: "\f014"; -@fa-var-tree: "\f1bb"; -@fa-var-trello: "\f181"; -@fa-var-trophy: "\f091"; -@fa-var-truck: "\f0d1"; -@fa-var-try: "\f195"; -@fa-var-tty: "\f1e4"; -@fa-var-tumblr: "\f173"; -@fa-var-tumblr-square: "\f174"; -@fa-var-turkish-lira: "\f195"; -@fa-var-twitch: "\f1e8"; -@fa-var-twitter: "\f099"; -@fa-var-twitter-square: "\f081"; -@fa-var-umbrella: "\f0e9"; -@fa-var-underline: "\f0cd"; -@fa-var-undo: "\f0e2"; -@fa-var-university: "\f19c"; -@fa-var-unlink: "\f127"; -@fa-var-unlock: "\f09c"; -@fa-var-unlock-alt: "\f13e"; -@fa-var-unsorted: "\f0dc"; -@fa-var-upload: "\f093"; -@fa-var-usd: "\f155"; -@fa-var-user: "\f007"; -@fa-var-user-md: "\f0f0"; -@fa-var-user-plus: "\f234"; -@fa-var-user-secret: "\f21b"; -@fa-var-user-times: "\f235"; -@fa-var-users: "\f0c0"; -@fa-var-venus: "\f221"; -@fa-var-venus-double: "\f226"; -@fa-var-venus-mars: "\f228"; -@fa-var-viacoin: "\f237"; -@fa-var-video-camera: "\f03d"; -@fa-var-vimeo-square: "\f194"; -@fa-var-vine: "\f1ca"; -@fa-var-vk: "\f189"; -@fa-var-volume-down: "\f027"; -@fa-var-volume-off: "\f026"; -@fa-var-volume-up: "\f028"; -@fa-var-warning: "\f071"; -@fa-var-wechat: "\f1d7"; -@fa-var-weibo: "\f18a"; -@fa-var-weixin: "\f1d7"; -@fa-var-whatsapp: "\f232"; -@fa-var-wheelchair: "\f193"; -@fa-var-wifi: "\f1eb"; -@fa-var-windows: "\f17a"; -@fa-var-won: "\f159"; -@fa-var-wordpress: "\f19a"; -@fa-var-wrench: "\f0ad"; -@fa-var-xing: "\f168"; -@fa-var-xing-square: "\f169"; -@fa-var-yahoo: "\f19e"; -@fa-var-yelp: "\f1e9"; -@fa-var-yen: "\f157"; -@fa-var-youtube: "\f167"; -@fa-var-youtube-play: "\f16a"; -@fa-var-youtube-square: "\f166"; - diff --git a/src/UI/Content/Images/404.png b/src/UI/Content/Images/404.png deleted file mode 100644 index deeb83f8f..000000000 Binary files a/src/UI/Content/Images/404.png and /dev/null differ diff --git a/src/UI/Content/Images/background/logo.png b/src/UI/Content/Images/background/logo.png deleted file mode 100644 index a67bc72e1..000000000 Binary files a/src/UI/Content/Images/background/logo.png and /dev/null differ diff --git a/src/UI/Content/Images/favicon-debug.ico b/src/UI/Content/Images/favicon-debug.ico deleted file mode 100644 index db19f38e4..000000000 Binary files a/src/UI/Content/Images/favicon-debug.ico and /dev/null differ diff --git a/src/UI/Content/Images/favicon.ico b/src/UI/Content/Images/favicon.ico deleted file mode 100644 index 1922557d6..000000000 Binary files a/src/UI/Content/Images/favicon.ico and /dev/null differ diff --git a/src/UI/Content/Images/logos/128.png b/src/UI/Content/Images/logos/128.png deleted file mode 100644 index b41422f1f..000000000 Binary files a/src/UI/Content/Images/logos/128.png and /dev/null differ diff --git a/src/UI/Content/Images/logos/32.png b/src/UI/Content/Images/logos/32.png deleted file mode 100644 index 0594fca88..000000000 Binary files a/src/UI/Content/Images/logos/32.png and /dev/null differ diff --git a/src/UI/Content/Images/logos/48.png b/src/UI/Content/Images/logos/48.png deleted file mode 100644 index a0dd26a1a..000000000 Binary files a/src/UI/Content/Images/logos/48.png and /dev/null differ diff --git a/src/UI/Content/Images/logos/64.png b/src/UI/Content/Images/logos/64.png deleted file mode 100644 index 03d680ff0..000000000 Binary files a/src/UI/Content/Images/logos/64.png and /dev/null differ diff --git a/src/UI/Content/Images/poster-dark.png b/src/UI/Content/Images/poster-dark.png deleted file mode 100644 index 6a88711c3..000000000 Binary files a/src/UI/Content/Images/poster-dark.png and /dev/null differ diff --git a/src/UI/Content/Images/safari/logo.svg b/src/UI/Content/Images/safari/logo.svg deleted file mode 100644 index d3eece392..000000000 --- a/src/UI/Content/Images/safari/logo.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" width="218px" height="218px" viewBox="0 0 218 218" enable-background="new 0 0 218 218" xml:space="preserve"><g display="none"/><g display="none"><path display="inline" fill-rule="evenodd" clip-rule="evenodd" fill="#EFEEEE" d="M217.5 108.95c0 29.833-10.533 55.399-31.6 76.7 -0.7 0.833-1.484 1.6-2.351 2.3 -3.466 3.399-7.134 6.483-11 9.25 -18.267 13.467-39.366 20.2-63.3 20.2 -23.967 0-45.033-6.733-63.2-20.2 -4.8-3.4-9.3-7.25-13.5-11.55 -16.367-16.267-26.417-35.167-30.15-56.7 -0.733-4.2-1.217-8.467-1.45-12.8 -0.1-2.4-0.15-4.801-0.15-7.2 0-2.534 0.05-4.95 0.15-7.25 0-0.233 0.066-0.467 0.2-0.7 1.567-26.6 12.033-49.583 31.4-68.95C53.85 11.017 79.417 0.5 109.25 0.5c29.934 0 55.483 10.517 76.65 31.55C206.967 53.483 217.5 79.117 217.5 108.95z"/></g><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="134.724 60.365 129.7 63.282 129.7 69.117 134.724 72.034 139.802 69.116 139.802 63.282 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="139.157 74.994 142.869 71.227 140.087 69.611 135.008 72.529 135.008 78.362 140.087 81.28 143.517 79.289 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="145.396 60.366 140.373 63.282 140.373 69.117 143.283 70.807 150.418 63.566 150.418 63.282 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="129.415 69.611 124.392 72.528 124.392 78.363 129.415 81.28 134.438 78.363 134.438 72.528 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="124.106 60.366 119.084 63.282 119.084 69.117 124.106 72.034 129.129 69.117 129.129 63.282 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="113.49 60.366 108.468 63.282 108.468 69.117 113.49 72.034 118.513 69.117 118.513 63.282 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M113.49 78.857l-0.479 0.278c0.423 0.05 0.843 0.109 1.259 0.176L113.49 78.857z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M123.821 72.528l-5.022-2.917 -5.023 2.917v5.835l2.188 1.27c-0.001 0-0.003 0-0.004 0 1.372 0.303 2.705 0.701 3.998 1.195 -0.076-0.029-0.152-0.059-0.229-0.087l4.093-2.377V72.528z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="150.703 51.175 145.681 54.037 145.681 59.871 150.703 62.787 151.831 62.132 155.726 58.18 155.726 54.037 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="182.551 32.684 177.528 35.6 177.528 40.433 184.392 33.753 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="163.429 163.159 166.342 166.172 166.342 164.819 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="182.551 51.175 177.528 54.037 177.528 59.871 178.193 60.257 185.561 52.89 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M175 157.7c-5.063-6.93-8.862-14.367-11.396-22.313 0.053 0.166 0.105 0.33 0.159 0.495l-2.159 1.254v5.836l5.022 2.916 0.941-0.546c0.083 0.172 0.166 0.343 0.25 0.515l-0.906 0.525v5.836l5.023 2.916 0.91-0.528c0.105 0.159 0.211 0.316 0.317 0.476l-0.942 0.547v5.834l5.022 2.862 2.8-1.596L175 157.7z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="145.681 151.549 145.681 152.219 147.293 153.155 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="157.737 153.715 159.54 155.54 157.05 158 157.769 157.305 161.034 160.683 161.034 155.629 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="164.581 53.033 161.319 51.175 156.297 54.037 156.297 57.981 157.888 59.548 157.901 59.535 158.597 60.247 157.902 59.533 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="158.808 42.894 161.319 44.352 166.342 41.436 166.342 35.6 166.202 35.519 166.62 35.102 166.627 35.106 171.65 32.19 171.65 30.085 172.173 29.564 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="186.188 34.796 178.68 42.104 182.551 44.352 187.573 41.436 187.573 35.6 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="171.936 32.684 166.912 35.6 166.912 41.436 171.936 44.352 175.817 42.098 176.958 40.988 176.958 35.6 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M113.205 72.528l-5.022-2.917 -5.022 2.917v5.835l1.447 0.84c-0.001 0-0.002 0-0.002 0 1.457-0.202 2.955-0.304 4.495-0.304 1.005 0 1.991 0.045 2.961 0.13 -0.002 0-0.003 0-0.004 0l1.148-0.667V72.528z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="71.026 97.292 66.004 100.209 66.004 106.044 71.026 108.961 76.049 106.044 76.049 100.209 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="65.718 106.539 60.639 109.457 60.639 115.29 65.718 118.208 70.741 115.291 70.741 109.456 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="71.026 115.785 66.004 118.702 66.004 124.535 71.026 127.397 76.049 124.535 76.049 118.702 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M79.5 114.5c-0.2-1.166-0.333-2.35-0.4-3.55 -0.033-0.667-0.05-1.333-0.05-2 0-0.456 0.019-0.875 0.033-1.302 -0.004 0.159-0.009 0.318-0.012 0.479l-2.735-1.588 -5.023 2.917v5.835l5.023 2.916 3.52-2.043c0.001 0.006 0.002 0.011 0.004 0.017C79.728 115.625 79.599 115.069 79.5 114.5z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="65.718 125.029 60.639 127.893 60.639 133.725 65.718 136.643 70.741 133.726 70.741 127.892 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M81.174 97.564l-4.554 2.645v5.835l2.467 1.433c0.006-0.171 0.004-0.36 0.013-0.527 0-0.067 0.017-0.134 0.05-0.2 0.192-3.263 0.87-6.329 2.03-9.199C81.178 97.556 81.176 97.56 81.174 97.564z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="71.026 134.221 66.004 137.137 66.004 142.973 69.161 144.806 74.729 139.022 76.049 140.294 76.049 137.137 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="65.718 88.102 60.639 90.965 60.639 96.797 65.718 99.715 70.741 96.798 70.741 90.964 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M60.354 78.857l-5.023 2.917v3.562c-0.007-0.021-0.014-0.043-0.021-0.064 0.298 0.951 0.577 1.909 0.838 2.874 -0.008-0.028-0.015-0.056-0.022-0.084l4.229 2.41 5.079-2.864v-5.832L60.354 78.857z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="139.802 137.138 134.724 134.22 129.7 137.137 129.7 142.973 134.724 145.89 138.07 143.967 136.979 142.88 139.802 140.048 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="76.335 125.03 71.312 127.892 71.312 133.726 76.335 136.643 81.357 133.726 81.357 127.892 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="102.875 60.366 97.852 63.282 97.852 69.117 102.875 72.034 107.897 69.117 107.897 63.282 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M60.068 78.363v-5.835l-5.023-2.917 -5.022 2.917v0.058c-0.099-0.188-0.199-0.374-0.299-0.561l0.014 0.008 5.022-2.917v-5.835l-5.022-2.916 -5.022 2.916v0.68c-0.121-0.171-0.243-0.342-0.365-0.512 4.843 6.734 8.48 13.96 10.913 21.677 -0.163-0.517-0.329-1.032-0.503-1.545v-1.809l-0.815-0.473c-0.106-0.284-0.214-0.566-0.324-0.849l1.425 0.828L60.068 78.363z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="61.124 153.154 60.354 152.712 55.331 155.629 55.331 161.463 56.063 161.88 59.78 158.156 57.979 156.422 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M99.735 80.313c0.003-0.001 0.007-0.002 0.011-0.004C99.743 80.311 99.739 80.312 99.735 80.313z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M103.72 79.348l-0.845-0.491 -1.861 1.081c0.906-0.244 1.83-0.444 2.771-0.601C103.763 79.341 103.742 79.345 103.72 79.348z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M97.566 69.611l-5.022 2.917v5.835l4.844 2.813c-0.076 0.032-0.15 0.067-0.226 0.1 0.439-0.189 0.884-0.368 1.332-0.534l4.095-2.378v-5.835L97.566 69.611z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="74.331 80.776 74.129 80.979 73.361 80.213 71.026 78.857 66.004 81.774 66.004 87.608 71.026 90.47 76.049 87.608 76.049 81.774 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="44.43 161.957 39.407 164.819 39.407 170.653 44.407 173.558 49.452 168.503 49.452 164.819 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="44.715 47.496 44.715 50.68 49.738 53.542 50.399 53.166 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="39.155 41.949 39.122 41.93 34.1 44.846 34.1 50.68 39.122 53.542 44.145 50.68 44.145 46.926 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="187.859 41.929 182.837 44.846 182.837 50.68 185.979 52.471 185.742 52.708 192.939 45.511 192.939 44.847 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="161.319 143.467 156.297 146.383 156.297 152.219 161.319 155.135 166.342 152.219 166.342 146.383 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="166.627 152.713 161.604 155.629 161.604 161.272 162.015 161.697 166.627 164.325 171.65 161.463 171.65 155.629 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="145.396 78.857 144.886 79.153 144.144 79.907 143.938 79.704 140.373 81.774 140.373 87.608 145.396 90.47 150.418 87.608 150.418 81.774 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="150.703 143.467 148.671 144.646 155.521 151.471 153.756 153.241 153.762 153.247 155.54 151.49 155.726 151.678 155.726 146.383 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="171.936 161.957 166.912 164.819 166.912 166.762 172.934 172.99 176.958 170.653 176.958 164.819 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="159.313 60.952 160.967 62.583 161.319 62.787 166.342 59.871 166.342 54.111 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="156.012 78.857 150.989 81.774 150.989 87.608 156.012 90.47 161.034 87.608 161.034 81.774 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M132.188 89.683c-0.002-0.003-0.005-0.005-0.007-0.008 0.826 0.988 1.577 2.005 2.256 3.052v-1.762L132.188 89.683z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="150.703 88.102 145.681 90.964 145.681 96.798 150.703 99.715 155.726 96.798 155.726 90.964 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="139.004 106.502 139.005 106.513 139.004 106.502 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M139.802 100.21l-2.347-1.349c-0.001-0.003-0.002-0.006-0.004-0.01 0.844 2.42 1.357 4.972 1.553 7.651l0.798-0.458V100.21zM140.087 106.539l-1.04 0.598c-0.012-0.209-0.027-0.416-0.042-0.623 0.058 0.802 0.095 1.612 0.095 2.437 0 3.003-0.39 5.849-1.16 8.539 0.041-0.148 0.08-0.297 0.12-0.446l2.027 1.165 5.023-2.917v-5.835L140.087 106.539z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="44.43 180.395 39.407 183.311 39.407 189.146 44.43 192.063 48.943 189.441 47.571 190.817 49.452 188.932 49.452 183.311 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M187.999 183.478c0.021-0.022 0.042-0.044 0.063-0.066C188.041 183.434 188.021 183.455 187.999 183.478z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="150.703 125.03 145.681 127.893 145.681 133.726 150.703 136.643 155.726 133.726 155.726 127.893 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="166.912 169.64 166.912 170.653 169.146 171.95 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M180.216 162.902l0.246 0.246 -2.934 1.671v5.834l5.022 2.917 5.022-2.917v-0.412l1.755 1.75 -1.469-0.844 -5.022 2.917v5.835l5.023 2.917 1.595-0.916c1.2-1.314 2.361-2.642 3.484-3.981v-2.326L180.216 162.902z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M188.145 183.311v0.011c0.008-0.008 0.015-0.016 0.021-0.023L188.145 183.311z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="63.821 70.701 60.639 72.529 60.639 78.362 65.718 81.28 70.741 78.363 70.741 77.601 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="65.718 51.174 60.639 54.038 60.639 59.871 60.789 59.957 61.871 58.872 65.769 62.758 70.741 59.871 70.741 54.037 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M31.319 34.132l-1.746 1.014c-0.264 0.283-0.522 0.569-0.782 0.854v5.436l5.023 2.917 4.898-2.845L31.319 34.132z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="44.715 192.557 44.715 193.681 47.379 191.01 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="156.012 134.221 150.989 137.137 150.989 142.973 156.012 145.889 161.034 142.973 161.034 137.137 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="167.349 53.131 171.65 50.68 171.65 48.944 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="172.221 44.846 172.221 45.599 174.137 43.733 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="160.711 63.094 153.397 70.516 156.012 72.034 161.034 69.117 161.034 63.282 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="178.255 42.517 172.221 48.39 172.221 50.68 177.243 53.542 182.266 50.68 182.266 44.846 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="171.936 51.175 166.912 54.037 166.912 59.871 171.936 62.787 176.958 59.871 176.958 54.037 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="166.627 41.93 161.604 44.847 161.604 50.68 165.007 52.619 171.65 46.153 171.65 44.846 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M134.724 78.857l-5.023 2.917v5.258c-0.003-0.003-0.006-0.005-0.009-0.008 0.22 0.207 0.441 0.41 0.658 0.625 0.23 0.235 0.449 0.474 0.671 0.713 -0.002-0.002-0.003-0.003-0.005-0.005l3.708 2.113 5.078-2.864v-5.832L134.724 78.857z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M124.106 78.857l-3.693 2.145c3.143 1.263 6.049 3.093 8.716 5.496v-4.724L124.106 78.857z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M133.052 127.103c0.055-0.071 0.109-0.143 0.164-0.214 -0.847 1.12-1.78 2.206-2.811 3.252l0.002 0.002 -0.058 0.058c-0.199 0.233-0.416 0.45-0.649 0.649l-0.707 0.707 -0.037-0.037c-0.744 0.669-1.506 1.306-2.306 1.881 -0.504 0.371-1.021 0.71-1.54 1.044 0.079-0.056 0.159-0.109 0.239-0.162l4.065 2.36 5.022-2.917v-5.833L133.052 127.103z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="152.983 70.936 145.681 78.347 145.681 78.363 150.703 81.28 155.726 78.363 155.726 72.528 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="135.008 93.631 135.008 93.631 135.007 93.629 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M140.087 88.102l-5.079 2.864v2.666c0.859 1.437 1.576 2.931 2.164 4.477 -0.011-0.028-0.021-0.057-0.031-0.085l2.945 1.693 5.024-2.917v-5.834L140.087 88.102z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="145.396 97.292 140.373 100.209 140.373 106.044 145.396 108.961 150.418 106.044 150.418 100.209 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M137.906 117.613c-0.944 3.243-2.448 6.259-4.515 9.046 0.004-0.005 0.007-0.009 0.011-0.014l1.321 0.753 5.078-2.863v-5.833L137.906 117.613z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="150.703 106.539 145.681 109.456 145.681 115.291 150.703 118.207 155.726 115.291 155.726 109.456 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="145.396 115.785 140.373 118.702 140.373 124.535 145.396 127.397 150.418 124.535 150.418 118.702 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="140.087 125.029 135.008 127.893 135.008 133.725 140.087 136.643 145.11 133.726 145.11 127.892 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M159.8 110c0-1.027 0.018-2.046 0.051-3.06 -0.005 0.153-0.011 0.306-0.015 0.46l-3.539 2.055v5.835l3.809 2.211c0.019 0.232 0.039 0.464 0.06 0.695l-4.153-2.412 -5.022 2.917v5.833l5.022 2.862 4.976-2.836c0.034 0.199 0.068 0.4 0.104 0.599l-4.794 2.731v5.834l5.022 2.917 2.266-1.315c0.004 0.013 0.008 0.025 0.012 0.038C161.065 127.418 159.8 118.963 159.8 110z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M169.602 70.966l-2.689 1.562v3.698c-0.068 0.148-0.133 0.297-0.2 0.445 0.96-2.113 2.02-4.185 3.18-6.213C169.795 70.628 169.697 70.796 169.602 70.966zM166.658 76.79c0.018-0.04 0.035-0.08 0.054-0.119C166.693 76.71 166.676 76.75 166.658 76.79zM166.658 76.79c-0.105 0.233-0.213 0.467-0.316 0.702v-4.964l-5.022-2.917 -5.022 2.917v5.835l5.022 2.917 4.523-2.627c-0.121 0.288-0.24 0.579-0.358 0.868l-3.88 2.253v5.834l0.979 0.558c-0.051 0.19-0.102 0.38-0.151 0.571l-1.113-0.635 -5.022 2.862v5.834l4.146 2.408c-0.024 0.206-0.049 0.411-0.073 0.617l-4.358-2.531 -5.022 2.917v5.835l5.022 2.917 3.846-2.233c-0.002 0.037-0.002 0.073-0.004 0.11C160.222 96.019 162.491 86.003 166.658 76.79z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M172.221 63.282v3.363c-0.192 0.296-0.383 0.591-0.57 0.889v-4.252l-5.023-2.916 -5.022 2.916v5.835l5.022 2.917 3.539-2.055c-0.059 0.102-0.116 0.205-0.175 0.307 0.953-1.657 1.972-3.285 3.059-4.885l4.726-4.726 -0.532-0.309L172.221 63.282z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="145.396 134.221 140.373 137.137 140.373 139.475 141.921 137.921 148.253 144.229 150.418 142.973 150.418 137.137 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="86.951 69.611 81.928 72.528 81.928 78.363 86.951 81.28 91.973 78.363 91.973 72.528 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="172.221 175.131 172.221 179.899 177.243 182.815 178.785 181.92 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="177.243 171.147 173.342 173.413 180.566 180.886 182.266 179.899 182.266 174.064 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="170.6 173.455 166.627 171.147 161.604 174.064 161.604 179.899 166.627 182.815 171.65 179.899 171.65 174.541 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="160.136 162.632 156.297 164.819 156.297 170.653 161.319 173.57 166.342 170.653 166.342 169.05 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="156.338 158.703 154.56 160.46 150.989 156.844 150.989 161.463 156.012 164.325 159.727 162.208 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M195.45 43c-2.051-2.664-4.251-5.266-6.585-7.813l-0.721 0.414v5.833l5.079 2.918 2.084-1.21 -0.755 0.755L195.45 43z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M158.39 43.311l0.146-0.146L158.1 43.6c-13.934 10.733-30.133 16.1-48.6 16.1 -2.231 0-4.431-0.08-6.598-0.238 0.086 0.006 0.172 0.013 0.258 0.019v0.39l5.022 2.916 5.022-2.916v-0.244c0.19-0.008 0.381-0.016 0.57-0.024v0.269l5.023 2.916 5.022-2.916V58.55c0.19-0.032 0.38-0.064 0.57-0.097v1.418l5.023 2.916 5.022-2.916v-3.853c0.189-0.06 0.381-0.121 0.57-0.182v4.034l5.079 2.917 5.023-2.917v-5.834l-2.209-1.258c0.213-0.097 0.426-0.195 0.638-0.293l1.856 1.058 5.022-2.862v-1.843c0.19-0.114 0.381-0.229 0.571-0.345v2.188l5.022 2.862 5.022-2.862v-5.834L158.39 43.311z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M177.4 24.35l-5.18 5.166v2.674l5.022 2.916 5.022-2.916v-3.614C180.664 27.105 179.044 25.692 177.4 24.35z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M61.9 44.35l-2.099-2.099 -4.47 2.595v5.834l5.023 2.863 5.079-2.864v-3.851c0.19 0.125 0.38 0.25 0.571 0.375v3.477l5.022 2.862 3.063-1.745c0.207 0.102 0.415 0.202 0.623 0.302l-3.4 1.938v5.834l5.023 2.916 5.022-2.916v-4.97c0.189 0.069 0.38 0.139 0.571 0.207v4.763l5.022 2.916 5.022-2.916v-1.935c0.191 0.04 0.379 0.079 0.571 0.117v1.818l5.022 2.916 5.023-2.916v-0.432c0.084 0.006 0.168 0.012 0.252 0.019C87.614 58.334 73.967 53.298 61.9 44.35z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="193.51 44.846 193.51 44.94 193.733 44.717 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M185.9 32.05c-1.012-1.011-2.034-1.986-3.063-2.943v3.083l1.979 1.15 1.086-1.057 1.395 1.434 -0.684 0.666 1.246 0.724 0.615-0.354C187.635 33.844 186.777 32.943 185.9 32.05z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="108.183 143.467 103.16 146.383 103.16 152.219 108.183 155.135 113.205 152.219 113.205 146.383 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M109.1 139c-0.341 0-0.671-0.026-1.008-0.036 0.125 0.005 0.25 0.011 0.376 0.014v3.995l5.022 2.916 5.022-2.916v-5.391c0.016-0.005 0.03-0.012 0.046-0.017C115.605 138.52 112.453 139 109.1 139z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="118.799 143.467 113.775 146.383 113.775 152.219 118.799 155.135 123.821 152.219 123.821 146.383 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M98.096 136.994l-0.244 0.143v5.836l5.023 2.916 5.022-2.916v-4.016C104.402 138.835 101.133 138.185 98.096 136.994z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="134.724 152.712 129.7 155.629 129.7 161.463 134.724 164.326 139.802 161.462 139.802 155.63 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="138.489 144.384 135.008 146.384 135.008 152.218 140.087 155.135 145.11 152.219 145.11 150.98 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M124.824 134.638c-1.817 1.14-3.734 2.043-5.74 2.735v5.6l5.022 2.916 5.022-2.916v-5.836L124.824 134.638z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="148.88 154.736 145.396 152.713 140.373 155.629 140.373 161.463 145.396 164.325 150.418 161.463 150.418 156.269 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="129.415 143.467 124.392 146.383 124.392 152.219 129.415 155.135 134.438 152.219 134.438 146.383 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="52.001 34.452 50.023 35.6 50.023 41.436 55.045 44.352 59.383 41.833 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="161.319 180.395 161.208 180.458 166.342 185.592 166.342 183.311 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M156.012 171.147l-3.688 2.143c-0.181-0.115-0.363-0.23-0.545-0.345l3.947-2.292v-5.834l-5.022-2.862 -5.022 2.862v4.685c-0.189-0.096-0.38-0.191-0.57-0.285v-4.399l-5.023-2.862 -5.079 2.863v0.293c-0.189-0.061-0.381-0.122-0.57-0.182v-0.112l-5.022-2.862 -1.981 1.129c-0.28-0.06-0.563-0.118-0.844-0.176l2.539-1.447v-5.834l-5.022-2.916 -5.022 2.916v5.834l0.633 0.361c-0.011-0.001-0.022-0.002-0.033-0.004 14.299 1.64 27.104 6.815 38.416 15.529l2.691 2.691 0.243-0.142v-5.835L156.012 171.147z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M118.168 161.66l0.345-0.197v-5.834l-5.022-2.916 -5.022 2.916v5.627c-0.19 0.002-0.38 0.005-0.57 0.008v-5.635l-5.022-2.916 -5.023 2.916v5.834l0.776 0.442c-0.314 0.039-0.627 0.079-0.939 0.121l-0.122-0.069 -0.198 0.113c-0.245 0.034-0.488 0.073-0.732 0.109 4.166-0.617 8.453-0.93 12.864-0.93 3.433 0 6.786 0.187 10.062 0.558C119.099 161.755 118.634 161.705 118.168 161.66z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M67.759 172.57c0.415-0.252 0.832-0.499 1.25-0.742C68.591 172.071 68.174 172.318 67.759 172.57z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M65.4 174.046l-5.045-2.898 -5.023 2.917v5.835l1.981 1.15 -0.417 0.418 -1.85-1.073 -5.022 2.916v5.048l-0.326 0.327L61.8 176.55c1.433-1.054 2.893-2.04 4.369-2.984C65.914 173.729 65.654 173.879 65.4 174.046z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M182.551 180.395l-1.575 0.914 4.443 4.596 -1.438 1.391 -4.787-4.951 -1.666 0.967v5.836l2.934 1.703c1.044-0.94 2.076-1.901 3.088-2.899 0.866-0.7 1.65-1.467 2.351-2.3 0.567-0.574 1.121-1.152 1.673-1.731v-0.608L182.551 180.395z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M193.51 176.161v1.075c0.162-0.195 0.33-0.39 0.49-0.586L193.51 176.161z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M39.122 189.641l-1.48 0.859c1.818 1.626 3.683 3.185 5.608 4.65l0.895-0.896v-1.697L39.122 189.641z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M177.243 189.641l-4.336 2.517 2.743 2.743c1.486-1.16 2.94-2.38 4.368-3.649L177.243 189.641z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="97.566 143.467 92.544 146.383 92.544 152.219 97.566 155.135 102.589 152.219 102.589 146.383 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="171.936 180.395 166.912 183.311 166.912 186.162 172.49 191.74 176.958 189.146 176.958 183.311 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M41.55 24c-0.044 0.036-0.088 0.074-0.132 0.11l0.58 0.337L41.55 24z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M43.57 26.021L40.951 24.5c-2.322 1.917-4.606 3.939-6.852 6.068v1.622l5.022 2.916 5.022-2.916v-5.595L43.57 26.021z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="44.715 27.166 44.715 32.19 49.738 35.106 51.584 34.034 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M32.55 32.05c-0.536 0.536-1.055 1.081-1.578 1.624l0.37-0.215 1.064-1.067 0.285 0.284 0.838-0.486v-1.076C33.203 31.426 32.875 31.733 32.55 32.05z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M29.823 171.913L24.7 177.05c1.479 1.784 3.036 3.54 4.652 5.274l4.176-2.425v-5.835L29.823 171.913z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M37.988 182.818l-4.126 4.134c1.09 1.081 2.206 2.123 3.335 3.146l1.64-0.951v-5.836L37.988 182.818z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M33.814 180.395l-4.062 2.357c0.88 0.937 1.772 1.868 2.692 2.79l3.755-3.763L33.814 180.395z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M30.321 34.344c-0.134 0.141-0.268 0.282-0.4 0.423C30.054 34.625 30.188 34.485 30.321 34.344z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M28.22 36.623c-1.713 1.903-3.359 3.843-4.92 5.827l1.282 1.279 -0.115-0.115 3.753-2.179V36.623z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="71.026 60.366 66.188 63.175 73.583 70.549 76.049 69.117 76.049 63.282 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="55.045 161.957 50.023 164.819 50.023 167.931 55.645 162.299 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M46.365 154.153c-0.026 0.041-0.052 0.081-0.078 0.121C46.313 154.234 46.339 154.194 46.365 154.153zM55.045 136.643l5.023-2.917v-5.834l-2.624-1.495c0.009-0.049 0.018-0.098 0.027-0.146 -1.89 10.016-5.593 19.316-11.107 27.903 0.049-0.076 0.1-0.152 0.148-0.229l2.939-1.706v-3.293c0.193-0.357 0.384-0.717 0.571-1.077v4.37l5.022 2.916 5.023-2.916v-5.836l-5.023-2.916 -3.985 2.314c0.146-0.303 0.291-0.607 0.433-0.912l3.268-1.896v-5.836l-0.126-0.073c0.063-0.185 0.124-0.367 0.186-0.552L55.045 136.643zM38.019 163.696l1.104 0.629 5.022-2.862v-4.024c0.192-0.27 0.382-0.54 0.571-0.812v4.836l5.022 2.862 5.022-2.862v-5.834l-5.022-2.916 -3.911 2.271c0.155-0.236 0.307-0.473 0.459-0.709 -0.777 1.206-1.588 2.397-2.437 3.575L38.019 163.696z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="39.407 58.519 39.407 58.519 38.896 58.008 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="44.43 51.175 39.407 54.037 39.407 58.519 42.65 61.754 44.43 62.787 49.452 59.871 49.452 54.037 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="50.818 53.583 50.023 54.037 50.023 59.871 55.045 62.787 58.206 60.953 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="56.929 63.829 59.003 61.748 58.624 61.37 55.331 63.282 55.331 69.117 60.354 72.034 63.402 70.283 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="71.312 78.17 71.312 78.363 71.776 78.633 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="92.258 134.221 87.236 137.137 87.236 142.973 92.258 145.889 97.281 142.973 97.281 137.137 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="81.643 152.713 76.62 155.629 76.62 161.463 81.643 164.325 86.665 161.463 86.665 155.629 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="86.951 143.467 81.928 146.383 81.928 152.219 86.951 155.135 91.973 152.219 91.973 146.383 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="78.793 144.895 71.588 152.379 76.335 155.135 81.357 152.219 81.357 146.383 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="71.18 152.803 66.004 158.18 66.004 161.463 71.026 164.325 76.049 161.463 76.049 155.629 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="65.718 143.466 60.639 146.384 60.639 152.218 61.532 152.73 68.754 145.229 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="60.354 134.22 55.331 137.137 55.331 142.973 60.354 145.89 65.433 142.972 65.433 137.138 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M60.354 115.784l-1.648 0.958c0.002-0.028 0.004-0.057 0.006-0.085 -0.236 3.253-0.646 6.437-1.232 9.552 0.025-0.135 0.051-0.271 0.076-0.406l2.799 1.596 5.079-2.863v-5.833L60.354 115.784z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="81.643 134.221 76.62 137.137 76.62 140.844 79.771 143.878 79.201 144.471 81.643 145.889 86.665 142.973 86.665 137.137 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M92.258 78.857l-5.022 2.917v5.834l0.423 0.241c0.064-0.066 0.125-0.134 0.191-0.199 2.713-2.682 5.679-4.74 8.892-6.188L92.258 78.857z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M79.983 116.749l-3.363 1.953v5.833l5.022 2.862 2.593-1.478c-1.984-2.82-3.409-5.875-4.253-9.173C79.982 116.747 79.983 116.748 79.983 116.749zM84.235 125.92c0.068 0.099 0.142 0.194 0.212 0.292 -0.068-0.099-0.136-0.198-0.205-0.296L84.235 125.92zM91.6 133.4c-1.333-0.934-2.583-2-3.75-3.2 -1.278-1.269-2.402-2.603-3.402-3.988 0.041 0.059 0.08 0.117 0.122 0.175l-2.641 1.505v5.834l5.022 2.917 5.022-2.917v-0.071C91.85 133.566 91.722 133.491 91.6 133.4z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="81.643 78.857 76.62 81.774 76.62 87.608 81.643 90.47 86.665 87.608 86.665 81.774 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M97.281 155.629l-5.022-2.916 -5.022 2.916v5.834l3.271 1.864c-0.085 0.02-0.17 0.04-0.255 0.06 1.974-0.458 3.978-0.841 6.011-1.151 -0.151 0.022-0.303 0.044-0.454 0.067l1.473-0.84V155.629z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="76.335 88.102 71.312 90.964 71.312 96.798 76.335 99.715 81.357 96.798 81.357 90.964 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M86.951 88.102l-5.022 2.862v4.912c1.315-2.72 3.09-5.254 5.323-7.603L86.951 88.102z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="49.738 171.147 49.498 171.287 44.715 176.078 44.715 179.899 49.738 182.815 54.76 179.899 54.76 174.064 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="39.122 171.147 34.1 174.064 34.1 179.899 36.617 181.361 43.99 173.975 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="38.406 182.399 39.122 182.815 44.145 179.899 44.145 176.65 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="32.688 51.816 32.688 51.816 32.383 51.512 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="33.529 50.68 33.529 44.846 28.506 41.93 24.886 44.032 32.383 51.512 32.269 51.398 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="38.836 54.037 33.814 51.175 32.688 51.816 38.836 57.95 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="37.601 164.115 30.24 171.495 33.814 173.57 38.836 170.653 38.836 164.819 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="57.445 163.324 50.091 170.692 55.045 173.57 60.068 170.653 60.068 164.819 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="65.718 161.957 60.639 164.82 60.639 170.652 65.718 173.57 70.741 170.653 70.741 164.819 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M89.689 163.519l-2.739-1.562 -5.022 2.862v0.997c-0.191 0.067-0.38 0.136-0.571 0.205v-1.202l-5.022-2.862 -5.023 2.862v5.704c-0.228 0.123-0.451 0.259-0.678 0.384 6.138-3.381 12.626-5.884 19.474-7.486C89.968 163.453 89.829 163.485 89.689 163.519z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="63.021 161.277 60.5 158.85 61.208 159.556 57.863 162.906 60.354 164.326 65.433 161.462 65.433 158.772 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="81.643 60.366 76.62 63.282 76.62 69.117 81.643 72.034 86.665 69.117 86.665 63.282 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="76.335 69.611 74.001 70.966 79.071 76.021 74.748 80.358 76.335 81.28 81.357 78.363 81.357 72.528 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="92.258 60.366 87.236 63.282 87.236 69.117 92.258 72.034 97.281 69.117 97.281 63.282 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M96.715 136.402c-0.133-0.061-0.266-0.122-0.398-0.185C96.449 136.28 96.582 136.342 96.715 136.402z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M95.012 135.559c-0.099-0.054-0.195-0.111-0.292-0.166C94.817 135.447 94.914 135.505 95.012 135.559z"/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="44.43 32.684 39.407 35.6 39.407 39.376 44.348 44.305 44.43 44.352 49.452 41.436 49.452 35.6 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="33.814 32.684 33.109 33.093 38.836 38.807 38.836 35.6 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="49.738 41.93 44.826 44.782 52.201 52.139 54.76 50.68 54.76 44.846 "/><polygon fill-rule="evenodd" clip-rule="evenodd" fill="#010101" points="55.045 51.175 52.621 52.557 59.996 59.914 60.068 59.871 60.068 54.037 "/><path fill-rule="evenodd" clip-rule="evenodd" fill="#010101" d="M60.354 97.292l-2.138 1.241c-0.027-0.204-0.054-0.409-0.083-0.612l1.934-1.123v-5.834l-3.736-2.129c-0.055-0.21-0.111-0.419-0.167-0.629 1.856 6.886 2.786 14.15 2.786 21.793 0 2.23-0.079 4.43-0.234 6.599 0.013-0.182 0.026-0.363 0.038-0.545l1.314-0.763v-5.835l-1.126-0.654c-0.003-0.222-0.006-0.445-0.011-0.667l1.423 0.826 5.079-2.917v-5.834L60.354 97.292z"/></svg> \ No newline at end of file diff --git a/src/UI/Content/Images/touch/114.png b/src/UI/Content/Images/touch/114.png deleted file mode 100644 index 2d5dc66d9..000000000 Binary files a/src/UI/Content/Images/touch/114.png and /dev/null differ diff --git a/src/UI/Content/Images/touch/144.png b/src/UI/Content/Images/touch/144.png deleted file mode 100644 index 09a2dffd1..000000000 Binary files a/src/UI/Content/Images/touch/144.png and /dev/null differ diff --git a/src/UI/Content/Images/touch/57.png b/src/UI/Content/Images/touch/57.png deleted file mode 100644 index 4b3d84b35..000000000 Binary files a/src/UI/Content/Images/touch/57.png and /dev/null differ diff --git a/src/UI/Content/Images/touch/72.png b/src/UI/Content/Images/touch/72.png deleted file mode 100644 index f8874e378..000000000 Binary files a/src/UI/Content/Images/touch/72.png and /dev/null differ diff --git a/src/UI/Content/Messenger/messenger.css b/src/UI/Content/Messenger/messenger.css deleted file mode 100644 index 9fc58c936..000000000 --- a/src/UI/Content/Messenger/messenger.css +++ /dev/null @@ -1,101 +0,0 @@ -/* line 4, ../../src/sass/messenger.sass */ -ul.messenger { - margin: 0; - padding: 0; -} -/* line 8, ../../src/sass/messenger.sass */ -ul.messenger > li { - list-style: none; - margin: 0; - padding: 0; -} -/* line 14, ../../src/sass/messenger.sass */ -ul.messenger.messenger-empty { - display: none; -} -/* line 17, ../../src/sass/messenger.sass */ -ul.messenger .messenger-message { - overflow: hidden; - *zoom: 1; -} -/* line 20, ../../src/sass/messenger.sass */ -ul.messenger .messenger-message.messenger-hidden { - display: none; -} -/* line 23, ../../src/sass/messenger.sass */ -ul.messenger .messenger-message .messenger-phrase, ul.messenger .messenger-message .messenger-actions a { - padding-right: 5px; -} -/* line 26, ../../src/sass/messenger.sass */ -ul.messenger .messenger-message .messenger-actions { - float: right; -} -/* line 29, ../../src/sass/messenger.sass */ -ul.messenger .messenger-message .messenger-actions a { - cursor: pointer; - text-decoration: underline; -} -/* line 33, ../../src/sass/messenger.sass */ -ul.messenger .messenger-message ul, ul.messenger .messenger-message ol { - margin: 10px 18px 0; -} -/* line 36, ../../src/sass/messenger.sass */ -ul.messenger.messenger-fixed { - position: fixed; - z-index: 10000; -} -/* line 40, ../../src/sass/messenger.sass */ -ul.messenger.messenger-fixed .messenger-message { - min-width: 0; - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; -} -/* line 45, ../../src/sass/messenger.sass */ -ul.messenger.messenger-fixed .message .messenger-actions { - float: left; -} -/* line 48, ../../src/sass/messenger.sass */ -ul.messenger.messenger-fixed.messenger-on-top { - top: 20px; -} -/* line 51, ../../src/sass/messenger.sass */ -ul.messenger.messenger-fixed.messenger-on-bottom { - bottom: 20px; -} -/* line 54, ../../src/sass/messenger.sass */ -ul.messenger.messenger-fixed.messenger-on-top, ul.messenger.messenger-fixed.messenger-on-bottom { - left: 50%; - width: 800px; - margin-left: -400px; -} -@media (max-width: 960px) { - /* line 54, ../../src/sass/messenger.sass */ - ul.messenger.messenger-fixed.messenger-on-top, ul.messenger.messenger-fixed.messenger-on-bottom { - left: 10%; - width: 80%; - margin-left: 0px; - } -} -/* line 64, ../../src/sass/messenger.sass */ -ul.messenger.messenger-fixed.messenger-on-top.messenger-on-right, ul.messenger.messenger-fixed.messenger-on-bottom.messenger-on-right { - right: 20px; - left: auto; -} -/* line 68, ../../src/sass/messenger.sass */ -ul.messenger.messenger-fixed.messenger-on-top.messenger-on-left, ul.messenger.messenger-fixed.messenger-on-bottom.messenger-on-left { - left: 20px; - margin-left: 0px; -} -/* line 72, ../../src/sass/messenger.sass */ -ul.messenger.messenger-fixed.messenger-on-right, ul.messenger.messenger-fixed.messenger-on-left { - width: 350px; -} -/* line 75, ../../src/sass/messenger.sass */ -ul.messenger.messenger-fixed.messenger-on-right .messenger-actions, ul.messenger.messenger-fixed.messenger-on-left .messenger-actions { - float: left; -} -/* line 78, ../../src/sass/messenger.sass */ -ul.messenger .messenger-spinner { - display: none; -} diff --git a/src/UI/Content/Messenger/messenger.flat.css b/src/UI/Content/Messenger/messenger.flat.css deleted file mode 100644 index df8d35aeb..000000000 --- a/src/UI/Content/Messenger/messenger.flat.css +++ /dev/null @@ -1,462 +0,0 @@ -@-webkit-keyframes ui-spinner-rotate-right { - /* line 64, ../../src/sass/messenger-spinner.scss */ - 0% { - -webkit-transform: rotate(0deg); - } - - /* line 65, ../../src/sass/messenger-spinner.scss */ - 25% { - -webkit-transform: rotate(180deg); - } - - /* line 66, ../../src/sass/messenger-spinner.scss */ - 50% { - -webkit-transform: rotate(180deg); - } - - /* line 67, ../../src/sass/messenger-spinner.scss */ - 75% { - -webkit-transform: rotate(360deg); - } - - /* line 68, ../../src/sass/messenger-spinner.scss */ - 100% { - -webkit-transform: rotate(360deg); - } -} - -@-webkit-keyframes ui-spinner-rotate-left { - /* line 72, ../../src/sass/messenger-spinner.scss */ - 0% { - -webkit-transform: rotate(0deg); - } - - /* line 73, ../../src/sass/messenger-spinner.scss */ - 25% { - -webkit-transform: rotate(0deg); - } - - /* line 74, ../../src/sass/messenger-spinner.scss */ - 50% { - -webkit-transform: rotate(180deg); - } - - /* line 75, ../../src/sass/messenger-spinner.scss */ - 75% { - -webkit-transform: rotate(180deg); - } - - /* line 76, ../../src/sass/messenger-spinner.scss */ - 100% { - -webkit-transform: rotate(360deg); - } -} - -@-moz-keyframes ui-spinner-rotate-right { - /* line 80, ../../src/sass/messenger-spinner.scss */ - 0% { - -moz-transform: rotate(0deg); - } - - /* line 81, ../../src/sass/messenger-spinner.scss */ - 25% { - -moz-transform: rotate(180deg); - } - - /* line 82, ../../src/sass/messenger-spinner.scss */ - 50% { - -moz-transform: rotate(180deg); - } - - /* line 83, ../../src/sass/messenger-spinner.scss */ - 75% { - -moz-transform: rotate(360deg); - } - - /* line 84, ../../src/sass/messenger-spinner.scss */ - 100% { - -moz-transform: rotate(360deg); - } -} - -@-moz-keyframes ui-spinner-rotate-left { - /* line 88, ../../src/sass/messenger-spinner.scss */ - 0% { - -moz-transform: rotate(0deg); - } - - /* line 89, ../../src/sass/messenger-spinner.scss */ - 25% { - -moz-transform: rotate(0deg); - } - - /* line 90, ../../src/sass/messenger-spinner.scss */ - 50% { - -moz-transform: rotate(180deg); - } - - /* line 91, ../../src/sass/messenger-spinner.scss */ - 75% { - -moz-transform: rotate(180deg); - } - - /* line 92, ../../src/sass/messenger-spinner.scss */ - 100% { - -moz-transform: rotate(360deg); - } -} - -@keyframes ui-spinner-rotate-right { - /* line 96, ../../src/sass/messenger-spinner.scss */ - 0% { - transform: rotate(0deg); - } - - /* line 97, ../../src/sass/messenger-spinner.scss */ - 25% { - transform: rotate(180deg); - } - - /* line 98, ../../src/sass/messenger-spinner.scss */ - 50% { - transform: rotate(180deg); - } - - /* line 99, ../../src/sass/messenger-spinner.scss */ - 75% { - transform: rotate(360deg); - } - - /* line 100, ../../src/sass/messenger-spinner.scss */ - 100% { - transform: rotate(360deg); - } -} - -@keyframes ui-spinner-rotate-left { - /* line 104, ../../src/sass/messenger-spinner.scss */ - 0% { - transform: rotate(0deg); - } - - /* line 105, ../../src/sass/messenger-spinner.scss */ - 25% { - transform: rotate(0deg); - } - - /* line 106, ../../src/sass/messenger-spinner.scss */ - 50% { - transform: rotate(180deg); - } - - /* line 107, ../../src/sass/messenger-spinner.scss */ - 75% { - transform: rotate(180deg); - } - - /* line 108, ../../src/sass/messenger-spinner.scss */ - 100% { - transform: rotate(360deg); - } -} - -/* line 116, ../../src/sass/messenger-spinner.scss */ -.messenger-spinner { - position: relative; - border-radius: 100%; -} -/* line 120, ../../src/sass/messenger-spinner.scss */ -ul.messenger.messenger-spinner-active .messenger-spinner .messenger-spinner { - display: block; -} -/* line 124, ../../src/sass/messenger-spinner.scss */ -.messenger-spinner .messenger-spinner-side { - width: 50%; - height: 100%; - overflow: hidden; - position: absolute; -} -/* line 130, ../../src/sass/messenger-spinner.scss */ -.messenger-spinner .messenger-spinner-side .messenger-spinner-fill { - border-radius: 999px; - position: absolute; - width: 100%; - height: 100%; - -webkit-animation-iteration-count: infinite; - -moz-animation-iteration-count: infinite; - -ms-animation-iteration-count: infinite; - -o-animation-iteration-count: infinite; - animation-iteration-count: infinite; - -webkit-animation-timing-function: linear; - -moz-animation-timing-function: linear; - -ms-animation-timing-function: linear; - -o-animation-timing-function: linear; - animation-timing-function: linear; -} -/* line 140, ../../src/sass/messenger-spinner.scss */ -.messenger-spinner .messenger-spinner-side-left { - left: 0; -} -/* line 143, ../../src/sass/messenger-spinner.scss */ -.messenger-spinner .messenger-spinner-side-left .messenger-spinner-fill { - left: 100%; - border-top-left-radius: 0; - border-bottom-left-radius: 0; - -webkit-animation-name: ui-spinner-rotate-left; - -moz-animation-name: ui-spinner-rotate-left; - -ms-animation-name: ui-spinner-rotate-left; - -o-animation-name: ui-spinner-rotate-left; - animation-name: ui-spinner-rotate-left; - -webkit-transform-origin: 0 50%; - -moz-transform-origin: 0 50%; - -ms-transform-origin: 0 50%; - -o-transform-origin: 0 50%; - transform-origin: 0 50%; -} -/* line 152, ../../src/sass/messenger-spinner.scss */ -.messenger-spinner .messenger-spinner-side-right { - left: 50%; -} -/* line 155, ../../src/sass/messenger-spinner.scss */ -.messenger-spinner .messenger-spinner-side-right .messenger-spinner-fill { - left: -100%; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - -webkit-animation-name: ui-spinner-rotate-right; - -moz-animation-name: ui-spinner-rotate-right; - -ms-animation-name: ui-spinner-rotate-right; - -o-animation-name: ui-spinner-rotate-right; - animation-name: ui-spinner-rotate-right; - -webkit-transform-origin: 100% 50%; - -moz-transform-origin: 100% 50%; - -ms-transform-origin: 100% 50%; - -o-transform-origin: 100% 50%; - transform-origin: 100% 50%; -} - -/* line 15, ../../src/sass/messenger-theme-flat.sass */ -ul.messenger-theme-flat { - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - -ms-border-radius: 4px; - -o-border-radius: 4px; - border-radius: 4px; - -moz-user-select: none; - -webkit-user-select: none; - -o-user-select: none; - user-select: none; - background: #404040; -} -/* line 20, ../../src/sass/messenger-theme-flat.sass */ -ul.messenger-theme-flat.messenger-empty { - display: none; -} -/* line 23, ../../src/sass/messenger-theme-flat.sass */ -ul.messenger-theme-flat .messenger-message { - -webkit-box-shadow: inset 0px 1px rgba(255, 255, 255, 0.13), inset 48px 0px 0px #292929; - -moz-box-shadow: inset 0px 1px rgba(255, 255, 255, 0.13), inset 48px 0px 0px #292929; - box-shadow: inset 0px 1px rgba(255, 255, 255, 0.13), inset 48px 0px 0px #292929; - -webkit-border-radius: 0px; - -moz-border-radius: 0px; - -ms-border-radius: 0px; - -o-border-radius: 0px; - border-radius: 0px; - position: relative; - border: 0px; - margin-bottom: 0px; - font-size: 13px; - background: transparent; - color: #f0f0f0; - font-weight: 500; - padding: 10px 30px 13px 65px; -} -/* line 35, ../../src/sass/messenger-theme-flat.sass */ -ul.messenger-theme-flat .messenger-message .messenger-close { - position: absolute; - top: 0px; - right: 0px; - color: #888888; - opacity: 1; - font-weight: bold; - display: block; - font-size: 20px; - line-height: 20px; - padding: 8px 10px 7px 7px; - cursor: pointer; - background: transparent; - border: 0; - -webkit-appearance: none; -} -/* line 51, ../../src/sass/messenger-theme-flat.sass */ -ul.messenger-theme-flat .messenger-message .messenger-close:hover { - color: #bbbbbb; -} -/* line 54, ../../src/sass/messenger-theme-flat.sass */ -ul.messenger-theme-flat .messenger-message .messenger-close:active { - color: #777777; -} -/* line 57, ../../src/sass/messenger-theme-flat.sass */ -ul.messenger-theme-flat .messenger-message .messenger-actions { - float: none; - margin-top: 10px; -} -/* line 61, ../../src/sass/messenger-theme-flat.sass */ -ul.messenger-theme-flat .messenger-message .messenger-actions a { - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - -ms-border-radius: 4px; - -o-border-radius: 4px; - border-radius: 4px; - text-decoration: none; - color: #aaaaaa; - background: #2e2e2e; - display: inline-block; - padding: 10px; - margin-right: 10px; - padding: 4px 11px 6px; - text-transform: capitalize; -} -/* line 72, ../../src/sass/messenger-theme-flat.sass */ -ul.messenger-theme-flat .messenger-message .messenger-actions a:hover { - color: #f0f0f0; - background: #2e2e2e; -} -/* line 76, ../../src/sass/messenger-theme-flat.sass */ -ul.messenger-theme-flat .messenger-message .messenger-actions a:active { - background: #292929; - color: #aaaaaa; -} -/* line 80, ../../src/sass/messenger-theme-flat.sass */ -ul.messenger-theme-flat .messenger-message .messenger-actions .messenger-phrase { - display: none; -} -/* line 83, ../../src/sass/messenger-theme-flat.sass */ -ul.messenger-theme-flat .messenger-message .messenger-message-inner:before { - -webkit-border-radius: 50%; - -moz-border-radius: 50%; - -ms-border-radius: 50%; - -o-border-radius: 50%; - border-radius: 50%; - position: absolute; - left: 17px; - display: block; - content: " "; - top: 50%; - margin-top: -8px; - height: 13px; - width: 13px; - z-index: 20; -} -/* line 95, ../../src/sass/messenger-theme-flat.sass */ -ul.messenger-theme-flat .messenger-message.alert-success .messenger-message-inner:before { - background: #5fca4a; -} -/* line 98, ../../src/sass/messenger-theme-flat.sass */ -ul.messenger-theme-flat .messenger-message.alert-info .messenger-message-inner:before { - background: #61c4b8; -} -/* line 103, ../../src/sass/messenger-theme-flat.sass */ -ul.messenger-theme-flat .messenger-message.alert-error .messenger-message-inner:before { - background: #dd6a45; -} -/* line 32, ../../src/sass/messenger-spinner.scss */ -ul.messenger-theme-flat .messenger-message.alert-error.messenger-retry-soon .messenger-spinner { - width: 32px; - height: 32px; - background: transparent; -} -/* line 37, ../../src/sass/messenger-spinner.scss */ -ul.messenger-theme-flat .messenger-message.alert-error.messenger-retry-soon .messenger-spinner .messenger-spinner-side .messenger-spinner-fill { - background: #dd6a45; - -webkit-animation-duration: 20s; - -moz-animation-duration: 20s; - -ms-animation-duration: 20s; - -o-animation-duration: 20s; - animation-duration: 20s; - opacity: 1; -} -/* line 45, ../../src/sass/messenger-spinner.scss */ -ul.messenger-theme-flat .messenger-message.alert-error.messenger-retry-soon .messenger-spinner:after { - content: ""; - background: #292929; - position: absolute; - width: 26px; - height: 26px; - border-radius: 50%; - top: 3px; - left: 3px; - display: block; -} -/* line 32, ../../src/sass/messenger-spinner.scss */ -ul.messenger-theme-flat .messenger-message.alert-error.messenger-retry-later .messenger-spinner { - width: 32px; - height: 32px; - background: transparent; -} -/* line 37, ../../src/sass/messenger-spinner.scss */ -ul.messenger-theme-flat .messenger-message.alert-error.messenger-retry-later .messenger-spinner .messenger-spinner-side .messenger-spinner-fill { - background: #dd6a45; - -webkit-animation-duration: 600s; - -moz-animation-duration: 600s; - -ms-animation-duration: 600s; - -o-animation-duration: 600s; - animation-duration: 600s; - opacity: 1; -} -/* line 45, ../../src/sass/messenger-spinner.scss */ -ul.messenger-theme-flat .messenger-message.alert-error.messenger-retry-later .messenger-spinner:after { - content: ""; - background: #292929; - position: absolute; - width: 26px; - height: 26px; - border-radius: 50%; - top: 3px; - left: 3px; - display: block; -} -/* line 114, ../../src/sass/messenger-theme-flat.sass */ -ul.messenger-theme-flat .messenger-message-slot.messenger-last .messenger-message { - -webkit-border-radius: 4px 4px 0px 0px; - -moz-border-radius: 4px 4px 0px 0px; - -ms-border-radius: 4px 4px 0px 0px; - -o-border-radius: 4px 4px 0px 0px; - border-radius: 4px 4px 0px 0px; - -webkit-box-shadow: inset 48px 0px 0px #292929; - -moz-box-shadow: inset 48px 0px 0px #292929; - box-shadow: inset 48px 0px 0px #292929; -} -/* line 118, ../../src/sass/messenger-theme-flat.sass */ -ul.messenger-theme-flat .messenger-message-slot.messenger-first .messenger-message { - -webkit-border-radius: 0px 0px 4px 4px; - -moz-border-radius: 0px 0px 4px 4px; - -ms-border-radius: 0px 0px 4px 4px; - -o-border-radius: 0px 0px 4px 4px; - border-radius: 0px 0px 4px 4px; - -webkit-box-shadow: inset 0px 1px rgba(255, 255, 255, 0.13), inset 48px 0px 0px #292929; - -moz-box-shadow: inset 0px 1px rgba(255, 255, 255, 0.13), inset 48px 0px 0px #292929; - box-shadow: inset 0px 1px rgba(255, 255, 255, 0.13), inset 48px 0px 0px #292929; -} -/* line 122, ../../src/sass/messenger-theme-flat.sass */ -ul.messenger-theme-flat .messenger-message-slot.messenger-first.messenger-last .messenger-message { - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - -ms-border-radius: 4px; - -o-border-radius: 4px; - border-radius: 4px; - -webkit-box-shadow: inset 48px 0px 0px #292929; - -moz-box-shadow: inset 48px 0px 0px #292929; - box-shadow: inset 48px 0px 0px #292929; -} -/* line 126, ../../src/sass/messenger-theme-flat.sass */ -ul.messenger-theme-flat .messenger-spinner { - display: block; - position: absolute; - left: 7px; - top: 50%; - margin-top: -18px; - z-index: 999; - height: 32px; - width: 32px; - z-index: 10; -} diff --git a/src/UI/Content/Overrides/bootstrap.less b/src/UI/Content/Overrides/bootstrap.less deleted file mode 100644 index 9ec9eb3be..000000000 --- a/src/UI/Content/Overrides/bootstrap.less +++ /dev/null @@ -1,82 +0,0 @@ -@import "../prefixer"; -@import "../Bootstrap/variables"; -@import "../variables"; - -@input-border-focus: @droneTeal; -@font-family-sans-serif: "open sans", "Helvetica Neue", Helvetica, Arial, sans-serif; -@modal-md: 800px; -@modal-lg: 1000px; - -.label, .badge, i { - cursor : default; -} - -.slide-button { - min-width : 0px; -} - -.popover-title { - text-transform : none; -} - -.line &>[class^="icon-sonarr-"], .line &>[class*="icon-sonarr-"] { - margin-top : 1em; - height : 1em; - line-height : 1em; -} - -.tooltip-inner { - word-wrap: break-word; -} - -.dropdown-submenu { - position:relative; - & > .dropdown-menu { - top:0; - left:100%; - margin-top:-6px; - margin-left:-1px; - -webkit-border-radius:0 6px 6px 6px; - -moz-border-radius:0 6px 6px 6px; - border-radius:0 6px 6px 6px; - } - & > a:after { - display:block; - content:" "; - float:right; - width:0; - height:0; - border-color:transparent; - border-style:solid; - border-width:5px 0 5px 5px; - border-left-color:#cccccc; - margin-top:5px; - margin-right:-10px; - } -} -.dropdown-submenu:hover { - & > .dropdown-menu { - display:block; - } - & > a:after { - border-left-color:#ffffff; - } -} -.dropdown-submenu.pull-left { - float:none; - & > .dropdown-menu { - left:-100%; - margin-left:10px; - -webkit-border-radius:6px 0 6px 6px; - -moz-border-radius:6px 0 6px 6px; - border-radius:6px 0 6px 6px; - } -} - -.btn { - text-transform: capitalize; -} - -.table-responsive { - overflow-x: visible; -} diff --git a/src/UI/Content/Overrides/bootstrap.tagsinput.less b/src/UI/Content/Overrides/bootstrap.tagsinput.less deleted file mode 100644 index 85f726ae6..000000000 --- a/src/UI/Content/Overrides/bootstrap.tagsinput.less +++ /dev/null @@ -1,35 +0,0 @@ -@import "../Bootstrap/variables"; - -.bootstrap-tagsinput { - width : 100%; - - .twitter-typeahead { - width : auto; - } - - .tag { - margin-right: 0px; - - [data-role="remove"] { - &:hover { - color: @brand-danger; - } - } - } - - .tt-dropdown-menu { - - .opacity(0.95); - - .tt-suggestion { - color: #222222; - cursor: pointer; - - //selected item - &.tt-cursor { - background-color: @droneTeal; - color: #ffffff; - } - } - } -} \ No newline at end of file diff --git a/src/UI/Content/Overrides/bootstrap.toggle-switch.less b/src/UI/Content/Overrides/bootstrap.toggle-switch.less deleted file mode 100644 index 4e980373d..000000000 --- a/src/UI/Content/Overrides/bootstrap.toggle-switch.less +++ /dev/null @@ -1,33 +0,0 @@ -@import "../Bootstrap/variables"; -@import "../Bootstrap/mixins"; - -.toggle { - height: 34px; - box-sizing: border-box; - font-weight: normal; - - .slide-button { - .button-variant(@btn-danger-color, @btn-danger-bg, @btn-danger-border); - - &.btn-danger, &.btn-warning { - //.buttonBackground(@btnInverseBackground, @btnInverseBackgroundHighlight); - .button-variant(@btn-warning-color, @btn-warning-bg, @btn-warning-border); - } - } - - input:first-of-type:checked ~ .slide-button { - .button-variant(@btn-primary-color, @btn-primary-bg, @btn-primary-border); - - &.btn-danger { - .button-variant(@btn-danger-color, @btn-danger-bg, @btn-danger-border); - } - - &.btn-warning { - .button-variant(@btn-warning-color, @btn-warning-bg, @btn-warning-border); - } - } - - input:first-of-type:disabled ~ .slide-button { - opacity: 0.5; - } -} \ No newline at end of file diff --git a/src/UI/Content/Overrides/browser.less b/src/UI/Content/Overrides/browser.less deleted file mode 100644 index 236f2f8d8..000000000 --- a/src/UI/Content/Overrides/browser.less +++ /dev/null @@ -1,17 +0,0 @@ -html { - overflow : -moz-scrollbars-vertical; - overflow-y : scroll; -} - -button::-moz-focus-inner, a::-moz-focus-inner { - border : 0; -} - -a:focus { - outline : none; -} - -body h1, body h2, body h3, body h4, body h5, body h6 { - text-transform : capitalize; - font-weight : 300; -} diff --git a/src/UI/Content/Overrides/fullcalendar.less b/src/UI/Content/Overrides/fullcalendar.less deleted file mode 100644 index 76d1b32d5..000000000 --- a/src/UI/Content/Overrides/fullcalendar.less +++ /dev/null @@ -1,49 +0,0 @@ -.fc-view { - overflow: visible; -} - -.fc-time { - padding: 0 1px; -} - -.fc-title { - padding: 0 1px; - display: block; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; -} - -.fc-scroller { - overflow-y: visible; -} - -@media (max-width: @screen-xs-max) { - .fc-button { - padding: 0px 5px; - } - - .fc-header-space { - padding-left: 5px; - } -} - -.fc-header { - .fc-state-active { - z-index: 1; - } -} - -.fc-event-container { - .fc-event { - line-height : inherit; - } -} - -.fc-icon { - width: auto; -} - -.fc-icon::after { - margin: 0px; -} diff --git a/src/UI/Content/Overrides/messenger.less b/src/UI/Content/Overrides/messenger.less deleted file mode 100644 index 160ed9512..000000000 --- a/src/UI/Content/Overrides/messenger.less +++ /dev/null @@ -1,23 +0,0 @@ -@import "../variables"; - -body.control-panel-visible { - ul.messenger.messenger-fixed.messenger-on-bottom { - bottom: 95px; - } -} - -ul.messenger-theme-flat .messenger-message.alert-info .messenger-message-inner:before { - background: @droneTeal; -} - -@media (max-width: @screen-xs-max) { - ul.messenger.messenger-fixed.messenger-on-bottom { - width: 100%; - bottom: 0px; - .border-bottom-radius(0); - - &.messenger-on-right { - right : 0px; - } - } -} \ No newline at end of file diff --git a/src/UI/Content/badges.less b/src/UI/Content/badges.less deleted file mode 100644 index 68caf5c45..000000000 --- a/src/UI/Content/badges.less +++ /dev/null @@ -1,37 +0,0 @@ -@import "../Content/Bootstrap/variables"; - -.badge-inverse { - background-color: #eee; - border: 1px solid @badge-bg; - color: @badge-bg; -} - -.badge-primary { - .badge-variant(@label-primary-bg); -} - -.badge-success { - .badge-variant(@label-success-bg); -} - -.badge-info { - .badge-variant(@label-info-bg); -} - -.badge-warning { - .badge-variant(@label-warning-bg); -} - -.badge-danger { - .badge-variant(@label-danger-bg); -} - -.badge-variant(@color) { - background-color: @color; - &[href] { - &:hover, - &:focus { - background-color: darken(@color, 10%); - } - } -} \ No newline at end of file diff --git a/src/UI/Content/bootstrap.less b/src/UI/Content/bootstrap.less deleted file mode 100644 index 10e23ce63..000000000 --- a/src/UI/Content/bootstrap.less +++ /dev/null @@ -1,3 +0,0 @@ -@import "./Bootstrap/bootstrap"; -@import "./Overrides/bootstrap"; -@import "./bootstrap.tagsinput.less"; \ No newline at end of file diff --git a/src/UI/Content/bootstrap.tagsinput.less b/src/UI/Content/bootstrap.tagsinput.less deleted file mode 100644 index face63f18..000000000 --- a/src/UI/Content/bootstrap.tagsinput.less +++ /dev/null @@ -1,50 +0,0 @@ -.bootstrap-tagsinput { - background-color: #fff; - border: 1px solid #ccc; - box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); - display: inline-block; - padding: 4px 6px; - margin-bottom: 10px; - color: #555; - vertical-align: middle; - border-radius: 4px; - max-width: 100%; - line-height: 22px; - cursor: text; - - input { - border: none; - box-shadow: none; - outline: none; - background-color: transparent; - padding: 0; - margin: 0; - width: auto !important; - max-width: inherit; - - &:focus { - border: none; - box-shadow: none; - } - } - - .tag { - margin-right: 2px; - color: white; - - [data-role="remove"] { - margin-left:8px; - cursor:pointer; - &:after{ - content: "x"; - padding:0px 2px; - } - &:hover { - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); - &:active { - box-shadow: inset 0 3px 5px rgba(0,0,0,0.125); - } - } - } - } -} diff --git a/src/UI/Content/bootstrap.toggle-switch.css b/src/UI/Content/bootstrap.toggle-switch.css deleted file mode 100644 index ce924d3ce..000000000 --- a/src/UI/Content/bootstrap.toggle-switch.css +++ /dev/null @@ -1,228 +0,0 @@ -/* ------------------------------------------ -CSS TOGGLE SWITCHES (Ionuț Colceriu) -Licensed under Unlicense -https://github.com/ghinda/css-toggle-switch ------------------------------------------- */ - -/* Hide by default */ - -.switch .slide-button, -.toggle p span { - display: none; -} - -/* Toggle Switches */ - -/* We can't test for a specific feature, - * so we only target browsers with support for media queries. - */ -@media only screen { - - /* Checkbox - */ - .toggle { - position: relative; - padding: 0; - margin-left: 100px; - } - - /* Position the label over all the elements, except the slide-button - * Clicking anywhere on the label will change the switch-state - */ - .toggle label { - position: relative; - z-index: 3; - display: block; - width: 100%; - } - - /* Don't hide the input from screen-readers and keyboard access - */ - .toggle input { - position: absolute; - opacity: 0; - z-index: 5; - } - - .toggle p { - position: absolute; - left: -100px; - width: 100%; - margin: 0; - text-align: left; - } - - .toggle p span { - position: absolute; - top: 0; - left: 0; - z-index: 5; - display: block; - width: 50%; - margin-left: 100px; - text-align: center; - color: #F5F5F5; - } - - .toggle p span:last-child { - left: 50%; - } - - .toggle .slide-button { - position: absolute; - right: 0; - top: 0; - z-index: 4; - display: inline; - width: 50%; - height: 100%; - padding: 0; - } - - /* Radio Switch - */ - .switch { - position: relative; - padding: 0; - } - - .switch input { - position: absolute; - opacity: 0; - } - - .switch label { - position: relative; - z-index: 2; - - float: left; - width: 50%; - height: 100%; - - margin: 0; - text-align: center; - } - - .switch .slide-button { - position: absolute; - top: 0; - left: 0; - padding: 0; - z-index: 1; - - width: 50%; - height: 100%; - } - - .switch input:last-of-type:checked ~ .slide-button { - left: 50%; - } - - /* Switch with 3 items */ - .switch.switch-three label, - .switch.switch-three .slide-button { - width: 33.3%; - } - - .switch.switch-three input:checked:nth-of-type(2) ~ .slide-button { - left: 33.3%; - } - - .switch.switch-three input:checked:last-of-type ~ .slide-button { - left: 66.6%; - } - - /* Switch with 4 items */ - .switch.switch-four label, - .switch.switch-four .slide-button { - width: 25%; - } - - .switch.switch-four input:checked:nth-of-type(2) ~ .slide-button { - left: 25%; - } - - .switch.switch-four input:checked:nth-of-type(3) ~ .slide-button { - left: 50%; - } - - .switch.switch-four input:checked:last-of-type ~ .slide-button { - left: 75%; - } - - /* Switch with 5 items */ - .switch.switch-five label, - .switch.switch-five .slide-button { - width: 20%; - } - - .switch.switch-five input:checked:nth-of-type(2) ~ .slide-button { - left: 20%; - } - - .switch.switch-five input:checked:nth-of-type(3) ~ .slide-button { - left: 40%; - } - - .switch.switch-five input:checked:nth-of-type(4) ~ .slide-button { - left: 60%; - } - - .switch.switch-five input:checked:last-of-type ~ .slide-button { - left: 80%; - } - - /* Shared */ - .toggle, - .switch { - display: block; - height: 30px; - } - - .switch *, - .toggle * { - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - -ms-box-sizing: border-box; - -o-box-sizing: border-box; - box-sizing: border-box; - } - - .switch .slide-button, - .toggle .slide-button { - display: block; - - -webkit-transition: all 0.3s ease-in 0s; - -moz-transition: all 0.3s ease-in 0s; - -ms-transition: all 0.3s ease-in 0s; - -o-transition: all 0.3s ease-in 0s; - transition: all 0.3s ease-in 0s; - } - - .toggle label, - .toggle p, - .switch label { - line-height: 30px; - vertical-align: middle; - } - - .toggle input:checked ~ .slide-button { - right: 50%; - } - - /* Outline the toggles when the inputs are focused */ - /*.toggle input:focus ~ .slide-button,*/ - /* WHY?! It looks awful and it seems to bug out in FF */ - /*.switch input:focus + label {*/ - /*outline: 1px dotted #888;*/ - /*}*/ - - /* Bugfix for older Webkit, including mobile Webkit. Adapted from: - * http://css-tricks.com/webkit-sibling-bug/ - */ - .switch, .toggle { - -webkit-animation: bugfix infinite 1s; - } - - @-webkit-keyframes bugfix { from { position: relative; } to { position: relative; } } -} diff --git a/src/UI/Content/checkbox-button.less b/src/UI/Content/checkbox-button.less deleted file mode 100644 index 51f54c7d8..000000000 --- a/src/UI/Content/checkbox-button.less +++ /dev/null @@ -1,33 +0,0 @@ -@import "Bootstrap/variables"; -@import "Bootstrap/mixins"; - -.checkbox-button div { - display: none; -} - -@media only screen { - .checkbox-button { - input { - position: absolute; - opacity: 0; - z-index: 5; - } - - div { - display: block; - } - - .btn { - .button-variant(@btn-default-color, @btn-default-bg, @btn-default-border); - color: #333333; - } - - .btn:hover { - color: #333333; - } - - input:first-of-type:checked ~ .btn-primary { - .button-variant(@btn-primary-color, @btn-primary-bg, @btn-primary-border); - } - } -} diff --git a/src/UI/Content/font.less b/src/UI/Content/font.less deleted file mode 100644 index f20b10dc1..000000000 --- a/src/UI/Content/font.less +++ /dev/null @@ -1,47 +0,0 @@ -@font-face { - font-family: 'Open Sans'; - font-style: normal; - font-weight: 300; - src: url('./fonts/opensans-light.eot'); - src: local('Open Sans Light'), - local('OpenSans-Light'), - url('./fonts/opensans-light.eot?#iefix') format('embedded-opentype'), - url('./fonts/opensans-light.woff') format('woff'), - url('./fonts/opensans-light.ttf') format('truetype'); -} - -@font-face { - font-family: 'Open Sans'; - font-style: normal; - font-weight: 400; - src: url('./fonts/opensans-regular.eot'); - src: local('Open Sans'), - local('OpenSans'), - url('./fonts/opensans-regular.eot?#iefix') format('embedded-opentype'), - url('./fonts/opensans-regular.woff') format('woff'), - url('./fonts/opensans-regular.ttf') format('truetype') -} - -@font-face { - font-family: 'Open Sans'; - font-style: normal; - font-weight: 600; - src: url('./fonts/opensans-semibold.eot'); - src: local('Open Sans SemiBold'), - local('OpenSans-SemiBold'), - url('./fonts/opensans-semibold.eot?#iefix') format('embedded-opentype'), - url('./fonts/opensans-semibold.woff') format('woff'), - url('./fonts/opensans-semibold.ttf') format('truetype') -} - -@font-face { - font-family: 'Ubuntu Mono'; - font-style: normal; - font-weight: 400; - src: url('./fonts/ubuntumono-regular.eot'); - src: local('Open Sans'), - local('OpenSans'), - url('./fonts/ubuntumono-regular.eot?#iefix') format('embedded-opentype'), - url('./fonts/ubuntumono-regular.woff') format('woff'), - url('./fonts/ubuntumono-regular.ttf') format('truetype') -} \ No newline at end of file diff --git a/src/UI/Content/fonts/opensans-light.eot b/src/UI/Content/fonts/opensans-light.eot deleted file mode 100644 index 3c203d8e7..000000000 Binary files a/src/UI/Content/fonts/opensans-light.eot and /dev/null differ diff --git a/src/UI/Content/fonts/opensans-light.ttf b/src/UI/Content/fonts/opensans-light.ttf deleted file mode 100644 index 0d381897d..000000000 Binary files a/src/UI/Content/fonts/opensans-light.ttf and /dev/null differ diff --git a/src/UI/Content/fonts/opensans-light.woff b/src/UI/Content/fonts/opensans-light.woff deleted file mode 100644 index 99f335326..000000000 Binary files a/src/UI/Content/fonts/opensans-light.woff and /dev/null differ diff --git a/src/UI/Content/fonts/opensans-regular.eot b/src/UI/Content/fonts/opensans-regular.eot deleted file mode 100644 index 091cd51b9..000000000 Binary files a/src/UI/Content/fonts/opensans-regular.eot and /dev/null differ diff --git a/src/UI/Content/fonts/opensans-regular.ttf b/src/UI/Content/fonts/opensans-regular.ttf deleted file mode 100644 index db433349b..000000000 Binary files a/src/UI/Content/fonts/opensans-regular.ttf and /dev/null differ diff --git a/src/UI/Content/fonts/opensans-regular.woff b/src/UI/Content/fonts/opensans-regular.woff deleted file mode 100644 index 55b25f867..000000000 Binary files a/src/UI/Content/fonts/opensans-regular.woff and /dev/null differ diff --git a/src/UI/Content/fonts/opensans-semibold.eot b/src/UI/Content/fonts/opensans-semibold.eot deleted file mode 100644 index 55d28c378..000000000 Binary files a/src/UI/Content/fonts/opensans-semibold.eot and /dev/null differ diff --git a/src/UI/Content/fonts/opensans-semibold.ttf b/src/UI/Content/fonts/opensans-semibold.ttf deleted file mode 100644 index 1a7679e39..000000000 Binary files a/src/UI/Content/fonts/opensans-semibold.ttf and /dev/null differ diff --git a/src/UI/Content/fonts/opensans-semibold.woff b/src/UI/Content/fonts/opensans-semibold.woff deleted file mode 100644 index e83bb333d..000000000 Binary files a/src/UI/Content/fonts/opensans-semibold.woff and /dev/null differ diff --git a/src/UI/Content/form.less b/src/UI/Content/form.less deleted file mode 100644 index 28474c962..000000000 --- a/src/UI/Content/form.less +++ /dev/null @@ -1,133 +0,0 @@ -@import "../Shared/Styles/clickable.less"; - -.form-group { - .input-group { - .checkbox { - width : 100px; - margin-left : 0px; - display : inline-block; - padding-top : 0px; - margin-bottom : 0px; - } - - .help-inline-checkbox { - display : inline-block; - margin-top : -20px; - margin-bottom : 0; - margin-left : 10px; - vertical-align : middle; - } - - .btn { - i { - margin-right : 0px; - color : inherit; - } - } - } - - .btn { - i { - margin-right : 0px; - color : inherit; - } - } - - i { - font-size : 16px; - color : #595959; - margin-right : 5px; - } - - .help-inline { - display : inline-block; - margin-top : 8px; - padding-left : 0px; - - @media (max-width: @screen-xs-max) { - margin-left: 0px; - } - } -} - -.text-area-help { - display : block; - color : #777777; - font-size : 12px; -} - -textarea.release-restrictions { - width : 100%; - max-width : 100%; -} - -.help-inline-text-area { - margin-top: 25px !important; -} - -.help-link { - text-decoration : none !important; - - i { - .clickable; - } -} - -h3 { - .help-inline { - font-size: 16px; - padding-left: 0px; - margin-top: -5px; - text-transform: none; - } -} - -.form-inline { - div { - display : inline-block; - } -} - -.has-error { - .help-inline { - color: #b94a48; - margin-left: 0px; - } -} - -.validation-error { - i { - text-decoration: none; - color: #b94a48; - } -} - -.has-warning { - .help-inline { - color: orange; - margin-left: 0px; - } -} - -.validation-warning { - i { - text-decoration: none; - color: orange; - } -} - -// Tooltips - -.help-inline-checkbox, .help-inline { - .tooltip-inner { - white-space : pre-wrap; - min-width : 200px; - } - - .help-link ~ .tooltip { - .tooltip-inner { - white-space : normal; - min-width : 0px; - } - } -} diff --git a/src/UI/Content/fullcalendar.css b/src/UI/Content/fullcalendar.css deleted file mode 100644 index 4e5e4eb61..000000000 --- a/src/UI/Content/fullcalendar.css +++ /dev/null @@ -1,1069 +0,0 @@ -/*! - * FullCalendar v2.3.2 Stylesheet - * Docs & License: http://fullcalendar.io/ - * (c) 2015 Adam Shaw - */ - - -.fc { - direction: ltr; - text-align: left; -} - -.fc-rtl { - text-align: right; -} - -body .fc { /* extra precedence to overcome jqui */ - font-size: 1em; -} - - -/* Colors ---------------------------------------------------------------------------------------------------*/ - -.fc-unthemed th, -.fc-unthemed td, -.fc-unthemed thead, -.fc-unthemed tbody, -.fc-unthemed .fc-divider, -.fc-unthemed .fc-row, -.fc-unthemed .fc-popover { - border-color: #ddd; -} - -.fc-unthemed .fc-popover { - background-color: #fff; -} - -.fc-unthemed .fc-divider, -.fc-unthemed .fc-popover .fc-header { - background: #eee; -} - -.fc-unthemed .fc-popover .fc-header .fc-close { - color: #666; -} - -.fc-unthemed .fc-today { - background: #fcf8e3; -} - -.fc-highlight { /* when user is selecting cells */ - background: #bce8f1; - opacity: .3; - filter: alpha(opacity=30); /* for IE */ -} - -.fc-bgevent { /* default look for background events */ - background: rgb(143, 223, 130); - opacity: .3; - filter: alpha(opacity=30); /* for IE */ -} - -.fc-nonbusiness { /* default look for non-business-hours areas */ - /* will inherit .fc-bgevent's styles */ - background: #d7d7d7; -} - - -/* Icons (inline elements with styled text that mock arrow icons) ---------------------------------------------------------------------------------------------------*/ - -.fc-icon { - display: inline-block; - width: 1em; - height: 1em; - line-height: 1em; - font-size: 1em; - text-align: center; - overflow: hidden; - font-family: "Courier New", Courier, monospace; - - /* don't allow browser text-selection */ - -webkit-touch-callout: none; - -webkit-user-select: none; - -khtml-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - } - -/* -Acceptable font-family overrides for individual icons: - "Arial", sans-serif - "Times New Roman", serif - -NOTE: use percentage font sizes or else old IE chokes -*/ - -.fc-icon:after { - position: relative; - margin: 0 -1em; /* ensures character will be centered, regardless of width */ -} - -.fc-icon-left-single-arrow:after { - content: "\02039"; - font-weight: bold; - font-size: 200%; - top: -7%; - left: 3%; -} - -.fc-icon-right-single-arrow:after { - content: "\0203A"; - font-weight: bold; - font-size: 200%; - top: -7%; - left: -3%; -} - -.fc-icon-left-double-arrow:after { - content: "\000AB"; - font-size: 160%; - top: -7%; -} - -.fc-icon-right-double-arrow:after { - content: "\000BB"; - font-size: 160%; - top: -7%; -} - -.fc-icon-left-triangle:after { - content: "\25C4"; - font-size: 125%; - top: 3%; - left: -2%; -} - -.fc-icon-right-triangle:after { - content: "\25BA"; - font-size: 125%; - top: 3%; - left: 2%; -} - -.fc-icon-down-triangle:after { - content: "\25BC"; - font-size: 125%; - top: 2%; -} - -.fc-icon-x:after { - content: "\000D7"; - font-size: 200%; - top: 6%; -} - - -/* Buttons (styled <button> tags, normalized to work cross-browser) ---------------------------------------------------------------------------------------------------*/ - -.fc button { - /* force height to include the border and padding */ - -moz-box-sizing: border-box; - -webkit-box-sizing: border-box; - box-sizing: border-box; - - /* dimensions */ - margin: 0; - height: 2.1em; - padding: 0 .6em; - - /* text & cursor */ - font-size: 1em; /* normalize */ - white-space: nowrap; - cursor: pointer; -} - -/* Firefox has an annoying inner border */ -.fc button::-moz-focus-inner { margin: 0; padding: 0; } - -.fc-state-default { /* non-theme */ - border: 1px solid; -} - -.fc-state-default.fc-corner-left { /* non-theme */ - border-top-left-radius: 4px; - border-bottom-left-radius: 4px; -} - -.fc-state-default.fc-corner-right { /* non-theme */ - border-top-right-radius: 4px; - border-bottom-right-radius: 4px; -} - -/* icons in buttons */ - -.fc button .fc-icon { /* non-theme */ - position: relative; - top: -0.05em; /* seems to be a good adjustment across browsers */ - margin: 0 .2em; - vertical-align: middle; -} - -/* - button states - borrowed from twitter bootstrap (http://twitter.github.com/bootstrap/) -*/ - -.fc-state-default { - background-color: #f5f5f5; - background-image: -moz-linear-gradient(top, #ffffff, #e6e6e6); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#ffffff), to(#e6e6e6)); - background-image: -webkit-linear-gradient(top, #ffffff, #e6e6e6); - background-image: -o-linear-gradient(top, #ffffff, #e6e6e6); - background-image: linear-gradient(to bottom, #ffffff, #e6e6e6); - background-repeat: repeat-x; - border-color: #e6e6e6 #e6e6e6 #bfbfbf; - border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); - color: #333; - text-shadow: 0 1px 1px rgba(255, 255, 255, 0.75); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); -} - -.fc-state-hover, -.fc-state-down, -.fc-state-active, -.fc-state-disabled { - color: #333333; - background-color: #e6e6e6; -} - -.fc-state-hover { - color: #333333; - text-decoration: none; - background-position: 0 -15px; - -webkit-transition: background-position 0.1s linear; - -moz-transition: background-position 0.1s linear; - -o-transition: background-position 0.1s linear; - transition: background-position 0.1s linear; -} - -.fc-state-down, -.fc-state-active { - background-color: #cccccc; - background-image: none; - box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.05); -} - -.fc-state-disabled { - cursor: default; - background-image: none; - opacity: 0.65; - filter: alpha(opacity=65); - box-shadow: none; -} - - -/* Buttons Groups ---------------------------------------------------------------------------------------------------*/ - -.fc-button-group { - display: inline-block; -} - -/* -every button that is not first in a button group should scootch over one pixel and cover the -previous button's border... -*/ - -.fc .fc-button-group > * { /* extra precedence b/c buttons have margin set to zero */ - float: left; - margin: 0 0 0 -1px; -} - -.fc .fc-button-group > :first-child { /* same */ - margin-left: 0; -} - - -/* Popover ---------------------------------------------------------------------------------------------------*/ - -.fc-popover { - position: absolute; - box-shadow: 0 2px 6px rgba(0,0,0,.15); -} - -.fc-popover .fc-header { /* TODO: be more consistent with fc-head/fc-body */ - padding: 2px 4px; -} - -.fc-popover .fc-header .fc-title { - margin: 0 2px; -} - -.fc-popover .fc-header .fc-close { - cursor: pointer; -} - -.fc-ltr .fc-popover .fc-header .fc-title, -.fc-rtl .fc-popover .fc-header .fc-close { - float: left; -} - -.fc-rtl .fc-popover .fc-header .fc-title, -.fc-ltr .fc-popover .fc-header .fc-close { - float: right; -} - -/* unthemed */ - -.fc-unthemed .fc-popover { - border-width: 1px; - border-style: solid; -} - -.fc-unthemed .fc-popover .fc-header .fc-close { - font-size: .9em; - margin-top: 2px; -} - -/* jqui themed */ - -.fc-popover > .ui-widget-header + .ui-widget-content { - border-top: 0; /* where they meet, let the header have the border */ -} - - -/* Misc Reusable Components ---------------------------------------------------------------------------------------------------*/ - -.fc-divider { - border-style: solid; - border-width: 1px; -} - -hr.fc-divider { - height: 0; - margin: 0; - padding: 0 0 2px; /* height is unreliable across browsers, so use padding */ - border-width: 1px 0; -} - -.fc-clear { - clear: both; -} - -.fc-bg, -.fc-bgevent-skeleton, -.fc-highlight-skeleton, -.fc-helper-skeleton { - /* these element should always cling to top-left/right corners */ - position: absolute; - top: 0; - left: 0; - right: 0; -} - -.fc-bg { - bottom: 0; /* strech bg to bottom edge */ -} - -.fc-bg table { - height: 100%; /* strech bg to bottom edge */ -} - - -/* Tables ---------------------------------------------------------------------------------------------------*/ - -.fc table { - width: 100%; - table-layout: fixed; - border-collapse: collapse; - border-spacing: 0; - font-size: 1em; /* normalize cross-browser */ -} - -.fc th { - text-align: center; -} - -.fc th, -.fc td { - border-style: solid; - border-width: 1px; - padding: 0; - vertical-align: top; -} - -.fc td.fc-today { - border-style: double; /* overcome neighboring borders */ -} - - -/* Fake Table Rows ---------------------------------------------------------------------------------------------------*/ - -.fc .fc-row { /* extra precedence to overcome themes w/ .ui-widget-content forcing a 1px border */ - /* no visible border by default. but make available if need be (scrollbar width compensation) */ - border-style: solid; - border-width: 0; -} - -.fc-row table { - /* don't put left/right border on anything within a fake row. - the outer tbody will worry about this */ - border-left: 0 hidden transparent; - border-right: 0 hidden transparent; - - /* no bottom borders on rows */ - border-bottom: 0 hidden transparent; -} - -.fc-row:first-child table { - border-top: 0 hidden transparent; /* no top border on first row */ -} - - -/* Day Row (used within the header and the DayGrid) ---------------------------------------------------------------------------------------------------*/ - -.fc-row { - position: relative; -} - -.fc-row .fc-bg { - z-index: 1; -} - -/* highlighting cells & background event skeleton */ - -.fc-row .fc-bgevent-skeleton, -.fc-row .fc-highlight-skeleton { - bottom: 0; /* stretch skeleton to bottom of row */ -} - -.fc-row .fc-bgevent-skeleton table, -.fc-row .fc-highlight-skeleton table { - height: 100%; /* stretch skeleton to bottom of row */ -} - -.fc-row .fc-highlight-skeleton td, -.fc-row .fc-bgevent-skeleton td { - border-color: transparent; -} - -.fc-row .fc-bgevent-skeleton { - z-index: 2; - -} - -.fc-row .fc-highlight-skeleton { - z-index: 3; -} - -/* -row content (which contains day/week numbers and events) as well as "helper" (which contains -temporary rendered events). -*/ - -.fc-row .fc-content-skeleton { - position: relative; - z-index: 4; - padding-bottom: 2px; /* matches the space above the events */ -} - -.fc-row .fc-helper-skeleton { - z-index: 5; -} - -.fc-row .fc-content-skeleton td, -.fc-row .fc-helper-skeleton td { - /* see-through to the background below */ - background: none; /* in case <td>s are globally styled */ - border-color: transparent; - - /* don't put a border between events and/or the day number */ - border-bottom: 0; -} - -.fc-row .fc-content-skeleton tbody td, /* cells with events inside (so NOT the day number cell) */ -.fc-row .fc-helper-skeleton tbody td { - /* don't put a border between event cells */ - border-top: 0; -} - - -/* Scrolling Container ---------------------------------------------------------------------------------------------------*/ - -.fc-scroller { /* this class goes on elements for guaranteed vertical scrollbars */ - overflow-y: scroll; - overflow-x: hidden; -} - -.fc-scroller > * { /* we expect an immediate inner element */ - position: relative; /* re-scope all positions */ - width: 100%; /* hack to force re-sizing this inner element when scrollbars appear/disappear */ - overflow: hidden; /* don't let negative margins or absolute positioning create further scroll */ -} - - -/* Global Event Styles ---------------------------------------------------------------------------------------------------*/ - -.fc-event { - position: relative; /* for resize handle and other inner positioning */ - display: block; /* make the <a> tag block */ - font-size: .85em; - line-height: 1.3; - border-radius: 3px; - border: 1px solid #3a87ad; /* default BORDER color */ - background-color: #3a87ad; /* default BACKGROUND color */ - font-weight: normal; /* undo jqui's ui-widget-header bold */ -} - -/* overpower some of bootstrap's and jqui's styles on <a> tags */ -.fc-event, -.fc-event:hover, -.ui-widget .fc-event { - color: #fff; /* default TEXT color */ - text-decoration: none; /* if <a> has an href */ -} - -.fc-event[href], -.fc-event.fc-draggable { - cursor: pointer; /* give events with links and draggable events a hand mouse pointer */ -} - -.fc-not-allowed, /* causes a "warning" cursor. applied on body */ -.fc-not-allowed .fc-event { /* to override an event's custom cursor */ - cursor: not-allowed; -} - -.fc-event .fc-bg { /* the generic .fc-bg already does position */ - z-index: 1; - background: #fff; - opacity: .25; - filter: alpha(opacity=25); /* for IE */ -} - -.fc-event .fc-content { - position: relative; - z-index: 2; -} - -.fc-event .fc-resizer { - position: absolute; - z-index: 3; -} - - -/* Horizontal Events ---------------------------------------------------------------------------------------------------*/ - -/* events that are continuing to/from another week. kill rounded corners and butt up against edge */ - -.fc-ltr .fc-h-event.fc-not-start, -.fc-rtl .fc-h-event.fc-not-end { - margin-left: 0; - border-left-width: 0; - padding-left: 1px; /* replace the border with padding */ - border-top-left-radius: 0; - border-bottom-left-radius: 0; -} - -.fc-ltr .fc-h-event.fc-not-end, -.fc-rtl .fc-h-event.fc-not-start { - margin-right: 0; - border-right-width: 0; - padding-right: 1px; /* replace the border with padding */ - border-top-right-radius: 0; - border-bottom-right-radius: 0; -} - -/* resizer */ - -.fc-h-event .fc-resizer { /* positioned it to overcome the event's borders */ - top: -1px; - bottom: -1px; - left: -1px; - right: -1px; - width: 5px; -} - -/* left resizer */ -.fc-ltr .fc-h-event .fc-start-resizer, -.fc-ltr .fc-h-event .fc-start-resizer:before, -.fc-ltr .fc-h-event .fc-start-resizer:after, -.fc-rtl .fc-h-event .fc-end-resizer, -.fc-rtl .fc-h-event .fc-end-resizer:before, -.fc-rtl .fc-h-event .fc-end-resizer:after { - right: auto; /* ignore the right and only use the left */ - cursor: w-resize; -} - -/* right resizer */ -.fc-ltr .fc-h-event .fc-end-resizer, -.fc-ltr .fc-h-event .fc-end-resizer:before, -.fc-ltr .fc-h-event .fc-end-resizer:after, -.fc-rtl .fc-h-event .fc-start-resizer, -.fc-rtl .fc-h-event .fc-start-resizer:before, -.fc-rtl .fc-h-event .fc-start-resizer:after { - left: auto; /* ignore the left and only use the right */ - cursor: e-resize; -} - - -/* DayGrid events ----------------------------------------------------------------------------------------------------- -We use the full "fc-day-grid-event" class instead of using descendants because the event won't -be a descendant of the grid when it is being dragged. -*/ - -.fc-day-grid-event { - margin: 1px 2px 0; /* spacing between events and edges */ - padding: 0 1px; -} - - -.fc-day-grid-event .fc-content { /* force events to be one-line tall */ - white-space: nowrap; - overflow: hidden; -} - -.fc-day-grid-event .fc-time { - font-weight: bold; -} - -.fc-day-grid-event .fc-resizer { /* enlarge the default hit area */ - left: -3px; - right: -3px; - width: 7px; -} - - -/* Event Limiting ---------------------------------------------------------------------------------------------------*/ - -/* "more" link that represents hidden events */ - -a.fc-more { - margin: 1px 3px; - font-size: .85em; - cursor: pointer; - text-decoration: none; -} - -a.fc-more:hover { - text-decoration: underline; -} - -.fc-limited { /* rows and cells that are hidden because of a "more" link */ - display: none; -} - -/* popover that appears when "more" link is clicked */ - -.fc-day-grid .fc-row { - z-index: 1; /* make the "more" popover one higher than this */ -} - -.fc-more-popover { - z-index: 2; - width: 220px; -} - -.fc-more-popover .fc-event-container { - padding: 10px; -} - -/* Toolbar ---------------------------------------------------------------------------------------------------*/ - -.fc-toolbar { - text-align: center; - margin-bottom: 1em; -} - -.fc-toolbar .fc-left { - float: left; -} - -.fc-toolbar .fc-right { - float: right; -} - -.fc-toolbar .fc-center { - display: inline-block; -} - -/* the things within each left/right/center section */ -.fc .fc-toolbar > * > * { /* extra precedence to override button border margins */ - float: left; - margin-left: .75em; -} - -/* the first thing within each left/center/right section */ -.fc .fc-toolbar > * > :first-child { /* extra precedence to override button border margins */ - margin-left: 0; -} - -/* title text */ - -.fc-toolbar h2 { - margin: 0; -} - -/* button layering (for border precedence) */ - -.fc-toolbar button { - position: relative; -} - -.fc-toolbar .fc-state-hover, -.fc-toolbar .ui-state-hover { - z-index: 2; -} - -.fc-toolbar .fc-state-down { - z-index: 3; -} - -.fc-toolbar .fc-state-active, -.fc-toolbar .ui-state-active { - z-index: 4; -} - -.fc-toolbar button:focus { - z-index: 5; -} - - -/* View Structure ---------------------------------------------------------------------------------------------------*/ - -/* undo twitter bootstrap's box-sizing rules. normalizes positioning techniques */ -/* don't do this for the toolbar because we'll want bootstrap to style those buttons as some pt */ -.fc-view-container *, -.fc-view-container *:before, -.fc-view-container *:after { - -webkit-box-sizing: content-box; - -moz-box-sizing: content-box; - box-sizing: content-box; -} - -.fc-view, /* scope positioning and z-index's for everything within the view */ -.fc-view > table { /* so dragged elements can be above the view's main element */ - position: relative; - z-index: 1; -} - -/* BasicView ---------------------------------------------------------------------------------------------------*/ - -/* day row structure */ - -.fc-basicWeek-view .fc-content-skeleton, -.fc-basicDay-view .fc-content-skeleton { - /* we are sure there are no day numbers in these views, so... */ - padding-top: 1px; /* add a pixel to make sure there are 2px padding above events */ - padding-bottom: 1em; /* ensure a space at bottom of cell for user selecting/clicking */ -} - -.fc-basic-view .fc-body .fc-row { - min-height: 4em; /* ensure that all rows are at least this tall */ -} - -/* a "rigid" row will take up a constant amount of height because content-skeleton is absolute */ - -.fc-row.fc-rigid { - overflow: hidden; -} - -.fc-row.fc-rigid .fc-content-skeleton { - position: absolute; - top: 0; - left: 0; - right: 0; -} - -/* week and day number styling */ - -.fc-basic-view .fc-week-number, -.fc-basic-view .fc-day-number { - padding: 0 2px; -} - -.fc-basic-view td.fc-week-number span, -.fc-basic-view td.fc-day-number { - padding-top: 2px; - padding-bottom: 2px; -} - -.fc-basic-view .fc-week-number { - text-align: center; -} - -.fc-basic-view .fc-week-number span { - /* work around the way we do column resizing and ensure a minimum width */ - display: inline-block; - min-width: 1.25em; -} - -.fc-ltr .fc-basic-view .fc-day-number { - text-align: right; -} - -.fc-rtl .fc-basic-view .fc-day-number { - text-align: left; -} - -.fc-day-number.fc-other-month { - opacity: 0.3; - filter: alpha(opacity=30); /* for IE */ - /* opacity with small font can sometimes look too faded - might want to set the 'color' property instead - making day-numbers bold also fixes the problem */ -} - -/* AgendaView all-day area ---------------------------------------------------------------------------------------------------*/ - -.fc-agenda-view .fc-day-grid { - position: relative; - z-index: 2; /* so the "more.." popover will be over the time grid */ -} - -.fc-agenda-view .fc-day-grid .fc-row { - min-height: 3em; /* all-day section will never get shorter than this */ -} - -.fc-agenda-view .fc-day-grid .fc-row .fc-content-skeleton { - padding-top: 1px; /* add a pixel to make sure there are 2px padding above events */ - padding-bottom: 1em; /* give space underneath events for clicking/selecting days */ -} - - -/* TimeGrid axis running down the side (for both the all-day area and the slot area) ---------------------------------------------------------------------------------------------------*/ - -.fc .fc-axis { /* .fc to overcome default cell styles */ - vertical-align: middle; - padding: 0 4px; - white-space: nowrap; -} - -.fc-ltr .fc-axis { - text-align: right; -} - -.fc-rtl .fc-axis { - text-align: left; -} - -.ui-widget td.fc-axis { - font-weight: normal; /* overcome jqui theme making it bold */ -} - - -/* TimeGrid Structure ---------------------------------------------------------------------------------------------------*/ - -.fc-time-grid-container, /* so scroll container's z-index is below all-day */ -.fc-time-grid { /* so slats/bg/content/etc positions get scoped within here */ - position: relative; - z-index: 1; -} - -.fc-time-grid { - min-height: 100%; /* so if height setting is 'auto', .fc-bg stretches to fill height */ -} - -.fc-time-grid table { /* don't put outer borders on slats/bg/content/etc */ - border: 0 hidden transparent; -} - -.fc-time-grid > .fc-bg { - z-index: 1; -} - -.fc-time-grid .fc-slats, -.fc-time-grid > hr { /* the <hr> AgendaView injects when grid is shorter than scroller */ - position: relative; - z-index: 2; -} - -.fc-time-grid .fc-bgevent-skeleton, -.fc-time-grid .fc-content-skeleton { - position: absolute; - top: 0; - left: 0; - right: 0; -} - -.fc-time-grid .fc-bgevent-skeleton { - z-index: 3; -} - -.fc-time-grid .fc-highlight-skeleton { - z-index: 4; -} - -.fc-time-grid .fc-content-skeleton { - z-index: 5; -} - -.fc-time-grid .fc-helper-skeleton { - z-index: 6; -} - - -/* TimeGrid Slats (lines that run horizontally) ---------------------------------------------------------------------------------------------------*/ - -.fc-time-grid .fc-slats td { - height: 1.5em; - border-bottom: 0; /* each cell is responsible for its top border */ -} - -.fc-time-grid .fc-slats .fc-minor td { - border-top-style: dotted; -} - -.fc-time-grid .fc-slats .ui-widget-content { /* for jqui theme */ - background: none; /* see through to fc-bg */ -} - - -/* TimeGrid Highlighting Slots ---------------------------------------------------------------------------------------------------*/ - -.fc-time-grid .fc-highlight-container { /* a div within a cell within the fc-highlight-skeleton */ - position: relative; /* scopes the left/right of the fc-highlight to be in the column */ -} - -.fc-time-grid .fc-highlight { - position: absolute; - left: 0; - right: 0; - /* top and bottom will be in by JS */ -} - - -/* TimeGrid Event Containment ---------------------------------------------------------------------------------------------------*/ - -.fc-time-grid .fc-event-container, /* a div within a cell within the fc-content-skeleton */ -.fc-time-grid .fc-bgevent-container { /* a div within a cell within the fc-bgevent-skeleton */ - position: relative; -} - -.fc-ltr .fc-time-grid .fc-event-container { /* space on the sides of events for LTR (default) */ - margin: 0 2.5% 0 2px; -} - -.fc-rtl .fc-time-grid .fc-event-container { /* space on the sides of events for RTL */ - margin: 0 2px 0 2.5%; -} - -.fc-time-grid .fc-event, -.fc-time-grid .fc-bgevent { - position: absolute; - z-index: 1; /* scope inner z-index's */ -} - -.fc-time-grid .fc-bgevent { - /* background events always span full width */ - left: 0; - right: 0; -} - - -/* Generic Vertical Event ---------------------------------------------------------------------------------------------------*/ - -.fc-v-event.fc-not-start { /* events that are continuing from another day */ - /* replace space made by the top border with padding */ - border-top-width: 0; - padding-top: 1px; - - /* remove top rounded corners */ - border-top-left-radius: 0; - border-top-right-radius: 0; -} - -.fc-v-event.fc-not-end { - /* replace space made by the top border with padding */ - border-bottom-width: 0; - padding-bottom: 1px; - - /* remove bottom rounded corners */ - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; -} - - -/* TimeGrid Event Styling ----------------------------------------------------------------------------------------------------- -We use the full "fc-time-grid-event" class instead of using descendants because the event won't -be a descendant of the grid when it is being dragged. -*/ - -.fc-time-grid-event { - overflow: hidden; /* don't let the bg flow over rounded corners */ -} - -.fc-time-grid-event .fc-time, -.fc-time-grid-event .fc-title { - padding: 0 1px; -} - -.fc-time-grid-event .fc-time { - font-size: .85em; - white-space: nowrap; -} - -/* short mode, where time and title are on the same line */ - -.fc-time-grid-event.fc-short .fc-content { - /* don't wrap to second line (now that contents will be inline) */ - white-space: nowrap; -} - -.fc-time-grid-event.fc-short .fc-time, -.fc-time-grid-event.fc-short .fc-title { - /* put the time and title on the same line */ - display: inline-block; - vertical-align: top; -} - -.fc-time-grid-event.fc-short .fc-time span { - display: none; /* don't display the full time text... */ -} - -.fc-time-grid-event.fc-short .fc-time:before { - content: attr(data-start); /* ...instead, display only the start time */ -} - -.fc-time-grid-event.fc-short .fc-time:after { - content: "\000A0-\000A0"; /* seperate with a dash, wrapped in nbsp's */ -} - -.fc-time-grid-event.fc-short .fc-title { - font-size: .85em; /* make the title text the same size as the time */ - padding: 0; /* undo padding from above */ -} - -/* resizer */ - -.fc-time-grid-event .fc-resizer { - left: 0; - right: 0; - bottom: 0; - height: 8px; - overflow: hidden; - line-height: 8px; - font-size: 11px; - font-family: monospace; - text-align: center; - cursor: s-resize; -} - -.fc-time-grid-event .fc-resizer:after { - content: "="; -} diff --git a/src/UI/Content/icons.less b/src/UI/Content/icons.less deleted file mode 100644 index cce09293a..000000000 --- a/src/UI/Content/icons.less +++ /dev/null @@ -1,505 +0,0 @@ -@import "FontAwesome/font-awesome"; -@import "Bootstrap/variables"; -@import "variables"; - -/* Icon rotations and mirroring */ -.fa-rotate-90() { - -webkit-transform : rotate(90deg); - -moz-transform : rotate(90deg); - -ms-transform : rotate(90deg); - -o-transform : rotate(90deg); - transform : rotate(90deg); - filter : progid:DXImageTransform.Microsoft.BasicImage(rotation=1); -} - -.fa-rotate-180() { - -webkit-transform : rotate(180deg); - -moz-transform : rotate(180deg); - -ms-transform : rotate(180deg); - -o-transform : rotate(180deg); - transform : rotate(180deg); - filter : progid:DXImageTransform.Microsoft.BasicImage(rotation=2); -} - -.fa-rotate-270() { - -webkit-transform : rotate(270deg); - -moz-transform : rotate(270deg); - -ms-transform : rotate(270deg); - -o-transform : rotate(270deg); - transform : rotate(270deg); - filter : progid:DXImageTransform.Microsoft.BasicImage(rotation=3); -} - -.fa-flip-horizontal() { - -webkit-transform : scale(-1, 1); - -moz-transform : scale(-1, 1); - -ms-transform : scale(-1, 1); - -o-transform : scale(-1, 1); - transform : scale(-1, 1); -} - -.fa-flip-vertical() { - -webkit-transform : scale(1, -1); - -moz-transform : scale(1, -1); - -ms-transform : scale(1, -1); - -o-transform : scale(1, -1); - transform : scale(1, -1); -} - -.fa-icon-content(@fa-icon) { - .fa-icon(); - &:before { content: @fa-icon; } -} - -.fa-icon-color(@color) { - &:before { color: @color; } -} - -.icon-sonarr-warning { - .fa-icon-content(@fa-var-exclamation-triangle); - .fa-icon-color(@brand-warning); -} - -.icon-sonarr-edit { - .fa-icon-content(@fa-var-wrench); -} - -.icon-sonarr-blacklist { - .fa-icon-content(@fa-var-ban); - .fa-icon-color(@brand-danger); - -} - -.icon-sonarr-spinner { - .fa-icon-content(@fa-var-spinner); -} - -.fa-spin-overlay { - .fa-icon(); - position : relative; - text-align : center; - vertical-align : baseline; - - i { - opacity : 0.0; - margin : 0 !important; - - &.icon-sonarr-spinner { - opacity : 1.0; - margin : 0 -0.5em !important; - } - } - - span { - position : absolute; - top : 0; - left : 0; - right : 0; - bottom : 0; - } -} - -.icon-sonarr-rename { - .fa-icon-content(@fa-var-sitemap) -} - -.icon-sonarr-add { - .fa-icon-content(@fa-var-plus); -} - -.icon-sonarr-form-info { - .fa-icon-content(@fa-var-question-circle); -} - -.icon-sonarr-form-warning { - .fa-icon-content(@fa-var-exclamation-triangle); - .fa-icon-color(@brand-warning); -} - -.icon-sonarr-form-danger { - .fa-icon-content(@fa-var-exclamation-circle); - .fa-icon-color(@brand-danger); -} - -.icon-sonarr-form-info-link { - .clickable(); - .fa-icon-content(@fa-var-info-circle); - .fa-icon-color(@brand-primary) -} - -.icon-sonarr-form-external-link { - .fa-icon-content(@fa-var-external-link); -} - -.icon-sonarr-update { - .fa-icon-content(@fa-var-download); -} - -.icon-sonarr-download { - .fa-icon-content(@fa-var-download); -} - -.icon-sonarr-downloading { - .fa-icon-content(@fa-var-cloud-download); -} - -.icon-sonarr-downloaded { - .fa-icon-content(@fa-var-inbox); -} - -.icon-sonarr-pending { - .fa-icon-content(@fa-var-clock-o); -} - -.icon-sonarr-queued { - .fa-icon-content(@fa-var-cloud); -} - -.icon-sonarr-paused { - .fa-icon-content(@fa-var-pause); -} - -.icon-sonarr-active { - .fa-icon-content(@fa-var-play); -} - -.icon-sonarr-tba { - .fa-icon-content(@fa-var-question-circle); -} - -.icon-sonarr-missing { - .fa-icon-content(@fa-var-exclamation-triangle); -} - -.icon-sonarr-not-aired { - .fa-icon-content(@fa-var-clock-o); -} - -.icon-sonarr-import { - .fa-icon-content(@fa-var-inbox); -} - -.icon-sonarr-import-manual { - .fa-icon-content(@fa-var-user); -} - -.icon-sonarr-imported { - .fa-icon-content(@fa-var-download); -} - -.icon-sonarr-status { - .fa-icon-content(@fa-var-circle); -} - -.icon-sonarr-monitored { - .fa-icon-content(@fa-var-bookmark); -} - -.icon-sonarr-unmonitored { - .fa-icon-content(@fa-var-bookmark-o); -} - -.icon-sonarr-log-info { - .fa-icon-content(@fa-var-info-circle); - .fa-icon-color(dodgerblue); -} - -.icon-sonarr-log-debug { - .fa-icon-content(@fa-var-info-circle); - .fa-icon-color(gray); -} - -.icon-sonarr-log-trace { - .fa-icon-content(@fa-var-info-circle); - .fa-icon-color(lightgrey); -} - -.icon-sonarr-log-warn { - .fa-icon-content(@fa-var-exclamation-circle); - .fa-icon-color(@brand-warning); -} - -.icon-sonarr-log-error { - .fa-icon-content(@fa-var-bug); - .fa-icon-color(@brand-danger); -} - -.icon-sonarr-log-fatal { - .fa-icon-content(@fa-var-times-circle); - .fa-icon-color(purple); -} - -.icon-sonarr-import-failed { - .fa-icon-content(@fa-var-download); - .fa-icon-color(@brand-danger); -} - -.icon-sonarr-download-failed { - .fa-icon-content(@fa-var-cloud-download); - .fa-icon-color(@brand-danger); -} - -.icon-sonarr-download-warning { - .fa-icon-content(@fa-var-cloud-download); - .fa-icon-color(@brand-warning); -} - -.icon-sonarr-shutdown { - .fa-icon-content(@fa-var-power-off); - .fa-icon-color(@brand-danger); -} - -.icon-sonarr-restart { - .fa-icon-content(@fa-var-repeat); -} - -.icon-sonarr-health-warning { - .fa-icon-content(@fa-var-exclamation-circle); - .fa-icon-color(@brand-warning); -} - -.icon-sonarr-health-error { - .fa-icon-content(@fa-var-exclamation-circle); - .fa-icon-color(@brand-danger); -} - -.icon-sonarr-search { - .fa-icon-content(@fa-var-search); -} - -.icon-sonarr-search-manual { - .fa-icon-content(@fa-var-user); -} - -.icon-sonarr-search-automatic { - .fa-icon-content(@fa-var-rocket); -} - -.icon-sonarr-delete { - .fa-icon-content(@fa-var-remove); - .fa-icon-color(@brand-danger); -} - -.icon-sonarr-deleted { - .fa-icon-content(@fa-var-trash); -} - -.icon-sonarr-clear { - .fa-icon-content(@fa-var-trash); -} - -.icon-sonarr-existing { - .fa-icon-content(@fa-var-minus); - .fa-icon-color(@brand-danger); -} - -.icon-sonarr-suggested { - .fa-icon-content(@fa-var-plus); - .fa-icon-color(@brand-success); -} - -.icon-sonarr-info { - .fa-icon-content(@fa-var-info-circle); -} - -.icon-sonarr-all { - .fa-icon-content(@fa-var-circle-o); -} - -//Navbar -.icon-sonarr-navbar-collapsed { - .fa-icon-content(@fa-var-bars); -} - -.icon-sonarr-navbar-series { - .fa-icon-content(@fa-var-play); -} - -.icon-sonarr-navbar-calendar { - .fa-icon-content(@fa-var-calendar); -} - -.icon-sonarr-navbar-activity { - .fa-icon-content(@fa-var-clock-o); -} - -.icon-sonarr-navbar-wanted { - .fa-icon-content(@fa-var-exclamation-triangle); -} - -.icon-sonarr-navbar-settings { - .fa-icon-content(@fa-var-cogs); -} - -.icon-sonarr-navbar-system { - .fa-icon-content(@fa-var-laptop); -} - -.icon-sonarr-navbar-donate { - .fa-icon-content(@fa-var-heart); - .fa-icon-color(@nzbdroneRed); -} - -.icon-sonarr-back-to-top { - .fa-icon-content(@fa-var-arrow-circle-up); -} - -.icon-sonarr-hdd { - .fa-icon-content(@fa-var-hdd-o); -} - -.icon-sonarr-copy { - .fa-icon-content(@fa-var-clipboard); -} - -.icon-sonarr-unknown { - .fa-icon-content(@fa-var-question); -} - -.icon-sonarr-load-more { - .fa-icon-content(@fa-var-angle-down); -} - -.icon-sonarr-ok { - .fa-icon-content(@fa-var-check); -} - -.icon-sonarr-calendar-o { - .fa-icon-content(@fa-var-calendar-o); -} - -.icon-sonarr-folder-open { - .fa-icon-content(@fa-var-folder-open); -} - -.icon-sonarr-refresh { - .fa-icon-content(@fa-var-refresh); -} - -.icon-sonarr-series-ended { - .fa-icon-content(@fa-var-stop); -} - -.icon-sonarr-series-continuing { - .fa-icon-content(@fa-var-play); -} - -.icon-sonarr-series-unmonitored { - .fa-icon-content(@fa-var-pause); -} - -.icon-sonarr-checked { - .fa-icon-content(@fa-var-check-square); -} - -.icon-sonarr-unchecked { - .fa-icon-content(@fa-var-square-o); -} - -.icon-sonarr-expand { - .fa-icon-content(@fa-var-chevron-right); -} - -.icon-sonarr-expanded { - .fa-icon-content(@fa-var-chevron-down); -} - -.icon-sonarr-panel-show { - .fa-icon-content(@fa-var-chevron-circle-down); -} - -.icon-sonarr-panel-hide { - .fa-icon-content(@fa-var-chevron-circle-up); -} - -.icon-sonarr-comment { - .fa-icon-content(@fa-var-comment) -} - -.icon-sonarr-rss { - .fa-icon-content(@fa-var-rss) -} - -.icon-sonarr-view-poster { - .fa-icon-content(@fa-var-th-large) -} - -.icon-sonarr-view-list { - .fa-icon-content(@fa-var-th-list) -} - -.icon-sonarr-view-table { - .fa-icon-content(@fa-var-table) -} - -.icon-sonarr-reorder { - .fa-icon-content(@fa-var-bars); -} - -.icon-sonarr-browser-computer { - .fa-icon-content(@fa-var-desktop); -} - -.icon-sonarr-browser-up { - .fa-icon-content(@fa-var-level-up); -} - -.icon-sonarr-browser-folder { - .fa-icon-content(@fa-var-folder-o); -} - -.icon-sonarr-browser-file { - .fa-icon-content(@fa-var-file-o); -} - -.icon-sonarr-sort-asc { - .fa-icon-content(@fa-var-sort-asc); -} - -.icon-sonarr-sort-desc { - .fa-icon-content(@fa-var-sort-desc); -} - -.icon-sonarr-pager-first { - .fa-icon-content(@fa-var-fast-backward); -} - -.icon-sonarr-pager-previous { - .fa-icon-content(@fa-var-backward); -} - -.icon-sonarr-pager-next { - .fa-icon-content(@fa-var-forward); -} - -.icon-sonarr-pager-last { - .fa-icon-content(@fa-var-fast-forward); -} - -.icon-sonarr-logout { - .fa-icon-content(@fa-var-sign-out); -} - -.icon-sonarr-file-text { - .fa-icon-content(@fa-var-file-text); -} - -.icon-sonarr-backup-scheduled { - .fa-icon-content(@fa-var-clock-o); -} - -.icon-sonarr-backup-manual { - .fa-icon-content(@fa-var-book); -} - -.icon-sonarr-backup-update { - .fa-icon-content(@fa-var-retweet); -} - -.icon-sonarr-episode-file { - .fa-icon-content(@fa-var-file-video-o); -} - -.icon-sonarr-header-rejections { - .fa-icon-content(@fa-var-exclamation-circle); -} \ No newline at end of file diff --git a/src/UI/Content/legend.less b/src/UI/Content/legend.less deleted file mode 100644 index 2335acd30..000000000 --- a/src/UI/Content/legend.less +++ /dev/null @@ -1,32 +0,0 @@ -@import "./Bootstrap/mixins"; - -.legend { - margin: 5px; - - ul { - margin: 0; - margin-bottom: 5px; - padding: 0; - float: left; - list-style: none; - - li { - font-size: 80%; - list-style: none; - margin-left: 0; - line-height: 18px; - margin-bottom: 2px; - - span { - display: block; - float: left; - height: 16px; - width: 30px; - margin-right: 5px; - margin-left: 0; - border: none; - border-radius: 3px; - } - } - } -} diff --git a/src/UI/Content/mixins.less b/src/UI/Content/mixins.less deleted file mode 100644 index d6da04c3a..000000000 --- a/src/UI/Content/mixins.less +++ /dev/null @@ -1,21 +0,0 @@ -.selectable() { - -moz-user-select : all; - -webkit-user-select : all; - -ms-user-select : all; -} - -.not-selectable() { - -moz-user-select : none; - -webkit-user-select : none; - -ms-user-select : none; -} - -.color-impaired-background-gradient(@angle, @color) { - .color-impaired-mode & { - background : repeating-linear-gradient(@angle, - darken(@color, 3%), - darken(@color, 3%) 6px, - @color 6px, - @color 12px); - } -} diff --git a/src/UI/Content/navbar.less b/src/UI/Content/navbar.less deleted file mode 100644 index be535a779..000000000 --- a/src/UI/Content/navbar.less +++ /dev/null @@ -1,235 +0,0 @@ -@import "prefixer"; -@import "variables"; - -@grid-float-breakpoint: @screen-xs-min; - -.backdrop { - .navbar-nzbdrone { - .opacity(0.85); - background-color : #000000; - padding-bottom: 10px; - z-index: 10; - } -} - -.navbar-nzbdrone { - text-align : center; - - i:before { - font-size : 35px; - display : block; - margin-bottom : 1px; - } - - .icon-sonarr-navbar-icon { - display: inline; - } - - .navbar-nav, .navbar-nav>li { - float : none; - } - - .navbar-toggle { - border-color: #333; - - &:hover, - &:focus { - color : #222; - background-color : #333; - } - } - - .navbar-brand { - position: absolute; - - @media (max-width: @screen-xs-max) { - padding: 9px 15px; - font-size: 14px; - } - - @media (min-width: @screen-sm-min) and (max-width: @screen-sm-max) { - padding: 20px 15px; - } - - @media (min-width: @screen-md-min) and (max-width: @screen-md-max) { - padding: 30px 15px; - } - - @media (min-width: @screen-lg-min) { - padding: 22px 15px; - } - } - - .logo-text { - color: white; - font-weight: 300; - - .highlight { - font-weight: 400; - color: @droneTeal; - } - } - - li { - list-style-type : none; - display : inline-block; - position : relative; - - a { - display : block; - color : #b9b9b9; - font-weight : 100; - - &:focus { - background-color : transparent; - text-decoration : none; - } - - &:hover { - background-color : #555555; - text-decoration : none; - } - - .label { - cursor: pointer; - } - - @media (min-width: @screen-sm-min) and (max-width: @screen-sm-max) { - border-radius : 6px; - padding : 5px 0px 5px; - min-height : 76px; - min-width : 64px; - margin : 20px 5px 5px; - } - - @media (min-width: @screen-md-min) and (max-width: @screen-md-max) { - border-radius : 6px; - padding : 15px 10px 5px; - min-height : 76px; - min-width : 64px; - margin : 20px 10px 5px; - } - - @media (min-width: @screen-lg-min) { - border-radius : 6px; - padding : 15px 10px 5px; - min-height : 76px; - min-width : 84px; - margin : 20px 10px 5px; - } - } - - .navbar-info { - .label { - position : absolute; - top : 10px; - right : 10px; - padding-left : 4px; - padding-right : 4px; - } - } - } - - @media (max-width: @screen-xs-max) { - text-align : left; - - i:before { - font-size : 14px; - display: inline-block; - } - - li { - display: block; - - a:hover { - background-color: transparent; - } - - .navbar-info { - margin-left: 5px; - - .label { - position : static; - } - } - } - } - - @media (max-width: @screen-xs-max) { - .navbar-collapse { - .navbar-nav { - li { - &:focus, &:hover { - background-color : #555555; - } - } - } - } - } -} - -.search { - - i:before { - font-size: 14px; - } - - .input-group { - input, .input-group-addon { - background-color: #333333; - } - } - - input, .input-group-addon { - border-color: #333333; - color: #cccccc; - } - - ul { - text-align: left; - } - - .tt-dropdown-menu { - - background-color: #333333; - color: #cccccc; - .opacity(0.95); - - .tt-suggestion { - color: #cccccc; - cursor: pointer; - - &.tt-cursor { - //item selected - - background-color: @droneTeal; - color: #222222; - - a { - //link in item selected - color: #222222; - } - } - } - } - - ::-webkit-input-placeholder { - color: #cccccc; - opacity: 0.25; - } - - :-moz-placeholder { /* Firefox 18- */ - color: #cccccc; - opacity: 0.25; - } - - ::-moz-placeholder { /* Firefox 19+ */ - color: #cccccc; - opacity: 0.25; - } - - :-ms-input-placeholder { - color: #cccccc; - opacity: 0.25; - } -} diff --git a/src/UI/Content/overrides.less b/src/UI/Content/overrides.less deleted file mode 100644 index abee53862..000000000 --- a/src/UI/Content/overrides.less +++ /dev/null @@ -1,6 +0,0 @@ -@import "Overrides/bootstrap"; -@import "Overrides/browser"; -@import "Overrides/bootstrap.toggle-switch"; -@import "Overrides/bootstrap.tagsinput.less"; -@import "Overrides/fullcalendar"; -@import "Overrides/messenger"; diff --git a/src/UI/Content/prefixer.less b/src/UI/Content/prefixer.less deleted file mode 100644 index c9040baa1..000000000 --- a/src/UI/Content/prefixer.less +++ /dev/null @@ -1,344 +0,0 @@ -//--------------------------------------------------- -// LESS Prefixer -//--------------------------------------------------- -// -// All of the CSS3 fun, none of the prefixes! -// -// As a rule, you can use the CSS properties you -// would expect just by adding a '.': -// -// box-shadow => .box-shadow(@args) -// -// Also, when shorthand is available, arguments are -// not parameterized. Learn CSS, not LESS Prefixer. -// -// ------------------------------------------------- -// TABLE OF CONTENTS -// (*) denotes a syntax-sugar helper -// ------------------------------------------------- -// -// .animation(@args) -// .animation-delay(@delay) -// .animation-direction(@direction) -// .animation-duration(@duration) -// .animation-iteration-count(@count) -// .animation-name(@name) -// .animation-play-state(@state) -// .animation-timing-function(@function) -// .background-size(@args) -// .border-radius(@args) -// .box-shadow(@args) -// .inner-shadow(@args) * -// .box-sizing(@args) -// .border-box() * -// .content-box() * -// .columns(@args) -// .column-count(@count) -// .column-gap(@gap) -// .column-rule(@args) -// .column-width(@width) -// .gradient(@default,@start,@stop) * -// .linear-gradient-top(@default,@color1,@stop1,@color2,@stop2,[@color3,@stop3,@color4,@stop4])* -// .linear-gradient-left(@default,@color1,@stop1,@color2,@stop2,[@color3,@stop3,@color4,@stop4])* -// .opacity(@factor) -// .transform(@args) -// .rotate(@deg) -// .scale(@factor) -// .translate(@x,@y) -// .translate3d(@x,@y,@z) -// .translateHardware(@x,@y) * -// .text-shadow(@args) -// .transition(@args) -// .transition-delay(@delay) -// .transition-duration(@duration) -// .transition-property(@property) -// .transition-timing-function(@function) -// -// -// -// Credit to LESS Elements for the motivation and -// to CSS3Please.com for implementation. -// -// Copyright (c) 2012 Joel Sutherland -// MIT Licensed: -// http://www.opensource.org/licenses/mit-license.php -// -//--------------------------------------------------- - - -// Animation - -.animation(@args) { - -webkit-animation: @args; - -moz-animation: @args; - -ms-animation: @args; - -o-animation: @args; - animation: @args; -} -.animation-delay(@delay) { - -webkit-animation-delay: @delay; - -moz-animation-delay: @delay; - -ms-animation-delay: @delay; - -o-animation-delay: @delay; - animation-delay: @delay; -} -.animation-direction(@direction) { - -webkit-animation-direction: @direction; - -moz-animation-direction: @direction; - -ms-animation-direction: @direction; - -o-animation-direction: @direction; - animation-direction: @direction; -} -.animation-duration(@duration) { - -webkit-animation-duration: @duration; - -moz-animation-duration: @duration; - -ms-animation-duration: @duration; - -o-animation-duration: @duration; - animation-duration: @duration; -} -.animation-iteration-count(@count) { - -webkit-animation-iteration-count: @count; - -moz-animation-iteration-count: @count; - -ms-animation-iteration-count: @count; - -o-animation-iteration-count: @count; - animation-iteration-count: @count; -} -.animation-name(@name) { - -webkit-animation-name: @name; - -moz-animation-name: @name; - -ms-animation-name: @name; - -o-animation-name: @name; - animation-name: @name; -} -.animation-play-state(@state) { - -webkit-animation-play-state: @state; - -moz-animation-play-state: @state; - -ms-animation-play-state: @state; - -o-animation-play-state: @state; - animation-play-state: @state; -} -.animation-timing-function(@function) { - -webkit-animation-timing-function: @function; - -moz-animation-timing-function: @function; - -ms-animation-timing-function: @function; - -o-animation-timing-function: @function; - animation-timing-function: @function; -} - - -// Background Size - -.background-size(@args) { - -webkit-background-size: @args; - -moz-background-size: @args; - background-size: @args; -} - - -// Border Radius - -.border-radius(@args) { - -webkit-border-radius: @args; - -moz-border-radius: @args; - border-radius: @args; - - -webkit-background-clip: padding-box; - -moz-background-clip: padding; - background-clip: padding-box; -} - - -// Box Shadows - -.box-shadow(@args) { - -webkit-box-shadow: @args; - -moz-box-shadow: @args; - box-shadow: @args; -} -.inner-shadow(@args) { - .box-shadow(inset @args); -} - - -// Box Sizing - -.box-sizing(@args){ - -webkit-box-sizing: @args; - -moz-box-sizing: @args; - box-sizing: @args; -} -.border-box(){ - .box-sizing(border-box); -} -.content-box(){ - .box-sizing(content-box); -} - - -// Columns - -.columns(@args){ - -webkit-columns: @args; - -moz-columns: @args; - columns: @args; -} -.column-count(@count) { - -webkit-column-count: @count; - -moz-column-count: @count; - column-count: @count; -} -.column-gap(@gap) { - -webkit-column-gap: @gap; - -moz-column-gap: @gap; - column-gap: @gap; -} -.column-width(@width){ - -webkit-column-width: @width; - -moz-column-width: @width; - column-width: @width; -} -.column-rule(@args){ - -webkit-column-rule: @args; - -moz-column-rule: @args; - column-rule: @args; -} - - -// Gradients - -.gradient(@default: #F5F5F5, @start: #EEE, @stop: #FFF) { - .linear-gradient-top(@default,@start,0%,@stop,100%); -} -.linear-gradient-top(@default,@color1,@stop1,@color2,@stop2) { - background-color: @default; - background-image: -webkit-gradient(linear, left top, left bottom, color-stop(@stop1, @color1), color-stop(@stop2 @color2)); - background-image: -webkit-linear-gradient(top, @color1 @stop1, @color2 @stop2); - background-image: -moz-linear-gradient(top, @color1 @stop1, @color2 @stop2); - background-image: -ms-linear-gradient(top, @color1 @stop1, @color2 @stop2); - background-image: -o-linear-gradient(top, @color1 @stop1, @color2 @stop2); - background-image: linear-gradient(top, @color1 @stop1, @color2 @stop2); -} -.linear-gradient-top(@default,@color1,@stop1,@color2,@stop2,@color3,@stop3) { - background-color: @default; - background-image: -webkit-gradient(linear, left top, left bottom, color-stop(@stop1, @color1), color-stop(@stop2 @color2), color-stop(@stop3 @color3)); - background-image: -webkit-linear-gradient(top, @color1 @stop1, @color2 @stop2, @color3 @stop3); - background-image: -moz-linear-gradient(top, @color1 @stop1, @color2 @stop2, @color3 @stop3); - background-image: -ms-linear-gradient(top, @color1 @stop1, @color2 @stop2, @color3 @stop3); - background-image: -o-linear-gradient(top, @color1 @stop1, @color2 @stop2, @color3 @stop3); - background-image: linear-gradient(top, @color1 @stop1, @color2 @stop2, @color3 @stop3); -} -.linear-gradient-top(@default,@color1,@stop1,@color2,@stop2,@color3,@stop3,@color4,@stop4) { - background-color: @default; - background-image: -webkit-gradient(linear, left top, left bottom, color-stop(@stop1, @color1), color-stop(@stop2 @color2), color-stop(@stop3 @color3), color-stop(@stop4 @color4)); - background-image: -webkit-linear-gradient(top, @color1 @stop1, @color2 @stop2, @color3 @stop3, @color4 @stop4); - background-image: -moz-linear-gradient(top, @color1 @stop1, @color2 @stop2, @color3 @stop3, @color4 @stop4); - background-image: -ms-linear-gradient(top, @color1 @stop1, @color2 @stop2, @color3 @stop3, @color4 @stop4); - background-image: -o-linear-gradient(top, @color1 @stop1, @color2 @stop2, @color3 @stop3, @color4 @stop4); - background-image: linear-gradient(top, @color1 @stop1, @color2 @stop2, @color3 @stop3, @color4 @stop4); -} -.linear-gradient-left(@default,@color1,@stop1,@color2,@stop2) { - background-color: @default; - background-image: -webkit-gradient(linear, left top, left top, color-stop(@stop1, @color1), color-stop(@stop2 @color2)); - background-image: -webkit-linear-gradient(left, @color1 @stop1, @color2 @stop2); - background-image: -moz-linear-gradient(left, @color1 @stop1, @color2 @stop2); - background-image: -ms-linear-gradient(left, @color1 @stop1, @color2 @stop2); - background-image: -o-linear-gradient(left, @color1 @stop1, @color2 @stop2); - background-image: linear-gradient(left, @color1 @stop1, @color2 @stop2); -} -.linear-gradient-left(@default,@color1,@stop1,@color2,@stop2,@color3,@stop3) { - background-color: @default; - background-image: -webkit-gradient(linear, left top, left top, color-stop(@stop1, @color1), color-stop(@stop2 @color2), color-stop(@stop3 @color3)); - background-image: -webkit-linear-gradient(left, @color1 @stop1, @color2 @stop2, @color3 @stop3); - background-image: -moz-linear-gradient(left, @color1 @stop1, @color2 @stop2, @color3 @stop3); - background-image: -ms-linear-gradient(left, @color1 @stop1, @color2 @stop2, @color3 @stop3); - background-image: -o-linear-gradient(left, @color1 @stop1, @color2 @stop2, @color3 @stop3); - background-image: linear-gradient(left, @color1 @stop1, @color2 @stop2, @color3 @stop3); -} -.linear-gradient-left(@default,@color1,@stop1,@color2,@stop2,@color3,@stop3,@color4,@stop4) { - background-color: @default; - background-image: -webkit-gradient(linear, left top, left top, color-stop(@stop1, @color1), color-stop(@stop2 @color2), color-stop(@stop3 @color3), color-stop(@stop4 @color4)); - background-image: -webkit-linear-gradient(left, @color1 @stop1, @color2 @stop2, @color3 @stop3, @color4 @stop4); - background-image: -moz-linear-gradient(left, @color1 @stop1, @color2 @stop2, @color3 @stop3, @color4 @stop4); - background-image: -ms-linear-gradient(left, @color1 @stop1, @color2 @stop2, @color3 @stop3, @color4 @stop4); - background-image: -o-linear-gradient(left, @color1 @stop1, @color2 @stop2, @color3 @stop3, @color4 @stop4); - background-image: linear-gradient(left, @color1 @stop1, @color2 @stop2, @color3 @stop3, @color4 @stop4); -} - - -// Opacity - -.opacity(@factor){ - opacity: @factor; - @iefactor: @factor*100; - filter: alpha(opacity=@iefactor); -} - - -// Text Shadow - -.text-shadow(@args){ - text-shadow: @args; -} - - -// Transforms - -.transform(@args) { - -webkit-transform: @args; - -moz-transform: @args; - -ms-transform: @args; - -o-transform: @args; - transform: @args; -} -.rotate(@deg:45deg){ - .transform(rotate(@deg)); -} -.scale(@factor:.5){ - .transform(scale(@factor)); -} -.translate(@x,@y){ - .transform(translate(@x,@y)); -} -.translate3d(@x,@y,@z) { - .transform(translate3d(@x,@y,@z)); -} -.translateHardware(@x,@y){ - .translate(@x,@y); - -webkit-transform: translate3d(@x,@y,0); - -moz-transform: translate3d(@x,@y,0); -} - - -// Transitions - -.transition(@args:200ms) { - -webkit-transition: @args; - -moz-transition: @args; - -o-transition: @args; - transition: @args; -} -.transition-delay(@delay:0) { - -webkit-transition-delay: @delay; - -moz-transition-delay: @delay; - -o-transition-delay: @delay; - transition-delay: @delay; -} -.transition-duration(@duration:200ms) { - -webkit-transition-duration: @duration; - -moz-transition-duration: @duration; - -o-transition-duration: @duration; - transition-duration: @duration; -} -.transition-property(@property:all) { - -webkit-transition-property: @property; - -moz-transition-property: @property; - -o-transition-property: @property; - transition-property: @property; -} -.transition-timing-function(@function:ease) { - -webkit-transition-timing-function: @function; - -moz-transition-timing-function: @function; - -o-transition-timing-function: @function; - transition-timing-function: @function; -} - diff --git a/src/UI/Content/progress-bars.less b/src/UI/Content/progress-bars.less deleted file mode 100644 index 9211b1c87..000000000 --- a/src/UI/Content/progress-bars.less +++ /dev/null @@ -1,39 +0,0 @@ -@import "Bootstrap/mixins"; -@import "Bootstrap/variables"; -@import "variables"; - -.progress.episode-progress { - position : relative; - margin-bottom : 2px; - - &, .progressbar-back-text, .progressbar-front-text { - width : 125px; - } - - .progressbar-back-text, .progressbar-front-text { - font-size : 12px; - font-weight : bold; - text-align : center; - cursor : default; - line-height : 20px; - } - - .progressbar-back-text { - position : absolute; - height : 100%; - } - - .progressbar-front-text { - display : block; - height : 100%; - } - - .progress-bar { - position : absolute; - overflow : hidden; - } -} - -.progress-bar-purple { - #gradient > .vertical(@purple, @nzbdronePurple); -} diff --git a/src/UI/Content/robots.txt b/src/UI/Content/robots.txt deleted file mode 100644 index 77470cb39..000000000 --- a/src/UI/Content/robots.txt +++ /dev/null @@ -1,2 +0,0 @@ -User-agent: * -Disallow: / \ No newline at end of file diff --git a/src/UI/Content/spinner.less b/src/UI/Content/spinner.less deleted file mode 100644 index 2a02f136b..000000000 --- a/src/UI/Content/spinner.less +++ /dev/null @@ -1,130 +0,0 @@ -@import "prefixer"; -@import "Bootstrap/variables"; - -@colorDark : @gray-dark; -@colorLight : @gray-lighter; - -#followingBalls { - position : relative; - height : 20px; - width : 256px; - margin : 50px auto; - display : block; - - .ball { - background-color : @colorDark; - position : absolute; - top : 0; - left : 0; - width : 20px; - height : 20px; - .border-radius(10px); - .animation-name(bounce); - .animation-duration(1.9s); - .animation-iteration-count(infinite); - .animation-direction(linear); - } - - #ball-1 { - .animation-delay(0s); - } - - #ball-2 { - .animation-delay(0.19s); - } - - #ball-3 { - .animation-delay(0.38s); - } - - #ball-4 { - .animation-delay(0.57s); - } - - @keyframes bounce { - 0% { - left : 0px; - background-color : @colorDark; - } - - 50% { - left : 236px; - background-color : @colorLight; - } - - 100% { - left : 0px; - background-color : @colorDark; - } - } - - @-moz-keyframes bounce { - 0% { - left : 0px; - background-color : @colorDark; - } - - 50% { - left : 236px; - background-color : @colorLight; - } - - 100% { - left : 0px; - background-color : @colorDark; - } - - } - - @-webkit-keyframes bounce { - 0% { - left : 0px; - background-color : @colorDark; - } - - 50% { - left : 236px; - background-color : @colorLight; - } - - 100% { - left : 0px; - background-color : @colorDark; - } - - } - - @-ms-keyframes bounce { - 0% { - left : 0px; - background-color : @colorDark; - } - - 50% { - left : 236px; - background-color : @colorLight; - } - - 100% { - left : 0px; - background-color : @colorDark; - } - } - - @-o-keyframes bounce { - 0% { - left : 0px; - background-color : @colorDark; - } - - 50% { - left : 236px; - background-color : @colorLight; - } - - 100% { - left : 0px; - background-color : @colorDark; - } - } -} diff --git a/src/UI/Content/theme.less b/src/UI/Content/theme.less deleted file mode 100644 index 9d32eb99a..000000000 --- a/src/UI/Content/theme.less +++ /dev/null @@ -1,306 +0,0 @@ -@import "Bootstrap/variables"; -@import "Bootstrap/mixins"; -@import "Bootstrap/type"; -@import "font"; -@import "form"; -@import "navbar"; -@import "Backgrid/backgrid"; -@import "prefixer"; -@import "icons"; -@import "checkbox-button"; -@import "spinner"; -@import "legend"; -@import "progress-bars"; -@import "../Shared/Styles/clickable"; -@import "../Shared/Styles/card"; -@import "../Rename/rename"; -@import "typeahead"; -@import "utilities"; -@import "../Hotkeys/hotkeys"; -@import "../Shared/FileBrowser/filebrowser"; -@import "badges"; -@import "../ManualImport/manualimport"; -@import "../SeasonPass/seasonpass"; - -.main-region { - @media (min-width : @screen-lg-min) { - padding-left : 30px; - padding-right : 30px; - } -} - -.toolbar { - - &:after { - visibility : hidden; - display : block; - font-size : 0; - content : " "; - clear : both; - height : 0; - } - - .page-toolbar { - margin-top : 10px; - margin-bottom : 30px; - - .toolbar-group { - display : inline-block; - } - - .sorting-buttons { - .sorting-title { - display : inline-block; - width : 110px; - } - } - } -} - -.toolbars { - margin-top : 5px; - margin-bottom : 30px; - - .page-toolbar { - margin-top : 5px; - margin-bottom : 0px; - } -} - -.page-container { - min-height : 500px; -} - -#scroll-up { - - i { - .clickable; - .opacity(0.3); - margin: 0px 20px; - - &:hover { - .opacity(0.4); - } - } - - position : fixed; - z-index : 9999; - bottom : 30px; - right : 0px; - display : none; - font-size : 56px; - color : gray; -} - -.control-panel-visible { - #scroll-up { - bottom : 100px; - } -} - -.label-large { - padding : 4px 6px; - font-size : 16px; -} - -.label-white { - color : black; - background-color : white; -} - -.label-disabled { - opacity : 0.5; -} - -th { - cursor : default; - - &.sortable { - &:hover { - background : @table-bg-hover; - } - .clickable(); - - } -} - -a, .btn { - i { - cursor : pointer; - } -} - -body { - background : url('../Content/Images/background/logo.png') 50px 75px no-repeat; - background-color : #272727; - margin-bottom : 100px; - p { - font-size : 0.9em; - } -} - -.footer { - font-size : 13px; - font-weight : lighter; - padding-top : 0px; - padding-bottom : 20px; - color : #999999; - margin : 0; - text-decoration : none; - - a { - color : #999999; - text-decoration : underline; - } - - p { - margin-bottom : 0px; - } - - #footer-region { - .text-center(); - position : relative; - width : 256px; - margin : 50px auto 0px auto; - display : block; - } -} - -.started #page { - .card(#aaaaaa); - /* width : 1210px; - min-width : 1210px; */ - max-width : 1210px; - margin : auto; - // margin-top : -70px; - padding : 20px 0px; - - .header { - padding-bottom : 10px; - margin-bottom : 20px; - border-bottom : 1px solid #eeeeee; - } -} - -.backdrop #page { - background-color : transparent; - box-shadow : none; -} - -.validation-errors { - i { - padding-right : 5px; - } -} - -.status-primary { - color : @link-color; -} - -.status-success { - color : @state-success-text; -} - -.status-warning { - color : @state-warning-text; -} - -.status-danger { - color : @state-danger-text; -} - -.error { - background : #FF0000; -} - -#errors { - display : none; -} - -.mono-space { - font-family : "ubuntu mono" -} - -.file-path { - .mono-space(); -} - -.control-panel { - .card(#333333); - - color : #f5f5f5; - background-color : #333333; - margin : 0px; - margin-bottom : -100px; - position : fixed; - left : 0; - bottom : 0; - width : 100%; - height : 80px; - opacity : 0; - - @media (max-width : @screen-sm-max) { - height : initial; - position : static; - } -} - -.tab-content { - .tab-pane { - padding-top : 10px; - } -} - -.modal-header { - h3 { - margin-top : 0px; - margin-bottom : 0px; - } -} - -.modal-body { - table { - font-size : 12px; - font-weight : bold; - - i { - font-size : 14px; - } - } -} - -.tooltip { - .tooltip-inner { - max-width : 250px; - } -} - -dl.info { - dt, dd { - padding-bottom : 5px; - } -} - -.label { - &.protocol-torrent { - background-color : #00853D; - } - - &.protocol-usenet { - background-color : #17B1D9; - } -} - -.login { - color : #ececec; - - h2 { - vertical-align : bottom; - } -} - -.sort-direction-icon { - .pull-right(); - position : relative; - width : 0px; -} diff --git a/src/UI/Content/typeahead.less b/src/UI/Content/typeahead.less deleted file mode 100644 index 5a901c3ee..000000000 --- a/src/UI/Content/typeahead.less +++ /dev/null @@ -1,152 +0,0 @@ -/* - * typehead.js-bootstrap3.less - * @version 0.2.3 - * https://github.com/hyspace/typeahead.js-bootstrap3.less - * - * Licensed under the MIT license: - * http://www.opensource.org/licenses/MIT - */ - -//custom mixin for .form-control-validation -.typeahead-form-control(@border-color: #ccc;) { - border-color: @border-color; - .box-shadow(inset 0 1px 1px rgba(0,0,0,.075)); // Redeclare so transitions work - &:focus { - border-color: darken(@border-color, 10%); - @shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 6px lighten(@border-color, 20%); - .box-shadow(@shadow); - } -} - -//main styles for control -.tt-input, -.tt-hint { - .twitter-typeahead &{ - //validation states - .has-warning &{ - .typeahead-form-control(@state-warning-text); - } - .has-error &{ - .typeahead-form-control(@state-danger-text); - } - .has-success &{ - .typeahead-form-control(@state-success-text); - } - } - - //border - .input-group .twitter-typeahead:first-child &{ - .border-left-radius(@border-radius-base); - } - .input-group .twitter-typeahead:last-child &{ - .border-right-radius(@border-radius-base); - } - - //sizing - small:size and border - .input-group.input-group-sm .twitter-typeahead &{ - .input-size(@input-height-small; @padding-small-vertical; @padding-small-horizontal; @font-size-small; @line-height-small; @border-radius-small); - } - .input-group.input-group-sm .twitter-typeahead:not(:first-child):not(:last-child) &{ - border-radius: 0; - } - .input-group.input-group-sm .twitter-typeahead:first-child &{ - .border-left-radius(@border-radius-small); - .border-right-radius(0); - } - .input-group.input-group-sm .twitter-typeahead:last-child &{ - .border-left-radius(0); - .border-right-radius(@border-radius-small); - } - - //sizing - large:size and border - .input-group.input-group-lg .twitter-typeahead &{ - .input-size(@input-height-large; @padding-large-vertical; @padding-large-horizontal; @font-size-large; @line-height-large; @border-radius-large); - } - .input-group.input-group-lg .twitter-typeahead:not(:first-child):not(:last-child) &{ - border-radius: 0; - } - .input-group.input-group-lg .twitter-typeahead:first-child &{ - .border-left-radius(@border-radius-large); - .border-right-radius(0); - } - .input-group.input-group-lg .twitter-typeahead:last-child &{ - .border-left-radius(0); - .border-right-radius(@border-radius-large); - } -} - -//for wrapper -.twitter-typeahead { - width: 100%; - .input-group &{ - //overwrite `display:inline-block` style - display: table-cell!important; - float: left; - } -} - -//particular style for each other -.twitter-typeahead .tt-hint { - color: @text-muted;//color - hint -} -.twitter-typeahead .tt-input { - z-index: 2; - //disabled status - //overwrite inline styles of .tt-query - &[disabled], - &[readonly], - fieldset[disabled] & { - cursor: not-allowed; - //overwirte inline style - background-color: @input-bg-disabled!important; - } -} - -//dropdown styles -.tt-dropdown-menu { - //dropdown menu - position: absolute; - top: 100%; - left: 0; - z-index: @zindex-dropdown; - min-width: 160px; - width: 100%; - padding: 5px 0; - margin: 2px 0 0; - list-style: none; - font-size: @font-size-base; - background-color: @dropdown-bg; - border: 1px solid @dropdown-fallback-border; - border: 1px solid @dropdown-border; - border-radius: @border-radius-base; - .box-shadow(0 6px 12px rgba(0,0,0,.175)); - background-clip: padding-box; - *border-right-width: 2px; - *border-bottom-width: 2px; - - .tt-suggestion { - //item - display: block; - padding: 3px 20px; - clear: both; - font-weight: normal; - line-height: @line-height-base; - color: @dropdown-link-color; - white-space: nowrap; - - &.tt-cursor { - //item selected - text-decoration: none; - outline: 0; - background-color: @dropdown-link-hover-bg; - color: @dropdown-link-hover-color; - a { - //link in item selected - color: @dropdown-link-hover-color; - } - } - p { - margin: 0; - } - } -} diff --git a/src/UI/Content/utilities.less b/src/UI/Content/utilities.less deleted file mode 100644 index cc2f2cc75..000000000 --- a/src/UI/Content/utilities.less +++ /dev/null @@ -1,19 +0,0 @@ -@import "Bootstrap/variables"; -@import "Bootstrap/mixins"; - -@media (max-width: @screen-sm-max) { - .pull-none-xs { - float : none !important; - } - - .btn-group { - &.btn-group-collapse { - > .btn { - margin : 2px; - display : block; - float : none; - border-radius : @border-radius-base !important; - } - } - } -} \ No newline at end of file diff --git a/src/UI/Content/variables.less b/src/UI/Content/variables.less deleted file mode 100644 index 4b898e1d0..000000000 --- a/src/UI/Content/variables.less +++ /dev/null @@ -1,13 +0,0 @@ -@nzbdroneRed : #c4273c; -@purple : #7a43b6; -@nzbdronePurple : #7932ea; -@nzbdronePink : #F43565; -@droneTeal : #35c5f4; -@brand-info : @droneTeal; - -@screen-tn-max: @screen-xs-min - 1; -@tn: ~'(max-width: @{screen-tn-max})'; -@xs: ~'(min-width: @{screen-xs-max}) and (max-width: @{screen-xs-max})'; -@sm: ~'(min-width: @{screen-sm-min}) and (max-width: @{screen-sm-max})'; -@md: ~'(min-width: @{screen-md-min}) and (max-width: @{screen-md-max})'; -@lg: ~'(min-width: @{screen-lg-min})'; \ No newline at end of file diff --git a/src/UI/Content/zero.clipboard.swf b/src/UI/Content/zero.clipboard.swf deleted file mode 100644 index 8bad6a3e3..000000000 Binary files a/src/UI/Content/zero.clipboard.swf and /dev/null differ diff --git a/src/UI/Controller.js b/src/UI/Controller.js deleted file mode 100644 index f1e4032ab..000000000 --- a/src/UI/Controller.js +++ /dev/null @@ -1,59 +0,0 @@ -var NzbDroneController = require('./Shared/NzbDroneController'); -var AppLayout = require('./AppLayout'); -var Marionette = require('marionette'); -var ActivityLayout = require('./Activity/ActivityLayout'); -var SettingsLayout = require('./Settings/SettingsLayout'); -var AddSeriesLayout = require('./AddSeries/AddSeriesLayout'); -var WantedLayout = require('./Wanted/WantedLayout'); -var CalendarLayout = require('./Calendar/CalendarLayout'); -var ReleaseLayout = require('./Release/ReleaseLayout'); -var SystemLayout = require('./System/SystemLayout'); -var SeasonPassLayout = require('./SeasonPass/SeasonPassLayout'); -var SeriesEditorLayout = require('./Series/Editor/SeriesEditorLayout'); - -module.exports = NzbDroneController.extend({ - addSeries : function(action) { - this.setTitle('Add Series'); - this.showMainRegion(new AddSeriesLayout({ action : action })); - }, - - calendar : function() { - this.setTitle('Calendar'); - this.showMainRegion(new CalendarLayout()); - }, - - settings : function(action) { - this.setTitle('Settings'); - this.showMainRegion(new SettingsLayout({ action : action })); - }, - - wanted : function(action) { - this.setTitle('Wanted'); - this.showMainRegion(new WantedLayout({ action : action })); - }, - - activity : function(action) { - this.setTitle('Activity'); - this.showMainRegion(new ActivityLayout({ action : action })); - }, - - rss : function() { - this.setTitle('RSS'); - this.showMainRegion(new ReleaseLayout()); - }, - - system : function(action) { - this.setTitle('System'); - this.showMainRegion(new SystemLayout({ action : action })); - }, - - seasonPass : function() { - this.setTitle('Season Pass'); - this.showMainRegion(new SeasonPassLayout()); - }, - - seriesEditor : function() { - this.setTitle('Series Editor'); - this.showMainRegion(new SeriesEditorLayout()); - } -}); \ No newline at end of file diff --git a/src/UI/Episode/EpisodeDetailsLayout.js b/src/UI/Episode/EpisodeDetailsLayout.js deleted file mode 100644 index ba1631d0e..000000000 --- a/src/UI/Episode/EpisodeDetailsLayout.js +++ /dev/null @@ -1,130 +0,0 @@ -var Marionette = require('marionette'); -var SummaryLayout = require('./Summary/EpisodeSummaryLayout'); -var SearchLayout = require('./Search/EpisodeSearchLayout'); -var EpisodeHistoryLayout = require('./History/EpisodeHistoryLayout'); -var SeriesCollection = require('../Series/SeriesCollection'); -var Messenger = require('../Shared/Messenger'); - -module.exports = Marionette.Layout.extend({ - className : 'modal-lg', - template : 'Episode/EpisodeDetailsLayoutTemplate', - - regions : { - summary : '#episode-summary', - history : '#episode-history', - search : '#episode-search' - }, - - ui : { - summary : '.x-episode-summary', - history : '.x-episode-history', - search : '.x-episode-search', - monitored : '.x-episode-monitored' - }, - - events : { - - 'click .x-episode-summary' : '_showSummary', - 'click .x-episode-history' : '_showHistory', - 'click .x-episode-search' : '_showSearch', - 'click .x-episode-monitored' : '_toggleMonitored' - }, - - templateHelpers : {}, - - initialize : function(options) { - this.templateHelpers.hideSeriesLink = options.hideSeriesLink; - - this.series = SeriesCollection.get(this.model.get('seriesId')); - this.templateHelpers.series = this.series.toJSON(); - this.openingTab = options.openingTab || 'summary'; - - this.listenTo(this.model, 'sync', this._setMonitoredState); - }, - - onShow : function() { - this.searchLayout = new SearchLayout({ model : this.model }); - - if (this.openingTab === 'search') { - this.searchLayout.startManualSearch = true; - this._showSearch(); - } - - else { - this._showSummary(); - } - - this._setMonitoredState(); - - if (this.series.get('monitored')) { - this.$el.removeClass('series-not-monitored'); - } - - else { - this.$el.addClass('series-not-monitored'); - } - }, - - _showSummary : function(e) { - if (e) { - e.preventDefault(); - } - - this.ui.summary.tab('show'); - this.summary.show(new SummaryLayout({ - model : this.model, - series : this.series - })); - }, - - _showHistory : function(e) { - if (e) { - e.preventDefault(); - } - - this.ui.history.tab('show'); - this.history.show(new EpisodeHistoryLayout({ - model : this.model, - series : this.series - })); - }, - - _showSearch : function(e) { - if (e) { - e.preventDefault(); - } - - this.ui.search.tab('show'); - this.search.show(this.searchLayout); - }, - - _toggleMonitored : function() { - if (!this.series.get('monitored')) { - - Messenger.show({ - message : 'Unable to change monitored state when series is not monitored', - type : 'error' - }); - - return; - } - - var name = 'monitored'; - this.model.set(name, !this.model.get(name), { silent : true }); - - this.ui.monitored.addClass('icon-sonarr-spinner fa-spin'); - this.model.save(); - }, - - _setMonitoredState : function() { - this.ui.monitored.removeClass('fa-spin icon-sonarr-spinner'); - - if (this.model.get('monitored')) { - this.ui.monitored.addClass('icon-sonarr-monitored'); - this.ui.monitored.removeClass('icon-sonarr-unmonitored'); - } else { - this.ui.monitored.addClass('icon-sonarr-unmonitored'); - this.ui.monitored.removeClass('icon-sonarr-monitored'); - } - } -}); \ No newline at end of file diff --git a/src/UI/Episode/EpisodeDetailsLayoutTemplate.hbs b/src/UI/Episode/EpisodeDetailsLayoutTemplate.hbs deleted file mode 100644 index bac2e4559..000000000 --- a/src/UI/Episode/EpisodeDetailsLayoutTemplate.hbs +++ /dev/null @@ -1,35 +0,0 @@ -<div class="modal-content"> - <div class="episode-detail-modal"> - <div class="modal-header"> - <span class="hidden-series-title x-series-title">{{series.title}}</span> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - - <h3> - <i class="icon-sonarr-monitored x-episode-monitored episode-monitored" title="Toggle monitored status" /> - {{series.title}} - {{EpisodeNumber}} - {{title}} - </h3> - - </div> - <div class="modal-body"> - <ul class="nav nav-tabs" id="myTab"> - <li><a href="#episode-summary" class="x-episode-summary">Summary</a></li> - <li><a href="#episode-history" class="x-episode-history">History</a></li> - <li><a href="#episode-search" class="x-episode-search">Search</a></li> - </ul> - <div class="tab-content"> - <div class="tab-pane" id="episode-summary"/> - <div class="tab-pane" id="episode-history"/> - <div class="tab-pane" id="episode-search"/> - </div> - </div> - <div class="modal-footer"> - {{#unless hideSeriesLink}} - {{#with series}} - <a href="{{route}}" class="btn btn-default pull-left" data-dismiss="modal">Go to Series</a> - {{/with}} - {{/unless}} - - <button class="btn btn-default" data-dismiss="modal">Close</button> - </div> - </div> -</div> diff --git a/src/UI/Episode/History/EpisodeHistoryActionsCell.js b/src/UI/Episode/History/EpisodeHistoryActionsCell.js deleted file mode 100644 index c8c352aab..000000000 --- a/src/UI/Episode/History/EpisodeHistoryActionsCell.js +++ /dev/null @@ -1,35 +0,0 @@ -var $ = require('jquery'); -var vent = require('vent'); -var Marionette = require('marionette'); -var NzbDroneCell = require('../../Cells/NzbDroneCell'); - -module.exports = NzbDroneCell.extend({ - className : 'episode-actions-cell', - - events : { - 'click .x-failed' : '_markAsFailed' - }, - - render : function() { - this.$el.empty(); - - if (this.model.get('eventType') === 'grabbed') { - this.$el.html('<i class="icon-sonarr-delete x-failed" title="Mark download as failed"></i>'); - } - - return this; - }, - - _markAsFailed : function() { - var url = window.NzbDrone.ApiRoot + '/history/failed'; - var data = { - id : this.model.get('id') - }; - - $.ajax({ - url : url, - type : 'POST', - data : data - }); - } -}); \ No newline at end of file diff --git a/src/UI/Episode/History/EpisodeHistoryDetailsCell.js b/src/UI/Episode/History/EpisodeHistoryDetailsCell.js deleted file mode 100644 index 366a25040..000000000 --- a/src/UI/Episode/History/EpisodeHistoryDetailsCell.js +++ /dev/null @@ -1,28 +0,0 @@ -var $ = require('jquery'); -var vent = require('vent'); -var Marionette = require('marionette'); -var NzbDroneCell = require('../../Cells/NzbDroneCell'); -var HistoryDetailsView = require('../../Activity/History/Details/HistoryDetailsView'); -require('bootstrap'); - -module.exports = NzbDroneCell.extend({ - className : 'episode-history-details-cell', - - render : function() { - this.$el.empty(); - this.$el.html('<i class="icon-sonarr-form-info"></i>'); - - var html = new HistoryDetailsView({ model : this.model }).render().$el; - - this.$el.popover({ - content : html, - html : true, - trigger : 'hover', - title : 'Details', - placement : 'left', - container : this.$el - }); - - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Episode/History/EpisodeHistoryLayout.js b/src/UI/Episode/History/EpisodeHistoryLayout.js deleted file mode 100644 index f474f4566..000000000 --- a/src/UI/Episode/History/EpisodeHistoryLayout.js +++ /dev/null @@ -1,84 +0,0 @@ -var Marionette = require('marionette'); -var Backgrid = require('backgrid'); -var HistoryCollection = require('../../Activity/History/HistoryCollection'); -var EventTypeCell = require('../../Cells/EventTypeCell'); -var QualityCell = require('../../Cells/QualityCell'); -var RelativeDateCell = require('../../Cells/RelativeDateCell'); -var EpisodeHistoryActionsCell = require('./EpisodeHistoryActionsCell'); -var EpisodeHistoryDetailsCell = require('./EpisodeHistoryDetailsCell'); -var NoHistoryView = require('./NoHistoryView'); -var LoadingView = require('../../Shared/LoadingView'); - -module.exports = Marionette.Layout.extend({ - template : 'Episode/History/EpisodeHistoryLayoutTemplate', - - regions : { - historyTable : '.history-table' - }, - - columns : [ - { - name : 'eventType', - label : '', - cell : EventTypeCell, - cellValue : 'this' - }, - { - name : 'sourceTitle', - label : 'Source Title', - cell : 'string' - }, - { - name : 'quality', - label : 'Quality', - cell : QualityCell - }, - { - name : 'date', - label : 'Date', - cell : RelativeDateCell - }, - { - name : 'this', - label : '', - cell : EpisodeHistoryDetailsCell, - sortable : false - }, - { - name : 'this', - label : '', - cell : EpisodeHistoryActionsCell, - sortable : false - } - ], - - initialize : function(options) { - this.model = options.model; - this.series = options.series; - - this.collection = new HistoryCollection({ - episodeId : this.model.id, - tableName : 'episodeHistory' - }); - this.collection.fetch(); - this.listenTo(this.collection, 'sync', this._showTable); - }, - - onRender : function() { - this.historyTable.show(new LoadingView()); - }, - - _showTable : function() { - if (this.collection.any()) { - this.historyTable.show(new Backgrid.Grid({ - collection : this.collection, - columns : this.columns, - className : 'table table-hover table-condensed' - })); - } - - else { - this.historyTable.show(new NoHistoryView()); - } - } -}); \ No newline at end of file diff --git a/src/UI/Episode/History/EpisodeHistoryLayoutTemplate.hbs b/src/UI/Episode/History/EpisodeHistoryLayoutTemplate.hbs deleted file mode 100644 index 54fb50522..000000000 --- a/src/UI/Episode/History/EpisodeHistoryLayoutTemplate.hbs +++ /dev/null @@ -1 +0,0 @@ -<div class="history-table table-responsive"></div> \ No newline at end of file diff --git a/src/UI/Episode/History/NoHistoryView.js b/src/UI/Episode/History/NoHistoryView.js deleted file mode 100644 index 883b5dfdc..000000000 --- a/src/UI/Episode/History/NoHistoryView.js +++ /dev/null @@ -1,5 +0,0 @@ -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'Episode/History/NoHistoryViewTemplate' -}); \ No newline at end of file diff --git a/src/UI/Episode/History/NoHistoryViewTemplate.hbs b/src/UI/Episode/History/NoHistoryViewTemplate.hbs deleted file mode 100644 index 561e84d59..000000000 --- a/src/UI/Episode/History/NoHistoryViewTemplate.hbs +++ /dev/null @@ -1,3 +0,0 @@ -<p class="text-warning"> - No history for this episode. -</p> \ No newline at end of file diff --git a/src/UI/Episode/Search/ButtonsView.js b/src/UI/Episode/Search/ButtonsView.js deleted file mode 100644 index 6972f1201..000000000 --- a/src/UI/Episode/Search/ButtonsView.js +++ /dev/null @@ -1,5 +0,0 @@ -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'Episode/Search/ButtonsViewTemplate' -}); \ No newline at end of file diff --git a/src/UI/Episode/Search/ButtonsViewTemplate.hbs b/src/UI/Episode/Search/ButtonsViewTemplate.hbs deleted file mode 100644 index 6ad9474d5..000000000 --- a/src/UI/Episode/Search/ButtonsViewTemplate.hbs +++ /dev/null @@ -1,4 +0,0 @@ -<div class="search-buttons"> - <button class="btn btn-lg btn-block x-search-auto"><i class="icon-sonarr-search-automatic"/> Automatic Search</button> - <button class="btn btn-lg btn-block btn-primary x-search-manual"><i class="icon-sonarr-search-manual"/> Manual Search</button> -</div> \ No newline at end of file diff --git a/src/UI/Episode/Search/EpisodeSearchLayout.js b/src/UI/Episode/Search/EpisodeSearchLayout.js deleted file mode 100644 index 14ee5ca42..000000000 --- a/src/UI/Episode/Search/EpisodeSearchLayout.js +++ /dev/null @@ -1,82 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); -var ButtonsView = require('./ButtonsView'); -var ManualSearchLayout = require('./ManualLayout'); -var ReleaseCollection = require('../../Release/ReleaseCollection'); -var CommandController = require('../../Commands/CommandController'); -var LoadingView = require('../../Shared/LoadingView'); -var NoResultsView = require('./NoResultsView'); - -module.exports = Marionette.Layout.extend({ - template : 'Episode/Search/EpisodeSearchLayoutTemplate', - - regions : { - main : '#episode-search-region' - }, - - events : { - 'click .x-search-auto' : '_searchAuto', - 'click .x-search-manual' : '_searchManual', - 'click .x-search-back' : '_showButtons' - }, - - initialize : function() { - this.mainView = new ButtonsView(); - this.releaseCollection = new ReleaseCollection(); - - this.listenTo(this.releaseCollection, 'sync', this._showSearchResults); - }, - - onShow : function() { - if (this.startManualSearch) { - this._searchManual(); - } - - else { - this._showMainView(); - } - }, - - _searchAuto : function(e) { - if (e) { - e.preventDefault(); - } - - CommandController.Execute('episodeSearch', { - episodeIds : [this.model.get('id')] - }); - - vent.trigger(vent.Commands.CloseModalCommand); - }, - - _searchManual : function(e) { - if (e) { - e.preventDefault(); - } - - this.mainView = new LoadingView(); - this._showMainView(); - this.releaseCollection.fetchEpisodeReleases(this.model.id); - }, - - _showMainView : function() { - this.main.show(this.mainView); - }, - - _showButtons : function() { - this.mainView = new ButtonsView(); - this._showMainView(); - }, - - _showSearchResults : function() { - if (this.releaseCollection.length === 0) { - this.mainView = new NoResultsView(); - } - - else { - this.mainView = new ManualSearchLayout({ collection : this.releaseCollection }); - } - - this._showMainView(); - } -}); \ No newline at end of file diff --git a/src/UI/Episode/Search/EpisodeSearchLayoutTemplate.hbs b/src/UI/Episode/Search/EpisodeSearchLayoutTemplate.hbs deleted file mode 100644 index 879e0b356..000000000 --- a/src/UI/Episode/Search/EpisodeSearchLayoutTemplate.hbs +++ /dev/null @@ -1 +0,0 @@ -<div id="episode-search-region"></div> \ No newline at end of file diff --git a/src/UI/Episode/Search/ManualLayout.js b/src/UI/Episode/Search/ManualLayout.js deleted file mode 100644 index 58c792063..000000000 --- a/src/UI/Episode/Search/ManualLayout.js +++ /dev/null @@ -1,86 +0,0 @@ -var Marionette = require('marionette'); -var Backgrid = require('backgrid'); -var ReleaseTitleCell = require('../../Cells/ReleaseTitleCell'); -var FileSizeCell = require('../../Cells/FileSizeCell'); -var QualityCell = require('../../Cells/QualityCell'); -var ApprovalStatusCell = require('../../Cells/ApprovalStatusCell'); -var DownloadReportCell = require('../../Release/DownloadReportCell'); -var AgeCell = require('../../Release/AgeCell'); -var ProtocolCell = require('../../Release/ProtocolCell'); -var PeersCell = require('../../Release/PeersCell'); - -module.exports = Marionette.Layout.extend({ - template : 'Episode/Search/ManualLayoutTemplate', - - regions : { - grid : '#episode-release-grid' - }, - - columns : [ - { - name : 'protocol', - label : 'Source', - cell : ProtocolCell - }, - { - name : 'age', - label : 'Age', - cell : AgeCell - }, - { - name : 'title', - label : 'Title', - cell : ReleaseTitleCell - }, - { - name : 'indexer', - label : 'Indexer', - cell : Backgrid.StringCell - }, - { - name : 'size', - label : 'Size', - cell : FileSizeCell - }, - { - name : 'seeders', - label : 'Peers', - cell : PeersCell - }, - { - name : 'quality', - label : 'Quality', - cell : QualityCell - }, - { - name : 'rejections', - label : '<i class="icon-sonarr-header-rejections" />', - tooltip : 'Rejections', - cell : ApprovalStatusCell, - sortable : true, - sortType : 'fixed', - direction : 'ascending', - title : 'Release Rejected' - }, - { - name : 'download', - label : '<i class="icon-sonarr-download" />', - tooltip : 'Auto-Search Prioritization', - cell : DownloadReportCell, - sortable : true, - sortType : 'fixed', - direction : 'ascending' - } - ], - - onShow : function() { - if (!this.isClosed) { - this.grid.show(new Backgrid.Grid({ - row : Backgrid.Row, - columns : this.columns, - collection : this.collection, - className : 'table table-hover' - })); - } - } -}); \ No newline at end of file diff --git a/src/UI/Episode/Search/ManualLayoutTemplate.hbs b/src/UI/Episode/Search/ManualLayoutTemplate.hbs deleted file mode 100644 index 1797eb289..000000000 --- a/src/UI/Episode/Search/ManualLayoutTemplate.hbs +++ /dev/null @@ -1,2 +0,0 @@ -<div id="episode-release-grid" class="table-responsive"></div> -<button class="btn x-search-back">Back</button> \ No newline at end of file diff --git a/src/UI/Episode/Search/NoResultsView.js b/src/UI/Episode/Search/NoResultsView.js deleted file mode 100644 index a1a68c4fa..000000000 --- a/src/UI/Episode/Search/NoResultsView.js +++ /dev/null @@ -1,5 +0,0 @@ -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'Episode/Search/NoResultsViewTemplate' -}); \ No newline at end of file diff --git a/src/UI/Episode/Search/NoResultsViewTemplate.hbs b/src/UI/Episode/Search/NoResultsViewTemplate.hbs deleted file mode 100644 index 7904e5520..000000000 --- a/src/UI/Episode/Search/NoResultsViewTemplate.hbs +++ /dev/null @@ -1 +0,0 @@ -<div>No results found</div> \ No newline at end of file diff --git a/src/UI/Episode/Summary/EpisodeSummaryLayout.js b/src/UI/Episode/Summary/EpisodeSummaryLayout.js deleted file mode 100644 index 29eaad626..000000000 --- a/src/UI/Episode/Summary/EpisodeSummaryLayout.js +++ /dev/null @@ -1,119 +0,0 @@ -var reqres = require('../../reqres'); -var Marionette = require('marionette'); -var Backgrid = require('backgrid'); -var EpisodeFileModel = require('../../Series/EpisodeFileModel'); -var EpisodeFileCollection = require('../../Series/EpisodeFileCollection'); -var FileSizeCell = require('../../Cells/FileSizeCell'); -var QualityCell = require('../../Cells/QualityCell'); -var DeleteEpisodeFileCell = require('../../Cells/DeleteEpisodeFileCell'); -var NoFileView = require('./NoFileView'); -var LoadingView = require('../../Shared/LoadingView'); - -module.exports = Marionette.Layout.extend({ - template : 'Episode/Summary/EpisodeSummaryLayoutTemplate', - - regions : { - overview : '.episode-overview', - activity : '.episode-file-info' - }, - - columns : [ - { - name : 'path', - label : 'Path', - cell : 'string', - sortable : false - }, - { - name : 'size', - label : 'Size', - cell : FileSizeCell, - sortable : false - }, - { - name : 'quality', - label : 'Quality', - cell : QualityCell, - sortable : false, - editable : true - }, - { - name : 'this', - label : '', - cell : DeleteEpisodeFileCell, - sortable : false - } - ], - - templateHelpers : {}, - - initialize : function(options) { - if (!this.model.series) { - this.templateHelpers.series = options.series.toJSON(); - } - }, - - onShow : function() { - if (this.model.get('hasFile')) { - var episodeFileId = this.model.get('episodeFileId'); - - if (reqres.hasHandler(reqres.Requests.GetEpisodeFileById)) { - var episodeFile = reqres.request(reqres.Requests.GetEpisodeFileById, episodeFileId); - this.episodeFileCollection = new EpisodeFileCollection(episodeFile, { seriesId : this.model.get('seriesId') }); - this.listenTo(episodeFile, 'destroy', this._episodeFileDeleted); - - this._showTable(); - } - - else { - this.activity.show(new LoadingView()); - - var self = this; - var newEpisodeFile = new EpisodeFileModel({ id : episodeFileId }); - this.episodeFileCollection = new EpisodeFileCollection(newEpisodeFile, { seriesId : this.model.get('seriesId') }); - var promise = newEpisodeFile.fetch(); - this.listenTo(newEpisodeFile, 'destroy', this._episodeFileDeleted); - - promise.done(function() { - self._showTable(); - }); - } - - this.listenTo(this.episodeFileCollection, 'add remove', this._collectionChanged); - } - - else { - this._showNoFileView(); - } - }, - - _showTable : function() { - this.activity.show(new Backgrid.Grid({ - collection : this.episodeFileCollection, - columns : this.columns, - className : 'table table-bordered', - emptyText : 'Nothing to see here!' - })); - }, - - _showNoFileView : function() { - this.activity.show(new NoFileView()); - }, - - _collectionChanged : function() { - if (!this.episodeFileCollection.any()) { - this._showNoFileView(); - } - - else { - this._showTable(); - } - }, - - _episodeFileDeleted : function() { - this.model.set({ - episodeFileId : 0, - hasFile : false - }); - } -}); \ No newline at end of file diff --git a/src/UI/Episode/Summary/EpisodeSummaryLayoutTemplate.hbs b/src/UI/Episode/Summary/EpisodeSummaryLayoutTemplate.hbs deleted file mode 100644 index 9cfeca2da..000000000 --- a/src/UI/Episode/Summary/EpisodeSummaryLayoutTemplate.hbs +++ /dev/null @@ -1,14 +0,0 @@ -<div class="episode-info"> - {{#with series}} - {{profile profileId}} - <span class="label label-info">{{network}}</span> - {{/with}} - <span class="label label-info">{{StartTime airDateUtc}}</span> - <span class="label label-info">{{RelativeDate airDateUtc}}</span> -</div> - -<div class="episode-overview"> - {{overview}} -</div> - -<div class="episode-file-info"></div> diff --git a/src/UI/Episode/Summary/NoFileView.js b/src/UI/Episode/Summary/NoFileView.js deleted file mode 100644 index 07aabc810..000000000 --- a/src/UI/Episode/Summary/NoFileView.js +++ /dev/null @@ -1,5 +0,0 @@ -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'Episode/Summary/NoFileViewTemplate' -}); \ No newline at end of file diff --git a/src/UI/Episode/Summary/NoFileViewTemplate.hbs b/src/UI/Episode/Summary/NoFileViewTemplate.hbs deleted file mode 100644 index 0f923737d..000000000 --- a/src/UI/Episode/Summary/NoFileViewTemplate.hbs +++ /dev/null @@ -1,3 +0,0 @@ -<p class="text-warning"> - No file available for this episode. -</p> \ No newline at end of file diff --git a/src/UI/EpisodeFile/Editor/EmptyView.js b/src/UI/EpisodeFile/Editor/EmptyView.js deleted file mode 100644 index e84453524..000000000 --- a/src/UI/EpisodeFile/Editor/EmptyView.js +++ /dev/null @@ -1,5 +0,0 @@ -var Marionette = require('marionette'); - -module.exports = Marionette.CompositeView.extend({ - template : 'EpisodeFile/Editor/EmptyViewTemplate' -}); \ No newline at end of file diff --git a/src/UI/EpisodeFile/Editor/EmptyViewTemplate.hbs b/src/UI/EpisodeFile/Editor/EmptyViewTemplate.hbs deleted file mode 100644 index 0a51692de..000000000 --- a/src/UI/EpisodeFile/Editor/EmptyViewTemplate.hbs +++ /dev/null @@ -1,5 +0,0 @@ -<div class="row"> - <div class="col-md-12"> - No episode files - </div> -</div> diff --git a/src/UI/EpisodeFile/Editor/EpisodeFileEditorLayout.js b/src/UI/EpisodeFile/Editor/EpisodeFileEditorLayout.js deleted file mode 100644 index a974c8f7c..000000000 --- a/src/UI/EpisodeFile/Editor/EpisodeFileEditorLayout.js +++ /dev/null @@ -1,200 +0,0 @@ -var _ = require('underscore'); -var reqres = require('../../reqres'); -var Marionette = require('marionette'); -var Backgrid = require('backgrid'); -var FormatHelpers = require('../../Shared/FormatHelpers'); -var SelectAllCell = require('../../Cells/SelectAllCell'); -var EpisodeNumberCell = require('../../Series/Details/EpisodeNumberCell'); -var SeasonEpisodeNumberCell = require('../../Cells/EpisodeNumberCell'); -var EpisodeFilePathCell = require('../../Cells/EpisodeFilePathCell'); -var EpisodeStatusCell = require('../../Cells/EpisodeStatusCell'); -var RelativeDateCell = require('../../Cells/RelativeDateCell'); -var EpisodeCollection = require('../../Series/EpisodeCollection'); -var ProfileSchemaCollection = require('../../Settings/Profile/ProfileSchemaCollection'); -var QualitySelectView = require('./QualitySelectView'); -var EmptyView = require('./EmptyView'); - -module.exports = Marionette.Layout.extend({ - className : 'modal-lg', - template : 'EpisodeFile/Editor/EpisodeFileEditorLayoutTemplate', - - regions : { - episodeGrid : '.x-episode-list', - quality : '.x-quality' - }, - - ui : { - seasonMonitored : '.x-season-monitored' - }, - - events : { - 'click .x-season-monitored' : '_seasonMonitored', - 'click .x-delete-files' : '_deleteFiles' - }, - - initialize : function(options) { - if (!options.series) { - throw 'series is required'; - } - - if (!options.episodeCollection) { - throw 'episodeCollection is required'; - } - - var filtered = options.episodeCollection.filter(function(episode) { - return episode.get('episodeFileId') > 0; - }); - - this.series = options.series; - this.episodeCollection = options.episodeCollection; - this.filteredEpisodes = new EpisodeCollection(filtered); - - this.templateHelpers = {}; - this.templateHelpers.series = this.series.toJSON(); - - this._getColumns(); - }, - - onRender : function() { - this._getQualities(); - this._showEpisodes(); - }, - - _getColumns : function () { - var episodeCell = {}; - - if (this.model) { - episodeCell.name = 'episodeNumber'; - episodeCell.label = '#'; - episodeCell.cell = EpisodeNumberCell; - } - - else { - episodeCell.name = 'seasonEpisode'; - episodeCell.cellValue = 'this'; - episodeCell.label = 'Episode'; - episodeCell.cell = SeasonEpisodeNumberCell; - episodeCell.sortValue = this._seasonEpisodeSorter; - } - - this.columns = [ - { - name : '', - cell : SelectAllCell, - headerCell : 'select-all', - sortable : false - }, - episodeCell, - { - name : 'episodeNumber', - label : 'Relative Path', - cell : EpisodeFilePathCell, - sortable : false - }, - { - name : 'airDateUtc', - label : 'Air Date', - cell : RelativeDateCell - }, - { - name : 'status', - label : 'Quality', - cell : EpisodeStatusCell, - sortable : false - } - ]; - }, - - _showEpisodes : function() { - if (this.filteredEpisodes.length === 0) { - this.episodeGrid.show(new EmptyView()); - return; - } - - this._setInitialSort(); - - this.episodeGridView = new Backgrid.Grid({ - columns : this.columns, - collection : this.filteredEpisodes, - className : 'table table-hover season-grid' - }); - - this.episodeGrid.show(this.episodeGridView); - }, - - _setInitialSort : function () { - if (!this.model) { - this.filteredEpisodes.setSorting('seasonEpisode', 1, { sortValue: this._seasonEpisodeSorter }); - this.filteredEpisodes.fullCollection.sort(); - } - }, - - _getQualities : function() { - var self = this; - - var profileSchemaCollection = new ProfileSchemaCollection(); - var promise = profileSchemaCollection.fetch(); - - promise.done(function() { - var profile = profileSchemaCollection.first(); - - self.qualitySelectView = new QualitySelectView({ qualities: _.map(profile.get('items'), 'quality') }); - self.listenTo(self.qualitySelectView, 'seasonedit:quality', self._changeQuality); - - self.quality.show(self.qualitySelectView); - }); - }, - - _changeQuality : function(options) { - var newQuality = { - quality : options.selected, - revision : { - version : 1, - real : 0 - } - }; - - var selected = this._getSelectedEpisodeFileIds(); - - _.each(selected, function(episodeFileId) { - if (reqres.hasHandler(reqres.Requests.GetEpisodeFileById)) { - var episodeFile = reqres.request(reqres.Requests.GetEpisodeFileById, episodeFileId); - episodeFile.set('quality', newQuality); - episodeFile.save(); - } - }); - }, - - _deleteFiles : function() { - if (!window.confirm('Are you sure you want to delete the episode files for the selected episodes?')) { - return; - } - - var selected = this._getSelectedEpisodeFileIds(); - - _.each(selected, function(episodeFileId) { - if (reqres.hasHandler(reqres.Requests.GetEpisodeFileById)) { - var episodeFile = reqres.request(reqres.Requests.GetEpisodeFileById, episodeFileId); - - episodeFile.destroy(); - } - }); - - _.each(this.episodeGridView.getSelectedModels(), function(episode) { - this.episodeGridView.removeRow(episode); - }, this); - }, - - _getSelectedEpisodeFileIds: function () { - return _.uniq(_.map(this.episodeGridView.getSelectedModels(), function (episode) { - return episode.get('episodeFileId'); - })); - }, - - _seasonEpisodeSorter : function (model, attr) { - var seasonNumber = FormatHelpers.pad(model.get('seasonNumber'), 4, 0); - var episodeNumber = FormatHelpers.pad(model.get('episodeNumber'), 4, 0); - - return seasonNumber + episodeNumber; - } -}); diff --git a/src/UI/EpisodeFile/Editor/EpisodeFileEditorLayoutTemplate.hbs b/src/UI/EpisodeFile/Editor/EpisodeFileEditorLayoutTemplate.hbs deleted file mode 100644 index 6f7e84109..000000000 --- a/src/UI/EpisodeFile/Editor/EpisodeFileEditorLayoutTemplate.hbs +++ /dev/null @@ -1,28 +0,0 @@ -<div class="modal-content"> - <div class="edit-season-modal"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - - <h3> - {{#if seasonNumber}} - {{#if_eq seasonNumber compare="0"}} - {{series.title}} - Specials - {{else}} - {{series.title}} - Season {{seasonNumber}} - {{/if_eq}} - {{else}} - {{series.title}} - {{/if}} - </h3> - - </div> - <div class="modal-body"> - <div class="x-episode-list"></div> - <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-default" data-dismiss="modal">Close</button> - </div> - </div> -</div> diff --git a/src/UI/EpisodeFile/Editor/QualitySelectView.js b/src/UI/EpisodeFile/Editor/QualitySelectView.js deleted file mode 100644 index beac4f304..000000000 --- a/src/UI/EpisodeFile/Editor/QualitySelectView.js +++ /dev/null @@ -1,35 +0,0 @@ -var _ = require('underscore'); -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'EpisodeFile/Editor/QualitySelectViewTemplate', - - ui : { - select : '.x-select' - }, - - events : { - 'change .x-select' : '_changeSelect' - }, - - initialize : function (options) { - this.qualities = options.qualities; - - this.templateHelpers = { - qualities : this.qualities - }; - }, - - _changeSelect : function () { - var value = this.ui.select.val(); - - if (value === 'choose') { - return; - } - - var quality = _.find(this.qualities, { 'id': parseInt(value) }); - - this.trigger('seasonedit:quality', { selected : quality }); - this.ui.select.val('choose'); - } -}); \ No newline at end of file diff --git a/src/UI/EpisodeFile/Editor/QualitySelectViewTemplate.hbs b/src/UI/EpisodeFile/Editor/QualitySelectViewTemplate.hbs deleted file mode 100644 index 4ab83c931..000000000 --- a/src/UI/EpisodeFile/Editor/QualitySelectViewTemplate.hbs +++ /dev/null @@ -1,10 +0,0 @@ -<div class="row"> - <div class="form-group col-md-3 col-md-offset-9"> - <select class="form-control x-select"> - <option value="choose">Select quality</option> - {{#eachReverse qualities}} - <option value="{{id}}">{{name}}</option> - {{/eachReverse}} - </select> - </div> -</div> diff --git a/src/UI/Form/ActionTemplate.hbs b/src/UI/Form/ActionTemplate.hbs deleted file mode 100644 index ecb861f99..000000000 --- a/src/UI/Form/ActionTemplate.hbs +++ /dev/null @@ -1,7 +0,0 @@ -<div class="form-group {{#if advanced}}advanced-setting{{/if}}"> - <label class="col-sm-3 control-label"></label> - - <div class="col-sm-5"> - <button class="form-control {{name}}" validation-name="{{name}}" data-value="{{value}}">{{label}}</button> - </div> -</div> diff --git a/src/UI/Form/CaptchaTemplate.hbs b/src/UI/Form/CaptchaTemplate.hbs deleted file mode 100644 index 12e472df0..000000000 --- a/src/UI/Form/CaptchaTemplate.hbs +++ /dev/null @@ -1,15 +0,0 @@ -<div class="form-group {{#if advanced}}advanced-setting{{/if}}"> - <label class="col-sm-3 control-label">{{label}}</label> - - <div class="col-sm-5"> - <div class="input-group"> - <input type="text" name="fields.{{order}}.value" validation-name="{{name}}" spellcheck="false" class="form-control x-captcha" readonly placeholder="(optional)" /> - <span class="input-group-btn"><button class="btn btn-primary x-captcha-refresh" title="Refresh CAPTCHA Token"><i class="icon-sonarr-refresh" /></button></span> - </div> - </div> - - <span class="col-sm-1 help-inline"> - <i class="icon-sonarr-form-warning" title="Expires periodically and will need to be refreshed."/> - <i class="icon-sonarr-form-warning" title="Refreshing the CAPTCHA Token will embed a temporary Google reCaptcha widget on this page."/> - </span> -</div> diff --git a/src/UI/Form/CheckboxTemplate.hbs b/src/UI/Form/CheckboxTemplate.hbs deleted file mode 100644 index d3803ab70..000000000 --- a/src/UI/Form/CheckboxTemplate.hbs +++ /dev/null @@ -1,23 +0,0 @@ -<div class="form-group {{#if advanced}}advanced-setting{{/if}}"> - <label class="col-sm-3 control-label">{{label}}</label> - - <div class="col-sm-5"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="fields.{{order}}.value"/> - <p> - <span>Yes</span> - <span>No</span> - </p> - - <div class="btn btn-primary slide-button"/> - </label> - - {{#if helpText}} - <span class="help-inline-checkbox"> - <i class="icon-sonarr-form-info" title="{{helpText}}"/> - </span> - {{/if}} - </div> - </div> -</div> diff --git a/src/UI/Form/FormBuilder.js b/src/UI/Form/FormBuilder.js deleted file mode 100644 index eef48eb91..000000000 --- a/src/UI/Form/FormBuilder.js +++ /dev/null @@ -1,66 +0,0 @@ -var Marionette = require('marionette'); -var Handlebars = require('handlebars'); -var _ = require('underscore'); -require('./FormMessage'); - -var _templateRenderer = function(templateName) { - var templateFunction = Marionette.TemplateCache.get(templateName); - return new Handlebars.SafeString(templateFunction(this)); -}; - -var _fieldBuilder = function(field) { - if (!field.type) { - return _templateRenderer.call(field, 'Form/TextboxTemplate'); - } - - if (field.type === 'hidden') { - return _templateRenderer.call(field, 'Form/HiddenTemplate'); - } - - if (field.type === 'url') { - return _templateRenderer.call(field, 'Form/UrlTemplate'); - } - - if (field.type === 'password') { - return _templateRenderer.call(field, 'Form/PasswordTemplate'); - } - - if (field.type === 'checkbox') { - return _templateRenderer.call(field, 'Form/CheckboxTemplate'); - } - - if (field.type === 'select') { - return _templateRenderer.call(field, 'Form/SelectTemplate'); - } - - if (field.type === 'hidden') { - return _templateRenderer.call(field, 'Form/HiddenTemplate'); - } - - if (field.type === 'path' || field.type === 'filepath') { - return _templateRenderer.call(field, 'Form/PathTemplate'); - } - - if (field.type === 'tag') { - return _templateRenderer.call(field, 'Form/TagTemplate'); - } - - if (field.type === 'action') { - return _templateRenderer.call(field, 'Form/ActionTemplate'); - } - - if (field.type === 'captcha') { - return _templateRenderer.call(field, 'Form/CaptchaTemplate'); - } - - return _templateRenderer.call(field, 'Form/TextboxTemplate'); -}; - -Handlebars.registerHelper('formBuilder', function() { - var ret = ''; - _.each(this.fields, function(field) { - ret += _fieldBuilder(field); - }); - - return new Handlebars.SafeString(ret); -}); diff --git a/src/UI/Form/FormHelpPartial.hbs b/src/UI/Form/FormHelpPartial.hbs deleted file mode 100644 index b698ccdea..000000000 --- a/src/UI/Form/FormHelpPartial.hbs +++ /dev/null @@ -1,8 +0,0 @@ -<span class="col-sm-1 help-inline"> - {{#if helpText}} - <i class="icon-sonarr-form-info" title="{{helpText}}"/> - {{/if}} - {{#if helpLink}} - <a href="{{helpLink}}" class="help-link"><i class="icon-sonarr-form-info-link"/></a> - {{/if}} -</span> diff --git a/src/UI/Form/FormMessage.js b/src/UI/Form/FormMessage.js deleted file mode 100644 index 209bccd42..000000000 --- a/src/UI/Form/FormMessage.js +++ /dev/null @@ -1,17 +0,0 @@ -var Handlebars = require('handlebars'); - -Handlebars.registerHelper('formMessage', function(message) { - if (!message) { - return ''; - } - - var level = message.type; - - if (message.type === 'error') { - level = 'danger'; - } - - var messageHtml = '<div class="alert alert-{0}" role="alert">{1}</div>'.format(level, message.message); - - return new Handlebars.SafeString(messageHtml); -}); \ No newline at end of file diff --git a/src/UI/Form/HiddenTemplate.hbs b/src/UI/Form/HiddenTemplate.hbs deleted file mode 100644 index 03933b122..000000000 --- a/src/UI/Form/HiddenTemplate.hbs +++ /dev/null @@ -1 +0,0 @@ -<input type="hidden" name="fields.{{order}}.value" validation-name="{{name}}" spellcheck="false"/> \ No newline at end of file diff --git a/src/UI/Form/PasswordTemplate.hbs b/src/UI/Form/PasswordTemplate.hbs deleted file mode 100644 index 3a96cab7f..000000000 --- a/src/UI/Form/PasswordTemplate.hbs +++ /dev/null @@ -1,8 +0,0 @@ -<div class="form-group {{#if advanced}}advanced-setting{{/if}}"> - <label class="col-sm-3 control-label">{{label}}</label> - - <div class="col-sm-5"> - <input type="password" name="fields.{{order}}.value" validation-name="{{name}}" autocomplete="new-password" class="form-control"/> - </div> - {{> FormHelpPartial}} -</div> diff --git a/src/UI/Form/PathTemplate.hbs b/src/UI/Form/PathTemplate.hbs deleted file mode 100644 index 5a95305e1..000000000 --- a/src/UI/Form/PathTemplate.hbs +++ /dev/null @@ -1,8 +0,0 @@ -<div class="form-group {{#if advanced}}advanced-setting{{/if}}"> - <label class="col-sm-3 control-label">{{label}}</label> - - <div class="col-sm-5"> - <input type="text" name="fields.{{order}}.value" validation-name="{{name}}" class="form-control x-path {{#if_eq type compare="filepath"}}x-filepath{{/if_eq}}"/> - </div> - {{> FormHelpPartial}} -</div> diff --git a/src/UI/Form/SelectTemplate.hbs b/src/UI/Form/SelectTemplate.hbs deleted file mode 100644 index 978d432df..000000000 --- a/src/UI/Form/SelectTemplate.hbs +++ /dev/null @@ -1,12 +0,0 @@ -<div class="form-group {{#if advanced}}advanced-setting{{/if}}"> - <label class="col-sm-3 control-label">{{label}}</label> - - <div class="col-sm-5"> - <select name="fields.{{order}}.value" class="form-control"> - {{#each selectOptions}} - <option value="{{value}}">{{name}}</option> - {{/each}} - </select> - </div> - {{> FormHelpPartial}} -</div> diff --git a/src/UI/Form/TagTemplate.hbs b/src/UI/Form/TagTemplate.hbs deleted file mode 100644 index 4df3ca6ba..000000000 --- a/src/UI/Form/TagTemplate.hbs +++ /dev/null @@ -1,9 +0,0 @@ -<div class="form-group {{#if advanced}}advanced-setting{{/if}}"> - <label class="col-sm-3 control-label">{{label}}</label> - - <div class="col-sm-5"> - <input type="text" name="fields.{{order}}.value" validation-name="{{name}}" class="form-control x-form-tag"/> - </div> - - {{> FormHelpPartial}} -</div> \ No newline at end of file diff --git a/src/UI/Form/TextboxTemplate.hbs b/src/UI/Form/TextboxTemplate.hbs deleted file mode 100644 index e7054cfac..000000000 --- a/src/UI/Form/TextboxTemplate.hbs +++ /dev/null @@ -1,8 +0,0 @@ -<div class="form-group {{#if advanced}}advanced-setting{{/if}}"> - <label class="col-sm-3 control-label">{{label}}</label> - - <div class="col-sm-5"> - <input type="text" name="fields.{{order}}.value" validation-name="{{name}}" spellcheck="false" class="form-control"/> - </div> - {{> FormHelpPartial}} -</div> diff --git a/src/UI/Form/UrlTemplate.hbs b/src/UI/Form/UrlTemplate.hbs deleted file mode 100644 index 7f41272f1..000000000 --- a/src/UI/Form/UrlTemplate.hbs +++ /dev/null @@ -1,8 +0,0 @@ -<div class="form-group {{#if advanced}}advanced-setting{{/if}}"> - <label class="col-sm-3 control-label">{{label}}</label> - - <div class="col-sm-5"> - <input type="url" name="fields.{{order}}.value" validation-name="{{name}}" spellcheck="false" class="form-control"/> - </div> - {{> FormHelpPartial}} -</div> diff --git a/src/UI/Handlebars/Handlebars.Debug.js b/src/UI/Handlebars/Handlebars.Debug.js deleted file mode 100644 index 84360f665..000000000 --- a/src/UI/Handlebars/Handlebars.Debug.js +++ /dev/null @@ -1,7 +0,0 @@ -var Handlebars = require('handlebars'); - -Handlebars.registerHelper('debug', function() { - console.group('Handlebar context'); - console.log(this); - console.groupEnd(); -}); \ No newline at end of file diff --git a/src/UI/Handlebars/Helpers/DateTime.js b/src/UI/Handlebars/Helpers/DateTime.js deleted file mode 100644 index 18a5f7cbc..000000000 --- a/src/UI/Handlebars/Helpers/DateTime.js +++ /dev/null @@ -1,90 +0,0 @@ -var Handlebars = require('handlebars'); -var moment = require('moment'); -var FormatHelpers = require('../../Shared/FormatHelpers'); -var UiSettings = require('../../Shared/UiSettingsModel'); - -Handlebars.registerHelper('ShortDate', function(input) { - if (!input) { - return ''; - } - - var date = moment(input); - var result = '<span title="' + date.format(UiSettings.longDateTime()) + '">' + date.format(UiSettings.get('shortDateFormat')) + '</span>'; - - return new Handlebars.SafeString(result); -}); - -Handlebars.registerHelper('RelativeDate', function(input) { - if (!input) { - return ''; - } - - var date = moment(input); - var result = '<span title="{0}">{1}</span>'; - var tooltip = date.format(UiSettings.longDateTime()); - var text; - - if (UiSettings.get('showRelativeDates')) { - text = FormatHelpers.relativeDate(input); - } else { - text = date.format(UiSettings.get('shortDateFormat')); - } - - result = result.format(tooltip, text); - - return new Handlebars.SafeString(result); -}); - -Handlebars.registerHelper('Day', function(input) { - if (!input) { - return ''; - } - - return moment(input).format('DD'); -}); - -Handlebars.registerHelper('Month', function(input) { - if (!input) { - return ''; - } - - return moment(input).format('MMM'); -}); - -Handlebars.registerHelper('StartTime', function(input) { - if (!input) { - return ''; - } - - return moment(input).format(UiSettings.time(false, false)); -}); - -Handlebars.registerHelper('LTS', function(input) { - if (!input) { - return ''; - } - - return moment(input).format(UiSettings.time(true, true)); -}); - -Handlebars.registerHelper('if_today', function(context, options) { - var date = moment(context).startOf('day'); - var today = moment().startOf('day'); - - if (date.isSame(today)) { - return options.fn(this); - } - - return options.inverse(this); -}); - -Handlebars.registerHelper('unless_today', function(context, options) { - var date = moment(context).startOf('day'); - var today = moment().startOf('day'); - - if (date.isSame(today)) { - return options.inverse(this); - } - - return options.fn(this); -}); \ No newline at end of file diff --git a/src/UI/Handlebars/Helpers/EachReverse.js b/src/UI/Handlebars/Helpers/EachReverse.js deleted file mode 100644 index 7e5e0983a..000000000 --- a/src/UI/Handlebars/Helpers/EachReverse.js +++ /dev/null @@ -1,16 +0,0 @@ -var Handlebars = require('handlebars'); - -Handlebars.registerHelper('eachReverse', function(context) { - var options = arguments[arguments.length - 1]; - var ret = ''; - - if (context && context.length > 0) { - for (var i = context.length - 1; i >= 0; i--) { - ret += options.fn(context[i]); - } - } else { - ret = options.inverse(this); - } - - return ret; -}); \ No newline at end of file diff --git a/src/UI/Handlebars/Helpers/Enumerable.js b/src/UI/Handlebars/Helpers/Enumerable.js deleted file mode 100644 index ae7f8708d..000000000 --- a/src/UI/Handlebars/Helpers/Enumerable.js +++ /dev/null @@ -1,21 +0,0 @@ -var Handlebars = require('handlebars'); - -Handlebars.registerHelper('times', function(n, block) { - var accum = ''; - - for (var i = 0; i < n; ++i) { - accum += block.fn(i); - } - - return accum; -}); - -Handlebars.registerHelper('for', function(from, to, incr, block) { - var accum = ''; - - for (var i = from; i < to; i += incr) { - accum += block.fn(i); - } - - return accum; -}); \ No newline at end of file diff --git a/src/UI/Handlebars/Helpers/Episode.js b/src/UI/Handlebars/Helpers/Episode.js deleted file mode 100644 index 154236489..000000000 --- a/src/UI/Handlebars/Helpers/Episode.js +++ /dev/null @@ -1,66 +0,0 @@ -var Handlebars = require('handlebars'); -var FormatHelpers = require('../../Shared/FormatHelpers'); -var moment = require('moment'); -require('../../Activity/Queue/QueueCollection'); - -Handlebars.registerHelper('EpisodeNumber', function() { - - if (this.series.seriesType === 'daily') { - return moment(this.airDate).format('L'); - } else if (this.series.seriesType === 'anime' && this.absoluteEpisodeNumber !== undefined) { - return '{0}x{1} ({2})'.format(this.seasonNumber, FormatHelpers.pad(this.episodeNumber, 2), FormatHelpers.pad(this.absoluteEpisodeNumber, 2)); - } else { - return '{0}x{1}'.format(this.seasonNumber, FormatHelpers.pad(this.episodeNumber, 2)); - } -}); - -Handlebars.registerHelper('StatusLevel', function() { - var hasFile = this.hasFile; - var downloading = require('../../Activity/Queue/QueueCollection').findEpisode(this.id) || this.downloading; - var currentTime = moment(); - var start = moment(this.airDateUtc); - var end = moment(this.end); - var monitored = this.series.monitored && this.monitored; - - if (hasFile) { - return 'success'; - } - - if (downloading) { - return 'purple'; - } - - else if (!monitored) { - return 'unmonitored'; - } - - if (this.episodeNumber === 1) { - return 'premiere'; - } - - if (currentTime.isAfter(start) && currentTime.isBefore(end)) { - return 'warning'; - } - - if (start.isBefore(currentTime) && !hasFile) { - return 'danger'; - } - - return 'primary'; -}); - -Handlebars.registerHelper('EpisodeProgressClass', function() { - if (this.episodeFileCount === this.episodeCount) { - if (this.status === 'continuing') { - return ''; - } - - return 'progress-bar-success'; - } - - if (this.monitored) { - return 'progress-bar-danger'; - } - - return 'progress-bar-warning'; -}); \ No newline at end of file diff --git a/src/UI/Handlebars/Helpers/Html.js b/src/UI/Handlebars/Helpers/Html.js deleted file mode 100644 index 962ccf10d..000000000 --- a/src/UI/Handlebars/Helpers/Html.js +++ /dev/null @@ -1,40 +0,0 @@ -var $ = require('jquery'); -var Handlebars = require('handlebars'); -var StatusModel = require('../../System/StatusModel'); - -var placeholder = StatusModel.get('urlBase') + '/Content/Images/poster-dark.png'; - -window.NzbDrone.imageError = function(img) { - if (!img.src.contains(placeholder)) { - img.src = placeholder; - img.srcset = ""; - $(img).addClass('placeholder-image'); - } - - img.onerror = null; -}; - -Handlebars.registerHelper('defaultImg', function(src, size) { - var endOfPath = /\.jpg($|\?)/g; - var errorAttr = 'onerror="window.NzbDrone.imageError(this);"'; - var srcsetAttr = ''; - var oneX = src, twoX; - - if (!src) { - return new Handlebars.SafeString(errorAttr); - } - - if (size) { - oneX = src.replace(endOfPath, '-' + size + '.jpg$1'); - twoX = src.replace(endOfPath, '-' + size * 2 + '.jpg$1'); - srcsetAttr = 'srcset="{0} 1x, {1} 2x"'.format(oneX, twoX); - } - - return new Handlebars.SafeString( - 'src="{0}" {1} {2}'.format(oneX, srcsetAttr, errorAttr) - ); -}); - -Handlebars.registerHelper('UrlBase', function() { - return new Handlebars.SafeString(StatusModel.get('urlBase')); -}); \ No newline at end of file diff --git a/src/UI/Handlebars/Helpers/Numbers.js b/src/UI/Handlebars/Helpers/Numbers.js deleted file mode 100644 index 19d7c63ab..000000000 --- a/src/UI/Handlebars/Helpers/Numbers.js +++ /dev/null @@ -1,14 +0,0 @@ -var Handlebars = require('handlebars'); -var FormatHelpers = require('../../Shared/FormatHelpers'); - -Handlebars.registerHelper('Bytes', function(size) { - return new Handlebars.SafeString(FormatHelpers.bytes(size)); -}); - -Handlebars.registerHelper('Pad2', function(input) { - return FormatHelpers.pad(input, 2); -}); - -Handlebars.registerHelper('Number', function(input) { - return FormatHelpers.number(input); -}); diff --git a/src/UI/Handlebars/Helpers/Quality.js b/src/UI/Handlebars/Helpers/Quality.js deleted file mode 100644 index 96b9c840f..000000000 --- a/src/UI/Handlebars/Helpers/Quality.js +++ /dev/null @@ -1,12 +0,0 @@ -var Handlebars = require('handlebars'); -var ProfileCollection = require('../../Profile/ProfileCollection'); - -Handlebars.registerHelper('profile', function(profileId) { - var profile = ProfileCollection.get(profileId); - - if (profile) { - return new Handlebars.SafeString('<span class="label label-default profile-label">' + profile.get('name') + '</span>'); - } - - return undefined; -}); \ No newline at end of file diff --git a/src/UI/Handlebars/Helpers/Series.js b/src/UI/Handlebars/Helpers/Series.js deleted file mode 100644 index 2c8a96bed..000000000 --- a/src/UI/Handlebars/Helpers/Series.js +++ /dev/null @@ -1,84 +0,0 @@ -var Handlebars = require('handlebars'); -var StatusModel = require('../../System/StatusModel'); -var _ = require('underscore'); - -Handlebars.registerHelper('poster', function() { - - var placeholder = StatusModel.get('urlBase') + '/Content/Images/poster-dark.png'; - var poster = _.where(this.images, { coverType : 'poster' }); - - if (poster[0]) { - if (!poster[0].url.match(/^https?:\/\//)) { - return new Handlebars.SafeString('<img class="series-poster x-series-poster" {0}>'.format(Handlebars.helpers.defaultImg.call(null, poster[0].url, 250))); - } else { - var url = poster[0].url.replace(/^https?\:/, ''); - return new Handlebars.SafeString('<img class="series-poster x-series-poster" {0}>'.format(Handlebars.helpers.defaultImg.call(null, url))); - } - } - - return new Handlebars.SafeString('<img class="series-poster placeholder-image" src="{0}">'.format(placeholder)); -}); - -Handlebars.registerHelper('traktUrl', function() { - return 'http://trakt.tv/search/tvdb/' + this.tvdbId + '?id_type=show'; -}); - -Handlebars.registerHelper('imdbUrl', function() { - return 'http://imdb.com/title/' + this.imdbId; -}); - -Handlebars.registerHelper('tvdbUrl', function() { - return 'http://www.thetvdb.com/?tab=series&id=' + this.tvdbId; -}); - -Handlebars.registerHelper('tvRageUrl', function() { - return 'http://www.tvrage.com/shows/id-' + this.tvRageId; -}); - -Handlebars.registerHelper('tvMazeUrl', function() { - return 'http://www.tvmaze.com/shows/' + this.tvMazeId + '/_'; -}); - -Handlebars.registerHelper('route', function() { - return StatusModel.get('urlBase') + '/series/' + this.titleSlug; -}); - -Handlebars.registerHelper('percentOfEpisodes', function() { - var episodeCount = this.episodeCount; - var episodeFileCount = this.episodeFileCount; - - var percent = 100; - - if (episodeCount > 0) { - percent = episodeFileCount / episodeCount * 100; - } - - return percent; -}); - -Handlebars.registerHelper('seasonCountHelper', function() { - var seasonCount = this.seasonCount; - var continuing = this.status === 'continuing'; - - if (continuing) { - return new Handlebars.SafeString('<span class="label label-info">Season {0}</span>'.format(seasonCount)); - } - - if (seasonCount === 1) { - return new Handlebars.SafeString('<span class="label label-info">{0} Season</span>'.format(seasonCount)); - } - - return new Handlebars.SafeString('<span class="label label-info">{0} Seasons</span>'.format(seasonCount)); -}); - -Handlebars.registerHelper('titleWithYear', function() { - if (this.title.endsWith(' ({0})'.format(this.year))) { - return this.title; - } - - if (!this.year) { - return this.title; - } - - return new Handlebars.SafeString('{0} <span class="year">({1})</span>'.format(this.title, this.year)); -}); diff --git a/src/UI/Handlebars/Helpers/String.js b/src/UI/Handlebars/Helpers/String.js deleted file mode 100644 index 761f565c0..000000000 --- a/src/UI/Handlebars/Helpers/String.js +++ /dev/null @@ -1,7 +0,0 @@ -var Handlebars = require('handlebars'); - -Handlebars.registerHelper('TitleCase', function(input) { - return new Handlebars.SafeString(input.replace(/\w\S*/g, function(txt) { - return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); - })); -}); \ No newline at end of file diff --git a/src/UI/Handlebars/Helpers/System.js b/src/UI/Handlebars/Helpers/System.js deleted file mode 100644 index 269414666..000000000 --- a/src/UI/Handlebars/Helpers/System.js +++ /dev/null @@ -1,18 +0,0 @@ -var Handlebars = require('handlebars'); -var StatusModel = require('../../System/StatusModel'); - -Handlebars.registerHelper('if_windows', function(options) { - if (StatusModel.get('isWindows')) { - return options.fn(this); - } - - return options.inverse(this); -}); - -Handlebars.registerHelper('if_mono', function(options) { - if (StatusModel.get('isMono')) { - return options.fn(this); - } - - return options.inverse(this); -}); \ No newline at end of file diff --git a/src/UI/Handlebars/backbone.marionette.templates.js b/src/UI/Handlebars/backbone.marionette.templates.js deleted file mode 100644 index 82bf4ec62..000000000 --- a/src/UI/Handlebars/backbone.marionette.templates.js +++ /dev/null @@ -1,36 +0,0 @@ -var Handlebars = require('handlebars'); -require('handlebars.helpers'); -require('./Helpers/DateTime'); -require('./Helpers/Html'); -require('./Helpers/Numbers'); -require('./Helpers/Episode'); -require('./Helpers/Series'); -require('./Helpers/Quality'); -require('./Helpers/System'); -require('./Helpers/EachReverse'); -require('./Helpers/String'); -require('./Handlebars.Debug'); - -module.exports = function() { - this.get = function(templateId) { - var templateKey = templateId.toLowerCase().replace('template', ''); - - var templateFunction = window.T[templateKey]; - - if (!templateFunction) { - throw 'couldn\'t find pre-compiled template ' + templateKey; - } - - return function(data) { - try { - var wrappedTemplate = Handlebars.template.call(Handlebars, templateFunction); - return wrappedTemplate(data); - } - catch (error) { - console.error('template render failed for ' + templateKey + ' ' + error); - console.error(data); - throw error; - } - }; - }; -}; \ No newline at end of file diff --git a/src/UI/Health/HealthCollection.js b/src/UI/Health/HealthCollection.js deleted file mode 100644 index e0935a885..000000000 --- a/src/UI/Health/HealthCollection.js +++ /dev/null @@ -1,13 +0,0 @@ -var Backbone = require('backbone'); -var HealthModel = require('./HealthModel'); -require('../Mixins/backbone.signalr.mixin'); - -var Collection = Backbone.Collection.extend({ - url : window.NzbDrone.ApiRoot + '/health', - model : HealthModel -}); - -var collection = new Collection().bindSignalR(); -collection.fetch(); - -module.exports = collection; \ No newline at end of file diff --git a/src/UI/Health/HealthModel.js b/src/UI/Health/HealthModel.js deleted file mode 100644 index 3986a5948..000000000 --- a/src/UI/Health/HealthModel.js +++ /dev/null @@ -1,3 +0,0 @@ -var Backbone = require('backbone'); - -module.exports = Backbone.Model.extend({}); \ No newline at end of file diff --git a/src/UI/Health/HealthView.js b/src/UI/Health/HealthView.js deleted file mode 100644 index 61ebd525a..000000000 --- a/src/UI/Health/HealthView.js +++ /dev/null @@ -1,37 +0,0 @@ -var _ = require('underscore'); -var Marionette = require('marionette'); -var HealthCollection = require('./HealthCollection'); - -module.exports = Marionette.ItemView.extend({ - tagName : 'span', - - initialize : function() { - this.listenTo(HealthCollection, 'sync', this._healthSync); - HealthCollection.fetch(); - }, - - render : function() { - this.$el.empty(); - - if (HealthCollection.length === 0) { - return this; - } - - var count = HealthCollection.length; - var label = 'label-warning'; - var errors = HealthCollection.some(function(model) { - return model.get('type') === 'error'; - }); - - if (errors) { - label = 'label-danger'; - } - - this.$el.html('<span class="label {0}">{1}</span>'.format(label, count)); - return this; - }, - - _healthSync : function() { - this.render(); - } -}); \ No newline at end of file diff --git a/src/UI/Hotkeys/Hotkeys.js b/src/UI/Hotkeys/Hotkeys.js deleted file mode 100644 index b72a574da..000000000 --- a/src/UI/Hotkeys/Hotkeys.js +++ /dev/null @@ -1,34 +0,0 @@ -var $ = require('jquery'); -var vent = require('vent'); -var HotkeysView = require('./HotkeysView'); - -$(document).on('keypress', function(e) { - if ($(e.target).is('input') || $(e.target).is('textarea')) { - return; - } - - if (e.charCode === 63) { - vent.trigger(vent.Commands.OpenModalCommand, new HotkeysView()); - } -}); - -$(document).on('keydown', function(e) { - if (e.ctrlKey && e.keyCode === 83) { - vent.trigger(vent.Hotkeys.SaveSettings); - e.preventDefault(); - return; - } - - if ($(e.target).is('input') || $(e.target).is('textarea')) { - return; - } - - if (e.ctrlKey || e.metaKey || e.altKey) { - return; - } - - if (e.keyCode === 84) { - vent.trigger(vent.Hotkeys.NavbarSearch); - e.preventDefault(); - } -}); diff --git a/src/UI/Hotkeys/HotkeysView.js b/src/UI/Hotkeys/HotkeysView.js deleted file mode 100644 index ee643fbb2..000000000 --- a/src/UI/Hotkeys/HotkeysView.js +++ /dev/null @@ -1,6 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'Hotkeys/HotkeysViewTemplate' -}); \ No newline at end of file diff --git a/src/UI/Hotkeys/HotkeysViewTemplate.hbs b/src/UI/Hotkeys/HotkeysViewTemplate.hbs deleted file mode 100644 index bce6e86c8..000000000 --- a/src/UI/Hotkeys/HotkeysViewTemplate.hbs +++ /dev/null @@ -1,45 +0,0 @@ -<div class="modal-content"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - <h3>Keyboard Shortcuts</h3> - </div> - <div class="modal-body hotkeys-modal"> - <div class="row hotkey-group"> - <div class="col-md-12"> - <div class="row"> - <div class="col-md-5 col-md-offset-1"> - <h3>Focus Search Box</h3> - </div> - <div class="col-md-3"> - <kbd class="hotkey">t</kbd> - </div> - </div> - <div class="row"> - <div class="col-md-11 col-md-offset-1"> - Pressing 't' puts the cursor in the search box below the navigation links - </div> - </div> - </div> - </div> - <div class="row hotkey-group"> - <div class="col-md-12"> - <div class="row"> - <div class="col-md-5 col-md-offset-1"> - <h3>Save Settings</h3> - </div> - <div class="col-md-3"> - <kbd class="hotkey">ctrl + s</kbd> - </div> - </div> - <div class="row"> - <div class="col-md-11 col-md-offset-1"> - Pressing ctrl + 's' saves your settings (only in settings) - </div> - </div> - </div> - </div> - </div> - <div class="modal-footer"> - <button class="btn" data-dismiss="modal">Close</button> - </div> -</div> diff --git a/src/UI/Hotkeys/hotkeys.less b/src/UI/Hotkeys/hotkeys.less deleted file mode 100644 index b3213825d..000000000 --- a/src/UI/Hotkeys/hotkeys.less +++ /dev/null @@ -1,23 +0,0 @@ -.hotkeys-modal { - h3 { - margin-top : 0px; - margin-botton : 0px; - } - - .hotkey-group { - &:first-of-type { - margin-top : 0px; - } - - &:last-of-type { - margin-bottom : 0px; - } - - margin-top : 25px; - margin-bottom : 25px; - - .hotkey { - font-size : 22px; - } - } -} \ No newline at end of file diff --git a/src/UI/Instrumentation/ErrorHandler.js b/src/UI/Instrumentation/ErrorHandler.js deleted file mode 100644 index 189173662..000000000 --- a/src/UI/Instrumentation/ErrorHandler.js +++ /dev/null @@ -1,86 +0,0 @@ -var $ = require('jquery'); -var Messenger = require('messenger'); - -window.alert = function(message) { - new Messenger().post(message); -}; - -var addError = function(message) { - $('#errors').append('<div>' + message + '</div>'); -}; - -window.onerror = function(msg, url, line) { - - try { - - var a = document.createElement('a'); - a.href = url; - var filename = a.pathname.split('/').pop(); - - //Suppress Firefox debug errors when console window is closed - if (filename.toLowerCase() === 'markupview.jsm' || filename.toLowerCase() === 'markup-view.js') { - return false; - } - - var messageText = filename + ' : ' + line + '</br>' + msg; - - var message = { - message : messageText, - type : 'error', - hideAfter : 1000, - showCloseButton : true - }; - - new Messenger().post(message); - - addError(message.message); - - } - catch (error) { - console.log('An error occurred while reporting error. ' + error); - console.log(msg); - new Messenger().post('Couldn\'t report JS error. ' + msg); - } - - return false; //don't suppress default alerts and logs. -}; - -$(document).ajaxError(function(event, xmlHttpRequest, ajaxOptions) { - - //don't report 200 error codes - if (xmlHttpRequest.status >= 200 && xmlHttpRequest.status <= 300) { - return undefined; - } - - //don't report aborted requests - if (xmlHttpRequest.statusText === 'abort') { - return undefined; - } - - var message = { - type : 'error', - hideAfter : 1000, - showCloseButton : true - }; - - if (xmlHttpRequest.status === 0 && xmlHttpRequest.readyState === 0) { - return false; - } - - if (xmlHttpRequest.status === 400 && ajaxOptions.isValidatedCall) { - return false; - } - - if (xmlHttpRequest.status === 503) { - message.message = xmlHttpRequest.responseJSON.message; - } else if (xmlHttpRequest.status === 409) { - message.message = xmlHttpRequest.responseJSON.message; - } else { - message.message = '[{0}] {1} : {2}'.format(ajaxOptions.type, xmlHttpRequest.statusText, ajaxOptions.url); - } - - new Messenger().post(message); - addError(message.message); - - return false; -}); \ No newline at end of file diff --git a/src/UI/Instrumentation/StringFormat.js b/src/UI/Instrumentation/StringFormat.js deleted file mode 100644 index 059d25f5e..000000000 --- a/src/UI/Instrumentation/StringFormat.js +++ /dev/null @@ -1,13 +0,0 @@ -'use strict'; - -String.prototype.format = function() { - var args = arguments; - - return this.replace(/{(\d+)}/g, function(match, number) { - if (typeof args[number] !== 'undefined') { - return args[number]; - } else { - return match; - } - }); -}; \ No newline at end of file diff --git a/src/UI/JsLibraries/backbone.backgrid.js b/src/UI/JsLibraries/backbone.backgrid.js deleted file mode 100644 index 6a0af616c..000000000 --- a/src/UI/JsLibraries/backbone.backgrid.js +++ /dev/null @@ -1,2764 +0,0 @@ -/*! - backgrid - http://github.com/wyuenho/backgrid - - Copyright (c) 2013 Jimmy Yuen Ho Wong and contributors <wyuenho@gmail.com> - Licensed under the MIT license. -*/ - -(function (factory) { - - // CommonJS - if (typeof exports == "object") { - module.exports = factory(module.exports, - require("underscore"), - require("backbone")); - } - // Browser - else if (typeof _ !== "undefined" && - typeof Backbone !== "undefined") { - factory(window, _, Backbone); - } -}(function (root, _, Backbone) { - - "use strict"; -/* - backgrid - http://github.com/wyuenho/backgrid - - Copyright (c) 2013 Jimmy Yuen Ho Wong and contributors - Licensed under the MIT license. -*/ - -// Copyright 2009, 2010 Kristopher Michael Kowal -// https://github.com/kriskowal/es5-shim -// ES5 15.5.4.20 -// http://es5.github.com/#x15.5.4.20 -var ws = "\x09\x0A\x0B\x0C\x0D\x20\xA0\u1680\u180E\u2000\u2001\u2002\u2003" + - "\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u202F\u205F\u3000\u2028" + - "\u2029\uFEFF"; -if (!String.prototype.trim || ws.trim()) { - // http://blog.stevenlevithan.com/archives/faster-trim-javascript - // http://perfectionkills.com/whitespace-deviations/ - ws = "[" + ws + "]"; - var trimBeginRegexp = new RegExp("^" + ws + ws + "*"), - trimEndRegexp = new RegExp(ws + ws + "*$"); - String.prototype.trim = function trim() { - if (this === undefined || this === null) { - throw new TypeError("can't convert " + this + " to object"); - } - return String(this) - .replace(trimBeginRegexp, "") - .replace(trimEndRegexp, ""); - }; -} - -function lpad(str, length, padstr) { - var paddingLen = length - (str + '').length; - paddingLen = paddingLen < 0 ? 0 : paddingLen; - var padding = ''; - for (var i = 0; i < paddingLen; i++) { - padding = padding + padstr; - } - return padding + str; -} - -var $ = Backbone.$; - -var Backgrid = root.Backgrid = { - - VERSION: "0.3.0", - - Extension: {}, - - resolveNameToClass: function (name, suffix) { - if (_.isString(name)) { - var key = _.map(name.split('-'), function (e) { - return e.slice(0, 1).toUpperCase() + e.slice(1); - }).join('') + suffix; - var klass = Backgrid[key] || Backgrid.Extension[key]; - if (_.isUndefined(klass)) { - throw new ReferenceError("Class '" + key + "' not found"); - } - return klass; - } - - return name; - }, - - callByNeed: function () { - var value = arguments[0]; - if (!_.isFunction(value)) return value; - - var context = arguments[1]; - var args = [].slice.call(arguments, 2); - return value.apply(context, !!(args + '') ? args : void 0); - } - -}; -_.extend(Backgrid, Backbone.Events); - -/** - Command translates a DOM Event into commands that Backgrid - recognizes. Interested parties can listen on selected Backgrid events that - come with an instance of this class and act on the commands. - - It is also possible to globally rebind the keyboard shortcuts by replacing - the methods in this class' prototype. - - @class Backgrid.Command - @constructor - */ -var Command = Backgrid.Command = function (evt) { - _.extend(this, { - altKey: !!evt.altKey, - "char": evt["char"], - charCode: evt.charCode, - ctrlKey: !!evt.ctrlKey, - key: evt.key, - keyCode: evt.keyCode, - locale: evt.locale, - location: evt.location, - metaKey: !!evt.metaKey, - repeat: !!evt.repeat, - shiftKey: !!evt.shiftKey, - which: evt.which - }); -}; -_.extend(Command.prototype, { - /** - Up Arrow - - @member Backgrid.Command - */ - moveUp: function () { return this.keyCode == 38; }, - /** - Down Arrow - - @member Backgrid.Command - */ - moveDown: function () { return this.keyCode === 40; }, - /** - Shift Tab - - @member Backgrid.Command - */ - moveLeft: function () { return this.shiftKey && this.keyCode === 9; }, - /** - Tab - - @member Backgrid.Command - */ - moveRight: function () { return !this.shiftKey && this.keyCode === 9; }, - /** - Enter - - @member Backgrid.Command - */ - save: function () { return this.keyCode === 13; }, - /** - Esc - - @member Backgrid.Command - */ - cancel: function () { return this.keyCode === 27; }, - /** - None of the above. - - @member Backgrid.Command - */ - passThru: function () { - return !(this.moveUp() || this.moveDown() || this.moveLeft() || - this.moveRight() || this.save() || this.cancel()); - } -}); - - -/* - backgrid - http://github.com/wyuenho/backgrid - - Copyright (c) 2013 Jimmy Yuen Ho Wong and contributors - Licensed under the MIT license. -*/ - -/** - Just a convenient class for interested parties to subclass. - - The default Cell classes don't require the formatter to be a subclass of - Formatter as long as the fromRaw(rawData) and toRaw(formattedData) methods - are defined. - - @abstract - @class Backgrid.CellFormatter - @constructor -*/ -var CellFormatter = Backgrid.CellFormatter = function () {}; -_.extend(CellFormatter.prototype, { - - /** - Takes a raw value from a model and returns an optionally formatted string - for display. The default implementation simply returns the supplied value - as is without any type conversion. - - @member Backgrid.CellFormatter - @param {*} rawData - @param {Backbone.Model} model Used for more complicated formatting - @return {*} - */ - fromRaw: function (rawData, model) { - return rawData; - }, - - /** - Takes a formatted string, usually from user input, and returns a - appropriately typed value for persistence in the model. - - If the user input is invalid or unable to be converted to a raw value - suitable for persistence in the model, toRaw must return `undefined`. - - @member Backgrid.CellFormatter - @param {string} formattedData - @param {Backbone.Model} model Used for more complicated formatting - @return {*|undefined} - */ - toRaw: function (formattedData, model) { - return formattedData; - } - -}); - -/** - A floating point number formatter. Doesn't understand scientific notation at - the moment. - - @class Backgrid.NumberFormatter - @extends Backgrid.CellFormatter - @constructor - @throws {RangeError} If decimals < 0 or > 20. -*/ -var NumberFormatter = Backgrid.NumberFormatter = function (options) { - _.extend(this, this.defaults, options || {}); - - if (this.decimals < 0 || this.decimals > 20) { - throw new RangeError("decimals must be between 0 and 20"); - } -}; -NumberFormatter.prototype = new CellFormatter(); -_.extend(NumberFormatter.prototype, { - - /** - @member Backgrid.NumberFormatter - @cfg {Object} options - - @cfg {number} [options.decimals=2] Number of decimals to display. Must be an integer. - - @cfg {string} [options.decimalSeparator='.'] The separator to use when - displaying decimals. - - @cfg {string} [options.orderSeparator=','] The separator to use to - separator thousands. May be an empty string. - */ - defaults: { - decimals: 2, - decimalSeparator: '.', - orderSeparator: ',' - }, - - HUMANIZED_NUM_RE: /(\d)(?=(?:\d{3})+$)/g, - - /** - Takes a floating point number and convert it to a formatted string where - every thousand is separated by `orderSeparator`, with a `decimal` number of - decimals separated by `decimalSeparator`. The number returned is rounded - the usual way. - - @member Backgrid.NumberFormatter - @param {number} number - @param {Backbone.Model} model Used for more complicated formatting - @return {string} - */ - fromRaw: function (number, model) { - if (_.isNull(number) || _.isUndefined(number)) return ''; - - number = number.toFixed(~~this.decimals); - - var parts = number.split('.'); - var integerPart = parts[0]; - var decimalPart = parts[1] ? (this.decimalSeparator || '.') + parts[1] : ''; - - return integerPart.replace(this.HUMANIZED_NUM_RE, '$1' + this.orderSeparator) + decimalPart; - }, - - /** - Takes a string, possibly formatted with `orderSeparator` and/or - `decimalSeparator`, and convert it back to a number. - - @member Backgrid.NumberFormatter - @param {string} formattedData - @param {Backbone.Model} model Used for more complicated formatting - @return {number|undefined} Undefined if the string cannot be converted to - a number. - */ - toRaw: function (formattedData, model) { - formattedData = formattedData.trim(); - - if (formattedData === '') return null; - - var rawData = ''; - - var thousands = formattedData.split(this.orderSeparator); - for (var i = 0; i < thousands.length; i++) { - rawData += thousands[i]; - } - - var decimalParts = rawData.split(this.decimalSeparator); - rawData = ''; - for (var i = 0; i < decimalParts.length; i++) { - rawData = rawData + decimalParts[i] + '.'; - } - - if (rawData[rawData.length - 1] === '.') { - rawData = rawData.slice(0, rawData.length - 1); - } - - var result = (rawData * 1).toFixed(~~this.decimals) * 1; - if (_.isNumber(result) && !_.isNaN(result)) return result; - } - -}); - -/** - Formatter to converts between various datetime formats. - - This class only understands ISO-8601 formatted datetime strings and UNIX - offset (number of milliseconds since UNIX Epoch). See - Backgrid.Extension.MomentFormatter if you need a much more flexible datetime - formatter. - - @class Backgrid.DatetimeFormatter - @extends Backgrid.CellFormatter - @constructor - @throws {Error} If both `includeDate` and `includeTime` are false. -*/ -var DatetimeFormatter = Backgrid.DatetimeFormatter = function (options) { - _.extend(this, this.defaults, options || {}); - - if (!this.includeDate && !this.includeTime) { - throw new Error("Either includeDate or includeTime must be true"); - } -}; -DatetimeFormatter.prototype = new CellFormatter(); -_.extend(DatetimeFormatter.prototype, { - - /** - @member Backgrid.DatetimeFormatter - - @cfg {Object} options - - @cfg {boolean} [options.includeDate=true] Whether the values include the - date part. - - @cfg {boolean} [options.includeTime=true] Whether the values include the - time part. - - @cfg {boolean} [options.includeMilli=false] If `includeTime` is true, - whether to include the millisecond part, if it exists. - */ - defaults: { - includeDate: true, - includeTime: true, - includeMilli: false - }, - - DATE_RE: /^([+\-]?\d{4})-(\d{2})-(\d{2})$/, - TIME_RE: /^(\d{2}):(\d{2}):(\d{2})(\.(\d{3}))?$/, - ISO_SPLITTER_RE: /T|Z| +/, - - _convert: function (data, validate) { - if ((data + '').trim() === '') return null; - - var date, time = null; - if (_.isNumber(data)) { - var jsDate = new Date(data); - date = lpad(jsDate.getUTCFullYear(), 4, 0) + '-' + lpad(jsDate.getUTCMonth() + 1, 2, 0) + '-' + lpad(jsDate.getUTCDate(), 2, 0); - time = lpad(jsDate.getUTCHours(), 2, 0) + ':' + lpad(jsDate.getUTCMinutes(), 2, 0) + ':' + lpad(jsDate.getUTCSeconds(), 2, 0); - } - else { - data = data.trim(); - var parts = data.split(this.ISO_SPLITTER_RE) || []; - date = this.DATE_RE.test(parts[0]) ? parts[0] : ''; - time = date && parts[1] ? parts[1] : this.TIME_RE.test(parts[0]) ? parts[0] : ''; - } - - var YYYYMMDD = this.DATE_RE.exec(date) || []; - var HHmmssSSS = this.TIME_RE.exec(time) || []; - - if (validate) { - if (this.includeDate && _.isUndefined(YYYYMMDD[0])) return; - if (this.includeTime && _.isUndefined(HHmmssSSS[0])) return; - if (!this.includeDate && date) return; - if (!this.includeTime && time) return; - } - - var jsDate = new Date(Date.UTC(YYYYMMDD[1] * 1 || 0, - YYYYMMDD[2] * 1 - 1 || 0, - YYYYMMDD[3] * 1 || 0, - HHmmssSSS[1] * 1 || null, - HHmmssSSS[2] * 1 || null, - HHmmssSSS[3] * 1 || null, - HHmmssSSS[5] * 1 || null)); - - var result = ''; - - if (this.includeDate) { - result = lpad(jsDate.getUTCFullYear(), 4, 0) + '-' + lpad(jsDate.getUTCMonth() + 1, 2, 0) + '-' + lpad(jsDate.getUTCDate(), 2, 0); - } - - if (this.includeTime) { - result = result + (this.includeDate ? 'T' : '') + lpad(jsDate.getUTCHours(), 2, 0) + ':' + lpad(jsDate.getUTCMinutes(), 2, 0) + ':' + lpad(jsDate.getUTCSeconds(), 2, 0); - - if (this.includeMilli) { - result = result + '.' + lpad(jsDate.getUTCMilliseconds(), 3, 0); - } - } - - if (this.includeDate && this.includeTime) { - result += "Z"; - } - - return result; - }, - - /** - Converts an ISO-8601 formatted datetime string to a datetime string, date - string or a time string. The timezone is ignored if supplied. - - @member Backgrid.DatetimeFormatter - @param {string} rawData - @param {Backbone.Model} model Used for more complicated formatting - @return {string|null|undefined} ISO-8601 string in UTC. Null and undefined - values are returned as is. - */ - fromRaw: function (rawData, model) { - if (_.isNull(rawData) || _.isUndefined(rawData)) return ''; - return this._convert(rawData); - }, - - /** - Converts an ISO-8601 formatted datetime string to a datetime string, date - string or a time string. The timezone is ignored if supplied. This method - parses the input values exactly the same way as - Backgrid.Extension.MomentFormatter#fromRaw(), in addition to doing some - sanity checks. - - @member Backgrid.DatetimeFormatter - @param {string} formattedData - @param {Backbone.Model} model Used for more complicated formatting - @return {string|undefined} ISO-8601 string in UTC. Undefined if a date is - found when `includeDate` is false, or a time is found when `includeTime` is - false, or if `includeDate` is true and a date is not found, or if - `includeTime` is true and a time is not found. - */ - toRaw: function (formattedData, model) { - return this._convert(formattedData, true); - } - -}); - -/** - Formatter to convert any value to string. - - @class Backgrid.StringFormatter - @extends Backgrid.CellFormatter - @constructor - */ -var StringFormatter = Backgrid.StringFormatter = function () {}; -StringFormatter.prototype = new CellFormatter(); -_.extend(StringFormatter.prototype, { - /** - Converts any value to a string using Ecmascript's implicit type - conversion. If the given value is `null` or `undefined`, an empty string is - returned instead. - - @member Backgrid.StringFormatter - @param {*} rawValue - @param {Backbone.Model} model Used for more complicated formatting - @return {string} - */ - fromRaw: function (rawValue, model) { - if (_.isUndefined(rawValue) || _.isNull(rawValue)) return ''; - return rawValue + ''; - } -}); - -/** - Simple email validation formatter. - - @class Backgrid.EmailFormatter - @extends Backgrid.CellFormatter - @constructor - */ -var EmailFormatter = Backgrid.EmailFormatter = function () {}; -EmailFormatter.prototype = new CellFormatter(); -_.extend(EmailFormatter.prototype, { - /** - Return the input if it is a string that contains an '@' character and if - the strings before and after '@' are non-empty. If the input does not - validate, `undefined` is returned. - - @member Backgrid.EmailFormatter - @param {*} formattedData - @param {Backbone.Model} model Used for more complicated formatting - @return {string|undefined} - */ - toRaw: function (formattedData, model) { - var parts = formattedData.trim().split("@"); - if (parts.length === 2 && _.all(parts)) { - return formattedData; - } - } -}); - -/** - Formatter for SelectCell. - - @class Backgrid.SelectFormatter - @extends Backgrid.CellFormatter - @constructor -*/ -var SelectFormatter = Backgrid.SelectFormatter = function () {}; -SelectFormatter.prototype = new CellFormatter(); -_.extend(SelectFormatter.prototype, { - - /** - Normalizes raw scalar or array values to an array. - - @member Backgrid.SelectFormatter - @param {*} rawValue - @param {Backbone.Model} model Used for more complicated formatting - @return {Array.<*>} - */ - fromRaw: function (rawValue, model) { - return _.isArray(rawValue) ? rawValue : rawValue != null ? [rawValue] : []; - } -}); - - -/* - backgrid - http://github.com/wyuenho/backgrid - - Copyright (c) 2013 Jimmy Yuen Ho Wong and contributors - Licensed under the MIT license. -*/ - -/** - Generic cell editor base class. Only defines an initializer for a number of - required parameters. - - @abstract - @class Backgrid.CellEditor - @extends Backbone.View -*/ -var CellEditor = Backgrid.CellEditor = Backbone.View.extend({ - - /** - Initializer. - - @param {Object} options - @param {Backgrid.CellFormatter} options.formatter - @param {Backgrid.Column} options.column - @param {Backbone.Model} options.model - - @throws {TypeError} If `formatter` is not a formatter instance, or when - `model` or `column` are undefined. - */ - initialize: function (options) { - this.formatter = options.formatter; - this.column = options.column; - if (!(this.column instanceof Column)) { - this.column = new Column(this.column); - } - - this.listenTo(this.model, "backgrid:editing", this.postRender); - }, - - /** - Post-rendering setup and initialization. Focuses the cell editor's `el` in - this default implementation. **Should** be called by Cell classes after - calling Backgrid.CellEditor#render. - */ - postRender: function (model, column) { - if (column == null || column.get("name") == this.column.get("name")) { - this.$el.focus(); - } - return this; - } - -}); - -/** - InputCellEditor the cell editor type used by most core cell types. This cell - editor renders a text input box as its editor. The input will render a - placeholder if the value is empty on supported browsers. - - @class Backgrid.InputCellEditor - @extends Backgrid.CellEditor -*/ -var InputCellEditor = Backgrid.InputCellEditor = CellEditor.extend({ - - /** @property */ - tagName: "input", - - /** @property */ - attributes: { - type: "text" - }, - - /** @property */ - events: { - "blur": "saveOrCancel", - "keydown": "saveOrCancel" - }, - - /** - Initializer. Removes this `el` from the DOM when a `done` event is - triggered. - - @param {Object} options - @param {Backgrid.CellFormatter} options.formatter - @param {Backgrid.Column} options.column - @param {Backbone.Model} options.model - @param {string} [options.placeholder] - */ - initialize: function (options) { - InputCellEditor.__super__.initialize.apply(this, arguments); - - if (options.placeholder) { - this.$el.attr("placeholder", options.placeholder); - } - }, - - /** - Renders a text input with the cell value formatted for display, if it - exists. - */ - render: function () { - var model = this.model - this.$el.val(this.formatter.fromRaw(model.get(this.column.get("name")), model)); - return this; - }, - - /** - If the key pressed is `enter`, `tab`, `up`, or `down`, converts the value - in the editor to a raw value for saving into the model using the formatter. - - If the key pressed is `esc` the changes are undone. - - If the editor goes out of focus (`blur`) but the value is invalid, the - event is intercepted and cancelled so the cell remains in focus pending for - further action. The changes are saved otherwise. - - Triggers a Backbone `backgrid:edited` event from the model when successful, - and `backgrid:error` if the value cannot be converted. Classes listening to - the `error` event, usually the Cell classes, should respond appropriately, - usually by rendering some kind of error feedback. - - @param {Event} e - */ - saveOrCancel: function (e) { - - var formatter = this.formatter; - var model = this.model; - var column = this.column; - - var command = new Command(e); - var blurred = e.type === "blur"; - - if (command.moveUp() || command.moveDown() || command.moveLeft() || command.moveRight() || - command.save() || blurred) { - - e.preventDefault(); - e.stopPropagation(); - - var val = this.$el.val(); - var newValue = formatter.toRaw(val, model); - if (_.isUndefined(newValue)) { - model.trigger("backgrid:error", model, column, val); - } - else { - model.set(column.get("name"), newValue); - model.trigger("backgrid:edited", model, column, command); - } - } - // esc - else if (command.cancel()) { - // undo - e.stopPropagation(); - model.trigger("backgrid:edited", model, column, command); - } - }, - - postRender: function (model, column) { - if (column == null || column.get("name") == this.column.get("name")) { - // move the cursor to the end on firefox if text is right aligned - if (this.$el.css("text-align") === "right") { - var val = this.$el.val(); - this.$el.focus().val(null).val(val); - } - else this.$el.focus(); - } - return this; - } - -}); - -/** - The super-class for all Cell types. By default, this class renders a plain - table cell with the model value converted to a string using the - formatter. The table cell is clickable, upon which the cell will go into - editor mode, which is rendered by a Backgrid.InputCellEditor instance by - default. Upon encountering any formatting errors, this class will add an - `error` CSS class to the table cell. - - @abstract - @class Backgrid.Cell - @extends Backbone.View -*/ -var Cell = Backgrid.Cell = Backbone.View.extend({ - - /** @property */ - tagName: "td", - - /** - @property {Backgrid.CellFormatter|Object|string} [formatter=CellFormatter] - */ - formatter: CellFormatter, - - /** - @property {Backgrid.CellEditor} [editor=Backgrid.InputCellEditor] The - default editor for all cell instances of this class. This value must be a - class, it will be automatically instantiated upon entering edit mode. - - See Backgrid.CellEditor - */ - editor: InputCellEditor, - - /** @property */ - events: { - "click": "enterEditMode" - }, - - /** - Initializer. - - @param {Object} options - @param {Backbone.Model} options.model - @param {Backgrid.Column} options.column - - @throws {ReferenceError} If formatter is a string but a formatter class of - said name cannot be found in the Backgrid module. - */ - initialize: function (options) { - this.column = options.column; - if (!(this.column instanceof Column)) { - this.column = new Column(this.column); - } - - var column = this.column, model = this.model, $el = this.$el; - - var formatter = Backgrid.resolveNameToClass(column.get("formatter") || - this.formatter, "Formatter"); - - if (!_.isFunction(formatter.fromRaw) && !_.isFunction(formatter.toRaw)) { - formatter = new formatter(); - } - - this.formatter = formatter; - - this.editor = Backgrid.resolveNameToClass(this.editor, "CellEditor"); - - this.listenTo(model, "change:" + column.get("name"), function () { - if (!$el.hasClass("editor")) this.render(); - }); - - this.listenTo(model, "backgrid:error", this.renderError); - - this.listenTo(column, "change:editable change:sortable change:renderable", - function (column) { - var changed = column.changedAttributes(); - for (var key in changed) { - if (changed.hasOwnProperty(key)) { - $el.toggleClass(key, changed[key]); - } - } - }); - - if (column.get("editable")) $el.addClass("editable"); - if (column.get("sortable")) $el.addClass("sortable"); - if (column.get("renderable")) $el.addClass("renderable"); - }, - - /** - Render a text string in a table cell. The text is converted from the - model's raw value for this cell's column. - */ - render: function () { - this.$el.empty(); - var model = this.model; - this.$el.text(this.formatter.fromRaw(model.get(this.column.get("name")), model)); - this.delegateEvents(); - return this; - }, - - /** - If this column is editable, a new CellEditor instance is instantiated with - its required parameters. An `editor` CSS class is added to the cell upon - entering edit mode. - - This method triggers a Backbone `backgrid:edit` event from the model when - the cell is entering edit mode and an editor instance has been constructed, - but before it is rendered and inserted into the DOM. The cell and the - constructed cell editor instance are sent as event parameters when this - event is triggered. - - When this cell has finished switching to edit mode, a Backbone - `backgrid:editing` event is triggered from the model. The cell and the - constructed cell instance are also sent as parameters in the event. - - When the model triggers a `backgrid:error` event, it means the editor is - unable to convert the current user input to an apprpriate value for the - model's column, and an `error` CSS class is added to the cell accordingly. - */ - enterEditMode: function () { - var model = this.model; - var column = this.column; - - var editable = Backgrid.callByNeed(column.editable(), column, model); - if (editable) { - - this.currentEditor = new this.editor({ - column: this.column, - model: this.model, - formatter: this.formatter - }); - - model.trigger("backgrid:edit", model, column, this, this.currentEditor); - - // Need to redundantly undelegate events for Firefox - this.undelegateEvents(); - this.$el.empty(); - this.$el.append(this.currentEditor.$el); - this.currentEditor.render(); - this.$el.addClass("editor"); - - model.trigger("backgrid:editing", model, column, this, this.currentEditor); - } - }, - - /** - Put an `error` CSS class on the table cell. - */ - renderError: function (model, column) { - if (column == null || column.get("name") == this.column.get("name")) { - this.$el.addClass("error"); - } - }, - - /** - Removes the editor and re-render in display mode. - */ - exitEditMode: function () { - this.$el.removeClass("error"); - this.currentEditor.remove(); - this.stopListening(this.currentEditor); - delete this.currentEditor; - this.$el.removeClass("editor"); - this.render(); - }, - - /** - Clean up this cell. - - @chainable - */ - remove: function () { - if (this.currentEditor) { - this.currentEditor.remove.apply(this.currentEditor, arguments); - delete this.currentEditor; - } - return Cell.__super__.remove.apply(this, arguments); - } - -}); - -/** - StringCell displays HTML escaped strings and accepts anything typed in. - - @class Backgrid.StringCell - @extends Backgrid.Cell -*/ -var StringCell = Backgrid.StringCell = Cell.extend({ - - /** @property */ - className: "string-cell", - - formatter: StringFormatter - -}); - -/** - UriCell renders an HTML `<a>` anchor for the value and accepts URIs as user - input values. No type conversion or URL validation is done by the formatter - of this cell. Users who need URL validation are encourage to subclass UriCell - to take advantage of the parsing capabilities of the HTMLAnchorElement - available on HTML5-capable browsers or using a third-party library like - [URI.js](https://github.com/medialize/URI.js). - - @class Backgrid.UriCell - @extends Backgrid.Cell -*/ -var UriCell = Backgrid.UriCell = Cell.extend({ - - /** @property */ - className: "uri-cell", - - /** - @property {string} [title] The title attribute of the generated anchor. It - uses the display value formatted by the `formatter.fromRaw` by default. - */ - title: null, - - /** - @property {string} [target="_blank"] The target attribute of the generated - anchor. - */ - target: "_blank", - - initialize: function (options) { - UriCell.__super__.initialize.apply(this, arguments); - this.title = options.title || this.title; - this.target = options.target || this.target; - }, - - render: function () { - this.$el.empty(); - var rawValue = this.model.get(this.column.get("name")); - var formattedValue = this.formatter.fromRaw(rawValue, this.model); - this.$el.append($("<a>", { - tabIndex: -1, - href: rawValue, - title: this.title || formattedValue, - target: this.target, - }).text(formattedValue)); - this.delegateEvents(); - return this; - } - -}); - -/** - Like Backgrid.UriCell, EmailCell renders an HTML `<a>` anchor for the - value. The `href` in the anchor is prefixed with `mailto:`. EmailCell will - complain if the user enters a string that doesn't contain the `@` sign. - - @class Backgrid.EmailCell - @extends Backgrid.StringCell -*/ -var EmailCell = Backgrid.EmailCell = StringCell.extend({ - - /** @property */ - className: "email-cell", - - formatter: EmailFormatter, - - render: function () { - this.$el.empty(); - var model = this.model; - var formattedValue = this.formatter.fromRaw(model.get(this.column.get("name")), model); - this.$el.append($("<a>", { - tabIndex: -1, - href: "mailto:" + formattedValue, - title: formattedValue - }).text(formattedValue)); - this.delegateEvents(); - return this; - } - -}); - -/** - NumberCell is a generic cell that renders all numbers. Numbers are formatted - using a Backgrid.NumberFormatter. - - @class Backgrid.NumberCell - @extends Backgrid.Cell -*/ -var NumberCell = Backgrid.NumberCell = Cell.extend({ - - /** @property */ - className: "number-cell", - - /** - @property {number} [decimals=2] Must be an integer. - */ - decimals: NumberFormatter.prototype.defaults.decimals, - - /** @property {string} [decimalSeparator='.'] */ - decimalSeparator: NumberFormatter.prototype.defaults.decimalSeparator, - - /** @property {string} [orderSeparator=','] */ - orderSeparator: NumberFormatter.prototype.defaults.orderSeparator, - - /** @property {Backgrid.CellFormatter} [formatter=Backgrid.NumberFormatter] */ - formatter: NumberFormatter, - - /** - Initializes this cell and the number formatter. - - @param {Object} options - @param {Backbone.Model} options.model - @param {Backgrid.Column} options.column - */ - initialize: function (options) { - NumberCell.__super__.initialize.apply(this, arguments); - var formatter = this.formatter; - formatter.decimals = this.decimals; - formatter.decimalSeparator = this.decimalSeparator; - formatter.orderSeparator = this.orderSeparator; - } - -}); - -/** - An IntegerCell is just a Backgrid.NumberCell with 0 decimals. If a floating - point number is supplied, the number is simply rounded the usual way when - displayed. - - @class Backgrid.IntegerCell - @extends Backgrid.NumberCell -*/ -var IntegerCell = Backgrid.IntegerCell = NumberCell.extend({ - - /** @property */ - className: "integer-cell", - - /** - @property {number} decimals Must be an integer. - */ - decimals: 0 -}); - -/** - DatetimeCell is a basic cell that accepts datetime string values in RFC-2822 - or W3C's subset of ISO-8601 and displays them in ISO-8601 format. For a much - more sophisticated date time cell with better datetime formatting, take a - look at the Backgrid.Extension.MomentCell extension. - - @class Backgrid.DatetimeCell - @extends Backgrid.Cell - - See: - - - Backgrid.Extension.MomentCell - - Backgrid.DatetimeFormatter -*/ -var DatetimeCell = Backgrid.DatetimeCell = Cell.extend({ - - /** @property */ - className: "datetime-cell", - - /** - @property {boolean} [includeDate=true] - */ - includeDate: DatetimeFormatter.prototype.defaults.includeDate, - - /** - @property {boolean} [includeTime=true] - */ - includeTime: DatetimeFormatter.prototype.defaults.includeTime, - - /** - @property {boolean} [includeMilli=false] - */ - includeMilli: DatetimeFormatter.prototype.defaults.includeMilli, - - /** @property {Backgrid.CellFormatter} [formatter=Backgrid.DatetimeFormatter] */ - formatter: DatetimeFormatter, - - /** - Initializes this cell and the datetime formatter. - - @param {Object} options - @param {Backbone.Model} options.model - @param {Backgrid.Column} options.column - */ - initialize: function (options) { - DatetimeCell.__super__.initialize.apply(this, arguments); - var formatter = this.formatter; - formatter.includeDate = this.includeDate; - formatter.includeTime = this.includeTime; - formatter.includeMilli = this.includeMilli; - - var placeholder = this.includeDate ? "YYYY-MM-DD" : ""; - placeholder += (this.includeDate && this.includeTime) ? "T" : ""; - placeholder += this.includeTime ? "HH:mm:ss" : ""; - placeholder += (this.includeTime && this.includeMilli) ? ".SSS" : ""; - - this.editor = this.editor.extend({ - attributes: _.extend({}, this.editor.prototype.attributes, this.editor.attributes, { - placeholder: placeholder - }) - }); - } - -}); - -/** - DateCell is a Backgrid.DatetimeCell without the time part. - - @class Backgrid.DateCell - @extends Backgrid.DatetimeCell -*/ -var DateCell = Backgrid.DateCell = DatetimeCell.extend({ - - /** @property */ - className: "date-cell", - - /** @property */ - includeTime: false - -}); - -/** - TimeCell is a Backgrid.DatetimeCell without the date part. - - @class Backgrid.TimeCell - @extends Backgrid.DatetimeCell -*/ -var TimeCell = Backgrid.TimeCell = DatetimeCell.extend({ - - /** @property */ - className: "time-cell", - - /** @property */ - includeDate: false - -}); - -/** - BooleanCellEditor renders a checkbox as its editor. - - @class Backgrid.BooleanCellEditor - @extends Backgrid.CellEditor -*/ -var BooleanCellEditor = Backgrid.BooleanCellEditor = CellEditor.extend({ - - /** @property */ - tagName: "input", - - /** @property */ - attributes: { - tabIndex: -1, - type: "checkbox" - }, - - /** @property */ - events: { - "mousedown": function () { - this.mouseDown = true; - }, - "blur": "enterOrExitEditMode", - "mouseup": function () { - this.mouseDown = false; - }, - "change": "saveOrCancel", - "keydown": "saveOrCancel" - }, - - /** - Renders a checkbox and check it if the model value of this column is true, - uncheck otherwise. - */ - render: function () { - var model = this.model; - var val = this.formatter.fromRaw(model.get(this.column.get("name")), model); - this.$el.prop("checked", val); - return this; - }, - - /** - Event handler. Hack to deal with the case where `blur` is fired before - `change` and `click` on a checkbox. - */ - enterOrExitEditMode: function (e) { - if (!this.mouseDown) { - var model = this.model; - model.trigger("backgrid:edited", model, this.column, new Command(e)); - } - }, - - /** - Event handler. Save the value into the model if the event is `change` or - one of the keyboard navigation key presses. Exit edit mode without saving - if `escape` was pressed. - */ - saveOrCancel: function (e) { - var model = this.model; - var column = this.column; - var formatter = this.formatter; - var command = new Command(e); - // skip ahead to `change` when space is pressed - if (command.passThru() && e.type != "change") return true; - if (command.cancel()) { - e.stopPropagation(); - model.trigger("backgrid:edited", model, column, command); - } - - var $el = this.$el; - if (command.save() || command.moveLeft() || command.moveRight() || command.moveUp() || - command.moveDown()) { - e.preventDefault(); - e.stopPropagation(); - var val = formatter.toRaw($el.prop("checked"), model); - model.set(column.get("name"), val); - model.trigger("backgrid:edited", model, column, command); - } - else if (e.type == "change") { - var val = formatter.toRaw($el.prop("checked"), model); - model.set(column.get("name"), val); - $el.focus(); - } - } - -}); - -/** - BooleanCell renders a checkbox both during display mode and edit mode. The - checkbox is checked if the model value is true, unchecked otherwise. - - @class Backgrid.BooleanCell - @extends Backgrid.Cell -*/ -var BooleanCell = Backgrid.BooleanCell = Cell.extend({ - - /** @property */ - className: "boolean-cell", - - /** @property */ - editor: BooleanCellEditor, - - /** @property */ - events: { - "click": "enterEditMode" - }, - - /** - Renders a checkbox and check it if the model value of this column is true, - uncheck otherwise. - */ - render: function () { - this.$el.empty(); - var model = this.model, column = this.column; - var editable = Backgrid.callByNeed(column.editable(), column, model); - this.$el.append($("<input>", { - tabIndex: -1, - type: "checkbox", - checked: this.formatter.fromRaw(model.get(column.get("name")), model), - disabled: !editable - })); - this.delegateEvents(); - return this; - } - -}); - -/** - SelectCellEditor renders an HTML `<select>` fragment as the editor. - - @class Backgrid.SelectCellEditor - @extends Backgrid.CellEditor -*/ -var SelectCellEditor = Backgrid.SelectCellEditor = CellEditor.extend({ - - /** @property */ - tagName: "select", - - /** @property */ - events: { - "change": "save", - "blur": "close", - "keydown": "close" - }, - - /** @property {function(Object, ?Object=): string} template */ - template: _.template('<option value="<%- value %>" <%= selected ? \'selected="selected"\' : "" %>><%- text %></option>', null, {variable: null}), - - setOptionValues: function (optionValues) { - this.optionValues = optionValues; - this.optionValues = _.result(this, "optionValues"); - }, - - setMultiple: function (multiple) { - this.multiple = multiple; - this.$el.prop("multiple", multiple); - }, - - _renderOptions: function (nvps, selectedValues) { - var options = ''; - for (var i = 0; i < nvps.length; i++) { - options = options + this.template({ - text: nvps[i][0], - value: nvps[i][1], - selected: selectedValues.indexOf(nvps[i][1]) > -1 - }); - } - return options; - }, - - /** - Renders the options if `optionValues` is a list of name-value pairs. The - options are contained inside option groups if `optionValues` is a list of - object hashes. The name is rendered at the option text and the value is the - option value. If `optionValues` is a function, it is called without a - parameter. - */ - render: function () { - this.$el.empty(); - - var optionValues = _.result(this, "optionValues"); - var model = this.model; - var selectedValues = this.formatter.fromRaw(model.get(this.column.get("name")), model); - - if (!_.isArray(optionValues)) throw new TypeError("optionValues must be an array"); - - var optionValue = null; - var optionText = null; - var optionValue = null; - var optgroupName = null; - var optgroup = null; - - for (var i = 0; i < optionValues.length; i++) { - var optionValue = optionValues[i]; - - if (_.isArray(optionValue)) { - optionText = optionValue[0]; - optionValue = optionValue[1]; - - this.$el.append(this.template({ - text: optionText, - value: optionValue, - selected: selectedValues.indexOf(optionValue) > -1 - })); - } - else if (_.isObject(optionValue)) { - optgroupName = optionValue.name; - optgroup = $("<optgroup></optgroup>", { label: optgroupName }); - optgroup.append(this._renderOptions(optionValue.values, selectedValues)); - this.$el.append(optgroup); - } - else { - throw new TypeError("optionValues elements must be a name-value pair or an object hash of { name: 'optgroup label', value: [option name-value pairs] }"); - } - } - - this.delegateEvents(); - - return this; - }, - - /** - Saves the value of the selected option to the model attribute. Triggers a - `backgrid:edited` Backbone event from the model. - */ - save: function (e) { - var model = this.model; - var column = this.column; - model.set(column.get("name"), this.formatter.toRaw(this.$el.val(), model)); - model.trigger("backgrid:edited", model, column, new Command(e)); - }, - - /** - Triggers a `backgrid:edited` event from the model so the body can close - this editor. - */ - close: function (e) { - var model = this.model; - var column = this.column; - var command = new Command(e); - if (command.cancel()) { - e.stopPropagation(); - model.trigger("backgrid:edited", model, column, new Command(e)); - } - else if (command.save() || command.moveLeft() || command.moveRight() || - command.moveUp() || command.moveDown() || e.type == "blur") { - e.preventDefault(); - e.stopPropagation(); - if (e.type == "blur" && this.$el.find("option").length === 1) { - model.set(column.get("name"), this.formatter.toRaw(this.$el.val(), model)); - } - model.trigger("backgrid:edited", model, column, new Command(e)); - } - } - -}); - -/** - SelectCell is also a different kind of cell in that upon going into edit mode - the cell renders a list of options to pick from, as opposed to an input box. - - SelectCell cannot be referenced by its string name when used in a column - definition because it requires an `optionValues` class attribute to be - defined. `optionValues` can either be a list of name-value pairs, to be - rendered as options, or a list of object hashes which consist of a key *name* - which is the option group name, and a key *values* which is a list of - name-value pairs to be rendered as options under that option group. - - In addition, `optionValues` can also be a parameter-less function that - returns one of the above. If the options are static, it is recommended the - returned values to be memoized. `_.memoize()` is a good function to help with - that. - - During display mode, the default formatter will normalize the raw model value - to an array of values whether the raw model value is a scalar or an - array. Each value is compared with the `optionValues` values using - Ecmascript's implicit type conversion rules. When exiting edit mode, no type - conversion is performed when saving into the model. This behavior is not - always desirable when the value type is anything other than string. To - control type conversion on the client-side, you should subclass SelectCell to - provide a custom formatter or provide the formatter to your column - definition. - - See: - [$.fn.val()](http://api.jquery.com/val/) - - @class Backgrid.SelectCell - @extends Backgrid.Cell -*/ -var SelectCell = Backgrid.SelectCell = Cell.extend({ - - /** @property */ - className: "select-cell", - - /** @property */ - editor: SelectCellEditor, - - /** @property */ - multiple: false, - - /** @property */ - formatter: SelectFormatter, - - /** - @property {Array.<Array>|Array.<{name: string, values: Array.<Array>}>} optionValues - */ - optionValues: undefined, - - /** @property */ - delimiter: ', ', - - /** - Initializer. - - @param {Object} options - @param {Backbone.Model} options.model - @param {Backgrid.Column} options.column - - @throws {TypeError} If `optionsValues` is undefined. - */ - initialize: function (options) { - SelectCell.__super__.initialize.apply(this, arguments); - this.listenTo(this.model, "backgrid:edit", function (model, column, cell, editor) { - if (column.get("name") == this.column.get("name")) { - editor.setOptionValues(this.optionValues); - editor.setMultiple(this.multiple); - } - }); - }, - - /** - Renders the label using the raw value as key to look up from `optionValues`. - - @throws {TypeError} If `optionValues` is malformed. - */ - render: function () { - this.$el.empty(); - - var optionValues = _.result(this, "optionValues"); - var model = this.model; - var rawData = this.formatter.fromRaw(model.get(this.column.get("name")), model); - - var selectedText = []; - - try { - if (!_.isArray(optionValues) || _.isEmpty(optionValues)) throw new TypeError; - - for (var k = 0; k < rawData.length; k++) { - var rawDatum = rawData[k]; - - for (var i = 0; i < optionValues.length; i++) { - var optionValue = optionValues[i]; - - if (_.isArray(optionValue)) { - var optionText = optionValue[0]; - var optionValue = optionValue[1]; - - if (optionValue == rawDatum) selectedText.push(optionText); - } - else if (_.isObject(optionValue)) { - var optionGroupValues = optionValue.values; - - for (var j = 0; j < optionGroupValues.length; j++) { - var optionGroupValue = optionGroupValues[j]; - if (optionGroupValue[1] == rawDatum) { - selectedText.push(optionGroupValue[0]); - } - } - } - else { - throw new TypeError; - } - } - } - - this.$el.append(selectedText.join(this.delimiter)); - } - catch (ex) { - if (ex instanceof TypeError) { - throw new TypeError("'optionValues' must be of type {Array.<Array>|Array.<{name: string, values: Array.<Array>}>}"); - } - throw ex; - } - - this.delegateEvents(); - - return this; - } - -}); - -/* - backgrid - http://github.com/wyuenho/backgrid - - Copyright (c) 2013 Jimmy Yuen Ho Wong and contributors - Licensed under the MIT license. -*/ - -/** - A Column is a placeholder for column metadata. - - You usually don't need to create an instance of this class yourself as a - collection of column instances will be created for you from a list of column - attributes in the Backgrid.js view class constructors. - - @class Backgrid.Column - @extends Backbone.Model -*/ -var Column = Backgrid.Column = Backbone.Model.extend({ - - /** - @cfg {Object} defaults Column defaults. To override any of these default - values, you can either change the prototype directly to override - Column.defaults globally or extend Column and supply the custom class to - Backgrid.Grid: - - // Override Column defaults globally - Column.prototype.defaults.sortable = false; - - // Override Column defaults locally - var MyColumn = Column.extend({ - defaults: _.defaults({ - editable: false - }, Column.prototype.defaults) - }); - - var grid = new Backgrid.Grid(columns: new Columns([{...}, {...}], { - model: MyColumn - })); - - @cfg {string} [defaults.name] The default name of the model attribute. - - @cfg {string} [defaults.label] The default label to show in the header. - - @cfg {string|Backgrid.Cell} [defaults.cell] The default cell type. If this - is a string, the capitalized form will be used to look up a cell class in - Backbone, i.e.: string => StringCell. If a Cell subclass is supplied, it is - initialized with a hash of parameters. If a Cell instance is supplied, it - is used directly. - - @cfg {string|Backgrid.HeaderCell} [defaults.headerCell] The default header - cell type. - - @cfg {boolean|string} [defaults.sortable=true] Whether this column is - sortable. If the value is a string, a method will the same name will be - looked up from the column instance to determine whether the column should - be sortable. The method's signature must be `function (Backgrid.Column, - Backbone.Model): boolean`. - - @cfg {boolean|string} [defaults.editable=true] Whether this column is - editable. If the value is a string, a method will the same name will be - looked up from the column instance to determine whether the column should - be editable. The method's signature must be `function (Backgrid.Column, - Backbone.Model): boolean`. - - @cfg {boolean|string} [defaults.renderable=true] Whether this column is - renderable. If the value is a string, a method will the same name will be - looked up from the column instance to determine whether the column should - be renderable. The method's signature must be `function (Backrid.Column, - Backbone.Model): boolean`. - - @cfg {Backgrid.CellFormatter | Object | string} [defaults.formatter] The - formatter to use to convert between raw model values and user input. - - @cfg {"toggle"|"cycle"} [defaults.sortType="cycle"] Whether sorting will - toggle between ascending and descending order, or cycle between insertion - order, ascending and descending order. - - @cfg {(function(Backbone.Model, string): *) | string} [defaults.sortValue] - The function to use to extract a value from the model for comparison during - sorting. If this value is a string, a method with the same name will be - looked up from the column instance. - - @cfg {"ascending"|"descending"|null} [defaults.direction=null] The initial - sorting direction for this column. The default is ordered by - Backbone.Model.cid, which usually means the collection is ordered by - insertion order. - */ - defaults: { - name: undefined, - label: undefined, - sortable: true, - editable: true, - renderable: true, - formatter: undefined, - sortType: "cycle", - sortValue: undefined, - direction: null, - cell: undefined, - headerCell: undefined - }, - - /** - Initializes this Column instance. - - @param {Object} attrs - - @param {string} attrs.name The model attribute this column is responsible - for. - - @param {string|Backgrid.Cell} attrs.cell The cell type to use to render - this column. - - @param {string} [attrs.label] - - @param {string|Backgrid.HeaderCell} [attrs.headerCell] - - @param {boolean|string} [attrs.sortable=true] - - @param {boolean|string} [attrs.editable=true] - - @param {boolean|string} [attrs.renderable=true] - - @param {Backgrid.CellFormatter | Object | string} [attrs.formatter] - - @param {"toggle"|"cycle"} [attrs.sortType="cycle"] - - @param {(function(Backbone.Model, string): *) | string} [attrs.sortValue] - - @throws {TypeError} If attrs.cell or attrs.options are not supplied. - - @throws {ReferenceError} If formatter is a string but a formatter class of - said name cannot be found in the Backgrid module. - - See: - - - Backgrid.Column.defaults - - Backgrid.Cell - - Backgrid.CellFormatter - */ - initialize: function (attrs) { - if (!this.has("label")) { - this.set({ label: this.get("name") }, { silent: true }); - } - - var headerCell = Backgrid.resolveNameToClass(this.get("headerCell"), "HeaderCell"); - - var cell = Backgrid.resolveNameToClass(this.get("cell"), "Cell"); - - this.set({cell: cell, headerCell: headerCell}, { silent: true }); - }, - - /** - Returns an appropriate value extraction function from a model for sorting. - - If the column model contains an attribute `sortValue`, if it is a string, a - method from the column instance identifified by the `sortValue` string is - returned. If it is a function, it it returned as is. If `sortValue` isn't - found from the column model's attributes, a default value extraction - function is returned which will compare according to the natural order of - the value's type. - - @return {function(Backbone.Model, string): *} - */ - sortValue: function () { - var sortValue = this.get("sortValue"); - if (_.isString(sortValue)) return this[sortValue]; - else if (_.isFunction(sortValue)) return sortValue; - - return function (model, colName) { - return model.get(colName); - }; - } - - /** - @member Backgrid.Column - @protected - @method sortable - @return {function(Backgrid.Column, Backbone.Model): boolean | boolean} - */ - - /** - @member Backgrid.Column - @protected - @method editable - @return {function(Backgrid.Column, Backbone.Model): boolean | boolean} - */ - - /** - @member Backgrid.Column - @protected - @method renderable - @return {function(Backgrid.Column, Backbone.Model): boolean | boolean} - */ -}); - -_.each(["sortable", "renderable", "editable"], function (key) { - Column.prototype[key] = function () { - var value = this.get(key); - if (_.isString(value)) return this[value]; - return !!value; - }; -}); - -/** - A Backbone collection of Column instances. - - @class Backgrid.Columns - @extends Backbone.Collection - */ -var Columns = Backgrid.Columns = Backbone.Collection.extend({ - - /** - @property {Backgrid.Column} model - */ - model: Column -}); - -/* - backgrid - http://github.com/wyuenho/backgrid - - Copyright (c) 2013 Jimmy Yuen Ho Wong and contributors - Licensed under the MIT license. -*/ - -/** - Row is a simple container view that takes a model instance and a list of - column metadata describing how each of the model's attribute is to be - rendered, and apply the appropriate cell to each attribute. - - @class Backgrid.Row - @extends Backbone.View -*/ -var Row = Backgrid.Row = Backbone.View.extend({ - - /** @property */ - tagName: "tr", - - /** - Initializes a row view instance. - - @param {Object} options - @param {Backbone.Collection.<Backgrid.Column>|Array.<Backgrid.Column>|Array.<Object>} options.columns Column metadata. - @param {Backbone.Model} options.model The model instance to render. - - @throws {TypeError} If options.columns or options.model is undefined. - */ - initialize: function (options) { - - var columns = this.columns = options.columns; - if (!(columns instanceof Backbone.Collection)) { - columns = this.columns = new Columns(columns); - } - - var cells = this.cells = []; - for (var i = 0; i < columns.length; i++) { - cells.push(this.makeCell(columns.at(i), options)); - } - - this.listenTo(columns, "add", function (column, columns) { - var i = columns.indexOf(column); - var cell = this.makeCell(column, options); - cells.splice(i, 0, cell); - - var $el = this.$el; - if (i === 0) { - $el.prepend(cell.render().$el); - } - else if (i === columns.length - 1) { - $el.append(cell.render().$el); - } - else { - $el.children().eq(i).before(cell.render().$el); - } - }); - - this.listenTo(columns, "remove", function (column, columns, opts) { - cells[opts.index].remove(); - cells.splice(opts.index, 1); - }); - }, - - /** - Factory method for making a cell. Used by #initialize internally. Override - this to provide an appropriate cell instance for a custom Row subclass. - - @protected - - @param {Backgrid.Column} column - @param {Object} options The options passed to #initialize. - - @return {Backgrid.Cell} - */ - makeCell: function (column) { - return new (column.get("cell"))({ - column: column, - model: this.model - }); - }, - - /** - Renders a row of cells for this row's model. - */ - render: function () { - this.$el.empty(); - - var fragment = document.createDocumentFragment(); - for (var i = 0; i < this.cells.length; i++) { - fragment.appendChild(this.cells[i].render().el); - } - - this.el.appendChild(fragment); - - this.delegateEvents(); - - return this; - }, - - /** - Clean up this row and its cells. - - @chainable - */ - remove: function () { - for (var i = 0; i < this.cells.length; i++) { - var cell = this.cells[i]; - cell.remove.apply(cell, arguments); - } - return Backbone.View.prototype.remove.apply(this, arguments); - } - -}); - -/** - EmptyRow is a simple container view that takes a list of column and render a - row with a single column. - - @class Backgrid.EmptyRow - @extends Backbone.View -*/ -var EmptyRow = Backgrid.EmptyRow = Backbone.View.extend({ - - /** @property */ - tagName: "tr", - - /** @property */ - emptyText: null, - - /** - Initializer. - - @param {Object} options - @param {string} options.emptyText - @param {Backbone.Collection.<Backgrid.Column>|Array.<Backgrid.Column>|Array.<Object>} options.columns Column metadata. - */ - initialize: function (options) { - this.emptyText = options.emptyText; - this.columns = options.columns; - }, - - /** - Renders an empty row. - */ - render: function () { - this.$el.empty(); - - var td = document.createElement("td"); - td.setAttribute("colspan", this.columns.length); - td.textContent = this.emptyText; - - this.el.setAttribute("class", "empty"); - this.el.appendChild(td); - - return this; - } -}); - -/* - backgrid - http://github.com/wyuenho/backgrid - - Copyright (c) 2013 Jimmy Yuen Ho Wong and contributors - Licensed under the MIT license. -*/ - -/** - HeaderCell is a special cell class that renders a column header cell. If the - column is sortable, a sorter is also rendered and will trigger a table - refresh after sorting. - - @class Backgrid.HeaderCell - @extends Backbone.View - */ -var HeaderCell = Backgrid.HeaderCell = Backbone.View.extend({ - - /** @property */ - tagName: "th", - - /** @property */ - events: { - "click a": "onClick" - }, - - /** - Initializer. - - @param {Object} options - @param {Backgrid.Column|Object} options.column - - @throws {TypeError} If options.column or options.collection is undefined. - */ - initialize: function (options) { - this.column = options.column; - if (!(this.column instanceof Column)) { - this.column = new Column(this.column); - } - - this.listenTo(this.collection, "backgrid:sort", this._resetCellDirection); - - var column = this.column, $el = this.$el; - - this.listenTo(column, "change:editable change:sortable change:renderable", - function (column) { - var changed = column.changedAttributes(); - for (var key in changed) { - if (changed.hasOwnProperty(key)) { - $el.toggleClass(key, changed[key]); - } - } - }); - - this.listenTo(column, "change:name change:label", this.render); - - if (column.get("editable")) $el.addClass("editable"); - if (column.get("sortable")) $el.addClass("sortable"); - if (column.get("renderable")) $el.addClass("renderable"); - }, - - /** - Gets or sets the direction of this cell. If called directly without - parameters, returns the current direction of this cell, otherwise sets - it. If a `null` is given, sets this cell back to the default order. - - @param {null|"ascending"|"descending"} dir - @return {null|string} The current direction or the changed direction. - */ - direction: function (dir) { - if (arguments.length) { - var direction = this.column.get('direction'); - if (direction) this.$el.removeClass(direction); - if (dir) this.$el.addClass(dir); - this.column.set('direction', dir) - } - - return this.column.get('direction'); - }, - - /** - Event handler for the Backbone `backgrid:sort` event. Resets this cell's - direction to default if sorting is being done on another column. - - @private - */ - _resetCellDirection: function (columnToSort, direction) { - if (columnToSort !== this.column) this.direction(null); - else this.direction(direction); - }, - - /** - Event handler for the `click` event on the cell's anchor. If the column is - sortable, clicking on the anchor will cycle through 3 sorting orderings - - `ascending`, `descending`, and default. - */ - onClick: function (e) { - e.preventDefault(); - - var collection = this.collection, event = "backgrid:sort"; - - function cycleSort(header, col) { - if (header.direction() === "ascending") collection.trigger(event, col, "descending"); - else if (header.direction() === "descending") collection.trigger(event, col, null); - else collection.trigger(event, col, "ascending"); - } - - function toggleSort(header, col) { - if (header.direction() === "ascending") collection.trigger(event, col, "descending"); - else collection.trigger(event, col, "ascending"); - } - - var column = this.column; - var sortable = Backgrid.callByNeed(column.sortable(), column, this.collection); - if (sortable) { - var sortType = column.get("sortType"); - if (sortType === "toggle") toggleSort(this, column); - else cycleSort(this, column); - } - }, - - /** - Renders a header cell with a sorter, a label, and a class name for this - column. - */ - render: function () { - this.$el.empty(); - var column = this.column; - var $label = $("<a>").text(column.get("label")); - var sortable = Backgrid.callByNeed(column.sortable(), column, this.collection); - if (sortable) $label.append("<b class='sort-caret'></b>"); - this.$el.append($label); - this.$el.addClass(column.get("name")); - this.delegateEvents(); - this.direction(column.get("direction")); - return this; -} - -}); - -/** - HeaderRow is a controller for a row of header cells. - - @class Backgrid.HeaderRow - @extends Backgrid.Row - */ -var HeaderRow = Backgrid.HeaderRow = Backgrid.Row.extend({ - - requiredOptions: ["columns", "collection"], - - /** - Initializer. - - @param {Object} options - @param {Backbone.Collection.<Backgrid.Column>|Array.<Backgrid.Column>|Array.<Object>} options.columns - @param {Backgrid.HeaderCell} [options.headerCell] Customized default - HeaderCell for all the columns. Supply a HeaderCell class or instance to a - the `headerCell` key in a column definition for column-specific header - rendering. - - @throws {TypeError} If options.columns or options.collection is undefined. - */ - initialize: function () { - Backgrid.Row.prototype.initialize.apply(this, arguments); - }, - - makeCell: function (column, options) { - var headerCell = column.get("headerCell") || options.headerCell || HeaderCell; - headerCell = new headerCell({ - column: column, - collection: this.collection - }); - return headerCell; - } - -}); - -/** - Header is a special structural view class that renders a table head with a - single row of header cells. - - @class Backgrid.Header - @extends Backbone.View - */ -var Header = Backgrid.Header = Backbone.View.extend({ - - /** @property */ - tagName: "thead", - - /** - Initializer. Initializes this table head view to contain a single header - row view. - - @param {Object} options - @param {Backbone.Collection.<Backgrid.Column>|Array.<Backgrid.Column>|Array.<Object>} options.columns Column metadata. - @param {Backbone.Model} options.model The model instance to render. - - @throws {TypeError} If options.columns or options.model is undefined. - */ - initialize: function (options) { - this.columns = options.columns; - if (!(this.columns instanceof Backbone.Collection)) { - this.columns = new Columns(this.columns); - } - - this.row = new Backgrid.HeaderRow({ - columns: this.columns, - collection: this.collection - }); - }, - - /** - Renders this table head with a single row of header cells. - */ - render: function () { - this.$el.append(this.row.render().$el); - this.delegateEvents(); - return this; - }, - - /** - Clean up this header and its row. - - @chainable - */ - remove: function () { - this.row.remove.apply(this.row, arguments); - return Backbone.View.prototype.remove.apply(this, arguments); - } - -}); - -/* - backgrid - http://github.com/wyuenho/backgrid - - Copyright (c) 2013 Jimmy Yuen Ho Wong and contributors - Licensed under the MIT license. -*/ - -/** - Body is the table body which contains the rows inside a table. Body is - responsible for refreshing the rows after sorting, insertion and removal. - - @class Backgrid.Body - @extends Backbone.View -*/ -var Body = Backgrid.Body = Backbone.View.extend({ - - /** @property */ - tagName: "tbody", - - /** - Initializer. - - @param {Object} options - @param {Backbone.Collection} options.collection - @param {Backbone.Collection.<Backgrid.Column>|Array.<Backgrid.Column>|Array.<Object>} options.columns - Column metadata. - @param {Backgrid.Row} [options.row=Backgrid.Row] The Row class to use. - @param {string} [options.emptyText] The text to display in the empty row. - - @throws {TypeError} If options.columns or options.collection is undefined. - - See Backgrid.Row. - */ - initialize: function (options) { - - this.columns = options.columns; - if (!(this.columns instanceof Backbone.Collection)) { - this.columns = new Columns(this.columns); - } - - this.row = options.row || Row; - this.rows = this.collection.map(function (model) { - var row = new this.row({ - columns: this.columns, - model: model - }); - - return row; - }, this); - - this.emptyText = options.emptyText; - this._unshiftEmptyRowMayBe(); - - var collection = this.collection; - this.listenTo(collection, "add", this.insertRow); - this.listenTo(collection, "remove", this.removeRow); - this.listenTo(collection, "sort", this.refresh); - this.listenTo(collection, "reset", this.refresh); - this.listenTo(collection, "backgrid:sort", this.sort); - this.listenTo(collection, "backgrid:edited", this.moveToNextCell); - }, - - _unshiftEmptyRowMayBe: function () { - if (this.rows.length === 0 && this.emptyText != null) { - this.rows.unshift(new EmptyRow({ - emptyText: this.emptyText, - columns: this.columns - })); - } - }, - - /** - This method can be called either directly or as a callback to a - [Backbone.Collecton#add](http://backbonejs.org/#Collection-add) event. - - When called directly, it accepts a model or an array of models and an - option hash just like - [Backbone.Collection#add](http://backbonejs.org/#Collection-add) and - delegates to it. Once the model is added, a new row is inserted into the - body and automatically rendered. - - When called as a callback of an `add` event, splices a new row into the - body and renders it. - - @param {Backbone.Model} model The model to render as a row. - @param {Backbone.Collection} collection When called directly, this - parameter is actually the options to - [Backbone.Collection#add](http://backbonejs.org/#Collection-add). - @param {Object} options When called directly, this must be null. - - See: - - - [Backbone.Collection#add](http://backbonejs.org/#Collection-add) - */ - insertRow: function (model, collection, options) { - - if (this.rows[0] instanceof EmptyRow) this.rows.pop().remove(); - - // insertRow() is called directly - if (!(collection instanceof Backbone.Collection) && !options) { - this.collection.add(model, (options = collection)); - return; - } - - options = _.extend({render: true}, options || {}); - - var row = new this.row({ - columns: this.columns, - model: model - }); - - var index = collection.indexOf(model); - this.rows.splice(index, 0, row); - - var $el = this.$el; - var $children = $el.children(); - var $rowEl = row.render().$el; - - if (options.render) { - if (index >= $children.length) { - $el.append($rowEl); - } - else { - $children.eq(index).before($rowEl); - } - } - - return this; - }, - - /** - The method can be called either directly or as a callback to a - [Backbone.Collection#remove](http://backbonejs.org/#Collection-remove) - event. - - When called directly, it accepts a model or an array of models and an - option hash just like - [Backbone.Collection#remove](http://backbonejs.org/#Collection-remove) and - delegates to it. Once the model is removed, a corresponding row is removed - from the body. - - When called as a callback of a `remove` event, splices into the rows and - removes the row responsible for rendering the model. - - @param {Backbone.Model} model The model to remove from the body. - @param {Backbone.Collection} collection When called directly, this - parameter is actually the options to - [Backbone.Collection#remove](http://backbonejs.org/#Collection-remove). - @param {Object} options When called directly, this must be null. - - See: - - - [Backbone.Collection#remove](http://backbonejs.org/#Collection-remove) - */ - removeRow: function (model, collection, options) { - - // removeRow() is called directly - if (!options) { - this.collection.remove(model, (options = collection)); - this._unshiftEmptyRowMayBe(); - return; - } - - if (_.isUndefined(options.render) || options.render) { - this.rows[options.index].remove(); - } - - this.rows.splice(options.index, 1); - this._unshiftEmptyRowMayBe(); - - return this; - }, - - /** - Reinitialize all the rows inside the body and re-render them. Triggers a - Backbone `backgrid:refresh` event from the collection along with the body - instance as its sole parameter when done. - */ - refresh: function () { - for (var i = 0; i < this.rows.length; i++) { - this.rows[i].remove(); - } - - this.rows = this.collection.map(function (model) { - var row = new this.row({ - columns: this.columns, - model: model - }); - - return row; - }, this); - this._unshiftEmptyRowMayBe(); - - this.render(); - - this.collection.trigger("backgrid:refresh", this); - - return this; - }, - - /** - Renders all the rows inside this body. If the collection is empty and - `options.emptyText` is defined and not null in the constructor, an empty - row is rendered, otherwise no row is rendered. - */ - render: function () { - this.$el.empty(); - - var fragment = document.createDocumentFragment(); - for (var i = 0; i < this.rows.length; i++) { - var row = this.rows[i]; - fragment.appendChild(row.render().el); - } - - this.el.appendChild(fragment); - - this.delegateEvents(); - - return this; - }, - - /** - Clean up this body and it's rows. - - @chainable - */ - remove: function () { - for (var i = 0; i < this.rows.length; i++) { - var row = this.rows[i]; - row.remove.apply(row, arguments); - } - return Backbone.View.prototype.remove.apply(this, arguments); - }, - - /** - If the underlying collection is a Backbone.PageableCollection in - server-mode or infinite-mode, a page of models is fetched after sorting is - done on the server. - - If the underlying collection is a Backbone.PageableCollection in - client-mode, or any - [Backbone.Collection](http://backbonejs.org/#Collection) instance, sorting - is done on the client side. If the collection is an instance of a - Backbone.PageableCollection, sorting will be done globally on all the pages - and the current page will then be returned. - - Triggers a Backbone `backgrid:sort` event from the collection when done - with the column, direction, comparator and a reference to the collection. - - @param {Backgrid.Column} column - @param {null|"ascending"|"descending"} direction - - See [Backbone.Collection#comparator](http://backbonejs.org/#Collection-comparator) - */ - sort: function (column, direction) { - - if (_.isString(column)) column = this.columns.findWhere({name: column}); - - var collection = this.collection; - - var order; - if (direction === "ascending") order = -1; - else if (direction === "descending") order = 1; - else order = null; - - var comparator = this.makeComparator(column.get("name"), order, - order ? - column.sortValue() : - function (model) { - return model.cid; - }); - - if (Backbone.PageableCollection && - collection instanceof Backbone.PageableCollection) { - - collection.setSorting(order && column.get("name"), order, - {sortValue: column.sortValue()}); - - if (collection.mode == "client") { - if (collection.fullCollection.comparator == null) { - collection.fullCollection.comparator = comparator; - } - collection.fullCollection.sort(); - } - else collection.fetch({reset: true}); - } - else { - collection.comparator = comparator; - collection.sort(); - } - - return this; - }, - - makeComparator: function (attr, order, func) { - - return function (left, right) { - // extract the values from the models - var l = func(left, attr), r = func(right, attr), t; - - // if descending order, swap left and right - if (order === 1) t = l, l = r, r = t; - - // compare as usual - if (l === r) return 0; - else if (l < r) return -1; - return 1; - }; - }, - - /** - Moves focus to the next renderable and editable cell and return the - currently editing cell to display mode. - - @param {Backbone.Model} model The originating model - @param {Backgrid.Column} column The originating model column - @param {Backgrid.Command} command The Command object constructed from a DOM - Event - */ - moveToNextCell: function (model, column, command) { - var i = this.collection.indexOf(model); - var j = this.columns.indexOf(column); - var cell, renderable, editable; - - this.rows[i].cells[j].exitEditMode(); - - if (command.moveUp() || command.moveDown() || command.moveLeft() || - command.moveRight() || command.save()) { - var l = this.columns.length; - var maxOffset = l * this.collection.length; - - if (command.moveUp() || command.moveDown()) { - var row = this.rows[i + (command.moveUp() ? -1 : 1)]; - if (row) { - cell = row.cells[j]; - if (Backgrid.callByNeed(cell.column.editable(), cell.column, model)) { - cell.enterEditMode(); - } - } - } - else if (command.moveLeft() || command.moveRight()) { - var right = command.moveRight(); - for (var offset = i * l + j + (right ? 1 : -1); - offset >= 0 && offset < maxOffset; - right ? offset++ : offset--) { - var m = ~~(offset / l); - var n = offset - m * l; - cell = this.rows[m].cells[n]; - renderable = Backgrid.callByNeed(cell.column.renderable(), cell.column, cell.model); - editable = Backgrid.callByNeed(cell.column.editable(), cell.column, model); - if (renderable && editable) { - cell.enterEditMode(); - break; - } - } - } - } - - return this; - } -}); - -/* - backgrid - http://github.com/wyuenho/backgrid - - Copyright (c) 2013 Jimmy Yuen Ho Wong and contributors - Licensed under the MIT license. -*/ - -/** - A Footer is a generic class that only defines a default tag `tfoot` and - number of required parameters in the initializer. - - @abstract - @class Backgrid.Footer - @extends Backbone.View - */ -var Footer = Backgrid.Footer = Backbone.View.extend({ - - /** @property */ - tagName: "tfoot", - - /** - Initializer. - - @param {Object} options - @param {Backbone.Collection.<Backgrid.Column>|Array.<Backgrid.Column>|Array.<Object>} options.columns - Column metadata. - @param {Backbone.Collection} options.collection - - @throws {TypeError} If options.columns or options.collection is undefined. - */ - initialize: function (options) { - this.columns = options.columns; - if (!(this.columns instanceof Backbone.Collection)) { - this.columns = new Backgrid.Columns(this.columns); - } - } - -}); - -/* - backgrid - http://github.com/wyuenho/backgrid - - Copyright (c) 2013 Jimmy Yuen Ho Wong and contributors - Licensed under the MIT license. -*/ - -/** - Grid represents a data grid that has a header, body and an optional footer. - - By default, a Grid treats each model in a collection as a row, and each - attribute in a model as a column. To render a grid you must provide a list of - column metadata and a collection to the Grid constructor. Just like any - Backbone.View class, the grid is rendered as a DOM node fragment when you - call render(). - - var grid = Backgrid.Grid({ - columns: [{ name: "id", label: "ID", type: "string" }, - // ... - ], - collections: books - }); - - $("#table-container").append(grid.render().el); - - Optionally, if you want to customize the rendering of the grid's header and - footer, you may choose to extend Backgrid.Header and Backgrid.Footer, and - then supply that class or an instance of that class to the Grid constructor. - See the documentation for Header and Footer for further details. - - var grid = Backgrid.Grid({ - columns: [{ name: "id", label: "ID", type: "string" }], - collections: books, - header: Backgrid.Header.extend({ - //... - }), - footer: Backgrid.Paginator - }); - - Finally, if you want to override how the rows are rendered in the table body, - you can supply a Body subclass as the `body` attribute that uses a different - Row class. - - @class Backgrid.Grid - @extends Backbone.View - - See: - - - Backgrid.Column - - Backgrid.Header - - Backgrid.Body - - Backgrid.Row - - Backgrid.Footer -*/ -var Grid = Backgrid.Grid = Backbone.View.extend({ - - /** @property */ - tagName: "table", - - /** @property */ - className: "backgrid", - - /** @property */ - header: Header, - - /** @property */ - body: Body, - - /** @property */ - footer: null, - - /** - Initializes a Grid instance. - - @param {Object} options - @param {Backbone.Collection.<Backgrid.Columns>|Array.<Backgrid.Column>|Array.<Object>} options.columns Column metadata. - @param {Backbone.Collection} options.collection The collection of tabular model data to display. - @param {Backgrid.Header} [options.header=Backgrid.Header] An optional Header class to override the default. - @param {Backgrid.Body} [options.body=Backgrid.Body] An optional Body class to override the default. - @param {Backgrid.Row} [options.row=Backgrid.Row] An optional Row class to override the default. - @param {Backgrid.Footer} [options.footer=Backgrid.Footer] An optional Footer class. - */ - initialize: function (options) { - // Convert the list of column objects here first so the subviews don't have - // to. - if (!(options.columns instanceof Backbone.Collection)) { - options.columns = new Columns(options.columns); - } - this.columns = options.columns; - - var filteredOptions = _.omit(options, ["el", "id", "attributes", - "className", "tagName", "events"]); - - // must construct body first so it listens to backgrid:sort first - this.body = options.body || this.body; - this.body = new this.body(filteredOptions); - - this.header = options.header || this.header; - if (this.header) { - this.header = new this.header(filteredOptions); - } - - this.footer = options.footer || this.footer; - if (this.footer) { - this.footer = new this.footer(filteredOptions); - } - - this.listenTo(this.columns, "reset", function () { - if (this.header) { - this.header = new (this.header.remove().constructor)(filteredOptions); - } - this.body = new (this.body.remove().constructor)(filteredOptions); - if (this.footer) { - this.footer = new (this.footer.remove().constructor)(filteredOptions); - } - this.render(); - }); - }, - - /** - Delegates to Backgrid.Body#insertRow. - */ - insertRow: function (model, collection, options) { - this.body.insertRow(model, collection, options); - return this; - }, - - /** - Delegates to Backgrid.Body#removeRow. - */ - removeRow: function (model, collection, options) { - this.body.removeRow(model, collection, options); - return this; - }, - - /** - Delegates to Backgrid.Columns#add for adding a column. Subviews can listen - to the `add` event from their internal `columns` if rerendering needs to - happen. - - @param {Object} [options] Options for `Backgrid.Columns#add`. - @param {boolean} [options.render=true] Whether to render the column - immediately after insertion. - */ - insertColumn: function (column, options) { - options = options || {render: true}; - this.columns.add(column, options); - return this; - }, - - /** - Delegates to Backgrid.Columns#remove for removing a column. Subviews can - listen to the `remove` event from the internal `columns` if rerendering - needs to happen. - - @param {Object} [options] Options for `Backgrid.Columns#remove`. - */ - removeColumn: function (column, options) { - this.columns.remove(column, options); - return this; - }, - - /** - Delegates to Backgrid.Body#sort. - */ - sort: function () { - this.body.sort(arguments); - return this; - }, - - /** - Renders the grid's header, then footer, then finally the body. Triggers a - Backbone `backgrid:rendered` event along with a reference to the grid when - the it has successfully been rendered. - */ - render: function () { - this.$el.empty(); - - if (this.header) { - this.$el.append(this.header.render().$el); - } - - if (this.footer) { - this.$el.append(this.footer.render().$el); - } - - this.$el.append(this.body.render().$el); - - this.delegateEvents(); - - this.trigger("backgrid:rendered", this); - - return this; - }, - - /** - Clean up this grid and its subviews. - - @chainable - */ - remove: function () { - this.header && this.header.remove.apply(this.header, arguments); - this.body.remove.apply(this.body, arguments); - this.footer && this.footer.remove.apply(this.footer, arguments); - return Backbone.View.prototype.remove.apply(this, arguments); - } - -}); -return Backgrid; -})); \ No newline at end of file diff --git a/src/UI/JsLibraries/backbone.backgrid.paginator.js b/src/UI/JsLibraries/backbone.backgrid.paginator.js deleted file mode 100644 index 03255f84d..000000000 --- a/src/UI/JsLibraries/backbone.backgrid.paginator.js +++ /dev/null @@ -1,352 +0,0 @@ -/* - backgrid-paginator - http://github.com/wyuenho/backgrid - - Copyright (c) 2013 Jimmy Yuen Ho Wong and contributors - Licensed under the MIT @license. -*/ -(function (factory) { - - // CommonJS - if (typeof exports == "object") { - module.exports = factory(require("underscore"), - require("backbone"), - require("backgrid"), - require("backbone-pageable")); - } - // Browser - else if (typeof _ !== "undefined" && - typeof Backbone !== "undefined" && - typeof Backgrid !== "undefined") { - factory(_, Backbone, Backgrid); - } - -}(function (_, Backbone, Backgrid) { - - "use strict"; - - /** - PageHandle is a class that renders the actual page handles and reacts to - click events for pagination. - - This class acts in two modes - control or discrete page handle modes. If - one of the `is*` flags is `true`, an instance of this class is under - control page handle mode. Setting a `pageIndex` to an instance of this - class under control mode has no effect and the correct page index will - always be inferred from the `is*` flag. Only one of the `is*` flags should - be set to `true` at a time. For example, an instance of this class cannot - simultaneously be a rewind control and a fast forward control. A `label` - and a `title` template or a string are required to be passed to the - constuctor under this mode. If a `title` template is provided, it __MUST__ - accept a parameter `label`. When the `label` is provided to the `title` - template function, its result will be used to render the generated anchor's - title attribute. - - If all of the `is*` flags is set to `false`, which is the default, an - instance of this class will be in discrete page handle mode. An instance - under this mode requires the `pageIndex` to be passed from the constructor - as an option and it __MUST__ be a 0-based index of the list of page numbers - to render. The constuctor will normalize the base to the same base the - underlying PageableCollection collection instance uses. A `label` is not - required under this mode, which will default to the equivalent 1-based page - index calculated from `pageIndex` and the underlying PageableCollection - instance. A provided `label` will still be honored however. The `title` - parameter is also not required under this mode, in which case the default - `title` template will be used. You are encouraged to provide your own - `title` template however if you wish to localize the title strings. - - If this page handle represents the current page, an `active` class will be - placed on the root list element. - - if this page handle is at the border of the list of pages, a `disabled` - class will be placed on the root list element. - - Only page handles that are neither `active` nor `disabled` will respond to - click events and triggers pagination. - - @class Backgrid.Extension.PageHandle - */ - var PageHandle = Backgrid.Extension.PageHandle = Backbone.View.extend({ - - /** @property */ - tagName: "li", - - /** @property */ - events: { - "click a": "changePage" - }, - - /** - @property {string|function(Object.<string, string>): string} title - The title to use for the `title` attribute of the generated page handle - anchor elements. It can be a string or an Underscore template function - that takes a mandatory `label` parameter. - */ - title: _.template('Page <%- label %>', null, {variable: null}), - - /** - @property {boolean} isRewind Whether this handle represents a rewind - control - */ - isRewind: false, - - /** - @property {boolean} isBack Whether this handle represents a back - control - */ - isBack: false, - - /** - @property {boolean} isForward Whether this handle represents a forward - control - */ - isForward: false, - - /** - @property {boolean} isFastForward Whether this handle represents a fast - forward control - */ - isFastForward: false, - - /** - Initializer. - - @param {Object} options - @param {Backbone.Collection} options.collection - @param {number} pageIndex 0-based index of the page number this handle - handles. This parameter will be normalized to the base the underlying - PageableCollection uses. - @param {string} [options.label] If provided it is used to render the - anchor text, otherwise the normalized pageIndex will be used - instead. Required if any of the `is*` flags is set to `true`. - @param {string} [options.title] - @param {boolean} [options.isRewind=false] - @param {boolean} [options.isBack=false] - @param {boolean} [options.isForward=false] - @param {boolean} [options.isFastForward=false] - */ - initialize: function (options) { - Backbone.View.prototype.initialize.apply(this, arguments); - - var collection = this.collection; - var state = collection.state; - var currentPage = state.currentPage; - var firstPage = state.firstPage; - var lastPage = state.lastPage; - - _.extend(this, _.pick(options, - ["isRewind", "isBack", "isForward", "isFastForward"])); - - var pageIndex; - if (this.isRewind) pageIndex = firstPage; - else if (this.isBack) pageIndex = Math.max(firstPage, currentPage - 1); - else if (this.isForward) pageIndex = Math.min(lastPage, currentPage + 1); - else if (this.isFastForward) pageIndex = lastPage; - else { - pageIndex = +options.pageIndex; - pageIndex = (firstPage ? pageIndex + 1 : pageIndex); - } - this.pageIndex = pageIndex; - - if (((this.isRewind || this.isBack) && currentPage == firstPage) || - ((this.isForward || this.isFastForward) && currentPage == lastPage)) { - this.$el.addClass("disabled"); - } - else if (!(this.isRewind || - this.isBack || - this.isForward || - this.isFastForward) && - currentPage == pageIndex) { - this.$el.addClass("active"); - } - - this.label = (options.label || (firstPage ? pageIndex : pageIndex + 1)) + ''; - var title = options.title || this.title; - this.title = _.isFunction(title) ? title({label: this.label}) : title; - }, - - /** - Renders a clickable anchor element under a list item. - */ - render: function () { - this.$el.empty(); - var anchor = document.createElement("a"); - anchor.href = '#'; - if (this.title) anchor.title = this.title; - anchor.innerHTML = this.label; - this.el.appendChild(anchor); - this.delegateEvents(); - return this; - }, - - /** - jQuery click event handler. Goes to the page this PageHandle instance - represents. No-op if this page handle is currently active or disabled. - */ - changePage: function (e) { - e.preventDefault(); - var $el = this.$el; - if (!$el.hasClass("active") && !$el.hasClass("disabled")) { - this.collection.getPage(this.pageIndex); - } - return this; - } - - }); - - /** - Paginator is a Backgrid extension that renders a series of configurable - pagination handles. This extension is best used for splitting a large data - set across multiple pages. If the number of pages is larger then a - threshold, which is set to 10 by default, the page handles are rendered - within a sliding window, plus the rewind, back, forward and fast forward - control handles. The individual control handles can be turned off. - - @class Backgrid.Extension.Paginator - */ - Backgrid.Extension.Paginator = Backbone.View.extend({ - - /** @property */ - className: "backgrid-paginator", - - /** @property */ - windowSize: 10, - - /** - @property {Object.<string, Object.<string, string>>} controls You can - disable specific control handles by omitting certain keys. - */ - controls: { - rewind: { - label: "《", - title: "First" - }, - back: { - label: "〈", - title: "Previous" - }, - forward: { - label: "〉", - title: "Next" - }, - fastForward: { - label: "》", - title: "Last" - } - }, - - /** - @property {Backgrid.Extension.PageHandle} pageHandle. The PageHandle - class to use for rendering individual handles - */ - pageHandle: PageHandle, - - /** @property */ - goBackFirstOnSort: true, - - /** - Initializer. - - @param {Object} options - @param {Backbone.Collection} options.collection - @param {boolean} [options.controls] - @param {boolean} [options.pageHandle=Backgrid.Extension.PageHandle] - @param {boolean} [options.goBackFirstOnSort=true] - */ - initialize: function (options) { - this.controls = options.controls || this.controls; - this.pageHandle = options.pageHandle || this.pageHandle; - - var collection = this.collection; - this.listenTo(collection, "add", this.render); - this.listenTo(collection, "remove", this.render); - this.listenTo(collection, "reset", this.render); - if ((options.goBackFirstOnSort || this.goBackFirstOnSort) && - collection.fullCollection) { - this.listenTo(collection.fullCollection, "sort", function () { - collection.getFirstPage(); - }); - } - }, - - _calculateWindow: function () { - var collection = this.collection; - var state = collection.state; - - // convert all indices to 0-based here - var firstPage = state.firstPage; - var lastPage = +state.lastPage; - lastPage = Math.max(0, firstPage ? lastPage - 1 : lastPage); - var currentPage = Math.max(state.currentPage, state.firstPage); - currentPage = firstPage ? currentPage - 1 : currentPage; - var windowStart = Math.floor(currentPage / this.windowSize) * this.windowSize; - var windowEnd = Math.min(lastPage + 1, windowStart + this.windowSize); - return [windowStart, windowEnd]; - }, - - /** - Creates a list of page handle objects for rendering. - - @return {Array.<Object>} an array of page handle objects hashes - */ - makeHandles: function () { - - var handles = []; - var collection = this.collection; - - var window = this._calculateWindow(); - var winStart = window[0], winEnd = window[1]; - - for (var i = winStart; i < winEnd; i++) { - handles.push(new this.pageHandle({ - collection: collection, - pageIndex: i - })); - } - - var controls = this.controls; - _.each(["back", "rewind", "forward", "fastForward"], function (key) { - var value = controls[key]; - if (value) { - var handleCtorOpts = { - collection: collection, - title: value.title, - label: value.label - }; - handleCtorOpts["is" + key.slice(0, 1).toUpperCase() + key.slice(1)] = true; - var handle = new this.pageHandle(handleCtorOpts); - if (key == "rewind" || key == "back") handles.unshift(handle); - else handles.push(handle); - } - }, this); - - return handles; - }, - - /** - Render the paginator handles inside an unordered list. - */ - render: function () { - this.$el.empty(); - - if (this.handles) { - for (var i = 0, l = this.handles.length; i < l; i++) { - this.handles[i].remove(); - } - } - - var handles = this.handles = this.makeHandles(); - - var ul = document.createElement("ul"); - for (var i = 0; i < handles.length; i++) { - ul.appendChild(handles[i].render().el); - } - - this.el.appendChild(ul); - - return this; - } - - }); - -})); diff --git a/src/UI/JsLibraries/backbone.backgrid.selectall.js b/src/UI/JsLibraries/backbone.backgrid.selectall.js deleted file mode 100644 index 7d36c73ae..000000000 --- a/src/UI/JsLibraries/backbone.backgrid.selectall.js +++ /dev/null @@ -1,243 +0,0 @@ -/* - backgrid-select-all - http://github.com/wyuenho/backgrid - - Copyright (c) 2013 Jimmy Yuen Ho Wong and contributors - Licensed under the MIT @license. -*/ -(function (factory) { - - // CommonJS - if (typeof exports == "object") { - module.exports = factory(require("backbone"), require("backgrid")); - } - // Browser - else if (typeof Backbone !== "undefined" && typeof Backgrid !== "undefined") { - factory(Backbone, Backgrid); - } - -}(function (Backbone, Backgrid) { - - "use strict"; - - var $ = Backbone.$; - - /** - Renders a checkbox for row selection. - - @class Backgrid.Extension.SelectRowCell - @extends Backbone.View - */ - var SelectRowCell = Backgrid.Extension.SelectRowCell = Backbone.View.extend({ - - /** @property */ - className: "select-row-cell", - - /** @property */ - tagName: "td", - - /** @property */ - events: { - "keydown :checkbox": "onKeydown", - "change :checkbox": "onChange", - "click :checkbox": "enterEditMode" - }, - - /** - Initializer. If the underlying model triggers a `select` event, this cell - will change its checked value according to the event's `selected` value. - - @param {Object} options - @param {Backgrid.Column} options.column - @param {Backbone.Model} options.model - */ - initialize: function (options) { - - this.column = options.column; - if (!(this.column instanceof Backgrid.Column)) { - this.column = new Backgrid.Column(this.column); - } - - this.listenTo(this.model, "backgrid:select", function (model, selected) { - this.$el.find(":checkbox").prop("checked", selected).change(); - }); - - var column = this.column, $el = this.$el; - this.listenTo(column, "change:renderable", function (column, renderable) { - $el.toggleClass("renderable", renderable); - }); - - if (column.get("renderable")) $el.addClass("renderable"); - }, - - /** - Focuses the checkbox. - */ - enterEditMode: function () { - this.$el.find(":checkbox").focus(); - }, - - /** - Unfocuses the checkbox. - */ - exitEditMode: function () { - this.$el.find(":checkbox").blur(); - }, - - /** - Process keyboard navigation. - */ - onKeydown: function (e) { - var command = new Backgrid.Command(e); - if (command.passThru()) return true; // skip ahead to `change` - if (command.cancel()) { - e.stopPropagation(); - this.$el.find(":checkbox").blur(); - } - else if (command.save() || command.moveLeft() || command.moveRight() || - command.moveUp() || command.moveDown()) { - e.preventDefault(); - e.stopPropagation(); - this.model.trigger("backgrid:edited", this.model, this.column, command); - } - }, - - /** - When the checkbox's value changes, this method will trigger a Backbone - `backgrid:selected` event with a reference of the model and the - checkbox's `checked` value. - */ - onChange: function (e) { - var checked = $(e.target).prop('checked'); - this.$el.parent().toggleClass('selected', checked); - this.model.trigger("backgrid:selected", this.model, checked); - }, - - /** - Renders a checkbox in a table cell. - */ - render: function () { - this.$el.empty().append('<input tabindex="-1" type="checkbox" />'); - this.delegateEvents(); - return this; - } - - }); - - /** - Renders a checkbox to select all rows on the current page. - - @class Backgrid.Extension.SelectAllHeaderCell - @extends Backgrid.Extension.SelectRowCell - */ - var SelectAllHeaderCell = Backgrid.Extension.SelectAllHeaderCell = SelectRowCell.extend({ - - /** @property */ - className: "select-all-header-cell", - - /** @property */ - tagName: "th", - - /** - Initializer. When this cell's checkbox is checked, a Backbone - `backgrid:select` event will be triggered for each model for the current - page in the underlying collection. If a `SelectRowCell` instance exists - for the rows representing the models, they will check themselves. If any - of the SelectRowCell instances trigger a Backbone `backgrid:selected` - event with a `false` value, this cell will uncheck its checkbox. In the - event of a Backbone `backgrid:refresh` event, which is triggered when the - body refreshes its rows, which can happen under a number of conditions - such as paging or the columns were reset, this cell will still remember - the previously selected models and trigger a Backbone `backgrid:select` - event on them such that the SelectRowCells can recheck themselves upon - refreshing. - - @param {Object} options - @param {Backgrid.Column} options.column - @param {Backbone.Collection} options.collection - */ - initialize: function (options) { - - this.column = options.column; - if (!(this.column instanceof Backgrid.Column)) { - this.column = new Backgrid.Column(this.column); - } - - var collection = this.collection; - var selectedModels = this.selectedModels = {}; - this.listenTo(collection, "backgrid:selected", function (model, selected) { - if (selected) selectedModels[model.id || model.cid] = model; - else { - delete selectedModels[model.id || model.cid]; - this.$el.find(":checkbox").prop("checked", false); - } - }); - - this.listenTo(collection, "remove", function (model) { - delete selectedModels[model.id || model.cid]; - }); - - this.listenTo(collection, "backgrid:refresh", function () { - this.$el.find(":checkbox").prop("checked", false); - for (var i = 0; i < collection.length; i++) { - var model = collection.at(i); - if (selectedModels[model.id || model.cid]) { - model.trigger('backgrid:select', model, true); - } - } - }); - - var column = this.column, $el = this.$el; - this.listenTo(column, "change:renderable", function (column, renderable) { - $el.toggleClass("renderable", renderable); - }); - - if (column.get("renderable")) $el.addClass("renderable"); - }, - - /** - Progagates the checked value of this checkbox to all the models of the - underlying collection by triggering a Backbone `backgrid:select` event on - the models themselves, passing each model and the current `checked` value - of the checkbox in each event. - */ - onChange: function (e) { - var checked = $(e.target).prop("checked"); - - var collection = this.collection; - collection.each(function (model) { - model.trigger("backgrid:select", model, checked); - }); - } - - }); - - /** - Convenient method to retrieve a list of selected models. This method only - exists when the `SelectAll` extension has been included. - - @member Backgrid.Grid - @return {Array.<Backbone.Model>} - */ - Backgrid.Grid.prototype.getSelectedModels = function () { - var selectAllHeaderCell; - var headerCells = this.header.row.cells; - for (var i = 0, l = headerCells.length; i < l; i++) { - var headerCell = headerCells[i]; - if (headerCell instanceof SelectAllHeaderCell) { - selectAllHeaderCell = headerCell; - break; - } - } - - var result = []; - if (selectAllHeaderCell) { - for (var modelId in selectAllHeaderCell.selectedModels) { - result.push(this.collection.get(modelId)); - } - } - - return result; - }; - -})); diff --git a/src/UI/JsLibraries/backbone.collectionview.js b/src/UI/JsLibraries/backbone.collectionview.js deleted file mode 100644 index 29ec982ff..000000000 --- a/src/UI/JsLibraries/backbone.collectionview.js +++ /dev/null @@ -1,1072 +0,0 @@ -/*! -* Backbone.CollectionView, v0.8.1 -* Copyright (c)2013 Rotunda Software, LLC. -* Distributed under MIT license -* http://github.com/rotundasoftware/backbone-collection-view -*/ - - -(function() { - var mDefaultModelViewConstructor = Backbone.View; - - var kDefaultReferenceBy = "model"; - - var kAllowedOptions = [ - "collection", "modelView", "modelViewOptions", "itemTemplate", "emptyListCaption", - "selectable", "clickToSelect", "selectableModelsFilter", "visibleModelsFilter", - "selectMultiple", "clickToToggle", "processKeyEvents", "sortable", "sortableOptions", "sortableModelsFilter", "itemTemplateFunction", "detachedRendering" - ]; - - var kOptionsRequiringRerendering = [ "collection", "modelView", "modelViewOptions", "itemTemplate", "selectableModelsFilter", "sortableModelsFilter", "visibleModelsFilter", "itemTemplateFunction", "detachedRendering", "sortableOptions" ]; - - var kStylesForEmptyListCaption = { - "background" : "transparent", - "border" : "none", - "box-shadow" : "none" - }; - - Backbone.CollectionView = Backbone.View.extend( { - - tagName : "ul", - - events : { - "mousedown li, td" : "_listItem_onMousedown", - "dblclick li, td" : "_listItem_onDoubleClick", - "click" : "_listBackground_onClick", - "click ul.collection-list, table.collection-list" : "_listBackground_onClick", - "keydown" : "_onKeydown" - }, - - // only used if Backbone.Courier is available - spawnMessages : { - "focus" : "focus" - }, - - //only used if Backbone.Courier is available - passMessages : { "*" : "." }, - - initialize : function( options ) { - var _this = this; - - this._hasBeenRendered = false; - - // default options - options = _.extend( {}, { - collection : null, - modelView : this.modelView || null, - modelViewOptions : {}, - itemTemplate : null, - itemTemplateFunction : null, - selectable : true, - clickToSelect : true, - selectableModelsFilter : null, - visibleModelsFilter : null, - sortableModelsFilter : null, - selectMultiple : false, - clickToToggle : false, - processKeyEvents : true, - sortable : false, - sortableOptions : null, - detachedRendering : false, - emptyListCaption : null - }, options ); - - // add each of the white-listed options to the CollectionView object itself - _.each( kAllowedOptions, function( option ) { - _this[ option ] = options[option]; - } ); - - if( ! this.collection ) this.collection = new Backbone.Collection(); - - if( this._isBackboneCourierAvailable() ) { - Backbone.Courier.add( this ); - } - - this.$el.data( "view", this ); // needed for connected sortable lists - this.$el.addClass( "collection-list" ); - if( this.processKeyEvents ) - this.$el.attr( "tabindex", 0 ); // so we get keyboard events - - this.selectedItems = []; - - this._updateItemTemplate(); - - if( this.collection ) - this._registerCollectionEvents(); - - this.viewManager = new ChildViewContainer(); - - //this.listenTo( this.collection, "change", function() { this.render(); this.spawn( "change" ); } ); // don't want changes to models bubbling up and triggering the list's render() function - - // note we do NOT call render here anymore, because if we inherit from this class we will likely call this - // function using __super__ before the rest of the initialization logic for the decedent class. however, we may - // override the render() function in that decedent class as well, and that will certainly expect all the initialization - // to be done already. so we have to make sure to not jump the gun and start rending at this point. - // this.render(); - }, - - setOption : function( name, value ) { - - var _this = this; - - if( name === "collection" ) { - this._setCollection( value ); - } - else { - if( _.contains( kAllowedOptions, name ) ) { - - switch( name ) { - case "selectMultiple" : - this[ name ] = value; - if( !value && this.selectedItems.length > 1 ) - this.setSelectedModel( _.first( this.selectedItems ), { by : "cid" } ); - break; - case "selectable" : - if( !value && this.selectedItems.length > 0 ) - this.setSelectedModels( [] ); - this[ name ] = value; - break; - case "selectableModelsFilter" : - this[ name ] = value; - if( value && _.isFunction( value ) ) - this._validateSelection(); - break; - case "itemTemplate" : - this[ name ] = value; - this._updateItemTemplate(); - break; - case "processKeyEvents" : - this[ name ] = value; - if( value ) this.$el.attr( "tabindex", 0 ); // so we get keyboard events - break; - case "modelView" : - this[ name ] = value; - //need to remove all old view instances - this.viewManager.each( function( view ) { - _this.viewManager.remove( view ); - // destroy the View itself - view.remove(); - } ); - break; - default : - this[ name ] = value; - } - - if( _.contains( kOptionsRequiringRerendering, name ) ) this.render(); - } - else throw name + " is not an allowed option"; - } - }, - - getSelectedModel : function( options ) { - return _.first( this.getSelectedModels( options ) ); - }, - - getSelectedModels : function ( options ) { - var _this = this; - - options = _.extend( {}, { - by : kDefaultReferenceBy - }, options ); - - var referenceBy = options.by; - var items = []; - - switch( referenceBy ) { - case "id" : - _.each( this.selectedItems, function ( item ) { - items.push( _this.collection.get( item ).id ); - } ); - break; - case "cid" : - items = items.concat( this.selectedItems ); - break; - case "offset" : - var curLineNumber = 0; - - var itemElements; - if( this._isRenderedAsTable() ) - itemElements = this.$el.find( "> tbody > [data-model-cid]:not(.not-visible)" ); - else if( this._isRenderedAsList() ) - itemElements = this.$el.find( "> [data-model-cid]:not(.not-visible)" ); - - itemElements.each( function() { - var thisItemEl = $( this ); - if( thisItemEl.is( ".selected" ) ) - items.push( curLineNumber ); - curLineNumber++; - } ); - break; - case "model" : - _.each( this.selectedItems, function ( item ) { - items.push( _this.collection.get( item ) ); - } ); - break; - case "view" : - _.each( this.selectedItems, function ( item ) { - items.push( _this.viewManager.findByModel( _this.collection.get( item ) ) ); - } ); - break; - } - - return items; - - }, - - setSelectedModels : function( newSelectedItems, options ) { - if( ! this.selectable ) return; // used to throw error, but there are some circumstances in which a list can be selectable at times and not at others, don't want to have to worry about catching errors - if( ! _.isArray( newSelectedItems ) ) throw "Invalid parameter value"; - - options = _.extend( {}, { - silent : false, - by : kDefaultReferenceBy - }, options ); - - var referenceBy = options.by; - var newSelectedCids = []; - - switch( referenceBy ) { - case "cid" : - newSelectedCids = newSelectedItems; - break; - case "id" : - this.collection.each( function( thisModel ) { - if( _.contains( newSelectedItems, thisModel.id ) ) newSelectedCids.push( thisModel.cid ); - } ); - break; - case "model" : - newSelectedCids = _.pluck( newSelectedItems, "cid" ); - break; - case "view" : - _.each( newSelectedItems, function( item ) { - newSelectedCids.push( item.model.cid ); - } ); - break; - case "offset" : - var curLineNumber = 0; - var selectedItems = []; - - var itemElements; - if( this._isRenderedAsTable() ) - itemElements = this.$el.find( "> tbody > [data-model-cid]:not(.not-visible)" ); - else if( this._isRenderedAsList() ) - itemElements = this.$el.find( "> [data-model-cid]:not(.not-visible)" ); - - itemElements.each( function() { - var thisItemEl = $( this ); - if( _.contains( newSelectedItems, curLineNumber ) ) - newSelectedCids.push( thisItemEl.attr( "data-model-cid" ) ); - curLineNumber++; - } ); - break; - } - - var oldSelectedModels = this.getSelectedModels(); - var oldSelectedCids = _.clone( this.selectedItems ); - - this.selectedItems = this._convertStringsToInts( newSelectedCids ); - this._validateSelection(); - - var newSelectedModels = this.getSelectedModels(); - - if( ! this._containSameElements( oldSelectedCids, this.selectedItems ) ) - { - this._addSelectedClassToSelectedItems( oldSelectedCids ); - - if( ! options.silent ) - { - this.trigger( "selectionChanged", newSelectedModels, oldSelectedModels ); - if( this._isBackboneCourierAvailable() ) { - this.spawn( "selectionChanged", { - selectedModels : newSelectedModels, - oldSelectedModels : oldSelectedModels - } ); - } - } - - this.updateDependentControls(); - } - }, - - setSelectedModel : function( newSelectedItem, options ) { - if( ! newSelectedItem && newSelectedItem !== 0 ) - this.setSelectedModels( [], options ); - else - this.setSelectedModels( [ newSelectedItem ], options ); - }, - - render : function(){ - var _this = this; - - this._hasBeenRendered = true; - - if( this.selectable ) this._saveSelection(); - - var modelViewContainerEl; - - // If collection view element is a table and it has a tbody - // within it, render the model views inside of the tbody - if( this._isRenderedAsTable() ) { - var tbodyChild = this.$el.find( "> tbody" ); - if( tbodyChild.length > 0 ) - modelViewContainerEl = tbodyChild; - } - - if( _.isUndefined( modelViewContainerEl ) ) - modelViewContainerEl = this.$el; - - var oldViewManager = this.viewManager; - this.viewManager = new ChildViewContainer(); - - // detach each of our subviews that we have already created to represent models - // in the collection. We are going to re-use the ones that represent models that - // are still here, instead of creating new ones, so that we don't loose state - // information in the views. - oldViewManager.each( function( thisModelView ) { - // to boost performance, only detach those views that will be sticking around. - // we won't need the other ones later, so no need to detach them individually. - if( _this.collection.get( thisModelView.model.cid ) ) - thisModelView.$el.detach(); - else - thisModelView.remove(); - } ); - - modelViewContainerEl.empty(); - var fragmentContainer; - - if( this.detachedRendering ) - fragmentContainer = document.createDocumentFragment(); - - this.collection.each( function( thisModel ) { - var thisModelView; - - thisModelView = oldViewManager.findByModelCid( thisModel.cid ); - if( _.isUndefined( thisModelView ) ) { - // if the model view was not already created on previous render, - // then create and initialize it now. - - var modelViewOptions = this._getModelViewOptions( thisModel ); - thisModelView = this._createNewModelView( thisModel, modelViewOptions ); - - thisModelView.collectionListView = _this; - } - - var thisModelViewWrapped = this._wrapModelView( thisModelView ); - if( this.detachedRendering ) - fragmentContainer.appendChild( thisModelViewWrapped[0] ); - else - modelViewContainerEl.append( thisModelViewWrapped ); - - // we have to render the modelView after it has been put in context, as opposed to in the - // initialize function of the modelView, because some rendering might be dependent on - // the modelView's context in the DOM tree. For example, if the modelView stretch()'s itself, - // it must be in full context in the DOM tree or else the stretch will not behave as intended. - var renderResult = thisModelView.render(); - - // return false from the view's render function to hide this item - if( renderResult === false ) { - thisModelViewWrapped.hide(); - thisModelViewWrapped.addClass( "not-visible" ); - } - - if( _.isFunction( this.visibleModelsFilter ) ) { - if( ! this.visibleModelsFilter( thisModel ) ) { - if( thisModelViewWrapped.children().length === 1 ) - thisModelViewWrapped.hide(); - else thisModelView.$el.hide(); - - thisModelViewWrapped.addClass( "not-visible" ); - } - } - - this.viewManager.add( thisModelView ); - }, this ); - - if( this.detachedRendering ) - modelViewContainerEl.append( fragmentContainer ); - - if( this.sortable ) - { - var sortableOptions = _.extend( { - axis: "y", - distance: 10, - forcePlaceholderSize : true, - start : _.bind( this._sortStart, this ), - change : _.bind( this._sortChange, this ), - stop : _.bind( this._sortStop, this ), - receive : _.bind( this._receive, this ), - over : _.bind( this._over, this ) - }, _.result( this, "sortableOptions" ) ); - - if( _this._isRenderedAsTable() ) { - sortableOptions.items = "> tbody > tr:not(.not-sortable)"; - } - else if( _this._isRenderedAsList() ) { - sortableOptions.items = "> li:not(.not-sortable)"; - } - - this.$el = this.$el.sortable( sortableOptions ); - } - - if( this.emptyListCaption ) { - var visibleView = this.viewManager.find( function( view ) { - return ! view.$el.hasClass( "not-visible" ); - } ); - - if( _.isUndefined( visibleView ) ) { - var emptyListString; - - if( _.isFunction( this.emptyListCaption ) ) - emptyListString = this.emptyListCaption(); - else - emptyListString = this.emptyListCaption; - - var $emptyCaptionEl; - var $varEl = $( "<var class='empty-list-caption'>" + emptyListString + "</var>" ); - - //need to wrap the empty caption to make it fit the rendered list structure (either with an li or a tr td) - if( this._isRenderedAsList() ) - $emptyListCaptionEl = $varEl.wrapAll( "<li class='not-sortable'></li>" ).parent().css( kStylesForEmptyListCaption ); - else - $emptyListCaptionEl = $varEl.wrapAll( "<tr class='not-sortable'><td></td></tr>" ).parent().parent().css( kStylesForEmptyListCaption ); - - this.$el.append( $emptyListCaptionEl ); - - } - } - - this.trigger( "render" ); - if( this._isBackboneCourierAvailable() ) - this.spawn( "render" ); - - if( this.selectable ) { - this._restoreSelection(); - this.updateDependentControls(); - } - - if( _.isFunction( this.onAfterRender ) ) - this.onAfterRender(); - }, - - updateDependentControls : function() { - this.trigger( "updateDependentControls", this.getSelectedModels() ); - if( this._isBackboneCourierAvailable() ) { - this.spawn( "updateDependentControls", { - selectedModels : this.getSelectedModels() - } ); - } - }, - - // Override `Backbone.View.remove` to also destroy all Views in `viewManager` - remove : function() { - this.viewManager.each( function( view ) { - view.remove(); - } ); - - Backbone.View.prototype.remove.apply( this, arguments ); - }, - - _validateSelectionAndRender : function() { - this._validateSelection(); - this.render(); - }, - - _registerCollectionEvents : function() { - this.listenTo( this.collection, "add", function() { - if( this._hasBeenRendered ) this.render(); - if( this._isBackboneCourierAvailable() ) - this.spawn( "add" ); - } ); - - this.listenTo( this.collection, "remove", function() { - if( this._hasBeenRendered ) this.render(); - if( this._isBackboneCourierAvailable() ) - this.spawn( "remove" ); - } ); - - this.listenTo( this.collection, "reset", function() { - if( this._hasBeenRendered ) this.render(); - if( this._isBackboneCourierAvailable() ) - this.spawn( "reset" ); - } ); - - // It should be up to the model to rerender itself when it changes. - // this.listenTo( this.collection, "change", function( model ) { - // if( this._hasBeenRendered ) this.viewManager.findByModel( model ).render(); - // if( this._isBackboneCourierAvailable() ) - // this.spawn( "change", { model : model } ); - // } ); - - this.listenTo( this.collection, "sort", function() { - if( this._hasBeenRendered ) this.render(); - if( this._isBackboneCourierAvailable() ) - this.spawn( "sort" ); - } ); - }, - - _getClickedItemId : function( theEvent ) { - var clickedItemId = null; - - // important to use currentTarget as opposed to target, since we could be bubbling - // an event that took place within another collectionList - var clickedItemEl = $( theEvent.currentTarget ); - if( clickedItemEl.closest( ".collection-list" ).get(0) !== this.$el.get(0) ) return; - - // determine which list item was clicked. If we clicked in the blank area - // underneath all the elements, we want to know that too, since in this - // case we will want to deselect all elements. so check to see if the clicked - // DOM element is the list itself to find that out. - var clickedItem = clickedItemEl.closest( "[data-model-cid]" ); - if( clickedItem.length > 0 ) - { - clickedItemId = clickedItem.attr( "data-model-cid" ); - if( $.isNumeric( clickedItemId ) ) clickedItemId = parseInt( clickedItemId, 10 ); - } - - return clickedItemId; - }, - - _setCollection : function( newCollection ) { - if( newCollection !== this.collection ) - { - this.stopListening( this.collection ); - this.collection = newCollection; - this._registerCollectionEvents(); - } - - if( this._hasBeenRendered ) this.render(); - }, - - _updateItemTemplate : function() { - var itemTemplateHtml; - if( this.itemTemplate ) - { - if( $( this.itemTemplate ).length === 0 ) - throw "Could not find item template from selector: " + this.itemTemplate; - - itemTemplateHtml = $( this.itemTemplate ).html(); - } - else - itemTemplateHtml = this.$( ".item-template" ).html(); - - if( itemTemplateHtml ) this.itemTemplateFunction = _.template( itemTemplateHtml ); - - }, - - _validateSelection : function() { - // note can't use the collection's proxy to underscore because "cid" ais not an attribute, - // but an element of the model object itself. - var modelReferenceIds = _.pluck( this.collection.models, "cid" ); - this.selectedItems = _.intersection( modelReferenceIds, this.selectedItems ); - - if( _.isFunction( this.selectableModelsFilter ) ) - { - this.selectedItems = _.filter( this.selectedItems, function( thisItemId ) { - return this.selectableModelsFilter.call( this, this.collection.get( thisItemId ) ); - }, this ); - } - }, - - _saveSelection : function() { - // save the current selection. use restoreSelection() to restore the selection to the state it was in the last time saveSelection() was called. - if( ! this.selectable ) throw "Attempt to save selection on non-selectable list"; - this.savedSelection = { - items : this.selectedItems, - offset : this.getSelectedModel( { by : "offset" } ) - }; - }, - - _restoreSelection : function() { - if( ! this.savedSelection ) throw "Attempt to restore selection but no selection has been saved!"; - - // reset selectedItems to empty so that we "redraw" all "selected" classes - // when we set our new selection. We do this because it is likely that our - // contents have been refreshed, and we have thus lost all old "selected" classes. - this.setSelectedModels( [], { silent : true } ); - - if( this.savedSelection.items.length > 0 ) - { - // first try to restore the old selected items using their reference ids. - this.setSelectedModels( this.savedSelection.items, { by : "cid", silent : true } ); - - // all the items with the saved reference ids have been removed from the list. - // ok. try to restore the selection based on the offset that used to be selected. - // this is the expected behavior after a item is deleted from a list (i.e. select - // the line that immediately follows the deleted line). - if( this.selectedItems.length === 0 ) - this.setSelectedModel( this.savedSelection.offset, { by : "offset" } ); - - // Trigger a selection changed if the previously selected items were not all found - if (this.selectedItems.length !== this.savedSelection.items.length) - { - this.trigger( "selectionChanged", this.getSelectedModels(), [] ); - if( this._isBackboneCourierAvailable() ) { - this.spawn( "selectionChanged", { - selectedModels : this.getSelectedModels(), - oldSelectedModels : [] - } ); - } - } - } - - delete this.savedSelection; - }, - - _addSelectedClassToSelectedItems : function( oldItemsIdsWithSelectedClass ) { - if( _.isUndefined( oldItemsIdsWithSelectedClass ) ) oldItemsIdsWithSelectedClass = []; - - // oldItemsIdsWithSelectedClass is used for optimization purposes only. If this info is supplied then we - // only have to add / remove the "selected" class from those items that "selected" state has changed. - - var itemsIdsFromWhichSelectedClassNeedsToBeRemoved = oldItemsIdsWithSelectedClass; - itemsIdsFromWhichSelectedClassNeedsToBeRemoved = _.without( itemsIdsFromWhichSelectedClassNeedsToBeRemoved, this.selectedItems ); - - _.each( itemsIdsFromWhichSelectedClassNeedsToBeRemoved, function( thisItemId ) { - this.$el.find( "[data-model-cid=" + thisItemId + "]" ).removeClass( "selected" ); - }, this ); - - var itemsIdsFromWhichSelectedClassNeedsToBeAdded = this.selectedItems; - itemsIdsFromWhichSelectedClassNeedsToBeAdded = _.without( itemsIdsFromWhichSelectedClassNeedsToBeAdded, oldItemsIdsWithSelectedClass ); - - _.each( itemsIdsFromWhichSelectedClassNeedsToBeAdded, function( thisItemId ) { - this.$el.find( "[data-model-cid=" + thisItemId + "]" ).addClass( "selected" ); - }, this ); - }, - - _reorderCollectionBasedOnHTML : function() { - var _this = this; - - this.$el.children().each( function() { - var thisModelCid = $( this ).attr( "data-model-cid" ); - - if( thisModelCid ) - { - // remove the current model and then add it back (at the end of the collection). - // When we are done looping through all models, they will be in the correct order. - var thisModel = _this.collection.get( thisModelCid ); - if( thisModel ) - { - _this.collection.remove( thisModel, { silent : true } ); - _this.collection.add( thisModel, { silent : true, sort : ! _this.collection.comparator } ); - } - } - } ); - - this.collection.trigger( "reorder" ); - - if( this._isBackboneCourierAvailable() ) this.spawn( "reorder" ); - - if( this.collection.comparator ) this.collection.sort(); - - }, - - _getModelViewConstructor : function( thisModel ) { - return this.modelView || mDefaultModelViewConstructor; - }, - - _getModelViewOptions : function( thisModel ) { - return _.extend( { model : thisModel }, this.modelViewOptions ); - }, - - _createNewModelView : function( model, modelViewOptions ) { - var modelViewConstructor = this._getModelViewConstructor( model ); - if( _.isUndefined( modelViewConstructor ) ) throw "Could not find modelView constructor for model"; - - return new ( modelViewConstructor )( modelViewOptions ); - }, - - _wrapModelView : function( modelView ) { - var _this = this; - - // we use items client ids as opposed to real ids, since we may not have a representation - // of these models on the server - var wrappedModelView; - - if( this._isRenderedAsTable() ) { - // if we are rendering the collection in a table, the template $el is a tr so we just need to set the data-model-cid - wrappedModelView = modelView.$el.attr( "data-model-cid", modelView.model.cid ); - } - else if( this._isRenderedAsList() ) { - // if we are rendering the collection in a list, we need wrap each item in an <li></li> (if its not already an <li>) - // and set the data-model-cid - if( modelView.$el.prop( "tagName" ).toLowerCase() === "li" ) { - wrappedModelView = modelView.$el.attr( "data-model-cid", modelView.model.cid ); - } else { - wrappedModelView = modelView.$el.wrapAll( "<li data-model-cid='" + modelView.model.cid + "'></li>" ).parent(); - } - } - - if( _.isFunction( this.sortableModelsFilter ) ) - if( ! this.sortableModelsFilter.call( _this, modelView.model ) ) - wrappedModelView.addClass( "not-sortable" ); - - if( _.isFunction( this.selectableModelsFilter ) ) - if( ! this.selectableModelsFilter.call( _this, modelView.model ) ) - wrappedModelView.addClass( "not-selectable" ); - - return wrappedModelView; - }, - - _convertStringsToInts : function( theArray ) { - return _.map( theArray, function( thisEl ) { - if( ! _.isString( thisEl ) ) return thisEl; - var thisElAsNumber = parseInt( thisEl, 10 ); - return( thisElAsNumber == thisEl ? thisElAsNumber : thisEl ); - } ); - }, - - _containSameElements : function( arrayA, arrayB ) { - if( arrayA.length != arrayB.length ) return false; - var intersectionSize = _.intersection( arrayA, arrayB ).length; - return intersectionSize == arrayA.length; // and must also equal arrayB.length, since arrayA.length == arrayB.length - }, - - _isRenderedAsTable : function() { - return this.$el.prop('tagName').toLowerCase() === 'table'; - }, - - - _isRenderedAsList : function() { - return ! this._isRenderedAsTable(); - }, - - _charCodes : { - upArrow : 38, - downArrow : 40 - }, - - _isBackboneCourierAvailable : function() { - return !_.isUndefined( Backbone.Courier ); - }, - - _sortStart : function( event, ui ) { - var modelBeingSorted = this.collection.get( ui.item.attr( "data-model-cid" ) ); - this.trigger( "sortStart", modelBeingSorted ); - if( this._isBackboneCourierAvailable() ) - this.spawn( "sortStart", { modelBeingSorted : modelBeingSorted } ); - }, - - _sortChange : function( event, ui ) { - var modelBeingSorted = this.collection.get( ui.item.attr( "data-model-cid" ) ); - this.trigger( "sortChange", modelBeingSorted ); - if( this._isBackboneCourierAvailable() ) - this.spawn( "sortChange", { modelBeingSorted : modelBeingSorted } ); - }, - - _sortStop : function( event, ui ) { - var modelBeingSorted = this.collection.get( ui.item.attr( "data-model-cid" ) ); - var modelViewContainerEl = (this._isRenderedAsTable()) ? this.$el.find( "> tbody" ) : this.$el; - var newIndex = modelViewContainerEl.children().index( ui.item ); - - if( newIndex == -1 ) { - // the element was removed from this list. can happen if this sortable is connected - // to another sortable, and the item was dropped into the other sortable. - this.collection.remove( modelBeingSorted ); - } - - this._reorderCollectionBasedOnHTML(); - this.updateDependentControls(); - this.trigger( "sortStop", modelBeingSorted, newIndex ); - if( this._isBackboneCourierAvailable() ) - this.spawn( "sortStop", { modelBeingSorted : modelBeingSorted, newIndex : newIndex } ); - }, - - _receive : function( event, ui ) { - var senderListEl = ui.sender; - var senderCollectionListView = senderListEl.data( "view" ); - if( ! senderCollectionListView || ! senderCollectionListView.collection ) return; - - var newIndex = this.$el.children().index( ui.item ); - var modelReceived = senderCollectionListView.collection.get( ui.item.attr( "data-model-cid" ) ); - this.collection.add( modelReceived, { at : newIndex } ); - modelReceived.collection = this.collection; // otherwise will not get properly set, since modelReceived.collection might already have a value. - this.setSelectedModel( modelReceived ); - }, - - _over : function( event, ui ) { - // when an item is being dragged into the sortable, - // hide the empty list caption if it exists - this.$el.find( ".empty-list-caption" ).hide(); - }, - - _onKeydown : function( event ) { - if( ! this.processKeyEvents ) return true; - - var trap = false; - - if( this.getSelectedModels( { by : "offset" } ).length == 1 ) - { - // need to trap down and up arrows or else the browser - // will end up scrolling a autoscroll div. - - var currentOffset = this.getSelectedModel( { by : "offset" } ); - if( event.which === this._charCodes.upArrow && currentOffset !== 0 ) - { - this.setSelectedModel( currentOffset - 1, { by : "offset" } ); - trap = true; - } - else if( event.which === this._charCodes.downArrow && currentOffset !== this.collection.length - 1 ) - { - this.setSelectedModel( currentOffset + 1, { by : "offset" } ); - trap = true; - } - } - - return ! trap; - }, - - _listItem_onMousedown : function( theEvent ) { - if( ! this.selectable || ! this.clickToSelect ) return; - - var clickedItemId = this._getClickedItemId( theEvent ); - - if( clickedItemId ) - { - // Exit if an unselectable item was clicked - if( _.isFunction( this.selectableModelsFilter ) && - ! this.selectableModelsFilter.call( this, this.collection.get( clickedItemId ) ) ) - { - return; - } - - // a selectable list item was clicked - if( this.selectMultiple && theEvent.shiftKey ) - { - var firstSelectedItemIndex = -1; - - if( this.selectedItems.length > 0 ) - { - this.collection.find( function( thisItemModel ) { - firstSelectedItemIndex++; - - // exit when we find our first selected element - return _.contains( this.selectedItems, thisItemModel.cid ); - }, this ); - } - - var clickedItemIndex = -1; - this.collection.find( function( thisItemModel ) { - clickedItemIndex++; - - // exit when we find the clicked element - return thisItemModel.cid == clickedItemId; - }, this ); - - var shiftKeyRootSelectedItemIndex = firstSelectedItemIndex == -1 ? clickedItemIndex : firstSelectedItemIndex; - var minSelectedItemIndex = Math.min( clickedItemIndex, shiftKeyRootSelectedItemIndex ); - var maxSelectedItemIndex = Math.max( clickedItemIndex, shiftKeyRootSelectedItemIndex ); - - var newSelectedItems = []; - for( var thisIndex = minSelectedItemIndex; thisIndex <= maxSelectedItemIndex; thisIndex ++ ) - newSelectedItems.push( this.collection.at( thisIndex ).cid ); - this.setSelectedModels( newSelectedItems, { by : "cid" } ); - - // shift clicking will usually highlight selectable text, which we do not want. - // this is a cross browser (hopefully) snippet that deselects all text selection. - if( document.selection && document.selection.empty ) - document.selection.empty(); - else if(window.getSelection) { - var sel = window.getSelection(); - if( sel && sel.removeAllRanges ) - sel.removeAllRanges(); - } - } - else if( this.selectMultiple && ( this.clickToToggle || theEvent.metaKey ) ) - { - if( _.contains( this.selectedItems, clickedItemId ) ) - this.setSelectedModels( _.without( this.selectedItems, clickedItemId ), { by : "cid" } ); - else this.setSelectedModels( _.union( this.selectedItems, [ clickedItemId ] ), { by : "cid" } ); - } - else - this.setSelectedModels( [ clickedItemId ], { by : "cid" } ); - } - else - // the blank area of the list was clicked - this.setSelectedModels( [] ); - - }, - - _listItem_onDoubleClick : function( theEvent ) { - var clickedItemId = this._getClickedItemId( theEvent ); - - if( clickedItemId ) - { - var clickedModel = this.collection.get( clickedItemId ); - this.trigger( "doubleClick", clickedModel ); - if( this._isBackboneCourierAvailable() ) - this.spawn( "doubleClick", { clickedModel : clickedModel } ); - } - }, - - _listBackground_onClick : function( theEvent ) { - if( ! this.selectable ) return; - if( ! $( theEvent.target ).is( ".collection-list" ) ) return; - - this.setSelectedModels( [] ); - } - - }, { - setDefaultModelViewConstructor : function( theConstructor ) { - mDefaultModelViewConstructor = theConstructor; - } - }); - - - // Backbone.BabySitter - // ------------------- - // v0.0.6 - // - // Copyright (c)2013 Derick Bailey, Muted Solutions, LLC. - // Distributed under MIT license - // - // http://github.com/babysitterjs/backbone.babysitter - - // Backbone.ChildViewContainer - // --------------------------- - // - // Provide a container to store, retrieve and - // shut down child views. - - ChildViewContainer = (function(Backbone, _){ - - // Container Constructor - // --------------------- - - var Container = function(views){ - this._views = {}; - this._indexByModel = {}; - this._indexByCustom = {}; - this._updateLength(); - - _.each(views, this.add, this); - }; - - // Container Methods - // ----------------- - - _.extend(Container.prototype, { - - // Add a view to this container. Stores the view - // by `cid` and makes it searchable by the model - // cid (and model itself). Optionally specify - // a custom key to store an retrieve the view. - add: function(view, customIndex){ - var viewCid = view.cid; - - // store the view - this._views[viewCid] = view; - - // index it by model - if (view.model){ - this._indexByModel[view.model.cid] = viewCid; - } - - // index by custom - if (customIndex){ - this._indexByCustom[customIndex] = viewCid; - } - - this._updateLength(); - }, - - // Find a view by the model that was attached to - // it. Uses the model's `cid` to find it. - findByModel: function(model){ - return this.findByModelCid(model.cid); - }, - - // Find a view by the `cid` of the model that was attached to - // it. Uses the model's `cid` to find the view `cid` and - // retrieve the view using it. - findByModelCid: function(modelCid){ - var viewCid = this._indexByModel[modelCid]; - return this.findByCid(viewCid); - }, - - // Find a view by a custom indexer. - findByCustom: function(index){ - var viewCid = this._indexByCustom[index]; - return this.findByCid(viewCid); - }, - - // Find by index. This is not guaranteed to be a - // stable index. - findByIndex: function(index){ - return _.values(this._views)[index]; - }, - - // retrieve a view by it's `cid` directly - findByCid: function(cid){ - return this._views[cid]; - }, - - // Remove a view - remove: function(view){ - var viewCid = view.cid; - - // delete model index - if (view.model){ - delete this._indexByModel[view.model.cid]; - } - - // delete custom index - _.any(this._indexByCustom, function(cid, key) { - if (cid === viewCid) { - delete this._indexByCustom[key]; - return true; - } - }, this); - - // remove the view from the container - delete this._views[viewCid]; - - // update the length - this._updateLength(); - }, - - // Call a method on every view in the container, - // passing parameters to the call method one at a - // time, like `function.call`. - call: function(method){ - this.apply(method, _.tail(arguments)); - }, - - // Apply a method on every view in the container, - // passing parameters to the call method one at a - // time, like `function.apply`. - apply: function(method, args){ - _.each(this._views, function(view){ - if (_.isFunction(view[method])){ - view[method].apply(view, args || []); - } - }); - }, - - // Update the `.length` attribute on this container - _updateLength: function(){ - this.length = _.size(this._views); - } - }); - - // Borrowing this code from Backbone.Collection: - // http://backbonejs.org/docs/backbone.html#section-106 - // - // Mix in methods from Underscore, for iteration, and other - // collection related features. - var methods = ['forEach', 'each', 'map', 'find', 'detect', 'filter', - 'select', 'reject', 'every', 'all', 'some', 'any', 'include', - 'contains', 'invoke', 'toArray', 'first', 'initial', 'rest', - 'last', 'without', 'isEmpty', 'pluck']; - - _.each(methods, function(method) { - Container.prototype[method] = function() { - var views = _.values(this._views); - var args = [views].concat(_.toArray(arguments)); - return _[method].apply(_, args); - }; - }); - - // return the public API - return Container; - })(Backbone, _); -})(); \ No newline at end of file diff --git a/src/UI/JsLibraries/backbone.deep.model.js b/src/UI/JsLibraries/backbone.deep.model.js deleted file mode 100644 index 7d65d8802..000000000 --- a/src/UI/JsLibraries/backbone.deep.model.js +++ /dev/null @@ -1,437 +0,0 @@ -/*jshint expr:true eqnull:true */ -/** - * - * Backbone.DeepModel v0.10.4 - * - * Copyright (c) 2013 Charles Davison, Pow Media Ltd - * - * https://github.com/powmedia/backbone-deep-model - * Licensed under the MIT License - */ - -/** - * Underscore mixins for deep objects - * - * Based on https://gist.github.com/echong/3861963 - */ -(function() { - var arrays, basicObjects, deepClone, deepExtend, deepExtendCouple, isBasicObject, - __slice = [].slice; - - deepClone = function(obj) { - var func, isArr; - if (!_.isObject(obj) || _.isFunction(obj)) { - return obj; - } - if (obj instanceof Backbone.Collection || obj instanceof Backbone.Model) { - return obj; - } - if (_.isDate(obj)) { - return new Date(obj.getTime()); - } - if (_.isRegExp(obj)) { - return new RegExp(obj.source, obj.toString().replace(/.*\//, "")); - } - isArr = _.isArray(obj || _.isArguments(obj)); - func = function(memo, value, key) { - if (isArr) { - memo.push(deepClone(value)); - } else { - memo[key] = deepClone(value); - } - return memo; - }; - return _.reduce(obj, func, isArr ? [] : {}); - }; - - isBasicObject = function(object) { - if (object == null) return false; - return (object.prototype === {}.prototype || object.prototype === Object.prototype) && _.isObject(object) && !_.isArray(object) && !_.isFunction(object) && !_.isDate(object) && !_.isRegExp(object) && !_.isArguments(object); - }; - - basicObjects = function(object) { - return _.filter(_.keys(object), function(key) { - return isBasicObject(object[key]); - }); - }; - - arrays = function(object) { - return _.filter(_.keys(object), function(key) { - return _.isArray(object[key]); - }); - }; - - deepExtendCouple = function(destination, source, maxDepth) { - var combine, recurse, sharedArrayKey, sharedArrayKeys, sharedObjectKey, sharedObjectKeys, _i, _j, _len, _len1; - if (maxDepth == null) { - maxDepth = 20; - } - if (maxDepth <= 0) { - console.warn('_.deepExtend(): Maximum depth of recursion hit.'); - return _.extend(destination, source); - } - sharedObjectKeys = _.intersection(basicObjects(destination), basicObjects(source)); - recurse = function(key) { - return source[key] = deepExtendCouple(destination[key], source[key], maxDepth - 1); - }; - for (_i = 0, _len = sharedObjectKeys.length; _i < _len; _i++) { - sharedObjectKey = sharedObjectKeys[_i]; - recurse(sharedObjectKey); - } - sharedArrayKeys = _.intersection(arrays(destination), arrays(source)); - combine = function(key) { - return source[key] = _.union(destination[key], source[key]); - }; - for (_j = 0, _len1 = sharedArrayKeys.length; _j < _len1; _j++) { - sharedArrayKey = sharedArrayKeys[_j]; - combine(sharedArrayKey); - } - return _.extend(destination, source); - }; - - deepExtend = function() { - var finalObj, maxDepth, objects, _i; - objects = 2 <= arguments.length ? __slice.call(arguments, 0, _i = arguments.length - 1) : (_i = 0, []), maxDepth = arguments[_i++]; - if (!_.isNumber(maxDepth)) { - objects.push(maxDepth); - maxDepth = 20; - } - if (objects.length <= 1) { - return objects[0]; - } - if (maxDepth <= 0) { - return _.extend.apply(this, objects); - } - finalObj = objects.shift(); - while (objects.length > 0) { - finalObj = deepExtendCouple(finalObj, deepClone(objects.shift()), maxDepth); - } - return finalObj; - }; - - _.mixin({ - deepClone: deepClone, - isBasicObject: isBasicObject, - basicObjects: basicObjects, - arrays: arrays, - deepExtend: deepExtend - }); - -}).call(this); - -/** - * Main source - */ - -;(function(factory) { - if (typeof define === 'function' && define.amd) { - // AMD - define(['underscore', 'backbone'], factory); - } else { - // globals - factory(_, Backbone); - } -}(function(_, Backbone) { - - /** - * Takes a nested object and returns a shallow object keyed with the path names - * e.g. { "level1.level2": "value" } - * - * @param {Object} Nested object e.g. { level1: { level2: 'value' } } - * @return {Object} Shallow object with path names e.g. { 'level1.level2': 'value' } - */ - function objToPaths(obj) { - var ret = {}, - separator = DeepModel.keyPathSeparator; - - for (var key in obj) { - var val = obj[key]; - - if (val && val.constructor === Object && !_.isEmpty(val)) { - //Recursion for embedded objects - var obj2 = objToPaths(val); - - for (var key2 in obj2) { - var val2 = obj2[key2]; - - ret[key + separator + key2] = val2; - } - } else { - ret[key] = val; - } - } - - return ret; - } - - /** - * @param {Object} Object to fetch attribute from - * @param {String} Object path e.g. 'user.name' - * @return {Mixed} - */ - function getNested(obj, path, return_exists) { - var separator = DeepModel.keyPathSeparator; - - var fields = path.split(separator); - var result = obj; - return_exists || (return_exists === false); - for (var i = 0, n = fields.length; i < n; i++) { - if (return_exists && !_.has(result, fields[i])) { - return false; - } - result = result[fields[i]]; - - if (result == null && i < n - 1) { - result = {}; - } - - if (typeof result === 'undefined') { - if (return_exists) - { - return true; - } - return result; - } - } - if (return_exists) - { - return true; - } - return result; - } - - /** - * @param {Object} obj Object to fetch attribute from - * @param {String} path Object path e.g. 'user.name' - * @param {Object} [options] Options - * @param {Boolean} [options.unset] Whether to delete the value - * @param {Mixed} Value to set - */ - function setNested(obj, path, val, options) { - options = options || {}; - - var separator = DeepModel.keyPathSeparator; - - var fields = path.split(separator); - var result = obj; - for (var i = 0, n = fields.length; i < n && result !== undefined ; i++) { - var field = fields[i]; - - //If the last in the path, set the value - if (i === n - 1) { - options.unset ? delete result[field] : result[field] = val; - } else { - //Create the child object if it doesn't exist, or isn't an object - if (typeof result[field] === 'undefined' || ! _.isObject(result[field])) { - result[field] = {}; - } - - //Move onto the next part of the path - result = result[field]; - } - } - } - - function deleteNested(obj, path) { - setNested(obj, path, null, { unset: true }); - } - - var DeepModel = Backbone.Model.extend({ - - // Override constructor - // Support having nested defaults by using _.deepExtend instead of _.extend - constructor: function(attributes, options) { - var defaults; - var attrs = attributes || {}; - this.cid = _.uniqueId('c'); - this.attributes = {}; - if (options && options.collection) this.collection = options.collection; - if (options && options.parse) attrs = this.parse(attrs, options) || {}; - if (defaults = _.result(this, 'defaults')) { - //<custom code> - // Replaced the call to _.defaults with _.deepExtend. - attrs = _.deepExtend({}, defaults, attrs); - //</custom code> - } - this.set(attrs, options); - this.changed = {}; - this.initialize.apply(this, arguments); - }, - - // Return a copy of the model's `attributes` object. - toJSON: function(options) { - return _.deepClone(this.attributes); - }, - - // Override get - // Supports nested attributes via the syntax 'obj.attr' e.g. 'author.user.name' - get: function(attr) { - return getNested(this.attributes, attr); - }, - - // Override set - // Supports nested attributes via the syntax 'obj.attr' e.g. 'author.user.name' - set: function(key, val, options) { - var attr, attrs, unset, changes, silent, changing, prev, current; - if (key == null) return this; - - // Handle both `"key", value` and `{key: value}` -style arguments. - if (typeof key === 'object') { - attrs = key; - options = val || {}; - } else { - (attrs = {})[key] = val; - } - - options || (options = {}); - - // Run validation. - if (!this._validate(attrs, options)) return false; - - // Extract attributes and options. - unset = options.unset; - silent = options.silent; - changes = []; - changing = this._changing; - this._changing = true; - - if (!changing) { - this._previousAttributes = _.deepClone(this.attributes); //<custom>: Replaced _.clone with _.deepClone - this.changed = {}; - } - current = this.attributes, prev = this._previousAttributes; - - // Check for changes of `id`. - if (this.idAttribute in attrs) this.id = attrs[this.idAttribute]; - - //<custom code> - attrs = objToPaths(attrs); - //</custom code> - - // For each `set` attribute, update or delete the current value. - for (attr in attrs) { - val = attrs[attr]; - - //<custom code>: Using getNested, setNested and deleteNested - if (!_.isEqual(getNested(current, attr), val)) changes.push(attr); - if (!_.isEqual(getNested(prev, attr), val)) { - setNested(this.changed, attr, val); - } else { - deleteNested(this.changed, attr); - } - unset ? deleteNested(current, attr) : setNested(current, attr, val); - //</custom code> - } - - // Trigger all relevant attribute changes. - if (!silent) { - if (changes.length) this._pending = true; - - //<custom code> - var separator = DeepModel.keyPathSeparator; - - for (var i = 0, l = changes.length; i < l; i++) { - var key = changes[i]; - - this.trigger('change:' + key, this, getNested(current, key), options); - - var fields = key.split(separator); - - //Trigger change events for parent keys with wildcard (*) notation - for(var n = fields.length - 1; n > 0; n--) { - var parentKey = _.first(fields, n).join(separator), - wildcardKey = parentKey + separator + '*'; - - this.trigger('change:' + wildcardKey, this, getNested(current, parentKey), options); - } - //</custom code> - } - } - - if (changing) return this; - if (!silent) { - while (this._pending) { - this._pending = false; - this.trigger('change', this, options); - } - } - this._pending = false; - this._changing = false; - return this; - }, - - // Clear all attributes on the model, firing `"change"` unless you choose - // to silence it. - clear: function(options) { - var attrs = {}; - var shallowAttributes = objToPaths(this.attributes); - for (var key in shallowAttributes) attrs[key] = void 0; - return this.set(attrs, _.extend({}, options, {unset: true})); - }, - - // Determine if the model has changed since the last `"change"` event. - // If you specify an attribute name, determine if that attribute has changed. - hasChanged: function(attr) { - if (attr == null) return !_.isEmpty(this.changed); - return getNested(this.changed, attr) !== undefined; - }, - - // Return an object containing all the attributes that have changed, or - // false if there are no changed attributes. Useful for determining what - // parts of a view need to be updated and/or what attributes need to be - // persisted to the server. Unset attributes will be set to undefined. - // You can also pass an attributes object to diff against the model, - // determining if there *would be* a change. - changedAttributes: function(diff) { - //<custom code>: objToPaths - if (!diff) return this.hasChanged() ? objToPaths(this.changed) : false; - //</custom code> - - var old = this._changing ? this._previousAttributes : this.attributes; - - //<custom code> - diff = objToPaths(diff); - old = objToPaths(old); - //</custom code> - - var val, changed = false; - for (var attr in diff) { - if (_.isEqual(old[attr], (val = diff[attr]))) continue; - (changed || (changed = {}))[attr] = val; - } - return changed; - }, - - // Get the previous value of an attribute, recorded at the time the last - // `"change"` event was fired. - previous: function(attr) { - if (attr == null || !this._previousAttributes) return null; - - //<custom code> - return getNested(this._previousAttributes, attr); - //</custom code> - }, - - // Get all of the attributes of the model at the time of the previous - // `"change"` event. - previousAttributes: function() { - //<custom code> - return _.deepClone(this._previousAttributes); - //</custom code> - } - }); - - - //Config; override in your app to customise - DeepModel.keyPathSeparator = '.'; - - - //Exports - Backbone.DeepModel = DeepModel; - - //For use in NodeJS - if (typeof module != 'undefined') module.exports = DeepModel; - - return Backbone; - -})); diff --git a/src/UI/JsLibraries/backbone.js b/src/UI/JsLibraries/backbone.js deleted file mode 100644 index 70a854d31..000000000 --- a/src/UI/JsLibraries/backbone.js +++ /dev/null @@ -1,1571 +0,0 @@ -// Backbone.js 1.0.0 - -// (c) 2010-2013 Jeremy Ashkenas, DocumentCloud Inc. -// Backbone may be freely distributed under the MIT license. -// For all details and documentation: -// http://backbonejs.org - -(function(){ - - // Initial Setup - // ------------- - - // Save a reference to the global object (`window` in the browser, `exports` - // on the server). - var root = this; - - // Save the previous value of the `Backbone` variable, so that it can be - // restored later on, if `noConflict` is used. - var previousBackbone = root.Backbone; - - // Create local references to array methods we'll want to use later. - var array = []; - var push = array.push; - var slice = array.slice; - var splice = array.splice; - - // The top-level namespace. All public Backbone classes and modules will - // be attached to this. Exported for both the browser and the server. - var Backbone; - if (typeof exports !== 'undefined') { - Backbone = exports; - } else { - Backbone = root.Backbone = {}; - } - - // Current version of the library. Keep in sync with `package.json`. - Backbone.VERSION = '1.0.0'; - - // Require Underscore, if we're on the server, and it's not already present. - var _ = root._; - if (!_ && (typeof require !== 'undefined')) _ = require('underscore'); - - // For Backbone's purposes, jQuery, Zepto, Ender, or My Library (kidding) owns - // the `$` variable. - Backbone.$ = root.jQuery || root.Zepto || root.ender || root.$; - - // Runs Backbone.js in *noConflict* mode, returning the `Backbone` variable - // to its previous owner. Returns a reference to this Backbone object. - Backbone.noConflict = function() { - root.Backbone = previousBackbone; - return this; - }; - - // Turn on `emulateHTTP` to support legacy HTTP servers. Setting this option - // will fake `"PUT"` and `"DELETE"` requests via the `_method` parameter and - // set a `X-Http-Method-Override` header. - Backbone.emulateHTTP = false; - - // Turn on `emulateJSON` to support legacy servers that can't deal with direct - // `application/json` requests ... will encode the body as - // `application/x-www-form-urlencoded` instead and will send the model in a - // form param named `model`. - Backbone.emulateJSON = false; - - // Backbone.Events - // --------------- - - // A module that can be mixed in to *any object* in order to provide it with - // custom events. You may bind with `on` or remove with `off` callback - // functions to an event; `trigger`-ing an event fires all callbacks in - // succession. - // - // var object = {}; - // _.extend(object, Backbone.Events); - // object.on('expand', function(){ alert('expanded'); }); - // object.trigger('expand'); - // - var Events = Backbone.Events = { - - // Bind an event to a `callback` function. Passing `"all"` will bind - // the callback to all events fired. - on: function(name, callback, context) { - if (!eventsApi(this, 'on', name, [callback, context]) || !callback) return this; - this._events || (this._events = {}); - var events = this._events[name] || (this._events[name] = []); - events.push({callback: callback, context: context, ctx: context || this}); - return this; - }, - - // Bind an event to only be triggered a single time. After the first time - // the callback is invoked, it will be removed. - once: function(name, callback, context) { - if (!eventsApi(this, 'once', name, [callback, context]) || !callback) return this; - var self = this; - var once = _.once(function() { - self.off(name, once); - callback.apply(this, arguments); - }); - once._callback = callback; - return this.on(name, once, context); - }, - - // Remove one or many callbacks. If `context` is null, removes all - // callbacks with that function. If `callback` is null, removes all - // callbacks for the event. If `name` is null, removes all bound - // callbacks for all events. - off: function(name, callback, context) { - var retain, ev, events, names, i, l, j, k; - if (!this._events || !eventsApi(this, 'off', name, [callback, context])) return this; - if (!name && !callback && !context) { - this._events = {}; - return this; - } - - names = name ? [name] : _.keys(this._events); - for (i = 0, l = names.length; i < l; i++) { - name = names[i]; - if (events = this._events[name]) { - this._events[name] = retain = []; - if (callback || context) { - for (j = 0, k = events.length; j < k; j++) { - ev = events[j]; - if ((callback && callback !== ev.callback && callback !== ev.callback._callback) || - (context && context !== ev.context)) { - retain.push(ev); - } - } - } - if (!retain.length) delete this._events[name]; - } - } - - return this; - }, - - // Trigger one or many events, firing all bound callbacks. Callbacks are - // passed the same arguments as `trigger` is, apart from the event name - // (unless you're listening on `"all"`, which will cause your callback to - // receive the true name of the event as the first argument). - trigger: function(name) { - if (!this._events) return this; - var args = slice.call(arguments, 1); - if (!eventsApi(this, 'trigger', name, args)) return this; - var events = this._events[name]; - var allEvents = this._events.all; - if (events) triggerEvents(events, args); - if (allEvents) triggerEvents(allEvents, arguments); - return this; - }, - - // Tell this object to stop listening to either specific events ... or - // to every object it's currently listening to. - stopListening: function(obj, name, callback) { - var listeners = this._listeners; - if (!listeners) return this; - var deleteListener = !name && !callback; - if (typeof name === 'object') callback = this; - if (obj) (listeners = {})[obj._listenerId] = obj; - for (var id in listeners) { - listeners[id].off(name, callback, this); - if (deleteListener) delete this._listeners[id]; - } - return this; - } - - }; - - // Regular expression used to split event strings. - var eventSplitter = /\s+/; - - // Implement fancy features of the Events API such as multiple event - // names `"change blur"` and jQuery-style event maps `{change: action}` - // in terms of the existing API. - var eventsApi = function(obj, action, name, rest) { - if (!name) return true; - - // Handle event maps. - if (typeof name === 'object') { - for (var key in name) { - obj[action].apply(obj, [key, name[key]].concat(rest)); - } - return false; - } - - // Handle space separated event names. - if (eventSplitter.test(name)) { - var names = name.split(eventSplitter); - for (var i = 0, l = names.length; i < l; i++) { - obj[action].apply(obj, [names[i]].concat(rest)); - } - return false; - } - - return true; - }; - - // A difficult-to-believe, but optimized internal dispatch function for - // triggering events. Tries to keep the usual cases speedy (most internal - // Backbone events have 3 arguments). - var triggerEvents = function(events, args) { - var ev, i = -1, l = events.length, a1 = args[0], a2 = args[1], a3 = args[2]; - switch (args.length) { - case 0: while (++i < l) (ev = events[i]).callback.call(ev.ctx); return; - case 1: while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1); return; - case 2: while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1, a2); return; - case 3: while (++i < l) (ev = events[i]).callback.call(ev.ctx, a1, a2, a3); return; - default: while (++i < l) (ev = events[i]).callback.apply(ev.ctx, args); - } - }; - - var listenMethods = {listenTo: 'on', listenToOnce: 'once'}; - - // Inversion-of-control versions of `on` and `once`. Tell *this* object to - // listen to an event in another object ... keeping track of what it's - // listening to. - _.each(listenMethods, function(implementation, method) { - Events[method] = function(obj, name, callback) { - var listeners = this._listeners || (this._listeners = {}); - var id = obj._listenerId || (obj._listenerId = _.uniqueId('l')); - listeners[id] = obj; - if (typeof name === 'object') callback = this; - obj[implementation](name, callback, this); - return this; - }; - }); - - // Aliases for backwards compatibility. - Events.bind = Events.on; - Events.unbind = Events.off; - - // Allow the `Backbone` object to serve as a global event bus, for folks who - // want global "pubsub" in a convenient place. - _.extend(Backbone, Events); - - // Backbone.Model - // -------------- - - // Backbone **Models** are the basic data object in the framework -- - // frequently representing a row in a table in a database on your server. - // A discrete chunk of data and a bunch of useful, related methods for - // performing computations and transformations on that data. - - // Create a new model with the specified attributes. A client id (`cid`) - // is automatically generated and assigned for you. - var Model = Backbone.Model = function(attributes, options) { - var defaults; - var attrs = attributes || {}; - options || (options = {}); - this.cid = _.uniqueId('c'); - this.attributes = {}; - _.extend(this, _.pick(options, modelOptions)); - if (options.parse) attrs = this.parse(attrs, options) || {}; - if (defaults = _.result(this, 'defaults')) { - attrs = _.defaults({}, attrs, defaults); - } - this.set(attrs, options); - this.changed = {}; - this.initialize.apply(this, arguments); - }; - - // A list of options to be attached directly to the model, if provided. - var modelOptions = ['urlRoot', 'collection']; - - // Attach all inheritable methods to the Model prototype. - _.extend(Model.prototype, Events, { - - // A hash of attributes whose current and previous value differ. - changed: null, - - // The value returned during the last failed validation. - validationError: null, - - // The default name for the JSON `id` attribute is `"id"`. MongoDB and - // CouchDB users may want to set this to `"_id"`. - idAttribute: 'id', - - // Initialize is an empty function by default. Override it with your own - // initialization logic. - initialize: function(){}, - - // Return a copy of the model's `attributes` object. - toJSON: function(options) { - return _.clone(this.attributes); - }, - - // Proxy `Backbone.sync` by default -- but override this if you need - // custom syncing semantics for *this* particular model. - sync: function() { - return Backbone.sync.apply(this, arguments); - }, - - // Get the value of an attribute. - get: function(attr) { - return this.attributes[attr]; - }, - - // Get the HTML-escaped value of an attribute. - escape: function(attr) { - return _.escape(this.get(attr)); - }, - - // Returns `true` if the attribute contains a value that is not null - // or undefined. - has: function(attr) { - return this.get(attr) != null; - }, - - // Set a hash of model attributes on the object, firing `"change"`. This is - // the core primitive operation of a model, updating the data and notifying - // anyone who needs to know about the change in state. The heart of the beast. - set: function(key, val, options) { - var attr, attrs, unset, changes, silent, changing, prev, current; - if (key == null) return this; - - // Handle both `"key", value` and `{key: value}` -style arguments. - if (typeof key === 'object') { - attrs = key; - options = val; - } else { - (attrs = {})[key] = val; - } - - options || (options = {}); - - // Run validation. - if (!this._validate(attrs, options)) return false; - - // Extract attributes and options. - unset = options.unset; - silent = options.silent; - changes = []; - changing = this._changing; - this._changing = true; - - if (!changing) { - this._previousAttributes = _.clone(this.attributes); - this.changed = {}; - } - current = this.attributes, prev = this._previousAttributes; - - // Check for changes of `id`. - if (this.idAttribute in attrs) this.id = attrs[this.idAttribute]; - - // For each `set` attribute, update or delete the current value. - for (attr in attrs) { - val = attrs[attr]; - if (!_.isEqual(current[attr], val)) changes.push(attr); - if (!_.isEqual(prev[attr], val)) { - this.changed[attr] = val; - } else { - delete this.changed[attr]; - } - unset ? delete current[attr] : current[attr] = val; - } - - // Trigger all relevant attribute changes. - if (!silent) { - if (changes.length) this._pending = true; - for (var i = 0, l = changes.length; i < l; i++) { - this.trigger('change:' + changes[i], this, current[changes[i]], options); - } - } - - // You might be wondering why there's a `while` loop here. Changes can - // be recursively nested within `"change"` events. - if (changing) return this; - if (!silent) { - while (this._pending) { - this._pending = false; - this.trigger('change', this, options); - } - } - this._pending = false; - this._changing = false; - return this; - }, - - // Remove an attribute from the model, firing `"change"`. `unset` is a noop - // if the attribute doesn't exist. - unset: function(attr, options) { - return this.set(attr, void 0, _.extend({}, options, {unset: true})); - }, - - // Clear all attributes on the model, firing `"change"`. - clear: function(options) { - var attrs = {}; - for (var key in this.attributes) attrs[key] = void 0; - return this.set(attrs, _.extend({}, options, {unset: true})); - }, - - // Determine if the model has changed since the last `"change"` event. - // If you specify an attribute name, determine if that attribute has changed. - hasChanged: function(attr) { - if (attr == null) return !_.isEmpty(this.changed); - return _.has(this.changed, attr); - }, - - // Return an object containing all the attributes that have changed, or - // false if there are no changed attributes. Useful for determining what - // parts of a view need to be updated and/or what attributes need to be - // persisted to the server. Unset attributes will be set to undefined. - // You can also pass an attributes object to diff against the model, - // determining if there *would be* a change. - changedAttributes: function(diff) { - if (!diff) return this.hasChanged() ? _.clone(this.changed) : false; - var val, changed = false; - var old = this._changing ? this._previousAttributes : this.attributes; - for (var attr in diff) { - if (_.isEqual(old[attr], (val = diff[attr]))) continue; - (changed || (changed = {}))[attr] = val; - } - return changed; - }, - - // Get the previous value of an attribute, recorded at the time the last - // `"change"` event was fired. - previous: function(attr) { - if (attr == null || !this._previousAttributes) return null; - return this._previousAttributes[attr]; - }, - - // Get all of the attributes of the model at the time of the previous - // `"change"` event. - previousAttributes: function() { - return _.clone(this._previousAttributes); - }, - - // Fetch the model from the server. If the server's representation of the - // model differs from its current attributes, they will be overridden, - // triggering a `"change"` event. - fetch: function(options) { - options = options ? _.clone(options) : {}; - if (options.parse === void 0) options.parse = true; - var model = this; - var success = options.success; - options.success = function(resp) { - if (!model.set(model.parse(resp, options), options)) return false; - if (success) success(model, resp, options); - model.trigger('sync', model, resp, options); - }; - wrapError(this, options); - return this.sync('read', this, options); - }, - - // Set a hash of model attributes, and sync the model to the server. - // If the server returns an attributes hash that differs, the model's - // state will be `set` again. - save: function(key, val, options) { - var attrs, method, xhr, attributes = this.attributes; - - // Handle both `"key", value` and `{key: value}` -style arguments. - if (key == null || typeof key === 'object') { - attrs = key; - options = val; - } else { - (attrs = {})[key] = val; - } - - // If we're not waiting and attributes exist, save acts as `set(attr).save(null, opts)`. - if (attrs && (!options || !options.wait) && !this.set(attrs, options)) return false; - - options = _.extend({validate: true}, options); - - // Do not persist invalid models. - if (!this._validate(attrs, options)) return false; - - // Set temporary attributes if `{wait: true}`. - if (attrs && options.wait) { - this.attributes = _.extend({}, attributes, attrs); - } - - // After a successful server-side save, the client is (optionally) - // updated with the server-side state. - if (options.parse === void 0) options.parse = true; - var model = this; - var success = options.success; - options.success = function(resp) { - // Ensure attributes are restored during synchronous saves. - model.attributes = attributes; - var serverAttrs = model.parse(resp, options); - if (options.wait) serverAttrs = _.extend(attrs || {}, serverAttrs); - if (_.isObject(serverAttrs) && !model.set(serverAttrs, options)) { - return false; - } - if (success) success(model, resp, options); - model.trigger('sync', model, resp, options); - }; - wrapError(this, options); - - method = this.isNew() ? 'create' : (options.patch ? 'patch' : 'update'); - if (method === 'patch') options.attrs = attrs; - xhr = this.sync(method, this, options); - - // Restore attributes. - if (attrs && options.wait) this.attributes = attributes; - - return xhr; - }, - - // Destroy this model on the server if it was already persisted. - // Optimistically removes the model from its collection, if it has one. - // If `wait: true` is passed, waits for the server to respond before removal. - destroy: function(options) { - options = options ? _.clone(options) : {}; - var model = this; - var success = options.success; - - var destroy = function() { - model.trigger('destroy', model, model.collection, options); - }; - - options.success = function(resp) { - if (options.wait || model.isNew()) destroy(); - if (success) success(model, resp, options); - if (!model.isNew()) model.trigger('sync', model, resp, options); - }; - - if (this.isNew()) { - options.success(); - return false; - } - wrapError(this, options); - - var xhr = this.sync('delete', this, options); - if (!options.wait) destroy(); - return xhr; - }, - - // Default URL for the model's representation on the server -- if you're - // using Backbone's restful methods, override this to change the endpoint - // that will be called. - url: function() { - var base = _.result(this, 'urlRoot') || _.result(this.collection, 'url') || urlError(); - if (this.isNew()) return base; - return base + (base.charAt(base.length - 1) === '/' ? '' : '/') + encodeURIComponent(this.id); - }, - - // **parse** converts a response into the hash of attributes to be `set` on - // the model. The default implementation is just to pass the response along. - parse: function(resp, options) { - return resp; - }, - - // Create a new model with identical attributes to this one. - clone: function() { - return new this.constructor(this.attributes); - }, - - // A model is new if it has never been saved to the server, and lacks an id. - isNew: function() { - return this.id == null; - }, - - // Check if the model is currently in a valid state. - isValid: function(options) { - return this._validate({}, _.extend(options || {}, { validate: true })); - }, - - // Run validation against the next complete set of model attributes, - // returning `true` if all is well. Otherwise, fire an `"invalid"` event. - _validate: function(attrs, options) { - if (!options.validate || !this.validate) return true; - attrs = _.extend({}, this.attributes, attrs); - var error = this.validationError = this.validate(attrs, options) || null; - if (!error) return true; - this.trigger('invalid', this, error, _.extend(options || {}, {validationError: error})); - return false; - } - - }); - - // Underscore methods that we want to implement on the Model. - var modelMethods = ['keys', 'values', 'pairs', 'invert', 'pick', 'omit']; - - // Mix in each Underscore method as a proxy to `Model#attributes`. - _.each(modelMethods, function(method) { - Model.prototype[method] = function() { - var args = slice.call(arguments); - args.unshift(this.attributes); - return _[method].apply(_, args); - }; - }); - - // Backbone.Collection - // ------------------- - - // If models tend to represent a single row of data, a Backbone Collection is - // more analagous to a table full of data ... or a small slice or page of that - // table, or a collection of rows that belong together for a particular reason - // -- all of the messages in this particular folder, all of the documents - // belonging to this particular author, and so on. Collections maintain - // indexes of their models, both in order, and for lookup by `id`. - - // Create a new **Collection**, perhaps to contain a specific type of `model`. - // If a `comparator` is specified, the Collection will maintain - // its models in sort order, as they're added and removed. - var Collection = Backbone.Collection = function(models, options) { - options || (options = {}); - if (options.url) this.url = options.url; - if (options.model) this.model = options.model; - if (options.comparator !== void 0) this.comparator = options.comparator; - this._reset(); - this.initialize.apply(this, arguments); - if (models) this.reset(models, _.extend({silent: true}, options)); - }; - - // Default options for `Collection#set`. - var setOptions = {add: true, remove: true, merge: true}; - var addOptions = {add: true, merge: false, remove: false}; - - // Define the Collection's inheritable methods. - _.extend(Collection.prototype, Events, { - - // The default model for a collection is just a **Backbone.Model**. - // This should be overridden in most cases. - model: Model, - - // Initialize is an empty function by default. Override it with your own - // initialization logic. - initialize: function(){}, - - // The JSON representation of a Collection is an array of the - // models' attributes. - toJSON: function(options) { - return this.map(function(model){ return model.toJSON(options); }); - }, - - // Proxy `Backbone.sync` by default. - sync: function() { - return Backbone.sync.apply(this, arguments); - }, - - // Add a model, or list of models to the set. - add: function(models, options) { - return this.set(models, _.defaults(options || {}, addOptions)); - }, - - // Remove a model, or a list of models from the set. - remove: function(models, options) { - models = _.isArray(models) ? models.slice() : [models]; - options || (options = {}); - var i, l, index, model; - for (i = 0, l = models.length; i < l; i++) { - model = this.get(models[i]); - if (!model) continue; - delete this._byId[model.id]; - delete this._byId[model.cid]; - index = this.indexOf(model); - this.models.splice(index, 1); - this.length--; - if (!options.silent) { - options.index = index; - model.trigger('remove', model, this, options); - } - this._removeReference(model); - } - return this; - }, - - // Update a collection by `set`-ing a new list of models, adding new ones, - // removing models that are no longer present, and merging models that - // already exist in the collection, as necessary. Similar to **Model#set**, - // the core operation for updating the data contained by the collection. - set: function(models, options) { - options = _.defaults(options || {}, setOptions); - if (options.parse) models = this.parse(models, options); - if (!_.isArray(models)) models = models ? [models] : []; - var i, l, model, attrs, existing, sort; - var at = options.at; - var sortable = this.comparator && (at == null) && options.sort !== false; - var sortAttr = _.isString(this.comparator) ? this.comparator : null; - var toAdd = [], toRemove = [], modelMap = {}; - - // Turn bare objects into model references, and prevent invalid models - // from being added. - for (i = 0, l = models.length; i < l; i++) { - if (!(model = this._prepareModel(models[i], options))) continue; - - // If a duplicate is found, prevent it from being added and - // optionally merge it into the existing model. - if (existing = this.get(model)) { - if (options.remove) modelMap[existing.cid] = true; - if (options.merge) { - existing.set(model.attributes, options); - if (sortable && !sort && existing.hasChanged(sortAttr)) sort = true; - } - - // This is a new model, push it to the `toAdd` list. - } else if (options.add) { - toAdd.push(model); - - // Listen to added models' events, and index models for lookup by - // `id` and by `cid`. - model.on('all', this._onModelEvent, this); - this._byId[model.cid] = model; - if (model.id != null) this._byId[model.id] = model; - } - } - - // Remove nonexistent models if appropriate. - if (options.remove) { - for (i = 0, l = this.length; i < l; ++i) { - if (!modelMap[(model = this.models[i]).cid]) toRemove.push(model); - } - if (toRemove.length) this.remove(toRemove, options); - } - - // See if sorting is needed, update `length` and splice in new models. - if (toAdd.length) { - if (sortable) sort = true; - this.length += toAdd.length; - if (at != null) { - splice.apply(this.models, [at, 0].concat(toAdd)); - } else { - push.apply(this.models, toAdd); - } - } - - // Silently sort the collection if appropriate. - if (sort) this.sort({silent: true}); - - if (options.silent) return this; - - // Trigger `add` events. - for (i = 0, l = toAdd.length; i < l; i++) { - (model = toAdd[i]).trigger('add', model, this, options); - } - - // Trigger `sort` if the collection was sorted. - if (sort) this.trigger('sort', this, options); - return this; - }, - - // When you have more items than you want to add or remove individually, - // you can reset the entire set with a new list of models, without firing - // any granular `add` or `remove` events. Fires `reset` when finished. - // Useful for bulk operations and optimizations. - reset: function(models, options) { - options || (options = {}); - for (var i = 0, l = this.models.length; i < l; i++) { - this._removeReference(this.models[i]); - } - options.previousModels = this.models; - this._reset(); - this.add(models, _.extend({silent: true}, options)); - if (!options.silent) this.trigger('reset', this, options); - return this; - }, - - // Add a model to the end of the collection. - push: function(model, options) { - model = this._prepareModel(model, options); - this.add(model, _.extend({at: this.length}, options)); - return model; - }, - - // Remove a model from the end of the collection. - pop: function(options) { - var model = this.at(this.length - 1); - this.remove(model, options); - return model; - }, - - // Add a model to the beginning of the collection. - unshift: function(model, options) { - model = this._prepareModel(model, options); - this.add(model, _.extend({at: 0}, options)); - return model; - }, - - // Remove a model from the beginning of the collection. - shift: function(options) { - var model = this.at(0); - this.remove(model, options); - return model; - }, - - // Slice out a sub-array of models from the collection. - slice: function(begin, end) { - return this.models.slice(begin, end); - }, - - // Get a model from the set by id. - get: function(obj) { - if (obj == null) return void 0; - return this._byId[obj.id != null ? obj.id : obj.cid || obj]; - }, - - // Get the model at the given index. - at: function(index) { - return this.models[index]; - }, - - // Return models with matching attributes. Useful for simple cases of - // `filter`. - where: function(attrs, first) { - if (_.isEmpty(attrs)) return first ? void 0 : []; - return this[first ? 'find' : 'filter'](function(model) { - for (var key in attrs) { - if (attrs[key] !== model.get(key)) return false; - } - return true; - }); - }, - - // Return the first model with matching attributes. Useful for simple cases - // of `find`. - findWhere: function(attrs) { - return this.where(attrs, true); - }, - - // Force the collection to re-sort itself. You don't need to call this under - // normal circumstances, as the set will maintain sort order as each item - // is added. - sort: function(options) { - if (!this.comparator) throw new Error('Cannot sort a set without a comparator'); - options || (options = {}); - - // Run sort based on type of `comparator`. - if (_.isString(this.comparator) || this.comparator.length === 1) { - this.models = this.sortBy(this.comparator, this); - } else { - this.models.sort(_.bind(this.comparator, this)); - } - - if (!options.silent) this.trigger('sort', this, options); - return this; - }, - - // Figure out the smallest index at which a model should be inserted so as - // to maintain order. - sortedIndex: function(model, value, context) { - value || (value = this.comparator); - var iterator = _.isFunction(value) ? value : function(model) { - return model.get(value); - }; - return _.sortedIndex(this.models, model, iterator, context); - }, - - // Pluck an attribute from each model in the collection. - pluck: function(attr) { - return _.invoke(this.models, 'get', attr); - }, - - // Fetch the default set of models for this collection, resetting the - // collection when they arrive. If `reset: true` is passed, the response - // data will be passed through the `reset` method instead of `set`. - fetch: function(options) { - options = options ? _.clone(options) : {}; - if (options.parse === void 0) options.parse = true; - var success = options.success; - var collection = this; - options.success = function(resp) { - var method = options.reset ? 'reset' : 'set'; - collection[method](resp, options); - if (success) success(collection, resp, options); - collection.trigger('sync', collection, resp, options); - }; - wrapError(this, options); - return this.sync('read', this, options); - }, - - // Create a new instance of a model in this collection. Add the model to the - // collection immediately, unless `wait: true` is passed, in which case we - // wait for the server to agree. - create: function(model, options) { - options = options ? _.clone(options) : {}; - if (!(model = this._prepareModel(model, options))) return false; - if (!options.wait) this.add(model, options); - var collection = this; - var success = options.success; - options.success = function(resp) { - if (options.wait) collection.add(model, options); - if (success) success(model, resp, options); - }; - model.save(null, options); - return model; - }, - - // **parse** converts a response into a list of models to be added to the - // collection. The default implementation is just to pass it through. - parse: function(resp, options) { - return resp; - }, - - // Create a new collection with an identical list of models as this one. - clone: function() { - return new this.constructor(this.models); - }, - - // Private method to reset all internal state. Called when the collection - // is first initialized or reset. - _reset: function() { - this.length = 0; - this.models = []; - this._byId = {}; - }, - - // Prepare a hash of attributes (or other model) to be added to this - // collection. - _prepareModel: function(attrs, options) { - if (attrs instanceof Model) { - if (!attrs.collection) attrs.collection = this; - return attrs; - } - options || (options = {}); - options.collection = this; - var model = new this.model(attrs, options); - if (!model._validate(attrs, options)) { - this.trigger('invalid', this, attrs, options); - return false; - } - return model; - }, - - // Internal method to sever a model's ties to a collection. - _removeReference: function(model) { - if (this === model.collection) delete model.collection; - model.off('all', this._onModelEvent, this); - }, - - // Internal method called every time a model in the set fires an event. - // Sets need to update their indexes when models change ids. All other - // events simply proxy through. "add" and "remove" events that originate - // in other collections are ignored. - _onModelEvent: function(event, model, collection, options) { - if ((event === 'add' || event === 'remove') && collection !== this) return; - if (event === 'destroy') this.remove(model, options); - if (model && event === 'change:' + model.idAttribute) { - delete this._byId[model.previous(model.idAttribute)]; - if (model.id != null) this._byId[model.id] = model; - } - this.trigger.apply(this, arguments); - } - - }); - - // Underscore methods that we want to implement on the Collection. - // 90% of the core usefulness of Backbone Collections is actually implemented - // right here: - var methods = ['forEach', 'each', 'map', 'collect', 'reduce', 'foldl', - 'inject', 'reduceRight', 'foldr', 'find', 'detect', 'filter', 'select', - 'reject', 'every', 'all', 'some', 'any', 'include', 'contains', 'invoke', - 'max', 'min', 'toArray', 'size', 'first', 'head', 'take', 'initial', 'rest', - 'tail', 'drop', 'last', 'without', 'indexOf', 'shuffle', 'lastIndexOf', - 'isEmpty', 'chain']; - - // Mix in each Underscore method as a proxy to `Collection#models`. - _.each(methods, function(method) { - Collection.prototype[method] = function() { - var args = slice.call(arguments); - args.unshift(this.models); - return _[method].apply(_, args); - }; - }); - - // Underscore methods that take a property name as an argument. - var attributeMethods = ['groupBy', 'countBy', 'sortBy']; - - // Use attributes instead of properties. - _.each(attributeMethods, function(method) { - Collection.prototype[method] = function(value, context) { - var iterator = _.isFunction(value) ? value : function(model) { - return model.get(value); - }; - return _[method](this.models, iterator, context); - }; - }); - - // Backbone.View - // ------------- - - // Backbone Views are almost more convention than they are actual code. A View - // is simply a JavaScript object that represents a logical chunk of UI in the - // DOM. This might be a single item, an entire list, a sidebar or panel, or - // even the surrounding frame which wraps your whole app. Defining a chunk of - // UI as a **View** allows you to define your DOM events declaratively, without - // having to worry about render order ... and makes it easy for the view to - // react to specific changes in the state of your models. - - // Creating a Backbone.View creates its initial element outside of the DOM, - // if an existing element is not provided... - var View = Backbone.View = function(options) { - this.cid = _.uniqueId('view'); - this._configure(options || {}); - this._ensureElement(); - this.initialize.apply(this, arguments); - this.delegateEvents(); - }; - - // Cached regex to split keys for `delegate`. - var delegateEventSplitter = /^(\S+)\s*(.*)$/; - - // List of view options to be merged as properties. - var viewOptions = ['model', 'collection', 'el', 'id', 'attributes', 'className', 'tagName', 'events']; - - // Set up all inheritable **Backbone.View** properties and methods. - _.extend(View.prototype, Events, { - - // The default `tagName` of a View's element is `"div"`. - tagName: 'div', - - // jQuery delegate for element lookup, scoped to DOM elements within the - // current view. This should be prefered to global lookups where possible. - $: function(selector) { - return this.$el.find(selector); - }, - - // Initialize is an empty function by default. Override it with your own - // initialization logic. - initialize: function(){}, - - // **render** is the core function that your view should override, in order - // to populate its element (`this.el`), with the appropriate HTML. The - // convention is for **render** to always return `this`. - render: function() { - return this; - }, - - // Remove this view by taking the element out of the DOM, and removing any - // applicable Backbone.Events listeners. - remove: function() { - this.$el.remove(); - this.stopListening(); - return this; - }, - - // Change the view's element (`this.el` property), including event - // re-delegation. - setElement: function(element, delegate) { - if (this.$el) this.undelegateEvents(); - this.$el = element instanceof Backbone.$ ? element : Backbone.$(element); - this.el = this.$el[0]; - if (delegate !== false) this.delegateEvents(); - return this; - }, - - // Set callbacks, where `this.events` is a hash of - // - // *{"event selector": "callback"}* - // - // { - // 'mousedown .title': 'edit', - // 'click .button': 'save' - // 'click .open': function(e) { ... } - // } - // - // pairs. Callbacks will be bound to the view, with `this` set properly. - // Uses event delegation for efficiency. - // Omitting the selector binds the event to `this.el`. - // This only works for delegate-able events: not `focus`, `blur`, and - // not `change`, `submit`, and `reset` in Internet Explorer. - delegateEvents: function(events) { - if (!(events || (events = _.result(this, 'events')))) return this; - this.undelegateEvents(); - for (var key in events) { - var method = events[key]; - if (!_.isFunction(method)) method = this[events[key]]; - if (!method) continue; - - var match = key.match(delegateEventSplitter); - var eventName = match[1], selector = match[2]; - method = _.bind(method, this); - eventName += '.delegateEvents' + this.cid; - if (selector === '') { - this.$el.on(eventName, method); - } else { - this.$el.on(eventName, selector, method); - } - } - return this; - }, - - // Clears all callbacks previously bound to the view with `delegateEvents`. - // You usually don't need to use this, but may wish to if you have multiple - // Backbone views attached to the same DOM element. - undelegateEvents: function() { - this.$el.off('.delegateEvents' + this.cid); - return this; - }, - - // Performs the initial configuration of a View with a set of options. - // Keys with special meaning *(e.g. model, collection, id, className)* are - // attached directly to the view. See `viewOptions` for an exhaustive - // list. - _configure: function(options) { - if (this.options) options = _.extend({}, _.result(this, 'options'), options); - _.extend(this, _.pick(options, viewOptions)); - this.options = options; - }, - - // Ensure that the View has a DOM element to render into. - // If `this.el` is a string, pass it through `$()`, take the first - // matching element, and re-assign it to `el`. Otherwise, create - // an element from the `id`, `className` and `tagName` properties. - _ensureElement: function() { - if (!this.el) { - var attrs = _.extend({}, _.result(this, 'attributes')); - if (this.id) attrs.id = _.result(this, 'id'); - if (this.className) attrs['class'] = _.result(this, 'className'); - var $el = Backbone.$('<' + _.result(this, 'tagName') + '>').attr(attrs); - this.setElement($el, false); - } else { - this.setElement(_.result(this, 'el'), false); - } - } - - }); - - // Backbone.sync - // ------------- - - // Override this function to change the manner in which Backbone persists - // models to the server. You will be passed the type of request, and the - // model in question. By default, makes a RESTful Ajax request - // to the model's `url()`. Some possible customizations could be: - // - // * Use `setTimeout` to batch rapid-fire updates into a single request. - // * Send up the models as XML instead of JSON. - // * Persist models via WebSockets instead of Ajax. - // - // Turn on `Backbone.emulateHTTP` in order to send `PUT` and `DELETE` requests - // as `POST`, with a `_method` parameter containing the true HTTP method, - // as well as all requests with the body as `application/x-www-form-urlencoded` - // instead of `application/json` with the model in a param named `model`. - // Useful when interfacing with server-side languages like **PHP** that make - // it difficult to read the body of `PUT` requests. - Backbone.sync = function(method, model, options) { - var type = methodMap[method]; - - // Default options, unless specified. - _.defaults(options || (options = {}), { - emulateHTTP: Backbone.emulateHTTP, - emulateJSON: Backbone.emulateJSON - }); - - // Default JSON-request options. - var params = {type: type, dataType: 'json'}; - - // Ensure that we have a URL. - if (!options.url) { - params.url = _.result(model, 'url') || urlError(); - } - - // Ensure that we have the appropriate request data. - if (options.data == null && model && (method === 'create' || method === 'update' || method === 'patch')) { - params.contentType = 'application/json'; - params.data = JSON.stringify(options.attrs || model.toJSON(options)); - } - - // For older servers, emulate JSON by encoding the request into an HTML-form. - if (options.emulateJSON) { - params.contentType = 'application/x-www-form-urlencoded'; - params.data = params.data ? {model: params.data} : {}; - } - - // For older servers, emulate HTTP by mimicking the HTTP method with `_method` - // And an `X-HTTP-Method-Override` header. - if (options.emulateHTTP && (type === 'PUT' || type === 'DELETE' || type === 'PATCH')) { - params.type = 'POST'; - if (options.emulateJSON) params.data._method = type; - var beforeSend = options.beforeSend; - options.beforeSend = function(xhr) { - xhr.setRequestHeader('X-HTTP-Method-Override', type); - if (beforeSend) return beforeSend.apply(this, arguments); - }; - } - - // Don't process data on a non-GET request. - if (params.type !== 'GET' && !options.emulateJSON) { - params.processData = false; - } - - // If we're sending a `PATCH` request, and we're in an old Internet Explorer - // that still has ActiveX enabled by default, override jQuery to use that - // for XHR instead. Remove this line when jQuery supports `PATCH` on IE8. - if (params.type === 'PATCH' && window.ActiveXObject && - !(window.external && window.external.msActiveXFilteringEnabled)) { - params.xhr = function() { - return new ActiveXObject("Microsoft.XMLHTTP"); - }; - } - - // Make the request, allowing the user to override any Ajax options. - var xhr = options.xhr = Backbone.ajax(_.extend(params, options)); - model.trigger('request', model, xhr, options); - return xhr; - }; - - // Map from CRUD to HTTP for our default `Backbone.sync` implementation. - var methodMap = { - 'create': 'POST', - 'update': 'PUT', - 'patch': 'PATCH', - 'delete': 'DELETE', - 'read': 'GET' - }; - - // Set the default implementation of `Backbone.ajax` to proxy through to `$`. - // Override this if you'd like to use a different library. - Backbone.ajax = function() { - return Backbone.$.ajax.apply(Backbone.$, arguments); - }; - - // Backbone.Router - // --------------- - - // Routers map faux-URLs to actions, and fire events when routes are - // matched. Creating a new one sets its `routes` hash, if not set statically. - var Router = Backbone.Router = function(options) { - options || (options = {}); - if (options.routes) this.routes = options.routes; - this._bindRoutes(); - this.initialize.apply(this, arguments); - }; - - // Cached regular expressions for matching named param parts and splatted - // parts of route strings. - var optionalParam = /\((.*?)\)/g; - var namedParam = /(\(\?)?:\w+/g; - var splatParam = /\*\w+/g; - var escapeRegExp = /[\-{}\[\]+?.,\\\^$|#\s]/g; - - // Set up all inheritable **Backbone.Router** properties and methods. - _.extend(Router.prototype, Events, { - - // Initialize is an empty function by default. Override it with your own - // initialization logic. - initialize: function(){}, - - // Manually bind a single named route to a callback. For example: - // - // this.route('search/:query/p:num', 'search', function(query, num) { - // ... - // }); - // - route: function(route, name, callback) { - if (!_.isRegExp(route)) route = this._routeToRegExp(route); - if (_.isFunction(name)) { - callback = name; - name = ''; - } - if (!callback) callback = this[name]; - var router = this; - Backbone.history.route(route, function(fragment) { - var args = router._extractParameters(route, fragment); - callback && callback.apply(router, args); - router.trigger.apply(router, ['route:' + name].concat(args)); - router.trigger('route', name, args); - Backbone.history.trigger('route', router, name, args); - }); - return this; - }, - - // Simple proxy to `Backbone.history` to save a fragment into the history. - navigate: function(fragment, options) { - Backbone.history.navigate(fragment, options); - return this; - }, - - // Bind all defined routes to `Backbone.history`. We have to reverse the - // order of the routes here to support behavior where the most general - // routes can be defined at the bottom of the route map. - _bindRoutes: function() { - if (!this.routes) return; - this.routes = _.result(this, 'routes'); - var route, routes = _.keys(this.routes); - while ((route = routes.pop()) != null) { - this.route(route, this.routes[route]); - } - }, - - // Convert a route string into a regular expression, suitable for matching - // against the current location hash. - _routeToRegExp: function(route) { - route = route.replace(escapeRegExp, '\\$&') - .replace(optionalParam, '(?:$1)?') - .replace(namedParam, function(match, optional){ - return optional ? match : '([^\/]+)'; - }) - .replace(splatParam, '(.*?)'); - return new RegExp('^' + route + '$'); - }, - - // Given a route, and a URL fragment that it matches, return the array of - // extracted decoded parameters. Empty or unmatched parameters will be - // treated as `null` to normalize cross-browser behavior. - _extractParameters: function(route, fragment) { - var params = route.exec(fragment).slice(1); - return _.map(params, function(param) { - return param ? decodeURIComponent(param) : null; - }); - } - - }); - - // Backbone.History - // ---------------- - - // Handles cross-browser history management, based on either - // [pushState](http://diveintohtml5.info/history.html) and real URLs, or - // [onhashchange](https://developer.mozilla.org/en-US/docs/DOM/window.onhashchange) - // and URL fragments. If the browser supports neither (old IE, natch), - // falls back to polling. - var History = Backbone.History = function() { - this.handlers = []; - _.bindAll(this, 'checkUrl'); - - // Ensure that `History` can be used outside of the browser. - if (typeof window !== 'undefined') { - this.location = window.location; - this.history = window.history; - } - }; - - // Cached regex for stripping a leading hash/slash and trailing space. - var routeStripper = /^[#\/]|\s+$/g; - - // Cached regex for stripping leading and trailing slashes. - var rootStripper = /^\/+|\/+$/g; - - // Cached regex for detecting MSIE. - var isExplorer = /msie [\w.]+/; - - // Cached regex for removing a trailing slash. - var trailingSlash = /\/$/; - - // Has the history handling already been started? - History.started = false; - - // Set up all inheritable **Backbone.History** properties and methods. - _.extend(History.prototype, Events, { - - // The default interval to poll for hash changes, if necessary, is - // twenty times a second. - interval: 50, - - // Gets the true hash value. Cannot use location.hash directly due to bug - // in Firefox where location.hash will always be decoded. - getHash: function(window) { - var match = (window || this).location.href.match(/#(.*)$/); - return match ? match[1] : ''; - }, - - // Get the cross-browser normalized URL fragment, either from the URL, - // the hash, or the override. - getFragment: function(fragment, forcePushState) { - if (fragment == null) { - if (this._hasPushState || !this._wantsHashChange || forcePushState) { - fragment = this.location.pathname; - var root = this.root.replace(trailingSlash, ''); - if (!fragment.indexOf(root)) fragment = fragment.substr(root.length); - } else { - fragment = this.getHash(); - } - } - return fragment.replace(routeStripper, ''); - }, - - // Start the hash change handling, returning `true` if the current URL matches - // an existing route, and `false` otherwise. - start: function(options) { - if (History.started) throw new Error("Backbone.history has already been started"); - History.started = true; - - // Figure out the initial configuration. Do we need an iframe? - // Is pushState desired ... is it available? - this.options = _.extend({}, {root: '/'}, this.options, options); - this.root = this.options.root; - this._wantsHashChange = this.options.hashChange !== false; - this._wantsPushState = !!this.options.pushState; - this._hasPushState = !!(this.options.pushState && this.history && this.history.pushState); - var fragment = this.getFragment(); - var docMode = document.documentMode; - var oldIE = (isExplorer.exec(navigator.userAgent.toLowerCase()) && (!docMode || docMode <= 7)); - - // Normalize root to always include a leading and trailing slash. - this.root = ('/' + this.root + '/').replace(rootStripper, '/'); - - if (oldIE && this._wantsHashChange) { - this.iframe = Backbone.$('<iframe src="javascript:0" tabindex="-1" />').hide().appendTo('body')[0].contentWindow; - this.navigate(fragment); - } - - // Depending on whether we're using pushState or hashes, and whether - // 'onhashchange' is supported, determine how we check the URL state. - if (this._hasPushState) { - Backbone.$(window).on('popstate', this.checkUrl); - } else if (this._wantsHashChange && ('onhashchange' in window) && !oldIE) { - Backbone.$(window).on('hashchange', this.checkUrl); - } else if (this._wantsHashChange) { - this._checkUrlInterval = setInterval(this.checkUrl, this.interval); - } - - // Determine if we need to change the base url, for a pushState link - // opened by a non-pushState browser. - this.fragment = fragment; - var loc = this.location; - var atRoot = loc.pathname.replace(/[^\/]$/, '$&/') === this.root; - - // If we've started off with a route from a `pushState`-enabled browser, - // but we're currently in a browser that doesn't support it... - if (this._wantsHashChange && this._wantsPushState && !this._hasPushState && !atRoot) { - this.fragment = this.getFragment(null, true); - this.location.replace(this.root + this.location.search + '#' + this.fragment); - // Return immediately as browser will do redirect to new url - return true; - - // Or if we've started out with a hash-based route, but we're currently - // in a browser where it could be `pushState`-based instead... - } else if (this._wantsPushState && this._hasPushState && atRoot && loc.hash) { - this.fragment = this.getHash().replace(routeStripper, ''); - this.history.replaceState({}, document.title, this.root + this.fragment + loc.search); - } - - if (!this.options.silent) return this.loadUrl(); - }, - - // Disable Backbone.history, perhaps temporarily. Not useful in a real app, - // but possibly useful for unit testing Routers. - stop: function() { - Backbone.$(window).off('popstate', this.checkUrl).off('hashchange', this.checkUrl); - clearInterval(this._checkUrlInterval); - History.started = false; - }, - - // Add a route to be tested when the fragment changes. Routes added later - // may override previous routes. - route: function(route, callback) { - this.handlers.unshift({route: route, callback: callback}); - }, - - // Checks the current URL to see if it has changed, and if it has, - // calls `loadUrl`, normalizing across the hidden iframe. - checkUrl: function(e) { - var current = this.getFragment(); - if (current === this.fragment && this.iframe) { - current = this.getFragment(this.getHash(this.iframe)); - } - if (current === this.fragment) return false; - if (this.iframe) this.navigate(current); - this.loadUrl() || this.loadUrl(this.getHash()); - }, - - // Attempt to load the current URL fragment. If a route succeeds with a - // match, returns `true`. If no defined routes matches the fragment, - // returns `false`. - loadUrl: function(fragmentOverride) { - var fragment = this.fragment = this.getFragment(fragmentOverride); - var matched = _.any(this.handlers, function(handler) { - if (handler.route.test(fragment)) { - handler.callback(fragment); - return true; - } - }); - return matched; - }, - - // Save a fragment into the hash history, or replace the URL state if the - // 'replace' option is passed. You are responsible for properly URL-encoding - // the fragment in advance. - // - // The options object can contain `trigger: true` if you wish to have the - // route callback be fired (not usually desirable), or `replace: true`, if - // you wish to modify the current URL without adding an entry to the history. - navigate: function(fragment, options) { - if (!History.started) return false; - if (!options || options === true) options = {trigger: options}; - fragment = this.getFragment(fragment || ''); - if (this.fragment === fragment) return; - this.fragment = fragment; - var url = this.root + fragment; - - // If pushState is available, we use it to set the fragment as a real URL. - if (this._hasPushState) { - this.history[options.replace ? 'replaceState' : 'pushState']({}, document.title, url); - - // If hash changes haven't been explicitly disabled, update the hash - // fragment to store history. - } else if (this._wantsHashChange) { - this._updateHash(this.location, fragment, options.replace); - if (this.iframe && (fragment !== this.getFragment(this.getHash(this.iframe)))) { - // Opening and closing the iframe tricks IE7 and earlier to push a - // history entry on hash-tag change. When replace is true, we don't - // want this. - if(!options.replace) this.iframe.document.open().close(); - this._updateHash(this.iframe.location, fragment, options.replace); - } - - // If you've told us that you explicitly don't want fallback hashchange- - // based history, then `navigate` becomes a page refresh. - } else { - return this.location.assign(url); - } - if (options.trigger) this.loadUrl(fragment); - }, - - // Update the hash location, either replacing the current entry, or adding - // a new one to the browser history. - _updateHash: function(location, fragment, replace) { - if (replace) { - var href = location.href.replace(/(javascript:|#).*$/, ''); - location.replace(href + '#' + fragment); - } else { - // Some browsers require that `hash` contains a leading #. - location.hash = '#' + fragment; - } - } - - }); - - // Create the default Backbone.history. - Backbone.history = new History; - - // Helpers - // ------- - - // Helper function to correctly set up the prototype chain, for subclasses. - // Similar to `goog.inherits`, but uses a hash of prototype properties and - // class properties to be extended. - var extend = function(protoProps, staticProps) { - var parent = this; - var child; - - // The constructor function for the new subclass is either defined by you - // (the "constructor" property in your `extend` definition), or defaulted - // by us to simply call the parent's constructor. - if (protoProps && _.has(protoProps, 'constructor')) { - child = protoProps.constructor; - } else { - child = function(){ return parent.apply(this, arguments); }; - } - - // Add static properties to the constructor function, if supplied. - _.extend(child, parent, staticProps); - - // Set the prototype chain to inherit from `parent`, without calling - // `parent`'s constructor function. - var Surrogate = function(){ this.constructor = child; }; - Surrogate.prototype = parent.prototype; - child.prototype = new Surrogate; - - // Add prototype properties (instance properties) to the subclass, - // if supplied. - if (protoProps) _.extend(child.prototype, protoProps); - - // Set a convenience property in case the parent's prototype is needed - // later. - child.__super__ = parent.prototype; - - return child; - }; - - // Set up inheritance for the model, collection, router, view and history. - Model.extend = Collection.extend = Router.extend = View.extend = History.extend = extend; - - // Throw an error when a URL is needed, and none is supplied. - var urlError = function() { - throw new Error('A "url" property or function must be specified'); - }; - - // Wrap an optional error callback with a fallback error event. - var wrapError = function (model, options) { - var error = options.error; - options.error = function(resp) { - if (error) error(model, resp, options); - model.trigger('error', model, resp, options); - }; - }; - -}).call(this); diff --git a/src/UI/JsLibraries/backbone.marionette.js b/src/UI/JsLibraries/backbone.marionette.js deleted file mode 100644 index 5ad3a5d9a..000000000 --- a/src/UI/JsLibraries/backbone.marionette.js +++ /dev/null @@ -1,2329 +0,0 @@ -// MarionetteJS (Backbone.Marionette) -// ---------------------------------- -// v1.0.4 -// -// Copyright (c)2013 Derick Bailey, Muted Solutions, LLC. -// Distributed under MIT license -// -// http://marionettejs.com - - - -/*! - * Includes BabySitter - * https://github.com/marionettejs/backbone.babysitter/ - * - * Includes Wreqr - * https://github.com/marionettejs/backbone.wreqr/ - */ - -// Backbone.BabySitter -// ------------------- -// v0.0.6 -// -// Copyright (c)2013 Derick Bailey, Muted Solutions, LLC. -// Distributed under MIT license -// -// http://github.com/babysitterjs/backbone.babysitter - -// Backbone.ChildViewContainer -// --------------------------- -// -// Provide a container to store, retrieve and -// shut down child views. - -Backbone.ChildViewContainer = (function(Backbone, _){ - - // Container Constructor - // --------------------- - - var Container = function(views){ - this._views = {}; - this._indexByModel = {}; - this._indexByCustom = {}; - this._updateLength(); - - _.each(views, this.add, this); - }; - - // Container Methods - // ----------------- - - _.extend(Container.prototype, { - - // Add a view to this container. Stores the view - // by `cid` and makes it searchable by the model - // cid (and model itself). Optionally specify - // a custom key to store an retrieve the view. - add: function(view, customIndex){ - var viewCid = view.cid; - - // store the view - this._views[viewCid] = view; - - // index it by model - if (view.model){ - this._indexByModel[view.model.cid] = viewCid; - } - - // index by custom - if (customIndex){ - this._indexByCustom[customIndex] = viewCid; - } - - this._updateLength(); - }, - - // Find a view by the model that was attached to - // it. Uses the model's `cid` to find it. - findByModel: function(model){ - return this.findByModelCid(model.cid); - }, - - // Find a view by the `cid` of the model that was attached to - // it. Uses the model's `cid` to find the view `cid` and - // retrieve the view using it. - findByModelCid: function(modelCid){ - var viewCid = this._indexByModel[modelCid]; - return this.findByCid(viewCid); - }, - - // Find a view by a custom indexer. - findByCustom: function(index){ - var viewCid = this._indexByCustom[index]; - return this.findByCid(viewCid); - }, - - // Find by index. This is not guaranteed to be a - // stable index. - findByIndex: function(index){ - return _.values(this._views)[index]; - }, - - // retrieve a view by it's `cid` directly - findByCid: function(cid){ - return this._views[cid]; - }, - - // Remove a view - remove: function(view){ - var viewCid = view.cid; - - // delete model index - if (view.model){ - delete this._indexByModel[view.model.cid]; - } - - // delete custom index - _.any(this._indexByCustom, function(cid, key) { - if (cid === viewCid) { - delete this._indexByCustom[key]; - return true; - } - }, this); - - // remove the view from the container - delete this._views[viewCid]; - - // update the length - this._updateLength(); - }, - - // Call a method on every view in the container, - // passing parameters to the call method one at a - // time, like `function.call`. - call: function(method){ - this.apply(method, _.tail(arguments)); - }, - - // Apply a method on every view in the container, - // passing parameters to the call method one at a - // time, like `function.apply`. - apply: function(method, args){ - _.each(this._views, function(view){ - if (_.isFunction(view[method])){ - view[method].apply(view, args || []); - } - }); - }, - - // Update the `.length` attribute on this container - _updateLength: function(){ - this.length = _.size(this._views); - } - }); - - // Borrowing this code from Backbone.Collection: - // http://backbonejs.org/docs/backbone.html#section-106 - // - // Mix in methods from Underscore, for iteration, and other - // collection related features. - var methods = ['forEach', 'each', 'map', 'find', 'detect', 'filter', - 'select', 'reject', 'every', 'all', 'some', 'any', 'include', - 'contains', 'invoke', 'toArray', 'first', 'initial', 'rest', - 'last', 'without', 'isEmpty', 'pluck']; - - _.each(methods, function(method) { - Container.prototype[method] = function() { - var views = _.values(this._views); - var args = [views].concat(_.toArray(arguments)); - return _[method].apply(_, args); - }; - }); - - // return the public API - return Container; -})(Backbone, _); - -// Backbone.Wreqr (Backbone.Marionette) -// ---------------------------------- -// v0.2.0 -// -// Copyright (c)2013 Derick Bailey, Muted Solutions, LLC. -// Distributed under MIT license -// -// http://github.com/marionettejs/backbone.wreqr - - -Backbone.Wreqr = (function(Backbone, Marionette, _){ - "use strict"; - var Wreqr = {}; - - // Handlers -// -------- -// A registry of functions to call, given a name - -Wreqr.Handlers = (function(Backbone, _){ - "use strict"; - - // Constructor - // ----------- - - var Handlers = function(options){ - this.options = options; - this._wreqrHandlers = {}; - - if (_.isFunction(this.initialize)){ - this.initialize(options); - } - }; - - Handlers.extend = Backbone.Model.extend; - - // Instance Members - // ---------------- - - _.extend(Handlers.prototype, Backbone.Events, { - - // Add multiple handlers using an object literal configuration - setHandlers: function(handlers){ - _.each(handlers, function(handler, name){ - var context = null; - - if (_.isObject(handler) && !_.isFunction(handler)){ - context = handler.context; - handler = handler.callback; - } - - this.setHandler(name, handler, context); - }, this); - }, - - // Add a handler for the given name, with an - // optional context to run the handler within - setHandler: function(name, handler, context){ - var config = { - callback: handler, - context: context - }; - - this._wreqrHandlers[name] = config; - - this.trigger("handler:add", name, handler, context); - }, - - // Determine whether or not a handler is registered - hasHandler: function(name){ - return !! this._wreqrHandlers[name]; - }, - - // Get the currently registered handler for - // the specified name. Throws an exception if - // no handler is found. - getHandler: function(name){ - var config = this._wreqrHandlers[name]; - - if (!config){ - throw new Error("Handler not found for '" + name + "'"); - } - - return function(){ - var args = Array.prototype.slice.apply(arguments); - return config.callback.apply(config.context, args); - }; - }, - - // Remove a handler for the specified name - removeHandler: function(name){ - delete this._wreqrHandlers[name]; - }, - - // Remove all handlers from this registry - removeAllHandlers: function(){ - this._wreqrHandlers = {}; - } - }); - - return Handlers; -})(Backbone, _); - - // Wreqr.CommandStorage -// -------------------- -// -// Store and retrieve commands for execution. -Wreqr.CommandStorage = (function(){ - "use strict"; - - // Constructor function - var CommandStorage = function(options){ - this.options = options; - this._commands = {}; - - if (_.isFunction(this.initialize)){ - this.initialize(options); - } - }; - - // Instance methods - _.extend(CommandStorage.prototype, Backbone.Events, { - - // Get an object literal by command name, that contains - // the `commandName` and the `instances` of all commands - // represented as an array of arguments to process - getCommands: function(commandName){ - var commands = this._commands[commandName]; - - // we don't have it, so add it - if (!commands){ - - // build the configuration - commands = { - command: commandName, - instances: [] - }; - - // store it - this._commands[commandName] = commands; - } - - return commands; - }, - - // Add a command by name, to the storage and store the - // args for the command - addCommand: function(commandName, args){ - var command = this.getCommands(commandName); - command.instances.push(args); - }, - - // Clear all commands for the given `commandName` - clearCommands: function(commandName){ - var command = this.getCommands(commandName); - command.instances = []; - } - }); - - return CommandStorage; -})(); - - // Wreqr.Commands -// -------------- -// -// A simple command pattern implementation. Register a command -// handler and execute it. -Wreqr.Commands = (function(Wreqr){ - "use strict"; - - return Wreqr.Handlers.extend({ - // default storage type - storageType: Wreqr.CommandStorage, - - constructor: function(options){ - this.options = options || {}; - - this._initializeStorage(this.options); - this.on("handler:add", this._executeCommands, this); - - var args = Array.prototype.slice.call(arguments); - Wreqr.Handlers.prototype.constructor.apply(this, args); - }, - - // Execute a named command with the supplied args - execute: function(name, args){ - name = arguments[0]; - args = Array.prototype.slice.call(arguments, 1); - - if (this.hasHandler(name)){ - this.getHandler(name).apply(this, args); - } else { - this.storage.addCommand(name, args); - } - - }, - - // Internal method to handle bulk execution of stored commands - _executeCommands: function(name, handler, context){ - var command = this.storage.getCommands(name); - - // loop through and execute all the stored command instances - _.each(command.instances, function(args){ - handler.apply(context, args); - }); - - this.storage.clearCommands(name); - }, - - // Internal method to initialize storage either from the type's - // `storageType` or the instance `options.storageType`. - _initializeStorage: function(options){ - var storage; - - var StorageType = options.storageType || this.storageType; - if (_.isFunction(StorageType)){ - storage = new StorageType(); - } else { - storage = StorageType; - } - - this.storage = storage; - } - }); - -})(Wreqr); - - // Wreqr.RequestResponse -// --------------------- -// -// A simple request/response implementation. Register a -// request handler, and return a response from it -Wreqr.RequestResponse = (function(Wreqr){ - "use strict"; - - return Wreqr.Handlers.extend({ - request: function(){ - var name = arguments[0]; - var args = Array.prototype.slice.call(arguments, 1); - - return this.getHandler(name).apply(this, args); - } - }); - -})(Wreqr); - - // Event Aggregator -// ---------------- -// A pub-sub object that can be used to decouple various parts -// of an application through event-driven architecture. - -Wreqr.EventAggregator = (function(Backbone, _){ - "use strict"; - var EA = function(){}; - - // Copy the `extend` function used by Backbone's classes - EA.extend = Backbone.Model.extend; - - // Copy the basic Backbone.Events on to the event aggregator - _.extend(EA.prototype, Backbone.Events); - - return EA; -})(Backbone, _); - - - return Wreqr; -})(Backbone, Backbone.Marionette, _); - -var Marionette = (function(global, Backbone, _){ - "use strict"; - - // Define and export the Marionette namespace - var Marionette = {}; - Backbone.Marionette = Marionette; - - // Get the DOM manipulator for later use - Marionette.$ = Backbone.$; - -// Helpers -// ------- - -// For slicing `arguments` in functions -var protoSlice = Array.prototype.slice; -function slice(args) { - return protoSlice.call(args); -} - -function throwError(message, name) { - var error = new Error(message); - error.name = name || 'Error'; - throw error; -} - -// Marionette.extend -// ----------------- - -// Borrow the Backbone `extend` method so we can use it as needed -Marionette.extend = Backbone.Model.extend; - -// Marionette.getOption -// -------------------- - -// Retrieve an object, function or other value from a target -// object or its `options`, with `options` taking precedence. -Marionette.getOption = function(target, optionName){ - if (!target || !optionName){ return; } - var value; - - if (target.options && (optionName in target.options) && (target.options[optionName] !== undefined)){ - value = target.options[optionName]; - } else { - value = target[optionName]; - } - - return value; -}; - -// Trigger an event and a corresponding method name. Examples: -// -// `this.triggerMethod("foo")` will trigger the "foo" event and -// call the "onFoo" method. -// -// `this.triggerMethod("foo:bar") will trigger the "foo:bar" event and -// call the "onFooBar" method. -Marionette.triggerMethod = (function(){ - - // split the event name on the : - var splitter = /(^|:)(\w)/gi; - - // take the event section ("section1:section2:section3") - // and turn it in to uppercase name - function getEventName(match, prefix, eventName) { - return eventName.toUpperCase(); - } - - // actual triggerMethod name - var triggerMethod = function(event) { - // get the method name from the event name - var methodName = 'on' + event.replace(splitter, getEventName); - var method = this[methodName]; - - // trigger the event - this.trigger.apply(this, arguments); - - // call the onMethodName if it exists - if (_.isFunction(method)) { - // pass all arguments, except the event name - return method.apply(this, _.tail(arguments)); - } - }; - - return triggerMethod; -})(); - -// DOMRefresh -// ---------- -// -// Monitor a view's state, and after it has been rendered and shown -// in the DOM, trigger a "dom:refresh" event every time it is -// re-rendered. - -Marionette.MonitorDOMRefresh = (function(){ - // track when the view has been rendered - function handleShow(view){ - view._isShown = true; - triggerDOMRefresh(view); - } - - // track when the view has been shown in the DOM, - // using a Marionette.Region (or by other means of triggering "show") - function handleRender(view){ - view._isRendered = true; - triggerDOMRefresh(view); - } - - // Trigger the "dom:refresh" event and corresponding "onDomRefresh" method - function triggerDOMRefresh(view){ - if (view._isShown && view._isRendered){ - if (_.isFunction(view.triggerMethod)){ - view.triggerMethod("dom:refresh"); - } - } - } - - // Export public API - return function(view){ - view.listenTo(view, "show", function(){ - handleShow(view); - }); - - view.listenTo(view, "render", function(){ - handleRender(view); - }); - }; -})(); - - -// Marionette.bindEntityEvents & unbindEntityEvents -// --------------------------- -// -// These methods are used to bind/unbind a backbone "entity" (collection/model) -// to methods on a target object. -// -// The first parameter, `target`, must have a `listenTo` method from the -// EventBinder object. -// -// The second parameter is the entity (Backbone.Model or Backbone.Collection) -// to bind the events from. -// -// The third parameter is a hash of { "event:name": "eventHandler" } -// configuration. Multiple handlers can be separated by a space. A -// function can be supplied instead of a string handler name. - -(function(Marionette){ - "use strict"; - - // Bind the event to handlers specified as a string of - // handler names on the target object - function bindFromStrings(target, entity, evt, methods){ - var methodNames = methods.split(/\s+/); - - _.each(methodNames,function(methodName) { - - var method = target[methodName]; - if(!method) { - throwError("Method '"+ methodName +"' was configured as an event handler, but does not exist."); - } - - target.listenTo(entity, evt, method, target); - }); - } - - // Bind the event to a supplied callback function - function bindToFunction(target, entity, evt, method){ - target.listenTo(entity, evt, method, target); - } - - // Bind the event to handlers specified as a string of - // handler names on the target object - function unbindFromStrings(target, entity, evt, methods){ - var methodNames = methods.split(/\s+/); - - _.each(methodNames,function(methodName) { - var method = target[methodName]; - target.stopListening(entity, evt, method, target); - }); - } - - // Bind the event to a supplied callback function - function unbindToFunction(target, entity, evt, method){ - target.stopListening(entity, evt, method, target); - } - - - // generic looping function - function iterateEvents(target, entity, bindings, functionCallback, stringCallback){ - if (!entity || !bindings) { return; } - - // allow the bindings to be a function - if (_.isFunction(bindings)){ - bindings = bindings.call(target); - } - - // iterate the bindings and bind them - _.each(bindings, function(methods, evt){ - - // allow for a function as the handler, - // or a list of event names as a string - if (_.isFunction(methods)){ - functionCallback(target, entity, evt, methods); - } else { - stringCallback(target, entity, evt, methods); - } - - }); - } - - // Export Public API - Marionette.bindEntityEvents = function(target, entity, bindings){ - iterateEvents(target, entity, bindings, bindToFunction, bindFromStrings); - }; - - Marionette.unbindEntityEvents = function(target, entity, bindings){ - iterateEvents(target, entity, bindings, unbindToFunction, unbindFromStrings); - }; - -})(Marionette); - - -// Callbacks -// --------- - -// A simple way of managing a collection of callbacks -// and executing them at a later point in time, using jQuery's -// `Deferred` object. -Marionette.Callbacks = function(){ - this._deferred = Marionette.$.Deferred(); - this._callbacks = []; -}; - -_.extend(Marionette.Callbacks.prototype, { - - // Add a callback to be executed. Callbacks added here are - // guaranteed to execute, even if they are added after the - // `run` method is called. - add: function(callback, contextOverride){ - this._callbacks.push({cb: callback, ctx: contextOverride}); - - this._deferred.done(function(context, options){ - if (contextOverride){ context = contextOverride; } - callback.call(context, options); - }); - }, - - // Run all registered callbacks with the context specified. - // Additional callbacks can be added after this has been run - // and they will still be executed. - run: function(options, context){ - this._deferred.resolve(context, options); - }, - - // Resets the list of callbacks to be run, allowing the same list - // to be run multiple times - whenever the `run` method is called. - reset: function(){ - var callbacks = this._callbacks; - this._deferred = Marionette.$.Deferred(); - this._callbacks = []; - - _.each(callbacks, function(cb){ - this.add(cb.cb, cb.ctx); - }, this); - } -}); - - -// Marionette Controller -// --------------------- -// -// A multi-purpose object to use as a controller for -// modules and routers, and as a mediator for workflow -// and coordination of other objects, views, and more. -Marionette.Controller = function(options){ - this.triggerMethod = Marionette.triggerMethod; - this.options = options || {}; - - if (_.isFunction(this.initialize)){ - this.initialize(this.options); - } -}; - -Marionette.Controller.extend = Marionette.extend; - -// Controller Methods -// -------------- - -// Ensure it can trigger events with Backbone.Events -_.extend(Marionette.Controller.prototype, Backbone.Events, { - close: function(){ - this.stopListening(); - this.triggerMethod("close"); - this.unbind(); - } -}); - -// Region -// ------ -// -// Manage the visual regions of your composite application. See -// http://lostechies.com/derickbailey/2011/12/12/composite-js-apps-regions-and-region-managers/ - -Marionette.Region = function(options){ - this.options = options || {}; - - this.el = Marionette.getOption(this, "el"); - - if (!this.el){ - var err = new Error("An 'el' must be specified for a region."); - err.name = "NoElError"; - throw err; - } - - if (this.initialize){ - var args = Array.prototype.slice.apply(arguments); - this.initialize.apply(this, args); - } -}; - - -// Region Type methods -// ------------------- - -_.extend(Marionette.Region, { - - // Build an instance of a region by passing in a configuration object - // and a default region type to use if none is specified in the config. - // - // The config object should either be a string as a jQuery DOM selector, - // a Region type directly, or an object literal that specifies both - // a selector and regionType: - // - // ```js - // { - // selector: "#foo", - // regionType: MyCustomRegion - // } - // ``` - // - buildRegion: function(regionConfig, defaultRegionType){ - var regionIsString = (typeof regionConfig === "string"); - var regionSelectorIsString = (typeof regionConfig.selector === "string"); - var regionTypeIsUndefined = (typeof regionConfig.regionType === "undefined"); - var regionIsType = (typeof regionConfig === "function"); - - if (!regionIsType && !regionIsString && !regionSelectorIsString) { - throw new Error("Region must be specified as a Region type, a selector string or an object with selector property"); - } - - var selector, RegionType; - - // get the selector for the region - - if (regionIsString) { - selector = regionConfig; - } - - if (regionConfig.selector) { - selector = regionConfig.selector; - } - - // get the type for the region - - if (regionIsType){ - RegionType = regionConfig; - } - - if (!regionIsType && regionTypeIsUndefined) { - RegionType = defaultRegionType; - } - - if (regionConfig.regionType) { - RegionType = regionConfig.regionType; - } - - // build the region instance - var region = new RegionType({ - el: selector - }); - - // override the `getEl` function if we have a parentEl - // this must be overridden to ensure the selector is found - // on the first use of the region. if we try to assign the - // region's `el` to `parentEl.find(selector)` in the object - // literal to build the region, the element will not be - // guaranteed to be in the DOM already, and will cause problems - if (regionConfig.parentEl){ - - region.getEl = function(selector) { - var parentEl = regionConfig.parentEl; - if (_.isFunction(parentEl)){ - parentEl = parentEl(); - } - return parentEl.find(selector); - }; - } - - return region; - } - -}); - -// Region Instance Methods -// ----------------------- - -_.extend(Marionette.Region.prototype, Backbone.Events, { - - // Displays a backbone view instance inside of the region. - // Handles calling the `render` method for you. Reads content - // directly from the `el` attribute. Also calls an optional - // `onShow` and `close` method on your view, just after showing - // or just before closing the view, respectively. - show: function(view){ - - this.ensureEl(); - - var isViewClosed = view.isClosed || _.isUndefined(view.$el); - - var isDifferentView = view !== this.currentView; - - if (isDifferentView) { - this.close(); - } - - view.render(); - - if (isDifferentView || isViewClosed) { - this.open(view); - } - - this.currentView = view; - - Marionette.triggerMethod.call(this, "show", view); - Marionette.triggerMethod.call(view, "show"); - }, - - ensureEl: function(){ - if (!this.$el || this.$el.length === 0){ - this.$el = this.getEl(this.el); - } - }, - - // Override this method to change how the region finds the - // DOM element that it manages. Return a jQuery selector object. - getEl: function(selector){ - return Marionette.$(selector); - }, - - // Override this method to change how the new view is - // appended to the `$el` that the region is managing - open: function(view){ - this.$el.empty().append(view.el); - }, - - // Close the current view, if there is one. If there is no - // current view, it does nothing and returns immediately. - close: function(){ - var view = this.currentView; - if (!view || view.isClosed){ return; } - - // call 'close' or 'remove', depending on which is found - if (view.close) { view.close(); } - else if (view.remove) { view.remove(); } - - Marionette.triggerMethod.call(this, "close"); - - delete this.currentView; - }, - - // Attach an existing view to the region. This - // will not call `render` or `onShow` for the new view, - // and will not replace the current HTML for the `el` - // of the region. - attachView: function(view){ - this.currentView = view; - }, - - // Reset the region by closing any existing view and - // clearing out the cached `$el`. The next time a view - // is shown via this region, the region will re-query the - // DOM for the region's `el`. - reset: function(){ - this.close(); - delete this.$el; - } -}); - -// Copy the `extend` function used by Backbone's classes -Marionette.Region.extend = Marionette.extend; - -// Marionette.RegionManager -// ------------------------ -// -// Manage one or more related `Marionette.Region` objects. -Marionette.RegionManager = (function(Marionette){ - - var RegionManager = Marionette.Controller.extend({ - constructor: function(options){ - this._regions = {}; - Marionette.Controller.prototype.constructor.call(this, options); - }, - - // Add multiple regions using an object literal, where - // each key becomes the region name, and each value is - // the region definition. - addRegions: function(regionDefinitions, defaults){ - var regions = {}; - - _.each(regionDefinitions, function(definition, name){ - if (typeof definition === "string"){ - definition = { selector: definition }; - } - - if (definition.selector){ - definition = _.defaults({}, definition, defaults); - } - - var region = this.addRegion(name, definition); - regions[name] = region; - }, this); - - return regions; - }, - - // Add an individual region to the region manager, - // and return the region instance - addRegion: function(name, definition){ - var region; - - var isObject = _.isObject(definition); - var isString = _.isString(definition); - var hasSelector = !!definition.selector; - - if (isString || (isObject && hasSelector)){ - region = Marionette.Region.buildRegion(definition, Marionette.Region); - } else if (_.isFunction(definition)){ - region = Marionette.Region.buildRegion(definition, Marionette.Region); - } else { - region = definition; - } - - this._store(name, region); - this.triggerMethod("region:add", name, region); - return region; - }, - - // Get a region by name - get: function(name){ - return this._regions[name]; - }, - - // Remove a region by name - removeRegion: function(name){ - var region = this._regions[name]; - this._remove(name, region); - }, - - // Close all regions in the region manager, and - // remove them - removeRegions: function(){ - _.each(this._regions, function(region, name){ - this._remove(name, region); - }, this); - }, - - // Close all regions in the region manager, but - // leave them attached - closeRegions: function(){ - _.each(this._regions, function(region, name){ - region.close(); - }, this); - }, - - // Close all regions and shut down the region - // manager entirely - close: function(){ - this.removeRegions(); - var args = Array.prototype.slice.call(arguments); - Marionette.Controller.prototype.close.apply(this, args); - }, - - // internal method to store regions - _store: function(name, region){ - this._regions[name] = region; - this._setLength(); - }, - - // internal method to remove a region - _remove: function(name, region){ - region.close(); - delete this._regions[name]; - this._setLength(); - this.triggerMethod("region:remove", name, region); - }, - - // set the number of regions current held - _setLength: function(){ - this.length = _.size(this._regions); - } - - }); - - // Borrowing this code from Backbone.Collection: - // http://backbonejs.org/docs/backbone.html#section-106 - // - // Mix in methods from Underscore, for iteration, and other - // collection related features. - var methods = ['forEach', 'each', 'map', 'find', 'detect', 'filter', - 'select', 'reject', 'every', 'all', 'some', 'any', 'include', - 'contains', 'invoke', 'toArray', 'first', 'initial', 'rest', - 'last', 'without', 'isEmpty', 'pluck']; - - _.each(methods, function(method) { - RegionManager.prototype[method] = function() { - var regions = _.values(this._regions); - var args = [regions].concat(_.toArray(arguments)); - return _[method].apply(_, args); - }; - }); - - return RegionManager; -})(Marionette); - - -// Template Cache -// -------------- - -// Manage templates stored in `<script>` blocks, -// caching them for faster access. -Marionette.TemplateCache = function(templateId){ - this.templateId = templateId; -}; - -// TemplateCache object-level methods. Manage the template -// caches from these method calls instead of creating -// your own TemplateCache instances -_.extend(Marionette.TemplateCache, { - templateCaches: {}, - - // Get the specified template by id. Either - // retrieves the cached version, or loads it - // from the DOM. - get: function(templateId){ - var cachedTemplate = this.templateCaches[templateId]; - - if (!cachedTemplate){ - cachedTemplate = new Marionette.TemplateCache(templateId); - this.templateCaches[templateId] = cachedTemplate; - } - - return cachedTemplate.load(); - }, - - // Clear templates from the cache. If no arguments - // are specified, clears all templates: - // `clear()` - // - // If arguments are specified, clears each of the - // specified templates from the cache: - // `clear("#t1", "#t2", "...")` - clear: function(){ - var i; - var args = slice(arguments); - var length = args.length; - - if (length > 0){ - for(i=0; i<length; i++){ - delete this.templateCaches[args[i]]; - } - } else { - this.templateCaches = {}; - } - } -}); - -// TemplateCache instance methods, allowing each -// template cache object to manage its own state -// and know whether or not it has been loaded -_.extend(Marionette.TemplateCache.prototype, { - - // Internal method to load the template - load: function(){ - // Guard clause to prevent loading this template more than once - if (this.compiledTemplate){ - return this.compiledTemplate; - } - - // Load the template and compile it - var template = this.loadTemplate(this.templateId); - this.compiledTemplate = this.compileTemplate(template); - - return this.compiledTemplate; - }, - - // Load a template from the DOM, by default. Override - // this method to provide your own template retrieval - // For asynchronous loading with AMD/RequireJS, consider - // using a template-loader plugin as described here: - // https://github.com/marionettejs/backbone.marionette/wiki/Using-marionette-with-requirejs - loadTemplate: function(templateId){ - var template = Marionette.$(templateId).html(); - - if (!template || template.length === 0){ - throwError("Could not find template: '" + templateId + "'", "NoTemplateError"); - } - - return template; - }, - - // Pre-compile the template before caching it. Override - // this method if you do not need to pre-compile a template - // (JST / RequireJS for example) or if you want to change - // the template engine used (Handebars, etc). - compileTemplate: function(rawTemplate){ - return _.template(rawTemplate); - } -}); - - -// Renderer -// -------- - -// Render a template with data by passing in the template -// selector and the data to render. -Marionette.Renderer = { - - // Render a template with data. The `template` parameter is - // passed to the `TemplateCache` object to retrieve the - // template function. Override this method to provide your own - // custom rendering and template handling for all of Marionette. - render: function(template, data){ - - if (!template) { - var error = new Error("Cannot render the template since it's false, null or undefined."); - error.name = "TemplateNotFoundError"; - throw error; - } - - var templateFunc; - if (typeof template === "function"){ - templateFunc = template; - } else { - templateFunc = Marionette.TemplateCache.get(template); - } - - return templateFunc(data); - } -}; - - - -// Marionette.View -// --------------- - -// The core view type that other Marionette views extend from. -Marionette.View = Backbone.View.extend({ - - constructor: function(){ - _.bindAll(this, "render"); - - var args = Array.prototype.slice.apply(arguments); - Backbone.View.prototype.constructor.apply(this, args); - - Marionette.MonitorDOMRefresh(this); - this.listenTo(this, "show", this.onShowCalled, this); - }, - - // import the "triggerMethod" to trigger events with corresponding - // methods if the method exists - triggerMethod: Marionette.triggerMethod, - - // Get the template for this view - // instance. You can set a `template` attribute in the view - // definition or pass a `template: "whatever"` parameter in - // to the constructor options. - getTemplate: function(){ - return Marionette.getOption(this, "template"); - }, - - // Mix in template helper methods. Looks for a - // `templateHelpers` attribute, which can either be an - // object literal, or a function that returns an object - // literal. All methods and attributes from this object - // are copies to the object passed in. - mixinTemplateHelpers: function(target){ - target = target || {}; - var templateHelpers = this.templateHelpers; - if (_.isFunction(templateHelpers)){ - templateHelpers = templateHelpers.call(this); - } - return _.extend(target, templateHelpers); - }, - - // Configure `triggers` to forward DOM events to view - // events. `triggers: {"click .foo": "do:foo"}` - configureTriggers: function(){ - if (!this.triggers) { return; } - - var triggerEvents = {}; - - // Allow `triggers` to be configured as a function - var triggers = _.result(this, "triggers"); - - // Configure the triggers, prevent default - // action and stop propagation of DOM events - _.each(triggers, function(value, key){ - - // build the event handler function for the DOM event - triggerEvents[key] = function(e){ - - // stop the event in its tracks - if (e && e.preventDefault){ e.preventDefault(); } - if (e && e.stopPropagation){ e.stopPropagation(); } - - // build the args for the event - var args = { - view: this, - model: this.model, - collection: this.collection - }; - - // trigger the event - this.triggerMethod(value, args); - }; - - }, this); - - return triggerEvents; - }, - - // Overriding Backbone.View's delegateEvents to handle - // the `triggers`, `modelEvents`, and `collectionEvents` configuration - delegateEvents: function(events){ - this._delegateDOMEvents(events); - Marionette.bindEntityEvents(this, this.model, Marionette.getOption(this, "modelEvents")); - Marionette.bindEntityEvents(this, this.collection, Marionette.getOption(this, "collectionEvents")); - }, - - // internal method to delegate DOM events and triggers - _delegateDOMEvents: function(events){ - events = events || this.events; - if (_.isFunction(events)){ events = events.call(this); } - - var combinedEvents = {}; - var triggers = this.configureTriggers(); - _.extend(combinedEvents, events, triggers); - - Backbone.View.prototype.delegateEvents.call(this, combinedEvents); - }, - - // Overriding Backbone.View's undelegateEvents to handle unbinding - // the `triggers`, `modelEvents`, and `collectionEvents` config - undelegateEvents: function(){ - var args = Array.prototype.slice.call(arguments); - Backbone.View.prototype.undelegateEvents.apply(this, args); - - Marionette.unbindEntityEvents(this, this.model, Marionette.getOption(this, "modelEvents")); - Marionette.unbindEntityEvents(this, this.collection, Marionette.getOption(this, "collectionEvents")); - }, - - // Internal method, handles the `show` event. - onShowCalled: function(){}, - - // Default `close` implementation, for removing a view from the - // DOM and unbinding it. Regions will call this method - // for you. You can specify an `onClose` method in your view to - // add custom code that is called after the view is closed. - close: function(){ - if (this.isClosed) { return; } - - // allow the close to be stopped by returning `false` - // from the `onBeforeClose` method - var shouldClose = this.triggerMethod("before:close"); - if (shouldClose === false){ - return; - } - - // mark as closed before doing the actual close, to - // prevent infinite loops within "close" event handlers - // that are trying to close other views - this.isClosed = true; - this.triggerMethod("close"); - - // unbind UI elements - this.unbindUIElements(); - - // remove the view from the DOM - this.remove(); - }, - - // This method binds the elements specified in the "ui" hash inside the view's code with - // the associated jQuery selectors. - bindUIElements: function(){ - if (!this.ui) { return; } - - // store the ui hash in _uiBindings so they can be reset later - // and so re-rendering the view will be able to find the bindings - if (!this._uiBindings){ - this._uiBindings = this.ui; - } - - // get the bindings result, as a function or otherwise - var bindings = _.result(this, "_uiBindings"); - - // empty the ui so we don't have anything to start with - this.ui = {}; - - // bind each of the selectors - _.each(_.keys(bindings), function(key) { - var selector = bindings[key]; - this.ui[key] = this.$(selector); - }, this); - }, - - // This method unbinds the elements specified in the "ui" hash - unbindUIElements: function(){ - if (!this.ui){ return; } - - // delete all of the existing ui bindings - _.each(this.ui, function($el, name){ - delete this.ui[name]; - }, this); - - // reset the ui element to the original bindings configuration - this.ui = this._uiBindings; - delete this._uiBindings; - } -}); - -// Item View -// --------- - -// A single item view implementation that contains code for rendering -// with underscore.js templates, serializing the view's model or collection, -// and calling several methods on extended views, such as `onRender`. -Marionette.ItemView = Marionette.View.extend({ - - // Setting up the inheritance chain which allows changes to - // Marionette.View.prototype.constructor which allows overriding - constructor: function(){ - Marionette.View.prototype.constructor.apply(this, slice(arguments)); - }, - - // Serialize the model or collection for the view. If a model is - // found, `.toJSON()` is called. If a collection is found, `.toJSON()` - // is also called, but is used to populate an `items` array in the - // resulting data. If both are found, defaults to the model. - // You can override the `serializeData` method in your own view - // definition, to provide custom serialization for your view's data. - serializeData: function(){ - var data = {}; - - if (this.model) { - data = this.model.toJSON(); - } - else if (this.collection) { - data = { items: this.collection.toJSON() }; - } - - return data; - }, - - // Render the view, defaulting to underscore.js templates. - // You can override this in your view definition to provide - // a very specific rendering for your view. In general, though, - // you should override the `Marionette.Renderer` object to - // change how Marionette renders views. - render: function(){ - this.isClosed = false; - - this.triggerMethod("before:render", this); - this.triggerMethod("item:before:render", this); - - var data = this.serializeData(); - data = this.mixinTemplateHelpers(data); - - var template = this.getTemplate(); - var html = Marionette.Renderer.render(template, data); - - this.$el.html(html); - this.bindUIElements(); - - this.triggerMethod("render", this); - this.triggerMethod("item:rendered", this); - - return this; - }, - - // Override the default close event to add a few - // more events that are triggered. - close: function(){ - if (this.isClosed){ return; } - - this.triggerMethod('item:before:close'); - - Marionette.View.prototype.close.apply(this, slice(arguments)); - - this.triggerMethod('item:closed'); - } -}); - -// Collection View -// --------------- - -// A view that iterates over a Backbone.Collection -// and renders an individual ItemView for each model. -Marionette.CollectionView = Marionette.View.extend({ - // used as the prefix for item view events - // that are forwarded through the collectionview - itemViewEventPrefix: "itemview", - - // constructor - constructor: function(options){ - this._initChildViewStorage(); - - Marionette.View.prototype.constructor.apply(this, slice(arguments)); - - this._initialEvents(); - }, - - // Configured the initial events that the collection view - // binds to. Override this method to prevent the initial - // events, or to add your own initial events. - _initialEvents: function(){ - if (this.collection){ - this.listenTo(this.collection, "add", this.addChildView, this); - this.listenTo(this.collection, "remove", this.removeItemView, this); - this.listenTo(this.collection, "reset", this.render, this); - } - }, - - // Handle a child item added to the collection - addChildView: function(item, collection, options){ - this.closeEmptyView(); - var ItemView = this.getItemView(item); - var index = this.collection.indexOf(item); - this.addItemView(item, ItemView, index); - }, - - // Override from `Marionette.View` to guarantee the `onShow` method - // of child views is called. - onShowCalled: function(){ - this.children.each(function(child){ - Marionette.triggerMethod.call(child, "show"); - }); - }, - - // Internal method to trigger the before render callbacks - // and events - triggerBeforeRender: function(){ - this.triggerMethod("before:render", this); - this.triggerMethod("collection:before:render", this); - }, - - // Internal method to trigger the rendered callbacks and - // events - triggerRendered: function(){ - this.triggerMethod("render", this); - this.triggerMethod("collection:rendered", this); - }, - - // Render the collection of items. Override this method to - // provide your own implementation of a render function for - // the collection view. - render: function(){ - this.isClosed = false; - this.triggerBeforeRender(); - this._renderChildren(); - this.triggerRendered(); - return this; - }, - - // Internal method. Separated so that CompositeView can have - // more control over events being triggered, around the rendering - // process - _renderChildren: function(){ - this.closeEmptyView(); - this.closeChildren(); - - if (this.collection && this.collection.length > 0) { - this.showCollection(); - } else { - this.showEmptyView(); - } - }, - - // Internal method to loop through each item in the - // collection view and show it - showCollection: function(){ - var ItemView; - this.collection.each(function(item, index){ - ItemView = this.getItemView(item); - this.addItemView(item, ItemView, index); - }, this); - }, - - // Internal method to show an empty view in place of - // a collection of item views, when the collection is - // empty - showEmptyView: function(){ - var EmptyView = Marionette.getOption(this, "emptyView"); - - if (EmptyView && !this._showingEmptyView){ - this._showingEmptyView = true; - var model = new Backbone.Model(); - this.addItemView(model, EmptyView, 0); - } - }, - - // Internal method to close an existing emptyView instance - // if one exists. Called when a collection view has been - // rendered empty, and then an item is added to the collection. - closeEmptyView: function(){ - if (this._showingEmptyView){ - this.closeChildren(); - delete this._showingEmptyView; - } - }, - - // Retrieve the itemView type, either from `this.options.itemView` - // or from the `itemView` in the object definition. The "options" - // takes precedence. - getItemView: function(item){ - var itemView = Marionette.getOption(this, "itemView"); - - if (!itemView){ - throwError("An `itemView` must be specified", "NoItemViewError"); - } - - return itemView; - }, - - // Render the child item's view and add it to the - // HTML for the collection view. - addItemView: function(item, ItemView, index){ - // get the itemViewOptions if any were specified - var itemViewOptions = Marionette.getOption(this, "itemViewOptions"); - if (_.isFunction(itemViewOptions)){ - itemViewOptions = itemViewOptions.call(this, item, index); - } - - // build the view - var view = this.buildItemView(item, ItemView, itemViewOptions); - - // set up the child view event forwarding - this.addChildViewEventForwarding(view); - - // this view is about to be added - this.triggerMethod("before:item:added", view); - - // Store the child view itself so we can properly - // remove and/or close it later - this.children.add(view); - - // Render it and show it - this.renderItemView(view, index); - - // call the "show" method if the collection view - // has already been shown - if (this._isShown){ - Marionette.triggerMethod.call(view, "show"); - } - - // this view was added - this.triggerMethod("after:item:added", view); - }, - - // Set up the child view event forwarding. Uses an "itemview:" - // prefix in front of all forwarded events. - addChildViewEventForwarding: function(view){ - var prefix = Marionette.getOption(this, "itemViewEventPrefix"); - - // Forward all child item view events through the parent, - // prepending "itemview:" to the event name - this.listenTo(view, "all", function(){ - var args = slice(arguments); - args[0] = prefix + ":" + args[0]; - args.splice(1, 0, view); - - Marionette.triggerMethod.apply(this, args); - }, this); - }, - - // render the item view - renderItemView: function(view, index) { - view.render(); - this.appendHtml(this, view, index); - }, - - // Build an `itemView` for every model in the collection. - buildItemView: function(item, ItemViewType, itemViewOptions){ - var options = _.extend({model: item}, itemViewOptions); - return new ItemViewType(options); - }, - - // get the child view by item it holds, and remove it - removeItemView: function(item){ - var view = this.children.findByModel(item); - this.removeChildView(view); - this.checkEmpty(); - }, - - // Remove the child view and close it - removeChildView: function(view){ - - // shut down the child view properly, - // including events that the collection has from it - if (view){ - this.stopListening(view); - - // call 'close' or 'remove', depending on which is found - if (view.close) { view.close(); } - else if (view.remove) { view.remove(); } - - this.children.remove(view); - } - - this.triggerMethod("item:removed", view); - }, - - // helper to show the empty view if the collection is empty - checkEmpty: function() { - // check if we're empty now, and if we are, show the - // empty view - if (!this.collection || this.collection.length === 0){ - this.showEmptyView(); - } - }, - - // Append the HTML to the collection's `el`. - // Override this method to do something other - // then `.append`. - appendHtml: function(collectionView, itemView, index){ - collectionView.$el.append(itemView.el); - }, - - // Internal method to set up the `children` object for - // storing all of the child views - _initChildViewStorage: function(){ - this.children = new Backbone.ChildViewContainer(); - }, - - // Handle cleanup and other closing needs for - // the collection of views. - close: function(){ - if (this.isClosed){ return; } - - this.triggerMethod("collection:before:close"); - this.closeChildren(); - this.triggerMethod("collection:closed"); - - Marionette.View.prototype.close.apply(this, slice(arguments)); - }, - - // Close the child views that this collection view - // is holding on to, if any - closeChildren: function(){ - this.children.each(function(child){ - this.removeChildView(child); - }, this); - this.checkEmpty(); - } -}); - - -// Composite View -// -------------- - -// Used for rendering a branch-leaf, hierarchical structure. -// Extends directly from CollectionView and also renders an -// an item view as `modelView`, for the top leaf -Marionette.CompositeView = Marionette.CollectionView.extend({ - - // Setting up the inheritance chain which allows changes to - // Marionette.CollectionView.prototype.constructor which allows overriding - constructor: function(){ - Marionette.CollectionView.prototype.constructor.apply(this, slice(arguments)); - }, - - // Configured the initial events that the composite view - // binds to. Override this method to prevent the initial - // events, or to add your own initial events. - _initialEvents: function(){ - if (this.collection){ - this.listenTo(this.collection, "add", this.addChildView, this); - this.listenTo(this.collection, "remove", this.removeItemView, this); - this.listenTo(this.collection, "reset", this._renderChildren, this); - } - }, - - // Retrieve the `itemView` to be used when rendering each of - // the items in the collection. The default is to return - // `this.itemView` or Marionette.CompositeView if no `itemView` - // has been defined - getItemView: function(item){ - var itemView = Marionette.getOption(this, "itemView") || this.constructor; - - if (!itemView){ - throwError("An `itemView` must be specified", "NoItemViewError"); - } - - return itemView; - }, - - // Serialize the collection for the view. - // You can override the `serializeData` method in your own view - // definition, to provide custom serialization for your view's data. - serializeData: function(){ - var data = {}; - - if (this.model){ - data = this.model.toJSON(); - } - - return data; - }, - - // Renders the model once, and the collection once. Calling - // this again will tell the model's view to re-render itself - // but the collection will not re-render. - render: function(){ - this.isRendered = true; - this.isClosed = false; - this.resetItemViewContainer(); - - this.triggerBeforeRender(); - var html = this.renderModel(); - this.$el.html(html); - // the ui bindings is done here and not at the end of render since they - // will not be available until after the model is rendered, but should be - // available before the collection is rendered. - this.bindUIElements(); - this.triggerMethod("composite:model:rendered"); - - this._renderChildren(); - - this.triggerMethod("composite:rendered"); - this.triggerRendered(); - return this; - }, - - _renderChildren: function(){ - if (this.isRendered){ - Marionette.CollectionView.prototype._renderChildren.call(this); - this.triggerMethod("composite:collection:rendered"); - } - }, - - // Render an individual model, if we have one, as - // part of a composite view (branch / leaf). For example: - // a treeview. - renderModel: function(){ - var data = {}; - data = this.serializeData(); - data = this.mixinTemplateHelpers(data); - - var template = this.getTemplate(); - return Marionette.Renderer.render(template, data); - }, - - // Appends the `el` of itemView instances to the specified - // `itemViewContainer` (a jQuery selector). Override this method to - // provide custom logic of how the child item view instances have their - // HTML appended to the composite view instance. - appendHtml: function(cv, iv, index){ - var $container = this.getItemViewContainer(cv); - $container.append(iv.el); - }, - - // Internal method to ensure an `$itemViewContainer` exists, for the - // `appendHtml` method to use. - getItemViewContainer: function(containerView){ - if ("$itemViewContainer" in containerView){ - return containerView.$itemViewContainer; - } - - var container; - if (containerView.itemViewContainer){ - - var selector = _.result(containerView, "itemViewContainer"); - container = containerView.$(selector); - if (container.length <= 0) { - throwError("The specified `itemViewContainer` was not found: " + containerView.itemViewContainer, "ItemViewContainerMissingError"); - } - - } else { - container = containerView.$el; - } - - containerView.$itemViewContainer = container; - return container; - }, - - // Internal method to reset the `$itemViewContainer` on render - resetItemViewContainer: function(){ - if (this.$itemViewContainer){ - delete this.$itemViewContainer; - } - } -}); - - -// Layout -// ------ - -// Used for managing application layouts, nested layouts and -// multiple regions within an application or sub-application. -// -// A specialized view type that renders an area of HTML and then -// attaches `Region` instances to the specified `regions`. -// Used for composite view management and sub-application areas. -Marionette.Layout = Marionette.ItemView.extend({ - regionType: Marionette.Region, - - // Ensure the regions are available when the `initialize` method - // is called. - constructor: function (options) { - options = options || {}; - - this._firstRender = true; - this._initializeRegions(options); - - Marionette.ItemView.prototype.constructor.call(this, options); - }, - - // Layout's render will use the existing region objects the - // first time it is called. Subsequent calls will close the - // views that the regions are showing and then reset the `el` - // for the regions to the newly rendered DOM elements. - render: function(){ - - if (this._firstRender){ - // if this is the first render, don't do anything to - // reset the regions - this._firstRender = false; - } else if (this.isClosed){ - // a previously closed layout means we need to - // completely re-initialize the regions - this._initializeRegions(); - } else { - // If this is not the first render call, then we need to - // re-initializing the `el` for each region - this._reInitializeRegions(); - } - - var args = Array.prototype.slice.apply(arguments); - var result = Marionette.ItemView.prototype.render.apply(this, args); - - return result; - }, - - // Handle closing regions, and then close the view itself. - close: function () { - if (this.isClosed){ return; } - this.regionManager.close(); - var args = Array.prototype.slice.apply(arguments); - Marionette.ItemView.prototype.close.apply(this, args); - }, - - // Add a single region, by name, to the layout - addRegion: function(name, definition){ - var regions = {}; - regions[name] = definition; - return this.addRegions(regions)[name]; - }, - - // Add multiple regions as a {name: definition, name2: def2} object literal - addRegions: function(regions){ - this.regions = _.extend(this.regions || {}, regions); - return this._buildRegions(regions); - }, - - // Remove a single region from the Layout, by name - removeRegion: function(name){ - return this.regionManager.removeRegion(name); - }, - - // internal method to build regions - _buildRegions: function(regions){ - var that = this; - - var defaults = { - parentEl: function(){ return that.$el; } - }; - - return this.regionManager.addRegions(regions, defaults); - }, - - // Internal method to initialize the regions that have been defined in a - // `regions` attribute on this layout. - _initializeRegions: function (options) { - var regions; - this._initRegionManager(); - - if (_.isFunction(this.regions)) { - regions = this.regions(options); - } else { - regions = this.regions || {}; - } - - this.addRegions(regions); - }, - - // Internal method to re-initialize all of the regions by updating the `el` that - // they point to - _reInitializeRegions: function(){ - this.regionManager.closeRegions(); - this.regionManager.each(function(region){ - region.reset(); - }); - }, - - // Internal method to initialize the region manager - // and all regions in it - _initRegionManager: function(){ - this.regionManager = new Marionette.RegionManager(); - - this.listenTo(this.regionManager, "region:add", function(name, region){ - this[name] = region; - this.trigger("region:add", name, region); - }); - - this.listenTo(this.regionManager, "region:remove", function(name, region){ - delete this[name]; - this.trigger("region:remove", name, region); - }); - } -}); - - -// AppRouter -// --------- - -// Reduce the boilerplate code of handling route events -// and then calling a single method on another object. -// Have your routers configured to call the method on -// your object, directly. -// -// Configure an AppRouter with `appRoutes`. -// -// App routers can only take one `controller` object. -// It is recommended that you divide your controller -// objects in to smaller pieces of related functionality -// and have multiple routers / controllers, instead of -// just one giant router and controller. -// -// You can also add standard routes to an AppRouter. - -Marionette.AppRouter = Backbone.Router.extend({ - - constructor: function(options){ - Backbone.Router.prototype.constructor.apply(this, slice(arguments)); - - this.options = options; - - if (this.appRoutes){ - var controller = Marionette.getOption(this, "controller"); - this.processAppRoutes(controller, this.appRoutes); - } - }, - - // Internal method to process the `appRoutes` for the - // router, and turn them in to routes that trigger the - // specified method on the specified `controller`. - processAppRoutes: function(controller, appRoutes) { - var routeNames = _.keys(appRoutes).reverse(); // Backbone requires reverted order of routes - - _.each(routeNames, function(route) { - var methodName = appRoutes[route]; - var method = controller[methodName]; - - if (!method) { - throw new Error("Method '" + methodName + "' was not found on the controller"); - } - - this.route(route, methodName, _.bind(method, controller)); - }, this); - } -}); - - -// Application -// ----------- - -// Contain and manage the composite application as a whole. -// Stores and starts up `Region` objects, includes an -// event aggregator as `app.vent` -Marionette.Application = function(options){ - this._initRegionManager(); - this._initCallbacks = new Marionette.Callbacks(); - this.vent = new Backbone.Wreqr.EventAggregator(); - this.commands = new Backbone.Wreqr.Commands(); - this.reqres = new Backbone.Wreqr.RequestResponse(); - this.submodules = {}; - - _.extend(this, options); - - this.triggerMethod = Marionette.triggerMethod; -}; - -_.extend(Marionette.Application.prototype, Backbone.Events, { - // Command execution, facilitated by Backbone.Wreqr.Commands - execute: function(){ - var args = Array.prototype.slice.apply(arguments); - this.commands.execute.apply(this.commands, args); - }, - - // Request/response, facilitated by Backbone.Wreqr.RequestResponse - request: function(){ - var args = Array.prototype.slice.apply(arguments); - return this.reqres.request.apply(this.reqres, args); - }, - - // Add an initializer that is either run at when the `start` - // method is called, or run immediately if added after `start` - // has already been called. - addInitializer: function(initializer){ - this._initCallbacks.add(initializer); - }, - - // kick off all of the application's processes. - // initializes all of the regions that have been added - // to the app, and runs all of the initializer functions - start: function(options){ - this.triggerMethod("initialize:before", options); - this._initCallbacks.run(options, this); - this.triggerMethod("initialize:after", options); - - this.triggerMethod("start", options); - }, - - // Add regions to your app. - // Accepts a hash of named strings or Region objects - // addRegions({something: "#someRegion"}) - // addRegions({something: Region.extend({el: "#someRegion"}) }); - addRegions: function(regions){ - return this._regionManager.addRegions(regions); - }, - - // Removes a region from your app. - // Accepts the regions name - // removeRegion('myRegion') - removeRegion: function(region) { - this._regionManager.removeRegion(region); - }, - - // Create a module, attached to the application - module: function(moduleNames, moduleDefinition){ - // slice the args, and add this application object as the - // first argument of the array - var args = slice(arguments); - args.unshift(this); - - // see the Marionette.Module object for more information - return Marionette.Module.create.apply(Marionette.Module, args); - }, - - // Internal method to set up the region manager - _initRegionManager: function(){ - this._regionManager = new Marionette.RegionManager(); - - this.listenTo(this._regionManager, "region:add", function(name, region){ - this[name] = region; - }); - - this.listenTo(this._regionManager, "region:remove", function(name, region){ - delete this[name]; - }); - } -}); - -// Copy the `extend` function used by Backbone's classes -Marionette.Application.extend = Marionette.extend; - -// Module -// ------ - -// A simple module system, used to create privacy and encapsulation in -// Marionette applications -Marionette.Module = function(moduleName, app){ - this.moduleName = moduleName; - - // store sub-modules - this.submodules = {}; - - this._setupInitializersAndFinalizers(); - - // store the configuration for this module - this.app = app; - this.startWithParent = true; - - this.triggerMethod = Marionette.triggerMethod; -}; - -// Extend the Module prototype with events / listenTo, so that the module -// can be used as an event aggregator or pub/sub. -_.extend(Marionette.Module.prototype, Backbone.Events, { - - // Initializer for a specific module. Initializers are run when the - // module's `start` method is called. - addInitializer: function(callback){ - this._initializerCallbacks.add(callback); - }, - - // Finalizers are run when a module is stopped. They are used to teardown - // and finalize any variables, references, events and other code that the - // module had set up. - addFinalizer: function(callback){ - this._finalizerCallbacks.add(callback); - }, - - // Start the module, and run all of its initializers - start: function(options){ - // Prevent re-starting a module that is already started - if (this._isInitialized){ return; } - - // start the sub-modules (depth-first hierarchy) - _.each(this.submodules, function(mod){ - // check to see if we should start the sub-module with this parent - if (mod.startWithParent){ - mod.start(options); - } - }); - - // run the callbacks to "start" the current module - this.triggerMethod("before:start", options); - - this._initializerCallbacks.run(options, this); - this._isInitialized = true; - - this.triggerMethod("start", options); - }, - - // Stop this module by running its finalizers and then stop all of - // the sub-modules for this module - stop: function(){ - // if we are not initialized, don't bother finalizing - if (!this._isInitialized){ return; } - this._isInitialized = false; - - Marionette.triggerMethod.call(this, "before:stop"); - - // stop the sub-modules; depth-first, to make sure the - // sub-modules are stopped / finalized before parents - _.each(this.submodules, function(mod){ mod.stop(); }); - - // run the finalizers - this._finalizerCallbacks.run(undefined,this); - - // reset the initializers and finalizers - this._initializerCallbacks.reset(); - this._finalizerCallbacks.reset(); - - Marionette.triggerMethod.call(this, "stop"); - }, - - // Configure the module with a definition function and any custom args - // that are to be passed in to the definition function - addDefinition: function(moduleDefinition, customArgs){ - this._runModuleDefinition(moduleDefinition, customArgs); - }, - - // Internal method: run the module definition function with the correct - // arguments - _runModuleDefinition: function(definition, customArgs){ - if (!definition){ return; } - - // build the correct list of arguments for the module definition - var args = _.flatten([ - this, - this.app, - Backbone, - Marionette, - Marionette.$, _, - customArgs - ]); - - definition.apply(this, args); - }, - - // Internal method: set up new copies of initializers and finalizers. - // Calling this method will wipe out all existing initializers and - // finalizers. - _setupInitializersAndFinalizers: function(){ - this._initializerCallbacks = new Marionette.Callbacks(); - this._finalizerCallbacks = new Marionette.Callbacks(); - } -}); - -// Type methods to create modules -_.extend(Marionette.Module, { - - // Create a module, hanging off the app parameter as the parent object. - create: function(app, moduleNames, moduleDefinition){ - var module = app; - - // get the custom args passed in after the module definition and - // get rid of the module name and definition function - var customArgs = slice(arguments); - customArgs.splice(0, 3); - - // split the module names and get the length - moduleNames = moduleNames.split("."); - var length = moduleNames.length; - - // store the module definition for the last module in the chain - var moduleDefinitions = []; - moduleDefinitions[length-1] = moduleDefinition; - - // Loop through all the parts of the module definition - _.each(moduleNames, function(moduleName, i){ - var parentModule = module; - module = this._getModule(parentModule, moduleName, app); - this._addModuleDefinition(parentModule, module, moduleDefinitions[i], customArgs); - }, this); - - // Return the last module in the definition chain - return module; - }, - - _getModule: function(parentModule, moduleName, app, def, args){ - // Get an existing module of this name if we have one - var module = parentModule[moduleName]; - - if (!module){ - // Create a new module if we don't have one - module = new Marionette.Module(moduleName, app); - parentModule[moduleName] = module; - // store the module on the parent - parentModule.submodules[moduleName] = module; - } - - return module; - }, - - _addModuleDefinition: function(parentModule, module, def, args){ - var fn; - var startWithParent; - - if (_.isFunction(def)){ - // if a function is supplied for the module definition - fn = def; - startWithParent = true; - - } else if (_.isObject(def)){ - // if an object is supplied - fn = def.define; - startWithParent = def.startWithParent; - - } else { - // if nothing is supplied - startWithParent = true; - } - - // add module definition if needed - if (fn){ - module.addDefinition(fn, args); - } - - // `and` the two together, ensuring a single `false` will prevent it - // from starting with the parent - module.startWithParent = module.startWithParent && startWithParent; - - // setup auto-start if needed - if (module.startWithParent && !module.startWithParentIsConfigured){ - - // only configure this once - module.startWithParentIsConfigured = true; - - // add the module initializer config - parentModule.addInitializer(function(options){ - if (module.startWithParent){ - module.start(options); - } - }); - - } - - } -}); - - - window.Marionette = Marionette; - return Marionette; -})(this, Backbone, _); diff --git a/src/UI/JsLibraries/backbone.modelbinder.js b/src/UI/JsLibraries/backbone.modelbinder.js deleted file mode 100644 index 8dde7b055..000000000 --- a/src/UI/JsLibraries/backbone.modelbinder.js +++ /dev/null @@ -1,576 +0,0 @@ -// Backbone.ModelBinder v1.0.2 -// (c) 2013 Bart Wood -// Distributed Under MIT License - -(function (factory) { - if (typeof define === 'function' && define.amd) { - // AMD. Register as an anonymous module. - define(['underscore', 'jquery', 'backbone'], factory); - } else { - // Browser globals - factory(_, $, Backbone); - } -}(function(_, $, Backbone){ - - if(!Backbone){ - throw 'Please include Backbone.js before Backbone.ModelBinder.js'; - } - - Backbone.ModelBinder = function(){ - _.bindAll.apply(_, [this].concat(_.functions(this))); - }; - - // Static setter for class level options - Backbone.ModelBinder.SetOptions = function(options){ - Backbone.ModelBinder.options = options; - }; - - // Current version of the library. - Backbone.ModelBinder.VERSION = '1.0.2'; - Backbone.ModelBinder.Constants = {}; - Backbone.ModelBinder.Constants.ModelToView = 'ModelToView'; - Backbone.ModelBinder.Constants.ViewToModel = 'ViewToModel'; - - _.extend(Backbone.ModelBinder.prototype, { - - bind:function (model, rootEl, attributeBindings, options) { - this.unbind(); - - this._model = model; - this._rootEl = rootEl; - this._setOptions(options); - - if (!this._model) this._throwException('model must be specified'); - if (!this._rootEl) this._throwException('rootEl must be specified'); - - if(attributeBindings){ - // Create a deep clone of the attribute bindings - this._attributeBindings = $.extend(true, {}, attributeBindings); - - this._initializeAttributeBindings(); - this._initializeElBindings(); - } - else { - this._initializeDefaultBindings(); - } - - this._bindModelToView(); - this._bindViewToModel(); - }, - - bindCustomTriggers: function (model, rootEl, triggers, attributeBindings, modelSetOptions) { - this._triggers = triggers; - this.bind(model, rootEl, attributeBindings, modelSetOptions) - }, - - unbind:function () { - this._unbindModelToView(); - this._unbindViewToModel(); - - if(this._attributeBindings){ - delete this._attributeBindings; - this._attributeBindings = undefined; - } - }, - - _setOptions: function(options){ - this._options = _.extend({ - boundAttribute: 'name' - }, Backbone.ModelBinder.options, options); - - // initialize default options - if(!this._options['modelSetOptions']){ - this._options['modelSetOptions'] = {}; - } - this._options['modelSetOptions'].changeSource = 'ModelBinder'; - - if(!this._options['changeTriggers']){ - this._options['changeTriggers'] = {'': 'change', '[contenteditable]': 'blur'}; - } - - if(!this._options['initialCopyDirection']){ - this._options['initialCopyDirection'] = Backbone.ModelBinder.Constants.ModelToView; - } - }, - - // Converts the input bindings, which might just be empty or strings, to binding objects - _initializeAttributeBindings:function () { - var attributeBindingKey, inputBinding, attributeBinding, elementBindingCount, elementBinding; - - for (attributeBindingKey in this._attributeBindings) { - inputBinding = this._attributeBindings[attributeBindingKey]; - - if (_.isString(inputBinding)) { - attributeBinding = {elementBindings: [{selector: inputBinding}]}; - } - else if (_.isArray(inputBinding)) { - attributeBinding = {elementBindings: inputBinding}; - } - else if(_.isObject(inputBinding)){ - attributeBinding = {elementBindings: [inputBinding]}; - } - else { - this._throwException('Unsupported type passed to Model Binder ' + attributeBinding); - } - - // Add a linkage from the element binding back to the attribute binding - for(elementBindingCount = 0; elementBindingCount < attributeBinding.elementBindings.length; elementBindingCount++){ - elementBinding = attributeBinding.elementBindings[elementBindingCount]; - elementBinding.attributeBinding = attributeBinding; - } - - attributeBinding.attributeName = attributeBindingKey; - this._attributeBindings[attributeBindingKey] = attributeBinding; - } - }, - - // If the bindings are not specified, the default binding is performed on the specified attribute, name by default - _initializeDefaultBindings: function(){ - var elCount, elsWithAttribute, matchedEl, name, attributeBinding; - - this._attributeBindings = {}; - elsWithAttribute = $('[' + this._options['boundAttribute'] + ']', this._rootEl); - - for(elCount = 0; elCount < elsWithAttribute.length; elCount++){ - matchedEl = elsWithAttribute[elCount]; - name = $(matchedEl).attr(this._options['boundAttribute']); - - // For elements like radio buttons we only want a single attribute binding with possibly multiple element bindings - if(!this._attributeBindings[name]){ - attributeBinding = {attributeName: name}; - attributeBinding.elementBindings = [{attributeBinding: attributeBinding, boundEls: [matchedEl]}]; - this._attributeBindings[name] = attributeBinding; - } - else{ - this._attributeBindings[name].elementBindings.push({attributeBinding: this._attributeBindings[name], boundEls: [matchedEl]}); - } - } - }, - - _initializeElBindings:function () { - var bindingKey, attributeBinding, bindingCount, elementBinding, foundEls, elCount, el; - for (bindingKey in this._attributeBindings) { - attributeBinding = this._attributeBindings[bindingKey]; - - for (bindingCount = 0; bindingCount < attributeBinding.elementBindings.length; bindingCount++) { - elementBinding = attributeBinding.elementBindings[bindingCount]; - if (elementBinding.selector === '') { - foundEls = $(this._rootEl); - } - else { - foundEls = $(elementBinding.selector, this._rootEl); - } - - if (foundEls.length === 0) { - this._throwException('Bad binding found. No elements returned for binding selector ' + elementBinding.selector); - } - else { - elementBinding.boundEls = []; - for (elCount = 0; elCount < foundEls.length; elCount++) { - el = foundEls[elCount]; - elementBinding.boundEls.push(el); - } - } - } - } - }, - - _bindModelToView: function () { - this._model.on('change', this._onModelChange, this); - - if(this._options['initialCopyDirection'] === Backbone.ModelBinder.Constants.ModelToView){ - this.copyModelAttributesToView(); - } - }, - - // attributesToCopy is an optional parameter - if empty, all attributes - // that are bound will be copied. Otherwise, only attributeBindings specified - // in the attributesToCopy are copied. - copyModelAttributesToView: function(attributesToCopy){ - var attributeName, attributeBinding; - - for (attributeName in this._attributeBindings) { - if(attributesToCopy === undefined || _.indexOf(attributesToCopy, attributeName) !== -1){ - attributeBinding = this._attributeBindings[attributeName]; - this._copyModelToView(attributeBinding); - } - } - }, - - copyViewValuesToModel: function(){ - var bindingKey, attributeBinding, bindingCount, elementBinding, elCount, el; - for (bindingKey in this._attributeBindings) { - attributeBinding = this._attributeBindings[bindingKey]; - - for (bindingCount = 0; bindingCount < attributeBinding.elementBindings.length; bindingCount++) { - elementBinding = attributeBinding.elementBindings[bindingCount]; - - if(this._isBindingUserEditable(elementBinding)){ - if(this._isBindingRadioGroup(elementBinding)){ - el = this._getRadioButtonGroupCheckedEl(elementBinding); - if(el){ - this._copyViewToModel(elementBinding, el); - } - } - else { - for(elCount = 0; elCount < elementBinding.boundEls.length; elCount++){ - el = $(elementBinding.boundEls[elCount]); - if(this._isElUserEditable(el)){ - this._copyViewToModel(elementBinding, el); - } - } - } - } - } - } - }, - - _unbindModelToView: function(){ - if(this._model){ - this._model.off('change', this._onModelChange); - this._model = undefined; - } - }, - - _bindViewToModel: function () { - _.each(this._options['changeTriggers'], function (event, selector) { - $(this._rootEl).delegate(selector, event, this._onElChanged); - }, this); - - if(this._options['initialCopyDirection'] === Backbone.ModelBinder.Constants.ViewToModel){ - this.copyViewValuesToModel(); - } - }, - - _unbindViewToModel: function () { - if(this._options && this._options['changeTriggers']){ - _.each(this._options['changeTriggers'], function (event, selector) { - $(this._rootEl).undelegate(selector, event, this._onElChanged); - }, this); - } - }, - - _onElChanged:function (event) { - var el, elBindings, elBindingCount, elBinding; - - el = $(event.target)[0]; - elBindings = this._getElBindings(el); - - for(elBindingCount = 0; elBindingCount < elBindings.length; elBindingCount++){ - elBinding = elBindings[elBindingCount]; - if (this._isBindingUserEditable(elBinding)) { - this._copyViewToModel(elBinding, el); - } - } - }, - - _isBindingUserEditable: function(elBinding){ - return elBinding.elAttribute === undefined || - elBinding.elAttribute === 'text' || - elBinding.elAttribute === 'html'; - }, - - _isElUserEditable: function(el){ - var isContentEditable = el.attr('contenteditable'); - return isContentEditable || el.is('input') || el.is('select') || el.is('textarea'); - }, - - _isBindingRadioGroup: function(elBinding){ - var elCount, el; - var isAllRadioButtons = elBinding.boundEls.length > 0; - for(elCount = 0; elCount < elBinding.boundEls.length; elCount++){ - el = $(elBinding.boundEls[elCount]); - if(el.attr('type') !== 'radio'){ - isAllRadioButtons = false; - break; - } - } - - return isAllRadioButtons; - }, - - _getRadioButtonGroupCheckedEl: function(elBinding){ - var elCount, el; - for(elCount = 0; elCount < elBinding.boundEls.length; elCount++){ - el = $(elBinding.boundEls[elCount]); - if(el.attr('type') === 'radio' && el.attr('checked')){ - return el; - } - } - - return undefined; - }, - - _getElBindings:function (findEl) { - var attributeName, attributeBinding, elementBindingCount, elementBinding, boundElCount, boundEl; - var elBindings = []; - - for (attributeName in this._attributeBindings) { - attributeBinding = this._attributeBindings[attributeName]; - - for (elementBindingCount = 0; elementBindingCount < attributeBinding.elementBindings.length; elementBindingCount++) { - elementBinding = attributeBinding.elementBindings[elementBindingCount]; - - for (boundElCount = 0; boundElCount < elementBinding.boundEls.length; boundElCount++) { - boundEl = elementBinding.boundEls[boundElCount]; - - if (boundEl === findEl) { - elBindings.push(elementBinding); - } - } - } - } - - return elBindings; - }, - - _onModelChange:function () { - var changedAttribute, attributeBinding; - - for (changedAttribute in this._model.changedAttributes()) { - attributeBinding = this._attributeBindings[changedAttribute]; - - if (attributeBinding) { - this._copyModelToView(attributeBinding); - } - } - }, - - _copyModelToView:function (attributeBinding) { - var elementBindingCount, elementBinding, boundElCount, boundEl, value, convertedValue; - - value = this._model.get(attributeBinding.attributeName); - - for (elementBindingCount = 0; elementBindingCount < attributeBinding.elementBindings.length; elementBindingCount++) { - elementBinding = attributeBinding.elementBindings[elementBindingCount]; - - for (boundElCount = 0; boundElCount < elementBinding.boundEls.length; boundElCount++) { - boundEl = elementBinding.boundEls[boundElCount]; - - if(!boundEl._isSetting){ - convertedValue = this._getConvertedValue(Backbone.ModelBinder.Constants.ModelToView, elementBinding, value); - this._setEl($(boundEl), elementBinding, convertedValue); - } - } - } - }, - - _setEl: function (el, elementBinding, convertedValue) { - if (elementBinding.elAttribute) { - this._setElAttribute(el, elementBinding, convertedValue); - } - else { - this._setElValue(el, convertedValue); - } - }, - - _setElAttribute:function (el, elementBinding, convertedValue) { - switch (elementBinding.elAttribute) { - case 'html': - el.html(convertedValue); - break; - case 'text': - el.text(convertedValue); - break; - case 'enabled': - el.prop('disabled', !convertedValue); - break; - case 'displayed': - el[convertedValue ? 'show' : 'hide'](); - break; - case 'hidden': - el[convertedValue ? 'hide' : 'show'](); - break; - case 'css': - el.css(elementBinding.cssAttribute, convertedValue); - break; - case 'class': - var previousValue = this._model.previous(elementBinding.attributeBinding.attributeName); - var currentValue = this._model.get(elementBinding.attributeBinding.attributeName); - // is current value is now defined then remove the class the may have been set for the undefined value - if(!_.isUndefined(previousValue) || !_.isUndefined(currentValue)){ - previousValue = this._getConvertedValue(Backbone.ModelBinder.Constants.ModelToView, elementBinding, previousValue); - el.removeClass(previousValue); - } - - if(convertedValue){ - el.addClass(convertedValue); - } - break; - default: - el.attr(elementBinding.elAttribute, convertedValue); - } - }, - - _setElValue:function (el, convertedValue) { - if(el.attr('type')){ - switch (el.attr('type')) { - case 'radio': - if (el.val() === convertedValue) { - // must defer the change trigger or the change will actually fire with the old value - el.prop('checked') || _.defer(function() { el.trigger('change'); }); - el.prop('checked', true); - } - else { - // must defer the change trigger or the change will actually fire with the old value - el.prop('checked', false); - } - break; - case 'checkbox': - // must defer the change trigger or the change will actually fire with the old value - el.prop('checked') === !!convertedValue || _.defer(function() { el.trigger('change') }); - el.prop('checked', !!convertedValue); - break; - case 'file': - break; - default: - el.val(convertedValue); - } - } - else if(el.is('input') || el.is('select') || el.is('textarea')){ - el.val(convertedValue || (convertedValue === 0 ? '0' : '')); - } - else { - el.text(convertedValue || (convertedValue === 0 ? '0' : '')); - } - }, - - _copyViewToModel: function (elementBinding, el) { - var result, value, convertedValue; - - if (!el._isSetting) { - - el._isSetting = true; - result = this._setModel(elementBinding, $(el)); - el._isSetting = false; - - if(result && elementBinding.converter){ - value = this._model.get(elementBinding.attributeBinding.attributeName); - convertedValue = this._getConvertedValue(Backbone.ModelBinder.Constants.ModelToView, elementBinding, value); - this._setEl($(el), elementBinding, convertedValue); - } - } - }, - - _getElValue: function(elementBinding, el){ - switch (el.attr('type')) { - case 'checkbox': - return el.prop('checked') ? true : false; - default: - if(el.attr('contenteditable') !== undefined){ - return el.html(); - } - else { - return el.val(); - } - } - }, - - _setModel: function (elementBinding, el) { - var data = {}; - var elVal = this._getElValue(elementBinding, el); - elVal = this._getConvertedValue(Backbone.ModelBinder.Constants.ViewToModel, elementBinding, elVal); - data[elementBinding.attributeBinding.attributeName] = elVal; - return this._model.set(data, this._options['modelSetOptions']); - }, - - _getConvertedValue: function (direction, elementBinding, value) { - if (elementBinding.converter) { - value = elementBinding.converter(direction, value, elementBinding.attributeBinding.attributeName, this._model, elementBinding.boundEls); - } - - return value; - }, - - _throwException: function(message){ - if(this._options.suppressThrows){ - if(console && console.error){ - console.error(message); - } - } - else { - throw message; - } - } - }); - - Backbone.ModelBinder.CollectionConverter = function(collection){ - this._collection = collection; - - if(!this._collection){ - throw 'Collection must be defined'; - } - _.bindAll(this, 'convert'); - }; - - _.extend(Backbone.ModelBinder.CollectionConverter.prototype, { - convert: function(direction, value){ - if (direction === Backbone.ModelBinder.Constants.ModelToView) { - return value ? value.id : undefined; - } - else { - return this._collection.get(value); - } - } - }); - - // A static helper function to create a default set of bindings that you can customize before calling the bind() function - // rootEl - where to find all of the bound elements - // attributeType - probably 'name' or 'id' in most cases - // converter(optional) - the default converter you want applied to all your bindings - // elAttribute(optional) - the default elAttribute you want applied to all your bindings - Backbone.ModelBinder.createDefaultBindings = function(rootEl, attributeType, converter, elAttribute){ - var foundEls, elCount, foundEl, attributeName; - var bindings = {}; - - foundEls = $('[' + attributeType + ']', rootEl); - - for(elCount = 0; elCount < foundEls.length; elCount++){ - foundEl = foundEls[elCount]; - attributeName = $(foundEl).attr(attributeType); - - if(!bindings[attributeName]){ - var attributeBinding = {selector: '[' + attributeType + '="' + attributeName + '"]'}; - bindings[attributeName] = attributeBinding; - - if(converter){ - bindings[attributeName].converter = converter; - } - - if(elAttribute){ - bindings[attributeName].elAttribute = elAttribute; - } - } - } - - return bindings; - }; - - // Helps you to combine 2 sets of bindings - Backbone.ModelBinder.combineBindings = function(destination, source){ - _.each(source, function(value, key){ - var elementBinding = {selector: value.selector}; - - if(value.converter){ - elementBinding.converter = value.converter; - } - - if(value.elAttribute){ - elementBinding.elAttribute = value.elAttribute; - } - - if(!destination[key]){ - destination[key] = elementBinding; - } - else { - destination[key] = [destination[key], elementBinding]; - } - }); - - return destination; - }; - - - return Backbone.ModelBinder; - -})); \ No newline at end of file diff --git a/src/UI/JsLibraries/backbone.pageable.js b/src/UI/JsLibraries/backbone.pageable.js deleted file mode 100644 index f6cdbcacd..000000000 --- a/src/UI/JsLibraries/backbone.pageable.js +++ /dev/null @@ -1,1345 +0,0 @@ -/* - backbone-pageable 1.4.1 - http://github.com/wyuenho/backbone-pageable - - Copyright (c) 2013 Jimmy Yuen Ho Wong - Licensed under the MIT @license. -*/ - -(function (factory) { - - // CommonJS - if (typeof exports == "object") { - module.exports = factory(require("underscore"), require("backbone")); - } - // AMD - else if (typeof define == "function" && define.amd) { - define(["underscore", "backbone"], factory); - } - // Browser - else if (typeof _ !== "undefined" && typeof Backbone !== "undefined") { - var oldPageableCollection = Backbone.PageableCollection; - var PageableCollection = factory(_, Backbone); - - /** - __BROWSER ONLY__ - - If you already have an object named `PageableCollection` attached to the - `Backbone` module, you can use this to return a local reference to this - Backbone.PageableCollection class and reset the name - Backbone.PageableCollection to its previous definition. - - // The left hand side gives you a reference to this - // Backbone.PageableCollection implementation, the right hand side - // resets Backbone.PageableCollection to your other - // Backbone.PageableCollection. - var PageableCollection = Backbone.PageableCollection.noConflict(); - - @static - @member Backbone.PageableCollection - @return {Backbone.PageableCollection} - */ - Backbone.PageableCollection.noConflict = function () { - Backbone.PageableCollection = oldPageableCollection; - return PageableCollection; - }; - } - -}(function (_, Backbone) { - - "use strict"; - - var _extend = _.extend; - var _omit = _.omit; - var _clone = _.clone; - var _each = _.each; - var _pick = _.pick; - var _contains = _.contains; - var _isEmpty = _.isEmpty; - var _pairs = _.pairs; - var _invert = _.invert; - var _isArray = _.isArray; - var _isFunction = _.isFunction; - var _isObject = _.isObject; - var _keys = _.keys; - var _isUndefined = _.isUndefined; - var _result = _.result; - var ceil = Math.ceil; - var floor = Math.floor; - var max = Math.max; - - var BBColProto = Backbone.Collection.prototype; - - function finiteInt (val, name) { - if (!_.isNumber(val) || _.isNaN(val) || !_.isFinite(val) || ~~val !== val) { - throw new TypeError("`" + name + "` must be a finite integer"); - } - return val; - } - - function queryStringToParams (qs) { - var kvp, k, v, ls, params = {}, decode = decodeURIComponent; - var kvps = qs.split('&'); - for (var i = 0, l = kvps.length; i < l; i++) { - var param = kvps[i]; - kvp = param.split('='), k = kvp[0], v = kvp[1] || true; - k = decode(k), v = decode(v), ls = params[k]; - if (_isArray(ls)) ls.push(v); - else if (ls) params[k] = [ls, v]; - else params[k] = v; - } - return params; - } - - // hack to make sure the whatever event handlers for this event is run - // before func is, and the event handlers that func will trigger. - function runOnceAtLastHandler (col, event, func) { - var eventHandlers = col._events[event]; - if (eventHandlers && eventHandlers.length) { - var lastHandler = eventHandlers[eventHandlers.length - 1]; - var oldCallback = lastHandler.callback; - lastHandler.callback = function () { - try { - oldCallback.apply(this, arguments); - func(); - } - catch (e) { - throw e; - } - finally { - lastHandler.callback = oldCallback; - } - }; - } - else func(); - } - - var PARAM_TRIM_RE = /[\s'"]/g; - var URL_TRIM_RE = /[<>\s'"]/g; - - /** - Drop-in replacement for Backbone.Collection. Supports server-side and - client-side pagination and sorting. Client-side mode also support fully - multi-directional synchronization of changes between pages. - - @class Backbone.PageableCollection - @extends Backbone.Collection - */ - var PageableCollection = Backbone.PageableCollection = Backbone.Collection.extend({ - - /** - The container object to store all pagination states. - - You can override the default state by extending this class or specifying - them in an `options` hash to the constructor. - - @property {Object} state - - @property {0|1} [state.firstPage=1] The first page index. Set to 0 if - your server API uses 0-based indices. You should only override this value - during extension, initialization or reset by the server after - fetching. This value should be read only at other times. - - @property {number} [state.lastPage=null] The last page index. This value - is __read only__ and it's calculated based on whether `firstPage` is 0 or - 1, during bootstrapping, fetching and resetting. Please don't change this - value under any circumstances. - - @property {number} [state.currentPage=null] The current page index. You - should only override this value during extension, initialization or reset - by the server after fetching. This value should be read only at other - times. Can be a 0-based or 1-based index, depending on whether - `firstPage` is 0 or 1. If left as default, it will be set to `firstPage` - on initialization. - - @property {number} [state.pageSize=25] How many records to show per - page. This value is __read only__ after initialization, if you want to - change the page size after initialization, you must call #setPageSize. - - @property {number} [state.totalPages=null] How many pages there are. This - value is __read only__ and it is calculated from `totalRecords`. - - @property {number} [state.totalRecords=null] How many records there - are. This value is __required__ under server mode. This value is optional - for client mode as the number will be the same as the number of models - during bootstrapping and during fetching, either supplied by the server - in the metadata, or calculated from the size of the response. - - @property {string} [state.sortKey=null] The model attribute to use for - sorting. - - @property {-1|0|1} [state.order=-1] The order to use for sorting. Specify - -1 for ascending order or 1 for descending order. If 0, no client side - sorting will be done and the order query parameter will not be sent to - the server during a fetch. - */ - state: { - firstPage: 1, - lastPage: null, - currentPage: null, - pageSize: 25, - totalPages: null, - totalRecords: null, - sortKey: null, - order: -1 - }, - - /** - @property {"server"|"client"|"infinite"} [mode="server"] The mode of - operations for this collection. `"server"` paginates on the server-side, - `"client"` paginates on the client-side and `"infinite"` paginates on the - server-side for APIs that do not support `totalRecords`. - */ - mode: "server", - - /** - A translation map to convert Backbone.PageableCollection state attributes - to the query parameters accepted by your server API. - - You can override the default state by extending this class or specifying - them in `options.queryParams` object hash to the constructor. - - @property {Object} queryParams - @property {string} [queryParams.currentPage="page"] - @property {string} [queryParams.pageSize="per_page"] - @property {string} [queryParams.totalPages="total_pages"] - @property {string} [queryParams.totalRecords="total_entries"] - @property {string} [queryParams.sortKey="sort_by"] - @property {string} [queryParams.order="order"] - @property {string} [queryParams.directions={"-1": "asc", "1": "desc"}] A - map for translating a Backbone.PageableCollection#state.order constant to - the ones your server API accepts. - */ - queryParams: { - currentPage: "page", - pageSize: "per_page", - totalPages: "total_pages", - totalRecords: "total_entries", - sortKey: "sort_by", - order: "order", - directions: { - "-1": "asc", - "1": "desc" - } - }, - - /** - __CLIENT MODE ONLY__ - - This collection is the internal storage for the bootstrapped or fetched - models. You can use this if you want to operate on all the pages. - - @property {Backbone.Collection} fullCollection - */ - - /** - Given a list of models or model attributues, bootstraps the full - collection in client mode or infinite mode, or just the page you want in - server mode. - - If you want to initialize a collection to a different state than the - default, you can specify them in `options.state`. Any state parameters - supplied will be merged with the default. If you want to change the - default mapping from #state keys to your server API's query parameter - names, you can specifiy an object hash in `option.queryParams`. Likewise, - any mapping provided will be merged with the default. Lastly, all - Backbone.Collection constructor options are also accepted. - - See: - - - Backbone.PageableCollection#state - - Backbone.PageableCollection#queryParams - - [Backbone.Collection#initialize](http://backbonejs.org/#Collection-constructor) - - @param {Array.<Object>} [models] - - @param {Object} [options] - - @param {function(*, *): number} [options.comparator] If specified, this - comparator is set to the current page under server mode, or the #fullCollection - otherwise. - - @param {boolean} [options.full] If `false` and either a - `options.comparator` or `sortKey` is defined, the comparator is attached - to the current page. Default is `true` under client or infinite mode and - the comparator will be attached to the #fullCollection. - - @param {Object} [options.state] The state attributes overriding the defaults. - - @param {string} [options.state.sortKey] The model attribute to use for - sorting. If specified instead of `options.comparator`, a comparator will - be automatically created using this value, and optionally a sorting order - specified in `options.state.order`. The comparator is then attached to - the new collection instance. - - @param {-1|1} [options.state.order] The order to use for sorting. Specify - -1 for ascending order and 1 for descending order. - - @param {Object} [options.queryParam] - */ - constructor: function (models, options) { - - BBColProto.constructor.apply(this, arguments); - - options = options || {}; - - var mode = this.mode = options.mode || this.mode || PageableProto.mode; - - var queryParams = _extend({}, PageableProto.queryParams, this.queryParams, - options.queryParams || {}); - - queryParams.directions = _extend({}, - PageableProto.queryParams.directions, - this.queryParams.directions, - queryParams.directions || {}); - - this.queryParams = queryParams; - - var state = this.state = _extend({}, PageableProto.state, this.state, - options.state || {}); - - state.currentPage = state.currentPage == null ? - state.firstPage : - state.currentPage; - - if (!_isArray(models)) models = models ? [models] : []; - - if (mode != "server" && state.totalRecords == null && !_isEmpty(models)) { - state.totalRecords = models.length; - } - - this.switchMode(mode, _extend({fetch: false, - resetState: false, - models: models}, options)); - - var comparator = options.comparator; - - if (state.sortKey && !comparator) { - this.setSorting(state.sortKey, state.order, options); - } - - if (mode != "server") { - var fullCollection = this.fullCollection; - - if (comparator && options.full) { - this.comparator = null; - fullCollection.comparator = comparator; - } - - if (options.full) fullCollection.sort(); - - // make sure the models in the current page and full collection have the - // same references - if (models && !_isEmpty(models)) { - this.reset([].slice.call(models), _extend({silent: true}, options)); - this.getPage(state.currentPage); - models.splice.apply(models, [0, models.length].concat(this.models)); - } - } - - this._initState = _clone(this.state); - }, - - /** - Makes a Backbone.Collection that contains all the pages. - - @private - @param {Array.<Object|Backbone.Model>} models - @param {Object} options Options for Backbone.Collection constructor. - @return {Backbone.Collection} - */ - _makeFullCollection: function (models, options) { - - var properties = ["url", "model", "sync", "comparator"]; - var thisProto = this.constructor.prototype; - var i, length, prop; - - var proto = {}; - for (i = 0, length = properties.length; i < length; i++) { - prop = properties[i]; - if (!_isUndefined(thisProto[prop])) { - proto[prop] = thisProto[prop]; - } - } - - var fullCollection = new (Backbone.Collection.extend(proto))(models, options); - - for (i = 0, length = properties.length; i < length; i++) { - prop = properties[i]; - if (this[prop] !== thisProto[prop]) { - fullCollection[prop] = this[prop]; - } - } - - return fullCollection; - }, - - /** - Factory method that returns a Backbone event handler that responses to - the `add`, `remove`, `reset`, and the `sort` events. The returned event - handler will synchronize the current page collection and the full - collection's models. - - @private - - @param {Backbone.PageableCollection} pageCol - @param {Backbone.Collection} fullCol - - @return {function(string, Backbone.Model, Backbone.Collection, Object)} - Collection event handler - */ - _makeCollectionEventHandler: function (pageCol, fullCol) { - - return function collectionEventHandler (event, model, collection, options) { - - var handlers = pageCol._handlers; - _each(_keys(handlers), function (event) { - var handler = handlers[event]; - pageCol.off(event, handler); - fullCol.off(event, handler); - }); - - var state = _clone(pageCol.state); - var firstPage = state.firstPage; - var currentPage = firstPage === 0 ? - state.currentPage : - state.currentPage - 1; - var pageSize = state.pageSize; - var pageStart = currentPage * pageSize, pageEnd = pageStart + pageSize; - - if (event == "add") { - var pageIndex, fullIndex, addAt, colToAdd, options = options || {}; - if (collection == fullCol) { - fullIndex = fullCol.indexOf(model); - if (fullIndex >= pageStart && fullIndex < pageEnd) { - colToAdd = pageCol; - pageIndex = addAt = fullIndex - pageStart; - } - } - else { - pageIndex = pageCol.indexOf(model); - fullIndex = pageStart + pageIndex; - colToAdd = fullCol; - var addAt = !_isUndefined(options.at) ? - options.at + pageStart : - fullIndex; - } - - ++state.totalRecords; - pageCol.state = pageCol._checkState(state); - - if (colToAdd) { - colToAdd.add(model, _extend({}, options || {}, {at: addAt})); - var modelToRemove = pageIndex >= pageSize ? - model : - !_isUndefined(options.at) && addAt < pageEnd && pageCol.length > pageSize ? - pageCol.at(pageSize) : - null; - if (modelToRemove) { - var popOptions = {onAdd: true}; - runOnceAtLastHandler(collection, event, function () { - pageCol.remove(modelToRemove, popOptions); - }); - } - } - } - - // remove the model from the other collection as well - if (event == "remove") { - if (!options.onAdd) { - // decrement totalRecords and update totalPages and lastPage - if (!--state.totalRecords) { - state.totalRecords = null; - state.totalPages = null; - } - else { - var totalPages = state.totalPages = ceil(state.totalRecords / pageSize); - state.lastPage = firstPage === 0 ? totalPages - 1 : totalPages || firstPage; - if (state.currentPage > totalPages) state.currentPage = state.lastPage; - } - pageCol.state = pageCol._checkState(state); - - var nextModel, removedIndex = options.index; - if (collection == pageCol) { - if (nextModel = fullCol.at(pageEnd)) { - runOnceAtLastHandler(pageCol, event, function () { - pageCol.push(nextModel); - }); - } - fullCol.remove(model); - } - else if (removedIndex >= pageStart && removedIndex < pageEnd) { - pageCol.remove(model); - var at = removedIndex + 1 - nextModel = fullCol.at(at) || fullCol.last(); - if (nextModel) pageCol.add(nextModel, {at: at}); - } - } - else delete options.onAdd; - } - - if (event == "reset") { - options = collection; - collection = model; - - // Reset that's not a result of getPage - if (collection == pageCol && options.from == null && - options.to == null) { - var head = fullCol.models.slice(0, pageStart); - var tail = fullCol.models.slice(pageStart + pageCol.models.length); - fullCol.reset(head.concat(pageCol.models).concat(tail), options); - } - else if (collection == fullCol) { - if (!(state.totalRecords = fullCol.models.length)) { - state.totalRecords = null; - state.totalPages = null; - } - if (pageCol.mode == "client") { - state.lastPage = state.currentPage = state.firstPage; - } - pageCol.state = pageCol._checkState(state); - pageCol.reset(fullCol.models.slice(pageStart, pageEnd), - _extend({}, options, {parse: false})); - } - } - - if (event == "sort") { - options = collection; - collection = model; - if (collection === fullCol) { - pageCol.reset(fullCol.models.slice(pageStart, pageEnd), - _extend({}, options, {parse: false})); - } - } - - _each(_keys(handlers), function (event) { - var handler = handlers[event]; - _each([pageCol, fullCol], function (col) { - col.on(event, handler); - var callbacks = col._events[event] || []; - callbacks.unshift(callbacks.pop()); - }); - }); - }; - }, - - /** - Sanity check this collection's pagination states. Only perform checks - when all the required pagination state values are defined and not null. - If `totalPages` is undefined or null, it is set to `totalRecords` / - `pageSize`. `lastPage` is set according to whether `firstPage` is 0 or 1 - when no error occurs. - - @private - - @throws {TypeError} If `totalRecords`, `pageSize`, `currentPage` or - `firstPage` is not a finite integer. - - @throws {RangeError} If `pageSize`, `currentPage` or `firstPage` is out - of bounds. - - @return {Object} Returns the `state` object if no error was found. - */ - _checkState: function (state) { - - var mode = this.mode; - var links = this.links; - var totalRecords = state.totalRecords; - var pageSize = state.pageSize; - var currentPage = state.currentPage; - var firstPage = state.firstPage; - var totalPages = state.totalPages; - - if (totalRecords != null && pageSize != null && currentPage != null && - firstPage != null && (mode == "infinite" ? links : true)) { - - totalRecords = finiteInt(totalRecords, "totalRecords"); - pageSize = finiteInt(pageSize, "pageSize"); - currentPage = finiteInt(currentPage, "currentPage"); - firstPage = finiteInt(firstPage, "firstPage"); - - if (pageSize < 1) { - throw new RangeError("`pageSize` must be >= 1"); - } - - totalPages = state.totalPages = ceil(totalRecords / pageSize); - - if (firstPage < 0 || firstPage > 1) { - throw new RangeError("`firstPage must be 0 or 1`"); - } - - state.lastPage = firstPage === 0 ? max(0, totalPages - 1) : totalPages || firstPage; - - if (mode == "infinite") { - if (!links[currentPage + '']) { - throw new RangeError("No link found for page " + currentPage); - } - } - else if (currentPage < firstPage || - (totalPages > 0 && - (firstPage ? currentPage > totalPages : currentPage >= totalPages))) { - var op = firstPage ? ">=" : ">"; - - throw new RangeError("`currentPage` must be firstPage <= currentPage " + - (firstPage ? ">" : ">=") + - " totalPages if " + firstPage + "-based. Got " + - currentPage + '.'); - } - } - - return state; - }, - - /** - Change the page size of this collection. - - Under most if not all circumstances, you should call this method to - change the page size of a pageable collection because it will keep the - pagination state sane. By default, the method will recalculate the - current page number to one that will retain the current page's models - when increasing the page size. When decreasing the page size, this method - will retain the last models to the current page that will fit into the - smaller page size. - - If `options.first` is true, changing the page size will also reset the - current page back to the first page instead of trying to be smart. - - For server mode operations, changing the page size will trigger a #fetch - and subsequently a `reset` event. - - For client mode operations, changing the page size will `reset` the - current page by recalculating the current page boundary on the client - side. - - If `options.fetch` is true, a fetch can be forced if the collection is in - client mode. - - @param {number} pageSize The new page size to set to #state. - @param {Object} [options] {@link #fetch} options. - @param {boolean} [options.first=false] Reset the current page number to - the first page if `true`. - @param {boolean} [options.fetch] If `true`, force a fetch in client mode. - - @throws {TypeError} If `pageSize` is not a finite integer. - @throws {RangeError} If `pageSize` is less than 1. - - @chainable - @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest - from fetch or this. - */ - setPageSize: function (pageSize, options) { - pageSize = finiteInt(pageSize, "pageSize"); - - options = options || {first: false}; - - var state = this.state; - var totalPages = ceil(state.totalRecords / pageSize); - var currentPage = totalPages ? - max(state.firstPage, - floor(totalPages * - (state.firstPage ? - state.currentPage : - state.currentPage + 1) / - state.totalPages)) : - state.firstPage; - - state = this.state = this._checkState(_extend({}, state, { - pageSize: pageSize, - currentPage: options.first ? state.firstPage : currentPage, - totalPages: totalPages - })); - - return this.getPage(state.currentPage, _omit(options, ["first"])); - }, - - /** - Switching between client, server and infinite mode. - - If switching from client to server mode, the #fullCollection is emptied - first and then deleted and a fetch is immediately issued for the current - page from the server. Pass `false` to `options.fetch` to skip fetching. - - If switching to infinite mode, and if `options.models` is given for an - array of models, #links will be populated with a URL per page, using the - default URL for this collection. - - If switching from server to client mode, all of the pages are immediately - refetched. If you have too many pages, you can pass `false` to - `options.fetch` to skip fetching. - - If switching to any mode from infinite mode, the #links will be deleted. - - @param {"server"|"client"|"infinite"} [mode] The mode to switch to. - - @param {Object} [options] - - @param {boolean} [options.fetch=true] If `false`, no fetching is done. - - @param {boolean} [options.resetState=true] If 'false', the state is not - reset, but checked for sanity instead. - - @chainable - @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest - from fetch or this if `options.fetch` is `false`. - */ - switchMode: function (mode, options) { - - if (!_contains(["server", "client", "infinite"], mode)) { - throw new TypeError('`mode` must be one of "server", "client" or "infinite"'); - } - - options = options || {fetch: true, resetState: true}; - - var state = this.state = options.resetState ? - _clone(this._initState) : - this._checkState(_extend({}, this.state)); - - this.mode = mode; - - var self = this; - var fullCollection = this.fullCollection; - var handlers = this._handlers = this._handlers || {}, handler; - if (mode != "server" && !fullCollection) { - fullCollection = this._makeFullCollection(options.models || [], options); - fullCollection.pageableCollection = this; - this.fullCollection = fullCollection; - var allHandler = this._makeCollectionEventHandler(this, fullCollection); - _each(["add", "remove", "reset", "sort"], function (event) { - handlers[event] = handler = _.bind(allHandler, {}, event); - self.on(event, handler); - fullCollection.on(event, handler); - }); - fullCollection.comparator = this._fullComparator; - } - else if (mode == "server" && fullCollection) { - _each(_keys(handlers), function (event) { - handler = handlers[event]; - self.off(event, handler); - fullCollection.off(event, handler); - }); - delete this._handlers; - this._fullComparator = fullCollection.comparator; - delete this.fullCollection; - } - - if (mode == "infinite") { - var links = this.links = {}; - var firstPage = state.firstPage; - var totalPages = ceil(state.totalRecords / state.pageSize); - var lastPage = firstPage === 0 ? max(0, totalPages - 1) : totalPages || firstPage; - for (var i = state.firstPage; i <= lastPage; i++) { - links[i] = this.url; - } - } - else if (this.links) delete this.links; - - return options.fetch ? - this.fetch(_omit(options, "fetch", "resetState")) : - this; - }, - - /** - @return {boolean} `true` if this collection can page backward, `false` - otherwise. - */ - hasPrevious: function () { - var state = this.state; - var currentPage = state.currentPage; - if (this.mode != "infinite") return currentPage > state.firstPage; - return !!this.links[currentPage - 1]; - }, - - /** - @return {boolean} `true` if this collection can page forward, `false` - otherwise. - */ - hasNext: function () { - var state = this.state; - var currentPage = this.state.currentPage; - if (this.mode != "infinite") return currentPage < state.lastPage; - return !!this.links[currentPage + 1]; - }, - - /** - Fetch the first page in server mode, or reset the current page of this - collection to the first page in client or infinite mode. - - @param {Object} options {@link #getPage} options. - - @chainable - @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest - from fetch or this. - */ - getFirstPage: function (options) { - return this.getPage("first", options); - }, - - /** - Fetch the previous page in server mode, or reset the current page of this - collection to the previous page in client or infinite mode. - - @param {Object} options {@link #getPage} options. - - @chainable - @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest - from fetch or this. - */ - getPreviousPage: function (options) { - return this.getPage("prev", options); - }, - - /** - Fetch the next page in server mode, or reset the current page of this - collection to the next page in client mode. - - @param {Object} options {@link #getPage} options. - - @chainable - @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest - from fetch or this. - */ - getNextPage: function (options) { - return this.getPage("next", options); - }, - - /** - Fetch the last page in server mode, or reset the current page of this - collection to the last page in client mode. - - @param {Object} options {@link #getPage} options. - - @chainable - @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest - from fetch or this. - */ - getLastPage: function (options) { - return this.getPage("last", options); - }, - - /** - Given a page index, set #state.currentPage to that index. If this - collection is in server mode, fetch the page using the updated state, - otherwise, reset the current page of this collection to the page - specified by `index` in client mode. If `options.fetch` is true, a fetch - can be forced in client mode before resetting the current page. Under - infinite mode, if the index is less than the current page, a reset is - done as in client mode. If the index is greater than the current page - number, a fetch is made with the results **appended** to #fullCollection. - The current page will then be reset after fetching. - - @param {number|string} index The page index to go to, or the page name to - look up from #links in infinite mode. - @param {Object} [options] {@link #fetch} options or - [reset](http://backbonejs.org/#Collection-reset) options for client mode - when `options.fetch` is `false`. - @param {boolean} [options.fetch=false] If true, force a {@link #fetch} in - client mode. - - @throws {TypeError} If `index` is not a finite integer under server or - client mode, or does not yield a URL from #links under infinite mode. - - @throws {RangeError} If `index` is out of bounds. - - @chainable - @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest - from fetch or this. - */ - getPage: function (index, options) { - - var mode = this.mode, fullCollection = this.fullCollection; - - options = options || {fetch: false}; - - var state = this.state, - firstPage = state.firstPage, - currentPage = state.currentPage, - lastPage = state.lastPage, - pageSize = state.pageSize; - - var pageNum = index; - switch (index) { - case "first": pageNum = firstPage; break; - case "prev": pageNum = currentPage - 1; break; - case "next": pageNum = currentPage + 1; break; - case "last": pageNum = lastPage; break; - default: pageNum = finiteInt(index, "index"); - } - - this.state = this._checkState(_extend({}, state, {currentPage: pageNum})); - - options.from = currentPage, options.to = pageNum; - - var pageStart = (firstPage === 0 ? pageNum : pageNum - 1) * pageSize; - var pageModels = fullCollection && fullCollection.length ? - fullCollection.models.slice(pageStart, pageStart + pageSize) : - []; - if ((mode == "client" || (mode == "infinite" && !_isEmpty(pageModels))) && - !options.fetch) { - this.reset(pageModels, _omit(options, "fetch")); - return this; - } - - if (mode == "infinite") options.url = this.links[pageNum]; - - return this.fetch(_omit(options, "fetch")); - }, - - /** - Fetch the page for the provided item offset in server mode, or reset the current page of this - collection to the page for the provided item offset in client mode. - - @param {Object} options {@link #getPage} options. - - @chainable - @return {XMLHttpRequest|Backbone.PageableCollection} The XMLHttpRequest - from fetch or this. - */ - getPageByOffset: function (offset, options) { - if (offset < 0) { - throw new RangeError("`offset must be > 0`"); - } - offset = finiteInt(offset); - - var page = floor(offset / this.state.pageSize); - if (this.state.firstPage !== 0) page++; - if (page > this.state.lastPage) page = this.state.lastPage; - return this.getPage(page, options); - }, - - /** - Overidden to make `getPage` compatible with Zepto. - - @param {string} method - @param {Backbone.Model|Backbone.Collection} model - @param {Object} [options] - - @return {XMLHttpRequest} - */ - sync: function (method, model, options) { - var self = this; - if (self.mode == "infinite") { - var success = options.success; - var currentPage = self.state.currentPage; - options.success = function (resp, status, xhr) { - var links = self.links; - var newLinks = self.parseLinks(resp, _extend({xhr: xhr}, options)); - if (newLinks.first) links[self.state.firstPage] = newLinks.first; - if (newLinks.prev) links[currentPage - 1] = newLinks.prev; - if (newLinks.next) links[currentPage + 1] = newLinks.next; - if (success) success(resp, status, xhr); - }; - } - - return (BBColProto.sync || Backbone.sync).call(self, method, model, options); - }, - - /** - Parse pagination links from the server response. Only valid under - infinite mode. - - Given a response body and a XMLHttpRequest object, extract pagination - links from them for infinite paging. - - This default implementation parses the RFC 5988 `Link` header and extract - 3 links from it - `first`, `prev`, `next`. If a `previous` link is found, - it will be found in the `prev` key in the returned object hash. Any - subclasses overriding this method __must__ return an object hash having - only the keys above. If `first` is missing, the collection's default URL - is assumed to be the `first` URL. If `prev` or `next` is missing, it is - assumed to be `null`. An empty object hash must be returned if there are - no links found. If either the response or the header contains information - pertaining to the total number of records on the server, #state.totalRecords - must be set to that number. The default implementation uses the `last` - link from the header to calculate it. - - @param {*} resp The deserialized response body. - @param {Object} [options] - @param {XMLHttpRequest} [options.xhr] The XMLHttpRequest object for this - response. - @return {Object} - */ - parseLinks: function (resp, options) { - var links = {}; - var linkHeader = options.xhr.getResponseHeader("Link"); - if (linkHeader) { - var relations = ["first", "prev", "previous", "next", "last"]; - _each(linkHeader.split(","), function (linkValue) { - var linkParts = linkValue.split(";"); - var url = linkParts[0].replace(URL_TRIM_RE, ''); - var params = linkParts.slice(1); - _each(params, function (param) { - var paramParts = param.split("="); - var key = paramParts[0].replace(PARAM_TRIM_RE, ''); - var value = paramParts[1].replace(PARAM_TRIM_RE, ''); - if (key == "rel" && _contains(relations, value)) { - if (value == "previous") links.prev = url; - else links[value] = url; - } - }); - }); - - var last = links.last || '', qsi, qs; - if (qs = (qsi = last.indexOf('?')) ? last.slice(qsi + 1) : '') { - var params = queryStringToParams(qs); - - var state = _clone(this.state); - var queryParams = this.queryParams; - var pageSize = state.pageSize; - - var totalRecords = params[queryParams.totalRecords] * 1; - var pageNum = params[queryParams.currentPage] * 1; - var totalPages = params[queryParams.totalPages]; - - if (!totalRecords) { - if (pageNum) totalRecords = (state.firstPage === 0 ? - pageNum + 1 : - pageNum) * pageSize; - else if (totalPages) totalRecords = totalPages * pageSize; - } - - if (totalRecords) state.totalRecords = totalRecords; - - this.state = this._checkState(state); - } - } - - delete links.last; - - return links; - }, - - /** - Parse server response data. - - This default implementation assumes the response data is in one of two - structures: - - [ - {}, // Your new pagination state - [{}, ...] // An array of JSON objects - ] - - Or, - - [{}] // An array of JSON objects - - The first structure is the preferred form because the pagination states - may have been updated on the server side, sending them down again allows - this collection to update its states. If the response has a pagination - state object, it is checked for errors. - - The second structure is the - [Backbone.Collection#parse](http://backbonejs.org/#Collection-parse) - default. - - **Note:** this method has been further simplified since 1.1.7. While - existing #parse implementations will continue to work, new code is - encouraged to override #parseState and #parseRecords instead. - - @param {Object} resp The deserialized response data from the server. - @param {Object} the options for the ajax request - - @return {Array.<Object>} An array of model objects - */ - parse: function (resp, options) { - var newState = this.parseState(resp, _clone(this.queryParams), _clone(this.state), options); - if (newState) this.state = this._checkState(_extend({}, this.state, newState)); - return this.parseRecords(resp, options); - }, - - /** - Parse server response for server pagination state updates. - - This default implementation first checks whether the response has any - state object as documented in #parse. If it exists, a state object is - returned by mapping the server state keys to this pageable collection - instance's query parameter keys using `queryParams`. - - It is __NOT__ neccessary to return a full state object complete with all - the mappings defined in #queryParams. Any state object resulted is merged - with a copy of the current pageable collection state and checked for - sanity before actually updating. Most of the time, simply providing a new - `totalRecords` value is enough to trigger a full pagination state - recalculation. - - parseState: function (resp, queryParams, state, options) { - return {totalRecords: resp.total_entries}; - } - - If you want to use header fields use: - - parseState: function (resp, queryParams, state, options) { - return {totalRecords: options.xhr.getResponseHeader("X-total")}; - } - - This method __MUST__ return a new state object instead of directly - modifying the #state object. The behavior of directly modifying #state is - undefined. - - @param {Object} resp The deserialized response data from the server. - @param {Object} queryParams A copy of #queryParams. - @param {Object} state A copy of #state. - @param {Object} [options] The options passed through from - `parse`. (backbone >= 0.9.10 only) - - @return {Object} A new (partial) state object. - */ - parseState: function (resp, queryParams, state, options) { - if (resp && resp.length === 2 && _isObject(resp[0]) && _isArray(resp[1])) { - - var newState = _clone(state); - var serverState = resp[0]; - - _each(_pairs(_omit(queryParams, "directions")), function (kvp) { - var k = kvp[0], v = kvp[1]; - var serverVal = serverState[v]; - if (!_isUndefined(serverVal) && !_.isNull(serverVal)) newState[k] = serverState[v]; - }); - - if (serverState.order) { - newState.order = _invert(queryParams.directions)[serverState.order] * 1; - } - - return newState; - } - }, - - /** - Parse server response for an array of model objects. - - This default implementation first checks whether the response has any - state object as documented in #parse. If it exists, the array of model - objects is assumed to be the second element, otherwise the entire - response is returned directly. - - @param {Object} resp The deserialized response data from the server. - @param {Object} [options] The options passed through from the - `parse`. (backbone >= 0.9.10 only) - - @return {Array.<Object>} An array of model objects - */ - parseRecords: function (resp, options) { - if (resp && resp.length === 2 && _isObject(resp[0]) && _isArray(resp[1])) { - return resp[1]; - } - - return resp; - }, - - /** - Fetch a page from the server in server mode, or all the pages in client - mode. Under infinite mode, the current page is refetched by default and - then reset. - - The query string is constructed by translating the current pagination - state to your server API query parameter using #queryParams. The current - page will reset after fetch. - - @param {Object} [options] Accepts all - [Backbone.Collection#fetch](http://backbonejs.org/#Collection-fetch) - options. - - @return {XMLHttpRequest} - */ - fetch: function (options) { - - options = options || {}; - - var state = this._checkState(this.state); - - var mode = this.mode; - - if (mode == "infinite" && !options.url) { - options.url = this.links[state.currentPage]; - } - - var data = options.data || {}; - - // dedup query params - var url = _result(options, "url") || _result(this, "url") || ''; - var qsi = url.indexOf('?'); - if (qsi != -1) { - _extend(data, queryStringToParams(url.slice(qsi + 1))); - url = url.slice(0, qsi); - } - - options.url = url; - options.data = data; - - // map params except directions - var queryParams = this.mode == "client" ? - _pick(this.queryParams, "sortKey", "order") : - _omit(_pick(this.queryParams, _keys(PageableProto.queryParams)), - "directions"); - - var i, kvp, k, v, kvps = _pairs(queryParams), thisCopy = _clone(this); - for (i = 0; i < kvps.length; i++) { - kvp = kvps[i], k = kvp[0], v = kvp[1]; - v = _isFunction(v) ? v.call(thisCopy) : v; - if (state[k] != null && v != null) { - data[v] = state[k]; - } - } - - // fix up sorting parameters - if (state.sortKey && state.order) { - data[queryParams.order] = this.queryParams.directions[state.order + ""]; - } - else if (!state.sortKey) delete data[queryParams.order]; - - // map extra query parameters - var extraKvps = _pairs(_omit(this.queryParams, - _keys(PageableProto.queryParams))); - for (i = 0; i < extraKvps.length; i++) { - kvp = extraKvps[i]; - v = kvp[1]; - v = _isFunction(v) ? v.call(thisCopy) : v; - if (v != null) data[kvp[0]] = v; - } - - if (mode != "server") { - var self = this, fullCol = this.fullCollection; - var success = options.success; - options.success = function (col, resp, opts) { - - // make sure the caller's intent is obeyed - opts = opts || {}; - if (_isUndefined(options.silent)) delete opts.silent; - else opts.silent = options.silent; - - var models = col.models; - if (mode == "client") fullCol.reset(models, opts); - else fullCol.add(models, _extend({at: fullCol.length}, opts)); - - if (success) success(col, resp, opts); - }; - - // silent the first reset from backbone - return BBColProto.fetch.call(self, _extend({}, options, {silent: true})); - } - - return BBColProto.fetch.call(this, options); - }, - - /** - Convenient method for making a `comparator` sorted by a model attribute - identified by `sortKey` and ordered by `order`. - - Like a Backbone.Collection, a Backbone.PageableCollection will maintain - the __current page__ in sorted order on the client side if a `comparator` - is attached to it. If the collection is in client mode, you can attach a - comparator to #fullCollection to have all the pages reflect the global - sorting order by specifying an option `full` to `true`. You __must__ call - `sort` manually or #fullCollection.sort after calling this method to - force a resort. - - While you can use this method to sort the current page in server mode, - the sorting order may not reflect the global sorting order due to the - additions or removals of the records on the server since the last - fetch. If you want the most updated page in a global sorting order, it is - recommended that you set #state.sortKey and optionally #state.order, and - then call #fetch. - - @protected - - @param {string} [sortKey=this.state.sortKey] See `state.sortKey`. - @param {number} [order=this.state.order] See `state.order`. - @param {(function(Backbone.Model, string): Object) | string} [sortValue] See #setSorting. - - See [Backbone.Collection.comparator](http://backbonejs.org/#Collection-comparator). - */ - _makeComparator: function (sortKey, order, sortValue) { - var state = this.state; - - sortKey = sortKey || state.sortKey; - order = order || state.order; - - if (!sortKey || !order) return; - - if (!sortValue) sortValue = function (model, attr) { - return model.get(attr); - }; - - return function (left, right) { - var l = sortValue(left, sortKey), r = sortValue(right, sortKey), t; - if (order === 1) t = l, l = r, r = t; - if (l === r) return 0; - else if (l < r) return -1; - return 1; - }; - }, - - /** - Adjusts the sorting for this pageable collection. - - Given a `sortKey` and an `order`, sets `state.sortKey` and - `state.order`. A comparator can be applied on the client side to sort in - the order defined if `options.side` is `"client"`. By default the - comparator is applied to the #fullCollection. Set `options.full` to - `false` to apply a comparator to the current page under any mode. Setting - `sortKey` to `null` removes the comparator from both the current page and - the full collection. - - If a `sortValue` function is given, it will be passed the `(model, - sortKey)` arguments and is used to extract a value from the model during - comparison sorts. If `sortValue` is not given, `model.get(sortKey)` is - used for sorting. - - @chainable - - @param {string} sortKey See `state.sortKey`. - @param {number} [order=this.state.order] See `state.order`. - @param {Object} [options] - @param {"server"|"client"} [options.side] By default, `"client"` if - `mode` is `"client"`, `"server"` otherwise. - @param {boolean} [options.full=true] - @param {(function(Backbone.Model, string): Object) | string} [options.sortValue] - */ - setSorting: function (sortKey, order, options) { - - var state = this.state; - - state.sortKey = sortKey; - state.order = order = order || state.order; - - var fullCollection = this.fullCollection; - - var delComp = false, delFullComp = false; - - if (!sortKey) delComp = delFullComp = true; - - var mode = this.mode; - options = _extend({side: mode == "client" ? mode : "server", full: true}, - options); - - var comparator = this._makeComparator(sortKey, order, options.sortValue); - - var full = options.full, side = options.side; - - if (side == "client") { - if (full) { - if (fullCollection) fullCollection.comparator = comparator; - delComp = true; - } - else { - this.comparator = comparator; - delFullComp = true; - } - } - else if (side == "server" && !full) { - this.comparator = comparator; - } - - if (delComp) this.comparator = null; - if (delFullComp && fullCollection) fullCollection.comparator = null; - - return this; - } - - }); - - var PageableProto = PageableCollection.prototype; - - return PageableCollection; - -})); diff --git a/src/UI/JsLibraries/backbone.validation.js b/src/UI/JsLibraries/backbone.validation.js deleted file mode 100644 index d81836168..000000000 --- a/src/UI/JsLibraries/backbone.validation.js +++ /dev/null @@ -1,606 +0,0 @@ -// Backbone.Validation v0.8.1 -// -// Copyright (c) 2011-2013 Thomas Pedersen -// Distributed under MIT License -// -// Documentation and full license available at: -// http://thedersen.com/projects/backbone-validation -Backbone.Validation = (function(_){ - 'use strict'; - - // Default options - // --------------- - - var defaultOptions = { - forceUpdate: false, - selector: 'name', - labelFormatter: 'sentenceCase', - valid: Function.prototype, - invalid: Function.prototype - }; - - - // Helper functions - // ---------------- - - // Formatting functions used for formatting error messages - var formatFunctions = { - // Uses the configured label formatter to format the attribute name - // to make it more readable for the user - formatLabel: function(attrName, model) { - return defaultLabelFormatters[defaultOptions.labelFormatter](attrName, model); - }, - - // Replaces nummeric placeholders like {0} in a string with arguments - // passed to the function - format: function() { - var args = Array.prototype.slice.call(arguments), - text = args.shift(); - return text.replace(/\{(\d+)\}/g, function(match, number) { - return typeof args[number] !== 'undefined' ? args[number] : match; - }); - } - }; - - // Flattens an object - // eg: - // - // var o = { - // address: { - // street: 'Street', - // zip: 1234 - // } - // }; - // - // becomes: - // - // var o = { - // 'address.street': 'Street', - // 'address.zip': 1234 - // }; - var flatten = function (obj, into, prefix) { - into = into || {}; - prefix = prefix || ''; - - _.each(obj, function(val, key) { - if(obj.hasOwnProperty(key)) { - if (val && typeof val === 'object' && !( - val instanceof Array || - val instanceof Date || - val instanceof RegExp || - val instanceof Backbone.Model || - val instanceof Backbone.Collection) - ) { - flatten(val, into, prefix + key + '.'); - } - else { - into[prefix + key] = val; - } - } - }); - - return into; - }; - - // Validation - // ---------- - - var Validation = (function(){ - - // Returns an object with undefined properties for all - // attributes on the model that has defined one or more - // validation rules. - var getValidatedAttrs = function(model) { - return _.reduce(_.keys(model.validation || {}), function(memo, key) { - memo[key] = void 0; - return memo; - }, {}); - }; - - // Looks on the model for validations for a specified - // attribute. Returns an array of any validators defined, - // or an empty array if none is defined. - var getValidators = function(model, attr) { - var attrValidationSet = model.validation ? model.validation[attr] || {} : {}; - - // If the validator is a function or a string, wrap it in a function validator - if (_.isFunction(attrValidationSet) || _.isString(attrValidationSet)) { - attrValidationSet = { - fn: attrValidationSet - }; - } - - // Stick the validator object into an array - if(!_.isArray(attrValidationSet)) { - attrValidationSet = [attrValidationSet]; - } - - // Reduces the array of validators into a new array with objects - // with a validation method to call, the value to validate against - // and the specified error message, if any - return _.reduce(attrValidationSet, function(memo, attrValidation) { - _.each(_.without(_.keys(attrValidation), 'msg'), function(validator) { - memo.push({ - fn: defaultValidators[validator], - val: attrValidation[validator], - msg: attrValidation.msg - }); - }); - return memo; - }, []); - }; - - // Validates an attribute against all validators defined - // for that attribute. If one or more errors are found, - // the first error message is returned. - // If the attribute is valid, an empty string is returned. - var validateAttr = function(model, attr, value, computed) { - // Reduces the array of validators to an error message by - // applying all the validators and returning the first error - // message, if any. - return _.reduce(getValidators(model, attr), function(memo, validator){ - // Pass the format functions plus the default - // validators as the context to the validator - var ctx = _.extend({}, formatFunctions, defaultValidators), - result = validator.fn.call(ctx, value, attr, validator.val, model, computed); - - if(result === false || memo === false) { - return false; - } - if (result && !memo) { - return validator.msg || result; - } - return memo; - }, ''); - }; - - // Loops through the model's attributes and validates them all. - // Returns and object containing names of invalid attributes - // as well as error messages. - var validateModel = function(model, attrs) { - var error, - invalidAttrs = {}, - isValid = true, - computed = _.clone(attrs), - flattened = flatten(attrs); - - _.each(flattened, function(val, attr) { - error = validateAttr(model, attr, val, computed); - if (error) { - invalidAttrs[attr] = error; - isValid = false; - } - }); - - return { - invalidAttrs: invalidAttrs, - isValid: isValid - }; - }; - - // Contains the methods that are mixed in on the model when binding - var mixin = function(view, options) { - return { - - // Check whether or not a value passes validation - // without updating the model - preValidate: function(attr, value) { - return validateAttr(this, attr, value, _.extend({}, this.attributes)); - }, - - // Check to see if an attribute, an array of attributes or the - // entire model is valid. Passing true will force a validation - // of the model. - isValid: function(option) { - var flattened = flatten(this.attributes); - - if(_.isString(option)){ - return !validateAttr(this, option, flattened[option], _.extend({}, this.attributes)); - } - if(_.isArray(option)){ - return _.reduce(option, function(memo, attr) { - return memo && !validateAttr(this, attr, flattened[attr], _.extend({}, this.attributes)); - }, true, this); - } - if(option === true) { - this.validate(); - } - return this.validation ? this._isValid : true; - }, - - // This is called by Backbone when it needs to perform validation. - // You can call it manually without any parameters to validate the - // entire model. - validate: function(attrs, setOptions){ - var model = this, - validateAll = !attrs, - opt = _.extend({}, options, setOptions), - validatedAttrs = getValidatedAttrs(model), - allAttrs = _.extend({}, validatedAttrs, model.attributes, attrs), - changedAttrs = flatten(attrs || allAttrs), - - result = validateModel(model, allAttrs); - - model._isValid = result.isValid; - - // After validation is performed, loop through all changed attributes - // and call the valid callbacks so the view is updated. - _.each(validatedAttrs, function(val, attr){ - var invalid = result.invalidAttrs.hasOwnProperty(attr); - if(!invalid){ - opt.valid(view, attr, opt.selector); - } - }); - - // After validation is performed, loop through all changed attributes - // and call the invalid callback so the view is updated. - _.each(validatedAttrs, function(val, attr){ - var invalid = result.invalidAttrs.hasOwnProperty(attr), - changed = changedAttrs.hasOwnProperty(attr); - - if(invalid && (changed || validateAll)){ - opt.invalid(view, attr, result.invalidAttrs[attr], opt.selector); - } - }); - - // Trigger validated events. - // Need to defer this so the model is actually updated before - // the event is triggered. - _.defer(function() { - model.trigger('validated', model._isValid, model, result.invalidAttrs); - model.trigger('validated:' + (model._isValid ? 'valid' : 'invalid'), model, result.invalidAttrs); - }); - - // Return any error messages to Backbone, unless the forceUpdate flag is set. - // Then we do not return anything and fools Backbone to believe the validation was - // a success. That way Backbone will update the model regardless. - if (!opt.forceUpdate && _.intersection(_.keys(result.invalidAttrs), _.keys(changedAttrs)).length > 0) { - return result.invalidAttrs; - } - } - }; - }; - - // Helper to mix in validation on a model - var bindModel = function(view, model, options) { - _.extend(model, mixin(view, options)); - }; - - // Removes the methods added to a model - var unbindModel = function(model) { - delete model.validate; - delete model.preValidate; - delete model.isValid; - }; - - // Mix in validation on a model whenever a model is - // added to a collection - var collectionAdd = function(model) { - bindModel(this.view, model, this.options); - }; - - // Remove validation from a model whenever a model is - // removed from a collection - var collectionRemove = function(model) { - unbindModel(model); - }; - - // Returns the public methods on Backbone.Validation - return { - - // Current version of the library - version: '0.8.1', - - // Called to configure the default options - configure: function(options) { - _.extend(defaultOptions, options); - }, - - // Hooks up validation on a view with a model - // or collection - bind: function(view, options) { - var model = view.model, - collection = view.collection; - - options = _.extend({}, defaultOptions, defaultCallbacks, options); - - if(typeof model === 'undefined' && typeof collection === 'undefined'){ - throw 'Before you execute the binding your view must have a model or a collection.\n' + - 'See http://thedersen.com/projects/backbone-validation/#using-form-model-validation for more information.'; - } - - if(model) { - bindModel(view, model, options); - } - else if(collection) { - collection.each(function(model){ - bindModel(view, model, options); - }); - collection.bind('add', collectionAdd, {view: view, options: options}); - collection.bind('remove', collectionRemove); - } - }, - - // Removes validation from a view with a model - // or collection - unbind: function(view) { - var model = view.model, - collection = view.collection; - - if(model) { - unbindModel(view.model); - } - if(collection) { - collection.each(function(model){ - unbindModel(model); - }); - collection.unbind('add', collectionAdd); - collection.unbind('remove', collectionRemove); - } - }, - - // Used to extend the Backbone.Model.prototype - // with validation - mixin: mixin(null, defaultOptions) - }; - }()); - - - // Callbacks - // --------- - - var defaultCallbacks = Validation.callbacks = { - - // Gets called when a previously invalid field in the - // view becomes valid. Removes any error message. - // Should be overridden with custom functionality. - valid: function(view, attr, selector) { - view.$('[' + selector + '~="' + attr + '"]') - .removeClass('invalid') - .removeAttr('data-error'); - }, - - // Gets called when a field in the view becomes invalid. - // Adds a error message. - // Should be overridden with custom functionality. - invalid: function(view, attr, error, selector) { - view.$('[' + selector + '~="' + attr + '"]') - .addClass('invalid') - .attr('data-error', error); - } - }; - - - // Patterns - // -------- - - var defaultPatterns = Validation.patterns = { - // Matches any digit(s) (i.e. 0-9) - digits: /^\d+$/, - - // Matched any number (e.g. 100.000) - number: /^-?(?:\d+|\d{1,3}(?:,\d{3})+)(?:\.\d+)?$/, - - // Matches a valid email address (e.g. mail@example.com) - email: /^((([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+(\.([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+)*)|((\x22)((((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(([\x01-\x08\x0b\x0c\x0e-\x1f\x7f]|\x21|[\x23-\x5b]|[\x5d-\x7e]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(\\([\x01-\x09\x0b\x0c\x0d-\x7f]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))))*(((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(\x22)))@((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))$/i, - - // Mathes any valid url (e.g. http://www.xample.com) - url: /^(https?|ftp):\/\/(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i - }; - - - // Error messages - // -------------- - - // Error message for the build in validators. - // {x} gets swapped out with arguments form the validator. - var defaultMessages = Validation.messages = { - required: '{0} is required', - acceptance: '{0} must be accepted', - min: '{0} must be greater than or equal to {1}', - max: '{0} must be less than or equal to {1}', - range: '{0} must be between {1} and {2}', - length: '{0} must be {1} characters', - minLength: '{0} must be at least {1} characters', - maxLength: '{0} must be at most {1} characters', - rangeLength: '{0} must be between {1} and {2} characters', - oneOf: '{0} must be one of: {1}', - equalTo: '{0} must be the same as {1}', - pattern: '{0} must be a valid {1}' - }; - - // Label formatters - // ---------------- - - // Label formatters are used to convert the attribute name - // to a more human friendly label when using the built in - // error messages. - // Configure which one to use with a call to - // - // Backbone.Validation.configure({ - // labelFormatter: 'label' - // }); - var defaultLabelFormatters = Validation.labelFormatters = { - - // Returns the attribute name with applying any formatting - none: function(attrName) { - return attrName; - }, - - // Converts attributeName or attribute_name to Attribute name - sentenceCase: function(attrName) { - return attrName.replace(/(?:^\w|[A-Z]|\b\w)/g, function(match, index) { - return index === 0 ? match.toUpperCase() : ' ' + match.toLowerCase(); - }).replace(/_/g, ' '); - }, - - // Looks for a label configured on the model and returns it - // - // var Model = Backbone.Model.extend({ - // validation: { - // someAttribute: { - // required: true - // } - // }, - // - // labels: { - // someAttribute: 'Custom label' - // } - // }); - label: function(attrName, model) { - return (model.labels && model.labels[attrName]) || defaultLabelFormatters.sentenceCase(attrName, model); - } - }; - - - // Built in validators - // ------------------- - - var defaultValidators = Validation.validators = (function(){ - // Use native trim when defined - var trim = String.prototype.trim ? - function(text) { - return text === null ? '' : String.prototype.trim.call(text); - } : - function(text) { - var trimLeft = /^\s+/, - trimRight = /\s+$/; - - return text === null ? '' : text.toString().replace(trimLeft, '').replace(trimRight, ''); - }; - - // Determines whether or not a value is a number - var isNumber = function(value){ - return _.isNumber(value) || (_.isString(value) && value.match(defaultPatterns.number)); - }; - - // Determines whether or not a value is empty - var hasValue = function(value) { - return !(_.isNull(value) || _.isUndefined(value) || (_.isString(value) && trim(value) === '') || (_.isArray(value) && _.isEmpty(value))); - }; - - return { - // Function validator - // Lets you implement a custom function used for validation - fn: function(value, attr, fn, model, computed) { - if(_.isString(fn)){ - fn = model[fn]; - } - return fn.call(model, value, attr, computed); - }, - - // Required validator - // Validates if the attribute is required or not - required: function(value, attr, required, model, computed) { - var isRequired = _.isFunction(required) ? required.call(model, value, attr, computed) : required; - if(!isRequired && !hasValue(value)) { - return false; // overrides all other validators - } - if (isRequired && !hasValue(value)) { - return this.format(defaultMessages.required, this.formatLabel(attr, model)); - } - }, - - // Acceptance validator - // Validates that something has to be accepted, e.g. terms of use - // `true` or 'true' are valid - acceptance: function(value, attr, accept, model) { - if(value !== 'true' && (!_.isBoolean(value) || value === false)) { - return this.format(defaultMessages.acceptance, this.formatLabel(attr, model)); - } - }, - - // Min validator - // Validates that the value has to be a number and equal to or greater than - // the min value specified - min: function(value, attr, minValue, model) { - if (!isNumber(value) || value < minValue) { - return this.format(defaultMessages.min, this.formatLabel(attr, model), minValue); - } - }, - - // Max validator - // Validates that the value has to be a number and equal to or less than - // the max value specified - max: function(value, attr, maxValue, model) { - if (!isNumber(value) || value > maxValue) { - return this.format(defaultMessages.max, this.formatLabel(attr, model), maxValue); - } - }, - - // Range validator - // Validates that the value has to be a number and equal to or between - // the two numbers specified - range: function(value, attr, range, model) { - if(!isNumber(value) || value < range[0] || value > range[1]) { - return this.format(defaultMessages.range, this.formatLabel(attr, model), range[0], range[1]); - } - }, - - // Length validator - // Validates that the value has to be a string with length equal to - // the length value specified - length: function(value, attr, length, model) { - if (!hasValue(value) || trim(value).length !== length) { - return this.format(defaultMessages.length, this.formatLabel(attr, model), length); - } - }, - - // Min length validator - // Validates that the value has to be a string with length equal to or greater than - // the min length value specified - minLength: function(value, attr, minLength, model) { - if (!hasValue(value) || trim(value).length < minLength) { - return this.format(defaultMessages.minLength, this.formatLabel(attr, model), minLength); - } - }, - - // Max length validator - // Validates that the value has to be a string with length equal to or less than - // the max length value specified - maxLength: function(value, attr, maxLength, model) { - if (!hasValue(value) || trim(value).length > maxLength) { - return this.format(defaultMessages.maxLength, this.formatLabel(attr, model), maxLength); - } - }, - - // Range length validator - // Validates that the value has to be a string and equal to or between - // the two numbers specified - rangeLength: function(value, attr, range, model) { - if(!hasValue(value) || trim(value).length < range[0] || trim(value).length > range[1]) { - return this.format(defaultMessages.rangeLength, this.formatLabel(attr, model), range[0], range[1]); - } - }, - - // One of validator - // Validates that the value has to be equal to one of the elements in - // the specified array. Case sensitive matching - oneOf: function(value, attr, values, model) { - if(!_.include(values, value)){ - return this.format(defaultMessages.oneOf, this.formatLabel(attr, model), values.join(', ')); - } - }, - - // Equal to validator - // Validates that the value has to be equal to the value of the attribute - // with the name specified - equalTo: function(value, attr, equalTo, model, computed) { - if(value !== computed[equalTo]) { - return this.format(defaultMessages.equalTo, this.formatLabel(attr, model), this.formatLabel(equalTo, model)); - } - }, - - // Pattern validator - // Validates that the value has to match the pattern specified. - // Can be a regular expression or the name of one of the built in patterns - pattern: function(value, attr, pattern, model) { - if (!hasValue(value) || !value.toString().match(defaultPatterns[pattern] || pattern)) { - return this.format(defaultMessages.pattern, this.formatLabel(attr, model), pattern); - } - } - }; - }()); - - return Validation; -}(_)); \ No newline at end of file diff --git a/src/UI/JsLibraries/backbone.wreqr.js b/src/UI/JsLibraries/backbone.wreqr.js deleted file mode 100644 index d8aa9bc9b..000000000 --- a/src/UI/JsLibraries/backbone.wreqr.js +++ /dev/null @@ -1,276 +0,0 @@ -(function (root, factory) { - if (typeof exports === 'object') { - - var underscore = require('underscore'); - var backbone = require('backbone'); - - module.exports = factory(underscore, backbone); - - } else if (typeof define === 'function' && define.amd) { - - define(['underscore', 'backbone'], factory); - - } -}(this, function (_, Backbone) { - 'use strict'; - - Backbone.Wreqr = (function(Backbone, Marionette, _){ - 'use strict'; - var Wreqr = {}; - - // Handlers -// -------- -// A registry of functions to call, given a name - -Wreqr.Handlers = (function(Backbone, _){ - 'use strict'; - - // Constructor - // ----------- - - var Handlers = function(options){ - this.options = options; - this._wreqrHandlers = {}; - - if (_.isFunction(this.initialize)){ - this.initialize(options); - } - }; - - Handlers.extend = Backbone.Model.extend; - - // Instance Members - // ---------------- - - _.extend(Handlers.prototype, Backbone.Events, { - - // Add multiple handlers using an object literal configuration - setHandlers: function(handlers){ - _.each(handlers, function(handler, name){ - var context = null; - - if (_.isObject(handler) && !_.isFunction(handler)){ - context = handler.context; - handler = handler.callback; - } - - this.setHandler(name, handler, context); - }, this); - }, - - // Add a handler for the given name, with an - // optional context to run the handler within - setHandler: function(name, handler, context){ - var config = { - callback: handler, - context: context - }; - - this._wreqrHandlers[name] = config; - - this.trigger("handler:add", name, handler, context); - }, - - // Determine whether or not a handler is registered - hasHandler: function(name){ - return !! this._wreqrHandlers[name]; - }, - - // Get the currently registered handler for - // the specified name. Throws an exception if - // no handler is found. - getHandler: function(name){ - var config = this._wreqrHandlers[name]; - - if (!config){ - throw new Error("Handler not found for '" + name + "'"); - } - - return function(){ - var args = Array.prototype.slice.apply(arguments); - return config.callback.apply(config.context, args); - }; - }, - - // Remove a handler for the specified name - removeHandler: function(name){ - delete this._wreqrHandlers[name]; - }, - - // Remove all handlers from this registry - removeAllHandlers: function(){ - this._wreqrHandlers = {}; - } - }); - - return Handlers; -})(Backbone, _); - - // Wreqr.CommandStorage -// -------------------- -// -// Store and retrieve commands for execution. -Wreqr.CommandStorage = (function(){ - 'use strict'; - - // Constructor function - var CommandStorage = function(options){ - this.options = options; - this._commands = {}; - - if (_.isFunction(this.initialize)){ - this.initialize(options); - } - }; - - // Instance methods - _.extend(CommandStorage.prototype, Backbone.Events, { - - // Get an object literal by command name, that contains - // the `commandName` and the `instances` of all commands - // represented as an array of arguments to process - getCommands: function(commandName){ - var commands = this._commands[commandName]; - - // we don't have it, so add it - if (!commands){ - - // build the configuration - commands = { - command: commandName, - instances: [] - }; - - // store it - this._commands[commandName] = commands; - } - - return commands; - }, - - // Add a command by name, to the storage and store the - // args for the command - addCommand: function(commandName, args){ - var command = this.getCommands(commandName); - command.instances.push(args); - }, - - // Clear all commands for the given `commandName` - clearCommands: function(commandName){ - var command = this.getCommands(commandName); - command.instances = []; - } - }); - - return CommandStorage; -})(); - - // Wreqr.Commands -// -------------- -// -// A simple command pattern implementation. Register a command -// handler and execute it. -Wreqr.Commands = (function(Wreqr){ - 'use strict'; - - return Wreqr.Handlers.extend({ - // default storage type - storageType: Wreqr.CommandStorage, - - constructor: function(options){ - this.options = options || {}; - - this._initializeStorage(this.options); - this.on("handler:add", this._executeCommands, this); - - var args = Array.prototype.slice.call(arguments); - Wreqr.Handlers.prototype.constructor.apply(this, args); - }, - - // Execute a named command with the supplied args - execute: function(name, args){ - name = arguments[0]; - args = Array.prototype.slice.call(arguments, 1); - - if (this.hasHandler(name)){ - this.getHandler(name).apply(this, args); - } else { - this.storage.addCommand(name, args); - } - - }, - - // Internal method to handle bulk execution of stored commands - _executeCommands: function(name, handler, context){ - var command = this.storage.getCommands(name); - - // loop through and execute all the stored command instances - _.each(command.instances, function(args){ - handler.apply(context, args); - }); - - this.storage.clearCommands(name); - }, - - // Internal method to initialize storage either from the type's - // `storageType` or the instance `options.storageType`. - _initializeStorage: function(options){ - var storage; - - var StorageType = options.storageType || this.storageType; - if (_.isFunction(StorageType)){ - storage = new StorageType(); - } else { - storage = StorageType; - } - - this.storage = storage; - } - }); - -})(Wreqr); - - // Wreqr.RequestResponse -// --------------------- -// -// A simple request/response implementation. Register a -// request handler, and return a response from it -Wreqr.RequestResponse = (function(Wreqr){ - 'use strict'; - - return Wreqr.Handlers.extend({ - request: function(){ - var name = arguments[0]; - var args = Array.prototype.slice.call(arguments, 1); - - return this.getHandler(name).apply(this, args); - } - }); - -})(Wreqr); - - // Event Aggregator -// ---------------- -// A pub-sub object that can be used to decouple various parts -// of an application through event-driven architecture. - -Wreqr.EventAggregator = (function(Backbone, _){ - 'use strict'; - var EA = function(){}; - - // Copy the `extend` function used by Backbone's classes - EA.extend = Backbone.Model.extend; - - // Copy the basic Backbone.Events on to the event aggregator - _.extend(EA.prototype, Backbone.Events); - - return EA; -})(Backbone, _); - - - return Wreqr; -})(Backbone, Backbone.Marionette, _); - - return Backbone.Wreqr; - -})); \ No newline at end of file diff --git a/src/UI/JsLibraries/bootstrap.js b/src/UI/JsLibraries/bootstrap.js deleted file mode 100644 index 5debfd7de..000000000 --- a/src/UI/JsLibraries/bootstrap.js +++ /dev/null @@ -1,2363 +0,0 @@ -/*! - * Bootstrap v3.3.5 (http://getbootstrap.com) - * Copyright 2011-2015 Twitter, Inc. - * Licensed under the MIT license - */ - -if (typeof jQuery === 'undefined') { - throw new Error('Bootstrap\'s JavaScript requires jQuery') -} - -+function ($) { - 'use strict'; - var version = $.fn.jquery.split(' ')[0].split('.') - if ((version[0] < 2 && version[1] < 9) || (version[0] == 1 && version[1] == 9 && version[2] < 1)) { - throw new Error('Bootstrap\'s JavaScript requires jQuery version 1.9.1 or higher') - } -}(jQuery); - -/* ======================================================================== - * Bootstrap: transition.js v3.3.5 - * http://getbootstrap.com/javascript/#transitions - * ======================================================================== - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - * ======================================================================== */ - - -+function ($) { - 'use strict'; - - // CSS TRANSITION SUPPORT (Shoutout: http://www.modernizr.com/) - // ============================================================ - - function transitionEnd() { - var el = document.createElement('bootstrap') - - var transEndEventNames = { - WebkitTransition : 'webkitTransitionEnd', - MozTransition : 'transitionend', - OTransition : 'oTransitionEnd otransitionend', - transition : 'transitionend' - } - - for (var name in transEndEventNames) { - if (el.style[name] !== undefined) { - return { end: transEndEventNames[name] } - } - } - - return false // explicit for ie8 ( ._.) - } - - // http://blog.alexmaccaw.com/css-transitions - $.fn.emulateTransitionEnd = function (duration) { - var called = false - var $el = this - $(this).one('bsTransitionEnd', function () { called = true }) - var callback = function () { if (!called) $($el).trigger($.support.transition.end) } - setTimeout(callback, duration) - return this - } - - $(function () { - $.support.transition = transitionEnd() - - if (!$.support.transition) return - - $.event.special.bsTransitionEnd = { - bindType: $.support.transition.end, - delegateType: $.support.transition.end, - handle: function (e) { - if ($(e.target).is(this)) return e.handleObj.handler.apply(this, arguments) - } - } - }) - -}(jQuery); - -/* ======================================================================== - * Bootstrap: alert.js v3.3.5 - * http://getbootstrap.com/javascript/#alerts - * ======================================================================== - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - * ======================================================================== */ - - -+function ($) { - 'use strict'; - - // ALERT CLASS DEFINITION - // ====================== - - var dismiss = '[data-dismiss="alert"]' - var Alert = function (el) { - $(el).on('click', dismiss, this.close) - } - - Alert.VERSION = '3.3.5' - - Alert.TRANSITION_DURATION = 150 - - Alert.prototype.close = function (e) { - var $this = $(this) - var selector = $this.attr('data-target') - - if (!selector) { - selector = $this.attr('href') - selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7 - } - - var $parent = $(selector) - - if (e) e.preventDefault() - - if (!$parent.length) { - $parent = $this.closest('.alert') - } - - $parent.trigger(e = $.Event('close.bs.alert')) - - if (e.isDefaultPrevented()) return - - $parent.removeClass('in') - - function removeElement() { - // detach from parent, fire event then clean up data - $parent.detach().trigger('closed.bs.alert').remove() - } - - $.support.transition && $parent.hasClass('fade') ? - $parent - .one('bsTransitionEnd', removeElement) - .emulateTransitionEnd(Alert.TRANSITION_DURATION) : - removeElement() - } - - - // ALERT PLUGIN DEFINITION - // ======================= - - function Plugin(option) { - return this.each(function () { - var $this = $(this) - var data = $this.data('bs.alert') - - if (!data) $this.data('bs.alert', (data = new Alert(this))) - if (typeof option == 'string') data[option].call($this) - }) - } - - var old = $.fn.alert - - $.fn.alert = Plugin - $.fn.alert.Constructor = Alert - - - // ALERT NO CONFLICT - // ================= - - $.fn.alert.noConflict = function () { - $.fn.alert = old - return this - } - - - // ALERT DATA-API - // ============== - - $(document).on('click.bs.alert.data-api', dismiss, Alert.prototype.close) - -}(jQuery); - -/* ======================================================================== - * Bootstrap: button.js v3.3.5 - * http://getbootstrap.com/javascript/#buttons - * ======================================================================== - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - * ======================================================================== */ - - -+function ($) { - 'use strict'; - - // BUTTON PUBLIC CLASS DEFINITION - // ============================== - - var Button = function (element, options) { - this.$element = $(element) - this.options = $.extend({}, Button.DEFAULTS, options) - this.isLoading = false - } - - Button.VERSION = '3.3.5' - - Button.DEFAULTS = { - loadingText: 'loading...' - } - - Button.prototype.setState = function (state) { - var d = 'disabled' - var $el = this.$element - var val = $el.is('input') ? 'val' : 'html' - var data = $el.data() - - state += 'Text' - - if (data.resetText == null) $el.data('resetText', $el[val]()) - - // push to event loop to allow forms to submit - setTimeout($.proxy(function () { - $el[val](data[state] == null ? this.options[state] : data[state]) - - if (state == 'loadingText') { - this.isLoading = true - $el.addClass(d).attr(d, d) - } else if (this.isLoading) { - this.isLoading = false - $el.removeClass(d).removeAttr(d) - } - }, this), 0) - } - - Button.prototype.toggle = function () { - var changed = true - var $parent = this.$element.closest('[data-toggle="buttons"]') - - if ($parent.length) { - var $input = this.$element.find('input') - if ($input.prop('type') == 'radio') { - if ($input.prop('checked')) changed = false - $parent.find('.active').removeClass('active') - this.$element.addClass('active') - } else if ($input.prop('type') == 'checkbox') { - if (($input.prop('checked')) !== this.$element.hasClass('active')) changed = false - this.$element.toggleClass('active') - } - $input.prop('checked', this.$element.hasClass('active')) - if (changed) $input.trigger('change') - } else { - this.$element.attr('aria-pressed', !this.$element.hasClass('active')) - this.$element.toggleClass('active') - } - } - - - // BUTTON PLUGIN DEFINITION - // ======================== - - function Plugin(option) { - return this.each(function () { - var $this = $(this) - var data = $this.data('bs.button') - var options = typeof option == 'object' && option - - if (!data) $this.data('bs.button', (data = new Button(this, options))) - - if (option == 'toggle') data.toggle() - else if (option) data.setState(option) - }) - } - - var old = $.fn.button - - $.fn.button = Plugin - $.fn.button.Constructor = Button - - - // BUTTON NO CONFLICT - // ================== - - $.fn.button.noConflict = function () { - $.fn.button = old - return this - } - - - // BUTTON DATA-API - // =============== - - $(document) - .on('click.bs.button.data-api', '[data-toggle^="button"]', function (e) { - var $btn = $(e.target) - if (!$btn.hasClass('btn')) $btn = $btn.closest('.btn') - Plugin.call($btn, 'toggle') - if (!($(e.target).is('input[type="radio"]') || $(e.target).is('input[type="checkbox"]'))) e.preventDefault() - }) - .on('focus.bs.button.data-api blur.bs.button.data-api', '[data-toggle^="button"]', function (e) { - $(e.target).closest('.btn').toggleClass('focus', /^focus(in)?$/.test(e.type)) - }) - -}(jQuery); - -/* ======================================================================== - * Bootstrap: carousel.js v3.3.5 - * http://getbootstrap.com/javascript/#carousel - * ======================================================================== - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - * ======================================================================== */ - - -+function ($) { - 'use strict'; - - // CAROUSEL CLASS DEFINITION - // ========================= - - var Carousel = function (element, options) { - this.$element = $(element) - this.$indicators = this.$element.find('.carousel-indicators') - this.options = options - this.paused = null - this.sliding = null - this.interval = null - this.$active = null - this.$items = null - - this.options.keyboard && this.$element.on('keydown.bs.carousel', $.proxy(this.keydown, this)) - - this.options.pause == 'hover' && !('ontouchstart' in document.documentElement) && this.$element - .on('mouseenter.bs.carousel', $.proxy(this.pause, this)) - .on('mouseleave.bs.carousel', $.proxy(this.cycle, this)) - } - - Carousel.VERSION = '3.3.5' - - Carousel.TRANSITION_DURATION = 600 - - Carousel.DEFAULTS = { - interval: 5000, - pause: 'hover', - wrap: true, - keyboard: true - } - - Carousel.prototype.keydown = function (e) { - if (/input|textarea/i.test(e.target.tagName)) return - switch (e.which) { - case 37: this.prev(); break - case 39: this.next(); break - default: return - } - - e.preventDefault() - } - - Carousel.prototype.cycle = function (e) { - e || (this.paused = false) - - this.interval && clearInterval(this.interval) - - this.options.interval - && !this.paused - && (this.interval = setInterval($.proxy(this.next, this), this.options.interval)) - - return this - } - - Carousel.prototype.getItemIndex = function (item) { - this.$items = item.parent().children('.item') - return this.$items.index(item || this.$active) - } - - Carousel.prototype.getItemForDirection = function (direction, active) { - var activeIndex = this.getItemIndex(active) - var willWrap = (direction == 'prev' && activeIndex === 0) - || (direction == 'next' && activeIndex == (this.$items.length - 1)) - if (willWrap && !this.options.wrap) return active - var delta = direction == 'prev' ? -1 : 1 - var itemIndex = (activeIndex + delta) % this.$items.length - return this.$items.eq(itemIndex) - } - - Carousel.prototype.to = function (pos) { - var that = this - var activeIndex = this.getItemIndex(this.$active = this.$element.find('.item.active')) - - if (pos > (this.$items.length - 1) || pos < 0) return - - if (this.sliding) return this.$element.one('slid.bs.carousel', function () { that.to(pos) }) // yes, "slid" - if (activeIndex == pos) return this.pause().cycle() - - return this.slide(pos > activeIndex ? 'next' : 'prev', this.$items.eq(pos)) - } - - Carousel.prototype.pause = function (e) { - e || (this.paused = true) - - if (this.$element.find('.next, .prev').length && $.support.transition) { - this.$element.trigger($.support.transition.end) - this.cycle(true) - } - - this.interval = clearInterval(this.interval) - - return this - } - - Carousel.prototype.next = function () { - if (this.sliding) return - return this.slide('next') - } - - Carousel.prototype.prev = function () { - if (this.sliding) return - return this.slide('prev') - } - - Carousel.prototype.slide = function (type, next) { - var $active = this.$element.find('.item.active') - var $next = next || this.getItemForDirection(type, $active) - var isCycling = this.interval - var direction = type == 'next' ? 'left' : 'right' - var that = this - - if ($next.hasClass('active')) return (this.sliding = false) - - var relatedTarget = $next[0] - var slideEvent = $.Event('slide.bs.carousel', { - relatedTarget: relatedTarget, - direction: direction - }) - this.$element.trigger(slideEvent) - if (slideEvent.isDefaultPrevented()) return - - this.sliding = true - - isCycling && this.pause() - - if (this.$indicators.length) { - this.$indicators.find('.active').removeClass('active') - var $nextIndicator = $(this.$indicators.children()[this.getItemIndex($next)]) - $nextIndicator && $nextIndicator.addClass('active') - } - - var slidEvent = $.Event('slid.bs.carousel', { relatedTarget: relatedTarget, direction: direction }) // yes, "slid" - if ($.support.transition && this.$element.hasClass('slide')) { - $next.addClass(type) - $next[0].offsetWidth // force reflow - $active.addClass(direction) - $next.addClass(direction) - $active - .one('bsTransitionEnd', function () { - $next.removeClass([type, direction].join(' ')).addClass('active') - $active.removeClass(['active', direction].join(' ')) - that.sliding = false - setTimeout(function () { - that.$element.trigger(slidEvent) - }, 0) - }) - .emulateTransitionEnd(Carousel.TRANSITION_DURATION) - } else { - $active.removeClass('active') - $next.addClass('active') - this.sliding = false - this.$element.trigger(slidEvent) - } - - isCycling && this.cycle() - - return this - } - - - // CAROUSEL PLUGIN DEFINITION - // ========================== - - function Plugin(option) { - return this.each(function () { - var $this = $(this) - var data = $this.data('bs.carousel') - var options = $.extend({}, Carousel.DEFAULTS, $this.data(), typeof option == 'object' && option) - var action = typeof option == 'string' ? option : options.slide - - if (!data) $this.data('bs.carousel', (data = new Carousel(this, options))) - if (typeof option == 'number') data.to(option) - else if (action) data[action]() - else if (options.interval) data.pause().cycle() - }) - } - - var old = $.fn.carousel - - $.fn.carousel = Plugin - $.fn.carousel.Constructor = Carousel - - - // CAROUSEL NO CONFLICT - // ==================== - - $.fn.carousel.noConflict = function () { - $.fn.carousel = old - return this - } - - - // CAROUSEL DATA-API - // ================= - - var clickHandler = function (e) { - var href - var $this = $(this) - var $target = $($this.attr('data-target') || (href = $this.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '')) // strip for ie7 - if (!$target.hasClass('carousel')) return - var options = $.extend({}, $target.data(), $this.data()) - var slideIndex = $this.attr('data-slide-to') - if (slideIndex) options.interval = false - - Plugin.call($target, options) - - if (slideIndex) { - $target.data('bs.carousel').to(slideIndex) - } - - e.preventDefault() - } - - $(document) - .on('click.bs.carousel.data-api', '[data-slide]', clickHandler) - .on('click.bs.carousel.data-api', '[data-slide-to]', clickHandler) - - $(window).on('load', function () { - $('[data-ride="carousel"]').each(function () { - var $carousel = $(this) - Plugin.call($carousel, $carousel.data()) - }) - }) - -}(jQuery); - -/* ======================================================================== - * Bootstrap: collapse.js v3.3.5 - * http://getbootstrap.com/javascript/#collapse - * ======================================================================== - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - * ======================================================================== */ - - -+function ($) { - 'use strict'; - - // COLLAPSE PUBLIC CLASS DEFINITION - // ================================ - - var Collapse = function (element, options) { - this.$element = $(element) - this.options = $.extend({}, Collapse.DEFAULTS, options) - this.$trigger = $('[data-toggle="collapse"][href="#' + element.id + '"],' + - '[data-toggle="collapse"][data-target="#' + element.id + '"]') - this.transitioning = null - - if (this.options.parent) { - this.$parent = this.getParent() - } else { - this.addAriaAndCollapsedClass(this.$element, this.$trigger) - } - - if (this.options.toggle) this.toggle() - } - - Collapse.VERSION = '3.3.5' - - Collapse.TRANSITION_DURATION = 350 - - Collapse.DEFAULTS = { - toggle: true - } - - Collapse.prototype.dimension = function () { - var hasWidth = this.$element.hasClass('width') - return hasWidth ? 'width' : 'height' - } - - Collapse.prototype.show = function () { - if (this.transitioning || this.$element.hasClass('in')) return - - var activesData - var actives = this.$parent && this.$parent.children('.panel').children('.in, .collapsing') - - if (actives && actives.length) { - activesData = actives.data('bs.collapse') - if (activesData && activesData.transitioning) return - } - - var startEvent = $.Event('show.bs.collapse') - this.$element.trigger(startEvent) - if (startEvent.isDefaultPrevented()) return - - if (actives && actives.length) { - Plugin.call(actives, 'hide') - activesData || actives.data('bs.collapse', null) - } - - var dimension = this.dimension() - - this.$element - .removeClass('collapse') - .addClass('collapsing')[dimension](0) - .attr('aria-expanded', true) - - this.$trigger - .removeClass('collapsed') - .attr('aria-expanded', true) - - this.transitioning = 1 - - var complete = function () { - this.$element - .removeClass('collapsing') - .addClass('collapse in')[dimension]('') - this.transitioning = 0 - this.$element - .trigger('shown.bs.collapse') - } - - if (!$.support.transition) return complete.call(this) - - var scrollSize = $.camelCase(['scroll', dimension].join('-')) - - this.$element - .one('bsTransitionEnd', $.proxy(complete, this)) - .emulateTransitionEnd(Collapse.TRANSITION_DURATION)[dimension](this.$element[0][scrollSize]) - } - - Collapse.prototype.hide = function () { - if (this.transitioning || !this.$element.hasClass('in')) return - - var startEvent = $.Event('hide.bs.collapse') - this.$element.trigger(startEvent) - if (startEvent.isDefaultPrevented()) return - - var dimension = this.dimension() - - this.$element[dimension](this.$element[dimension]())[0].offsetHeight - - this.$element - .addClass('collapsing') - .removeClass('collapse in') - .attr('aria-expanded', false) - - this.$trigger - .addClass('collapsed') - .attr('aria-expanded', false) - - this.transitioning = 1 - - var complete = function () { - this.transitioning = 0 - this.$element - .removeClass('collapsing') - .addClass('collapse') - .trigger('hidden.bs.collapse') - } - - if (!$.support.transition) return complete.call(this) - - this.$element - [dimension](0) - .one('bsTransitionEnd', $.proxy(complete, this)) - .emulateTransitionEnd(Collapse.TRANSITION_DURATION) - } - - Collapse.prototype.toggle = function () { - this[this.$element.hasClass('in') ? 'hide' : 'show']() - } - - Collapse.prototype.getParent = function () { - return $(this.options.parent) - .find('[data-toggle="collapse"][data-parent="' + this.options.parent + '"]') - .each($.proxy(function (i, element) { - var $element = $(element) - this.addAriaAndCollapsedClass(getTargetFromTrigger($element), $element) - }, this)) - .end() - } - - Collapse.prototype.addAriaAndCollapsedClass = function ($element, $trigger) { - var isOpen = $element.hasClass('in') - - $element.attr('aria-expanded', isOpen) - $trigger - .toggleClass('collapsed', !isOpen) - .attr('aria-expanded', isOpen) - } - - function getTargetFromTrigger($trigger) { - var href - var target = $trigger.attr('data-target') - || (href = $trigger.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '') // strip for ie7 - - return $(target) - } - - - // COLLAPSE PLUGIN DEFINITION - // ========================== - - function Plugin(option) { - return this.each(function () { - var $this = $(this) - var data = $this.data('bs.collapse') - var options = $.extend({}, Collapse.DEFAULTS, $this.data(), typeof option == 'object' && option) - - if (!data && options.toggle && /show|hide/.test(option)) options.toggle = false - if (!data) $this.data('bs.collapse', (data = new Collapse(this, options))) - if (typeof option == 'string') data[option]() - }) - } - - var old = $.fn.collapse - - $.fn.collapse = Plugin - $.fn.collapse.Constructor = Collapse - - - // COLLAPSE NO CONFLICT - // ==================== - - $.fn.collapse.noConflict = function () { - $.fn.collapse = old - return this - } - - - // COLLAPSE DATA-API - // ================= - - $(document).on('click.bs.collapse.data-api', '[data-toggle="collapse"]', function (e) { - var $this = $(this) - - if (!$this.attr('data-target')) e.preventDefault() - - var $target = getTargetFromTrigger($this) - var data = $target.data('bs.collapse') - var option = data ? 'toggle' : $this.data() - - Plugin.call($target, option) - }) - -}(jQuery); - -/* ======================================================================== - * Bootstrap: dropdown.js v3.3.5 - * http://getbootstrap.com/javascript/#dropdowns - * ======================================================================== - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - * ======================================================================== */ - - -+function ($) { - 'use strict'; - - // DROPDOWN CLASS DEFINITION - // ========================= - - var backdrop = '.dropdown-backdrop' - var toggle = '[data-toggle="dropdown"]' - var Dropdown = function (element) { - $(element).on('click.bs.dropdown', this.toggle) - } - - Dropdown.VERSION = '3.3.5' - - function getParent($this) { - var selector = $this.attr('data-target') - - if (!selector) { - selector = $this.attr('href') - selector = selector && /#[A-Za-z]/.test(selector) && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7 - } - - var $parent = selector && $(selector) - - return $parent && $parent.length ? $parent : $this.parent() - } - - function clearMenus(e) { - if (e && e.which === 3) return - $(backdrop).remove() - $(toggle).each(function () { - var $this = $(this) - var $parent = getParent($this) - var relatedTarget = { relatedTarget: this } - - if (!$parent.hasClass('open')) return - - if (e && e.type == 'click' && /input|textarea/i.test(e.target.tagName) && $.contains($parent[0], e.target)) return - - $parent.trigger(e = $.Event('hide.bs.dropdown', relatedTarget)) - - if (e.isDefaultPrevented()) return - - $this.attr('aria-expanded', 'false') - $parent.removeClass('open').trigger('hidden.bs.dropdown', relatedTarget) - }) - } - - Dropdown.prototype.toggle = function (e) { - var $this = $(this) - - if ($this.is('.disabled, :disabled')) return - - var $parent = getParent($this) - var isActive = $parent.hasClass('open') - - clearMenus() - - if (!isActive) { - if ('ontouchstart' in document.documentElement && !$parent.closest('.navbar-nav').length) { - // if mobile we use a backdrop because click events don't delegate - $(document.createElement('div')) - .addClass('dropdown-backdrop') - .insertAfter($(this)) - .on('click', clearMenus) - } - - var relatedTarget = { relatedTarget: this } - $parent.trigger(e = $.Event('show.bs.dropdown', relatedTarget)) - - if (e.isDefaultPrevented()) return - - $this - .trigger('focus') - .attr('aria-expanded', 'true') - - $parent - .toggleClass('open') - .trigger('shown.bs.dropdown', relatedTarget) - } - - return false - } - - Dropdown.prototype.keydown = function (e) { - if (!/(38|40|27|32)/.test(e.which) || /input|textarea/i.test(e.target.tagName)) return - - var $this = $(this) - - e.preventDefault() - e.stopPropagation() - - if ($this.is('.disabled, :disabled')) return - - var $parent = getParent($this) - var isActive = $parent.hasClass('open') - - if (!isActive && e.which != 27 || isActive && e.which == 27) { - if (e.which == 27) $parent.find(toggle).trigger('focus') - return $this.trigger('click') - } - - var desc = ' li:not(.disabled):visible a' - var $items = $parent.find('.dropdown-menu' + desc) - - if (!$items.length) return - - var index = $items.index(e.target) - - if (e.which == 38 && index > 0) index-- // up - if (e.which == 40 && index < $items.length - 1) index++ // down - if (!~index) index = 0 - - $items.eq(index).trigger('focus') - } - - - // DROPDOWN PLUGIN DEFINITION - // ========================== - - function Plugin(option) { - return this.each(function () { - var $this = $(this) - var data = $this.data('bs.dropdown') - - if (!data) $this.data('bs.dropdown', (data = new Dropdown(this))) - if (typeof option == 'string') data[option].call($this) - }) - } - - var old = $.fn.dropdown - - $.fn.dropdown = Plugin - $.fn.dropdown.Constructor = Dropdown - - - // DROPDOWN NO CONFLICT - // ==================== - - $.fn.dropdown.noConflict = function () { - $.fn.dropdown = old - return this - } - - - // APPLY TO STANDARD DROPDOWN ELEMENTS - // =================================== - - $(document) - .on('click.bs.dropdown.data-api', clearMenus) - .on('click.bs.dropdown.data-api', '.dropdown form', function (e) { e.stopPropagation() }) - .on('click.bs.dropdown.data-api', toggle, Dropdown.prototype.toggle) - .on('keydown.bs.dropdown.data-api', toggle, Dropdown.prototype.keydown) - .on('keydown.bs.dropdown.data-api', '.dropdown-menu', Dropdown.prototype.keydown) - -}(jQuery); - -/* ======================================================================== - * Bootstrap: modal.js v3.3.5 - * http://getbootstrap.com/javascript/#modals - * ======================================================================== - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - * ======================================================================== */ - - -+function ($) { - 'use strict'; - - // MODAL CLASS DEFINITION - // ====================== - - var Modal = function (element, options) { - this.options = options - this.$body = $(document.body) - this.$element = $(element) - this.$dialog = this.$element.find('.modal-dialog') - this.$backdrop = null - this.isShown = null - this.originalBodyPad = null - this.scrollbarWidth = 0 - this.ignoreBackdropClick = false - - if (this.options.remote) { - this.$element - .find('.modal-content') - .load(this.options.remote, $.proxy(function () { - this.$element.trigger('loaded.bs.modal') - }, this)) - } - } - - Modal.VERSION = '3.3.5' - - Modal.TRANSITION_DURATION = 300 - Modal.BACKDROP_TRANSITION_DURATION = 150 - - Modal.DEFAULTS = { - backdrop: true, - keyboard: true, - show: true - } - - Modal.prototype.toggle = function (_relatedTarget) { - return this.isShown ? this.hide() : this.show(_relatedTarget) - } - - Modal.prototype.show = function (_relatedTarget) { - var that = this - var e = $.Event('show.bs.modal', { relatedTarget: _relatedTarget }) - - this.$element.trigger(e) - - if (this.isShown || e.isDefaultPrevented()) return - - this.isShown = true - - this.checkScrollbar() - this.setScrollbar() - this.$body.addClass('modal-open') - - this.escape() - this.resize() - - this.$element.on('click.dismiss.bs.modal', '[data-dismiss="modal"]', $.proxy(this.hide, this)) - - this.$dialog.on('mousedown.dismiss.bs.modal', function () { - that.$element.one('mouseup.dismiss.bs.modal', function (e) { - if ($(e.target).is(that.$element)) that.ignoreBackdropClick = true - }) - }) - - this.backdrop(function () { - var transition = $.support.transition && that.$element.hasClass('fade') - - if (!that.$element.parent().length) { - that.$element.appendTo(that.$body) // don't move modals dom position - } - - that.$element - .show() - .scrollTop(0) - - that.adjustDialog() - - if (transition) { - that.$element[0].offsetWidth // force reflow - } - - that.$element.addClass('in') - - that.enforceFocus() - - var e = $.Event('shown.bs.modal', { relatedTarget: _relatedTarget }) - - transition ? - that.$dialog // wait for modal to slide in - .one('bsTransitionEnd', function () { - that.$element.trigger('focus').trigger(e) - }) - .emulateTransitionEnd(Modal.TRANSITION_DURATION) : - that.$element.trigger('focus').trigger(e) - }) - } - - Modal.prototype.hide = function (e) { - if (e) e.preventDefault() - - e = $.Event('hide.bs.modal') - - this.$element.trigger(e) - - if (!this.isShown || e.isDefaultPrevented()) return - - this.isShown = false - - this.escape() - this.resize() - - $(document).off('focusin.bs.modal') - - this.$element - .removeClass('in') - .off('click.dismiss.bs.modal') - .off('mouseup.dismiss.bs.modal') - - this.$dialog.off('mousedown.dismiss.bs.modal') - - $.support.transition && this.$element.hasClass('fade') ? - this.$element - .one('bsTransitionEnd', $.proxy(this.hideModal, this)) - .emulateTransitionEnd(Modal.TRANSITION_DURATION) : - this.hideModal() - } - - Modal.prototype.enforceFocus = function () { - $(document) - .off('focusin.bs.modal') // guard against infinite focus loop - .on('focusin.bs.modal', $.proxy(function (e) { - if (this.$element[0] !== e.target && !this.$element.has(e.target).length) { - this.$element.trigger('focus') - } - }, this)) - } - - Modal.prototype.escape = function () { - if (this.isShown && this.options.keyboard) { - this.$element.on('keydown.dismiss.bs.modal', $.proxy(function (e) { - e.which == 27 && this.hide() - }, this)) - } else if (!this.isShown) { - this.$element.off('keydown.dismiss.bs.modal') - } - } - - Modal.prototype.resize = function () { - if (this.isShown) { - $(window).on('resize.bs.modal', $.proxy(this.handleUpdate, this)) - } else { - $(window).off('resize.bs.modal') - } - } - - Modal.prototype.hideModal = function () { - var that = this - this.$element.hide() - this.backdrop(function () { - that.$body.removeClass('modal-open') - that.resetAdjustments() - that.resetScrollbar() - that.$element.trigger('hidden.bs.modal') - }) - } - - Modal.prototype.removeBackdrop = function () { - this.$backdrop && this.$backdrop.remove() - this.$backdrop = null - } - - Modal.prototype.backdrop = function (callback) { - var that = this - var animate = this.$element.hasClass('fade') ? 'fade' : '' - - if (this.isShown && this.options.backdrop) { - var doAnimate = $.support.transition && animate - - this.$backdrop = $(document.createElement('div')) - .addClass('modal-backdrop ' + animate) - .appendTo(this.$body) - - this.$element.on('click.dismiss.bs.modal', $.proxy(function (e) { - if (this.ignoreBackdropClick) { - this.ignoreBackdropClick = false - return - } - if (e.target !== e.currentTarget) return - this.options.backdrop == 'static' - ? this.$element[0].focus() - : this.hide() - }, this)) - - if (doAnimate) this.$backdrop[0].offsetWidth // force reflow - - this.$backdrop.addClass('in') - - if (!callback) return - - doAnimate ? - this.$backdrop - .one('bsTransitionEnd', callback) - .emulateTransitionEnd(Modal.BACKDROP_TRANSITION_DURATION) : - callback() - - } else if (!this.isShown && this.$backdrop) { - this.$backdrop.removeClass('in') - - var callbackRemove = function () { - that.removeBackdrop() - callback && callback() - } - $.support.transition && this.$element.hasClass('fade') ? - this.$backdrop - .one('bsTransitionEnd', callbackRemove) - .emulateTransitionEnd(Modal.BACKDROP_TRANSITION_DURATION) : - callbackRemove() - - } else if (callback) { - callback() - } - } - - // these following methods are used to handle overflowing modals - - Modal.prototype.handleUpdate = function () { - this.adjustDialog() - } - - Modal.prototype.adjustDialog = function () { - var modalIsOverflowing = this.$element[0].scrollHeight > document.documentElement.clientHeight - - this.$element.css({ - paddingLeft: !this.bodyIsOverflowing && modalIsOverflowing ? this.scrollbarWidth : '', - paddingRight: this.bodyIsOverflowing && !modalIsOverflowing ? this.scrollbarWidth : '' - }) - } - - Modal.prototype.resetAdjustments = function () { - this.$element.css({ - paddingLeft: '', - paddingRight: '' - }) - } - - Modal.prototype.checkScrollbar = function () { - var fullWindowWidth = window.innerWidth - if (!fullWindowWidth) { // workaround for missing window.innerWidth in IE8 - var documentElementRect = document.documentElement.getBoundingClientRect() - fullWindowWidth = documentElementRect.right - Math.abs(documentElementRect.left) - } - this.bodyIsOverflowing = document.body.clientWidth < fullWindowWidth - this.scrollbarWidth = this.measureScrollbar() - } - - Modal.prototype.setScrollbar = function () { - var bodyPad = parseInt((this.$body.css('padding-right') || 0), 10) - this.originalBodyPad = document.body.style.paddingRight || '' - if (this.bodyIsOverflowing) this.$body.css('padding-right', bodyPad + this.scrollbarWidth) - } - - Modal.prototype.resetScrollbar = function () { - this.$body.css('padding-right', this.originalBodyPad) - } - - Modal.prototype.measureScrollbar = function () { // thx walsh - var scrollDiv = document.createElement('div') - scrollDiv.className = 'modal-scrollbar-measure' - this.$body.append(scrollDiv) - var scrollbarWidth = scrollDiv.offsetWidth - scrollDiv.clientWidth - this.$body[0].removeChild(scrollDiv) - return scrollbarWidth - } - - - // MODAL PLUGIN DEFINITION - // ======================= - - function Plugin(option, _relatedTarget) { - return this.each(function () { - var $this = $(this) - var data = $this.data('bs.modal') - var options = $.extend({}, Modal.DEFAULTS, $this.data(), typeof option == 'object' && option) - - if (!data) $this.data('bs.modal', (data = new Modal(this, options))) - if (typeof option == 'string') data[option](_relatedTarget) - else if (options.show) data.show(_relatedTarget) - }) - } - - var old = $.fn.modal - - $.fn.modal = Plugin - $.fn.modal.Constructor = Modal - - - // MODAL NO CONFLICT - // ================= - - $.fn.modal.noConflict = function () { - $.fn.modal = old - return this - } - - - // MODAL DATA-API - // ============== - - $(document).on('click.bs.modal.data-api', '[data-toggle="modal"]', function (e) { - var $this = $(this) - var href = $this.attr('href') - var $target = $($this.attr('data-target') || (href && href.replace(/.*(?=#[^\s]+$)/, ''))) // strip for ie7 - var option = $target.data('bs.modal') ? 'toggle' : $.extend({ remote: !/#/.test(href) && href }, $target.data(), $this.data()) - - if ($this.is('a')) e.preventDefault() - - $target.one('show.bs.modal', function (showEvent) { - if (showEvent.isDefaultPrevented()) return // only register focus restorer if modal will actually get shown - $target.one('hidden.bs.modal', function () { - $this.is(':visible') && $this.trigger('focus') - }) - }) - Plugin.call($target, option, this) - }) - -}(jQuery); - -/* ======================================================================== - * Bootstrap: tooltip.js v3.3.5 - * http://getbootstrap.com/javascript/#tooltip - * Inspired by the original jQuery.tipsy by Jason Frame - * ======================================================================== - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - * ======================================================================== */ - - -+function ($) { - 'use strict'; - - // TOOLTIP PUBLIC CLASS DEFINITION - // =============================== - - var Tooltip = function (element, options) { - this.type = null - this.options = null - this.enabled = null - this.timeout = null - this.hoverState = null - this.$element = null - this.inState = null - - this.init('tooltip', element, options) - } - - Tooltip.VERSION = '3.3.5' - - Tooltip.TRANSITION_DURATION = 150 - - Tooltip.DEFAULTS = { - animation: true, - placement: 'top', - selector: false, - template: '<div class="tooltip" role="tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>', - trigger: 'hover focus', - title: '', - delay: 0, - html: false, - container: false, - viewport: { - selector: 'body', - padding: 0 - } - } - - Tooltip.prototype.init = function (type, element, options) { - this.enabled = true - this.type = type - this.$element = $(element) - this.options = this.getOptions(options) - this.$viewport = this.options.viewport && $($.isFunction(this.options.viewport) ? this.options.viewport.call(this, this.$element) : (this.options.viewport.selector || this.options.viewport)) - this.inState = { click: false, hover: false, focus: false } - - if (this.$element[0] instanceof document.constructor && !this.options.selector) { - throw new Error('`selector` option must be specified when initializing ' + this.type + ' on the window.document object!') - } - - var triggers = this.options.trigger.split(' ') - - for (var i = triggers.length; i--;) { - var trigger = triggers[i] - - if (trigger == 'click') { - this.$element.on('click.' + this.type, this.options.selector, $.proxy(this.toggle, this)) - } else if (trigger != 'manual') { - var eventIn = trigger == 'hover' ? 'mouseenter' : 'focusin' - var eventOut = trigger == 'hover' ? 'mouseleave' : 'focusout' - - this.$element.on(eventIn + '.' + this.type, this.options.selector, $.proxy(this.enter, this)) - this.$element.on(eventOut + '.' + this.type, this.options.selector, $.proxy(this.leave, this)) - } - } - - this.options.selector ? - (this._options = $.extend({}, this.options, { trigger: 'manual', selector: '' })) : - this.fixTitle() - } - - Tooltip.prototype.getDefaults = function () { - return Tooltip.DEFAULTS - } - - Tooltip.prototype.getOptions = function (options) { - options = $.extend({}, this.getDefaults(), this.$element.data(), options) - - if (options.delay && typeof options.delay == 'number') { - options.delay = { - show: options.delay, - hide: options.delay - } - } - - return options - } - - Tooltip.prototype.getDelegateOptions = function () { - var options = {} - var defaults = this.getDefaults() - - this._options && $.each(this._options, function (key, value) { - if (defaults[key] != value) options[key] = value - }) - - return options - } - - Tooltip.prototype.enter = function (obj) { - var self = obj instanceof this.constructor ? - obj : $(obj.currentTarget).data('bs.' + this.type) - - if (!self) { - self = new this.constructor(obj.currentTarget, this.getDelegateOptions()) - $(obj.currentTarget).data('bs.' + this.type, self) - } - - if (obj instanceof $.Event) { - self.inState[obj.type == 'focusin' ? 'focus' : 'hover'] = true - } - - if (self.tip().hasClass('in') || self.hoverState == 'in') { - self.hoverState = 'in' - return - } - - clearTimeout(self.timeout) - - self.hoverState = 'in' - - if (!self.options.delay || !self.options.delay.show) return self.show() - - self.timeout = setTimeout(function () { - if (self.hoverState == 'in') self.show() - }, self.options.delay.show) - } - - Tooltip.prototype.isInStateTrue = function () { - for (var key in this.inState) { - if (this.inState[key]) return true - } - - return false - } - - Tooltip.prototype.leave = function (obj) { - var self = obj instanceof this.constructor ? - obj : $(obj.currentTarget).data('bs.' + this.type) - - if (!self) { - self = new this.constructor(obj.currentTarget, this.getDelegateOptions()) - $(obj.currentTarget).data('bs.' + this.type, self) - } - - if (obj instanceof $.Event) { - self.inState[obj.type == 'focusout' ? 'focus' : 'hover'] = false - } - - if (self.isInStateTrue()) return - - clearTimeout(self.timeout) - - self.hoverState = 'out' - - if (!self.options.delay || !self.options.delay.hide) return self.hide() - - self.timeout = setTimeout(function () { - if (self.hoverState == 'out') self.hide() - }, self.options.delay.hide) - } - - Tooltip.prototype.show = function () { - var e = $.Event('show.bs.' + this.type) - - if (this.hasContent() && this.enabled) { - this.$element.trigger(e) - - var inDom = $.contains(this.$element[0].ownerDocument.documentElement, this.$element[0]) - if (e.isDefaultPrevented() || !inDom) return - var that = this - - var $tip = this.tip() - - var tipId = this.getUID(this.type) - - this.setContent() - $tip.attr('id', tipId) - this.$element.attr('aria-describedby', tipId) - - if (this.options.animation) $tip.addClass('fade') - - var placement = typeof this.options.placement == 'function' ? - this.options.placement.call(this, $tip[0], this.$element[0]) : - this.options.placement - - var autoToken = /\s?auto?\s?/i - var autoPlace = autoToken.test(placement) - if (autoPlace) placement = placement.replace(autoToken, '') || 'top' - - $tip - .detach() - .css({ top: 0, left: 0, display: 'block' }) - .addClass(placement) - .data('bs.' + this.type, this) - - this.options.container ? $tip.appendTo(this.options.container) : $tip.insertAfter(this.$element) - this.$element.trigger('inserted.bs.' + this.type) - - var pos = this.getPosition() - var actualWidth = $tip[0].offsetWidth - var actualHeight = $tip[0].offsetHeight - - if (autoPlace) { - var orgPlacement = placement - var viewportDim = this.getPosition(this.$viewport) - - placement = placement == 'bottom' && pos.bottom + actualHeight > viewportDim.bottom ? 'top' : - placement == 'top' && pos.top - actualHeight < viewportDim.top ? 'bottom' : - placement == 'right' && pos.right + actualWidth > viewportDim.width ? 'left' : - placement == 'left' && pos.left - actualWidth < viewportDim.left ? 'right' : - placement - - $tip - .removeClass(orgPlacement) - .addClass(placement) - } - - var calculatedOffset = this.getCalculatedOffset(placement, pos, actualWidth, actualHeight) - - this.applyPlacement(calculatedOffset, placement) - - var complete = function () { - var prevHoverState = that.hoverState - that.$element.trigger('shown.bs.' + that.type) - that.hoverState = null - - if (prevHoverState == 'out') that.leave(that) - } - - $.support.transition && this.$tip.hasClass('fade') ? - $tip - .one('bsTransitionEnd', complete) - .emulateTransitionEnd(Tooltip.TRANSITION_DURATION) : - complete() - } - } - - Tooltip.prototype.applyPlacement = function (offset, placement) { - var $tip = this.tip() - var width = $tip[0].offsetWidth - var height = $tip[0].offsetHeight - - // manually read margins because getBoundingClientRect includes difference - var marginTop = parseInt($tip.css('margin-top'), 10) - var marginLeft = parseInt($tip.css('margin-left'), 10) - - // we must check for NaN for ie 8/9 - if (isNaN(marginTop)) marginTop = 0 - if (isNaN(marginLeft)) marginLeft = 0 - - offset.top += marginTop - offset.left += marginLeft - - // $.fn.offset doesn't round pixel values - // so we use setOffset directly with our own function B-0 - $.offset.setOffset($tip[0], $.extend({ - using: function (props) { - $tip.css({ - top: Math.round(props.top), - left: Math.round(props.left) - }) - } - }, offset), 0) - - $tip.addClass('in') - - // check to see if placing tip in new offset caused the tip to resize itself - var actualWidth = $tip[0].offsetWidth - var actualHeight = $tip[0].offsetHeight - - if (placement == 'top' && actualHeight != height) { - offset.top = offset.top + height - actualHeight - } - - var delta = this.getViewportAdjustedDelta(placement, offset, actualWidth, actualHeight) - - if (delta.left) offset.left += delta.left - else offset.top += delta.top - - var isVertical = /top|bottom/.test(placement) - var arrowDelta = isVertical ? delta.left * 2 - width + actualWidth : delta.top * 2 - height + actualHeight - var arrowOffsetPosition = isVertical ? 'offsetWidth' : 'offsetHeight' - - $tip.offset(offset) - this.replaceArrow(arrowDelta, $tip[0][arrowOffsetPosition], isVertical) - } - - Tooltip.prototype.replaceArrow = function (delta, dimension, isVertical) { - this.arrow() - .css(isVertical ? 'left' : 'top', 50 * (1 - delta / dimension) + '%') - .css(isVertical ? 'top' : 'left', '') - } - - Tooltip.prototype.setContent = function () { - var $tip = this.tip() - var title = this.getTitle() - - $tip.find('.tooltip-inner')[this.options.html ? 'html' : 'text'](title) - $tip.removeClass('fade in top bottom left right') - } - - Tooltip.prototype.hide = function (callback) { - var that = this - var $tip = $(this.$tip) - var e = $.Event('hide.bs.' + this.type) - - function complete() { - if (that.hoverState != 'in') $tip.detach() - that.$element - .removeAttr('aria-describedby') - .trigger('hidden.bs.' + that.type) - callback && callback() - } - - this.$element.trigger(e) - - if (e.isDefaultPrevented()) return - - $tip.removeClass('in') - - $.support.transition && $tip.hasClass('fade') ? - $tip - .one('bsTransitionEnd', complete) - .emulateTransitionEnd(Tooltip.TRANSITION_DURATION) : - complete() - - this.hoverState = null - - return this - } - - Tooltip.prototype.fixTitle = function () { - var $e = this.$element - if ($e.attr('title') || typeof $e.attr('data-original-title') != 'string') { - $e.attr('data-original-title', $e.attr('title') || '').attr('title', '') - } - } - - Tooltip.prototype.hasContent = function () { - return this.getTitle() - } - - Tooltip.prototype.getPosition = function ($element) { - $element = $element || this.$element - - var el = $element[0] - var isBody = el.tagName == 'BODY' - - var elRect = el.getBoundingClientRect() - if (elRect.width == null) { - // width and height are missing in IE8, so compute them manually; see https://github.com/twbs/bootstrap/issues/14093 - elRect = $.extend({}, elRect, { width: elRect.right - elRect.left, height: elRect.bottom - elRect.top }) - } - var elOffset = isBody ? { top: 0, left: 0 } : $element.offset() - var scroll = { scroll: isBody ? document.documentElement.scrollTop || document.body.scrollTop : $element.scrollTop() } - var outerDims = isBody ? { width: $(window).width(), height: $(window).height() } : null - - return $.extend({}, elRect, scroll, outerDims, elOffset) - } - - Tooltip.prototype.getCalculatedOffset = function (placement, pos, actualWidth, actualHeight) { - return placement == 'bottom' ? { top: pos.top + pos.height, left: pos.left + pos.width / 2 - actualWidth / 2 } : - placement == 'top' ? { top: pos.top - actualHeight, left: pos.left + pos.width / 2 - actualWidth / 2 } : - placement == 'left' ? { top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left - actualWidth } : - /* placement == 'right' */ { top: pos.top + pos.height / 2 - actualHeight / 2, left: pos.left + pos.width } - - } - - Tooltip.prototype.getViewportAdjustedDelta = function (placement, pos, actualWidth, actualHeight) { - var delta = { top: 0, left: 0 } - if (!this.$viewport) return delta - - var viewportPadding = this.options.viewport && this.options.viewport.padding || 0 - var viewportDimensions = this.getPosition(this.$viewport) - - if (/right|left/.test(placement)) { - var topEdgeOffset = pos.top - viewportPadding - viewportDimensions.scroll - var bottomEdgeOffset = pos.top + viewportPadding - viewportDimensions.scroll + actualHeight - if (topEdgeOffset < viewportDimensions.top) { // top overflow - delta.top = viewportDimensions.top - topEdgeOffset - } else if (bottomEdgeOffset > viewportDimensions.top + viewportDimensions.height) { // bottom overflow - delta.top = viewportDimensions.top + viewportDimensions.height - bottomEdgeOffset - } - } else { - var leftEdgeOffset = pos.left - viewportPadding - var rightEdgeOffset = pos.left + viewportPadding + actualWidth - if (leftEdgeOffset < viewportDimensions.left) { // left overflow - delta.left = viewportDimensions.left - leftEdgeOffset - } else if (rightEdgeOffset > viewportDimensions.right) { // right overflow - delta.left = viewportDimensions.left + viewportDimensions.width - rightEdgeOffset - } - } - - return delta - } - - Tooltip.prototype.getTitle = function () { - var title - var $e = this.$element - var o = this.options - - title = $e.attr('data-original-title') - || (typeof o.title == 'function' ? o.title.call($e[0]) : o.title) - - return title - } - - Tooltip.prototype.getUID = function (prefix) { - do prefix += ~~(Math.random() * 1000000) - while (document.getElementById(prefix)) - return prefix - } - - Tooltip.prototype.tip = function () { - if (!this.$tip) { - this.$tip = $(this.options.template) - if (this.$tip.length != 1) { - throw new Error(this.type + ' `template` option must consist of exactly 1 top-level element!') - } - } - return this.$tip - } - - Tooltip.prototype.arrow = function () { - return (this.$arrow = this.$arrow || this.tip().find('.tooltip-arrow')) - } - - Tooltip.prototype.enable = function () { - this.enabled = true - } - - Tooltip.prototype.disable = function () { - this.enabled = false - } - - Tooltip.prototype.toggleEnabled = function () { - this.enabled = !this.enabled - } - - Tooltip.prototype.toggle = function (e) { - var self = this - if (e) { - self = $(e.currentTarget).data('bs.' + this.type) - if (!self) { - self = new this.constructor(e.currentTarget, this.getDelegateOptions()) - $(e.currentTarget).data('bs.' + this.type, self) - } - } - - if (e) { - self.inState.click = !self.inState.click - if (self.isInStateTrue()) self.enter(self) - else self.leave(self) - } else { - self.tip().hasClass('in') ? self.leave(self) : self.enter(self) - } - } - - Tooltip.prototype.destroy = function () { - var that = this - clearTimeout(this.timeout) - this.hide(function () { - that.$element.off('.' + that.type).removeData('bs.' + that.type) - if (that.$tip) { - that.$tip.detach() - } - that.$tip = null - that.$arrow = null - that.$viewport = null - }) - } - - - // TOOLTIP PLUGIN DEFINITION - // ========================= - - function Plugin(option) { - return this.each(function () { - var $this = $(this) - var data = $this.data('bs.tooltip') - var options = typeof option == 'object' && option - - if (!data && /destroy|hide/.test(option)) return - if (!data) $this.data('bs.tooltip', (data = new Tooltip(this, options))) - if (typeof option == 'string') data[option]() - }) - } - - var old = $.fn.tooltip - - $.fn.tooltip = Plugin - $.fn.tooltip.Constructor = Tooltip - - - // TOOLTIP NO CONFLICT - // =================== - - $.fn.tooltip.noConflict = function () { - $.fn.tooltip = old - return this - } - -}(jQuery); - -/* ======================================================================== - * Bootstrap: popover.js v3.3.5 - * http://getbootstrap.com/javascript/#popovers - * ======================================================================== - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - * ======================================================================== */ - - -+function ($) { - 'use strict'; - - // POPOVER PUBLIC CLASS DEFINITION - // =============================== - - var Popover = function (element, options) { - this.init('popover', element, options) - } - - if (!$.fn.tooltip) throw new Error('Popover requires tooltip.js') - - Popover.VERSION = '3.3.5' - - Popover.DEFAULTS = $.extend({}, $.fn.tooltip.Constructor.DEFAULTS, { - placement: 'right', - trigger: 'click', - content: '', - template: '<div class="popover" role="tooltip"><div class="arrow"></div><h3 class="popover-title"></h3><div class="popover-content"></div></div>' - }) - - - // NOTE: POPOVER EXTENDS tooltip.js - // ================================ - - Popover.prototype = $.extend({}, $.fn.tooltip.Constructor.prototype) - - Popover.prototype.constructor = Popover - - Popover.prototype.getDefaults = function () { - return Popover.DEFAULTS - } - - Popover.prototype.setContent = function () { - var $tip = this.tip() - var title = this.getTitle() - var content = this.getContent() - - $tip.find('.popover-title')[this.options.html ? 'html' : 'text'](title) - $tip.find('.popover-content').children().detach().end()[ // we use append for html objects to maintain js events - this.options.html ? (typeof content == 'string' ? 'html' : 'append') : 'text' - ](content) - - $tip.removeClass('fade top bottom left right in') - - // IE8 doesn't accept hiding via the `:empty` pseudo selector, we have to do - // this manually by checking the contents. - if (!$tip.find('.popover-title').html()) $tip.find('.popover-title').hide() - } - - Popover.prototype.hasContent = function () { - return this.getTitle() || this.getContent() - } - - Popover.prototype.getContent = function () { - var $e = this.$element - var o = this.options - - return $e.attr('data-content') - || (typeof o.content == 'function' ? - o.content.call($e[0]) : - o.content) - } - - Popover.prototype.arrow = function () { - return (this.$arrow = this.$arrow || this.tip().find('.arrow')) - } - - - // POPOVER PLUGIN DEFINITION - // ========================= - - function Plugin(option) { - return this.each(function () { - var $this = $(this) - var data = $this.data('bs.popover') - var options = typeof option == 'object' && option - - if (!data && /destroy|hide/.test(option)) return - if (!data) $this.data('bs.popover', (data = new Popover(this, options))) - if (typeof option == 'string') data[option]() - }) - } - - var old = $.fn.popover - - $.fn.popover = Plugin - $.fn.popover.Constructor = Popover - - - // POPOVER NO CONFLICT - // =================== - - $.fn.popover.noConflict = function () { - $.fn.popover = old - return this - } - -}(jQuery); - -/* ======================================================================== - * Bootstrap: scrollspy.js v3.3.5 - * http://getbootstrap.com/javascript/#scrollspy - * ======================================================================== - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - * ======================================================================== */ - - -+function ($) { - 'use strict'; - - // SCROLLSPY CLASS DEFINITION - // ========================== - - function ScrollSpy(element, options) { - this.$body = $(document.body) - this.$scrollElement = $(element).is(document.body) ? $(window) : $(element) - this.options = $.extend({}, ScrollSpy.DEFAULTS, options) - this.selector = (this.options.target || '') + ' .nav li > a' - this.offsets = [] - this.targets = [] - this.activeTarget = null - this.scrollHeight = 0 - - this.$scrollElement.on('scroll.bs.scrollspy', $.proxy(this.process, this)) - this.refresh() - this.process() - } - - ScrollSpy.VERSION = '3.3.5' - - ScrollSpy.DEFAULTS = { - offset: 10 - } - - ScrollSpy.prototype.getScrollHeight = function () { - return this.$scrollElement[0].scrollHeight || Math.max(this.$body[0].scrollHeight, document.documentElement.scrollHeight) - } - - ScrollSpy.prototype.refresh = function () { - var that = this - var offsetMethod = 'offset' - var offsetBase = 0 - - this.offsets = [] - this.targets = [] - this.scrollHeight = this.getScrollHeight() - - if (!$.isWindow(this.$scrollElement[0])) { - offsetMethod = 'position' - offsetBase = this.$scrollElement.scrollTop() - } - - this.$body - .find(this.selector) - .map(function () { - var $el = $(this) - var href = $el.data('target') || $el.attr('href') - var $href = /^#./.test(href) && $(href) - - return ($href - && $href.length - && $href.is(':visible') - && [[$href[offsetMethod]().top + offsetBase, href]]) || null - }) - .sort(function (a, b) { return a[0] - b[0] }) - .each(function () { - that.offsets.push(this[0]) - that.targets.push(this[1]) - }) - } - - ScrollSpy.prototype.process = function () { - var scrollTop = this.$scrollElement.scrollTop() + this.options.offset - var scrollHeight = this.getScrollHeight() - var maxScroll = this.options.offset + scrollHeight - this.$scrollElement.height() - var offsets = this.offsets - var targets = this.targets - var activeTarget = this.activeTarget - var i - - if (this.scrollHeight != scrollHeight) { - this.refresh() - } - - if (scrollTop >= maxScroll) { - return activeTarget != (i = targets[targets.length - 1]) && this.activate(i) - } - - if (activeTarget && scrollTop < offsets[0]) { - this.activeTarget = null - return this.clear() - } - - for (i = offsets.length; i--;) { - activeTarget != targets[i] - && scrollTop >= offsets[i] - && (offsets[i + 1] === undefined || scrollTop < offsets[i + 1]) - && this.activate(targets[i]) - } - } - - ScrollSpy.prototype.activate = function (target) { - this.activeTarget = target - - this.clear() - - var selector = this.selector + - '[data-target="' + target + '"],' + - this.selector + '[href="' + target + '"]' - - var active = $(selector) - .parents('li') - .addClass('active') - - if (active.parent('.dropdown-menu').length) { - active = active - .closest('li.dropdown') - .addClass('active') - } - - active.trigger('activate.bs.scrollspy') - } - - ScrollSpy.prototype.clear = function () { - $(this.selector) - .parentsUntil(this.options.target, '.active') - .removeClass('active') - } - - - // SCROLLSPY PLUGIN DEFINITION - // =========================== - - function Plugin(option) { - return this.each(function () { - var $this = $(this) - var data = $this.data('bs.scrollspy') - var options = typeof option == 'object' && option - - if (!data) $this.data('bs.scrollspy', (data = new ScrollSpy(this, options))) - if (typeof option == 'string') data[option]() - }) - } - - var old = $.fn.scrollspy - - $.fn.scrollspy = Plugin - $.fn.scrollspy.Constructor = ScrollSpy - - - // SCROLLSPY NO CONFLICT - // ===================== - - $.fn.scrollspy.noConflict = function () { - $.fn.scrollspy = old - return this - } - - - // SCROLLSPY DATA-API - // ================== - - $(window).on('load.bs.scrollspy.data-api', function () { - $('[data-spy="scroll"]').each(function () { - var $spy = $(this) - Plugin.call($spy, $spy.data()) - }) - }) - -}(jQuery); - -/* ======================================================================== - * Bootstrap: tab.js v3.3.5 - * http://getbootstrap.com/javascript/#tabs - * ======================================================================== - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - * ======================================================================== */ - - -+function ($) { - 'use strict'; - - // TAB CLASS DEFINITION - // ==================== - - var Tab = function (element) { - // jscs:disable requireDollarBeforejQueryAssignment - this.element = $(element) - // jscs:enable requireDollarBeforejQueryAssignment - } - - Tab.VERSION = '3.3.5' - - Tab.TRANSITION_DURATION = 150 - - Tab.prototype.show = function () { - var $this = this.element - var $ul = $this.closest('ul:not(.dropdown-menu)') - var selector = $this.data('target') - - if (!selector) { - selector = $this.attr('href') - selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7 - } - - if ($this.parent('li').hasClass('active')) return - - var $previous = $ul.find('.active:last a') - var hideEvent = $.Event('hide.bs.tab', { - relatedTarget: $this[0] - }) - var showEvent = $.Event('show.bs.tab', { - relatedTarget: $previous[0] - }) - - $previous.trigger(hideEvent) - $this.trigger(showEvent) - - if (showEvent.isDefaultPrevented() || hideEvent.isDefaultPrevented()) return - - var $target = $(selector) - - this.activate($this.closest('li'), $ul) - this.activate($target, $target.parent(), function () { - $previous.trigger({ - type: 'hidden.bs.tab', - relatedTarget: $this[0] - }) - $this.trigger({ - type: 'shown.bs.tab', - relatedTarget: $previous[0] - }) - }) - } - - Tab.prototype.activate = function (element, container, callback) { - var $active = container.find('> .active') - var transition = callback - && $.support.transition - && ($active.length && $active.hasClass('fade') || !!container.find('> .fade').length) - - function next() { - $active - .removeClass('active') - .find('> .dropdown-menu > .active') - .removeClass('active') - .end() - .find('[data-toggle="tab"]') - .attr('aria-expanded', false) - - element - .addClass('active') - .find('[data-toggle="tab"]') - .attr('aria-expanded', true) - - if (transition) { - element[0].offsetWidth // reflow for transition - element.addClass('in') - } else { - element.removeClass('fade') - } - - if (element.parent('.dropdown-menu').length) { - element - .closest('li.dropdown') - .addClass('active') - .end() - .find('[data-toggle="tab"]') - .attr('aria-expanded', true) - } - - callback && callback() - } - - $active.length && transition ? - $active - .one('bsTransitionEnd', next) - .emulateTransitionEnd(Tab.TRANSITION_DURATION) : - next() - - $active.removeClass('in') - } - - - // TAB PLUGIN DEFINITION - // ===================== - - function Plugin(option) { - return this.each(function () { - var $this = $(this) - var data = $this.data('bs.tab') - - if (!data) $this.data('bs.tab', (data = new Tab(this))) - if (typeof option == 'string') data[option]() - }) - } - - var old = $.fn.tab - - $.fn.tab = Plugin - $.fn.tab.Constructor = Tab - - - // TAB NO CONFLICT - // =============== - - $.fn.tab.noConflict = function () { - $.fn.tab = old - return this - } - - - // TAB DATA-API - // ============ - - var clickHandler = function (e) { - e.preventDefault() - Plugin.call($(this), 'show') - } - - $(document) - .on('click.bs.tab.data-api', '[data-toggle="tab"]', clickHandler) - .on('click.bs.tab.data-api', '[data-toggle="pill"]', clickHandler) - -}(jQuery); - -/* ======================================================================== - * Bootstrap: affix.js v3.3.5 - * http://getbootstrap.com/javascript/#affix - * ======================================================================== - * Copyright 2011-2015 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - * ======================================================================== */ - - -+function ($) { - 'use strict'; - - // AFFIX CLASS DEFINITION - // ====================== - - var Affix = function (element, options) { - this.options = $.extend({}, Affix.DEFAULTS, options) - - this.$target = $(this.options.target) - .on('scroll.bs.affix.data-api', $.proxy(this.checkPosition, this)) - .on('click.bs.affix.data-api', $.proxy(this.checkPositionWithEventLoop, this)) - - this.$element = $(element) - this.affixed = null - this.unpin = null - this.pinnedOffset = null - - this.checkPosition() - } - - Affix.VERSION = '3.3.5' - - Affix.RESET = 'affix affix-top affix-bottom' - - Affix.DEFAULTS = { - offset: 0, - target: window - } - - Affix.prototype.getState = function (scrollHeight, height, offsetTop, offsetBottom) { - var scrollTop = this.$target.scrollTop() - var position = this.$element.offset() - var targetHeight = this.$target.height() - - if (offsetTop != null && this.affixed == 'top') return scrollTop < offsetTop ? 'top' : false - - if (this.affixed == 'bottom') { - if (offsetTop != null) return (scrollTop + this.unpin <= position.top) ? false : 'bottom' - return (scrollTop + targetHeight <= scrollHeight - offsetBottom) ? false : 'bottom' - } - - var initializing = this.affixed == null - var colliderTop = initializing ? scrollTop : position.top - var colliderHeight = initializing ? targetHeight : height - - if (offsetTop != null && scrollTop <= offsetTop) return 'top' - if (offsetBottom != null && (colliderTop + colliderHeight >= scrollHeight - offsetBottom)) return 'bottom' - - return false - } - - Affix.prototype.getPinnedOffset = function () { - if (this.pinnedOffset) return this.pinnedOffset - this.$element.removeClass(Affix.RESET).addClass('affix') - var scrollTop = this.$target.scrollTop() - var position = this.$element.offset() - return (this.pinnedOffset = position.top - scrollTop) - } - - Affix.prototype.checkPositionWithEventLoop = function () { - setTimeout($.proxy(this.checkPosition, this), 1) - } - - Affix.prototype.checkPosition = function () { - if (!this.$element.is(':visible')) return - - var height = this.$element.height() - var offset = this.options.offset - var offsetTop = offset.top - var offsetBottom = offset.bottom - var scrollHeight = Math.max($(document).height(), $(document.body).height()) - - if (typeof offset != 'object') offsetBottom = offsetTop = offset - if (typeof offsetTop == 'function') offsetTop = offset.top(this.$element) - if (typeof offsetBottom == 'function') offsetBottom = offset.bottom(this.$element) - - var affix = this.getState(scrollHeight, height, offsetTop, offsetBottom) - - if (this.affixed != affix) { - if (this.unpin != null) this.$element.css('top', '') - - var affixType = 'affix' + (affix ? '-' + affix : '') - var e = $.Event(affixType + '.bs.affix') - - this.$element.trigger(e) - - if (e.isDefaultPrevented()) return - - this.affixed = affix - this.unpin = affix == 'bottom' ? this.getPinnedOffset() : null - - this.$element - .removeClass(Affix.RESET) - .addClass(affixType) - .trigger(affixType.replace('affix', 'affixed') + '.bs.affix') - } - - if (affix == 'bottom') { - this.$element.offset({ - top: scrollHeight - height - offsetBottom - }) - } - } - - - // AFFIX PLUGIN DEFINITION - // ======================= - - function Plugin(option) { - return this.each(function () { - var $this = $(this) - var data = $this.data('bs.affix') - var options = typeof option == 'object' && option - - if (!data) $this.data('bs.affix', (data = new Affix(this, options))) - if (typeof option == 'string') data[option]() - }) - } - - var old = $.fn.affix - - $.fn.affix = Plugin - $.fn.affix.Constructor = Affix - - - // AFFIX NO CONFLICT - // ================= - - $.fn.affix.noConflict = function () { - $.fn.affix = old - return this - } - - - // AFFIX DATA-API - // ============== - - $(window).on('load', function () { - $('[data-spy="affix"]').each(function () { - var $spy = $(this) - var data = $spy.data() - - data.offset = data.offset || {} - - if (data.offsetBottom != null) data.offset.bottom = data.offsetBottom - if (data.offsetTop != null) data.offset.top = data.offsetTop - - Plugin.call($spy, data) - }) - }) - -}(jQuery); diff --git a/src/UI/JsLibraries/bootstrap.tagsinput.js b/src/UI/JsLibraries/bootstrap.tagsinput.js deleted file mode 100644 index 93e7548a4..000000000 --- a/src/UI/JsLibraries/bootstrap.tagsinput.js +++ /dev/null @@ -1,617 +0,0 @@ -(function ($) { - "use strict"; - - var defaultOptions = { - tagClass: function(item) { - return 'label label-info'; - }, - itemValue: function(item) { - return item ? item.toString() : item; - }, - itemText: function(item) { - return this.itemValue(item); - }, - freeInput: true, - addOnBlur: true, - maxTags: undefined, - maxChars: undefined, - confirmKeys: [13, 44], - onTagExists: function(item, $tag) { - $tag.hide().fadeIn(); - }, - trimValue: false, - allowDuplicates: false - }; - - /** - * Constructor function - */ - function TagsInput(element, options) { - this.itemsArray = []; - - this.$element = $(element); - this.$element.hide(); - - this.isSelect = (element.tagName === 'SELECT'); - this.multiple = (this.isSelect && element.hasAttribute('multiple')); - this.objectItems = options && options.itemValue; - this.placeholderText = element.hasAttribute('placeholder') ? this.$element.attr('placeholder') : ''; - this.inputSize = Math.max(1, this.placeholderText.length); - - this.$container = $('<div class="bootstrap-tagsinput"></div>'); - this.$input = $('<input type="text" placeholder="' + this.placeholderText + '"/>').appendTo(this.$container); - - this.$element.after(this.$container); - -// var inputWidth = (this.inputSize < 3 ? 3 : this.inputSize) + "em"; -// this.$input.get(0).style.cssText = "width: " + inputWidth + " !important;"; - this.build(options); - } - - TagsInput.prototype = { - constructor: TagsInput, - - /** - * Adds the given item as a new tag. Pass true to dontPushVal to prevent - * updating the elements val() - */ - add: function(item, dontPushVal) { - var self = this; - - if (self.options.maxTags && self.itemsArray.length >= self.options.maxTags) - return; - - // Ignore falsey values, except false - if (item !== false && !item) - return; - - // Trim value - if (typeof item === "string" && self.options.trimValue) { - item = $.trim(item); - } - - // Throw an error when trying to add an object while the itemValue option was not set - if (typeof item === "object" && !self.objectItems) - throw("Can't add objects when itemValue option is not set"); - - // Ignore strings only containg whitespace - if (item.toString().match(/^\s*$/)) - return; - - // If SELECT but not multiple, remove current tag - if (self.isSelect && !self.multiple && self.itemsArray.length > 0) - self.remove(self.itemsArray[0]); - - if (typeof item === "string" && this.$element[0].tagName === 'INPUT') { - var items = item.split(','); - if (items.length > 1) { - for (var i = 0; i < items.length; i++) { - this.add(items[i], true); - } - - if (!dontPushVal) - self.pushVal(); - return; - } - } - - var itemValue = self.options.itemValue(item), - itemText = self.options.itemText(item), - tagClass = self.options.tagClass(item); - - // Ignore items allready added - var existing = $.grep(self.itemsArray, function(item) { return self.options.itemValue(item) === itemValue; } )[0]; - if (existing && !self.options.allowDuplicates) { - // Invoke onTagExists - if (self.options.onTagExists) { - var $existingTag = $(".tag", self.$container).filter(function() { return $(this).data("item") === existing; }); - self.options.onTagExists(item, $existingTag); - } - return; - } - - // if length greater than limit - if (self.items().toString().length + item.length + 1 > self.options.maxInputLength) - return; - - // raise beforeItemAdd arg - var beforeItemAddEvent = $.Event('beforeItemAdd', { item: item, cancel: false }); - self.$element.trigger(beforeItemAddEvent); - if (beforeItemAddEvent.cancel) - return; - - // register item in internal array and map - self.itemsArray.push(item); - - // add a tag element - var $tag = $('<span class="tag ' + htmlEncode(tagClass) + '">' + htmlEncode(itemText) + '<span data-role="remove"></span></span>'); - $tag.data('item', item); - self.findInputWrapper().before($tag); - $tag.after(' '); - - // add <option /> if item represents a value not present in one of the <select />'s options - if (self.isSelect && !$('option[value="' + encodeURIComponent(itemValue) + '"]',self.$element)[0]) { - var $option = $('<option selected>' + htmlEncode(itemText) + '</option>'); - $option.data('item', item); - $option.attr('value', itemValue); - self.$element.append($option); - } - - if (!dontPushVal) - self.pushVal(); - - // Add class when reached maxTags - if (self.options.maxTags === self.itemsArray.length || self.items().toString().length === self.options.maxInputLength) - self.$container.addClass('bootstrap-tagsinput-max'); - - self.$element.trigger($.Event('itemAdded', { item: item })); - }, - - /** - * Removes the given item. Pass true to dontPushVal to prevent updating the - * elements val() - */ - remove: function(item, dontPushVal) { - var self = this; - - if (self.objectItems) { - if (typeof item === "object") - item = $.grep(self.itemsArray, function(other) { return self.options.itemValue(other) == self.options.itemValue(item); } ); - else - item = $.grep(self.itemsArray, function(other) { return self.options.itemValue(other) == item; } ); - - item = item[item.length-1]; - } - - if (item) { - var beforeItemRemoveEvent = $.Event('beforeItemRemove', { item: item, cancel: false }); - self.$element.trigger(beforeItemRemoveEvent); - if (beforeItemRemoveEvent.cancel) - return; - - $('.tag', self.$container).filter(function() { return $(this).data('item') === item; }).remove(); - $('option', self.$element).filter(function() { return $(this).data('item') === item; }).remove(); - if($.inArray(item, self.itemsArray) !== -1) - self.itemsArray.splice($.inArray(item, self.itemsArray), 1); - } - - if (!dontPushVal) - self.pushVal(); - - // Remove class when reached maxTags - if (self.options.maxTags > self.itemsArray.length) - self.$container.removeClass('bootstrap-tagsinput-max'); - - self.$element.trigger($.Event('itemRemoved', { item: item })); - }, - - /** - * Removes all items - */ - removeAll: function() { - var self = this; - - $('.tag', self.$container).remove(); - $('option', self.$element).remove(); - - while(self.itemsArray.length > 0) - self.itemsArray.pop(); - - self.pushVal(); - }, - - /** - * Refreshes the tags so they match the text/value of their corresponding - * item. - */ - refresh: function() { - var self = this; - $('.tag', self.$container).each(function() { - var $tag = $(this), - item = $tag.data('item'), - itemValue = self.options.itemValue(item), - itemText = self.options.itemText(item), - tagClass = self.options.tagClass(item); - - // Update tag's class and inner text - $tag.attr('class', null); - $tag.addClass('tag ' + htmlEncode(tagClass)); - $tag.contents().filter(function() { - return this.nodeType == 3; - })[0].nodeValue = htmlEncode(itemText); - - if (self.isSelect) { - var option = $('option', self.$element).filter(function() { return $(this).data('item') === item; }); - option.attr('value', itemValue); - } - }); - }, - - /** - * Returns the items added as tags - */ - items: function() { - return this.itemsArray; - }, - - /** - * Assembly value by retrieving the value of each item, and set it on the - * element. - */ - pushVal: function() { - var self = this, - val = $.map(self.items(), function(item) { - return self.options.itemValue(item).toString(); - }); - - self.$element.val(val, true).trigger('change'); - }, - - /** - * Initializes the tags input behaviour on the element - */ - build: function(options) { - var self = this; - - self.options = $.extend({}, defaultOptions, options); - // When itemValue is set, freeInput should always be false - if (self.objectItems) - self.options.freeInput = false; - - makeOptionItemFunction(self.options, 'itemValue'); - makeOptionItemFunction(self.options, 'itemText'); - makeOptionFunction(self.options, 'tagClass'); - - // Typeahead Bootstrap version 2.3.2 - if (self.options.typeahead) { - var typeahead = self.options.typeahead || {}; - - makeOptionFunction(typeahead, 'source'); - - self.$input.typeahead($.extend({}, typeahead, { - source: function (query, process) { - function processItems(items) { - var texts = []; - - for (var i = 0; i < items.length; i++) { - var text = self.options.itemText(items[i]); - map[text] = items[i]; - texts.push(text); - } - process(texts); - } - - this.map = {}; - var map = this.map, - data = typeahead.source(query); - - if ($.isFunction(data.success)) { - // support for Angular callbacks - data.success(processItems); - } else if ($.isFunction(data.then)) { - // support for Angular promises - data.then(processItems); - } else { - // support for functions and jquery promises - $.when(data) - .then(processItems); - } - }, - updater: function (text) { - self.add(this.map[text]); - }, - matcher: function (text) { - return (text.toLowerCase().indexOf(this.query.trim().toLowerCase()) !== -1); - }, - sorter: function (texts) { - return texts.sort(); - }, - highlighter: function (text) { - var regex = new RegExp( '(' + this.query + ')', 'gi' ); - return text.replace( regex, "<strong>$1</strong>" ); - } - })); - } - - // typeahead.js - if (self.options.typeaheadjs) { - var typeaheadjs = self.options.typeaheadjs || {}; - - self.$input.typeahead(null, typeaheadjs).on('typeahead:selected', $.proxy(function (obj, datum) { - if (typeaheadjs.valueKey) - self.add(datum[typeaheadjs.valueKey]); - else - self.add(datum); - self.$input.typeahead('val', ''); - }, self)); - } - - self.$container.on('click', $.proxy(function(event) { - if (! self.$element.attr('disabled')) { - self.$input.removeAttr('disabled'); - } - self.$input.focus(); - }, self)); - - if (self.options.addOnBlur && self.options.freeInput) { - self.$input.on('focusout', $.proxy(function(event) { - // HACK: only process on focusout when no typeahead opened, to - // avoid adding the typeahead text as tag - if ($('.typeahead, .twitter-typeahead', self.$container).length === 0) { - self.add(self.$input.val()); - self.$input.val(''); - } - }, self)); - } - - - self.$container.on('keydown', 'input', $.proxy(function(event) { - var $input = $(event.target), - $inputWrapper = self.findInputWrapper(); - - if (self.$element.attr('disabled')) { - self.$input.attr('disabled', 'disabled'); - return; - } - - switch (event.which) { - // BACKSPACE - case 8: - if (doGetCaretPosition($input[0]) === 0) { - var prev = $inputWrapper.prev(); - if (prev) { - self.remove(prev.data('item')); - } - } - break; - - // DELETE - case 46: - if (doGetCaretPosition($input[0]) === 0) { - var next = $inputWrapper.next(); - if (next) { - self.remove(next.data('item')); - } - } - break; - - // LEFT ARROW - case 37: - // Try to move the input before the previous tag - var $prevTag = $inputWrapper.prev(); - if ($input.val().length === 0 && $prevTag[0]) { - $prevTag.before($inputWrapper); - $input.focus(); - } - break; - // RIGHT ARROW - case 39: - // Try to move the input after the next tag - var $nextTag = $inputWrapper.next(); - if ($input.val().length === 0 && $nextTag[0]) { - $nextTag.after($inputWrapper); - $input.focus(); - } - break; - default: - // ignore - } - - // Reset internal input's size - var textLength = $input.val().length, - wordSpace = Math.ceil(textLength / 5), - size = textLength + wordSpace + 1; - $input.attr('size', Math.max(this.inputSize, $input.val().length)); - }, self)); - - self.$container.on('keypress', 'input', $.proxy(function(event) { - var $input = $(event.target); - - if (self.$element.attr('disabled')) { - self.$input.attr('disabled', 'disabled'); - return; - } - - var text = $input.val(), - maxLengthReached = self.options.maxChars && text.length >= self.options.maxChars; - if (self.options.freeInput && (keyCombinationInList(event, self.options.confirmKeys) || maxLengthReached)) { - self.add(maxLengthReached ? text.substr(0, self.options.maxChars) : text); - $input.val(''); - event.preventDefault(); - } - - // Reset internal input's size - var textLength = $input.val().length, - wordSpace = Math.ceil(textLength / 5), - size = textLength + wordSpace + 1; - $input.attr('size', Math.max(this.inputSize, $input.val().length)); - }, self)); - - // Remove icon clicked - self.$container.on('click', '[data-role=remove]', $.proxy(function(event) { - if (self.$element.attr('disabled')) { - return; - } - self.remove($(event.target).closest('.tag').data('item')); - }, self)); - - // Only add existing value as tags when using strings as tags - if (self.options.itemValue === defaultOptions.itemValue) { - if (self.$element[0].tagName === 'INPUT') { - self.add(self.$element.val()); - } else { - $('option', self.$element).each(function() { - self.add($(this).attr('value'), true); - }); - } - } - }, - - /** - * Removes all tagsinput behaviour and unregsiter all event handlers - */ - destroy: function() { - var self = this; - - // Unbind events - self.$container.off('keypress', 'input'); - self.$container.off('click', '[role=remove]'); - - self.$container.remove(); - self.$element.removeData('tagsinput'); - self.$element.show(); - }, - - /** - * Sets focus on the tagsinput - */ - focus: function() { - this.$input.focus(); - }, - - /** - * Returns the internal input element - */ - input: function() { - return this.$input; - }, - - /** - * Returns the element which is wrapped around the internal input. This - * is normally the $container, but typeahead.js moves the $input element. - */ - findInputWrapper: function() { - var elt = this.$input[0], - container = this.$container[0]; - while(elt && elt.parentNode !== container) - elt = elt.parentNode; - - return $(elt); - } - }; - - /** - * Register JQuery plugin - */ - $.fn.tagsinput = function(arg1, arg2) { - var results = []; - - this.each(function() { - var tagsinput = $(this).data('tagsinput'); - // Initialize a new tags input - if (!tagsinput) { - tagsinput = new TagsInput(this, arg1); - $(this).data('tagsinput', tagsinput); - results.push(tagsinput); - - if (this.tagName === 'SELECT') { - $('option', $(this)).attr('selected', 'selected'); - } - - // Init tags from $(this).val() - $(this).val($(this).val()); - } else if (!arg1 && !arg2) { - // tagsinput already exists - // no function, trying to init - results.push(tagsinput); - } else if(tagsinput[arg1] !== undefined) { - // Invoke function on existing tags input - var retVal = tagsinput[arg1](arg2); - if (retVal !== undefined) - results.push(retVal); - } - }); - - if ( typeof arg1 == 'string') { - // Return the results from the invoked function calls - return results.length > 1 ? results : results[0]; - } else { - return results; - } - }; - - $.fn.tagsinput.Constructor = TagsInput; - - /** - * Most options support both a string or number as well as a function as - * option value. This function makes sure that the option with the given - * key in the given options is wrapped in a function - */ - function makeOptionItemFunction(options, key) { - if (typeof options[key] !== 'function') { - var propertyName = options[key]; - options[key] = function(item) { return item[propertyName]; }; - } - } - function makeOptionFunction(options, key) { - if (typeof options[key] !== 'function') { - var value = options[key]; - options[key] = function() { return value; }; - } - } - /** - * HtmlEncodes the given value - */ - var htmlEncodeContainer = $('<div />'); - function htmlEncode(value) { - if (value) { - return htmlEncodeContainer.text(value).html(); - } else { - return ''; - } - } - - /** - * Returns the position of the caret in the given input field - * http://flightschool.acylt.com/devnotes/caret-position-woes/ - */ - function doGetCaretPosition(oField) { - var iCaretPos = 0; - if (document.selection) { - oField.focus (); - var oSel = document.selection.createRange(); - oSel.moveStart ('character', -oField.value.length); - iCaretPos = oSel.text.length; - } else if (oField.selectionStart || oField.selectionStart == '0') { - iCaretPos = oField.selectionStart; - } - return (iCaretPos); - } - - /** - * Returns boolean indicates whether user has pressed an expected key combination. - * @param object keyPressEvent: JavaScript event object, refer - * http://www.w3.org/TR/2003/WD-DOM-Level-3-Events-20030331/ecma-script-binding.html - * @param object lookupList: expected key combinations, as in: - * [13, {which: 188, shiftKey: true}] - */ - function keyCombinationInList(keyPressEvent, lookupList) { - var found = false; - $.each(lookupList, function (index, keyCombination) { - if (typeof (keyCombination) === 'number' && keyPressEvent.which === keyCombination) { - found = true; - return false; - } - - if (keyPressEvent.which === keyCombination.which) { - var alt = !keyCombination.hasOwnProperty('altKey') || keyPressEvent.altKey === keyCombination.altKey, - shift = !keyCombination.hasOwnProperty('shiftKey') || keyPressEvent.shiftKey === keyCombination.shiftKey, - ctrl = !keyCombination.hasOwnProperty('ctrlKey') || keyPressEvent.ctrlKey === keyCombination.ctrlKey; - if (alt && shift && ctrl) { - found = true; - return false; - } - } - }); - - return found; - } - - /** - * Initialize tagsinput behaviour on inputs and selects which have - * data-role=tagsinput - */ - $(function() { - $("input[data-role=tagsinput], select[multiple][data-role=tagsinput]").tagsinput(); - }); -})(window.jQuery); diff --git a/src/UI/JsLibraries/filesize.js b/src/UI/JsLibraries/filesize.js deleted file mode 100644 index 74f600064..000000000 --- a/src/UI/JsLibraries/filesize.js +++ /dev/null @@ -1,141 +0,0 @@ -/** - * filesize - * - * @author Jason Mulligan <jason.mulligan@avoidwork.com> - * @copyright 2013 Jason Mulligan - * @license BSD-3 <https://raw.github.com/avoidwork/filesize.js/master/LICENSE> - * @link http://filesizejs.com - * @module filesize - * @version 2.0.0 - */ -( function ( global ) { -"use strict"; - -var bit = /b$/, - bite = /^B$/, - radix = 10, - right = /\.(.*)/, - zero = /^0$/; - -/** - * filesize - * - * @method filesize - * @param {Mixed} arg String, Int or Float to transform - * @param {Object} descriptor [Optional] Flags - * @return {String} Readable file size String - */ -function filesize ( arg, descriptor ) { - var result = "", - skip = false, - i = 6, - base, bits, neg, num, round, size, sizes, unix, spacer, suffix, z; - - if ( isNaN( arg ) ) { - throw new Error( "Invalid arguments" ); - } - - descriptor = descriptor || {}; - bits = ( descriptor.bits === true ); - unix = ( descriptor.unix === true ); - base = descriptor.base !== undefined ? descriptor.base : unix ? 2 : 10; - round = descriptor.round !== undefined ? descriptor.round : unix ? 1 : 2; - spacer = descriptor.spacer !== undefined ? descriptor.spacer : unix ? "" : " "; - num = Number( arg ); - neg = ( num < 0 ); - - // Flipping a negative number to determine the size - if ( neg ) { - num = -num; - } - - // Zero is now a special case because bytes divide by 1 - if ( num === 0 ) { - if ( unix ) { - result = "0"; - } - else { - result = "0" + spacer + "B"; - } - } - else { - sizes = options[base][bits ? "bits" : "bytes"]; - - while ( i-- ) { - size = sizes[i][1]; - suffix = sizes[i][0]; - - if ( num >= size ) { - // Treating bytes as cardinal - if ( bite.test( suffix ) ) { - skip = true; - round = 0; - } - - result = ( num / size ).toFixed( round ); - - if ( !skip && unix ) { - if ( bits && bit.test( suffix ) ) { - suffix = suffix.toLowerCase(); - } - - suffix = suffix.charAt( 0 ); - z = right.exec( result ); - - if ( !bits && suffix === "k" ) { - suffix = "K"; - } - - if ( z !== null && z[1] !== undefined && zero.test( z[1] ) ) { - result = parseInt( result, radix ); - } - - result += spacer + suffix; - } - else if ( !unix ) { - result += spacer + suffix; - } - - break; - } - } - } - - // Decorating a 'diff' - if ( neg ) { - result = "-" + result; - } - - return result; -} - -/** - * Size options - * - * @type {Object} - */ -var options = { - 2 : { - bits : [["B", 1], ["kb", 128], ["Mb", 131072], ["Gb", 134217728], ["Tb", 137438953472], ["Pb", 140737488355328]], - bytes : [["B", 1], ["kB", 1024], ["MB", 1048576], ["GB", 1073741824], ["TB", 1099511627776], ["PB", 1125899906842624]] - }, - 10 : { - bits : [["B", 1], ["kb", 125], ["Mb", 125000], ["Gb", 125000000], ["Tb", 125000000000], ["Pb", 125000000000000]], - bytes : [["B", 1], ["kB", 1000], ["MB", 1000000], ["GB", 1000000000], ["TB", 1000000000000], ["PB", 1000000000000000]] - } -}; - -// CommonJS, AMD, script tag -if ( typeof exports !== "undefined" ) { - module.exports = filesize; -} -else if ( typeof define === "function" ) { - define( function () { - return filesize; - } ); -} -else { - global.filesize = filesize; -} - -} )( this ); diff --git a/src/UI/JsLibraries/fullcalendar.js b/src/UI/JsLibraries/fullcalendar.js deleted file mode 100644 index 7cd7aca2e..000000000 --- a/src/UI/JsLibraries/fullcalendar.js +++ /dev/null @@ -1,10879 +0,0 @@ -/*! - * FullCalendar v2.3.2 - * Docs & License: http://fullcalendar.io/ - * (c) 2015 Adam Shaw - */ - -(function(factory) { - if (typeof define === 'function' && define.amd) { - define([ 'jquery', 'moment' ], factory); - } - else if (typeof exports === 'object') { // Node/CommonJS - module.exports = factory(require('jquery'), require('moment')); - } - else { - factory(jQuery, moment); - } -})(function($, moment) { - -;; - -var fc = $.fullCalendar = { version: "2.3.2" }; -var fcViews = fc.views = {}; - - -$.fn.fullCalendar = function(options) { - var args = Array.prototype.slice.call(arguments, 1); // for a possible method call - var res = this; // what this function will return (this jQuery object by default) - - this.each(function(i, _element) { // loop each DOM element involved - var element = $(_element); - var calendar = element.data('fullCalendar'); // get the existing calendar object (if any) - var singleRes; // the returned value of this single method call - - // a method call - if (typeof options === 'string') { - if (calendar && $.isFunction(calendar[options])) { - singleRes = calendar[options].apply(calendar, args); - if (!i) { - res = singleRes; // record the first method call result - } - if (options === 'destroy') { // for the destroy method, must remove Calendar object data - element.removeData('fullCalendar'); - } - } - } - // a new calendar initialization - else if (!calendar) { // don't initialize twice - calendar = new Calendar(element, options); - element.data('fullCalendar', calendar); - calendar.render(); - } - }); - - return res; -}; - - -var complexOptions = [ // names of options that are objects whose properties should be combined - 'header', - 'buttonText', - 'buttonIcons', - 'themeButtonIcons' -]; - - -// Merges an array of option objects into a single object -function mergeOptions(optionObjs) { - return mergeProps(optionObjs, complexOptions); -} - - -// Given options specified for the calendar's constructor, massages any legacy options into a non-legacy form. -// Converts View-Option-Hashes into the View-Specific-Options format. -function massageOverrides(input) { - var overrides = { views: input.views || {} }; // the output. ensure a `views` hash - var subObj; - - // iterate through all option override properties (except `views`) - $.each(input, function(name, val) { - if (name != 'views') { - - // could the value be a legacy View-Option-Hash? - if ( - $.isPlainObject(val) && - !/(time|duration|interval)$/i.test(name) && // exclude duration options. might be given as objects - $.inArray(name, complexOptions) == -1 // complex options aren't allowed to be View-Option-Hashes - ) { - subObj = null; - - // iterate through the properties of this possible View-Option-Hash value - $.each(val, function(subName, subVal) { - - // is the property targeting a view? - if (/^(month|week|day|default|basic(Week|Day)?|agenda(Week|Day)?)$/.test(subName)) { - if (!overrides.views[subName]) { // ensure the view-target entry exists - overrides.views[subName] = {}; - } - overrides.views[subName][name] = subVal; // record the value in the `views` object - } - else { // a non-View-Option-Hash property - if (!subObj) { - subObj = {}; - } - subObj[subName] = subVal; // accumulate these unrelated values for later - } - }); - - if (subObj) { // non-View-Option-Hash properties? transfer them as-is - overrides[name] = subObj; - } - } - else { - overrides[name] = val; // transfer normal options as-is - } - } - }); - - return overrides; -} - -;; - -// exports -fc.intersectionToSeg = intersectionToSeg; -fc.applyAll = applyAll; -fc.debounce = debounce; -fc.isInt = isInt; -fc.htmlEscape = htmlEscape; -fc.cssToStr = cssToStr; -fc.proxy = proxy; -fc.capitaliseFirstLetter = capitaliseFirstLetter; - - -/* FullCalendar-specific DOM Utilities -----------------------------------------------------------------------------------------------------------------------*/ - - -// Given the scrollbar widths of some other container, create borders/margins on rowEls in order to match the left -// and right space that was offset by the scrollbars. A 1-pixel border first, then margin beyond that. -function compensateScroll(rowEls, scrollbarWidths) { - if (scrollbarWidths.left) { - rowEls.css({ - 'border-left-width': 1, - 'margin-left': scrollbarWidths.left - 1 - }); - } - if (scrollbarWidths.right) { - rowEls.css({ - 'border-right-width': 1, - 'margin-right': scrollbarWidths.right - 1 - }); - } -} - - -// Undoes compensateScroll and restores all borders/margins -function uncompensateScroll(rowEls) { - rowEls.css({ - 'margin-left': '', - 'margin-right': '', - 'border-left-width': '', - 'border-right-width': '' - }); -} - - -// Make the mouse cursor express that an event is not allowed in the current area -function disableCursor() { - $('body').addClass('fc-not-allowed'); -} - - -// Returns the mouse cursor to its original look -function enableCursor() { - $('body').removeClass('fc-not-allowed'); -} - - -// Given a total available height to fill, have `els` (essentially child rows) expand to accomodate. -// By default, all elements that are shorter than the recommended height are expanded uniformly, not considering -// any other els that are already too tall. if `shouldRedistribute` is on, it considers these tall rows and -// reduces the available height. -function distributeHeight(els, availableHeight, shouldRedistribute) { - - // *FLOORING NOTE*: we floor in certain places because zoom can give inaccurate floating-point dimensions, - // and it is better to be shorter than taller, to avoid creating unnecessary scrollbars. - - var minOffset1 = Math.floor(availableHeight / els.length); // for non-last element - var minOffset2 = Math.floor(availableHeight - minOffset1 * (els.length - 1)); // for last element *FLOORING NOTE* - var flexEls = []; // elements that are allowed to expand. array of DOM nodes - var flexOffsets = []; // amount of vertical space it takes up - var flexHeights = []; // actual css height - var usedHeight = 0; - - undistributeHeight(els); // give all elements their natural height - - // find elements that are below the recommended height (expandable). - // important to query for heights in a single first pass (to avoid reflow oscillation). - els.each(function(i, el) { - var minOffset = i === els.length - 1 ? minOffset2 : minOffset1; - var naturalOffset = $(el).outerHeight(true); - - if (naturalOffset < minOffset) { - flexEls.push(el); - flexOffsets.push(naturalOffset); - flexHeights.push($(el).height()); - } - else { - // this element stretches past recommended height (non-expandable). mark the space as occupied. - usedHeight += naturalOffset; - } - }); - - // readjust the recommended height to only consider the height available to non-maxed-out rows. - if (shouldRedistribute) { - availableHeight -= usedHeight; - minOffset1 = Math.floor(availableHeight / flexEls.length); - minOffset2 = Math.floor(availableHeight - minOffset1 * (flexEls.length - 1)); // *FLOORING NOTE* - } - - // assign heights to all expandable elements - $(flexEls).each(function(i, el) { - var minOffset = i === flexEls.length - 1 ? minOffset2 : minOffset1; - var naturalOffset = flexOffsets[i]; - var naturalHeight = flexHeights[i]; - var newHeight = minOffset - (naturalOffset - naturalHeight); // subtract the margin/padding - - if (naturalOffset < minOffset) { // we check this again because redistribution might have changed things - $(el).height(newHeight); - } - }); -} - - -// Undoes distrubuteHeight, restoring all els to their natural height -function undistributeHeight(els) { - els.height(''); -} - - -// Given `els`, a jQuery set of <td> cells, find the cell with the largest natural width and set the widths of all the -// cells to be that width. -// PREREQUISITE: if you want a cell to take up width, it needs to have a single inner element w/ display:inline -function matchCellWidths(els) { - var maxInnerWidth = 0; - - els.find('> *').each(function(i, innerEl) { - var innerWidth = $(innerEl).outerWidth(); - if (innerWidth > maxInnerWidth) { - maxInnerWidth = innerWidth; - } - }); - - maxInnerWidth++; // sometimes not accurate of width the text needs to stay on one line. insurance - - els.width(maxInnerWidth); - - return maxInnerWidth; -} - - -// Turns a container element into a scroller if its contents is taller than the allotted height. -// Returns true if the element is now a scroller, false otherwise. -// NOTE: this method is best because it takes weird zooming dimensions into account -function setPotentialScroller(containerEl, height) { - containerEl.height(height).addClass('fc-scroller'); - - // are scrollbars needed? - if (containerEl[0].scrollHeight - 1 > containerEl[0].clientHeight) { // !!! -1 because IE is often off-by-one :( - return true; - } - - unsetScroller(containerEl); // undo - return false; -} - - -// Takes an element that might have been a scroller, and turns it back into a normal element. -function unsetScroller(containerEl) { - containerEl.height('').removeClass('fc-scroller'); -} - - -/* General DOM Utilities -----------------------------------------------------------------------------------------------------------------------*/ - -fc.getClientRect = getClientRect; -fc.getContentRect = getContentRect; -fc.getScrollbarWidths = getScrollbarWidths; - - -// borrowed from https://github.com/jquery/jquery-ui/blob/1.11.0/ui/core.js#L51 -function getScrollParent(el) { - var position = el.css('position'), - scrollParent = el.parents().filter(function() { - var parent = $(this); - return (/(auto|scroll)/).test( - parent.css('overflow') + parent.css('overflow-y') + parent.css('overflow-x') - ); - }).eq(0); - - return position === 'fixed' || !scrollParent.length ? $(el[0].ownerDocument || document) : scrollParent; -} - - -// Queries the outer bounding area of a jQuery element. -// Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive). -function getOuterRect(el) { - var offset = el.offset(); - - return { - left: offset.left, - right: offset.left + el.outerWidth(), - top: offset.top, - bottom: offset.top + el.outerHeight() - }; -} - - -// Queries the area within the margin/border/scrollbars of a jQuery element. Does not go within the padding. -// Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive). -// NOTE: should use clientLeft/clientTop, but very unreliable cross-browser. -function getClientRect(el) { - var offset = el.offset(); - var scrollbarWidths = getScrollbarWidths(el); - var left = offset.left + getCssFloat(el, 'border-left-width') + scrollbarWidths.left; - var top = offset.top + getCssFloat(el, 'border-top-width') + scrollbarWidths.top; - - return { - left: left, - right: left + el[0].clientWidth, // clientWidth includes padding but NOT scrollbars - top: top, - bottom: top + el[0].clientHeight // clientHeight includes padding but NOT scrollbars - }; -} - - -// Queries the area within the margin/border/padding of a jQuery element. Assumed not to have scrollbars. -// Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive). -function getContentRect(el) { - var offset = el.offset(); // just outside of border, margin not included - var left = offset.left + getCssFloat(el, 'border-left-width') + getCssFloat(el, 'padding-left'); - var top = offset.top + getCssFloat(el, 'border-top-width') + getCssFloat(el, 'padding-top'); - - return { - left: left, - right: left + el.width(), - top: top, - bottom: top + el.height() - }; -} - - -// Returns the computed left/right/top/bottom scrollbar widths for the given jQuery element. -// NOTE: should use clientLeft/clientTop, but very unreliable cross-browser. -function getScrollbarWidths(el) { - var leftRightWidth = el.innerWidth() - el[0].clientWidth; // the paddings cancel out, leaving the scrollbars - var widths = { - left: 0, - right: 0, - top: 0, - bottom: el.innerHeight() - el[0].clientHeight // the paddings cancel out, leaving the bottom scrollbar - }; - - if (getIsLeftRtlScrollbars() && el.css('direction') == 'rtl') { // is the scrollbar on the left side? - widths.left = leftRightWidth; - } - else { - widths.right = leftRightWidth; - } - - return widths; -} - - -// Logic for determining if, when the element is right-to-left, the scrollbar appears on the left side - -var _isLeftRtlScrollbars = null; - -function getIsLeftRtlScrollbars() { // responsible for caching the computation - if (_isLeftRtlScrollbars === null) { - _isLeftRtlScrollbars = computeIsLeftRtlScrollbars(); - } - return _isLeftRtlScrollbars; -} - -function computeIsLeftRtlScrollbars() { // creates an offscreen test element, then removes it - var el = $('<div><div/></div>') - .css({ - position: 'absolute', - top: -1000, - left: 0, - border: 0, - padding: 0, - overflow: 'scroll', - direction: 'rtl' - }) - .appendTo('body'); - var innerEl = el.children(); - var res = innerEl.offset().left > el.offset().left; // is the inner div shifted to accommodate a left scrollbar? - el.remove(); - return res; -} - - -// Retrieves a jQuery element's computed CSS value as a floating-point number. -// If the queried value is non-numeric (ex: IE can return "medium" for border width), will just return zero. -function getCssFloat(el, prop) { - return parseFloat(el.css(prop)) || 0; -} - - -// Returns a boolean whether this was a left mouse click and no ctrl key (which means right click on Mac) -function isPrimaryMouseButton(ev) { - return ev.which == 1 && !ev.ctrlKey; -} - - -/* Geometry -----------------------------------------------------------------------------------------------------------------------*/ - - -// Returns a new rectangle that is the intersection of the two rectangles. If they don't intersect, returns false -function intersectRects(rect1, rect2) { - var res = { - left: Math.max(rect1.left, rect2.left), - right: Math.min(rect1.right, rect2.right), - top: Math.max(rect1.top, rect2.top), - bottom: Math.min(rect1.bottom, rect2.bottom) - }; - - if (res.left < res.right && res.top < res.bottom) { - return res; - } - return false; -} - - -// Returns a new point that will have been moved to reside within the given rectangle -function constrainPoint(point, rect) { - return { - left: Math.min(Math.max(point.left, rect.left), rect.right), - top: Math.min(Math.max(point.top, rect.top), rect.bottom) - }; -} - - -// Returns a point that is the center of the given rectangle -function getRectCenter(rect) { - return { - left: (rect.left + rect.right) / 2, - top: (rect.top + rect.bottom) / 2 - }; -} - - -// Subtracts point2's coordinates from point1's coordinates, returning a delta -function diffPoints(point1, point2) { - return { - left: point1.left - point2.left, - top: point1.top - point2.top - }; -} - - -/* FullCalendar-specific Misc Utilities -----------------------------------------------------------------------------------------------------------------------*/ - - -// Creates a basic segment with the intersection of the two ranges. Returns undefined if no intersection. -// Expects all dates to be normalized to the same timezone beforehand. -// TODO: move to date section? -function intersectionToSeg(subjectRange, constraintRange) { - var subjectStart = subjectRange.start; - var subjectEnd = subjectRange.end; - var constraintStart = constraintRange.start; - var constraintEnd = constraintRange.end; - var segStart, segEnd; - var isStart, isEnd; - - if (subjectEnd > constraintStart && subjectStart < constraintEnd) { // in bounds at all? - - if (subjectStart >= constraintStart) { - segStart = subjectStart.clone(); - isStart = true; - } - else { - segStart = constraintStart.clone(); - isStart = false; - } - - if (subjectEnd <= constraintEnd) { - segEnd = subjectEnd.clone(); - isEnd = true; - } - else { - segEnd = constraintEnd.clone(); - isEnd = false; - } - - return { - start: segStart, - end: segEnd, - isStart: isStart, - isEnd: isEnd - }; - } -} - - -/* Date Utilities -----------------------------------------------------------------------------------------------------------------------*/ - -fc.computeIntervalUnit = computeIntervalUnit; -fc.durationHasTime = durationHasTime; - -var dayIDs = [ 'sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat' ]; -var intervalUnits = [ 'year', 'month', 'week', 'day', 'hour', 'minute', 'second', 'millisecond' ]; - - -// Diffs the two moments into a Duration where full-days are recorded first, then the remaining time. -// Moments will have their timezones normalized. -function diffDayTime(a, b) { - return moment.duration({ - days: a.clone().stripTime().diff(b.clone().stripTime(), 'days'), - ms: a.time() - b.time() // time-of-day from day start. disregards timezone - }); -} - - -// Diffs the two moments via their start-of-day (regardless of timezone). Produces whole-day durations. -function diffDay(a, b) { - return moment.duration({ - days: a.clone().stripTime().diff(b.clone().stripTime(), 'days') - }); -} - - -// Diffs two moments, producing a duration, made of a whole-unit-increment of the given unit. Uses rounding. -function diffByUnit(a, b, unit) { - return moment.duration( - Math.round(a.diff(b, unit, true)), // returnFloat=true - unit - ); -} - - -// Computes the unit name of the largest whole-unit period of time. -// For example, 48 hours will be "days" whereas 49 hours will be "hours". -// Accepts start/end, a range object, or an original duration object. -function computeIntervalUnit(start, end) { - var i, unit; - var val; - - for (i = 0; i < intervalUnits.length; i++) { - unit = intervalUnits[i]; - val = computeRangeAs(unit, start, end); - - if (val >= 1 && isInt(val)) { - break; - } - } - - return unit; // will be "milliseconds" if nothing else matches -} - - -// Computes the number of units (like "hours") in the given range. -// Range can be a {start,end} object, separate start/end args, or a Duration. -// Results are based on Moment's .as() and .diff() methods, so results can depend on internal handling -// of month-diffing logic (which tends to vary from version to version). -function computeRangeAs(unit, start, end) { - - if (end != null) { // given start, end - return end.diff(start, unit, true); - } - else if (moment.isDuration(start)) { // given duration - return start.as(unit); - } - else { // given { start, end } range object - return start.end.diff(start.start, unit, true); - } -} - - -// Returns a boolean about whether the given duration has any time parts (hours/minutes/seconds/ms) -function durationHasTime(dur) { - return Boolean(dur.hours() || dur.minutes() || dur.seconds() || dur.milliseconds()); -} - - -function isNativeDate(input) { - return Object.prototype.toString.call(input) === '[object Date]' || input instanceof Date; -} - - -// Returns a boolean about whether the given input is a time string, like "06:40:00" or "06:00" -function isTimeString(str) { - return /^\d+\:\d+(?:\:\d+\.?(?:\d{3})?)?$/.test(str); -} - - -/* General Utilities -----------------------------------------------------------------------------------------------------------------------*/ - -var hasOwnPropMethod = {}.hasOwnProperty; - - -// Merges an array of objects into a single object. -// The second argument allows for an array of property names who's object values will be merged together. -function mergeProps(propObjs, complexProps) { - var dest = {}; - var i, name; - var complexObjs; - var j, val; - var props; - - if (complexProps) { - for (i = 0; i < complexProps.length; i++) { - name = complexProps[i]; - complexObjs = []; - - // collect the trailing object values, stopping when a non-object is discovered - for (j = propObjs.length - 1; j >= 0; j--) { - val = propObjs[j][name]; - - if (typeof val === 'object') { - complexObjs.unshift(val); - } - else if (val !== undefined) { - dest[name] = val; // if there were no objects, this value will be used - break; - } - } - - // if the trailing values were objects, use the merged value - if (complexObjs.length) { - dest[name] = mergeProps(complexObjs); - } - } - } - - // copy values into the destination, going from last to first - for (i = propObjs.length - 1; i >= 0; i--) { - props = propObjs[i]; - - for (name in props) { - if (!(name in dest)) { // if already assigned by previous props or complex props, don't reassign - dest[name] = props[name]; - } - } - } - - return dest; -} - - -// Create an object that has the given prototype. Just like Object.create -function createObject(proto) { - var f = function() {}; - f.prototype = proto; - return new f(); -} - - -function copyOwnProps(src, dest) { - for (var name in src) { - if (hasOwnProp(src, name)) { - dest[name] = src[name]; - } - } -} - - -// Copies over certain methods with the same names as Object.prototype methods. Overcomes an IE<=8 bug: -// https://developer.mozilla.org/en-US/docs/ECMAScript_DontEnum_attribute#JScript_DontEnum_Bug -function copyNativeMethods(src, dest) { - var names = [ 'constructor', 'toString', 'valueOf' ]; - var i, name; - - for (i = 0; i < names.length; i++) { - name = names[i]; - - if (src[name] !== Object.prototype[name]) { - dest[name] = src[name]; - } - } -} - - -function hasOwnProp(obj, name) { - return hasOwnPropMethod.call(obj, name); -} - - -// Is the given value a non-object non-function value? -function isAtomic(val) { - return /undefined|null|boolean|number|string/.test($.type(val)); -} - - -function applyAll(functions, thisObj, args) { - if ($.isFunction(functions)) { - functions = [ functions ]; - } - if (functions) { - var i; - var ret; - for (i=0; i<functions.length; i++) { - ret = functions[i].apply(thisObj, args) || ret; - } - return ret; - } -} - - -function firstDefined() { - for (var i=0; i<arguments.length; i++) { - if (arguments[i] !== undefined) { - return arguments[i]; - } - } -} - - -function htmlEscape(s) { - return (s + '').replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/'/g, ''') - .replace(/"/g, '"') - .replace(/\n/g, '<br />'); -} - - -function stripHtmlEntities(text) { - return text.replace(/&.*?;/g, ''); -} - - -// Given a hash of CSS properties, returns a string of CSS. -// Uses property names as-is (no camel-case conversion). Will not make statements for null/undefined values. -function cssToStr(cssProps) { - var statements = []; - - $.each(cssProps, function(name, val) { - if (val != null) { - statements.push(name + ':' + val); - } - }); - - return statements.join(';'); -} - - -function capitaliseFirstLetter(str) { - return str.charAt(0).toUpperCase() + str.slice(1); -} - - -function compareNumbers(a, b) { // for .sort() - return a - b; -} - - -function isInt(n) { - return n % 1 === 0; -} - - -// Returns a method bound to the given object context. -// Just like one of the jQuery.proxy signatures, but without the undesired behavior of treating the same method with -// different contexts as identical when binding/unbinding events. -function proxy(obj, methodName) { - var method = obj[methodName]; - - return function() { - return method.apply(obj, arguments); - }; -} - - -// Returns a function, that, as long as it continues to be invoked, will not -// be triggered. The function will be called after it stops being called for -// N milliseconds. -// https://github.com/jashkenas/underscore/blob/1.6.0/underscore.js#L714 -function debounce(func, wait) { - var timeoutId; - var args; - var context; - var timestamp; // of most recent call - var later = function() { - var last = +new Date() - timestamp; - if (last < wait && last > 0) { - timeoutId = setTimeout(later, wait - last); - } - else { - timeoutId = null; - func.apply(context, args); - if (!timeoutId) { - context = args = null; - } - } - }; - - return function() { - context = this; - args = arguments; - timestamp = +new Date(); - if (!timeoutId) { - timeoutId = setTimeout(later, wait); - } - }; -} - -;; - -var ambigDateOfMonthRegex = /^\s*\d{4}-\d\d$/; -var ambigTimeOrZoneRegex = - /^\s*\d{4}-(?:(\d\d-\d\d)|(W\d\d$)|(W\d\d-\d)|(\d\d\d))((T| )(\d\d(:\d\d(:\d\d(\.\d+)?)?)?)?)?$/; -var newMomentProto = moment.fn; // where we will attach our new methods -var oldMomentProto = $.extend({}, newMomentProto); // copy of original moment methods -var allowValueOptimization; -var setUTCValues; // function defined below -var setLocalValues; // function defined below - - -// Creating -// ------------------------------------------------------------------------------------------------- - -// Creates a new moment, similar to the vanilla moment(...) constructor, but with -// extra features (ambiguous time, enhanced formatting). When given an existing moment, -// it will function as a clone (and retain the zone of the moment). Anything else will -// result in a moment in the local zone. -fc.moment = function() { - return makeMoment(arguments); -}; - -// Sames as fc.moment, but forces the resulting moment to be in the UTC timezone. -fc.moment.utc = function() { - var mom = makeMoment(arguments, true); - - // Force it into UTC because makeMoment doesn't guarantee it - // (if given a pre-existing moment for example) - if (mom.hasTime()) { // don't give ambiguously-timed moments a UTC zone - mom.utc(); - } - - return mom; -}; - -// Same as fc.moment, but when given an ISO8601 string, the timezone offset is preserved. -// ISO8601 strings with no timezone offset will become ambiguously zoned. -fc.moment.parseZone = function() { - return makeMoment(arguments, true, true); -}; - -// Builds an enhanced moment from args. When given an existing moment, it clones. When given a -// native Date, or called with no arguments (the current time), the resulting moment will be local. -// Anything else needs to be "parsed" (a string or an array), and will be affected by: -// parseAsUTC - if there is no zone information, should we parse the input in UTC? -// parseZone - if there is zone information, should we force the zone of the moment? -function makeMoment(args, parseAsUTC, parseZone) { - var input = args[0]; - var isSingleString = args.length == 1 && typeof input === 'string'; - var isAmbigTime; - var isAmbigZone; - var ambigMatch; - var mom; - - if (moment.isMoment(input)) { - mom = moment.apply(null, args); // clone it - transferAmbigs(input, mom); // the ambig flags weren't transfered with the clone - } - else if (isNativeDate(input) || input === undefined) { - mom = moment.apply(null, args); // will be local - } - else { // "parsing" is required - isAmbigTime = false; - isAmbigZone = false; - - if (isSingleString) { - if (ambigDateOfMonthRegex.test(input)) { - // accept strings like '2014-05', but convert to the first of the month - input += '-01'; - args = [ input ]; // for when we pass it on to moment's constructor - isAmbigTime = true; - isAmbigZone = true; - } - else if ((ambigMatch = ambigTimeOrZoneRegex.exec(input))) { - isAmbigTime = !ambigMatch[5]; // no time part? - isAmbigZone = true; - } - } - else if ($.isArray(input)) { - // arrays have no timezone information, so assume ambiguous zone - isAmbigZone = true; - } - // otherwise, probably a string with a format - - if (parseAsUTC || isAmbigTime) { - mom = moment.utc.apply(moment, args); - } - else { - mom = moment.apply(null, args); - } - - if (isAmbigTime) { - mom._ambigTime = true; - mom._ambigZone = true; // ambiguous time always means ambiguous zone - } - else if (parseZone) { // let's record the inputted zone somehow - if (isAmbigZone) { - mom._ambigZone = true; - } - else if (isSingleString) { - if (mom.utcOffset) { - mom.utcOffset(input); // if not a valid zone, will assign UTC - } - else { - mom.zone(input); // for moment-pre-2.9 - } - } - } - } - - mom._fullCalendar = true; // flag for extended functionality - - return mom; -} - - -// A clone method that works with the flags related to our enhanced functionality. -// In the future, use moment.momentProperties -newMomentProto.clone = function() { - var mom = oldMomentProto.clone.apply(this, arguments); - - // these flags weren't transfered with the clone - transferAmbigs(this, mom); - if (this._fullCalendar) { - mom._fullCalendar = true; - } - - return mom; -}; - - -// Week Number -// ------------------------------------------------------------------------------------------------- - - -// Returns the week number, considering the locale's custom week number calcuation -// `weeks` is an alias for `week` -newMomentProto.week = newMomentProto.weeks = function(input) { - var weekCalc = (this._locale || this._lang) // works pre-moment-2.8 - ._fullCalendar_weekCalc; - - if (input == null && typeof weekCalc === 'function') { // custom function only works for getter - return weekCalc(this); - } - else if (weekCalc === 'ISO') { - return oldMomentProto.isoWeek.apply(this, arguments); // ISO getter/setter - } - - return oldMomentProto.week.apply(this, arguments); // local getter/setter -}; - - -// Time-of-day -// ------------------------------------------------------------------------------------------------- - -// GETTER -// Returns a Duration with the hours/minutes/seconds/ms values of the moment. -// If the moment has an ambiguous time, a duration of 00:00 will be returned. -// -// SETTER -// You can supply a Duration, a Moment, or a Duration-like argument. -// When setting the time, and the moment has an ambiguous time, it then becomes unambiguous. -newMomentProto.time = function(time) { - - // Fallback to the original method (if there is one) if this moment wasn't created via FullCalendar. - // `time` is a generic enough method name where this precaution is necessary to avoid collisions w/ other plugins. - if (!this._fullCalendar) { - return oldMomentProto.time.apply(this, arguments); - } - - if (time == null) { // getter - return moment.duration({ - hours: this.hours(), - minutes: this.minutes(), - seconds: this.seconds(), - milliseconds: this.milliseconds() - }); - } - else { // setter - - this._ambigTime = false; // mark that the moment now has a time - - if (!moment.isDuration(time) && !moment.isMoment(time)) { - time = moment.duration(time); - } - - // The day value should cause overflow (so 24 hours becomes 00:00:00 of next day). - // Only for Duration times, not Moment times. - var dayHours = 0; - if (moment.isDuration(time)) { - dayHours = Math.floor(time.asDays()) * 24; - } - - // We need to set the individual fields. - // Can't use startOf('day') then add duration. In case of DST at start of day. - return this.hours(dayHours + time.hours()) - .minutes(time.minutes()) - .seconds(time.seconds()) - .milliseconds(time.milliseconds()); - } -}; - -// Converts the moment to UTC, stripping out its time-of-day and timezone offset, -// but preserving its YMD. A moment with a stripped time will display no time -// nor timezone offset when .format() is called. -newMomentProto.stripTime = function() { - var a; - - if (!this._ambigTime) { - - // get the values before any conversion happens - a = this.toArray(); // array of y/m/d/h/m/s/ms - - // TODO: use keepLocalTime in the future - this.utc(); // set the internal UTC flag (will clear the ambig flags) - setUTCValues(this, a.slice(0, 3)); // set the year/month/date. time will be zero - - // Mark the time as ambiguous. This needs to happen after the .utc() call, which might call .utcOffset(), - // which clears all ambig flags. Same with setUTCValues with moment-timezone. - this._ambigTime = true; - this._ambigZone = true; // if ambiguous time, also ambiguous timezone offset - } - - return this; // for chaining -}; - -// Returns if the moment has a non-ambiguous time (boolean) -newMomentProto.hasTime = function() { - return !this._ambigTime; -}; - - -// Timezone -// ------------------------------------------------------------------------------------------------- - -// Converts the moment to UTC, stripping out its timezone offset, but preserving its -// YMD and time-of-day. A moment with a stripped timezone offset will display no -// timezone offset when .format() is called. -// TODO: look into Moment's keepLocalTime functionality -newMomentProto.stripZone = function() { - var a, wasAmbigTime; - - if (!this._ambigZone) { - - // get the values before any conversion happens - a = this.toArray(); // array of y/m/d/h/m/s/ms - wasAmbigTime = this._ambigTime; - - this.utc(); // set the internal UTC flag (might clear the ambig flags, depending on Moment internals) - setUTCValues(this, a); // will set the year/month/date/hours/minutes/seconds/ms - - // the above call to .utc()/.utcOffset() unfortunately might clear the ambig flags, so restore - this._ambigTime = wasAmbigTime || false; - - // Mark the zone as ambiguous. This needs to happen after the .utc() call, which might call .utcOffset(), - // which clears the ambig flags. Same with setUTCValues with moment-timezone. - this._ambigZone = true; - } - - return this; // for chaining -}; - -// Returns of the moment has a non-ambiguous timezone offset (boolean) -newMomentProto.hasZone = function() { - return !this._ambigZone; -}; - - -// this method implicitly marks a zone -newMomentProto.local = function() { - var a = this.toArray(); // year,month,date,hours,minutes,seconds,ms as an array - var wasAmbigZone = this._ambigZone; - - oldMomentProto.local.apply(this, arguments); - - // ensure non-ambiguous - // this probably already happened via local() -> utcOffset(), but don't rely on Moment's internals - this._ambigTime = false; - this._ambigZone = false; - - if (wasAmbigZone) { - // If the moment was ambiguously zoned, the date fields were stored as UTC. - // We want to preserve these, but in local time. - // TODO: look into Moment's keepLocalTime functionality - setLocalValues(this, a); - } - - return this; // for chaining -}; - - -// implicitly marks a zone -newMomentProto.utc = function() { - oldMomentProto.utc.apply(this, arguments); - - // ensure non-ambiguous - // this probably already happened via utc() -> utcOffset(), but don't rely on Moment's internals - this._ambigTime = false; - this._ambigZone = false; - - return this; -}; - - -// methods for arbitrarily manipulating timezone offset. -// should clear time/zone ambiguity when called. -$.each([ - 'zone', // only in moment-pre-2.9. deprecated afterwards - 'utcOffset' -], function(i, name) { - if (oldMomentProto[name]) { // original method exists? - - // this method implicitly marks a zone (will probably get called upon .utc() and .local()) - newMomentProto[name] = function(tzo) { - - if (tzo != null) { // setter - // these assignments needs to happen before the original zone method is called. - // I forget why, something to do with a browser crash. - this._ambigTime = false; - this._ambigZone = false; - } - - return oldMomentProto[name].apply(this, arguments); - }; - } -}); - - -// Formatting -// ------------------------------------------------------------------------------------------------- - -newMomentProto.format = function() { - if (this._fullCalendar && arguments[0]) { // an enhanced moment? and a format string provided? - return formatDate(this, arguments[0]); // our extended formatting - } - if (this._ambigTime) { - return oldMomentFormat(this, 'YYYY-MM-DD'); - } - if (this._ambigZone) { - return oldMomentFormat(this, 'YYYY-MM-DD[T]HH:mm:ss'); - } - return oldMomentProto.format.apply(this, arguments); -}; - -newMomentProto.toISOString = function() { - if (this._ambigTime) { - return oldMomentFormat(this, 'YYYY-MM-DD'); - } - if (this._ambigZone) { - return oldMomentFormat(this, 'YYYY-MM-DD[T]HH:mm:ss'); - } - return oldMomentProto.toISOString.apply(this, arguments); -}; - - -// Querying -// ------------------------------------------------------------------------------------------------- - -// Is the moment within the specified range? `end` is exclusive. -// FYI, this method is not a standard Moment method, so always do our enhanced logic. -newMomentProto.isWithin = function(start, end) { - var a = commonlyAmbiguate([ this, start, end ]); - return a[0] >= a[1] && a[0] < a[2]; -}; - -// When isSame is called with units, timezone ambiguity is normalized before the comparison happens. -// If no units specified, the two moments must be identically the same, with matching ambig flags. -newMomentProto.isSame = function(input, units) { - var a; - - // only do custom logic if this is an enhanced moment - if (!this._fullCalendar) { - return oldMomentProto.isSame.apply(this, arguments); - } - - if (units) { - a = commonlyAmbiguate([ this, input ], true); // normalize timezones but don't erase times - return oldMomentProto.isSame.call(a[0], a[1], units); - } - else { - input = fc.moment.parseZone(input); // normalize input - return oldMomentProto.isSame.call(this, input) && - Boolean(this._ambigTime) === Boolean(input._ambigTime) && - Boolean(this._ambigZone) === Boolean(input._ambigZone); - } -}; - -// Make these query methods work with ambiguous moments -$.each([ - 'isBefore', - 'isAfter' -], function(i, methodName) { - newMomentProto[methodName] = function(input, units) { - var a; - - // only do custom logic if this is an enhanced moment - if (!this._fullCalendar) { - return oldMomentProto[methodName].apply(this, arguments); - } - - a = commonlyAmbiguate([ this, input ]); - return oldMomentProto[methodName].call(a[0], a[1], units); - }; -}); - - -// Misc Internals -// ------------------------------------------------------------------------------------------------- - -// given an array of moment-like inputs, return a parallel array w/ moments similarly ambiguated. -// for example, of one moment has ambig time, but not others, all moments will have their time stripped. -// set `preserveTime` to `true` to keep times, but only normalize zone ambiguity. -// returns the original moments if no modifications are necessary. -function commonlyAmbiguate(inputs, preserveTime) { - var anyAmbigTime = false; - var anyAmbigZone = false; - var len = inputs.length; - var moms = []; - var i, mom; - - // parse inputs into real moments and query their ambig flags - for (i = 0; i < len; i++) { - mom = inputs[i]; - if (!moment.isMoment(mom)) { - mom = fc.moment.parseZone(mom); - } - anyAmbigTime = anyAmbigTime || mom._ambigTime; - anyAmbigZone = anyAmbigZone || mom._ambigZone; - moms.push(mom); - } - - // strip each moment down to lowest common ambiguity - // use clones to avoid modifying the original moments - for (i = 0; i < len; i++) { - mom = moms[i]; - if (!preserveTime && anyAmbigTime && !mom._ambigTime) { - moms[i] = mom.clone().stripTime(); - } - else if (anyAmbigZone && !mom._ambigZone) { - moms[i] = mom.clone().stripZone(); - } - } - - return moms; -} - -// Transfers all the flags related to ambiguous time/zone from the `src` moment to the `dest` moment -// TODO: look into moment.momentProperties for this. -function transferAmbigs(src, dest) { - if (src._ambigTime) { - dest._ambigTime = true; - } - else if (dest._ambigTime) { - dest._ambigTime = false; - } - - if (src._ambigZone) { - dest._ambigZone = true; - } - else if (dest._ambigZone) { - dest._ambigZone = false; - } -} - - -// Sets the year/month/date/etc values of the moment from the given array. -// Inefficient because it calls each individual setter. -function setMomentValues(mom, a) { - mom.year(a[0] || 0) - .month(a[1] || 0) - .date(a[2] || 0) - .hours(a[3] || 0) - .minutes(a[4] || 0) - .seconds(a[5] || 0) - .milliseconds(a[6] || 0); -} - -// Can we set the moment's internal date directly? -allowValueOptimization = '_d' in moment() && 'updateOffset' in moment; - -// Utility function. Accepts a moment and an array of the UTC year/month/date/etc values to set. -// Assumes the given moment is already in UTC mode. -setUTCValues = allowValueOptimization ? function(mom, a) { - // simlate what moment's accessors do - mom._d.setTime(Date.UTC.apply(Date, a)); - moment.updateOffset(mom, false); // keepTime=false -} : setMomentValues; - -// Utility function. Accepts a moment and an array of the local year/month/date/etc values to set. -// Assumes the given moment is already in local mode. -setLocalValues = allowValueOptimization ? function(mom, a) { - // simlate what moment's accessors do - mom._d.setTime(+new Date( // FYI, there is now way to apply an array of args to a constructor - a[0] || 0, - a[1] || 0, - a[2] || 0, - a[3] || 0, - a[4] || 0, - a[5] || 0, - a[6] || 0 - )); - moment.updateOffset(mom, false); // keepTime=false -} : setMomentValues; - -;; - -// Single Date Formatting -// ------------------------------------------------------------------------------------------------- - - -// call this if you want Moment's original format method to be used -function oldMomentFormat(mom, formatStr) { - return oldMomentProto.format.call(mom, formatStr); // oldMomentProto defined in moment-ext.js -} - - -// Formats `date` with a Moment formatting string, but allow our non-zero areas and -// additional token. -function formatDate(date, formatStr) { - return formatDateWithChunks(date, getFormatStringChunks(formatStr)); -} - - -function formatDateWithChunks(date, chunks) { - var s = ''; - var i; - - for (i=0; i<chunks.length; i++) { - s += formatDateWithChunk(date, chunks[i]); - } - - return s; -} - - -// addition formatting tokens we want recognized -var tokenOverrides = { - t: function(date) { // "a" or "p" - return oldMomentFormat(date, 'a').charAt(0); - }, - T: function(date) { // "A" or "P" - return oldMomentFormat(date, 'A').charAt(0); - } -}; - - -function formatDateWithChunk(date, chunk) { - var token; - var maybeStr; - - if (typeof chunk === 'string') { // a literal string - return chunk; - } - else if ((token = chunk.token)) { // a token, like "YYYY" - if (tokenOverrides[token]) { - return tokenOverrides[token](date); // use our custom token - } - return oldMomentFormat(date, token); - } - else if (chunk.maybe) { // a grouping of other chunks that must be non-zero - maybeStr = formatDateWithChunks(date, chunk.maybe); - if (maybeStr.match(/[1-9]/)) { - return maybeStr; - } - } - - return ''; -} - - -// Date Range Formatting -// ------------------------------------------------------------------------------------------------- -// TODO: make it work with timezone offset - -// Using a formatting string meant for a single date, generate a range string, like -// "Sep 2 - 9 2013", that intelligently inserts a separator where the dates differ. -// If the dates are the same as far as the format string is concerned, just return a single -// rendering of one date, without any separator. -function formatRange(date1, date2, formatStr, separator, isRTL) { - var localeData; - - date1 = fc.moment.parseZone(date1); - date2 = fc.moment.parseZone(date2); - - localeData = (date1.localeData || date1.lang).call(date1); // works with moment-pre-2.8 - - // Expand localized format strings, like "LL" -> "MMMM D YYYY" - formatStr = localeData.longDateFormat(formatStr) || formatStr; - // BTW, this is not important for `formatDate` because it is impossible to put custom tokens - // or non-zero areas in Moment's localized format strings. - - separator = separator || ' - '; - - return formatRangeWithChunks( - date1, - date2, - getFormatStringChunks(formatStr), - separator, - isRTL - ); -} -fc.formatRange = formatRange; // expose - - -function formatRangeWithChunks(date1, date2, chunks, separator, isRTL) { - var chunkStr; // the rendering of the chunk - var leftI; - var leftStr = ''; - var rightI; - var rightStr = ''; - var middleI; - var middleStr1 = ''; - var middleStr2 = ''; - var middleStr = ''; - - // Start at the leftmost side of the formatting string and continue until you hit a token - // that is not the same between dates. - for (leftI=0; leftI<chunks.length; leftI++) { - chunkStr = formatSimilarChunk(date1, date2, chunks[leftI]); - if (chunkStr === false) { - break; - } - leftStr += chunkStr; - } - - // Similarly, start at the rightmost side of the formatting string and move left - for (rightI=chunks.length-1; rightI>leftI; rightI--) { - chunkStr = formatSimilarChunk(date1, date2, chunks[rightI]); - if (chunkStr === false) { - break; - } - rightStr = chunkStr + rightStr; - } - - // The area in the middle is different for both of the dates. - // Collect them distinctly so we can jam them together later. - for (middleI=leftI; middleI<=rightI; middleI++) { - middleStr1 += formatDateWithChunk(date1, chunks[middleI]); - middleStr2 += formatDateWithChunk(date2, chunks[middleI]); - } - - if (middleStr1 || middleStr2) { - if (isRTL) { - middleStr = middleStr2 + separator + middleStr1; - } - else { - middleStr = middleStr1 + separator + middleStr2; - } - } - - return leftStr + middleStr + rightStr; -} - - -var similarUnitMap = { - Y: 'year', - M: 'month', - D: 'day', // day of month - d: 'day', // day of week - // prevents a separator between anything time-related... - A: 'second', // AM/PM - a: 'second', // am/pm - T: 'second', // A/P - t: 'second', // a/p - H: 'second', // hour (24) - h: 'second', // hour (12) - m: 'second', // minute - s: 'second' // second -}; -// TODO: week maybe? - - -// Given a formatting chunk, and given that both dates are similar in the regard the -// formatting chunk is concerned, format date1 against `chunk`. Otherwise, return `false`. -function formatSimilarChunk(date1, date2, chunk) { - var token; - var unit; - - if (typeof chunk === 'string') { // a literal string - return chunk; - } - else if ((token = chunk.token)) { - unit = similarUnitMap[token.charAt(0)]; - // are the dates the same for this unit of measurement? - if (unit && date1.isSame(date2, unit)) { - return oldMomentFormat(date1, token); // would be the same if we used `date2` - // BTW, don't support custom tokens - } - } - - return false; // the chunk is NOT the same for the two dates - // BTW, don't support splitting on non-zero areas -} - - -// Chunking Utils -// ------------------------------------------------------------------------------------------------- - - -var formatStringChunkCache = {}; - - -function getFormatStringChunks(formatStr) { - if (formatStr in formatStringChunkCache) { - return formatStringChunkCache[formatStr]; - } - return (formatStringChunkCache[formatStr] = chunkFormatString(formatStr)); -} - - -// Break the formatting string into an array of chunks -function chunkFormatString(formatStr) { - var chunks = []; - var chunker = /\[([^\]]*)\]|\(([^\)]*)\)|(LTS|LT|(\w)\4*o?)|([^\w\[\(]+)/g; // TODO: more descrimination - var match; - - while ((match = chunker.exec(formatStr))) { - if (match[1]) { // a literal string inside [ ... ] - chunks.push(match[1]); - } - else if (match[2]) { // non-zero formatting inside ( ... ) - chunks.push({ maybe: chunkFormatString(match[2]) }); - } - else if (match[3]) { // a formatting token - chunks.push({ token: match[3] }); - } - else if (match[5]) { // an unenclosed literal string - chunks.push(match[5]); - } - } - - return chunks; -} - -;; - -fc.Class = Class; // export - -// class that all other classes will inherit from -function Class() { } - -// called upon a class to create a subclass -Class.extend = function(members) { - var superClass = this; - var subClass; - - members = members || {}; - - // ensure a constructor for the subclass, forwarding all arguments to the super-constructor if it doesn't exist - if (hasOwnProp(members, 'constructor')) { - subClass = members.constructor; - } - if (typeof subClass !== 'function') { - subClass = members.constructor = function() { - superClass.apply(this, arguments); - }; - } - - // build the base prototype for the subclass, which is an new object chained to the superclass's prototype - subClass.prototype = createObject(superClass.prototype); - - // copy each member variable/method onto the the subclass's prototype - copyOwnProps(members, subClass.prototype); - copyNativeMethods(members, subClass.prototype); // hack for IE8 - - // copy over all class variables/methods to the subclass, such as `extend` and `mixin` - copyOwnProps(superClass, subClass); - - return subClass; -}; - -// adds new member variables/methods to the class's prototype. -// can be called with another class, or a plain object hash containing new members. -Class.mixin = function(members) { - copyOwnProps(members.prototype || members, this.prototype); // TODO: copyNativeMethods? -}; -;; - -/* A rectangular panel that is absolutely positioned over other content ------------------------------------------------------------------------------------------------------------------------- -Options: - - className (string) - - content (HTML string or jQuery element set) - - parentEl - - top - - left - - right (the x coord of where the right edge should be. not a "CSS" right) - - autoHide (boolean) - - show (callback) - - hide (callback) -*/ - -var Popover = Class.extend({ - - isHidden: true, - options: null, - el: null, // the container element for the popover. generated by this object - documentMousedownProxy: null, // document mousedown handler bound to `this` - margin: 10, // the space required between the popover and the edges of the scroll container - - - constructor: function(options) { - this.options = options || {}; - }, - - - // Shows the popover on the specified position. Renders it if not already - show: function() { - if (this.isHidden) { - if (!this.el) { - this.render(); - } - this.el.show(); - this.position(); - this.isHidden = false; - this.trigger('show'); - } - }, - - - // Hides the popover, through CSS, but does not remove it from the DOM - hide: function() { - if (!this.isHidden) { - this.el.hide(); - this.isHidden = true; - this.trigger('hide'); - } - }, - - - // Creates `this.el` and renders content inside of it - render: function() { - var _this = this; - var options = this.options; - - this.el = $('<div class="fc-popover"/>') - .addClass(options.className || '') - .css({ - // position initially to the top left to avoid creating scrollbars - top: 0, - left: 0 - }) - .append(options.content) - .appendTo(options.parentEl); - - // when a click happens on anything inside with a 'fc-close' className, hide the popover - this.el.on('click', '.fc-close', function() { - _this.hide(); - }); - - if (options.autoHide) { - $(document).on('mousedown', this.documentMousedownProxy = proxy(this, 'documentMousedown')); - } - }, - - - // Triggered when the user clicks *anywhere* in the document, for the autoHide feature - documentMousedown: function(ev) { - // only hide the popover if the click happened outside the popover - if (this.el && !$(ev.target).closest(this.el).length) { - this.hide(); - } - }, - - - // Hides and unregisters any handlers - removeElement: function() { - this.hide(); - - if (this.el) { - this.el.remove(); - this.el = null; - } - - $(document).off('mousedown', this.documentMousedownProxy); - }, - - - // Positions the popover optimally, using the top/left/right options - position: function() { - var options = this.options; - var origin = this.el.offsetParent().offset(); - var width = this.el.outerWidth(); - var height = this.el.outerHeight(); - var windowEl = $(window); - var viewportEl = getScrollParent(this.el); - var viewportTop; - var viewportLeft; - var viewportOffset; - var top; // the "position" (not "offset") values for the popover - var left; // - - // compute top and left - top = options.top || 0; - if (options.left !== undefined) { - left = options.left; - } - else if (options.right !== undefined) { - left = options.right - width; // derive the left value from the right value - } - else { - left = 0; - } - - if (viewportEl.is(window) || viewportEl.is(document)) { // normalize getScrollParent's result - viewportEl = windowEl; - viewportTop = 0; // the window is always at the top left - viewportLeft = 0; // (and .offset() won't work if called here) - } - else { - viewportOffset = viewportEl.offset(); - viewportTop = viewportOffset.top; - viewportLeft = viewportOffset.left; - } - - // if the window is scrolled, it causes the visible area to be further down - viewportTop += windowEl.scrollTop(); - viewportLeft += windowEl.scrollLeft(); - - // constrain to the view port. if constrained by two edges, give precedence to top/left - if (options.viewportConstrain !== false) { - top = Math.min(top, viewportTop + viewportEl.outerHeight() - height - this.margin); - top = Math.max(top, viewportTop + this.margin); - left = Math.min(left, viewportLeft + viewportEl.outerWidth() - width - this.margin); - left = Math.max(left, viewportLeft + this.margin); - } - - this.el.css({ - top: top - origin.top, - left: left - origin.left - }); - }, - - - // Triggers a callback. Calls a function in the option hash of the same name. - // Arguments beyond the first `name` are forwarded on. - // TODO: better code reuse for this. Repeat code - trigger: function(name) { - if (this.options[name]) { - this.options[name].apply(this, Array.prototype.slice.call(arguments, 1)); - } - } - -}); - -;; - -/* A "coordinate map" converts pixel coordinates into an associated cell, which has an associated date ------------------------------------------------------------------------------------------------------------------------- -Common interface: - - CoordMap.prototype = { - build: function() {}, - getCell: function(x, y) {} - }; - -*/ - -/* Coordinate map for a grid component -----------------------------------------------------------------------------------------------------------------------*/ - -var GridCoordMap = Class.extend({ - - grid: null, // reference to the Grid - rowCoords: null, // array of {top,bottom} objects - colCoords: null, // array of {left,right} objects - - containerEl: null, // container element that all coordinates are constrained to. optionally assigned - bounds: null, - - - constructor: function(grid) { - this.grid = grid; - }, - - - // Queries the grid for the coordinates of all the cells - build: function() { - this.grid.build(); - this.rowCoords = this.grid.computeRowCoords(); - this.colCoords = this.grid.computeColCoords(); - this.computeBounds(); - }, - - - // Clears the coordinates data to free up memory - clear: function() { - this.grid.clear(); - this.rowCoords = null; - this.colCoords = null; - }, - - - // Given a coordinate of the document, gets the associated cell. If no cell is underneath, returns null - getCell: function(x, y) { - var rowCoords = this.rowCoords; - var rowCnt = rowCoords.length; - var colCoords = this.colCoords; - var colCnt = colCoords.length; - var hitRow = null; - var hitCol = null; - var i, coords; - var cell; - - if (this.inBounds(x, y)) { - - for (i = 0; i < rowCnt; i++) { - coords = rowCoords[i]; - if (y >= coords.top && y < coords.bottom) { - hitRow = i; - break; - } - } - - for (i = 0; i < colCnt; i++) { - coords = colCoords[i]; - if (x >= coords.left && x < coords.right) { - hitCol = i; - break; - } - } - - if (hitRow !== null && hitCol !== null) { - - cell = this.grid.getCell(hitRow, hitCol); // expected to return a fresh object we can modify - cell.grid = this.grid; // for CellDragListener's isCellsEqual. dragging between grids - - // make the coordinates available on the cell object - $.extend(cell, rowCoords[hitRow], colCoords[hitCol]); - - return cell; - } - } - - return null; - }, - - - // If there is a containerEl, compute the bounds into min/max values - computeBounds: function() { - this.bounds = this.containerEl ? - getClientRect(this.containerEl) : // area within scrollbars - null; - }, - - - // Determines if the given coordinates are in bounds. If no `containerEl`, always true - inBounds: function(x, y) { - var bounds = this.bounds; - - if (bounds) { - return x >= bounds.left && x < bounds.right && y >= bounds.top && y < bounds.bottom; - } - - return true; - } - -}); - - -/* Coordinate map that is a combination of multiple other coordinate maps -----------------------------------------------------------------------------------------------------------------------*/ - -var ComboCoordMap = Class.extend({ - - coordMaps: null, // an array of CoordMaps - - - constructor: function(coordMaps) { - this.coordMaps = coordMaps; - }, - - - // Builds all coordMaps - build: function() { - var coordMaps = this.coordMaps; - var i; - - for (i = 0; i < coordMaps.length; i++) { - coordMaps[i].build(); - } - }, - - - // Queries all coordMaps for the cell underneath the given coordinates, returning the first result - getCell: function(x, y) { - var coordMaps = this.coordMaps; - var cell = null; - var i; - - for (i = 0; i < coordMaps.length && !cell; i++) { - cell = coordMaps[i].getCell(x, y); - } - - return cell; - }, - - - // Clears all coordMaps - clear: function() { - var coordMaps = this.coordMaps; - var i; - - for (i = 0; i < coordMaps.length; i++) { - coordMaps[i].clear(); - } - } - -}); - -;; - -/* Tracks a drag's mouse movement, firing various handlers -----------------------------------------------------------------------------------------------------------------------*/ - -var DragListener = fc.DragListener = Class.extend({ - - options: null, - - isListening: false, - isDragging: false, - - // coordinates of the initial mousedown - originX: null, - originY: null, - - // handler attached to the document, bound to the DragListener's `this` - mousemoveProxy: null, - mouseupProxy: null, - - // for IE8 bug-fighting behavior, for now - subjectEl: null, // the element being draged. optional - subjectHref: null, - - scrollEl: null, - scrollBounds: null, // { top, bottom, left, right } - scrollTopVel: null, // pixels per second - scrollLeftVel: null, // pixels per second - scrollIntervalId: null, // ID of setTimeout for scrolling animation loop - scrollHandlerProxy: null, // this-scoped function for handling when scrollEl is scrolled - - scrollSensitivity: 30, // pixels from edge for scrolling to start - scrollSpeed: 200, // pixels per second, at maximum speed - scrollIntervalMs: 50, // millisecond wait between scroll increment - - - constructor: function(options) { - options = options || {}; - this.options = options; - this.subjectEl = options.subjectEl; - }, - - - // Call this when the user does a mousedown. Will probably lead to startListening - mousedown: function(ev) { - if (isPrimaryMouseButton(ev)) { - - ev.preventDefault(); // prevents native selection in most browsers - - this.startListening(ev); - - // start the drag immediately if there is no minimum distance for a drag start - if (!this.options.distance) { - this.startDrag(ev); - } - } - }, - - - // Call this to start tracking mouse movements - startListening: function(ev) { - var scrollParent; - - if (!this.isListening) { - - // grab scroll container and attach handler - if (ev && this.options.scroll) { - scrollParent = getScrollParent($(ev.target)); - if (!scrollParent.is(window) && !scrollParent.is(document)) { - this.scrollEl = scrollParent; - - // scope to `this`, and use `debounce` to make sure rapid calls don't happen - this.scrollHandlerProxy = debounce(proxy(this, 'scrollHandler'), 100); - this.scrollEl.on('scroll', this.scrollHandlerProxy); - } - } - - $(document) - .on('mousemove', this.mousemoveProxy = proxy(this, 'mousemove')) - .on('mouseup', this.mouseupProxy = proxy(this, 'mouseup')) - .on('selectstart', this.preventDefault); // prevents native selection in IE<=8 - - if (ev) { - this.originX = ev.pageX; - this.originY = ev.pageY; - } - else { - // if no starting information was given, origin will be the topleft corner of the screen. - // if so, dx/dy in the future will be the absolute coordinates. - this.originX = 0; - this.originY = 0; - } - - this.isListening = true; - this.listenStart(ev); - } - }, - - - // Called when drag listening has started (but a real drag has not necessarily began) - listenStart: function(ev) { - this.trigger('listenStart', ev); - }, - - - // Called when the user moves the mouse - mousemove: function(ev) { - var dx = ev.pageX - this.originX; - var dy = ev.pageY - this.originY; - var minDistance; - var distanceSq; // current distance from the origin, squared - - if (!this.isDragging) { // if not already dragging... - // then start the drag if the minimum distance criteria is met - minDistance = this.options.distance || 1; - distanceSq = dx * dx + dy * dy; - if (distanceSq >= minDistance * minDistance) { // use pythagorean theorem - this.startDrag(ev); - } - } - - if (this.isDragging) { - this.drag(dx, dy, ev); // report a drag, even if this mousemove initiated the drag - } - }, - - - // Call this to initiate a legitimate drag. - // This function is called internally from this class, but can also be called explicitly from outside - startDrag: function(ev) { - - if (!this.isListening) { // startDrag must have manually initiated - this.startListening(); - } - - if (!this.isDragging) { - this.isDragging = true; - this.dragStart(ev); - } - }, - - - // Called when the actual drag has started (went beyond minDistance) - dragStart: function(ev) { - var subjectEl = this.subjectEl; - - this.trigger('dragStart', ev); - - // remove a mousedown'd <a>'s href so it is not visited (IE8 bug) - if ((this.subjectHref = subjectEl ? subjectEl.attr('href') : null)) { - subjectEl.removeAttr('href'); - } - }, - - - // Called while the mouse is being moved and when we know a legitimate drag is taking place - drag: function(dx, dy, ev) { - this.trigger('drag', dx, dy, ev); - this.updateScroll(ev); // will possibly cause scrolling - }, - - - // Called when the user does a mouseup - mouseup: function(ev) { - this.stopListening(ev); - }, - - - // Called when the drag is over. Will not cause listening to stop however. - // A concluding 'cellOut' event will NOT be triggered. - stopDrag: function(ev) { - if (this.isDragging) { - this.stopScrolling(); - this.dragStop(ev); - this.isDragging = false; - } - }, - - - // Called when dragging has been stopped - dragStop: function(ev) { - var _this = this; - - this.trigger('dragStop', ev); - - // restore a mousedown'd <a>'s href (for IE8 bug) - setTimeout(function() { // must be outside of the click's execution - if (_this.subjectHref) { - _this.subjectEl.attr('href', _this.subjectHref); - } - }, 0); - }, - - - // Call this to stop listening to the user's mouse events - stopListening: function(ev) { - this.stopDrag(ev); // if there's a current drag, kill it - - if (this.isListening) { - - // remove the scroll handler if there is a scrollEl - if (this.scrollEl) { - this.scrollEl.off('scroll', this.scrollHandlerProxy); - this.scrollHandlerProxy = null; - } - - $(document) - .off('mousemove', this.mousemoveProxy) - .off('mouseup', this.mouseupProxy) - .off('selectstart', this.preventDefault); - - this.mousemoveProxy = null; - this.mouseupProxy = null; - - this.isListening = false; - this.listenStop(ev); - } - }, - - - // Called when drag listening has stopped - listenStop: function(ev) { - this.trigger('listenStop', ev); - }, - - - // Triggers a callback. Calls a function in the option hash of the same name. - // Arguments beyond the first `name` are forwarded on. - trigger: function(name) { - if (this.options[name]) { - this.options[name].apply(this, Array.prototype.slice.call(arguments, 1)); - } - }, - - - // Stops a given mouse event from doing it's native browser action. In our case, text selection. - preventDefault: function(ev) { - ev.preventDefault(); - }, - - - /* Scrolling - ------------------------------------------------------------------------------------------------------------------*/ - - - // Computes and stores the bounding rectangle of scrollEl - computeScrollBounds: function() { - var el = this.scrollEl; - - this.scrollBounds = el ? getOuterRect(el) : null; - // TODO: use getClientRect in future. but prevents auto scrolling when on top of scrollbars - }, - - - // Called when the dragging is in progress and scrolling should be updated - updateScroll: function(ev) { - var sensitivity = this.scrollSensitivity; - var bounds = this.scrollBounds; - var topCloseness, bottomCloseness; - var leftCloseness, rightCloseness; - var topVel = 0; - var leftVel = 0; - - if (bounds) { // only scroll if scrollEl exists - - // compute closeness to edges. valid range is from 0.0 - 1.0 - topCloseness = (sensitivity - (ev.pageY - bounds.top)) / sensitivity; - bottomCloseness = (sensitivity - (bounds.bottom - ev.pageY)) / sensitivity; - leftCloseness = (sensitivity - (ev.pageX - bounds.left)) / sensitivity; - rightCloseness = (sensitivity - (bounds.right - ev.pageX)) / sensitivity; - - // translate vertical closeness into velocity. - // mouse must be completely in bounds for velocity to happen. - if (topCloseness >= 0 && topCloseness <= 1) { - topVel = topCloseness * this.scrollSpeed * -1; // negative. for scrolling up - } - else if (bottomCloseness >= 0 && bottomCloseness <= 1) { - topVel = bottomCloseness * this.scrollSpeed; - } - - // translate horizontal closeness into velocity - if (leftCloseness >= 0 && leftCloseness <= 1) { - leftVel = leftCloseness * this.scrollSpeed * -1; // negative. for scrolling left - } - else if (rightCloseness >= 0 && rightCloseness <= 1) { - leftVel = rightCloseness * this.scrollSpeed; - } - } - - this.setScrollVel(topVel, leftVel); - }, - - - // Sets the speed-of-scrolling for the scrollEl - setScrollVel: function(topVel, leftVel) { - - this.scrollTopVel = topVel; - this.scrollLeftVel = leftVel; - - this.constrainScrollVel(); // massages into realistic values - - // if there is non-zero velocity, and an animation loop hasn't already started, then START - if ((this.scrollTopVel || this.scrollLeftVel) && !this.scrollIntervalId) { - this.scrollIntervalId = setInterval( - proxy(this, 'scrollIntervalFunc'), // scope to `this` - this.scrollIntervalMs - ); - } - }, - - - // Forces scrollTopVel and scrollLeftVel to be zero if scrolling has already gone all the way - constrainScrollVel: function() { - var el = this.scrollEl; - - if (this.scrollTopVel < 0) { // scrolling up? - if (el.scrollTop() <= 0) { // already scrolled all the way up? - this.scrollTopVel = 0; - } - } - else if (this.scrollTopVel > 0) { // scrolling down? - if (el.scrollTop() + el[0].clientHeight >= el[0].scrollHeight) { // already scrolled all the way down? - this.scrollTopVel = 0; - } - } - - if (this.scrollLeftVel < 0) { // scrolling left? - if (el.scrollLeft() <= 0) { // already scrolled all the left? - this.scrollLeftVel = 0; - } - } - else if (this.scrollLeftVel > 0) { // scrolling right? - if (el.scrollLeft() + el[0].clientWidth >= el[0].scrollWidth) { // already scrolled all the way right? - this.scrollLeftVel = 0; - } - } - }, - - - // This function gets called during every iteration of the scrolling animation loop - scrollIntervalFunc: function() { - var el = this.scrollEl; - var frac = this.scrollIntervalMs / 1000; // considering animation frequency, what the vel should be mult'd by - - // change the value of scrollEl's scroll - if (this.scrollTopVel) { - el.scrollTop(el.scrollTop() + this.scrollTopVel * frac); - } - if (this.scrollLeftVel) { - el.scrollLeft(el.scrollLeft() + this.scrollLeftVel * frac); - } - - this.constrainScrollVel(); // since the scroll values changed, recompute the velocities - - // if scrolled all the way, which causes the vels to be zero, stop the animation loop - if (!this.scrollTopVel && !this.scrollLeftVel) { - this.stopScrolling(); - } - }, - - - // Kills any existing scrolling animation loop - stopScrolling: function() { - if (this.scrollIntervalId) { - clearInterval(this.scrollIntervalId); - this.scrollIntervalId = null; - - // when all done with scrolling, recompute positions since they probably changed - this.scrollStop(); - } - }, - - - // Get called when the scrollEl is scrolled (NOTE: this is delayed via debounce) - scrollHandler: function() { - // recompute all coordinates, but *only* if this is *not* part of our scrolling animation - if (!this.scrollIntervalId) { - this.scrollStop(); - } - }, - - - // Called when scrolling has stopped, whether through auto scroll, or the user scrolling - scrollStop: function() { - } - -}); - -;; - -/* Tracks mouse movements over a CoordMap and raises events about which cell the mouse is over. ------------------------------------------------------------------------------------------------------------------------- -options: -- subjectEl -- subjectCenter -*/ - -var CellDragListener = DragListener.extend({ - - coordMap: null, // converts coordinates to date cells - origCell: null, // the cell the mouse was over when listening started - cell: null, // the cell the mouse is over - coordAdjust: null, // delta that will be added to the mouse coordinates when computing collisions - - - constructor: function(coordMap, options) { - DragListener.prototype.constructor.call(this, options); // call the super-constructor - - this.coordMap = coordMap; - }, - - - // Called when drag listening starts (but a real drag has not necessarily began). - // ev might be undefined if dragging was started manually. - listenStart: function(ev) { - var subjectEl = this.subjectEl; - var subjectRect; - var origPoint; - var point; - - DragListener.prototype.listenStart.apply(this, arguments); // call the super-method - - this.computeCoords(); - - if (ev) { - origPoint = { left: ev.pageX, top: ev.pageY }; - point = origPoint; - - // constrain the point to bounds of the element being dragged - if (subjectEl) { - subjectRect = getOuterRect(subjectEl); // used for centering as well - point = constrainPoint(point, subjectRect); - } - - this.origCell = this.getCell(point.left, point.top); - - // treat the center of the subject as the collision point? - if (subjectEl && this.options.subjectCenter) { - - // only consider the area the subject overlaps the cell. best for large subjects - if (this.origCell) { - subjectRect = intersectRects(this.origCell, subjectRect) || - subjectRect; // in case there is no intersection - } - - point = getRectCenter(subjectRect); - } - - this.coordAdjust = diffPoints(point, origPoint); // point - origPoint - } - else { - this.origCell = null; - this.coordAdjust = null; - } - }, - - - // Recomputes the drag-critical positions of elements - computeCoords: function() { - this.coordMap.build(); - this.computeScrollBounds(); - }, - - - // Called when the actual drag has started - dragStart: function(ev) { - var cell; - - DragListener.prototype.dragStart.apply(this, arguments); // call the super-method - - cell = this.getCell(ev.pageX, ev.pageY); // might be different from this.origCell if the min-distance is large - - // report the initial cell the mouse is over - // especially important if no min-distance and drag starts immediately - if (cell) { - this.cellOver(cell); - } - }, - - - // Called when the drag moves - drag: function(dx, dy, ev) { - var cell; - - DragListener.prototype.drag.apply(this, arguments); // call the super-method - - cell = this.getCell(ev.pageX, ev.pageY); - - if (!isCellsEqual(cell, this.cell)) { // a different cell than before? - if (this.cell) { - this.cellOut(); - } - if (cell) { - this.cellOver(cell); - } - } - }, - - - // Called when dragging has been stopped - dragStop: function() { - this.cellDone(); - DragListener.prototype.dragStop.apply(this, arguments); // call the super-method - }, - - - // Called when a the mouse has just moved over a new cell - cellOver: function(cell) { - this.cell = cell; - this.trigger('cellOver', cell, isCellsEqual(cell, this.origCell), this.origCell); - }, - - - // Called when the mouse has just moved out of a cell - cellOut: function() { - if (this.cell) { - this.trigger('cellOut', this.cell); - this.cellDone(); - this.cell = null; - } - }, - - - // Called after a cellOut. Also called before a dragStop - cellDone: function() { - if (this.cell) { - this.trigger('cellDone', this.cell); - } - }, - - - // Called when drag listening has stopped - listenStop: function() { - DragListener.prototype.listenStop.apply(this, arguments); // call the super-method - - this.origCell = this.cell = null; - this.coordMap.clear(); - }, - - - // Called when scrolling has stopped, whether through auto scroll, or the user scrolling - scrollStop: function() { - DragListener.prototype.scrollStop.apply(this, arguments); // call the super-method - - this.computeCoords(); // cells' absolute positions will be in new places. recompute - }, - - - // Gets the cell underneath the coordinates for the given mouse event - getCell: function(left, top) { - - if (this.coordAdjust) { - left += this.coordAdjust.left; - top += this.coordAdjust.top; - } - - return this.coordMap.getCell(left, top); - } - -}); - - -// Returns `true` if the cells are identically equal. `false` otherwise. -// They must have the same row, col, and be from the same grid. -// Two null values will be considered equal, as two "out of the grid" states are the same. -function isCellsEqual(cell1, cell2) { - - if (!cell1 && !cell2) { - return true; - } - - if (cell1 && cell2) { - return cell1.grid === cell2.grid && - cell1.row === cell2.row && - cell1.col === cell2.col; - } - - return false; -} - -;; - -/* Creates a clone of an element and lets it track the mouse as it moves -----------------------------------------------------------------------------------------------------------------------*/ - -var MouseFollower = Class.extend({ - - options: null, - - sourceEl: null, // the element that will be cloned and made to look like it is dragging - el: null, // the clone of `sourceEl` that will track the mouse - parentEl: null, // the element that `el` (the clone) will be attached to - - // the initial position of el, relative to the offset parent. made to match the initial offset of sourceEl - top0: null, - left0: null, - - // the initial position of the mouse - mouseY0: null, - mouseX0: null, - - // the number of pixels the mouse has moved from its initial position - topDelta: null, - leftDelta: null, - - mousemoveProxy: null, // document mousemove handler, bound to the MouseFollower's `this` - - isFollowing: false, - isHidden: false, - isAnimating: false, // doing the revert animation? - - constructor: function(sourceEl, options) { - this.options = options = options || {}; - this.sourceEl = sourceEl; - this.parentEl = options.parentEl ? $(options.parentEl) : sourceEl.parent(); // default to sourceEl's parent - }, - - - // Causes the element to start following the mouse - start: function(ev) { - if (!this.isFollowing) { - this.isFollowing = true; - - this.mouseY0 = ev.pageY; - this.mouseX0 = ev.pageX; - this.topDelta = 0; - this.leftDelta = 0; - - if (!this.isHidden) { - this.updatePosition(); - } - - $(document).on('mousemove', this.mousemoveProxy = proxy(this, 'mousemove')); - } - }, - - - // Causes the element to stop following the mouse. If shouldRevert is true, will animate back to original position. - // `callback` gets invoked when the animation is complete. If no animation, it is invoked immediately. - stop: function(shouldRevert, callback) { - var _this = this; - var revertDuration = this.options.revertDuration; - - function complete() { - this.isAnimating = false; - _this.removeElement(); - - this.top0 = this.left0 = null; // reset state for future updatePosition calls - - if (callback) { - callback(); - } - } - - if (this.isFollowing && !this.isAnimating) { // disallow more than one stop animation at a time - this.isFollowing = false; - - $(document).off('mousemove', this.mousemoveProxy); - - if (shouldRevert && revertDuration && !this.isHidden) { // do a revert animation? - this.isAnimating = true; - this.el.animate({ - top: this.top0, - left: this.left0 - }, { - duration: revertDuration, - complete: complete - }); - } - else { - complete(); - } - } - }, - - - // Gets the tracking element. Create it if necessary - getEl: function() { - var el = this.el; - - if (!el) { - this.sourceEl.width(); // hack to force IE8 to compute correct bounding box - el = this.el = this.sourceEl.clone() - .css({ - position: 'absolute', - visibility: '', // in case original element was hidden (commonly through hideEvents()) - display: this.isHidden ? 'none' : '', // for when initially hidden - margin: 0, - right: 'auto', // erase and set width instead - bottom: 'auto', // erase and set height instead - width: this.sourceEl.width(), // explicit height in case there was a 'right' value - height: this.sourceEl.height(), // explicit width in case there was a 'bottom' value - opacity: this.options.opacity || '', - zIndex: this.options.zIndex - }) - .appendTo(this.parentEl); - } - - return el; - }, - - - // Removes the tracking element if it has already been created - removeElement: function() { - if (this.el) { - this.el.remove(); - this.el = null; - } - }, - - - // Update the CSS position of the tracking element - updatePosition: function() { - var sourceOffset; - var origin; - - this.getEl(); // ensure this.el - - // make sure origin info was computed - if (this.top0 === null) { - this.sourceEl.width(); // hack to force IE8 to compute correct bounding box - sourceOffset = this.sourceEl.offset(); - origin = this.el.offsetParent().offset(); - this.top0 = sourceOffset.top - origin.top; - this.left0 = sourceOffset.left - origin.left; - } - - this.el.css({ - top: this.top0 + this.topDelta, - left: this.left0 + this.leftDelta - }); - }, - - - // Gets called when the user moves the mouse - mousemove: function(ev) { - this.topDelta = ev.pageY - this.mouseY0; - this.leftDelta = ev.pageX - this.mouseX0; - - if (!this.isHidden) { - this.updatePosition(); - } - }, - - - // Temporarily makes the tracking element invisible. Can be called before following starts - hide: function() { - if (!this.isHidden) { - this.isHidden = true; - if (this.el) { - this.el.hide(); - } - } - }, - - - // Show the tracking element after it has been temporarily hidden - show: function() { - if (this.isHidden) { - this.isHidden = false; - this.updatePosition(); - this.getEl().show(); - } - } - -}); - -;; - -/* A utility class for rendering <tr> rows. -----------------------------------------------------------------------------------------------------------------------*/ -// It leverages methods of the subclass and the View to determine custom rendering behavior for each row "type" -// (such as highlight rows, day rows, helper rows, etc). - -var RowRenderer = Class.extend({ - - view: null, // a View object - isRTL: null, // shortcut to the view's isRTL option - cellHtml: '<td/>', // plain default HTML used for a cell when no other is available - - - constructor: function(view) { - this.view = view; - this.isRTL = view.opt('isRTL'); - }, - - - // Renders the HTML for a row, leveraging custom cell-HTML-renderers based on the `rowType`. - // Also applies the "intro" and "outro" cells, which are specified by the subclass and views. - // `row` is an optional row number. - rowHtml: function(rowType, row) { - var renderCell = this.getHtmlRenderer('cell', rowType); - var rowCellHtml = ''; - var col; - var cell; - - row = row || 0; - - for (col = 0; col < this.colCnt; col++) { - cell = this.getCell(row, col); - rowCellHtml += renderCell(cell); - } - - rowCellHtml = this.bookendCells(rowCellHtml, rowType, row); // apply intro and outro - - return '<tr>' + rowCellHtml + '</tr>'; - }, - - - // Applies the "intro" and "outro" HTML to the given cells. - // Intro means the leftmost cell when the calendar is LTR and the rightmost cell when RTL. Vice-versa for outro. - // `cells` can be an HTML string of <td>'s or a jQuery <tr> element - // `row` is an optional row number. - bookendCells: function(cells, rowType, row) { - var intro = this.getHtmlRenderer('intro', rowType)(row || 0); - var outro = this.getHtmlRenderer('outro', rowType)(row || 0); - var prependHtml = this.isRTL ? outro : intro; - var appendHtml = this.isRTL ? intro : outro; - - if (typeof cells === 'string') { - return prependHtml + cells + appendHtml; - } - else { // a jQuery <tr> element - return cells.prepend(prependHtml).append(appendHtml); - } - }, - - - // Returns an HTML-rendering function given a specific `rendererName` (like cell, intro, or outro) and a specific - // `rowType` (like day, eventSkeleton, helperSkeleton), which is optional. - // If a renderer for the specific rowType doesn't exist, it will fall back to a generic renderer. - // We will query the View object first for any custom rendering functions, then the methods of the subclass. - getHtmlRenderer: function(rendererName, rowType) { - var view = this.view; - var generalName; // like "cellHtml" - var specificName; // like "dayCellHtml". based on rowType - var provider; // either the View or the RowRenderer subclass, whichever provided the method - var renderer; - - generalName = rendererName + 'Html'; - if (rowType) { - specificName = rowType + capitaliseFirstLetter(rendererName) + 'Html'; - } - - if (specificName && (renderer = view[specificName])) { - provider = view; - } - else if (specificName && (renderer = this[specificName])) { - provider = this; - } - else if ((renderer = view[generalName])) { - provider = view; - } - else if ((renderer = this[generalName])) { - provider = this; - } - - if (typeof renderer === 'function') { - return function() { - return renderer.apply(provider, arguments) || ''; // use correct `this` and always return a string - }; - } - - // the rendered can be a plain string as well. if not specified, always an empty string. - return function() { - return renderer || ''; - }; - } - -}); - -;; - -/* An abstract class comprised of a "grid" of cells that each represent a specific datetime -----------------------------------------------------------------------------------------------------------------------*/ - -var Grid = fc.Grid = RowRenderer.extend({ - - start: null, // the date of the first cell - end: null, // the date after the last cell - - rowCnt: 0, // number of rows - colCnt: 0, // number of cols - - el: null, // the containing element - coordMap: null, // a GridCoordMap that converts pixel values to datetimes - elsByFill: null, // a hash of jQuery element sets used for rendering each fill. Keyed by fill name. - - externalDragStartProxy: null, // binds the Grid's scope to externalDragStart (in DayGrid.events) - - // derived from options - colHeadFormat: null, // TODO: move to another class. not applicable to all Grids - eventTimeFormat: null, - displayEventTime: null, - displayEventEnd: null, - - // if all cells are the same length of time, the duration they all share. optional. - // when defined, allows the computeCellRange shortcut, as well as improved resizing behavior. - cellDuration: null, - - // if defined, holds the unit identified (ex: "year" or "month") that determines the level of granularity - // of the date cells. if not defined, assumes to be day and time granularity. - largeUnit: null, - - - constructor: function() { - RowRenderer.apply(this, arguments); // call the super-constructor - - this.coordMap = new GridCoordMap(this); - this.elsByFill = {}; - this.externalDragStartProxy = proxy(this, 'externalDragStart'); - }, - - - /* Options - ------------------------------------------------------------------------------------------------------------------*/ - - - // Generates the format string used for the text in column headers, if not explicitly defined by 'columnFormat' - // TODO: move to another class. not applicable to all Grids - computeColHeadFormat: function() { - // subclasses must implement if they want to use headHtml() - }, - - - // Generates the format string used for event time text, if not explicitly defined by 'timeFormat' - computeEventTimeFormat: function() { - return this.view.opt('smallTimeFormat'); - }, - - - // Determines whether events should have their end times displayed, if not explicitly defined by 'displayEventTime'. - // Only applies to non-all-day events. - computeDisplayEventTime: function() { - return true; - }, - - - // Determines whether events should have their end times displayed, if not explicitly defined by 'displayEventEnd' - computeDisplayEventEnd: function() { - return true; - }, - - - /* Dates - ------------------------------------------------------------------------------------------------------------------*/ - - - // Tells the grid about what period of time to display. - // Any date-related cell system internal data should be generated. - setRange: function(range) { - this.start = range.start.clone(); - this.end = range.end.clone(); - - this.rangeUpdated(); - this.processRangeOptions(); - }, - - - // Called when internal variables that rely on the range should be updated - rangeUpdated: function() { - }, - - - // Updates values that rely on options and also relate to range - processRangeOptions: function() { - var view = this.view; - var displayEventTime; - var displayEventEnd; - - // Populate option-derived settings. Look for override first, then compute if necessary. - this.colHeadFormat = view.opt('columnFormat') || this.computeColHeadFormat(); - - this.eventTimeFormat = - view.opt('eventTimeFormat') || - view.opt('timeFormat') || // deprecated - this.computeEventTimeFormat(); - - displayEventTime = view.opt('displayEventTime'); - if (displayEventTime == null) { - displayEventTime = this.computeDisplayEventTime(); // might be based off of range - } - - displayEventEnd = view.opt('displayEventEnd'); - if (displayEventEnd == null) { - displayEventEnd = this.computeDisplayEventEnd(); // might be based off of range - } - - this.displayEventTime = displayEventTime; - this.displayEventEnd = displayEventEnd; - }, - - - // Called before the grid's coordinates will need to be queried for cells. - // Any non-date-related cell system internal data should be built. - build: function() { - }, - - - // Called after the grid's coordinates are done being relied upon. - // Any non-date-related cell system internal data should be cleared. - clear: function() { - }, - - - // Converts a range with an inclusive `start` and an exclusive `end` into an array of segment objects - rangeToSegs: function(range) { - // subclasses must implement - }, - - - // Diffs the two dates, returning a duration, based on granularity of the grid - diffDates: function(a, b) { - if (this.largeUnit) { - return diffByUnit(a, b, this.largeUnit); - } - else { - return diffDayTime(a, b); - } - }, - - - /* Cells - ------------------------------------------------------------------------------------------------------------------*/ - // NOTE: columns are ordered left-to-right - - - // Gets an object containing row/col number, misc data, and range information about the cell. - // Accepts row/col values, an object with row/col properties, or a single-number offset from the first cell. - getCell: function(row, col) { - var cell; - - if (col == null) { - if (typeof row === 'number') { // a single-number offset - col = row % this.colCnt; - row = Math.floor(row / this.colCnt); - } - else { // an object with row/col properties - col = row.col; - row = row.row; - } - } - - cell = { row: row, col: col }; - - $.extend(cell, this.getRowData(row), this.getColData(col)); - $.extend(cell, this.computeCellRange(cell)); - - return cell; - }, - - - // Given a cell object with index and misc data, generates a range object - // If the grid is leveraging cellDuration, this doesn't need to be defined. Only computeCellDate does. - // If being overridden, should return a range with reference-free date copies. - computeCellRange: function(cell) { - var date = this.computeCellDate(cell); - - return { - start: date, - end: date.clone().add(this.cellDuration) - }; - }, - - - // Given a cell, returns its start date. Should return a reference-free date copy. - computeCellDate: function(cell) { - // subclasses can implement - }, - - - // Retrieves misc data about the given row - getRowData: function(row) { - return {}; - }, - - - // Retrieves misc data baout the given column - getColData: function(col) { - return {}; - }, - - - // Retrieves the element representing the given row - getRowEl: function(row) { - // subclasses should implement if leveraging the default getCellDayEl() or computeRowCoords() - }, - - - // Retrieves the element representing the given column - getColEl: function(col) { - // subclasses should implement if leveraging the default getCellDayEl() or computeColCoords() - }, - - - // Given a cell object, returns the element that represents the cell's whole-day - getCellDayEl: function(cell) { - return this.getColEl(cell.col) || this.getRowEl(cell.row); - }, - - - /* Cell Coordinates - ------------------------------------------------------------------------------------------------------------------*/ - - - // Computes the top/bottom coordinates of all rows. - // By default, queries the dimensions of the element provided by getRowEl(). - computeRowCoords: function() { - var items = []; - var i, el; - var top; - - for (i = 0; i < this.rowCnt; i++) { - el = this.getRowEl(i); - top = el.offset().top; - items.push({ - top: top, - bottom: top + el.outerHeight() - }); - } - - return items; - }, - - - // Computes the left/right coordinates of all rows. - // By default, queries the dimensions of the element provided by getColEl(). Columns can be LTR or RTL. - computeColCoords: function() { - var items = []; - var i, el; - var left; - - for (i = 0; i < this.colCnt; i++) { - el = this.getColEl(i); - left = el.offset().left; - items.push({ - left: left, - right: left + el.outerWidth() - }); - } - - return items; - }, - - - /* Rendering - ------------------------------------------------------------------------------------------------------------------*/ - - - // Sets the container element that the grid should render inside of. - // Does other DOM-related initializations. - setElement: function(el) { - var _this = this; - - this.el = el; - - // attach a handler to the grid's root element. - // jQuery will take care of unregistering them when removeElement gets called. - el.on('mousedown', function(ev) { - if ( - !$(ev.target).is('.fc-event-container *, .fc-more') && // not an an event element, or "more.." link - !$(ev.target).closest('.fc-popover').length // not on a popover (like the "more.." events one) - ) { - _this.dayMousedown(ev); - } - }); - - // attach event-element-related handlers. in Grid.events - // same garbage collection note as above. - this.bindSegHandlers(); - - this.bindGlobalHandlers(); - }, - - - // Removes the grid's container element from the DOM. Undoes any other DOM-related attachments. - // DOES NOT remove any content beforehand (doesn't clear events or call unrenderDates), unlike View - removeElement: function() { - this.unbindGlobalHandlers(); - - this.el.remove(); - - // NOTE: we don't null-out this.el for the same reasons we don't do it within View::removeElement - }, - - - // Renders the basic structure of grid view before any content is rendered - renderSkeleton: function() { - // subclasses should implement - }, - - - // Renders the grid's date-related content (like cells that represent days/times). - // Assumes setRange has already been called and the skeleton has already been rendered. - renderDates: function() { - // subclasses should implement - }, - - - // Unrenders the grid's date-related content - unrenderDates: function() { - // subclasses should implement - }, - - - /* Handlers - ------------------------------------------------------------------------------------------------------------------*/ - - - // Binds DOM handlers to elements that reside outside the grid, such as the document - bindGlobalHandlers: function() { - $(document).on('dragstart sortstart', this.externalDragStartProxy); // jqui - }, - - - // Unbinds DOM handlers from elements that reside outside the grid - unbindGlobalHandlers: function() { - $(document).off('dragstart sortstart', this.externalDragStartProxy); // jqui - }, - - - // Process a mousedown on an element that represents a day. For day clicking and selecting. - dayMousedown: function(ev) { - var _this = this; - var view = this.view; - var isSelectable = view.opt('selectable'); - var dayClickCell; // null if invalid dayClick - var selectionRange; // null if invalid selection - - // this listener tracks a mousedown on a day element, and a subsequent drag. - // if the drag ends on the same day, it is a 'dayClick'. - // if 'selectable' is enabled, this listener also detects selections. - var dragListener = new CellDragListener(this.coordMap, { - //distance: 5, // needs more work if we want dayClick to fire correctly - scroll: view.opt('dragScroll'), - dragStart: function() { - view.unselect(); // since we could be rendering a new selection, we want to clear any old one - }, - cellOver: function(cell, isOrig, origCell) { - if (origCell) { // click needs to have started on a cell - dayClickCell = isOrig ? cell : null; // single-cell selection is a day click - if (isSelectable) { - selectionRange = _this.computeSelection(origCell, cell); - if (selectionRange) { - _this.renderSelection(selectionRange); - } - else { - disableCursor(); - } - } - } - }, - cellOut: function(cell) { - dayClickCell = null; - selectionRange = null; - _this.unrenderSelection(); - enableCursor(); - }, - listenStop: function(ev) { - if (dayClickCell) { - view.triggerDayClick(dayClickCell, _this.getCellDayEl(dayClickCell), ev); - } - if (selectionRange) { - // the selection will already have been rendered. just report it - view.reportSelection(selectionRange, ev); - } - enableCursor(); - } - }); - - dragListener.mousedown(ev); // start listening, which will eventually initiate a dragStart - }, - - - /* Event Helper - ------------------------------------------------------------------------------------------------------------------*/ - // TODO: should probably move this to Grid.events, like we did event dragging / resizing - - - // Renders a mock event over the given range - renderRangeHelper: function(range, sourceSeg) { - var fakeEvent = this.fabricateHelperEvent(range, sourceSeg); - - this.renderHelper(fakeEvent, sourceSeg); // do the actual rendering - }, - - - // Builds a fake event given a date range it should cover, and a segment is should be inspired from. - // The range's end can be null, in which case the mock event that is rendered will have a null end time. - // `sourceSeg` is the internal segment object involved in the drag. If null, something external is dragging. - fabricateHelperEvent: function(range, sourceSeg) { - var fakeEvent = sourceSeg ? createObject(sourceSeg.event) : {}; // mask the original event object if possible - - fakeEvent.start = range.start.clone(); - fakeEvent.end = range.end ? range.end.clone() : null; - fakeEvent.allDay = null; // force it to be freshly computed by normalizeEventRange - this.view.calendar.normalizeEventRange(fakeEvent); - - // this extra className will be useful for differentiating real events from mock events in CSS - fakeEvent.className = (fakeEvent.className || []).concat('fc-helper'); - - // if something external is being dragged in, don't render a resizer - if (!sourceSeg) { - fakeEvent.editable = false; - } - - return fakeEvent; - }, - - - // Renders a mock event - renderHelper: function(event, sourceSeg) { - // subclasses must implement - }, - - - // Unrenders a mock event - unrenderHelper: function() { - // subclasses must implement - }, - - - /* Selection - ------------------------------------------------------------------------------------------------------------------*/ - - - // Renders a visual indication of a selection. Will highlight by default but can be overridden by subclasses. - renderSelection: function(range) { - this.renderHighlight(this.selectionRangeToSegs(range)); - }, - - - // Unrenders any visual indications of a selection. Will unrender a highlight by default. - unrenderSelection: function() { - this.unrenderHighlight(); - }, - - - // Given the first and last cells of a selection, returns a range object. - // Will return something falsy if the selection is invalid (when outside of selectionConstraint for example). - // Subclasses can override and provide additional data in the range object. Will be passed to renderSelection(). - computeSelection: function(firstCell, lastCell) { - var dates = [ - firstCell.start, - firstCell.end, - lastCell.start, - lastCell.end - ]; - var range; - - dates.sort(compareNumbers); // sorts chronologically. works with Moments - - range = { - start: dates[0].clone(), - end: dates[3].clone() - }; - - if (!this.view.calendar.isSelectionRangeAllowed(range)) { - return null; - } - - return range; - }, - - - selectionRangeToSegs: function(range) { - return this.rangeToSegs(range); - }, - - - /* Highlight - ------------------------------------------------------------------------------------------------------------------*/ - - - // Renders an emphasis on the given date range. Given an array of segments. - renderHighlight: function(segs) { - this.renderFill('highlight', segs); - }, - - - // Unrenders the emphasis on a date range - unrenderHighlight: function() { - this.unrenderFill('highlight'); - }, - - - // Generates an array of classNames for rendering the highlight. Used by the fill system. - highlightSegClasses: function() { - return [ 'fc-highlight' ]; - }, - - - /* Fill System (highlight, background events, business hours) - ------------------------------------------------------------------------------------------------------------------*/ - - - // Renders a set of rectangles over the given segments of time. - // MUST RETURN a subset of segs, the segs that were actually rendered. - // Responsible for populating this.elsByFill. TODO: better API for expressing this requirement - renderFill: function(type, segs) { - // subclasses must implement - }, - - - // Unrenders a specific type of fill that is currently rendered on the grid - unrenderFill: function(type) { - var el = this.elsByFill[type]; - - if (el) { - el.remove(); - delete this.elsByFill[type]; - } - }, - - - // Renders and assigns an `el` property for each fill segment. Generic enough to work with different types. - // Only returns segments that successfully rendered. - // To be harnessed by renderFill (implemented by subclasses). - // Analagous to renderFgSegEls. - renderFillSegEls: function(type, segs) { - var _this = this; - var segElMethod = this[type + 'SegEl']; - var html = ''; - var renderedSegs = []; - var i; - - if (segs.length) { - - // build a large concatenation of segment HTML - for (i = 0; i < segs.length; i++) { - html += this.fillSegHtml(type, segs[i]); - } - - // Grab individual elements from the combined HTML string. Use each as the default rendering. - // Then, compute the 'el' for each segment. - $(html).each(function(i, node) { - var seg = segs[i]; - var el = $(node); - - // allow custom filter methods per-type - if (segElMethod) { - el = segElMethod.call(_this, seg, el); - } - - if (el) { // custom filters did not cancel the render - el = $(el); // allow custom filter to return raw DOM node - - // correct element type? (would be bad if a non-TD were inserted into a table for example) - if (el.is(_this.fillSegTag)) { - seg.el = el; - renderedSegs.push(seg); - } - } - }); - } - - return renderedSegs; - }, - - - fillSegTag: 'div', // subclasses can override - - - // Builds the HTML needed for one fill segment. Generic enought o work with different types. - fillSegHtml: function(type, seg) { - - // custom hooks per-type - var classesMethod = this[type + 'SegClasses']; - var cssMethod = this[type + 'SegCss']; - - var classes = classesMethod ? classesMethod.call(this, seg) : []; - var css = cssToStr(cssMethod ? cssMethod.call(this, seg) : {}); - - return '<' + this.fillSegTag + - (classes.length ? ' class="' + classes.join(' ') + '"' : '') + - (css ? ' style="' + css + '"' : '') + - ' />'; - }, - - - /* Generic rendering utilities for subclasses - ------------------------------------------------------------------------------------------------------------------*/ - - - // Renders a day-of-week header row. - // TODO: move to another class. not applicable to all Grids - headHtml: function() { - return '' + - '<div class="fc-row ' + this.view.widgetHeaderClass + '">' + - '<table>' + - '<thead>' + - this.rowHtml('head') + // leverages RowRenderer - '</thead>' + - '</table>' + - '</div>'; - }, - - - // Used by the `headHtml` method, via RowRenderer, for rendering the HTML of a day-of-week header cell - // TODO: move to another class. not applicable to all Grids - headCellHtml: function(cell) { - var view = this.view; - var date = cell.start; - - return '' + - '<th class="fc-day-header ' + view.widgetHeaderClass + ' fc-' + dayIDs[date.day()] + '">' + - htmlEscape(date.format(this.colHeadFormat)) + - '</th>'; - }, - - - // Renders the HTML for a single-day background cell - bgCellHtml: function(cell) { - var view = this.view; - var date = cell.start; - var classes = this.getDayClasses(date); - - classes.unshift('fc-day', view.widgetContentClass); - - return '<td class="' + classes.join(' ') + '"' + - ' data-date="' + date.format('YYYY-MM-DD') + '"' + // if date has a time, won't format it - '></td>'; - }, - - - // Computes HTML classNames for a single-day cell - getDayClasses: function(date) { - var view = this.view; - var today = view.calendar.getNow().stripTime(); - var classes = [ 'fc-' + dayIDs[date.day()] ]; - - if ( - view.intervalDuration.as('months') == 1 && - date.month() != view.intervalStart.month() - ) { - classes.push('fc-other-month'); - } - - if (date.isSame(today, 'day')) { - classes.push( - 'fc-today', - view.highlightStateClass - ); - } - else if (date < today) { - classes.push('fc-past'); - } - else { - classes.push('fc-future'); - } - - return classes; - } - -}); - -;; - -/* Event-rendering and event-interaction methods for the abstract Grid class -----------------------------------------------------------------------------------------------------------------------*/ - -Grid.mixin({ - - mousedOverSeg: null, // the segment object the user's mouse is over. null if over nothing - isDraggingSeg: false, // is a segment being dragged? boolean - isResizingSeg: false, // is a segment being resized? boolean - isDraggingExternal: false, // jqui-dragging an external element? boolean - segs: null, // the event segments currently rendered in the grid - - - // Renders the given events onto the grid - renderEvents: function(events) { - var segs = this.eventsToSegs(events); - var bgSegs = []; - var fgSegs = []; - var i, seg; - - for (i = 0; i < segs.length; i++) { - seg = segs[i]; - - if (isBgEvent(seg.event)) { - bgSegs.push(seg); - } - else { - fgSegs.push(seg); - } - } - - // Render each different type of segment. - // Each function may return a subset of the segs, segs that were actually rendered. - bgSegs = this.renderBgSegs(bgSegs) || bgSegs; - fgSegs = this.renderFgSegs(fgSegs) || fgSegs; - - this.segs = bgSegs.concat(fgSegs); - }, - - - // Unrenders all events currently rendered on the grid - unrenderEvents: function() { - this.triggerSegMouseout(); // trigger an eventMouseout if user's mouse is over an event - - this.unrenderFgSegs(); - this.unrenderBgSegs(); - - this.segs = null; - }, - - - // Retrieves all rendered segment objects currently rendered on the grid - getEventSegs: function() { - return this.segs || []; - }, - - - /* Foreground Segment Rendering - ------------------------------------------------------------------------------------------------------------------*/ - - - // Renders foreground event segments onto the grid. May return a subset of segs that were rendered. - renderFgSegs: function(segs) { - // subclasses must implement - }, - - - // Unrenders all currently rendered foreground segments - unrenderFgSegs: function() { - // subclasses must implement - }, - - - // Renders and assigns an `el` property for each foreground event segment. - // Only returns segments that successfully rendered. - // A utility that subclasses may use. - renderFgSegEls: function(segs, disableResizing) { - var view = this.view; - var html = ''; - var renderedSegs = []; - var i; - - if (segs.length) { // don't build an empty html string - - // build a large concatenation of event segment HTML - for (i = 0; i < segs.length; i++) { - html += this.fgSegHtml(segs[i], disableResizing); - } - - // Grab individual elements from the combined HTML string. Use each as the default rendering. - // Then, compute the 'el' for each segment. An el might be null if the eventRender callback returned false. - $(html).each(function(i, node) { - var seg = segs[i]; - var el = view.resolveEventEl(seg.event, $(node)); - - if (el) { - el.data('fc-seg', seg); // used by handlers - seg.el = el; - renderedSegs.push(seg); - } - }); - } - - return renderedSegs; - }, - - - // Generates the HTML for the default rendering of a foreground event segment. Used by renderFgSegEls() - fgSegHtml: function(seg, disableResizing) { - // subclasses should implement - }, - - - /* Background Segment Rendering - ------------------------------------------------------------------------------------------------------------------*/ - - - // Renders the given background event segments onto the grid. - // Returns a subset of the segs that were actually rendered. - renderBgSegs: function(segs) { - return this.renderFill('bgEvent', segs); - }, - - - // Unrenders all the currently rendered background event segments - unrenderBgSegs: function() { - this.unrenderFill('bgEvent'); - }, - - - // Renders a background event element, given the default rendering. Called by the fill system. - bgEventSegEl: function(seg, el) { - return this.view.resolveEventEl(seg.event, el); // will filter through eventRender - }, - - - // Generates an array of classNames to be used for the default rendering of a background event. - // Called by the fill system. - bgEventSegClasses: function(seg) { - var event = seg.event; - var source = event.source || {}; - - return [ 'fc-bgevent' ].concat( - event.className, - source.className || [] - ); - }, - - - // Generates a semicolon-separated CSS string to be used for the default rendering of a background event. - // Called by the fill system. - // TODO: consolidate with getEventSkinCss? - bgEventSegCss: function(seg) { - var view = this.view; - var event = seg.event; - var source = event.source || {}; - - return { - 'background-color': - event.backgroundColor || - event.color || - source.backgroundColor || - source.color || - view.opt('eventBackgroundColor') || - view.opt('eventColor') - }; - }, - - - // Generates an array of classNames to be used for the rendering business hours overlay. Called by the fill system. - businessHoursSegClasses: function(seg) { - return [ 'fc-nonbusiness', 'fc-bgevent' ]; - }, - - - /* Handlers - ------------------------------------------------------------------------------------------------------------------*/ - - - // Attaches event-element-related handlers to the container element and leverage bubbling - bindSegHandlers: function() { - var _this = this; - var view = this.view; - - $.each( - { - mouseenter: function(seg, ev) { - _this.triggerSegMouseover(seg, ev); - }, - mouseleave: function(seg, ev) { - _this.triggerSegMouseout(seg, ev); - }, - click: function(seg, ev) { - return view.trigger('eventClick', this, seg.event, ev); // can return `false` to cancel - }, - mousedown: function(seg, ev) { - if ($(ev.target).is('.fc-resizer') && view.isEventResizable(seg.event)) { - _this.segResizeMousedown(seg, ev, $(ev.target).is('.fc-start-resizer')); - } - else if (view.isEventDraggable(seg.event)) { - _this.segDragMousedown(seg, ev); - } - } - }, - function(name, func) { - // attach the handler to the container element and only listen for real event elements via bubbling - _this.el.on(name, '.fc-event-container > *', function(ev) { - var seg = $(this).data('fc-seg'); // grab segment data. put there by View::renderEvents - - // only call the handlers if there is not a drag/resize in progress - if (seg && !_this.isDraggingSeg && !_this.isResizingSeg) { - return func.call(this, seg, ev); // `this` will be the event element - } - }); - } - ); - }, - - - // Updates internal state and triggers handlers for when an event element is moused over - triggerSegMouseover: function(seg, ev) { - if (!this.mousedOverSeg) { - this.mousedOverSeg = seg; - this.view.trigger('eventMouseover', seg.el[0], seg.event, ev); - } - }, - - - // Updates internal state and triggers handlers for when an event element is moused out. - // Can be given no arguments, in which case it will mouseout the segment that was previously moused over. - triggerSegMouseout: function(seg, ev) { - ev = ev || {}; // if given no args, make a mock mouse event - - if (this.mousedOverSeg) { - seg = seg || this.mousedOverSeg; // if given no args, use the currently moused-over segment - this.mousedOverSeg = null; - this.view.trigger('eventMouseout', seg.el[0], seg.event, ev); - } - }, - - - /* Event Dragging - ------------------------------------------------------------------------------------------------------------------*/ - - - // Called when the user does a mousedown on an event, which might lead to dragging. - // Generic enough to work with any type of Grid. - segDragMousedown: function(seg, ev) { - var _this = this; - var view = this.view; - var calendar = view.calendar; - var el = seg.el; - var event = seg.event; - var dropLocation; - - // A clone of the original element that will move with the mouse - var mouseFollower = new MouseFollower(seg.el, { - parentEl: view.el, - opacity: view.opt('dragOpacity'), - revertDuration: view.opt('dragRevertDuration'), - zIndex: 2 // one above the .fc-view - }); - - // Tracks mouse movement over the *view's* coordinate map. Allows dragging and dropping between subcomponents - // of the view. - var dragListener = new CellDragListener(view.coordMap, { - distance: 5, - scroll: view.opt('dragScroll'), - subjectEl: el, - subjectCenter: true, - listenStart: function(ev) { - mouseFollower.hide(); // don't show until we know this is a real drag - mouseFollower.start(ev); - }, - dragStart: function(ev) { - _this.triggerSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported - _this.segDragStart(seg, ev); - view.hideEvent(event); // hide all event segments. our mouseFollower will take over - }, - cellOver: function(cell, isOrig, origCell) { - - // starting cell could be forced (DayGrid.limit) - if (seg.cell) { - origCell = seg.cell; - } - - dropLocation = _this.computeEventDrop(origCell, cell, event); - - if (dropLocation && !calendar.isEventRangeAllowed(dropLocation, event)) { - disableCursor(); - dropLocation = null; - } - - // if a valid drop location, have the subclass render a visual indication - if (dropLocation && view.renderDrag(dropLocation, seg)) { - mouseFollower.hide(); // if the subclass is already using a mock event "helper", hide our own - } - else { - mouseFollower.show(); // otherwise, have the helper follow the mouse (no snapping) - } - - if (isOrig) { - dropLocation = null; // needs to have moved cells to be a valid drop - } - }, - cellOut: function() { // called before mouse moves to a different cell OR moved out of all cells - view.unrenderDrag(); // unrender whatever was done in renderDrag - mouseFollower.show(); // show in case we are moving out of all cells - dropLocation = null; - }, - cellDone: function() { // Called after a cellOut OR before a dragStop - enableCursor(); - }, - dragStop: function(ev) { - // do revert animation if hasn't changed. calls a callback when finished (whether animation or not) - mouseFollower.stop(!dropLocation, function() { - view.unrenderDrag(); - view.showEvent(event); - _this.segDragStop(seg, ev); - - if (dropLocation) { - view.reportEventDrop(event, dropLocation, this.largeUnit, el, ev); - } - }); - }, - listenStop: function() { - mouseFollower.stop(); // put in listenStop in case there was a mousedown but the drag never started - } - }); - - dragListener.mousedown(ev); // start listening, which will eventually lead to a dragStart - }, - - - // Called before event segment dragging starts - segDragStart: function(seg, ev) { - this.isDraggingSeg = true; - this.view.trigger('eventDragStart', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy - }, - - - // Called after event segment dragging stops - segDragStop: function(seg, ev) { - this.isDraggingSeg = false; - this.view.trigger('eventDragStop', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy - }, - - - // Given the cell an event drag began, and the cell event was dropped, calculates the new start/end/allDay - // values for the event. Subclasses may override and set additional properties to be used by renderDrag. - // A falsy returned value indicates an invalid drop. - computeEventDrop: function(startCell, endCell, event) { - var calendar = this.view.calendar; - var dragStart = startCell.start; - var dragEnd = endCell.start; - var delta; - var dropLocation; - - if (dragStart.hasTime() === dragEnd.hasTime()) { - delta = this.diffDates(dragEnd, dragStart); - - // if an all-day event was in a timed area and it was dragged to a different time, - // guarantee an end and adjust start/end to have times - if (event.allDay && durationHasTime(delta)) { - dropLocation = { - start: event.start.clone(), - end: calendar.getEventEnd(event), // will be an ambig day - allDay: false // for normalizeEventRangeTimes - }; - calendar.normalizeEventRangeTimes(dropLocation); - } - // othewise, work off existing values - else { - dropLocation = { - start: event.start.clone(), - end: event.end ? event.end.clone() : null, - allDay: event.allDay // keep it the same - }; - } - - dropLocation.start.add(delta); - if (dropLocation.end) { - dropLocation.end.add(delta); - } - } - else { - // if switching from day <-> timed, start should be reset to the dropped date, and the end cleared - dropLocation = { - start: dragEnd.clone(), - end: null, // end should be cleared - allDay: !dragEnd.hasTime() - }; - } - - return dropLocation; - }, - - - // Utility for apply dragOpacity to a jQuery set - applyDragOpacity: function(els) { - var opacity = this.view.opt('dragOpacity'); - - if (opacity != null) { - els.each(function(i, node) { - // Don't use jQuery (will set an IE filter), do it the old fashioned way. - // In IE8, a helper element will disappears if there's a filter. - node.style.opacity = opacity; - }); - } - }, - - - /* External Element Dragging - ------------------------------------------------------------------------------------------------------------------*/ - - - // Called when a jQuery UI drag is initiated anywhere in the DOM - externalDragStart: function(ev, ui) { - var view = this.view; - var el; - var accept; - - if (view.opt('droppable')) { // only listen if this setting is on - el = $((ui ? ui.item : null) || ev.target); - - // Test that the dragged element passes the dropAccept selector or filter function. - // FYI, the default is "*" (matches all) - accept = view.opt('dropAccept'); - if ($.isFunction(accept) ? accept.call(el[0], el) : el.is(accept)) { - if (!this.isDraggingExternal) { // prevent double-listening if fired twice - this.listenToExternalDrag(el, ev, ui); - } - } - } - }, - - - // Called when a jQuery UI drag starts and it needs to be monitored for cell dropping - listenToExternalDrag: function(el, ev, ui) { - var _this = this; - var meta = getDraggedElMeta(el); // extra data about event drop, including possible event to create - var dragListener; - var dropLocation; // a null value signals an unsuccessful drag - - // listener that tracks mouse movement over date-associated pixel regions - dragListener = new CellDragListener(this.coordMap, { - listenStart: function() { - _this.isDraggingExternal = true; - }, - cellOver: function(cell) { - dropLocation = _this.computeExternalDrop(cell, meta); - if (dropLocation) { - _this.renderDrag(dropLocation); // called without a seg parameter - } - else { // invalid drop cell - disableCursor(); - } - }, - cellOut: function() { - dropLocation = null; // signal unsuccessful - _this.unrenderDrag(); - enableCursor(); - }, - dragStop: function() { - _this.unrenderDrag(); - enableCursor(); - - if (dropLocation) { // element was dropped on a valid date/time cell - _this.view.reportExternalDrop(meta, dropLocation, el, ev, ui); - } - }, - listenStop: function() { - _this.isDraggingExternal = false; - } - }); - - dragListener.startDrag(ev); // start listening immediately - }, - - - // Given a cell to be dropped upon, and misc data associated with the jqui drag (guaranteed to be a plain object), - // returns start/end dates for the event that would result from the hypothetical drop. end might be null. - // Returning a null value signals an invalid drop cell. - computeExternalDrop: function(cell, meta) { - var dropLocation = { - start: cell.start.clone(), - end: null - }; - - // if dropped on an all-day cell, and element's metadata specified a time, set it - if (meta.startTime && !dropLocation.start.hasTime()) { - dropLocation.start.time(meta.startTime); - } - - if (meta.duration) { - dropLocation.end = dropLocation.start.clone().add(meta.duration); - } - - if (!this.view.calendar.isExternalDropRangeAllowed(dropLocation, meta.eventProps)) { - return null; - } - - return dropLocation; - }, - - - - /* Drag Rendering (for both events and an external elements) - ------------------------------------------------------------------------------------------------------------------*/ - - - // Renders a visual indication of an event or external element being dragged. - // `dropLocation` contains hypothetical start/end/allDay values the event would have if dropped. end can be null. - // `seg` is the internal segment object that is being dragged. If dragging an external element, `seg` is null. - // A truthy returned value indicates this method has rendered a helper element. - renderDrag: function(dropLocation, seg) { - // subclasses must implement - }, - - - // Unrenders a visual indication of an event or external element being dragged - unrenderDrag: function() { - // subclasses must implement - }, - - - /* Resizing - ------------------------------------------------------------------------------------------------------------------*/ - - - // Called when the user does a mousedown on an event's resizer, which might lead to resizing. - // Generic enough to work with any type of Grid. - segResizeMousedown: function(seg, ev, isStart) { - var _this = this; - var view = this.view; - var calendar = view.calendar; - var el = seg.el; - var event = seg.event; - var eventEnd = calendar.getEventEnd(event); - var dragListener; - var resizeLocation; // falsy if invalid resize - - // Tracks mouse movement over the *grid's* coordinate map - dragListener = new CellDragListener(this.coordMap, { - distance: 5, - scroll: view.opt('dragScroll'), - subjectEl: el, - dragStart: function(ev) { - _this.triggerSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported - _this.segResizeStart(seg, ev); - }, - cellOver: function(cell, isOrig, origCell) { - resizeLocation = isStart ? - _this.computeEventStartResize(origCell, cell, event) : - _this.computeEventEndResize(origCell, cell, event); - - if (resizeLocation) { - if (!calendar.isEventRangeAllowed(resizeLocation, event)) { - disableCursor(); - resizeLocation = null; - } - // no change? (TODO: how does this work with timezones?) - else if (resizeLocation.start.isSame(event.start) && resizeLocation.end.isSame(eventEnd)) { - resizeLocation = null; - } - } - - if (resizeLocation) { - view.hideEvent(event); - _this.renderEventResize(resizeLocation, seg); - } - }, - cellOut: function() { // called before mouse moves to a different cell OR moved out of all cells - resizeLocation = null; - }, - cellDone: function() { // resets the rendering to show the original event - _this.unrenderEventResize(); - view.showEvent(event); - enableCursor(); - }, - dragStop: function(ev) { - _this.segResizeStop(seg, ev); - - if (resizeLocation) { // valid date to resize to? - view.reportEventResize(event, resizeLocation, this.largeUnit, el, ev); - } - } - }); - - dragListener.mousedown(ev); // start listening, which will eventually lead to a dragStart - }, - - - // Called before event segment resizing starts - segResizeStart: function(seg, ev) { - this.isResizingSeg = true; - this.view.trigger('eventResizeStart', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy - }, - - - // Called after event segment resizing stops - segResizeStop: function(seg, ev) { - this.isResizingSeg = false; - this.view.trigger('eventResizeStop', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy - }, - - - // Returns new date-information for an event segment being resized from its start - computeEventStartResize: function(startCell, endCell, event) { - return this.computeEventResize('start', startCell, endCell, event); - }, - - - // Returns new date-information for an event segment being resized from its end - computeEventEndResize: function(startCell, endCell, event) { - return this.computeEventResize('end', startCell, endCell, event); - }, - - - // Returns new date-information for an event segment being resized from its start OR end - // `type` is either 'start' or 'end' - computeEventResize: function(type, startCell, endCell, event) { - var calendar = this.view.calendar; - var delta = this.diffDates(endCell[type], startCell[type]); - var range; - var defaultDuration; - - // build original values to work from, guaranteeing a start and end - range = { - start: event.start.clone(), - end: calendar.getEventEnd(event), - allDay: event.allDay - }; - - // if an all-day event was in a timed area and was resized to a time, adjust start/end to have times - if (range.allDay && durationHasTime(delta)) { - range.allDay = false; - calendar.normalizeEventRangeTimes(range); - } - - range[type].add(delta); // apply delta to start or end - - // if the event was compressed too small, find a new reasonable duration for it - if (!range.start.isBefore(range.end)) { - - defaultDuration = event.allDay ? - calendar.defaultAllDayEventDuration : - calendar.defaultTimedEventDuration; - - // between the cell's duration and the event's default duration, use the smaller of the two. - // example: if year-length slots, and compressed to one slot, we don't want the event to be a year long - if (this.cellDuration && this.cellDuration < defaultDuration) { - defaultDuration = this.cellDuration; - } - - if (type == 'start') { // resizing the start? - range.start = range.end.clone().subtract(defaultDuration); - } - else { // resizing the end? - range.end = range.start.clone().add(defaultDuration); - } - } - - return range; - }, - - - // Renders a visual indication of an event being resized. - // `range` has the updated dates of the event. `seg` is the original segment object involved in the drag. - renderEventResize: function(range, seg) { - // subclasses must implement - }, - - - // Unrenders a visual indication of an event being resized. - unrenderEventResize: function() { - // subclasses must implement - }, - - - /* Rendering Utils - ------------------------------------------------------------------------------------------------------------------*/ - - - // Compute the text that should be displayed on an event's element. - // `range` can be the Event object itself, or something range-like, with at least a `start`. - // If event times are disabled, or the event has no time, will return a blank string. - // If not specified, formatStr will default to the eventTimeFormat setting, - // and displayEnd will default to the displayEventEnd setting. - getEventTimeText: function(range, formatStr, displayEnd) { - - if (formatStr == null) { - formatStr = this.eventTimeFormat; - } - - if (displayEnd == null) { - displayEnd = this.displayEventEnd; - } - - if (this.displayEventTime && range.start.hasTime()) { - if (displayEnd && range.end) { - return this.view.formatRange(range, formatStr); - } - else { - return range.start.format(formatStr); - } - } - - return ''; - }, - - - // Generic utility for generating the HTML classNames for an event segment's element - getSegClasses: function(seg, isDraggable, isResizable) { - var event = seg.event; - var classes = [ - 'fc-event', - seg.isStart ? 'fc-start' : 'fc-not-start', - seg.isEnd ? 'fc-end' : 'fc-not-end' - ].concat( - event.className, - event.source ? event.source.className : [] - ); - - if (isDraggable) { - classes.push('fc-draggable'); - } - if (isResizable) { - classes.push('fc-resizable'); - } - - return classes; - }, - - - // Utility for generating event skin-related CSS properties - getEventSkinCss: function(event) { - var view = this.view; - var source = event.source || {}; - var eventColor = event.color; - var sourceColor = source.color; - var optionColor = view.opt('eventColor'); - - return { - 'background-color': - event.backgroundColor || - eventColor || - source.backgroundColor || - sourceColor || - view.opt('eventBackgroundColor') || - optionColor, - 'border-color': - event.borderColor || - eventColor || - source.borderColor || - sourceColor || - view.opt('eventBorderColor') || - optionColor, - color: - event.textColor || - source.textColor || - view.opt('eventTextColor') - }; - }, - - - /* Converting events -> ranges -> segs - ------------------------------------------------------------------------------------------------------------------*/ - - - // Converts an array of event objects into an array of event segment objects. - // A custom `rangeToSegsFunc` may be given for arbitrarily slicing up events. - // Doesn't guarantee an order for the resulting array. - eventsToSegs: function(events, rangeToSegsFunc) { - var eventRanges = this.eventsToRanges(events); - var segs = []; - var i; - - for (i = 0; i < eventRanges.length; i++) { - segs.push.apply( - segs, - this.eventRangeToSegs(eventRanges[i], rangeToSegsFunc) - ); - } - - return segs; - }, - - - // Converts an array of events into an array of "range" objects. - // A "range" object is a plain object with start/end properties denoting the time it covers. Also an event property. - // For "normal" events, this will be identical to the event's start/end, but for "inverse-background" events, - // will create an array of ranges that span the time *not* covered by the given event. - // Doesn't guarantee an order for the resulting array. - eventsToRanges: function(events) { - var _this = this; - var eventsById = groupEventsById(events); - var ranges = []; - - // group by ID so that related inverse-background events can be rendered together - $.each(eventsById, function(id, eventGroup) { - if (eventGroup.length) { - ranges.push.apply( - ranges, - isInverseBgEvent(eventGroup[0]) ? - _this.eventsToInverseRanges(eventGroup) : - _this.eventsToNormalRanges(eventGroup) - ); - } - }); - - return ranges; - }, - - - // Converts an array of "normal" events (not inverted rendering) into a parallel array of ranges - eventsToNormalRanges: function(events) { - var calendar = this.view.calendar; - var ranges = []; - var i, event; - var eventStart, eventEnd; - - for (i = 0; i < events.length; i++) { - event = events[i]; - - // make copies and normalize by stripping timezone - eventStart = event.start.clone().stripZone(); - eventEnd = calendar.getEventEnd(event).stripZone(); - - ranges.push({ - event: event, - start: eventStart, - end: eventEnd, - eventStartMS: +eventStart, - eventDurationMS: eventEnd - eventStart - }); - } - - return ranges; - }, - - - // Converts an array of events, with inverse-background rendering, into an array of range objects. - // The range objects will cover all the time NOT covered by the events. - eventsToInverseRanges: function(events) { - var view = this.view; - var viewStart = view.start.clone().stripZone(); // normalize timezone - var viewEnd = view.end.clone().stripZone(); // normalize timezone - var normalRanges = this.eventsToNormalRanges(events); // will give us normalized dates we can use w/o copies - var inverseRanges = []; - var event0 = events[0]; // assign this to each range's `.event` - var start = viewStart; // the end of the previous range. the start of the new range - var i, normalRange; - - // ranges need to be in order. required for our date-walking algorithm - normalRanges.sort(compareNormalRanges); - - for (i = 0; i < normalRanges.length; i++) { - normalRange = normalRanges[i]; - - // add the span of time before the event (if there is any) - if (normalRange.start > start) { // compare millisecond time (skip any ambig logic) - inverseRanges.push({ - event: event0, - start: start, - end: normalRange.start - }); - } - - start = normalRange.end; - } - - // add the span of time after the last event (if there is any) - if (start < viewEnd) { // compare millisecond time (skip any ambig logic) - inverseRanges.push({ - event: event0, - start: start, - end: viewEnd - }); - } - - return inverseRanges; - }, - - - // Slices the given event range into one or more segment objects. - // A `rangeToSegsFunc` custom slicing function can be given. - eventRangeToSegs: function(eventRange, rangeToSegsFunc) { - var segs; - var i, seg; - - eventRange = this.view.calendar.ensureVisibleEventRange(eventRange); - - if (rangeToSegsFunc) { - segs = rangeToSegsFunc(eventRange); - } - else { - segs = this.rangeToSegs(eventRange); // defined by the subclass - } - - for (i = 0; i < segs.length; i++) { - seg = segs[i]; - seg.event = eventRange.event; - seg.eventStartMS = eventRange.eventStartMS; - seg.eventDurationMS = eventRange.eventDurationMS; - } - - return segs; - } - -}); - - -/* Utilities -----------------------------------------------------------------------------------------------------------------------*/ - - -function isBgEvent(event) { // returns true if background OR inverse-background - var rendering = getEventRendering(event); - return rendering === 'background' || rendering === 'inverse-background'; -} - - -function isInverseBgEvent(event) { - return getEventRendering(event) === 'inverse-background'; -} - - -function getEventRendering(event) { - return firstDefined((event.source || {}).rendering, event.rendering); -} - - -function groupEventsById(events) { - var eventsById = {}; - var i, event; - - for (i = 0; i < events.length; i++) { - event = events[i]; - (eventsById[event._id] || (eventsById[event._id] = [])).push(event); - } - - return eventsById; -} - - -// A cmp function for determining which non-inverted "ranges" (see above) happen earlier -function compareNormalRanges(range1, range2) { - return range1.eventStartMS - range2.eventStartMS; // earlier ranges go first -} - - -// A cmp function for determining which segments should take visual priority -// DOES NOT WORK ON INVERTED BACKGROUND EVENTS because they have no eventStartMS/eventDurationMS -function compareSegs(seg1, seg2) { - return seg1.eventStartMS - seg2.eventStartMS || // earlier events go first - seg2.eventDurationMS - seg1.eventDurationMS || // tie? longer events go first - seg2.event.allDay - seg1.event.allDay || // tie? put all-day events first (booleans cast to 0/1) - (seg1.event.title || '').localeCompare(seg2.event.title) || // tie? alphabetically by title - seg1.event.sortOrder - seg2.event.sortOrder; // tie? use sortOrder -} - -fc.compareSegs = compareSegs; // export - - -/* External-Dragging-Element Data -----------------------------------------------------------------------------------------------------------------------*/ - -// Require all HTML5 data-* attributes used by FullCalendar to have this prefix. -// A value of '' will query attributes like data-event. A value of 'fc' will query attributes like data-fc-event. -fc.dataAttrPrefix = ''; - -// Given a jQuery element that might represent a dragged FullCalendar event, returns an intermediate data structure -// to be used for Event Object creation. -// A defined `.eventProps`, even when empty, indicates that an event should be created. -function getDraggedElMeta(el) { - var prefix = fc.dataAttrPrefix; - var eventProps; // properties for creating the event, not related to date/time - var startTime; // a Duration - var duration; - var stick; - - if (prefix) { prefix += '-'; } - eventProps = el.data(prefix + 'event') || null; - - if (eventProps) { - if (typeof eventProps === 'object') { - eventProps = $.extend({}, eventProps); // make a copy - } - else { // something like 1 or true. still signal event creation - eventProps = {}; - } - - // pluck special-cased date/time properties - startTime = eventProps.start; - if (startTime == null) { startTime = eventProps.time; } // accept 'time' as well - duration = eventProps.duration; - stick = eventProps.stick; - delete eventProps.start; - delete eventProps.time; - delete eventProps.duration; - delete eventProps.stick; - } - - // fallback to standalone attribute values for each of the date/time properties - if (startTime == null) { startTime = el.data(prefix + 'start'); } - if (startTime == null) { startTime = el.data(prefix + 'time'); } // accept 'time' as well - if (duration == null) { duration = el.data(prefix + 'duration'); } - if (stick == null) { stick = el.data(prefix + 'stick'); } - - // massage into correct data types - startTime = startTime != null ? moment.duration(startTime) : null; - duration = duration != null ? moment.duration(duration) : null; - stick = Boolean(stick); - - return { eventProps: eventProps, startTime: startTime, duration: duration, stick: stick }; -} - - -;; - -/* A component that renders a grid of whole-days that runs horizontally. There can be multiple rows, one per week. -----------------------------------------------------------------------------------------------------------------------*/ - -var DayGrid = Grid.extend({ - - numbersVisible: false, // should render a row for day/week numbers? set by outside view. TODO: make internal - bottomCoordPadding: 0, // hack for extending the hit area for the last row of the coordinate grid - breakOnWeeks: null, // should create a new row for each week? set by outside view - - cellDates: null, // flat chronological array of each cell's dates - dayToCellOffsets: null, // maps days offsets from grid's start date, to cell offsets - - rowEls: null, // set of fake row elements - dayEls: null, // set of whole-day elements comprising the row's background - helperEls: null, // set of cell skeleton elements for rendering the mock event "helper" - - - constructor: function() { - Grid.apply(this, arguments); - - this.cellDuration = moment.duration(1, 'day'); // for Grid system - }, - - - // Renders the rows and columns into the component's `this.el`, which should already be assigned. - // isRigid determins whether the individual rows should ignore the contents and be a constant height. - // Relies on the view's colCnt and rowCnt. In the future, this component should probably be self-sufficient. - renderDates: function(isRigid) { - var view = this.view; - var rowCnt = this.rowCnt; - var colCnt = this.colCnt; - var cellCnt = rowCnt * colCnt; - var html = ''; - var row; - var i, cell; - - for (row = 0; row < rowCnt; row++) { - html += this.dayRowHtml(row, isRigid); - } - this.el.html(html); - - this.rowEls = this.el.find('.fc-row'); - this.dayEls = this.el.find('.fc-day'); - - // trigger dayRender with each cell's element - for (i = 0; i < cellCnt; i++) { - cell = this.getCell(i); - view.trigger('dayRender', null, cell.start, this.dayEls.eq(i)); - } - }, - - - unrenderDates: function() { - this.removeSegPopover(); - }, - - - renderBusinessHours: function() { - var events = this.view.calendar.getBusinessHoursEvents(true); // wholeDay=true - var segs = this.eventsToSegs(events); - - this.renderFill('businessHours', segs, 'bgevent'); - }, - - - // Generates the HTML for a single row. `row` is the row number. - dayRowHtml: function(row, isRigid) { - var view = this.view; - var classes = [ 'fc-row', 'fc-week', view.widgetContentClass ]; - - if (isRigid) { - classes.push('fc-rigid'); - } - - return '' + - '<div class="' + classes.join(' ') + '">' + - '<div class="fc-bg">' + - '<table>' + - this.rowHtml('day', row) + // leverages RowRenderer. calls dayCellHtml() - '</table>' + - '</div>' + - '<div class="fc-content-skeleton">' + - '<table>' + - (this.numbersVisible ? - '<thead>' + - this.rowHtml('number', row) + // leverages RowRenderer. View will define render method - '</thead>' : - '' - ) + - '</table>' + - '</div>' + - '</div>'; - }, - - - // Renders the HTML for a whole-day cell. Will eventually end up in the day-row's background. - // We go through a 'day' row type instead of just doing a 'bg' row type so that the View can do custom rendering - // specifically for whole-day rows, whereas a 'bg' might also be used for other purposes (TimeGrid bg for example). - dayCellHtml: function(cell) { - return this.bgCellHtml(cell); - }, - - - /* Options - ------------------------------------------------------------------------------------------------------------------*/ - - - // Computes a default column header formatting string if `colFormat` is not explicitly defined - computeColHeadFormat: function() { - if (this.rowCnt > 1) { // more than one week row. day numbers will be in each cell - return 'ddd'; // "Sat" - } - else if (this.colCnt > 1) { // multiple days, so full single date string WON'T be in title text - return this.view.opt('dayOfMonthFormat'); // "Sat 12/10" - } - else { // single day, so full single date string will probably be in title text - return 'dddd'; // "Saturday" - } - }, - - - // Computes a default event time formatting string if `timeFormat` is not explicitly defined - computeEventTimeFormat: function() { - return this.view.opt('extraSmallTimeFormat'); // like "6p" or "6:30p" - }, - - - // Computes a default `displayEventEnd` value if one is not expliclty defined - computeDisplayEventEnd: function() { - return this.colCnt == 1; // we'll likely have space if there's only one day - }, - - - /* Cell System - ------------------------------------------------------------------------------------------------------------------*/ - - - rangeUpdated: function() { - var cellDates; - var firstDay; - var rowCnt; - var colCnt; - - this.updateCellDates(); // populates cellDates and dayToCellOffsets - cellDates = this.cellDates; - - if (this.breakOnWeeks) { - // count columns until the day-of-week repeats - firstDay = cellDates[0].day(); - for (colCnt = 1; colCnt < cellDates.length; colCnt++) { - if (cellDates[colCnt].day() == firstDay) { - break; - } - } - rowCnt = Math.ceil(cellDates.length / colCnt); - } - else { - rowCnt = 1; - colCnt = cellDates.length; - } - - this.rowCnt = rowCnt; - this.colCnt = colCnt; - }, - - - // Populates cellDates and dayToCellOffsets - updateCellDates: function() { - var view = this.view; - var date = this.start.clone(); - var dates = []; - var offset = -1; - var offsets = []; - - while (date.isBefore(this.end)) { // loop each day from start to end - if (view.isHiddenDay(date)) { - offsets.push(offset + 0.5); // mark that it's between offsets - } - else { - offset++; - offsets.push(offset); - dates.push(date.clone()); - } - date.add(1, 'days'); - } - - this.cellDates = dates; - this.dayToCellOffsets = offsets; - }, - - - // Given a cell object, generates its start date. Returns a reference-free copy. - computeCellDate: function(cell) { - var colCnt = this.colCnt; - var index = cell.row * colCnt + (this.isRTL ? colCnt - cell.col - 1 : cell.col); - - return this.cellDates[index].clone(); - }, - - - // Retrieves the element representing the given row - getRowEl: function(row) { - return this.rowEls.eq(row); - }, - - - // Retrieves the element representing the given column - getColEl: function(col) { - return this.dayEls.eq(col); - }, - - - // Gets the whole-day element associated with the cell - getCellDayEl: function(cell) { - return this.dayEls.eq(cell.row * this.colCnt + cell.col); - }, - - - // Overrides Grid's method for when row coordinates are computed - computeRowCoords: function() { - var rowCoords = Grid.prototype.computeRowCoords.call(this); // call the super-method - - // hack for extending last row (used by AgendaView) - rowCoords[rowCoords.length - 1].bottom += this.bottomCoordPadding; - - return rowCoords; - }, - - - /* Dates - ------------------------------------------------------------------------------------------------------------------*/ - - - // Slices up a date range by row into an array of segments - rangeToSegs: function(range) { - var isRTL = this.isRTL; - var rowCnt = this.rowCnt; - var colCnt = this.colCnt; - var segs = []; - var first, last; // inclusive cell-offset range for given range - var row; - var rowFirst, rowLast; // inclusive cell-offset range for current row - var isStart, isEnd; - var segFirst, segLast; // inclusive cell-offset range for segment - var seg; - - range = this.view.computeDayRange(range); // make whole-day range, considering nextDayThreshold - first = this.dateToCellOffset(range.start); - last = this.dateToCellOffset(range.end.subtract(1, 'days')); // offset of inclusive end date - - for (row = 0; row < rowCnt; row++) { - rowFirst = row * colCnt; - rowLast = rowFirst + colCnt - 1; - - // intersect segment's offset range with the row's - segFirst = Math.max(rowFirst, first); - segLast = Math.min(rowLast, last); - - // deal with in-between indices - segFirst = Math.ceil(segFirst); // in-between starts round to next cell - segLast = Math.floor(segLast); // in-between ends round to prev cell - - if (segFirst <= segLast) { // was there any intersection with the current row? - - // must be matching integers to be the segment's start/end - isStart = segFirst === first; - isEnd = segLast === last; - - // translate offsets to be relative to start-of-row - segFirst -= rowFirst; - segLast -= rowFirst; - - seg = { row: row, isStart: isStart, isEnd: isEnd }; - if (isRTL) { - seg.leftCol = colCnt - segLast - 1; - seg.rightCol = colCnt - segFirst - 1; - } - else { - seg.leftCol = segFirst; - seg.rightCol = segLast; - } - segs.push(seg); - } - } - - return segs; - }, - - - // Given a date, returns its chronolocial cell-offset from the first cell of the grid. - // If the date lies between cells (because of hiddenDays), returns a floating-point value between offsets. - // If before the first offset, returns a negative number. - // If after the last offset, returns an offset past the last cell offset. - // Only works for *start* dates of cells. Will not work for exclusive end dates for cells. - dateToCellOffset: function(date) { - var offsets = this.dayToCellOffsets; - var day = date.diff(this.start, 'days'); - - if (day < 0) { - return offsets[0] - 1; - } - else if (day >= offsets.length) { - return offsets[offsets.length - 1] + 1; - } - else { - return offsets[day]; - } - }, - - - /* Event Drag Visualization - ------------------------------------------------------------------------------------------------------------------*/ - // TODO: move to DayGrid.event, similar to what we did with Grid's drag methods - - - // Renders a visual indication of an event or external element being dragged. - // The dropLocation's end can be null. seg can be null. See Grid::renderDrag for more info. - renderDrag: function(dropLocation, seg) { - - // always render a highlight underneath - this.renderHighlight(this.eventRangeToSegs(dropLocation)); - - // if a segment from the same calendar but another component is being dragged, render a helper event - if (seg && !seg.el.closest(this.el).length) { - - this.renderRangeHelper(dropLocation, seg); - this.applyDragOpacity(this.helperEls); - - return true; // a helper has been rendered - } - }, - - - // Unrenders any visual indication of a hovering event - unrenderDrag: function() { - this.unrenderHighlight(); - this.unrenderHelper(); - }, - - - /* Event Resize Visualization - ------------------------------------------------------------------------------------------------------------------*/ - - - // Renders a visual indication of an event being resized - renderEventResize: function(range, seg) { - this.renderHighlight(this.eventRangeToSegs(range)); - this.renderRangeHelper(range, seg); - }, - - - // Unrenders a visual indication of an event being resized - unrenderEventResize: function() { - this.unrenderHighlight(); - this.unrenderHelper(); - }, - - - /* Event Helper - ------------------------------------------------------------------------------------------------------------------*/ - - - // Renders a mock "helper" event. `sourceSeg` is the associated internal segment object. It can be null. - renderHelper: function(event, sourceSeg) { - var helperNodes = []; - var segs = this.eventsToSegs([ event ]); - var rowStructs; - - segs = this.renderFgSegEls(segs); // assigns each seg's el and returns a subset of segs that were rendered - rowStructs = this.renderSegRows(segs); - - // inject each new event skeleton into each associated row - this.rowEls.each(function(row, rowNode) { - var rowEl = $(rowNode); // the .fc-row - var skeletonEl = $('<div class="fc-helper-skeleton"><table/></div>'); // will be absolutely positioned - var skeletonTop; - - // If there is an original segment, match the top position. Otherwise, put it at the row's top level - if (sourceSeg && sourceSeg.row === row) { - skeletonTop = sourceSeg.el.position().top; - } - else { - skeletonTop = rowEl.find('.fc-content-skeleton tbody').position().top; - } - - skeletonEl.css('top', skeletonTop) - .find('table') - .append(rowStructs[row].tbodyEl); - - rowEl.append(skeletonEl); - helperNodes.push(skeletonEl[0]); - }); - - this.helperEls = $(helperNodes); // array -> jQuery set - }, - - - // Unrenders any visual indication of a mock helper event - unrenderHelper: function() { - if (this.helperEls) { - this.helperEls.remove(); - this.helperEls = null; - } - }, - - - /* Fill System (highlight, background events, business hours) - ------------------------------------------------------------------------------------------------------------------*/ - - - fillSegTag: 'td', // override the default tag name - - - // Renders a set of rectangles over the given segments of days. - // Only returns segments that successfully rendered. - renderFill: function(type, segs, className) { - var nodes = []; - var i, seg; - var skeletonEl; - - segs = this.renderFillSegEls(type, segs); // assignes `.el` to each seg. returns successfully rendered segs - - for (i = 0; i < segs.length; i++) { - seg = segs[i]; - skeletonEl = this.renderFillRow(type, seg, className); - this.rowEls.eq(seg.row).append(skeletonEl); - nodes.push(skeletonEl[0]); - } - - this.elsByFill[type] = $(nodes); - - return segs; - }, - - - // Generates the HTML needed for one row of a fill. Requires the seg's el to be rendered. - renderFillRow: function(type, seg, className) { - var colCnt = this.colCnt; - var startCol = seg.leftCol; - var endCol = seg.rightCol + 1; - var skeletonEl; - var trEl; - - className = className || type.toLowerCase(); - - skeletonEl = $( - '<div class="fc-' + className + '-skeleton">' + - '<table><tr/></table>' + - '</div>' - ); - trEl = skeletonEl.find('tr'); - - if (startCol > 0) { - trEl.append('<td colspan="' + startCol + '"/>'); - } - - trEl.append( - seg.el.attr('colspan', endCol - startCol) - ); - - if (endCol < colCnt) { - trEl.append('<td colspan="' + (colCnt - endCol) + '"/>'); - } - - this.bookendCells(trEl, type); - - return skeletonEl; - } - -}); - -;; - -/* Event-rendering methods for the DayGrid class -----------------------------------------------------------------------------------------------------------------------*/ - -DayGrid.mixin({ - - rowStructs: null, // an array of objects, each holding information about a row's foreground event-rendering - - - // Unrenders all events currently rendered on the grid - unrenderEvents: function() { - this.removeSegPopover(); // removes the "more.." events popover - Grid.prototype.unrenderEvents.apply(this, arguments); // calls the super-method - }, - - - // Retrieves all rendered segment objects currently rendered on the grid - getEventSegs: function() { - return Grid.prototype.getEventSegs.call(this) // get the segments from the super-method - .concat(this.popoverSegs || []); // append the segments from the "more..." popover - }, - - - // Renders the given background event segments onto the grid - renderBgSegs: function(segs) { - - // don't render timed background events - var allDaySegs = $.grep(segs, function(seg) { - return seg.event.allDay; - }); - - return Grid.prototype.renderBgSegs.call(this, allDaySegs); // call the super-method - }, - - - // Renders the given foreground event segments onto the grid - renderFgSegs: function(segs) { - var rowStructs; - - // render an `.el` on each seg - // returns a subset of the segs. segs that were actually rendered - segs = this.renderFgSegEls(segs); - - rowStructs = this.rowStructs = this.renderSegRows(segs); - - // append to each row's content skeleton - this.rowEls.each(function(i, rowNode) { - $(rowNode).find('.fc-content-skeleton > table').append( - rowStructs[i].tbodyEl - ); - }); - - return segs; // return only the segs that were actually rendered - }, - - - // Unrenders all currently rendered foreground event segments - unrenderFgSegs: function() { - var rowStructs = this.rowStructs || []; - var rowStruct; - - while ((rowStruct = rowStructs.pop())) { - rowStruct.tbodyEl.remove(); - } - - this.rowStructs = null; - }, - - - // Uses the given events array to generate <tbody> elements that should be appended to each row's content skeleton. - // Returns an array of rowStruct objects (see the bottom of `renderSegRow`). - // PRECONDITION: each segment shoud already have a rendered and assigned `.el` - renderSegRows: function(segs) { - var rowStructs = []; - var segRows; - var row; - - segRows = this.groupSegRows(segs); // group into nested arrays - - // iterate each row of segment groupings - for (row = 0; row < segRows.length; row++) { - rowStructs.push( - this.renderSegRow(row, segRows[row]) - ); - } - - return rowStructs; - }, - - - // Builds the HTML to be used for the default element for an individual segment - fgSegHtml: function(seg, disableResizing) { - var view = this.view; - var event = seg.event; - var isDraggable = view.isEventDraggable(event); - var isResizableFromStart = !disableResizing && event.allDay && - seg.isStart && view.isEventResizableFromStart(event); - var isResizableFromEnd = !disableResizing && event.allDay && - seg.isEnd && view.isEventResizableFromEnd(event); - var classes = this.getSegClasses(seg, isDraggable, isResizableFromStart || isResizableFromEnd); - var skinCss = cssToStr(this.getEventSkinCss(event)); - var timeHtml = ''; - var timeText; - var titleHtml; - - classes.unshift('fc-day-grid-event', 'fc-h-event'); - - // Only display a timed events time if it is the starting segment - if (seg.isStart) { - timeText = this.getEventTimeText(event); - if (timeText) { - timeHtml = '<span class="fc-time">' + htmlEscape(timeText) + '</span>'; - } - } - - titleHtml = - '<span class="fc-title">' + - (htmlEscape(event.title || '') || ' ') + // we always want one line of height - '</span>'; - - return '<a class="' + classes.join(' ') + '"' + - (event.url ? - ' href="' + htmlEscape(event.url) + '"' : - '' - ) + - (skinCss ? - ' style="' + skinCss + '"' : - '' - ) + - '>' + - '<div class="fc-content">' + - (this.isRTL ? - titleHtml + ' ' + timeHtml : // put a natural space in between - timeHtml + ' ' + titleHtml // - ) + - '</div>' + - (isResizableFromStart ? - '<div class="fc-resizer fc-start-resizer" />' : - '' - ) + - (isResizableFromEnd ? - '<div class="fc-resizer fc-end-resizer" />' : - '' - ) + - '</a>'; - }, - - - // Given a row # and an array of segments all in the same row, render a <tbody> element, a skeleton that contains - // the segments. Returns object with a bunch of internal data about how the render was calculated. - // NOTE: modifies rowSegs - renderSegRow: function(row, rowSegs) { - var colCnt = this.colCnt; - var segLevels = this.buildSegLevels(rowSegs); // group into sub-arrays of levels - var levelCnt = Math.max(1, segLevels.length); // ensure at least one level - var tbody = $('<tbody/>'); - var segMatrix = []; // lookup for which segments are rendered into which level+col cells - var cellMatrix = []; // lookup for all <td> elements of the level+col matrix - var loneCellMatrix = []; // lookup for <td> elements that only take up a single column - var i, levelSegs; - var col; - var tr; - var j, seg; - var td; - - // populates empty cells from the current column (`col`) to `endCol` - function emptyCellsUntil(endCol) { - while (col < endCol) { - // try to grab a cell from the level above and extend its rowspan. otherwise, create a fresh cell - td = (loneCellMatrix[i - 1] || [])[col]; - if (td) { - td.attr( - 'rowspan', - parseInt(td.attr('rowspan') || 1, 10) + 1 - ); - } - else { - td = $('<td/>'); - tr.append(td); - } - cellMatrix[i][col] = td; - loneCellMatrix[i][col] = td; - col++; - } - } - - for (i = 0; i < levelCnt; i++) { // iterate through all levels - levelSegs = segLevels[i]; - col = 0; - tr = $('<tr/>'); - - segMatrix.push([]); - cellMatrix.push([]); - loneCellMatrix.push([]); - - // levelCnt might be 1 even though there are no actual levels. protect against this. - // this single empty row is useful for styling. - if (levelSegs) { - for (j = 0; j < levelSegs.length; j++) { // iterate through segments in level - seg = levelSegs[j]; - - emptyCellsUntil(seg.leftCol); - - // create a container that occupies or more columns. append the event element. - td = $('<td class="fc-event-container"/>').append(seg.el); - if (seg.leftCol != seg.rightCol) { - td.attr('colspan', seg.rightCol - seg.leftCol + 1); - } - else { // a single-column segment - loneCellMatrix[i][col] = td; - } - - while (col <= seg.rightCol) { - cellMatrix[i][col] = td; - segMatrix[i][col] = seg; - col++; - } - - tr.append(td); - } - } - - emptyCellsUntil(colCnt); // finish off the row - this.bookendCells(tr, 'eventSkeleton'); - tbody.append(tr); - } - - return { // a "rowStruct" - row: row, // the row number - tbodyEl: tbody, - cellMatrix: cellMatrix, - segMatrix: segMatrix, - segLevels: segLevels, - segs: rowSegs - }; - }, - - - // Stacks a flat array of segments, which are all assumed to be in the same row, into subarrays of vertical levels. - // NOTE: modifies segs - buildSegLevels: function(segs) { - var levels = []; - var i, seg; - var j; - - // Give preference to elements with certain criteria, so they have - // a chance to be closer to the top. - segs.sort(compareSegs); - - for (i = 0; i < segs.length; i++) { - seg = segs[i]; - - // loop through levels, starting with the topmost, until the segment doesn't collide with other segments - for (j = 0; j < levels.length; j++) { - if (!isDaySegCollision(seg, levels[j])) { - break; - } - } - // `j` now holds the desired subrow index - seg.level = j; - - // create new level array if needed and append segment - (levels[j] || (levels[j] = [])).push(seg); - } - - // order segments left-to-right. very important if calendar is RTL - for (j = 0; j < levels.length; j++) { - levels[j].sort(compareDaySegCols); - } - - return levels; - }, - - - // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's row - groupSegRows: function(segs) { - var segRows = []; - var i; - - for (i = 0; i < this.rowCnt; i++) { - segRows.push([]); - } - - for (i = 0; i < segs.length; i++) { - segRows[segs[i].row].push(segs[i]); - } - - return segRows; - } - -}); - - -// Computes whether two segments' columns collide. They are assumed to be in the same row. -function isDaySegCollision(seg, otherSegs) { - var i, otherSeg; - - for (i = 0; i < otherSegs.length; i++) { - otherSeg = otherSegs[i]; - - if ( - otherSeg.leftCol <= seg.rightCol && - otherSeg.rightCol >= seg.leftCol - ) { - return true; - } - } - - return false; -} - - -// A cmp function for determining the leftmost event -function compareDaySegCols(a, b) { - return a.leftCol - b.leftCol; -} - -;; - -/* Methods relate to limiting the number events for a given day on a DayGrid -----------------------------------------------------------------------------------------------------------------------*/ -// NOTE: all the segs being passed around in here are foreground segs - -DayGrid.mixin({ - - segPopover: null, // the Popover that holds events that can't fit in a cell. null when not visible - popoverSegs: null, // an array of segment objects that the segPopover holds. null when not visible - - - removeSegPopover: function() { - if (this.segPopover) { - this.segPopover.hide(); // in handler, will call segPopover's removeElement - } - }, - - - // Limits the number of "levels" (vertically stacking layers of events) for each row of the grid. - // `levelLimit` can be false (don't limit), a number, or true (should be computed). - limitRows: function(levelLimit) { - var rowStructs = this.rowStructs || []; - var row; // row # - var rowLevelLimit; - - for (row = 0; row < rowStructs.length; row++) { - this.unlimitRow(row); - - if (!levelLimit) { - rowLevelLimit = false; - } - else if (typeof levelLimit === 'number') { - rowLevelLimit = levelLimit; - } - else { - rowLevelLimit = this.computeRowLevelLimit(row); - } - - if (rowLevelLimit !== false) { - this.limitRow(row, rowLevelLimit); - } - } - }, - - - // Computes the number of levels a row will accomodate without going outside its bounds. - // Assumes the row is "rigid" (maintains a constant height regardless of what is inside). - // `row` is the row number. - computeRowLevelLimit: function(row) { - var rowEl = this.rowEls.eq(row); // the containing "fake" row div - var rowHeight = rowEl.height(); // TODO: cache somehow? - var trEls = this.rowStructs[row].tbodyEl.children(); - var i, trEl; - var trHeight; - - function iterInnerHeights(i, childNode) { - trHeight = Math.max(trHeight, $(childNode).outerHeight()); - } - - // Reveal one level <tr> at a time and stop when we find one out of bounds - for (i = 0; i < trEls.length; i++) { - trEl = trEls.eq(i).removeClass('fc-limited'); // reset to original state (reveal) - - // with rowspans>1 and IE8, trEl.outerHeight() would return the height of the largest cell, - // so instead, find the tallest inner content element. - trHeight = 0; - trEl.find('> td > :first-child').each(iterInnerHeights); - - if (trEl.position().top + trHeight > rowHeight) { - return i; - } - } - - return false; // should not limit at all - }, - - - // Limits the given grid row to the maximum number of levels and injects "more" links if necessary. - // `row` is the row number. - // `levelLimit` is a number for the maximum (inclusive) number of levels allowed. - limitRow: function(row, levelLimit) { - var _this = this; - var rowStruct = this.rowStructs[row]; - var moreNodes = []; // array of "more" <a> links and <td> DOM nodes - var col = 0; // col #, left-to-right (not chronologically) - var cell; - var levelSegs; // array of segment objects in the last allowable level, ordered left-to-right - var cellMatrix; // a matrix (by level, then column) of all <td> jQuery elements in the row - var limitedNodes; // array of temporarily hidden level <tr> and segment <td> DOM nodes - var i, seg; - var segsBelow; // array of segment objects below `seg` in the current `col` - var totalSegsBelow; // total number of segments below `seg` in any of the columns `seg` occupies - var colSegsBelow; // array of segment arrays, below seg, one for each column (offset from segs's first column) - var td, rowspan; - var segMoreNodes; // array of "more" <td> cells that will stand-in for the current seg's cell - var j; - var moreTd, moreWrap, moreLink; - - // Iterates through empty level cells and places "more" links inside if need be - function emptyCellsUntil(endCol) { // goes from current `col` to `endCol` - while (col < endCol) { - cell = _this.getCell(row, col); - segsBelow = _this.getCellSegs(cell, levelLimit); - if (segsBelow.length) { - td = cellMatrix[levelLimit - 1][col]; - moreLink = _this.renderMoreLink(cell, segsBelow); - moreWrap = $('<div/>').append(moreLink); - td.append(moreWrap); - moreNodes.push(moreWrap[0]); - } - col++; - } - } - - if (levelLimit && levelLimit < rowStruct.segLevels.length) { // is it actually over the limit? - levelSegs = rowStruct.segLevels[levelLimit - 1]; - cellMatrix = rowStruct.cellMatrix; - - limitedNodes = rowStruct.tbodyEl.children().slice(levelLimit) // get level <tr> elements past the limit - .addClass('fc-limited').get(); // hide elements and get a simple DOM-nodes array - - // iterate though segments in the last allowable level - for (i = 0; i < levelSegs.length; i++) { - seg = levelSegs[i]; - emptyCellsUntil(seg.leftCol); // process empty cells before the segment - - // determine *all* segments below `seg` that occupy the same columns - colSegsBelow = []; - totalSegsBelow = 0; - while (col <= seg.rightCol) { - cell = this.getCell(row, col); - segsBelow = this.getCellSegs(cell, levelLimit); - colSegsBelow.push(segsBelow); - totalSegsBelow += segsBelow.length; - col++; - } - - if (totalSegsBelow) { // do we need to replace this segment with one or many "more" links? - td = cellMatrix[levelLimit - 1][seg.leftCol]; // the segment's parent cell - rowspan = td.attr('rowspan') || 1; - segMoreNodes = []; - - // make a replacement <td> for each column the segment occupies. will be one for each colspan - for (j = 0; j < colSegsBelow.length; j++) { - moreTd = $('<td class="fc-more-cell"/>').attr('rowspan', rowspan); - segsBelow = colSegsBelow[j]; - cell = this.getCell(row, seg.leftCol + j); - moreLink = this.renderMoreLink(cell, [ seg ].concat(segsBelow)); // count seg as hidden too - moreWrap = $('<div/>').append(moreLink); - moreTd.append(moreWrap); - segMoreNodes.push(moreTd[0]); - moreNodes.push(moreTd[0]); - } - - td.addClass('fc-limited').after($(segMoreNodes)); // hide original <td> and inject replacements - limitedNodes.push(td[0]); - } - } - - emptyCellsUntil(this.colCnt); // finish off the level - rowStruct.moreEls = $(moreNodes); // for easy undoing later - rowStruct.limitedEls = $(limitedNodes); // for easy undoing later - } - }, - - - // Reveals all levels and removes all "more"-related elements for a grid's row. - // `row` is a row number. - unlimitRow: function(row) { - var rowStruct = this.rowStructs[row]; - - if (rowStruct.moreEls) { - rowStruct.moreEls.remove(); - rowStruct.moreEls = null; - } - - if (rowStruct.limitedEls) { - rowStruct.limitedEls.removeClass('fc-limited'); - rowStruct.limitedEls = null; - } - }, - - - // Renders an <a> element that represents hidden event element for a cell. - // Responsible for attaching click handler as well. - renderMoreLink: function(cell, hiddenSegs) { - var _this = this; - var view = this.view; - - return $('<a class="fc-more"/>') - .text( - this.getMoreLinkText(hiddenSegs.length) - ) - .on('click', function(ev) { - var clickOption = view.opt('eventLimitClick'); - var date = cell.start; - var moreEl = $(this); - var dayEl = _this.getCellDayEl(cell); - var allSegs = _this.getCellSegs(cell); - - // rescope the segments to be within the cell's date - var reslicedAllSegs = _this.resliceDaySegs(allSegs, date); - var reslicedHiddenSegs = _this.resliceDaySegs(hiddenSegs, date); - - if (typeof clickOption === 'function') { - // the returned value can be an atomic option - clickOption = view.trigger('eventLimitClick', null, { - date: date, - dayEl: dayEl, - moreEl: moreEl, - segs: reslicedAllSegs, - hiddenSegs: reslicedHiddenSegs - }, ev); - } - - if (clickOption === 'popover') { - _this.showSegPopover(cell, moreEl, reslicedAllSegs); - } - else if (typeof clickOption === 'string') { // a view name - view.calendar.zoomTo(date, clickOption); - } - }); - }, - - - // Reveals the popover that displays all events within a cell - showSegPopover: function(cell, moreLink, segs) { - var _this = this; - var view = this.view; - var moreWrap = moreLink.parent(); // the <div> wrapper around the <a> - var topEl; // the element we want to match the top coordinate of - var options; - - if (this.rowCnt == 1) { - topEl = view.el; // will cause the popover to cover any sort of header - } - else { - topEl = this.rowEls.eq(cell.row); // will align with top of row - } - - options = { - className: 'fc-more-popover', - content: this.renderSegPopoverContent(cell, segs), - parentEl: this.el, - top: topEl.offset().top, - autoHide: true, // when the user clicks elsewhere, hide the popover - viewportConstrain: view.opt('popoverViewportConstrain'), - hide: function() { - // kill everything when the popover is hidden - _this.segPopover.removeElement(); - _this.segPopover = null; - _this.popoverSegs = null; - } - }; - - // Determine horizontal coordinate. - // We use the moreWrap instead of the <td> to avoid border confusion. - if (this.isRTL) { - options.right = moreWrap.offset().left + moreWrap.outerWidth() + 1; // +1 to be over cell border - } - else { - options.left = moreWrap.offset().left - 1; // -1 to be over cell border - } - - this.segPopover = new Popover(options); - this.segPopover.show(); - }, - - - // Builds the inner DOM contents of the segment popover - renderSegPopoverContent: function(cell, segs) { - var view = this.view; - var isTheme = view.opt('theme'); - var title = cell.start.format(view.opt('dayPopoverFormat')); - var content = $( - '<div class="fc-header ' + view.widgetHeaderClass + '">' + - '<span class="fc-close ' + - (isTheme ? 'ui-icon ui-icon-closethick' : 'fc-icon fc-icon-x') + - '"></span>' + - '<span class="fc-title">' + - htmlEscape(title) + - '</span>' + - '<div class="fc-clear"/>' + - '</div>' + - '<div class="fc-body ' + view.widgetContentClass + '">' + - '<div class="fc-event-container"></div>' + - '</div>' - ); - var segContainer = content.find('.fc-event-container'); - var i; - - // render each seg's `el` and only return the visible segs - segs = this.renderFgSegEls(segs, true); // disableResizing=true - this.popoverSegs = segs; - - for (i = 0; i < segs.length; i++) { - - // because segments in the popover are not part of a grid coordinate system, provide a hint to any - // grids that want to do drag-n-drop about which cell it came from - segs[i].cell = cell; - - segContainer.append(segs[i].el); - } - - return content; - }, - - - // Given the events within an array of segment objects, reslice them to be in a single day - resliceDaySegs: function(segs, dayDate) { - - // build an array of the original events - var events = $.map(segs, function(seg) { - return seg.event; - }); - - var dayStart = dayDate.clone().stripTime(); - var dayEnd = dayStart.clone().add(1, 'days'); - var dayRange = { start: dayStart, end: dayEnd }; - - // slice the events with a custom slicing function - segs = this.eventsToSegs( - events, - function(range) { - var seg = intersectionToSeg(range, dayRange); // undefind if no intersection - return seg ? [ seg ] : []; // must return an array of segments - } - ); - - // force an order because eventsToSegs doesn't guarantee one - segs.sort(compareSegs); - - return segs; - }, - - - // Generates the text that should be inside a "more" link, given the number of events it represents - getMoreLinkText: function(num) { - var opt = this.view.opt('eventLimitText'); - - if (typeof opt === 'function') { - return opt(num); - } - else { - return '+' + num + ' ' + opt; - } - }, - - - // Returns segments within a given cell. - // If `startLevel` is specified, returns only events including and below that level. Otherwise returns all segs. - getCellSegs: function(cell, startLevel) { - var segMatrix = this.rowStructs[cell.row].segMatrix; - var level = startLevel || 0; - var segs = []; - var seg; - - while (level < segMatrix.length) { - seg = segMatrix[level][cell.col]; - if (seg) { - segs.push(seg); - } - level++; - } - - return segs; - } - -}); - -;; - -/* A component that renders one or more columns of vertical time slots -----------------------------------------------------------------------------------------------------------------------*/ - -var TimeGrid = Grid.extend({ - - slotDuration: null, // duration of a "slot", a distinct time segment on given day, visualized by lines - snapDuration: null, // granularity of time for dragging and selecting - minTime: null, // Duration object that denotes the first visible time of any given day - maxTime: null, // Duration object that denotes the exclusive visible end time of any given day - colDates: null, // whole-day dates for each column. left to right - axisFormat: null, // formatting string for times running along vertical axis - - dayEls: null, // cells elements in the day-row background - slatEls: null, // elements running horizontally across all columns - - slatTops: null, // an array of top positions, relative to the container. last item holds bottom of last slot - - helperEl: null, // cell skeleton element for rendering the mock event "helper" - - businessHourSegs: null, - - - constructor: function() { - Grid.apply(this, arguments); // call the super-constructor - this.processOptions(); - }, - - - // Renders the time grid into `this.el`, which should already be assigned. - // Relies on the view's colCnt. In the future, this component should probably be self-sufficient. - renderDates: function() { - this.el.html(this.renderHtml()); - this.dayEls = this.el.find('.fc-day'); - this.slatEls = this.el.find('.fc-slats tr'); - }, - - - renderBusinessHours: function() { - var events = this.view.calendar.getBusinessHoursEvents(); - this.businessHourSegs = this.renderFill('businessHours', this.eventsToSegs(events), 'bgevent'); - }, - - - // Renders the basic HTML skeleton for the grid - renderHtml: function() { - return '' + - '<div class="fc-bg">' + - '<table>' + - this.rowHtml('slotBg') + // leverages RowRenderer, which will call slotBgCellHtml - '</table>' + - '</div>' + - '<div class="fc-slats">' + - '<table>' + - this.slatRowHtml() + - '</table>' + - '</div>'; - }, - - - // Renders the HTML for a vertical background cell behind the slots. - // This method is distinct from 'bg' because we wanted a new `rowType` so the View could customize the rendering. - slotBgCellHtml: function(cell) { - return this.bgCellHtml(cell); - }, - - - // Generates the HTML for the horizontal "slats" that run width-wise. Has a time axis on a side. Depends on RTL. - slatRowHtml: function() { - var view = this.view; - var isRTL = this.isRTL; - var html = ''; - var slotNormal = this.slotDuration.asMinutes() % 15 === 0; - var slotTime = moment.duration(+this.minTime); // wish there was .clone() for durations - var slotDate; // will be on the view's first day, but we only care about its time - var minutes; - var axisHtml; - - // Calculate the time for each slot - while (slotTime < this.maxTime) { - slotDate = this.start.clone().time(slotTime); // will be in UTC but that's good. to avoid DST issues - minutes = slotDate.minutes(); - - axisHtml = - '<td class="fc-axis fc-time ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '>' + - ((!slotNormal || !minutes) ? // if irregular slot duration, or on the hour, then display the time - '<span>' + // for matchCellWidths - htmlEscape(slotDate.format(this.axisFormat)) + - '</span>' : - '' - ) + - '</td>'; - - html += - '<tr ' + (!minutes ? '' : 'class="fc-minor"') + '>' + - (!isRTL ? axisHtml : '') + - '<td class="' + view.widgetContentClass + '"/>' + - (isRTL ? axisHtml : '') + - "</tr>"; - - slotTime.add(this.slotDuration); - } - - return html; - }, - - - /* Options - ------------------------------------------------------------------------------------------------------------------*/ - - - // Parses various options into properties of this object - processOptions: function() { - var view = this.view; - var slotDuration = view.opt('slotDuration'); - var snapDuration = view.opt('snapDuration'); - - slotDuration = moment.duration(slotDuration); - snapDuration = snapDuration ? moment.duration(snapDuration) : slotDuration; - - this.slotDuration = slotDuration; - this.snapDuration = snapDuration; - this.cellDuration = snapDuration; // for Grid system - - this.minTime = moment.duration(view.opt('minTime')); - this.maxTime = moment.duration(view.opt('maxTime')); - - this.axisFormat = view.opt('axisFormat') || view.opt('smallTimeFormat'); - }, - - - // Computes a default column header formatting string if `colFormat` is not explicitly defined - computeColHeadFormat: function() { - if (this.colCnt > 1) { // multiple days, so full single date string WON'T be in title text - return this.view.opt('dayOfMonthFormat'); // "Sat 12/10" - } - else { // single day, so full single date string will probably be in title text - return 'dddd'; // "Saturday" - } - }, - - - // Computes a default event time formatting string if `timeFormat` is not explicitly defined - computeEventTimeFormat: function() { - return this.view.opt('noMeridiemTimeFormat'); // like "6:30" (no AM/PM) - }, - - - // Computes a default `displayEventEnd` value if one is not expliclty defined - computeDisplayEventEnd: function() { - return true; - }, - - - /* Cell System - ------------------------------------------------------------------------------------------------------------------*/ - - - rangeUpdated: function() { - var view = this.view; - var colDates = []; - var date; - - date = this.start.clone(); - while (date.isBefore(this.end)) { - colDates.push(date.clone()); - date.add(1, 'day'); - date = view.skipHiddenDays(date); - } - - if (this.isRTL) { - colDates.reverse(); - } - - this.colDates = colDates; - this.colCnt = colDates.length; - this.rowCnt = Math.ceil((this.maxTime - this.minTime) / this.snapDuration); // # of vertical snaps - }, - - - // Given a cell object, generates its start date. Returns a reference-free copy. - computeCellDate: function(cell) { - var date = this.colDates[cell.col]; - var time = this.computeSnapTime(cell.row); - - date = this.view.calendar.rezoneDate(date); // give it a 00:00 time - date.time(time); - - return date; - }, - - - // Retrieves the element representing the given column - getColEl: function(col) { - return this.dayEls.eq(col); - }, - - - /* Dates - ------------------------------------------------------------------------------------------------------------------*/ - - - // Given a row number of the grid, representing a "snap", returns a time (Duration) from its start-of-day - computeSnapTime: function(row) { - return moment.duration(this.minTime + this.snapDuration * row); - }, - - - // Slices up a date range by column into an array of segments - rangeToSegs: function(range) { - var colCnt = this.colCnt; - var segs = []; - var seg; - var col; - var colDate; - var colRange; - - // normalize :( - range = { - start: range.start.clone().stripZone(), - end: range.end.clone().stripZone() - }; - - for (col = 0; col < colCnt; col++) { - colDate = this.colDates[col]; // will be ambig time/timezone - colRange = { - start: colDate.clone().time(this.minTime), - end: colDate.clone().time(this.maxTime) - }; - seg = intersectionToSeg(range, colRange); // both will be ambig timezone - if (seg) { - seg.col = col; - segs.push(seg); - } - } - - return segs; - }, - - - /* Coordinates - ------------------------------------------------------------------------------------------------------------------*/ - - - updateSize: function(isResize) { // NOT a standard Grid method - this.computeSlatTops(); - - if (isResize) { - this.updateSegVerticals(); - } - }, - - - // Computes the top/bottom coordinates of each "snap" rows - computeRowCoords: function() { - var originTop = this.el.offset().top; - var items = []; - var i; - var item; - - for (i = 0; i < this.rowCnt; i++) { - item = { - top: originTop + this.computeTimeTop(this.computeSnapTime(i)) - }; - if (i > 0) { - items[i - 1].bottom = item.top; - } - items.push(item); - } - item.bottom = item.top + this.computeTimeTop(this.computeSnapTime(i)); - - return items; - }, - - - // Computes the top coordinate, relative to the bounds of the grid, of the given date. - // A `startOfDayDate` must be given for avoiding ambiguity over how to treat midnight. - computeDateTop: function(date, startOfDayDate) { - return this.computeTimeTop( - moment.duration( - date.clone().stripZone() - startOfDayDate.clone().stripTime() - ) - ); - }, - - - // Computes the top coordinate, relative to the bounds of the grid, of the given time (a Duration). - computeTimeTop: function(time) { - var slatCoverage = (time - this.minTime) / this.slotDuration; // floating-point value of # of slots covered - var slatIndex; - var slatRemainder; - var slatTop; - var slatBottom; - - // constrain. because minTime/maxTime might be customized - slatCoverage = Math.max(0, slatCoverage); - slatCoverage = Math.min(this.slatEls.length, slatCoverage); - - slatIndex = Math.floor(slatCoverage); // an integer index of the furthest whole slot - slatRemainder = slatCoverage - slatIndex; - slatTop = this.slatTops[slatIndex]; // the top position of the furthest whole slot - - if (slatRemainder) { // time spans part-way into the slot - slatBottom = this.slatTops[slatIndex + 1]; - return slatTop + (slatBottom - slatTop) * slatRemainder; // part-way between slots - } - else { - return slatTop; - } - }, - - - // Queries each `slatEl` for its position relative to the grid's container and stores it in `slatTops`. - // Includes the the bottom of the last slat as the last item in the array. - computeSlatTops: function() { - var tops = []; - var top; - - this.slatEls.each(function(i, node) { - top = $(node).position().top; - tops.push(top); - }); - - tops.push(top + this.slatEls.last().outerHeight()); // bottom of the last slat - - this.slatTops = tops; - }, - - - /* Event Drag Visualization - ------------------------------------------------------------------------------------------------------------------*/ - - - // Renders a visual indication of an event being dragged over the specified date(s). - // dropLocation's end might be null, as well as `seg`. See Grid::renderDrag for more info. - // A returned value of `true` signals that a mock "helper" event has been rendered. - renderDrag: function(dropLocation, seg) { - - if (seg) { // if there is event information for this drag, render a helper event - this.renderRangeHelper(dropLocation, seg); - this.applyDragOpacity(this.helperEl); - - return true; // signal that a helper has been rendered - } - else { - // otherwise, just render a highlight - this.renderHighlight(this.eventRangeToSegs(dropLocation)); - } - }, - - - // Unrenders any visual indication of an event being dragged - unrenderDrag: function() { - this.unrenderHelper(); - this.unrenderHighlight(); - }, - - - /* Event Resize Visualization - ------------------------------------------------------------------------------------------------------------------*/ - - - // Renders a visual indication of an event being resized - renderEventResize: function(range, seg) { - this.renderRangeHelper(range, seg); - }, - - - // Unrenders any visual indication of an event being resized - unrenderEventResize: function() { - this.unrenderHelper(); - }, - - - /* Event Helper - ------------------------------------------------------------------------------------------------------------------*/ - - - // Renders a mock "helper" event. `sourceSeg` is the original segment object and might be null (an external drag) - renderHelper: function(event, sourceSeg) { - var segs = this.eventsToSegs([ event ]); - var tableEl; - var i, seg; - var sourceEl; - - segs = this.renderFgSegEls(segs); // assigns each seg's el and returns a subset of segs that were rendered - tableEl = this.renderSegTable(segs); - - // Try to make the segment that is in the same row as sourceSeg look the same - for (i = 0; i < segs.length; i++) { - seg = segs[i]; - if (sourceSeg && sourceSeg.col === seg.col) { - sourceEl = sourceSeg.el; - seg.el.css({ - left: sourceEl.css('left'), - right: sourceEl.css('right'), - 'margin-left': sourceEl.css('margin-left'), - 'margin-right': sourceEl.css('margin-right') - }); - } - } - - this.helperEl = $('<div class="fc-helper-skeleton"/>') - .append(tableEl) - .appendTo(this.el); - }, - - - // Unrenders any mock helper event - unrenderHelper: function() { - if (this.helperEl) { - this.helperEl.remove(); - this.helperEl = null; - } - }, - - - /* Selection - ------------------------------------------------------------------------------------------------------------------*/ - - - // Renders a visual indication of a selection. Overrides the default, which was to simply render a highlight. - renderSelection: function(range) { - if (this.view.opt('selectHelper')) { // this setting signals that a mock helper event should be rendered - this.renderRangeHelper(range); - } - else { - this.renderHighlight(this.selectionRangeToSegs(range)); - } - }, - - - // Unrenders any visual indication of a selection - unrenderSelection: function() { - this.unrenderHelper(); - this.unrenderHighlight(); - }, - - - /* Fill System (highlight, background events, business hours) - ------------------------------------------------------------------------------------------------------------------*/ - - - // Renders a set of rectangles over the given time segments. - // Only returns segments that successfully rendered. - renderFill: function(type, segs, className) { - var segCols; - var skeletonEl; - var trEl; - var col, colSegs; - var tdEl; - var containerEl; - var dayDate; - var i, seg; - - if (segs.length) { - - segs = this.renderFillSegEls(type, segs); // assignes `.el` to each seg. returns successfully rendered segs - segCols = this.groupSegCols(segs); // group into sub-arrays, and assigns 'col' to each seg - - className = className || type.toLowerCase(); - skeletonEl = $( - '<div class="fc-' + className + '-skeleton">' + - '<table><tr/></table>' + - '</div>' - ); - trEl = skeletonEl.find('tr'); - - for (col = 0; col < segCols.length; col++) { - colSegs = segCols[col]; - tdEl = $('<td/>').appendTo(trEl); - - if (colSegs.length) { - containerEl = $('<div class="fc-' + className + '-container"/>').appendTo(tdEl); - dayDate = this.colDates[col]; - - for (i = 0; i < colSegs.length; i++) { - seg = colSegs[i]; - containerEl.append( - seg.el.css({ - top: this.computeDateTop(seg.start, dayDate), - bottom: -this.computeDateTop(seg.end, dayDate) // the y position of the bottom edge - }) - ); - } - } - } - - this.bookendCells(trEl, type); - - this.el.append(skeletonEl); - this.elsByFill[type] = skeletonEl; - } - - return segs; - } - -}); - -;; - -/* Event-rendering methods for the TimeGrid class -----------------------------------------------------------------------------------------------------------------------*/ - -TimeGrid.mixin({ - - eventSkeletonEl: null, // has cells with event-containers, which contain absolutely positioned event elements - - - // Renders the given foreground event segments onto the grid - renderFgSegs: function(segs) { - segs = this.renderFgSegEls(segs); // returns a subset of the segs. segs that were actually rendered - - this.el.append( - this.eventSkeletonEl = $('<div class="fc-content-skeleton"/>') - .append(this.renderSegTable(segs)) - ); - - return segs; // return only the segs that were actually rendered - }, - - - // Unrenders all currently rendered foreground event segments - unrenderFgSegs: function(segs) { - if (this.eventSkeletonEl) { - this.eventSkeletonEl.remove(); - this.eventSkeletonEl = null; - } - }, - - - // Renders and returns the <table> portion of the event-skeleton. - // Returns an object with properties 'tbodyEl' and 'segs'. - renderSegTable: function(segs) { - var tableEl = $('<table><tr/></table>'); - var trEl = tableEl.find('tr'); - var segCols; - var i, seg; - var col, colSegs; - var containerEl; - - segCols = this.groupSegCols(segs); // group into sub-arrays, and assigns 'col' to each seg - - this.computeSegVerticals(segs); // compute and assign top/bottom - - for (col = 0; col < segCols.length; col++) { // iterate each column grouping - colSegs = segCols[col]; - placeSlotSegs(colSegs); // compute horizontal coordinates, z-index's, and reorder the array - - containerEl = $('<div class="fc-event-container"/>'); - - // assign positioning CSS and insert into container - for (i = 0; i < colSegs.length; i++) { - seg = colSegs[i]; - seg.el.css(this.generateSegPositionCss(seg)); - - // if the height is short, add a className for alternate styling - if (seg.bottom - seg.top < 30) { - seg.el.addClass('fc-short'); - } - - containerEl.append(seg.el); - } - - trEl.append($('<td/>').append(containerEl)); - } - - this.bookendCells(trEl, 'eventSkeleton'); - - return tableEl; - }, - - - // Refreshes the CSS top/bottom coordinates for each segment element. Probably after a window resize/zoom. - // Repositions business hours segs too, so not just for events. Maybe shouldn't be here. - updateSegVerticals: function() { - var allSegs = (this.segs || []).concat(this.businessHourSegs || []); - var i; - - this.computeSegVerticals(allSegs); - - for (i = 0; i < allSegs.length; i++) { - allSegs[i].el.css( - this.generateSegVerticalCss(allSegs[i]) - ); - } - }, - - - // For each segment in an array, computes and assigns its top and bottom properties - computeSegVerticals: function(segs) { - var i, seg; - - for (i = 0; i < segs.length; i++) { - seg = segs[i]; - seg.top = this.computeDateTop(seg.start, seg.start); - seg.bottom = this.computeDateTop(seg.end, seg.start); - } - }, - - - // Renders the HTML for a single event segment's default rendering - fgSegHtml: function(seg, disableResizing) { - var view = this.view; - var event = seg.event; - var isDraggable = view.isEventDraggable(event); - var isResizableFromStart = !disableResizing && seg.isStart && view.isEventResizableFromStart(event); - var isResizableFromEnd = !disableResizing && seg.isEnd && view.isEventResizableFromEnd(event); - var classes = this.getSegClasses(seg, isDraggable, isResizableFromStart || isResizableFromEnd); - var skinCss = cssToStr(this.getEventSkinCss(event)); - var timeText; - var fullTimeText; // more verbose time text. for the print stylesheet - var startTimeText; // just the start time text - - classes.unshift('fc-time-grid-event', 'fc-v-event'); - - if (view.isMultiDayEvent(event)) { // if the event appears to span more than one day... - // Don't display time text on segments that run entirely through a day. - // That would appear as midnight-midnight and would look dumb. - // Otherwise, display the time text for the *segment's* times (like 6pm-midnight or midnight-10am) - if (seg.isStart || seg.isEnd) { - timeText = this.getEventTimeText(seg); - fullTimeText = this.getEventTimeText(seg, 'LT'); - startTimeText = this.getEventTimeText(seg, null, false); // displayEnd=false - } - } else { - // Display the normal time text for the *event's* times - timeText = this.getEventTimeText(event); - fullTimeText = this.getEventTimeText(event, 'LT'); - startTimeText = this.getEventTimeText(event, null, false); // displayEnd=false - } - - return '<a class="' + classes.join(' ') + '"' + - (event.url ? - ' href="' + htmlEscape(event.url) + '"' : - '' - ) + - (skinCss ? - ' style="' + skinCss + '"' : - '' - ) + - '>' + - '<div class="fc-content">' + - (timeText ? - '<div class="fc-time"' + - ' data-start="' + htmlEscape(startTimeText) + '"' + - ' data-full="' + htmlEscape(fullTimeText) + '"' + - '>' + - '<span>' + htmlEscape(timeText) + '</span>' + - '</div>' : - '' - ) + - (event.title ? - '<div class="fc-title">' + - htmlEscape(event.title) + - '</div>' : - '' - ) + - '</div>' + - '<div class="fc-bg"/>' + - /* TODO: write CSS for this - (isResizableFromStart ? - '<div class="fc-resizer fc-start-resizer" />' : - '' - ) + - */ - (isResizableFromEnd ? - '<div class="fc-resizer fc-end-resizer" />' : - '' - ) + - '</a>'; - }, - - - // Generates an object with CSS properties/values that should be applied to an event segment element. - // Contains important positioning-related properties that should be applied to any event element, customized or not. - generateSegPositionCss: function(seg) { - var shouldOverlap = this.view.opt('slotEventOverlap'); - var backwardCoord = seg.backwardCoord; // the left side if LTR. the right side if RTL. floating-point - var forwardCoord = seg.forwardCoord; // the right side if LTR. the left side if RTL. floating-point - var props = this.generateSegVerticalCss(seg); // get top/bottom first - var left; // amount of space from left edge, a fraction of the total width - var right; // amount of space from right edge, a fraction of the total width - - if (shouldOverlap) { - // double the width, but don't go beyond the maximum forward coordinate (1.0) - forwardCoord = Math.min(1, backwardCoord + (forwardCoord - backwardCoord) * 2); - } - - if (this.isRTL) { - left = 1 - forwardCoord; - right = backwardCoord; - } - else { - left = backwardCoord; - right = 1 - forwardCoord; - } - - props.zIndex = seg.level + 1; // convert from 0-base to 1-based - props.left = left * 100 + '%'; - props.right = right * 100 + '%'; - - if (shouldOverlap && seg.forwardPressure) { - // add padding to the edge so that forward stacked events don't cover the resizer's icon - props[this.isRTL ? 'marginLeft' : 'marginRight'] = 10 * 2; // 10 is a guesstimate of the icon's width - } - - return props; - }, - - - // Generates an object with CSS properties for the top/bottom coordinates of a segment element - generateSegVerticalCss: function(seg) { - return { - top: seg.top, - bottom: -seg.bottom // flipped because needs to be space beyond bottom edge of event container - }; - }, - - - // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's col - groupSegCols: function(segs) { - var segCols = []; - var i; - - for (i = 0; i < this.colCnt; i++) { - segCols.push([]); - } - - for (i = 0; i < segs.length; i++) { - segCols[segs[i].col].push(segs[i]); - } - - return segCols; - } - -}); - - -// Given an array of segments that are all in the same column, sets the backwardCoord and forwardCoord on each. -// NOTE: Also reorders the given array by date! -function placeSlotSegs(segs) { - var levels; - var level0; - var i; - - segs.sort(compareSegs); // order by date - levels = buildSlotSegLevels(segs); - computeForwardSlotSegs(levels); - - if ((level0 = levels[0])) { - - for (i = 0; i < level0.length; i++) { - computeSlotSegPressures(level0[i]); - } - - for (i = 0; i < level0.length; i++) { - computeSlotSegCoords(level0[i], 0, 0); - } - } -} - - -// Builds an array of segments "levels". The first level will be the leftmost tier of segments if the calendar is -// left-to-right, or the rightmost if the calendar is right-to-left. Assumes the segments are already ordered by date. -function buildSlotSegLevels(segs) { - var levels = []; - var i, seg; - var j; - - for (i=0; i<segs.length; i++) { - seg = segs[i]; - - // go through all the levels and stop on the first level where there are no collisions - for (j=0; j<levels.length; j++) { - if (!computeSlotSegCollisions(seg, levels[j]).length) { - break; - } - } - - seg.level = j; - - (levels[j] || (levels[j] = [])).push(seg); - } - - return levels; -} - - -// For every segment, figure out the other segments that are in subsequent -// levels that also occupy the same vertical space. Accumulate in seg.forwardSegs -function computeForwardSlotSegs(levels) { - var i, level; - var j, seg; - var k; - - for (i=0; i<levels.length; i++) { - level = levels[i]; - - for (j=0; j<level.length; j++) { - seg = level[j]; - - seg.forwardSegs = []; - for (k=i+1; k<levels.length; k++) { - computeSlotSegCollisions(seg, levels[k], seg.forwardSegs); - } - } - } -} - - -// Figure out which path forward (via seg.forwardSegs) results in the longest path until -// the furthest edge is reached. The number of segments in this path will be seg.forwardPressure -function computeSlotSegPressures(seg) { - var forwardSegs = seg.forwardSegs; - var forwardPressure = 0; - var i, forwardSeg; - - if (seg.forwardPressure === undefined) { // not already computed - - for (i=0; i<forwardSegs.length; i++) { - forwardSeg = forwardSegs[i]; - - // figure out the child's maximum forward path - computeSlotSegPressures(forwardSeg); - - // either use the existing maximum, or use the child's forward pressure - // plus one (for the forwardSeg itself) - forwardPressure = Math.max( - forwardPressure, - 1 + forwardSeg.forwardPressure - ); - } - - seg.forwardPressure = forwardPressure; - } -} - - -// Calculate seg.forwardCoord and seg.backwardCoord for the segment, where both values range -// from 0 to 1. If the calendar is left-to-right, the seg.backwardCoord maps to "left" and -// seg.forwardCoord maps to "right" (via percentage). Vice-versa if the calendar is right-to-left. -// -// The segment might be part of a "series", which means consecutive segments with the same pressure -// who's width is unknown until an edge has been hit. `seriesBackwardPressure` is the number of -// segments behind this one in the current series, and `seriesBackwardCoord` is the starting -// coordinate of the first segment in the series. -function computeSlotSegCoords(seg, seriesBackwardPressure, seriesBackwardCoord) { - var forwardSegs = seg.forwardSegs; - var i; - - if (seg.forwardCoord === undefined) { // not already computed - - if (!forwardSegs.length) { - - // if there are no forward segments, this segment should butt up against the edge - seg.forwardCoord = 1; - } - else { - - // sort highest pressure first - forwardSegs.sort(compareForwardSlotSegs); - - // this segment's forwardCoord will be calculated from the backwardCoord of the - // highest-pressure forward segment. - computeSlotSegCoords(forwardSegs[0], seriesBackwardPressure + 1, seriesBackwardCoord); - seg.forwardCoord = forwardSegs[0].backwardCoord; - } - - // calculate the backwardCoord from the forwardCoord. consider the series - seg.backwardCoord = seg.forwardCoord - - (seg.forwardCoord - seriesBackwardCoord) / // available width for series - (seriesBackwardPressure + 1); // # of segments in the series - - // use this segment's coordinates to computed the coordinates of the less-pressurized - // forward segments - for (i=0; i<forwardSegs.length; i++) { - computeSlotSegCoords(forwardSegs[i], 0, seg.forwardCoord); - } - } -} - - -// Find all the segments in `otherSegs` that vertically collide with `seg`. -// Append into an optionally-supplied `results` array and return. -function computeSlotSegCollisions(seg, otherSegs, results) { - results = results || []; - - for (var i=0; i<otherSegs.length; i++) { - if (isSlotSegCollision(seg, otherSegs[i])) { - results.push(otherSegs[i]); - } - } - - return results; -} - - -// Do these segments occupy the same vertical space? -function isSlotSegCollision(seg1, seg2) { - return seg1.bottom > seg2.top && seg1.top < seg2.bottom; -} - - -// A cmp function for determining which forward segment to rely on more when computing coordinates. -function compareForwardSlotSegs(seg1, seg2) { - // put higher-pressure first - return seg2.forwardPressure - seg1.forwardPressure || - // put segments that are closer to initial edge first (and favor ones with no coords yet) - (seg1.backwardCoord || 0) - (seg2.backwardCoord || 0) || - // do normal sorting... - compareSegs(seg1, seg2); -} - -;; - -/* An abstract class from which other views inherit from -----------------------------------------------------------------------------------------------------------------------*/ - -var View = fc.View = Class.extend({ - - type: null, // subclass' view name (string) - name: null, // deprecated. use `type` instead - title: null, // the text that will be displayed in the header's title - - calendar: null, // owner Calendar object - options: null, // hash containing all options. already merged with view-specific-options - coordMap: null, // a CoordMap object for converting pixel regions to dates - el: null, // the view's containing element. set by Calendar - - displaying: null, // a promise representing the state of rendering. null if no render requested - isSkeletonRendered: false, - isEventsRendered: false, - - // range the view is actually displaying (moments) - start: null, - end: null, // exclusive - - // range the view is formally responsible for (moments) - // may be different from start/end. for example, a month view might have 1st-31st, excluding padded dates - intervalStart: null, - intervalEnd: null, // exclusive - intervalDuration: null, - intervalUnit: null, // name of largest unit being displayed, like "month" or "week" - - isRTL: false, - isSelected: false, // boolean whether a range of time is user-selected or not - - // subclasses can optionally use a scroll container - scrollerEl: null, // the element that will most likely scroll when content is too tall - scrollTop: null, // cached vertical scroll value - - // classNames styled by jqui themes - widgetHeaderClass: null, - widgetContentClass: null, - highlightStateClass: null, - - // for date utils, computed from options - nextDayThreshold: null, - isHiddenDayHash: null, - - // document handlers, bound to `this` object - documentMousedownProxy: null, // TODO: doesn't work with touch - - - constructor: function(calendar, type, options, intervalDuration) { - - this.calendar = calendar; - this.type = this.name = type; // .name is deprecated - this.options = options; - this.intervalDuration = intervalDuration || moment.duration(1, 'day'); - - this.nextDayThreshold = moment.duration(this.opt('nextDayThreshold')); - this.initThemingProps(); - this.initHiddenDays(); - this.isRTL = this.opt('isRTL'); - - this.documentMousedownProxy = proxy(this, 'documentMousedown'); - - this.initialize(); - }, - - - // A good place for subclasses to initialize member variables - initialize: function() { - // subclasses can implement - }, - - - // Retrieves an option with the given name - opt: function(name) { - return this.options[name]; - }, - - - // Triggers handlers that are view-related. Modifies args before passing to calendar. - trigger: function(name, thisObj) { // arguments beyond thisObj are passed along - var calendar = this.calendar; - - return calendar.trigger.apply( - calendar, - [name, thisObj || this].concat( - Array.prototype.slice.call(arguments, 2), // arguments beyond thisObj - [ this ] // always make the last argument a reference to the view. TODO: deprecate - ) - ); - }, - - - /* Dates - ------------------------------------------------------------------------------------------------------------------*/ - - - // Updates all internal dates to center around the given current date - setDate: function(date) { - this.setRange(this.computeRange(date)); - }, - - - // Updates all internal dates for displaying the given range. - // Expects all values to be normalized (like what computeRange does). - setRange: function(range) { - $.extend(this, range); - this.updateTitle(); - }, - - - // Given a single current date, produce information about what range to display. - // Subclasses can override. Must return all properties. - computeRange: function(date) { - var intervalUnit = computeIntervalUnit(this.intervalDuration); - var intervalStart = date.clone().startOf(intervalUnit); - var intervalEnd = intervalStart.clone().add(this.intervalDuration); - var start, end; - - // normalize the range's time-ambiguity - if (/year|month|week|day/.test(intervalUnit)) { // whole-days? - intervalStart.stripTime(); - intervalEnd.stripTime(); - } - else { // needs to have a time? - if (!intervalStart.hasTime()) { - intervalStart = this.calendar.rezoneDate(intervalStart); // convert to current timezone, with 00:00 - } - if (!intervalEnd.hasTime()) { - intervalEnd = this.calendar.rezoneDate(intervalEnd); // convert to current timezone, with 00:00 - } - } - - start = intervalStart.clone(); - start = this.skipHiddenDays(start); - end = intervalEnd.clone(); - end = this.skipHiddenDays(end, -1, true); // exclusively move backwards - - return { - intervalUnit: intervalUnit, - intervalStart: intervalStart, - intervalEnd: intervalEnd, - start: start, - end: end - }; - }, - - - // Computes the new date when the user hits the prev button, given the current date - computePrevDate: function(date) { - return this.massageCurrentDate( - date.clone().startOf(this.intervalUnit).subtract(this.intervalDuration), -1 - ); - }, - - - // Computes the new date when the user hits the next button, given the current date - computeNextDate: function(date) { - return this.massageCurrentDate( - date.clone().startOf(this.intervalUnit).add(this.intervalDuration) - ); - }, - - - // Given an arbitrarily calculated current date of the calendar, returns a date that is ensured to be completely - // visible. `direction` is optional and indicates which direction the current date was being - // incremented or decremented (1 or -1). - massageCurrentDate: function(date, direction) { - if (this.intervalDuration.as('days') <= 1) { // if the view displays a single day or smaller - if (this.isHiddenDay(date)) { - date = this.skipHiddenDays(date, direction); - date.startOf('day'); - } - } - - return date; - }, - - - /* Title and Date Formatting - ------------------------------------------------------------------------------------------------------------------*/ - - - // Sets the view's title property to the most updated computed value - updateTitle: function() { - this.title = this.computeTitle(); - }, - - - // Computes what the title at the top of the calendar should be for this view - computeTitle: function() { - return this.formatRange( - { start: this.intervalStart, end: this.intervalEnd }, - this.opt('titleFormat') || this.computeTitleFormat(), - this.opt('titleRangeSeparator') - ); - }, - - - // Generates the format string that should be used to generate the title for the current date range. - // Attempts to compute the most appropriate format if not explicitly specified with `titleFormat`. - computeTitleFormat: function() { - if (this.intervalUnit == 'year') { - return 'YYYY'; - } - else if (this.intervalUnit == 'month') { - return this.opt('monthYearFormat'); // like "September 2014" - } - else if (this.intervalDuration.as('days') > 1) { - return 'll'; // multi-day range. shorter, like "Sep 9 - 10 2014" - } - else { - return 'LL'; // one day. longer, like "September 9 2014" - } - }, - - - // Utility for formatting a range. Accepts a range object, formatting string, and optional separator. - // Displays all-day ranges naturally, with an inclusive end. Takes the current isRTL into account. - formatRange: function(range, formatStr, separator) { - var end = range.end; - - if (!end.hasTime()) { // all-day? - end = end.clone().subtract(1); // convert to inclusive. last ms of previous day - } - - return formatRange(range.start, end, formatStr, separator, this.opt('isRTL')); - }, - - - /* Rendering - ------------------------------------------------------------------------------------------------------------------*/ - - - // Sets the container element that the view should render inside of. - // Does other DOM-related initializations. - setElement: function(el) { - this.el = el; - this.bindGlobalHandlers(); - }, - - - // Removes the view's container element from the DOM, clearing any content beforehand. - // Undoes any other DOM-related attachments. - removeElement: function() { - this.clear(); // clears all content - - // clean up the skeleton - if (this.isSkeletonRendered) { - this.unrenderSkeleton(); - this.isSkeletonRendered = false; - } - - this.unbindGlobalHandlers(); - - this.el.remove(); - - // NOTE: don't null-out this.el in case the View was destroyed within an API callback. - // We don't null-out the View's other jQuery element references upon destroy, - // so we shouldn't kill this.el either. - }, - - - // Does everything necessary to display the view centered around the given date. - // Does every type of rendering EXCEPT rendering events. - // Is asychronous and returns a promise. - display: function(date) { - var _this = this; - var scrollState = null; - - if (this.displaying) { - scrollState = this.queryScroll(); - } - - return this.clear().then(function() { // clear the content first (async) - return ( - _this.displaying = - $.when(_this.displayView(date)) // displayView might return a promise - .then(function() { - _this.forceScroll(_this.computeInitialScroll(scrollState)); - _this.triggerRender(); - }) - ); - }); - }, - - - // Does everything necessary to clear the content of the view. - // Clears dates and events. Does not clear the skeleton. - // Is asychronous and returns a promise. - clear: function() { - var _this = this; - var displaying = this.displaying; - - if (displaying) { // previously displayed, or in the process of being displayed? - return displaying.then(function() { // wait for the display to finish - _this.displaying = null; - _this.clearEvents(); - return _this.clearView(); // might return a promise. chain it - }); - } - else { - return $.when(); // an immediately-resolved promise - } - }, - - - // Displays the view's non-event content, such as date-related content or anything required by events. - // Renders the view's non-content skeleton if necessary. - // Can be asynchronous and return a promise. - displayView: function(date) { - if (!this.isSkeletonRendered) { - this.renderSkeleton(); - this.isSkeletonRendered = true; - } - this.setDate(date); - if (this.render) { - this.render(); // TODO: deprecate - } - this.renderDates(); - this.updateSize(); - this.renderBusinessHours(); // might need coordinates, so should go after updateSize() - }, - - - // Unrenders the view content that was rendered in displayView. - // Can be asynchronous and return a promise. - clearView: function() { - this.unselect(); - this.triggerUnrender(); - this.unrenderBusinessHours(); - this.unrenderDates(); - if (this.destroy) { - this.destroy(); // TODO: deprecate - } - }, - - - // Renders the basic structure of the view before any content is rendered - renderSkeleton: function() { - // subclasses should implement - }, - - - // Unrenders the basic structure of the view - unrenderSkeleton: function() { - // subclasses should implement - }, - - - // Renders the view's date-related content (like cells that represent days/times). - // Assumes setRange has already been called and the skeleton has already been rendered. - renderDates: function() { - // subclasses should implement - }, - - - // Unrenders the view's date-related content - unrenderDates: function() { - // subclasses should override - }, - - - // Renders business-hours onto the view. Assumes updateSize has already been called. - renderBusinessHours: function() { - // subclasses should implement - }, - - - // Unrenders previously-rendered business-hours - unrenderBusinessHours: function() { - // subclasses should implement - }, - - - // Signals that the view's content has been rendered - triggerRender: function() { - this.trigger('viewRender', this, this, this.el); - }, - - - // Signals that the view's content is about to be unrendered - triggerUnrender: function() { - this.trigger('viewDestroy', this, this, this.el); - }, - - - // Binds DOM handlers to elements that reside outside the view container, such as the document - bindGlobalHandlers: function() { - $(document).on('mousedown', this.documentMousedownProxy); - }, - - - // Unbinds DOM handlers from elements that reside outside the view container - unbindGlobalHandlers: function() { - $(document).off('mousedown', this.documentMousedownProxy); - }, - - - // Initializes internal variables related to theming - initThemingProps: function() { - var tm = this.opt('theme') ? 'ui' : 'fc'; - - this.widgetHeaderClass = tm + '-widget-header'; - this.widgetContentClass = tm + '-widget-content'; - this.highlightStateClass = tm + '-state-highlight'; - }, - - - /* Dimensions - ------------------------------------------------------------------------------------------------------------------*/ - - - // Refreshes anything dependant upon sizing of the container element of the grid - updateSize: function(isResize) { - var scrollState; - - if (isResize) { - scrollState = this.queryScroll(); - } - - this.updateHeight(isResize); - this.updateWidth(isResize); - - if (isResize) { - this.setScroll(scrollState); - } - }, - - - // Refreshes the horizontal dimensions of the calendar - updateWidth: function(isResize) { - // subclasses should implement - }, - - - // Refreshes the vertical dimensions of the calendar - updateHeight: function(isResize) { - var calendar = this.calendar; // we poll the calendar for height information - - this.setHeight( - calendar.getSuggestedViewHeight(), - calendar.isHeightAuto() - ); - }, - - - // Updates the vertical dimensions of the calendar to the specified height. - // if `isAuto` is set to true, height becomes merely a suggestion and the view should use its "natural" height. - setHeight: function(height, isAuto) { - // subclasses should implement - }, - - - /* Scroller - ------------------------------------------------------------------------------------------------------------------*/ - - - // Given the total height of the view, return the number of pixels that should be used for the scroller. - // Utility for subclasses. - computeScrollerHeight: function(totalHeight) { - var scrollerEl = this.scrollerEl; - var both; - var otherHeight; // cumulative height of everything that is not the scrollerEl in the view (header+borders) - - both = this.el.add(scrollerEl); - - // fuckin IE8/9/10/11 sometimes returns 0 for dimensions. this weird hack was the only thing that worked - both.css({ - position: 'relative', // cause a reflow, which will force fresh dimension recalculation - left: -1 // ensure reflow in case the el was already relative. negative is less likely to cause new scroll - }); - otherHeight = this.el.outerHeight() - scrollerEl.height(); // grab the dimensions - both.css({ position: '', left: '' }); // undo hack - - return totalHeight - otherHeight; - }, - - - // Computes the initial pre-configured scroll state prior to allowing the user to change it. - // Given the scroll state from the previous rendering. If first time rendering, given null. - computeInitialScroll: function(previousScrollState) { - return 0; - }, - - - // Retrieves the view's current natural scroll state. Can return an arbitrary format. - queryScroll: function() { - if (this.scrollerEl) { - return this.scrollerEl.scrollTop(); // operates on scrollerEl by default - } - }, - - - // Sets the view's scroll state. Will accept the same format computeInitialScroll and queryScroll produce. - setScroll: function(scrollState) { - if (this.scrollerEl) { - return this.scrollerEl.scrollTop(scrollState); // operates on scrollerEl by default - } - }, - - - // Sets the scroll state, making sure to overcome any predefined scroll value the browser has in mind - forceScroll: function(scrollState) { - var _this = this; - - this.setScroll(scrollState); - setTimeout(function() { - _this.setScroll(scrollState); - }, 0); - }, - - - /* Event Elements / Segments - ------------------------------------------------------------------------------------------------------------------*/ - - - // Does everything necessary to display the given events onto the current view - displayEvents: function(events) { - var scrollState = this.queryScroll(); - - this.clearEvents(); - this.renderEvents(events); - this.isEventsRendered = true; - this.setScroll(scrollState); - this.triggerEventRender(); - }, - - - // Does everything necessary to clear the view's currently-rendered events - clearEvents: function() { - if (this.isEventsRendered) { - this.triggerEventUnrender(); - if (this.destroyEvents) { - this.destroyEvents(); // TODO: deprecate - } - this.unrenderEvents(); - this.isEventsRendered = false; - } - }, - - - // Renders the events onto the view. - renderEvents: function(events) { - // subclasses should implement - }, - - - // Removes event elements from the view. - unrenderEvents: function() { - // subclasses should implement - }, - - - // Signals that all events have been rendered - triggerEventRender: function() { - this.renderedEventSegEach(function(seg) { - this.trigger('eventAfterRender', seg.event, seg.event, seg.el); - }); - this.trigger('eventAfterAllRender'); - }, - - - // Signals that all event elements are about to be removed - triggerEventUnrender: function() { - this.renderedEventSegEach(function(seg) { - this.trigger('eventDestroy', seg.event, seg.event, seg.el); - }); - }, - - - // Given an event and the default element used for rendering, returns the element that should actually be used. - // Basically runs events and elements through the eventRender hook. - resolveEventEl: function(event, el) { - var custom = this.trigger('eventRender', event, event, el); - - if (custom === false) { // means don't render at all - el = null; - } - else if (custom && custom !== true) { - el = $(custom); - } - - return el; - }, - - - // Hides all rendered event segments linked to the given event - showEvent: function(event) { - this.renderedEventSegEach(function(seg) { - seg.el.css('visibility', ''); - }, event); - }, - - - // Shows all rendered event segments linked to the given event - hideEvent: function(event) { - this.renderedEventSegEach(function(seg) { - seg.el.css('visibility', 'hidden'); - }, event); - }, - - - // Iterates through event segments that have been rendered (have an el). Goes through all by default. - // If the optional `event` argument is specified, only iterates through segments linked to that event. - // The `this` value of the callback function will be the view. - renderedEventSegEach: function(func, event) { - var segs = this.getEventSegs(); - var i; - - for (i = 0; i < segs.length; i++) { - if (!event || segs[i].event._id === event._id) { - if (segs[i].el) { - func.call(this, segs[i]); - } - } - } - }, - - - // Retrieves all the rendered segment objects for the view - getEventSegs: function() { - // subclasses must implement - return []; - }, - - - /* Event Drag-n-Drop - ------------------------------------------------------------------------------------------------------------------*/ - - - // Computes if the given event is allowed to be dragged by the user - isEventDraggable: function(event) { - var source = event.source || {}; - - return firstDefined( - event.startEditable, - source.startEditable, - this.opt('eventStartEditable'), - event.editable, - source.editable, - this.opt('editable') - ); - }, - - - // Must be called when an event in the view is dropped onto new location. - // `dropLocation` is an object that contains the new start/end/allDay values for the event. - reportEventDrop: function(event, dropLocation, largeUnit, el, ev) { - var calendar = this.calendar; - var mutateResult = calendar.mutateEvent(event, dropLocation, largeUnit); - var undoFunc = function() { - mutateResult.undo(); - calendar.reportEventChange(); - }; - - this.triggerEventDrop(event, mutateResult.dateDelta, undoFunc, el, ev); - calendar.reportEventChange(); // will rerender events - }, - - - // Triggers event-drop handlers that have subscribed via the API - triggerEventDrop: function(event, dateDelta, undoFunc, el, ev) { - this.trigger('eventDrop', el[0], event, dateDelta, undoFunc, ev, {}); // {} = jqui dummy - }, - - - /* External Element Drag-n-Drop - ------------------------------------------------------------------------------------------------------------------*/ - - - // Must be called when an external element, via jQuery UI, has been dropped onto the calendar. - // `meta` is the parsed data that has been embedded into the dragging event. - // `dropLocation` is an object that contains the new start/end/allDay values for the event. - reportExternalDrop: function(meta, dropLocation, el, ev, ui) { - var eventProps = meta.eventProps; - var eventInput; - var event; - - // Try to build an event object and render it. TODO: decouple the two - if (eventProps) { - eventInput = $.extend({}, eventProps, dropLocation); - event = this.calendar.renderEvent(eventInput, meta.stick)[0]; // renderEvent returns an array - } - - this.triggerExternalDrop(event, dropLocation, el, ev, ui); - }, - - - // Triggers external-drop handlers that have subscribed via the API - triggerExternalDrop: function(event, dropLocation, el, ev, ui) { - - // trigger 'drop' regardless of whether element represents an event - this.trigger('drop', el[0], dropLocation.start, ev, ui); - - if (event) { - this.trigger('eventReceive', null, event); // signal an external event landed - } - }, - - - /* Drag-n-Drop Rendering (for both events and external elements) - ------------------------------------------------------------------------------------------------------------------*/ - - - // Renders a visual indication of a event or external-element drag over the given drop zone. - // If an external-element, seg will be `null` - renderDrag: function(dropLocation, seg) { - // subclasses must implement - }, - - - // Unrenders a visual indication of an event or external-element being dragged. - unrenderDrag: function() { - // subclasses must implement - }, - - - /* Event Resizing - ------------------------------------------------------------------------------------------------------------------*/ - - - // Computes if the given event is allowed to be resized from its starting edge - isEventResizableFromStart: function(event) { - return this.opt('eventResizableFromStart') && this.isEventResizable(event); - }, - - - // Computes if the given event is allowed to be resized from its ending edge - isEventResizableFromEnd: function(event) { - return this.isEventResizable(event); - }, - - - // Computes if the given event is allowed to be resized by the user at all - isEventResizable: function(event) { - var source = event.source || {}; - - return firstDefined( - event.durationEditable, - source.durationEditable, - this.opt('eventDurationEditable'), - event.editable, - source.editable, - this.opt('editable') - ); - }, - - - // Must be called when an event in the view has been resized to a new length - reportEventResize: function(event, resizeLocation, largeUnit, el, ev) { - var calendar = this.calendar; - var mutateResult = calendar.mutateEvent(event, resizeLocation, largeUnit); - var undoFunc = function() { - mutateResult.undo(); - calendar.reportEventChange(); - }; - - this.triggerEventResize(event, mutateResult.durationDelta, undoFunc, el, ev); - calendar.reportEventChange(); // will rerender events - }, - - - // Triggers event-resize handlers that have subscribed via the API - triggerEventResize: function(event, durationDelta, undoFunc, el, ev) { - this.trigger('eventResize', el[0], event, durationDelta, undoFunc, ev, {}); // {} = jqui dummy - }, - - - /* Selection - ------------------------------------------------------------------------------------------------------------------*/ - - - // Selects a date range on the view. `start` and `end` are both Moments. - // `ev` is the native mouse event that begin the interaction. - select: function(range, ev) { - this.unselect(ev); - this.renderSelection(range); - this.reportSelection(range, ev); - }, - - - // Renders a visual indication of the selection - renderSelection: function(range) { - // subclasses should implement - }, - - - // Called when a new selection is made. Updates internal state and triggers handlers. - reportSelection: function(range, ev) { - this.isSelected = true; - this.triggerSelect(range, ev); - }, - - - // Triggers handlers to 'select' - triggerSelect: function(range, ev) { - this.trigger('select', null, range.start, range.end, ev); - }, - - - // Undoes a selection. updates in the internal state and triggers handlers. - // `ev` is the native mouse event that began the interaction. - unselect: function(ev) { - if (this.isSelected) { - this.isSelected = false; - if (this.destroySelection) { - this.destroySelection(); // TODO: deprecate - } - this.unrenderSelection(); - this.trigger('unselect', null, ev); - } - }, - - - // Unrenders a visual indication of selection - unrenderSelection: function() { - // subclasses should implement - }, - - - // Handler for unselecting when the user clicks something and the 'unselectAuto' setting is on - documentMousedown: function(ev) { - var ignore; - - // is there a selection, and has the user made a proper left click? - if (this.isSelected && this.opt('unselectAuto') && isPrimaryMouseButton(ev)) { - - // only unselect if the clicked element is not identical to or inside of an 'unselectCancel' element - ignore = this.opt('unselectCancel'); - if (!ignore || !$(ev.target).closest(ignore).length) { - this.unselect(ev); - } - } - }, - - - /* Day Click - ------------------------------------------------------------------------------------------------------------------*/ - - - // Triggers handlers to 'dayClick' - triggerDayClick: function(cell, dayEl, ev) { - this.trigger('dayClick', dayEl, cell.start, ev); - }, - - - /* Date Utils - ------------------------------------------------------------------------------------------------------------------*/ - - - // Initializes internal variables related to calculating hidden days-of-week - initHiddenDays: function() { - var hiddenDays = this.opt('hiddenDays') || []; // array of day-of-week indices that are hidden - var isHiddenDayHash = []; // is the day-of-week hidden? (hash with day-of-week-index -> bool) - var dayCnt = 0; - var i; - - if (this.opt('weekends') === false) { - hiddenDays.push(0, 6); // 0=sunday, 6=saturday - } - - for (i = 0; i < 7; i++) { - if ( - !(isHiddenDayHash[i] = $.inArray(i, hiddenDays) !== -1) - ) { - dayCnt++; - } - } - - if (!dayCnt) { - throw 'invalid hiddenDays'; // all days were hidden? bad. - } - - this.isHiddenDayHash = isHiddenDayHash; - }, - - - // Is the current day hidden? - // `day` is a day-of-week index (0-6), or a Moment - isHiddenDay: function(day) { - if (moment.isMoment(day)) { - day = day.day(); - } - return this.isHiddenDayHash[day]; - }, - - - // Incrementing the current day until it is no longer a hidden day, returning a copy. - // If the initial value of `date` is not a hidden day, don't do anything. - // Pass `isExclusive` as `true` if you are dealing with an end date. - // `inc` defaults to `1` (increment one day forward each time) - skipHiddenDays: function(date, inc, isExclusive) { - var out = date.clone(); - inc = inc || 1; - while ( - this.isHiddenDayHash[(out.day() + (isExclusive ? inc : 0) + 7) % 7] - ) { - out.add(inc, 'days'); - } - return out; - }, - - - // Returns the date range of the full days the given range visually appears to occupy. - // Returns a new range object. - computeDayRange: function(range) { - var startDay = range.start.clone().stripTime(); // the beginning of the day the range starts - var end = range.end; - var endDay = null; - var endTimeMS; - - if (end) { - endDay = end.clone().stripTime(); // the beginning of the day the range exclusively ends - endTimeMS = +end.time(); // # of milliseconds into `endDay` - - // If the end time is actually inclusively part of the next day and is equal to or - // beyond the next day threshold, adjust the end to be the exclusive end of `endDay`. - // Otherwise, leaving it as inclusive will cause it to exclude `endDay`. - if (endTimeMS && endTimeMS >= this.nextDayThreshold) { - endDay.add(1, 'days'); - } - } - - // If no end was specified, or if it is within `startDay` but not past nextDayThreshold, - // assign the default duration of one day. - if (!end || endDay <= startDay) { - endDay = startDay.clone().add(1, 'days'); - } - - return { start: startDay, end: endDay }; - }, - - - // Does the given event visually appear to occupy more than one day? - isMultiDayEvent: function(event) { - var range = this.computeDayRange(event); // event is range-ish - - return range.end.diff(range.start, 'days') > 1; - } - -}); - -;; - -var Calendar = fc.Calendar = Class.extend({ - - dirDefaults: null, // option defaults related to LTR or RTL - langDefaults: null, // option defaults related to current locale - overrides: null, // option overrides given to the fullCalendar constructor - options: null, // all defaults combined with overrides - viewSpecCache: null, // cache of view definitions - view: null, // current View object - header: null, - loadingLevel: 0, // number of simultaneous loading tasks - - - // a lot of this class' OOP logic is scoped within this constructor function, - // but in the future, write individual methods on the prototype. - constructor: Calendar_constructor, - - - // Subclasses can override this for initialization logic after the constructor has been called - initialize: function() { - }, - - - // Initializes `this.options` and other important options-related objects - initOptions: function(overrides) { - var lang, langDefaults; - var isRTL, dirDefaults; - - // converts legacy options into non-legacy ones. - // in the future, when this is removed, don't use `overrides` reference. make a copy. - overrides = massageOverrides(overrides); - - lang = overrides.lang; - langDefaults = langOptionHash[lang]; - if (!langDefaults) { - lang = Calendar.defaults.lang; - langDefaults = langOptionHash[lang] || {}; - } - - isRTL = firstDefined( - overrides.isRTL, - langDefaults.isRTL, - Calendar.defaults.isRTL - ); - dirDefaults = isRTL ? Calendar.rtlDefaults : {}; - - this.dirDefaults = dirDefaults; - this.langDefaults = langDefaults; - this.overrides = overrides; - this.options = mergeOptions([ // merge defaults and overrides. lowest to highest precedence - Calendar.defaults, // global defaults - dirDefaults, - langDefaults, - overrides - ]); - populateInstanceComputableOptions(this.options); - - this.viewSpecCache = {}; // somewhat unrelated - }, - - - // Gets information about how to create a view. Will use a cache. - getViewSpec: function(viewType) { - var cache = this.viewSpecCache; - - return cache[viewType] || (cache[viewType] = this.buildViewSpec(viewType)); - }, - - - // Given a duration singular unit, like "week" or "day", finds a matching view spec. - // Preference is given to views that have corresponding buttons. - getUnitViewSpec: function(unit) { - var viewTypes; - var i; - var spec; - - if ($.inArray(unit, intervalUnits) != -1) { - - // put views that have buttons first. there will be duplicates, but oh well - viewTypes = this.header.getViewsWithButtons(); - $.each(fc.views, function(viewType) { // all views - viewTypes.push(viewType); - }); - - for (i = 0; i < viewTypes.length; i++) { - spec = this.getViewSpec(viewTypes[i]); - if (spec) { - if (spec.singleUnit == unit) { - return spec; - } - } - } - } - }, - - - // Builds an object with information on how to create a given view - buildViewSpec: function(requestedViewType) { - var viewOverrides = this.overrides.views || {}; - var specChain = []; // for the view. lowest to highest priority - var defaultsChain = []; // for the view. lowest to highest priority - var overridesChain = []; // for the view. lowest to highest priority - var viewType = requestedViewType; - var spec; // for the view - var overrides; // for the view - var duration; - var unit; - - // iterate from the specific view definition to a more general one until we hit an actual View class - while (viewType) { - spec = fcViews[viewType]; - overrides = viewOverrides[viewType]; - viewType = null; // clear. might repopulate for another iteration - - if (typeof spec === 'function') { // TODO: deprecate - spec = { 'class': spec }; - } - - if (spec) { - specChain.unshift(spec); - defaultsChain.unshift(spec.defaults || {}); - duration = duration || spec.duration; - viewType = viewType || spec.type; - } - - if (overrides) { - overridesChain.unshift(overrides); // view-specific option hashes have options at zero-level - duration = duration || overrides.duration; - viewType = viewType || overrides.type; - } - } - - spec = mergeProps(specChain); - spec.type = requestedViewType; - if (!spec['class']) { - return false; - } - - if (duration) { - duration = moment.duration(duration); - if (duration.valueOf()) { // valid? - spec.duration = duration; - unit = computeIntervalUnit(duration); - - // view is a single-unit duration, like "week" or "day" - // incorporate options for this. lowest priority - if (duration.as(unit) === 1) { - spec.singleUnit = unit; - overridesChain.unshift(viewOverrides[unit] || {}); - } - } - } - - spec.defaults = mergeOptions(defaultsChain); - spec.overrides = mergeOptions(overridesChain); - - this.buildViewSpecOptions(spec); - this.buildViewSpecButtonText(spec, requestedViewType); - - return spec; - }, - - - // Builds and assigns a view spec's options object from its already-assigned defaults and overrides - buildViewSpecOptions: function(spec) { - spec.options = mergeOptions([ // lowest to highest priority - Calendar.defaults, // global defaults - spec.defaults, // view's defaults (from ViewSubclass.defaults) - this.dirDefaults, - this.langDefaults, // locale and dir take precedence over view's defaults! - this.overrides, // calendar's overrides (options given to constructor) - spec.overrides // view's overrides (view-specific options) - ]); - populateInstanceComputableOptions(spec.options); - }, - - - // Computes and assigns a view spec's buttonText-related options - buildViewSpecButtonText: function(spec, requestedViewType) { - - // given an options object with a possible `buttonText` hash, lookup the buttonText for the - // requested view, falling back to a generic unit entry like "week" or "day" - function queryButtonText(options) { - var buttonText = options.buttonText || {}; - return buttonText[requestedViewType] || - (spec.singleUnit ? buttonText[spec.singleUnit] : null); - } - - // highest to lowest priority - spec.buttonTextOverride = - queryButtonText(this.overrides) || // constructor-specified buttonText lookup hash takes precedence - spec.overrides.buttonText; // `buttonText` for view-specific options is a string - - // highest to lowest priority. mirrors buildViewSpecOptions - spec.buttonTextDefault = - queryButtonText(this.langDefaults) || - queryButtonText(this.dirDefaults) || - spec.defaults.buttonText || // a single string. from ViewSubclass.defaults - queryButtonText(Calendar.defaults) || - (spec.duration ? this.humanizeDuration(spec.duration) : null) || // like "3 days" - requestedViewType; // fall back to given view name - }, - - - // Given a view name for a custom view or a standard view, creates a ready-to-go View object - instantiateView: function(viewType) { - var spec = this.getViewSpec(viewType); - - return new spec['class'](this, viewType, spec.options, spec.duration); - }, - - - // Returns a boolean about whether the view is okay to instantiate at some point - isValidViewType: function(viewType) { - return Boolean(this.getViewSpec(viewType)); - }, - - - // Should be called when any type of async data fetching begins - pushLoading: function() { - if (!(this.loadingLevel++)) { - this.trigger('loading', null, true, this.view); - } - }, - - - // Should be called when any type of async data fetching completes - popLoading: function() { - if (!(--this.loadingLevel)) { - this.trigger('loading', null, false, this.view); - } - }, - - - // Given arguments to the select method in the API, returns a range - buildSelectRange: function(start, end) { - - start = this.moment(start); - if (end) { - end = this.moment(end); - } - else if (start.hasTime()) { - end = start.clone().add(this.defaultTimedEventDuration); - } - else { - end = start.clone().add(this.defaultAllDayEventDuration); - } - - return { start: start, end: end }; - } - -}); - - -function Calendar_constructor(element, overrides) { - var t = this; - - - t.initOptions(overrides || {}); - var options = this.options; - - - // Exports - // ----------------------------------------------------------------------------------- - - t.render = render; - t.destroy = destroy; - t.refetchEvents = refetchEvents; - t.reportEvents = reportEvents; - t.reportEventChange = reportEventChange; - t.rerenderEvents = renderEvents; // `renderEvents` serves as a rerender. an API method - t.changeView = renderView; // `renderView` will switch to another view - t.select = select; - t.unselect = unselect; - t.prev = prev; - t.next = next; - t.prevYear = prevYear; - t.nextYear = nextYear; - t.today = today; - t.gotoDate = gotoDate; - t.incrementDate = incrementDate; - t.zoomTo = zoomTo; - t.getDate = getDate; - t.getCalendar = getCalendar; - t.getView = getView; - t.option = option; - t.trigger = trigger; - - - - // Language-data Internals - // ----------------------------------------------------------------------------------- - // Apply overrides to the current language's data - - - var localeData = createObject( // make a cheap copy - getMomentLocaleData(options.lang) // will fall back to en - ); - - if (options.monthNames) { - localeData._months = options.monthNames; - } - if (options.monthNamesShort) { - localeData._monthsShort = options.monthNamesShort; - } - if (options.dayNames) { - localeData._weekdays = options.dayNames; - } - if (options.dayNamesShort) { - localeData._weekdaysShort = options.dayNamesShort; - } - if (options.firstDay != null) { - var _week = createObject(localeData._week); // _week: { dow: # } - _week.dow = options.firstDay; - localeData._week = _week; - } - - // assign a normalized value, to be used by our .week() moment extension - localeData._fullCalendar_weekCalc = (function(weekCalc) { - if (typeof weekCalc === 'function') { - return weekCalc; - } - else if (weekCalc === 'local') { - return weekCalc; - } - else if (weekCalc === 'iso' || weekCalc === 'ISO') { - return 'ISO'; - } - })(options.weekNumberCalculation); - - - - // Calendar-specific Date Utilities - // ----------------------------------------------------------------------------------- - - - t.defaultAllDayEventDuration = moment.duration(options.defaultAllDayEventDuration); - t.defaultTimedEventDuration = moment.duration(options.defaultTimedEventDuration); - - - // Builds a moment using the settings of the current calendar: timezone and language. - // Accepts anything the vanilla moment() constructor accepts. - t.moment = function() { - var mom; - - if (options.timezone === 'local') { - mom = fc.moment.apply(null, arguments); - - // Force the moment to be local, because fc.moment doesn't guarantee it. - if (mom.hasTime()) { // don't give ambiguously-timed moments a local zone - mom.local(); - } - } - else if (options.timezone === 'UTC') { - mom = fc.moment.utc.apply(null, arguments); // process as UTC - } - else { - mom = fc.moment.parseZone.apply(null, arguments); // let the input decide the zone - } - - if ('_locale' in mom) { // moment 2.8 and above - mom._locale = localeData; - } - else { // pre-moment-2.8 - mom._lang = localeData; - } - - return mom; - }; - - - // Returns a boolean about whether or not the calendar knows how to calculate - // the timezone offset of arbitrary dates in the current timezone. - t.getIsAmbigTimezone = function() { - return options.timezone !== 'local' && options.timezone !== 'UTC'; - }; - - - // Returns a copy of the given date in the current timezone of it is ambiguously zoned. - // This will also give the date an unambiguous time. - t.rezoneDate = function(date) { - return t.moment(date.toArray()); - }; - - - // Returns a moment for the current date, as defined by the client's computer, - // or overridden by the `now` option. - t.getNow = function() { - var now = options.now; - if (typeof now === 'function') { - now = now(); - } - return t.moment(now); - }; - - - // Get an event's normalized end date. If not present, calculate it from the defaults. - t.getEventEnd = function(event) { - if (event.end) { - return event.end.clone(); - } - else { - return t.getDefaultEventEnd(event.allDay, event.start); - } - }; - - - // Given an event's allDay status and start date, return swhat its fallback end date should be. - t.getDefaultEventEnd = function(allDay, start) { // TODO: rename to computeDefaultEventEnd - var end = start.clone(); - - if (allDay) { - end.stripTime().add(t.defaultAllDayEventDuration); - } - else { - end.add(t.defaultTimedEventDuration); - } - - if (t.getIsAmbigTimezone()) { - end.stripZone(); // we don't know what the tzo should be - } - - return end; - }; - - - // Produces a human-readable string for the given duration. - // Side-effect: changes the locale of the given duration. - t.humanizeDuration = function(duration) { - return (duration.locale || duration.lang).call(duration, options.lang) // works moment-pre-2.8 - .humanize(); - }; - - - - // Imports - // ----------------------------------------------------------------------------------- - - - EventManager.call(t, options); - var isFetchNeeded = t.isFetchNeeded; - var fetchEvents = t.fetchEvents; - - - - // Locals - // ----------------------------------------------------------------------------------- - - - var _element = element[0]; - var header; - var headerElement; - var content; - var tm; // for making theme classes - var currentView; // NOTE: keep this in sync with this.view - var viewsByType = {}; // holds all instantiated view instances, current or not - var suggestedViewHeight; - var windowResizeProxy; // wraps the windowResize function - var ignoreWindowResize = 0; - var date; - var events = []; - - - - // Main Rendering - // ----------------------------------------------------------------------------------- - - - if (options.defaultDate != null) { - date = t.moment(options.defaultDate); - } - else { - date = t.getNow(); - } - - - function render() { - if (!content) { - initialRender(); - } - else if (elementVisible()) { - // mainly for the public API - calcSize(); - renderView(); - } - } - - - function initialRender() { - tm = options.theme ? 'ui' : 'fc'; - element.addClass('fc'); - - if (options.isRTL) { - element.addClass('fc-rtl'); - } - else { - element.addClass('fc-ltr'); - } - - if (options.theme) { - element.addClass('ui-widget'); - } - else { - element.addClass('fc-unthemed'); - } - - content = $("<div class='fc-view-container'/>").prependTo(element); - - header = t.header = new Header(t, options); - headerElement = header.render(); - if (headerElement) { - element.prepend(headerElement); - } - - renderView(options.defaultView); - - if (options.handleWindowResize) { - windowResizeProxy = debounce(windowResize, options.windowResizeDelay); // prevents rapid calls - $(window).resize(windowResizeProxy); - } - } - - - function destroy() { - - if (currentView) { - currentView.removeElement(); - - // NOTE: don't null-out currentView/t.view in case API methods are called after destroy. - // It is still the "current" view, just not rendered. - } - - header.removeElement(); - content.remove(); - element.removeClass('fc fc-ltr fc-rtl fc-unthemed ui-widget'); - - if (windowResizeProxy) { - $(window).unbind('resize', windowResizeProxy); - } - } - - - function elementVisible() { - return element.is(':visible'); - } - - - - // View Rendering - // ----------------------------------------------------------------------------------- - - - // Renders a view because of a date change, view-type change, or for the first time. - // If not given a viewType, keep the current view but render different dates. - function renderView(viewType) { - ignoreWindowResize++; - - // if viewType is changing, remove the old view's rendering - if (currentView && viewType && currentView.type !== viewType) { - header.deactivateButton(currentView.type); - freezeContentHeight(); // prevent a scroll jump when view element is removed - currentView.removeElement(); - currentView = t.view = null; - } - - // if viewType changed, or the view was never created, create a fresh view - if (!currentView && viewType) { - currentView = t.view = - viewsByType[viewType] || - (viewsByType[viewType] = t.instantiateView(viewType)); - - currentView.setElement( - $("<div class='fc-view fc-" + viewType + "-view' />").appendTo(content) - ); - header.activateButton(viewType); - } - - if (currentView) { - - // in case the view should render a period of time that is completely hidden - date = currentView.massageCurrentDate(date); - - // render or rerender the view - if ( - !currentView.displaying || - !date.isWithin(currentView.intervalStart, currentView.intervalEnd) // implicit date window change - ) { - if (elementVisible()) { - - freezeContentHeight(); - currentView.display(date); - unfreezeContentHeight(); // immediately unfreeze regardless of whether display is async - - // need to do this after View::render, so dates are calculated - updateHeaderTitle(); - updateTodayButton(); - - getAndRenderEvents(); - } - } - } - - unfreezeContentHeight(); // undo any lone freezeContentHeight calls - ignoreWindowResize--; - } - - - - // Resizing - // ----------------------------------------------------------------------------------- - - - t.getSuggestedViewHeight = function() { - if (suggestedViewHeight === undefined) { - calcSize(); - } - return suggestedViewHeight; - }; - - - t.isHeightAuto = function() { - return options.contentHeight === 'auto' || options.height === 'auto'; - }; - - - function updateSize(shouldRecalc) { - if (elementVisible()) { - - if (shouldRecalc) { - _calcSize(); - } - - ignoreWindowResize++; - currentView.updateSize(true); // isResize=true. will poll getSuggestedViewHeight() and isHeightAuto() - ignoreWindowResize--; - - return true; // signal success - } - } - - - function calcSize() { - if (elementVisible()) { - _calcSize(); - } - } - - - function _calcSize() { // assumes elementVisible - if (typeof options.contentHeight === 'number') { // exists and not 'auto' - suggestedViewHeight = options.contentHeight; - } - else if (typeof options.height === 'number') { // exists and not 'auto' - suggestedViewHeight = options.height - (headerElement ? headerElement.outerHeight(true) : 0); - } - else { - suggestedViewHeight = Math.round(content.width() / Math.max(options.aspectRatio, .5)); - } - } - - - function windowResize(ev) { - if ( - !ignoreWindowResize && - ev.target === window && // so we don't process jqui "resize" events that have bubbled up - currentView.start // view has already been rendered - ) { - if (updateSize(true)) { - currentView.trigger('windowResize', _element); - } - } - } - - - - /* Event Fetching/Rendering - -----------------------------------------------------------------------------*/ - // TODO: going forward, most of this stuff should be directly handled by the view - - - function refetchEvents() { // can be called as an API method - destroyEvents(); // so that events are cleared before user starts waiting for AJAX - fetchAndRenderEvents(); - } - - - function renderEvents() { // destroys old events if previously rendered - if (elementVisible()) { - freezeContentHeight(); - currentView.displayEvents(events); - unfreezeContentHeight(); - } - } - - - function destroyEvents() { - freezeContentHeight(); - currentView.clearEvents(); - unfreezeContentHeight(); - } - - - function getAndRenderEvents() { - if (!options.lazyFetching || isFetchNeeded(currentView.start, currentView.end)) { - fetchAndRenderEvents(); - } - else { - renderEvents(); - } - } - - - function fetchAndRenderEvents() { - fetchEvents(currentView.start, currentView.end); - // ... will call reportEvents - // ... which will call renderEvents - } - - - // called when event data arrives - function reportEvents(_events) { - events = _events; - renderEvents(); - } - - - // called when a single event's data has been changed - function reportEventChange() { - renderEvents(); - } - - - - /* Header Updating - -----------------------------------------------------------------------------*/ - - - function updateHeaderTitle() { - header.updateTitle(currentView.title); - } - - - function updateTodayButton() { - var now = t.getNow(); - if (now.isWithin(currentView.intervalStart, currentView.intervalEnd)) { - header.disableButton('today'); - } - else { - header.enableButton('today'); - } - } - - - - /* Selection - -----------------------------------------------------------------------------*/ - - - function select(start, end) { - currentView.select( - t.buildSelectRange.apply(t, arguments) - ); - } - - - function unselect() { // safe to be called before renderView - if (currentView) { - currentView.unselect(); - } - } - - - - /* Date - -----------------------------------------------------------------------------*/ - - - function prev() { - date = currentView.computePrevDate(date); - renderView(); - } - - - function next() { - date = currentView.computeNextDate(date); - renderView(); - } - - - function prevYear() { - date.add(-1, 'years'); - renderView(); - } - - - function nextYear() { - date.add(1, 'years'); - renderView(); - } - - - function today() { - date = t.getNow(); - renderView(); - } - - - function gotoDate(dateInput) { - date = t.moment(dateInput); - renderView(); - } - - - function incrementDate(delta) { - date.add(moment.duration(delta)); - renderView(); - } - - - // Forces navigation to a view for the given date. - // `viewType` can be a specific view name or a generic one like "week" or "day". - function zoomTo(newDate, viewType) { - var spec; - - viewType = viewType || 'day'; // day is default zoom - spec = t.getViewSpec(viewType) || t.getUnitViewSpec(viewType); - - date = newDate; - renderView(spec ? spec.type : null); - } - - - function getDate() { - return date.clone(); - } - - - - /* Height "Freezing" - -----------------------------------------------------------------------------*/ - // TODO: move this into the view - - - function freezeContentHeight() { - content.css({ - width: '100%', - height: content.height(), - overflow: 'hidden' - }); - } - - - function unfreezeContentHeight() { - content.css({ - width: '', - height: '', - overflow: '' - }); - } - - - - /* Misc - -----------------------------------------------------------------------------*/ - - - function getCalendar() { - return t; - } - - - function getView() { - return currentView; - } - - - function option(name, value) { - if (value === undefined) { - return options[name]; - } - if (name == 'height' || name == 'contentHeight' || name == 'aspectRatio') { - options[name] = value; - updateSize(true); // true = allow recalculation of height - } - } - - - function trigger(name, thisObj) { - if (options[name]) { - return options[name].apply( - thisObj || _element, - Array.prototype.slice.call(arguments, 2) - ); - } - } - - t.initialize(); -} - -;; - -Calendar.defaults = { - - titleRangeSeparator: ' \u2014 ', // emphasized dash - monthYearFormat: 'MMMM YYYY', // required for en. other languages rely on datepicker computable option - - defaultTimedEventDuration: '02:00:00', - defaultAllDayEventDuration: { days: 1 }, - forceEventDuration: false, - nextDayThreshold: '09:00:00', // 9am - - // display - defaultView: 'month', - aspectRatio: 1.35, - header: { - left: 'title', - center: '', - right: 'today prev,next' - }, - weekends: true, - weekNumbers: false, - - weekNumberTitle: 'W', - weekNumberCalculation: 'local', - - //editable: false, - - scrollTime: '06:00:00', - - // event ajax - lazyFetching: true, - startParam: 'start', - endParam: 'end', - timezoneParam: 'timezone', - - timezone: false, - - //allDayDefault: undefined, - - // locale - isRTL: false, - buttonText: { - prev: "prev", - next: "next", - prevYear: "prev year", - nextYear: "next year", - year: 'year', // TODO: locale files need to specify this - today: 'today', - month: 'month', - week: 'week', - day: 'day' - }, - - buttonIcons: { - prev: 'left-single-arrow', - next: 'right-single-arrow', - prevYear: 'left-double-arrow', - nextYear: 'right-double-arrow' - }, - - // jquery-ui theming - theme: false, - themeButtonIcons: { - prev: 'circle-triangle-w', - next: 'circle-triangle-e', - prevYear: 'seek-prev', - nextYear: 'seek-next' - }, - - //eventResizableFromStart: false, - dragOpacity: .75, - dragRevertDuration: 500, - dragScroll: true, - - //selectable: false, - unselectAuto: true, - - dropAccept: '*', - - eventLimit: false, - eventLimitText: 'more', - eventLimitClick: 'popover', - dayPopoverFormat: 'LL', - - handleWindowResize: true, - windowResizeDelay: 200 // milliseconds before an updateSize happens - -}; - - -Calendar.englishDefaults = { // used by lang.js - dayPopoverFormat: 'dddd, MMMM D' -}; - - -Calendar.rtlDefaults = { // right-to-left defaults - header: { // TODO: smarter solution (first/center/last ?) - left: 'next,prev today', - center: '', - right: 'title' - }, - buttonIcons: { - prev: 'right-single-arrow', - next: 'left-single-arrow', - prevYear: 'right-double-arrow', - nextYear: 'left-double-arrow' - }, - themeButtonIcons: { - prev: 'circle-triangle-e', - next: 'circle-triangle-w', - nextYear: 'seek-prev', - prevYear: 'seek-next' - } -}; - -;; - -var langOptionHash = fc.langs = {}; // initialize and expose - - -// TODO: document the structure and ordering of a FullCalendar lang file -// TODO: rename everything "lang" to "locale", like what the moment project did - - -// Initialize jQuery UI datepicker translations while using some of the translations -// Will set this as the default language for datepicker. -fc.datepickerLang = function(langCode, dpLangCode, dpOptions) { - - // get the FullCalendar internal option hash for this language. create if necessary - var fcOptions = langOptionHash[langCode] || (langOptionHash[langCode] = {}); - - // transfer some simple options from datepicker to fc - fcOptions.isRTL = dpOptions.isRTL; - fcOptions.weekNumberTitle = dpOptions.weekHeader; - - // compute some more complex options from datepicker - $.each(dpComputableOptions, function(name, func) { - fcOptions[name] = func(dpOptions); - }); - - // is jQuery UI Datepicker is on the page? - if ($.datepicker) { - - // Register the language data. - // FullCalendar and MomentJS use language codes like "pt-br" but Datepicker - // does it like "pt-BR" or if it doesn't have the language, maybe just "pt". - // Make an alias so the language can be referenced either way. - $.datepicker.regional[dpLangCode] = - $.datepicker.regional[langCode] = // alias - dpOptions; - - // Alias 'en' to the default language data. Do this every time. - $.datepicker.regional.en = $.datepicker.regional['']; - - // Set as Datepicker's global defaults. - $.datepicker.setDefaults(dpOptions); - } -}; - - -// Sets FullCalendar-specific translations. Will set the language as the global default. -fc.lang = function(langCode, newFcOptions) { - var fcOptions; - var momOptions; - - // get the FullCalendar internal option hash for this language. create if necessary - fcOptions = langOptionHash[langCode] || (langOptionHash[langCode] = {}); - - // provided new options for this language? merge them in - if (newFcOptions) { - fcOptions = langOptionHash[langCode] = mergeOptions([ fcOptions, newFcOptions ]); - } - - // compute language options that weren't defined. - // always do this. newFcOptions can be undefined when initializing from i18n file, - // so no way to tell if this is an initialization or a default-setting. - momOptions = getMomentLocaleData(langCode); // will fall back to en - $.each(momComputableOptions, function(name, func) { - if (fcOptions[name] == null) { - fcOptions[name] = func(momOptions, fcOptions); - } - }); - - // set it as the default language for FullCalendar - Calendar.defaults.lang = langCode; -}; - - -// NOTE: can't guarantee any of these computations will run because not every language has datepicker -// configs, so make sure there are English fallbacks for these in the defaults file. -var dpComputableOptions = { - - buttonText: function(dpOptions) { - return { - // the translations sometimes wrongly contain HTML entities - prev: stripHtmlEntities(dpOptions.prevText), - next: stripHtmlEntities(dpOptions.nextText), - today: stripHtmlEntities(dpOptions.currentText) - }; - }, - - // Produces format strings like "MMMM YYYY" -> "September 2014" - monthYearFormat: function(dpOptions) { - return dpOptions.showMonthAfterYear ? - 'YYYY[' + dpOptions.yearSuffix + '] MMMM' : - 'MMMM YYYY[' + dpOptions.yearSuffix + ']'; - } - -}; - -var momComputableOptions = { - - // Produces format strings like "ddd M/D" -> "Fri 9/15" - dayOfMonthFormat: function(momOptions, fcOptions) { - var format = momOptions.longDateFormat('l'); // for the format like "M/D/YYYY" - - // strip the year off the edge, as well as other misc non-whitespace chars - format = format.replace(/^Y+[^\w\s]*|[^\w\s]*Y+$/g, ''); - - if (fcOptions.isRTL) { - format += ' ddd'; // for RTL, add day-of-week to end - } - else { - format = 'ddd ' + format; // for LTR, add day-of-week to beginning - } - return format; - }, - - // Produces format strings like "h:mma" -> "6:00pm" - mediumTimeFormat: function(momOptions) { // can't be called `timeFormat` because collides with option - return momOptions.longDateFormat('LT') - .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand - }, - - // Produces format strings like "h(:mm)a" -> "6pm" / "6:30pm" - smallTimeFormat: function(momOptions) { - return momOptions.longDateFormat('LT') - .replace(':mm', '(:mm)') - .replace(/(\Wmm)$/, '($1)') // like above, but for foreign langs - .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand - }, - - // Produces format strings like "h(:mm)t" -> "6p" / "6:30p" - extraSmallTimeFormat: function(momOptions) { - return momOptions.longDateFormat('LT') - .replace(':mm', '(:mm)') - .replace(/(\Wmm)$/, '($1)') // like above, but for foreign langs - .replace(/\s*a$/i, 't'); // convert to AM/PM/am/pm to lowercase one-letter. remove any spaces beforehand - }, - - // Produces format strings like "ha" / "H" -> "6pm" / "18" - hourFormat: function(momOptions) { - return momOptions.longDateFormat('LT') - .replace(':mm', '') - .replace(/(\Wmm)$/, '') // like above, but for foreign langs - .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand - }, - - // Produces format strings like "h:mm" -> "6:30" (with no AM/PM) - noMeridiemTimeFormat: function(momOptions) { - return momOptions.longDateFormat('LT') - .replace(/\s*a$/i, ''); // remove trailing AM/PM - } - -}; - - -// options that should be computed off live calendar options (considers override options) -var instanceComputableOptions = { // TODO: best place for this? related to lang? - - // Produces format strings for results like "Mo 16" - smallDayDateFormat: function(options) { - return options.isRTL ? - 'D dd' : - 'dd D'; - }, - - // Produces format strings for results like "Wk 5" - weekFormat: function(options) { - return options.isRTL ? - 'w[ ' + options.weekNumberTitle + ']' : - '[' + options.weekNumberTitle + ' ]w'; - }, - - // Produces format strings for results like "Wk5" - smallWeekFormat: function(options) { - return options.isRTL ? - 'w[' + options.weekNumberTitle + ']' : - '[' + options.weekNumberTitle + ']w'; - } - -}; - -function populateInstanceComputableOptions(options) { - $.each(instanceComputableOptions, function(name, func) { - if (options[name] == null) { - options[name] = func(options); - } - }); -} - - -// Returns moment's internal locale data. If doesn't exist, returns English. -// Works with moment-pre-2.8 -function getMomentLocaleData(langCode) { - var func = moment.localeData || moment.langData; - return func.call(moment, langCode) || - func.call(moment, 'en'); // the newer localData could return null, so fall back to en -} - - -// Initialize English by forcing computation of moment-derived options. -// Also, sets it as the default. -fc.lang('en', Calendar.englishDefaults); - -;; - -/* Top toolbar area with buttons and title -----------------------------------------------------------------------------------------------------------------------*/ -// TODO: rename all header-related things to "toolbar" - -function Header(calendar, options) { - var t = this; - - // exports - t.render = render; - t.removeElement = removeElement; - t.updateTitle = updateTitle; - t.activateButton = activateButton; - t.deactivateButton = deactivateButton; - t.disableButton = disableButton; - t.enableButton = enableButton; - t.getViewsWithButtons = getViewsWithButtons; - - // locals - var el = $(); - var viewsWithButtons = []; - var tm; - - - function render() { - var sections = options.header; - - tm = options.theme ? 'ui' : 'fc'; - - if (sections) { - el = $("<div class='fc-toolbar'/>") - .append(renderSection('left')) - .append(renderSection('right')) - .append(renderSection('center')) - .append('<div class="fc-clear"/>'); - - return el; - } - } - - - function removeElement() { - el.remove(); - el = $(); - } - - - function renderSection(position) { - var sectionEl = $('<div class="fc-' + position + '"/>'); - var buttonStr = options.header[position]; - - if (buttonStr) { - $.each(buttonStr.split(' '), function(i) { - var groupChildren = $(); - var isOnlyButtons = true; - var groupEl; - - $.each(this.split(','), function(j, buttonName) { - var viewSpec; - var buttonClick; - var overrideText; // text explicitly set by calendar's constructor options. overcomes icons - var defaultText; - var themeIcon; - var normalIcon; - var innerHtml; - var classes; - var button; - - if (buttonName == 'title') { - groupChildren = groupChildren.add($('<h2> </h2>')); // we always want it to take up height - isOnlyButtons = false; - } - else { - viewSpec = calendar.getViewSpec(buttonName); - - if (viewSpec) { - buttonClick = function() { - calendar.changeView(buttonName); - }; - viewsWithButtons.push(buttonName); - overrideText = viewSpec.buttonTextOverride; - defaultText = viewSpec.buttonTextDefault; - } - else if (calendar[buttonName]) { // a calendar method - buttonClick = function() { - calendar[buttonName](); - }; - overrideText = (calendar.overrides.buttonText || {})[buttonName]; - defaultText = options.buttonText[buttonName]; // everything else is considered default - } - - if (buttonClick) { - - themeIcon = options.themeButtonIcons[buttonName]; - normalIcon = options.buttonIcons[buttonName]; - - if (overrideText) { - innerHtml = htmlEscape(overrideText); - } - else if (themeIcon && options.theme) { - innerHtml = "<span class='ui-icon ui-icon-" + themeIcon + "'></span>"; - } - else if (normalIcon && !options.theme) { - innerHtml = "<span class='fc-icon fc-icon-" + normalIcon + "'></span>"; - } - else { - innerHtml = htmlEscape(defaultText); - } - - classes = [ - 'fc-' + buttonName + '-button', - tm + '-button', - tm + '-state-default' - ]; - - button = $( // type="button" so that it doesn't submit a form - '<button type="button" class="' + classes.join(' ') + '">' + - innerHtml + - '</button>' - ) - .click(function() { - // don't process clicks for disabled buttons - if (!button.hasClass(tm + '-state-disabled')) { - - buttonClick(); - - // after the click action, if the button becomes the "active" tab, or disabled, - // it should never have a hover class, so remove it now. - if ( - button.hasClass(tm + '-state-active') || - button.hasClass(tm + '-state-disabled') - ) { - button.removeClass(tm + '-state-hover'); - } - } - }) - .mousedown(function() { - // the *down* effect (mouse pressed in). - // only on buttons that are not the "active" tab, or disabled - button - .not('.' + tm + '-state-active') - .not('.' + tm + '-state-disabled') - .addClass(tm + '-state-down'); - }) - .mouseup(function() { - // undo the *down* effect - button.removeClass(tm + '-state-down'); - }) - .hover( - function() { - // the *hover* effect. - // only on buttons that are not the "active" tab, or disabled - button - .not('.' + tm + '-state-active') - .not('.' + tm + '-state-disabled') - .addClass(tm + '-state-hover'); - }, - function() { - // undo the *hover* effect - button - .removeClass(tm + '-state-hover') - .removeClass(tm + '-state-down'); // if mouseleave happens before mouseup - } - ); - - groupChildren = groupChildren.add(button); - } - } - }); - - if (isOnlyButtons) { - groupChildren - .first().addClass(tm + '-corner-left').end() - .last().addClass(tm + '-corner-right').end(); - } - - if (groupChildren.length > 1) { - groupEl = $('<div/>'); - if (isOnlyButtons) { - groupEl.addClass('fc-button-group'); - } - groupEl.append(groupChildren); - sectionEl.append(groupEl); - } - else { - sectionEl.append(groupChildren); // 1 or 0 children - } - }); - } - - return sectionEl; - } - - - function updateTitle(text) { - el.find('h2').text(text); - } - - - function activateButton(buttonName) { - el.find('.fc-' + buttonName + '-button') - .addClass(tm + '-state-active'); - } - - - function deactivateButton(buttonName) { - el.find('.fc-' + buttonName + '-button') - .removeClass(tm + '-state-active'); - } - - - function disableButton(buttonName) { - el.find('.fc-' + buttonName + '-button') - .attr('disabled', 'disabled') - .addClass(tm + '-state-disabled'); - } - - - function enableButton(buttonName) { - el.find('.fc-' + buttonName + '-button') - .removeAttr('disabled') - .removeClass(tm + '-state-disabled'); - } - - - function getViewsWithButtons() { - return viewsWithButtons; - } - -} - -;; - -fc.sourceNormalizers = []; -fc.sourceFetchers = []; - -var ajaxDefaults = { - dataType: 'json', - cache: false -}; - -var eventGUID = 1; - - -function EventManager(options) { // assumed to be a calendar - var t = this; - - - // exports - t.isFetchNeeded = isFetchNeeded; - t.fetchEvents = fetchEvents; - t.addEventSource = addEventSource; - t.removeEventSource = removeEventSource; - t.updateEvent = updateEvent; - t.renderEvent = renderEvent; - t.removeEvents = removeEvents; - t.clientEvents = clientEvents; - t.mutateEvent = mutateEvent; - t.normalizeEventRange = normalizeEventRange; - t.normalizeEventRangeTimes = normalizeEventRangeTimes; - t.ensureVisibleEventRange = ensureVisibleEventRange; - - - // imports - var reportEvents = t.reportEvents; - - - // locals - var stickySource = { events: [] }; - var sources = [ stickySource ]; - var rangeStart, rangeEnd; - var currentFetchID = 0; - var pendingSourceCnt = 0; - var cache = []; // holds events that have already been expanded - - - $.each( - (options.events ? [ options.events ] : []).concat(options.eventSources || []), - function(i, sourceInput) { - var source = buildEventSource(sourceInput); - if (source) { - sources.push(source); - } - } - ); - - - - /* Fetching - -----------------------------------------------------------------------------*/ - - - function isFetchNeeded(start, end) { - return !rangeStart || // nothing has been fetched yet? - // or, a part of the new range is outside of the old range? (after normalizing) - start.clone().stripZone() < rangeStart.clone().stripZone() || - end.clone().stripZone() > rangeEnd.clone().stripZone(); - } - - - function fetchEvents(start, end) { - rangeStart = start; - rangeEnd = end; - cache = []; - var fetchID = ++currentFetchID; - var len = sources.length; - pendingSourceCnt = len; - for (var i=0; i<len; i++) { - fetchEventSource(sources[i], fetchID); - } - } - - - function fetchEventSource(source, fetchID) { - _fetchEventSource(source, function(eventInputs) { - var isArraySource = $.isArray(source.events); - var i, eventInput; - var abstractEvent; - - if (fetchID == currentFetchID) { - - if (eventInputs) { - for (i = 0; i < eventInputs.length; i++) { - eventInput = eventInputs[i]; - - if (isArraySource) { // array sources have already been convert to Event Objects - abstractEvent = eventInput; - } - else { - abstractEvent = buildEventFromInput(eventInput, source); - } - - if (abstractEvent) { // not false (an invalid event) - cache.push.apply( - cache, - expandEvent(abstractEvent) // add individual expanded events to the cache - ); - } - } - } - - pendingSourceCnt--; - if (!pendingSourceCnt) { - reportEvents(cache); - } - } - }); - } - - - function _fetchEventSource(source, callback) { - var i; - var fetchers = fc.sourceFetchers; - var res; - - for (i=0; i<fetchers.length; i++) { - res = fetchers[i].call( - t, // this, the Calendar object - source, - rangeStart.clone(), - rangeEnd.clone(), - options.timezone, - callback - ); - - if (res === true) { - // the fetcher is in charge. made its own async request - return; - } - else if (typeof res == 'object') { - // the fetcher returned a new source. process it - _fetchEventSource(res, callback); - return; - } - } - - var events = source.events; - if (events) { - if ($.isFunction(events)) { - t.pushLoading(); - events.call( - t, // this, the Calendar object - rangeStart.clone(), - rangeEnd.clone(), - options.timezone, - function(events) { - callback(events); - t.popLoading(); - } - ); - } - else if ($.isArray(events)) { - callback(events); - } - else { - callback(); - } - }else{ - var url = source.url; - if (url) { - var success = source.success; - var error = source.error; - var complete = source.complete; - - // retrieve any outbound GET/POST $.ajax data from the options - var customData; - if ($.isFunction(source.data)) { - // supplied as a function that returns a key/value object - customData = source.data(); - } - else { - // supplied as a straight key/value object - customData = source.data; - } - - // use a copy of the custom data so we can modify the parameters - // and not affect the passed-in object. - var data = $.extend({}, customData || {}); - - var startParam = firstDefined(source.startParam, options.startParam); - var endParam = firstDefined(source.endParam, options.endParam); - var timezoneParam = firstDefined(source.timezoneParam, options.timezoneParam); - - if (startParam) { - data[startParam] = rangeStart.format(); - } - if (endParam) { - data[endParam] = rangeEnd.format(); - } - if (options.timezone && options.timezone != 'local') { - data[timezoneParam] = options.timezone; - } - - t.pushLoading(); - $.ajax($.extend({}, ajaxDefaults, source, { - data: data, - success: function(events) { - events = events || []; - var res = applyAll(success, this, arguments); - if ($.isArray(res)) { - events = res; - } - callback(events); - }, - error: function() { - applyAll(error, this, arguments); - callback(); - }, - complete: function() { - applyAll(complete, this, arguments); - t.popLoading(); - } - })); - }else{ - callback(); - } - } - } - - - - /* Sources - -----------------------------------------------------------------------------*/ - - - function addEventSource(sourceInput) { - var source = buildEventSource(sourceInput); - if (source) { - sources.push(source); - pendingSourceCnt++; - fetchEventSource(source, currentFetchID); // will eventually call reportEvents - } - } - - - function buildEventSource(sourceInput) { // will return undefined if invalid source - var normalizers = fc.sourceNormalizers; - var source; - var i; - - if ($.isFunction(sourceInput) || $.isArray(sourceInput)) { - source = { events: sourceInput }; - } - else if (typeof sourceInput === 'string') { - source = { url: sourceInput }; - } - else if (typeof sourceInput === 'object') { - source = $.extend({}, sourceInput); // shallow copy - } - - if (source) { - - // TODO: repeat code, same code for event classNames - if (source.className) { - if (typeof source.className === 'string') { - source.className = source.className.split(/\s+/); - } - // otherwise, assumed to be an array - } - else { - source.className = []; - } - - // for array sources, we convert to standard Event Objects up front - if ($.isArray(source.events)) { - source.origArray = source.events; // for removeEventSource - source.events = $.map(source.events, function(eventInput) { - return buildEventFromInput(eventInput, source); - }); - } - - for (i=0; i<normalizers.length; i++) { - normalizers[i].call(t, source); - } - - return source; - } - } - - - function removeEventSource(source) { - sources = $.grep(sources, function(src) { - return !isSourcesEqual(src, source); - }); - // remove all client events from that source - cache = $.grep(cache, function(e) { - return !isSourcesEqual(e.source, source); - }); - reportEvents(cache); - } - - - function isSourcesEqual(source1, source2) { - return source1 && source2 && getSourcePrimitive(source1) == getSourcePrimitive(source2); - } - - - function getSourcePrimitive(source) { - return ( - (typeof source === 'object') ? // a normalized event source? - (source.origArray || source.googleCalendarId || source.url || source.events) : // get the primitive - null - ) || - source; // the given argument *is* the primitive - } - - - - /* Manipulation - -----------------------------------------------------------------------------*/ - - - // Only ever called from the externally-facing API - function updateEvent(event) { - - // massage start/end values, even if date string values - event.start = t.moment(event.start); - if (event.end) { - event.end = t.moment(event.end); - } - else { - event.end = null; - } - - mutateEvent(event, getMiscEventProps(event)); // will handle start/end/allDay normalization - reportEvents(cache); // reports event modifications (so we can redraw) - } - - - // Returns a hash of misc event properties that should be copied over to related events. - function getMiscEventProps(event) { - var props = {}; - - $.each(event, function(name, val) { - if (isMiscEventPropName(name)) { - if (val !== undefined && isAtomic(val)) { // a defined non-object - props[name] = val; - } - } - }); - - return props; - } - - // non-date-related, non-id-related, non-secret - function isMiscEventPropName(name) { - return !/^_|^(id|allDay|start|end)$/.test(name); - } - - - // returns the expanded events that were created - function renderEvent(eventInput, stick) { - var abstractEvent = buildEventFromInput(eventInput); - var events; - var i, event; - - if (abstractEvent) { // not false (a valid input) - events = expandEvent(abstractEvent); - - for (i = 0; i < events.length; i++) { - event = events[i]; - - if (!event.source) { - if (stick) { - stickySource.events.push(event); - event.source = stickySource; - } - cache.push(event); - } - } - - reportEvents(cache); - - return events; - } - - return []; - } - - - function removeEvents(filter) { - var eventID; - var i; - - if (filter == null) { // null or undefined. remove all events - filter = function() { return true; }; // will always match - } - else if (!$.isFunction(filter)) { // an event ID - eventID = filter + ''; - filter = function(event) { - return event._id == eventID; - }; - } - - // Purge event(s) from our local cache - cache = $.grep(cache, filter, true); // inverse=true - - // Remove events from array sources. - // This works because they have been converted to official Event Objects up front. - // (and as a result, event._id has been calculated). - for (i=0; i<sources.length; i++) { - if ($.isArray(sources[i].events)) { - sources[i].events = $.grep(sources[i].events, filter, true); - } - } - - reportEvents(cache); - } - - - function clientEvents(filter) { - if ($.isFunction(filter)) { - return $.grep(cache, filter); - } - else if (filter != null) { // not null, not undefined. an event ID - filter += ''; - return $.grep(cache, function(e) { - return e._id == filter; - }); - } - return cache; // else, return all - } - - - - /* Event Normalization - -----------------------------------------------------------------------------*/ - - - // Given a raw object with key/value properties, returns an "abstract" Event object. - // An "abstract" event is an event that, if recurring, will not have been expanded yet. - // Will return `false` when input is invalid. - // `source` is optional - function buildEventFromInput(input, source) { - var out = {}; - var start, end; - var allDay; - - if (options.eventDataTransform) { - input = options.eventDataTransform(input); - } - if (source && source.eventDataTransform) { - input = source.eventDataTransform(input); - } - - // Copy all properties over to the resulting object. - // The special-case properties will be copied over afterwards. - $.extend(out, input); - - if (source) { - out.source = source; - } - - out._id = input._id || (input.id === undefined ? '_fc' + eventGUID++ : input.id + ''); - - if (input.className) { - if (typeof input.className == 'string') { - out.className = input.className.split(/\s+/); - } - else { // assumed to be an array - out.className = input.className; - } - } - else { - out.className = []; - } - - start = input.start || input.date; // "date" is an alias for "start" - end = input.end; - - // parse as a time (Duration) if applicable - if (isTimeString(start)) { - start = moment.duration(start); - } - if (isTimeString(end)) { - end = moment.duration(end); - } - - if (input.dow || moment.isDuration(start) || moment.isDuration(end)) { - - // the event is "abstract" (recurring) so don't calculate exact start/end dates just yet - out.start = start ? moment.duration(start) : null; // will be a Duration or null - out.end = end ? moment.duration(end) : null; // will be a Duration or null - out._recurring = true; // our internal marker - } - else { - - if (start) { - start = t.moment(start); - if (!start.isValid()) { - return false; - } - } - - if (end) { - end = t.moment(end); - if (!end.isValid()) { - end = null; // let defaults take over - } - } - - allDay = input.allDay; - if (allDay === undefined) { // still undefined? fallback to default - allDay = firstDefined( - source ? source.allDayDefault : undefined, - options.allDayDefault - ); - // still undefined? normalizeEventRange will calculate it - } - - assignDatesToEvent(start, end, allDay, out); - } - - return out; - } - - - // Normalizes and assigns the given dates to the given partially-formed event object. - // NOTE: mutates the given start/end moments. does not make a copy. - function assignDatesToEvent(start, end, allDay, event) { - event.start = start; - event.end = end; - event.allDay = allDay; - normalizeEventRange(event); - backupEventDates(event); - } - - - // Ensures proper values for allDay/start/end. Accepts an Event object, or a plain object with event-ish properties. - // NOTE: Will modify the given object. - function normalizeEventRange(props) { - - normalizeEventRangeTimes(props); - - if (props.end && !props.end.isAfter(props.start)) { - props.end = null; - } - - if (!props.end) { - if (options.forceEventDuration) { - props.end = t.getDefaultEventEnd(props.allDay, props.start); - } - else { - props.end = null; - } - } - } - - - // Ensures the allDay property exists and the timeliness of the start/end dates are consistent - function normalizeEventRangeTimes(range) { - if (range.allDay == null) { - range.allDay = !(range.start.hasTime() || (range.end && range.end.hasTime())); - } - - if (range.allDay) { - range.start.stripTime(); - if (range.end) { - // TODO: consider nextDayThreshold here? If so, will require a lot of testing and adjustment - range.end.stripTime(); - } - } - else { - if (!range.start.hasTime()) { - range.start = t.rezoneDate(range.start); // will assign a 00:00 time - } - if (range.end && !range.end.hasTime()) { - range.end = t.rezoneDate(range.end); // will assign a 00:00 time - } - } - } - - - // If `range` is a proper range with a start and end, returns the original object. - // If missing an end, computes a new range with an end, computing it as if it were an event. - // TODO: make this a part of the event -> eventRange system - function ensureVisibleEventRange(range) { - var allDay; - - if (!range.end) { - - allDay = range.allDay; // range might be more event-ish than we think - if (allDay == null) { - allDay = !range.start.hasTime(); - } - - range = $.extend({}, range); // make a copy, copying over other misc properties - range.end = t.getDefaultEventEnd(allDay, range.start); - } - return range; - } - - - // If the given event is a recurring event, break it down into an array of individual instances. - // If not a recurring event, return an array with the single original event. - // If given a falsy input (probably because of a failed buildEventFromInput call), returns an empty array. - // HACK: can override the recurring window by providing custom rangeStart/rangeEnd (for businessHours). - function expandEvent(abstractEvent, _rangeStart, _rangeEnd) { - var events = []; - var dowHash; - var dow; - var i; - var date; - var startTime, endTime; - var start, end; - var event; - - _rangeStart = _rangeStart || rangeStart; - _rangeEnd = _rangeEnd || rangeEnd; - - if (abstractEvent) { - if (abstractEvent._recurring) { - - // make a boolean hash as to whether the event occurs on each day-of-week - if ((dow = abstractEvent.dow)) { - dowHash = {}; - for (i = 0; i < dow.length; i++) { - dowHash[dow[i]] = true; - } - } - - // iterate through every day in the current range - date = _rangeStart.clone().stripTime(); // holds the date of the current day - while (date.isBefore(_rangeEnd)) { - - if (!dowHash || dowHash[date.day()]) { // if everyday, or this particular day-of-week - - startTime = abstractEvent.start; // the stored start and end properties are times (Durations) - endTime = abstractEvent.end; // " - start = date.clone(); - end = null; - - if (startTime) { - start = start.time(startTime); - } - if (endTime) { - end = date.clone().time(endTime); - } - - event = $.extend({}, abstractEvent); // make a copy of the original - assignDatesToEvent( - start, end, - !startTime && !endTime, // allDay? - event - ); - events.push(event); - } - - date.add(1, 'days'); - } - } - else { - events.push(abstractEvent); // return the original event. will be a one-item array - } - } - - return events; - } - - - - /* Event Modification Math - -----------------------------------------------------------------------------------------*/ - - - // Modifies an event and all related events by applying the given properties. - // Special date-diffing logic is used for manipulation of dates. - // If `props` does not contain start/end dates, the updated values are assumed to be the event's current start/end. - // All date comparisons are done against the event's pristine _start and _end dates. - // Returns an object with delta information and a function to undo all operations. - // For making computations in a granularity greater than day/time, specify largeUnit. - // NOTE: The given `newProps` might be mutated for normalization purposes. - function mutateEvent(event, newProps, largeUnit) { - var miscProps = {}; - var oldProps; - var clearEnd; - var startDelta; - var endDelta; - var durationDelta; - var undoFunc; - - // diffs the dates in the appropriate way, returning a duration - function diffDates(date1, date0) { // date1 - date0 - if (largeUnit) { - return diffByUnit(date1, date0, largeUnit); - } - else if (newProps.allDay) { - return diffDay(date1, date0); - } - else { - return diffDayTime(date1, date0); - } - } - - newProps = newProps || {}; - - // normalize new date-related properties - if (!newProps.start) { - newProps.start = event.start.clone(); - } - if (newProps.end === undefined) { - newProps.end = event.end ? event.end.clone() : null; - } - if (newProps.allDay == null) { // is null or undefined? - newProps.allDay = event.allDay; - } - normalizeEventRange(newProps); - - // create normalized versions of the original props to compare against - // need a real end value, for diffing - oldProps = { - start: event._start.clone(), - end: event._end ? event._end.clone() : t.getDefaultEventEnd(event._allDay, event._start), - allDay: newProps.allDay // normalize the dates in the same regard as the new properties - }; - normalizeEventRange(oldProps); - - // need to clear the end date if explicitly changed to null - clearEnd = event._end !== null && newProps.end === null; - - // compute the delta for moving the start date - startDelta = diffDates(newProps.start, oldProps.start); - - // compute the delta for moving the end date - if (newProps.end) { - endDelta = diffDates(newProps.end, oldProps.end); - durationDelta = endDelta.subtract(startDelta); - } - else { - durationDelta = null; - } - - // gather all non-date-related properties - $.each(newProps, function(name, val) { - if (isMiscEventPropName(name)) { - if (val !== undefined) { - miscProps[name] = val; - } - } - }); - - // apply the operations to the event and all related events - undoFunc = mutateEvents( - clientEvents(event._id), // get events with this ID - clearEnd, - newProps.allDay, - startDelta, - durationDelta, - miscProps - ); - - return { - dateDelta: startDelta, - durationDelta: durationDelta, - undo: undoFunc - }; - } - - - // Modifies an array of events in the following ways (operations are in order): - // - clear the event's `end` - // - convert the event to allDay - // - add `dateDelta` to the start and end - // - add `durationDelta` to the event's duration - // - assign `miscProps` to the event - // - // Returns a function that can be called to undo all the operations. - // - // TODO: don't use so many closures. possible memory issues when lots of events with same ID. - // - function mutateEvents(events, clearEnd, allDay, dateDelta, durationDelta, miscProps) { - var isAmbigTimezone = t.getIsAmbigTimezone(); - var undoFunctions = []; - - // normalize zero-length deltas to be null - if (dateDelta && !dateDelta.valueOf()) { dateDelta = null; } - if (durationDelta && !durationDelta.valueOf()) { durationDelta = null; } - - $.each(events, function(i, event) { - var oldProps; - var newProps; - - // build an object holding all the old values, both date-related and misc. - // for the undo function. - oldProps = { - start: event.start.clone(), - end: event.end ? event.end.clone() : null, - allDay: event.allDay - }; - $.each(miscProps, function(name) { - oldProps[name] = event[name]; - }); - - // new date-related properties. work off the original date snapshot. - // ok to use references because they will be thrown away when backupEventDates is called. - newProps = { - start: event._start, - end: event._end, - allDay: allDay // normalize the dates in the same regard as the new properties - }; - normalizeEventRange(newProps); // massages start/end/allDay - - // strip or ensure the end date - if (clearEnd) { - newProps.end = null; - } - else if (durationDelta && !newProps.end) { // the duration translation requires an end date - newProps.end = t.getDefaultEventEnd(newProps.allDay, newProps.start); - } - - if (dateDelta) { - newProps.start.add(dateDelta); - if (newProps.end) { - newProps.end.add(dateDelta); - } - } - - if (durationDelta) { - newProps.end.add(durationDelta); // end already ensured above - } - - // if the dates have changed, and we know it is impossible to recompute the - // timezone offsets, strip the zone. - if ( - isAmbigTimezone && - !newProps.allDay && - (dateDelta || durationDelta) - ) { - newProps.start.stripZone(); - if (newProps.end) { - newProps.end.stripZone(); - } - } - - $.extend(event, miscProps, newProps); // copy over misc props, then date-related props - backupEventDates(event); // regenerate internal _start/_end/_allDay - - undoFunctions.push(function() { - $.extend(event, oldProps); - backupEventDates(event); // regenerate internal _start/_end/_allDay - }); - }); - - return function() { - for (var i = 0; i < undoFunctions.length; i++) { - undoFunctions[i](); - } - }; - } - - - /* Business Hours - -----------------------------------------------------------------------------------------*/ - - t.getBusinessHoursEvents = getBusinessHoursEvents; - - - // Returns an array of events as to when the business hours occur in the given view. - // Abuse of our event system :( - function getBusinessHoursEvents(wholeDay) { - var optionVal = options.businessHours; - var defaultVal = { - className: 'fc-nonbusiness', - start: '09:00', - end: '17:00', - dow: [ 1, 2, 3, 4, 5 ], // monday - friday - rendering: 'inverse-background' - }; - var view = t.getView(); - var eventInput; - - if (optionVal) { // `true` (which means "use the defaults") or an override object - eventInput = $.extend( - {}, // copy to a new object in either case - defaultVal, - typeof optionVal === 'object' ? optionVal : {} // override the defaults - ); - } - - if (eventInput) { - - // if a whole-day series is requested, clear the start/end times - if (wholeDay) { - eventInput.start = null; - eventInput.end = null; - } - - return expandEvent( - buildEventFromInput(eventInput), - view.start, - view.end - ); - } - - return []; - } - - - /* Overlapping / Constraining - -----------------------------------------------------------------------------------------*/ - - t.isEventRangeAllowed = isEventRangeAllowed; - t.isSelectionRangeAllowed = isSelectionRangeAllowed; - t.isExternalDropRangeAllowed = isExternalDropRangeAllowed; - - - function isEventRangeAllowed(range, event) { - var source = event.source || {}; - var constraint = firstDefined( - event.constraint, - source.constraint, - options.eventConstraint - ); - var overlap = firstDefined( - event.overlap, - source.overlap, - options.eventOverlap - ); - - range = ensureVisibleEventRange(range); // ensure a proper range with an end for isRangeAllowed - - return isRangeAllowed(range, constraint, overlap, event); - } - - - function isSelectionRangeAllowed(range) { - return isRangeAllowed(range, options.selectConstraint, options.selectOverlap); - } - - - // when `eventProps` is defined, consider this an event. - // `eventProps` can contain misc non-date-related info about the event. - function isExternalDropRangeAllowed(range, eventProps) { - var eventInput; - var event; - - // note: very similar logic is in View's reportExternalDrop - if (eventProps) { - eventInput = $.extend({}, eventProps, range); - event = expandEvent(buildEventFromInput(eventInput))[0]; - } - - if (event) { - return isEventRangeAllowed(range, event); - } - else { // treat it as a selection - - range = ensureVisibleEventRange(range); // ensure a proper range with an end for isSelectionRangeAllowed - - return isSelectionRangeAllowed(range); - } - } - - - // Returns true if the given range (caused by an event drop/resize or a selection) is allowed to exist - // according to the constraint/overlap settings. - // `event` is not required if checking a selection. - function isRangeAllowed(range, constraint, overlap, event) { - var constraintEvents; - var anyContainment; - var peerEvents; - var i, peerEvent; - var peerOverlap; - - // normalize. fyi, we're normalizing in too many places :( - range = $.extend({}, range); // copy all properties in case there are misc non-date properties - range.start = range.start.clone().stripZone(); - range.end = range.end.clone().stripZone(); - - // the range must be fully contained by at least one of produced constraint events - if (constraint != null) { - - // not treated as an event! intermediate data structure - // TODO: use ranges in the future - constraintEvents = constraintToEvents(constraint); - - anyContainment = false; - for (i = 0; i < constraintEvents.length; i++) { - if (eventContainsRange(constraintEvents[i], range)) { - anyContainment = true; - break; - } - } - - if (!anyContainment) { - return false; - } - } - - peerEvents = t.getPeerEvents(event, range); - - for (i = 0; i < peerEvents.length; i++) { - peerEvent = peerEvents[i]; - - // there needs to be an actual intersection before disallowing anything - if (eventIntersectsRange(peerEvent, range)) { - - // evaluate overlap for the given range and short-circuit if necessary - if (overlap === false) { - return false; - } - // if the event's overlap is a test function, pass the peer event in question as the first param - else if (typeof overlap === 'function' && !overlap(peerEvent, event)) { - return false; - } - - // if we are computing if the given range is allowable for an event, consider the other event's - // EventObject-specific or Source-specific `overlap` property - if (event) { - peerOverlap = firstDefined( - peerEvent.overlap, - (peerEvent.source || {}).overlap - // we already considered the global `eventOverlap` - ); - if (peerOverlap === false) { - return false; - } - // if the peer event's overlap is a test function, pass the subject event as the first param - if (typeof peerOverlap === 'function' && !peerOverlap(event, peerEvent)) { - return false; - } - } - } - } - - return true; - } - - - // Given an event input from the API, produces an array of event objects. Possible event inputs: - // 'businessHours' - // An event ID (number or string) - // An object with specific start/end dates or a recurring event (like what businessHours accepts) - function constraintToEvents(constraintInput) { - - if (constraintInput === 'businessHours') { - return getBusinessHoursEvents(); - } - - if (typeof constraintInput === 'object') { - return expandEvent(buildEventFromInput(constraintInput)); - } - - return clientEvents(constraintInput); // probably an ID - } - - - // Does the event's date range fully contain the given range? - // start/end already assumed to have stripped zones :( - function eventContainsRange(event, range) { - var eventStart = event.start.clone().stripZone(); - var eventEnd = t.getEventEnd(event).stripZone(); - - return range.start >= eventStart && range.end <= eventEnd; - } - - - // Does the event's date range intersect with the given range? - // start/end already assumed to have stripped zones :( - function eventIntersectsRange(event, range) { - var eventStart = event.start.clone().stripZone(); - var eventEnd = t.getEventEnd(event).stripZone(); - - return range.start < eventEnd && range.end > eventStart; - } - - - t.getEventCache = function() { - return cache; - }; - -} - - -// Returns a list of events that the given event should be compared against when being considered for a move to -// the specified range. Attached to the Calendar's prototype because EventManager is a mixin for a Calendar. -Calendar.prototype.getPeerEvents = function(event, range) { - var cache = this.getEventCache(); - var peerEvents = []; - var i, otherEvent; - - for (i = 0; i < cache.length; i++) { - otherEvent = cache[i]; - if ( - !event || - event._id !== otherEvent._id // don't compare the event to itself or other related [repeating] events - ) { - peerEvents.push(otherEvent); - } - } - - return peerEvents; -}; - - -// updates the "backup" properties, which are preserved in order to compute diffs later on. -function backupEventDates(event) { - event._allDay = event.allDay; - event._start = event.start.clone(); - event._end = event.end ? event.end.clone() : null; -} - -;; - -/* An abstract class for the "basic" views, as well as month view. Renders one or more rows of day cells. -----------------------------------------------------------------------------------------------------------------------*/ -// It is a manager for a DayGrid subcomponent, which does most of the heavy lifting. -// It is responsible for managing width/height. - -var BasicView = View.extend({ - - dayGrid: null, // the main subcomponent that does most of the heavy lifting - - dayNumbersVisible: false, // display day numbers on each day cell? - weekNumbersVisible: false, // display week numbers along the side? - - weekNumberWidth: null, // width of all the week-number cells running down the side - - headRowEl: null, // the fake row element of the day-of-week header - - - initialize: function() { - this.dayGrid = new DayGrid(this); - this.coordMap = this.dayGrid.coordMap; // the view's date-to-cell mapping is identical to the subcomponent's - }, - - - // Sets the display range and computes all necessary dates - setRange: function(range) { - View.prototype.setRange.call(this, range); // call the super-method - - this.dayGrid.breakOnWeeks = /year|month|week/.test(this.intervalUnit); // do before setRange - this.dayGrid.setRange(range); - }, - - - // Compute the value to feed into setRange. Overrides superclass. - computeRange: function(date) { - var range = View.prototype.computeRange.call(this, date); // get value from the super-method - - // year and month views should be aligned with weeks. this is already done for week - if (/year|month/.test(range.intervalUnit)) { - range.start.startOf('week'); - range.start = this.skipHiddenDays(range.start); - - // make end-of-week if not already - if (range.end.weekday()) { - range.end.add(1, 'week').startOf('week'); - range.end = this.skipHiddenDays(range.end, -1, true); // exclusively move backwards - } - } - - return range; - }, - - - // Renders the view into `this.el`, which should already be assigned - renderDates: function() { - - this.dayNumbersVisible = this.dayGrid.rowCnt > 1; // TODO: make grid responsible - this.weekNumbersVisible = this.opt('weekNumbers'); - this.dayGrid.numbersVisible = this.dayNumbersVisible || this.weekNumbersVisible; - - this.el.addClass('fc-basic-view').html(this.renderHtml()); - - this.headRowEl = this.el.find('thead .fc-row'); - - this.scrollerEl = this.el.find('.fc-day-grid-container'); - this.dayGrid.coordMap.containerEl = this.scrollerEl; // constrain clicks/etc to the dimensions of the scroller - - this.dayGrid.setElement(this.el.find('.fc-day-grid')); - this.dayGrid.renderDates(this.hasRigidRows()); - }, - - - // Unrenders the content of the view. Since we haven't separated skeleton rendering from date rendering, - // always completely kill the dayGrid's rendering. - unrenderDates: function() { - this.dayGrid.unrenderDates(); - this.dayGrid.removeElement(); - }, - - - renderBusinessHours: function() { - this.dayGrid.renderBusinessHours(); - }, - - - // Builds the HTML skeleton for the view. - // The day-grid component will render inside of a container defined by this HTML. - renderHtml: function() { - return '' + - '<table>' + - '<thead class="fc-head">' + - '<tr>' + - '<td class="' + this.widgetHeaderClass + '">' + - this.dayGrid.headHtml() + // render the day-of-week headers - '</td>' + - '</tr>' + - '</thead>' + - '<tbody class="fc-body">' + - '<tr>' + - '<td class="' + this.widgetContentClass + '">' + - '<div class="fc-day-grid-container">' + - '<div class="fc-day-grid"/>' + - '</div>' + - '</td>' + - '</tr>' + - '</tbody>' + - '</table>'; - }, - - - // Generates the HTML that will go before the day-of week header cells. - // Queried by the DayGrid subcomponent when generating rows. Ordering depends on isRTL. - headIntroHtml: function() { - if (this.weekNumbersVisible) { - return '' + - '<th class="fc-week-number ' + this.widgetHeaderClass + '" ' + this.weekNumberStyleAttr() + '>' + - '<span>' + // needed for matchCellWidths - htmlEscape(this.opt('weekNumberTitle')) + - '</span>' + - '</th>'; - } - }, - - - // Generates the HTML that will go before content-skeleton cells that display the day/week numbers. - // Queried by the DayGrid subcomponent. Ordering depends on isRTL. - numberIntroHtml: function(row) { - if (this.weekNumbersVisible) { - return '' + - '<td class="fc-week-number" ' + this.weekNumberStyleAttr() + '>' + - '<span>' + // needed for matchCellWidths - this.dayGrid.getCell(row, 0).start.format('w') + - '</span>' + - '</td>'; - } - }, - - - // Generates the HTML that goes before the day bg cells for each day-row. - // Queried by the DayGrid subcomponent. Ordering depends on isRTL. - dayIntroHtml: function() { - if (this.weekNumbersVisible) { - return '<td class="fc-week-number ' + this.widgetContentClass + '" ' + - this.weekNumberStyleAttr() + '></td>'; - } - }, - - - // Generates the HTML that goes before every other type of row generated by DayGrid. Ordering depends on isRTL. - // Affects helper-skeleton and highlight-skeleton rows. - introHtml: function() { - if (this.weekNumbersVisible) { - return '<td class="fc-week-number" ' + this.weekNumberStyleAttr() + '></td>'; - } - }, - - - // Generates the HTML for the <td>s of the "number" row in the DayGrid's content skeleton. - // The number row will only exist if either day numbers or week numbers are turned on. - numberCellHtml: function(cell) { - var date = cell.start; - var classes; - - if (!this.dayNumbersVisible) { // if there are week numbers but not day numbers - return '<td/>'; // will create an empty space above events :( - } - - classes = this.dayGrid.getDayClasses(date); - classes.unshift('fc-day-number'); - - return '' + - '<td class="' + classes.join(' ') + '" data-date="' + date.format() + '">' + - date.date() + - '</td>'; - }, - - - // Generates an HTML attribute string for setting the width of the week number column, if it is known - weekNumberStyleAttr: function() { - if (this.weekNumberWidth !== null) { - return 'style="width:' + this.weekNumberWidth + 'px"'; - } - return ''; - }, - - - // Determines whether each row should have a constant height - hasRigidRows: function() { - var eventLimit = this.opt('eventLimit'); - return eventLimit && typeof eventLimit !== 'number'; - }, - - - /* Dimensions - ------------------------------------------------------------------------------------------------------------------*/ - - - // Refreshes the horizontal dimensions of the view - updateWidth: function() { - if (this.weekNumbersVisible) { - // Make sure all week number cells running down the side have the same width. - // Record the width for cells created later. - this.weekNumberWidth = matchCellWidths( - this.el.find('.fc-week-number') - ); - } - }, - - - // Adjusts the vertical dimensions of the view to the specified values - setHeight: function(totalHeight, isAuto) { - var eventLimit = this.opt('eventLimit'); - var scrollerHeight; - - // reset all heights to be natural - unsetScroller(this.scrollerEl); - uncompensateScroll(this.headRowEl); - - this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed - - // is the event limit a constant level number? - if (eventLimit && typeof eventLimit === 'number') { - this.dayGrid.limitRows(eventLimit); // limit the levels first so the height can redistribute after - } - - scrollerHeight = this.computeScrollerHeight(totalHeight); - this.setGridHeight(scrollerHeight, isAuto); - - // is the event limit dynamically calculated? - if (eventLimit && typeof eventLimit !== 'number') { - this.dayGrid.limitRows(eventLimit); // limit the levels after the grid's row heights have been set - } - - if (!isAuto && setPotentialScroller(this.scrollerEl, scrollerHeight)) { // using scrollbars? - - compensateScroll(this.headRowEl, getScrollbarWidths(this.scrollerEl)); - - // doing the scrollbar compensation might have created text overflow which created more height. redo - scrollerHeight = this.computeScrollerHeight(totalHeight); - this.scrollerEl.height(scrollerHeight); - } - }, - - - // Sets the height of just the DayGrid component in this view - setGridHeight: function(height, isAuto) { - if (isAuto) { - undistributeHeight(this.dayGrid.rowEls); // let the rows be their natural height with no expanding - } - else { - distributeHeight(this.dayGrid.rowEls, height, true); // true = compensate for height-hogging rows - } - }, - - - /* Events - ------------------------------------------------------------------------------------------------------------------*/ - - - // Renders the given events onto the view and populates the segments array - renderEvents: function(events) { - this.dayGrid.renderEvents(events); - - this.updateHeight(); // must compensate for events that overflow the row - }, - - - // Retrieves all segment objects that are rendered in the view - getEventSegs: function() { - return this.dayGrid.getEventSegs(); - }, - - - // Unrenders all event elements and clears internal segment data - unrenderEvents: function() { - this.dayGrid.unrenderEvents(); - - // we DON'T need to call updateHeight() because: - // A) a renderEvents() call always happens after this, which will eventually call updateHeight() - // B) in IE8, this causes a flash whenever events are rerendered - }, - - - /* Dragging (for both events and external elements) - ------------------------------------------------------------------------------------------------------------------*/ - - - // A returned value of `true` signals that a mock "helper" event has been rendered. - renderDrag: function(dropLocation, seg) { - return this.dayGrid.renderDrag(dropLocation, seg); - }, - - - unrenderDrag: function() { - this.dayGrid.unrenderDrag(); - }, - - - /* Selection - ------------------------------------------------------------------------------------------------------------------*/ - - - // Renders a visual indication of a selection - renderSelection: function(range) { - this.dayGrid.renderSelection(range); - }, - - - // Unrenders a visual indications of a selection - unrenderSelection: function() { - this.dayGrid.unrenderSelection(); - } - -}); - -;; - -/* A month view with day cells running in rows (one-per-week) and columns -----------------------------------------------------------------------------------------------------------------------*/ - -var MonthView = BasicView.extend({ - - // Produces information about what range to display - computeRange: function(date) { - var range = BasicView.prototype.computeRange.call(this, date); // get value from super-method - var rowCnt; - - // ensure 6 weeks - if (this.isFixedWeeks()) { - rowCnt = Math.ceil(range.end.diff(range.start, 'weeks', true)); // could be partial weeks due to hiddenDays - range.end.add(6 - rowCnt, 'weeks'); - } - - return range; - }, - - - // Overrides the default BasicView behavior to have special multi-week auto-height logic - setGridHeight: function(height, isAuto) { - - isAuto = isAuto || this.opt('weekMode') === 'variable'; // LEGACY: weekMode is deprecated - - // if auto, make the height of each row the height that it would be if there were 6 weeks - if (isAuto) { - height *= this.rowCnt / 6; - } - - distributeHeight(this.dayGrid.rowEls, height, !isAuto); // if auto, don't compensate for height-hogging rows - }, - - - isFixedWeeks: function() { - var weekMode = this.opt('weekMode'); // LEGACY: weekMode is deprecated - if (weekMode) { - return weekMode === 'fixed'; // if any other type of weekMode, assume NOT fixed - } - - return this.opt('fixedWeekCount'); - } - -}); - -;; - -fcViews.basic = { - 'class': BasicView -}; - -fcViews.basicDay = { - type: 'basic', - duration: { days: 1 } -}; - -fcViews.basicWeek = { - type: 'basic', - duration: { weeks: 1 } -}; - -fcViews.month = { - 'class': MonthView, - duration: { months: 1 }, // important for prev/next - defaults: { - fixedWeekCount: true - } -}; -;; - -/* An abstract class for all agenda-related views. Displays one more columns with time slots running vertically. -----------------------------------------------------------------------------------------------------------------------*/ -// Is a manager for the TimeGrid subcomponent and possibly the DayGrid subcomponent (if allDaySlot is on). -// Responsible for managing width/height. - -var AgendaView = View.extend({ - - timeGrid: null, // the main time-grid subcomponent of this view - dayGrid: null, // the "all-day" subcomponent. if all-day is turned off, this will be null - - axisWidth: null, // the width of the time axis running down the side - - noScrollRowEls: null, // set of fake row elements that must compensate when scrollerEl has scrollbars - - // when the time-grid isn't tall enough to occupy the given height, we render an <hr> underneath - bottomRuleEl: null, - bottomRuleHeight: null, - - - initialize: function() { - this.timeGrid = new TimeGrid(this); - - if (this.opt('allDaySlot')) { // should we display the "all-day" area? - this.dayGrid = new DayGrid(this); // the all-day subcomponent of this view - - // the coordinate grid will be a combination of both subcomponents' grids - this.coordMap = new ComboCoordMap([ - this.dayGrid.coordMap, - this.timeGrid.coordMap - ]); - } - else { - this.coordMap = this.timeGrid.coordMap; - } - }, - - - /* Rendering - ------------------------------------------------------------------------------------------------------------------*/ - - - // Sets the display range and computes all necessary dates - setRange: function(range) { - View.prototype.setRange.call(this, range); // call the super-method - - this.timeGrid.setRange(range); - if (this.dayGrid) { - this.dayGrid.setRange(range); - } - }, - - - // Renders the view into `this.el`, which has already been assigned - renderDates: function() { - - this.el.addClass('fc-agenda-view').html(this.renderHtml()); - - // the element that wraps the time-grid that will probably scroll - this.scrollerEl = this.el.find('.fc-time-grid-container'); - this.timeGrid.coordMap.containerEl = this.scrollerEl; // don't accept clicks/etc outside of this - - this.timeGrid.setElement(this.el.find('.fc-time-grid')); - this.timeGrid.renderDates(); - - // the <hr> that sometimes displays under the time-grid - this.bottomRuleEl = $('<hr class="fc-divider ' + this.widgetHeaderClass + '"/>') - .appendTo(this.timeGrid.el); // inject it into the time-grid - - if (this.dayGrid) { - this.dayGrid.setElement(this.el.find('.fc-day-grid')); - this.dayGrid.renderDates(); - - // have the day-grid extend it's coordinate area over the <hr> dividing the two grids - this.dayGrid.bottomCoordPadding = this.dayGrid.el.next('hr').outerHeight(); - } - - this.noScrollRowEls = this.el.find('.fc-row:not(.fc-scroller *)'); // fake rows not within the scroller - }, - - - // Unrenders the content of the view. Since we haven't separated skeleton rendering from date rendering, - // always completely kill each grid's rendering. - unrenderDates: function() { - this.timeGrid.unrenderDates(); - this.timeGrid.removeElement(); - - if (this.dayGrid) { - this.dayGrid.unrenderDates(); - this.dayGrid.removeElement(); - } - }, - - - renderBusinessHours: function() { - this.timeGrid.renderBusinessHours(); - - if (this.dayGrid) { - this.dayGrid.renderBusinessHours(); - } - }, - - - // Builds the HTML skeleton for the view. - // The day-grid and time-grid components will render inside containers defined by this HTML. - renderHtml: function() { - return '' + - '<table>' + - '<thead class="fc-head">' + - '<tr>' + - '<td class="' + this.widgetHeaderClass + '">' + - this.timeGrid.headHtml() + // render the day-of-week headers - '</td>' + - '</tr>' + - '</thead>' + - '<tbody class="fc-body">' + - '<tr>' + - '<td class="' + this.widgetContentClass + '">' + - (this.dayGrid ? - '<div class="fc-day-grid"/>' + - '<hr class="fc-divider ' + this.widgetHeaderClass + '"/>' : - '' - ) + - '<div class="fc-time-grid-container">' + - '<div class="fc-time-grid"/>' + - '</div>' + - '</td>' + - '</tr>' + - '</tbody>' + - '</table>'; - }, - - - // Generates the HTML that will go before the day-of week header cells. - // Queried by the TimeGrid subcomponent when generating rows. Ordering depends on isRTL. - headIntroHtml: function() { - var date; - var weekText; - - if (this.opt('weekNumbers')) { - date = this.timeGrid.getCell(0).start; - weekText = date.format(this.opt('smallWeekFormat')); - - return '' + - '<th class="fc-axis fc-week-number ' + this.widgetHeaderClass + '" ' + this.axisStyleAttr() + '>' + - '<span>' + // needed for matchCellWidths - htmlEscape(weekText) + - '</span>' + - '</th>'; - } - else { - return '<th class="fc-axis ' + this.widgetHeaderClass + '" ' + this.axisStyleAttr() + '></th>'; - } - }, - - - // Generates the HTML that goes before the all-day cells. - // Queried by the DayGrid subcomponent when generating rows. Ordering depends on isRTL. - dayIntroHtml: function() { - return '' + - '<td class="fc-axis ' + this.widgetContentClass + '" ' + this.axisStyleAttr() + '>' + - '<span>' + // needed for matchCellWidths - (this.opt('allDayHtml') || htmlEscape(this.opt('allDayText'))) + - '</span>' + - '</td>'; - }, - - - // Generates the HTML that goes before the bg of the TimeGrid slot area. Long vertical column. - slotBgIntroHtml: function() { - return '<td class="fc-axis ' + this.widgetContentClass + '" ' + this.axisStyleAttr() + '></td>'; - }, - - - // Generates the HTML that goes before all other types of cells. - // Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid. - // Queried by the TimeGrid and DayGrid subcomponents when generating rows. Ordering depends on isRTL. - introHtml: function() { - return '<td class="fc-axis" ' + this.axisStyleAttr() + '></td>'; - }, - - - // Generates an HTML attribute string for setting the width of the axis, if it is known - axisStyleAttr: function() { - if (this.axisWidth !== null) { - return 'style="width:' + this.axisWidth + 'px"'; - } - return ''; - }, - - - /* Dimensions - ------------------------------------------------------------------------------------------------------------------*/ - - - updateSize: function(isResize) { - this.timeGrid.updateSize(isResize); - - View.prototype.updateSize.call(this, isResize); // call the super-method - }, - - - // Refreshes the horizontal dimensions of the view - updateWidth: function() { - // make all axis cells line up, and record the width so newly created axis cells will have it - this.axisWidth = matchCellWidths(this.el.find('.fc-axis')); - }, - - - // Adjusts the vertical dimensions of the view to the specified values - setHeight: function(totalHeight, isAuto) { - var eventLimit; - var scrollerHeight; - - if (this.bottomRuleHeight === null) { - // calculate the height of the rule the very first time - this.bottomRuleHeight = this.bottomRuleEl.outerHeight(); - } - this.bottomRuleEl.hide(); // .show() will be called later if this <hr> is necessary - - // reset all dimensions back to the original state - this.scrollerEl.css('overflow', ''); - unsetScroller(this.scrollerEl); - uncompensateScroll(this.noScrollRowEls); - - // limit number of events in the all-day area - if (this.dayGrid) { - this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed - - eventLimit = this.opt('eventLimit'); - if (eventLimit && typeof eventLimit !== 'number') { - eventLimit = AGENDA_ALL_DAY_EVENT_LIMIT; // make sure "auto" goes to a real number - } - if (eventLimit) { - this.dayGrid.limitRows(eventLimit); - } - } - - if (!isAuto) { // should we force dimensions of the scroll container, or let the contents be natural height? - - scrollerHeight = this.computeScrollerHeight(totalHeight); - if (setPotentialScroller(this.scrollerEl, scrollerHeight)) { // using scrollbars? - - // make the all-day and header rows lines up - compensateScroll(this.noScrollRowEls, getScrollbarWidths(this.scrollerEl)); - - // the scrollbar compensation might have changed text flow, which might affect height, so recalculate - // and reapply the desired height to the scroller. - scrollerHeight = this.computeScrollerHeight(totalHeight); - this.scrollerEl.height(scrollerHeight); - } - else { // no scrollbars - // still, force a height and display the bottom rule (marks the end of day) - this.scrollerEl.height(scrollerHeight).css('overflow', 'hidden'); // in case <hr> goes outside - this.bottomRuleEl.show(); - } - } - }, - - - // Computes the initial pre-configured scroll state prior to allowing the user to change it - computeInitialScroll: function() { - var scrollTime = moment.duration(this.opt('scrollTime')); - var top = this.timeGrid.computeTimeTop(scrollTime); - - // zoom can give weird floating-point values. rather scroll a little bit further - top = Math.ceil(top); - - if (top) { - top++; // to overcome top border that slots beyond the first have. looks better - } - - return top; - }, - - - /* Events - ------------------------------------------------------------------------------------------------------------------*/ - - - // Renders events onto the view and populates the View's segment array - renderEvents: function(events) { - var dayEvents = []; - var timedEvents = []; - var daySegs = []; - var timedSegs; - var i; - - // separate the events into all-day and timed - for (i = 0; i < events.length; i++) { - if (events[i].allDay) { - dayEvents.push(events[i]); - } - else { - timedEvents.push(events[i]); - } - } - - // render the events in the subcomponents - timedSegs = this.timeGrid.renderEvents(timedEvents); - if (this.dayGrid) { - daySegs = this.dayGrid.renderEvents(dayEvents); - } - - // the all-day area is flexible and might have a lot of events, so shift the height - this.updateHeight(); - }, - - - // Retrieves all segment objects that are rendered in the view - getEventSegs: function() { - return this.timeGrid.getEventSegs().concat( - this.dayGrid ? this.dayGrid.getEventSegs() : [] - ); - }, - - - // Unrenders all event elements and clears internal segment data - unrenderEvents: function() { - - // unrender the events in the subcomponents - this.timeGrid.unrenderEvents(); - if (this.dayGrid) { - this.dayGrid.unrenderEvents(); - } - - // we DON'T need to call updateHeight() because: - // A) a renderEvents() call always happens after this, which will eventually call updateHeight() - // B) in IE8, this causes a flash whenever events are rerendered - }, - - - /* Dragging (for events and external elements) - ------------------------------------------------------------------------------------------------------------------*/ - - - // A returned value of `true` signals that a mock "helper" event has been rendered. - renderDrag: function(dropLocation, seg) { - if (dropLocation.start.hasTime()) { - return this.timeGrid.renderDrag(dropLocation, seg); - } - else if (this.dayGrid) { - return this.dayGrid.renderDrag(dropLocation, seg); - } - }, - - - unrenderDrag: function() { - this.timeGrid.unrenderDrag(); - if (this.dayGrid) { - this.dayGrid.unrenderDrag(); - } - }, - - - /* Selection - ------------------------------------------------------------------------------------------------------------------*/ - - - // Renders a visual indication of a selection - renderSelection: function(range) { - if (range.start.hasTime() || range.end.hasTime()) { - this.timeGrid.renderSelection(range); - } - else if (this.dayGrid) { - this.dayGrid.renderSelection(range); - } - }, - - - // Unrenders a visual indications of a selection - unrenderSelection: function() { - this.timeGrid.unrenderSelection(); - if (this.dayGrid) { - this.dayGrid.unrenderSelection(); - } - } - -}); - -;; - -var AGENDA_ALL_DAY_EVENT_LIMIT = 5; - -fcViews.agenda = { - 'class': AgendaView, - defaults: { - allDaySlot: true, - allDayText: 'all-day', - slotDuration: '00:30:00', - minTime: '00:00:00', - maxTime: '24:00:00', - slotEventOverlap: true // a bad name. confused with overlap/constraint system - } -}; - -fcViews.agendaDay = { - type: 'agenda', - duration: { days: 1 } -}; - -fcViews.agendaWeek = { - type: 'agenda', - duration: { weeks: 1 } -}; -;; - -return fc; // export for Node/CommonJS -}); \ No newline at end of file diff --git a/src/UI/JsLibraries/handlebars.helpers.js b/src/UI/JsLibraries/handlebars.helpers.js deleted file mode 100644 index 56df9b642..000000000 --- a/src/UI/JsLibraries/handlebars.helpers.js +++ /dev/null @@ -1,145 +0,0 @@ -/* Handlebars Helpers - Dan Harper (http://github.com/danharper) */ - -/* This program is free software. It comes without any warranty, to - * the extent permitted by applicable law. You can redistribute it - * and/or modify it under the terms of the Do What The Fuck You Want - * To Public License, Version 2, as published by Sam Hocevar. See - * http://sam.zoy.org/wtfpl/COPYING for more details. */ - -/** - * Following lines make Handlebars helper function to work with all - * three such as Direct web, RequireJS AMD and Node JS. - * This concepts derived from UMD. - * @courtesy - https://github.com/umdjs/umd/blob/master/returnExports.js - */ - -(function (root, factory) { - if (typeof exports === 'object') { - // Node. Does not work with strict CommonJS, but - // only CommonJS-like enviroments that support module.exports, - // like Node. - module.exports = factory(require('handlebars')); - } else if (typeof define === 'function' && define.amd) { - // AMD. Register as an anonymous module. - define(['handlebars'], factory); - } else { - // Browser globals (root is window) - root.returnExports = factory(root.Handlebars); - } -}(this, function (Handlebars) { - - /** - * If Equals - * if_eq this compare=that - */ - Handlebars.registerHelper('if_eq', function(context, options) { - if (context == options.hash.compare) - return options.fn(this); - return options.inverse(this); - }); - - /** - * Unless Equals - * unless_eq this compare=that - */ - Handlebars.registerHelper('unless_eq', function(context, options) { - if (context == options.hash.compare) - return options.inverse(this); - return options.fn(this); - }); - - - /** - * If Greater Than - * if_gt this compare=that - */ - Handlebars.registerHelper('if_gt', function(context, options) { - if (context > options.hash.compare) - return options.fn(this); - return options.inverse(this); - }); - - /** - * Unless Greater Than - * unless_gt this compare=that - */ - Handlebars.registerHelper('unless_gt', function(context, options) { - if (context > options.hash.compare) - return options.inverse(this); - return options.fn(this); - }); - - - /** - * If Less Than - * if_lt this compare=that - */ - Handlebars.registerHelper('if_lt', function(context, options) { - if (context < options.hash.compare) - return options.fn(this); - return options.inverse(this); - }); - - /** - * Unless Less Than - * unless_lt this compare=that - */ - Handlebars.registerHelper('unless_lt', function(context, options) { - if (context < options.hash.compare) - return options.inverse(this); - return options.fn(this); - }); - - - /** - * If Greater Than or Equal To - * if_gteq this compare=that - */ - Handlebars.registerHelper('if_gteq', function(context, options) { - if (context >= options.hash.compare) - return options.fn(this); - return options.inverse(this); - }); - - /** - * Unless Greater Than or Equal To - * unless_gteq this compare=that - */ - Handlebars.registerHelper('unless_gteq', function(context, options) { - if (context >= options.hash.compare) - return options.inverse(this); - return options.fn(this); - }); - - - /** - * If Less Than or Equal To - * if_lteq this compare=that - */ - Handlebars.registerHelper('if_lteq', function(context, options) { - if (context <= options.hash.compare) - return options.fn(this); - return options.inverse(this); - }); - - /** - * Unless Less Than or Equal To - * unless_lteq this compare=that - */ - Handlebars.registerHelper('unless_lteq', function(context, options) { - if (context <= options.hash.compare) - return options.inverse(this); - return options.fn(this); - }); - - /** - * Convert new line (\n\r) to <br> - * from http://phpjs.org/functions/nl2br:480 - */ - Handlebars.registerHelper('nl2br', function(text) { - text = Handlebars.Utils.escapeExpression(text); - var nl2br = (text + '').replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, '$1' + '<br>' + '$2'); - return new Handlebars.SafeString(nl2br); - }); - -})); \ No newline at end of file diff --git a/src/UI/JsLibraries/handlebars.runtime.js b/src/UI/JsLibraries/handlebars.runtime.js deleted file mode 100644 index 94af5a379..000000000 --- a/src/UI/JsLibraries/handlebars.runtime.js +++ /dev/null @@ -1,660 +0,0 @@ -/*! - - handlebars v2.0.0 - - Copyright (C) 2011-2014 by Yehuda Katz - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in - all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - THE SOFTWARE. - - @license - */ -/* exported Handlebars */ -(function (root, factory) { - if (typeof define === 'function' && define.amd) { - define([], factory); - } else if (typeof exports === 'object') { - module.exports = factory(); - } else { - root.Handlebars = root.Handlebars || factory(); - } -}(this, function () { - // handlebars/safe-string.js - var __module3__ = (function() { - "use strict"; - var __exports__; - // Build out our basic SafeString type - function SafeString(string) { - this.string = string; - } - - SafeString.prototype.toString = function() { - return "" + this.string; - }; - - __exports__ = SafeString; - return __exports__; - })(); - - // handlebars/utils.js - var __module2__ = (function(__dependency1__) { - "use strict"; - var __exports__ = {}; - /*jshint -W004 */ - var SafeString = __dependency1__; - - var escape = { - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", - "`": "`" - }; - - var badChars = /[&<>"'`]/g; - var possible = /[&<>"'`]/; - - function escapeChar(chr) { - return escape[chr]; - } - - function extend(obj /* , ...source */) { - for (var i = 1; i < arguments.length; i++) { - for (var key in arguments[i]) { - if (Object.prototype.hasOwnProperty.call(arguments[i], key)) { - obj[key] = arguments[i][key]; - } - } - } - - return obj; - } - - __exports__.extend = extend;var toString = Object.prototype.toString; - __exports__.toString = toString; - // Sourced from lodash - // https://github.com/bestiejs/lodash/blob/master/LICENSE.txt - var isFunction = function(value) { - return typeof value === 'function'; - }; - // fallback for older versions of Chrome and Safari - /* istanbul ignore next */ - if (isFunction(/x/)) { - isFunction = function(value) { - return typeof value === 'function' && toString.call(value) === '[object Function]'; - }; - } - var isFunction; - __exports__.isFunction = isFunction; - /* istanbul ignore next */ - var isArray = Array.isArray || function(value) { - return (value && typeof value === 'object') ? toString.call(value) === '[object Array]' : false; - }; - __exports__.isArray = isArray; - - function escapeExpression(string) { - // don't escape SafeStrings, since they're already safe - if (string instanceof SafeString) { - return string.toString(); - } else if (string == null) { - return ""; - } else if (!string) { - return string + ''; - } - - // Force a string conversion as this will be done by the append regardless and - // the regex test will do this transparently behind the scenes, causing issues if - // an object's to string has escaped characters in it. - string = "" + string; - - if(!possible.test(string)) { return string; } - return string.replace(badChars, escapeChar); - } - - __exports__.escapeExpression = escapeExpression;function isEmpty(value) { - if (!value && value !== 0) { - return true; - } else if (isArray(value) && value.length === 0) { - return true; - } else { - return false; - } - } - - __exports__.isEmpty = isEmpty;function appendContextPath(contextPath, id) { - return (contextPath ? contextPath + '.' : '') + id; - } - - __exports__.appendContextPath = appendContextPath; - return __exports__; - })(__module3__); - - // handlebars/exception.js - var __module4__ = (function() { - "use strict"; - var __exports__; - - var errorProps = ['description', 'fileName', 'lineNumber', 'message', 'name', 'number', 'stack']; - - function Exception(message, node) { - var line; - if (node && node.firstLine) { - line = node.firstLine; - - message += ' - ' + line + ':' + node.firstColumn; - } - - var tmp = Error.prototype.constructor.call(this, message); - - // Unfortunately errors are not enumerable in Chrome (at least), so `for prop in tmp` doesn't work. - for (var idx = 0; idx < errorProps.length; idx++) { - this[errorProps[idx]] = tmp[errorProps[idx]]; - } - - if (line) { - this.lineNumber = line; - this.column = node.firstColumn; - } - } - - Exception.prototype = new Error(); - - __exports__ = Exception; - return __exports__; - })(); - - // handlebars/base.js - var __module1__ = (function(__dependency1__, __dependency2__) { - "use strict"; - var __exports__ = {}; - var Utils = __dependency1__; - var Exception = __dependency2__; - - var VERSION = "2.0.0"; - __exports__.VERSION = VERSION;var COMPILER_REVISION = 6; - __exports__.COMPILER_REVISION = COMPILER_REVISION; - var REVISION_CHANGES = { - 1: '<= 1.0.rc.2', // 1.0.rc.2 is actually rev2 but doesn't report it - 2: '== 1.0.0-rc.3', - 3: '== 1.0.0-rc.4', - 4: '== 1.x.x', - 5: '== 2.0.0-alpha.x', - 6: '>= 2.0.0-beta.1' - }; - __exports__.REVISION_CHANGES = REVISION_CHANGES; - var isArray = Utils.isArray, - isFunction = Utils.isFunction, - toString = Utils.toString, - objectType = '[object Object]'; - - function HandlebarsEnvironment(helpers, partials) { - this.helpers = helpers || {}; - this.partials = partials || {}; - - registerDefaultHelpers(this); - } - - __exports__.HandlebarsEnvironment = HandlebarsEnvironment;HandlebarsEnvironment.prototype = { - constructor: HandlebarsEnvironment, - - logger: logger, - log: log, - - registerHelper: function(name, fn) { - if (toString.call(name) === objectType) { - if (fn) { throw new Exception('Arg not supported with multiple helpers'); } - Utils.extend(this.helpers, name); - } else { - this.helpers[name] = fn; - } - }, - unregisterHelper: function(name) { - delete this.helpers[name]; - }, - - registerPartial: function(name, partial) { - if (toString.call(name) === objectType) { - Utils.extend(this.partials, name); - } else { - this.partials[name] = partial; - } - }, - unregisterPartial: function(name) { - delete this.partials[name]; - } - }; - - function registerDefaultHelpers(instance) { - instance.registerHelper('helperMissing', function(/* [args, ]options */) { - if(arguments.length === 1) { - // A missing field in a {{foo}} constuct. - return undefined; - } else { - // Someone is actually trying to call something, blow up. - throw new Exception("Missing helper: '" + arguments[arguments.length-1].name + "'"); - } - }); - - instance.registerHelper('blockHelperMissing', function(context, options) { - var inverse = options.inverse, - fn = options.fn; - - if(context === true) { - return fn(this); - } else if(context === false || context == null) { - return inverse(this); - } else if (isArray(context)) { - if(context.length > 0) { - if (options.ids) { - options.ids = [options.name]; - } - - return instance.helpers.each(context, options); - } else { - return inverse(this); - } - } else { - if (options.data && options.ids) { - var data = createFrame(options.data); - data.contextPath = Utils.appendContextPath(options.data.contextPath, options.name); - options = {data: data}; - } - - return fn(context, options); - } - }); - - instance.registerHelper('each', function(context, options) { - if (!options) { - throw new Exception('Must pass iterator to #each'); - } - - var fn = options.fn, inverse = options.inverse; - var i = 0, ret = "", data; - - var contextPath; - if (options.data && options.ids) { - contextPath = Utils.appendContextPath(options.data.contextPath, options.ids[0]) + '.'; - } - - if (isFunction(context)) { context = context.call(this); } - - if (options.data) { - data = createFrame(options.data); - } - - if(context && typeof context === 'object') { - if (isArray(context)) { - for(var j = context.length; i<j; i++) { - if (data) { - data.index = i; - data.first = (i === 0); - data.last = (i === (context.length-1)); - - if (contextPath) { - data.contextPath = contextPath + i; - } - } - ret = ret + fn(context[i], { data: data }); - } - } else { - for(var key in context) { - if(context.hasOwnProperty(key)) { - if(data) { - data.key = key; - data.index = i; - data.first = (i === 0); - - if (contextPath) { - data.contextPath = contextPath + key; - } - } - ret = ret + fn(context[key], {data: data}); - i++; - } - } - } - } - - if(i === 0){ - ret = inverse(this); - } - - return ret; - }); - - instance.registerHelper('if', function(conditional, options) { - if (isFunction(conditional)) { conditional = conditional.call(this); } - - // Default behavior is to render the positive path if the value is truthy and not empty. - // The `includeZero` option may be set to treat the condtional as purely not empty based on the - // behavior of isEmpty. Effectively this determines if 0 is handled by the positive path or negative. - if ((!options.hash.includeZero && !conditional) || Utils.isEmpty(conditional)) { - return options.inverse(this); - } else { - return options.fn(this); - } - }); - - instance.registerHelper('unless', function(conditional, options) { - return instance.helpers['if'].call(this, conditional, {fn: options.inverse, inverse: options.fn, hash: options.hash}); - }); - - instance.registerHelper('with', function(context, options) { - if (isFunction(context)) { context = context.call(this); } - - var fn = options.fn; - - if (!Utils.isEmpty(context)) { - if (options.data && options.ids) { - var data = createFrame(options.data); - data.contextPath = Utils.appendContextPath(options.data.contextPath, options.ids[0]); - options = {data:data}; - } - - return fn(context, options); - } else { - return options.inverse(this); - } - }); - - instance.registerHelper('log', function(message, options) { - var level = options.data && options.data.level != null ? parseInt(options.data.level, 10) : 1; - instance.log(level, message); - }); - - instance.registerHelper('lookup', function(obj, field) { - return obj && obj[field]; - }); - } - - var logger = { - methodMap: { 0: 'debug', 1: 'info', 2: 'warn', 3: 'error' }, - - // State enum - DEBUG: 0, - INFO: 1, - WARN: 2, - ERROR: 3, - level: 3, - - // can be overridden in the host environment - log: function(level, message) { - if (logger.level <= level) { - var method = logger.methodMap[level]; - if (typeof console !== 'undefined' && console[method]) { - console[method].call(console, message); - } - } - } - }; - __exports__.logger = logger; - var log = logger.log; - __exports__.log = log; - var createFrame = function(object) { - var frame = Utils.extend({}, object); - frame._parent = object; - return frame; - }; - __exports__.createFrame = createFrame; - return __exports__; - })(__module2__, __module4__); - - // handlebars/runtime.js - var __module5__ = (function(__dependency1__, __dependency2__, __dependency3__) { - "use strict"; - var __exports__ = {}; - var Utils = __dependency1__; - var Exception = __dependency2__; - var COMPILER_REVISION = __dependency3__.COMPILER_REVISION; - var REVISION_CHANGES = __dependency3__.REVISION_CHANGES; - var createFrame = __dependency3__.createFrame; - - function checkRevision(compilerInfo) { - var compilerRevision = compilerInfo && compilerInfo[0] || 1, - currentRevision = COMPILER_REVISION; - - if (compilerRevision !== currentRevision) { - if (compilerRevision < currentRevision) { - var runtimeVersions = REVISION_CHANGES[currentRevision], - compilerVersions = REVISION_CHANGES[compilerRevision]; - throw new Exception("Template was precompiled with an older version of Handlebars than the current runtime. "+ - "Please update your precompiler to a newer version ("+runtimeVersions+") or downgrade your runtime to an older version ("+compilerVersions+")."); - } else { - // Use the embedded version info since the runtime doesn't know about this revision yet - throw new Exception("Template was precompiled with a newer version of Handlebars than the current runtime. "+ - "Please update your runtime to a newer version ("+compilerInfo[1]+")."); - } - } - } - - __exports__.checkRevision = checkRevision;// TODO: Remove this line and break up compilePartial - - function template(templateSpec, env) { - /* istanbul ignore next */ - if (!env) { - throw new Exception("No environment passed to template"); - } - if (!templateSpec || !templateSpec.main) { - throw new Exception('Unknown template object: ' + typeof templateSpec); - } - - // Note: Using env.VM references rather than local var references throughout this section to allow - // for external users to override these as psuedo-supported APIs. - env.VM.checkRevision(templateSpec.compiler); - - var invokePartialWrapper = function(partial, indent, name, context, hash, helpers, partials, data, depths) { - if (hash) { - context = Utils.extend({}, context, hash); - } - - var result = env.VM.invokePartial.call(this, partial, name, context, helpers, partials, data, depths); - - if (result == null && env.compile) { - var options = { helpers: helpers, partials: partials, data: data, depths: depths }; - partials[name] = env.compile(partial, { data: data !== undefined, compat: templateSpec.compat }, env); - result = partials[name](context, options); - } - if (result != null) { - if (indent) { - var lines = result.split('\n'); - for (var i = 0, l = lines.length; i < l; i++) { - if (!lines[i] && i + 1 === l) { - break; - } - - lines[i] = indent + lines[i]; - } - result = lines.join('\n'); - } - return result; - } else { - throw new Exception("The partial " + name + " could not be compiled when running in runtime-only mode"); - } - }; - - // Just add water - var container = { - lookup: function(depths, name) { - var len = depths.length; - for (var i = 0; i < len; i++) { - if (depths[i] && depths[i][name] != null) { - return depths[i][name]; - } - } - }, - lambda: function(current, context) { - return typeof current === 'function' ? current.call(context) : current; - }, - - escapeExpression: Utils.escapeExpression, - invokePartial: invokePartialWrapper, - - fn: function(i) { - return templateSpec[i]; - }, - - programs: [], - program: function(i, data, depths) { - var programWrapper = this.programs[i], - fn = this.fn(i); - if (data || depths) { - programWrapper = program(this, i, fn, data, depths); - } else if (!programWrapper) { - programWrapper = this.programs[i] = program(this, i, fn); - } - return programWrapper; - }, - - data: function(data, depth) { - while (data && depth--) { - data = data._parent; - } - return data; - }, - merge: function(param, common) { - var ret = param || common; - - if (param && common && (param !== common)) { - ret = Utils.extend({}, common, param); - } - - return ret; - }, - - noop: env.VM.noop, - compilerInfo: templateSpec.compiler - }; - - var ret = function(context, options) { - options = options || {}; - var data = options.data; - - ret._setup(options); - if (!options.partial && templateSpec.useData) { - data = initData(context, data); - } - var depths; - if (templateSpec.useDepths) { - depths = options.depths ? [context].concat(options.depths) : [context]; - } - - return templateSpec.main.call(container, context, container.helpers, container.partials, data, depths); - }; - ret.isTop = true; - - ret._setup = function(options) { - if (!options.partial) { - container.helpers = container.merge(options.helpers, env.helpers); - - if (templateSpec.usePartial) { - container.partials = container.merge(options.partials, env.partials); - } - } else { - container.helpers = options.helpers; - container.partials = options.partials; - } - }; - - ret._child = function(i, data, depths) { - if (templateSpec.useDepths && !depths) { - throw new Exception('must pass parent depths'); - } - - return program(container, i, templateSpec[i], data, depths); - }; - return ret; - } - - __exports__.template = template;function program(container, i, fn, data, depths) { - var prog = function(context, options) { - options = options || {}; - - return fn.call(container, context, container.helpers, container.partials, options.data || data, depths && [context].concat(depths)); - }; - prog.program = i; - prog.depth = depths ? depths.length : 0; - return prog; - } - - __exports__.program = program;function invokePartial(partial, name, context, helpers, partials, data, depths) { - var options = { partial: true, helpers: helpers, partials: partials, data: data, depths: depths }; - - if(partial === undefined) { - throw new Exception("The partial " + name + " could not be found"); - } else if(partial instanceof Function) { - return partial(context, options); - } - } - - __exports__.invokePartial = invokePartial;function noop() { return ""; } - - __exports__.noop = noop;function initData(context, data) { - if (!data || !('root' in data)) { - data = data ? createFrame(data) : {}; - data.root = context; - } - return data; - } - return __exports__; - })(__module2__, __module4__, __module1__); - - // handlebars.runtime.js - var __module0__ = (function(__dependency1__, __dependency2__, __dependency3__, __dependency4__, __dependency5__) { - "use strict"; - var __exports__; - /*globals Handlebars: true */ - var base = __dependency1__; - - // Each of these augment the Handlebars object. No need to setup here. - // (This is done to easily share code between commonjs and browse envs) - var SafeString = __dependency2__; - var Exception = __dependency3__; - var Utils = __dependency4__; - var runtime = __dependency5__; - - // For compatibility and usage outside of module systems, make the Handlebars object a namespace - var create = function() { - var hb = new base.HandlebarsEnvironment(); - - Utils.extend(hb, base); - hb.SafeString = SafeString; - hb.Exception = Exception; - hb.Utils = Utils; - hb.escapeExpression = Utils.escapeExpression; - - hb.VM = runtime; - hb.template = function(spec) { - return runtime.template(spec, hb); - }; - - return hb; - }; - - var Handlebars = create(); - Handlebars.create = create; - - Handlebars['default'] = Handlebars; - - __exports__ = Handlebars; - return __exports__; - })(__module1__, __module3__, __module4__, __module2__, __module5__); - - return __module0__; -})); diff --git a/src/UI/JsLibraries/jquery-ui.js b/src/UI/JsLibraries/jquery-ui.js deleted file mode 100644 index fe44a9c84..000000000 --- a/src/UI/JsLibraries/jquery-ui.js +++ /dev/null @@ -1,4233 +0,0 @@ -/*! jQuery UI - v1.10.4 - 2014-01-22 -* http://jqueryui.com -* Includes: jquery.ui.core.js, jquery.ui.widget.js, jquery.ui.mouse.js, jquery.ui.draggable.js, jquery.ui.droppable.js, jquery.ui.sortable.js, jquery.ui.slider.js -* Copyright 2014 jQuery Foundation and other contributors; Licensed MIT */ - -(function( $, undefined ) { - -var uuid = 0, - runiqueId = /^ui-id-\d+$/; - -// $.ui might exist from components with no dependencies, e.g., $.ui.position -$.ui = $.ui || {}; - -$.extend( $.ui, { - version: "1.10.4", - - keyCode: { - BACKSPACE: 8, - COMMA: 188, - DELETE: 46, - DOWN: 40, - END: 35, - ENTER: 13, - ESCAPE: 27, - HOME: 36, - LEFT: 37, - NUMPAD_ADD: 107, - NUMPAD_DECIMAL: 110, - NUMPAD_DIVIDE: 111, - NUMPAD_ENTER: 108, - NUMPAD_MULTIPLY: 106, - NUMPAD_SUBTRACT: 109, - PAGE_DOWN: 34, - PAGE_UP: 33, - PERIOD: 190, - RIGHT: 39, - SPACE: 32, - TAB: 9, - UP: 38 - } -}); - -// plugins -$.fn.extend({ - focus: (function( orig ) { - return function( delay, fn ) { - return typeof delay === "number" ? - this.each(function() { - var elem = this; - setTimeout(function() { - $( elem ).focus(); - if ( fn ) { - fn.call( elem ); - } - }, delay ); - }) : - orig.apply( this, arguments ); - }; - })( $.fn.focus ), - - scrollParent: function() { - var scrollParent; - if (($.ui.ie && (/(static|relative)/).test(this.css("position"))) || (/absolute/).test(this.css("position"))) { - scrollParent = this.parents().filter(function() { - return (/(relative|absolute|fixed)/).test($.css(this,"position")) && (/(auto|scroll)/).test($.css(this,"overflow")+$.css(this,"overflow-y")+$.css(this,"overflow-x")); - }).eq(0); - } else { - scrollParent = this.parents().filter(function() { - return (/(auto|scroll)/).test($.css(this,"overflow")+$.css(this,"overflow-y")+$.css(this,"overflow-x")); - }).eq(0); - } - - return (/fixed/).test(this.css("position")) || !scrollParent.length ? $(document) : scrollParent; - }, - - zIndex: function( zIndex ) { - if ( zIndex !== undefined ) { - return this.css( "zIndex", zIndex ); - } - - if ( this.length ) { - var elem = $( this[ 0 ] ), position, value; - while ( elem.length && elem[ 0 ] !== document ) { - // Ignore z-index if position is set to a value where z-index is ignored by the browser - // This makes behavior of this function consistent across browsers - // WebKit always returns auto if the element is positioned - position = elem.css( "position" ); - if ( position === "absolute" || position === "relative" || position === "fixed" ) { - // IE returns 0 when zIndex is not specified - // other browsers return a string - // we ignore the case of nested elements with an explicit value of 0 - // <div style="z-index: -10;"><div style="z-index: 0;"></div></div> - value = parseInt( elem.css( "zIndex" ), 10 ); - if ( !isNaN( value ) && value !== 0 ) { - return value; - } - } - elem = elem.parent(); - } - } - - return 0; - }, - - uniqueId: function() { - return this.each(function() { - if ( !this.id ) { - this.id = "ui-id-" + (++uuid); - } - }); - }, - - removeUniqueId: function() { - return this.each(function() { - if ( runiqueId.test( this.id ) ) { - $( this ).removeAttr( "id" ); - } - }); - } -}); - -// selectors -function focusable( element, isTabIndexNotNaN ) { - var map, mapName, img, - nodeName = element.nodeName.toLowerCase(); - if ( "area" === nodeName ) { - map = element.parentNode; - mapName = map.name; - if ( !element.href || !mapName || map.nodeName.toLowerCase() !== "map" ) { - return false; - } - img = $( "img[usemap=#" + mapName + "]" )[0]; - return !!img && visible( img ); - } - return ( /input|select|textarea|button|object/.test( nodeName ) ? - !element.disabled : - "a" === nodeName ? - element.href || isTabIndexNotNaN : - isTabIndexNotNaN) && - // the element and all of its ancestors must be visible - visible( element ); -} - -function visible( element ) { - return $.expr.filters.visible( element ) && - !$( element ).parents().addBack().filter(function() { - return $.css( this, "visibility" ) === "hidden"; - }).length; -} - -$.extend( $.expr[ ":" ], { - data: $.expr.createPseudo ? - $.expr.createPseudo(function( dataName ) { - return function( elem ) { - return !!$.data( elem, dataName ); - }; - }) : - // support: jQuery <1.8 - function( elem, i, match ) { - return !!$.data( elem, match[ 3 ] ); - }, - - focusable: function( element ) { - return focusable( element, !isNaN( $.attr( element, "tabindex" ) ) ); - }, - - tabbable: function( element ) { - var tabIndex = $.attr( element, "tabindex" ), - isTabIndexNaN = isNaN( tabIndex ); - return ( isTabIndexNaN || tabIndex >= 0 ) && focusable( element, !isTabIndexNaN ); - } -}); - -// support: jQuery <1.8 -if ( !$( "<a>" ).outerWidth( 1 ).jquery ) { - $.each( [ "Width", "Height" ], function( i, name ) { - var side = name === "Width" ? [ "Left", "Right" ] : [ "Top", "Bottom" ], - type = name.toLowerCase(), - orig = { - innerWidth: $.fn.innerWidth, - innerHeight: $.fn.innerHeight, - outerWidth: $.fn.outerWidth, - outerHeight: $.fn.outerHeight - }; - - function reduce( elem, size, border, margin ) { - $.each( side, function() { - size -= parseFloat( $.css( elem, "padding" + this ) ) || 0; - if ( border ) { - size -= parseFloat( $.css( elem, "border" + this + "Width" ) ) || 0; - } - if ( margin ) { - size -= parseFloat( $.css( elem, "margin" + this ) ) || 0; - } - }); - return size; - } - - $.fn[ "inner" + name ] = function( size ) { - if ( size === undefined ) { - return orig[ "inner" + name ].call( this ); - } - - return this.each(function() { - $( this ).css( type, reduce( this, size ) + "px" ); - }); - }; - - $.fn[ "outer" + name] = function( size, margin ) { - if ( typeof size !== "number" ) { - return orig[ "outer" + name ].call( this, size ); - } - - return this.each(function() { - $( this).css( type, reduce( this, size, true, margin ) + "px" ); - }); - }; - }); -} - -// support: jQuery <1.8 -if ( !$.fn.addBack ) { - $.fn.addBack = function( selector ) { - return this.add( selector == null ? - this.prevObject : this.prevObject.filter( selector ) - ); - }; -} - -// support: jQuery 1.6.1, 1.6.2 (http://bugs.jquery.com/ticket/9413) -if ( $( "<a>" ).data( "a-b", "a" ).removeData( "a-b" ).data( "a-b" ) ) { - $.fn.removeData = (function( removeData ) { - return function( key ) { - if ( arguments.length ) { - return removeData.call( this, $.camelCase( key ) ); - } else { - return removeData.call( this ); - } - }; - })( $.fn.removeData ); -} - - - - - -// deprecated -$.ui.ie = !!/msie [\w.]+/.exec( navigator.userAgent.toLowerCase() ); - -$.support.selectstart = "onselectstart" in document.createElement( "div" ); -$.fn.extend({ - disableSelection: function() { - return this.bind( ( $.support.selectstart ? "selectstart" : "mousedown" ) + - ".ui-disableSelection", function( event ) { - event.preventDefault(); - }); - }, - - enableSelection: function() { - return this.unbind( ".ui-disableSelection" ); - } -}); - -$.extend( $.ui, { - // $.ui.plugin is deprecated. Use $.widget() extensions instead. - plugin: { - add: function( module, option, set ) { - var i, - proto = $.ui[ module ].prototype; - for ( i in set ) { - proto.plugins[ i ] = proto.plugins[ i ] || []; - proto.plugins[ i ].push( [ option, set[ i ] ] ); - } - }, - call: function( instance, name, args ) { - var i, - set = instance.plugins[ name ]; - if ( !set || !instance.element[ 0 ].parentNode || instance.element[ 0 ].parentNode.nodeType === 11 ) { - return; - } - - for ( i = 0; i < set.length; i++ ) { - if ( instance.options[ set[ i ][ 0 ] ] ) { - set[ i ][ 1 ].apply( instance.element, args ); - } - } - } - }, - - // only used by resizable - hasScroll: function( el, a ) { - - //If overflow is hidden, the element might have extra content, but the user wants to hide it - if ( $( el ).css( "overflow" ) === "hidden") { - return false; - } - - var scroll = ( a && a === "left" ) ? "scrollLeft" : "scrollTop", - has = false; - - if ( el[ scroll ] > 0 ) { - return true; - } - - // TODO: determine which cases actually cause this to happen - // if the element doesn't have the scroll set, see if it's possible to - // set the scroll - el[ scroll ] = 1; - has = ( el[ scroll ] > 0 ); - el[ scroll ] = 0; - return has; - } -}); - -})( jQuery ); -(function( $, undefined ) { - -var uuid = 0, - slice = Array.prototype.slice, - _cleanData = $.cleanData; -$.cleanData = function( elems ) { - for ( var i = 0, elem; (elem = elems[i]) != null; i++ ) { - try { - $( elem ).triggerHandler( "remove" ); - // http://bugs.jquery.com/ticket/8235 - } catch( e ) {} - } - _cleanData( elems ); -}; - -$.widget = function( name, base, prototype ) { - var fullName, existingConstructor, constructor, basePrototype, - // proxiedPrototype allows the provided prototype to remain unmodified - // so that it can be used as a mixin for multiple widgets (#8876) - proxiedPrototype = {}, - namespace = name.split( "." )[ 0 ]; - - name = name.split( "." )[ 1 ]; - fullName = namespace + "-" + name; - - if ( !prototype ) { - prototype = base; - base = $.Widget; - } - - // create selector for plugin - $.expr[ ":" ][ fullName.toLowerCase() ] = function( elem ) { - return !!$.data( elem, fullName ); - }; - - $[ namespace ] = $[ namespace ] || {}; - existingConstructor = $[ namespace ][ name ]; - constructor = $[ namespace ][ name ] = function( options, element ) { - // allow instantiation without "new" keyword - if ( !this._createWidget ) { - return new constructor( options, element ); - } - - // allow instantiation without initializing for simple inheritance - // must use "new" keyword (the code above always passes args) - if ( arguments.length ) { - this._createWidget( options, element ); - } - }; - // extend with the existing constructor to carry over any static properties - $.extend( constructor, existingConstructor, { - version: prototype.version, - // copy the object used to create the prototype in case we need to - // redefine the widget later - _proto: $.extend( {}, prototype ), - // track widgets that inherit from this widget in case this widget is - // redefined after a widget inherits from it - _childConstructors: [] - }); - - basePrototype = new base(); - // we need to make the options hash a property directly on the new instance - // otherwise we'll modify the options hash on the prototype that we're - // inheriting from - basePrototype.options = $.widget.extend( {}, basePrototype.options ); - $.each( prototype, function( prop, value ) { - if ( !$.isFunction( value ) ) { - proxiedPrototype[ prop ] = value; - return; - } - proxiedPrototype[ prop ] = (function() { - var _super = function() { - return base.prototype[ prop ].apply( this, arguments ); - }, - _superApply = function( args ) { - return base.prototype[ prop ].apply( this, args ); - }; - return function() { - var __super = this._super, - __superApply = this._superApply, - returnValue; - - this._super = _super; - this._superApply = _superApply; - - returnValue = value.apply( this, arguments ); - - this._super = __super; - this._superApply = __superApply; - - return returnValue; - }; - })(); - }); - constructor.prototype = $.widget.extend( basePrototype, { - // TODO: remove support for widgetEventPrefix - // always use the name + a colon as the prefix, e.g., draggable:start - // don't prefix for widgets that aren't DOM-based - widgetEventPrefix: existingConstructor ? (basePrototype.widgetEventPrefix || name) : name - }, proxiedPrototype, { - constructor: constructor, - namespace: namespace, - widgetName: name, - widgetFullName: fullName - }); - - // If this widget is being redefined then we need to find all widgets that - // are inheriting from it and redefine all of them so that they inherit from - // the new version of this widget. We're essentially trying to replace one - // level in the prototype chain. - if ( existingConstructor ) { - $.each( existingConstructor._childConstructors, function( i, child ) { - var childPrototype = child.prototype; - - // redefine the child widget using the same prototype that was - // originally used, but inherit from the new version of the base - $.widget( childPrototype.namespace + "." + childPrototype.widgetName, constructor, child._proto ); - }); - // remove the list of existing child constructors from the old constructor - // so the old child constructors can be garbage collected - delete existingConstructor._childConstructors; - } else { - base._childConstructors.push( constructor ); - } - - $.widget.bridge( name, constructor ); -}; - -$.widget.extend = function( target ) { - var input = slice.call( arguments, 1 ), - inputIndex = 0, - inputLength = input.length, - key, - value; - for ( ; inputIndex < inputLength; inputIndex++ ) { - for ( key in input[ inputIndex ] ) { - value = input[ inputIndex ][ key ]; - if ( input[ inputIndex ].hasOwnProperty( key ) && value !== undefined ) { - // Clone objects - if ( $.isPlainObject( value ) ) { - target[ key ] = $.isPlainObject( target[ key ] ) ? - $.widget.extend( {}, target[ key ], value ) : - // Don't extend strings, arrays, etc. with objects - $.widget.extend( {}, value ); - // Copy everything else by reference - } else { - target[ key ] = value; - } - } - } - } - return target; -}; - -$.widget.bridge = function( name, object ) { - var fullName = object.prototype.widgetFullName || name; - $.fn[ name ] = function( options ) { - var isMethodCall = typeof options === "string", - args = slice.call( arguments, 1 ), - returnValue = this; - - // allow multiple hashes to be passed on init - options = !isMethodCall && args.length ? - $.widget.extend.apply( null, [ options ].concat(args) ) : - options; - - if ( isMethodCall ) { - this.each(function() { - var methodValue, - instance = $.data( this, fullName ); - if ( !instance ) { - return $.error( "cannot call methods on " + name + " prior to initialization; " + - "attempted to call method '" + options + "'" ); - } - if ( !$.isFunction( instance[options] ) || options.charAt( 0 ) === "_" ) { - return $.error( "no such method '" + options + "' for " + name + " widget instance" ); - } - methodValue = instance[ options ].apply( instance, args ); - if ( methodValue !== instance && methodValue !== undefined ) { - returnValue = methodValue && methodValue.jquery ? - returnValue.pushStack( methodValue.get() ) : - methodValue; - return false; - } - }); - } else { - this.each(function() { - var instance = $.data( this, fullName ); - if ( instance ) { - instance.option( options || {} )._init(); - } else { - $.data( this, fullName, new object( options, this ) ); - } - }); - } - - return returnValue; - }; -}; - -$.Widget = function( /* options, element */ ) {}; -$.Widget._childConstructors = []; - -$.Widget.prototype = { - widgetName: "widget", - widgetEventPrefix: "", - defaultElement: "<div>", - options: { - disabled: false, - - // callbacks - create: null - }, - _createWidget: function( options, element ) { - element = $( element || this.defaultElement || this )[ 0 ]; - this.element = $( element ); - this.uuid = uuid++; - this.eventNamespace = "." + this.widgetName + this.uuid; - this.options = $.widget.extend( {}, - this.options, - this._getCreateOptions(), - options ); - - this.bindings = $(); - this.hoverable = $(); - this.focusable = $(); - - if ( element !== this ) { - $.data( element, this.widgetFullName, this ); - this._on( true, this.element, { - remove: function( event ) { - if ( event.target === element ) { - this.destroy(); - } - } - }); - this.document = $( element.style ? - // element within the document - element.ownerDocument : - // element is window or document - element.document || element ); - this.window = $( this.document[0].defaultView || this.document[0].parentWindow ); - } - - this._create(); - this._trigger( "create", null, this._getCreateEventData() ); - this._init(); - }, - _getCreateOptions: $.noop, - _getCreateEventData: $.noop, - _create: $.noop, - _init: $.noop, - - destroy: function() { - this._destroy(); - // we can probably remove the unbind calls in 2.0 - // all event bindings should go through this._on() - this.element - .unbind( this.eventNamespace ) - // 1.9 BC for #7810 - // TODO remove dual storage - .removeData( this.widgetName ) - .removeData( this.widgetFullName ) - // support: jquery <1.6.3 - // http://bugs.jquery.com/ticket/9413 - .removeData( $.camelCase( this.widgetFullName ) ); - this.widget() - .unbind( this.eventNamespace ) - .removeAttr( "aria-disabled" ) - .removeClass( - this.widgetFullName + "-disabled " + - "ui-state-disabled" ); - - // clean up events and states - this.bindings.unbind( this.eventNamespace ); - this.hoverable.removeClass( "ui-state-hover" ); - this.focusable.removeClass( "ui-state-focus" ); - }, - _destroy: $.noop, - - widget: function() { - return this.element; - }, - - option: function( key, value ) { - var options = key, - parts, - curOption, - i; - - if ( arguments.length === 0 ) { - // don't return a reference to the internal hash - return $.widget.extend( {}, this.options ); - } - - if ( typeof key === "string" ) { - // handle nested keys, e.g., "foo.bar" => { foo: { bar: ___ } } - options = {}; - parts = key.split( "." ); - key = parts.shift(); - if ( parts.length ) { - curOption = options[ key ] = $.widget.extend( {}, this.options[ key ] ); - for ( i = 0; i < parts.length - 1; i++ ) { - curOption[ parts[ i ] ] = curOption[ parts[ i ] ] || {}; - curOption = curOption[ parts[ i ] ]; - } - key = parts.pop(); - if ( arguments.length === 1 ) { - return curOption[ key ] === undefined ? null : curOption[ key ]; - } - curOption[ key ] = value; - } else { - if ( arguments.length === 1 ) { - return this.options[ key ] === undefined ? null : this.options[ key ]; - } - options[ key ] = value; - } - } - - this._setOptions( options ); - - return this; - }, - _setOptions: function( options ) { - var key; - - for ( key in options ) { - this._setOption( key, options[ key ] ); - } - - return this; - }, - _setOption: function( key, value ) { - this.options[ key ] = value; - - if ( key === "disabled" ) { - this.widget() - .toggleClass( this.widgetFullName + "-disabled ui-state-disabled", !!value ) - .attr( "aria-disabled", value ); - this.hoverable.removeClass( "ui-state-hover" ); - this.focusable.removeClass( "ui-state-focus" ); - } - - return this; - }, - - enable: function() { - return this._setOption( "disabled", false ); - }, - disable: function() { - return this._setOption( "disabled", true ); - }, - - _on: function( suppressDisabledCheck, element, handlers ) { - var delegateElement, - instance = this; - - // no suppressDisabledCheck flag, shuffle arguments - if ( typeof suppressDisabledCheck !== "boolean" ) { - handlers = element; - element = suppressDisabledCheck; - suppressDisabledCheck = false; - } - - // no element argument, shuffle and use this.element - if ( !handlers ) { - handlers = element; - element = this.element; - delegateElement = this.widget(); - } else { - // accept selectors, DOM elements - element = delegateElement = $( element ); - this.bindings = this.bindings.add( element ); - } - - $.each( handlers, function( event, handler ) { - function handlerProxy() { - // allow widgets to customize the disabled handling - // - disabled as an array instead of boolean - // - disabled class as method for disabling individual parts - if ( !suppressDisabledCheck && - ( instance.options.disabled === true || - $( this ).hasClass( "ui-state-disabled" ) ) ) { - return; - } - return ( typeof handler === "string" ? instance[ handler ] : handler ) - .apply( instance, arguments ); - } - - // copy the guid so direct unbinding works - if ( typeof handler !== "string" ) { - handlerProxy.guid = handler.guid = - handler.guid || handlerProxy.guid || $.guid++; - } - - var match = event.match( /^(\w+)\s*(.*)$/ ), - eventName = match[1] + instance.eventNamespace, - selector = match[2]; - if ( selector ) { - delegateElement.delegate( selector, eventName, handlerProxy ); - } else { - element.bind( eventName, handlerProxy ); - } - }); - }, - - _off: function( element, eventName ) { - eventName = (eventName || "").split( " " ).join( this.eventNamespace + " " ) + this.eventNamespace; - element.unbind( eventName ).undelegate( eventName ); - }, - - _delay: function( handler, delay ) { - function handlerProxy() { - return ( typeof handler === "string" ? instance[ handler ] : handler ) - .apply( instance, arguments ); - } - var instance = this; - return setTimeout( handlerProxy, delay || 0 ); - }, - - _hoverable: function( element ) { - this.hoverable = this.hoverable.add( element ); - this._on( element, { - mouseenter: function( event ) { - $( event.currentTarget ).addClass( "ui-state-hover" ); - }, - mouseleave: function( event ) { - $( event.currentTarget ).removeClass( "ui-state-hover" ); - } - }); - }, - - _focusable: function( element ) { - this.focusable = this.focusable.add( element ); - this._on( element, { - focusin: function( event ) { - $( event.currentTarget ).addClass( "ui-state-focus" ); - }, - focusout: function( event ) { - $( event.currentTarget ).removeClass( "ui-state-focus" ); - } - }); - }, - - _trigger: function( type, event, data ) { - var prop, orig, - callback = this.options[ type ]; - - data = data || {}; - event = $.Event( event ); - event.type = ( type === this.widgetEventPrefix ? - type : - this.widgetEventPrefix + type ).toLowerCase(); - // the original event may come from any element - // so we need to reset the target on the new event - event.target = this.element[ 0 ]; - - // copy original event properties over to the new event - orig = event.originalEvent; - if ( orig ) { - for ( prop in orig ) { - if ( !( prop in event ) ) { - event[ prop ] = orig[ prop ]; - } - } - } - - this.element.trigger( event, data ); - return !( $.isFunction( callback ) && - callback.apply( this.element[0], [ event ].concat( data ) ) === false || - event.isDefaultPrevented() ); - } -}; - -$.each( { show: "fadeIn", hide: "fadeOut" }, function( method, defaultEffect ) { - $.Widget.prototype[ "_" + method ] = function( element, options, callback ) { - if ( typeof options === "string" ) { - options = { effect: options }; - } - var hasOptions, - effectName = !options ? - method : - options === true || typeof options === "number" ? - defaultEffect : - options.effect || defaultEffect; - options = options || {}; - if ( typeof options === "number" ) { - options = { duration: options }; - } - hasOptions = !$.isEmptyObject( options ); - options.complete = callback; - if ( options.delay ) { - element.delay( options.delay ); - } - if ( hasOptions && $.effects && $.effects.effect[ effectName ] ) { - element[ method ]( options ); - } else if ( effectName !== method && element[ effectName ] ) { - element[ effectName ]( options.duration, options.easing, callback ); - } else { - element.queue(function( next ) { - $( this )[ method ](); - if ( callback ) { - callback.call( element[ 0 ] ); - } - next(); - }); - } - }; -}); - -})( jQuery ); -(function( $, undefined ) { - -var mouseHandled = false; -$( document ).mouseup( function() { - mouseHandled = false; -}); - -$.widget("ui.mouse", { - version: "1.10.4", - options: { - cancel: "input,textarea,button,select,option", - distance: 1, - delay: 0 - }, - _mouseInit: function() { - var that = this; - - this.element - .bind("mousedown."+this.widgetName, function(event) { - return that._mouseDown(event); - }) - .bind("click."+this.widgetName, function(event) { - if (true === $.data(event.target, that.widgetName + ".preventClickEvent")) { - $.removeData(event.target, that.widgetName + ".preventClickEvent"); - event.stopImmediatePropagation(); - return false; - } - }); - - this.started = false; - }, - - // TODO: make sure destroying one instance of mouse doesn't mess with - // other instances of mouse - _mouseDestroy: function() { - this.element.unbind("."+this.widgetName); - if ( this._mouseMoveDelegate ) { - $(document) - .unbind("mousemove."+this.widgetName, this._mouseMoveDelegate) - .unbind("mouseup."+this.widgetName, this._mouseUpDelegate); - } - }, - - _mouseDown: function(event) { - // don't let more than one widget handle mouseStart - if( mouseHandled ) { return; } - - // we may have missed mouseup (out of window) - (this._mouseStarted && this._mouseUp(event)); - - this._mouseDownEvent = event; - - var that = this, - btnIsLeft = (event.which === 1), - // event.target.nodeName works around a bug in IE 8 with - // disabled inputs (#7620) - elIsCancel = (typeof this.options.cancel === "string" && event.target.nodeName ? $(event.target).closest(this.options.cancel).length : false); - if (!btnIsLeft || elIsCancel || !this._mouseCapture(event)) { - return true; - } - - this.mouseDelayMet = !this.options.delay; - if (!this.mouseDelayMet) { - this._mouseDelayTimer = setTimeout(function() { - that.mouseDelayMet = true; - }, this.options.delay); - } - - if (this._mouseDistanceMet(event) && this._mouseDelayMet(event)) { - this._mouseStarted = (this._mouseStart(event) !== false); - if (!this._mouseStarted) { - event.preventDefault(); - return true; - } - } - - // Click event may never have fired (Gecko & Opera) - if (true === $.data(event.target, this.widgetName + ".preventClickEvent")) { - $.removeData(event.target, this.widgetName + ".preventClickEvent"); - } - - // these delegates are required to keep context - this._mouseMoveDelegate = function(event) { - return that._mouseMove(event); - }; - this._mouseUpDelegate = function(event) { - return that._mouseUp(event); - }; - $(document) - .bind("mousemove."+this.widgetName, this._mouseMoveDelegate) - .bind("mouseup."+this.widgetName, this._mouseUpDelegate); - - event.preventDefault(); - - mouseHandled = true; - return true; - }, - - _mouseMove: function(event) { - // IE mouseup check - mouseup happened when mouse was out of window - if ($.ui.ie && ( !document.documentMode || document.documentMode < 9 ) && !event.button) { - return this._mouseUp(event); - } - - if (this._mouseStarted) { - this._mouseDrag(event); - return event.preventDefault(); - } - - if (this._mouseDistanceMet(event) && this._mouseDelayMet(event)) { - this._mouseStarted = - (this._mouseStart(this._mouseDownEvent, event) !== false); - (this._mouseStarted ? this._mouseDrag(event) : this._mouseUp(event)); - } - - return !this._mouseStarted; - }, - - _mouseUp: function(event) { - $(document) - .unbind("mousemove."+this.widgetName, this._mouseMoveDelegate) - .unbind("mouseup."+this.widgetName, this._mouseUpDelegate); - - if (this._mouseStarted) { - this._mouseStarted = false; - - if (event.target === this._mouseDownEvent.target) { - $.data(event.target, this.widgetName + ".preventClickEvent", true); - } - - this._mouseStop(event); - } - - return false; - }, - - _mouseDistanceMet: function(event) { - return (Math.max( - Math.abs(this._mouseDownEvent.pageX - event.pageX), - Math.abs(this._mouseDownEvent.pageY - event.pageY) - ) >= this.options.distance - ); - }, - - _mouseDelayMet: function(/* event */) { - return this.mouseDelayMet; - }, - - // These are placeholder methods, to be overriden by extending plugin - _mouseStart: function(/* event */) {}, - _mouseDrag: function(/* event */) {}, - _mouseStop: function(/* event */) {}, - _mouseCapture: function(/* event */) { return true; } -}); - -})(jQuery); -(function( $, undefined ) { - -$.widget("ui.draggable", $.ui.mouse, { - version: "1.10.4", - widgetEventPrefix: "drag", - options: { - addClasses: true, - appendTo: "parent", - axis: false, - connectToSortable: false, - containment: false, - cursor: "auto", - cursorAt: false, - grid: false, - handle: false, - helper: "original", - iframeFix: false, - opacity: false, - refreshPositions: false, - revert: false, - revertDuration: 500, - scope: "default", - scroll: true, - scrollSensitivity: 20, - scrollSpeed: 20, - snap: false, - snapMode: "both", - snapTolerance: 20, - stack: false, - zIndex: false, - - // callbacks - drag: null, - start: null, - stop: null - }, - _create: function() { - - if (this.options.helper === "original" && !(/^(?:r|a|f)/).test(this.element.css("position"))) { - this.element[0].style.position = "relative"; - } - if (this.options.addClasses){ - this.element.addClass("ui-draggable"); - } - if (this.options.disabled){ - this.element.addClass("ui-draggable-disabled"); - } - - this._mouseInit(); - - }, - - _destroy: function() { - this.element.removeClass( "ui-draggable ui-draggable-dragging ui-draggable-disabled" ); - this._mouseDestroy(); - }, - - _mouseCapture: function(event) { - - var o = this.options; - - // among others, prevent a drag on a resizable-handle - if (this.helper || o.disabled || $(event.target).closest(".ui-resizable-handle").length > 0) { - return false; - } - - //Quit if we're not on a valid handle - this.handle = this._getHandle(event); - if (!this.handle) { - return false; - } - - $(o.iframeFix === true ? "iframe" : o.iframeFix).each(function() { - $("<div class='ui-draggable-iframeFix' style='background: #fff;'></div>") - .css({ - width: this.offsetWidth+"px", height: this.offsetHeight+"px", - position: "absolute", opacity: "0.001", zIndex: 1000 - }) - .css($(this).offset()) - .appendTo("body"); - }); - - return true; - - }, - - _mouseStart: function(event) { - - var o = this.options; - - //Create and append the visible helper - this.helper = this._createHelper(event); - - this.helper.addClass("ui-draggable-dragging"); - - //Cache the helper size - this._cacheHelperProportions(); - - //If ddmanager is used for droppables, set the global draggable - if($.ui.ddmanager) { - $.ui.ddmanager.current = this; - } - - /* - * - Position generation - - * This block generates everything position related - it's the core of draggables. - */ - - //Cache the margins of the original element - this._cacheMargins(); - - //Store the helper's css position - this.cssPosition = this.helper.css( "position" ); - this.scrollParent = this.helper.scrollParent(); - this.offsetParent = this.helper.offsetParent(); - this.offsetParentCssPosition = this.offsetParent.css( "position" ); - - //The element's absolute position on the page minus margins - this.offset = this.positionAbs = this.element.offset(); - this.offset = { - top: this.offset.top - this.margins.top, - left: this.offset.left - this.margins.left - }; - - //Reset scroll cache - this.offset.scroll = false; - - $.extend(this.offset, { - click: { //Where the click happened, relative to the element - left: event.pageX - this.offset.left, - top: event.pageY - this.offset.top - }, - parent: this._getParentOffset(), - relative: this._getRelativeOffset() //This is a relative to absolute position minus the actual position calculation - only used for relative positioned helper - }); - - //Generate the original position - this.originalPosition = this.position = this._generatePosition(event); - this.originalPageX = event.pageX; - this.originalPageY = event.pageY; - - //Adjust the mouse offset relative to the helper if "cursorAt" is supplied - (o.cursorAt && this._adjustOffsetFromHelper(o.cursorAt)); - - //Set a containment if given in the options - this._setContainment(); - - //Trigger event + callbacks - if(this._trigger("start", event) === false) { - this._clear(); - return false; - } - - //Recache the helper size - this._cacheHelperProportions(); - - //Prepare the droppable offsets - if ($.ui.ddmanager && !o.dropBehaviour) { - $.ui.ddmanager.prepareOffsets(this, event); - } - - - this._mouseDrag(event, true); //Execute the drag once - this causes the helper not to be visible before getting its correct position - - //If the ddmanager is used for droppables, inform the manager that dragging has started (see #5003) - if ( $.ui.ddmanager ) { - $.ui.ddmanager.dragStart(this, event); - } - - return true; - }, - - _mouseDrag: function(event, noPropagation) { - // reset any necessary cached properties (see #5009) - if ( this.offsetParentCssPosition === "fixed" ) { - this.offset.parent = this._getParentOffset(); - } - - //Compute the helpers position - this.position = this._generatePosition(event); - this.positionAbs = this._convertPositionTo("absolute"); - - //Call plugins and callbacks and use the resulting position if something is returned - if (!noPropagation) { - var ui = this._uiHash(); - if(this._trigger("drag", event, ui) === false) { - this._mouseUp({}); - return false; - } - this.position = ui.position; - } - - if(!this.options.axis || this.options.axis !== "y") { - this.helper[0].style.left = this.position.left+"px"; - } - if(!this.options.axis || this.options.axis !== "x") { - this.helper[0].style.top = this.position.top+"px"; - } - if($.ui.ddmanager) { - $.ui.ddmanager.drag(this, event); - } - - return false; - }, - - _mouseStop: function(event) { - - //If we are using droppables, inform the manager about the drop - var that = this, - dropped = false; - if ($.ui.ddmanager && !this.options.dropBehaviour) { - dropped = $.ui.ddmanager.drop(this, event); - } - - //if a drop comes from outside (a sortable) - if(this.dropped) { - dropped = this.dropped; - this.dropped = false; - } - - //if the original element is no longer in the DOM don't bother to continue (see #8269) - if ( this.options.helper === "original" && !$.contains( this.element[ 0 ].ownerDocument, this.element[ 0 ] ) ) { - return false; - } - - if((this.options.revert === "invalid" && !dropped) || (this.options.revert === "valid" && dropped) || this.options.revert === true || ($.isFunction(this.options.revert) && this.options.revert.call(this.element, dropped))) { - $(this.helper).animate(this.originalPosition, parseInt(this.options.revertDuration, 10), function() { - if(that._trigger("stop", event) !== false) { - that._clear(); - } - }); - } else { - if(this._trigger("stop", event) !== false) { - this._clear(); - } - } - - return false; - }, - - _mouseUp: function(event) { - //Remove frame helpers - $("div.ui-draggable-iframeFix").each(function() { - this.parentNode.removeChild(this); - }); - - //If the ddmanager is used for droppables, inform the manager that dragging has stopped (see #5003) - if( $.ui.ddmanager ) { - $.ui.ddmanager.dragStop(this, event); - } - - return $.ui.mouse.prototype._mouseUp.call(this, event); - }, - - cancel: function() { - - if(this.helper.is(".ui-draggable-dragging")) { - this._mouseUp({}); - } else { - this._clear(); - } - - return this; - - }, - - _getHandle: function(event) { - return this.options.handle ? - !!$( event.target ).closest( this.element.find( this.options.handle ) ).length : - true; - }, - - _createHelper: function(event) { - - var o = this.options, - helper = $.isFunction(o.helper) ? $(o.helper.apply(this.element[0], [event])) : (o.helper === "clone" ? this.element.clone().removeAttr("id") : this.element); - - if(!helper.parents("body").length) { - helper.appendTo((o.appendTo === "parent" ? this.element[0].parentNode : o.appendTo)); - } - - if(helper[0] !== this.element[0] && !(/(fixed|absolute)/).test(helper.css("position"))) { - helper.css("position", "absolute"); - } - - return helper; - - }, - - _adjustOffsetFromHelper: function(obj) { - if (typeof obj === "string") { - obj = obj.split(" "); - } - if ($.isArray(obj)) { - obj = {left: +obj[0], top: +obj[1] || 0}; - } - if ("left" in obj) { - this.offset.click.left = obj.left + this.margins.left; - } - if ("right" in obj) { - this.offset.click.left = this.helperProportions.width - obj.right + this.margins.left; - } - if ("top" in obj) { - this.offset.click.top = obj.top + this.margins.top; - } - if ("bottom" in obj) { - this.offset.click.top = this.helperProportions.height - obj.bottom + this.margins.top; - } - }, - - _getParentOffset: function() { - - //Get the offsetParent and cache its position - var po = this.offsetParent.offset(); - - // This is a special case where we need to modify a offset calculated on start, since the following happened: - // 1. The position of the helper is absolute, so it's position is calculated based on the next positioned parent - // 2. The actual offset parent is a child of the scroll parent, and the scroll parent isn't the document, which means that - // the scroll is included in the initial calculation of the offset of the parent, and never recalculated upon drag - if(this.cssPosition === "absolute" && this.scrollParent[0] !== document && $.contains(this.scrollParent[0], this.offsetParent[0])) { - po.left += this.scrollParent.scrollLeft(); - po.top += this.scrollParent.scrollTop(); - } - - //This needs to be actually done for all browsers, since pageX/pageY includes this information - //Ugly IE fix - if((this.offsetParent[0] === document.body) || - (this.offsetParent[0].tagName && this.offsetParent[0].tagName.toLowerCase() === "html" && $.ui.ie)) { - po = { top: 0, left: 0 }; - } - - return { - top: po.top + (parseInt(this.offsetParent.css("borderTopWidth"),10) || 0), - left: po.left + (parseInt(this.offsetParent.css("borderLeftWidth"),10) || 0) - }; - - }, - - _getRelativeOffset: function() { - - if(this.cssPosition === "relative") { - var p = this.element.position(); - return { - top: p.top - (parseInt(this.helper.css("top"),10) || 0) + this.scrollParent.scrollTop(), - left: p.left - (parseInt(this.helper.css("left"),10) || 0) + this.scrollParent.scrollLeft() - }; - } else { - return { top: 0, left: 0 }; - } - - }, - - _cacheMargins: function() { - this.margins = { - left: (parseInt(this.element.css("marginLeft"),10) || 0), - top: (parseInt(this.element.css("marginTop"),10) || 0), - right: (parseInt(this.element.css("marginRight"),10) || 0), - bottom: (parseInt(this.element.css("marginBottom"),10) || 0) - }; - }, - - _cacheHelperProportions: function() { - this.helperProportions = { - width: this.helper.outerWidth(), - height: this.helper.outerHeight() - }; - }, - - _setContainment: function() { - - var over, c, ce, - o = this.options; - - if ( !o.containment ) { - this.containment = null; - return; - } - - if ( o.containment === "window" ) { - this.containment = [ - $( window ).scrollLeft() - this.offset.relative.left - this.offset.parent.left, - $( window ).scrollTop() - this.offset.relative.top - this.offset.parent.top, - $( window ).scrollLeft() + $( window ).width() - this.helperProportions.width - this.margins.left, - $( window ).scrollTop() + ( $( window ).height() || document.body.parentNode.scrollHeight ) - this.helperProportions.height - this.margins.top - ]; - return; - } - - if ( o.containment === "document") { - this.containment = [ - 0, - 0, - $( document ).width() - this.helperProportions.width - this.margins.left, - ( $( document ).height() || document.body.parentNode.scrollHeight ) - this.helperProportions.height - this.margins.top - ]; - return; - } - - if ( o.containment.constructor === Array ) { - this.containment = o.containment; - return; - } - - if ( o.containment === "parent" ) { - o.containment = this.helper[ 0 ].parentNode; - } - - c = $( o.containment ); - ce = c[ 0 ]; - - if( !ce ) { - return; - } - - over = c.css( "overflow" ) !== "hidden"; - - this.containment = [ - ( parseInt( c.css( "borderLeftWidth" ), 10 ) || 0 ) + ( parseInt( c.css( "paddingLeft" ), 10 ) || 0 ), - ( parseInt( c.css( "borderTopWidth" ), 10 ) || 0 ) + ( parseInt( c.css( "paddingTop" ), 10 ) || 0 ) , - ( over ? Math.max( ce.scrollWidth, ce.offsetWidth ) : ce.offsetWidth ) - ( parseInt( c.css( "borderRightWidth" ), 10 ) || 0 ) - ( parseInt( c.css( "paddingRight" ), 10 ) || 0 ) - this.helperProportions.width - this.margins.left - this.margins.right, - ( over ? Math.max( ce.scrollHeight, ce.offsetHeight ) : ce.offsetHeight ) - ( parseInt( c.css( "borderBottomWidth" ), 10 ) || 0 ) - ( parseInt( c.css( "paddingBottom" ), 10 ) || 0 ) - this.helperProportions.height - this.margins.top - this.margins.bottom - ]; - this.relative_container = c; - }, - - _convertPositionTo: function(d, pos) { - - if(!pos) { - pos = this.position; - } - - var mod = d === "absolute" ? 1 : -1, - scroll = this.cssPosition === "absolute" && !( this.scrollParent[ 0 ] !== document && $.contains( this.scrollParent[ 0 ], this.offsetParent[ 0 ] ) ) ? this.offsetParent : this.scrollParent; - - //Cache the scroll - if (!this.offset.scroll) { - this.offset.scroll = {top : scroll.scrollTop(), left : scroll.scrollLeft()}; - } - - return { - top: ( - pos.top + // The absolute mouse position - this.offset.relative.top * mod + // Only for relative positioned nodes: Relative offset from element to offset parent - this.offset.parent.top * mod - // The offsetParent's offset without borders (offset + border) - ( ( this.cssPosition === "fixed" ? -this.scrollParent.scrollTop() : this.offset.scroll.top ) * mod ) - ), - left: ( - pos.left + // The absolute mouse position - this.offset.relative.left * mod + // Only for relative positioned nodes: Relative offset from element to offset parent - this.offset.parent.left * mod - // The offsetParent's offset without borders (offset + border) - ( ( this.cssPosition === "fixed" ? -this.scrollParent.scrollLeft() : this.offset.scroll.left ) * mod ) - ) - }; - - }, - - _generatePosition: function(event) { - - var containment, co, top, left, - o = this.options, - scroll = this.cssPosition === "absolute" && !( this.scrollParent[ 0 ] !== document && $.contains( this.scrollParent[ 0 ], this.offsetParent[ 0 ] ) ) ? this.offsetParent : this.scrollParent, - pageX = event.pageX, - pageY = event.pageY; - - //Cache the scroll - if (!this.offset.scroll) { - this.offset.scroll = {top : scroll.scrollTop(), left : scroll.scrollLeft()}; - } - - /* - * - Position constraining - - * Constrain the position to a mix of grid, containment. - */ - - // If we are not dragging yet, we won't check for options - if ( this.originalPosition ) { - if ( this.containment ) { - if ( this.relative_container ){ - co = this.relative_container.offset(); - containment = [ - this.containment[ 0 ] + co.left, - this.containment[ 1 ] + co.top, - this.containment[ 2 ] + co.left, - this.containment[ 3 ] + co.top - ]; - } - else { - containment = this.containment; - } - - if(event.pageX - this.offset.click.left < containment[0]) { - pageX = containment[0] + this.offset.click.left; - } - if(event.pageY - this.offset.click.top < containment[1]) { - pageY = containment[1] + this.offset.click.top; - } - if(event.pageX - this.offset.click.left > containment[2]) { - pageX = containment[2] + this.offset.click.left; - } - if(event.pageY - this.offset.click.top > containment[3]) { - pageY = containment[3] + this.offset.click.top; - } - } - - if(o.grid) { - //Check for grid elements set to 0 to prevent divide by 0 error causing invalid argument errors in IE (see ticket #6950) - top = o.grid[1] ? this.originalPageY + Math.round((pageY - this.originalPageY) / o.grid[1]) * o.grid[1] : this.originalPageY; - pageY = containment ? ((top - this.offset.click.top >= containment[1] || top - this.offset.click.top > containment[3]) ? top : ((top - this.offset.click.top >= containment[1]) ? top - o.grid[1] : top + o.grid[1])) : top; - - left = o.grid[0] ? this.originalPageX + Math.round((pageX - this.originalPageX) / o.grid[0]) * o.grid[0] : this.originalPageX; - pageX = containment ? ((left - this.offset.click.left >= containment[0] || left - this.offset.click.left > containment[2]) ? left : ((left - this.offset.click.left >= containment[0]) ? left - o.grid[0] : left + o.grid[0])) : left; - } - - } - - return { - top: ( - pageY - // The absolute mouse position - this.offset.click.top - // Click offset (relative to the element) - this.offset.relative.top - // Only for relative positioned nodes: Relative offset from element to offset parent - this.offset.parent.top + // The offsetParent's offset without borders (offset + border) - ( this.cssPosition === "fixed" ? -this.scrollParent.scrollTop() : this.offset.scroll.top ) - ), - left: ( - pageX - // The absolute mouse position - this.offset.click.left - // Click offset (relative to the element) - this.offset.relative.left - // Only for relative positioned nodes: Relative offset from element to offset parent - this.offset.parent.left + // The offsetParent's offset without borders (offset + border) - ( this.cssPosition === "fixed" ? -this.scrollParent.scrollLeft() : this.offset.scroll.left ) - ) - }; - - }, - - _clear: function() { - this.helper.removeClass("ui-draggable-dragging"); - if(this.helper[0] !== this.element[0] && !this.cancelHelperRemoval) { - this.helper.remove(); - } - this.helper = null; - this.cancelHelperRemoval = false; - }, - - // From now on bulk stuff - mainly helpers - - _trigger: function(type, event, ui) { - ui = ui || this._uiHash(); - $.ui.plugin.call(this, type, [event, ui]); - //The absolute position has to be recalculated after plugins - if(type === "drag") { - this.positionAbs = this._convertPositionTo("absolute"); - } - return $.Widget.prototype._trigger.call(this, type, event, ui); - }, - - plugins: {}, - - _uiHash: function() { - return { - helper: this.helper, - position: this.position, - originalPosition: this.originalPosition, - offset: this.positionAbs - }; - } - -}); - -$.ui.plugin.add("draggable", "connectToSortable", { - start: function(event, ui) { - - var inst = $(this).data("ui-draggable"), o = inst.options, - uiSortable = $.extend({}, ui, { item: inst.element }); - inst.sortables = []; - $(o.connectToSortable).each(function() { - var sortable = $.data(this, "ui-sortable"); - if (sortable && !sortable.options.disabled) { - inst.sortables.push({ - instance: sortable, - shouldRevert: sortable.options.revert - }); - sortable.refreshPositions(); // Call the sortable's refreshPositions at drag start to refresh the containerCache since the sortable container cache is used in drag and needs to be up to date (this will ensure it's initialised as well as being kept in step with any changes that might have happened on the page). - sortable._trigger("activate", event, uiSortable); - } - }); - - }, - stop: function(event, ui) { - - //If we are still over the sortable, we fake the stop event of the sortable, but also remove helper - var inst = $(this).data("ui-draggable"), - uiSortable = $.extend({}, ui, { item: inst.element }); - - $.each(inst.sortables, function() { - if(this.instance.isOver) { - - this.instance.isOver = 0; - - inst.cancelHelperRemoval = true; //Don't remove the helper in the draggable instance - this.instance.cancelHelperRemoval = false; //Remove it in the sortable instance (so sortable plugins like revert still work) - - //The sortable revert is supported, and we have to set a temporary dropped variable on the draggable to support revert: "valid/invalid" - if(this.shouldRevert) { - this.instance.options.revert = this.shouldRevert; - } - - //Trigger the stop of the sortable - this.instance._mouseStop(event); - - this.instance.options.helper = this.instance.options._helper; - - //If the helper has been the original item, restore properties in the sortable - if(inst.options.helper === "original") { - this.instance.currentItem.css({ top: "auto", left: "auto" }); - } - - } else { - this.instance.cancelHelperRemoval = false; //Remove the helper in the sortable instance - this.instance._trigger("deactivate", event, uiSortable); - } - - }); - - }, - drag: function(event, ui) { - - var inst = $(this).data("ui-draggable"), that = this; - - $.each(inst.sortables, function() { - - var innermostIntersecting = false, - thisSortable = this; - - //Copy over some variables to allow calling the sortable's native _intersectsWith - this.instance.positionAbs = inst.positionAbs; - this.instance.helperProportions = inst.helperProportions; - this.instance.offset.click = inst.offset.click; - - if(this.instance._intersectsWith(this.instance.containerCache)) { - innermostIntersecting = true; - $.each(inst.sortables, function () { - this.instance.positionAbs = inst.positionAbs; - this.instance.helperProportions = inst.helperProportions; - this.instance.offset.click = inst.offset.click; - if (this !== thisSortable && - this.instance._intersectsWith(this.instance.containerCache) && - $.contains(thisSortable.instance.element[0], this.instance.element[0]) - ) { - innermostIntersecting = false; - } - return innermostIntersecting; - }); - } - - - if(innermostIntersecting) { - //If it intersects, we use a little isOver variable and set it once, so our move-in stuff gets fired only once - if(!this.instance.isOver) { - - this.instance.isOver = 1; - //Now we fake the start of dragging for the sortable instance, - //by cloning the list group item, appending it to the sortable and using it as inst.currentItem - //We can then fire the start event of the sortable with our passed browser event, and our own helper (so it doesn't create a new one) - this.instance.currentItem = $(that).clone().removeAttr("id").appendTo(this.instance.element).data("ui-sortable-item", true); - this.instance.options._helper = this.instance.options.helper; //Store helper option to later restore it - this.instance.options.helper = function() { return ui.helper[0]; }; - - event.target = this.instance.currentItem[0]; - this.instance._mouseCapture(event, true); - this.instance._mouseStart(event, true, true); - - //Because the browser event is way off the new appended portlet, we modify a couple of variables to reflect the changes - this.instance.offset.click.top = inst.offset.click.top; - this.instance.offset.click.left = inst.offset.click.left; - this.instance.offset.parent.left -= inst.offset.parent.left - this.instance.offset.parent.left; - this.instance.offset.parent.top -= inst.offset.parent.top - this.instance.offset.parent.top; - - inst._trigger("toSortable", event); - inst.dropped = this.instance.element; //draggable revert needs that - //hack so receive/update callbacks work (mostly) - inst.currentItem = inst.element; - this.instance.fromOutside = inst; - - } - - //Provided we did all the previous steps, we can fire the drag event of the sortable on every draggable drag, when it intersects with the sortable - if(this.instance.currentItem) { - this.instance._mouseDrag(event); - } - - } else { - - //If it doesn't intersect with the sortable, and it intersected before, - //we fake the drag stop of the sortable, but make sure it doesn't remove the helper by using cancelHelperRemoval - if(this.instance.isOver) { - - this.instance.isOver = 0; - this.instance.cancelHelperRemoval = true; - - //Prevent reverting on this forced stop - this.instance.options.revert = false; - - // The out event needs to be triggered independently - this.instance._trigger("out", event, this.instance._uiHash(this.instance)); - - this.instance._mouseStop(event, true); - this.instance.options.helper = this.instance.options._helper; - - //Now we remove our currentItem, the list group clone again, and the placeholder, and animate the helper back to it's original size - this.instance.currentItem.remove(); - if(this.instance.placeholder) { - this.instance.placeholder.remove(); - } - - inst._trigger("fromSortable", event); - inst.dropped = false; //draggable revert needs that - } - - } - - }); - - } -}); - -$.ui.plugin.add("draggable", "cursor", { - start: function() { - var t = $("body"), o = $(this).data("ui-draggable").options; - if (t.css("cursor")) { - o._cursor = t.css("cursor"); - } - t.css("cursor", o.cursor); - }, - stop: function() { - var o = $(this).data("ui-draggable").options; - if (o._cursor) { - $("body").css("cursor", o._cursor); - } - } -}); - -$.ui.plugin.add("draggable", "opacity", { - start: function(event, ui) { - var t = $(ui.helper), o = $(this).data("ui-draggable").options; - if(t.css("opacity")) { - o._opacity = t.css("opacity"); - } - t.css("opacity", o.opacity); - }, - stop: function(event, ui) { - var o = $(this).data("ui-draggable").options; - if(o._opacity) { - $(ui.helper).css("opacity", o._opacity); - } - } -}); - -$.ui.plugin.add("draggable", "scroll", { - start: function() { - var i = $(this).data("ui-draggable"); - if(i.scrollParent[0] !== document && i.scrollParent[0].tagName !== "HTML") { - i.overflowOffset = i.scrollParent.offset(); - } - }, - drag: function( event ) { - - var i = $(this).data("ui-draggable"), o = i.options, scrolled = false; - - if(i.scrollParent[0] !== document && i.scrollParent[0].tagName !== "HTML") { - - if(!o.axis || o.axis !== "x") { - if((i.overflowOffset.top + i.scrollParent[0].offsetHeight) - event.pageY < o.scrollSensitivity) { - i.scrollParent[0].scrollTop = scrolled = i.scrollParent[0].scrollTop + o.scrollSpeed; - } else if(event.pageY - i.overflowOffset.top < o.scrollSensitivity) { - i.scrollParent[0].scrollTop = scrolled = i.scrollParent[0].scrollTop - o.scrollSpeed; - } - } - - if(!o.axis || o.axis !== "y") { - if((i.overflowOffset.left + i.scrollParent[0].offsetWidth) - event.pageX < o.scrollSensitivity) { - i.scrollParent[0].scrollLeft = scrolled = i.scrollParent[0].scrollLeft + o.scrollSpeed; - } else if(event.pageX - i.overflowOffset.left < o.scrollSensitivity) { - i.scrollParent[0].scrollLeft = scrolled = i.scrollParent[0].scrollLeft - o.scrollSpeed; - } - } - - } else { - - if(!o.axis || o.axis !== "x") { - if(event.pageY - $(document).scrollTop() < o.scrollSensitivity) { - scrolled = $(document).scrollTop($(document).scrollTop() - o.scrollSpeed); - } else if($(window).height() - (event.pageY - $(document).scrollTop()) < o.scrollSensitivity) { - scrolled = $(document).scrollTop($(document).scrollTop() + o.scrollSpeed); - } - } - - if(!o.axis || o.axis !== "y") { - if(event.pageX - $(document).scrollLeft() < o.scrollSensitivity) { - scrolled = $(document).scrollLeft($(document).scrollLeft() - o.scrollSpeed); - } else if($(window).width() - (event.pageX - $(document).scrollLeft()) < o.scrollSensitivity) { - scrolled = $(document).scrollLeft($(document).scrollLeft() + o.scrollSpeed); - } - } - - } - - if(scrolled !== false && $.ui.ddmanager && !o.dropBehaviour) { - $.ui.ddmanager.prepareOffsets(i, event); - } - - } -}); - -$.ui.plugin.add("draggable", "snap", { - start: function() { - - var i = $(this).data("ui-draggable"), - o = i.options; - - i.snapElements = []; - - $(o.snap.constructor !== String ? ( o.snap.items || ":data(ui-draggable)" ) : o.snap).each(function() { - var $t = $(this), - $o = $t.offset(); - if(this !== i.element[0]) { - i.snapElements.push({ - item: this, - width: $t.outerWidth(), height: $t.outerHeight(), - top: $o.top, left: $o.left - }); - } - }); - - }, - drag: function(event, ui) { - - var ts, bs, ls, rs, l, r, t, b, i, first, - inst = $(this).data("ui-draggable"), - o = inst.options, - d = o.snapTolerance, - x1 = ui.offset.left, x2 = x1 + inst.helperProportions.width, - y1 = ui.offset.top, y2 = y1 + inst.helperProportions.height; - - for (i = inst.snapElements.length - 1; i >= 0; i--){ - - l = inst.snapElements[i].left; - r = l + inst.snapElements[i].width; - t = inst.snapElements[i].top; - b = t + inst.snapElements[i].height; - - if ( x2 < l - d || x1 > r + d || y2 < t - d || y1 > b + d || !$.contains( inst.snapElements[ i ].item.ownerDocument, inst.snapElements[ i ].item ) ) { - if(inst.snapElements[i].snapping) { - (inst.options.snap.release && inst.options.snap.release.call(inst.element, event, $.extend(inst._uiHash(), { snapItem: inst.snapElements[i].item }))); - } - inst.snapElements[i].snapping = false; - continue; - } - - if(o.snapMode !== "inner") { - ts = Math.abs(t - y2) <= d; - bs = Math.abs(b - y1) <= d; - ls = Math.abs(l - x2) <= d; - rs = Math.abs(r - x1) <= d; - if(ts) { - ui.position.top = inst._convertPositionTo("relative", { top: t - inst.helperProportions.height, left: 0 }).top - inst.margins.top; - } - if(bs) { - ui.position.top = inst._convertPositionTo("relative", { top: b, left: 0 }).top - inst.margins.top; - } - if(ls) { - ui.position.left = inst._convertPositionTo("relative", { top: 0, left: l - inst.helperProportions.width }).left - inst.margins.left; - } - if(rs) { - ui.position.left = inst._convertPositionTo("relative", { top: 0, left: r }).left - inst.margins.left; - } - } - - first = (ts || bs || ls || rs); - - if(o.snapMode !== "outer") { - ts = Math.abs(t - y1) <= d; - bs = Math.abs(b - y2) <= d; - ls = Math.abs(l - x1) <= d; - rs = Math.abs(r - x2) <= d; - if(ts) { - ui.position.top = inst._convertPositionTo("relative", { top: t, left: 0 }).top - inst.margins.top; - } - if(bs) { - ui.position.top = inst._convertPositionTo("relative", { top: b - inst.helperProportions.height, left: 0 }).top - inst.margins.top; - } - if(ls) { - ui.position.left = inst._convertPositionTo("relative", { top: 0, left: l }).left - inst.margins.left; - } - if(rs) { - ui.position.left = inst._convertPositionTo("relative", { top: 0, left: r - inst.helperProportions.width }).left - inst.margins.left; - } - } - - if(!inst.snapElements[i].snapping && (ts || bs || ls || rs || first)) { - (inst.options.snap.snap && inst.options.snap.snap.call(inst.element, event, $.extend(inst._uiHash(), { snapItem: inst.snapElements[i].item }))); - } - inst.snapElements[i].snapping = (ts || bs || ls || rs || first); - - } - - } -}); - -$.ui.plugin.add("draggable", "stack", { - start: function() { - var min, - o = this.data("ui-draggable").options, - group = $.makeArray($(o.stack)).sort(function(a,b) { - return (parseInt($(a).css("zIndex"),10) || 0) - (parseInt($(b).css("zIndex"),10) || 0); - }); - - if (!group.length) { return; } - - min = parseInt($(group[0]).css("zIndex"), 10) || 0; - $(group).each(function(i) { - $(this).css("zIndex", min + i); - }); - this.css("zIndex", (min + group.length)); - } -}); - -$.ui.plugin.add("draggable", "zIndex", { - start: function(event, ui) { - var t = $(ui.helper), o = $(this).data("ui-draggable").options; - if(t.css("zIndex")) { - o._zIndex = t.css("zIndex"); - } - t.css("zIndex", o.zIndex); - }, - stop: function(event, ui) { - var o = $(this).data("ui-draggable").options; - if(o._zIndex) { - $(ui.helper).css("zIndex", o._zIndex); - } - } -}); - -})(jQuery); -(function( $, undefined ) { - -function isOverAxis( x, reference, size ) { - return ( x > reference ) && ( x < ( reference + size ) ); -} - -$.widget("ui.droppable", { - version: "1.10.4", - widgetEventPrefix: "drop", - options: { - accept: "*", - activeClass: false, - addClasses: true, - greedy: false, - hoverClass: false, - scope: "default", - tolerance: "intersect", - - // callbacks - activate: null, - deactivate: null, - drop: null, - out: null, - over: null - }, - _create: function() { - - var proportions, - o = this.options, - accept = o.accept; - - this.isover = false; - this.isout = true; - - this.accept = $.isFunction(accept) ? accept : function(d) { - return d.is(accept); - }; - - this.proportions = function( /* valueToWrite */ ) { - if ( arguments.length ) { - // Store the droppable's proportions - proportions = arguments[ 0 ]; - } else { - // Retrieve or derive the droppable's proportions - return proportions ? - proportions : - proportions = { - width: this.element[ 0 ].offsetWidth, - height: this.element[ 0 ].offsetHeight - }; - } - }; - - // Add the reference and positions to the manager - $.ui.ddmanager.droppables[o.scope] = $.ui.ddmanager.droppables[o.scope] || []; - $.ui.ddmanager.droppables[o.scope].push(this); - - (o.addClasses && this.element.addClass("ui-droppable")); - - }, - - _destroy: function() { - var i = 0, - drop = $.ui.ddmanager.droppables[this.options.scope]; - - for ( ; i < drop.length; i++ ) { - if ( drop[i] === this ) { - drop.splice(i, 1); - } - } - - this.element.removeClass("ui-droppable ui-droppable-disabled"); - }, - - _setOption: function(key, value) { - - if(key === "accept") { - this.accept = $.isFunction(value) ? value : function(d) { - return d.is(value); - }; - } - $.Widget.prototype._setOption.apply(this, arguments); - }, - - _activate: function(event) { - var draggable = $.ui.ddmanager.current; - if(this.options.activeClass) { - this.element.addClass(this.options.activeClass); - } - if(draggable){ - this._trigger("activate", event, this.ui(draggable)); - } - }, - - _deactivate: function(event) { - var draggable = $.ui.ddmanager.current; - if(this.options.activeClass) { - this.element.removeClass(this.options.activeClass); - } - if(draggable){ - this._trigger("deactivate", event, this.ui(draggable)); - } - }, - - _over: function(event) { - - var draggable = $.ui.ddmanager.current; - - // Bail if draggable and droppable are same element - if (!draggable || (draggable.currentItem || draggable.element)[0] === this.element[0]) { - return; - } - - if (this.accept.call(this.element[0],(draggable.currentItem || draggable.element))) { - if(this.options.hoverClass) { - this.element.addClass(this.options.hoverClass); - } - this._trigger("over", event, this.ui(draggable)); - } - - }, - - _out: function(event) { - - var draggable = $.ui.ddmanager.current; - - // Bail if draggable and droppable are same element - if (!draggable || (draggable.currentItem || draggable.element)[0] === this.element[0]) { - return; - } - - if (this.accept.call(this.element[0],(draggable.currentItem || draggable.element))) { - if(this.options.hoverClass) { - this.element.removeClass(this.options.hoverClass); - } - this._trigger("out", event, this.ui(draggable)); - } - - }, - - _drop: function(event,custom) { - - var draggable = custom || $.ui.ddmanager.current, - childrenIntersection = false; - - // Bail if draggable and droppable are same element - if (!draggable || (draggable.currentItem || draggable.element)[0] === this.element[0]) { - return false; - } - - this.element.find(":data(ui-droppable)").not(".ui-draggable-dragging").each(function() { - var inst = $.data(this, "ui-droppable"); - if( - inst.options.greedy && - !inst.options.disabled && - inst.options.scope === draggable.options.scope && - inst.accept.call(inst.element[0], (draggable.currentItem || draggable.element)) && - $.ui.intersect(draggable, $.extend(inst, { offset: inst.element.offset() }), inst.options.tolerance) - ) { childrenIntersection = true; return false; } - }); - if(childrenIntersection) { - return false; - } - - if(this.accept.call(this.element[0],(draggable.currentItem || draggable.element))) { - if(this.options.activeClass) { - this.element.removeClass(this.options.activeClass); - } - if(this.options.hoverClass) { - this.element.removeClass(this.options.hoverClass); - } - this._trigger("drop", event, this.ui(draggable)); - return this.element; - } - - return false; - - }, - - ui: function(c) { - return { - draggable: (c.currentItem || c.element), - helper: c.helper, - position: c.position, - offset: c.positionAbs - }; - } - -}); - -$.ui.intersect = function(draggable, droppable, toleranceMode) { - - if (!droppable.offset) { - return false; - } - - var draggableLeft, draggableTop, - x1 = (draggable.positionAbs || draggable.position.absolute).left, - y1 = (draggable.positionAbs || draggable.position.absolute).top, - x2 = x1 + draggable.helperProportions.width, - y2 = y1 + draggable.helperProportions.height, - l = droppable.offset.left, - t = droppable.offset.top, - r = l + droppable.proportions().width, - b = t + droppable.proportions().height; - - switch (toleranceMode) { - case "fit": - return (l <= x1 && x2 <= r && t <= y1 && y2 <= b); - case "intersect": - return (l < x1 + (draggable.helperProportions.width / 2) && // Right Half - x2 - (draggable.helperProportions.width / 2) < r && // Left Half - t < y1 + (draggable.helperProportions.height / 2) && // Bottom Half - y2 - (draggable.helperProportions.height / 2) < b ); // Top Half - case "pointer": - draggableLeft = ((draggable.positionAbs || draggable.position.absolute).left + (draggable.clickOffset || draggable.offset.click).left); - draggableTop = ((draggable.positionAbs || draggable.position.absolute).top + (draggable.clickOffset || draggable.offset.click).top); - return isOverAxis( draggableTop, t, droppable.proportions().height ) && isOverAxis( draggableLeft, l, droppable.proportions().width ); - case "touch": - return ( - (y1 >= t && y1 <= b) || // Top edge touching - (y2 >= t && y2 <= b) || // Bottom edge touching - (y1 < t && y2 > b) // Surrounded vertically - ) && ( - (x1 >= l && x1 <= r) || // Left edge touching - (x2 >= l && x2 <= r) || // Right edge touching - (x1 < l && x2 > r) // Surrounded horizontally - ); - default: - return false; - } - -}; - -/* - This manager tracks offsets of draggables and droppables -*/ -$.ui.ddmanager = { - current: null, - droppables: { "default": [] }, - prepareOffsets: function(t, event) { - - var i, j, - m = $.ui.ddmanager.droppables[t.options.scope] || [], - type = event ? event.type : null, // workaround for #2317 - list = (t.currentItem || t.element).find(":data(ui-droppable)").addBack(); - - droppablesLoop: for (i = 0; i < m.length; i++) { - - //No disabled and non-accepted - if(m[i].options.disabled || (t && !m[i].accept.call(m[i].element[0],(t.currentItem || t.element)))) { - continue; - } - - // Filter out elements in the current dragged item - for (j=0; j < list.length; j++) { - if(list[j] === m[i].element[0]) { - m[i].proportions().height = 0; - continue droppablesLoop; - } - } - - m[i].visible = m[i].element.css("display") !== "none"; - if(!m[i].visible) { - continue; - } - - //Activate the droppable if used directly from draggables - if(type === "mousedown") { - m[i]._activate.call(m[i], event); - } - - m[ i ].offset = m[ i ].element.offset(); - m[ i ].proportions({ width: m[ i ].element[ 0 ].offsetWidth, height: m[ i ].element[ 0 ].offsetHeight }); - - } - - }, - drop: function(draggable, event) { - - var dropped = false; - // Create a copy of the droppables in case the list changes during the drop (#9116) - $.each(($.ui.ddmanager.droppables[draggable.options.scope] || []).slice(), function() { - - if(!this.options) { - return; - } - if (!this.options.disabled && this.visible && $.ui.intersect(draggable, this, this.options.tolerance)) { - dropped = this._drop.call(this, event) || dropped; - } - - if (!this.options.disabled && this.visible && this.accept.call(this.element[0],(draggable.currentItem || draggable.element))) { - this.isout = true; - this.isover = false; - this._deactivate.call(this, event); - } - - }); - return dropped; - - }, - dragStart: function( draggable, event ) { - //Listen for scrolling so that if the dragging causes scrolling the position of the droppables can be recalculated (see #5003) - draggable.element.parentsUntil( "body" ).bind( "scroll.droppable", function() { - if( !draggable.options.refreshPositions ) { - $.ui.ddmanager.prepareOffsets( draggable, event ); - } - }); - }, - drag: function(draggable, event) { - - //If you have a highly dynamic page, you might try this option. It renders positions every time you move the mouse. - if(draggable.options.refreshPositions) { - $.ui.ddmanager.prepareOffsets(draggable, event); - } - - //Run through all droppables and check their positions based on specific tolerance options - $.each($.ui.ddmanager.droppables[draggable.options.scope] || [], function() { - - if(this.options.disabled || this.greedyChild || !this.visible) { - return; - } - - var parentInstance, scope, parent, - intersects = $.ui.intersect(draggable, this, this.options.tolerance), - c = !intersects && this.isover ? "isout" : (intersects && !this.isover ? "isover" : null); - if(!c) { - return; - } - - if (this.options.greedy) { - // find droppable parents with same scope - scope = this.options.scope; - parent = this.element.parents(":data(ui-droppable)").filter(function () { - return $.data(this, "ui-droppable").options.scope === scope; - }); - - if (parent.length) { - parentInstance = $.data(parent[0], "ui-droppable"); - parentInstance.greedyChild = (c === "isover"); - } - } - - // we just moved into a greedy child - if (parentInstance && c === "isover") { - parentInstance.isover = false; - parentInstance.isout = true; - parentInstance._out.call(parentInstance, event); - } - - this[c] = true; - this[c === "isout" ? "isover" : "isout"] = false; - this[c === "isover" ? "_over" : "_out"].call(this, event); - - // we just moved out of a greedy child - if (parentInstance && c === "isout") { - parentInstance.isout = false; - parentInstance.isover = true; - parentInstance._over.call(parentInstance, event); - } - }); - - }, - dragStop: function( draggable, event ) { - draggable.element.parentsUntil( "body" ).unbind( "scroll.droppable" ); - //Call prepareOffsets one final time since IE does not fire return scroll events when overflow was caused by drag (see #5003) - if( !draggable.options.refreshPositions ) { - $.ui.ddmanager.prepareOffsets( draggable, event ); - } - } -}; - -})(jQuery); -(function( $, undefined ) { - -function isOverAxis( x, reference, size ) { - return ( x > reference ) && ( x < ( reference + size ) ); -} - -function isFloating(item) { - return (/left|right/).test(item.css("float")) || (/inline|table-cell/).test(item.css("display")); -} - -$.widget("ui.sortable", $.ui.mouse, { - version: "1.10.4", - widgetEventPrefix: "sort", - ready: false, - options: { - appendTo: "parent", - axis: false, - connectWith: false, - containment: false, - cursor: "auto", - cursorAt: false, - dropOnEmpty: true, - forcePlaceholderSize: false, - forceHelperSize: false, - grid: false, - handle: false, - helper: "original", - items: "> *", - opacity: false, - placeholder: false, - revert: false, - scroll: true, - scrollSensitivity: 20, - scrollSpeed: 20, - scope: "default", - tolerance: "intersect", - zIndex: 1000, - - // callbacks - activate: null, - beforeStop: null, - change: null, - deactivate: null, - out: null, - over: null, - receive: null, - remove: null, - sort: null, - start: null, - stop: null, - update: null - }, - _create: function() { - - var o = this.options; - this.containerCache = {}; - this.element.addClass("ui-sortable"); - - //Get the items - this.refresh(); - - //Let's determine if the items are being displayed horizontally - this.floating = this.items.length ? o.axis === "x" || isFloating(this.items[0].item) : false; - - //Let's determine the parent's offset - this.offset = this.element.offset(); - - //Initialize mouse events for interaction - this._mouseInit(); - - //We're ready to go - this.ready = true; - - }, - - _destroy: function() { - this.element - .removeClass("ui-sortable ui-sortable-disabled"); - this._mouseDestroy(); - - for ( var i = this.items.length - 1; i >= 0; i-- ) { - this.items[i].item.removeData(this.widgetName + "-item"); - } - - return this; - }, - - _setOption: function(key, value){ - if ( key === "disabled" ) { - this.options[ key ] = value; - - this.widget().toggleClass( "ui-sortable-disabled", !!value ); - } else { - // Don't call widget base _setOption for disable as it adds ui-state-disabled class - $.Widget.prototype._setOption.apply(this, arguments); - } - }, - - _mouseCapture: function(event, overrideHandle) { - var currentItem = null, - validHandle = false, - that = this; - - if (this.reverting) { - return false; - } - - if(this.options.disabled || this.options.type === "static") { - return false; - } - - //We have to refresh the items data once first - this._refreshItems(event); - - //Find out if the clicked node (or one of its parents) is a actual item in this.items - $(event.target).parents().each(function() { - if($.data(this, that.widgetName + "-item") === that) { - currentItem = $(this); - return false; - } - }); - if($.data(event.target, that.widgetName + "-item") === that) { - currentItem = $(event.target); - } - - if(!currentItem) { - return false; - } - if(this.options.handle && !overrideHandle) { - $(this.options.handle, currentItem).find("*").addBack().each(function() { - if(this === event.target) { - validHandle = true; - } - }); - if(!validHandle) { - return false; - } - } - - this.currentItem = currentItem; - this._removeCurrentsFromItems(); - return true; - - }, - - _mouseStart: function(event, overrideHandle, noActivation) { - - var i, body, - o = this.options; - - this.currentContainer = this; - - //We only need to call refreshPositions, because the refreshItems call has been moved to mouseCapture - this.refreshPositions(); - - //Create and append the visible helper - this.helper = this._createHelper(event); - - //Cache the helper size - this._cacheHelperProportions(); - - /* - * - Position generation - - * This block generates everything position related - it's the core of draggables. - */ - - //Cache the margins of the original element - this._cacheMargins(); - - //Get the next scrolling parent - this.scrollParent = this.helper.scrollParent(); - - //The element's absolute position on the page minus margins - this.offset = this.currentItem.offset(); - this.offset = { - top: this.offset.top - this.margins.top, - left: this.offset.left - this.margins.left - }; - - $.extend(this.offset, { - click: { //Where the click happened, relative to the element - left: event.pageX - this.offset.left, - top: event.pageY - this.offset.top - }, - parent: this._getParentOffset(), - relative: this._getRelativeOffset() //This is a relative to absolute position minus the actual position calculation - only used for relative positioned helper - }); - - // Only after we got the offset, we can change the helper's position to absolute - // TODO: Still need to figure out a way to make relative sorting possible - this.helper.css("position", "absolute"); - this.cssPosition = this.helper.css("position"); - - //Generate the original position - this.originalPosition = this._generatePosition(event); - this.originalPageX = event.pageX; - this.originalPageY = event.pageY; - - //Adjust the mouse offset relative to the helper if "cursorAt" is supplied - (o.cursorAt && this._adjustOffsetFromHelper(o.cursorAt)); - - //Cache the former DOM position - this.domPosition = { prev: this.currentItem.prev()[0], parent: this.currentItem.parent()[0] }; - - //If the helper is not the original, hide the original so it's not playing any role during the drag, won't cause anything bad this way - if(this.helper[0] !== this.currentItem[0]) { - this.currentItem.hide(); - } - - //Create the placeholder - this._createPlaceholder(); - - //Set a containment if given in the options - if(o.containment) { - this._setContainment(); - } - - if( o.cursor && o.cursor !== "auto" ) { // cursor option - body = this.document.find( "body" ); - - // support: IE - this.storedCursor = body.css( "cursor" ); - body.css( "cursor", o.cursor ); - - this.storedStylesheet = $( "<style>*{ cursor: "+o.cursor+" !important; }</style>" ).appendTo( body ); - } - - if(o.opacity) { // opacity option - if (this.helper.css("opacity")) { - this._storedOpacity = this.helper.css("opacity"); - } - this.helper.css("opacity", o.opacity); - } - - if(o.zIndex) { // zIndex option - if (this.helper.css("zIndex")) { - this._storedZIndex = this.helper.css("zIndex"); - } - this.helper.css("zIndex", o.zIndex); - } - - //Prepare scrolling - if(this.scrollParent[0] !== document && this.scrollParent[0].tagName !== "HTML") { - this.overflowOffset = this.scrollParent.offset(); - } - - //Call callbacks - this._trigger("start", event, this._uiHash()); - - //Recache the helper size - if(!this._preserveHelperProportions) { - this._cacheHelperProportions(); - } - - - //Post "activate" events to possible containers - if( !noActivation ) { - for ( i = this.containers.length - 1; i >= 0; i-- ) { - this.containers[ i ]._trigger( "activate", event, this._uiHash( this ) ); - } - } - - //Prepare possible droppables - if($.ui.ddmanager) { - $.ui.ddmanager.current = this; - } - - if ($.ui.ddmanager && !o.dropBehaviour) { - $.ui.ddmanager.prepareOffsets(this, event); - } - - this.dragging = true; - - this.helper.addClass("ui-sortable-helper"); - this._mouseDrag(event); //Execute the drag once - this causes the helper not to be visible before getting its correct position - return true; - - }, - - _mouseDrag: function(event) { - var i, item, itemElement, intersection, - o = this.options, - scrolled = false; - - //Compute the helpers position - this.position = this._generatePosition(event); - this.positionAbs = this._convertPositionTo("absolute"); - - if (!this.lastPositionAbs) { - this.lastPositionAbs = this.positionAbs; - } - - //Do scrolling - if(this.options.scroll) { - if(this.scrollParent[0] !== document && this.scrollParent[0].tagName !== "HTML") { - - if((this.overflowOffset.top + this.scrollParent[0].offsetHeight) - event.pageY < o.scrollSensitivity) { - this.scrollParent[0].scrollTop = scrolled = this.scrollParent[0].scrollTop + o.scrollSpeed; - } else if(event.pageY - this.overflowOffset.top < o.scrollSensitivity) { - this.scrollParent[0].scrollTop = scrolled = this.scrollParent[0].scrollTop - o.scrollSpeed; - } - - if((this.overflowOffset.left + this.scrollParent[0].offsetWidth) - event.pageX < o.scrollSensitivity) { - this.scrollParent[0].scrollLeft = scrolled = this.scrollParent[0].scrollLeft + o.scrollSpeed; - } else if(event.pageX - this.overflowOffset.left < o.scrollSensitivity) { - this.scrollParent[0].scrollLeft = scrolled = this.scrollParent[0].scrollLeft - o.scrollSpeed; - } - - } else { - - if(event.pageY - $(document).scrollTop() < o.scrollSensitivity) { - scrolled = $(document).scrollTop($(document).scrollTop() - o.scrollSpeed); - } else if($(window).height() - (event.pageY - $(document).scrollTop()) < o.scrollSensitivity) { - scrolled = $(document).scrollTop($(document).scrollTop() + o.scrollSpeed); - } - - if(event.pageX - $(document).scrollLeft() < o.scrollSensitivity) { - scrolled = $(document).scrollLeft($(document).scrollLeft() - o.scrollSpeed); - } else if($(window).width() - (event.pageX - $(document).scrollLeft()) < o.scrollSensitivity) { - scrolled = $(document).scrollLeft($(document).scrollLeft() + o.scrollSpeed); - } - - } - - if(scrolled !== false && $.ui.ddmanager && !o.dropBehaviour) { - $.ui.ddmanager.prepareOffsets(this, event); - } - } - - //Regenerate the absolute position used for position checks - this.positionAbs = this._convertPositionTo("absolute"); - - //Set the helper position - if(!this.options.axis || this.options.axis !== "y") { - this.helper[0].style.left = this.position.left+"px"; - } - if(!this.options.axis || this.options.axis !== "x") { - this.helper[0].style.top = this.position.top+"px"; - } - - //Rearrange - for (i = this.items.length - 1; i >= 0; i--) { - - //Cache variables and intersection, continue if no intersection - item = this.items[i]; - itemElement = item.item[0]; - intersection = this._intersectsWithPointer(item); - if (!intersection) { - continue; - } - - // Only put the placeholder inside the current Container, skip all - // items from other containers. This works because when moving - // an item from one container to another the - // currentContainer is switched before the placeholder is moved. - // - // Without this, moving items in "sub-sortables" can cause - // the placeholder to jitter beetween the outer and inner container. - if (item.instance !== this.currentContainer) { - continue; - } - - // cannot intersect with itself - // no useless actions that have been done before - // no action if the item moved is the parent of the item checked - if (itemElement !== this.currentItem[0] && - this.placeholder[intersection === 1 ? "next" : "prev"]()[0] !== itemElement && - !$.contains(this.placeholder[0], itemElement) && - (this.options.type === "semi-dynamic" ? !$.contains(this.element[0], itemElement) : true) - ) { - - this.direction = intersection === 1 ? "down" : "up"; - - if (this.options.tolerance === "pointer" || this._intersectsWithSides(item)) { - this._rearrange(event, item); - } else { - break; - } - - this._trigger("change", event, this._uiHash()); - break; - } - } - - //Post events to containers - this._contactContainers(event); - - //Interconnect with droppables - if($.ui.ddmanager) { - $.ui.ddmanager.drag(this, event); - } - - //Call callbacks - this._trigger("sort", event, this._uiHash()); - - this.lastPositionAbs = this.positionAbs; - return false; - - }, - - _mouseStop: function(event, noPropagation) { - - if(!event) { - return; - } - - //If we are using droppables, inform the manager about the drop - if ($.ui.ddmanager && !this.options.dropBehaviour) { - $.ui.ddmanager.drop(this, event); - } - - if(this.options.revert) { - var that = this, - cur = this.placeholder.offset(), - axis = this.options.axis, - animation = {}; - - if ( !axis || axis === "x" ) { - animation.left = cur.left - this.offset.parent.left - this.margins.left + (this.offsetParent[0] === document.body ? 0 : this.offsetParent[0].scrollLeft); - } - if ( !axis || axis === "y" ) { - animation.top = cur.top - this.offset.parent.top - this.margins.top + (this.offsetParent[0] === document.body ? 0 : this.offsetParent[0].scrollTop); - } - this.reverting = true; - $(this.helper).animate( animation, parseInt(this.options.revert, 10) || 500, function() { - that._clear(event); - }); - } else { - this._clear(event, noPropagation); - } - - return false; - - }, - - cancel: function() { - - if(this.dragging) { - - this._mouseUp({ target: null }); - - if(this.options.helper === "original") { - this.currentItem.css(this._storedCSS).removeClass("ui-sortable-helper"); - } else { - this.currentItem.show(); - } - - //Post deactivating events to containers - for (var i = this.containers.length - 1; i >= 0; i--){ - this.containers[i]._trigger("deactivate", null, this._uiHash(this)); - if(this.containers[i].containerCache.over) { - this.containers[i]._trigger("out", null, this._uiHash(this)); - this.containers[i].containerCache.over = 0; - } - } - - } - - if (this.placeholder) { - //$(this.placeholder[0]).remove(); would have been the jQuery way - unfortunately, it unbinds ALL events from the original node! - if(this.placeholder[0].parentNode) { - this.placeholder[0].parentNode.removeChild(this.placeholder[0]); - } - if(this.options.helper !== "original" && this.helper && this.helper[0].parentNode) { - this.helper.remove(); - } - - $.extend(this, { - helper: null, - dragging: false, - reverting: false, - _noFinalSort: null - }); - - if(this.domPosition.prev) { - $(this.domPosition.prev).after(this.currentItem); - } else { - $(this.domPosition.parent).prepend(this.currentItem); - } - } - - return this; - - }, - - serialize: function(o) { - - var items = this._getItemsAsjQuery(o && o.connected), - str = []; - o = o || {}; - - $(items).each(function() { - var res = ($(o.item || this).attr(o.attribute || "id") || "").match(o.expression || (/(.+)[\-=_](.+)/)); - if (res) { - str.push((o.key || res[1]+"[]")+"="+(o.key && o.expression ? res[1] : res[2])); - } - }); - - if(!str.length && o.key) { - str.push(o.key + "="); - } - - return str.join("&"); - - }, - - toArray: function(o) { - - var items = this._getItemsAsjQuery(o && o.connected), - ret = []; - - o = o || {}; - - items.each(function() { ret.push($(o.item || this).attr(o.attribute || "id") || ""); }); - return ret; - - }, - - /* Be careful with the following core functions */ - _intersectsWith: function(item) { - - var x1 = this.positionAbs.left, - x2 = x1 + this.helperProportions.width, - y1 = this.positionAbs.top, - y2 = y1 + this.helperProportions.height, - l = item.left, - r = l + item.width, - t = item.top, - b = t + item.height, - dyClick = this.offset.click.top, - dxClick = this.offset.click.left, - isOverElementHeight = ( this.options.axis === "x" ) || ( ( y1 + dyClick ) > t && ( y1 + dyClick ) < b ), - isOverElementWidth = ( this.options.axis === "y" ) || ( ( x1 + dxClick ) > l && ( x1 + dxClick ) < r ), - isOverElement = isOverElementHeight && isOverElementWidth; - - if ( this.options.tolerance === "pointer" || - this.options.forcePointerForContainers || - (this.options.tolerance !== "pointer" && this.helperProportions[this.floating ? "width" : "height"] > item[this.floating ? "width" : "height"]) - ) { - return isOverElement; - } else { - - return (l < x1 + (this.helperProportions.width / 2) && // Right Half - x2 - (this.helperProportions.width / 2) < r && // Left Half - t < y1 + (this.helperProportions.height / 2) && // Bottom Half - y2 - (this.helperProportions.height / 2) < b ); // Top Half - - } - }, - - _intersectsWithPointer: function(item) { - - var isOverElementHeight = (this.options.axis === "x") || isOverAxis(this.positionAbs.top + this.offset.click.top, item.top, item.height), - isOverElementWidth = (this.options.axis === "y") || isOverAxis(this.positionAbs.left + this.offset.click.left, item.left, item.width), - isOverElement = isOverElementHeight && isOverElementWidth, - verticalDirection = this._getDragVerticalDirection(), - horizontalDirection = this._getDragHorizontalDirection(); - - if (!isOverElement) { - return false; - } - - return this.floating ? - ( ((horizontalDirection && horizontalDirection === "right") || verticalDirection === "down") ? 2 : 1 ) - : ( verticalDirection && (verticalDirection === "down" ? 2 : 1) ); - - }, - - _intersectsWithSides: function(item) { - - var isOverBottomHalf = isOverAxis(this.positionAbs.top + this.offset.click.top, item.top + (item.height/2), item.height), - isOverRightHalf = isOverAxis(this.positionAbs.left + this.offset.click.left, item.left + (item.width/2), item.width), - verticalDirection = this._getDragVerticalDirection(), - horizontalDirection = this._getDragHorizontalDirection(); - - if (this.floating && horizontalDirection) { - return ((horizontalDirection === "right" && isOverRightHalf) || (horizontalDirection === "left" && !isOverRightHalf)); - } else { - return verticalDirection && ((verticalDirection === "down" && isOverBottomHalf) || (verticalDirection === "up" && !isOverBottomHalf)); - } - - }, - - _getDragVerticalDirection: function() { - var delta = this.positionAbs.top - this.lastPositionAbs.top; - return delta !== 0 && (delta > 0 ? "down" : "up"); - }, - - _getDragHorizontalDirection: function() { - var delta = this.positionAbs.left - this.lastPositionAbs.left; - return delta !== 0 && (delta > 0 ? "right" : "left"); - }, - - refresh: function(event) { - this._refreshItems(event); - this.refreshPositions(); - return this; - }, - - _connectWith: function() { - var options = this.options; - return options.connectWith.constructor === String ? [options.connectWith] : options.connectWith; - }, - - _getItemsAsjQuery: function(connected) { - - var i, j, cur, inst, - items = [], - queries = [], - connectWith = this._connectWith(); - - if(connectWith && connected) { - for (i = connectWith.length - 1; i >= 0; i--){ - cur = $(connectWith[i]); - for ( j = cur.length - 1; j >= 0; j--){ - inst = $.data(cur[j], this.widgetFullName); - if(inst && inst !== this && !inst.options.disabled) { - queries.push([$.isFunction(inst.options.items) ? inst.options.items.call(inst.element) : $(inst.options.items, inst.element).not(".ui-sortable-helper").not(".ui-sortable-placeholder"), inst]); - } - } - } - } - - queries.push([$.isFunction(this.options.items) ? this.options.items.call(this.element, null, { options: this.options, item: this.currentItem }) : $(this.options.items, this.element).not(".ui-sortable-helper").not(".ui-sortable-placeholder"), this]); - - function addItems() { - items.push( this ); - } - for (i = queries.length - 1; i >= 0; i--){ - queries[i][0].each( addItems ); - } - - return $(items); - - }, - - _removeCurrentsFromItems: function() { - - var list = this.currentItem.find(":data(" + this.widgetName + "-item)"); - - this.items = $.grep(this.items, function (item) { - for (var j=0; j < list.length; j++) { - if(list[j] === item.item[0]) { - return false; - } - } - return true; - }); - - }, - - _refreshItems: function(event) { - - this.items = []; - this.containers = [this]; - - var i, j, cur, inst, targetData, _queries, item, queriesLength, - items = this.items, - queries = [[$.isFunction(this.options.items) ? this.options.items.call(this.element[0], event, { item: this.currentItem }) : $(this.options.items, this.element), this]], - connectWith = this._connectWith(); - - if(connectWith && this.ready) { //Shouldn't be run the first time through due to massive slow-down - for (i = connectWith.length - 1; i >= 0; i--){ - cur = $(connectWith[i]); - for (j = cur.length - 1; j >= 0; j--){ - inst = $.data(cur[j], this.widgetFullName); - if(inst && inst !== this && !inst.options.disabled) { - queries.push([$.isFunction(inst.options.items) ? inst.options.items.call(inst.element[0], event, { item: this.currentItem }) : $(inst.options.items, inst.element), inst]); - this.containers.push(inst); - } - } - } - } - - for (i = queries.length - 1; i >= 0; i--) { - targetData = queries[i][1]; - _queries = queries[i][0]; - - for (j=0, queriesLength = _queries.length; j < queriesLength; j++) { - item = $(_queries[j]); - - item.data(this.widgetName + "-item", targetData); // Data for target checking (mouse manager) - - items.push({ - item: item, - instance: targetData, - width: 0, height: 0, - left: 0, top: 0 - }); - } - } - - }, - - refreshPositions: function(fast) { - - //This has to be redone because due to the item being moved out/into the offsetParent, the offsetParent's position will change - if(this.offsetParent && this.helper) { - this.offset.parent = this._getParentOffset(); - } - - var i, item, t, p; - - for (i = this.items.length - 1; i >= 0; i--){ - item = this.items[i]; - - //We ignore calculating positions of all connected containers when we're not over them - if(item.instance !== this.currentContainer && this.currentContainer && item.item[0] !== this.currentItem[0]) { - continue; - } - - t = this.options.toleranceElement ? $(this.options.toleranceElement, item.item) : item.item; - - if (!fast) { - item.width = t.outerWidth(); - item.height = t.outerHeight(); - } - - p = t.offset(); - item.left = p.left; - item.top = p.top; - } - - if(this.options.custom && this.options.custom.refreshContainers) { - this.options.custom.refreshContainers.call(this); - } else { - for (i = this.containers.length - 1; i >= 0; i--){ - p = this.containers[i].element.offset(); - this.containers[i].containerCache.left = p.left; - this.containers[i].containerCache.top = p.top; - this.containers[i].containerCache.width = this.containers[i].element.outerWidth(); - this.containers[i].containerCache.height = this.containers[i].element.outerHeight(); - } - } - - return this; - }, - - _createPlaceholder: function(that) { - that = that || this; - var className, - o = that.options; - - if(!o.placeholder || o.placeholder.constructor === String) { - className = o.placeholder; - o.placeholder = { - element: function() { - - var nodeName = that.currentItem[0].nodeName.toLowerCase(), - element = $( "<" + nodeName + ">", that.document[0] ) - .addClass(className || that.currentItem[0].className+" ui-sortable-placeholder") - .removeClass("ui-sortable-helper"); - - if ( nodeName === "tr" ) { - that.currentItem.children().each(function() { - $( "<td> </td>", that.document[0] ) - .attr( "colspan", $( this ).attr( "colspan" ) || 1 ) - .appendTo( element ); - }); - } else if ( nodeName === "img" ) { - element.attr( "src", that.currentItem.attr( "src" ) ); - } - - if ( !className ) { - element.css( "visibility", "hidden" ); - } - - return element; - }, - update: function(container, p) { - - // 1. If a className is set as 'placeholder option, we don't force sizes - the class is responsible for that - // 2. The option 'forcePlaceholderSize can be enabled to force it even if a class name is specified - if(className && !o.forcePlaceholderSize) { - return; - } - - //If the element doesn't have a actual height by itself (without styles coming from a stylesheet), it receives the inline height from the dragged item - if(!p.height()) { p.height(that.currentItem.innerHeight() - parseInt(that.currentItem.css("paddingTop")||0, 10) - parseInt(that.currentItem.css("paddingBottom")||0, 10)); } - if(!p.width()) { p.width(that.currentItem.innerWidth() - parseInt(that.currentItem.css("paddingLeft")||0, 10) - parseInt(that.currentItem.css("paddingRight")||0, 10)); } - } - }; - } - - //Create the placeholder - that.placeholder = $(o.placeholder.element.call(that.element, that.currentItem)); - - //Append it after the actual current item - that.currentItem.after(that.placeholder); - - //Update the size of the placeholder (TODO: Logic to fuzzy, see line 316/317) - o.placeholder.update(that, that.placeholder); - - }, - - _contactContainers: function(event) { - var i, j, dist, itemWithLeastDistance, posProperty, sizeProperty, base, cur, nearBottom, floating, - innermostContainer = null, - innermostIndex = null; - - // get innermost container that intersects with item - for (i = this.containers.length - 1; i >= 0; i--) { - - // never consider a container that's located within the item itself - if($.contains(this.currentItem[0], this.containers[i].element[0])) { - continue; - } - - if(this._intersectsWith(this.containers[i].containerCache)) { - - // if we've already found a container and it's more "inner" than this, then continue - if(innermostContainer && $.contains(this.containers[i].element[0], innermostContainer.element[0])) { - continue; - } - - innermostContainer = this.containers[i]; - innermostIndex = i; - - } else { - // container doesn't intersect. trigger "out" event if necessary - if(this.containers[i].containerCache.over) { - this.containers[i]._trigger("out", event, this._uiHash(this)); - this.containers[i].containerCache.over = 0; - } - } - - } - - // if no intersecting containers found, return - if(!innermostContainer) { - return; - } - - // move the item into the container if it's not there already - if(this.containers.length === 1) { - if (!this.containers[innermostIndex].containerCache.over) { - this.containers[innermostIndex]._trigger("over", event, this._uiHash(this)); - this.containers[innermostIndex].containerCache.over = 1; - } - } else { - - //When entering a new container, we will find the item with the least distance and append our item near it - dist = 10000; - itemWithLeastDistance = null; - floating = innermostContainer.floating || isFloating(this.currentItem); - posProperty = floating ? "left" : "top"; - sizeProperty = floating ? "width" : "height"; - base = this.positionAbs[posProperty] + this.offset.click[posProperty]; - for (j = this.items.length - 1; j >= 0; j--) { - if(!$.contains(this.containers[innermostIndex].element[0], this.items[j].item[0])) { - continue; - } - if(this.items[j].item[0] === this.currentItem[0]) { - continue; - } - if (floating && !isOverAxis(this.positionAbs.top + this.offset.click.top, this.items[j].top, this.items[j].height)) { - continue; - } - cur = this.items[j].item.offset()[posProperty]; - nearBottom = false; - if(Math.abs(cur - base) > Math.abs(cur + this.items[j][sizeProperty] - base)){ - nearBottom = true; - cur += this.items[j][sizeProperty]; - } - - if(Math.abs(cur - base) < dist) { - dist = Math.abs(cur - base); itemWithLeastDistance = this.items[j]; - this.direction = nearBottom ? "up": "down"; - } - } - - //Check if dropOnEmpty is enabled - if(!itemWithLeastDistance && !this.options.dropOnEmpty) { - return; - } - - if(this.currentContainer === this.containers[innermostIndex]) { - return; - } - - itemWithLeastDistance ? this._rearrange(event, itemWithLeastDistance, null, true) : this._rearrange(event, null, this.containers[innermostIndex].element, true); - this._trigger("change", event, this._uiHash()); - this.containers[innermostIndex]._trigger("change", event, this._uiHash(this)); - this.currentContainer = this.containers[innermostIndex]; - - //Update the placeholder - this.options.placeholder.update(this.currentContainer, this.placeholder); - - this.containers[innermostIndex]._trigger("over", event, this._uiHash(this)); - this.containers[innermostIndex].containerCache.over = 1; - } - - - }, - - _createHelper: function(event) { - - var o = this.options, - helper = $.isFunction(o.helper) ? $(o.helper.apply(this.element[0], [event, this.currentItem])) : (o.helper === "clone" ? this.currentItem.clone() : this.currentItem); - - //Add the helper to the DOM if that didn't happen already - if(!helper.parents("body").length) { - $(o.appendTo !== "parent" ? o.appendTo : this.currentItem[0].parentNode)[0].appendChild(helper[0]); - } - - if(helper[0] === this.currentItem[0]) { - this._storedCSS = { width: this.currentItem[0].style.width, height: this.currentItem[0].style.height, position: this.currentItem.css("position"), top: this.currentItem.css("top"), left: this.currentItem.css("left") }; - } - - if(!helper[0].style.width || o.forceHelperSize) { - helper.width(this.currentItem.width()); - } - if(!helper[0].style.height || o.forceHelperSize) { - helper.height(this.currentItem.height()); - } - - return helper; - - }, - - _adjustOffsetFromHelper: function(obj) { - if (typeof obj === "string") { - obj = obj.split(" "); - } - if ($.isArray(obj)) { - obj = {left: +obj[0], top: +obj[1] || 0}; - } - if ("left" in obj) { - this.offset.click.left = obj.left + this.margins.left; - } - if ("right" in obj) { - this.offset.click.left = this.helperProportions.width - obj.right + this.margins.left; - } - if ("top" in obj) { - this.offset.click.top = obj.top + this.margins.top; - } - if ("bottom" in obj) { - this.offset.click.top = this.helperProportions.height - obj.bottom + this.margins.top; - } - }, - - _getParentOffset: function() { - - - //Get the offsetParent and cache its position - this.offsetParent = this.helper.offsetParent(); - var po = this.offsetParent.offset(); - - // This is a special case where we need to modify a offset calculated on start, since the following happened: - // 1. The position of the helper is absolute, so it's position is calculated based on the next positioned parent - // 2. The actual offset parent is a child of the scroll parent, and the scroll parent isn't the document, which means that - // the scroll is included in the initial calculation of the offset of the parent, and never recalculated upon drag - if(this.cssPosition === "absolute" && this.scrollParent[0] !== document && $.contains(this.scrollParent[0], this.offsetParent[0])) { - po.left += this.scrollParent.scrollLeft(); - po.top += this.scrollParent.scrollTop(); - } - - // This needs to be actually done for all browsers, since pageX/pageY includes this information - // with an ugly IE fix - if( this.offsetParent[0] === document.body || (this.offsetParent[0].tagName && this.offsetParent[0].tagName.toLowerCase() === "html" && $.ui.ie)) { - po = { top: 0, left: 0 }; - } - - return { - top: po.top + (parseInt(this.offsetParent.css("borderTopWidth"),10) || 0), - left: po.left + (parseInt(this.offsetParent.css("borderLeftWidth"),10) || 0) - }; - - }, - - _getRelativeOffset: function() { - - if(this.cssPosition === "relative") { - var p = this.currentItem.position(); - return { - top: p.top - (parseInt(this.helper.css("top"),10) || 0) + this.scrollParent.scrollTop(), - left: p.left - (parseInt(this.helper.css("left"),10) || 0) + this.scrollParent.scrollLeft() - }; - } else { - return { top: 0, left: 0 }; - } - - }, - - _cacheMargins: function() { - this.margins = { - left: (parseInt(this.currentItem.css("marginLeft"),10) || 0), - top: (parseInt(this.currentItem.css("marginTop"),10) || 0) - }; - }, - - _cacheHelperProportions: function() { - this.helperProportions = { - width: this.helper.outerWidth(), - height: this.helper.outerHeight() - }; - }, - - _setContainment: function() { - - var ce, co, over, - o = this.options; - if(o.containment === "parent") { - o.containment = this.helper[0].parentNode; - } - if(o.containment === "document" || o.containment === "window") { - this.containment = [ - 0 - this.offset.relative.left - this.offset.parent.left, - 0 - this.offset.relative.top - this.offset.parent.top, - $(o.containment === "document" ? document : window).width() - this.helperProportions.width - this.margins.left, - ($(o.containment === "document" ? document : window).height() || document.body.parentNode.scrollHeight) - this.helperProportions.height - this.margins.top - ]; - } - - if(!(/^(document|window|parent)$/).test(o.containment)) { - ce = $(o.containment)[0]; - co = $(o.containment).offset(); - over = ($(ce).css("overflow") !== "hidden"); - - this.containment = [ - co.left + (parseInt($(ce).css("borderLeftWidth"),10) || 0) + (parseInt($(ce).css("paddingLeft"),10) || 0) - this.margins.left, - co.top + (parseInt($(ce).css("borderTopWidth"),10) || 0) + (parseInt($(ce).css("paddingTop"),10) || 0) - this.margins.top, - co.left+(over ? Math.max(ce.scrollWidth,ce.offsetWidth) : ce.offsetWidth) - (parseInt($(ce).css("borderLeftWidth"),10) || 0) - (parseInt($(ce).css("paddingRight"),10) || 0) - this.helperProportions.width - this.margins.left, - co.top+(over ? Math.max(ce.scrollHeight,ce.offsetHeight) : ce.offsetHeight) - (parseInt($(ce).css("borderTopWidth"),10) || 0) - (parseInt($(ce).css("paddingBottom"),10) || 0) - this.helperProportions.height - this.margins.top - ]; - } - - }, - - _convertPositionTo: function(d, pos) { - - if(!pos) { - pos = this.position; - } - var mod = d === "absolute" ? 1 : -1, - scroll = this.cssPosition === "absolute" && !(this.scrollParent[0] !== document && $.contains(this.scrollParent[0], this.offsetParent[0])) ? this.offsetParent : this.scrollParent, - scrollIsRootNode = (/(html|body)/i).test(scroll[0].tagName); - - return { - top: ( - pos.top + // The absolute mouse position - this.offset.relative.top * mod + // Only for relative positioned nodes: Relative offset from element to offset parent - this.offset.parent.top * mod - // The offsetParent's offset without borders (offset + border) - ( ( this.cssPosition === "fixed" ? -this.scrollParent.scrollTop() : ( scrollIsRootNode ? 0 : scroll.scrollTop() ) ) * mod) - ), - left: ( - pos.left + // The absolute mouse position - this.offset.relative.left * mod + // Only for relative positioned nodes: Relative offset from element to offset parent - this.offset.parent.left * mod - // The offsetParent's offset without borders (offset + border) - ( ( this.cssPosition === "fixed" ? -this.scrollParent.scrollLeft() : scrollIsRootNode ? 0 : scroll.scrollLeft() ) * mod) - ) - }; - - }, - - _generatePosition: function(event) { - - var top, left, - o = this.options, - pageX = event.pageX, - pageY = event.pageY, - scroll = this.cssPosition === "absolute" && !(this.scrollParent[0] !== document && $.contains(this.scrollParent[0], this.offsetParent[0])) ? this.offsetParent : this.scrollParent, scrollIsRootNode = (/(html|body)/i).test(scroll[0].tagName); - - // This is another very weird special case that only happens for relative elements: - // 1. If the css position is relative - // 2. and the scroll parent is the document or similar to the offset parent - // we have to refresh the relative offset during the scroll so there are no jumps - if(this.cssPosition === "relative" && !(this.scrollParent[0] !== document && this.scrollParent[0] !== this.offsetParent[0])) { - this.offset.relative = this._getRelativeOffset(); - } - - /* - * - Position constraining - - * Constrain the position to a mix of grid, containment. - */ - - if(this.originalPosition) { //If we are not dragging yet, we won't check for options - - if(this.containment) { - if(event.pageX - this.offset.click.left < this.containment[0]) { - pageX = this.containment[0] + this.offset.click.left; - } - if(event.pageY - this.offset.click.top < this.containment[1]) { - pageY = this.containment[1] + this.offset.click.top; - } - if(event.pageX - this.offset.click.left > this.containment[2]) { - pageX = this.containment[2] + this.offset.click.left; - } - if(event.pageY - this.offset.click.top > this.containment[3]) { - pageY = this.containment[3] + this.offset.click.top; - } - } - - if(o.grid) { - top = this.originalPageY + Math.round((pageY - this.originalPageY) / o.grid[1]) * o.grid[1]; - pageY = this.containment ? ( (top - this.offset.click.top >= this.containment[1] && top - this.offset.click.top <= this.containment[3]) ? top : ((top - this.offset.click.top >= this.containment[1]) ? top - o.grid[1] : top + o.grid[1])) : top; - - left = this.originalPageX + Math.round((pageX - this.originalPageX) / o.grid[0]) * o.grid[0]; - pageX = this.containment ? ( (left - this.offset.click.left >= this.containment[0] && left - this.offset.click.left <= this.containment[2]) ? left : ((left - this.offset.click.left >= this.containment[0]) ? left - o.grid[0] : left + o.grid[0])) : left; - } - - } - - return { - top: ( - pageY - // The absolute mouse position - this.offset.click.top - // Click offset (relative to the element) - this.offset.relative.top - // Only for relative positioned nodes: Relative offset from element to offset parent - this.offset.parent.top + // The offsetParent's offset without borders (offset + border) - ( ( this.cssPosition === "fixed" ? -this.scrollParent.scrollTop() : ( scrollIsRootNode ? 0 : scroll.scrollTop() ) )) - ), - left: ( - pageX - // The absolute mouse position - this.offset.click.left - // Click offset (relative to the element) - this.offset.relative.left - // Only for relative positioned nodes: Relative offset from element to offset parent - this.offset.parent.left + // The offsetParent's offset without borders (offset + border) - ( ( this.cssPosition === "fixed" ? -this.scrollParent.scrollLeft() : scrollIsRootNode ? 0 : scroll.scrollLeft() )) - ) - }; - - }, - - _rearrange: function(event, i, a, hardRefresh) { - - a ? a[0].appendChild(this.placeholder[0]) : i.item[0].parentNode.insertBefore(this.placeholder[0], (this.direction === "down" ? i.item[0] : i.item[0].nextSibling)); - - //Various things done here to improve the performance: - // 1. we create a setTimeout, that calls refreshPositions - // 2. on the instance, we have a counter variable, that get's higher after every append - // 3. on the local scope, we copy the counter variable, and check in the timeout, if it's still the same - // 4. this lets only the last addition to the timeout stack through - this.counter = this.counter ? ++this.counter : 1; - var counter = this.counter; - - this._delay(function() { - if(counter === this.counter) { - this.refreshPositions(!hardRefresh); //Precompute after each DOM insertion, NOT on mousemove - } - }); - - }, - - _clear: function(event, noPropagation) { - - this.reverting = false; - // We delay all events that have to be triggered to after the point where the placeholder has been removed and - // everything else normalized again - var i, - delayedTriggers = []; - - // We first have to update the dom position of the actual currentItem - // Note: don't do it if the current item is already removed (by a user), or it gets reappended (see #4088) - if(!this._noFinalSort && this.currentItem.parent().length) { - this.placeholder.before(this.currentItem); - } - this._noFinalSort = null; - - if(this.helper[0] === this.currentItem[0]) { - for(i in this._storedCSS) { - if(this._storedCSS[i] === "auto" || this._storedCSS[i] === "static") { - this._storedCSS[i] = ""; - } - } - this.currentItem.css(this._storedCSS).removeClass("ui-sortable-helper"); - } else { - this.currentItem.show(); - } - - if(this.fromOutside && !noPropagation) { - delayedTriggers.push(function(event) { this._trigger("receive", event, this._uiHash(this.fromOutside)); }); - } - if((this.fromOutside || this.domPosition.prev !== this.currentItem.prev().not(".ui-sortable-helper")[0] || this.domPosition.parent !== this.currentItem.parent()[0]) && !noPropagation) { - delayedTriggers.push(function(event) { this._trigger("update", event, this._uiHash()); }); //Trigger update callback if the DOM position has changed - } - - // Check if the items Container has Changed and trigger appropriate - // events. - if (this !== this.currentContainer) { - if(!noPropagation) { - delayedTriggers.push(function(event) { this._trigger("remove", event, this._uiHash()); }); - delayedTriggers.push((function(c) { return function(event) { c._trigger("receive", event, this._uiHash(this)); }; }).call(this, this.currentContainer)); - delayedTriggers.push((function(c) { return function(event) { c._trigger("update", event, this._uiHash(this)); }; }).call(this, this.currentContainer)); - } - } - - - //Post events to containers - function delayEvent( type, instance, container ) { - return function( event ) { - container._trigger( type, event, instance._uiHash( instance ) ); - }; - } - for (i = this.containers.length - 1; i >= 0; i--){ - if (!noPropagation) { - delayedTriggers.push( delayEvent( "deactivate", this, this.containers[ i ] ) ); - } - if(this.containers[i].containerCache.over) { - delayedTriggers.push( delayEvent( "out", this, this.containers[ i ] ) ); - this.containers[i].containerCache.over = 0; - } - } - - //Do what was originally in plugins - if ( this.storedCursor ) { - this.document.find( "body" ).css( "cursor", this.storedCursor ); - this.storedStylesheet.remove(); - } - if(this._storedOpacity) { - this.helper.css("opacity", this._storedOpacity); - } - if(this._storedZIndex) { - this.helper.css("zIndex", this._storedZIndex === "auto" ? "" : this._storedZIndex); - } - - this.dragging = false; - if(this.cancelHelperRemoval) { - if(!noPropagation) { - this._trigger("beforeStop", event, this._uiHash()); - for (i=0; i < delayedTriggers.length; i++) { - delayedTriggers[i].call(this, event); - } //Trigger all delayed events - this._trigger("stop", event, this._uiHash()); - } - - this.fromOutside = false; - return false; - } - - if(!noPropagation) { - this._trigger("beforeStop", event, this._uiHash()); - } - - //$(this.placeholder[0]).remove(); would have been the jQuery way - unfortunately, it unbinds ALL events from the original node! - this.placeholder[0].parentNode.removeChild(this.placeholder[0]); - - if(this.helper[0] !== this.currentItem[0]) { - this.helper.remove(); - } - this.helper = null; - - if(!noPropagation) { - for (i=0; i < delayedTriggers.length; i++) { - delayedTriggers[i].call(this, event); - } //Trigger all delayed events - this._trigger("stop", event, this._uiHash()); - } - - this.fromOutside = false; - return true; - - }, - - _trigger: function() { - if ($.Widget.prototype._trigger.apply(this, arguments) === false) { - this.cancel(); - } - }, - - _uiHash: function(_inst) { - var inst = _inst || this; - return { - helper: inst.helper, - placeholder: inst.placeholder || $([]), - position: inst.position, - originalPosition: inst.originalPosition, - offset: inst.positionAbs, - item: inst.currentItem, - sender: _inst ? _inst.element : null - }; - } - -}); - -})(jQuery); -(function( $, undefined ) { - -// number of pages in a slider -// (how many times can you page up/down to go through the whole range) -var numPages = 5; - -$.widget( "ui.slider", $.ui.mouse, { - version: "1.10.4", - widgetEventPrefix: "slide", - - options: { - animate: false, - distance: 0, - max: 100, - min: 0, - orientation: "horizontal", - range: false, - step: 1, - value: 0, - values: null, - - // callbacks - change: null, - slide: null, - start: null, - stop: null - }, - - _create: function() { - this._keySliding = false; - this._mouseSliding = false; - this._animateOff = true; - this._handleIndex = null; - this._detectOrientation(); - this._mouseInit(); - - this.element - .addClass( "ui-slider" + - " ui-slider-" + this.orientation + - " ui-widget" + - " ui-widget-content" + - " ui-corner-all"); - - this._refresh(); - this._setOption( "disabled", this.options.disabled ); - - this._animateOff = false; - }, - - _refresh: function() { - this._createRange(); - this._createHandles(); - this._setupEvents(); - this._refreshValue(); - }, - - _createHandles: function() { - var i, handleCount, - options = this.options, - existingHandles = this.element.find( ".ui-slider-handle" ).addClass( "ui-state-default ui-corner-all" ), - handle = "<a class='ui-slider-handle ui-state-default ui-corner-all' href='#'></a>", - handles = []; - - handleCount = ( options.values && options.values.length ) || 1; - - if ( existingHandles.length > handleCount ) { - existingHandles.slice( handleCount ).remove(); - existingHandles = existingHandles.slice( 0, handleCount ); - } - - for ( i = existingHandles.length; i < handleCount; i++ ) { - handles.push( handle ); - } - - this.handles = existingHandles.add( $( handles.join( "" ) ).appendTo( this.element ) ); - - this.handle = this.handles.eq( 0 ); - - this.handles.each(function( i ) { - $( this ).data( "ui-slider-handle-index", i ); - }); - }, - - _createRange: function() { - var options = this.options, - classes = ""; - - if ( options.range ) { - if ( options.range === true ) { - if ( !options.values ) { - options.values = [ this._valueMin(), this._valueMin() ]; - } else if ( options.values.length && options.values.length !== 2 ) { - options.values = [ options.values[0], options.values[0] ]; - } else if ( $.isArray( options.values ) ) { - options.values = options.values.slice(0); - } - } - - if ( !this.range || !this.range.length ) { - this.range = $( "<div></div>" ) - .appendTo( this.element ); - - classes = "ui-slider-range" + - // note: this isn't the most fittingly semantic framework class for this element, - // but worked best visually with a variety of themes - " ui-widget-header ui-corner-all"; - } else { - this.range.removeClass( "ui-slider-range-min ui-slider-range-max" ) - // Handle range switching from true to min/max - .css({ - "left": "", - "bottom": "" - }); - } - - this.range.addClass( classes + - ( ( options.range === "min" || options.range === "max" ) ? " ui-slider-range-" + options.range : "" ) ); - } else { - if ( this.range ) { - this.range.remove(); - } - this.range = null; - } - }, - - _setupEvents: function() { - var elements = this.handles.add( this.range ).filter( "a" ); - this._off( elements ); - this._on( elements, this._handleEvents ); - this._hoverable( elements ); - this._focusable( elements ); - }, - - _destroy: function() { - this.handles.remove(); - if ( this.range ) { - this.range.remove(); - } - - this.element - .removeClass( "ui-slider" + - " ui-slider-horizontal" + - " ui-slider-vertical" + - " ui-widget" + - " ui-widget-content" + - " ui-corner-all" ); - - this._mouseDestroy(); - }, - - _mouseCapture: function( event ) { - var position, normValue, distance, closestHandle, index, allowed, offset, mouseOverHandle, - that = this, - o = this.options; - - if ( o.disabled ) { - return false; - } - - this.elementSize = { - width: this.element.outerWidth(), - height: this.element.outerHeight() - }; - this.elementOffset = this.element.offset(); - - position = { x: event.pageX, y: event.pageY }; - normValue = this._normValueFromMouse( position ); - distance = this._valueMax() - this._valueMin() + 1; - this.handles.each(function( i ) { - var thisDistance = Math.abs( normValue - that.values(i) ); - if (( distance > thisDistance ) || - ( distance === thisDistance && - (i === that._lastChangedValue || that.values(i) === o.min ))) { - distance = thisDistance; - closestHandle = $( this ); - index = i; - } - }); - - allowed = this._start( event, index ); - if ( allowed === false ) { - return false; - } - this._mouseSliding = true; - - this._handleIndex = index; - - closestHandle - .addClass( "ui-state-active" ) - .focus(); - - offset = closestHandle.offset(); - mouseOverHandle = !$( event.target ).parents().addBack().is( ".ui-slider-handle" ); - this._clickOffset = mouseOverHandle ? { left: 0, top: 0 } : { - left: event.pageX - offset.left - ( closestHandle.width() / 2 ), - top: event.pageY - offset.top - - ( closestHandle.height() / 2 ) - - ( parseInt( closestHandle.css("borderTopWidth"), 10 ) || 0 ) - - ( parseInt( closestHandle.css("borderBottomWidth"), 10 ) || 0) + - ( parseInt( closestHandle.css("marginTop"), 10 ) || 0) - }; - - if ( !this.handles.hasClass( "ui-state-hover" ) ) { - this._slide( event, index, normValue ); - } - this._animateOff = true; - return true; - }, - - _mouseStart: function() { - return true; - }, - - _mouseDrag: function( event ) { - var position = { x: event.pageX, y: event.pageY }, - normValue = this._normValueFromMouse( position ); - - this._slide( event, this._handleIndex, normValue ); - - return false; - }, - - _mouseStop: function( event ) { - this.handles.removeClass( "ui-state-active" ); - this._mouseSliding = false; - - this._stop( event, this._handleIndex ); - this._change( event, this._handleIndex ); - - this._handleIndex = null; - this._clickOffset = null; - this._animateOff = false; - - return false; - }, - - _detectOrientation: function() { - this.orientation = ( this.options.orientation === "vertical" ) ? "vertical" : "horizontal"; - }, - - _normValueFromMouse: function( position ) { - var pixelTotal, - pixelMouse, - percentMouse, - valueTotal, - valueMouse; - - if ( this.orientation === "horizontal" ) { - pixelTotal = this.elementSize.width; - pixelMouse = position.x - this.elementOffset.left - ( this._clickOffset ? this._clickOffset.left : 0 ); - } else { - pixelTotal = this.elementSize.height; - pixelMouse = position.y - this.elementOffset.top - ( this._clickOffset ? this._clickOffset.top : 0 ); - } - - percentMouse = ( pixelMouse / pixelTotal ); - if ( percentMouse > 1 ) { - percentMouse = 1; - } - if ( percentMouse < 0 ) { - percentMouse = 0; - } - if ( this.orientation === "vertical" ) { - percentMouse = 1 - percentMouse; - } - - valueTotal = this._valueMax() - this._valueMin(); - valueMouse = this._valueMin() + percentMouse * valueTotal; - - return this._trimAlignValue( valueMouse ); - }, - - _start: function( event, index ) { - var uiHash = { - handle: this.handles[ index ], - value: this.value() - }; - if ( this.options.values && this.options.values.length ) { - uiHash.value = this.values( index ); - uiHash.values = this.values(); - } - return this._trigger( "start", event, uiHash ); - }, - - _slide: function( event, index, newVal ) { - var otherVal, - newValues, - allowed; - - if ( this.options.values && this.options.values.length ) { - otherVal = this.values( index ? 0 : 1 ); - - if ( ( this.options.values.length === 2 && this.options.range === true ) && - ( ( index === 0 && newVal > otherVal) || ( index === 1 && newVal < otherVal ) ) - ) { - newVal = otherVal; - } - - if ( newVal !== this.values( index ) ) { - newValues = this.values(); - newValues[ index ] = newVal; - // A slide can be canceled by returning false from the slide callback - allowed = this._trigger( "slide", event, { - handle: this.handles[ index ], - value: newVal, - values: newValues - } ); - otherVal = this.values( index ? 0 : 1 ); - if ( allowed !== false ) { - this.values( index, newVal ); - } - } - } else { - if ( newVal !== this.value() ) { - // A slide can be canceled by returning false from the slide callback - allowed = this._trigger( "slide", event, { - handle: this.handles[ index ], - value: newVal - } ); - if ( allowed !== false ) { - this.value( newVal ); - } - } - } - }, - - _stop: function( event, index ) { - var uiHash = { - handle: this.handles[ index ], - value: this.value() - }; - if ( this.options.values && this.options.values.length ) { - uiHash.value = this.values( index ); - uiHash.values = this.values(); - } - - this._trigger( "stop", event, uiHash ); - }, - - _change: function( event, index ) { - if ( !this._keySliding && !this._mouseSliding ) { - var uiHash = { - handle: this.handles[ index ], - value: this.value() - }; - if ( this.options.values && this.options.values.length ) { - uiHash.value = this.values( index ); - uiHash.values = this.values(); - } - - //store the last changed value index for reference when handles overlap - this._lastChangedValue = index; - - this._trigger( "change", event, uiHash ); - } - }, - - value: function( newValue ) { - if ( arguments.length ) { - this.options.value = this._trimAlignValue( newValue ); - this._refreshValue(); - this._change( null, 0 ); - return; - } - - return this._value(); - }, - - values: function( index, newValue ) { - var vals, - newValues, - i; - - if ( arguments.length > 1 ) { - this.options.values[ index ] = this._trimAlignValue( newValue ); - this._refreshValue(); - this._change( null, index ); - return; - } - - if ( arguments.length ) { - if ( $.isArray( arguments[ 0 ] ) ) { - vals = this.options.values; - newValues = arguments[ 0 ]; - for ( i = 0; i < vals.length; i += 1 ) { - vals[ i ] = this._trimAlignValue( newValues[ i ] ); - this._change( null, i ); - } - this._refreshValue(); - } else { - if ( this.options.values && this.options.values.length ) { - return this._values( index ); - } else { - return this.value(); - } - } - } else { - return this._values(); - } - }, - - _setOption: function( key, value ) { - var i, - valsLength = 0; - - if ( key === "range" && this.options.range === true ) { - if ( value === "min" ) { - this.options.value = this._values( 0 ); - this.options.values = null; - } else if ( value === "max" ) { - this.options.value = this._values( this.options.values.length-1 ); - this.options.values = null; - } - } - - if ( $.isArray( this.options.values ) ) { - valsLength = this.options.values.length; - } - - $.Widget.prototype._setOption.apply( this, arguments ); - - switch ( key ) { - case "orientation": - this._detectOrientation(); - this.element - .removeClass( "ui-slider-horizontal ui-slider-vertical" ) - .addClass( "ui-slider-" + this.orientation ); - this._refreshValue(); - break; - case "value": - this._animateOff = true; - this._refreshValue(); - this._change( null, 0 ); - this._animateOff = false; - break; - case "values": - this._animateOff = true; - this._refreshValue(); - for ( i = 0; i < valsLength; i += 1 ) { - this._change( null, i ); - } - this._animateOff = false; - break; - case "min": - case "max": - this._animateOff = true; - this._refreshValue(); - this._animateOff = false; - break; - case "range": - this._animateOff = true; - this._refresh(); - this._animateOff = false; - break; - } - }, - - //internal value getter - // _value() returns value trimmed by min and max, aligned by step - _value: function() { - var val = this.options.value; - val = this._trimAlignValue( val ); - - return val; - }, - - //internal values getter - // _values() returns array of values trimmed by min and max, aligned by step - // _values( index ) returns single value trimmed by min and max, aligned by step - _values: function( index ) { - var val, - vals, - i; - - if ( arguments.length ) { - val = this.options.values[ index ]; - val = this._trimAlignValue( val ); - - return val; - } else if ( this.options.values && this.options.values.length ) { - // .slice() creates a copy of the array - // this copy gets trimmed by min and max and then returned - vals = this.options.values.slice(); - for ( i = 0; i < vals.length; i+= 1) { - vals[ i ] = this._trimAlignValue( vals[ i ] ); - } - - return vals; - } else { - return []; - } - }, - - // returns the step-aligned value that val is closest to, between (inclusive) min and max - _trimAlignValue: function( val ) { - if ( val <= this._valueMin() ) { - return this._valueMin(); - } - if ( val >= this._valueMax() ) { - return this._valueMax(); - } - var step = ( this.options.step > 0 ) ? this.options.step : 1, - valModStep = (val - this._valueMin()) % step, - alignValue = val - valModStep; - - if ( Math.abs(valModStep) * 2 >= step ) { - alignValue += ( valModStep > 0 ) ? step : ( -step ); - } - - // Since JavaScript has problems with large floats, round - // the final value to 5 digits after the decimal point (see #4124) - return parseFloat( alignValue.toFixed(5) ); - }, - - _valueMin: function() { - return this.options.min; - }, - - _valueMax: function() { - return this.options.max; - }, - - _refreshValue: function() { - var lastValPercent, valPercent, value, valueMin, valueMax, - oRange = this.options.range, - o = this.options, - that = this, - animate = ( !this._animateOff ) ? o.animate : false, - _set = {}; - - if ( this.options.values && this.options.values.length ) { - this.handles.each(function( i ) { - valPercent = ( that.values(i) - that._valueMin() ) / ( that._valueMax() - that._valueMin() ) * 100; - _set[ that.orientation === "horizontal" ? "left" : "bottom" ] = valPercent + "%"; - $( this ).stop( 1, 1 )[ animate ? "animate" : "css" ]( _set, o.animate ); - if ( that.options.range === true ) { - if ( that.orientation === "horizontal" ) { - if ( i === 0 ) { - that.range.stop( 1, 1 )[ animate ? "animate" : "css" ]( { left: valPercent + "%" }, o.animate ); - } - if ( i === 1 ) { - that.range[ animate ? "animate" : "css" ]( { width: ( valPercent - lastValPercent ) + "%" }, { queue: false, duration: o.animate } ); - } - } else { - if ( i === 0 ) { - that.range.stop( 1, 1 )[ animate ? "animate" : "css" ]( { bottom: ( valPercent ) + "%" }, o.animate ); - } - if ( i === 1 ) { - that.range[ animate ? "animate" : "css" ]( { height: ( valPercent - lastValPercent ) + "%" }, { queue: false, duration: o.animate } ); - } - } - } - lastValPercent = valPercent; - }); - } else { - value = this.value(); - valueMin = this._valueMin(); - valueMax = this._valueMax(); - valPercent = ( valueMax !== valueMin ) ? - ( value - valueMin ) / ( valueMax - valueMin ) * 100 : - 0; - _set[ this.orientation === "horizontal" ? "left" : "bottom" ] = valPercent + "%"; - this.handle.stop( 1, 1 )[ animate ? "animate" : "css" ]( _set, o.animate ); - - if ( oRange === "min" && this.orientation === "horizontal" ) { - this.range.stop( 1, 1 )[ animate ? "animate" : "css" ]( { width: valPercent + "%" }, o.animate ); - } - if ( oRange === "max" && this.orientation === "horizontal" ) { - this.range[ animate ? "animate" : "css" ]( { width: ( 100 - valPercent ) + "%" }, { queue: false, duration: o.animate } ); - } - if ( oRange === "min" && this.orientation === "vertical" ) { - this.range.stop( 1, 1 )[ animate ? "animate" : "css" ]( { height: valPercent + "%" }, o.animate ); - } - if ( oRange === "max" && this.orientation === "vertical" ) { - this.range[ animate ? "animate" : "css" ]( { height: ( 100 - valPercent ) + "%" }, { queue: false, duration: o.animate } ); - } - } - }, - - _handleEvents: { - keydown: function( event ) { - var allowed, curVal, newVal, step, - index = $( event.target ).data( "ui-slider-handle-index" ); - - switch ( event.keyCode ) { - case $.ui.keyCode.HOME: - case $.ui.keyCode.END: - case $.ui.keyCode.PAGE_UP: - case $.ui.keyCode.PAGE_DOWN: - case $.ui.keyCode.UP: - case $.ui.keyCode.RIGHT: - case $.ui.keyCode.DOWN: - case $.ui.keyCode.LEFT: - event.preventDefault(); - if ( !this._keySliding ) { - this._keySliding = true; - $( event.target ).addClass( "ui-state-active" ); - allowed = this._start( event, index ); - if ( allowed === false ) { - return; - } - } - break; - } - - step = this.options.step; - if ( this.options.values && this.options.values.length ) { - curVal = newVal = this.values( index ); - } else { - curVal = newVal = this.value(); - } - - switch ( event.keyCode ) { - case $.ui.keyCode.HOME: - newVal = this._valueMin(); - break; - case $.ui.keyCode.END: - newVal = this._valueMax(); - break; - case $.ui.keyCode.PAGE_UP: - newVal = this._trimAlignValue( curVal + ( (this._valueMax() - this._valueMin()) / numPages ) ); - break; - case $.ui.keyCode.PAGE_DOWN: - newVal = this._trimAlignValue( curVal - ( (this._valueMax() - this._valueMin()) / numPages ) ); - break; - case $.ui.keyCode.UP: - case $.ui.keyCode.RIGHT: - if ( curVal === this._valueMax() ) { - return; - } - newVal = this._trimAlignValue( curVal + step ); - break; - case $.ui.keyCode.DOWN: - case $.ui.keyCode.LEFT: - if ( curVal === this._valueMin() ) { - return; - } - newVal = this._trimAlignValue( curVal - step ); - break; - } - - this._slide( event, index, newVal ); - }, - click: function( event ) { - event.preventDefault(); - }, - keyup: function( event ) { - var index = $( event.target ).data( "ui-slider-handle-index" ); - - if ( this._keySliding ) { - this._keySliding = false; - this._stop( event, index ); - this._change( event, index ); - $( event.target ).removeClass( "ui-state-active" ); - } - } - } - -}); - -}(jQuery)); diff --git a/src/UI/JsLibraries/jquery.backstretch.js b/src/UI/JsLibraries/jquery.backstretch.js deleted file mode 100644 index 7ac0875f8..000000000 --- a/src/UI/JsLibraries/jquery.backstretch.js +++ /dev/null @@ -1,377 +0,0 @@ -/*! Backstretch - v2.0.4 - 2013-06-19 -* http://srobbin.com/jquery-plugins/backstretch/ -* Copyright (c) 2013 Scott Robbin; Licensed MIT */ - -;(function ($, window, undefined) { - 'use strict'; - - /* PLUGIN DEFINITION - * ========================= */ - - $.fn.backstretch = function (images, options) { - // We need at least one image or method name - if (images === undefined || images.length === 0) { - $.error("No images were supplied for Backstretch"); - } - - /* - * Scroll the page one pixel to get the right window height on iOS - * Pretty harmless for everyone else - */ - if ($(window).scrollTop() === 0 ) { - window.scrollTo(0, 0); - } - - return this.each(function () { - var $this = $(this) - , obj = $this.data('backstretch'); - - // Do we already have an instance attached to this element? - if (obj) { - - // Is this a method they're trying to execute? - if (typeof images == 'string' && typeof obj[images] == 'function') { - // Call the method - obj[images](options); - - // No need to do anything further - return; - } - - // Merge the old options with the new - options = $.extend(obj.options, options); - - // Remove the old instance - obj.destroy(true); - } - - obj = new Backstretch(this, images, options); - $this.data('backstretch', obj); - }); - }; - - // If no element is supplied, we'll attach to body - $.backstretch = function (images, options) { - // Return the instance - return $('body') - .backstretch(images, options) - .data('backstretch'); - }; - - // Custom selector - $.expr[':'].backstretch = function(elem) { - return $(elem).data('backstretch') !== undefined; - }; - - /* DEFAULTS - * ========================= */ - - $.fn.backstretch.defaults = { - centeredX: true // Should we center the image on the X axis? - , centeredY: true // Should we center the image on the Y axis? - , duration: 5000 // Amount of time in between slides (if slideshow) - , fade: 0 // Speed of fade transition between slides - }; - - /* STYLES - * - * Baked-in styles that we'll apply to our elements. - * In an effort to keep the plugin simple, these are not exposed as options. - * That said, anyone can override these in their own stylesheet. - * ========================= */ - var styles = { - wrap: { - left: 0 - , top: 0 - , overflow: 'hidden' - , margin: 0 - , padding: 0 - , height: '100%' - , width: '100%' - , zIndex: -999999 - } - , img: { - position: 'absolute' - , display: 'none' - , margin: 0 - , padding: 0 - , border: 'none' - , width: 'auto' - , height: 'auto' - , maxHeight: 'none' - , maxWidth: 'none' - , zIndex: -999999 - } - }; - - /* CLASS DEFINITION - * ========================= */ - var Backstretch = function (container, images, options) { - this.options = $.extend({}, $.fn.backstretch.defaults, options || {}); - - /* In its simplest form, we allow Backstretch to be called on an image path. - * e.g. $.backstretch('/path/to/image.jpg') - * So, we need to turn this back into an array. - */ - this.images = $.isArray(images) ? images : [images]; - - // Preload images - $.each(this.images, function () { - $('<img />')[0].src = this; - }); - - // Convenience reference to know if the container is body. - this.isBody = container === document.body; - - /* We're keeping track of a few different elements - * - * Container: the element that Backstretch was called on. - * Wrap: a DIV that we place the image into, so we can hide the overflow. - * Root: Convenience reference to help calculate the correct height. - */ - this.$container = $(container); - this.$root = this.isBody ? supportsFixedPosition ? $(window) : $(document) : this.$container; - - // Don't create a new wrap if one already exists (from a previous instance of Backstretch) - var $existing = this.$container.children(".backstretch").first(); - this.$wrap = $existing.length ? $existing : $('<div class="backstretch"></div>').css(styles.wrap).appendTo(this.$container); - - // Non-body elements need some style adjustments - if (!this.isBody) { - // If the container is statically positioned, we need to make it relative, - // and if no zIndex is defined, we should set it to zero. - var position = this.$container.css('position') - , zIndex = this.$container.css('zIndex'); - - this.$container.css({ - position: position === 'static' ? 'relative' : position - , zIndex: zIndex === 'auto' ? 0 : zIndex - , background: 'none' - }); - - // Needs a higher z-index - this.$wrap.css({zIndex: -999998}); - } - - // Fixed or absolute positioning? - this.$wrap.css({ - position: this.isBody && supportsFixedPosition ? 'fixed' : 'absolute' - }); - - // Set the first image - this.index = 0; - this.show(this.index); - - // Listen for resize - $(window).on('resize.backstretch', $.proxy(this.resize, this)) - .on('orientationchange.backstretch', $.proxy(function () { - // Need to do this in order to get the right window height - if (this.isBody && window.pageYOffset === 0) { - window.scrollTo(0, 1); - this.resize(); - } - }, this)); - }; - - /* PUBLIC METHODS - * ========================= */ - Backstretch.prototype = { - resize: function () { - try { - var bgCSS = {left: 0, top: 0} - , rootWidth = this.isBody ? this.$root.width() : this.$root.innerWidth() - , bgWidth = rootWidth - , rootHeight = this.isBody ? ( window.innerHeight ? window.innerHeight : this.$root.height() ) : this.$root.innerHeight() - , bgHeight = bgWidth / this.$img.data('ratio') - , bgOffset; - - // Make adjustments based on image ratio - if (bgHeight >= rootHeight) { - bgOffset = (bgHeight - rootHeight) / 2; - if(this.options.centeredY) { - bgCSS.top = '-' + bgOffset + 'px'; - } - } else { - bgHeight = rootHeight; - bgWidth = bgHeight * this.$img.data('ratio'); - bgOffset = (bgWidth - rootWidth) / 2; - if(this.options.centeredX) { - bgCSS.left = '-' + bgOffset + 'px'; - } - } - - this.$wrap.css({width: rootWidth, height: rootHeight}) - .find('img:not(.deleteable)').css({width: bgWidth, height: bgHeight}).css(bgCSS); - } catch(err) { - // IE7 seems to trigger resize before the image is loaded. - // This try/catch block is a hack to let it fail gracefully. - } - - return this; - } - - // Show the slide at a certain position - , show: function (newIndex) { - - // Validate index - if (Math.abs(newIndex) > this.images.length - 1) { - return; - } - - // Vars - var self = this - , oldImage = self.$wrap.find('img').addClass('deleteable') - , evtOptions = { relatedTarget: self.$container[0] }; - - // Trigger the "before" event - self.$container.trigger($.Event('backstretch.before', evtOptions), [self, newIndex]); - - // Set the new index - this.index = newIndex; - - // Pause the slideshow - clearInterval(self.interval); - - // New image - self.$img = $('<img />') - .css(styles.img) - .bind('load', function (e) { - var imgWidth = this.width || $(e.target).width() - , imgHeight = this.height || $(e.target).height(); - - // Save the ratio - $(this).data('ratio', imgWidth / imgHeight); - - // Show the image, then delete the old one - // "speed" option has been deprecated, but we want backwards compatibilty - $(this).fadeIn(self.options.speed || self.options.fade, function () { - oldImage.remove(); - - // Resume the slideshow - if (!self.paused) { - self.cycle(); - } - - // Trigger the "after" and "show" events - // "show" is being deprecated - $(['after', 'show']).each(function () { - self.$container.trigger($.Event('backstretch.' + this, evtOptions), [self, newIndex]); - }); - }); - - // Resize - self.resize(); - }) - .appendTo(self.$wrap); - - // Hack for IE img onload event - self.$img.attr('src', self.images[newIndex]); - return self; - } - - , next: function () { - // Next slide - return this.show(this.index < this.images.length - 1 ? this.index + 1 : 0); - } - - , prev: function () { - // Previous slide - return this.show(this.index === 0 ? this.images.length - 1 : this.index - 1); - } - - , pause: function () { - // Pause the slideshow - this.paused = true; - return this; - } - - , resume: function () { - // Resume the slideshow - this.paused = false; - this.next(); - return this; - } - - , cycle: function () { - // Start/resume the slideshow - if(this.images.length > 1) { - // Clear the interval, just in case - clearInterval(this.interval); - - this.interval = setInterval($.proxy(function () { - // Check for paused slideshow - if (!this.paused) { - this.next(); - } - }, this), this.options.duration); - } - return this; - } - - , destroy: function (preserveBackground) { - // Stop the resize events - $(window).off('resize.backstretch orientationchange.backstretch'); - - // Clear the interval - clearInterval(this.interval); - - // Remove Backstretch - if(!preserveBackground) { - this.$wrap.remove(); - } - this.$container.removeData('backstretch'); - } - }; - - /* SUPPORTS FIXED POSITION? - * - * Based on code from jQuery Mobile 1.1.0 - * http://jquerymobile.com/ - * - * In a nutshell, we need to figure out if fixed positioning is supported. - * Unfortunately, this is very difficult to do on iOS, and usually involves - * injecting content, scrolling the page, etc.. It's ugly. - * jQuery Mobile uses this workaround. It's not ideal, but works. - * - * Modified to detect IE6 - * ========================= */ - - var supportsFixedPosition = (function () { - var ua = navigator.userAgent - , platform = navigator.platform - // Rendering engine is Webkit, and capture major version - , wkmatch = ua.match( /AppleWebKit\/([0-9]+)/ ) - , wkversion = !!wkmatch && wkmatch[ 1 ] - , ffmatch = ua.match( /Fennec\/([0-9]+)/ ) - , ffversion = !!ffmatch && ffmatch[ 1 ] - , operammobilematch = ua.match( /Opera Mobi\/([0-9]+)/ ) - , omversion = !!operammobilematch && operammobilematch[ 1 ] - , iematch = ua.match( /MSIE ([0-9]+)/ ) - , ieversion = !!iematch && iematch[ 1 ]; - - return !( - // iOS 4.3 and older : Platform is iPhone/Pad/Touch and Webkit version is less than 534 (ios5) - ((platform.indexOf( "iPhone" ) > -1 || platform.indexOf( "iPad" ) > -1 || platform.indexOf( "iPod" ) > -1 ) && wkversion && wkversion < 534) || - - // Opera Mini - (window.operamini && ({}).toString.call( window.operamini ) === "[object OperaMini]") || - (operammobilematch && omversion < 7458) || - - //Android lte 2.1: Platform is Android and Webkit version is less than 533 (Android 2.2) - (ua.indexOf( "Android" ) > -1 && wkversion && wkversion < 533) || - - // Firefox Mobile before 6.0 - - (ffversion && ffversion < 6) || - - // WebOS less than 3 - ("palmGetResource" in window && wkversion && wkversion < 534) || - - // MeeGo - (ua.indexOf( "MeeGo" ) > -1 && ua.indexOf( "NokiaBrowser/8.5.0" ) > -1) || - - // IE6 - (ieversion && ieversion <= 6) - ); - }()); - -}(jQuery, window)); \ No newline at end of file diff --git a/src/UI/JsLibraries/jquery.dotdotdot.js b/src/UI/JsLibraries/jquery.dotdotdot.js deleted file mode 100644 index c7f58d039..000000000 --- a/src/UI/JsLibraries/jquery.dotdotdot.js +++ /dev/null @@ -1,632 +0,0 @@ -/* - * jQuery dotdotdot 1.6.1 - * - * Copyright (c) 2013 Fred Heusschen - * www.frebsite.nl - * - * Plugin website: - * dotdotdot.frebsite.nl - * - * Dual licensed under the MIT and GPL licenses. - * http://en.wikipedia.org/wiki/MIT_License - * http://en.wikipedia.org/wiki/GNU_General_Public_License - */ - -(function( $ ) -{ - if ( $.fn.dotdotdot ) - { - return; - } - - $.fn.dotdotdot = function( o ) - { - if ( this.length == 0 ) - { - if ( !o || o.debug !== false ) - { - debug( true, 'No element found for "' + this.selector + '".' ); - } - return this; - } - if ( this.length > 1 ) - { - return this.each( - function() - { - $(this).dotdotdot( o ); - } - ); - } - - - var $dot = this; - - if ( $dot.data( 'dotdotdot' ) ) - { - $dot.trigger( 'destroy.dot' ); - } - - $dot.data( 'dotdotdot-style', $dot.attr( 'style' ) ); - $dot.css( 'word-wrap', 'break-word' ); - if ($dot.css( 'white-space' ) === 'nowrap') - { - $dot.css( 'white-space', 'normal' ); - } - - $dot.bind_events = function() - { - $dot.bind( - 'update.dot', - function( e, c ) - { - e.preventDefault(); - e.stopPropagation(); - - opts.maxHeight = ( typeof opts.height == 'number' ) - ? opts.height - : getTrueInnerHeight( $dot ); - - opts.maxHeight += opts.tolerance; - - if ( typeof c != 'undefined' ) - { - if ( typeof c == 'string' || c instanceof HTMLElement ) - { - c = $('<div />').append( c ).contents(); - } - if ( c instanceof $ ) - { - orgContent = c; - } - } - - $inr = $dot.wrapInner( '<div class="dotdotdot" />' ).children(); - $inr.empty() - .append( orgContent.clone( true ) ) - .css({ - 'height' : 'auto', - 'width' : 'auto', - 'border' : 'none', - 'padding' : 0, - 'margin' : 0 - }); - - var after = false, - trunc = false; - - if ( conf.afterElement ) - { - after = conf.afterElement.clone( true ); - conf.afterElement.remove(); - } - if ( test( $inr, opts ) ) - { - if ( opts.wrap == 'children' ) - { - trunc = children( $inr, opts, after ); - } - else - { - trunc = ellipsis( $inr, $dot, $inr, opts, after ); - } - } - $inr.replaceWith( $inr.contents() ); - $inr = null; - - if ( $.isFunction( opts.callback ) ) - { - opts.callback.call( $dot[ 0 ], trunc, orgContent ); - } - - conf.isTruncated = trunc; - return trunc; - } - - ).bind( - 'isTruncated.dot', - function( e, fn ) - { - e.preventDefault(); - e.stopPropagation(); - - if ( typeof fn == 'function' ) - { - fn.call( $dot[ 0 ], conf.isTruncated ); - } - return conf.isTruncated; - } - - ).bind( - 'originalContent.dot', - function( e, fn ) - { - e.preventDefault(); - e.stopPropagation(); - - if ( typeof fn == 'function' ) - { - fn.call( $dot[ 0 ], orgContent ); - } - return orgContent; - } - - ).bind( - 'destroy.dot', - function( e ) - { - e.preventDefault(); - e.stopPropagation(); - - $dot.unwatch() - .unbind_events() - .empty() - .append( orgContent ) - .attr( 'style', $dot.data( 'dotdotdot-style' ) ) - .data( 'dotdotdot', false ); - } - ); - return $dot; - }; // /bind_events - - $dot.unbind_events = function() - { - $dot.unbind('.dot'); - return $dot; - }; // /unbind_events - - $dot.watch = function() - { - $dot.unwatch(); - if ( opts.watch == 'window' ) - { - var $window = $(window), - _wWidth = $window.width(), - _wHeight = $window.height(); - - $window.bind( - 'resize.dot' + conf.dotId, - function() - { - if ( _wWidth != $window.width() || _wHeight != $window.height() || !opts.windowResizeFix ) - { - _wWidth = $window.width(); - _wHeight = $window.height(); - - if ( watchInt ) - { - clearInterval( watchInt ); - } - watchInt = setTimeout( - function() - { - $dot.trigger( 'update.dot' ); - }, 10 - ); - } - } - ); - } - else - { - watchOrg = getSizes( $dot ); - watchInt = setInterval( - function() - { - var watchNew = getSizes( $dot ); - if ( watchOrg.width != watchNew.width || - watchOrg.height != watchNew.height ) - { - $dot.trigger( 'update.dot' ); - watchOrg = getSizes( $dot ); - } - }, 100 - ); - } - return $dot; - }; - $dot.unwatch = function() - { - $(window).unbind( 'resize.dot' + conf.dotId ); - if ( watchInt ) - { - clearInterval( watchInt ); - } - return $dot; - }; - - var orgContent = $dot.contents(), - opts = $.extend( true, {}, $.fn.dotdotdot.defaults, o ), - conf = {}, - watchOrg = {}, - watchInt = null, - $inr = null; - - - if ( !( opts.lastCharacter.remove instanceof Array ) ) - { - opts.lastCharacter.remove = $.fn.dotdotdot.defaultArrays.lastCharacter.remove; - } - if ( !( opts.lastCharacter.noEllipsis instanceof Array ) ) - { - opts.lastCharacter.noEllipsis = $.fn.dotdotdot.defaultArrays.lastCharacter.noEllipsis; - } - - - conf.afterElement = getElement( opts.after, $dot ); - conf.isTruncated = false; - conf.dotId = dotId++; - - - $dot.data( 'dotdotdot', true ) - .bind_events() - .trigger( 'update.dot' ); - - if ( opts.watch ) - { - $dot.watch(); - } - - return $dot; - }; - - - // public - $.fn.dotdotdot.defaults = { - 'ellipsis' : '... ', - 'wrap' : 'word', - 'fallbackToLetter' : true, - 'lastCharacter' : {}, - 'tolerance' : 0, - 'callback' : null, - 'after' : null, - 'height' : null, - 'watch' : false, - 'windowResizeFix' : true, - 'debug' : false - }; - $.fn.dotdotdot.defaultArrays = { - 'lastCharacter' : { - 'remove' : [ ' ', '\u3000', ',', ';', '.', '!', '?' ], - 'noEllipsis' : [] - } - }; - - - // private - var dotId = 1; - - function children( $elem, o, after ) - { - var $elements = $elem.children(), - isTruncated = false; - - $elem.empty(); - - for ( var a = 0, l = $elements.length; a < l; a++ ) - { - var $e = $elements.eq( a ); - $elem.append( $e ); - if ( after ) - { - $elem.append( after ); - } - if ( test( $elem, o ) ) - { - $e.remove(); - isTruncated = true; - break; - } - else - { - if ( after ) - { - after.detach(); - } - } - } - return isTruncated; - } - function ellipsis( $elem, $d, $i, o, after ) - { - var $elements = $elem.contents(), - isTruncated = false; - - $elem.empty(); - - var notx = 'table, thead, tbody, tfoot, tr, col, colgroup, object, embed, param, ol, ul, dl, blockquote, select, optgroup, option, textarea, script, style'; - for ( var a = 0, l = $elements.length; a < l; a++ ) - { - - if ( isTruncated ) - { - break; - } - - var e = $elements[ a ], - $e = $(e); - - if ( typeof e == 'undefined' ) - { - continue; - } - - $elem.append( $e ); - if ( after ) - { - $elem[ ( $elem.is( notx ) ) ? 'after' : 'append' ]( after ); - } - if ( e.nodeType == 3 ) - { - if ( test( $i, o ) ) - { - isTruncated = ellipsisElement( $e, $d, $i, o, after ); - } - } - else - { - isTruncated = ellipsis( $e, $d, $i, o, after ); - } - - if ( !isTruncated ) - { - if ( after ) - { - after.detach(); - } - } - } - return isTruncated; - } - function ellipsisElement( $e, $d, $i, o, after ) - { - var isTruncated = false, - e = $e[ 0 ]; - - if ( typeof e == 'undefined' ) - { - return false; - } - - var txt = getTextContent( e ), - space = ( txt.indexOf(' ') !== -1 ) ? ' ' : '\u3000', - separator = ( o.wrap == 'letter' ) ? '' : space, - textArr = txt.split( separator ), - position = -1, - midPos = -1, - startPos = 0, - endPos = textArr.length - 1; - - while ( startPos <= endPos && !( startPos == 0 && endPos == 0 ) ) - { - var m = Math.floor( ( startPos + endPos ) / 2 ); - if ( m == midPos ) - { - break; - } - midPos = m; - - setTextContent( e, textArr.slice( 0, midPos + 1 ).join( separator ) + o.ellipsis ); - - if ( !test( $i, o ) ) - { - position = midPos; - startPos = midPos; - } - else - { - endPos = midPos; - } - if( endPos == startPos && endPos == 0 && o.fallbackToLetter ) - { - separator = ''; - textArr = textArr[0].split(separator); - position = -1; - midPos = -1; - startPos = 0; - endPos = textArr.length - 1; - } - } - - if ( position != -1 && !( textArr.length == 1 && textArr[ 0 ].length == 0 ) ) - { - txt = addEllipsis( textArr.slice( 0, position + 1 ).join( separator ), o ); - isTruncated = true; - setTextContent( e, txt ); - } - else - { - var $w = $e.parent(); - $e.remove(); - - var afterLength = ( after ) ? after.length : 0 ; - - if ( $w.contents().size() > afterLength ) - { - var $n = $w.contents().eq( -1 - afterLength ); - isTruncated = ellipsisElement( $n, $d, $i, o, after ); - } - else - { - var $p = $w.prev() - var e = $p.contents().eq( -1 )[ 0 ]; - - if ( typeof e != 'undefined' ) - { - var txt = addEllipsis( getTextContent( e ), o ); - setTextContent( e, txt ); - if ( after ) - { - $p.append( after ); - } - $w.remove(); - isTruncated = true; - } - - } - } - - return isTruncated; - } - function test( $i, o ) - { - return $i.innerHeight() > o.maxHeight; - } - function addEllipsis( txt, o ) - { - while( $.inArray( txt.slice( -1 ), o.lastCharacter.remove ) > -1 ) - { - txt = txt.slice( 0, -1 ); - } - if ( $.inArray( txt.slice( -1 ), o.lastCharacter.noEllipsis ) < 0 ) - { - txt += o.ellipsis; - } - return txt; - } - function getSizes( $d ) - { - return { - 'width' : $d.innerWidth(), - 'height': $d.innerHeight() - }; - } - function setTextContent( e, content ) - { - if ( e.innerText ) - { - e.innerText = content; - } - else if ( e.nodeValue ) - { - e.nodeValue = content; - } - else if (e.textContent) - { - e.textContent = content; - } - - } - function getTextContent( e ) - { - if ( e.innerText ) - { - return e.innerText; - } - else if ( e.nodeValue ) - { - return e.nodeValue; - } - else if ( e.textContent ) - { - return e.textContent; - } - else - { - return ""; - } - } - function getElement( e, $i ) - { - if ( typeof e == 'undefined' ) - { - return false; - } - if ( !e ) - { - return false; - } - if ( typeof e == 'string' ) - { - e = $(e, $i); - return ( e.length ) - ? e - : false; - } - if ( typeof e == 'object' ) - { - return ( typeof e.jquery == 'undefined' ) - ? false - : e; - } - return false; - } - function getTrueInnerHeight( $el ) - { - var h = $el.innerHeight(), - a = [ 'paddingTop', 'paddingBottom' ]; - - for ( var z = 0, l = a.length; z < l; z++ ) { - var m = parseInt( $el.css( a[ z ] ), 10 ); - if ( isNaN( m ) ) - { - m = 0; - } - h -= m; - } - return h; - } - function debug( d, m ) - { - if ( !d ) - { - return false; - } - if ( typeof m == 'string' ) - { - m = 'dotdotdot: ' + m; - } - else - { - m = [ 'dotdotdot:', m ]; - } - - if ( typeof window.console != 'undefined' ) - { - if ( typeof window.console.log != 'undefined' ) - { - window.console.log( m ); - } - } - return false; - } - - - // override jQuery.html - var _orgHtml = $.fn.html; - $.fn.html = function( str ) { - if ( typeof str != 'undefined' ) - { - if ( this.data( 'dotdotdot' ) ) - { - if ( typeof str != 'function' ) - { - return this.trigger( 'update', [ str ] ); - } - } - return _orgHtml.call( this, str ); - } - return _orgHtml.call( this ); - }; - - - // override jQuery.text - var _orgText = $.fn.text; - $.fn.text = function( str ) { - if ( typeof str != 'undefined' ) - { - if ( this.data( 'dotdotdot' ) ) - { - var temp = $( '<div />' ); - temp.text( str ); - str = temp.html(); - temp.remove(); - return this.trigger( 'update', [ str ] ); - } - return _orgText.call( this, str ); - } - return _orgText.call( this ); - }; - - -})( jQuery ); diff --git a/src/UI/JsLibraries/jquery.easypiechart.js b/src/UI/JsLibraries/jquery.easypiechart.js deleted file mode 100644 index c600fb85f..000000000 --- a/src/UI/JsLibraries/jquery.easypiechart.js +++ /dev/null @@ -1,357 +0,0 @@ -/**! - * easyPieChart - * Lightweight plugin to render simple, animated and retina optimized pie charts - * - * @license - * @author Robert Fleischmann <rendro87@gmail.com> (http://robert-fleischmann.de) - * @version 2.1.3 - **/ - -(function(root, factory) { - if(typeof exports === 'object') { - module.exports = factory(require('jquery')); - } - else if(typeof define === 'function' && define.amd) { - define(['jquery'], factory); - } - else { - factory(root.jQuery); - } -}(this, function($) { -/** - * Renderer to render the chart on a canvas object - * @param {DOMElement} el DOM element to host the canvas (root of the plugin) - * @param {object} options options object of the plugin - */ -var CanvasRenderer = function(el, options) { - var cachedBackground; - var canvas = document.createElement('canvas'); - - el.appendChild(canvas); - - if (typeof(G_vmlCanvasManager) !== 'undefined') { - G_vmlCanvasManager.initElement(canvas); - } - - var ctx = canvas.getContext('2d'); - - canvas.width = canvas.height = options.size; - - // canvas on retina devices - var scaleBy = 1; - if (window.devicePixelRatio > 1) { - scaleBy = window.devicePixelRatio; - canvas.style.width = canvas.style.height = [options.size, 'px'].join(''); - canvas.width = canvas.height = options.size * scaleBy; - ctx.scale(scaleBy, scaleBy); - } - - // move 0,0 coordinates to the center - ctx.translate(options.size / 2, options.size / 2); - - // rotate canvas -90deg - ctx.rotate((-1 / 2 + options.rotate / 180) * Math.PI); - - var radius = (options.size - options.lineWidth) / 2; - if (options.scaleColor && options.scaleLength) { - radius -= options.scaleLength + 2; // 2 is the distance between scale and bar - } - - // IE polyfill for Date - Date.now = Date.now || function() { - return +(new Date()); - }; - - /** - * Draw a circle around the center of the canvas - * @param {strong} color Valid CSS color string - * @param {number} lineWidth Width of the line in px - * @param {number} percent Percentage to draw (float between -1 and 1) - */ - var drawCircle = function(color, lineWidth, percent) { - percent = Math.min(Math.max(-1, percent || 0), 1); - var isNegative = percent <= 0 ? true : false; - - ctx.beginPath(); - ctx.arc(0, 0, radius, 0, Math.PI * 2 * percent, isNegative); - - ctx.strokeStyle = color; - ctx.lineWidth = lineWidth; - - ctx.stroke(); - }; - - /** - * Draw the scale of the chart - */ - var drawScale = function() { - var offset; - var length; - - ctx.lineWidth = 1; - ctx.fillStyle = options.scaleColor; - - ctx.save(); - for (var i = 24; i > 0; --i) { - if (i % 6 === 0) { - length = options.scaleLength; - offset = 0; - } else { - length = options.scaleLength * 0.6; - offset = options.scaleLength - length; - } - ctx.fillRect(-options.size/2 + offset, 0, length, 1); - ctx.rotate(Math.PI / 12); - } - ctx.restore(); - }; - - /** - * Request animation frame wrapper with polyfill - * @return {function} Request animation frame method or timeout fallback - */ - var reqAnimationFrame = (function() { - return window.requestAnimationFrame || - window.webkitRequestAnimationFrame || - window.mozRequestAnimationFrame || - function(callback) { - window.setTimeout(callback, 1000 / 60); - }; - }()); - - /** - * Draw the background of the plugin including the scale and the track - */ - var drawBackground = function() { - if(options.scaleColor) drawScale(); - if(options.trackColor) drawCircle(options.trackColor, options.lineWidth, 1); - }; - - /** - * Canvas accessor - */ - this.getCanvas = function() { - return canvas; - }; - - /** - * Canvas 2D context 'ctx' accessor - */ - this.getCtx = function() { - return ctx; - }; - - /** - * Clear the complete canvas - */ - this.clear = function() { - ctx.clearRect(options.size / -2, options.size / -2, options.size, options.size); - }; - - /** - * Draw the complete chart - * @param {number} percent Percent shown by the chart between -100 and 100 - */ - this.draw = function(percent) { - // do we need to render a background - if (!!options.scaleColor || !!options.trackColor) { - // getImageData and putImageData are supported - if (ctx.getImageData && ctx.putImageData) { - if (!cachedBackground) { - drawBackground(); - cachedBackground = ctx.getImageData(0, 0, options.size * scaleBy, options.size * scaleBy); - } else { - ctx.putImageData(cachedBackground, 0, 0); - } - } else { - this.clear(); - drawBackground(); - } - } else { - this.clear(); - } - - ctx.lineCap = options.lineCap; - - // if barcolor is a function execute it and pass the percent as a value - var color; - if (typeof(options.barColor) === 'function') { - color = options.barColor(percent); - } else { - color = options.barColor; - } - - // draw bar - drawCircle(color, options.lineWidth, percent / 100); - }.bind(this); - - /** - * Animate from some percent to some other percentage - * @param {number} from Starting percentage - * @param {number} to Final percentage - */ - this.animate = function(from, to) { - var startTime = Date.now(); - options.onStart(from, to); - var animation = function() { - var process = Math.min(Date.now() - startTime, options.animate.duration); - var currentValue = options.easing(this, process, from, to - from, options.animate.duration); - this.draw(currentValue); - options.onStep(from, to, currentValue); - if (process >= options.animate.duration) { - options.onStop(from, to); - } else { - reqAnimationFrame(animation); - } - }.bind(this); - - reqAnimationFrame(animation); - }.bind(this); -}; - -var EasyPieChart = function(el, opts) { - var defaultOptions = { - barColor: '#ef1e25', - trackColor: '#f9f9f9', - scaleColor: '#dfe0e0', - scaleLength: 5, - lineCap: 'round', - lineWidth: 3, - size: 110, - rotate: 0, - animate: { - duration: 1000, - enabled: true - }, - easing: function (x, t, b, c, d) { // more can be found here: http://gsgd.co.uk/sandbox/jquery/easing/ - t = t / (d/2); - if (t < 1) { - return c / 2 * t * t + b; - } - return -c/2 * ((--t)*(t-2) - 1) + b; - }, - onStart: function(from, to) { - return; - }, - onStep: function(from, to, currentValue) { - return; - }, - onStop: function(from, to) { - return; - } - }; - - // detect present renderer - if (typeof(CanvasRenderer) !== 'undefined') { - defaultOptions.renderer = CanvasRenderer; - } else if (typeof(SVGRenderer) !== 'undefined') { - defaultOptions.renderer = SVGRenderer; - } else { - throw new Error('Please load either the SVG- or the CanvasRenderer'); - } - - var options = {}; - var currentValue = 0; - - /** - * Initialize the plugin by creating the options object and initialize rendering - */ - var init = function() { - this.el = el; - this.options = options; - - // merge user options into default options - for (var i in defaultOptions) { - if (defaultOptions.hasOwnProperty(i)) { - options[i] = opts && typeof(opts[i]) !== 'undefined' ? opts[i] : defaultOptions[i]; - if (typeof(options[i]) === 'function') { - options[i] = options[i].bind(this); - } - } - } - - // check for jQuery easing - if (typeof(options.easing) === 'string' && typeof(jQuery) !== 'undefined' && jQuery.isFunction(jQuery.easing[options.easing])) { - options.easing = jQuery.easing[options.easing]; - } else { - options.easing = defaultOptions.easing; - } - - // process earlier animate option to avoid bc breaks - if (typeof(options.animate) === 'number') { - options.animate = { - duration: options.animate, - enabled: true - }; - } - - if (typeof(options.animate) === 'boolean' && !options.animate) { - options.animate = { - duration: 1000, - enabled: options.animate - }; - } - - // create renderer - this.renderer = new options.renderer(el, options); - - // initial draw - this.renderer.draw(currentValue); - - // initial update - if (el.dataset && el.dataset.percent) { - this.update(parseFloat(el.dataset.percent)); - } else if (el.getAttribute && el.getAttribute('data-percent')) { - this.update(parseFloat(el.getAttribute('data-percent'))); - } - }.bind(this); - - /** - * Update the value of the chart - * @param {number} newValue Number between 0 and 100 - * @return {object} Instance of the plugin for method chaining - */ - this.update = function(newValue) { - newValue = parseFloat(newValue); - if (options.animate.enabled) { - this.renderer.animate(currentValue, newValue); - } else { - this.renderer.draw(newValue); - } - currentValue = newValue; - return this; - }.bind(this); - - /** - * Disable animation - * @return {object} Instance of the plugin for method chaining - */ - this.disableAnimation = function() { - options.animate.enabled = false; - return this; - }; - - /** - * Enable animation - * @return {object} Instance of the plugin for method chaining - */ - this.enableAnimation = function() { - options.animate.enabled = true; - return this; - }; - - init(); -}; - -$.fn.easyPieChart = function(options) { - return this.each(function() { - var instanceOptions; - - if (!$.data(this, 'easyPieChart')) { - instanceOptions = $.extend({}, options, $(this).data()); - $.data(this, 'easyPieChart', new EasyPieChart(this, instanceOptions)); - } - }); -}; - -})); diff --git a/src/UI/JsLibraries/jquery.js b/src/UI/JsLibraries/jquery.js deleted file mode 100644 index 87dd04093..000000000 --- a/src/UI/JsLibraries/jquery.js +++ /dev/null @@ -1,10351 +0,0 @@ -/*! - * jQuery JavaScript Library v1.11.3 - * http://jquery.com/ - * - * Includes Sizzle.js - * http://sizzlejs.com/ - * - * Copyright 2005, 2014 jQuery Foundation, Inc. and other contributors - * Released under the MIT license - * http://jquery.org/license - * - * Date: 2015-04-28T16:19Z - */ - -(function( global, factory ) { - - if ( typeof module === "object" && typeof module.exports === "object" ) { - // For CommonJS and CommonJS-like environments where a proper window is present, - // execute the factory and get jQuery - // For environments that do not inherently posses a window with a document - // (such as Node.js), expose a jQuery-making factory as module.exports - // This accentuates the need for the creation of a real window - // e.g. var jQuery = require("jquery")(window); - // See ticket #14549 for more info - module.exports = global.document ? - factory( global, true ) : - function( w ) { - if ( !w.document ) { - throw new Error( "jQuery requires a window with a document" ); - } - return factory( w ); - }; - } else { - factory( global ); - } - -// Pass this if window is not defined yet -}(typeof window !== "undefined" ? window : this, function( window, noGlobal ) { - -// Can't do this because several apps including ASP.NET trace -// the stack via arguments.caller.callee and Firefox dies if -// you try to trace through "use strict" call chains. (#13335) -// Support: Firefox 18+ -// - -var deletedIds = []; - -var slice = deletedIds.slice; - -var concat = deletedIds.concat; - -var push = deletedIds.push; - -var indexOf = deletedIds.indexOf; - -var class2type = {}; - -var toString = class2type.toString; - -var hasOwn = class2type.hasOwnProperty; - -var support = {}; - - - -var - version = "1.11.3", - - // Define a local copy of jQuery - jQuery = function( selector, context ) { - // The jQuery object is actually just the init constructor 'enhanced' - // Need init if jQuery is called (just allow error to be thrown if not included) - return new jQuery.fn.init( selector, context ); - }, - - // Support: Android<4.1, IE<9 - // Make sure we trim BOM and NBSP - rtrim = /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, - - // Matches dashed string for camelizing - rmsPrefix = /^-ms-/, - rdashAlpha = /-([\da-z])/gi, - - // Used by jQuery.camelCase as callback to replace() - fcamelCase = function( all, letter ) { - return letter.toUpperCase(); - }; - -jQuery.fn = jQuery.prototype = { - // The current version of jQuery being used - jquery: version, - - constructor: jQuery, - - // Start with an empty selector - selector: "", - - // The default length of a jQuery object is 0 - length: 0, - - toArray: function() { - return slice.call( this ); - }, - - // Get the Nth element in the matched element set OR - // Get the whole matched element set as a clean array - get: function( num ) { - return num != null ? - - // Return just the one element from the set - ( num < 0 ? this[ num + this.length ] : this[ num ] ) : - - // Return all the elements in a clean array - slice.call( this ); - }, - - // Take an array of elements and push it onto the stack - // (returning the new matched element set) - pushStack: function( elems ) { - - // Build a new jQuery matched element set - var ret = jQuery.merge( this.constructor(), elems ); - - // Add the old object onto the stack (as a reference) - ret.prevObject = this; - ret.context = this.context; - - // Return the newly-formed element set - return ret; - }, - - // Execute a callback for every element in the matched set. - // (You can seed the arguments with an array of args, but this is - // only used internally.) - each: function( callback, args ) { - return jQuery.each( this, callback, args ); - }, - - map: function( callback ) { - return this.pushStack( jQuery.map(this, function( elem, i ) { - return callback.call( elem, i, elem ); - })); - }, - - slice: function() { - return this.pushStack( slice.apply( this, arguments ) ); - }, - - first: function() { - return this.eq( 0 ); - }, - - last: function() { - return this.eq( -1 ); - }, - - eq: function( i ) { - var len = this.length, - j = +i + ( i < 0 ? len : 0 ); - return this.pushStack( j >= 0 && j < len ? [ this[j] ] : [] ); - }, - - end: function() { - return this.prevObject || this.constructor(null); - }, - - // For internal use only. - // Behaves like an Array's method, not like a jQuery method. - push: push, - sort: deletedIds.sort, - splice: deletedIds.splice -}; - -jQuery.extend = jQuery.fn.extend = function() { - var src, copyIsArray, copy, name, options, clone, - target = arguments[0] || {}, - i = 1, - length = arguments.length, - deep = false; - - // Handle a deep copy situation - if ( typeof target === "boolean" ) { - deep = target; - - // skip the boolean and the target - target = arguments[ i ] || {}; - i++; - } - - // Handle case when target is a string or something (possible in deep copy) - if ( typeof target !== "object" && !jQuery.isFunction(target) ) { - target = {}; - } - - // extend jQuery itself if only one argument is passed - if ( i === length ) { - target = this; - i--; - } - - for ( ; i < length; i++ ) { - // Only deal with non-null/undefined values - if ( (options = arguments[ i ]) != null ) { - // Extend the base object - for ( name in options ) { - src = target[ name ]; - copy = options[ name ]; - - // Prevent never-ending loop - if ( target === copy ) { - continue; - } - - // Recurse if we're merging plain objects or arrays - if ( deep && copy && ( jQuery.isPlainObject(copy) || (copyIsArray = jQuery.isArray(copy)) ) ) { - if ( copyIsArray ) { - copyIsArray = false; - clone = src && jQuery.isArray(src) ? src : []; - - } else { - clone = src && jQuery.isPlainObject(src) ? src : {}; - } - - // Never move original objects, clone them - target[ name ] = jQuery.extend( deep, clone, copy ); - - // Don't bring in undefined values - } else if ( copy !== undefined ) { - target[ name ] = copy; - } - } - } - } - - // Return the modified object - return target; -}; - -jQuery.extend({ - // Unique for each copy of jQuery on the page - expando: "jQuery" + ( version + Math.random() ).replace( /\D/g, "" ), - - // Assume jQuery is ready without the ready module - isReady: true, - - error: function( msg ) { - throw new Error( msg ); - }, - - noop: function() {}, - - // See test/unit/core.js for details concerning isFunction. - // Since version 1.3, DOM methods and functions like alert - // aren't supported. They return false on IE (#2968). - isFunction: function( obj ) { - return jQuery.type(obj) === "function"; - }, - - isArray: Array.isArray || function( obj ) { - return jQuery.type(obj) === "array"; - }, - - isWindow: function( obj ) { - /* jshint eqeqeq: false */ - return obj != null && obj == obj.window; - }, - - isNumeric: function( obj ) { - // parseFloat NaNs numeric-cast false positives (null|true|false|"") - // ...but misinterprets leading-number strings, particularly hex literals ("0x...") - // subtraction forces infinities to NaN - // adding 1 corrects loss of precision from parseFloat (#15100) - return !jQuery.isArray( obj ) && (obj - parseFloat( obj ) + 1) >= 0; - }, - - isEmptyObject: function( obj ) { - var name; - for ( name in obj ) { - return false; - } - return true; - }, - - isPlainObject: function( obj ) { - var key; - - // Must be an Object. - // Because of IE, we also have to check the presence of the constructor property. - // Make sure that DOM nodes and window objects don't pass through, as well - if ( !obj || jQuery.type(obj) !== "object" || obj.nodeType || jQuery.isWindow( obj ) ) { - return false; - } - - try { - // Not own constructor property must be Object - if ( obj.constructor && - !hasOwn.call(obj, "constructor") && - !hasOwn.call(obj.constructor.prototype, "isPrototypeOf") ) { - return false; - } - } catch ( e ) { - // IE8,9 Will throw exceptions on certain host objects #9897 - return false; - } - - // Support: IE<9 - // Handle iteration over inherited properties before own properties. - if ( support.ownLast ) { - for ( key in obj ) { - return hasOwn.call( obj, key ); - } - } - - // Own properties are enumerated firstly, so to speed up, - // if last one is own, then all properties are own. - for ( key in obj ) {} - - return key === undefined || hasOwn.call( obj, key ); - }, - - type: function( obj ) { - if ( obj == null ) { - return obj + ""; - } - return typeof obj === "object" || typeof obj === "function" ? - class2type[ toString.call(obj) ] || "object" : - typeof obj; - }, - - // Evaluates a script in a global context - // Workarounds based on findings by Jim Driscoll - // http://weblogs.java.net/blog/driscoll/archive/2009/09/08/eval-javascript-global-context - globalEval: function( data ) { - if ( data && jQuery.trim( data ) ) { - // We use execScript on Internet Explorer - // We use an anonymous function so that context is window - // rather than jQuery in Firefox - ( window.execScript || function( data ) { - window[ "eval" ].call( window, data ); - } )( data ); - } - }, - - // Convert dashed to camelCase; used by the css and data modules - // Microsoft forgot to hump their vendor prefix (#9572) - camelCase: function( string ) { - return string.replace( rmsPrefix, "ms-" ).replace( rdashAlpha, fcamelCase ); - }, - - nodeName: function( elem, name ) { - return elem.nodeName && elem.nodeName.toLowerCase() === name.toLowerCase(); - }, - - // args is for internal usage only - each: function( obj, callback, args ) { - var value, - i = 0, - length = obj.length, - isArray = isArraylike( obj ); - - if ( args ) { - if ( isArray ) { - for ( ; i < length; i++ ) { - value = callback.apply( obj[ i ], args ); - - if ( value === false ) { - break; - } - } - } else { - for ( i in obj ) { - value = callback.apply( obj[ i ], args ); - - if ( value === false ) { - break; - } - } - } - - // A special, fast, case for the most common use of each - } else { - if ( isArray ) { - for ( ; i < length; i++ ) { - value = callback.call( obj[ i ], i, obj[ i ] ); - - if ( value === false ) { - break; - } - } - } else { - for ( i in obj ) { - value = callback.call( obj[ i ], i, obj[ i ] ); - - if ( value === false ) { - break; - } - } - } - } - - return obj; - }, - - // Support: Android<4.1, IE<9 - trim: function( text ) { - return text == null ? - "" : - ( text + "" ).replace( rtrim, "" ); - }, - - // results is for internal usage only - makeArray: function( arr, results ) { - var ret = results || []; - - if ( arr != null ) { - if ( isArraylike( Object(arr) ) ) { - jQuery.merge( ret, - typeof arr === "string" ? - [ arr ] : arr - ); - } else { - push.call( ret, arr ); - } - } - - return ret; - }, - - inArray: function( elem, arr, i ) { - var len; - - if ( arr ) { - if ( indexOf ) { - return indexOf.call( arr, elem, i ); - } - - len = arr.length; - i = i ? i < 0 ? Math.max( 0, len + i ) : i : 0; - - for ( ; i < len; i++ ) { - // Skip accessing in sparse arrays - if ( i in arr && arr[ i ] === elem ) { - return i; - } - } - } - - return -1; - }, - - merge: function( first, second ) { - var len = +second.length, - j = 0, - i = first.length; - - while ( j < len ) { - first[ i++ ] = second[ j++ ]; - } - - // Support: IE<9 - // Workaround casting of .length to NaN on otherwise arraylike objects (e.g., NodeLists) - if ( len !== len ) { - while ( second[j] !== undefined ) { - first[ i++ ] = second[ j++ ]; - } - } - - first.length = i; - - return first; - }, - - grep: function( elems, callback, invert ) { - var callbackInverse, - matches = [], - i = 0, - length = elems.length, - callbackExpect = !invert; - - // Go through the array, only saving the items - // that pass the validator function - for ( ; i < length; i++ ) { - callbackInverse = !callback( elems[ i ], i ); - if ( callbackInverse !== callbackExpect ) { - matches.push( elems[ i ] ); - } - } - - return matches; - }, - - // arg is for internal usage only - map: function( elems, callback, arg ) { - var value, - i = 0, - length = elems.length, - isArray = isArraylike( elems ), - ret = []; - - // Go through the array, translating each of the items to their new values - if ( isArray ) { - for ( ; i < length; i++ ) { - value = callback( elems[ i ], i, arg ); - - if ( value != null ) { - ret.push( value ); - } - } - - // Go through every key on the object, - } else { - for ( i in elems ) { - value = callback( elems[ i ], i, arg ); - - if ( value != null ) { - ret.push( value ); - } - } - } - - // Flatten any nested arrays - return concat.apply( [], ret ); - }, - - // A global GUID counter for objects - guid: 1, - - // Bind a function to a context, optionally partially applying any - // arguments. - proxy: function( fn, context ) { - var args, proxy, tmp; - - if ( typeof context === "string" ) { - tmp = fn[ context ]; - context = fn; - fn = tmp; - } - - // Quick check to determine if target is callable, in the spec - // this throws a TypeError, but we will just return undefined. - if ( !jQuery.isFunction( fn ) ) { - return undefined; - } - - // Simulated bind - args = slice.call( arguments, 2 ); - proxy = function() { - return fn.apply( context || this, args.concat( slice.call( arguments ) ) ); - }; - - // Set the guid of unique handler to the same of original handler, so it can be removed - proxy.guid = fn.guid = fn.guid || jQuery.guid++; - - return proxy; - }, - - now: function() { - return +( new Date() ); - }, - - // jQuery.support is not used in Core but other projects attach their - // properties to it so it needs to exist. - support: support -}); - -// Populate the class2type map -jQuery.each("Boolean Number String Function Array Date RegExp Object Error".split(" "), function(i, name) { - class2type[ "[object " + name + "]" ] = name.toLowerCase(); -}); - -function isArraylike( obj ) { - - // Support: iOS 8.2 (not reproducible in simulator) - // `in` check used to prevent JIT error (gh-2145) - // hasOwn isn't used here due to false negatives - // regarding Nodelist length in IE - var length = "length" in obj && obj.length, - type = jQuery.type( obj ); - - if ( type === "function" || jQuery.isWindow( obj ) ) { - return false; - } - - if ( obj.nodeType === 1 && length ) { - return true; - } - - return type === "array" || length === 0 || - typeof length === "number" && length > 0 && ( length - 1 ) in obj; -} -var Sizzle = -/*! - * Sizzle CSS Selector Engine v2.2.0-pre - * http://sizzlejs.com/ - * - * Copyright 2008, 2014 jQuery Foundation, Inc. and other contributors - * Released under the MIT license - * http://jquery.org/license - * - * Date: 2014-12-16 - */ -(function( window ) { - -var i, - support, - Expr, - getText, - isXML, - tokenize, - compile, - select, - outermostContext, - sortInput, - hasDuplicate, - - // Local document vars - setDocument, - document, - docElem, - documentIsHTML, - rbuggyQSA, - rbuggyMatches, - matches, - contains, - - // Instance-specific data - expando = "sizzle" + 1 * new Date(), - preferredDoc = window.document, - dirruns = 0, - done = 0, - classCache = createCache(), - tokenCache = createCache(), - compilerCache = createCache(), - sortOrder = function( a, b ) { - if ( a === b ) { - hasDuplicate = true; - } - return 0; - }, - - // General-purpose constants - MAX_NEGATIVE = 1 << 31, - - // Instance methods - hasOwn = ({}).hasOwnProperty, - arr = [], - pop = arr.pop, - push_native = arr.push, - push = arr.push, - slice = arr.slice, - // Use a stripped-down indexOf as it's faster than native - // http://jsperf.com/thor-indexof-vs-for/5 - indexOf = function( list, elem ) { - var i = 0, - len = list.length; - for ( ; i < len; i++ ) { - if ( list[i] === elem ) { - return i; - } - } - return -1; - }, - - booleans = "checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped", - - // Regular expressions - - // Whitespace characters http://www.w3.org/TR/css3-selectors/#whitespace - whitespace = "[\\x20\\t\\r\\n\\f]", - // http://www.w3.org/TR/css3-syntax/#characters - characterEncoding = "(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+", - - // Loosely modeled on CSS identifier characters - // An unquoted value should be a CSS identifier http://www.w3.org/TR/css3-selectors/#attribute-selectors - // Proper syntax: http://www.w3.org/TR/CSS21/syndata.html#value-def-identifier - identifier = characterEncoding.replace( "w", "w#" ), - - // Attribute selectors: http://www.w3.org/TR/selectors/#attribute-selectors - attributes = "\\[" + whitespace + "*(" + characterEncoding + ")(?:" + whitespace + - // Operator (capture 2) - "*([*^$|!~]?=)" + whitespace + - // "Attribute values must be CSS identifiers [capture 5] or strings [capture 3 or capture 4]" - "*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|(" + identifier + "))|)" + whitespace + - "*\\]", - - pseudos = ":(" + characterEncoding + ")(?:\\((" + - // To reduce the number of selectors needing tokenize in the preFilter, prefer arguments: - // 1. quoted (capture 3; capture 4 or capture 5) - "('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|" + - // 2. simple (capture 6) - "((?:\\\\.|[^\\\\()[\\]]|" + attributes + ")*)|" + - // 3. anything else (capture 2) - ".*" + - ")\\)|)", - - // Leading and non-escaped trailing whitespace, capturing some non-whitespace characters preceding the latter - rwhitespace = new RegExp( whitespace + "+", "g" ), - rtrim = new RegExp( "^" + whitespace + "+|((?:^|[^\\\\])(?:\\\\.)*)" + whitespace + "+$", "g" ), - - rcomma = new RegExp( "^" + whitespace + "*," + whitespace + "*" ), - rcombinators = new RegExp( "^" + whitespace + "*([>+~]|" + whitespace + ")" + whitespace + "*" ), - - rattributeQuotes = new RegExp( "=" + whitespace + "*([^\\]'\"]*?)" + whitespace + "*\\]", "g" ), - - rpseudo = new RegExp( pseudos ), - ridentifier = new RegExp( "^" + identifier + "$" ), - - matchExpr = { - "ID": new RegExp( "^#(" + characterEncoding + ")" ), - "CLASS": new RegExp( "^\\.(" + characterEncoding + ")" ), - "TAG": new RegExp( "^(" + characterEncoding.replace( "w", "w*" ) + ")" ), - "ATTR": new RegExp( "^" + attributes ), - "PSEUDO": new RegExp( "^" + pseudos ), - "CHILD": new RegExp( "^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\(" + whitespace + - "*(even|odd|(([+-]|)(\\d*)n|)" + whitespace + "*(?:([+-]|)" + whitespace + - "*(\\d+)|))" + whitespace + "*\\)|)", "i" ), - "bool": new RegExp( "^(?:" + booleans + ")$", "i" ), - // For use in libraries implementing .is() - // We use this for POS matching in `select` - "needsContext": new RegExp( "^" + whitespace + "*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\(" + - whitespace + "*((?:-\\d)?\\d*)" + whitespace + "*\\)|)(?=[^-]|$)", "i" ) - }, - - rinputs = /^(?:input|select|textarea|button)$/i, - rheader = /^h\d$/i, - - rnative = /^[^{]+\{\s*\[native \w/, - - // Easily-parseable/retrievable ID or TAG or CLASS selectors - rquickExpr = /^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/, - - rsibling = /[+~]/, - rescape = /'|\\/g, - - // CSS escapes http://www.w3.org/TR/CSS21/syndata.html#escaped-characters - runescape = new RegExp( "\\\\([\\da-f]{1,6}" + whitespace + "?|(" + whitespace + ")|.)", "ig" ), - funescape = function( _, escaped, escapedWhitespace ) { - var high = "0x" + escaped - 0x10000; - // NaN means non-codepoint - // Support: Firefox<24 - // Workaround erroneous numeric interpretation of +"0x" - return high !== high || escapedWhitespace ? - escaped : - high < 0 ? - // BMP codepoint - String.fromCharCode( high + 0x10000 ) : - // Supplemental Plane codepoint (surrogate pair) - String.fromCharCode( high >> 10 | 0xD800, high & 0x3FF | 0xDC00 ); - }, - - // Used for iframes - // See setDocument() - // Removing the function wrapper causes a "Permission Denied" - // error in IE - unloadHandler = function() { - setDocument(); - }; - -// Optimize for push.apply( _, NodeList ) -try { - push.apply( - (arr = slice.call( preferredDoc.childNodes )), - preferredDoc.childNodes - ); - // Support: Android<4.0 - // Detect silently failing push.apply - arr[ preferredDoc.childNodes.length ].nodeType; -} catch ( e ) { - push = { apply: arr.length ? - - // Leverage slice if possible - function( target, els ) { - push_native.apply( target, slice.call(els) ); - } : - - // Support: IE<9 - // Otherwise append directly - function( target, els ) { - var j = target.length, - i = 0; - // Can't trust NodeList.length - while ( (target[j++] = els[i++]) ) {} - target.length = j - 1; - } - }; -} - -function Sizzle( selector, context, results, seed ) { - var match, elem, m, nodeType, - // QSA vars - i, groups, old, nid, newContext, newSelector; - - if ( ( context ? context.ownerDocument || context : preferredDoc ) !== document ) { - setDocument( context ); - } - - context = context || document; - results = results || []; - nodeType = context.nodeType; - - if ( typeof selector !== "string" || !selector || - nodeType !== 1 && nodeType !== 9 && nodeType !== 11 ) { - - return results; - } - - if ( !seed && documentIsHTML ) { - - // Try to shortcut find operations when possible (e.g., not under DocumentFragment) - if ( nodeType !== 11 && (match = rquickExpr.exec( selector )) ) { - // Speed-up: Sizzle("#ID") - if ( (m = match[1]) ) { - if ( nodeType === 9 ) { - elem = context.getElementById( m ); - // Check parentNode to catch when Blackberry 4.6 returns - // nodes that are no longer in the document (jQuery #6963) - if ( elem && elem.parentNode ) { - // Handle the case where IE, Opera, and Webkit return items - // by name instead of ID - if ( elem.id === m ) { - results.push( elem ); - return results; - } - } else { - return results; - } - } else { - // Context is not a document - if ( context.ownerDocument && (elem = context.ownerDocument.getElementById( m )) && - contains( context, elem ) && elem.id === m ) { - results.push( elem ); - return results; - } - } - - // Speed-up: Sizzle("TAG") - } else if ( match[2] ) { - push.apply( results, context.getElementsByTagName( selector ) ); - return results; - - // Speed-up: Sizzle(".CLASS") - } else if ( (m = match[3]) && support.getElementsByClassName ) { - push.apply( results, context.getElementsByClassName( m ) ); - return results; - } - } - - // QSA path - if ( support.qsa && (!rbuggyQSA || !rbuggyQSA.test( selector )) ) { - nid = old = expando; - newContext = context; - newSelector = nodeType !== 1 && selector; - - // qSA works strangely on Element-rooted queries - // We can work around this by specifying an extra ID on the root - // and working up from there (Thanks to Andrew Dupont for the technique) - // IE 8 doesn't work on object elements - if ( nodeType === 1 && context.nodeName.toLowerCase() !== "object" ) { - groups = tokenize( selector ); - - if ( (old = context.getAttribute("id")) ) { - nid = old.replace( rescape, "\\$&" ); - } else { - context.setAttribute( "id", nid ); - } - nid = "[id='" + nid + "'] "; - - i = groups.length; - while ( i-- ) { - groups[i] = nid + toSelector( groups[i] ); - } - newContext = rsibling.test( selector ) && testContext( context.parentNode ) || context; - newSelector = groups.join(","); - } - - if ( newSelector ) { - try { - push.apply( results, - newContext.querySelectorAll( newSelector ) - ); - return results; - } catch(qsaError) { - } finally { - if ( !old ) { - context.removeAttribute("id"); - } - } - } - } - } - - // All others - return select( selector.replace( rtrim, "$1" ), context, results, seed ); -} - -/** - * Create key-value caches of limited size - * @returns {Function(string, Object)} Returns the Object data after storing it on itself with - * property name the (space-suffixed) string and (if the cache is larger than Expr.cacheLength) - * deleting the oldest entry - */ -function createCache() { - var keys = []; - - function cache( key, value ) { - // Use (key + " ") to avoid collision with native prototype properties (see Issue #157) - if ( keys.push( key + " " ) > Expr.cacheLength ) { - // Only keep the most recent entries - delete cache[ keys.shift() ]; - } - return (cache[ key + " " ] = value); - } - return cache; -} - -/** - * Mark a function for special use by Sizzle - * @param {Function} fn The function to mark - */ -function markFunction( fn ) { - fn[ expando ] = true; - return fn; -} - -/** - * Support testing using an element - * @param {Function} fn Passed the created div and expects a boolean result - */ -function assert( fn ) { - var div = document.createElement("div"); - - try { - return !!fn( div ); - } catch (e) { - return false; - } finally { - // Remove from its parent by default - if ( div.parentNode ) { - div.parentNode.removeChild( div ); - } - // release memory in IE - div = null; - } -} - -/** - * Adds the same handler for all of the specified attrs - * @param {String} attrs Pipe-separated list of attributes - * @param {Function} handler The method that will be applied - */ -function addHandle( attrs, handler ) { - var arr = attrs.split("|"), - i = attrs.length; - - while ( i-- ) { - Expr.attrHandle[ arr[i] ] = handler; - } -} - -/** - * Checks document order of two siblings - * @param {Element} a - * @param {Element} b - * @returns {Number} Returns less than 0 if a precedes b, greater than 0 if a follows b - */ -function siblingCheck( a, b ) { - var cur = b && a, - diff = cur && a.nodeType === 1 && b.nodeType === 1 && - ( ~b.sourceIndex || MAX_NEGATIVE ) - - ( ~a.sourceIndex || MAX_NEGATIVE ); - - // Use IE sourceIndex if available on both nodes - if ( diff ) { - return diff; - } - - // Check if b follows a - if ( cur ) { - while ( (cur = cur.nextSibling) ) { - if ( cur === b ) { - return -1; - } - } - } - - return a ? 1 : -1; -} - -/** - * Returns a function to use in pseudos for input types - * @param {String} type - */ -function createInputPseudo( type ) { - return function( elem ) { - var name = elem.nodeName.toLowerCase(); - return name === "input" && elem.type === type; - }; -} - -/** - * Returns a function to use in pseudos for buttons - * @param {String} type - */ -function createButtonPseudo( type ) { - return function( elem ) { - var name = elem.nodeName.toLowerCase(); - return (name === "input" || name === "button") && elem.type === type; - }; -} - -/** - * Returns a function to use in pseudos for positionals - * @param {Function} fn - */ -function createPositionalPseudo( fn ) { - return markFunction(function( argument ) { - argument = +argument; - return markFunction(function( seed, matches ) { - var j, - matchIndexes = fn( [], seed.length, argument ), - i = matchIndexes.length; - - // Match elements found at the specified indexes - while ( i-- ) { - if ( seed[ (j = matchIndexes[i]) ] ) { - seed[j] = !(matches[j] = seed[j]); - } - } - }); - }); -} - -/** - * Checks a node for validity as a Sizzle context - * @param {Element|Object=} context - * @returns {Element|Object|Boolean} The input node if acceptable, otherwise a falsy value - */ -function testContext( context ) { - return context && typeof context.getElementsByTagName !== "undefined" && context; -} - -// Expose support vars for convenience -support = Sizzle.support = {}; - -/** - * Detects XML nodes - * @param {Element|Object} elem An element or a document - * @returns {Boolean} True iff elem is a non-HTML XML node - */ -isXML = Sizzle.isXML = function( elem ) { - // documentElement is verified for cases where it doesn't yet exist - // (such as loading iframes in IE - #4833) - var documentElement = elem && (elem.ownerDocument || elem).documentElement; - return documentElement ? documentElement.nodeName !== "HTML" : false; -}; - -/** - * Sets document-related variables once based on the current document - * @param {Element|Object} [doc] An element or document object to use to set the document - * @returns {Object} Returns the current document - */ -setDocument = Sizzle.setDocument = function( node ) { - var hasCompare, parent, - doc = node ? node.ownerDocument || node : preferredDoc; - - // If no document and documentElement is available, return - if ( doc === document || doc.nodeType !== 9 || !doc.documentElement ) { - return document; - } - - // Set our document - document = doc; - docElem = doc.documentElement; - parent = doc.defaultView; - - // Support: IE>8 - // If iframe document is assigned to "document" variable and if iframe has been reloaded, - // IE will throw "permission denied" error when accessing "document" variable, see jQuery #13936 - // IE6-8 do not support the defaultView property so parent will be undefined - if ( parent && parent !== parent.top ) { - // IE11 does not have attachEvent, so all must suffer - if ( parent.addEventListener ) { - parent.addEventListener( "unload", unloadHandler, false ); - } else if ( parent.attachEvent ) { - parent.attachEvent( "onunload", unloadHandler ); - } - } - - /* Support tests - ---------------------------------------------------------------------- */ - documentIsHTML = !isXML( doc ); - - /* Attributes - ---------------------------------------------------------------------- */ - - // Support: IE<8 - // Verify that getAttribute really returns attributes and not properties - // (excepting IE8 booleans) - support.attributes = assert(function( div ) { - div.className = "i"; - return !div.getAttribute("className"); - }); - - /* getElement(s)By* - ---------------------------------------------------------------------- */ - - // Check if getElementsByTagName("*") returns only elements - support.getElementsByTagName = assert(function( div ) { - div.appendChild( doc.createComment("") ); - return !div.getElementsByTagName("*").length; - }); - - // Support: IE<9 - support.getElementsByClassName = rnative.test( doc.getElementsByClassName ); - - // Support: IE<10 - // Check if getElementById returns elements by name - // The broken getElementById methods don't pick up programatically-set names, - // so use a roundabout getElementsByName test - support.getById = assert(function( div ) { - docElem.appendChild( div ).id = expando; - return !doc.getElementsByName || !doc.getElementsByName( expando ).length; - }); - - // ID find and filter - if ( support.getById ) { - Expr.find["ID"] = function( id, context ) { - if ( typeof context.getElementById !== "undefined" && documentIsHTML ) { - var m = context.getElementById( id ); - // Check parentNode to catch when Blackberry 4.6 returns - // nodes that are no longer in the document #6963 - return m && m.parentNode ? [ m ] : []; - } - }; - Expr.filter["ID"] = function( id ) { - var attrId = id.replace( runescape, funescape ); - return function( elem ) { - return elem.getAttribute("id") === attrId; - }; - }; - } else { - // Support: IE6/7 - // getElementById is not reliable as a find shortcut - delete Expr.find["ID"]; - - Expr.filter["ID"] = function( id ) { - var attrId = id.replace( runescape, funescape ); - return function( elem ) { - var node = typeof elem.getAttributeNode !== "undefined" && elem.getAttributeNode("id"); - return node && node.value === attrId; - }; - }; - } - - // Tag - Expr.find["TAG"] = support.getElementsByTagName ? - function( tag, context ) { - if ( typeof context.getElementsByTagName !== "undefined" ) { - return context.getElementsByTagName( tag ); - - // DocumentFragment nodes don't have gEBTN - } else if ( support.qsa ) { - return context.querySelectorAll( tag ); - } - } : - - function( tag, context ) { - var elem, - tmp = [], - i = 0, - // By happy coincidence, a (broken) gEBTN appears on DocumentFragment nodes too - results = context.getElementsByTagName( tag ); - - // Filter out possible comments - if ( tag === "*" ) { - while ( (elem = results[i++]) ) { - if ( elem.nodeType === 1 ) { - tmp.push( elem ); - } - } - - return tmp; - } - return results; - }; - - // Class - Expr.find["CLASS"] = support.getElementsByClassName && function( className, context ) { - if ( documentIsHTML ) { - return context.getElementsByClassName( className ); - } - }; - - /* QSA/matchesSelector - ---------------------------------------------------------------------- */ - - // QSA and matchesSelector support - - // matchesSelector(:active) reports false when true (IE9/Opera 11.5) - rbuggyMatches = []; - - // qSa(:focus) reports false when true (Chrome 21) - // We allow this because of a bug in IE8/9 that throws an error - // whenever `document.activeElement` is accessed on an iframe - // So, we allow :focus to pass through QSA all the time to avoid the IE error - // See http://bugs.jquery.com/ticket/13378 - rbuggyQSA = []; - - if ( (support.qsa = rnative.test( doc.querySelectorAll )) ) { - // Build QSA regex - // Regex strategy adopted from Diego Perini - assert(function( div ) { - // Select is set to empty string on purpose - // This is to test IE's treatment of not explicitly - // setting a boolean content attribute, - // since its presence should be enough - // http://bugs.jquery.com/ticket/12359 - docElem.appendChild( div ).innerHTML = "<a id='" + expando + "'></a>" + - "<select id='" + expando + "-\f]' msallowcapture=''>" + - "<option selected=''></option></select>"; - - // Support: IE8, Opera 11-12.16 - // Nothing should be selected when empty strings follow ^= or $= or *= - // The test attribute must be unknown in Opera but "safe" for WinRT - // http://msdn.microsoft.com/en-us/library/ie/hh465388.aspx#attribute_section - if ( div.querySelectorAll("[msallowcapture^='']").length ) { - rbuggyQSA.push( "[*^$]=" + whitespace + "*(?:''|\"\")" ); - } - - // Support: IE8 - // Boolean attributes and "value" are not treated correctly - if ( !div.querySelectorAll("[selected]").length ) { - rbuggyQSA.push( "\\[" + whitespace + "*(?:value|" + booleans + ")" ); - } - - // Support: Chrome<29, Android<4.2+, Safari<7.0+, iOS<7.0+, PhantomJS<1.9.7+ - if ( !div.querySelectorAll( "[id~=" + expando + "-]" ).length ) { - rbuggyQSA.push("~="); - } - - // Webkit/Opera - :checked should return selected option elements - // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked - // IE8 throws error here and will not see later tests - if ( !div.querySelectorAll(":checked").length ) { - rbuggyQSA.push(":checked"); - } - - // Support: Safari 8+, iOS 8+ - // https://bugs.webkit.org/show_bug.cgi?id=136851 - // In-page `selector#id sibing-combinator selector` fails - if ( !div.querySelectorAll( "a#" + expando + "+*" ).length ) { - rbuggyQSA.push(".#.+[+~]"); - } - }); - - assert(function( div ) { - // Support: Windows 8 Native Apps - // The type and name attributes are restricted during .innerHTML assignment - var input = doc.createElement("input"); - input.setAttribute( "type", "hidden" ); - div.appendChild( input ).setAttribute( "name", "D" ); - - // Support: IE8 - // Enforce case-sensitivity of name attribute - if ( div.querySelectorAll("[name=d]").length ) { - rbuggyQSA.push( "name" + whitespace + "*[*^$|!~]?=" ); - } - - // FF 3.5 - :enabled/:disabled and hidden elements (hidden elements are still enabled) - // IE8 throws error here and will not see later tests - if ( !div.querySelectorAll(":enabled").length ) { - rbuggyQSA.push( ":enabled", ":disabled" ); - } - - // Opera 10-11 does not throw on post-comma invalid pseudos - div.querySelectorAll("*,:x"); - rbuggyQSA.push(",.*:"); - }); - } - - if ( (support.matchesSelector = rnative.test( (matches = docElem.matches || - docElem.webkitMatchesSelector || - docElem.mozMatchesSelector || - docElem.oMatchesSelector || - docElem.msMatchesSelector) )) ) { - - assert(function( div ) { - // Check to see if it's possible to do matchesSelector - // on a disconnected node (IE 9) - support.disconnectedMatch = matches.call( div, "div" ); - - // This should fail with an exception - // Gecko does not error, returns false instead - matches.call( div, "[s!='']:x" ); - rbuggyMatches.push( "!=", pseudos ); - }); - } - - rbuggyQSA = rbuggyQSA.length && new RegExp( rbuggyQSA.join("|") ); - rbuggyMatches = rbuggyMatches.length && new RegExp( rbuggyMatches.join("|") ); - - /* Contains - ---------------------------------------------------------------------- */ - hasCompare = rnative.test( docElem.compareDocumentPosition ); - - // Element contains another - // Purposefully does not implement inclusive descendent - // As in, an element does not contain itself - contains = hasCompare || rnative.test( docElem.contains ) ? - function( a, b ) { - var adown = a.nodeType === 9 ? a.documentElement : a, - bup = b && b.parentNode; - return a === bup || !!( bup && bup.nodeType === 1 && ( - adown.contains ? - adown.contains( bup ) : - a.compareDocumentPosition && a.compareDocumentPosition( bup ) & 16 - )); - } : - function( a, b ) { - if ( b ) { - while ( (b = b.parentNode) ) { - if ( b === a ) { - return true; - } - } - } - return false; - }; - - /* Sorting - ---------------------------------------------------------------------- */ - - // Document order sorting - sortOrder = hasCompare ? - function( a, b ) { - - // Flag for duplicate removal - if ( a === b ) { - hasDuplicate = true; - return 0; - } - - // Sort on method existence if only one input has compareDocumentPosition - var compare = !a.compareDocumentPosition - !b.compareDocumentPosition; - if ( compare ) { - return compare; - } - - // Calculate position if both inputs belong to the same document - compare = ( a.ownerDocument || a ) === ( b.ownerDocument || b ) ? - a.compareDocumentPosition( b ) : - - // Otherwise we know they are disconnected - 1; - - // Disconnected nodes - if ( compare & 1 || - (!support.sortDetached && b.compareDocumentPosition( a ) === compare) ) { - - // Choose the first element that is related to our preferred document - if ( a === doc || a.ownerDocument === preferredDoc && contains(preferredDoc, a) ) { - return -1; - } - if ( b === doc || b.ownerDocument === preferredDoc && contains(preferredDoc, b) ) { - return 1; - } - - // Maintain original order - return sortInput ? - ( indexOf( sortInput, a ) - indexOf( sortInput, b ) ) : - 0; - } - - return compare & 4 ? -1 : 1; - } : - function( a, b ) { - // Exit early if the nodes are identical - if ( a === b ) { - hasDuplicate = true; - return 0; - } - - var cur, - i = 0, - aup = a.parentNode, - bup = b.parentNode, - ap = [ a ], - bp = [ b ]; - - // Parentless nodes are either documents or disconnected - if ( !aup || !bup ) { - return a === doc ? -1 : - b === doc ? 1 : - aup ? -1 : - bup ? 1 : - sortInput ? - ( indexOf( sortInput, a ) - indexOf( sortInput, b ) ) : - 0; - - // If the nodes are siblings, we can do a quick check - } else if ( aup === bup ) { - return siblingCheck( a, b ); - } - - // Otherwise we need full lists of their ancestors for comparison - cur = a; - while ( (cur = cur.parentNode) ) { - ap.unshift( cur ); - } - cur = b; - while ( (cur = cur.parentNode) ) { - bp.unshift( cur ); - } - - // Walk down the tree looking for a discrepancy - while ( ap[i] === bp[i] ) { - i++; - } - - return i ? - // Do a sibling check if the nodes have a common ancestor - siblingCheck( ap[i], bp[i] ) : - - // Otherwise nodes in our document sort first - ap[i] === preferredDoc ? -1 : - bp[i] === preferredDoc ? 1 : - 0; - }; - - return doc; -}; - -Sizzle.matches = function( expr, elements ) { - return Sizzle( expr, null, null, elements ); -}; - -Sizzle.matchesSelector = function( elem, expr ) { - // Set document vars if needed - if ( ( elem.ownerDocument || elem ) !== document ) { - setDocument( elem ); - } - - // Make sure that attribute selectors are quoted - expr = expr.replace( rattributeQuotes, "='$1']" ); - - if ( support.matchesSelector && documentIsHTML && - ( !rbuggyMatches || !rbuggyMatches.test( expr ) ) && - ( !rbuggyQSA || !rbuggyQSA.test( expr ) ) ) { - - try { - var ret = matches.call( elem, expr ); - - // IE 9's matchesSelector returns false on disconnected nodes - if ( ret || support.disconnectedMatch || - // As well, disconnected nodes are said to be in a document - // fragment in IE 9 - elem.document && elem.document.nodeType !== 11 ) { - return ret; - } - } catch (e) {} - } - - return Sizzle( expr, document, null, [ elem ] ).length > 0; -}; - -Sizzle.contains = function( context, elem ) { - // Set document vars if needed - if ( ( context.ownerDocument || context ) !== document ) { - setDocument( context ); - } - return contains( context, elem ); -}; - -Sizzle.attr = function( elem, name ) { - // Set document vars if needed - if ( ( elem.ownerDocument || elem ) !== document ) { - setDocument( elem ); - } - - var fn = Expr.attrHandle[ name.toLowerCase() ], - // Don't get fooled by Object.prototype properties (jQuery #13807) - val = fn && hasOwn.call( Expr.attrHandle, name.toLowerCase() ) ? - fn( elem, name, !documentIsHTML ) : - undefined; - - return val !== undefined ? - val : - support.attributes || !documentIsHTML ? - elem.getAttribute( name ) : - (val = elem.getAttributeNode(name)) && val.specified ? - val.value : - null; -}; - -Sizzle.error = function( msg ) { - throw new Error( "Syntax error, unrecognized expression: " + msg ); -}; - -/** - * Document sorting and removing duplicates - * @param {ArrayLike} results - */ -Sizzle.uniqueSort = function( results ) { - var elem, - duplicates = [], - j = 0, - i = 0; - - // Unless we *know* we can detect duplicates, assume their presence - hasDuplicate = !support.detectDuplicates; - sortInput = !support.sortStable && results.slice( 0 ); - results.sort( sortOrder ); - - if ( hasDuplicate ) { - while ( (elem = results[i++]) ) { - if ( elem === results[ i ] ) { - j = duplicates.push( i ); - } - } - while ( j-- ) { - results.splice( duplicates[ j ], 1 ); - } - } - - // Clear input after sorting to release objects - // See https://github.com/jquery/sizzle/pull/225 - sortInput = null; - - return results; -}; - -/** - * Utility function for retrieving the text value of an array of DOM nodes - * @param {Array|Element} elem - */ -getText = Sizzle.getText = function( elem ) { - var node, - ret = "", - i = 0, - nodeType = elem.nodeType; - - if ( !nodeType ) { - // If no nodeType, this is expected to be an array - while ( (node = elem[i++]) ) { - // Do not traverse comment nodes - ret += getText( node ); - } - } else if ( nodeType === 1 || nodeType === 9 || nodeType === 11 ) { - // Use textContent for elements - // innerText usage removed for consistency of new lines (jQuery #11153) - if ( typeof elem.textContent === "string" ) { - return elem.textContent; - } else { - // Traverse its children - for ( elem = elem.firstChild; elem; elem = elem.nextSibling ) { - ret += getText( elem ); - } - } - } else if ( nodeType === 3 || nodeType === 4 ) { - return elem.nodeValue; - } - // Do not include comment or processing instruction nodes - - return ret; -}; - -Expr = Sizzle.selectors = { - - // Can be adjusted by the user - cacheLength: 50, - - createPseudo: markFunction, - - match: matchExpr, - - attrHandle: {}, - - find: {}, - - relative: { - ">": { dir: "parentNode", first: true }, - " ": { dir: "parentNode" }, - "+": { dir: "previousSibling", first: true }, - "~": { dir: "previousSibling" } - }, - - preFilter: { - "ATTR": function( match ) { - match[1] = match[1].replace( runescape, funescape ); - - // Move the given value to match[3] whether quoted or unquoted - match[3] = ( match[3] || match[4] || match[5] || "" ).replace( runescape, funescape ); - - if ( match[2] === "~=" ) { - match[3] = " " + match[3] + " "; - } - - return match.slice( 0, 4 ); - }, - - "CHILD": function( match ) { - /* matches from matchExpr["CHILD"] - 1 type (only|nth|...) - 2 what (child|of-type) - 3 argument (even|odd|\d*|\d*n([+-]\d+)?|...) - 4 xn-component of xn+y argument ([+-]?\d*n|) - 5 sign of xn-component - 6 x of xn-component - 7 sign of y-component - 8 y of y-component - */ - match[1] = match[1].toLowerCase(); - - if ( match[1].slice( 0, 3 ) === "nth" ) { - // nth-* requires argument - if ( !match[3] ) { - Sizzle.error( match[0] ); - } - - // numeric x and y parameters for Expr.filter.CHILD - // remember that false/true cast respectively to 0/1 - match[4] = +( match[4] ? match[5] + (match[6] || 1) : 2 * ( match[3] === "even" || match[3] === "odd" ) ); - match[5] = +( ( match[7] + match[8] ) || match[3] === "odd" ); - - // other types prohibit arguments - } else if ( match[3] ) { - Sizzle.error( match[0] ); - } - - return match; - }, - - "PSEUDO": function( match ) { - var excess, - unquoted = !match[6] && match[2]; - - if ( matchExpr["CHILD"].test( match[0] ) ) { - return null; - } - - // Accept quoted arguments as-is - if ( match[3] ) { - match[2] = match[4] || match[5] || ""; - - // Strip excess characters from unquoted arguments - } else if ( unquoted && rpseudo.test( unquoted ) && - // Get excess from tokenize (recursively) - (excess = tokenize( unquoted, true )) && - // advance to the next closing parenthesis - (excess = unquoted.indexOf( ")", unquoted.length - excess ) - unquoted.length) ) { - - // excess is a negative index - match[0] = match[0].slice( 0, excess ); - match[2] = unquoted.slice( 0, excess ); - } - - // Return only captures needed by the pseudo filter method (type and argument) - return match.slice( 0, 3 ); - } - }, - - filter: { - - "TAG": function( nodeNameSelector ) { - var nodeName = nodeNameSelector.replace( runescape, funescape ).toLowerCase(); - return nodeNameSelector === "*" ? - function() { return true; } : - function( elem ) { - return elem.nodeName && elem.nodeName.toLowerCase() === nodeName; - }; - }, - - "CLASS": function( className ) { - var pattern = classCache[ className + " " ]; - - return pattern || - (pattern = new RegExp( "(^|" + whitespace + ")" + className + "(" + whitespace + "|$)" )) && - classCache( className, function( elem ) { - return pattern.test( typeof elem.className === "string" && elem.className || typeof elem.getAttribute !== "undefined" && elem.getAttribute("class") || "" ); - }); - }, - - "ATTR": function( name, operator, check ) { - return function( elem ) { - var result = Sizzle.attr( elem, name ); - - if ( result == null ) { - return operator === "!="; - } - if ( !operator ) { - return true; - } - - result += ""; - - return operator === "=" ? result === check : - operator === "!=" ? result !== check : - operator === "^=" ? check && result.indexOf( check ) === 0 : - operator === "*=" ? check && result.indexOf( check ) > -1 : - operator === "$=" ? check && result.slice( -check.length ) === check : - operator === "~=" ? ( " " + result.replace( rwhitespace, " " ) + " " ).indexOf( check ) > -1 : - operator === "|=" ? result === check || result.slice( 0, check.length + 1 ) === check + "-" : - false; - }; - }, - - "CHILD": function( type, what, argument, first, last ) { - var simple = type.slice( 0, 3 ) !== "nth", - forward = type.slice( -4 ) !== "last", - ofType = what === "of-type"; - - return first === 1 && last === 0 ? - - // Shortcut for :nth-*(n) - function( elem ) { - return !!elem.parentNode; - } : - - function( elem, context, xml ) { - var cache, outerCache, node, diff, nodeIndex, start, - dir = simple !== forward ? "nextSibling" : "previousSibling", - parent = elem.parentNode, - name = ofType && elem.nodeName.toLowerCase(), - useCache = !xml && !ofType; - - if ( parent ) { - - // :(first|last|only)-(child|of-type) - if ( simple ) { - while ( dir ) { - node = elem; - while ( (node = node[ dir ]) ) { - if ( ofType ? node.nodeName.toLowerCase() === name : node.nodeType === 1 ) { - return false; - } - } - // Reverse direction for :only-* (if we haven't yet done so) - start = dir = type === "only" && !start && "nextSibling"; - } - return true; - } - - start = [ forward ? parent.firstChild : parent.lastChild ]; - - // non-xml :nth-child(...) stores cache data on `parent` - if ( forward && useCache ) { - // Seek `elem` from a previously-cached index - outerCache = parent[ expando ] || (parent[ expando ] = {}); - cache = outerCache[ type ] || []; - nodeIndex = cache[0] === dirruns && cache[1]; - diff = cache[0] === dirruns && cache[2]; - node = nodeIndex && parent.childNodes[ nodeIndex ]; - - while ( (node = ++nodeIndex && node && node[ dir ] || - - // Fallback to seeking `elem` from the start - (diff = nodeIndex = 0) || start.pop()) ) { - - // When found, cache indexes on `parent` and break - if ( node.nodeType === 1 && ++diff && node === elem ) { - outerCache[ type ] = [ dirruns, nodeIndex, diff ]; - break; - } - } - - // Use previously-cached element index if available - } else if ( useCache && (cache = (elem[ expando ] || (elem[ expando ] = {}))[ type ]) && cache[0] === dirruns ) { - diff = cache[1]; - - // xml :nth-child(...) or :nth-last-child(...) or :nth(-last)?-of-type(...) - } else { - // Use the same loop as above to seek `elem` from the start - while ( (node = ++nodeIndex && node && node[ dir ] || - (diff = nodeIndex = 0) || start.pop()) ) { - - if ( ( ofType ? node.nodeName.toLowerCase() === name : node.nodeType === 1 ) && ++diff ) { - // Cache the index of each encountered element - if ( useCache ) { - (node[ expando ] || (node[ expando ] = {}))[ type ] = [ dirruns, diff ]; - } - - if ( node === elem ) { - break; - } - } - } - } - - // Incorporate the offset, then check against cycle size - diff -= last; - return diff === first || ( diff % first === 0 && diff / first >= 0 ); - } - }; - }, - - "PSEUDO": function( pseudo, argument ) { - // pseudo-class names are case-insensitive - // http://www.w3.org/TR/selectors/#pseudo-classes - // Prioritize by case sensitivity in case custom pseudos are added with uppercase letters - // Remember that setFilters inherits from pseudos - var args, - fn = Expr.pseudos[ pseudo ] || Expr.setFilters[ pseudo.toLowerCase() ] || - Sizzle.error( "unsupported pseudo: " + pseudo ); - - // The user may use createPseudo to indicate that - // arguments are needed to create the filter function - // just as Sizzle does - if ( fn[ expando ] ) { - return fn( argument ); - } - - // But maintain support for old signatures - if ( fn.length > 1 ) { - args = [ pseudo, pseudo, "", argument ]; - return Expr.setFilters.hasOwnProperty( pseudo.toLowerCase() ) ? - markFunction(function( seed, matches ) { - var idx, - matched = fn( seed, argument ), - i = matched.length; - while ( i-- ) { - idx = indexOf( seed, matched[i] ); - seed[ idx ] = !( matches[ idx ] = matched[i] ); - } - }) : - function( elem ) { - return fn( elem, 0, args ); - }; - } - - return fn; - } - }, - - pseudos: { - // Potentially complex pseudos - "not": markFunction(function( selector ) { - // Trim the selector passed to compile - // to avoid treating leading and trailing - // spaces as combinators - var input = [], - results = [], - matcher = compile( selector.replace( rtrim, "$1" ) ); - - return matcher[ expando ] ? - markFunction(function( seed, matches, context, xml ) { - var elem, - unmatched = matcher( seed, null, xml, [] ), - i = seed.length; - - // Match elements unmatched by `matcher` - while ( i-- ) { - if ( (elem = unmatched[i]) ) { - seed[i] = !(matches[i] = elem); - } - } - }) : - function( elem, context, xml ) { - input[0] = elem; - matcher( input, null, xml, results ); - // Don't keep the element (issue #299) - input[0] = null; - return !results.pop(); - }; - }), - - "has": markFunction(function( selector ) { - return function( elem ) { - return Sizzle( selector, elem ).length > 0; - }; - }), - - "contains": markFunction(function( text ) { - text = text.replace( runescape, funescape ); - return function( elem ) { - return ( elem.textContent || elem.innerText || getText( elem ) ).indexOf( text ) > -1; - }; - }), - - // "Whether an element is represented by a :lang() selector - // is based solely on the element's language value - // being equal to the identifier C, - // or beginning with the identifier C immediately followed by "-". - // The matching of C against the element's language value is performed case-insensitively. - // The identifier C does not have to be a valid language name." - // http://www.w3.org/TR/selectors/#lang-pseudo - "lang": markFunction( function( lang ) { - // lang value must be a valid identifier - if ( !ridentifier.test(lang || "") ) { - Sizzle.error( "unsupported lang: " + lang ); - } - lang = lang.replace( runescape, funescape ).toLowerCase(); - return function( elem ) { - var elemLang; - do { - if ( (elemLang = documentIsHTML ? - elem.lang : - elem.getAttribute("xml:lang") || elem.getAttribute("lang")) ) { - - elemLang = elemLang.toLowerCase(); - return elemLang === lang || elemLang.indexOf( lang + "-" ) === 0; - } - } while ( (elem = elem.parentNode) && elem.nodeType === 1 ); - return false; - }; - }), - - // Miscellaneous - "target": function( elem ) { - var hash = window.location && window.location.hash; - return hash && hash.slice( 1 ) === elem.id; - }, - - "root": function( elem ) { - return elem === docElem; - }, - - "focus": function( elem ) { - return elem === document.activeElement && (!document.hasFocus || document.hasFocus()) && !!(elem.type || elem.href || ~elem.tabIndex); - }, - - // Boolean properties - "enabled": function( elem ) { - return elem.disabled === false; - }, - - "disabled": function( elem ) { - return elem.disabled === true; - }, - - "checked": function( elem ) { - // In CSS3, :checked should return both checked and selected elements - // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked - var nodeName = elem.nodeName.toLowerCase(); - return (nodeName === "input" && !!elem.checked) || (nodeName === "option" && !!elem.selected); - }, - - "selected": function( elem ) { - // Accessing this property makes selected-by-default - // options in Safari work properly - if ( elem.parentNode ) { - elem.parentNode.selectedIndex; - } - - return elem.selected === true; - }, - - // Contents - "empty": function( elem ) { - // http://www.w3.org/TR/selectors/#empty-pseudo - // :empty is negated by element (1) or content nodes (text: 3; cdata: 4; entity ref: 5), - // but not by others (comment: 8; processing instruction: 7; etc.) - // nodeType < 6 works because attributes (2) do not appear as children - for ( elem = elem.firstChild; elem; elem = elem.nextSibling ) { - if ( elem.nodeType < 6 ) { - return false; - } - } - return true; - }, - - "parent": function( elem ) { - return !Expr.pseudos["empty"]( elem ); - }, - - // Element/input types - "header": function( elem ) { - return rheader.test( elem.nodeName ); - }, - - "input": function( elem ) { - return rinputs.test( elem.nodeName ); - }, - - "button": function( elem ) { - var name = elem.nodeName.toLowerCase(); - return name === "input" && elem.type === "button" || name === "button"; - }, - - "text": function( elem ) { - var attr; - return elem.nodeName.toLowerCase() === "input" && - elem.type === "text" && - - // Support: IE<8 - // New HTML5 attribute values (e.g., "search") appear with elem.type === "text" - ( (attr = elem.getAttribute("type")) == null || attr.toLowerCase() === "text" ); - }, - - // Position-in-collection - "first": createPositionalPseudo(function() { - return [ 0 ]; - }), - - "last": createPositionalPseudo(function( matchIndexes, length ) { - return [ length - 1 ]; - }), - - "eq": createPositionalPseudo(function( matchIndexes, length, argument ) { - return [ argument < 0 ? argument + length : argument ]; - }), - - "even": createPositionalPseudo(function( matchIndexes, length ) { - var i = 0; - for ( ; i < length; i += 2 ) { - matchIndexes.push( i ); - } - return matchIndexes; - }), - - "odd": createPositionalPseudo(function( matchIndexes, length ) { - var i = 1; - for ( ; i < length; i += 2 ) { - matchIndexes.push( i ); - } - return matchIndexes; - }), - - "lt": createPositionalPseudo(function( matchIndexes, length, argument ) { - var i = argument < 0 ? argument + length : argument; - for ( ; --i >= 0; ) { - matchIndexes.push( i ); - } - return matchIndexes; - }), - - "gt": createPositionalPseudo(function( matchIndexes, length, argument ) { - var i = argument < 0 ? argument + length : argument; - for ( ; ++i < length; ) { - matchIndexes.push( i ); - } - return matchIndexes; - }) - } -}; - -Expr.pseudos["nth"] = Expr.pseudos["eq"]; - -// Add button/input type pseudos -for ( i in { radio: true, checkbox: true, file: true, password: true, image: true } ) { - Expr.pseudos[ i ] = createInputPseudo( i ); -} -for ( i in { submit: true, reset: true } ) { - Expr.pseudos[ i ] = createButtonPseudo( i ); -} - -// Easy API for creating new setFilters -function setFilters() {} -setFilters.prototype = Expr.filters = Expr.pseudos; -Expr.setFilters = new setFilters(); - -tokenize = Sizzle.tokenize = function( selector, parseOnly ) { - var matched, match, tokens, type, - soFar, groups, preFilters, - cached = tokenCache[ selector + " " ]; - - if ( cached ) { - return parseOnly ? 0 : cached.slice( 0 ); - } - - soFar = selector; - groups = []; - preFilters = Expr.preFilter; - - while ( soFar ) { - - // Comma and first run - if ( !matched || (match = rcomma.exec( soFar )) ) { - if ( match ) { - // Don't consume trailing commas as valid - soFar = soFar.slice( match[0].length ) || soFar; - } - groups.push( (tokens = []) ); - } - - matched = false; - - // Combinators - if ( (match = rcombinators.exec( soFar )) ) { - matched = match.shift(); - tokens.push({ - value: matched, - // Cast descendant combinators to space - type: match[0].replace( rtrim, " " ) - }); - soFar = soFar.slice( matched.length ); - } - - // Filters - for ( type in Expr.filter ) { - if ( (match = matchExpr[ type ].exec( soFar )) && (!preFilters[ type ] || - (match = preFilters[ type ]( match ))) ) { - matched = match.shift(); - tokens.push({ - value: matched, - type: type, - matches: match - }); - soFar = soFar.slice( matched.length ); - } - } - - if ( !matched ) { - break; - } - } - - // Return the length of the invalid excess - // if we're just parsing - // Otherwise, throw an error or return tokens - return parseOnly ? - soFar.length : - soFar ? - Sizzle.error( selector ) : - // Cache the tokens - tokenCache( selector, groups ).slice( 0 ); -}; - -function toSelector( tokens ) { - var i = 0, - len = tokens.length, - selector = ""; - for ( ; i < len; i++ ) { - selector += tokens[i].value; - } - return selector; -} - -function addCombinator( matcher, combinator, base ) { - var dir = combinator.dir, - checkNonElements = base && dir === "parentNode", - doneName = done++; - - return combinator.first ? - // Check against closest ancestor/preceding element - function( elem, context, xml ) { - while ( (elem = elem[ dir ]) ) { - if ( elem.nodeType === 1 || checkNonElements ) { - return matcher( elem, context, xml ); - } - } - } : - - // Check against all ancestor/preceding elements - function( elem, context, xml ) { - var oldCache, outerCache, - newCache = [ dirruns, doneName ]; - - // We can't set arbitrary data on XML nodes, so they don't benefit from dir caching - if ( xml ) { - while ( (elem = elem[ dir ]) ) { - if ( elem.nodeType === 1 || checkNonElements ) { - if ( matcher( elem, context, xml ) ) { - return true; - } - } - } - } else { - while ( (elem = elem[ dir ]) ) { - if ( elem.nodeType === 1 || checkNonElements ) { - outerCache = elem[ expando ] || (elem[ expando ] = {}); - if ( (oldCache = outerCache[ dir ]) && - oldCache[ 0 ] === dirruns && oldCache[ 1 ] === doneName ) { - - // Assign to newCache so results back-propagate to previous elements - return (newCache[ 2 ] = oldCache[ 2 ]); - } else { - // Reuse newcache so results back-propagate to previous elements - outerCache[ dir ] = newCache; - - // A match means we're done; a fail means we have to keep checking - if ( (newCache[ 2 ] = matcher( elem, context, xml )) ) { - return true; - } - } - } - } - } - }; -} - -function elementMatcher( matchers ) { - return matchers.length > 1 ? - function( elem, context, xml ) { - var i = matchers.length; - while ( i-- ) { - if ( !matchers[i]( elem, context, xml ) ) { - return false; - } - } - return true; - } : - matchers[0]; -} - -function multipleContexts( selector, contexts, results ) { - var i = 0, - len = contexts.length; - for ( ; i < len; i++ ) { - Sizzle( selector, contexts[i], results ); - } - return results; -} - -function condense( unmatched, map, filter, context, xml ) { - var elem, - newUnmatched = [], - i = 0, - len = unmatched.length, - mapped = map != null; - - for ( ; i < len; i++ ) { - if ( (elem = unmatched[i]) ) { - if ( !filter || filter( elem, context, xml ) ) { - newUnmatched.push( elem ); - if ( mapped ) { - map.push( i ); - } - } - } - } - - return newUnmatched; -} - -function setMatcher( preFilter, selector, matcher, postFilter, postFinder, postSelector ) { - if ( postFilter && !postFilter[ expando ] ) { - postFilter = setMatcher( postFilter ); - } - if ( postFinder && !postFinder[ expando ] ) { - postFinder = setMatcher( postFinder, postSelector ); - } - return markFunction(function( seed, results, context, xml ) { - var temp, i, elem, - preMap = [], - postMap = [], - preexisting = results.length, - - // Get initial elements from seed or context - elems = seed || multipleContexts( selector || "*", context.nodeType ? [ context ] : context, [] ), - - // Prefilter to get matcher input, preserving a map for seed-results synchronization - matcherIn = preFilter && ( seed || !selector ) ? - condense( elems, preMap, preFilter, context, xml ) : - elems, - - matcherOut = matcher ? - // If we have a postFinder, or filtered seed, or non-seed postFilter or preexisting results, - postFinder || ( seed ? preFilter : preexisting || postFilter ) ? - - // ...intermediate processing is necessary - [] : - - // ...otherwise use results directly - results : - matcherIn; - - // Find primary matches - if ( matcher ) { - matcher( matcherIn, matcherOut, context, xml ); - } - - // Apply postFilter - if ( postFilter ) { - temp = condense( matcherOut, postMap ); - postFilter( temp, [], context, xml ); - - // Un-match failing elements by moving them back to matcherIn - i = temp.length; - while ( i-- ) { - if ( (elem = temp[i]) ) { - matcherOut[ postMap[i] ] = !(matcherIn[ postMap[i] ] = elem); - } - } - } - - if ( seed ) { - if ( postFinder || preFilter ) { - if ( postFinder ) { - // Get the final matcherOut by condensing this intermediate into postFinder contexts - temp = []; - i = matcherOut.length; - while ( i-- ) { - if ( (elem = matcherOut[i]) ) { - // Restore matcherIn since elem is not yet a final match - temp.push( (matcherIn[i] = elem) ); - } - } - postFinder( null, (matcherOut = []), temp, xml ); - } - - // Move matched elements from seed to results to keep them synchronized - i = matcherOut.length; - while ( i-- ) { - if ( (elem = matcherOut[i]) && - (temp = postFinder ? indexOf( seed, elem ) : preMap[i]) > -1 ) { - - seed[temp] = !(results[temp] = elem); - } - } - } - - // Add elements to results, through postFinder if defined - } else { - matcherOut = condense( - matcherOut === results ? - matcherOut.splice( preexisting, matcherOut.length ) : - matcherOut - ); - if ( postFinder ) { - postFinder( null, results, matcherOut, xml ); - } else { - push.apply( results, matcherOut ); - } - } - }); -} - -function matcherFromTokens( tokens ) { - var checkContext, matcher, j, - len = tokens.length, - leadingRelative = Expr.relative[ tokens[0].type ], - implicitRelative = leadingRelative || Expr.relative[" "], - i = leadingRelative ? 1 : 0, - - // The foundational matcher ensures that elements are reachable from top-level context(s) - matchContext = addCombinator( function( elem ) { - return elem === checkContext; - }, implicitRelative, true ), - matchAnyContext = addCombinator( function( elem ) { - return indexOf( checkContext, elem ) > -1; - }, implicitRelative, true ), - matchers = [ function( elem, context, xml ) { - var ret = ( !leadingRelative && ( xml || context !== outermostContext ) ) || ( - (checkContext = context).nodeType ? - matchContext( elem, context, xml ) : - matchAnyContext( elem, context, xml ) ); - // Avoid hanging onto element (issue #299) - checkContext = null; - return ret; - } ]; - - for ( ; i < len; i++ ) { - if ( (matcher = Expr.relative[ tokens[i].type ]) ) { - matchers = [ addCombinator(elementMatcher( matchers ), matcher) ]; - } else { - matcher = Expr.filter[ tokens[i].type ].apply( null, tokens[i].matches ); - - // Return special upon seeing a positional matcher - if ( matcher[ expando ] ) { - // Find the next relative operator (if any) for proper handling - j = ++i; - for ( ; j < len; j++ ) { - if ( Expr.relative[ tokens[j].type ] ) { - break; - } - } - return setMatcher( - i > 1 && elementMatcher( matchers ), - i > 1 && toSelector( - // If the preceding token was a descendant combinator, insert an implicit any-element `*` - tokens.slice( 0, i - 1 ).concat({ value: tokens[ i - 2 ].type === " " ? "*" : "" }) - ).replace( rtrim, "$1" ), - matcher, - i < j && matcherFromTokens( tokens.slice( i, j ) ), - j < len && matcherFromTokens( (tokens = tokens.slice( j )) ), - j < len && toSelector( tokens ) - ); - } - matchers.push( matcher ); - } - } - - return elementMatcher( matchers ); -} - -function matcherFromGroupMatchers( elementMatchers, setMatchers ) { - var bySet = setMatchers.length > 0, - byElement = elementMatchers.length > 0, - superMatcher = function( seed, context, xml, results, outermost ) { - var elem, j, matcher, - matchedCount = 0, - i = "0", - unmatched = seed && [], - setMatched = [], - contextBackup = outermostContext, - // We must always have either seed elements or outermost context - elems = seed || byElement && Expr.find["TAG"]( "*", outermost ), - // Use integer dirruns iff this is the outermost matcher - dirrunsUnique = (dirruns += contextBackup == null ? 1 : Math.random() || 0.1), - len = elems.length; - - if ( outermost ) { - outermostContext = context !== document && context; - } - - // Add elements passing elementMatchers directly to results - // Keep `i` a string if there are no elements so `matchedCount` will be "00" below - // Support: IE<9, Safari - // Tolerate NodeList properties (IE: "length"; Safari: <number>) matching elements by id - for ( ; i !== len && (elem = elems[i]) != null; i++ ) { - if ( byElement && elem ) { - j = 0; - while ( (matcher = elementMatchers[j++]) ) { - if ( matcher( elem, context, xml ) ) { - results.push( elem ); - break; - } - } - if ( outermost ) { - dirruns = dirrunsUnique; - } - } - - // Track unmatched elements for set filters - if ( bySet ) { - // They will have gone through all possible matchers - if ( (elem = !matcher && elem) ) { - matchedCount--; - } - - // Lengthen the array for every element, matched or not - if ( seed ) { - unmatched.push( elem ); - } - } - } - - // Apply set filters to unmatched elements - matchedCount += i; - if ( bySet && i !== matchedCount ) { - j = 0; - while ( (matcher = setMatchers[j++]) ) { - matcher( unmatched, setMatched, context, xml ); - } - - if ( seed ) { - // Reintegrate element matches to eliminate the need for sorting - if ( matchedCount > 0 ) { - while ( i-- ) { - if ( !(unmatched[i] || setMatched[i]) ) { - setMatched[i] = pop.call( results ); - } - } - } - - // Discard index placeholder values to get only actual matches - setMatched = condense( setMatched ); - } - - // Add matches to results - push.apply( results, setMatched ); - - // Seedless set matches succeeding multiple successful matchers stipulate sorting - if ( outermost && !seed && setMatched.length > 0 && - ( matchedCount + setMatchers.length ) > 1 ) { - - Sizzle.uniqueSort( results ); - } - } - - // Override manipulation of globals by nested matchers - if ( outermost ) { - dirruns = dirrunsUnique; - outermostContext = contextBackup; - } - - return unmatched; - }; - - return bySet ? - markFunction( superMatcher ) : - superMatcher; -} - -compile = Sizzle.compile = function( selector, match /* Internal Use Only */ ) { - var i, - setMatchers = [], - elementMatchers = [], - cached = compilerCache[ selector + " " ]; - - if ( !cached ) { - // Generate a function of recursive functions that can be used to check each element - if ( !match ) { - match = tokenize( selector ); - } - i = match.length; - while ( i-- ) { - cached = matcherFromTokens( match[i] ); - if ( cached[ expando ] ) { - setMatchers.push( cached ); - } else { - elementMatchers.push( cached ); - } - } - - // Cache the compiled function - cached = compilerCache( selector, matcherFromGroupMatchers( elementMatchers, setMatchers ) ); - - // Save selector and tokenization - cached.selector = selector; - } - return cached; -}; - -/** - * A low-level selection function that works with Sizzle's compiled - * selector functions - * @param {String|Function} selector A selector or a pre-compiled - * selector function built with Sizzle.compile - * @param {Element} context - * @param {Array} [results] - * @param {Array} [seed] A set of elements to match against - */ -select = Sizzle.select = function( selector, context, results, seed ) { - var i, tokens, token, type, find, - compiled = typeof selector === "function" && selector, - match = !seed && tokenize( (selector = compiled.selector || selector) ); - - results = results || []; - - // Try to minimize operations if there is no seed and only one group - if ( match.length === 1 ) { - - // Take a shortcut and set the context if the root selector is an ID - tokens = match[0] = match[0].slice( 0 ); - if ( tokens.length > 2 && (token = tokens[0]).type === "ID" && - support.getById && context.nodeType === 9 && documentIsHTML && - Expr.relative[ tokens[1].type ] ) { - - context = ( Expr.find["ID"]( token.matches[0].replace(runescape, funescape), context ) || [] )[0]; - if ( !context ) { - return results; - - // Precompiled matchers will still verify ancestry, so step up a level - } else if ( compiled ) { - context = context.parentNode; - } - - selector = selector.slice( tokens.shift().value.length ); - } - - // Fetch a seed set for right-to-left matching - i = matchExpr["needsContext"].test( selector ) ? 0 : tokens.length; - while ( i-- ) { - token = tokens[i]; - - // Abort if we hit a combinator - if ( Expr.relative[ (type = token.type) ] ) { - break; - } - if ( (find = Expr.find[ type ]) ) { - // Search, expanding context for leading sibling combinators - if ( (seed = find( - token.matches[0].replace( runescape, funescape ), - rsibling.test( tokens[0].type ) && testContext( context.parentNode ) || context - )) ) { - - // If seed is empty or no tokens remain, we can return early - tokens.splice( i, 1 ); - selector = seed.length && toSelector( tokens ); - if ( !selector ) { - push.apply( results, seed ); - return results; - } - - break; - } - } - } - } - - // Compile and execute a filtering function if one is not provided - // Provide `match` to avoid retokenization if we modified the selector above - ( compiled || compile( selector, match ) )( - seed, - context, - !documentIsHTML, - results, - rsibling.test( selector ) && testContext( context.parentNode ) || context - ); - return results; -}; - -// One-time assignments - -// Sort stability -support.sortStable = expando.split("").sort( sortOrder ).join("") === expando; - -// Support: Chrome 14-35+ -// Always assume duplicates if they aren't passed to the comparison function -support.detectDuplicates = !!hasDuplicate; - -// Initialize against the default document -setDocument(); - -// Support: Webkit<537.32 - Safari 6.0.3/Chrome 25 (fixed in Chrome 27) -// Detached nodes confoundingly follow *each other* -support.sortDetached = assert(function( div1 ) { - // Should return 1, but returns 4 (following) - return div1.compareDocumentPosition( document.createElement("div") ) & 1; -}); - -// Support: IE<8 -// Prevent attribute/property "interpolation" -// http://msdn.microsoft.com/en-us/library/ms536429%28VS.85%29.aspx -if ( !assert(function( div ) { - div.innerHTML = "<a href='#'></a>"; - return div.firstChild.getAttribute("href") === "#" ; -}) ) { - addHandle( "type|href|height|width", function( elem, name, isXML ) { - if ( !isXML ) { - return elem.getAttribute( name, name.toLowerCase() === "type" ? 1 : 2 ); - } - }); -} - -// Support: IE<9 -// Use defaultValue in place of getAttribute("value") -if ( !support.attributes || !assert(function( div ) { - div.innerHTML = "<input/>"; - div.firstChild.setAttribute( "value", "" ); - return div.firstChild.getAttribute( "value" ) === ""; -}) ) { - addHandle( "value", function( elem, name, isXML ) { - if ( !isXML && elem.nodeName.toLowerCase() === "input" ) { - return elem.defaultValue; - } - }); -} - -// Support: IE<9 -// Use getAttributeNode to fetch booleans when getAttribute lies -if ( !assert(function( div ) { - return div.getAttribute("disabled") == null; -}) ) { - addHandle( booleans, function( elem, name, isXML ) { - var val; - if ( !isXML ) { - return elem[ name ] === true ? name.toLowerCase() : - (val = elem.getAttributeNode( name )) && val.specified ? - val.value : - null; - } - }); -} - -return Sizzle; - -})( window ); - - - -jQuery.find = Sizzle; -jQuery.expr = Sizzle.selectors; -jQuery.expr[":"] = jQuery.expr.pseudos; -jQuery.unique = Sizzle.uniqueSort; -jQuery.text = Sizzle.getText; -jQuery.isXMLDoc = Sizzle.isXML; -jQuery.contains = Sizzle.contains; - - - -var rneedsContext = jQuery.expr.match.needsContext; - -var rsingleTag = (/^<(\w+)\s*\/?>(?:<\/\1>|)$/); - - - -var risSimple = /^.[^:#\[\.,]*$/; - -// Implement the identical functionality for filter and not -function winnow( elements, qualifier, not ) { - if ( jQuery.isFunction( qualifier ) ) { - return jQuery.grep( elements, function( elem, i ) { - /* jshint -W018 */ - return !!qualifier.call( elem, i, elem ) !== not; - }); - - } - - if ( qualifier.nodeType ) { - return jQuery.grep( elements, function( elem ) { - return ( elem === qualifier ) !== not; - }); - - } - - if ( typeof qualifier === "string" ) { - if ( risSimple.test( qualifier ) ) { - return jQuery.filter( qualifier, elements, not ); - } - - qualifier = jQuery.filter( qualifier, elements ); - } - - return jQuery.grep( elements, function( elem ) { - return ( jQuery.inArray( elem, qualifier ) >= 0 ) !== not; - }); -} - -jQuery.filter = function( expr, elems, not ) { - var elem = elems[ 0 ]; - - if ( not ) { - expr = ":not(" + expr + ")"; - } - - return elems.length === 1 && elem.nodeType === 1 ? - jQuery.find.matchesSelector( elem, expr ) ? [ elem ] : [] : - jQuery.find.matches( expr, jQuery.grep( elems, function( elem ) { - return elem.nodeType === 1; - })); -}; - -jQuery.fn.extend({ - find: function( selector ) { - var i, - ret = [], - self = this, - len = self.length; - - if ( typeof selector !== "string" ) { - return this.pushStack( jQuery( selector ).filter(function() { - for ( i = 0; i < len; i++ ) { - if ( jQuery.contains( self[ i ], this ) ) { - return true; - } - } - }) ); - } - - for ( i = 0; i < len; i++ ) { - jQuery.find( selector, self[ i ], ret ); - } - - // Needed because $( selector, context ) becomes $( context ).find( selector ) - ret = this.pushStack( len > 1 ? jQuery.unique( ret ) : ret ); - ret.selector = this.selector ? this.selector + " " + selector : selector; - return ret; - }, - filter: function( selector ) { - return this.pushStack( winnow(this, selector || [], false) ); - }, - not: function( selector ) { - return this.pushStack( winnow(this, selector || [], true) ); - }, - is: function( selector ) { - return !!winnow( - this, - - // If this is a positional/relative selector, check membership in the returned set - // so $("p:first").is("p:last") won't return true for a doc with two "p". - typeof selector === "string" && rneedsContext.test( selector ) ? - jQuery( selector ) : - selector || [], - false - ).length; - } -}); - - -// Initialize a jQuery object - - -// A central reference to the root jQuery(document) -var rootjQuery, - - // Use the correct document accordingly with window argument (sandbox) - document = window.document, - - // A simple way to check for HTML strings - // Prioritize #id over <tag> to avoid XSS via location.hash (#9521) - // Strict HTML recognition (#11290: must start with <) - rquickExpr = /^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/, - - init = jQuery.fn.init = function( selector, context ) { - var match, elem; - - // HANDLE: $(""), $(null), $(undefined), $(false) - if ( !selector ) { - return this; - } - - // Handle HTML strings - if ( typeof selector === "string" ) { - if ( selector.charAt(0) === "<" && selector.charAt( selector.length - 1 ) === ">" && selector.length >= 3 ) { - // Assume that strings that start and end with <> are HTML and skip the regex check - match = [ null, selector, null ]; - - } else { - match = rquickExpr.exec( selector ); - } - - // Match html or make sure no context is specified for #id - if ( match && (match[1] || !context) ) { - - // HANDLE: $(html) -> $(array) - if ( match[1] ) { - context = context instanceof jQuery ? context[0] : context; - - // scripts is true for back-compat - // Intentionally let the error be thrown if parseHTML is not present - jQuery.merge( this, jQuery.parseHTML( - match[1], - context && context.nodeType ? context.ownerDocument || context : document, - true - ) ); - - // HANDLE: $(html, props) - if ( rsingleTag.test( match[1] ) && jQuery.isPlainObject( context ) ) { - for ( match in context ) { - // Properties of context are called as methods if possible - if ( jQuery.isFunction( this[ match ] ) ) { - this[ match ]( context[ match ] ); - - // ...and otherwise set as attributes - } else { - this.attr( match, context[ match ] ); - } - } - } - - return this; - - // HANDLE: $(#id) - } else { - elem = document.getElementById( match[2] ); - - // Check parentNode to catch when Blackberry 4.6 returns - // nodes that are no longer in the document #6963 - if ( elem && elem.parentNode ) { - // Handle the case where IE and Opera return items - // by name instead of ID - if ( elem.id !== match[2] ) { - return rootjQuery.find( selector ); - } - - // Otherwise, we inject the element directly into the jQuery object - this.length = 1; - this[0] = elem; - } - - this.context = document; - this.selector = selector; - return this; - } - - // HANDLE: $(expr, $(...)) - } else if ( !context || context.jquery ) { - return ( context || rootjQuery ).find( selector ); - - // HANDLE: $(expr, context) - // (which is just equivalent to: $(context).find(expr) - } else { - return this.constructor( context ).find( selector ); - } - - // HANDLE: $(DOMElement) - } else if ( selector.nodeType ) { - this.context = this[0] = selector; - this.length = 1; - return this; - - // HANDLE: $(function) - // Shortcut for document ready - } else if ( jQuery.isFunction( selector ) ) { - return typeof rootjQuery.ready !== "undefined" ? - rootjQuery.ready( selector ) : - // Execute immediately if ready is not present - selector( jQuery ); - } - - if ( selector.selector !== undefined ) { - this.selector = selector.selector; - this.context = selector.context; - } - - return jQuery.makeArray( selector, this ); - }; - -// Give the init function the jQuery prototype for later instantiation -init.prototype = jQuery.fn; - -// Initialize central reference -rootjQuery = jQuery( document ); - - -var rparentsprev = /^(?:parents|prev(?:Until|All))/, - // methods guaranteed to produce a unique set when starting from a unique set - guaranteedUnique = { - children: true, - contents: true, - next: true, - prev: true - }; - -jQuery.extend({ - dir: function( elem, dir, until ) { - var matched = [], - cur = elem[ dir ]; - - while ( cur && cur.nodeType !== 9 && (until === undefined || cur.nodeType !== 1 || !jQuery( cur ).is( until )) ) { - if ( cur.nodeType === 1 ) { - matched.push( cur ); - } - cur = cur[dir]; - } - return matched; - }, - - sibling: function( n, elem ) { - var r = []; - - for ( ; n; n = n.nextSibling ) { - if ( n.nodeType === 1 && n !== elem ) { - r.push( n ); - } - } - - return r; - } -}); - -jQuery.fn.extend({ - has: function( target ) { - var i, - targets = jQuery( target, this ), - len = targets.length; - - return this.filter(function() { - for ( i = 0; i < len; i++ ) { - if ( jQuery.contains( this, targets[i] ) ) { - return true; - } - } - }); - }, - - closest: function( selectors, context ) { - var cur, - i = 0, - l = this.length, - matched = [], - pos = rneedsContext.test( selectors ) || typeof selectors !== "string" ? - jQuery( selectors, context || this.context ) : - 0; - - for ( ; i < l; i++ ) { - for ( cur = this[i]; cur && cur !== context; cur = cur.parentNode ) { - // Always skip document fragments - if ( cur.nodeType < 11 && (pos ? - pos.index(cur) > -1 : - - // Don't pass non-elements to Sizzle - cur.nodeType === 1 && - jQuery.find.matchesSelector(cur, selectors)) ) { - - matched.push( cur ); - break; - } - } - } - - return this.pushStack( matched.length > 1 ? jQuery.unique( matched ) : matched ); - }, - - // Determine the position of an element within - // the matched set of elements - index: function( elem ) { - - // No argument, return index in parent - if ( !elem ) { - return ( this[0] && this[0].parentNode ) ? this.first().prevAll().length : -1; - } - - // index in selector - if ( typeof elem === "string" ) { - return jQuery.inArray( this[0], jQuery( elem ) ); - } - - // Locate the position of the desired element - return jQuery.inArray( - // If it receives a jQuery object, the first element is used - elem.jquery ? elem[0] : elem, this ); - }, - - add: function( selector, context ) { - return this.pushStack( - jQuery.unique( - jQuery.merge( this.get(), jQuery( selector, context ) ) - ) - ); - }, - - addBack: function( selector ) { - return this.add( selector == null ? - this.prevObject : this.prevObject.filter(selector) - ); - } -}); - -function sibling( cur, dir ) { - do { - cur = cur[ dir ]; - } while ( cur && cur.nodeType !== 1 ); - - return cur; -} - -jQuery.each({ - parent: function( elem ) { - var parent = elem.parentNode; - return parent && parent.nodeType !== 11 ? parent : null; - }, - parents: function( elem ) { - return jQuery.dir( elem, "parentNode" ); - }, - parentsUntil: function( elem, i, until ) { - return jQuery.dir( elem, "parentNode", until ); - }, - next: function( elem ) { - return sibling( elem, "nextSibling" ); - }, - prev: function( elem ) { - return sibling( elem, "previousSibling" ); - }, - nextAll: function( elem ) { - return jQuery.dir( elem, "nextSibling" ); - }, - prevAll: function( elem ) { - return jQuery.dir( elem, "previousSibling" ); - }, - nextUntil: function( elem, i, until ) { - return jQuery.dir( elem, "nextSibling", until ); - }, - prevUntil: function( elem, i, until ) { - return jQuery.dir( elem, "previousSibling", until ); - }, - siblings: function( elem ) { - return jQuery.sibling( ( elem.parentNode || {} ).firstChild, elem ); - }, - children: function( elem ) { - return jQuery.sibling( elem.firstChild ); - }, - contents: function( elem ) { - return jQuery.nodeName( elem, "iframe" ) ? - elem.contentDocument || elem.contentWindow.document : - jQuery.merge( [], elem.childNodes ); - } -}, function( name, fn ) { - jQuery.fn[ name ] = function( until, selector ) { - var ret = jQuery.map( this, fn, until ); - - if ( name.slice( -5 ) !== "Until" ) { - selector = until; - } - - if ( selector && typeof selector === "string" ) { - ret = jQuery.filter( selector, ret ); - } - - if ( this.length > 1 ) { - // Remove duplicates - if ( !guaranteedUnique[ name ] ) { - ret = jQuery.unique( ret ); - } - - // Reverse order for parents* and prev-derivatives - if ( rparentsprev.test( name ) ) { - ret = ret.reverse(); - } - } - - return this.pushStack( ret ); - }; -}); -var rnotwhite = (/\S+/g); - - - -// String to Object options format cache -var optionsCache = {}; - -// Convert String-formatted options into Object-formatted ones and store in cache -function createOptions( options ) { - var object = optionsCache[ options ] = {}; - jQuery.each( options.match( rnotwhite ) || [], function( _, flag ) { - object[ flag ] = true; - }); - return object; -} - -/* - * Create a callback list using the following parameters: - * - * options: an optional list of space-separated options that will change how - * the callback list behaves or a more traditional option object - * - * By default a callback list will act like an event callback list and can be - * "fired" multiple times. - * - * Possible options: - * - * once: will ensure the callback list can only be fired once (like a Deferred) - * - * memory: will keep track of previous values and will call any callback added - * after the list has been fired right away with the latest "memorized" - * values (like a Deferred) - * - * unique: will ensure a callback can only be added once (no duplicate in the list) - * - * stopOnFalse: interrupt callings when a callback returns false - * - */ -jQuery.Callbacks = function( options ) { - - // Convert options from String-formatted to Object-formatted if needed - // (we check in cache first) - options = typeof options === "string" ? - ( optionsCache[ options ] || createOptions( options ) ) : - jQuery.extend( {}, options ); - - var // Flag to know if list is currently firing - firing, - // Last fire value (for non-forgettable lists) - memory, - // Flag to know if list was already fired - fired, - // End of the loop when firing - firingLength, - // Index of currently firing callback (modified by remove if needed) - firingIndex, - // First callback to fire (used internally by add and fireWith) - firingStart, - // Actual callback list - list = [], - // Stack of fire calls for repeatable lists - stack = !options.once && [], - // Fire callbacks - fire = function( data ) { - memory = options.memory && data; - fired = true; - firingIndex = firingStart || 0; - firingStart = 0; - firingLength = list.length; - firing = true; - for ( ; list && firingIndex < firingLength; firingIndex++ ) { - if ( list[ firingIndex ].apply( data[ 0 ], data[ 1 ] ) === false && options.stopOnFalse ) { - memory = false; // To prevent further calls using add - break; - } - } - firing = false; - if ( list ) { - if ( stack ) { - if ( stack.length ) { - fire( stack.shift() ); - } - } else if ( memory ) { - list = []; - } else { - self.disable(); - } - } - }, - // Actual Callbacks object - self = { - // Add a callback or a collection of callbacks to the list - add: function() { - if ( list ) { - // First, we save the current length - var start = list.length; - (function add( args ) { - jQuery.each( args, function( _, arg ) { - var type = jQuery.type( arg ); - if ( type === "function" ) { - if ( !options.unique || !self.has( arg ) ) { - list.push( arg ); - } - } else if ( arg && arg.length && type !== "string" ) { - // Inspect recursively - add( arg ); - } - }); - })( arguments ); - // Do we need to add the callbacks to the - // current firing batch? - if ( firing ) { - firingLength = list.length; - // With memory, if we're not firing then - // we should call right away - } else if ( memory ) { - firingStart = start; - fire( memory ); - } - } - return this; - }, - // Remove a callback from the list - remove: function() { - if ( list ) { - jQuery.each( arguments, function( _, arg ) { - var index; - while ( ( index = jQuery.inArray( arg, list, index ) ) > -1 ) { - list.splice( index, 1 ); - // Handle firing indexes - if ( firing ) { - if ( index <= firingLength ) { - firingLength--; - } - if ( index <= firingIndex ) { - firingIndex--; - } - } - } - }); - } - return this; - }, - // Check if a given callback is in the list. - // If no argument is given, return whether or not list has callbacks attached. - has: function( fn ) { - return fn ? jQuery.inArray( fn, list ) > -1 : !!( list && list.length ); - }, - // Remove all callbacks from the list - empty: function() { - list = []; - firingLength = 0; - return this; - }, - // Have the list do nothing anymore - disable: function() { - list = stack = memory = undefined; - return this; - }, - // Is it disabled? - disabled: function() { - return !list; - }, - // Lock the list in its current state - lock: function() { - stack = undefined; - if ( !memory ) { - self.disable(); - } - return this; - }, - // Is it locked? - locked: function() { - return !stack; - }, - // Call all callbacks with the given context and arguments - fireWith: function( context, args ) { - if ( list && ( !fired || stack ) ) { - args = args || []; - args = [ context, args.slice ? args.slice() : args ]; - if ( firing ) { - stack.push( args ); - } else { - fire( args ); - } - } - return this; - }, - // Call all the callbacks with the given arguments - fire: function() { - self.fireWith( this, arguments ); - return this; - }, - // To know if the callbacks have already been called at least once - fired: function() { - return !!fired; - } - }; - - return self; -}; - - -jQuery.extend({ - - Deferred: function( func ) { - var tuples = [ - // action, add listener, listener list, final state - [ "resolve", "done", jQuery.Callbacks("once memory"), "resolved" ], - [ "reject", "fail", jQuery.Callbacks("once memory"), "rejected" ], - [ "notify", "progress", jQuery.Callbacks("memory") ] - ], - state = "pending", - promise = { - state: function() { - return state; - }, - always: function() { - deferred.done( arguments ).fail( arguments ); - return this; - }, - then: function( /* fnDone, fnFail, fnProgress */ ) { - var fns = arguments; - return jQuery.Deferred(function( newDefer ) { - jQuery.each( tuples, function( i, tuple ) { - var fn = jQuery.isFunction( fns[ i ] ) && fns[ i ]; - // deferred[ done | fail | progress ] for forwarding actions to newDefer - deferred[ tuple[1] ](function() { - var returned = fn && fn.apply( this, arguments ); - if ( returned && jQuery.isFunction( returned.promise ) ) { - returned.promise() - .done( newDefer.resolve ) - .fail( newDefer.reject ) - .progress( newDefer.notify ); - } else { - newDefer[ tuple[ 0 ] + "With" ]( this === promise ? newDefer.promise() : this, fn ? [ returned ] : arguments ); - } - }); - }); - fns = null; - }).promise(); - }, - // Get a promise for this deferred - // If obj is provided, the promise aspect is added to the object - promise: function( obj ) { - return obj != null ? jQuery.extend( obj, promise ) : promise; - } - }, - deferred = {}; - - // Keep pipe for back-compat - promise.pipe = promise.then; - - // Add list-specific methods - jQuery.each( tuples, function( i, tuple ) { - var list = tuple[ 2 ], - stateString = tuple[ 3 ]; - - // promise[ done | fail | progress ] = list.add - promise[ tuple[1] ] = list.add; - - // Handle state - if ( stateString ) { - list.add(function() { - // state = [ resolved | rejected ] - state = stateString; - - // [ reject_list | resolve_list ].disable; progress_list.lock - }, tuples[ i ^ 1 ][ 2 ].disable, tuples[ 2 ][ 2 ].lock ); - } - - // deferred[ resolve | reject | notify ] - deferred[ tuple[0] ] = function() { - deferred[ tuple[0] + "With" ]( this === deferred ? promise : this, arguments ); - return this; - }; - deferred[ tuple[0] + "With" ] = list.fireWith; - }); - - // Make the deferred a promise - promise.promise( deferred ); - - // Call given func if any - if ( func ) { - func.call( deferred, deferred ); - } - - // All done! - return deferred; - }, - - // Deferred helper - when: function( subordinate /* , ..., subordinateN */ ) { - var i = 0, - resolveValues = slice.call( arguments ), - length = resolveValues.length, - - // the count of uncompleted subordinates - remaining = length !== 1 || ( subordinate && jQuery.isFunction( subordinate.promise ) ) ? length : 0, - - // the master Deferred. If resolveValues consist of only a single Deferred, just use that. - deferred = remaining === 1 ? subordinate : jQuery.Deferred(), - - // Update function for both resolve and progress values - updateFunc = function( i, contexts, values ) { - return function( value ) { - contexts[ i ] = this; - values[ i ] = arguments.length > 1 ? slice.call( arguments ) : value; - if ( values === progressValues ) { - deferred.notifyWith( contexts, values ); - - } else if ( !(--remaining) ) { - deferred.resolveWith( contexts, values ); - } - }; - }, - - progressValues, progressContexts, resolveContexts; - - // add listeners to Deferred subordinates; treat others as resolved - if ( length > 1 ) { - progressValues = new Array( length ); - progressContexts = new Array( length ); - resolveContexts = new Array( length ); - for ( ; i < length; i++ ) { - if ( resolveValues[ i ] && jQuery.isFunction( resolveValues[ i ].promise ) ) { - resolveValues[ i ].promise() - .done( updateFunc( i, resolveContexts, resolveValues ) ) - .fail( deferred.reject ) - .progress( updateFunc( i, progressContexts, progressValues ) ); - } else { - --remaining; - } - } - } - - // if we're not waiting on anything, resolve the master - if ( !remaining ) { - deferred.resolveWith( resolveContexts, resolveValues ); - } - - return deferred.promise(); - } -}); - - -// The deferred used on DOM ready -var readyList; - -jQuery.fn.ready = function( fn ) { - // Add the callback - jQuery.ready.promise().done( fn ); - - return this; -}; - -jQuery.extend({ - // Is the DOM ready to be used? Set to true once it occurs. - isReady: false, - - // A counter to track how many items to wait for before - // the ready event fires. See #6781 - readyWait: 1, - - // Hold (or release) the ready event - holdReady: function( hold ) { - if ( hold ) { - jQuery.readyWait++; - } else { - jQuery.ready( true ); - } - }, - - // Handle when the DOM is ready - ready: function( wait ) { - - // Abort if there are pending holds or we're already ready - if ( wait === true ? --jQuery.readyWait : jQuery.isReady ) { - return; - } - - // Make sure body exists, at least, in case IE gets a little overzealous (ticket #5443). - if ( !document.body ) { - return setTimeout( jQuery.ready ); - } - - // Remember that the DOM is ready - jQuery.isReady = true; - - // If a normal DOM Ready event fired, decrement, and wait if need be - if ( wait !== true && --jQuery.readyWait > 0 ) { - return; - } - - // If there are functions bound, to execute - readyList.resolveWith( document, [ jQuery ] ); - - // Trigger any bound ready events - if ( jQuery.fn.triggerHandler ) { - jQuery( document ).triggerHandler( "ready" ); - jQuery( document ).off( "ready" ); - } - } -}); - -/** - * Clean-up method for dom ready events - */ -function detach() { - if ( document.addEventListener ) { - document.removeEventListener( "DOMContentLoaded", completed, false ); - window.removeEventListener( "load", completed, false ); - - } else { - document.detachEvent( "onreadystatechange", completed ); - window.detachEvent( "onload", completed ); - } -} - -/** - * The ready event handler and self cleanup method - */ -function completed() { - // readyState === "complete" is good enough for us to call the dom ready in oldIE - if ( document.addEventListener || event.type === "load" || document.readyState === "complete" ) { - detach(); - jQuery.ready(); - } -} - -jQuery.ready.promise = function( obj ) { - if ( !readyList ) { - - readyList = jQuery.Deferred(); - - // Catch cases where $(document).ready() is called after the browser event has already occurred. - // we once tried to use readyState "interactive" here, but it caused issues like the one - // discovered by ChrisS here: http://bugs.jquery.com/ticket/12282#comment:15 - if ( document.readyState === "complete" ) { - // Handle it asynchronously to allow scripts the opportunity to delay ready - setTimeout( jQuery.ready ); - - // Standards-based browsers support DOMContentLoaded - } else if ( document.addEventListener ) { - // Use the handy event callback - document.addEventListener( "DOMContentLoaded", completed, false ); - - // A fallback to window.onload, that will always work - window.addEventListener( "load", completed, false ); - - // If IE event model is used - } else { - // Ensure firing before onload, maybe late but safe also for iframes - document.attachEvent( "onreadystatechange", completed ); - - // A fallback to window.onload, that will always work - window.attachEvent( "onload", completed ); - - // If IE and not a frame - // continually check to see if the document is ready - var top = false; - - try { - top = window.frameElement == null && document.documentElement; - } catch(e) {} - - if ( top && top.doScroll ) { - (function doScrollCheck() { - if ( !jQuery.isReady ) { - - try { - // Use the trick by Diego Perini - // http://javascript.nwbox.com/IEContentLoaded/ - top.doScroll("left"); - } catch(e) { - return setTimeout( doScrollCheck, 50 ); - } - - // detach all dom ready events - detach(); - - // and execute any waiting functions - jQuery.ready(); - } - })(); - } - } - } - return readyList.promise( obj ); -}; - - -var strundefined = typeof undefined; - - - -// Support: IE<9 -// Iteration over object's inherited properties before its own -var i; -for ( i in jQuery( support ) ) { - break; -} -support.ownLast = i !== "0"; - -// Note: most support tests are defined in their respective modules. -// false until the test is run -support.inlineBlockNeedsLayout = false; - -// Execute ASAP in case we need to set body.style.zoom -jQuery(function() { - // Minified: var a,b,c,d - var val, div, body, container; - - body = document.getElementsByTagName( "body" )[ 0 ]; - if ( !body || !body.style ) { - // Return for frameset docs that don't have a body - return; - } - - // Setup - div = document.createElement( "div" ); - container = document.createElement( "div" ); - container.style.cssText = "position:absolute;border:0;width:0;height:0;top:0;left:-9999px"; - body.appendChild( container ).appendChild( div ); - - if ( typeof div.style.zoom !== strundefined ) { - // Support: IE<8 - // Check if natively block-level elements act like inline-block - // elements when setting their display to 'inline' and giving - // them layout - div.style.cssText = "display:inline;margin:0;border:0;padding:1px;width:1px;zoom:1"; - - support.inlineBlockNeedsLayout = val = div.offsetWidth === 3; - if ( val ) { - // Prevent IE 6 from affecting layout for positioned elements #11048 - // Prevent IE from shrinking the body in IE 7 mode #12869 - // Support: IE<8 - body.style.zoom = 1; - } - } - - body.removeChild( container ); -}); - - - - -(function() { - var div = document.createElement( "div" ); - - // Execute the test only if not already executed in another module. - if (support.deleteExpando == null) { - // Support: IE<9 - support.deleteExpando = true; - try { - delete div.test; - } catch( e ) { - support.deleteExpando = false; - } - } - - // Null elements to avoid leaks in IE. - div = null; -})(); - - -/** - * Determines whether an object can have data - */ -jQuery.acceptData = function( elem ) { - var noData = jQuery.noData[ (elem.nodeName + " ").toLowerCase() ], - nodeType = +elem.nodeType || 1; - - // Do not set data on non-element DOM nodes because it will not be cleared (#8335). - return nodeType !== 1 && nodeType !== 9 ? - false : - - // Nodes accept data unless otherwise specified; rejection can be conditional - !noData || noData !== true && elem.getAttribute("classid") === noData; -}; - - -var rbrace = /^(?:\{[\w\W]*\}|\[[\w\W]*\])$/, - rmultiDash = /([A-Z])/g; - -function dataAttr( elem, key, data ) { - // If nothing was found internally, try to fetch any - // data from the HTML5 data-* attribute - if ( data === undefined && elem.nodeType === 1 ) { - - var name = "data-" + key.replace( rmultiDash, "-$1" ).toLowerCase(); - - data = elem.getAttribute( name ); - - if ( typeof data === "string" ) { - try { - data = data === "true" ? true : - data === "false" ? false : - data === "null" ? null : - // Only convert to a number if it doesn't change the string - +data + "" === data ? +data : - rbrace.test( data ) ? jQuery.parseJSON( data ) : - data; - } catch( e ) {} - - // Make sure we set the data so it isn't changed later - jQuery.data( elem, key, data ); - - } else { - data = undefined; - } - } - - return data; -} - -// checks a cache object for emptiness -function isEmptyDataObject( obj ) { - var name; - for ( name in obj ) { - - // if the public data object is empty, the private is still empty - if ( name === "data" && jQuery.isEmptyObject( obj[name] ) ) { - continue; - } - if ( name !== "toJSON" ) { - return false; - } - } - - return true; -} - -function internalData( elem, name, data, pvt /* Internal Use Only */ ) { - if ( !jQuery.acceptData( elem ) ) { - return; - } - - var ret, thisCache, - internalKey = jQuery.expando, - - // We have to handle DOM nodes and JS objects differently because IE6-7 - // can't GC object references properly across the DOM-JS boundary - isNode = elem.nodeType, - - // Only DOM nodes need the global jQuery cache; JS object data is - // attached directly to the object so GC can occur automatically - cache = isNode ? jQuery.cache : elem, - - // Only defining an ID for JS objects if its cache already exists allows - // the code to shortcut on the same path as a DOM node with no cache - id = isNode ? elem[ internalKey ] : elem[ internalKey ] && internalKey; - - // Avoid doing any more work than we need to when trying to get data on an - // object that has no data at all - if ( (!id || !cache[id] || (!pvt && !cache[id].data)) && data === undefined && typeof name === "string" ) { - return; - } - - if ( !id ) { - // Only DOM nodes need a new unique ID for each element since their data - // ends up in the global cache - if ( isNode ) { - id = elem[ internalKey ] = deletedIds.pop() || jQuery.guid++; - } else { - id = internalKey; - } - } - - if ( !cache[ id ] ) { - // Avoid exposing jQuery metadata on plain JS objects when the object - // is serialized using JSON.stringify - cache[ id ] = isNode ? {} : { toJSON: jQuery.noop }; - } - - // An object can be passed to jQuery.data instead of a key/value pair; this gets - // shallow copied over onto the existing cache - if ( typeof name === "object" || typeof name === "function" ) { - if ( pvt ) { - cache[ id ] = jQuery.extend( cache[ id ], name ); - } else { - cache[ id ].data = jQuery.extend( cache[ id ].data, name ); - } - } - - thisCache = cache[ id ]; - - // jQuery data() is stored in a separate object inside the object's internal data - // cache in order to avoid key collisions between internal data and user-defined - // data. - if ( !pvt ) { - if ( !thisCache.data ) { - thisCache.data = {}; - } - - thisCache = thisCache.data; - } - - if ( data !== undefined ) { - thisCache[ jQuery.camelCase( name ) ] = data; - } - - // Check for both converted-to-camel and non-converted data property names - // If a data property was specified - if ( typeof name === "string" ) { - - // First Try to find as-is property data - ret = thisCache[ name ]; - - // Test for null|undefined property data - if ( ret == null ) { - - // Try to find the camelCased property - ret = thisCache[ jQuery.camelCase( name ) ]; - } - } else { - ret = thisCache; - } - - return ret; -} - -function internalRemoveData( elem, name, pvt ) { - if ( !jQuery.acceptData( elem ) ) { - return; - } - - var thisCache, i, - isNode = elem.nodeType, - - // See jQuery.data for more information - cache = isNode ? jQuery.cache : elem, - id = isNode ? elem[ jQuery.expando ] : jQuery.expando; - - // If there is already no cache entry for this object, there is no - // purpose in continuing - if ( !cache[ id ] ) { - return; - } - - if ( name ) { - - thisCache = pvt ? cache[ id ] : cache[ id ].data; - - if ( thisCache ) { - - // Support array or space separated string names for data keys - if ( !jQuery.isArray( name ) ) { - - // try the string as a key before any manipulation - if ( name in thisCache ) { - name = [ name ]; - } else { - - // split the camel cased version by spaces unless a key with the spaces exists - name = jQuery.camelCase( name ); - if ( name in thisCache ) { - name = [ name ]; - } else { - name = name.split(" "); - } - } - } else { - // If "name" is an array of keys... - // When data is initially created, via ("key", "val") signature, - // keys will be converted to camelCase. - // Since there is no way to tell _how_ a key was added, remove - // both plain key and camelCase key. #12786 - // This will only penalize the array argument path. - name = name.concat( jQuery.map( name, jQuery.camelCase ) ); - } - - i = name.length; - while ( i-- ) { - delete thisCache[ name[i] ]; - } - - // If there is no data left in the cache, we want to continue - // and let the cache object itself get destroyed - if ( pvt ? !isEmptyDataObject(thisCache) : !jQuery.isEmptyObject(thisCache) ) { - return; - } - } - } - - // See jQuery.data for more information - if ( !pvt ) { - delete cache[ id ].data; - - // Don't destroy the parent cache unless the internal data object - // had been the only thing left in it - if ( !isEmptyDataObject( cache[ id ] ) ) { - return; - } - } - - // Destroy the cache - if ( isNode ) { - jQuery.cleanData( [ elem ], true ); - - // Use delete when supported for expandos or `cache` is not a window per isWindow (#10080) - /* jshint eqeqeq: false */ - } else if ( support.deleteExpando || cache != cache.window ) { - /* jshint eqeqeq: true */ - delete cache[ id ]; - - // When all else fails, null - } else { - cache[ id ] = null; - } -} - -jQuery.extend({ - cache: {}, - - // The following elements (space-suffixed to avoid Object.prototype collisions) - // throw uncatchable exceptions if you attempt to set expando properties - noData: { - "applet ": true, - "embed ": true, - // ...but Flash objects (which have this classid) *can* handle expandos - "object ": "clsid:D27CDB6E-AE6D-11cf-96B8-444553540000" - }, - - hasData: function( elem ) { - elem = elem.nodeType ? jQuery.cache[ elem[jQuery.expando] ] : elem[ jQuery.expando ]; - return !!elem && !isEmptyDataObject( elem ); - }, - - data: function( elem, name, data ) { - return internalData( elem, name, data ); - }, - - removeData: function( elem, name ) { - return internalRemoveData( elem, name ); - }, - - // For internal use only. - _data: function( elem, name, data ) { - return internalData( elem, name, data, true ); - }, - - _removeData: function( elem, name ) { - return internalRemoveData( elem, name, true ); - } -}); - -jQuery.fn.extend({ - data: function( key, value ) { - var i, name, data, - elem = this[0], - attrs = elem && elem.attributes; - - // Special expections of .data basically thwart jQuery.access, - // so implement the relevant behavior ourselves - - // Gets all values - if ( key === undefined ) { - if ( this.length ) { - data = jQuery.data( elem ); - - if ( elem.nodeType === 1 && !jQuery._data( elem, "parsedAttrs" ) ) { - i = attrs.length; - while ( i-- ) { - - // Support: IE11+ - // The attrs elements can be null (#14894) - if ( attrs[ i ] ) { - name = attrs[ i ].name; - if ( name.indexOf( "data-" ) === 0 ) { - name = jQuery.camelCase( name.slice(5) ); - dataAttr( elem, name, data[ name ] ); - } - } - } - jQuery._data( elem, "parsedAttrs", true ); - } - } - - return data; - } - - // Sets multiple values - if ( typeof key === "object" ) { - return this.each(function() { - jQuery.data( this, key ); - }); - } - - return arguments.length > 1 ? - - // Sets one value - this.each(function() { - jQuery.data( this, key, value ); - }) : - - // Gets one value - // Try to fetch any internally stored data first - elem ? dataAttr( elem, key, jQuery.data( elem, key ) ) : undefined; - }, - - removeData: function( key ) { - return this.each(function() { - jQuery.removeData( this, key ); - }); - } -}); - - -jQuery.extend({ - queue: function( elem, type, data ) { - var queue; - - if ( elem ) { - type = ( type || "fx" ) + "queue"; - queue = jQuery._data( elem, type ); - - // Speed up dequeue by getting out quickly if this is just a lookup - if ( data ) { - if ( !queue || jQuery.isArray(data) ) { - queue = jQuery._data( elem, type, jQuery.makeArray(data) ); - } else { - queue.push( data ); - } - } - return queue || []; - } - }, - - dequeue: function( elem, type ) { - type = type || "fx"; - - var queue = jQuery.queue( elem, type ), - startLength = queue.length, - fn = queue.shift(), - hooks = jQuery._queueHooks( elem, type ), - next = function() { - jQuery.dequeue( elem, type ); - }; - - // If the fx queue is dequeued, always remove the progress sentinel - if ( fn === "inprogress" ) { - fn = queue.shift(); - startLength--; - } - - if ( fn ) { - - // Add a progress sentinel to prevent the fx queue from being - // automatically dequeued - if ( type === "fx" ) { - queue.unshift( "inprogress" ); - } - - // clear up the last queue stop function - delete hooks.stop; - fn.call( elem, next, hooks ); - } - - if ( !startLength && hooks ) { - hooks.empty.fire(); - } - }, - - // not intended for public consumption - generates a queueHooks object, or returns the current one - _queueHooks: function( elem, type ) { - var key = type + "queueHooks"; - return jQuery._data( elem, key ) || jQuery._data( elem, key, { - empty: jQuery.Callbacks("once memory").add(function() { - jQuery._removeData( elem, type + "queue" ); - jQuery._removeData( elem, key ); - }) - }); - } -}); - -jQuery.fn.extend({ - queue: function( type, data ) { - var setter = 2; - - if ( typeof type !== "string" ) { - data = type; - type = "fx"; - setter--; - } - - if ( arguments.length < setter ) { - return jQuery.queue( this[0], type ); - } - - return data === undefined ? - this : - this.each(function() { - var queue = jQuery.queue( this, type, data ); - - // ensure a hooks for this queue - jQuery._queueHooks( this, type ); - - if ( type === "fx" && queue[0] !== "inprogress" ) { - jQuery.dequeue( this, type ); - } - }); - }, - dequeue: function( type ) { - return this.each(function() { - jQuery.dequeue( this, type ); - }); - }, - clearQueue: function( type ) { - return this.queue( type || "fx", [] ); - }, - // Get a promise resolved when queues of a certain type - // are emptied (fx is the type by default) - promise: function( type, obj ) { - var tmp, - count = 1, - defer = jQuery.Deferred(), - elements = this, - i = this.length, - resolve = function() { - if ( !( --count ) ) { - defer.resolveWith( elements, [ elements ] ); - } - }; - - if ( typeof type !== "string" ) { - obj = type; - type = undefined; - } - type = type || "fx"; - - while ( i-- ) { - tmp = jQuery._data( elements[ i ], type + "queueHooks" ); - if ( tmp && tmp.empty ) { - count++; - tmp.empty.add( resolve ); - } - } - resolve(); - return defer.promise( obj ); - } -}); -var pnum = (/[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/).source; - -var cssExpand = [ "Top", "Right", "Bottom", "Left" ]; - -var isHidden = function( elem, el ) { - // isHidden might be called from jQuery#filter function; - // in that case, element will be second argument - elem = el || elem; - return jQuery.css( elem, "display" ) === "none" || !jQuery.contains( elem.ownerDocument, elem ); - }; - - - -// Multifunctional method to get and set values of a collection -// The value/s can optionally be executed if it's a function -var access = jQuery.access = function( elems, fn, key, value, chainable, emptyGet, raw ) { - var i = 0, - length = elems.length, - bulk = key == null; - - // Sets many values - if ( jQuery.type( key ) === "object" ) { - chainable = true; - for ( i in key ) { - jQuery.access( elems, fn, i, key[i], true, emptyGet, raw ); - } - - // Sets one value - } else if ( value !== undefined ) { - chainable = true; - - if ( !jQuery.isFunction( value ) ) { - raw = true; - } - - if ( bulk ) { - // Bulk operations run against the entire set - if ( raw ) { - fn.call( elems, value ); - fn = null; - - // ...except when executing function values - } else { - bulk = fn; - fn = function( elem, key, value ) { - return bulk.call( jQuery( elem ), value ); - }; - } - } - - if ( fn ) { - for ( ; i < length; i++ ) { - fn( elems[i], key, raw ? value : value.call( elems[i], i, fn( elems[i], key ) ) ); - } - } - } - - return chainable ? - elems : - - // Gets - bulk ? - fn.call( elems ) : - length ? fn( elems[0], key ) : emptyGet; -}; -var rcheckableType = (/^(?:checkbox|radio)$/i); - - - -(function() { - // Minified: var a,b,c - var input = document.createElement( "input" ), - div = document.createElement( "div" ), - fragment = document.createDocumentFragment(); - - // Setup - div.innerHTML = " <link/><table></table><a href='/a'>a</a><input type='checkbox'/>"; - - // IE strips leading whitespace when .innerHTML is used - support.leadingWhitespace = div.firstChild.nodeType === 3; - - // Make sure that tbody elements aren't automatically inserted - // IE will insert them into empty tables - support.tbody = !div.getElementsByTagName( "tbody" ).length; - - // Make sure that link elements get serialized correctly by innerHTML - // This requires a wrapper element in IE - support.htmlSerialize = !!div.getElementsByTagName( "link" ).length; - - // Makes sure cloning an html5 element does not cause problems - // Where outerHTML is undefined, this still works - support.html5Clone = - document.createElement( "nav" ).cloneNode( true ).outerHTML !== "<:nav></:nav>"; - - // Check if a disconnected checkbox will retain its checked - // value of true after appended to the DOM (IE6/7) - input.type = "checkbox"; - input.checked = true; - fragment.appendChild( input ); - support.appendChecked = input.checked; - - // Make sure textarea (and checkbox) defaultValue is properly cloned - // Support: IE6-IE11+ - div.innerHTML = "<textarea>x</textarea>"; - support.noCloneChecked = !!div.cloneNode( true ).lastChild.defaultValue; - - // #11217 - WebKit loses check when the name is after the checked attribute - fragment.appendChild( div ); - div.innerHTML = "<input type='radio' checked='checked' name='t'/>"; - - // Support: Safari 5.1, iOS 5.1, Android 4.x, Android 2.3 - // old WebKit doesn't clone checked state correctly in fragments - support.checkClone = div.cloneNode( true ).cloneNode( true ).lastChild.checked; - - // Support: IE<9 - // Opera does not clone events (and typeof div.attachEvent === undefined). - // IE9-10 clones events bound via attachEvent, but they don't trigger with .click() - support.noCloneEvent = true; - if ( div.attachEvent ) { - div.attachEvent( "onclick", function() { - support.noCloneEvent = false; - }); - - div.cloneNode( true ).click(); - } - - // Execute the test only if not already executed in another module. - if (support.deleteExpando == null) { - // Support: IE<9 - support.deleteExpando = true; - try { - delete div.test; - } catch( e ) { - support.deleteExpando = false; - } - } -})(); - - -(function() { - var i, eventName, - div = document.createElement( "div" ); - - // Support: IE<9 (lack submit/change bubble), Firefox 23+ (lack focusin event) - for ( i in { submit: true, change: true, focusin: true }) { - eventName = "on" + i; - - if ( !(support[ i + "Bubbles" ] = eventName in window) ) { - // Beware of CSP restrictions (https://developer.mozilla.org/en/Security/CSP) - div.setAttribute( eventName, "t" ); - support[ i + "Bubbles" ] = div.attributes[ eventName ].expando === false; - } - } - - // Null elements to avoid leaks in IE. - div = null; -})(); - - -var rformElems = /^(?:input|select|textarea)$/i, - rkeyEvent = /^key/, - rmouseEvent = /^(?:mouse|pointer|contextmenu)|click/, - rfocusMorph = /^(?:focusinfocus|focusoutblur)$/, - rtypenamespace = /^([^.]*)(?:\.(.+)|)$/; - -function returnTrue() { - return true; -} - -function returnFalse() { - return false; -} - -function safeActiveElement() { - try { - return document.activeElement; - } catch ( err ) { } -} - -/* - * Helper functions for managing events -- not part of the public interface. - * Props to Dean Edwards' addEvent library for many of the ideas. - */ -jQuery.event = { - - global: {}, - - add: function( elem, types, handler, data, selector ) { - var tmp, events, t, handleObjIn, - special, eventHandle, handleObj, - handlers, type, namespaces, origType, - elemData = jQuery._data( elem ); - - // Don't attach events to noData or text/comment nodes (but allow plain objects) - if ( !elemData ) { - return; - } - - // Caller can pass in an object of custom data in lieu of the handler - if ( handler.handler ) { - handleObjIn = handler; - handler = handleObjIn.handler; - selector = handleObjIn.selector; - } - - // Make sure that the handler has a unique ID, used to find/remove it later - if ( !handler.guid ) { - handler.guid = jQuery.guid++; - } - - // Init the element's event structure and main handler, if this is the first - if ( !(events = elemData.events) ) { - events = elemData.events = {}; - } - if ( !(eventHandle = elemData.handle) ) { - eventHandle = elemData.handle = function( e ) { - // Discard the second event of a jQuery.event.trigger() and - // when an event is called after a page has unloaded - return typeof jQuery !== strundefined && (!e || jQuery.event.triggered !== e.type) ? - jQuery.event.dispatch.apply( eventHandle.elem, arguments ) : - undefined; - }; - // Add elem as a property of the handle fn to prevent a memory leak with IE non-native events - eventHandle.elem = elem; - } - - // Handle multiple events separated by a space - types = ( types || "" ).match( rnotwhite ) || [ "" ]; - t = types.length; - while ( t-- ) { - tmp = rtypenamespace.exec( types[t] ) || []; - type = origType = tmp[1]; - namespaces = ( tmp[2] || "" ).split( "." ).sort(); - - // There *must* be a type, no attaching namespace-only handlers - if ( !type ) { - continue; - } - - // If event changes its type, use the special event handlers for the changed type - special = jQuery.event.special[ type ] || {}; - - // If selector defined, determine special event api type, otherwise given type - type = ( selector ? special.delegateType : special.bindType ) || type; - - // Update special based on newly reset type - special = jQuery.event.special[ type ] || {}; - - // handleObj is passed to all event handlers - handleObj = jQuery.extend({ - type: type, - origType: origType, - data: data, - handler: handler, - guid: handler.guid, - selector: selector, - needsContext: selector && jQuery.expr.match.needsContext.test( selector ), - namespace: namespaces.join(".") - }, handleObjIn ); - - // Init the event handler queue if we're the first - if ( !(handlers = events[ type ]) ) { - handlers = events[ type ] = []; - handlers.delegateCount = 0; - - // Only use addEventListener/attachEvent if the special events handler returns false - if ( !special.setup || special.setup.call( elem, data, namespaces, eventHandle ) === false ) { - // Bind the global event handler to the element - if ( elem.addEventListener ) { - elem.addEventListener( type, eventHandle, false ); - - } else if ( elem.attachEvent ) { - elem.attachEvent( "on" + type, eventHandle ); - } - } - } - - if ( special.add ) { - special.add.call( elem, handleObj ); - - if ( !handleObj.handler.guid ) { - handleObj.handler.guid = handler.guid; - } - } - - // Add to the element's handler list, delegates in front - if ( selector ) { - handlers.splice( handlers.delegateCount++, 0, handleObj ); - } else { - handlers.push( handleObj ); - } - - // Keep track of which events have ever been used, for event optimization - jQuery.event.global[ type ] = true; - } - - // Nullify elem to prevent memory leaks in IE - elem = null; - }, - - // Detach an event or set of events from an element - remove: function( elem, types, handler, selector, mappedTypes ) { - var j, handleObj, tmp, - origCount, t, events, - special, handlers, type, - namespaces, origType, - elemData = jQuery.hasData( elem ) && jQuery._data( elem ); - - if ( !elemData || !(events = elemData.events) ) { - return; - } - - // Once for each type.namespace in types; type may be omitted - types = ( types || "" ).match( rnotwhite ) || [ "" ]; - t = types.length; - while ( t-- ) { - tmp = rtypenamespace.exec( types[t] ) || []; - type = origType = tmp[1]; - namespaces = ( tmp[2] || "" ).split( "." ).sort(); - - // Unbind all events (on this namespace, if provided) for the element - if ( !type ) { - for ( type in events ) { - jQuery.event.remove( elem, type + types[ t ], handler, selector, true ); - } - continue; - } - - special = jQuery.event.special[ type ] || {}; - type = ( selector ? special.delegateType : special.bindType ) || type; - handlers = events[ type ] || []; - tmp = tmp[2] && new RegExp( "(^|\\.)" + namespaces.join("\\.(?:.*\\.|)") + "(\\.|$)" ); - - // Remove matching events - origCount = j = handlers.length; - while ( j-- ) { - handleObj = handlers[ j ]; - - if ( ( mappedTypes || origType === handleObj.origType ) && - ( !handler || handler.guid === handleObj.guid ) && - ( !tmp || tmp.test( handleObj.namespace ) ) && - ( !selector || selector === handleObj.selector || selector === "**" && handleObj.selector ) ) { - handlers.splice( j, 1 ); - - if ( handleObj.selector ) { - handlers.delegateCount--; - } - if ( special.remove ) { - special.remove.call( elem, handleObj ); - } - } - } - - // Remove generic event handler if we removed something and no more handlers exist - // (avoids potential for endless recursion during removal of special event handlers) - if ( origCount && !handlers.length ) { - if ( !special.teardown || special.teardown.call( elem, namespaces, elemData.handle ) === false ) { - jQuery.removeEvent( elem, type, elemData.handle ); - } - - delete events[ type ]; - } - } - - // Remove the expando if it's no longer used - if ( jQuery.isEmptyObject( events ) ) { - delete elemData.handle; - - // removeData also checks for emptiness and clears the expando if empty - // so use it instead of delete - jQuery._removeData( elem, "events" ); - } - }, - - trigger: function( event, data, elem, onlyHandlers ) { - var handle, ontype, cur, - bubbleType, special, tmp, i, - eventPath = [ elem || document ], - type = hasOwn.call( event, "type" ) ? event.type : event, - namespaces = hasOwn.call( event, "namespace" ) ? event.namespace.split(".") : []; - - cur = tmp = elem = elem || document; - - // Don't do events on text and comment nodes - if ( elem.nodeType === 3 || elem.nodeType === 8 ) { - return; - } - - // focus/blur morphs to focusin/out; ensure we're not firing them right now - if ( rfocusMorph.test( type + jQuery.event.triggered ) ) { - return; - } - - if ( type.indexOf(".") >= 0 ) { - // Namespaced trigger; create a regexp to match event type in handle() - namespaces = type.split("."); - type = namespaces.shift(); - namespaces.sort(); - } - ontype = type.indexOf(":") < 0 && "on" + type; - - // Caller can pass in a jQuery.Event object, Object, or just an event type string - event = event[ jQuery.expando ] ? - event : - new jQuery.Event( type, typeof event === "object" && event ); - - // Trigger bitmask: & 1 for native handlers; & 2 for jQuery (always true) - event.isTrigger = onlyHandlers ? 2 : 3; - event.namespace = namespaces.join("."); - event.namespace_re = event.namespace ? - new RegExp( "(^|\\.)" + namespaces.join("\\.(?:.*\\.|)") + "(\\.|$)" ) : - null; - - // Clean up the event in case it is being reused - event.result = undefined; - if ( !event.target ) { - event.target = elem; - } - - // Clone any incoming data and prepend the event, creating the handler arg list - data = data == null ? - [ event ] : - jQuery.makeArray( data, [ event ] ); - - // Allow special events to draw outside the lines - special = jQuery.event.special[ type ] || {}; - if ( !onlyHandlers && special.trigger && special.trigger.apply( elem, data ) === false ) { - return; - } - - // Determine event propagation path in advance, per W3C events spec (#9951) - // Bubble up to document, then to window; watch for a global ownerDocument var (#9724) - if ( !onlyHandlers && !special.noBubble && !jQuery.isWindow( elem ) ) { - - bubbleType = special.delegateType || type; - if ( !rfocusMorph.test( bubbleType + type ) ) { - cur = cur.parentNode; - } - for ( ; cur; cur = cur.parentNode ) { - eventPath.push( cur ); - tmp = cur; - } - - // Only add window if we got to document (e.g., not plain obj or detached DOM) - if ( tmp === (elem.ownerDocument || document) ) { - eventPath.push( tmp.defaultView || tmp.parentWindow || window ); - } - } - - // Fire handlers on the event path - i = 0; - while ( (cur = eventPath[i++]) && !event.isPropagationStopped() ) { - - event.type = i > 1 ? - bubbleType : - special.bindType || type; - - // jQuery handler - handle = ( jQuery._data( cur, "events" ) || {} )[ event.type ] && jQuery._data( cur, "handle" ); - if ( handle ) { - handle.apply( cur, data ); - } - - // Native handler - handle = ontype && cur[ ontype ]; - if ( handle && handle.apply && jQuery.acceptData( cur ) ) { - event.result = handle.apply( cur, data ); - if ( event.result === false ) { - event.preventDefault(); - } - } - } - event.type = type; - - // If nobody prevented the default action, do it now - if ( !onlyHandlers && !event.isDefaultPrevented() ) { - - if ( (!special._default || special._default.apply( eventPath.pop(), data ) === false) && - jQuery.acceptData( elem ) ) { - - // Call a native DOM method on the target with the same name name as the event. - // Can't use an .isFunction() check here because IE6/7 fails that test. - // Don't do default actions on window, that's where global variables be (#6170) - if ( ontype && elem[ type ] && !jQuery.isWindow( elem ) ) { - - // Don't re-trigger an onFOO event when we call its FOO() method - tmp = elem[ ontype ]; - - if ( tmp ) { - elem[ ontype ] = null; - } - - // Prevent re-triggering of the same event, since we already bubbled it above - jQuery.event.triggered = type; - try { - elem[ type ](); - } catch ( e ) { - // IE<9 dies on focus/blur to hidden element (#1486,#12518) - // only reproducible on winXP IE8 native, not IE9 in IE8 mode - } - jQuery.event.triggered = undefined; - - if ( tmp ) { - elem[ ontype ] = tmp; - } - } - } - } - - return event.result; - }, - - dispatch: function( event ) { - - // Make a writable jQuery.Event from the native event object - event = jQuery.event.fix( event ); - - var i, ret, handleObj, matched, j, - handlerQueue = [], - args = slice.call( arguments ), - handlers = ( jQuery._data( this, "events" ) || {} )[ event.type ] || [], - special = jQuery.event.special[ event.type ] || {}; - - // Use the fix-ed jQuery.Event rather than the (read-only) native event - args[0] = event; - event.delegateTarget = this; - - // Call the preDispatch hook for the mapped type, and let it bail if desired - if ( special.preDispatch && special.preDispatch.call( this, event ) === false ) { - return; - } - - // Determine handlers - handlerQueue = jQuery.event.handlers.call( this, event, handlers ); - - // Run delegates first; they may want to stop propagation beneath us - i = 0; - while ( (matched = handlerQueue[ i++ ]) && !event.isPropagationStopped() ) { - event.currentTarget = matched.elem; - - j = 0; - while ( (handleObj = matched.handlers[ j++ ]) && !event.isImmediatePropagationStopped() ) { - - // Triggered event must either 1) have no namespace, or - // 2) have namespace(s) a subset or equal to those in the bound event (both can have no namespace). - if ( !event.namespace_re || event.namespace_re.test( handleObj.namespace ) ) { - - event.handleObj = handleObj; - event.data = handleObj.data; - - ret = ( (jQuery.event.special[ handleObj.origType ] || {}).handle || handleObj.handler ) - .apply( matched.elem, args ); - - if ( ret !== undefined ) { - if ( (event.result = ret) === false ) { - event.preventDefault(); - event.stopPropagation(); - } - } - } - } - } - - // Call the postDispatch hook for the mapped type - if ( special.postDispatch ) { - special.postDispatch.call( this, event ); - } - - return event.result; - }, - - handlers: function( event, handlers ) { - var sel, handleObj, matches, i, - handlerQueue = [], - delegateCount = handlers.delegateCount, - cur = event.target; - - // Find delegate handlers - // Black-hole SVG <use> instance trees (#13180) - // Avoid non-left-click bubbling in Firefox (#3861) - if ( delegateCount && cur.nodeType && (!event.button || event.type !== "click") ) { - - /* jshint eqeqeq: false */ - for ( ; cur != this; cur = cur.parentNode || this ) { - /* jshint eqeqeq: true */ - - // Don't check non-elements (#13208) - // Don't process clicks on disabled elements (#6911, #8165, #11382, #11764) - if ( cur.nodeType === 1 && (cur.disabled !== true || event.type !== "click") ) { - matches = []; - for ( i = 0; i < delegateCount; i++ ) { - handleObj = handlers[ i ]; - - // Don't conflict with Object.prototype properties (#13203) - sel = handleObj.selector + " "; - - if ( matches[ sel ] === undefined ) { - matches[ sel ] = handleObj.needsContext ? - jQuery( sel, this ).index( cur ) >= 0 : - jQuery.find( sel, this, null, [ cur ] ).length; - } - if ( matches[ sel ] ) { - matches.push( handleObj ); - } - } - if ( matches.length ) { - handlerQueue.push({ elem: cur, handlers: matches }); - } - } - } - } - - // Add the remaining (directly-bound) handlers - if ( delegateCount < handlers.length ) { - handlerQueue.push({ elem: this, handlers: handlers.slice( delegateCount ) }); - } - - return handlerQueue; - }, - - fix: function( event ) { - if ( event[ jQuery.expando ] ) { - return event; - } - - // Create a writable copy of the event object and normalize some properties - var i, prop, copy, - type = event.type, - originalEvent = event, - fixHook = this.fixHooks[ type ]; - - if ( !fixHook ) { - this.fixHooks[ type ] = fixHook = - rmouseEvent.test( type ) ? this.mouseHooks : - rkeyEvent.test( type ) ? this.keyHooks : - {}; - } - copy = fixHook.props ? this.props.concat( fixHook.props ) : this.props; - - event = new jQuery.Event( originalEvent ); - - i = copy.length; - while ( i-- ) { - prop = copy[ i ]; - event[ prop ] = originalEvent[ prop ]; - } - - // Support: IE<9 - // Fix target property (#1925) - if ( !event.target ) { - event.target = originalEvent.srcElement || document; - } - - // Support: Chrome 23+, Safari? - // Target should not be a text node (#504, #13143) - if ( event.target.nodeType === 3 ) { - event.target = event.target.parentNode; - } - - // Support: IE<9 - // For mouse/key events, metaKey==false if it's undefined (#3368, #11328) - event.metaKey = !!event.metaKey; - - return fixHook.filter ? fixHook.filter( event, originalEvent ) : event; - }, - - // Includes some event props shared by KeyEvent and MouseEvent - props: "altKey bubbles cancelable ctrlKey currentTarget eventPhase metaKey relatedTarget shiftKey target timeStamp view which".split(" "), - - fixHooks: {}, - - keyHooks: { - props: "char charCode key keyCode".split(" "), - filter: function( event, original ) { - - // Add which for key events - if ( event.which == null ) { - event.which = original.charCode != null ? original.charCode : original.keyCode; - } - - return event; - } - }, - - mouseHooks: { - props: "button buttons clientX clientY fromElement offsetX offsetY pageX pageY screenX screenY toElement".split(" "), - filter: function( event, original ) { - var body, eventDoc, doc, - button = original.button, - fromElement = original.fromElement; - - // Calculate pageX/Y if missing and clientX/Y available - if ( event.pageX == null && original.clientX != null ) { - eventDoc = event.target.ownerDocument || document; - doc = eventDoc.documentElement; - body = eventDoc.body; - - event.pageX = original.clientX + ( doc && doc.scrollLeft || body && body.scrollLeft || 0 ) - ( doc && doc.clientLeft || body && body.clientLeft || 0 ); - event.pageY = original.clientY + ( doc && doc.scrollTop || body && body.scrollTop || 0 ) - ( doc && doc.clientTop || body && body.clientTop || 0 ); - } - - // Add relatedTarget, if necessary - if ( !event.relatedTarget && fromElement ) { - event.relatedTarget = fromElement === event.target ? original.toElement : fromElement; - } - - // Add which for click: 1 === left; 2 === middle; 3 === right - // Note: button is not normalized, so don't use it - if ( !event.which && button !== undefined ) { - event.which = ( button & 1 ? 1 : ( button & 2 ? 3 : ( button & 4 ? 2 : 0 ) ) ); - } - - return event; - } - }, - - special: { - load: { - // Prevent triggered image.load events from bubbling to window.load - noBubble: true - }, - focus: { - // Fire native event if possible so blur/focus sequence is correct - trigger: function() { - if ( this !== safeActiveElement() && this.focus ) { - try { - this.focus(); - return false; - } catch ( e ) { - // Support: IE<9 - // If we error on focus to hidden element (#1486, #12518), - // let .trigger() run the handlers - } - } - }, - delegateType: "focusin" - }, - blur: { - trigger: function() { - if ( this === safeActiveElement() && this.blur ) { - this.blur(); - return false; - } - }, - delegateType: "focusout" - }, - click: { - // For checkbox, fire native event so checked state will be right - trigger: function() { - if ( jQuery.nodeName( this, "input" ) && this.type === "checkbox" && this.click ) { - this.click(); - return false; - } - }, - - // For cross-browser consistency, don't fire native .click() on links - _default: function( event ) { - return jQuery.nodeName( event.target, "a" ); - } - }, - - beforeunload: { - postDispatch: function( event ) { - - // Support: Firefox 20+ - // Firefox doesn't alert if the returnValue field is not set. - if ( event.result !== undefined && event.originalEvent ) { - event.originalEvent.returnValue = event.result; - } - } - } - }, - - simulate: function( type, elem, event, bubble ) { - // Piggyback on a donor event to simulate a different one. - // Fake originalEvent to avoid donor's stopPropagation, but if the - // simulated event prevents default then we do the same on the donor. - var e = jQuery.extend( - new jQuery.Event(), - event, - { - type: type, - isSimulated: true, - originalEvent: {} - } - ); - if ( bubble ) { - jQuery.event.trigger( e, null, elem ); - } else { - jQuery.event.dispatch.call( elem, e ); - } - if ( e.isDefaultPrevented() ) { - event.preventDefault(); - } - } -}; - -jQuery.removeEvent = document.removeEventListener ? - function( elem, type, handle ) { - if ( elem.removeEventListener ) { - elem.removeEventListener( type, handle, false ); - } - } : - function( elem, type, handle ) { - var name = "on" + type; - - if ( elem.detachEvent ) { - - // #8545, #7054, preventing memory leaks for custom events in IE6-8 - // detachEvent needed property on element, by name of that event, to properly expose it to GC - if ( typeof elem[ name ] === strundefined ) { - elem[ name ] = null; - } - - elem.detachEvent( name, handle ); - } - }; - -jQuery.Event = function( src, props ) { - // Allow instantiation without the 'new' keyword - if ( !(this instanceof jQuery.Event) ) { - return new jQuery.Event( src, props ); - } - - // Event object - if ( src && src.type ) { - this.originalEvent = src; - this.type = src.type; - - // Events bubbling up the document may have been marked as prevented - // by a handler lower down the tree; reflect the correct value. - this.isDefaultPrevented = src.defaultPrevented || - src.defaultPrevented === undefined && - // Support: IE < 9, Android < 4.0 - src.returnValue === false ? - returnTrue : - returnFalse; - - // Event type - } else { - this.type = src; - } - - // Put explicitly provided properties onto the event object - if ( props ) { - jQuery.extend( this, props ); - } - - // Create a timestamp if incoming event doesn't have one - this.timeStamp = src && src.timeStamp || jQuery.now(); - - // Mark it as fixed - this[ jQuery.expando ] = true; -}; - -// jQuery.Event is based on DOM3 Events as specified by the ECMAScript Language Binding -// http://www.w3.org/TR/2003/WD-DOM-Level-3-Events-20030331/ecma-script-binding.html -jQuery.Event.prototype = { - isDefaultPrevented: returnFalse, - isPropagationStopped: returnFalse, - isImmediatePropagationStopped: returnFalse, - - preventDefault: function() { - var e = this.originalEvent; - - this.isDefaultPrevented = returnTrue; - if ( !e ) { - return; - } - - // If preventDefault exists, run it on the original event - if ( e.preventDefault ) { - e.preventDefault(); - - // Support: IE - // Otherwise set the returnValue property of the original event to false - } else { - e.returnValue = false; - } - }, - stopPropagation: function() { - var e = this.originalEvent; - - this.isPropagationStopped = returnTrue; - if ( !e ) { - return; - } - // If stopPropagation exists, run it on the original event - if ( e.stopPropagation ) { - e.stopPropagation(); - } - - // Support: IE - // Set the cancelBubble property of the original event to true - e.cancelBubble = true; - }, - stopImmediatePropagation: function() { - var e = this.originalEvent; - - this.isImmediatePropagationStopped = returnTrue; - - if ( e && e.stopImmediatePropagation ) { - e.stopImmediatePropagation(); - } - - this.stopPropagation(); - } -}; - -// Create mouseenter/leave events using mouseover/out and event-time checks -jQuery.each({ - mouseenter: "mouseover", - mouseleave: "mouseout", - pointerenter: "pointerover", - pointerleave: "pointerout" -}, function( orig, fix ) { - jQuery.event.special[ orig ] = { - delegateType: fix, - bindType: fix, - - handle: function( event ) { - var ret, - target = this, - related = event.relatedTarget, - handleObj = event.handleObj; - - // For mousenter/leave call the handler if related is outside the target. - // NB: No relatedTarget if the mouse left/entered the browser window - if ( !related || (related !== target && !jQuery.contains( target, related )) ) { - event.type = handleObj.origType; - ret = handleObj.handler.apply( this, arguments ); - event.type = fix; - } - return ret; - } - }; -}); - -// IE submit delegation -if ( !support.submitBubbles ) { - - jQuery.event.special.submit = { - setup: function() { - // Only need this for delegated form submit events - if ( jQuery.nodeName( this, "form" ) ) { - return false; - } - - // Lazy-add a submit handler when a descendant form may potentially be submitted - jQuery.event.add( this, "click._submit keypress._submit", function( e ) { - // Node name check avoids a VML-related crash in IE (#9807) - var elem = e.target, - form = jQuery.nodeName( elem, "input" ) || jQuery.nodeName( elem, "button" ) ? elem.form : undefined; - if ( form && !jQuery._data( form, "submitBubbles" ) ) { - jQuery.event.add( form, "submit._submit", function( event ) { - event._submit_bubble = true; - }); - jQuery._data( form, "submitBubbles", true ); - } - }); - // return undefined since we don't need an event listener - }, - - postDispatch: function( event ) { - // If form was submitted by the user, bubble the event up the tree - if ( event._submit_bubble ) { - delete event._submit_bubble; - if ( this.parentNode && !event.isTrigger ) { - jQuery.event.simulate( "submit", this.parentNode, event, true ); - } - } - }, - - teardown: function() { - // Only need this for delegated form submit events - if ( jQuery.nodeName( this, "form" ) ) { - return false; - } - - // Remove delegated handlers; cleanData eventually reaps submit handlers attached above - jQuery.event.remove( this, "._submit" ); - } - }; -} - -// IE change delegation and checkbox/radio fix -if ( !support.changeBubbles ) { - - jQuery.event.special.change = { - - setup: function() { - - if ( rformElems.test( this.nodeName ) ) { - // IE doesn't fire change on a check/radio until blur; trigger it on click - // after a propertychange. Eat the blur-change in special.change.handle. - // This still fires onchange a second time for check/radio after blur. - if ( this.type === "checkbox" || this.type === "radio" ) { - jQuery.event.add( this, "propertychange._change", function( event ) { - if ( event.originalEvent.propertyName === "checked" ) { - this._just_changed = true; - } - }); - jQuery.event.add( this, "click._change", function( event ) { - if ( this._just_changed && !event.isTrigger ) { - this._just_changed = false; - } - // Allow triggered, simulated change events (#11500) - jQuery.event.simulate( "change", this, event, true ); - }); - } - return false; - } - // Delegated event; lazy-add a change handler on descendant inputs - jQuery.event.add( this, "beforeactivate._change", function( e ) { - var elem = e.target; - - if ( rformElems.test( elem.nodeName ) && !jQuery._data( elem, "changeBubbles" ) ) { - jQuery.event.add( elem, "change._change", function( event ) { - if ( this.parentNode && !event.isSimulated && !event.isTrigger ) { - jQuery.event.simulate( "change", this.parentNode, event, true ); - } - }); - jQuery._data( elem, "changeBubbles", true ); - } - }); - }, - - handle: function( event ) { - var elem = event.target; - - // Swallow native change events from checkbox/radio, we already triggered them above - if ( this !== elem || event.isSimulated || event.isTrigger || (elem.type !== "radio" && elem.type !== "checkbox") ) { - return event.handleObj.handler.apply( this, arguments ); - } - }, - - teardown: function() { - jQuery.event.remove( this, "._change" ); - - return !rformElems.test( this.nodeName ); - } - }; -} - -// Create "bubbling" focus and blur events -if ( !support.focusinBubbles ) { - jQuery.each({ focus: "focusin", blur: "focusout" }, function( orig, fix ) { - - // Attach a single capturing handler on the document while someone wants focusin/focusout - var handler = function( event ) { - jQuery.event.simulate( fix, event.target, jQuery.event.fix( event ), true ); - }; - - jQuery.event.special[ fix ] = { - setup: function() { - var doc = this.ownerDocument || this, - attaches = jQuery._data( doc, fix ); - - if ( !attaches ) { - doc.addEventListener( orig, handler, true ); - } - jQuery._data( doc, fix, ( attaches || 0 ) + 1 ); - }, - teardown: function() { - var doc = this.ownerDocument || this, - attaches = jQuery._data( doc, fix ) - 1; - - if ( !attaches ) { - doc.removeEventListener( orig, handler, true ); - jQuery._removeData( doc, fix ); - } else { - jQuery._data( doc, fix, attaches ); - } - } - }; - }); -} - -jQuery.fn.extend({ - - on: function( types, selector, data, fn, /*INTERNAL*/ one ) { - var type, origFn; - - // Types can be a map of types/handlers - if ( typeof types === "object" ) { - // ( types-Object, selector, data ) - if ( typeof selector !== "string" ) { - // ( types-Object, data ) - data = data || selector; - selector = undefined; - } - for ( type in types ) { - this.on( type, selector, data, types[ type ], one ); - } - return this; - } - - if ( data == null && fn == null ) { - // ( types, fn ) - fn = selector; - data = selector = undefined; - } else if ( fn == null ) { - if ( typeof selector === "string" ) { - // ( types, selector, fn ) - fn = data; - data = undefined; - } else { - // ( types, data, fn ) - fn = data; - data = selector; - selector = undefined; - } - } - if ( fn === false ) { - fn = returnFalse; - } else if ( !fn ) { - return this; - } - - if ( one === 1 ) { - origFn = fn; - fn = function( event ) { - // Can use an empty set, since event contains the info - jQuery().off( event ); - return origFn.apply( this, arguments ); - }; - // Use same guid so caller can remove using origFn - fn.guid = origFn.guid || ( origFn.guid = jQuery.guid++ ); - } - return this.each( function() { - jQuery.event.add( this, types, fn, data, selector ); - }); - }, - one: function( types, selector, data, fn ) { - return this.on( types, selector, data, fn, 1 ); - }, - off: function( types, selector, fn ) { - var handleObj, type; - if ( types && types.preventDefault && types.handleObj ) { - // ( event ) dispatched jQuery.Event - handleObj = types.handleObj; - jQuery( types.delegateTarget ).off( - handleObj.namespace ? handleObj.origType + "." + handleObj.namespace : handleObj.origType, - handleObj.selector, - handleObj.handler - ); - return this; - } - if ( typeof types === "object" ) { - // ( types-object [, selector] ) - for ( type in types ) { - this.off( type, selector, types[ type ] ); - } - return this; - } - if ( selector === false || typeof selector === "function" ) { - // ( types [, fn] ) - fn = selector; - selector = undefined; - } - if ( fn === false ) { - fn = returnFalse; - } - return this.each(function() { - jQuery.event.remove( this, types, fn, selector ); - }); - }, - - trigger: function( type, data ) { - return this.each(function() { - jQuery.event.trigger( type, data, this ); - }); - }, - triggerHandler: function( type, data ) { - var elem = this[0]; - if ( elem ) { - return jQuery.event.trigger( type, data, elem, true ); - } - } -}); - - -function createSafeFragment( document ) { - var list = nodeNames.split( "|" ), - safeFrag = document.createDocumentFragment(); - - if ( safeFrag.createElement ) { - while ( list.length ) { - safeFrag.createElement( - list.pop() - ); - } - } - return safeFrag; -} - -var nodeNames = "abbr|article|aside|audio|bdi|canvas|data|datalist|details|figcaption|figure|footer|" + - "header|hgroup|mark|meter|nav|output|progress|section|summary|time|video", - rinlinejQuery = / jQuery\d+="(?:null|\d+)"/g, - rnoshimcache = new RegExp("<(?:" + nodeNames + ")[\\s/>]", "i"), - rleadingWhitespace = /^\s+/, - rxhtmlTag = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi, - rtagName = /<([\w:]+)/, - rtbody = /<tbody/i, - rhtml = /<|&#?\w+;/, - rnoInnerhtml = /<(?:script|style|link)/i, - // checked="checked" or checked - rchecked = /checked\s*(?:[^=]|=\s*.checked.)/i, - rscriptType = /^$|\/(?:java|ecma)script/i, - rscriptTypeMasked = /^true\/(.*)/, - rcleanScript = /^\s*<!(?:\[CDATA\[|--)|(?:\]\]|--)>\s*$/g, - - // We have to close these tags to support XHTML (#13200) - wrapMap = { - option: [ 1, "<select multiple='multiple'>", "</select>" ], - legend: [ 1, "<fieldset>", "</fieldset>" ], - area: [ 1, "<map>", "</map>" ], - param: [ 1, "<object>", "</object>" ], - thead: [ 1, "<table>", "</table>" ], - tr: [ 2, "<table><tbody>", "</tbody></table>" ], - col: [ 2, "<table><tbody></tbody><colgroup>", "</colgroup></table>" ], - td: [ 3, "<table><tbody><tr>", "</tr></tbody></table>" ], - - // IE6-8 can't serialize link, script, style, or any html5 (NoScope) tags, - // unless wrapped in a div with non-breaking characters in front of it. - _default: support.htmlSerialize ? [ 0, "", "" ] : [ 1, "X<div>", "</div>" ] - }, - safeFragment = createSafeFragment( document ), - fragmentDiv = safeFragment.appendChild( document.createElement("div") ); - -wrapMap.optgroup = wrapMap.option; -wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead; -wrapMap.th = wrapMap.td; - -function getAll( context, tag ) { - var elems, elem, - i = 0, - found = typeof context.getElementsByTagName !== strundefined ? context.getElementsByTagName( tag || "*" ) : - typeof context.querySelectorAll !== strundefined ? context.querySelectorAll( tag || "*" ) : - undefined; - - if ( !found ) { - for ( found = [], elems = context.childNodes || context; (elem = elems[i]) != null; i++ ) { - if ( !tag || jQuery.nodeName( elem, tag ) ) { - found.push( elem ); - } else { - jQuery.merge( found, getAll( elem, tag ) ); - } - } - } - - return tag === undefined || tag && jQuery.nodeName( context, tag ) ? - jQuery.merge( [ context ], found ) : - found; -} - -// Used in buildFragment, fixes the defaultChecked property -function fixDefaultChecked( elem ) { - if ( rcheckableType.test( elem.type ) ) { - elem.defaultChecked = elem.checked; - } -} - -// Support: IE<8 -// Manipulating tables requires a tbody -function manipulationTarget( elem, content ) { - return jQuery.nodeName( elem, "table" ) && - jQuery.nodeName( content.nodeType !== 11 ? content : content.firstChild, "tr" ) ? - - elem.getElementsByTagName("tbody")[0] || - elem.appendChild( elem.ownerDocument.createElement("tbody") ) : - elem; -} - -// Replace/restore the type attribute of script elements for safe DOM manipulation -function disableScript( elem ) { - elem.type = (jQuery.find.attr( elem, "type" ) !== null) + "/" + elem.type; - return elem; -} -function restoreScript( elem ) { - var match = rscriptTypeMasked.exec( elem.type ); - if ( match ) { - elem.type = match[1]; - } else { - elem.removeAttribute("type"); - } - return elem; -} - -// Mark scripts as having already been evaluated -function setGlobalEval( elems, refElements ) { - var elem, - i = 0; - for ( ; (elem = elems[i]) != null; i++ ) { - jQuery._data( elem, "globalEval", !refElements || jQuery._data( refElements[i], "globalEval" ) ); - } -} - -function cloneCopyEvent( src, dest ) { - - if ( dest.nodeType !== 1 || !jQuery.hasData( src ) ) { - return; - } - - var type, i, l, - oldData = jQuery._data( src ), - curData = jQuery._data( dest, oldData ), - events = oldData.events; - - if ( events ) { - delete curData.handle; - curData.events = {}; - - for ( type in events ) { - for ( i = 0, l = events[ type ].length; i < l; i++ ) { - jQuery.event.add( dest, type, events[ type ][ i ] ); - } - } - } - - // make the cloned public data object a copy from the original - if ( curData.data ) { - curData.data = jQuery.extend( {}, curData.data ); - } -} - -function fixCloneNodeIssues( src, dest ) { - var nodeName, e, data; - - // We do not need to do anything for non-Elements - if ( dest.nodeType !== 1 ) { - return; - } - - nodeName = dest.nodeName.toLowerCase(); - - // IE6-8 copies events bound via attachEvent when using cloneNode. - if ( !support.noCloneEvent && dest[ jQuery.expando ] ) { - data = jQuery._data( dest ); - - for ( e in data.events ) { - jQuery.removeEvent( dest, e, data.handle ); - } - - // Event data gets referenced instead of copied if the expando gets copied too - dest.removeAttribute( jQuery.expando ); - } - - // IE blanks contents when cloning scripts, and tries to evaluate newly-set text - if ( nodeName === "script" && dest.text !== src.text ) { - disableScript( dest ).text = src.text; - restoreScript( dest ); - - // IE6-10 improperly clones children of object elements using classid. - // IE10 throws NoModificationAllowedError if parent is null, #12132. - } else if ( nodeName === "object" ) { - if ( dest.parentNode ) { - dest.outerHTML = src.outerHTML; - } - - // This path appears unavoidable for IE9. When cloning an object - // element in IE9, the outerHTML strategy above is not sufficient. - // If the src has innerHTML and the destination does not, - // copy the src.innerHTML into the dest.innerHTML. #10324 - if ( support.html5Clone && ( src.innerHTML && !jQuery.trim(dest.innerHTML) ) ) { - dest.innerHTML = src.innerHTML; - } - - } else if ( nodeName === "input" && rcheckableType.test( src.type ) ) { - // IE6-8 fails to persist the checked state of a cloned checkbox - // or radio button. Worse, IE6-7 fail to give the cloned element - // a checked appearance if the defaultChecked value isn't also set - - dest.defaultChecked = dest.checked = src.checked; - - // IE6-7 get confused and end up setting the value of a cloned - // checkbox/radio button to an empty string instead of "on" - if ( dest.value !== src.value ) { - dest.value = src.value; - } - - // IE6-8 fails to return the selected option to the default selected - // state when cloning options - } else if ( nodeName === "option" ) { - dest.defaultSelected = dest.selected = src.defaultSelected; - - // IE6-8 fails to set the defaultValue to the correct value when - // cloning other types of input fields - } else if ( nodeName === "input" || nodeName === "textarea" ) { - dest.defaultValue = src.defaultValue; - } -} - -jQuery.extend({ - clone: function( elem, dataAndEvents, deepDataAndEvents ) { - var destElements, node, clone, i, srcElements, - inPage = jQuery.contains( elem.ownerDocument, elem ); - - if ( support.html5Clone || jQuery.isXMLDoc(elem) || !rnoshimcache.test( "<" + elem.nodeName + ">" ) ) { - clone = elem.cloneNode( true ); - - // IE<=8 does not properly clone detached, unknown element nodes - } else { - fragmentDiv.innerHTML = elem.outerHTML; - fragmentDiv.removeChild( clone = fragmentDiv.firstChild ); - } - - if ( (!support.noCloneEvent || !support.noCloneChecked) && - (elem.nodeType === 1 || elem.nodeType === 11) && !jQuery.isXMLDoc(elem) ) { - - // We eschew Sizzle here for performance reasons: http://jsperf.com/getall-vs-sizzle/2 - destElements = getAll( clone ); - srcElements = getAll( elem ); - - // Fix all IE cloning issues - for ( i = 0; (node = srcElements[i]) != null; ++i ) { - // Ensure that the destination node is not null; Fixes #9587 - if ( destElements[i] ) { - fixCloneNodeIssues( node, destElements[i] ); - } - } - } - - // Copy the events from the original to the clone - if ( dataAndEvents ) { - if ( deepDataAndEvents ) { - srcElements = srcElements || getAll( elem ); - destElements = destElements || getAll( clone ); - - for ( i = 0; (node = srcElements[i]) != null; i++ ) { - cloneCopyEvent( node, destElements[i] ); - } - } else { - cloneCopyEvent( elem, clone ); - } - } - - // Preserve script evaluation history - destElements = getAll( clone, "script" ); - if ( destElements.length > 0 ) { - setGlobalEval( destElements, !inPage && getAll( elem, "script" ) ); - } - - destElements = srcElements = node = null; - - // Return the cloned set - return clone; - }, - - buildFragment: function( elems, context, scripts, selection ) { - var j, elem, contains, - tmp, tag, tbody, wrap, - l = elems.length, - - // Ensure a safe fragment - safe = createSafeFragment( context ), - - nodes = [], - i = 0; - - for ( ; i < l; i++ ) { - elem = elems[ i ]; - - if ( elem || elem === 0 ) { - - // Add nodes directly - if ( jQuery.type( elem ) === "object" ) { - jQuery.merge( nodes, elem.nodeType ? [ elem ] : elem ); - - // Convert non-html into a text node - } else if ( !rhtml.test( elem ) ) { - nodes.push( context.createTextNode( elem ) ); - - // Convert html into DOM nodes - } else { - tmp = tmp || safe.appendChild( context.createElement("div") ); - - // Deserialize a standard representation - tag = (rtagName.exec( elem ) || [ "", "" ])[ 1 ].toLowerCase(); - wrap = wrapMap[ tag ] || wrapMap._default; - - tmp.innerHTML = wrap[1] + elem.replace( rxhtmlTag, "<$1></$2>" ) + wrap[2]; - - // Descend through wrappers to the right content - j = wrap[0]; - while ( j-- ) { - tmp = tmp.lastChild; - } - - // Manually add leading whitespace removed by IE - if ( !support.leadingWhitespace && rleadingWhitespace.test( elem ) ) { - nodes.push( context.createTextNode( rleadingWhitespace.exec( elem )[0] ) ); - } - - // Remove IE's autoinserted <tbody> from table fragments - if ( !support.tbody ) { - - // String was a <table>, *may* have spurious <tbody> - elem = tag === "table" && !rtbody.test( elem ) ? - tmp.firstChild : - - // String was a bare <thead> or <tfoot> - wrap[1] === "<table>" && !rtbody.test( elem ) ? - tmp : - 0; - - j = elem && elem.childNodes.length; - while ( j-- ) { - if ( jQuery.nodeName( (tbody = elem.childNodes[j]), "tbody" ) && !tbody.childNodes.length ) { - elem.removeChild( tbody ); - } - } - } - - jQuery.merge( nodes, tmp.childNodes ); - - // Fix #12392 for WebKit and IE > 9 - tmp.textContent = ""; - - // Fix #12392 for oldIE - while ( tmp.firstChild ) { - tmp.removeChild( tmp.firstChild ); - } - - // Remember the top-level container for proper cleanup - tmp = safe.lastChild; - } - } - } - - // Fix #11356: Clear elements from fragment - if ( tmp ) { - safe.removeChild( tmp ); - } - - // Reset defaultChecked for any radios and checkboxes - // about to be appended to the DOM in IE 6/7 (#8060) - if ( !support.appendChecked ) { - jQuery.grep( getAll( nodes, "input" ), fixDefaultChecked ); - } - - i = 0; - while ( (elem = nodes[ i++ ]) ) { - - // #4087 - If origin and destination elements are the same, and this is - // that element, do not do anything - if ( selection && jQuery.inArray( elem, selection ) !== -1 ) { - continue; - } - - contains = jQuery.contains( elem.ownerDocument, elem ); - - // Append to fragment - tmp = getAll( safe.appendChild( elem ), "script" ); - - // Preserve script evaluation history - if ( contains ) { - setGlobalEval( tmp ); - } - - // Capture executables - if ( scripts ) { - j = 0; - while ( (elem = tmp[ j++ ]) ) { - if ( rscriptType.test( elem.type || "" ) ) { - scripts.push( elem ); - } - } - } - } - - tmp = null; - - return safe; - }, - - cleanData: function( elems, /* internal */ acceptData ) { - var elem, type, id, data, - i = 0, - internalKey = jQuery.expando, - cache = jQuery.cache, - deleteExpando = support.deleteExpando, - special = jQuery.event.special; - - for ( ; (elem = elems[i]) != null; i++ ) { - if ( acceptData || jQuery.acceptData( elem ) ) { - - id = elem[ internalKey ]; - data = id && cache[ id ]; - - if ( data ) { - if ( data.events ) { - for ( type in data.events ) { - if ( special[ type ] ) { - jQuery.event.remove( elem, type ); - - // This is a shortcut to avoid jQuery.event.remove's overhead - } else { - jQuery.removeEvent( elem, type, data.handle ); - } - } - } - - // Remove cache only if it was not already removed by jQuery.event.remove - if ( cache[ id ] ) { - - delete cache[ id ]; - - // IE does not allow us to delete expando properties from nodes, - // nor does it have a removeAttribute function on Document nodes; - // we must handle all of these cases - if ( deleteExpando ) { - delete elem[ internalKey ]; - - } else if ( typeof elem.removeAttribute !== strundefined ) { - elem.removeAttribute( internalKey ); - - } else { - elem[ internalKey ] = null; - } - - deletedIds.push( id ); - } - } - } - } - } -}); - -jQuery.fn.extend({ - text: function( value ) { - return access( this, function( value ) { - return value === undefined ? - jQuery.text( this ) : - this.empty().append( ( this[0] && this[0].ownerDocument || document ).createTextNode( value ) ); - }, null, value, arguments.length ); - }, - - append: function() { - return this.domManip( arguments, function( elem ) { - if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { - var target = manipulationTarget( this, elem ); - target.appendChild( elem ); - } - }); - }, - - prepend: function() { - return this.domManip( arguments, function( elem ) { - if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { - var target = manipulationTarget( this, elem ); - target.insertBefore( elem, target.firstChild ); - } - }); - }, - - before: function() { - return this.domManip( arguments, function( elem ) { - if ( this.parentNode ) { - this.parentNode.insertBefore( elem, this ); - } - }); - }, - - after: function() { - return this.domManip( arguments, function( elem ) { - if ( this.parentNode ) { - this.parentNode.insertBefore( elem, this.nextSibling ); - } - }); - }, - - remove: function( selector, keepData /* Internal Use Only */ ) { - var elem, - elems = selector ? jQuery.filter( selector, this ) : this, - i = 0; - - for ( ; (elem = elems[i]) != null; i++ ) { - - if ( !keepData && elem.nodeType === 1 ) { - jQuery.cleanData( getAll( elem ) ); - } - - if ( elem.parentNode ) { - if ( keepData && jQuery.contains( elem.ownerDocument, elem ) ) { - setGlobalEval( getAll( elem, "script" ) ); - } - elem.parentNode.removeChild( elem ); - } - } - - return this; - }, - - empty: function() { - var elem, - i = 0; - - for ( ; (elem = this[i]) != null; i++ ) { - // Remove element nodes and prevent memory leaks - if ( elem.nodeType === 1 ) { - jQuery.cleanData( getAll( elem, false ) ); - } - - // Remove any remaining nodes - while ( elem.firstChild ) { - elem.removeChild( elem.firstChild ); - } - - // If this is a select, ensure that it displays empty (#12336) - // Support: IE<9 - if ( elem.options && jQuery.nodeName( elem, "select" ) ) { - elem.options.length = 0; - } - } - - return this; - }, - - clone: function( dataAndEvents, deepDataAndEvents ) { - dataAndEvents = dataAndEvents == null ? false : dataAndEvents; - deepDataAndEvents = deepDataAndEvents == null ? dataAndEvents : deepDataAndEvents; - - return this.map(function() { - return jQuery.clone( this, dataAndEvents, deepDataAndEvents ); - }); - }, - - html: function( value ) { - return access( this, function( value ) { - var elem = this[ 0 ] || {}, - i = 0, - l = this.length; - - if ( value === undefined ) { - return elem.nodeType === 1 ? - elem.innerHTML.replace( rinlinejQuery, "" ) : - undefined; - } - - // See if we can take a shortcut and just use innerHTML - if ( typeof value === "string" && !rnoInnerhtml.test( value ) && - ( support.htmlSerialize || !rnoshimcache.test( value ) ) && - ( support.leadingWhitespace || !rleadingWhitespace.test( value ) ) && - !wrapMap[ (rtagName.exec( value ) || [ "", "" ])[ 1 ].toLowerCase() ] ) { - - value = value.replace( rxhtmlTag, "<$1></$2>" ); - - try { - for (; i < l; i++ ) { - // Remove element nodes and prevent memory leaks - elem = this[i] || {}; - if ( elem.nodeType === 1 ) { - jQuery.cleanData( getAll( elem, false ) ); - elem.innerHTML = value; - } - } - - elem = 0; - - // If using innerHTML throws an exception, use the fallback method - } catch(e) {} - } - - if ( elem ) { - this.empty().append( value ); - } - }, null, value, arguments.length ); - }, - - replaceWith: function() { - var arg = arguments[ 0 ]; - - // Make the changes, replacing each context element with the new content - this.domManip( arguments, function( elem ) { - arg = this.parentNode; - - jQuery.cleanData( getAll( this ) ); - - if ( arg ) { - arg.replaceChild( elem, this ); - } - }); - - // Force removal if there was no new content (e.g., from empty arguments) - return arg && (arg.length || arg.nodeType) ? this : this.remove(); - }, - - detach: function( selector ) { - return this.remove( selector, true ); - }, - - domManip: function( args, callback ) { - - // Flatten any nested arrays - args = concat.apply( [], args ); - - var first, node, hasScripts, - scripts, doc, fragment, - i = 0, - l = this.length, - set = this, - iNoClone = l - 1, - value = args[0], - isFunction = jQuery.isFunction( value ); - - // We can't cloneNode fragments that contain checked, in WebKit - if ( isFunction || - ( l > 1 && typeof value === "string" && - !support.checkClone && rchecked.test( value ) ) ) { - return this.each(function( index ) { - var self = set.eq( index ); - if ( isFunction ) { - args[0] = value.call( this, index, self.html() ); - } - self.domManip( args, callback ); - }); - } - - if ( l ) { - fragment = jQuery.buildFragment( args, this[ 0 ].ownerDocument, false, this ); - first = fragment.firstChild; - - if ( fragment.childNodes.length === 1 ) { - fragment = first; - } - - if ( first ) { - scripts = jQuery.map( getAll( fragment, "script" ), disableScript ); - hasScripts = scripts.length; - - // Use the original fragment for the last item instead of the first because it can end up - // being emptied incorrectly in certain situations (#8070). - for ( ; i < l; i++ ) { - node = fragment; - - if ( i !== iNoClone ) { - node = jQuery.clone( node, true, true ); - - // Keep references to cloned scripts for later restoration - if ( hasScripts ) { - jQuery.merge( scripts, getAll( node, "script" ) ); - } - } - - callback.call( this[i], node, i ); - } - - if ( hasScripts ) { - doc = scripts[ scripts.length - 1 ].ownerDocument; - - // Reenable scripts - jQuery.map( scripts, restoreScript ); - - // Evaluate executable scripts on first document insertion - for ( i = 0; i < hasScripts; i++ ) { - node = scripts[ i ]; - if ( rscriptType.test( node.type || "" ) && - !jQuery._data( node, "globalEval" ) && jQuery.contains( doc, node ) ) { - - if ( node.src ) { - // Optional AJAX dependency, but won't run scripts if not present - if ( jQuery._evalUrl ) { - jQuery._evalUrl( node.src ); - } - } else { - jQuery.globalEval( ( node.text || node.textContent || node.innerHTML || "" ).replace( rcleanScript, "" ) ); - } - } - } - } - - // Fix #11809: Avoid leaking memory - fragment = first = null; - } - } - - return this; - } -}); - -jQuery.each({ - appendTo: "append", - prependTo: "prepend", - insertBefore: "before", - insertAfter: "after", - replaceAll: "replaceWith" -}, function( name, original ) { - jQuery.fn[ name ] = function( selector ) { - var elems, - i = 0, - ret = [], - insert = jQuery( selector ), - last = insert.length - 1; - - for ( ; i <= last; i++ ) { - elems = i === last ? this : this.clone(true); - jQuery( insert[i] )[ original ]( elems ); - - // Modern browsers can apply jQuery collections as arrays, but oldIE needs a .get() - push.apply( ret, elems.get() ); - } - - return this.pushStack( ret ); - }; -}); - - -var iframe, - elemdisplay = {}; - -/** - * Retrieve the actual display of a element - * @param {String} name nodeName of the element - * @param {Object} doc Document object - */ -// Called only from within defaultDisplay -function actualDisplay( name, doc ) { - var style, - elem = jQuery( doc.createElement( name ) ).appendTo( doc.body ), - - // getDefaultComputedStyle might be reliably used only on attached element - display = window.getDefaultComputedStyle && ( style = window.getDefaultComputedStyle( elem[ 0 ] ) ) ? - - // Use of this method is a temporary fix (more like optmization) until something better comes along, - // since it was removed from specification and supported only in FF - style.display : jQuery.css( elem[ 0 ], "display" ); - - // We don't have any data stored on the element, - // so use "detach" method as fast way to get rid of the element - elem.detach(); - - return display; -} - -/** - * Try to determine the default display value of an element - * @param {String} nodeName - */ -function defaultDisplay( nodeName ) { - var doc = document, - display = elemdisplay[ nodeName ]; - - if ( !display ) { - display = actualDisplay( nodeName, doc ); - - // If the simple way fails, read from inside an iframe - if ( display === "none" || !display ) { - - // Use the already-created iframe if possible - iframe = (iframe || jQuery( "<iframe frameborder='0' width='0' height='0'/>" )).appendTo( doc.documentElement ); - - // Always write a new HTML skeleton so Webkit and Firefox don't choke on reuse - doc = ( iframe[ 0 ].contentWindow || iframe[ 0 ].contentDocument ).document; - - // Support: IE - doc.write(); - doc.close(); - - display = actualDisplay( nodeName, doc ); - iframe.detach(); - } - - // Store the correct default display - elemdisplay[ nodeName ] = display; - } - - return display; -} - - -(function() { - var shrinkWrapBlocksVal; - - support.shrinkWrapBlocks = function() { - if ( shrinkWrapBlocksVal != null ) { - return shrinkWrapBlocksVal; - } - - // Will be changed later if needed. - shrinkWrapBlocksVal = false; - - // Minified: var b,c,d - var div, body, container; - - body = document.getElementsByTagName( "body" )[ 0 ]; - if ( !body || !body.style ) { - // Test fired too early or in an unsupported environment, exit. - return; - } - - // Setup - div = document.createElement( "div" ); - container = document.createElement( "div" ); - container.style.cssText = "position:absolute;border:0;width:0;height:0;top:0;left:-9999px"; - body.appendChild( container ).appendChild( div ); - - // Support: IE6 - // Check if elements with layout shrink-wrap their children - if ( typeof div.style.zoom !== strundefined ) { - // Reset CSS: box-sizing; display; margin; border - div.style.cssText = - // Support: Firefox<29, Android 2.3 - // Vendor-prefix box-sizing - "-webkit-box-sizing:content-box;-moz-box-sizing:content-box;" + - "box-sizing:content-box;display:block;margin:0;border:0;" + - "padding:1px;width:1px;zoom:1"; - div.appendChild( document.createElement( "div" ) ).style.width = "5px"; - shrinkWrapBlocksVal = div.offsetWidth !== 3; - } - - body.removeChild( container ); - - return shrinkWrapBlocksVal; - }; - -})(); -var rmargin = (/^margin/); - -var rnumnonpx = new RegExp( "^(" + pnum + ")(?!px)[a-z%]+$", "i" ); - - - -var getStyles, curCSS, - rposition = /^(top|right|bottom|left)$/; - -if ( window.getComputedStyle ) { - getStyles = function( elem ) { - // Support: IE<=11+, Firefox<=30+ (#15098, #14150) - // IE throws on elements created in popups - // FF meanwhile throws on frame elements through "defaultView.getComputedStyle" - if ( elem.ownerDocument.defaultView.opener ) { - return elem.ownerDocument.defaultView.getComputedStyle( elem, null ); - } - - return window.getComputedStyle( elem, null ); - }; - - curCSS = function( elem, name, computed ) { - var width, minWidth, maxWidth, ret, - style = elem.style; - - computed = computed || getStyles( elem ); - - // getPropertyValue is only needed for .css('filter') in IE9, see #12537 - ret = computed ? computed.getPropertyValue( name ) || computed[ name ] : undefined; - - if ( computed ) { - - if ( ret === "" && !jQuery.contains( elem.ownerDocument, elem ) ) { - ret = jQuery.style( elem, name ); - } - - // A tribute to the "awesome hack by Dean Edwards" - // Chrome < 17 and Safari 5.0 uses "computed value" instead of "used value" for margin-right - // Safari 5.1.7 (at least) returns percentage for a larger set of values, but width seems to be reliably pixels - // this is against the CSSOM draft spec: http://dev.w3.org/csswg/cssom/#resolved-values - if ( rnumnonpx.test( ret ) && rmargin.test( name ) ) { - - // Remember the original values - width = style.width; - minWidth = style.minWidth; - maxWidth = style.maxWidth; - - // Put in the new values to get a computed value out - style.minWidth = style.maxWidth = style.width = ret; - ret = computed.width; - - // Revert the changed values - style.width = width; - style.minWidth = minWidth; - style.maxWidth = maxWidth; - } - } - - // Support: IE - // IE returns zIndex value as an integer. - return ret === undefined ? - ret : - ret + ""; - }; -} else if ( document.documentElement.currentStyle ) { - getStyles = function( elem ) { - return elem.currentStyle; - }; - - curCSS = function( elem, name, computed ) { - var left, rs, rsLeft, ret, - style = elem.style; - - computed = computed || getStyles( elem ); - ret = computed ? computed[ name ] : undefined; - - // Avoid setting ret to empty string here - // so we don't default to auto - if ( ret == null && style && style[ name ] ) { - ret = style[ name ]; - } - - // From the awesome hack by Dean Edwards - // http://erik.eae.net/archives/2007/07/27/18.54.15/#comment-102291 - - // If we're not dealing with a regular pixel number - // but a number that has a weird ending, we need to convert it to pixels - // but not position css attributes, as those are proportional to the parent element instead - // and we can't measure the parent instead because it might trigger a "stacking dolls" problem - if ( rnumnonpx.test( ret ) && !rposition.test( name ) ) { - - // Remember the original values - left = style.left; - rs = elem.runtimeStyle; - rsLeft = rs && rs.left; - - // Put in the new values to get a computed value out - if ( rsLeft ) { - rs.left = elem.currentStyle.left; - } - style.left = name === "fontSize" ? "1em" : ret; - ret = style.pixelLeft + "px"; - - // Revert the changed values - style.left = left; - if ( rsLeft ) { - rs.left = rsLeft; - } - } - - // Support: IE - // IE returns zIndex value as an integer. - return ret === undefined ? - ret : - ret + "" || "auto"; - }; -} - - - - -function addGetHookIf( conditionFn, hookFn ) { - // Define the hook, we'll check on the first run if it's really needed. - return { - get: function() { - var condition = conditionFn(); - - if ( condition == null ) { - // The test was not ready at this point; screw the hook this time - // but check again when needed next time. - return; - } - - if ( condition ) { - // Hook not needed (or it's not possible to use it due to missing dependency), - // remove it. - // Since there are no other hooks for marginRight, remove the whole object. - delete this.get; - return; - } - - // Hook needed; redefine it so that the support test is not executed again. - - return (this.get = hookFn).apply( this, arguments ); - } - }; -} - - -(function() { - // Minified: var b,c,d,e,f,g, h,i - var div, style, a, pixelPositionVal, boxSizingReliableVal, - reliableHiddenOffsetsVal, reliableMarginRightVal; - - // Setup - div = document.createElement( "div" ); - div.innerHTML = " <link/><table></table><a href='/a'>a</a><input type='checkbox'/>"; - a = div.getElementsByTagName( "a" )[ 0 ]; - style = a && a.style; - - // Finish early in limited (non-browser) environments - if ( !style ) { - return; - } - - style.cssText = "float:left;opacity:.5"; - - // Support: IE<9 - // Make sure that element opacity exists (as opposed to filter) - support.opacity = style.opacity === "0.5"; - - // Verify style float existence - // (IE uses styleFloat instead of cssFloat) - support.cssFloat = !!style.cssFloat; - - div.style.backgroundClip = "content-box"; - div.cloneNode( true ).style.backgroundClip = ""; - support.clearCloneStyle = div.style.backgroundClip === "content-box"; - - // Support: Firefox<29, Android 2.3 - // Vendor-prefix box-sizing - support.boxSizing = style.boxSizing === "" || style.MozBoxSizing === "" || - style.WebkitBoxSizing === ""; - - jQuery.extend(support, { - reliableHiddenOffsets: function() { - if ( reliableHiddenOffsetsVal == null ) { - computeStyleTests(); - } - return reliableHiddenOffsetsVal; - }, - - boxSizingReliable: function() { - if ( boxSizingReliableVal == null ) { - computeStyleTests(); - } - return boxSizingReliableVal; - }, - - pixelPosition: function() { - if ( pixelPositionVal == null ) { - computeStyleTests(); - } - return pixelPositionVal; - }, - - // Support: Android 2.3 - reliableMarginRight: function() { - if ( reliableMarginRightVal == null ) { - computeStyleTests(); - } - return reliableMarginRightVal; - } - }); - - function computeStyleTests() { - // Minified: var b,c,d,j - var div, body, container, contents; - - body = document.getElementsByTagName( "body" )[ 0 ]; - if ( !body || !body.style ) { - // Test fired too early or in an unsupported environment, exit. - return; - } - - // Setup - div = document.createElement( "div" ); - container = document.createElement( "div" ); - container.style.cssText = "position:absolute;border:0;width:0;height:0;top:0;left:-9999px"; - body.appendChild( container ).appendChild( div ); - - div.style.cssText = - // Support: Firefox<29, Android 2.3 - // Vendor-prefix box-sizing - "-webkit-box-sizing:border-box;-moz-box-sizing:border-box;" + - "box-sizing:border-box;display:block;margin-top:1%;top:1%;" + - "border:1px;padding:1px;width:4px;position:absolute"; - - // Support: IE<9 - // Assume reasonable values in the absence of getComputedStyle - pixelPositionVal = boxSizingReliableVal = false; - reliableMarginRightVal = true; - - // Check for getComputedStyle so that this code is not run in IE<9. - if ( window.getComputedStyle ) { - pixelPositionVal = ( window.getComputedStyle( div, null ) || {} ).top !== "1%"; - boxSizingReliableVal = - ( window.getComputedStyle( div, null ) || { width: "4px" } ).width === "4px"; - - // Support: Android 2.3 - // Div with explicit width and no margin-right incorrectly - // gets computed margin-right based on width of container (#3333) - // WebKit Bug 13343 - getComputedStyle returns wrong value for margin-right - contents = div.appendChild( document.createElement( "div" ) ); - - // Reset CSS: box-sizing; display; margin; border; padding - contents.style.cssText = div.style.cssText = - // Support: Firefox<29, Android 2.3 - // Vendor-prefix box-sizing - "-webkit-box-sizing:content-box;-moz-box-sizing:content-box;" + - "box-sizing:content-box;display:block;margin:0;border:0;padding:0"; - contents.style.marginRight = contents.style.width = "0"; - div.style.width = "1px"; - - reliableMarginRightVal = - !parseFloat( ( window.getComputedStyle( contents, null ) || {} ).marginRight ); - - div.removeChild( contents ); - } - - // Support: IE8 - // Check if table cells still have offsetWidth/Height when they are set - // to display:none and there are still other visible table cells in a - // table row; if so, offsetWidth/Height are not reliable for use when - // determining if an element has been hidden directly using - // display:none (it is still safe to use offsets if a parent element is - // hidden; don safety goggles and see bug #4512 for more information). - div.innerHTML = "<table><tr><td></td><td>t</td></tr></table>"; - contents = div.getElementsByTagName( "td" ); - contents[ 0 ].style.cssText = "margin:0;border:0;padding:0;display:none"; - reliableHiddenOffsetsVal = contents[ 0 ].offsetHeight === 0; - if ( reliableHiddenOffsetsVal ) { - contents[ 0 ].style.display = ""; - contents[ 1 ].style.display = "none"; - reliableHiddenOffsetsVal = contents[ 0 ].offsetHeight === 0; - } - - body.removeChild( container ); - } - -})(); - - -// A method for quickly swapping in/out CSS properties to get correct calculations. -jQuery.swap = function( elem, options, callback, args ) { - var ret, name, - old = {}; - - // Remember the old values, and insert the new ones - for ( name in options ) { - old[ name ] = elem.style[ name ]; - elem.style[ name ] = options[ name ]; - } - - ret = callback.apply( elem, args || [] ); - - // Revert the old values - for ( name in options ) { - elem.style[ name ] = old[ name ]; - } - - return ret; -}; - - -var - ralpha = /alpha\([^)]*\)/i, - ropacity = /opacity\s*=\s*([^)]*)/, - - // swappable if display is none or starts with table except "table", "table-cell", or "table-caption" - // see here for display values: https://developer.mozilla.org/en-US/docs/CSS/display - rdisplayswap = /^(none|table(?!-c[ea]).+)/, - rnumsplit = new RegExp( "^(" + pnum + ")(.*)$", "i" ), - rrelNum = new RegExp( "^([+-])=(" + pnum + ")", "i" ), - - cssShow = { position: "absolute", visibility: "hidden", display: "block" }, - cssNormalTransform = { - letterSpacing: "0", - fontWeight: "400" - }, - - cssPrefixes = [ "Webkit", "O", "Moz", "ms" ]; - - -// return a css property mapped to a potentially vendor prefixed property -function vendorPropName( style, name ) { - - // shortcut for names that are not vendor prefixed - if ( name in style ) { - return name; - } - - // check for vendor prefixed names - var capName = name.charAt(0).toUpperCase() + name.slice(1), - origName = name, - i = cssPrefixes.length; - - while ( i-- ) { - name = cssPrefixes[ i ] + capName; - if ( name in style ) { - return name; - } - } - - return origName; -} - -function showHide( elements, show ) { - var display, elem, hidden, - values = [], - index = 0, - length = elements.length; - - for ( ; index < length; index++ ) { - elem = elements[ index ]; - if ( !elem.style ) { - continue; - } - - values[ index ] = jQuery._data( elem, "olddisplay" ); - display = elem.style.display; - if ( show ) { - // Reset the inline display of this element to learn if it is - // being hidden by cascaded rules or not - if ( !values[ index ] && display === "none" ) { - elem.style.display = ""; - } - - // Set elements which have been overridden with display: none - // in a stylesheet to whatever the default browser style is - // for such an element - if ( elem.style.display === "" && isHidden( elem ) ) { - values[ index ] = jQuery._data( elem, "olddisplay", defaultDisplay(elem.nodeName) ); - } - } else { - hidden = isHidden( elem ); - - if ( display && display !== "none" || !hidden ) { - jQuery._data( elem, "olddisplay", hidden ? display : jQuery.css( elem, "display" ) ); - } - } - } - - // Set the display of most of the elements in a second loop - // to avoid the constant reflow - for ( index = 0; index < length; index++ ) { - elem = elements[ index ]; - if ( !elem.style ) { - continue; - } - if ( !show || elem.style.display === "none" || elem.style.display === "" ) { - elem.style.display = show ? values[ index ] || "" : "none"; - } - } - - return elements; -} - -function setPositiveNumber( elem, value, subtract ) { - var matches = rnumsplit.exec( value ); - return matches ? - // Guard against undefined "subtract", e.g., when used as in cssHooks - Math.max( 0, matches[ 1 ] - ( subtract || 0 ) ) + ( matches[ 2 ] || "px" ) : - value; -} - -function augmentWidthOrHeight( elem, name, extra, isBorderBox, styles ) { - var i = extra === ( isBorderBox ? "border" : "content" ) ? - // If we already have the right measurement, avoid augmentation - 4 : - // Otherwise initialize for horizontal or vertical properties - name === "width" ? 1 : 0, - - val = 0; - - for ( ; i < 4; i += 2 ) { - // both box models exclude margin, so add it if we want it - if ( extra === "margin" ) { - val += jQuery.css( elem, extra + cssExpand[ i ], true, styles ); - } - - if ( isBorderBox ) { - // border-box includes padding, so remove it if we want content - if ( extra === "content" ) { - val -= jQuery.css( elem, "padding" + cssExpand[ i ], true, styles ); - } - - // at this point, extra isn't border nor margin, so remove border - if ( extra !== "margin" ) { - val -= jQuery.css( elem, "border" + cssExpand[ i ] + "Width", true, styles ); - } - } else { - // at this point, extra isn't content, so add padding - val += jQuery.css( elem, "padding" + cssExpand[ i ], true, styles ); - - // at this point, extra isn't content nor padding, so add border - if ( extra !== "padding" ) { - val += jQuery.css( elem, "border" + cssExpand[ i ] + "Width", true, styles ); - } - } - } - - return val; -} - -function getWidthOrHeight( elem, name, extra ) { - - // Start with offset property, which is equivalent to the border-box value - var valueIsBorderBox = true, - val = name === "width" ? elem.offsetWidth : elem.offsetHeight, - styles = getStyles( elem ), - isBorderBox = support.boxSizing && jQuery.css( elem, "boxSizing", false, styles ) === "border-box"; - - // some non-html elements return undefined for offsetWidth, so check for null/undefined - // svg - https://bugzilla.mozilla.org/show_bug.cgi?id=649285 - // MathML - https://bugzilla.mozilla.org/show_bug.cgi?id=491668 - if ( val <= 0 || val == null ) { - // Fall back to computed then uncomputed css if necessary - val = curCSS( elem, name, styles ); - if ( val < 0 || val == null ) { - val = elem.style[ name ]; - } - - // Computed unit is not pixels. Stop here and return. - if ( rnumnonpx.test(val) ) { - return val; - } - - // we need the check for style in case a browser which returns unreliable values - // for getComputedStyle silently falls back to the reliable elem.style - valueIsBorderBox = isBorderBox && ( support.boxSizingReliable() || val === elem.style[ name ] ); - - // Normalize "", auto, and prepare for extra - val = parseFloat( val ) || 0; - } - - // use the active box-sizing model to add/subtract irrelevant styles - return ( val + - augmentWidthOrHeight( - elem, - name, - extra || ( isBorderBox ? "border" : "content" ), - valueIsBorderBox, - styles - ) - ) + "px"; -} - -jQuery.extend({ - // Add in style property hooks for overriding the default - // behavior of getting and setting a style property - cssHooks: { - opacity: { - get: function( elem, computed ) { - if ( computed ) { - // We should always get a number back from opacity - var ret = curCSS( elem, "opacity" ); - return ret === "" ? "1" : ret; - } - } - } - }, - - // Don't automatically add "px" to these possibly-unitless properties - cssNumber: { - "columnCount": true, - "fillOpacity": true, - "flexGrow": true, - "flexShrink": true, - "fontWeight": true, - "lineHeight": true, - "opacity": true, - "order": true, - "orphans": true, - "widows": true, - "zIndex": true, - "zoom": true - }, - - // Add in properties whose names you wish to fix before - // setting or getting the value - cssProps: { - // normalize float css property - "float": support.cssFloat ? "cssFloat" : "styleFloat" - }, - - // Get and set the style property on a DOM Node - style: function( elem, name, value, extra ) { - // Don't set styles on text and comment nodes - if ( !elem || elem.nodeType === 3 || elem.nodeType === 8 || !elem.style ) { - return; - } - - // Make sure that we're working with the right name - var ret, type, hooks, - origName = jQuery.camelCase( name ), - style = elem.style; - - name = jQuery.cssProps[ origName ] || ( jQuery.cssProps[ origName ] = vendorPropName( style, origName ) ); - - // gets hook for the prefixed version - // followed by the unprefixed version - hooks = jQuery.cssHooks[ name ] || jQuery.cssHooks[ origName ]; - - // Check if we're setting a value - if ( value !== undefined ) { - type = typeof value; - - // convert relative number strings (+= or -=) to relative numbers. #7345 - if ( type === "string" && (ret = rrelNum.exec( value )) ) { - value = ( ret[1] + 1 ) * ret[2] + parseFloat( jQuery.css( elem, name ) ); - // Fixes bug #9237 - type = "number"; - } - - // Make sure that null and NaN values aren't set. See: #7116 - if ( value == null || value !== value ) { - return; - } - - // If a number was passed in, add 'px' to the (except for certain CSS properties) - if ( type === "number" && !jQuery.cssNumber[ origName ] ) { - value += "px"; - } - - // Fixes #8908, it can be done more correctly by specifing setters in cssHooks, - // but it would mean to define eight (for every problematic property) identical functions - if ( !support.clearCloneStyle && value === "" && name.indexOf("background") === 0 ) { - style[ name ] = "inherit"; - } - - // If a hook was provided, use that value, otherwise just set the specified value - if ( !hooks || !("set" in hooks) || (value = hooks.set( elem, value, extra )) !== undefined ) { - - // Support: IE - // Swallow errors from 'invalid' CSS values (#5509) - try { - style[ name ] = value; - } catch(e) {} - } - - } else { - // If a hook was provided get the non-computed value from there - if ( hooks && "get" in hooks && (ret = hooks.get( elem, false, extra )) !== undefined ) { - return ret; - } - - // Otherwise just get the value from the style object - return style[ name ]; - } - }, - - css: function( elem, name, extra, styles ) { - var num, val, hooks, - origName = jQuery.camelCase( name ); - - // Make sure that we're working with the right name - name = jQuery.cssProps[ origName ] || ( jQuery.cssProps[ origName ] = vendorPropName( elem.style, origName ) ); - - // gets hook for the prefixed version - // followed by the unprefixed version - hooks = jQuery.cssHooks[ name ] || jQuery.cssHooks[ origName ]; - - // If a hook was provided get the computed value from there - if ( hooks && "get" in hooks ) { - val = hooks.get( elem, true, extra ); - } - - // Otherwise, if a way to get the computed value exists, use that - if ( val === undefined ) { - val = curCSS( elem, name, styles ); - } - - //convert "normal" to computed value - if ( val === "normal" && name in cssNormalTransform ) { - val = cssNormalTransform[ name ]; - } - - // Return, converting to number if forced or a qualifier was provided and val looks numeric - if ( extra === "" || extra ) { - num = parseFloat( val ); - return extra === true || jQuery.isNumeric( num ) ? num || 0 : val; - } - return val; - } -}); - -jQuery.each([ "height", "width" ], function( i, name ) { - jQuery.cssHooks[ name ] = { - get: function( elem, computed, extra ) { - if ( computed ) { - // certain elements can have dimension info if we invisibly show them - // however, it must have a current display style that would benefit from this - return rdisplayswap.test( jQuery.css( elem, "display" ) ) && elem.offsetWidth === 0 ? - jQuery.swap( elem, cssShow, function() { - return getWidthOrHeight( elem, name, extra ); - }) : - getWidthOrHeight( elem, name, extra ); - } - }, - - set: function( elem, value, extra ) { - var styles = extra && getStyles( elem ); - return setPositiveNumber( elem, value, extra ? - augmentWidthOrHeight( - elem, - name, - extra, - support.boxSizing && jQuery.css( elem, "boxSizing", false, styles ) === "border-box", - styles - ) : 0 - ); - } - }; -}); - -if ( !support.opacity ) { - jQuery.cssHooks.opacity = { - get: function( elem, computed ) { - // IE uses filters for opacity - return ropacity.test( (computed && elem.currentStyle ? elem.currentStyle.filter : elem.style.filter) || "" ) ? - ( 0.01 * parseFloat( RegExp.$1 ) ) + "" : - computed ? "1" : ""; - }, - - set: function( elem, value ) { - var style = elem.style, - currentStyle = elem.currentStyle, - opacity = jQuery.isNumeric( value ) ? "alpha(opacity=" + value * 100 + ")" : "", - filter = currentStyle && currentStyle.filter || style.filter || ""; - - // IE has trouble with opacity if it does not have layout - // Force it by setting the zoom level - style.zoom = 1; - - // if setting opacity to 1, and no other filters exist - attempt to remove filter attribute #6652 - // if value === "", then remove inline opacity #12685 - if ( ( value >= 1 || value === "" ) && - jQuery.trim( filter.replace( ralpha, "" ) ) === "" && - style.removeAttribute ) { - - // Setting style.filter to null, "" & " " still leave "filter:" in the cssText - // if "filter:" is present at all, clearType is disabled, we want to avoid this - // style.removeAttribute is IE Only, but so apparently is this code path... - style.removeAttribute( "filter" ); - - // if there is no filter style applied in a css rule or unset inline opacity, we are done - if ( value === "" || currentStyle && !currentStyle.filter ) { - return; - } - } - - // otherwise, set new filter values - style.filter = ralpha.test( filter ) ? - filter.replace( ralpha, opacity ) : - filter + " " + opacity; - } - }; -} - -jQuery.cssHooks.marginRight = addGetHookIf( support.reliableMarginRight, - function( elem, computed ) { - if ( computed ) { - // WebKit Bug 13343 - getComputedStyle returns wrong value for margin-right - // Work around by temporarily setting element display to inline-block - return jQuery.swap( elem, { "display": "inline-block" }, - curCSS, [ elem, "marginRight" ] ); - } - } -); - -// These hooks are used by animate to expand properties -jQuery.each({ - margin: "", - padding: "", - border: "Width" -}, function( prefix, suffix ) { - jQuery.cssHooks[ prefix + suffix ] = { - expand: function( value ) { - var i = 0, - expanded = {}, - - // assumes a single number if not a string - parts = typeof value === "string" ? value.split(" ") : [ value ]; - - for ( ; i < 4; i++ ) { - expanded[ prefix + cssExpand[ i ] + suffix ] = - parts[ i ] || parts[ i - 2 ] || parts[ 0 ]; - } - - return expanded; - } - }; - - if ( !rmargin.test( prefix ) ) { - jQuery.cssHooks[ prefix + suffix ].set = setPositiveNumber; - } -}); - -jQuery.fn.extend({ - css: function( name, value ) { - return access( this, function( elem, name, value ) { - var styles, len, - map = {}, - i = 0; - - if ( jQuery.isArray( name ) ) { - styles = getStyles( elem ); - len = name.length; - - for ( ; i < len; i++ ) { - map[ name[ i ] ] = jQuery.css( elem, name[ i ], false, styles ); - } - - return map; - } - - return value !== undefined ? - jQuery.style( elem, name, value ) : - jQuery.css( elem, name ); - }, name, value, arguments.length > 1 ); - }, - show: function() { - return showHide( this, true ); - }, - hide: function() { - return showHide( this ); - }, - toggle: function( state ) { - if ( typeof state === "boolean" ) { - return state ? this.show() : this.hide(); - } - - return this.each(function() { - if ( isHidden( this ) ) { - jQuery( this ).show(); - } else { - jQuery( this ).hide(); - } - }); - } -}); - - -function Tween( elem, options, prop, end, easing ) { - return new Tween.prototype.init( elem, options, prop, end, easing ); -} -jQuery.Tween = Tween; - -Tween.prototype = { - constructor: Tween, - init: function( elem, options, prop, end, easing, unit ) { - this.elem = elem; - this.prop = prop; - this.easing = easing || "swing"; - this.options = options; - this.start = this.now = this.cur(); - this.end = end; - this.unit = unit || ( jQuery.cssNumber[ prop ] ? "" : "px" ); - }, - cur: function() { - var hooks = Tween.propHooks[ this.prop ]; - - return hooks && hooks.get ? - hooks.get( this ) : - Tween.propHooks._default.get( this ); - }, - run: function( percent ) { - var eased, - hooks = Tween.propHooks[ this.prop ]; - - if ( this.options.duration ) { - this.pos = eased = jQuery.easing[ this.easing ]( - percent, this.options.duration * percent, 0, 1, this.options.duration - ); - } else { - this.pos = eased = percent; - } - this.now = ( this.end - this.start ) * eased + this.start; - - if ( this.options.step ) { - this.options.step.call( this.elem, this.now, this ); - } - - if ( hooks && hooks.set ) { - hooks.set( this ); - } else { - Tween.propHooks._default.set( this ); - } - return this; - } -}; - -Tween.prototype.init.prototype = Tween.prototype; - -Tween.propHooks = { - _default: { - get: function( tween ) { - var result; - - if ( tween.elem[ tween.prop ] != null && - (!tween.elem.style || tween.elem.style[ tween.prop ] == null) ) { - return tween.elem[ tween.prop ]; - } - - // passing an empty string as a 3rd parameter to .css will automatically - // attempt a parseFloat and fallback to a string if the parse fails - // so, simple values such as "10px" are parsed to Float. - // complex values such as "rotate(1rad)" are returned as is. - result = jQuery.css( tween.elem, tween.prop, "" ); - // Empty strings, null, undefined and "auto" are converted to 0. - return !result || result === "auto" ? 0 : result; - }, - set: function( tween ) { - // use step hook for back compat - use cssHook if its there - use .style if its - // available and use plain properties where available - if ( jQuery.fx.step[ tween.prop ] ) { - jQuery.fx.step[ tween.prop ]( tween ); - } else if ( tween.elem.style && ( tween.elem.style[ jQuery.cssProps[ tween.prop ] ] != null || jQuery.cssHooks[ tween.prop ] ) ) { - jQuery.style( tween.elem, tween.prop, tween.now + tween.unit ); - } else { - tween.elem[ tween.prop ] = tween.now; - } - } - } -}; - -// Support: IE <=9 -// Panic based approach to setting things on disconnected nodes - -Tween.propHooks.scrollTop = Tween.propHooks.scrollLeft = { - set: function( tween ) { - if ( tween.elem.nodeType && tween.elem.parentNode ) { - tween.elem[ tween.prop ] = tween.now; - } - } -}; - -jQuery.easing = { - linear: function( p ) { - return p; - }, - swing: function( p ) { - return 0.5 - Math.cos( p * Math.PI ) / 2; - } -}; - -jQuery.fx = Tween.prototype.init; - -// Back Compat <1.8 extension point -jQuery.fx.step = {}; - - - - -var - fxNow, timerId, - rfxtypes = /^(?:toggle|show|hide)$/, - rfxnum = new RegExp( "^(?:([+-])=|)(" + pnum + ")([a-z%]*)$", "i" ), - rrun = /queueHooks$/, - animationPrefilters = [ defaultPrefilter ], - tweeners = { - "*": [ function( prop, value ) { - var tween = this.createTween( prop, value ), - target = tween.cur(), - parts = rfxnum.exec( value ), - unit = parts && parts[ 3 ] || ( jQuery.cssNumber[ prop ] ? "" : "px" ), - - // Starting value computation is required for potential unit mismatches - start = ( jQuery.cssNumber[ prop ] || unit !== "px" && +target ) && - rfxnum.exec( jQuery.css( tween.elem, prop ) ), - scale = 1, - maxIterations = 20; - - if ( start && start[ 3 ] !== unit ) { - // Trust units reported by jQuery.css - unit = unit || start[ 3 ]; - - // Make sure we update the tween properties later on - parts = parts || []; - - // Iteratively approximate from a nonzero starting point - start = +target || 1; - - do { - // If previous iteration zeroed out, double until we get *something* - // Use a string for doubling factor so we don't accidentally see scale as unchanged below - scale = scale || ".5"; - - // Adjust and apply - start = start / scale; - jQuery.style( tween.elem, prop, start + unit ); - - // Update scale, tolerating zero or NaN from tween.cur() - // And breaking the loop if scale is unchanged or perfect, or if we've just had enough - } while ( scale !== (scale = tween.cur() / target) && scale !== 1 && --maxIterations ); - } - - // Update tween properties - if ( parts ) { - start = tween.start = +start || +target || 0; - tween.unit = unit; - // If a +=/-= token was provided, we're doing a relative animation - tween.end = parts[ 1 ] ? - start + ( parts[ 1 ] + 1 ) * parts[ 2 ] : - +parts[ 2 ]; - } - - return tween; - } ] - }; - -// Animations created synchronously will run synchronously -function createFxNow() { - setTimeout(function() { - fxNow = undefined; - }); - return ( fxNow = jQuery.now() ); -} - -// Generate parameters to create a standard animation -function genFx( type, includeWidth ) { - var which, - attrs = { height: type }, - i = 0; - - // if we include width, step value is 1 to do all cssExpand values, - // if we don't include width, step value is 2 to skip over Left and Right - includeWidth = includeWidth ? 1 : 0; - for ( ; i < 4 ; i += 2 - includeWidth ) { - which = cssExpand[ i ]; - attrs[ "margin" + which ] = attrs[ "padding" + which ] = type; - } - - if ( includeWidth ) { - attrs.opacity = attrs.width = type; - } - - return attrs; -} - -function createTween( value, prop, animation ) { - var tween, - collection = ( tweeners[ prop ] || [] ).concat( tweeners[ "*" ] ), - index = 0, - length = collection.length; - for ( ; index < length; index++ ) { - if ( (tween = collection[ index ].call( animation, prop, value )) ) { - - // we're done with this property - return tween; - } - } -} - -function defaultPrefilter( elem, props, opts ) { - /* jshint validthis: true */ - var prop, value, toggle, tween, hooks, oldfire, display, checkDisplay, - anim = this, - orig = {}, - style = elem.style, - hidden = elem.nodeType && isHidden( elem ), - dataShow = jQuery._data( elem, "fxshow" ); - - // handle queue: false promises - if ( !opts.queue ) { - hooks = jQuery._queueHooks( elem, "fx" ); - if ( hooks.unqueued == null ) { - hooks.unqueued = 0; - oldfire = hooks.empty.fire; - hooks.empty.fire = function() { - if ( !hooks.unqueued ) { - oldfire(); - } - }; - } - hooks.unqueued++; - - anim.always(function() { - // doing this makes sure that the complete handler will be called - // before this completes - anim.always(function() { - hooks.unqueued--; - if ( !jQuery.queue( elem, "fx" ).length ) { - hooks.empty.fire(); - } - }); - }); - } - - // height/width overflow pass - if ( elem.nodeType === 1 && ( "height" in props || "width" in props ) ) { - // Make sure that nothing sneaks out - // Record all 3 overflow attributes because IE does not - // change the overflow attribute when overflowX and - // overflowY are set to the same value - opts.overflow = [ style.overflow, style.overflowX, style.overflowY ]; - - // Set display property to inline-block for height/width - // animations on inline elements that are having width/height animated - display = jQuery.css( elem, "display" ); - - // Test default display if display is currently "none" - checkDisplay = display === "none" ? - jQuery._data( elem, "olddisplay" ) || defaultDisplay( elem.nodeName ) : display; - - if ( checkDisplay === "inline" && jQuery.css( elem, "float" ) === "none" ) { - - // inline-level elements accept inline-block; - // block-level elements need to be inline with layout - if ( !support.inlineBlockNeedsLayout || defaultDisplay( elem.nodeName ) === "inline" ) { - style.display = "inline-block"; - } else { - style.zoom = 1; - } - } - } - - if ( opts.overflow ) { - style.overflow = "hidden"; - if ( !support.shrinkWrapBlocks() ) { - anim.always(function() { - style.overflow = opts.overflow[ 0 ]; - style.overflowX = opts.overflow[ 1 ]; - style.overflowY = opts.overflow[ 2 ]; - }); - } - } - - // show/hide pass - for ( prop in props ) { - value = props[ prop ]; - if ( rfxtypes.exec( value ) ) { - delete props[ prop ]; - toggle = toggle || value === "toggle"; - if ( value === ( hidden ? "hide" : "show" ) ) { - - // If there is dataShow left over from a stopped hide or show and we are going to proceed with show, we should pretend to be hidden - if ( value === "show" && dataShow && dataShow[ prop ] !== undefined ) { - hidden = true; - } else { - continue; - } - } - orig[ prop ] = dataShow && dataShow[ prop ] || jQuery.style( elem, prop ); - - // Any non-fx value stops us from restoring the original display value - } else { - display = undefined; - } - } - - if ( !jQuery.isEmptyObject( orig ) ) { - if ( dataShow ) { - if ( "hidden" in dataShow ) { - hidden = dataShow.hidden; - } - } else { - dataShow = jQuery._data( elem, "fxshow", {} ); - } - - // store state if its toggle - enables .stop().toggle() to "reverse" - if ( toggle ) { - dataShow.hidden = !hidden; - } - if ( hidden ) { - jQuery( elem ).show(); - } else { - anim.done(function() { - jQuery( elem ).hide(); - }); - } - anim.done(function() { - var prop; - jQuery._removeData( elem, "fxshow" ); - for ( prop in orig ) { - jQuery.style( elem, prop, orig[ prop ] ); - } - }); - for ( prop in orig ) { - tween = createTween( hidden ? dataShow[ prop ] : 0, prop, anim ); - - if ( !( prop in dataShow ) ) { - dataShow[ prop ] = tween.start; - if ( hidden ) { - tween.end = tween.start; - tween.start = prop === "width" || prop === "height" ? 1 : 0; - } - } - } - - // If this is a noop like .hide().hide(), restore an overwritten display value - } else if ( (display === "none" ? defaultDisplay( elem.nodeName ) : display) === "inline" ) { - style.display = display; - } -} - -function propFilter( props, specialEasing ) { - var index, name, easing, value, hooks; - - // camelCase, specialEasing and expand cssHook pass - for ( index in props ) { - name = jQuery.camelCase( index ); - easing = specialEasing[ name ]; - value = props[ index ]; - if ( jQuery.isArray( value ) ) { - easing = value[ 1 ]; - value = props[ index ] = value[ 0 ]; - } - - if ( index !== name ) { - props[ name ] = value; - delete props[ index ]; - } - - hooks = jQuery.cssHooks[ name ]; - if ( hooks && "expand" in hooks ) { - value = hooks.expand( value ); - delete props[ name ]; - - // not quite $.extend, this wont overwrite keys already present. - // also - reusing 'index' from above because we have the correct "name" - for ( index in value ) { - if ( !( index in props ) ) { - props[ index ] = value[ index ]; - specialEasing[ index ] = easing; - } - } - } else { - specialEasing[ name ] = easing; - } - } -} - -function Animation( elem, properties, options ) { - var result, - stopped, - index = 0, - length = animationPrefilters.length, - deferred = jQuery.Deferred().always( function() { - // don't match elem in the :animated selector - delete tick.elem; - }), - tick = function() { - if ( stopped ) { - return false; - } - var currentTime = fxNow || createFxNow(), - remaining = Math.max( 0, animation.startTime + animation.duration - currentTime ), - // archaic crash bug won't allow us to use 1 - ( 0.5 || 0 ) (#12497) - temp = remaining / animation.duration || 0, - percent = 1 - temp, - index = 0, - length = animation.tweens.length; - - for ( ; index < length ; index++ ) { - animation.tweens[ index ].run( percent ); - } - - deferred.notifyWith( elem, [ animation, percent, remaining ]); - - if ( percent < 1 && length ) { - return remaining; - } else { - deferred.resolveWith( elem, [ animation ] ); - return false; - } - }, - animation = deferred.promise({ - elem: elem, - props: jQuery.extend( {}, properties ), - opts: jQuery.extend( true, { specialEasing: {} }, options ), - originalProperties: properties, - originalOptions: options, - startTime: fxNow || createFxNow(), - duration: options.duration, - tweens: [], - createTween: function( prop, end ) { - var tween = jQuery.Tween( elem, animation.opts, prop, end, - animation.opts.specialEasing[ prop ] || animation.opts.easing ); - animation.tweens.push( tween ); - return tween; - }, - stop: function( gotoEnd ) { - var index = 0, - // if we are going to the end, we want to run all the tweens - // otherwise we skip this part - length = gotoEnd ? animation.tweens.length : 0; - if ( stopped ) { - return this; - } - stopped = true; - for ( ; index < length ; index++ ) { - animation.tweens[ index ].run( 1 ); - } - - // resolve when we played the last frame - // otherwise, reject - if ( gotoEnd ) { - deferred.resolveWith( elem, [ animation, gotoEnd ] ); - } else { - deferred.rejectWith( elem, [ animation, gotoEnd ] ); - } - return this; - } - }), - props = animation.props; - - propFilter( props, animation.opts.specialEasing ); - - for ( ; index < length ; index++ ) { - result = animationPrefilters[ index ].call( animation, elem, props, animation.opts ); - if ( result ) { - return result; - } - } - - jQuery.map( props, createTween, animation ); - - if ( jQuery.isFunction( animation.opts.start ) ) { - animation.opts.start.call( elem, animation ); - } - - jQuery.fx.timer( - jQuery.extend( tick, { - elem: elem, - anim: animation, - queue: animation.opts.queue - }) - ); - - // attach callbacks from options - return animation.progress( animation.opts.progress ) - .done( animation.opts.done, animation.opts.complete ) - .fail( animation.opts.fail ) - .always( animation.opts.always ); -} - -jQuery.Animation = jQuery.extend( Animation, { - tweener: function( props, callback ) { - if ( jQuery.isFunction( props ) ) { - callback = props; - props = [ "*" ]; - } else { - props = props.split(" "); - } - - var prop, - index = 0, - length = props.length; - - for ( ; index < length ; index++ ) { - prop = props[ index ]; - tweeners[ prop ] = tweeners[ prop ] || []; - tweeners[ prop ].unshift( callback ); - } - }, - - prefilter: function( callback, prepend ) { - if ( prepend ) { - animationPrefilters.unshift( callback ); - } else { - animationPrefilters.push( callback ); - } - } -}); - -jQuery.speed = function( speed, easing, fn ) { - var opt = speed && typeof speed === "object" ? jQuery.extend( {}, speed ) : { - complete: fn || !fn && easing || - jQuery.isFunction( speed ) && speed, - duration: speed, - easing: fn && easing || easing && !jQuery.isFunction( easing ) && easing - }; - - opt.duration = jQuery.fx.off ? 0 : typeof opt.duration === "number" ? opt.duration : - opt.duration in jQuery.fx.speeds ? jQuery.fx.speeds[ opt.duration ] : jQuery.fx.speeds._default; - - // normalize opt.queue - true/undefined/null -> "fx" - if ( opt.queue == null || opt.queue === true ) { - opt.queue = "fx"; - } - - // Queueing - opt.old = opt.complete; - - opt.complete = function() { - if ( jQuery.isFunction( opt.old ) ) { - opt.old.call( this ); - } - - if ( opt.queue ) { - jQuery.dequeue( this, opt.queue ); - } - }; - - return opt; -}; - -jQuery.fn.extend({ - fadeTo: function( speed, to, easing, callback ) { - - // show any hidden elements after setting opacity to 0 - return this.filter( isHidden ).css( "opacity", 0 ).show() - - // animate to the value specified - .end().animate({ opacity: to }, speed, easing, callback ); - }, - animate: function( prop, speed, easing, callback ) { - var empty = jQuery.isEmptyObject( prop ), - optall = jQuery.speed( speed, easing, callback ), - doAnimation = function() { - // Operate on a copy of prop so per-property easing won't be lost - var anim = Animation( this, jQuery.extend( {}, prop ), optall ); - - // Empty animations, or finishing resolves immediately - if ( empty || jQuery._data( this, "finish" ) ) { - anim.stop( true ); - } - }; - doAnimation.finish = doAnimation; - - return empty || optall.queue === false ? - this.each( doAnimation ) : - this.queue( optall.queue, doAnimation ); - }, - stop: function( type, clearQueue, gotoEnd ) { - var stopQueue = function( hooks ) { - var stop = hooks.stop; - delete hooks.stop; - stop( gotoEnd ); - }; - - if ( typeof type !== "string" ) { - gotoEnd = clearQueue; - clearQueue = type; - type = undefined; - } - if ( clearQueue && type !== false ) { - this.queue( type || "fx", [] ); - } - - return this.each(function() { - var dequeue = true, - index = type != null && type + "queueHooks", - timers = jQuery.timers, - data = jQuery._data( this ); - - if ( index ) { - if ( data[ index ] && data[ index ].stop ) { - stopQueue( data[ index ] ); - } - } else { - for ( index in data ) { - if ( data[ index ] && data[ index ].stop && rrun.test( index ) ) { - stopQueue( data[ index ] ); - } - } - } - - for ( index = timers.length; index--; ) { - if ( timers[ index ].elem === this && (type == null || timers[ index ].queue === type) ) { - timers[ index ].anim.stop( gotoEnd ); - dequeue = false; - timers.splice( index, 1 ); - } - } - - // start the next in the queue if the last step wasn't forced - // timers currently will call their complete callbacks, which will dequeue - // but only if they were gotoEnd - if ( dequeue || !gotoEnd ) { - jQuery.dequeue( this, type ); - } - }); - }, - finish: function( type ) { - if ( type !== false ) { - type = type || "fx"; - } - return this.each(function() { - var index, - data = jQuery._data( this ), - queue = data[ type + "queue" ], - hooks = data[ type + "queueHooks" ], - timers = jQuery.timers, - length = queue ? queue.length : 0; - - // enable finishing flag on private data - data.finish = true; - - // empty the queue first - jQuery.queue( this, type, [] ); - - if ( hooks && hooks.stop ) { - hooks.stop.call( this, true ); - } - - // look for any active animations, and finish them - for ( index = timers.length; index--; ) { - if ( timers[ index ].elem === this && timers[ index ].queue === type ) { - timers[ index ].anim.stop( true ); - timers.splice( index, 1 ); - } - } - - // look for any animations in the old queue and finish them - for ( index = 0; index < length; index++ ) { - if ( queue[ index ] && queue[ index ].finish ) { - queue[ index ].finish.call( this ); - } - } - - // turn off finishing flag - delete data.finish; - }); - } -}); - -jQuery.each([ "toggle", "show", "hide" ], function( i, name ) { - var cssFn = jQuery.fn[ name ]; - jQuery.fn[ name ] = function( speed, easing, callback ) { - return speed == null || typeof speed === "boolean" ? - cssFn.apply( this, arguments ) : - this.animate( genFx( name, true ), speed, easing, callback ); - }; -}); - -// Generate shortcuts for custom animations -jQuery.each({ - slideDown: genFx("show"), - slideUp: genFx("hide"), - slideToggle: genFx("toggle"), - fadeIn: { opacity: "show" }, - fadeOut: { opacity: "hide" }, - fadeToggle: { opacity: "toggle" } -}, function( name, props ) { - jQuery.fn[ name ] = function( speed, easing, callback ) { - return this.animate( props, speed, easing, callback ); - }; -}); - -jQuery.timers = []; -jQuery.fx.tick = function() { - var timer, - timers = jQuery.timers, - i = 0; - - fxNow = jQuery.now(); - - for ( ; i < timers.length; i++ ) { - timer = timers[ i ]; - // Checks the timer has not already been removed - if ( !timer() && timers[ i ] === timer ) { - timers.splice( i--, 1 ); - } - } - - if ( !timers.length ) { - jQuery.fx.stop(); - } - fxNow = undefined; -}; - -jQuery.fx.timer = function( timer ) { - jQuery.timers.push( timer ); - if ( timer() ) { - jQuery.fx.start(); - } else { - jQuery.timers.pop(); - } -}; - -jQuery.fx.interval = 13; - -jQuery.fx.start = function() { - if ( !timerId ) { - timerId = setInterval( jQuery.fx.tick, jQuery.fx.interval ); - } -}; - -jQuery.fx.stop = function() { - clearInterval( timerId ); - timerId = null; -}; - -jQuery.fx.speeds = { - slow: 600, - fast: 200, - // Default speed - _default: 400 -}; - - -// Based off of the plugin by Clint Helfers, with permission. -// http://blindsignals.com/index.php/2009/07/jquery-delay/ -jQuery.fn.delay = function( time, type ) { - time = jQuery.fx ? jQuery.fx.speeds[ time ] || time : time; - type = type || "fx"; - - return this.queue( type, function( next, hooks ) { - var timeout = setTimeout( next, time ); - hooks.stop = function() { - clearTimeout( timeout ); - }; - }); -}; - - -(function() { - // Minified: var a,b,c,d,e - var input, div, select, a, opt; - - // Setup - div = document.createElement( "div" ); - div.setAttribute( "className", "t" ); - div.innerHTML = " <link/><table></table><a href='/a'>a</a><input type='checkbox'/>"; - a = div.getElementsByTagName("a")[ 0 ]; - - // First batch of tests. - select = document.createElement("select"); - opt = select.appendChild( document.createElement("option") ); - input = div.getElementsByTagName("input")[ 0 ]; - - a.style.cssText = "top:1px"; - - // Test setAttribute on camelCase class. If it works, we need attrFixes when doing get/setAttribute (ie6/7) - support.getSetAttribute = div.className !== "t"; - - // Get the style information from getAttribute - // (IE uses .cssText instead) - support.style = /top/.test( a.getAttribute("style") ); - - // Make sure that URLs aren't manipulated - // (IE normalizes it by default) - support.hrefNormalized = a.getAttribute("href") === "/a"; - - // Check the default checkbox/radio value ("" on WebKit; "on" elsewhere) - support.checkOn = !!input.value; - - // Make sure that a selected-by-default option has a working selected property. - // (WebKit defaults to false instead of true, IE too, if it's in an optgroup) - support.optSelected = opt.selected; - - // Tests for enctype support on a form (#6743) - support.enctype = !!document.createElement("form").enctype; - - // Make sure that the options inside disabled selects aren't marked as disabled - // (WebKit marks them as disabled) - select.disabled = true; - support.optDisabled = !opt.disabled; - - // Support: IE8 only - // Check if we can trust getAttribute("value") - input = document.createElement( "input" ); - input.setAttribute( "value", "" ); - support.input = input.getAttribute( "value" ) === ""; - - // Check if an input maintains its value after becoming a radio - input.value = "t"; - input.setAttribute( "type", "radio" ); - support.radioValue = input.value === "t"; -})(); - - -var rreturn = /\r/g; - -jQuery.fn.extend({ - val: function( value ) { - var hooks, ret, isFunction, - elem = this[0]; - - if ( !arguments.length ) { - if ( elem ) { - hooks = jQuery.valHooks[ elem.type ] || jQuery.valHooks[ elem.nodeName.toLowerCase() ]; - - if ( hooks && "get" in hooks && (ret = hooks.get( elem, "value" )) !== undefined ) { - return ret; - } - - ret = elem.value; - - return typeof ret === "string" ? - // handle most common string cases - ret.replace(rreturn, "") : - // handle cases where value is null/undef or number - ret == null ? "" : ret; - } - - return; - } - - isFunction = jQuery.isFunction( value ); - - return this.each(function( i ) { - var val; - - if ( this.nodeType !== 1 ) { - return; - } - - if ( isFunction ) { - val = value.call( this, i, jQuery( this ).val() ); - } else { - val = value; - } - - // Treat null/undefined as ""; convert numbers to string - if ( val == null ) { - val = ""; - } else if ( typeof val === "number" ) { - val += ""; - } else if ( jQuery.isArray( val ) ) { - val = jQuery.map( val, function( value ) { - return value == null ? "" : value + ""; - }); - } - - hooks = jQuery.valHooks[ this.type ] || jQuery.valHooks[ this.nodeName.toLowerCase() ]; - - // If set returns undefined, fall back to normal setting - if ( !hooks || !("set" in hooks) || hooks.set( this, val, "value" ) === undefined ) { - this.value = val; - } - }); - } -}); - -jQuery.extend({ - valHooks: { - option: { - get: function( elem ) { - var val = jQuery.find.attr( elem, "value" ); - return val != null ? - val : - // Support: IE10-11+ - // option.text throws exceptions (#14686, #14858) - jQuery.trim( jQuery.text( elem ) ); - } - }, - select: { - get: function( elem ) { - var value, option, - options = elem.options, - index = elem.selectedIndex, - one = elem.type === "select-one" || index < 0, - values = one ? null : [], - max = one ? index + 1 : options.length, - i = index < 0 ? - max : - one ? index : 0; - - // Loop through all the selected options - for ( ; i < max; i++ ) { - option = options[ i ]; - - // oldIE doesn't update selected after form reset (#2551) - if ( ( option.selected || i === index ) && - // Don't return options that are disabled or in a disabled optgroup - ( support.optDisabled ? !option.disabled : option.getAttribute("disabled") === null ) && - ( !option.parentNode.disabled || !jQuery.nodeName( option.parentNode, "optgroup" ) ) ) { - - // Get the specific value for the option - value = jQuery( option ).val(); - - // We don't need an array for one selects - if ( one ) { - return value; - } - - // Multi-Selects return an array - values.push( value ); - } - } - - return values; - }, - - set: function( elem, value ) { - var optionSet, option, - options = elem.options, - values = jQuery.makeArray( value ), - i = options.length; - - while ( i-- ) { - option = options[ i ]; - - if ( jQuery.inArray( jQuery.valHooks.option.get( option ), values ) >= 0 ) { - - // Support: IE6 - // When new option element is added to select box we need to - // force reflow of newly added node in order to workaround delay - // of initialization properties - try { - option.selected = optionSet = true; - - } catch ( _ ) { - - // Will be executed only in IE6 - option.scrollHeight; - } - - } else { - option.selected = false; - } - } - - // Force browsers to behave consistently when non-matching value is set - if ( !optionSet ) { - elem.selectedIndex = -1; - } - - return options; - } - } - } -}); - -// Radios and checkboxes getter/setter -jQuery.each([ "radio", "checkbox" ], function() { - jQuery.valHooks[ this ] = { - set: function( elem, value ) { - if ( jQuery.isArray( value ) ) { - return ( elem.checked = jQuery.inArray( jQuery(elem).val(), value ) >= 0 ); - } - } - }; - if ( !support.checkOn ) { - jQuery.valHooks[ this ].get = function( elem ) { - // Support: Webkit - // "" is returned instead of "on" if a value isn't specified - return elem.getAttribute("value") === null ? "on" : elem.value; - }; - } -}); - - - - -var nodeHook, boolHook, - attrHandle = jQuery.expr.attrHandle, - ruseDefault = /^(?:checked|selected)$/i, - getSetAttribute = support.getSetAttribute, - getSetInput = support.input; - -jQuery.fn.extend({ - attr: function( name, value ) { - return access( this, jQuery.attr, name, value, arguments.length > 1 ); - }, - - removeAttr: function( name ) { - return this.each(function() { - jQuery.removeAttr( this, name ); - }); - } -}); - -jQuery.extend({ - attr: function( elem, name, value ) { - var hooks, ret, - nType = elem.nodeType; - - // don't get/set attributes on text, comment and attribute nodes - if ( !elem || nType === 3 || nType === 8 || nType === 2 ) { - return; - } - - // Fallback to prop when attributes are not supported - if ( typeof elem.getAttribute === strundefined ) { - return jQuery.prop( elem, name, value ); - } - - // All attributes are lowercase - // Grab necessary hook if one is defined - if ( nType !== 1 || !jQuery.isXMLDoc( elem ) ) { - name = name.toLowerCase(); - hooks = jQuery.attrHooks[ name ] || - ( jQuery.expr.match.bool.test( name ) ? boolHook : nodeHook ); - } - - if ( value !== undefined ) { - - if ( value === null ) { - jQuery.removeAttr( elem, name ); - - } else if ( hooks && "set" in hooks && (ret = hooks.set( elem, value, name )) !== undefined ) { - return ret; - - } else { - elem.setAttribute( name, value + "" ); - return value; - } - - } else if ( hooks && "get" in hooks && (ret = hooks.get( elem, name )) !== null ) { - return ret; - - } else { - ret = jQuery.find.attr( elem, name ); - - // Non-existent attributes return null, we normalize to undefined - return ret == null ? - undefined : - ret; - } - }, - - removeAttr: function( elem, value ) { - var name, propName, - i = 0, - attrNames = value && value.match( rnotwhite ); - - if ( attrNames && elem.nodeType === 1 ) { - while ( (name = attrNames[i++]) ) { - propName = jQuery.propFix[ name ] || name; - - // Boolean attributes get special treatment (#10870) - if ( jQuery.expr.match.bool.test( name ) ) { - // Set corresponding property to false - if ( getSetInput && getSetAttribute || !ruseDefault.test( name ) ) { - elem[ propName ] = false; - // Support: IE<9 - // Also clear defaultChecked/defaultSelected (if appropriate) - } else { - elem[ jQuery.camelCase( "default-" + name ) ] = - elem[ propName ] = false; - } - - // See #9699 for explanation of this approach (setting first, then removal) - } else { - jQuery.attr( elem, name, "" ); - } - - elem.removeAttribute( getSetAttribute ? name : propName ); - } - } - }, - - attrHooks: { - type: { - set: function( elem, value ) { - if ( !support.radioValue && value === "radio" && jQuery.nodeName(elem, "input") ) { - // Setting the type on a radio button after the value resets the value in IE6-9 - // Reset value to default in case type is set after value during creation - var val = elem.value; - elem.setAttribute( "type", value ); - if ( val ) { - elem.value = val; - } - return value; - } - } - } - } -}); - -// Hook for boolean attributes -boolHook = { - set: function( elem, value, name ) { - if ( value === false ) { - // Remove boolean attributes when set to false - jQuery.removeAttr( elem, name ); - } else if ( getSetInput && getSetAttribute || !ruseDefault.test( name ) ) { - // IE<8 needs the *property* name - elem.setAttribute( !getSetAttribute && jQuery.propFix[ name ] || name, name ); - - // Use defaultChecked and defaultSelected for oldIE - } else { - elem[ jQuery.camelCase( "default-" + name ) ] = elem[ name ] = true; - } - - return name; - } -}; - -// Retrieve booleans specially -jQuery.each( jQuery.expr.match.bool.source.match( /\w+/g ), function( i, name ) { - - var getter = attrHandle[ name ] || jQuery.find.attr; - - attrHandle[ name ] = getSetInput && getSetAttribute || !ruseDefault.test( name ) ? - function( elem, name, isXML ) { - var ret, handle; - if ( !isXML ) { - // Avoid an infinite loop by temporarily removing this function from the getter - handle = attrHandle[ name ]; - attrHandle[ name ] = ret; - ret = getter( elem, name, isXML ) != null ? - name.toLowerCase() : - null; - attrHandle[ name ] = handle; - } - return ret; - } : - function( elem, name, isXML ) { - if ( !isXML ) { - return elem[ jQuery.camelCase( "default-" + name ) ] ? - name.toLowerCase() : - null; - } - }; -}); - -// fix oldIE attroperties -if ( !getSetInput || !getSetAttribute ) { - jQuery.attrHooks.value = { - set: function( elem, value, name ) { - if ( jQuery.nodeName( elem, "input" ) ) { - // Does not return so that setAttribute is also used - elem.defaultValue = value; - } else { - // Use nodeHook if defined (#1954); otherwise setAttribute is fine - return nodeHook && nodeHook.set( elem, value, name ); - } - } - }; -} - -// IE6/7 do not support getting/setting some attributes with get/setAttribute -if ( !getSetAttribute ) { - - // Use this for any attribute in IE6/7 - // This fixes almost every IE6/7 issue - nodeHook = { - set: function( elem, value, name ) { - // Set the existing or create a new attribute node - var ret = elem.getAttributeNode( name ); - if ( !ret ) { - elem.setAttributeNode( - (ret = elem.ownerDocument.createAttribute( name )) - ); - } - - ret.value = value += ""; - - // Break association with cloned elements by also using setAttribute (#9646) - if ( name === "value" || value === elem.getAttribute( name ) ) { - return value; - } - } - }; - - // Some attributes are constructed with empty-string values when not defined - attrHandle.id = attrHandle.name = attrHandle.coords = - function( elem, name, isXML ) { - var ret; - if ( !isXML ) { - return (ret = elem.getAttributeNode( name )) && ret.value !== "" ? - ret.value : - null; - } - }; - - // Fixing value retrieval on a button requires this module - jQuery.valHooks.button = { - get: function( elem, name ) { - var ret = elem.getAttributeNode( name ); - if ( ret && ret.specified ) { - return ret.value; - } - }, - set: nodeHook.set - }; - - // Set contenteditable to false on removals(#10429) - // Setting to empty string throws an error as an invalid value - jQuery.attrHooks.contenteditable = { - set: function( elem, value, name ) { - nodeHook.set( elem, value === "" ? false : value, name ); - } - }; - - // Set width and height to auto instead of 0 on empty string( Bug #8150 ) - // This is for removals - jQuery.each([ "width", "height" ], function( i, name ) { - jQuery.attrHooks[ name ] = { - set: function( elem, value ) { - if ( value === "" ) { - elem.setAttribute( name, "auto" ); - return value; - } - } - }; - }); -} - -if ( !support.style ) { - jQuery.attrHooks.style = { - get: function( elem ) { - // Return undefined in the case of empty string - // Note: IE uppercases css property names, but if we were to .toLowerCase() - // .cssText, that would destroy case senstitivity in URL's, like in "background" - return elem.style.cssText || undefined; - }, - set: function( elem, value ) { - return ( elem.style.cssText = value + "" ); - } - }; -} - - - - -var rfocusable = /^(?:input|select|textarea|button|object)$/i, - rclickable = /^(?:a|area)$/i; - -jQuery.fn.extend({ - prop: function( name, value ) { - return access( this, jQuery.prop, name, value, arguments.length > 1 ); - }, - - removeProp: function( name ) { - name = jQuery.propFix[ name ] || name; - return this.each(function() { - // try/catch handles cases where IE balks (such as removing a property on window) - try { - this[ name ] = undefined; - delete this[ name ]; - } catch( e ) {} - }); - } -}); - -jQuery.extend({ - propFix: { - "for": "htmlFor", - "class": "className" - }, - - prop: function( elem, name, value ) { - var ret, hooks, notxml, - nType = elem.nodeType; - - // don't get/set properties on text, comment and attribute nodes - if ( !elem || nType === 3 || nType === 8 || nType === 2 ) { - return; - } - - notxml = nType !== 1 || !jQuery.isXMLDoc( elem ); - - if ( notxml ) { - // Fix name and attach hooks - name = jQuery.propFix[ name ] || name; - hooks = jQuery.propHooks[ name ]; - } - - if ( value !== undefined ) { - return hooks && "set" in hooks && (ret = hooks.set( elem, value, name )) !== undefined ? - ret : - ( elem[ name ] = value ); - - } else { - return hooks && "get" in hooks && (ret = hooks.get( elem, name )) !== null ? - ret : - elem[ name ]; - } - }, - - propHooks: { - tabIndex: { - get: function( elem ) { - // elem.tabIndex doesn't always return the correct value when it hasn't been explicitly set - // http://fluidproject.org/blog/2008/01/09/getting-setting-and-removing-tabindex-values-with-javascript/ - // Use proper attribute retrieval(#12072) - var tabindex = jQuery.find.attr( elem, "tabindex" ); - - return tabindex ? - parseInt( tabindex, 10 ) : - rfocusable.test( elem.nodeName ) || rclickable.test( elem.nodeName ) && elem.href ? - 0 : - -1; - } - } - } -}); - -// Some attributes require a special call on IE -// http://msdn.microsoft.com/en-us/library/ms536429%28VS.85%29.aspx -if ( !support.hrefNormalized ) { - // href/src property should get the full normalized URL (#10299/#12915) - jQuery.each([ "href", "src" ], function( i, name ) { - jQuery.propHooks[ name ] = { - get: function( elem ) { - return elem.getAttribute( name, 4 ); - } - }; - }); -} - -// Support: Safari, IE9+ -// mis-reports the default selected property of an option -// Accessing the parent's selectedIndex property fixes it -if ( !support.optSelected ) { - jQuery.propHooks.selected = { - get: function( elem ) { - var parent = elem.parentNode; - - if ( parent ) { - parent.selectedIndex; - - // Make sure that it also works with optgroups, see #5701 - if ( parent.parentNode ) { - parent.parentNode.selectedIndex; - } - } - return null; - } - }; -} - -jQuery.each([ - "tabIndex", - "readOnly", - "maxLength", - "cellSpacing", - "cellPadding", - "rowSpan", - "colSpan", - "useMap", - "frameBorder", - "contentEditable" -], function() { - jQuery.propFix[ this.toLowerCase() ] = this; -}); - -// IE6/7 call enctype encoding -if ( !support.enctype ) { - jQuery.propFix.enctype = "encoding"; -} - - - - -var rclass = /[\t\r\n\f]/g; - -jQuery.fn.extend({ - addClass: function( value ) { - var classes, elem, cur, clazz, j, finalValue, - i = 0, - len = this.length, - proceed = typeof value === "string" && value; - - if ( jQuery.isFunction( value ) ) { - return this.each(function( j ) { - jQuery( this ).addClass( value.call( this, j, this.className ) ); - }); - } - - if ( proceed ) { - // The disjunction here is for better compressibility (see removeClass) - classes = ( value || "" ).match( rnotwhite ) || []; - - for ( ; i < len; i++ ) { - elem = this[ i ]; - cur = elem.nodeType === 1 && ( elem.className ? - ( " " + elem.className + " " ).replace( rclass, " " ) : - " " - ); - - if ( cur ) { - j = 0; - while ( (clazz = classes[j++]) ) { - if ( cur.indexOf( " " + clazz + " " ) < 0 ) { - cur += clazz + " "; - } - } - - // only assign if different to avoid unneeded rendering. - finalValue = jQuery.trim( cur ); - if ( elem.className !== finalValue ) { - elem.className = finalValue; - } - } - } - } - - return this; - }, - - removeClass: function( value ) { - var classes, elem, cur, clazz, j, finalValue, - i = 0, - len = this.length, - proceed = arguments.length === 0 || typeof value === "string" && value; - - if ( jQuery.isFunction( value ) ) { - return this.each(function( j ) { - jQuery( this ).removeClass( value.call( this, j, this.className ) ); - }); - } - if ( proceed ) { - classes = ( value || "" ).match( rnotwhite ) || []; - - for ( ; i < len; i++ ) { - elem = this[ i ]; - // This expression is here for better compressibility (see addClass) - cur = elem.nodeType === 1 && ( elem.className ? - ( " " + elem.className + " " ).replace( rclass, " " ) : - "" - ); - - if ( cur ) { - j = 0; - while ( (clazz = classes[j++]) ) { - // Remove *all* instances - while ( cur.indexOf( " " + clazz + " " ) >= 0 ) { - cur = cur.replace( " " + clazz + " ", " " ); - } - } - - // only assign if different to avoid unneeded rendering. - finalValue = value ? jQuery.trim( cur ) : ""; - if ( elem.className !== finalValue ) { - elem.className = finalValue; - } - } - } - } - - return this; - }, - - toggleClass: function( value, stateVal ) { - var type = typeof value; - - if ( typeof stateVal === "boolean" && type === "string" ) { - return stateVal ? this.addClass( value ) : this.removeClass( value ); - } - - if ( jQuery.isFunction( value ) ) { - return this.each(function( i ) { - jQuery( this ).toggleClass( value.call(this, i, this.className, stateVal), stateVal ); - }); - } - - return this.each(function() { - if ( type === "string" ) { - // toggle individual class names - var className, - i = 0, - self = jQuery( this ), - classNames = value.match( rnotwhite ) || []; - - while ( (className = classNames[ i++ ]) ) { - // check each className given, space separated list - if ( self.hasClass( className ) ) { - self.removeClass( className ); - } else { - self.addClass( className ); - } - } - - // Toggle whole class name - } else if ( type === strundefined || type === "boolean" ) { - if ( this.className ) { - // store className if set - jQuery._data( this, "__className__", this.className ); - } - - // If the element has a class name or if we're passed "false", - // then remove the whole classname (if there was one, the above saved it). - // Otherwise bring back whatever was previously saved (if anything), - // falling back to the empty string if nothing was stored. - this.className = this.className || value === false ? "" : jQuery._data( this, "__className__" ) || ""; - } - }); - }, - - hasClass: function( selector ) { - var className = " " + selector + " ", - i = 0, - l = this.length; - for ( ; i < l; i++ ) { - if ( this[i].nodeType === 1 && (" " + this[i].className + " ").replace(rclass, " ").indexOf( className ) >= 0 ) { - return true; - } - } - - return false; - } -}); - - - - -// Return jQuery for attributes-only inclusion - - -jQuery.each( ("blur focus focusin focusout load resize scroll unload click dblclick " + - "mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave " + - "change select submit keydown keypress keyup error contextmenu").split(" "), function( i, name ) { - - // Handle event binding - jQuery.fn[ name ] = function( data, fn ) { - return arguments.length > 0 ? - this.on( name, null, data, fn ) : - this.trigger( name ); - }; -}); - -jQuery.fn.extend({ - hover: function( fnOver, fnOut ) { - return this.mouseenter( fnOver ).mouseleave( fnOut || fnOver ); - }, - - bind: function( types, data, fn ) { - return this.on( types, null, data, fn ); - }, - unbind: function( types, fn ) { - return this.off( types, null, fn ); - }, - - delegate: function( selector, types, data, fn ) { - return this.on( types, selector, data, fn ); - }, - undelegate: function( selector, types, fn ) { - // ( namespace ) or ( selector, types [, fn] ) - return arguments.length === 1 ? this.off( selector, "**" ) : this.off( types, selector || "**", fn ); - } -}); - - -var nonce = jQuery.now(); - -var rquery = (/\?/); - - - -var rvalidtokens = /(,)|(\[|{)|(}|])|"(?:[^"\\\r\n]|\\["\\\/bfnrt]|\\u[\da-fA-F]{4})*"\s*:?|true|false|null|-?(?!0\d)\d+(?:\.\d+|)(?:[eE][+-]?\d+|)/g; - -jQuery.parseJSON = function( data ) { - // Attempt to parse using the native JSON parser first - if ( window.JSON && window.JSON.parse ) { - // Support: Android 2.3 - // Workaround failure to string-cast null input - return window.JSON.parse( data + "" ); - } - - var requireNonComma, - depth = null, - str = jQuery.trim( data + "" ); - - // Guard against invalid (and possibly dangerous) input by ensuring that nothing remains - // after removing valid tokens - return str && !jQuery.trim( str.replace( rvalidtokens, function( token, comma, open, close ) { - - // Force termination if we see a misplaced comma - if ( requireNonComma && comma ) { - depth = 0; - } - - // Perform no more replacements after returning to outermost depth - if ( depth === 0 ) { - return token; - } - - // Commas must not follow "[", "{", or "," - requireNonComma = open || comma; - - // Determine new depth - // array/object open ("[" or "{"): depth += true - false (increment) - // array/object close ("]" or "}"): depth += false - true (decrement) - // other cases ("," or primitive): depth += true - true (numeric cast) - depth += !close - !open; - - // Remove this token - return ""; - }) ) ? - ( Function( "return " + str ) )() : - jQuery.error( "Invalid JSON: " + data ); -}; - - -// Cross-browser xml parsing -jQuery.parseXML = function( data ) { - var xml, tmp; - if ( !data || typeof data !== "string" ) { - return null; - } - try { - if ( window.DOMParser ) { // Standard - tmp = new DOMParser(); - xml = tmp.parseFromString( data, "text/xml" ); - } else { // IE - xml = new ActiveXObject( "Microsoft.XMLDOM" ); - xml.async = "false"; - xml.loadXML( data ); - } - } catch( e ) { - xml = undefined; - } - if ( !xml || !xml.documentElement || xml.getElementsByTagName( "parsererror" ).length ) { - jQuery.error( "Invalid XML: " + data ); - } - return xml; -}; - - -var - // Document location - ajaxLocParts, - ajaxLocation, - - rhash = /#.*$/, - rts = /([?&])_=[^&]*/, - rheaders = /^(.*?):[ \t]*([^\r\n]*)\r?$/mg, // IE leaves an \r character at EOL - // #7653, #8125, #8152: local protocol detection - rlocalProtocol = /^(?:about|app|app-storage|.+-extension|file|res|widget):$/, - rnoContent = /^(?:GET|HEAD)$/, - rprotocol = /^\/\//, - rurl = /^([\w.+-]+:)(?:\/\/(?:[^\/?#]*@|)([^\/?#:]*)(?::(\d+)|)|)/, - - /* Prefilters - * 1) They are useful to introduce custom dataTypes (see ajax/jsonp.js for an example) - * 2) These are called: - * - BEFORE asking for a transport - * - AFTER param serialization (s.data is a string if s.processData is true) - * 3) key is the dataType - * 4) the catchall symbol "*" can be used - * 5) execution will start with transport dataType and THEN continue down to "*" if needed - */ - prefilters = {}, - - /* Transports bindings - * 1) key is the dataType - * 2) the catchall symbol "*" can be used - * 3) selection will start with transport dataType and THEN go to "*" if needed - */ - transports = {}, - - // Avoid comment-prolog char sequence (#10098); must appease lint and evade compression - allTypes = "*/".concat("*"); - -// #8138, IE may throw an exception when accessing -// a field from window.location if document.domain has been set -try { - ajaxLocation = location.href; -} catch( e ) { - // Use the href attribute of an A element - // since IE will modify it given document.location - ajaxLocation = document.createElement( "a" ); - ajaxLocation.href = ""; - ajaxLocation = ajaxLocation.href; -} - -// Segment location into parts -ajaxLocParts = rurl.exec( ajaxLocation.toLowerCase() ) || []; - -// Base "constructor" for jQuery.ajaxPrefilter and jQuery.ajaxTransport -function addToPrefiltersOrTransports( structure ) { - - // dataTypeExpression is optional and defaults to "*" - return function( dataTypeExpression, func ) { - - if ( typeof dataTypeExpression !== "string" ) { - func = dataTypeExpression; - dataTypeExpression = "*"; - } - - var dataType, - i = 0, - dataTypes = dataTypeExpression.toLowerCase().match( rnotwhite ) || []; - - if ( jQuery.isFunction( func ) ) { - // For each dataType in the dataTypeExpression - while ( (dataType = dataTypes[i++]) ) { - // Prepend if requested - if ( dataType.charAt( 0 ) === "+" ) { - dataType = dataType.slice( 1 ) || "*"; - (structure[ dataType ] = structure[ dataType ] || []).unshift( func ); - - // Otherwise append - } else { - (structure[ dataType ] = structure[ dataType ] || []).push( func ); - } - } - } - }; -} - -// Base inspection function for prefilters and transports -function inspectPrefiltersOrTransports( structure, options, originalOptions, jqXHR ) { - - var inspected = {}, - seekingTransport = ( structure === transports ); - - function inspect( dataType ) { - var selected; - inspected[ dataType ] = true; - jQuery.each( structure[ dataType ] || [], function( _, prefilterOrFactory ) { - var dataTypeOrTransport = prefilterOrFactory( options, originalOptions, jqXHR ); - if ( typeof dataTypeOrTransport === "string" && !seekingTransport && !inspected[ dataTypeOrTransport ] ) { - options.dataTypes.unshift( dataTypeOrTransport ); - inspect( dataTypeOrTransport ); - return false; - } else if ( seekingTransport ) { - return !( selected = dataTypeOrTransport ); - } - }); - return selected; - } - - return inspect( options.dataTypes[ 0 ] ) || !inspected[ "*" ] && inspect( "*" ); -} - -// A special extend for ajax options -// that takes "flat" options (not to be deep extended) -// Fixes #9887 -function ajaxExtend( target, src ) { - var deep, key, - flatOptions = jQuery.ajaxSettings.flatOptions || {}; - - for ( key in src ) { - if ( src[ key ] !== undefined ) { - ( flatOptions[ key ] ? target : ( deep || (deep = {}) ) )[ key ] = src[ key ]; - } - } - if ( deep ) { - jQuery.extend( true, target, deep ); - } - - return target; -} - -/* Handles responses to an ajax request: - * - finds the right dataType (mediates between content-type and expected dataType) - * - returns the corresponding response - */ -function ajaxHandleResponses( s, jqXHR, responses ) { - var firstDataType, ct, finalDataType, type, - contents = s.contents, - dataTypes = s.dataTypes; - - // Remove auto dataType and get content-type in the process - while ( dataTypes[ 0 ] === "*" ) { - dataTypes.shift(); - if ( ct === undefined ) { - ct = s.mimeType || jqXHR.getResponseHeader("Content-Type"); - } - } - - // Check if we're dealing with a known content-type - if ( ct ) { - for ( type in contents ) { - if ( contents[ type ] && contents[ type ].test( ct ) ) { - dataTypes.unshift( type ); - break; - } - } - } - - // Check to see if we have a response for the expected dataType - if ( dataTypes[ 0 ] in responses ) { - finalDataType = dataTypes[ 0 ]; - } else { - // Try convertible dataTypes - for ( type in responses ) { - if ( !dataTypes[ 0 ] || s.converters[ type + " " + dataTypes[0] ] ) { - finalDataType = type; - break; - } - if ( !firstDataType ) { - firstDataType = type; - } - } - // Or just use first one - finalDataType = finalDataType || firstDataType; - } - - // If we found a dataType - // We add the dataType to the list if needed - // and return the corresponding response - if ( finalDataType ) { - if ( finalDataType !== dataTypes[ 0 ] ) { - dataTypes.unshift( finalDataType ); - } - return responses[ finalDataType ]; - } -} - -/* Chain conversions given the request and the original response - * Also sets the responseXXX fields on the jqXHR instance - */ -function ajaxConvert( s, response, jqXHR, isSuccess ) { - var conv2, current, conv, tmp, prev, - converters = {}, - // Work with a copy of dataTypes in case we need to modify it for conversion - dataTypes = s.dataTypes.slice(); - - // Create converters map with lowercased keys - if ( dataTypes[ 1 ] ) { - for ( conv in s.converters ) { - converters[ conv.toLowerCase() ] = s.converters[ conv ]; - } - } - - current = dataTypes.shift(); - - // Convert to each sequential dataType - while ( current ) { - - if ( s.responseFields[ current ] ) { - jqXHR[ s.responseFields[ current ] ] = response; - } - - // Apply the dataFilter if provided - if ( !prev && isSuccess && s.dataFilter ) { - response = s.dataFilter( response, s.dataType ); - } - - prev = current; - current = dataTypes.shift(); - - if ( current ) { - - // There's only work to do if current dataType is non-auto - if ( current === "*" ) { - - current = prev; - - // Convert response if prev dataType is non-auto and differs from current - } else if ( prev !== "*" && prev !== current ) { - - // Seek a direct converter - conv = converters[ prev + " " + current ] || converters[ "* " + current ]; - - // If none found, seek a pair - if ( !conv ) { - for ( conv2 in converters ) { - - // If conv2 outputs current - tmp = conv2.split( " " ); - if ( tmp[ 1 ] === current ) { - - // If prev can be converted to accepted input - conv = converters[ prev + " " + tmp[ 0 ] ] || - converters[ "* " + tmp[ 0 ] ]; - if ( conv ) { - // Condense equivalence converters - if ( conv === true ) { - conv = converters[ conv2 ]; - - // Otherwise, insert the intermediate dataType - } else if ( converters[ conv2 ] !== true ) { - current = tmp[ 0 ]; - dataTypes.unshift( tmp[ 1 ] ); - } - break; - } - } - } - } - - // Apply converter (if not an equivalence) - if ( conv !== true ) { - - // Unless errors are allowed to bubble, catch and return them - if ( conv && s[ "throws" ] ) { - response = conv( response ); - } else { - try { - response = conv( response ); - } catch ( e ) { - return { state: "parsererror", error: conv ? e : "No conversion from " + prev + " to " + current }; - } - } - } - } - } - } - - return { state: "success", data: response }; -} - -jQuery.extend({ - - // Counter for holding the number of active queries - active: 0, - - // Last-Modified header cache for next request - lastModified: {}, - etag: {}, - - ajaxSettings: { - url: ajaxLocation, - type: "GET", - isLocal: rlocalProtocol.test( ajaxLocParts[ 1 ] ), - global: true, - processData: true, - async: true, - contentType: "application/x-www-form-urlencoded; charset=UTF-8", - /* - timeout: 0, - data: null, - dataType: null, - username: null, - password: null, - cache: null, - throws: false, - traditional: false, - headers: {}, - */ - - accepts: { - "*": allTypes, - text: "text/plain", - html: "text/html", - xml: "application/xml, text/xml", - json: "application/json, text/javascript" - }, - - contents: { - xml: /xml/, - html: /html/, - json: /json/ - }, - - responseFields: { - xml: "responseXML", - text: "responseText", - json: "responseJSON" - }, - - // Data converters - // Keys separate source (or catchall "*") and destination types with a single space - converters: { - - // Convert anything to text - "* text": String, - - // Text to html (true = no transformation) - "text html": true, - - // Evaluate text as a json expression - "text json": jQuery.parseJSON, - - // Parse text as xml - "text xml": jQuery.parseXML - }, - - // For options that shouldn't be deep extended: - // you can add your own custom options here if - // and when you create one that shouldn't be - // deep extended (see ajaxExtend) - flatOptions: { - url: true, - context: true - } - }, - - // Creates a full fledged settings object into target - // with both ajaxSettings and settings fields. - // If target is omitted, writes into ajaxSettings. - ajaxSetup: function( target, settings ) { - return settings ? - - // Building a settings object - ajaxExtend( ajaxExtend( target, jQuery.ajaxSettings ), settings ) : - - // Extending ajaxSettings - ajaxExtend( jQuery.ajaxSettings, target ); - }, - - ajaxPrefilter: addToPrefiltersOrTransports( prefilters ), - ajaxTransport: addToPrefiltersOrTransports( transports ), - - // Main method - ajax: function( url, options ) { - - // If url is an object, simulate pre-1.5 signature - if ( typeof url === "object" ) { - options = url; - url = undefined; - } - - // Force options to be an object - options = options || {}; - - var // Cross-domain detection vars - parts, - // Loop variable - i, - // URL without anti-cache param - cacheURL, - // Response headers as string - responseHeadersString, - // timeout handle - timeoutTimer, - - // To know if global events are to be dispatched - fireGlobals, - - transport, - // Response headers - responseHeaders, - // Create the final options object - s = jQuery.ajaxSetup( {}, options ), - // Callbacks context - callbackContext = s.context || s, - // Context for global events is callbackContext if it is a DOM node or jQuery collection - globalEventContext = s.context && ( callbackContext.nodeType || callbackContext.jquery ) ? - jQuery( callbackContext ) : - jQuery.event, - // Deferreds - deferred = jQuery.Deferred(), - completeDeferred = jQuery.Callbacks("once memory"), - // Status-dependent callbacks - statusCode = s.statusCode || {}, - // Headers (they are sent all at once) - requestHeaders = {}, - requestHeadersNames = {}, - // The jqXHR state - state = 0, - // Default abort message - strAbort = "canceled", - // Fake xhr - jqXHR = { - readyState: 0, - - // Builds headers hashtable if needed - getResponseHeader: function( key ) { - var match; - if ( state === 2 ) { - if ( !responseHeaders ) { - responseHeaders = {}; - while ( (match = rheaders.exec( responseHeadersString )) ) { - responseHeaders[ match[1].toLowerCase() ] = match[ 2 ]; - } - } - match = responseHeaders[ key.toLowerCase() ]; - } - return match == null ? null : match; - }, - - // Raw string - getAllResponseHeaders: function() { - return state === 2 ? responseHeadersString : null; - }, - - // Caches the header - setRequestHeader: function( name, value ) { - var lname = name.toLowerCase(); - if ( !state ) { - name = requestHeadersNames[ lname ] = requestHeadersNames[ lname ] || name; - requestHeaders[ name ] = value; - } - return this; - }, - - // Overrides response content-type header - overrideMimeType: function( type ) { - if ( !state ) { - s.mimeType = type; - } - return this; - }, - - // Status-dependent callbacks - statusCode: function( map ) { - var code; - if ( map ) { - if ( state < 2 ) { - for ( code in map ) { - // Lazy-add the new callback in a way that preserves old ones - statusCode[ code ] = [ statusCode[ code ], map[ code ] ]; - } - } else { - // Execute the appropriate callbacks - jqXHR.always( map[ jqXHR.status ] ); - } - } - return this; - }, - - // Cancel the request - abort: function( statusText ) { - var finalText = statusText || strAbort; - if ( transport ) { - transport.abort( finalText ); - } - done( 0, finalText ); - return this; - } - }; - - // Attach deferreds - deferred.promise( jqXHR ).complete = completeDeferred.add; - jqXHR.success = jqXHR.done; - jqXHR.error = jqXHR.fail; - - // Remove hash character (#7531: and string promotion) - // Add protocol if not provided (#5866: IE7 issue with protocol-less urls) - // Handle falsy url in the settings object (#10093: consistency with old signature) - // We also use the url parameter if available - s.url = ( ( url || s.url || ajaxLocation ) + "" ).replace( rhash, "" ).replace( rprotocol, ajaxLocParts[ 1 ] + "//" ); - - // Alias method option to type as per ticket #12004 - s.type = options.method || options.type || s.method || s.type; - - // Extract dataTypes list - s.dataTypes = jQuery.trim( s.dataType || "*" ).toLowerCase().match( rnotwhite ) || [ "" ]; - - // A cross-domain request is in order when we have a protocol:host:port mismatch - if ( s.crossDomain == null ) { - parts = rurl.exec( s.url.toLowerCase() ); - s.crossDomain = !!( parts && - ( parts[ 1 ] !== ajaxLocParts[ 1 ] || parts[ 2 ] !== ajaxLocParts[ 2 ] || - ( parts[ 3 ] || ( parts[ 1 ] === "http:" ? "80" : "443" ) ) !== - ( ajaxLocParts[ 3 ] || ( ajaxLocParts[ 1 ] === "http:" ? "80" : "443" ) ) ) - ); - } - - // Convert data if not already a string - if ( s.data && s.processData && typeof s.data !== "string" ) { - s.data = jQuery.param( s.data, s.traditional ); - } - - // Apply prefilters - inspectPrefiltersOrTransports( prefilters, s, options, jqXHR ); - - // If request was aborted inside a prefilter, stop there - if ( state === 2 ) { - return jqXHR; - } - - // We can fire global events as of now if asked to - // Don't fire events if jQuery.event is undefined in an AMD-usage scenario (#15118) - fireGlobals = jQuery.event && s.global; - - // Watch for a new set of requests - if ( fireGlobals && jQuery.active++ === 0 ) { - jQuery.event.trigger("ajaxStart"); - } - - // Uppercase the type - s.type = s.type.toUpperCase(); - - // Determine if request has content - s.hasContent = !rnoContent.test( s.type ); - - // Save the URL in case we're toying with the If-Modified-Since - // and/or If-None-Match header later on - cacheURL = s.url; - - // More options handling for requests with no content - if ( !s.hasContent ) { - - // If data is available, append data to url - if ( s.data ) { - cacheURL = ( s.url += ( rquery.test( cacheURL ) ? "&" : "?" ) + s.data ); - // #9682: remove data so that it's not used in an eventual retry - delete s.data; - } - - // Add anti-cache in url if needed - if ( s.cache === false ) { - s.url = rts.test( cacheURL ) ? - - // If there is already a '_' parameter, set its value - cacheURL.replace( rts, "$1_=" + nonce++ ) : - - // Otherwise add one to the end - cacheURL + ( rquery.test( cacheURL ) ? "&" : "?" ) + "_=" + nonce++; - } - } - - // Set the If-Modified-Since and/or If-None-Match header, if in ifModified mode. - if ( s.ifModified ) { - if ( jQuery.lastModified[ cacheURL ] ) { - jqXHR.setRequestHeader( "If-Modified-Since", jQuery.lastModified[ cacheURL ] ); - } - if ( jQuery.etag[ cacheURL ] ) { - jqXHR.setRequestHeader( "If-None-Match", jQuery.etag[ cacheURL ] ); - } - } - - // Set the correct header, if data is being sent - if ( s.data && s.hasContent && s.contentType !== false || options.contentType ) { - jqXHR.setRequestHeader( "Content-Type", s.contentType ); - } - - // Set the Accepts header for the server, depending on the dataType - jqXHR.setRequestHeader( - "Accept", - s.dataTypes[ 0 ] && s.accepts[ s.dataTypes[0] ] ? - s.accepts[ s.dataTypes[0] ] + ( s.dataTypes[ 0 ] !== "*" ? ", " + allTypes + "; q=0.01" : "" ) : - s.accepts[ "*" ] - ); - - // Check for headers option - for ( i in s.headers ) { - jqXHR.setRequestHeader( i, s.headers[ i ] ); - } - - // Allow custom headers/mimetypes and early abort - if ( s.beforeSend && ( s.beforeSend.call( callbackContext, jqXHR, s ) === false || state === 2 ) ) { - // Abort if not done already and return - return jqXHR.abort(); - } - - // aborting is no longer a cancellation - strAbort = "abort"; - - // Install callbacks on deferreds - for ( i in { success: 1, error: 1, complete: 1 } ) { - jqXHR[ i ]( s[ i ] ); - } - - // Get transport - transport = inspectPrefiltersOrTransports( transports, s, options, jqXHR ); - - // If no transport, we auto-abort - if ( !transport ) { - done( -1, "No Transport" ); - } else { - jqXHR.readyState = 1; - - // Send global event - if ( fireGlobals ) { - globalEventContext.trigger( "ajaxSend", [ jqXHR, s ] ); - } - // Timeout - if ( s.async && s.timeout > 0 ) { - timeoutTimer = setTimeout(function() { - jqXHR.abort("timeout"); - }, s.timeout ); - } - - try { - state = 1; - transport.send( requestHeaders, done ); - } catch ( e ) { - // Propagate exception as error if not done - if ( state < 2 ) { - done( -1, e ); - // Simply rethrow otherwise - } else { - throw e; - } - } - } - - // Callback for when everything is done - function done( status, nativeStatusText, responses, headers ) { - var isSuccess, success, error, response, modified, - statusText = nativeStatusText; - - // Called once - if ( state === 2 ) { - return; - } - - // State is "done" now - state = 2; - - // Clear timeout if it exists - if ( timeoutTimer ) { - clearTimeout( timeoutTimer ); - } - - // Dereference transport for early garbage collection - // (no matter how long the jqXHR object will be used) - transport = undefined; - - // Cache response headers - responseHeadersString = headers || ""; - - // Set readyState - jqXHR.readyState = status > 0 ? 4 : 0; - - // Determine if successful - isSuccess = status >= 200 && status < 300 || status === 304; - - // Get response data - if ( responses ) { - response = ajaxHandleResponses( s, jqXHR, responses ); - } - - // Convert no matter what (that way responseXXX fields are always set) - response = ajaxConvert( s, response, jqXHR, isSuccess ); - - // If successful, handle type chaining - if ( isSuccess ) { - - // Set the If-Modified-Since and/or If-None-Match header, if in ifModified mode. - if ( s.ifModified ) { - modified = jqXHR.getResponseHeader("Last-Modified"); - if ( modified ) { - jQuery.lastModified[ cacheURL ] = modified; - } - modified = jqXHR.getResponseHeader("etag"); - if ( modified ) { - jQuery.etag[ cacheURL ] = modified; - } - } - - // if no content - if ( status === 204 || s.type === "HEAD" ) { - statusText = "nocontent"; - - // if not modified - } else if ( status === 304 ) { - statusText = "notmodified"; - - // If we have data, let's convert it - } else { - statusText = response.state; - success = response.data; - error = response.error; - isSuccess = !error; - } - } else { - // We extract error from statusText - // then normalize statusText and status for non-aborts - error = statusText; - if ( status || !statusText ) { - statusText = "error"; - if ( status < 0 ) { - status = 0; - } - } - } - - // Set data for the fake xhr object - jqXHR.status = status; - jqXHR.statusText = ( nativeStatusText || statusText ) + ""; - - // Success/Error - if ( isSuccess ) { - deferred.resolveWith( callbackContext, [ success, statusText, jqXHR ] ); - } else { - deferred.rejectWith( callbackContext, [ jqXHR, statusText, error ] ); - } - - // Status-dependent callbacks - jqXHR.statusCode( statusCode ); - statusCode = undefined; - - if ( fireGlobals ) { - globalEventContext.trigger( isSuccess ? "ajaxSuccess" : "ajaxError", - [ jqXHR, s, isSuccess ? success : error ] ); - } - - // Complete - completeDeferred.fireWith( callbackContext, [ jqXHR, statusText ] ); - - if ( fireGlobals ) { - globalEventContext.trigger( "ajaxComplete", [ jqXHR, s ] ); - // Handle the global AJAX counter - if ( !( --jQuery.active ) ) { - jQuery.event.trigger("ajaxStop"); - } - } - } - - return jqXHR; - }, - - getJSON: function( url, data, callback ) { - return jQuery.get( url, data, callback, "json" ); - }, - - getScript: function( url, callback ) { - return jQuery.get( url, undefined, callback, "script" ); - } -}); - -jQuery.each( [ "get", "post" ], function( i, method ) { - jQuery[ method ] = function( url, data, callback, type ) { - // shift arguments if data argument was omitted - if ( jQuery.isFunction( data ) ) { - type = type || callback; - callback = data; - data = undefined; - } - - return jQuery.ajax({ - url: url, - type: method, - dataType: type, - data: data, - success: callback - }); - }; -}); - - -jQuery._evalUrl = function( url ) { - return jQuery.ajax({ - url: url, - type: "GET", - dataType: "script", - async: false, - global: false, - "throws": true - }); -}; - - -jQuery.fn.extend({ - wrapAll: function( html ) { - if ( jQuery.isFunction( html ) ) { - return this.each(function(i) { - jQuery(this).wrapAll( html.call(this, i) ); - }); - } - - if ( this[0] ) { - // The elements to wrap the target around - var wrap = jQuery( html, this[0].ownerDocument ).eq(0).clone(true); - - if ( this[0].parentNode ) { - wrap.insertBefore( this[0] ); - } - - wrap.map(function() { - var elem = this; - - while ( elem.firstChild && elem.firstChild.nodeType === 1 ) { - elem = elem.firstChild; - } - - return elem; - }).append( this ); - } - - return this; - }, - - wrapInner: function( html ) { - if ( jQuery.isFunction( html ) ) { - return this.each(function(i) { - jQuery(this).wrapInner( html.call(this, i) ); - }); - } - - return this.each(function() { - var self = jQuery( this ), - contents = self.contents(); - - if ( contents.length ) { - contents.wrapAll( html ); - - } else { - self.append( html ); - } - }); - }, - - wrap: function( html ) { - var isFunction = jQuery.isFunction( html ); - - return this.each(function(i) { - jQuery( this ).wrapAll( isFunction ? html.call(this, i) : html ); - }); - }, - - unwrap: function() { - return this.parent().each(function() { - if ( !jQuery.nodeName( this, "body" ) ) { - jQuery( this ).replaceWith( this.childNodes ); - } - }).end(); - } -}); - - -jQuery.expr.filters.hidden = function( elem ) { - // Support: Opera <= 12.12 - // Opera reports offsetWidths and offsetHeights less than zero on some elements - return elem.offsetWidth <= 0 && elem.offsetHeight <= 0 || - (!support.reliableHiddenOffsets() && - ((elem.style && elem.style.display) || jQuery.css( elem, "display" )) === "none"); -}; - -jQuery.expr.filters.visible = function( elem ) { - return !jQuery.expr.filters.hidden( elem ); -}; - - - - -var r20 = /%20/g, - rbracket = /\[\]$/, - rCRLF = /\r?\n/g, - rsubmitterTypes = /^(?:submit|button|image|reset|file)$/i, - rsubmittable = /^(?:input|select|textarea|keygen)/i; - -function buildParams( prefix, obj, traditional, add ) { - var name; - - if ( jQuery.isArray( obj ) ) { - // Serialize array item. - jQuery.each( obj, function( i, v ) { - if ( traditional || rbracket.test( prefix ) ) { - // Treat each array item as a scalar. - add( prefix, v ); - - } else { - // Item is non-scalar (array or object), encode its numeric index. - buildParams( prefix + "[" + ( typeof v === "object" ? i : "" ) + "]", v, traditional, add ); - } - }); - - } else if ( !traditional && jQuery.type( obj ) === "object" ) { - // Serialize object item. - for ( name in obj ) { - buildParams( prefix + "[" + name + "]", obj[ name ], traditional, add ); - } - - } else { - // Serialize scalar item. - add( prefix, obj ); - } -} - -// Serialize an array of form elements or a set of -// key/values into a query string -jQuery.param = function( a, traditional ) { - var prefix, - s = [], - add = function( key, value ) { - // If value is a function, invoke it and return its value - value = jQuery.isFunction( value ) ? value() : ( value == null ? "" : value ); - s[ s.length ] = encodeURIComponent( key ) + "=" + encodeURIComponent( value ); - }; - - // Set traditional to true for jQuery <= 1.3.2 behavior. - if ( traditional === undefined ) { - traditional = jQuery.ajaxSettings && jQuery.ajaxSettings.traditional; - } - - // If an array was passed in, assume that it is an array of form elements. - if ( jQuery.isArray( a ) || ( a.jquery && !jQuery.isPlainObject( a ) ) ) { - // Serialize the form elements - jQuery.each( a, function() { - add( this.name, this.value ); - }); - - } else { - // If traditional, encode the "old" way (the way 1.3.2 or older - // did it), otherwise encode params recursively. - for ( prefix in a ) { - buildParams( prefix, a[ prefix ], traditional, add ); - } - } - - // Return the resulting serialization - return s.join( "&" ).replace( r20, "+" ); -}; - -jQuery.fn.extend({ - serialize: function() { - return jQuery.param( this.serializeArray() ); - }, - serializeArray: function() { - return this.map(function() { - // Can add propHook for "elements" to filter or add form elements - var elements = jQuery.prop( this, "elements" ); - return elements ? jQuery.makeArray( elements ) : this; - }) - .filter(function() { - var type = this.type; - // Use .is(":disabled") so that fieldset[disabled] works - return this.name && !jQuery( this ).is( ":disabled" ) && - rsubmittable.test( this.nodeName ) && !rsubmitterTypes.test( type ) && - ( this.checked || !rcheckableType.test( type ) ); - }) - .map(function( i, elem ) { - var val = jQuery( this ).val(); - - return val == null ? - null : - jQuery.isArray( val ) ? - jQuery.map( val, function( val ) { - return { name: elem.name, value: val.replace( rCRLF, "\r\n" ) }; - }) : - { name: elem.name, value: val.replace( rCRLF, "\r\n" ) }; - }).get(); - } -}); - - -// Create the request object -// (This is still attached to ajaxSettings for backward compatibility) -jQuery.ajaxSettings.xhr = window.ActiveXObject !== undefined ? - // Support: IE6+ - function() { - - // XHR cannot access local files, always use ActiveX for that case - return !this.isLocal && - - // Support: IE7-8 - // oldIE XHR does not support non-RFC2616 methods (#13240) - // See http://msdn.microsoft.com/en-us/library/ie/ms536648(v=vs.85).aspx - // and http://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9 - // Although this check for six methods instead of eight - // since IE also does not support "trace" and "connect" - /^(get|post|head|put|delete|options)$/i.test( this.type ) && - - createStandardXHR() || createActiveXHR(); - } : - // For all other browsers, use the standard XMLHttpRequest object - createStandardXHR; - -var xhrId = 0, - xhrCallbacks = {}, - xhrSupported = jQuery.ajaxSettings.xhr(); - -// Support: IE<10 -// Open requests must be manually aborted on unload (#5280) -// See https://support.microsoft.com/kb/2856746 for more info -if ( window.attachEvent ) { - window.attachEvent( "onunload", function() { - for ( var key in xhrCallbacks ) { - xhrCallbacks[ key ]( undefined, true ); - } - }); -} - -// Determine support properties -support.cors = !!xhrSupported && ( "withCredentials" in xhrSupported ); -xhrSupported = support.ajax = !!xhrSupported; - -// Create transport if the browser can provide an xhr -if ( xhrSupported ) { - - jQuery.ajaxTransport(function( options ) { - // Cross domain only allowed if supported through XMLHttpRequest - if ( !options.crossDomain || support.cors ) { - - var callback; - - return { - send: function( headers, complete ) { - var i, - xhr = options.xhr(), - id = ++xhrId; - - // Open the socket - xhr.open( options.type, options.url, options.async, options.username, options.password ); - - // Apply custom fields if provided - if ( options.xhrFields ) { - for ( i in options.xhrFields ) { - xhr[ i ] = options.xhrFields[ i ]; - } - } - - // Override mime type if needed - if ( options.mimeType && xhr.overrideMimeType ) { - xhr.overrideMimeType( options.mimeType ); - } - - // X-Requested-With header - // For cross-domain requests, seeing as conditions for a preflight are - // akin to a jigsaw puzzle, we simply never set it to be sure. - // (it can always be set on a per-request basis or even using ajaxSetup) - // For same-domain requests, won't change header if already provided. - if ( !options.crossDomain && !headers["X-Requested-With"] ) { - headers["X-Requested-With"] = "XMLHttpRequest"; - } - - // Set headers - for ( i in headers ) { - // Support: IE<9 - // IE's ActiveXObject throws a 'Type Mismatch' exception when setting - // request header to a null-value. - // - // To keep consistent with other XHR implementations, cast the value - // to string and ignore `undefined`. - if ( headers[ i ] !== undefined ) { - xhr.setRequestHeader( i, headers[ i ] + "" ); - } - } - - // Do send the request - // This may raise an exception which is actually - // handled in jQuery.ajax (so no try/catch here) - xhr.send( ( options.hasContent && options.data ) || null ); - - // Listener - callback = function( _, isAbort ) { - var status, statusText, responses; - - // Was never called and is aborted or complete - if ( callback && ( isAbort || xhr.readyState === 4 ) ) { - // Clean up - delete xhrCallbacks[ id ]; - callback = undefined; - xhr.onreadystatechange = jQuery.noop; - - // Abort manually if needed - if ( isAbort ) { - if ( xhr.readyState !== 4 ) { - xhr.abort(); - } - } else { - responses = {}; - status = xhr.status; - - // Support: IE<10 - // Accessing binary-data responseText throws an exception - // (#11426) - if ( typeof xhr.responseText === "string" ) { - responses.text = xhr.responseText; - } - - // Firefox throws an exception when accessing - // statusText for faulty cross-domain requests - try { - statusText = xhr.statusText; - } catch( e ) { - // We normalize with Webkit giving an empty statusText - statusText = ""; - } - - // Filter status for non standard behaviors - - // If the request is local and we have data: assume a success - // (success with no data won't get notified, that's the best we - // can do given current implementations) - if ( !status && options.isLocal && !options.crossDomain ) { - status = responses.text ? 200 : 404; - // IE - #1450: sometimes returns 1223 when it should be 204 - } else if ( status === 1223 ) { - status = 204; - } - } - } - - // Call complete if needed - if ( responses ) { - complete( status, statusText, responses, xhr.getAllResponseHeaders() ); - } - }; - - if ( !options.async ) { - // if we're in sync mode we fire the callback - callback(); - } else if ( xhr.readyState === 4 ) { - // (IE6 & IE7) if it's in cache and has been - // retrieved directly we need to fire the callback - setTimeout( callback ); - } else { - // Add to the list of active xhr callbacks - xhr.onreadystatechange = xhrCallbacks[ id ] = callback; - } - }, - - abort: function() { - if ( callback ) { - callback( undefined, true ); - } - } - }; - } - }); -} - -// Functions to create xhrs -function createStandardXHR() { - try { - return new window.XMLHttpRequest(); - } catch( e ) {} -} - -function createActiveXHR() { - try { - return new window.ActiveXObject( "Microsoft.XMLHTTP" ); - } catch( e ) {} -} - - - - -// Install script dataType -jQuery.ajaxSetup({ - accepts: { - script: "text/javascript, application/javascript, application/ecmascript, application/x-ecmascript" - }, - contents: { - script: /(?:java|ecma)script/ - }, - converters: { - "text script": function( text ) { - jQuery.globalEval( text ); - return text; - } - } -}); - -// Handle cache's special case and global -jQuery.ajaxPrefilter( "script", function( s ) { - if ( s.cache === undefined ) { - s.cache = false; - } - if ( s.crossDomain ) { - s.type = "GET"; - s.global = false; - } -}); - -// Bind script tag hack transport -jQuery.ajaxTransport( "script", function(s) { - - // This transport only deals with cross domain requests - if ( s.crossDomain ) { - - var script, - head = document.head || jQuery("head")[0] || document.documentElement; - - return { - - send: function( _, callback ) { - - script = document.createElement("script"); - - script.async = true; - - if ( s.scriptCharset ) { - script.charset = s.scriptCharset; - } - - script.src = s.url; - - // Attach handlers for all browsers - script.onload = script.onreadystatechange = function( _, isAbort ) { - - if ( isAbort || !script.readyState || /loaded|complete/.test( script.readyState ) ) { - - // Handle memory leak in IE - script.onload = script.onreadystatechange = null; - - // Remove the script - if ( script.parentNode ) { - script.parentNode.removeChild( script ); - } - - // Dereference the script - script = null; - - // Callback if not abort - if ( !isAbort ) { - callback( 200, "success" ); - } - } - }; - - // Circumvent IE6 bugs with base elements (#2709 and #4378) by prepending - // Use native DOM manipulation to avoid our domManip AJAX trickery - head.insertBefore( script, head.firstChild ); - }, - - abort: function() { - if ( script ) { - script.onload( undefined, true ); - } - } - }; - } -}); - - - - -var oldCallbacks = [], - rjsonp = /(=)\?(?=&|$)|\?\?/; - -// Default jsonp settings -jQuery.ajaxSetup({ - jsonp: "callback", - jsonpCallback: function() { - var callback = oldCallbacks.pop() || ( jQuery.expando + "_" + ( nonce++ ) ); - this[ callback ] = true; - return callback; - } -}); - -// Detect, normalize options and install callbacks for jsonp requests -jQuery.ajaxPrefilter( "json jsonp", function( s, originalSettings, jqXHR ) { - - var callbackName, overwritten, responseContainer, - jsonProp = s.jsonp !== false && ( rjsonp.test( s.url ) ? - "url" : - typeof s.data === "string" && !( s.contentType || "" ).indexOf("application/x-www-form-urlencoded") && rjsonp.test( s.data ) && "data" - ); - - // Handle iff the expected data type is "jsonp" or we have a parameter to set - if ( jsonProp || s.dataTypes[ 0 ] === "jsonp" ) { - - // Get callback name, remembering preexisting value associated with it - callbackName = s.jsonpCallback = jQuery.isFunction( s.jsonpCallback ) ? - s.jsonpCallback() : - s.jsonpCallback; - - // Insert callback into url or form data - if ( jsonProp ) { - s[ jsonProp ] = s[ jsonProp ].replace( rjsonp, "$1" + callbackName ); - } else if ( s.jsonp !== false ) { - s.url += ( rquery.test( s.url ) ? "&" : "?" ) + s.jsonp + "=" + callbackName; - } - - // Use data converter to retrieve json after script execution - s.converters["script json"] = function() { - if ( !responseContainer ) { - jQuery.error( callbackName + " was not called" ); - } - return responseContainer[ 0 ]; - }; - - // force json dataType - s.dataTypes[ 0 ] = "json"; - - // Install callback - overwritten = window[ callbackName ]; - window[ callbackName ] = function() { - responseContainer = arguments; - }; - - // Clean-up function (fires after converters) - jqXHR.always(function() { - // Restore preexisting value - window[ callbackName ] = overwritten; - - // Save back as free - if ( s[ callbackName ] ) { - // make sure that re-using the options doesn't screw things around - s.jsonpCallback = originalSettings.jsonpCallback; - - // save the callback name for future use - oldCallbacks.push( callbackName ); - } - - // Call if it was a function and we have a response - if ( responseContainer && jQuery.isFunction( overwritten ) ) { - overwritten( responseContainer[ 0 ] ); - } - - responseContainer = overwritten = undefined; - }); - - // Delegate to script - return "script"; - } -}); - - - - -// data: string of html -// context (optional): If specified, the fragment will be created in this context, defaults to document -// keepScripts (optional): If true, will include scripts passed in the html string -jQuery.parseHTML = function( data, context, keepScripts ) { - if ( !data || typeof data !== "string" ) { - return null; - } - if ( typeof context === "boolean" ) { - keepScripts = context; - context = false; - } - context = context || document; - - var parsed = rsingleTag.exec( data ), - scripts = !keepScripts && []; - - // Single tag - if ( parsed ) { - return [ context.createElement( parsed[1] ) ]; - } - - parsed = jQuery.buildFragment( [ data ], context, scripts ); - - if ( scripts && scripts.length ) { - jQuery( scripts ).remove(); - } - - return jQuery.merge( [], parsed.childNodes ); -}; - - -// Keep a copy of the old load method -var _load = jQuery.fn.load; - -/** - * Load a url into a page - */ -jQuery.fn.load = function( url, params, callback ) { - if ( typeof url !== "string" && _load ) { - return _load.apply( this, arguments ); - } - - var selector, response, type, - self = this, - off = url.indexOf(" "); - - if ( off >= 0 ) { - selector = jQuery.trim( url.slice( off, url.length ) ); - url = url.slice( 0, off ); - } - - // If it's a function - if ( jQuery.isFunction( params ) ) { - - // We assume that it's the callback - callback = params; - params = undefined; - - // Otherwise, build a param string - } else if ( params && typeof params === "object" ) { - type = "POST"; - } - - // If we have elements to modify, make the request - if ( self.length > 0 ) { - jQuery.ajax({ - url: url, - - // if "type" variable is undefined, then "GET" method will be used - type: type, - dataType: "html", - data: params - }).done(function( responseText ) { - - // Save response for use in complete callback - response = arguments; - - self.html( selector ? - - // If a selector was specified, locate the right elements in a dummy div - // Exclude scripts to avoid IE 'Permission Denied' errors - jQuery("<div>").append( jQuery.parseHTML( responseText ) ).find( selector ) : - - // Otherwise use the full result - responseText ); - - }).complete( callback && function( jqXHR, status ) { - self.each( callback, response || [ jqXHR.responseText, status, jqXHR ] ); - }); - } - - return this; -}; - - - - -// Attach a bunch of functions for handling common AJAX events -jQuery.each( [ "ajaxStart", "ajaxStop", "ajaxComplete", "ajaxError", "ajaxSuccess", "ajaxSend" ], function( i, type ) { - jQuery.fn[ type ] = function( fn ) { - return this.on( type, fn ); - }; -}); - - - - -jQuery.expr.filters.animated = function( elem ) { - return jQuery.grep(jQuery.timers, function( fn ) { - return elem === fn.elem; - }).length; -}; - - - - - -var docElem = window.document.documentElement; - -/** - * Gets a window from an element - */ -function getWindow( elem ) { - return jQuery.isWindow( elem ) ? - elem : - elem.nodeType === 9 ? - elem.defaultView || elem.parentWindow : - false; -} - -jQuery.offset = { - setOffset: function( elem, options, i ) { - var curPosition, curLeft, curCSSTop, curTop, curOffset, curCSSLeft, calculatePosition, - position = jQuery.css( elem, "position" ), - curElem = jQuery( elem ), - props = {}; - - // set position first, in-case top/left are set even on static elem - if ( position === "static" ) { - elem.style.position = "relative"; - } - - curOffset = curElem.offset(); - curCSSTop = jQuery.css( elem, "top" ); - curCSSLeft = jQuery.css( elem, "left" ); - calculatePosition = ( position === "absolute" || position === "fixed" ) && - jQuery.inArray("auto", [ curCSSTop, curCSSLeft ] ) > -1; - - // need to be able to calculate position if either top or left is auto and position is either absolute or fixed - if ( calculatePosition ) { - curPosition = curElem.position(); - curTop = curPosition.top; - curLeft = curPosition.left; - } else { - curTop = parseFloat( curCSSTop ) || 0; - curLeft = parseFloat( curCSSLeft ) || 0; - } - - if ( jQuery.isFunction( options ) ) { - options = options.call( elem, i, curOffset ); - } - - if ( options.top != null ) { - props.top = ( options.top - curOffset.top ) + curTop; - } - if ( options.left != null ) { - props.left = ( options.left - curOffset.left ) + curLeft; - } - - if ( "using" in options ) { - options.using.call( elem, props ); - } else { - curElem.css( props ); - } - } -}; - -jQuery.fn.extend({ - offset: function( options ) { - if ( arguments.length ) { - return options === undefined ? - this : - this.each(function( i ) { - jQuery.offset.setOffset( this, options, i ); - }); - } - - var docElem, win, - box = { top: 0, left: 0 }, - elem = this[ 0 ], - doc = elem && elem.ownerDocument; - - if ( !doc ) { - return; - } - - docElem = doc.documentElement; - - // Make sure it's not a disconnected DOM node - if ( !jQuery.contains( docElem, elem ) ) { - return box; - } - - // If we don't have gBCR, just use 0,0 rather than error - // BlackBerry 5, iOS 3 (original iPhone) - if ( typeof elem.getBoundingClientRect !== strundefined ) { - box = elem.getBoundingClientRect(); - } - win = getWindow( doc ); - return { - top: box.top + ( win.pageYOffset || docElem.scrollTop ) - ( docElem.clientTop || 0 ), - left: box.left + ( win.pageXOffset || docElem.scrollLeft ) - ( docElem.clientLeft || 0 ) - }; - }, - - position: function() { - if ( !this[ 0 ] ) { - return; - } - - var offsetParent, offset, - parentOffset = { top: 0, left: 0 }, - elem = this[ 0 ]; - - // fixed elements are offset from window (parentOffset = {top:0, left: 0}, because it is its only offset parent - if ( jQuery.css( elem, "position" ) === "fixed" ) { - // we assume that getBoundingClientRect is available when computed position is fixed - offset = elem.getBoundingClientRect(); - } else { - // Get *real* offsetParent - offsetParent = this.offsetParent(); - - // Get correct offsets - offset = this.offset(); - if ( !jQuery.nodeName( offsetParent[ 0 ], "html" ) ) { - parentOffset = offsetParent.offset(); - } - - // Add offsetParent borders - parentOffset.top += jQuery.css( offsetParent[ 0 ], "borderTopWidth", true ); - parentOffset.left += jQuery.css( offsetParent[ 0 ], "borderLeftWidth", true ); - } - - // Subtract parent offsets and element margins - // note: when an element has margin: auto the offsetLeft and marginLeft - // are the same in Safari causing offset.left to incorrectly be 0 - return { - top: offset.top - parentOffset.top - jQuery.css( elem, "marginTop", true ), - left: offset.left - parentOffset.left - jQuery.css( elem, "marginLeft", true) - }; - }, - - offsetParent: function() { - return this.map(function() { - var offsetParent = this.offsetParent || docElem; - - while ( offsetParent && ( !jQuery.nodeName( offsetParent, "html" ) && jQuery.css( offsetParent, "position" ) === "static" ) ) { - offsetParent = offsetParent.offsetParent; - } - return offsetParent || docElem; - }); - } -}); - -// Create scrollLeft and scrollTop methods -jQuery.each( { scrollLeft: "pageXOffset", scrollTop: "pageYOffset" }, function( method, prop ) { - var top = /Y/.test( prop ); - - jQuery.fn[ method ] = function( val ) { - return access( this, function( elem, method, val ) { - var win = getWindow( elem ); - - if ( val === undefined ) { - return win ? (prop in win) ? win[ prop ] : - win.document.documentElement[ method ] : - elem[ method ]; - } - - if ( win ) { - win.scrollTo( - !top ? val : jQuery( win ).scrollLeft(), - top ? val : jQuery( win ).scrollTop() - ); - - } else { - elem[ method ] = val; - } - }, method, val, arguments.length, null ); - }; -}); - -// Add the top/left cssHooks using jQuery.fn.position -// Webkit bug: https://bugs.webkit.org/show_bug.cgi?id=29084 -// getComputedStyle returns percent when specified for top/left/bottom/right -// rather than make the css module depend on the offset module, we just check for it here -jQuery.each( [ "top", "left" ], function( i, prop ) { - jQuery.cssHooks[ prop ] = addGetHookIf( support.pixelPosition, - function( elem, computed ) { - if ( computed ) { - computed = curCSS( elem, prop ); - // if curCSS returns percentage, fallback to offset - return rnumnonpx.test( computed ) ? - jQuery( elem ).position()[ prop ] + "px" : - computed; - } - } - ); -}); - - -// Create innerHeight, innerWidth, height, width, outerHeight and outerWidth methods -jQuery.each( { Height: "height", Width: "width" }, function( name, type ) { - jQuery.each( { padding: "inner" + name, content: type, "": "outer" + name }, function( defaultExtra, funcName ) { - // margin is only for outerHeight, outerWidth - jQuery.fn[ funcName ] = function( margin, value ) { - var chainable = arguments.length && ( defaultExtra || typeof margin !== "boolean" ), - extra = defaultExtra || ( margin === true || value === true ? "margin" : "border" ); - - return access( this, function( elem, type, value ) { - var doc; - - if ( jQuery.isWindow( elem ) ) { - // As of 5/8/2012 this will yield incorrect results for Mobile Safari, but there - // isn't a whole lot we can do. See pull request at this URL for discussion: - // https://github.com/jquery/jquery/pull/764 - return elem.document.documentElement[ "client" + name ]; - } - - // Get document width or height - if ( elem.nodeType === 9 ) { - doc = elem.documentElement; - - // Either scroll[Width/Height] or offset[Width/Height] or client[Width/Height], whichever is greatest - // unfortunately, this causes bug #3838 in IE6/8 only, but there is currently no good, small way to fix it. - return Math.max( - elem.body[ "scroll" + name ], doc[ "scroll" + name ], - elem.body[ "offset" + name ], doc[ "offset" + name ], - doc[ "client" + name ] - ); - } - - return value === undefined ? - // Get width or height on the element, requesting but not forcing parseFloat - jQuery.css( elem, type, extra ) : - - // Set width or height on the element - jQuery.style( elem, type, value, extra ); - }, type, chainable ? margin : undefined, chainable, null ); - }; - }); -}); - - -// The number of elements contained in the matched element set -jQuery.fn.size = function() { - return this.length; -}; - -jQuery.fn.andSelf = jQuery.fn.addBack; - - - - -// Register as a named AMD module, since jQuery can be concatenated with other -// files that may use define, but not via a proper concatenation script that -// understands anonymous AMD modules. A named AMD is safest and most robust -// way to register. Lowercase jquery is used because AMD module names are -// derived from file names, and jQuery is normally delivered in a lowercase -// file name. Do this after creating the global so that if an AMD module wants -// to call noConflict to hide this version of jQuery, it will work. - -// Note that for maximum portability, libraries that are not jQuery should -// declare themselves as anonymous modules, and avoid setting a global if an -// AMD loader is present. jQuery is a special case. For more information, see -// https://github.com/jrburke/requirejs/wiki/Updating-existing-libraries#wiki-anon - -if ( typeof define === "function" && define.amd ) { - define( "jquery", [], function() { - return jQuery; - }); -} - - - - -var - // Map over jQuery in case of overwrite - _jQuery = window.jQuery, - - // Map over the $ in case of overwrite - _$ = window.$; - -jQuery.noConflict = function( deep ) { - if ( window.$ === jQuery ) { - window.$ = _$; - } - - if ( deep && window.jQuery === jQuery ) { - window.jQuery = _jQuery; - } - - return jQuery; -}; - -// Expose jQuery and $ identifiers, even in -// AMD (#7102#comment:10, https://github.com/jquery/jquery/pull/557) -// and CommonJS for browser emulators (#13566) -if ( typeof noGlobal === strundefined ) { - window.jQuery = window.$ = jQuery; -} - - - - -return jQuery; - -})); diff --git a/src/UI/JsLibraries/jquery.knob.js b/src/UI/JsLibraries/jquery.knob.js deleted file mode 100644 index a657773d4..000000000 --- a/src/UI/JsLibraries/jquery.knob.js +++ /dev/null @@ -1,672 +0,0 @@ -/*!jQuery Knob*/ -/** - * Downward compatible, touchable dial - * - * Version: 1.2.0 (15/07/2012) - * Requires: jQuery v1.7+ - * - * Copyright (c) 2012 Anthony Terrien - * Under MIT and GPL licenses: - * http://www.opensource.org/licenses/mit-license.php - * http://www.gnu.org/licenses/gpl.html - * - * Thanks to vor, eskimoblood, spiffistan, FabrizioC - */ -(function($) { - - /** - * Kontrol library - */ - "use strict"; - - /** - * Definition of globals and core - */ - var k = {}, // kontrol - max = Math.max, - min = Math.min; - - k.c = {}; - k.c.d = $(document); - k.c.t = function (e) { - return e.originalEvent.touches.length - 1; - }; - - /** - * Kontrol Object - * - * Definition of an abstract UI control - * - * Each concrete component must call this one. - * <code> - * k.o.call(this); - * </code> - */ - k.o = function () { - var s = this; - - this.o = null; // array of options - this.$ = null; // jQuery wrapped element - this.i = null; // mixed HTMLInputElement or array of HTMLInputElement - this.g = null; // 2D graphics context for 'pre-rendering' - this.v = null; // value ; mixed array or integer - this.cv = null; // change value ; not commited value - this.x = 0; // canvas x position - this.y = 0; // canvas y position - this.$c = null; // jQuery canvas element - this.c = null; // rendered canvas context - this.t = 0; // touches index - this.isInit = false; - this.fgColor = null; // main color - this.pColor = null; // previous color - this.dH = null; // draw hook - this.cH = null; // change hook - this.eH = null; // cancel hook - this.rH = null; // release hook - - this.run = function () { - var cf = function (e, conf) { - var k; - for (k in conf) { - s.o[k] = conf[k]; - } - s.init(); - s._configure() - ._draw(); - }; - - if(this.$.data('kontroled')) return; - this.$.data('kontroled', true); - - this.extend(); - this.o = $.extend( - { - // Config - min : this.$.data('min') || 0, - max : this.$.data('max') || 100, - stopper : true, - readOnly : this.$.data('readonly'), - - // UI - cursor : (this.$.data('cursor') === true && 30) - || this.$.data('cursor') - || 0, - thickness : this.$.data('thickness') || 0.35, - lineCap : this.$.data('linecap') || 'butt', - width : this.$.data('width') || 200, - height : this.$.data('height') || 200, - displayInput : this.$.data('displayinput') == null || this.$.data('displayinput'), - displayPrevious : this.$.data('displayprevious'), - fgColor : this.$.data('fgcolor') || '#87CEEB', - inputColor: this.$.data('inputcolor') || this.$.data('fgcolor') || '#87CEEB', - inline : false, - step : this.$.data('step') || 1, - - // Hooks - draw : null, // function () {} - change : null, // function (value) {} - cancel : null, // function () {} - release : null, // function (value) {} - error : null // function () {} - }, this.o - ); - - // routing value - if(this.$.is('fieldset')) { - - // fieldset = array of integer - this.v = {}; - this.i = this.$.find('input') - this.i.each(function(k) { - var $this = $(this); - s.i[k] = $this; - s.v[k] = $this.val(); - - $this.bind( - 'change' - , function () { - var val = {}; - val[k] = $this.val(); - s.val(val); - } - ); - }); - this.$.find('legend').remove(); - - } else { - // input = integer - this.i = this.$; - this.v = this.$.val(); - (this.v == '') && (this.v = this.o.min); - - this.$.bind( - 'change' - , function () { - s.val(s._validate(s.$.val())); - } - ); - } - - (!this.o.displayInput) && this.$.hide(); - - this.$c = $('<canvas width="' + - this.o.width + 'px" height="' + - this.o.height + 'px"></canvas>'); - - this.c = this.$c[0].getContext? this.$c[0].getContext('2d') : null; - - if (!this.c) { - this.o.error && this.o.error(); - return; - } - - this.$ - .wrap($('<div style="' + (this.o.inline ? 'display:inline;' : '') + - 'width:' + this.o.width + 'px;height:' + - this.o.height + 'px;"></div>')) - .before(this.$c); - - if (this.v instanceof Object) { - this.cv = {}; - this.copy(this.v, this.cv); - } else { - this.cv = this.v; - } - - this.$ - .bind("configure", cf) - .parent() - .bind("configure", cf); - - this._listen() - ._configure() - ._xy() - .init(); - - this.isInit = true; - - this._draw(); - - return this; - }; - - this._draw = function () { - - // canvas pre-rendering - var d = true, - c = document.createElement('canvas'); - - c.width = s.o.width; - c.height = s.o.height; - s.g = c.getContext('2d'); - - s.clear(); - - s.dH - && (d = s.dH()); - - (d !== false) && s.draw(); - - s.c.drawImage(c, 0, 0); - c = null; - }; - - this._touch = function (e) { - - var touchMove = function (e) { - - var v = s.xy2val( - e.originalEvent.touches[s.t].pageX, - e.originalEvent.touches[s.t].pageY - ); - - if (v == s.cv) return; - - if ( - s.cH - && (s.cH(v) === false) - ) return; - - - s.change(s._validate(v)); - s.$.trigger('change', v); - s._draw(); - }; - - // get touches index - this.t = k.c.t(e); - - // First touch - touchMove(e); - - // Touch events listeners - k.c.d - .bind("touchmove.k", touchMove) - .bind( - "touchend.k" - , function () { - k.c.d.unbind('touchmove.k touchend.k'); - - if ( - s.rH - && (s.rH(s.cv) === false) - ) return; - - s.val(s.cv); - } - ); - - return this; - }; - - this._mouse = function (e) { - - var mouseMove = function (e) { - var v = s.xy2val(e.pageX, e.pageY); - if (v == s.cv) return; - - if ( - s.cH - && (s.cH(v) === false) - ) return; - - s.change(s._validate(v)); - s.$.trigger('change', v); - s._draw(); - }; - - // First click - mouseMove(e); - - // Mouse events listeners - k.c.d - .bind("mousemove.k", mouseMove) - .bind( - // Escape key cancel current change - "keyup.k" - , function (e) { - if (e.keyCode === 27) { - k.c.d.unbind("mouseup.k mousemove.k keyup.k"); - - if ( - s.eH - && (s.eH() === false) - ) return; - - s.cancel(); - } - } - ) - .bind( - "mouseup.k" - , function (e) { - k.c.d.unbind('mousemove.k mouseup.k keyup.k'); - - if ( - s.rH - && (s.rH(s.cv) === false) - ) return; - - s.val(s.cv); - } - ); - - return this; - }; - - this._xy = function () { - var o = this.$c.offset(); - this.x = o.left; - this.y = o.top; - return this; - }; - - this._listen = function () { - - if (!this.o.readOnly) { - this.$c - .bind( - "mousedown" - , function (e) { - e.preventDefault(); - s._xy()._mouse(e); - } - ) - .bind( - "touchstart" - , function (e) { - e.preventDefault(); - s._xy()._touch(e); - } - ); - this.listen(); - } else { - this.$.attr('readonly', 'readonly'); - } - - return this; - }; - - this._configure = function () { - - // Hooks - if (this.o.draw) this.dH = this.o.draw; - if (this.o.change) this.cH = this.o.change; - if (this.o.cancel) this.eH = this.o.cancel; - if (this.o.release) this.rH = this.o.release; - - if (this.o.displayPrevious) { - this.pColor = this.h2rgba(this.o.fgColor, "0.4"); - this.fgColor = this.h2rgba(this.o.fgColor, "0.6"); - } else { - this.fgColor = this.o.fgColor; - } - - return this; - }; - - this._clear = function () { - this.$c[0].width = this.$c[0].width; - }; - - this._validate = function(v) { - return (~~ (((v < 0) ? -0.5 : 0.5) + (v/this.o.step))) * this.o.step; - }; - - // Abstract methods - this.listen = function () {}; // on start, one time - this.extend = function () {}; // each time configure triggered - this.init = function () {}; // each time configure triggered - this.change = function (v) {}; // on change - this.val = function (v) {}; // on release - this.xy2val = function (x, y) {}; // - this.draw = function () {}; // on change / on release - this.clear = function () { this._clear(); }; - - // Utils - this.h2rgba = function (h, a) { - var rgb; - h = h.substring(1,7) - rgb = [parseInt(h.substring(0,2),16) - ,parseInt(h.substring(2,4),16) - ,parseInt(h.substring(4,6),16)]; - return "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + a + ")"; - }; - - this.copy = function (f, t) { - for (var i in f) { t[i] = f[i]; } - }; - }; - - - /** - * k.Dial - */ - k.Dial = function () { - k.o.call(this); - - this.startAngle = null; - this.xy = null; - this.radius = null; - this.lineWidth = null; - this.cursorExt = null; - this.w2 = null; - this.PI2 = 2*Math.PI; - - this.extend = function () { - this.o = $.extend( - { - bgColor : this.$.data('bgcolor') || '#EEEEEE', - angleOffset : this.$.data('angleoffset') || 0, - angleArc : this.$.data('anglearc') || 360, - inline : true - }, this.o - ); - }; - - this.val = function (v) { - if (null != v) { - this.cv = this.o.stopper ? max(min(v, this.o.max), this.o.min) : v; - this.v = this.cv; - this.$.val(this.v); - this._draw(); - } else { - return this.v; - } - }; - - this.xy2val = function (x, y) { - var a, ret; - - a = Math.atan2( - x - (this.x + this.w2) - , - (y - this.y - this.w2) - ) - this.angleOffset; - - if(this.angleArc != this.PI2 && (a < 0) && (a > -0.5)) { - // if isset angleArc option, set to min if .5 under min - a = 0; - } else if (a < 0) { - a += this.PI2; - } - - ret = ~~ (0.5 + (a * (this.o.max - this.o.min) / this.angleArc)) - + this.o.min; - - this.o.stopper - && (ret = max(min(ret, this.o.max), this.o.min)); - - return ret; - }; - - this.listen = function () { - // bind MouseWheel - var s = this, - mw = function (e) { - e.preventDefault(); - var ori = e.originalEvent - ,deltaX = ori.detail || ori.wheelDeltaX - ,deltaY = ori.detail || ori.wheelDeltaY - ,v = parseInt(s.$.val()) + (deltaX>0 || deltaY>0 ? s.o.step : deltaX<0 || deltaY<0 ? -s.o.step : 0); - - if ( - s.cH - && (s.cH(v) === false) - ) return; - - s.val(v); - s.$.trigger('change', v); - } - , kval, to, m = 1, kv = {37:-s.o.step, 38:s.o.step, 39:s.o.step, 40:-s.o.step}; - - this.$ - .bind( - "keydown" - ,function (e) { - var kc = e.keyCode; - - // numpad support - if(kc >= 96 && kc <= 105) { - kc = e.keyCode = kc - 48; - } - - kval = parseInt(String.fromCharCode(kc)); - - if (isNaN(kval)) { - - (kc !== 13) // enter - && (kc !== 8) // bs - && (kc !== 9) // tab - && (kc !== 189) // - - && e.preventDefault(); - - // arrows - if ($.inArray(kc,[37,38,39,40]) > -1) { - e.preventDefault(); - - var v = parseInt(s.$.val()) + kv[kc] * m; - - s.o.stopper - && (v = max(min(v, s.o.max), s.o.min)); - - s.change(v); - s.$.trigger('change', v); - s._draw(); - - // long time keydown speed-up - to = window.setTimeout( - function () { m*=2; } - ,30 - ); - } - } - } - ) - .bind( - "keyup" - ,function (e) { - if (isNaN(kval)) { - if (to) { - window.clearTimeout(to); - to = null; - m = 1; - s.val(s.$.val()); - } - } else { - // kval postcond - (s.$.val() > s.o.max && s.$.val(s.o.max)) - || (s.$.val() < s.o.min && s.$.val(s.o.min)); - } - - } - ); - - this.$c.bind("mousewheel DOMMouseScroll", mw); - this.$.bind("mousewheel DOMMouseScroll", mw) - }; - - this.init = function () { - - if ( - this.v < this.o.min - || this.v > this.o.max - ) this.v = this.o.min; - - this.$.val(this.v); - this.w2 = this.o.width / 2; - this.cursorExt = this.o.cursor / 100; - this.xy = this.w2; - this.lineWidth = this.xy * this.o.thickness; - this.lineCap = this.o.lineCap; - this.radius = this.xy - this.lineWidth / 2; - - this.o.angleOffset - && (this.o.angleOffset = isNaN(this.o.angleOffset) ? 0 : this.o.angleOffset); - - this.o.angleArc - && (this.o.angleArc = isNaN(this.o.angleArc) ? this.PI2 : this.o.angleArc); - - // deg to rad - this.angleOffset = this.o.angleOffset * Math.PI / 180; - this.angleArc = this.o.angleArc * Math.PI / 180; - - // compute start and end angles - this.startAngle = 1.5 * Math.PI + this.angleOffset; - this.endAngle = 1.5 * Math.PI + this.angleOffset + this.angleArc; - - var s = max( - String(Math.abs(this.o.max)).length - , String(Math.abs(this.o.min)).length - , 2 - ) + 2; - - this.o.displayInput - && this.i.css({ - 'width' : ((this.o.width / 2 + 4) >> 0) + 'px' - ,'height' : ((this.o.width / 3) >> 0) + 'px' - ,'position' : 'absolute' - ,'vertical-align' : 'middle' - ,'margin-top' : ((this.o.width / 3) >> 0) + 'px' - ,'margin-left' : '-' + ((this.o.width * 3 / 4 + 2) >> 0) + 'px' - ,'border' : 0 - ,'background' : 'none' - ,'font' : 'bold ' + ((this.o.width / s) >> 0) + 'px Arial' - ,'text-align' : 'center' - ,'color' : this.o.inputColor || this.o.fgColor - ,'padding' : '0px' - ,'-webkit-appearance': 'none' - }) - || this.i.css({ - 'width' : '0px' - ,'visibility' : 'hidden' - }); - }; - - this.change = function (v) { - this.cv = v; - this.$.val(v); - }; - - this.angle = function (v) { - return (v - this.o.min) * this.angleArc / (this.o.max - this.o.min); - }; - - this.draw = function () { - - var c = this.g, // context - a = this.angle(this.cv) // Angle - , sat = this.startAngle // Start angle - , eat = sat + a // End angle - , sa, ea // Previous angles - , r = 1; - - c.lineWidth = this.lineWidth; - - c.lineCap = this.lineCap; - - this.o.cursor - && (sat = eat - this.cursorExt) - && (eat = eat + this.cursorExt); - - c.beginPath(); - c.strokeStyle = this.o.bgColor; - c.arc(this.xy, this.xy, this.radius, this.endAngle, this.startAngle, true); - c.stroke(); - - if (this.o.displayPrevious) { - ea = this.startAngle + this.angle(this.v); - sa = this.startAngle; - this.o.cursor - && (sa = ea - this.cursorExt) - && (ea = ea + this.cursorExt); - - c.beginPath(); - c.strokeStyle = this.pColor; - c.arc(this.xy, this.xy, this.radius, sa, ea, false); - c.stroke(); - r = (this.cv == this.v); - } - - c.beginPath(); - c.strokeStyle = r ? this.o.fgColor : this.fgColor ; - c.arc(this.xy, this.xy, this.radius, sat, eat, false); - c.stroke(); - }; - - this.cancel = function () { - this.val(this.v); - }; - }; - - $.fn.dial = $.fn.knob = function (o) { - return this.each( - function () { - var d = new k.Dial(); - d.o = o; - d.$ = $(this); - d.run(); - } - ).parent(); - }; - -})(jQuery); \ No newline at end of file diff --git a/src/UI/JsLibraries/jquery.signalR.js b/src/UI/JsLibraries/jquery.signalR.js deleted file mode 100644 index fcacbc371..000000000 --- a/src/UI/JsLibraries/jquery.signalR.js +++ /dev/null @@ -1,2193 +0,0 @@ -/* jquery.signalR.core.js */ -/*global window:false */ -/*! - * ASP.NET SignalR JavaScript Library v1.1.3 - * http://signalr.net/ - * - * Copyright Microsoft Open Technologies, Inc. All rights reserved. - * Licensed under the Apache 2.0 - * https://github.com/SignalR/SignalR/blob/master/LICENSE.md - * - */ - -/// <reference path="Scripts/jquery-1.6.4.js" /> -(function ($, window) { - "use strict"; - - if (typeof ($) !== "function") { - // no jQuery! - throw new Error("SignalR: jQuery not found. Please ensure jQuery is referenced before the SignalR.js file."); - } - - if (!window.JSON) { - // no JSON! - throw new Error("SignalR: No JSON parser found. Please ensure json2.js is referenced before the SignalR.js file if you need to support clients without native JSON parsing support, e.g. IE<8."); - } - - var signalR, - _connection, - _pageLoaded = (window.document.readyState === "complete"), - _pageWindow = $(window), - - events = { - onStart: "onStart", - onStarting: "onStarting", - onReceived: "onReceived", - onError: "onError", - onConnectionSlow: "onConnectionSlow", - onReconnecting: "onReconnecting", - onReconnect: "onReconnect", - onStateChanged: "onStateChanged", - onDisconnect: "onDisconnect" - }, - - log = function (msg, logging) { - if (logging === false) { - return; - } - var m; - if (typeof (window.console) === "undefined") { - return; - } - m = "[" + new Date().toTimeString() + "] SignalR: " + msg; - if (window.console.debug) { - window.console.debug(m); - } else if (window.console.log) { - window.console.log(m); - } - }, - - changeState = function (connection, expectedState, newState) { - if (expectedState === connection.state) { - connection.state = newState; - - $(connection).triggerHandler(events.onStateChanged, [{ oldState: expectedState, newState: newState }]); - return true; - } - - return false; - }, - - isDisconnecting = function (connection) { - return connection.state === signalR.connectionState.disconnected; - }, - - configureStopReconnectingTimeout = function (connection) { - var stopReconnectingTimeout, - onReconnectTimeout; - - // Check if this connection has already been configured to stop reconnecting after a specified timeout. - // Without this check if a connection is stopped then started events will be bound multiple times. - if (!connection._.configuredStopReconnectingTimeout) { - onReconnectTimeout = function (connection) { - connection.log("Couldn't reconnect within the configured timeout (" + connection.disconnectTimeout + "ms), disconnecting."); - connection.stop(/* async */ false, /* notifyServer */ false); - }; - - connection.reconnecting(function () { - var connection = this; - - // Guard against state changing in a previous user defined even handler - if (connection.state === signalR.connectionState.reconnecting) { - stopReconnectingTimeout = window.setTimeout(function () { onReconnectTimeout(connection); }, connection.disconnectTimeout); - } - }); - - connection.stateChanged(function (data) { - if (data.oldState === signalR.connectionState.reconnecting) { - // Clear the pending reconnect timeout check - window.clearTimeout(stopReconnectingTimeout); - } - }); - - connection._.configuredStopReconnectingTimeout = true; - } - }; - - signalR = function (url, qs, logging) { - /// <summary>Creates a new SignalR connection for the given url</summary> - /// <param name="url" type="String">The URL of the long polling endpoint</param> - /// <param name="qs" type="Object"> - /// [Optional] Custom querystring parameters to add to the connection URL. - /// If an object, every non-function member will be added to the querystring. - /// If a string, it's added to the QS as specified. - /// </param> - /// <param name="logging" type="Boolean"> - /// [Optional] A flag indicating whether connection logging is enabled to the browser - /// console/log. Defaults to false. - /// </param> - - return new signalR.fn.init(url, qs, logging); - }; - - signalR._ = { - defaultContentType: "application/x-www-form-urlencoded; charset=UTF-8", - ieVersion: (function () { - var version, - matches; - - if (window.navigator.appName === 'Microsoft Internet Explorer') { - // Check if the user agent has the pattern "MSIE (one or more numbers).(one or more numbers)"; - matches = /MSIE ([0-9]+\.[0-9]+)/.exec(window.navigator.userAgent); - - if (matches) { - version = window.parseFloat(matches[1]); - } - } - - // undefined value means not IE - return version; - })() - }; - - signalR.events = events; - - signalR.changeState = changeState; - - signalR.isDisconnecting = isDisconnecting; - - signalR.connectionState = { - connecting: 0, - connected: 1, - reconnecting: 2, - disconnected: 4 - }; - - signalR.hub = { - start: function () { - // This will get replaced with the real hub connection start method when hubs is referenced correctly - throw new Error("SignalR: Error loading hubs. Ensure your hubs reference is correct, e.g. <script src='/signalr/hubs'></script>."); - } - }; - - _pageWindow.load(function () { _pageLoaded = true; }); - - function validateTransport(requestedTransport, connection) { - /// <summary>Validates the requested transport by cross checking it with the pre-defined signalR.transports</summary> - /// <param name="requestedTransport" type="Object">The designated transports that the user has specified.</param> - /// <param name="connection" type="signalR">The connection that will be using the requested transports. Used for logging purposes.</param> - /// <returns type="Object" /> - - if ($.isArray(requestedTransport)) { - // Go through transport array and remove an "invalid" tranports - for (var i = requestedTransport.length - 1; i >= 0; i--) { - var transport = requestedTransport[i]; - if ($.type(requestedTransport) !== "object" && ($.type(transport) !== "string" || !signalR.transports[transport])) { - connection.log("Invalid transport: " + transport + ", removing it from the transports list."); - requestedTransport.splice(i, 1); - } - } - - // Verify we still have transports left, if we dont then we have invalid transports - if (requestedTransport.length === 0) { - connection.log("No transports remain within the specified transport array."); - requestedTransport = null; - } - } else if ($.type(requestedTransport) !== "object" && !signalR.transports[requestedTransport] && requestedTransport !== "auto") { - connection.log("Invalid transport: " + requestedTransport.toString()); - requestedTransport = null; - } - else if (requestedTransport === "auto" && signalR._.ieVersion <= 8) - { - // If we're doing an auto transport and we're IE8 then force longPolling, #1764 - return ["longPolling"]; - - } - - return requestedTransport; - } - - function getDefaultPort(protocol) { - if(protocol === "http:") { - return 80; - } - else if (protocol === "https:") { - return 443; - } - } - - function addDefaultPort(protocol, url) { - // Remove ports from url. We have to check if there's a / or end of line - // following the port in order to avoid removing ports such as 8080. - if(url.match(/:\d+$/)) { - return url; - } else { - return url + ":" + getDefaultPort(protocol); - } - } - - signalR.fn = signalR.prototype = { - init: function (url, qs, logging) { - this.url = url; - this.qs = qs; - this._ = {}; - if (typeof (logging) === "boolean") { - this.logging = logging; - } - }, - - isCrossDomain: function (url, against) { - /// <summary>Checks if url is cross domain</summary> - /// <param name="url" type="String">The base URL</param> - /// <param name="against" type="Object"> - /// An optional argument to compare the URL against, if not specified it will be set to window.location. - /// If specified it must contain a protocol and a host property. - /// </param> - var link; - - url = $.trim(url); - if (url.indexOf("http") !== 0) { - return false; - } - - against = against || window.location; - - // Create an anchor tag. - link = window.document.createElement("a"); - link.href = url; - - // When checking for cross domain we have to special case port 80 because the window.location will remove the - return link.protocol + addDefaultPort(link.protocol, link.host) !== against.protocol + addDefaultPort(against.protocol, against.host); - }, - - ajaxDataType: "json", - - contentType: "application/json; charset=UTF-8", - - logging: false, - - state: signalR.connectionState.disconnected, - - keepAliveData: {}, - - reconnectDelay: 2000, - - disconnectTimeout: 30000, // This should be set by the server in response to the negotiate request (30s default) - - keepAliveWarnAt: 2 / 3, // Warn user of slow connection if we breach the X% mark of the keep alive timeout - - start: function (options, callback) { - /// <summary>Starts the connection</summary> - /// <param name="options" type="Object">Options map</param> - /// <param name="callback" type="Function">A callback function to execute when the connection has started</param> - var connection = this, - config = { - waitForPageLoad: true, - transport: "auto", - jsonp: false - }, - initialize, - deferred = connection._deferral || $.Deferred(), // Check to see if there is a pre-existing deferral that's being built on, if so we want to keep using it - parser = window.document.createElement("a"); - - if ($.type(options) === "function") { - // Support calling with single callback parameter - callback = options; - } else if ($.type(options) === "object") { - $.extend(config, options); - if ($.type(config.callback) === "function") { - callback = config.callback; - } - } - - config.transport = validateTransport(config.transport, connection); - - // If the transport is invalid throw an error and abort start - if (!config.transport) { - throw new Error("SignalR: Invalid transport(s) specified, aborting start."); - } - - // Check to see if start is being called prior to page load - // If waitForPageLoad is true we then want to re-direct function call to the window load event - if (!_pageLoaded && config.waitForPageLoad === true) { - _pageWindow.load(function () { - connection._deferral = deferred; - connection.start(options, callback); - }); - return deferred.promise(); - } - - configureStopReconnectingTimeout(connection); - - if (changeState(connection, - signalR.connectionState.disconnected, - signalR.connectionState.connecting) === false) { - // Already started, just return - deferred.resolve(connection); - return deferred.promise(); - } - - // Resolve the full url - parser.href = connection.url; - if (!parser.protocol || parser.protocol === ":") { - connection.protocol = window.document.location.protocol; - connection.host = window.document.location.host; - connection.baseUrl = connection.protocol + "//" + connection.host; - } - else { - connection.protocol = parser.protocol; - connection.host = parser.host; - connection.baseUrl = parser.protocol + "//" + parser.host; - } - - // Set the websocket protocol - connection.wsProtocol = connection.protocol === "https:" ? "wss://" : "ws://"; - - // If jsonp with no/auto transport is specified, then set the transport to long polling - // since that is the only transport for which jsonp really makes sense. - // Some developers might actually choose to specify jsonp for same origin requests - // as demonstrated by Issue #623. - if (config.transport === "auto" && config.jsonp === true) { - config.transport = "longPolling"; - } - - if (this.isCrossDomain(connection.url)) { - connection.log("Auto detected cross domain url."); - - if (config.transport === "auto") { - // Try webSockets and longPolling since SSE doesn't support CORS - // TODO: Support XDM with foreverFrame - config.transport = ["webSockets", "longPolling"]; - } - - // Determine if jsonp is the only choice for negotiation, ajaxSend and ajaxAbort. - // i.e. if the browser doesn't supports CORS - // If it is, ignore any preference to the contrary, and switch to jsonp. - if (!config.jsonp) { - config.jsonp = !$.support.cors; - - if (config.jsonp) { - connection.log("Using jsonp because this browser doesn't support CORS"); - } - } - - connection.contentType = signalR._.defaultContentType; - } - - connection.ajaxDataType = config.jsonp ? "jsonp" : "json"; - - $(connection).bind(events.onStart, function (e, data) { - if ($.type(callback) === "function") { - callback.call(connection); - } - deferred.resolve(connection); - }); - - initialize = function (transports, index) { - index = index || 0; - if (index >= transports.length) { - if (!connection.transport) { - // No transport initialized successfully - $(connection).triggerHandler(events.onError, ["SignalR: No transport could be initialized successfully. Try specifying a different transport or none at all for auto initialization."]); - deferred.reject("SignalR: No transport could be initialized successfully. Try specifying a different transport or none at all for auto initialization."); - // Stop the connection if it has connected and move it into the disconnected state - connection.stop(); - } - return; - } - - var transportName = transports[index], - transport = $.type(transportName) === "object" ? transportName : signalR.transports[transportName]; - - if (transportName.indexOf("_") === 0) { - // Private member - initialize(transports, index + 1); - return; - } - - transport.start(connection, function () { // success - if (transport.supportsKeepAlive && connection.keepAliveData.activated) { - signalR.transports._logic.monitorKeepAlive(connection); - } - - connection.transport = transport; - - changeState(connection, - signalR.connectionState.connecting, - signalR.connectionState.connected); - - $(connection).triggerHandler(events.onStart); - - _pageWindow.unload(function () { // failure - connection.stop(false /* async */); - }); - - }, function () { - initialize(transports, index + 1); - }); - }; - - var url = connection.url + "/negotiate"; - - url = signalR.transports._logic.addQs(url, connection); - - connection.log("Negotiating with '" + url + "'."); - $.ajax({ - url: url, - global: true, - cache: false, - type: "GET", - contentType: connection.contentType, - data: {}, - dataType: connection.ajaxDataType, - error: function (error) { - $(connection).triggerHandler(events.onError, [error.responseText]); - deferred.reject("SignalR: Error during negotiation request: " + error.responseText); - // Stop the connection if negotiate failed - connection.stop(); - }, - success: function (res) { - var keepAliveData = connection.keepAliveData; - - connection.appRelativeUrl = res.Url; - connection.id = res.ConnectionId; - connection.token = res.ConnectionToken; - connection.webSocketServerUrl = res.WebSocketServerUrl; - - // Once the server has labeled the PersistentConnection as Disconnected, we should stop attempting to reconnect - // after res.DisconnectTimeout seconds. - connection.disconnectTimeout = res.DisconnectTimeout * 1000; // in ms - - - // If we have a keep alive - if (res.KeepAliveTimeout) { - // Register the keep alive data as activated - keepAliveData.activated = true; - - // Timeout to designate when to force the connection into reconnecting converted to milliseconds - keepAliveData.timeout = res.KeepAliveTimeout * 1000; - - // Timeout to designate when to warn the developer that the connection may be dead or is not responding. - keepAliveData.timeoutWarning = keepAliveData.timeout * connection.keepAliveWarnAt; - - // Instantiate the frequency in which we check the keep alive. It must be short in order to not miss/pick up any changes - keepAliveData.checkInterval = (keepAliveData.timeout - keepAliveData.timeoutWarning) / 3; - } - else { - keepAliveData.activated = false; - } - - if (!res.ProtocolVersion || res.ProtocolVersion !== "1.2") { - $(connection).triggerHandler(events.onError, ["You are using a version of the client that isn't compatible with the server. Client version 1.2, server version " + res.ProtocolVersion + "."]); - deferred.reject("You are using a version of the client that isn't compatible with the server. Client version 1.2, server version " + res.ProtocolVersion + "."); - return; - } - - $(connection).triggerHandler(events.onStarting); - - var transports = [], - supportedTransports = []; - - $.each(signalR.transports, function (key) { - if (key === "webSockets" && !res.TryWebSockets) { - // Server said don't even try WebSockets, but keep processing the loop - return true; - } - supportedTransports.push(key); - }); - - if ($.isArray(config.transport)) { - // ordered list provided - $.each(config.transport, function () { - var transport = this; - if ($.type(transport) === "object" || ($.type(transport) === "string" && $.inArray("" + transport, supportedTransports) >= 0)) { - transports.push($.type(transport) === "string" ? "" + transport : transport); - } - }); - } else if ($.type(config.transport) === "object" || - $.inArray(config.transport, supportedTransports) >= 0) { - // specific transport provided, as object or a named transport, e.g. "longPolling" - transports.push(config.transport); - } else { // default "auto" - transports = supportedTransports; - } - initialize(transports); - } - }); - - return deferred.promise(); - }, - - starting: function (callback) { - /// <summary>Adds a callback that will be invoked before anything is sent over the connection</summary> - /// <param name="callback" type="Function">A callback function to execute before each time data is sent on the connection</param> - /// <returns type="signalR" /> - var connection = this; - $(connection).bind(events.onStarting, function (e, data) { - callback.call(connection); - }); - return connection; - }, - - send: function (data) { - /// <summary>Sends data over the connection</summary> - /// <param name="data" type="String">The data to send over the connection</param> - /// <returns type="signalR" /> - var connection = this; - - if (connection.state === signalR.connectionState.disconnected) { - // Connection hasn't been started yet - throw new Error("SignalR: Connection must be started before data can be sent. Call .start() before .send()"); - } - - if (connection.state === signalR.connectionState.connecting) { - // Connection hasn't been started yet - throw new Error("SignalR: Connection has not been fully initialized. Use .start().done() or .start().fail() to run logic after the connection has started."); - } - - connection.transport.send(connection, data); - // REVIEW: Should we return deferred here? - return connection; - }, - - received: function (callback) { - /// <summary>Adds a callback that will be invoked after anything is received over the connection</summary> - /// <param name="callback" type="Function">A callback function to execute when any data is received on the connection</param> - /// <returns type="signalR" /> - var connection = this; - $(connection).bind(events.onReceived, function (e, data) { - callback.call(connection, data); - }); - return connection; - }, - - stateChanged: function (callback) { - /// <summary>Adds a callback that will be invoked when the connection state changes</summary> - /// <param name="callback" type="Function">A callback function to execute when the connection state changes</param> - /// <returns type="signalR" /> - var connection = this; - $(connection).bind(events.onStateChanged, function (e, data) { - callback.call(connection, data); - }); - return connection; - }, - - error: function (callback) { - /// <summary>Adds a callback that will be invoked after an error occurs with the connection</summary> - /// <param name="callback" type="Function">A callback function to execute when an error occurs on the connection</param> - /// <returns type="signalR" /> - var connection = this; - $(connection).bind(events.onError, function (e, data) { - callback.call(connection, data); - }); - return connection; - }, - - disconnected: function (callback) { - /// <summary>Adds a callback that will be invoked when the client disconnects</summary> - /// <param name="callback" type="Function">A callback function to execute when the connection is broken</param> - /// <returns type="signalR" /> - var connection = this; - $(connection).bind(events.onDisconnect, function (e, data) { - callback.call(connection); - }); - return connection; - }, - - connectionSlow: function (callback) { - /// <summary>Adds a callback that will be invoked when the client detects a slow connection</summary> - /// <param name="callback" type="Function">A callback function to execute when the connection is slow</param> - /// <returns type="signalR" /> - var connection = this; - $(connection).bind(events.onConnectionSlow, function(e, data) { - callback.call(connection); - }); - - return connection; - }, - - reconnecting: function (callback) { - /// <summary>Adds a callback that will be invoked when the underlying transport begins reconnecting</summary> - /// <param name="callback" type="Function">A callback function to execute when the connection enters a reconnecting state</param> - /// <returns type="signalR" /> - var connection = this; - $(connection).bind(events.onReconnecting, function (e, data) { - callback.call(connection); - }); - return connection; - }, - - reconnected: function (callback) { - /// <summary>Adds a callback that will be invoked when the underlying transport reconnects</summary> - /// <param name="callback" type="Function">A callback function to execute when the connection is restored</param> - /// <returns type="signalR" /> - var connection = this; - $(connection).bind(events.onReconnect, function (e, data) { - callback.call(connection); - }); - return connection; - }, - - stop: function (async, notifyServer) { - /// <summary>Stops listening</summary> - /// <param name="async" type="Boolean">Whether or not to asynchronously abort the connection</param> - /// <param name="notifyServer" type="Boolean">Whether we want to notify the server that we are aborting the connection</param> - /// <returns type="signalR" /> - var connection = this; - - if (connection.state === signalR.connectionState.disconnected) { - return; - } - - try { - if (connection.transport) { - if (notifyServer !== false) { - connection.transport.abort(connection, async); - } - - if (connection.transport.supportsKeepAlive && connection.keepAliveData.activated) { - signalR.transports._logic.stopMonitoringKeepAlive(connection); - } - - connection.transport.stop(connection); - connection.transport = null; - } - - // Trigger the disconnect event - $(connection).triggerHandler(events.onDisconnect); - - delete connection.messageId; - delete connection.groupsToken; - - // Remove the ID and the deferral on stop, this is to ensure that if a connection is restarted it takes on a new id/deferral. - delete connection.id; - delete connection._deferral; - } - finally { - changeState(connection, connection.state, signalR.connectionState.disconnected); - } - - return connection; - }, - - log: function (msg) { - log(msg, this.logging); - } - }; - - signalR.fn.init.prototype = signalR.fn; - - signalR.noConflict = function () { - /// <summary>Reinstates the original value of $.connection and returns the signalR object for manual assignment</summary> - /// <returns type="signalR" /> - if ($.connection === signalR) { - $.connection = _connection; - } - return signalR; - }; - - if ($.connection) { - _connection = $.connection; - } - - $.connection = $.signalR = signalR; - -}(window.jQuery, window)); -/* jquery.signalR.transports.common.js */ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -/*global window:false */ -/// <reference path="jquery.signalR.core.js" /> - -(function ($, window) { - "use strict"; - - var signalR = $.signalR, - events = $.signalR.events, - changeState = $.signalR.changeState; - - signalR.transports = {}; - - function checkIfAlive(connection) { - var keepAliveData = connection.keepAliveData, - diff, - timeElapsed; - - // Only check if we're connected - if (connection.state === signalR.connectionState.connected) { - diff = new Date(); - - diff.setTime(diff - keepAliveData.lastKeepAlive); - timeElapsed = diff.getTime(); - - // Check if the keep alive has completely timed out - if (timeElapsed >= keepAliveData.timeout) { - connection.log("Keep alive timed out. Notifying transport that connection has been lost."); - - // Notify transport that the connection has been lost - connection.transport.lostConnection(connection); - } - else if (timeElapsed >= keepAliveData.timeoutWarning) { - // This is to assure that the user only gets a single warning - if (!keepAliveData.userNotified) { - connection.log("Keep alive has been missed, connection may be dead/slow."); - $(connection).triggerHandler(events.onConnectionSlow); - keepAliveData.userNotified = true; - } - } - else { - keepAliveData.userNotified = false; - } - } - - // Verify we're monitoring the keep alive - // We don't want this as a part of the inner if statement above because we want keep alives to continue to be checked - // in the event that the server comes back online (if it goes offline). - if (keepAliveData.monitoring) { - window.setTimeout(function () { - checkIfAlive(connection); - }, keepAliveData.checkInterval); - } - } - - function isConnectedOrReconnecting(connection) { - return connection.state === signalR.connectionState.connected || - connection.state === signalR.connectionState.reconnecting; - } - - signalR.transports._logic = { - pingServer: function (connection, transport) { - /// <summary>Pings the server</summary> - /// <param name="connection" type="signalr">Connection associated with the server ping</param> - /// <returns type="signalR" /> - var baseUrl = transport === "webSockets" ? "" : connection.baseUrl, - url = baseUrl + connection.appRelativeUrl + "/ping", - deferral = $.Deferred(); - - url = this.addQs(url, connection); - - $.ajax({ - url: url, - global: true, - cache: false, - type: "GET", - contentType: connection.contentType, - data: {}, - dataType: connection.ajaxDataType, - success: function (data) { - if (data.Response === "pong") { - deferral.resolve(); - } - else { - deferral.reject("SignalR: Invalid ping response when pinging server: " + (data.responseText || data.statusText)); - } - }, - error: function (data) { - deferral.reject("SignalR: Error pinging server: " + (data.responseText || data.statusText)); - } - }); - - return deferral.promise(); - }, - - addQs: function (url, connection) { - var appender = url.indexOf("?") !== -1 ? "&" : "?", - firstChar; - - if (!connection.qs) { - return url; - } - - if (typeof (connection.qs) === "object") { - return url + appender + $.param(connection.qs); - } - - if (typeof (connection.qs) === "string") { - firstChar = connection.qs.charAt(0); - - if (firstChar === "?" || firstChar === "&") { - appender = ""; - } - - return url + appender + connection.qs; - } - - throw new Error("Connections query string property must be either a string or object."); - }, - - getUrl: function (connection, transport, reconnecting, poll) { - /// <summary>Gets the url for making a GET based connect request</summary> - var baseUrl = transport === "webSockets" ? "" : connection.baseUrl, - url = baseUrl + connection.appRelativeUrl, - qs = "transport=" + transport + "&connectionToken=" + window.encodeURIComponent(connection.token); - - if (connection.data) { - qs += "&connectionData=" + window.encodeURIComponent(connection.data); - } - - if (connection.groupsToken) { - qs += "&groupsToken=" + window.encodeURIComponent(connection.groupsToken); - } - - if (!reconnecting) { - url += "/connect"; - } else { - if (poll) { - // longPolling transport specific - url += "/poll"; - } else { - url += "/reconnect"; - } - - if (connection.messageId) { - qs += "&messageId=" + window.encodeURIComponent(connection.messageId); - } - } - url += "?" + qs; - url = this.addQs(url, connection); - url += "&tid=" + Math.floor(Math.random() * 11); - return url; - }, - - maximizePersistentResponse: function (minPersistentResponse) { - return { - MessageId: minPersistentResponse.C, - Messages: minPersistentResponse.M, - Disconnect: typeof (minPersistentResponse.D) !== "undefined" ? true : false, - TimedOut: typeof (minPersistentResponse.T) !== "undefined" ? true : false, - LongPollDelay: minPersistentResponse.L, - GroupsToken: minPersistentResponse.G - }; - }, - - updateGroups: function (connection, groupsToken) { - if (groupsToken) { - connection.groupsToken = groupsToken; - } - }, - - ajaxSend: function (connection, data) { - var url = connection.url + "/send" + "?transport=" + connection.transport.name + "&connectionToken=" + window.encodeURIComponent(connection.token); - url = this.addQs(url, connection); - return $.ajax({ - url: url, - global: true, - type: connection.ajaxDataType === "jsonp" ? "GET" : "POST", - contentType: signalR._.defaultContentType, - dataType: connection.ajaxDataType, - data: { - data: data - }, - success: function (result) { - if (result) { - $(connection).triggerHandler(events.onReceived, [result]); - } - }, - error: function (errData, textStatus) { - if (textStatus === "abort" || textStatus === "parsererror") { - // The parsererror happens for sends that don't return any data, and hence - // don't write the jsonp callback to the response. This is harder to fix on the server - // so just hack around it on the client for now. - return; - } - $(connection).triggerHandler(events.onError, [errData, data]); - } - }); - }, - - ajaxAbort: function (connection, async) { - if (typeof (connection.transport) === "undefined") { - return; - } - - // Async by default unless explicitly overidden - async = typeof async === "undefined" ? true : async; - - var url = connection.url + "/abort" + "?transport=" + connection.transport.name + "&connectionToken=" + window.encodeURIComponent(connection.token); - url = this.addQs(url, connection); - $.ajax({ - url: url, - async: async, - timeout: 1000, - global: true, - type: "POST", - contentType: connection.contentType, - dataType: connection.ajaxDataType, - data: {} - }); - - connection.log("Fired ajax abort async = " + async); - }, - - processMessages: function (connection, minData) { - var data; - // Transport can be null if we've just closed the connection - if (connection.transport) { - var $connection = $(connection); - - // If our transport supports keep alive then we need to update the last keep alive time stamp. - // Very rarely the transport can be null. - if (connection.transport.supportsKeepAlive && connection.keepAliveData.activated) { - this.updateKeepAlive(connection); - } - - if (!minData) { - return; - } - - data = this.maximizePersistentResponse(minData); - - if (data.Disconnect) { - connection.log("Disconnect command received from server"); - - // Disconnected by the server - connection.stop(false, false); - return; - } - - this.updateGroups(connection, data.GroupsToken); - - if (data.Messages) { - $.each(data.Messages, function (index, message) { - $connection.triggerHandler(events.onReceived, [message]); - }); - } - - if (data.MessageId) { - connection.messageId = data.MessageId; - } - } - }, - - monitorKeepAlive: function (connection) { - var keepAliveData = connection.keepAliveData, - that = this; - - // If we haven't initiated the keep alive timeouts then we need to - if (!keepAliveData.monitoring) { - keepAliveData.monitoring = true; - - // Initialize the keep alive time stamp ping - that.updateKeepAlive(connection); - - // Save the function so we can unbind it on stop - connection.keepAliveData.reconnectKeepAliveUpdate = function () { - that.updateKeepAlive(connection); - }; - - // Update Keep alive on reconnect - $(connection).bind(events.onReconnect, connection.keepAliveData.reconnectKeepAliveUpdate); - - connection.log("Now monitoring keep alive with a warning timeout of " + keepAliveData.timeoutWarning + " and a connection lost timeout of " + keepAliveData.timeout); - // Start the monitoring of the keep alive - checkIfAlive(connection); - } - else { - connection.log("Tried to monitor keep alive but it's already being monitored"); - } - }, - - stopMonitoringKeepAlive: function (connection) { - var keepAliveData = connection.keepAliveData; - - // Only attempt to stop the keep alive monitoring if its being monitored - if (keepAliveData.monitoring) { - // Stop monitoring - keepAliveData.monitoring = false; - - // Remove the updateKeepAlive function from the reconnect event - $(connection).unbind(events.onReconnect, connection.keepAliveData.reconnectKeepAliveUpdate); - - // Clear all the keep alive data - connection.keepAliveData = {}; - connection.log("Stopping the monitoring of the keep alive"); - } - }, - - updateKeepAlive: function (connection) { - connection.keepAliveData.lastKeepAlive = new Date(); - }, - - ensureReconnectingState: function (connection) { - if (changeState(connection, - signalR.connectionState.connected, - signalR.connectionState.reconnecting) === true) { - $(connection).triggerHandler(events.onReconnecting); - } - return connection.state === signalR.connectionState.reconnecting; - }, - - clearReconnectTimeout: function (connection) { - if (connection && connection._.reconnectTimeout) { - window.clearTimeout(connection._.reconnectTimeout); - delete connection._.reconnectTimeout; - } - }, - - reconnect: function (connection, transportName) { - var transport = signalR.transports[transportName], - that = this; - - // We should only set a reconnectTimeout if we are currently connected - // and a reconnectTimeout isn't already set. - if (isConnectedOrReconnecting(connection) && !connection._.reconnectTimeout) { - - connection._.reconnectTimeout = window.setTimeout(function () { - transport.stop(connection); - - if (that.ensureReconnectingState(connection)) { - connection.log(transportName + " reconnecting"); - transport.start(connection); - } - }, connection.reconnectDelay); - } - }, - - foreverFrame: { - count: 0, - connections: {} - } - }; - -}(window.jQuery, window)); -/* jquery.signalR.transports.webSockets.js */ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -/*global window:false */ -/// <reference path="jquery.signalR.transports.common.js" /> - -(function ($, window) { - "use strict"; - - var signalR = $.signalR, - events = $.signalR.events, - changeState = $.signalR.changeState, - transportLogic = signalR.transports._logic; - - signalR.transports.webSockets = { - name: "webSockets", - - supportsKeepAlive: true, - - send: function (connection, data) { - connection.socket.send(data); - }, - - start: function (connection, onSuccess, onFailed) { - var url, - opened = false, - that = this, - reconnecting = !onSuccess, - $connection = $(connection); - - if (!window.WebSocket) { - onFailed(); - return; - } - - if (!connection.socket) { - if (connection.webSocketServerUrl) { - url = connection.webSocketServerUrl; - } - else { - url = connection.wsProtocol + connection.host; - } - - url += transportLogic.getUrl(connection, this.name, reconnecting); - - connection.log("Connecting to websocket endpoint '" + url + "'"); - connection.socket = new window.WebSocket(url); - connection.socket.onopen = function () { - opened = true; - connection.log("Websocket opened"); - - transportLogic.clearReconnectTimeout(connection); - - if (onSuccess) { - onSuccess(); - } else if (changeState(connection, - signalR.connectionState.reconnecting, - signalR.connectionState.connected) === true) { - $connection.triggerHandler(events.onReconnect); - } - }; - - connection.socket.onclose = function (event) { - // Only handle a socket close if the close is from the current socket. - // Sometimes on disconnect the server will push down an onclose event - // to an expired socket. - if (this === connection.socket) { - if (!opened) { - if (onFailed) { - onFailed(); - } - else if (reconnecting) { - that.reconnect(connection); - } - return; - } - else if (typeof event.wasClean !== "undefined" && event.wasClean === false) { - // Ideally this would use the websocket.onerror handler (rather than checking wasClean in onclose) but - // I found in some circumstances Chrome won't call onerror. This implementation seems to work on all browsers. - $(connection).triggerHandler(events.onError, [event.reason]); - connection.log("Unclean disconnect from websocket." + event.reason); - } - else { - connection.log("Websocket closed"); - } - - that.reconnect(connection); - } - }; - - connection.socket.onmessage = function (event) { - var data = window.JSON.parse(event.data), - $connection = $(connection); - - if (data) { - // data.M is PersistentResponse.Messages - if ($.isEmptyObject(data) || data.M) { - transportLogic.processMessages(connection, data); - } else { - // For websockets we need to trigger onReceived - // for callbacks to outgoing hub calls. - $connection.triggerHandler(events.onReceived, [data]); - } - } - }; - } - }, - - reconnect: function (connection) { - transportLogic.reconnect(connection, this.name); - }, - - lostConnection: function (connection) { - this.reconnect(connection); - - }, - - stop: function (connection) { - // Don't trigger a reconnect after stopping - transportLogic.clearReconnectTimeout(connection); - - if (connection.socket !== null) { - connection.log("Closing the Websocket"); - connection.socket.close(); - connection.socket = null; - } - }, - - abort: function (connection) { - } - }; - -}(window.jQuery, window)); -/* jquery.signalR.transports.serverSentEvents.js */ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -/*global window:false */ -/// <reference path="jquery.signalR.transports.common.js" /> - -(function ($, window) { - "use strict"; - - var signalR = $.signalR, - events = $.signalR.events, - changeState = $.signalR.changeState, - transportLogic = signalR.transports._logic; - - signalR.transports.serverSentEvents = { - name: "serverSentEvents", - - supportsKeepAlive: true, - - timeOut: 3000, - - start: function (connection, onSuccess, onFailed) { - var that = this, - opened = false, - $connection = $(connection), - reconnecting = !onSuccess, - url, - connectTimeOut; - - if (connection.eventSource) { - connection.log("The connection already has an event source. Stopping it."); - connection.stop(); - } - - if (!window.EventSource) { - if (onFailed) { - connection.log("This browser doesn't support SSE."); - onFailed(); - } - return; - } - - url = transportLogic.getUrl(connection, this.name, reconnecting); - - try { - connection.log("Attempting to connect to SSE endpoint '" + url + "'"); - connection.eventSource = new window.EventSource(url); - } - catch (e) { - connection.log("EventSource failed trying to connect with error " + e.Message); - if (onFailed) { - // The connection failed, call the failed callback - onFailed(); - } - else { - $connection.triggerHandler(events.onError, [e]); - if (reconnecting) { - // If we were reconnecting, rather than doing initial connect, then try reconnect again - that.reconnect(connection); - } - } - return; - } - - // After connecting, if after the specified timeout there's no response stop the connection - // and raise on failed - connectTimeOut = window.setTimeout(function () { - if (opened === false) { - connection.log("EventSource timed out trying to connect"); - connection.log("EventSource readyState: " + connection.eventSource.readyState); - - if (!reconnecting) { - that.stop(connection); - } - - if (reconnecting) { - // If we're reconnecting and the event source is attempting to connect, - // don't keep retrying. This causes duplicate connections to spawn. - if (connection.eventSource.readyState !== window.EventSource.CONNECTING && - connection.eventSource.readyState !== window.EventSource.OPEN) { - // If we were reconnecting, rather than doing initial connect, then try reconnect again - that.reconnect(connection); - } - } else if (onFailed) { - onFailed(); - } - } - }, - that.timeOut); - - connection.eventSource.addEventListener("open", function (e) { - connection.log("EventSource connected"); - - if (connectTimeOut) { - window.clearTimeout(connectTimeOut); - } - - transportLogic.clearReconnectTimeout(connection); - - if (opened === false) { - opened = true; - - if (onSuccess) { - onSuccess(); - } else if (changeState(connection, - signalR.connectionState.reconnecting, - signalR.connectionState.connected) === true) { - // If there's no onSuccess handler we assume this is a reconnect - $connection.triggerHandler(events.onReconnect); - } - } - }, false); - - connection.eventSource.addEventListener("message", function (e) { - // process messages - if (e.data === "initialized") { - return; - } - - transportLogic.processMessages(connection, window.JSON.parse(e.data)); - }, false); - - connection.eventSource.addEventListener("error", function (e) { - // Only handle an error if the error is from the current Event Source. - // Sometimes on disconnect the server will push down an error event - // to an expired Event Source. - if (this === connection.eventSource) { - if (!opened) { - if (onFailed) { - onFailed(); - } - - return; - } - - connection.log("EventSource readyState: " + connection.eventSource.readyState); - - if (e.eventPhase === window.EventSource.CLOSED) { - // We don't use the EventSource's native reconnect function as it - // doesn't allow us to change the URL when reconnecting. We need - // to change the URL to not include the /connect suffix, and pass - // the last message id we received. - connection.log("EventSource reconnecting due to the server connection ending"); - that.reconnect(connection); - } else { - // connection error - connection.log("EventSource error"); - $connection.triggerHandler(events.onError); - } - } - }, false); - }, - - reconnect: function (connection) { - transportLogic.reconnect(connection, this.name); - }, - - lostConnection: function (connection) { - this.reconnect(connection); - }, - - send: function (connection, data) { - transportLogic.ajaxSend(connection, data); - }, - - stop: function (connection) { - // Don't trigger a reconnect after stopping - transportLogic.clearReconnectTimeout(connection); - - if (connection && connection.eventSource) { - connection.log("EventSource calling close()"); - connection.eventSource.close(); - connection.eventSource = null; - delete connection.eventSource; - } - }, - - abort: function (connection, async) { - transportLogic.ajaxAbort(connection, async); - } - }; - -}(window.jQuery, window)); -/* jquery.signalR.transports.foreverFrame.js */ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -/*global window:false */ -/// <reference path="jquery.signalR.transports.common.js" /> - -(function ($, window) { - "use strict"; - - var signalR = $.signalR, - events = $.signalR.events, - changeState = $.signalR.changeState, - transportLogic = signalR.transports._logic, - // Used to prevent infinite loading icon spins in older versions of ie - // We build this object inside a closure so we don't pollute the rest of - // the foreverFrame transport with unnecessary functions/utilities. - loadPreventer = (function () { - var loadingFixIntervalId = null, - loadingFixInterval = 1000, - attachedTo = 0; - - return { - prevent: function () { - // Prevent additional iframe removal procedures from newer browsers - if (signalR._.ieVersion <= 8) { - // We only ever want to set the interval one time, so on the first attachedTo - if (attachedTo === 0) { - // Create and destroy iframe every 3 seconds to prevent loading icon, super hacky - loadingFixIntervalId = window.setInterval(function () { - var tempFrame = $("<iframe style='position:absolute;top:0;left:0;width:0;height:0;visibility:hidden;' src=''></iframe>"); - - $("body").append(tempFrame); - tempFrame.remove(); - tempFrame = null; - }, loadingFixInterval); - } - - attachedTo++; - } - }, - cancel: function () { - // Only clear the interval if there's only one more object that the loadPreventer is attachedTo - if (attachedTo === 1) { - window.clearInterval(loadingFixIntervalId); - } - - if (attachedTo > 0) { - attachedTo--; - } - } - }; - })(); - - signalR.transports.foreverFrame = { - name: "foreverFrame", - - supportsKeepAlive: true, - - timeOut: 3000, - - start: function (connection, onSuccess, onFailed) { - var that = this, - frameId = (transportLogic.foreverFrame.count += 1), - url, - frame = $("<iframe data-signalr-connection-id='" + connection.id + "' style='position:absolute;top:0;left:0;width:0;height:0;visibility:hidden;' src=''></iframe>"); - - if (window.EventSource) { - // If the browser supports SSE, don't use Forever Frame - if (onFailed) { - connection.log("This browser supports SSE, skipping Forever Frame."); - onFailed(); - } - return; - } - - // Start preventing loading icon - // This will only perform work if the loadPreventer is not attached to another connection. - loadPreventer.prevent(); - - // Build the url - url = transportLogic.getUrl(connection, this.name); - url += "&frameId=" + frameId; - - // Set body prior to setting URL to avoid caching issues. - $("body").append(frame); - - frame.prop("src", url); - transportLogic.foreverFrame.connections[frameId] = connection; - - connection.log("Binding to iframe's readystatechange event."); - frame.bind("readystatechange", function () { - if ($.inArray(this.readyState, ["loaded", "complete"]) >= 0) { - connection.log("Forever frame iframe readyState changed to " + this.readyState + ", reconnecting"); - - that.reconnect(connection); - } - }); - - connection.frame = frame[0]; - connection.frameId = frameId; - - if (onSuccess) { - connection.onSuccess = onSuccess; - } - - // After connecting, if after the specified timeout there's no response stop the connection - // and raise on failed - window.setTimeout(function () { - if (connection.onSuccess) { - connection.log("Failed to connect using forever frame source, it timed out after " + that.timeOut + "ms."); - that.stop(connection); - - if (onFailed) { - onFailed(); - } - } - }, that.timeOut); - }, - - reconnect: function (connection) { - var that = this; - window.setTimeout(function () { - if (connection.frame && transportLogic.ensureReconnectingState(connection)) { - var frame = connection.frame, - src = transportLogic.getUrl(connection, that.name, true) + "&frameId=" + connection.frameId; - connection.log("Updating iframe src to '" + src + "'."); - frame.src = src; - } - }, connection.reconnectDelay); - }, - - lostConnection: function (connection) { - this.reconnect(connection); - }, - - send: function (connection, data) { - transportLogic.ajaxSend(connection, data); - }, - - receive: function (connection, data) { - var cw; - - transportLogic.processMessages(connection, data); - // Delete the script & div elements - connection.frameMessageCount = (connection.frameMessageCount || 0) + 1; - if (connection.frameMessageCount > 50) { - connection.frameMessageCount = 0; - cw = connection.frame.contentWindow || connection.frame.contentDocument; - if (cw && cw.document) { - $("body", cw.document).empty(); - } - } - }, - - stop: function (connection) { - var cw = null; - - // Stop attempting to prevent loading icon - loadPreventer.cancel(); - - if (connection.frame) { - if (connection.frame.stop) { - connection.frame.stop(); - } else { - try { - cw = connection.frame.contentWindow || connection.frame.contentDocument; - if (cw.document && cw.document.execCommand) { - cw.document.execCommand("Stop"); - } - } - catch (e) { - connection.log("SignalR: Error occured when stopping foreverFrame transport. Message = " + e.message); - } - } - $(connection.frame).remove(); - delete transportLogic.foreverFrame.connections[connection.frameId]; - connection.frame = null; - connection.frameId = null; - delete connection.frame; - delete connection.frameId; - connection.log("Stopping forever frame"); - } - }, - - abort: function (connection, async) { - transportLogic.ajaxAbort(connection, async); - }, - - getConnection: function (id) { - return transportLogic.foreverFrame.connections[id]; - }, - - started: function (connection) { - if (connection.onSuccess) { - connection.onSuccess(); - connection.onSuccess = null; - delete connection.onSuccess; - } else if (changeState(connection, - signalR.connectionState.reconnecting, - signalR.connectionState.connected) === true) { - // If there's no onSuccess handler we assume this is a reconnect - $(connection).triggerHandler(events.onReconnect); - } - } - }; - -}(window.jQuery, window)); -/* jquery.signalR.transports.longPolling.js */ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -/*global window:false */ -/// <reference path="jquery.signalR.transports.common.js" /> - -(function ($, window) { - "use strict"; - - var signalR = $.signalR, - events = $.signalR.events, - changeState = $.signalR.changeState, - isDisconnecting = $.signalR.isDisconnecting, - transportLogic = signalR.transports._logic; - - signalR.transports.longPolling = { - name: "longPolling", - - supportsKeepAlive: false, - - reconnectDelay: 3000, - - init: function (connection, onComplete) { - /// <summary>Pings the server to ensure availability</summary> - /// <param name="connection" type="signalr">Connection associated with the server ping</param> - /// <param name="onComplete" type="Function">Callback to call once initialization has completed</param> - - var that = this, - pingLoop, - // pingFail is used to loop the re-ping behavior. When we fail we want to re-try. - pingFail = function (reason) { - if (isDisconnecting(connection) === false) { - connection.log("SignalR: Server ping failed because '" + reason + "', re-trying ping."); - window.setTimeout(pingLoop, that.reconnectDelay); - } - }; - - connection.log("SignalR: Initializing long polling connection with server."); - pingLoop = function () { - // Ping the server, on successful ping call the onComplete method, otherwise if we fail call the pingFail - transportLogic.pingServer(connection, that.name).done(onComplete).fail(pingFail); - }; - - pingLoop(); - }, - - start: function (connection, onSuccess, onFailed) { - /// <summary>Starts the long polling connection</summary> - /// <param name="connection" type="signalR">The SignalR connection to start</param> - var that = this, - initialConnectedFired = false, - fireConnect = function () { - if (initialConnectedFired) { - return; - } - initialConnectedFired = true; - onSuccess(); - connection.log("Longpolling connected"); - }, - reconnectErrors = 0, - reconnectTimeoutId = null, - fireReconnected = function (instance) { - window.clearTimeout(reconnectTimeoutId); - reconnectTimeoutId = null; - - if (changeState(connection, - signalR.connectionState.reconnecting, - signalR.connectionState.connected) === true) { - // Successfully reconnected! - connection.log("Raising the reconnect event"); - $(instance).triggerHandler(events.onReconnect); - } - }, - // 1 hour - maxFireReconnectedTimeout = 3600000; - - if (connection.pollXhr) { - connection.log("Polling xhr requests already exists, aborting."); - connection.stop(); - } - - // We start with an initialization procedure which pings the server to verify that it is there. - // On scucessful initialization we'll then proceed with starting the transport. - that.init(connection, function () { - connection.messageId = null; - - window.setTimeout(function () { - (function poll(instance, raiseReconnect) { - var messageId = instance.messageId, - connect = (messageId === null), - reconnecting = !connect, - polling = !raiseReconnect, - url = transportLogic.getUrl(instance, that.name, reconnecting, polling); - - // If we've disconnected during the time we've tried to re-instantiate the poll then stop. - if (isDisconnecting(instance) === true) { - return; - } - - connection.log("Attempting to connect to '" + url + "' using longPolling."); - instance.pollXhr = $.ajax({ - url: url, - global: true, - cache: false, - type: "GET", - dataType: connection.ajaxDataType, - contentType: connection.contentType, - success: function (minData) { - var delay = 0, - data; - - // Reset our reconnect errors so if we transition into a reconnecting state again we trigger - // reconnected quickly - reconnectErrors = 0; - - // If there's currently a timeout to trigger reconnect, fire it now before processing messages - if (reconnectTimeoutId !== null) { - fireReconnected(); - } - - fireConnect(); - - if (minData) { - data = transportLogic.maximizePersistentResponse(minData); - } - - transportLogic.processMessages(instance, minData); - - if (data && - $.type(data.LongPollDelay) === "number") { - delay = data.LongPollDelay; - } - - if (data && data.Disconnect) { - return; - } - - if (isDisconnecting(instance) === true) { - return; - } - - // We never want to pass a raiseReconnect flag after a successful poll. This is handled via the error function - if (delay > 0) { - window.setTimeout(function () { - poll(instance, false); - }, delay); - } else { - poll(instance, false); - } - }, - - error: function (data, textStatus) { - // Stop trying to trigger reconnect, connection is in an error state - // If we're not in the reconnect state this will noop - window.clearTimeout(reconnectTimeoutId); - reconnectTimeoutId = null; - - if (textStatus === "abort") { - connection.log("Aborted xhr requst."); - return; - } - - // Increment our reconnect errors, we assume all errors to be reconnect errors - // In the case that it's our first error this will cause Reconnect to be fired - // after 1 second due to reconnectErrors being = 1. - reconnectErrors++; - - if (connection.state !== signalR.connectionState.reconnecting) { - connection.log("An error occurred using longPolling. Status = " + textStatus + ". " + data.responseText); - $(instance).triggerHandler(events.onError, [data.responseText]); - } - - // Transition into the reconnecting state - transportLogic.ensureReconnectingState(instance); - - // If we've errored out we need to verify that the server is still there, so re-start initialization process - // This will ping the server until it successfully gets a response. - that.init(instance, function () { - // Call poll with the raiseReconnect flag as true - poll(instance, true); - }); - } - }); - - - // This will only ever pass after an error has occured via the poll ajax procedure. - if (reconnecting && raiseReconnect === true) { - // We wait to reconnect depending on how many times we've failed to reconnect. - // This is essentially a heuristic that will exponentially increase in wait time before - // triggering reconnected. This depends on the "error" handler of Poll to cancel this - // timeout if it triggers before the Reconnected event fires. - // The Math.min at the end is to ensure that the reconnect timeout does not overflow. - reconnectTimeoutId = window.setTimeout(function () { fireReconnected(instance); }, Math.min(1000 * (Math.pow(2, reconnectErrors) - 1), maxFireReconnectedTimeout)); - } - }(connection)); - - // Set an arbitrary timeout to trigger onSuccess, this will alot for enough time on the server to wire up the connection. - // Will be fixed by #1189 and this code can be modified to not be a timeout - window.setTimeout(function () { - // Trigger the onSuccess() method because we've now instantiated a connection - fireConnect(); - }, 250); - }, 250); // Have to delay initial poll so Chrome doesn't show loader spinner in tab - }); - }, - - lostConnection: function (connection) { - throw new Error("Lost Connection not handled for LongPolling"); - }, - - send: function (connection, data) { - transportLogic.ajaxSend(connection, data); - }, - - stop: function (connection) { - /// <summary>Stops the long polling connection</summary> - /// <param name="connection" type="signalR">The SignalR connection to stop</param> - if (connection.pollXhr) { - connection.pollXhr.abort(); - connection.pollXhr = null; - delete connection.pollXhr; - } - }, - - abort: function (connection, async) { - transportLogic.ajaxAbort(connection, async); - } - }; - -}(window.jQuery, window)); -/* jquery.signalR.hubs.js */ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -/*global window:false */ -/// <reference path="jquery.signalR.core.js" /> - -(function ($, window) { - "use strict"; - - // we use a global id for tracking callbacks so the server doesn't have to send extra info like hub name - var eventNamespace = ".hubProxy"; - - function makeEventName(event) { - return event + eventNamespace; - } - - // Equivalent to Array.prototype.map - function map(arr, fun, thisp) { - var i, - length = arr.length, - result = []; - for (i = 0; i < length; i += 1) { - if (arr.hasOwnProperty(i)) { - result[i] = fun.call(thisp, arr[i], i, arr); - } - } - return result; - } - - function getArgValue(a) { - return $.isFunction(a) ? null : ($.type(a) === "undefined" ? null : a); - } - - function hasMembers(obj) { - for (var key in obj) { - // If we have any properties in our callback map then we have callbacks and can exit the loop via return - if (obj.hasOwnProperty(key)) { - return true; - } - } - - return false; - } - - function clearInvocationCallbacks(connection, error) { - /// <param name="connection" type="hubConnection" /> - var callbacks = connection._.invocationCallbacks, - callback; - - connection.log("Clearing hub invocation callbacks with error: " + error); - - // Reset the callback cache now as we have a local var referencing it - connection._.invocationCallbackId = 0; - delete connection._.invocationCallbacks; - connection._.invocationCallbacks = {}; - - // Loop over the callbacks and invoke them. - // We do this using a local var reference and *after* we've cleared the cache - // so that if a fail callback itself tries to invoke another method we don't - // end up with its callback in the list we're looping over. - for (var callbackId in callbacks) { - callback = callbacks[callbackId]; - callback.method.call(callback.scope, { E: error }); - } - } - - // hubProxy - function hubProxy(hubConnection, hubName) { - /// <summary> - /// Creates a new proxy object for the given hub connection that can be used to invoke - /// methods on server hubs and handle client method invocation requests from the server. - /// </summary> - return new hubProxy.fn.init(hubConnection, hubName); - } - - hubProxy.fn = hubProxy.prototype = { - init: function (connection, hubName) { - this.state = {}; - this.connection = connection; - this.hubName = hubName; - this._ = { - callbackMap: {} - }; - }, - - hasSubscriptions: function () { - return hasMembers(this._.callbackMap); - }, - - on: function (eventName, callback) { - /// <summary>Wires up a callback to be invoked when a invocation request is received from the server hub.</summary> - /// <param name="eventName" type="String">The name of the hub event to register the callback for.</param> - /// <param name="callback" type="Function">The callback to be invoked.</param> - var self = this, - callbackMap = self._.callbackMap; - - // Normalize the event name to lowercase - eventName = eventName.toLowerCase(); - - // If there is not an event registered for this callback yet we want to create its event space in the callback map. - if (!callbackMap[eventName]) { - callbackMap[eventName] = {}; - } - - // Map the callback to our encompassed function - callbackMap[eventName][callback] = function (e, data) { - callback.apply(self, data); - }; - - $(self).bind(makeEventName(eventName), callbackMap[eventName][callback]); - - return self; - }, - - off: function (eventName, callback) { - /// <summary>Removes the callback invocation request from the server hub for the given event name.</summary> - /// <param name="eventName" type="String">The name of the hub event to unregister the callback for.</param> - /// <param name="callback" type="Function">The callback to be invoked.</param> - var self = this, - callbackMap = self._.callbackMap, - callbackSpace; - - // Normalize the event name to lowercase - eventName = eventName.toLowerCase(); - - callbackSpace = callbackMap[eventName]; - - // Verify that there is an event space to unbind - if (callbackSpace) { - // Only unbind if there's an event bound with eventName and a callback with the specified callback - if (callbackSpace[callback]) { - $(self).unbind(makeEventName(eventName), callbackSpace[callback]); - - // Remove the callback from the callback map - delete callbackSpace[callback]; - - // Check if there are any members left on the event, if not we need to destroy it. - if (!hasMembers(callbackSpace)) { - delete callbackMap[eventName]; - } - } - else if (!callback) { // Check if we're removing the whole event and we didn't error because of an invalid callback - $(self).unbind(makeEventName(eventName)); - - delete callbackMap[eventName]; - } - } - - return self; - }, - - invoke: function (methodName) { - /// <summary>Invokes a server hub method with the given arguments.</summary> - /// <param name="methodName" type="String">The name of the server hub method.</param> - - var self = this, - connection = self.connection, - args = $.makeArray(arguments).slice(1), - argValues = map(args, getArgValue), - data = { H: self.hubName, M: methodName, A: argValues, I: connection._.invocationCallbackId }, - d = $.Deferred(), - callback = function (minResult) { - var result = self._maximizeHubResponse(minResult); - - // Update the hub state - $.extend(self.state, result.State); - - if (result.Error) { - // Server hub method threw an exception, log it & reject the deferred - if (result.StackTrace) { - connection.log(result.Error + "\n" + result.StackTrace); - } - d.rejectWith(self, [result.Error]); - } else { - // Server invocation succeeded, resolve the deferred - d.resolveWith(self, [result.Result]); - } - }; - - connection._.invocationCallbacks[connection._.invocationCallbackId.toString()] = { scope: self, method: callback }; - connection._.invocationCallbackId += 1; - - if (!$.isEmptyObject(self.state)) { - data.S = self.state; - } - - connection.send(window.JSON.stringify(data)); - - return d.promise(); - }, - - _maximizeHubResponse: function (minHubResponse) { - return { - State: minHubResponse.S, - Result: minHubResponse.R, - Id: minHubResponse.I, - Error: minHubResponse.E, - StackTrace: minHubResponse.T - }; - } - }; - - hubProxy.fn.init.prototype = hubProxy.fn; - - // hubConnection - function hubConnection(url, options) { - /// <summary>Creates a new hub connection.</summary> - /// <param name="url" type="String">[Optional] The hub route url, defaults to "/signalr".</param> - /// <param name="options" type="Object">[Optional] Settings to use when creating the hubConnection.</param> - var settings = { - qs: null, - logging: false, - useDefaultPath: true - }; - - $.extend(settings, options); - - if (!url || settings.useDefaultPath) { - url = (url || "") + "/signalr"; - } - return new hubConnection.fn.init(url, settings); - } - - hubConnection.fn = hubConnection.prototype = $.connection(); - - hubConnection.fn.init = function (url, options) { - var settings = { - qs: null, - logging: false, - useDefaultPath: true - }, - connection = this; - - $.extend(settings, options); - - // Call the base constructor - $.signalR.fn.init.call(connection, url, settings.qs, settings.logging); - - // Object to store hub proxies for this connection - connection.proxies = {}; - - connection._.invocationCallbackId = 0; - connection._.invocationCallbacks = {}; - - // Wire up the received handler - connection.received(function (minData) { - var data, proxy, dataCallbackId, callback, hubName, eventName; - if (!minData) { - return; - } - - if (typeof (minData.I) !== "undefined") { - // We received the return value from a server method invocation, look up callback by id and call it - dataCallbackId = minData.I.toString(); - callback = connection._.invocationCallbacks[dataCallbackId]; - if (callback) { - // Delete the callback from the proxy - connection._.invocationCallbacks[dataCallbackId] = null; - delete connection._.invocationCallbacks[dataCallbackId]; - - // Invoke the callback - callback.method.call(callback.scope, minData); - } - } else { - data = this._maximizeClientHubInvocation(minData); - - // We received a client invocation request, i.e. broadcast from server hub - connection.log("Triggering client hub event '" + data.Method + "' on hub '" + data.Hub + "'."); - - // Normalize the names to lowercase - hubName = data.Hub.toLowerCase(); - eventName = data.Method.toLowerCase(); - - // Trigger the local invocation event - proxy = this.proxies[hubName]; - - // Update the hub state - $.extend(proxy.state, data.State); - $(proxy).triggerHandler(makeEventName(eventName), [data.Args]); - } - }); - - connection.error(function (errData, origData) { - var data, callbackId, callback; - - if (connection.transport && connection.transport.name === "webSockets") { - // WebSockets connections have all callbacks removed on reconnect instead - // as WebSockets sends are fire & forget - return; - } - - if (!origData) { - // No original data passed so this is not a send error - return; - } - - try { - data = window.JSON.parse(origData); - if (!data.I) { - // The original data doesn't have a callback ID so not a send error - return; - } - } catch (e) { - // The original data is not a JSON payload so this is not a send error - return; - } - - callbackId = data.I; - callback = connection._.invocationCallbacks[callbackId]; - - // Invoke the callback with an error to reject the promise - callback.method.call(callback.scope, { E: errData }); - - // Delete the callback - connection._.invocationCallbacks[callbackId] = null; - delete connection._.invocationCallbacks[callbackId]; - }); - - connection.reconnecting(function () { - if (connection.transport && connection.transport.name === "webSockets") { - clearInvocationCallbacks(connection, "Connection started reconnecting before invocation result was received."); - } - }); - - connection.disconnected(function () { - clearInvocationCallbacks(connection, "Connection was disconnected before invocation result was received."); - }); - }; - - hubConnection.fn._maximizeClientHubInvocation = function (minClientHubInvocation) { - return { - Hub: minClientHubInvocation.H, - Method: minClientHubInvocation.M, - Args: minClientHubInvocation.A, - State: minClientHubInvocation.S - }; - }; - - hubConnection.fn._registerSubscribedHubs = function () { - /// <summary> - /// Sets the starting event to loop through the known hubs and register any new hubs - /// that have been added to the proxy. - /// </summary> - - if (!this._subscribedToHubs) { - this._subscribedToHubs = true; - this.starting(function () { - // Set the connection's data object with all the hub proxies with active subscriptions. - // These proxies will receive notifications from the server. - var subscribedHubs = []; - - $.each(this.proxies, function (key) { - if (this.hasSubscriptions()) { - subscribedHubs.push({ name: key }); - } - }); - - this.data = window.JSON.stringify(subscribedHubs); - }); - } - }; - - hubConnection.fn.createHubProxy = function (hubName) { - /// <summary> - /// Creates a new proxy object for the given hub connection that can be used to invoke - /// methods on server hubs and handle client method invocation requests from the server. - /// </summary> - /// <param name="hubName" type="String"> - /// The name of the hub on the server to create the proxy for. - /// </param> - - // Normalize the name to lowercase - hubName = hubName.toLowerCase(); - - var proxy = this.proxies[hubName]; - if (!proxy) { - proxy = hubProxy(this, hubName); - this.proxies[hubName] = proxy; - } - - this._registerSubscribedHubs(); - - return proxy; - }; - - hubConnection.fn.init.prototype = hubConnection.fn; - - $.hubConnection = hubConnection; - -}(window.jQuery, window)); -/* jquery.signalR.version.js */ -// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.md in the project root for license information. - -/*global window:false */ -/// <reference path="jquery.signalR.core.js" /> -(function ($) { - $.signalR.version = "1.1.3"; -}(window.jQuery)); diff --git a/src/UI/JsLibraries/locale/placeholder.txt b/src/UI/JsLibraries/locale/placeholder.txt deleted file mode 100644 index 89326d0d4..000000000 --- a/src/UI/JsLibraries/locale/placeholder.txt +++ /dev/null @@ -1 +0,0 @@ -//Need this directory for moment/webpack, but git doesn't like empty directories. \ No newline at end of file diff --git a/src/UI/JsLibraries/lodash.underscore.js b/src/UI/JsLibraries/lodash.underscore.js deleted file mode 100644 index 02fc342c5..000000000 --- a/src/UI/JsLibraries/lodash.underscore.js +++ /dev/null @@ -1,4619 +0,0 @@ -/** - * @license - * Lo-Dash 1.3.1 (Custom Build) <http://lodash.com/> - * Build: `lodash underscore exports="amd,commonjs,global,node" -o ./dist/lodash.underscore.js` - * Copyright 2012-2013 The Dojo Foundation <http://dojofoundation.org/> - * Based on Underscore.js 1.5.1 <http://underscorejs.org/LICENSE> - * Copyright 2009-2013 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors - * Available under MIT license <http://lodash.com/license> - */ -;(function(window) { - - /** Used as a safe reference for `undefined` in pre ES5 environments */ - var undefined; - - /** Used to generate unique IDs */ - var idCounter = 0; - - /** Used internally to indicate various things */ - var indicatorObject = {}; - - /** Used to prefix keys to avoid issues with `__proto__` and properties on `Object.prototype` */ - var keyPrefix = +new Date + ''; - - /** Used to match "interpolate" template delimiters */ - var reInterpolate = /<%=([\s\S]+?)%>/g; - - /** Used to ensure capturing order of template delimiters */ - var reNoMatch = /($^)/; - - /** Used to match unescaped characters in compiled string literals */ - var reUnescapedString = /['\n\r\t\u2028\u2029\\]/g; - - /** `Object#toString` result shortcuts */ - var argsClass = '[object Arguments]', - arrayClass = '[object Array]', - boolClass = '[object Boolean]', - dateClass = '[object Date]', - funcClass = '[object Function]', - numberClass = '[object Number]', - objectClass = '[object Object]', - regexpClass = '[object RegExp]', - stringClass = '[object String]'; - - /** Used to determine if values are of the language type Object */ - var objectTypes = { - 'boolean': false, - 'function': true, - 'object': true, - 'number': false, - 'string': false, - 'undefined': false - }; - - /** Used to escape characters for inclusion in compiled string literals */ - var stringEscapes = { - '\\': '\\', - "'": "'", - '\n': 'n', - '\r': 'r', - '\t': 't', - '\u2028': 'u2028', - '\u2029': 'u2029' - }; - - /** Detect free variable `exports` */ - var freeExports = objectTypes[typeof exports] && exports; - - /** Detect free variable `module` */ - var freeModule = objectTypes[typeof module] && module && module.exports == freeExports && module; - - /** Detect free variable `global` from Node.js or Browserified code and use it as `window` */ - var freeGlobal = objectTypes[typeof global] && global; - if (freeGlobal && (freeGlobal.global === freeGlobal || freeGlobal.window === freeGlobal)) { - window = freeGlobal; - } - - /*--------------------------------------------------------------------------*/ - - /** - * The base implementation of `_.indexOf` without support for binary searches - * or `fromIndex` constraints. - * - * @private - * @param {Array} array The array to search. - * @param {Mixed} value The value to search for. - * @param {Number} [fromIndex=0] The index to search from. - * @returns {Number} Returns the index of the matched value or `-1`. - */ - function baseIndexOf(array, value, fromIndex) { - var index = (fromIndex || 0) - 1, - length = array ? array.length : 0; - - while (++index < length) { - if (array[index] === value) { - return index; - } - } - return -1; - } - - /** - * Used by `sortBy` to compare transformed `collection` elements, stable sorting - * them in ascending order. - * - * @private - * @param {Object} a The object to compare to `b`. - * @param {Object} b The object to compare to `a`. - * @returns {Number} Returns the sort order indicator of `1` or `-1`. - */ - function compareAscending(a, b) { - var ac = a.criteria, - bc = b.criteria; - - // ensure a stable sort in V8 and other engines - // http://code.google.com/p/v8/issues/detail?id=90 - if (ac !== bc) { - if (ac > bc || typeof ac == 'undefined') { - return 1; - } - if (ac < bc || typeof bc == 'undefined') { - return -1; - } - } - // The JS engine embedded in Adobe applications like InDesign has a buggy - // `Array#sort` implementation that causes it, under certain circumstances, - // to return the same value for `a` and `b`. - // See https://github.com/jashkenas/underscore/pull/1247 - return a.index - b.index; - } - - /** - * Used by `template` to escape characters for inclusion in compiled - * string literals. - * - * @private - * @param {String} match The matched character to escape. - * @returns {String} Returns the escaped character. - */ - function escapeStringChar(match) { - return '\\' + stringEscapes[match]; - } - - /** - * A no-operation function. - * - * @private - */ - function noop() { - // no operation performed - } - - /*--------------------------------------------------------------------------*/ - - /** - * Used for `Array` method references. - * - * Normally `Array.prototype` would suffice, however, using an array literal - * avoids issues in Narwhal. - */ - var arrayRef = []; - - /** Used for native method references */ - var objectProto = Object.prototype; - - /** Used to restore the original `_` reference in `noConflict` */ - var oldDash = window._; - - /** Used to detect if a method is native */ - var reNative = RegExp('^' + - String(objectProto.valueOf) - .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - .replace(/valueOf|for [^\]]+/g, '.+?') + '$' - ); - - /** Native method shortcuts */ - var ceil = Math.ceil, - floor = Math.floor, - hasOwnProperty = objectProto.hasOwnProperty, - push = arrayRef.push, - toString = objectProto.toString, - unshift = arrayRef.unshift; - - /* Native method shortcuts for methods with the same name as other `lodash` methods */ - var nativeBind = reNative.test(nativeBind = toString.bind) && nativeBind, - nativeCreate = reNative.test(nativeCreate = Object.create) && nativeCreate, - nativeIsArray = reNative.test(nativeIsArray = Array.isArray) && nativeIsArray, - nativeIsFinite = window.isFinite, - nativeIsNaN = window.isNaN, - nativeKeys = reNative.test(nativeKeys = Object.keys) && nativeKeys, - nativeMax = Math.max, - nativeMin = Math.min, - nativeRandom = Math.random, - nativeSlice = arrayRef.slice; - - /** Detect various environments */ - var isIeOpera = reNative.test(window.attachEvent), - isV8 = nativeBind && !/\n|true/.test(nativeBind + isIeOpera); - - /*--------------------------------------------------------------------------*/ - - /** - * Creates a `lodash` object which wraps the given value to enable method - * chaining. - * - * In addition to Lo-Dash methods, wrappers also have the following `Array` methods: - * `concat`, `join`, `pop`, `push`, `reverse`, `shift`, `slice`, `sort`, `splice`, - * and `unshift` - * - * Chaining is supported in custom builds as long as the `value` method is - * implicitly or explicitly included in the build. - * - * The chainable wrapper functions are: - * `after`, `assign`, `bind`, `bindAll`, `bindKey`, `chain`, `compact`, - * `compose`, `concat`, `countBy`, `createCallback`, `curry`, `debounce`, - * `defaults`, `defer`, `delay`, `difference`, `filter`, `flatten`, `forEach`, - * `forEachRight`, `forIn`, `forInRight`, `forOwn`, `forOwnRight`, `functions`, - * `groupBy`, `indexBy`, `initial`, `intersection`, `invert`, `invoke`, `keys`, - * `map`, `max`, `memoize`, `merge`, `min`, `object`, `omit`, `once`, `pairs`, - * `partial`, `partialRight`, `pick`, `pluck`, `pull`, `push`, `range`, `reject`, - * `remove`, `rest`, `reverse`, `shuffle`, `slice`, `sort`, `sortBy`, `splice`, - * `tap`, `throttle`, `times`, `toArray`, `transform`, `union`, `uniq`, `unshift`, - * `unzip`, `values`, `where`, `without`, `wrap`, and `zip` - * - * The non-chainable wrapper functions are: - * `clone`, `cloneDeep`, `contains`, `escape`, `every`, `find`, `findIndex`, - * `findKey`, `findLast`, `findLastIndex`, `findLastKey`, `has`, `identity`, - * `indexOf`, `isArguments`, `isArray`, `isBoolean`, `isDate`, `isElement`, - * `isEmpty`, `isEqual`, `isFinite`, `isFunction`, `isNaN`, `isNull`, `isNumber`, - * `isObject`, `isPlainObject`, `isRegExp`, `isString`, `isUndefined`, `join`, - * `lastIndexOf`, `mixin`, `noConflict`, `parseInt`, `pop`, `random`, `reduce`, - * `reduceRight`, `result`, `shift`, `size`, `some`, `sortedIndex`, `runInContext`, - * `template`, `unescape`, `uniqueId`, and `value` - * - * The wrapper functions `first` and `last` return wrapped values when `n` is - * provided, otherwise they return unwrapped values. - * - * @name _ - * @constructor - * @category Chaining - * @param {Mixed} value The value to wrap in a `lodash` instance. - * @returns {Object} Returns a `lodash` instance. - * @example - * - * var wrapped = _([1, 2, 3]); - * - * // returns an unwrapped value - * wrapped.reduce(function(sum, num) { - * return sum + num; - * }); - * // => 6 - * - * // returns a wrapped value - * var squares = wrapped.map(function(num) { - * return num * num; - * }); - * - * _.isArray(squares); - * // => false - * - * _.isArray(squares.value()); - * // => true - */ - function lodash(value) { - return (value instanceof lodash) - ? value - : new lodashWrapper(value); - } - - /** - * A fast path for creating `lodash` wrapper objects. - * - * @private - * @param {Mixed} value The value to wrap in a `lodash` instance. - * @param {Boolean} chainAll A flag to enable chaining for all methods - * @returns {Object} Returns a `lodash` instance. - */ - function lodashWrapper(value, chainAll) { - this.__chain__ = !!chainAll; - this.__wrapped__ = value; - } - // ensure `new lodashWrapper` is an instance of `lodash` - lodashWrapper.prototype = lodash.prototype; - - /** - * An object used to flag environments features. - * - * @static - * @memberOf _ - * @type Object - */ - var support = {}; - - (function() { - var object = { '0': 1, 'length': 1 }; - - /** - * Detect if `Function#bind` exists and is inferred to be fast (all but V8). - * - * @memberOf _.support - * @type Boolean - */ - support.fastBind = nativeBind && !isV8; - - /** - * Detect if `Array#shift` and `Array#splice` augment array-like objects correctly. - * - * Firefox < 10, IE compatibility mode, and IE < 9 have buggy Array `shift()` - * and `splice()` functions that fail to remove the last element, `value[0]`, - * of array-like objects even though the `length` property is set to `0`. - * The `shift()` method is buggy in IE 8 compatibility mode, while `splice()` - * is buggy regardless of mode in IE < 9 and buggy in compatibility mode in IE 9. - * - * @memberOf _.support - * @type Boolean - */ - support.spliceObjects = (arrayRef.splice.call(object, 0, 1), !object[0]); - }(1)); - - /** - * By default, the template delimiters used by Lo-Dash are similar to those in - * embedded Ruby (ERB). Change the following template settings to use alternative - * delimiters. - * - * @static - * @memberOf _ - * @type Object - */ - lodash.templateSettings = { - - /** - * Used to detect `data` property values to be HTML-escaped. - * - * @memberOf _.templateSettings - * @type RegExp - */ - 'escape': /<%-([\s\S]+?)%>/g, - - /** - * Used to detect code to be evaluated. - * - * @memberOf _.templateSettings - * @type RegExp - */ - 'evaluate': /<%([\s\S]+?)%>/g, - - /** - * Used to detect `data` property values to inject. - * - * @memberOf _.templateSettings - * @type RegExp - */ - 'interpolate': reInterpolate, - - /** - * Used to reference the data object in the template text. - * - * @memberOf _.templateSettings - * @type String - */ - 'variable': '' - }; - - /*--------------------------------------------------------------------------*/ - - /** - * The base implementation of `_.createCallback` without support for creating - * "_.pluck" or "_.where" style callbacks. - * - * @private - * @param {Mixed} [func=identity] The value to convert to a callback. - * @param {Mixed} [thisArg] The `this` binding of the created callback. - * @param {Number} [argCount] The number of arguments the callback accepts. - * @returns {Function} Returns a callback function. - */ - function baseCreateCallback(func, thisArg, argCount) { - if (typeof func != 'function') { - return identity; - } - // exit early if there is no `thisArg` - if (typeof thisArg == 'undefined') { - return func; - } - switch (argCount) { - case 1: return function(value) { - return func.call(thisArg, value); - }; - case 2: return function(a, b) { - return func.call(thisArg, a, b); - }; - case 3: return function(value, index, collection) { - return func.call(thisArg, value, index, collection); - }; - case 4: return function(accumulator, value, index, collection) { - return func.call(thisArg, accumulator, value, index, collection); - }; - } - return bind(func, thisArg); - } - - /** - * The base implementation of `_.flatten` without support for callback - * shorthands or `thisArg` binding. - * - * @private - * @param {Array} array The array to flatten. - * @param {Boolean} [isShallow=false] A flag to restrict flattening to a single level. - * @param {Boolean} [isArgArrays=false] A flag to restrict flattening to arrays and `arguments` objects. - * @param {Number} [fromIndex=0] The index to start from. - * @returns {Array} Returns a new flattened array. - */ - function baseFlatten(array, isShallow, isArgArrays, fromIndex) { - var index = (fromIndex || 0) - 1, - length = array ? array.length : 0, - result = []; - - while (++index < length) { - var value = array[index]; - // recursively flatten arrays (susceptible to call stack limits) - if (value && typeof value == 'object' && (isArray(value) || isArguments(value))) { - push.apply(result, isShallow ? value : baseFlatten(value, isShallow, isArgArrays)); - } else if (!isArgArrays) { - result.push(value); - } - } - return result; - } - - /** - * The base implementation of `_.isEqual`, without support for `thisArg` binding, - * that allows partial "_.where" style comparisons. - * - * @private - * @param {Mixed} a The value to compare. - * @param {Mixed} b The other value to compare. - * @param {Function} [callback] The function to customize comparing values. - * @param {Function} [isWhere=false] A flag to indicate performing partial comparisons. - * @param {Array} [stackA=[]] Tracks traversed `a` objects. - * @param {Array} [stackB=[]] Tracks traversed `b` objects. - * @returns {Boolean} Returns `true` if the values are equivalent, else `false`. - */ - function baseIsEqual(a, b, stackA, stackB) { - if (a === b) { - return a !== 0 || (1 / a == 1 / b); - } - var type = typeof a, - otherType = typeof b; - - if (a === a && - !(a && objectTypes[type]) && - !(b && objectTypes[otherType])) { - return false; - } - if (a == null || b == null) { - return a === b; - } - var className = toString.call(a), - otherClass = toString.call(b); - - if (className != otherClass) { - return false; - } - switch (className) { - case boolClass: - case dateClass: - return +a == +b; - - case numberClass: - return a != +a - ? b != +b - : (a == 0 ? (1 / a == 1 / b) : a == +b); - - case regexpClass: - case stringClass: - return a == String(b); - } - var isArr = className == arrayClass; - if (!isArr) { - if (hasOwnProperty.call(a, '__wrapped__ ') || b instanceof lodash) { - return baseIsEqual(a.__wrapped__ || a, b.__wrapped__ || b, stackA, stackB); - } - if (className != objectClass) { - return false; - } - var ctorA = a.constructor, - ctorB = b.constructor; - - if (ctorA != ctorB && !( - isFunction(ctorA) && ctorA instanceof ctorA && - isFunction(ctorB) && ctorB instanceof ctorB - )) { - return false; - } - } - stackA || (stackA = []); - stackB || (stackB = []); - - var length = stackA.length; - while (length--) { - if (stackA[length] == a) { - return stackB[length] == b; - } - } - var result = true, - size = 0; - - stackA.push(a); - stackB.push(b); - - if (isArr) { - size = b.length; - result = size == a.length; - - if (result) { - while (size--) { - if (!(result = baseIsEqual(a[size], b[size], stackA, stackB))) { - break; - } - } - } - return result; - } - forIn(b, function(value, key, b) { - if (hasOwnProperty.call(b, key)) { - size++; - return !(result = hasOwnProperty.call(a, key) && baseIsEqual(a[key], value, stackA, stackB)) && indicatorObject; - } - }); - - if (result) { - forIn(a, function(value, key, a) { - if (hasOwnProperty.call(a, key)) { - return !(result = --size > -1) && indicatorObject; - } - }); - } - return result; - } - - /** - * The base implementation of `_.uniq` without support for callback shorthands - * or `thisArg` binding. - * - * @private - * @param {Array} array The array to process. - * @param {Boolean} [isSorted=false] A flag to indicate that `array` is sorted. - * @param {Function} [callback] The function called per iteration. - * @returns {Array} Returns a duplicate-value-free array. - */ - function baseUniq(array, isSorted, callback) { - var index = -1, - indexOf = getIndexOf(), - length = array ? array.length : 0, - result = [], - seen = callback ? [] : result; - - while (++index < length) { - var value = array[index], - computed = callback ? callback(value, index, array) : value; - - if (isSorted - ? !index || seen[seen.length - 1] !== computed - : indexOf(seen, computed) < 0 - ) { - if (callback) { - seen.push(computed); - } - result.push(value); - } - } - return result; - } - - /** - * Creates a function that aggregates a collection, creating an object composed - * of keys generated from the results of running each element of the collection - * through a callback. The given `setter` function sets the keys and values - * of the composed object. - * - * @private - * @param {Function} setter The setter function. - * @returns {Function} Returns the new aggregator function. - */ - function createAggregator(setter) { - return function(collection, callback, thisArg) { - var result = {}; - callback = createCallback(callback, thisArg, 3); - forEach(collection, function(value, key, collection) { - key = String(callback(value, key, collection)); - setter(result, value, key, collection); - }); - return result; - }; - } - - /** - * Creates a function that, when called, either curries or invokes `func` - * with an optional `this` binding and partially applied arguments. - * - * @private - * @param {Function|String} func The function or method name to reference. - * @param {Number} bitmask The bitmask of method flags to compose. - * The bitmask may be composed of the following flags: - * 1 - `_.bind` - * 2 - `_.bindKey` - * 4 - `_.curry` - * 8 - `_.curry` (bound) - * 16 - `_.partial` - * 32 - `_.partialRight` - * @param {Array} [partialArgs] An array of arguments to prepend to those - * provided to the new function. - * @param {Array} [partialRightArgs] An array of arguments to append to those - * provided to the new function. - * @param {Mixed} [thisArg] The `this` binding of `func`. - * @param {Number} [arity] The arity of `func`. - * @returns {Function} Returns the new bound function. - */ - function createBound(func, bitmask, partialArgs, partialRightArgs, thisArg, arity) { - var isBind = bitmask & 1, - isBindKey = bitmask & 2, - isCurry = bitmask & 4, - isCurryBound = bitmask & 8, - isPartial = bitmask & 16, - isPartialRight = bitmask & 32; - - if (!isBindKey && !isFunction(func)) { - throw new TypeError; - } - // use `Function#bind` if it exists and is fast - // (in V8 `Function#bind` is slower except when partially applied) - if (isBind && !(isBindKey || isCurry || isPartialRight) && - (support.fastBind || (nativeBind && partialArgs.length))) { - var args = [func, thisArg]; - push.apply(args, partialArgs); - var bound = nativeBind.call.apply(nativeBind, args); - } - else { - bound = function() { - // `Function#bind` spec - // http://es5.github.io/#x15.3.4.5 - var args = arguments, - thisBinding = isBind ? thisArg : this; - - if (partialArgs) { - unshift.apply(args, partialArgs); - } - if (partialRightArgs) { - push.apply(args, partialRightArgs); - } - if (isCurry && args.length < arity) { - bitmask |= 16 & ~32 - return createBound(func, (isCurryBound ? bitmask : bitmask & ~3), args, null, thisArg, arity); - } - if (isBindKey) { - func = thisBinding[key]; - } - if (this instanceof bound) { - // ensure `new bound` is an instance of `func` - thisBinding = createObject(func.prototype); - - // mimic the constructor's `return` behavior - // http://es5.github.io/#x13.2.2 - var result = func.apply(thisBinding, args); - return isObject(result) ? result : thisBinding; - } - return func.apply(thisBinding, args); - }; - } - if (isBindKey) { - var key = thisArg; - thisArg = func; - } - return bound; - } - - /** - * Creates a new object with the specified `prototype`. - * - * @private - * @param {Object} prototype The prototype object. - * @returns {Object} Returns the new object. - */ - function createObject(prototype) { - return isObject(prototype) ? nativeCreate(prototype) : {}; - } - // fallback for browsers without `Object.create` - if (!nativeCreate) { - createObject = function(prototype) { - if (isObject(prototype)) { - noop.prototype = prototype; - var result = new noop; - noop.prototype = null; - } - return result || {}; - }; - } - - /** - * Used by `escape` to convert characters to HTML entities. - * - * @private - * @param {String} match The matched character to escape. - * @returns {String} Returns the escaped character. - */ - function escapeHtmlChar(match) { - return htmlEscapes[match]; - } - - /** - * Gets the appropriate "indexOf" function. If the `_.indexOf` method is - * customized, this method returns the custom method, otherwise it returns - * the `baseIndexOf` function. - * - * @private - * @returns {Function} Returns the "indexOf" function. - */ - function getIndexOf() { - var result = (result = lodash.indexOf) === indexOf ? baseIndexOf : result; - return result; - } - - /** - * Used by `unescape` to convert HTML entities to characters. - * - * @private - * @param {String} match The matched character to unescape. - * @returns {String} Returns the unescaped character. - */ - function unescapeHtmlChar(match) { - return htmlUnescapes[match]; - } - - /*--------------------------------------------------------------------------*/ - - /** - * Checks if `value` is an `arguments` object. - * - * @static - * @memberOf _ - * @category Objects - * @param {Mixed} value The value to check. - * @returns {Boolean} Returns `true` if the `value` is an `arguments` object, else `false`. - * @example - * - * (function() { return _.isArguments(arguments); })(1, 2, 3); - * // => true - * - * _.isArguments([1, 2, 3]); - * // => false - */ - function isArguments(value) { - return (value && typeof value == 'object') ? toString.call(value) == argsClass : false; - } - // fallback for browsers that can't detect `arguments` objects by [[Class]] - if (!isArguments(arguments)) { - isArguments = function(value) { - return (value && typeof value == 'object') ? hasOwnProperty.call(value, 'callee') : false; - }; - } - - /** - * Checks if `value` is an array. - * - * @static - * @memberOf _ - * @type Function - * @category Objects - * @param {Mixed} value The value to check. - * @returns {Boolean} Returns `true` if the `value` is an array, else `false`. - * @example - * - * (function() { return _.isArray(arguments); })(); - * // => false - * - * _.isArray([1, 2, 3]); - * // => true - */ - var isArray = nativeIsArray || function(value) { - return (value && typeof value == 'object') ? toString.call(value) == arrayClass : false; - }; - - /** - * A fallback implementation of `Object.keys` which produces an array of the - * given object's own enumerable property names. - * - * @private - * @type Function - * @param {Object} object The object to inspect. - * @returns {Array} Returns an array of property names. - */ - var shimKeys = function(object) { - var index, iterable = object, result = []; - if (!iterable) return result; - if (!(objectTypes[typeof object])) return result; - for (index in iterable) { - if (hasOwnProperty.call(iterable, index)) { - result.push(index); - } - } - return result - }; - - /** - * Creates an array composed of the own enumerable property names of an object. - * - * @static - * @memberOf _ - * @category Objects - * @param {Object} object The object to inspect. - * @returns {Array} Returns an array of property names. - * @example - * - * _.keys({ 'one': 1, 'two': 2, 'three': 3 }); - * // => ['one', 'two', 'three'] (order is not guaranteed) - */ - var keys = !nativeKeys ? shimKeys : function(object) { - if (!isObject(object)) { - return []; - } - return nativeKeys(object); - }; - - /** - * Used to convert characters to HTML entities: - * - * Though the `>` character is escaped for symmetry, characters like `>` and `/` - * don't require escaping in HTML and have no special meaning unless they're part - * of a tag or an unquoted attribute value. - * http://mathiasbynens.be/notes/ambiguous-ampersands (under "semi-related fun fact") - */ - var htmlEscapes = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - '/': '/' - }; - - /** Used to convert HTML entities to characters */ - var htmlUnescapes = invert(htmlEscapes); - - /** Used to match HTML entities and HTML characters */ - var reEscapedHtml = RegExp('(' + keys(htmlUnescapes).join('|') + ')', 'g'), - reUnescapedHtml = RegExp('[' + keys(htmlEscapes).join('') + ']', 'g'); - - /*--------------------------------------------------------------------------*/ - - /** - * Assigns own enumerable properties of source object(s) to the destination - * object. Subsequent sources will overwrite property assignments of previous - * sources. If a callback is provided it will be executed to produce the - * assigned values. The callback is bound to `thisArg` and invoked with two - * arguments; (objectValue, sourceValue). - * - * @static - * @memberOf _ - * @type Function - * @alias extend - * @category Objects - * @param {Object} object The destination object. - * @param {Object} [source1, source2, ...] The source objects. - * @param {Function} [callback] The function to customize assigning values. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Object} Returns the destination object. - * @example - * - * _.assign({ 'name': 'moe' }, { 'age': 40 }); - * // => { 'name': 'moe', 'age': 40 } - * - * var defaults = _.partialRight(_.assign, function(a, b) { - * return typeof a == 'undefined' ? b : a; - * }); - * - * var food = { 'name': 'apple' }; - * defaults(food, { 'name': 'banana', 'type': 'fruit' }); - * // => { 'name': 'apple', 'type': 'fruit' } - */ - function assign(object) { - if (!object) { - return object; - } - for (var argsIndex = 1, argsLength = arguments.length; argsIndex < argsLength; argsIndex++) { - var iterable = arguments[argsIndex]; - if (iterable) { - for (var key in iterable) { - object[key] = iterable[key]; - } - } - } - return object; - } - - /** - * Creates a clone of `value`. If `deep` is `true` nested objects will also - * be cloned, otherwise they will be assigned by reference. If a callback - * is provided it will be executed to produce the cloned values. If the - * callback returns `undefined` cloning will be handled by the method instead. - * The callback is bound to `thisArg` and invoked with one argument; (value). - * - * @static - * @memberOf _ - * @category Objects - * @param {Mixed} value The value to clone. - * @param {Boolean} [deep=false] A flag to indicate a deep clone. - * @param {Function} [callback] The function to customize cloning values. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Mixed} Returns the cloned `value`. - * @example - * - * var stooges = [ - * { 'name': 'moe', 'age': 40 }, - * { 'name': 'larry', 'age': 50 } - * ]; - * - * var shallow = _.clone(stooges); - * shallow[0] === stooges[0]; - * // => true - * - * var deep = _.clone(stooges, true); - * deep[0] === stooges[0]; - * // => false - * - * _.mixin({ - * 'clone': _.partialRight(_.clone, function(value) { - * return _.isElement(value) ? value.cloneNode(false) : undefined; - * }) - * }); - * - * var clone = _.clone(document.body); - * clone.childNodes.length; - * // => 0 - */ - function clone(value) { - return isObject(value) - ? (isArray(value) ? nativeSlice.call(value) : assign({}, value)) - : value; - } - - /** - * Assigns own enumerable properties of source object(s) to the destination - * object for all destination properties that resolve to `undefined`. Once a - * property is set, additional defaults of the same property will be ignored. - * - * @static - * @memberOf _ - * @type Function - * @category Objects - * @param {Object} object The destination object. - * @param {Object} [source1, source2, ...] The source objects. - * @param- {Object} [guard] Allows working with `_.reduce` without using - * their `key` and `object` arguments as sources. - * @returns {Object} Returns the destination object. - * @example - * - * var food = { 'name': 'apple' }; - * _.defaults(food, { 'name': 'banana', 'type': 'fruit' }); - * // => { 'name': 'apple', 'type': 'fruit' } - */ - function defaults(object) { - if (!object) { - return object; - } - for (var argsIndex = 1, argsLength = arguments.length; argsIndex < argsLength; argsIndex++) { - var iterable = arguments[argsIndex]; - if (iterable) { - for (var key in iterable) { - if (typeof object[key] == 'undefined') { - object[key] = iterable[key]; - } - } - } - } - return object; - } - - /** - * Iterates over own and inherited enumerable properties of an object, - * executing the callback for each property. The callback is bound to `thisArg` - * and invoked with three arguments; (value, key, object). Callbacks may exit - * iteration early by explicitly returning `false`. - * - * @static - * @memberOf _ - * @type Function - * @category Objects - * @param {Object} object The object to iterate over. - * @param {Function} [callback=identity] The function called per iteration. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Object} Returns `object`. - * @example - * - * function Dog(name) { - * this.name = name; - * } - * - * Dog.prototype.bark = function() { - * console.log('Woof, woof!'); - * }; - * - * _.forIn(new Dog('Dagny'), function(value, key) { - * console.log(key); - * }); - * // => logs 'bark' and 'name' (order is not guaranteed) - */ - var forIn = function(collection, callback) { - var index, iterable = collection, result = iterable; - if (!iterable) return result; - if (!objectTypes[typeof iterable]) return result; - for (index in iterable) { - if (callback(iterable[index], index, collection) === indicatorObject) return result; - } - return result - }; - - /** - * Iterates over own enumerable properties of an object, executing the callback - * for each property. The callback is bound to `thisArg` and invoked with three - * arguments; (value, key, object). Callbacks may exit iteration early by - * explicitly returning `false`. - * - * @static - * @memberOf _ - * @type Function - * @category Objects - * @param {Object} object The object to iterate over. - * @param {Function} [callback=identity] The function called per iteration. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Object} Returns `object`. - * @example - * - * _.forOwn({ '0': 'zero', '1': 'one', 'length': 2 }, function(num, key) { - * console.log(key); - * }); - * // => logs '0', '1', and 'length' (order is not guaranteed) - */ - var forOwn = function(collection, callback) { - var index, iterable = collection, result = iterable; - if (!iterable) return result; - if (!objectTypes[typeof iterable]) return result; - for (index in iterable) { - if (hasOwnProperty.call(iterable, index)) { - if (callback(iterable[index], index, collection) === indicatorObject) return result; - } - } - return result - }; - - /** - * Creates a sorted array of property names of all enumerable properties, - * own and inherited, of `object` that have function values. - * - * @static - * @memberOf _ - * @alias methods - * @category Objects - * @param {Object} object The object to inspect. - * @returns {Array} Returns an array of property names that have function values. - * @example - * - * _.functions(_); - * // => ['all', 'any', 'bind', 'bindAll', 'clone', 'compact', 'compose', ...] - */ - function functions(object) { - var result = []; - forIn(object, function(value, key) { - if (isFunction(value)) { - result.push(key); - } - }); - return result.sort(); - } - - /** - * Checks if the specified object `property` exists and is a direct property, - * instead of an inherited property. - * - * @static - * @memberOf _ - * @category Objects - * @param {Object} object The object to check. - * @param {String} property The property to check for. - * @returns {Boolean} Returns `true` if key is a direct property, else `false`. - * @example - * - * _.has({ 'a': 1, 'b': 2, 'c': 3 }, 'b'); - * // => true - */ - function has(object, property) { - return object ? hasOwnProperty.call(object, property) : false; - } - - /** - * Creates an object composed of the inverted keys and values of the given object. - * - * @static - * @memberOf _ - * @category Objects - * @param {Object} object The object to invert. - * @returns {Object} Returns the created inverted object. - * @example - * - * _.invert({ 'first': 'moe', 'second': 'larry' }); - * // => { 'moe': 'first', 'larry': 'second' } - */ - function invert(object) { - var index = -1, - props = keys(object), - length = props.length, - result = {}; - - while (++index < length) { - var key = props[index]; - result[object[key]] = key; - } - return result; - } - - /** - * Checks if `value` is a boolean value. - * - * @static - * @memberOf _ - * @category Objects - * @param {Mixed} value The value to check. - * @returns {Boolean} Returns `true` if the `value` is a boolean value, else `false`. - * @example - * - * _.isBoolean(null); - * // => false - */ - function isBoolean(value) { - return value === true || value === false || toString.call(value) == boolClass; - } - - /** - * Checks if `value` is a date. - * - * @static - * @memberOf _ - * @category Objects - * @param {Mixed} value The value to check. - * @returns {Boolean} Returns `true` if the `value` is a date, else `false`. - * @example - * - * _.isDate(new Date); - * // => true - */ - function isDate(value) { - return value ? (typeof value == 'object' && toString.call(value) == dateClass) : false; - } - - /** - * Checks if `value` is a DOM element. - * - * @static - * @memberOf _ - * @category Objects - * @param {Mixed} value The value to check. - * @returns {Boolean} Returns `true` if the `value` is a DOM element, else `false`. - * @example - * - * _.isElement(document.body); - * // => true - */ - function isElement(value) { - return value ? value.nodeType === 1 : false; - } - - /** - * Checks if `value` is empty. Arrays, strings, or `arguments` objects with a - * length of `0` and objects with no own enumerable properties are considered - * "empty". - * - * @static - * @memberOf _ - * @category Objects - * @param {Array|Object|String} value The value to inspect. - * @returns {Boolean} Returns `true` if the `value` is empty, else `false`. - * @example - * - * _.isEmpty([1, 2, 3]); - * // => false - * - * _.isEmpty({}); - * // => true - * - * _.isEmpty(''); - * // => true - */ - function isEmpty(value) { - if (!value) { - return true; - } - if (isArray(value) || isString(value)) { - return !value.length; - } - for (var key in value) { - if (hasOwnProperty.call(value, key)) { - return false; - } - } - return true; - } - - /** - * Performs a deep comparison between two values to determine if they are - * equivalent to each other. If a callback is provided it will be executed - * to compare values. If the callback returns `undefined` comparisons will - * be handled by the method instead. The callback is bound to `thisArg` and - * invoked with two arguments; (a, b). - * - * @static - * @memberOf _ - * @category Objects - * @param {Mixed} a The value to compare. - * @param {Mixed} b The other value to compare. - * @param {Function} [callback] The function to customize comparing values. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Boolean} Returns `true` if the values are equivalent, else `false`. - * @example - * - * var moe = { 'name': 'moe', 'age': 40 }; - * var copy = { 'name': 'moe', 'age': 40 }; - * - * moe == copy; - * // => false - * - * _.isEqual(moe, copy); - * // => true - * - * var words = ['hello', 'goodbye']; - * var otherWords = ['hi', 'goodbye']; - * - * _.isEqual(words, otherWords, function(a, b) { - * var reGreet = /^(?:hello|hi)$/i, - * aGreet = _.isString(a) && reGreet.test(a), - * bGreet = _.isString(b) && reGreet.test(b); - * - * return (aGreet || bGreet) ? (aGreet == bGreet) : undefined; - * }); - * // => true - */ - function isEqual(a, b) { - return baseIsEqual(a, b); - } - - /** - * Checks if `value` is, or can be coerced to, a finite number. - * - * Note: This is not the same as native `isFinite` which will return true for - * booleans and empty strings. See http://es5.github.io/#x15.1.2.5. - * - * @static - * @memberOf _ - * @category Objects - * @param {Mixed} value The value to check. - * @returns {Boolean} Returns `true` if the `value` is finite, else `false`. - * @example - * - * _.isFinite(-101); - * // => true - * - * _.isFinite('10'); - * // => true - * - * _.isFinite(true); - * // => false - * - * _.isFinite(''); - * // => false - * - * _.isFinite(Infinity); - * // => false - */ - function isFinite(value) { - return nativeIsFinite(value) && !nativeIsNaN(parseFloat(value)); - } - - /** - * Checks if `value` is a function. - * - * @static - * @memberOf _ - * @category Objects - * @param {Mixed} value The value to check. - * @returns {Boolean} Returns `true` if the `value` is a function, else `false`. - * @example - * - * _.isFunction(_); - * // => true - */ - function isFunction(value) { - return typeof value == 'function'; - } - // fallback for older versions of Chrome and Safari - if (isFunction(/x/)) { - isFunction = function(value) { - return typeof value == 'function' && toString.call(value) == funcClass; - }; - } - - /** - * Checks if `value` is the language type of Object. - * (e.g. arrays, functions, objects, regexes, `new Number(0)`, and `new String('')`) - * - * @static - * @memberOf _ - * @category Objects - * @param {Mixed} value The value to check. - * @returns {Boolean} Returns `true` if the `value` is an object, else `false`. - * @example - * - * _.isObject({}); - * // => true - * - * _.isObject([1, 2, 3]); - * // => true - * - * _.isObject(1); - * // => false - */ - function isObject(value) { - // check if the value is the ECMAScript language type of Object - // http://es5.github.io/#x8 - // and avoid a V8 bug - // http://code.google.com/p/v8/issues/detail?id=2291 - return !!(value && objectTypes[typeof value]); - } - - /** - * Checks if `value` is `NaN`. - * - * Note: This is not the same as native `isNaN` which will return `true` for - * `undefined` and other non-numeric values. See http://es5.github.io/#x15.1.2.4. - * - * @static - * @memberOf _ - * @category Objects - * @param {Mixed} value The value to check. - * @returns {Boolean} Returns `true` if the `value` is `NaN`, else `false`. - * @example - * - * _.isNaN(NaN); - * // => true - * - * _.isNaN(new Number(NaN)); - * // => true - * - * isNaN(undefined); - * // => true - * - * _.isNaN(undefined); - * // => false - */ - function isNaN(value) { - // `NaN` as a primitive is the only value that is not equal to itself - // (perform the [[Class]] check first to avoid errors with some host objects in IE) - return isNumber(value) && value != +value; - } - - /** - * Checks if `value` is `null`. - * - * @static - * @memberOf _ - * @category Objects - * @param {Mixed} value The value to check. - * @returns {Boolean} Returns `true` if the `value` is `null`, else `false`. - * @example - * - * _.isNull(null); - * // => true - * - * _.isNull(undefined); - * // => false - */ - function isNull(value) { - return value === null; - } - - /** - * Checks if `value` is a number. - * - * Note: `NaN` is considered a number. See http://es5.github.io/#x8.5. - * - * @static - * @memberOf _ - * @category Objects - * @param {Mixed} value The value to check. - * @returns {Boolean} Returns `true` if the `value` is a number, else `false`. - * @example - * - * _.isNumber(8.4 * 5); - * // => true - */ - function isNumber(value) { - return typeof value == 'number' || toString.call(value) == numberClass; - } - - /** - * Checks if `value` is a regular expression. - * - * @static - * @memberOf _ - * @category Objects - * @param {Mixed} value The value to check. - * @returns {Boolean} Returns `true` if the `value` is a regular expression, else `false`. - * @example - * - * _.isRegExp(/moe/); - * // => true - */ - function isRegExp(value) { - return (value && objectTypes[typeof value]) ? toString.call(value) == regexpClass : false; - } - - /** - * Checks if `value` is a string. - * - * @static - * @memberOf _ - * @category Objects - * @param {Mixed} value The value to check. - * @returns {Boolean} Returns `true` if the `value` is a string, else `false`. - * @example - * - * _.isString('moe'); - * // => true - */ - function isString(value) { - return typeof value == 'string' || toString.call(value) == stringClass; - } - - /** - * Checks if `value` is `undefined`. - * - * @static - * @memberOf _ - * @category Objects - * @param {Mixed} value The value to check. - * @returns {Boolean} Returns `true` if the `value` is `undefined`, else `false`. - * @example - * - * _.isUndefined(void 0); - * // => true - */ - function isUndefined(value) { - return typeof value == 'undefined'; - } - - /** - * Creates a shallow clone of `object` excluding the specified properties. - * Property names may be specified as individual arguments or as arrays of - * property names. If a callback is provided it will be executed for each - * property of `object` omitting the properties the callback returns truthy - * for. The callback is bound to `thisArg` and invoked with three arguments; - * (value, key, object). - * - * @static - * @memberOf _ - * @category Objects - * @param {Object} object The source object. - * @param {Function|String} callback|[prop1, prop2, ...] The properties to omit - * or the function called per iteration. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Object} Returns an object without the omitted properties. - * @example - * - * _.omit({ 'name': 'moe', 'age': 40 }, 'age'); - * // => { 'name': 'moe' } - * - * _.omit({ 'name': 'moe', 'age': 40 }, function(value) { - * return typeof value == 'number'; - * }); - * // => { 'name': 'moe' } - */ - function omit(object) { - var indexOf = getIndexOf(), - props = baseFlatten(arguments, true, false, 1), - result = {}; - - forIn(object, function(value, key) { - if (indexOf(props, key) < 0) { - result[key] = value; - } - }); - return result; - } - - /** - * Creates a two dimensional array of an object's key-value pairs, - * i.e. `[[key1, value1], [key2, value2]]`. - * - * @static - * @memberOf _ - * @category Objects - * @param {Object} object The object to inspect. - * @returns {Array} Returns new array of key-value pairs. - * @example - * - * _.pairs({ 'moe': 30, 'larry': 40 }); - * // => [['moe', 30], ['larry', 40]] (order is not guaranteed) - */ - function pairs(object) { - var index = -1, - props = keys(object), - length = props.length, - result = Array(length); - - while (++index < length) { - var key = props[index]; - result[index] = [key, object[key]]; - } - return result; - } - - /** - * Creates a shallow clone of `object` composed of the specified properties. - * Property names may be specified as individual arguments or as arrays of - * property names. If a callback is provided it will be executed for each - * property of `object` picking the properties the callback returns truthy - * for. The callback is bound to `thisArg` and invoked with three arguments; - * (value, key, object). - * - * @static - * @memberOf _ - * @category Objects - * @param {Object} object The source object. - * @param {Array|Function|String} callback|[prop1, prop2, ...] The function - * called per iteration or property names to pick, specified as individual - * property names or arrays of property names. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Object} Returns an object composed of the picked properties. - * @example - * - * _.pick({ 'name': 'moe', '_userid': 'moe1' }, 'name'); - * // => { 'name': 'moe' } - * - * _.pick({ 'name': 'moe', '_userid': 'moe1' }, function(value, key) { - * return key.charAt(0) != '_'; - * }); - * // => { 'name': 'moe' } - */ - function pick(object) { - var index = -1, - props = baseFlatten(arguments, true, false, 1), - length = props.length, - result = {}; - - while (++index < length) { - var prop = props[index]; - if (prop in object) { - result[prop] = object[prop]; - } - } - return result; - } - - /** - * Creates an array composed of the own enumerable property values of `object`. - * - * @static - * @memberOf _ - * @category Objects - * @param {Object} object The object to inspect. - * @returns {Array} Returns an array of property values. - * @example - * - * _.values({ 'one': 1, 'two': 2, 'three': 3 }); - * // => [1, 2, 3] (order is not guaranteed) - */ - function values(object) { - var index = -1, - props = keys(object), - length = props.length, - result = Array(length); - - while (++index < length) { - result[index] = object[props[index]]; - } - return result; - } - - /*--------------------------------------------------------------------------*/ - - /** - * Checks if a given value is present in a collection using strict equality - * for comparisons, i.e. `===`. If `fromIndex` is negative, it is used as the - * offset from the end of the collection. - * - * @static - * @memberOf _ - * @alias include - * @category Collections - * @param {Array|Object|String} collection The collection to iterate over. - * @param {Mixed} target The value to check for. - * @param {Number} [fromIndex=0] The index to search from. - * @returns {Boolean} Returns `true` if the `target` element is found, else `false`. - * @example - * - * _.contains([1, 2, 3], 1); - * // => true - * - * _.contains([1, 2, 3], 1, 2); - * // => false - * - * _.contains({ 'name': 'moe', 'age': 40 }, 'moe'); - * // => true - * - * _.contains('curly', 'ur'); - * // => true - */ - function contains(collection, target) { - var indexOf = getIndexOf(), - length = collection ? collection.length : 0, - result = false; - if (length && typeof length == 'number') { - result = indexOf(collection, target) > -1; - } else { - forOwn(collection, function(value) { - return (result = value === target) && indicatorObject; - }); - } - return result; - } - - /** - * Creates an object composed of keys generated from the results of running - * each element of `collection` through the callback. The corresponding value - * of each key is the number of times the key was returned by the callback. - * The callback is bound to `thisArg` and invoked with three arguments; - * (value, index|key, collection). - * - * If a property name is provided for `callback` the created "_.pluck" style - * callback will return the property value of the given element. - * - * If an object is provided for `callback` the created "_.where" style callback - * will return `true` for elements that have the properties of the given object, - * else `false`. - * - * @static - * @memberOf _ - * @category Collections - * @param {Array|Object|String} collection The collection to iterate over. - * @param {Function|Object|String} [callback=identity] The function called - * per iteration. If a property name or object is provided it will be used - * to create a "_.pluck" or "_.where" style callback, respectively. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Object} Returns the composed aggregate object. - * @example - * - * _.countBy([4.3, 6.1, 6.4], function(num) { return Math.floor(num); }); - * // => { '4': 1, '6': 2 } - * - * _.countBy([4.3, 6.1, 6.4], function(num) { return this.floor(num); }, Math); - * // => { '4': 1, '6': 2 } - * - * _.countBy(['one', 'two', 'three'], 'length'); - * // => { '3': 2, '5': 1 } - */ - var countBy = createAggregator(function(result, value, key) { - (hasOwnProperty.call(result, key) ? result[key]++ : result[key] = 1); - }); - - /** - * Checks if the given callback returns truthy value for **all** elements of - * a collection. The callback is bound to `thisArg` and invoked with three - * arguments; (value, index|key, collection). - * - * If a property name is provided for `callback` the created "_.pluck" style - * callback will return the property value of the given element. - * - * If an object is provided for `callback` the created "_.where" style callback - * will return `true` for elements that have the properties of the given object, - * else `false`. - * - * @static - * @memberOf _ - * @alias all - * @category Collections - * @param {Array|Object|String} collection The collection to iterate over. - * @param {Function|Object|String} [callback=identity] The function called - * per iteration. If a property name or object is provided it will be used - * to create a "_.pluck" or "_.where" style callback, respectively. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Boolean} Returns `true` if all elements passed the callback check, - * else `false`. - * @example - * - * _.every([true, 1, null, 'yes'], Boolean); - * // => false - * - * var stooges = [ - * { 'name': 'moe', 'age': 40 }, - * { 'name': 'larry', 'age': 50 } - * ]; - * - * // using "_.pluck" callback shorthand - * _.every(stooges, 'age'); - * // => true - * - * // using "_.where" callback shorthand - * _.every(stooges, { 'age': 50 }); - * // => false - */ - function every(collection, callback, thisArg) { - var result = true; - callback = createCallback(callback, thisArg, 3); - - var index = -1, - length = collection ? collection.length : 0; - - if (typeof length == 'number') { - while (++index < length) { - if (!(result = !!callback(collection[index], index, collection))) { - break; - } - } - } else { - forOwn(collection, function(value, index, collection) { - return !(result = !!callback(value, index, collection)) && indicatorObject; - }); - } - return result; - } - - /** - * Iterates over elements of a collection, returning an array of all elements - * the callback returns truthy for. The callback is bound to `thisArg` and - * invoked with three arguments; (value, index|key, collection). - * - * If a property name is provided for `callback` the created "_.pluck" style - * callback will return the property value of the given element. - * - * If an object is provided for `callback` the created "_.where" style callback - * will return `true` for elements that have the properties of the given object, - * else `false`. - * - * @static - * @memberOf _ - * @alias select - * @category Collections - * @param {Array|Object|String} collection The collection to iterate over. - * @param {Function|Object|String} [callback=identity] The function called - * per iteration. If a property name or object is provided it will be used - * to create a "_.pluck" or "_.where" style callback, respectively. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Array} Returns a new array of elements that passed the callback check. - * @example - * - * var evens = _.filter([1, 2, 3, 4, 5, 6], function(num) { return num % 2 == 0; }); - * // => [2, 4, 6] - * - * var food = [ - * { 'name': 'apple', 'organic': false, 'type': 'fruit' }, - * { 'name': 'carrot', 'organic': true, 'type': 'vegetable' } - * ]; - * - * // using "_.pluck" callback shorthand - * _.filter(food, 'organic'); - * // => [{ 'name': 'carrot', 'organic': true, 'type': 'vegetable' }] - * - * // using "_.where" callback shorthand - * _.filter(food, { 'type': 'fruit' }); - * // => [{ 'name': 'apple', 'organic': false, 'type': 'fruit' }] - */ - function filter(collection, callback, thisArg) { - var result = []; - callback = createCallback(callback, thisArg, 3); - - var index = -1, - length = collection ? collection.length : 0; - - if (typeof length == 'number') { - while (++index < length) { - var value = collection[index]; - if (callback(value, index, collection)) { - result.push(value); - } - } - } else { - forOwn(collection, function(value, index, collection) { - if (callback(value, index, collection)) { - result.push(value); - } - }); - } - return result; - } - - /** - * Iterates over elements of a collection, returning the first element that - * the callback returns truthy for. The callback is bound to `thisArg` and - * invoked with three arguments; (value, index|key, collection). - * - * If a property name is provided for `callback` the created "_.pluck" style - * callback will return the property value of the given element. - * - * If an object is provided for `callback` the created "_.where" style callback - * will return `true` for elements that have the properties of the given object, - * else `false`. - * - * @static - * @memberOf _ - * @alias detect, findWhere - * @category Collections - * @param {Array|Object|String} collection The collection to iterate over. - * @param {Function|Object|String} [callback=identity] The function called - * per iteration. If a property name or object is provided it will be used - * to create a "_.pluck" or "_.where" style callback, respectively. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Mixed} Returns the found element, else `undefined`. - * @example - * - * _.find([1, 2, 3, 4], function(num) { - * return num % 2 == 0; - * }); - * // => 2 - * - * var food = [ - * { 'name': 'apple', 'organic': false, 'type': 'fruit' }, - * { 'name': 'banana', 'organic': true, 'type': 'fruit' }, - * { 'name': 'beet', 'organic': false, 'type': 'vegetable' } - * ]; - * - * // using "_.where" callback shorthand - * _.find(food, { 'type': 'vegetable' }); - * // => { 'name': 'beet', 'organic': false, 'type': 'vegetable' } - * - * // using "_.pluck" callback shorthand - * _.find(food, 'organic'); - * // => { 'name': 'banana', 'organic': true, 'type': 'fruit' } - */ - function find(collection, callback, thisArg) { - callback = createCallback(callback, thisArg, 3); - - var index = -1, - length = collection ? collection.length : 0; - - if (typeof length == 'number') { - while (++index < length) { - var value = collection[index]; - if (callback(value, index, collection)) { - return value; - } - } - } else { - var result; - forOwn(collection, function(value, index, collection) { - if (callback(value, index, collection)) { - result = value; - return indicatorObject; - } - }); - return result; - } - } - - /** - * Examines each element in a `collection`, returning the first that - * has the given `properties`. When checking `properties`, this method - * performs a deep comparison between values to determine if they are - * equivalent to each other. - * - * @static - * @memberOf _ - * @category Collections - * @param {Array|Object|String} collection The collection to iterate over. - * @param {Object} properties The object of property values to filter by. - * @returns {Mixed} Returns the found element, else `undefined`. - * @example - * - * var food = [ - * { 'name': 'apple', 'organic': false, 'type': 'fruit' }, - * { 'name': 'banana', 'organic': true, 'type': 'fruit' }, - * { 'name': 'beet', 'organic': false, 'type': 'vegetable' } - * ]; - * - * _.findWhere(food, { 'type': 'vegetable' }); - * // => { 'name': 'beet', 'organic': false, 'type': 'vegetable' } - */ - function findWhere(object, properties) { - return where(object, properties, true); - } - - /** - * Iterates over elements of a collection, executing the callback for each - * element. The callback is bound to `thisArg` and invoked with three arguments; - * (value, index|key, collection). Callbacks may exit iteration early by - * explicitly returning `false`. - * - * @static - * @memberOf _ - * @alias each - * @category Collections - * @param {Array|Object|String} collection The collection to iterate over. - * @param {Function} [callback=identity] The function called per iteration. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Array|Object|String} Returns `collection`. - * @example - * - * _([1, 2, 3]).forEach(function(num) { console.log(num); }).join(','); - * // => logs each number and returns '1,2,3' - * - * _.forEach({ 'one': 1, 'two': 2, 'three': 3 }, function(num) { console.log(num); }); - * // => logs each number value and returns the object (order is not guaranteed) - */ - function forEach(collection, callback, thisArg) { - var index = -1, - length = collection ? collection.length : 0; - - callback = callback && typeof thisArg == 'undefined' ? callback : baseCreateCallback(callback, thisArg, 3); - if (typeof length == 'number') { - while (++index < length) { - if (callback(collection[index], index, collection) === indicatorObject) { - break; - } - } - } else { - forOwn(collection, callback); - } - } - - /** - * This method is like `_.forEach` except that it iterates over elements - * of a `collection` from right to left. - * - * @static - * @memberOf _ - * @alias eachRight - * @category Collections - * @param {Array|Object|String} collection The collection to iterate over. - * @param {Function} [callback=identity] The function called per iteration. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Array|Object|String} Returns `collection`. - * @example - * - * _([1, 2, 3]).forEachRight(function(num) { console.log(num); }).join(','); - * // => logs each number from right to left and returns '3,2,1' - */ - function forEachRight(collection, callback) { - var iterable = collection, - length = collection ? collection.length : 0; - - if (typeof length != 'number') { - var props = keys(collection); - length = props.length; - } - forEach(collection, function(value, index, collection) { - index = props ? props[--length] : --length; - return callback(iterable[index], index, collection) === false && indicatorObject; - }); - } - - /** - * Creates an object composed of keys generated from the results of running - * each element of a collection through the callback. The corresponding value - * of each key is an array of the elements responsible for generating the key. - * The callback is bound to `thisArg` and invoked with three arguments; - * (value, index|key, collection). - * - * If a property name is provided for `callback` the created "_.pluck" style - * callback will return the property value of the given element. - * - * If an object is provided for `callback` the created "_.where" style callback - * will return `true` for elements that have the properties of the given object, - * else `false` - * - * @static - * @memberOf _ - * @category Collections - * @param {Array|Object|String} collection The collection to iterate over. - * @param {Function|Object|String} [callback=identity] The function called - * per iteration. If a property name or object is provided it will be used - * to create a "_.pluck" or "_.where" style callback, respectively. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Object} Returns the composed aggregate object. - * @example - * - * _.groupBy([4.2, 6.1, 6.4], function(num) { return Math.floor(num); }); - * // => { '4': [4.2], '6': [6.1, 6.4] } - * - * _.groupBy([4.2, 6.1, 6.4], function(num) { return this.floor(num); }, Math); - * // => { '4': [4.2], '6': [6.1, 6.4] } - * - * // using "_.pluck" callback shorthand - * _.groupBy(['one', 'two', 'three'], 'length'); - * // => { '3': ['one', 'two'], '5': ['three'] } - */ - var groupBy = createAggregator(function(result, value, key) { - (hasOwnProperty.call(result, key) ? result[key] : result[key] = []).push(value); - }); - - /** - * Invokes the method named by `methodName` on each element in the `collection` - * returning an array of the results of each invoked method. Additional arguments - * will be provided to each invoked method. If `methodName` is a function it - * will be invoked for, and `this` bound to, each element in the `collection`. - * - * @static - * @memberOf _ - * @category Collections - * @param {Array|Object|String} collection The collection to iterate over. - * @param {Function|String} methodName The name of the method to invoke or - * the function invoked per iteration. - * @param {Mixed} [arg1, arg2, ...] Arguments to invoke the method with. - * @returns {Array} Returns a new array of the results of each invoked method. - * @example - * - * _.invoke([[5, 1, 7], [3, 2, 1]], 'sort'); - * // => [[1, 5, 7], [1, 2, 3]] - * - * _.invoke([123, 456], String.prototype.split, ''); - * // => [['1', '2', '3'], ['4', '5', '6']] - */ - function invoke(collection, methodName) { - var args = nativeSlice.call(arguments, 2), - index = -1, - isFunc = typeof methodName == 'function', - length = collection ? collection.length : 0, - result = Array(typeof length == 'number' ? length : 0); - - forEach(collection, function(value) { - result[++index] = (isFunc ? methodName : value[methodName]).apply(value, args); - }); - return result; - } - - /** - * Creates an array of values by running each element in the collection - * through the callback. The callback is bound to `thisArg` and invoked with - * three arguments; (value, index|key, collection). - * - * If a property name is provided for `callback` the created "_.pluck" style - * callback will return the property value of the given element. - * - * If an object is provided for `callback` the created "_.where" style callback - * will return `true` for elements that have the properties of the given object, - * else `false`. - * - * @static - * @memberOf _ - * @alias collect - * @category Collections - * @param {Array|Object|String} collection The collection to iterate over. - * @param {Function|Object|String} [callback=identity] The function called - * per iteration. If a property name or object is provided it will be used - * to create a "_.pluck" or "_.where" style callback, respectively. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Array} Returns a new array of the results of each `callback` execution. - * @example - * - * _.map([1, 2, 3], function(num) { return num * 3; }); - * // => [3, 6, 9] - * - * _.map({ 'one': 1, 'two': 2, 'three': 3 }, function(num) { return num * 3; }); - * // => [3, 6, 9] (order is not guaranteed) - * - * var stooges = [ - * { 'name': 'moe', 'age': 40 }, - * { 'name': 'larry', 'age': 50 } - * ]; - * - * // using "_.pluck" callback shorthand - * _.map(stooges, 'name'); - * // => ['moe', 'larry'] - */ - function map(collection, callback, thisArg) { - var index = -1, - length = collection ? collection.length : 0; - - callback = createCallback(callback, thisArg, 3); - if (typeof length == 'number') { - var result = Array(length); - while (++index < length) { - result[index] = callback(collection[index], index, collection); - } - } else { - result = []; - forOwn(collection, function(value, key, collection) { - result[++index] = callback(value, key, collection); - }); - } - return result; - } - - /** - * Retrieves the maximum value of an array. If a callback is provided it - * will be executed for each value in the array to generate the criterion by - * which the value is ranked. The callback is bound to `thisArg` and invoked - * with three arguments; (value, index, collection). - * - * If a property name is provided for `callback` the created "_.pluck" style - * callback will return the property value of the given element. - * - * If an object is provided for `callback` the created "_.where" style callback - * will return `true` for elements that have the properties of the given object, - * else `false`. - * - * @static - * @memberOf _ - * @category Collections - * @param {Array|Object|String} collection The collection to iterate over. - * @param {Function|Object|String} [callback=identity] The function called - * per iteration. If a property name or object is provided it will be used - * to create a "_.pluck" or "_.where" style callback, respectively. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Mixed} Returns the maximum value. - * @example - * - * _.max([4, 2, 8, 6]); - * // => 8 - * - * var stooges = [ - * { 'name': 'moe', 'age': 40 }, - * { 'name': 'larry', 'age': 50 } - * ]; - * - * _.max(stooges, function(stooge) { return stooge.age; }); - * // => { 'name': 'larry', 'age': 50 }; - * - * // using "_.pluck" callback shorthand - * _.max(stooges, 'age'); - * // => { 'name': 'larry', 'age': 50 }; - */ - function max(collection, callback, thisArg) { - var computed = -Infinity, - result = computed; - - var index = -1, - length = collection ? collection.length : 0; - - if (!callback && typeof length == 'number') { - while (++index < length) { - var value = collection[index]; - if (value > result) { - result = value; - } - } - } else { - callback = createCallback(callback, thisArg, 3); - - forEach(collection, function(value, index, collection) { - var current = callback(value, index, collection); - if (current > computed) { - computed = current; - result = value; - } - }); - } - return result; - } - - /** - * Retrieves the minimum value of an array. If a callback is provided it - * will be executed for each value in the array to generate the criterion by - * which the value is ranked. The callback is bound to `thisArg` and invoked - * with three arguments; (value, index, collection). - * - * If a property name is provided for `callback` the created "_.pluck" style - * callback will return the property value of the given element. - * - * If an object is provided for `callback` the created "_.where" style callback - * will return `true` for elements that have the properties of the given object, - * else `false`. - * - * @static - * @memberOf _ - * @category Collections - * @param {Array|Object|String} collection The collection to iterate over. - * @param {Function|Object|String} [callback=identity] The function called - * per iteration. If a property name or object is provided it will be used - * to create a "_.pluck" or "_.where" style callback, respectively. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Mixed} Returns the minimum value. - * @example - * - * _.min([4, 2, 8, 6]); - * // => 2 - * - * var stooges = [ - * { 'name': 'moe', 'age': 40 }, - * { 'name': 'larry', 'age': 50 } - * ]; - * - * _.min(stooges, function(stooge) { return stooge.age; }); - * // => { 'name': 'moe', 'age': 40 }; - * - * // using "_.pluck" callback shorthand - * _.min(stooges, 'age'); - * // => { 'name': 'moe', 'age': 40 }; - */ - function min(collection, callback, thisArg) { - var computed = Infinity, - result = computed; - - var index = -1, - length = collection ? collection.length : 0; - - if (!callback && typeof length == 'number') { - while (++index < length) { - var value = collection[index]; - if (value < result) { - result = value; - } - } - } else { - callback = createCallback(callback, thisArg, 3); - - forEach(collection, function(value, index, collection) { - var current = callback(value, index, collection); - if (current < computed) { - computed = current; - result = value; - } - }); - } - return result; - } - - /** - * Retrieves the value of a specified property from all elements in the `collection`. - * - * @static - * @memberOf _ - * @type Function - * @category Collections - * @param {Array|Object|String} collection The collection to iterate over. - * @param {String} property The property to pluck. - * @returns {Array} Returns a new array of property values. - * @example - * - * var stooges = [ - * { 'name': 'moe', 'age': 40 }, - * { 'name': 'larry', 'age': 50 } - * ]; - * - * _.pluck(stooges, 'name'); - * // => ['moe', 'larry'] - */ - function pluck(collection, property) { - var index = -1, - length = collection ? collection.length : 0; - - if (typeof length == 'number') { - var result = Array(length); - while (++index < length) { - result[index] = collection[index][property]; - } - } - return result || map(collection, property); - } - - /** - * Reduces a collection to a value which is the accumulated result of running - * each element in the collection through the callback, where each successive - * callback execution consumes the return value of the previous execution. If - * `accumulator` is not provided the first element of the collection will be - * used as the initial `accumulator` value. The callback is bound to `thisArg` - * and invoked with four arguments; (accumulator, value, index|key, collection). - * - * @static - * @memberOf _ - * @alias foldl, inject - * @category Collections - * @param {Array|Object|String} collection The collection to iterate over. - * @param {Function} [callback=identity] The function called per iteration. - * @param {Mixed} [accumulator] Initial value of the accumulator. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Mixed} Returns the accumulated value. - * @example - * - * var sum = _.reduce([1, 2, 3], function(sum, num) { - * return sum + num; - * }); - * // => 6 - * - * var mapped = _.reduce({ 'a': 1, 'b': 2, 'c': 3 }, function(result, num, key) { - * result[key] = num * 3; - * return result; - * }, {}); - * // => { 'a': 3, 'b': 6, 'c': 9 } - */ - function reduce(collection, callback, accumulator, thisArg) { - if (!collection) return accumulator; - var noaccum = arguments.length < 3; - callback = baseCreateCallback(callback, thisArg, 4); - - var index = -1, - length = collection.length; - - if (typeof length == 'number') { - if (noaccum) { - accumulator = collection[++index]; - } - while (++index < length) { - accumulator = callback(accumulator, collection[index], index, collection); - } - } else { - forOwn(collection, function(value, index, collection) { - accumulator = noaccum - ? (noaccum = false, value) - : callback(accumulator, value, index, collection) - }); - } - return accumulator; - } - - /** - * This method is like `_.reduce` except that it iterates over elements - * of a `collection` from right to left. - * - * @static - * @memberOf _ - * @alias foldr - * @category Collections - * @param {Array|Object|String} collection The collection to iterate over. - * @param {Function} [callback=identity] The function called per iteration. - * @param {Mixed} [accumulator] Initial value of the accumulator. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Mixed} Returns the accumulated value. - * @example - * - * var list = [[0, 1], [2, 3], [4, 5]]; - * var flat = _.reduceRight(list, function(a, b) { return a.concat(b); }, []); - * // => [4, 5, 2, 3, 0, 1] - */ - function reduceRight(collection, callback, accumulator, thisArg) { - var noaccum = arguments.length < 3; - callback = baseCreateCallback(callback, thisArg, 4); - forEachRight(collection, function(value, index, collection) { - accumulator = noaccum - ? (noaccum = false, value) - : callback(accumulator, value, index, collection); - }); - return accumulator; - } - - /** - * The opposite of `_.filter` this method returns the elements of a - * collection that the callback does **not** return truthy for. - * - * If a property name is provided for `callback` the created "_.pluck" style - * callback will return the property value of the given element. - * - * If an object is provided for `callback` the created "_.where" style callback - * will return `true` for elements that have the properties of the given object, - * else `false`. - * - * @static - * @memberOf _ - * @category Collections - * @param {Array|Object|String} collection The collection to iterate over. - * @param {Function|Object|String} [callback=identity] The function called - * per iteration. If a property name or object is provided it will be used - * to create a "_.pluck" or "_.where" style callback, respectively. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Array} Returns a new array of elements that failed the callback check. - * @example - * - * var odds = _.reject([1, 2, 3, 4, 5, 6], function(num) { return num % 2 == 0; }); - * // => [1, 3, 5] - * - * var food = [ - * { 'name': 'apple', 'organic': false, 'type': 'fruit' }, - * { 'name': 'carrot', 'organic': true, 'type': 'vegetable' } - * ]; - * - * // using "_.pluck" callback shorthand - * _.reject(food, 'organic'); - * // => [{ 'name': 'apple', 'organic': false, 'type': 'fruit' }] - * - * // using "_.where" callback shorthand - * _.reject(food, { 'type': 'fruit' }); - * // => [{ 'name': 'carrot', 'organic': true, 'type': 'vegetable' }] - */ - function reject(collection, callback, thisArg) { - callback = createCallback(callback, thisArg, 3); - return filter(collection, function(value, index, collection) { - return !callback(value, index, collection); - }); - } - - /** - * Creates an array of shuffled values, using a version of the Fisher-Yates - * shuffle. See http://en.wikipedia.org/wiki/Fisher-Yates_shuffle. - * - * @static - * @memberOf _ - * @category Collections - * @param {Array|Object|String} collection The collection to shuffle. - * @returns {Array} Returns a new shuffled collection. - * @example - * - * _.shuffle([1, 2, 3, 4, 5, 6]); - * // => [4, 1, 6, 3, 5, 2] - */ - function shuffle(collection) { - var index = -1, - length = collection ? collection.length : 0, - result = Array(typeof length == 'number' ? length : 0); - - forEach(collection, function(value) { - var rand = floor(nativeRandom() * (++index + 1)); - result[index] = result[rand]; - result[rand] = value; - }); - return result; - } - - /** - * Gets the size of the `collection` by returning `collection.length` for arrays - * and array-like objects or the number of own enumerable properties for objects. - * - * @static - * @memberOf _ - * @category Collections - * @param {Array|Object|String} collection The collection to inspect. - * @returns {Number} Returns `collection.length` or number of own enumerable properties. - * @example - * - * _.size([1, 2]); - * // => 2 - * - * _.size({ 'one': 1, 'two': 2, 'three': 3 }); - * // => 3 - * - * _.size('curly'); - * // => 5 - */ - function size(collection) { - var length = collection ? collection.length : 0; - return typeof length == 'number' ? length : keys(collection).length; - } - - /** - * Checks if the callback returns a truthy value for **any** element of a - * collection. The function returns as soon as it finds a passing value and - * does not iterate over the entire collection. The callback is bound to - * `thisArg` and invoked with three arguments; (value, index|key, collection). - * - * If a property name is provided for `callback` the created "_.pluck" style - * callback will return the property value of the given element. - * - * If an object is provided for `callback` the created "_.where" style callback - * will return `true` for elements that have the properties of the given object, - * else `false`. - * - * @static - * @memberOf _ - * @alias any - * @category Collections - * @param {Array|Object|String} collection The collection to iterate over. - * @param {Function|Object|String} [callback=identity] The function called - * per iteration. If a property name or object is provided it will be used - * to create a "_.pluck" or "_.where" style callback, respectively. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Boolean} Returns `true` if any element passed the callback check, - * else `false`. - * @example - * - * _.some([null, 0, 'yes', false], Boolean); - * // => true - * - * var food = [ - * { 'name': 'apple', 'organic': false, 'type': 'fruit' }, - * { 'name': 'carrot', 'organic': true, 'type': 'vegetable' } - * ]; - * - * // using "_.pluck" callback shorthand - * _.some(food, 'organic'); - * // => true - * - * // using "_.where" callback shorthand - * _.some(food, { 'type': 'meat' }); - * // => false - */ - function some(collection, callback, thisArg) { - var result; - callback = createCallback(callback, thisArg, 3); - - var index = -1, - length = collection ? collection.length : 0; - - if (typeof length == 'number') { - while (++index < length) { - if ((result = callback(collection[index], index, collection))) { - break; - } - } - } else { - forOwn(collection, function(value, index, collection) { - return (result = callback(value, index, collection)) && indicatorObject; - }); - } - return !!result; - } - - /** - * Creates an array of elements, sorted in ascending order by the results of - * running each element in a collection through the callback. This method - * performs a stable sort, that is, it will preserve the original sort order - * of equal elements. The callback is bound to `thisArg` and invoked with - * three arguments; (value, index|key, collection). - * - * If a property name is provided for `callback` the created "_.pluck" style - * callback will return the property value of the given element. - * - * If an object is provided for `callback` the created "_.where" style callback - * will return `true` for elements that have the properties of the given object, - * else `false`. - * - * @static - * @memberOf _ - * @category Collections - * @param {Array|Object|String} collection The collection to iterate over. - * @param {Function|Object|String} [callback=identity] The function called - * per iteration. If a property name or object is provided it will be used - * to create a "_.pluck" or "_.where" style callback, respectively. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Array} Returns a new array of sorted elements. - * @example - * - * _.sortBy([1, 2, 3], function(num) { return Math.sin(num); }); - * // => [3, 1, 2] - * - * _.sortBy([1, 2, 3], function(num) { return this.sin(num); }, Math); - * // => [3, 1, 2] - * - * // using "_.pluck" callback shorthand - * _.sortBy(['banana', 'strawberry', 'apple'], 'length'); - * // => ['apple', 'banana', 'strawberry'] - */ - function sortBy(collection, callback, thisArg) { - var index = -1, - length = collection ? collection.length : 0, - result = Array(typeof length == 'number' ? length : 0); - - callback = createCallback(callback, thisArg, 3); - forEach(collection, function(value, key, collection) { - result[++index] = { - 'criteria': callback(value, key, collection), - 'index': index, - 'value': value - }; - }); - - length = result.length; - result.sort(compareAscending); - while (length--) { - result[length] = result[length].value; - } - return result; - } - - /** - * Converts the `collection` to an array. - * - * @static - * @memberOf _ - * @category Collections - * @param {Array|Object|String} collection The collection to convert. - * @returns {Array} Returns the new converted array. - * @example - * - * (function() { return _.toArray(arguments).slice(1); })(1, 2, 3, 4); - * // => [2, 3, 4] - */ - function toArray(collection) { - if (isArray(collection)) { - return nativeSlice.call(collection); - } - if (collection && typeof collection.length == 'number') { - return map(collection); - } - return values(collection); - } - - /** - * Performs a deep comparison of each element in a `collection` to the given - * `properties` object, returning an array of all elements that have equivalent - * property values. - * - * @static - * @memberOf _ - * @type Function - * @category Collections - * @param {Array|Object|String} collection The collection to iterate over. - * @param {Object} properties The object of property values to filter by. - * @returns {Array} Returns a new array of elements that have the given `properties`. - * @example - * - * var stooges = [ - * { 'name': 'curly', 'age': 30, 'quotes': ['Oh, a wise guy, eh?', 'Poifect!'] }, - * { 'name': 'moe', 'age': '40', 'quotes': ['Spread out!', 'You knucklehead!'] } - * ]; - * - * _.where(stooges, { 'age': 40 }); - * // => [{ 'name': 'moe', 'age': '40', 'quotes': ['Spread out!', 'You knucklehead!'] }] - * - * _.where(stooges, { 'quotes': ['Poifect!'] }); - * // => [{ 'name': 'curly', 'age': 30, 'quotes': ['Oh, a wise guy, eh?', 'Poifect!'] }] - */ - function where(collection, properties, first) { - return (first && isEmpty(properties)) - ? undefined - : (first ? find : filter)(collection, properties); - } - - /*--------------------------------------------------------------------------*/ - - /** - * Creates an array with all falsey values removed. The values `false`, `null`, - * `0`, `""`, `undefined`, and `NaN` are all falsey. - * - * @static - * @memberOf _ - * @category Arrays - * @param {Array} array The array to compact. - * @returns {Array} Returns a new array of filtered values. - * @example - * - * _.compact([0, 1, false, 2, '', 3]); - * // => [1, 2, 3] - */ - function compact(array) { - var index = -1, - length = array ? array.length : 0, - result = []; - - while (++index < length) { - var value = array[index]; - if (value) { - result.push(value); - } - } - return result; - } - - /** - * Creates an array excluding all values of the provided arrays using strict - * equality for comparisons, i.e. `===`. - * - * @static - * @memberOf _ - * @category Arrays - * @param {Array} array The array to process. - * @param {Array} [array1, array2, ...] The arrays of values to exclude. - * @returns {Array} Returns a new array of filtered values. - * @example - * - * _.difference([1, 2, 3, 4, 5], [5, 2, 10]); - * // => [1, 3, 4] - */ - function difference(array) { - var index = -1, - indexOf = getIndexOf(), - length = array.length, - flattened = baseFlatten(arguments, true, true, 1), - result = []; - - while (++index < length) { - var value = array[index]; - if (indexOf(flattened, value) < 0) { - result.push(value); - } - } - return result; - } - - /** - * Gets the first element of an array. If a number `n` is provided the first - * `n` elements of the array are returned. If a callback is provided elements - * at the beginning of the array are returned as long as the callback returns - * truthy. The callback is bound to `thisArg` and invoked with three arguments; - * (value, index, array). - * - * If a property name is provided for `callback` the created "_.pluck" style - * callback will return the property value of the given element. - * - * If an object is provided for `callback` the created "_.where" style callback - * will return `true` for elements that have the properties of the given object, - * else `false`. - * - * @static - * @memberOf _ - * @alias head, take - * @category Arrays - * @param {Array} array The array to query. - * @param {Function|Object|Number|String} [callback|n] The function called - * per element or the number of elements to return. If a property name or - * object is provided it will be used to create a "_.pluck" or "_.where" - * style callback, respectively. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Mixed} Returns the first element(s) of `array`. - * @example - * - * _.first([1, 2, 3]); - * // => 1 - * - * _.first([1, 2, 3], 2); - * // => [1, 2] - * - * _.first([1, 2, 3], function(num) { - * return num < 3; - * }); - * // => [1, 2] - * - * var food = [ - * { 'name': 'banana', 'organic': true }, - * { 'name': 'beet', 'organic': false }, - * ]; - * - * // using "_.pluck" callback shorthand - * _.first(food, 'organic'); - * // => [{ 'name': 'banana', 'organic': true }] - * - * var food = [ - * { 'name': 'apple', 'type': 'fruit' }, - * { 'name': 'banana', 'type': 'fruit' }, - * { 'name': 'beet', 'type': 'vegetable' } - * ]; - * - * // using "_.where" callback shorthand - * _.first(food, { 'type': 'fruit' }); - * // => [{ 'name': 'apple', 'type': 'fruit' }, { 'name': 'banana', 'type': 'fruit' }] - */ - function first(array, callback, thisArg) { - if (array) { - var n = 0, - length = array.length; - - if (typeof callback != 'number' && callback != null) { - var index = -1; - callback = createCallback(callback, thisArg, 3); - while (++index < length && callback(array[index], index, array)) { - n++; - } - } else { - n = callback; - if (n == null || thisArg) { - return array[0]; - } - } - return nativeSlice.call(array, 0, nativeMin(nativeMax(0, n), length)); - } - } - - /** - * Flattens a nested array (the nesting can be to any depth). If `isShallow` - * is truthy, the array will only be flattened a single level. If a callback - * is provided each element of the array is passed through the callback before - * flattening. The callback is bound to `thisArg` and invoked with three - * arguments; (value, index, array). - * - * If a property name is provided for `callback` the created "_.pluck" style - * callback will return the property value of the given element. - * - * If an object is provided for `callback` the created "_.where" style callback - * will return `true` for elements that have the properties of the given object, - * else `false`. - * - * @static - * @memberOf _ - * @category Arrays - * @param {Array} array The array to flatten. - * @param {Boolean} [isShallow=false] A flag to restrict flattening to a single level. - * @param {Function|Object|String} [callback=identity] The function called - * per iteration. If a property name or object is provided it will be used - * to create a "_.pluck" or "_.where" style callback, respectively. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Array} Returns a new flattened array. - * @example - * - * _.flatten([1, [2], [3, [[4]]]]); - * // => [1, 2, 3, 4]; - * - * _.flatten([1, [2], [3, [[4]]]], true); - * // => [1, 2, 3, [[4]]]; - * - * var stooges = [ - * { 'name': 'curly', 'quotes': ['Oh, a wise guy, eh?', 'Poifect!'] }, - * { 'name': 'moe', 'quotes': ['Spread out!', 'You knucklehead!'] } - * ]; - * - * // using "_.pluck" callback shorthand - * _.flatten(stooges, 'quotes'); - * // => ['Oh, a wise guy, eh?', 'Poifect!', 'Spread out!', 'You knucklehead!'] - */ - function flatten(array, isShallow) { - return baseFlatten(array, isShallow); - } - - /** - * Gets the index at which the first occurrence of `value` is found using - * strict equality for comparisons, i.e. `===`. If the array is already sorted - * providing `true` for `fromIndex` will run a faster binary search. - * - * @static - * @memberOf _ - * @category Arrays - * @param {Array} array The array to search. - * @param {Mixed} value The value to search for. - * @param {Boolean|Number} [fromIndex=0] The index to search from or `true` - * to perform a binary search on a sorted array. - * @returns {Number} Returns the index of the matched value or `-1`. - * @example - * - * _.indexOf([1, 2, 3, 1, 2, 3], 2); - * // => 1 - * - * _.indexOf([1, 2, 3, 1, 2, 3], 2, 3); - * // => 4 - * - * _.indexOf([1, 1, 2, 2, 3, 3], 2, true); - * // => 2 - */ - function indexOf(array, value, fromIndex) { - if (typeof fromIndex == 'number') { - var length = array ? array.length : 0; - fromIndex = (fromIndex < 0 ? nativeMax(0, length + fromIndex) : fromIndex || 0); - } else if (fromIndex) { - var index = sortedIndex(array, value); - return array[index] === value ? index : -1; - } - return array ? baseIndexOf(array, value, fromIndex) : -1; - } - - /** - * Gets all but the last element of an array. If a number `n` is provided - * the last `n` elements are excluded from the result. If a callback is - * provided elements at the end of the array are excluded from the result - * as long as the callback returns truthy. The callback is bound to `thisArg` - * and invoked with three arguments; (value, index, array). - * - * If a property name is provided for `callback` the created "_.pluck" style - * callback will return the property value of the given element. - * - * If an object is provided for `callback` the created "_.where" style callback - * will return `true` for elements that have the properties of the given object, - * else `false`. - * - * @static - * @memberOf _ - * @category Arrays - * @param {Array} array The array to query. - * @param {Function|Object|Number|String} [callback|n=1] The function called - * per element or the number of elements to exclude. If a property name or - * object is provided it will be used to create a "_.pluck" or "_.where" - * style callback, respectively. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Array} Returns a slice of `array`. - * @example - * - * _.initial([1, 2, 3]); - * // => [1, 2] - * - * _.initial([1, 2, 3], 2); - * // => [1] - * - * _.initial([1, 2, 3], function(num) { - * return num > 1; - * }); - * // => [1] - * - * var food = [ - * { 'name': 'beet', 'organic': false }, - * { 'name': 'carrot', 'organic': true } - * ]; - * - * // using "_.pluck" callback shorthand - * _.initial(food, 'organic'); - * // => [{ 'name': 'beet', 'organic': false }] - * - * var food = [ - * { 'name': 'banana', 'type': 'fruit' }, - * { 'name': 'beet', 'type': 'vegetable' }, - * { 'name': 'carrot', 'type': 'vegetable' } - * ]; - * - * // using "_.where" callback shorthand - * _.initial(food, { 'type': 'vegetable' }); - * // => [{ 'name': 'banana', 'type': 'fruit' }] - */ - function initial(array, callback, thisArg) { - if (!array) { - return []; - } - var n = 0, - length = array.length; - - if (typeof callback != 'number' && callback != null) { - var index = length; - callback = createCallback(callback, thisArg, 3); - while (index-- && callback(array[index], index, array)) { - n++; - } - } else { - n = (callback == null || thisArg) ? 1 : callback || n; - } - return nativeSlice.call(array, 0, nativeMin(nativeMax(0, length - n), length)); - } - - /** - * Creates an array of unique values present in all provided arrays using - * strict equality for comparisons, i.e. `===`. - * - * @static - * @memberOf _ - * @category Arrays - * @param {Array} [array1, array2, ...] The arrays to inspect. - * @returns {Array} Returns an array of composite values. - * @example - * - * _.intersection([1, 2, 3], [101, 2, 1, 10], [2, 1]); - * // => [1, 2] - */ - function intersection(array) { - var args = arguments, - argsLength = args.length, - index = -1, - indexOf = getIndexOf(), - length = array ? array.length : 0, - result = []; - - outer: - while (++index < length) { - var value = array[index]; - if (indexOf(result, value) < 0) { - var argsIndex = argsLength; - while (--argsIndex) { - if (indexOf(args[argsIndex], value) < 0) { - continue outer; - } - } - result.push(value); - } - } - return result; - } - - /** - * Gets the last element of an array. If a number `n` is provided the last - * `n` elements of the array are returned. If a callback is provided elements - * at the end of the array are returned as long as the callback returns truthy. - * The callback is bound to `thisArg` and invoked with three arguments; - * (value, index, array). - * - * - * If a property name is provided for `callback` the created "_.pluck" style - * callback will return the property value of the given element. - * - * If an object is provided for `callback` the created "_.where" style callback - * will return `true` for elements that have the properties of the given object, - * else `false`. - * - * @static - * @memberOf _ - * @category Arrays - * @param {Array} array The array to query. - * @param {Function|Object|Number|String} [callback|n] The function called - * per element or the number of elements to return. If a property name or - * object is provided it will be used to create a "_.pluck" or "_.where" - * style callback, respectively. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Mixed} Returns the last element(s) of `array`. - * @example - * - * _.last([1, 2, 3]); - * // => 3 - * - * _.last([1, 2, 3], 2); - * // => [2, 3] - * - * _.last([1, 2, 3], function(num) { - * return num > 1; - * }); - * // => [2, 3] - * - * var food = [ - * { 'name': 'beet', 'organic': false }, - * { 'name': 'carrot', 'organic': true } - * ]; - * - * // using "_.pluck" callback shorthand - * _.last(food, 'organic'); - * // => [{ 'name': 'carrot', 'organic': true }] - * - * var food = [ - * { 'name': 'banana', 'type': 'fruit' }, - * { 'name': 'beet', 'type': 'vegetable' }, - * { 'name': 'carrot', 'type': 'vegetable' } - * ]; - * - * // using "_.where" callback shorthand - * _.last(food, { 'type': 'vegetable' }); - * // => [{ 'name': 'beet', 'type': 'vegetable' }, { 'name': 'carrot', 'type': 'vegetable' }] - */ - function last(array, callback, thisArg) { - if (array) { - var n = 0, - length = array.length; - - if (typeof callback != 'number' && callback != null) { - var index = length; - callback = createCallback(callback, thisArg, 3); - while (index-- && callback(array[index], index, array)) { - n++; - } - } else { - n = callback; - if (n == null || thisArg) { - return array[length - 1]; - } - } - return nativeSlice.call(array, nativeMax(0, length - n)); - } - } - - /** - * Gets the index at which the last occurrence of `value` is found using strict - * equality for comparisons, i.e. `===`. If `fromIndex` is negative, it is used - * as the offset from the end of the collection. - * - * @static - * @memberOf _ - * @category Arrays - * @param {Array} array The array to search. - * @param {Mixed} value The value to search for. - * @param {Number} [fromIndex=array.length-1] The index to search from. - * @returns {Number} Returns the index of the matched value or `-1`. - * @example - * - * _.lastIndexOf([1, 2, 3, 1, 2, 3], 2); - * // => 4 - * - * _.lastIndexOf([1, 2, 3, 1, 2, 3], 2, 3); - * // => 1 - */ - function lastIndexOf(array, value, fromIndex) { - var index = array ? array.length : 0; - if (typeof fromIndex == 'number') { - index = (fromIndex < 0 ? nativeMax(0, index + fromIndex) : nativeMin(fromIndex, index - 1)) + 1; - } - while (index--) { - if (array[index] === value) { - return index; - } - } - return -1; - } - - /** - * Creates an array of numbers (positive and/or negative) progressing from - * `start` up to but not including `end`. If `start` is less than `stop` a - * zero-length range is created unless a negative `step` is specified. - * - * @static - * @memberOf _ - * @category Arrays - * @param {Number} [start=0] The start of the range. - * @param {Number} end The end of the range. - * @param {Number} [step=1] The value to increment or decrement by. - * @returns {Array} Returns a new range array. - * @example - * - * _.range(10); - * // => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] - * - * _.range(1, 11); - * // => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] - * - * _.range(0, 30, 5); - * // => [0, 5, 10, 15, 20, 25] - * - * _.range(0, -10, -1); - * // => [0, -1, -2, -3, -4, -5, -6, -7, -8, -9] - * - * _.range(1, 4, 0); - * // => [1, 1, 1] - * - * _.range(0); - * // => [] - */ - function range(start, end, step) { - start = +start || 0; - step = (+step || 1); - - if (end == null) { - end = start; - start = 0; - } - // use `Array(length)` so engines, like Chakra and V8, avoid slower modes - // http://youtu.be/XAqIpGU8ZZk#t=17m25s - var index = -1, - length = nativeMax(0, ceil((end - start) / step)), - result = Array(length); - - while (++index < length) { - result[index] = start; - start += step; - } - return result; - } - - /** - * The opposite of `_.initial` this method gets all but the first value of - * an array. If a number `n` is provided the first `n` values are excluded - * from the result. If a callback function is provided elements at the beginning - * of the array are excluded from the result as long as the callback returns - * truthy. The callback is bound to `thisArg` and invoked with three - * arguments; (value, index, array). - * - * If a property name is provided for `callback` the created "_.pluck" style - * callback will return the property value of the given element. - * - * If an object is provided for `callback` the created "_.where" style callback - * will return `true` for elements that have the properties of the given object, - * else `false`. - * - * @static - * @memberOf _ - * @alias drop, tail - * @category Arrays - * @param {Array} array The array to query. - * @param {Function|Object|Number|String} [callback|n=1] The function called - * per element or the number of elements to exclude. If a property name or - * object is provided it will be used to create a "_.pluck" or "_.where" - * style callback, respectively. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Array} Returns a slice of `array`. - * @example - * - * _.rest([1, 2, 3]); - * // => [2, 3] - * - * _.rest([1, 2, 3], 2); - * // => [3] - * - * _.rest([1, 2, 3], function(num) { - * return num < 3; - * }); - * // => [3] - * - * var food = [ - * { 'name': 'banana', 'organic': true }, - * { 'name': 'beet', 'organic': false }, - * ]; - * - * // using "_.pluck" callback shorthand - * _.rest(food, 'organic'); - * // => [{ 'name': 'beet', 'organic': false }] - * - * var food = [ - * { 'name': 'apple', 'type': 'fruit' }, - * { 'name': 'banana', 'type': 'fruit' }, - * { 'name': 'beet', 'type': 'vegetable' } - * ]; - * - * // using "_.where" callback shorthand - * _.rest(food, { 'type': 'fruit' }); - * // => [{ 'name': 'beet', 'type': 'vegetable' }] - */ - function rest(array, callback, thisArg) { - if (typeof callback != 'number' && callback != null) { - var n = 0, - index = -1, - length = array ? array.length : 0; - - callback = createCallback(callback, thisArg, 3); - while (++index < length && callback(array[index], index, array)) { - n++; - } - } else { - n = (callback == null || thisArg) ? 1 : nativeMax(0, callback); - } - return nativeSlice.call(array, n); - } - - /** - * Uses a binary search to determine the smallest index at which a value - * should be inserted into a given sorted array in order to maintain the sort - * order of the array. If a callback is provided it will be executed for - * `value` and each element of `array` to compute their sort ranking. The - * callback is bound to `thisArg` and invoked with one argument; (value). - * - * If a property name is provided for `callback` the created "_.pluck" style - * callback will return the property value of the given element. - * - * If an object is provided for `callback` the created "_.where" style callback - * will return `true` for elements that have the properties of the given object, - * else `false`. - * - * @static - * @memberOf _ - * @category Arrays - * @param {Array} array The array to inspect. - * @param {Mixed} value The value to evaluate. - * @param {Function|Object|String} [callback=identity] The function called - * per iteration. If a property name or object is provided it will be used - * to create a "_.pluck" or "_.where" style callback, respectively. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Number} Returns the index at which `value` should be inserted - * into `array`. - * @example - * - * _.sortedIndex([20, 30, 50], 40); - * // => 2 - * - * // using "_.pluck" callback shorthand - * _.sortedIndex([{ 'x': 20 }, { 'x': 30 }, { 'x': 50 }], { 'x': 40 }, 'x'); - * // => 2 - * - * var dict = { - * 'wordToNumber': { 'twenty': 20, 'thirty': 30, 'fourty': 40, 'fifty': 50 } - * }; - * - * _.sortedIndex(['twenty', 'thirty', 'fifty'], 'fourty', function(word) { - * return dict.wordToNumber[word]; - * }); - * // => 2 - * - * _.sortedIndex(['twenty', 'thirty', 'fifty'], 'fourty', function(word) { - * return this.wordToNumber[word]; - * }, dict); - * // => 2 - */ - function sortedIndex(array, value, callback, thisArg) { - var low = 0, - high = array ? array.length : low; - - // explicitly reference `identity` for better inlining in Firefox - callback = callback ? createCallback(callback, thisArg, 1) : identity; - value = callback(value); - - while (low < high) { - var mid = (low + high) >>> 1; - (callback(array[mid]) < value) - ? low = mid + 1 - : high = mid; - } - return low; - } - - /** - * Creates an array of unique values, in order, of the provided arrays using - * strict equality for comparisons, i.e. `===`. - * - * @static - * @memberOf _ - * @category Arrays - * @param {Array} [array1, array2, ...] The arrays to inspect. - * @returns {Array} Returns an array of composite values. - * @example - * - * _.union([1, 2, 3], [101, 2, 1, 10], [2, 1]); - * // => [1, 2, 3, 101, 10] - */ - function union(array) { - return baseUniq(baseFlatten(arguments, true, true)); - } - - /** - * Creates a duplicate-value-free version of an array using strict equality - * for comparisons, i.e. `===`. If the array is sorted, providing - * `true` for `isSorted` will use a faster algorithm. If a callback is provided - * each element of `array` is passed through the callback before uniqueness - * is computed. The callback is bound to `thisArg` and invoked with three - * arguments; (value, index, array). - * - * If a property name is provided for `callback` the created "_.pluck" style - * callback will return the property value of the given element. - * - * If an object is provided for `callback` the created "_.where" style callback - * will return `true` for elements that have the properties of the given object, - * else `false`. - * - * @static - * @memberOf _ - * @alias unique - * @category Arrays - * @param {Array} array The array to process. - * @param {Boolean} [isSorted=false] A flag to indicate that `array` is sorted. - * @param {Function|Object|String} [callback=identity] The function called - * per iteration. If a property name or object is provided it will be used - * to create a "_.pluck" or "_.where" style callback, respectively. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Array} Returns a duplicate-value-free array. - * @example - * - * _.uniq([1, 2, 1, 3, 1]); - * // => [1, 2, 3] - * - * _.uniq([1, 1, 2, 2, 3], true); - * // => [1, 2, 3] - * - * _.uniq(['A', 'b', 'C', 'a', 'B', 'c'], function(letter) { return letter.toLowerCase(); }); - * // => ['A', 'b', 'C'] - * - * _.uniq([1, 2.5, 3, 1.5, 2, 3.5], function(num) { return this.floor(num); }, Math); - * // => [1, 2.5, 3] - * - * // using "_.pluck" callback shorthand - * _.uniq([{ 'x': 1 }, { 'x': 2 }, { 'x': 1 }], 'x'); - * // => [{ 'x': 1 }, { 'x': 2 }] - */ - function uniq(array, isSorted, callback, thisArg) { - // juggle arguments - if (typeof isSorted != 'boolean' && isSorted != null) { - thisArg = callback; - callback = !(thisArg && thisArg[isSorted] === array) ? isSorted : undefined; - isSorted = false; - } - if (callback != null) { - callback = createCallback(callback, thisArg, 3); - } - return baseUniq(array, isSorted, callback); - } - - /** - * Creates an array excluding all provided values using strict equality for - * comparisons, i.e. `===`. - * - * @static - * @memberOf _ - * @category Arrays - * @param {Array} array The array to filter. - * @param {Mixed} [value1, value2, ...] The values to exclude. - * @returns {Array} Returns a new array of filtered values. - * @example - * - * _.without([1, 2, 1, 0, 3, 1, 4], 0, 1); - * // => [2, 3, 4] - */ - function without(array) { - return difference(array, nativeSlice.call(arguments, 1)); - } - - /** - * Creates an array of grouped elements, the first of which contains the first - * elements of the given arrays, the second of which contains the second - * elements of the given arrays, and so on. - * - * @static - * @memberOf _ - * @alias unzip - * @category Arrays - * @param {Array} [array1, array2, ...] Arrays to process. - * @returns {Array} Returns a new array of grouped elements. - * @example - * - * _.zip(['moe', 'larry'], [30, 40], [true, false]); - * // => [['moe', 30, true], ['larry', 40, false]] - */ - function zip() { - var index = -1, - length = max(pluck(arguments, 'length')), - result = Array(length < 0 ? 0 : length); - - while (++index < length) { - result[index] = pluck(arguments, index); - } - return result; - } - - /** - * Creates an object composed from arrays of `keys` and `values`. Provide - * either a single two dimensional array, i.e. `[[key1, value1], [key2, value2]]` - * or two arrays, one of `keys` and one of corresponding `values`. - * - * @static - * @memberOf _ - * @alias object - * @category Arrays - * @param {Array} keys The array of keys. - * @param {Array} [values=[]] The array of values. - * @returns {Object} Returns an object composed of the given keys and - * corresponding values. - * @example - * - * _.zipObject(['moe', 'larry'], [30, 40]); - * // => { 'moe': 30, 'larry': 40 } - */ - function zipObject(keys, values) { - var index = -1, - length = keys ? keys.length : 0, - result = {}; - - while (++index < length) { - var key = keys[index]; - if (values) { - result[key] = values[index]; - } else if (key) { - result[key[0]] = key[1]; - } - } - return result; - } - - /*--------------------------------------------------------------------------*/ - - /** - * Creates a function this is restricted to executing `func` with the `this` - * binding and arguments of the created function, only after it is called `n` times. - * - * @static - * @memberOf _ - * @category Functions - * @param {Number} n The number of times the function must be called before - * `func` is executed. - * @param {Function} func The function to restrict. - * @returns {Function} Returns the new restricted function. - * @example - * - * var renderNotes = _.after(notes.length, render); - * _.forEach(notes, function(note) { - * note.asyncSave({ 'success': renderNotes }); - * }); - * // `renderNotes` is run once, after all notes have saved - */ - function after(n, func) { - if (!isFunction(func)) { - throw new TypeError; - } - return function() { - if (--n < 1) { - return func.apply(this, arguments); - } - }; - } - - /** - * Creates a function that, when called, invokes `func` with the `this` - * binding of `thisArg` and prepends any additional `bind` arguments to those - * provided to the bound function. - * - * @static - * @memberOf _ - * @category Functions - * @param {Function} func The function to bind. - * @param {Mixed} [thisArg] The `this` binding of `func`. - * @param {Mixed} [arg1, arg2, ...] Arguments to be partially applied. - * @returns {Function} Returns the new bound function. - * @example - * - * var func = function(greeting) { - * return greeting + ' ' + this.name; - * }; - * - * func = _.bind(func, { 'name': 'moe' }, 'hi'); - * func(); - * // => 'hi moe' - */ - function bind(func, thisArg) { - return createBound(func, 17, nativeSlice.call(arguments, 2), null, thisArg); - } - - /** - * Binds methods of an object to the object itself, overwriting the existing - * method. Method names may be specified as individual arguments or as arrays - * of method names. If no method names are provided all the function properties - * of `object` will be bound. - * - * @static - * @memberOf _ - * @category Functions - * @param {Object} object The object to bind and assign the bound methods to. - * @param {String} [methodName1, methodName2, ...] The object method names to - * bind, specified as individual method names or arrays of method names. - * @returns {Object} Returns `object`. - * @example - * - * var view = { - * 'label': 'docs', - * 'onClick': function() { console.log('clicked ' + this.label); } - * }; - * - * _.bindAll(view); - * jQuery('#docs').on('click', view.onClick); - * // => logs 'clicked docs', when the button is clicked - */ - function bindAll(object) { - var funcs = arguments.length > 1 ? baseFlatten(arguments, true, false, 1) : functions(object), - index = -1, - length = funcs.length; - - while (++index < length) { - var key = funcs[index]; - object[key] = bind(object[key], object); - } - return object; - } - - /** - * Creates a function that is the composition of the provided functions, - * where each function consumes the return value of the function that follows. - * For example, composing the functions `f()`, `g()`, and `h()` produces `f(g(h()))`. - * Each function is executed with the `this` binding of the composed function. - * - * @static - * @memberOf _ - * @category Functions - * @param {Function} [func1, func2, ...] Functions to compose. - * @returns {Function} Returns the new composed function. - * @example - * - * var realNameMap = { - * 'curly': 'jerome' - * }; - * - * var format = function(name) { - * name = realNameMap[name.toLowerCase()] || name; - * return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase(); - * }; - * - * var greet = function(formatted) { - * return 'Hiya ' + formatted + '!'; - * }; - * - * var welcome = _.compose(greet, format); - * welcome('curly'); - * // => 'Hiya Jerome!' - */ - function compose() { - var funcs = arguments, - length = funcs.length || 1; - - while (length--) { - if (!isFunction(funcs[length])) { - throw new TypeError; - } - } - return function() { - var args = arguments, - length = funcs.length; - - while (length--) { - args = [funcs[length].apply(this, args)]; - } - return args[0]; - }; - } - - /** - * Produces a callback bound to an optional `thisArg`. If `func` is a property - * name the created callback will return the property value for a given element. - * If `func` is an object the created callback will return `true` for elements - * that contain the equivalent object properties, otherwise it will return `false`. - * - * @static - * @memberOf _ - * @category Functions - * @param {Mixed} [func=identity] The value to convert to a callback. - * @param {Mixed} [thisArg] The `this` binding of the created callback. - * @param {Number} [argCount] The number of arguments the callback accepts. - * @returns {Function} Returns a callback function. - * @example - * - * var stooges = [ - * { 'name': 'moe', 'age': 40 }, - * { 'name': 'larry', 'age': 50 } - * ]; - * - * // wrap to create custom callback shorthands - * _.createCallback = _.wrap(_.createCallback, function(func, callback, thisArg) { - * var match = /^(.+?)__([gl]t)(.+)$/.exec(callback); - * return !match ? func(callback, thisArg) : function(object) { - * return match[2] == 'gt' ? object[match[1]] > match[3] : object[match[1]] < match[3]; - * }; - * }); - * - * _.filter(stooges, 'age__gt45'); - * // => [{ 'name': 'larry', 'age': 50 }] - */ - function createCallback(func, thisArg, argCount) { - var type = typeof func; - if (func == null || type == 'function') { - return baseCreateCallback(func, thisArg, argCount); - } - // handle "_.pluck" style callback shorthands - if (type != 'object') { - return function(object) { - return object[func]; - }; - } - var props = keys(func); - return function(object) { - var length = props.length, - result = false; - - while (length--) { - if (!(result = object[props[length]] === func[props[length]])) { - break; - } - } - return result; - }; - } - - /** - * Creates a function that will delay the execution of `func` until after - * `wait` milliseconds have elapsed since the last time it was invoked. - * Provide an options object to indicate that `func` should be invoked on - * the leading and/or trailing edge of the `wait` timeout. Subsequent calls - * to the debounced function will return the result of the last `func` call. - * - * Note: If `leading` and `trailing` options are `true` `func` will be called - * on the trailing edge of the timeout only if the the debounced function is - * invoked more than once during the `wait` timeout. - * - * @static - * @memberOf _ - * @category Functions - * @param {Function} func The function to debounce. - * @param {Number} wait The number of milliseconds to delay. - * @param {Object} options The options object. - * [leading=false] A boolean to specify execution on the leading edge of the timeout. - * [maxWait] The maximum time `func` is allowed to be delayed before it's called. - * [trailing=true] A boolean to specify execution on the trailing edge of the timeout. - * @returns {Function} Returns the new debounced function. - * @example - * - * // avoid costly calculations while the window size is in flux - * var lazyLayout = _.debounce(calculateLayout, 150); - * jQuery(window).on('resize', lazyLayout); - * - * // execute `sendMail` when the click event is fired, debouncing subsequent calls - * jQuery('#postbox').on('click', _.debounce(sendMail, 300, { - * 'leading': true, - * 'trailing': false - * }); - * - * // ensure `batchLog` is executed once after 1 second of debounced calls - * var source = new EventSource('/stream'); - * source.addEventListener('message', _.debounce(batchLog, 250, { - * 'maxWait': 1000 - * }, false); - */ - function debounce(func, wait, options) { - var args, - result, - thisArg, - callCount = 0, - lastCalled = 0, - maxWait = false, - maxTimeoutId = null, - timeoutId = null, - trailing = true; - - if (!isFunction(func)) { - throw new TypeError; - } - wait = nativeMax(0, wait || 0); - if (options === true) { - var leading = true; - trailing = false; - } else if (isObject(options)) { - leading = options.leading; - maxWait = 'maxWait' in options && nativeMax(wait, options.maxWait || 0); - trailing = 'trailing' in options ? options.trailing : trailing; - } - var clear = function() { - clearTimeout(maxTimeoutId); - clearTimeout(timeoutId); - callCount = 0; - maxTimeoutId = timeoutId = null; - }; - - var delayed = function() { - var isCalled = trailing && (!leading || callCount > 1); - clear(); - if (isCalled) { - if (maxWait !== false) { - lastCalled = +new Date; - } - result = func.apply(thisArg, args); - } - }; - - var maxDelayed = function() { - clear(); - if (trailing || (maxWait !== wait)) { - lastCalled = +new Date; - result = func.apply(thisArg, args); - } - }; - - return function() { - args = arguments; - thisArg = this; - callCount++; - - // avoid issues with Titanium and `undefined` timeout ids - // https://github.com/appcelerator/titanium_mobile/blob/3_1_0_GA/android/titanium/src/java/ti/modules/titanium/TitaniumModule.java#L185-L192 - clearTimeout(timeoutId); - - if (maxWait === false) { - if (leading && callCount < 2) { - result = func.apply(thisArg, args); - } - } else { - var stamp = +new Date; - if (!maxTimeoutId && !leading) { - lastCalled = stamp; - } - var remaining = maxWait - (stamp - lastCalled); - if (remaining <= 0) { - clearTimeout(maxTimeoutId); - maxTimeoutId = null; - lastCalled = stamp; - result = func.apply(thisArg, args); - } - else if (!maxTimeoutId) { - maxTimeoutId = setTimeout(maxDelayed, remaining); - } - } - if (wait !== maxWait) { - timeoutId = setTimeout(delayed, wait); - } - return result; - }; - } - - /** - * Defers executing the `func` function until the current call stack has cleared. - * Additional arguments will be provided to `func` when it is invoked. - * - * @static - * @memberOf _ - * @category Functions - * @param {Function} func The function to defer. - * @param {Mixed} [arg1, arg2, ...] Arguments to invoke the function with. - * @returns {Number} Returns the timer id. - * @example - * - * _.defer(function() { console.log('deferred'); }); - * // returns from the function before 'deferred' is logged - */ - function defer(func) { - if (!isFunction(func)) { - throw new TypeError; - } - var args = nativeSlice.call(arguments, 1); - return setTimeout(function() { func.apply(undefined, args); }, 1); - } - - /** - * Executes the `func` function after `wait` milliseconds. Additional arguments - * will be provided to `func` when it is invoked. - * - * @static - * @memberOf _ - * @category Functions - * @param {Function} func The function to delay. - * @param {Number} wait The number of milliseconds to delay execution. - * @param {Mixed} [arg1, arg2, ...] Arguments to invoke the function with. - * @returns {Number} Returns the timer id. - * @example - * - * var log = _.bind(console.log, console); - * _.delay(log, 1000, 'logged later'); - * // => 'logged later' (Appears after one second.) - */ - function delay(func, wait) { - if (!isFunction(func)) { - throw new TypeError; - } - var args = nativeSlice.call(arguments, 2); - return setTimeout(function() { func.apply(undefined, args); }, wait); - } - - /** - * Creates a function that memoizes the result of `func`. If `resolver` is - * provided it will be used to determine the cache key for storing the result - * based on the arguments provided to the memoized function. By default, the - * first argument provided to the memoized function is used as the cache key. - * The `func` is executed with the `this` binding of the memoized function. - * The result cache is exposed as the `cache` property on the memoized function. - * - * @static - * @memberOf _ - * @category Functions - * @param {Function} func The function to have its output memoized. - * @param {Function} [resolver] A function used to resolve the cache key. - * @returns {Function} Returns the new memoizing function. - * @example - * - * var fibonacci = _.memoize(function(n) { - * return n < 2 ? n : fibonacci(n - 1) + fibonacci(n - 2); - * }); - */ - function memoize(func, resolver) { - var cache = {}; - return function() { - var key = keyPrefix + (resolver ? resolver.apply(this, arguments) : arguments[0]); - return hasOwnProperty.call(cache, key) - ? cache[key] - : (cache[key] = func.apply(this, arguments)); - }; - } - - /** - * Creates a function that is restricted to execute `func` once. Repeat calls to - * the function will return the value of the first call. The `func` is executed - * with the `this` binding of the created function. - * - * @static - * @memberOf _ - * @category Functions - * @param {Function} func The function to restrict. - * @returns {Function} Returns the new restricted function. - * @example - * - * var initialize = _.once(createApplication); - * initialize(); - * initialize(); - * // `initialize` executes `createApplication` once - */ - function once(func) { - var ran, - result; - - if (!isFunction(func)) { - throw new TypeError; - } - return function() { - if (ran) { - return result; - } - ran = true; - result = func.apply(this, arguments); - - // clear the `func` variable so the function may be garbage collected - func = null; - return result; - }; - } - - /** - * Creates a function that, when called, invokes `func` with any additional - * `partial` arguments prepended to those provided to the new function. This - * method is similar to `_.bind` except it does **not** alter the `this` binding. - * - * @static - * @memberOf _ - * @category Functions - * @param {Function} func The function to partially apply arguments to. - * @param {Mixed} [arg1, arg2, ...] Arguments to be partially applied. - * @returns {Function} Returns the new partially applied function. - * @example - * - * var greet = function(greeting, name) { return greeting + ' ' + name; }; - * var hi = _.partial(greet, 'hi'); - * hi('moe'); - * // => 'hi moe' - */ - function partial(func) { - return createBound(func, 16, nativeSlice.call(arguments, 1)); - } - - /** - * Creates a function that, when executed, will only call the `func` function - * at most once per every `wait` milliseconds. Provide an options object to - * indicate that `func` should be invoked on the leading and/or trailing edge - * of the `wait` timeout. Subsequent calls to the throttled function will - * return the result of the last `func` call. - * - * Note: If `leading` and `trailing` options are `true` `func` will be called - * on the trailing edge of the timeout only if the the throttled function is - * invoked more than once during the `wait` timeout. - * - * @static - * @memberOf _ - * @category Functions - * @param {Function} func The function to throttle. - * @param {Number} wait The number of milliseconds to throttle executions to. - * @param {Object} options The options object. - * [leading=true] A boolean to specify execution on the leading edge of the timeout. - * [trailing=true] A boolean to specify execution on the trailing edge of the timeout. - * @returns {Function} Returns the new throttled function. - * @example - * - * // avoid excessively updating the position while scrolling - * var throttled = _.throttle(updatePosition, 100); - * jQuery(window).on('scroll', throttled); - * - * // execute `renewToken` when the click event is fired, but not more than once every 5 minutes - * jQuery('.interactive').on('click', _.throttle(renewToken, 300000, { - * 'trailing': false - * })); - */ - function throttle(func, wait, options) { - var leading = true, - trailing = true; - - if (options === false) { - leading = false; - } else if (isObject(options)) { - leading = 'leading' in options ? options.leading : leading; - trailing = 'trailing' in options ? options.trailing : trailing; - } - options = {}; - options.leading = leading; - options.maxWait = wait; - options.trailing = trailing; - - return debounce(func, wait, options); - } - - /** - * Creates a function that provides `value` to the wrapper function as its - * first argument. Additional arguments provided to the function are appended - * to those provided to the wrapper function. The wrapper is executed with - * the `this` binding of the created function. - * - * @static - * @memberOf _ - * @category Functions - * @param {Mixed} value The value to wrap. - * @param {Function} wrapper The wrapper function. - * @returns {Function} Returns the new function. - * @example - * - * var hello = function(name) { return 'hello ' + name; }; - * hello = _.wrap(hello, function(func) { - * return 'before, ' + func('moe') + ', after'; - * }); - * hello(); - * // => 'before, hello moe, after' - */ - function wrap(value, wrapper) { - if (!isFunction(wrapper)) { - throw new TypeError; - } - return function() { - var args = [value]; - push.apply(args, arguments); - return wrapper.apply(this, args); - }; - } - - /*--------------------------------------------------------------------------*/ - - /** - * Converts the characters `&`, `<`, `>`, `"`, and `'` in `string` to their - * corresponding HTML entities. - * - * @static - * @memberOf _ - * @category Utilities - * @param {String} string The string to escape. - * @returns {String} Returns the escaped string. - * @example - * - * _.escape('Moe, Larry & Curly'); - * // => 'Moe, Larry & Curly' - */ - function escape(string) { - return string == null ? '' : String(string).replace(reUnescapedHtml, escapeHtmlChar); - } - - /** - * This method returns the first argument provided to it. - * - * @static - * @memberOf _ - * @category Utilities - * @param {Mixed} value Any value. - * @returns {Mixed} Returns `value`. - * @example - * - * var moe = { 'name': 'moe' }; - * moe === _.identity(moe); - * // => true - */ - function identity(value) { - return value; - } - - /** - * Adds function properties of a source object to the `lodash` function and - * chainable wrapper. - * - * @static - * @memberOf _ - * @category Utilities - * @param {Object} object The object of function properties to add to `lodash`. - * @param {Object} object The object of function properties to add to `lodash`. - * @example - * - * _.mixin({ - * 'capitalize': function(string) { - * return string.charAt(0).toUpperCase() + string.slice(1).toLowerCase(); - * } - * }); - * - * _.capitalize('moe'); - * // => 'Moe' - * - * _('moe').capitalize(); - * // => 'Moe' - */ - function mixin(object) { - forEach(functions(object), function(methodName) { - var func = lodash[methodName] = object[methodName]; - - lodash.prototype[methodName] = function() { - var args = [this.__wrapped__]; - push.apply(args, arguments); - - var result = func.apply(lodash, args); - if (this.__chain__) { - result = new lodashWrapper(result); - result.__chain__ = true; - } - return result; - }; - }); - } - - /** - * Reverts the '_' variable to its previous value and returns a reference to - * the `lodash` function. - * - * @static - * @memberOf _ - * @category Utilities - * @returns {Function} Returns the `lodash` function. - * @example - * - * var lodash = _.noConflict(); - */ - function noConflict() { - window._ = oldDash; - return this; - } - - /** - * Produces a random number between `min` and `max` (inclusive). If only one - * argument is provided a number between `0` and the given number will be - * returned. - * - * @static - * @memberOf _ - * @category Utilities - * @param {Number} [min=0] The minimum possible value. - * @param {Number} [max=1] The maximum possible value. - * @returns {Number} Returns a random number. - * @example - * - * _.random(0, 5); - * // => a number between 0 and 5 - * - * _.random(5); - * // => also a number between 0 and 5 - */ - function random(min, max) { - if (min == null && max == null) { - max = 1; - } - min = +min || 0; - if (max == null) { - max = min; - min = 0; - } else { - max = +max || 0; - } - var rand = nativeRandom(); - return (min % 1 || max % 1) - ? min + nativeMin(rand * (max - min + parseFloat('1e-' + ((rand +'').length - 1))), max) - : min + floor(rand * (max - min + 1)); - } - - /** - * Resolves the value of `property` on `object`. If `property` is a function - * it will be invoked with the `this` binding of `object` and its result returned, - * else the property value is returned. If `object` is falsey then `undefined` - * is returned. - * - * @static - * @memberOf _ - * @category Utilities - * @param {Object} object The object to inspect. - * @param {String} property The property to get the value of. - * @returns {Mixed} Returns the resolved value. - * @example - * - * var object = { - * 'cheese': 'crumpets', - * 'stuff': function() { - * return 'nonsense'; - * } - * }; - * - * _.result(object, 'cheese'); - * // => 'crumpets' - * - * _.result(object, 'stuff'); - * // => 'nonsense' - */ - function result(object, property) { - var value = object ? object[property] : undefined; - return isFunction(value) ? object[property]() : value; - } - - /** - * A micro-templating method that handles arbitrary delimiters, preserves - * whitespace, and correctly escapes quotes within interpolated code. - * - * Note: In the development build, `_.template` utilizes sourceURLs for easier - * debugging. See http://www.html5rocks.com/en/tutorials/developertools/sourcemaps/#toc-sourceurl - * - * For more information on precompiling templates see: - * http://lodash.com/#custom-builds - * - * For more information on Chrome extension sandboxes see: - * http://developer.chrome.com/stable/extensions/sandboxingEval.html - * - * @static - * @memberOf _ - * @category Utilities - * @param {String} text The template text. - * @param {Object} data The data object used to populate the text. - * @param {Object} options The options object. - * escape - The "escape" delimiter regexp. - * evaluate - The "evaluate" delimiter regexp. - * imports - An object of properties to import into the compiled template as local variables. - * interpolate - The "interpolate" delimiter regexp. - * sourceURL - The sourceURL of the template's compiled source. - * variable - The data object variable name. - * @returns {Function|String} Returns a compiled function when no `data` object - * is given, else it returns the interpolated text. - * @example - * - * // using a compiled template - * var compiled = _.template('hello <%= name %>'); - * compiled({ 'name': 'moe' }); - * // => 'hello moe' - * - * // using the "escape" delimiter to escape HTML in data property values - * _.template('<b><%- value %></b>', { 'value': '<script>' }); - * // => '<b><script></b>' - * - * // using the "evaluate" delimiter to generate HTML - * var list = '<% _.forEach(people, function(name) { %><li><%= name %></li><% }); %>'; - * _.template(list, { 'people': ['moe', 'larry'] }); - * // => '<li>moe</li><li>larry</li>' - * - * // using the ES6 delimiter as an alternative to the default "interpolate" delimiter - * _.template('hello ${ name }', { 'name': 'curly' }); - * // => 'hello curly' - * - * // using the internal `print` function in "evaluate" delimiters - * _.template('<% print("hello " + epithet); %>!', { 'epithet': 'stooge' }); - * // => 'hello stooge!' - * - * // using a custom template delimiters - * _.templateSettings = { - * 'interpolate': /{{([\s\S]+?)}}/g - * }; - * - * _.template('hello {{ name }}!', { 'name': 'mustache' }); - * // => 'hello mustache!' - * - * // using the `imports` option to import jQuery - * var list = '<% $.each(people, function(name) { %><li><%= name %></li><% }); %>'; - * _.template(list, { 'people': ['moe', 'larry'] }, { 'imports': { '$': jQuery }); - * // => '<li>moe</li><li>larry</li>' - * - * // using the `sourceURL` option to specify a custom sourceURL for the template - * var compiled = _.template('hello <%= name %>', null, { 'sourceURL': '/basic/greeting.jst' }); - * compiled(data); - * // => find the source of "greeting.jst" under the Sources tab or Resources panel of the web inspector - * - * // using the `variable` option to ensure a with-statement isn't used in the compiled template - * var compiled = _.template('hi <%= data.name %>!', null, { 'variable': 'data' }); - * compiled.source; - * // => function(data) { - * var __t, __p = '', __e = _.escape; - * __p += 'hi ' + ((__t = ( data.name )) == null ? '' : __t) + '!'; - * return __p; - * } - * - * // using the `source` property to inline compiled templates for meaningful - * // line numbers in error messages and a stack trace - * fs.writeFileSync(path.join(cwd, 'jst.js'), '\ - * var JST = {\ - * "main": ' + _.template(mainText).source + '\ - * };\ - * '); - */ - function template(text, data, options) { - var _ = lodash, - settings = _.templateSettings; - - text || (text = ''); - options = defaults({}, options, settings); - - var index = 0, - source = "__p += '", - variable = options.variable; - - var reDelimiters = RegExp( - (options.escape || reNoMatch).source + '|' + - (options.interpolate || reNoMatch).source + '|' + - (options.evaluate || reNoMatch).source + '|$' - , 'g'); - - text.replace(reDelimiters, function(match, escapeValue, interpolateValue, evaluateValue, offset) { - source += text.slice(index, offset).replace(reUnescapedString, escapeStringChar); - if (escapeValue) { - source += "' +\n_.escape(" + escapeValue + ") +\n'"; - } - if (evaluateValue) { - source += "';\n" + evaluateValue + ";\n__p += '"; - } - if (interpolateValue) { - source += "' +\n((__t = (" + interpolateValue + ")) == null ? '' : __t) +\n'"; - } - index = offset + match.length; - return match; - }); - - source += "';\n"; - if (!variable) { - variable = 'obj'; - source = 'with (' + variable + ' || {}) {\n' + source + '\n}\n'; - } - source = 'function(' + variable + ') {\n' + - "var __t, __p = '', __j = Array.prototype.join;\n" + - "function print() { __p += __j.call(arguments, '') }\n" + - source + - 'return __p\n}'; - - try { - var result = Function('_', 'return ' + source)(_); - } catch(e) { - e.source = source; - throw e; - } - if (data) { - return result(data); - } - result.source = source; - return result; - } - - /** - * Executes the callback `n` times, returning an array of the results - * of each callback execution. The callback is bound to `thisArg` and invoked - * with one argument; (index). - * - * @static - * @memberOf _ - * @category Utilities - * @param {Number} n The number of times to execute the callback. - * @param {Function} callback The function called per iteration. - * @param {Mixed} [thisArg] The `this` binding of `callback`. - * @returns {Array} Returns an array of the results of each `callback` execution. - * @example - * - * var diceRolls = _.times(3, _.partial(_.random, 1, 6)); - * // => [3, 6, 4] - * - * _.times(3, function(n) { mage.castSpell(n); }); - * // => calls `mage.castSpell(n)` three times, passing `n` of `0`, `1`, and `2` respectively - * - * _.times(3, function(n) { this.cast(n); }, mage); - * // => also calls `mage.castSpell(n)` three times - */ - function times(n, callback, thisArg) { - var index = -1, - result = Array(n > -1 ? n : 0); - - while (++index < n) { - result[index] = callback.call(thisArg, index); - } - return result; - } - - /** - * The inverse of `_.escape` this method converts the HTML entities - * `&`, `<`, `>`, `"`, and `'` in `string` to their - * corresponding characters. - * - * @static - * @memberOf _ - * @category Utilities - * @param {String} string The string to unescape. - * @returns {String} Returns the unescaped string. - * @example - * - * _.unescape('Moe, Larry & Curly'); - * // => 'Moe, Larry & Curly' - */ - function unescape(string) { - return string == null ? '' : String(string).replace(reEscapedHtml, unescapeHtmlChar); - } - - /** - * Generates a unique ID. If `prefix` is provided the ID will be appended to it. - * - * @static - * @memberOf _ - * @category Utilities - * @param {String} [prefix] The value to prefix the ID with. - * @returns {String} Returns the unique ID. - * @example - * - * _.uniqueId('contact_'); - * // => 'contact_104' - * - * _.uniqueId(); - * // => '105' - */ - function uniqueId(prefix) { - var id = ++idCounter + ''; - return prefix ? prefix + id : id; - } - - /*--------------------------------------------------------------------------*/ - - /** - * Creates a `lodash` object that wraps the given `value`. - * - * @static - * @memberOf _ - * @category Chaining - * @param {Mixed} value The value to wrap. - * @returns {Object} Returns the wrapper object. - * @example - * - * var stooges = [ - * { 'name': 'moe', 'age': 40 }, - * { 'name': 'larry', 'age': 50 }, - * { 'name': 'curly', 'age': 60 } - * ]; - * - * var youngest = _.chain(stooges) - * .sortBy(function(stooge) { return stooge.age; }) - * .map(function(stooge) { return stooge.name + ' is ' + stooge.age; }) - * .first(); - * // => 'moe is 40' - */ - function chain(value) { - value = new lodashWrapper(value); - value.__chain__ = true; - return value; - } - - /** - * Invokes `interceptor` with the `value` as the first argument and then - * returns `value`. The purpose of this method is to "tap into" a method - * chain in order to perform operations on intermediate results within - * the chain. - * - * @static - * @memberOf _ - * @category Chaining - * @param {Mixed} value The value to provide to `interceptor`. - * @param {Function} interceptor The function to invoke. - * @returns {Mixed} Returns `value`. - * @example - * - * _([1, 2, 3, 4]) - * .filter(function(num) { return num % 2 == 0; }) - * .tap(function(array) { console.log(array); }) - * .map(function(num) { return num * num; }) - * .value(); - * // => // [2, 4] (logged) - * // => [4, 16] - */ - function tap(value, interceptor) { - interceptor(value); - return value; - } - - /** - * Enables method chaining on the wrapper object. - * - * @name chain - * @memberOf _ - * @category Chaining - * @returns {Mixed} Returns the wrapper object. - * @example - * - * var sum = _([1, 2, 3]) - * .chain() - * .reduce(function(sum, num) { return sum + num; }) - * .value() - * // => 6` - */ - function wrapperChain() { - this.__chain__ = true; - return this; - } - - /** - * Extracts the wrapped value. - * - * @name valueOf - * @memberOf _ - * @alias value - * @category Chaining - * @returns {Mixed} Returns the wrapped value. - * @example - * - * _([1, 2, 3]).valueOf(); - * // => [1, 2, 3] - */ - function wrapperValueOf() { - return this.__wrapped__; - } - - /*--------------------------------------------------------------------------*/ - - // add functions that return wrapped values when chaining - lodash.after = after; - lodash.bind = bind; - lodash.bindAll = bindAll; - lodash.chain = chain; - lodash.compact = compact; - lodash.compose = compose; - lodash.countBy = countBy; - lodash.debounce = debounce; - lodash.defaults = defaults; - lodash.defer = defer; - lodash.delay = delay; - lodash.difference = difference; - lodash.filter = filter; - lodash.flatten = flatten; - lodash.forEach = forEach; - lodash.functions = functions; - lodash.groupBy = groupBy; - lodash.initial = initial; - lodash.intersection = intersection; - lodash.invert = invert; - lodash.invoke = invoke; - lodash.keys = keys; - lodash.map = map; - lodash.max = max; - lodash.memoize = memoize; - lodash.min = min; - lodash.omit = omit; - lodash.once = once; - lodash.pairs = pairs; - lodash.partial = partial; - lodash.pick = pick; - lodash.pluck = pluck; - lodash.range = range; - lodash.reject = reject; - lodash.rest = rest; - lodash.shuffle = shuffle; - lodash.sortBy = sortBy; - lodash.tap = tap; - lodash.throttle = throttle; - lodash.times = times; - lodash.toArray = toArray; - lodash.union = union; - lodash.uniq = uniq; - lodash.values = values; - lodash.where = where; - lodash.without = without; - lodash.wrap = wrap; - lodash.zip = zip; - - // add aliases - lodash.collect = map; - lodash.drop = rest; - lodash.each = forEach; - lodash.extend = assign; - lodash.methods = functions; - lodash.object = zipObject; - lodash.select = filter; - lodash.tail = rest; - lodash.unique = uniq; - - /*--------------------------------------------------------------------------*/ - - // add functions that return unwrapped values when chaining - lodash.clone = clone; - lodash.contains = contains; - lodash.escape = escape; - lodash.every = every; - lodash.find = find; - lodash.has = has; - lodash.identity = identity; - lodash.indexOf = indexOf; - lodash.isArguments = isArguments; - lodash.isArray = isArray; - lodash.isBoolean = isBoolean; - lodash.isDate = isDate; - lodash.isElement = isElement; - lodash.isEmpty = isEmpty; - lodash.isEqual = isEqual; - lodash.isFinite = isFinite; - lodash.isFunction = isFunction; - lodash.isNaN = isNaN; - lodash.isNull = isNull; - lodash.isNumber = isNumber; - lodash.isObject = isObject; - lodash.isRegExp = isRegExp; - lodash.isString = isString; - lodash.isUndefined = isUndefined; - lodash.lastIndexOf = lastIndexOf; - lodash.mixin = mixin; - lodash.noConflict = noConflict; - lodash.random = random; - lodash.reduce = reduce; - lodash.reduceRight = reduceRight; - lodash.result = result; - lodash.size = size; - lodash.some = some; - lodash.sortedIndex = sortedIndex; - lodash.template = template; - lodash.unescape = unescape; - lodash.uniqueId = uniqueId; - - // add aliases - lodash.all = every; - lodash.any = some; - lodash.detect = find; - lodash.findWhere = findWhere; - lodash.foldl = reduce; - lodash.foldr = reduceRight; - lodash.include = contains; - lodash.inject = reduce; - - /*--------------------------------------------------------------------------*/ - - // add functions capable of returning wrapped and unwrapped values when chaining - lodash.first = first; - lodash.last = last; - - // add aliases - lodash.take = first; - lodash.head = first; - - /*--------------------------------------------------------------------------*/ - - // add functions to `lodash.prototype` - mixin(lodash); - - /** - * The semantic version number. - * - * @static - * @memberOf _ - * @type String - */ - lodash.VERSION = '1.3.1'; - - // add "Chaining" functions to the wrapper - lodash.prototype.chain = wrapperChain; - lodash.prototype.value = wrapperValueOf; - - // add `Array` mutator functions to the wrapper - forEach(['pop', 'push', 'reverse', 'shift', 'sort', 'splice', 'unshift'], function(methodName) { - var func = arrayRef[methodName]; - lodash.prototype[methodName] = function() { - var value = this.__wrapped__; - func.apply(value, arguments); - - // avoid array-like object bugs with `Array#shift` and `Array#splice` - // in Firefox < 10 and IE < 9 - if (!support.spliceObjects && value.length === 0) { - delete value[0]; - } - return this; - }; - }); - - // add `Array` accessor functions to the wrapper - forEach(['concat', 'join', 'slice'], function(methodName) { - var func = arrayRef[methodName]; - lodash.prototype[methodName] = function() { - var value = this.__wrapped__, - result = func.apply(value, arguments); - - if (this.__chain__) { - result = new lodashWrapper(result); - result.__chain__ = true; - } - return result; - }; - }); - - /*--------------------------------------------------------------------------*/ - - // some AMD build optimizers, like r.js, check for specific condition patterns like the following: - if (typeof define == 'function' && typeof define.amd == 'object' && define.amd) { - // Expose Lo-Dash to the global object even when an AMD loader is present in - // case Lo-Dash was injected by a third-party script and not intended to be - // loaded as a module. The global assignment can be reverted in the Lo-Dash - // module via its `noConflict()` method. - window._ = lodash; - - // define as an anonymous module so, through path mapping, it can be - // referenced as the "underscore" module - define(function() { - return lodash; - }); - } - // check for `exports` after `define` in case a build optimizer adds an `exports` object - else if (freeExports && !freeExports.nodeType) { - // in Node.js or RingoJS v0.8.0+ - if (freeModule) { - (freeModule.exports = lodash)._ = lodash; - } - // in Narwhal or RingoJS v0.7.0- - else { - freeExports._ = lodash; - } - } - else { - // in a browser or Rhino - window._ = lodash; - } -}(this)); diff --git a/src/UI/JsLibraries/messenger.js b/src/UI/JsLibraries/messenger.js deleted file mode 100644 index 8acdbcff3..000000000 --- a/src/UI/JsLibraries/messenger.js +++ /dev/null @@ -1,1263 +0,0 @@ -/*! messenger 1.4.1 */ -/* - * This file begins the output concatenated into messenger.js - * - * It establishes the Messenger object while preserving whatever it was before - * (for noConflict), and making it a callable function. - */ - -(function(){ - var _prevMessenger = window.Messenger; - var localMessenger; - - localMessenger = window.Messenger = function(){ - return localMessenger._call.apply(this, arguments); - } - - window.Messenger.noConflict = function(){ - window.Messenger = _prevMessenger; - - return localMessenger; - } -})(); - -/* - * This file contains shims for when Underscore and Backbone - * are not included. - * - * Portions taken from Underscore.js and Backbone.js - * Both of which are Copyright (c) 2009-2013 Jeremy Ashkenas, DocumentCloud - */ -window.Messenger._ = (function() { - if (window._) - return window._ - - var ArrayProto = Array.prototype, ObjProto = Object.prototype, FuncProto = Function.prototype; - - // Create quick reference variables for speed access to core prototypes. - var push = ArrayProto.push, - slice = ArrayProto.slice, - concat = ArrayProto.concat, - toString = ObjProto.toString, - hasOwnProperty = ObjProto.hasOwnProperty; - - // All **ECMAScript 5** native function implementations that we hope to use - // are declared here. - var - nativeForEach = ArrayProto.forEach, - nativeMap = ArrayProto.map, - nativeReduce = ArrayProto.reduce, - nativeReduceRight = ArrayProto.reduceRight, - nativeFilter = ArrayProto.filter, - nativeEvery = ArrayProto.every, - nativeSome = ArrayProto.some, - nativeIndexOf = ArrayProto.indexOf, - nativeLastIndexOf = ArrayProto.lastIndexOf, - nativeIsArray = Array.isArray, - nativeKeys = Object.keys, - nativeBind = FuncProto.bind; - - // Create a safe reference to the Underscore object for use below. - var _ = {}; - - // Establish the object that gets returned to break out of a loop iteration. - var breaker = {}; - - var each = _.each = _.forEach = function(obj, iterator, context) { - if (obj == null) return; - if (nativeForEach && obj.forEach === nativeForEach) { - obj.forEach(iterator, context); - } else if (obj.length === +obj.length) { - for (var i = 0, l = obj.length; i < l; i++) { - if (iterator.call(context, obj[i], i, obj) === breaker) return; - } - } else { - for (var key in obj) { - if (_.has(obj, key)) { - if (iterator.call(context, obj[key], key, obj) === breaker) return; - } - } - } - }; - - _.result = function(object, property) { - if (object == null) return null; - var value = object[property]; - return _.isFunction(value) ? value.call(object) : value; - }; - - _.once = function(func) { - var ran = false, memo; - return function() { - if (ran) return memo; - ran = true; - memo = func.apply(this, arguments); - func = null; - return memo; - }; - }; - - var idCounter = 0; - _.uniqueId = function(prefix) { - var id = ++idCounter + ''; - return prefix ? prefix + id : id; - }; - - _.filter = _.select = function(obj, iterator, context) { - var results = []; - if (obj == null) return results; - if (nativeFilter && obj.filter === nativeFilter) return obj.filter(iterator, context); - each(obj, function(value, index, list) { - if (iterator.call(context, value, index, list)) results[results.length] = value; - }); - return results; - }; - - // Add some isType methods: isArguments, isFunction, isString, isNumber, isDate, isRegExp. - each(['Arguments', 'Function', 'String', 'Number', 'Date', 'RegExp'], function(name) { - _['is' + name] = function(obj) { - return toString.call(obj) == '[object ' + name + ']'; - }; - }); - - _.defaults = function(obj) { - each(slice.call(arguments, 1), function(source) { - if (source) { - for (var prop in source) { - if (obj[prop] == null) obj[prop] = source[prop]; - } - } - }); - return obj; - }; - - _.extend = function(obj) { - each(slice.call(arguments, 1), function(source) { - if (source) { - for (var prop in source) { - obj[prop] = source[prop]; - } - } - }); - return obj; - }; - - _.keys = nativeKeys || function(obj) { - if (obj !== Object(obj)) throw new TypeError('Invalid object'); - var keys = []; - for (var key in obj) if (_.has(obj, key)) keys[keys.length] = key; - return keys; - }; - - _.bind = function(func, context) { - if (func.bind === nativeBind && nativeBind) return nativeBind.apply(func, slice.call(arguments, 1)); - var args = slice.call(arguments, 2); - return function() { - return func.apply(context, args.concat(slice.call(arguments))); - }; - }; - - _.isObject = function(obj) { - return obj === Object(obj); - }; - - return _; -})(); - -window.Messenger.Events = (function() { - if (window.Backbone && Backbone.Events) { - return Backbone.Events; - } - - var eventsShim = function() { - var eventSplitter = /\s+/; - - var eventsApi = function(obj, action, name, rest) { - if (!name) return true; - if (typeof name === 'object') { - for (var key in name) { - obj[action].apply(obj, [key, name[key]].concat(rest)); - } - } else if (eventSplitter.test(name)) { - var names = name.split(eventSplitter); - for (var i = 0, l = names.length; i < l; i++) { - obj[action].apply(obj, [names[i]].concat(rest)); - } - } else { - return true; - } - }; - - var triggerEvents = function(events, args) { - var ev, i = -1, l = events.length; - switch (args.length) { - case 0: while (++i < l) (ev = events[i]).callback.call(ev.ctx); - return; - case 1: while (++i < l) (ev = events[i]).callback.call(ev.ctx, args[0]); - return; - case 2: while (++i < l) (ev = events[i]).callback.call(ev.ctx, args[0], args[1]); - return; - case 3: while (++i < l) (ev = events[i]).callback.call(ev.ctx, args[0], args[1], args[2]); - return; - default: while (++i < l) (ev = events[i]).callback.apply(ev.ctx, args); - } - }; - - var Events = { - - on: function(name, callback, context) { - if (!(eventsApi(this, 'on', name, [callback, context]) && callback)) return this; - this._events || (this._events = {}); - var list = this._events[name] || (this._events[name] = []); - list.push({callback: callback, context: context, ctx: context || this}); - return this; - }, - - once: function(name, callback, context) { - if (!(eventsApi(this, 'once', name, [callback, context]) && callback)) return this; - var self = this; - var once = _.once(function() { - self.off(name, once); - callback.apply(this, arguments); - }); - once._callback = callback; - this.on(name, once, context); - return this; - }, - - off: function(name, callback, context) { - var list, ev, events, names, i, l, j, k; - if (!this._events || !eventsApi(this, 'off', name, [callback, context])) return this; - if (!name && !callback && !context) { - this._events = {}; - return this; - } - - names = name ? [name] : _.keys(this._events); - for (i = 0, l = names.length; i < l; i++) { - name = names[i]; - if (list = this._events[name]) { - events = []; - if (callback || context) { - for (j = 0, k = list.length; j < k; j++) { - ev = list[j]; - if ((callback && callback !== ev.callback && - callback !== ev.callback._callback) || - (context && context !== ev.context)) { - events.push(ev); - } - } - } - this._events[name] = events; - } - } - - return this; - }, - - trigger: function(name) { - if (!this._events) return this; - var args = Array.prototype.slice.call(arguments, 1); - if (!eventsApi(this, 'trigger', name, args)) return this; - var events = this._events[name]; - var allEvents = this._events.all; - if (events) triggerEvents(events, args); - if (allEvents) triggerEvents(allEvents, arguments); - return this; - }, - - listenTo: function(obj, name, callback) { - var listeners = this._listeners || (this._listeners = {}); - var id = obj._listenerId || (obj._listenerId = _.uniqueId('l')); - listeners[id] = obj; - obj.on(name, typeof name === 'object' ? this : callback, this); - return this; - }, - - stopListening: function(obj, name, callback) { - var listeners = this._listeners; - if (!listeners) return; - if (obj) { - obj.off(name, typeof name === 'object' ? this : callback, this); - if (!name && !callback) delete listeners[obj._listenerId]; - } else { - if (typeof name === 'object') callback = this; - for (var id in listeners) { - listeners[id].off(name, callback, this); - } - this._listeners = {}; - } - return this; - } - }; - - Events.bind = Events.on; - Events.unbind = Events.off; - return Events; - }; - return eventsShim(); -})(); - -(function() { - var $, ActionMessenger, BaseView, Events, RetryingMessage, _, _Message, _Messenger, _ref, _ref1, _ref2, - __hasProp = {}.hasOwnProperty, - __extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }, - __slice = [].slice, - __indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; - - $ = jQuery; - - _ = (_ref = window._) != null ? _ref : window.Messenger._; - - Events = (_ref1 = typeof Backbone !== "undefined" && Backbone !== null ? Backbone.Events : void 0) != null ? _ref1 : window.Messenger.Events; - - BaseView = (function() { - - function BaseView(options) { - $.extend(this, Events); - if (_.isObject(options)) { - if (options.el) { - this.setElement(options.el); - } - this.model = options.model; - } - this.initialize.apply(this, arguments); - } - - BaseView.prototype.setElement = function(el) { - this.$el = $(el); - return this.el = this.$el[0]; - }; - - BaseView.prototype.delegateEvents = function(events) { - var delegateEventSplitter, eventName, key, match, method, selector, _results; - if (!(events || (events = _.result(this, "events")))) { - return; - } - this.undelegateEvents(); - delegateEventSplitter = /^(\S+)\s*(.*)$/; - _results = []; - for (key in events) { - method = events[key]; - if (!_.isFunction(method)) { - method = this[events[key]]; - } - if (!method) { - throw new Error("Method \"" + events[key] + "\" does not exist"); - } - match = key.match(delegateEventSplitter); - eventName = match[1]; - selector = match[2]; - method = _.bind(method, this); - eventName += ".delegateEvents" + this.cid; - if (selector === '') { - _results.push(this.jqon(eventName, method)); - } else { - _results.push(this.jqon(eventName, selector, method)); - } - } - return _results; - }; - - BaseView.prototype.jqon = function(eventName, selector, method) { - var _ref2; - if (this.$el.on != null) { - return (_ref2 = this.$el).on.apply(_ref2, arguments); - } else { - if (!(method != null)) { - method = selector; - selector = void 0; - } - if (selector != null) { - return this.$el.delegate(selector, eventName, method); - } else { - return this.$el.bind(eventName, method); - } - } - }; - - BaseView.prototype.jqoff = function(eventName) { - var _ref2; - if (this.$el.off != null) { - return (_ref2 = this.$el).off.apply(_ref2, arguments); - } else { - this.$el.undelegate(); - return this.$el.unbind(eventName); - } - }; - - BaseView.prototype.undelegateEvents = function() { - return this.jqoff(".delegateEvents" + this.cid); - }; - - BaseView.prototype.remove = function() { - this.undelegateEvents(); - return this.$el.remove(); - }; - - return BaseView; - - })(); - - _Message = (function(_super) { - - __extends(_Message, _super); - - function _Message() { - return _Message.__super__.constructor.apply(this, arguments); - } - - _Message.prototype.defaults = { - hideAfter: 10, - scroll: true, - closeButtonText: "×" - }; - - _Message.prototype.initialize = function(opts) { - if (opts == null) { - opts = {}; - } - this.shown = false; - this.rendered = false; - this.messenger = opts.messenger; - return this.options = $.extend({}, this.options, opts, this.defaults); - }; - - _Message.prototype.show = function() { - var wasShown; - if (!this.rendered) { - this.render(); - } - this.$message.removeClass('messenger-hidden'); - wasShown = this.shown; - this.shown = true; - if (!wasShown) { - return this.trigger('show'); - } - }; - - _Message.prototype.hide = function() { - var wasShown; - if (!this.rendered) { - return; - } - this.$message.addClass('messenger-hidden'); - wasShown = this.shown; - this.shown = false; - if (wasShown) { - return this.trigger('hide'); - } - }; - - _Message.prototype.cancel = function() { - return this.hide(); - }; - - _Message.prototype.update = function(opts) { - var _ref2, - _this = this; - if (_.isString(opts)) { - opts = { - message: opts - }; - } - $.extend(this.options, opts); - this.lastUpdate = new Date(); - this.rendered = false; - this.events = (_ref2 = this.options.events) != null ? _ref2 : {}; - this.render(); - this.actionsToEvents(); - this.delegateEvents(); - this.checkClickable(); - if (this.options.hideAfter) { - this.$message.addClass('messenger-will-hide-after'); - if (this._hideTimeout != null) { - clearTimeout(this._hideTimeout); - } - this._hideTimeout = setTimeout(function() { - return _this.hide(); - }, this.options.hideAfter * 1000); - } else { - this.$message.removeClass('messenger-will-hide-after'); - } - if (this.options.hideOnNavigate) { - this.$message.addClass('messenger-will-hide-on-navigate'); - if ((typeof Backbone !== "undefined" && Backbone !== null ? Backbone.history : void 0) != null) { - Backbone.history.on('route', function() { - return _this.hide(); - }); - } - } else { - this.$message.removeClass('messenger-will-hide-on-navigate'); - } - return this.trigger('update', this); - }; - - _Message.prototype.scrollTo = function() { - if (!this.options.scroll) { - return; - } - return $.scrollTo(this.$el, { - duration: 400, - offset: { - left: 0, - top: -20 - } - }); - }; - - _Message.prototype.timeSinceUpdate = function() { - if (this.lastUpdate) { - return (new Date) - this.lastUpdate; - } else { - return null; - } - }; - - _Message.prototype.actionsToEvents = function() { - var act, name, _ref2, _results, - _this = this; - _ref2 = this.options.actions; - _results = []; - for (name in _ref2) { - act = _ref2[name]; - _results.push(this.events["click [data-action=\"" + name + "\"] a"] = (function(act) { - return function(e) { - e.preventDefault(); - e.stopPropagation(); - _this.trigger("action:" + name, act, e); - return act.action.call(_this, e, _this); - }; - })(act)); - } - return _results; - }; - - _Message.prototype.checkClickable = function() { - var evt, name, _ref2, _results; - _ref2 = this.events; - _results = []; - for (name in _ref2) { - evt = _ref2[name]; - if (name === 'click') { - _results.push(this.$message.addClass('messenger-clickable')); - } else { - _results.push(void 0); - } - } - return _results; - }; - - _Message.prototype.undelegateEvents = function() { - var _ref2; - _Message.__super__.undelegateEvents.apply(this, arguments); - return (_ref2 = this.$message) != null ? _ref2.removeClass('messenger-clickable') : void 0; - }; - - _Message.prototype.parseActions = function() { - var act, actions, n_act, name, _ref2, _ref3; - actions = []; - _ref2 = this.options.actions; - for (name in _ref2) { - act = _ref2[name]; - n_act = $.extend({}, act); - n_act.name = name; - if ((_ref3 = n_act.label) == null) { - n_act.label = name; - } - actions.push(n_act); - } - return actions; - }; - - _Message.prototype.template = function(opts) { - var $action, $actions, $cancel, $link, $message, $text, action, _i, _len, _ref2, - _this = this; - $message = $("<div class='messenger-message message alert " + opts.type + " message-" + opts.type + " alert-" + opts.type + "'>"); - if (opts.showCloseButton) { - $cancel = $('<button type="button" class="messenger-close" data-dismiss="alert">'); - $cancel.html(opts.closeButtonText); - $cancel.click(function() { - _this.cancel(); - return true; - }); - $message.append($cancel); - } - $text = $("<div class=\"messenger-message-inner\">" + opts.message + "</div>"); - $message.append($text); - if (opts.actions.length) { - $actions = $('<div class="messenger-actions">'); - } - _ref2 = opts.actions; - for (_i = 0, _len = _ref2.length; _i < _len; _i++) { - action = _ref2[_i]; - $action = $('<span>'); - $action.attr('data-action', "" + action.name); - $link = $('<a>'); - $link.html(action.label); - $action.append($('<span class="messenger-phrase">')); - $action.append($link); - $actions.append($action); - } - $message.append($actions); - return $message; - }; - - _Message.prototype.render = function() { - var opts; - if (this.rendered) { - return; - } - if (!this._hasSlot) { - this.setElement(this.messenger._reserveMessageSlot(this)); - this._hasSlot = true; - } - opts = $.extend({}, this.options, { - actions: this.parseActions() - }); - this.$message = $(this.template(opts)); - this.$el.html(this.$message); - this.shown = true; - this.rendered = true; - return this.trigger('render'); - }; - - return _Message; - - })(BaseView); - - RetryingMessage = (function(_super) { - - __extends(RetryingMessage, _super); - - function RetryingMessage() { - return RetryingMessage.__super__.constructor.apply(this, arguments); - } - - RetryingMessage.prototype.initialize = function() { - RetryingMessage.__super__.initialize.apply(this, arguments); - return this._timers = {}; - }; - - RetryingMessage.prototype.cancel = function() { - this.clearTimers(); - this.hide(); - if ((this._actionInstance != null) && (this._actionInstance.abort != null)) { - return this._actionInstance.abort(); - } - }; - - RetryingMessage.prototype.clearTimers = function() { - var name, timer, _ref2, _ref3; - _ref2 = this._timers; - for (name in _ref2) { - timer = _ref2[name]; - clearTimeout(timer); - } - this._timers = {}; - return (_ref3 = this.$message) != null ? _ref3.removeClass('messenger-retry-soon messenger-retry-later') : void 0; - }; - - RetryingMessage.prototype.render = function() { - var action, name, _ref2, _results; - RetryingMessage.__super__.render.apply(this, arguments); - this.clearTimers(); - _ref2 = this.options.actions; - _results = []; - for (name in _ref2) { - action = _ref2[name]; - if (action.auto) { - _results.push(this.startCountdown(name, action)); - } else { - _results.push(void 0); - } - } - return _results; - }; - - RetryingMessage.prototype.renderPhrase = function(action, time) { - var phrase; - phrase = action.phrase.replace('TIME', this.formatTime(time)); - return phrase; - }; - - RetryingMessage.prototype.formatTime = function(time) { - var pluralize; - pluralize = function(num, str) { - num = Math.floor(num); - if (num !== 1) { - str = str + 's'; - } - return 'in ' + num + ' ' + str; - }; - if (Math.floor(time) === 0) { - return 'now...'; - } - if (time < 60) { - return pluralize(time, 'second'); - } - time /= 60; - if (time < 60) { - return pluralize(time, 'minute'); - } - time /= 60; - return pluralize(time, 'hour'); - }; - - RetryingMessage.prototype.startCountdown = function(name, action) { - var $phrase, remaining, tick, _ref2, - _this = this; - if (this._timers[name] != null) { - return; - } - $phrase = this.$message.find("[data-action='" + name + "'] .messenger-phrase"); - remaining = (_ref2 = action.delay) != null ? _ref2 : 3; - if (remaining <= 10) { - this.$message.removeClass('messenger-retry-later'); - this.$message.addClass('messenger-retry-soon'); - } else { - this.$message.removeClass('messenger-retry-soon'); - this.$message.addClass('messenger-retry-later'); - } - tick = function() { - var delta; - $phrase.text(_this.renderPhrase(action, remaining)); - if (remaining > 0) { - delta = Math.min(remaining, 1); - remaining -= delta; - return _this._timers[name] = setTimeout(tick, delta * 1000); - } else { - _this.$message.removeClass('messenger-retry-soon messenger-retry-later'); - delete _this._timers[name]; - return action.action(); - } - }; - return tick(); - }; - - return RetryingMessage; - - })(_Message); - - _Messenger = (function(_super) { - - __extends(_Messenger, _super); - - function _Messenger() { - return _Messenger.__super__.constructor.apply(this, arguments); - } - - _Messenger.prototype.tagName = 'ul'; - - _Messenger.prototype.className = 'messenger'; - - _Messenger.prototype.messageDefaults = { - type: 'info' - }; - - _Messenger.prototype.initialize = function(options) { - this.options = options != null ? options : {}; - this.history = []; - return this.messageDefaults = $.extend({}, this.messageDefaults, this.options.messageDefaults); - }; - - _Messenger.prototype.render = function() { - return this.updateMessageSlotClasses(); - }; - - _Messenger.prototype.findById = function(id) { - return _.filter(this.history, function(rec) { - return rec.msg.options.id === id; - }); - }; - - _Messenger.prototype._reserveMessageSlot = function(msg) { - var $slot, dmsg, - _this = this; - $slot = $('<li>'); - $slot.addClass('messenger-message-slot'); - this.$el.prepend($slot); - this.history.push({ - msg: msg, - $slot: $slot - }); - this._enforceIdConstraint(msg); - msg.on('update', function() { - return _this._enforceIdConstraint(msg); - }); - while (this.options.maxMessages && this.history.length > this.options.maxMessages) { - dmsg = this.history.shift(); - dmsg.msg.remove(); - dmsg.$slot.remove(); - } - return $slot; - }; - - _Messenger.prototype._enforceIdConstraint = function(msg) { - var entry, _i, _len, _msg, _ref2; - if (msg.options.id == null) { - return; - } - _ref2 = this.history; - for (_i = 0, _len = _ref2.length; _i < _len; _i++) { - entry = _ref2[_i]; - _msg = entry.msg; - if ((_msg.options.id != null) && _msg.options.id === msg.options.id && msg !== _msg) { - if (msg.options.singleton) { - msg.hide(); - return; - } else { - _msg.hide(); - } - } - } - }; - - _Messenger.prototype.newMessage = function(opts) { - var msg, _ref2, _ref3, _ref4, - _this = this; - if (opts == null) { - opts = {}; - } - opts.messenger = this; - _Message = (_ref2 = (_ref3 = Messenger.themes[(_ref4 = opts.theme) != null ? _ref4 : this.options.theme]) != null ? _ref3.Message : void 0) != null ? _ref2 : RetryingMessage; - msg = new _Message(opts); - msg.on('show', function() { - if (opts.scrollTo && _this.$el.css('position') !== 'fixed') { - return msg.scrollTo(); - } - }); - msg.on('hide show render', this.updateMessageSlotClasses, this); - return msg; - }; - - _Messenger.prototype.updateMessageSlotClasses = function() { - var anyShown, last, rec, willBeFirst, _i, _len, _ref2; - willBeFirst = true; - last = null; - anyShown = false; - _ref2 = this.history; - for (_i = 0, _len = _ref2.length; _i < _len; _i++) { - rec = _ref2[_i]; - rec.$slot.removeClass('messenger-first messenger-last messenger-shown'); - if (rec.msg.shown && rec.msg.rendered) { - rec.$slot.addClass('messenger-shown'); - anyShown = true; - last = rec; - if (willBeFirst) { - willBeFirst = false; - rec.$slot.addClass('messenger-first'); - } - } - } - if (last != null) { - last.$slot.addClass('messenger-last'); - } - return this.$el["" + (anyShown ? 'remove' : 'add') + "Class"]('messenger-empty'); - }; - - _Messenger.prototype.hideAll = function() { - var rec, _i, _len, _ref2, _results; - _ref2 = this.history; - _results = []; - for (_i = 0, _len = _ref2.length; _i < _len; _i++) { - rec = _ref2[_i]; - _results.push(rec.msg.hide()); - } - return _results; - }; - - _Messenger.prototype.post = function(opts) { - var msg; - if (_.isString(opts)) { - opts = { - message: opts - }; - } - opts = $.extend(true, {}, this.messageDefaults, opts); - msg = this.newMessage(opts); - msg.update(opts); - return msg; - }; - - return _Messenger; - - })(BaseView); - - ActionMessenger = (function(_super) { - - __extends(ActionMessenger, _super); - - function ActionMessenger() { - return ActionMessenger.__super__.constructor.apply(this, arguments); - } - - ActionMessenger.prototype.doDefaults = { - progressMessage: null, - successMessage: null, - errorMessage: "Error connecting to the server.", - showSuccessWithoutError: true, - retry: { - auto: true, - allow: true - }, - action: $.ajax - }; - - ActionMessenger.prototype.hookBackboneAjax = function(msgr_opts) { - var _ajax, - _this = this; - if (msgr_opts == null) { - msgr_opts = {}; - } - if (!(window.Backbone != null)) { - throw 'Expected Backbone to be defined'; - } - msgr_opts = _.defaults(msgr_opts, { - id: 'BACKBONE_ACTION', - errorMessage: false, - successMessage: "Request completed successfully.", - showSuccessWithoutError: false - }); - _ajax = function(options) { - var sync_msgr_opts; - sync_msgr_opts = _.extend({}, msgr_opts, options.messenger); - return _this["do"](sync_msgr_opts, options); - }; - if (Backbone.ajax != null) { - if (Backbone.ajax._withoutMessenger) { - Backbone.ajax = Backbone.ajax._withoutMessenger; - } - if (!(msgr_opts.action != null) || msgr_opts.action === this.doDefaults.action) { - msgr_opts.action = Backbone.ajax; - } - _ajax._withoutMessenger = Backbone.ajax; - return Backbone.ajax = _ajax; - } else { - return Backbone.sync = _.wrap(Backbone.sync, function() { - var args, _old_ajax, _old_sync; - _old_sync = arguments[0], args = 2 <= arguments.length ? __slice.call(arguments, 1) : []; - _old_ajax = $.ajax; - $.ajax = _ajax; - _old_sync.call.apply(_old_sync, [this].concat(__slice.call(args))); - return $.ajax = _old_ajax; - }); - } - }; - - ActionMessenger.prototype._getHandlerResponse = function(returnVal) { - if (returnVal === false) { - return false; - } - if (returnVal === true || !(returnVal != null)) { - return true; - } - return returnVal; - }; - - ActionMessenger.prototype._parseEvents = function(events) { - var desc, firstSpace, func, label, out, type, _ref2; - if (events == null) { - events = {}; - } - out = {}; - for (label in events) { - func = events[label]; - firstSpace = label.indexOf(' '); - type = label.substring(0, firstSpace); - desc = label.substring(firstSpace + 1); - if ((_ref2 = out[type]) == null) { - out[type] = {}; - } - out[type][desc] = func; - } - return out; - }; - - ActionMessenger.prototype._normalizeResponse = function() { - var data, elem, resp, type, xhr, _i, _len; - resp = 1 <= arguments.length ? __slice.call(arguments, 0) : []; - type = null; - xhr = null; - data = null; - for (_i = 0, _len = resp.length; _i < _len; _i++) { - elem = resp[_i]; - if (elem === 'success' || elem === 'timeout' || elem === 'abort') { - type = elem; - } else if (((elem != null ? elem.readyState : void 0) != null) && ((elem != null ? elem.responseText : void 0) != null)) { - xhr = elem; - } else if (_.isObject(elem)) { - data = elem; - } - } - return [type, data, xhr]; - }; - - ActionMessenger.prototype.run = function() { - var args, events, getMessageText, handler, handlers, m_opts, msg, old, opts, type, _ref2, - _this = this; - m_opts = arguments[0], opts = arguments[1], args = 3 <= arguments.length ? __slice.call(arguments, 2) : []; - if (opts == null) { - opts = {}; - } - m_opts = $.extend(true, {}, this.messageDefaults, this.doDefaults, m_opts != null ? m_opts : {}); - events = this._parseEvents(m_opts.events); - getMessageText = function(type, xhr) { - var message; - message = m_opts[type + 'Message']; - if (_.isFunction(message)) { - return message.call(_this, type, xhr); - } - return message; - }; - msg = (_ref2 = m_opts.messageInstance) != null ? _ref2 : this.newMessage(m_opts); - if (m_opts.id != null) { - msg.options.id = m_opts.id; - } - if (m_opts.progressMessage != null) { - msg.update($.extend({}, m_opts, { - message: getMessageText('progress', null), - type: 'info' - })); - } - handlers = {}; - _.each(['error', 'success'], function(type) { - var originalHandler; - originalHandler = opts[type]; - return handlers[type] = function() { - var data, defaultOpts, handlerResp, msgOpts, reason, resp, responseOpts, xhr, _ref3, _ref4, _ref5, _ref6, _ref7, _ref8, _ref9; - resp = 1 <= arguments.length ? __slice.call(arguments, 0) : []; - _ref3 = _this._normalizeResponse.apply(_this, resp), reason = _ref3[0], data = _ref3[1], xhr = _ref3[2]; - if (type === 'success' && !(msg.errorCount != null) && m_opts.showSuccessWithoutError === false) { - m_opts['successMessage'] = null; - } - if (type === 'error') { - if ((_ref4 = m_opts.errorCount) == null) { - m_opts.errorCount = 0; - } - m_opts.errorCount += 1; - } - handlerResp = m_opts.returnsPromise ? resp[0] : typeof originalHandler === "function" ? originalHandler.apply(null, resp) : void 0; - responseOpts = _this._getHandlerResponse(handlerResp); - if (_.isString(responseOpts)) { - responseOpts = { - message: responseOpts - }; - } - if (type === 'error' && ((xhr != null ? xhr.status : void 0) === 0 || reason === 'abort')) { - msg.hide(); - return; - } - if (type === 'error' && ((m_opts.ignoredErrorCodes != null) && (_ref5 = xhr != null ? xhr.status : void 0, __indexOf.call(m_opts.ignoredErrorCodes, _ref5) >= 0))) { - msg.hide(); - return; - } - defaultOpts = { - message: getMessageText(type, xhr), - type: type, - events: (_ref6 = events[type]) != null ? _ref6 : {}, - hideOnNavigate: type === 'success' - }; - msgOpts = $.extend({}, m_opts, defaultOpts, responseOpts); - if (typeof ((_ref7 = msgOpts.retry) != null ? _ref7.allow : void 0) === 'number') { - msgOpts.retry.allow--; - } - if (type === 'error' && (xhr != null ? xhr.status : void 0) >= 500 && ((_ref8 = msgOpts.retry) != null ? _ref8.allow : void 0)) { - if (msgOpts.retry.delay == null) { - if (msgOpts.errorCount < 4) { - msgOpts.retry.delay = 10; - } else { - msgOpts.retry.delay = 5 * 60; - } - } - if (msgOpts.hideAfter) { - if ((_ref9 = msgOpts._hideAfter) == null) { - msgOpts._hideAfter = msgOpts.hideAfter; - } - msgOpts.hideAfter = msgOpts._hideAfter + msgOpts.retry.delay; - } - msgOpts._retryActions = true; - msgOpts.actions = { - retry: { - label: 'retry now', - phrase: 'Retrying TIME', - auto: msgOpts.retry.auto, - delay: msgOpts.retry.delay, - action: function() { - msgOpts.messageInstance = msg; - return setTimeout(function() { - return _this["do"].apply(_this, [msgOpts, opts].concat(__slice.call(args))); - }, 0); - } - }, - cancel: { - action: function() { - return msg.cancel(); - } - } - }; - } else if (msgOpts._retryActions) { - delete msgOpts.actions.retry; - delete msgOpts.actions.cancel; - delete m_opts._retryActions; - } - msg.update(msgOpts); - if (responseOpts && msgOpts.message) { - Messenger(_.extend({}, _this.options, { - instance: _this - })); - return msg.show(); - } else { - return msg.hide(); - } - }; - }); - if (!m_opts.returnsPromise) { - for (type in handlers) { - handler = handlers[type]; - old = opts[type]; - opts[type] = handler; - } - } - msg._actionInstance = m_opts.action.apply(m_opts, [opts].concat(__slice.call(args))); - if (m_opts.returnsPromise) { - msg._actionInstance.then(handlers.success, handlers.error); - } - return msg; - }; - - ActionMessenger.prototype["do"] = ActionMessenger.prototype.run; - - ActionMessenger.prototype.ajax = function() { - var args, m_opts; - m_opts = arguments[0], args = 2 <= arguments.length ? __slice.call(arguments, 1) : []; - m_opts.action = $.ajax; - return this.run.apply(this, [m_opts].concat(__slice.call(args))); - }; - - ActionMessenger.prototype.expectPromise = function(action, m_opts) { - m_opts = _.extend({}, m_opts, { - action: action, - returnsPromise: true - }); - return this.run(m_opts); - }; - - ActionMessenger.prototype.error = function(m_opts) { - if (m_opts == null) { - m_opts = {}; - } - if (typeof m_opts === 'string') { - m_opts = { - message: m_opts - }; - } - m_opts.type = 'error'; - return this.post(m_opts); - }; - - ActionMessenger.prototype.info = function(m_opts) { - if (m_opts == null) { - m_opts = {}; - } - if (typeof m_opts === 'string') { - m_opts = { - message: m_opts - }; - } - m_opts.type = 'info'; - return this.post(m_opts); - }; - - ActionMessenger.prototype.success = function(m_opts) { - if (m_opts == null) { - m_opts = {}; - } - if (typeof m_opts === 'string') { - m_opts = { - message: m_opts - }; - } - m_opts.type = 'success'; - return this.post(m_opts); - }; - - return ActionMessenger; - - })(_Messenger); - - $.fn.messenger = function() { - var $el, args, func, instance, opts, _ref2, _ref3, _ref4; - func = arguments[0], args = 2 <= arguments.length ? __slice.call(arguments, 1) : []; - if (func == null) { - func = {}; - } - $el = this; - if (!(func != null) || !_.isString(func)) { - opts = func; - if (!($el.data('messenger') != null)) { - _Messenger = (_ref2 = (_ref3 = Messenger.themes[opts.theme]) != null ? _ref3.Messenger : void 0) != null ? _ref2 : ActionMessenger; - $el.data('messenger', instance = new _Messenger($.extend({ - el: $el - }, opts))); - instance.render(); - } - return $el.data('messenger'); - } else { - return (_ref4 = $el.data('messenger'))[func].apply(_ref4, args); - } - }; - - window.Messenger._call = function(opts) { - var $el, $parent, choosen_loc, chosen_loc, classes, defaultOpts, inst, loc, locations, _i, _len; - defaultOpts = { - extraClasses: 'messenger-fixed messenger-on-bottom messenger-on-right', - theme: 'future', - maxMessages: 9, - parentLocations: ['body'] - }; - opts = $.extend(defaultOpts, $._messengerDefaults, Messenger.options, opts); - if (opts.theme != null) { - opts.extraClasses += " messenger-theme-" + opts.theme; - } - inst = opts.instance || Messenger.instance; - if (opts.instance == null) { - locations = opts.parentLocations; - $parent = null; - choosen_loc = null; - for (_i = 0, _len = locations.length; _i < _len; _i++) { - loc = locations[_i]; - $parent = $(loc); - if ($parent.length) { - chosen_loc = loc; - break; - } - } - if (!inst) { - $el = $('<ul>'); - $parent.prepend($el); - inst = $el.messenger(opts); - inst._location = chosen_loc; - Messenger.instance = inst; - } else if (!$(inst._location).is($(chosen_loc))) { - inst.$el.detach(); - $parent.prepend(inst.$el); - } - } - if (inst._addedClasses != null) { - inst.$el.removeClass(inst._addedClasses); - } - inst.$el.addClass(classes = "" + inst.className + " " + opts.extraClasses); - inst._addedClasses = classes; - return inst; - }; - - $.extend(Messenger, { - Message: RetryingMessage, - Messenger: ActionMessenger, - themes: (_ref2 = Messenger.themes) != null ? _ref2 : {} - }); - - $.globalMessenger = window.Messenger = Messenger; - -}).call(this); diff --git a/src/UI/JsLibraries/moment.js b/src/UI/JsLibraries/moment.js deleted file mode 100644 index 275a3c324..000000000 --- a/src/UI/JsLibraries/moment.js +++ /dev/null @@ -1,3111 +0,0 @@ -//! moment.js -//! version : 2.10.3 -//! authors : Tim Wood, Iskren Chernev, Moment.js contributors -//! license : MIT -//! momentjs.com - -(function (global, factory) { - typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : - typeof define === 'function' && define.amd ? define(factory) : - global.moment = factory() -}(this, function () { 'use strict'; - - var hookCallback; - - function utils_hooks__hooks () { - return hookCallback.apply(null, arguments); - } - - // This is done to register the method called with moment() - // without creating circular dependencies. - function setHookCallback (callback) { - hookCallback = callback; - } - - function isArray(input) { - return Object.prototype.toString.call(input) === '[object Array]'; - } - - function isDate(input) { - return input instanceof Date || Object.prototype.toString.call(input) === '[object Date]'; - } - - function map(arr, fn) { - var res = [], i; - for (i = 0; i < arr.length; ++i) { - res.push(fn(arr[i], i)); - } - return res; - } - - function hasOwnProp(a, b) { - return Object.prototype.hasOwnProperty.call(a, b); - } - - function extend(a, b) { - for (var i in b) { - if (hasOwnProp(b, i)) { - a[i] = b[i]; - } - } - - if (hasOwnProp(b, 'toString')) { - a.toString = b.toString; - } - - if (hasOwnProp(b, 'valueOf')) { - a.valueOf = b.valueOf; - } - - return a; - } - - function create_utc__createUTC (input, format, locale, strict) { - return createLocalOrUTC(input, format, locale, strict, true).utc(); - } - - function defaultParsingFlags() { - // We need to deep clone this object. - return { - empty : false, - unusedTokens : [], - unusedInput : [], - overflow : -2, - charsLeftOver : 0, - nullInput : false, - invalidMonth : null, - invalidFormat : false, - userInvalidated : false, - iso : false - }; - } - - function getParsingFlags(m) { - if (m._pf == null) { - m._pf = defaultParsingFlags(); - } - return m._pf; - } - - function valid__isValid(m) { - if (m._isValid == null) { - var flags = getParsingFlags(m); - m._isValid = !isNaN(m._d.getTime()) && - flags.overflow < 0 && - !flags.empty && - !flags.invalidMonth && - !flags.nullInput && - !flags.invalidFormat && - !flags.userInvalidated; - - if (m._strict) { - m._isValid = m._isValid && - flags.charsLeftOver === 0 && - flags.unusedTokens.length === 0 && - flags.bigHour === undefined; - } - } - return m._isValid; - } - - function valid__createInvalid (flags) { - var m = create_utc__createUTC(NaN); - if (flags != null) { - extend(getParsingFlags(m), flags); - } - else { - getParsingFlags(m).userInvalidated = true; - } - - return m; - } - - var momentProperties = utils_hooks__hooks.momentProperties = []; - - function copyConfig(to, from) { - var i, prop, val; - - if (typeof from._isAMomentObject !== 'undefined') { - to._isAMomentObject = from._isAMomentObject; - } - if (typeof from._i !== 'undefined') { - to._i = from._i; - } - if (typeof from._f !== 'undefined') { - to._f = from._f; - } - if (typeof from._l !== 'undefined') { - to._l = from._l; - } - if (typeof from._strict !== 'undefined') { - to._strict = from._strict; - } - if (typeof from._tzm !== 'undefined') { - to._tzm = from._tzm; - } - if (typeof from._isUTC !== 'undefined') { - to._isUTC = from._isUTC; - } - if (typeof from._offset !== 'undefined') { - to._offset = from._offset; - } - if (typeof from._pf !== 'undefined') { - to._pf = getParsingFlags(from); - } - if (typeof from._locale !== 'undefined') { - to._locale = from._locale; - } - - if (momentProperties.length > 0) { - for (i in momentProperties) { - prop = momentProperties[i]; - val = from[prop]; - if (typeof val !== 'undefined') { - to[prop] = val; - } - } - } - - return to; - } - - var updateInProgress = false; - - // Moment prototype object - function Moment(config) { - copyConfig(this, config); - this._d = new Date(+config._d); - // Prevent infinite loop in case updateOffset creates new moment - // objects. - if (updateInProgress === false) { - updateInProgress = true; - utils_hooks__hooks.updateOffset(this); - updateInProgress = false; - } - } - - function isMoment (obj) { - return obj instanceof Moment || (obj != null && obj._isAMomentObject != null); - } - - function toInt(argumentForCoercion) { - var coercedNumber = +argumentForCoercion, - value = 0; - - if (coercedNumber !== 0 && isFinite(coercedNumber)) { - if (coercedNumber >= 0) { - value = Math.floor(coercedNumber); - } else { - value = Math.ceil(coercedNumber); - } - } - - return value; - } - - function compareArrays(array1, array2, dontConvert) { - var len = Math.min(array1.length, array2.length), - lengthDiff = Math.abs(array1.length - array2.length), - diffs = 0, - i; - for (i = 0; i < len; i++) { - if ((dontConvert && array1[i] !== array2[i]) || - (!dontConvert && toInt(array1[i]) !== toInt(array2[i]))) { - diffs++; - } - } - return diffs + lengthDiff; - } - - function Locale() { - } - - var locales = {}; - var globalLocale; - - function normalizeLocale(key) { - return key ? key.toLowerCase().replace('_', '-') : key; - } - - // pick the locale from the array - // try ['en-au', 'en-gb'] as 'en-au', 'en-gb', 'en', as in move through the list trying each - // substring from most specific to least, but move to the next array item if it's a more specific variant than the current root - function chooseLocale(names) { - var i = 0, j, next, locale, split; - - while (i < names.length) { - split = normalizeLocale(names[i]).split('-'); - j = split.length; - next = normalizeLocale(names[i + 1]); - next = next ? next.split('-') : null; - while (j > 0) { - locale = loadLocale(split.slice(0, j).join('-')); - if (locale) { - return locale; - } - if (next && next.length >= j && compareArrays(split, next, true) >= j - 1) { - //the next array item is better than a shallower substring of this one - break; - } - j--; - } - i++; - } - return null; - } - - function loadLocale(name) { - var oldLocale = null; - // TODO: Find a better way to register and load all the locales in Node - if (!locales[name] && typeof module !== 'undefined' && - module && module.exports) { - try { - oldLocale = globalLocale._abbr; - require('./locale/' + name); - // because defineLocale currently also sets the global locale, we - // want to undo that for lazy loaded locales - locale_locales__getSetGlobalLocale(oldLocale); - } catch (e) { } - } - return locales[name]; - } - - // This function will load locale and then set the global locale. If - // no arguments are passed in, it will simply return the current global - // locale key. - function locale_locales__getSetGlobalLocale (key, values) { - var data; - if (key) { - if (typeof values === 'undefined') { - data = locale_locales__getLocale(key); - } - else { - data = defineLocale(key, values); - } - - if (data) { - // moment.duration._locale = moment._locale = data; - globalLocale = data; - } - } - - return globalLocale._abbr; - } - - function defineLocale (name, values) { - if (values !== null) { - values.abbr = name; - if (!locales[name]) { - locales[name] = new Locale(); - } - locales[name].set(values); - - // backwards compat for now: also set the locale - locale_locales__getSetGlobalLocale(name); - - return locales[name]; - } else { - // useful for testing - delete locales[name]; - return null; - } - } - - // returns locale data - function locale_locales__getLocale (key) { - var locale; - - if (key && key._locale && key._locale._abbr) { - key = key._locale._abbr; - } - - if (!key) { - return globalLocale; - } - - if (!isArray(key)) { - //short-circuit everything else - locale = loadLocale(key); - if (locale) { - return locale; - } - key = [key]; - } - - return chooseLocale(key); - } - - var aliases = {}; - - function addUnitAlias (unit, shorthand) { - var lowerCase = unit.toLowerCase(); - aliases[lowerCase] = aliases[lowerCase + 's'] = aliases[shorthand] = unit; - } - - function normalizeUnits(units) { - return typeof units === 'string' ? aliases[units] || aliases[units.toLowerCase()] : undefined; - } - - function normalizeObjectUnits(inputObject) { - var normalizedInput = {}, - normalizedProp, - prop; - - for (prop in inputObject) { - if (hasOwnProp(inputObject, prop)) { - normalizedProp = normalizeUnits(prop); - if (normalizedProp) { - normalizedInput[normalizedProp] = inputObject[prop]; - } - } - } - - return normalizedInput; - } - - function makeGetSet (unit, keepTime) { - return function (value) { - if (value != null) { - get_set__set(this, unit, value); - utils_hooks__hooks.updateOffset(this, keepTime); - return this; - } else { - return get_set__get(this, unit); - } - }; - } - - function get_set__get (mom, unit) { - return mom._d['get' + (mom._isUTC ? 'UTC' : '') + unit](); - } - - function get_set__set (mom, unit, value) { - return mom._d['set' + (mom._isUTC ? 'UTC' : '') + unit](value); - } - - // MOMENTS - - function getSet (units, value) { - var unit; - if (typeof units === 'object') { - for (unit in units) { - this.set(unit, units[unit]); - } - } else { - units = normalizeUnits(units); - if (typeof this[units] === 'function') { - return this[units](value); - } - } - return this; - } - - function zeroFill(number, targetLength, forceSign) { - var output = '' + Math.abs(number), - sign = number >= 0; - - while (output.length < targetLength) { - output = '0' + output; - } - return (sign ? (forceSign ? '+' : '') : '-') + output; - } - - var formattingTokens = /(\[[^\[]*\])|(\\)?(Mo|MM?M?M?|Do|DDDo|DD?D?D?|ddd?d?|do?|w[o|w]?|W[o|W]?|Q|YYYYYY|YYYYY|YYYY|YY|gg(ggg?)?|GG(GGG?)?|e|E|a|A|hh?|HH?|mm?|ss?|S{1,4}|x|X|zz?|ZZ?|.)/g; - - var localFormattingTokens = /(\[[^\[]*\])|(\\)?(LTS|LT|LL?L?L?|l{1,4})/g; - - var formatFunctions = {}; - - var formatTokenFunctions = {}; - - // token: 'M' - // padded: ['MM', 2] - // ordinal: 'Mo' - // callback: function () { this.month() + 1 } - function addFormatToken (token, padded, ordinal, callback) { - var func = callback; - if (typeof callback === 'string') { - func = function () { - return this[callback](); - }; - } - if (token) { - formatTokenFunctions[token] = func; - } - if (padded) { - formatTokenFunctions[padded[0]] = function () { - return zeroFill(func.apply(this, arguments), padded[1], padded[2]); - }; - } - if (ordinal) { - formatTokenFunctions[ordinal] = function () { - return this.localeData().ordinal(func.apply(this, arguments), token); - }; - } - } - - function removeFormattingTokens(input) { - if (input.match(/\[[\s\S]/)) { - return input.replace(/^\[|\]$/g, ''); - } - return input.replace(/\\/g, ''); - } - - function makeFormatFunction(format) { - var array = format.match(formattingTokens), i, length; - - for (i = 0, length = array.length; i < length; i++) { - if (formatTokenFunctions[array[i]]) { - array[i] = formatTokenFunctions[array[i]]; - } else { - array[i] = removeFormattingTokens(array[i]); - } - } - - return function (mom) { - var output = ''; - for (i = 0; i < length; i++) { - output += array[i] instanceof Function ? array[i].call(mom, format) : array[i]; - } - return output; - }; - } - - // format date using native date object - function formatMoment(m, format) { - if (!m.isValid()) { - return m.localeData().invalidDate(); - } - - format = expandFormat(format, m.localeData()); - - if (!formatFunctions[format]) { - formatFunctions[format] = makeFormatFunction(format); - } - - return formatFunctions[format](m); - } - - function expandFormat(format, locale) { - var i = 5; - - function replaceLongDateFormatTokens(input) { - return locale.longDateFormat(input) || input; - } - - localFormattingTokens.lastIndex = 0; - while (i >= 0 && localFormattingTokens.test(format)) { - format = format.replace(localFormattingTokens, replaceLongDateFormatTokens); - localFormattingTokens.lastIndex = 0; - i -= 1; - } - - return format; - } - - var match1 = /\d/; // 0 - 9 - var match2 = /\d\d/; // 00 - 99 - var match3 = /\d{3}/; // 000 - 999 - var match4 = /\d{4}/; // 0000 - 9999 - var match6 = /[+-]?\d{6}/; // -999999 - 999999 - var match1to2 = /\d\d?/; // 0 - 99 - var match1to3 = /\d{1,3}/; // 0 - 999 - var match1to4 = /\d{1,4}/; // 0 - 9999 - var match1to6 = /[+-]?\d{1,6}/; // -999999 - 999999 - - var matchUnsigned = /\d+/; // 0 - inf - var matchSigned = /[+-]?\d+/; // -inf - inf - - var matchOffset = /Z|[+-]\d\d:?\d\d/gi; // +00:00 -00:00 +0000 -0000 or Z - - var matchTimestamp = /[+-]?\d+(\.\d{1,3})?/; // 123456789 123456789.123 - - // any word (or two) characters or numbers including two/three word month in arabic. - var matchWord = /[0-9]*['a-z\u00A0-\u05FF\u0700-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]+|[\u0600-\u06FF\/]+(\s*?[\u0600-\u06FF]+){1,2}/i; - - var regexes = {}; - - function addRegexToken (token, regex, strictRegex) { - regexes[token] = typeof regex === 'function' ? regex : function (isStrict) { - return (isStrict && strictRegex) ? strictRegex : regex; - }; - } - - function getParseRegexForToken (token, config) { - if (!hasOwnProp(regexes, token)) { - return new RegExp(unescapeFormat(token)); - } - - return regexes[token](config._strict, config._locale); - } - - // Code from http://stackoverflow.com/questions/3561493/is-there-a-regexp-escape-function-in-javascript - function unescapeFormat(s) { - return s.replace('\\', '').replace(/\\(\[)|\\(\])|\[([^\]\[]*)\]|\\(.)/g, function (matched, p1, p2, p3, p4) { - return p1 || p2 || p3 || p4; - }).replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); - } - - var tokens = {}; - - function addParseToken (token, callback) { - var i, func = callback; - if (typeof token === 'string') { - token = [token]; - } - if (typeof callback === 'number') { - func = function (input, array) { - array[callback] = toInt(input); - }; - } - for (i = 0; i < token.length; i++) { - tokens[token[i]] = func; - } - } - - function addWeekParseToken (token, callback) { - addParseToken(token, function (input, array, config, token) { - config._w = config._w || {}; - callback(input, config._w, config, token); - }); - } - - function addTimeToArrayFromToken(token, input, config) { - if (input != null && hasOwnProp(tokens, token)) { - tokens[token](input, config._a, config, token); - } - } - - var YEAR = 0; - var MONTH = 1; - var DATE = 2; - var HOUR = 3; - var MINUTE = 4; - var SECOND = 5; - var MILLISECOND = 6; - - function daysInMonth(year, month) { - return new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); - } - - // FORMATTING - - addFormatToken('M', ['MM', 2], 'Mo', function () { - return this.month() + 1; - }); - - addFormatToken('MMM', 0, 0, function (format) { - return this.localeData().monthsShort(this, format); - }); - - addFormatToken('MMMM', 0, 0, function (format) { - return this.localeData().months(this, format); - }); - - // ALIASES - - addUnitAlias('month', 'M'); - - // PARSING - - addRegexToken('M', match1to2); - addRegexToken('MM', match1to2, match2); - addRegexToken('MMM', matchWord); - addRegexToken('MMMM', matchWord); - - addParseToken(['M', 'MM'], function (input, array) { - array[MONTH] = toInt(input) - 1; - }); - - addParseToken(['MMM', 'MMMM'], function (input, array, config, token) { - var month = config._locale.monthsParse(input, token, config._strict); - // if we didn't find a month name, mark the date as invalid. - if (month != null) { - array[MONTH] = month; - } else { - getParsingFlags(config).invalidMonth = input; - } - }); - - // LOCALES - - var defaultLocaleMonths = 'January_February_March_April_May_June_July_August_September_October_November_December'.split('_'); - function localeMonths (m) { - return this._months[m.month()]; - } - - var defaultLocaleMonthsShort = 'Jan_Feb_Mar_Apr_May_Jun_Jul_Aug_Sep_Oct_Nov_Dec'.split('_'); - function localeMonthsShort (m) { - return this._monthsShort[m.month()]; - } - - function localeMonthsParse (monthName, format, strict) { - var i, mom, regex; - - if (!this._monthsParse) { - this._monthsParse = []; - this._longMonthsParse = []; - this._shortMonthsParse = []; - } - - for (i = 0; i < 12; i++) { - // make the regex if we don't have it already - mom = create_utc__createUTC([2000, i]); - if (strict && !this._longMonthsParse[i]) { - this._longMonthsParse[i] = new RegExp('^' + this.months(mom, '').replace('.', '') + '$', 'i'); - this._shortMonthsParse[i] = new RegExp('^' + this.monthsShort(mom, '').replace('.', '') + '$', 'i'); - } - if (!strict && !this._monthsParse[i]) { - regex = '^' + this.months(mom, '') + '|^' + this.monthsShort(mom, ''); - this._monthsParse[i] = new RegExp(regex.replace('.', ''), 'i'); - } - // test the regex - if (strict && format === 'MMMM' && this._longMonthsParse[i].test(monthName)) { - return i; - } else if (strict && format === 'MMM' && this._shortMonthsParse[i].test(monthName)) { - return i; - } else if (!strict && this._monthsParse[i].test(monthName)) { - return i; - } - } - } - - // MOMENTS - - function setMonth (mom, value) { - var dayOfMonth; - - // TODO: Move this out of here! - if (typeof value === 'string') { - value = mom.localeData().monthsParse(value); - // TODO: Another silent failure? - if (typeof value !== 'number') { - return mom; - } - } - - dayOfMonth = Math.min(mom.date(), daysInMonth(mom.year(), value)); - mom._d['set' + (mom._isUTC ? 'UTC' : '') + 'Month'](value, dayOfMonth); - return mom; - } - - function getSetMonth (value) { - if (value != null) { - setMonth(this, value); - utils_hooks__hooks.updateOffset(this, true); - return this; - } else { - return get_set__get(this, 'Month'); - } - } - - function getDaysInMonth () { - return daysInMonth(this.year(), this.month()); - } - - function checkOverflow (m) { - var overflow; - var a = m._a; - - if (a && getParsingFlags(m).overflow === -2) { - overflow = - a[MONTH] < 0 || a[MONTH] > 11 ? MONTH : - a[DATE] < 1 || a[DATE] > daysInMonth(a[YEAR], a[MONTH]) ? DATE : - a[HOUR] < 0 || a[HOUR] > 24 || (a[HOUR] === 24 && (a[MINUTE] !== 0 || a[SECOND] !== 0 || a[MILLISECOND] !== 0)) ? HOUR : - a[MINUTE] < 0 || a[MINUTE] > 59 ? MINUTE : - a[SECOND] < 0 || a[SECOND] > 59 ? SECOND : - a[MILLISECOND] < 0 || a[MILLISECOND] > 999 ? MILLISECOND : - -1; - - if (getParsingFlags(m)._overflowDayOfYear && (overflow < YEAR || overflow > DATE)) { - overflow = DATE; - } - - getParsingFlags(m).overflow = overflow; - } - - return m; - } - - function warn(msg) { - if (utils_hooks__hooks.suppressDeprecationWarnings === false && typeof console !== 'undefined' && console.warn) { - console.warn('Deprecation warning: ' + msg); - } - } - - function deprecate(msg, fn) { - var firstTime = true, - msgWithStack = msg + '\n' + (new Error()).stack; - - return extend(function () { - if (firstTime) { - warn(msgWithStack); - firstTime = false; - } - return fn.apply(this, arguments); - }, fn); - } - - var deprecations = {}; - - function deprecateSimple(name, msg) { - if (!deprecations[name]) { - warn(msg); - deprecations[name] = true; - } - } - - utils_hooks__hooks.suppressDeprecationWarnings = false; - - var from_string__isoRegex = /^\s*(?:[+-]\d{6}|\d{4})-(?:(\d\d-\d\d)|(W\d\d$)|(W\d\d-\d)|(\d\d\d))((T| )(\d\d(:\d\d(:\d\d(\.\d+)?)?)?)?([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/; - - var isoDates = [ - ['YYYYYY-MM-DD', /[+-]\d{6}-\d{2}-\d{2}/], - ['YYYY-MM-DD', /\d{4}-\d{2}-\d{2}/], - ['GGGG-[W]WW-E', /\d{4}-W\d{2}-\d/], - ['GGGG-[W]WW', /\d{4}-W\d{2}/], - ['YYYY-DDD', /\d{4}-\d{3}/] - ]; - - // iso time formats and regexes - var isoTimes = [ - ['HH:mm:ss.SSSS', /(T| )\d\d:\d\d:\d\d\.\d+/], - ['HH:mm:ss', /(T| )\d\d:\d\d:\d\d/], - ['HH:mm', /(T| )\d\d:\d\d/], - ['HH', /(T| )\d\d/] - ]; - - var aspNetJsonRegex = /^\/?Date\((\-?\d+)/i; - - // date from iso format - function configFromISO(config) { - var i, l, - string = config._i, - match = from_string__isoRegex.exec(string); - - if (match) { - getParsingFlags(config).iso = true; - for (i = 0, l = isoDates.length; i < l; i++) { - if (isoDates[i][1].exec(string)) { - // match[5] should be 'T' or undefined - config._f = isoDates[i][0] + (match[6] || ' '); - break; - } - } - for (i = 0, l = isoTimes.length; i < l; i++) { - if (isoTimes[i][1].exec(string)) { - config._f += isoTimes[i][0]; - break; - } - } - if (string.match(matchOffset)) { - config._f += 'Z'; - } - configFromStringAndFormat(config); - } else { - config._isValid = false; - } - } - - // date from iso format or fallback - function configFromString(config) { - var matched = aspNetJsonRegex.exec(config._i); - - if (matched !== null) { - config._d = new Date(+matched[1]); - return; - } - - configFromISO(config); - if (config._isValid === false) { - delete config._isValid; - utils_hooks__hooks.createFromInputFallback(config); - } - } - - utils_hooks__hooks.createFromInputFallback = deprecate( - 'moment construction falls back to js Date. This is ' + - 'discouraged and will be removed in upcoming major ' + - 'release. Please refer to ' + - 'https://github.com/moment/moment/issues/1407 for more info.', - function (config) { - config._d = new Date(config._i + (config._useUTC ? ' UTC' : '')); - } - ); - - function createDate (y, m, d, h, M, s, ms) { - //can't just apply() to create a date: - //http://stackoverflow.com/questions/181348/instantiating-a-javascript-object-by-calling-prototype-constructor-apply - var date = new Date(y, m, d, h, M, s, ms); - - //the date constructor doesn't accept years < 1970 - if (y < 1970) { - date.setFullYear(y); - } - return date; - } - - function createUTCDate (y) { - var date = new Date(Date.UTC.apply(null, arguments)); - if (y < 1970) { - date.setUTCFullYear(y); - } - return date; - } - - addFormatToken(0, ['YY', 2], 0, function () { - return this.year() % 100; - }); - - addFormatToken(0, ['YYYY', 4], 0, 'year'); - addFormatToken(0, ['YYYYY', 5], 0, 'year'); - addFormatToken(0, ['YYYYYY', 6, true], 0, 'year'); - - // ALIASES - - addUnitAlias('year', 'y'); - - // PARSING - - addRegexToken('Y', matchSigned); - addRegexToken('YY', match1to2, match2); - addRegexToken('YYYY', match1to4, match4); - addRegexToken('YYYYY', match1to6, match6); - addRegexToken('YYYYYY', match1to6, match6); - - addParseToken(['YYYY', 'YYYYY', 'YYYYYY'], YEAR); - addParseToken('YY', function (input, array) { - array[YEAR] = utils_hooks__hooks.parseTwoDigitYear(input); - }); - - // HELPERS - - function daysInYear(year) { - return isLeapYear(year) ? 366 : 365; - } - - function isLeapYear(year) { - return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; - } - - // HOOKS - - utils_hooks__hooks.parseTwoDigitYear = function (input) { - return toInt(input) + (toInt(input) > 68 ? 1900 : 2000); - }; - - // MOMENTS - - var getSetYear = makeGetSet('FullYear', false); - - function getIsLeapYear () { - return isLeapYear(this.year()); - } - - addFormatToken('w', ['ww', 2], 'wo', 'week'); - addFormatToken('W', ['WW', 2], 'Wo', 'isoWeek'); - - // ALIASES - - addUnitAlias('week', 'w'); - addUnitAlias('isoWeek', 'W'); - - // PARSING - - addRegexToken('w', match1to2); - addRegexToken('ww', match1to2, match2); - addRegexToken('W', match1to2); - addRegexToken('WW', match1to2, match2); - - addWeekParseToken(['w', 'ww', 'W', 'WW'], function (input, week, config, token) { - week[token.substr(0, 1)] = toInt(input); - }); - - // HELPERS - - // firstDayOfWeek 0 = sun, 6 = sat - // the day of the week that starts the week - // (usually sunday or monday) - // firstDayOfWeekOfYear 0 = sun, 6 = sat - // the first week is the week that contains the first - // of this day of the week - // (eg. ISO weeks use thursday (4)) - function weekOfYear(mom, firstDayOfWeek, firstDayOfWeekOfYear) { - var end = firstDayOfWeekOfYear - firstDayOfWeek, - daysToDayOfWeek = firstDayOfWeekOfYear - mom.day(), - adjustedMoment; - - - if (daysToDayOfWeek > end) { - daysToDayOfWeek -= 7; - } - - if (daysToDayOfWeek < end - 7) { - daysToDayOfWeek += 7; - } - - adjustedMoment = local__createLocal(mom).add(daysToDayOfWeek, 'd'); - return { - week: Math.ceil(adjustedMoment.dayOfYear() / 7), - year: adjustedMoment.year() - }; - } - - // LOCALES - - function localeWeek (mom) { - return weekOfYear(mom, this._week.dow, this._week.doy).week; - } - - var defaultLocaleWeek = { - dow : 0, // Sunday is the first day of the week. - doy : 6 // The week that contains Jan 1st is the first week of the year. - }; - - function localeFirstDayOfWeek () { - return this._week.dow; - } - - function localeFirstDayOfYear () { - return this._week.doy; - } - - // MOMENTS - - function getSetWeek (input) { - var week = this.localeData().week(this); - return input == null ? week : this.add((input - week) * 7, 'd'); - } - - function getSetISOWeek (input) { - var week = weekOfYear(this, 1, 4).week; - return input == null ? week : this.add((input - week) * 7, 'd'); - } - - addFormatToken('DDD', ['DDDD', 3], 'DDDo', 'dayOfYear'); - - // ALIASES - - addUnitAlias('dayOfYear', 'DDD'); - - // PARSING - - addRegexToken('DDD', match1to3); - addRegexToken('DDDD', match3); - addParseToken(['DDD', 'DDDD'], function (input, array, config) { - config._dayOfYear = toInt(input); - }); - - // HELPERS - - //http://en.wikipedia.org/wiki/ISO_week_date#Calculating_a_date_given_the_year.2C_week_number_and_weekday - function dayOfYearFromWeeks(year, week, weekday, firstDayOfWeekOfYear, firstDayOfWeek) { - var d = createUTCDate(year, 0, 1).getUTCDay(); - var daysToAdd; - var dayOfYear; - - d = d === 0 ? 7 : d; - weekday = weekday != null ? weekday : firstDayOfWeek; - daysToAdd = firstDayOfWeek - d + (d > firstDayOfWeekOfYear ? 7 : 0) - (d < firstDayOfWeek ? 7 : 0); - dayOfYear = 7 * (week - 1) + (weekday - firstDayOfWeek) + daysToAdd + 1; - - return { - year : dayOfYear > 0 ? year : year - 1, - dayOfYear : dayOfYear > 0 ? dayOfYear : daysInYear(year - 1) + dayOfYear - }; - } - - // MOMENTS - - function getSetDayOfYear (input) { - var dayOfYear = Math.round((this.clone().startOf('day') - this.clone().startOf('year')) / 864e5) + 1; - return input == null ? dayOfYear : this.add((input - dayOfYear), 'd'); - } - - // Pick the first defined of two or three arguments. - function defaults(a, b, c) { - if (a != null) { - return a; - } - if (b != null) { - return b; - } - return c; - } - - function currentDateArray(config) { - var now = new Date(); - if (config._useUTC) { - return [now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()]; - } - return [now.getFullYear(), now.getMonth(), now.getDate()]; - } - - // convert an array to a date. - // the array should mirror the parameters below - // note: all values past the year are optional and will default to the lowest possible value. - // [year, month, day , hour, minute, second, millisecond] - function configFromArray (config) { - var i, date, input = [], currentDate, yearToUse; - - if (config._d) { - return; - } - - currentDate = currentDateArray(config); - - //compute day of the year from weeks and weekdays - if (config._w && config._a[DATE] == null && config._a[MONTH] == null) { - dayOfYearFromWeekInfo(config); - } - - //if the day of the year is set, figure out what it is - if (config._dayOfYear) { - yearToUse = defaults(config._a[YEAR], currentDate[YEAR]); - - if (config._dayOfYear > daysInYear(yearToUse)) { - getParsingFlags(config)._overflowDayOfYear = true; - } - - date = createUTCDate(yearToUse, 0, config._dayOfYear); - config._a[MONTH] = date.getUTCMonth(); - config._a[DATE] = date.getUTCDate(); - } - - // Default to current date. - // * if no year, month, day of month are given, default to today - // * if day of month is given, default month and year - // * if month is given, default only year - // * if year is given, don't default anything - for (i = 0; i < 3 && config._a[i] == null; ++i) { - config._a[i] = input[i] = currentDate[i]; - } - - // Zero out whatever was not defaulted, including time - for (; i < 7; i++) { - config._a[i] = input[i] = (config._a[i] == null) ? (i === 2 ? 1 : 0) : config._a[i]; - } - - // Check for 24:00:00.000 - if (config._a[HOUR] === 24 && - config._a[MINUTE] === 0 && - config._a[SECOND] === 0 && - config._a[MILLISECOND] === 0) { - config._nextDay = true; - config._a[HOUR] = 0; - } - - config._d = (config._useUTC ? createUTCDate : createDate).apply(null, input); - // Apply timezone offset from input. The actual utcOffset can be changed - // with parseZone. - if (config._tzm != null) { - config._d.setUTCMinutes(config._d.getUTCMinutes() - config._tzm); - } - - if (config._nextDay) { - config._a[HOUR] = 24; - } - } - - function dayOfYearFromWeekInfo(config) { - var w, weekYear, week, weekday, dow, doy, temp; - - w = config._w; - if (w.GG != null || w.W != null || w.E != null) { - dow = 1; - doy = 4; - - // TODO: We need to take the current isoWeekYear, but that depends on - // how we interpret now (local, utc, fixed offset). So create - // a now version of current config (take local/utc/offset flags, and - // create now). - weekYear = defaults(w.GG, config._a[YEAR], weekOfYear(local__createLocal(), 1, 4).year); - week = defaults(w.W, 1); - weekday = defaults(w.E, 1); - } else { - dow = config._locale._week.dow; - doy = config._locale._week.doy; - - weekYear = defaults(w.gg, config._a[YEAR], weekOfYear(local__createLocal(), dow, doy).year); - week = defaults(w.w, 1); - - if (w.d != null) { - // weekday -- low day numbers are considered next week - weekday = w.d; - if (weekday < dow) { - ++week; - } - } else if (w.e != null) { - // local weekday -- counting starts from begining of week - weekday = w.e + dow; - } else { - // default to begining of week - weekday = dow; - } - } - temp = dayOfYearFromWeeks(weekYear, week, weekday, doy, dow); - - config._a[YEAR] = temp.year; - config._dayOfYear = temp.dayOfYear; - } - - utils_hooks__hooks.ISO_8601 = function () {}; - - // date from string and format string - function configFromStringAndFormat(config) { - // TODO: Move this to another part of the creation flow to prevent circular deps - if (config._f === utils_hooks__hooks.ISO_8601) { - configFromISO(config); - return; - } - - config._a = []; - getParsingFlags(config).empty = true; - - // This array is used to make a Date, either with `new Date` or `Date.UTC` - var string = '' + config._i, - i, parsedInput, tokens, token, skipped, - stringLength = string.length, - totalParsedInputLength = 0; - - tokens = expandFormat(config._f, config._locale).match(formattingTokens) || []; - - for (i = 0; i < tokens.length; i++) { - token = tokens[i]; - parsedInput = (string.match(getParseRegexForToken(token, config)) || [])[0]; - if (parsedInput) { - skipped = string.substr(0, string.indexOf(parsedInput)); - if (skipped.length > 0) { - getParsingFlags(config).unusedInput.push(skipped); - } - string = string.slice(string.indexOf(parsedInput) + parsedInput.length); - totalParsedInputLength += parsedInput.length; - } - // don't parse if it's not a known token - if (formatTokenFunctions[token]) { - if (parsedInput) { - getParsingFlags(config).empty = false; - } - else { - getParsingFlags(config).unusedTokens.push(token); - } - addTimeToArrayFromToken(token, parsedInput, config); - } - else if (config._strict && !parsedInput) { - getParsingFlags(config).unusedTokens.push(token); - } - } - - // add remaining unparsed input length to the string - getParsingFlags(config).charsLeftOver = stringLength - totalParsedInputLength; - if (string.length > 0) { - getParsingFlags(config).unusedInput.push(string); - } - - // clear _12h flag if hour is <= 12 - if (getParsingFlags(config).bigHour === true && - config._a[HOUR] <= 12 && - config._a[HOUR] > 0) { - getParsingFlags(config).bigHour = undefined; - } - // handle meridiem - config._a[HOUR] = meridiemFixWrap(config._locale, config._a[HOUR], config._meridiem); - - configFromArray(config); - checkOverflow(config); - } - - - function meridiemFixWrap (locale, hour, meridiem) { - var isPm; - - if (meridiem == null) { - // nothing to do - return hour; - } - if (locale.meridiemHour != null) { - return locale.meridiemHour(hour, meridiem); - } else if (locale.isPM != null) { - // Fallback - isPm = locale.isPM(meridiem); - if (isPm && hour < 12) { - hour += 12; - } - if (!isPm && hour === 12) { - hour = 0; - } - return hour; - } else { - // this is not supposed to happen - return hour; - } - } - - function configFromStringAndArray(config) { - var tempConfig, - bestMoment, - - scoreToBeat, - i, - currentScore; - - if (config._f.length === 0) { - getParsingFlags(config).invalidFormat = true; - config._d = new Date(NaN); - return; - } - - for (i = 0; i < config._f.length; i++) { - currentScore = 0; - tempConfig = copyConfig({}, config); - if (config._useUTC != null) { - tempConfig._useUTC = config._useUTC; - } - tempConfig._f = config._f[i]; - configFromStringAndFormat(tempConfig); - - if (!valid__isValid(tempConfig)) { - continue; - } - - // if there is any input that was not parsed add a penalty for that format - currentScore += getParsingFlags(tempConfig).charsLeftOver; - - //or tokens - currentScore += getParsingFlags(tempConfig).unusedTokens.length * 10; - - getParsingFlags(tempConfig).score = currentScore; - - if (scoreToBeat == null || currentScore < scoreToBeat) { - scoreToBeat = currentScore; - bestMoment = tempConfig; - } - } - - extend(config, bestMoment || tempConfig); - } - - function configFromObject(config) { - if (config._d) { - return; - } - - var i = normalizeObjectUnits(config._i); - config._a = [i.year, i.month, i.day || i.date, i.hour, i.minute, i.second, i.millisecond]; - - configFromArray(config); - } - - function createFromConfig (config) { - var input = config._i, - format = config._f, - res; - - config._locale = config._locale || locale_locales__getLocale(config._l); - - if (input === null || (format === undefined && input === '')) { - return valid__createInvalid({nullInput: true}); - } - - if (typeof input === 'string') { - config._i = input = config._locale.preparse(input); - } - - if (isMoment(input)) { - return new Moment(checkOverflow(input)); - } else if (isArray(format)) { - configFromStringAndArray(config); - } else if (format) { - configFromStringAndFormat(config); - } else if (isDate(input)) { - config._d = input; - } else { - configFromInput(config); - } - - res = new Moment(checkOverflow(config)); - if (res._nextDay) { - // Adding is smart enough around DST - res.add(1, 'd'); - res._nextDay = undefined; - } - - return res; - } - - function configFromInput(config) { - var input = config._i; - if (input === undefined) { - config._d = new Date(); - } else if (isDate(input)) { - config._d = new Date(+input); - } else if (typeof input === 'string') { - configFromString(config); - } else if (isArray(input)) { - config._a = map(input.slice(0), function (obj) { - return parseInt(obj, 10); - }); - configFromArray(config); - } else if (typeof(input) === 'object') { - configFromObject(config); - } else if (typeof(input) === 'number') { - // from milliseconds - config._d = new Date(input); - } else { - utils_hooks__hooks.createFromInputFallback(config); - } - } - - function createLocalOrUTC (input, format, locale, strict, isUTC) { - var c = {}; - - if (typeof(locale) === 'boolean') { - strict = locale; - locale = undefined; - } - // object construction must be done this way. - // https://github.com/moment/moment/issues/1423 - c._isAMomentObject = true; - c._useUTC = c._isUTC = isUTC; - c._l = locale; - c._i = input; - c._f = format; - c._strict = strict; - - return createFromConfig(c); - } - - function local__createLocal (input, format, locale, strict) { - return createLocalOrUTC(input, format, locale, strict, false); - } - - var prototypeMin = deprecate( - 'moment().min is deprecated, use moment.min instead. https://github.com/moment/moment/issues/1548', - function () { - var other = local__createLocal.apply(null, arguments); - return other < this ? this : other; - } - ); - - var prototypeMax = deprecate( - 'moment().max is deprecated, use moment.max instead. https://github.com/moment/moment/issues/1548', - function () { - var other = local__createLocal.apply(null, arguments); - return other > this ? this : other; - } - ); - - // Pick a moment m from moments so that m[fn](other) is true for all - // other. This relies on the function fn to be transitive. - // - // moments should either be an array of moment objects or an array, whose - // first element is an array of moment objects. - function pickBy(fn, moments) { - var res, i; - if (moments.length === 1 && isArray(moments[0])) { - moments = moments[0]; - } - if (!moments.length) { - return local__createLocal(); - } - res = moments[0]; - for (i = 1; i < moments.length; ++i) { - if (moments[i][fn](res)) { - res = moments[i]; - } - } - return res; - } - - // TODO: Use [].sort instead? - function min () { - var args = [].slice.call(arguments, 0); - - return pickBy('isBefore', args); - } - - function max () { - var args = [].slice.call(arguments, 0); - - return pickBy('isAfter', args); - } - - function Duration (duration) { - var normalizedInput = normalizeObjectUnits(duration), - years = normalizedInput.year || 0, - quarters = normalizedInput.quarter || 0, - months = normalizedInput.month || 0, - weeks = normalizedInput.week || 0, - days = normalizedInput.day || 0, - hours = normalizedInput.hour || 0, - minutes = normalizedInput.minute || 0, - seconds = normalizedInput.second || 0, - milliseconds = normalizedInput.millisecond || 0; - - // representation for dateAddRemove - this._milliseconds = +milliseconds + - seconds * 1e3 + // 1000 - minutes * 6e4 + // 1000 * 60 - hours * 36e5; // 1000 * 60 * 60 - // Because of dateAddRemove treats 24 hours as different from a - // day when working around DST, we need to store them separately - this._days = +days + - weeks * 7; - // It is impossible translate months into days without knowing - // which months you are are talking about, so we have to store - // it separately. - this._months = +months + - quarters * 3 + - years * 12; - - this._data = {}; - - this._locale = locale_locales__getLocale(); - - this._bubble(); - } - - function isDuration (obj) { - return obj instanceof Duration; - } - - function offset (token, separator) { - addFormatToken(token, 0, 0, function () { - var offset = this.utcOffset(); - var sign = '+'; - if (offset < 0) { - offset = -offset; - sign = '-'; - } - return sign + zeroFill(~~(offset / 60), 2) + separator + zeroFill(~~(offset) % 60, 2); - }); - } - - offset('Z', ':'); - offset('ZZ', ''); - - // PARSING - - addRegexToken('Z', matchOffset); - addRegexToken('ZZ', matchOffset); - addParseToken(['Z', 'ZZ'], function (input, array, config) { - config._useUTC = true; - config._tzm = offsetFromString(input); - }); - - // HELPERS - - // timezone chunker - // '+10:00' > ['10', '00'] - // '-1530' > ['-15', '30'] - var chunkOffset = /([\+\-]|\d\d)/gi; - - function offsetFromString(string) { - var matches = ((string || '').match(matchOffset) || []); - var chunk = matches[matches.length - 1] || []; - var parts = (chunk + '').match(chunkOffset) || ['-', 0, 0]; - var minutes = +(parts[1] * 60) + toInt(parts[2]); - - return parts[0] === '+' ? minutes : -minutes; - } - - // Return a moment from input, that is local/utc/zone equivalent to model. - function cloneWithOffset(input, model) { - var res, diff; - if (model._isUTC) { - res = model.clone(); - diff = (isMoment(input) || isDate(input) ? +input : +local__createLocal(input)) - (+res); - // Use low-level api, because this fn is low-level api. - res._d.setTime(+res._d + diff); - utils_hooks__hooks.updateOffset(res, false); - return res; - } else { - return local__createLocal(input).local(); - } - return model._isUTC ? local__createLocal(input).zone(model._offset || 0) : local__createLocal(input).local(); - } - - function getDateOffset (m) { - // On Firefox.24 Date#getTimezoneOffset returns a floating point. - // https://github.com/moment/moment/pull/1871 - return -Math.round(m._d.getTimezoneOffset() / 15) * 15; - } - - // HOOKS - - // This function will be called whenever a moment is mutated. - // It is intended to keep the offset in sync with the timezone. - utils_hooks__hooks.updateOffset = function () {}; - - // MOMENTS - - // keepLocalTime = true means only change the timezone, without - // affecting the local hour. So 5:31:26 +0300 --[utcOffset(2, true)]--> - // 5:31:26 +0200 It is possible that 5:31:26 doesn't exist with offset - // +0200, so we adjust the time as needed, to be valid. - // - // Keeping the time actually adds/subtracts (one hour) - // from the actual represented time. That is why we call updateOffset - // a second time. In case it wants us to change the offset again - // _changeInProgress == true case, then we have to adjust, because - // there is no such time in the given timezone. - function getSetOffset (input, keepLocalTime) { - var offset = this._offset || 0, - localAdjust; - if (input != null) { - if (typeof input === 'string') { - input = offsetFromString(input); - } - if (Math.abs(input) < 16) { - input = input * 60; - } - if (!this._isUTC && keepLocalTime) { - localAdjust = getDateOffset(this); - } - this._offset = input; - this._isUTC = true; - if (localAdjust != null) { - this.add(localAdjust, 'm'); - } - if (offset !== input) { - if (!keepLocalTime || this._changeInProgress) { - add_subtract__addSubtract(this, create__createDuration(input - offset, 'm'), 1, false); - } else if (!this._changeInProgress) { - this._changeInProgress = true; - utils_hooks__hooks.updateOffset(this, true); - this._changeInProgress = null; - } - } - return this; - } else { - return this._isUTC ? offset : getDateOffset(this); - } - } - - function getSetZone (input, keepLocalTime) { - if (input != null) { - if (typeof input !== 'string') { - input = -input; - } - - this.utcOffset(input, keepLocalTime); - - return this; - } else { - return -this.utcOffset(); - } - } - - function setOffsetToUTC (keepLocalTime) { - return this.utcOffset(0, keepLocalTime); - } - - function setOffsetToLocal (keepLocalTime) { - if (this._isUTC) { - this.utcOffset(0, keepLocalTime); - this._isUTC = false; - - if (keepLocalTime) { - this.subtract(getDateOffset(this), 'm'); - } - } - return this; - } - - function setOffsetToParsedOffset () { - if (this._tzm) { - this.utcOffset(this._tzm); - } else if (typeof this._i === 'string') { - this.utcOffset(offsetFromString(this._i)); - } - return this; - } - - function hasAlignedHourOffset (input) { - if (!input) { - input = 0; - } - else { - input = local__createLocal(input).utcOffset(); - } - - return (this.utcOffset() - input) % 60 === 0; - } - - function isDaylightSavingTime () { - return ( - this.utcOffset() > this.clone().month(0).utcOffset() || - this.utcOffset() > this.clone().month(5).utcOffset() - ); - } - - function isDaylightSavingTimeShifted () { - if (this._a) { - var other = this._isUTC ? create_utc__createUTC(this._a) : local__createLocal(this._a); - return this.isValid() && compareArrays(this._a, other.toArray()) > 0; - } - - return false; - } - - function isLocal () { - return !this._isUTC; - } - - function isUtcOffset () { - return this._isUTC; - } - - function isUtc () { - return this._isUTC && this._offset === 0; - } - - var aspNetRegex = /(\-)?(?:(\d*)\.)?(\d+)\:(\d+)(?:\:(\d+)\.?(\d{3})?)?/; - - // from http://docs.closure-library.googlecode.com/git/closure_goog_date_date.js.source.html - // somewhat more in line with 4.4.3.2 2004 spec, but allows decimal anywhere - var create__isoRegex = /^(-)?P(?:(?:([0-9,.]*)Y)?(?:([0-9,.]*)M)?(?:([0-9,.]*)D)?(?:T(?:([0-9,.]*)H)?(?:([0-9,.]*)M)?(?:([0-9,.]*)S)?)?|([0-9,.]*)W)$/; - - function create__createDuration (input, key) { - var duration = input, - // matching against regexp is expensive, do it on demand - match = null, - sign, - ret, - diffRes; - - if (isDuration(input)) { - duration = { - ms : input._milliseconds, - d : input._days, - M : input._months - }; - } else if (typeof input === 'number') { - duration = {}; - if (key) { - duration[key] = input; - } else { - duration.milliseconds = input; - } - } else if (!!(match = aspNetRegex.exec(input))) { - sign = (match[1] === '-') ? -1 : 1; - duration = { - y : 0, - d : toInt(match[DATE]) * sign, - h : toInt(match[HOUR]) * sign, - m : toInt(match[MINUTE]) * sign, - s : toInt(match[SECOND]) * sign, - ms : toInt(match[MILLISECOND]) * sign - }; - } else if (!!(match = create__isoRegex.exec(input))) { - sign = (match[1] === '-') ? -1 : 1; - duration = { - y : parseIso(match[2], sign), - M : parseIso(match[3], sign), - d : parseIso(match[4], sign), - h : parseIso(match[5], sign), - m : parseIso(match[6], sign), - s : parseIso(match[7], sign), - w : parseIso(match[8], sign) - }; - } else if (duration == null) {// checks for null or undefined - duration = {}; - } else if (typeof duration === 'object' && ('from' in duration || 'to' in duration)) { - diffRes = momentsDifference(local__createLocal(duration.from), local__createLocal(duration.to)); - - duration = {}; - duration.ms = diffRes.milliseconds; - duration.M = diffRes.months; - } - - ret = new Duration(duration); - - if (isDuration(input) && hasOwnProp(input, '_locale')) { - ret._locale = input._locale; - } - - return ret; - } - - create__createDuration.fn = Duration.prototype; - - function parseIso (inp, sign) { - // We'd normally use ~~inp for this, but unfortunately it also - // converts floats to ints. - // inp may be undefined, so careful calling replace on it. - var res = inp && parseFloat(inp.replace(',', '.')); - // apply sign while we're at it - return (isNaN(res) ? 0 : res) * sign; - } - - function positiveMomentsDifference(base, other) { - var res = {milliseconds: 0, months: 0}; - - res.months = other.month() - base.month() + - (other.year() - base.year()) * 12; - if (base.clone().add(res.months, 'M').isAfter(other)) { - --res.months; - } - - res.milliseconds = +other - +(base.clone().add(res.months, 'M')); - - return res; - } - - function momentsDifference(base, other) { - var res; - other = cloneWithOffset(other, base); - if (base.isBefore(other)) { - res = positiveMomentsDifference(base, other); - } else { - res = positiveMomentsDifference(other, base); - res.milliseconds = -res.milliseconds; - res.months = -res.months; - } - - return res; - } - - function createAdder(direction, name) { - return function (val, period) { - var dur, tmp; - //invert the arguments, but complain about it - if (period !== null && !isNaN(+period)) { - deprecateSimple(name, 'moment().' + name + '(period, number) is deprecated. Please use moment().' + name + '(number, period).'); - tmp = val; val = period; period = tmp; - } - - val = typeof val === 'string' ? +val : val; - dur = create__createDuration(val, period); - add_subtract__addSubtract(this, dur, direction); - return this; - }; - } - - function add_subtract__addSubtract (mom, duration, isAdding, updateOffset) { - var milliseconds = duration._milliseconds, - days = duration._days, - months = duration._months; - updateOffset = updateOffset == null ? true : updateOffset; - - if (milliseconds) { - mom._d.setTime(+mom._d + milliseconds * isAdding); - } - if (days) { - get_set__set(mom, 'Date', get_set__get(mom, 'Date') + days * isAdding); - } - if (months) { - setMonth(mom, get_set__get(mom, 'Month') + months * isAdding); - } - if (updateOffset) { - utils_hooks__hooks.updateOffset(mom, days || months); - } - } - - var add_subtract__add = createAdder(1, 'add'); - var add_subtract__subtract = createAdder(-1, 'subtract'); - - function moment_calendar__calendar (time) { - // We want to compare the start of today, vs this. - // Getting start-of-today depends on whether we're local/utc/offset or not. - var now = time || local__createLocal(), - sod = cloneWithOffset(now, this).startOf('day'), - diff = this.diff(sod, 'days', true), - format = diff < -6 ? 'sameElse' : - diff < -1 ? 'lastWeek' : - diff < 0 ? 'lastDay' : - diff < 1 ? 'sameDay' : - diff < 2 ? 'nextDay' : - diff < 7 ? 'nextWeek' : 'sameElse'; - return this.format(this.localeData().calendar(format, this, local__createLocal(now))); - } - - function clone () { - return new Moment(this); - } - - function isAfter (input, units) { - var inputMs; - units = normalizeUnits(typeof units !== 'undefined' ? units : 'millisecond'); - if (units === 'millisecond') { - input = isMoment(input) ? input : local__createLocal(input); - return +this > +input; - } else { - inputMs = isMoment(input) ? +input : +local__createLocal(input); - return inputMs < +this.clone().startOf(units); - } - } - - function isBefore (input, units) { - var inputMs; - units = normalizeUnits(typeof units !== 'undefined' ? units : 'millisecond'); - if (units === 'millisecond') { - input = isMoment(input) ? input : local__createLocal(input); - return +this < +input; - } else { - inputMs = isMoment(input) ? +input : +local__createLocal(input); - return +this.clone().endOf(units) < inputMs; - } - } - - function isBetween (from, to, units) { - return this.isAfter(from, units) && this.isBefore(to, units); - } - - function isSame (input, units) { - var inputMs; - units = normalizeUnits(units || 'millisecond'); - if (units === 'millisecond') { - input = isMoment(input) ? input : local__createLocal(input); - return +this === +input; - } else { - inputMs = +local__createLocal(input); - return +(this.clone().startOf(units)) <= inputMs && inputMs <= +(this.clone().endOf(units)); - } - } - - function absFloor (number) { - if (number < 0) { - return Math.ceil(number); - } else { - return Math.floor(number); - } - } - - function diff (input, units, asFloat) { - var that = cloneWithOffset(input, this), - zoneDelta = (that.utcOffset() - this.utcOffset()) * 6e4, - delta, output; - - units = normalizeUnits(units); - - if (units === 'year' || units === 'month' || units === 'quarter') { - output = monthDiff(this, that); - if (units === 'quarter') { - output = output / 3; - } else if (units === 'year') { - output = output / 12; - } - } else { - delta = this - that; - output = units === 'second' ? delta / 1e3 : // 1000 - units === 'minute' ? delta / 6e4 : // 1000 * 60 - units === 'hour' ? delta / 36e5 : // 1000 * 60 * 60 - units === 'day' ? (delta - zoneDelta) / 864e5 : // 1000 * 60 * 60 * 24, negate dst - units === 'week' ? (delta - zoneDelta) / 6048e5 : // 1000 * 60 * 60 * 24 * 7, negate dst - delta; - } - return asFloat ? output : absFloor(output); - } - - function monthDiff (a, b) { - // difference in months - var wholeMonthDiff = ((b.year() - a.year()) * 12) + (b.month() - a.month()), - // b is in (anchor - 1 month, anchor + 1 month) - anchor = a.clone().add(wholeMonthDiff, 'months'), - anchor2, adjust; - - if (b - anchor < 0) { - anchor2 = a.clone().add(wholeMonthDiff - 1, 'months'); - // linear across the month - adjust = (b - anchor) / (anchor - anchor2); - } else { - anchor2 = a.clone().add(wholeMonthDiff + 1, 'months'); - // linear across the month - adjust = (b - anchor) / (anchor2 - anchor); - } - - return -(wholeMonthDiff + adjust); - } - - utils_hooks__hooks.defaultFormat = 'YYYY-MM-DDTHH:mm:ssZ'; - - function toString () { - return this.clone().locale('en').format('ddd MMM DD YYYY HH:mm:ss [GMT]ZZ'); - } - - function moment_format__toISOString () { - var m = this.clone().utc(); - if (0 < m.year() && m.year() <= 9999) { - if ('function' === typeof Date.prototype.toISOString) { - // native implementation is ~50x faster, use it when we can - return this.toDate().toISOString(); - } else { - return formatMoment(m, 'YYYY-MM-DD[T]HH:mm:ss.SSS[Z]'); - } - } else { - return formatMoment(m, 'YYYYYY-MM-DD[T]HH:mm:ss.SSS[Z]'); - } - } - - function format (inputString) { - var output = formatMoment(this, inputString || utils_hooks__hooks.defaultFormat); - return this.localeData().postformat(output); - } - - function from (time, withoutSuffix) { - if (!this.isValid()) { - return this.localeData().invalidDate(); - } - return create__createDuration({to: this, from: time}).locale(this.locale()).humanize(!withoutSuffix); - } - - function fromNow (withoutSuffix) { - return this.from(local__createLocal(), withoutSuffix); - } - - function to (time, withoutSuffix) { - if (!this.isValid()) { - return this.localeData().invalidDate(); - } - return create__createDuration({from: this, to: time}).locale(this.locale()).humanize(!withoutSuffix); - } - - function toNow (withoutSuffix) { - return this.to(local__createLocal(), withoutSuffix); - } - - function locale (key) { - var newLocaleData; - - if (key === undefined) { - return this._locale._abbr; - } else { - newLocaleData = locale_locales__getLocale(key); - if (newLocaleData != null) { - this._locale = newLocaleData; - } - return this; - } - } - - var lang = deprecate( - 'moment().lang() is deprecated. Instead, use moment().localeData() to get the language configuration. Use moment().locale() to change languages.', - function (key) { - if (key === undefined) { - return this.localeData(); - } else { - return this.locale(key); - } - } - ); - - function localeData () { - return this._locale; - } - - function startOf (units) { - units = normalizeUnits(units); - // the following switch intentionally omits break keywords - // to utilize falling through the cases. - switch (units) { - case 'year': - this.month(0); - /* falls through */ - case 'quarter': - case 'month': - this.date(1); - /* falls through */ - case 'week': - case 'isoWeek': - case 'day': - this.hours(0); - /* falls through */ - case 'hour': - this.minutes(0); - /* falls through */ - case 'minute': - this.seconds(0); - /* falls through */ - case 'second': - this.milliseconds(0); - } - - // weeks are a special case - if (units === 'week') { - this.weekday(0); - } - if (units === 'isoWeek') { - this.isoWeekday(1); - } - - // quarters are also special - if (units === 'quarter') { - this.month(Math.floor(this.month() / 3) * 3); - } - - return this; - } - - function endOf (units) { - units = normalizeUnits(units); - if (units === undefined || units === 'millisecond') { - return this; - } - return this.startOf(units).add(1, (units === 'isoWeek' ? 'week' : units)).subtract(1, 'ms'); - } - - function to_type__valueOf () { - return +this._d - ((this._offset || 0) * 60000); - } - - function unix () { - return Math.floor(+this / 1000); - } - - function toDate () { - return this._offset ? new Date(+this) : this._d; - } - - function toArray () { - var m = this; - return [m.year(), m.month(), m.date(), m.hour(), m.minute(), m.second(), m.millisecond()]; - } - - function moment_valid__isValid () { - return valid__isValid(this); - } - - function parsingFlags () { - return extend({}, getParsingFlags(this)); - } - - function invalidAt () { - return getParsingFlags(this).overflow; - } - - addFormatToken(0, ['gg', 2], 0, function () { - return this.weekYear() % 100; - }); - - addFormatToken(0, ['GG', 2], 0, function () { - return this.isoWeekYear() % 100; - }); - - function addWeekYearFormatToken (token, getter) { - addFormatToken(0, [token, token.length], 0, getter); - } - - addWeekYearFormatToken('gggg', 'weekYear'); - addWeekYearFormatToken('ggggg', 'weekYear'); - addWeekYearFormatToken('GGGG', 'isoWeekYear'); - addWeekYearFormatToken('GGGGG', 'isoWeekYear'); - - // ALIASES - - addUnitAlias('weekYear', 'gg'); - addUnitAlias('isoWeekYear', 'GG'); - - // PARSING - - addRegexToken('G', matchSigned); - addRegexToken('g', matchSigned); - addRegexToken('GG', match1to2, match2); - addRegexToken('gg', match1to2, match2); - addRegexToken('GGGG', match1to4, match4); - addRegexToken('gggg', match1to4, match4); - addRegexToken('GGGGG', match1to6, match6); - addRegexToken('ggggg', match1to6, match6); - - addWeekParseToken(['gggg', 'ggggg', 'GGGG', 'GGGGG'], function (input, week, config, token) { - week[token.substr(0, 2)] = toInt(input); - }); - - addWeekParseToken(['gg', 'GG'], function (input, week, config, token) { - week[token] = utils_hooks__hooks.parseTwoDigitYear(input); - }); - - // HELPERS - - function weeksInYear(year, dow, doy) { - return weekOfYear(local__createLocal([year, 11, 31 + dow - doy]), dow, doy).week; - } - - // MOMENTS - - function getSetWeekYear (input) { - var year = weekOfYear(this, this.localeData()._week.dow, this.localeData()._week.doy).year; - return input == null ? year : this.add((input - year), 'y'); - } - - function getSetISOWeekYear (input) { - var year = weekOfYear(this, 1, 4).year; - return input == null ? year : this.add((input - year), 'y'); - } - - function getISOWeeksInYear () { - return weeksInYear(this.year(), 1, 4); - } - - function getWeeksInYear () { - var weekInfo = this.localeData()._week; - return weeksInYear(this.year(), weekInfo.dow, weekInfo.doy); - } - - addFormatToken('Q', 0, 0, 'quarter'); - - // ALIASES - - addUnitAlias('quarter', 'Q'); - - // PARSING - - addRegexToken('Q', match1); - addParseToken('Q', function (input, array) { - array[MONTH] = (toInt(input) - 1) * 3; - }); - - // MOMENTS - - function getSetQuarter (input) { - return input == null ? Math.ceil((this.month() + 1) / 3) : this.month((input - 1) * 3 + this.month() % 3); - } - - addFormatToken('D', ['DD', 2], 'Do', 'date'); - - // ALIASES - - addUnitAlias('date', 'D'); - - // PARSING - - addRegexToken('D', match1to2); - addRegexToken('DD', match1to2, match2); - addRegexToken('Do', function (isStrict, locale) { - return isStrict ? locale._ordinalParse : locale._ordinalParseLenient; - }); - - addParseToken(['D', 'DD'], DATE); - addParseToken('Do', function (input, array) { - array[DATE] = toInt(input.match(match1to2)[0], 10); - }); - - // MOMENTS - - var getSetDayOfMonth = makeGetSet('Date', true); - - addFormatToken('d', 0, 'do', 'day'); - - addFormatToken('dd', 0, 0, function (format) { - return this.localeData().weekdaysMin(this, format); - }); - - addFormatToken('ddd', 0, 0, function (format) { - return this.localeData().weekdaysShort(this, format); - }); - - addFormatToken('dddd', 0, 0, function (format) { - return this.localeData().weekdays(this, format); - }); - - addFormatToken('e', 0, 0, 'weekday'); - addFormatToken('E', 0, 0, 'isoWeekday'); - - // ALIASES - - addUnitAlias('day', 'd'); - addUnitAlias('weekday', 'e'); - addUnitAlias('isoWeekday', 'E'); - - // PARSING - - addRegexToken('d', match1to2); - addRegexToken('e', match1to2); - addRegexToken('E', match1to2); - addRegexToken('dd', matchWord); - addRegexToken('ddd', matchWord); - addRegexToken('dddd', matchWord); - - addWeekParseToken(['dd', 'ddd', 'dddd'], function (input, week, config) { - var weekday = config._locale.weekdaysParse(input); - // if we didn't get a weekday name, mark the date as invalid - if (weekday != null) { - week.d = weekday; - } else { - getParsingFlags(config).invalidWeekday = input; - } - }); - - addWeekParseToken(['d', 'e', 'E'], function (input, week, config, token) { - week[token] = toInt(input); - }); - - // HELPERS - - function parseWeekday(input, locale) { - if (typeof input === 'string') { - if (!isNaN(input)) { - input = parseInt(input, 10); - } - else { - input = locale.weekdaysParse(input); - if (typeof input !== 'number') { - return null; - } - } - } - return input; - } - - // LOCALES - - var defaultLocaleWeekdays = 'Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday'.split('_'); - function localeWeekdays (m) { - return this._weekdays[m.day()]; - } - - var defaultLocaleWeekdaysShort = 'Sun_Mon_Tue_Wed_Thu_Fri_Sat'.split('_'); - function localeWeekdaysShort (m) { - return this._weekdaysShort[m.day()]; - } - - var defaultLocaleWeekdaysMin = 'Su_Mo_Tu_We_Th_Fr_Sa'.split('_'); - function localeWeekdaysMin (m) { - return this._weekdaysMin[m.day()]; - } - - function localeWeekdaysParse (weekdayName) { - var i, mom, regex; - - if (!this._weekdaysParse) { - this._weekdaysParse = []; - } - - for (i = 0; i < 7; i++) { - // make the regex if we don't have it already - if (!this._weekdaysParse[i]) { - mom = local__createLocal([2000, 1]).day(i); - regex = '^' + this.weekdays(mom, '') + '|^' + this.weekdaysShort(mom, '') + '|^' + this.weekdaysMin(mom, ''); - this._weekdaysParse[i] = new RegExp(regex.replace('.', ''), 'i'); - } - // test the regex - if (this._weekdaysParse[i].test(weekdayName)) { - return i; - } - } - } - - // MOMENTS - - function getSetDayOfWeek (input) { - var day = this._isUTC ? this._d.getUTCDay() : this._d.getDay(); - if (input != null) { - input = parseWeekday(input, this.localeData()); - return this.add(input - day, 'd'); - } else { - return day; - } - } - - function getSetLocaleDayOfWeek (input) { - var weekday = (this.day() + 7 - this.localeData()._week.dow) % 7; - return input == null ? weekday : this.add(input - weekday, 'd'); - } - - function getSetISODayOfWeek (input) { - // behaves the same as moment#day except - // as a getter, returns 7 instead of 0 (1-7 range instead of 0-6) - // as a setter, sunday should belong to the previous week. - return input == null ? this.day() || 7 : this.day(this.day() % 7 ? input : input - 7); - } - - addFormatToken('H', ['HH', 2], 0, 'hour'); - addFormatToken('h', ['hh', 2], 0, function () { - return this.hours() % 12 || 12; - }); - - function meridiem (token, lowercase) { - addFormatToken(token, 0, 0, function () { - return this.localeData().meridiem(this.hours(), this.minutes(), lowercase); - }); - } - - meridiem('a', true); - meridiem('A', false); - - // ALIASES - - addUnitAlias('hour', 'h'); - - // PARSING - - function matchMeridiem (isStrict, locale) { - return locale._meridiemParse; - } - - addRegexToken('a', matchMeridiem); - addRegexToken('A', matchMeridiem); - addRegexToken('H', match1to2); - addRegexToken('h', match1to2); - addRegexToken('HH', match1to2, match2); - addRegexToken('hh', match1to2, match2); - - addParseToken(['H', 'HH'], HOUR); - addParseToken(['a', 'A'], function (input, array, config) { - config._isPm = config._locale.isPM(input); - config._meridiem = input; - }); - addParseToken(['h', 'hh'], function (input, array, config) { - array[HOUR] = toInt(input); - getParsingFlags(config).bigHour = true; - }); - - // LOCALES - - function localeIsPM (input) { - // IE8 Quirks Mode & IE7 Standards Mode do not allow accessing strings like arrays - // Using charAt should be more compatible. - return ((input + '').toLowerCase().charAt(0) === 'p'); - } - - var defaultLocaleMeridiemParse = /[ap]\.?m?\.?/i; - function localeMeridiem (hours, minutes, isLower) { - if (hours > 11) { - return isLower ? 'pm' : 'PM'; - } else { - return isLower ? 'am' : 'AM'; - } - } - - - // MOMENTS - - // Setting the hour should keep the time, because the user explicitly - // specified which hour he wants. So trying to maintain the same hour (in - // a new timezone) makes sense. Adding/subtracting hours does not follow - // this rule. - var getSetHour = makeGetSet('Hours', true); - - addFormatToken('m', ['mm', 2], 0, 'minute'); - - // ALIASES - - addUnitAlias('minute', 'm'); - - // PARSING - - addRegexToken('m', match1to2); - addRegexToken('mm', match1to2, match2); - addParseToken(['m', 'mm'], MINUTE); - - // MOMENTS - - var getSetMinute = makeGetSet('Minutes', false); - - addFormatToken('s', ['ss', 2], 0, 'second'); - - // ALIASES - - addUnitAlias('second', 's'); - - // PARSING - - addRegexToken('s', match1to2); - addRegexToken('ss', match1to2, match2); - addParseToken(['s', 'ss'], SECOND); - - // MOMENTS - - var getSetSecond = makeGetSet('Seconds', false); - - addFormatToken('S', 0, 0, function () { - return ~~(this.millisecond() / 100); - }); - - addFormatToken(0, ['SS', 2], 0, function () { - return ~~(this.millisecond() / 10); - }); - - function millisecond__milliseconds (token) { - addFormatToken(0, [token, 3], 0, 'millisecond'); - } - - millisecond__milliseconds('SSS'); - millisecond__milliseconds('SSSS'); - - // ALIASES - - addUnitAlias('millisecond', 'ms'); - - // PARSING - - addRegexToken('S', match1to3, match1); - addRegexToken('SS', match1to3, match2); - addRegexToken('SSS', match1to3, match3); - addRegexToken('SSSS', matchUnsigned); - addParseToken(['S', 'SS', 'SSS', 'SSSS'], function (input, array) { - array[MILLISECOND] = toInt(('0.' + input) * 1000); - }); - - // MOMENTS - - var getSetMillisecond = makeGetSet('Milliseconds', false); - - addFormatToken('z', 0, 0, 'zoneAbbr'); - addFormatToken('zz', 0, 0, 'zoneName'); - - // MOMENTS - - function getZoneAbbr () { - return this._isUTC ? 'UTC' : ''; - } - - function getZoneName () { - return this._isUTC ? 'Coordinated Universal Time' : ''; - } - - var momentPrototype__proto = Moment.prototype; - - momentPrototype__proto.add = add_subtract__add; - momentPrototype__proto.calendar = moment_calendar__calendar; - momentPrototype__proto.clone = clone; - momentPrototype__proto.diff = diff; - momentPrototype__proto.endOf = endOf; - momentPrototype__proto.format = format; - momentPrototype__proto.from = from; - momentPrototype__proto.fromNow = fromNow; - momentPrototype__proto.to = to; - momentPrototype__proto.toNow = toNow; - momentPrototype__proto.get = getSet; - momentPrototype__proto.invalidAt = invalidAt; - momentPrototype__proto.isAfter = isAfter; - momentPrototype__proto.isBefore = isBefore; - momentPrototype__proto.isBetween = isBetween; - momentPrototype__proto.isSame = isSame; - momentPrototype__proto.isValid = moment_valid__isValid; - momentPrototype__proto.lang = lang; - momentPrototype__proto.locale = locale; - momentPrototype__proto.localeData = localeData; - momentPrototype__proto.max = prototypeMax; - momentPrototype__proto.min = prototypeMin; - momentPrototype__proto.parsingFlags = parsingFlags; - momentPrototype__proto.set = getSet; - momentPrototype__proto.startOf = startOf; - momentPrototype__proto.subtract = add_subtract__subtract; - momentPrototype__proto.toArray = toArray; - momentPrototype__proto.toDate = toDate; - momentPrototype__proto.toISOString = moment_format__toISOString; - momentPrototype__proto.toJSON = moment_format__toISOString; - momentPrototype__proto.toString = toString; - momentPrototype__proto.unix = unix; - momentPrototype__proto.valueOf = to_type__valueOf; - - // Year - momentPrototype__proto.year = getSetYear; - momentPrototype__proto.isLeapYear = getIsLeapYear; - - // Week Year - momentPrototype__proto.weekYear = getSetWeekYear; - momentPrototype__proto.isoWeekYear = getSetISOWeekYear; - - // Quarter - momentPrototype__proto.quarter = momentPrototype__proto.quarters = getSetQuarter; - - // Month - momentPrototype__proto.month = getSetMonth; - momentPrototype__proto.daysInMonth = getDaysInMonth; - - // Week - momentPrototype__proto.week = momentPrototype__proto.weeks = getSetWeek; - momentPrototype__proto.isoWeek = momentPrototype__proto.isoWeeks = getSetISOWeek; - momentPrototype__proto.weeksInYear = getWeeksInYear; - momentPrototype__proto.isoWeeksInYear = getISOWeeksInYear; - - // Day - momentPrototype__proto.date = getSetDayOfMonth; - momentPrototype__proto.day = momentPrototype__proto.days = getSetDayOfWeek; - momentPrototype__proto.weekday = getSetLocaleDayOfWeek; - momentPrototype__proto.isoWeekday = getSetISODayOfWeek; - momentPrototype__proto.dayOfYear = getSetDayOfYear; - - // Hour - momentPrototype__proto.hour = momentPrototype__proto.hours = getSetHour; - - // Minute - momentPrototype__proto.minute = momentPrototype__proto.minutes = getSetMinute; - - // Second - momentPrototype__proto.second = momentPrototype__proto.seconds = getSetSecond; - - // Millisecond - momentPrototype__proto.millisecond = momentPrototype__proto.milliseconds = getSetMillisecond; - - // Offset - momentPrototype__proto.utcOffset = getSetOffset; - momentPrototype__proto.utc = setOffsetToUTC; - momentPrototype__proto.local = setOffsetToLocal; - momentPrototype__proto.parseZone = setOffsetToParsedOffset; - momentPrototype__proto.hasAlignedHourOffset = hasAlignedHourOffset; - momentPrototype__proto.isDST = isDaylightSavingTime; - momentPrototype__proto.isDSTShifted = isDaylightSavingTimeShifted; - momentPrototype__proto.isLocal = isLocal; - momentPrototype__proto.isUtcOffset = isUtcOffset; - momentPrototype__proto.isUtc = isUtc; - momentPrototype__proto.isUTC = isUtc; - - // Timezone - momentPrototype__proto.zoneAbbr = getZoneAbbr; - momentPrototype__proto.zoneName = getZoneName; - - // Deprecations - momentPrototype__proto.dates = deprecate('dates accessor is deprecated. Use date instead.', getSetDayOfMonth); - momentPrototype__proto.months = deprecate('months accessor is deprecated. Use month instead', getSetMonth); - momentPrototype__proto.years = deprecate('years accessor is deprecated. Use year instead', getSetYear); - momentPrototype__proto.zone = deprecate('moment().zone is deprecated, use moment().utcOffset instead. https://github.com/moment/moment/issues/1779', getSetZone); - - var momentPrototype = momentPrototype__proto; - - function moment__createUnix (input) { - return local__createLocal(input * 1000); - } - - function moment__createInZone () { - return local__createLocal.apply(null, arguments).parseZone(); - } - - var defaultCalendar = { - sameDay : '[Today at] LT', - nextDay : '[Tomorrow at] LT', - nextWeek : 'dddd [at] LT', - lastDay : '[Yesterday at] LT', - lastWeek : '[Last] dddd [at] LT', - sameElse : 'L' - }; - - function locale_calendar__calendar (key, mom, now) { - var output = this._calendar[key]; - return typeof output === 'function' ? output.call(mom, now) : output; - } - - var defaultLongDateFormat = { - LTS : 'h:mm:ss A', - LT : 'h:mm A', - L : 'MM/DD/YYYY', - LL : 'MMMM D, YYYY', - LLL : 'MMMM D, YYYY LT', - LLLL : 'dddd, MMMM D, YYYY LT' - }; - - function longDateFormat (key) { - var output = this._longDateFormat[key]; - if (!output && this._longDateFormat[key.toUpperCase()]) { - output = this._longDateFormat[key.toUpperCase()].replace(/MMMM|MM|DD|dddd/g, function (val) { - return val.slice(1); - }); - this._longDateFormat[key] = output; - } - return output; - } - - var defaultInvalidDate = 'Invalid date'; - - function invalidDate () { - return this._invalidDate; - } - - var defaultOrdinal = '%d'; - var defaultOrdinalParse = /\d{1,2}/; - - function ordinal (number) { - return this._ordinal.replace('%d', number); - } - - function preParsePostFormat (string) { - return string; - } - - var defaultRelativeTime = { - future : 'in %s', - past : '%s ago', - s : 'a few seconds', - m : 'a minute', - mm : '%d minutes', - h : 'an hour', - hh : '%d hours', - d : 'a day', - dd : '%d days', - M : 'a month', - MM : '%d months', - y : 'a year', - yy : '%d years' - }; - - function relative__relativeTime (number, withoutSuffix, string, isFuture) { - var output = this._relativeTime[string]; - return (typeof output === 'function') ? - output(number, withoutSuffix, string, isFuture) : - output.replace(/%d/i, number); - } - - function pastFuture (diff, output) { - var format = this._relativeTime[diff > 0 ? 'future' : 'past']; - return typeof format === 'function' ? format(output) : format.replace(/%s/i, output); - } - - function locale_set__set (config) { - var prop, i; - for (i in config) { - prop = config[i]; - if (typeof prop === 'function') { - this[i] = prop; - } else { - this['_' + i] = prop; - } - } - // Lenient ordinal parsing accepts just a number in addition to - // number + (possibly) stuff coming from _ordinalParseLenient. - this._ordinalParseLenient = new RegExp(this._ordinalParse.source + '|' + (/\d{1,2}/).source); - } - - var prototype__proto = Locale.prototype; - - prototype__proto._calendar = defaultCalendar; - prototype__proto.calendar = locale_calendar__calendar; - prototype__proto._longDateFormat = defaultLongDateFormat; - prototype__proto.longDateFormat = longDateFormat; - prototype__proto._invalidDate = defaultInvalidDate; - prototype__proto.invalidDate = invalidDate; - prototype__proto._ordinal = defaultOrdinal; - prototype__proto.ordinal = ordinal; - prototype__proto._ordinalParse = defaultOrdinalParse; - prototype__proto.preparse = preParsePostFormat; - prototype__proto.postformat = preParsePostFormat; - prototype__proto._relativeTime = defaultRelativeTime; - prototype__proto.relativeTime = relative__relativeTime; - prototype__proto.pastFuture = pastFuture; - prototype__proto.set = locale_set__set; - - // Month - prototype__proto.months = localeMonths; - prototype__proto._months = defaultLocaleMonths; - prototype__proto.monthsShort = localeMonthsShort; - prototype__proto._monthsShort = defaultLocaleMonthsShort; - prototype__proto.monthsParse = localeMonthsParse; - - // Week - prototype__proto.week = localeWeek; - prototype__proto._week = defaultLocaleWeek; - prototype__proto.firstDayOfYear = localeFirstDayOfYear; - prototype__proto.firstDayOfWeek = localeFirstDayOfWeek; - - // Day of Week - prototype__proto.weekdays = localeWeekdays; - prototype__proto._weekdays = defaultLocaleWeekdays; - prototype__proto.weekdaysMin = localeWeekdaysMin; - prototype__proto._weekdaysMin = defaultLocaleWeekdaysMin; - prototype__proto.weekdaysShort = localeWeekdaysShort; - prototype__proto._weekdaysShort = defaultLocaleWeekdaysShort; - prototype__proto.weekdaysParse = localeWeekdaysParse; - - // Hours - prototype__proto.isPM = localeIsPM; - prototype__proto._meridiemParse = defaultLocaleMeridiemParse; - prototype__proto.meridiem = localeMeridiem; - - function lists__get (format, index, field, setter) { - var locale = locale_locales__getLocale(); - var utc = create_utc__createUTC().set(setter, index); - return locale[field](utc, format); - } - - function list (format, index, field, count, setter) { - if (typeof format === 'number') { - index = format; - format = undefined; - } - - format = format || ''; - - if (index != null) { - return lists__get(format, index, field, setter); - } - - var i; - var out = []; - for (i = 0; i < count; i++) { - out[i] = lists__get(format, i, field, setter); - } - return out; - } - - function lists__listMonths (format, index) { - return list(format, index, 'months', 12, 'month'); - } - - function lists__listMonthsShort (format, index) { - return list(format, index, 'monthsShort', 12, 'month'); - } - - function lists__listWeekdays (format, index) { - return list(format, index, 'weekdays', 7, 'day'); - } - - function lists__listWeekdaysShort (format, index) { - return list(format, index, 'weekdaysShort', 7, 'day'); - } - - function lists__listWeekdaysMin (format, index) { - return list(format, index, 'weekdaysMin', 7, 'day'); - } - - locale_locales__getSetGlobalLocale('en', { - ordinalParse: /\d{1,2}(th|st|nd|rd)/, - ordinal : function (number) { - var b = number % 10, - output = (toInt(number % 100 / 10) === 1) ? 'th' : - (b === 1) ? 'st' : - (b === 2) ? 'nd' : - (b === 3) ? 'rd' : 'th'; - return number + output; - } - }); - - // Side effect imports - utils_hooks__hooks.lang = deprecate('moment.lang is deprecated. Use moment.locale instead.', locale_locales__getSetGlobalLocale); - utils_hooks__hooks.langData = deprecate('moment.langData is deprecated. Use moment.localeData instead.', locale_locales__getLocale); - - var mathAbs = Math.abs; - - function duration_abs__abs () { - var data = this._data; - - this._milliseconds = mathAbs(this._milliseconds); - this._days = mathAbs(this._days); - this._months = mathAbs(this._months); - - data.milliseconds = mathAbs(data.milliseconds); - data.seconds = mathAbs(data.seconds); - data.minutes = mathAbs(data.minutes); - data.hours = mathAbs(data.hours); - data.months = mathAbs(data.months); - data.years = mathAbs(data.years); - - return this; - } - - function duration_add_subtract__addSubtract (duration, input, value, direction) { - var other = create__createDuration(input, value); - - duration._milliseconds += direction * other._milliseconds; - duration._days += direction * other._days; - duration._months += direction * other._months; - - return duration._bubble(); - } - - // supports only 2.0-style add(1, 's') or add(duration) - function duration_add_subtract__add (input, value) { - return duration_add_subtract__addSubtract(this, input, value, 1); - } - - // supports only 2.0-style subtract(1, 's') or subtract(duration) - function duration_add_subtract__subtract (input, value) { - return duration_add_subtract__addSubtract(this, input, value, -1); - } - - function bubble () { - var milliseconds = this._milliseconds; - var days = this._days; - var months = this._months; - var data = this._data; - var seconds, minutes, hours, years = 0; - - // The following code bubbles up values, see the tests for - // examples of what that means. - data.milliseconds = milliseconds % 1000; - - seconds = absFloor(milliseconds / 1000); - data.seconds = seconds % 60; - - minutes = absFloor(seconds / 60); - data.minutes = minutes % 60; - - hours = absFloor(minutes / 60); - data.hours = hours % 24; - - days += absFloor(hours / 24); - - // Accurately convert days to years, assume start from year 0. - years = absFloor(daysToYears(days)); - days -= absFloor(yearsToDays(years)); - - // 30 days to a month - // TODO (iskren): Use anchor date (like 1st Jan) to compute this. - months += absFloor(days / 30); - days %= 30; - - // 12 months -> 1 year - years += absFloor(months / 12); - months %= 12; - - data.days = days; - data.months = months; - data.years = years; - - return this; - } - - function daysToYears (days) { - // 400 years have 146097 days (taking into account leap year rules) - return days * 400 / 146097; - } - - function yearsToDays (years) { - // years * 365 + absFloor(years / 4) - - // absFloor(years / 100) + absFloor(years / 400); - return years * 146097 / 400; - } - - function as (units) { - var days; - var months; - var milliseconds = this._milliseconds; - - units = normalizeUnits(units); - - if (units === 'month' || units === 'year') { - days = this._days + milliseconds / 864e5; - months = this._months + daysToYears(days) * 12; - return units === 'month' ? months : months / 12; - } else { - // handle milliseconds separately because of floating point math errors (issue #1867) - days = this._days + Math.round(yearsToDays(this._months / 12)); - switch (units) { - case 'week' : return days / 7 + milliseconds / 6048e5; - case 'day' : return days + milliseconds / 864e5; - case 'hour' : return days * 24 + milliseconds / 36e5; - case 'minute' : return days * 1440 + milliseconds / 6e4; - case 'second' : return days * 86400 + milliseconds / 1000; - // Math.floor prevents floating point math errors here - case 'millisecond': return Math.floor(days * 864e5) + milliseconds; - default: throw new Error('Unknown unit ' + units); - } - } - } - - // TODO: Use this.as('ms')? - function duration_as__valueOf () { - return ( - this._milliseconds + - this._days * 864e5 + - (this._months % 12) * 2592e6 + - toInt(this._months / 12) * 31536e6 - ); - } - - function makeAs (alias) { - return function () { - return this.as(alias); - }; - } - - var asMilliseconds = makeAs('ms'); - var asSeconds = makeAs('s'); - var asMinutes = makeAs('m'); - var asHours = makeAs('h'); - var asDays = makeAs('d'); - var asWeeks = makeAs('w'); - var asMonths = makeAs('M'); - var asYears = makeAs('y'); - - function duration_get__get (units) { - units = normalizeUnits(units); - return this[units + 's'](); - } - - function makeGetter(name) { - return function () { - return this._data[name]; - }; - } - - var duration_get__milliseconds = makeGetter('milliseconds'); - var seconds = makeGetter('seconds'); - var minutes = makeGetter('minutes'); - var hours = makeGetter('hours'); - var days = makeGetter('days'); - var months = makeGetter('months'); - var years = makeGetter('years'); - - function weeks () { - return absFloor(this.days() / 7); - } - - var round = Math.round; - var thresholds = { - s: 45, // seconds to minute - m: 45, // minutes to hour - h: 22, // hours to day - d: 26, // days to month - M: 11 // months to year - }; - - // helper function for moment.fn.from, moment.fn.fromNow, and moment.duration.fn.humanize - function substituteTimeAgo(string, number, withoutSuffix, isFuture, locale) { - return locale.relativeTime(number || 1, !!withoutSuffix, string, isFuture); - } - - function duration_humanize__relativeTime (posNegDuration, withoutSuffix, locale) { - var duration = create__createDuration(posNegDuration).abs(); - var seconds = round(duration.as('s')); - var minutes = round(duration.as('m')); - var hours = round(duration.as('h')); - var days = round(duration.as('d')); - var months = round(duration.as('M')); - var years = round(duration.as('y')); - - var a = seconds < thresholds.s && ['s', seconds] || - minutes === 1 && ['m'] || - minutes < thresholds.m && ['mm', minutes] || - hours === 1 && ['h'] || - hours < thresholds.h && ['hh', hours] || - days === 1 && ['d'] || - days < thresholds.d && ['dd', days] || - months === 1 && ['M'] || - months < thresholds.M && ['MM', months] || - years === 1 && ['y'] || ['yy', years]; - - a[2] = withoutSuffix; - a[3] = +posNegDuration > 0; - a[4] = locale; - return substituteTimeAgo.apply(null, a); - } - - // This function allows you to set a threshold for relative time strings - function duration_humanize__getSetRelativeTimeThreshold (threshold, limit) { - if (thresholds[threshold] === undefined) { - return false; - } - if (limit === undefined) { - return thresholds[threshold]; - } - thresholds[threshold] = limit; - return true; - } - - function humanize (withSuffix) { - var locale = this.localeData(); - var output = duration_humanize__relativeTime(this, !withSuffix, locale); - - if (withSuffix) { - output = locale.pastFuture(+this, output); - } - - return locale.postformat(output); - } - - var iso_string__abs = Math.abs; - - function iso_string__toISOString() { - // inspired by https://github.com/dordille/moment-isoduration/blob/master/moment.isoduration.js - var Y = iso_string__abs(this.years()); - var M = iso_string__abs(this.months()); - var D = iso_string__abs(this.days()); - var h = iso_string__abs(this.hours()); - var m = iso_string__abs(this.minutes()); - var s = iso_string__abs(this.seconds() + this.milliseconds() / 1000); - var total = this.asSeconds(); - - if (!total) { - // this is the same as C#'s (Noda) and python (isodate)... - // but not other JS (goog.date) - return 'P0D'; - } - - return (total < 0 ? '-' : '') + - 'P' + - (Y ? Y + 'Y' : '') + - (M ? M + 'M' : '') + - (D ? D + 'D' : '') + - ((h || m || s) ? 'T' : '') + - (h ? h + 'H' : '') + - (m ? m + 'M' : '') + - (s ? s + 'S' : ''); - } - - var duration_prototype__proto = Duration.prototype; - - duration_prototype__proto.abs = duration_abs__abs; - duration_prototype__proto.add = duration_add_subtract__add; - duration_prototype__proto.subtract = duration_add_subtract__subtract; - duration_prototype__proto.as = as; - duration_prototype__proto.asMilliseconds = asMilliseconds; - duration_prototype__proto.asSeconds = asSeconds; - duration_prototype__proto.asMinutes = asMinutes; - duration_prototype__proto.asHours = asHours; - duration_prototype__proto.asDays = asDays; - duration_prototype__proto.asWeeks = asWeeks; - duration_prototype__proto.asMonths = asMonths; - duration_prototype__proto.asYears = asYears; - duration_prototype__proto.valueOf = duration_as__valueOf; - duration_prototype__proto._bubble = bubble; - duration_prototype__proto.get = duration_get__get; - duration_prototype__proto.milliseconds = duration_get__milliseconds; - duration_prototype__proto.seconds = seconds; - duration_prototype__proto.minutes = minutes; - duration_prototype__proto.hours = hours; - duration_prototype__proto.days = days; - duration_prototype__proto.weeks = weeks; - duration_prototype__proto.months = months; - duration_prototype__proto.years = years; - duration_prototype__proto.humanize = humanize; - duration_prototype__proto.toISOString = iso_string__toISOString; - duration_prototype__proto.toString = iso_string__toISOString; - duration_prototype__proto.toJSON = iso_string__toISOString; - duration_prototype__proto.locale = locale; - duration_prototype__proto.localeData = localeData; - - // Deprecations - duration_prototype__proto.toIsoString = deprecate('toIsoString() is deprecated. Please use toISOString() instead (notice the capitals)', iso_string__toISOString); - duration_prototype__proto.lang = lang; - - // Side effect imports - - addFormatToken('X', 0, 0, 'unix'); - addFormatToken('x', 0, 0, 'valueOf'); - - // PARSING - - addRegexToken('x', matchSigned); - addRegexToken('X', matchTimestamp); - addParseToken('X', function (input, array, config) { - config._d = new Date(parseFloat(input, 10) * 1000); - }); - addParseToken('x', function (input, array, config) { - config._d = new Date(toInt(input)); - }); - - // Side effect imports - - - utils_hooks__hooks.version = '2.10.3'; - - setHookCallback(local__createLocal); - - utils_hooks__hooks.fn = momentPrototype; - utils_hooks__hooks.min = min; - utils_hooks__hooks.max = max; - utils_hooks__hooks.utc = create_utc__createUTC; - utils_hooks__hooks.unix = moment__createUnix; - utils_hooks__hooks.months = lists__listMonths; - utils_hooks__hooks.isDate = isDate; - utils_hooks__hooks.locale = locale_locales__getSetGlobalLocale; - utils_hooks__hooks.invalid = valid__createInvalid; - utils_hooks__hooks.duration = create__createDuration; - utils_hooks__hooks.isMoment = isMoment; - utils_hooks__hooks.weekdays = lists__listWeekdays; - utils_hooks__hooks.parseZone = moment__createInZone; - utils_hooks__hooks.localeData = locale_locales__getLocale; - utils_hooks__hooks.isDuration = isDuration; - utils_hooks__hooks.monthsShort = lists__listMonthsShort; - utils_hooks__hooks.weekdaysMin = lists__listWeekdaysMin; - utils_hooks__hooks.defineLocale = defineLocale; - utils_hooks__hooks.weekdaysShort = lists__listWeekdaysShort; - utils_hooks__hooks.normalizeUnits = normalizeUnits; - utils_hooks__hooks.relativeTimeThreshold = duration_humanize__getSetRelativeTimeThreshold; - - var _moment = utils_hooks__hooks; - - return _moment; - -})); \ No newline at end of file diff --git a/src/UI/JsLibraries/typeahead.js b/src/UI/JsLibraries/typeahead.js deleted file mode 100644 index 450a6ca43..000000000 --- a/src/UI/JsLibraries/typeahead.js +++ /dev/null @@ -1,1716 +0,0 @@ -/*! - * typeahead.js 0.10.2 - * https://github.com/twitter/typeahead.js - * Copyright 2013-2014 Twitter, Inc. and other contributors; Licensed MIT - */ - -(function($) { - var _ = { - isMsie: function() { - return /(msie|trident)/i.test(navigator.userAgent) ? navigator.userAgent.match(/(msie |rv:)(\d+(.\d+)?)/i)[2] : false; - }, - isBlankString: function(str) { - return !str || /^\s*$/.test(str); - }, - escapeRegExChars: function(str) { - return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); - }, - isString: function(obj) { - return typeof obj === "string"; - }, - isNumber: function(obj) { - return typeof obj === "number"; - }, - isArray: $.isArray, - isFunction: $.isFunction, - isObject: $.isPlainObject, - isUndefined: function(obj) { - return typeof obj === "undefined"; - }, - bind: $.proxy, - each: function(collection, cb) { - $.each(collection, reverseArgs); - function reverseArgs(index, value) { - return cb(value, index); - } - }, - map: $.map, - filter: $.grep, - every: function(obj, test) { - var result = true; - if (!obj) { - return result; - } - $.each(obj, function(key, val) { - if (!(result = test.call(null, val, key, obj))) { - return false; - } - }); - return !!result; - }, - some: function(obj, test) { - var result = false; - if (!obj) { - return result; - } - $.each(obj, function(key, val) { - if (result = test.call(null, val, key, obj)) { - return false; - } - }); - return !!result; - }, - mixin: $.extend, - getUniqueId: function() { - var counter = 0; - return function() { - return counter++; - }; - }(), - templatify: function templatify(obj) { - return $.isFunction(obj) ? obj : template; - function template() { - return String(obj); - } - }, - defer: function(fn) { - setTimeout(fn, 0); - }, - debounce: function(func, wait, immediate) { - var timeout, result; - return function() { - var context = this, args = arguments, later, callNow; - later = function() { - timeout = null; - if (!immediate) { - result = func.apply(context, args); - } - }; - callNow = immediate && !timeout; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - if (callNow) { - result = func.apply(context, args); - } - return result; - }; - }, - throttle: function(func, wait) { - var context, args, timeout, result, previous, later; - previous = 0; - later = function() { - previous = new Date(); - timeout = null; - result = func.apply(context, args); - }; - return function() { - var now = new Date(), remaining = wait - (now - previous); - context = this; - args = arguments; - if (remaining <= 0) { - clearTimeout(timeout); - timeout = null; - previous = now; - result = func.apply(context, args); - } else if (!timeout) { - timeout = setTimeout(later, remaining); - } - return result; - }; - }, - noop: function() {} - }; - var VERSION = "0.10.2"; - var tokenizers = function(root) { - return { - nonword: nonword, - whitespace: whitespace, - obj: { - nonword: getObjTokenizer(nonword), - whitespace: getObjTokenizer(whitespace) - } - }; - function whitespace(s) { - return s.split(/\s+/); - } - function nonword(s) { - return s.split(/\W+/); - } - function getObjTokenizer(tokenizer) { - return function setKey(key) { - return function tokenize(o) { - return tokenizer(o[key]); - }; - }; - } - }(); - var LruCache = function() { - function LruCache(maxSize) { - this.maxSize = maxSize || 100; - this.size = 0; - this.hash = {}; - this.list = new List(); - } - _.mixin(LruCache.prototype, { - set: function set(key, val) { - var tailItem = this.list.tail, node; - if (this.size >= this.maxSize) { - this.list.remove(tailItem); - delete this.hash[tailItem.key]; - } - if (node = this.hash[key]) { - node.val = val; - this.list.moveToFront(node); - } else { - node = new Node(key, val); - this.list.add(node); - this.hash[key] = node; - this.size++; - } - }, - get: function get(key) { - var node = this.hash[key]; - if (node) { - this.list.moveToFront(node); - return node.val; - } - } - }); - function List() { - this.head = this.tail = null; - } - _.mixin(List.prototype, { - add: function add(node) { - if (this.head) { - node.next = this.head; - this.head.prev = node; - } - this.head = node; - this.tail = this.tail || node; - }, - remove: function remove(node) { - node.prev ? node.prev.next = node.next : this.head = node.next; - node.next ? node.next.prev = node.prev : this.tail = node.prev; - }, - moveToFront: function(node) { - this.remove(node); - this.add(node); - } - }); - function Node(key, val) { - this.key = key; - this.val = val; - this.prev = this.next = null; - } - return LruCache; - }(); - var PersistentStorage = function() { - var ls, methods; - try { - ls = window.localStorage; - ls.setItem("~~~", "!"); - ls.removeItem("~~~"); - } catch (err) { - ls = null; - } - function PersistentStorage(namespace) { - this.prefix = [ "__", namespace, "__" ].join(""); - this.ttlKey = "__ttl__"; - this.keyMatcher = new RegExp("^" + this.prefix); - } - if (ls && window.JSON) { - methods = { - _prefix: function(key) { - return this.prefix + key; - }, - _ttlKey: function(key) { - return this._prefix(key) + this.ttlKey; - }, - get: function(key) { - if (this.isExpired(key)) { - this.remove(key); - } - return decode(ls.getItem(this._prefix(key))); - }, - set: function(key, val, ttl) { - if (_.isNumber(ttl)) { - ls.setItem(this._ttlKey(key), encode(now() + ttl)); - } else { - ls.removeItem(this._ttlKey(key)); - } - return ls.setItem(this._prefix(key), encode(val)); - }, - remove: function(key) { - ls.removeItem(this._ttlKey(key)); - ls.removeItem(this._prefix(key)); - return this; - }, - clear: function() { - var i, key, keys = [], len = ls.length; - for (i = 0; i < len; i++) { - if ((key = ls.key(i)).match(this.keyMatcher)) { - keys.push(key.replace(this.keyMatcher, "")); - } - } - for (i = keys.length; i--; ) { - this.remove(keys[i]); - } - return this; - }, - isExpired: function(key) { - var ttl = decode(ls.getItem(this._ttlKey(key))); - return _.isNumber(ttl) && now() > ttl ? true : false; - } - }; - } else { - methods = { - get: _.noop, - set: _.noop, - remove: _.noop, - clear: _.noop, - isExpired: _.noop - }; - } - _.mixin(PersistentStorage.prototype, methods); - return PersistentStorage; - function now() { - return new Date().getTime(); - } - function encode(val) { - return JSON.stringify(_.isUndefined(val) ? null : val); - } - function decode(val) { - return JSON.parse(val); - } - }(); - var Transport = function() { - var pendingRequestsCount = 0, pendingRequests = {}, maxPendingRequests = 6, requestCache = new LruCache(10); - function Transport(o) { - o = o || {}; - this._send = o.transport ? callbackToDeferred(o.transport) : $.ajax; - this._get = o.rateLimiter ? o.rateLimiter(this._get) : this._get; - } - Transport.setMaxPendingRequests = function setMaxPendingRequests(num) { - maxPendingRequests = num; - }; - Transport.resetCache = function clearCache() { - requestCache = new LruCache(10); - }; - _.mixin(Transport.prototype, { - _get: function(url, o, cb) { - var that = this, jqXhr; - if (jqXhr = pendingRequests[url]) { - jqXhr.done(done).fail(fail); - } else if (pendingRequestsCount < maxPendingRequests) { - pendingRequestsCount++; - pendingRequests[url] = this._send(url, o).done(done).fail(fail).always(always); - } else { - this.onDeckRequestArgs = [].slice.call(arguments, 0); - } - function done(resp) { - cb && cb(null, resp); - requestCache.set(url, resp); - } - function fail() { - cb && cb(true); - } - function always() { - pendingRequestsCount--; - delete pendingRequests[url]; - if (that.onDeckRequestArgs) { - that._get.apply(that, that.onDeckRequestArgs); - that.onDeckRequestArgs = null; - } - } - }, - get: function(url, o, cb) { - var resp; - if (_.isFunction(o)) { - cb = o; - o = {}; - } - if (resp = requestCache.get(url)) { - _.defer(function() { - cb && cb(null, resp); - }); - } else { - this._get(url, o, cb); - } - return !!resp; - } - }); - return Transport; - function callbackToDeferred(fn) { - return function customSendWrapper(url, o) { - var deferred = $.Deferred(); - fn(url, o, onSuccess, onError); - return deferred; - function onSuccess(resp) { - _.defer(function() { - deferred.resolve(resp); - }); - } - function onError(err) { - _.defer(function() { - deferred.reject(err); - }); - } - }; - } - }(); - var SearchIndex = function() { - function SearchIndex(o) { - o = o || {}; - if (!o.datumTokenizer || !o.queryTokenizer) { - $.error("datumTokenizer and queryTokenizer are both required"); - } - this.datumTokenizer = o.datumTokenizer; - this.queryTokenizer = o.queryTokenizer; - this.reset(); - } - _.mixin(SearchIndex.prototype, { - bootstrap: function bootstrap(o) { - this.datums = o.datums; - this.trie = o.trie; - }, - add: function(data) { - var that = this; - data = _.isArray(data) ? data : [ data ]; - _.each(data, function(datum) { - var id, tokens; - id = that.datums.push(datum) - 1; - tokens = normalizeTokens(that.datumTokenizer(datum)); - _.each(tokens, function(token) { - var node, chars, ch; - node = that.trie; - chars = token.split(""); - while (ch = chars.shift()) { - node = node.children[ch] || (node.children[ch] = newNode()); - node.ids.push(id); - } - }); - }); - }, - get: function get(query) { - var that = this, tokens, matches; - tokens = normalizeTokens(this.queryTokenizer(query)); - _.each(tokens, function(token) { - var node, chars, ch, ids; - if (matches && matches.length === 0) { - return false; - } - node = that.trie; - chars = token.split(""); - while (node && (ch = chars.shift())) { - node = node.children[ch]; - } - if (node && chars.length === 0) { - ids = node.ids.slice(0); - matches = matches ? getIntersection(matches, ids) : ids; - } else { - matches = []; - return false; - } - }); - return matches ? _.map(unique(matches), function(id) { - return that.datums[id]; - }) : []; - }, - reset: function reset() { - this.datums = []; - this.trie = newNode(); - }, - serialize: function serialize() { - return { - datums: this.datums, - trie: this.trie - }; - } - }); - return SearchIndex; - function normalizeTokens(tokens) { - tokens = _.filter(tokens, function(token) { - return !!token; - }); - tokens = _.map(tokens, function(token) { - return token.toLowerCase(); - }); - return tokens; - } - function newNode() { - return { - ids: [], - children: {} - }; - } - function unique(array) { - var seen = {}, uniques = []; - for (var i = 0; i < array.length; i++) { - if (!seen[array[i]]) { - seen[array[i]] = true; - uniques.push(array[i]); - } - } - return uniques; - } - function getIntersection(arrayA, arrayB) { - var ai = 0, bi = 0, intersection = []; - arrayA = arrayA.sort(compare); - arrayB = arrayB.sort(compare); - while (ai < arrayA.length && bi < arrayB.length) { - if (arrayA[ai] < arrayB[bi]) { - ai++; - } else if (arrayA[ai] > arrayB[bi]) { - bi++; - } else { - intersection.push(arrayA[ai]); - ai++; - bi++; - } - } - return intersection; - function compare(a, b) { - return a - b; - } - } - }(); - var oParser = function() { - return { - local: getLocal, - prefetch: getPrefetch, - remote: getRemote - }; - function getLocal(o) { - return o.local || null; - } - function getPrefetch(o) { - var prefetch, defaults; - defaults = { - url: null, - thumbprint: "", - ttl: 24 * 60 * 60 * 1e3, - filter: null, - ajax: {} - }; - if (prefetch = o.prefetch || null) { - prefetch = _.isString(prefetch) ? { - url: prefetch - } : prefetch; - prefetch = _.mixin(defaults, prefetch); - prefetch.thumbprint = VERSION + prefetch.thumbprint; - prefetch.ajax.type = prefetch.ajax.type || "GET"; - prefetch.ajax.dataType = prefetch.ajax.dataType || "json"; - !prefetch.url && $.error("prefetch requires url to be set"); - } - return prefetch; - } - function getRemote(o) { - var remote, defaults; - defaults = { - url: null, - wildcard: "%QUERY", - replace: null, - rateLimitBy: "debounce", - rateLimitWait: 300, - send: null, - filter: null, - ajax: {} - }; - if (remote = o.remote || null) { - remote = _.isString(remote) ? { - url: remote - } : remote; - remote = _.mixin(defaults, remote); - remote.rateLimiter = /^throttle$/i.test(remote.rateLimitBy) ? byThrottle(remote.rateLimitWait) : byDebounce(remote.rateLimitWait); - remote.ajax.type = remote.ajax.type || "GET"; - remote.ajax.dataType = remote.ajax.dataType || "json"; - delete remote.rateLimitBy; - delete remote.rateLimitWait; - !remote.url && $.error("remote requires url to be set"); - } - return remote; - function byDebounce(wait) { - return function(fn) { - return _.debounce(fn, wait); - }; - } - function byThrottle(wait) { - return function(fn) { - return _.throttle(fn, wait); - }; - } - } - }(); - (function(root) { - var old, keys; - old = root.Bloodhound; - keys = { - data: "data", - protocol: "protocol", - thumbprint: "thumbprint" - }; - root.Bloodhound = Bloodhound; - function Bloodhound(o) { - if (!o || !o.local && !o.prefetch && !o.remote) { - $.error("one of local, prefetch, or remote is required"); - } - this.limit = o.limit || 5; - this.sorter = getSorter(o.sorter); - this.dupDetector = o.dupDetector || ignoreDuplicates; - this.local = oParser.local(o); - this.prefetch = oParser.prefetch(o); - this.remote = oParser.remote(o); - this.cacheKey = this.prefetch ? this.prefetch.cacheKey || this.prefetch.url : null; - this.index = new SearchIndex({ - datumTokenizer: o.datumTokenizer, - queryTokenizer: o.queryTokenizer - }); - this.storage = this.cacheKey ? new PersistentStorage(this.cacheKey) : null; - } - Bloodhound.noConflict = function noConflict() { - root.Bloodhound = old; - return Bloodhound; - }; - Bloodhound.tokenizers = tokenizers; - _.mixin(Bloodhound.prototype, { - _loadPrefetch: function loadPrefetch(o) { - var that = this, serialized, deferred; - if (serialized = this._readFromStorage(o.thumbprint)) { - this.index.bootstrap(serialized); - deferred = $.Deferred().resolve(); - } else { - deferred = $.ajax(o.url, o.ajax).done(handlePrefetchResponse); - } - return deferred; - function handlePrefetchResponse(resp) { - that.clear(); - that.add(o.filter ? o.filter(resp) : resp); - that._saveToStorage(that.index.serialize(), o.thumbprint, o.ttl); - } - }, - _getFromRemote: function getFromRemote(query, cb) { - var that = this, url, uriEncodedQuery; - query = query || ""; - uriEncodedQuery = encodeURIComponent(query); - url = this.remote.replace ? this.remote.replace(this.remote.url, query) : this.remote.url.replace(this.remote.wildcard, uriEncodedQuery); - return this.transport.get(url, this.remote.ajax, handleRemoteResponse); - function handleRemoteResponse(err, resp) { - err ? cb([]) : cb(that.remote.filter ? that.remote.filter(resp) : resp); - } - }, - _saveToStorage: function saveToStorage(data, thumbprint, ttl) { - if (this.storage) { - this.storage.set(keys.data, data, ttl); - this.storage.set(keys.protocol, location.protocol, ttl); - this.storage.set(keys.thumbprint, thumbprint, ttl); - } - }, - _readFromStorage: function readFromStorage(thumbprint) { - var stored = {}, isExpired; - if (this.storage) { - stored.data = this.storage.get(keys.data); - stored.protocol = this.storage.get(keys.protocol); - stored.thumbprint = this.storage.get(keys.thumbprint); - } - isExpired = stored.thumbprint !== thumbprint || stored.protocol !== location.protocol; - return stored.data && !isExpired ? stored.data : null; - }, - _initialize: function initialize() { - var that = this, local = this.local, deferred; - deferred = this.prefetch ? this._loadPrefetch(this.prefetch) : $.Deferred().resolve(); - local && deferred.done(addLocalToIndex); - this.transport = this.remote ? new Transport(this.remote) : null; - return this.initPromise = deferred.promise(); - function addLocalToIndex() { - that.add(_.isFunction(local) ? local() : local); - } - }, - initialize: function initialize(force) { - return !this.initPromise || force ? this._initialize() : this.initPromise; - }, - add: function add(data) { - this.index.add(data); - }, - get: function get(query, cb) { - var that = this, matches = [], cacheHit = false; - matches = this.index.get(query); - matches = this.sorter(matches).slice(0, this.limit); - if (matches.length < this.limit && this.transport) { - cacheHit = this._getFromRemote(query, returnRemoteMatches); - } - if (!cacheHit) { - (matches.length > 0 || !this.transport) && cb && cb(matches); - } - function returnRemoteMatches(remoteMatches) { - var matchesWithBackfill = matches.slice(0); - _.each(remoteMatches, function(remoteMatch) { - var isDuplicate; - isDuplicate = _.some(matchesWithBackfill, function(match) { - return that.dupDetector(remoteMatch, match); - }); - !isDuplicate && matchesWithBackfill.push(remoteMatch); - return matchesWithBackfill.length < that.limit; - }); - cb && cb(that.sorter(matchesWithBackfill)); - } - }, - clear: function clear() { - this.index.reset(); - }, - clearPrefetchCache: function clearPrefetchCache() { - this.storage && this.storage.clear(); - }, - clearRemoteCache: function clearRemoteCache() { - this.transport && Transport.resetCache(); - }, - ttAdapter: function ttAdapter() { - return _.bind(this.get, this); - } - }); - return Bloodhound; - function getSorter(sortFn) { - return _.isFunction(sortFn) ? sort : noSort; - function sort(array) { - return array.sort(sortFn); - } - function noSort(array) { - return array; - } - } - function ignoreDuplicates() { - return false; - } - })(this); - var html = { - wrapper: '<span class="twitter-typeahead"></span>', - dropdown: '<span class="tt-dropdown-menu"></span>', - dataset: '<div class="tt-dataset-%CLASS%"></div>', - suggestions: '<span class="tt-suggestions"></span>', - suggestion: '<div class="tt-suggestion"></div>' - }; - var css = { - wrapper: { - position: "relative", - display: "inline-block" - }, - hint: { - position: "absolute", - top: "0", - left: "0", - borderColor: "transparent", - boxShadow: "none" - }, - input: { - position: "relative", - verticalAlign: "top", - backgroundColor: "transparent" - }, - inputWithNoHint: { - position: "relative", - verticalAlign: "top" - }, - dropdown: { - position: "absolute", - top: "100%", - left: "0", - zIndex: "100", - display: "none" - }, - suggestions: { - display: "block" - }, - suggestion: { - whiteSpace: "nowrap", - cursor: "pointer" - }, - suggestionChild: { - whiteSpace: "normal" - }, - ltr: { - left: "0", - right: "auto" - }, - rtl: { - left: "auto", - right: " 0" - } - }; - if (_.isMsie()) { - _.mixin(css.input, { - backgroundImage: "url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)" - }); - } - if (_.isMsie() && _.isMsie() <= 7) { - _.mixin(css.input, { - marginTop: "-1px" - }); - } - var EventBus = function() { - var namespace = "typeahead:"; - function EventBus(o) { - if (!o || !o.el) { - $.error("EventBus initialized without el"); - } - this.$el = $(o.el); - } - _.mixin(EventBus.prototype, { - trigger: function(type) { - var args = [].slice.call(arguments, 1); - this.$el.trigger(namespace + type, args); - } - }); - return EventBus; - }(); - var EventEmitter = function() { - var splitter = /\s+/, nextTick = getNextTick(); - return { - onSync: onSync, - onAsync: onAsync, - off: off, - trigger: trigger - }; - function on(method, types, cb, context) { - var type; - if (!cb) { - return this; - } - types = types.split(splitter); - cb = context ? bindContext(cb, context) : cb; - this._callbacks = this._callbacks || {}; - while (type = types.shift()) { - this._callbacks[type] = this._callbacks[type] || { - sync: [], - async: [] - }; - this._callbacks[type][method].push(cb); - } - return this; - } - function onAsync(types, cb, context) { - return on.call(this, "async", types, cb, context); - } - function onSync(types, cb, context) { - return on.call(this, "sync", types, cb, context); - } - function off(types) { - var type; - if (!this._callbacks) { - return this; - } - types = types.split(splitter); - while (type = types.shift()) { - delete this._callbacks[type]; - } - return this; - } - function trigger(types) { - var type, callbacks, args, syncFlush, asyncFlush; - if (!this._callbacks) { - return this; - } - types = types.split(splitter); - args = [].slice.call(arguments, 1); - while ((type = types.shift()) && (callbacks = this._callbacks[type])) { - syncFlush = getFlush(callbacks.sync, this, [ type ].concat(args)); - asyncFlush = getFlush(callbacks.async, this, [ type ].concat(args)); - syncFlush() && nextTick(asyncFlush); - } - return this; - } - function getFlush(callbacks, context, args) { - return flush; - function flush() { - var cancelled; - for (var i = 0; !cancelled && i < callbacks.length; i += 1) { - cancelled = callbacks[i].apply(context, args) === false; - } - return !cancelled; - } - } - function getNextTick() { - var nextTickFn; - if (window.setImmediate) { - nextTickFn = function nextTickSetImmediate(fn) { - setImmediate(function() { - fn(); - }); - }; - } else { - nextTickFn = function nextTickSetTimeout(fn) { - setTimeout(function() { - fn(); - }, 0); - }; - } - return nextTickFn; - } - function bindContext(fn, context) { - return fn.bind ? fn.bind(context) : function() { - fn.apply(context, [].slice.call(arguments, 0)); - }; - } - }(); - var highlight = function(doc) { - var defaults = { - node: null, - pattern: null, - tagName: "strong", - className: null, - wordsOnly: false, - caseSensitive: false - }; - return function hightlight(o) { - var regex; - o = _.mixin({}, defaults, o); - if (!o.node || !o.pattern) { - return; - } - o.pattern = _.isArray(o.pattern) ? o.pattern : [ o.pattern ]; - regex = getRegex(o.pattern, o.caseSensitive, o.wordsOnly); - traverse(o.node, hightlightTextNode); - function hightlightTextNode(textNode) { - var match, patternNode; - if (match = regex.exec(textNode.data)) { - wrapperNode = doc.createElement(o.tagName); - o.className && (wrapperNode.className = o.className); - patternNode = textNode.splitText(match.index); - patternNode.splitText(match[0].length); - wrapperNode.appendChild(patternNode.cloneNode(true)); - textNode.parentNode.replaceChild(wrapperNode, patternNode); - } - return !!match; - } - function traverse(el, hightlightTextNode) { - var childNode, TEXT_NODE_TYPE = 3; - for (var i = 0; i < el.childNodes.length; i++) { - childNode = el.childNodes[i]; - if (childNode.nodeType === TEXT_NODE_TYPE) { - i += hightlightTextNode(childNode) ? 1 : 0; - } else { - traverse(childNode, hightlightTextNode); - } - } - } - }; - function getRegex(patterns, caseSensitive, wordsOnly) { - var escapedPatterns = [], regexStr; - for (var i = 0; i < patterns.length; i++) { - escapedPatterns.push(_.escapeRegExChars(patterns[i])); - } - regexStr = wordsOnly ? "\\b(" + escapedPatterns.join("|") + ")\\b" : "(" + escapedPatterns.join("|") + ")"; - return caseSensitive ? new RegExp(regexStr) : new RegExp(regexStr, "i"); - } - }(window.document); - var Input = function() { - var specialKeyCodeMap; - specialKeyCodeMap = { - 9: "tab", - 27: "esc", - 37: "left", - 39: "right", - 13: "enter", - 38: "up", - 40: "down" - }; - function Input(o) { - var that = this, onBlur, onFocus, onKeydown, onInput; - o = o || {}; - if (!o.input) { - $.error("input is missing"); - } - onBlur = _.bind(this._onBlur, this); - onFocus = _.bind(this._onFocus, this); - onKeydown = _.bind(this._onKeydown, this); - onInput = _.bind(this._onInput, this); - this.$hint = $(o.hint); - this.$input = $(o.input).on("blur.tt", onBlur).on("focus.tt", onFocus).on("keydown.tt", onKeydown); - if (this.$hint.length === 0) { - this.setHint = this.getHint = this.clearHint = this.clearHintIfInvalid = _.noop; - } - if (!_.isMsie()) { - this.$input.on("input.tt", onInput); - } else { - this.$input.on("keydown.tt keypress.tt cut.tt paste.tt", function($e) { - if (specialKeyCodeMap[$e.which || $e.keyCode]) { - return; - } - _.defer(_.bind(that._onInput, that, $e)); - }); - } - this.query = this.$input.val(); - this.$overflowHelper = buildOverflowHelper(this.$input); - } - Input.normalizeQuery = function(str) { - return (str || "").replace(/^\s*/g, "").replace(/\s{2,}/g, " "); - }; - _.mixin(Input.prototype, EventEmitter, { - _onBlur: function onBlur() { - this.resetInputValue(); - this.trigger("blurred"); - }, - _onFocus: function onFocus() { - this.trigger("focused"); - }, - _onKeydown: function onKeydown($e) { - var keyName = specialKeyCodeMap[$e.which || $e.keyCode]; - this._managePreventDefault(keyName, $e); - if (keyName && this._shouldTrigger(keyName, $e)) { - this.trigger(keyName + "Keyed", $e); - } - }, - _onInput: function onInput() { - this._checkInputValue(); - }, - _managePreventDefault: function managePreventDefault(keyName, $e) { - var preventDefault, hintValue, inputValue; - switch (keyName) { - case "tab": - hintValue = this.getHint(); - inputValue = this.getInputValue(); - preventDefault = hintValue && hintValue !== inputValue && !withModifier($e); - break; - - case "up": - case "down": - preventDefault = !withModifier($e); - break; - - default: - preventDefault = false; - } - preventDefault && $e.preventDefault(); - }, - _shouldTrigger: function shouldTrigger(keyName, $e) { - var trigger; - switch (keyName) { - case "tab": - trigger = !withModifier($e); - break; - - default: - trigger = true; - } - return trigger; - }, - _checkInputValue: function checkInputValue() { - var inputValue, areEquivalent, hasDifferentWhitespace; - inputValue = this.getInputValue(); - areEquivalent = areQueriesEquivalent(inputValue, this.query); - hasDifferentWhitespace = areEquivalent ? this.query.length !== inputValue.length : false; - if (!areEquivalent) { - this.trigger("queryChanged", this.query = inputValue); - } else if (hasDifferentWhitespace) { - this.trigger("whitespaceChanged", this.query); - } - }, - focus: function focus() { - this.$input.focus(); - }, - blur: function blur() { - this.$input.blur(); - }, - getQuery: function getQuery() { - return this.query; - }, - setQuery: function setQuery(query) { - this.query = query; - }, - getInputValue: function getInputValue() { - return this.$input.val(); - }, - setInputValue: function setInputValue(value, silent) { - this.$input.val(value); - silent ? this.clearHint() : this._checkInputValue(); - }, - resetInputValue: function resetInputValue() { - this.setInputValue(this.query, true); - }, - getHint: function getHint() { - return this.$hint.val(); - }, - setHint: function setHint(value) { - this.$hint.val(value); - }, - clearHint: function clearHint() { - this.setHint(""); - }, - clearHintIfInvalid: function clearHintIfInvalid() { - var val, hint, valIsPrefixOfHint, isValid; - val = this.getInputValue(); - hint = this.getHint(); - valIsPrefixOfHint = val !== hint && hint.indexOf(val) === 0; - isValid = val !== "" && valIsPrefixOfHint && !this.hasOverflow(); - !isValid && this.clearHint(); - }, - getLanguageDirection: function getLanguageDirection() { - return (this.$input.css("direction") || "ltr").toLowerCase(); - }, - hasOverflow: function hasOverflow() { - var constraint = this.$input.width() - 2; - this.$overflowHelper.text(this.getInputValue()); - return this.$overflowHelper.width() >= constraint; - }, - isCursorAtEnd: function() { - var valueLength, selectionStart, range; - valueLength = this.$input.val().length; - selectionStart = this.$input[0].selectionStart; - if (_.isNumber(selectionStart)) { - return selectionStart === valueLength; - } else if (document.selection) { - range = document.selection.createRange(); - range.moveStart("character", -valueLength); - return valueLength === range.text.length; - } - return true; - }, - destroy: function destroy() { - this.$hint.off(".tt"); - this.$input.off(".tt"); - this.$hint = this.$input = this.$overflowHelper = null; - } - }); - return Input; - function buildOverflowHelper($input) { - return $('<pre aria-hidden="true"></pre>').css({ - position: "absolute", - visibility: "hidden", - whiteSpace: "pre", - fontFamily: $input.css("font-family"), - fontSize: $input.css("font-size"), - fontStyle: $input.css("font-style"), - fontVariant: $input.css("font-variant"), - fontWeight: $input.css("font-weight"), - wordSpacing: $input.css("word-spacing"), - letterSpacing: $input.css("letter-spacing"), - textIndent: $input.css("text-indent"), - textRendering: $input.css("text-rendering"), - textTransform: $input.css("text-transform") - }).insertAfter($input); - } - function areQueriesEquivalent(a, b) { - return Input.normalizeQuery(a) === Input.normalizeQuery(b); - } - function withModifier($e) { - return $e.altKey || $e.ctrlKey || $e.metaKey || $e.shiftKey; - } - }(); - var Dataset = function() { - var datasetKey = "ttDataset", valueKey = "ttValue", datumKey = "ttDatum"; - function Dataset(o) { - o = o || {}; - o.templates = o.templates || {}; - if (!o.source) { - $.error("missing source"); - } - if (o.name && !isValidName(o.name)) { - $.error("invalid dataset name: " + o.name); - } - this.query = null; - this.highlight = !!o.highlight; - this.name = o.name || _.getUniqueId(); - this.source = o.source; - this.displayFn = getDisplayFn(o.display || o.displayKey); - this.templates = getTemplates(o.templates, this.displayFn); - this.$el = $(html.dataset.replace("%CLASS%", this.name)); - } - Dataset.extractDatasetName = function extractDatasetName(el) { - return $(el).data(datasetKey); - }; - Dataset.extractValue = function extractDatum(el) { - return $(el).data(valueKey); - }; - Dataset.extractDatum = function extractDatum(el) { - return $(el).data(datumKey); - }; - _.mixin(Dataset.prototype, EventEmitter, { - _render: function render(query, suggestions) { - if (!this.$el) { - return; - } - var that = this, hasSuggestions; - this.$el.empty(); - hasSuggestions = suggestions && suggestions.length; - if (!hasSuggestions && this.templates.empty) { - this.$el.html(getEmptyHtml()).prepend(that.templates.header ? getHeaderHtml() : null).append(that.templates.footer ? getFooterHtml() : null); - } else if (hasSuggestions) { - this.$el.html(getSuggestionsHtml()).prepend(that.templates.header ? getHeaderHtml() : null).append(that.templates.footer ? getFooterHtml() : null); - } - this.trigger("rendered"); - function getEmptyHtml() { - return that.templates.empty({ - query: query, - isEmpty: true - }); - } - function getSuggestionsHtml() { - var $suggestions, nodes; - $suggestions = $(html.suggestions).css(css.suggestions); - nodes = _.map(suggestions, getSuggestionNode); - $suggestions.append.apply($suggestions, nodes); - that.highlight && highlight({ - node: $suggestions[0], - pattern: query - }); - return $suggestions; - function getSuggestionNode(suggestion) { - var $el; - $el = $(html.suggestion).append(that.templates.suggestion(suggestion)).data(datasetKey, that.name).data(valueKey, that.displayFn(suggestion)).data(datumKey, suggestion); - $el.children().each(function() { - $(this).css(css.suggestionChild); - }); - return $el; - } - } - function getHeaderHtml() { - return that.templates.header({ - query: query, - isEmpty: !hasSuggestions - }); - } - function getFooterHtml() { - return that.templates.footer({ - query: query, - isEmpty: !hasSuggestions - }); - } - }, - getRoot: function getRoot() { - return this.$el; - }, - update: function update(query) { - var that = this; - this.query = query; - this.canceled = false; - this.source(query, render); - function render(suggestions) { - if (!that.canceled && query === that.query) { - that._render(query, suggestions); - } - } - }, - cancel: function cancel() { - this.canceled = true; - }, - clear: function clear() { - this.cancel(); - this.$el.empty(); - this.trigger("rendered"); - }, - isEmpty: function isEmpty() { - return this.$el.is(":empty"); - }, - destroy: function destroy() { - this.$el = null; - } - }); - return Dataset; - function getDisplayFn(display) { - display = display || "value"; - return _.isFunction(display) ? display : displayFn; - function displayFn(obj) { - return obj[display]; - } - } - function getTemplates(templates, displayFn) { - return { - empty: templates.empty && _.templatify(templates.empty), - header: templates.header && _.templatify(templates.header), - footer: templates.footer && _.templatify(templates.footer), - suggestion: templates.suggestion || suggestionTemplate - }; - function suggestionTemplate(context) { - return "<p>" + displayFn(context) + "</p>"; - } - } - function isValidName(str) { - return /^[_a-zA-Z0-9-]+$/.test(str); - } - }(); - var Dropdown = function() { - function Dropdown(o) { - var that = this, onSuggestionClick, onSuggestionMouseEnter, onSuggestionMouseLeave; - o = o || {}; - if (!o.menu) { - $.error("menu is required"); - } - this.isOpen = false; - this.isEmpty = true; - this.datasets = _.map(o.datasets, initializeDataset); - onSuggestionClick = _.bind(this._onSuggestionClick, this); - onSuggestionMouseEnter = _.bind(this._onSuggestionMouseEnter, this); - onSuggestionMouseLeave = _.bind(this._onSuggestionMouseLeave, this); - this.$menu = $(o.menu).on("click.tt", ".tt-suggestion", onSuggestionClick).on("mouseenter.tt", ".tt-suggestion", onSuggestionMouseEnter).on("mouseleave.tt", ".tt-suggestion", onSuggestionMouseLeave); - _.each(this.datasets, function(dataset) { - that.$menu.append(dataset.getRoot()); - dataset.onSync("rendered", that._onRendered, that); - }); - } - _.mixin(Dropdown.prototype, EventEmitter, { - _onSuggestionClick: function onSuggestionClick($e) { - this.trigger("suggestionClicked", $($e.currentTarget)); - }, - _onSuggestionMouseEnter: function onSuggestionMouseEnter($e) { - this._removeCursor(); - this._setCursor($($e.currentTarget), true); - }, - _onSuggestionMouseLeave: function onSuggestionMouseLeave() { - this._removeCursor(); - }, - _onRendered: function onRendered() { - this.isEmpty = _.every(this.datasets, isDatasetEmpty); - this.isEmpty ? this._hide() : this.isOpen && this._show(); - this.trigger("datasetRendered"); - function isDatasetEmpty(dataset) { - return dataset.isEmpty(); - } - }, - _hide: function() { - this.$menu.hide(); - }, - _show: function() { - this.$menu.css("display", "block"); - }, - _getSuggestions: function getSuggestions() { - return this.$menu.find(".tt-suggestion"); - }, - _getCursor: function getCursor() { - return this.$menu.find(".tt-cursor").first(); - }, - _setCursor: function setCursor($el, silent) { - $el.first().addClass("tt-cursor"); - !silent && this.trigger("cursorMoved"); - }, - _removeCursor: function removeCursor() { - this._getCursor().removeClass("tt-cursor"); - }, - _moveCursor: function moveCursor(increment) { - var $suggestions, $oldCursor, newCursorIndex, $newCursor; - if (!this.isOpen) { - return; - } - $oldCursor = this._getCursor(); - $suggestions = this._getSuggestions(); - this._removeCursor(); - newCursorIndex = $suggestions.index($oldCursor) + increment; - newCursorIndex = (newCursorIndex + 1) % ($suggestions.length + 1) - 1; - if (newCursorIndex === -1) { - this.trigger("cursorRemoved"); - return; - } else if (newCursorIndex < -1) { - newCursorIndex = $suggestions.length - 1; - } - this._setCursor($newCursor = $suggestions.eq(newCursorIndex)); - this._ensureVisible($newCursor); - }, - _ensureVisible: function ensureVisible($el) { - var elTop, elBottom, menuScrollTop, menuHeight; - elTop = $el.position().top; - elBottom = elTop + $el.outerHeight(true); - menuScrollTop = this.$menu.scrollTop(); - menuHeight = this.$menu.height() + parseInt(this.$menu.css("paddingTop"), 10) + parseInt(this.$menu.css("paddingBottom"), 10); - if (elTop < 0) { - this.$menu.scrollTop(menuScrollTop + elTop); - } else if (menuHeight < elBottom) { - this.$menu.scrollTop(menuScrollTop + (elBottom - menuHeight)); - } - }, - close: function close() { - if (this.isOpen) { - this.isOpen = false; - this._removeCursor(); - this._hide(); - this.trigger("closed"); - } - }, - open: function open() { - if (!this.isOpen) { - this.isOpen = true; - !this.isEmpty && this._show(); - this.trigger("opened"); - } - }, - setLanguageDirection: function setLanguageDirection(dir) { - this.$menu.css(dir === "ltr" ? css.ltr : css.rtl); - }, - moveCursorUp: function moveCursorUp() { - this._moveCursor(-1); - }, - moveCursorDown: function moveCursorDown() { - this._moveCursor(+1); - }, - getDatumForSuggestion: function getDatumForSuggestion($el) { - var datum = null; - if ($el.length) { - datum = { - raw: Dataset.extractDatum($el), - value: Dataset.extractValue($el), - datasetName: Dataset.extractDatasetName($el) - }; - } - return datum; - }, - getDatumForCursor: function getDatumForCursor() { - return this.getDatumForSuggestion(this._getCursor().first()); - }, - getDatumForTopSuggestion: function getDatumForTopSuggestion() { - return this.getDatumForSuggestion(this._getSuggestions().first()); - }, - update: function update(query) { - _.each(this.datasets, updateDataset); - function updateDataset(dataset) { - dataset.update(query); - } - }, - empty: function empty() { - _.each(this.datasets, clearDataset); - this.isEmpty = true; - function clearDataset(dataset) { - dataset.clear(); - } - }, - isVisible: function isVisible() { - return this.isOpen && !this.isEmpty; - }, - destroy: function destroy() { - this.$menu.off(".tt"); - this.$menu = null; - _.each(this.datasets, destroyDataset); - function destroyDataset(dataset) { - dataset.destroy(); - } - } - }); - return Dropdown; - function initializeDataset(oDataset) { - return new Dataset(oDataset); - } - }(); - var Typeahead = function() { - var attrsKey = "ttAttrs"; - function Typeahead(o) { - var $menu, $input, $hint; - o = o || {}; - if (!o.input) { - $.error("missing input"); - } - this.isActivated = false; - this.autoselect = !!o.autoselect; - this.minLength = _.isNumber(o.minLength) ? o.minLength : 1; - this.$node = buildDomStructure(o.input, o.withHint); - $menu = this.$node.find(".tt-dropdown-menu"); - $input = this.$node.find(".tt-input"); - $hint = this.$node.find(".tt-hint"); - $input.on("blur.tt", function($e) { - var active, isActive, hasActive; - active = document.activeElement; - isActive = $menu.is(active); - hasActive = $menu.has(active).length > 0; - if (_.isMsie() && (isActive || hasActive)) { - $e.preventDefault(); - $e.stopImmediatePropagation(); - _.defer(function() { - $input.focus(); - }); - } - }); - $menu.on("mousedown.tt", function($e) { - $e.preventDefault(); - }); - this.eventBus = o.eventBus || new EventBus({ - el: $input - }); - this.dropdown = new Dropdown({ - menu: $menu, - datasets: o.datasets - }).onSync("suggestionClicked", this._onSuggestionClicked, this).onSync("cursorMoved", this._onCursorMoved, this).onSync("cursorRemoved", this._onCursorRemoved, this).onSync("opened", this._onOpened, this).onSync("closed", this._onClosed, this).onAsync("datasetRendered", this._onDatasetRendered, this); - this.input = new Input({ - input: $input, - hint: $hint - }).onSync("focused", this._onFocused, this).onSync("blurred", this._onBlurred, this).onSync("enterKeyed", this._onEnterKeyed, this).onSync("tabKeyed", this._onTabKeyed, this).onSync("escKeyed", this._onEscKeyed, this).onSync("upKeyed", this._onUpKeyed, this).onSync("downKeyed", this._onDownKeyed, this).onSync("leftKeyed", this._onLeftKeyed, this).onSync("rightKeyed", this._onRightKeyed, this).onSync("queryChanged", this._onQueryChanged, this).onSync("whitespaceChanged", this._onWhitespaceChanged, this); - this._setLanguageDirection(); - } - _.mixin(Typeahead.prototype, { - _onSuggestionClicked: function onSuggestionClicked(type, $el) { - var datum; - if (datum = this.dropdown.getDatumForSuggestion($el)) { - this._select(datum); - } - }, - _onCursorMoved: function onCursorMoved() { - var datum = this.dropdown.getDatumForCursor(); - this.input.setInputValue(datum.value, true); - this.eventBus.trigger("cursorchanged", datum.raw, datum.datasetName); - }, - _onCursorRemoved: function onCursorRemoved() { - this.input.resetInputValue(); - this._updateHint(); - }, - _onDatasetRendered: function onDatasetRendered() { - this._updateHint(); - }, - _onOpened: function onOpened() { - this._updateHint(); - this.eventBus.trigger("opened"); - }, - _onClosed: function onClosed() { - this.input.clearHint(); - this.eventBus.trigger("closed"); - }, - _onFocused: function onFocused() { - this.isActivated = true; - this.dropdown.open(); - }, - _onBlurred: function onBlurred() { - this.isActivated = false; - this.dropdown.empty(); - this.dropdown.close(); - }, - _onEnterKeyed: function onEnterKeyed(type, $e) { - var cursorDatum, topSuggestionDatum; - cursorDatum = this.dropdown.getDatumForCursor(); - topSuggestionDatum = this.dropdown.getDatumForTopSuggestion(); - if (cursorDatum) { - this._select(cursorDatum); - $e.preventDefault(); - } else if (this.autoselect && topSuggestionDatum) { - this._select(topSuggestionDatum); - $e.preventDefault(); - } - }, - _onTabKeyed: function onTabKeyed(type, $e) { - var datum; - if (datum = this.dropdown.getDatumForCursor()) { - this._select(datum); - $e.preventDefault(); - } else { - this._autocomplete(true); - } - }, - _onEscKeyed: function onEscKeyed() { - this.dropdown.close(); - this.input.resetInputValue(); - }, - _onUpKeyed: function onUpKeyed() { - var query = this.input.getQuery(); - this.dropdown.isEmpty && query.length >= this.minLength ? this.dropdown.update(query) : this.dropdown.moveCursorUp(); - this.dropdown.open(); - }, - _onDownKeyed: function onDownKeyed() { - var query = this.input.getQuery(); - this.dropdown.isEmpty && query.length >= this.minLength ? this.dropdown.update(query) : this.dropdown.moveCursorDown(); - this.dropdown.open(); - }, - _onLeftKeyed: function onLeftKeyed() { - this.dir === "rtl" && this._autocomplete(); - }, - _onRightKeyed: function onRightKeyed() { - this.dir === "ltr" && this._autocomplete(); - }, - _onQueryChanged: function onQueryChanged(e, query) { - this.input.clearHintIfInvalid(); - query.length >= this.minLength ? this.dropdown.update(query) : this.dropdown.empty(); - this.dropdown.open(); - this._setLanguageDirection(); - }, - _onWhitespaceChanged: function onWhitespaceChanged() { - this._updateHint(); - this.dropdown.open(); - }, - _setLanguageDirection: function setLanguageDirection() { - var dir; - if (this.dir !== (dir = this.input.getLanguageDirection())) { - this.dir = dir; - this.$node.css("direction", dir); - this.dropdown.setLanguageDirection(dir); - } - }, - _updateHint: function updateHint() { - var datum, val, query, escapedQuery, frontMatchRegEx, match; - datum = this.dropdown.getDatumForTopSuggestion(); - if (datum && this.dropdown.isVisible() && !this.input.hasOverflow()) { - val = this.input.getInputValue(); - query = Input.normalizeQuery(val); - escapedQuery = _.escapeRegExChars(query); - frontMatchRegEx = new RegExp("^(?:" + escapedQuery + ")(.+$)", "i"); - match = frontMatchRegEx.exec(datum.value); - match ? this.input.setHint(val + match[1]) : this.input.clearHint(); - } else { - this.input.clearHint(); - } - }, - _autocomplete: function autocomplete(laxCursor) { - var hint, query, isCursorAtEnd, datum; - hint = this.input.getHint(); - query = this.input.getQuery(); - isCursorAtEnd = laxCursor || this.input.isCursorAtEnd(); - if (hint && query !== hint && isCursorAtEnd) { - datum = this.dropdown.getDatumForTopSuggestion(); - datum && this.input.setInputValue(datum.value); - this.eventBus.trigger("autocompleted", datum.raw, datum.datasetName); - } - }, - _select: function select(datum) { - this.input.setQuery(datum.value); - this.input.setInputValue(datum.value, true); - this._setLanguageDirection(); - this.eventBus.trigger("selected", datum.raw, datum.datasetName); - this.dropdown.close(); - _.defer(_.bind(this.dropdown.empty, this.dropdown)); - }, - open: function open() { - this.dropdown.open(); - }, - close: function close() { - this.dropdown.close(); - }, - setVal: function setVal(val) { - if (this.isActivated) { - this.input.setInputValue(val); - } else { - this.input.setQuery(val); - this.input.setInputValue(val, true); - } - this._setLanguageDirection(); - }, - getVal: function getVal() { - return this.input.getQuery(); - }, - destroy: function destroy() { - this.input.destroy(); - this.dropdown.destroy(); - destroyDomStructure(this.$node); - this.$node = null; - } - }); - return Typeahead; - function buildDomStructure(input, withHint) { - var $input, $wrapper, $dropdown, $hint; - $input = $(input); - $wrapper = $(html.wrapper).css(css.wrapper); - $dropdown = $(html.dropdown).css(css.dropdown); - $hint = $input.clone().css(css.hint).css(getBackgroundStyles($input)); - $hint.val("").removeData().addClass("tt-hint").removeAttr("id name placeholder").prop("disabled", true).attr({ - autocomplete: "off", - spellcheck: "false" - }); - $input.data(attrsKey, { - dir: $input.attr("dir"), - autocomplete: $input.attr("autocomplete"), - spellcheck: $input.attr("spellcheck"), - style: $input.attr("style") - }); - $input.addClass("tt-input").attr({ - autocomplete: "off", - spellcheck: false - }).css(withHint ? css.input : css.inputWithNoHint); - try { - !$input.attr("dir") && $input.attr("dir", "auto"); - } catch (e) {} - return $input.wrap($wrapper).parent().prepend(withHint ? $hint : null).append($dropdown); - } - function getBackgroundStyles($el) { - return { - backgroundAttachment: $el.css("background-attachment"), - backgroundClip: $el.css("background-clip"), - backgroundColor: $el.css("background-color"), - backgroundImage: $el.css("background-image"), - backgroundOrigin: $el.css("background-origin"), - backgroundPosition: $el.css("background-position"), - backgroundRepeat: $el.css("background-repeat"), - backgroundSize: $el.css("background-size") - }; - } - function destroyDomStructure($node) { - var $input = $node.find(".tt-input"); - _.each($input.data(attrsKey), function(val, key) { - _.isUndefined(val) ? $input.removeAttr(key) : $input.attr(key, val); - }); - $input.detach().removeData(attrsKey).removeClass("tt-input").insertAfter($node); - $node.remove(); - } - }(); - (function() { - var old, typeaheadKey, methods; - old = $.fn.typeahead; - typeaheadKey = "ttTypeahead"; - methods = { - initialize: function initialize(o, datasets) { - datasets = _.isArray(datasets) ? datasets : [].slice.call(arguments, 1); - o = o || {}; - return this.each(attach); - function attach() { - var $input = $(this), eventBus, typeahead; - _.each(datasets, function(d) { - d.highlight = !!o.highlight; - }); - typeahead = new Typeahead({ - input: $input, - eventBus: eventBus = new EventBus({ - el: $input - }), - withHint: _.isUndefined(o.hint) ? true : !!o.hint, - minLength: o.minLength, - autoselect: o.autoselect, - datasets: datasets - }); - $input.data(typeaheadKey, typeahead); - } - }, - open: function open() { - return this.each(openTypeahead); - function openTypeahead() { - var $input = $(this), typeahead; - if (typeahead = $input.data(typeaheadKey)) { - typeahead.open(); - } - } - }, - close: function close() { - return this.each(closeTypeahead); - function closeTypeahead() { - var $input = $(this), typeahead; - if (typeahead = $input.data(typeaheadKey)) { - typeahead.close(); - } - } - }, - val: function val(newVal) { - return !arguments.length ? getVal(this.first()) : this.each(setVal); - function setVal() { - var $input = $(this), typeahead; - if (typeahead = $input.data(typeaheadKey)) { - typeahead.setVal(newVal); - } - } - function getVal($input) { - var typeahead, query; - if (typeahead = $input.data(typeaheadKey)) { - query = typeahead.getVal(); - } - return query; - } - }, - destroy: function destroy() { - return this.each(unattach); - function unattach() { - var $input = $(this), typeahead; - if (typeahead = $input.data(typeaheadKey)) { - typeahead.destroy(); - $input.removeData(typeaheadKey); - } - } - } - }; - $.fn.typeahead = function(method) { - if (methods[method]) { - return methods[method].apply(this, [].slice.call(arguments, 1)); - } else { - return methods.initialize.apply(this, arguments); - } - }; - $.fn.typeahead.noConflict = function noConflict() { - $.fn.typeahead = old; - return this; - }; - })(); -})(window.jQuery); \ No newline at end of file diff --git a/src/UI/JsLibraries/zero.clipboard.js b/src/UI/JsLibraries/zero.clipboard.js deleted file mode 100644 index dd44ac46a..000000000 --- a/src/UI/JsLibraries/zero.clipboard.js +++ /dev/null @@ -1,2581 +0,0 @@ -/*! - * ZeroClipboard - * The ZeroClipboard library provides an easy way to copy text to the clipboard using an invisible Adobe Flash movie and a JavaScript interface. - * Copyright (c) 2009-2014 Jon Rohan, James M. Greene - * Licensed MIT - * http://zeroclipboard.org/ - * v2.2.0 - */ -(function(window, undefined) { - "use strict"; - /** - * Store references to critically important global functions that may be - * overridden on certain web pages. - */ - var _window = window, _document = _window.document, _navigator = _window.navigator, _setTimeout = _window.setTimeout, _clearTimeout = _window.clearTimeout, _setInterval = _window.setInterval, _clearInterval = _window.clearInterval, _getComputedStyle = _window.getComputedStyle, _encodeURIComponent = _window.encodeURIComponent, _ActiveXObject = _window.ActiveXObject, _Error = _window.Error, _parseInt = _window.Number.parseInt || _window.parseInt, _parseFloat = _window.Number.parseFloat || _window.parseFloat, _isNaN = _window.Number.isNaN || _window.isNaN, _now = _window.Date.now, _keys = _window.Object.keys, _defineProperty = _window.Object.defineProperty, _hasOwn = _window.Object.prototype.hasOwnProperty, _slice = _window.Array.prototype.slice, _unwrap = function() { - var unwrapper = function(el) { - return el; - }; - if (typeof _window.wrap === "function" && typeof _window.unwrap === "function") { - try { - var div = _document.createElement("div"); - var unwrappedDiv = _window.unwrap(div); - if (div.nodeType === 1 && unwrappedDiv && unwrappedDiv.nodeType === 1) { - unwrapper = _window.unwrap; - } - } catch (e) {} - } - return unwrapper; - }(); - /** - * Convert an `arguments` object into an Array. - * - * @returns The arguments as an Array - * @private - */ - var _args = function(argumentsObj) { - return _slice.call(argumentsObj, 0); - }; - /** - * Shallow-copy the owned, enumerable properties of one object over to another, similar to jQuery's `$.extend`. - * - * @returns The target object, augmented - * @private - */ - var _extend = function() { - var i, len, arg, prop, src, copy, args = _args(arguments), target = args[0] || {}; - for (i = 1, len = args.length; i < len; i++) { - if ((arg = args[i]) != null) { - for (prop in arg) { - if (_hasOwn.call(arg, prop)) { - src = target[prop]; - copy = arg[prop]; - if (target !== copy && copy !== undefined) { - target[prop] = copy; - } - } - } - } - } - return target; - }; - /** - * Return a deep copy of the source object or array. - * - * @returns Object or Array - * @private - */ - var _deepCopy = function(source) { - var copy, i, len, prop; - if (typeof source !== "object" || source == null || typeof source.nodeType === "number") { - copy = source; - } else if (typeof source.length === "number") { - copy = []; - for (i = 0, len = source.length; i < len; i++) { - if (_hasOwn.call(source, i)) { - copy[i] = _deepCopy(source[i]); - } - } - } else { - copy = {}; - for (prop in source) { - if (_hasOwn.call(source, prop)) { - copy[prop] = _deepCopy(source[prop]); - } - } - } - return copy; - }; - /** - * Makes a shallow copy of `obj` (like `_extend`) but filters its properties based on a list of `keys` to keep. - * The inverse of `_omit`, mostly. The big difference is that these properties do NOT need to be enumerable to - * be kept. - * - * @returns A new filtered object. - * @private - */ - var _pick = function(obj, keys) { - var newObj = {}; - for (var i = 0, len = keys.length; i < len; i++) { - if (keys[i] in obj) { - newObj[keys[i]] = obj[keys[i]]; - } - } - return newObj; - }; - /** - * Makes a shallow copy of `obj` (like `_extend`) but filters its properties based on a list of `keys` to omit. - * The inverse of `_pick`. - * - * @returns A new filtered object. - * @private - */ - var _omit = function(obj, keys) { - var newObj = {}; - for (var prop in obj) { - if (keys.indexOf(prop) === -1) { - newObj[prop] = obj[prop]; - } - } - return newObj; - }; - /** - * Remove all owned, enumerable properties from an object. - * - * @returns The original object without its owned, enumerable properties. - * @private - */ - var _deleteOwnProperties = function(obj) { - if (obj) { - for (var prop in obj) { - if (_hasOwn.call(obj, prop)) { - delete obj[prop]; - } - } - } - return obj; - }; - /** - * Determine if an element is contained within another element. - * - * @returns Boolean - * @private - */ - var _containedBy = function(el, ancestorEl) { - if (el && el.nodeType === 1 && el.ownerDocument && ancestorEl && (ancestorEl.nodeType === 1 && ancestorEl.ownerDocument && ancestorEl.ownerDocument === el.ownerDocument || ancestorEl.nodeType === 9 && !ancestorEl.ownerDocument && ancestorEl === el.ownerDocument)) { - do { - if (el === ancestorEl) { - return true; - } - el = el.parentNode; - } while (el); - } - return false; - }; - /** - * Get the URL path's parent directory. - * - * @returns String or `undefined` - * @private - */ - var _getDirPathOfUrl = function(url) { - var dir; - if (typeof url === "string" && url) { - dir = url.split("#")[0].split("?")[0]; - dir = url.slice(0, url.lastIndexOf("/") + 1); - } - return dir; - }; - /** - * Get the current script's URL by throwing an `Error` and analyzing it. - * - * @returns String or `undefined` - * @private - */ - var _getCurrentScriptUrlFromErrorStack = function(stack) { - var url, matches; - if (typeof stack === "string" && stack) { - matches = stack.match(/^(?:|[^:@]*@|.+\)@(?=http[s]?|file)|.+?\s+(?: at |@)(?:[^:\(]+ )*[\(]?)((?:http[s]?|file):\/\/[\/]?.+?\/[^:\)]*?)(?::\d+)(?::\d+)?/); - if (matches && matches[1]) { - url = matches[1]; - } else { - matches = stack.match(/\)@((?:http[s]?|file):\/\/[\/]?.+?\/[^:\)]*?)(?::\d+)(?::\d+)?/); - if (matches && matches[1]) { - url = matches[1]; - } - } - } - return url; - }; - /** - * Get the current script's URL by throwing an `Error` and analyzing it. - * - * @returns String or `undefined` - * @private - */ - var _getCurrentScriptUrlFromError = function() { - var url, err; - try { - throw new _Error(); - } catch (e) { - err = e; - } - if (err) { - url = err.sourceURL || err.fileName || _getCurrentScriptUrlFromErrorStack(err.stack); - } - return url; - }; - /** - * Get the current script's URL. - * - * @returns String or `undefined` - * @private - */ - var _getCurrentScriptUrl = function() { - var jsPath, scripts, i; - if (_document.currentScript && (jsPath = _document.currentScript.src)) { - return jsPath; - } - scripts = _document.getElementsByTagName("script"); - if (scripts.length === 1) { - return scripts[0].src || undefined; - } - if ("readyState" in scripts[0]) { - for (i = scripts.length; i--; ) { - if (scripts[i].readyState === "interactive" && (jsPath = scripts[i].src)) { - return jsPath; - } - } - } - if (_document.readyState === "loading" && (jsPath = scripts[scripts.length - 1].src)) { - return jsPath; - } - if (jsPath = _getCurrentScriptUrlFromError()) { - return jsPath; - } - return undefined; - }; - /** - * Get the unanimous parent directory of ALL script tags. - * If any script tags are either (a) inline or (b) from differing parent - * directories, this method must return `undefined`. - * - * @returns String or `undefined` - * @private - */ - var _getUnanimousScriptParentDir = function() { - var i, jsDir, jsPath, scripts = _document.getElementsByTagName("script"); - for (i = scripts.length; i--; ) { - if (!(jsPath = scripts[i].src)) { - jsDir = null; - break; - } - jsPath = _getDirPathOfUrl(jsPath); - if (jsDir == null) { - jsDir = jsPath; - } else if (jsDir !== jsPath) { - jsDir = null; - break; - } - } - return jsDir || undefined; - }; - /** - * Get the presumed location of the "ZeroClipboard.swf" file, based on the location - * of the executing JavaScript file (e.g. "ZeroClipboard.js", etc.). - * - * @returns String - * @private - */ - var _getDefaultSwfPath = function() { - var jsDir = _getDirPathOfUrl(_getCurrentScriptUrl()) || _getUnanimousScriptParentDir() || ""; - return jsDir + "ZeroClipboard.swf"; - }; - /** - * Keep track of if the page is framed (in an `iframe`). This can never change. - * @private - */ - var _pageIsFramed = function() { - return window.opener == null && (!!window.top && window != window.top || !!window.parent && window != window.parent); - }(); - /** - * Keep track of the state of the Flash object. - * @private - */ - var _flashState = { - bridge: null, - version: "0.0.0", - pluginType: "unknown", - disabled: null, - outdated: null, - sandboxed: null, - unavailable: null, - degraded: null, - deactivated: null, - overdue: null, - ready: null - }; - /** - * The minimum Flash Player version required to use ZeroClipboard completely. - * @readonly - * @private - */ - var _minimumFlashVersion = "11.0.0"; - /** - * The ZeroClipboard library version number, as reported by Flash, at the time the SWF was compiled. - */ - var _zcSwfVersion; - /** - * Keep track of all event listener registrations. - * @private - */ - var _handlers = {}; - /** - * Keep track of the currently activated element. - * @private - */ - var _currentElement; - /** - * Keep track of the element that was activated when a `copy` process started. - * @private - */ - var _copyTarget; - /** - * Keep track of data for the pending clipboard transaction. - * @private - */ - var _clipData = {}; - /** - * Keep track of data formats for the pending clipboard transaction. - * @private - */ - var _clipDataFormatMap = null; - /** - * Keep track of the Flash availability check timeout. - * @private - */ - var _flashCheckTimeout = 0; - /** - * Keep track of SWF network errors interval polling. - * @private - */ - var _swfFallbackCheckInterval = 0; - /** - * The `message` store for events - * @private - */ - var _eventMessages = { - ready: "Flash communication is established", - error: { - "flash-disabled": "Flash is disabled or not installed. May also be attempting to run Flash in a sandboxed iframe, which is impossible.", - "flash-outdated": "Flash is too outdated to support ZeroClipboard", - "flash-sandboxed": "Attempting to run Flash in a sandboxed iframe, which is impossible", - "flash-unavailable": "Flash is unable to communicate bidirectionally with JavaScript", - "flash-degraded": "Flash is unable to preserve data fidelity when communicating with JavaScript", - "flash-deactivated": "Flash is too outdated for your browser and/or is configured as click-to-activate.\nThis may also mean that the ZeroClipboard SWF object could not be loaded, so please check your `swfPath` configuration and/or network connectivity.\nMay also be attempting to run Flash in a sandboxed iframe, which is impossible.", - "flash-overdue": "Flash communication was established but NOT within the acceptable time limit", - "version-mismatch": "ZeroClipboard JS version number does not match ZeroClipboard SWF version number", - "clipboard-error": "At least one error was thrown while ZeroClipboard was attempting to inject your data into the clipboard", - "config-mismatch": "ZeroClipboard configuration does not match Flash's reality", - "swf-not-found": "The ZeroClipboard SWF object could not be loaded, so please check your `swfPath` configuration and/or network connectivity" - } - }; - /** - * The `name`s of `error` events that can only occur is Flash has at least - * been able to load the SWF successfully. - * @private - */ - var _errorsThatOnlyOccurAfterFlashLoads = [ "flash-unavailable", "flash-degraded", "flash-overdue", "version-mismatch", "config-mismatch", "clipboard-error" ]; - /** - * The `name`s of `error` events that should likely result in the `_flashState` - * variable's property values being updated. - * @private - */ - var _flashStateErrorNames = [ "flash-disabled", "flash-outdated", "flash-sandboxed", "flash-unavailable", "flash-degraded", "flash-deactivated", "flash-overdue" ]; - /** - * A RegExp to match the `name` property of `error` events related to Flash. - * @private - */ - var _flashStateErrorNameMatchingRegex = new RegExp("^flash-(" + _flashStateErrorNames.map(function(errorName) { - return errorName.replace(/^flash-/, ""); - }).join("|") + ")$"); - /** - * A RegExp to match the `name` property of `error` events related to Flash, - * which is enabled. - * @private - */ - var _flashStateEnabledErrorNameMatchingRegex = new RegExp("^flash-(" + _flashStateErrorNames.slice(1).map(function(errorName) { - return errorName.replace(/^flash-/, ""); - }).join("|") + ")$"); - /** - * ZeroClipboard configuration defaults for the Core module. - * @private - */ - var _globalConfig = { - swfPath: _getDefaultSwfPath(), - trustedDomains: window.location.host ? [ window.location.host ] : [], - cacheBust: true, - forceEnhancedClipboard: false, - flashLoadTimeout: 3e4, - autoActivate: true, - bubbleEvents: true, - containerId: "global-zeroclipboard-html-bridge", - containerClass: "global-zeroclipboard-container", - swfObjectId: "global-zeroclipboard-flash-bridge", - hoverClass: "zeroclipboard-is-hover", - activeClass: "zeroclipboard-is-active", - forceHandCursor: false, - title: null, - zIndex: 999999999 - }; - /** - * The underlying implementation of `ZeroClipboard.config`. - * @private - */ - var _config = function(options) { - if (typeof options === "object" && options !== null) { - for (var prop in options) { - if (_hasOwn.call(options, prop)) { - if (/^(?:forceHandCursor|title|zIndex|bubbleEvents)$/.test(prop)) { - _globalConfig[prop] = options[prop]; - } else if (_flashState.bridge == null) { - if (prop === "containerId" || prop === "swfObjectId") { - if (_isValidHtml4Id(options[prop])) { - _globalConfig[prop] = options[prop]; - } else { - throw new Error("The specified `" + prop + "` value is not valid as an HTML4 Element ID"); - } - } else { - _globalConfig[prop] = options[prop]; - } - } - } - } - } - if (typeof options === "string" && options) { - if (_hasOwn.call(_globalConfig, options)) { - return _globalConfig[options]; - } - return; - } - return _deepCopy(_globalConfig); - }; - /** - * The underlying implementation of `ZeroClipboard.state`. - * @private - */ - var _state = function() { - _detectSandbox(); - return { - browser: _pick(_navigator, [ "userAgent", "platform", "appName" ]), - flash: _omit(_flashState, [ "bridge" ]), - zeroclipboard: { - version: ZeroClipboard.version, - config: ZeroClipboard.config() - } - }; - }; - /** - * The underlying implementation of `ZeroClipboard.isFlashUnusable`. - * @private - */ - var _isFlashUnusable = function() { - return !!(_flashState.disabled || _flashState.outdated || _flashState.sandboxed || _flashState.unavailable || _flashState.degraded || _flashState.deactivated); - }; - /** - * The underlying implementation of `ZeroClipboard.on`. - * @private - */ - var _on = function(eventType, listener) { - var i, len, events, added = {}; - if (typeof eventType === "string" && eventType) { - events = eventType.toLowerCase().split(/\s+/); - } else if (typeof eventType === "object" && eventType && typeof listener === "undefined") { - for (i in eventType) { - if (_hasOwn.call(eventType, i) && typeof i === "string" && i && typeof eventType[i] === "function") { - ZeroClipboard.on(i, eventType[i]); - } - } - } - if (events && events.length) { - for (i = 0, len = events.length; i < len; i++) { - eventType = events[i].replace(/^on/, ""); - added[eventType] = true; - if (!_handlers[eventType]) { - _handlers[eventType] = []; - } - _handlers[eventType].push(listener); - } - if (added.ready && _flashState.ready) { - ZeroClipboard.emit({ - type: "ready" - }); - } - if (added.error) { - for (i = 0, len = _flashStateErrorNames.length; i < len; i++) { - if (_flashState[_flashStateErrorNames[i].replace(/^flash-/, "")] === true) { - ZeroClipboard.emit({ - type: "error", - name: _flashStateErrorNames[i] - }); - break; - } - } - if (_zcSwfVersion !== undefined && ZeroClipboard.version !== _zcSwfVersion) { - ZeroClipboard.emit({ - type: "error", - name: "version-mismatch", - jsVersion: ZeroClipboard.version, - swfVersion: _zcSwfVersion - }); - } - } - } - return ZeroClipboard; - }; - /** - * The underlying implementation of `ZeroClipboard.off`. - * @private - */ - var _off = function(eventType, listener) { - var i, len, foundIndex, events, perEventHandlers; - if (arguments.length === 0) { - events = _keys(_handlers); - } else if (typeof eventType === "string" && eventType) { - events = eventType.split(/\s+/); - } else if (typeof eventType === "object" && eventType && typeof listener === "undefined") { - for (i in eventType) { - if (_hasOwn.call(eventType, i) && typeof i === "string" && i && typeof eventType[i] === "function") { - ZeroClipboard.off(i, eventType[i]); - } - } - } - if (events && events.length) { - for (i = 0, len = events.length; i < len; i++) { - eventType = events[i].toLowerCase().replace(/^on/, ""); - perEventHandlers = _handlers[eventType]; - if (perEventHandlers && perEventHandlers.length) { - if (listener) { - foundIndex = perEventHandlers.indexOf(listener); - while (foundIndex !== -1) { - perEventHandlers.splice(foundIndex, 1); - foundIndex = perEventHandlers.indexOf(listener, foundIndex); - } - } else { - perEventHandlers.length = 0; - } - } - } - } - return ZeroClipboard; - }; - /** - * The underlying implementation of `ZeroClipboard.handlers`. - * @private - */ - var _listeners = function(eventType) { - var copy; - if (typeof eventType === "string" && eventType) { - copy = _deepCopy(_handlers[eventType]) || null; - } else { - copy = _deepCopy(_handlers); - } - return copy; - }; - /** - * The underlying implementation of `ZeroClipboard.emit`. - * @private - */ - var _emit = function(event) { - var eventCopy, returnVal, tmp; - event = _createEvent(event); - if (!event) { - return; - } - if (_preprocessEvent(event)) { - return; - } - if (event.type === "ready" && _flashState.overdue === true) { - return ZeroClipboard.emit({ - type: "error", - name: "flash-overdue" - }); - } - eventCopy = _extend({}, event); - _dispatchCallbacks.call(this, eventCopy); - if (event.type === "copy") { - tmp = _mapClipDataToFlash(_clipData); - returnVal = tmp.data; - _clipDataFormatMap = tmp.formatMap; - } - return returnVal; - }; - /** - * The underlying implementation of `ZeroClipboard.create`. - * @private - */ - var _create = function() { - var previousState = _flashState.sandboxed; - _detectSandbox(); - if (typeof _flashState.ready !== "boolean") { - _flashState.ready = false; - } - if (_flashState.sandboxed !== previousState && _flashState.sandboxed === true) { - _flashState.ready = false; - ZeroClipboard.emit({ - type: "error", - name: "flash-sandboxed" - }); - } else if (!ZeroClipboard.isFlashUnusable() && _flashState.bridge === null) { - var maxWait = _globalConfig.flashLoadTimeout; - if (typeof maxWait === "number" && maxWait >= 0) { - _flashCheckTimeout = _setTimeout(function() { - if (typeof _flashState.deactivated !== "boolean") { - _flashState.deactivated = true; - } - if (_flashState.deactivated === true) { - ZeroClipboard.emit({ - type: "error", - name: "flash-deactivated" - }); - } - }, maxWait); - } - _flashState.overdue = false; - _embedSwf(); - } - }; - /** - * The underlying implementation of `ZeroClipboard.destroy`. - * @private - */ - var _destroy = function() { - ZeroClipboard.clearData(); - ZeroClipboard.blur(); - ZeroClipboard.emit("destroy"); - _unembedSwf(); - ZeroClipboard.off(); - }; - /** - * The underlying implementation of `ZeroClipboard.setData`. - * @private - */ - var _setData = function(format, data) { - var dataObj; - if (typeof format === "object" && format && typeof data === "undefined") { - dataObj = format; - ZeroClipboard.clearData(); - } else if (typeof format === "string" && format) { - dataObj = {}; - dataObj[format] = data; - } else { - return; - } - for (var dataFormat in dataObj) { - if (typeof dataFormat === "string" && dataFormat && _hasOwn.call(dataObj, dataFormat) && typeof dataObj[dataFormat] === "string" && dataObj[dataFormat]) { - _clipData[dataFormat] = dataObj[dataFormat]; - } - } - }; - /** - * The underlying implementation of `ZeroClipboard.clearData`. - * @private - */ - var _clearData = function(format) { - if (typeof format === "undefined") { - _deleteOwnProperties(_clipData); - _clipDataFormatMap = null; - } else if (typeof format === "string" && _hasOwn.call(_clipData, format)) { - delete _clipData[format]; - } - }; - /** - * The underlying implementation of `ZeroClipboard.getData`. - * @private - */ - var _getData = function(format) { - if (typeof format === "undefined") { - return _deepCopy(_clipData); - } else if (typeof format === "string" && _hasOwn.call(_clipData, format)) { - return _clipData[format]; - } - }; - /** - * The underlying implementation of `ZeroClipboard.focus`/`ZeroClipboard.activate`. - * @private - */ - var _focus = function(element) { - if (!(element && element.nodeType === 1)) { - return; - } - if (_currentElement) { - _removeClass(_currentElement, _globalConfig.activeClass); - if (_currentElement !== element) { - _removeClass(_currentElement, _globalConfig.hoverClass); - } - } - _currentElement = element; - _addClass(element, _globalConfig.hoverClass); - var newTitle = element.getAttribute("title") || _globalConfig.title; - if (typeof newTitle === "string" && newTitle) { - var htmlBridge = _getHtmlBridge(_flashState.bridge); - if (htmlBridge) { - htmlBridge.setAttribute("title", newTitle); - } - } - var useHandCursor = _globalConfig.forceHandCursor === true || _getStyle(element, "cursor") === "pointer"; - _setHandCursor(useHandCursor); - _reposition(); - }; - /** - * The underlying implementation of `ZeroClipboard.blur`/`ZeroClipboard.deactivate`. - * @private - */ - var _blur = function() { - var htmlBridge = _getHtmlBridge(_flashState.bridge); - if (htmlBridge) { - htmlBridge.removeAttribute("title"); - htmlBridge.style.left = "0px"; - htmlBridge.style.top = "-9999px"; - htmlBridge.style.width = "1px"; - htmlBridge.style.height = "1px"; - } - if (_currentElement) { - _removeClass(_currentElement, _globalConfig.hoverClass); - _removeClass(_currentElement, _globalConfig.activeClass); - _currentElement = null; - } - }; - /** - * The underlying implementation of `ZeroClipboard.activeElement`. - * @private - */ - var _activeElement = function() { - return _currentElement || null; - }; - /** - * Check if a value is a valid HTML4 `ID` or `Name` token. - * @private - */ - var _isValidHtml4Id = function(id) { - return typeof id === "string" && id && /^[A-Za-z][A-Za-z0-9_:\-\.]*$/.test(id); - }; - /** - * Create or update an `event` object, based on the `eventType`. - * @private - */ - var _createEvent = function(event) { - var eventType; - if (typeof event === "string" && event) { - eventType = event; - event = {}; - } else if (typeof event === "object" && event && typeof event.type === "string" && event.type) { - eventType = event.type; - } - if (!eventType) { - return; - } - eventType = eventType.toLowerCase(); - if (!event.target && (/^(copy|aftercopy|_click)$/.test(eventType) || eventType === "error" && event.name === "clipboard-error")) { - event.target = _copyTarget; - } - _extend(event, { - type: eventType, - target: event.target || _currentElement || null, - relatedTarget: event.relatedTarget || null, - currentTarget: _flashState && _flashState.bridge || null, - timeStamp: event.timeStamp || _now() || null - }); - var msg = _eventMessages[event.type]; - if (event.type === "error" && event.name && msg) { - msg = msg[event.name]; - } - if (msg) { - event.message = msg; - } - if (event.type === "ready") { - _extend(event, { - target: null, - version: _flashState.version - }); - } - if (event.type === "error") { - if (_flashStateErrorNameMatchingRegex.test(event.name)) { - _extend(event, { - target: null, - minimumVersion: _minimumFlashVersion - }); - } - if (_flashStateEnabledErrorNameMatchingRegex.test(event.name)) { - _extend(event, { - version: _flashState.version - }); - } - } - if (event.type === "copy") { - event.clipboardData = { - setData: ZeroClipboard.setData, - clearData: ZeroClipboard.clearData - }; - } - if (event.type === "aftercopy") { - event = _mapClipResultsFromFlash(event, _clipDataFormatMap); - } - if (event.target && !event.relatedTarget) { - event.relatedTarget = _getRelatedTarget(event.target); - } - return _addMouseData(event); - }; - /** - * Get a relatedTarget from the target's `data-clipboard-target` attribute - * @private - */ - var _getRelatedTarget = function(targetEl) { - var relatedTargetId = targetEl && targetEl.getAttribute && targetEl.getAttribute("data-clipboard-target"); - return relatedTargetId ? _document.getElementById(relatedTargetId) : null; - }; - /** - * Add element and position data to `MouseEvent` instances - * @private - */ - var _addMouseData = function(event) { - if (event && /^_(?:click|mouse(?:over|out|down|up|move))$/.test(event.type)) { - var srcElement = event.target; - var fromElement = event.type === "_mouseover" && event.relatedTarget ? event.relatedTarget : undefined; - var toElement = event.type === "_mouseout" && event.relatedTarget ? event.relatedTarget : undefined; - var pos = _getElementPosition(srcElement); - var screenLeft = _window.screenLeft || _window.screenX || 0; - var screenTop = _window.screenTop || _window.screenY || 0; - var scrollLeft = _document.body.scrollLeft + _document.documentElement.scrollLeft; - var scrollTop = _document.body.scrollTop + _document.documentElement.scrollTop; - var pageX = pos.left + (typeof event._stageX === "number" ? event._stageX : 0); - var pageY = pos.top + (typeof event._stageY === "number" ? event._stageY : 0); - var clientX = pageX - scrollLeft; - var clientY = pageY - scrollTop; - var screenX = screenLeft + clientX; - var screenY = screenTop + clientY; - var moveX = typeof event.movementX === "number" ? event.movementX : 0; - var moveY = typeof event.movementY === "number" ? event.movementY : 0; - delete event._stageX; - delete event._stageY; - _extend(event, { - srcElement: srcElement, - fromElement: fromElement, - toElement: toElement, - screenX: screenX, - screenY: screenY, - pageX: pageX, - pageY: pageY, - clientX: clientX, - clientY: clientY, - x: clientX, - y: clientY, - movementX: moveX, - movementY: moveY, - offsetX: 0, - offsetY: 0, - layerX: 0, - layerY: 0 - }); - } - return event; - }; - /** - * Determine if an event's registered handlers should be execute synchronously or asynchronously. - * - * @returns {boolean} - * @private - */ - var _shouldPerformAsync = function(event) { - var eventType = event && typeof event.type === "string" && event.type || ""; - return !/^(?:(?:before)?copy|destroy)$/.test(eventType); - }; - /** - * Control if a callback should be executed asynchronously or not. - * - * @returns `undefined` - * @private - */ - var _dispatchCallback = function(func, context, args, async) { - if (async) { - _setTimeout(function() { - func.apply(context, args); - }, 0); - } else { - func.apply(context, args); - } - }; - /** - * Handle the actual dispatching of events to client instances. - * - * @returns `undefined` - * @private - */ - var _dispatchCallbacks = function(event) { - if (!(typeof event === "object" && event && event.type)) { - return; - } - var async = _shouldPerformAsync(event); - var wildcardTypeHandlers = _handlers["*"] || []; - var specificTypeHandlers = _handlers[event.type] || []; - var handlers = wildcardTypeHandlers.concat(specificTypeHandlers); - if (handlers && handlers.length) { - var i, len, func, context, eventCopy, originalContext = this; - for (i = 0, len = handlers.length; i < len; i++) { - func = handlers[i]; - context = originalContext; - if (typeof func === "string" && typeof _window[func] === "function") { - func = _window[func]; - } - if (typeof func === "object" && func && typeof func.handleEvent === "function") { - context = func; - func = func.handleEvent; - } - if (typeof func === "function") { - eventCopy = _extend({}, event); - _dispatchCallback(func, context, [ eventCopy ], async); - } - } - } - return this; - }; - /** - * Check an `error` event's `name` property to see if Flash has - * already loaded, which rules out possible `iframe` sandboxing. - * @private - */ - var _getSandboxStatusFromErrorEvent = function(event) { - var isSandboxed = null; - if (_pageIsFramed === false || event && event.type === "error" && event.name && _errorsThatOnlyOccurAfterFlashLoads.indexOf(event.name) !== -1) { - isSandboxed = false; - } - return isSandboxed; - }; - /** - * Preprocess any special behaviors, reactions, or state changes after receiving this event. - * Executes only once per event emitted, NOT once per client. - * @private - */ - var _preprocessEvent = function(event) { - var element = event.target || _currentElement || null; - var sourceIsSwf = event._source === "swf"; - delete event._source; - switch (event.type) { - case "error": - var isSandboxed = event.name === "flash-sandboxed" || _getSandboxStatusFromErrorEvent(event); - if (typeof isSandboxed === "boolean") { - _flashState.sandboxed = isSandboxed; - } - if (_flashStateErrorNames.indexOf(event.name) !== -1) { - _extend(_flashState, { - disabled: event.name === "flash-disabled", - outdated: event.name === "flash-outdated", - unavailable: event.name === "flash-unavailable", - degraded: event.name === "flash-degraded", - deactivated: event.name === "flash-deactivated", - overdue: event.name === "flash-overdue", - ready: false - }); - } else if (event.name === "version-mismatch") { - _zcSwfVersion = event.swfVersion; - _extend(_flashState, { - disabled: false, - outdated: false, - unavailable: false, - degraded: false, - deactivated: false, - overdue: false, - ready: false - }); - } - _clearTimeoutsAndPolling(); - break; - - case "ready": - _zcSwfVersion = event.swfVersion; - var wasDeactivated = _flashState.deactivated === true; - _extend(_flashState, { - disabled: false, - outdated: false, - sandboxed: false, - unavailable: false, - degraded: false, - deactivated: false, - overdue: wasDeactivated, - ready: !wasDeactivated - }); - _clearTimeoutsAndPolling(); - break; - - case "beforecopy": - _copyTarget = element; - break; - - case "copy": - var textContent, htmlContent, targetEl = event.relatedTarget; - if (!(_clipData["text/html"] || _clipData["text/plain"]) && targetEl && (htmlContent = targetEl.value || targetEl.outerHTML || targetEl.innerHTML) && (textContent = targetEl.value || targetEl.textContent || targetEl.innerText)) { - event.clipboardData.clearData(); - event.clipboardData.setData("text/plain", textContent); - if (htmlContent !== textContent) { - event.clipboardData.setData("text/html", htmlContent); - } - } else if (!_clipData["text/plain"] && event.target && (textContent = event.target.getAttribute("data-clipboard-text"))) { - event.clipboardData.clearData(); - event.clipboardData.setData("text/plain", textContent); - } - break; - - case "aftercopy": - _queueEmitClipboardErrors(event); - ZeroClipboard.clearData(); - if (element && element !== _safeActiveElement() && element.focus) { - element.focus(); - } - break; - - case "_mouseover": - ZeroClipboard.focus(element); - if (_globalConfig.bubbleEvents === true && sourceIsSwf) { - if (element && element !== event.relatedTarget && !_containedBy(event.relatedTarget, element)) { - _fireMouseEvent(_extend({}, event, { - type: "mouseenter", - bubbles: false, - cancelable: false - })); - } - _fireMouseEvent(_extend({}, event, { - type: "mouseover" - })); - } - break; - - case "_mouseout": - ZeroClipboard.blur(); - if (_globalConfig.bubbleEvents === true && sourceIsSwf) { - if (element && element !== event.relatedTarget && !_containedBy(event.relatedTarget, element)) { - _fireMouseEvent(_extend({}, event, { - type: "mouseleave", - bubbles: false, - cancelable: false - })); - } - _fireMouseEvent(_extend({}, event, { - type: "mouseout" - })); - } - break; - - case "_mousedown": - _addClass(element, _globalConfig.activeClass); - if (_globalConfig.bubbleEvents === true && sourceIsSwf) { - _fireMouseEvent(_extend({}, event, { - type: event.type.slice(1) - })); - } - break; - - case "_mouseup": - _removeClass(element, _globalConfig.activeClass); - if (_globalConfig.bubbleEvents === true && sourceIsSwf) { - _fireMouseEvent(_extend({}, event, { - type: event.type.slice(1) - })); - } - break; - - case "_click": - _copyTarget = null; - if (_globalConfig.bubbleEvents === true && sourceIsSwf) { - _fireMouseEvent(_extend({}, event, { - type: event.type.slice(1) - })); - } - break; - - case "_mousemove": - if (_globalConfig.bubbleEvents === true && sourceIsSwf) { - _fireMouseEvent(_extend({}, event, { - type: event.type.slice(1) - })); - } - break; - } - if (/^_(?:click|mouse(?:over|out|down|up|move))$/.test(event.type)) { - return true; - } - }; - /** - * Check an "aftercopy" event for clipboard errors and emit a corresponding "error" event. - * @private - */ - var _queueEmitClipboardErrors = function(aftercopyEvent) { - if (aftercopyEvent.errors && aftercopyEvent.errors.length > 0) { - var errorEvent = _deepCopy(aftercopyEvent); - _extend(errorEvent, { - type: "error", - name: "clipboard-error" - }); - delete errorEvent.success; - _setTimeout(function() { - ZeroClipboard.emit(errorEvent); - }, 0); - } - }; - /** - * Dispatch a synthetic MouseEvent. - * - * @returns `undefined` - * @private - */ - var _fireMouseEvent = function(event) { - if (!(event && typeof event.type === "string" && event)) { - return; - } - var e, target = event.target || null, doc = target && target.ownerDocument || _document, defaults = { - view: doc.defaultView || _window, - canBubble: true, - cancelable: true, - detail: event.type === "click" ? 1 : 0, - button: typeof event.which === "number" ? event.which - 1 : typeof event.button === "number" ? event.button : doc.createEvent ? 0 : 1 - }, args = _extend(defaults, event); - if (!target) { - return; - } - if (doc.createEvent && target.dispatchEvent) { - args = [ args.type, args.canBubble, args.cancelable, args.view, args.detail, args.screenX, args.screenY, args.clientX, args.clientY, args.ctrlKey, args.altKey, args.shiftKey, args.metaKey, args.button, args.relatedTarget ]; - e = doc.createEvent("MouseEvents"); - if (e.initMouseEvent) { - e.initMouseEvent.apply(e, args); - e._source = "js"; - target.dispatchEvent(e); - } - } - }; - /** - * Continuously poll the DOM until either: - * (a) the fallback content becomes visible, or - * (b) we receive an event from SWF (handled elsewhere) - * - * IMPORTANT: - * This is NOT a necessary check but it can result in significantly faster - * detection of bad `swfPath` configuration and/or network/server issues [in - * supported browsers] than waiting for the entire `flashLoadTimeout` duration - * to elapse before detecting that the SWF cannot be loaded. The detection - * duration can be anywhere from 10-30 times faster [in supported browsers] by - * using this approach. - * - * @returns `undefined` - * @private - */ - var _watchForSwfFallbackContent = function() { - var maxWait = _globalConfig.flashLoadTimeout; - if (typeof maxWait === "number" && maxWait >= 0) { - var pollWait = Math.min(1e3, maxWait / 10); - var fallbackContentId = _globalConfig.swfObjectId + "_fallbackContent"; - _swfFallbackCheckInterval = _setInterval(function() { - var el = _document.getElementById(fallbackContentId); - if (_isElementVisible(el)) { - _clearTimeoutsAndPolling(); - _flashState.deactivated = null; - ZeroClipboard.emit({ - type: "error", - name: "swf-not-found" - }); - } - }, pollWait); - } - }; - /** - * Create the HTML bridge element to embed the Flash object into. - * @private - */ - var _createHtmlBridge = function() { - var container = _document.createElement("div"); - container.id = _globalConfig.containerId; - container.className = _globalConfig.containerClass; - container.style.position = "absolute"; - container.style.left = "0px"; - container.style.top = "-9999px"; - container.style.width = "1px"; - container.style.height = "1px"; - container.style.zIndex = "" + _getSafeZIndex(_globalConfig.zIndex); - return container; - }; - /** - * Get the HTML element container that wraps the Flash bridge object/element. - * @private - */ - var _getHtmlBridge = function(flashBridge) { - var htmlBridge = flashBridge && flashBridge.parentNode; - while (htmlBridge && htmlBridge.nodeName === "OBJECT" && htmlBridge.parentNode) { - htmlBridge = htmlBridge.parentNode; - } - return htmlBridge || null; - }; - /** - * Create the SWF object. - * - * @returns The SWF object reference. - * @private - */ - var _embedSwf = function() { - var len, flashBridge = _flashState.bridge, container = _getHtmlBridge(flashBridge); - if (!flashBridge) { - var allowScriptAccess = _determineScriptAccess(_window.location.host, _globalConfig); - var allowNetworking = allowScriptAccess === "never" ? "none" : "all"; - var flashvars = _vars(_extend({ - jsVersion: ZeroClipboard.version - }, _globalConfig)); - var swfUrl = _globalConfig.swfPath + _cacheBust(_globalConfig.swfPath, _globalConfig); - container = _createHtmlBridge(); - var divToBeReplaced = _document.createElement("div"); - container.appendChild(divToBeReplaced); - _document.body.appendChild(container); - var tmpDiv = _document.createElement("div"); - var usingActiveX = _flashState.pluginType === "activex"; - tmpDiv.innerHTML = '<object id="' + _globalConfig.swfObjectId + '" name="' + _globalConfig.swfObjectId + '" ' + 'width="100%" height="100%" ' + (usingActiveX ? 'classid="clsid:d27cdb6e-ae6d-11cf-96b8-444553540000"' : 'type="application/x-shockwave-flash" data="' + swfUrl + '"') + ">" + (usingActiveX ? '<param name="movie" value="' + swfUrl + '"/>' : "") + '<param name="allowScriptAccess" value="' + allowScriptAccess + '"/>' + '<param name="allowNetworking" value="' + allowNetworking + '"/>' + '<param name="menu" value="false"/>' + '<param name="wmode" value="transparent"/>' + '<param name="flashvars" value="' + flashvars + '"/>' + '<div id="' + _globalConfig.swfObjectId + '_fallbackContent"> </div>' + "</object>"; - flashBridge = tmpDiv.firstChild; - tmpDiv = null; - _unwrap(flashBridge).ZeroClipboard = ZeroClipboard; - container.replaceChild(flashBridge, divToBeReplaced); - _watchForSwfFallbackContent(); - } - if (!flashBridge) { - flashBridge = _document[_globalConfig.swfObjectId]; - if (flashBridge && (len = flashBridge.length)) { - flashBridge = flashBridge[len - 1]; - } - if (!flashBridge && container) { - flashBridge = container.firstChild; - } - } - _flashState.bridge = flashBridge || null; - return flashBridge; - }; - /** - * Destroy the SWF object. - * @private - */ - var _unembedSwf = function() { - var flashBridge = _flashState.bridge; - if (flashBridge) { - var htmlBridge = _getHtmlBridge(flashBridge); - if (htmlBridge) { - if (_flashState.pluginType === "activex" && "readyState" in flashBridge) { - flashBridge.style.display = "none"; - (function removeSwfFromIE() { - if (flashBridge.readyState === 4) { - for (var prop in flashBridge) { - if (typeof flashBridge[prop] === "function") { - flashBridge[prop] = null; - } - } - if (flashBridge.parentNode) { - flashBridge.parentNode.removeChild(flashBridge); - } - if (htmlBridge.parentNode) { - htmlBridge.parentNode.removeChild(htmlBridge); - } - } else { - _setTimeout(removeSwfFromIE, 10); - } - })(); - } else { - if (flashBridge.parentNode) { - flashBridge.parentNode.removeChild(flashBridge); - } - if (htmlBridge.parentNode) { - htmlBridge.parentNode.removeChild(htmlBridge); - } - } - } - _clearTimeoutsAndPolling(); - _flashState.ready = null; - _flashState.bridge = null; - _flashState.deactivated = null; - _zcSwfVersion = undefined; - } - }; - /** - * Map the data format names of the "clipData" to Flash-friendly names. - * - * @returns A new transformed object. - * @private - */ - var _mapClipDataToFlash = function(clipData) { - var newClipData = {}, formatMap = {}; - if (!(typeof clipData === "object" && clipData)) { - return; - } - for (var dataFormat in clipData) { - if (dataFormat && _hasOwn.call(clipData, dataFormat) && typeof clipData[dataFormat] === "string" && clipData[dataFormat]) { - switch (dataFormat.toLowerCase()) { - case "text/plain": - case "text": - case "air:text": - case "flash:text": - newClipData.text = clipData[dataFormat]; - formatMap.text = dataFormat; - break; - - case "text/html": - case "html": - case "air:html": - case "flash:html": - newClipData.html = clipData[dataFormat]; - formatMap.html = dataFormat; - break; - - case "application/rtf": - case "text/rtf": - case "rtf": - case "richtext": - case "air:rtf": - case "flash:rtf": - newClipData.rtf = clipData[dataFormat]; - formatMap.rtf = dataFormat; - break; - - default: - break; - } - } - } - return { - data: newClipData, - formatMap: formatMap - }; - }; - /** - * Map the data format names from Flash-friendly names back to their original "clipData" names (via a format mapping). - * - * @returns A new transformed object. - * @private - */ - var _mapClipResultsFromFlash = function(clipResults, formatMap) { - if (!(typeof clipResults === "object" && clipResults && typeof formatMap === "object" && formatMap)) { - return clipResults; - } - var newResults = {}; - for (var prop in clipResults) { - if (_hasOwn.call(clipResults, prop)) { - if (prop === "errors") { - newResults[prop] = clipResults[prop] ? clipResults[prop].slice() : []; - for (var i = 0, len = newResults[prop].length; i < len; i++) { - newResults[prop][i].format = formatMap[newResults[prop][i].format]; - } - } else if (prop !== "success" && prop !== "data") { - newResults[prop] = clipResults[prop]; - } else { - newResults[prop] = {}; - var tmpHash = clipResults[prop]; - for (var dataFormat in tmpHash) { - if (dataFormat && _hasOwn.call(tmpHash, dataFormat) && _hasOwn.call(formatMap, dataFormat)) { - newResults[prop][formatMap[dataFormat]] = tmpHash[dataFormat]; - } - } - } - } - } - return newResults; - }; - /** - * Will look at a path, and will create a "?noCache={time}" or "&noCache={time}" - * query param string to return. Does NOT append that string to the original path. - * This is useful because ExternalInterface often breaks when a Flash SWF is cached. - * - * @returns The `noCache` query param with necessary "?"/"&" prefix. - * @private - */ - var _cacheBust = function(path, options) { - var cacheBust = options == null || options && options.cacheBust === true; - if (cacheBust) { - return (path.indexOf("?") === -1 ? "?" : "&") + "noCache=" + _now(); - } else { - return ""; - } - }; - /** - * Creates a query string for the FlashVars param. - * Does NOT include the cache-busting query param. - * - * @returns FlashVars query string - * @private - */ - var _vars = function(options) { - var i, len, domain, domains, str = "", trustedOriginsExpanded = []; - if (options.trustedDomains) { - if (typeof options.trustedDomains === "string") { - domains = [ options.trustedDomains ]; - } else if (typeof options.trustedDomains === "object" && "length" in options.trustedDomains) { - domains = options.trustedDomains; - } - } - if (domains && domains.length) { - for (i = 0, len = domains.length; i < len; i++) { - if (_hasOwn.call(domains, i) && domains[i] && typeof domains[i] === "string") { - domain = _extractDomain(domains[i]); - if (!domain) { - continue; - } - if (domain === "*") { - trustedOriginsExpanded.length = 0; - trustedOriginsExpanded.push(domain); - break; - } - trustedOriginsExpanded.push.apply(trustedOriginsExpanded, [ domain, "//" + domain, _window.location.protocol + "//" + domain ]); - } - } - } - if (trustedOriginsExpanded.length) { - str += "trustedOrigins=" + _encodeURIComponent(trustedOriginsExpanded.join(",")); - } - if (options.forceEnhancedClipboard === true) { - str += (str ? "&" : "") + "forceEnhancedClipboard=true"; - } - if (typeof options.swfObjectId === "string" && options.swfObjectId) { - str += (str ? "&" : "") + "swfObjectId=" + _encodeURIComponent(options.swfObjectId); - } - if (typeof options.jsVersion === "string" && options.jsVersion) { - str += (str ? "&" : "") + "jsVersion=" + _encodeURIComponent(options.jsVersion); - } - return str; - }; - /** - * Extract the domain (e.g. "github.com") from an origin (e.g. "https://github.com") or - * URL (e.g. "https://github.com/zeroclipboard/zeroclipboard/"). - * - * @returns the domain - * @private - */ - var _extractDomain = function(originOrUrl) { - if (originOrUrl == null || originOrUrl === "") { - return null; - } - originOrUrl = originOrUrl.replace(/^\s+|\s+$/g, ""); - if (originOrUrl === "") { - return null; - } - var protocolIndex = originOrUrl.indexOf("//"); - originOrUrl = protocolIndex === -1 ? originOrUrl : originOrUrl.slice(protocolIndex + 2); - var pathIndex = originOrUrl.indexOf("/"); - originOrUrl = pathIndex === -1 ? originOrUrl : protocolIndex === -1 || pathIndex === 0 ? null : originOrUrl.slice(0, pathIndex); - if (originOrUrl && originOrUrl.slice(-4).toLowerCase() === ".swf") { - return null; - } - return originOrUrl || null; - }; - /** - * Set `allowScriptAccess` based on `trustedDomains` and `window.location.host` vs. `swfPath`. - * - * @returns The appropriate script access level. - * @private - */ - var _determineScriptAccess = function() { - var _extractAllDomains = function(origins) { - var i, len, tmp, resultsArray = []; - if (typeof origins === "string") { - origins = [ origins ]; - } - if (!(typeof origins === "object" && origins && typeof origins.length === "number")) { - return resultsArray; - } - for (i = 0, len = origins.length; i < len; i++) { - if (_hasOwn.call(origins, i) && (tmp = _extractDomain(origins[i]))) { - if (tmp === "*") { - resultsArray.length = 0; - resultsArray.push("*"); - break; - } - if (resultsArray.indexOf(tmp) === -1) { - resultsArray.push(tmp); - } - } - } - return resultsArray; - }; - return function(currentDomain, configOptions) { - var swfDomain = _extractDomain(configOptions.swfPath); - if (swfDomain === null) { - swfDomain = currentDomain; - } - var trustedDomains = _extractAllDomains(configOptions.trustedDomains); - var len = trustedDomains.length; - if (len > 0) { - if (len === 1 && trustedDomains[0] === "*") { - return "always"; - } - if (trustedDomains.indexOf(currentDomain) !== -1) { - if (len === 1 && currentDomain === swfDomain) { - return "sameDomain"; - } - return "always"; - } - } - return "never"; - }; - }(); - /** - * Get the currently active/focused DOM element. - * - * @returns the currently active/focused element, or `null` - * @private - */ - var _safeActiveElement = function() { - try { - return _document.activeElement; - } catch (err) { - return null; - } - }; - /** - * Add a class to an element, if it doesn't already have it. - * - * @returns The element, with its new class added. - * @private - */ - var _addClass = function(element, value) { - var c, cl, className, classNames = []; - if (typeof value === "string" && value) { - classNames = value.split(/\s+/); - } - if (element && element.nodeType === 1 && classNames.length > 0) { - if (element.classList) { - for (c = 0, cl = classNames.length; c < cl; c++) { - element.classList.add(classNames[c]); - } - } else if (element.hasOwnProperty("className")) { - className = " " + element.className + " "; - for (c = 0, cl = classNames.length; c < cl; c++) { - if (className.indexOf(" " + classNames[c] + " ") === -1) { - className += classNames[c] + " "; - } - } - element.className = className.replace(/^\s+|\s+$/g, ""); - } - } - return element; - }; - /** - * Remove a class from an element, if it has it. - * - * @returns The element, with its class removed. - * @private - */ - var _removeClass = function(element, value) { - var c, cl, className, classNames = []; - if (typeof value === "string" && value) { - classNames = value.split(/\s+/); - } - if (element && element.nodeType === 1 && classNames.length > 0) { - if (element.classList && element.classList.length > 0) { - for (c = 0, cl = classNames.length; c < cl; c++) { - element.classList.remove(classNames[c]); - } - } else if (element.className) { - className = (" " + element.className + " ").replace(/[\r\n\t]/g, " "); - for (c = 0, cl = classNames.length; c < cl; c++) { - className = className.replace(" " + classNames[c] + " ", " "); - } - element.className = className.replace(/^\s+|\s+$/g, ""); - } - } - return element; - }; - /** - * Attempt to interpret the element's CSS styling. If `prop` is `"cursor"`, - * then we assume that it should be a hand ("pointer") cursor if the element - * is an anchor element ("a" tag). - * - * @returns The computed style property. - * @private - */ - var _getStyle = function(el, prop) { - var value = _getComputedStyle(el, null).getPropertyValue(prop); - if (prop === "cursor") { - if (!value || value === "auto") { - if (el.nodeName === "A") { - return "pointer"; - } - } - } - return value; - }; - /** - * Get the absolutely positioned coordinates of a DOM element. - * - * @returns Object containing the element's position, width, and height. - * @private - */ - var _getElementPosition = function(el) { - var pos = { - left: 0, - top: 0, - width: 0, - height: 0 - }; - if (el.getBoundingClientRect) { - var elRect = el.getBoundingClientRect(); - var pageXOffset = _window.pageXOffset; - var pageYOffset = _window.pageYOffset; - var leftBorderWidth = _document.documentElement.clientLeft || 0; - var topBorderWidth = _document.documentElement.clientTop || 0; - var leftBodyOffset = 0; - var topBodyOffset = 0; - if (_getStyle(_document.body, "position") === "relative") { - var bodyRect = _document.body.getBoundingClientRect(); - var htmlRect = _document.documentElement.getBoundingClientRect(); - leftBodyOffset = bodyRect.left - htmlRect.left || 0; - topBodyOffset = bodyRect.top - htmlRect.top || 0; - } - pos.left = elRect.left + pageXOffset - leftBorderWidth - leftBodyOffset; - pos.top = elRect.top + pageYOffset - topBorderWidth - topBodyOffset; - pos.width = "width" in elRect ? elRect.width : elRect.right - elRect.left; - pos.height = "height" in elRect ? elRect.height : elRect.bottom - elRect.top; - } - return pos; - }; - /** - * Determine is an element is visible somewhere within the document (page). - * - * @returns Boolean - * @private - */ - var _isElementVisible = function(el) { - if (!el) { - return false; - } - var styles = _getComputedStyle(el, null); - var hasCssHeight = _parseFloat(styles.height) > 0; - var hasCssWidth = _parseFloat(styles.width) > 0; - var hasCssTop = _parseFloat(styles.top) >= 0; - var hasCssLeft = _parseFloat(styles.left) >= 0; - var cssKnows = hasCssHeight && hasCssWidth && hasCssTop && hasCssLeft; - var rect = cssKnows ? null : _getElementPosition(el); - var isVisible = styles.display !== "none" && styles.visibility !== "collapse" && (cssKnows || !!rect && (hasCssHeight || rect.height > 0) && (hasCssWidth || rect.width > 0) && (hasCssTop || rect.top >= 0) && (hasCssLeft || rect.left >= 0)); - return isVisible; - }; - /** - * Clear all existing timeouts and interval polling delegates. - * - * @returns `undefined` - * @private - */ - var _clearTimeoutsAndPolling = function() { - _clearTimeout(_flashCheckTimeout); - _flashCheckTimeout = 0; - _clearInterval(_swfFallbackCheckInterval); - _swfFallbackCheckInterval = 0; - }; - /** - * Reposition the Flash object to cover the currently activated element. - * - * @returns `undefined` - * @private - */ - var _reposition = function() { - var htmlBridge; - if (_currentElement && (htmlBridge = _getHtmlBridge(_flashState.bridge))) { - var pos = _getElementPosition(_currentElement); - _extend(htmlBridge.style, { - width: pos.width + "px", - height: pos.height + "px", - top: pos.top + "px", - left: pos.left + "px", - zIndex: "" + _getSafeZIndex(_globalConfig.zIndex) - }); - } - }; - /** - * Sends a signal to the Flash object to display the hand cursor if `true`. - * - * @returns `undefined` - * @private - */ - var _setHandCursor = function(enabled) { - if (_flashState.ready === true) { - if (_flashState.bridge && typeof _flashState.bridge.setHandCursor === "function") { - _flashState.bridge.setHandCursor(enabled); - } else { - _flashState.ready = false; - } - } - }; - /** - * Get a safe value for `zIndex` - * - * @returns an integer, or "auto" - * @private - */ - var _getSafeZIndex = function(val) { - if (/^(?:auto|inherit)$/.test(val)) { - return val; - } - var zIndex; - if (typeof val === "number" && !_isNaN(val)) { - zIndex = val; - } else if (typeof val === "string") { - zIndex = _getSafeZIndex(_parseInt(val, 10)); - } - return typeof zIndex === "number" ? zIndex : "auto"; - }; - /** - * Attempt to detect if ZeroClipboard is executing inside of a sandboxed iframe. - * If it is, Flash Player cannot be used, so ZeroClipboard is dead in the water. - * - * @see {@link http://lists.w3.org/Archives/Public/public-whatwg-archive/2014Dec/0002.html} - * @see {@link https://github.com/zeroclipboard/zeroclipboard/issues/511} - * @see {@link http://zeroclipboard.org/test-iframes.html} - * - * @returns `true` (is sandboxed), `false` (is not sandboxed), or `null` (uncertain) - * @private - */ - var _detectSandbox = function(doNotReassessFlashSupport) { - var effectiveScriptOrigin, frame, frameError, previousState = _flashState.sandboxed, isSandboxed = null; - doNotReassessFlashSupport = doNotReassessFlashSupport === true; - if (_pageIsFramed === false) { - isSandboxed = false; - } else { - try { - frame = window.frameElement || null; - } catch (e) { - frameError = { - name: e.name, - message: e.message - }; - } - if (frame && frame.nodeType === 1 && frame.nodeName === "IFRAME") { - try { - isSandboxed = frame.hasAttribute("sandbox"); - } catch (e) { - isSandboxed = null; - } - } else { - try { - effectiveScriptOrigin = document.domain || null; - } catch (e) { - effectiveScriptOrigin = null; - } - if (effectiveScriptOrigin === null || frameError && frameError.name === "SecurityError" && /(^|[\s\(\[@])sandbox(es|ed|ing|[\s\.,!\)\]@]|$)/.test(frameError.message.toLowerCase())) { - isSandboxed = true; - } - } - } - _flashState.sandboxed = isSandboxed; - if (previousState !== isSandboxed && !doNotReassessFlashSupport) { - _detectFlashSupport(_ActiveXObject); - } - return isSandboxed; - }; - /** - * Detect the Flash Player status, version, and plugin type. - * - * @see {@link https://code.google.com/p/doctype-mirror/wiki/ArticleDetectFlash#The_code} - * @see {@link http://stackoverflow.com/questions/12866060/detecting-pepper-ppapi-flash-with-javascript} - * - * @returns `undefined` - * @private - */ - var _detectFlashSupport = function(ActiveXObject) { - var plugin, ax, mimeType, hasFlash = false, isActiveX = false, isPPAPI = false, flashVersion = ""; - /** - * Derived from Apple's suggested sniffer. - * @param {String} desc e.g. "Shockwave Flash 7.0 r61" - * @returns {String} "7.0.61" - * @private - */ - function parseFlashVersion(desc) { - var matches = desc.match(/[\d]+/g); - matches.length = 3; - return matches.join("."); - } - function isPepperFlash(flashPlayerFileName) { - return !!flashPlayerFileName && (flashPlayerFileName = flashPlayerFileName.toLowerCase()) && (/^(pepflashplayer\.dll|libpepflashplayer\.so|pepperflashplayer\.plugin)$/.test(flashPlayerFileName) || flashPlayerFileName.slice(-13) === "chrome.plugin"); - } - function inspectPlugin(plugin) { - if (plugin) { - hasFlash = true; - if (plugin.version) { - flashVersion = parseFlashVersion(plugin.version); - } - if (!flashVersion && plugin.description) { - flashVersion = parseFlashVersion(plugin.description); - } - if (plugin.filename) { - isPPAPI = isPepperFlash(plugin.filename); - } - } - } - if (_navigator.plugins && _navigator.plugins.length) { - plugin = _navigator.plugins["Shockwave Flash"]; - inspectPlugin(plugin); - if (_navigator.plugins["Shockwave Flash 2.0"]) { - hasFlash = true; - flashVersion = "2.0.0.11"; - } - } else if (_navigator.mimeTypes && _navigator.mimeTypes.length) { - mimeType = _navigator.mimeTypes["application/x-shockwave-flash"]; - plugin = mimeType && mimeType.enabledPlugin; - inspectPlugin(plugin); - } else if (typeof ActiveXObject !== "undefined") { - isActiveX = true; - try { - ax = new ActiveXObject("ShockwaveFlash.ShockwaveFlash.7"); - hasFlash = true; - flashVersion = parseFlashVersion(ax.GetVariable("$version")); - } catch (e1) { - try { - ax = new ActiveXObject("ShockwaveFlash.ShockwaveFlash.6"); - hasFlash = true; - flashVersion = "6.0.21"; - } catch (e2) { - try { - ax = new ActiveXObject("ShockwaveFlash.ShockwaveFlash"); - hasFlash = true; - flashVersion = parseFlashVersion(ax.GetVariable("$version")); - } catch (e3) { - isActiveX = false; - } - } - } - } - _flashState.disabled = hasFlash !== true; - _flashState.outdated = flashVersion && _parseFloat(flashVersion) < _parseFloat(_minimumFlashVersion); - _flashState.version = flashVersion || "0.0.0"; - _flashState.pluginType = isPPAPI ? "pepper" : isActiveX ? "activex" : hasFlash ? "netscape" : "unknown"; - }; - /** - * Invoke the Flash detection algorithms immediately upon inclusion so we're not waiting later. - */ - _detectFlashSupport(_ActiveXObject); - /** - * Always assess the `sandboxed` state of the page at important Flash-related moments. - */ - _detectSandbox(true); - /** - * A shell constructor for `ZeroClipboard` client instances. - * - * @constructor - */ - var ZeroClipboard = function() { - if (!(this instanceof ZeroClipboard)) { - return new ZeroClipboard(); - } - if (typeof ZeroClipboard._createClient === "function") { - ZeroClipboard._createClient.apply(this, _args(arguments)); - } - }; - /** - * The ZeroClipboard library's version number. - * - * @static - * @readonly - * @property {string} - */ - _defineProperty(ZeroClipboard, "version", { - value: "2.2.0", - writable: false, - configurable: true, - enumerable: true - }); - /** - * Update or get a copy of the ZeroClipboard global configuration. - * Returns a copy of the current/updated configuration. - * - * @returns Object - * @static - */ - ZeroClipboard.config = function() { - return _config.apply(this, _args(arguments)); - }; - /** - * Diagnostic method that describes the state of the browser, Flash Player, and ZeroClipboard. - * - * @returns Object - * @static - */ - ZeroClipboard.state = function() { - return _state.apply(this, _args(arguments)); - }; - /** - * Check if Flash is unusable for any reason: disabled, outdated, deactivated, etc. - * - * @returns Boolean - * @static - */ - ZeroClipboard.isFlashUnusable = function() { - return _isFlashUnusable.apply(this, _args(arguments)); - }; - /** - * Register an event listener. - * - * @returns `ZeroClipboard` - * @static - */ - ZeroClipboard.on = function() { - return _on.apply(this, _args(arguments)); - }; - /** - * Unregister an event listener. - * If no `listener` function/object is provided, it will unregister all listeners for the provided `eventType`. - * If no `eventType` is provided, it will unregister all listeners for every event type. - * - * @returns `ZeroClipboard` - * @static - */ - ZeroClipboard.off = function() { - return _off.apply(this, _args(arguments)); - }; - /** - * Retrieve event listeners for an `eventType`. - * If no `eventType` is provided, it will retrieve all listeners for every event type. - * - * @returns array of listeners for the `eventType`; if no `eventType`, then a map/hash object of listeners for all event types; or `null` - */ - ZeroClipboard.handlers = function() { - return _listeners.apply(this, _args(arguments)); - }; - /** - * Event emission receiver from the Flash object, forwarding to any registered JavaScript event listeners. - * - * @returns For the "copy" event, returns the Flash-friendly "clipData" object; otherwise `undefined`. - * @static - */ - ZeroClipboard.emit = function() { - return _emit.apply(this, _args(arguments)); - }; - /** - * Create and embed the Flash object. - * - * @returns The Flash object - * @static - */ - ZeroClipboard.create = function() { - return _create.apply(this, _args(arguments)); - }; - /** - * Self-destruct and clean up everything, including the embedded Flash object. - * - * @returns `undefined` - * @static - */ - ZeroClipboard.destroy = function() { - return _destroy.apply(this, _args(arguments)); - }; - /** - * Set the pending data for clipboard injection. - * - * @returns `undefined` - * @static - */ - ZeroClipboard.setData = function() { - return _setData.apply(this, _args(arguments)); - }; - /** - * Clear the pending data for clipboard injection. - * If no `format` is provided, all pending data formats will be cleared. - * - * @returns `undefined` - * @static - */ - ZeroClipboard.clearData = function() { - return _clearData.apply(this, _args(arguments)); - }; - /** - * Get a copy of the pending data for clipboard injection. - * If no `format` is provided, a copy of ALL pending data formats will be returned. - * - * @returns `String` or `Object` - * @static - */ - ZeroClipboard.getData = function() { - return _getData.apply(this, _args(arguments)); - }; - /** - * Sets the current HTML object that the Flash object should overlay. This will put the global - * Flash object on top of the current element; depending on the setup, this may also set the - * pending clipboard text data as well as the Flash object's wrapping element's title attribute - * based on the underlying HTML element and ZeroClipboard configuration. - * - * @returns `undefined` - * @static - */ - ZeroClipboard.focus = ZeroClipboard.activate = function() { - return _focus.apply(this, _args(arguments)); - }; - /** - * Un-overlays the Flash object. This will put the global Flash object off-screen; depending on - * the setup, this may also unset the Flash object's wrapping element's title attribute based on - * the underlying HTML element and ZeroClipboard configuration. - * - * @returns `undefined` - * @static - */ - ZeroClipboard.blur = ZeroClipboard.deactivate = function() { - return _blur.apply(this, _args(arguments)); - }; - /** - * Returns the currently focused/"activated" HTML element that the Flash object is wrapping. - * - * @returns `HTMLElement` or `null` - * @static - */ - ZeroClipboard.activeElement = function() { - return _activeElement.apply(this, _args(arguments)); - }; - /** - * Keep track of the ZeroClipboard client instance counter. - */ - var _clientIdCounter = 0; - /** - * Keep track of the state of the client instances. - * - * Entry structure: - * _clientMeta[client.id] = { - * instance: client, - * elements: [], - * handlers: {} - * }; - */ - var _clientMeta = {}; - /** - * Keep track of the ZeroClipboard clipped elements counter. - */ - var _elementIdCounter = 0; - /** - * Keep track of the state of the clipped element relationships to clients. - * - * Entry structure: - * _elementMeta[element.zcClippingId] = [client1.id, client2.id]; - */ - var _elementMeta = {}; - /** - * Keep track of the state of the mouse event handlers for clipped elements. - * - * Entry structure: - * _mouseHandlers[element.zcClippingId] = { - * mouseover: function(event) {}, - * mouseout: function(event) {}, - * mouseenter: function(event) {}, - * mouseleave: function(event) {}, - * mousemove: function(event) {} - * }; - */ - var _mouseHandlers = {}; - /** - * Extending the ZeroClipboard configuration defaults for the Client module. - */ - _extend(_globalConfig, { - autoActivate: true - }); - /** - * The real constructor for `ZeroClipboard` client instances. - * @private - */ - var _clientConstructor = function(elements) { - var client = this; - client.id = "" + _clientIdCounter++; - _clientMeta[client.id] = { - instance: client, - elements: [], - handlers: {} - }; - if (elements) { - client.clip(elements); - } - ZeroClipboard.on("*", function(event) { - return client.emit(event); - }); - ZeroClipboard.on("destroy", function() { - client.destroy(); - }); - ZeroClipboard.create(); - }; - /** - * The underlying implementation of `ZeroClipboard.Client.prototype.on`. - * @private - */ - var _clientOn = function(eventType, listener) { - var i, len, events, added = {}, meta = _clientMeta[this.id], handlers = meta && meta.handlers; - if (!meta) { - throw new Error("Attempted to add new listener(s) to a destroyed ZeroClipboard client instance"); - } - if (typeof eventType === "string" && eventType) { - events = eventType.toLowerCase().split(/\s+/); - } else if (typeof eventType === "object" && eventType && typeof listener === "undefined") { - for (i in eventType) { - if (_hasOwn.call(eventType, i) && typeof i === "string" && i && typeof eventType[i] === "function") { - this.on(i, eventType[i]); - } - } - } - if (events && events.length) { - for (i = 0, len = events.length; i < len; i++) { - eventType = events[i].replace(/^on/, ""); - added[eventType] = true; - if (!handlers[eventType]) { - handlers[eventType] = []; - } - handlers[eventType].push(listener); - } - if (added.ready && _flashState.ready) { - this.emit({ - type: "ready", - client: this - }); - } - if (added.error) { - for (i = 0, len = _flashStateErrorNames.length; i < len; i++) { - if (_flashState[_flashStateErrorNames[i].replace(/^flash-/, "")]) { - this.emit({ - type: "error", - name: _flashStateErrorNames[i], - client: this - }); - break; - } - } - if (_zcSwfVersion !== undefined && ZeroClipboard.version !== _zcSwfVersion) { - this.emit({ - type: "error", - name: "version-mismatch", - jsVersion: ZeroClipboard.version, - swfVersion: _zcSwfVersion - }); - } - } - } - return this; - }; - /** - * The underlying implementation of `ZeroClipboard.Client.prototype.off`. - * @private - */ - var _clientOff = function(eventType, listener) { - var i, len, foundIndex, events, perEventHandlers, meta = _clientMeta[this.id], handlers = meta && meta.handlers; - if (!handlers) { - return this; - } - if (arguments.length === 0) { - events = _keys(handlers); - } else if (typeof eventType === "string" && eventType) { - events = eventType.split(/\s+/); - } else if (typeof eventType === "object" && eventType && typeof listener === "undefined") { - for (i in eventType) { - if (_hasOwn.call(eventType, i) && typeof i === "string" && i && typeof eventType[i] === "function") { - this.off(i, eventType[i]); - } - } - } - if (events && events.length) { - for (i = 0, len = events.length; i < len; i++) { - eventType = events[i].toLowerCase().replace(/^on/, ""); - perEventHandlers = handlers[eventType]; - if (perEventHandlers && perEventHandlers.length) { - if (listener) { - foundIndex = perEventHandlers.indexOf(listener); - while (foundIndex !== -1) { - perEventHandlers.splice(foundIndex, 1); - foundIndex = perEventHandlers.indexOf(listener, foundIndex); - } - } else { - perEventHandlers.length = 0; - } - } - } - } - return this; - }; - /** - * The underlying implementation of `ZeroClipboard.Client.prototype.handlers`. - * @private - */ - var _clientListeners = function(eventType) { - var copy = null, handlers = _clientMeta[this.id] && _clientMeta[this.id].handlers; - if (handlers) { - if (typeof eventType === "string" && eventType) { - copy = handlers[eventType] ? handlers[eventType].slice(0) : []; - } else { - copy = _deepCopy(handlers); - } - } - return copy; - }; - /** - * The underlying implementation of `ZeroClipboard.Client.prototype.emit`. - * @private - */ - var _clientEmit = function(event) { - if (_clientShouldEmit.call(this, event)) { - if (typeof event === "object" && event && typeof event.type === "string" && event.type) { - event = _extend({}, event); - } - var eventCopy = _extend({}, _createEvent(event), { - client: this - }); - _clientDispatchCallbacks.call(this, eventCopy); - } - return this; - }; - /** - * The underlying implementation of `ZeroClipboard.Client.prototype.clip`. - * @private - */ - var _clientClip = function(elements) { - if (!_clientMeta[this.id]) { - throw new Error("Attempted to clip element(s) to a destroyed ZeroClipboard client instance"); - } - elements = _prepClip(elements); - for (var i = 0; i < elements.length; i++) { - if (_hasOwn.call(elements, i) && elements[i] && elements[i].nodeType === 1) { - if (!elements[i].zcClippingId) { - elements[i].zcClippingId = "zcClippingId_" + _elementIdCounter++; - _elementMeta[elements[i].zcClippingId] = [ this.id ]; - if (_globalConfig.autoActivate === true) { - _addMouseHandlers(elements[i]); - } - } else if (_elementMeta[elements[i].zcClippingId].indexOf(this.id) === -1) { - _elementMeta[elements[i].zcClippingId].push(this.id); - } - var clippedElements = _clientMeta[this.id] && _clientMeta[this.id].elements; - if (clippedElements.indexOf(elements[i]) === -1) { - clippedElements.push(elements[i]); - } - } - } - return this; - }; - /** - * The underlying implementation of `ZeroClipboard.Client.prototype.unclip`. - * @private - */ - var _clientUnclip = function(elements) { - var meta = _clientMeta[this.id]; - if (!meta) { - return this; - } - var clippedElements = meta.elements; - var arrayIndex; - if (typeof elements === "undefined") { - elements = clippedElements.slice(0); - } else { - elements = _prepClip(elements); - } - for (var i = elements.length; i--; ) { - if (_hasOwn.call(elements, i) && elements[i] && elements[i].nodeType === 1) { - arrayIndex = 0; - while ((arrayIndex = clippedElements.indexOf(elements[i], arrayIndex)) !== -1) { - clippedElements.splice(arrayIndex, 1); - } - var clientIds = _elementMeta[elements[i].zcClippingId]; - if (clientIds) { - arrayIndex = 0; - while ((arrayIndex = clientIds.indexOf(this.id, arrayIndex)) !== -1) { - clientIds.splice(arrayIndex, 1); - } - if (clientIds.length === 0) { - if (_globalConfig.autoActivate === true) { - _removeMouseHandlers(elements[i]); - } - delete elements[i].zcClippingId; - } - } - } - } - return this; - }; - /** - * The underlying implementation of `ZeroClipboard.Client.prototype.elements`. - * @private - */ - var _clientElements = function() { - var meta = _clientMeta[this.id]; - return meta && meta.elements ? meta.elements.slice(0) : []; - }; - /** - * The underlying implementation of `ZeroClipboard.Client.prototype.destroy`. - * @private - */ - var _clientDestroy = function() { - if (!_clientMeta[this.id]) { - return; - } - this.unclip(); - this.off(); - delete _clientMeta[this.id]; - }; - /** - * Inspect an Event to see if the Client (`this`) should honor it for emission. - * @private - */ - var _clientShouldEmit = function(event) { - if (!(event && event.type)) { - return false; - } - if (event.client && event.client !== this) { - return false; - } - var meta = _clientMeta[this.id]; - var clippedEls = meta && meta.elements; - var hasClippedEls = !!clippedEls && clippedEls.length > 0; - var goodTarget = !event.target || hasClippedEls && clippedEls.indexOf(event.target) !== -1; - var goodRelTarget = event.relatedTarget && hasClippedEls && clippedEls.indexOf(event.relatedTarget) !== -1; - var goodClient = event.client && event.client === this; - if (!meta || !(goodTarget || goodRelTarget || goodClient)) { - return false; - } - return true; - }; - /** - * Handle the actual dispatching of events to a client instance. - * - * @returns `undefined` - * @private - */ - var _clientDispatchCallbacks = function(event) { - var meta = _clientMeta[this.id]; - if (!(typeof event === "object" && event && event.type && meta)) { - return; - } - var async = _shouldPerformAsync(event); - var wildcardTypeHandlers = meta && meta.handlers["*"] || []; - var specificTypeHandlers = meta && meta.handlers[event.type] || []; - var handlers = wildcardTypeHandlers.concat(specificTypeHandlers); - if (handlers && handlers.length) { - var i, len, func, context, eventCopy, originalContext = this; - for (i = 0, len = handlers.length; i < len; i++) { - func = handlers[i]; - context = originalContext; - if (typeof func === "string" && typeof _window[func] === "function") { - func = _window[func]; - } - if (typeof func === "object" && func && typeof func.handleEvent === "function") { - context = func; - func = func.handleEvent; - } - if (typeof func === "function") { - eventCopy = _extend({}, event); - _dispatchCallback(func, context, [ eventCopy ], async); - } - } - } - }; - /** - * Prepares the elements for clipping/unclipping. - * - * @returns An Array of elements. - * @private - */ - var _prepClip = function(elements) { - if (typeof elements === "string") { - elements = []; - } - return typeof elements.length !== "number" ? [ elements ] : elements; - }; - /** - * Add a `mouseover` handler function for a clipped element. - * - * @returns `undefined` - * @private - */ - var _addMouseHandlers = function(element) { - if (!(element && element.nodeType === 1)) { - return; - } - var _suppressMouseEvents = function(event) { - if (!(event || (event = _window.event))) { - return; - } - if (event._source !== "js") { - event.stopImmediatePropagation(); - event.preventDefault(); - } - delete event._source; - }; - var _elementMouseOver = function(event) { - if (!(event || (event = _window.event))) { - return; - } - _suppressMouseEvents(event); - ZeroClipboard.focus(element); - }; - element.addEventListener("mouseover", _elementMouseOver, false); - element.addEventListener("mouseout", _suppressMouseEvents, false); - element.addEventListener("mouseenter", _suppressMouseEvents, false); - element.addEventListener("mouseleave", _suppressMouseEvents, false); - element.addEventListener("mousemove", _suppressMouseEvents, false); - _mouseHandlers[element.zcClippingId] = { - mouseover: _elementMouseOver, - mouseout: _suppressMouseEvents, - mouseenter: _suppressMouseEvents, - mouseleave: _suppressMouseEvents, - mousemove: _suppressMouseEvents - }; - }; - /** - * Remove a `mouseover` handler function for a clipped element. - * - * @returns `undefined` - * @private - */ - var _removeMouseHandlers = function(element) { - if (!(element && element.nodeType === 1)) { - return; - } - var mouseHandlers = _mouseHandlers[element.zcClippingId]; - if (!(typeof mouseHandlers === "object" && mouseHandlers)) { - return; - } - var key, val, mouseEvents = [ "move", "leave", "enter", "out", "over" ]; - for (var i = 0, len = mouseEvents.length; i < len; i++) { - key = "mouse" + mouseEvents[i]; - val = mouseHandlers[key]; - if (typeof val === "function") { - element.removeEventListener(key, val, false); - } - } - delete _mouseHandlers[element.zcClippingId]; - }; - /** - * Creates a new ZeroClipboard client instance. - * Optionally, auto-`clip` an element or collection of elements. - * - * @constructor - */ - ZeroClipboard._createClient = function() { - _clientConstructor.apply(this, _args(arguments)); - }; - /** - * Register an event listener to the client. - * - * @returns `this` - */ - ZeroClipboard.prototype.on = function() { - return _clientOn.apply(this, _args(arguments)); - }; - /** - * Unregister an event handler from the client. - * If no `listener` function/object is provided, it will unregister all handlers for the provided `eventType`. - * If no `eventType` is provided, it will unregister all handlers for every event type. - * - * @returns `this` - */ - ZeroClipboard.prototype.off = function() { - return _clientOff.apply(this, _args(arguments)); - }; - /** - * Retrieve event listeners for an `eventType` from the client. - * If no `eventType` is provided, it will retrieve all listeners for every event type. - * - * @returns array of listeners for the `eventType`; if no `eventType`, then a map/hash object of listeners for all event types; or `null` - */ - ZeroClipboard.prototype.handlers = function() { - return _clientListeners.apply(this, _args(arguments)); - }; - /** - * Event emission receiver from the Flash object for this client's registered JavaScript event listeners. - * - * @returns For the "copy" event, returns the Flash-friendly "clipData" object; otherwise `undefined`. - */ - ZeroClipboard.prototype.emit = function() { - return _clientEmit.apply(this, _args(arguments)); - }; - /** - * Register clipboard actions for new element(s) to the client. - * - * @returns `this` - */ - ZeroClipboard.prototype.clip = function() { - return _clientClip.apply(this, _args(arguments)); - }; - /** - * Unregister the clipboard actions of previously registered element(s) on the page. - * If no elements are provided, ALL registered elements will be unregistered. - * - * @returns `this` - */ - ZeroClipboard.prototype.unclip = function() { - return _clientUnclip.apply(this, _args(arguments)); - }; - /** - * Get all of the elements to which this client is clipped. - * - * @returns array of clipped elements - */ - ZeroClipboard.prototype.elements = function() { - return _clientElements.apply(this, _args(arguments)); - }; - /** - * Self-destruct and clean up everything for a single client. - * This will NOT destroy the embedded Flash object. - * - * @returns `undefined` - */ - ZeroClipboard.prototype.destroy = function() { - return _clientDestroy.apply(this, _args(arguments)); - }; - /** - * Stores the pending plain text to inject into the clipboard. - * - * @returns `this` - */ - ZeroClipboard.prototype.setText = function(text) { - if (!_clientMeta[this.id]) { - throw new Error("Attempted to set pending clipboard data from a destroyed ZeroClipboard client instance"); - } - ZeroClipboard.setData("text/plain", text); - return this; - }; - /** - * Stores the pending HTML text to inject into the clipboard. - * - * @returns `this` - */ - ZeroClipboard.prototype.setHtml = function(html) { - if (!_clientMeta[this.id]) { - throw new Error("Attempted to set pending clipboard data from a destroyed ZeroClipboard client instance"); - } - ZeroClipboard.setData("text/html", html); - return this; - }; - /** - * Stores the pending rich text (RTF) to inject into the clipboard. - * - * @returns `this` - */ - ZeroClipboard.prototype.setRichText = function(richText) { - if (!_clientMeta[this.id]) { - throw new Error("Attempted to set pending clipboard data from a destroyed ZeroClipboard client instance"); - } - ZeroClipboard.setData("application/rtf", richText); - return this; - }; - /** - * Stores the pending data to inject into the clipboard. - * - * @returns `this` - */ - ZeroClipboard.prototype.setData = function() { - if (!_clientMeta[this.id]) { - throw new Error("Attempted to set pending clipboard data from a destroyed ZeroClipboard client instance"); - } - ZeroClipboard.setData.apply(this, _args(arguments)); - return this; - }; - /** - * Clears the pending data to inject into the clipboard. - * If no `format` is provided, all pending data formats will be cleared. - * - * @returns `this` - */ - ZeroClipboard.prototype.clearData = function() { - if (!_clientMeta[this.id]) { - throw new Error("Attempted to clear pending clipboard data from a destroyed ZeroClipboard client instance"); - } - ZeroClipboard.clearData.apply(this, _args(arguments)); - return this; - }; - /** - * Gets a copy of the pending data to inject into the clipboard. - * If no `format` is provided, a copy of ALL pending data formats will be returned. - * - * @returns `String` or `Object` - */ - ZeroClipboard.prototype.getData = function() { - if (!_clientMeta[this.id]) { - throw new Error("Attempted to get pending clipboard data from a destroyed ZeroClipboard client instance"); - } - return ZeroClipboard.getData.apply(this, _args(arguments)); - }; - if (typeof define === "function" && define.amd) { - define(function() { - return ZeroClipboard; - }); - } else if (typeof module === "object" && module && typeof module.exports === "object" && module.exports) { - module.exports = ZeroClipboard; - } else { - window.ZeroClipboard = ZeroClipboard; - } -})(function() { - return this || window; -}()); \ No newline at end of file diff --git a/src/UI/LifeCycle.js b/src/UI/LifeCycle.js deleted file mode 100644 index 59a237340..000000000 --- a/src/UI/LifeCycle.js +++ /dev/null @@ -1,3 +0,0 @@ -window.onbeforeunload = function() { - window.NzbDrone.unloading = true; -}; \ No newline at end of file diff --git a/src/UI/ManualImport/Cells/EpisodesCell.js b/src/UI/ManualImport/Cells/EpisodesCell.js deleted file mode 100644 index 68c4b5166..000000000 --- a/src/UI/ManualImport/Cells/EpisodesCell.js +++ /dev/null @@ -1,46 +0,0 @@ -var _ = require('underscore'); -var vent = require('../../vent'); -var NzbDroneCell = require('../../Cells/NzbDroneCell'); -var SelectEpisodeLayout = require('../Episode/SelectEpisodeLayout'); - -module.exports = NzbDroneCell.extend({ - className : 'episodes-cell', - - events : { - 'click' : '_onClick' - }, - - render : function() { - this.$el.empty(); - - var episodes = this.model.get('episodes'); - - if (episodes) - { - var episodeNumbers = _.map(episodes, 'episodeNumber'); - - this.$el.html(episodeNumbers.join(', ')); - } - - return this; - }, - - _onClick : function () { - var series = this.model.get('series'); - var seasonNumber = this.model.get('seasonNumber'); - - if (series === undefined || seasonNumber === undefined) { - return; - } - - var view = new SelectEpisodeLayout({ series: series, seasonNumber: seasonNumber }); - - this.listenTo(view, 'manualimport:selected:episodes', this._setEpisodes); - - vent.trigger(vent.Commands.OpenModal2Command, view); - }, - - _setEpisodes : function (e) { - this.model.set('episodes', e.episodes); - } -}); \ No newline at end of file diff --git a/src/UI/ManualImport/Cells/PathCell.js b/src/UI/ManualImport/Cells/PathCell.js deleted file mode 100644 index 7397d1623..000000000 --- a/src/UI/ManualImport/Cells/PathCell.js +++ /dev/null @@ -1,16 +0,0 @@ -var NzbDroneCell = require('../../Cells/NzbDroneCell'); - -module.exports = NzbDroneCell.extend({ - className : 'path-cell', - - render : function() { - this.$el.empty(); - - var relativePath = this.model.get('relativePath'); - var path = this.model.get('path'); - - this.$el.html('<div title="{0}">{1}</div>'.format(path, relativePath)); - - return this; - } -}); \ No newline at end of file diff --git a/src/UI/ManualImport/Cells/QualityCell.js b/src/UI/ManualImport/Cells/QualityCell.js deleted file mode 100644 index 181ebf254..000000000 --- a/src/UI/ManualImport/Cells/QualityCell.js +++ /dev/null @@ -1,23 +0,0 @@ -var vent = require('../../vent'); -var QualityCell = require('../../Cells/QualityCell'); -var SelectQualityLayout = require('../Quality/SelectQualityLayout'); - -module.exports = QualityCell.extend({ - className : 'quality-cell editable', - - events : { - 'click' : '_onClick' - }, - - _onClick : function () { - var view = new SelectQualityLayout(); - - this.listenTo(view, 'manualimport:selected:quality', this._setQuality); - - vent.trigger(vent.Commands.OpenModal2Command, view); - }, - - _setQuality : function (e) { - this.model.set('quality', e.quality); - } -}); \ No newline at end of file diff --git a/src/UI/ManualImport/Cells/SeasonCell.js b/src/UI/ManualImport/Cells/SeasonCell.js deleted file mode 100644 index 6120055ea..000000000 --- a/src/UI/ManualImport/Cells/SeasonCell.js +++ /dev/null @@ -1,47 +0,0 @@ -var vent = require('../../vent'); -var NzbDroneCell = require('../../Cells/NzbDroneCell'); -var SelectSeasonLayout = require('../Season/SelectSeasonLayout'); - -module.exports = NzbDroneCell.extend({ - className : 'season-cell', - - events : { - 'click' : '_onClick' - }, - - render : function() { - this.$el.empty(); - - if (this.model.has('seasonNumber')) { - this.$el.html(this.model.get('seasonNumber')); - } - - this.delegateEvents(); - return this; - }, - - _onClick : function () { - var series = this.model.get('series'); - - if (!series) { - return; - } - - var view = new SelectSeasonLayout({ seasons: series.seasons }); - - this.listenTo(view, 'manualimport:selected:season', this._setSeason); - - vent.trigger(vent.Commands.OpenModal2Command, view); - }, - - _setSeason : function (e) { - if (this.model.has('seasonNumber') && e.seasonNumber === this.model.get('seasonNumber')) { - return; - } - - this.model.set({ - seasonNumber : e.seasonNumber, - episodes : [] - }); - } -}); \ No newline at end of file diff --git a/src/UI/ManualImport/Cells/SeriesCell.js b/src/UI/ManualImport/Cells/SeriesCell.js deleted file mode 100644 index cb66f6826..000000000 --- a/src/UI/ManualImport/Cells/SeriesCell.js +++ /dev/null @@ -1,45 +0,0 @@ -var vent = require('../../vent'); -var NzbDroneCell = require('../../Cells/NzbDroneCell'); -var SelectSeriesLayout = require('../Series/SelectSeriesLayout'); - -module.exports = NzbDroneCell.extend({ - className : 'series-title-cell editable', - - events : { - 'click' : '_onClick' - }, - - render : function() { - this.$el.empty(); - - var series = this.model.get('series'); - - if (series) - { - this.$el.html(series.title); - } - - this.delegateEvents(); - return this; - }, - - _onClick : function () { - var view = new SelectSeriesLayout(); - - this.listenTo(view, 'manualimport:selected:series', this._setSeries); - - vent.trigger(vent.Commands.OpenModal2Command, view); - }, - - _setSeries : function (e) { - if (this.model.has('series') && e.model.id === this.model.get('series').id) { - return; - } - - this.model.set({ - series : e.model.toJSON(), - seasonNumber : undefined, - episodes : [] - }); - } -}); \ No newline at end of file diff --git a/src/UI/ManualImport/EmptyView.js b/src/UI/ManualImport/EmptyView.js deleted file mode 100644 index 2b4394d3f..000000000 --- a/src/UI/ManualImport/EmptyView.js +++ /dev/null @@ -1,5 +0,0 @@ -var Marionette = require('marionette'); - -module.exports = Marionette.CompositeView.extend({ - template : 'ManualImport/EmptyViewTemplate' -}); \ No newline at end of file diff --git a/src/UI/ManualImport/EmptyViewTemplate.hbs b/src/UI/ManualImport/EmptyViewTemplate.hbs deleted file mode 100644 index fe59eb600..000000000 --- a/src/UI/ManualImport/EmptyViewTemplate.hbs +++ /dev/null @@ -1 +0,0 @@ -No video files were found in the selected folder. \ No newline at end of file diff --git a/src/UI/ManualImport/Episode/SelectEpisodeLayout.js b/src/UI/ManualImport/Episode/SelectEpisodeLayout.js deleted file mode 100644 index 04617a0bc..000000000 --- a/src/UI/ManualImport/Episode/SelectEpisodeLayout.js +++ /dev/null @@ -1,81 +0,0 @@ -var _ = require('underscore'); -var vent = require('vent'); -var Marionette = require('marionette'); -var Backgrid = require('backgrid'); -var EpisodeCollection = require('../../Series/EpisodeCollection'); -var LoadingView = require('../../Shared/LoadingView'); -var SelectAllCell = require('../../Cells/SelectAllCell'); -var EpisodeNumberCell = require('../../Series/Details/EpisodeNumberCell'); -var RelativeDateCell = require('../../Cells/RelativeDateCell'); -var SelectEpisodeRow = require('./SelectEpisodeRow'); - -module.exports = Marionette.Layout.extend({ - template : 'ManualImport/Episode/SelectEpisodeLayoutTemplate', - - regions : { - episodes : '.x-episodes' - }, - - events : { - 'click .x-select' : '_selectEpisodes' - }, - - columns : [ - { - name : '', - cell : SelectAllCell, - headerCell : 'select-all', - sortable : false - }, - { - name : 'episodeNumber', - label : '#', - cell : EpisodeNumberCell - }, - { - name : 'title', - label : 'Title', - hideSeriesLink : true, - cell : 'string', - sortable : false - }, - { - name : 'airDateUtc', - label : 'Air Date', - cell : RelativeDateCell - } - ], - - initialize : function(options) { - this.series = options.series; - this.seasonNumber = options.seasonNumber; - }, - - onRender : function() { - this.episodes.show(new LoadingView()); - - this.episodeCollection = new EpisodeCollection({ seriesId : this.series.id }); - this.episodeCollection.fetch(); - - this.listenToOnce(this.episodeCollection, 'sync', function () { - - this.episodeView = new Backgrid.Grid({ - columns : this.columns, - collection : this.episodeCollection.bySeason(this.seasonNumber), - className : 'table table-hover season-grid', - row : SelectEpisodeRow - }); - - this.episodes.show(this.episodeView); - }); - }, - - _selectEpisodes : function () { - var episodes = _.map(this.episodeView.getSelectedModels(), function (episode) { - return episode.toJSON(); - }); - - this.trigger('manualimport:selected:episodes', { episodes: episodes }); - vent.trigger(vent.Commands.CloseModal2Command); - } -}); diff --git a/src/UI/ManualImport/Episode/SelectEpisodeLayoutTemplate.hbs b/src/UI/ManualImport/Episode/SelectEpisodeLayoutTemplate.hbs deleted file mode 100644 index 68a9af81a..000000000 --- a/src/UI/ManualImport/Episode/SelectEpisodeLayoutTemplate.hbs +++ /dev/null @@ -1,21 +0,0 @@ -<div class="modal-content"> - <div class="manual-import-modal"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - - <h3> - Manual Import - Select Episode(s) - </h3> - - </div> - <div class="modal-body"> - <div class="row"> - <div class="col-md-12 x-episodes"></div> - </div> - </div> - <div class="modal-footer"> - <button class="btn btn-default" data-dismiss="modal">Cancel</button> - <button class="btn btn-success x-select" data-dismiss="modal">Select Episodes</button> - </div> - </div> -</div> diff --git a/src/UI/ManualImport/Episode/SelectEpisodeRow.js b/src/UI/ManualImport/Episode/SelectEpisodeRow.js deleted file mode 100644 index 6dc90fc99..000000000 --- a/src/UI/ManualImport/Episode/SelectEpisodeRow.js +++ /dev/null @@ -1,20 +0,0 @@ -var Backgrid = require('backgrid'); - -module.exports = Backgrid.Row.extend({ - className : 'select-episode-row', - - events : { - 'click' : '_toggle' - }, - - _toggle : function(e) { - - if (e.target.type === 'checkbox') { - return; - } - - var checked = this.$el.find('.select-row-cell :checkbox').prop('checked'); - - this.model.trigger('backgrid:select', this.model, !checked); - } -}); \ No newline at end of file diff --git a/src/UI/ManualImport/Folder/SelectFolderView.js b/src/UI/ManualImport/Folder/SelectFolderView.js deleted file mode 100644 index 0a2c066c2..000000000 --- a/src/UI/ManualImport/Folder/SelectFolderView.js +++ /dev/null @@ -1,84 +0,0 @@ -var _ = require('underscore'); -var $ = require('jquery'); -var Config = require('../../Config'); -var Marionette = require('marionette'); -var moment = require('moment'); -require('../../Mixins/FileBrowser'); - -module.exports = Marionette.ItemView.extend({ - template : 'ManualImport/Folder/SelectFolderViewTemplate', - - ui : { - path : '.x-path', - buttons : '.x-button' - }, - - events: { - 'click .x-manual-import' : '_manualImport', - 'click .x-automatic-import' : '_automaticImport', - 'change .x-path' : '_updateButtons', - 'keyup .x-path' : '_updateButtons', - 'click .x-recent-folder' : '_selectRecentFolder' - }, - - initialize : function () { - this.templateHelpers = { - recentFolders: Config.getValueJson('manualimport.recentfolders', []) - }; - }, - - onRender : function() { - this.ui.path.fileBrowser(); - this._updateButtons(); - }, - - path : function() { - return this.ui.path.val(); - }, - - _manualImport : function () { - var path = this.ui.path.val(); - - if (path) { - this._setRecentFolders(path); - this.trigger('manualImport', { folder: path }); - } - }, - - _automaticImport : function () { - var path = this.ui.path.val(); - - if (path) { - this._setRecentFolders(path); - this.trigger('automaticImport', { folder: path }); - } - }, - - _updateButtons : function () { - if (this.ui.path.val()) { - this.ui.buttons.removeAttr('disabled'); - } - - else { - this.ui.buttons.attr('disabled', 'disabled'); - } - }, - - _selectRecentFolder : function (e) { - var path = $(e.target).closest('tr').data('path'); - this.ui.path.val(path); - this.ui.path.trigger('change'); - }, - - _setRecentFolders : function (path) { - var recentFolders = Config.getValueJson('manualimport.recentfolders', []); - - recentFolders = _.filter(recentFolders, function (folder) { - return folder.path.toLowerCase() !== path.toLowerCase(); - }); - - recentFolders.unshift({ path: path, lastUsed: moment.utc().toISOString() }); - - Config.setValueJson('manualimport.recentfolders', _.take(recentFolders, 5)); - } -}); diff --git a/src/UI/ManualImport/Folder/SelectFolderViewTemplate.hbs b/src/UI/ManualImport/Folder/SelectFolderViewTemplate.hbs deleted file mode 100644 index 0e0dc18f2..000000000 --- a/src/UI/ManualImport/Folder/SelectFolderViewTemplate.hbs +++ /dev/null @@ -1,43 +0,0 @@ -<div class="select-folder"> - <div class="row"> - <div class="form-group"> - <div class="col-md-12"> - <input type="text" class="form-control x-path" placeholder="Select a folder to import" name="path"> - </div> - </div> - </div> - <div class="recent-folders"> - {{#if recentFolders}} - <h4>Recent Folders</h4> - - <table class="table table-hover"> - <thead> - <tr> - <th>Path</th> - <th>Last Used</th> - </tr> - </thead> - <tbody> - {{#each recentFolders}} - <tr class="recent-folder x-recent-folder" data-path="{{path}}"> - <td>{{path}}</td> - <td>{{RelativeDate lastUsed}}</td> - </tr> - {{/each}} - </tbody> - </table> - {{/if}} - </div> - <div class="buttons"> - <div class="row"> - <div class="col-md-4 col-md-offset-4"> - <button class="btn btn-primary btn-lg btn-block x-automatic-import x-button"><i class="icon-sonarr-search-automatic"></i> Import File(s) Automatically</button> - </div> - </div> - <div class="row"> - <div class="col-md-4 col-md-offset-4"> - <button class="btn btn-primary btn-lg btn-block x-manual-import x-button"><i class="icon-sonarr-search-manual"></i> Manual Import</button> - </div> - </div> - </div> -</div> \ No newline at end of file diff --git a/src/UI/ManualImport/ManualImportCollection.js b/src/UI/ManualImport/ManualImportCollection.js deleted file mode 100644 index c7cff70f7..000000000 --- a/src/UI/ManualImport/ManualImportCollection.js +++ /dev/null @@ -1,74 +0,0 @@ -var PageableCollection = require('backbone.pageable'); -var ManualImportModel = require('./ManualImportModel'); -var AsSortedCollection = require('../Mixins/AsSortedCollection'); - -var Collection = PageableCollection.extend({ - model : ManualImportModel, - url : window.NzbDrone.ApiRoot + '/manualimport', - - state : { - sortKey : 'quality', - order : 1, - pageSize : 100000 - }, - - mode : 'client', - - originalFetch : PageableCollection.prototype.fetch, - - initialize : function (options) { - options = options || {}; - - if (!options.folder && !options.downloadId) { - throw 'folder or downloadId is required'; - } - - this.folder = options.folder; - this.downloadId = options.downloadId; - }, - - fetch : function(options) { - options = options || {}; - - options.data = { folder : this.folder, downloadId : this.downloadId }; - - return this.originalFetch.call(this, options); - }, - - sortMappings : { - series : { - sortValue : function(model, attr, order) { - var series = model.get(attr); - - if (series) { - return series.sortTitle; - } - - return ''; - } - }, - - quality : { - sortKey : 'qualityWeight' - } - }, - - comparator : function(model1, model2) { - var quality1 = model1.get('quality'); - var quality2 = model2.get('quality'); - - if (quality1 < quality2) { - return 1; - } - - if (quality1 > quality2) { - return -1; - } - - return 0; - } -}); - -Collection = AsSortedCollection.call(Collection); - -module.exports = Collection; \ No newline at end of file diff --git a/src/UI/ManualImport/ManualImportLayout.js b/src/UI/ManualImport/ManualImportLayout.js deleted file mode 100644 index ba5a139fc..000000000 --- a/src/UI/ManualImport/ManualImportLayout.js +++ /dev/null @@ -1,259 +0,0 @@ -var _ = require('underscore'); -var vent = require('vent'); -var Marionette = require('marionette'); -var Backgrid = require('backgrid'); -var CommandController = require('../Commands/CommandController'); -var EmptyView = require('./EmptyView'); -var SelectFolderView = require('./Folder/SelectFolderView'); -var LoadingView = require('../Shared/LoadingView'); -var ManualImportRow = require('./ManualImportRow'); -var SelectAllCell = require('../Cells/SelectAllCell'); -var PathCell = require('./Cells/PathCell'); -var SeriesCell = require('./Cells/SeriesCell'); -var SeasonCell = require('./Cells/SeasonCell'); -var EpisodesCell = require('./Cells/EpisodesCell'); -var QualityCell = require('./Cells/QualityCell'); -var FileSizeCell = require('../Cells/FileSizeCell'); -var ApprovalStatusCell = require('../Cells/ApprovalStatusCell'); -var ManualImportCollection = require('./ManualImportCollection'); -var Messenger = require('../Shared/Messenger'); - -module.exports = Marionette.Layout.extend({ - className : 'modal-lg', - template : 'ManualImport/ManualImportLayoutTemplate', - - regions : { - workspace : '.x-workspace' - }, - - ui : { - importButton : '.x-import', - importMode : '.x-importmode' - }, - - events : { - 'click .x-import' : '_import' - }, - - columns : [ - { - name : '', - cell : SelectAllCell, - headerCell : 'select-all', - sortable : false - }, - { - name : 'relativePath', - label : 'Relative Path', - cell : PathCell, - sortable : true - }, - { - name : 'series', - label : 'Series', - cell : SeriesCell, - sortable : true - }, - { - name : 'seasonNumber', - label : 'Season', - cell : SeasonCell, - sortable : true - }, - { - name : 'episodes', - label : 'Episode(s)', - cell : EpisodesCell, - sortable : false - }, - { - name : 'quality', - label : 'Quality', - cell : QualityCell, - sortable : true - - }, - { - name : 'size', - label : 'Size', - cell : FileSizeCell, - sortable : true - }, - { - name : 'rejections', - label : '<i class="icon-sonarr-header-rejections" />', - tooltip : 'Rejections', - cell : ApprovalStatusCell, - sortable : false, - sortType : 'fixed', - direction : 'ascending', - title : 'Import Rejected' - } - ], - - initialize : function(options) { - this.folder = options.folder; - this.downloadId = options.downloadId; - this.title = options.title; - this.importMode = options.importMode || 'Move'; - - this.templateHelpers = { - title : this.title || this.folder - }; - }, - - onRender : function() { - - if (this.folder || this.downloadId) { - this._showLoading(); - this._loadCollection(); - this.ui.importMode.val(this.importMode); - } - - else { - this._showSelectFolder(); - this.ui.importButton.hide(); - this.ui.importMode.hide(); - } - }, - - _showLoading : function () { - this.workspace.show(new LoadingView()); - }, - - _loadCollection : function () { - this.manualImportCollection = new ManualImportCollection({ folder: this.folder, downloadId: this.downloadId }); - this.manualImportCollection.fetch(); - - this.listenTo(this.manualImportCollection, 'sync', this._showTable); - this.listenTo(this.manualImportCollection, 'backgrid:selected', this._updateButtons); - }, - - _showTable : function () { - if (this.manualImportCollection.length === 0) { - this.workspace.show(new EmptyView()); - return; - } - - this.fileView = new Backgrid.Grid({ - columns : this.columns, - collection : this.manualImportCollection, - className : 'table table-hover', - row : ManualImportRow - }); - - this.workspace.show(this.fileView); - this._updateButtons(); - }, - - _showSelectFolder : function () { - this.selectFolderView = new SelectFolderView(); - this.workspace.show(this.selectFolderView); - - this.listenTo(this.selectFolderView, 'manualImport', this._manualImport); - this.listenTo(this.selectFolderView, 'automaticImport', this._automaticImport); - }, - - _manualImport : function (e) { - this.folder = e.folder; - this.templateHelpers.title = this.folder; - this.render(); - }, - - _automaticImport : function (e) { - CommandController.Execute('downloadedEpisodesScan', { - name : 'downloadedEpisodesScan', - path : e.folder - }); - - vent.trigger(vent.Commands.CloseModalCommand); - }, - - _import : function () { - var selected = this.fileView.getSelectedModels(); - - if (selected.length === 0) { - return; - } - - if (_.any(selected, function (model) { - return !model.has('series'); - })) { - - this._showErrorMessage('Series must be chosen for each selected file'); - return; - } - - if (_.any(selected, function (model) { - return !model.has('seasonNumber'); - })) { - - this._showErrorMessage('Season must be chosen for each selected file'); - return; - } - - if (_.any(selected, function (model) { - return !model.has('episodes') || model.get('episodes').length === 0; - })) { - - this._showErrorMessage('One or more episodes must be chosen for each selected file'); - return; - } - - var importMode = this.ui.importMode.val(); - - CommandController.Execute('manualImport', { - name : 'manualImport', - files : _.map(selected, function (file) { - return { - path : file.get('path'), - seriesId : file.get('series').id, - episodeIds : _.map(file.get('episodes'), 'id'), - quality : file.get('quality'), - downloadId : file.get('downloadId') - }; - }), - importMode : importMode - }); - - vent.trigger(vent.Commands.CloseModalCommand); - }, - - _updateButtons : function (model, selected) { - if (!this.fileView) { - this.ui.importButton.attr('disabled', 'disabled'); - return; - } - - if (!model) { - return; - } - - var selectedModels = this.fileView.getSelectedModels(); - var selectedCount = 0; - - if (selected) { - selectedCount = _.any(selectedModels, { id : model.id }) ? selectedModels.length : selectedModels.length + 1; - } - - else { - selectedCount = _.any(selectedModels, { id : model.id }) ? selectedModels.length - 1 : selectedModels.length; - } - - if (selectedCount === 0) { - this.ui.importButton.attr('disabled', 'disabled'); - } - - else { - this.ui.importButton.removeAttr('disabled'); - } - }, - - _showErrorMessage : function (message) { - Messenger.show({ - message : message, - type : 'error', - hideAfter : 5 - }); - } -}); \ No newline at end of file diff --git a/src/UI/ManualImport/ManualImportLayoutTemplate.hbs b/src/UI/ManualImport/ManualImportLayoutTemplate.hbs deleted file mode 100644 index 194e094e9..000000000 --- a/src/UI/ManualImport/ManualImportLayoutTemplate.hbs +++ /dev/null @@ -1,26 +0,0 @@ -<div class="modal-content"> - <div class="manual-import-modal"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - - <h3> - Manual Import - {{#if title}}{{title}}{{else}}Select Folder{{/if}} - </h3> - - </div> - <div class="modal-body"> - <div class="x-workspace"></div> - <div class="x-footer"></div> - </div> - <div class="modal-footer"> - <div class="col-md-2 pull-left"> - <select class="form-control x-importmode"> - <option value="Move">Move Files</option> - <option value="Copy">Copy Files</option> - </select> - </div> - <button class="btn btn-default" data-dismiss="modal">Cancel</button> - <button class="btn btn-success x-import" disabled="disabled">Import</button> - </div> - </div> -</div> diff --git a/src/UI/ManualImport/ManualImportModel.js b/src/UI/ManualImport/ManualImportModel.js deleted file mode 100644 index dfd34cead..000000000 --- a/src/UI/ManualImport/ManualImportModel.js +++ /dev/null @@ -1,4 +0,0 @@ -var Backbone = require('backbone'); - -module.exports = Backbone.Model.extend({ -}); \ No newline at end of file diff --git a/src/UI/ManualImport/ManualImportRow.js b/src/UI/ManualImport/ManualImportRow.js deleted file mode 100644 index 5699e83c3..000000000 --- a/src/UI/ManualImport/ManualImportRow.js +++ /dev/null @@ -1,41 +0,0 @@ -var Backgrid = require('backgrid'); - -module.exports = Backgrid.Row.extend({ - className : 'manual-import-row', - - _originalInit : Backgrid.Row.prototype.initialize, - _originalRender : Backgrid.Row.prototype.render, - - initialize : function () { - this._originalInit.apply(this, arguments); - - this.listenTo(this.model, 'change', this._setError); - this.listenTo(this.model, 'change', this._setClasses); - }, - - render : function () { - this._originalRender.apply(this, arguments); - this._setError(); - this._setClasses(); - - return this; - }, - - _setError : function () { - if (this.model.has('series') && - this.model.has('seasonNumber') && - (this.model.has('episodes') && this.model.get('episodes').length > 0)&& - this.model.has('quality')) { - this.$el.removeClass('manual-import-error'); - } - - else { - this.$el.addClass('manual-import-error'); - } - }, - - _setClasses : function () { - this.$el.toggleClass('has-series', this.model.has('series')); - this.$el.toggleClass('has-season', this.model.has('seasonNumber')); - } -}); \ No newline at end of file diff --git a/src/UI/ManualImport/Quality/SelectQualityLayout.js b/src/UI/ManualImport/Quality/SelectQualityLayout.js deleted file mode 100644 index beba005e9..000000000 --- a/src/UI/ManualImport/Quality/SelectQualityLayout.js +++ /dev/null @@ -1,43 +0,0 @@ -var _ = require('underscore'); -var vent = require('../../vent'); -var Marionette = require('marionette'); -var LoadingView = require('../../Shared/LoadingView'); -var ProfileSchemaCollection = require('../../Settings/Profile/ProfileSchemaCollection'); -var SelectQualityView = require('./SelectQualityView'); - -module.exports = Marionette.Layout.extend({ - template : 'ManualImport/Quality/SelectQualityLayoutTemplate', - - regions : { - quality : '.x-quality' - }, - - events : { - 'click .x-select' : '_selectQuality' - }, - - initialize : function() { - this.profileSchemaCollection = new ProfileSchemaCollection(); - this.profileSchemaCollection.fetch(); - - this.listenTo(this.profileSchemaCollection, 'sync', this._showQuality); - }, - - onRender : function() { - this.quality.show(new LoadingView()); - }, - - _showQuality : function () { - var qualities = _.map(this.profileSchemaCollection.first().get('items'), function (quality) { - return quality.quality; - }); - - this.selectQualityView = new SelectQualityView({ qualities: qualities }); - this.quality.show(this.selectQualityView); - }, - - _selectQuality : function () { - this.trigger('manualimport:selected:quality', { quality: this.selectQualityView.selectedQuality() }); - vent.trigger(vent.Commands.CloseModal2Command); - } -}); \ No newline at end of file diff --git a/src/UI/ManualImport/Quality/SelectQualityLayoutTemplate.hbs b/src/UI/ManualImport/Quality/SelectQualityLayoutTemplate.hbs deleted file mode 100644 index d5d2098e6..000000000 --- a/src/UI/ManualImport/Quality/SelectQualityLayoutTemplate.hbs +++ /dev/null @@ -1,19 +0,0 @@ -<div class="modal-content"> - <div class="manual-import-modal"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - - <h3> - Manual Import - Select Quality - </h3> - - </div> - <div class="modal-body"> - <div class="x-quality"></div> - </div> - <div class="modal-footer"> - <button class="btn btn-default" data-dismiss="modal">Cancel</button> - <button class="btn btn-success x-select" data-dismiss="modal">Select Quality</button> - </div> - </div> -</div> diff --git a/src/UI/ManualImport/Quality/SelectQualityView.js b/src/UI/ManualImport/Quality/SelectQualityView.js deleted file mode 100644 index 8a39fab82..000000000 --- a/src/UI/ManualImport/Quality/SelectQualityView.js +++ /dev/null @@ -1,37 +0,0 @@ -var _ = require('underscore'); -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'ManualImport/Quality/SelectQualityViewTemplate', - - ui : { - select : '.x-select-quality', - proper : 'x-proper' - }, - - initialize : function(options) { - this.qualities = options.qualities; - - this.templateHelpers = { - qualities: this.qualities - }; - }, - - selectedQuality : function () { - var selected = parseInt(this.ui.select.val(), 10); - var proper = this.ui.proper.prop('checked'); - - var quality = _.find(this.qualities, function(q) { - return q.id === selected; - }); - - - return { - quality : quality, - revision : { - version : proper ? 2 : 1, - real : 0 - } - }; - } -}); \ No newline at end of file diff --git a/src/UI/ManualImport/Quality/SelectQualityViewTemplate.hbs b/src/UI/ManualImport/Quality/SelectQualityViewTemplate.hbs deleted file mode 100644 index a04342280..000000000 --- a/src/UI/ManualImport/Quality/SelectQualityViewTemplate.hbs +++ /dev/null @@ -1,33 +0,0 @@ -<div class="form-horizontal"> - <div class="form-group"> - <label class="col-sm-4 control-label">Quality</label> - - <div class="col-sm-4"> - <select class="form-control x-select-quality"> - <option value="-1">Select Quality</option> - {{#each qualities}} - <option value="{{id}}">{{name}}</option> - {{/each}} - </select> - - </div> - </div> - - <div class="form-group"> - <label class="col-sm-4 control-label">Proper</label> - - <div class="col-sm-8"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" class="x-proper"/> - <p> - <span>Yes</span> - <span>No</span> - </p> - - <div class="btn btn-primary slide-button"/> - </label> - </div> - </div> - </div> -</div> diff --git a/src/UI/ManualImport/Season/SelectSeasonLayout.js b/src/UI/ManualImport/Season/SelectSeasonLayout.js deleted file mode 100644 index 6f46f9cd9..000000000 --- a/src/UI/ManualImport/Season/SelectSeasonLayout.js +++ /dev/null @@ -1,28 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); - -module.exports = Marionette.Layout.extend({ - template : 'ManualImport/Season/SelectSeasonLayoutTemplate', - - events : { - 'change .x-select-season' : '_selectSeason' - }, - - initialize : function(options) { - - this.templateHelpers = { - seasons : options.seasons - }; - }, - - _selectSeason : function (e) { - var seasonNumber = parseInt(e.target.value, 10); - - if (seasonNumber === -1) { - return; - } - - this.trigger('manualimport:selected:season', { seasonNumber: seasonNumber }); - vent.trigger(vent.Commands.CloseModal2Command); - } -}); \ No newline at end of file diff --git a/src/UI/ManualImport/Season/SelectSeasonLayoutTemplate.hbs b/src/UI/ManualImport/Season/SelectSeasonLayoutTemplate.hbs deleted file mode 100644 index b459c6bf5..000000000 --- a/src/UI/ManualImport/Season/SelectSeasonLayoutTemplate.hbs +++ /dev/null @@ -1,29 +0,0 @@ -<div class="modal-content"> - <div class="manual-import-modal"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - - <h3> - Manual Import - Select Season - </h3> - - </div> - <div class="modal-body"> - <div class="row"> - <div class="form-group col-md-4 col-md-offset-4"> - <select class="form-control x-select-season"> - <option value="-1">Select Season</option> - {{#each seasons}} - <option value="{{seasonNumber}}">Season {{seasonNumber}}</option> - {{/each}} - </select> - </div> - </div> - </div> - <div class="modal-footer"> - <button class="btn btn-default" data-dismiss="modal">Cancel</button> - </div> - </div> -</div> - - diff --git a/src/UI/ManualImport/Series/SelectSeriesLayout.js b/src/UI/ManualImport/Series/SelectSeriesLayout.js deleted file mode 100644 index 2d0ea1487..000000000 --- a/src/UI/ManualImport/Series/SelectSeriesLayout.js +++ /dev/null @@ -1,101 +0,0 @@ -var _ = require('underscore'); -var vent = require('vent'); -var Marionette = require('marionette'); -var Backgrid = require('backgrid'); -var SeriesCollection = require('../../Series/SeriesCollection'); -var SelectRow = require('./SelectSeriesRow'); - -module.exports = Marionette.Layout.extend({ - template : 'ManualImport/Series/SelectSeriesLayoutTemplate', - - regions : { - series : '.x-series' - }, - - ui : { - filter : '.x-filter' - }, - - columns : [ - { - name : 'title', - label : 'Title', - cell : 'String', - sortValue : 'sortTitle' - } - ], - - initialize : function() { - this.seriesCollection = SeriesCollection.clone(); - this._setModelCollection(); - - this.listenTo(this.seriesCollection, 'row:selected', this._onSelected); - this.listenTo(this, 'modal:afterShow', this._setFocus); - }, - - onRender : function() { - this.seriesView = new Backgrid.Grid({ - columns : this.columns, - collection : this.seriesCollection, - className : 'table table-hover season-grid', - row : SelectRow - }); - - this.series.show(this.seriesView); - this._setupFilter(); - }, - - _setupFilter : function () { - var self = this; - - //TODO: This should be a mixin (same as Add Series searching) - this.ui.filter.keyup(function(e) { - if (_.contains([ - 9, - 16, - 17, - 18, - 19, - 20, - 33, - 34, - 35, - 36, - 37, - 38, - 39, - 40, - 91, - 92, - 93 - ], e.keyCode)) { - return; - } - - self._filter(self.ui.filter.val()); - }); - }, - - _filter : function (term) { - this.seriesCollection.setFilter(['title', term, 'contains']); - this._setModelCollection(); - }, - - _onSelected : function (e) { - this.trigger('manualimport:selected:series', { model: e.model }); - - vent.trigger(vent.Commands.CloseModal2Command); - }, - - _setFocus : function () { - this.ui.filter.focus(); - }, - - _setModelCollection: function () { - var self = this; - - _.each(this.seriesCollection.models, function (model) { - model.collection = self.seriesCollection; - }); - } -}); diff --git a/src/UI/ManualImport/Series/SelectSeriesLayoutTemplate.hbs b/src/UI/ManualImport/Series/SelectSeriesLayoutTemplate.hbs deleted file mode 100644 index 0db951d99..000000000 --- a/src/UI/ManualImport/Series/SelectSeriesLayoutTemplate.hbs +++ /dev/null @@ -1,30 +0,0 @@ -<div class="modal-content"> - <div class="manual-import-modal"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - - <h3> - Manual Import - Select Series - </h3> - - </div> - <div class="modal-body"> - <div class="row"> - <div class="col-md-12"> - <div class="form-group"> - <input type="text" class="form-control x-filter" placeholder="Filter series" /> - </div> - </div> - </div> - - <div class="row"> - <div class="col-md-12 x-series"></div> - </div> - </div> - <div class="modal-footer"> - <button class="btn btn-default" data-dismiss="modal">Cancel</button> - </div> - </div> -</div> - - diff --git a/src/UI/ManualImport/Series/SelectSeriesRow.js b/src/UI/ManualImport/Series/SelectSeriesRow.js deleted file mode 100644 index 38a2d5ca6..000000000 --- a/src/UI/ManualImport/Series/SelectSeriesRow.js +++ /dev/null @@ -1,13 +0,0 @@ -var Backgrid = require('backgrid'); - -module.exports = Backgrid.Row.extend({ - className : 'select-row select-series-row', - - events : { - 'click' : '_onClick' - }, - - _onClick : function() { - this.model.collection.trigger('row:selected', { model: this.model }); - } -}); \ No newline at end of file diff --git a/src/UI/ManualImport/Summary/ManualImportSummaryView.js b/src/UI/ManualImport/Summary/ManualImportSummaryView.js deleted file mode 100644 index a4ab847c2..000000000 --- a/src/UI/ManualImport/Summary/ManualImportSummaryView.js +++ /dev/null @@ -1,20 +0,0 @@ -var _ = require('underscore'); -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'ManualImport/Summary/ManualImportSummaryViewTemplate', - - initialize : function (options) { - var episodes = _.map(options.episodes, function (episode) { - return episode.toJSON(); - }); - - this.templateHelpers = { - file : options.file, - series : options.series, - season : options.season, - episodes : episodes, - quality : options.quality - }; - } -}); \ No newline at end of file diff --git a/src/UI/ManualImport/Summary/ManualImportSummaryViewTemplate.hbs b/src/UI/ManualImport/Summary/ManualImportSummaryViewTemplate.hbs deleted file mode 100644 index d65ff52f1..000000000 --- a/src/UI/ManualImport/Summary/ManualImportSummaryViewTemplate.hbs +++ /dev/null @@ -1,19 +0,0 @@ -<dl class="dl-horizontal"> - - <dt>Path:</dt> - <dd>{{file}}</dd> - - <dt>Series:</dt> - <dd>{{series.title}}</dd> - - <dt>Season:</dt> - <dd>{{season.seasonNumber}}</dd> - - {{#each episodes}} - <dt>Episode:</dt> - <dd>{{episodeNumber}} - {{title}}</dd> - {{/each}} - - <dt>Quality:</dt> - <dd>{{quality.name}}</dd> -</dl> diff --git a/src/UI/ManualImport/manualimport.less b/src/UI/ManualImport/manualimport.less deleted file mode 100644 index c1d7af5a2..000000000 --- a/src/UI/ManualImport/manualimport.less +++ /dev/null @@ -1,63 +0,0 @@ -@import "../Shared/Styles/card.less"; -@import "../Shared/Styles/clickable.less"; -@import "../Content/Bootstrap/variables"; - -.manual-import-modal { - .path-cell { - word-break : break-all; - } - - .file-size-cell { - min-width : 80px; - } - - .has-series { - .season-cell { - .clickable(); - } - } - - .has-season { - .episodes-cell { - .clickable(); - } - } - - .editable { - .clickable(); - - .badge { - .clickable(); - } - } - - .select-row { - .clickable(); - } - - .select-folder { - .buttons { - margin-top: 20px; - - .row { - margin-top: 10px; - } - } - - .recent-folders { - margin-top: 20px; - } - - .recent-folder { - .clickable(); - } - } - - .manual-import-error { - background-color : #fdefef; - } - - .recent-folder { - .clickable(); - } -} diff --git a/src/UI/Mixins/AsChangeTrackingModel.js b/src/UI/Mixins/AsChangeTrackingModel.js deleted file mode 100644 index b3524b244..000000000 --- a/src/UI/Mixins/AsChangeTrackingModel.js +++ /dev/null @@ -1,22 +0,0 @@ -module.exports = function() { - var originalInit = this.prototype.initialize; - - this.prototype.initialize = function() { - - this.isSaved = true; - - this.on('change', function() { - this.isSaved = false; - }, this); - - this.on('sync', function() { - this.isSaved = true; - }, this); - - if (originalInit) { - originalInit.call(this); - } - }; - - return this; -}; \ No newline at end of file diff --git a/src/UI/Mixins/AsEditModalView.js b/src/UI/Mixins/AsEditModalView.js deleted file mode 100644 index 383e0315b..000000000 --- a/src/UI/Mixins/AsEditModalView.js +++ /dev/null @@ -1,114 +0,0 @@ -var AppLayout = require('../AppLayout'); - -module.exports = function() { - var originalInitialize = this.prototype.initialize; - var originalOnBeforeClose = this.prototype.onBeforeClose; - - var saveInternal = function() { - var self = this; - - if (this.saving) { - return this.savePromise; - } - - this.saving = true; - this.ui.indicator.show(); - - if (this._onBeforeSave) { - this._onBeforeSave.call(this); - } - - this.savePromise = this.model.save(); - - this.savePromise.always(function() { - self.saving = false; - - if (!self.isClosed) { - self.ui.indicator.hide(); - } - }); - - this.savePromise.done(function() { - self.originalModelData = JSON.stringify(self.model.toJSON()); - }); - - return this.savePromise; - }; - - this.prototype.initialize = function(options) { - if (!this.model) { - throw 'View has no model'; - } - - this.testing = false; - this.saving = false; - - this.originalModelData = JSON.stringify(this.model.toJSON()); - - this.events = this.events || {}; - this.events['click .x-save'] = '_save'; - this.events['click .x-save-and-add'] = '_saveAndAdd'; - this.events['click .x-test'] = '_test'; - this.events['click .x-delete'] = '_delete'; - - this.ui = this.ui || {}; - this.ui.indicator = '.x-indicator'; - - if (originalInitialize) { - originalInitialize.call(this, options); - } - }; - - this.prototype._save = function() { - var self = this; - var promise = saveInternal.call(this); - - promise.done(function() { - if (self._onAfterSave) { - self._onAfterSave.call(self); - } - }); - }; - - this.prototype._saveAndAdd = function() { - var self = this; - var promise = saveInternal.call(this); - - promise.done(function() { - if (self._onAfterSaveAndAdd) { - self._onAfterSaveAndAdd.call(self); - } - }); - }; - - this.prototype._test = function() { - var self = this; - - if (this.testing) { - return; - } - - this.testing = true; - this.ui.indicator.show(); - - this.model.test().always(function() { - self.testing = false; - self.ui.indicator.hide(); - }); - }; - - this.prototype._delete = function() { - var view = new this._deleteView({ model : this.model }); - AppLayout.modalRegion.show(view); - }; - - this.prototype.onBeforeClose = function() { - this.model.set(JSON.parse(this.originalModelData)); - - if (originalOnBeforeClose) { - originalOnBeforeClose.call(this); - } - }; - - return this; -}; diff --git a/src/UI/Mixins/AsFilteredCollection.js b/src/UI/Mixins/AsFilteredCollection.js deleted file mode 100644 index 4b3fd3272..000000000 --- a/src/UI/Mixins/AsFilteredCollection.js +++ /dev/null @@ -1,79 +0,0 @@ -var _ = require('underscore'); -var Backbone = require('backbone'); - -module.exports = function() { - - this.prototype.setFilter = function(filter, options) { - options = _.extend({ reset : true }, options || {}); - - this.state.filterKey = filter[0]; - this.state.filterValue = filter[1]; - this.state.filterType = filter[2] || 'equal'; - - if (options.reset) { - if (this.mode !== 'server') { - this.fullCollection.resetFiltered(); - } else { - return this.fetch(); - } - } - }; - - this.prototype.setFilterMode = function(mode, options) { - return this.setFilter(this.filterModes[mode], options); - }; - - var originalMakeFullCollection = this.prototype._makeFullCollection; - - this.prototype._makeFullCollection = function(models, options) { - var self = this; - - self.shadowCollection = originalMakeFullCollection.call(this, models, options); - - var filterModel = function(model) { - if (_.isFunction(self.state.filterType)) { - return self.state.filterType(model); - } - - if (!self.state.filterKey) { - return true; - } - else if (self.state.filterType === 'contains') { - return model.get(self.state.filterKey).toLowerCase().indexOf(self.state.filterValue.toLowerCase()) > -1; - } - else { - return model.get(self.state.filterKey) === self.state.filterValue; - } - }; - - self.shadowCollection.filtered = function() { - return this.filter(filterModel); - }; - - var filteredModels = self.shadowCollection.filtered(); - var fullCollection = originalMakeFullCollection.call(this, filteredModels, options); - - fullCollection.resetFiltered = function(options) { - Backbone.Collection.prototype.reset.call(this, self.shadowCollection.filtered(), options); - }; - - fullCollection.reset = function(models, options) { - self.shadowCollection.reset(models, options); - self.fullCollection.resetFiltered(); - }; - - return fullCollection; - }; - - _.extend(this.prototype.state, { - filterKey : null, - filterValue : null - }); - - _.extend(this.prototype.queryParams, { - filterKey : 'filterKey', - filterValue : 'filterValue' - }); - - return this; -}; diff --git a/src/UI/Mixins/AsModelBoundView.js b/src/UI/Mixins/AsModelBoundView.js deleted file mode 100644 index 12d3fcca3..000000000 --- a/src/UI/Mixins/AsModelBoundView.js +++ /dev/null @@ -1,46 +0,0 @@ -var ModelBinder = require('backbone.modelbinder'); - -module.exports = function() { - - var originalOnRender = this.prototype.onRender; - var originalBeforeClose = this.prototype.onBeforeClose; - - this.prototype.onRender = function() { - - if (!this.model) { - throw 'View has no model for binding'; - } - - if (!this._modelBinder) { - this._modelBinder = new ModelBinder(); - } - - var options = { - changeTriggers : { - '' : 'change typeahead:selected typeahead:autocompleted', - '[contenteditable]' : 'blur', - '[data-onkeyup]' : 'keyup' - } - }; - - this._modelBinder.bind(this.model, this.el, null, options); - - if (originalOnRender) { - originalOnRender.call(this); - } - }; - - this.prototype.onBeforeClose = function() { - - if (this._modelBinder) { - this._modelBinder.unbind(); - delete this._modelBinder; - } - - if (originalBeforeClose) { - originalBeforeClose.call(this); - } - }; - - return this; -}; diff --git a/src/UI/Mixins/AsNamedView.js b/src/UI/Mixins/AsNamedView.js deleted file mode 100644 index 8bdd4b604..000000000 --- a/src/UI/Mixins/AsNamedView.js +++ /dev/null @@ -1,31 +0,0 @@ -module.exports = function() { - - window.NzbDrone.NameViews = window.NzbDrone.NameViews || !window.NzbDrone.Production; - - var regex = new RegExp('/', 'g'); - - var _getViewName = function(template) { - if (template) { - return template.toLocaleLowerCase().replace('template', '').replace(regex, '-'); - } - - return undefined; - }; - - var originalOnRender = this.onRender; - - this.onRender = function() { - - if (window.NzbDrone.NameViews) { - this.$el.addClass('iv-' + _getViewName(this.template)); - } - - if (originalOnRender) { - return originalOnRender.call(this); - } - - return undefined; - }; - - return this; -}; \ No newline at end of file diff --git a/src/UI/Mixins/AsPageableCollection.js b/src/UI/Mixins/AsPageableCollection.js deleted file mode 100644 index 60145e569..000000000 --- a/src/UI/Mixins/AsPageableCollection.js +++ /dev/null @@ -1,45 +0,0 @@ -var _ = require('underscore'); - -module.exports = function() { - var originalMakeCollectionEventHandler = this.prototype._makeCollectionEventHandler; - - this.prototype._makeCollectionEventHandler = function (pageCollection, fullCollection) { - var self = this; - this.pageCollection = pageCollection; - this.fullCollection = fullCollection; - var eventHandler = originalMakeCollectionEventHandler.apply(this, arguments); - - return _.wrap(eventHandler, _.bind(self._resetEventHandler, self)); - }; - - this.prototype._resetEventHandler = function (originalEventHandler, event, model, collection, options) { - if (event === 'reset') { - var currentPage = this.state.currentPage; - var pageSize = this.state.pageSize; - - originalEventHandler.apply(this, [].slice.call(arguments, 1)); - - var totalPages = Math.max(1,Math.ceil(this.state.totalRecords / pageSize)); - var newPage = Math.min(currentPage, totalPages); - - if (newPage !== this.state.currentPage) { - this.state.currentPage = newPage; - - // If backbone pageable fixes their reset bug - // (they reset the page number, but not the range), - // we'll want to do this for all resets where the page number changed - if (currentPage !== newPage) { - var pageStart = (newPage - 1) * pageSize; - var pageEnd = pageStart + pageSize; - - this.pageCollection.reset(this.fullCollection.models.slice(pageStart, pageEnd), - _.extend({}, options, { parse : false })); - } - } - } else { - originalEventHandler.call(this, [].slice.call(arguments, 1)); - } - }; - - return this; -}; diff --git a/src/UI/Mixins/AsPersistedStateCollection.js b/src/UI/Mixins/AsPersistedStateCollection.js deleted file mode 100644 index cecdeb2d8..000000000 --- a/src/UI/Mixins/AsPersistedStateCollection.js +++ /dev/null @@ -1,72 +0,0 @@ -var _ = require('underscore'); -var Config = require('../Config'); - -module.exports = function() { - - var originalInit = this.prototype.initialize; - this.prototype.initialize = function(options) { - - options = options || {}; - - if (options.tableName) { - this.tableName = options.tableName; - } - - if (!this.tableName && !options.tableName) { - throw 'tableName is required'; - } - - _setInitialState.call(this); - - this.on('backgrid:sort', _storeStateFromBackgrid, this); - this.on('drone:sort', _storeState, this); - - if (originalInit) { - originalInit.call(this, options); - } - }; - - if (!this.prototype._getSortMapping) { - this.prototype._getSortMapping = function(key) { - return { - name : key, - sortKey : key - }; - }; - } - - var _setInitialState = function() { - var key = Config.getValue('{0}.sortKey'.format(this.tableName), this.state.sortKey); - var direction = Config.getValue('{0}.sortDirection'.format(this.tableName), this.state.order); - var order = parseInt(direction, 10); - - this.state.sortKey = this._getSortMapping(key).sortKey; - this.state.order = order; - }; - - var _storeStateFromBackgrid = function(column, sortDirection) { - var order = _convertDirectionToInt(sortDirection); - var sortKey = this._getSortMapping(column.get('name')).sortKey; - - Config.setValue('{0}.sortKey'.format(this.tableName), sortKey); - Config.setValue('{0}.sortDirection'.format(this.tableName), order); - }; - - var _storeState = function(sortModel, sortDirection) { - var order = _convertDirectionToInt(sortDirection); - var sortKey = this._getSortMapping(sortModel.get('name')).sortKey; - - Config.setValue('{0}.sortKey'.format(this.tableName), sortKey); - Config.setValue('{0}.sortDirection'.format(this.tableName), order); - }; - - var _convertDirectionToInt = function(dir) { - if (dir === 'ascending') { - return '-1'; - } - - return '1'; - }; - - return this; -}; diff --git a/src/UI/Mixins/AsSortedCollection.js b/src/UI/Mixins/AsSortedCollection.js deleted file mode 100644 index 78a31edb6..000000000 --- a/src/UI/Mixins/AsSortedCollection.js +++ /dev/null @@ -1,130 +0,0 @@ -var _ = require('underscore'); -var Config = require('../Config'); - -module.exports = function() { - - var originalSetSorting = this.prototype.setSorting; - - this.prototype.setSorting = function(sortKey, order, options) { - var sortMapping = this._getSortMapping(sortKey); - - options = _.defaults({ sortValue : sortMapping.sortValue }, options || {}); - - return originalSetSorting.call(this, sortMapping.sortKey, order, options); - }; - - this.prototype._getSortMappings = function() { - var result = {}; - - if (this.sortMappings) { - _.each(this.sortMappings, function(values, key) { - var item = { - name : key, - sortKey : values.sortKey || key, - sortValue : values.sortValue - }; - result[key] = item; - result[item.sortKey] = item; - }); - } - - return result; - }; - - this.prototype._getSortMapping = function(key) { - var sortMappings = this._getSortMappings(); - - return sortMappings[key] || { - name : key, - sortKey : key - }; - }; - - this.prototype._getSecondarySorting = function() { - var sortKey = this.state.secondarySortKey; - var sortOrder = this.state.secondarySortOrder || -1; - - if (!sortKey || sortKey === this.state.sortKey) { - return null; - } - - var sortMapping = this._getSortMapping(sortKey); - - if (!sortMapping.sortValue) { - sortMapping.sortValue = function(model, attr) { - return model.get(attr); - }; - } - - return { - key : sortKey, - order : sortOrder, - sortValue : sortMapping.sortValue - }; - }; - - this.prototype._makeComparator = function(sortKey, order, sortValue) { - var state = this.state; - var secondarySorting = this._getSecondarySorting(); - - sortKey = sortKey || state.sortKey; - order = order || state.order; - - if (!sortKey || !order) { - return; - } - - if (!sortValue) { - sortValue = function(model, attr) { - return model.get(attr); - }; - } - - return function(left, right) { - var l = sortValue(left, sortKey, order); - var r = sortValue(right, sortKey, order); - var t; - - if (order === 1) { - t = l; - l = r; - r = t; - } - - if (l === r) { - - if (secondarySorting) { - var ls = secondarySorting.sortValue(left, secondarySorting.key, order); - var rs = secondarySorting.sortValue(right, secondarySorting.key, order); - var ts; - - if (secondarySorting.order === 1) { - ts = ls; - ls = rs; - rs = ts; - } - - if (ls === rs) { - return 0; - } - - if (ls < rs) { - return -1; - } - - return 1; - } - - return 0; - } - - else if (l < r) { - return -1; - } - - return 1; - }; - }; - - return this; -}; diff --git a/src/UI/Mixins/AsSortedCollectionView.js b/src/UI/Mixins/AsSortedCollectionView.js deleted file mode 100644 index e68b833a7..000000000 --- a/src/UI/Mixins/AsSortedCollectionView.js +++ /dev/null @@ -1,24 +0,0 @@ -module.exports = function() { - this.prototype.appendHtml = function(collectionView, itemView, index) { - var childrenContainer = collectionView.itemViewContainer ? collectionView.$(collectionView.itemViewContainer) : collectionView.$el; - var collection = collectionView.collection; - - // If the index of the model is at the end of the collection append, else insert at proper index - if (index >= collection.size() - 1) { - childrenContainer.append(itemView.el); - } else { - var previousModel = collection.at(index + 1); - var previousView = this.children.findByModel(previousModel); - - if (previousView) { - previousView.$el.before(itemView.$el); - } - - else { - childrenContainer.append(itemView.el); - } - } - }; - - return this; -}; \ No newline at end of file diff --git a/src/UI/Mixins/AsValidatedView.js b/src/UI/Mixins/AsValidatedView.js deleted file mode 100644 index 22e3c0844..000000000 --- a/src/UI/Mixins/AsValidatedView.js +++ /dev/null @@ -1,93 +0,0 @@ -var Validation = require('backbone.validation'); -var _ = require('underscore'); - -module.exports = (function() { - 'use strict'; - return function() { - - var originalInitialize = this.prototype.initialize; - var originalOnRender = this.prototype.onRender; - var originalBeforeClose = this.prototype.onBeforeClose; - - var errorHandler = function(response) { - if (this.model) { - this.model.trigger('validation:failed', response); - } else { - this.trigger('validation:failed', response); - } - }; - - var validatedSync = function(method, model, options) { - model.trigger('validation:sync'); - - arguments[2].isValidatedCall = true; - return model._originalSync.apply(this, arguments).fail(errorHandler.bind(this)); - }; - - var bindToModel = function(model) { - if (!model._originalSync) { - model._originalSync = model.sync; - model.sync = validatedSync.bind(this); - } - }; - - var validationFailed = function(response) { - if (response.status === 400) { - var view = this; - var validationErrors = JSON.parse(response.responseText); - _.each(validationErrors, function(error) { - view.$el.processServerError(error); - }); - } - }; - - this.prototype.initialize = function(options) { - if (this.model) { - this.listenTo(this.model, 'validation:sync', function() { - this.$el.removeAllErrors(); - }); - - this.listenTo(this.model, 'validation:failed', validationFailed); - } else { - this.listenTo(this, 'validation:sync', function() { - this.$el.removeAllErrors(); - }); - - this.listenTo(this, 'validation:failed', validationFailed); - } - - if (originalInitialize) { - originalInitialize.call(this, options); - } - }; - - this.prototype.onRender = function() { - Validation.bind(this); - this.bindToModelValidation = bindToModel.bind(this); - - if (this.model) { - this.bindToModelValidation(this.model); - } - - if (originalOnRender) { - originalOnRender.call(this); - } - }; - - this.prototype.onBeforeClose = function() { - if (this.model) { - Validation.unbind(this); - - //If we don't do this the next time the model is used the sync is bound to an old view - this.model.sync = this.model._originalSync; - this.model._originalSync = undefined; - } - - if (originalBeforeClose) { - originalBeforeClose.call(this); - } - }; - - return this; - }; -}).call(this); \ No newline at end of file diff --git a/src/UI/Mixins/AutoComplete.js b/src/UI/Mixins/AutoComplete.js deleted file mode 100644 index f0499d373..000000000 --- a/src/UI/Mixins/AutoComplete.js +++ /dev/null @@ -1,51 +0,0 @@ -var $ = require('jquery'); -require('typeahead'); - -$.fn.autoComplete = function(options) { - if (!options) { - throw 'options are required'; - } - - if (!options.resource) { - throw 'resource is required'; - } - - if (!options.query) { - throw 'query is required'; - } - - $(this).typeahead({ - hint : true, - highlight : true, - minLength : 3, - items : 20 - }, { - name : options.resource.replace('/'), - displayKey : '', - source : function(filter, callback) { - var data = options.data || {}; - data[options.query] = filter; - $.ajax({ - url : window.NzbDrone.ApiRoot + options.resource, - dataType : 'json', - type : 'GET', - data : data, - success : function(response) { - if (options.filter) { - options.filter.call(this, filter, response, callback); - } else { - var matches = []; - - $.each(response, function(i, d) { - if (d[options.query] && d[options.property].startsWith(filter)) { - matches.push({ value : d[options.property] }); - } - }); - - callback(matches); - } - } - }); - } - }); -}; \ No newline at end of file diff --git a/src/UI/Mixins/CopyToClipboard.js b/src/UI/Mixins/CopyToClipboard.js deleted file mode 100644 index 77db6e39a..000000000 --- a/src/UI/Mixins/CopyToClipboard.js +++ /dev/null @@ -1,22 +0,0 @@ -var $ = require('jquery'); -var StatusModel = require('../System/StatusModel'); -var ZeroClipboard = require('zero.clipboard'); -var Messenger = require('../Shared/Messenger'); - -$.fn.copyToClipboard = function(input) { - - ZeroClipboard.config({ - swfPath : StatusModel.get('urlBase') + '/Content/zero.clipboard.swf' - }); - - var client = new ZeroClipboard(this); - - client.on('ready', function(e) { - client.on('copy', function(e) { - e.clipboardData.setData("text/plain", input.val()); - }); - client.on('aftercopy', function() { - Messenger.show({ message : 'Copied text to clipboard' }); - }); - }); -}; \ No newline at end of file diff --git a/src/UI/Mixins/DirectoryAutoComplete.js b/src/UI/Mixins/DirectoryAutoComplete.js deleted file mode 100644 index f18ed35de..000000000 --- a/src/UI/Mixins/DirectoryAutoComplete.js +++ /dev/null @@ -1,29 +0,0 @@ -var $ = require('jquery'); -require('./AutoComplete'); - -$.fn.directoryAutoComplete = function(options) { - options = options || {}; - - var query = 'path'; - var data = { - includeFiles: options.includeFiles || false - }; - - $(this).autoComplete({ - resource : '/filesystem', - query : query, - data : data, - filter : function(filter, response, callback) { - var matches = []; - var results = response.directories.concat(response.files); - - $.each(results, function(i, d) { - if (d[query] && d[query].startsWith(filter)) { - matches.push({ value : d[query] }); - } - }); - - callback(matches); - } - }); -}; \ No newline at end of file diff --git a/src/UI/Mixins/FileBrowser.js b/src/UI/Mixins/FileBrowser.js deleted file mode 100644 index ddcbefabf..000000000 --- a/src/UI/Mixins/FileBrowser.js +++ /dev/null @@ -1,32 +0,0 @@ -var $ = require('jquery'); -var vent = require('vent'); -require('../Shared/FileBrowser/FileBrowserLayout'); -require('./DirectoryAutoComplete'); - -$.fn.fileBrowser = function(options) { - var inputs = $(this); - - inputs.each(function() { - var input = $(this); - var inputOptions = $.extend({ input : input, showFiles: input.hasClass('x-filepath') }, options); - var inputGroup = $('<div class="input-group"></div>'); - var inputGroupButton = $('<span class="input-group-btn"></span>'); - - var button = $('<button class="btn btn-primary x-file-browser" title="Browse"><i class="icon-sonarr-folder-open"/></button>'); - - if (input.parent('.input-group').length > 0) { - input.parent('.input-group').find('.input-group-btn').prepend(button); - } else { - inputGroupButton.append(button); - input.wrap(inputGroup); - input.after(inputGroupButton); - } - - button.on('click', function() { - vent.trigger(vent.Commands.ShowFileBrowser, inputOptions); - }); - - input.directoryAutoComplete({ includeFiles: inputOptions.showFiles }); - }); - -}; diff --git a/src/UI/Mixins/TagInput.js b/src/UI/Mixins/TagInput.js deleted file mode 100644 index 0f6a542b4..000000000 --- a/src/UI/Mixins/TagInput.js +++ /dev/null @@ -1,156 +0,0 @@ -var $ = require('jquery'); -var _ = require('underscore'); -var TagCollection = require('../Tags/TagCollection'); -var TagModel = require('../Tags/TagModel'); -require('bootstrap.tagsinput'); - -var substringMatcher = function(tagCollection) { - return function findMatches (q, cb) { - q = q.replace(/[^-_a-z0-9]/gi, '').toLowerCase(); - var matches = _.select(tagCollection.toJSON(), function(tag) { - return tag.label.toLowerCase().indexOf(q) > -1; - }); - cb(matches); - }; -}; -var getExistingTags = function(tagValues) { - return _.select(TagCollection.toJSON(), function(tag) { - return _.contains(tagValues, tag.id); - }); -}; - -var testTag = function(item) { - var tagLimitations = new RegExp('[^-_a-z0-9]', 'i'); - try { - return !tagLimitations.test(item); - } - catch (e) { - return false; - } -}; - -var originalAdd = $.fn.tagsinput.Constructor.prototype.add; -var originalRemove = $.fn.tagsinput.Constructor.prototype.remove; -var originalBuild = $.fn.tagsinput.Constructor.prototype.build; - -$.fn.tagsinput.Constructor.prototype.add = function(item, dontPushVal) { - var tagCollection = this.options.tagCollection; - - if (!tagCollection) { - originalAdd.call(this, item, dontPushVal); - return; - } - var self = this; - - if (typeof item === 'string') { - var existing = _.find(tagCollection.toJSON(), { label : item }); - - if (existing) { - originalAdd.call(this, existing, dontPushVal); - } else if (this.options.allowNew) { - if (item === null || item === '' || !testTag(item)) { - return; - } - - var newTag = new TagModel(); - newTag.set({ label : item.toLowerCase() }); - tagCollection.add(newTag); - - newTag.save().done(function() { - item = newTag.toJSON(); - originalAdd.call(self, item, dontPushVal); - }); - } - } else { - originalAdd.call(self, item, dontPushVal); - } - - self.$input.typeahead('val', ''); -}; - -$.fn.tagsinput.Constructor.prototype.remove = function(item, dontPushVal) { - if (item === null) { - return; - } - - originalRemove.call(this, item, dontPushVal); -}; - -$.fn.tagsinput.Constructor.prototype.build = function(options) { - var self = this; - var defaults = { - confirmKeys : [ - 9, - 13, - 32, - 44, - 59 - ] //tab, enter, space, comma, semi-colon - }; - - options = $.extend({}, defaults, options); - - self.$input.on('keydown', function(event) { - if (event.which === 9) { - var e = $.Event('keypress'); - e.which = 9; - self.$input.trigger(e); - event.preventDefault(); - } - }); - - self.$input.on('focusout', function() { - self.add(self.$input.val()); - self.$input.val(''); - }); - - originalBuild.call(this, options); -}; - -$.fn.tagInput = function(options) { - options = $.extend({}, { allowNew : true }, options); - - var input = this; - var model = options.model; - var property = options.property; - - var tagInput = $(this).tagsinput({ - tagCollection : TagCollection, - freeInput : true, - allowNew : options.allowNew, - itemValue : 'id', - itemText : 'label', - trimValue : true, - typeaheadjs : { - name : 'tags', - displayKey : 'label', - source : substringMatcher(TagCollection) - } - }); - - //Override the free input being set to false because we're using objects - $(tagInput)[0].options.freeInput = true; - - if (model) { - var tags = getExistingTags(model.get(property)); - - //Remove any existing tags and re-add them - $(this).tagsinput('removeAll'); - _.each(tags, function(tag) { - $(input).tagsinput('add', tag); - }); - $(this).tagsinput('refresh'); - $(this).on('itemAdded', function(event) { - var tags = model.get(property); - tags.push(event.item.id); - model.set(property, tags); - }); - $(this).on('itemRemoved', function(event) { - if (!event.item) { - return; - } - var tags = _.without(model.get(property), event.item.id); - model.set(property, tags); - }); - } -}; \ No newline at end of file diff --git a/src/UI/Mixins/backbone.signalr.mixin.js b/src/UI/Mixins/backbone.signalr.mixin.js deleted file mode 100644 index 8aad9b71c..000000000 --- a/src/UI/Mixins/backbone.signalr.mixin.js +++ /dev/null @@ -1,46 +0,0 @@ -var vent = require('vent'); -var _ = require('underscore'); -var Backbone = require('backbone'); - -require('signalR'); - -module.exports = _.extend(Backbone.Collection.prototype, { - bindSignalR : function(bindOptions) { - - var collection = this; - bindOptions = bindOptions || {}; - - var processMessage = function(options) { - if (options.action === 'sync') { - console.log('sync received, re-fetching collection'); - collection.fetch(); - - return; - } - - if (options.action === 'deleted') { - collection.remove(new collection.model(options.resource, { parse : true })); - - return; - } - - var model = new collection.model(options.resource, { parse : true }); - - //updateOnly will prevent the collection from adding a new item - if (bindOptions.updateOnly && !collection.get(model.get('id'))) { - return; - } - - collection.add(model, { - merge : true, - changeSource : 'signalr' - }); - - console.log(options.action + ': {0}}'.format(options.resource)); - }; - - collection.listenTo(vent, 'server:' + collection.url.split('/api/')[1], processMessage); - - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Navbar/NavbarLayout.js b/src/UI/Navbar/NavbarLayout.js deleted file mode 100644 index c364a0923..000000000 --- a/src/UI/Navbar/NavbarLayout.js +++ /dev/null @@ -1,63 +0,0 @@ -var Marionette = require('marionette'); -var $ = require('jquery'); -var HealthView = require('../Health/HealthView'); -var QueueView = require('../Activity/Queue/QueueView'); -require('./Search'); - -module.exports = Marionette.Layout.extend({ - template : 'Navbar/NavbarLayoutTemplate', - - regions : { - health : '#x-health', - queue : '#x-queue-count' - }, - - ui : { - search : '.x-series-search', - collapse : '.x-navbar-collapse' - }, - - events : { - 'click a' : 'onClick' - }, - - onRender : function() { - this.ui.search.bindSearch(); - this.health.show(new HealthView()); - this.queue.show(new QueueView()); - }, - - onClick : function(event) { - var target = $(event.target); - - //look down for <a/> - var href = event.target.getAttribute('href'); - - if (href && href.startsWith("http")) { - return; - } - - event.preventDefault(); - - //if couldn't find it look up' - if (!href && target.closest('a') && target.closest('a')[0]) { - - var linkElement = target.closest('a')[0]; - - href = linkElement.getAttribute('href'); - this.setActive(linkElement); - } else { - this.setActive(event.target); - } - - if ($(window).width() < 768) { - this.ui.collapse.collapse('hide'); - } - }, - - setActive : function(element) { - //Todo: Set active on first load - this.$('a').removeClass('active'); - $(element).addClass('active'); - } -}); \ No newline at end of file diff --git a/src/UI/Navbar/NavbarLayoutTemplate.hbs b/src/UI/Navbar/NavbarLayoutTemplate.hbs deleted file mode 100644 index 75cfc096f..000000000 --- a/src/UI/Navbar/NavbarLayoutTemplate.hbs +++ /dev/null @@ -1,44 +0,0 @@ -<!-- Static navbar --> -<div class="navbar navbar-nzbdrone" role="navigation"> - <div class="container-fluid"> - <div class="navbar-header"> - <button type="button" class="navbar-toggle navbar-inverse" data-toggle="collapse" data-target=".navbar-collapse"> - <span class="sr-only">Toggle navigation</span> - <span class="icon-sonarr-navbar-collapsed fa-lg"></span> - </button> - <a class="navbar-brand" href="{{UrlBase}}/"> - <!--<img src="{{UrlBase}}/Content/Images/logo.png?v=2" alt="Sonarr">--> - <img src="{{UrlBase}}/Content/Images/logos/128.png" class="visible-lg"/> - <img src="{{UrlBase}}/Content/Images/logos/64.png" class="visible-md visible-sm"/> - <span class="visible-xs"> - <img src="{{UrlBase}}/Content/Images/logos/32.png"/> - <span class="logo-text">sonarr</span> - </span> - - </a> - </div> - <div class="navbar-collapse collapse x-navbar-collapse"> - <ul class="nav navbar-nav"> - <li><a href="{{UrlBase}}/" class="x-series-nav"><i class="icon-sonarr-navbar-icon icon-sonarr-navbar-series"></i> Series</a></li> - <li><a href="{{UrlBase}}/calendar" class="x-calendar-nav"><i class="icon-sonarr-navbar-icon icon-sonarr-navbar-calendar"></i> Calendar</a></li> - <li><a href="{{UrlBase}}/activity" class="x-activity-nav"><i class="icon-sonarr-navbar-icon icon-sonarr-navbar-activity"></i> Activity<span id="x-queue-count" class="navbar-info"></span></a></li> - <li><a href="{{UrlBase}}/wanted" class="x-wanted-nav"><i class="icon-sonarr-navbar-icon icon-sonarr-navbar-wanted"></i> Wanted</a></li> - <li><a href="{{UrlBase}}/settings" class="x-settings-nav"><i class="icon-sonarr-navbar-icon icon-sonarr-navbar-settings"></i> Settings</a></li> - <li><a href="{{UrlBase}}/system" class="x-system-nav"><i class="icon-sonarr-navbar-icon icon-sonarr-navbar-system"></i> System<span id="x-health" class="navbar-info"></span></a></li> - <li><a href="https://sonarr.tv/donate" target="_blank"><i class="icon-sonarr-navbar-icon icon-sonarr-navbar-donate"></i> Donate</a></li> - </ul> - <ul class="nav navbar-nav navbar-right"> - <li class="active screen-size"></li> - </ul> - </div><!--/.nav-collapse --> - </div><!--/.container-fluid --> - - <div class="col-md-12 search"> - <div class="col-md-6 col-md-offset-3"> - <div class="input-group"> - <span class="input-group-addon"><i class="fa fa-search"></i></span> - <input type="text" class="col-md-6 form-control x-series-search" placeholder="Search the series in your library"> - </div> - </div> - </div> -</div> \ No newline at end of file diff --git a/src/UI/Navbar/Search.js b/src/UI/Navbar/Search.js deleted file mode 100644 index ec1e14ead..000000000 --- a/src/UI/Navbar/Search.js +++ /dev/null @@ -1,37 +0,0 @@ -var _ = require('underscore'); -var $ = require('jquery'); -var vent = require('vent'); -var Backbone = require('backbone'); -var SeriesCollection = require('../Series/SeriesCollection'); -require('typeahead'); - -vent.on(vent.Hotkeys.NavbarSearch, function() { - $('.x-series-search').focus(); -}); - -var substringMatcher = function() { - return function findMatches (q, cb) { - var matches = _.select(SeriesCollection.toJSON(), function(series) { - return series.title.toLowerCase().indexOf(q.toLowerCase()) > -1; - }); - cb(matches); - }; -}; - -$.fn.bindSearch = function() { - $(this).typeahead({ - hint : true, - highlight : true, - minLength : 1 - }, { - name : 'series', - displayKey : 'title', - source : substringMatcher() - }); - - $(this).on('typeahead:selected typeahead:autocompleted', function(e, series) { - this.blur(); - $(this).val(''); - Backbone.history.navigate('/series/{0}'.format(series.titleSlug), { trigger : true }); - }); -}; \ No newline at end of file diff --git a/src/UI/Profile/ProfileCollection.js b/src/UI/Profile/ProfileCollection.js deleted file mode 100644 index 838ac3c9c..000000000 --- a/src/UI/Profile/ProfileCollection.js +++ /dev/null @@ -1,13 +0,0 @@ -var Backbone = require('backbone'); -var ProfileModel = require('./ProfileModel'); - -var ProfileCollection = Backbone.Collection.extend({ - model : ProfileModel, - url : window.NzbDrone.ApiRoot + '/profile' -}); - -var profiles = new ProfileCollection(); - -profiles.fetch(); - -module.exports = profiles; diff --git a/src/UI/Profile/ProfileModel.js b/src/UI/Profile/ProfileModel.js deleted file mode 100644 index 259e4be5f..000000000 --- a/src/UI/Profile/ProfileModel.js +++ /dev/null @@ -1,9 +0,0 @@ -var DeepModel = require('backbone.deepmodel'); - -module.exports = DeepModel.extend({ - defaults : { - id : null, - name : '', - cutoff : null - } -}); \ No newline at end of file diff --git a/src/UI/Profile/ProfileSelectionPartial.hbs b/src/UI/Profile/ProfileSelectionPartial.hbs deleted file mode 100644 index 5526b361d..000000000 --- a/src/UI/Profile/ProfileSelectionPartial.hbs +++ /dev/null @@ -1,5 +0,0 @@ -<select class="col-md-2 form-control x-profile"> - {{#each this}} - <option value="{{id}}">{{name}}</option> - {{/each}} -</select> \ No newline at end of file diff --git a/src/UI/Quality/QualityDefinitionCollection.js b/src/UI/Quality/QualityDefinitionCollection.js deleted file mode 100644 index 7f111f2fd..000000000 --- a/src/UI/Quality/QualityDefinitionCollection.js +++ /dev/null @@ -1,7 +0,0 @@ -var Backbone = require('backbone'); -var QualityDefinitionModel = require('./QualityDefinitionModel'); - -module.exports = Backbone.Collection.extend({ - model : QualityDefinitionModel, - url : window.NzbDrone.ApiRoot + '/qualitydefinition' -}); \ No newline at end of file diff --git a/src/UI/Quality/QualityDefinitionModel.js b/src/UI/Quality/QualityDefinitionModel.js deleted file mode 100644 index e5a901b6d..000000000 --- a/src/UI/Quality/QualityDefinitionModel.js +++ /dev/null @@ -1,14 +0,0 @@ -var ModelBase = require('../Settings/SettingsModelBase'); - -module.exports = ModelBase.extend({ - baseInitialize : ModelBase.prototype.initialize, - - initialize : function() { - var name = this.get('quality').name; - - this.successMessage = 'Saved ' + name + ' quality settings'; - this.errorMessage = 'Couldn\'t save ' + name + ' quality settings'; - - this.baseInitialize.call(this); - } -}); \ No newline at end of file diff --git a/src/UI/Release/AgeCell.js b/src/UI/Release/AgeCell.js deleted file mode 100644 index f5a4bc7de..000000000 --- a/src/UI/Release/AgeCell.js +++ /dev/null @@ -1,33 +0,0 @@ -var moment = require('moment'); -var Backgrid = require('backgrid'); -var UiSettings = require('../Shared/UiSettingsModel'); -var FormatHelpers = require('../Shared/FormatHelpers'); - -module.exports = Backgrid.Cell.extend({ - className : 'age-cell', - - render : function() { - var age = this.model.get('age'); - var ageHours = this.model.get('ageHours'); - var ageMinutes = this.model.get('ageMinutes'); - var published = moment(this.model.get('publishDate')); - var publishedFormatted = published.format('{0} {1}'.format(UiSettings.get('shortDateFormat'), UiSettings.time(true, true))); - var formatted = age; - var suffix = FormatHelpers.plural(age, 'day'); - - if (age < 2) { - formatted = ageHours.toFixed(1); - suffix = FormatHelpers.plural(Math.round(ageHours), 'hour'); - } - - if (ageHours < 2) { - formatted = ageMinutes.toFixed(1); - suffix = FormatHelpers.plural(Math.round(ageMinutes), 'minute'); - } - - this.$el.html('<div title="{2}">{0} {1}</div>'.format(formatted, suffix, publishedFormatted)); - - this.delegateEvents(); - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Release/DownloadReportCell.js b/src/UI/Release/DownloadReportCell.js deleted file mode 100644 index c422446fc..000000000 --- a/src/UI/Release/DownloadReportCell.js +++ /dev/null @@ -1,49 +0,0 @@ -var Backgrid = require('backgrid'); - -module.exports = Backgrid.Cell.extend({ - className : 'download-report-cell', - - events : { - 'click' : '_onClick' - }, - - _onClick : function() { - if (!this.model.get('downloadAllowed')) { - return; - } - - var self = this; - - this.$el.html('<i class="icon-sonarr-spinner fa-spin" title="Adding to download queue" />'); - - //Using success callback instead of promise so it - //gets called before the sync event is triggered - var promise = this.model.save(null, { - success : function() { - self.model.set('queued', true); - } - }); - - promise.fail(function (xhr) { - if (xhr.responseJSON && xhr.responseJSON.message) { - self.$el.html('<i class="icon-sonarr-download-failed" title="{0}" />'.format(xhr.responseJSON.message)); - } else { - self.$el.html('<i class="icon-sonarr-download-failed" title="Failed to add to download queue" />'); - } - }); - }, - - render : function() { - this.$el.empty(); - - if (this.model.get('queued')) { - this.$el.html('<i class="icon-sonarr-downloading" title="Added to downloaded queue" />'); - } else if (this.model.get('downloadAllowed')) { - this.$el.html('<i class="icon-sonarr-download" title="Add to download queue" />'); - } else { - this.className = 'no-download-report-cell'; - } - - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Release/PeersCell.js b/src/UI/Release/PeersCell.js deleted file mode 100644 index 033c69115..000000000 --- a/src/UI/Release/PeersCell.js +++ /dev/null @@ -1,28 +0,0 @@ -var Backgrid = require('backgrid'); - -module.exports = Backgrid.Cell.extend({ - className : 'peers-cell', - - render : function() { - if (this.model.get('protocol') === 'torrent') { - var seeders = this.model.get('seeders') || 0; - var leechers = this.model.get('leechers') || 0; - - var level = 'danger'; - - if (seeders > 0) { - level = 'warning'; - } else if (seeders > 10) { - level = 'info'; - } else if (seeders > 50) { - level = 'primary'; - } - - this.$el.html('<div class="label label-{2}" title="{0} seeders, {1} leechers">{0} / {1}</div>'.format(seeders, leechers, level)); - } - - this.delegateEvents(); - - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Release/ProtocolCell.js b/src/UI/Release/ProtocolCell.js deleted file mode 100644 index ede35f9b3..000000000 --- a/src/UI/Release/ProtocolCell.js +++ /dev/null @@ -1,24 +0,0 @@ -var Backgrid = require('backgrid'); - -module.exports = Backgrid.Cell.extend({ - className : 'protocol-cell', - - render : function() { - var protocol = this.model.get('protocol') || 'Unknown'; - var label = '??'; - - if (protocol) { - if (protocol === 'torrent') { - label = 'torrent'; - } else if (protocol === 'usenet') { - label = 'nzb'; - } - - this.$el.html('<div class="label label-default protocol-{0}" title="{0}">{1}</div>'.format(protocol, label)); - } - - this.delegateEvents(); - - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Release/ReleaseCollection.js b/src/UI/Release/ReleaseCollection.js deleted file mode 100644 index a66547f00..000000000 --- a/src/UI/Release/ReleaseCollection.js +++ /dev/null @@ -1,56 +0,0 @@ -var PagableCollection = require('backbone.pageable'); -var ReleaseModel = require('./ReleaseModel'); -var AsSortedCollection = require('../Mixins/AsSortedCollection'); - -var Collection = PagableCollection.extend({ - url : window.NzbDrone.ApiRoot + '/release', - model : ReleaseModel, - - state : { - pageSize : 2000, - sortKey : 'download', - order : -1 - }, - - mode : 'client', - - sortMappings : { - 'quality' : { - sortKey : 'qualityWeight' - }, - 'rejections' : { - sortValue : function(model) { - var rejections = model.get('rejections'); - var releaseWeight = model.get('releaseWeight'); - - if (rejections.length !== 0) { - return releaseWeight + 1000000; - } - - return releaseWeight; - } - }, - 'download' : { - sortKey : 'releaseWeight' - }, - 'seeders' : { - sortValue : function(model) { - var seeders = model.get('seeders') || 0; - var leechers = model.get('leechers') || 0; - - return seeders * 1000000 + leechers; - } - }, - 'age' : { - sortKey : 'ageMinutes' - } - }, - - fetchEpisodeReleases : function(episodeId) { - return this.fetch({ data : { episodeId : episodeId } }); - } -}); - -Collection = AsSortedCollection.call(Collection); - -module.exports = Collection; \ No newline at end of file diff --git a/src/UI/Release/ReleaseLayout.js b/src/UI/Release/ReleaseLayout.js deleted file mode 100644 index 07f4a1af6..000000000 --- a/src/UI/Release/ReleaseLayout.js +++ /dev/null @@ -1,78 +0,0 @@ -var Marionette = require('marionette'); -var Backgrid = require('backgrid'); -var ReleaseCollection = require('./ReleaseCollection'); -var IndexerCell = require('../Cells/IndexerCell'); -var EpisodeNumberCell = require('../Cells/EpisodeNumberCell'); -var FileSizeCell = require('../Cells/FileSizeCell'); -var QualityCell = require('../Cells/QualityCell'); -var ApprovalStatusCell = require('../Cells/ApprovalStatusCell'); -var LoadingView = require('../Shared/LoadingView'); - -module.exports = Marionette.Layout.extend({ - template : 'Release/ReleaseLayoutTemplate', - - regions : { - grid : '#x-grid', - toolbar : '#x-toolbar' - }, - - columns : [ - { - name : 'indexer', - label : 'Indexer', - sortable : true, - cell : IndexerCell - }, - { - name : 'title', - label : 'Title', - sortable : true, - cell : Backgrid.StringCell - }, - { - name : 'episodeNumbers', - episodes : 'episodeNumbers', - label : 'season', - cell : EpisodeNumberCell - }, - { - name : 'size', - label : 'Size', - sortable : true, - cell : FileSizeCell - }, - { - name : 'quality', - label : 'Quality', - sortable : true, - cell : QualityCell - }, - { - name : 'rejections', - label : '', - cell : ApprovalStatusCell, - title : 'Release Rejected' - } - ], - - initialize : function() { - this.collection = new ReleaseCollection(); - this.listenTo(this.collection, 'sync', this._showTable); - }, - - onRender : function() { - this.grid.show(new LoadingView()); - this.collection.fetch(); - }, - - _showTable : function() { - if (!this.isClosed) { - this.grid.show(new Backgrid.Grid({ - row : Backgrid.Row, - columns : this.columns, - collection : this.collection, - className : 'table table-hover' - })); - } - } -}); \ No newline at end of file diff --git a/src/UI/Release/ReleaseLayoutTemplate.hbs b/src/UI/Release/ReleaseLayoutTemplate.hbs deleted file mode 100644 index 429260d74..000000000 --- a/src/UI/Release/ReleaseLayoutTemplate.hbs +++ /dev/null @@ -1,7 +0,0 @@ -<div id="x-toolbar"/> -<div class="row"> - <div class="col-md-12"> - <div id="x-grid"/> - </div> -</div> - diff --git a/src/UI/Release/ReleaseModel.js b/src/UI/Release/ReleaseModel.js deleted file mode 100644 index 3986a5948..000000000 --- a/src/UI/Release/ReleaseModel.js +++ /dev/null @@ -1,3 +0,0 @@ -var Backbone = require('backbone'); - -module.exports = Backbone.Model.extend({}); \ No newline at end of file diff --git a/src/UI/Rename/RenamePreviewCollection.js b/src/UI/Rename/RenamePreviewCollection.js deleted file mode 100644 index ce9f49b4a..000000000 --- a/src/UI/Rename/RenamePreviewCollection.js +++ /dev/null @@ -1,34 +0,0 @@ -var Backbone = require('backbone'); -var RenamePreviewModel = require('./RenamePreviewModel'); - -module.exports = Backbone.Collection.extend({ - url : window.NzbDrone.ApiRoot + '/rename', - model : RenamePreviewModel, - - originalFetch : Backbone.Collection.prototype.fetch, - - initialize : function(options) { - if (!options.seriesId) { - throw 'seriesId is required'; - } - - this.seriesId = options.seriesId; - this.seasonNumber = options.seasonNumber; - }, - - fetch : function(options) { - if (!this.seriesId) { - throw 'seriesId is required'; - } - - options = options || {}; - options.data = {}; - options.data.seriesId = this.seriesId; - - if (this.seasonNumber !== undefined) { - options.data.seasonNumber = this.seasonNumber; - } - - return this.originalFetch.call(this, options); - } -}); \ No newline at end of file diff --git a/src/UI/Rename/RenamePreviewCollectionView.js b/src/UI/Rename/RenamePreviewCollectionView.js deleted file mode 100644 index 7751b666d..000000000 --- a/src/UI/Rename/RenamePreviewCollectionView.js +++ /dev/null @@ -1,6 +0,0 @@ -var Marionette = require('marionette'); -var RenamePreviewItemView = require('./RenamePreviewItemView'); - -module.exports = Marionette.CollectionView.extend({ - itemView : RenamePreviewItemView -}); \ No newline at end of file diff --git a/src/UI/Rename/RenamePreviewEmptyCollectionView.js b/src/UI/Rename/RenamePreviewEmptyCollectionView.js deleted file mode 100644 index f7b7a5166..000000000 --- a/src/UI/Rename/RenamePreviewEmptyCollectionView.js +++ /dev/null @@ -1,6 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'Rename/RenamePreviewEmptyCollectionViewTemplate' -}); \ No newline at end of file diff --git a/src/UI/Rename/RenamePreviewEmptyCollectionViewTemplate.hbs b/src/UI/Rename/RenamePreviewEmptyCollectionViewTemplate.hbs deleted file mode 100644 index eeb0e3d0c..000000000 --- a/src/UI/Rename/RenamePreviewEmptyCollectionViewTemplate.hbs +++ /dev/null @@ -1,3 +0,0 @@ -<div class="alert alert-success"> - Success! My work is done, no files to rename. -</div> \ No newline at end of file diff --git a/src/UI/Rename/RenamePreviewFormatView.js b/src/UI/Rename/RenamePreviewFormatView.js deleted file mode 100644 index f34f955a1..000000000 --- a/src/UI/Rename/RenamePreviewFormatView.js +++ /dev/null @@ -1,21 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); -var NamingModel = require('../Settings/MediaManagement/Naming/NamingModel'); - -module.exports = Marionette.ItemView.extend({ - template : 'Rename/RenamePreviewFormatViewTemplate', - - templateHelpers : function() { - var type = this.model.get('seriesType'); - return { - rename : this.naming.get('renameEpisodes'), - format : this.naming.get(type + 'EpisodeFormat') - }; - }, - - initialize : function() { - this.naming = new NamingModel(); - this.naming.fetch(); - this.listenTo(this.naming, 'sync', this.render); - } -}); \ No newline at end of file diff --git a/src/UI/Rename/RenamePreviewFormatViewTemplate.hbs b/src/UI/Rename/RenamePreviewFormatViewTemplate.hbs deleted file mode 100644 index 77297f56b..000000000 --- a/src/UI/Rename/RenamePreviewFormatViewTemplate.hbs +++ /dev/null @@ -1,3 +0,0 @@ -{{#if rename}} -Naming pattern: {{format}} -{{/if}} diff --git a/src/UI/Rename/RenamePreviewItemView.js b/src/UI/Rename/RenamePreviewItemView.js deleted file mode 100644 index f0b73e14e..000000000 --- a/src/UI/Rename/RenamePreviewItemView.js +++ /dev/null @@ -1,39 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); -var AsModelBoundView = require('../Mixins/AsModelBoundView'); - -var view = Marionette.ItemView.extend({ - template : 'Rename/RenamePreviewItemViewTemplate', - - ui : { - itemDiv : '.rename-preview-item', - checkboxIcon : '.rename-checkbox i' - }, - - onRender : function() { - this._setItemState(); - this.listenTo(this.model, 'change', this._setItemState); - this.listenTo(this.model, 'rename:select', this._onRenameAll); - }, - - _setItemState : function() { - var checked = this.model.get('rename'); - this.model.trigger('rename:select', this.model, checked); - - if (checked) { - this.ui.itemDiv.removeClass('do-not-rename'); - this.ui.checkboxIcon.addClass('icon-sonarr-checked'); - this.ui.checkboxIcon.removeClass('icon-sonarr-unchecked'); - } else { - this.ui.itemDiv.addClass('do-not-rename'); - this.ui.checkboxIcon.addClass('icon-sonarr-unchecked'); - this.ui.checkboxIcon.removeClass('icon-sonarr-checked'); - } - }, - - _onRenameAll : function(model, checked) { - this.model.set('rename', checked); - } -}); - -module.exports = AsModelBoundView.apply(view); \ No newline at end of file diff --git a/src/UI/Rename/RenamePreviewItemViewTemplate.hbs b/src/UI/Rename/RenamePreviewItemViewTemplate.hbs deleted file mode 100644 index fd9497e01..000000000 --- a/src/UI/Rename/RenamePreviewItemViewTemplate.hbs +++ /dev/null @@ -1,20 +0,0 @@ -<div class="rename-preview-item"> - <div class="row"> - <div class="rename-checkbox col-md-1"> - <label class="checkbox-button" title="Rename File"> - <input type="checkbox" name="rename"/> - <div class="btn"> - <i></i> - </div> - </label> - </div> - <div class="col-md-11"> - <div class="row"> - <div class="col-md-12 file-path"><i class="icon-sonarr-existing" title="Existing path" /> {{existingPath}}</div> - </div> - <div class="row"> - <div class="col-md-12 file-path"><i class="icon-sonarr-suggested" title="Suggested path" /> {{newPath}}</div> - </div> - </div> - </div> -</div> \ No newline at end of file diff --git a/src/UI/Rename/RenamePreviewLayout.js b/src/UI/Rename/RenamePreviewLayout.js deleted file mode 100644 index eb1cf604a..000000000 --- a/src/UI/Rename/RenamePreviewLayout.js +++ /dev/null @@ -1,124 +0,0 @@ -var _ = require('underscore'); -var vent = require('vent'); -var Marionette = require('marionette'); -var RenamePreviewCollection = require('./RenamePreviewCollection'); -var RenamePreviewCollectionView = require('./RenamePreviewCollectionView'); -var EmptyCollectionView = require('./RenamePreviewEmptyCollectionView'); -var RenamePreviewFormatView = require('./RenamePreviewFormatView'); -var LoadingView = require('../Shared/LoadingView'); -var CommandController = require('../Commands/CommandController'); - -module.exports = Marionette.Layout.extend({ - className : 'modal-lg', - template : 'Rename/RenamePreviewLayoutTemplate', - - regions : { - renamePreviews : '#rename-previews', - formatRegion : '.x-format-region' - }, - - ui : { - pathInfo : '.x-path-info', - renameAll : '.x-rename-all', - checkboxIcon : '.x-rename-all-button i' - }, - - events : { - 'click .x-organize' : '_organizeFiles', - 'change .x-rename-all' : '_toggleAll' - }, - - initialize : function(options) { - this.model = options.series; - this.seasonNumber = options.seasonNumber; - - var viewOptions = {}; - viewOptions.seriesId = this.model.id; - viewOptions.seasonNumber = this.seasonNumber; - - this.collection = new RenamePreviewCollection(viewOptions); - this.listenTo(this.collection, 'sync', this._showPreviews); - this.listenTo(this.collection, 'rename:select', this._itemRenameChanged); - - this.collection.fetch(); - }, - - onRender : function() { - this.renamePreviews.show(new LoadingView()); - this.formatRegion.show(new RenamePreviewFormatView({ model : this.model })); - }, - - _showPreviews : function() { - if (this.collection.length === 0) { - this.ui.pathInfo.hide(); - this.renamePreviews.show(new EmptyCollectionView()); - return; - } - - this.ui.pathInfo.show(); - this.collection.invoke('set', { rename : true }); - this.renamePreviews.show(new RenamePreviewCollectionView({ collection : this.collection })); - }, - - _organizeFiles : function() { - if (this.collection.length === 0) { - vent.trigger(vent.Commands.CloseModalCommand); - } - - var files = _.map(this.collection.where({ rename : true }), function(model) { - return model.get('episodeFileId'); - }); - - if (files.length === 0) { - vent.trigger(vent.Commands.CloseModalCommand); - return; - } - - if (this.seasonNumber) { - CommandController.Execute('renameFiles', { - name : 'renameFiles', - seriesId : this.model.id, - seasonNumber : this.seasonNumber, - files : files - }); - } else { - CommandController.Execute('renameFiles', { - name : 'renameFiles', - seriesId : this.model.id, - seasonNumber : -1, - files : files - }); - } - - vent.trigger(vent.Commands.CloseModalCommand); - }, - - _setCheckedState : function(checked) { - if (checked) { - this.ui.checkboxIcon.addClass('icon-sonarr-checked'); - this.ui.checkboxIcon.removeClass('icon-sonarr-unchecked'); - } else { - this.ui.checkboxIcon.addClass('icon-sonarr-unchecked'); - this.ui.checkboxIcon.removeClass('icon-sonarr-checked'); - } - }, - - _toggleAll : function() { - var checked = this.ui.renameAll.prop('checked'); - this._setCheckedState(checked); - - this.collection.each(function(model) { - model.trigger('rename:select', model, checked); - }); - }, - - _itemRenameChanged : function(model, checked) { - var allChecked = this.collection.all(function(m) { - return m.get('rename'); - }); - - if (!checked || allChecked) { - this._setCheckedState(checked); - } - } -}); \ No newline at end of file diff --git a/src/UI/Rename/RenamePreviewLayoutTemplate.hbs b/src/UI/Rename/RenamePreviewLayoutTemplate.hbs deleted file mode 100644 index a3aa41d51..000000000 --- a/src/UI/Rename/RenamePreviewLayoutTemplate.hbs +++ /dev/null @@ -1,34 +0,0 @@ -<div class="modal-content"> - <div class="rename-preview-modal"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - <h3> - <i class="icon-sonarr-rename"></i> Organize & Rename - </h3> - - </div> - <div class="modal-body"> - <div class="alert alert-info"> - <div class="path-info x-path-info">All paths are relative to: <strong>{{path}}</strong></div> - <div class="x-format-region"></div> - </div> - - <div id="rename-previews"></div> - - </div> - <div class="modal-footer"> - - <span class="rename-all-button x-rename-all-button pull-left"> - <label class="checkbox-button" title="Toggle all"> - <input type="checkbox" checked="checked" class="x-rename-all"/> - <div class="btn btn-icon-only"> - <i class="icon-sonarr-checked"></i> - </div> - </label> - </span> - - <button class="btn" data-dismiss="modal">Close</button> - <button class="btn btn-primary x-organize">Organize</button> - </div> - </div> -</div> diff --git a/src/UI/Rename/RenamePreviewModel.js b/src/UI/Rename/RenamePreviewModel.js deleted file mode 100644 index 3986a5948..000000000 --- a/src/UI/Rename/RenamePreviewModel.js +++ /dev/null @@ -1,3 +0,0 @@ -var Backbone = require('backbone'); - -module.exports = Backbone.Model.extend({}); \ No newline at end of file diff --git a/src/UI/Rename/rename.less b/src/UI/Rename/rename.less deleted file mode 100644 index 07e694016..000000000 --- a/src/UI/Rename/rename.less +++ /dev/null @@ -1,42 +0,0 @@ -@import "../Content/FontAwesome/font-awesome"; -@import "../Content/Bootstrap/variables"; -@import "../Content/variables"; - -.rename-preview-item { - margin-bottom: 5px; - padding: 5px; - border-bottom: 1px solid #e5e5e5; - - &.do-not-rename { - background-color: #aaaaaa; - opacity: 0.7; - } - - .rename-checkbox { - width: 40px; - padding-top: 5px; - margin-right: 10px; - - .checkbox-button { - .btn { - text-align: left; - width: 38px; - } - } - } -} - -.path-info { - display: none; -} - -.rename-all-button { - display: inline-block; - - .checkbox-button { - .btn { - text-align: left; - width: 38px; - } - } -} diff --git a/src/UI/Router.js b/src/UI/Router.js deleted file mode 100644 index 91b42a074..000000000 --- a/src/UI/Router.js +++ /dev/null @@ -1,25 +0,0 @@ -var Marionette = require('marionette'); -var Controller = require('./Controller'); - -module.exports = Marionette.AppRouter.extend({ - controller : new Controller(), - appRoutes : { - 'addseries' : 'addSeries', - 'addseries/:action(/:query)' : 'addSeries', - 'calendar' : 'calendar', - 'settings' : 'settings', - 'settings/:action(/:query)' : 'settings', - 'wanted' : 'wanted', - 'wanted/:action' : 'wanted', - 'history' : 'activity', - 'history/:action' : 'activity', - 'activity' : 'activity', - 'activity/:action' : 'activity', - 'rss' : 'rss', - 'system' : 'system', - 'system/:action' : 'system', - 'seasonpass' : 'seasonPass', - 'serieseditor' : 'seriesEditor', - ':whatever' : 'showNotFound' - } -}); \ No newline at end of file diff --git a/src/UI/SeasonPass/SeasonPassFooterView.js b/src/UI/SeasonPass/SeasonPassFooterView.js deleted file mode 100644 index 64a2c8916..000000000 --- a/src/UI/SeasonPass/SeasonPassFooterView.js +++ /dev/null @@ -1,139 +0,0 @@ -var _ = require('underscore'); -var $ = require('jquery'); -var Marionette = require('marionette'); -var vent = require('vent'); -var RootFolders = require('../AddSeries/RootFolders/RootFolderCollection'); - -module.exports = Marionette.ItemView.extend({ - template : 'SeasonPass/SeasonPassFooterViewTemplate', - - ui : { - seriesMonitored : '.x-series-monitored', - monitor : '.x-monitor', - selectedCount : '.x-selected-count', - container : '.series-editor-footer', - actions : '.x-action', - indicator : '.x-indicator', - indicatorIcon : '.x-indicator-icon' - }, - - events : { - 'click .x-update' : '_update' - }, - - initialize : function(options) { - this.seriesCollection = options.collection; - - RootFolders.fetch().done(function() { - RootFolders.synced = true; - }); - - this.editorGrid = options.editorGrid; - this.listenTo(this.seriesCollection, 'backgrid:selected', this._updateInfo); - }, - - onRender : function() { - this._updateInfo(); - }, - - _update : function() { - var self = this; - var selected = this.editorGrid.getSelectedModels(); - var seriesMonitored = this.ui.seriesMonitored.val(); - var monitoringOptions; - - _.each(selected, function(model) { - if (seriesMonitored === 'true') { - model.set('monitored', true); - } else if (seriesMonitored === 'false') { - model.set('monitored', false); - } - - monitoringOptions = self._getMonitoringOptions(model); - model.set('addOptions', monitoringOptions); - }); - - var promise = $.ajax({ - url : window.NzbDrone.ApiRoot + '/seasonpass', - type : 'POST', - data : JSON.stringify({ - series : _.map(selected, function (model) { - return model.toJSON(); - }), - monitoringOptions : monitoringOptions - }) - }); - - this.ui.indicator.show(); - - promise.always(function () { - self.ui.indicator.hide(); - }); - - promise.done(function () { - self.seriesCollection.trigger('seasonpass:saved'); - }); - }, - - _updateInfo : function() { - var selected = this.editorGrid.getSelectedModels(); - var selectedCount = selected.length; - - this.ui.selectedCount.html('{0} series selected'.format(selectedCount)); - - if (selectedCount === 0) { - this.ui.actions.attr('disabled', 'disabled'); - } else { - this.ui.actions.removeAttr('disabled'); - } - }, - - _getMonitoringOptions : function(model) { - var monitor = this.ui.monitor.val(); - var lastSeason = _.max(model.get('seasons'), 'seasonNumber'); - var firstSeason = _.min(_.reject(model.get('seasons'), { seasonNumber : 0 }), 'seasonNumber'); - - if (monitor === 'noChange') { - return null; - } - - model.setSeasonPass(firstSeason.seasonNumber); - - var options = { - ignoreEpisodesWithFiles : false, - ignoreEpisodesWithoutFiles : false - }; - - if (monitor === 'all') { - return options; - } - - else if (monitor === 'future') { - options.ignoreEpisodesWithFiles = true; - options.ignoreEpisodesWithoutFiles = true; - } - - else if (monitor === 'latest') { - model.setSeasonPass(lastSeason.seasonNumber); - } - - else if (monitor === 'first') { - model.setSeasonPass(lastSeason.seasonNumber + 1); - model.setSeasonMonitored(firstSeason.seasonNumber); - } - - else if (monitor === 'missing') { - options.ignoreEpisodesWithFiles = true; - } - - else if (monitor === 'existing') { - options.ignoreEpisodesWithoutFiles = true; - } - - else if (monitor === 'none') { - model.setSeasonPass(lastSeason.seasonNumber + 1); - } - - return options; - } -}); \ No newline at end of file diff --git a/src/UI/SeasonPass/SeasonPassFooterViewTemplate.hbs b/src/UI/SeasonPass/SeasonPassFooterViewTemplate.hbs deleted file mode 100644 index 522b85745..000000000 --- a/src/UI/SeasonPass/SeasonPassFooterViewTemplate.hbs +++ /dev/null @@ -1,36 +0,0 @@ -<div class="series-editor-footer"> - <div class="row"> - <div class="form-group col-md-2"> - <label>Monitor series</label> - - <select class="form-control x-action x-series-monitored"> - <option value="noChange">No change</option> - <option value="true">Monitored</option> - <option value="false">Unmonitored</option> - </select> - </div> - - <div class="form-group col-md-2"> - <label>Monitor episodes</label> - - <select class="form-control x-action x-monitor"> - <option value="noChange">No change</option> - <option value="all">All</option> - <option value="future">Future</option> - <option value="missing">Missing</option> - <option value="existing">Existing</option> - <option value="first">First Season</option> - <option value="latest">Latest Season</option> - <option value="none">None</option> - </select> - </div> - - <div class="form-group col-md-3 actions"> - <label class="x-selected-count">0 series selected</label> - <div> - <button class="btn btn-primary x-action x-update">Update Selected Series</button> - <span class="indicator x-indicator"><i class="icon-sonarr-spinner fa-spin"></i></span> - </div> - </div> - </div> -</div> diff --git a/src/UI/SeasonPass/SeasonPassLayout.js b/src/UI/SeasonPass/SeasonPassLayout.js deleted file mode 100644 index 5330fcf77..000000000 --- a/src/UI/SeasonPass/SeasonPassLayout.js +++ /dev/null @@ -1,152 +0,0 @@ -var _ = require('underscore'); -var vent = require('vent'); -var Backgrid = require('backgrid'); -var Marionette = require('marionette'); -var EmptyView = require('../Series/Index/EmptyView'); -var SeriesCollection = require('../Series/SeriesCollection'); -var ToolbarLayout = require('../Shared/Toolbar/ToolbarLayout'); -var FooterView = require('./SeasonPassFooterView'); -var SelectAllCell = require('../Cells/SelectAllCell'); -var SeriesStatusCell = require('../Cells/SeriesStatusCell'); -var SeriesTitleCell = require('../Cells/SeriesTitleCell'); -var SeriesMonitoredCell = require('../Cells/ToggleCell'); -var SeasonsCell = require('./SeasonsCell'); -require('../Mixins/backbone.signalr.mixin'); - -module.exports = Marionette.Layout.extend({ - template : 'SeasonPass/SeasonPassLayoutTemplate', - - regions : { - toolbar : '#x-toolbar', - series : '#x-series' - }, - - columns : [ - { - name : '', - cell : SelectAllCell, - headerCell : 'select-all', - sortable : false - }, - { - name : 'statusWeight', - label : '', - cell : SeriesStatusCell - }, - { - name : 'title', - label : 'Title', - cell : SeriesTitleCell, - cellValue : 'this' - }, - { - name : 'monitored', - label : '', - cell : SeriesMonitoredCell, - trueClass : 'icon-sonarr-monitored', - falseClass : 'icon-sonarr-unmonitored', - tooltip : 'Toggle series monitored status', - sortable : false - }, - { - name : 'seasons', - label : 'Seasons', - cell : SeasonsCell, - cellValue : 'this' - } - ], - - initialize : function() { - this.seriesCollection = SeriesCollection.clone(); - this.seriesCollection.shadowCollection.bindSignalR(); - -// this.listenTo(this.seriesCollection, 'sync', this.render); - this.listenTo(this.seriesCollection, 'seasonpass:saved', this.render); - - this.filteringOptions = { - type : 'radio', - storeState : true, - menuKey : 'seasonpass.filterMode', - defaultAction : 'all', - items : [ - { - key : 'all', - title : '', - tooltip : 'All', - icon : 'icon-sonarr-all', - callback : this._setFilter - }, - { - key : 'monitored', - title : '', - tooltip : 'Monitored Only', - icon : 'icon-sonarr-monitored', - callback : this._setFilter - }, - { - key : 'continuing', - title : '', - tooltip : 'Continuing Only', - icon : 'icon-sonarr-series-continuing', - callback : this._setFilter - }, - { - key : 'ended', - title : '', - tooltip : 'Ended Only', - icon : 'icon-sonarr-series-ended', - callback : this._setFilter - } - ] - }; - }, - - onRender : function() { - this._showTable(); - this._showToolbar(); - this._showFooter(); - }, - - onClose : function() { - vent.trigger(vent.Commands.CloseControlPanelCommand); - }, - - _showToolbar : function() { - this.toolbar.show(new ToolbarLayout({ - right : [this.filteringOptions], - context : this - })); - }, - - _showTable : function() { - if (this.seriesCollection.shadowCollection.length === 0) { - this.series.show(new EmptyView()); - this.toolbar.close(); - return; - } - - this.columns[0].sortedCollection = this.seriesCollection; - - this.editorGrid = new Backgrid.Grid({ - collection : this.seriesCollection, - columns : this.columns, - className : 'table table-hover' - }); - - this.series.show(this.editorGrid); - this._showFooter(); - }, - - _showFooter : function() { - vent.trigger(vent.Commands.OpenControlPanelCommand, new FooterView({ - editorGrid : this.editorGrid, - collection : this.seriesCollection - })); - }, - - _setFilter : function(buttonContext) { - var mode = buttonContext.model.get('key'); - - this.seriesCollection.setFilterMode(mode); - } -}); \ No newline at end of file diff --git a/src/UI/SeasonPass/SeasonPassLayoutTemplate.hbs b/src/UI/SeasonPass/SeasonPassLayoutTemplate.hbs deleted file mode 100644 index 3365f018d..000000000 --- a/src/UI/SeasonPass/SeasonPassLayoutTemplate.hbs +++ /dev/null @@ -1,13 +0,0 @@ -<div id="x-toolbar"></div> - -<div class="row"> - <div class="col-md-12"> - <div class="alert alert-info">Season Pass allows you to quickly change the monitored status of seasons for all your series in one place</div> - </div> -</div> - -<div class="row"> - <div class="col-md-12"> - <div id="x-series"></div> - </div> -</div> \ No newline at end of file diff --git a/src/UI/SeasonPass/SeasonsCell.js b/src/UI/SeasonPass/SeasonsCell.js deleted file mode 100644 index 81af02d1b..000000000 --- a/src/UI/SeasonPass/SeasonsCell.js +++ /dev/null @@ -1,26 +0,0 @@ -var _ = require('underscore'); -var TemplatedCell = require('../Cells/TemplatedCell'); -//require('../Handlebars/Helpers/Numbers'); - -module.exports = TemplatedCell.extend({ - className : 'seasons-cell', - template : 'SeasonPass/SeasonsCellTemplate', - - events : { - 'click .x-season-monitored' : '_toggleSeasonMonitored' - }, - - _toggleSeasonMonitored : function(e) { - var target = this.$(e.target).closest('.x-season-monitored'); - var seasonNumber = parseInt(this.$(target).data('season-number'), 10); - var icon = this.$(target).children('.x-season-monitored-icon'); - - this.model.setSeasonMonitored(seasonNumber); - - //TODO: unbounce the save so we don't multiple to the server at the same time - var savePromise = this.model.save(); - - icon.spinForPromise(savePromise); - savePromise.always(this.render.bind(this)); - } -}); \ No newline at end of file diff --git a/src/UI/SeasonPass/SeasonsCellTemplate.hbs b/src/UI/SeasonPass/SeasonsCellTemplate.hbs deleted file mode 100644 index d9966aec8..000000000 --- a/src/UI/SeasonPass/SeasonsCellTemplate.hbs +++ /dev/null @@ -1,37 +0,0 @@ -{{#each seasons}} - {{debug}} - {{#if_eq statistics.totalEpisodeCount compare=0}} - <span class="season season-unaired"> - {{else}} - {{#if_eq statistics.percentOfEpisodes compare=100}} - <span class="season season-all"> - {{else}} - <span class="season season-partial"> - {{/if_eq}} - {{/if_eq}} - <span class="label"> - <span class="x-season-monitored season-monitored" title="Toggle season monitored status" data-season-number="{{seasonNumber}}"> - <i class="x-season-monitored-icon {{#if monitored}}icon-sonarr-monitored{{else}}icon-sonarr-unmonitored{{/if}}"/> - </span> - {{#if_eq seasonNumber compare="0"}} - <span class="season-number">Specials</span> - {{else}} - <span class="season-number">S{{Pad2 seasonNumber}}</span> - {{/if_eq}} - </span><span class="label"> - {{#with statistics}} - {{#if_eq totalEpisodeCount compare=0}} - <span class="season-status" title="No aired episodes"> </span> - {{else}} - {{#if_eq percentOfEpisodes compare=100}} - <span class="season-status" title="{{episodeFileCount}}/{{totalEpisodeCount}} episodes downloaded">{{episodeFileCount}}/{{totalEpisodeCount}}</span> - {{else}} - <span class="season-status" title="{{episodeFileCount}}/{{totalEpisodeCount}} episodes downloaded">{{episodeFileCount}}/{{totalEpisodeCount}}</span> - {{/if_eq}} - {{/if_eq}} - {{else}} - <span class="season-status" title="No aired episodes"> </span> - {{/with}} - </span> - </span> -{{/each}} \ No newline at end of file diff --git a/src/UI/SeasonPass/seasonpass.less b/src/UI/SeasonPass/seasonpass.less deleted file mode 100644 index 4b1810280..000000000 --- a/src/UI/SeasonPass/seasonpass.less +++ /dev/null @@ -1,54 +0,0 @@ -@import "../Content/badges.less"; -@import "../Shared/Styles/clickable.less"; - -.season { - display : inline-block; - margin-bottom : 4px; - - .label { - .badge-inverse(); - - display : inline-block; - padding : 4px; - - font-size : 14px; - height : 25px; - } - - .label:first-child { - border-right : 0px; - border-top-right-radius : 0.0em; - border-bottom-right-radius : 0.0em; - color : #777; - background-color : #eee; - } - - .label:last-child { - border-left : 0px; - border-top-left-radius : 0.0em; - border-bottom-left-radius : 0.0em; - color : #999; - background-color : #f7f7f7; - } - - &.season-all .label:last-child { - background-color : #e0ffe0; - } - - .season-monitored { - width : 16px; - - i { - .clickable(); - } - } - - .season-number { - font-size : 12px; - } - - .season-status { - display : inline-block; - vertical-align : baseline !important; - } -} diff --git a/src/UI/Series/Delete/DeleteSeriesTemplate.hbs b/src/UI/Series/Delete/DeleteSeriesTemplate.hbs deleted file mode 100644 index 7ff12ad0b..000000000 --- a/src/UI/Series/Delete/DeleteSeriesTemplate.hbs +++ /dev/null @@ -1,50 +0,0 @@ -<div class="modal-content"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - <h3>Delete {{title}}</h3> - </div> - <div class="modal-body delete-series-modal"> - - <div class="row"> - <div class="col-sm-3 hidden-xs"> - {{poster}} - </div> - <div class="col-sm-9"> - <div class="form-horizontal"> - <h3 class="path">{{path}}</h3> - - <div class="form-group"> - <label class="col-sm-4 control-label">Delete all files</label> - - <div class="col-sm-8"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" class="x-delete-files"/> - <p> - <span>Yes</span> - <span>No</span> - </p> - - <div class="btn slide-button btn-danger"/> - </label> - - <span class="help-inline-checkbox"> - <i class="icon-sonarr-form-info" title="Do you want to delete all files from disk?"/> - <i class="icon-sonarr-form-warning" title="This option is irreversible, use with extreme caution"/> - </span> - </div> - </div> - </div> - <div class="col-md-offset-1 col-md-5 delete-files-info x-delete-files-info"> - {{episodeFileCount}} episode files will be deleted - </div> - </div> - </div> - </div> - </div> - <div class="modal-footer"> - <span class="indicator x-indicator"><i class="icon-sonarr-spinner fa-spin"></i></span> - <button class="btn" data-dismiss="modal">Cancel</button> - <button class="btn btn-danger x-confirm-delete">Delete</button> - </div> -</div> diff --git a/src/UI/Series/Delete/DeleteSeriesView.js b/src/UI/Series/Delete/DeleteSeriesView.js deleted file mode 100644 index de6640b5e..000000000 --- a/src/UI/Series/Delete/DeleteSeriesView.js +++ /dev/null @@ -1,41 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'Series/Delete/DeleteSeriesTemplate', - - events : { - 'click .x-confirm-delete' : 'removeSeries', - 'change .x-delete-files' : 'changeDeletedFiles' - }, - - ui : { - deleteFiles : '.x-delete-files', - deleteFilesInfo : '.x-delete-files-info', - indicator : '.x-indicator' - }, - - removeSeries : function() { - var self = this; - var deleteFiles = this.ui.deleteFiles.prop('checked'); - this.ui.indicator.show(); - - this.model.destroy({ - data : { 'deleteFiles' : deleteFiles }, - wait : true - }).done(function() { - vent.trigger(vent.Events.SeriesDeleted, { series : self.model }); - vent.trigger(vent.Commands.CloseModalCommand); - }); - }, - - changeDeletedFiles : function() { - var deleteFiles = this.ui.deleteFiles.prop('checked'); - - if (deleteFiles) { - this.ui.deleteFilesInfo.show(); - } else { - this.ui.deleteFilesInfo.hide(); - } - } -}); \ No newline at end of file diff --git a/src/UI/Series/Details/EpisodeNumberCell.js b/src/UI/Series/Details/EpisodeNumberCell.js deleted file mode 100644 index 9a84e644e..000000000 --- a/src/UI/Series/Details/EpisodeNumberCell.js +++ /dev/null @@ -1,47 +0,0 @@ -var Marionette = require('marionette'); -var NzbDroneCell = require('../../Cells/NzbDroneCell'); -var reqres = require('../../reqres'); -var SeriesCollection = require('../SeriesCollection'); - -module.exports = NzbDroneCell.extend({ - className : 'episode-number-cell', - template : 'Series/Details/EpisodeNumberCellTemplate', - - render : function() { - this.$el.empty(); - this.$el.html(this.model.get('episodeNumber')); - - var series = SeriesCollection.get(this.model.get('seriesId')); - - if (series.get('seriesType') === 'anime' && this.model.has('absoluteEpisodeNumber')) { - this.$el.html('{0} ({1})'.format(this.model.get('episodeNumber'), this.model.get('absoluteEpisodeNumber'))); - } - - var alternateTitles = []; - - if (reqres.hasHandler(reqres.Requests.GetAlternateNameBySeasonNumber)) { - alternateTitles = reqres.request(reqres.Requests.GetAlternateNameBySeasonNumber, this.model.get('seriesId'), this.model.get('seasonNumber'), this.model.get('sceneSeasonNumber')); - } - - if (this.model.get('sceneSeasonNumber') > 0 || this.model.get('sceneEpisodeNumber') > 0 || this.model.has('sceneAbsoluteEpisodeNumber') || alternateTitles.length > 0) { - this.templateFunction = Marionette.TemplateCache.get(this.template); - - var json = this.model.toJSON(); - json.alternateTitles = alternateTitles; - - var html = this.templateFunction(json); - - this.$el.popover({ - content : html, - html : true, - trigger : 'hover', - title : 'Scene Information', - placement : 'right', - container : this.$el - }); - } - - this.delegateEvents(); - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Series/Details/EpisodeNumberCellTemplate.hbs b/src/UI/Series/Details/EpisodeNumberCellTemplate.hbs deleted file mode 100644 index a9028a423..000000000 --- a/src/UI/Series/Details/EpisodeNumberCellTemplate.hbs +++ /dev/null @@ -1,39 +0,0 @@ -<div class="scene-info"> - {{#if sceneSeasonNumber}} - <div class="row"> - <div class="key">Season</div> - <div class="value">{{sceneSeasonNumber}}</div> - </div> - {{/if}} - - {{#if sceneEpisodeNumber}} - <div class="row"> - <div class="key">Episode</div> - <div class="value">{{sceneEpisodeNumber}}</div> - </div> - {{/if}} - - {{#if sceneAbsoluteEpisodeNumber}} - <div class="row"> - <div class="key">Absolute</div> - <div class="value">{{sceneAbsoluteEpisodeNumber}}</div> - </div> - {{/if}} - - {{#if alternateTitles}} - <div class="row"> - {{#if_gt alternateTitles.length compare="1"}} - <div class="key">Titles</div> - {{else}} - <div class="key">Title</div> - {{/if_gt}} - <div class="value"> - <ul> - {{#each alternateTitles}} - <li>{{title}}</li> - {{/each}} - </ul> - </div> - </div> - {{/if}} -</div> \ No newline at end of file diff --git a/src/UI/Series/Details/EpisodeWarningCell.js b/src/UI/Series/Details/EpisodeWarningCell.js deleted file mode 100644 index c9befe7a1..000000000 --- a/src/UI/Series/Details/EpisodeWarningCell.js +++ /dev/null @@ -1,21 +0,0 @@ -var NzbDroneCell = require('../../Cells/NzbDroneCell'); -var SeriesCollection = require('../SeriesCollection'); - -module.exports = NzbDroneCell.extend({ - className : 'episode-warning-cell', - - render : function() { - this.$el.empty(); - - if (this.model.get('unverifiedSceneNumbering')) { - this.$el.html('<i class="icon-sonarr-form-warning" title="Scene number hasn\'t been verified yet."></i>'); - } - - else if (SeriesCollection.get(this.model.get('seriesId')).get('seriesType') === 'anime' && this.model.get('seasonNumber') > 0 && !this.model.has('absoluteEpisodeNumber')) { - this.$el.html('<i class="icon-sonarr-form-warning" title="Episode does not have an absolute episode number"></i>'); - } - - this.delegateEvents(); - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Series/Details/InfoView.js b/src/UI/Series/Details/InfoView.js deleted file mode 100644 index c7fab9fc4..000000000 --- a/src/UI/Series/Details/InfoView.js +++ /dev/null @@ -1,18 +0,0 @@ -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'Series/Details/InfoViewTemplate', - - initialize : function(options) { - this.episodeFileCollection = options.episodeFileCollection; - - this.listenTo(this.model, 'change', this.render); - this.listenTo(this.episodeFileCollection, 'sync', this.render); - }, - - templateHelpers : function() { - return { - fileCount : this.episodeFileCollection.length - }; - } -}); \ No newline at end of file diff --git a/src/UI/Series/Details/InfoViewTemplate.hbs b/src/UI/Series/Details/InfoViewTemplate.hbs deleted file mode 100644 index b52130246..000000000 --- a/src/UI/Series/Details/InfoViewTemplate.hbs +++ /dev/null @@ -1,73 +0,0 @@ -<div class="row"> - <div class="col-md-9"> - {{profile profileId}} - - {{#if network}} - <span class="label label-info">{{network}}</span> - {{/if}} - - <span class="label label-info">{{runtime}} minutes</span> - <span class="label label-info">{{path}}</span> - - {{#if ratings}} - <span class="label label-info" title="{{ratings.votes}} vote{{#if_gt ratings.votes compare="1"}}s{{/if_gt}}">{{ratings.value}}</span> - {{/if}} - - <span class="label label-info">{{Bytes sizeOnDisk}}</span> - - {{#if_eq fileCount compare="1"}} - <span class="label label-info"> 1 file</span> - {{else}} - <span class="label label-info"> {{fileCount}} files</span> - {{/if_eq}} - - {{#if_eq status compare="continuing"}} - <span class="label label-info">Continuing</span> - {{else}} - <span class="label label-default">Ended</span> - {{/if_eq}} - </div> - <div class="col-md-3"> - <span class="series-info-links"> - <a href="{{traktUrl}}" class="label label-info">Trakt</a> - - <a href="{{tvdbUrl}}" class="label label-info">The TVDB</a> - - {{#if imdbId}} - <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}} - </span> - </div> -</div> - -{{#if alternateTitles}} -<div class="row"> - <div class="col-md-12"> - {{#each alternateTitles}} - {{#if_eq seasonNumber compare="-1"}} - <span class="label label-default">{{title}}</span> - {{/if_eq}} - - {{#if_eq sceneSeasonNumber compare="-1"}} - <span class="label label-default">{{title}}</span> - {{/if_eq}} - {{/each}} - </div> -</div> -{{/if}} - -{{#if tags}} -<div class="row"> - <div class="col-md-12"> - {{tagDisplay tags}} - </div> -</div> -{{/if}} diff --git a/src/UI/Series/Details/SeasonCollectionView.js b/src/UI/Series/Details/SeasonCollectionView.js deleted file mode 100644 index 24da6171c..000000000 --- a/src/UI/Series/Details/SeasonCollectionView.js +++ /dev/null @@ -1,44 +0,0 @@ -var _ = require('underscore'); -var Marionette = require('marionette'); -var SeasonLayout = require('./SeasonLayout'); -var AsSortedCollectionView = require('../../Mixins/AsSortedCollectionView'); - -var view = Marionette.CollectionView.extend({ - - itemView : SeasonLayout, - - initialize : function(options) { - if (!options.episodeCollection) { - throw 'episodeCollection is needed'; - } - - this.episodeCollection = options.episodeCollection; - this.series = options.series; - }, - - itemViewOptions : function() { - return { - episodeCollection : this.episodeCollection, - series : this.series - }; - }, - - onEpisodeGrabbed : function(message) { - if (message.episode.series.id !== this.episodeCollection.seriesId) { - return; - } - - var self = this; - - _.each(message.episode.episodes, function(episode) { - var ep = self.episodeCollection.get(episode.id); - ep.set('downloading', true); - }); - - this.render(); - } -}); - -AsSortedCollectionView.call(view); - -module.exports = view; \ No newline at end of file diff --git a/src/UI/Series/Details/SeasonLayout.js b/src/UI/Series/Details/SeasonLayout.js deleted file mode 100644 index cf10b6fa8..000000000 --- a/src/UI/Series/Details/SeasonLayout.js +++ /dev/null @@ -1,301 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); -var Backgrid = require('backgrid'); -var ToggleCell = require('../../Cells/EpisodeMonitoredCell'); -var EpisodeTitleCell = require('../../Cells/EpisodeTitleCell'); -var RelativeDateCell = require('../../Cells/RelativeDateCell'); -var EpisodeStatusCell = require('../../Cells/EpisodeStatusCell'); -var EpisodeActionsCell = require('../../Cells/EpisodeActionsCell'); -var EpisodeNumberCell = require('./EpisodeNumberCell'); -var EpisodeWarningCell = require('./EpisodeWarningCell'); -var CommandController = require('../../Commands/CommandController'); -var EpisodeFileEditorLayout = require('../../EpisodeFile/Editor/EpisodeFileEditorLayout'); -var moment = require('moment'); -var _ = require('underscore'); -var Messenger = require('../../Shared/Messenger'); - -module.exports = Marionette.Layout.extend({ - template : 'Series/Details/SeasonLayoutTemplate', - - ui : { - seasonSearch : '.x-season-search', - seasonMonitored : '.x-season-monitored', - seasonRename : '.x-season-rename' - }, - - events : { - 'click .x-season-episode-file-editor' : '_openEpisodeFileEditor', - 'click .x-season-monitored' : '_seasonMonitored', - 'click .x-season-search' : '_seasonSearch', - 'click .x-season-rename' : '_seasonRename', - 'click .x-show-hide-episodes' : '_showHideEpisodes', - 'dblclick .series-season h2' : '_showHideEpisodes' - }, - - regions : { - episodeGrid : '.x-episode-grid' - }, - - columns : [ - { - name : 'monitored', - label : '', - cell : ToggleCell, - trueClass : 'icon-sonarr-monitored', - falseClass : 'icon-sonarr-unmonitored', - tooltip : 'Toggle monitored status', - sortable : false - }, - { - name : 'episodeNumber', - label : '#', - cell : EpisodeNumberCell - }, - { - name : 'this', - label : '', - cell : EpisodeWarningCell, - sortable : false, - className : 'episode-warning-cell' - }, - { - name : 'this', - label : 'Title', - hideSeriesLink : true, - cell : EpisodeTitleCell, - sortable : false - }, - { - name : 'airDateUtc', - label : 'Air Date', - cell : RelativeDateCell - }, - { - name : 'status', - label : 'Status', - cell : EpisodeStatusCell, - sortable : false - }, - { - name : 'this', - label : '', - cell : EpisodeActionsCell, - sortable : false - } - ], - - templateHelpers : function() { - var episodeCount = this.episodeCollection.filter(function(episode) { - return episode.get('hasFile') || episode.get('monitored') && moment(episode.get('airDateUtc')).isBefore(moment()); - }).length; - - var episodeFileCount = this.episodeCollection.where({ hasFile : true }).length; - var percentOfEpisodes = 100; - - if (episodeCount > 0) { - percentOfEpisodes = episodeFileCount / episodeCount * 100; - } - - return { - showingEpisodes : this.showingEpisodes, - episodeCount : episodeCount, - episodeFileCount : episodeFileCount, - percentOfEpisodes : percentOfEpisodes - }; - }, - - initialize : function(options) { - if (!options.episodeCollection) { - throw 'episodeCollection is required'; - } - - this.series = options.series; - this.fullEpisodeCollection = options.episodeCollection; - this.episodeCollection = this.fullEpisodeCollection.bySeason(this.model.get('seasonNumber')); - this._updateEpisodeCollection(); - - this.showingEpisodes = this._shouldShowEpisodes(); - - this.listenTo(this.model, 'sync', this._afterSeasonMonitored); - this.listenTo(this.episodeCollection, 'sync', this.render); - - this.listenTo(this.fullEpisodeCollection, 'sync', this._refreshEpisodes); - }, - - onRender : function() { - if (this.showingEpisodes) { - this._showEpisodes(); - } - - this._setSeasonMonitoredState(); - - CommandController.bindToCommand({ - element : this.ui.seasonSearch, - command : { - name : 'seasonSearch', - seriesId : this.series.id, - seasonNumber : this.model.get('seasonNumber') - } - }); - - CommandController.bindToCommand({ - element : this.ui.seasonRename, - command : { - name : 'renameFiles', - seriesId : this.series.id, - seasonNumber : this.model.get('seasonNumber') - } - }); - }, - - _seasonSearch : function() { - CommandController.Execute('seasonSearch', { - name : 'seasonSearch', - seriesId : this.series.id, - seasonNumber : this.model.get('seasonNumber') - }); - }, - - _seasonRename : function() { - vent.trigger(vent.Commands.ShowRenamePreview, { - series : this.series, - seasonNumber : this.model.get('seasonNumber') - }); - }, - - _seasonMonitored : function() { - if (!this.series.get('monitored')) { - - Messenger.show({ - message : 'Unable to change monitored state when series is not monitored', - type : 'error' - }); - - return; - } - - var name = 'monitored'; - this.model.set(name, !this.model.get(name)); - this.series.setSeasonMonitored(this.model.get('seasonNumber')); - - var savePromise = this.series.save().always(this._afterSeasonMonitored.bind(this)); - - this.ui.seasonMonitored.spinForPromise(savePromise); - }, - - _afterSeasonMonitored : function() { - var self = this; - - _.each(this.episodeCollection.models, function(episode) { - episode.set({ monitored : self.model.get('monitored') }); - }); - - this.render(); - }, - - _setSeasonMonitoredState : function() { - this.ui.seasonMonitored.removeClass('icon-sonarr-spinner fa-spin'); - - if (this.model.get('monitored')) { - this.ui.seasonMonitored.addClass('icon-sonarr-monitored'); - this.ui.seasonMonitored.removeClass('icon-sonarr-unmonitored'); - } else { - this.ui.seasonMonitored.addClass('icon-sonarr-unmonitored'); - this.ui.seasonMonitored.removeClass('icon-sonarr-monitored'); - } - }, - - _showEpisodes : function() { - this.episodeGrid.show(new Backgrid.Grid({ - columns : this.columns, - collection : this.episodeCollection, - className : 'table table-hover season-grid' - })); - }, - - _shouldShowEpisodes : function() { - var startDate = moment().add('month', -1); - var endDate = moment().add('year', 1); - - return this.episodeCollection.some(function(episode) { - var airDate = episode.get('airDateUtc'); - - if (airDate) { - var airDateMoment = moment(airDate); - - if (airDateMoment.isAfter(startDate) && airDateMoment.isBefore(endDate)) { - return true; - } - } - - return false; - }); - }, - - _showHideEpisodes : function() { - if (this.showingEpisodes) { - this.showingEpisodes = false; - this.episodeGrid.close(); - } else { - this.showingEpisodes = true; - this._showEpisodes(); - } - - this.templateHelpers.showingEpisodes = this.showingEpisodes; - this.render(); - }, - - _episodeMonitoredToggled : function(options) { - var model = options.model; - var shiftKey = options.shiftKey; - - if (!this.episodeCollection.get(model.get('id'))) { - return; - } - - if (!shiftKey) { - return; - } - - var lastToggled = this.episodeCollection.lastToggled; - - if (!lastToggled) { - return; - } - - var currentIndex = this.episodeCollection.indexOf(model); - var lastIndex = this.episodeCollection.indexOf(lastToggled); - - var low = Math.min(currentIndex, lastIndex); - var high = Math.max(currentIndex, lastIndex); - var range = _.range(low + 1, high); - - this.episodeCollection.lastToggled = model; - }, - - _updateEpisodeCollection : function() { - var self = this; - - this.episodeCollection.add(this.fullEpisodeCollection.bySeason(this.model.get('seasonNumber')).models, { merge : true }); - - this.episodeCollection.each(function(model) { - model.episodeCollection = self.episodeCollection; - }); - }, - - _refreshEpisodes : function() { - this._updateEpisodeCollection(); - this.episodeCollection.fullCollection.sort(); - this.render(); - }, - - _openEpisodeFileEditor : function() { - var view = new EpisodeFileEditorLayout({ - model : this.model, - series : this.series, - episodeCollection : this.episodeCollection - }); - - vent.trigger(vent.Commands.OpenModalCommand, view); - } -}); \ No newline at end of file diff --git a/src/UI/Series/Details/SeasonLayoutTemplate.hbs b/src/UI/Series/Details/SeasonLayoutTemplate.hbs deleted file mode 100644 index 06034f19d..000000000 --- a/src/UI/Series/Details/SeasonLayoutTemplate.hbs +++ /dev/null @@ -1,50 +0,0 @@ -<div class="series-season" id="season-{{seasonNumber}}"> - <h2> - <i class="x-season-monitored season-monitored clickable" title="Toggle season monitored status"/> - - {{#if seasonNumber}} - Season {{seasonNumber}} - {{else}} - Specials - {{/if}} - - - {{#if_eq episodeCount compare=0}} - {{#if monitored}} - <span class="badge badge-primary season-status" title="No aired episodes"> </span> - {{else}} - <span class="badge badge-warning season-status" title="Season is not monitored"> </span> - {{/if}} - {{else}} - {{#if_eq percentOfEpisodes compare=100}} - <span class="badge badge-success season-status" title="{{episodeFileCount}}/{{episodeCount}} episodes downloaded">{{episodeFileCount}} / {{episodeCount}}</span> - {{else}} - <span class="badge badge-danger season-status" title="{{episodeFileCount}}/{{episodeCount}} episodes downloaded">{{episodeFileCount}} / {{episodeCount}}</span> - {{/if_eq}} - {{/if_eq}} - - <span class="season-actions pull-right"> - <div class="x-season-episode-file-editor"> - <i class="icon-sonarr-episode-file" title="Modify episode files for season"/> - </div> - <div class="x-season-rename"> - <i class="icon-sonarr-rename" title="Preview rename for season {{seasonNumber}}"/> - </div> - <div class="x-season-search"> - <i class="icon-sonarr-search" title="Search for monitored episodes in season {{seasonNumber}}"/> - </div> - </span> - </h2> - <div class="show-hide-episodes x-show-hide-episodes"> - <h4> - {{#if showingEpisodes}} - <i class="icon-sonarr-panel-hide"/> - Hide Episodes - {{else}} - <i class="icon-sonarr-panel-show"/> - Show Episodes - {{/if}} - </h4> - </div> - <div class="x-episode-grid table-responsive"></div> -</div> diff --git a/src/UI/Series/Details/SeriesDetailsLayout.js b/src/UI/Series/Details/SeriesDetailsLayout.js deleted file mode 100644 index f33cb0414..000000000 --- a/src/UI/Series/Details/SeriesDetailsLayout.js +++ /dev/null @@ -1,258 +0,0 @@ -var $ = require('jquery'); -var _ = require('underscore'); -var vent = require('vent'); -var reqres = require('../../reqres'); -var Marionette = require('marionette'); -var Backbone = require('backbone'); -var SeriesCollection = require('../SeriesCollection'); -var EpisodeCollection = require('../EpisodeCollection'); -var EpisodeFileCollection = require('../EpisodeFileCollection'); -var SeasonCollection = require('../SeasonCollection'); -var SeasonCollectionView = require('./SeasonCollectionView'); -var InfoView = require('./InfoView'); -var CommandController = require('../../Commands/CommandController'); -var LoadingView = require('../../Shared/LoadingView'); -var EpisodeFileEditorLayout = require('../../EpisodeFile/Editor/EpisodeFileEditorLayout'); -require('backstrech'); -require('../../Mixins/backbone.signalr.mixin'); - -module.exports = Marionette.Layout.extend({ - itemViewContainer : '.x-series-seasons', - template : 'Series/Details/SeriesDetailsTemplate', - - regions : { - seasons : '#seasons', - info : '#info' - }, - - ui : { - header : '.x-header', - monitored : '.x-monitored', - edit : '.x-edit', - refresh : '.x-refresh', - rename : '.x-rename', - search : '.x-search', - poster : '.x-series-poster' - }, - - events : { - 'click .x-episode-file-editor' : '_openEpisodeFileEditor', - 'click .x-monitored' : '_toggleMonitored', - 'click .x-edit' : '_editSeries', - 'click .x-refresh' : '_refreshSeries', - 'click .x-rename' : '_renameSeries', - 'click .x-search' : '_seriesSearch' - }, - - initialize : function() { - this.seriesCollection = SeriesCollection.clone(); - this.seriesCollection.shadowCollection.bindSignalR(); - - this.listenTo(this.model, 'change:monitored', this._setMonitoredState); - this.listenTo(this.model, 'remove', this._seriesRemoved); - this.listenTo(vent, vent.Events.CommandComplete, this._commandComplete); - - this.listenTo(this.model, 'change', function(model, options) { - if (options && options.changeSource === 'signalr') { - this._refresh(); - } - }); - - this.listenTo(this.model, 'change:images', this._updateImages); - }, - - onShow : function() { - this._showBackdrop(); - this._showSeasons(); - this._setMonitoredState(); - this._showInfo(); - }, - - onRender : function() { - CommandController.bindToCommand({ - element : this.ui.refresh, - command : { - name : 'refreshSeries' - } - }); - CommandController.bindToCommand({ - element : this.ui.search, - command : { - name : 'seriesSearch' - } - }); - - CommandController.bindToCommand({ - element : this.ui.rename, - command : { - name : 'renameFiles', - seriesId : this.model.id, - seasonNumber : -1 - } - }); - }, - - onClose : function() { - if (this._backstrech) { - this._backstrech.destroy(); - delete this._backstrech; - } - - $('body').removeClass('backdrop'); - reqres.removeHandler(reqres.Requests.GetEpisodeFileById); - }, - - _getImage : function(type) { - var image = _.where(this.model.get('images'), { coverType : type }); - - if (image && image[0]) { - return image[0].url; - } - - return undefined; - }, - - _toggleMonitored : function() { - var savePromise = this.model.save('monitored', !this.model.get('monitored'), { wait : true }); - - this.ui.monitored.spinForPromise(savePromise); - }, - - _setMonitoredState : function() { - var monitored = this.model.get('monitored'); - - this.ui.monitored.removeAttr('data-idle-icon'); - this.ui.monitored.removeClass('fa-spin icon-sonarr-spinner'); - - if (monitored) { - this.ui.monitored.addClass('icon-sonarr-monitored'); - this.ui.monitored.removeClass('icon-sonarr-unmonitored'); - this.$el.removeClass('series-not-monitored'); - } else { - this.ui.monitored.addClass('icon-sonarr-unmonitored'); - this.ui.monitored.removeClass('icon-sonarr-monitored'); - this.$el.addClass('series-not-monitored'); - } - }, - - _editSeries : function() { - vent.trigger(vent.Commands.EditSeriesCommand, { series : this.model }); - }, - - _refreshSeries : function() { - CommandController.Execute('refreshSeries', { - name : 'refreshSeries', - seriesId : this.model.id - }); - }, - - _seriesRemoved : function() { - Backbone.history.navigate('/', { trigger : true }); - }, - - _renameSeries : function() { - vent.trigger(vent.Commands.ShowRenamePreview, { series : this.model }); - }, - - _seriesSearch : function() { - CommandController.Execute('seriesSearch', { - name : 'seriesSearch', - seriesId : this.model.id - }); - }, - - _showSeasons : function() { - var self = this; - - this.seasons.show(new LoadingView()); - - this.seasonCollection = new SeasonCollection(this.model.get('seasons')); - this.episodeCollection = new EpisodeCollection({ seriesId : this.model.id }).bindSignalR(); - this.episodeFileCollection = new EpisodeFileCollection({ seriesId : this.model.id }).bindSignalR(); - - reqres.setHandler(reqres.Requests.GetEpisodeFileById, function(episodeFileId) { - return self.episodeFileCollection.get(episodeFileId); - }); - - reqres.setHandler(reqres.Requests.GetAlternateNameBySeasonNumber, function(seriesId, seasonNumber, sceneSeasonNumber) { - if (self.model.get('id') !== seriesId) { - return []; - } - - if (sceneSeasonNumber === undefined) { - sceneSeasonNumber = seasonNumber; - } - - return _.where(self.model.get('alternateTitles'), - function(alt) { - return alt.sceneSeasonNumber === sceneSeasonNumber || alt.seasonNumber === seasonNumber; - }); - }); - - $.when(this.episodeCollection.fetch(), this.episodeFileCollection.fetch()).done(function() { - var seasonCollectionView = new SeasonCollectionView({ - collection : self.seasonCollection, - episodeCollection : self.episodeCollection, - series : self.model - }); - - if (!self.isClosed) { - self.seasons.show(seasonCollectionView); - } - }); - }, - - _showInfo : function() { - this.info.show(new InfoView({ - model : this.model, - episodeFileCollection : this.episodeFileCollection - })); - }, - - _commandComplete : function(options) { - if (options.command.get('name') === 'renamefiles') { - if (options.command.get('seriesId') === this.model.get('id')) { - this._refresh(); - } - } - }, - - _refresh : function() { - this.seasonCollection.add(this.model.get('seasons'), { merge : true }); - this.episodeCollection.fetch(); - this.episodeFileCollection.fetch(); - - this._setMonitoredState(); - this._showInfo(); - }, - - _openEpisodeFileEditor : function() { - var view = new EpisodeFileEditorLayout({ - series : this.model, - episodeCollection : this.episodeCollection - }); - - vent.trigger(vent.Commands.OpenModalCommand, view); - }, - - _updateImages : function () { - var poster = this._getImage('poster'); - - if (poster) { - this.ui.poster.attr('src', poster); - } - - this._showBackdrop(); - }, - - _showBackdrop : function () { - $('body').addClass('backdrop'); - var fanArt = this._getImage('fanart'); - - if (fanArt) { - this._backstrech = $.backstretch(fanArt); - } else { - $('body').removeClass('backdrop'); - } - } -}); \ No newline at end of file diff --git a/src/UI/Series/Details/SeriesDetailsTemplate.hbs b/src/UI/Series/Details/SeriesDetailsTemplate.hbs deleted file mode 100644 index 818cee455..000000000 --- a/src/UI/Series/Details/SeriesDetailsTemplate.hbs +++ /dev/null @@ -1,35 +0,0 @@ -<div class="row series-page-header"> - <div class="visible-lg col-lg-2 poster"> - {{poster}} - </div> - <div class="col-md-12 col-lg-10"> - <div> - <h1 class="header-text"> - <i class="x-monitored" title="Toggle monitored state for entire series"/> - {{title}} - <div class="series-actions pull-right"> - <div class="x-episode-file-editor"> - <i class="icon-sonarr-episode-file" title="Modify episode files for series"/> - </div> - <div class="x-refresh"> - <i class="icon-sonarr-refresh icon-can-spin" title="Update series info and scan disk"/> - </div> - <div class="x-rename"> - <i class="icon-sonarr-rename" title="Preview rename for all episodes"/> - </div> - <div class="x-search"> - <i class="icon-sonarr-search" title="Search for monitored episodes in this series"/> - </div> - <div class="x-edit"> - <i class="icon-sonarr-edit" title="Edit series"/> - </div> - </div> - </h1> - </div> - <div class="series-detail-overview"> - {{overview}} - </div> - <div id="info" class="series-info"></div> - </div> -</div> -<div id="seasons"></div> diff --git a/src/UI/Series/Edit/EditSeriesView.js b/src/UI/Series/Edit/EditSeriesView.js deleted file mode 100644 index 3f8c789e8..000000000 --- a/src/UI/Series/Edit/EditSeriesView.js +++ /dev/null @@ -1,54 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); -var Profiles = require('../../Profile/ProfileCollection'); -var AsModelBoundView = require('../../Mixins/AsModelBoundView'); -var AsValidatedView = require('../../Mixins/AsValidatedView'); -var AsEditModalView = require('../../Mixins/AsEditModalView'); -require('../../Mixins/TagInput'); -require('../../Mixins/FileBrowser'); - -var view = Marionette.ItemView.extend({ - template : 'Series/Edit/EditSeriesViewTemplate', - - ui : { - profile : '.x-profile', - path : '.x-path', - tags : '.x-tags' - }, - - events : { - 'click .x-remove' : '_removeSeries' - }, - - initialize : function() { - this.model.set('profiles', Profiles); - }, - - onRender : function() { - this.ui.path.fileBrowser(); - this.ui.tags.tagInput({ - model : this.model, - property : 'tags' - }); - }, - - _onBeforeSave : function() { - var profileId = this.ui.profile.val(); - this.model.set({ profileId : profileId }); - }, - - _onAfterSave : function() { - this.trigger('saved'); - vent.trigger(vent.Commands.CloseModalCommand); - }, - - _removeSeries : function() { - vent.trigger(vent.Commands.DeleteSeriesCommand, { series : this.model }); - } -}); - -AsModelBoundView.call(view); -AsValidatedView.call(view); -AsEditModalView.call(view); - -module.exports = view; \ No newline at end of file diff --git a/src/UI/Series/Edit/EditSeriesViewTemplate.hbs b/src/UI/Series/Edit/EditSeriesViewTemplate.hbs deleted file mode 100644 index 746504cc9..000000000 --- a/src/UI/Series/Edit/EditSeriesViewTemplate.hbs +++ /dev/null @@ -1,104 +0,0 @@ -<div class="modal-content"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - <h3>{{title}}</h3> - </div> - <div class="modal-body edit-series-modal"> - <div class="row"> - <div class="col-sm-3 hidden-xs"> - {{poster}} - </div> - <div class="col-sm-9"> - <div class="form-horizontal"> - - <div class="form-group"> - <label class="col-sm-4 control-label">Monitored</label> - - <div class="col-sm-8"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="monitored"/> - <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="Should Sonarr download episodes for this series?"/> - </span> - </div> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-4 control-label">Use Season Folder</label> - - <div class="col-sm-8"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="seasonFolder"/> - <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="Should downloaded episodes be stored in season folders?"/> - </span> - </div> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-4 control-label">Profile</label> - - <div class="col-sm-4"> - <select class="form-control x-profile" id="inputProfile" name="profileId"> - {{#each profiles.models}} - <option value="{{id}}">{{attributes.name}}</option> - {{/each}} - </select> - - </div> - </div> - - <div class="form-group"> - <label class="col-sm-4 control-label">Series Type</label> - <div class="col-sm-4"> - {{> SeriesTypeSelectionPartial}} - </div> - </div> - - <div class="form-group"> - <label class="col-sm-4 control-label">Path</label> - - <div class="col-sm-6"> - <input type="text" class="form-control x-path" placeholder="Path" name="path"> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-4 control-label">Tags</label> - - <div class="col-sm-6"> - <input type="text" class="form-control x-tags"> - </div> - </div> - </div> - </div> - </div> - </div> - <div class="modal-footer"> - <button class="btn btn-danger pull-left x-remove">Delete</button> - - <span class="indicator x-indicator"><i class="icon-sonarr-spinner fa-spin"></i></span> - <button class="btn" data-dismiss="modal">Cancel</button> - <button class="btn btn-primary x-save">Save</button> - </div> -</div> diff --git a/src/UI/Series/Editor/Organize/OrganizeFilesView.js b/src/UI/Series/Editor/Organize/OrganizeFilesView.js deleted file mode 100644 index 25534fb21..000000000 --- a/src/UI/Series/Editor/Organize/OrganizeFilesView.js +++ /dev/null @@ -1,33 +0,0 @@ -var _ = require('underscore'); -var vent = require('vent'); -var Backbone = require('backbone'); -var Marionette = require('marionette'); -var CommandController = require('../../../Commands/CommandController'); - -module.exports = Marionette.ItemView.extend({ - template : 'Series/Editor/Organize/OrganizeFilesViewTemplate', - - events : { - 'click .x-confirm-organize' : '_organize' - }, - - initialize : function(options) { - this.series = options.series; - this.templateHelpers = { - numberOfSeries : this.series.length, - series : new Backbone.Collection(this.series).toJSON() - }; - }, - - _organize : function() { - var seriesIds = _.pluck(this.series, 'id'); - - CommandController.Execute('renameSeries', { - name : 'renameSeries', - seriesIds : seriesIds - }); - - this.trigger('organizingFiles'); - vent.trigger(vent.Commands.CloseModalCommand); - } -}); \ No newline at end of file diff --git a/src/UI/Series/Editor/Organize/OrganizeFilesViewTemplate.hbs b/src/UI/Series/Editor/Organize/OrganizeFilesViewTemplate.hbs deleted file mode 100644 index 312c8b6e2..000000000 --- a/src/UI/Series/Editor/Organize/OrganizeFilesViewTemplate.hbs +++ /dev/null @@ -1,25 +0,0 @@ -<div class="modal-content"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - <h3>Organize of Selected Series</h3> - </div> - <div class="modal-body update-files-series-modal"> - <div class="alert alert-info"> - <button type="button" class="close" data-dismiss="alert">×</button> - Tip: To preview a rename... select "Cancel" then any series title and use the <i data-original-title="" class="icon-sonarr-rename" title=""></i> - </div> - - Are you sure you want to update all files in the {{numberOfSeries}} selected series? - - {{debug}} - <ul class="selected-series"> - {{#each series}} - <li>{{title}}</li> - {{/each}} - </ul> - </div> - <div class="modal-footer"> - <button class="btn" data-dismiss="modal">Cancel</button> - <button class="btn btn-danger x-confirm-organize">Organize</button> - </div> -</div> diff --git a/src/UI/Series/Editor/SeriesEditorFooterView.js b/src/UI/Series/Editor/SeriesEditorFooterView.js deleted file mode 100644 index 6f4f83a6c..000000000 --- a/src/UI/Series/Editor/SeriesEditorFooterView.js +++ /dev/null @@ -1,126 +0,0 @@ -var _ = require('underscore'); -var Marionette = require('marionette'); -var vent = require('vent'); -var Profiles = require('../../Profile/ProfileCollection'); -var RootFolders = require('../../AddSeries/RootFolders/RootFolderCollection'); -var RootFolderLayout = require('../../AddSeries/RootFolders/RootFolderLayout'); -var UpdateFilesSeriesView = require('./Organize/OrganizeFilesView'); -var Config = require('../../Config'); - -module.exports = Marionette.ItemView.extend({ - template : 'Series/Editor/SeriesEditorFooterViewTemplate', - - ui : { - monitored : '.x-monitored', - profile : '.x-profiles', - seasonFolder : '.x-season-folder', - rootFolder : '.x-root-folder', - selectedCount : '.x-selected-count', - container : '.series-editor-footer', - actions : '.x-action' - }, - - events : { - 'click .x-save' : '_updateAndSave', - 'change .x-root-folder' : '_rootFolderChanged', - 'click .x-organize-files' : '_organizeFiles' - }, - - templateHelpers : function() { - return { - profiles : Profiles, - rootFolders : RootFolders.toJSON() - }; - }, - - initialize : function(options) { - this.seriesCollection = options.collection; - - RootFolders.fetch().done(function() { - RootFolders.synced = true; - }); - - this.editorGrid = options.editorGrid; - this.listenTo(this.seriesCollection, 'backgrid:selected', this._updateInfo); - this.listenTo(RootFolders, 'all', this.render); - }, - - onRender : function() { - this._updateInfo(); - }, - - _updateAndSave : function() { - var selected = this.editorGrid.getSelectedModels(); - - var monitored = this.ui.monitored.val(); - var profile = this.ui.profile.val(); - var seasonFolder = this.ui.seasonFolder.val(); - var rootFolder = this.ui.rootFolder.val(); - - _.each(selected, function(model) { - if (monitored === 'true') { - model.set('monitored', true); - } else if (monitored === 'false') { - model.set('monitored', false); - } - - if (profile !== 'noChange') { - model.set('profileId', parseInt(profile, 10)); - } - - if (seasonFolder === 'true') { - model.set('seasonFolder', true); - } else if (seasonFolder === 'false') { - model.set('seasonFolder', false); - } - - if (rootFolder !== 'noChange') { - var rootFolderPath = RootFolders.get(parseInt(rootFolder, 10)); - - model.set('rootFolderPath', rootFolderPath.get('path')); - } - - model.edited = true; - }); - - this.seriesCollection.save(); - }, - - _updateInfo : function() { - var selected = this.editorGrid.getSelectedModels(); - var selectedCount = selected.length; - - this.ui.selectedCount.html('{0} series selected'.format(selectedCount)); - - if (selectedCount === 0) { - this.ui.actions.attr('disabled', 'disabled'); - } else { - this.ui.actions.removeAttr('disabled'); - } - }, - - _rootFolderChanged : function() { - var rootFolderValue = this.ui.rootFolder.val(); - if (rootFolderValue === 'addNew') { - var rootFolderLayout = new RootFolderLayout(); - this.listenToOnce(rootFolderLayout, 'folderSelected', this._setRootFolder); - vent.trigger(vent.Commands.OpenModalCommand, rootFolderLayout); - } else { - Config.setValue(Config.Keys.DefaultRootFolderId, rootFolderValue); - } - }, - - _setRootFolder : function(options) { - vent.trigger(vent.Commands.CloseModalCommand); - this.ui.rootFolder.val(options.model.id); - this._rootFolderChanged(); - }, - - _organizeFiles : function() { - var selected = this.editorGrid.getSelectedModels(); - var updateFilesSeriesView = new UpdateFilesSeriesView({ series : selected }); - this.listenToOnce(updateFilesSeriesView, 'updatingFiles', this._afterSave); - - vent.trigger(vent.Commands.OpenModalCommand, updateFilesSeriesView); - } -}); \ No newline at end of file diff --git a/src/UI/Series/Editor/SeriesEditorFooterViewTemplate.hbs b/src/UI/Series/Editor/SeriesEditorFooterViewTemplate.hbs deleted file mode 100644 index c47b3c50a..000000000 --- a/src/UI/Series/Editor/SeriesEditorFooterViewTemplate.hbs +++ /dev/null @@ -1,54 +0,0 @@ -<div class="series-editor-footer"> - <div class="row"> - <div class="form-group col-md-2"> - <label>Monitored</label> - - <select class="form-control x-action x-monitored"> - <option value="noChange">No change</option> - <option value="true">Monitored</option> - <option value="false">Unmonitored</option> - </select> - </div> - - <div class="form-group col-md-2"> - <label>Profile</label> - - <select class="form-control x-action x-profiles"> - <option value="noChange">No change</option> - {{#each profiles.models}} - <option value="{{id}}">{{attributes.name}}</option> - {{/each}} - </select> - </div> - - <div class="form-group col-md-2"> - <label>Season Folder</label> - - <select class="form-control x-action x-season-folder"> - <option value="noChange">No change</option> - <option value="true">Yes</option> - <option value="false">No</option> - </select> - </div> - - <div class="form-group col-md-3"> - <label>Root Folder</label> - - <select class="form-control x-action x-root-folder" validation-name="RootFolderPath"> - <option value="noChange">No change</option> - {{#each rootFolders}} - <option value="{{id}}">{{path}}</option> - {{/each}} - <option value="addNew">Add a different path</option> - </select> - </div> - - <div class="form-group col-md-3 actions"> - <label class="x-selected-count">0 series selected</label> - <div> - <button class="btn btn-primary x-action x-save">Save</button> - <button class="btn btn-danger x-action x-organize-files" title="Organize and rename episode files">Organize</button> - </div> - </div> - </div> -</div> diff --git a/src/UI/Series/Editor/SeriesEditorLayout.js b/src/UI/Series/Editor/SeriesEditorLayout.js deleted file mode 100644 index 2dd7dc3f0..000000000 --- a/src/UI/Series/Editor/SeriesEditorLayout.js +++ /dev/null @@ -1,184 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); -var Backgrid = require('backgrid'); -var EmptyView = require('../Index/EmptyView'); -var SeriesCollection = require('../SeriesCollection'); -var SeriesTitleCell = require('../../Cells/SeriesTitleCell'); -var ProfileCell = require('../../Cells/ProfileCell'); -var SeriesStatusCell = require('../../Cells/SeriesStatusCell'); -var SeasonFolderCell = require('../../Cells/SeasonFolderCell'); -var SelectAllCell = require('../../Cells/SelectAllCell'); -var ToolbarLayout = require('../../Shared/Toolbar/ToolbarLayout'); -var FooterView = require('./SeriesEditorFooterView'); -require('../../Mixins/backbone.signalr.mixin'); - -module.exports = Marionette.Layout.extend({ - template : 'Series/Editor/SeriesEditorLayoutTemplate', - - regions : { - seriesRegion : '#x-series-editor', - toolbar : '#x-toolbar' - }, - - ui : { - monitored : '.x-monitored', - profiles : '.x-profiles', - rootFolder : '.x-root-folder', - selectedCount : '.x-selected-count' - }, - - events : { - 'click .x-save' : '_updateAndSave', - 'change .x-root-folder' : '_rootFolderChanged' - }, - - columns : [ - { - name : '', - cell : SelectAllCell, - headerCell : 'select-all', - sortable : false - }, - { - name : 'statusWeight', - label : '', - cell : SeriesStatusCell - }, - { - name : 'title', - label : 'Title', - cell : SeriesTitleCell, - cellValue : 'this' - }, - { - name : 'profileId', - label : 'Profile', - cell : ProfileCell - }, - { - name : 'seasonFolder', - label : 'Season Folder', - cell : SeasonFolderCell - }, - { - name : 'path', - label : 'Path', - cell : 'string' - } - ], - - leftSideButtons : { - type : 'default', - storeState : false, - items : [ - { - title : 'Season Pass', - icon : 'icon-sonarr-monitored', - route : 'seasonpass' - }, - { - title : 'Update Library', - icon : 'icon-sonarr-refresh', - command : 'refreshseries', - successMessage : 'Library was updated!', - errorMessage : 'Library update failed!' - } - ] - }, - - initialize : function() { - this.seriesCollection = SeriesCollection.clone(); - this.seriesCollection.shadowCollection.bindSignalR(); - this.listenTo(this.seriesCollection, 'save', this.render); - - this.filteringOptions = { - type : 'radio', - storeState : true, - menuKey : 'serieseditor.filterMode', - defaultAction : 'all', - items : [ - { - key : 'all', - title : '', - tooltip : 'All', - icon : 'icon-sonarr-all', - callback : this._setFilter - }, - { - key : 'monitored', - title : '', - tooltip : 'Monitored Only', - icon : 'icon-sonarr-monitored', - callback : this._setFilter - }, - { - key : 'continuing', - title : '', - tooltip : 'Continuing Only', - icon : 'icon-sonarr-series-continuing', - callback : this._setFilter - }, - { - key : 'ended', - title : '', - tooltip : 'Ended Only', - icon : 'icon-sonarr-series-ended', - callback : this._setFilter - } - ] - }; - }, - - onRender : function() { - this._showToolbar(); - this._showTable(); - }, - - onClose : function() { - vent.trigger(vent.Commands.CloseControlPanelCommand); - }, - - _showTable : function() { - if (this.seriesCollection.shadowCollection.length === 0) { - this.seriesRegion.show(new EmptyView()); - this.toolbar.close(); - return; - } - - this.columns[0].sortedCollection = this.seriesCollection; - - this.editorGrid = new Backgrid.Grid({ - collection : this.seriesCollection, - columns : this.columns, - className : 'table table-hover' - }); - - this.seriesRegion.show(this.editorGrid); - this._showFooter(); - }, - - _showToolbar : function() { - this.toolbar.show(new ToolbarLayout({ - left : [ - this.leftSideButtons - ], - right : [ - this.filteringOptions - ], - context : this - })); - }, - - _showFooter : function() { - vent.trigger(vent.Commands.OpenControlPanelCommand, new FooterView({ - editorGrid : this.editorGrid, - collection : this.seriesCollection - })); - }, - - _setFilter : function(buttonContext) { - var mode = buttonContext.model.get('key'); - - this.seriesCollection.setFilterMode(mode); - } -}); \ No newline at end of file diff --git a/src/UI/Series/Editor/SeriesEditorLayoutTemplate.hbs b/src/UI/Series/Editor/SeriesEditorLayoutTemplate.hbs deleted file mode 100644 index 1d0519894..000000000 --- a/src/UI/Series/Editor/SeriesEditorLayoutTemplate.hbs +++ /dev/null @@ -1,7 +0,0 @@ -<div id="x-toolbar"></div> - -<div class="row"> - <div class="col-md-12"> - <div id="x-series-editor" class="table-responsive"></div> - </div> -</div> \ No newline at end of file diff --git a/src/UI/Series/EpisodeCollection.js b/src/UI/Series/EpisodeCollection.js deleted file mode 100644 index a6794394b..000000000 --- a/src/UI/Series/EpisodeCollection.js +++ /dev/null @@ -1,62 +0,0 @@ -var Backbone = require('backbone'); -var PageableCollection = require('backbone.pageable'); -var EpisodeModel = require('./EpisodeModel'); -require('./EpisodeCollection'); - -module.exports = PageableCollection.extend({ - url : window.NzbDrone.ApiRoot + '/episode', - model : EpisodeModel, - - state : { - sortKey : 'episodeNumber', - order : 1, - pageSize : 100000 - }, - - mode : 'client', - - originalFetch : Backbone.Collection.prototype.fetch, - - initialize : function(options) { - this.seriesId = options.seriesId; - }, - - bySeason : function(season) { - var filtered = this.filter(function(episode) { - return episode.get('seasonNumber') === season; - }); - - var EpisodeCollection = require('./EpisodeCollection'); - - return new EpisodeCollection(filtered); - }, - - comparator : function(model1, model2) { - var episode1 = model1.get('episodeNumber'); - var episode2 = model2.get('episodeNumber'); - - if (episode1 < episode2) { - return 1; - } - - if (episode1 > episode2) { - return -1; - } - - return 0; - }, - - fetch : function(options) { - if (!this.seriesId) { - throw 'seriesId is required'; - } - - if (!options) { - options = {}; - } - - options.data = { seriesId : this.seriesId }; - - return this.originalFetch.call(this, options); - } -}); \ No newline at end of file diff --git a/src/UI/Series/EpisodeFileCollection.js b/src/UI/Series/EpisodeFileCollection.js deleted file mode 100644 index dff988512..000000000 --- a/src/UI/Series/EpisodeFileCollection.js +++ /dev/null @@ -1,28 +0,0 @@ -var Backbone = require('backbone'); -var EpisodeFileModel = require('./EpisodeFileModel'); - -module.exports = Backbone.Collection.extend({ - url : window.NzbDrone.ApiRoot + '/episodefile', - model : EpisodeFileModel, - - originalFetch : Backbone.Collection.prototype.fetch, - - initialize : function(options) { - this.seriesId = options.seriesId; - this.models = []; - }, - - fetch : function(options) { - if (!this.seriesId) { - throw 'seriesId is required'; - } - - if (!options) { - options = {}; - } - - options.data = { seriesId : this.seriesId }; - - return this.originalFetch.call(this, options); - } -}); \ No newline at end of file diff --git a/src/UI/Series/EpisodeFileModel.js b/src/UI/Series/EpisodeFileModel.js deleted file mode 100644 index 3986a5948..000000000 --- a/src/UI/Series/EpisodeFileModel.js +++ /dev/null @@ -1,3 +0,0 @@ -var Backbone = require('backbone'); - -module.exports = Backbone.Model.extend({}); \ No newline at end of file diff --git a/src/UI/Series/EpisodeModel.js b/src/UI/Series/EpisodeModel.js deleted file mode 100644 index ebb72cf29..000000000 --- a/src/UI/Series/EpisodeModel.js +++ /dev/null @@ -1,20 +0,0 @@ -var Backbone = require('backbone'); - -module.exports = Backbone.Model.extend({ - defaults : { - seasonNumber : 0, - status : 0 - }, - - methodUrls : { - 'update' : window.NzbDrone.ApiRoot + '/episode' - }, - - sync : function(method, model, options) { - if (model.methodUrls && model.methodUrls[method.toLowerCase()]) { - options = options || {}; - options.url = model.methodUrls[method.toLowerCase()]; - } - return Backbone.sync(method, model, options); - } -}); \ No newline at end of file diff --git a/src/UI/Series/Index/EmptyTemplate.hbs b/src/UI/Series/Index/EmptyTemplate.hbs deleted file mode 100644 index abca7f764..000000000 --- a/src/UI/Series/Index/EmptyTemplate.hbs +++ /dev/null @@ -1,16 +0,0 @@ -<div class="no-series"> - <div class="row"> - <div class="well col-md-12"> - <i class="icon-sonarr-comment"/> - You must be new around here, You should add some series. - </div> - </div> - <div class="row"> - <div class="col-md-4 col-md-offset-4"> - <a href="/addseries" class='btn btn-lg btn-block btn-success x-add-series'> - <i class='icon-sonarr-add'></i> - Add Series - </a> - </div> - </div> -</div> diff --git a/src/UI/Series/Index/EmptyView.js b/src/UI/Series/Index/EmptyView.js deleted file mode 100644 index 01dcc07a4..000000000 --- a/src/UI/Series/Index/EmptyView.js +++ /dev/null @@ -1,5 +0,0 @@ -var Marionette = require('marionette'); - -module.exports = Marionette.CompositeView.extend({ - template : 'Series/Index/EmptyTemplate' -}); \ No newline at end of file diff --git a/src/UI/Series/Index/EpisodeProgressPartial.hbs b/src/UI/Series/Index/EpisodeProgressPartial.hbs deleted file mode 100644 index db5c49a2b..000000000 --- a/src/UI/Series/Index/EpisodeProgressPartial.hbs +++ /dev/null @@ -1,4 +0,0 @@ -<div class="progress episode-progress"> - <span class="progressbar-back-text">{{episodeFileCount}} / {{episodeCount}}</span> - <div class="progress-bar {{EpisodeProgressClass}} episode-progress" style="width:{{percentOfEpisodes}}%"><span class="progressbar-front-text">{{episodeFileCount}} / {{episodeCount}}</span></div> -</div> \ No newline at end of file diff --git a/src/UI/Series/Index/FooterModel.js b/src/UI/Series/Index/FooterModel.js deleted file mode 100644 index 235552061..000000000 --- a/src/UI/Series/Index/FooterModel.js +++ /dev/null @@ -1,4 +0,0 @@ -var Backbone = require('backbone'); -var _ = require('underscore'); - -module.exports = Backbone.Model.extend({}); \ No newline at end of file diff --git a/src/UI/Series/Index/FooterView.js b/src/UI/Series/Index/FooterView.js deleted file mode 100644 index 1d31cc404..000000000 --- a/src/UI/Series/Index/FooterView.js +++ /dev/null @@ -1,5 +0,0 @@ -var Marionette = require('marionette'); - -module.exports = Marionette.CompositeView.extend({ - template : 'Series/Index/FooterViewTemplate' -}); \ No newline at end of file diff --git a/src/UI/Series/Index/FooterViewTemplate.hbs b/src/UI/Series/Index/FooterViewTemplate.hbs deleted file mode 100644 index 1b45fa747..000000000 --- a/src/UI/Series/Index/FooterViewTemplate.hbs +++ /dev/null @@ -1,46 +0,0 @@ -<div class="row"> - <div class="series-legend legend col-xs-6 col-sm-4"> - <ul class='legend-labels'> - <li><span class="progress-bar"></span>Continuing (All episodes downloaded)</li> - <li><span class="progress-bar-success"></span>Ended (All episodes downloaded)</li> - <li><span class="progress-bar-danger"></span>Missing Episodes (Series monitored)</li> - <li><span class="progress-bar-warning"></span>Missing Episodes (Series not monitored)</li> - </ul> - </div> - <div class="col-xs-5 col-sm-7"> - <div class="row"> - <div class="series-stats col-sm-4"> - <dl class="dl-horizontal"> - <dt>Series</dt> - <dd>{{series}}</dd> - - <dt>Ended</dt> - <dd>{{ended}}</dd> - - <dt>Continuing</dt> - <dd>{{continuing}}</dd> - </dl> - </div> - - <div class="series-stats col-sm-4"> - <dl class="dl-horizontal"> - <dt>Monitored</dt> - <dd>{{monitored}}</dd> - - <dt>Unmonitored</dt> - <dd>{{unmonitored}}</dd> - </dl> - </div> - - <div class="series-stats col-sm-4"> - <dl class="dl-horizontal"> - <dt>Episodes</dt> - <dd>{{episodes}}</dd> - - <dt>Files</dt> - <dd>{{episodeFiles}}</dd> - </dl> - </div> - </div> - </div> -</div> diff --git a/src/UI/Series/Index/Overview/SeriesOverviewCollectionView.js b/src/UI/Series/Index/Overview/SeriesOverviewCollectionView.js deleted file mode 100644 index 7db4b76f0..000000000 --- a/src/UI/Series/Index/Overview/SeriesOverviewCollectionView.js +++ /dev/null @@ -1,8 +0,0 @@ -var Marionette = require('marionette'); -var ListItemView = require('./SeriesOverviewItemView'); - -module.exports = Marionette.CompositeView.extend({ - itemView : ListItemView, - itemViewContainer : '#x-series-list', - template : 'Series/Index/Overview/SeriesOverviewCollectionViewTemplate' -}); \ No newline at end of file diff --git a/src/UI/Series/Index/Overview/SeriesOverviewCollectionViewTemplate.hbs b/src/UI/Series/Index/Overview/SeriesOverviewCollectionViewTemplate.hbs deleted file mode 100644 index 046bb3348..000000000 --- a/src/UI/Series/Index/Overview/SeriesOverviewCollectionViewTemplate.hbs +++ /dev/null @@ -1 +0,0 @@ -<div id="x-series-list"/> diff --git a/src/UI/Series/Index/Overview/SeriesOverviewItemView.js b/src/UI/Series/Index/Overview/SeriesOverviewItemView.js deleted file mode 100644 index bb780480b..000000000 --- a/src/UI/Series/Index/Overview/SeriesOverviewItemView.js +++ /dev/null @@ -1,7 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); -var SeriesIndexItemView = require('../SeriesIndexItemView'); - -module.exports = SeriesIndexItemView.extend({ - template : 'Series/Index/Overview/SeriesOverviewItemViewTemplate' -}); \ No newline at end of file diff --git a/src/UI/Series/Index/Overview/SeriesOverviewItemViewTemplate.hbs b/src/UI/Series/Index/Overview/SeriesOverviewItemViewTemplate.hbs deleted file mode 100644 index ee6ddddee..000000000 --- a/src/UI/Series/Index/Overview/SeriesOverviewItemViewTemplate.hbs +++ /dev/null @@ -1,56 +0,0 @@ -<div class="series-item"> - <div class="row"> - <div class="col-md-2 col-xs-3"> - <a href="{{route}}"> - {{poster}} - </a> - </div> - <div class="col-md-10 col-xs-9"> - <div class="row"> - <div class="col-md-10 col-xs-10"> - <a href="{{route}}" target="_blank"> - <h2>{{title}}</h2> - </a> - </div> - <div class="col-md-2 col-xs-2"> - <div class="pull-right series-overview-list-actions"> - <i class="icon-sonarr-refresh x-refresh" title="Update series info and scan disk"/> - <i class="icon-sonarr-edit x-edit" title="Edit Series"/> - </div> - </div> - </div> - <div class="row"> - <div class="col-md-12 col-xs-12"> - <a href="{{route}}"> - <div> - {{overview}} - </div> - </a> - </div> - </div> - <div class="row"> - <div class="col-md-12"> -   - </div> - </div> - <div class="row"> - <div class="col-md-10 col-xs-8"> - {{#if_eq status compare="ended"}} - <span class="label label-danger">Ended</span> - {{/if_eq}} - - {{#if nextAiring}} - <span class="label label-default">{{RelativeDate nextAiring}}</span> - {{/if}} - - {{seasonCountHelper}} - - {{profile profileId}} - </div> - <div class="col-md-2 col-xs-4"> - {{> EpisodeProgressPartial }} - </div> - </div> - </div> - </div> -</div> diff --git a/src/UI/Series/Index/Posters/SeriesPostersCollectionView.js b/src/UI/Series/Index/Posters/SeriesPostersCollectionView.js deleted file mode 100644 index 0d6094f1c..000000000 --- a/src/UI/Series/Index/Posters/SeriesPostersCollectionView.js +++ /dev/null @@ -1,8 +0,0 @@ -var Marionette = require('marionette'); -var PosterItemView = require('./SeriesPostersItemView'); - -module.exports = Marionette.CompositeView.extend({ - itemView : PosterItemView, - itemViewContainer : '#x-series-posters', - template : 'Series/Index/Posters/SeriesPostersCollectionViewTemplate' -}); \ No newline at end of file diff --git a/src/UI/Series/Index/Posters/SeriesPostersCollectionViewTemplate.hbs b/src/UI/Series/Index/Posters/SeriesPostersCollectionViewTemplate.hbs deleted file mode 100644 index 11b8e8ac7..000000000 --- a/src/UI/Series/Index/Posters/SeriesPostersCollectionViewTemplate.hbs +++ /dev/null @@ -1 +0,0 @@ -<ul id="x-series-posters" class="series-posters"></ul> \ No newline at end of file diff --git a/src/UI/Series/Index/Posters/SeriesPostersItemView.js b/src/UI/Series/Index/Posters/SeriesPostersItemView.js deleted file mode 100644 index 9a42b4655..000000000 --- a/src/UI/Series/Index/Posters/SeriesPostersItemView.js +++ /dev/null @@ -1,19 +0,0 @@ -var SeriesIndexItemView = require('../SeriesIndexItemView'); - -module.exports = SeriesIndexItemView.extend({ - tagName : 'li', - template : 'Series/Index/Posters/SeriesPostersItemViewTemplate', - - initialize : function() { - this.events['mouseenter .x-series-poster-container'] = 'posterHoverAction'; - this.events['mouseleave .x-series-poster-container'] = 'posterHoverAction'; - - this.ui.controls = '.x-series-controls'; - this.ui.title = '.x-title'; - }, - - posterHoverAction : function() { - this.ui.controls.slideToggle(); - this.ui.title.slideToggle(); - } -}); \ No newline at end of file diff --git a/src/UI/Series/Index/Posters/SeriesPostersItemViewTemplate.hbs b/src/UI/Series/Index/Posters/SeriesPostersItemViewTemplate.hbs deleted file mode 100644 index fba301c4f..000000000 --- a/src/UI/Series/Index/Posters/SeriesPostersItemViewTemplate.hbs +++ /dev/null @@ -1,30 +0,0 @@ -<div class="series-posters-item"> - <div class="center"> - <div class="series-poster-container x-series-poster-container"> - <div class="series-controls x-series-controls"> - <i class="icon-sonarr-refresh x-refresh" title="Refresh Series"/> - <i class="icon-sonarr-edit x-edit" title="Edit Series"/> - </div> - {{#unless_eq status compare="continuing"}} - <div class="ended-banner">Ended</div> - {{/unless_eq}} - <a href="{{route}}"> - {{poster}} - <div class="center title">{{title}}</div> - </a> - <div class="hidden-title x-title"> - {{title}} - </div> - </div> - </div> - - <div class="center"> - <div class="labels"> - {{> EpisodeProgressPartial }} - - {{#if nextAiring}} - <span class="label label-default">{{RelativeDate nextAiring}}</span> - {{/if}} - </div> - </div> -</div> diff --git a/src/UI/Series/Index/SeriesIndexItemView.js b/src/UI/Series/Index/SeriesIndexItemView.js deleted file mode 100644 index 427fe489e..000000000 --- a/src/UI/Series/Index/SeriesIndexItemView.js +++ /dev/null @@ -1,35 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); -var CommandController = require('../../Commands/CommandController'); - -module.exports = Marionette.ItemView.extend({ - ui : { - refresh : '.x-refresh' - }, - - events : { - 'click .x-edit' : '_editSeries', - 'click .x-refresh' : '_refreshSeries' - }, - - onRender : function() { - CommandController.bindToCommand({ - element : this.ui.refresh, - command : { - name : 'refreshSeries', - seriesId : this.model.get('id') - } - }); - }, - - _editSeries : function() { - vent.trigger(vent.Commands.EditSeriesCommand, { series : this.model }); - }, - - _refreshSeries : function() { - CommandController.Execute('refreshSeries', { - name : 'refreshSeries', - seriesId : this.model.id - }); - } -}); \ No newline at end of file diff --git a/src/UI/Series/Index/SeriesIndexLayout.js b/src/UI/Series/Index/SeriesIndexLayout.js deleted file mode 100644 index f5f47b983..000000000 --- a/src/UI/Series/Index/SeriesIndexLayout.js +++ /dev/null @@ -1,354 +0,0 @@ -var _ = require('underscore'); -var Marionette = require('marionette'); -var Backgrid = require('backgrid'); -var PosterCollectionView = require('./Posters/SeriesPostersCollectionView'); -var ListCollectionView = require('./Overview/SeriesOverviewCollectionView'); -var EmptyView = require('./EmptyView'); -var SeriesCollection = require('../SeriesCollection'); -var RelativeDateCell = require('../../Cells/RelativeDateCell'); -var SeriesTitleCell = require('../../Cells/SeriesTitleCell'); -var TemplatedCell = require('../../Cells/TemplatedCell'); -var ProfileCell = require('../../Cells/ProfileCell'); -var EpisodeProgressCell = require('../../Cells/EpisodeProgressCell'); -var SeriesActionsCell = require('../../Cells/SeriesActionsCell'); -var SeriesStatusCell = require('../../Cells/SeriesStatusCell'); -var FooterView = require('./FooterView'); -var FooterModel = require('./FooterModel'); -var ToolbarLayout = require('../../Shared/Toolbar/ToolbarLayout'); -require('../../Mixins/backbone.signalr.mixin'); - -module.exports = Marionette.Layout.extend({ - template : 'Series/Index/SeriesIndexLayoutTemplate', - - regions : { - seriesRegion : '#x-series', - toolbar : '#x-toolbar', - toolbar2 : '#x-toolbar2', - footer : '#x-series-footer' - }, - - columns : [ - { - name : 'statusWeight', - label : '', - cell : SeriesStatusCell - }, - { - name : 'title', - label : 'Title', - cell : SeriesTitleCell, - cellValue : 'this', - sortValue : 'sortTitle' - }, - { - name : 'seasonCount', - label : 'Seasons', - cell : 'integer' - }, - { - name : 'profileId', - label : 'Profile', - cell : ProfileCell - }, - { - name : 'network', - label : 'Network', - cell : 'string' - }, - { - name : 'nextAiring', - label : 'Next Airing', - cell : RelativeDateCell - }, - { - name : 'percentOfEpisodes', - label : 'Episodes', - cell : EpisodeProgressCell, - className : 'episode-progress-cell' - }, - { - name : 'this', - label : '', - sortable : false, - cell : SeriesActionsCell - } - ], - - leftSideButtons : { - type : 'default', - storeState : false, - collapse : true, - items : [ - { - title : 'Add Series', - icon : 'icon-sonarr-add', - route : 'addseries' - }, - { - title : 'Season Pass', - icon : 'icon-sonarr-monitored', - route : 'seasonpass' - }, - { - title : 'Series Editor', - icon : 'icon-sonarr-edit', - route : 'serieseditor' - }, - { - title : 'RSS Sync', - icon : 'icon-sonarr-rss', - command : 'rsssync', - errorMessage : 'RSS Sync Failed!' - }, - { - title : 'Update Library', - icon : 'icon-sonarr-refresh', - command : 'refreshseries', - successMessage : 'Library was updated!', - errorMessage : 'Library update failed!' - } - ] - }, - - initialize : function() { - this.seriesCollection = SeriesCollection.clone(); - this.seriesCollection.shadowCollection.bindSignalR(); - - this.listenTo(this.seriesCollection.shadowCollection, 'sync', function(model, collection, options) { - this.seriesCollection.fullCollection.resetFiltered(); - this._renderView(); - }); - - this.listenTo(this.seriesCollection.shadowCollection, 'add', function(model, collection, options) { - this.seriesCollection.fullCollection.resetFiltered(); - this._renderView(); - }); - - this.listenTo(this.seriesCollection.shadowCollection, 'remove', function(model, collection, options) { - this.seriesCollection.fullCollection.resetFiltered(); - this._renderView(); - }); - - this.sortingOptions = { - type : 'sorting', - storeState : false, - viewCollection : this.seriesCollection, - items : [ - { - title : 'Title', - name : 'title' - }, - { - title : 'Seasons', - name : 'seasonCount' - }, - { - title : 'Quality', - name : 'profileId' - }, - { - title : 'Network', - name : 'network' - }, - { - title : 'Next Airing', - name : 'nextAiring' - }, - { - title : 'Episodes', - name : 'percentOfEpisodes' - } - ] - }; - - this.filteringOptions = { - type : 'radio', - storeState : true, - menuKey : 'series.filterMode', - defaultAction : 'all', - items : [ - { - key : 'all', - title : '', - tooltip : 'All', - icon : 'icon-sonarr-all', - callback : this._setFilter - }, - { - key : 'monitored', - title : '', - tooltip : 'Monitored Only', - icon : 'icon-sonarr-monitored', - callback : this._setFilter - }, - { - key : 'continuing', - title : '', - tooltip : 'Continuing Only', - icon : 'icon-sonarr-series-continuing', - callback : this._setFilter - }, - { - key : 'ended', - title : '', - tooltip : 'Ended Only', - icon : 'icon-sonarr-series-ended', - callback : this._setFilter - }, - { - key : 'missing', - title : '', - tooltip : 'Missing', - icon : 'icon-sonarr-missing', - callback : this._setFilter - } - ] - }; - - this.viewButtons = { - type : 'radio', - storeState : true, - menuKey : 'seriesViewMode', - defaultAction : 'listView', - items : [ - { - key : 'posterView', - title : '', - tooltip : 'Posters', - icon : 'icon-sonarr-view-poster', - callback : this._showPosters - }, - { - key : 'listView', - title : '', - tooltip : 'Overview List', - icon : 'icon-sonarr-view-list', - callback : this._showList - }, - { - key : 'tableView', - title : '', - tooltip : 'Table', - icon : 'icon-sonarr-view-table', - callback : this._showTable - } - ] - }; - }, - - onShow : function() { - this._showToolbar(); - this._fetchCollection(); - }, - - _showTable : function() { - this.currentView = new Backgrid.Grid({ - collection : this.seriesCollection, - columns : this.columns, - className : 'table table-hover' - }); - - this._renderView(); - }, - - _showList : function() { - this.currentView = new ListCollectionView({ - collection : this.seriesCollection - }); - - this._renderView(); - }, - - _showPosters : function() { - this.currentView = new PosterCollectionView({ - collection : this.seriesCollection - }); - - this._renderView(); - }, - - _renderView : function() { - if (SeriesCollection.length === 0) { - this.seriesRegion.show(new EmptyView()); - - this.toolbar.close(); - this.toolbar2.close(); - } else { - this.seriesRegion.show(this.currentView); - - this._showToolbar(); - this._showFooter(); - } - }, - - _fetchCollection : function() { - this.seriesCollection.fetch(); - }, - - _setFilter : function(buttonContext) { - var mode = buttonContext.model.get('key'); - - this.seriesCollection.setFilterMode(mode); - }, - - _showToolbar : function() { - if (this.toolbar.currentView) { - return; - } - - this.toolbar2.show(new ToolbarLayout({ - right : [ - this.filteringOptions - ], - context : this - })); - - this.toolbar.show(new ToolbarLayout({ - right : [ - this.sortingOptions, - this.viewButtons - ], - left : [ - this.leftSideButtons - ], - context : this - })); - }, - - _showFooter : function() { - var footerModel = new FooterModel(); - var series = SeriesCollection.models.length; - var episodes = 0; - var episodeFiles = 0; - var ended = 0; - var continuing = 0; - var monitored = 0; - - _.each(SeriesCollection.models, function(model) { - episodes += model.get('episodeCount'); - episodeFiles += model.get('episodeFileCount'); - - if (model.get('status').toLowerCase() === 'ended') { - ended++; - } else { - continuing++; - } - - if (model.get('monitored')) { - monitored++; - } - }); - - footerModel.set({ - series : series, - ended : ended, - continuing : continuing, - monitored : monitored, - unmonitored : series - monitored, - episodes : episodes, - episodeFiles : episodeFiles - }); - - this.footer.show(new FooterView({ model : footerModel })); - } -}); diff --git a/src/UI/Series/Index/SeriesIndexLayoutTemplate.hbs b/src/UI/Series/Index/SeriesIndexLayoutTemplate.hbs deleted file mode 100644 index d9e6b3263..000000000 --- a/src/UI/Series/Index/SeriesIndexLayoutTemplate.hbs +++ /dev/null @@ -1,12 +0,0 @@ -<div class="toolbars"> - <div id="x-toolbar"></div> - <div id="x-toolbar2"></div> -</div> - -<div class="row"> - <div class="col-md-12"> - <div id="x-series" class="table-responsive"></div> - </div> -</div> - -<div id="x-series-footer"></div> \ No newline at end of file diff --git a/src/UI/Series/SeasonCollection.js b/src/UI/Series/SeasonCollection.js deleted file mode 100644 index ed661af2b..000000000 --- a/src/UI/Series/SeasonCollection.js +++ /dev/null @@ -1,10 +0,0 @@ -var Backbone = require('backbone'); -var SeasonModel = require('./SeasonModel'); - -module.exports = Backbone.Collection.extend({ - model : SeasonModel, - - comparator : function(season) { - return -season.get('seasonNumber'); - } -}); \ No newline at end of file diff --git a/src/UI/Series/SeasonModel.js b/src/UI/Series/SeasonModel.js deleted file mode 100644 index 1ba049eb6..000000000 --- a/src/UI/Series/SeasonModel.js +++ /dev/null @@ -1,11 +0,0 @@ -var Backbone = require('backbone'); - -module.exports = Backbone.Model.extend({ - defaults : { - seasonNumber : 0 - }, - - initialize : function() { - this.set('id', this.get('seasonNumber')); - } -}); \ No newline at end of file diff --git a/src/UI/Series/SeriesCollection.js b/src/UI/Series/SeriesCollection.js deleted file mode 100644 index bef8fe338..000000000 --- a/src/UI/Series/SeriesCollection.js +++ /dev/null @@ -1,120 +0,0 @@ -var _ = require('underscore'); -var Backbone = require('backbone'); -var PageableCollection = require('backbone.pageable'); -var SeriesModel = require('./SeriesModel'); -var ApiData = require('../Shared/ApiData'); -var AsFilteredCollection = require('../Mixins/AsFilteredCollection'); -var AsSortedCollection = require('../Mixins/AsSortedCollection'); -var AsPersistedStateCollection = require('../Mixins/AsPersistedStateCollection'); -var moment = require('moment'); -require('../Mixins/backbone.signalr.mixin'); - -var Collection = PageableCollection.extend({ - url : window.NzbDrone.ApiRoot + '/series', - model : SeriesModel, - tableName : 'series', - - state : { - sortKey : 'sortTitle', - order : -1, - pageSize : 100000, - secondarySortKey : 'sortTitle', - secondarySortOrder : -1 - }, - - mode : 'client', - - save : function() { - var self = this; - - var proxy = _.extend(new Backbone.Model(), { - id : '', - - url : self.url + '/editor', - - toJSON : function() { - return self.filter(function(model) { - return model.edited; - }); - } - }); - - this.listenTo(proxy, 'sync', function(proxyModel, models) { - this.add(models, { merge : true }); - this.trigger('save', this); - }); - - return proxy.save(); - }, - - filterModes : { - 'all' : [ - null, - null - ], - 'continuing' : [ - 'status', - 'continuing' - ], - 'ended' : [ - 'status', - 'ended' - ], - 'monitored' : [ - 'monitored', - true - ], - 'missing' : [ - null, - null, - function(model) { return model.get('episodeCount') !== model.get('episodeFileCount'); } - ] - }, - - sortMappings : { - title : { - sortKey : 'sortTitle' - }, - - nextAiring : { - sortValue : function(model, attr, order) { - var nextAiring = model.get(attr); - - if (nextAiring) { - return moment(nextAiring).unix(); - } - - if (order === 1) { - return 0; - } - - return Number.MAX_VALUE; - } - }, - - percentOfEpisodes : { - sortValue : function(model, attr) { - var percentOfEpisodes = model.get(attr); - var episodeCount = model.get('episodeCount'); - - return percentOfEpisodes + episodeCount / 1000000; - } - }, - - path : { - sortValue : function(model) { - var path = model.get('path'); - - return path.toLowerCase(); - } - } - } -}); - -Collection = AsFilteredCollection.call(Collection); -Collection = AsSortedCollection.call(Collection); -Collection = AsPersistedStateCollection.call(Collection); - -var data = ApiData.get('series'); - -module.exports = new Collection(data, { full : true }).bindSignalR(); diff --git a/src/UI/Series/SeriesController.js b/src/UI/Series/SeriesController.js deleted file mode 100644 index 60d1049cd..000000000 --- a/src/UI/Series/SeriesController.js +++ /dev/null @@ -1,34 +0,0 @@ -var NzbDroneController = require('../Shared/NzbDroneController'); -var AppLayout = require('../AppLayout'); -var SeriesCollection = require('./SeriesCollection'); -var SeriesIndexLayout = require('./Index/SeriesIndexLayout'); -var SeriesDetailsLayout = require('./Details/SeriesDetailsLayout'); - -module.exports = NzbDroneController.extend({ - _originalInit : NzbDroneController.prototype.initialize, - - initialize : function() { - this.route('', this.series); - this.route('series', this.series); - this.route('series/:query', this.seriesDetails); - - this._originalInit.apply(this, arguments); - }, - - series : function() { - this.setTitle('Sonarr'); - this.showMainRegion(new SeriesIndexLayout()); - }, - - seriesDetails : function(query) { - var series = SeriesCollection.where({ titleSlug : query }); - - if (series.length !== 0) { - var targetSeries = series[0]; - this.setTitle(targetSeries.get('title')); - this.showMainRegion(new SeriesDetailsLayout({ model : targetSeries })); - } else { - this.showNotFound(); - } - } -}); \ No newline at end of file diff --git a/src/UI/Series/SeriesModel.js b/src/UI/Series/SeriesModel.js deleted file mode 100644 index 9d154fa7d..000000000 --- a/src/UI/Series/SeriesModel.js +++ /dev/null @@ -1,31 +0,0 @@ -var Backbone = require('backbone'); -var _ = require('underscore'); - -module.exports = Backbone.Model.extend({ - urlRoot : window.NzbDrone.ApiRoot + '/series', - - defaults : { - episodeFileCount : 0, - episodeCount : 0, - isExisting : false, - status : 0 - }, - - setSeasonMonitored : function(seasonNumber) { - _.each(this.get('seasons'), function(season) { - if (season.seasonNumber === seasonNumber) { - season.monitored = !season.monitored; - } - }); - }, - - setSeasonPass : function(seasonNumber) { - _.each(this.get('seasons'), function(season) { - if (season.seasonNumber >= seasonNumber) { - season.monitored = true; - } else { - season.monitored = false; - } - }); - } -}); \ No newline at end of file diff --git a/src/UI/Series/series.less b/src/UI/Series/series.less deleted file mode 100644 index c023a7da5..000000000 --- a/src/UI/Series/series.less +++ /dev/null @@ -1,471 +0,0 @@ -@import "../Content/Bootstrap/variables"; -@import "../Shared/Styles/card.less"; -@import "../Shared/Styles/clickable.less"; -@import "../Content/prefixer"; - -.series-poster { - min-width: 56px; - max-width: 100%; -} - -.edit-series-modal, .delete-series-modal { - overflow : visible; - - .series-poster { - padding-left : 20px; - width : 168px; - } - - .form-horizontal { - margin-top : 10px; - } - - .twitter-typeahead { - .form-control[disabled] { - background-color: #ffffff; - } - } -} - -.delete-series-modal { - .path { - margin-left : 30px; - } - - .delete-files-info { - margin-top : 10px; - display : none; - } -} - -.series-item { - padding-bottom : 30px; - - :hover { - text-decoration : none; - } - - h2 { - margin-top : 0px; - } - - a { - color : #000000; - } -} - -.series-page-header { - .card(black); - .opacity(0.9); - background : #000000; - color : #ffffff; - padding : 30px 15px; - margin : 50px 10px; - - .poster { - margin-top : 4px; - } - - .header-text { - margin-top : 0px; - } -} - -.series-season { - .card; - .opacity(0.9); - margin : 30px 10px; - padding : 10px 25px; - - .show-hide-episodes { - .clickable(); - text-align : center; - - i { - .clickable(); - } - } -} - -.series-posters { - list-style-type: none; - - @media (max-width: @screen-xs-max) { - padding : 0px; - } - - li { - display : inline-block; - vertical-align : top; - } - - .series-posters-item { - - .card; - .clickable; - margin-bottom : 20px; - height : 315px; - - .center { - display : block; - margin-left : auto; - margin-right : auto; - text-align : center; - - .progress { - text-align : left; - margin-top : 5px; - left : 0px; - width : 170px; - - .progressbar-front-text, .progressbar-back-text { - width : 170px; - } - } - } - - .labels { - display : inline-block; - .opacity(0.75); - width : 170px; - - :hover { - cursor : default; - } - - .label { - margin-top : 3px; - display : block; - } - - .tooltip { - .opacity(1); - } - } - - @media (max-width: @screen-xs-max) { - height : 235px; - margin : 5px; - padding : 6px 5px; - - .center { - .progress { - width : 125px; - - .progressbar-front-text, .progressbar-back-text { - width : 125px - } - } - } - - .labels { - width: 125px; - } - } - } - - .series-poster-container { - position : relative; - overflow : hidden; - display : inline-block; - - .placeholder-image ~ .title { - opacity: 1.0; - } - - .title { - position : absolute; - top : 25px; - color : #f5f5f5; - width : 100%; - font-size : 22px; - line-height: 24px; - opacity : 0.0; - font-weight: 100; - } - - .ended-banner { - color : #eeeeee; - background-color : #b94a48; - .box-shadow(2px 2px 20px #888888); - -moz-transform-origin : 50% 50%; - -webkit-transform-origin : 50% 50%; - position : absolute; - width : 320px; - top : 200px; - left : -122px; - text-align : center; - .opacity(0.9); - - .transform(rotate(45deg)); - } - - .series-controls { - position : absolute;; - top : 0px; - overflow : hidden; - background-color : #eeeeee; - width : 100%; - text-align : right; - padding-right : 10px; - display : none; - .opacity(0.8); - - i { - .clickable(); - } - } - - .hidden-title { - position : absolute;; - bottom : 0px; - overflow : hidden; - background-color : #eeeeee; - width : 100%; - text-align : center; - .opacity(0.8); - display : none; - } - - .series-poster { - width : 168px; - height : 247px; - display : block; - font-size : 34px; - line-height : 34px; - } - - @media (max-width: @screen-xs-max) { - .series-poster { - width : 120px; - height : 176px; - } - - .ended-banner { - top : 145px; - left : -137px; - } - } - } -} - -.series-detail-overview { - margin-bottom : 50px; -} - -.series-season { - - .episode-number-cell { - width : 40px; - white-space: nowrap; - } - .episode-air-date-cell { - width : 150px; - } - - .episode-status-cell { - width : 100px; - } - - .episode-title-cell { - cursor : pointer; - } -} - -.episode-detail-modal { - - .episode-info { - margin-bottom : 10px; - } - - .episode-overview { - font-style : italic; - } - - .episode-file-info { - margin-top : 30px; - font-size : 12px; - } - - .episode-history-details-cell .popover { - max-width: 800px; - } - - .hidden-series-title { - display : none; - } -} - -.season-grid { - .toggle-cell { - width : 28px; - text-align : center; - padding-left : 0px; - padding-right : 0px; - } - - .toggle-cell { - i { - .clickable; - } - } -} - -.season-actions { - width: 100px; -} - -.season-actions, .series-actions { - - div { - display : inline-block - } - - text-transform : none; - - i { - .clickable(); - font-size : 24px; - margin-left : 5px; - } -} - -.series-stats { - font-size : 11px; -} - -.series-legend { - padding-top : 5px; -} - -.seasonpass-series { - .card; - margin : 20px 0px; - - .title { - font-weight : 300; - font-size : 24px; - line-height : 30px; - margin-left : 5px; - } - - .season-select { - margin-bottom : 0px; - } - - .expander { - .clickable; - line-height : 30px; - margin-left : 8px; - width : 16px; - } - - .season-grid { - margin-top : 10px; - } - - .season-pass-button { - display : inline-block; - } - - .series-monitor-toggle { - font-size : 24px; - margin-top : 3px; - } - - .help-inline { - margin-top : 7px; - display : inline-block; - } -} - -.season-status { - font-size : 11px; - vertical-align : middle !important; -} - -//Overview List -.series-overview-list-actions { - min-width: 56px; - max-width: 56px; - - i { - .clickable(); - } -} - -//Editor - -.series-editor-footer { - max-width: 1160px; - color: #f5f5f5; - margin-left: auto; - margin-right: auto; - - .form-group { - padding-top: 0px; - } -} - -.update-files-series-modal { - .selected-series { - margin-top: 15px; - } -} - -//Series Details - -.series-not-monitored { - .season-monitored, .episode-monitored { - color: #888888; - cursor: not-allowed; - - i { - cursor: not-allowed; - } - } -} - -.series-info { - .row { - margin-bottom : 3px; - - .label { - display : inline-block; - margin-bottom : 2px; - padding : 4px 6px 3px 6px; - max-width : 100%; - white-space : normal; - word-wrap : break-word; - } - } - - .series-info-links { - @media (max-width: @screen-sm-max) { - display : inline-block; - margin-top : 5px; - } - } -} - -.scene-info { - .key, .value { - display : inline-block; - } - - .key { - width : 80px; - margin-left : 10px; - vertical-align : top; - } - - .value { - margin-right : 10px; - max-width : 170px; - } - - ul { - padding-left : 0px; - list-style-type : none; - } -} diff --git a/src/UI/Settings/DownloadClient/Add/DownloadClientAddCollectionView.js b/src/UI/Settings/DownloadClient/Add/DownloadClientAddCollectionView.js deleted file mode 100644 index 9efced249..000000000 --- a/src/UI/Settings/DownloadClient/Add/DownloadClientAddCollectionView.js +++ /dev/null @@ -1,9 +0,0 @@ -var ThingyAddCollectionView = require('../../ThingyAddCollectionView'); -var ThingyHeaderGroupView = require('../../ThingyHeaderGroupView'); -var AddItemView = require('./DownloadClientAddItemView'); - -module.exports = ThingyAddCollectionView.extend({ - itemView : ThingyHeaderGroupView.extend({ itemView : AddItemView }), - itemViewContainer : '.add-download-client .items', - template : 'Settings/DownloadClient/Add/DownloadClientAddCollectionViewTemplate' -}); \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/Add/DownloadClientAddCollectionViewTemplate.hbs b/src/UI/Settings/DownloadClient/Add/DownloadClientAddCollectionViewTemplate.hbs deleted file mode 100644 index f3a823f4a..000000000 --- a/src/UI/Settings/DownloadClient/Add/DownloadClientAddCollectionViewTemplate.hbs +++ /dev/null @@ -1,14 +0,0 @@ -<div class="modal-content"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - <h3>Add Download Client</h3> - </div> - <div class="modal-body"> - <div class="add-download-client add-thingies"> - <ul class="items"></ul> - </div> - </div> - <div class="modal-footer"> - <button class="btn" data-dismiss="modal">Close</button> - </div> -</div> diff --git a/src/UI/Settings/DownloadClient/Add/DownloadClientAddItemView.js b/src/UI/Settings/DownloadClient/Add/DownloadClientAddItemView.js deleted file mode 100644 index 75a39e2b5..000000000 --- a/src/UI/Settings/DownloadClient/Add/DownloadClientAddItemView.js +++ /dev/null @@ -1,58 +0,0 @@ -var _ = require('underscore'); -var $ = require('jquery'); -var AppLayout = require('../../../AppLayout'); -var Marionette = require('marionette'); -var EditView = require('../Edit/DownloadClientEditView'); - -module.exports = Marionette.ItemView.extend({ - template : 'Settings/DownloadClient/Add/DownloadClientAddItemViewTemplate', - tagName : 'li', - className : 'add-thingy-item', - - events : { - 'click .x-preset' : '_addPreset', - 'click' : '_add' - }, - - initialize : function(options) { - this.targetCollection = options.targetCollection; - }, - - _addPreset : function(e) { - var presetName = $(e.target).closest('.x-preset').attr('data-id'); - - var presetData = _.where(this.model.get('presets'), { name : presetName })[0]; - - this.model.set(presetData); - - this.model.set({ - id : undefined, - enable : true - }); - - var editView = new EditView({ - model : this.model, - targetCollection : this.targetCollection - }); - - AppLayout.modalRegion.show(editView); - }, - - _add : function(e) { - if ($(e.target).closest('.btn,.btn-group').length !== 0 && $(e.target).closest('.x-custom').length === 0) { - return; - } - - this.model.set({ - id : undefined, - enable : true - }); - - var editView = new EditView({ - model : this.model, - targetCollection : this.targetCollection - }); - - AppLayout.modalRegion.show(editView); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/Add/DownloadClientAddItemViewTemplate.hbs b/src/UI/Settings/DownloadClient/Add/DownloadClientAddItemViewTemplate.hbs deleted file mode 100644 index 40bcb4391..000000000 --- a/src/UI/Settings/DownloadClient/Add/DownloadClientAddItemViewTemplate.hbs +++ /dev/null @@ -1,30 +0,0 @@ -<div class="add-thingy"> - <div> - {{implementationName}} - </div> - <div class="pull-right"> - {{#if_gt presets.length compare=0}} - <button class="btn btn-xs btn-default x-custom"> - Custom - </button> - <div class="btn-group"> - <button class="btn btn-xs btn-default dropdown-toggle" data-toggle="dropdown"> - Presets - <span class="caret"></span> - </button> - <ul class="dropdown-menu"> - {{#each presets}} - <li class="x-preset" data-id="{{name}}"> - <a>{{name}}</a> - </li> - {{/each}} - </ul> - </div> - {{/if_gt}} - {{#if infoLink}} - <a class="btn btn-xs btn-default x-info" href="{{infoLink}}"> - <i class="icon-sonarr-form-info"/> - </a> - {{/if}} - </div> -</div> \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/Add/DownloadClientSchemaModal.js b/src/UI/Settings/DownloadClient/Add/DownloadClientSchemaModal.js deleted file mode 100644 index 603a4dfdc..000000000 --- a/src/UI/Settings/DownloadClient/Add/DownloadClientSchemaModal.js +++ /dev/null @@ -1,39 +0,0 @@ -var _ = require('underscore'); -var AppLayout = require('../../../AppLayout'); -var Backbone = require('backbone'); -var SchemaCollection = require('../DownloadClientCollection'); -var AddCollectionView = require('./DownloadClientAddCollectionView'); - -module.exports = { - open : function(collection) { - var schemaCollection = new SchemaCollection(); - var originalUrl = schemaCollection.url; - schemaCollection.url = schemaCollection.url + '/schema'; - schemaCollection.fetch(); - schemaCollection.url = originalUrl; - - var groupedSchemaCollection = new Backbone.Collection(); - - schemaCollection.on('sync', function() { - - var groups = schemaCollection.groupBy(function(model, iterator) { - return model.get('protocol'); - }); - var modelCollection = _.map(groups, function(values, key, list) { - return { - 'header' : key, - collection : values - }; - }); - - groupedSchemaCollection.reset(modelCollection); - }); - - var view = new AddCollectionView({ - collection : groupedSchemaCollection, - targetCollection : collection - }); - - AppLayout.modalRegion.show(view); - } -}; \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/Delete/DownloadClientDeleteView.js b/src/UI/Settings/DownloadClient/Delete/DownloadClientDeleteView.js deleted file mode 100644 index e2b9e8556..000000000 --- a/src/UI/Settings/DownloadClient/Delete/DownloadClientDeleteView.js +++ /dev/null @@ -1,19 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'Settings/DownloadClient/Delete/DownloadClientDeleteViewTemplate', - - events : { - 'click .x-confirm-delete' : '_delete' - }, - - _delete : function() { - this.model.destroy({ - wait : true, - success : function() { - vent.trigger(vent.Commands.CloseModalCommand); - } - }); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/Delete/DownloadClientDeleteViewTemplate.hbs b/src/UI/Settings/DownloadClient/Delete/DownloadClientDeleteViewTemplate.hbs deleted file mode 100644 index f31729279..000000000 --- a/src/UI/Settings/DownloadClient/Delete/DownloadClientDeleteViewTemplate.hbs +++ /dev/null @@ -1,13 +0,0 @@ -<div class="modal-content"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - <h3>Delete Download Client</h3> - </div> - <div class="modal-body"> - <p>Are you sure you want to delete '{{name}}'?</p> - </div> - <div class="modal-footer"> - <button class="btn" data-dismiss="modal">Cancel</button> - <button class="btn btn-danger x-confirm-delete">Delete</button> - </div> -</div> diff --git a/src/UI/Settings/DownloadClient/DownloadClientCollection.js b/src/UI/Settings/DownloadClient/DownloadClientCollection.js deleted file mode 100644 index 6e0a37083..000000000 --- a/src/UI/Settings/DownloadClient/DownloadClientCollection.js +++ /dev/null @@ -1,25 +0,0 @@ -var Backbone = require('backbone'); -var DownloadClientModel = require('./DownloadClientModel'); - -module.exports = Backbone.Collection.extend({ - model : DownloadClientModel, - url : window.NzbDrone.ApiRoot + '/downloadclient', - - comparator : function(left, right, collection) { - var result = 0; - - if (left.get('protocol')) { - result = -left.get('protocol').localeCompare(right.get('protocol')); - } - - if (result === 0 && left.get('name')) { - result = left.get('name').localeCompare(right.get('name')); - } - - if (result === 0) { - result = left.get('implementation').localeCompare(right.get('implementation')); - } - - return result; - } -}); \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/DownloadClientCollectionView.js b/src/UI/Settings/DownloadClient/DownloadClientCollectionView.js deleted file mode 100644 index 457c7afcb..000000000 --- a/src/UI/Settings/DownloadClient/DownloadClientCollectionView.js +++ /dev/null @@ -1,25 +0,0 @@ -var Marionette = require('marionette'); -var ItemView = require('./DownloadClientItemView'); -var SchemaModal = require('./Add/DownloadClientSchemaModal'); - -module.exports = Marionette.CompositeView.extend({ - itemView : ItemView, - itemViewContainer : '.download-client-list', - template : 'Settings/DownloadClient/DownloadClientCollectionViewTemplate', - - ui : { - 'addCard' : '.x-add-card' - }, - - events : { - 'click .x-add-card' : '_openSchemaModal' - }, - - appendHtml : function(collectionView, itemView, index) { - collectionView.ui.addCard.parent('li').before(itemView.el); - }, - - _openSchemaModal : function() { - SchemaModal.open(this.collection); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/DownloadClientCollectionViewTemplate.hbs b/src/UI/Settings/DownloadClient/DownloadClientCollectionViewTemplate.hbs deleted file mode 100644 index ec30db7b1..000000000 --- a/src/UI/Settings/DownloadClient/DownloadClientCollectionViewTemplate.hbs +++ /dev/null @@ -1,16 +0,0 @@ -<fieldset> - <legend>Download Clients</legend> - <div class="row"> - <div class="col-md-12"> - <ul class="download-client-list thingies"> - <li> - <div class="download-client-item thingy add-card x-add-card"> - <span class="center well"> - <i class="icon-sonarr-add"/> - </span> - </div> - </li> - </ul> - </div> - </div> -</fieldset> \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/DownloadClientItemView.js b/src/UI/Settings/DownloadClient/DownloadClientItemView.js deleted file mode 100644 index fc8a65b4f..000000000 --- a/src/UI/Settings/DownloadClient/DownloadClientItemView.js +++ /dev/null @@ -1,24 +0,0 @@ -var AppLayout = require('../../AppLayout'); -var Marionette = require('marionette'); -var EditView = require('./Edit/DownloadClientEditView'); - -module.exports = Marionette.ItemView.extend({ - template : 'Settings/DownloadClient/DownloadClientItemViewTemplate', - tagName : 'li', - - events : { - 'click' : '_edit' - }, - - initialize : function() { - this.listenTo(this.model, 'sync', this.render); - }, - - _edit : function() { - var view = new EditView({ - model : this.model, - targetCollection : this.model.collection - }); - AppLayout.modalRegion.show(view); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/DownloadClientItemViewTemplate.hbs b/src/UI/Settings/DownloadClient/DownloadClientItemViewTemplate.hbs deleted file mode 100644 index ca9fb65f9..000000000 --- a/src/UI/Settings/DownloadClient/DownloadClientItemViewTemplate.hbs +++ /dev/null @@ -1,13 +0,0 @@ -<div class="download-client-item thingy"> - <div> - <h3>{{name}}</h3> - </div> - - <div class="settings"> - {{#if enable}} - <span class="label label-success">Enabled</span> - {{else}} - <span class="label label-default">Not Enabled</span> - {{/if}} - </div> -</div> diff --git a/src/UI/Settings/DownloadClient/DownloadClientLayout.js b/src/UI/Settings/DownloadClient/DownloadClientLayout.js deleted file mode 100644 index fdd6e1b80..000000000 --- a/src/UI/Settings/DownloadClient/DownloadClientLayout.js +++ /dev/null @@ -1,32 +0,0 @@ -var Marionette = require('marionette'); -var DownloadClientCollection = require('./DownloadClientCollection'); -var DownloadClientCollectionView = require('./DownloadClientCollectionView'); -var DownloadHandlingView = require('./DownloadHandling/DownloadHandlingView'); -var DroneFactoryView = require('./DroneFactory/DroneFactoryView'); -var RemotePathMappingCollection = require('./RemotePathMapping/RemotePathMappingCollection'); -var RemotePathMappingCollectionView = require('./RemotePathMapping/RemotePathMappingCollectionView'); - -module.exports = Marionette.Layout.extend({ - template : 'Settings/DownloadClient/DownloadClientLayoutTemplate', - - regions : { - downloadClients : '#x-download-clients-region', - downloadHandling : '#x-download-handling-region', - droneFactory : '#x-dronefactory-region', - remotePathMappings : '#x-remotepath-mapping-region' - }, - - initialize : function() { - this.downloadClientsCollection = new DownloadClientCollection(); - this.downloadClientsCollection.fetch(); - this.remotePathMappingCollection = new RemotePathMappingCollection(); - this.remotePathMappingCollection.fetch(); - }, - - onShow : function() { - this.downloadClients.show(new DownloadClientCollectionView({ collection : this.downloadClientsCollection })); - this.downloadHandling.show(new DownloadHandlingView({ model : this.model })); - this.droneFactory.show(new DroneFactoryView({ model : this.model })); - this.remotePathMappings.show(new RemotePathMappingCollectionView({ collection : this.remotePathMappingCollection })); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/DownloadClientLayoutTemplate.hbs b/src/UI/Settings/DownloadClient/DownloadClientLayoutTemplate.hbs deleted file mode 100644 index ab039d682..000000000 --- a/src/UI/Settings/DownloadClient/DownloadClientLayoutTemplate.hbs +++ /dev/null @@ -1,6 +0,0 @@ -<div id="x-download-clients-region"></div> -<div class="form-horizontal"> - <div id="x-download-handling-region"></div> - <div id="x-dronefactory-region"></div> - <div id="x-remotepath-mapping-region"></div> -</div> diff --git a/src/UI/Settings/DownloadClient/DownloadClientModel.js b/src/UI/Settings/DownloadClient/DownloadClientModel.js deleted file mode 100644 index 288e45362..000000000 --- a/src/UI/Settings/DownloadClient/DownloadClientModel.js +++ /dev/null @@ -1,3 +0,0 @@ -var ProviderSettingsModelBase = require('../ProviderSettingsModelBase'); - -module.exports = ProviderSettingsModelBase.extend({}); \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/DownloadClientSettingsModel.js b/src/UI/Settings/DownloadClient/DownloadClientSettingsModel.js deleted file mode 100644 index eef6d7557..000000000 --- a/src/UI/Settings/DownloadClient/DownloadClientSettingsModel.js +++ /dev/null @@ -1,7 +0,0 @@ -var SettingsModelBase = require('../SettingsModelBase'); - -module.exports = SettingsModelBase.extend({ - url : window.NzbDrone.ApiRoot + '/config/downloadclient', - successMessage : 'Download client settings saved', - errorMessage : 'Failed to save download client settings' -}); \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/DownloadHandling/DownloadHandlingView.js b/src/UI/Settings/DownloadClient/DownloadHandling/DownloadHandlingView.js deleted file mode 100644 index f3411025c..000000000 --- a/src/UI/Settings/DownloadClient/DownloadHandling/DownloadHandlingView.js +++ /dev/null @@ -1,50 +0,0 @@ -var Marionette = require('marionette'); -var AsModelBoundView = require('../../../Mixins/AsModelBoundView'); -var AsValidatedView = require('../../../Mixins/AsValidatedView'); - -var view = Marionette.ItemView.extend({ - template : 'Settings/DownloadClient/DownloadHandling/DownloadHandlingViewTemplate', - - ui : { - completedDownloadHandlingCheckbox : '.x-completed-download-handling', - completedDownloadOptions : '.x-completed-download-options', - failedAutoRedownladCheckbox : '.x-failed-auto-redownload', - failedDownloadOptions : '.x-failed-download-options' - }, - - events : { - 'change .x-completed-download-handling' : '_setCompletedDownloadOptionsVisibility', - 'change .x-failed-auto-redownload' : '_setFailedDownloadOptionsVisibility' - }, - - onRender : function() { - if (!this.ui.completedDownloadHandlingCheckbox.prop('checked')) { - this.ui.completedDownloadOptions.hide(); - } - if (!this.ui.failedAutoRedownladCheckbox.prop('checked')) { - this.ui.failedDownloadOptions.hide(); - } - }, - - _setCompletedDownloadOptionsVisibility : function() { - var checked = this.ui.completedDownloadHandlingCheckbox.prop('checked'); - if (checked) { - this.ui.completedDownloadOptions.slideDown(); - } else { - this.ui.completedDownloadOptions.slideUp(); - } - }, - - _setFailedDownloadOptionsVisibility : function() { - var checked = this.ui.failedAutoRedownladCheckbox.prop('checked'); - if (checked) { - this.ui.failedDownloadOptions.slideDown(); - } else { - this.ui.failedDownloadOptions.slideUp(); - } - } -}); - -AsModelBoundView.call(view); -AsValidatedView.call(view); -module.exports = view; \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/DownloadHandling/DownloadHandlingViewTemplate.hbs b/src/UI/Settings/DownloadClient/DownloadHandling/DownloadHandlingViewTemplate.hbs deleted file mode 100644 index 2c23678e3..000000000 --- a/src/UI/Settings/DownloadClient/DownloadHandling/DownloadHandlingViewTemplate.hbs +++ /dev/null @@ -1,93 +0,0 @@ -<fieldset> - <legend>Completed Download Handling</legend> - <div class="form-group"> - <label class="col-sm-3 control-label">Enable</label> - - <div class="col-sm-8"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="enableCompletedDownloadHandling" class="x-completed-download-handling"/> - <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="Automatically import completed downloads from download client"/> - </span> - </div> - </div> - </div> - - <div class="x-completed-download-options advanced-setting"> - <div class="form-group"> - <label class="col-sm-3 control-label">Remove</label> - - <div class="col-sm-8"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="removeCompletedDownloads"/> - <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="Remove imported downloads from download client history"/> - </span> - </div> - </div> - </div> - </div> -</fieldset> - -<fieldset> - <legend>Failed Download Handling</legend> - <div class="form-group"> - <label class="col-sm-3 control-label">Redownload</label> - - <div class="col-sm-8"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="autoRedownloadFailed" class="x-failed-auto-redownload"/> - <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="Automatically search for and attempt to download a different release"/> - </span> - </div> - </div> - </div> - <div class="x-failed-download-options advanced-setting"> - <div class="form-group "> - <label class="col-sm-3 control-label">Remove</label> - <div class="col-sm-8"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="removeFailedDownloads"/> - <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="Remove failed downloads from download client history"/> - </span> - </div> - </div> - </div> - </div> -</fieldset> \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/DroneFactory/DroneFactoryView.js b/src/UI/Settings/DownloadClient/DroneFactory/DroneFactoryView.js deleted file mode 100644 index 154be0a4b..000000000 --- a/src/UI/Settings/DownloadClient/DroneFactory/DroneFactoryView.js +++ /dev/null @@ -1,21 +0,0 @@ -var Marionette = require('marionette'); -var AsModelBoundView = require('../../../Mixins/AsModelBoundView'); -var AsValidatedView = require('../../../Mixins/AsValidatedView'); -require('../../../Mixins/FileBrowser'); - -var view = Marionette.ItemView.extend({ - template : 'Settings/DownloadClient/DroneFactory/DroneFactoryViewTemplate', - - ui : { - droneFactory : '.x-path' - }, - - onShow : function() { - this.ui.droneFactory.fileBrowser(); - } -}); - -AsModelBoundView.call(view); -AsValidatedView.call(view); - -module.exports = view; \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/DroneFactory/DroneFactoryViewTemplate.hbs b/src/UI/Settings/DownloadClient/DroneFactory/DroneFactoryViewTemplate.hbs deleted file mode 100644 index 9043ad2f5..000000000 --- a/src/UI/Settings/DownloadClient/DroneFactory/DroneFactoryViewTemplate.hbs +++ /dev/null @@ -1,29 +0,0 @@ -<fieldset class="advanced-setting"> - <legend>Drone Factory Options</legend> - <div class="form-group"> - <label class="col-sm-3 control-label">Drone Factory</label> - - <div class="col-sm-1 col-sm-push-8 help-inline"> - <i class="icon-sonarr-form-info" title="Optional folder to periodically scan for possible imports"/> - <i class="icon-sonarr-form-warning" title="Do not use the folder that contains some or all of your sorted and named TV shows - doing so could cause data loss"></i> - <i class="icon-sonarr-form-warning" title="Download client history items that are stored in the drone factory will be ignored."/> - </div> - - <div class="col-sm-8 col-sm-pull-1"> - <input type="text" name="downloadedEpisodesFolder" class="form-control x-path" /> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Drone Factory Interval</label> - - <div class="col-sm-1 col-sm-push-2 help-inline"> - <i class="icon-sonarr-form-info" title="Interval in minutes to scan the Drone Factory. Set to zero to disable."/> - <i class="icon-sonarr-form-warning" title="Setting a high interval or disabling scanning will prevent episodes from being imported."></i> - </div> - - <div class="col-sm-2 col-sm-pull-1"> - <input type="number" name="downloadedEpisodesScanInterval" class="form-control" /> - </div> - </div> -</fieldset> \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/Edit/DownloadClientEditView.js b/src/UI/Settings/DownloadClient/Edit/DownloadClientEditView.js deleted file mode 100644 index 1ae48d999..000000000 --- a/src/UI/Settings/DownloadClient/Edit/DownloadClientEditView.js +++ /dev/null @@ -1,56 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); -var DeleteView = require('../Delete/DownloadClientDeleteView'); -var AsModelBoundView = require('../../../Mixins/AsModelBoundView'); -var AsValidatedView = require('../../../Mixins/AsValidatedView'); -var AsEditModalView = require('../../../Mixins/AsEditModalView'); -require('../../../Form/FormBuilder'); -require('../../../Mixins/FileBrowser'); -require('bootstrap'); - -var view = Marionette.ItemView.extend({ - template : 'Settings/DownloadClient/Edit/DownloadClientEditViewTemplate', - - ui : { - path : '.x-path', - modalBody : '.modal-body' - }, - - events : { - 'click .x-back' : '_back' - }, - - _deleteView : DeleteView, - - initialize : function(options) { - this.targetCollection = options.targetCollection; - }, - - onShow : function() { - if (this.ui.path.length > 0) { - this.ui.modalBody.addClass('modal-overflow'); - } - - this.ui.path.fileBrowser(); - }, - - _onAfterSave : function() { - this.targetCollection.add(this.model, { merge : true }); - vent.trigger(vent.Commands.CloseModalCommand); - }, - - _onAfterSaveAndAdd : function() { - this.targetCollection.add(this.model, { merge : true }); - - require('../Add/DownloadClientSchemaModal').open(this.targetCollection); - }, - _back : function() { - require('../Add/DownloadClientSchemaModal').open(this.targetCollection); - } -}); - -AsModelBoundView.call(view); -AsValidatedView.call(view); -AsEditModalView.call(view); - -module.exports = view; \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/Edit/DownloadClientEditViewTemplate.hbs b/src/UI/Settings/DownloadClient/Edit/DownloadClientEditViewTemplate.hbs deleted file mode 100644 index e4e576209..000000000 --- a/src/UI/Settings/DownloadClient/Edit/DownloadClientEditViewTemplate.hbs +++ /dev/null @@ -1,68 +0,0 @@ -<div class="modal-content"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - {{#if id}} - <h3>Edit - {{implementation}}</h3> - {{else}} - <h3>Add - {{implementation}}</h3> - {{/if}} - </div> - <div class="modal-body download-client-modal"> - {{formMessage message}} - - <div class="form-horizontal"> - <div class="form-group"> - <label class="col-sm-3 control-label">Name</label> - - <div class="col-sm-5"> - <input type="text" name="name" class="form-control"/> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Enable</label> - - <div class="col-sm-5"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="enable"/> - <p> - <span>Yes</span> - <span>No</span> - </p> - - <div class="btn btn-primary slide-button"/> - </label> - </div> - </div> - </div> - - <hr> - - {{formBuilder}} - </div> - </div> - <div class="modal-footer"> - {{#if id}} - <button class="btn btn-danger pull-left x-delete">Delete</button> - {{else}} - <button class="btn pull-left x-back">Back</button> - {{/if}} - - <span class="indicator x-indicator"><i class="icon-sonarr-spinner fa-spin"></i></span> - <button class="btn x-test">test <i class="x-test-icon icon-sonarr-test"/></button> - <button class="btn" data-dismiss="modal">Cancel</button> - - <div class="btn-group"> - <button class="btn btn-primary x-save">Save</button> - <button class="btn btn-icon-only btn-primary dropdown-toggle" data-toggle="dropdown"> - <span class="caret"></span> - </button> - <ul class="dropdown-menu"> - <li class="save-and-add x-save-and-add"> - save and add - </li> - </ul> - </div> - </div> -</div> diff --git a/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingCollection.js b/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingCollection.js deleted file mode 100644 index 2906e2254..000000000 --- a/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingCollection.js +++ /dev/null @@ -1,7 +0,0 @@ -var Backbone = require('backbone'); -var RemotePathMappingModel = require('./RemotePathMappingModel'); - -module.exports = Backbone.Collection.extend({ - model : RemotePathMappingModel, - url : window.NzbDrone.ApiRoot + '/remotePathMapping' -}); \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingCollectionView.js b/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingCollectionView.js deleted file mode 100644 index 9a24a95d3..000000000 --- a/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingCollectionView.js +++ /dev/null @@ -1,28 +0,0 @@ -var AppLayout = require('../../../AppLayout'); -var Marionette = require('marionette'); -var RemotePathMappingItemView = require('./RemotePathMappingItemView'); -var EditView = require('./RemotePathMappingEditView'); -var RemotePathMappingModel = require('./RemotePathMappingModel'); -require('bootstrap'); - -module.exports = Marionette.CompositeView.extend({ - template : 'Settings/DownloadClient/RemotePathMapping/RemotePathMappingCollectionViewTemplate', - itemViewContainer : '.x-rows', - itemView : RemotePathMappingItemView, - - events : { - 'click .x-add' : '_addMapping' - }, - - _addMapping : function() { - var model = new RemotePathMappingModel(); - model.collection = this.collection; - - var view = new EditView({ - model : model, - targetCollection : this.collection - }); - - AppLayout.modalRegion.show(view); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingCollectionViewTemplate.hbs b/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingCollectionViewTemplate.hbs deleted file mode 100644 index 423bf84d8..000000000 --- a/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingCollectionViewTemplate.hbs +++ /dev/null @@ -1,24 +0,0 @@ -<fieldset class="advanced-setting"> - <legend>Remote Path Mappings</legend> - - <div class="col-md-12"> - <div class="rule-setting-list"> - <div class="rule-setting-header x-header hidden-xs"> - <div class="row"> - <span class="col-sm-2">Host</span> - <span class="col-sm-5">Remote Path</span> - <span class="col-sm-4">Local Path</span> - </div> - </div> - <div class="rows x-rows"> - </div> - <div class="rule-setting-footer"> - <div class="pull-right"> - <span class="add-rule-setting-mapping"> - <i class="icon-sonarr-add x-add" title="Add new mapping" /> - </span> - </div> - </div> - </div> - </div> -</fieldset> \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingDeleteView.js b/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingDeleteView.js deleted file mode 100644 index 1ddf5f94b..000000000 --- a/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingDeleteView.js +++ /dev/null @@ -1,19 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'Settings/DownloadClient/RemotePathMapping/RemotePathMappingDeleteViewTemplate', - - events : { - 'click .x-confirm-delete' : '_delete' - }, - - _delete : function() { - this.model.destroy({ - wait : true, - success : function() { - vent.trigger(vent.Commands.CloseModalCommand); - } - }); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingDeleteViewTemplate.hbs b/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingDeleteViewTemplate.hbs deleted file mode 100644 index 10d94278a..000000000 --- a/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingDeleteViewTemplate.hbs +++ /dev/null @@ -1,13 +0,0 @@ -<div class="modal-content"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - <h3>Delete Mapping</h3> - </div> - <div class="modal-body"> - <p>Are you sure you want to delete the mapping for '{{localPath}}'?</p> - </div> - <div class="modal-footer"> - <button class="btn" data-dismiss="modal">Cancel</button> - <button class="btn btn-danger x-confirm-delete">Delete</button> - </div> -</div> diff --git a/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingEditView.js b/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingEditView.js deleted file mode 100644 index 642901162..000000000 --- a/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingEditView.js +++ /dev/null @@ -1,45 +0,0 @@ -var _ = require('underscore'); -var vent = require('vent'); -var AppLayout = require('../../../AppLayout'); -var Marionette = require('marionette'); -var DeleteView = require('./RemotePathMappingDeleteView'); -var CommandController = require('../../../Commands/CommandController'); -var AsModelBoundView = require('../../../Mixins/AsModelBoundView'); -var AsValidatedView = require('../../../Mixins/AsValidatedView'); -var AsEditModalView = require('../../../Mixins/AsEditModalView'); -require('../../../Mixins/FileBrowser'); -require('bootstrap'); - -var view = Marionette.ItemView.extend({ - template : 'Settings/DownloadClient/RemotePathMapping/RemotePathMappingEditViewTemplate', - - ui : { - path : '.x-path', - modalBody : '.modal-body' - }, - - _deleteView : DeleteView, - - initialize : function(options) { - this.targetCollection = options.targetCollection; - }, - - onShow : function() { - if (this.ui.path.length > 0) { - this.ui.modalBody.addClass('modal-overflow'); - } - - this.ui.path.fileBrowser(); - }, - - _onAfterSave : function() { - this.targetCollection.add(this.model, { merge : true }); - vent.trigger(vent.Commands.CloseModalCommand); - } -}); - -AsModelBoundView.call(view); -AsValidatedView.call(view); -AsEditModalView.call(view); - -module.exports = view; \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingEditViewTemplate.hbs b/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingEditViewTemplate.hbs deleted file mode 100644 index bc7926439..000000000 --- a/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingEditViewTemplate.hbs +++ /dev/null @@ -1,63 +0,0 @@ -<div class="modal-content"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - {{#if id}} - <h3>Edit Mapping</h3> - {{else}} - <h3>Add Mapping</h3> - {{/if}} - </div> - <div class="modal-body remotepath-mapping-modal"> - <div class="form-horizontal"> - <div> - <p>Use this feature if you have a remotely running Download Client. Sonarr will use the information provided to translate the paths provided by the Download Client API to something Sonarr can access and import.</p> - </div> - <div class="form-group"> - <label class="col-sm-3 control-label">Host</label> - - <div class="col-sm-1 col-sm-push-3 help-inline"> - <i class="icon-sonarr-form-info" title="Host you specified for the remote Download Client." /> - </div> - - <div class="col-sm-3 col-sm-pull-1"> - <input type="text" name="host" class="form-control"/> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Remote Path</label> - - <div class="col-sm-1 col-sm-push-5 help-inline"> - <i class="icon-sonarr-form-info" title="Root path to the directory that the Download Client accesses." /> - </div> - - <div class="col-sm-5 col-sm-pull-1"> - <input type="text" name="remotePath" class="form-control"/> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Local Path</label> - - <div class="col-sm-1 col-sm-push-5 help-inline"> - <i class="icon-sonarr-form-info" title="Path that Sonarr should use to access the same directory remotely." /> - </div> - - <div class="col-sm-5 col-sm-pull-1"> - <input type="text" name="localPath" class="form-control x-path"/> - </div> - </div> - </div> - </div> - <div class="modal-footer"> - {{#if id}} - <button class="btn btn-danger pull-left x-delete">Delete</button> - {{/if}} - - <button class="btn" data-dismiss="modal">Cancel</button> - - <div class="btn-group"> - <button class="btn btn-primary x-save">Save</button> - </div> - </div> -</div> \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingItemView.js b/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingItemView.js deleted file mode 100644 index d81690e57..000000000 --- a/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingItemView.js +++ /dev/null @@ -1,25 +0,0 @@ -var AppLayout = require('../../../AppLayout'); -var Marionette = require('marionette'); -var EditView = require('./RemotePathMappingEditView'); - -module.exports = Marionette.ItemView.extend({ - template : 'Settings/DownloadClient/RemotePathMapping/RemotePathMappingItemViewTemplate', - className : 'row', - - events : { - 'click .x-edit' : '_editMapping' - }, - - initialize : function() { - this.listenTo(this.model, 'sync', this.render); - }, - - _editMapping : function() { - var view = new EditView({ - model : this.model, - targetCollection : this.model.collection - }); - - AppLayout.modalRegion.show(view); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingItemViewTemplate.hbs b/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingItemViewTemplate.hbs deleted file mode 100644 index 80b2cb6ed..000000000 --- a/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingItemViewTemplate.hbs +++ /dev/null @@ -1,12 +0,0 @@ - <div class="col-sm-2"> - {{host}} - </div> - <div class="col-sm-5"> - {{remotePath}} - </div> - <div class="col-sm-4"> - {{localPath}} - </div> - <div class="col-sm-1"> - <div class="pull-right"><i class="icon-sonarr-edit x-edit" title="" data-original-title="Edit Mapping"></i></div> - </div> \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingModel.js b/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingModel.js deleted file mode 100644 index e8ea08465..000000000 --- a/src/UI/Settings/DownloadClient/RemotePathMapping/RemotePathMappingModel.js +++ /dev/null @@ -1,4 +0,0 @@ -var $ = require('jquery'); -var DeepModel = require('backbone.deepmodel'); - -module.exports = DeepModel.extend({}); \ No newline at end of file diff --git a/src/UI/Settings/DownloadClient/downloadclient.less b/src/UI/Settings/DownloadClient/downloadclient.less deleted file mode 100644 index cc2f8f77e..000000000 --- a/src/UI/Settings/DownloadClient/downloadclient.less +++ /dev/null @@ -1,33 +0,0 @@ -@import "../../Shared/Styles/clickable.less"; - -.download-client-list { - li { - display: inline-block; - vertical-align: top; - } -} - -.download-client-item { - - .clickable; - - width: 290px; - height: 90px; - padding: 10px 15px; - - &.add-card { - .center { - margin-top: -3px; - } - } -} - -.modal-overflow { - overflow-y: visible; -} - -.add-download-client { - li.add-thingy-item { - width: 33%; - } -} diff --git a/src/UI/Settings/General/GeneralSettingsModel.js b/src/UI/Settings/General/GeneralSettingsModel.js deleted file mode 100644 index b8ef7de49..000000000 --- a/src/UI/Settings/General/GeneralSettingsModel.js +++ /dev/null @@ -1,7 +0,0 @@ -var SettingsModelBase = require('../SettingsModelBase'); - -module.exports = SettingsModelBase.extend({ - url : window.NzbDrone.ApiRoot + '/config/host', - successMessage : 'General settings saved', - errorMessage : 'Failed to save general settings' -}); \ No newline at end of file diff --git a/src/UI/Settings/General/GeneralView.js b/src/UI/Settings/General/GeneralView.js deleted file mode 100644 index 81f638f34..000000000 --- a/src/UI/Settings/General/GeneralView.js +++ /dev/null @@ -1,136 +0,0 @@ -var vent = require('../../vent'); -var Marionette = require('marionette'); -var CommandController = require('../../Commands/CommandController'); -var AsModelBoundView = require('../../Mixins/AsModelBoundView'); -var AsValidatedView = require('../../Mixins/AsValidatedView'); - -require('../../Mixins/CopyToClipboard'); - -var view = Marionette.ItemView.extend({ - template : 'Settings/General/GeneralViewTemplate', - - events : { - 'change .x-auth' : '_setAuthOptionsVisibility', - 'change .x-proxy' : '_setProxyOptionsVisibility', - 'change .x-ssl' : '_setSslOptionsVisibility', - 'click .x-reset-api-key' : '_resetApiKey', - 'change .x-update-mechanism' : '_setScriptGroupVisibility' - }, - - ui : { - authToggle : '.x-auth', - authOptions : '.x-auth-options', - sslToggle : '.x-ssl', - sslOptions : '.x-ssl-options', - resetApiKey : '.x-reset-api-key', - copyApiKey : '.x-copy-api-key', - apiKeyInput : '.x-api-key', - updateMechanism : '.x-update-mechanism', - scriptGroup : '.x-script-group', - proxyToggle : '.x-proxy', - proxyOptions : '.x-proxy-settings' - }, - - initialize : function() { - this.listenTo(vent, vent.Events.CommandComplete, this._commandComplete); - }, - - onRender : function() { - if (this.ui.authToggle.val() === 'none') { - this.ui.authOptions.hide(); - } - - if (!this.ui.proxyToggle.prop('checked')) { - this.ui.proxyOptions.hide(); - } - - if (!this.ui.sslToggle.prop('checked')) { - this.ui.sslOptions.hide(); - } - - if (!this._showScriptGroup()) { - this.ui.scriptGroup.hide(); - } - - CommandController.bindToCommand({ - element : this.ui.resetApiKey, - command : { - name : 'resetApiKey' - } - }); - }, - - onShow : function() { - this.ui.copyApiKey.copyToClipboard(this.ui.apiKeyInput); - }, - - _setAuthOptionsVisibility : function() { - - var showAuthOptions = this.ui.authToggle.val() !== 'none'; - - if (showAuthOptions) { - this.ui.authOptions.slideDown(); - } - - else { - this.ui.authOptions.slideUp(); - } - }, - - _setProxyOptionsVisibility : function() { - if (this.ui.proxyToggle.prop('checked')) { - this.ui.proxyOptions.slideDown(); - } - else { - this.ui.proxyOptions.slideUp(); - } - }, - - _setSslOptionsVisibility : function() { - - var showSslOptions = this.ui.sslToggle.prop('checked'); - - if (showSslOptions) { - this.ui.sslOptions.slideDown(); - } - - else { - this.ui.sslOptions.slideUp(); - } - }, - - _resetApiKey : function() { - if (window.confirm('Reset API Key?')) { - CommandController.Execute('resetApiKey', { - name : 'resetApiKey' - }); - } - }, - - _commandComplete : function(options) { - if (options.command.get('name') === 'resetapikey') { - this.model.fetch(); - } - }, - - _setScriptGroupVisibility : function() { - - if (this._showScriptGroup()) { - this.ui.scriptGroup.slideDown(); - } - - else { - this.ui.scriptGroup.slideUp(); - } - }, - - _showScriptGroup : function() { - return this.ui.updateMechanism.val() === 'script'; - } -}); - -AsModelBoundView.call(view); -AsValidatedView.call(view); - -module.exports = view; - diff --git a/src/UI/Settings/General/GeneralViewTemplate.hbs b/src/UI/Settings/General/GeneralViewTemplate.hbs deleted file mode 100644 index 69f80723e..000000000 --- a/src/UI/Settings/General/GeneralViewTemplate.hbs +++ /dev/null @@ -1,382 +0,0 @@ -<div class="form-horizontal"> - <fieldset> - <legend>Start-Up</legend> - - <div class="form-group advanced-setting"> - <label class="col-sm-3 control-label">Bind Address</label> - - <div class="col-sm-1 col-sm-push-4 help-inline"> - <i class="icon-sonarr-form-warning" title="Requires restart to take effect" /> - <i class="icon-sonarr-form-info" title="Valid IP4 address or '*' for all interfaces"/> - </div> - - <div class="col-sm-4 col-sm-pull-1"> - <input type="text" name="bindAddress" class="form-control" /> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Port Number</label> - - <div class="col-sm-1 col-sm-push-4 help-inline"> - <i class="icon-sonarr-form-warning" title="Requires restart to take effect"/> - </div> - - <div class="col-sm-4 col-sm-pull-1"> - <input type="number" placeholder="8989" name="port" class="form-control"/> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">URL Base</label> - - <div class="col-sm-1 col-sm-push-4 help-inline"> - <i class="icon-sonarr-form-warning" title="Requires restart to take effect"/> - <i class="icon-sonarr-form-info" title="For reverse proxy support, default is empty"/> - </div> - - <div class="col-sm-4 col-sm-pull-1"> - <input type="text" name="urlBase" class="form-control"/> - </div> - </div> - - <div class="form-group advanced-setting"> - <label class="col-sm-3 control-label">Enable SSL</label> - - <div class="col-sm-8"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="enableSsl" class="x-ssl"/> - - <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-warning" title="Requires restart running as administrator to take effect"/> - </span> - </div> - </div> - </div> - - <div class="x-ssl-options"> - <div class="form-group advanced-setting"> - <label class="col-sm-3 control-label">SSL Port Number</label> - - <div class="col-sm-4"> - <input type="number" placeholder="8989" name="sslPort" class="form-control"/> - </div> - </div> - - {{#if_windows}} - <div class="form-group advanced-setting"> - <label class="col-sm-3 control-label">SSL Cert Hash</label> - - <div class="col-sm-4"> - <input type="text" name="sslCertHash" class="form-control"/> - </div> - </div> - {{/if_windows}} - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Open browser on start</label> - - <div class="col-sm-8"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="launchBrowser" class="form-control"/> - - <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="Open a web browser and navigate to Sonarr homepage on app start. Has no effect if installed as a windows service"/> - </span> - </div> - </div> - </div> - </fieldset> - - <fieldset> - <legend>Security</legend> - <div class="form-group"> - <label class="col-sm-3 control-label">Authentication</label> - - <div class="col-sm-1 col-sm-push-4 help-inline"> - <i class="icon-sonarr-form-warning" title="Requires restart to take effect"/> - <i class="icon-sonarr-form-info" title="Require Username and Password to access Sonarr"/> - </div> - - <div class="col-sm-4 col-sm-pull-1"> - <select name="authenticationMethod" class="form-control x-auth"> - <option value="none">None</option> - <option value="basic">Basic (Browser popup)</option> - <option value="forms">Forms (Login page)</option> - </select> - </div> - </div> - - <div class="x-auth-options"> - <div class="form-group"> - <label class="col-sm-3 control-label">Username</label> - - <div class="col-sm-4"> - <input type="text" placeholder="Username" name="username" spellcheck="false" class="form-control"/> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Password</label> - - <div class="col-sm-4"> - <input type="password" name="password" autocomplete="new-password" class="form-control"/> - </div> - </div> - </div> - - <div class="form-group api-key"> - <label class="col-sm-3 control-label">API Key</label> - - <div class="col-sm-1 col-sm-push-4 help-inline"> - <i class="icon-sonarr-form-warning" title="Requires restart to take effect"/> - </div> - - <div class="col-sm-4 col-sm-pull-1"> - <div class="input-group"> - <input type="text" name="apiKey" readonly="readonly" class="form-control x-api-key"/> - <div class="input-group-btn"> - <button class="btn btn-icon-only x-copy-api-key hidden-xs"><i class="icon-sonarr-copy"></i></button> - <button class="btn btn-danger btn-icon-only x-reset-api-key" title="Reset API Key"><i class="icon-sonarr-refresh"></i></button> - </div> - </div> - </div> - </div> - </fieldset> - - <fieldset> - <legend>Proxy Settings</legend> - - <div class="form-group"> - <label class="col-sm-3 control-label">Use Proxy</label> - - <div class="col-sm-8"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="proxyEnabled" class="form-control x-proxy"/> - - <p> - <span>Yes</span> - <span>No</span> - </p> - - <div class="btn btn-primary slide-button"/> - </label> - </div> - </div> - </div> - - <div class="x-proxy-settings"> - <div class="form-group"> - <label class="col-sm-3 control-label">Proxy Type</label> - - <div class="col-sm-4"> - <select name="proxyType" class="form-control"> - <option value="http" selected="selected">HTTP(S)</option> - <option value="socks4">Socks4</option> - <option value="socks5">Socks5 (This option supports Tor)</option> - </select> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Hostname</label> - - <div class="col-sm-4"> - <input type="text" placeholder="localhost" name="proxyHostname" class="form-control"/> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Port</label> - - <div class="col-sm-4"> - <input type="number" placeholder="8080" name="proxyPort" class="form-control"/> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Username</label> - - <div class="col-sm-1 col-sm-push-4 help-inline"> - <i class="icon-sonarr-form-info" title="You only need to enter a username and password if one is required. Leave them blank otherwise."/> - </div> - - <div class="col-sm-4 col-sm-pull-1"> - <input type="text" name="proxyUsername" class="form-control"/> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Password</label> - - <div class="col-sm-1 col-sm-push-4 help-inline"> - <i class="icon-sonarr-form-info" title="You only need to enter a username and password if one is required. Leave them blank otherwise."/> - </div> - - <div class="col-sm-4 col-sm-pull-1"> - <input type="password" name="proxyPassword" class="form-control"/> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Addresses for the proxy to ignore</label> - - <div class="col-sm-1 col-sm-push-4 help-inline"> - <i class="icon-sonarr-form-info" title="Use ',' as a separator, and '*.' as a wildcard for subdomains"/> - </div> - - <div class="col-sm-4 col-sm-pull-1"> - <input type="text" name="proxyBypassFilter" class="form-control"/> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Bypass Proxy for Local Addresses</label> - - <div class="col-sm-8"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="proxyBypassLocalAddresses" class="form-control"/> - - <p> - <span>Yes</span> - <span>No</span> - </p> - - <div class="btn btn-primary slide-button"/> - </label> - </div> - </div> - </div> - </div> - </fieldset> - - <fieldset> - <legend>Logging</legend> - - <div class="form-group"> - <label class="col-sm-3 control-label">Log Level</label> - - <div class="col-sm-1 col-sm-push-2 help-inline"> - <i class="icon-sonarr-form-warning" title="Trace logging should only be enabled temporarily"/> - </div> - - <div class="col-sm-2 col-sm-pull-1"> - <select name="logLevel" class="form-control"> - <option value="Trace">Trace</option> - <option value="Debug">Debug</option> - <option value="Info">Info</option> - </select> - </div> - </div> - </fieldset> - <fieldset> - <legend>Analytics</legend> - - <div class="form-group"> - <label class="col-sm-3 control-label">Enable</label> - - <div class="col-sm-8"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="analyticsEnabled" class="form-control"/> - <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="Send anonymous usage and error information to Sonarr's servers. This includes information on your browser, which Sonarr WebUI pages you use, error reporting as well as OS and runtime version. We will use this information to prioritize features and bug fixes."/> - <i class="icon-sonarr-form-warning" title="Requires restart to take effect"/> - </span> - </div> - </div> - </div> - </fieldset> - - <fieldset class="advanced-setting"> - <legend>Updates</legend> - - <div class="form-group"> - <label class="col-sm-3 control-label">Branch</label> - - <div class="col-sm-4"> - <input type="text" placeholder="master" name="branch" class="form-control"/> - </div> - </div> - - {{#if_mono}} - <div class="alert alert-warning">Please see: <a href="https://github.com/NzbDrone/NzbDrone/wiki/Updating">the wiki</a> for more information</div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Automatic</label> - - <div class="col-sm-8"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="updateAutomatically"/> - <p> - <span>On</span> - <span>Off</span> - </p> - <div class="btn btn-primary slide-button"/> - </label> - - <span class="help-inline-checkbox"> - <i class="icon-sonarr-form-info" title="Automatically download and install updates. You will still be able to install from System: Updates"/> - </span> - </div> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Mechanism</label> - - <div class="col-sm-1 col-sm-push-4 help-inline"> - <i class="icon-sonarr-form-info" title="Use built-in updater or external script"/> - </div> - - <div class="col-sm-4 col-sm-pull-1"> - <select name="updateMechanism" class="form-control x-update-mechanism"> - <option value="builtIn">Built-in</option> - <option value="script">Script</option> - </select> - </div> - </div> - - <div class="form-group x-script-group"> - <label class="col-sm-3 control-label">Script Path</label> - - <div class="col-sm-1 col-sm-push-4 help-inline"> - <i class="icon-sonarr-form-info" title="Path to a custom script that take an extracted update package and handle the remainder of the update process"/> - </div> - - <div class="col-sm-4 col-sm-pull-1"> - <input type="text" name="updateScriptPath" class="form-control"/> - </div> - </div> - {{/if_mono}} - </fieldset> -</div> diff --git a/src/UI/Settings/Indexers/Add/IndexerAddCollectionView.js b/src/UI/Settings/Indexers/Add/IndexerAddCollectionView.js deleted file mode 100644 index 5a4102cf2..000000000 --- a/src/UI/Settings/Indexers/Add/IndexerAddCollectionView.js +++ /dev/null @@ -1,9 +0,0 @@ -var ThingyAddCollectionView = require('../../ThingyAddCollectionView'); -var ThingyHeaderGroupView = require('../../ThingyHeaderGroupView'); -var AddItemView = require('./IndexerAddItemView'); - -module.exports = ThingyAddCollectionView.extend({ - itemView : ThingyHeaderGroupView.extend({ itemView : AddItemView }), - itemViewContainer : '.add-indexer .items', - template : 'Settings/Indexers/Add/IndexerAddCollectionViewTemplate' -}); \ No newline at end of file diff --git a/src/UI/Settings/Indexers/Add/IndexerAddCollectionViewTemplate.hbs b/src/UI/Settings/Indexers/Add/IndexerAddCollectionViewTemplate.hbs deleted file mode 100644 index 16bc741ad..000000000 --- a/src/UI/Settings/Indexers/Add/IndexerAddCollectionViewTemplate.hbs +++ /dev/null @@ -1,18 +0,0 @@ -<div class="modal-content"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - <h3>Add Indexer</h3> - </div> - <div class="modal-body"> - <div class="alert alert-info"> - Sonarr supports any indexer that uses the Newznab standard, as well as other indexers listed below.<br/> - For more information on the individual indexers, click on the info buttons. - </div> - <div class="add-indexer add-thingies"> - <ul class="items"></ul> - </div> - </div> - <div class="modal-footer"> - <button class="btn" data-dismiss="modal">Close</button> - </div> -</div> diff --git a/src/UI/Settings/Indexers/Add/IndexerAddItemView.js b/src/UI/Settings/Indexers/Add/IndexerAddItemView.js deleted file mode 100644 index 3a8b0493a..000000000 --- a/src/UI/Settings/Indexers/Add/IndexerAddItemView.js +++ /dev/null @@ -1,52 +0,0 @@ -var _ = require('underscore'); -var $ = require('jquery'); -var AppLayout = require('../../../AppLayout'); -var Marionette = require('marionette'); -var EditView = require('../Edit/IndexerEditView'); - -module.exports = Marionette.ItemView.extend({ - template : 'Settings/Indexers/Add/IndexerAddItemViewTemplate', - tagName : 'li', - className : 'add-thingy-item', - - events : { - 'click .x-preset' : '_addPreset', - 'click' : '_add' - }, - - initialize : function(options) { - this.targetCollection = options.targetCollection; - }, - - _addPreset : function(e) { - var presetName = $(e.target).closest('.x-preset').attr('data-id'); - var presetData = _.where(this.model.get('presets'), { name : presetName })[0]; - - this.model.set(presetData); - - this._openEdit(); - }, - - _add : function(e) { - if ($(e.target).closest('.btn,.btn-group').length !== 0 && $(e.target).closest('.x-custom').length === 0) { - return; - } - - this._openEdit(); - }, - - _openEdit : function() { - this.model.set({ - id : undefined, - enableRss : this.model.get('supportsRss'), - enableSearch : this.model.get('supportsSearch') - }); - - var editView = new EditView({ - model : this.model, - targetCollection : this.targetCollection - }); - - AppLayout.modalRegion.show(editView); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/Indexers/Add/IndexerAddItemViewTemplate.hbs b/src/UI/Settings/Indexers/Add/IndexerAddItemViewTemplate.hbs deleted file mode 100644 index 40bcb4391..000000000 --- a/src/UI/Settings/Indexers/Add/IndexerAddItemViewTemplate.hbs +++ /dev/null @@ -1,30 +0,0 @@ -<div class="add-thingy"> - <div> - {{implementationName}} - </div> - <div class="pull-right"> - {{#if_gt presets.length compare=0}} - <button class="btn btn-xs btn-default x-custom"> - Custom - </button> - <div class="btn-group"> - <button class="btn btn-xs btn-default dropdown-toggle" data-toggle="dropdown"> - Presets - <span class="caret"></span> - </button> - <ul class="dropdown-menu"> - {{#each presets}} - <li class="x-preset" data-id="{{name}}"> - <a>{{name}}</a> - </li> - {{/each}} - </ul> - </div> - {{/if_gt}} - {{#if infoLink}} - <a class="btn btn-xs btn-default x-info" href="{{infoLink}}"> - <i class="icon-sonarr-form-info"/> - </a> - {{/if}} - </div> -</div> \ No newline at end of file diff --git a/src/UI/Settings/Indexers/Add/IndexerSchemaModal.js b/src/UI/Settings/Indexers/Add/IndexerSchemaModal.js deleted file mode 100644 index 52b430e89..000000000 --- a/src/UI/Settings/Indexers/Add/IndexerSchemaModal.js +++ /dev/null @@ -1,39 +0,0 @@ -var _ = require('underscore'); -var AppLayout = require('../../../AppLayout'); -var Backbone = require('backbone'); -var SchemaCollection = require('../IndexerCollection'); -var AddCollectionView = require('./IndexerAddCollectionView'); - -module.exports = { - open : function(collection) { - var schemaCollection = new SchemaCollection(); - var originalUrl = schemaCollection.url; - schemaCollection.url = schemaCollection.url + '/schema'; - schemaCollection.fetch(); - schemaCollection.url = originalUrl; - - var groupedSchemaCollection = new Backbone.Collection(); - - schemaCollection.on('sync', function() { - - var groups = schemaCollection.groupBy(function(model, iterator) { - return model.get('protocol'); - }); - var modelCollection = _.map(groups, function(values, key, list) { - return { - "header" : key, - collection : values - }; - }); - - groupedSchemaCollection.reset(modelCollection); - }); - - var view = new AddCollectionView({ - collection : groupedSchemaCollection, - targetCollection : collection - }); - - AppLayout.modalRegion.show(view); - } -}; \ No newline at end of file diff --git a/src/UI/Settings/Indexers/Delete/IndexerDeleteView.js b/src/UI/Settings/Indexers/Delete/IndexerDeleteView.js deleted file mode 100644 index 58e7e3eb5..000000000 --- a/src/UI/Settings/Indexers/Delete/IndexerDeleteView.js +++ /dev/null @@ -1,19 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'Settings/Indexers/Delete/IndexerDeleteViewTemplate', - - events : { - 'click .x-confirm-delete' : '_delete' - }, - - _delete : function() { - this.model.destroy({ - wait : true, - success : function() { - vent.trigger(vent.Commands.CloseModalCommand); - } - }); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/Indexers/Delete/IndexerDeleteViewTemplate.hbs b/src/UI/Settings/Indexers/Delete/IndexerDeleteViewTemplate.hbs deleted file mode 100644 index c5c7ad7db..000000000 --- a/src/UI/Settings/Indexers/Delete/IndexerDeleteViewTemplate.hbs +++ /dev/null @@ -1,13 +0,0 @@ -<div class="modal-content"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - <h3>Delete Indexer</h3> - </div> - <div class="modal-body"> - <p>Are you sure you want to delete '{{name}}'?</p> - </div> - <div class="modal-footer"> - <button class="btn" data-dismiss="modal">Cancel</button> - <button class="btn btn-danger x-confirm-delete">Delete</button> - </div> -</div> \ No newline at end of file diff --git a/src/UI/Settings/Indexers/Edit/IndexerEditView.js b/src/UI/Settings/Indexers/Edit/IndexerEditView.js deleted file mode 100644 index 616c863a7..000000000 --- a/src/UI/Settings/Indexers/Edit/IndexerEditView.js +++ /dev/null @@ -1,122 +0,0 @@ -var _ = require('underscore'); -var $ = require('jquery'); -var vent = require('vent'); -var Marionette = require('marionette'); -var DeleteView = require('../Delete/IndexerDeleteView'); -var AsModelBoundView = require('../../../Mixins/AsModelBoundView'); -var AsValidatedView = require('../../../Mixins/AsValidatedView'); -var AsEditModalView = require('../../../Mixins/AsEditModalView'); -require('../../../Form/FormBuilder'); -require('../../../Mixins/AutoComplete'); -require('bootstrap'); - -var view = Marionette.ItemView.extend({ - template : 'Settings/Indexers/Edit/IndexerEditViewTemplate', - - events : { - 'click .x-back' : '_back', - 'click .x-captcha-refresh' : '_onRefreshCaptcha' - }, - - _deleteView : DeleteView, - - initialize : function(options) { - this.targetCollection = options.targetCollection; - }, - - _onAfterSave : function() { - this.targetCollection.add(this.model, { merge : true }); - vent.trigger(vent.Commands.CloseModalCommand); - }, - - _onAfterSaveAndAdd : function() { - this.targetCollection.add(this.model, { merge : true }); - - require('../Add/IndexerSchemaModal').open(this.targetCollection); - }, - - _back : function() { - if (this.model.isNew()) { - this.model.destroy(); - } - - require('../Add/IndexerSchemaModal').open(this.targetCollection); - }, - - _onRefreshCaptcha : function(event) { - var self = this; - - var target = $(event.target).parents('.input-group'); - - this.ui.indicator.show(); - - this.model.requestAction("checkCaptcha") - .then(function(result) { - if (!result.captchaRequest) { - self.model.setFieldValue('CaptchaToken', ''); - - return result; - } - - return self._showCaptcha(target, result.captchaRequest); - }) - .always(function() { - self.ui.indicator.hide(); - }); - }, - - _showCaptcha : function(target, captchaRequest) { - var self = this; - - var widget = $('<div class="g-recaptcha"></div>').insertAfter(target); - - return this._loadRecaptchaWidget(widget[0], captchaRequest.siteKey, captchaRequest.secretToken) - .then(function(captchaResponse) { - target.parents('.form-group').removeAllErrors(); - widget.remove(); - - var queryParams = { - responseUrl : captchaRequest.responseUrl, - ray : captchaRequest.ray, - captchaResponse: captchaResponse - }; - - return self.model.requestAction("getCaptchaCookie", queryParams); - }) - .then(function(response) { - self.model.setFieldValue('CaptchaToken', response.captchaToken); - }); - }, - - _loadRecaptchaWidget : function(widget, sitekey, stoken) { - var promise = $.Deferred(); - - var renderWidget = function() { - window.grecaptcha.render(widget, { - 'sitekey' : sitekey, - 'stoken' : stoken, - 'callback' : promise.resolve - }); - }; - - if (window.grecaptcha) { - renderWidget(); - } else { - window.grecaptchaLoadCallback = function() { - delete window.grecaptchaLoadCallback; - renderWidget(); - }; - - $.getScript('https://www.google.com/recaptcha/api.js?onload=grecaptchaLoadCallback&render=explicit') - .fail(function() { promise.reject(); }); - } - - return promise; - } -}); - -AsModelBoundView.call(view); -AsValidatedView.call(view); -AsEditModalView.call(view); - -module.exports = view; \ No newline at end of file diff --git a/src/UI/Settings/Indexers/Edit/IndexerEditViewTemplate.hbs b/src/UI/Settings/Indexers/Edit/IndexerEditViewTemplate.hbs deleted file mode 100644 index acfb62cbb..000000000 --- a/src/UI/Settings/Indexers/Edit/IndexerEditViewTemplate.hbs +++ /dev/null @@ -1,92 +0,0 @@ -<div class="modal-content"> - <div class="modal-header"> - <button type="button" class="close" aria-hidden="true" data-dismiss="modal">×</button> - {{#if id}} - <h3>Edit - {{implementationName}}</h3> - {{else}} - <h3>Add - {{implementationName}}</h3> - {{/if}} - </div> - <div class="modal-body indexer-modal"> - <div class="form-horizontal"> - <div class="form-group"> - <label class="col-sm-3 control-label">Name</label> - - <div class="col-sm-5"> - <input type="text" name="name" class="form-control"/> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Enable RSS Sync</label> - - <div class="col-sm-5"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="enableRss" {{#unless supportsRss}}disabled="disabled"{{/unless}}/> - <p> - <span>Yes</span> - <span>No</span> - </p> - - <div class="btn btn-primary slide-button"/> - </label> - {{#unless supportsRss}} - <span class="help-inline-checkbox"> - <i class="icon-sonarr-form-warning" title="" data-original-title="RSS is not supported with this indexer"></i> - </span> - {{/unless}} - </div> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Enable Search</label> - - <div class="col-sm-5"> - <div class="input-group"> - <label class="checkbox toggle well"> - - <input type="checkbox" name="enableSearch" {{#unless supportsSearch}}disabled="disabled"{{/unless}}/> - <p> - <span>Yes</span> - <span>No</span> - </p> - - <div class="btn btn-primary slide-button"/> - </label> - {{#unless supportsSearch}} - <span class="help-inline-checkbox"> - <i class="icon-sonarr-form-warning" title="" data-original-title="Search is not supported with this indexer"></i> - </span> - {{/unless}} - </div> - </div> - </div> - - {{formBuilder}} - </div> - </div> - <div class="modal-footer"> - {{#if id}} - <button class="btn btn-danger pull-left x-delete">Delete</button> - {{else}} - <button class="btn pull-left x-back">Back</button> - {{/if}} - <span class="indicator x-indicator"><i class="icon-sonarr-spinner fa-spin"></i></span> - <button class="btn x-test">test <i class="x-test-icon icon-sonarr-test"/></button> - <button class="btn" data-dismiss="modal">Cancel</button> - - <div class="btn-group"> - <button class="btn btn-primary x-save">Save</button> - <button class="btn btn-icon-only btn-primary dropdown-toggle" data-toggle="dropdown"> - <span class="caret"></span> - </button> - <ul class="dropdown-menu"> - <li class="save-and-add x-save-and-add"> - save and add - </li> - </ul> - </div> - </div> -</div> diff --git a/src/UI/Settings/Indexers/IndexerCollection.js b/src/UI/Settings/Indexers/IndexerCollection.js deleted file mode 100644 index 3eb447392..000000000 --- a/src/UI/Settings/Indexers/IndexerCollection.js +++ /dev/null @@ -1,25 +0,0 @@ -var Backbone = require('backbone'); -var IndexerModel = require('./IndexerModel'); - -module.exports = Backbone.Collection.extend({ - model : IndexerModel, - url : window.NzbDrone.ApiRoot + '/indexer', - - comparator : function(left, right, collection) { - var result = 0; - - if (left.get('protocol')) { - result = -left.get('protocol').localeCompare(right.get('protocol')); - } - - if (result === 0 && left.get('name')) { - result = left.get('name').localeCompare(right.get('name')); - } - - if (result === 0) { - result = left.get('implementation').localeCompare(right.get('implementation')); - } - - return result; - } -}); \ No newline at end of file diff --git a/src/UI/Settings/Indexers/IndexerCollectionView.js b/src/UI/Settings/Indexers/IndexerCollectionView.js deleted file mode 100644 index df6ae9596..000000000 --- a/src/UI/Settings/Indexers/IndexerCollectionView.js +++ /dev/null @@ -1,25 +0,0 @@ -var Marionette = require('marionette'); -var ItemView = require('./IndexerItemView'); -var SchemaModal = require('./Add/IndexerSchemaModal'); - -module.exports = Marionette.CompositeView.extend({ - itemView : ItemView, - itemViewContainer : '.indexer-list', - template : 'Settings/Indexers/IndexerCollectionViewTemplate', - - ui : { - 'addCard' : '.x-add-card' - }, - - events : { - 'click .x-add-card' : '_openSchemaModal' - }, - - appendHtml : function(collectionView, itemView, index) { - collectionView.ui.addCard.parent('li').before(itemView.el); - }, - - _openSchemaModal : function() { - SchemaModal.open(this.collection); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/Indexers/IndexerCollectionViewTemplate.hbs b/src/UI/Settings/Indexers/IndexerCollectionViewTemplate.hbs deleted file mode 100644 index 0f4ece3d8..000000000 --- a/src/UI/Settings/Indexers/IndexerCollectionViewTemplate.hbs +++ /dev/null @@ -1,16 +0,0 @@ -<fieldset> - <legend>Indexers</legend> - <div class="row"> - <div class="col-md-12"> - <ul class="indexer-list thingies"> - <li> - <div class="indexer-item thingy add-card x-add-card"> - <span class="center well"> - <i class="icon-sonarr-add"/> - </span> - </div> - </li> - </ul> - </div> - </div> -</fieldset> \ No newline at end of file diff --git a/src/UI/Settings/Indexers/IndexerItemView.js b/src/UI/Settings/Indexers/IndexerItemView.js deleted file mode 100644 index 29cf3d7c5..000000000 --- a/src/UI/Settings/Indexers/IndexerItemView.js +++ /dev/null @@ -1,24 +0,0 @@ -var AppLayout = require('../../AppLayout'); -var Marionette = require('marionette'); -var EditView = require('./Edit/IndexerEditView'); - -module.exports = Marionette.ItemView.extend({ - template : 'Settings/Indexers/IndexerItemViewTemplate', - tagName : 'li', - - events : { - 'click' : '_edit' - }, - - initialize : function() { - this.listenTo(this.model, 'sync', this.render); - }, - - _edit : function() { - var view = new EditView({ - model : this.model, - targetCollection : this.model.collection - }); - AppLayout.modalRegion.show(view); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/Indexers/IndexerItemViewTemplate.hbs b/src/UI/Settings/Indexers/IndexerItemViewTemplate.hbs deleted file mode 100644 index abef39886..000000000 --- a/src/UI/Settings/Indexers/IndexerItemViewTemplate.hbs +++ /dev/null @@ -1,27 +0,0 @@ -<div class="indexer-item thingy"> - <div> - <h3>{{name}}</h3> - </div> - - <div class="settings"> - {{#if supportsRss}} - {{#if enableRss}} - <span class="label label-success">RSS</span> - {{else}} - <span class="label label-default">RSS</span> - {{/if}} - {{else}} - <span class="label label-default label-disabled">RSS</span> - {{/if}} - - {{#if supportsSearch}} - {{#if enableSearch}} - <span class="label label-success">Search</span> - {{else}} - <span class="label label-default">Search</span> - {{/if}} - {{else}} - <span class="label label-default label-disabled">Search</span> - {{/if}} - </div> -</div> diff --git a/src/UI/Settings/Indexers/IndexerLayout.js b/src/UI/Settings/Indexers/IndexerLayout.js deleted file mode 100644 index f6cbd1ab6..000000000 --- a/src/UI/Settings/Indexers/IndexerLayout.js +++ /dev/null @@ -1,30 +0,0 @@ -var Marionette = require('marionette'); -var IndexerCollection = require('./IndexerCollection'); -var CollectionView = require('./IndexerCollectionView'); -var OptionsView = require('./Options/IndexerOptionsView'); -var RestrictionCollection = require('./Restriction/RestrictionCollection'); -var RestrictionCollectionView = require('./Restriction/RestrictionCollectionView'); - -module.exports = Marionette.Layout.extend({ - template : 'Settings/Indexers/IndexerLayoutTemplate', - - regions : { - indexers : '#x-indexers-region', - indexerOptions : '#x-indexer-options-region', - restriction : '#x-restriction-region' - }, - - initialize : function() { - this.indexersCollection = new IndexerCollection(); - this.indexersCollection.fetch(); - - this.restrictionCollection = new RestrictionCollection(); - this.restrictionCollection.fetch(); - }, - - onShow : function() { - this.indexers.show(new CollectionView({ collection : this.indexersCollection })); - this.indexerOptions.show(new OptionsView({ model : this.model })); - this.restriction.show(new RestrictionCollectionView({ collection : this.restrictionCollection })); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/Indexers/IndexerLayoutTemplate.hbs b/src/UI/Settings/Indexers/IndexerLayoutTemplate.hbs deleted file mode 100644 index b82535642..000000000 --- a/src/UI/Settings/Indexers/IndexerLayoutTemplate.hbs +++ /dev/null @@ -1,5 +0,0 @@ -<div id="x-indexers-region"></div> -<div class="form-horizontal"> - <div id="x-indexer-options-region"></div> - <div id="x-restriction-region"></div> -</div> diff --git a/src/UI/Settings/Indexers/IndexerModel.js b/src/UI/Settings/Indexers/IndexerModel.js deleted file mode 100644 index 288e45362..000000000 --- a/src/UI/Settings/Indexers/IndexerModel.js +++ /dev/null @@ -1,3 +0,0 @@ -var ProviderSettingsModelBase = require('../ProviderSettingsModelBase'); - -module.exports = ProviderSettingsModelBase.extend({}); \ No newline at end of file diff --git a/src/UI/Settings/Indexers/IndexerSettingsModel.js b/src/UI/Settings/Indexers/IndexerSettingsModel.js deleted file mode 100644 index 14b9db863..000000000 --- a/src/UI/Settings/Indexers/IndexerSettingsModel.js +++ /dev/null @@ -1,7 +0,0 @@ -var SettingsModelBase = require('../SettingsModelBase'); - -module.exports = SettingsModelBase.extend({ - url : window.NzbDrone.ApiRoot + '/config/indexer', - successMessage : 'Indexer settings saved', - errorMessage : 'Failed to save indexer settings' -}); \ No newline at end of file diff --git a/src/UI/Settings/Indexers/Options/IndexerOptionsView.js b/src/UI/Settings/Indexers/Options/IndexerOptionsView.js deleted file mode 100644 index 5d4386faa..000000000 --- a/src/UI/Settings/Indexers/Options/IndexerOptionsView.js +++ /dev/null @@ -1,12 +0,0 @@ -var Marionette = require('marionette'); -var AsModelBoundView = require('../../../Mixins/AsModelBoundView'); -var AsValidatedView = require('../../../Mixins/AsValidatedView'); - -var view = Marionette.ItemView.extend({ - template : 'Settings/Indexers/Options/IndexerOptionsViewTemplate' -}); - -AsModelBoundView.call(view); -AsValidatedView.call(view); - -module.exports = view; \ No newline at end of file diff --git a/src/UI/Settings/Indexers/Options/IndexerOptionsViewTemplate.hbs b/src/UI/Settings/Indexers/Options/IndexerOptionsViewTemplate.hbs deleted file mode 100644 index 056d12648..000000000 --- a/src/UI/Settings/Indexers/Options/IndexerOptionsViewTemplate.hbs +++ /dev/null @@ -1,40 +0,0 @@ -<fieldset> - <legend>Options</legend> - - <div class="form-group"> - <label class="col-sm-3 control-label">Minimum Age</label> - - <div class="col-sm-1 col-sm-push-2 help-inline"> - <i class="icon-sonarr-form-info" title="Usenet only: Minimum age in minutes of NZBs before they are grabbed. Use this to give new releases time to propagate to your usenet provider."/> - </div> - - <div class="col-sm-2 col-sm-pull-1"> - <input type="number" min="0" name="minimumAge" class="form-control"/> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Retention</label> - - <div class="col-sm-1 col-sm-push-2 help-inline"> - <i class="icon-sonarr-form-info" title="Usenet only: Set to zero to set to unlimited"/> - </div> - - <div class="col-sm-2 col-sm-pull-1"> - <input type="number" min="0" name="retention" class="form-control"/> - </div> - </div> - - <div class="form-group advanced-setting"> - <label class="col-sm-3 control-label">RSS Sync Interval</label> - - <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)"/> - </div> - - <div class="col-sm-2 col-sm-pull-1"> - <input type="number" name="rssSyncInterval" class="form-control" min="0" max="120"/> - </div> - </div> -</fieldset> diff --git a/src/UI/Settings/Indexers/Restriction/RestrictionCollection.js b/src/UI/Settings/Indexers/Restriction/RestrictionCollection.js deleted file mode 100644 index 369250343..000000000 --- a/src/UI/Settings/Indexers/Restriction/RestrictionCollection.js +++ /dev/null @@ -1,7 +0,0 @@ -var Backbone = require('backbone'); -var RestrictionModel = require('./RestrictionModel'); - -module.exports = Backbone.Collection.extend({ - model : RestrictionModel, - url : window.NzbDrone.ApiRoot + '/Restriction' -}); \ No newline at end of file diff --git a/src/UI/Settings/Indexers/Restriction/RestrictionCollectionView.js b/src/UI/Settings/Indexers/Restriction/RestrictionCollectionView.js deleted file mode 100644 index 58b3a6bfa..000000000 --- a/src/UI/Settings/Indexers/Restriction/RestrictionCollectionView.js +++ /dev/null @@ -1,26 +0,0 @@ -var AppLayout = require('../../../AppLayout'); -var Marionette = require('marionette'); -var RestrictionItemView = require('./RestrictionItemView'); -var EditView = require('./RestrictionEditView'); -require('../../../Tags/TagHelpers'); -require('bootstrap'); - -module.exports = Marionette.CompositeView.extend({ - template : 'Settings/Indexers/Restriction/RestrictionCollectionViewTemplate', - itemViewContainer : '.x-rows', - itemView : RestrictionItemView, - - events : { - 'click .x-add' : '_addMapping' - }, - - _addMapping : function() { - var model = this.collection.create({ tags : [] }); - var view = new EditView({ - model : model, - targetCollection : this.collection - }); - - AppLayout.modalRegion.show(view); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/Indexers/Restriction/RestrictionCollectionViewTemplate.hbs b/src/UI/Settings/Indexers/Restriction/RestrictionCollectionViewTemplate.hbs deleted file mode 100644 index 6dc978854..000000000 --- a/src/UI/Settings/Indexers/Restriction/RestrictionCollectionViewTemplate.hbs +++ /dev/null @@ -1,24 +0,0 @@ -<fieldset class="advanced-setting"> - <legend>Restrictions</legend> - - <div class="col-md-12"> - <div class="rule-setting-list"> - <div class="rule-setting-header x-header hidden-xs"> - <div class="row"> - <span class="col-sm-4">Must Contain</span> - <span class="col-sm-4">Must Not Contain</span> - <span class="col-sm-3">Tags</span> - </div> - </div> - <div class="rows x-rows"> - </div> - <div class="rule-setting-footer"> - <div class="pull-right"> - <span class="add-rule-setting-mapping"> - <i class="icon-sonarr-add x-add" title="Add new restriction" /> - </span> - </div> - </div> - </div> - </div> -</fieldset> \ No newline at end of file diff --git a/src/UI/Settings/Indexers/Restriction/RestrictionDeleteView.js b/src/UI/Settings/Indexers/Restriction/RestrictionDeleteView.js deleted file mode 100644 index d2166c5ed..000000000 --- a/src/UI/Settings/Indexers/Restriction/RestrictionDeleteView.js +++ /dev/null @@ -1,19 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'Settings/Indexers/Restriction/RestrictionDeleteViewTemplate', - - events : { - 'click .x-confirm-delete' : '_delete' - }, - - _delete : function() { - this.model.destroy({ - wait : true, - success : function() { - vent.trigger(vent.Commands.CloseModalCommand); - } - }); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/Indexers/Restriction/RestrictionDeleteViewTemplate.hbs b/src/UI/Settings/Indexers/Restriction/RestrictionDeleteViewTemplate.hbs deleted file mode 100644 index 215631e5b..000000000 --- a/src/UI/Settings/Indexers/Restriction/RestrictionDeleteViewTemplate.hbs +++ /dev/null @@ -1,13 +0,0 @@ -<div class="modal-content"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - <h3>Delete Restriction</h3> - </div> - <div class="modal-body"> - <p>Are you sure you want to delete this restriction?</p> - </div> - <div class="modal-footer"> - <button class="btn" data-dismiss="modal">Cancel</button> - <button class="btn btn-danger x-confirm-delete">Delete</button> - </div> -</div> diff --git a/src/UI/Settings/Indexers/Restriction/RestrictionEditView.js b/src/UI/Settings/Indexers/Restriction/RestrictionEditView.js deleted file mode 100644 index e8540d1a5..000000000 --- a/src/UI/Settings/Indexers/Restriction/RestrictionEditView.js +++ /dev/null @@ -1,55 +0,0 @@ -var _ = require('underscore'); -var vent = require('vent'); -var AppLayout = require('../../../AppLayout'); -var Marionette = require('marionette'); -var DeleteView = require('./RestrictionDeleteView'); -var CommandController = require('../../../Commands/CommandController'); -var AsModelBoundView = require('../../../Mixins/AsModelBoundView'); -var AsValidatedView = require('../../../Mixins/AsValidatedView'); -var AsEditModalView = require('../../../Mixins/AsEditModalView'); -require('../../../Mixins/TagInput'); -require('bootstrap'); -require('bootstrap.tagsinput'); - -var view = Marionette.ItemView.extend({ - template : 'Settings/Indexers/Restriction/RestrictionEditViewTemplate', - - ui : { - required : '.x-required', - ignored : '.x-ignored', - tags : '.x-tags' - }, - - _deleteView : DeleteView, - - initialize : function(options) { - this.targetCollection = options.targetCollection; - }, - - onRender : function() { - this.ui.required.tagsinput({ - trimValue : true, - tagClass : 'label label-success' - }); - - this.ui.ignored.tagsinput({ - trimValue : true, - tagClass : 'label label-danger' - }); - - this.ui.tags.tagInput({ - model : this.model, - property : 'tags' - }); - }, - - _onAfterSave : function() { - this.targetCollection.add(this.model, { merge : true }); - vent.trigger(vent.Commands.CloseModalCommand); - } -}); - -AsModelBoundView.call(view); -AsValidatedView.call(view); -AsEditModalView.call(view); -module.exports = view; \ No newline at end of file diff --git a/src/UI/Settings/Indexers/Restriction/RestrictionEditViewTemplate.hbs b/src/UI/Settings/Indexers/Restriction/RestrictionEditViewTemplate.hbs deleted file mode 100644 index e02175c20..000000000 --- a/src/UI/Settings/Indexers/Restriction/RestrictionEditViewTemplate.hbs +++ /dev/null @@ -1,60 +0,0 @@ -<div class="modal-content"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - {{#if id}} - <h3>Edit Restriction</h3> - {{else}} - <h3>Add Restriction</h3> - {{/if}} - </div> - <div class="modal-body remotepath-mapping-modal"> - <div class="form-horizontal"> - <div class="form-group"> - <label class="col-sm-3 control-label">Must contain</label> - - <div class="col-sm-1 col-sm-push-5 help-inline"> - <i class="icon-sonarr-form-info" title="The release must contain at least one of these terms (case insensitive)" /> - </div> - - <div class="col-sm-5 col-sm-pull-1"> - <input type="text" name="required" class="form-control x-required"/> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Must not contain</label> - - <div class="col-sm-1 col-sm-push-5 help-inline"> - <i class="icon-sonarr-form-info" title="The release will be rejected if it contains one or more of terms (case insensitive)" /> - </div> - - <div class="col-sm-5 col-sm-pull-1"> - <input type="text" name="ignored" class="form-control x-ignored"/> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Tags</label> - - <div class="col-sm-1 col-sm-push-5 help-inline"> - <i class="icon-sonarr-form-info" title="Restrictions will apply to series with one or more matching tags. Leave blank to apply to all series" /> - </div> - - <div class="col-sm-5 col-sm-pull-1"> - <input type="text" class="form-control x-tags"> - </div> - </div> - </div> - </div> - <div class="modal-footer"> - {{#if id}} - <button class="btn btn-danger pull-left x-delete">Delete</button> - {{/if}} - - <button class="btn" data-dismiss="modal">Cancel</button> - - <div class="btn-group"> - <button class="btn btn-primary x-save">Save</button> - </div> - </div> -</div> diff --git a/src/UI/Settings/Indexers/Restriction/RestrictionItemView.js b/src/UI/Settings/Indexers/Restriction/RestrictionItemView.js deleted file mode 100644 index 729d8ef7d..000000000 --- a/src/UI/Settings/Indexers/Restriction/RestrictionItemView.js +++ /dev/null @@ -1,28 +0,0 @@ -var AppLayout = require('../../../AppLayout'); -var Marionette = require('marionette'); -var EditView = require('./RestrictionEditView'); - -module.exports = Marionette.ItemView.extend({ - template : 'Settings/Indexers/Restriction/RestrictionItemViewTemplate', - className : 'row', - - ui : { - tags : '.x-tags' - }, - - events : { - 'click .x-edit' : '_edit' - }, - - initialize : function() { - this.listenTo(this.model, 'sync', this.render); - }, - - _edit : function() { - var view = new EditView({ - model : this.model, - targetCollection : this.model.collection - }); - AppLayout.modalRegion.show(view); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/Indexers/Restriction/RestrictionItemViewTemplate.hbs b/src/UI/Settings/Indexers/Restriction/RestrictionItemViewTemplate.hbs deleted file mode 100644 index d7648cb73..000000000 --- a/src/UI/Settings/Indexers/Restriction/RestrictionItemViewTemplate.hbs +++ /dev/null @@ -1,12 +0,0 @@ - <div class="col-sm-4"> - {{genericTagDisplay required 'label label-success'}} - </div> - <div class="col-sm-4"> - {{genericTagDisplay ignored 'label label-danger'}} - </div> - <div class="col-sm-3"> - {{tagDisplay tags}} - </div> - <div class="col-sm-1"> - <div class="pull-right"><i class="icon-sonarr-edit x-edit" title="" data-original-title="Edit"></i></div> - </div> \ No newline at end of file diff --git a/src/UI/Settings/Indexers/Restriction/RestrictionModel.js b/src/UI/Settings/Indexers/Restriction/RestrictionModel.js deleted file mode 100644 index e8ea08465..000000000 --- a/src/UI/Settings/Indexers/Restriction/RestrictionModel.js +++ /dev/null @@ -1,4 +0,0 @@ -var $ = require('jquery'); -var DeepModel = require('backbone.deepmodel'); - -module.exports = DeepModel.extend({}); \ No newline at end of file diff --git a/src/UI/Settings/Indexers/indexers.less b/src/UI/Settings/Indexers/indexers.less deleted file mode 100644 index 3fed3ef5f..000000000 --- a/src/UI/Settings/Indexers/indexers.less +++ /dev/null @@ -1,33 +0,0 @@ -@import "../../Shared/Styles/clickable.less"; - -.indexer-list { - li { - display: inline-block; - vertical-align: top; - } -} - -.indexer-item { - - .clickable; - - width: 290px; - height: 90px; - padding: 10px 15px; - - &.add-card { - .center { - margin-top: -3px; - } - } -} - -.modal-overflow { - overflow-y: visible; -} - -.add-indexer { - li.add-thingy-item { - width: 33%; - } -} \ No newline at end of file diff --git a/src/UI/Settings/MediaManagement/FileManagement/FileManagementView.js b/src/UI/Settings/MediaManagement/FileManagement/FileManagementView.js deleted file mode 100644 index 49c2cad37..000000000 --- a/src/UI/Settings/MediaManagement/FileManagement/FileManagementView.js +++ /dev/null @@ -1,23 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); -var AsModelBoundView = require('../../../Mixins/AsModelBoundView'); -var AsValidatedView = require('../../../Mixins/AsValidatedView'); -require('../../../Mixins/DirectoryAutoComplete'); -require('../../../Mixins/FileBrowser'); - -var view = Marionette.ItemView.extend({ - template : 'Settings/MediaManagement/FileManagement/FileManagementViewTemplate', - - ui : { - recyclingBin : '.x-path' - }, - - onShow : function() { - this.ui.recyclingBin.fileBrowser(); - } -}); - -AsModelBoundView.call(view); -AsValidatedView.call(view); - -module.exports = view; \ No newline at end of file diff --git a/src/UI/Settings/MediaManagement/FileManagement/FileManagementViewTemplate.hbs b/src/UI/Settings/MediaManagement/FileManagement/FileManagementViewTemplate.hbs deleted file mode 100644 index 2a3dd5d51..000000000 --- a/src/UI/Settings/MediaManagement/FileManagement/FileManagementViewTemplate.hbs +++ /dev/null @@ -1,98 +0,0 @@ -<fieldset> - <legend>File Management</legend> - - <div class="form-group"> - <label class="col-sm-3 control-label">Ignore Deleted Episodes</label> - - <div class="col-sm-9"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="autoUnmonitorPreviouslyDownloadedEpisodes"/> - <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="Episodes deleted from disk are automatically unmonitored in Sonarr"/> - </span> - </div> - </div> - </div> - - <div class="form-group advanced-setting"> - <label class="col-sm-3 control-label">Download Propers</label> - - <div class="col-sm-9"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="autoDownloadPropers"/> - <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="Should Sonarr automatically upgrade to propers when available?"/> - </span> - </div> - </div> - </div> - - <div class="form-group advanced-setting"> - <label class="col-sm-3 control-label">Analyse video files</label> - - <div class="col-sm-9"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="enableMediaInfo"/> - <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="Extract video information such as resolution, runtime and codec information from files. This requires Sonarr to read parts of the file which may cause high disk or network activity during scans."/> - </span> - </div> - </div> - </div> - - <div class="form-group advanced-setting"> - <label class="col-sm-3 control-label">Change File Date</label> - - <div class="col-sm-1 col-sm-push-2 help-inline"> - <i class="icon-sonarr-form-info" title="Change file date on import/rescan"/> - </div> - - <div class="col-sm-2 col-sm-pull-1"> - <select class="form-control" name="fileDate"> - <option value="none">None</option> - <option value="localAirDate">Local Air Date</option> - <option value="utcAirDate">UTC Air Date</option> - </select> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Recycling Bin</label> - - <div class="col-sm-1 col-sm-push-8 help-inline"> - <i class="icon-sonarr-form-info" title="Episode files will go here when deleted instead of being permanently deleted"/> - </div> - - <div class="col-sm-8 col-sm-pull-1"> - <input type="text" name="recycleBin" class="form-control x-path"/> - </div> - - </div> -</fieldset> diff --git a/src/UI/Settings/MediaManagement/MediaManagementLayout.js b/src/UI/Settings/MediaManagement/MediaManagementLayout.js deleted file mode 100644 index da6ea2954..000000000 --- a/src/UI/Settings/MediaManagement/MediaManagementLayout.js +++ /dev/null @@ -1,28 +0,0 @@ -var Marionette = require('marionette'); -var NamingView = require('./Naming/NamingView'); -var SortingView = require('./Sorting/SortingView'); -var FileManagementView = require('./FileManagement/FileManagementView'); -var PermissionsView = require('./Permissions/PermissionsView'); - -module.exports = Marionette.Layout.extend({ - template : 'Settings/MediaManagement/MediaManagementLayoutTemplate', - - regions : { - episodeNaming : '#episode-naming', - sorting : '#sorting', - fileManagement : '#file-management', - permissions : '#permissions' - }, - - initialize : function(options) { - this.settings = options.settings; - this.namingSettings = options.namingSettings; - }, - - onShow : function() { - this.episodeNaming.show(new NamingView({ model : this.namingSettings })); - this.sorting.show(new SortingView({ model : this.settings })); - this.fileManagement.show(new FileManagementView({ model : this.settings })); - this.permissions.show(new PermissionsView({ model : this.settings })); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/MediaManagement/MediaManagementLayoutTemplate.hbs b/src/UI/Settings/MediaManagement/MediaManagementLayoutTemplate.hbs deleted file mode 100644 index 44fb14ac3..000000000 --- a/src/UI/Settings/MediaManagement/MediaManagementLayoutTemplate.hbs +++ /dev/null @@ -1,6 +0,0 @@ -<div class="form-horizontal"> - <div id="episode-naming"></div> - <div id="sorting"></div> - <div id="file-management"></div> - {{#if_mono}}<div id="permissions"></div>{{/if_mono}} -</div> \ No newline at end of file diff --git a/src/UI/Settings/MediaManagement/MediaManagementSettingsModel.js b/src/UI/Settings/MediaManagement/MediaManagementSettingsModel.js deleted file mode 100644 index f80d74800..000000000 --- a/src/UI/Settings/MediaManagement/MediaManagementSettingsModel.js +++ /dev/null @@ -1,7 +0,0 @@ -var SettingsModelBase = require('../SettingsModelBase'); - -module.exports = SettingsModelBase.extend({ - url : window.NzbDrone.ApiRoot + '/config/mediamanagement', - successMessage : 'Media management settings saved', - errorMessage : 'Failed to save media management settings' -}); \ No newline at end of file diff --git a/src/UI/Settings/MediaManagement/Naming/Basic/BasicNamingModel.js b/src/UI/Settings/MediaManagement/Naming/Basic/BasicNamingModel.js deleted file mode 100644 index 3986a5948..000000000 --- a/src/UI/Settings/MediaManagement/Naming/Basic/BasicNamingModel.js +++ /dev/null @@ -1,3 +0,0 @@ -var Backbone = require('backbone'); - -module.exports = Backbone.Model.extend({}); \ No newline at end of file diff --git a/src/UI/Settings/MediaManagement/Naming/Basic/BasicNamingView.js b/src/UI/Settings/MediaManagement/Naming/Basic/BasicNamingView.js deleted file mode 100644 index 916a15aed..000000000 --- a/src/UI/Settings/MediaManagement/Naming/Basic/BasicNamingView.js +++ /dev/null @@ -1,118 +0,0 @@ -var _ = require('underscore'); -var Marionette = require('marionette'); -var Config = require('../../../../Config'); -var NamingSampleModel = require('../NamingSampleModel'); -var BasicNamingModel = require('./BasicNamingModel'); -var AsModelBoundView = require('../../../../Mixins/AsModelBoundView'); - -var view = Marionette.ItemView.extend({ - template : 'Settings/MediaManagement/Naming/Basic/BasicNamingViewTemplate', - - ui : { - namingOptions : '.x-naming-options', - singleEpisodeExample : '.x-single-episode-example', - multiEpisodeExample : '.x-multi-episode-example', - dailyEpisodeExample : '.x-daily-episode-example' - }, - - initialize : function(options) { - this.namingModel = options.model; - this.model = new BasicNamingModel(); - - this._parseNamingModel(); - - this.listenTo(this.model, 'change', this._buildFormat); - this.listenTo(this.namingModel, 'sync', this._parseNamingModel); - }, - - _parseNamingModel : function() { - var standardFormat = this.namingModel.get('standardEpisodeFormat'); - - var includeSeriesTitle = standardFormat.match(/\{Series[-_. ]Title\}/i); - var includeEpisodeTitle = standardFormat.match(/\{Episode[-_. ]Title\}/i); - var includeQuality = standardFormat.match(/\{Quality[-_. ]Title\}/i); - var numberStyle = standardFormat.match(/s?\{season(?:\:0+)?\}[ex]\{episode(?:\:0+)?\}/i); - var replaceSpaces = standardFormat.indexOf(' ') === -1; - var separator = standardFormat.match(/\}( - |\.-\.|\.| )|( - |\.-\.|\.| )\{/i); - - if (separator === null || separator[1] === '.-.') { - separator = ' - '; - } else { - separator = separator[1]; - } - - if (numberStyle === null) { - numberStyle = 'S{season:00}E{episode:00}'; - } else { - numberStyle = numberStyle[0]; - } - - this.model.set({ - includeSeriesTitle : includeSeriesTitle !== null, - includeEpisodeTitle : includeEpisodeTitle !== null, - includeQuality : includeQuality !== null, - numberStyle : numberStyle, - replaceSpaces : replaceSpaces, - separator : separator - }, { silent : true }); - }, - - _buildFormat : function() { - if (Config.getValueBoolean(Config.Keys.AdvancedSettings)) { - return; - } - - var standardEpisodeFormat = ''; - var dailyEpisodeFormat = ''; - - if (this.model.get('includeSeriesTitle')) { - if (this.model.get('replaceSpaces')) { - standardEpisodeFormat += '{Series.Title}'; - dailyEpisodeFormat += '{Series.Title}'; - } else { - standardEpisodeFormat += '{Series Title}'; - dailyEpisodeFormat += '{Series Title}'; - } - - standardEpisodeFormat += this.model.get('separator'); - dailyEpisodeFormat += this.model.get('separator'); - } - - standardEpisodeFormat += this.model.get('numberStyle'); - dailyEpisodeFormat += '{Air-Date}'; - - if (this.model.get('includeEpisodeTitle')) { - standardEpisodeFormat += this.model.get('separator'); - dailyEpisodeFormat += this.model.get('separator'); - - if (this.model.get('replaceSpaces')) { - standardEpisodeFormat += '{Episode.Title}'; - dailyEpisodeFormat += '{Episode.Title}'; - } else { - standardEpisodeFormat += '{Episode Title}'; - dailyEpisodeFormat += '{Episode Title}'; - } - } - - if (this.model.get('includeQuality')) { - if (this.model.get('replaceSpaces')) { - standardEpisodeFormat += ' {Quality.Title}'; - dailyEpisodeFormat += ' {Quality.Title}'; - } else { - standardEpisodeFormat += ' {Quality Title}'; - dailyEpisodeFormat += ' {Quality Title}'; - } - } - - if (this.model.get('replaceSpaces')) { - standardEpisodeFormat = standardEpisodeFormat.replace(/\s/g, '.'); - dailyEpisodeFormat = dailyEpisodeFormat.replace(/\s/g, '.'); - } - - this.namingModel.set('standardEpisodeFormat', standardEpisodeFormat); - this.namingModel.set('dailyEpisodeFormat', dailyEpisodeFormat); - this.namingModel.set('animeEpisodeFormat', standardEpisodeFormat); - } -}); - -module.exports = AsModelBoundView.call(view); \ No newline at end of file diff --git a/src/UI/Settings/MediaManagement/Naming/Basic/BasicNamingViewTemplate.hbs b/src/UI/Settings/MediaManagement/Naming/Basic/BasicNamingViewTemplate.hbs deleted file mode 100644 index 06429a722..000000000 --- a/src/UI/Settings/MediaManagement/Naming/Basic/BasicNamingViewTemplate.hbs +++ /dev/null @@ -1,102 +0,0 @@ -<div class="form-group"> - <label class="col-sm-3 control-label">Include Series Title</label> - - <div class="col-sm-9"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="includeSeriesTitle"/> - - <p> - <span>Yes</span> - <span>No</span> - </p> - - <div class="btn btn-primary slide-button"/> - </label> - </div> - </div> -</div> - - - -<div class="form-group"> - <label class="col-sm-3 control-label">Include Episode Title</label> - - <div class="col-sm-9"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="includeEpisodeTitle"/> - - <p> - <span>Yes</span> - <span>No</span> - </p> - - <div class="btn btn-primary slide-button"/> - </label> - </div> - </div> -</div> - -<div class="form-group"> - <label class="col-sm-3 control-label">Include Quality</label> - - <div class="col-sm-9"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="includeQuality"/> - - <p> - <span>Yes</span> - <span>No</span> - </p> - - <div class="btn btn-primary slide-button"/> - </label> - </div> - </div> -</div> - -<div class="form-group"> - <label class="col-sm-3 control-label">Replace Spaces</label> - - <div class="col-sm-9"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="replaceSpaces"/> - - <p> - <span>Yes</span> - <span>No</span> - </p> - - <div class="btn btn-primary slide-button"/> - </label> - </div> - </div> -</div> - -<div class="form-group"> - <label class="col-sm-3 control-label">Separator</label> - - <div class="col-sm-9"> - <select class="form-control" name="separator"> - <option value=" - ">Dash</option> - <option value=" ">Space</option> - <option value=".">Period</option> - </select> - </div> -</div> - -<div class="form-group"> - <label class="col-sm-3 control-label">Numbering Style</label> - - <div class="col-sm-9"> - <select class="form-control" name="numberStyle"> - <option value="{season}x{episode:00}">1x05</option> - <option value="{season:00}x{episode:00}">01x05</option> - <option value="S{season:00}E{episode:00}">S01E05</option> - <option value="s{season:00}e{episode:00}">s01e05</option> - </select> - </div> -</div> diff --git a/src/UI/Settings/MediaManagement/Naming/NamingModel.js b/src/UI/Settings/MediaManagement/Naming/NamingModel.js deleted file mode 100644 index 5ff713850..000000000 --- a/src/UI/Settings/MediaManagement/Naming/NamingModel.js +++ /dev/null @@ -1,7 +0,0 @@ -var ModelBase = require('../../SettingsModelBase'); - -module.exports = ModelBase.extend({ - url : window.NzbDrone.ApiRoot + '/config/naming', - successMessage : 'MediaManagement settings saved', - errorMessage : 'Couldn\'t save naming settings' -}); \ No newline at end of file diff --git a/src/UI/Settings/MediaManagement/Naming/NamingSampleModel.js b/src/UI/Settings/MediaManagement/Naming/NamingSampleModel.js deleted file mode 100644 index 375d74a6f..000000000 --- a/src/UI/Settings/MediaManagement/Naming/NamingSampleModel.js +++ /dev/null @@ -1,3 +0,0 @@ -var Backbone = require('backbone'); - -module.exports = Backbone.Model.extend({ url : window.NzbDrone.ApiRoot + '/config/naming/samples' }); \ No newline at end of file diff --git a/src/UI/Settings/MediaManagement/Naming/NamingView.js b/src/UI/Settings/MediaManagement/Naming/NamingView.js deleted file mode 100644 index 71e4df4f8..000000000 --- a/src/UI/Settings/MediaManagement/Naming/NamingView.js +++ /dev/null @@ -1,85 +0,0 @@ -var _ = require('underscore'); -var Marionette = require('marionette'); -var NamingSampleModel = require('./NamingSampleModel'); -var BasicNamingView = require('./Basic/BasicNamingView'); -var AsModelBoundView = require('../../../Mixins/AsModelBoundView'); -var AsValidatedView = require('../../../Mixins/AsValidatedView'); - -module.exports = (function() { - var view = Marionette.Layout.extend({ - template : 'Settings/MediaManagement/Naming/NamingViewTemplate', - ui : { - namingOptions : '.x-naming-options', - renameEpisodesCheckbox : '.x-rename-episodes', - singleEpisodeExample : '.x-single-episode-example', - multiEpisodeExample : '.x-multi-episode-example', - dailyEpisodeExample : '.x-daily-episode-example', - animeEpisodeExample : '.x-anime-episode-example', - animeMultiEpisodeExample : '.x-anime-multi-episode-example', - namingTokenHelper : '.x-naming-token-helper', - multiEpisodeStyle : '.x-multi-episode-style', - seriesFolderExample : '.x-series-folder-example', - seasonFolderExample : '.x-season-folder-example' - }, - events : { - "change .x-rename-episodes" : '_setFailedDownloadOptionsVisibility', - "click .x-show-wizard" : '_showWizard', - "click .x-naming-token-helper a" : '_addToken', - "change .x-multi-episode-style" : '_multiEpisodeFomatChanged' - }, - regions : { basicNamingRegion : '.x-basic-naming' }, - onRender : function() { - if (!this.model.get('renameEpisodes')) { - this.ui.namingOptions.hide(); - } - var basicNamingView = new BasicNamingView({ model : this.model }); - this.basicNamingRegion.show(basicNamingView); - this.namingSampleModel = new NamingSampleModel(); - this.listenTo(this.model, 'change', this._updateSamples); - this.listenTo(this.namingSampleModel, 'sync', this._showSamples); - this._updateSamples(); - }, - _setFailedDownloadOptionsVisibility : function() { - var checked = this.ui.renameEpisodesCheckbox.prop('checked'); - if (checked) { - this.ui.namingOptions.slideDown(); - } else { - this.ui.namingOptions.slideUp(); - } - }, - _updateSamples : function() { - this.namingSampleModel.fetch({ data : this.model.toJSON() }); - }, - _showSamples : function() { - this.ui.singleEpisodeExample.html(this.namingSampleModel.get('singleEpisodeExample')); - this.ui.multiEpisodeExample.html(this.namingSampleModel.get('multiEpisodeExample')); - this.ui.dailyEpisodeExample.html(this.namingSampleModel.get('dailyEpisodeExample')); - this.ui.animeEpisodeExample.html(this.namingSampleModel.get('animeEpisodeExample')); - this.ui.animeMultiEpisodeExample.html(this.namingSampleModel.get('animeMultiEpisodeExample')); - this.ui.seriesFolderExample.html(this.namingSampleModel.get('seriesFolderExample')); - this.ui.seasonFolderExample.html(this.namingSampleModel.get('seasonFolderExample')); - }, - _addToken : function(e) { - e.preventDefault(); - e.stopPropagation(); - var target = e.target; - var token = ''; - var input = this.$(target).closest('.x-helper-input').children('input'); - if (this.$(target).attr('data-token')) { - token = '{{0}}'.format(this.$(target).attr('data-token')); - } else { - token = this.$(target).attr('data-separator'); - } - input.val(input.val() + token); - input.change(); - this.ui.namingTokenHelper.removeClass('open'); - input.focus(); - }, - multiEpisodeFormatChanged : function() { - this.model.set('multiEpisodeStyle', this.ui.multiEpisodeStyle.val()); - } - }); - AsModelBoundView.call(view); - AsValidatedView.call(view); - return view; -}).call(this); \ No newline at end of file diff --git a/src/UI/Settings/MediaManagement/Naming/NamingViewTemplate.hbs b/src/UI/Settings/MediaManagement/Naming/NamingViewTemplate.hbs deleted file mode 100644 index 361954d70..000000000 --- a/src/UI/Settings/MediaManagement/Naming/NamingViewTemplate.hbs +++ /dev/null @@ -1,262 +0,0 @@ -<fieldset> - <legend>Episode Naming</legend> - - <div class="form-group"> - <label class="col-sm-3 control-label">Rename Episodes</label> - - <div class="col-sm-8"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="renameEpisodes" class="x-rename-episodes"/> - - <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-warning" title="Sonarr will use the existing file name if set to no"/> - </span> - </div> - </div> - </div> - - <div class="form-group advanced-setting"> - <label class="col-sm-3 control-label">Replace Illegal Characters</label> - - <div class="col-sm-8"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="replaceIllegalCharacters" /> - - <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="Replace or Remove illegal characters"/> - </span> - </div> - </div> - </div> - - <div class="x-naming-options"> - <div class="basic-setting x-basic-naming"></div> - - <div class="form-group advanced-setting"> - <label class="col-sm-3 control-label">Standard Episode Format</label> - - <div class="col-sm-1 col-sm-push-8 help-inline"> - <i class="icon-sonarr-form-info" title="" data-original-title="All caps or all lower-case can also be used"></i> - <a href="https://github.com/NzbDrone/NzbDrone/wiki/Sorting-and-Renaming" class="help-link" title="More information"><i class="icon-sonarr-form-info-link"/></a> - </div> - - <div class="col-sm-8 col-sm-pull-1"> - <div class="input-group x-helper-input"> - <input type="text" class="form-control naming-format" name="standardEpisodeFormat" data-onkeyup="true" /> - <div class="input-group-btn btn-group x-naming-token-helper"> - <button class="btn btn-icon-only dropdown-toggle" data-toggle="dropdown"> - <i class="icon-sonarr-add"></i> - </button> - <ul class="dropdown-menu"> - {{> SeriesTitleNamingPartial}} - {{> SeasonNamingPartial}} - {{> EpisodeNamingPartial}} - {{> EpisodeTitleNamingPartial}} - {{> QualityNamingPartial}} - {{> MediaInfoNamingPartial}} - {{> ReleaseGroupNamingPartial}} - {{> OriginalTitleNamingPartial}} - {{> SeparatorNamingPartial}} - </ul> - </div> - </div> - </div> - </div> - - <div class="form-group advanced-setting"> - <label class="col-sm-3 control-label">Daily Episode Format</label> - - <div class="col-sm-1 col-sm-push-8 help-inline"> - <i class="icon-sonarr-form-info" title="" data-original-title="All caps or all lower-case can also be used"></i> - <a href="https://github.com/NzbDrone/NzbDrone/wiki/Sorting-and-Renaming" class="help-link" title="More information"><i class="icon-sonarr-form-info-link"/></a> - </div> - - <div class="col-sm-8 col-sm-pull-1"> - <div class="input-group x-helper-input"> - <input type="text" class="form-control naming-format" name="dailyEpisodeFormat" data-onkeyup="true" /> - <div class="input-group-btn btn-group x-naming-token-helper"> - <button class="btn btn-icon-only dropdown-toggle" data-toggle="dropdown"> - <i class="icon-sonarr-add"></i> - </button> - <ul class="dropdown-menu"> - {{> SeriesTitleNamingPartial}} - {{> AirDateNamingPartial}} - {{> SeasonNamingPartial}} - {{> EpisodeNamingPartial}} - {{> EpisodeTitleNamingPartial}} - {{> QualityNamingPartial}} - {{> MediaInfoNamingPartial}} - {{> ReleaseGroupNamingPartial}} - {{> OriginalTitleNamingPartial}} - {{> SeparatorNamingPartial}} - </ul> - </div> - </div> - </div> - </div> - - <div class="form-group advanced-setting"> - <label class="col-sm-3 control-label">Anime Episode Format</label> - - <div class="col-sm-1 col-sm-push-8 help-inline"> - <i class="icon-sonarr-form-info" title="" data-original-title="All caps or all lower-case can also be used"></i> - <a href="https://github.com/NzbDrone/NzbDrone/wiki/Sorting-and-Renaming" class="help-link" title="More information"><i class="icon-sonarr-form-info-link"/></a> - </div> - - <div class="col-sm-8 col-sm-pull-1"> - <div class="input-group x-helper-input"> - <input type="text" class="form-control naming-format" name="animeEpisodeFormat" data-onkeyup="true" /> - <div class="input-group-btn btn-group x-naming-token-helper"> - <button class="btn btn-icon-only dropdown-toggle" data-toggle="dropdown"> - <i class="icon-sonarr-add"></i> - </button> - <ul class="dropdown-menu"> - {{> SeriesTitleNamingPartial}} - {{> AbsoluteEpisodeNamingPartial}} - {{> SeasonNamingPartial}} - {{> EpisodeNamingPartial}} - {{> EpisodeTitleNamingPartial}} - {{> QualityNamingPartial}} - {{> MediaInfoNamingPartial}} - {{> ReleaseGroupNamingPartial}} - {{> OriginalTitleNamingPartial}} - {{> SeparatorNamingPartial}} - </ul> - </div> - </div> - </div> - </div> - </div> - - <div class="form-group advanced-setting"> - <label class="col-sm-3 control-label">Series Folder Format</label> - - <div class="col-sm-1 col-sm-push-8 help-inline"> - <i class="icon-sonarr-form-info" title="" data-original-title="All caps or all lower-case can also be used. Only used when adding a new series."></i> - </div> - - <div class="col-sm-8 col-sm-pull-1"> - <div class="input-group x-helper-input"> - <input type="text" class="form-control naming-format" name="seriesFolderFormat" data-onkeyup="true"/> - <div class="input-group-btn btn-group x-naming-token-helper"> - <button class="btn btn-icon-only dropdown-toggle" data-toggle="dropdown"> - <i class="icon-sonarr-add"></i> - </button> - <ul class="dropdown-menu"> - {{> SeriesTitleNamingPartial}} - </ul> - </div> - </div> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Season Folder Format</label> - - <div class="col-sm-8"> - <div class="input-group x-helper-input"> - <input type="text" class="form-control naming-format" name="seasonFolderFormat" data-onkeyup="true"/> - <div class="input-group-btn btn-group x-naming-token-helper"> - <button class="btn btn-icon-only dropdown-toggle" data-toggle="dropdown"> - <i class="icon-sonarr-add"></i> - </button> - <ul class="dropdown-menu"> - {{> SeriesTitleNamingPartial}} - {{> SeasonNamingPartial}} - {{> SeparatorNamingPartial}} - </ul> - </div> - </div> - </div> - </div> - - <div class="x-naming-options"> - <div class="form-group"> - <label class="col-sm-3 control-label">Multi-Episode Style</label> - - <div class="col-sm-2"> - <select class="form-control x-multi-episode-style" name="multiEpisodeStyle"> - <option value="0">Extend</option> - <option value="1">Duplicate</option> - <option value="2">Repeat</option> - <option value="3">Scene</option> - <option value="4">Range</option> - <option value="5">Prefixed Range</option> - </select> - </div> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Single Episode Example</label> - - <div class="col-sm-8"> - <p class="form-control-static x-single-episode-example naming-example"></p> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Multi-Episode Example</label> - - <div class="col-sm-8"> - <p class="form-control-static x-multi-episode-example naming-example"></p> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Daily-Episode Example</label> - - <div class="col-sm-8"> - <p class="form-control-static x-daily-episode-example naming-example"></p> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Anime Episode Example</label> - <div class="col-sm-8"> - <p class="form-control-static x-anime-episode-example naming-example"></p> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Anime Multi-Episode Example</label> - - <div class="col-sm-8"> - <p class="form-control-static x-anime-multi-episode-example naming-example"></p> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Series Folder Example</label> - - <div class="col-sm-8"> - <p class="form-control-static x-series-folder-example naming-example"></p> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Season Folder Example</label> - - <div class="col-sm-8"> - <p class="form-control-static x-season-folder-example naming-example"></p> - </div> - </div> -</fieldset> diff --git a/src/UI/Settings/MediaManagement/Naming/Partials/AbsoluteEpisodeNamingPartial.hbs b/src/UI/Settings/MediaManagement/Naming/Partials/AbsoluteEpisodeNamingPartial.hbs deleted file mode 100644 index ba31a196e..000000000 --- a/src/UI/Settings/MediaManagement/Naming/Partials/AbsoluteEpisodeNamingPartial.hbs +++ /dev/null @@ -1,8 +0,0 @@ -<li class="dropdown-submenu"> - <a href="#" tabindex="-1" data-token="absolute">Absolute</a> - <ul class="dropdown-menu"> - <li><a href="#" data-token="absolute">1</a></li> - <li><a href="#" data-token="absolute:00">01</a></li> - <li><a href="#" data-token="absolute:000">001</a></li> - </ul> -</li> diff --git a/src/UI/Settings/MediaManagement/Naming/Partials/AirDateNamingPartial.hbs b/src/UI/Settings/MediaManagement/Naming/Partials/AirDateNamingPartial.hbs deleted file mode 100644 index ed845e2c0..000000000 --- a/src/UI/Settings/MediaManagement/Naming/Partials/AirDateNamingPartial.hbs +++ /dev/null @@ -1,9 +0,0 @@ -<li class="dropdown-submenu"> - <a href="#" tabindex="-1" data-token="Air-Date">Air-Date</a> - <ul class="dropdown-menu"> - <li><a href="#" data-token="Air-Date">Air-Date</a></li> - <li><a href="#" data-token="Air Date">Air Date</a></li> - <li><a href="#" data-token="Air.Date">Air.Date</a></li> - <li><a href="#" data-token="Air_Date">Air_Date</a></li> - </ul> -</li> diff --git a/src/UI/Settings/MediaManagement/Naming/Partials/EpisodeNamingPartial.hbs b/src/UI/Settings/MediaManagement/Naming/Partials/EpisodeNamingPartial.hbs deleted file mode 100644 index 4c20f4ffa..000000000 --- a/src/UI/Settings/MediaManagement/Naming/Partials/EpisodeNamingPartial.hbs +++ /dev/null @@ -1,7 +0,0 @@ -<li class="dropdown-submenu"> - <a href="#" tabindex="-1" data-token="episode">Episode</a> - <ul class="dropdown-menu"> - <li><a href="#" data-token="episode">1</a></li> - <li><a href="#" data-token="episode:00">01</a></li> - </ul> -</li> diff --git a/src/UI/Settings/MediaManagement/Naming/Partials/EpisodeTitleNamingPartial.hbs b/src/UI/Settings/MediaManagement/Naming/Partials/EpisodeTitleNamingPartial.hbs deleted file mode 100644 index 10f2ec67e..000000000 --- a/src/UI/Settings/MediaManagement/Naming/Partials/EpisodeTitleNamingPartial.hbs +++ /dev/null @@ -1,11 +0,0 @@ -<li class="dropdown-submenu"> - <a href="#" tabindex="-1" data-token="Episode Title">Episode Title</a> - <ul class="dropdown-menu"> - <li><a href="#" data-token="Episode Title">Episode Title</a></li> - <li><a href="#" data-token="Episode.Title">Episode.Title</a></li> - <li><a href="#" data-token="Episode_Title">Episode_Title</a></li> - <li><a href="#" data-token="Episode CleanTitle">Episode CleanTitle</a></li> - <li><a href="#" data-token="Episode.CleanTitle">Episode.CleanTitle</a></li> - <li><a href="#" data-token="Episode_CleanTitle">Episode_CleanTitle</a></li> - </ul> -</li> diff --git a/src/UI/Settings/MediaManagement/Naming/Partials/MediaInfoNamingPartial.hbs b/src/UI/Settings/MediaManagement/Naming/Partials/MediaInfoNamingPartial.hbs deleted file mode 100644 index 49203cafc..000000000 --- a/src/UI/Settings/MediaManagement/Naming/Partials/MediaInfoNamingPartial.hbs +++ /dev/null @@ -1,11 +0,0 @@ -<li class="dropdown-submenu"> - <a href="#" tabindex="-1" data-token="MediaInfo.Simple">MediaInfo</a> - <ul class="dropdown-menu"> - <li><a href="#" data-token="MediaInfo Simple">MediaInfo Simple</a></li> - <li><a href="#" data-token="MediaInfo.Simple">MediaInfo.Simple</a></li> - <li><a href="#" data-token="MediaInfo_Simple">MediaInfo_Simple</a></li> - <li><a href="#" data-token="MediaInfo Full">MediaInfo Full</a></li> - <li><a href="#" data-token="MediaInfo.Full">MediaInfo.Full</a></li> - <li><a href="#" data-token="MediaInfo_Full">MediaInfo_Full</a></li> - </ul> -</li> diff --git a/src/UI/Settings/MediaManagement/Naming/Partials/OriginalTitleNamingPartial.hbs b/src/UI/Settings/MediaManagement/Naming/Partials/OriginalTitleNamingPartial.hbs deleted file mode 100644 index cef96b894..000000000 --- a/src/UI/Settings/MediaManagement/Naming/Partials/OriginalTitleNamingPartial.hbs +++ /dev/null @@ -1 +0,0 @@ -<li><a href="#" data-token="Original Title">Original Title</a></li> \ No newline at end of file diff --git a/src/UI/Settings/MediaManagement/Naming/Partials/QualityNamingPartial.hbs b/src/UI/Settings/MediaManagement/Naming/Partials/QualityNamingPartial.hbs deleted file mode 100644 index b3da5f0af..000000000 --- a/src/UI/Settings/MediaManagement/Naming/Partials/QualityNamingPartial.hbs +++ /dev/null @@ -1,11 +0,0 @@ -<li class="dropdown-submenu"> - <a href="#" tabindex="-1" data-token="Quality Full">Quality</a> - <ul class="dropdown-menu"> - <li><a href="#" data-token="Quality Full">Quality Full</a></li> - <li><a href="#" data-token="Quality.Full">Quality.Full</a></li> - <li><a href="#" data-token="Quality_Full">Quality_Full</a></li> - <li><a href="#" data-token="Quality Title">Quality Title</a></li> - <li><a href="#" data-token="Quality.Title">Quality.Title</a></li> - <li><a href="#" data-token="Quality_Title">Quality_Title</a></li> - </ul> -</li> diff --git a/src/UI/Settings/MediaManagement/Naming/Partials/ReleaseGroupNamingPartial.hbs b/src/UI/Settings/MediaManagement/Naming/Partials/ReleaseGroupNamingPartial.hbs deleted file mode 100644 index bf9ea50a4..000000000 --- a/src/UI/Settings/MediaManagement/Naming/Partials/ReleaseGroupNamingPartial.hbs +++ /dev/null @@ -1,8 +0,0 @@ -<li class="dropdown-submenu"> - <a href="#" tabindex="-1" data-token="Release Group">Release Group</a> - <ul class="dropdown-menu"> - <li><a href="#" data-token="Release Group">Release Group</a></li> - <li><a href="#" data-token="Release.Group">Release.Group</a></li> - <li><a href="#" data-token="Release_Group">Release_Group</a></li> - </ul> -</li> diff --git a/src/UI/Settings/MediaManagement/Naming/Partials/SeasonNamingPartial.hbs b/src/UI/Settings/MediaManagement/Naming/Partials/SeasonNamingPartial.hbs deleted file mode 100644 index 2c56024da..000000000 --- a/src/UI/Settings/MediaManagement/Naming/Partials/SeasonNamingPartial.hbs +++ /dev/null @@ -1,7 +0,0 @@ -<li class="dropdown-submenu"> - <a href="#" tabindex="-1" data-token="season">Season</a> - <ul class="dropdown-menu"> - <li><a href="#" data-token="season">1</a></li> - <li><a href="#" data-token="season:00">01</a></li> - </ul> -</li> diff --git a/src/UI/Settings/MediaManagement/Naming/Partials/SeparatorNamingPartial.hbs b/src/UI/Settings/MediaManagement/Naming/Partials/SeparatorNamingPartial.hbs deleted file mode 100644 index 2b19d32b5..000000000 --- a/src/UI/Settings/MediaManagement/Naming/Partials/SeparatorNamingPartial.hbs +++ /dev/null @@ -1,10 +0,0 @@ -<li class="dropdown-submenu"> - <a href="#" tabindex="-1" data-separator=" - ">Separator</a> - <ul class="dropdown-menu"> - <li><a href="#" data-separator=" - ">Space-Dash-Space</a></li> - <li><a href="#" data-separator="-">Dash</a></li> - <li><a href="#" data-separator=" ">Space</a></li> - <li><a href="#" data-separator=".">Period</a></li> - <li><a href="#" data-separator="_">Underscore</a></li> - </ul> -</li> diff --git a/src/UI/Settings/MediaManagement/Naming/Partials/SeriesTitleNamingPartial.hbs b/src/UI/Settings/MediaManagement/Naming/Partials/SeriesTitleNamingPartial.hbs deleted file mode 100644 index cc76c95b5..000000000 --- a/src/UI/Settings/MediaManagement/Naming/Partials/SeriesTitleNamingPartial.hbs +++ /dev/null @@ -1,11 +0,0 @@ -<li class="dropdown-submenu"> - <a href="#" tabindex="-1" data-token="Series Title">Series Title</a> - <ul class="dropdown-menu"> - <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 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> - </ul> -</li> diff --git a/src/UI/Settings/MediaManagement/Permissions/PermissionsView.js b/src/UI/Settings/MediaManagement/Permissions/PermissionsView.js deleted file mode 100644 index 6bf74221b..000000000 --- a/src/UI/Settings/MediaManagement/Permissions/PermissionsView.js +++ /dev/null @@ -1,11 +0,0 @@ -var Marionette = require('marionette'); -var AsModelBoundView = require('../../../Mixins/AsModelBoundView'); -var AsValidatedView = require('../../../Mixins/AsValidatedView'); - -var view = Marionette.ItemView.extend({ - template : 'Settings/MediaManagement/Permissions/PermissionsViewTemplate' -}); -AsModelBoundView.call(view); -AsValidatedView.call(view); - -module.exports = view; \ No newline at end of file diff --git a/src/UI/Settings/MediaManagement/Permissions/PermissionsViewTemplate.hbs b/src/UI/Settings/MediaManagement/Permissions/PermissionsViewTemplate.hbs deleted file mode 100644 index 2d870c1ae..000000000 --- a/src/UI/Settings/MediaManagement/Permissions/PermissionsViewTemplate.hbs +++ /dev/null @@ -1,74 +0,0 @@ -<fieldset class="advanced-setting"> - <legend>Permissions</legend> - - <div class="form-group"> - <label class="col-sm-3 control-label">Set Permissions</label> - - <div class="col-sm-8"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="setPermissionsLinux"/> - <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="Should chmod/chown be run when files are imported/renamed?"/> - <i class="icon-sonarr-form-warning" title="If you're unsure what these settings do, do not alter them."/> - </span> - </div> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">File chmod mask</label> - - <div class="col-sm-1 col-sm-push-4 help-inline"> - <i class="icon-sonarr-form-info" title="Octal, applied to media files when imported/renamed by Sonarr"/> - </div> - - <div class="col-sm-4 col-sm-pull-1"> - <input type="text" name="fileChmod" class="form-control"/> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Folder chmod mask</label> - - <div class="col-sm-1 col-sm-push-4 help-inline"> - <i class="icon-sonarr-form-info" title="Octal, applied to series/season folders created by Sonarr"/> - </div> - - <div class="col-sm-4 col-sm-pull-1"> - <input type="text" name="folderChmod" class="form-control"/> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">chown User</label> - - <div class="col-sm-1 col-sm-push-4 help-inline"> - <i class="icon-sonarr-form-info" title="Username or uid. Use uid for remote file systems."/> - </div> - - <div class="col-sm-4 col-sm-pull-1"> - <input type="text" name="chownUser" class="form-control"/> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">chown Group</label> - - <div class="col-sm-1 col-sm-push-4 help-inline"> - <i class="icon-sonarr-form-info" title="Group name or gid. Use gid for remote file systems."/> - </div> - - <div class="col-sm-4 col-sm-pull-1"> - <input type="text" name="chownGroup" class="form-control"/> - </div> - </div> -</fieldset> diff --git a/src/UI/Settings/MediaManagement/Sorting/SortingView.js b/src/UI/Settings/MediaManagement/Sorting/SortingView.js deleted file mode 100644 index f339f9dea..000000000 --- a/src/UI/Settings/MediaManagement/Sorting/SortingView.js +++ /dev/null @@ -1,12 +0,0 @@ -var Marionette = require('marionette'); -var AsModelBoundView = require('../../../Mixins/AsModelBoundView'); -var AsValidatedView = require('../../../Mixins/AsValidatedView'); - -var view = Marionette.ItemView.extend({ - template : 'Settings/MediaManagement/Sorting/SortingViewTemplate' -}); - -AsModelBoundView.call(view); -AsValidatedView.call(view); - -module.exports = view; \ No newline at end of file diff --git a/src/UI/Settings/MediaManagement/Sorting/SortingViewTemplate.hbs b/src/UI/Settings/MediaManagement/Sorting/SortingViewTemplate.hbs deleted file mode 100644 index c78c7393a..000000000 --- a/src/UI/Settings/MediaManagement/Sorting/SortingViewTemplate.hbs +++ /dev/null @@ -1,79 +0,0 @@ -<fieldset class="advanced-setting"> - <legend>Folders</legend> - - <div class="form-group"> - <label class="col-sm-3 control-label">Create empty series folders</label> - - <div class="col-sm-9"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="createEmptySeriesFolders"/> - - <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="Create missing series folders during disk scan"/> - </span> - </div> - </div> - </div> -</fieldset> - -<fieldset class="advanced-setting"> - <legend>Importing</legend> - -{{#if_mono}} - <div class="form-group"> - <label class="col-sm-3 control-label">Skip Free Space Check</label> - - <div class="col-sm-9"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="skipFreeSpaceCheckWhenImporting"/> - - <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="Use when drone is unable to detect free space from your series root folder"/> - </span> - </div> - </div> - </div> -{{/if_mono}} - - <div class="form-group"> - <label class="col-sm-3 control-label">Use Hardlinks instead of Copy</label> - - <div class="col-sm-9"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="copyUsingHardlinks"/> - - <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="Use Hardlinks when trying to copy files from torrents that are still being seeded"/> - <i class="icon-sonarr-form-warning" title="Occasionally, file locks may prevent renaming files that are being seeded. You may temporarily disable seeding and use Sonarr's rename function as a work around."/> - </span> - </div> - </div> - </div> -</fieldset> diff --git a/src/UI/Settings/Metadata/MetadataCollection.js b/src/UI/Settings/Metadata/MetadataCollection.js deleted file mode 100644 index f37d80961..000000000 --- a/src/UI/Settings/Metadata/MetadataCollection.js +++ /dev/null @@ -1,7 +0,0 @@ -var Backbone = require('backbone'); -var MetadataModel = require('./MetadataModel'); - -module.exports = Backbone.Collection.extend({ - model : MetadataModel, - url : window.NzbDrone.ApiRoot + '/metadata' -}); \ No newline at end of file diff --git a/src/UI/Settings/Metadata/MetadataCollectionView.js b/src/UI/Settings/Metadata/MetadataCollectionView.js deleted file mode 100644 index 1f60d8fe0..000000000 --- a/src/UI/Settings/Metadata/MetadataCollectionView.js +++ /dev/null @@ -1,9 +0,0 @@ -var AppLayout = require('../../AppLayout'); -var Marionette = require('marionette'); -var MetadataItemView = require('./MetadataItemView'); - -module.exports = Marionette.CompositeView.extend({ - itemView : MetadataItemView, - itemViewContainer : '#x-metadata', - template : 'Settings/Metadata/MetadataCollectionViewTemplate' -}); \ No newline at end of file diff --git a/src/UI/Settings/Metadata/MetadataCollectionViewTemplate.hbs b/src/UI/Settings/Metadata/MetadataCollectionViewTemplate.hbs deleted file mode 100644 index a5c034668..000000000 --- a/src/UI/Settings/Metadata/MetadataCollectionViewTemplate.hbs +++ /dev/null @@ -1,8 +0,0 @@ -<fieldset> - <legend>Metadata</legend> - <div class="row"> - <div class="col-md-12"> - <ul id="x-metadata" class="metadata-list"></ul> - </div> - </div> -</fieldset> \ No newline at end of file diff --git a/src/UI/Settings/Metadata/MetadataEditView.js b/src/UI/Settings/Metadata/MetadataEditView.js deleted file mode 100644 index ed364824f..000000000 --- a/src/UI/Settings/Metadata/MetadataEditView.js +++ /dev/null @@ -1,19 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); -var AsModelBoundView = require('../../Mixins/AsModelBoundView'); -var AsValidatedView = require('../../Mixins/AsValidatedView'); -var AsEditModalView = require('../../Mixins/AsEditModalView'); - -var view = Marionette.ItemView.extend({ - template : 'Settings/Metadata/MetadataEditViewTemplate', - - _onAfterSave : function() { - vent.trigger(vent.Commands.CloseModalCommand); - } -}); - -AsModelBoundView.call(view); -AsValidatedView.call(view); -AsEditModalView.call(view); - -module.exports = view; \ No newline at end of file diff --git a/src/UI/Settings/Metadata/MetadataEditViewTemplate.hbs b/src/UI/Settings/Metadata/MetadataEditViewTemplate.hbs deleted file mode 100644 index 207bc4518..000000000 --- a/src/UI/Settings/Metadata/MetadataEditViewTemplate.hbs +++ /dev/null @@ -1,45 +0,0 @@ -<div class="modal-content"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - <h3>Edit</h3> - </div> - <div class="modal-body"> - <div class="form-horizontal"> - <div class="form-group"> - <label class="col-sm-3 control-label">Name</label> - - <div class="col-sm-5 controls"> - <input type="text" name="name" class="form-control"/> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Enable</label> - - <div class="col-sm-5"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="enable"/> - <p> - <span>Yes</span> - <span>No</span> - </p> - - <div class="btn btn-primary slide-button"/> - </label> - </div> - </div> - </div> - - <hr> - - {{formBuilder}} - </div> - </div> - <div class="modal-footer"> - <span class="indicator x-indicator"><i class="icon-sonarr-spinner fa-spin"></i></span> - - <button class="btn" data-dismiss="modal">Cancel</button> - <button class="btn btn-primary x-save">Save</button> - </div> -</div> diff --git a/src/UI/Settings/Metadata/MetadataItemView.js b/src/UI/Settings/Metadata/MetadataItemView.js deleted file mode 100644 index c72066d6c..000000000 --- a/src/UI/Settings/Metadata/MetadataItemView.js +++ /dev/null @@ -1,24 +0,0 @@ -var AppLayout = require('../../AppLayout'); -var Marionette = require('marionette'); -var EditView = require('./MetadataEditView'); -var AsModelBoundView = require('../../Mixins/AsModelBoundView'); - -var view = Marionette.ItemView.extend({ - template : 'Settings/Metadata/MetadataItemViewTemplate', - tagName : 'li', - - events : { - 'click' : '_edit' - }, - - initialize : function() { - this.listenTo(this.model, 'sync', this.render); - }, - - _edit : function() { - var view = new EditView({ model : this.model }); - AppLayout.modalRegion.show(view); - } -}); - -module.exports = AsModelBoundView.call(view); \ No newline at end of file diff --git a/src/UI/Settings/Metadata/MetadataItemViewTemplate.hbs b/src/UI/Settings/Metadata/MetadataItemViewTemplate.hbs deleted file mode 100644 index af9adc982..000000000 --- a/src/UI/Settings/Metadata/MetadataItemViewTemplate.hbs +++ /dev/null @@ -1,23 +0,0 @@ -<div class="metadata-item"> - <div> - <h3>{{implementationName}}</h3> - </div> - - <div class="settings"> - {{#if enable}} - <span class="label label-success">Enabled</span> - {{else}} - <span class="label label-default">Not Enabled</span> - {{/if}} - <hr> - {{#each fields}} - {{#if_eq type compare="checkbox"}} - {{#if value}} - <span class="label label-success">{{label}}</span> - {{else}} - <span class="label">{{label}}</span> - {{/if}} - {{/if_eq}} - {{/each}} - </div> -</div> diff --git a/src/UI/Settings/Metadata/MetadataLayout.js b/src/UI/Settings/Metadata/MetadataLayout.js deleted file mode 100644 index 66b5f5901..000000000 --- a/src/UI/Settings/Metadata/MetadataLayout.js +++ /dev/null @@ -1,20 +0,0 @@ -var Marionette = require('marionette'); -var MetadataCollection = require('./MetadataCollection'); -var MetadataCollectionView = require('./MetadataCollectionView'); - -module.exports = Marionette.Layout.extend({ - template : 'Settings/Metadata/MetadataLayoutTemplate', - - regions : { - metadata : '#x-metadata-providers' - }, - - initialize : function(options) { - this.settings = options.settings; - this.metadataCollection = new MetadataCollection(); - this.metadataCollection.fetch(); - }, - onShow : function() { - this.metadata.show(new MetadataCollectionView({ collection : this.metadataCollection })); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/Metadata/MetadataLayoutTemplate.hbs b/src/UI/Settings/Metadata/MetadataLayoutTemplate.hbs deleted file mode 100644 index a32fe464e..000000000 --- a/src/UI/Settings/Metadata/MetadataLayoutTemplate.hbs +++ /dev/null @@ -1,3 +0,0 @@ -<div class="row"> - <div class="col-md-12" id="x-metadata-providers"/> -</div> diff --git a/src/UI/Settings/Metadata/MetadataModel.js b/src/UI/Settings/Metadata/MetadataModel.js deleted file mode 100644 index 288e45362..000000000 --- a/src/UI/Settings/Metadata/MetadataModel.js +++ /dev/null @@ -1,3 +0,0 @@ -var ProviderSettingsModelBase = require('../ProviderSettingsModelBase'); - -module.exports = ProviderSettingsModelBase.extend({}); \ No newline at end of file diff --git a/src/UI/Settings/Metadata/metadata.less b/src/UI/Settings/Metadata/metadata.less deleted file mode 100644 index 566114a39..000000000 --- a/src/UI/Settings/Metadata/metadata.less +++ /dev/null @@ -1,37 +0,0 @@ -@import "../../Shared/Styles/card"; - -.metadata-list { - li { - display: inline-block; - vertical-align: top; - } -} - -.metadata-item { - - .card; - .clickable; - - width: 200px; - height: 230px; - padding: 10px 15px; - - h3 { - margin-top: 0px; - display: inline-block; - width: 180px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .btn-group { - margin-top: 8px; - } - - .label { - margin-top : 3px; - display : block; - text-align : center; - } -} \ No newline at end of file diff --git a/src/UI/Settings/Notifications/Add/NotificationAddCollectionView.js b/src/UI/Settings/Notifications/Add/NotificationAddCollectionView.js deleted file mode 100644 index 68a304fd9..000000000 --- a/src/UI/Settings/Notifications/Add/NotificationAddCollectionView.js +++ /dev/null @@ -1,8 +0,0 @@ -var ThingyAddCollectionView = require('../../ThingyAddCollectionView'); -var AddItemView = require('./NotificationAddItemView'); - -module.exports = ThingyAddCollectionView.extend({ - itemView : AddItemView, - itemViewContainer : '.add-notifications .items', - template : 'Settings/Notifications/Add/NotificationAddCollectionViewTemplate' -}); \ No newline at end of file diff --git a/src/UI/Settings/Notifications/Add/NotificationAddCollectionViewTemplate.hbs b/src/UI/Settings/Notifications/Add/NotificationAddCollectionViewTemplate.hbs deleted file mode 100644 index 0075fa504..000000000 --- a/src/UI/Settings/Notifications/Add/NotificationAddCollectionViewTemplate.hbs +++ /dev/null @@ -1,14 +0,0 @@ -<div class="modal-content"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - <h3>Add Notification</h3> - </div> - <div class="modal-body"> - <div class="add-notifications add-thingies"> - <ul class="items"></ul> - </div> - </div> - <div class="modal-footer"> - <button class="btn" data-dismiss="modal">Close</button> - </div> -</div> diff --git a/src/UI/Settings/Notifications/Add/NotificationAddItemView.js b/src/UI/Settings/Notifications/Add/NotificationAddItemView.js deleted file mode 100644 index 04b7c8944..000000000 --- a/src/UI/Settings/Notifications/Add/NotificationAddItemView.js +++ /dev/null @@ -1,64 +0,0 @@ -var _ = require('underscore'); -var $ = require('jquery'); -var AppLayout = require('../../../AppLayout'); -var Marionette = require('marionette'); -var EditView = require('../Edit/NotificationEditView'); - -module.exports = Marionette.ItemView.extend({ - template : 'Settings/Notifications/Add/NotificationAddItemViewTemplate', - tagName : 'li', - className : 'add-thingy-item', - - events : { - 'click .x-preset' : '_addPreset', - 'click' : '_add' - }, - - initialize : function(options) { - this.targetCollection = options.targetCollection; - }, - - _addPreset : function(e) { - var presetName = $(e.target).closest('.x-preset').attr('data-id'); - - var presetData = _.where(this.model.get('presets'), { name : presetName })[0]; - - this.model.set(presetData); - - this.model.set({ - id : undefined, - onGrab : this.model.get('supportsOnGrab'), - onDownload : this.model.get('supportsOnDownload'), - onUpgrade : this.model.get('supportsOnUpgrade'), - onRename : this.model.get('supportsOnRename') - }); - - var editView = new EditView({ - model : this.model, - targetCollection : this.targetCollection - }); - - AppLayout.modalRegion.show(editView); - }, - - _add : function(e) { - if ($(e.target).closest('.btn,.btn-group').length !== 0 && $(e.target).closest('.x-custom').length === 0) { - return; - } - - this.model.set({ - id : undefined, - onGrab : this.model.get('supportsOnGrab'), - onDownload : this.model.get('supportsOnDownload'), - onUpgrade : this.model.get('supportsOnUpgrade'), - onRename : this.model.get('supportsOnRename') - }); - - var editView = new EditView({ - model : this.model, - targetCollection : this.targetCollection - }); - - AppLayout.modalRegion.show(editView); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/Notifications/Add/NotificationAddItemViewTemplate.hbs b/src/UI/Settings/Notifications/Add/NotificationAddItemViewTemplate.hbs deleted file mode 100644 index 40bcb4391..000000000 --- a/src/UI/Settings/Notifications/Add/NotificationAddItemViewTemplate.hbs +++ /dev/null @@ -1,30 +0,0 @@ -<div class="add-thingy"> - <div> - {{implementationName}} - </div> - <div class="pull-right"> - {{#if_gt presets.length compare=0}} - <button class="btn btn-xs btn-default x-custom"> - Custom - </button> - <div class="btn-group"> - <button class="btn btn-xs btn-default dropdown-toggle" data-toggle="dropdown"> - Presets - <span class="caret"></span> - </button> - <ul class="dropdown-menu"> - {{#each presets}} - <li class="x-preset" data-id="{{name}}"> - <a>{{name}}</a> - </li> - {{/each}} - </ul> - </div> - {{/if_gt}} - {{#if infoLink}} - <a class="btn btn-xs btn-default x-info" href="{{infoLink}}"> - <i class="icon-sonarr-form-info"/> - </a> - {{/if}} - </div> -</div> \ No newline at end of file diff --git a/src/UI/Settings/Notifications/Add/NotificationSchemaModal.js b/src/UI/Settings/Notifications/Add/NotificationSchemaModal.js deleted file mode 100644 index 54b60973b..000000000 --- a/src/UI/Settings/Notifications/Add/NotificationSchemaModal.js +++ /dev/null @@ -1,18 +0,0 @@ -var AppLayout = require('../../../AppLayout'); -var SchemaCollection = require('../NotificationCollection'); -var AddCollectionView = require('./NotificationAddCollectionView'); - -module.exports = { - open : function(collection) { - var schemaCollection = new SchemaCollection(); - var originalUrl = schemaCollection.url; - schemaCollection.url = schemaCollection.url + '/schema'; - schemaCollection.fetch(); - schemaCollection.url = originalUrl; - var view = new AddCollectionView({ - collection : schemaCollection, - targetCollection : collection - }); - AppLayout.modalRegion.show(view); - } -}; \ No newline at end of file diff --git a/src/UI/Settings/Notifications/Delete/NotificationDeleteView.js b/src/UI/Settings/Notifications/Delete/NotificationDeleteView.js deleted file mode 100644 index f80ab92a7..000000000 --- a/src/UI/Settings/Notifications/Delete/NotificationDeleteView.js +++ /dev/null @@ -1,18 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'Settings/Notifications/Delete/NotificationDeleteViewTemplate', - - events : { - 'click .x-confirm-delete' : '_delete' - }, - _delete : function() { - this.model.destroy({ - wait : true, - success : function() { - vent.trigger(vent.Commands.CloseModalCommand); - } - }); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/Notifications/Delete/NotificationDeleteViewTemplate.hbs b/src/UI/Settings/Notifications/Delete/NotificationDeleteViewTemplate.hbs deleted file mode 100644 index 1e6a52b73..000000000 --- a/src/UI/Settings/Notifications/Delete/NotificationDeleteViewTemplate.hbs +++ /dev/null @@ -1,13 +0,0 @@ -<div class="modal-content"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - <h3>Delete Notification</h3> - </div> - <div class="modal-body"> - <p>Are you sure you want to delete '{{name}}'?</p> - </div> - <div class="modal-footer"> - <button class="btn" data-dismiss="modal">Cancel</button> - <button class="btn btn-danger x-confirm-delete">Delete</button> - </div> -</div> \ No newline at end of file diff --git a/src/UI/Settings/Notifications/Edit/NotificationEditView.js b/src/UI/Settings/Notifications/Edit/NotificationEditView.js deleted file mode 100644 index 5e626ce90..000000000 --- a/src/UI/Settings/Notifications/Edit/NotificationEditView.js +++ /dev/null @@ -1,140 +0,0 @@ -var _ = require('underscore'); -var $ = require('jquery'); -var vent = require('vent'); -var Marionette = require('marionette'); -var DeleteView = require('../Delete/NotificationDeleteView'); -var AsModelBoundView = require('../../../Mixins/AsModelBoundView'); -var AsValidatedView = require('../../../Mixins/AsValidatedView'); -var AsEditModalView = require('../../../Mixins/AsEditModalView'); -require('../../../Form/FormBuilder'); -require('../../../Mixins/TagInput'); -require('../../../Mixins/FileBrowser'); -require('bootstrap.tagsinput'); - -var view = Marionette.ItemView.extend({ - template : 'Settings/Notifications/Edit/NotificationEditViewTemplate', - - ui : { - onDownloadToggle : '.x-on-download', - onUpgradeSection : '.x-on-upgrade', - tags : '.x-tags', - modalBody : '.x-modal-body', - formTag : '.x-form-tag', - path : '.x-path', - authorizedNotificationButton : '.AuthorizeNotification' - }, - - events : { - 'click .x-back' : '_back', - 'change .x-on-download' : '_onDownloadChanged', - 'click .AuthorizeNotification' : '_onAuthorizeNotification' - }, - - _deleteView : DeleteView, - - initialize : function(options) { - this.targetCollection = options.targetCollection; - }, - - onRender : function() { - this._onDownloadChanged(); - - this.ui.tags.tagInput({ - model : this.model, - property : 'tags' - }); - - this.ui.formTag.tagsinput({ - trimValue : true, - tagClass : 'label label-default' - }); - }, - - onShow : function() { - if (this.ui.path.length > 0) { - this.ui.modalBody.addClass('modal-overflow'); - } - - this.ui.path.fileBrowser(); - }, - - _onAfterSave : function() { - this.targetCollection.add(this.model, { merge : true }); - vent.trigger(vent.Commands.CloseModalCommand); - }, - - _onAfterSaveAndAdd : function() { - this.targetCollection.add(this.model, { merge : true }); - - require('../Add/NotificationSchemaModal').open(this.targetCollection); - }, - - _back : function() { - if (this.model.isNew()) { - this.model.destroy(); - } - - require('../Add/NotificationSchemaModal').open(this.targetCollection); - }, - - _onDownloadChanged : function() { - var checked = this.ui.onDownloadToggle.prop('checked'); - - if (checked) { - this.ui.onUpgradeSection.show(); - } else { - this.ui.onUpgradeSection.hide(); - } - }, - - _onAuthorizeNotification : function() { - this.ui.indicator.show(); - - var self = this; - - var promise = this.model.requestAction('startOAuth', { callbackUrl: window.location.origin + '/oauth.html' }) - .then(function(response) { - return self._showOAuthWindow(response.oauthUrl); - }) - .then(function(responseQueryParams) { - return self.model.requestAction('getOAuthToken', responseQueryParams); - }) - .then(function(response) { - self.model.setFieldValue('AccessToken', response.accessToken); - self.model.setFieldValue('AccessTokenSecret', response.accessTokenSecret); - }); - - promise.always(function() { - self.ui.indicator.hide(); - }); - }, - - _showOAuthWindow : function(oauthUrl) { - var promise = $.Deferred(); - - window.open(oauthUrl); - var selfWindow = window; - selfWindow.onCompleteOauth = function(query, callback) { - delete selfWindow.onCompleteOauth; - - var queryParams = {}; - var splitQuery = query.substring(1).split('&'); - _.each(splitQuery, function (param) { - var paramSplit = param.split('='); - queryParams[paramSplit[0]] = paramSplit[1]; - }); - - callback(); - - promise.resolve(queryParams); - }; - - return promise; - } -}); - -AsModelBoundView.call(view); -AsValidatedView.call(view); -AsEditModalView.call(view); - -module.exports = view; \ No newline at end of file diff --git a/src/UI/Settings/Notifications/Edit/NotificationEditViewTemplate.hbs b/src/UI/Settings/Notifications/Edit/NotificationEditViewTemplate.hbs deleted file mode 100644 index 02196cb75..000000000 --- a/src/UI/Settings/Notifications/Edit/NotificationEditViewTemplate.hbs +++ /dev/null @@ -1,148 +0,0 @@ -<div class="modal-content"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - {{#if id}} - <h3>Edit - {{implementationName}}</h3> - {{else}} - <h3>Add - {{implementationName}}</h3> - {{/if}} - </div> - <div class="modal-body notification-modal x-modal"> - <div class="form-horizontal"> - <div class="form-group"> - <label class="col-sm-3 control-label">Name</label> - - <div class="col-sm-5"> - <input type="text" name="name" class="form-control"/> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">On Grab</label> - - <div class="col-sm-5"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="onGrab" {{#unless supportsOnGrab}}disabled="disabled"{{/unless}}/> - <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="Be notified when episodes are available for download and has been sent to a download client"/> - </span> - </div> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">On Download</label> - - <div class="col-sm-5"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="onDownload" class="x-on-download" {{#unless supportsOnDownload}}disabled="disabled"{{/unless}}/> - <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="Be notified when episodes are successfully downloaded"/> - </span> - </div> - </div> - </div> - - <div class="form-group x-on-upgrade"> - <label class="col-sm-3 control-label">On Upgrade</label> - - <div class="col-sm-5"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="onUpgrade" {{#unless supportsOnUpgrade}}disabled="disabled"{{/unless}}/> - <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="Be notified when episodes are upgraded to a better quality"/> - </span> - </div> - </div> - </div> - - <div class="form-group x-on-upgrade"> - <label class="col-sm-3 control-label">On Rename</label> - - <div class="col-sm-5"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="onRename" {{#unless supportsOnRename}}disabled="disabled"{{/unless}}/> - <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="Be notified when episodes are renamed"/> - </span> - </div> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Filter Series Tags</label> - - <div class="col-sm-5"> - <input type="text" class="form-control x-tags"> - </div> - - <div class="col-sm-1 help-inline"> - <i class="icon-sonarr-form-info" title="Only send notifications for series with matching tags"/> - </div> - </div> - - <hr> - - {{formBuilder}} - </div> - </div> - <div class="modal-footer"> - {{#if id}} - <button class="btn btn-danger pull-left x-delete">Delete</button> - {{else}} - <button class="btn pull-left x-back">Back</button> - {{/if}} - - <span class="indicator x-indicator"><i class="icon-sonarr-spinner fa-spin"></i></span> - <button class="btn x-test">test <i class="x-test-icon icon-sonarr-test"/></button> - <button class="btn" data-dismiss="modal">Cancel</button> - - <div class="btn-group"> - <button class="btn btn-primary x-save">Save</button> - <button class="btn btn-icon-only btn-primary dropdown-toggle" data-toggle="dropdown"> - <span class="caret"></span> - </button> - <ul class="dropdown-menu"> - <li class="save-and-add x-save-and-add"> - save and add - </li> - </ul> - </div> - </div> -</div> diff --git a/src/UI/Settings/Notifications/NotificationCollection.js b/src/UI/Settings/Notifications/NotificationCollection.js deleted file mode 100644 index 25160c33a..000000000 --- a/src/UI/Settings/Notifications/NotificationCollection.js +++ /dev/null @@ -1,7 +0,0 @@ -var Backbone = require('backbone'); -var NotificationModel = require('./NotificationModel'); - -module.exports = Backbone.Collection.extend({ - model : NotificationModel, - url : window.NzbDrone.ApiRoot + '/notification' -}); \ No newline at end of file diff --git a/src/UI/Settings/Notifications/NotificationCollectionView.js b/src/UI/Settings/Notifications/NotificationCollectionView.js deleted file mode 100644 index c25433e5c..000000000 --- a/src/UI/Settings/Notifications/NotificationCollectionView.js +++ /dev/null @@ -1,25 +0,0 @@ -var Marionette = require('marionette'); -var ItemView = require('./NotificationItemView'); -var SchemaModal = require('./Add/NotificationSchemaModal'); - -module.exports = Marionette.CompositeView.extend({ - itemView : ItemView, - itemViewContainer : '.notification-list', - template : 'Settings/Notifications/NotificationCollectionViewTemplate', - - ui : { - 'addCard' : '.x-add-card' - }, - - events : { - 'click .x-add-card' : '_openSchemaModal' - }, - - appendHtml : function(collectionView, itemView, index) { - collectionView.ui.addCard.parent('li').before(itemView.el); - }, - - _openSchemaModal : function() { - SchemaModal.open(this.collection); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/Notifications/NotificationCollectionViewTemplate.hbs b/src/UI/Settings/Notifications/NotificationCollectionViewTemplate.hbs deleted file mode 100644 index f58134e5e..000000000 --- a/src/UI/Settings/Notifications/NotificationCollectionViewTemplate.hbs +++ /dev/null @@ -1,16 +0,0 @@ -<fieldset> - <legend>Connections</legend> - <div class="row"> - <div class="col-md-12"> - <ul class="notification-list thingies"> - <li> - <div class="notification-item thingy add-card x-add-card"> - <span class="center well"> - <i class="icon-sonarr-add"/> - </span> - </div> - </li> - </ul> - </div> - </div> -</fieldset> \ No newline at end of file diff --git a/src/UI/Settings/Notifications/NotificationItemView.js b/src/UI/Settings/Notifications/NotificationItemView.js deleted file mode 100644 index 6f3665b2f..000000000 --- a/src/UI/Settings/Notifications/NotificationItemView.js +++ /dev/null @@ -1,24 +0,0 @@ -var AppLayout = require('../../AppLayout'); -var Marionette = require('marionette'); -var EditView = require('./Edit/NotificationEditView'); - -module.exports = Marionette.ItemView.extend({ - template : 'Settings/Notifications/NotificationItemViewTemplate', - tagName : 'li', - - events : { - 'click' : '_edit' - }, - - initialize : function() { - this.listenTo(this.model, 'sync', this.render); - }, - - _edit : function() { - var view = new EditView({ - model : this.model, - targetCollection : this.model.collection - }); - AppLayout.modalRegion.show(view); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/Notifications/NotificationItemViewTemplate.hbs b/src/UI/Settings/Notifications/NotificationItemViewTemplate.hbs deleted file mode 100644 index bbfa0a3af..000000000 --- a/src/UI/Settings/Notifications/NotificationItemViewTemplate.hbs +++ /dev/null @@ -1,47 +0,0 @@ -<div class="notification-item thingy"> - <div> - <h3>{{name}}</h3> - </div> - - <div class="settings"> - {{#if supportsOnGrab}} - {{#if onGrab}} - <span class="label label-success">On Grab</span> - {{else}} - <span class="label label-default">On Grab</span> - {{/if}} - {{else}} - <span class="label label-default label-disabled">On Grab</span> - {{/if}} - - {{#if supportsOnDownload}} - {{#if onDownload}} - <span class="label label-success">On Download</span> - {{else}} - <span class="label label-default">On Download</span> - {{/if}} - {{else}} - <span class="label label-default label-disabled">On Download</span> - {{/if}} - - {{#if supportsOnUpgrade}} - {{#if onUpgrade}} - <span class="label label-success">On Upgrade</span> - {{else}} - <span class="label label-default">On Upgrade</span> - {{/if}} - {{else}} - <span class="label label-default label-disabled">On Upgrade</span> - {{/if}} - - {{#if supportsOnRename}} - {{#if onRename}} - <span class="label label-success">On Rename</span> - {{else}} - <span class="label label-default">On Rename</span> - {{/if}} - {{else}} - <span class="label label-default label-disabled">On Rename</span> - {{/if}} - </div> -</div> diff --git a/src/UI/Settings/Notifications/NotificationModel.js b/src/UI/Settings/Notifications/NotificationModel.js deleted file mode 100644 index 288e45362..000000000 --- a/src/UI/Settings/Notifications/NotificationModel.js +++ /dev/null @@ -1,3 +0,0 @@ -var ProviderSettingsModelBase = require('../ProviderSettingsModelBase'); - -module.exports = ProviderSettingsModelBase.extend({}); \ No newline at end of file diff --git a/src/UI/Settings/Notifications/notifications.less b/src/UI/Settings/Notifications/notifications.less deleted file mode 100644 index fb3a0e4a0..000000000 --- a/src/UI/Settings/Notifications/notifications.less +++ /dev/null @@ -1,37 +0,0 @@ -@import "../../Shared/Styles/clickable.less"; - -//.notifications { -// width: -webkit-fit-content; -// width: -moz-fit-content; -// width: fit-content; -//} - -.notification-item { - .clickable; - - width: 290px; - height: 115px; - padding: 20px 20px; - - .settings { - margin-top: 5px; - - .label { - display : inline-block; - margin-bottom : 2px; - padding : 4px 6px 3px 6px; - } - } - - &.add-card { - .center { - margin-top: -4px; - } - } -} - -.add-notifications { - li.add-thingy-item { - width: 40%; - } -} \ No newline at end of file diff --git a/src/UI/Settings/Profile/AllowedLabeler.js b/src/UI/Settings/Profile/AllowedLabeler.js deleted file mode 100644 index c5da373c3..000000000 --- a/src/UI/Settings/Profile/AllowedLabeler.js +++ /dev/null @@ -1,19 +0,0 @@ -var Handlebars = require('handlebars'); -var _ = require('underscore'); - -Handlebars.registerHelper('allowedLabeler', function() { - var ret = ''; - var cutoff = this.cutoff; - - _.each(this.items, function(item) { - if (item.allowed) { - if (item.quality.id === cutoff.id) { - ret += '<li><span class="label label-info" title="Cutoff">' + item.quality.name + '</span></li>'; - } else { - ret += '<li><span class="label label-default">' + item.quality.name + '</span></li>'; - } - } - }); - - return new Handlebars.SafeString(ret); -}); \ No newline at end of file diff --git a/src/UI/Settings/Profile/Delay/DelayProfileCollection.js b/src/UI/Settings/Profile/Delay/DelayProfileCollection.js deleted file mode 100644 index fcb240a5b..000000000 --- a/src/UI/Settings/Profile/Delay/DelayProfileCollection.js +++ /dev/null @@ -1,7 +0,0 @@ -var Backbone = require('backbone'); -var DelayProfileModel = require('./DelayProfileModel'); - -module.exports = Backbone.Collection.extend({ - model : DelayProfileModel, - url : window.NzbDrone.ApiRoot + '/delayprofile' -}); \ No newline at end of file diff --git a/src/UI/Settings/Profile/Delay/DelayProfileCollectionView.js b/src/UI/Settings/Profile/Delay/DelayProfileCollectionView.js deleted file mode 100644 index 87cc93d2d..000000000 --- a/src/UI/Settings/Profile/Delay/DelayProfileCollectionView.js +++ /dev/null @@ -1,13 +0,0 @@ -var BackboneSortableCollectionView = require('backbone.collectionview'); -var DelayProfileItemView = require('./DelayProfileItemView'); - -module.exports = BackboneSortableCollectionView.extend({ - className : 'delay-profiles', - modelView : DelayProfileItemView, - - events : { - 'click li, td' : '_listItem_onMousedown', - 'dblclick li, td' : '_listItem_onDoubleClick', - 'keydown' : '_onKeydown' - } -}); \ No newline at end of file diff --git a/src/UI/Settings/Profile/Delay/DelayProfileItemView.js b/src/UI/Settings/Profile/Delay/DelayProfileItemView.js deleted file mode 100644 index b8d89364b..000000000 --- a/src/UI/Settings/Profile/Delay/DelayProfileItemView.js +++ /dev/null @@ -1,25 +0,0 @@ -var $ = require('jquery'); -var AppLayout = require('../../../AppLayout'); -var Marionette = require('marionette'); -var EditView = require('./Edit/DelayProfileEditView'); - -module.exports = Marionette.ItemView.extend({ - template : 'Settings/Profile/Delay/DelayProfileItemViewTemplate', - className : 'row', - - events : { - 'click .x-edit' : '_edit' - }, - - initialize : function() { - this.listenTo(this.model, 'sync', this.render); - }, - - _edit : function() { - var view = new EditView({ - model : this.model, - targetCollection : this.model.collection - }); - AppLayout.modalRegion.show(view); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/Profile/Delay/DelayProfileItemViewTemplate.hbs b/src/UI/Settings/Profile/Delay/DelayProfileItemViewTemplate.hbs deleted file mode 100644 index bc2f9b96b..000000000 --- a/src/UI/Settings/Profile/Delay/DelayProfileItemViewTemplate.hbs +++ /dev/null @@ -1,57 +0,0 @@ - <div class="col-sm-2"> - {{#if enableUsenet}} - {{#if enableTorrent}} - {{#if_eq preferredProtocol compare="usenet"}} - Prefer Usenet - {{else}} - Prefer Torrent - {{/if_eq}} - {{else}} - Only Usenet - {{/if}} - {{else}} - Only Torrent - {{/if}} - </div> - <div class="col-sm-2"> - {{#if enableUsenet}} - {{#if_eq usenetDelay compare="0"}} - No delay - {{else}} - {{#if_eq usenetDelay compare="1"}} - 1 minute - {{else}} - {{usenetDelay}} minutes - {{/if_eq}} - {{/if_eq}} - {{else}} - - - {{/if}} - </div> - <div class="col-sm-2"> - {{#if enableTorrent}} - {{#if_eq torrentDelay compare="0"}} - No delay - {{else}} - {{#if_eq torrentDelay compare="1"}} - 1 minute - {{else}} - {{torrentDelay}} minutes - {{/if_eq}} - {{/if_eq}} - {{else}} - - - {{/if}} - </div> - <div class="col-sm-5"> - {{tagDisplay tags}} - </div> - <div class="col-sm-1"> - <div class="pull-right"> - {{#unless_eq id compare="1"}} - <i class="drag-handle icon-sonarr-reorder x-drag-handle" title="Reorder"/> - {{/unless_eq}} - - <i class="icon-sonarr-edit x-edit" title="Edit"></i> - </div> - </div> \ No newline at end of file diff --git a/src/UI/Settings/Profile/Delay/DelayProfileLayout.js b/src/UI/Settings/Profile/Delay/DelayProfileLayout.js deleted file mode 100644 index 024be5a99..000000000 --- a/src/UI/Settings/Profile/Delay/DelayProfileLayout.js +++ /dev/null @@ -1,101 +0,0 @@ -var $ = require('jquery'); -var _ = require('underscore'); -var vent = require('vent'); -var AppLayout = require('../../../AppLayout'); -var Marionette = require('marionette'); -var Backbone = require('backbone'); -var DelayProfileCollectionView = require('./DelayProfileCollectionView'); -var EditView = require('./Edit/DelayProfileEditView'); -var Model = require('./DelayProfileModel'); - -module.exports = Marionette.Layout.extend({ - template : 'Settings/Profile/Delay/DelayProfileLayoutTemplate', - - regions : { - delayProfiles : '.x-rows' - }, - - events : { - 'click .x-add' : '_add' - }, - - initialize : function(options) { - this.collection = options.collection; - - this._updateOrderedCollection(); - - this.listenTo(this.collection, 'sync', this._updateOrderedCollection); - this.listenTo(this.collection, 'add', this._updateOrderedCollection); - this.listenTo(this.collection, 'remove', function() { - this.collection.fetch(); - }); - }, - - onRender : function() { - - this.sortableListView = new DelayProfileCollectionView({ - sortable : true, - collection : this.orderedCollection, - - sortableOptions : { - handle : '.x-drag-handle' - }, - - sortableModelsFilter : function(model) { - return model.get('id') !== 1; - } - }); - - this.delayProfiles.show(this.sortableListView); - - this.listenTo(this.sortableListView, 'sortStop', this._updateOrder); - }, - - _updateOrder : function() { - var self = this; - - this.collection.forEach(function(model) { - if (model.get('id') === 1) { - return; - } - - var orderedModel = self.orderedCollection.get(model); - var order = self.orderedCollection.indexOf(orderedModel) + 1; - - if (model.get('order') !== order) { - model.set('order', order); - model.save(); - } - }); - }, - - _add : function() { - var model = new Model({ - enableUsenet : true, - enableTorrent : true, - preferredProtocol : 'usenet', - usenetDelay : 0, - torrentDelay : 0, - order : this.collection.length, - tags : [] - }); - - model.collection = this.collection; - var view = new EditView({ - model : model, - targetCollection : this.collection - }); - - AppLayout.modalRegion.show(view); - }, - - _updateOrderedCollection : function() { - if (!this.orderedCollection) { - this.orderedCollection = new Backbone.Collection(); - } - - this.orderedCollection.reset(_.sortBy(this.collection.models, function(model) { - return model.get('order'); - })); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/Profile/Delay/DelayProfileLayoutTemplate.hbs b/src/UI/Settings/Profile/Delay/DelayProfileLayoutTemplate.hbs deleted file mode 100644 index 8b32e77e4..000000000 --- a/src/UI/Settings/Profile/Delay/DelayProfileLayoutTemplate.hbs +++ /dev/null @@ -1,24 +0,0 @@ -<fieldset class="advanced-setting"> - <legend>Delay Profiles</legend> - - <div class="col-md-12"> - <div class="rule-setting-list"> - <div class="rule-setting-header x-header hidden-xs"> - <div class="row"> - <span class="col-sm-2">Protocol</span> - <span class="col-sm-2">Usenet Delay</span> - <span class="col-sm-2">Torrent Delay</span> - <span class="col-sm-5">Tags</span> - </div> - </div> - <div class="rows x-rows"></div> - <div class="rule-setting-footer"> - <div class="pull-right"> - <span class="add-rule-setting-mapping"> - <i class="icon-sonarr-add x-add" title="Add new delay profile" /> - </span> - </div> - </div> - </div> - </div> -</fieldset> \ No newline at end of file diff --git a/src/UI/Settings/Profile/Delay/DelayProfileModel.js b/src/UI/Settings/Profile/Delay/DelayProfileModel.js deleted file mode 100644 index 3986a5948..000000000 --- a/src/UI/Settings/Profile/Delay/DelayProfileModel.js +++ /dev/null @@ -1,3 +0,0 @@ -var Backbone = require('backbone'); - -module.exports = Backbone.Model.extend({}); \ No newline at end of file diff --git a/src/UI/Settings/Profile/Delay/Delete/DelayProfileDeleteView.js b/src/UI/Settings/Profile/Delay/Delete/DelayProfileDeleteView.js deleted file mode 100644 index 6b948d782..000000000 --- a/src/UI/Settings/Profile/Delay/Delete/DelayProfileDeleteView.js +++ /dev/null @@ -1,21 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'Settings/Profile/Delay/Delete/DelayProfileDeleteViewTemplate', - - events : { - 'click .x-confirm-delete' : '_delete' - }, - - _delete : function() { - var collection = this.model.collection; - - this.model.destroy({ - wait : true, - success : function() { - vent.trigger(vent.Commands.CloseModalCommand); - } - }); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/Profile/Delay/Delete/DelayProfileDeleteViewTemplate.hbs b/src/UI/Settings/Profile/Delay/Delete/DelayProfileDeleteViewTemplate.hbs deleted file mode 100644 index dc6b5125f..000000000 --- a/src/UI/Settings/Profile/Delay/Delete/DelayProfileDeleteViewTemplate.hbs +++ /dev/null @@ -1,13 +0,0 @@ -<div class="modal-content"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - <h3>Delete Delay Profile</h3> - </div> - <div class="modal-body"> - <p>Are you sure you want to delete this delay profile?</p> - </div> - <div class="modal-footer"> - <button class="btn" data-dismiss="modal">Cancel</button> - <button class="btn btn-danger x-confirm-delete">Delete</button> - </div> -</div> diff --git a/src/UI/Settings/Profile/Delay/Edit/DelayProfileEditView.js b/src/UI/Settings/Profile/Delay/Edit/DelayProfileEditView.js deleted file mode 100644 index 277527f79..000000000 --- a/src/UI/Settings/Profile/Delay/Edit/DelayProfileEditView.js +++ /dev/null @@ -1,122 +0,0 @@ -var vent = require('vent'); -var AppLayout = require('../../../../AppLayout'); -var Marionette = require('marionette'); -var DeleteView = require('../Delete/DelayProfileDeleteView'); -var AsModelBoundView = require('../../../../Mixins/AsModelBoundView'); -var AsValidatedView = require('../../../../Mixins/AsValidatedView'); -var AsEditModalView = require('../../../../Mixins/AsEditModalView'); -require('../../../../Mixins/TagInput'); -require('bootstrap'); - -var view = Marionette.ItemView.extend({ - template : 'Settings/Profile/Delay/Edit/DelayProfileEditViewTemplate', - - _deleteView : DeleteView, - - ui : { - tags : '.x-tags', - usenetDelay : '.x-usenet-delay', - torrentDelay : '.x-torrent-delay', - protocol : '.x-protocol' - }, - - events : { - 'change .x-protocol' : '_updateModel' - }, - - initialize : function(options) { - this.targetCollection = options.targetCollection; - }, - - onRender : function() { - if (this.model.id !== 1) { - this.ui.tags.tagInput({ - model : this.model, - property : 'tags' - }); - } - - this._toggleControls(); - }, - - _onAfterSave : function() { - this.targetCollection.add(this.model, { merge : true }); - vent.trigger(vent.Commands.CloseModalCommand); - }, - - _updateModel : function() { - var protocol = this.ui.protocol.val(); - - if (protocol === 'preferUsenet') { - this.model.set({ - enableUsenet : true, - enableTorrent : true, - preferredProtocol : 'usenet' - }); - } - - if (protocol === 'preferTorrent') { - this.model.set({ - enableUsenet : true, - enableTorrent : true, - preferredProtocol : 'torrent' - }); - } - - if (protocol === 'onlyUsenet') { - this.model.set({ - enableUsenet : true, - enableTorrent : false, - preferredProtocol : 'usenet' - }); - } - - if (protocol === 'onlyTorrent') { - this.model.set({ - enableUsenet : false, - enableTorrent : true, - preferredProtocol : 'torrent' - }); - } - - this._toggleControls(); - }, - - _toggleControls : function() { - var enableUsenet = this.model.get('enableUsenet'); - var enableTorrent = this.model.get('enableTorrent'); - var preferred = this.model.get('preferredProtocol'); - - if (preferred === 'usenet') { - this.ui.protocol.val('preferUsenet'); - } - - else { - this.ui.protocol.val('preferTorrent'); - } - - if (enableUsenet) { - this.ui.usenetDelay.show(); - } - - else { - this.ui.protocol.val('onlyTorrent'); - this.ui.usenetDelay.hide(); - } - - if (enableTorrent) { - this.ui.torrentDelay.show(); - } - - else { - this.ui.protocol.val('onlyUsenet'); - this.ui.torrentDelay.hide(); - } - } -}); - -AsModelBoundView.call(view); -AsValidatedView.call(view); -AsEditModalView.call(view); - -module.exports = view; \ No newline at end of file diff --git a/src/UI/Settings/Profile/Delay/Edit/DelayProfileEditViewTemplate.hbs b/src/UI/Settings/Profile/Delay/Edit/DelayProfileEditViewTemplate.hbs deleted file mode 100644 index 5ff9c3bea..000000000 --- a/src/UI/Settings/Profile/Delay/Edit/DelayProfileEditViewTemplate.hbs +++ /dev/null @@ -1,80 +0,0 @@ -<div class="modal-content"> - <div class="modal-header"> - <button type="button" class="close" aria-hidden="true" data-dismiss="modal">×</button> - {{#if id}} - <h3>Edit - Delay Profile</h3> - {{else}} - <h3>Add - Delay Profile</h3> - {{/if}} - </div> - <div class="modal-body indexer-modal"> - <div class="form-horizontal"> - <div class="form-group"> - <label class="col-sm-3 control-label">Protocol</label> - - <div class="col-sm-1 col-sm-push-5 help-inline"> - <i class="icon-sonarr-form-info" title="Choose which protocol(s) to use and which one is preferred when choosing between otherwise equal releases" /> - </div> - - <div class="col-sm-5 col-sm-pull-1"> - <select class="form-control x-protocol"> - <option value="preferUsenet">Prefer Usenet</option> - <option value="preferTorrent">Prefer Torrent</option> - <option value="onlyUsenet">Only Usenet</option> - <option value="onlyTorrent">Only Torrent</option> - </select> - </div> - </div> - - <div class="form-group x-usenet-delay"> - <label class="col-sm-3 control-label">Usenet Delay</label> - - <div class="col-sm-1 col-sm-push-5 help-inline"> - <i class="icon-sonarr-form-info" title="Delay in minutes to wait before grabbing a release from Usenet" /> - </div> - - <div class="col-sm-5 col-sm-pull-1"> - <input type="number" class="form-control" name="usenetDelay"/> - </div> - </div> - - <div class="form-group x-torrent-delay"> - <label class="col-sm-3 control-label">Torrent Delay</label> - - <div class="col-sm-1 col-sm-push-5 help-inline"> - <i class="icon-sonarr-form-info" title="Delay in minutes to wait before grabbing a torrent" /> - </div> - - <div class="col-sm-5 col-sm-pull-1"> - <input type="number" class="form-control" name="torrentDelay"/> - </div> - </div> - - {{#if_eq id compare="1"}} - <div class="alert alert-info" role="alert">This is the default profile. It applies to all series that don't have an explicit profile.</div> - {{else}} - <div class="form-group"> - <label class="col-sm-3 control-label">Tags</label> - - <div class="col-sm-1 col-sm-push-5 help-inline"> - <i class="icon-sonarr-form-info" title="One or more tags to apply these rules to matching series" /> - </div> - - <div class="col-sm-5 col-sm-pull-1"> - <input type="text" class="form-control x-tags"> - </div> - </div> - {{/if_eq}} - </div> - </div> - <div class="modal-footer"> - {{#if id}} - {{#if_gt id compare="1"}} - <button class="btn btn-danger pull-left x-delete">Delete</button> - {{/if_gt}} - {{/if}} - <span class="indicator x-indicator"><i class="icon-sonarr-spinner fa-spin"></i></span> - <button class="btn" data-dismiss="modal">Cancel</button> - <button class="btn btn-primary x-save">Save</button> - </div> -</div> diff --git a/src/UI/Settings/Profile/DeleteProfileView.js b/src/UI/Settings/Profile/DeleteProfileView.js deleted file mode 100644 index 4b91e0e07..000000000 --- a/src/UI/Settings/Profile/DeleteProfileView.js +++ /dev/null @@ -1,16 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'Settings/Profile/DeleteProfileViewTemplate', - - events : { - 'click .x-confirm-delete' : '_removeProfile' - }, - - _removeProfile : function() { - this.model.destroy({ wait : true }).done(function() { - vent.trigger(vent.Commands.CloseModalCommand); - }); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/Profile/DeleteProfileViewTemplate.hbs b/src/UI/Settings/Profile/DeleteProfileViewTemplate.hbs deleted file mode 100644 index c9a826855..000000000 --- a/src/UI/Settings/Profile/DeleteProfileViewTemplate.hbs +++ /dev/null @@ -1,13 +0,0 @@ -<div class="modal-content"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - <h3>Delete: {{name}}</h3> - </div> - <div class="modal-body"> - <p>Are you sure you want to delete '{{name}}'?</p> - </div> - <div class="modal-footer"> - <button class="btn" data-dismiss="modal">Cancel</button> - <button class="btn btn-danger x-confirm-delete">Delete</button> - </div> -</div> diff --git a/src/UI/Settings/Profile/Edit/EditProfileItemView.js b/src/UI/Settings/Profile/Edit/EditProfileItemView.js deleted file mode 100644 index 535fff211..000000000 --- a/src/UI/Settings/Profile/Edit/EditProfileItemView.js +++ /dev/null @@ -1,5 +0,0 @@ -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'Settings/Profile/Edit/EditProfileItemViewTemplate' -}); \ No newline at end of file diff --git a/src/UI/Settings/Profile/Edit/EditProfileItemViewTemplate.hbs b/src/UI/Settings/Profile/Edit/EditProfileItemViewTemplate.hbs deleted file mode 100644 index 85289bf22..000000000 --- a/src/UI/Settings/Profile/Edit/EditProfileItemViewTemplate.hbs +++ /dev/null @@ -1,3 +0,0 @@ -<i class="select-handle pull-left x-select" /> -<span class="quality-label">{{quality.name}}</span> -<i class="drag-handle pull-right icon-sonarr-reorder advanced-setting x-drag-handle" /> diff --git a/src/UI/Settings/Profile/Edit/EditProfileLayout.js b/src/UI/Settings/Profile/Edit/EditProfileLayout.js deleted file mode 100644 index 0eb0789d5..000000000 --- a/src/UI/Settings/Profile/Edit/EditProfileLayout.js +++ /dev/null @@ -1,117 +0,0 @@ -var _ = require('underscore'); -var vent = require('vent'); -var AppLayout = require('../../../AppLayout'); -var Marionette = require('marionette'); -var Backbone = require('backbone'); -var EditProfileItemView = require('./EditProfileItemView'); -var QualitySortableCollectionView = require('./QualitySortableCollectionView'); -var EditProfileView = require('./EditProfileView'); -var DeleteView = require('../DeleteProfileView'); -var SeriesCollection = require('../../../Series/SeriesCollection'); -var Config = require('../../../Config'); -var AsEditModalView = require('../../../Mixins/AsEditModalView'); - -var view = Marionette.Layout.extend({ - template : 'Settings/Profile/Edit/EditProfileLayoutTemplate', - - regions : { - fields : '#x-fields', - qualities : '#x-qualities' - }, - - ui : { - deleteButton : '.x-delete' - }, - - _deleteView : DeleteView, - - initialize : function(options) { - this.profileCollection = options.profileCollection; - this.itemsCollection = new Backbone.Collection(_.toArray(this.model.get('items')).reverse()); - this.listenTo(SeriesCollection, 'all', this._updateDisableStatus); - }, - - onRender : function() { - this._updateDisableStatus(); - }, - - onShow : function() { - this.fieldsView = new EditProfileView({ model : this.model }); - this._showFieldsView(); - var advancedShown = Config.getValueBoolean(Config.Keys.AdvancedSettings, false); - - this.sortableListView = new QualitySortableCollectionView({ - selectable : true, - selectMultiple : true, - clickToSelect : true, - clickToToggle : true, - sortable : advancedShown, - - sortableOptions : { - handle : '.x-drag-handle' - }, - - visibleModelsFilter : function(model) { - return model.get('quality').id !== 0 || advancedShown; - }, - - collection : this.itemsCollection, - model : this.model - }); - - this.sortableListView.setSelectedModels(this.itemsCollection.filter(function(item) { - return item.get('allowed') === true; - })); - this.qualities.show(this.sortableListView); - - this.listenTo(this.sortableListView, 'selectionChanged', this._selectionChanged); - this.listenTo(this.sortableListView, 'sortStop', this._updateModel); - }, - - _onBeforeSave : function() { - var cutoff = this.fieldsView.getCutoff(); - this.model.set('cutoff', cutoff); - }, - - _onAfterSave : function() { - this.profileCollection.add(this.model, { merge : true }); - vent.trigger(vent.Commands.CloseModalCommand); - }, - - _selectionChanged : function(newSelectedModels, oldSelectedModels) { - var addedModels = _.difference(newSelectedModels, oldSelectedModels); - var removeModels = _.difference(oldSelectedModels, newSelectedModels); - - _.each(removeModels, function(item) { - item.set('allowed', false); - }); - _.each(addedModels, function(item) { - item.set('allowed', true); - }); - this._updateModel(); - }, - - _updateModel : function() { - this.model.set('items', this.itemsCollection.toJSON().reverse()); - - this._showFieldsView(); - }, - - _showFieldsView : function() { - this.fields.show(this.fieldsView); - }, - - _updateDisableStatus : function() { - if (this._isQualityInUse()) { - this.ui.deleteButton.addClass('disabled'); - this.ui.deleteButton.attr('title', 'Can\'t delete a profile that is attached to a series.'); - } else { - this.ui.deleteButton.removeClass('disabled'); - } - }, - - _isQualityInUse : function() { - return SeriesCollection.where({ 'profileId' : this.model.id }).length !== 0; - } -}); -module.exports = AsEditModalView.call(view); diff --git a/src/UI/Settings/Profile/Edit/EditProfileLayoutTemplate.hbs b/src/UI/Settings/Profile/Edit/EditProfileLayoutTemplate.hbs deleted file mode 100644 index 19ea12f77..000000000 --- a/src/UI/Settings/Profile/Edit/EditProfileLayoutTemplate.hbs +++ /dev/null @@ -1,36 +0,0 @@ -<div class="modal-content"> - <div class="modal-header"> - <button type="button" class="close" aria-hidden="true" data-dismiss="modal">×</button> - {{#if id}} - <h3>Edit</h3> - {{else}} - <h3>Add</h3> - {{/if}} -</div> - <div class="modal-body"> - <div class="form-horizontal"> - <div id="x-fields"></div> - <div class="form-group"> - <label class="col-sm-3 control-label">Qualities</label> - - <div class="col-sm-5"> - <div class="controls qualities-controls"> - <span id="x-qualities"></span> - </div> - </div> - - <div class="col-sm-1 help-inline"> - <i class="icon-sonarr-form-info" title="Qualities higher in the list are more preferred. Only checked qualities will be wanted."/> - </div> - </div> - </div> - </div> - <div class="modal-footer"> - {{#if id}} - <button class="btn btn-danger pull-left x-delete">Delete</button> - {{/if}} - <span class="indicator x-indicator"><i class="icon-sonarr-spinner fa-spin"></i></span> - <button class="btn" data-dismiss="modal">Cancel</button> - <button class="btn btn-primary x-save">Save</button> - </div> -</div> diff --git a/src/UI/Settings/Profile/Edit/EditProfileView.js b/src/UI/Settings/Profile/Edit/EditProfileView.js deleted file mode 100644 index 23535d9e6..000000000 --- a/src/UI/Settings/Profile/Edit/EditProfileView.js +++ /dev/null @@ -1,28 +0,0 @@ -var _ = require('underscore'); -var Marionette = require('marionette'); -var LanguageCollection = require('../Language/LanguageCollection'); -var Config = require('../../../Config'); -var AsModelBoundView = require('../../../Mixins/AsModelBoundView'); -var AsValidatedView = require('../../../Mixins/AsValidatedView'); - -var view = Marionette.ItemView.extend({ - template : 'Settings/Profile/Edit/EditProfileViewTemplate', - - ui : { cutoff : '.x-cutoff' }, - - templateHelpers : function() { - return { - languages : LanguageCollection.toJSON() - }; - }, - - getCutoff : function() { - var self = this; - - return _.findWhere(_.pluck(this.model.get('items'), 'quality'), { id : parseInt(self.ui.cutoff.val(), 10) }); - } -}); - -AsValidatedView.call(view); - -module.exports = AsModelBoundView.call(view); \ No newline at end of file diff --git a/src/UI/Settings/Profile/Edit/EditProfileViewTemplate.hbs b/src/UI/Settings/Profile/Edit/EditProfileViewTemplate.hbs deleted file mode 100644 index cae0f2447..000000000 --- a/src/UI/Settings/Profile/Edit/EditProfileViewTemplate.hbs +++ /dev/null @@ -1,45 +0,0 @@ -<div class="form-group"> - <label class="col-sm-3 control-label">Name</label> - - <div class="col-sm-5"> - <input type="text" name="name" class="form-control"> - </div> -</div> - -<hr> - -<div class="form-group"> - <label class="col-sm-3 control-label">Language</label> - - <div class="col-sm-5"> - <select class="form-control" name="language"> - {{#each languages}} - {{#unless_eq nameLower compare="unknown"}} - <option value="{{nameLower}}">{{name}}</option> - {{/unless_eq}} - {{/each}} - </select> - </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"/> - </div> -</div> - -<div class="form-group"> - <label class="col-sm-3 control-label">Cutoff</label> - - <div class="col-sm-5"> - <select class="form-control x-cutoff" name="cutoff.id" validation-name="cutoff"> - {{#eachReverse items}} - {{#if allowed}} - <option value="{{quality.id}}">{{quality.name}}</option> - {{/if}} - {{/eachReverse}} - </select> - </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"/> - </div> -</div> diff --git a/src/UI/Settings/Profile/Edit/QualitySortableCollectionView.js b/src/UI/Settings/Profile/Edit/QualitySortableCollectionView.js deleted file mode 100644 index 6fc6253aa..000000000 --- a/src/UI/Settings/Profile/Edit/QualitySortableCollectionView.js +++ /dev/null @@ -1,17 +0,0 @@ -var BackboneSortableCollectionView = require('backbone.collectionview'); -var EditProfileItemView = require('./EditProfileItemView'); - -module.exports = BackboneSortableCollectionView.extend({ - className : 'qualities', - modelView : EditProfileItemView, - - attributes : { - 'validation-name' : 'items' - }, - - events : { - 'click li, td' : '_listItem_onMousedown', - 'dblclick li, td' : '_listItem_onDoubleClick', - 'keydown' : '_onKeydown' - } -}); \ No newline at end of file diff --git a/src/UI/Settings/Profile/Language/LanguageCollection.js b/src/UI/Settings/Profile/Language/LanguageCollection.js deleted file mode 100644 index d016c0441..000000000 --- a/src/UI/Settings/Profile/Language/LanguageCollection.js +++ /dev/null @@ -1,12 +0,0 @@ -var Backbone = require('backbone'); -var LanguageModel = require('./LanguageModel'); - -var LanuageCollection = Backbone.Collection.extend({ - model : LanguageModel, - url : window.NzbDrone.ApiRoot + '/language' -}); - -var languages = new LanuageCollection(); -languages.fetch(); - -module.exports = languages; \ No newline at end of file diff --git a/src/UI/Settings/Profile/Language/LanguageModel.js b/src/UI/Settings/Profile/Language/LanguageModel.js deleted file mode 100644 index 3986a5948..000000000 --- a/src/UI/Settings/Profile/Language/LanguageModel.js +++ /dev/null @@ -1,3 +0,0 @@ -var Backbone = require('backbone'); - -module.exports = Backbone.Model.extend({}); \ No newline at end of file diff --git a/src/UI/Settings/Profile/LanguageLabel.js b/src/UI/Settings/Profile/LanguageLabel.js deleted file mode 100644 index b162d5683..000000000 --- a/src/UI/Settings/Profile/LanguageLabel.js +++ /dev/null @@ -1,15 +0,0 @@ -var _ = require('underscore'); -var Handlebars = require('handlebars'); -var LanguageCollection = require('./Language/LanguageCollection'); - -Handlebars.registerHelper('languageLabel', function() { - var wantedLanguage = this.language; - - var language = LanguageCollection.find(function(lang) { - return lang.get('nameLower') === wantedLanguage; - }); - - var result = '<span class="label label-primary">' + language.get('name') + '</span>'; - - return new Handlebars.SafeString(result); -}); \ No newline at end of file diff --git a/src/UI/Settings/Profile/ProfileCollectionTemplate.hbs b/src/UI/Settings/Profile/ProfileCollectionTemplate.hbs deleted file mode 100644 index 11e1d6711..000000000 --- a/src/UI/Settings/Profile/ProfileCollectionTemplate.hbs +++ /dev/null @@ -1,16 +0,0 @@ -<fieldset> - <legend>Profiles</legend> - <div class="row"> - <div class="col-md-12"> - <ul class="profiles thingies"> - <li> - <div class="profile-item thingy add-card x-add-card"> - <span class="center well"> - <i class="icon-sonarr-add"/> - </span> - </div> - </li> - </ul> - </div> - </div> -</fieldset> \ No newline at end of file diff --git a/src/UI/Settings/Profile/ProfileCollectionView.js b/src/UI/Settings/Profile/ProfileCollectionView.js deleted file mode 100644 index 1f96fc44f..000000000 --- a/src/UI/Settings/Profile/ProfileCollectionView.js +++ /dev/null @@ -1,43 +0,0 @@ -var AppLayout = require('../../AppLayout'); -var Marionette = require('marionette'); -var ProfileView = require('./ProfileView'); -var EditProfileView = require('./Edit/EditProfileLayout'); -var ProfileCollection = require('./ProfileSchemaCollection'); -var _ = require('underscore'); - -module.exports = Marionette.CompositeView.extend({ - itemView : ProfileView, - itemViewContainer : '.profiles', - template : 'Settings/Profile/ProfileCollectionTemplate', - - ui : { - 'addCard' : '.x-add-card' - }, - - events : { - 'click .x-add-card' : '_addProfile' - }, - - appendHtml : function(collectionView, itemView, index) { - collectionView.ui.addCard.parent('li').before(itemView.el); - }, - - _addProfile : function() { - var self = this; - var schemaCollection = new ProfileCollection(); - schemaCollection.fetch({ - success : function(collection) { - var model = _.first(collection.models); - model.set('id', undefined); - model.set('name', ''); - model.collection = self.collection; - var view = new EditProfileView({ - model : model, - profileCollection : self.collection - }); - - AppLayout.modalRegion.show(view); - } - }); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/Profile/ProfileLayout.js b/src/UI/Settings/Profile/ProfileLayout.js deleted file mode 100644 index d8f226271..000000000 --- a/src/UI/Settings/Profile/ProfileLayout.js +++ /dev/null @@ -1,28 +0,0 @@ -var Marionette = require('marionette'); -var ProfileCollection = require('../../Profile/ProfileCollection'); -var ProfileCollectionView = require('./ProfileCollectionView'); -var DelayProfileLayout = require('./Delay/DelayProfileLayout'); -var DelayProfileCollection = require('./Delay/DelayProfileCollection'); -var LanguageCollection = require('./Language/LanguageCollection'); - -module.exports = Marionette.Layout.extend({ - template : 'Settings/Profile/ProfileLayoutTemplate', - - regions : { - profile : '#profile', - delayProfile : '#delay-profile' - }, - - initialize : function(options) { - this.settings = options.settings; - ProfileCollection.fetch(); - - this.delayProfileCollection = new DelayProfileCollection(); - this.delayProfileCollection.fetch(); - }, - - onShow : function() { - this.profile.show(new ProfileCollectionView({ collection : ProfileCollection })); - this.delayProfile.show(new DelayProfileLayout({ collection : this.delayProfileCollection })); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/Profile/ProfileLayoutTemplate.hbs b/src/UI/Settings/Profile/ProfileLayoutTemplate.hbs deleted file mode 100644 index 99adeab97..000000000 --- a/src/UI/Settings/Profile/ProfileLayoutTemplate.hbs +++ /dev/null @@ -1,5 +0,0 @@ -<div class="row"> - <div class="col-md-12" id="profile"/> - - <div class="col-md-12 delay-profile-region" id="delay-profile"/> -</div> diff --git a/src/UI/Settings/Profile/ProfileSchemaCollection.js b/src/UI/Settings/Profile/ProfileSchemaCollection.js deleted file mode 100644 index 6f32ff2e8..000000000 --- a/src/UI/Settings/Profile/ProfileSchemaCollection.js +++ /dev/null @@ -1,7 +0,0 @@ -var Backbone = require('backbone'); -var ProfileModel = require('../../Profile/ProfileModel'); - -module.exports = Backbone.Collection.extend({ - model : ProfileModel, - url : window.NzbDrone.ApiRoot + '/profile/schema' -}); \ No newline at end of file diff --git a/src/UI/Settings/Profile/ProfileView.js b/src/UI/Settings/Profile/ProfileView.js deleted file mode 100644 index 4241c3f12..000000000 --- a/src/UI/Settings/Profile/ProfileView.js +++ /dev/null @@ -1,35 +0,0 @@ -var AppLayout = require('../../AppLayout'); -var Marionette = require('marionette'); -var EditProfileView = require('./Edit/EditProfileLayout'); -var AsModelBoundView = require('../../Mixins/AsModelBoundView'); -require('./AllowedLabeler'); -require('./LanguageLabel'); -require('bootstrap'); - -var view = Marionette.ItemView.extend({ - template : 'Settings/Profile/ProfileViewTemplate', - tagName : 'li', - - ui : { - "progressbar" : '.progress .bar', - "deleteButton" : '.x-delete' - }, - - events : { - 'click' : '_editProfile' - }, - - initialize : function() { - this.listenTo(this.model, 'sync', this.render); - }, - - _editProfile : function() { - var view = new EditProfileView({ - model : this.model, - profileCollection : this.model.collection - }); - AppLayout.modalRegion.show(view); - } -}); - -module.exports = AsModelBoundView.call(view); \ No newline at end of file diff --git a/src/UI/Settings/Profile/ProfileViewTemplate.hbs b/src/UI/Settings/Profile/ProfileViewTemplate.hbs deleted file mode 100644 index 4f5b3eef0..000000000 --- a/src/UI/Settings/Profile/ProfileViewTemplate.hbs +++ /dev/null @@ -1,13 +0,0 @@ -<div class="profile-item thingy"> - <div> - <h3 name="name"></h3> - </div> - - <div class="language"> - {{languageLabel}} - </div> - - <ul class="allowed-qualities"> - {{allowedLabeler}} - </ul> -</div> \ No newline at end of file diff --git a/src/UI/Settings/Profile/profile.less b/src/UI/Settings/Profile/profile.less deleted file mode 100644 index df217a398..000000000 --- a/src/UI/Settings/Profile/profile.less +++ /dev/null @@ -1,43 +0,0 @@ -@import "../../Content/Bootstrap/mixins"; -@import "../../Content/FontAwesome/font-awesome"; -@import "../../Shared/Styles/clickable.less"; - -.profile-item { - .clickable; - - width: 300px; - height: 158px; - padding: 10px 15px; - - &.add-card { - .center { - margin-top: 30px; - } - } - - .allowed-qualities { - - padding-left: 0px; - - li { - list-style-type : none; - margin: 1px; - } - } - - .language { - margin-bottom: 3px; - } -} - -.delay-profile-region { - margin-top : 30px; -} - -.delay-profiles { - padding-left : 0px; - - li { - list-style-type : none; - } -} \ No newline at end of file diff --git a/src/UI/Settings/ProviderSettingsModelBase.js b/src/UI/Settings/ProviderSettingsModelBase.js deleted file mode 100644 index 674aba4e5..000000000 --- a/src/UI/Settings/ProviderSettingsModelBase.js +++ /dev/null @@ -1,71 +0,0 @@ -var $ = require('jquery'); -var _ = require('underscore'); -var DeepModel = require('backbone.deepmodel'); -var Messenger = require('../Shared/Messenger'); - -module.exports = DeepModel.extend({ - - getFieldValue : function(name) { - var index = _.indexOf(_.pluck(this.get('fields'), 'name'), name); - return this.get('fields.' + index + '.value'); - }, - - setFieldValue : function(name, value) { - var index = _.indexOf(_.pluck(this.get('fields'), 'name'), name); - return this.set('fields.' + index + '.value', value); - }, - - requestAction : function(action, queryParams) { - var self = this; - - this.trigger('validation:sync'); - - var params = { - url : this.collection.url + '/action/' + action, - contentType : 'application/json', - data : JSON.stringify(this.toJSON()), - type : 'POST', - isValidatedCall : true - }; - - if (queryParams) { - params.url += '?' + $.param(queryParams, true); - } - - var promise = $.ajax(params); - - promise.fail(function(response) { - self.trigger('validation:failed', response); - }); - - return promise; - }, - - test : function() { - var self = this; - - this.trigger('validation:sync'); - - var params = {}; - - params.url = this.collection.url + '/test'; - params.contentType = 'application/json'; - params.data = JSON.stringify(this.toJSON()); - params.type = 'POST'; - params.isValidatedCall = true; - - var promise = $.ajax(params); - - Messenger.monitor({ - promise : promise, - successMessage : 'Testing \'{0}\' succeeded'.format(this.get('name')), - errorMessage : 'Testing \'{0}\' failed'.format(this.get('name')) - }); - - promise.fail(function(response) { - self.trigger('validation:failed', response); - }); - - return promise; - } -}); \ No newline at end of file diff --git a/src/UI/Settings/Quality/Definition/QualityDefinitionCollectionTemplate.hbs b/src/UI/Settings/Quality/Definition/QualityDefinitionCollectionTemplate.hbs deleted file mode 100644 index ac514ba90..000000000 --- a/src/UI/Settings/Quality/Definition/QualityDefinitionCollectionTemplate.hbs +++ /dev/null @@ -1,16 +0,0 @@ -<fieldset> - <legend>Quality Definitions</legend> - <div class="col-md-11"> - <div id="quality-definition-list"> - <div class="quality-header x-header hidden-xs"> - <div class="row"> - <span class="col-md-2 col-sm-3">Quality</span> - <span class="col-md-2 col-sm-3">Title</span> - <span class="col-md-4 col-sm-6">Size Limit <i class="icon-sonarr-info" title="Limits are automatically adjusted for the series runtime and number of episodes in the file." /></span> - </div> - </div> - <div class="rows x-rows"> - </div> - </div> - </div> -</fieldset> diff --git a/src/UI/Settings/Quality/Definition/QualityDefinitionCollectionView.js b/src/UI/Settings/Quality/Definition/QualityDefinitionCollectionView.js deleted file mode 100644 index be2743d5b..000000000 --- a/src/UI/Settings/Quality/Definition/QualityDefinitionCollectionView.js +++ /dev/null @@ -1,10 +0,0 @@ -var Marionette = require('marionette'); -var QualityDefinitionItemView = require('./QualityDefinitionItemView'); - -module.exports = Marionette.CompositeView.extend({ - template : 'Settings/Quality/Definition/QualityDefinitionCollectionTemplate', - - itemViewContainer : '.x-rows', - - itemView : QualityDefinitionItemView -}); \ No newline at end of file diff --git a/src/UI/Settings/Quality/Definition/QualityDefinitionItemView.js b/src/UI/Settings/Quality/Definition/QualityDefinitionItemView.js deleted file mode 100644 index b663cf310..000000000 --- a/src/UI/Settings/Quality/Definition/QualityDefinitionItemView.js +++ /dev/null @@ -1,95 +0,0 @@ -var Marionette = require('marionette'); -var AsModelBoundView = require('../../../Mixins/AsModelBoundView'); -require('jquery-ui'); -var FormatHelpers = require('../../../Shared/FormatHelpers'); - -var view = Marionette.ItemView.extend({ - template : 'Settings/Quality/Definition/QualityDefinitionItemViewTemplate', - className : 'row', - - slider : { - min : 0, - max : 200, - step : 0.1 - }, - - ui : { - sizeSlider : '.x-slider', - thirtyMinuteMinSize : '.x-min-thirty', - sixtyMinuteMinSize : '.x-min-sixty', - thirtyMinuteMaxSize : '.x-max-thirty', - sixtyMinuteMaxSize : '.x-max-sixty' - }, - - events : { - 'slide .x-slider' : '_updateSize' - }, - - initialize : function(options) { - this.profileCollection = options.profiles; - }, - - onRender : function() { - if (this.model.get('quality').id === 0) { - this.$el.addClass('row advanced-setting'); - } - - this.ui.sizeSlider.slider({ - range : true, - min : this.slider.min, - max : this.slider.max, - step : this.slider.step, - values : [ - this.model.get('minSize') || this.slider.min, - this.model.get('maxSize') || this.slider.max - ] - }); - - this._changeSize(); - }, - - _updateSize : function(event, ui) { - var minSize = ui.values[0]; - var maxSize = ui.values[1]; - - if (maxSize === this.slider.max) { - maxSize = null; - } - - this.model.set('minSize', minSize); - this.model.set('maxSize', maxSize); - - this._changeSize(); - }, - - _changeSize : function() { - var minSize = this.model.get('minSize') || this.slider.min; - var maxSize = this.model.get('maxSize') || null; - { - var minBytes = minSize * 1024 * 1024; - var minThirty = FormatHelpers.bytes(minBytes * 30, 2); - var minSixty = FormatHelpers.bytes(minBytes * 60, 2); - - this.ui.thirtyMinuteMinSize.html(minThirty); - this.ui.sixtyMinuteMinSize.html(minSixty); - } - - { - if (maxSize === 0 || maxSize === null) { - this.ui.thirtyMinuteMaxSize.html('Unlimited'); - this.ui.sixtyMinuteMaxSize.html('Unlimited'); - } else { - var maxBytes = maxSize * 1024 * 1024; - var maxThirty = FormatHelpers.bytes(maxBytes * 30, 2); - var maxSixty = FormatHelpers.bytes(maxBytes * 60, 2); - - this.ui.thirtyMinuteMaxSize.html(maxThirty); - this.ui.sixtyMinuteMaxSize.html(maxSixty); - } - } - } -}); - -view = AsModelBoundView.call(view); - -module.exports = view; \ No newline at end of file diff --git a/src/UI/Settings/Quality/Definition/QualityDefinitionItemViewTemplate.hbs b/src/UI/Settings/Quality/Definition/QualityDefinitionItemViewTemplate.hbs deleted file mode 100644 index 39b94b650..000000000 --- a/src/UI/Settings/Quality/Definition/QualityDefinitionItemViewTemplate.hbs +++ /dev/null @@ -1,31 +0,0 @@ - <span class="col-md-2 col-sm-3"> - {{quality.name}} - </span> - <span class="col-md-2 col-sm-3"> - <input type="text" class="form-control" name="title"> - </span> - <span class="col-md-4 col-sm-6"> - <div class="x-slider"></div> - <div class="size-label-wrapper"> - <div class="pull-left"> - <span class="label label-warning x-min-thirty" - name="thirtyMinuteMinSize" - title="Minimum size for a 30 minute episode"> - </span> - <span class="label label-info x-min-sixty" - name="sixtyMinuteMinSize" - title="Minimum size for a 60 minute episode"> - </span> - </div> - <div class="pull-right"> - <span class="label label-warning x-max-thirty" - name="thirtyMinuteMaxSize" - title="Maximum size for a 30 minute episode"> - </span> - <span class="label label-info x-max-sixty" - name="sixtyMinuteMaxSize" - title="Maximum size for a 60 minute episode"> - </span> - </div> - </div> - </span> \ No newline at end of file diff --git a/src/UI/Settings/Quality/QualityLayout.js b/src/UI/Settings/Quality/QualityLayout.js deleted file mode 100644 index e93ca1854..000000000 --- a/src/UI/Settings/Quality/QualityLayout.js +++ /dev/null @@ -1,21 +0,0 @@ -var Marionette = require('marionette'); -var QualityDefinitionCollection = require('../../Quality/QualityDefinitionCollection'); -var QualityDefinitionCollectionView = require('./Definition/QualityDefinitionCollectionView'); - -module.exports = Marionette.Layout.extend({ - template : 'Settings/Quality/QualityLayoutTemplate', - - regions : { - qualityDefinition : '#quality-definition' - }, - - initialize : function(options) { - this.settings = options.settings; - this.qualityDefinitionCollection = new QualityDefinitionCollection(); - this.qualityDefinitionCollection.fetch(); - }, - - onShow : function() { - this.qualityDefinition.show(new QualityDefinitionCollectionView({ collection : this.qualityDefinitionCollection })); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/Quality/QualityLayoutTemplate.hbs b/src/UI/Settings/Quality/QualityLayoutTemplate.hbs deleted file mode 100644 index a12f1926a..000000000 --- a/src/UI/Settings/Quality/QualityLayoutTemplate.hbs +++ /dev/null @@ -1,3 +0,0 @@ -<div class="row"> - <div class="col-md-12" id="quality-definition"/> -</div> diff --git a/src/UI/Settings/Quality/quality.less b/src/UI/Settings/Quality/quality.less deleted file mode 100644 index 23732cdfd..000000000 --- a/src/UI/Settings/Quality/quality.less +++ /dev/null @@ -1,135 +0,0 @@ -@import "../../Content/Bootstrap/mixins"; -@import (reference) "../../Content/icons"; -@import "../../Shared/Styles/clickable.less"; - -ul.qualities { - .user-select(none); - - min-height: 100px; - margin: 0; - padding: 0; - list-style-type: none; - outline: none; - width: 220px; - display: inline-block; - - li { - margin: 2px; - padding: 2px 4px; - line-height: 20px; - border: 1px solid #aaaaaa; - border-radius: 4px; /* may need vendor varients */ - background: #fafafa; - cursor: pointer; - - &.selected { - .select-handle { - opacity: 1.0; - cursor: pointer; - } - - .quality-label { - color: #444444; - } - - .select-handle { - .fa-icon-content(@fa-var-check-square-o); - } - } - - &:hover { - border-color: #888888; - background: #eeeeee; - - .drag-handle { - opacity: 1.0; - cursor: move; - } - } - - .quality-label { - color: #c6c6c6; - } - - .drag-handle, .select-handle { - opacity: 0.2; - line-height: 20px; - cursor: pointer; - } - - .select-handle { - .fa-icon-content(@fa-var-square-o); - - &:before { - display : inline-block; - width : 14px; - margin-top : 3px; - } - } - } -} - -.qualities-controls { - .help-inline { - vertical-align: top; - margin-top: 5px; - } -} - -#quality-definition-list { - - .quality-header .row { - font-weight: bold; - line-height: 40px; - } - - .rows .row { - line-height: 30px; - border-top: 1px solid #ddd; - vertical-align: middle; - padding: 5px; - - input { - margin-bottom: 0px; - } - - .size-label-wrapper { - line-height: 20px; - } - - .label { - min-width: 70px; - text-align: center; - margin: 0px 1px; - padding: 1px 4px; - } - - .ui-slider { - position: relative; - text-align: left; - background-color: #f5f5f5; - border-radius: 3px; - border: 1px solid #ccc; - height: 8px; - - .ui-slider-range { - position: absolute; - display: block; - background-color: #ddd; - height: 100%; - } - - .ui-slider-handle { - position: absolute; - z-index: 2; - width: 6px; - height: 12px; - cursor: default; - background-color: #ccc; - border: 1px solid #aaa; - border-radius: 3px; - top: -3px; - } - } - } -} diff --git a/src/UI/Settings/SettingsLayout.js b/src/UI/Settings/SettingsLayout.js deleted file mode 100644 index 429d702cd..000000000 --- a/src/UI/Settings/SettingsLayout.js +++ /dev/null @@ -1,252 +0,0 @@ -var $ = require('jquery'); -var _ = require('underscore'); -var vent = require('vent'); -var Marionette = require('marionette'); -var Backbone = require('backbone'); -var GeneralSettingsModel = require('./General/GeneralSettingsModel'); -var NamingModel = require('./MediaManagement/Naming/NamingModel'); -var MediaManagementLayout = require('./MediaManagement/MediaManagementLayout'); -var MediaManagementSettingsModel = require('./MediaManagement/MediaManagementSettingsModel'); -var ProfileLayout = require('./Profile/ProfileLayout'); -var QualityLayout = require('./Quality/QualityLayout'); -var IndexerLayout = require('./Indexers/IndexerLayout'); -var IndexerCollection = require('./Indexers/IndexerCollection'); -var IndexerSettingsModel = require('./Indexers/IndexerSettingsModel'); -var DownloadClientLayout = require('./DownloadClient/DownloadClientLayout'); -var DownloadClientSettingsModel = require('./DownloadClient/DownloadClientSettingsModel'); -var NotificationCollectionView = require('./Notifications/NotificationCollectionView'); -var NotificationCollection = require('./Notifications/NotificationCollection'); -var MetadataLayout = require('./Metadata/MetadataLayout'); -var GeneralView = require('./General/GeneralView'); -var UiView = require('./UI/UiView'); -var UiSettingsModel = require('./UI/UiSettingsModel'); -var LoadingView = require('../Shared/LoadingView'); -var Config = require('../Config'); - -module.exports = Marionette.Layout.extend({ - template : 'Settings/SettingsLayoutTemplate', - - regions : { - mediaManagement : '#media-management', - profiles : '#profiles', - quality : '#quality', - indexers : '#indexers', - downloadClient : '#download-client', - notifications : '#notifications', - metadata : '#metadata', - general : '#general', - uiRegion : '#ui', - loading : '#loading-region' - }, - - ui : { - mediaManagementTab : '.x-media-management-tab', - profilesTab : '.x-profiles-tab', - qualityTab : '.x-quality-tab', - indexersTab : '.x-indexers-tab', - downloadClientTab : '.x-download-client-tab', - notificationsTab : '.x-notifications-tab', - metadataTab : '.x-metadata-tab', - generalTab : '.x-general-tab', - uiTab : '.x-ui-tab', - advancedSettings : '.x-advanced-settings' - }, - - events : { - 'click .x-media-management-tab' : '_showMediaManagement', - 'click .x-profiles-tab' : '_showProfiles', - 'click .x-quality-tab' : '_showQuality', - 'click .x-indexers-tab' : '_showIndexers', - 'click .x-download-client-tab' : '_showDownloadClient', - 'click .x-notifications-tab' : '_showNotifications', - 'click .x-metadata-tab' : '_showMetadata', - 'click .x-general-tab' : '_showGeneral', - 'click .x-ui-tab' : '_showUi', - 'click .x-save-settings' : '_save', - 'change .x-advanced-settings' : '_toggleAdvancedSettings' - }, - - initialize : function(options) { - if (options.action) { - this.action = options.action.toLowerCase(); - } - - this.listenTo(vent, vent.Hotkeys.SaveSettings, this._save); - }, - - onRender : function() { - this.loading.show(new LoadingView()); - var self = this; - - this.mediaManagementSettings = new MediaManagementSettingsModel(); - this.namingSettings = new NamingModel(); - this.indexerSettings = new IndexerSettingsModel(); - this.downloadClientSettings = new DownloadClientSettingsModel(); - this.notificationCollection = new NotificationCollection(); - this.generalSettings = new GeneralSettingsModel(); - this.uiSettings = new UiSettingsModel(); - Backbone.$.when(this.mediaManagementSettings.fetch(), this.namingSettings.fetch(), this.indexerSettings.fetch(), this.downloadClientSettings.fetch(), - this.notificationCollection.fetch(), this.generalSettings.fetch(), this.uiSettings.fetch()).done(function() { - if (!self.isClosed) { - self.loading.$el.hide(); - self.mediaManagement.show(new MediaManagementLayout({ - settings : self.mediaManagementSettings, - namingSettings : self.namingSettings - })); - self.profiles.show(new ProfileLayout()); - self.quality.show(new QualityLayout()); - self.indexers.show(new IndexerLayout({ model : self.indexerSettings })); - self.downloadClient.show(new DownloadClientLayout({ model : self.downloadClientSettings })); - self.notifications.show(new NotificationCollectionView({ collection : self.notificationCollection })); - self.metadata.show(new MetadataLayout()); - self.general.show(new GeneralView({ model : self.generalSettings })); - self.uiRegion.show(new UiView({ model : self.uiSettings })); - } - }); - - this._setAdvancedSettingsState(); - }, - - onShow : function() { - switch (this.action) { - case 'profiles': - this._showProfiles(); - break; - case 'quality': - this._showQuality(); - break; - case 'indexers': - this._showIndexers(); - break; - case 'downloadclient': - this._showDownloadClient(); - break; - case 'connect': - this._showNotifications(); - break; - case 'notifications': - this._showNotifications(); - break; - case 'metadata': - this._showMetadata(); - break; - case 'general': - this._showGeneral(); - break; - case 'ui': - this._showUi(); - break; - default: - this._showMediaManagement(); - } - }, - - _showMediaManagement : function(e) { - if (e) { - e.preventDefault(); - } - - this.ui.mediaManagementTab.tab('show'); - this._navigate('settings/mediamanagement'); - }, - - _showProfiles : function(e) { - if (e) { - e.preventDefault(); - } - - this.ui.profilesTab.tab('show'); - this._navigate('settings/profiles'); - }, - - _showQuality : function(e) { - if (e) { - e.preventDefault(); - } - - this.ui.qualityTab.tab('show'); - this._navigate('settings/quality'); - }, - - _showIndexers : function(e) { - if (e) { - e.preventDefault(); - } - - this.ui.indexersTab.tab('show'); - this._navigate('settings/indexers'); - }, - - _showDownloadClient : function(e) { - if (e) { - e.preventDefault(); - } - - this.ui.downloadClientTab.tab('show'); - this._navigate('settings/downloadclient'); - }, - - _showNotifications : function(e) { - if (e) { - e.preventDefault(); - } - - this.ui.notificationsTab.tab('show'); - this._navigate('settings/connect'); - }, - - _showMetadata : function(e) { - if (e) { - e.preventDefault(); - } - this.ui.metadataTab.tab('show'); - this._navigate('settings/metadata'); - }, - - _showGeneral : function(e) { - if (e) { - e.preventDefault(); - } - this.ui.generalTab.tab('show'); - this._navigate('settings/general'); - }, - - _showUi : function(e) { - if (e) { - e.preventDefault(); - } - this.ui.uiTab.tab('show'); - this._navigate('settings/ui'); - }, - - _navigate : function(route) { - Backbone.history.navigate(route, { - trigger : false, - replace : true - }); - }, - - _save : function() { - vent.trigger(vent.Commands.SaveSettings); - }, - - _setAdvancedSettingsState : function() { - var checked = Config.getValueBoolean(Config.Keys.AdvancedSettings); - this.ui.advancedSettings.prop('checked', checked); - - if (checked) { - $('body').addClass('show-advanced-settings'); - } - }, - - _toggleAdvancedSettings : function() { - var checked = this.ui.advancedSettings.prop('checked'); - Config.setValue(Config.Keys.AdvancedSettings, checked); - - if (checked) { - $('body').addClass('show-advanced-settings'); - } else { - $('body').removeClass('show-advanced-settings'); - } - } -}); \ No newline at end of file diff --git a/src/UI/Settings/SettingsLayoutTemplate.hbs b/src/UI/Settings/SettingsLayoutTemplate.hbs deleted file mode 100644 index c69ba9f16..000000000 --- a/src/UI/Settings/SettingsLayoutTemplate.hbs +++ /dev/null @@ -1,49 +0,0 @@ -<ul class="nav nav-tabs nav-justified settings-tabs"> - <li><a href="#media-management" class="x-media-management-tab no-router">Media Management</a></li> - <li><a href="#profiles" class="x-profiles-tab no-router">Profiles</a></li> - <li><a href="#quality" class="x-quality-tab no-router">Quality</a></li> - <li><a href="#indexers" class="x-indexers-tab no-router">Indexers</a></li> - <li><a href="#download-client" class="x-download-client-tab no-router">Download Client</a></li> - <li><a href="#notifications" class="x-notifications-tab no-router">Connect</a></li> - <li><a href="#metadata" class="x-metadata-tab no-router">Metadata</a></li> - <li><a href="#general" class="x-general-tab no-router">General</a></li> - <li><a href="#ui" class="x-ui-tab no-router">UI</a></li> -</ul> - -<div class="row settings-controls"> - <div class="col-sm-4 col-sm-offset-7 col-md-3 col-md-offset-8"> - <div class="advanced-settings-toggle"> - <span class="help-inline-checkbox hidden-xs"> - Advanced Settings - </span> - <label class="checkbox toggle well"> - <input type="checkbox" class="x-advanced-settings"/> - <p> - <span>Shown</span> - <span>Hidden</span> - </p> - <div class="btn btn-warning slide-button"/> - </label> - <span class="help-inline-checkbox hidden-sm hidden-md hidden-lg"> - Advanced Settings - </span> - </div> - </div> - <div class="col-sm-1 col-md-1"> - <button class="btn btn-primary x-save-settings">Save</button> - </div> -</div> - -<div class="tab-content"> - <div class="tab-pane" id="media-management"></div> - <div class="tab-pane" id="profiles"></div> - <div class="tab-pane" id="quality"></div> - <div class="tab-pane" id="indexers"></div> - <div class="tab-pane" id="download-client"></div> - <div class="tab-pane" id="notifications"></div> - <div class="tab-pane" id="metadata"></div> - <div class="tab-pane" id="general"></div> - <div class="tab-pane" id="ui"></div> -</div> - -<div id="loading-region"></div> \ No newline at end of file diff --git a/src/UI/Settings/SettingsModelBase.js b/src/UI/Settings/SettingsModelBase.js deleted file mode 100644 index f08773f91..000000000 --- a/src/UI/Settings/SettingsModelBase.js +++ /dev/null @@ -1,34 +0,0 @@ -var vent = require('vent'); -var DeepModel = require('backbone.deepmodel'); -var AsChangeTrackingModel = require('../Mixins/AsChangeTrackingModel'); -var Messenger = require('../Shared/Messenger'); - -var model = DeepModel.extend({ - - initialize : function() { - this.listenTo(vent, vent.Commands.SaveSettings, this.saveSettings); - this.listenTo(this, 'destroy', this._stopListening); - }, - - saveSettings : function() { - if (!this.isSaved) { - var savePromise = this.save(); - - Messenger.monitor({ - promise : savePromise, - successMessage : this.successMessage, - errorMessage : this.errorMessage - }); - - return savePromise; - } - - return undefined; - }, - - _stopListening : function() { - this.stopListening(vent, vent.Commands.SaveSettings); - } -}); - -module.exports = AsChangeTrackingModel.call(model); diff --git a/src/UI/Settings/ThingyAddCollectionView.js b/src/UI/Settings/ThingyAddCollectionView.js deleted file mode 100644 index ecce0dd7b..000000000 --- a/src/UI/Settings/ThingyAddCollectionView.js +++ /dev/null @@ -1,13 +0,0 @@ -var Marionette = require('marionette'); - -module.exports = Marionette.CompositeView.extend({ - itemViewOptions : function() { - return { - targetCollection : this.targetCollection || this.options.targetCollection - }; - }, - - initialize : function(options) { - this.targetCollection = options.targetCollection; - } -}); \ No newline at end of file diff --git a/src/UI/Settings/ThingyHeaderGroupView.js b/src/UI/Settings/ThingyHeaderGroupView.js deleted file mode 100644 index 0f7e9a2f8..000000000 --- a/src/UI/Settings/ThingyHeaderGroupView.js +++ /dev/null @@ -1,18 +0,0 @@ -var Backbone = require('backbone'); -var Marionette = require('marionette'); - -module.exports = Marionette.CompositeView.extend({ - itemViewContainer : '.item-list', - template : 'Settings/ThingyHeaderGroupViewTemplate', - tagName : 'div', - - itemViewOptions : function() { - return { - targetCollection : this.targetCollection || this.options.targetCollection - }; - }, - - initialize : function() { - this.collection = new Backbone.Collection(this.model.get('collection')); - } -}); \ No newline at end of file diff --git a/src/UI/Settings/ThingyHeaderGroupViewTemplate.hbs b/src/UI/Settings/ThingyHeaderGroupViewTemplate.hbs deleted file mode 100644 index 310a29241..000000000 --- a/src/UI/Settings/ThingyHeaderGroupViewTemplate.hbs +++ /dev/null @@ -1,2 +0,0 @@ -<legend>{{header}}</legend> -<ul class="item-list" /> \ No newline at end of file diff --git a/src/UI/Settings/UI/UiSettingsModel.js b/src/UI/Settings/UI/UiSettingsModel.js deleted file mode 100644 index baf6a5297..000000000 --- a/src/UI/Settings/UI/UiSettingsModel.js +++ /dev/null @@ -1,7 +0,0 @@ -var SettingsModelBase = require('../SettingsModelBase'); - -module.exports = SettingsModelBase.extend({ - url : window.NzbDrone.ApiRoot + '/config/ui', - successMessage : 'UI settings saved', - errorMessage : 'Failed to save UI settings' -}); \ No newline at end of file diff --git a/src/UI/Settings/UI/UiView.js b/src/UI/Settings/UI/UiView.js deleted file mode 100644 index 5e8664036..000000000 --- a/src/UI/Settings/UI/UiView.js +++ /dev/null @@ -1,22 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); -var UiSettingsModel = require('../../Shared/UiSettingsModel'); -var AsModelBoundView = require('../../Mixins/AsModelBoundView'); -var AsValidatedView = require('../../Mixins/AsValidatedView'); - -var view = Marionette.ItemView.extend({ - template : 'Settings/UI/UiViewTemplate', - - initialize : function() { - this.listenTo(this.model, 'sync', this._reloadUiSettings); - }, - - _reloadUiSettings : function() { - UiSettingsModel.fetch(); - } -}); - -AsModelBoundView.call(view); -AsValidatedView.call(view); - -module.exports = view; \ No newline at end of file diff --git a/src/UI/Settings/UI/UiViewTemplate.hbs b/src/UI/Settings/UI/UiViewTemplate.hbs deleted file mode 100644 index 5a3d46d27..000000000 --- a/src/UI/Settings/UI/UiViewTemplate.hbs +++ /dev/null @@ -1,124 +0,0 @@ -<div class="form-horizontal"> - <fieldset> - <legend>Calendar</legend> - - <div class="form-group"> - <label class="col-sm-3 control-label">First Day of Week</label> - - <div class="col-sm-4"> - <select name="firstDayOfWeek" class="form-control"> - <option value="0">Sunday</option> - <option value="1">Monday</option> - </select> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Week Column Header</label> - - <div class="col-sm-1 col-sm-push-4 help-inline"> - <i class="icon-sonarr-form-info" title="Shown above each column when week is the active view"/> - </div> - - <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> - </select> - </div> - </div> - </fieldset> - - <fieldset> - <legend>Dates</legend> - - <div class="form-group"> - <label class="col-sm-3 control-label">Short Date Format</label> - - <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> - </select> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Long Date Format</label> - - <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> - </select> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Time Format</label> - - <div class="col-sm-4"> - <select name="timeFormat" class="form-control"> - <option value="h(:mm)a">5pm/5:30pm</option> - <option value="HH:mm">17:00/17:30</option> - </select> - </div> - </div> - - <div class="form-group"> - <label class="col-sm-3 control-label">Show Relative Dates</label> - - <div class="col-sm-8"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="showRelativeDates"/> - - <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="Show relative (Today/Yesterday/etc) or absolute dates"/> - </span> - </div> - </div> - </div> - </fieldset> - - <fieldset> - <legend>Style</legend> - - <div class="form-group"> - <label class="col-sm-3 control-label">Enable Color-Impaired mode</label> - - <div class="col-sm-8"> - <div class="input-group"> - <label class="checkbox toggle well"> - <input type="checkbox" name="enableColorImpairedMode" /> - - <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="Altered style to allow color-impaired users to better distinguish color coded information"/> - </span> - </div> - </div> - </div> - </fieldset> -</div> diff --git a/src/UI/Settings/settings.less b/src/UI/Settings/settings.less deleted file mode 100644 index ec6bd2a1c..000000000 --- a/src/UI/Settings/settings.less +++ /dev/null @@ -1,161 +0,0 @@ -@import "../Content/Bootstrap/variables"; -@import "../Shared/Styles/clickable.less"; -@import "Indexers/indexers"; -@import "Quality/quality"; -@import "Profile/profile"; -@import "Notifications/notifications"; -@import "Metadata/metadata"; -@import "DownloadClient/downloadclient"; -@import "thingy"; - -li.save-and-add { - .clickable; - - display: block; - padding: 3px 20px; - clear: both; - font-weight: normal; - line-height: 20px; - color: rgb(51, 51, 51); - white-space: nowrap; -} - -li.save-and-add:hover { - text-decoration: none; - color: rgb(255, 255, 255); - background-color: rgb(0, 129, 194); -} - -.add-card { - .clickable; - color: #adadad; - font-size: 50px; - text-align: center; - background-color: #f5f5f5; - - .center { - display: inline-block; - padding: 5px 20px 0px; - background-color: white; - } - - i { - .clickable; - } -} - -.naming-example { - display: inline-block; - margin-top: 5px; -} - -.naming-format { - width: 500px; -} - -.settings-controls { - margin-top: 10px; -} - -.advanced-settings-toggle { - display: inline-block; - margin-bottom: 10px; - - .checkbox { - width : 100px; - margin-left : 0px; - display : inline-block; - padding-top : 0px; - margin-bottom : -10px; - margin-top : -1px; - } - - .help-inline-checkbox { - display : inline-block; - margin-top : -3px; - margin-bottom : 0; - vertical-align : middle; - } -} - -.advanced-setting { - display: none; - - .control-label { - color: @brand-warning; - } -} - -.basic-setting { - display: block; -} - -.show-advanced-settings { - .advanced-setting { - display: block; - } - - .basic-setting { - display: none; - } -} - -.api-key { - - input { - width : 280px; - cursor : text; - } -} - -.settings-tabs { - li>a { - padding : 10px; - } - - @media (min-width: @screen-sm-min) and (max-width: @screen-md-max) { - li { - a { - white-space : nowrap; - padding : 10px; - } - } - } -} - -.indicator { - display : none; - padding-right : 5px; -} - -.add-rule-setting-mapping { - cursor : pointer; - font-size : 14px; - text-align : center; - display : inline-block; - padding : 2px 6px; - - i { - cursor : pointer; - } -} - -.rule-setting-list { - - .rule-setting-header .row { - font-weight : bold; - line-height : 40px; - } - - .rows .row { - line-height : 30px; - border-top : 1px solid #ddd; - vertical-align : middle; - padding : 5px; - - i { - cursor : pointer; - margin-left : 5px; - } - } -} diff --git a/src/UI/Settings/thingy.less b/src/UI/Settings/thingy.less deleted file mode 100644 index 2368240b7..000000000 --- a/src/UI/Settings/thingy.less +++ /dev/null @@ -1,65 +0,0 @@ -@import "../Shared/Styles/card"; -@import "../Shared/Styles/clickable"; - -.add-thingy { - .card; - cursor: pointer; - font-size: 24px; - font-weight: lighter; - text-align: center; - height: 85px; -} - -.add-thingies { - text-align: center; - - legend { - text-align: left; - text-transform: capitalize; - } - - ul.items { - list-style-type: none; - margin: 0px; - padding: 0px; - - li.add-thingy-item { - display: inline-block; - vertical-align: top; - } - } -} - -.thingy { - - .card; - - h3 { - margin-top: 0px; - display: inline-block; - white-space: nowrap; - overflow: hidden; - line-height: 30px; - text-overflow: ellipsis; - text-transform: none; - } - - .btn-group { - margin-top: 8px; - } - - .settings { - margin-top: 5px; - } -} - -.thingies { - li { - display: inline-block; - vertical-align: top; - } - - @media (max-width: @screen-xs-max) { - padding-left: 0px; - } -} \ No newline at end of file diff --git a/src/UI/Shared/ApiData.js b/src/UI/Shared/ApiData.js deleted file mode 100644 index 6d8e62043..000000000 --- a/src/UI/Shared/ApiData.js +++ /dev/null @@ -1,17 +0,0 @@ -var $ = require('jquery'); - -module.exports = { - get : function(resource) { - var url = window.NzbDrone.ApiRoot + '/' + resource; - var _data; - $.ajax({ - url : url, - async : false - }).done(function(data) { - _data = data; - }).error(function(xhr, status, error) { - throw error; - }); - return _data; - } -}; \ No newline at end of file diff --git a/src/UI/Shared/ControlPanel/ControlPanelController.js b/src/UI/Shared/ControlPanel/ControlPanelController.js deleted file mode 100644 index c2a31c3cc..000000000 --- a/src/UI/Shared/ControlPanel/ControlPanelController.js +++ /dev/null @@ -1,18 +0,0 @@ -var vent = require('vent'); -var AppLayout = require('../../AppLayout'); -var Marionette = require('marionette'); - -module.exports = Marionette.AppRouter.extend({ - initialize : function() { - vent.on(vent.Commands.OpenControlPanelCommand, this._openControlPanel, this); - vent.on(vent.Commands.CloseControlPanelCommand, this._closeControlPanel, this); - }, - - _openControlPanel : function(view) { - AppLayout.controlPanelRegion.show(view); - }, - - _closeControlPanel : function() { - AppLayout.controlPanelRegion.closePanel(); - } -}); \ No newline at end of file diff --git a/src/UI/Shared/ControlPanel/ControlPanelRegion.js b/src/UI/Shared/ControlPanel/ControlPanelRegion.js deleted file mode 100644 index e32c02552..000000000 --- a/src/UI/Shared/ControlPanel/ControlPanelRegion.js +++ /dev/null @@ -1,41 +0,0 @@ -var $ = require('jquery'); -var Backbone = require('backbone'); -var Marionette = require('marionette'); -var region = Marionette.Region.extend({ - el : '#control-panel-region', - - constructor : function() { - Backbone.Marionette.Region.prototype.constructor.apply(this, arguments); - this.on('show', this.showPanel, this); - }, - - getEl : function(selector) { - var $el = $(selector); - - return $el; - }, - - showPanel : function() { - $('body').addClass('control-panel-visible'); - this.$el.animate({ - 'margin-bottom' : 0, - 'opacity' : 1 - }, { - queue : false, - duration : 300 - }); - }, - - closePanel : function() { - $('body').removeClass('control-panel-visible'); - this.$el.animate({ - 'margin-bottom' : -100, - 'opacity' : 0 - }, { - queue : false, - duration : 300 - }); - this.reset(); - } -}); -module.exports = region; \ No newline at end of file diff --git a/src/UI/Shared/FileBrowser/EmptyView.js b/src/UI/Shared/FileBrowser/EmptyView.js deleted file mode 100644 index 3bd8ddc93..000000000 --- a/src/UI/Shared/FileBrowser/EmptyView.js +++ /dev/null @@ -1,5 +0,0 @@ -var Marionette = require('marionette'); - -module.exports = Marionette.CompositeView.extend({ - template : 'Shared/FileBrowser/EmptyViewTemplate' -}); \ No newline at end of file diff --git a/src/UI/Shared/FileBrowser/EmptyViewTemplate.hbs b/src/UI/Shared/FileBrowser/EmptyViewTemplate.hbs deleted file mode 100644 index 53469ac16..000000000 --- a/src/UI/Shared/FileBrowser/EmptyViewTemplate.hbs +++ /dev/null @@ -1,3 +0,0 @@ -<div class="text-center col-md-12 file-browser-empty"> - <span>No files/folders were found, edit the path above, or clear to start again</span> -</div> diff --git a/src/UI/Shared/FileBrowser/FileBrowserCollection.js b/src/UI/Shared/FileBrowser/FileBrowserCollection.js deleted file mode 100644 index d2771b15d..000000000 --- a/src/UI/Shared/FileBrowser/FileBrowserCollection.js +++ /dev/null @@ -1,28 +0,0 @@ -var $ = require('jquery'); -var Backbone = require('backbone'); -var FileBrowserModel = require('./FileBrowserModel'); - -module.exports = Backbone.Collection.extend({ - model : FileBrowserModel, - url : window.NzbDrone.ApiRoot + '/filesystem', - - parse : function(response) { - var contents = []; - if (response.parent || response.parent === '') { - var type = 'parent'; - var name = '...'; - if (response.parent === '') { - type = 'computer'; - name = 'My Computer'; - } - contents.push({ - type : type, - name : name, - path : response.parent - }); - } - $.merge(contents, response.directories); - $.merge(contents, response.files); - return contents; - } -}); \ No newline at end of file diff --git a/src/UI/Shared/FileBrowser/FileBrowserLayout.js b/src/UI/Shared/FileBrowser/FileBrowserLayout.js deleted file mode 100644 index 82ae8b32b..000000000 --- a/src/UI/Shared/FileBrowser/FileBrowserLayout.js +++ /dev/null @@ -1,162 +0,0 @@ -var _ = require('underscore'); -var vent = require('vent'); -var Marionette = require('marionette'); -var Backgrid = require('backgrid'); -var FileBrowserCollection = require('./FileBrowserCollection'); -var EmptyView = require('./EmptyView'); -var FileBrowserRow = require('./FileBrowserRow'); -var FileBrowserTypeCell = require('./FileBrowserTypeCell'); -var FileBrowserNameCell = require('./FileBrowserNameCell'); -var RelativeDateCell = require('../../Cells/RelativeDateCell'); -var FileSizeCell = require('../../Cells/FileSizeCell'); -var LoadingView = require('../LoadingView'); -require('../../Mixins/DirectoryAutoComplete'); - -module.exports = Marionette.Layout.extend({ - template : 'Shared/FileBrowser/FileBrowserLayoutTemplate', - - regions : { - browser : '#x-browser' - }, - - ui : { - path : '.x-path', - indicator : '.x-indicator' - }, - - events : { - 'typeahead:selected .x-path' : '_pathChanged', - 'typeahead:autocompleted .x-path' : '_pathChanged', - 'keyup .x-path' : '_inputChanged', - 'click .x-ok' : '_selectPath' - }, - - initialize : function(options) { - this.collection = new FileBrowserCollection(); - this.collection.showFiles = options.showFiles || false; - this.collection.showLastModified = options.showLastModified || false; - this.input = options.input; - this._setColumns(); - this.listenTo(this.collection, 'sync', this._showGrid); - this.listenTo(this.collection, 'filebrowser:row:folderselected', this._rowSelected); - this.listenTo(this.collection, 'filebrowser:row:fileselected', this._fileSelected); - }, - - onRender : function() { - this.browser.show(new LoadingView()); - this.ui.path.directoryAutoComplete(); - this._fetchCollection(this.input.val()); - this._updatePath(this.input.val()); - }, - - _setColumns : function() { - this.columns = [ - { - name : 'type', - label : '', - sortable : false, - cell : FileBrowserTypeCell - }, - { - name : 'name', - label : 'Name', - sortable : false, - cell : FileBrowserNameCell - } - ]; - if (this.collection.showLastModified) { - this.columns.push({ - name : 'lastModified', - label : 'Last Modified', - sortable : false, - cell : RelativeDateCell - }); - } - if (this.collection.showFiles) { - this.columns.push({ - name : 'size', - label : 'Size', - sortable : false, - cell : FileSizeCell - }); - } - }, - - _fetchCollection : function(path) { - this.ui.indicator.show(); - var data = { includeFiles : this.collection.showFiles }; - if (path) { - data.path = path; - } - this.collection.fetch({ data : data }); - }, - - _showGrid : function() { - this.ui.indicator.hide(); - if (this.collection.models.length === 0) { - this.browser.show(new EmptyView()); - return; - } - var grid = new Backgrid.Grid({ - row : FileBrowserRow, - collection : this.collection, - columns : this.columns, - className : 'table table-hover' - }); - this.browser.show(grid); - }, - - _rowSelected : function(model) { - var path = model.get('path'); - - this._updatePath(path); - this._fetchCollection(path); - }, - - _fileSelected : function(model) { - var path = model.get('path'); - var type = model.get('type'); - - this.input.val(path); - this.input.trigger('change'); - - this.input.trigger('filebrowser:fileselected', { - type : type, - path : path - }); - - vent.trigger(vent.Commands.CloseFileBrowser); - }, - - _pathChanged : function(e, path) { - this._fetchCollection(path.value); - this._updatePath(path.value); - }, - - _inputChanged : function() { - var path = this.ui.path.val(); - if (path === '' || path.endsWith('\\') || path.endsWith('/')) { - this._fetchCollection(path); - } - }, - - _updatePath : function(path) { - if (path !== undefined || path !== null) { - this.ui.path.val(path); - } - }, - - _selectPath : function() { - var path = this.ui.path.val(); - - this.input.val(path); - this.input.trigger('change'); - - this.input.trigger('filebrowser:folderselected', { - type: 'folder', - path: path - }); - - vent.trigger(vent.Commands.CloseFileBrowser); - } -}); diff --git a/src/UI/Shared/FileBrowser/FileBrowserLayoutTemplate.hbs b/src/UI/Shared/FileBrowser/FileBrowserLayoutTemplate.hbs deleted file mode 100644 index ca5812b4e..000000000 --- a/src/UI/Shared/FileBrowser/FileBrowserLayoutTemplate.hbs +++ /dev/null @@ -1,26 +0,0 @@ -<div class="modal-content"> - <div class="modal-header"> - <button type="button" class="close" aria-hidden="true" data-dismiss="modal">×</button> - <h3>File Browser</h3> -</div> - <div class="modal-body"> - <div class="row"> - <div class="col-sm-12"> - <div class="form-group"> - <input type="text" class="form-control x-path" placeholder="Start typing or select a path below"/> - </div> - </div> - </div> - - <div class="row"> - <div class="col-sm-12"> - <div id="x-browser"></div> - </div> - </div> - </div> - <div class="modal-footer"> - <span class="indicator x-indicator"><i class="icon-sonarr-spinner fa-spin"></i></span> - <button class="btn" data-dismiss="modal">Close</button> - <button class="btn btn-primary x-ok">Ok</button> - </div> -</div> diff --git a/src/UI/Shared/FileBrowser/FileBrowserModel.js b/src/UI/Shared/FileBrowser/FileBrowserModel.js deleted file mode 100644 index 3986a5948..000000000 --- a/src/UI/Shared/FileBrowser/FileBrowserModel.js +++ /dev/null @@ -1,3 +0,0 @@ -var Backbone = require('backbone'); - -module.exports = Backbone.Model.extend({}); \ No newline at end of file diff --git a/src/UI/Shared/FileBrowser/FileBrowserNameCell.js b/src/UI/Shared/FileBrowser/FileBrowserNameCell.js deleted file mode 100644 index 90cb704be..000000000 --- a/src/UI/Shared/FileBrowser/FileBrowserNameCell.js +++ /dev/null @@ -1,18 +0,0 @@ -var vent = require('vent'); -var NzbDroneCell = require('../../Cells/NzbDroneCell'); - -module.exports = NzbDroneCell.extend({ - className : 'file-browser-name-cell', - - render : function() { - this.$el.empty(); - - var name = this.model.get(this.column.get('name')); - - this.$el.html(name); - - this.delegateEvents(); - - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Shared/FileBrowser/FileBrowserRow.js b/src/UI/Shared/FileBrowser/FileBrowserRow.js deleted file mode 100644 index af982cf72..000000000 --- a/src/UI/Shared/FileBrowser/FileBrowserRow.js +++ /dev/null @@ -1,24 +0,0 @@ -var _ = require('underscore'); -var Backgrid = require('backgrid'); - -module.exports = Backgrid.Row.extend({ - className : 'file-browser-row', - - events : { - 'click' : '_selectRow' - }, - - _originalInit : Backgrid.Row.prototype.initialize, - - initialize : function() { - this._originalInit.apply(this, arguments); - }, - - _selectRow : function() { - if (this.model.get('type') === 'file') { - this.model.collection.trigger('filebrowser:row:fileselected', this.model); - } else { - this.model.collection.trigger('filebrowser:row:folderselected', this.model); - } - } -}); \ No newline at end of file diff --git a/src/UI/Shared/FileBrowser/FileBrowserTypeCell.js b/src/UI/Shared/FileBrowser/FileBrowserTypeCell.js deleted file mode 100644 index ff829024f..000000000 --- a/src/UI/Shared/FileBrowser/FileBrowserTypeCell.js +++ /dev/null @@ -1,28 +0,0 @@ -var vent = require('vent'); -var NzbDroneCell = require('../../Cells/NzbDroneCell'); - -module.exports = NzbDroneCell.extend({ - className : 'file-browser-type-cell', - - render : function() { - this.$el.empty(); - - var type = this.model.get(this.column.get('name')); - var icon = 'icon-sonarr-hdd'; - - if (type === 'computer') { - icon = 'icon-sonarr-browser-computer'; - } else if (type === 'parent') { - icon = 'icon-sonarr-browser-up'; - } else if (type === 'folder') { - icon = 'icon-sonarr-browser-folder'; - } else if (type === 'file') { - icon = 'icon-sonarr-browser-file'; - } - - this.$el.html('<i class="{0}"></i>'.format(icon)); - this.delegateEvents(); - - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Shared/FileBrowser/filebrowser.less b/src/UI/Shared/FileBrowser/filebrowser.less deleted file mode 100644 index c8810147b..000000000 --- a/src/UI/Shared/FileBrowser/filebrowser.less +++ /dev/null @@ -1,24 +0,0 @@ -.file-browser-row { - cursor : pointer; - - .file-size-cell { - white-space : nowrap; - } - - .relative-date-cell { - width : 120px; - white-space : nowrap; - } -} - -.file-browser-type-cell { - width : 16px; -} - -.file-browser-name-cell { - word-break : break-all; -} - -.file-browser-empty { - margin-top : 20px; -} \ No newline at end of file diff --git a/src/UI/Shared/FormatHelpers.js b/src/UI/Shared/FormatHelpers.js deleted file mode 100644 index 303f60ff6..000000000 --- a/src/UI/Shared/FormatHelpers.js +++ /dev/null @@ -1,71 +0,0 @@ -var moment = require('moment'); -var filesize = require('filesize'); -var UiSettings = require('./UiSettingsModel'); - -module.exports = { - bytes : function(sourceSize, sourceRounding) { - var size = Number(sourceSize); - var rounding = Number(sourceRounding); - - if (isNaN(size)) { - return ''; - } - - if (isNaN(rounding)) { - rounding = 1; - } - - return filesize(size, { - base : 2, - round : rounding - }); - }, - - relativeDate : function(sourceDate) { - if (!sourceDate) { - return ''; - } - - var date = moment(sourceDate); - var calendarDate = date.calendar(); - - //TODO: It would be nice to not have to hack this... - var strippedCalendarDate = calendarDate.substring(0, calendarDate.indexOf(' at ')); - - if (strippedCalendarDate) { - return strippedCalendarDate; - } - - if (date.isAfter(moment())) { - return 'in ' + date.fromNow(true); - } - - if (date.isBefore(moment().add('years', -1))) { - return date.format(UiSettings.get('shortDateFormat')); - } - - return date.fromNow(); - }, - - pad : function(n, width, z) { - z = z || '0'; - n = n + ''; - return n.length >= width ? n : new Array(width - n.length + 1).join(z) + n; - }, - - number : function(input) { - if (!input) { - return '0'; - } - - return input.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); - }, - - plural : function(input, unit) { - if (input === 1) { - return unit; - } - - return unit + 's'; - } -}; \ No newline at end of file diff --git a/src/UI/Shared/Grid/HeaderCell.js b/src/UI/Shared/Grid/HeaderCell.js deleted file mode 100644 index 2e72c8cb4..000000000 --- a/src/UI/Shared/Grid/HeaderCell.js +++ /dev/null @@ -1,155 +0,0 @@ -module.exports = function() { - var Backgrid = this; - - Backgrid.SonarrHeaderCell = Backgrid.HeaderCell.extend({ - events : { - 'click' : 'onClick' - }, - - _originalInit : Backgrid.HeaderCell.prototype.initialize, - - initialize : function(options) { - this._originalInit.call(this, options); - - this.listenTo(this.collection, 'drone:sort', this.render); - }, - - render : function() { - this.$el.empty(); - this.$el.append(this.column.get('label')); - if (this.column.get('tooltip')) { - this.$el.attr({ - 'title' : this.column.get('tooltip'), - 'data-container' : '.table' - }); - } - - var column = this.column; - var sortable = Backgrid.callByNeed(column.sortable(), column, this.collection); - - if (sortable) { - this.$el.addClass('sortable'); - this.$el.prepend(' <i class="sort-direction-icon"></i>'); - } - - //Do we need this? - this.$el.addClass(column.get('name')); - - if (column.has('className')) { - this.$el.addClass(column.get('className')); - } - - this.delegateEvents(); - this.direction(column.get('direction')); - - if (this.collection.state) { - var name = this._getSortMapping().name; - var order = this.collection.state.order; - - if (name === column.get('name')) { - this._setSortIcon(order); - } else { - this._removeSortIcon(); - } - } - - return this; - }, - - direction : function(dir) { - this.$el.children('i.sort-direction-icon').removeClass('icon-sonarr-sort-asc icon-sonarr-sort-desc'); - - if (arguments.length) { - if (dir) { - this._setSortIcon(dir); - } - - this.column.set('direction', dir); - } - - var columnDirection = this.column.get('direction'); - - if (!columnDirection && this.collection.state) { - var name = this._getSortMapping().name; - var order = this.collection.state.order; - - if (name === this.column.get('name')) { - columnDirection = order; - } - } - - return columnDirection; - }, - - _getSortMapping : function() { - var sortKey = this.collection.state.sortKey; - - if (this.collection._getSortMapping) { - return this.collection._getSortMapping(sortKey); - } - - return { - name : sortKey, - sortKey : sortKey - }; - }, - - onClick : function(e) { - e.preventDefault(); - - var collection = this.collection; - var event = 'backgrid:sort'; - - var column = this.column; - var sortable = Backgrid.callByNeed(column.sortable(), column, collection); - if (sortable) { - var isSorted = this.$el.children('.icon-sonarr-sort-asc,.icon-sonarr-sort-desc').length !== 0; - var direction = collection.state.order; - if (column.get('sortType') === 'fixed' || !isSorted) { - direction = column.get('direction') || 'ascending'; - } else { - if (direction === 'ascending' || direction === -1) { - direction = 'descending'; - } else { - direction = 'ascending'; - } - } - - if (collection.setSorting) { - collection.setSorting(column.get('name'), direction); - } else { - collection.state.sortKey = column.get('name'); - collection.state.order = direction; - } - collection.trigger(event, column, direction); - } - }, - - _resetCellDirection : function(columnToSort, direction) { - if (columnToSort !== this.column) { - this.direction(null); - } else { - this.direction(direction); - } - }, - - _convertDirectionToIcon : function(dir) { - if (dir === 'ascending' || dir === -1) { - return 'icon-sonarr-sort-asc'; - } - - return 'icon-sonarr-sort-desc'; - }, - - _setSortIcon : function(dir) { - this._removeSortIcon(); - this.$el.children('i.sort-direction-icon').addClass(this._convertDirectionToIcon(dir)); - }, - - _removeSortIcon : function() { - this.$el.children('i.sort-direction-icon').removeClass('icon-sonarr-sort-asc icon-sonarr-sort-desc'); - } - }); - - return Backgrid.SonarrHeaderCell; -}; diff --git a/src/UI/Shared/Grid/JumpToPageTemplate.hbs b/src/UI/Shared/Grid/JumpToPageTemplate.hbs deleted file mode 100644 index 9a157ece6..000000000 --- a/src/UI/Shared/Grid/JumpToPageTemplate.hbs +++ /dev/null @@ -1,9 +0,0 @@ -<select class="x-page-select"> - {{#each pages}} - {{#if current}} - <option value="{{page}}" selected="selected">{{page}}</option> - {{else}} - <option value="{{page}}">{{page}}</option> - {{/if}} - {{/each}} -</select> \ No newline at end of file diff --git a/src/UI/Shared/Grid/Pager.js b/src/UI/Shared/Grid/Pager.js deleted file mode 100644 index 618117cf9..000000000 --- a/src/UI/Shared/Grid/Pager.js +++ /dev/null @@ -1,188 +0,0 @@ -var $ = require('jquery'); -var Marionette = require('marionette'); -var Paginator = require('backgrid.paginator'); - -module.exports = Paginator.extend({ - template : 'Shared/Grid/PagerTemplate', - - events : { - 'click .pager-btn' : 'changePage', - 'click .x-page-number' : '_showPageJumper', - 'change .x-page-select' : '_jumpToPage', - 'blur .x-page-select' : 'render' - }, - - windowSize : 1, - - fastForwardHandleLabels : { - first : 'icon-sonarr-pager-first', - prev : 'icon-sonarr-pager-previous', - next : 'icon-sonarr-pager-next', - last : 'icon-sonarr-pager-last' - }, - - changePage : function(e) { - e.preventDefault(); - - var target = this.$(e.target); - - if (target.closest('li').hasClass('disabled')) { - return; - } - - var icon = target.closest('li i'); - var iconClasses = icon.attr('class').match(/(?:^|\s)icon\-.+?(?:$|\s)/); - var iconClass = $.trim(iconClasses[0]); - - icon.removeClass(iconClass); - icon.addClass('icon-sonarr-spinner fa-spin'); - - var label = target.attr('data-action'); - var ffLabels = this.fastForwardHandleLabels; - - var collection = this.collection; - - if (ffLabels) { - switch (label) { - case 'first': - collection.getFirstPage(); - return; - case 'prev': - if (collection.hasPrevious()) { - collection.getPreviousPage(); - } - return; - case 'next': - if (collection.hasNext()) { - collection.getNextPage(); - } - return; - case 'last': - collection.getLastPage(); - return; - } - } - - var state = collection.state; - var pageIndex = target.text(); - collection.getPage(state.firstPage === 0 ? pageIndex - 1 : pageIndex); - }, - - makeHandles : function() { - var handles = []; - - var collection = this.collection; - var state = collection.state; - - // convert all indices to 0-based here - var firstPage = state.firstPage; - var lastPage = +state.lastPage; - lastPage = Math.max(0, firstPage ? lastPage - 1 : lastPage); - var currentPage = Math.max(state.currentPage, state.firstPage); - currentPage = firstPage ? currentPage - 1 : currentPage; - var windowStart = Math.floor(currentPage / this.windowSize) * this.windowSize; - var windowEnd = Math.min(lastPage + 1, windowStart + this.windowSize); - - if (collection.mode !== 'infinite') { - for (var i = windowStart; i < windowEnd; i++) { - handles.push({ - label : i + 1, - title : 'No. ' + (i + 1), - className : currentPage === i ? 'active' : undefined, - pageNumber : i + 1, - lastPage : lastPage + 1 - }); - } - } - - var ffLabels = this.fastForwardHandleLabels; - if (ffLabels) { - if (ffLabels.prev) { - handles.unshift({ - label : ffLabels.prev, - className : collection.hasPrevious() ? void 0 : 'disabled', - action : 'prev' - }); - } - - if (ffLabels.first) { - handles.unshift({ - label : ffLabels.first, - className : collection.hasPrevious() ? void 0 : 'disabled', - action : 'first' - }); - } - - if (ffLabels.next) { - handles.push({ - label : ffLabels.next, - className : collection.hasNext() ? void 0 : 'disabled', - action : 'next' - }); - } - - if (ffLabels.last) { - handles.push({ - label : ffLabels.last, - className : collection.hasNext() ? void 0 : 'disabled', - action : 'last' - }); - } - } - - return handles; - }, - - render : function() { - this.$el.empty(); - - var templateFunction = Marionette.TemplateCache.get(this.template); - - this.$el.html(templateFunction({ - handles : this.makeHandles(), - state : this.collection.state - })); - - this.delegateEvents(); - - return this; - }, - - _showPageJumper : function(e) { - if ($(e.target).is('select')) { - return; - } - - var templateFunction = Marionette.TemplateCache.get('Shared/Grid/JumpToPageTemplate'); - var state = this.collection.state; - var currentPage = Math.max(state.currentPage, state.firstPage); - currentPage = state.firstPage ? currentPage - 1 : currentPage; - - var pages = []; - - for (var i = 0; i < this.collection.state.lastPage; i++) { - if (i === currentPage) { - pages.push({ - page : i + 1, - current : true - }); - } else { - pages.push({ page : i + 1 }); - } - } - - this.$el.find('.x-page-number').html(templateFunction({ pages : pages })); - }, - - _jumpToPage : function() { - var target = this.$el.find('.x-page-select'); - - //Remove event handlers so the blur event is not triggered - this.undelegateEvents(); - - var selectedPage = parseInt(target.val(), 10); - - this.$el.find('.x-page-number').html('<i class="icon-sonarr-spinner fa-spin"></i>'); - this.collection.getPage(selectedPage); - } -}); \ No newline at end of file diff --git a/src/UI/Shared/Grid/PagerTemplate.hbs b/src/UI/Shared/Grid/PagerTemplate.hbs deleted file mode 100644 index 795e76e6e..000000000 --- a/src/UI/Shared/Grid/PagerTemplate.hbs +++ /dev/null @@ -1,16 +0,0 @@ -<ul> - {{#each handles}} - <li {{#if className}}class="{{className}}"{{/if}} > - {{#if pageNumber}} - <span class="x-page-number">{{pageNumber}} / {{lastPage}}</span> - {{else}} - <i class="pager-btn clickable {{label}}" data-action="{{action}}"/> - {{/if}} - </li> - {{/each}} -</ul> - -<span class="total-records"> - <span class="hidden-xs">Total records: {{Number state.totalRecords}}</span> - <span class="visible-xs label label-info" title="Total records">{{Number state.totalRecords}}</span> -</span> \ No newline at end of file diff --git a/src/UI/Shared/LoadingView.js b/src/UI/Shared/LoadingView.js deleted file mode 100644 index 1b703940e..000000000 --- a/src/UI/Shared/LoadingView.js +++ /dev/null @@ -1,6 +0,0 @@ -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'Shared/LoadingViewTemplate', - className : 'nz-loading row' -}); \ No newline at end of file diff --git a/src/UI/Shared/LoadingViewTemplate.hbs b/src/UI/Shared/LoadingViewTemplate.hbs deleted file mode 100644 index 1ae3d7f54..000000000 --- a/src/UI/Shared/LoadingViewTemplate.hbs +++ /dev/null @@ -1,10 +0,0 @@ -<div class="row"> - <div class="col-md-12"> - <div id="followingBalls"> - <div id="ball-1" class="ball"></div> - <div id="ball-2" class="ball"></div> - <div id="ball-3" class="ball"></div> - <div id="ball-4" class="ball"></div> - </div> - </div> -</div> diff --git a/src/UI/Shared/Messenger.js b/src/UI/Shared/Messenger.js deleted file mode 100644 index 11837396a..000000000 --- a/src/UI/Shared/Messenger.js +++ /dev/null @@ -1,66 +0,0 @@ -require('messenger'); - -var messenger = require('messenger'); -module.exports = { - show : function(options) { - if (!options.type) { - options.type = 'info'; - } - - if (options.hideAfter === undefined) { - switch (options.type) { - case 'info': - options.hideAfter = 5; - break; - - case 'success': - options.hideAfter = 5; - break; - - default: - options.hideAfter = 5; - } - } - - options.hideOnNavigate = options.hideOnNavigate || false; - - return messenger().post({ - message : options.message, - type : options.type, - showCloseButton : true, - hideAfter : options.hideAfter, - id : options.id, - actions : options.actions, - hideOnNavigate : options.hideOnNavigate - }); - }, - - monitor : function(options) { - if (!options.promise) { - throw 'promise is required'; - } - - if (!options.successMessage) { - throw 'success message is required'; - } - - if (!options.errorMessage) { - throw 'error message is required'; - } - - var self = this; - - options.promise.done(function() { - self.show({ message : options.successMessage }); - }); - - options.promise.fail(function() { - self.show({ - message : options.errorMessage, - type : 'error' - }); - }); - - return options.promise; - } -}; \ No newline at end of file diff --git a/src/UI/Shared/Modal/ModalController.js b/src/UI/Shared/Modal/ModalController.js deleted file mode 100644 index ae5c1ec8c..000000000 --- a/src/UI/Shared/Modal/ModalController.js +++ /dev/null @@ -1,93 +0,0 @@ -var vent = require('vent'); -var AppLayout = require('../../AppLayout'); -var Marionette = require('marionette'); -var EditSeriesView = require('../../Series/Edit/EditSeriesView'); -var DeleteSeriesView = require('../../Series/Delete/DeleteSeriesView'); -var EpisodeDetailsLayout = require('../../Episode/EpisodeDetailsLayout'); -var HistoryDetailsLayout = require('../../Activity/History/Details/HistoryDetailsLayout'); -var LogDetailsView = require('../../System/Logs/Table/Details/LogDetailsView'); -var RenamePreviewLayout = require('../../Rename/RenamePreviewLayout'); -var ManualImportLayout = require('../../ManualImport/ManualImportLayout'); -var FileBrowserLayout = require('../FileBrowser/FileBrowserLayout'); - -module.exports = Marionette.AppRouter.extend({ - initialize : function() { - vent.on(vent.Commands.OpenModalCommand, this._openModal, this); - vent.on(vent.Commands.CloseModalCommand, this._closeModal, this); - vent.on(vent.Commands.OpenModal2Command, this._openModal2, this); - vent.on(vent.Commands.CloseModal2Command, this._closeModal2, this); - vent.on(vent.Commands.EditSeriesCommand, this._editSeries, this); - vent.on(vent.Commands.DeleteSeriesCommand, this._deleteSeries, this); - vent.on(vent.Commands.ShowEpisodeDetails, this._showEpisode, this); - vent.on(vent.Commands.ShowHistoryDetails, this._showHistory, this); - vent.on(vent.Commands.ShowLogDetails, this._showLogDetails, this); - vent.on(vent.Commands.ShowRenamePreview, this._showRenamePreview, this); - vent.on(vent.Commands.ShowManualImport, this._showManualImport, this); - vent.on(vent.Commands.ShowFileBrowser, this._showFileBrowser, this); - vent.on(vent.Commands.CloseFileBrowser, this._closeFileBrowser, this); - }, - - _openModal : function(view) { - AppLayout.modalRegion.show(view); - }, - - _closeModal : function() { - AppLayout.modalRegion.closeModal(); - }, - - _openModal2 : function(view) { - AppLayout.modalRegion2.show(view); - }, - - _closeModal2 : function() { - AppLayout.modalRegion2.closeModal(); - }, - - _editSeries : function(options) { - var view = new EditSeriesView({ model : options.series }); - AppLayout.modalRegion.show(view); - }, - - _deleteSeries : function(options) { - var view = new DeleteSeriesView({ model : options.series }); - AppLayout.modalRegion.show(view); - }, - - _showEpisode : function(options) { - var view = new EpisodeDetailsLayout({ - model : options.episode, - hideSeriesLink : options.hideSeriesLink, - openingTab : options.openingTab - }); - AppLayout.modalRegion.show(view); - }, - - _showHistory : function(options) { - var view = new HistoryDetailsLayout({ model : options.model }); - AppLayout.modalRegion.show(view); - }, - - _showLogDetails : function(options) { - var view = new LogDetailsView({ model : options.model }); - AppLayout.modalRegion.show(view); - }, - - _showRenamePreview : function(options) { - var view = new RenamePreviewLayout(options); - AppLayout.modalRegion.show(view); - }, - - _showManualImport : function(options) { - var view = new ManualImportLayout(options); - AppLayout.modalRegion.show(view); - }, - - _showFileBrowser : function(options) { - var view = new FileBrowserLayout(options); - AppLayout.modalRegion2.show(view); - }, - - _closeFileBrowser : function() { - AppLayout.modalRegion2.closeModal(); - } -}); \ No newline at end of file diff --git a/src/UI/Shared/Modal/ModalRegion.js b/src/UI/Shared/Modal/ModalRegion.js deleted file mode 100644 index 91fccccc5..000000000 --- a/src/UI/Shared/Modal/ModalRegion.js +++ /dev/null @@ -1,7 +0,0 @@ -var ModalRegionBase = require('./ModalRegionBase'); - -var region = ModalRegionBase.extend({ - el : '#modal-region' -}); - -module.exports = region; \ No newline at end of file diff --git a/src/UI/Shared/Modal/ModalRegion2.js b/src/UI/Shared/Modal/ModalRegion2.js deleted file mode 100644 index f9f38bea4..000000000 --- a/src/UI/Shared/Modal/ModalRegion2.js +++ /dev/null @@ -1,30 +0,0 @@ -var $ = require('jquery'); -var ModalRegionBase = require('./ModalRegionBase'); - -var region = ModalRegionBase.extend({ - el : '#modal-region2', - - initialize : function () { - this.listenTo(this, 'modal:beforeShow', this.onBeforeShow); - }, - - onBeforeShow : function () { - this.$el.addClass('modal fade'); - this.$el.attr('tabindex', '-1'); - this.$el.css('z-index', '1060'); - - this.$el.on('shown.bs.modal', function() { - $('.modal-backdrop:last').css('z-index', 1059); - }); - }, - - _closed : function () { - ModalRegionBase.prototype._closed.apply(this, arguments); - - if (require('../../AppLayout').modalRegion.currentView) { - $('body').addClass('modal-open'); - } - } -}); - -module.exports = region; \ No newline at end of file diff --git a/src/UI/Shared/Modal/ModalRegionBase.js b/src/UI/Shared/Modal/ModalRegionBase.js deleted file mode 100644 index 91c8ab32d..000000000 --- a/src/UI/Shared/Modal/ModalRegionBase.js +++ /dev/null @@ -1,65 +0,0 @@ -var _ = require('underscore'); -var $ = require('jquery'); -var Backbone = require('backbone'); -var Marionette = require('marionette'); -require('bootstrap'); -var region = Marionette.Region.extend({ - el : '#modal-region', - - constructor : function() { - Backbone.Marionette.Region.prototype.constructor.apply(this, arguments); - this.on('show', this.showModal, this); - }, - - getEl : function(selector) { - var $el = $(selector); - $el.on('hidden', this.close); - return $el; - }, - - showModal : function() { - this.trigger('modal:beforeShow'); - this.$el.addClass('modal fade'); - - //need tab index so close on escape works - //https://github.com/twitter/bootstrap/issues/4663 - this.$el.attr('tabindex', '-1'); - this.$el.modal({ - show : true, - keyboard : true, - backdrop : true - }); - - this.$el.on('hide.bs.modal', $.proxy(this._closing, this)); - this.$el.on('hidden.bs.modal', $.proxy(this._closed, this)); - - this.currentView.$el.addClass('modal-dialog'); - - this.$el.on('shown.bs.modal', _.bind(function() { - this.trigger('modal:afterShow'); - this.currentView.trigger('modal:afterShow'); - }, this)); - }, - - closeModal : function() { - $(this.el).modal('hide'); - this.reset(); - }, - - _closing : function() { - if (this.$el) { - this.$el.off('hide.bs.modal'); - this.$el.off('shown.bs.modal'); - } - - this.reset(); - }, - - _closed: function () { - if (this.$el) { - this.$el.off('hidden.bs.modal'); - } - } -}); - -module.exports = region; \ No newline at end of file diff --git a/src/UI/Shared/NotFoundView.js b/src/UI/Shared/NotFoundView.js deleted file mode 100644 index f0b34039a..000000000 --- a/src/UI/Shared/NotFoundView.js +++ /dev/null @@ -1,5 +0,0 @@ -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'Shared/NotFoundViewTemplate' -}); \ No newline at end of file diff --git a/src/UI/Shared/NotFoundViewTemplate.hbs b/src/UI/Shared/NotFoundViewTemplate.hbs deleted file mode 100644 index 4073bba9f..000000000 --- a/src/UI/Shared/NotFoundViewTemplate.hbs +++ /dev/null @@ -1,4 +0,0 @@ -<div> - <img src="{{UrlBase}}/Content/Images/404.png" style="height:400px; margin-top: 50px"/> - -</div> diff --git a/src/UI/Shared/NzbDroneController.js b/src/UI/Shared/NzbDroneController.js deleted file mode 100644 index a97dea369..000000000 --- a/src/UI/Shared/NzbDroneController.js +++ /dev/null @@ -1,67 +0,0 @@ -var vent = require('vent'); -var AppLayout = require('../AppLayout'); -var Marionette = require('marionette'); -var NotFoundView = require('./NotFoundView'); -var Messenger = require('./Messenger'); - -module.exports = Marionette.AppRouter.extend({ - initialize : function() { - this.listenTo(vent, vent.Events.ServerUpdated, this._onServerUpdated); - }, - - showNotFound : function() { - this.setTitle('Not Found'); - this.showMainRegion(new NotFoundView(this)); - }, - - setTitle : function(title) { - title = title; - if (title === 'Sonarr') { - document.title = 'Sonarr'; - } else { - document.title = title + ' - Sonarr'; - } - - if (window.NzbDrone.Analytics && window.Piwik) { - try { - var piwik = window.Piwik.getTracker(window.location.protocol + '//piwik.nzbdrone.com/piwik.php', 1); - piwik.setReferrerUrl(''); - piwik.setCustomUrl('http://local' + window.location.pathname); - piwik.setCustomVariable(1, 'version', window.NzbDrone.Version, 'page'); - piwik.setCustomVariable(2, 'branch', window.NzbDrone.Branch, 'page'); - piwik.trackPageView(title); - } - catch (e) { - console.error(e); - } - } - }, - - _onServerUpdated : function() { - var label = window.location.pathname === window.NzbDrone.UrlBase + '/system/updates' ? 'Reload' : 'View Changes'; - - Messenger.show({ - message : 'Sonarr has been updated', - hideAfter : 0, - id : 'sonarrUpdated', - actions : { - viewChanges : { - label : label, - action : function() { - window.location = window.NzbDrone.UrlBase + '/system/updates'; - } - } - } - }); - - this.pendingUpdate = true; - }, - - showMainRegion : function(view) { - if (this.pendingUpdate) { - window.location.reload(); - } else { - AppLayout.mainRegion.show(view); - } - } -}); \ No newline at end of file diff --git a/src/UI/Shared/SignalRBroadcaster.js b/src/UI/Shared/SignalRBroadcaster.js deleted file mode 100644 index 2d5292760..000000000 --- a/src/UI/Shared/SignalRBroadcaster.js +++ /dev/null @@ -1,76 +0,0 @@ -var vent = require('vent'); -var $ = require('jquery'); -var Messenger = require('./Messenger'); -var StatusModel = require('../System/StatusModel'); -require('signalR'); - -module.exports = { - appInitializer : function() { - console.log('starting signalR'); - - var getStatus = function(status) { - switch (status) { - case 0: - return 'connecting'; - case 1: - return 'connected'; - case 2: - return 'reconnecting'; - case 4: - return 'disconnected'; - default: - throw 'invalid status ' + status; - } - }; - - var tryingToReconnect = false; - var messengerId = 'signalR'; - - this.signalRconnection = $.connection(StatusModel.get('urlBase') + '/signalr'); - - this.signalRconnection.stateChanged(function(change) { - console.debug('SignalR: [{0}]'.format(getStatus(change.newState))); - }); - - this.signalRconnection.received(function(message) { - vent.trigger('server:' + message.name, message.body); - }); - - this.signalRconnection.reconnecting(function() { - if (window.NzbDrone.unloading) { - return; - } - - tryingToReconnect = true; - }); - - this.signalRconnection.reconnected(function() { - tryingToReconnect = false; - }); - - this.signalRconnection.disconnected(function() { - if (tryingToReconnect) { - $('<div class="modal-backdrop fade in"></div>').appendTo(document.body); - - Messenger.show({ - id : messengerId, - type : 'error', - hideAfter : 0, - message : 'Connection to backend lost', - actions : { - cancel : { - label : 'Reload', - action : function() { - window.location.reload(); - } - } - } - }); - } - }); - - this.signalRconnection.start({ transport : ['longPolling'] }); - - return this; - } -}; \ No newline at end of file diff --git a/src/UI/Shared/Styles/card.less b/src/UI/Shared/Styles/card.less deleted file mode 100644 index 92c275a8b..000000000 --- a/src/UI/Shared/Styles/card.less +++ /dev/null @@ -1,10 +0,0 @@ -@import "../../Content/prefixer"; - -.card(@color : #e1e1e1 ) { - margin : 10px; - background-color : #ffffff; - padding : 10px; - color : #444444; - .box-shadow( 0px 0px 10px 1px @color); - .border-radius(3px); -} diff --git a/src/UI/Shared/Toolbar/Button/ButtonCollectionView.js b/src/UI/Shared/Toolbar/Button/ButtonCollectionView.js deleted file mode 100644 index 097df89ab..000000000 --- a/src/UI/Shared/Toolbar/Button/ButtonCollectionView.js +++ /dev/null @@ -1,22 +0,0 @@ -var Marionette = require('marionette'); -var ButtonView = require('./ButtonView'); - -module.exports = Marionette.CollectionView.extend({ - className : 'btn-group', - itemView : ButtonView, - - initialize : function(options) { - this.menu = options.menu; - this.className = 'btn-group'; - - if (options.menu.collapse) { - this.className += ' btn-group-collapse'; - } - }, - - onRender : function() { - if (this.menu.collapse) { - this.$el.addClass('btn-group-collapse'); - } - } -}); \ No newline at end of file diff --git a/src/UI/Shared/Toolbar/Button/ButtonView.js b/src/UI/Shared/Toolbar/Button/ButtonView.js deleted file mode 100644 index 20e77e4e9..000000000 --- a/src/UI/Shared/Toolbar/Button/ButtonView.js +++ /dev/null @@ -1,85 +0,0 @@ -var Backbone = require('backbone'); -var Marionette = require('marionette'); -var _ = require('underscore'); -var CommandController = require('../../../Commands/CommandController'); - -module.exports = Marionette.ItemView.extend({ - template : 'Shared/Toolbar/ButtonTemplate', - className : 'btn btn-default btn-icon-only-xs', - - ui : { - icon : 'i' - }, - - events : { - 'click' : 'onClick' - }, - - initialize : function() { - this.storageKey = this.model.get('menuKey') + ':' + this.model.get('key'); - }, - - onRender : function() { - if (this.model.get('active')) { - this.$el.addClass('active'); - this.invokeCallback(); - } - - if (!this.model.get('title')) { - this.$el.addClass('btn-icon-only'); - } - - if (this.model.get('className')) { - this.$el.addClass(this.model.get('className')); - } - - if (this.model.get('tooltip')) { - this.$el.attr('title', this.model.get('tooltip')); - } - - var command = this.model.get('command'); - if (command) { - var properties = _.extend({ name : command }, this.model.get('properties')); - - CommandController.bindToCommand({ - command : properties, - element : this.$el - }); - } - }, - - onClick : function() { - if (this.$el.hasClass('disabled')) { - return; - } - - this.invokeCallback(); - this.invokeRoute(); - this.invokeCommand(); - }, - - invokeCommand : function() { - var command = this.model.get('command'); - if (command) { - CommandController.Execute(command, this.model.get('properties')); - } - }, - - invokeRoute : function() { - var route = this.model.get('route'); - if (route) { - Backbone.history.navigate(route, { trigger : true }); - } - }, - - invokeCallback : function() { - if (!this.model.ownerContext) { - throw 'ownerContext must be set.'; - } - - var callback = this.model.get('callback'); - if (callback) { - callback.call(this.model.ownerContext, this); - } - } -}); \ No newline at end of file diff --git a/src/UI/Shared/Toolbar/ButtonCollection.js b/src/UI/Shared/Toolbar/ButtonCollection.js deleted file mode 100644 index a48a04574..000000000 --- a/src/UI/Shared/Toolbar/ButtonCollection.js +++ /dev/null @@ -1,6 +0,0 @@ -var Backbone = require('backbone'); -var ButtonModel = require('./ButtonModel'); - -module.exports = Backbone.Collection.extend({ - model : ButtonModel -}); \ No newline at end of file diff --git a/src/UI/Shared/Toolbar/ButtonModel.js b/src/UI/Shared/Toolbar/ButtonModel.js deleted file mode 100644 index 055a4ef8f..000000000 --- a/src/UI/Shared/Toolbar/ButtonModel.js +++ /dev/null @@ -1,11 +0,0 @@ -var _ = require('underscore'); -var Backbone = require('backbone'); - -module.exports = Backbone.Model.extend({ - defaults : { - 'target' : '/nzbdrone/route', - 'title' : '', - 'active' : false, - 'tooltip' : undefined - } -}); \ No newline at end of file diff --git a/src/UI/Shared/Toolbar/ButtonTemplate.hbs b/src/UI/Shared/Toolbar/ButtonTemplate.hbs deleted file mode 100644 index d21cdbc9d..000000000 --- a/src/UI/Shared/Toolbar/ButtonTemplate.hbs +++ /dev/null @@ -1 +0,0 @@ -<i class="{{icon}} x-icon"/><span> {{title}}</span> diff --git a/src/UI/Shared/Toolbar/Radio/RadioButtonCollectionView.js b/src/UI/Shared/Toolbar/Radio/RadioButtonCollectionView.js deleted file mode 100644 index 70c4fb188..000000000 --- a/src/UI/Shared/Toolbar/Radio/RadioButtonCollectionView.js +++ /dev/null @@ -1,37 +0,0 @@ -var Marionette = require('marionette'); -var RadioButtonView = require('./RadioButtonView'); -var Config = require('../../../Config'); - -module.exports = Marionette.CollectionView.extend({ - className : 'btn-group', - itemView : RadioButtonView, - - attributes : { - 'data-toggle' : 'buttons' - }, - - initialize : function(options) { - this.menu = options.menu; - - this.setActive(); - }, - - setActive : function() { - var storedKey = this.menu.defaultAction; - - if (this.menu.storeState) { - storedKey = Config.getValue(this.menu.menuKey, storedKey); - } - - if (!storedKey) { - return; - } - this.collection.each(function(model) { - if (model.get('key').toLocaleLowerCase() === storedKey.toLowerCase()) { - model.set('active', true); - } else { - model.set('active, false'); - } - }); - } -}); \ No newline at end of file diff --git a/src/UI/Shared/Toolbar/Radio/RadioButtonView.js b/src/UI/Shared/Toolbar/Radio/RadioButtonView.js deleted file mode 100644 index 90fa0bd0c..000000000 --- a/src/UI/Shared/Toolbar/Radio/RadioButtonView.js +++ /dev/null @@ -1,50 +0,0 @@ -var Marionette = require('marionette'); -var Config = require('../../../Config'); - -module.exports = Marionette.ItemView.extend({ - template : 'Shared/Toolbar/RadioButtonTemplate', - className : 'btn btn-default', - - ui : { - icon : 'i' - }, - - events : { - 'click' : 'onClick' - }, - - initialize : function() { - this.storageKey = this.model.get('menuKey') + ':' + this.model.get('key'); - }, - - onRender : function() { - if (this.model.get('active')) { - this.$el.addClass('active'); - this.invokeCallback(); - } - - if (!this.model.get('title')) { - this.$el.addClass('btn-icon-only'); - } - - if (this.model.get('tooltip')) { - this.$el.attr('title', this.model.get('tooltip')); - } - }, - - onClick : function() { - Config.setValue(this.model.get('menuKey'), this.model.get('key')); - this.invokeCallback(); - }, - - invokeCallback : function() { - if (!this.model.ownerContext) { - throw 'ownerContext must be set.'; - } - - var callback = this.model.get('callback'); - if (callback) { - callback.call(this.model.ownerContext, this); - } - } -}); \ No newline at end of file diff --git a/src/UI/Shared/Toolbar/RadioButtonTemplate.hbs b/src/UI/Shared/Toolbar/RadioButtonTemplate.hbs deleted file mode 100644 index aaff67405..000000000 --- a/src/UI/Shared/Toolbar/RadioButtonTemplate.hbs +++ /dev/null @@ -1 +0,0 @@ -<input type="radio"><i class="{{icon}} x-icon"/><span> {{title}}</span> diff --git a/src/UI/Shared/Toolbar/Sorting/SortingButtonCollectionView.js b/src/UI/Shared/Toolbar/Sorting/SortingButtonCollectionView.js deleted file mode 100644 index 6db8995a2..000000000 --- a/src/UI/Shared/Toolbar/Sorting/SortingButtonCollectionView.js +++ /dev/null @@ -1,38 +0,0 @@ -var PageableCollection = require('backbone.pageable'); -var Marionette = require('marionette'); -var ButtonView = require('./SortingButtonView'); - -module.exports = Marionette.CompositeView.extend({ - itemView : ButtonView, - template : 'Shared/Toolbar/Sorting/SortingButtonCollectionViewTemplate', - itemViewContainer : '.dropdown-menu', - - initialize : function(options) { - this.viewCollection = options.viewCollection; - this.listenTo(this.viewCollection, 'drone:sort', this.sort); - }, - - itemViewOptions : function() { - return { - viewCollection : this.viewCollection - }; - }, - - sort : function(sortModel, sortDirection) { - var collection = this.viewCollection; - - var order; - if (sortDirection === 'ascending') { - order = -1; - } else if (sortDirection === 'descending') { - order = 1; - } else { - order = null; - } - - collection.setSorting(sortModel.get('name'), order); - collection.fullCollection.sort(); - - return this; - } -}); \ No newline at end of file diff --git a/src/UI/Shared/Toolbar/Sorting/SortingButtonCollectionViewTemplate.hbs b/src/UI/Shared/Toolbar/Sorting/SortingButtonCollectionViewTemplate.hbs deleted file mode 100644 index 80d4888de..000000000 --- a/src/UI/Shared/Toolbar/Sorting/SortingButtonCollectionViewTemplate.hbs +++ /dev/null @@ -1,8 +0,0 @@ -<div class="btn-group sorting-buttons"> - <a class="btn btn-default dropdown-toggle" data-toggle="dropdown" href="#"> - Sort <span class="caret"></span> - </a> - <ul class="dropdown-menu"> - - </ul> -</div> diff --git a/src/UI/Shared/Toolbar/Sorting/SortingButtonView.js b/src/UI/Shared/Toolbar/Sorting/SortingButtonView.js deleted file mode 100644 index 6f6833ed2..000000000 --- a/src/UI/Shared/Toolbar/Sorting/SortingButtonView.js +++ /dev/null @@ -1,70 +0,0 @@ -var Backbone = require('backbone'); -var Marionette = require('marionette'); -var _ = require('underscore'); - -module.exports = Marionette.ItemView.extend({ - template : 'Shared/Toolbar/Sorting/SortingButtonViewTemplate', - tagName : 'li', - - ui : { - icon : 'i' - }, - - events : { - 'click' : 'onClick' - }, - - initialize : function(options) { - this.viewCollection = options.viewCollection; - this.listenTo(this.viewCollection, 'drone:sort', this.render); - this.listenTo(this.viewCollection, 'backgrid:sort', this.render); - }, - - onRender : function() { - if (this.viewCollection.state) { - var sortKey = this.viewCollection.state.sortKey; - var name = this.viewCollection._getSortMapping(sortKey).name; - var order = this.viewCollection.state.order; - - if (name === this.model.get('name')) { - this._setSortIcon(order); - } else { - this._removeSortIcon(); - } - } - }, - - onClick : function(e) { - e.preventDefault(); - - var collection = this.viewCollection; - var event = 'drone:sort'; - - var direction = collection.state.order; - if (direction === 'ascending' || direction === -1) { - direction = 'descending'; - } else { - direction = 'ascending'; - } - - collection.setSorting(this.model.get('name'), direction); - collection.trigger(event, this.model, direction); - }, - - _convertDirectionToIcon : function(dir) { - if (dir === 'ascending' || dir === -1) { - return 'icon-sonarr-sort-asc'; - } - - return 'icon-sonarr-sort-desc'; - }, - - _setSortIcon : function(dir) { - this._removeSortIcon(); - this.ui.icon.addClass(this._convertDirectionToIcon(dir)); - }, - - _removeSortIcon : function() { - this.ui.icon.removeClass('icon-sonarr-sort-asc icon-sonarr-sort-desc'); - } -}); \ No newline at end of file diff --git a/src/UI/Shared/Toolbar/Sorting/SortingButtonViewTemplate.hbs b/src/UI/Shared/Toolbar/Sorting/SortingButtonViewTemplate.hbs deleted file mode 100644 index 57018028d..000000000 --- a/src/UI/Shared/Toolbar/Sorting/SortingButtonViewTemplate.hbs +++ /dev/null @@ -1,4 +0,0 @@ -<a href="#"> - <span class="sorting-title">{{title}}</span> - <i class=""></i> -</a> \ No newline at end of file diff --git a/src/UI/Shared/Toolbar/ToolbarLayout.js b/src/UI/Shared/Toolbar/ToolbarLayout.js deleted file mode 100644 index ebad4c789..000000000 --- a/src/UI/Shared/Toolbar/ToolbarLayout.js +++ /dev/null @@ -1,108 +0,0 @@ -var Marionette = require('marionette'); -var ButtonCollection = require('./ButtonCollection'); -var ButtonModel = require('./ButtonModel'); -var RadioButtonCollectionView = require('./Radio/RadioButtonCollectionView'); -var ButtonCollectionView = require('./Button/ButtonCollectionView'); -var SortingButtonCollectionView = require('./Sorting/SortingButtonCollectionView'); -var _ = require('underscore'); - -module.exports = Marionette.Layout.extend({ - template : 'Shared/Toolbar/ToolbarLayoutTemplate', - className : 'toolbar', - - ui : { - left_x : '.x-toolbar-left', - right_x : '.x-toolbar-right' - }, - - initialize : function(options) { - if (!options) { - throw 'options needs to be passed'; - } - - if (!options.context) { - throw 'context needs to be passed'; - } - - this.templateHelpers = { - floatOnMobile : options.floatOnMobile || false - }; - - this.left = options.left; - this.right = options.right; - this.toolbarContext = options.context; - }, - - onShow : function() { - if (this.left) { - _.each(this.left, this._showToolbarLeft, this); - } - if (this.right) { - _.each(this.right, this._showToolbarRight, this); - } - }, - - _showToolbarLeft : function(element, index) { - this._showToolbar(element, index, 'left'); - }, - - _showToolbarRight : function(element, index) { - this._showToolbar(element, index, 'right'); - }, - - _showToolbar : function(buttonGroup, index, position) { - var groupCollection = new ButtonCollection(); - - _.each(buttonGroup.items, function(button) { - if (buttonGroup.storeState && !button.key) { - throw 'must provide key for all buttons when storeState is enabled'; - } - - var model = new ButtonModel(button); - model.set('menuKey', buttonGroup.menuKey); - model.ownerContext = this.toolbarContext; - groupCollection.add(model); - }, this); - - var buttonGroupView; - - switch (buttonGroup.type) { - case 'radio': - { - buttonGroupView = new RadioButtonCollectionView({ - collection : groupCollection, - menu : buttonGroup - }); - break; - } - case 'sorting': - { - buttonGroupView = new SortingButtonCollectionView({ - collection : groupCollection, - menu : buttonGroup, - viewCollection : buttonGroup.viewCollection - }); - break; - } - default: - { - buttonGroupView = new ButtonCollectionView({ - collection : groupCollection, - menu : buttonGroup - }); - break; - } - } - - var regionId = position + '_' + (index + 1); - var region = this[regionId]; - - if (!region) { - var regionClassName = 'x-toolbar-' + position + '-' + (index + 1); - this.ui[position + '_x'].append('<div class="toolbar-group ' + regionClassName + '" />\r\n'); - region = this.addRegion(regionId, '.' + regionClassName); - } - - region.show(buttonGroupView); - } -}); \ No newline at end of file diff --git a/src/UI/Shared/Toolbar/ToolbarLayoutTemplate.hbs b/src/UI/Shared/Toolbar/ToolbarLayoutTemplate.hbs deleted file mode 100644 index 0cd0e21e0..000000000 --- a/src/UI/Shared/Toolbar/ToolbarLayoutTemplate.hbs +++ /dev/null @@ -1,2 +0,0 @@ -<div class="page-toolbar pull-left {{#unless floatOnMobile}}pull-none-xs{{/unless}} x-toolbar-left" /> -<div class="page-toolbar pull-right {{#unless floatOnMobile}}pull-none-xs{{/unless}} x-toolbar-right" /> diff --git a/src/UI/Shared/Tooltip.js b/src/UI/Shared/Tooltip.js deleted file mode 100644 index c19b369fb..000000000 --- a/src/UI/Shared/Tooltip.js +++ /dev/null @@ -1,47 +0,0 @@ -var $ = require('jquery'); -require('bootstrap'); - -var Tooltip = $.fn.tooltip.Constructor; - -var origGetOptions = Tooltip.prototype.getOptions; -Tooltip.prototype.getOptions = function(options) { - var result = origGetOptions.call(this, options); - - if (result.container === false) { - - var container = this.$element.closest('.btn-group,.input-group').parent(); - - if (container.length) { - result.container = container; - } - } - - return result; -}; - -var onElementRemoved = function(event) { - event.data.hide(); -}; - -var origShow = Tooltip.prototype.show; -Tooltip.prototype.show = function() { - origShow.call(this); - - this.$element.on('remove', this, onElementRemoved); -}; - -var origHide = Tooltip.prototype.hide; -Tooltip.prototype.hide = function() { - origHide.call(this); - - this.$element.off('remove', onElementRemoved); -}; - -module.exports = { - appInitializer : function() { - - $('body').tooltip({ selector : '[title]' }); - - return this; - } -}; \ No newline at end of file diff --git a/src/UI/Shared/UiSettingsController.js b/src/UI/Shared/UiSettingsController.js deleted file mode 100644 index eb9210659..000000000 --- a/src/UI/Shared/UiSettingsController.js +++ /dev/null @@ -1,26 +0,0 @@ -var $ = require('jquery'); -var _ = require('underscore'); -var UiSettingsModel = require('./UiSettingsModel'); - -var Controller = { - - appInitializer : function() { - - UiSettingsModel.on('sync', this._updateUiSettings); - - this._updateUiSettings(); - }, - - _updateUiSettings: function() { - - if (UiSettingsModel.get('enableColorImpairedMode')) { - $('body').addClass('color-impaired-mode'); - } else { - $('body').removeClass('color-impaired-mode'); - } - } -}; - -_.bindAll(Controller, 'appInitializer'); - -module.exports = Controller; \ No newline at end of file diff --git a/src/UI/Shared/UiSettingsModel.js b/src/UI/Shared/UiSettingsModel.js deleted file mode 100644 index a517b5aba..000000000 --- a/src/UI/Shared/UiSettingsModel.js +++ /dev/null @@ -1,29 +0,0 @@ -var Backbone = require('backbone'); -var ApiData = require('./ApiData'); - -var UiSettings = Backbone.Model.extend({ - url : window.NzbDrone.ApiRoot + '/config/ui', - - shortDateTime : function(includeSeconds) { - return this.get('shortDateFormat') + ' ' + this.time(true, includeSeconds); - }, - - longDateTime : function(includeSeconds) { - return this.get('longDateFormat') + ' ' + this.time(true, includeSeconds); - }, - - time : function(includeMinuteZero, includeSeconds) { - if (includeSeconds) { - return this.get('timeFormat').replace(/\(?\:mm\)?/, ':mm:ss'); - } - if (includeMinuteZero) { - return this.get('timeFormat').replace('(', '').replace(')', ''); - } - - return this.get('timeFormat').replace(/\(\:mm\)/, ''); - } -}); - -var instance = new UiSettings(ApiData.get('config/ui')); - -module.exports = instance; \ No newline at end of file diff --git a/src/UI/Shared/VersionChangeMonitor.js b/src/UI/Shared/VersionChangeMonitor.js deleted file mode 100644 index 932d97f6c..000000000 --- a/src/UI/Shared/VersionChangeMonitor.js +++ /dev/null @@ -1,13 +0,0 @@ -var $ = require('jquery'); -var vent = require('vent'); - -$(document).ajaxSuccess(function(event, xhr) { - var version = xhr.getResponseHeader('X-ApplicationVersion'); - if (!version || !window.NzbDrone || !window.NzbDrone.Version) { - return; - } - - if (version !== window.NzbDrone.Version) { - vent.trigger(vent.Events.ServerUpdated); - } -}); diff --git a/src/UI/Shared/piwikCheck.js b/src/UI/Shared/piwikCheck.js deleted file mode 100644 index 0146d36b5..000000000 --- a/src/UI/Shared/piwikCheck.js +++ /dev/null @@ -1,12 +0,0 @@ -'use strict'; - -if(window.NzbDrone.Analytics) { - var d = document; - var g = d.createElement('script'); - var s = d.getElementsByTagName('script')[0]; - g.type = 'text/javascript'; - g.async = true; - g.defer = true; - g.src = '//piwik.sonarr.tv/piwik.js'; - s.parentNode.insertBefore(g, s); -} diff --git a/src/UI/Shims/backbone.backgrid.selectall.js b/src/UI/Shims/backbone.backgrid.selectall.js deleted file mode 100644 index 10b92f5d9..000000000 --- a/src/UI/Shims/backbone.backgrid.selectall.js +++ /dev/null @@ -1,4 +0,0 @@ -var backgrid = require('backgrid'); -require('../JsLibraries/backbone.backgrid.selectall'); - -module.exports = backgrid.Extension.SelectRowCell; \ No newline at end of file diff --git a/src/UI/Shims/backbone.collectionview.js b/src/UI/Shims/backbone.collectionview.js deleted file mode 100644 index a4080a462..000000000 --- a/src/UI/Shims/backbone.collectionview.js +++ /dev/null @@ -1,4 +0,0 @@ -require('backbone'); -require('../JsLibraries/backbone.collectionview'); - -module.exports = window.Backbone.CollectionView; \ No newline at end of file diff --git a/src/UI/Shims/backbone.deep.model.js b/src/UI/Shims/backbone.deep.model.js deleted file mode 100644 index dc7b47265..000000000 --- a/src/UI/Shims/backbone.deep.model.js +++ /dev/null @@ -1,4 +0,0 @@ -require('backbone'); -require('../JsLibraries/backbone.deep.model'); - -module.exports = window.Backbone.DeepModel; \ No newline at end of file diff --git a/src/UI/Shims/backbone.js b/src/UI/Shims/backbone.js deleted file mode 100644 index 0896076d8..000000000 --- a/src/UI/Shims/backbone.js +++ /dev/null @@ -1,7 +0,0 @@ -var jquery = require('jquery'); -var Backbone = require('../JsLibraries/backbone'); - -window.Backbone = Backbone; -Backbone.$ = jquery; - -module.exports = Backbone; \ No newline at end of file diff --git a/src/UI/Shims/backbone.marionette.js b/src/UI/Shims/backbone.marionette.js deleted file mode 100644 index 50b3bf182..000000000 --- a/src/UI/Shims/backbone.marionette.js +++ /dev/null @@ -1,10 +0,0 @@ -require('backbone'); -require('../JsLibraries/backbone.marionette'); - -var templateMixin = require('../Handlebars/backbone.marionette.templates'); -var asNamedView = require('../Mixins/AsNamedView'); - -templateMixin.call(window.Marionette.TemplateCache); -asNamedView.call(window.Marionette.ItemView.prototype); - -module.exports = window.Marionette; \ No newline at end of file diff --git a/src/UI/Shims/backbone.validation.js b/src/UI/Shims/backbone.validation.js deleted file mode 100644 index 158e42265..000000000 --- a/src/UI/Shims/backbone.validation.js +++ /dev/null @@ -1,8 +0,0 @@ -require('backbone'); -require('../JsLibraries/backbone.validation'); -var $ = require('jquery'); - -var jqueryValidation = require('../jQuery/jquery.validation'); -jqueryValidation.call($); - -module.exports = window.Backbone.Validation; \ No newline at end of file diff --git a/src/UI/Shims/backgrid.js b/src/UI/Shims/backgrid.js deleted file mode 100644 index 0292b5264..000000000 --- a/src/UI/Shims/backgrid.js +++ /dev/null @@ -1,19 +0,0 @@ -require('backbone'); - -var backgrid = require('../JsLibraries/backbone.backgrid'); -var header = require('../Shared/Grid/HeaderCell'); - -header.call(backgrid); - -backgrid.Column.prototype.defaults = { - name : undefined, - label : undefined, - sortable : true, - editable : false, - renderable : true, - formatter : undefined, - cell : undefined, - headerCell : 'Sonarr', - sortType : 'toggle' -}; -module.exports = backgrid; \ No newline at end of file diff --git a/src/UI/Shims/backgrid.paginator.js b/src/UI/Shims/backgrid.paginator.js deleted file mode 100644 index 874fb4006..000000000 --- a/src/UI/Shims/backgrid.paginator.js +++ /dev/null @@ -1,5 +0,0 @@ -require('backbone'); -var backgrid = require('backgrid'); -require('../JsLibraries/backbone.backgrid.paginator'); - -module.exports = backgrid.Extension.Paginator; \ No newline at end of file diff --git a/src/UI/Shims/handlebars.js b/src/UI/Shims/handlebars.js deleted file mode 100644 index 539e73271..000000000 --- a/src/UI/Shims/handlebars.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = window.Handlebars; \ No newline at end of file diff --git a/src/UI/Shims/jquery.js b/src/UI/Shims/jquery.js deleted file mode 100644 index 19e901944..000000000 --- a/src/UI/Shims/jquery.js +++ /dev/null @@ -1,11 +0,0 @@ -var jquery = require('../JsLibraries/jquery'); -require('../Instrumentation/StringFormat'); -var spin = require('../jQuery/jquery.spin'); -var ajax = require('../jQuery/jquery.ajax'); - -spin.call(jquery); -ajax.call(jquery); - -window.$ = jquery; -window.jQuery = jquery; -module.exports = jquery; diff --git a/src/UI/Shims/jquery.signalR.js b/src/UI/Shims/jquery.signalR.js deleted file mode 100644 index 70139ccef..000000000 --- a/src/UI/Shims/jquery.signalR.js +++ /dev/null @@ -1,4 +0,0 @@ -require('jquery'); -var signalR = require('../JsLibraries/jquery.signalR'); - -module.exports = signalR; \ No newline at end of file diff --git a/src/UI/Shims/messenger.js b/src/UI/Shims/messenger.js deleted file mode 100644 index f070bb991..000000000 --- a/src/UI/Shims/messenger.js +++ /dev/null @@ -1,6 +0,0 @@ -require('jquery'); -var m = require('../JsLibraries/messenger'); - -window.Messenger.options = { theme : 'flat' }; - -module.exports = window.Messenger; \ No newline at end of file diff --git a/src/UI/Shims/underscore.js b/src/UI/Shims/underscore.js deleted file mode 100644 index 67b9b808b..000000000 --- a/src/UI/Shims/underscore.js +++ /dev/null @@ -1,4 +0,0 @@ -var _ = require('../JsLibraries/lodash.underscore'); -window._ = window._ || _; - -module.exports = window._; \ No newline at end of file diff --git a/src/UI/System/Backup/BackupCollection.js b/src/UI/System/Backup/BackupCollection.js deleted file mode 100644 index 5bee1fc35..000000000 --- a/src/UI/System/Backup/BackupCollection.js +++ /dev/null @@ -1,15 +0,0 @@ -var PageableCollection = require('backbone.pageable'); -var BackupModel = require('./BackupModel'); - -module.exports = PageableCollection.extend({ - url : window.NzbDrone.ApiRoot + '/system/backup', - model : BackupModel, - - state : { - sortKey : 'time', - order : 1, - pageSize : 100000 - }, - - mode : 'client' -}); \ No newline at end of file diff --git a/src/UI/System/Backup/BackupEmptyView.js b/src/UI/System/Backup/BackupEmptyView.js deleted file mode 100644 index a86ba42bc..000000000 --- a/src/UI/System/Backup/BackupEmptyView.js +++ /dev/null @@ -1,5 +0,0 @@ -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'System/Backup/BackupEmptyViewTemplate' -}); \ No newline at end of file diff --git a/src/UI/System/Backup/BackupEmptyViewTemplate.hbs b/src/UI/System/Backup/BackupEmptyViewTemplate.hbs deleted file mode 100644 index 2e14e7145..000000000 --- a/src/UI/System/Backup/BackupEmptyViewTemplate.hbs +++ /dev/null @@ -1 +0,0 @@ -<div>No backups are available</div> \ No newline at end of file diff --git a/src/UI/System/Backup/BackupFilenameCell.js b/src/UI/System/Backup/BackupFilenameCell.js deleted file mode 100644 index c8a57d1f9..000000000 --- a/src/UI/System/Backup/BackupFilenameCell.js +++ /dev/null @@ -1,6 +0,0 @@ -var TemplatedCell = require('../../Cells/TemplatedCell'); - -module.exports = TemplatedCell.extend({ - className : 'series-title-cell', - template : 'System/Backup/BackupFilenameCellTemplate' -}); \ No newline at end of file diff --git a/src/UI/System/Backup/BackupFilenameCellTemplate.hbs b/src/UI/System/Backup/BackupFilenameCellTemplate.hbs deleted file mode 100644 index e129039c3..000000000 --- a/src/UI/System/Backup/BackupFilenameCellTemplate.hbs +++ /dev/null @@ -1 +0,0 @@ -<a href="{{UrlBase}}/backup/{{type}}/{{name}}" class="no-router">{{name}}</a> diff --git a/src/UI/System/Backup/BackupLayout.js b/src/UI/System/Backup/BackupLayout.js deleted file mode 100644 index c1eb341cd..000000000 --- a/src/UI/System/Backup/BackupLayout.js +++ /dev/null @@ -1,94 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); -var Backgrid = require('backgrid'); -var BackupCollection = require('./BackupCollection'); -var RelativeDateCell = require('../../Cells/RelativeDateCell'); -var BackupFilenameCell = require('./BackupFilenameCell'); -var BackupTypeCell = require('./BackupTypeCell'); -var EmptyView = require('./BackupEmptyView'); -var LoadingView = require('../../Shared/LoadingView'); -var ToolbarLayout = require('../../Shared/Toolbar/ToolbarLayout'); - -module.exports = Marionette.Layout.extend({ - template : 'System/Backup/BackupLayoutTemplate', - - regions : { - backups : '#x-backups', - toolbar : '#x-backup-toolbar' - }, - - columns : [ - { - name : 'type', - label : '', - sortable : false, - cell : BackupTypeCell - }, - { - name : 'this', - label : 'Name', - sortable : false, - cell : BackupFilenameCell - }, - { - name : 'time', - label : 'Time', - sortable : false, - cell : RelativeDateCell - } - ], - - leftSideButtons : { - type : 'default', - storeState : false, - collapse : false, - items : [ - { - title : 'Backup', - icon : 'icon-sonarr-file-text', - command : 'backup', - properties : { type : 'manual' }, - successMessage : 'Database and settings were backed up successfully', - errorMessage : 'Backup Failed!' - } - ] - }, - - initialize : function() { - this.backupCollection = new BackupCollection(); - - this.listenTo(this.backupCollection, 'sync', this._showBackups); - this.listenTo(vent, vent.Events.CommandComplete, this._commandComplete); - }, - - onRender : function() { - this._showToolbar(); - this.backups.show(new LoadingView()); - - this.backupCollection.fetch(); - }, - - _showBackups : function() { - if (this.backupCollection.length === 0) { - this.backups.show(new EmptyView()); - } else { - this.backups.show(new Backgrid.Grid({ - columns : this.columns, - collection : this.backupCollection, - className : 'table table-hover' - })); - } - }, - - _showToolbar : function() { - this.toolbar.show(new ToolbarLayout({ - left : [this.leftSideButtons], - context : this - })); - }, - _commandComplete : function(options) { - if (options.command.get('name') === 'backup') { - this.backupCollection.fetch(); - } - } -}); \ No newline at end of file diff --git a/src/UI/System/Backup/BackupLayoutTemplate.hbs b/src/UI/System/Backup/BackupLayoutTemplate.hbs deleted file mode 100644 index b50db1799..000000000 --- a/src/UI/System/Backup/BackupLayoutTemplate.hbs +++ /dev/null @@ -1,10 +0,0 @@ -<div class="row"> - <div class="col-md-12"> - <div id="x-backup-toolbar"/> - </div> -</div> -<div class="row"> - <div class="col-md-12"> - <div id="x-backups" class="table-responsive"/> - </div> -</div> diff --git a/src/UI/System/Backup/BackupModel.js b/src/UI/System/Backup/BackupModel.js deleted file mode 100644 index 3986a5948..000000000 --- a/src/UI/System/Backup/BackupModel.js +++ /dev/null @@ -1,3 +0,0 @@ -var Backbone = require('backbone'); - -module.exports = Backbone.Model.extend({}); \ No newline at end of file diff --git a/src/UI/System/Backup/BackupTypeCell.js b/src/UI/System/Backup/BackupTypeCell.js deleted file mode 100644 index 2f2a3c16c..000000000 --- a/src/UI/System/Backup/BackupTypeCell.js +++ /dev/null @@ -1,26 +0,0 @@ -var NzbDroneCell = require('../../Cells/NzbDroneCell'); - -module.exports = NzbDroneCell.extend({ - className : 'backup-type-cell', - - render : function() { - this.$el.empty(); - - var icon = 'icon-sonarr-backup-scheduled'; - var title = 'Scheduled'; - - var type = this.model.get(this.column.get('name')); - - if (type === 'manual') { - icon = 'icon-sonarr-backup-manual'; - title = 'Manual'; - } else if (type === 'update') { - icon = 'icon-sonarr-backup-update'; - title = 'Before update'; - } - - this.$el.html('<i class="{0}" title="{1}"></i>'.format(icon, title)); - - return this; - } -}); \ No newline at end of file diff --git a/src/UI/System/Info/About/AboutView.js b/src/UI/System/Info/About/AboutView.js deleted file mode 100644 index 494b9a3ef..000000000 --- a/src/UI/System/Info/About/AboutView.js +++ /dev/null @@ -1,10 +0,0 @@ -var Marionette = require('marionette'); -var StatusModel = require('../../StatusModel'); - -module.exports = Marionette.ItemView.extend({ - template : 'System/Info/About/AboutViewTemplate', - - initialize : function() { - this.model = StatusModel; - } -}); \ No newline at end of file diff --git a/src/UI/System/Info/About/AboutViewTemplate.hbs b/src/UI/System/Info/About/AboutViewTemplate.hbs deleted file mode 100644 index a7fff2483..000000000 --- a/src/UI/System/Info/About/AboutViewTemplate.hbs +++ /dev/null @@ -1,20 +0,0 @@ -<fieldset> - <legend>About</legend> - - <dl class="dl-horizontal info"> - <dt>Version</dt> - <dd>{{version}}</dd> - - {{#if isMonoRuntime}} - <dt>Mono Version</dt> - <dd>{{runtimeVersion}}</dd> - {{/if}} - - <dt>AppData directory</dt> - <dd>{{appData}}</dd> - - <dt>Startup directory</dt> - <dd>{{startupPath}}</dd> - </dl> -</fieldset> - diff --git a/src/UI/System/Info/DiskSpace/DiskSpaceCollection.js b/src/UI/System/Info/DiskSpace/DiskSpaceCollection.js deleted file mode 100644 index 9769ba7fb..000000000 --- a/src/UI/System/Info/DiskSpace/DiskSpaceCollection.js +++ /dev/null @@ -1,7 +0,0 @@ -var Backbone = require('backbone'); -var DiskSpaceModel = require('./DiskSpaceModel'); - -module.exports = Backbone.Collection.extend({ - url : window.NzbDrone.ApiRoot + '/diskspace', - model : DiskSpaceModel -}); \ No newline at end of file diff --git a/src/UI/System/Info/DiskSpace/DiskSpaceLayout.js b/src/UI/System/Info/DiskSpace/DiskSpaceLayout.js deleted file mode 100644 index bd3470750..000000000 --- a/src/UI/System/Info/DiskSpace/DiskSpaceLayout.js +++ /dev/null @@ -1,58 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); -var Backgrid = require('backgrid'); -var DiskSpaceCollection = require('./DiskSpaceCollection'); -var LoadingView = require('../../../Shared/LoadingView'); -var DiskSpacePathCell = require('./DiskSpacePathCell'); -var FileSizeCell = require('../../../Cells/FileSizeCell'); - -module.exports = Marionette.Layout.extend({ - template : 'System/Info/DiskSpace/DiskSpaceLayoutTemplate', - - regions : { - grid : '#x-grid' - }, - - columns : [ - { - name : 'path', - label : 'Location', - cell : DiskSpacePathCell, - sortable : false - }, - { - name : 'freeSpace', - label : 'Free Space', - cell : FileSizeCell, - sortable : false - }, - { - name : 'totalSpace', - label : 'Total Space', - cell : FileSizeCell, - sortable : false - } - ], - - initialize : function() { - this.collection = new DiskSpaceCollection(); - this.listenTo(this.collection, 'sync', this._showTable); - }, - - onRender : function() { - this.grid.show(new LoadingView()); - }, - - onShow : function() { - this.collection.fetch(); - }, - - _showTable : function() { - this.grid.show(new Backgrid.Grid({ - row : Backgrid.Row, - columns : this.columns, - collection : this.collection, - className : 'table table-hover' - })); - } -}); \ No newline at end of file diff --git a/src/UI/System/Info/DiskSpace/DiskSpaceLayoutTemplate.hbs b/src/UI/System/Info/DiskSpace/DiskSpaceLayoutTemplate.hbs deleted file mode 100644 index 99c218b67..000000000 --- a/src/UI/System/Info/DiskSpace/DiskSpaceLayoutTemplate.hbs +++ /dev/null @@ -1,5 +0,0 @@ -<fieldset> - <legend>Disk Space</legend> - - <div id="x-grid"/> -</fieldset> \ No newline at end of file diff --git a/src/UI/System/Info/DiskSpace/DiskSpaceModel.js b/src/UI/System/Info/DiskSpace/DiskSpaceModel.js deleted file mode 100644 index 3986a5948..000000000 --- a/src/UI/System/Info/DiskSpace/DiskSpaceModel.js +++ /dev/null @@ -1,3 +0,0 @@ -var Backbone = require('backbone'); - -module.exports = Backbone.Model.extend({}); \ No newline at end of file diff --git a/src/UI/System/Info/DiskSpace/DiskSpacePathCell.js b/src/UI/System/Info/DiskSpace/DiskSpacePathCell.js deleted file mode 100644 index de2ceb9b6..000000000 --- a/src/UI/System/Info/DiskSpace/DiskSpacePathCell.js +++ /dev/null @@ -1,22 +0,0 @@ -var Backgrid = require('backgrid'); - -module.exports = Backgrid.Cell.extend({ - className : 'disk-space-path-cell', - - render : function() { - this.$el.empty(); - - var path = this.model.get('path'); - var label = this.model.get('label'); - - var contents = path; - - if (label) { - contents += ' ({0})'.format(label); - } - - this.$el.html(contents); - - return this; - } -}); \ No newline at end of file diff --git a/src/UI/System/Info/Health/HealthCell.js b/src/UI/System/Info/Health/HealthCell.js deleted file mode 100644 index 606b2b486..000000000 --- a/src/UI/System/Info/Health/HealthCell.js +++ /dev/null @@ -1,12 +0,0 @@ -var NzbDroneCell = require('../../../Cells/NzbDroneCell'); - -module.exports = NzbDroneCell.extend({ - className : 'log-level-cell', - - render : function() { - var level = this._getValue(); - this.$el.html('<i class="icon-sonarr-health-{0}" title="{1}"/>'.format(this._getValue().toLowerCase(), level)); - - return this; - } -}); \ No newline at end of file diff --git a/src/UI/System/Info/Health/HealthLayout.js b/src/UI/System/Info/Health/HealthLayout.js deleted file mode 100644 index bc2bc33eb..000000000 --- a/src/UI/System/Info/Health/HealthLayout.js +++ /dev/null @@ -1,57 +0,0 @@ -var Marionette = require('marionette'); -var Backgrid = require('backgrid'); -var HealthCollection = require('../../../Health/HealthCollection'); -var HealthCell = require('./HealthCell'); -var HealthWikiCell = require('./HealthWikiCell'); -var HealthOkView = require('./HealthOkView'); - -module.exports = Marionette.Layout.extend({ - template : 'System/Info/Health/HealthLayoutTemplate', - - regions : { - grid : '#x-health-grid' - }, - - columns : [ - { - name : 'type', - label : '', - cell : HealthCell, - sortable : false - }, - { - name : 'message', - label : 'Message', - cell : 'string', - sortable : false - }, - { - name : 'wikiUrl', - label : '', - cell : HealthWikiCell, - sortable : false - } - ], - - initialize : function() { - this.listenTo(HealthCollection, 'sync', this.render); - HealthCollection.fetch(); - }, - - onRender : function() { - if (HealthCollection.length === 0) { - this.grid.show(new HealthOkView()); - } else { - this._showTable(); - } - }, - - _showTable : function() { - this.grid.show(new Backgrid.Grid({ - row : Backgrid.Row, - columns : this.columns, - collection : HealthCollection, - className : 'table table-hover' - })); - } -}); \ No newline at end of file diff --git a/src/UI/System/Info/Health/HealthLayoutTemplate.hbs b/src/UI/System/Info/Health/HealthLayoutTemplate.hbs deleted file mode 100644 index eda20b205..000000000 --- a/src/UI/System/Info/Health/HealthLayoutTemplate.hbs +++ /dev/null @@ -1,6 +0,0 @@ -<fieldset class="x-health"> - <legend>Health</legend> - - <div id="x-health-grid"/> -</fieldset> - diff --git a/src/UI/System/Info/Health/HealthOkView.js b/src/UI/System/Info/Health/HealthOkView.js deleted file mode 100644 index 662d9d278..000000000 --- a/src/UI/System/Info/Health/HealthOkView.js +++ /dev/null @@ -1,5 +0,0 @@ -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'System/Info/Health/HealthOkViewTemplate' -}); \ No newline at end of file diff --git a/src/UI/System/Info/Health/HealthOkViewTemplate.hbs b/src/UI/System/Info/Health/HealthOkViewTemplate.hbs deleted file mode 100644 index b33a62360..000000000 --- a/src/UI/System/Info/Health/HealthOkViewTemplate.hbs +++ /dev/null @@ -1,3 +0,0 @@ -<div class="row health-ok"> - <div class="col-md-12">No issues with your configuration</div> -</div> \ No newline at end of file diff --git a/src/UI/System/Info/Health/HealthWikiCell.js b/src/UI/System/Info/Health/HealthWikiCell.js deleted file mode 100644 index bb93f0606..000000000 --- a/src/UI/System/Info/Health/HealthWikiCell.js +++ /dev/null @@ -1,24 +0,0 @@ -var $ = require('jquery'); -var Backgrid = require('backgrid'); - -module.exports = Backgrid.UriCell.extend({ - className : 'wiki-link-cell', - - title : 'Read the Wiki for more information', - - text : 'Wiki', - - render : function() { - this.$el.empty(); - var rawValue = this.model.get(this.column.get('name')); - var formattedValue = this.formatter.fromRaw(rawValue, this.model); - this.$el.append($('<a>', { - tabIndex : -1, - href : rawValue, - title : this.title || formattedValue, - target : this.target - }).text(this.text)); - this.delegateEvents(); - return this; - } -}); \ No newline at end of file diff --git a/src/UI/System/Info/MoreInfo/MoreInfoView.js b/src/UI/System/Info/MoreInfo/MoreInfoView.js deleted file mode 100644 index 0217ed742..000000000 --- a/src/UI/System/Info/MoreInfo/MoreInfoView.js +++ /dev/null @@ -1,5 +0,0 @@ -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'System/Info/MoreInfo/MoreInfoViewTemplate' -}); \ No newline at end of file diff --git a/src/UI/System/Info/MoreInfo/MoreInfoViewTemplate.hbs b/src/UI/System/Info/MoreInfo/MoreInfoViewTemplate.hbs deleted file mode 100644 index c3e5971de..000000000 --- a/src/UI/System/Info/MoreInfo/MoreInfoViewTemplate.hbs +++ /dev/null @@ -1,28 +0,0 @@ -<fieldset> - <legend>More Info</legend> - - <dl class="dl-horizontal info"> - <dt>Home page</dt> - <dd><a href="https://sonarr.tv/">sonarr.tv</a></dd> - - <dt>Wiki</dt> - <dd><a href="https://wiki.sonarr.tv/">wiki.sonarr.tv</a></dd> - - <dt>Forums</dt> - <dd><a href="https://forums.sonarr.tv/">forums.sonarr.tv</a></dd> - - <dt>Twitter</dt> - <dd><a href="https://twitter.com/sonarrtv">@sonarrtv</a></dd> - - <dt>IRC</dt> - <dd><a href="irc://irc.freenode.net/#sonarr">#sonarr on Freenode</a> or (<a href="http://webchat.freenode.net/?channels=#sonarr">webchat</a>)</dd> - - <dt>Source</dt> - <dd><a href="https://github.com/Sonarr/Sonarr/">github.com/Sonarr/Sonarr</a></dd> - - <dt>Feature Requests</dt> - <dd><a href="https://forums.sonarr.tv/">forums.sonarr.tv</a></dd> - <dd><a href="https://github.com/Sonarr/Sonarr/issues">github.com/Sonarr/Sonarr/issues</a> <b>(Please post issues on the forum first and not on github)</b></dd> - </dl> -</fieldset> - diff --git a/src/UI/System/Info/SystemInfoLayout.js b/src/UI/System/Info/SystemInfoLayout.js deleted file mode 100644 index 0b56318ea..000000000 --- a/src/UI/System/Info/SystemInfoLayout.js +++ /dev/null @@ -1,24 +0,0 @@ -var Backbone = require('backbone'); -var Marionette = require('marionette'); -var AboutView = require('./About/AboutView'); -var DiskSpaceLayout = require('./DiskSpace/DiskSpaceLayout'); -var HealthLayout = require('./Health/HealthLayout'); -var MoreInfoView = require('./MoreInfo/MoreInfoView'); - -module.exports = Marionette.Layout.extend({ - template : 'System/Info/SystemInfoLayoutTemplate', - - regions : { - about : '#about', - diskSpace : '#diskspace', - health : '#health', - moreInfo : '#more-info' - }, - - onRender : function() { - this.health.show(new HealthLayout()); - this.diskSpace.show(new DiskSpaceLayout()); - this.about.show(new AboutView()); - this.moreInfo.show(new MoreInfoView()); - } -}); \ No newline at end of file diff --git a/src/UI/System/Info/SystemInfoLayoutTemplate.hbs b/src/UI/System/Info/SystemInfoLayoutTemplate.hbs deleted file mode 100644 index d6eef7abd..000000000 --- a/src/UI/System/Info/SystemInfoLayoutTemplate.hbs +++ /dev/null @@ -1,15 +0,0 @@ -<div class="row"> - <div class="col-md-12" id="health"></div> -</div> - -<div class="row"> - <div class="col-md-12" id="diskspace"></div> -</div> - -<div class="row"> - <div class="col-md-12" id="about"></div> -</div> - -<div class="row"> - <div class="col-md-12" id="more-info"></div> -</div> diff --git a/src/UI/System/Info/info.less b/src/UI/System/Info/info.less deleted file mode 100644 index 59746b7d8..000000000 --- a/src/UI/System/Info/info.less +++ /dev/null @@ -1,3 +0,0 @@ -.health-ok { - margin-bottom: 30px; -} \ No newline at end of file diff --git a/src/UI/System/Logs/Files/ContentsModel.js b/src/UI/System/Logs/Files/ContentsModel.js deleted file mode 100644 index c9c47b1bb..000000000 --- a/src/UI/System/Logs/Files/ContentsModel.js +++ /dev/null @@ -1,13 +0,0 @@ -var Backbone = require('backbone'); - -module.exports = Backbone.Model.extend({ - url : function() { - return this.get('contentsUrl'); - }, - - parse : function(contents) { - var response = {}; - response.contents = contents; - return response; - } -}); \ No newline at end of file diff --git a/src/UI/System/Logs/Files/ContentsView.js b/src/UI/System/Logs/Files/ContentsView.js deleted file mode 100644 index 6b5b9e067..000000000 --- a/src/UI/System/Logs/Files/ContentsView.js +++ /dev/null @@ -1,5 +0,0 @@ -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'System/Logs/Files/ContentsViewTemplate' -}); \ No newline at end of file diff --git a/src/UI/System/Logs/Files/ContentsViewTemplate.hbs b/src/UI/System/Logs/Files/ContentsViewTemplate.hbs deleted file mode 100644 index c9a21b736..000000000 --- a/src/UI/System/Logs/Files/ContentsViewTemplate.hbs +++ /dev/null @@ -1,11 +0,0 @@ -<div class="row"> - <div class="col-md-12"> - <h3>{{filename}}</h3> - </div> -</div> - -<div class="row"> - <div class="col-md-12"> - <pre>{{contents}}</pre> - </div> -</div> \ No newline at end of file diff --git a/src/UI/System/Logs/Files/DownloadLogCell.js b/src/UI/System/Logs/Files/DownloadLogCell.js deleted file mode 100644 index 8be2d0176..000000000 --- a/src/UI/System/Logs/Files/DownloadLogCell.js +++ /dev/null @@ -1,12 +0,0 @@ -var NzbDroneCell = require('../../../Cells/NzbDroneCell'); - -module.exports = NzbDroneCell.extend({ - className : 'download-log-cell', - - render : function() { - this.$el.empty(); - this.$el.html('<a href="{0}" class="no-router" target="_blank">Download</a>'.format(this.cellValue)); - - return this; - } -}); \ No newline at end of file diff --git a/src/UI/System/Logs/Files/FilenameCell.js b/src/UI/System/Logs/Files/FilenameCell.js deleted file mode 100644 index aedbb8cfb..000000000 --- a/src/UI/System/Logs/Files/FilenameCell.js +++ /dev/null @@ -1,12 +0,0 @@ -var NzbDroneCell = require('../../../Cells/NzbDroneCell'); - -module.exports = NzbDroneCell.extend({ - className : 'log-filename-cell', - - render : function() { - var filename = this._getValue(); - this.$el.html(filename); - - return this; - } -}); \ No newline at end of file diff --git a/src/UI/System/Logs/Files/LogFileCollection.js b/src/UI/System/Logs/Files/LogFileCollection.js deleted file mode 100644 index 590dda677..000000000 --- a/src/UI/System/Logs/Files/LogFileCollection.js +++ /dev/null @@ -1,12 +0,0 @@ -var Backbone = require('backbone'); -var LogFileModel = require('./LogFileModel'); - -module.exports = Backbone.Collection.extend({ - url : window.NzbDrone.ApiRoot + '/log/file', - model : LogFileModel, - - state : { - sortKey : 'lastWriteTime', - order : 1 - } -}); \ No newline at end of file diff --git a/src/UI/System/Logs/Files/LogFileLayout.js b/src/UI/System/Logs/Files/LogFileLayout.js deleted file mode 100644 index 60d9068ce..000000000 --- a/src/UI/System/Logs/Files/LogFileLayout.js +++ /dev/null @@ -1,135 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); -var Backgrid = require('backgrid'); -var FilenameCell = require('./FilenameCell'); -var RelativeDateCell = require('../../../Cells/RelativeDateCell'); -var DownloadLogCell = require('./DownloadLogCell'); -var LogFileRow = require('./Row'); -var ContentsView = require('./ContentsView'); -var ContentsModel = require('./ContentsModel'); -var ToolbarLayout = require('../../../Shared/Toolbar/ToolbarLayout'); -var LoadingView = require('../../../Shared/LoadingView'); -require('../../../jQuery/jquery.spin'); - -module.exports = Marionette.Layout.extend({ - template : 'System/Logs/Files/LogFileLayoutTemplate', - - regions : { - toolbar : '#x-toolbar', - grid : '#x-grid', - contents : '#x-contents' - }, - - columns : [ - { - name : 'filename', - label : 'Filename', - cell : FilenameCell, - sortable : false - }, - { - name : 'lastWriteTime', - label : 'Last Write Time', - cell : RelativeDateCell, - sortable : false - }, - { - name : 'downloadUrl', - label : '', - cell : DownloadLogCell, - sortable : false - } - ], - - initialize : function(options) { - this.collection = options.collection; - this.deleteFilesCommand = options.deleteFilesCommand; - - this.listenTo(vent, vent.Commands.ShowLogFile, this._fetchLogFileContents); - this.listenTo(vent, vent.Events.CommandComplete, this._commandComplete); - this.listenTo(this.collection, 'sync', this._collectionSynced); - - this.collection.fetch(); - }, - - onShow : function() { - this._showToolbar(); - this._showTable(); - }, - - _showToolbar : function() { - var leftSideButtons = { - type : 'default', - storeState : false, - items : [ - { - title : 'Refresh', - icon : 'icon-sonarr-refresh', - ownerContext : this, - callback : this._refreshTable - }, - { - title : 'Clear Log Files', - icon : 'icon-sonarr-clear', - command : this.deleteFilesCommand, - successMessage : 'Log files have been deleted', - errorMessage : 'Failed to delete log files' - } - ] - }; - - this.toolbar.show(new ToolbarLayout({ - left : [leftSideButtons], - context : this - })); - }, - - _showTable : function() { - this.grid.show(new Backgrid.Grid({ - row : LogFileRow, - columns : this.columns, - collection : this.collection, - className : 'table table-hover' - })); - }, - - _collectionSynced : function() { - if (!this.collection.any()) { - return; - } - - var model = this.collection.first(); - this._fetchLogFileContents({ model : model }); - }, - - _fetchLogFileContents : function(options) { - this.contents.show(new LoadingView()); - - var model = options.model; - var contentsModel = new ContentsModel(model.toJSON()); - - this.listenToOnce(contentsModel, 'sync', this._showDetails); - - contentsModel.fetch({ dataType : 'text' }); - }, - - _showDetails : function(model) { - this.contents.show(new ContentsView({ model : model })); - }, - - _refreshTable : function(buttonContext) { - this.contents.close(); - var promise = this.collection.fetch(); - - //Would be nice to spin the icon on the refresh button - if (buttonContext) { - buttonContext.ui.icon.spinForPromise(promise); - } - }, - - _commandComplete : function(options) { - if (options.command.get('name') === this.deleteFilesCommand.toLowerCase()) { - this._refreshTable(); - } - } -}); \ No newline at end of file diff --git a/src/UI/System/Logs/Files/LogFileLayoutTemplate.hbs b/src/UI/System/Logs/Files/LogFileLayoutTemplate.hbs deleted file mode 100644 index 0188f1d0e..000000000 --- a/src/UI/System/Logs/Files/LogFileLayoutTemplate.hbs +++ /dev/null @@ -1,12 +0,0 @@ -<div id="x-toolbar"/> -<div class="row"> - <div class="col-md-12"> - <div id="x-grid"/> - </div> -</div> - -<div class="row"> - <div class="col-md-12"> - <div id="x-contents"/> - </div> -</div> \ No newline at end of file diff --git a/src/UI/System/Logs/Files/LogFileModel.js b/src/UI/System/Logs/Files/LogFileModel.js deleted file mode 100644 index 3986a5948..000000000 --- a/src/UI/System/Logs/Files/LogFileModel.js +++ /dev/null @@ -1,3 +0,0 @@ -var Backbone = require('backbone'); - -module.exports = Backbone.Model.extend({}); \ No newline at end of file diff --git a/src/UI/System/Logs/Files/Row.js b/src/UI/System/Logs/Files/Row.js deleted file mode 100644 index 01e6bd55a..000000000 --- a/src/UI/System/Logs/Files/Row.js +++ /dev/null @@ -1,14 +0,0 @@ -var vent = require('vent'); -var Backgrid = require('backgrid'); - -module.exports = Backgrid.Row.extend({ - className : 'log-file-row', - - events : { - 'click' : '_showDetails' - }, - - _showDetails : function() { - vent.trigger(vent.Commands.ShowLogFile, { model : this.model }); - } -}); \ No newline at end of file diff --git a/src/UI/System/Logs/LogsCollection.js b/src/UI/System/Logs/LogsCollection.js deleted file mode 100644 index c233a9d63..000000000 --- a/src/UI/System/Logs/LogsCollection.js +++ /dev/null @@ -1,64 +0,0 @@ -var PagableCollection = require('backbone.pageable'); -var LogsModel = require('./LogsModel'); -var AsFilteredCollection = require('../../Mixins/AsFilteredCollection'); -var AsPersistedStateCollection = require('../../Mixins/AsPersistedStateCollection'); - -var collection = PagableCollection.extend({ - url : window.NzbDrone.ApiRoot + '/log', - model : LogsModel, - tableName : 'logs', - - state : { - pageSize : 50, - sortKey : 'time', - order : 1 - }, - - queryParams : { - totalPages : null, - totalRecords : null, - pageSize : 'pageSize', - sortKey : 'sortKey', - order : 'sortDir', - directions : { - '-1' : 'asc', - '1' : 'desc' - } - }, - - // Filter Modes - filterModes : { - "all" : [ - null, - null - ], - "info" : [ - 'level', - 'Info' - ], - "warn" : [ - 'level', - 'Warn' - ], - "error" : [ - 'level', - 'Error' - ] - }, - - parseState : function(resp, queryParams, state) { - return { totalRecords : resp.totalRecords }; - }, - - parseRecords : function(resp) { - if (resp) { - return resp.records; - } - - return resp; - } -}); - -collection = AsFilteredCollection.apply(collection); - -module.exports = AsPersistedStateCollection.apply(collection); \ No newline at end of file diff --git a/src/UI/System/Logs/LogsLayout.js b/src/UI/System/Logs/LogsLayout.js deleted file mode 100644 index d064cff64..000000000 --- a/src/UI/System/Logs/LogsLayout.js +++ /dev/null @@ -1,64 +0,0 @@ -var Marionette = require('marionette'); -var LogsTableLayout = require('./Table/LogsTableLayout'); -var LogsFileLayout = require('./Files/LogFileLayout'); -var LogFileCollection = require('./Files/LogFileCollection'); -var UpdateLogFileCollection = require('./Updates/LogFileCollection'); - -module.exports = Marionette.Layout.extend({ - template : 'System/Logs/LogsLayoutTemplate', - - ui : { - tableTab : '.x-table-tab', - filesTab : '.x-files-tab', - updateFilesTab : '.x-update-files-tab' - }, - - regions : { - table : '#table', - files : '#files', - updateFiles : '#update-files' - }, - - events : { - 'click .x-table-tab' : '_showTable', - 'click .x-files-tab' : '_showFiles', - 'click .x-update-files-tab' : '_showUpdateFiles' - }, - - onShow : function() { - this._showTable(); - }, - - _showTable : function(e) { - if (e) { - e.preventDefault(); - } - - this.ui.tableTab.tab('show'); - this.table.show(new LogsTableLayout()); - }, - - _showFiles : function(e) { - if (e) { - e.preventDefault(); - } - - this.ui.filesTab.tab('show'); - this.files.show(new LogsFileLayout({ - collection : new LogFileCollection(), - deleteFilesCommand : 'deleteLogFiles' - })); - }, - - _showUpdateFiles : function(e) { - if (e) { - e.preventDefault(); - } - - this.ui.updateFilesTab.tab('show'); - this.updateFiles.show(new LogsFileLayout({ - collection : new UpdateLogFileCollection(), - deleteFilesCommand : 'deleteUpdateLogFiles' - })); - } -}); \ No newline at end of file diff --git a/src/UI/System/Logs/LogsLayoutTemplate.hbs b/src/UI/System/Logs/LogsLayoutTemplate.hbs deleted file mode 100644 index 1bf663f29..000000000 --- a/src/UI/System/Logs/LogsLayoutTemplate.hbs +++ /dev/null @@ -1,17 +0,0 @@ -<div class="row"> - <div class="col-md-2 col-sm-2"> - <ul class="nav nav-pills nav-stacked"> - <li><a href="#table" class="x-table-tab no-router">Table</a></li> - <li><a href="#files" class="x-files-tab no-router">Files</a></li> - <li><a href="#update-files" class="x-update-files-tab no-router">Updates</a></li> - </ul> - </div> - - <div class="col-md-10 col-sm-10"> - <div class="tab-content"> - <div class="tab-pane" id="table"></div> - <div class="tab-pane" id="files"></div> - <div class="tab-pane" id="update-files"></div> - </div> - </div> -</div> \ No newline at end of file diff --git a/src/UI/System/Logs/LogsModel.js b/src/UI/System/Logs/LogsModel.js deleted file mode 100644 index 3986a5948..000000000 --- a/src/UI/System/Logs/LogsModel.js +++ /dev/null @@ -1,3 +0,0 @@ -var Backbone = require('backbone'); - -module.exports = Backbone.Model.extend({}); \ No newline at end of file diff --git a/src/UI/System/Logs/Table/Details/LogDetailsView.js b/src/UI/System/Logs/Table/Details/LogDetailsView.js deleted file mode 100644 index dcdadcf0b..000000000 --- a/src/UI/System/Logs/Table/Details/LogDetailsView.js +++ /dev/null @@ -1,6 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'System/Logs/Table/Details/LogDetailsViewTemplate' -}); \ No newline at end of file diff --git a/src/UI/System/Logs/Table/Details/LogDetailsViewTemplate.hbs b/src/UI/System/Logs/Table/Details/LogDetailsViewTemplate.hbs deleted file mode 100644 index 80a8f7d26..000000000 --- a/src/UI/System/Logs/Table/Details/LogDetailsViewTemplate.hbs +++ /dev/null @@ -1,23 +0,0 @@ -<div class="modal-content"> - <div class="log-details-modal"> - <div class="modal-header"> - <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> - - <h3>Details</h3> - - </div> - <div class="modal-body"> - Message - <pre>{{message}}</pre> - - {{#if exception}} - <br/> - Exception - <pre>{{exception}}</pre> - {{/if}} - </div> - <div class="modal-footer"> - <button class="btn" data-dismiss="modal">Close</button> - </div> - </div> -</div> diff --git a/src/UI/System/Logs/Table/LogLevelCell.js b/src/UI/System/Logs/Table/LogLevelCell.js deleted file mode 100644 index 63c5bb440..000000000 --- a/src/UI/System/Logs/Table/LogLevelCell.js +++ /dev/null @@ -1,12 +0,0 @@ -var NzbDroneCell = require('../../../Cells/NzbDroneCell'); - -module.exports = NzbDroneCell.extend({ - className : 'log-level-cell', - - render : function() { - var level = this._getValue(); - this.$el.html('<i class="icon-sonarr-log-{0}" title="{1}"/>'.format(this._getValue().toLowerCase(), level)); - - return this; - } -}); \ No newline at end of file diff --git a/src/UI/System/Logs/Table/LogRow.js b/src/UI/System/Logs/Table/LogRow.js deleted file mode 100644 index c8cf6eb97..000000000 --- a/src/UI/System/Logs/Table/LogRow.js +++ /dev/null @@ -1,14 +0,0 @@ -var vent = require('vent'); -var Backgrid = require('backgrid'); - -module.exports = Backgrid.Row.extend({ - className : 'log-row', - - events : { - 'click' : '_showDetails' - }, - - _showDetails : function() { - vent.trigger(vent.Commands.ShowLogDetails, { model : this.model }); - } -}); \ No newline at end of file diff --git a/src/UI/System/Logs/Table/LogTimeCell.js b/src/UI/System/Logs/Table/LogTimeCell.js deleted file mode 100644 index 1adbab10e..000000000 --- a/src/UI/System/Logs/Table/LogTimeCell.js +++ /dev/null @@ -1,31 +0,0 @@ -var NzbDroneCell = require('../../../Cells/NzbDroneCell'); -var moment = require('moment'); -var FormatHelpers = require('../../../Shared/FormatHelpers'); -var UiSettings = require('../../../Shared/UiSettingsModel'); - -module.exports = NzbDroneCell.extend({ - className : 'log-time-cell', - - render : function() { - var dateStr = this._getValue(); - var date = moment(dateStr); - var diff = date.diff(moment().zone(date.zone()).startOf('day'), 'days', true); - var result = '<span title="{0}">{1}</span>'; - var tooltip = date.format(UiSettings.longDateTime(true)); - var text; - - if (diff > 0 && diff < 1) { - text = date.format(UiSettings.time(true, false)); - } else { - if (UiSettings.get('showRelativeDates')) { - text = FormatHelpers.relativeDate(dateStr); - } else { - text = date.format(UiSettings.get('shortDateFormat')); - } - } - - this.$el.html(result.format(tooltip, text)); - - 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 deleted file mode 100644 index f7d9430b6..000000000 --- a/src/UI/System/Logs/Table/LogsTableLayout.js +++ /dev/null @@ -1,175 +0,0 @@ -var vent = require('vent'); -var Marionette = require('marionette'); -var Backgrid = require('backgrid'); -var LogTimeCell = require('./LogTimeCell'); -var LogLevelCell = require('./LogLevelCell'); -var LogRow = require('./LogRow'); -var GridPager = require('../../../Shared/Grid/Pager'); -var LogCollection = require('../LogsCollection'); -var ToolbarLayout = require('../../../Shared/Toolbar/ToolbarLayout'); -var LoadingView = require('../../../Shared/LoadingView'); -require('../../../jQuery/jquery.spin'); - -module.exports = Marionette.Layout.extend({ - template : 'System/Logs/Table/LogsTableLayoutTemplate', - - regions : { - grid : '#x-grid', - toolbar : '#x-toolbar', - pager : '#x-pager' - }, - - attributes : { - id : 'logs-screen' - }, - - columns : [ - { - name : 'level', - label : '', - sortable : true, - cell : LogLevelCell - }, - { - name : 'logger', - label : 'Component', - sortable : true, - cell : Backgrid.StringCell.extend({ - className : 'log-logger-cell' - }) - }, - { - name : 'message', - label : 'Message', - sortable : false, - cell : Backgrid.StringCell.extend({ - className : 'log-message-cell' - }) - }, - { - name : 'time', - label : 'Time', - cell : LogTimeCell - } - ], - - initialize : function() { - this.collection = new LogCollection(); - - this.listenTo(this.collection, 'sync', this._showTable); - this.listenTo(vent, vent.Events.CommandComplete, this._commandComplete); - }, - - onRender : function() { - this.grid.show(new LoadingView()); - }, - - onShow : function() { - this._showToolbar(); - }, - - _showTable : function() { - this.grid.show(new Backgrid.Grid({ - row : LogRow, - columns : this.columns, - collection : this.collection, - className : 'table table-hover' - })); - - this.pager.show(new GridPager({ - columns : this.columns, - collection : this.collection - })); - }, - - _showToolbar : function() { - var filterButtons = { - type : 'radio', - storeState : true, - menuKey : 'logs.filterMode', - defaultAction : 'all', - items : [ - { - key : 'all', - title : '', - tooltip : 'All', - icon : 'icon-sonarr-all', - callback : this._setFilter - }, - { - key : 'info', - title : '', - tooltip : 'Info', - icon : 'icon-sonarr-log-info', - callback : this._setFilter - }, - { - key : 'warn', - title : '', - tooltip : 'Warn', - icon : 'icon-sonarr-log-warn', - callback : this._setFilter - }, - { - key : 'error', - title : '', - tooltip : 'Error', - icon : 'icon-sonarr-log-error', - callback : this._setFilter - } - ] - }; - - var leftSideButtons = { - type : 'default', - storeState : false, - items : [ - { - title : 'Refresh', - icon : 'icon-sonarr-refresh', - ownerContext : this, - callback : this._refreshTable - }, - { - title : 'Clear Logs', - icon : 'icon-sonarr-clear', - command : 'clearLog' - } - ] - }; - - this.toolbar.show(new ToolbarLayout({ - left : [leftSideButtons], - right : [filterButtons], - context : this - })); - }, - - _refreshTable : function(buttonContext) { - this.collection.state.currentPage = 1; - var promise = this.collection.fetch({ reset : true }); - - if (buttonContext) { - buttonContext.ui.icon.spinForPromise(promise); - } - }, - - _setFilter : function(buttonContext) { - var mode = buttonContext.model.get('key'); - - this.collection.setFilterMode(mode, { reset : false }); - - this.collection.state.currentPage = 1; - var promise = this.collection.fetch({ reset : true }); - - if (buttonContext) { - buttonContext.ui.icon.spinForPromise(promise); - } - }, - - _commandComplete : function(options) { - if (options.command.get('name') === 'clearlog') { - this._refreshTable(); - } - } -}); \ No newline at end of file diff --git a/src/UI/System/Logs/Table/LogsTableLayoutTemplate.hbs b/src/UI/System/Logs/Table/LogsTableLayoutTemplate.hbs deleted file mode 100644 index 1d579ffcd..000000000 --- a/src/UI/System/Logs/Table/LogsTableLayoutTemplate.hbs +++ /dev/null @@ -1,11 +0,0 @@ -<div id="x-toolbar"/> -<div class="row"> - <div class="col-md-12"> - <div id="x-grid" class="table-responsive"/> - </div> -</div> -<div class="row"> - <div class="col-md-12"> - <div id="x-pager"/> - </div> -</div> diff --git a/src/UI/System/Logs/Updates/LogFileCollection.js b/src/UI/System/Logs/Updates/LogFileCollection.js deleted file mode 100644 index 1f957dbf1..000000000 --- a/src/UI/System/Logs/Updates/LogFileCollection.js +++ /dev/null @@ -1,12 +0,0 @@ -var Backbone = require('backbone'); -var LogFileModel = require('./LogFileModel'); - -module.exports = Backbone.Collection.extend({ - url : window.NzbDrone.ApiRoot + '/log/file/update', - model : LogFileModel, - - state : { - sortKey : 'lastWriteTime', - order : 1 - } -}); \ No newline at end of file diff --git a/src/UI/System/Logs/Updates/LogFileModel.js b/src/UI/System/Logs/Updates/LogFileModel.js deleted file mode 100644 index 3986a5948..000000000 --- a/src/UI/System/Logs/Updates/LogFileModel.js +++ /dev/null @@ -1,3 +0,0 @@ -var Backbone = require('backbone'); - -module.exports = Backbone.Model.extend({}); \ No newline at end of file diff --git a/src/UI/System/Logs/logs.less b/src/UI/System/Logs/logs.less deleted file mode 100644 index 7142583ad..000000000 --- a/src/UI/System/Logs/logs.less +++ /dev/null @@ -1,25 +0,0 @@ -@import "../../Shared/Styles/clickable"; - -#logs-screen { - - .log-time-cell{ - width: 100px; - } - - .log-level-cell{ - width: 12px; - font-size: 14px; - } - - td{ - font-size: 13px; - } -} - -.log-file-row { - .clickable; -} - -.log-row { - .clickable; -} \ No newline at end of file diff --git a/src/UI/System/StatusModel.js b/src/UI/System/StatusModel.js deleted file mode 100644 index 075dd0918..000000000 --- a/src/UI/System/StatusModel.js +++ /dev/null @@ -1,9 +0,0 @@ -var Backbone = require('backbone'); -var ApiData = require('../Shared/ApiData'); - -var StatusModel = Backbone.Model.extend({ - url : window.NzbDrone.ApiRoot + '/system/status' -}); -var instance = new StatusModel(ApiData.get('system/status')); - -module.exports = instance; \ No newline at end of file diff --git a/src/UI/System/SystemLayout.js b/src/UI/System/SystemLayout.js deleted file mode 100644 index d0c71ca09..000000000 --- a/src/UI/System/SystemLayout.js +++ /dev/null @@ -1,150 +0,0 @@ -var $ = require('jquery'); -var Backbone = require('backbone'); -var Marionette = require('marionette'); -var SystemInfoLayout = require('./Info/SystemInfoLayout'); -var LogsLayout = require('./Logs/LogsLayout'); -var UpdateLayout = require('./Update/UpdateLayout'); -var BackupLayout = require('./Backup/BackupLayout'); -var TaskLayout = require('./Task/TaskLayout'); -var Messenger = require('../Shared/Messenger'); -var StatusModel = require('./StatusModel'); - -module.exports = Marionette.Layout.extend({ - template : 'System/SystemLayoutTemplate', - - regions : { - status : '#status', - logs : '#logs', - updates : '#updates', - backup : '#backup', - tasks : '#tasks' - }, - - ui : { - statusTab : '.x-status-tab', - logsTab : '.x-logs-tab', - updatesTab : '.x-updates-tab', - backupTab : '.x-backup-tab', - tasksTab : '.x-tasks-tab' - }, - - events : { - 'click .x-status-tab' : '_showStatus', - 'click .x-logs-tab' : '_showLogs', - 'click .x-updates-tab' : '_showUpdates', - 'click .x-backup-tab' : '_showBackup', - 'click .x-tasks-tab' : '_showTasks', - 'click .x-shutdown' : '_shutdown', - 'click .x-restart' : '_restart' - }, - - initialize : function(options) { - if (options.action) { - this.action = options.action.toLowerCase(); - } - - this.templateHelpers = { - authentication : StatusModel.get('authentication') - }; - }, - - onShow : function() { - switch (this.action) { - case 'logs': - this._showLogs(); - break; - case 'updates': - this._showUpdates(); - break; - case 'backup': - this._showBackup(); - break; - case 'tasks': - this._showTasks(); - break; - default: - this._showStatus(); - } - }, - - _navigate : function(route) { - Backbone.history.navigate(route, { - trigger : true, - replace : true - }); - }, - - _showStatus : function(e) { - if (e) { - e.preventDefault(); - } - - this.status.show(new SystemInfoLayout()); - this.ui.statusTab.tab('show'); - this._navigate('system/status'); - }, - - _showLogs : function(e) { - if (e) { - e.preventDefault(); - } - - this.logs.show(new LogsLayout()); - this.ui.logsTab.tab('show'); - this._navigate('system/logs'); - }, - - _showUpdates : function(e) { - if (e) { - e.preventDefault(); - } - - this.updates.show(new UpdateLayout()); - this.ui.updatesTab.tab('show'); - this._navigate('system/updates'); - }, - - _showBackup : function(e) { - if (e) { - e.preventDefault(); - } - - this.backup.show(new BackupLayout()); - this.ui.backupTab.tab('show'); - this._navigate('system/backup'); - }, - - _showTasks : function(e) { - if (e) { - e.preventDefault(); - } - - this.tasks.show(new TaskLayout()); - this.ui.tasksTab.tab('show'); - this._navigate('system/tasks'); - }, - - _shutdown : function() { - $.ajax({ - url : window.NzbDrone.ApiRoot + '/system/shutdown', - type : 'POST' - }); - - Messenger.show({ - message : 'Sonarr will shutdown shortly', - type : 'info' - }); - }, - - _restart : function() { - $.ajax({ - url : window.NzbDrone.ApiRoot + '/system/restart', - type : 'POST' - }); - - Messenger.show({ - message : 'Sonarr will restart shortly', - type : 'info' - }); - } -}); \ No newline at end of file diff --git a/src/UI/System/SystemLayoutTemplate.hbs b/src/UI/System/SystemLayoutTemplate.hbs deleted file mode 100644 index 47e30cf43..000000000 --- a/src/UI/System/SystemLayoutTemplate.hbs +++ /dev/null @@ -1,31 +0,0 @@ -<ul class="nav nav-tabs"> - <li><a href="#status" class="x-status-tab no-router">Status</a></li> - <li><a href="#updates" class="x-updates-tab no-router">Updates</a></li> - <li><a href="#tasks" class="x-tasks-tab no-router">Tasks</a></li> - <li><a href="#backup" class="x-backup-tab no-router">Backup</a></li> - <li><a href="#logs" class="x-logs-tab no-router">Logs</a></li> - <li class="lifecycle-controls pull-right"> - <div class="btn-group"> - <button class="btn btn-default btn-icon-only x-shutdown" title="Shutdown"> - <i class="icon-sonarr-shutdown"></i> - </button> - <button class="btn btn-default btn-icon-only x-restart" title="Restart"> - <i class="icon-sonarr-restart"></i> - </button> - - {{#if_eq authentication compare="forms"}} - <a href="{{UrlBase}}/logout" class="btn btn-default btn-icon-only" title="Logout"> - <i class="icon-sonarr-logout"></i> - </a> - {{/if_eq}} - </div> - </li> -</ul> - -<div class="tab-content"> - <div class="tab-pane" id="status"></div> - <div class="tab-pane" id="updates"></div> - <div class="tab-pane" id="tasks"></div> - <div class="tab-pane" id="backup"></div> - <div class="tab-pane" id="logs"></div> -</div> \ No newline at end of file diff --git a/src/UI/System/Task/ExecuteTaskCell.js b/src/UI/System/Task/ExecuteTaskCell.js deleted file mode 100644 index d5e655296..000000000 --- a/src/UI/System/Task/ExecuteTaskCell.js +++ /dev/null @@ -1,30 +0,0 @@ -var NzbDroneCell = require('../../Cells/NzbDroneCell'); -var CommandController = require('../../Commands/CommandController'); - -module.exports = NzbDroneCell.extend({ - className : 'execute-task-cell', - - events : { - 'click .x-execute' : '_executeTask' - }, - - render : function() { - this.$el.empty(); - - var name = this.model.get('name'); - var task = this.model.get('taskName'); - - this.$el.html('<i class="icon-sonarr-refresh icon-can-spin x-execute" title="Execute {0}"></i>'.format(name)); - - CommandController.bindToCommand({ - element : this.$el.find('.x-execute'), - command : { name : task } - }); - - return this; - }, - - _executeTask : function() { - CommandController.Execute(this.model.get('taskName'), { name : this.model.get('taskName') }); - } -}); \ No newline at end of file diff --git a/src/UI/System/Task/NextExecutionCell.js b/src/UI/System/Task/NextExecutionCell.js deleted file mode 100644 index 39140f5a9..000000000 --- a/src/UI/System/Task/NextExecutionCell.js +++ /dev/null @@ -1,34 +0,0 @@ -var NzbDroneCell = require('../../Cells/NzbDroneCell'); -var moment = require('moment'); -var UiSettings = require('../../Shared/UiSettingsModel'); - -module.exports = NzbDroneCell.extend({ - className : 'next-execution-cell', - - render : function() { - this.$el.empty(); - - var interval = this.model.get('interval'); - var nextExecution = moment(this.model.get('nextExecution')); - - if (interval === 0) { - this.$el.html('-'); - } else if (moment().isAfter(nextExecution)) { - this.$el.html('now'); - } else { - var result = '<span title="{0}">{1}</span>'; - var tooltip = nextExecution.format(UiSettings.longDateTime()); - var text; - - if (UiSettings.get('showRelativeDates')) { - text = nextExecution.fromNow(); - } else { - text = nextExecution.format(UiSettings.shortDateTime()); - } - - this.$el.html(result.format(tooltip, text)); - } - - return this; - } -}); \ No newline at end of file diff --git a/src/UI/System/Task/TaskCollection.js b/src/UI/System/Task/TaskCollection.js deleted file mode 100644 index bca599554..000000000 --- a/src/UI/System/Task/TaskCollection.js +++ /dev/null @@ -1,15 +0,0 @@ -var PageableCollection = require('backbone.pageable'); -var TaskModel = require('./TaskModel'); - -module.exports = PageableCollection.extend({ - url : window.NzbDrone.ApiRoot + '/system/task', - model : TaskModel, - - state : { - sortKey : 'name', - order : -1, - pageSize : 100000 - }, - - mode : 'client' -}); \ No newline at end of file diff --git a/src/UI/System/Task/TaskIntervalCell.js b/src/UI/System/Task/TaskIntervalCell.js deleted file mode 100644 index b2f246c48..000000000 --- a/src/UI/System/Task/TaskIntervalCell.js +++ /dev/null @@ -1,21 +0,0 @@ -var NzbDroneCell = require('../../Cells/NzbDroneCell'); -var moment = require('moment'); - -module.exports = NzbDroneCell.extend({ - className : 'task-interval-cell', - - render : function() { - this.$el.empty(); - - var interval = this.model.get('interval'); - var duration = moment.duration(interval, 'minutes').humanize().replace(/an?(?=\s)/, '1'); - - if (interval === 0) { - this.$el.html('disabled'); - } else { - this.$el.html(duration); - } - - return this; - } -}); \ No newline at end of file diff --git a/src/UI/System/Task/TaskLayout.js b/src/UI/System/Task/TaskLayout.js deleted file mode 100644 index 621797e3c..000000000 --- a/src/UI/System/Task/TaskLayout.js +++ /dev/null @@ -1,71 +0,0 @@ -var Marionette = require('marionette'); -var Backgrid = require('backgrid'); -var BackupCollection = require('./TaskCollection'); -var RelativeTimeCell = require('../../Cells/RelativeTimeCell'); -var TaskIntervalCell = require('./TaskIntervalCell'); -var ExecuteTaskCell = require('./ExecuteTaskCell'); -var NextExecutionCell = require('./NextExecutionCell'); -var LoadingView = require('../../Shared/LoadingView'); -require('../../Mixins/backbone.signalr.mixin'); - -module.exports = Marionette.Layout.extend({ - template : 'System/Task/TaskLayoutTemplate', - - regions : { - tasks : '#x-tasks' - }, - - columns : [ - { - name : 'name', - label : 'Name', - sortable : true, - cell : 'string' - }, - { - name : 'interval', - label : 'Interval', - sortable : true, - cell : TaskIntervalCell - }, - { - name : 'lastExecution', - label : 'Last Execution', - sortable : true, - cell : RelativeTimeCell - }, - { - name : 'nextExecution', - label : 'Next Execution', - sortable : true, - cell : NextExecutionCell - }, - { - name : 'this', - label : '', - sortable : false, - cell : ExecuteTaskCell - } - ], - - initialize : function() { - this.taskCollection = new BackupCollection(); - - this.listenTo(this.taskCollection, 'sync', this._showTasks); - this.taskCollection.bindSignalR(); - }, - - onRender : function() { - this.tasks.show(new LoadingView()); - - this.taskCollection.fetch(); - }, - - _showTasks : function() { - this.tasks.show(new Backgrid.Grid({ - columns : this.columns, - collection : this.taskCollection, - className : 'table table-hover' - })); - } -}); \ No newline at end of file diff --git a/src/UI/System/Task/TaskLayoutTemplate.hbs b/src/UI/System/Task/TaskLayoutTemplate.hbs deleted file mode 100644 index 0a3631541..000000000 --- a/src/UI/System/Task/TaskLayoutTemplate.hbs +++ /dev/null @@ -1,5 +0,0 @@ -<div class="row"> - <div class="col-md-12"> - <div id="x-tasks" class="tasks table-responsive"/> - </div> -</div> diff --git a/src/UI/System/Task/TaskModel.js b/src/UI/System/Task/TaskModel.js deleted file mode 100644 index 3986a5948..000000000 --- a/src/UI/System/Task/TaskModel.js +++ /dev/null @@ -1,3 +0,0 @@ -var Backbone = require('backbone'); - -module.exports = Backbone.Model.extend({}); \ No newline at end of file diff --git a/src/UI/System/Update/EmptyView.js b/src/UI/System/Update/EmptyView.js deleted file mode 100644 index a18f84f4d..000000000 --- a/src/UI/System/Update/EmptyView.js +++ /dev/null @@ -1,5 +0,0 @@ -var Marionette = require('marionette'); - -module.exports = Marionette.ItemView.extend({ - template : 'System/Update/EmptyViewTemplate' -}); \ No newline at end of file diff --git a/src/UI/System/Update/EmptyViewTemplate.hbs b/src/UI/System/Update/EmptyViewTemplate.hbs deleted file mode 100644 index 728e10d93..000000000 --- a/src/UI/System/Update/EmptyViewTemplate.hbs +++ /dev/null @@ -1 +0,0 @@ -<div>No updates are available</div> \ No newline at end of file diff --git a/src/UI/System/Update/UpdateCollection.js b/src/UI/System/Update/UpdateCollection.js deleted file mode 100644 index 2b21a6616..000000000 --- a/src/UI/System/Update/UpdateCollection.js +++ /dev/null @@ -1,7 +0,0 @@ -var Backbone = require('backbone'); -var UpdateModel = require('./UpdateModel'); - -module.exports = Backbone.Collection.extend({ - url : window.NzbDrone.ApiRoot + '/update', - model : UpdateModel -}); \ No newline at end of file diff --git a/src/UI/System/Update/UpdateCollectionView.js b/src/UI/System/Update/UpdateCollectionView.js deleted file mode 100644 index 7af0bfc73..000000000 --- a/src/UI/System/Update/UpdateCollectionView.js +++ /dev/null @@ -1,8 +0,0 @@ -var Marionette = require('marionette'); -var UpdateItemView = require('./UpdateItemView'); -var EmptyView = require('./EmptyView'); - -module.exports = Marionette.CollectionView.extend({ - itemView : UpdateItemView, - emptyView : EmptyView -}); \ No newline at end of file diff --git a/src/UI/System/Update/UpdateItemView.js b/src/UI/System/Update/UpdateItemView.js deleted file mode 100644 index 73ed31e0c..000000000 --- a/src/UI/System/Update/UpdateItemView.js +++ /dev/null @@ -1,31 +0,0 @@ -var Marionette = require('marionette'); -var CommandController = require('../../Commands/CommandController'); - -module.exports = Marionette.ItemView.extend({ - template : 'System/Update/UpdateItemViewTemplate', - - events : { - 'click .x-install-update' : '_installUpdate' - }, - - initialize : function() { - this.updating = false; - }, - - _installUpdate : function() { - if (this.updating) { - return; - } - - this.updating = true; - var self = this; - - var promise = CommandController.Execute('applicationUpdate'); - - promise.done(function() { - window.setTimeout(function() { - self.updating = false; - }, 5000); - }); - } -}); \ No newline at end of file diff --git a/src/UI/System/Update/UpdateItemViewTemplate.hbs b/src/UI/System/Update/UpdateItemViewTemplate.hbs deleted file mode 100644 index f9a2dce19..000000000 --- a/src/UI/System/Update/UpdateItemViewTemplate.hbs +++ /dev/null @@ -1,43 +0,0 @@ -<div class="update"> - <fieldset> - <legend>{{version}} - <span class="date"> - - {{ShortDate releaseDate}} - </span> - <span class="status"> - {{#unless_eq branch compare="master"}} - <span class="label label-default">{{branch}}</span> - {{/unless_eq}} - {{#if installed}} - <span class="label label-success">Installed</span> - {{else}} - {{#if latest}} - {{#if installable}} - <span class="label label-info install-update x-install-update">Install Latest</span> - {{else}} - <span class="label label-info label-disabled" title="Cannot install an older version">Install Latest</span> - {{/if}} - {{/if}} - {{/if}} - </span> - </legend> - - {{#with changes}} - {{#each new}} - <div class="change"> - <span class="label label-success">New</span> {{this}} - </div> - {{/each}} - - {{#each fixed}} - <div class="change"> - <span class="label label-info">Fixed</span> {{this}} - </div> - {{/each}} - {{/with}} - - {{#unless changes}} - Maintenance release - {{/unless}} - </fieldset> -</div> diff --git a/src/UI/System/Update/UpdateLayout.js b/src/UI/System/Update/UpdateLayout.js deleted file mode 100644 index a1fd84d06..000000000 --- a/src/UI/System/Update/UpdateLayout.js +++ /dev/null @@ -1,29 +0,0 @@ -var Marionette = require('marionette'); -var Backgrid = require('backgrid'); -var UpdateCollection = require('./UpdateCollection'); -var UpdateCollectionView = require('./UpdateCollectionView'); -var LoadingView = require('../../Shared/LoadingView'); - -module.exports = Marionette.Layout.extend({ - template : 'System/Update/UpdateLayoutTemplate', - - regions : { - updates : '#x-updates' - }, - - initialize : function() { - this.updateCollection = new UpdateCollection(); - - this.listenTo(this.updateCollection, 'sync', this._showUpdates); - }, - - onRender : function() { - this.updates.show(new LoadingView()); - - this.updateCollection.fetch(); - }, - - _showUpdates : function() { - this.updates.show(new UpdateCollectionView({ collection : this.updateCollection })); - } -}); \ No newline at end of file diff --git a/src/UI/System/Update/UpdateLayoutTemplate.hbs b/src/UI/System/Update/UpdateLayoutTemplate.hbs deleted file mode 100644 index 0bd69dc20..000000000 --- a/src/UI/System/Update/UpdateLayoutTemplate.hbs +++ /dev/null @@ -1,5 +0,0 @@ -<div class="row"> - <div class="col-md-12"> - <div id="x-updates"/> - </div> -</div> diff --git a/src/UI/System/Update/UpdateModel.js b/src/UI/System/Update/UpdateModel.js deleted file mode 100644 index 3986a5948..000000000 --- a/src/UI/System/Update/UpdateModel.js +++ /dev/null @@ -1,3 +0,0 @@ -var Backbone = require('backbone'); - -module.exports = Backbone.Model.extend({}); \ No newline at end of file diff --git a/src/UI/System/Update/update.less b/src/UI/System/Update/update.less deleted file mode 100644 index 1db354fd6..000000000 --- a/src/UI/System/Update/update.less +++ /dev/null @@ -1,51 +0,0 @@ -@import '../../Shared/Styles/clickable'; - -.update { - margin-bottom: 30px; - - legend { - cursor : default; - margin-bottom : 5px; - line-height : 30px; - display : inline-block; - - .date { - font-size : 16px; - display : inline-block; - } - - .status { - margin-left : 5px; - font-size : 14px; - margin-top : -3px; - display : inline-block; - vertical-align : middle; - } - - .install-update { - .clickable(); - } - } - - .changes-header { - font-size: 18px; - } - - .label { - width: 40px; - text-align: center; - } - .change { - margin-bottom: 2px; - font-size: 13px; - } - - a { - color: white; - text-decoration: none; - } - - a:hover { - text-decoration: none; - } -} \ No newline at end of file diff --git a/src/UI/Tags/TagCollection.js b/src/UI/Tags/TagCollection.js deleted file mode 100644 index 287f6eaef..000000000 --- a/src/UI/Tags/TagCollection.js +++ /dev/null @@ -1,14 +0,0 @@ -var Backbone = require('backbone'); -var TagModel = require('./TagModel'); -var ApiData = require('../Shared/ApiData'); - -var Collection = Backbone.Collection.extend({ - url : window.NzbDrone.ApiRoot + '/tag', - model : TagModel, - - comparator : function(model) { - return model.get('label'); - } -}); - -module.exports = new Collection(ApiData.get('tag')); diff --git a/src/UI/Tags/TagHelpers.js b/src/UI/Tags/TagHelpers.js deleted file mode 100644 index a745de057..000000000 --- a/src/UI/Tags/TagHelpers.js +++ /dev/null @@ -1,25 +0,0 @@ -var _ = require('underscore'); -var Handlebars = require('handlebars'); -var TagCollection = require('./TagCollection'); - -Handlebars.registerHelper('tagDisplay', function(tags) { - var tagLabels = _.map(TagCollection.filter(function(tag) { - return _.contains(tags, tag.get('id')); - }), function(tag) { - return '<span class="label label-info">{0}</span>'.format(tag.get('label')); - }); - - return new Handlebars.SafeString(tagLabels.join(' ')); -}); - -Handlebars.registerHelper('genericTagDisplay', function(tags, classes) { - if (!tags) { - return new Handlebars.SafeString(''); - } - - var tagLabels = _.map(tags.split(','), function(tag) { - return '<span class="{0}">{1}</span>'.format(classes, tag); - }); - - return new Handlebars.SafeString(tagLabels.join(' ')); -}); \ No newline at end of file diff --git a/src/UI/Tags/TagModel.js b/src/UI/Tags/TagModel.js deleted file mode 100644 index 3986a5948..000000000 --- a/src/UI/Tags/TagModel.js +++ /dev/null @@ -1,3 +0,0 @@ -var Backbone = require('backbone'); - -module.exports = Backbone.Model.extend({}); \ No newline at end of file diff --git a/src/UI/Wanted/ControlsColumnTemplate.hbs b/src/UI/Wanted/ControlsColumnTemplate.hbs deleted file mode 100644 index 8943c7806..000000000 --- a/src/UI/Wanted/ControlsColumnTemplate.hbs +++ /dev/null @@ -1 +0,0 @@ -<i class="icon-sonarr-search x-search" title="Search"/> diff --git a/src/UI/Wanted/Cutoff/CutoffUnmetCollection.js b/src/UI/Wanted/Cutoff/CutoffUnmetCollection.js deleted file mode 100644 index 5f2a6546f..000000000 --- a/src/UI/Wanted/Cutoff/CutoffUnmetCollection.js +++ /dev/null @@ -1,63 +0,0 @@ -var _ = require('underscore'); -var EpisodeModel = require('../../Series/EpisodeModel'); -var PagableCollection = require('backbone.pageable'); -var AsFilteredCollection = require('../../Mixins/AsFilteredCollection'); -var AsSortedCollection = require('../../Mixins/AsSortedCollection'); -var AsPersistedStateCollection = require('../../Mixins/AsPersistedStateCollection'); - -var Collection = PagableCollection.extend({ - url : window.NzbDrone.ApiRoot + '/wanted/cutoff', - model : EpisodeModel, - tableName : 'wanted.cutoff', - - state : { - pageSize : 15, - sortKey : 'airDateUtc', - order : 1 - }, - - queryParams : { - totalPages : null, - totalRecords : null, - pageSize : 'pageSize', - sortKey : 'sortKey', - order : 'sortDir', - directions : { - '-1' : 'asc', - '1' : 'desc' - } - }, - - // Filter Modes - filterModes : { - 'monitored' : [ - 'monitored', - 'true' - ], - 'unmonitored' : [ - 'monitored', - 'false' - ], - }, - - sortMappings : { - 'series' : { sortKey : 'series.sortTitle' } - }, - - parseState : function(resp) { - return { totalRecords : resp.totalRecords }; - }, - - parseRecords : function(resp) { - if (resp) { - return resp.records; - } - - return resp; - } -}); - -Collection = AsFilteredCollection.call(Collection); -Collection = AsSortedCollection.call(Collection); - -module.exports = AsPersistedStateCollection.call(Collection); \ No newline at end of file diff --git a/src/UI/Wanted/Cutoff/CutoffUnmetLayout.js b/src/UI/Wanted/Cutoff/CutoffUnmetLayout.js deleted file mode 100644 index 2221f04fe..000000000 --- a/src/UI/Wanted/Cutoff/CutoffUnmetLayout.js +++ /dev/null @@ -1,188 +0,0 @@ -var _ = require('underscore'); -var Marionette = require('marionette'); -var Backgrid = require('backgrid'); -var CutoffUnmetCollection = require('./CutoffUnmetCollection'); -var SelectAllCell = require('../../Cells/SelectAllCell'); -var SeriesTitleCell = require('../../Cells/SeriesTitleCell'); -var EpisodeNumberCell = require('../../Cells/EpisodeNumberCell'); -var EpisodeTitleCell = require('../../Cells/EpisodeTitleCell'); -var RelativeDateCell = require('../../Cells/RelativeDateCell'); -var EpisodeStatusCell = require('../../Cells/EpisodeStatusCell'); -var GridPager = require('../../Shared/Grid/Pager'); -var ToolbarLayout = require('../../Shared/Toolbar/ToolbarLayout'); -var LoadingView = require('../../Shared/LoadingView'); -var Messenger = require('../../Shared/Messenger'); -var CommandController = require('../../Commands/CommandController'); -require('backgrid.selectall'); -require('../../Mixins/backbone.signalr.mixin'); - -module.exports = Marionette.Layout.extend({ - template : 'Wanted/Cutoff/CutoffUnmetLayoutTemplate', - - regions : { - cutoff : '#x-cutoff-unmet', - toolbar : '#x-toolbar', - pager : '#x-pager' - }, - - ui : { - searchSelectedButton : '.btn i.icon-sonarr-search' - }, - - columns : [ - { - name : '', - cell : SelectAllCell, - headerCell : 'select-all', - sortable : false - }, - { - name : 'series', - label : 'Series Title', - cell : SeriesTitleCell, - sortValue : 'series.sortTitle' - }, - { - name : 'this', - label : 'Episode', - cell : EpisodeNumberCell, - sortable : false - }, - { - name : 'this', - label : 'Episode Title', - cell : EpisodeTitleCell, - sortable : false - }, - { - name : 'airDateUtc', - label : 'Air Date', - cell : RelativeDateCell - }, - { - name : 'status', - label : 'Status', - cell : EpisodeStatusCell, - sortable : false - } - ], - - initialize : function() { - this.collection = new CutoffUnmetCollection().bindSignalR({ updateOnly : true }); - - this.listenTo(this.collection, 'sync', this._showTable); - }, - - onShow : function() { - this.cutoff.show(new LoadingView()); - this._showToolbar(); - this.collection.fetch(); - }, - - _showTable : function() { - this.cutoffGrid = new Backgrid.Grid({ - columns : this.columns, - collection : this.collection, - className : 'table table-hover' - }); - - this.cutoff.show(this.cutoffGrid); - - this.pager.show(new GridPager({ - columns : this.columns, - collection : this.collection - })); - }, - - _showToolbar : function() { - var leftSideButtons = { - type : 'default', - storeState : false, - items : [ - { - title : 'Search Selected', - icon : 'icon-sonarr-search', - callback : this._searchSelected, - ownerContext : this, - className : 'x-search-selected' - }, - { - title : 'Season Pass', - icon : 'icon-sonarr-monitored', - route : 'seasonpass' - } - ] - }; - - var filterOptions = { - type : 'radio', - storeState : false, - menuKey : 'wanted.filterMode', - defaultAction : 'monitored', - items : [ - { - key : 'monitored', - title : '', - tooltip : 'Monitored Only', - icon : 'icon-sonarr-monitored', - callback : this._setFilter - }, - { - key : 'unmonitored', - title : '', - tooltip : 'Unmonitored Only', - icon : 'icon-sonarr-unmonitored', - callback : this._setFilter - } - ] - }; - - this.toolbar.show(new ToolbarLayout({ - left : [ - leftSideButtons - ], - right : [ - filterOptions - ], - context : this - })); - - CommandController.bindToCommand({ - element : this.$('.x-search-selected'), - command : { - name : 'episodeSearch' - } - }); - }, - - _setFilter : function(buttonContext) { - var mode = buttonContext.model.get('key'); - - this.collection.state.currentPage = 1; - var promise = this.collection.setFilterMode(mode); - - if (buttonContext) { - buttonContext.ui.icon.spinForPromise(promise); - } - }, - - _searchSelected : function() { - var selected = this.cutoffGrid.getSelectedModels(); - - if (selected.length === 0) { - Messenger.show({ - type : 'error', - message : 'No episodes selected' - }); - - return; - } - - var ids = _.pluck(selected, 'id'); - - CommandController.Execute('episodeSearch', { - name : 'episodeSearch', - episodeIds : ids - }); - } -}); \ No newline at end of file diff --git a/src/UI/Wanted/Cutoff/CutoffUnmetLayoutTemplate.hbs b/src/UI/Wanted/Cutoff/CutoffUnmetLayoutTemplate.hbs deleted file mode 100644 index 7c6d095c0..000000000 --- a/src/UI/Wanted/Cutoff/CutoffUnmetLayoutTemplate.hbs +++ /dev/null @@ -1,11 +0,0 @@ -<div id="x-toolbar"/> -<div class="row"> - <div class="col-md-12"> - <div id="x-cutoff-unmet" class="table-responsive"/> - </div> -</div> -<div class="row"> - <div class="col-md-12"> - <div id="x-pager"/> - </div> -</div> diff --git a/src/UI/Wanted/Missing/MissingCollection.js b/src/UI/Wanted/Missing/MissingCollection.js deleted file mode 100644 index 28ceee62e..000000000 --- a/src/UI/Wanted/Missing/MissingCollection.js +++ /dev/null @@ -1,61 +0,0 @@ -var _ = require('underscore'); -var EpisodeModel = require('../../Series/EpisodeModel'); -var PagableCollection = require('backbone.pageable'); -var AsFilteredCollection = require('../../Mixins/AsFilteredCollection'); -var AsSortedCollection = require('../../Mixins/AsSortedCollection'); -var AsPersistedStateCollection = require('../../Mixins/AsPersistedStateCollection'); - -var Collection = PagableCollection.extend({ - url : window.NzbDrone.ApiRoot + '/wanted/missing', - model : EpisodeModel, - tableName : 'wanted.missing', - - state : { - pageSize : 15, - sortKey : 'airDateUtc', - order : 1 - }, - - queryParams : { - totalPages : null, - totalRecords : null, - pageSize : 'pageSize', - sortKey : 'sortKey', - order : 'sortDir', - directions : { - '-1' : 'asc', - '1' : 'desc' - } - }, - - filterModes : { - 'monitored' : [ - 'monitored', - 'true' - ], - 'unmonitored' : [ - 'monitored', - 'false' - ] - }, - - sortMappings : { - 'series' : { sortKey : 'series.sortTitle' } - }, - - parseState : function(resp) { - return { totalRecords : resp.totalRecords }; - }, - - parseRecords : function(resp) { - if (resp) { - return resp.records; - } - - return resp; - } -}); -Collection = AsFilteredCollection.call(Collection); -Collection = AsSortedCollection.call(Collection); - -module.exports = AsPersistedStateCollection.call(Collection); \ No newline at end of file diff --git a/src/UI/Wanted/Missing/MissingLayout.js b/src/UI/Wanted/Missing/MissingLayout.js deleted file mode 100644 index 3adb4876b..000000000 --- a/src/UI/Wanted/Missing/MissingLayout.js +++ /dev/null @@ -1,240 +0,0 @@ -var $ = require('jquery'); -var _ = require('underscore'); -var vent = require('../../vent'); -var Marionette = require('marionette'); -var Backgrid = require('backgrid'); -var MissingCollection = require('./MissingCollection'); -var SelectAllCell = require('../../Cells/SelectAllCell'); -var SeriesTitleCell = require('../../Cells/SeriesTitleCell'); -var EpisodeNumberCell = require('../../Cells/EpisodeNumberCell'); -var EpisodeTitleCell = require('../../Cells/EpisodeTitleCell'); -var RelativeDateCell = require('../../Cells/RelativeDateCell'); -var EpisodeStatusCell = require('../../Cells/EpisodeStatusCell'); -var GridPager = require('../../Shared/Grid/Pager'); -var ToolbarLayout = require('../../Shared/Toolbar/ToolbarLayout'); -var LoadingView = require('../../Shared/LoadingView'); -var Messenger = require('../../Shared/Messenger'); -var CommandController = require('../../Commands/CommandController'); - -require('backgrid.selectall'); -require('../../Mixins/backbone.signalr.mixin'); - -module.exports = Marionette.Layout.extend({ - template : 'Wanted/Missing/MissingLayoutTemplate', - - regions : { - missing : '#x-missing', - toolbar : '#x-toolbar', - pager : '#x-pager' - }, - - ui : { - searchSelectedButton : '.btn i.icon-sonarr-search' - }, - - columns : [ - { - name : '', - cell : SelectAllCell, - headerCell : 'select-all', - sortable : false - }, - { - name : 'series', - label : 'Series Title', - cell : SeriesTitleCell, - sortValue : 'series.sortTitle' - }, - { - name : 'this', - label : 'Episode', - cell : EpisodeNumberCell, - sortable : false - }, - { - name : 'this', - label : 'Episode Title', - cell : EpisodeTitleCell, - sortable : false - }, - { - name : 'airDateUtc', - label : 'Air Date', - cell : RelativeDateCell - }, - { - name : 'status', - label : 'Status', - cell : EpisodeStatusCell, - sortable : false - } - ], - - initialize : function() { - this.collection = new MissingCollection().bindSignalR({ updateOnly : true }); - - this.listenTo(this.collection, 'sync', this._showTable); - }, - - onShow : function() { - this.missing.show(new LoadingView()); - this._showToolbar(); - this.collection.fetch(); - }, - - _showTable : function() { - this.missingGrid = new Backgrid.Grid({ - columns : this.columns, - collection : this.collection, - className : 'table table-hover' - }); - - this.missing.show(this.missingGrid); - - this.pager.show(new GridPager({ - columns : this.columns, - collection : this.collection - })); - }, - - _showToolbar : function() { - var leftSideButtons = { - type : 'default', - storeState : false, - collapse : true, - items : [ - { - title : 'Search Selected', - icon : 'icon-sonarr-search', - callback : this._searchSelected, - ownerContext : this, - className : 'x-search-selected' - }, - { - title : 'Search All Missing', - icon : 'icon-sonarr-search', - callback : this._searchMissing, - ownerContext : this, - className : 'x-search-missing' - }, - { - title : 'Toggle Selected', - icon : 'icon-sonarr-monitored', - tooltip : 'Toggle monitored status of selected', - callback : this._toggleMonitoredOfSelected, - ownerContext : this, - className : 'x-unmonitor-selected' - }, - { - title : 'Season Pass', - icon : 'icon-sonarr-monitored', - route : 'seasonpass' - }, - { - title : 'Rescan Drone Factory Folder', - icon : 'icon-sonarr-refresh', - command : 'downloadedepisodesscan', - properties : { sendUpdates : true } - }, - { - title : 'Manual Import', - icon : 'icon-sonarr-search-manual', - callback : this._manualImport, - ownerContext : this - } - ] - }; - var filterOptions = { - type : 'radio', - storeState : false, - menuKey : 'wanted.filterMode', - defaultAction : 'monitored', - items : [ - { - key : 'monitored', - title : '', - tooltip : 'Monitored Only', - icon : 'icon-sonarr-monitored', - callback : this._setFilter - }, - { - key : 'unmonitored', - title : '', - tooltip : 'Unmonitored Only', - icon : 'icon-sonarr-unmonitored', - callback : this._setFilter - } - ] - }; - this.toolbar.show(new ToolbarLayout({ - left : [leftSideButtons], - right : [filterOptions], - context : this - })); - CommandController.bindToCommand({ - element : this.$('.x-search-selected'), - command : { name : 'episodeSearch' } - }); - CommandController.bindToCommand({ - element : this.$('.x-search-missing'), - command : { name : 'missingEpisodeSearch' } - }); - }, - - _setFilter : function(buttonContext) { - var mode = buttonContext.model.get('key'); - this.collection.state.currentPage = 1; - var promise = this.collection.setFilterMode(mode); - if (buttonContext) { - buttonContext.ui.icon.spinForPromise(promise); - } - }, - - _searchSelected : function() { - var selected = this.missingGrid.getSelectedModels(); - if (selected.length === 0) { - Messenger.show({ - type : 'error', - message : 'No episodes selected' - }); - return; - } - var ids = _.pluck(selected, 'id'); - CommandController.Execute('episodeSearch', { - name : 'episodeSearch', - episodeIds : ids - }); - }, - _searchMissing : function() { - if (window.confirm('Are you sure you want to search for {0} missing episodes? '.format(this.collection.state.totalRecords) + - 'One API request to each indexer will be used for each episode. ' + 'This cannot be stopped once started.')) { - CommandController.Execute('missingEpisodeSearch', { name : 'missingEpisodeSearch' }); - } - }, - _toggleMonitoredOfSelected : function() { - var selected = this.missingGrid.getSelectedModels(); - - if (selected.length === 0) { - Messenger.show({ - type : 'error', - message : 'No episodes selected' - }); - return; - } - - var promises = []; - var self = this; - - _.each(selected, function (episode) { - episode.set('monitored', !episode.get('monitored')); - promises.push(episode.save()); - }); - - $.when(promises).done(function () { - self.collection.fetch(); - }); - }, - _manualImport : function () { - vent.trigger(vent.Commands.ShowManualImport); - } -}); \ No newline at end of file diff --git a/src/UI/Wanted/Missing/MissingLayoutTemplate.hbs b/src/UI/Wanted/Missing/MissingLayoutTemplate.hbs deleted file mode 100644 index 4fd573b09..000000000 --- a/src/UI/Wanted/Missing/MissingLayoutTemplate.hbs +++ /dev/null @@ -1,11 +0,0 @@ -<div id="x-toolbar"/> -<div class="row"> - <div class="col-md-12"> - <div id="x-missing" class="table-responsive"/> - </div> -</div> -<div class="row"> - <div class="col-md-12"> - <div id="x-pager"/> - </div> -</div> diff --git a/src/UI/Wanted/WantedLayout.js b/src/UI/Wanted/WantedLayout.js deleted file mode 100644 index f7cce2109..000000000 --- a/src/UI/Wanted/WantedLayout.js +++ /dev/null @@ -1,68 +0,0 @@ -var Marionette = require('marionette'); -var Backbone = require('backbone'); -var Backgrid = require('backgrid'); -var MissingLayout = require('./Missing/MissingLayout'); -var CutoffUnmetLayout = require('./Cutoff/CutoffUnmetLayout'); - -module.exports = Marionette.Layout.extend({ - template : 'Wanted/WantedLayoutTemplate', - - regions : { - content : '#content' - //missing : '#missing', - //cutoff : '#cutoff' - }, - - ui : { - missingTab : '.x-missing-tab', - cutoffTab : '.x-cutoff-tab' - }, - - events : { - 'click .x-missing-tab' : '_showMissing', - 'click .x-cutoff-tab' : '_showCutoffUnmet' - }, - - initialize : function(options) { - if (options.action) { - this.action = options.action.toLowerCase(); - } - }, - - onShow : function() { - switch (this.action) { - case 'cutoff': - this._showCutoffUnmet(); - break; - default: - this._showMissing(); - } - }, - - _navigate : function(route) { - Backbone.history.navigate(route, { - trigger : false, - replace : true - }); - }, - - _showMissing : function(e) { - if (e) { - e.preventDefault(); - } - - this.content.show(new MissingLayout()); - this.ui.missingTab.tab('show'); - this._navigate('/wanted/missing'); - }, - - _showCutoffUnmet : function(e) { - if (e) { - e.preventDefault(); - } - - this.content.show(new CutoffUnmetLayout()); - this.ui.cutoffTab.tab('show'); - this._navigate('/wanted/cutoff'); - } -}); \ No newline at end of file diff --git a/src/UI/Wanted/WantedLayoutTemplate.hbs b/src/UI/Wanted/WantedLayoutTemplate.hbs deleted file mode 100644 index 973feb838..000000000 --- a/src/UI/Wanted/WantedLayoutTemplate.hbs +++ /dev/null @@ -1,10 +0,0 @@ -<ul class="nav nav-tabs"> - <li><a href="#missing" class="x-missing-tab no-router">Missing</a></li> - <li><a href="#cutoff" class="x-cutoff-tab no-router">Cutoff Unmet</a></li> -</ul> - -<div class="tab-pane" id="content"></div> -<!--<div class="tab-content"> - <div class="tab-pane" id="missing"></div> - <div class="tab-pane" id="cutoff"></div> -</div>--> \ No newline at end of file diff --git a/src/UI/app.js b/src/UI/app.js deleted file mode 100644 index 3ebfafdb0..000000000 --- a/src/UI/app.js +++ /dev/null @@ -1,159 +0,0 @@ -'use strict'; -require.config({ - - paths : { - 'backbone' : 'JsLibraries/backbone', - 'moment' : 'JsLibraries/moment', - 'filesize' : 'JsLibraries/filesize', - 'handlebars' : 'Shared/Shims/handlebars', - 'handlebars.helpers' : 'JsLibraries/handlebars.helpers', - 'bootstrap' : 'JsLibraries/bootstrap', - 'bootstrap.tagsinput' : 'JsLibraries/bootstrap.tagsinput', - 'backbone.deepmodel' : 'JsLibraries/backbone.deep.model', - 'backbone.pageable' : 'JsLibraries/backbone.pageable', - 'backbone.validation' : 'JsLibraries/backbone.validation', - 'backbone.modelbinder' : 'JsLibraries/backbone.modelbinder', - 'backbone.collectionview' : 'JsLibraries/backbone.collectionview', - 'backgrid' : 'JsLibraries/backbone.backgrid', - 'backgrid.paginator' : 'JsLibraries/backbone.backgrid.paginator', - 'backgrid.selectall' : 'JsLibraries/backbone.backgrid.selectall', - 'fullcalendar' : 'JsLibraries/fullcalendar', - 'backstrech' : 'JsLibraries/jquery.backstretch', - 'underscore' : 'JsLibraries/lodash.underscore', - 'marionette' : 'JsLibraries/backbone.marionette', - 'signalR' : 'JsLibraries/jquery.signalR', - 'jquery-ui' : 'JsLibraries/jquery-ui', - 'jquery.knob' : 'JsLibraries/jquery.knob', - 'jquery.easypiechart' : 'JsLibraries/jquery.easypiechart', - 'jquery.dotdotdot' : 'JsLibraries/jquery.dotdotdot', - 'messenger' : 'JsLibraries/messenger', - 'jquery' : 'JsLibraries/jquery', - 'typeahead' : 'JsLibraries/typeahead', - 'zero.clipboard' : 'JsLibraries/zero.clipboard', - 'libs' : 'JsLibraries/' - }, - - shim : { - api : { - deps : ['jquery'] - }, - jquery : { - exports : '$' - }, - messenger : { - deps : ['jquery'], - exports : 'Messenger', - init : function() { - window.Messenger.options = { - theme : 'flat' - }; - } - }, - signalR : { - deps : ['jquery'] - }, - bootstrap : { - deps : ['jquery'] - }, - 'bootstrap.tagsinput' : { - deps : [ - 'bootstrap', - 'typeahead' - ] - }, - backstrech : { - deps : ['jquery'] - }, - underscore : { - deps : ['jquery'], - exports : '_' - }, - backbone : { - deps : [ - 'jquery', - 'Instrumentation/ErrorHandler', - 'underscore', - 'Mixins/jquery.ajax', - 'jQuery/ToTheTop' - ], - exports : 'Backbone' - }, - marionette : { - deps : [ - 'backbone', - 'Handlebars/backbone.marionette.templates', - 'Mixins/AsNamedView' - ], - exports : 'Marionette', - init : function(Backbone, TemplateMixin, AsNamedView) { - TemplateMixin.call(window.Marionette.TemplateCache); - AsNamedView.call(window.Marionette.ItemView.prototype); - } - }, - 'typeahead' : { - deps : ['jquery'] - }, - 'jquery-ui' : { - deps : ['jquery'] - }, - 'jquery.knob' : { - deps : ['jquery'] - }, - 'jquery.easypiechart' : { - deps : ['jquery'] - }, - 'jquery.dotdotdot' : { - deps : ['jquery'] - }, - 'backbone.pageable' : { - deps : ['backbone'] - }, - 'backbone.deepmodel' : { - deps : [ - 'backbone', - 'underscore' - ] - }, - 'backbone.validation' : { - deps : ['backbone'], - exports : 'Backbone.Validation' - }, - 'backbone.modelbinder' : { - deps : ['backbone'] - }, - 'backbone.collectionview' : { - deps : [ - 'backbone', - 'jquery-ui' - ], - exports : 'Backbone.CollectionView' - }, - backgrid : { - deps : ['backbone'], - exports : 'Backgrid', - init : function() { - require(['Shared/Grid/HeaderCell'], function() { - window.Backgrid.Column.prototype.defaults = { - name : undefined, - label : undefined, - sortable : true, - editable : false, - renderable : true, - formatter : undefined, - cell : undefined, - headerCell : 'NzbDrone', - sortType : 'toggle' - }; - }); - } - }, - 'backgrid.paginator' : { - deps : ['backgrid'], - exports : 'Backgrid.Extension.Paginator' - }, - 'backgrid.selectall' : { - deps : ['backgrid'], - exports : 'Backgrid.Extension.SelectRowCell' - } - } -}); \ No newline at end of file diff --git a/src/UI/index.html b/src/UI/index.html deleted file mode 100644 index 94ebba2af..000000000 --- a/src/UI/index.html +++ /dev/null @@ -1,102 +0,0 @@ -<!doctype html> -<html> -<head> - <title>Sonarr - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - - -
    -
    - -
    - -
    -
    - - - - - - - - - diff --git a/src/UI/jQuery/RouteBinder.js b/src/UI/jQuery/RouteBinder.js deleted file mode 100644 index a09b42a41..000000000 --- a/src/UI/jQuery/RouteBinder.js +++ /dev/null @@ -1,63 +0,0 @@ -var Backbone = require('backbone'); -var $ = require('jquery'); -var StatusModel = require('../System/StatusModel'); - -//This module will automatically route all relative links through backbone router rather than -//causing links to reload pages. - -var routeBinder = { - - bind : function() { - var self = this; - $(document).on('click contextmenu', 'a[href]', function(event) { - self._handleClick(event); - }); - }, - - _handleClick : function(event) { - var $target = $(event.target); - - //check if tab nav - if ($target.parents('.nav-tabs').length) { - return; - } - - var linkElement = $target.closest('a').first(); - var href = linkElement.attr('href'); - - if (href && href.startsWith('http')) { - // Set noreferrer for external links. - if (!linkElement.attr('rel')) { - linkElement.attr('rel', 'noreferrer'); - } - // Open all external links in new windows. - if (!linkElement.attr('target')) { - linkElement.attr('target', '_blank'); - } - } - - if (linkElement.hasClass('no-router') || event.type !== 'click') { - return; - } - - if (!href) { - throw 'couldn\'t find route target'; - } - - if (!href.startsWith('http')) { - event.preventDefault(); - - if (event.ctrlKey) { - window.open(href, '_blank'); - } - - else { - var relativeHref = href.replace(StatusModel.get('urlBase'), ''); - - Backbone.history.navigate(relativeHref, { trigger : true }); - } - } - } -}; - -module.exports = routeBinder; \ No newline at end of file diff --git a/src/UI/jQuery/ToTheTop.js b/src/UI/jQuery/ToTheTop.js deleted file mode 100644 index 696f903a4..000000000 --- a/src/UI/jQuery/ToTheTop.js +++ /dev/null @@ -1,23 +0,0 @@ -var $ = require('jquery'); -var _ = require('underscore'); - -$(document).ready(function() { - var _window = $(window); - var _scrollContainer = $('#scroll-up'); - var _scrollButton = $('#scroll-up i'); - - var _scrollHandler = function() { - if (_window.scrollTop() > 400) { - _scrollContainer.fadeIn(); - } else { - _scrollContainer.fadeOut(); - } - }; - - $(window).scroll(_.throttle(_scrollHandler, 500)); - _scrollButton.click(function() { - $('html, body').animate({ scrollTop : 0 }, 600); - return false; - }); -}); - diff --git a/src/UI/jQuery/jquery.ajax.js b/src/UI/jQuery/jquery.ajax.js deleted file mode 100644 index 0073e8619..000000000 --- a/src/UI/jQuery/jquery.ajax.js +++ /dev/null @@ -1,23 +0,0 @@ -module.exports = function() { - - var $ = this; - - var original = $.ajax; - $.ajax = function(xhr) { - 'use strict'; - if (xhr && xhr.data && xhr.type === 'DELETE') { - if (xhr.url.contains('?')) { - xhr.url += '&'; - } else { - xhr.url += '?'; - } - xhr.url += $.param(xhr.data); - delete xhr.data; - } - if (xhr) { - xhr.headers = xhr.headers || {}; - xhr.headers['X-Api-Key'] = window.NzbDrone.ApiKey; - } - return original.apply(this, arguments); - }; -}; diff --git a/src/UI/jQuery/jquery.spin.js b/src/UI/jQuery/jquery.spin.js deleted file mode 100644 index 5df52a958..000000000 --- a/src/UI/jQuery/jquery.spin.js +++ /dev/null @@ -1,62 +0,0 @@ -module.exports = function() { - 'use strict'; - - var $ = this; - - $.fn.spinForPromise = function(promise) { - var self = this; - - if (!promise || promise.state() !== 'pending') { - return this; - } - promise.always(function() { - self.stopSpin(); - }); - - return this.startSpin(); - }; - - $.fn.startSpin = function() { - var icon = this.find('i').andSelf('i'); - - if (!icon || !icon.attr('class')) { - return this; - } - - var iconClasses = icon.attr('class').match(/(?:^|\s)icon\-.+?(?:$|\s)/); - - if (!iconClasses || iconClasses.length === 0) { - return this; - } - - var iconClass = $.trim(iconClasses[0]); - - this.addClass('disabled'); - - if (icon.hasClass('icon-can-spin')) { - icon.addClass('fa-spin'); - } else { - icon.attr('data-idle-icon', iconClass); - icon.removeClass(iconClass); - icon.addClass('fa-spin-overlay'); - icon.html(''); - } - - return this; - }; - - $.fn.stopSpin = function() { - var icon = this.find('i').andSelf('i'); - - icon.empty(); - this.removeClass('disabled'); - icon.removeClass('fa-spin fa-spin-overlay'); - var idleIcon = icon.attr('data-idle-icon'); - - if (idleIcon) { - icon.addClass(idleIcon); - } - - return this; - }; -}; \ No newline at end of file diff --git a/src/UI/jQuery/jquery.validation.js b/src/UI/jQuery/jquery.validation.js deleted file mode 100644 index 18cdd2f51..000000000 --- a/src/UI/jQuery/jquery.validation.js +++ /dev/null @@ -1,105 +0,0 @@ -module.exports = function() { - 'use strict'; - var $ = this; - $.fn.processServerError = function(error) { - var validationName = error.propertyName.toLowerCase(); - - var errorMessage = this.formatErrorMessage(error); - - this.find('.validation-errors').addClass('alert alert-danger').append('
    ' + errorMessage + '
    '); - - if (!validationName || validationName === '') { - this.addFormError(error); - return this; - } - - var input = this.find('[name]').filter(function() { - return this.name.toLowerCase() === validationName; - }); - - if (input.length === 0) { - input = this.find('[validation-name]').filter(function() { - return $(this).attr('validation-name').toLowerCase() === validationName; - }); - - //still not found? - if (input.length === 0) { - this.addFormError(error); - console.error('couldn\'t find input for ' + error.propertyName); - return this; - } - } - - var formGroup = input.parents('.form-group'); - - if (formGroup.length === 0) { - formGroup = input.parent(); - } else { - var inputGroup = formGroup.find('.input-group'); - - var validationClass = error.isWarning ? 'validation-warning' : 'validation-error'; - - if (inputGroup.length === 0) { - formGroup.append('{1}'.format(validationClass, errorMessage)); - } - else { - inputGroup.parent().append('{1}'.format(validationClass, errorMessage)); - } - } - - if (error.isWarning) { - formGroup.addClass('has-warning'); - } else { - formGroup.addClass('has-error'); - } - - return formGroup.find('.help-inline').text(); - }; - - $.fn.processClientError = function(error) { - - }; - - $.fn.addFormError = function(error) { - - var errorMessage = this.formatErrorMessage(error); - - var target = this.find('.modal-body'); - if (!target.length) { - target = this; - } - - var validationClass = error.isWarning ? 'alert alert-warning validation-warning' : 'alert alert-danger validation-error'; - - target.prepend('
    {1}
    '.format(validationClass, errorMessage)); - }; - - $.fn.removeAllErrors = function() { - this.removeClass('has-error'); - this.removeClass('has-warning'); - this.find('.has-error').removeClass('has-error'); - this.find('.has-warning').removeClass('has-warning'); - this.find('.error').removeClass('error'); - this.find('.validation-errors').removeClass('alert').removeClass('alert-danger').removeClass('alert-warning').html(''); - this.find('.validation-error').remove(); - this.find('.validation-warning').remove(); - return this.find('.help-inline.error-message').remove(); - }; - - $.fn.formatErrorMessage = function(error) { - - var errorMessage = error.errorMessage; - - if (error.infoLink) { - if (error.detailedDescription) { - errorMessage += ' '; - } else { - errorMessage += ' '; - } - } else if (error.detailedDescription) { - errorMessage += ' '; - } - - return errorMessage; - }; -}; \ No newline at end of file diff --git a/src/UI/login.html b/src/UI/login.html deleted file mode 100644 index 487e62680..000000000 --- a/src/UI/login.html +++ /dev/null @@ -1,55 +0,0 @@ - - - - Sonarr - Login - - - - - - - - - - - - - - - - -
    - -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
    -
    - - diff --git a/src/UI/main.js b/src/UI/main.js deleted file mode 100644 index f46f68b93..000000000 --- a/src/UI/main.js +++ /dev/null @@ -1,60 +0,0 @@ -var $ = require('jquery'); -var Backbone = require('backbone'); -var Marionette = require('marionette'); -var RouteBinder = require('./jQuery/RouteBinder'); -var SignalRBroadcaster = require('./Shared/SignalRBroadcaster'); -var NavbarLayout = require('./Navbar/NavbarLayout'); -var AppLayout = require('./AppLayout'); -var SeriesController = require('./Series/SeriesController'); -var Router = require('./Router'); -var ModalController = require('./Shared/Modal/ModalController'); -var ControlPanelController = require('./Shared/ControlPanel/ControlPanelController'); -var serverStatusModel = require('./System/StatusModel'); -var Tooltip = require('./Shared/Tooltip'); -var UiSettingsController = require('./Shared/UiSettingsController'); - -require('./jQuery/ToTheTop'); -require('./Instrumentation/StringFormat'); -require('./LifeCycle'); -require('./Hotkeys/Hotkeys'); -require('./Shared/piwikCheck'); -require('./Shared/VersionChangeMonitor'); - -new SeriesController(); -new ModalController(); -new ControlPanelController(); -new Router(); - -var app = new Marionette.Application(); - -app.addInitializer(function() { - console.log('starting application'); -}); - -app.addInitializer(SignalRBroadcaster.appInitializer, { app : app }); - -app.addInitializer(Tooltip.appInitializer, { app : app }); - -app.addInitializer(function() { - Backbone.history.start({ - pushState : true, - root : serverStatusModel.get('urlBase') - }); - RouteBinder.bind(); - AppLayout.navbarRegion.show(new NavbarLayout()); - $('body').addClass('started'); -}); - -app.addInitializer(UiSettingsController.appInitializer); - -app.addInitializer(function() { - var footerText = serverStatusModel.get('version'); - if (serverStatusModel.get('branch') !== 'master') { - footerText += '
    ' + serverStatusModel.get('branch'); - } - $('#footer-region .version').html(footerText); -}); - -app.start(); - -module.exports = app; diff --git a/src/UI/polyfills.js b/src/UI/polyfills.js deleted file mode 100644 index fef657524..000000000 --- a/src/UI/polyfills.js +++ /dev/null @@ -1,39 +0,0 @@ -window.console = window.console || {}; -window.console.log = window.console.log || function() {}; -window.console.group = window.console.group || function() {}; -window.console.groupEnd = window.console.groupEnd || function() {}; -window.console.debug = window.console.debug || function() {}; -window.console.warn = window.console.warn || function() {}; -window.console.assert = window.console.assert || function() {}; - -if (!String.prototype.startsWith) { - Object.defineProperty(String.prototype, 'startsWith', { - enumerable : false, - configurable : false, - writable : false, - value : function(searchString, position) { - position = position || 0; - return this.indexOf(searchString, position) === position; - } - }); -} - -if (!String.prototype.endsWith) { - Object.defineProperty(String.prototype, 'endsWith', { - enumerable : false, - configurable : false, - writable : false, - value : function(searchString, position) { - position = position || this.length; - position = position - searchString.length; - var lastIndex = this.lastIndexOf(searchString); - return lastIndex !== -1 && lastIndex === position; - } - }); -} - -if (!('contains' in String.prototype)) { - String.prototype.contains = function(str, startIndex) { - return -1 !== String.prototype.indexOf.call(this, str, startIndex); - }; -} \ No newline at end of file diff --git a/src/UI/reqres.js b/src/UI/reqres.js deleted file mode 100644 index 1904f3602..000000000 --- a/src/UI/reqres.js +++ /dev/null @@ -1,10 +0,0 @@ -var Wreqr = require('./JsLibraries/backbone.wreqr'); - -var reqres = new Wreqr.RequestResponse(); - -reqres.Requests = { - GetEpisodeFileById : 'GetEpisodeFileById', - GetAlternateNameBySeasonNumber : 'GetAlternateNameBySeasonNumber' -}; - -module.exports = reqres; \ No newline at end of file diff --git a/src/UI/vendor.js b/src/UI/vendor.js deleted file mode 100644 index dc343bb35..000000000 --- a/src/UI/vendor.js +++ /dev/null @@ -1,34 +0,0 @@ -/*Base*/ -require('jquery'); -require('underscore'); -require('messenger'); -require('moment'); -require('fullcalendar'); -require('backstrech'); -require('signalR'); -require('jquery-ui'); -require('jquery.knob'); -require('jquery.easypiechart'); -require('jquery.dotdotdot'); -require('typeahead'); -require('zero.clipboard'); - -/*Bootstrap*/ -require('bootstrap'); -require('bootstrap.tagsinput'); - -/*Backbone*/ -require('backbone'); -require('backbone.deepmodel'); -require('backbone.pageable'); -require('backbone-pageable'); -require('backbone.validation'); - -require('backbone.modelbinder'); -require('backbone.collectionview'); -require('backgrid'); -require('backgrid.paginator'); -require('backgrid.selectall'); - -require('marionette'); //this brings in a bunch of our code into this chunk because of template helpers. -require('vent'); diff --git a/src/UI/vent.js b/src/UI/vent.js deleted file mode 100644 index 1b9346529..000000000 --- a/src/UI/vent.js +++ /dev/null @@ -1,39 +0,0 @@ -var Wreqr = require('./JsLibraries/backbone.wreqr'); - -var vent = new Wreqr.EventAggregator(); - -vent.Events = { - SeriesAdded : 'series:added', - SeriesDeleted : 'series:deleted', - CommandComplete : 'command:complete', - ServerUpdated : 'server:updated', - EpisodeFileDeleted : 'episodefile:deleted' -}; - -vent.Commands = { - EditSeriesCommand : 'EditSeriesCommand', - DeleteSeriesCommand : 'DeleteSeriesCommand', - OpenModalCommand : 'OpenModalCommand', - CloseModalCommand : 'CloseModalCommand', - OpenModal2Command : 'OpenModal2Command', - CloseModal2Command : 'CloseModal2Command', - ShowEpisodeDetails : 'ShowEpisodeDetails', - ShowHistoryDetails : 'ShowHistoryDetails', - ShowLogDetails : 'ShowLogDetails', - SaveSettings : 'saveSettings', - ShowLogFile : 'showLogFile', - ShowRenamePreview : 'showRenamePreview', - ShowManualImport : 'showManualImport', - ShowFileBrowser : 'showFileBrowser', - CloseFileBrowser : 'closeFileBrowser', - OpenControlPanelCommand : 'OpenControlPanelCommand', - CloseControlPanelCommand : 'CloseControlPanelCommand' -}; - -vent.Hotkeys = { - NavbarSearch : 'navbar:search', - SaveSettings : 'settings:save', - ShowHotkeys : 'hotkeys:show' -}; - -module.exports = vent; \ No newline at end of file diff --git a/test.sh b/test.sh old mode 100644 new mode 100755 index 77b58f2e5..fa8bc606f --- a/test.sh +++ b/test.sh @@ -1,24 +1,48 @@ +#! /bin/bash PLATFORM=$1 TYPE=$2 +COVERAGE=$3 WHERE="cat != ManualTest" -TEST_DIR="." TEST_PATTERN="*Test.dll" ASSEMBLIES="" +TEST_LOG_FILE="TestLog.txt" + +echo "test dir: $TEST_DIR" +if [ -z "$TEST_DIR" ]; then + TEST_DIR="." +fi if [ -d "$TEST_DIR/_tests" ]; then TEST_DIR="$TEST_DIR/_tests" fi -NUNIT="$TEST_DIR/NUnit.ConsoleRunner.3.2.0/tools/nunit3-console.exe" +COVERAGE_RESULT_DIRECTORY="$TEST_DIR/CoverageResults/" + +rm -f "$TEST_LOG_FILE" + +# Uncomment to log test output to a file instead of the console +export LIDARR_TESTS_LOG_OUTPUT="File" + +NUNIT="$TEST_DIR/NUnit.ConsoleRunner.3.10.0/tools/nunit3-console.exe" NUNIT_COMMAND="$NUNIT" -NUNIT_PARAMS="--teamcity" +NUNIT_PARAMS="--workers=1" + +if [ "$PLATFORM" = "Mac" ]; then + + export DYLD_FALLBACK_LIBRARY_PATH="$TEST_DIR:/usr/local/lib:/lib:/usr/lib" + echo $LD_LIBRARY_PATH + echo $DYLD_LIBRARY_PATH + echo $DYLD_FALLBACK_LIBRARY_PATH + + # To debug which libraries are being loaded: + # export DYLD_PRINT_LIBRARIES=YES +fi if [ "$PLATFORM" = "Windows" ]; then + mkdir -p "$ProgramData/Lidarr" WHERE="$WHERE && cat != LINUX" -elif [ "$PLATFORM" = "Linux" ]; then - WHERE="$WHERE && cat != WINDOWS" - NUNIT_COMMAND="mono --debug --runtime=v4.0 $NUNIT" -elif [ "$PLATFORM" = "Mac" ]; then +elif [ "$PLATFORM" = "Linux" ] || [ "$PLATFORM" = "Mac" ] ; then + mkdir -p ~/.config/Lidarr WHERE="$WHERE && cat != WINDOWS" NUNIT_COMMAND="mono --debug --runtime=v4.0 $NUNIT" else @@ -41,8 +65,24 @@ for i in `find $TEST_DIR -name "$TEST_PATTERN"`; do ASSEMBLIES="$ASSEMBLIES $i" done -$NUNIT_COMMAND --where "$WHERE" $NUNIT_PARAMS $ASSEMBLIES; -EXIT_CODE=$? +if [ "$COVERAGE" = "Coverage" ]; then + if [ "$PLATFORM" = "Windows" ] || [ "$PLATFORM" = "Linux" ]; then + dotnet tool install coverlet.console --tool-path="$TEST_DIR/coverlet/" + mkdir $COVERAGE_RESULT_DIRECTORY + OPEN_COVER="$TEST_DIR/coverlet/coverlet" + $OPEN_COVER "$TEST_DIR/" --verbosity "detailed" --format "cobertura" --format "opencover" --output "$COVERAGE_RESULT_DIRECTORY" --exclude "[Lidarr.*.Test]*" --exclude "[Lidarr.Test.*]*" --exclude "[Lidarr.Api.V1]*" --exclude "[Marr.Data]*" --exclude "[MonoTorrent]*" --exclude "[CurlSharp]*" --target "$NUNIT" --targetargs "$NUNIT_PARAMS --where=\"$WHERE\" $ASSEMBLIES"; + EXIT_CODE=$? + else + echo "Coverage only supported on Windows and Linux" + exit 3 + fi +elif [ "$COVERAGE" = "Test" ] ; then + $NUNIT_COMMAND --where "$WHERE" $NUNIT_PARAMS $ASSEMBLIES; + EXIT_CODE=$? +else + echo "Run Type must be provided as third argument: Coverage or Test" + exit 3 +fi if [ "$EXIT_CODE" -ge 0 ]; then echo "Failed tests: $EXIT_CODE" diff --git a/tools/nuget/nuget.exe b/tools/nuget/nuget.exe index 9f8781de0..e00ef51e1 100644 Binary files a/tools/nuget/nuget.exe and b/tools/nuget/nuget.exe differ diff --git a/tools/vswhere/vswhere.exe b/tools/vswhere/vswhere.exe new file mode 100644 index 000000000..e1b511803 Binary files /dev/null and b/tools/vswhere/vswhere.exe differ diff --git a/webpack.config.js b/webpack.config.js deleted file mode 100644 index 5a15477c9..000000000 --- a/webpack.config.js +++ /dev/null @@ -1,74 +0,0 @@ -var path = require('path'); -var stylish = require('jshint-stylish'); -var webpack = require('webpack'); - -var uglifyJsPlugin = new webpack.optimize.UglifyJsPlugin(); - -var uiFolder = 'UI'; -var root = path.join(__dirname, 'src', uiFolder); - -module.exports = { - devtool : '#source-map', - watchOptions : { poll: true }, - entry: { - vendor: 'vendor.js', - main: 'main.js' - }, - resolve: { - root: root, - alias: { - 'vent': 'vent', - 'backbone': 'Shims/backbone', - 'moment': 'JsLibraries/moment', - 'filesize': 'JsLibraries/filesize', - 'handlebars': 'Shims/handlebars', - 'handlebars.helpers': 'JsLibraries/handlebars.helpers', - 'bootstrap': 'JsLibraries/bootstrap', - 'backbone.deepmodel': 'Shims/backbone.deep.model', - 'backbone.pageable': 'JsLibraries/backbone.pageable', - 'backbone-pageable': 'JsLibraries/backbone.pageable', - 'backbone.paginator': 'JsLibraries/backbone.paginator', - 'backbone.validation': 'Shims/backbone.validation', - 'backbone.modelbinder': 'JsLibraries/backbone.modelbinder', - 'backbone.collectionview': 'Shims/backbone.collectionview', - 'backgrid': 'Shims/backgrid', - 'backgrid.paginator': 'Shims/backgrid.paginator', - 'backgrid.selectall': 'Shims/backbone.backgrid.selectall', - 'fullcalendar': 'JsLibraries/fullcalendar', - 'backstrech': 'JsLibraries/jquery.backstretch', - 'underscore': 'Shims/underscore', - 'marionette': 'Shims/backbone.marionette', - 'signalR': 'Shims/jquery.signalR', - 'jquery-ui': 'JsLibraries/jquery-ui', - 'jquery.knob': 'JsLibraries/jquery.knob', - 'jquery.easypiechart': 'JsLibraries/jquery.easypiechart', - 'jquery.dotdotdot': 'JsLibraries/jquery.dotdotdot', - 'jquery.lazyload': 'JsLibraries/jquery.lazyload', - 'messenger': 'Shims/messenger', - 'jquery': 'Shims/jquery', - 'typeahead': 'JsLibraries/typeahead', - 'zero.clipboard': 'JsLibraries/zero.clipboard', - 'bootstrap.tagsinput': 'JsLibraries/bootstrap.tagsinput', - 'libs': 'JsLibraries/' - } - }, - output: { - filename: '_output/' + uiFolder + '/[name].js', - sourceMapFilename: '_output/' + uiFolder + '/[name].map' - }, - plugins: [ - new webpack.optimize.CommonsChunkPlugin({ name: 'vendor' }) - ], - module: { - - //this doesn't work yet. waiting for https://github.com/spenceralger/rcloader/issues/5 - /*preLoaders: [ - { - test: /\.js$/, // include .js files - loader: "jshint-loader", - exclude: [/JsLibraries/,/node_modules/] - } - ] - */ - } -}; diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 000000000..fe05320a2 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,9640 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.5.5": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.5.5.tgz#bc0782f6d69f7b7d49531219699b988f669a8f9d" + integrity sha512-27d4lZoomVyo51VegxI20xZPuSHusqbQag/ztrBC7wegWoQ1nLREPVSKSW8byhTlzTKyNE4ifaTA6lCp7JjpFw== + dependencies: + "@babel/highlight" "^7.0.0" + +"@babel/core@7.5.5", "@babel/core@>=7.2.2": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.5.5.tgz#17b2686ef0d6bc58f963dddd68ab669755582c30" + integrity sha512-i4qoSr2KTtce0DmkuuQBV4AuQgGPUcPXMr9L5MyYAtk06z068lQ10a4O009fe5OB/DfNV+h+qqT7ddNV8UnRjg== + dependencies: + "@babel/code-frame" "^7.5.5" + "@babel/generator" "^7.5.5" + "@babel/helpers" "^7.5.5" + "@babel/parser" "^7.5.5" + "@babel/template" "^7.4.4" + "@babel/traverse" "^7.5.5" + "@babel/types" "^7.5.5" + convert-source-map "^1.1.0" + debug "^4.1.0" + json5 "^2.1.0" + lodash "^4.17.13" + resolve "^1.3.2" + semver "^5.4.1" + source-map "^0.5.0" + +"@babel/generator@^7.5.5": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.5.5.tgz#873a7f936a3c89491b43536d12245b626664e3cf" + integrity sha512-ETI/4vyTSxTzGnU2c49XHv2zhExkv9JHLTwDAFz85kmcwuShvYG2H08FwgIguQf4JC75CBnXAUM5PqeF4fj0nQ== + dependencies: + "@babel/types" "^7.5.5" + jsesc "^2.5.1" + lodash "^4.17.13" + source-map "^0.5.0" + trim-right "^1.0.1" + +"@babel/helper-annotate-as-pure@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.0.0.tgz#323d39dd0b50e10c7c06ca7d7638e6864d8c5c32" + integrity sha512-3UYcJUj9kvSLbLbUIfQTqzcy5VX7GRZ/CCDrnOaZorFFM01aXp1+GJwuFGV4NDDoAS+mOUyHcO6UD/RfqOks3Q== + dependencies: + "@babel/types" "^7.0.0" + +"@babel/helper-builder-binary-assignment-operator-visitor@^7.1.0": + version "7.1.0" + resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.1.0.tgz#6b69628dfe4087798e0c4ed98e3d4a6b2fbd2f5f" + integrity sha512-qNSR4jrmJ8M1VMM9tibvyRAHXQs2PmaksQF7c1CGJNipfe3D8p+wgNwgso/P2A2r2mdgBWAXljNWR0QRZAMW8w== + dependencies: + "@babel/helper-explode-assignable-expression" "^7.1.0" + "@babel/types" "^7.0.0" + +"@babel/helper-builder-react-jsx@^7.3.0": + version "7.3.0" + resolved "https://registry.yarnpkg.com/@babel/helper-builder-react-jsx/-/helper-builder-react-jsx-7.3.0.tgz#a1ac95a5d2b3e88ae5e54846bf462eeb81b318a4" + integrity sha512-MjA9KgwCuPEkQd9ncSXvSyJ5y+j2sICHyrI0M3L+6fnS4wMSNDc1ARXsbTfbb2cXHn17VisSnU/sHFTCxVxSMw== + dependencies: + "@babel/types" "^7.3.0" + esutils "^2.0.0" + +"@babel/helper-call-delegate@^7.4.4": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@babel/helper-call-delegate/-/helper-call-delegate-7.4.4.tgz#87c1f8ca19ad552a736a7a27b1c1fcf8b1ff1f43" + integrity sha512-l79boDFJ8S1c5hvQvG+rc+wHw6IuH7YldmRKsYtpbawsxURu/paVy57FZMomGK22/JckepaikOkY0MoAmdyOlQ== + dependencies: + "@babel/helper-hoist-variables" "^7.4.4" + "@babel/traverse" "^7.4.4" + "@babel/types" "^7.4.4" + +"@babel/helper-create-class-features-plugin@^7.4.4", "@babel/helper-create-class-features-plugin@^7.5.5": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.5.5.tgz#401f302c8ddbc0edd36f7c6b2887d8fa1122e5a4" + integrity sha512-ZsxkyYiRA7Bg+ZTRpPvB6AbOFKTFFK4LrvTet8lInm0V468MWCaSYJE+I7v2z2r8KNLtYiV+K5kTCnR7dvyZjg== + dependencies: + "@babel/helper-function-name" "^7.1.0" + "@babel/helper-member-expression-to-functions" "^7.5.5" + "@babel/helper-optimise-call-expression" "^7.0.0" + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/helper-replace-supers" "^7.5.5" + "@babel/helper-split-export-declaration" "^7.4.4" + +"@babel/helper-define-map@^7.5.5": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/helper-define-map/-/helper-define-map-7.5.5.tgz#3dec32c2046f37e09b28c93eb0b103fd2a25d369" + integrity sha512-fTfxx7i0B5NJqvUOBBGREnrqbTxRh7zinBANpZXAVDlsZxYdclDp467G1sQ8VZYMnAURY3RpBUAgOYT9GfzHBg== + dependencies: + "@babel/helper-function-name" "^7.1.0" + "@babel/types" "^7.5.5" + lodash "^4.17.13" + +"@babel/helper-explode-assignable-expression@^7.1.0": + version "7.1.0" + resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.1.0.tgz#537fa13f6f1674df745b0c00ec8fe4e99681c8f6" + integrity sha512-NRQpfHrJ1msCHtKjbzs9YcMmJZOg6mQMmGRB+hbamEdG5PNpaSm95275VD92DvJKuyl0s2sFiDmMZ+EnnvufqA== + dependencies: + "@babel/traverse" "^7.1.0" + "@babel/types" "^7.0.0" + +"@babel/helper-function-name@^7.1.0": + version "7.1.0" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.1.0.tgz#a0ceb01685f73355d4360c1247f582bfafc8ff53" + integrity sha512-A95XEoCpb3TO+KZzJ4S/5uW5fNe26DjBGqf1o9ucyLyCmi1dXq/B3c8iaWTfBk3VvetUxl16e8tIrd5teOCfGw== + dependencies: + "@babel/helper-get-function-arity" "^7.0.0" + "@babel/template" "^7.1.0" + "@babel/types" "^7.0.0" + +"@babel/helper-get-function-arity@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.0.0.tgz#83572d4320e2a4657263734113c42868b64e49c3" + integrity sha512-r2DbJeg4svYvt3HOS74U4eWKsUAMRH01Z1ds1zx8KNTPtpTL5JAsdFv8BNyOpVqdFhHkkRDIg5B4AsxmkjAlmQ== + dependencies: + "@babel/types" "^7.0.0" + +"@babel/helper-hoist-variables@^7.4.4": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.4.4.tgz#0298b5f25c8c09c53102d52ac4a98f773eb2850a" + integrity sha512-VYk2/H/BnYbZDDg39hr3t2kKyifAm1W6zHRfhx8jGjIHpQEBv9dry7oQ2f3+J703TLu69nYdxsovl0XYfcnK4w== + dependencies: + "@babel/types" "^7.4.4" + +"@babel/helper-member-expression-to-functions@^7.5.5": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.5.5.tgz#1fb5b8ec4453a93c439ee9fe3aeea4a84b76b590" + integrity sha512-5qZ3D1uMclSNqYcXqiHoA0meVdv+xUEex9em2fqMnrk/scphGlGgg66zjMrPJESPwrFJ6sbfFQYUSa0Mz7FabA== + dependencies: + "@babel/types" "^7.5.5" + +"@babel/helper-module-imports@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.0.0.tgz#96081b7111e486da4d2cd971ad1a4fe216cc2e3d" + integrity sha512-aP/hlLq01DWNEiDg4Jn23i+CXxW/owM4WpDLFUbpjxe4NS3BhLVZQ5i7E0ZrxuQ/vwekIeciyamgB1UIYxxM6A== + dependencies: + "@babel/types" "^7.0.0" + +"@babel/helper-module-transforms@^7.1.0", "@babel/helper-module-transforms@^7.4.4": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.5.5.tgz#f84ff8a09038dcbca1fd4355661a500937165b4a" + integrity sha512-jBeCvETKuJqeiaCdyaheF40aXnnU1+wkSiUs/IQg3tB85up1LyL8x77ClY8qJpuRJUcXQo+ZtdNESmZl4j56Pw== + dependencies: + "@babel/helper-module-imports" "^7.0.0" + "@babel/helper-simple-access" "^7.1.0" + "@babel/helper-split-export-declaration" "^7.4.4" + "@babel/template" "^7.4.4" + "@babel/types" "^7.5.5" + lodash "^4.17.13" + +"@babel/helper-optimise-call-expression@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.0.0.tgz#a2920c5702b073c15de51106200aa8cad20497d5" + integrity sha512-u8nd9NQePYNQV8iPWu/pLLYBqZBa4ZaY1YWRFMuxrid94wKI1QNt67NEZ7GAe5Kc/0LLScbim05xZFWkAdrj9g== + dependencies: + "@babel/types" "^7.0.0" + +"@babel/helper-plugin-utils@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.0.0.tgz#bbb3fbee98661c569034237cc03967ba99b4f250" + integrity sha512-CYAOUCARwExnEixLdB6sDm2dIJ/YgEAKDM1MOeMeZu9Ld/bDgVo8aiWrXwcY7OBh+1Ea2uUcVRcxKk0GJvW7QA== + +"@babel/helper-regex@^7.0.0", "@babel/helper-regex@^7.4.4": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/helper-regex/-/helper-regex-7.5.5.tgz#0aa6824f7100a2e0e89c1527c23936c152cab351" + integrity sha512-CkCYQLkfkiugbRDO8eZn6lRuR8kzZoGXCg3149iTk5se7g6qykSpy3+hELSwquhu+TgHn8nkLiBwHvNX8Hofcw== + dependencies: + lodash "^4.17.13" + +"@babel/helper-remap-async-to-generator@^7.1.0": + version "7.1.0" + resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.1.0.tgz#361d80821b6f38da75bd3f0785ece20a88c5fe7f" + integrity sha512-3fOK0L+Fdlg8S5al8u/hWE6vhufGSn0bN09xm2LXMy//REAF8kDCrYoOBKYmA8m5Nom+sV9LyLCwrFynA8/slg== + dependencies: + "@babel/helper-annotate-as-pure" "^7.0.0" + "@babel/helper-wrap-function" "^7.1.0" + "@babel/template" "^7.1.0" + "@babel/traverse" "^7.1.0" + "@babel/types" "^7.0.0" + +"@babel/helper-replace-supers@^7.5.5": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.5.5.tgz#f84ce43df031222d2bad068d2626cb5799c34bc2" + integrity sha512-XvRFWrNnlsow2u7jXDuH4jDDctkxbS7gXssrP4q2nUD606ukXHRvydj346wmNg+zAgpFx4MWf4+usfC93bElJg== + dependencies: + "@babel/helper-member-expression-to-functions" "^7.5.5" + "@babel/helper-optimise-call-expression" "^7.0.0" + "@babel/traverse" "^7.5.5" + "@babel/types" "^7.5.5" + +"@babel/helper-simple-access@^7.1.0": + version "7.1.0" + resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.1.0.tgz#65eeb954c8c245beaa4e859da6188f39d71e585c" + integrity sha512-Vk+78hNjRbsiu49zAPALxTb+JUQCz1aolpd8osOF16BGnLtseD21nbHgLPGUwrXEurZgiCOUmvs3ExTu4F5x6w== + dependencies: + "@babel/template" "^7.1.0" + "@babel/types" "^7.0.0" + +"@babel/helper-split-export-declaration@^7.4.4": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.4.4.tgz#ff94894a340be78f53f06af038b205c49d993677" + integrity sha512-Ro/XkzLf3JFITkW6b+hNxzZ1n5OQ80NvIUdmHspih1XAhtN3vPTuUFT4eQnela+2MaZ5ulH+iyP513KJrxbN7Q== + dependencies: + "@babel/types" "^7.4.4" + +"@babel/helper-wrap-function@^7.1.0", "@babel/helper-wrap-function@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.2.0.tgz#c4e0012445769e2815b55296ead43a958549f6fa" + integrity sha512-o9fP1BZLLSrYlxYEYyl2aS+Flun5gtjTIG8iln+XuEzQTs0PLagAGSXUcqruJwD5fM48jzIEggCKpIfWTcR7pQ== + dependencies: + "@babel/helper-function-name" "^7.1.0" + "@babel/template" "^7.1.0" + "@babel/traverse" "^7.1.0" + "@babel/types" "^7.2.0" + +"@babel/helpers@^7.5.5": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.5.5.tgz#63908d2a73942229d1e6685bc2a0e730dde3b75e" + integrity sha512-nRq2BUhxZFnfEn/ciJuhklHvFOqjJUD5wpx+1bxUF2axL9C+v4DE/dmp5sT2dKnpOs4orZWzpAZqlCy8QqE/7g== + dependencies: + "@babel/template" "^7.4.4" + "@babel/traverse" "^7.5.5" + "@babel/types" "^7.5.5" + +"@babel/highlight@^7.0.0": + version "7.5.0" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.5.0.tgz#56d11312bd9248fa619591d02472be6e8cb32540" + integrity sha512-7dV4eu9gBxoM0dAnj/BCFDW9LFU0zvTrkq0ugM7pnHEgguOEeOz1so2ZghEdzviYzQEED0r4EAgpsBChKy1TRQ== + dependencies: + chalk "^2.0.0" + esutils "^2.0.2" + js-tokens "^4.0.0" + +"@babel/parser@^7.0.0", "@babel/parser@^7.4.4", "@babel/parser@^7.5.5": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.5.5.tgz#02f077ac8817d3df4a832ef59de67565e71cca4b" + integrity sha512-E5BN68cqR7dhKan1SfqgPGhQ178bkVKpXTPEXnFJBrEt8/DKRZlybmy+IgYLTeN7tp1R5Ccmbm2rBk17sHYU3g== + +"@babel/plugin-proposal-async-generator-functions@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.2.0.tgz#b289b306669dce4ad20b0252889a15768c9d417e" + integrity sha512-+Dfo/SCQqrwx48ptLVGLdE39YtWRuKc/Y9I5Fy0P1DDBB9lsAHpjcEJQt+4IifuSOSTLBKJObJqMvaO1pIE8LQ== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/helper-remap-async-to-generator" "^7.1.0" + "@babel/plugin-syntax-async-generators" "^7.2.0" + +"@babel/plugin-proposal-class-properties@7.5.5": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.5.5.tgz#a974cfae1e37c3110e71f3c6a2e48b8e71958cd4" + integrity sha512-AF79FsnWFxjlaosgdi421vmYG6/jg79bVD0dpD44QdgobzHKuLZ6S3vl8la9qIeSwGi8i1fS0O1mfuDAAdo1/A== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.5.5" + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-proposal-decorators@7.4.4": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.4.4.tgz#de9b2a1a8ab0196f378e2a82f10b6e2a36f21cc0" + integrity sha512-z7MpQz3XC/iQJWXH9y+MaWcLPNSMY9RQSthrLzak8R8hCj0fuyNk+Dzi9kfNe/JxxlWQ2g7wkABbgWjW36MTcw== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.4.4" + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-syntax-decorators" "^7.2.0" + +"@babel/plugin-proposal-dynamic-import@^7.5.0": + version "7.5.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.5.0.tgz#e532202db4838723691b10a67b8ce509e397c506" + integrity sha512-x/iMjggsKTFHYC6g11PL7Qy58IK8H5zqfm9e6hu4z1iH2IRyAp9u9dL80zA6R76yFovETFLKz2VJIC2iIPBuFw== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-syntax-dynamic-import" "^7.2.0" + +"@babel/plugin-proposal-export-default-from@7.5.2": + version "7.5.2" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.5.2.tgz#2c0ac2dcc36e3b2443fead2c3c5fc796fb1b5145" + integrity sha512-wr9Itk05L1/wyyZKVEmXWCdcsp/e185WUNl6AfYZeEKYaUPPvHXRDqO5K1VH7/UamYqGJowFRuCv30aDYZawsg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-syntax-export-default-from" "^7.2.0" + +"@babel/plugin-proposal-export-namespace-from@7.5.2": + version "7.5.2" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.5.2.tgz#ccd5ed05b06d700688ff1db01a9dd27155e0d2a0" + integrity sha512-TKUdOL07anjZEbR1iSxb5WFh810KyObdd29XLFLGo1IDsSuGrjH3ouWSbAxHNmrVKzr9X71UYl2dQ7oGGcRp0g== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-syntax-export-namespace-from" "^7.2.0" + +"@babel/plugin-proposal-function-sent@7.5.0": + version "7.5.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-function-sent/-/plugin-proposal-function-sent-7.5.0.tgz#39233aa801145e7d8072077cdb2d25f781c1ffd7" + integrity sha512-JXdfiQpKoC6UgQliZkp3NX7K3MVec1o1nfTWiCCIORE5ag/QZXhL0aSD8/Y2K+hIHonSTxuJF9rh9zsB6hBi2A== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/helper-wrap-function" "^7.2.0" + "@babel/plugin-syntax-function-sent" "^7.2.0" + +"@babel/plugin-proposal-json-strings@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.2.0.tgz#568ecc446c6148ae6b267f02551130891e29f317" + integrity sha512-MAFV1CA/YVmYwZG0fBQyXhmj0BHCB5egZHCKWIFVv/XCxAeVGIHfos3SwDck4LvCllENIAg7xMKOG5kH0dzyUg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-syntax-json-strings" "^7.2.0" + +"@babel/plugin-proposal-nullish-coalescing-operator@7.4.4": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.4.4.tgz#41c360d59481d88e0ce3a3f837df10121a769b39" + integrity sha512-Amph7Epui1Dh/xxUxS2+K22/MUi6+6JVTvy3P58tja3B6yKTSjwwx0/d83rF7551D6PVSSoplQb8GCwqec7HRw== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.2.0" + +"@babel/plugin-proposal-numeric-separator@7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.2.0.tgz#646854daf4cd22fd6733f6076013a936310443ac" + integrity sha512-DohMOGDrZiMKS7LthjUZNNcWl8TAf5BZDwZAH4wpm55FuJTHgfqPGdibg7rZDmont/8Yg0zA03IgT6XLeP+4sg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-syntax-numeric-separator" "^7.2.0" + +"@babel/plugin-proposal-object-rest-spread@^7.5.5": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.5.5.tgz#61939744f71ba76a3ae46b5eea18a54c16d22e58" + integrity sha512-F2DxJJSQ7f64FyTVl5cw/9MWn6naXGdk3Q3UhDbFEEHv+EilCPoeRD3Zh/Utx1CJz4uyKlQ4uH+bJPbEhMV7Zw== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-syntax-object-rest-spread" "^7.2.0" + +"@babel/plugin-proposal-optional-catch-binding@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.2.0.tgz#135d81edb68a081e55e56ec48541ece8065c38f5" + integrity sha512-mgYj3jCcxug6KUcX4OBoOJz3CMrwRfQELPQ5560F70YQUBZB7uac9fqaWamKR1iWUzGiK2t0ygzjTScZnVz75g== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-syntax-optional-catch-binding" "^7.2.0" + +"@babel/plugin-proposal-optional-chaining@7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.2.0.tgz#ae454f4c21c6c2ce8cb2397dc332ae8b420c5441" + integrity sha512-ea3Q6edZC/55wEBVZAEz42v528VulyO0eir+7uky/sT4XRcdkWJcFi1aPtitTlwUzGnECWJNExWww1SStt+yWw== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-syntax-optional-chaining" "^7.2.0" + +"@babel/plugin-proposal-throw-expressions@7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-throw-expressions/-/plugin-proposal-throw-expressions-7.2.0.tgz#2d9e452d370f139000e51db65d0a85dc60c64739" + integrity sha512-adsydM8DQF4i5DLNO4ySAU5VtHTPewOtNBV3u7F4lNMPADFF9bWQ+iDtUUe8+033cYCUz+bFlQdXQJmJOwoLpw== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-syntax-throw-expressions" "^7.2.0" + +"@babel/plugin-proposal-unicode-property-regex@^7.4.4": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.4.4.tgz#501ffd9826c0b91da22690720722ac7cb1ca9c78" + integrity sha512-j1NwnOqMG9mFUOH58JTFsA/+ZYzQLUZ/drqWUqxCYLGeu2JFZL8YrNC9hBxKmWtAuOCHPcRpgv7fhap09Fb4kA== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/helper-regex" "^7.4.4" + regexpu-core "^4.5.4" + +"@babel/plugin-syntax-async-generators@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.2.0.tgz#69e1f0db34c6f5a0cf7e2b3323bf159a76c8cb7f" + integrity sha512-1ZrIRBv2t0GSlcwVoQ6VgSLpLgiN/FVQUzt9znxo7v2Ov4jJrs8RY8tv0wvDmFN3qIdMKWrmMMW6yZ0G19MfGg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-syntax-decorators@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.2.0.tgz#c50b1b957dcc69e4b1127b65e1c33eef61570c1b" + integrity sha512-38QdqVoXdHUQfTpZo3rQwqQdWtCn5tMv4uV6r2RMfTqNBuv4ZBhz79SfaQWKTVmxHjeFv/DnXVC/+agHCklYWA== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-syntax-dynamic-import@7.2.0", "@babel/plugin-syntax-dynamic-import@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.2.0.tgz#69c159ffaf4998122161ad8ebc5e6d1f55df8612" + integrity sha512-mVxuJ0YroI/h/tbFTPGZR8cv6ai+STMKNBq0f8hFxsxWjl94qqhsb+wXbpNMDPU3cfR1TIsVFzU3nXyZMqyK4w== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-syntax-export-default-from@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.2.0.tgz#edd83b7adc2e0d059e2467ca96c650ab6d2f3820" + integrity sha512-c7nqUnNST97BWPtoe+Ssi+fJukc9P9/JMZ71IOMNQWza2E+Psrd46N6AEvtw6pqK+gt7ChjXyrw4SPDO79f3Lw== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-syntax-export-namespace-from@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.2.0.tgz#8d257838c6b3b779db52c0224443459bd27fb039" + integrity sha512-1zGA3UNch6A+A11nIzBVEaE3DDJbjfB+eLIcf0GGOh/BJr/8NxL3546MGhV/r0RhH4xADFIEso39TKCfEMlsGA== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-syntax-function-sent@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-function-sent/-/plugin-syntax-function-sent-7.2.0.tgz#91474d4d400604e4c6cbd4d77cd6cb3b8565576c" + integrity sha512-2MOVuJ6IMAifp2cf0RFkHQaOvHpbBYyWCvgtF/WVqXhTd7Bgtov8iXVCadLXp2FN1BrI2EFl+JXuwXy0qr3KoQ== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-syntax-json-strings@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.2.0.tgz#72bd13f6ffe1d25938129d2a186b11fd62951470" + integrity sha512-5UGYnMSLRE1dqqZwug+1LISpA403HzlSfsg6P9VXU6TBjcSHeNlw4DxDx7LgpF+iKZoOG/+uzqoRHTdcUpiZNg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-syntax-jsx@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.2.0.tgz#0b85a3b4bc7cdf4cc4b8bf236335b907ca22e7c7" + integrity sha512-VyN4QANJkRW6lDBmENzRszvZf3/4AXaj9YR7GwrWeeN9tEBPuXbmDYVU9bYBN0D70zCWVwUy0HWq2553VCb6Hw== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-syntax-nullish-coalescing-operator@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.2.0.tgz#f75083dfd5ade73e783db729bbd87e7b9efb7624" + integrity sha512-lRCEaKE+LTxDQtgbYajI04ddt6WW0WJq57xqkAZ+s11h4YgfRHhVA/Y2VhfPzzFD4qeLHWg32DMp9HooY4Kqlg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-syntax-numeric-separator@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.2.0.tgz#7470fe070c2944469a756752a69a6963135018be" + integrity sha512-DroeVNkO/BnGpL2R7+ZNZqW+E24aR/4YWxP3Qb15d6lPU8KDzF8HlIUIRCOJRn4X77/oyW4mJY+7FHfY82NLtQ== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-syntax-object-rest-spread@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.2.0.tgz#3b7a3e733510c57e820b9142a6579ac8b0dfad2e" + integrity sha512-t0JKGgqk2We+9may3t0xDdmneaXmyxq0xieYcKHxIsrJO64n1OiMWNUtc5gQK1PA0NpdCRrtZp4z+IUaKugrSA== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-syntax-optional-catch-binding@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.2.0.tgz#a94013d6eda8908dfe6a477e7f9eda85656ecf5c" + integrity sha512-bDe4xKNhb0LI7IvZHiA13kff0KEfaGX/Hv4lMA9+7TEc63hMNvfKo6ZFpXhKuEp+II/q35Gc4NoMeDZyaUbj9w== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-syntax-optional-chaining@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.2.0.tgz#a59d6ae8c167e7608eaa443fda9fa8fa6bf21dff" + integrity sha512-HtGCtvp5Uq/jH/WNUPkK6b7rufnCPLLlDAFN7cmACoIjaOOiXxUt3SswU5loHqrhtqTsa/WoLQ1OQ1AGuZqaWA== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-syntax-throw-expressions@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-throw-expressions/-/plugin-syntax-throw-expressions-7.2.0.tgz#79001ee2afe1b174b1733cdc2fc69c9a46a0f1f8" + integrity sha512-ngwynuqu1Rx0JUS9zxSDuPgW1K8TyVZCi2hHehrL4vyjqE7RGoNHWlZsS7KQT2vw9Yjk4YLa0+KldBXTRdPLRg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-arrow-functions@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.2.0.tgz#9aeafbe4d6ffc6563bf8f8372091628f00779550" + integrity sha512-ER77Cax1+8/8jCB9fo4Ud161OZzWN5qawi4GusDuRLcDbDG+bIGYY20zb2dfAFdTRGzrfq2xZPvF0R64EHnimg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-async-to-generator@^7.5.0": + version "7.5.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.5.0.tgz#89a3848a0166623b5bc481164b5936ab947e887e" + integrity sha512-mqvkzwIGkq0bEF1zLRRiTdjfomZJDV33AH3oQzHVGkI2VzEmXLpKKOBvEVaFZBJdN0XTyH38s9j/Kiqr68dggg== + dependencies: + "@babel/helper-module-imports" "^7.0.0" + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/helper-remap-async-to-generator" "^7.1.0" + +"@babel/plugin-transform-block-scoped-functions@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.2.0.tgz#5d3cc11e8d5ddd752aa64c9148d0db6cb79fd190" + integrity sha512-ntQPR6q1/NKuphly49+QiQiTN0O63uOwjdD6dhIjSWBI5xlrbUFh720TIpzBhpnrLfv2tNH/BXvLIab1+BAI0w== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-block-scoping@^7.5.5": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.5.5.tgz#a35f395e5402822f10d2119f6f8e045e3639a2ce" + integrity sha512-82A3CLRRdYubkG85lKwhZB0WZoHxLGsJdux/cOVaJCJpvYFl1LVzAIFyRsa7CvXqW8rBM4Zf3Bfn8PHt5DP0Sg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + lodash "^4.17.13" + +"@babel/plugin-transform-classes@^7.5.5": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.5.5.tgz#d094299d9bd680a14a2a0edae38305ad60fb4de9" + integrity sha512-U2htCNK/6e9K7jGyJ++1p5XRU+LJjrwtoiVn9SzRlDT2KubcZ11OOwy3s24TjHxPgxNwonCYP7U2K51uVYCMDg== + dependencies: + "@babel/helper-annotate-as-pure" "^7.0.0" + "@babel/helper-define-map" "^7.5.5" + "@babel/helper-function-name" "^7.1.0" + "@babel/helper-optimise-call-expression" "^7.0.0" + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/helper-replace-supers" "^7.5.5" + "@babel/helper-split-export-declaration" "^7.4.4" + globals "^11.1.0" + +"@babel/plugin-transform-computed-properties@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.2.0.tgz#83a7df6a658865b1c8f641d510c6f3af220216da" + integrity sha512-kP/drqTxY6Xt3NNpKiMomfgkNn4o7+vKxK2DDKcBG9sHj51vHqMBGy8wbDS/J4lMxnqs153/T3+DmCEAkC5cpA== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-destructuring@^7.5.0": + version "7.5.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.5.0.tgz#f6c09fdfe3f94516ff074fe877db7bc9ef05855a" + integrity sha512-YbYgbd3TryYYLGyC7ZR+Tq8H/+bCmwoaxHfJHupom5ECstzbRLTch6gOQbhEY9Z4hiCNHEURgq06ykFv9JZ/QQ== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-dotall-regex@^7.4.4": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.4.4.tgz#361a148bc951444312c69446d76ed1ea8e4450c3" + integrity sha512-P05YEhRc2h53lZDjRPk/OektxCVevFzZs2Gfjd545Wde3k+yFDbXORgl2e0xpbq8mLcKJ7Idss4fAg0zORN/zg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/helper-regex" "^7.4.4" + regexpu-core "^4.5.4" + +"@babel/plugin-transform-duplicate-keys@^7.5.0": + version "7.5.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.5.0.tgz#c5dbf5106bf84cdf691222c0974c12b1df931853" + integrity sha512-igcziksHizyQPlX9gfSjHkE2wmoCH3evvD2qR5w29/Dk0SMKE/eOI7f1HhBdNhR/zxJDqrgpoDTq5YSLH/XMsQ== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-exponentiation-operator@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.2.0.tgz#a63868289e5b4007f7054d46491af51435766008" + integrity sha512-umh4hR6N7mu4Elq9GG8TOu9M0bakvlsREEC+ialrQN6ABS4oDQ69qJv1VtR3uxlKMCQMCvzk7vr17RHKcjx68A== + dependencies: + "@babel/helper-builder-binary-assignment-operator-visitor" "^7.1.0" + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-for-of@^7.4.4": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.4.4.tgz#0267fc735e24c808ba173866c6c4d1440fc3c556" + integrity sha512-9T/5Dlr14Z9TIEXLXkt8T1DU7F24cbhwhMNUziN3hB1AXoZcdzPcTiKGRn/6iOymDqtTKWnr/BtRKN9JwbKtdQ== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-function-name@^7.4.4": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.4.4.tgz#e1436116abb0610c2259094848754ac5230922ad" + integrity sha512-iU9pv7U+2jC9ANQkKeNF6DrPy4GBa4NWQtl6dHB4Pb3izX2JOEvDTFarlNsBj/63ZEzNNIAMs3Qw4fNCcSOXJA== + dependencies: + "@babel/helper-function-name" "^7.1.0" + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-literals@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.2.0.tgz#690353e81f9267dad4fd8cfd77eafa86aba53ea1" + integrity sha512-2ThDhm4lI4oV7fVQ6pNNK+sx+c/GM5/SaML0w/r4ZB7sAneD/piDJtwdKlNckXeyGK7wlwg2E2w33C/Hh+VFCg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-member-expression-literals@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.2.0.tgz#fa10aa5c58a2cb6afcf2c9ffa8cb4d8b3d489a2d" + integrity sha512-HiU3zKkSU6scTidmnFJ0bMX8hz5ixC93b4MHMiYebmk2lUVNGOboPsqQvx5LzooihijUoLR/v7Nc1rbBtnc7FA== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-modules-amd@^7.5.0": + version "7.5.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.5.0.tgz#ef00435d46da0a5961aa728a1d2ecff063e4fb91" + integrity sha512-n20UsQMKnWrltocZZm24cRURxQnWIvsABPJlw/fvoy9c6AgHZzoelAIzajDHAQrDpuKFFPPcFGd7ChsYuIUMpg== + dependencies: + "@babel/helper-module-transforms" "^7.1.0" + "@babel/helper-plugin-utils" "^7.0.0" + babel-plugin-dynamic-import-node "^2.3.0" + +"@babel/plugin-transform-modules-commonjs@^7.5.0": + version "7.5.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.5.0.tgz#425127e6045231360858eeaa47a71d75eded7a74" + integrity sha512-xmHq0B+ytyrWJvQTc5OWAC4ii6Dhr0s22STOoydokG51JjWhyYo5mRPXoi+ZmtHQhZZwuXNN+GG5jy5UZZJxIQ== + dependencies: + "@babel/helper-module-transforms" "^7.4.4" + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/helper-simple-access" "^7.1.0" + babel-plugin-dynamic-import-node "^2.3.0" + +"@babel/plugin-transform-modules-systemjs@^7.5.0": + version "7.5.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.5.0.tgz#e75266a13ef94202db2a0620977756f51d52d249" + integrity sha512-Q2m56tyoQWmuNGxEtUyeEkm6qJYFqs4c+XyXH5RAuYxObRNz9Zgj/1g2GMnjYp2EUyEy7YTrxliGCXzecl/vJg== + dependencies: + "@babel/helper-hoist-variables" "^7.4.4" + "@babel/helper-plugin-utils" "^7.0.0" + babel-plugin-dynamic-import-node "^2.3.0" + +"@babel/plugin-transform-modules-umd@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.2.0.tgz#7678ce75169f0877b8eb2235538c074268dd01ae" + integrity sha512-BV3bw6MyUH1iIsGhXlOK6sXhmSarZjtJ/vMiD9dNmpY8QXFFQTj+6v92pcfy1iqa8DeAfJFwoxcrS/TUZda6sw== + dependencies: + "@babel/helper-module-transforms" "^7.1.0" + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-named-capturing-groups-regex@^7.4.5": + version "7.4.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.4.5.tgz#9d269fd28a370258199b4294736813a60bbdd106" + integrity sha512-z7+2IsWafTBbjNsOxU/Iv5CvTJlr5w4+HGu1HovKYTtgJ362f7kBcQglkfmlspKKZ3bgrbSGvLfNx++ZJgCWsg== + dependencies: + regexp-tree "^0.1.6" + +"@babel/plugin-transform-new-target@^7.4.4": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.4.4.tgz#18d120438b0cc9ee95a47f2c72bc9768fbed60a5" + integrity sha512-r1z3T2DNGQwwe2vPGZMBNjioT2scgWzK9BCnDEh+46z8EEwXBq24uRzd65I7pjtugzPSj921aM15RpESgzsSuA== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-object-super@^7.5.5": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.5.5.tgz#c70021df834073c65eb613b8679cc4a381d1a9f9" + integrity sha512-un1zJQAhSosGFBduPgN/YFNvWVpRuHKU7IHBglLoLZsGmruJPOo6pbInneflUdmq7YvSVqhpPs5zdBvLnteltQ== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/helper-replace-supers" "^7.5.5" + +"@babel/plugin-transform-parameters@^7.4.4": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.4.4.tgz#7556cf03f318bd2719fe4c922d2d808be5571e16" + integrity sha512-oMh5DUO1V63nZcu/ZVLQFqiihBGo4OpxJxR1otF50GMeCLiRx5nUdtokd+u9SuVJrvvuIh9OosRFPP4pIPnwmw== + dependencies: + "@babel/helper-call-delegate" "^7.4.4" + "@babel/helper-get-function-arity" "^7.0.0" + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-property-literals@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.2.0.tgz#03e33f653f5b25c4eb572c98b9485055b389e905" + integrity sha512-9q7Dbk4RhgcLp8ebduOpCbtjh7C0itoLYHXd9ueASKAG/is5PQtMR5VJGka9NKqGhYEGn5ITahd4h9QeBMylWQ== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-react-display-name@^7.0.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.2.0.tgz#ebfaed87834ce8dc4279609a4f0c324c156e3eb0" + integrity sha512-Htf/tPa5haZvRMiNSQSFifK12gtr/8vwfr+A9y69uF0QcU77AVu4K7MiHEkTxF7lQoHOL0F9ErqgfNEAKgXj7A== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-react-jsx-self@^7.0.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.2.0.tgz#461e21ad9478f1031dd5e276108d027f1b5240ba" + integrity sha512-v6S5L/myicZEy+jr6ielB0OR8h+EH/1QFx/YJ7c7Ua+7lqsjj/vW6fD5FR9hB/6y7mGbfT4vAURn3xqBxsUcdg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-syntax-jsx" "^7.2.0" + +"@babel/plugin-transform-react-jsx-source@^7.0.0": + version "7.5.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.5.0.tgz#583b10c49cf057e237085bcbd8cc960bd83bd96b" + integrity sha512-58Q+Jsy4IDCZx7kqEZuSDdam/1oW8OdDX8f+Loo6xyxdfg1yF0GE2XNJQSTZCaMol93+FBzpWiPEwtbMloAcPg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-syntax-jsx" "^7.2.0" + +"@babel/plugin-transform-react-jsx@^7.0.0": + version "7.3.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.3.0.tgz#f2cab99026631c767e2745a5368b331cfe8f5290" + integrity sha512-a/+aRb7R06WcKvQLOu4/TpjKOdvVEKRLWFpKcNuHhiREPgGRB4TQJxq07+EZLS8LFVYpfq1a5lDUnuMdcCpBKg== + dependencies: + "@babel/helper-builder-react-jsx" "^7.3.0" + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-syntax-jsx" "^7.2.0" + +"@babel/plugin-transform-regenerator@^7.4.5": + version "7.4.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.4.5.tgz#629dc82512c55cee01341fb27bdfcb210354680f" + integrity sha512-gBKRh5qAaCWntnd09S8QC7r3auLCqq5DI6O0DlfoyDjslSBVqBibrMdsqO+Uhmx3+BlOmE/Kw1HFxmGbv0N9dA== + dependencies: + regenerator-transform "^0.14.0" + +"@babel/plugin-transform-reserved-words@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.2.0.tgz#4792af87c998a49367597d07fedf02636d2e1634" + integrity sha512-fz43fqW8E1tAB3DKF19/vxbpib1fuyCwSPE418ge5ZxILnBhWyhtPgz8eh1RCGGJlwvksHkyxMxh0eenFi+kFw== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-shorthand-properties@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.2.0.tgz#6333aee2f8d6ee7e28615457298934a3b46198f0" + integrity sha512-QP4eUM83ha9zmYtpbnyjTLAGKQritA5XW/iG9cjtuOI8s1RuL/3V6a3DeSHfKutJQ+ayUfeZJPcnCYEQzaPQqg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-spread@^7.2.0": + version "7.2.2" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.2.2.tgz#3103a9abe22f742b6d406ecd3cd49b774919b406" + integrity sha512-KWfky/58vubwtS0hLqEnrWJjsMGaOeSBn90Ezn5Jeg9Z8KKHmELbP1yGylMlm5N6TPKeY9A2+UaSYLdxahg01w== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-sticky-regex@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.2.0.tgz#a1e454b5995560a9c1e0d537dfc15061fd2687e1" + integrity sha512-KKYCoGaRAf+ckH8gEL3JHUaFVyNHKe3ASNsZ+AlktgHevvxGigoIttrEJb8iKN03Q7Eazlv1s6cx2B2cQ3Jabw== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/helper-regex" "^7.0.0" + +"@babel/plugin-transform-template-literals@^7.4.4": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.4.4.tgz#9d28fea7bbce637fb7612a0750989d8321d4bcb0" + integrity sha512-mQrEC4TWkhLN0z8ygIvEL9ZEToPhG5K7KDW3pzGqOfIGZ28Jb0POUkeWcoz8HnHvhFy6dwAT1j8OzqN8s804+g== + dependencies: + "@babel/helper-annotate-as-pure" "^7.0.0" + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-typeof-symbol@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.2.0.tgz#117d2bcec2fbf64b4b59d1f9819894682d29f2b2" + integrity sha512-2LNhETWYxiYysBtrBTqL8+La0jIoQQnIScUJc74OYvUGRmkskNY4EzLCnjHBzdmb38wqtTaixpo1NctEcvMDZw== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/plugin-transform-unicode-regex@^7.4.4": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.4.4.tgz#ab4634bb4f14d36728bf5978322b35587787970f" + integrity sha512-il+/XdNw01i93+M9J9u4T7/e/Ue/vWfNZE4IRUQjplu2Mqb/AFTDimkw2tdEdSH50wuQXZAbXSql0UphQke+vA== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/helper-regex" "^7.4.4" + regexpu-core "^4.5.4" + +"@babel/preset-env@7.5.5": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.5.5.tgz#bc470b53acaa48df4b8db24a570d6da1fef53c9a" + integrity sha512-GMZQka/+INwsMz1A5UEql8tG015h5j/qjptpKY2gJ7giy8ohzU710YciJB5rcKsWGWHiW3RUnHib0E5/m3Tp3A== + dependencies: + "@babel/helper-module-imports" "^7.0.0" + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-proposal-async-generator-functions" "^7.2.0" + "@babel/plugin-proposal-dynamic-import" "^7.5.0" + "@babel/plugin-proposal-json-strings" "^7.2.0" + "@babel/plugin-proposal-object-rest-spread" "^7.5.5" + "@babel/plugin-proposal-optional-catch-binding" "^7.2.0" + "@babel/plugin-proposal-unicode-property-regex" "^7.4.4" + "@babel/plugin-syntax-async-generators" "^7.2.0" + "@babel/plugin-syntax-dynamic-import" "^7.2.0" + "@babel/plugin-syntax-json-strings" "^7.2.0" + "@babel/plugin-syntax-object-rest-spread" "^7.2.0" + "@babel/plugin-syntax-optional-catch-binding" "^7.2.0" + "@babel/plugin-transform-arrow-functions" "^7.2.0" + "@babel/plugin-transform-async-to-generator" "^7.5.0" + "@babel/plugin-transform-block-scoped-functions" "^7.2.0" + "@babel/plugin-transform-block-scoping" "^7.5.5" + "@babel/plugin-transform-classes" "^7.5.5" + "@babel/plugin-transform-computed-properties" "^7.2.0" + "@babel/plugin-transform-destructuring" "^7.5.0" + "@babel/plugin-transform-dotall-regex" "^7.4.4" + "@babel/plugin-transform-duplicate-keys" "^7.5.0" + "@babel/plugin-transform-exponentiation-operator" "^7.2.0" + "@babel/plugin-transform-for-of" "^7.4.4" + "@babel/plugin-transform-function-name" "^7.4.4" + "@babel/plugin-transform-literals" "^7.2.0" + "@babel/plugin-transform-member-expression-literals" "^7.2.0" + "@babel/plugin-transform-modules-amd" "^7.5.0" + "@babel/plugin-transform-modules-commonjs" "^7.5.0" + "@babel/plugin-transform-modules-systemjs" "^7.5.0" + "@babel/plugin-transform-modules-umd" "^7.2.0" + "@babel/plugin-transform-named-capturing-groups-regex" "^7.4.5" + "@babel/plugin-transform-new-target" "^7.4.4" + "@babel/plugin-transform-object-super" "^7.5.5" + "@babel/plugin-transform-parameters" "^7.4.4" + "@babel/plugin-transform-property-literals" "^7.2.0" + "@babel/plugin-transform-regenerator" "^7.4.5" + "@babel/plugin-transform-reserved-words" "^7.2.0" + "@babel/plugin-transform-shorthand-properties" "^7.2.0" + "@babel/plugin-transform-spread" "^7.2.0" + "@babel/plugin-transform-sticky-regex" "^7.2.0" + "@babel/plugin-transform-template-literals" "^7.4.4" + "@babel/plugin-transform-typeof-symbol" "^7.2.0" + "@babel/plugin-transform-unicode-regex" "^7.4.4" + "@babel/types" "^7.5.5" + browserslist "^4.6.0" + core-js-compat "^3.1.1" + invariant "^2.2.2" + js-levenshtein "^1.1.3" + semver "^5.5.0" + +"@babel/preset-react@7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/preset-react/-/preset-react-7.0.0.tgz#e86b4b3d99433c7b3e9e91747e2653958bc6b3c0" + integrity sha512-oayxyPS4Zj+hF6Et11BwuBkmpgT/zMxyuZgFrMeZID6Hdh3dGlk4sHCAhdBCpuCKW2ppBfl2uCCetlrUIJRY3w== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-transform-react-display-name" "^7.0.0" + "@babel/plugin-transform-react-jsx" "^7.0.0" + "@babel/plugin-transform-react-jsx-self" "^7.0.0" + "@babel/plugin-transform-react-jsx-source" "^7.0.0" + +"@babel/runtime@^7.1.2", "@babel/runtime@^7.4.0", "@babel/runtime@^7.5.5": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.5.5.tgz#74fba56d35efbeca444091c7850ccd494fd2f132" + integrity sha512-28QvEGyQyNkB0/m2B4FU7IEZGK2NUrcMtT6BZEFALTguLk+AUT6ofsHtPk5QyjAdUkpMJ+/Em+quwz4HOt30AQ== + dependencies: + regenerator-runtime "^0.13.2" + +"@babel/template@^7.1.0", "@babel/template@^7.4.4": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.4.4.tgz#f4b88d1225689a08f5bc3a17483545be9e4ed237" + integrity sha512-CiGzLN9KgAvgZsnivND7rkA+AeJ9JB0ciPOD4U59GKbQP2iQl+olF1l76kJOupqidozfZ32ghwBEJDhnk9MEcw== + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/parser" "^7.4.4" + "@babel/types" "^7.4.4" + +"@babel/traverse@^7.0.0", "@babel/traverse@^7.1.0", "@babel/traverse@^7.4.4", "@babel/traverse@^7.5.5": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.5.5.tgz#f664f8f368ed32988cd648da9f72d5ca70f165bb" + integrity sha512-MqB0782whsfffYfSjH4TM+LMjrJnhCNEDMDIjeTpl+ASaUvxcjoiVCo/sM1GhS1pHOXYfWVCYneLjMckuUxDaQ== + dependencies: + "@babel/code-frame" "^7.5.5" + "@babel/generator" "^7.5.5" + "@babel/helper-function-name" "^7.1.0" + "@babel/helper-split-export-declaration" "^7.4.4" + "@babel/parser" "^7.5.5" + "@babel/types" "^7.5.5" + debug "^4.1.0" + globals "^11.1.0" + lodash "^4.17.13" + +"@babel/types@^7.0.0", "@babel/types@^7.2.0", "@babel/types@^7.3.0", "@babel/types@^7.4.4", "@babel/types@^7.5.5": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.5.5.tgz#97b9f728e182785909aa4ab56264f090a028d18a" + integrity sha512-s63F9nJioLqOlW3UkyMd+BYhXt44YuaFm/VV0VwuteqjYwRrObkU7ra9pY4wAJR3oXi8hJrMcrcJdO/HH33vtw== + dependencies: + esutils "^2.0.2" + lodash "^4.17.13" + to-fast-properties "^2.0.0" + +"@fortawesome/fontawesome-common-types@^0.2.22": + version "0.2.22" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.22.tgz#3f1328d232a0fd5de8484d833c8519426f39f016" + integrity sha512-QmEuZsipX5/cR9JOg0fsTN4Yr/9lieYWM8AQpmRa0eIfeOcl/HLYoEa366BCGRSrgNJEexuvOgbq9jnJ22IY5g== + +"@fortawesome/fontawesome-free@5.10.2": + version "5.10.2" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.10.2.tgz#27e02da1e34b50c9869179d364fb46627b521130" + integrity sha512-9pw+Nsnunl9unstGEHQ+u41wBEQue6XPBsILXtJF/4fNN1L3avJcMF/gGF86rIjeTAgfLjTY9ndm68/X4f4idQ== + +"@fortawesome/fontawesome-svg-core@1.2.22": + version "1.2.22" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.22.tgz#9a6117c96c8b823c7d531000568ac75c3c02e123" + integrity sha512-Q941E4x8UfnMH3308n0qrgoja+GoqyiV846JTLoCcCWAKokLKrixCkq6RDBs8r+TtAWaLUrBpI+JFxQNX/WNPQ== + dependencies: + "@fortawesome/fontawesome-common-types" "^0.2.22" + +"@fortawesome/free-regular-svg-icons@5.10.2": + version "5.10.2" + resolved "https://registry.yarnpkg.com/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-5.10.2.tgz#e4ada1c15f42133ad92761418a9b0e2d407fb022" + integrity sha512-Qk4FmwXuRDY5K2GyiKt7adCN204dTlTb0Ps3/JU4BfYoCrU43DResd1QZxfcoQJfV2kw29spZ4+BDL+9IRyj1Q== + dependencies: + "@fortawesome/fontawesome-common-types" "^0.2.22" + +"@fortawesome/free-solid-svg-icons@5.10.2": + version "5.10.2" + resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.10.2.tgz#61bcecce3aa5001fd154826238dfa840de4aa05a" + integrity sha512-9Os/GRUcy+iVaznlg8GKcPSQFpIQpAg14jF0DWsMdnpJfIftlvfaQCWniR/ex9FoOpSEOrlXqmUCFL+JGeciuA== + dependencies: + "@fortawesome/fontawesome-common-types" "^0.2.22" + +"@fortawesome/react-fontawesome@0.1.4": + version "0.1.4" + resolved "https://registry.yarnpkg.com/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.4.tgz#18d61d9b583ca289a61aa7dccc05bd164d6bc9ad" + integrity sha512-GwmxQ+TK7PEdfSwvxtGnMCqrfEm0/HbRHArbUudsYiy9KzVCwndxa2KMcfyTQ8El0vROrq8gOOff09RF1oQe8g== + dependencies: + humps "^2.0.1" + prop-types "^15.5.10" + +"@gulp-sourcemaps/identity-map@1.X": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@gulp-sourcemaps/identity-map/-/identity-map-1.0.2.tgz#1e6fe5d8027b1f285dc0d31762f566bccd73d5a9" + integrity sha512-ciiioYMLdo16ShmfHBXJBOFm3xPC4AuwO4xeRpFeHz7WK9PYsWCmigagG2XyzZpubK4a3qNKoUBDhbzHfa50LQ== + dependencies: + acorn "^5.0.3" + css "^2.2.1" + normalize-path "^2.1.1" + source-map "^0.6.0" + through2 "^2.0.3" + +"@gulp-sourcemaps/map-sources@1.X": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@gulp-sourcemaps/map-sources/-/map-sources-1.0.0.tgz#890ae7c5d8c877f6d384860215ace9d7ec945bda" + integrity sha1-iQrnxdjId/bThIYCFazp1+yUW9o= + dependencies: + normalize-path "^2.0.1" + through2 "^2.0.3" + +"@mrmlnc/readdir-enhanced@^2.2.1": + version "2.2.1" + resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde" + integrity sha512-bPHp6Ji8b41szTOcaP63VlnbbO5Ny6dwAATtY6JTjh5N2OLrb5Qk/Th5cRkRQhkWCt+EJsYrNB0MiL+Gpn6e3g== + dependencies: + call-me-maybe "^1.0.1" + glob-to-regexp "^0.3.0" + +"@nodelib/fs.scandir@2.1.2": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.2.tgz#1f981cd5b83e85cfdeb386fc693d4baab392fa54" + integrity sha512-wrIBsjA5pl13f0RN4Zx4FNWmU71lv03meGKnqRUoCyan17s4V3WL92f3w3AIuWbNnpcrQyFBU5qMavJoB8d27w== + dependencies: + "@nodelib/fs.stat" "2.0.2" + run-parallel "^1.1.9" + +"@nodelib/fs.stat@2.0.2", "@nodelib/fs.stat@^2.0.1": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.2.tgz#2762aea8fe78ea256860182dcb52d61ee4b8fda6" + integrity sha512-z8+wGWV2dgUhLqrtRYa03yDx4HWMvXKi1z8g3m2JyxAx8F7xk74asqPk5LAETjqDSGLFML/6CDl0+yFunSYicw== + +"@nodelib/fs.stat@^1.1.2": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz#2b5a3ab3f918cca48a8c754c08168e3f03eba61b" + integrity sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw== + +"@nodelib/fs.walk@^1.2.1": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.3.tgz#a555dc256acaf00c62b0db29529028dd4d4cb141" + integrity sha512-l6t8xEhfK9Sa4YO5mIRdau7XSOADfmh3jCr0evNHdY+HNkW6xuQhgMH7D73VV6WpZOagrW0UludvMTiifiwTfA== + dependencies: + "@nodelib/fs.scandir" "2.1.2" + fastq "^1.6.0" + +"@sentry/browser@5.6.3": + version "5.6.3" + resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-5.6.3.tgz#5cc37b0443eba55ad13c13d34d6b95ff30dfbfe3" + integrity sha512-bP1LTbcKPOkkmfJOAM6c7WZ0Ov0ZEW6B9keVZ9wH9fw/lBPd9UyDMDCwJ+FAYKz9M9S5pxQeJ4Ebd7WUUrGVAQ== + dependencies: + "@sentry/core" "5.6.2" + "@sentry/types" "5.6.1" + "@sentry/utils" "5.6.1" + tslib "^1.9.3" + +"@sentry/cli@1.47.1": + version "1.47.1" + resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-1.47.1.tgz#6a3238e5bfa4f618867bc0bc145b8e2ba191ff46" + integrity sha512-WijaRu1lb99OL6rHee6uOSb1wDyNCbrWcTJoRCuZD83K2fw3U58p68nli/y8CoMwQ55Mrg6CgtY8pmBiuseG0A== + dependencies: + fs-copy-file-sync "^1.1.1" + https-proxy-agent "^2.2.1" + mkdirp "^0.5.1" + node-fetch "^2.1.2" + progress "2.0.0" + proxy-from-env "^1.0.0" + +"@sentry/core@5.6.2": + version "5.6.2" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-5.6.2.tgz#8c5477654a83ebe41a72e86a79215deb5025e418" + integrity sha512-grbjvNmyxP5WSPR6UobN2q+Nss7Hvz+BClBT8QTr7VTEG5q89TwNddn6Ej3bGkaUVbct/GpVlI3XflWYDsnU6Q== + dependencies: + "@sentry/hub" "5.6.1" + "@sentry/minimal" "5.6.1" + "@sentry/types" "5.6.1" + "@sentry/utils" "5.6.1" + tslib "^1.9.3" + +"@sentry/hub@5.6.1": + version "5.6.1" + resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-5.6.1.tgz#9f355c0abcc92327fbd10b9b939608aa4967bece" + integrity sha512-m+OhkIV5yTAL3R1+XfCwzUQka0UF/xG4py8sEfPXyYIcoOJ2ZTX+1kQJLy8QQJ4RzOBwZA+DzRKP0cgzPJ3+oQ== + dependencies: + "@sentry/types" "5.6.1" + "@sentry/utils" "5.6.1" + tslib "^1.9.3" + +"@sentry/integrations@5.6.1": + version "5.6.1" + resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-5.6.1.tgz#fcee1a6e5535a07fdefd365178662283279ce0d7" + integrity sha512-bPtJbmhLDH9Exy0luIKxjlfqmuyAjUPTHZ2CLIw6YlhA5WgK9aYyyjLHTmWK+E9baZBqSp0ShVPAgue2jfpQmQ== + dependencies: + "@sentry/types" "5.6.1" + "@sentry/utils" "5.6.1" + tslib "^1.9.3" + +"@sentry/minimal@5.6.1": + version "5.6.1" + resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-5.6.1.tgz#09d92b26de0b24555cd50c3c33ba4c3e566009a1" + integrity sha512-ercCKuBWHog6aS6SsJRuKhJwNdJ2oRQVWT2UAx1zqvsbHT9mSa8ZRjdPHYOtqY3DoXKk/pLUFW/fkmAnpdMqRw== + dependencies: + "@sentry/hub" "5.6.1" + "@sentry/types" "5.6.1" + tslib "^1.9.3" + +"@sentry/types@5.6.1": + version "5.6.1" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-5.6.1.tgz#5915e1ee4b7a678da3ac260c356b1cb91139a299" + integrity sha512-Kub8TETefHpdhvtnDj3kKfhCj0u/xn3Zi2zIC7PB11NJHvvPXENx97tciz4roJGp7cLRCJsFqCg4tHXniqDSnQ== + +"@sentry/utils@5.6.1": + version "5.6.1" + resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-5.6.1.tgz#69d9e151e50415bc91f2428e3bcca8beb9bc2815" + integrity sha512-rfgha+UsHW816GqlSRPlniKqAZylOmQWML2JsujoUP03nPu80zdN43DK9Poy/d9OxBxv0gd5K2n+bFdM2kqLQQ== + dependencies: + "@sentry/types" "5.6.1" + tslib "^1.9.3" + +"@types/asap@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@types/asap/-/asap-2.0.0.tgz#d529e9608c83499a62ae08c871c5e62271aa2963" + integrity sha512-upIS0Gt9Mc8eEpCbYMZ1K8rhNosfKUtimNcINce+zLwJF5UpM3Vv7yz3S5l/1IX+DxTa8lTkUjqynvjRXyJzsg== + +"@types/events@*": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7" + integrity sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g== + +"@types/glob@^7.1.1": + version "7.1.1" + resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.1.tgz#aa59a1c6e3fbc421e07ccd31a944c30eba521575" + integrity sha512-1Bh06cbWJUHMC97acuD6UMG29nMt0Aqz1vF3guLfG+kHHJhy3AyohZFFxYk2f7Q1SQIrNwvncxAE0N/9s70F2w== + dependencies: + "@types/events" "*" + "@types/minimatch" "*" + "@types/node" "*" + +"@types/hoist-non-react-statics@^3.3.1": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f" + integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA== + dependencies: + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + +"@types/invariant@^2.2.30": + version "2.2.30" + resolved "https://registry.yarnpkg.com/@types/invariant/-/invariant-2.2.30.tgz#20efa342807606ada5483731a8137cb1561e5fe9" + integrity sha512-98fB+yo7imSD2F7PF7GIpELNgtLNgo5wjivu0W5V4jx+KVVJxo6p/qN4zdzSTBWy4/sN3pPyXwnhRSD28QX+ag== + +"@types/minimatch@*": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" + integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== + +"@types/node@*": + version "12.7.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-12.7.2.tgz#c4e63af5e8823ce9cc3f0b34f7b998c2171f0c44" + integrity sha512-dyYO+f6ihZEtNPDcWNR1fkoTDf3zAK3lAABDze3mz6POyIercH0lEUawUFXlG8xaQZmm1yEBON/4TsYv/laDYg== + +"@types/prop-types@*": + version "15.7.1" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.1.tgz#f1a11e7babb0c3cad68100be381d1e064c68f1f6" + integrity sha512-CFzn9idOEpHrgdw8JsoTkaDDyRWk1jrzIV8djzcgpq0y9tG4B4lFT+Nxh52DVpDXV+n4+NPNv7M1Dj5uMp6XFg== + +"@types/q@^1.5.1": + version "1.5.2" + resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8" + integrity sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw== + +"@types/react@*": + version "16.9.2" + resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.2.tgz#6d1765431a1ad1877979013906731aae373de268" + integrity sha512-jYP2LWwlh+FTqGd9v7ynUKZzjj98T8x7Yclz479QdRhHfuW9yQ+0jjnD31eXSXutmBpppj5PYNLYLRfnZJvcfg== + dependencies: + "@types/prop-types" "*" + csstype "^2.2.0" + +"@types/shallowequal@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@types/shallowequal/-/shallowequal-1.1.1.tgz#aad262bb3f2b1257d94c71d545268d592575c9b1" + integrity sha512-Lhni3aX80zbpdxRuWhnuYPm8j8UQaa571lHP/xI4W+7BAFhSIhRReXnqjEgT/XzPoXZTJkCqstFMJ8CZTK6IlQ== + +"@types/unist@*", "@types/unist@^2.0.0": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.3.tgz#9c088679876f374eb5983f150d4787aa6fb32d7e" + integrity sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ== + +"@types/vfile-message@*": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@types/vfile-message/-/vfile-message-1.0.1.tgz#e1e9895cc6b36c462d4244e64e6d0b6eaf65355a" + integrity sha512-mlGER3Aqmq7bqR1tTTIVHq8KSAFFRyGbrxuM8C/H82g6k7r2fS+IMEkIu3D7JHzG10NvPdR8DNx0jr0pwpp4dA== + dependencies: + "@types/node" "*" + "@types/unist" "*" + +"@types/vfile@^3.0.0": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/vfile/-/vfile-3.0.2.tgz#19c18cd232df11ce6fa6ad80259bc86c366b09b9" + integrity sha512-b3nLFGaGkJ9rzOcuXRfHkZMdjsawuDD0ENL9fzTophtBg8FJHSGbH7daXkEpcwy3v7Xol3pAvsmlYyFhR4pqJw== + dependencies: + "@types/node" "*" + "@types/unist" "*" + "@types/vfile-message" "*" + +"@webassemblyjs/ast@1.8.5": + version "1.8.5" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.8.5.tgz#51b1c5fe6576a34953bf4b253df9f0d490d9e359" + integrity sha512-aJMfngIZ65+t71C3y2nBBg5FFG0Okt9m0XEgWZ7Ywgn1oMAT8cNwx00Uv1cQyHtidq0Xn94R4TAywO+LCQ+ZAQ== + dependencies: + "@webassemblyjs/helper-module-context" "1.8.5" + "@webassemblyjs/helper-wasm-bytecode" "1.8.5" + "@webassemblyjs/wast-parser" "1.8.5" + +"@webassemblyjs/floating-point-hex-parser@1.8.5": + version "1.8.5" + resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.8.5.tgz#1ba926a2923613edce496fd5b02e8ce8a5f49721" + integrity sha512-9p+79WHru1oqBh9ewP9zW95E3XAo+90oth7S5Re3eQnECGq59ly1Ri5tsIipKGpiStHsUYmY3zMLqtk3gTcOtQ== + +"@webassemblyjs/helper-api-error@1.8.5": + version "1.8.5" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.8.5.tgz#c49dad22f645227c5edb610bdb9697f1aab721f7" + integrity sha512-Za/tnzsvnqdaSPOUXHyKJ2XI7PDX64kWtURyGiJJZKVEdFOsdKUCPTNEVFZq3zJ2R0G5wc2PZ5gvdTRFgm81zA== + +"@webassemblyjs/helper-buffer@1.8.5": + version "1.8.5" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.8.5.tgz#fea93e429863dd5e4338555f42292385a653f204" + integrity sha512-Ri2R8nOS0U6G49Q86goFIPNgjyl6+oE1abW1pS84BuhP1Qcr5JqMwRFT3Ah3ADDDYGEgGs1iyb1DGX+kAi/c/Q== + +"@webassemblyjs/helper-code-frame@1.8.5": + version "1.8.5" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-code-frame/-/helper-code-frame-1.8.5.tgz#9a740ff48e3faa3022b1dff54423df9aa293c25e" + integrity sha512-VQAadSubZIhNpH46IR3yWO4kZZjMxN1opDrzePLdVKAZ+DFjkGD/rf4v1jap744uPVU6yjL/smZbRIIJTOUnKQ== + dependencies: + "@webassemblyjs/wast-printer" "1.8.5" + +"@webassemblyjs/helper-fsm@1.8.5": + version "1.8.5" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-fsm/-/helper-fsm-1.8.5.tgz#ba0b7d3b3f7e4733da6059c9332275d860702452" + integrity sha512-kRuX/saORcg8se/ft6Q2UbRpZwP4y7YrWsLXPbbmtepKr22i8Z4O3V5QE9DbZK908dh5Xya4Un57SDIKwB9eow== + +"@webassemblyjs/helper-module-context@1.8.5": + version "1.8.5" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-module-context/-/helper-module-context-1.8.5.tgz#def4b9927b0101dc8cbbd8d1edb5b7b9c82eb245" + integrity sha512-/O1B236mN7UNEU4t9X7Pj38i4VoU8CcMHyy3l2cV/kIF4U5KoHXDVqcDuOs1ltkac90IM4vZdHc52t1x8Yfs3g== + dependencies: + "@webassemblyjs/ast" "1.8.5" + mamacro "^0.0.3" + +"@webassemblyjs/helper-wasm-bytecode@1.8.5": + version "1.8.5" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.8.5.tgz#537a750eddf5c1e932f3744206551c91c1b93e61" + integrity sha512-Cu4YMYG3Ddl72CbmpjU/wbP6SACcOPVbHN1dI4VJNJVgFwaKf1ppeFJrwydOG3NDHxVGuCfPlLZNyEdIYlQ6QQ== + +"@webassemblyjs/helper-wasm-section@1.8.5": + version "1.8.5" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.8.5.tgz#74ca6a6bcbe19e50a3b6b462847e69503e6bfcbf" + integrity sha512-VV083zwR+VTrIWWtgIUpqfvVdK4ff38loRmrdDBgBT8ADXYsEZ5mPQ4Nde90N3UYatHdYoDIFb7oHzMncI02tA== + dependencies: + "@webassemblyjs/ast" "1.8.5" + "@webassemblyjs/helper-buffer" "1.8.5" + "@webassemblyjs/helper-wasm-bytecode" "1.8.5" + "@webassemblyjs/wasm-gen" "1.8.5" + +"@webassemblyjs/ieee754@1.8.5": + version "1.8.5" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.8.5.tgz#712329dbef240f36bf57bd2f7b8fb9bf4154421e" + integrity sha512-aaCvQYrvKbY/n6wKHb/ylAJr27GglahUO89CcGXMItrOBqRarUMxWLJgxm9PJNuKULwN5n1csT9bYoMeZOGF3g== + dependencies: + "@xtuc/ieee754" "^1.2.0" + +"@webassemblyjs/leb128@1.8.5": + version "1.8.5" + resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.8.5.tgz#044edeb34ea679f3e04cd4fd9824d5e35767ae10" + integrity sha512-plYUuUwleLIziknvlP8VpTgO4kqNaH57Y3JnNa6DLpu/sGcP6hbVdfdX5aHAV716pQBKrfuU26BJK29qY37J7A== + dependencies: + "@xtuc/long" "4.2.2" + +"@webassemblyjs/utf8@1.8.5": + version "1.8.5" + resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.8.5.tgz#a8bf3b5d8ffe986c7c1e373ccbdc2a0915f0cedc" + integrity sha512-U7zgftmQriw37tfD934UNInokz6yTmn29inT2cAetAsaU9YeVCveWEwhKL1Mg4yS7q//NGdzy79nlXh3bT8Kjw== + +"@webassemblyjs/wasm-edit@1.8.5": + version "1.8.5" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.8.5.tgz#962da12aa5acc1c131c81c4232991c82ce56e01a" + integrity sha512-A41EMy8MWw5yvqj7MQzkDjU29K7UJq1VrX2vWLzfpRHt3ISftOXqrtojn7nlPsZ9Ijhp5NwuODuycSvfAO/26Q== + dependencies: + "@webassemblyjs/ast" "1.8.5" + "@webassemblyjs/helper-buffer" "1.8.5" + "@webassemblyjs/helper-wasm-bytecode" "1.8.5" + "@webassemblyjs/helper-wasm-section" "1.8.5" + "@webassemblyjs/wasm-gen" "1.8.5" + "@webassemblyjs/wasm-opt" "1.8.5" + "@webassemblyjs/wasm-parser" "1.8.5" + "@webassemblyjs/wast-printer" "1.8.5" + +"@webassemblyjs/wasm-gen@1.8.5": + version "1.8.5" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.8.5.tgz#54840766c2c1002eb64ed1abe720aded714f98bc" + integrity sha512-BCZBT0LURC0CXDzj5FXSc2FPTsxwp3nWcqXQdOZE4U7h7i8FqtFK5Egia6f9raQLpEKT1VL7zr4r3+QX6zArWg== + dependencies: + "@webassemblyjs/ast" "1.8.5" + "@webassemblyjs/helper-wasm-bytecode" "1.8.5" + "@webassemblyjs/ieee754" "1.8.5" + "@webassemblyjs/leb128" "1.8.5" + "@webassemblyjs/utf8" "1.8.5" + +"@webassemblyjs/wasm-opt@1.8.5": + version "1.8.5" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.8.5.tgz#b24d9f6ba50394af1349f510afa8ffcb8a63d264" + integrity sha512-HKo2mO/Uh9A6ojzu7cjslGaHaUU14LdLbGEKqTR7PBKwT6LdPtLLh9fPY33rmr5wcOMrsWDbbdCHq4hQUdd37Q== + dependencies: + "@webassemblyjs/ast" "1.8.5" + "@webassemblyjs/helper-buffer" "1.8.5" + "@webassemblyjs/wasm-gen" "1.8.5" + "@webassemblyjs/wasm-parser" "1.8.5" + +"@webassemblyjs/wasm-parser@1.8.5": + version "1.8.5" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.8.5.tgz#21576f0ec88b91427357b8536383668ef7c66b8d" + integrity sha512-pi0SYE9T6tfcMkthwcgCpL0cM9nRYr6/6fjgDtL6q/ZqKHdMWvxitRi5JcZ7RI4SNJJYnYNaWy5UUrHQy998lw== + dependencies: + "@webassemblyjs/ast" "1.8.5" + "@webassemblyjs/helper-api-error" "1.8.5" + "@webassemblyjs/helper-wasm-bytecode" "1.8.5" + "@webassemblyjs/ieee754" "1.8.5" + "@webassemblyjs/leb128" "1.8.5" + "@webassemblyjs/utf8" "1.8.5" + +"@webassemblyjs/wast-parser@1.8.5": + version "1.8.5" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-parser/-/wast-parser-1.8.5.tgz#e10eecd542d0e7bd394f6827c49f3df6d4eefb8c" + integrity sha512-daXC1FyKWHF1i11obK086QRlsMsY4+tIOKgBqI1lxAnkp9xe9YMcgOxm9kLe+ttjs5aWV2KKE1TWJCN57/Btsg== + dependencies: + "@webassemblyjs/ast" "1.8.5" + "@webassemblyjs/floating-point-hex-parser" "1.8.5" + "@webassemblyjs/helper-api-error" "1.8.5" + "@webassemblyjs/helper-code-frame" "1.8.5" + "@webassemblyjs/helper-fsm" "1.8.5" + "@xtuc/long" "4.2.2" + +"@webassemblyjs/wast-printer@1.8.5": + version "1.8.5" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.8.5.tgz#114bbc481fd10ca0e23b3560fa812748b0bae5bc" + integrity sha512-w0U0pD4EhlnvRyeJzBqaVSJAo9w/ce7/WPogeXLzGkO6hzhr4GnQIZ4W4uUt5b9ooAaXPtnXlj0gzsXEOUNYMg== + dependencies: + "@webassemblyjs/ast" "1.8.5" + "@webassemblyjs/wast-parser" "1.8.5" + "@xtuc/long" "4.2.2" + +"@xtuc/ieee754@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" + integrity sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA== + +"@xtuc/long@4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" + integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== + +abbrev@1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" + integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== + +acorn-jsx@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.0.2.tgz#84b68ea44b373c4f8686023a551f61a21b7c4a4f" + integrity sha512-tiNTrP1MP0QrChmD2DdupCr6HWSFeKVw5d/dHTu4Y7rkAkRhU/Dt7dphAfIUyxtHpl/eBVip5uTNSpQJHylpAw== + +acorn@5.X, acorn@^5.0.3: + version "5.7.3" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.3.tgz#67aa231bf8812974b85235a96771eb6bd07ea279" + integrity sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw== + +acorn@^6.2.1: + version "6.3.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.3.0.tgz#0087509119ffa4fc0a0041d1e93a417e68cb856e" + integrity sha512-/czfa8BwS88b9gWQVhc8eknunSA2DoJpJyTQkhheIf5E48u1N0R4q/YxxsAeqRrmK9TQ/uYfgLDfZo91UlANIA== + +acorn@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.0.0.tgz#26b8d1cd9a9b700350b71c0905546f64d1284e7a" + integrity sha512-PaF/MduxijYYt7unVGRuds1vBC9bFxbNf+VWqhOClfdgy7RlVkQqt610ig1/yxTgsDIfW1cWDel5EBbOy3jdtQ== + +add-px-to-style@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/add-px-to-style/-/add-px-to-style-1.0.0.tgz#d0c135441fa8014a8137904531096f67f28f263a" + integrity sha1-0ME1RB+oAUqBN5BFMQlvZ/KPJjo= + +agent-base@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee" + integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg== + dependencies: + es6-promisify "^5.0.0" + +aggregate-error@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.0.0.tgz#5b5a3c95e9095f311c9ab16c19fb4f3527cd3f79" + integrity sha512-yKD9kEoJIR+2IFqhMwayIBgheLYbB3PS2OBhWae1L/ODTd/JF/30cW0bc9TqzRL3k4U41Dieu3BF4I29p8xesA== + dependencies: + clean-stack "^2.0.0" + indent-string "^3.2.0" + +ajv-errors@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-1.0.1.tgz#f35986aceb91afadec4102fbd85014950cefa64d" + integrity sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ== + +ajv-keywords@^3.1.0, ajv-keywords@^3.4.1: + version "3.4.1" + resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.4.1.tgz#ef916e271c64ac12171fd8384eaae6b2345854da" + integrity sha512-RO1ibKvd27e6FEShVFfPALuHI3WjSVNeK5FIsmme/LYRNxjKuNj+Dt7bucLa6NdSv3JcVTyMlm9kGR84z1XpaQ== + +ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2: + version "6.10.2" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.2.tgz#d3cea04d6b017b2894ad69040fec8b623eb4bd52" + integrity sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw== + dependencies: + fast-deep-equal "^2.0.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +alphanum-sort@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3" + integrity sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM= + +ansi-colors@1.1.0, ansi-colors@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-1.1.0.tgz#6374b4dd5d4718ff3ce27a671a3b1cad077132a9" + integrity sha512-SFKX67auSNoVR38N3L+nvsPjOE0bybKTYbkf5tRvushrAPQ9V75huw0ZxBkKVeRU9kqH3d6HA4xTckbwZ4ixmA== + dependencies: + ansi-wrap "^0.1.0" + +ansi-colors@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" + integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== + +ansi-colors@^3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-3.2.4.tgz#e3a3da4bfbae6c86a9c285625de124a234026fbf" + integrity sha512-hHUXGagefjN2iRrID63xckIvotOXOojhQKWIPUZ4mNUZ9nLZW+7FMNoE1lOkEhNWYsx/7ysGIuJYCiMAA9FnrA== + +ansi-cyan@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/ansi-cyan/-/ansi-cyan-0.1.1.tgz#538ae528af8982f28ae30d86f2f17456d2609873" + integrity sha1-U4rlKK+JgvKK4w2G8vF0VtJgmHM= + dependencies: + ansi-wrap "0.1.0" + +ansi-escapes@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b" + integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ== + +ansi-gray@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/ansi-gray/-/ansi-gray-0.1.1.tgz#2962cf54ec9792c48510a3deb524436861ef7251" + integrity sha1-KWLPVOyXksSFEKPetSRDaGHvclE= + dependencies: + ansi-wrap "0.1.0" + +ansi-red@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/ansi-red/-/ansi-red-0.1.1.tgz#8c638f9d1080800a353c9c28c8a81ca4705d946c" + integrity sha1-jGOPnRCAgAo1PJwoyKgcpHBdlGw= + dependencies: + ansi-wrap "0.1.0" + +ansi-regex@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" + integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8= + +ansi-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" + integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= + +ansi-regex@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" + integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== + +ansi-styles@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" + integrity sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4= + +ansi-styles@^3.2.0, ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +ansi-wrap@0.1.0, ansi-wrap@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/ansi-wrap/-/ansi-wrap-0.1.0.tgz#a82250ddb0015e9a27ca82e82ea603bbfa45efaf" + integrity sha1-qCJQ3bABXponyoLoLqYDu/pF768= + +anymatch@^1.3.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.2.tgz#553dcb8f91e3c889845dfdba34c77721b90b9d7a" + integrity sha512-0XNayC8lTHQ2OI8aljNCN3sSx6hsr/1+rlcDAotXJR7C1oZZHCNsfpbKwMjRA3Uqb5tF1Rae2oloTr4xpq+WjA== + dependencies: + micromatch "^2.1.5" + normalize-path "^2.0.0" + +anymatch@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" + integrity sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw== + dependencies: + micromatch "^3.1.4" + normalize-path "^2.1.1" + +append-buffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/append-buffer/-/append-buffer-1.0.2.tgz#d8220cf466081525efea50614f3de6514dfa58f1" + integrity sha1-2CIM9GYIFSXv6lBhTz3mUU36WPE= + dependencies: + buffer-equal "^1.0.0" + +aproba@^1.0.3, aproba@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" + integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== + +archy@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/archy/-/archy-1.0.0.tgz#f9c8c13757cc1dd7bc379ac77b2c62a5c2868c40" + integrity sha1-+cjBN1fMHde8N5rHeyxipcKGjEA= + +are-we-there-yet@~1.1.2: + version "1.1.5" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21" + integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w== + dependencies: + delegates "^1.0.0" + readable-stream "^2.0.6" + +argparse@^1.0.7: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + +arr-diff@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-1.1.0.tgz#687c32758163588fef7de7b36fabe495eb1a399a" + integrity sha1-aHwydYFjWI/vfeezb6vklesaOZo= + dependencies: + arr-flatten "^1.0.1" + array-slice "^0.2.3" + +arr-diff@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-2.0.0.tgz#8f3b827f955a8bd669697e4a4256ac3ceae356cf" + integrity sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8= + dependencies: + arr-flatten "^1.0.1" + +arr-diff@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" + integrity sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA= + +arr-filter@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/arr-filter/-/arr-filter-1.1.2.tgz#43fdddd091e8ef11aa4c45d9cdc18e2dff1711ee" + integrity sha1-Q/3d0JHo7xGqTEXZzcGOLf8XEe4= + dependencies: + make-iterator "^1.0.0" + +arr-flatten@^1.0.1, arr-flatten@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1" + integrity sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg== + +arr-map@^2.0.0, arr-map@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/arr-map/-/arr-map-2.0.2.tgz#3a77345ffc1cf35e2a91825601f9e58f2e24cac4" + integrity sha1-Onc0X/wc814qkYJWAfnljy4kysQ= + dependencies: + make-iterator "^1.0.0" + +arr-union@^2.0.1: + version "2.1.0" + resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-2.1.0.tgz#20f9eab5ec70f5c7d215b1077b1c39161d292c7d" + integrity sha1-IPnqtexw9cfSFbEHexw5Fh0pLH0= + +arr-union@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" + integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ= + +array-each@^1.0.0, array-each@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/array-each/-/array-each-1.0.1.tgz#a794af0c05ab1752846ee753a1f211a05ba0c44f" + integrity sha1-p5SvDAWrF1KEbudTofIRoFugxE8= + +array-find-index@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/array-find-index/-/array-find-index-1.0.2.tgz#df010aa1287e164bbda6f9723b0a96a1ec4187a1" + integrity sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E= + +array-includes@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.0.3.tgz#184b48f62d92d7452bb31b323165c7f8bd02266d" + integrity sha1-GEtI9i2S10UrsxsyMWXH+L0CJm0= + dependencies: + define-properties "^1.1.2" + es-abstract "^1.7.0" + +array-initial@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/array-initial/-/array-initial-1.1.0.tgz#2fa74b26739371c3947bd7a7adc73be334b3d795" + integrity sha1-L6dLJnOTccOUe9enrcc74zSz15U= + dependencies: + array-slice "^1.0.0" + is-number "^4.0.0" + +array-last@^1.1.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/array-last/-/array-last-1.3.0.tgz#7aa77073fec565ddab2493f5f88185f404a9d336" + integrity sha512-eOCut5rXlI6aCOS7Z7kCplKRKyiFQ6dHFBem4PwlwKeNFk2/XxTrhRh5T9PyaEWGy/NHTZWbY+nsZlNFJu9rYg== + dependencies: + is-number "^4.0.0" + +array-slice@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/array-slice/-/array-slice-0.2.3.tgz#dd3cfb80ed7973a75117cdac69b0b99ec86186f5" + integrity sha1-3Tz7gO15c6dRF82sabC5nshhhvU= + +array-slice@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/array-slice/-/array-slice-1.1.0.tgz#e368ea15f89bc7069f7ffb89aec3a6c7d4ac22d4" + integrity sha512-B1qMD3RBP7O8o0H2KbrXDyB0IccejMF15+87Lvlor12ONPRHP6gTjXMNkt/d3ZuOGbAe66hFmaCfECI24Ufp6w== + +array-sort@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/array-sort/-/array-sort-1.0.0.tgz#e4c05356453f56f53512a7d1d6123f2c54c0a88a" + integrity sha512-ihLeJkonmdiAsD7vpgN3CRcx2J2S0TiYW+IS/5zHBI7mKUq3ySvBdzzBfD236ubDBQFiiyG3SWCPc+msQ9KoYg== + dependencies: + default-compare "^1.0.0" + get-value "^2.0.6" + kind-of "^5.0.2" + +array-union@^1.0.1, array-union@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39" + integrity sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk= + dependencies: + array-uniq "^1.0.1" + +array-union@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" + integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== + +array-uniq@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6" + integrity sha1-r2rId6Jcx/dOBYiUdThY39sk/bY= + +array-unique@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.2.1.tgz#a1d97ccafcbc2625cc70fadceb36a50c58b01a53" + integrity sha1-odl8yvy8JiXMcPrc6zalDFiwGlM= + +array-unique@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" + integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg= + +arrify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" + integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0= + +asap@^2.0.6, asap@~2.0.3: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= + +asn1.js@^4.0.0: + version "4.10.1" + resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-4.10.1.tgz#b9c2bf5805f1e64aadeed6df3a2bfafb5a73f5a0" + integrity sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw== + dependencies: + bn.js "^4.0.0" + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + +assert@^1.1.1: + version "1.5.0" + resolved "https://registry.yarnpkg.com/assert/-/assert-1.5.0.tgz#55c109aaf6e0aefdb3dc4b71240c70bf574b18eb" + integrity sha512-EDsgawzwoun2CZkCgtxJbv392v4nbk9XDD06zI+kQYoBM/3RBWLlEyJARDOmhAAosBjWACEkKL6S+lIZtcAubA== + dependencies: + object-assign "^4.1.1" + util "0.10.3" + +assign-symbols@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" + integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c= + +astral-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" + integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg== + +async-done@^1.2.0, async-done@^1.2.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/async-done/-/async-done-1.3.2.tgz#5e15aa729962a4b07414f528a88cdf18e0b290a2" + integrity sha512-uYkTP8dw2og1tu1nmza1n1CMW0qb8gWWlwqMmLb7MhBVs4BXrFziT6HXUd+/RlRA/i4H9AkofYloUbs1fwMqlw== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.2" + process-nextick-args "^2.0.0" + stream-exhaust "^1.0.1" + +async-each@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf" + integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ== + +async-settle@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/async-settle/-/async-settle-1.0.0.tgz#1d0a914bb02575bec8a8f3a74e5080f72b2c0c6b" + integrity sha1-HQqRS7Aldb7IqPOnTlCA9yssDGs= + dependencies: + async-done "^1.2.2" + +atob@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" + integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== + +autoprefixer@9.6.1, autoprefixer@^9.5.1: + version "9.6.1" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.6.1.tgz#51967a02d2d2300bb01866c1611ec8348d355a47" + integrity sha512-aVo5WxR3VyvyJxcJC3h4FKfwCQvQWb1tSI5VHNibddCVWrcD1NvlxEweg3TSgiPztMnWfjpy2FURKA2kvDE+Tw== + dependencies: + browserslist "^4.6.3" + caniuse-lite "^1.0.30000980" + chalk "^2.4.2" + normalize-range "^0.1.2" + num2fraction "^1.2.2" + postcss "^7.0.17" + postcss-value-parser "^4.0.0" + +babel-eslint@10.0.3: + version "10.0.3" + resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.0.3.tgz#81a2c669be0f205e19462fed2482d33e4687a88a" + integrity sha512-z3U7eMY6r/3f3/JB9mTsLjyxrv0Yb1zb8PCWCLpguxfCzBIZUwy23R1t/XKewP+8mEN2Ck8Dtr4q20z6ce6SoA== + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/parser" "^7.0.0" + "@babel/traverse" "^7.0.0" + "@babel/types" "^7.0.0" + eslint-visitor-keys "^1.0.0" + resolve "^1.12.0" + +babel-loader@8.0.6: + version "8.0.6" + resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-8.0.6.tgz#e33bdb6f362b03f4bb141a0c21ab87c501b70dfb" + integrity sha512-4BmWKtBOBm13uoUwd08UwjZlaw3O9GWf456R9j+5YykFZ6LUIjIKLc0zEZf+hauxPOJs96C8k6FvYD09vWzhYw== + dependencies: + find-cache-dir "^2.0.0" + loader-utils "^1.0.2" + mkdirp "^0.5.1" + pify "^4.0.1" + +babel-plugin-dynamic-import-node@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.0.tgz#f00f507bdaa3c3e3ff6e7e5e98d90a7acab96f7f" + integrity sha512-o6qFkpeQEBxcqt0XYlWzAVxNCSCZdUgcR8IRlhD/8DylxjjO4foPcvTW0GGKa/cVt3rvxZ7o5ippJ+/0nvLhlQ== + dependencies: + object.assign "^4.1.0" + +babel-plugin-inline-classnames@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/babel-plugin-inline-classnames/-/babel-plugin-inline-classnames-2.0.1.tgz#d871490af06781a42f231a1e090bc4133594f168" + integrity sha512-Pq/jJ6hTiGiqcMmy2d4CyJcfBDeUHOdQl1t1MDWNaSKR2RxDmShSAx4Zqz6NDmFaiinaRqF8eQoTVgSRGU+McQ== + +babel-plugin-transform-react-remove-prop-types@0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-remove-prop-types/-/babel-plugin-transform-react-remove-prop-types-0.4.24.tgz#f2edaf9b4c6a5fbe5c1d678bfb531078c1555f3a" + integrity sha512-eqj0hVcJUR57/Ug2zE1Yswsw4LhuqqHhD+8v120T1cl3kjg76QwtyBrdIk4WVwK+lAhBJVYCd/v+4nc4y+8JsA== + +babel-runtime@^6.26.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" + integrity sha1-llxwWGaOgrVde/4E/yM3vItWR/4= + dependencies: + core-js "^2.4.0" + regenerator-runtime "^0.11.0" + +bach@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/bach/-/bach-1.2.0.tgz#4b3ce96bf27134f79a1b414a51c14e34c3bd9880" + integrity sha1-Szzpa/JxNPeaG0FKUcFONMO9mIA= + dependencies: + arr-filter "^1.1.1" + arr-flatten "^1.0.1" + arr-map "^2.0.0" + array-each "^1.0.0" + array-initial "^1.0.0" + array-last "^1.1.1" + async-done "^1.2.2" + async-settle "^1.0.0" + now-and-later "^2.0.0" + +bail@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/bail/-/bail-1.0.4.tgz#7181b66d508aa3055d3f6c13f0a0c720641dde9b" + integrity sha512-S8vuDB4w6YpRhICUDET3guPlQpaJl7od94tpZ0Fvnyp+MKW/HyDTcRDck+29C9g+d/qQHnddRH3+94kZdrW0Ww== + +balanced-match@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.1.0.tgz#b504bd05869b39259dd0c5efc35d843176dccc4a" + integrity sha1-tQS9BYabOSWd0MXvw12EMXbczEo= + +balanced-match@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" + integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= + +base64-js@^1.0.2: + version "1.3.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1" + integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g== + +base@^0.11.1: + version "0.11.2" + resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" + integrity sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg== + dependencies: + cache-base "^1.0.1" + class-utils "^0.3.5" + component-emitter "^1.2.1" + define-property "^1.0.0" + isobject "^3.0.1" + mixin-deep "^1.2.0" + pascalcase "^0.1.1" + +big.js@^5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" + integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== + +binary-extensions@^1.0.0: + version "1.13.1" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65" + integrity sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw== + +bindings@^1.2.1: + version "1.5.0" + resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" + integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== + dependencies: + file-uri-to-path "1.0.0" + +bluebird@^3.1.1, bluebird@^3.5.5: + version "3.5.5" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.5.tgz#a8d0afd73251effbbd5fe384a77d73003c17a71f" + integrity sha512-5am6HnnfN+urzt4yfg7IgTbotDjIT/u8AJpEt0sIU9FtXfVeezXAPKswrG+xKUCOYAINpSdgZVDU6QFh+cuH3w== + +bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0: + version "4.11.8" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f" + integrity sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA== + +body@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/body/-/body-5.1.0.tgz#e4ba0ce410a46936323367609ecb4e6553125069" + integrity sha1-5LoM5BCkaTYyM2dgnstOZVMSUGk= + dependencies: + continuable-cache "^0.3.1" + error "^7.0.0" + raw-body "~1.1.0" + safe-json-parse "~1.0.1" + +boolbase@^1.0.0, boolbase@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" + integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24= + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +braces@^1.8.2: + version "1.8.5" + resolved "https://registry.yarnpkg.com/braces/-/braces-1.8.5.tgz#ba77962e12dff969d6b76711e914b737857bf6a7" + integrity sha1-uneWLhLf+WnWt2cR6RS3N4V79qc= + dependencies: + expand-range "^1.8.1" + preserve "^0.2.0" + repeat-element "^1.1.2" + +braces@^2.3.1, braces@^2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729" + integrity sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w== + dependencies: + arr-flatten "^1.1.0" + array-unique "^0.3.2" + extend-shallow "^2.0.1" + fill-range "^4.0.0" + isobject "^3.0.1" + repeat-element "^1.1.2" + snapdragon "^0.8.1" + snapdragon-node "^2.0.1" + split-string "^3.0.2" + to-regex "^3.0.1" + +braces@^3.0.1: + version "3.0.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" + integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== + dependencies: + fill-range "^7.0.1" + +brorand@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" + integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8= + +browserify-aes@^1.0.0, browserify-aes@^1.0.4: + version "1.2.0" + resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.2.0.tgz#326734642f403dabc3003209853bb70ad428ef48" + integrity sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA== + dependencies: + buffer-xor "^1.0.3" + cipher-base "^1.0.0" + create-hash "^1.1.0" + evp_bytestokey "^1.0.3" + inherits "^2.0.1" + safe-buffer "^5.0.1" + +browserify-cipher@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/browserify-cipher/-/browserify-cipher-1.0.1.tgz#8d6474c1b870bfdabcd3bcfcc1934a10e94f15f0" + integrity sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w== + dependencies: + browserify-aes "^1.0.4" + browserify-des "^1.0.0" + evp_bytestokey "^1.0.0" + +browserify-des@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/browserify-des/-/browserify-des-1.0.2.tgz#3af4f1f59839403572f1c66204375f7a7f703e9c" + integrity sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A== + dependencies: + cipher-base "^1.0.1" + des.js "^1.0.0" + inherits "^2.0.1" + safe-buffer "^5.1.2" + +browserify-rsa@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.0.1.tgz#21e0abfaf6f2029cf2fafb133567a701d4135524" + integrity sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ= + dependencies: + bn.js "^4.1.0" + randombytes "^2.0.1" + +browserify-sign@^4.0.0: + version "4.0.4" + resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.0.4.tgz#aa4eb68e5d7b658baa6bf6a57e630cbd7a93d298" + integrity sha1-qk62jl17ZYuqa/alfmMMvXqT0pg= + dependencies: + bn.js "^4.1.1" + browserify-rsa "^4.0.0" + create-hash "^1.1.0" + create-hmac "^1.1.2" + elliptic "^6.0.0" + inherits "^2.0.1" + parse-asn1 "^5.0.0" + +browserify-zlib@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.2.0.tgz#2869459d9aa3be245fe8fe2ca1f46e2e7f54d73f" + integrity sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA== + dependencies: + pako "~1.0.5" + +browserslist@^4.0.0, browserslist@^4.6.0, browserslist@^4.6.3, browserslist@^4.6.6: + version "4.6.6" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.6.6.tgz#6e4bf467cde520bc9dbdf3747dafa03531cec453" + integrity sha512-D2Nk3W9JL9Fp/gIcWei8LrERCS+eXu9AM5cfXA8WEZ84lFks+ARnZ0q/R69m2SV3Wjma83QDDPxsNKXUwdIsyA== + dependencies: + caniuse-lite "^1.0.30000984" + electron-to-chromium "^1.3.191" + node-releases "^1.1.25" + +bser@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/bser/-/bser-2.1.0.tgz#65fc784bf7f87c009b973c12db6546902fa9c7b5" + integrity sha512-8zsjWrQkkBoLK6uxASk1nJ2SKv97ltiGDo6A3wA0/yRPz+CwmEyDo0hUrhIuukG2JHpAl3bvFIixw2/3Hi0DOg== + dependencies: + node-int64 "^0.4.0" + +buffer-equal@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/buffer-equal/-/buffer-equal-1.0.0.tgz#59616b498304d556abd466966b22eeda3eca5fbe" + integrity sha1-WWFrSYME1Var1GaWayLu2j7KX74= + +buffer-from@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" + integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== + +buffer-xor@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" + integrity sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk= + +buffer@^4.3.0: + version "4.9.1" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-4.9.1.tgz#6d1bb601b07a4efced97094132093027c95bc298" + integrity sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg= + dependencies: + base64-js "^1.0.2" + ieee754 "^1.1.4" + isarray "^1.0.0" + +bufferstreams@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/bufferstreams/-/bufferstreams-1.0.1.tgz#cfb1ad9568d3ba3cfe935ba9abdd952de88aab2a" + integrity sha1-z7GtlWjTujz+k1upq92VLeiKqyo= + dependencies: + readable-stream "^1.0.33" + +builtin-status-codes@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8" + integrity sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug= + +bytes@1: + version "1.0.0" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-1.0.0.tgz#3569ede8ba34315fab99c3e92cb04c7220de1fa8" + integrity sha1-NWnt6Lo0MV+rmcPpLLBMciDeH6g= + +cacache@^12.0.2: + version "12.0.3" + resolved "https://registry.yarnpkg.com/cacache/-/cacache-12.0.3.tgz#be99abba4e1bf5df461cd5a2c1071fc432573390" + integrity sha512-kqdmfXEGFepesTuROHMs3MpFLWrPkSSpRqOw80RCflZXy/khxaArvFrQ7uJxSUduzAufc6G0g1VUCOZXxWavPw== + dependencies: + bluebird "^3.5.5" + chownr "^1.1.1" + figgy-pudding "^3.5.1" + glob "^7.1.4" + graceful-fs "^4.1.15" + infer-owner "^1.0.3" + lru-cache "^5.1.1" + mississippi "^3.0.0" + mkdirp "^0.5.1" + move-concurrently "^1.0.1" + promise-inflight "^1.0.1" + rimraf "^2.6.3" + ssri "^6.0.1" + unique-filename "^1.1.1" + y18n "^4.0.0" + +cache-base@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" + integrity sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ== + dependencies: + collection-visit "^1.0.0" + component-emitter "^1.2.1" + get-value "^2.0.6" + has-value "^1.0.0" + isobject "^3.0.1" + set-value "^2.0.0" + to-object-path "^0.3.0" + union-value "^1.0.0" + unset-value "^1.0.0" + +call-me-maybe@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b" + integrity sha1-JtII6onje1y95gJQoV8DHBak1ms= + +caller-callsite@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/caller-callsite/-/caller-callsite-2.0.0.tgz#847e0fce0a223750a9a027c54b33731ad3154134" + integrity sha1-hH4PzgoiN1CpoCfFSzNzGtMVQTQ= + dependencies: + callsites "^2.0.0" + +caller-path@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-2.0.0.tgz#468f83044e369ab2010fac5f06ceee15bb2cb1f4" + integrity sha1-Ro+DBE42mrIBD6xfBs7uFbsssfQ= + dependencies: + caller-callsite "^2.0.0" + +callsites@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-2.0.0.tgz#06eb84f00eea413da86affefacbffb36093b3c50" + integrity sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA= + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +camelcase-css@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5" + integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA== + +camelcase-keys@^4.0.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-4.2.0.tgz#a2aa5fb1af688758259c32c141426d78923b9b77" + integrity sha1-oqpfsa9oh1glnDLBQUJteJI7m3c= + dependencies: + camelcase "^4.1.0" + map-obj "^2.0.0" + quick-lru "^1.0.0" + +camelcase@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-3.0.0.tgz#32fc4b9fcdaf845fcdf7e73bb97cac2261f0ab0a" + integrity sha1-MvxLn82vhF/N9+c7uXysImHwqwo= + +camelcase@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd" + integrity sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0= + +camelcase@^5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" + integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== + +caniuse-api@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0" + integrity sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw== + dependencies: + browserslist "^4.0.0" + caniuse-lite "^1.0.0" + lodash.memoize "^4.1.2" + lodash.uniq "^4.5.0" + +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000980, caniuse-lite@^1.0.30000984: + version "1.0.30000989" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000989.tgz#b9193e293ccf7e4426c5245134b8f2a56c0ac4b9" + integrity sha512-vrMcvSuMz16YY6GSVZ0dWDTJP8jqk3iFQ/Aq5iqblPwxSVVZI+zxDyTX0VPqtQsDnfdrBDcsmhgTEOh5R8Lbpw== + +ccount@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.0.4.tgz#9cf2de494ca84060a2a8d2854edd6dfb0445f386" + integrity sha512-fpZ81yYfzentuieinmGnphk0pLkOTMm6MZdVqwd77ROvhko6iujLNGrHH5E7utq3ygWklwfmwuG+A7P+NpqT6w== + +chalk@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" + integrity sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg= + dependencies: + ansi-styles "^2.2.1" + escape-string-regexp "^1.0.2" + has-ansi "^2.0.0" + strip-ansi "^3.0.0" + supports-color "^2.0.0" + +chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.4.1, chalk@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +character-entities-html4@^1.0.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-1.1.3.tgz#5ce6e01618e47048ac22f34f7f39db5c6fd679ef" + integrity sha512-SwnyZ7jQBCRHELk9zf2CN5AnGEc2nA+uKMZLHvcqhpPprjkYhiLn0DywMHgN5ttFZuITMATbh68M6VIVKwJbcg== + +character-entities-legacy@^1.0.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-1.1.3.tgz#3c729991d9293da0ede6dddcaf1f2ce1009ee8b4" + integrity sha512-YAxUpPoPwxYFsslbdKkhrGnXAtXoHNgYjlBM3WMXkWGTl5RsY3QmOyhwAgL8Nxm9l5LBThXGawxKPn68y6/fww== + +character-entities@^1.0.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-1.2.3.tgz#bbed4a52fe7ef98cc713c6d80d9faa26916d54e6" + integrity sha512-yB4oYSAa9yLcGyTbB4ItFwHw43QHdH129IJ5R+WvxOkWlyFnR5FAaBNnUq4mcxsTVZGh28bHoeTHMKXH1wZf3w== + +character-reference-invalid@^1.0.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-1.1.3.tgz#1647f4f726638d3ea4a750cf5d1975c1c7919a85" + integrity sha512-VOq6PRzQBam/8Jm6XBGk2fNEnHXAdGd6go0rtd4weAGECBamHDwwCQSOT12TACIYUZegUXnV6xBXqUssijtxIg== + +chardet@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" + integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== + +chokidar@^2.0.0, chokidar@^2.0.2: + version "2.1.8" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917" + integrity sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg== + dependencies: + anymatch "^2.0.0" + async-each "^1.0.1" + braces "^2.3.2" + glob-parent "^3.1.0" + inherits "^2.0.3" + is-binary-path "^1.0.0" + is-glob "^4.0.0" + normalize-path "^3.0.0" + path-is-absolute "^1.0.0" + readdirp "^2.2.1" + upath "^1.1.1" + optionalDependencies: + fsevents "^1.2.7" + +chownr@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.2.tgz#a18f1e0b269c8a6a5d3c86eb298beb14c3dd7bf6" + integrity sha512-GkfeAQh+QNy3wquu9oIZr6SS5x7wGdSgNQvD10X3r+AZr1Oys22HW8kAmDMvNg2+Dm0TeGaEuO8gFwdBXxwO8A== + +chrome-trace-event@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.2.tgz#234090ee97c7d4ad1a2c4beae27505deffc608a4" + integrity sha512-9e/zx1jw7B4CO+c/RXoCsfg/x1AfUBioy4owYH0bJprEYAx5hRFLRhWBqHAG57D0ZM4H7vxbP7bPe0VwhQRYDQ== + dependencies: + tslib "^1.9.0" + +cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" + integrity sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q== + dependencies: + inherits "^2.0.1" + safe-buffer "^5.0.1" + +class-utils@^0.3.5: + version "0.3.6" + resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463" + integrity sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg== + dependencies: + arr-union "^3.1.0" + define-property "^0.2.5" + isobject "^3.0.0" + static-extend "^0.1.1" + +classnames@2.2.6: + version "2.2.6" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" + integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q== + +clean-stack@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" + integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== + +cli-cursor@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5" + integrity sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU= + dependencies: + restore-cursor "^2.0.0" + +cli-width@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639" + integrity sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk= + +clipboard@2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-2.0.4.tgz#836dafd66cf0fea5d71ce5d5b0bf6e958009112d" + integrity sha512-Vw26VSLRpJfBofiVaFb/I8PVfdI1OxKcYShe6fm0sP/DtmiWQNCjhM/okTvdCo0G+lMMm1rMYbk4IK4x1X+kgQ== + dependencies: + good-listener "^1.2.2" + select "^1.1.2" + tiny-emitter "^2.0.0" + +cliui@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-3.2.0.tgz#120601537a916d29940f934da3b48d585a39213d" + integrity sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0= + dependencies: + string-width "^1.0.1" + strip-ansi "^3.0.1" + wrap-ansi "^2.0.0" + +clone-buffer@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/clone-buffer/-/clone-buffer-1.0.0.tgz#e3e25b207ac4e701af721e2cb5a16792cac3dc58" + integrity sha1-4+JbIHrE5wGvch4staFnksrD3Fg= + +clone-regexp@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/clone-regexp/-/clone-regexp-2.2.0.tgz#7d65e00885cd8796405c35a737e7a86b7429e36f" + integrity sha512-beMpP7BOtTipFuW8hrJvREQ2DrRu3BE7by0ZpibtfBA+qfHYvMGTc2Yb1JMYPKg/JUw0CHYvpg796aNTSW9z7Q== + dependencies: + is-regexp "^2.0.0" + +clone-stats@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-0.0.1.tgz#b88f94a82cf38b8791d58046ea4029ad88ca99d1" + integrity sha1-uI+UqCzzi4eR1YBG6kAprYjKmdE= + +clone-stats@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-1.0.0.tgz#b3782dff8bb5474e18b9b6bf0fdfe782f8777680" + integrity sha1-s3gt/4u1R04Yuba/D9/ngvh3doA= + +clone@^1.0.0, clone@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" + integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4= + +clone@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" + integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18= + +cloneable-readable@^1.0.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/cloneable-readable/-/cloneable-readable-1.1.3.tgz#120a00cb053bfb63a222e709f9683ea2e11d8cec" + integrity sha512-2EF8zTQOxYq70Y4XKtorQupqF0m49MBz2/yf5Bj+MHjvpG3Hy7sImifnqD6UA+TKYxeSV+u6qqQPawN5UvnpKQ== + dependencies: + inherits "^2.0.1" + process-nextick-args "^2.0.0" + readable-stream "^2.3.5" + +clsx@^1.0.1: + version "1.0.4" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.0.4.tgz#0c0171f6d5cb2fe83848463c15fcc26b4df8c2ec" + integrity sha512-1mQ557MIZTrL/140j+JVdRM6e31/OA4vTYxXgqIIZlndyfjHpyawKZia1Im05Vp9BWmImkcNrNtFYQMyFcgJDg== + +coa@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/coa/-/coa-2.0.2.tgz#43f6c21151b4ef2bf57187db0d73de229e3e7ec3" + integrity sha512-q5/jG+YQnSy4nRTV4F7lPepBJZ8qBNJJDBuJdoejDyLXgmL7IEo+Le2JDZudFTFt7mrCqIRaSjws4ygRCTCAXA== + dependencies: + "@types/q" "^1.5.1" + chalk "^2.4.1" + q "^1.1.2" + +code-point-at@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" + integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= + +collapse-white-space@^1.0.2: + version "1.0.5" + resolved "https://registry.yarnpkg.com/collapse-white-space/-/collapse-white-space-1.0.5.tgz#c2495b699ab1ed380d29a1091e01063e75dbbe3a" + integrity sha512-703bOOmytCYAX9cXYqoikYIx6twmFCXsnzRQheBcTG3nzKYBR4P/+wkYeH+Mvj7qUz8zZDtdyzbxfnEi/kYzRQ== + +collection-map@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/collection-map/-/collection-map-1.0.0.tgz#aea0f06f8d26c780c2b75494385544b2255af18c" + integrity sha1-rqDwb40mx4DCt1SUOFVEsiVa8Yw= + dependencies: + arr-map "^2.0.2" + for-own "^1.0.0" + make-iterator "^1.0.0" + +collection-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" + integrity sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA= + dependencies: + map-visit "^1.0.0" + object-visit "^1.0.0" + +color-convert@^1.3.0, color-convert@^1.9.0, color-convert@^1.9.1: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= + +color-name@^1.0.0: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +color-string@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-0.3.0.tgz#27d46fb67025c5c2fa25993bfbf579e47841b991" + integrity sha1-J9RvtnAlxcL6JZk7+/V55HhBuZE= + dependencies: + color-name "^1.0.0" + +color-string@^1.5.2: + version "1.5.3" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.3.tgz#c9bbc5f01b58b5492f3d6857459cb6590ce204cc" + integrity sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw== + dependencies: + color-name "^1.0.0" + simple-swizzle "^0.2.2" + +color-support@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" + integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== + +color@^0.11.0: + version "0.11.4" + resolved "https://registry.yarnpkg.com/color/-/color-0.11.4.tgz#6d7b5c74fb65e841cd48792ad1ed5e07b904d764" + integrity sha1-bXtcdPtl6EHNSHkq0e1eB7kE12Q= + dependencies: + clone "^1.0.2" + color-convert "^1.3.0" + color-string "^0.3.0" + +color@^3.0.0: + version "3.1.2" + resolved "https://registry.yarnpkg.com/color/-/color-3.1.2.tgz#68148e7f85d41ad7649c5fa8c8106f098d229e10" + integrity sha512-vXTJhHebByxZn3lDvDJYw4lR5+uB3vuoHsuYA5AKuxRVn5wzzIfQKGLBmgdVRHKTJYeK5rvJcHnrd0Li49CFpg== + dependencies: + color-convert "^1.9.1" + color-string "^1.5.2" + +commander@^2.20.0, commander@~2.20.0: + version "2.20.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.0.tgz#d58bb2b5c1ee8f87b0d340027e9e94e222c5a422" + integrity sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ== + +commondir@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" + integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs= + +component-emitter@^1.2.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" + integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= + +concat-stream@^1.5.0, concat-stream@^1.6.0: + version "1.6.2" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" + integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^2.2.2" + typedarray "^0.0.6" + +concat-with-sourcemaps@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/concat-with-sourcemaps/-/concat-with-sourcemaps-1.1.0.tgz#d4ea93f05ae25790951b99e7b3b09e3908a4082e" + integrity sha512-4gEjHJFT9e+2W/77h/DS5SGUgwDaOwprX8L/gl5+3ixnzkVJJsZWDSelmN3Oilw3LNDZjZV0yqH1hLG3k6nghg== + dependencies: + source-map "^0.6.1" + +connected-react-router@6.5.2: + version "6.5.2" + resolved "https://registry.yarnpkg.com/connected-react-router/-/connected-react-router-6.5.2.tgz#422af70f86cb276681e20ab4295cf27dd9b6c7e3" + integrity sha512-qzsLPZCofSI80fwy+HgxtEgSGS4ndYUUZAWaw1dqaOGPLKX/FVwIOEb7q+hjHdnZ4v5pKZcNv5GG4urjujIoyA== + dependencies: + immutable "^3.8.1" + prop-types "^15.7.2" + seamless-immutable "^7.1.3" + +console-browserify@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.1.0.tgz#f0241c45730a9fc6323b206dbf38edc741d0bb10" + integrity sha1-8CQcRXMKn8YyOyBtvzjtx0HQuxA= + dependencies: + date-now "^0.1.4" + +console-control-strings@^1.0.0, console-control-strings@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" + integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= + +consolidate@^0.15.1: + version "0.15.1" + resolved "https://registry.yarnpkg.com/consolidate/-/consolidate-0.15.1.tgz#21ab043235c71a07d45d9aad98593b0dba56bab7" + integrity sha512-DW46nrsMJgy9kqAbPt5rKaCr7uFtpo4mSUvLHIUbJEjm0vo+aY5QLwBUq3FK4tRnJr/X0Psc0C4jf/h+HtXSMw== + dependencies: + bluebird "^3.1.1" + +constants-browserify@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75" + integrity sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U= + +continuable-cache@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/continuable-cache/-/continuable-cache-0.3.1.tgz#bd727a7faed77e71ff3985ac93351a912733ad0f" + integrity sha1-vXJ6f67XfnH/OYWskzUakSczrQ8= + +convert-source-map@1.X, convert-source-map@^1.1.0, convert-source-map@^1.5.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.6.0.tgz#51b537a8c43e0f04dec1993bffcdd504e758ac20" + integrity sha512-eFu7XigvxdZ1ETfbgPBohgyQ/Z++C0eEhTor0qRwBw9unw+L0/6V8wkSuGgzdThkiS5lSpdptOQPD8Ak40a+7A== + dependencies: + safe-buffer "~5.1.1" + +copy-concurrently@^1.0.0: + version "1.0.5" + resolved "https://registry.yarnpkg.com/copy-concurrently/-/copy-concurrently-1.0.5.tgz#92297398cae34937fcafd6ec8139c18051f0b5e0" + integrity sha512-f2domd9fsVDFtaFcbaRZuYXwtdmnzqbADSwhSWYxYB/Q8zsdUUFMXVRwXGDMWmbEzAn1kdRrtI1T/KTFOL4X2A== + dependencies: + aproba "^1.1.1" + fs-write-stream-atomic "^1.0.8" + iferr "^0.1.5" + mkdirp "^0.5.1" + rimraf "^2.5.4" + run-queue "^1.0.0" + +copy-descriptor@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" + integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= + +copy-props@^2.0.1: + version "2.0.4" + resolved "https://registry.yarnpkg.com/copy-props/-/copy-props-2.0.4.tgz#93bb1cadfafd31da5bb8a9d4b41f471ec3a72dfe" + integrity sha512-7cjuUME+p+S3HZlbllgsn2CDwS+5eCCX16qBgNC4jgSTf49qR1VKy/Zhl400m0IQXl/bPGEVqncgUUMjrr4s8A== + dependencies: + each-props "^1.3.0" + is-plain-object "^2.0.1" + +core-js-compat@^3.1.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.2.1.tgz#0cbdbc2e386e8e00d3b85dc81c848effec5b8150" + integrity sha512-MwPZle5CF9dEaMYdDeWm73ao/IflDH+FjeJCWEADcEgFSE9TLimFKwJsfmkwzI8eC0Aj0mgvMDjeQjrElkz4/A== + dependencies: + browserslist "^4.6.6" + semver "^6.3.0" + +core-js@3: + version "3.2.1" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.2.1.tgz#cd41f38534da6cc59f7db050fe67307de9868b09" + integrity sha512-Qa5XSVefSVPRxy2XfUC13WbvqkxhkwB3ve+pgCQveNgYzbM/UxZeu1dcOX/xr4UmfUd+muuvsaxilQzCyUurMw== + +core-js@^1.0.0: + version "1.2.7" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" + integrity sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY= + +core-js@^2.4.0: + version "2.6.9" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.9.tgz#6b4b214620c834152e179323727fc19741b084f2" + integrity sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A== + +core-util-is@~1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" + integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= + +cosmiconfig@^5.0.0, cosmiconfig@^5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-5.2.1.tgz#040f726809c591e77a17c0a3626ca45b4f168b1a" + integrity sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA== + dependencies: + import-fresh "^2.0.0" + is-directory "^0.3.1" + js-yaml "^3.13.1" + parse-json "^4.0.0" + +create-ecdh@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.3.tgz#c9111b6f33045c4697f144787f9254cdc77c45ff" + integrity sha512-GbEHQPMOswGpKXM9kCWVrremUcBmjteUaQ01T9rkKCPDXfUHX0IoP9LpHYo2NPFampa4e+/pFDc3jQdxrxQLaw== + dependencies: + bn.js "^4.1.0" + elliptic "^6.0.0" + +create-hash@^1.1.0, create-hash@^1.1.2: + version "1.2.0" + resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" + integrity sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg== + dependencies: + cipher-base "^1.0.1" + inherits "^2.0.1" + md5.js "^1.3.4" + ripemd160 "^2.0.1" + sha.js "^2.4.0" + +create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4: + version "1.1.7" + resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.7.tgz#69170c78b3ab957147b2b8b04572e47ead2243ff" + integrity sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg== + dependencies: + cipher-base "^1.0.3" + create-hash "^1.1.0" + inherits "^2.0.1" + ripemd160 "^2.0.0" + safe-buffer "^5.0.1" + sha.js "^2.4.8" + +create-react-class@15.6.3: + version "15.6.3" + resolved "https://registry.yarnpkg.com/create-react-class/-/create-react-class-15.6.3.tgz#2d73237fb3f970ae6ebe011a9e66f46dbca80036" + integrity sha512-M+/3Q6E6DLO6Yx3OwrWjwHBnvfXXYA7W+dFjt/ZDBemHO1DDZhsalX/NUtnTYclN6GfnBDRh4qRHjcDHmlJBJg== + dependencies: + fbjs "^0.8.9" + loose-envify "^1.3.1" + object-assign "^4.1.1" + +create-react-context@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/create-react-context/-/create-react-context-0.3.0.tgz#546dede9dc422def0d3fc2fe03afe0bc0f4f7d8c" + integrity sha512-dNldIoSuNSvlTJ7slIKC/ZFGKexBMBrrcc+TTe1NdmROnaASuLPvqpwj9v4XS4uXZ8+YPu0sNmShX2rXI5LNsw== + dependencies: + gud "^1.0.0" + warning "^4.0.3" + +cross-spawn@^5.0.1: + version "5.1.0" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" + integrity sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk= + dependencies: + lru-cache "^4.0.1" + shebang-command "^1.2.0" + which "^1.2.9" + +cross-spawn@^6.0.5: + version "6.0.5" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" + integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== + dependencies: + nice-try "^1.0.4" + path-key "^2.0.1" + semver "^5.5.0" + shebang-command "^1.2.0" + which "^1.2.9" + +crypto-browserify@^3.11.0: + version "3.12.0" + resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.12.0.tgz#396cf9f3137f03e4b8e532c58f698254e00f80ec" + integrity sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg== + dependencies: + browserify-cipher "^1.0.0" + browserify-sign "^4.0.0" + create-ecdh "^4.0.0" + create-hash "^1.1.0" + create-hmac "^1.1.0" + diffie-hellman "^5.0.0" + inherits "^2.0.1" + pbkdf2 "^3.0.3" + public-encrypt "^4.0.0" + randombytes "^2.0.0" + randomfill "^1.0.3" + +css-color-function@~1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/css-color-function/-/css-color-function-1.3.3.tgz#8ed24c2c0205073339fafa004bc8c141fccb282e" + integrity sha1-jtJMLAIFBzM5+voAS8jBQfzLKC4= + dependencies: + balanced-match "0.1.0" + color "^0.11.0" + debug "^3.1.0" + rgb "~0.1.0" + +css-color-names@0.0.4, css-color-names@^0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0" + integrity sha1-gIrcLnnPhHOAabZGyyDsJ762KeA= + +css-declaration-sorter@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/css-declaration-sorter/-/css-declaration-sorter-4.0.1.tgz#c198940f63a76d7e36c1e71018b001721054cb22" + integrity sha512-BcxQSKTSEEQUftYpBVnsH4SF05NTuBokb19/sBt6asXGKZ/6VP7PLG1CBCkFDYOnhXhPh0jMhO6xZ71oYHXHBA== + dependencies: + postcss "^7.0.1" + timsort "^0.3.0" + +css-loader@3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-3.2.0.tgz#bb570d89c194f763627fcf1f80059c6832d009b2" + integrity sha512-QTF3Ud5H7DaZotgdcJjGMvyDj5F3Pn1j/sC6VBEOVp94cbwqyIBdcs/quzj4MC1BKQSrTpQznegH/5giYbhnCQ== + dependencies: + camelcase "^5.3.1" + cssesc "^3.0.0" + icss-utils "^4.1.1" + loader-utils "^1.2.3" + normalize-path "^3.0.0" + postcss "^7.0.17" + postcss-modules-extract-imports "^2.0.0" + postcss-modules-local-by-default "^3.0.2" + postcss-modules-scope "^2.1.0" + postcss-modules-values "^3.0.0" + postcss-value-parser "^4.0.0" + schema-utils "^2.0.0" + +css-select-base-adapter@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz#3b2ff4972cc362ab88561507a95408a1432135d7" + integrity sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w== + +css-select@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-2.0.2.tgz#ab4386cec9e1f668855564b17c3733b43b2a5ede" + integrity sha512-dSpYaDVoWaELjvZ3mS6IKZM/y2PMPa/XYoEfYNZePL4U/XgyxZNroHEHReDx/d+VgXh9VbCTtFqLkFbmeqeaRQ== + dependencies: + boolbase "^1.0.0" + css-what "^2.1.2" + domutils "^1.7.0" + nth-check "^1.0.2" + +css-tree@1.0.0-alpha.29: + version "1.0.0-alpha.29" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.0.0-alpha.29.tgz#3fa9d4ef3142cbd1c301e7664c1f352bd82f5a39" + integrity sha512-sRNb1XydwkW9IOci6iB2xmy8IGCj6r/fr+JWitvJ2JxQRPzN3T4AGGVWCMlVmVwM1gtgALJRmGIlWv5ppnGGkg== + dependencies: + mdn-data "~1.1.0" + source-map "^0.5.3" + +css-tree@1.0.0-alpha.33: + version "1.0.0-alpha.33" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.0.0-alpha.33.tgz#970e20e5a91f7a378ddd0fc58d0b6c8d4f3be93e" + integrity sha512-SPt57bh5nQnpsTBsx/IXbO14sRc9xXu5MtMAVuo0BaQQmyf0NupNPPSoMaqiAF5tDFafYsTkfeH4Q/HCKXkg4w== + dependencies: + mdn-data "2.0.4" + source-map "^0.5.3" + +css-unit-converter@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/css-unit-converter/-/css-unit-converter-1.1.1.tgz#d9b9281adcfd8ced935bdbaba83786897f64e996" + integrity sha1-2bkoGtz9jO2TW9urqDeGiX9k6ZY= + +css-what@^2.1.2: + version "2.1.3" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2" + integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg== + +css@2.X, css@^2.2.1: + version "2.2.4" + resolved "https://registry.yarnpkg.com/css/-/css-2.2.4.tgz#c646755c73971f2bba6a601e2cf2fd71b1298929" + integrity sha512-oUnjmWpy0niI3x/mPL8dVEI1l7MnG3+HHyRPHf+YFSbK+svOhXpmSOcDURUh2aOCgl2grzrOPt1nHLuCVFULLw== + dependencies: + inherits "^2.0.3" + source-map "^0.6.1" + source-map-resolve "^0.5.2" + urix "^0.1.0" + +cssesc@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-2.0.0.tgz#3b13bd1bb1cb36e1bcb5a4dcd27f54c5dcb35703" + integrity sha512-MsCAG1z9lPdoO/IUMLSBWBSVxVtJ1395VGIQ+Fc2gNdkQ1hNDnQdw3YhA71WJCBW1vdwA0cAnk/DnW6bqoEUYg== + +cssesc@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" + integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== + +cssnano-preset-default@^4.0.7: + version "4.0.7" + resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-4.0.7.tgz#51ec662ccfca0f88b396dcd9679cdb931be17f76" + integrity sha512-x0YHHx2h6p0fCl1zY9L9roD7rnlltugGu7zXSKQx6k2rYw0Hi3IqxcoAGF7u9Q5w1nt7vK0ulxV8Lo+EvllGsA== + dependencies: + css-declaration-sorter "^4.0.1" + cssnano-util-raw-cache "^4.0.1" + postcss "^7.0.0" + postcss-calc "^7.0.1" + postcss-colormin "^4.0.3" + postcss-convert-values "^4.0.1" + postcss-discard-comments "^4.0.2" + postcss-discard-duplicates "^4.0.2" + postcss-discard-empty "^4.0.1" + postcss-discard-overridden "^4.0.1" + postcss-merge-longhand "^4.0.11" + postcss-merge-rules "^4.0.3" + postcss-minify-font-values "^4.0.2" + postcss-minify-gradients "^4.0.2" + postcss-minify-params "^4.0.2" + postcss-minify-selectors "^4.0.2" + postcss-normalize-charset "^4.0.1" + postcss-normalize-display-values "^4.0.2" + postcss-normalize-positions "^4.0.2" + postcss-normalize-repeat-style "^4.0.2" + postcss-normalize-string "^4.0.2" + postcss-normalize-timing-functions "^4.0.2" + postcss-normalize-unicode "^4.0.1" + postcss-normalize-url "^4.0.1" + postcss-normalize-whitespace "^4.0.2" + postcss-ordered-values "^4.1.2" + postcss-reduce-initial "^4.0.3" + postcss-reduce-transforms "^4.0.2" + postcss-svgo "^4.0.2" + postcss-unique-selectors "^4.0.1" + +cssnano-util-get-arguments@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cssnano-util-get-arguments/-/cssnano-util-get-arguments-4.0.0.tgz#ed3a08299f21d75741b20f3b81f194ed49cc150f" + integrity sha1-7ToIKZ8h11dBsg87gfGU7UnMFQ8= + +cssnano-util-get-match@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cssnano-util-get-match/-/cssnano-util-get-match-4.0.0.tgz#c0e4ca07f5386bb17ec5e52250b4f5961365156d" + integrity sha1-wOTKB/U4a7F+xeUiULT1lhNlFW0= + +cssnano-util-raw-cache@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/cssnano-util-raw-cache/-/cssnano-util-raw-cache-4.0.1.tgz#b26d5fd5f72a11dfe7a7846fb4c67260f96bf282" + integrity sha512-qLuYtWK2b2Dy55I8ZX3ky1Z16WYsx544Q0UWViebptpwn/xDBmog2TLg4f+DBMg1rJ6JDWtn96WHbOKDWt1WQA== + dependencies: + postcss "^7.0.0" + +cssnano-util-same-parent@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/cssnano-util-same-parent/-/cssnano-util-same-parent-4.0.1.tgz#574082fb2859d2db433855835d9a8456ea18bbf3" + integrity sha512-WcKx5OY+KoSIAxBW6UBBRay1U6vkYheCdjyVNDm85zt5K9mHoGOfsOsqIszfAqrQQFIIKgjh2+FDgIj/zsl21Q== + +cssnano@^4.1.10: + version "4.1.10" + resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-4.1.10.tgz#0ac41f0b13d13d465487e111b778d42da631b8b2" + integrity sha512-5wny+F6H4/8RgNlaqab4ktc3e0/blKutmq8yNlBFXA//nSFFAqAngjNVRzUvCgYROULmZZUoosL/KSoZo5aUaQ== + dependencies: + cosmiconfig "^5.0.0" + cssnano-preset-default "^4.0.7" + is-resolvable "^1.0.0" + postcss "^7.0.0" + +csso@^3.5.1: + version "3.5.1" + resolved "https://registry.yarnpkg.com/csso/-/csso-3.5.1.tgz#7b9eb8be61628973c1b261e169d2f024008e758b" + integrity sha512-vrqULLffYU1Q2tLdJvaCYbONStnfkfimRxXNaGjxMldI0C7JPBC4rB1RyjhfdZ4m1frm8pM9uRPKH3d2knZ8gg== + dependencies: + css-tree "1.0.0-alpha.29" + +csstype@^2.2.0: + version "2.6.6" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.6.tgz#c34f8226a94bbb10c32cc0d714afdf942291fc41" + integrity sha512-RpFbQGUE74iyPgvr46U9t1xoQBM8T4BL8SxrN66Le2xYAPSaDJJKeztV3awugusb3g3G9iL8StmkBBXhcbbXhg== + +cuint@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/cuint/-/cuint-0.2.2.tgz#408086d409550c2631155619e9fa7bcadc3b991b" + integrity sha1-QICG1AlVDCYxFVYZ6fp7ytw7mRs= + +currently-unhandled@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea" + integrity sha1-mI3zP+qxke95mmE2nddsF635V+o= + dependencies: + array-find-index "^1.0.1" + +cyclist@~0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-0.2.2.tgz#1b33792e11e914a2fd6d6ed6447464444e5fa640" + integrity sha1-GzN5LhHpFKL9bW7WRHRkRE5fpkA= + +d@1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/d/-/d-1.0.1.tgz#8698095372d58dbee346ffd0c7093f99f8f9eb5a" + integrity sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA== + dependencies: + es5-ext "^0.10.50" + type "^1.0.1" + +date-now@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b" + integrity sha1-6vQ5/U1ISK105cx9vvIAZyueNFs= + +debug-fabulous@1.X: + version "1.1.0" + resolved "https://registry.yarnpkg.com/debug-fabulous/-/debug-fabulous-1.1.0.tgz#af8a08632465224ef4174a9f06308c3c2a1ebc8e" + integrity sha512-GZqvGIgKNlUnHUPQhepnUZFIMoi3dgZKQBzKDeL2g7oJF9SNAji/AAu36dusFUas0O+pae74lNeoIPHqXWDkLg== + dependencies: + debug "3.X" + memoizee "0.4.X" + object-assign "4.X" + +debug@3.X, debug@^3.1.0, debug@^3.2.6: + version "3.2.6" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" + integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== + dependencies: + ms "^2.1.1" + +debug@^2.2.0, debug@^2.3.3: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +debug@^4.0.1, debug@^4.1.0, debug@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" + integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== + dependencies: + ms "^2.1.1" + +decamelize-keys@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/decamelize-keys/-/decamelize-keys-1.1.0.tgz#d171a87933252807eb3cb61dc1c1445d078df2d9" + integrity sha1-0XGoeTMlKAfrPLYdwcFEXQeN8tk= + dependencies: + decamelize "^1.1.0" + map-obj "^1.0.0" + +decamelize@^1.1.0, decamelize@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= + +decode-uri-component@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" + integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= + +deep-extend@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" + integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== + +deep-is@~0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" + integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ= + +default-compare@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/default-compare/-/default-compare-1.0.0.tgz#cb61131844ad84d84788fb68fd01681ca7781a2f" + integrity sha512-QWfXlM0EkAbqOCbD/6HjdwT19j7WCkMyiRhWilc4H9/5h/RzTF9gv5LYh1+CmDV5d1rki6KAWLtQale0xt20eQ== + dependencies: + kind-of "^5.0.2" + +default-resolution@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/default-resolution/-/default-resolution-2.0.0.tgz#bcb82baa72ad79b426a76732f1a81ad6df26d684" + integrity sha1-vLgrqnKtebQmp2cy8aga1t8m1oQ= + +define-properties@^1.1.2, define-properties@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1" + integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ== + dependencies: + object-keys "^1.0.12" + +define-property@^0.2.5: + version "0.2.5" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116" + integrity sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY= + dependencies: + is-descriptor "^0.1.0" + +define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6" + integrity sha1-dp66rz9KY6rTr56NMEybvnm/sOY= + dependencies: + is-descriptor "^1.0.0" + +define-property@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d" + integrity sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ== + dependencies: + is-descriptor "^1.0.2" + isobject "^3.0.1" + +del@5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/del/-/del-5.1.0.tgz#d9487c94e367410e6eff2925ee58c0c84a75b3a7" + integrity sha512-wH9xOVHnczo9jN2IW68BabcecVPxacIA3g/7z6vhSU/4stOKQzeCRK0yD0A24WiAAUJmmVpWqrERcTxnLo3AnA== + dependencies: + globby "^10.0.1" + graceful-fs "^4.2.2" + is-glob "^4.0.1" + is-path-cwd "^2.2.0" + is-path-inside "^3.0.1" + p-map "^3.0.0" + rimraf "^3.0.0" + slash "^3.0.0" + +delegate@^3.1.2: + version "3.2.0" + resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166" + integrity sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw== + +delegates@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" + integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= + +des.js@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.0.tgz#c074d2e2aa6a8a9a07dbd61f9a15c2cd83ec8ecc" + integrity sha1-wHTS4qpqipoH29YfmhXCzYPsjsw= + dependencies: + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + +detect-file@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7" + integrity sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc= + +detect-libc@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" + integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= + +detect-newline@2.X: + version "2.1.0" + resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2" + integrity sha1-9B8cEL5LAOh7XxPaaAdZ8sW/0+I= + +diffie-hellman@^5.0.0: + version "5.0.3" + resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875" + integrity sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg== + dependencies: + bn.js "^4.1.0" + miller-rabin "^4.0.0" + randombytes "^2.0.0" + +dir-glob@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-2.0.0.tgz#0b205d2b6aef98238ca286598a8204d29d0a0034" + integrity sha512-37qirFDz8cA5fimp9feo43fSuRo2gHwaIn6dXL8Ber1dGwUosDrGZeCCXq57WnIqE4aQ+u3eQZzsk1yOzhdwag== + dependencies: + arrify "^1.0.1" + path-type "^3.0.0" + +dir-glob@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-2.2.2.tgz#fa09f0694153c8918b18ba0deafae94769fc50c4" + integrity sha512-f9LBi5QWzIW3I6e//uxZoLBlUt9kcp66qo0sSCxL6YZKc75R1c4MFCoe/LaZiBGmgujvQdxc5Bn3QhfyvK5Hsw== + dependencies: + path-type "^3.0.0" + +dir-glob@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" + integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== + dependencies: + path-type "^4.0.0" + +dnd-core@^9.3.4: + version "9.3.4" + resolved "https://registry.yarnpkg.com/dnd-core/-/dnd-core-9.3.4.tgz#56b5fdc165aa7d102506d3d5a08ec1fa789e0775" + integrity sha512-sDzBiGXgpj9bQhs8gtPWFIKMg4WY8ywI9RI81rRAUWI4oNj/Sm/ztjS67UjCvMa+fWoQ2WNIV3U9oDqeBN0+2g== + dependencies: + "@types/asap" "^2.0.0" + "@types/invariant" "^2.2.30" + asap "^2.0.6" + invariant "^2.2.4" + redux "^4.0.1" + +dnode-protocol@~0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/dnode-protocol/-/dnode-protocol-0.2.2.tgz#51151d16fc3b5f84815ee0b9497a1061d0d1949d" + integrity sha1-URUdFvw7X4SBXuC5SXoQYdDRlJ0= + dependencies: + jsonify "~0.0.0" + traverse "~0.6.3" + +dnode@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/dnode/-/dnode-1.2.2.tgz#4ac3cfe26e292b3b39b8258ae7d94edc58132efa" + integrity sha1-SsPP4m4pKzs5uCWK59lO3FgTLvo= + dependencies: + dnode-protocol "~0.2.2" + jsonify "~0.0.0" + optionalDependencies: + weak "^1.0.0" + +doctrine@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" + integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw== + dependencies: + esutils "^2.0.2" + +doctrine@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" + integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== + dependencies: + esutils "^2.0.2" + +dom-css@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/dom-css/-/dom-css-2.1.0.tgz#fdbc2d5a015d0a3e1872e11472bbd0e7b9e6a202" + integrity sha1-/bwtWgFdCj4YcuEUcrvQ57nmogI= + dependencies: + add-px-to-style "1.0.0" + prefix-style "2.0.1" + to-camel-case "1.0.0" + +"dom-helpers@^2.4.0 || ^3.0.0": + version "3.4.0" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8" + integrity sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA== + dependencies: + "@babel/runtime" "^7.1.2" + +dom-serializer@0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.1.tgz#13650c850daffea35d8b626a4cfc4d3a17643fdb" + integrity sha512-sK3ujri04WyjwQXVoK4PU3y8ula1stq10GJZpqHIUgoGZdsGzAGu65BnU3d08aTVSvO7mGPZUc0wTEDL+qGE0Q== + dependencies: + domelementtype "^2.0.1" + entities "^2.0.0" + +domain-browser@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda" + integrity sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA== + +domelementtype@1, domelementtype@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f" + integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w== + +domelementtype@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.0.1.tgz#1f8bdfe91f5a78063274e803b4bdcedf6e94f94d" + integrity sha512-5HOHUDsYZWV8FGWN0Njbr/Rn7f/eWSQi1v7+HsUVwXgn8nWWlL64zKDkS0n8ZmQ3mlWOMuXOnR+7Nx/5tMO5AQ== + +domhandler@^2.3.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.4.2.tgz#8805097e933d65e85546f726d60f5eb88b44f803" + integrity sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA== + dependencies: + domelementtype "1" + +domutils@^1.5.1, domutils@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a" + integrity sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg== + dependencies: + dom-serializer "0" + domelementtype "1" + +dot-prop@^4.1.1: + version "4.2.0" + resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.0.tgz#1f19e0c2e1aa0e32797c49799f2837ac6af69c57" + integrity sha512-tUMXrxlExSW6U2EXiiKGSBVdYgtV8qlHL+C10TsW4PURY/ic+eaysnSkwB4kA/mBlCyy/IKDJ+Lc3wbWeaXtuQ== + dependencies: + is-obj "^1.0.0" + +duplexer@~0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1" + integrity sha1-rOb/gIwc5mtX0ev5eXessCM0z8E= + +duplexify@^3.4.2, duplexify@^3.6.0: + version "3.7.1" + resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.7.1.tgz#2a4df5317f6ccfd91f86d6fd25d8d8a103b88309" + integrity sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g== + dependencies: + end-of-stream "^1.0.0" + inherits "^2.0.1" + readable-stream "^2.0.0" + stream-shift "^1.0.0" + +each-props@^1.3.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/each-props/-/each-props-1.3.2.tgz#ea45a414d16dd5cfa419b1a81720d5ca06892333" + integrity sha512-vV0Hem3zAGkJAyU7JSjixeU66rwdynTAa1vofCrSA5fEln+m67Az9CcnkVD776/fsN/UjIWmBDoNRS6t6G9RfA== + dependencies: + is-plain-object "^2.0.1" + object.defaults "^1.1.0" + +electron-to-chromium@^1.3.191: + version "1.3.244" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.244.tgz#7ba5461fa320ab16540a31b1d0defb7ec29b16e4" + integrity sha512-nEfPd2EKnFeLuZ/+JsRG3KixRQwWf2SPpp09ftNt5ouGhg408N759+oXvdXy57+TcM34ykfJYj2JMkc1O3R0lQ== + +element-class@0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/element-class/-/element-class-0.2.2.tgz#9d3bbd0767f9013ef8e1c8ebe722c1402a60050e" + integrity sha1-nTu9B2f5AT744cjr5yLBQCpgBQ4= + +elliptic@^6.0.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.0.tgz#2b8ed4c891b7de3200e14412a5b8248c7af505ca" + integrity sha512-eFOJTMyCYb7xtE/caJ6JJu+bhi67WCYNbkGSknu20pmM8Ke/bqOfdnZWxyoGN26JgfxTbXrsCkEw4KheCT/KGg== + dependencies: + bn.js "^4.4.0" + brorand "^1.0.1" + hash.js "^1.0.0" + hmac-drbg "^1.0.0" + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + minimalistic-crypto-utils "^1.0.0" + +emoji-regex@^7.0.1: + version "7.0.3" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" + integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emojis-list@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" + integrity sha1-TapNnbAPmBmIDHn6RXrlsJof04k= + +encoding@^0.1.11: + version "0.1.12" + resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb" + integrity sha1-U4tm8+5izRq1HsMjgp0flIDHS+s= + dependencies: + iconv-lite "~0.4.13" + +end-of-stream@^1.0.0, end-of-stream@^1.1.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.1.tgz#ed29634d19baba463b6ce6b80a37213eab71ec43" + integrity sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q== + dependencies: + once "^1.4.0" + +enhanced-resolve@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.1.0.tgz#41c7e0bfdfe74ac1ffe1e57ad6a5c6c9f3742a7f" + integrity sha512-F/7vkyTtyc/llOIn8oWclcB25KdRaiPBpZYDgJHgh/UHtpgT2p2eldQgtQnLtUvfMKPKxbRaQM/hHkvLHt1Vng== + dependencies: + graceful-fs "^4.1.2" + memory-fs "^0.4.0" + tapable "^1.0.0" + +entities@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" + integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w== + +entities@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.0.tgz#68d6084cab1b079767540d80e56a39b423e4abf4" + integrity sha512-D9f7V0JSRwIxlRI2mjMqufDrRDnx8p+eEOz7aUM9SuvF8gsBzra0/6tbjl1m8eQHrZlYj6PxqE00hZ1SAIKPLw== + +errno@^0.1.3, errno@~0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.7.tgz#4684d71779ad39af177e3f007996f7c67c852618" + integrity sha512-MfrRBDWzIWifgq6tJj60gkAwtLNb6sQPlcFrSOflcP1aFmmruKQ2wRnze/8V6kgyz7H3FF8Npzv78mZ7XLLflg== + dependencies: + prr "~1.0.1" + +error-ex@^1.2.0, error-ex@^1.3.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" + integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== + dependencies: + is-arrayish "^0.2.1" + +error@^7.0.0: + version "7.0.2" + resolved "https://registry.yarnpkg.com/error/-/error-7.0.2.tgz#a5f75fff4d9926126ddac0ea5dc38e689153cb02" + integrity sha1-pfdf/02ZJhJt2sDqXcOOaJFTywI= + dependencies: + string-template "~0.2.1" + xtend "~4.0.0" + +es-abstract@^1.11.0, es-abstract@^1.12.0, es-abstract@^1.5.1, es-abstract@^1.7.0: + version "1.13.0" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.13.0.tgz#ac86145fdd5099d8dd49558ccba2eaf9b88e24e9" + integrity sha512-vDZfg/ykNxQVwup/8E1BZhVzFfBxs9NqMzGcvIJrqg5k2/5Za2bWo40dK2J1pgLngZ7c+Shh8lwYtLGyrwPutg== + dependencies: + es-to-primitive "^1.2.0" + function-bind "^1.1.1" + has "^1.0.3" + is-callable "^1.1.4" + is-regex "^1.0.4" + object-keys "^1.0.12" + +es-to-primitive@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.0.tgz#edf72478033456e8dda8ef09e00ad9650707f377" + integrity sha512-qZryBOJjV//LaxLTV6UC//WewneB3LcXOL9NP++ozKVXsIIIpm/2c13UDiD9Jp2eThsecw9m3jPqDwTyobcdbg== + dependencies: + is-callable "^1.1.4" + is-date-object "^1.0.1" + is-symbol "^1.0.2" + +es5-ext@^0.10.35, es5-ext@^0.10.45, es5-ext@^0.10.46, es5-ext@^0.10.50, es5-ext@~0.10.14, es5-ext@~0.10.2, es5-ext@~0.10.46: + version "0.10.50" + resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.50.tgz#6d0e23a0abdb27018e5ac4fd09b412bc5517a778" + integrity sha512-KMzZTPBkeQV/JcSQhI5/z6d9VWJ3EnQ194USTUwIYZ2ZbpN8+SGXQKt1h68EX44+qt+Fzr8DO17vnxrw7c3agw== + dependencies: + es6-iterator "~2.0.3" + es6-symbol "~3.1.1" + next-tick "^1.0.0" + +es6-iterator@^2.0.1, es6-iterator@^2.0.3, es6-iterator@~2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7" + integrity sha1-p96IkUGgWpSwhUQDstCg+/qY87c= + dependencies: + d "1" + es5-ext "^0.10.35" + es6-symbol "^3.1.1" + +es6-promise@^4.0.3, es6-promise@^4.2.6: + version "4.2.8" + resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a" + integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w== + +es6-promisify@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203" + integrity sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM= + dependencies: + es6-promise "^4.0.3" + +es6-symbol@^3.1.1, es6-symbol@~3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.1.tgz#bf00ef4fdab6ba1b46ecb7b629b4c7ed5715cc77" + integrity sha1-vwDvT9q2uhtG7Le2KbTH7VcVzHc= + dependencies: + d "1" + es5-ext "~0.10.14" + +es6-weak-map@^2.0.1, es6-weak-map@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/es6-weak-map/-/es6-weak-map-2.0.3.tgz#b6da1f16cc2cc0d9be43e6bdbfc5e7dfcdf31d53" + integrity sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA== + dependencies: + d "1" + es5-ext "^0.10.46" + es6-iterator "^2.0.3" + es6-symbol "^3.1.1" + +escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= + +eslint-plugin-filenames@1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/eslint-plugin-filenames/-/eslint-plugin-filenames-1.3.2.tgz#7094f00d7aefdd6999e3ac19f72cea058e590cf7" + integrity sha512-tqxJTiEM5a0JmRCUYQmxw23vtTxrb2+a3Q2mMOPhFxvt7ZQQJmdiuMby9B/vUAuVMghyP7oET+nIf6EO6CBd/w== + dependencies: + lodash.camelcase "4.3.0" + lodash.kebabcase "4.1.1" + lodash.snakecase "4.1.1" + lodash.upperfirst "4.3.1" + +eslint-plugin-react@7.14.3: + version "7.14.3" + resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.14.3.tgz#911030dd7e98ba49e1b2208599571846a66bdf13" + integrity sha512-EzdyyBWC4Uz2hPYBiEJrKCUi2Fn+BJ9B/pJQcjw5X+x/H2Nm59S4MJIvL4O5NEE0+WbnQwEBxWY03oUk+Bc3FA== + dependencies: + array-includes "^3.0.3" + doctrine "^2.1.0" + has "^1.0.3" + jsx-ast-utils "^2.1.0" + object.entries "^1.1.0" + object.fromentries "^2.0.0" + object.values "^1.1.0" + prop-types "^15.7.2" + resolve "^1.10.1" + +eslint-scope@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.3.tgz#ca03833310f6889a3264781aa82e63eb9cfe7848" + integrity sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg== + dependencies: + esrecurse "^4.1.0" + estraverse "^4.1.1" + +eslint-scope@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.0.0.tgz#e87c8887c73e8d1ec84f1ca591645c358bfc8fb9" + integrity sha512-oYrhJW7S0bxAFDvWqzvMPRm6pcgcnWc4QnofCAqRTRfQC0JcwenzGglTtsLyIuuWFfkqDG9vz67cnttSd53djw== + dependencies: + esrecurse "^4.1.0" + estraverse "^4.1.1" + +eslint-utils@^1.3.1: + version "1.4.2" + resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.4.2.tgz#166a5180ef6ab7eb462f162fd0e6f2463d7309ab" + integrity sha512-eAZS2sEUMlIeCjBeubdj45dmBHQwPHWyBcT1VSYB7o9x9WRRqKxyUoiXlRjyAwzN7YEzHJlYg0NmzDRWx6GP4Q== + dependencies: + eslint-visitor-keys "^1.0.0" + +eslint-visitor-keys@^1.0.0, eslint-visitor-keys@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz#e2a82cea84ff246ad6fb57f9bde5b46621459ec2" + integrity sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A== + +eslint@6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-6.1.0.tgz#06438a4a278b1d84fb107d24eaaa35471986e646" + integrity sha512-QhrbdRD7ofuV09IuE2ySWBz0FyXCq0rriLTZXZqaWSI79CVtHVRdkFuFTViiqzZhkCgfOh9USpriuGN2gIpZDQ== + dependencies: + "@babel/code-frame" "^7.0.0" + ajv "^6.10.0" + chalk "^2.1.0" + cross-spawn "^6.0.5" + debug "^4.0.1" + doctrine "^3.0.0" + eslint-scope "^5.0.0" + eslint-utils "^1.3.1" + eslint-visitor-keys "^1.0.0" + espree "^6.0.0" + esquery "^1.0.1" + esutils "^2.0.2" + file-entry-cache "^5.0.1" + functional-red-black-tree "^1.0.1" + glob-parent "^5.0.0" + globals "^11.7.0" + ignore "^4.0.6" + import-fresh "^3.0.0" + imurmurhash "^0.1.4" + inquirer "^6.4.1" + is-glob "^4.0.0" + js-yaml "^3.13.1" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.3.0" + lodash "^4.17.14" + minimatch "^3.0.4" + mkdirp "^0.5.1" + natural-compare "^1.4.0" + optionator "^0.8.2" + progress "^2.0.0" + regexpp "^2.0.1" + semver "^6.1.2" + strip-ansi "^5.2.0" + strip-json-comments "^3.0.1" + table "^5.2.3" + text-table "^0.2.0" + v8-compile-cache "^2.0.3" + +espree@^6.0.0: + version "6.1.1" + resolved "https://registry.yarnpkg.com/espree/-/espree-6.1.1.tgz#7f80e5f7257fc47db450022d723e356daeb1e5de" + integrity sha512-EYbr8XZUhWbYCqQRW0duU5LxzL5bETN6AjKBGy1302qqzPaCH10QbRg3Wvco79Z8x9WbiE8HYB4e75xl6qUYvQ== + dependencies: + acorn "^7.0.0" + acorn-jsx "^5.0.2" + eslint-visitor-keys "^1.1.0" + +esprima@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +esprint@0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/esprint/-/esprint-0.5.0.tgz#25975b855b9df625ce2e32655db6dff1a84bbe36" + integrity sha512-TpaXKPy6g1saDqMYwqppZC6C0wQpYQAnhms6829oVvP6XieUbGjQdcNgatGQMihin2bMgE90tmX+1OOPc5tuiw== + dependencies: + dnode "^1.2.2" + fb-watchman "^2.0.0" + glob "^7.1.1" + sane "^1.6.0" + worker-farm "^1.3.1" + yargs "^8.0.1" + +esquery@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.0.1.tgz#406c51658b1f5991a5f9b62b1dc25b00e3e5c708" + integrity sha512-SmiyZ5zIWH9VM+SRUReLS5Q8a7GxtRdxEBVZpm98rJM7Sb+A9DVCndXfkeFUd3byderg+EbDkfnevfCwynWaNA== + dependencies: + estraverse "^4.0.0" + +esrecurse@^4.1.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.2.1.tgz#007a3b9fdbc2b3bb87e4879ea19c92fdbd3942cf" + integrity sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ== + dependencies: + estraverse "^4.1.0" + +estraverse@^4.0.0, estraverse@^4.1.0, estraverse@^4.1.1: + version "4.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" + integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== + +esutils@^2.0.0, esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +event-emitter@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39" + integrity sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk= + dependencies: + d "1" + es5-ext "~0.10.14" + +event-stream@3.3.4: + version "3.3.4" + resolved "https://registry.yarnpkg.com/event-stream/-/event-stream-3.3.4.tgz#4ab4c9a0f5a54db9338b4c34d86bfce8f4b35571" + integrity sha1-SrTJoPWlTbkzi0w02Gv86PSzVXE= + dependencies: + duplexer "~0.1.1" + from "~0" + map-stream "~0.1.0" + pause-stream "0.0.11" + split "0.3" + stream-combiner "~0.0.4" + through "~2.3.1" + +events@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.0.0.tgz#9a0a0dfaf62893d92b875b8f2698ca4114973e88" + integrity sha512-Dc381HFWJzEOhQ+d8pkNon++bk9h6cdAoAj4iE6Q4y6xgTzySWXlKn05/TVNpjnfRqi/X0EpJEJohPjNI3zpVA== + +evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz#7fcbdb198dc71959432efe13842684e0525acb02" + integrity sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA== + dependencies: + md5.js "^1.3.4" + safe-buffer "^5.1.1" + +exec-sh@^0.2.0: + version "0.2.2" + resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.2.2.tgz#2a5e7ffcbd7d0ba2755bdecb16e5a427dfbdec36" + integrity sha512-FIUCJz1RbuS0FKTdaAafAByGS0CPvU3R0MeHxgtl+djzCc//F8HakL8GzmVNZanasTbTAY/3DRFA0KpVqj/eAw== + dependencies: + merge "^1.2.0" + +execa@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777" + integrity sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c= + dependencies: + cross-spawn "^5.0.1" + get-stream "^3.0.0" + is-stream "^1.1.0" + npm-run-path "^2.0.0" + p-finally "^1.0.0" + signal-exit "^3.0.0" + strip-eof "^1.0.0" + +execall@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/execall/-/execall-2.0.0.tgz#16a06b5fe5099df7d00be5d9c06eecded1663b45" + integrity sha512-0FU2hZ5Hh6iQnarpRtQurM/aAvp3RIbfvgLHrcqJYzhXyV2KFruhuChf9NC6waAhiUR7FFtlugkI4p7f2Fqlow== + dependencies: + clone-regexp "^2.1.0" + +expand-brackets@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b" + integrity sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s= + dependencies: + is-posix-bracket "^0.1.0" + +expand-brackets@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622" + integrity sha1-t3c14xXOMPa27/D4OwQVGiJEliI= + dependencies: + debug "^2.3.3" + define-property "^0.2.5" + extend-shallow "^2.0.1" + posix-character-classes "^0.1.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +expand-range@^1.8.1: + version "1.8.2" + resolved "https://registry.yarnpkg.com/expand-range/-/expand-range-1.8.2.tgz#a299effd335fe2721ebae8e257ec79644fc85337" + integrity sha1-opnv/TNf4nIeuujiV+x5ZE/IUzc= + dependencies: + fill-range "^2.1.0" + +expand-tilde@^2.0.0, expand-tilde@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-2.0.2.tgz#97e801aa052df02454de46b02bf621642cdc8502" + integrity sha1-l+gBqgUt8CRU3kawK/YhZCzchQI= + dependencies: + homedir-polyfill "^1.0.1" + +extend-shallow@^1.1.2: + version "1.1.4" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-1.1.4.tgz#19d6bf94dfc09d76ba711f39b872d21ff4dd9071" + integrity sha1-Gda/lN/AnXa6cR85uHLSH/TdkHE= + dependencies: + kind-of "^1.1.0" + +extend-shallow@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" + integrity sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8= + dependencies: + is-extendable "^0.1.0" + +extend-shallow@^3.0.0, extend-shallow@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8" + integrity sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg= + dependencies: + assign-symbols "^1.0.0" + is-extendable "^1.0.1" + +extend@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + +external-editor@^3.0.3: + version "3.1.0" + resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495" + integrity sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew== + dependencies: + chardet "^0.7.0" + iconv-lite "^0.4.24" + tmp "^0.0.33" + +extglob@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1" + integrity sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE= + dependencies: + is-extglob "^1.0.0" + +extglob@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543" + integrity sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw== + dependencies: + array-unique "^0.3.2" + define-property "^1.0.0" + expand-brackets "^2.1.4" + extend-shallow "^2.0.1" + fragment-cache "^0.2.1" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +fancy-log@1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/fancy-log/-/fancy-log-1.3.2.tgz#f41125e3d84f2e7d89a43d06d958c8f78be16be1" + integrity sha1-9BEl49hPLn2JpD0G2VjI94vha+E= + dependencies: + ansi-gray "^0.1.1" + color-support "^1.1.3" + time-stamp "^1.0.0" + +fancy-log@^1.3.2, fancy-log@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/fancy-log/-/fancy-log-1.3.3.tgz#dbc19154f558690150a23953a0adbd035be45fc7" + integrity sha512-k9oEhlyc0FrVh25qYuSELjr8oxsCoc4/LEZfg2iJJrfEk/tZL9bCoJE47gqAvI2m/AUjluCS4+3I0eTx8n3AEw== + dependencies: + ansi-gray "^0.1.1" + color-support "^1.1.3" + parse-node-version "^1.0.0" + time-stamp "^1.0.0" + +fast-deep-equal@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" + integrity sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk= + +fast-glob@^2.0.2, fast-glob@^2.2.6: + version "2.2.7" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-2.2.7.tgz#6953857c3afa475fff92ee6015d52da70a4cd39d" + integrity sha512-g1KuQwHOZAmOZMuBtHdxDtju+T2RT8jgCC9aANsbpdiDDTSnjgfuVsIBNKbUeJI3oKMRExcfNDtJl4OhbffMsw== + dependencies: + "@mrmlnc/readdir-enhanced" "^2.2.1" + "@nodelib/fs.stat" "^1.1.2" + glob-parent "^3.1.0" + is-glob "^4.0.0" + merge2 "^1.2.3" + micromatch "^3.1.10" + +fast-glob@^3.0.3: + version "3.0.4" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.0.4.tgz#d484a41005cb6faeb399b951fd1bd70ddaebb602" + integrity sha512-wkIbV6qg37xTJwqSsdnIphL1e+LaGz4AIQqr00mIubMaEhv1/HEmJ0uuCGZRNRUkZZmOB5mJKO0ZUTVq+SxMQg== + dependencies: + "@nodelib/fs.stat" "^2.0.1" + "@nodelib/fs.walk" "^1.2.1" + glob-parent "^5.0.0" + is-glob "^4.0.1" + merge2 "^1.2.3" + micromatch "^4.0.2" + +fast-json-stable-stringify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2" + integrity sha1-1RQsDK7msRifh9OnYREGT4bIu/I= + +fast-levenshtein@~2.0.4: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= + +fastq@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.6.0.tgz#4ec8a38f4ac25f21492673adb7eae9cfef47d1c2" + integrity sha512-jmxqQ3Z/nXoeyDmWAzF9kH1aGZSis6e/SbfPmJpUnyZ0ogr6iscHQaml4wsEepEWSdtmpy+eVXmCRIMpxaXqOA== + dependencies: + reusify "^1.0.0" + +faye-websocket@~0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.10.0.tgz#4e492f8d04dfb6f89003507f6edbf2d501e7c6f4" + integrity sha1-TkkvjQTftviQA1B/btvy1QHnxvQ= + dependencies: + websocket-driver ">=0.5.1" + +fb-watchman@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.0.tgz#54e9abf7dfa2f26cd9b1636c588c1afc05de5d58" + integrity sha1-VOmr99+i8mzZsWNsWIwa/AXeXVg= + dependencies: + bser "^2.0.0" + +fbjs@^0.8.4, fbjs@^0.8.9: + version "0.8.17" + resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.17.tgz#c4d598ead6949112653d6588b01a5cdcd9f90fdd" + integrity sha1-xNWY6taUkRJlPWWIsBpc3Nn5D90= + dependencies: + core-js "^1.0.0" + isomorphic-fetch "^2.1.1" + loose-envify "^1.0.0" + object-assign "^4.1.0" + promise "^7.1.1" + setimmediate "^1.0.5" + ua-parser-js "^0.7.18" + +figgy-pudding@^3.5.1: + version "3.5.1" + resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.1.tgz#862470112901c727a0e495a80744bd5baa1d6790" + integrity sha512-vNKxJHTEKNThjfrdJwHc7brvM6eVevuO5nTj6ez8ZQ1qbXTvGthucRF7S4vf2cr71QVnT70V34v0S1DyQsti0w== + +figures@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962" + integrity sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI= + dependencies: + escape-string-regexp "^1.0.5" + +file-entry-cache@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-5.0.1.tgz#ca0f6efa6dd3d561333fb14515065c2fafdf439c" + integrity sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g== + dependencies: + flat-cache "^2.0.1" + +file-loader@4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-4.2.0.tgz#5fb124d2369d7075d70a9a5abecd12e60a95215e" + integrity sha512-+xZnaK5R8kBJrHK0/6HRlrKNamvVS5rjyuju+rnyxRGuwUJwpAMsVzUl5dz6rK8brkzjV6JpcFNjp6NqV0g1OQ== + dependencies: + loader-utils "^1.2.3" + schema-utils "^2.0.0" + +file-uri-to-path@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" + integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== + +filename-regex@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26" + integrity sha1-wcS5vuPglyXdsQa3XB4wH+LxiyY= + +filesize@4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/filesize/-/filesize-4.1.2.tgz#fcd570af1353cea97897be64f56183adb995994b" + integrity sha512-iSWteWtfNcrWQTkQw8ble2bnonSl7YJImsn9OZKpE2E4IHhXI78eASpDYUljXZZdYj36QsEKjOs/CsiDqmKMJw== + +fill-range@^2.1.0: + version "2.2.4" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-2.2.4.tgz#eb1e773abb056dcd8df2bfdf6af59b8b3a936565" + integrity sha512-cnrcCbj01+j2gTG921VZPnHbjmdAf8oQV/iGeV2kZxGSyfYjjTyY79ErsK1WJWMpw6DaApEX72binqJE+/d+5Q== + dependencies: + is-number "^2.1.0" + isobject "^2.0.0" + randomatic "^3.0.0" + repeat-element "^1.1.2" + repeat-string "^1.5.2" + +fill-range@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" + integrity sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc= + dependencies: + extend-shallow "^2.0.1" + is-number "^3.0.0" + repeat-string "^1.6.1" + to-regex-range "^2.1.0" + +fill-range@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" + integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== + dependencies: + to-regex-range "^5.0.1" + +find-cache-dir@^2.0.0, find-cache-dir@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-2.1.0.tgz#8d0f94cd13fe43c6c7c261a0d86115ca918c05f7" + integrity sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ== + dependencies: + commondir "^1.0.1" + make-dir "^2.0.0" + pkg-dir "^3.0.0" + +find-up@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f" + integrity sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8= + dependencies: + path-exists "^2.0.0" + pinkie-promise "^2.0.0" + +find-up@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" + integrity sha1-RdG35QbHF93UgndaK3eSCjwMV6c= + dependencies: + locate-path "^2.0.0" + +find-up@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" + integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== + dependencies: + locate-path "^3.0.0" + +findup-sync@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-2.0.0.tgz#9326b1488c22d1a6088650a86901b2d9a90a2cbc" + integrity sha1-kyaxSIwi0aYIhlCoaQGy2akKLLw= + dependencies: + detect-file "^1.0.0" + is-glob "^3.1.0" + micromatch "^3.0.4" + resolve-dir "^1.0.1" + +findup-sync@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-3.0.0.tgz#17b108f9ee512dfb7a5c7f3c8b27ea9e1a9c08d1" + integrity sha512-YbffarhcicEhOrm4CtrwdKBdCuz576RLdhJDsIfvNtxUuhdRet1qZcsMjqbePtAseKdAnDyM/IyXbu7PRPRLYg== + dependencies: + detect-file "^1.0.0" + is-glob "^4.0.0" + micromatch "^3.0.4" + resolve-dir "^1.0.1" + +fined@^1.0.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/fined/-/fined-1.2.0.tgz#d00beccf1aa2b475d16d423b0238b713a2c4a37b" + integrity sha512-ZYDqPLGxDkDhDZBjZBb+oD1+j0rA4E0pXY50eplAAOPg2N/gUBSSk5IM1/QhPfyVo19lJ+CvXpqfvk+b2p/8Ng== + dependencies: + expand-tilde "^2.0.2" + is-plain-object "^2.0.3" + object.defaults "^1.1.0" + object.pick "^1.2.0" + parse-filepath "^1.0.1" + +first-chunk-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/first-chunk-stream/-/first-chunk-stream-2.0.0.tgz#1bdecdb8e083c0664b91945581577a43a9f31d70" + integrity sha1-G97NuOCDwGZLkZRVgVd6Q6nzHXA= + dependencies: + readable-stream "^2.0.2" + +flagged-respawn@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/flagged-respawn/-/flagged-respawn-1.0.1.tgz#e7de6f1279ddd9ca9aac8a5971d618606b3aab41" + integrity sha512-lNaHNVymajmk0OJMBn8fVUAU1BtDeKIqKoVhk4xAALB57aALg6b4W0MfJ/cUE0g9YBXy5XhSlPIpYIJ7HaY/3Q== + +flat-cache@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-2.0.1.tgz#5d296d6f04bda44a4630a301413bdbc2ec085ec0" + integrity sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA== + dependencies: + flatted "^2.0.0" + rimraf "2.6.3" + write "1.0.3" + +flatted@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.1.tgz#69e57caa8f0eacbc281d2e2cb458d46fdb449e08" + integrity sha512-a1hQMktqW9Nmqr5aktAux3JMNqaucxGcjtjWnZLHX7yyPCmlSV3M54nGYbqT8K+0GhF3NBgmJCc3ma+WOgX8Jg== + +flush-write-stream@^1.0.0, flush-write-stream@^1.0.2: + version "1.1.1" + resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.1.1.tgz#8dd7d873a1babc207d94ead0c2e0e44276ebf2e8" + integrity sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w== + dependencies: + inherits "^2.0.3" + readable-stream "^2.3.6" + +for-in@^1.0.1, for-in@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" + integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA= + +for-own@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.5.tgz#5265c681a4f294dabbf17c9509b6763aa84510ce" + integrity sha1-UmXGgaTylNq78XyVCbZ2OqhFEM4= + dependencies: + for-in "^1.0.1" + +for-own@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/for-own/-/for-own-1.0.0.tgz#c63332f415cedc4b04dbfe70cf836494c53cb44b" + integrity sha1-xjMy9BXO3EsE2/5wz4NklMU8tEs= + dependencies: + for-in "^1.0.1" + +fragment-cache@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" + integrity sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk= + dependencies: + map-cache "^0.2.2" + +from2@^2.1.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/from2/-/from2-2.3.0.tgz#8bfb5502bde4a4d36cfdeea007fcca21d7e382af" + integrity sha1-i/tVAr3kpNNs/e6gB/zKIdfjgq8= + dependencies: + inherits "^2.0.1" + readable-stream "^2.0.0" + +from@~0: + version "0.1.7" + resolved "https://registry.yarnpkg.com/from/-/from-0.1.7.tgz#83c60afc58b9c56997007ed1a768b3ab303a44fe" + integrity sha1-g8YK/Fi5xWmXAH7Rp2izqzA6RP4= + +fs-copy-file-sync@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/fs-copy-file-sync/-/fs-copy-file-sync-1.1.1.tgz#11bf32c096c10d126e5f6b36d06eece776062918" + integrity sha512-2QY5eeqVv4m2PfyMiEuy9adxNP+ajf+8AR05cEi+OAzPcOj90hvFImeZhTmKLBgSd9EvG33jsD7ZRxsx9dThkQ== + +fs-minipass@^1.2.5: + version "1.2.6" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.6.tgz#2c5cc30ded81282bfe8a0d7c7c1853ddeb102c07" + integrity sha512-crhvyXcMejjv3Z5d2Fa9sf5xLYVCF5O1c71QxbVnbLsmYMBEvDAftewesN/HhY03YRoA7zOMxjNGrF5svGaaeQ== + dependencies: + minipass "^2.2.1" + +fs-mkdirp-stream@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs-mkdirp-stream/-/fs-mkdirp-stream-1.0.0.tgz#0b7815fc3201c6a69e14db98ce098c16935259eb" + integrity sha1-C3gV/DIBxqaeFNuYzgmMFpNSWes= + dependencies: + graceful-fs "^4.1.11" + through2 "^2.0.3" + +fs-readfile-promise@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/fs-readfile-promise/-/fs-readfile-promise-3.0.1.tgz#d0d307b7f6aedfc920c31fa6e5712efaa297c958" + integrity sha512-LsSxMeaJdYH27XrW7Dmq0Gx63mioULCRel63B5VeELYLavi1wF5s0XfsIdKDFdCL9hsfQ2qBvXJszQtQJ9h17A== + dependencies: + graceful-fs "^4.1.11" + +fs-write-stream-atomic@^1.0.8: + version "1.0.10" + resolved "https://registry.yarnpkg.com/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz#b47df53493ef911df75731e70a9ded0189db40c9" + integrity sha1-tH31NJPvkR33VzHnCp3tAYnbQMk= + dependencies: + graceful-fs "^4.1.2" + iferr "^0.1.5" + imurmurhash "^0.1.4" + readable-stream "1 || 2" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= + +fsevents@^1.2.7: + version "1.2.9" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.9.tgz#3f5ed66583ccd6f400b5a00db6f7e861363e388f" + integrity sha512-oeyj2H3EjjonWcFjD5NvZNE9Rqe4UW+nQBU2HNeKw0koVLEFIhtyETyAakeAM3de7Z/SW5kcA+fZUait9EApnw== + dependencies: + nan "^2.12.1" + node-pre-gyp "^0.12.0" + +function-bind@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" + integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== + +functional-red-black-tree@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" + integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= + +fuse.js@3.4.5: + version "3.4.5" + resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-3.4.5.tgz#8954fb43f9729bd5dbcb8c08f251db552595a7a6" + integrity sha512-s9PGTaQIkT69HaeoTVjwGsLfb8V8ScJLx5XGFcKHg0MqLUH/UZ4EKOtqtXX9k7AFqCGxD1aJmYb8Q5VYDibVRQ== + +gauge@~2.7.3: + version "2.7.4" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" + integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c= + dependencies: + aproba "^1.0.3" + console-control-strings "^1.0.0" + has-unicode "^2.0.0" + object-assign "^4.1.0" + signal-exit "^3.0.0" + string-width "^1.0.1" + strip-ansi "^3.0.1" + wide-align "^1.1.0" + +get-caller-file@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.3.tgz#f978fa4c90d1dfe7ff2d6beda2a515e713bdcf4a" + integrity sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w== + +get-node-dimensions@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/get-node-dimensions/-/get-node-dimensions-1.2.1.tgz#fb7b4bb57060fb4247dd51c9d690dfbec56b0823" + integrity sha512-2MSPMu7S1iOTL+BOa6K1S62hB2zUAYNF/lV0gSVlOaacd087lc6nR1H1r0e3B1CerTo+RceOmi1iJW+vp21xcQ== + +get-stdin@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-7.0.0.tgz#8d5de98f15171a125c5e516643c7a6d0ea8a96f6" + integrity sha512-zRKcywvrXlXsA0v0i9Io4KDRaAw7+a1ZpjRwl9Wox8PFlVCCHra7E9c4kqXCoCM9nR5tBkaTTZRBoCm60bFqTQ== + +get-stream@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" + integrity sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ= + +get-value@^2.0.3, get-value@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" + integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg= + +glob-base@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4" + integrity sha1-27Fk9iIbHAscz4Kuoyi0l98Oo8Q= + dependencies: + glob-parent "^2.0.0" + is-glob "^2.0.0" + +glob-parent@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-2.0.0.tgz#81383d72db054fcccf5336daa902f182f6edbb28" + integrity sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg= + dependencies: + is-glob "^2.0.0" + +glob-parent@^3.0.1, glob-parent@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" + integrity sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4= + dependencies: + is-glob "^3.1.0" + path-dirname "^1.0.0" + +glob-parent@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.0.0.tgz#1dc99f0f39b006d3e92c2c284068382f0c20e954" + integrity sha512-Z2RwiujPRGluePM6j699ktJYxmPpJKCfpGA13jz2hmFZC7gKetzrWvg5KN3+OsIFmydGyZ1AVwERCq1w/ZZwRg== + dependencies: + is-glob "^4.0.1" + +glob-stream@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/glob-stream/-/glob-stream-6.1.0.tgz#7045c99413b3eb94888d83ab46d0b404cc7bdde4" + integrity sha1-cEXJlBOz65SIjYOrRtC0BMx73eQ= + dependencies: + extend "^3.0.0" + glob "^7.1.1" + glob-parent "^3.1.0" + is-negated-glob "^1.0.0" + ordered-read-streams "^1.0.0" + pumpify "^1.3.5" + readable-stream "^2.1.5" + remove-trailing-separator "^1.0.1" + to-absolute-glob "^2.0.0" + unique-stream "^2.0.2" + +glob-to-regexp@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab" + integrity sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs= + +glob-watcher@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/glob-watcher/-/glob-watcher-5.0.3.tgz#88a8abf1c4d131eb93928994bc4a593c2e5dd626" + integrity sha512-8tWsULNEPHKQ2MR4zXuzSmqbdyV5PtwwCaWSGQ1WwHsJ07ilNeN1JB8ntxhckbnpSHaf9dXFUHzIWvm1I13dsg== + dependencies: + anymatch "^2.0.0" + async-done "^1.2.0" + chokidar "^2.0.0" + is-negated-glob "^1.0.0" + just-debounce "^1.0.0" + object.defaults "^1.1.0" + +glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4: + version "7.1.4" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.4.tgz#aa608a2f6c577ad357e1ae5a5c26d9a8d1969255" + integrity sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +global-modules@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea" + integrity sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg== + dependencies: + global-prefix "^1.0.1" + is-windows "^1.0.1" + resolve-dir "^1.0.0" + +global-modules@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780" + integrity sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A== + dependencies: + global-prefix "^3.0.0" + +global-prefix@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-1.0.2.tgz#dbf743c6c14992593c655568cb66ed32c0122ebe" + integrity sha1-2/dDxsFJklk8ZVVoy2btMsASLr4= + dependencies: + expand-tilde "^2.0.2" + homedir-polyfill "^1.0.1" + ini "^1.3.4" + is-windows "^1.0.1" + which "^1.2.14" + +global-prefix@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-3.0.0.tgz#fc85f73064df69f50421f47f883fe5b913ba9b97" + integrity sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg== + dependencies: + ini "^1.3.5" + kind-of "^6.0.2" + which "^1.3.1" + +globals@^11.1.0, globals@^11.7.0: + version "11.12.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" + integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== + +globby@^10.0.1: + version "10.0.1" + resolved "https://registry.yarnpkg.com/globby/-/globby-10.0.1.tgz#4782c34cb75dd683351335c5829cc3420e606b22" + integrity sha512-sSs4inE1FB2YQiymcmTv6NWENryABjUNPeWhOvmn4SjtKybglsyPZxFB3U1/+L1bYi0rNZDqCLlHyLYDl1Pq5A== + dependencies: + "@types/glob" "^7.1.1" + array-union "^2.1.0" + dir-glob "^3.0.1" + fast-glob "^3.0.3" + glob "^7.1.3" + ignore "^5.1.1" + merge2 "^1.2.3" + slash "^3.0.0" + +globby@^8.0.1: + version "8.0.2" + resolved "https://registry.yarnpkg.com/globby/-/globby-8.0.2.tgz#5697619ccd95c5275dbb2d6faa42087c1a941d8d" + integrity sha512-yTzMmKygLp8RUpG1Ymu2VXPSJQZjNAZPD4ywgYEaG7e4tBJeUQBO8OpXrf1RCNcEs5alsoJYPAMiIHP0cmeC7w== + dependencies: + array-union "^1.0.1" + dir-glob "2.0.0" + fast-glob "^2.0.2" + glob "^7.1.2" + ignore "^3.3.5" + pify "^3.0.0" + slash "^1.0.0" + +globby@^9.2.0: + version "9.2.0" + resolved "https://registry.yarnpkg.com/globby/-/globby-9.2.0.tgz#fd029a706c703d29bdd170f4b6db3a3f7a7cb63d" + integrity sha512-ollPHROa5mcxDEkwg6bPt3QbEf4pDQSNtd6JPL1YvOvAo/7/0VAm9TccUeoTmarjPw4pfUthSCqcyfNB1I3ZSg== + dependencies: + "@types/glob" "^7.1.1" + array-union "^1.0.2" + dir-glob "^2.2.2" + fast-glob "^2.2.6" + glob "^7.1.3" + ignore "^4.0.3" + pify "^4.0.1" + slash "^2.0.0" + +globjoin@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/globjoin/-/globjoin-0.1.4.tgz#2f4494ac8919e3767c5cbb691e9f463324285d43" + integrity sha1-L0SUrIkZ43Z8XLtpHp9GMyQoXUM= + +glogg@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/glogg/-/glogg-1.0.2.tgz#2d7dd702beda22eb3bffadf880696da6d846313f" + integrity sha512-5mwUoSuBk44Y4EshyiqcH95ZntbDdTQqA3QYSrxmzj28Ai0vXBGMH1ApSANH14j2sIRtqCEyg6PfsuP7ElOEDA== + dependencies: + sparkles "^1.0.0" + +gonzales-pe@^4.2.3: + version "4.2.4" + resolved "https://registry.yarnpkg.com/gonzales-pe/-/gonzales-pe-4.2.4.tgz#356ae36a312c46fe0f1026dd6cb539039f8500d2" + integrity sha512-v0Ts/8IsSbh9n1OJRnSfa7Nlxi4AkXIsWB6vPept8FDbL4bXn3FNuxjYtO/nmBGu7GDkL9MFeGebeSu6l55EPQ== + dependencies: + minimist "1.1.x" + +good-listener@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50" + integrity sha1-1TswzfkxPf+33JoNR3CWqm0UXFA= + dependencies: + delegate "^3.1.2" + +graceful-fs@4.X, graceful-fs@^4.0.0, graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.2.tgz#6f0952605d0140c1cfdb138ed005775b92d67b02" + integrity sha512-IItsdsea19BoLC7ELy13q1iJFNmd7ofZH5+X/pJr90/nRoPEX0DJo1dHDbgtYWOhJhcCgMDTOw84RZ72q6lB+Q== + +gud@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/gud/-/gud-1.0.0.tgz#a489581b17e6a70beca9abe3ae57de7a499852c0" + integrity sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw== + +gulp-cached@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/gulp-cached/-/gulp-cached-1.1.1.tgz#fe7cd4f87f37601e6073cfedee5c2bdaf8b6acce" + integrity sha1-/nzU+H83YB5gc8/t7lwr2vi2rM4= + dependencies: + lodash.defaults "^4.2.0" + through2 "^2.0.1" + +gulp-cli@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/gulp-cli/-/gulp-cli-2.2.0.tgz#5533126eeb7fe415a7e3e84a297d334d5cf70ebc" + integrity sha512-rGs3bVYHdyJpLqR0TUBnlcZ1O5O++Zs4bA0ajm+zr3WFCfiSLjGwoCBqFs18wzN+ZxahT9DkOK5nDf26iDsWjA== + dependencies: + ansi-colors "^1.0.1" + archy "^1.0.0" + array-sort "^1.0.0" + color-support "^1.1.3" + concat-stream "^1.6.0" + copy-props "^2.0.1" + fancy-log "^1.3.2" + gulplog "^1.0.0" + interpret "^1.1.0" + isobject "^3.0.1" + liftoff "^3.1.0" + matchdep "^2.0.0" + mute-stdout "^1.0.0" + pretty-hrtime "^1.0.0" + replace-homedir "^1.0.0" + semver-greatest-satisfied-range "^1.1.0" + v8flags "^3.0.1" + yargs "^7.1.0" + +gulp-concat@2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/gulp-concat/-/gulp-concat-2.6.1.tgz#633d16c95d88504628ad02665663cee5a4793353" + integrity sha1-Yz0WyV2IUEYorQJmVmPO5aR5M1M= + dependencies: + concat-with-sourcemaps "^1.0.0" + through2 "^2.0.0" + vinyl "^2.0.0" + +gulp-livereload@4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/gulp-livereload/-/gulp-livereload-4.0.1.tgz#cb438e62f24363e26b44ddf36fd37c274b8b15ee" + integrity sha512-BfjRd3gyJ9VuFqIOM6C3041P0FUc0T5MXjABWWHp4iDLmdnJ1fDZAQz514OID+ICXbgIW7942r9luommHBtrfQ== + dependencies: + chalk "^2.4.1" + debug "^3.1.0" + event-stream "3.3.4" + fancy-log "^1.3.2" + lodash.assign "^4.2.0" + tiny-lr "^1.1.1" + vinyl "^2.2.0" + +gulp-postcss@8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/gulp-postcss/-/gulp-postcss-8.0.0.tgz#8d3772cd4d27bca55ec8cb4c8e576e3bde4dc550" + integrity sha512-Wtl6vH7a+8IS/fU5W9IbOpcaLqKxd5L1DUOzaPmlnCbX1CrG0aWdwVnC3Spn8th0m8D59YbysV5zPUe1n/GJYg== + dependencies: + fancy-log "^1.3.2" + plugin-error "^1.0.1" + postcss "^7.0.2" + postcss-load-config "^2.0.0" + vinyl-sourcemaps-apply "^0.2.1" + +gulp-print@5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/gulp-print/-/gulp-print-5.0.2.tgz#8f379148218d2e168461baa74352e11d1bf7aa75" + integrity sha512-iIpHMzC/b3gFvVXOfP9Jk94SWGIsDLVNUrxULRleQev+08ug07mh84b1AOlW6QDQdmInQiqDFqJN1UvhU2nXdg== + dependencies: + ansi-colors "^3.2.4" + fancy-log "^1.3.3" + map-stream "0.0.7" + vinyl "^2.2.0" + +gulp-sourcemaps@2.6.5: + version "2.6.5" + resolved "https://registry.yarnpkg.com/gulp-sourcemaps/-/gulp-sourcemaps-2.6.5.tgz#a3f002d87346d2c0f3aec36af7eb873f23de8ae6" + integrity sha512-SYLBRzPTew8T5Suh2U8jCSDKY+4NARua4aqjj8HOysBh2tSgT9u4jc1FYirAdPx1akUxxDeK++fqw6Jg0LkQRg== + dependencies: + "@gulp-sourcemaps/identity-map" "1.X" + "@gulp-sourcemaps/map-sources" "1.X" + acorn "5.X" + convert-source-map "1.X" + css "2.X" + debug-fabulous "1.X" + detect-newline "2.X" + graceful-fs "4.X" + source-map "~0.6.0" + strip-bom-string "1.X" + through2 "2.X" + +gulp-watch@5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/gulp-watch/-/gulp-watch-5.0.1.tgz#83d378752f5bfb46da023e73c17ed1da7066215d" + integrity sha512-HnTSBdzAOFIT4wmXYPDUn783TaYAq9bpaN05vuZNP5eni3z3aRx0NAKbjhhMYtcq76x4R1wf4oORDGdlrEjuog== + dependencies: + ansi-colors "1.1.0" + anymatch "^1.3.0" + chokidar "^2.0.0" + fancy-log "1.3.2" + glob-parent "^3.0.1" + object-assign "^4.1.0" + path-is-absolute "^1.0.1" + plugin-error "1.0.1" + readable-stream "^2.2.2" + slash "^1.0.0" + vinyl "^2.1.0" + vinyl-file "^2.0.0" + +gulp-wrap@0.15.0: + version "0.15.0" + resolved "https://registry.yarnpkg.com/gulp-wrap/-/gulp-wrap-0.15.0.tgz#e9014c9bb8643ab310e938d4469b8568551a552f" + integrity sha512-f17zkGObA+hE/FThlg55gfA0nsXbdmHK1WqzjjB2Ytq1TuhLR7JiCBJ3K4AlMzCyoFaCjfowos+VkToUNE0WTQ== + dependencies: + consolidate "^0.15.1" + es6-promise "^4.2.6" + fs-readfile-promise "^3.0.1" + js-yaml "^3.13.0" + lodash "^4.17.11" + node.extend "2.0.2" + plugin-error "^1.0.1" + through2 "^3.0.1" + tryit "^1.0.1" + vinyl-bufferstream "^1.0.1" + +gulp@4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/gulp/-/gulp-4.0.2.tgz#543651070fd0f6ab0a0650c6a3e6ff5a7cb09caa" + integrity sha512-dvEs27SCZt2ibF29xYgmnwwCYZxdxhQ/+LFWlbAW8y7jt68L/65402Lz3+CKy0Ov4rOs+NERmDq7YlZaDqUIfA== + dependencies: + glob-watcher "^5.0.3" + gulp-cli "^2.2.0" + undertaker "^1.2.1" + vinyl-fs "^3.0.0" + +gulplog@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/gulplog/-/gulplog-1.0.0.tgz#e28c4d45d05ecbbed818363ce8f9c5926229ffe5" + integrity sha1-4oxNRdBey77YGDY86PnFkmIp/+U= + dependencies: + glogg "^1.0.0" + +has-ansi@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" + integrity sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE= + dependencies: + ansi-regex "^2.0.0" + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= + +has-symbols@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.0.tgz#ba1a8f1af2a0fc39650f5c850367704122063b44" + integrity sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q= + +has-unicode@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" + integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk= + +has-value@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" + integrity sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8= + dependencies: + get-value "^2.0.3" + has-values "^0.1.4" + isobject "^2.0.0" + +has-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177" + integrity sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc= + dependencies: + get-value "^2.0.6" + has-values "^1.0.0" + isobject "^3.0.0" + +has-values@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771" + integrity sha1-bWHeldkd/Km5oCCJrThL/49it3E= + +has-values@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f" + integrity sha1-lbC2P+whRmGab+V/51Yo1aOe/k8= + dependencies: + is-number "^3.0.0" + kind-of "^4.0.0" + +has@^1.0.0, has@^1.0.1, has@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" + integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== + dependencies: + function-bind "^1.1.1" + +hash-base@^3.0.0: + version "3.0.4" + resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.0.4.tgz#5fc8686847ecd73499403319a6b0a3f3f6ae4918" + integrity sha1-X8hoaEfs1zSZQDMZprCj8/auSRg= + dependencies: + inherits "^2.0.1" + safe-buffer "^5.0.1" + +hash.js@^1.0.0, hash.js@^1.0.3: + version "1.1.7" + resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.7.tgz#0babca538e8d4ee4a0f8988d68866537a003cf42" + integrity sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA== + dependencies: + inherits "^2.0.3" + minimalistic-assert "^1.0.1" + +hex-color-regex@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" + integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ== + +history@4.9.0, history@^4.9.0: + version "4.9.0" + resolved "https://registry.yarnpkg.com/history/-/history-4.9.0.tgz#84587c2068039ead8af769e9d6a6860a14fa1bca" + integrity sha512-H2DkjCjXf0Op9OAr6nJ56fcRkTSNrUiv41vNJ6IswJjif6wlpZK0BTfFbi7qK9dXLSYZxkq5lBsj3vUjlYBYZA== + dependencies: + "@babel/runtime" "^7.1.2" + loose-envify "^1.2.0" + resolve-pathname "^2.2.0" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + value-equal "^0.4.0" + +hmac-drbg@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" + integrity sha1-0nRXAQJabHdabFRXk+1QL8DGSaE= + dependencies: + hash.js "^1.0.3" + minimalistic-assert "^1.0.0" + minimalistic-crypto-utils "^1.0.1" + +hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.0.tgz#b09178f0122184fb95acf525daaecb4d8f45958b" + integrity sha512-0XsbTXxgiaCDYDIWFcwkmerZPSwywfUqYmwT4jzewKTQSWoE6FCMoUVOeBJWK3E/CrWbxRG3m5GzY4lnIwGRBA== + dependencies: + react-is "^16.7.0" + +homedir-polyfill@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8" + integrity sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA== + dependencies: + parse-passwd "^1.0.0" + +hosted-git-info@^2.1.4: + version "2.8.4" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.4.tgz#44119abaf4bc64692a16ace34700fed9c03e2546" + integrity sha512-pzXIvANXEFrc5oFFXRMkbLPQ2rXRoDERwDLyrcUxGhaZhgP54BBSl9Oheh7Vv0T090cszWBxPjkQQ5Sq1PbBRQ== + +hsl-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/hsl-regex/-/hsl-regex-1.0.0.tgz#d49330c789ed819e276a4c0d272dffa30b18fe6e" + integrity sha1-1JMwx4ntgZ4nakwNJy3/owsY/m4= + +hsla-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/hsla-regex/-/hsla-regex-1.0.0.tgz#c1ce7a3168c8c6614033a4b5f7877f3b225f9c38" + integrity sha1-wc56MWjIxmFAM6S194d/OyJfnDg= + +html-comment-regex@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.2.tgz#97d4688aeb5c81886a364faa0cad1dda14d433a7" + integrity sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ== + +html-tags@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.1.0.tgz#7b5e6f7e665e9fb41f30007ed9e0d41e97fb2140" + integrity sha512-1qYz89hW3lFDEazhjW0yVAV87lw8lVkrJocr72XmBkMKsoSVJCQx3W8BXsC7hO2qAt8BoVjYjtAcZ9perqGnNg== + +htmlparser2@^3.10.0: + version "3.10.1" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f" + integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ== + dependencies: + domelementtype "^1.3.1" + domhandler "^2.3.0" + domutils "^1.5.1" + entities "^1.1.1" + inherits "^2.0.1" + readable-stream "^3.1.1" + +"http-parser-js@>=0.4.0 <0.4.11": + version "0.4.10" + resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.4.10.tgz#92c9c1374c35085f75db359ec56cc257cbb93fa4" + integrity sha1-ksnBN0w1CF912zWexWzCV8u5P6Q= + +https-browserify@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" + integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM= + +https-proxy-agent@^2.2.1: + version "2.2.2" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.2.tgz#271ea8e90f836ac9f119daccd39c19ff7dfb0793" + integrity sha512-c8Ndjc9Bkpfx/vCJueCPy0jlP4ccCCSNDp8xwCZzPjKJUm+B+u9WX2x98Qx4n1PiMNTWo3D7KK5ifNV/yJyRzg== + dependencies: + agent-base "^4.3.0" + debug "^3.1.0" + +humps@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/humps/-/humps-2.0.1.tgz#dd02ea6081bd0568dc5d073184463957ba9ef9aa" + integrity sha1-3QLqYIG9BWjcXQcxhEY5V7qe+ao= + +iconv-lite@^0.4.24, iconv-lite@^0.4.4, iconv-lite@~0.4.13: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +icss-utils@^4.0.0, icss-utils@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-4.1.1.tgz#21170b53789ee27447c2f47dd683081403f9a467" + integrity sha512-4aFq7wvWyMHKgxsH8QQtGpvbASCf+eM3wPRLI6R+MgAnTCZ6STYsRvttLvRWK0Nfif5piF394St3HeJDaljGPA== + dependencies: + postcss "^7.0.14" + +ieee754@^1.1.4: + version "1.1.13" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" + integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== + +iferr@^0.1.5: + version "0.1.5" + resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" + integrity sha1-xg7taebY/bazEEofy8ocGS3FtQE= + +ignore-walk@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.1.tgz#a83e62e7d272ac0e3b551aaa82831a19b69f82f8" + integrity sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ== + dependencies: + minimatch "^3.0.4" + +ignore@^3.3.5: + version "3.3.10" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.10.tgz#0a97fb876986e8081c631160f8f9f389157f0043" + integrity sha512-Pgs951kaMm5GXP7MOvxERINe3gsaVjUWFm+UZPSq9xYriQAksyhg0csnS0KXSNRD5NmNdapXEpjxG49+AKh/ug== + +ignore@^4.0.3, ignore@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" + integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== + +ignore@^5.0.6, ignore@^5.1.1: + version "5.1.4" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.4.tgz#84b7b3dbe64552b6ef0eca99f6743dbec6d97adf" + integrity sha512-MzbUSahkTW1u7JpKKjY7LCARd1fU5W2rLdxlM4kdkayuCwZImjkpluF9CM1aLewYJguPDqewLam18Y6AU69A8A== + +immutable@^3.8.1: + version "3.8.2" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3" + integrity sha1-wkOZUUVbs5kT2vKBN28VMOEErfM= + +import-cwd@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9" + integrity sha1-qmzzbnInYShcs3HsZRn1PiQ1sKk= + dependencies: + import-from "^2.1.0" + +import-fresh@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-2.0.0.tgz#d81355c15612d386c61f9ddd3922d4304822a546" + integrity sha1-2BNVwVYS04bGH53dOSLUMEgipUY= + dependencies: + caller-path "^2.0.0" + resolve-from "^3.0.0" + +import-fresh@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.1.0.tgz#6d33fa1dcef6df930fae003446f33415af905118" + integrity sha512-PpuksHKGt8rXfWEr9m9EHIpgyyaltBy8+eF6GJM0QCAxMgxCfucMF3mjecK2QsJr0amJW7gTqh5/wht0z2UhEQ== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +import-from@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/import-from/-/import-from-2.1.0.tgz#335db7f2a7affd53aaa471d4b8021dee36b7f3b1" + integrity sha1-M1238qev/VOqpHHUuAId7ja387E= + dependencies: + resolve-from "^3.0.0" + +import-lazy@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-4.0.0.tgz#e8eb627483a0a43da3c03f3e35548be5cb0cc153" + integrity sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw== + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= + +indent-string@^3.0.0, indent-string@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-3.2.0.tgz#4a5fd6d27cc332f37e5419a504dbb837105c9289" + integrity sha1-Sl/W0nzDMvN+VBmlBNu4NxBckok= + +indexes-of@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/indexes-of/-/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607" + integrity sha1-8w9xbI4r00bHtn0985FVZqfAVgc= + +infer-owner@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/infer-owner/-/infer-owner-1.0.4.tgz#c4cefcaa8e51051c2a40ba2ce8a3d27295af9467" + integrity sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A== + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +inherits@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1" + integrity sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE= + +inherits@2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" + integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= + +ini@^1.3.4, ini@^1.3.5, ini@~1.3.0: + version "1.3.5" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" + integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== + +inquirer@^6.4.1: + version "6.5.2" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.5.2.tgz#ad50942375d036d327ff528c08bd5fab089928ca" + integrity sha512-cntlB5ghuB0iuO65Ovoi8ogLHiWGs/5yNrtUcKjFhSSiVeAIVpD7koaSU9RM8mpXw5YDi9RdYXGQMaOURB7ycQ== + dependencies: + ansi-escapes "^3.2.0" + chalk "^2.4.2" + cli-cursor "^2.1.0" + cli-width "^2.0.0" + external-editor "^3.0.3" + figures "^2.0.0" + lodash "^4.17.12" + mute-stream "0.0.7" + run-async "^2.2.0" + rxjs "^6.4.0" + string-width "^2.1.0" + strip-ansi "^5.1.0" + through "^2.3.6" + +interpret@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.2.0.tgz#d5061a6224be58e8083985f5014d844359576296" + integrity sha512-mT34yGKMNceBQUoVn7iCDKDntA7SC6gycMAWzGx1z/CMCTV7b2AAtXlo3nRyHZ1FelRkQbQjprHSYGwzLtkVbw== + +invariant@^2.2.2, invariant@^2.2.4: + version "2.2.4" + resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" + integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== + dependencies: + loose-envify "^1.0.0" + +invert-kv@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" + integrity sha1-EEqOSqym09jNFXqO+L+rLXo//bY= + +is-absolute-url@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6" + integrity sha1-UFMN+4T8yap9vnhS6Do3uTufKqY= + +is-absolute@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-absolute/-/is-absolute-1.0.0.tgz#395e1ae84b11f26ad1795e73c17378e48a301576" + integrity sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA== + dependencies: + is-relative "^1.0.0" + is-windows "^1.0.1" + +is-accessor-descriptor@^0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" + integrity sha1-qeEss66Nh2cn7u84Q/igiXtcmNY= + dependencies: + kind-of "^3.0.2" + +is-accessor-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656" + integrity sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ== + dependencies: + kind-of "^6.0.0" + +is-alphabetical@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-1.0.3.tgz#eb04cc47219a8895d8450ace4715abff2258a1f8" + integrity sha512-eEMa6MKpHFzw38eKm56iNNi6GJ7lf6aLLio7Kr23sJPAECscgRtZvOBYybejWDQ2bM949Y++61PY+udzj5QMLA== + +is-alphanumeric@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-alphanumeric/-/is-alphanumeric-1.0.0.tgz#4a9cef71daf4c001c1d81d63d140cf53fd6889f4" + integrity sha1-Spzvcdr0wAHB2B1j0UDPU/1oifQ= + +is-alphanumerical@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/is-alphanumerical/-/is-alphanumerical-1.0.3.tgz#57ae21c374277b3defe0274c640a5704b8f6657c" + integrity sha512-A1IGAPO5AW9vSh7omxIlOGwIqEvpW/TA+DksVOPM5ODuxKlZS09+TEM1E3275lJqO2oJ38vDpeAL3DCIiHE6eA== + dependencies: + is-alphabetical "^1.0.0" + is-decimal "^1.0.0" + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" + integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= + +is-arrayish@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" + integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== + +is-binary-path@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898" + integrity sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg= + dependencies: + binary-extensions "^1.0.0" + +is-buffer@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" + integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== + +is-buffer@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.3.tgz#4ecf3fcf749cbd1e472689e109ac66261a25e725" + integrity sha512-U15Q7MXTuZlrbymiz95PJpZxu8IlipAp4dtS3wOdgPXx3mqBnslrWU14kxfHB+Py/+2PVKSr37dMAgM2A4uArw== + +is-callable@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.4.tgz#1e1adf219e1eeb684d691f9d6a05ff0d30a24d75" + integrity sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA== + +is-color-stop@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-color-stop/-/is-color-stop-1.1.0.tgz#cfff471aee4dd5c9e158598fbe12967b5cdad345" + integrity sha1-z/9HGu5N1cnhWFmPvhKWe1za00U= + dependencies: + css-color-names "^0.0.4" + hex-color-regex "^1.1.0" + hsl-regex "^1.0.0" + hsla-regex "^1.0.0" + rgb-regex "^1.0.1" + rgba-regex "^1.0.0" + +is-data-descriptor@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" + integrity sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y= + dependencies: + kind-of "^3.0.2" + +is-data-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7" + integrity sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ== + dependencies: + kind-of "^6.0.0" + +is-date-object@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.1.tgz#9aa20eb6aeebbff77fbd33e74ca01b33581d3a16" + integrity sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY= + +is-decimal@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/is-decimal/-/is-decimal-1.0.3.tgz#381068759b9dc807d8c0dc0bfbae2b68e1da48b7" + integrity sha512-bvLSwoDg2q6Gf+E2LEPiklHZxxiSi3XAh4Mav65mKqTfCO1HM3uBs24TjEH8iJX3bbDdLXKJXBTmGzuTUuAEjQ== + +is-descriptor@^0.1.0: + version "0.1.6" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca" + integrity sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg== + dependencies: + is-accessor-descriptor "^0.1.6" + is-data-descriptor "^0.1.4" + kind-of "^5.0.0" + +is-descriptor@^1.0.0, is-descriptor@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec" + integrity sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg== + dependencies: + is-accessor-descriptor "^1.0.0" + is-data-descriptor "^1.0.0" + kind-of "^6.0.2" + +is-directory@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/is-directory/-/is-directory-0.3.1.tgz#61339b6f2475fc772fd9c9d83f5c8575dc154ae1" + integrity sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE= + +is-dotfile@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1" + integrity sha1-pqLzL/0t+wT1yiXs0Pa4PPeYoeE= + +is-equal-shallow@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz#2238098fc221de0bcfa5d9eac4c45d638aa1c534" + integrity sha1-IjgJj8Ih3gvPpdnqxMRdY4qhxTQ= + dependencies: + is-primitive "^2.0.0" + +is-extendable@^0.1.0, is-extendable@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" + integrity sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik= + +is-extendable@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4" + integrity sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA== + dependencies: + is-plain-object "^2.0.4" + +is-extglob@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0" + integrity sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA= + +is-extglob@^2.1.0, is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= + +is-fullwidth-code-point@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" + integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs= + dependencies: + number-is-nan "^1.0.0" + +is-fullwidth-code-point@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" + integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-glob@^2.0.0, is-glob@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863" + integrity sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM= + dependencies: + is-extglob "^1.0.0" + +is-glob@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a" + integrity sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo= + dependencies: + is-extglob "^2.1.0" + +is-glob@^4.0.0, is-glob@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" + integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== + dependencies: + is-extglob "^2.1.1" + +is-hexadecimal@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.3.tgz#e8a426a69b6d31470d3a33a47bb825cda02506ee" + integrity sha512-zxQ9//Q3D/34poZf8fiy3m3XVpbQc7ren15iKqrTtLPwkPD/t3Scy9Imp63FujULGxuK0ZlCwoo5xNpktFgbOA== + +is-negated-glob@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-negated-glob/-/is-negated-glob-1.0.0.tgz#6910bca5da8c95e784b5751b976cf5a10fee36d2" + integrity sha1-aRC8pdqMleeEtXUbl2z1oQ/uNtI= + +is-number@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f" + integrity sha1-Afy7s5NGOlSPL0ZszhbezknbkI8= + dependencies: + kind-of "^3.0.2" + +is-number@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195" + integrity sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU= + dependencies: + kind-of "^3.0.2" + +is-number@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-4.0.0.tgz#0026e37f5454d73e356dfe6564699867c6a7f0ff" + integrity sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ== + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-obj@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" + integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8= + +is-path-cwd@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-2.2.0.tgz#67d43b82664a7b5191fd9119127eb300048a9fdb" + integrity sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ== + +is-path-inside@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.1.tgz#7417049ed551d053ab82bba3fdd6baa6b3a81e89" + integrity sha512-CKstxrctq1kUesU6WhtZDbYKzzYBuRH0UYInAVrkc/EYdB9ltbfE0gOoayG9nhohG6447sOOVGhHqsdmBvkbNg== + +is-plain-obj@^1.0.0, is-plain-obj@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" + integrity sha1-caUMhCnfync8kqOQpKA7OfzVHT4= + +is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" + integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== + dependencies: + isobject "^3.0.1" + +is-posix-bracket@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz#3334dc79774368e92f016e6fbc0a88f5cd6e6bc4" + integrity sha1-MzTceXdDaOkvAW5vvAqI9c1ua8Q= + +is-primitive@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575" + integrity sha1-IHurkWOEmcB7Kt8kCkGochADRXU= + +is-promise@^2.1, is-promise@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa" + integrity sha1-eaKp7OfwlugPNtKy87wWwf9L8/o= + +is-regex@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.4.tgz#5517489b547091b0930e095654ced25ee97e9491" + integrity sha1-VRdIm1RwkbCTDglWVM7SXul+lJE= + dependencies: + has "^1.0.1" + +is-regexp@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-2.1.0.tgz#cd734a56864e23b956bf4e7c66c396a4c0b22c2d" + integrity sha512-OZ4IlER3zmRIoB9AqNhEggVxqIH4ofDns5nRrPS6yQxXE1TPCUpFznBfRQmQa8uC+pXqjMnukiJBxCisIxiLGA== + +is-relative@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-relative/-/is-relative-1.0.0.tgz#a1bb6935ce8c5dba1e8b9754b9b2dcc020e2260d" + integrity sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA== + dependencies: + is-unc-path "^1.0.0" + +is-resolvable@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.1.0.tgz#fb18f87ce1feb925169c9a407c19318a3206ed88" + integrity sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg== + +is-stream@^1.0.1, is-stream@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" + integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= + +is-svg@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-svg/-/is-svg-3.0.0.tgz#9321dbd29c212e5ca99c4fa9794c714bcafa2f75" + integrity sha512-gi4iHK53LR2ujhLVVj+37Ykh9GLqYHX6JOVXbLAucaG/Cqw9xwdFOjDM2qeifLs1sF1npXXFvDu0r5HNgCMrzQ== + dependencies: + html-comment-regex "^1.1.0" + +is-symbol@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.2.tgz#a055f6ae57192caee329e7a860118b497a950f38" + integrity sha512-HS8bZ9ox60yCJLH9snBpIwv9pYUAkcuLhSA1oero1UB5y9aiQpRA8y2ex945AOtCZL1lJDeIk3G5LthswI46Lw== + dependencies: + has-symbols "^1.0.0" + +is-unc-path@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-unc-path/-/is-unc-path-1.0.0.tgz#d731e8898ed090a12c352ad2eaed5095ad322c9d" + integrity sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ== + dependencies: + unc-path-regex "^0.1.2" + +is-utf8@^0.2.0, is-utf8@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" + integrity sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI= + +is-valid-glob@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-valid-glob/-/is-valid-glob-1.0.0.tgz#29bf3eff701be2d4d315dbacc39bc39fe8f601aa" + integrity sha1-Kb8+/3Ab4tTTFdusw5vDn+j2Aao= + +is-whitespace-character@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/is-whitespace-character/-/is-whitespace-character-1.0.3.tgz#b3ad9546d916d7d3ffa78204bca0c26b56257fac" + integrity sha512-SNPgMLz9JzPccD3nPctcj8sZlX9DAMJSKH8bP7Z6bohCwuNgX8xbWr1eTAYXX9Vpi/aSn8Y1akL9WgM3t43YNQ== + +is-windows@^1.0.1, is-windows@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" + integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== + +is-word-character@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/is-word-character/-/is-word-character-1.0.3.tgz#264d15541cbad0ba833d3992c34e6b40873b08aa" + integrity sha512-0wfcrFgOOOBdgRNT9H33xe6Zi6yhX/uoc4U8NBZGeQQB0ctU1dnlNTyL9JM2646bHDTpsDm1Brb3VPoCIMrd/A== + +is-wsl@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d" + integrity sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0= + +is@^3.2.1: + version "3.3.0" + resolved "https://registry.yarnpkg.com/is/-/is-3.3.0.tgz#61cff6dd3c4193db94a3d62582072b44e5645d79" + integrity sha512-nW24QBoPcFGGHJGUwnfpI7Yc5CdqWNdsyHQszVE/z2pKHXzh7FZ5GWhJqSyaQ9wMkQnsTx+kAI8bHlCX4tKdbg== + +isarray@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" + integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= + +isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= + +isobject@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" + integrity sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk= + dependencies: + isarray "1.0.0" + +isobject@^3.0.0, isobject@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" + integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= + +isomorphic-fetch@^2.1.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz#611ae1acf14f5e81f729507472819fe9733558a9" + integrity sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk= + dependencies: + node-fetch "^1.0.1" + whatwg-fetch ">=0.10.0" + +isstream@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" + integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= + +jdu@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/jdu/-/jdu-1.0.0.tgz#28f1e388501785ae0a1d93e93ed0b14dd41e51ce" + integrity sha1-KPHjiFAXha4KHZPpPtCxTdQeUc4= + +jquery@3.4.1, jquery@>=1.6.4: + version "3.4.1" + resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.4.1.tgz#714f1f8d9dde4bdfa55764ba37ef214630d80ef2" + integrity sha512-36+AdBzCL+y6qjw5Tx7HgzeGCzC81MDDgaUP8ld2zhx58HdqXGoBd+tHdrBMiyjGQs0Hxs/MLZTu/eHNJJuWPw== + +js-levenshtein@^1.1.3: + version "1.1.6" + resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.6.tgz#c6cee58eb3550372df8deb85fad5ce66ce01d59d" + integrity sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g== + +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +js-yaml@^3.13.0, js-yaml@^3.13.1: + version "3.13.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847" + integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +jsesc@^2.5.1: + version "2.5.2" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" + integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== + +jsesc@~0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" + integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0= + +json-parse-better-errors@^1.0.1, json-parse-better-errors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" + integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE= + +json5@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe" + integrity sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow== + dependencies: + minimist "^1.2.0" + +json5@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.0.tgz#e7a0c62c48285c628d20a10b85c89bb807c32850" + integrity sha512-8Mh9h6xViijj36g7Dxi+Y4S6hNGV96vcJZr/SrlHh1LR/pEn/8j/+qIBbs44YKl69Lrfctp4QD+AdWLTMqEZAQ== + dependencies: + minimist "^1.2.0" + +jsonify@~0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" + integrity sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM= + +jsx-ast-utils@^2.1.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-2.2.1.tgz#4d4973ebf8b9d2837ee91a8208cc66f3a2776cfb" + integrity sha512-v3FxCcAf20DayI+uxnCuw795+oOIkVu6EnJ1+kSzhqqTZHNkTZ7B66ZgLp4oLJ/gbA64cI0B7WRoHZMSRdyVRQ== + dependencies: + array-includes "^3.0.3" + object.assign "^4.1.0" + +just-curry-it@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/just-curry-it/-/just-curry-it-3.1.0.tgz#ab59daed308a58b847ada166edd0a2d40766fbc5" + integrity sha512-mjzgSOFzlrurlURaHVjnQodyPNvrHrf1TbQP2XU9NSqBtHQPuHZ+Eb6TAJP7ASeJN9h9K0KXoRTs8u6ouHBKvg== + +just-debounce@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/just-debounce/-/just-debounce-1.0.0.tgz#87fccfaeffc0b68cd19d55f6722943f929ea35ea" + integrity sha1-h/zPrv/AtozRnVX2cilD+SnqNeo= + +kind-of@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-1.1.0.tgz#140a3d2d41a36d2efcfa9377b62c24f8495a5c44" + integrity sha1-FAo9LUGjbS78+pN3tiwk+ElaXEQ= + +kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: + version "3.2.2" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" + integrity sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ= + dependencies: + is-buffer "^1.1.5" + +kind-of@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57" + integrity sha1-IIE989cSkosgc3hpGkUGb65y3Vc= + dependencies: + is-buffer "^1.1.5" + +kind-of@^5.0.0, kind-of@^5.0.2: + version "5.1.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d" + integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw== + +kind-of@^6.0.0, kind-of@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051" + integrity sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA== + +known-css-properties@^0.14.0: + version "0.14.0" + resolved "https://registry.yarnpkg.com/known-css-properties/-/known-css-properties-0.14.0.tgz#d7032b4334a32dc22e6e46b081ec789daf18756c" + integrity sha512-P+0a/gBzLgVlCnK8I7VcD0yuYJscmWn66wH9tlKsQnmVdg689tLEmziwB9PuazZYLkcm07fvWOKCJJqI55sD5Q== + +last-call-webpack-plugin@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/last-call-webpack-plugin/-/last-call-webpack-plugin-3.0.0.tgz#9742df0e10e3cf46e5c0381c2de90d3a7a2d7555" + integrity sha512-7KI2l2GIZa9p2spzPIVZBYyNKkN+e/SQPpnjlTiPhdbDW3F86tdKKELxKpzJ5sgU19wQWsACULZmpTPYHeWO5w== + dependencies: + lodash "^4.17.5" + webpack-sources "^1.1.0" + +last-run@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/last-run/-/last-run-1.1.1.tgz#45b96942c17b1c79c772198259ba943bebf8ca5b" + integrity sha1-RblpQsF7HHnHchmCWbqUO+v4yls= + dependencies: + default-resolution "^2.0.0" + es6-weak-map "^2.0.1" + +lazystream@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/lazystream/-/lazystream-1.0.0.tgz#f6995fe0f820392f61396be89462407bb77168e4" + integrity sha1-9plf4PggOS9hOWvolGJAe7dxaOQ= + dependencies: + readable-stream "^2.0.5" + +lcid@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835" + integrity sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU= + dependencies: + invert-kv "^1.0.0" + +lead@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/lead/-/lead-1.0.0.tgz#6f14f99a37be3a9dd784f5495690e5903466ee42" + integrity sha1-bxT5mje+Op3XhPVJVpDlkDRm7kI= + dependencies: + flush-write-stream "^1.0.2" + +leven@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" + integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== + +levn@^0.3.0, levn@~0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" + integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4= + dependencies: + prelude-ls "~1.1.2" + type-check "~0.3.2" + +liftoff@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/liftoff/-/liftoff-3.1.0.tgz#c9ba6081f908670607ee79062d700df062c52ed3" + integrity sha512-DlIPlJUkCV0Ips2zf2pJP0unEoT1kwYhiiPUGF3s/jtxTCjziNLoiVVh+jqWOWeFi6mmwQ5fNxvAUyPad4Dfog== + dependencies: + extend "^3.0.0" + findup-sync "^3.0.0" + fined "^1.0.1" + flagged-respawn "^1.0.0" + is-plain-object "^2.0.4" + object.map "^1.0.0" + rechoir "^0.6.2" + resolve "^1.1.7" + +linear-layout-vector@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/linear-layout-vector/-/linear-layout-vector-0.0.1.tgz#398114d7303b6ecc7fd6b273af7b8401d8ba9c70" + integrity sha1-OYEU1zA7bsx/1rJzr3uEAdi6nHA= + +livereload-js@^2.3.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/livereload-js/-/livereload-js-2.4.0.tgz#447c31cf1ea9ab52fc20db615c5ddf678f78009c" + integrity sha512-XPQH8Z2GDP/Hwz2PCDrh2mth4yFejwA1OZ/81Ti3LgKyhDcEjsSsqFWZojHG0va/duGd+WyosY7eXLDoOyqcPw== + +load-json-file@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0" + integrity sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA= + dependencies: + graceful-fs "^4.1.2" + parse-json "^2.2.0" + pify "^2.0.0" + pinkie-promise "^2.0.0" + strip-bom "^2.0.0" + +load-json-file@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-2.0.0.tgz#7947e42149af80d696cbf797bcaabcfe1fe29ca8" + integrity sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg= + dependencies: + graceful-fs "^4.1.2" + parse-json "^2.2.0" + pify "^2.0.0" + strip-bom "^3.0.0" + +load-json-file@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-4.0.0.tgz#2f5f45ab91e33216234fd53adab668eb4ec0993b" + integrity sha1-L19Fq5HjMhYjT9U62rZo607AmTs= + dependencies: + graceful-fs "^4.1.2" + parse-json "^4.0.0" + pify "^3.0.0" + strip-bom "^3.0.0" + +loader-runner@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.4.0.tgz#ed47066bfe534d7e84c4c7b9998c2a75607d9357" + integrity sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw== + +loader-utils@^1.0.2, loader-utils@^1.1.0, loader-utils@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.2.3.tgz#1ff5dc6911c9f0a062531a4c04b609406108c2c7" + integrity sha512-fkpz8ejdnEMG3s37wGL07iSBDg99O9D5yflE9RGNH3hRdx9SOwYfnGYdZOUIZitN8E+E2vkq3MUMYMvPYl5ZZA== + dependencies: + big.js "^5.2.2" + emojis-list "^2.0.0" + json5 "^1.0.1" + +locate-path@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" + integrity sha1-K1aLJl7slExtnA3pw9u7ygNUzY4= + dependencies: + p-locate "^2.0.0" + path-exists "^3.0.0" + +locate-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" + integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A== + dependencies: + p-locate "^3.0.0" + path-exists "^3.0.0" + +lodash.assign@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-4.2.0.tgz#0d99f3ccd7a6d261d19bdaeb9245005d285808e7" + integrity sha1-DZnzzNem0mHRm9rrkkUAXShYCOc= + +lodash.camelcase@4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" + integrity sha1-soqmKIorn8ZRA1x3EfZathkDMaY= + +lodash.clone@^4.3.2: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.clone/-/lodash.clone-4.5.0.tgz#195870450f5a13192478df4bc3d23d2dea1907b6" + integrity sha1-GVhwRQ9aExkkeN9Lw9I9LeoZB7Y= + +lodash.defaults@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" + integrity sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw= + +lodash.kebabcase@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36" + integrity sha1-hImxyw0p/4gZXM7KRI/21swpXDY= + +lodash.memoize@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" + integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= + +lodash.snakecase@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz#39d714a35357147837aefd64b5dcbb16becd8f8d" + integrity sha1-OdcUo1NXFHg3rv1ktdy7Fr7Nj40= + +lodash.some@^4.2.2: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.some/-/lodash.some-4.6.0.tgz#1bb9f314ef6b8baded13b549169b2a945eb68e4d" + integrity sha1-G7nzFO9ri63tE7VJFpsqlF62jk0= + +lodash.uniq@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" + integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= + +lodash.upperfirst@4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz#1365edf431480481ef0d1c68957a5ed99d49f7ce" + integrity sha1-E2Xt9DFIBIHvDRxolXpe2Z1J984= + +lodash@4.17.15, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.5: + version "4.17.15" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" + integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== + +log-symbols@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a" + integrity sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg== + dependencies: + chalk "^2.0.1" + +log-symbols@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-3.0.0.tgz#f3a08516a5dea893336a7dee14d18a1cfdab77c4" + integrity sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ== + dependencies: + chalk "^2.4.2" + +longest-streak@^2.0.1: + version "2.0.3" + resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-2.0.3.tgz#3de7a3f47ee18e9074ded8575b5c091f5d0a4105" + integrity sha512-9lz5IVdpwsKLMzQi0MQ+oD9EA0mIGcWYP7jXMTZVXP8D42PwuAk+M/HBFYQoxt1G5OR8m7aSIgb1UymfWGBWEw== + +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.0, loose-envify@^1.3.1, loose-envify@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +loud-rejection@^1.0.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f" + integrity sha1-W0b4AUft7leIcPCG0Eghz5mOVR8= + dependencies: + currently-unhandled "^0.4.1" + signal-exit "^3.0.0" + +lru-cache@^4.0.1: + version "4.1.5" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" + integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g== + dependencies: + pseudomap "^1.0.2" + yallist "^2.1.2" + +lru-cache@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" + integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== + dependencies: + yallist "^3.0.2" + +lru-queue@0.1: + version "0.1.0" + resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3" + integrity sha1-Jzi9nw089PhEkMVzbEhpmsYyzaM= + dependencies: + es5-ext "~0.10.2" + +make-dir@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5" + integrity sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA== + dependencies: + pify "^4.0.1" + semver "^5.6.0" + +make-iterator@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/make-iterator/-/make-iterator-1.0.1.tgz#29b33f312aa8f547c4a5e490f56afcec99133ad6" + integrity sha512-pxiuXh0iVEq7VM7KMIhs5gxsfxCux2URptUQaXo4iZZJxBAzTPOLE2BumO5dbfVYq/hBJFBR/a1mFDmOx5AGmw== + dependencies: + kind-of "^6.0.2" + +makeerror@1.0.x: + version "1.0.11" + resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.11.tgz#e01a5c9109f2af79660e4e8b9587790184f5a96c" + integrity sha1-4BpckQnyr3lmDk6LlYd5AYT1qWw= + dependencies: + tmpl "1.0.x" + +mamacro@^0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/mamacro/-/mamacro-0.0.3.tgz#ad2c9576197c9f1abf308d0787865bd975a3f3e4" + integrity sha512-qMEwh+UujcQ+kbz3T6V+wAmO2U8veoq2w+3wY8MquqwVA3jChfwY+Tk52GZKDfACEPjuZ7r2oJLejwpt8jtwTA== + +map-cache@^0.2.0, map-cache@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" + integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8= + +map-obj@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d" + integrity sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0= + +map-obj@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-2.0.0.tgz#a65cd29087a92598b8791257a523e021222ac1f9" + integrity sha1-plzSkIepJZi4eRJXpSPgISIqwfk= + +map-stream@0.0.7: + version "0.0.7" + resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.0.7.tgz#8a1f07896d82b10926bd3744a2420009f88974a8" + integrity sha1-ih8HiW2CsQkmvTdEokIACfiJdKg= + +map-stream@~0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.1.0.tgz#e56aa94c4c8055a16404a0674b78f215f7c8e194" + integrity sha1-5WqpTEyAVaFkBKBnS3jyFffI4ZQ= + +map-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f" + integrity sha1-7Nyo8TFE5mDxtb1B8S80edmN+48= + dependencies: + object-visit "^1.0.0" + +markdown-escapes@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/markdown-escapes/-/markdown-escapes-1.0.3.tgz#6155e10416efaafab665d466ce598216375195f5" + integrity sha512-XUi5HJhhV5R74k8/0H2oCbCiYf/u4cO/rX8tnGkRvrqhsr5BRNU6Mg0yt/8UIx1iIS8220BNJsDb7XnILhLepw== + +markdown-table@^1.1.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-1.1.3.tgz#9fcb69bcfdb8717bfd0398c6ec2d93036ef8de60" + integrity sha512-1RUZVgQlpJSPWYbFSpmudq5nHY1doEIv89gBtF0s4gW1GF2XorxcA/70M5vq7rLv0a6mhOUccRsqkwhwLCIQ2Q== + +matchdep@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/matchdep/-/matchdep-2.0.0.tgz#c6f34834a0d8dbc3b37c27ee8bbcb27c7775582e" + integrity sha1-xvNINKDY28OzfCfui7yyfHd1WC4= + dependencies: + findup-sync "^2.0.0" + micromatch "^3.0.4" + resolve "^1.4.0" + stack-trace "0.0.10" + +math-random@^1.0.1: + version "1.0.4" + resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.4.tgz#5dd6943c938548267016d4e34f057583080c514c" + integrity sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A== + +mathml-tag-names@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/mathml-tag-names/-/mathml-tag-names-2.1.1.tgz#6dff66c99d55ecf739ca53c492e626f1d12a33cc" + integrity sha512-pWB896KPGSGkp1XtyzRBftpTzwSOL0Gfk0wLvxt4f2mgzjY19o0LxJ3U25vNWTzsh7da+KTbuXQoQ3lOJZ8WHw== + +md5.js@^1.3.4: + version "1.3.5" + resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" + integrity sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg== + dependencies: + hash-base "^3.0.0" + inherits "^2.0.1" + safe-buffer "^5.1.2" + +mdast-util-compact@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/mdast-util-compact/-/mdast-util-compact-1.0.3.tgz#98a25cc8a7865761a41477b3a87d1dcef0b1e79d" + integrity sha512-nRiU5GpNy62rZppDKbLwhhtw5DXoFMqw9UNZFmlPsNaQCZ//WLjGKUwWMdJrUH+Se7UvtO2gXtAMe0g/N+eI5w== + dependencies: + unist-util-visit "^1.1.0" + +mdn-data@2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.4.tgz#699b3c38ac6f1d728091a64650b65d388502fd5b" + integrity sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA== + +mdn-data@~1.1.0: + version "1.1.4" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-1.1.4.tgz#50b5d4ffc4575276573c4eedb8780812a8419f01" + integrity sha512-FSYbp3lyKjyj3E7fMl6rYvUdX0FBXaluGqlFoYESWQlyUTq8R+wp0rkFxoYFqZlHCvsUXGjyJmLQSnXToYhOSA== + +mem@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/mem/-/mem-1.1.0.tgz#5edd52b485ca1d900fe64895505399a0dfa45f76" + integrity sha1-Xt1StIXKHZAP5kiVUFOZoN+kX3Y= + dependencies: + mimic-fn "^1.0.0" + +memoizee@0.4.X: + version "0.4.14" + resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.14.tgz#07a00f204699f9a95c2d9e77218271c7cd610d57" + integrity sha512-/SWFvWegAIYAO4NQMpcX+gcra0yEZu4OntmUdrBaWrJncxOqAziGFlHxc7yjKVK2uu3lpPW27P27wkR82wA8mg== + dependencies: + d "1" + es5-ext "^0.10.45" + es6-weak-map "^2.0.2" + event-emitter "^0.3.5" + is-promise "^2.1" + lru-queue "0.1" + next-tick "1" + timers-ext "^0.1.5" + +memory-fs@^0.4.0, memory-fs@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552" + integrity sha1-OpoguEYlI+RHz7x+i7gO1me/xVI= + dependencies: + errno "^0.1.3" + readable-stream "^2.0.1" + +meow@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/meow/-/meow-5.0.0.tgz#dfc73d63a9afc714a5e371760eb5c88b91078aa4" + integrity sha512-CbTqYU17ABaLefO8vCU153ZZlprKYWDljcndKKDCFcYQITzWCXZAVk4QMFZPgvzrnUQ3uItnIE/LoUOwrT15Ig== + dependencies: + camelcase-keys "^4.0.0" + decamelize-keys "^1.0.0" + loud-rejection "^1.0.0" + minimist-options "^3.0.1" + normalize-package-data "^2.3.4" + read-pkg-up "^3.0.0" + redent "^2.0.0" + trim-newlines "^2.0.0" + yargs-parser "^10.0.0" + +merge2@^1.2.3: + version "1.2.4" + resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.2.4.tgz#c9269589e6885a60cf80605d9522d4b67ca646e3" + integrity sha512-FYE8xI+6pjFOhokZu0We3S5NKCirLbCzSh2Usf3qEyr4X8U+0jNg9P8RZ4qz+V2UoECLVwSyzU3LxXBaLGtD3A== + +merge@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/merge/-/merge-1.2.1.tgz#38bebf80c3220a8a487b6fcfb3941bb11720c145" + integrity sha512-VjFo4P5Whtj4vsLzsYBu5ayHhoHJ0UqNm7ibvShmbmoz7tGi0vXaoJbGdB+GmDMLUdg8DpQXEIeVDAe8MaABvQ== + +micromatch@^2.1.5: + version "2.3.11" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-2.3.11.tgz#86677c97d1720b363431d04d0d15293bd38c1565" + integrity sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU= + dependencies: + arr-diff "^2.0.0" + array-unique "^0.2.1" + braces "^1.8.2" + expand-brackets "^0.1.4" + extglob "^0.3.1" + filename-regex "^2.0.0" + is-extglob "^1.0.0" + is-glob "^2.0.1" + kind-of "^3.0.2" + normalize-path "^2.0.1" + object.omit "^2.0.0" + parse-glob "^3.0.4" + regex-cache "^0.4.2" + +micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4: + version "3.1.10" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" + integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg== + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + braces "^2.3.1" + define-property "^2.0.2" + extend-shallow "^3.0.2" + extglob "^2.0.4" + fragment-cache "^0.2.1" + kind-of "^6.0.2" + nanomatch "^1.2.9" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.2" + +micromatch@^4.0.0, micromatch@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259" + integrity sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q== + dependencies: + braces "^3.0.1" + picomatch "^2.0.5" + +miller-rabin@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.1.tgz#f080351c865b0dc562a8462966daa53543c78a4d" + integrity sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA== + dependencies: + bn.js "^4.0.0" + brorand "^1.0.1" + +mime@^2.3.1, mime@^2.4.4: + version "2.4.4" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.4.tgz#bd7b91135fc6b01cde3e9bae33d659b63d8857e5" + integrity sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA== + +mimic-fn@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" + integrity sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ== + +mini-create-react-context@^0.3.0: + version "0.3.2" + resolved "https://registry.yarnpkg.com/mini-create-react-context/-/mini-create-react-context-0.3.2.tgz#79fc598f283dd623da8e088b05db8cddab250189" + integrity sha512-2v+OeetEyliMt5VHMXsBhABoJ0/M4RCe7fatd/fBy6SMiKazUSEt3gxxypfnk2SHMkdBYvorHRoQxuGoiwbzAw== + dependencies: + "@babel/runtime" "^7.4.0" + gud "^1.0.0" + tiny-warning "^1.0.2" + +mini-css-extract-plugin@0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.8.0.tgz#81d41ec4fe58c713a96ad7c723cdb2d0bd4d70e1" + integrity sha512-MNpRGbNA52q6U92i0qbVpQNsgk7LExy41MdAlG84FeytfDOtRIf/mCHdEgG8rpTKOaNKiqUnZdlptF469hxqOw== + dependencies: + loader-utils "^1.1.0" + normalize-url "1.9.1" + schema-utils "^1.0.0" + webpack-sources "^1.1.0" + +minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" + integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== + +minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" + integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo= + +minimatch@^3.0.2, minimatch@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" + integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== + dependencies: + brace-expansion "^1.1.7" + +minimist-options@^3.0.1: + version "3.0.2" + resolved "https://registry.yarnpkg.com/minimist-options/-/minimist-options-3.0.2.tgz#fba4c8191339e13ecf4d61beb03f070103f3d954" + integrity sha512-FyBrT/d0d4+uiZRbqznPXqw3IpZZG3gl3wKWiX784FycUKVwBt0uLBFkQrtE4tZOrgo78nZp2jnKz3L65T5LdQ== + dependencies: + arrify "^1.0.1" + is-plain-obj "^1.1.0" + +minimist@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" + integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0= + +minimist@1.1.x: + version "1.1.3" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.1.3.tgz#3bedfd91a92d39016fcfaa1c681e8faa1a1efda8" + integrity sha1-O+39kaktOQFvz6ocaB6Pqhoe/ag= + +minimist@^1.1.1, minimist@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" + integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ= + +minipass@^2.2.1, minipass@^2.3.5: + version "2.5.0" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.5.0.tgz#dddb1d001976978158a05badfcbef4a771612857" + integrity sha512-9FwMVYhn6ERvMR8XFdOavRz4QK/VJV8elU1x50vYexf9lslDcWe/f4HBRxCPd185ekRSjU6CfYyJCECa/CQy7Q== + dependencies: + safe-buffer "^5.1.2" + yallist "^3.0.0" + +minizlib@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.2.1.tgz#dd27ea6136243c7c880684e8672bb3a45fd9b614" + integrity sha512-7+4oTUOWKg7AuL3vloEWekXY2/D20cevzsrNT2kGWm+39J9hGTCBv8VI5Pm5lXZ/o3/mdR4f8rflAPhnQb8mPA== + dependencies: + minipass "^2.2.1" + +mississippi@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-3.0.0.tgz#ea0a3291f97e0b5e8776b363d5f0a12d94c67022" + integrity sha512-x471SsVjUtBRtcvd4BzKE9kFC+/2TeWgKCgw0bZcw1b9l2X3QX5vCWgF+KaZaYm87Ss//rHnWryupDrgLvmSkA== + dependencies: + concat-stream "^1.5.0" + duplexify "^3.4.2" + end-of-stream "^1.1.0" + flush-write-stream "^1.0.0" + from2 "^2.1.0" + parallel-transform "^1.1.0" + pump "^3.0.0" + pumpify "^1.3.3" + stream-each "^1.1.0" + through2 "^2.0.0" + +mixin-deep@^1.2.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566" + integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA== + dependencies: + for-in "^1.0.2" + is-extendable "^1.0.1" + +mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" + integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM= + dependencies: + minimist "0.0.8" + +mobile-detect@1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/mobile-detect/-/mobile-detect-1.4.3.tgz#e436a3839f5807dd4d3cd4e081f7d3a51ffda2dd" + integrity sha512-UaahPNLllQsstHOEHAmVnTHCMQrAS9eL5Qgdi50QrYz6UgGk+Xziz2udz2GN6NYcyODcPLnasC7a7s6R2DjiaQ== + +moment@2.24.0: + version "2.24.0" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b" + integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg== + +mousetrap@1.6.3: + version "1.6.3" + resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.3.tgz#80fee49665fd478bccf072c9d46bdf1bfed3558a" + integrity sha512-bd+nzwhhs9ifsUrC2tWaSgm24/oo2c83zaRyZQF06hYA6sANfsXHtnZ19AbbbDXCDzeH5nZBSQ4NvCjgD62tJA== + +move-concurrently@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92" + integrity sha1-viwAX9oy4LKa8fBdfEszIUxwH5I= + dependencies: + aproba "^1.1.1" + copy-concurrently "^1.0.0" + fs-write-stream-atomic "^1.0.8" + mkdirp "^0.5.1" + rimraf "^2.5.4" + run-queue "^1.0.3" + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= + +ms@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +mute-stdout@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/mute-stdout/-/mute-stdout-1.0.1.tgz#acb0300eb4de23a7ddeec014e3e96044b3472331" + integrity sha512-kDcwXR4PS7caBpuRYYBUz9iVixUk3anO3f5OYFiIPwK/20vCzKCHyKoulbiDY1S53zD2bxUpxN/IJ+TnXjfvxg== + +mute-stream@0.0.7: + version "0.0.7" + resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" + integrity sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s= + +nan@^2.0.5, nan@^2.12.1: + version "2.14.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c" + integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg== + +nanomatch@^1.2.9: + version "1.2.13" + resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" + integrity sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA== + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + define-property "^2.0.2" + extend-shallow "^3.0.2" + fragment-cache "^0.2.1" + is-windows "^1.0.2" + kind-of "^6.0.2" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= + +needle@^2.2.1: + version "2.4.0" + resolved "https://registry.yarnpkg.com/needle/-/needle-2.4.0.tgz#6833e74975c444642590e15a750288c5f939b57c" + integrity sha512-4Hnwzr3mi5L97hMYeNl8wRW/Onhy4nUKR/lVemJ8gJedxxUyBLm9kkrDColJvoSfwi0jCNhD+xCdOtiGDQiRZg== + dependencies: + debug "^3.2.6" + iconv-lite "^0.4.4" + sax "^1.2.4" + +neo-async@^2.5.0, neo-async@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.1.tgz#ac27ada66167fa8849a6addd837f6b189ad2081c" + integrity sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw== + +next-tick@1, next-tick@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c" + integrity sha1-yobR/ogoFpsBICCOPchCS524NCw= + +nice-try@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" + integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== + +node-fetch@^1.0.1: + version "1.7.3" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" + integrity sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ== + dependencies: + encoding "^0.1.11" + is-stream "^1.0.1" + +node-fetch@^2.1.2: + version "2.6.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd" + integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA== + +node-int64@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" + integrity sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs= + +node-libs-browser@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-2.2.1.tgz#b64f513d18338625f90346d27b0d235e631f6425" + integrity sha512-h/zcD8H9kaDZ9ALUWwlBUDo6TKF8a7qBSCSEGfjTVIYeqsioSKaAX+BN7NgiMGp6iSIXZ3PxgCu8KS3b71YK5Q== + dependencies: + assert "^1.1.1" + browserify-zlib "^0.2.0" + buffer "^4.3.0" + console-browserify "^1.1.0" + constants-browserify "^1.0.0" + crypto-browserify "^3.11.0" + domain-browser "^1.1.1" + events "^3.0.0" + https-browserify "^1.0.0" + os-browserify "^0.3.0" + path-browserify "0.0.1" + process "^0.11.10" + punycode "^1.2.4" + querystring-es3 "^0.2.0" + readable-stream "^2.3.3" + stream-browserify "^2.0.1" + stream-http "^2.7.2" + string_decoder "^1.0.0" + timers-browserify "^2.0.4" + tty-browserify "0.0.0" + url "^0.11.0" + util "^0.11.0" + vm-browserify "^1.0.1" + +node-pre-gyp@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.12.0.tgz#39ba4bb1439da030295f899e3b520b7785766149" + integrity sha512-4KghwV8vH5k+g2ylT+sLTjy5wmUOb9vPhnM8NHvRf9dHmnW/CndrFXy2aRPaPST6dugXSdHXfeaHQm77PIz/1A== + dependencies: + detect-libc "^1.0.2" + mkdirp "^0.5.1" + needle "^2.2.1" + nopt "^4.0.1" + npm-packlist "^1.1.6" + npmlog "^4.0.2" + rc "^1.2.7" + rimraf "^2.6.1" + semver "^5.3.0" + tar "^4" + +node-releases@^1.1.25: + version "1.1.28" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.28.tgz#503c3c70d0e4732b84e7aaa2925fbdde10482d4a" + integrity sha512-AQw4emh6iSXnCpDiFe0phYcThiccmkNWMZnFZ+lDJjAP8J0m2fVd59duvUUyuTirQOhIAajTFkzG6FHCLBO59g== + dependencies: + semver "^5.3.0" + +node.extend@2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/node.extend/-/node.extend-2.0.2.tgz#b4404525494acc99740f3703c496b7d5182cc6cc" + integrity sha512-pDT4Dchl94/+kkgdwyS2PauDFjZG0Hk0IcHIB+LkW27HLDtdoeMxHTxZh39DYbPP8UflWXWj9JcdDozF+YDOpQ== + dependencies: + has "^1.0.3" + is "^3.2.1" + +nopt@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d" + integrity sha1-0NRoWv1UFRk8jHUFYC0NF81kR00= + dependencies: + abbrev "1" + osenv "^0.1.4" + +normalize-package-data@^2.3.2, normalize-package-data@^2.3.4: + version "2.5.0" + resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" + integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA== + dependencies: + hosted-git-info "^2.1.4" + resolve "^1.10.0" + semver "2 || 3 || 4 || 5" + validate-npm-package-license "^3.0.1" + +normalize-path@^2.0.0, normalize-path@^2.0.1, normalize-path@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" + integrity sha1-GrKLVW4Zg2Oowab35vogE3/mrtk= + dependencies: + remove-trailing-separator "^1.0.1" + +normalize-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +normalize-range@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" + integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI= + +normalize-selector@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/normalize-selector/-/normalize-selector-0.2.0.tgz#d0b145eb691189c63a78d201dc4fdb1293ef0c03" + integrity sha1-0LFF62kRicY6eNIB3E/bEpPvDAM= + +normalize-url@1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-1.9.1.tgz#2cc0d66b31ea23036458436e3620d85954c66c3c" + integrity sha1-LMDWazHqIwNkWENuNiDYWVTGbDw= + dependencies: + object-assign "^4.0.1" + prepend-http "^1.0.0" + query-string "^4.1.0" + sort-keys "^1.0.0" + +normalize-url@^3.0.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-3.3.0.tgz#b2e1c4dc4f7c6d57743df733a4f5978d18650559" + integrity sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg== + +normalize.css@8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/normalize.css/-/normalize.css-8.0.1.tgz#9b98a208738b9cc2634caacbc42d131c97487bf3" + integrity sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg== + +now-and-later@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/now-and-later/-/now-and-later-2.0.1.tgz#8e579c8685764a7cc02cb680380e94f43ccb1f7c" + integrity sha512-KGvQ0cB70AQfg107Xvs/Fbu+dGmZoTRJp2TaPwcwQm3/7PteUyN2BCgk8KBMPGBUXZdVwyWS8fDCGFygBm19UQ== + dependencies: + once "^1.3.2" + +npm-bundled@^1.0.1: + version "1.0.6" + resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.6.tgz#e7ba9aadcef962bb61248f91721cd932b3fe6bdd" + integrity sha512-8/JCaftHwbd//k6y2rEWp6k1wxVfpFzB6t1p825+cUb7Ym2XQfhwIC5KwhrvzZRJu+LtDE585zVaS32+CGtf0g== + +npm-packlist@^1.1.6: + version "1.4.4" + resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.4.tgz#866224233850ac534b63d1a6e76050092b5d2f44" + integrity sha512-zTLo8UcVYtDU3gdeaFu2Xu0n0EvelfHDGuqtNIn5RO7yQj4H1TqNdBc/yZjxnWA0PVB8D3Woyp0i5B43JwQ6Vw== + dependencies: + ignore-walk "^3.0.1" + npm-bundled "^1.0.1" + +npm-run-path@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" + integrity sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8= + dependencies: + path-key "^2.0.0" + +npmlog@^4.0.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" + integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== + dependencies: + are-we-there-yet "~1.1.2" + console-control-strings "~1.1.0" + gauge "~2.7.3" + set-blocking "~2.0.0" + +nth-check@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c" + integrity sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg== + dependencies: + boolbase "~1.0.0" + +num2fraction@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/num2fraction/-/num2fraction-1.2.2.tgz#6f682b6a027a4e9ddfa4564cd2589d1d4e669ede" + integrity sha1-b2gragJ6Tp3fpFZM0lidHU5mnt4= + +number-is-nan@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" + integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0= + +object-assign@4.X, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= + +object-assign@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-3.0.0.tgz#9bedd5ca0897949bca47e7ff408062d549f587f2" + integrity sha1-m+3VygiXlJvKR+f/QIBi1Un1h/I= + +object-copy@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c" + integrity sha1-fn2Fi3gb18mRpBupde04EnVOmYw= + dependencies: + copy-descriptor "^0.1.0" + define-property "^0.2.5" + kind-of "^3.0.3" + +object-keys@^1.0.11, object-keys@^1.0.12: + version "1.1.1" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" + integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== + +object-visit@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" + integrity sha1-95xEk68MU3e1n+OdOV5BBC3QRbs= + dependencies: + isobject "^3.0.0" + +object.assign@^4.0.4, object.assign@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.0.tgz#968bf1100d7956bb3ca086f006f846b3bc4008da" + integrity sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w== + dependencies: + define-properties "^1.1.2" + function-bind "^1.1.1" + has-symbols "^1.0.0" + object-keys "^1.0.11" + +object.defaults@^1.0.0, object.defaults@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/object.defaults/-/object.defaults-1.1.0.tgz#3a7f868334b407dea06da16d88d5cd29e435fecf" + integrity sha1-On+GgzS0B96gbaFtiNXNKeQ1/s8= + dependencies: + array-each "^1.0.1" + array-slice "^1.0.0" + for-own "^1.0.0" + isobject "^3.0.0" + +object.entries@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.0.tgz#2024fc6d6ba246aee38bdb0ffd5cfbcf371b7519" + integrity sha512-l+H6EQ8qzGRxbkHOd5I/aHRhHDKoQXQ8g0BYt4uSweQU1/J6dZUOyWh9a2Vky35YCKjzmgxOzta2hH6kf9HuXA== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.12.0" + function-bind "^1.1.1" + has "^1.0.3" + +object.fromentries@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.0.tgz#49a543d92151f8277b3ac9600f1e930b189d30ab" + integrity sha512-9iLiI6H083uiqUuvzyY6qrlmc/Gz8hLQFOcb/Ri/0xXFkSNS3ctV+CbE6yM2+AnkYfOB3dGjdzC0wrMLIhQICA== + dependencies: + define-properties "^1.1.2" + es-abstract "^1.11.0" + function-bind "^1.1.1" + has "^1.0.1" + +object.getownpropertydescriptors@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.0.3.tgz#8758c846f5b407adab0f236e0986f14b051caa16" + integrity sha1-h1jIRvW0B62rDyNuCYbxSwUcqhY= + dependencies: + define-properties "^1.1.2" + es-abstract "^1.5.1" + +object.map@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/object.map/-/object.map-1.0.1.tgz#cf83e59dc8fcc0ad5f4250e1f78b3b81bd801d37" + integrity sha1-z4Plncj8wK1fQlDh94s7gb2AHTc= + dependencies: + for-own "^1.0.0" + make-iterator "^1.0.0" + +object.omit@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa" + integrity sha1-Gpx0SCnznbuFjHbKNXmuKlTr0fo= + dependencies: + for-own "^0.1.4" + is-extendable "^0.1.1" + +object.pick@^1.2.0, object.pick@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747" + integrity sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c= + dependencies: + isobject "^3.0.1" + +object.reduce@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/object.reduce/-/object.reduce-1.0.1.tgz#6fe348f2ac7fa0f95ca621226599096825bb03ad" + integrity sha1-b+NI8qx/oPlcpiEiZZkJaCW7A60= + dependencies: + for-own "^1.0.0" + make-iterator "^1.0.0" + +object.values@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.0.tgz#bf6810ef5da3e5325790eaaa2be213ea84624da9" + integrity sha512-8mf0nKLAoFX6VlNVdhGj31SVYpaNFtUnuoOXWyFEstsWRgU837AK+JYM0iAxwkSzGRbwn8cbFmgbyxj1j4VbXg== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.12.0" + function-bind "^1.1.1" + has "^1.0.3" + +once@^1.3.0, once@^1.3.1, once@^1.3.2, once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= + dependencies: + wrappy "1" + +onetime@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4" + integrity sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ= + dependencies: + mimic-fn "^1.0.0" + +optimize-css-assets-webpack-plugin@5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/optimize-css-assets-webpack-plugin/-/optimize-css-assets-webpack-plugin-5.0.3.tgz#e2f1d4d94ad8c0af8967ebd7cf138dcb1ef14572" + integrity sha512-q9fbvCRS6EYtUKKSwI87qm2IxlyJK5b4dygW1rKUBT6mMDhdG5e5bZT63v6tnJR9F9FB/H5a0HTmtw+laUBxKA== + dependencies: + cssnano "^4.1.10" + last-call-webpack-plugin "^3.0.0" + +optionator@^0.8.2: + version "0.8.2" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.2.tgz#364c5e409d3f4d6301d6c0b4c05bba50180aeb64" + integrity sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q= + dependencies: + deep-is "~0.1.3" + fast-levenshtein "~2.0.4" + levn "~0.3.0" + prelude-ls "~1.1.2" + type-check "~0.3.2" + wordwrap "~1.0.0" + +ordered-read-streams@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/ordered-read-streams/-/ordered-read-streams-1.0.1.tgz#77c0cb37c41525d64166d990ffad7ec6a0e1363e" + integrity sha1-d8DLN8QVJdZBZtmQ/61+xqDhNj4= + dependencies: + readable-stream "^2.0.1" + +os-browserify@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27" + integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc= + +os-homedir@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" + integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M= + +os-locale@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-1.4.0.tgz#20f9f17ae29ed345e8bde583b13d2009803c14d9" + integrity sha1-IPnxeuKe00XoveWDsT0gCYA8FNk= + dependencies: + lcid "^1.0.0" + +os-locale@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-2.1.0.tgz#42bc2900a6b5b8bd17376c8e882b65afccf24bf2" + integrity sha512-3sslG3zJbEYcaC4YVAvDorjGxc7tv6KVATnLPZONiljsUncvihe9BQoVCEs0RZ1kmf4Hk9OBqlZfJZWI4GanKA== + dependencies: + execa "^0.7.0" + lcid "^1.0.0" + mem "^1.1.0" + +os-tmpdir@^1.0.0, os-tmpdir@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" + integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= + +osenv@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410" + integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g== + dependencies: + os-homedir "^1.0.0" + os-tmpdir "^1.0.0" + +p-finally@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4= + +p-limit@^1.1.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8" + integrity sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q== + dependencies: + p-try "^1.0.0" + +p-limit@^2.0.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.2.1.tgz#aa07a788cc3151c939b5131f63570f0dd2009537" + integrity sha512-85Tk+90UCVWvbDavCLKPOLC9vvY8OwEX/RtKF+/1OADJMVlFfEHOiMTPVyxg7mk/dKa+ipdHm0OUkTvCpMTuwg== + dependencies: + p-try "^2.0.0" + +p-locate@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" + integrity sha1-IKAQOyIqcMj9OcwuWAaA893l7EM= + dependencies: + p-limit "^1.1.0" + +p-locate@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" + integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ== + dependencies: + p-limit "^2.0.0" + +p-map@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-3.0.0.tgz#d704d9af8a2ba684e2600d9a215983d4141a979d" + integrity sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ== + dependencies: + aggregate-error "^3.0.0" + +p-try@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" + integrity sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M= + +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + +pako@~1.0.5: + version "1.0.10" + resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.10.tgz#4328badb5086a426aa90f541977d4955da5c9732" + integrity sha512-0DTvPVU3ed8+HNXOu5Bs+o//Mbdj9VNQMUOe9oKCwh8l0GNwpTDMKCWbRjgtD291AWnkAgkqA/LOnQS8AmS1tw== + +parallel-transform@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/parallel-transform/-/parallel-transform-1.1.0.tgz#d410f065b05da23081fcd10f28854c29bda33b06" + integrity sha1-1BDwZbBdojCB/NEPKIVMKb2jOwY= + dependencies: + cyclist "~0.2.2" + inherits "^2.0.3" + readable-stream "^2.1.5" + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +parse-asn1@^5.0.0: + version "5.1.4" + resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.4.tgz#37f6628f823fbdeb2273b4d540434a22f3ef1fcc" + integrity sha512-Qs5duJcuvNExRfFZ99HDD3z4mAi3r9Wl/FOjEOijlxwCZs7E7mW2vjTpgQ4J8LpTF8x5v+1Vn5UQFejmWT11aw== + dependencies: + asn1.js "^4.0.0" + browserify-aes "^1.0.0" + create-hash "^1.1.0" + evp_bytestokey "^1.0.0" + pbkdf2 "^3.0.3" + safe-buffer "^5.1.1" + +parse-entities@^1.0.2, parse-entities@^1.1.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-1.2.2.tgz#c31bf0f653b6661354f8973559cb86dd1d5edf50" + integrity sha512-NzfpbxW/NPrzZ/yYSoQxyqUZMZXIdCfE0OIN4ESsnptHJECoUk3FZktxNuzQf4tjt5UEopnxpYJbvYuxIFDdsg== + dependencies: + character-entities "^1.0.0" + character-entities-legacy "^1.0.0" + character-reference-invalid "^1.0.0" + is-alphanumerical "^1.0.0" + is-decimal "^1.0.0" + is-hexadecimal "^1.0.0" + +parse-filepath@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/parse-filepath/-/parse-filepath-1.0.2.tgz#a632127f53aaf3d15876f5872f3ffac763d6c891" + integrity sha1-pjISf1Oq89FYdvWHLz/6x2PWyJE= + dependencies: + is-absolute "^1.0.0" + map-cache "^0.2.0" + path-root "^0.1.1" + +parse-glob@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c" + integrity sha1-ssN2z7EfNVE7rdFz7wu246OIORw= + dependencies: + glob-base "^0.3.0" + is-dotfile "^1.0.0" + is-extglob "^1.0.0" + is-glob "^2.0.0" + +parse-json@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9" + integrity sha1-9ID0BDTvgHQfhGkJn43qGPVaTck= + dependencies: + error-ex "^1.2.0" + +parse-json@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0" + integrity sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA= + dependencies: + error-ex "^1.3.1" + json-parse-better-errors "^1.0.1" + +parse-node-version@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parse-node-version/-/parse-node-version-1.0.1.tgz#e2b5dbede00e7fa9bc363607f53327e8b073189b" + integrity sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA== + +parse-passwd@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6" + integrity sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY= + +pascalcase@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" + integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ= + +path-browserify@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-0.0.1.tgz#e6c4ddd7ed3aa27c68a20cc4e50e1a4ee83bbc4a" + integrity sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ== + +path-dirname@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0" + integrity sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA= + +path-exists@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-2.1.0.tgz#0feb6c64f0fc518d9a754dd5efb62c7022761f4b" + integrity sha1-D+tsZPD8UY2adU3V77YscCJ2H0s= + dependencies: + pinkie-promise "^2.0.0" + +path-exists@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" + integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= + +path-is-absolute@^1.0.0, path-is-absolute@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= + +path-key@^2.0.0, path-key@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" + integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A= + +path-parse@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" + integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw== + +path-root-regex@^0.1.0: + version "0.1.2" + resolved "https://registry.yarnpkg.com/path-root-regex/-/path-root-regex-0.1.2.tgz#bfccdc8df5b12dc52c8b43ec38d18d72c04ba96d" + integrity sha1-v8zcjfWxLcUsi0PsONGNcsBLqW0= + +path-root@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/path-root/-/path-root-0.1.1.tgz#9a4a6814cac1c0cd73360a95f32083c8ea4745b7" + integrity sha1-mkpoFMrBwM1zNgqV8yCDyOpHRbc= + dependencies: + path-root-regex "^0.1.0" + +path-to-regexp@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.7.0.tgz#59fde0f435badacba103a84e9d3bc64e96b9937d" + integrity sha1-Wf3g9DW62suhA6hOnTvGTpa5k30= + dependencies: + isarray "0.0.1" + +path-type@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441" + integrity sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE= + dependencies: + graceful-fs "^4.1.2" + pify "^2.0.0" + pinkie-promise "^2.0.0" + +path-type@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-2.0.0.tgz#f012ccb8415b7096fc2daa1054c3d72389594c73" + integrity sha1-8BLMuEFbcJb8LaoQVMPXI4lZTHM= + dependencies: + pify "^2.0.0" + +path-type@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f" + integrity sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg== + dependencies: + pify "^3.0.0" + +path-type@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" + integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== + +pause-stream@0.0.11: + version "0.0.11" + resolved "https://registry.yarnpkg.com/pause-stream/-/pause-stream-0.0.11.tgz#fe5a34b0cbce12b5aa6a2b403ee2e73b602f1445" + integrity sha1-/lo0sMvOErWqaitAPuLnO2AvFEU= + dependencies: + through "~2.3" + +pbkdf2@^3.0.3: + version "3.0.17" + resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.0.17.tgz#976c206530617b14ebb32114239f7b09336e93a6" + integrity sha512-U/il5MsrZp7mGg3mSQfn742na2T+1/vHDCG5/iTI3X9MKUuYUZVLQhyRsg06mCgDBTd57TxzgZt7P+fYfjRLtA== + dependencies: + create-hash "^1.1.2" + create-hmac "^1.1.4" + ripemd160 "^2.0.1" + safe-buffer "^5.0.1" + sha.js "^2.4.8" + +performance-now@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" + integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= + +picomatch@^2.0.5: + version "2.0.7" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.0.7.tgz#514169d8c7cd0bdbeecc8a2609e34a7163de69f6" + integrity sha512-oLHIdio3tZ0qH76NybpeneBhYVj0QFTfXEFTc/B3zKQspYfYYkWYgFsmzo+4kvId/bQRcNkVeguI3y+CD22BtA== + +pify@^2.0.0, pify@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" + integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw= + +pify@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" + integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY= + +pify@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231" + integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g== + +pinkie-promise@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa" + integrity sha1-ITXW36ejWMBprJsXh3YogihFD/o= + dependencies: + pinkie "^2.0.0" + +pinkie@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" + integrity sha1-clVrgM+g1IqXToDnckjoDtT3+HA= + +pkg-dir@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-3.0.0.tgz#2749020f239ed990881b1f71210d51eb6523bea3" + integrity sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw== + dependencies: + find-up "^3.0.0" + +plugin-error@1.0.1, plugin-error@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/plugin-error/-/plugin-error-1.0.1.tgz#77016bd8919d0ac377fdcdd0322328953ca5781c" + integrity sha512-L1zP0dk7vGweZME2i+EeakvUNqSrdiI3F91TwEoYiGrAfUXmVv6fJIq4g82PAXxNsWOp0J7ZqQy/3Szz0ajTxA== + dependencies: + ansi-colors "^1.0.1" + arr-diff "^4.0.0" + arr-union "^3.1.0" + extend-shallow "^3.0.2" + +plugin-error@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/plugin-error/-/plugin-error-0.1.2.tgz#3b9bb3335ccf00f425e07437e19276967da47ace" + integrity sha1-O5uzM1zPAPQl4HQ34ZJ2ln2kes4= + dependencies: + ansi-cyan "^0.1.1" + ansi-red "^0.1.1" + arr-diff "^1.0.1" + arr-union "^2.0.1" + extend-shallow "^1.1.2" + +popper.js@^1.14.4: + version "1.15.0" + resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.15.0.tgz#5560b99bbad7647e9faa475c6b8056621f5a4ff2" + integrity sha512-w010cY1oCUmI+9KwwlWki+r5jxKfTFDVoadl7MSrIujHU5MJ5OR6HTDj6Xo8aoR/QsA56x8jKjA59qGH4ELtrA== + +posix-character-classes@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" + integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= + +postcss-calc@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-7.0.1.tgz#36d77bab023b0ecbb9789d84dcb23c4941145436" + integrity sha512-oXqx0m6tb4N3JGdmeMSc/i91KppbYsFZKdH0xMOqK8V1rJlzrKlTdokz8ozUXLVejydRN6u2IddxpcijRj2FqQ== + dependencies: + css-unit-converter "^1.1.1" + postcss "^7.0.5" + postcss-selector-parser "^5.0.0-rc.4" + postcss-value-parser "^3.3.1" + +postcss-color-function@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/postcss-color-function/-/postcss-color-function-4.1.0.tgz#b6f9355e07b12fcc5c34dab957834769b03d8f57" + integrity sha512-2/fuv6mP5Lt03XbRpVfMdGC8lRP1sykme+H1bR4ARyOmSMB8LPSjcL6EAI1iX6dqUF+jNEvKIVVXhan1w/oFDQ== + dependencies: + css-color-function "~1.3.3" + postcss "^6.0.23" + postcss-message-helpers "^2.0.0" + postcss-value-parser "^3.3.1" + +postcss-colormin@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/postcss-colormin/-/postcss-colormin-4.0.3.tgz#ae060bce93ed794ac71264f08132d550956bd381" + integrity sha512-WyQFAdDZpExQh32j0U0feWisZ0dmOtPl44qYmJKkq9xFWY3p+4qnRzCHeNrkeRhwPHz9bQ3mo0/yVkaply0MNw== + dependencies: + browserslist "^4.0.0" + color "^3.0.0" + has "^1.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-convert-values@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-convert-values/-/postcss-convert-values-4.0.1.tgz#ca3813ed4da0f812f9d43703584e449ebe189a7f" + integrity sha512-Kisdo1y77KUC0Jmn0OXU/COOJbzM8cImvw1ZFsBgBgMgb1iL23Zs/LXRe3r+EZqM3vGYKdQ2YJVQ5VkJI+zEJQ== + dependencies: + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-discard-comments@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-discard-comments/-/postcss-discard-comments-4.0.2.tgz#1fbabd2c246bff6aaad7997b2b0918f4d7af4033" + integrity sha512-RJutN259iuRf3IW7GZyLM5Sw4GLTOH8FmsXBnv8Ab/Tc2k4SR4qbV4DNbyyY4+Sjo362SyDmW2DQ7lBSChrpkg== + dependencies: + postcss "^7.0.0" + +postcss-discard-duplicates@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-discard-duplicates/-/postcss-discard-duplicates-4.0.2.tgz#3fe133cd3c82282e550fc9b239176a9207b784eb" + integrity sha512-ZNQfR1gPNAiXZhgENFfEglF93pciw0WxMkJeVmw8eF+JZBbMD7jp6C67GqJAXVZP2BWbOztKfbsdmMp/k8c6oQ== + dependencies: + postcss "^7.0.0" + +postcss-discard-empty@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-discard-empty/-/postcss-discard-empty-4.0.1.tgz#c8c951e9f73ed9428019458444a02ad90bb9f765" + integrity sha512-B9miTzbznhDjTfjvipfHoqbWKwd0Mj+/fL5s1QOz06wufguil+Xheo4XpOnc4NqKYBCNqqEzgPv2aPBIJLox0w== + dependencies: + postcss "^7.0.0" + +postcss-discard-overridden@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-discard-overridden/-/postcss-discard-overridden-4.0.1.tgz#652aef8a96726f029f5e3e00146ee7a4e755ff57" + integrity sha512-IYY2bEDD7g1XM1IDEsUT4//iEYCxAmP5oDSFMVU/JVvT7gh+l4fmjciLqGgwjdWpQIdb0Che2VX00QObS5+cTg== + dependencies: + postcss "^7.0.0" + +postcss-html@^0.36.0: + version "0.36.0" + resolved "https://registry.yarnpkg.com/postcss-html/-/postcss-html-0.36.0.tgz#b40913f94eaacc2453fd30a1327ad6ee1f88b204" + integrity sha512-HeiOxGcuwID0AFsNAL0ox3mW6MHH5cstWN1Z3Y+n6H+g12ih7LHdYxWwEA/QmrebctLjo79xz9ouK3MroHwOJw== + dependencies: + htmlparser2 "^3.10.0" + +postcss-js@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-2.0.2.tgz#a5e75d3fb9d85b28e1d2bd57956c115665ea8542" + integrity sha512-HxXLw1lrczsbVXxyC+t/VIfje9ZeZhkkXE8KpFa3MEKfp2FyHDv29JShYY9eLhYrhLyWWHNIuwkktTfLXu2otw== + dependencies: + camelcase-css "^2.0.1" + postcss "^7.0.17" + +postcss-jsx@^0.36.1: + version "0.36.3" + resolved "https://registry.yarnpkg.com/postcss-jsx/-/postcss-jsx-0.36.3.tgz#c91113eae2935a1c94f00353b788ece9acae3f46" + integrity sha512-yV8Ndo6KzU8eho5mCn7LoLUGPkXrRXRjhMpX4AaYJ9wLJPv099xbtpbRQ8FrPnzVxb/cuMebbPR7LweSt+hTfA== + dependencies: + "@babel/core" ">=7.2.2" + +postcss-less@^3.1.4: + version "3.1.4" + resolved "https://registry.yarnpkg.com/postcss-less/-/postcss-less-3.1.4.tgz#369f58642b5928ef898ffbc1a6e93c958304c5ad" + integrity sha512-7TvleQWNM2QLcHqvudt3VYjULVB49uiW6XzEUFmvwHzvsOEF5MwBrIXZDJQvJNFGjJQTzSzZnDoCJ8h/ljyGXA== + dependencies: + postcss "^7.0.14" + +postcss-load-config@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-2.1.0.tgz#c84d692b7bb7b41ddced94ee62e8ab31b417b003" + integrity sha512-4pV3JJVPLd5+RueiVVB+gFOAa7GWc25XQcMp86Zexzke69mKf6Nx9LRcQywdz7yZI9n1udOxmLuAwTBypypF8Q== + dependencies: + cosmiconfig "^5.0.0" + import-cwd "^2.0.0" + +postcss-loader@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/postcss-loader/-/postcss-loader-3.0.0.tgz#6b97943e47c72d845fa9e03f273773d4e8dd6c2d" + integrity sha512-cLWoDEY5OwHcAjDnkyRQzAXfs2jrKjXpO/HQFcc5b5u/r7aa471wdmChmwfnv7x2u840iat/wi0lQ5nbRgSkUA== + dependencies: + loader-utils "^1.1.0" + postcss "^7.0.0" + postcss-load-config "^2.0.0" + schema-utils "^1.0.0" + +postcss-markdown@^0.36.0: + version "0.36.0" + resolved "https://registry.yarnpkg.com/postcss-markdown/-/postcss-markdown-0.36.0.tgz#7f22849ae0e3db18820b7b0d5e7833f13a447560" + integrity sha512-rl7fs1r/LNSB2bWRhyZ+lM/0bwKv9fhl38/06gF6mKMo/NPnp55+K1dSTosSVjFZc0e1ppBlu+WT91ba0PMBfQ== + dependencies: + remark "^10.0.1" + unist-util-find-all-after "^1.0.2" + +postcss-media-query-parser@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz#27b39c6f4d94f81b1a73b8f76351c609e5cef244" + integrity sha1-J7Ocb02U+Bsac7j3Y1HGCeXO8kQ= + +postcss-merge-longhand@^4.0.11: + version "4.0.11" + resolved "https://registry.yarnpkg.com/postcss-merge-longhand/-/postcss-merge-longhand-4.0.11.tgz#62f49a13e4a0ee04e7b98f42bb16062ca2549e24" + integrity sha512-alx/zmoeXvJjp7L4mxEMjh8lxVlDFX1gqWHzaaQewwMZiVhLo42TEClKaeHbRf6J7j82ZOdTJ808RtN0ZOZwvw== + dependencies: + css-color-names "0.0.4" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + stylehacks "^4.0.0" + +postcss-merge-rules@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/postcss-merge-rules/-/postcss-merge-rules-4.0.3.tgz#362bea4ff5a1f98e4075a713c6cb25aefef9a650" + integrity sha512-U7e3r1SbvYzO0Jr3UT/zKBVgYYyhAz0aitvGIYOYK5CPmkNih+WDSsS5tvPrJ8YMQYlEMvsZIiqmn7HdFUaeEQ== + dependencies: + browserslist "^4.0.0" + caniuse-api "^3.0.0" + cssnano-util-same-parent "^4.0.0" + postcss "^7.0.0" + postcss-selector-parser "^3.0.0" + vendors "^1.0.0" + +postcss-message-helpers@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postcss-message-helpers/-/postcss-message-helpers-2.0.0.tgz#a4f2f4fab6e4fe002f0aed000478cdf52f9ba60e" + integrity sha1-pPL0+rbk/gAvCu0ABHjN9S+bpg4= + +postcss-minify-font-values@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-minify-font-values/-/postcss-minify-font-values-4.0.2.tgz#cd4c344cce474343fac5d82206ab2cbcb8afd5a6" + integrity sha512-j85oO6OnRU9zPf04+PZv1LYIYOprWm6IA6zkXkrJXyRveDEuQggG6tvoy8ir8ZwjLxLuGfNkCZEQG7zan+Hbtg== + dependencies: + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-minify-gradients@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-minify-gradients/-/postcss-minify-gradients-4.0.2.tgz#93b29c2ff5099c535eecda56c4aa6e665a663471" + integrity sha512-qKPfwlONdcf/AndP1U8SJ/uzIJtowHlMaSioKzebAXSG4iJthlWC9iSWznQcX4f66gIWX44RSA841HTHj3wK+Q== + dependencies: + cssnano-util-get-arguments "^4.0.0" + is-color-stop "^1.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-minify-params@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-minify-params/-/postcss-minify-params-4.0.2.tgz#6b9cef030c11e35261f95f618c90036d680db874" + integrity sha512-G7eWyzEx0xL4/wiBBJxJOz48zAKV2WG3iZOqVhPet/9geefm/Px5uo1fzlHu+DOjT+m0Mmiz3jkQzVHe6wxAWg== + dependencies: + alphanum-sort "^1.0.0" + browserslist "^4.0.0" + cssnano-util-get-arguments "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + uniqs "^2.0.0" + +postcss-minify-selectors@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-minify-selectors/-/postcss-minify-selectors-4.0.2.tgz#e2e5eb40bfee500d0cd9243500f5f8ea4262fbd8" + integrity sha512-D5S1iViljXBj9kflQo4YutWnJmwm8VvIsU1GeXJGiG9j8CIg9zs4voPMdQDUmIxetUOh60VilsNzCiAFTOqu3g== + dependencies: + alphanum-sort "^1.0.0" + has "^1.0.0" + postcss "^7.0.0" + postcss-selector-parser "^3.0.0" + +postcss-mixins@6.2.2: + version "6.2.2" + resolved "https://registry.yarnpkg.com/postcss-mixins/-/postcss-mixins-6.2.2.tgz#3acea63271e2c75db62fb80bc1c29e1a609a4742" + integrity sha512-QqEZamiAMguYR6d2h73XXEHZgkxs03PlbU0PqgqtdCnbRlMLFNQgsfL/Td0rjIe2SwpLXOQyB9uoiLWa4GR7tg== + dependencies: + globby "^8.0.1" + postcss "^7.0.17" + postcss-js "^2.0.2" + postcss-simple-vars "^5.0.2" + sugarss "^2.0.0" + +postcss-modules-extract-imports@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-2.0.0.tgz#818719a1ae1da325f9832446b01136eeb493cd7e" + integrity sha512-LaYLDNS4SG8Q5WAWqIJgdHPJrDDr/Lv775rMBFUbgjTz6j34lUznACHcdRWroPvXANP2Vj7yNK57vp9eFqzLWQ== + dependencies: + postcss "^7.0.5" + +postcss-modules-local-by-default@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-3.0.2.tgz#e8a6561be914aaf3c052876377524ca90dbb7915" + integrity sha512-jM/V8eqM4oJ/22j0gx4jrp63GSvDH6v86OqyTHHUvk4/k1vceipZsaymiZ5PvocqZOl5SFHiFJqjs3la0wnfIQ== + dependencies: + icss-utils "^4.1.1" + postcss "^7.0.16" + postcss-selector-parser "^6.0.2" + postcss-value-parser "^4.0.0" + +postcss-modules-scope@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-2.1.0.tgz#ad3f5bf7856114f6fcab901b0502e2a2bc39d4eb" + integrity sha512-91Rjps0JnmtUB0cujlc8KIKCsJXWjzuxGeT/+Q2i2HXKZ7nBUeF9YQTZZTNvHVoNYj1AthsjnGLtqDUE0Op79A== + dependencies: + postcss "^7.0.6" + postcss-selector-parser "^6.0.0" + +postcss-modules-values@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-3.0.0.tgz#5b5000d6ebae29b4255301b4a3a54574423e7f10" + integrity sha512-1//E5jCBrZ9DmRX+zCtmQtRSV6PV42Ix7Bzj9GbwJceduuf7IqP8MgeTXuRDHOWj2m0VzZD5+roFWDuU8RQjcg== + dependencies: + icss-utils "^4.0.0" + postcss "^7.0.6" + +postcss-nested@4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-4.1.2.tgz#8e0570f736bfb4be5136e31901bf2380b819a561" + integrity sha512-9bQFr2TezohU3KRSu9f6sfecXmf/x6RXDedl8CHF6fyuyVW7UqgNMRdWMHZQWuFY6Xqs2NYk+Fj4Z4vSOf7PQg== + dependencies: + postcss "^7.0.14" + postcss-selector-parser "^5.0.0" + +postcss-normalize-charset@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-normalize-charset/-/postcss-normalize-charset-4.0.1.tgz#8b35add3aee83a136b0471e0d59be58a50285dd4" + integrity sha512-gMXCrrlWh6G27U0hF3vNvR3w8I1s2wOBILvA87iNXaPvSNo5uZAMYsZG7XjCUf1eVxuPfyL4TJ7++SGZLc9A3g== + dependencies: + postcss "^7.0.0" + +postcss-normalize-display-values@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-normalize-display-values/-/postcss-normalize-display-values-4.0.2.tgz#0dbe04a4ce9063d4667ed2be476bb830c825935a" + integrity sha512-3F2jcsaMW7+VtRMAqf/3m4cPFhPD3EFRgNs18u+k3lTJJlVe7d0YPO+bnwqo2xg8YiRpDXJI2u8A0wqJxMsQuQ== + dependencies: + cssnano-util-get-match "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-positions@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-normalize-positions/-/postcss-normalize-positions-4.0.2.tgz#05f757f84f260437378368a91f8932d4b102917f" + integrity sha512-Dlf3/9AxpxE+NF1fJxYDeggi5WwV35MXGFnnoccP/9qDtFrTArZ0D0R+iKcg5WsUd8nUYMIl8yXDCtcrT8JrdA== + dependencies: + cssnano-util-get-arguments "^4.0.0" + has "^1.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-repeat-style@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-4.0.2.tgz#c4ebbc289f3991a028d44751cbdd11918b17910c" + integrity sha512-qvigdYYMpSuoFs3Is/f5nHdRLJN/ITA7huIoCyqqENJe9PvPmLhNLMu7QTjPdtnVf6OcYYO5SHonx4+fbJE1+Q== + dependencies: + cssnano-util-get-arguments "^4.0.0" + cssnano-util-get-match "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-string@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-normalize-string/-/postcss-normalize-string-4.0.2.tgz#cd44c40ab07a0c7a36dc5e99aace1eca4ec2690c" + integrity sha512-RrERod97Dnwqq49WNz8qo66ps0swYZDSb6rM57kN2J+aoyEAJfZ6bMx0sx/F9TIEX0xthPGCmeyiam/jXif0eA== + dependencies: + has "^1.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-timing-functions@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-4.0.2.tgz#8e009ca2a3949cdaf8ad23e6b6ab99cb5e7d28d9" + integrity sha512-acwJY95edP762e++00Ehq9L4sZCEcOPyaHwoaFOhIwWCDfik6YvqsYNxckee65JHLKzuNSSmAdxwD2Cud1Z54A== + dependencies: + cssnano-util-get-match "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-unicode@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-normalize-unicode/-/postcss-normalize-unicode-4.0.1.tgz#841bd48fdcf3019ad4baa7493a3d363b52ae1cfb" + integrity sha512-od18Uq2wCYn+vZ/qCOeutvHjB5jm57ToxRaMeNuf0nWVHaP9Hua56QyMF6fs/4FSUnVIw0CBPsU0K4LnBPwYwg== + dependencies: + browserslist "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-url@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-normalize-url/-/postcss-normalize-url-4.0.1.tgz#10e437f86bc7c7e58f7b9652ed878daaa95faae1" + integrity sha512-p5oVaF4+IHwu7VpMan/SSpmpYxcJMtkGppYf0VbdH5B6hN8YNmVyJLuY9FmLQTzY3fag5ESUUHDqM+heid0UVA== + dependencies: + is-absolute-url "^2.0.0" + normalize-url "^3.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-normalize-whitespace@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-normalize-whitespace/-/postcss-normalize-whitespace-4.0.2.tgz#bf1d4070fe4fcea87d1348e825d8cc0c5faa7d82" + integrity sha512-tO8QIgrsI3p95r8fyqKV+ufKlSHh9hMJqACqbv2XknufqEDhDvbguXGBBqxw9nsQoXWf0qOqppziKJKHMD4GtA== + dependencies: + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-ordered-values@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/postcss-ordered-values/-/postcss-ordered-values-4.1.2.tgz#0cf75c820ec7d5c4d280189559e0b571ebac0eee" + integrity sha512-2fCObh5UanxvSxeXrtLtlwVThBvHn6MQcu4ksNT2tsaV2Fg76R2CV98W7wNSlX+5/pFwEyaDwKLLoEV7uRybAw== + dependencies: + cssnano-util-get-arguments "^4.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-reduce-initial@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/postcss-reduce-initial/-/postcss-reduce-initial-4.0.3.tgz#7fd42ebea5e9c814609639e2c2e84ae270ba48df" + integrity sha512-gKWmR5aUulSjbzOfD9AlJiHCGH6AEVLaM0AV+aSioxUDd16qXP1PCh8d1/BGVvpdWn8k/HiK7n6TjeoXN1F7DA== + dependencies: + browserslist "^4.0.0" + caniuse-api "^3.0.0" + has "^1.0.0" + postcss "^7.0.0" + +postcss-reduce-transforms@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-reduce-transforms/-/postcss-reduce-transforms-4.0.2.tgz#17efa405eacc6e07be3414a5ca2d1074681d4e29" + integrity sha512-EEVig1Q2QJ4ELpJXMZR8Vt5DQx8/mo+dGWSR7vWXqcob2gQLyQGsionYcGKATXvQzMPn6DSN1vTN7yFximdIAg== + dependencies: + cssnano-util-get-match "^4.0.0" + has "^1.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + +postcss-reporter@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/postcss-reporter/-/postcss-reporter-6.0.1.tgz#7c055120060a97c8837b4e48215661aafb74245f" + integrity sha512-LpmQjfRWyabc+fRygxZjpRxfhRf9u/fdlKf4VHG4TSPbV2XNsuISzYW1KL+1aQzx53CAppa1bKG4APIB/DOXXw== + dependencies: + chalk "^2.4.1" + lodash "^4.17.11" + log-symbols "^2.2.0" + postcss "^7.0.7" + +postcss-resolve-nested-selector@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.1.tgz#29ccbc7c37dedfac304e9fff0bf1596b3f6a0e4e" + integrity sha1-Kcy8fDfe36wwTp//C/FZaz9qDk4= + +postcss-safe-parser@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-safe-parser/-/postcss-safe-parser-4.0.1.tgz#8756d9e4c36fdce2c72b091bbc8ca176ab1fcdea" + integrity sha512-xZsFA3uX8MO3yAda03QrG3/Eg1LN3EPfjjf07vke/46HERLZyHrTsQ9E1r1w1W//fWEhtYNndo2hQplN2cVpCQ== + dependencies: + postcss "^7.0.0" + +postcss-sass@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/postcss-sass/-/postcss-sass-0.3.5.tgz#6d3e39f101a53d2efa091f953493116d32beb68c" + integrity sha512-B5z2Kob4xBxFjcufFnhQ2HqJQ2y/Zs/ic5EZbCywCkxKd756Q40cIQ/veRDwSrw1BF6+4wUgmpm0sBASqVi65A== + dependencies: + gonzales-pe "^4.2.3" + postcss "^7.0.1" + +postcss-scss@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postcss-scss/-/postcss-scss-2.0.0.tgz#248b0a28af77ea7b32b1011aba0f738bda27dea1" + integrity sha512-um9zdGKaDZirMm+kZFKKVsnKPF7zF7qBAtIfTSnZXD1jZ0JNZIxdB6TxQOjCnlSzLRInVl2v3YdBh/M881C4ug== + dependencies: + postcss "^7.0.0" + +postcss-selector-parser@^3.0.0, postcss-selector-parser@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-3.1.1.tgz#4f875f4afb0c96573d5cf4d74011aee250a7e865" + integrity sha1-T4dfSvsMllc9XPTXQBGu4lCn6GU= + dependencies: + dot-prop "^4.1.1" + indexes-of "^1.0.1" + uniq "^1.0.1" + +postcss-selector-parser@^5.0.0, postcss-selector-parser@^5.0.0-rc.4: + version "5.0.0" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-5.0.0.tgz#249044356697b33b64f1a8f7c80922dddee7195c" + integrity sha512-w+zLE5Jhg6Liz8+rQOWEAwtwkyqpfnmsinXjXg6cY7YIONZZtgvE0v2O0uhQBs0peNomOJwWRKt6JBfTdTd3OQ== + dependencies: + cssesc "^2.0.0" + indexes-of "^1.0.1" + uniq "^1.0.1" + +postcss-selector-parser@^6.0.0, postcss-selector-parser@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.2.tgz#934cf799d016c83411859e09dcecade01286ec5c" + integrity sha512-36P2QR59jDTOAiIkqEprfJDsoNrvwFei3eCqKd1Y0tUsBimsq39BLp7RD+JWny3WgB1zGhJX8XVePwm9k4wdBg== + dependencies: + cssesc "^3.0.0" + indexes-of "^1.0.1" + uniq "^1.0.1" + +postcss-simple-vars@5.0.2, postcss-simple-vars@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/postcss-simple-vars/-/postcss-simple-vars-5.0.2.tgz#e2f81b3d0847ddd4169816b6d141b91d51e6e22e" + integrity sha512-xWIufxBoINJv6JiLb7jl5oElgp+6puJwvT5zZHliUSydoLz4DADRB3NDDsYgfKVwojn4TDLiseoC65MuS8oGGg== + dependencies: + postcss "^7.0.14" + +postcss-sorting@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/postcss-sorting/-/postcss-sorting-5.0.1.tgz#10d5d0059eea8334dacc820c0121864035bc3f11" + integrity sha512-Y9fUFkIhfrm6i0Ta3n+89j56EFqaNRdUKqXyRp6kvTcSXnmgEjaVowCXH+JBe9+YKWqd4nc28r2sgwnzJalccA== + dependencies: + lodash "^4.17.14" + postcss "^7.0.17" + +postcss-svgo@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-svgo/-/postcss-svgo-4.0.2.tgz#17b997bc711b333bab143aaed3b8d3d6e3d38258" + integrity sha512-C6wyjo3VwFm0QgBy+Fu7gCYOkCmgmClghO+pjcxvrcBKtiKt0uCF+hvbMO1fyv5BMImRK90SMb+dwUnfbGd+jw== + dependencies: + is-svg "^3.0.0" + postcss "^7.0.0" + postcss-value-parser "^3.0.0" + svgo "^1.0.0" + +postcss-syntax@^0.36.2: + version "0.36.2" + resolved "https://registry.yarnpkg.com/postcss-syntax/-/postcss-syntax-0.36.2.tgz#f08578c7d95834574e5593a82dfbfa8afae3b51c" + integrity sha512-nBRg/i7E3SOHWxF3PpF5WnJM/jQ1YpY9000OaVXlAQj6Zp/kIqJxEDWIZ67tAd7NLuk7zqN4yqe9nc0oNAOs1w== + +postcss-unique-selectors@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-unique-selectors/-/postcss-unique-selectors-4.0.1.tgz#9446911f3289bfd64c6d680f073c03b1f9ee4bac" + integrity sha512-+JanVaryLo9QwZjKrmJgkI4Fn8SBgRO6WXQBJi7KiAVPlmxikB5Jzc4EvXMT2H0/m0RjrVVm9rGNhZddm/8Spg== + dependencies: + alphanum-sort "^1.0.0" + postcss "^7.0.0" + uniqs "^2.0.0" + +postcss-url@8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/postcss-url/-/postcss-url-8.0.0.tgz#7b10059bd12929cdbb1971c60f61a0e5af86b4ca" + integrity sha512-E2cbOQ5aii2zNHh8F6fk1cxls7QVFZjLPSrqvmiza8OuXLzIpErij8BDS5Y3STPfJgpIMNCPEr8JlKQWEoozUw== + dependencies: + mime "^2.3.1" + minimatch "^3.0.4" + mkdirp "^0.5.0" + postcss "^7.0.2" + xxhashjs "^0.2.1" + +postcss-value-parser@^3.0.0, postcss-value-parser@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281" + integrity sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ== + +postcss-value-parser@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.0.2.tgz#482282c09a42706d1fc9a069b73f44ec08391dc9" + integrity sha512-LmeoohTpp/K4UiyQCwuGWlONxXamGzCMtFxLq4W1nZVGIQLYvMCJx3yAF9qyyuFpflABI9yVdtJAqbihOsCsJQ== + +postcss@^6.0.23: + version "6.0.23" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.23.tgz#61c82cc328ac60e677645f979054eb98bc0e3324" + integrity sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag== + dependencies: + chalk "^2.4.1" + source-map "^0.6.1" + supports-color "^5.4.0" + +postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.16, postcss@^7.0.17, postcss@^7.0.2, postcss@^7.0.5, postcss@^7.0.6, postcss@^7.0.7: + version "7.0.17" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.17.tgz#4da1bdff5322d4a0acaab4d87f3e782436bad31f" + integrity sha512-546ZowA+KZ3OasvQZHsbuEpysvwTZNGJv9EfyCQdsIDltPSWHAeTQ5fQy/Npi2ZDtLI3zs7Ps/p6wThErhm9fQ== + dependencies: + chalk "^2.4.2" + source-map "^0.6.1" + supports-color "^6.1.0" + +prefix-style@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/prefix-style/-/prefix-style-2.0.1.tgz#66bba9a870cfda308a5dc20e85e9120932c95a06" + integrity sha1-ZrupqHDP2jCKXcIOhekSCTLJWgY= + +prelude-ls@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" + integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= + +prepend-http@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc" + integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw= + +preserve@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b" + integrity sha1-gV7R9uvGWSb4ZbMQwHE7yzMVzks= + +pretty-hrtime@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" + integrity sha1-t+PqQkNaTJsnWdmeDyAesZWALuE= + +private@^0.1.6: + version "0.1.8" + resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff" + integrity sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg== + +process-nextick-args@^2.0.0, process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + +process@^0.11.10: + version "0.11.10" + resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" + integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI= + +progress@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.0.tgz#8a1be366bf8fc23db2bd23f10c6fe920b4389d1f" + integrity sha1-ihvjZr+Pwj2yvSPxDG/pILQ4nR8= + +progress@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" + integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== + +promise-inflight@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" + integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM= + +promise@^7.1.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" + integrity sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg== + dependencies: + asap "~2.0.3" + +prop-types@15.7.2, prop-types@^15.5.0, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.6, prop-types@^15.5.7, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: + version "15.7.2" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" + integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== + dependencies: + loose-envify "^1.4.0" + object-assign "^4.1.1" + react-is "^16.8.1" + +proxy-from-env@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.0.0.tgz#33c50398f70ea7eb96d21f7b817630a55791c7ee" + integrity sha1-M8UDmPcOp+uW0h97gXYwpVeRx+4= + +prr@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476" + integrity sha1-0/wRS6BplaRexok/SEzrHXj19HY= + +pseudomap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" + integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= + +public-encrypt@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.3.tgz#4fcc9d77a07e48ba7527e7cbe0de33d0701331e0" + integrity sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q== + dependencies: + bn.js "^4.1.0" + browserify-rsa "^4.0.0" + create-hash "^1.1.0" + parse-asn1 "^5.0.0" + randombytes "^2.0.1" + safe-buffer "^5.1.2" + +pump@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pump/-/pump-2.0.1.tgz#12399add6e4cf7526d973cbc8b5ce2e2908b3909" + integrity sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +pump@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" + integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +pumpify@^1.3.3, pumpify@^1.3.5: + version "1.5.1" + resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-1.5.1.tgz#36513be246ab27570b1a374a5ce278bfd74370ce" + integrity sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ== + dependencies: + duplexify "^3.6.0" + inherits "^2.0.3" + pump "^2.0.0" + +punycode@1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" + integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0= + +punycode@^1.2.4: + version "1.4.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" + integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= + +punycode@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" + integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== + +q@^1.1.2: + version "1.5.1" + resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" + integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc= + +qs@6.7.0: + version "6.7.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" + integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== + +qs@^6.4.0: + version "6.8.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.8.0.tgz#87b763f0d37ca54200334cd57bb2ef8f68a1d081" + integrity sha512-tPSkj8y92PfZVbinY1n84i1Qdx75lZjMQYx9WZhnkofyxzw2r7Ho39G3/aEvSUdebxpnnM4LZJCtvE/Aq3+s9w== + +query-string@^4.1.0: + version "4.3.4" + resolved "https://registry.yarnpkg.com/query-string/-/query-string-4.3.4.tgz#bbb693b9ca915c232515b228b1a02b609043dbeb" + integrity sha1-u7aTucqRXCMlFbIosaArYJBD2+s= + dependencies: + object-assign "^4.1.0" + strict-uri-encode "^1.0.0" + +querystring-es3@^0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" + integrity sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM= + +querystring@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" + integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA= + +quick-lru@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-1.1.0.tgz#4360b17c61136ad38078397ff11416e186dcfbb8" + integrity sha1-Q2CxfGETatOAeDl/8RQW4Ybc+7g= + +raf@^3.1.0: + version "3.4.1" + resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" + integrity sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA== + dependencies: + performance-now "^2.1.0" + +randomatic@^3.0.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-3.1.1.tgz#b776efc59375984e36c537b2f51a1f0aff0da1ed" + integrity sha512-TuDE5KxZ0J461RVjrJZCJc+J+zCkTb1MbH9AQUq68sMhOMcy9jLcb3BrZKgp9q9Ncltdg4QVqWrH02W2EFFVYw== + dependencies: + is-number "^4.0.0" + kind-of "^6.0.0" + math-random "^1.0.1" + +randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5: + version "2.1.0" + resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" + integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== + dependencies: + safe-buffer "^5.1.0" + +randomfill@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/randomfill/-/randomfill-1.0.4.tgz#c92196fc86ab42be983f1bf31778224931d61458" + integrity sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw== + dependencies: + randombytes "^2.0.5" + safe-buffer "^5.1.0" + +raw-body@~1.1.0: + version "1.1.7" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-1.1.7.tgz#1d027c2bfa116acc6623bca8f00016572a87d425" + integrity sha1-HQJ8K/oRasxmI7yo8AAWVyqH1CU= + dependencies: + bytes "1" + string_decoder "0.10" + +rc@^1.2.7: + version "1.2.8" + resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" + integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== + dependencies: + deep-extend "^0.6.0" + ini "~1.3.0" + minimist "^1.2.0" + strip-json-comments "~2.0.1" + +react-addons-shallow-compare@15.6.2: + version "15.6.2" + resolved "https://registry.yarnpkg.com/react-addons-shallow-compare/-/react-addons-shallow-compare-15.6.2.tgz#198a00b91fc37623db64a28fd17b596ba362702f" + integrity sha1-GYoAuR/DdiPbZKKP0XtZa6NicC8= + dependencies: + fbjs "^0.8.4" + object-assign "^4.1.0" + +react-async-script@1.1.1, react-async-script@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/react-async-script/-/react-async-script-1.1.1.tgz#f481c6c5f094bf4b94a9d52da0d0dda2e1a74bdf" + integrity sha512-pmgS3O7JcX4YtH/Xy//NXylpD5CNb5T4/zqlVUV3HvcuyOanatvuveYoxl3X30ZSq/+q/+mSXcNS8xDVQJpSeA== + dependencies: + hoist-non-react-statics "^3.3.0" + prop-types "^15.5.0" + +react-autosuggest@9.4.3: + version "9.4.3" + resolved "https://registry.yarnpkg.com/react-autosuggest/-/react-autosuggest-9.4.3.tgz#eb46852422a48144ab9f39fb5470319222f26c7c" + integrity sha512-wFbp5QpgFQRfw9cwKvcgLR8theikOUkv8PFsuLYqI2PUgVlx186Cz8MYt5bLxculi+jxGGUUVt+h0esaBZZouw== + dependencies: + prop-types "^15.5.10" + react-autowhatever "^10.1.2" + shallow-equal "^1.0.0" + +react-autowhatever@^10.1.2: + version "10.2.0" + resolved "https://registry.yarnpkg.com/react-autowhatever/-/react-autowhatever-10.2.0.tgz#bdd07bf19ddf78acdb8ce7ae162ac13b646874ab" + integrity sha512-dqHH4uqiJldPMbL8hl/i2HV4E8FMTDEdVlOIbRqYnJi0kTpWseF9fJslk/KS9pGDnm80JkYzVI+nzFjnOG/u+g== + dependencies: + prop-types "^15.5.8" + react-themeable "^1.1.0" + section-iterator "^2.0.0" + +react-custom-scrollbars@4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/react-custom-scrollbars/-/react-custom-scrollbars-4.2.1.tgz#830fd9502927e97e8a78c2086813899b2a8b66db" + integrity sha1-gw/ZUCkn6X6KeMIIaBOJmyqLZts= + dependencies: + dom-css "^2.0.0" + prop-types "^15.5.10" + raf "^3.1.0" + +react-dnd-html5-backend@9.3.4: + version "9.3.4" + resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-9.3.4.tgz#5d1f5ac608206d7b294b7407b9e1a336589eedd7" + integrity sha512-s+Xu0j7fHV9bLMSaOCuX76baQKcZfycAx0EzDmkxcFXPBiiFlI8l6rzwURdSJCjNcvLYXd8MLb4VkSNSq5ISZQ== + dependencies: + dnd-core "^9.3.4" + +react-dnd@9.3.4: + version "9.3.4" + resolved "https://registry.yarnpkg.com/react-dnd/-/react-dnd-9.3.4.tgz#ebab4b5b430b72f3580c058a29298054e1f9d2b8" + integrity sha512-UUtyoHFRrryMxVMEGYa3EdZIdibnys/ax7ZRs6CKpETHlnJQOFhHE3rpI+ManvKS0o3MFc1DZ+aoudAFtrOvFA== + dependencies: + "@types/hoist-non-react-statics" "^3.3.1" + "@types/shallowequal" "^1.1.1" + dnd-core "^9.3.4" + hoist-non-react-statics "^3.3.0" + shallowequal "^1.1.0" + +react-document-title@2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/react-document-title/-/react-document-title-2.0.3.tgz#bbf922a0d71412fc948245e4283b2412df70f2b9" + integrity sha1-u/kioNcUEvyUgkXkKDskEt9w8rk= + dependencies: + prop-types "^15.5.6" + react-side-effect "^1.0.2" + +react-dom@16.8.6: + version "16.8.6" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.8.6.tgz#71d6303f631e8b0097f56165ef608f051ff6e10f" + integrity sha512-1nL7PIq9LTL3fthPqwkvr2zY7phIPjYrT0jp4HjyEQrEROnw4dG41VVwi/wfoCneoleqrNX7iAD+pXebJZwrwA== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + prop-types "^15.6.2" + scheduler "^0.13.6" + +react-google-recaptcha@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/react-google-recaptcha/-/react-google-recaptcha-1.1.0.tgz#f33bef3e22e8c016820e80da48d573f516bb99e8" + integrity sha512-GMWZEsIKyBVG+iXfVMwtMVKFJATu5c+oguL/5i95H3Jb5d5CG4DY0W9t4QhdSSulgkXbZMgv0VSuGF/GV1ENTA== + dependencies: + prop-types "^15.5.0" + react-async-script "^1.0.0" + +react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.9.0: + version "16.9.0" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.9.0.tgz#21ca9561399aad0ff1a7701c01683e8ca981edcb" + integrity sha512-tJBzzzIgnnRfEm046qRcURvwQnZVXmuCbscxUO5RWrGTXpon2d4c8mI0D8WE6ydVIm29JiLB6+RslkIvym9Rjw== + +react-lazyload@2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/react-lazyload/-/react-lazyload-2.6.2.tgz#6a1660de6e8653632797539189d19d64e924482c" + integrity sha512-zbFiwI3H7W0/Qvb6T/ew2NiGe2wj+soYNW7vv5Dte1eZuJDvvyUOHo8GpYfEeWoP5x4Rree2Hwop+lCISalBwg== + +react-lifecycles-compat@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" + integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== + +react-measure@1.4.7: + version "1.4.7" + resolved "https://registry.yarnpkg.com/react-measure/-/react-measure-1.4.7.tgz#a1d2ca0dcfef04978b7ac263a765dcb6a0936fdb" + integrity sha1-odLKDc/vBJeLesJjp2XctqCTb9s= + dependencies: + get-node-dimensions "^1.2.0" + prop-types "^15.5.4" + resize-observer-polyfill "^1.4.1" + +react-popper@1.3.4: + version "1.3.4" + resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-1.3.4.tgz#f0cd3b0d30378e1f663b0d79bcc8614221652ced" + integrity sha512-9AcQB29V+WrBKk6X7p0eojd1f25/oJajVdMZkywIoAV6Ag7hzE1Mhyeup2Q1QnvFRtGQFQvtqfhlEoDAPfKAVA== + dependencies: + "@babel/runtime" "^7.1.2" + create-react-context "^0.3.0" + popper.js "^1.14.4" + prop-types "^15.6.1" + typed-styles "^0.0.7" + warning "^4.0.2" + +react-redux@7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.1.1.tgz#ce6eee1b734a7a76e0788b3309bf78ff6b34fa0a" + integrity sha512-QsW0vcmVVdNQzEkrgzh2W3Ksvr8cqpAv5FhEk7tNEft+5pp7rXxAudTz3VOPawRkLIepItpkEIyLcN/VVXzjTg== + dependencies: + "@babel/runtime" "^7.5.5" + hoist-non-react-statics "^3.3.0" + invariant "^2.2.4" + loose-envify "^1.4.0" + prop-types "^15.7.2" + react-is "^16.9.0" + +react-router-dom@5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.0.1.tgz#ee66f4a5d18b6089c361958e443489d6bab714be" + integrity sha512-zaVHSy7NN0G91/Bz9GD4owex5+eop+KvgbxXsP/O+iW1/Ln+BrJ8QiIR5a6xNPtrdTvLkxqlDClx13QO1uB8CA== + dependencies: + "@babel/runtime" "^7.1.2" + history "^4.9.0" + loose-envify "^1.3.1" + prop-types "^15.6.2" + react-router "5.0.1" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + +react-router@5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.0.1.tgz#04ee77df1d1ab6cb8939f9f01ad5702dbadb8b0f" + integrity sha512-EM7suCPNKb1NxcTZ2LEOWFtQBQRQXecLxVpdsP4DW4PbbqYWeRiLyV/Tt1SdCrvT2jcyXAXmVTmzvSzrPR63Bg== + dependencies: + "@babel/runtime" "^7.1.2" + history "^4.9.0" + hoist-non-react-statics "^3.1.0" + loose-envify "^1.3.1" + mini-create-react-context "^0.3.0" + path-to-regexp "^1.7.0" + prop-types "^15.6.2" + react-is "^16.6.0" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + +react-side-effect@^1.0.2: + version "1.2.0" + resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-1.2.0.tgz#0e940c78faba0c73b9b0eba9cd3dda8dfb7e7dae" + integrity sha512-v1ht1aHg5k/thv56DRcjw+WtojuuDHFUgGfc+bFHOWsF4ZK6C2V57DO0Or0GPsg6+LSTE0M6Ry/gfzhzSwbc5w== + dependencies: + shallowequal "^1.0.1" + +react-slider@0.11.2: + version "0.11.2" + resolved "https://registry.yarnpkg.com/react-slider/-/react-slider-0.11.2.tgz#ae014e1454c3cdd5f28b5c2495b2a08abd9971e6" + integrity sha512-y49ZwJJ7OcPdihgt71xYI8GRdAzpFuSLQR8b+cKotutxqf8MAEPEtqvWKlg+3ZQRe5PMN6oWbIb7wEYDF8XhNQ== + +react-text-truncate@0.15.0: + version "0.15.0" + resolved "https://registry.yarnpkg.com/react-text-truncate/-/react-text-truncate-0.15.0.tgz#eaba63bfa1516424f78318977ed43ab9eae2f057" + integrity sha512-dOwE34gFtJw7qERqdcyDZP1kYbGbhgM+Oh6V7TqgfiBWOBxHsBCw0D23NJPEVSY1TFQRgspZI8ULp3XwFqYdrA== + dependencies: + prop-types "^15.5.7" + +react-themeable@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/react-themeable/-/react-themeable-1.1.0.tgz#7d4466dd9b2b5fa75058727825e9f152ba379a0e" + integrity sha1-fURm3ZsrX6dQWHJ4JenxUro3mg4= + dependencies: + object-assign "^3.0.0" + +react-virtualized@9.21.1: + version "9.21.1" + resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.21.1.tgz#4dbbf8f0a1420e2de3abf28fbb77120815277b3a" + integrity sha512-E53vFjRRMCyUTEKuDLuGH1ld/9TFzjf/fFW816PE4HFXWZorESbSTYtiZz1oAjra0MminaUU1EnvUxoGuEFFPA== + dependencies: + babel-runtime "^6.26.0" + clsx "^1.0.1" + dom-helpers "^2.4.0 || ^3.0.0" + linear-layout-vector "0.0.1" + loose-envify "^1.3.0" + prop-types "^15.6.0" + react-lifecycles-compat "^3.0.4" + +react@16.8.6: + version "16.8.6" + resolved "https://registry.yarnpkg.com/react/-/react-16.8.6.tgz#ad6c3a9614fd3a4e9ef51117f54d888da01f2bbe" + integrity sha512-pC0uMkhLaHm11ZSJULfOBqV4tIZkx87ZLvbbQYunNixAAvjnC+snJCg0XQXn9VIsttVsbZP/H/ewzgsd5fxKXw== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + prop-types "^15.6.2" + scheduler "^0.13.6" + +read-pkg-up@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02" + integrity sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI= + dependencies: + find-up "^1.0.0" + read-pkg "^1.0.0" + +read-pkg-up@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-2.0.0.tgz#6b72a8048984e0c41e79510fd5e9fa99b3b549be" + integrity sha1-a3KoBImE4MQeeVEP1en6mbO1Sb4= + dependencies: + find-up "^2.0.0" + read-pkg "^2.0.0" + +read-pkg-up@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-3.0.0.tgz#3ed496685dba0f8fe118d0691dc51f4a1ff96f07" + integrity sha1-PtSWaF26D4/hGNBpHcUfSh/5bwc= + dependencies: + find-up "^2.0.0" + read-pkg "^3.0.0" + +read-pkg@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-1.1.0.tgz#f5ffaa5ecd29cb31c0474bca7d756b6bb29e3f28" + integrity sha1-9f+qXs0pyzHAR0vKfXVra7KePyg= + dependencies: + load-json-file "^1.0.0" + normalize-package-data "^2.3.2" + path-type "^1.0.0" + +read-pkg@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-2.0.0.tgz#8ef1c0623c6a6db0dc6713c4bfac46332b2368f8" + integrity sha1-jvHAYjxqbbDcZxPEv6xGMysjaPg= + dependencies: + load-json-file "^2.0.0" + normalize-package-data "^2.3.2" + path-type "^2.0.0" + +read-pkg@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-3.0.0.tgz#9cbc686978fee65d16c00e2b19c237fcf6e38389" + integrity sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k= + dependencies: + load-json-file "^4.0.0" + normalize-package-data "^2.3.2" + path-type "^3.0.0" + +"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6: + version "2.3.6" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf" + integrity sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +"readable-stream@2 || 3", readable-stream@^3.1.1: + version "3.4.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.4.0.tgz#a51c26754658e0a3c21dbf59163bd45ba6f447fc" + integrity sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +readable-stream@^1.0.33: + version "1.1.14" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" + integrity sha1-fPTFTvZI44EwhMY23SB54WbAgdk= + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "0.0.1" + string_decoder "~0.10.x" + +readdirp@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525" + integrity sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ== + dependencies: + graceful-fs "^4.1.11" + micromatch "^3.1.10" + readable-stream "^2.0.2" + +rechoir@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" + integrity sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q= + dependencies: + resolve "^1.1.6" + +redent@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/redent/-/redent-2.0.0.tgz#c1b2007b42d57eb1389079b3c8333639d5e1ccaa" + integrity sha1-wbIAe0LVfrE4kHmzyDM2OdXhzKo= + dependencies: + indent-string "^3.0.0" + strip-indent "^2.0.0" + +reduce-reducers@^0.4.3: + version "0.4.3" + resolved "https://registry.yarnpkg.com/reduce-reducers/-/reduce-reducers-0.4.3.tgz#8e052618801cd8fc2714b4915adaa8937eb6d66c" + integrity sha512-+CNMnI8QhgVMtAt54uQs3kUxC3Sybpa7Y63HR14uGLgI9/QR5ggHvpxwhGGe3wmx5V91YwqQIblN9k5lspAmGw== + +redux-actions@2.6.5: + version "2.6.5" + resolved "https://registry.yarnpkg.com/redux-actions/-/redux-actions-2.6.5.tgz#bdca548768ee99832a63910c276def85e821a27e" + integrity sha512-pFhEcWFTYNk7DhQgxMGnbsB1H2glqhQJRQrtPb96kD3hWiZRzXHwwmFPswg6V2MjraXRXWNmuP9P84tvdLAJmw== + dependencies: + invariant "^2.2.4" + just-curry-it "^3.1.0" + loose-envify "^1.4.0" + reduce-reducers "^0.4.3" + to-camel-case "^1.0.0" + +redux-batched-actions@0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/redux-batched-actions/-/redux-batched-actions-0.4.1.tgz#a8de8cef50a1db4f009d5222820c836515597e22" + integrity sha512-r6tLDyBP3U9cXNLEHs0n1mX5TQfmk6xE0Y9uinYZ5HOyAWDgIJxYqRRkU/bC6XrJ4nS7tasNbxaHJHVmf9UdkA== + +redux-localstorage@0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/redux-localstorage/-/redux-localstorage-0.4.1.tgz#faf6d719c581397294d811473ffcedee065c933c" + integrity sha1-+vbXGcWBOXKU2BFHP/zt7gZckzw= + +redux-thunk@2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" + integrity sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw== + +redux@4.0.4, redux@^4.0.1: + version "4.0.4" + resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.4.tgz#4ee1aeb164b63d6a1bcc57ae4aa0b6e6fa7a3796" + integrity sha512-vKv4WdiJxOWKxK0yRoaK3Y4pxxB0ilzVx6dszU2W8wLxlb2yikRph4iV/ymtdJ6ZxpBLFbyrxklnT5yBbQSl3Q== + dependencies: + loose-envify "^1.4.0" + symbol-observable "^1.2.0" + +regenerate-unicode-properties@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.1.0.tgz#ef51e0f0ea4ad424b77bf7cb41f3e015c70a3f0e" + integrity sha512-LGZzkgtLY79GeXLm8Dp0BVLdQlWICzBnJz/ipWUgo59qBaZ+BHtq51P2q1uVZlppMuUAT37SDk39qUbjTWB7bA== + dependencies: + regenerate "^1.4.0" + +regenerate@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.0.tgz#4a856ec4b56e4077c557589cae85e7a4c8869a11" + integrity sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg== + +regenerator-runtime@^0.11.0: + version "0.11.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" + integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== + +regenerator-runtime@^0.13.2: + version "0.13.3" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz#7cf6a77d8f5c6f60eb73c5fc1955b2ceb01e6bf5" + integrity sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw== + +regenerator-transform@^0.14.0: + version "0.14.1" + resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.14.1.tgz#3b2fce4e1ab7732c08f665dfdb314749c7ddd2fb" + integrity sha512-flVuee02C3FKRISbxhXl9mGzdbWUVHubl1SMaknjxkFB1/iqpJhArQUvRxOOPEc/9tAiX0BaQ28FJH10E4isSQ== + dependencies: + private "^0.1.6" + +regex-cache@^0.4.2: + version "0.4.4" + resolved "https://registry.yarnpkg.com/regex-cache/-/regex-cache-0.4.4.tgz#75bdc58a2a1496cec48a12835bc54c8d562336dd" + integrity sha512-nVIZwtCjkC9YgvWkpM55B5rBhBYRZhAaJbgcFYXXsHnbZ9UZI9nnVWYZpBlCqv9ho2eZryPnWrZGsOdPwVWXWQ== + dependencies: + is-equal-shallow "^0.1.3" + +regex-not@^1.0.0, regex-not@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" + integrity sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A== + dependencies: + extend-shallow "^3.0.2" + safe-regex "^1.1.0" + +regexp-tree@^0.1.6: + version "0.1.12" + resolved "https://registry.yarnpkg.com/regexp-tree/-/regexp-tree-0.1.12.tgz#28eaaa6e66eeb3527c15108a3ff740d9e574e420" + integrity sha512-TsXZ8+cv2uxMEkLfgwO0E068gsNMLfuYwMMhiUxf0Kw2Vcgzq93vgl6wIlIYuPmfMqMjfQ9zAporiozqCnwLuQ== + +regexpp@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-2.0.1.tgz#8d19d31cf632482b589049f8281f93dbcba4d07f" + integrity sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw== + +regexpu-core@^4.5.4: + version "4.5.5" + resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.5.5.tgz#aaffe61c2af58269b3e516b61a73790376326411" + integrity sha512-FpI67+ky9J+cDizQUJlIlNZFKual/lUkFr1AG6zOCpwZ9cLrg8UUVakyUQJD7fCDIe9Z2nwTQJNPyonatNmDFQ== + dependencies: + regenerate "^1.4.0" + regenerate-unicode-properties "^8.1.0" + regjsgen "^0.5.0" + regjsparser "^0.6.0" + unicode-match-property-ecmascript "^1.0.4" + unicode-match-property-value-ecmascript "^1.1.0" + +regjsgen@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.5.0.tgz#a7634dc08f89209c2049adda3525711fb97265dd" + integrity sha512-RnIrLhrXCX5ow/E5/Mh2O4e/oa1/jW0eaBKTSy3LaCj+M3Bqvm97GWDp2yUtzIs4LEn65zR2yiYGFqb2ApnzDA== + +regjsparser@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.6.0.tgz#f1e6ae8b7da2bae96c99399b868cd6c933a2ba9c" + integrity sha512-RQ7YyokLiQBomUJuUG8iGVvkgOLxwyZM8k6d3q5SAXpg4r5TZJZigKFvC6PpD+qQ98bCDC5YelPeA3EucDoNeQ== + dependencies: + jsesc "~0.5.0" + +remark-parse@^6.0.0: + version "6.0.3" + resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-6.0.3.tgz#c99131052809da482108413f87b0ee7f52180a3a" + integrity sha512-QbDXWN4HfKTUC0hHa4teU463KclLAnwpn/FBn87j9cKYJWWawbiLgMfP2Q4XwhxxuuuOxHlw+pSN0OKuJwyVvg== + dependencies: + collapse-white-space "^1.0.2" + is-alphabetical "^1.0.0" + is-decimal "^1.0.0" + is-whitespace-character "^1.0.0" + is-word-character "^1.0.0" + markdown-escapes "^1.0.0" + parse-entities "^1.1.0" + repeat-string "^1.5.4" + state-toggle "^1.0.0" + trim "0.0.1" + trim-trailing-lines "^1.0.0" + unherit "^1.0.4" + unist-util-remove-position "^1.0.0" + vfile-location "^2.0.0" + xtend "^4.0.1" + +remark-stringify@^6.0.0: + version "6.0.4" + resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-6.0.4.tgz#16ac229d4d1593249018663c7bddf28aafc4e088" + integrity sha512-eRWGdEPMVudijE/psbIDNcnJLRVx3xhfuEsTDGgH4GsFF91dVhw5nhmnBppafJ7+NWINW6C7ZwWbi30ImJzqWg== + dependencies: + ccount "^1.0.0" + is-alphanumeric "^1.0.0" + is-decimal "^1.0.0" + is-whitespace-character "^1.0.0" + longest-streak "^2.0.1" + markdown-escapes "^1.0.0" + markdown-table "^1.1.0" + mdast-util-compact "^1.0.0" + parse-entities "^1.0.2" + repeat-string "^1.5.4" + state-toggle "^1.0.0" + stringify-entities "^1.0.1" + unherit "^1.0.4" + xtend "^4.0.1" + +remark@^10.0.1: + version "10.0.1" + resolved "https://registry.yarnpkg.com/remark/-/remark-10.0.1.tgz#3058076dc41781bf505d8978c291485fe47667df" + integrity sha512-E6lMuoLIy2TyiokHprMjcWNJ5UxfGQjaMSMhV+f4idM625UjjK4j798+gPs5mfjzDE6vL0oFKVeZM6gZVSVrzQ== + dependencies: + remark-parse "^6.0.0" + remark-stringify "^6.0.0" + unified "^7.0.0" + +remove-bom-buffer@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/remove-bom-buffer/-/remove-bom-buffer-3.0.0.tgz#c2bf1e377520d324f623892e33c10cac2c252b53" + integrity sha512-8v2rWhaakv18qcvNeli2mZ/TMTL2nEyAKRvzo1WtnZBl15SHyEhrCu2/xKlJyUFKHiHgfXIyuY6g2dObJJycXQ== + dependencies: + is-buffer "^1.1.5" + is-utf8 "^0.2.1" + +remove-bom-stream@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/remove-bom-stream/-/remove-bom-stream-1.2.0.tgz#05f1a593f16e42e1fb90ebf59de8e569525f9523" + integrity sha1-BfGlk/FuQuH7kOv1nejlaVJflSM= + dependencies: + remove-bom-buffer "^3.0.0" + safe-buffer "^5.1.0" + through2 "^2.0.3" + +remove-trailing-separator@^1.0.1, remove-trailing-separator@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" + integrity sha1-wkvOKig62tW8P1jg1IJJuSN52O8= + +repeat-element@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce" + integrity sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g== + +repeat-string@^1.5.2, repeat-string@^1.5.4, repeat-string@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" + integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= + +replace-ext@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-0.0.1.tgz#29bbd92078a739f0bcce2b4ee41e837953522924" + integrity sha1-KbvZIHinOfC8zitO5B6DeVNSKSQ= + +replace-ext@1.0.0, replace-ext@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.0.tgz#de63128373fcbf7c3ccfa4de5a480c45a67958eb" + integrity sha1-3mMSg3P8v3w8z6TeWkgMRaZ5WOs= + +replace-homedir@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/replace-homedir/-/replace-homedir-1.0.0.tgz#e87f6d513b928dde808260c12be7fec6ff6e798c" + integrity sha1-6H9tUTuSjd6AgmDBK+f+xv9ueYw= + dependencies: + homedir-polyfill "^1.0.1" + is-absolute "^1.0.0" + remove-trailing-separator "^1.1.0" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= + +require-main-filename@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1" + integrity sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE= + +require-nocache@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/require-nocache/-/require-nocache-1.0.0.tgz#a665d0b60a07e8249875790a4d350219d3c85fa3" + integrity sha1-pmXQtgoH6CSYdXkKTTUCGdPIX6M= + +reselect@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.0.0.tgz#f2529830e5d3d0e021408b246a206ef4ea4437f7" + integrity sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA== + +resize-observer-polyfill@^1.4.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" + integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== + +resolve-dir@^1.0.0, resolve-dir@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-1.0.1.tgz#79a40644c362be82f26effe739c9bb5382046f43" + integrity sha1-eaQGRMNivoLybv/nOcm7U4IEb0M= + dependencies: + expand-tilde "^2.0.0" + global-modules "^1.0.0" + +resolve-from@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748" + integrity sha1-six699nWiBvItuZTM17rywoYh0g= + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + +resolve-options@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/resolve-options/-/resolve-options-1.1.0.tgz#32bb9e39c06d67338dc9378c0d6d6074566ad131" + integrity sha1-MrueOcBtZzONyTeMDW1gdFZq0TE= + dependencies: + value-or-function "^3.0.0" + +resolve-pathname@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/resolve-pathname/-/resolve-pathname-2.2.0.tgz#7e9ae21ed815fd63ab189adeee64dc831eefa879" + integrity sha512-bAFz9ld18RzJfddgrO2e/0S2O81710++chRMUxHjXOYKF6jTAMrUNZrEZ1PvV0zlhfjidm08iRPdTLPno1FuRg== + +resolve-url@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" + integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= + +resolve@^1.1.6, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.10.1, resolve@^1.12.0, resolve@^1.3.2, resolve@^1.4.0: + version "1.12.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.12.0.tgz#3fc644a35c84a48554609ff26ec52b66fa577df6" + integrity sha512-B/dOmuoAik5bKcD6s6nXDCjzUKnaDvdkRyAk6rsmsKLipWj4797iothd7jmmUhWTfinVMU+wc56rYKsit2Qy4w== + dependencies: + path-parse "^1.0.6" + +restore-cursor@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" + integrity sha1-n37ih/gv0ybU/RYpI9YhKe7g368= + dependencies: + onetime "^2.0.0" + signal-exit "^3.0.2" + +ret@~0.1.10: + version "0.1.15" + resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" + integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== + +reusify@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" + integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== + +rgb-regex@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/rgb-regex/-/rgb-regex-1.0.1.tgz#c0e0d6882df0e23be254a475e8edd41915feaeb1" + integrity sha1-wODWiC3w4jviVKR16O3UGRX+rrE= + +rgb@~0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/rgb/-/rgb-0.1.0.tgz#be27b291e8feffeac1bd99729721bfa40fc037b5" + integrity sha1-vieykej+/+rBvZlylyG/pA/AN7U= + +rgba-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/rgba-regex/-/rgba-regex-1.0.0.tgz#43374e2e2ca0968b0ef1523460b7d730ff22eeb3" + integrity sha1-QzdOLiyglosO8VI0YLfXMP8i7rM= + +rimraf@2.6.3: + version "2.6.3" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab" + integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA== + dependencies: + glob "^7.1.3" + +rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.3: + version "2.7.1" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" + integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== + dependencies: + glob "^7.1.3" + +rimraf@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.0.tgz#614176d4b3010b75e5c390eb0ee96f6dc0cebb9b" + integrity sha512-NDGVxTsjqfunkds7CqsOiEnxln4Bo7Nddl3XhS4pXg5OzwkLqJ971ZVAAnB+DDLnF76N+VnDEiBHaVV8I06SUg== + dependencies: + glob "^7.1.3" + +ripemd160@^2.0.0, ripemd160@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.2.tgz#a1c1a6f624751577ba5d07914cbc92850585890c" + integrity sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA== + dependencies: + hash-base "^3.0.0" + inherits "^2.0.1" + +run-async@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0" + integrity sha1-A3GrSuC91yDUFm19/aZP96RFpsA= + dependencies: + is-promise "^2.1.0" + +run-parallel@^1.1.9: + version "1.1.9" + resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.1.9.tgz#c9dd3a7cf9f4b2c4b6244e173a6ed866e61dd679" + integrity sha512-DEqnSRTDw/Tc3FXf49zedI638Z9onwUotBMiUFKmrO2sdFKIbXamXGQ3Axd4qgphxKB4kw/qP1w5kTxnfU1B9Q== + +run-queue@^1.0.0, run-queue@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/run-queue/-/run-queue-1.0.3.tgz#e848396f057d223f24386924618e25694161ec47" + integrity sha1-6Eg5bwV9Ij8kOGkkYY4laUFh7Ec= + dependencies: + aproba "^1.1.1" + +run-sequence@2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/run-sequence/-/run-sequence-2.2.1.tgz#1ce643da36fd8c7ea7e1a9329da33fc2b8898495" + integrity sha512-qkzZnQWMZjcKbh3CNly2srtrkaO/2H/SI5f2eliMCapdRD3UhMrwjfOAZJAnZ2H8Ju4aBzFZkBGXUqFs9V0yxw== + dependencies: + chalk "^1.1.3" + fancy-log "^1.3.2" + plugin-error "^0.1.2" + +rxjs@^6.4.0: + version "6.5.2" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.2.tgz#2e35ce815cd46d84d02a209fb4e5921e051dbec7" + integrity sha512-HUb7j3kvb7p7eCUHE3FqjoDsC1xfZQ4AHFWfTKSpZ+sAhhz5X1WX0ZuUqWbzB2QhSLp3DoLUG+hMdEDKqWo2Zg== + dependencies: + tslib "^1.9.0" + +safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519" + integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg== + +safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +safe-json-parse@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/safe-json-parse/-/safe-json-parse-1.0.1.tgz#3e76723e38dfdda13c9b1d29a1e07ffee4b30b57" + integrity sha1-PnZyPjjf3aE8mx0poeB//uSzC1c= + +safe-regex@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" + integrity sha1-QKNmnzsHfR6UPURinhV91IAjvy4= + dependencies: + ret "~0.1.10" + +"safer-buffer@>= 2.1.2 < 3": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +sane@^1.6.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/sane/-/sane-1.7.0.tgz#b3579bccb45c94cf20355cc81124990dfd346e30" + integrity sha1-s1ebzLRclM8gNVzIESSZDf00bjA= + dependencies: + anymatch "^1.3.0" + exec-sh "^0.2.0" + fb-watchman "^2.0.0" + minimatch "^3.0.2" + minimist "^1.1.1" + walker "~1.0.5" + watch "~0.10.0" + +sax@^1.2.4, sax@~1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" + integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== + +scheduler@^0.13.6: + version "0.13.6" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.13.6.tgz#466a4ec332467b31a91b9bf74e5347072e4cd889" + integrity sha512-IWnObHt413ucAYKsD9J1QShUKkbKLQQHdxRyw73sw4FN26iWr3DY/H34xGPe4nmL1DwXyWmSWmMrA9TfQbE/XQ== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + +schema-utils@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-1.0.0.tgz#0b79a93204d7b600d4b2850d1f66c2a34951c770" + integrity sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g== + dependencies: + ajv "^6.1.0" + ajv-errors "^1.0.0" + ajv-keywords "^3.1.0" + +schema-utils@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.1.0.tgz#940363b6b1ec407800a22951bdcc23363c039393" + integrity sha512-g6SViEZAfGNrToD82ZPUjq52KUPDYc+fN5+g6Euo5mLokl/9Yx14z0Cu4RR1m55HtBXejO0sBt+qw79axN+Fiw== + dependencies: + ajv "^6.1.0" + ajv-keywords "^3.1.0" + +seamless-immutable@^7.1.3: + version "7.1.4" + resolved "https://registry.yarnpkg.com/seamless-immutable/-/seamless-immutable-7.1.4.tgz#6e9536def083ddc4dea0207d722e0e80d0f372f8" + integrity sha512-XiUO1QP4ki4E2PHegiGAlu6r82o5A+6tRh7IkGGTVg/h+UoeX4nFBeCGPOhb4CYjvkqsfm/TUtvOMYC1xmV30A== + +section-iterator@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/section-iterator/-/section-iterator-2.0.0.tgz#bf444d7afeeb94ad43c39ad2fb26151627ccba2a" + integrity sha1-v0RNev7rlK1Dw5rS+yYVFifMuio= + +select@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d" + integrity sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0= + +semver-greatest-satisfied-range@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/semver-greatest-satisfied-range/-/semver-greatest-satisfied-range-1.1.0.tgz#13e8c2658ab9691cb0cd71093240280d36f77a5b" + integrity sha1-E+jCZYq5aRywzXEJMkAoDTb3els= + dependencies: + sver-compat "^1.5.0" + +"semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.6.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" + integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== + +semver@^6.1.2, semver@^6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" + integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== + +serialize-javascript@^1.7.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-1.9.0.tgz#5b77019d7c3b85fe91b33ae424c53dcbfb6618bd" + integrity sha512-UkGlcYMtw4d9w7YfCtJFgdRTps8N4L0A48R+SmcGL57ki1+yHwJXnalk5bjgrw+ljv6SfzjzPjhohod2qllg/Q== + +set-blocking@^2.0.0, set-blocking@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= + +set-value@^2.0.0, set-value@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b" + integrity sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw== + dependencies: + extend-shallow "^2.0.1" + is-extendable "^0.1.1" + is-plain-object "^2.0.3" + split-string "^3.0.1" + +setimmediate@^1.0.4, setimmediate@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" + integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU= + +sha.js@^2.4.0, sha.js@^2.4.8: + version "2.4.11" + resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7" + integrity sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ== + dependencies: + inherits "^2.0.1" + safe-buffer "^5.0.1" + +shallow-equal@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/shallow-equal/-/shallow-equal-1.2.0.tgz#fd828d2029ff4e19569db7e19e535e94e2d1f5cc" + integrity sha512-Z21pVxR4cXsfwpMKMhCEIO1PCi5sp7KEp+CmOpBQ+E8GpHwKOw2sEzk7sgblM3d/j4z4gakoWEoPcjK0VJQogA== + +shallowequal@^1.0.1, shallowequal@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" + integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ== + +shebang-command@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" + integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo= + dependencies: + shebang-regex "^1.0.0" + +shebang-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" + integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM= + +signal-exit@^3.0.0, signal-exit@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" + integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0= + +signalr@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/signalr/-/signalr-2.4.1.tgz#57cde6e0bf43265028e0ca3d954a8577b9e336e2" + integrity sha512-HhIcA9kOE9WBs/DPHd+9jN90GDeSD7RRAETcmxn80laDBQmkQeHblzGBNw4rBzn1behe2WiFYQcbKyx11H3ADw== + dependencies: + jquery ">=1.6.4" + +simple-swizzle@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" + integrity sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo= + dependencies: + is-arrayish "^0.3.1" + +slash@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55" + integrity sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU= + +slash@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44" + integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A== + +slash@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" + integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== + +slice-ansi@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-2.1.0.tgz#cacd7693461a637a5788d92a7dd4fba068e81636" + integrity sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ== + dependencies: + ansi-styles "^3.2.0" + astral-regex "^1.0.0" + is-fullwidth-code-point "^2.0.0" + +snapdragon-node@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" + integrity sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw== + dependencies: + define-property "^1.0.0" + isobject "^3.0.0" + snapdragon-util "^3.0.1" + +snapdragon-util@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2" + integrity sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ== + dependencies: + kind-of "^3.2.0" + +snapdragon@^0.8.1: + version "0.8.2" + resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.2.tgz#64922e7c565b0e14204ba1aa7d6964278d25182d" + integrity sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg== + dependencies: + base "^0.11.1" + debug "^2.2.0" + define-property "^0.2.5" + extend-shallow "^2.0.1" + map-cache "^0.2.2" + source-map "^0.5.6" + source-map-resolve "^0.5.0" + use "^3.1.0" + +sort-keys@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad" + integrity sha1-RBttTTRnmPG05J6JIK37oOVD+a0= + dependencies: + is-plain-obj "^1.0.0" + +source-list-map@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34" + integrity sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw== + +source-map-resolve@^0.5.0, source-map-resolve@^0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.2.tgz#72e2cc34095543e43b2c62b2c4c10d4a9054f259" + integrity sha512-MjqsvNwyz1s0k81Goz/9vRBe9SZdB09Bdw+/zYyO+3CuPk6fouTaxscHkgtE8jKvf01kVfl8riHzERQ/kefaSA== + dependencies: + atob "^2.1.1" + decode-uri-component "^0.2.0" + resolve-url "^0.2.1" + source-map-url "^0.4.0" + urix "^0.1.0" + +source-map-support@~0.5.12: + version "0.5.13" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.13.tgz#31b24a9c2e73c2de85066c0feb7d44767ed52932" + integrity sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map-url@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3" + integrity sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM= + +source-map@^0.5.0, source-map@^0.5.1, source-map@^0.5.3, source-map@^0.5.6: + version "0.5.7" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" + integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= + +source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +sparkles@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/sparkles/-/sparkles-1.0.1.tgz#008db65edce6c50eec0c5e228e1945061dd0437c" + integrity sha512-dSO0DDYUahUt/0/pD/Is3VIm5TGJjludZ0HVymmhYF6eNA53PVLhnUk0znSYbH8IYBuJdCE+1luR22jNLMaQdw== + +spdx-correct@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.0.tgz#fb83e504445268f154b074e218c87c003cd31df4" + integrity sha512-lr2EZCctC2BNR7j7WzJ2FpDznxky1sjfxvvYEyzxNyb6lZXHODmEoJeFu4JupYlkfha1KZpJyoqiJ7pgA1qq8Q== + dependencies: + spdx-expression-parse "^3.0.0" + spdx-license-ids "^3.0.0" + +spdx-exceptions@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.2.0.tgz#2ea450aee74f2a89bfb94519c07fcd6f41322977" + integrity sha512-2XQACfElKi9SlVb1CYadKDXvoajPgBVPn/gOQLrTvHdElaVhr7ZEbqJaRnJLVNeaI4cMEAgVCeBMKF6MWRDCRA== + +spdx-expression-parse@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz#99e119b7a5da00e05491c9fa338b7904823b41d0" + integrity sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg== + dependencies: + spdx-exceptions "^2.1.0" + spdx-license-ids "^3.0.0" + +spdx-license-ids@^3.0.0: + version "3.0.5" + resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz#3694b5804567a458d3c8045842a6358632f62654" + integrity sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q== + +specificity@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/specificity/-/specificity-0.4.1.tgz#aab5e645012db08ba182e151165738d00887b019" + integrity sha512-1klA3Gi5PD1Wv9Q0wUoOQN1IWAuPu0D1U03ThXTr0cJ20+/iq2tHSDnK7Kk/0LXJ1ztUB2/1Os0wKmfyNgUQfg== + +split-string@^3.0.1, split-string@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" + integrity sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw== + dependencies: + extend-shallow "^3.0.0" + +split@0.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/split/-/split-0.3.3.tgz#cd0eea5e63a211dfff7eb0f091c4133e2d0dd28f" + integrity sha1-zQ7qXmOiEd//frDwkcQTPi0N0o8= + dependencies: + through "2" + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= + +ssri@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/ssri/-/ssri-6.0.1.tgz#2a3c41b28dd45b62b63676ecb74001265ae9edd8" + integrity sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA== + dependencies: + figgy-pudding "^3.5.1" + +stable@^0.1.8: + version "0.1.8" + resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf" + integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w== + +stack-trace@0.0.10: + version "0.0.10" + resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" + integrity sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA= + +state-toggle@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/state-toggle/-/state-toggle-1.0.2.tgz#75e93a61944116b4959d665c8db2d243631d6ddc" + integrity sha512-8LpelPGR0qQM4PnfLiplOQNJcIN1/r2Gy0xKB2zKnIW2YzPMt2sR4I/+gtPjhN7Svh9kw+zqEg2SFwpBO9iNiw== + +static-extend@^0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" + integrity sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY= + dependencies: + define-property "^0.2.5" + object-copy "^0.1.0" + +stream-browserify@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.2.tgz#87521d38a44aa7ee91ce1cd2a47df0cb49dd660b" + integrity sha512-nX6hmklHs/gr2FuxYDltq8fJA1GDlxKQCz8O/IM4atRqBH8OORmBNgfvW5gG10GT/qQ9u0CzIvr2X5Pkt6ntqg== + dependencies: + inherits "~2.0.1" + readable-stream "^2.0.2" + +stream-combiner@~0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/stream-combiner/-/stream-combiner-0.0.4.tgz#4d5e433c185261dde623ca3f44c586bcf5c4ad14" + integrity sha1-TV5DPBhSYd3mI8o/RMWGvPXErRQ= + dependencies: + duplexer "~0.1.1" + +stream-each@^1.1.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/stream-each/-/stream-each-1.2.3.tgz#ebe27a0c389b04fbcc233642952e10731afa9bae" + integrity sha512-vlMC2f8I2u/bZGqkdfLQW/13Zihpej/7PmSiMQsbYddxuTsJp8vRe2x2FvVExZg7FaOds43ROAuFJwPR4MTZLw== + dependencies: + end-of-stream "^1.1.0" + stream-shift "^1.0.0" + +stream-exhaust@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/stream-exhaust/-/stream-exhaust-1.0.2.tgz#acdac8da59ef2bc1e17a2c0ccf6c320d120e555d" + integrity sha512-b/qaq/GlBK5xaq1yrK9/zFcyRSTNxmcZwFLGSTG0mXgZl/4Z6GgiyYOXOvY7N3eEvFRAG1bkDRz5EPGSvPYQlw== + +stream-http@^2.7.2: + version "2.8.3" + resolved "https://registry.yarnpkg.com/stream-http/-/stream-http-2.8.3.tgz#b2d242469288a5a27ec4fe8933acf623de6514fc" + integrity sha512-+TSkfINHDo4J+ZobQLWiMouQYB+UVYFttRA94FpEzzJ7ZdqcL4uUUQ7WkdkI4DSozGmgBUE/a47L+38PenXhUw== + dependencies: + builtin-status-codes "^3.0.0" + inherits "^2.0.1" + readable-stream "^2.3.6" + to-arraybuffer "^1.0.0" + xtend "^4.0.0" + +stream-shift@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.0.tgz#d5c752825e5367e786f78e18e445ea223a155952" + integrity sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI= + +streamqueue@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/streamqueue/-/streamqueue-1.1.2.tgz#6c99c7c20d62b57f5819296bf9ec942542380192" + integrity sha512-CHUpqa+1BM99z7clQz9W6L9ZW4eXRRQCR0H+utVAGGvNo2ePlJAFjhdK0IjunaBbY/gWKJawk5kpJeyz0EXxRA== + dependencies: + isstream "^0.1.2" + readable-stream "^2.3.3" + +strict-uri-encode@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" + integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM= + +string-template@~0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add" + integrity sha1-QpMuWYo1LQH8IuwzZ9nYTuxsmt0= + +string-width@^1.0.1, string-width@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" + integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M= + dependencies: + code-point-at "^1.0.0" + is-fullwidth-code-point "^1.0.0" + strip-ansi "^3.0.0" + +"string-width@^1.0.2 || 2", string-width@^2.0.0, string-width@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" + integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== + dependencies: + is-fullwidth-code-point "^2.0.0" + strip-ansi "^4.0.0" + +string-width@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" + integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w== + dependencies: + emoji-regex "^7.0.1" + is-fullwidth-code-point "^2.0.0" + strip-ansi "^5.1.0" + +string-width@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.1.0.tgz#ba846d1daa97c3c596155308063e075ed1c99aff" + integrity sha512-NrX+1dVVh+6Y9dnQ19pR0pP4FiEIlUvdTGn8pw6CKTNq5sgib2nIhmUNT5TAmhWmvKr3WcxBcP3E8nWezuipuQ== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^5.2.0" + +string_decoder@0.10, string_decoder@~0.10.x: + version "0.10.31" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" + integrity sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ= + +string_decoder@^1.0.0, string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + +stringify-entities@^1.0.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/stringify-entities/-/stringify-entities-1.3.2.tgz#a98417e5471fd227b3e45d3db1861c11caf668f7" + integrity sha512-nrBAQClJAPN2p+uGCVJRPIPakKeKWZ9GtBCmormE7pWOSlHat7+x5A8gx85M7HM5Dt0BP3pP5RhVW77WdbJJ3A== + dependencies: + character-entities-html4 "^1.0.0" + character-entities-legacy "^1.0.0" + is-alphanumerical "^1.0.0" + is-hexadecimal "^1.0.0" + +strip-ansi@^3.0.0, strip-ansi@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" + integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8= + dependencies: + ansi-regex "^2.0.0" + +strip-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" + integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8= + dependencies: + ansi-regex "^3.0.0" + +strip-ansi@^5.1.0, strip-ansi@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" + integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== + dependencies: + ansi-regex "^4.1.0" + +strip-bom-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-bom-stream/-/strip-bom-stream-2.0.0.tgz#f87db5ef2613f6968aa545abfe1ec728b6a829ca" + integrity sha1-+H217yYT9paKpUWr/h7HKLaoKco= + dependencies: + first-chunk-stream "^2.0.0" + strip-bom "^2.0.0" + +strip-bom-string@1.X: + version "1.0.0" + resolved "https://registry.yarnpkg.com/strip-bom-string/-/strip-bom-string-1.0.0.tgz#e5211e9224369fbb81d633a2f00044dc8cedad92" + integrity sha1-5SEekiQ2n7uB1jOi8ABE3IztrZI= + +strip-bom@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e" + integrity sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4= + dependencies: + is-utf8 "^0.2.0" + +strip-bom@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" + integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM= + +strip-eof@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" + integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8= + +strip-indent@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-2.0.0.tgz#5ef8db295d01e6ed6cbf7aab96998d7822527b68" + integrity sha1-XvjbKV0B5u1sv3qrlpmNeCJSe2g= + +strip-json-comments@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.0.1.tgz#85713975a91fb87bf1b305cca77395e40d2a64a7" + integrity sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw== + +strip-json-comments@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" + integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= + +style-loader@0.23.1: + version "0.23.1" + resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-0.23.1.tgz#cb9154606f3e771ab6c4ab637026a1049174d925" + integrity sha512-XK+uv9kWwhZMZ1y7mysB+zoihsEj4wneFWAS5qoiLwzW0WzSqMrrsIy+a3zkQJq0ipFtBpX5W3MqyRIBF/WFGg== + dependencies: + loader-utils "^1.1.0" + schema-utils "^1.0.0" + +style-search@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/style-search/-/style-search-0.1.0.tgz#7958c793e47e32e07d2b5cafe5c0bf8e12e77902" + integrity sha1-eVjHk+R+MuB9K1yv5cC/jhLneQI= + +stylehacks@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-4.0.3.tgz#6718fcaf4d1e07d8a1318690881e8d96726a71d5" + integrity sha512-7GlLk9JwlElY4Y6a/rmbH2MhVlTyVmiJd1PfTCqFaIBEGMYNsrO/v3SeGTdhBThLg4Z+NbOk/qFMwCa+J+3p/g== + dependencies: + browserslist "^4.0.0" + postcss "^7.0.0" + postcss-selector-parser "^3.0.0" + +stylelint-order@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/stylelint-order/-/stylelint-order-3.0.1.tgz#f1dd5e39345d04b684a6f04f1133cafa28606175" + integrity sha512-isVEJ1oUoVB7bb5pYop96KYOac4c+tLOqa5dPtAEwAwQUVSbi7OPFbfaCclcTjOlXicymasLpwhRirhFWh93yw== + dependencies: + lodash "^4.17.14" + postcss "^7.0.17" + postcss-sorting "^5.0.1" + +stylelint@10.1.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-10.1.0.tgz#1bc4c4ce878107e7c396b19226d91ba28268911a" + integrity sha512-OmlUXrgzEMLQYj1JPTpyZPR9G4bl0StidfHnGJEMpdiQ0JyTq0MPg1xkHk1/xVJ2rTPESyJCDWjG8Kbpoo7Kuw== + dependencies: + autoprefixer "^9.5.1" + balanced-match "^1.0.0" + chalk "^2.4.2" + cosmiconfig "^5.2.0" + debug "^4.1.1" + execall "^2.0.0" + file-entry-cache "^5.0.1" + get-stdin "^7.0.0" + global-modules "^2.0.0" + globby "^9.2.0" + globjoin "^0.1.4" + html-tags "^3.0.0" + ignore "^5.0.6" + import-lazy "^4.0.0" + imurmurhash "^0.1.4" + known-css-properties "^0.14.0" + leven "^3.1.0" + lodash "^4.17.11" + log-symbols "^3.0.0" + mathml-tag-names "^2.1.0" + meow "^5.0.0" + micromatch "^4.0.0" + normalize-selector "^0.2.0" + pify "^4.0.1" + postcss "^7.0.14" + postcss-html "^0.36.0" + postcss-jsx "^0.36.1" + postcss-less "^3.1.4" + postcss-markdown "^0.36.0" + postcss-media-query-parser "^0.2.3" + postcss-reporter "^6.0.1" + postcss-resolve-nested-selector "^0.1.1" + postcss-safe-parser "^4.0.1" + postcss-sass "^0.3.5" + postcss-scss "^2.0.0" + postcss-selector-parser "^3.1.0" + postcss-syntax "^0.36.2" + postcss-value-parser "^3.3.1" + resolve-from "^5.0.0" + signal-exit "^3.0.2" + slash "^3.0.0" + specificity "^0.4.1" + string-width "^4.1.0" + strip-ansi "^5.2.0" + style-search "^0.1.0" + sugarss "^2.0.0" + svg-tags "^1.0.0" + table "^5.2.3" + +sugarss@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/sugarss/-/sugarss-2.0.0.tgz#ddd76e0124b297d40bf3cca31c8b22ecb43bc61d" + integrity sha512-WfxjozUk0UVA4jm+U1d736AUpzSrNsQcIbyOkoE364GrtWmIrFdk5lksEupgWMD4VaT/0kVx1dobpiDumSgmJQ== + dependencies: + postcss "^7.0.2" + +supports-color@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" + integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc= + +supports-color@^5.3.0, supports-color@^5.4.0, supports-color@^5.5.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +supports-color@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3" + integrity sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ== + dependencies: + has-flag "^3.0.0" + +sver-compat@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/sver-compat/-/sver-compat-1.5.0.tgz#3cf87dfeb4d07b4a3f14827bc186b3fd0c645cd8" + integrity sha1-PPh9/rTQe0o/FIJ7wYaz/QxkXNg= + dependencies: + es6-iterator "^2.0.1" + es6-symbol "^3.1.1" + +svg-tags@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/svg-tags/-/svg-tags-1.0.0.tgz#58f71cee3bd519b59d4b2a843b6c7de64ac04764" + integrity sha1-WPcc7jvVGbWdSyqEO2x95krAR2Q= + +svgo@^1.0.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/svgo/-/svgo-1.3.0.tgz#bae51ba95ded9a33a36b7c46ce9c359ae9154313" + integrity sha512-MLfUA6O+qauLDbym+mMZgtXCGRfIxyQoeH6IKVcFslyODEe/ElJNwr0FohQ3xG4C6HK6bk3KYPPXwHVJk3V5NQ== + dependencies: + chalk "^2.4.1" + coa "^2.0.2" + css-select "^2.0.0" + css-select-base-adapter "^0.1.1" + css-tree "1.0.0-alpha.33" + csso "^3.5.1" + js-yaml "^3.13.1" + mkdirp "~0.5.1" + object.values "^1.1.0" + sax "~1.2.4" + stable "^0.1.8" + unquote "~1.1.1" + util.promisify "~1.0.0" + +symbol-observable@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" + integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== + +table@^5.2.3: + version "5.4.6" + resolved "https://registry.yarnpkg.com/table/-/table-5.4.6.tgz#1292d19500ce3f86053b05f0e8e7e4a3bb21079e" + integrity sha512-wmEc8m4fjnob4gt5riFRtTu/6+4rSe12TpAELNSqHMfF3IqnA+CH37USM6/YR3qRZv7e56kAEAtd6nKZaxe0Ug== + dependencies: + ajv "^6.10.2" + lodash "^4.17.14" + slice-ansi "^2.1.0" + string-width "^3.0.0" + +tapable@^1.0.0, tapable@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2" + integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA== + +tar@^4: + version "4.4.10" + resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.10.tgz#946b2810b9a5e0b26140cf78bea6b0b0d689eba1" + integrity sha512-g2SVs5QIxvo6OLp0GudTqEf05maawKUxXru104iaayWA09551tFCTI8f1Asb4lPfkBr91k07iL4c11XO3/b0tA== + dependencies: + chownr "^1.1.1" + fs-minipass "^1.2.5" + minipass "^2.3.5" + minizlib "^1.2.1" + mkdirp "^0.5.0" + safe-buffer "^5.1.2" + yallist "^3.0.3" + +terser-webpack-plugin@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-1.4.1.tgz#61b18e40eaee5be97e771cdbb10ed1280888c2b4" + integrity sha512-ZXmmfiwtCLfz8WKZyYUuuHf3dMYEjg8NrjHMb0JqHVHVOSkzp3cW2/XG1fP3tRhqEqSzMwzzRQGtAPbs4Cncxg== + dependencies: + cacache "^12.0.2" + find-cache-dir "^2.1.0" + is-wsl "^1.1.0" + schema-utils "^1.0.0" + serialize-javascript "^1.7.0" + source-map "^0.6.1" + terser "^4.1.2" + webpack-sources "^1.4.0" + worker-farm "^1.7.0" + +terser@^4.1.2: + version "4.2.1" + resolved "https://registry.yarnpkg.com/terser/-/terser-4.2.1.tgz#1052cfe17576c66e7bc70fcc7119f22b155bdac1" + integrity sha512-cGbc5utAcX4a9+2GGVX4DsenG6v0x3glnDi5hx8816X1McEAwPlPgRtXPJzSBsbpILxZ8MQMT0KvArLuE0HP5A== + dependencies: + commander "^2.20.0" + source-map "~0.6.1" + source-map-support "~0.5.12" + +text-table@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" + integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= + +through2-filter@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/through2-filter/-/through2-filter-3.0.0.tgz#700e786df2367c2c88cd8aa5be4cf9c1e7831254" + integrity sha512-jaRjI2WxN3W1V8/FMZ9HKIBXixtiqs3SQSX4/YGIiP3gL6djW48VoZq9tDqeCWs3MT8YY5wb/zli8VW8snY1CA== + dependencies: + through2 "~2.0.0" + xtend "~4.0.0" + +through2@2.X, through2@^2.0.0, through2@^2.0.1, through2@^2.0.3, through2@~2.0.0: + version "2.0.5" + resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd" + integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ== + dependencies: + readable-stream "~2.3.6" + xtend "~4.0.1" + +through2@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/through2/-/through2-3.0.1.tgz#39276e713c3302edf9e388dd9c812dd3b825bd5a" + integrity sha512-M96dvTalPT3YbYLaKaCuwu+j06D/8Jfib0o/PxbVt6Amhv3dUAtW6rTV1jPgJSBG83I/e04Y6xkVdVhSRhi0ww== + dependencies: + readable-stream "2 || 3" + +through@2, through@^2.3.6, through@^2.3.8, through@~2.3, through@~2.3.1: + version "2.3.8" + resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" + integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= + +time-stamp@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/time-stamp/-/time-stamp-1.1.0.tgz#764a5a11af50561921b133f3b44e618687e0f5c3" + integrity sha1-dkpaEa9QVhkhsTPztE5hhofg9cM= + +timers-browserify@^2.0.4: + version "2.0.11" + resolved "https://registry.yarnpkg.com/timers-browserify/-/timers-browserify-2.0.11.tgz#800b1f3eee272e5bc53ee465a04d0e804c31211f" + integrity sha512-60aV6sgJ5YEbzUdn9c8kYGIqOubPoUdqQCul3SBAsRCZ40s6Y5cMcrW4dt3/k/EsbLVJNl9n6Vz3fTc+k2GeKQ== + dependencies: + setimmediate "^1.0.4" + +timers-ext@^0.1.5: + version "0.1.7" + resolved "https://registry.yarnpkg.com/timers-ext/-/timers-ext-0.1.7.tgz#6f57ad8578e07a3fb9f91d9387d65647555e25c6" + integrity sha512-b85NUNzTSdodShTIbky6ZF02e8STtVVfD+fu4aXXShEELpozH+bCpJLYMPZbsABN2wDH7fJpqIoXxJpzbf0NqQ== + dependencies: + es5-ext "~0.10.46" + next-tick "1" + +timsort@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" + integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= + +tiny-emitter@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" + integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== + +tiny-invariant@^1.0.2: + version "1.0.6" + resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.0.6.tgz#b3f9b38835e36a41c843a3b0907a5a7b3755de73" + integrity sha512-FOyLWWVjG+aC0UqG76V53yAWdXfH8bO6FNmyZOuUrzDzK8DI3/JRY25UD7+g49JWM1LXwymsKERB+DzI0dTEQA== + +tiny-lr@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/tiny-lr/-/tiny-lr-1.1.1.tgz#9fa547412f238fedb068ee295af8b682c98b2aab" + integrity sha512-44yhA3tsaRoMOjQQ+5v5mVdqef+kH6Qze9jTpqtVufgYjYt08zyZAwNwwVBj3i1rJMnR52IxOW0LK0vBzgAkuA== + dependencies: + body "^5.1.0" + debug "^3.1.0" + faye-websocket "~0.10.0" + livereload-js "^2.3.0" + object-assign "^4.1.0" + qs "^6.4.0" + +tiny-warning@^1.0.0, tiny-warning@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" + integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== + +tmp@^0.0.33: + version "0.0.33" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" + integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw== + dependencies: + os-tmpdir "~1.0.2" + +tmpl@1.0.x: + version "1.0.4" + resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1" + integrity sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE= + +to-absolute-glob@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/to-absolute-glob/-/to-absolute-glob-2.0.2.tgz#1865f43d9e74b0822db9f145b78cff7d0f7c849b" + integrity sha1-GGX0PZ50sIItufFFt4z/fQ98hJs= + dependencies: + is-absolute "^1.0.0" + is-negated-glob "^1.0.0" + +to-arraybuffer@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43" + integrity sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M= + +to-camel-case@1.0.0, to-camel-case@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/to-camel-case/-/to-camel-case-1.0.0.tgz#1a56054b2f9d696298ce66a60897322b6f423e46" + integrity sha1-GlYFSy+daWKYzmamCJcyK29CPkY= + dependencies: + to-space-case "^1.0.0" + +to-fast-properties@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" + integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4= + +to-no-case@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/to-no-case/-/to-no-case-1.0.2.tgz#c722907164ef6b178132c8e69930212d1b4aa16a" + integrity sha1-xyKQcWTvaxeBMsjmmTAhLRtKoWo= + +to-object-path@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af" + integrity sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68= + dependencies: + kind-of "^3.0.2" + +to-regex-range@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38" + integrity sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg= + dependencies: + is-number "^3.0.0" + repeat-string "^1.6.1" + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +to-regex@^3.0.1, to-regex@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce" + integrity sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw== + dependencies: + define-property "^2.0.2" + extend-shallow "^3.0.2" + regex-not "^1.0.2" + safe-regex "^1.1.0" + +to-space-case@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/to-space-case/-/to-space-case-1.0.0.tgz#b052daafb1b2b29dc770cea0163e5ec0ebc9fc17" + integrity sha1-sFLar7Gysp3HcM6gFj5ewOvJ/Bc= + dependencies: + to-no-case "^1.0.0" + +to-through@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/to-through/-/to-through-2.0.0.tgz#fc92adaba072647bc0b67d6b03664aa195093af6" + integrity sha1-/JKtq6ByZHvAtn1rA2ZKoZUJOvY= + dependencies: + through2 "^2.0.3" + +traverse@~0.6.3: + version "0.6.6" + resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.6.6.tgz#cbdf560fd7b9af632502fed40f918c157ea97137" + integrity sha1-y99WD9e5r2MlAv7UD5GMFX6pcTc= + +trim-newlines@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-2.0.0.tgz#b403d0b91be50c331dfc4b82eeceb22c3de16d20" + integrity sha1-tAPQuRvlDDMd/EuC7s6yLD3hbSA= + +trim-right@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003" + integrity sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM= + +trim-trailing-lines@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/trim-trailing-lines/-/trim-trailing-lines-1.1.2.tgz#d2f1e153161152e9f02fabc670fb40bec2ea2e3a" + integrity sha512-MUjYItdrqqj2zpcHFTkMa9WAv4JHTI6gnRQGPFLrt5L9a6tRMiDnIqYl8JBvu2d2Tc3lWJKQwlGCp0K8AvCM+Q== + +trim@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/trim/-/trim-0.0.1.tgz#5858547f6b290757ee95cccc666fb50084c460dd" + integrity sha1-WFhUf2spB1fulczMZm+1AITEYN0= + +trough@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/trough/-/trough-1.0.4.tgz#3b52b1f13924f460c3fbfd0df69b587dbcbc762e" + integrity sha512-tdzBRDGWcI1OpPVmChbdSKhvSVurznZ8X36AYURAcl+0o2ldlCY2XPzyXNNxwJwwyIU+rIglTCG4kxtNKBQH7Q== + +tryit@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tryit/-/tryit-1.0.3.tgz#393be730a9446fd1ead6da59a014308f36c289cb" + integrity sha1-OTvnMKlEb9Hq1tpZoBQwjzbCics= + +tslib@^1.9.0, tslib@^1.9.3: + version "1.10.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a" + integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ== + +tty-browserify@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6" + integrity sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY= + +type-check@~0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" + integrity sha1-WITKtRLPHTVeP7eE8wgEsrUg23I= + dependencies: + prelude-ls "~1.1.2" + +type@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/type/-/type-1.0.3.tgz#16f5d39f27a2d28d86e48f8981859e9d3296c179" + integrity sha512-51IMtNfVcee8+9GJvj0spSuFcZHe9vSib6Xtgsny1Km9ugyz2mbS08I3rsUIRYgJohFRFU1160sgRodYz378Hg== + +typed-styles@^0.0.7: + version "0.0.7" + resolved "https://registry.yarnpkg.com/typed-styles/-/typed-styles-0.0.7.tgz#93392a008794c4595119ff62dde6809dbc40a3d9" + integrity sha512-pzP0PWoZUhsECYjABgCGQlRGL1n7tOHsgwYv3oIiEpJwGhFTuty/YNeduxQYzXXa3Ge5BdT6sHYIQYpl4uJ+5Q== + +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= + +ua-parser-js@^0.7.18: + version "0.7.20" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.20.tgz#7527178b82f6a62a0f243d1f94fd30e3e3c21098" + integrity sha512-8OaIKfzL5cpx8eCMAhhvTlft8GYF8b2eQr6JkCyVdrgjcytyOmPCXrqXFcUnhonRpLlh5yxEZVohm6mzaowUOw== + +uglify-js@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.6.0.tgz#704681345c53a8b2079fb6cec294b05ead242ff5" + integrity sha512-W+jrUHJr3DXKhrsS7NUVxn3zqMOFn0hL/Ei6v0anCIMoKC93TjcflTagwIHLW7SfMFfiQuktQyFVCFHGUE0+yg== + dependencies: + commander "~2.20.0" + source-map "~0.6.1" + +uglifyjs-webpack-plugin@2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/uglifyjs-webpack-plugin/-/uglifyjs-webpack-plugin-2.2.0.tgz#e75bc80e7f1937f725954c9b4c5a1e967ea9d0d7" + integrity sha512-mHSkufBmBuJ+KHQhv5H0MXijtsoA1lynJt1lXOaotja8/I0pR4L9oGaPIZw+bQBOFittXZg9OC1sXSGO9D9ZYg== + dependencies: + cacache "^12.0.2" + find-cache-dir "^2.1.0" + is-wsl "^1.1.0" + schema-utils "^1.0.0" + serialize-javascript "^1.7.0" + source-map "^0.6.1" + uglify-js "^3.6.0" + webpack-sources "^1.4.0" + worker-farm "^1.7.0" + +unc-path-regex@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa" + integrity sha1-5z3T17DXxe2G+6xrCufYxqadUPo= + +undertaker-registry@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/undertaker-registry/-/undertaker-registry-1.0.1.tgz#5e4bda308e4a8a2ae584f9b9a4359a499825cc50" + integrity sha1-XkvaMI5KiirlhPm5pDWaSZglzFA= + +undertaker@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/undertaker/-/undertaker-1.2.1.tgz#701662ff8ce358715324dfd492a4f036055dfe4b" + integrity sha512-71WxIzDkgYk9ZS+spIB8iZXchFhAdEo2YU8xYqBYJ39DIUIqziK78ftm26eecoIY49X0J2MLhG4hr18Yp6/CMA== + dependencies: + arr-flatten "^1.0.1" + arr-map "^2.0.0" + bach "^1.0.0" + collection-map "^1.0.0" + es6-weak-map "^2.0.1" + last-run "^1.1.0" + object.defaults "^1.0.0" + object.reduce "^1.0.0" + undertaker-registry "^1.0.0" + +unherit@^1.0.4: + version "1.1.2" + resolved "https://registry.yarnpkg.com/unherit/-/unherit-1.1.2.tgz#14f1f397253ee4ec95cec167762e77df83678449" + integrity sha512-W3tMnpaMG7ZY6xe/moK04U9fBhi6wEiCYHUW5Mop/wQHf12+79EQGwxYejNdhEz2mkqkBlGwm7pxmgBKMVUj0w== + dependencies: + inherits "^2.0.1" + xtend "^4.0.1" + +unicode-canonical-property-names-ecmascript@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818" + integrity sha512-jDrNnXWHd4oHiTZnx/ZG7gtUTVp+gCcTTKr8L0HjlwphROEW3+Him+IpvC+xcJEFegapiMZyZe02CyuOnRmbnQ== + +unicode-match-property-ecmascript@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-1.0.4.tgz#8ed2a32569961bce9227d09cd3ffbb8fed5f020c" + integrity sha512-L4Qoh15vTfntsn4P1zqnHulG0LdXgjSO035fEpdtp6YxXhMT51Q6vgM5lYdG/5X3MjS+k/Y9Xw4SFCY9IkR0rg== + dependencies: + unicode-canonical-property-names-ecmascript "^1.0.4" + unicode-property-aliases-ecmascript "^1.0.4" + +unicode-match-property-value-ecmascript@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-1.1.0.tgz#5b4b426e08d13a80365e0d657ac7a6c1ec46a277" + integrity sha512-hDTHvaBk3RmFzvSl0UVrUmC3PuW9wKVnpoUDYH0JDkSIovzw+J5viQmeYHxVSBptubnr7PbH2e0fnpDRQnQl5g== + +unicode-property-aliases-ecmascript@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.0.5.tgz#a9cc6cc7ce63a0a3023fc99e341b94431d405a57" + integrity sha512-L5RAqCfXqAwR3RriF8pM0lU0w4Ryf/GgzONwi6KnL1taJQa7x1TCxdJnILX59WIGOwR57IVxn7Nej0fz1Ny6fw== + +unified@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/unified/-/unified-7.1.0.tgz#5032f1c1ee3364bd09da12e27fdd4a7553c7be13" + integrity sha512-lbk82UOIGuCEsZhPj8rNAkXSDXd6p0QLzIuSsCdxrqnqU56St4eyOB+AlXsVgVeRmetPTYydIuvFfpDIed8mqw== + dependencies: + "@types/unist" "^2.0.0" + "@types/vfile" "^3.0.0" + bail "^1.0.0" + extend "^3.0.0" + is-plain-obj "^1.1.0" + trough "^1.0.0" + vfile "^3.0.0" + x-is-string "^0.1.0" + +union-value@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847" + integrity sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg== + dependencies: + arr-union "^3.1.0" + get-value "^2.0.6" + is-extendable "^0.1.1" + set-value "^2.0.1" + +uniq@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff" + integrity sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8= + +uniqs@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/uniqs/-/uniqs-2.0.0.tgz#ffede4b36b25290696e6e165d4a59edb998e6b02" + integrity sha1-/+3ks2slKQaW5uFl1KWe25mOawI= + +unique-filename@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230" + integrity sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ== + dependencies: + unique-slug "^2.0.0" + +unique-slug@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-2.0.2.tgz#baabce91083fc64e945b0f3ad613e264f7cd4e6c" + integrity sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w== + dependencies: + imurmurhash "^0.1.4" + +unique-stream@^2.0.2: + version "2.3.1" + resolved "https://registry.yarnpkg.com/unique-stream/-/unique-stream-2.3.1.tgz#c65d110e9a4adf9a6c5948b28053d9a8d04cbeac" + integrity sha512-2nY4TnBE70yoxHkDli7DMazpWiP7xMdCYqU2nBRO0UB+ZpEkGsSija7MvmvnZFUeC+mrgiUfcHSr3LmRFIg4+A== + dependencies: + json-stable-stringify-without-jsonify "^1.0.1" + through2-filter "^3.0.0" + +unist-util-find-all-after@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/unist-util-find-all-after/-/unist-util-find-all-after-1.0.4.tgz#2eeaba818fd98492d69c44f9bee52c6a25282eef" + integrity sha512-CaxvMjTd+yF93BKLJvZnEfqdM7fgEACsIpQqz8vIj9CJnUb9VpyymFS3tg6TCtgrF7vfCJBF5jbT2Ox9CBRYRQ== + dependencies: + unist-util-is "^3.0.0" + +unist-util-is@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-3.0.0.tgz#d9e84381c2468e82629e4a5be9d7d05a2dd324cd" + integrity sha512-sVZZX3+kspVNmLWBPAB6r+7D9ZgAFPNWm66f7YNb420RlQSbn+n8rG8dGZSkrER7ZIXGQYNm5pqC3v3HopH24A== + +unist-util-remove-position@^1.0.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/unist-util-remove-position/-/unist-util-remove-position-1.1.3.tgz#d91aa8b89b30cb38bad2924da11072faa64fd972" + integrity sha512-CtszTlOjP2sBGYc2zcKA/CvNdTdEs3ozbiJ63IPBxh8iZg42SCCb8m04f8z2+V1aSk5a7BxbZKEdoDjadmBkWA== + dependencies: + unist-util-visit "^1.1.0" + +unist-util-stringify-position@^1.0.0, unist-util-stringify-position@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-1.1.2.tgz#3f37fcf351279dcbca7480ab5889bb8a832ee1c6" + integrity sha512-pNCVrk64LZv1kElr0N1wPiHEUoXNVFERp+mlTg/s9R5Lwg87f9bM/3sQB99w+N9D/qnM9ar3+AKDBwo/gm/iQQ== + +unist-util-visit-parents@^2.0.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-2.1.2.tgz#25e43e55312166f3348cae6743588781d112c1e9" + integrity sha512-DyN5vD4NE3aSeB+PXYNKxzGsfocxp6asDc2XXE3b0ekO2BaRUpBicbbUygfSvYfUz1IkmjFR1YF7dPklraMZ2g== + dependencies: + unist-util-is "^3.0.0" + +unist-util-visit@^1.1.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-1.4.1.tgz#4724aaa8486e6ee6e26d7ff3c8685960d560b1e3" + integrity sha512-AvGNk7Bb//EmJZyhtRUnNMEpId/AZ5Ph/KUpTI09WHQuDZHKovQ1oEv3mfmKpWKtoMzyMC4GLBm1Zy5k12fjIw== + dependencies: + unist-util-visit-parents "^2.0.0" + +unquote@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/unquote/-/unquote-1.1.1.tgz#8fded7324ec6e88a0ff8b905e7c098cdc086d544" + integrity sha1-j97XMk7G6IoP+LkF58CYzcCG1UQ= + +unset-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559" + integrity sha1-g3aHP30jNRef+x5vw6jtDfyKtVk= + dependencies: + has-value "^0.3.1" + isobject "^3.0.0" + +upath@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/upath/-/upath-1.1.2.tgz#3db658600edaeeccbe6db5e684d67ee8c2acd068" + integrity sha512-kXpym8nmDmlCBr7nKdIx8P2jNBa+pBpIUFRnKJ4dr8htyYGJFokkr2ZvERRtUN+9SY+JqXouNgUPtv6JQva/2Q== + +uri-js@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0" + integrity sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ== + dependencies: + punycode "^2.1.0" + +urix@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" + integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= + +url-loader@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/url-loader/-/url-loader-2.1.0.tgz#bcc1ecabbd197e913eca23f5e0378e24b4412961" + integrity sha512-kVrp/8VfEm5fUt+fl2E0FQyrpmOYgMEkBsv8+UDP1wFhszECq5JyGF33I7cajlVY90zRZ6MyfgKXngLvHYZX8A== + dependencies: + loader-utils "^1.2.3" + mime "^2.4.4" + schema-utils "^2.0.0" + +url@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" + integrity sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE= + dependencies: + punycode "1.3.2" + querystring "0.2.0" + +use@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" + integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== + +util-deprecate@^1.0.1, util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= + +util.promisify@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/util.promisify/-/util.promisify-1.0.0.tgz#440f7165a459c9a16dc145eb8e72f35687097030" + integrity sha512-i+6qA2MPhvoKLuxnJNpXAGhg7HphQOSUq2LKMZD0m15EiskXUkMvKdF4Uui0WYeCUGea+o2cw/ZuwehtfsrNkA== + dependencies: + define-properties "^1.1.2" + object.getownpropertydescriptors "^2.0.3" + +util@0.10.3: + version "0.10.3" + resolved "https://registry.yarnpkg.com/util/-/util-0.10.3.tgz#7afb1afe50805246489e3db7fe0ed379336ac0f9" + integrity sha1-evsa/lCAUkZInj23/g7TeTNqwPk= + dependencies: + inherits "2.0.1" + +util@^0.11.0: + version "0.11.1" + resolved "https://registry.yarnpkg.com/util/-/util-0.11.1.tgz#3236733720ec64bb27f6e26f421aaa2e1b588d61" + integrity sha512-HShAsny+zS2TZfaXxD9tYj4HQGlBezXZMZuM/S5PKLLoZkShZiGk9o5CzukI1LVHZvjdvZ2Sj1aW/Ndn2NB/HQ== + dependencies: + inherits "2.0.3" + +v8-compile-cache@^2.0.3: + version "2.1.0" + resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz#e14de37b31a6d194f5690d67efc4e7f6fc6ab30e" + integrity sha512-usZBT3PW+LOjM25wbqIlZwPeJV+3OSz3M1k1Ws8snlW39dZyYL9lOGC5FgPVHfk0jKmjiDV8Z0mIbVQPiwFs7g== + +v8flags@^3.0.1: + version "3.1.3" + resolved "https://registry.yarnpkg.com/v8flags/-/v8flags-3.1.3.tgz#fc9dc23521ca20c5433f81cc4eb9b3033bb105d8" + integrity sha512-amh9CCg3ZxkzQ48Mhcb8iX7xpAfYJgePHxWMQCBWECpOSqJUXgY26ncA61UTV0BkPqfhcy6mzwCIoP4ygxpW8w== + dependencies: + homedir-polyfill "^1.0.1" + +validate-npm-package-license@^3.0.1: + version "3.0.4" + resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" + integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew== + dependencies: + spdx-correct "^3.0.0" + spdx-expression-parse "^3.0.0" + +value-equal@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-0.4.0.tgz#c5bdd2f54ee093c04839d71ce2e4758a6890abc7" + integrity sha512-x+cYdNnaA3CxvMaTX0INdTCN8m8aF2uY9BvEqmxuYp8bL09cs/kWVQPVGcA35fMktdOsP69IgU7wFj/61dJHEw== + +value-or-function@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/value-or-function/-/value-or-function-3.0.0.tgz#1c243a50b595c1be54a754bfece8563b9ff8d813" + integrity sha1-HCQ6ULWVwb5Up1S/7OhWO5/42BM= + +vendors@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/vendors/-/vendors-1.0.3.tgz#a6467781abd366217c050f8202e7e50cc9eef8c0" + integrity sha512-fOi47nsJP5Wqefa43kyWSg80qF+Q3XA6MUkgi7Hp1HQaKDQW4cQrK2D0P7mmbFtsV1N89am55Yru/nyEwRubcw== + +vfile-location@^2.0.0: + version "2.0.5" + resolved "https://registry.yarnpkg.com/vfile-location/-/vfile-location-2.0.5.tgz#c83eb02f8040228a8d2b3f10e485be3e3433e0a2" + integrity sha512-Pa1ey0OzYBkLPxPZI3d9E+S4BmvfVwNAAXrrqGbwTVXWaX2p9kM1zZ+n35UtVM06shmWKH4RPRN8KI80qE3wNQ== + +vfile-message@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-1.1.1.tgz#5833ae078a1dfa2d96e9647886cd32993ab313e1" + integrity sha512-1WmsopSGhWt5laNir+633LszXvZ+Z/lxveBf6yhGsqnQIhlhzooZae7zV6YVM1Sdkw68dtAW3ow0pOdPANugvA== + dependencies: + unist-util-stringify-position "^1.1.1" + +vfile@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/vfile/-/vfile-3.0.1.tgz#47331d2abe3282424f4a4bb6acd20a44c4121803" + integrity sha512-y7Y3gH9BsUSdD4KzHsuMaCzRjglXN0W2EcMf0gpvu6+SbsGhMje7xDc8AEoeXy6mIwCKMI6BkjMsRjzQbhMEjQ== + dependencies: + is-buffer "^2.0.0" + replace-ext "1.0.0" + unist-util-stringify-position "^1.0.0" + vfile-message "^1.0.0" + +vinyl-bufferstream@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/vinyl-bufferstream/-/vinyl-bufferstream-1.0.1.tgz#0537869f580effa4ca45acb47579e4b9fe63081a" + integrity sha1-BTeGn1gO/6TKRay0dXnkuf5jCBo= + dependencies: + bufferstreams "1.0.1" + +vinyl-file@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/vinyl-file/-/vinyl-file-2.0.0.tgz#a7ebf5ffbefda1b7d18d140fcb07b223efb6751a" + integrity sha1-p+v1/779obfRjRQPyweyI++2dRo= + dependencies: + graceful-fs "^4.1.2" + pify "^2.3.0" + pinkie-promise "^2.0.0" + strip-bom "^2.0.0" + strip-bom-stream "^2.0.0" + vinyl "^1.1.0" + +vinyl-fs@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/vinyl-fs/-/vinyl-fs-3.0.3.tgz#c85849405f67428feabbbd5c5dbdd64f47d31bc7" + integrity sha512-vIu34EkyNyJxmP0jscNzWBSygh7VWhqun6RmqVfXePrOwi9lhvRs//dOaGOTRUQr4tx7/zd26Tk5WeSVZitgng== + dependencies: + fs-mkdirp-stream "^1.0.0" + glob-stream "^6.1.0" + graceful-fs "^4.0.0" + is-valid-glob "^1.0.0" + lazystream "^1.0.0" + lead "^1.0.0" + object.assign "^4.0.4" + pumpify "^1.3.5" + readable-stream "^2.3.3" + remove-bom-buffer "^3.0.0" + remove-bom-stream "^1.2.0" + resolve-options "^1.1.0" + through2 "^2.0.0" + to-through "^2.0.0" + value-or-function "^3.0.0" + vinyl "^2.0.0" + vinyl-sourcemap "^1.1.0" + +vinyl-sourcemap@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/vinyl-sourcemap/-/vinyl-sourcemap-1.1.0.tgz#92a800593a38703a8cdb11d8b300ad4be63b3e16" + integrity sha1-kqgAWTo4cDqM2xHYswCtS+Y7PhY= + dependencies: + append-buffer "^1.0.2" + convert-source-map "^1.5.0" + graceful-fs "^4.1.6" + normalize-path "^2.1.1" + now-and-later "^2.0.0" + remove-bom-buffer "^3.0.0" + vinyl "^2.0.0" + +vinyl-sourcemaps-apply@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/vinyl-sourcemaps-apply/-/vinyl-sourcemaps-apply-0.2.1.tgz#ab6549d61d172c2b1b87be5c508d239c8ef87705" + integrity sha1-q2VJ1h0XLCsbh75cUI0jnI74dwU= + dependencies: + source-map "^0.5.1" + +vinyl@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-1.2.0.tgz#5c88036cf565e5df05558bfc911f8656df218884" + integrity sha1-XIgDbPVl5d8FVYv8kR+GVt8hiIQ= + dependencies: + clone "^1.0.0" + clone-stats "^0.0.1" + replace-ext "0.0.1" + +vinyl@^2.0.0, vinyl@^2.1.0, vinyl@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-2.2.0.tgz#d85b07da96e458d25b2ffe19fece9f2caa13ed86" + integrity sha512-MBH+yP0kC/GQ5GwBqrTPTzEfiiLjta7hTtvQtbxBgTeSXsmKQRQecjibMbxIXzVT3Y9KJK+drOz1/k+vsu8Nkg== + dependencies: + clone "^2.1.1" + clone-buffer "^1.0.0" + clone-stats "^1.0.0" + cloneable-readable "^1.0.0" + remove-trailing-separator "^1.0.1" + replace-ext "^1.0.0" + +vm-browserify@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.0.tgz#bd76d6a23323e2ca8ffa12028dc04559c75f9019" + integrity sha512-iq+S7vZJE60yejDYM0ek6zg308+UZsdtPExWP9VZoCFCz1zkJoXFnAX7aZfd/ZwrkidzdUZL0C/ryW+JwAiIGw== + +walker@~1.0.5: + version "1.0.7" + resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.7.tgz#2f7f9b8fd10d677262b18a884e28d19618e028fb" + integrity sha1-L3+bj9ENZ3JisYqITijRlhjgKPs= + dependencies: + makeerror "1.0.x" + +warning@^4.0.2, warning@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" + integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w== + dependencies: + loose-envify "^1.0.0" + +watch@~0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/watch/-/watch-0.10.0.tgz#77798b2da0f9910d595f1ace5b0c2258521f21dc" + integrity sha1-d3mLLaD5kQ1ZXxrOWwwiWFIfIdw= + +watchpack@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.6.0.tgz#4bc12c2ebe8aa277a71f1d3f14d685c7b446cd00" + integrity sha512-i6dHe3EyLjMmDlU1/bGQpEw25XSjkJULPuAVKCbNRefQVq48yXKUpwg538F7AZTf9kyr57zj++pQFltUa5H7yA== + dependencies: + chokidar "^2.0.2" + graceful-fs "^4.1.2" + neo-async "^2.5.0" + +weak@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/weak/-/weak-1.0.1.tgz#ab99aab30706959aa0200cb8cf545bb9cb33b99e" + integrity sha1-q5mqswcGlZqgIAy4z1RbucszuZ4= + dependencies: + bindings "^1.2.1" + nan "^2.0.5" + +webpack-sources@^1.1.0, webpack-sources@^1.4.0, webpack-sources@^1.4.1: + version "1.4.3" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933" + integrity sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ== + dependencies: + source-list-map "^2.0.0" + source-map "~0.6.1" + +webpack-stream@5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/webpack-stream/-/webpack-stream-5.2.1.tgz#35c992161399fe8cad9c10d4a5c258f022629b39" + integrity sha512-WvyVU0K1/VB1NZ7JfsaemVdG0PXAQUqbjUNW4A58th4pULvKMQxG+y33HXTL02JvD56ko2Cub+E2NyPwrLBT/A== + dependencies: + fancy-log "^1.3.3" + lodash.clone "^4.3.2" + lodash.some "^4.2.2" + memory-fs "^0.4.1" + plugin-error "^1.0.1" + supports-color "^5.5.0" + through "^2.3.8" + vinyl "^2.1.0" + webpack "^4.26.1" + +webpack@4.39.3, webpack@^4.26.1: + version "4.39.3" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.39.3.tgz#a02179d1032156b713b6ec2da7e0df9d037def50" + integrity sha512-BXSI9M211JyCVc3JxHWDpze85CvjC842EvpRsVTc/d15YJGlox7GIDd38kJgWrb3ZluyvIjgenbLDMBQPDcxYQ== + dependencies: + "@webassemblyjs/ast" "1.8.5" + "@webassemblyjs/helper-module-context" "1.8.5" + "@webassemblyjs/wasm-edit" "1.8.5" + "@webassemblyjs/wasm-parser" "1.8.5" + acorn "^6.2.1" + ajv "^6.10.2" + ajv-keywords "^3.4.1" + chrome-trace-event "^1.0.2" + enhanced-resolve "^4.1.0" + eslint-scope "^4.0.3" + json-parse-better-errors "^1.0.2" + loader-runner "^2.4.0" + loader-utils "^1.2.3" + memory-fs "^0.4.1" + micromatch "^3.1.10" + mkdirp "^0.5.1" + neo-async "^2.6.1" + node-libs-browser "^2.2.1" + schema-utils "^1.0.0" + tapable "^1.1.3" + terser-webpack-plugin "^1.4.1" + watchpack "^1.6.0" + webpack-sources "^1.4.1" + +websocket-driver@>=0.5.1: + version "0.7.3" + resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.3.tgz#a2d4e0d4f4f116f1e6297eba58b05d430100e9f9" + integrity sha512-bpxWlvbbB459Mlipc5GBzzZwhoZgGEZLuqPaR0INBGnPAY1vdBX6hPnoFXiw+3yWxDuHyQjO2oXTMyS8A5haFg== + dependencies: + http-parser-js ">=0.4.0 <0.4.11" + safe-buffer ">=5.1.0" + websocket-extensions ">=0.1.1" + +websocket-extensions@>=0.1.1: + version "0.1.3" + resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.3.tgz#5d2ff22977003ec687a4b87073dfbbac146ccf29" + integrity sha512-nqHUnMXmBzT0w570r2JpJxfiSD1IzoI+HGVdd3aZ0yNi3ngvQ4jv1dtHt5VGxfI2yj5yqImPhOK4vmIh2xMbGg== + +whatwg-fetch@>=0.10.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz#fc804e458cc460009b1a2b966bc8817d2578aefb" + integrity sha512-9GSJUgz1D4MfyKU7KRqwOjXCXTqWdFNvEr7eUBYchQiVc744mqK/MzXPNR2WsPkmkOa4ywfg8C2n8h+13Bey1Q== + +which-module@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/which-module/-/which-module-1.0.0.tgz#bba63ca861948994ff307736089e3b96026c2a4f" + integrity sha1-u6Y8qGGUiZT/MHc2CJ47lgJsKk8= + +which-module@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" + integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho= + +which@^1.2.14, which@^1.2.9, which@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" + integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== + dependencies: + isexe "^2.0.0" + +wide-align@^1.1.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" + integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== + dependencies: + string-width "^1.0.2 || 2" + +wordwrap@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus= + +worker-farm@^1.3.1, worker-farm@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.7.0.tgz#26a94c5391bbca926152002f69b84a4bf772e5a8" + integrity sha512-rvw3QTZc8lAxyVrqcSGVm5yP/IJ2UcB3U0graE3LCFoZ0Yn2x4EoVSqJKdB/T5M+FLcRPjz4TDacRf3OCfNUzw== + dependencies: + errno "~0.1.7" + +wrap-ansi@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" + integrity sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU= + dependencies: + string-width "^1.0.1" + strip-ansi "^3.0.1" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= + +write@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/write/-/write-1.0.3.tgz#0800e14523b923a387e415123c865616aae0f5c3" + integrity sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig== + dependencies: + mkdirp "^0.5.1" + +x-is-string@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/x-is-string/-/x-is-string-0.1.0.tgz#474b50865af3a49a9c4657f05acd145458f77d82" + integrity sha1-R0tQhlrzpJqcRlfwWs0UVFj3fYI= + +xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.0, xtend@~4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== + +xxhashjs@^0.2.1: + version "0.2.2" + resolved "https://registry.yarnpkg.com/xxhashjs/-/xxhashjs-0.2.2.tgz#8a6251567621a1c46a5ae204da0249c7f8caa9d8" + integrity sha512-AkTuIuVTET12tpsVIQo+ZU6f/qDmKuRUcjaqR+OIvm+aCBsZ95i7UVY5WJ9TMsSaZ0DA2WxoZ4acu0sPH+OKAw== + dependencies: + cuint "^0.2.2" + +y18n@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" + integrity sha1-bRX7qITAhnnA136I53WegR4H+kE= + +y18n@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" + integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w== + +yallist@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" + integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI= + +yallist@^3.0.0, yallist@^3.0.2, yallist@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.0.3.tgz#b4b049e314be545e3ce802236d6cd22cd91c3de9" + integrity sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A== + +yargs-parser@^10.0.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-10.1.0.tgz#7202265b89f7e9e9f2e5765e0fe735a905edbaa8" + integrity sha512-VCIyR1wJoEBZUqk5PA+oOBF6ypbwh5aNB3I50guxAL/quggdfs4TtNHQrSazFA3fYZ+tEqfs0zIGlv0c/rgjbQ== + dependencies: + camelcase "^4.1.0" + +yargs-parser@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-5.0.0.tgz#275ecf0d7ffe05c77e64e7c86e4cd94bf0e1228a" + integrity sha1-J17PDX/+Bcd+ZOfIbkzZS/DhIoo= + dependencies: + camelcase "^3.0.0" + +yargs-parser@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-7.0.0.tgz#8d0ac42f16ea55debd332caf4c4038b3e3f5dfd9" + integrity sha1-jQrELxbqVd69MyyvTEA4s+P139k= + dependencies: + camelcase "^4.1.0" + +yargs@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-7.1.0.tgz#6ba318eb16961727f5d284f8ea003e8d6154d0c8" + integrity sha1-a6MY6xaWFyf10oT46gA+jWFU0Mg= + dependencies: + camelcase "^3.0.0" + cliui "^3.2.0" + decamelize "^1.1.1" + get-caller-file "^1.0.1" + os-locale "^1.4.0" + read-pkg-up "^1.0.1" + require-directory "^2.1.1" + require-main-filename "^1.0.1" + set-blocking "^2.0.0" + string-width "^1.0.2" + which-module "^1.0.0" + y18n "^3.2.1" + yargs-parser "^5.0.0" + +yargs@^8.0.1: + version "8.0.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-8.0.2.tgz#6299a9055b1cefc969ff7e79c1d918dceb22c360" + integrity sha1-YpmpBVsc78lp/355wdkY3Osiw2A= + dependencies: + camelcase "^4.1.0" + cliui "^3.2.0" + decamelize "^1.1.1" + get-caller-file "^1.0.1" + os-locale "^2.0.0" + read-pkg-up "^2.0.0" + require-directory "^2.1.1" + require-main-filename "^1.0.1" + set-blocking "^2.0.0" + string-width "^2.0.0" + which-module "^2.0.0" + y18n "^3.2.1" + yargs-parser "^7.0.0"